Cinux: 为大内核铺路

Cinux: 为大内核铺路

如果您是想尝试 Cinux,并对一些驱动、前沿细节的实现感兴趣的朋友,请移步到下面的仓库:

https://github.com/Awesome-Embedded-Learning-Studio/Cinux

如果您对手写一个现代 C++ 操作系统感兴趣的朋友,请到这里:

https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book

或者,直接访问文档站开始阅读:https://awesome-embedded-learning-studio.github.io/Cinux-Book/

如果上面的内容,对您的学习和实际的开发哪怕有一丝帮助,都是笔者极大的荣幸!喜欢的话,麻烦小小的赏一个 ⭐(QAQ)。自己的知识仍不精湛,文章必然还有很多错误,还请各位大佬批评斧正!
mini kernel 从 004 诞生起,它的终极使命就一件事:把那个功能完整的 big kernel 从磁盘弄进内存、交权给它。前面几章我们给它配齐了输出、内存、异常------现在,是时候造"读盘"和"解析内核镜像"这两件家伙了。这一章我们写出 ATA PIO 磁盘驱动和 ELF64 加载器,把它们串成一条加载流水线。不过有个诚实的边界:big kernel 本身要到 009 才登场,所以这一章我们用两个 demo 验证这套流水线能干活,真正的加载+跳转留给下一章。

这一章我们要点亮什么

我们要造两样东西,再合成第三样。

一样是 ATA PIO 磁盘驱动:让 mini kernel 能直接对硬盘控制器下"读这几个扇区"的命令,把原始字节读进内存。PIO(Programmed I/O)意味着 CPU 亲自一个字一个字地从数据端口搬数据,不用 DMA、不用中断------最简单直接的法子。

另一样是 ELF64 加载器:内核镜像是标准的 ELF 可执行文件,带头部和若干"可加载段"(PT_LOAD)。加载器要读懂这个头部,把每个段从镜像里的位置搬到它该去的物理地址,多出来的 BSS 部分清零。

第三样是把它们串起来的 big_kernel_loader :先让 ATA 把整份内核镜像读到一个中转缓冲区(staging),再让 ELF 加载器解析它、把段各就各位,最后吐出入口地址。调用者拿到入口地址,理论上 jmp 过去就完成了接力。

但这一章 main 不会真去 jmp------因为 big kernel 还不存在。main 只用两个 demo 验证这套家伙是好的:读主引导扇区(MBR)看它的 0xAA55 签名对不对、读 mini kernel 所在的 LBA 16 试试 ELF 头解析(预期失败,因为 mini kernel 是裸二进制不是 ELF)。看到 MBR 签名 VALID、ELF 解析能正确判出"这不是 ELF",就说明读盘和解析这两条路都通了。

为什么现在需要它

mini kernel 自己其实不需要读盘------它已经被 bootloader 读进内存了。它会读盘,完全是为了伺候 big kernel。整个 004--008 这一段,mini kernel 的角色就是个"二传手":bootloader 把它弄起来,它把自己收拾利索(输出、内存、异常),然后要把 big kernel 也弄起来、交权。这最后一步"弄起来",就要求它能读磁盘、能解析 ELF。

为什么现在写、而不是等到 009 big kernel 出现了再写?因为这套加载流水线本身是个独立、可测的子系统。ATA 驱动的命令编码、ELF 头的字段解析,全是和"具体哪个内核"无关的纯逻辑,正好用 005 搭好的双轨测试狠狠磨一遍(host 单测验编码、QEMU 验真读盘)。等 009 big kernel 真的到来,这套已经验证过的流水线直接 load_big_kernel() 一调用就行,不用在"内核本身还没稳"的时候同时调试加载器。先把工具打牢,再上战场。

外部依据:OSDev 的 ATA PIO Mode 页详细描述了 ATA 控制器的 I/O 端口、状态位(BSY/DRQ/RDY/ERR)、LBA28/LBA48 寻址与 PIO 读扇区的时序(含 400ns 延时约定);ELF 页与 TIS ELF 规范定义了 Elf64_Ehdr/Elf64_Phdr 字段和 PT_LOAD 段语义。

设计图

先看磁盘长什么样。到现在,盘上已经住了三房客:MBR、stage2、mini kernel;big kernel 预订在更后面:

text 复制代码
扇区 0        MBR(512B,末尾 0xAA55)
扇区 1..15    Stage2
扇区 16..847  mini kernel(832 扇区 ≈ 416KB)
扇区 848+     big kernel(预订,009 才入住)

big_kernel_loader 的常量就是照这个布局定的:BIG_KERNEL_LBA = 848BIG_KERNEL_MAX_SECTORS = 512(256KB 上界,够装任何合理的内核)。

再看 ATA 读一个扇区的 PIO 时序,精髓是"下命令 → 轮询状态 → 搬数据":

text 复制代码
read(lba, count, buf):
  选 master + LBA 模式,把 lba 拆字节写进 LBA_LOW/MID/HIGH 寄存器
  写 SECTOR_CNT = count
  写 COMMAND = READ_PIO(0x20)         ← 下命令
  for 每个扇区:
    delay 400ns                        ← ATA 规范要求,读控制口 4 次
    wait_data_ready()                  ← 轮询:BSY 清零 且 DRQ 置位
    for 256 次: buf[word] = inw(DATA)  ← 从 16 位数据端口搬 512 字节

最后是 ELF 加载这条流水线:

text 复制代码
big_kernel → staging@0x1000000(16MB,ATA 读来的原始 ELF)
   └─ load_elf:
        验 ELF magic(7F 45 4C 46)
        遍历 program header:
          PT_LOAD 段 → 从 p_offset 拷 p_filesz 字节到 p_paddr
                       再把 p_memsz - p_filesz 清零(BSS)
        返回 e_entry(入口地址)

代码路线

1. ATA PIO:轮询式读盘

ata.cpp 直接对 ATA 控制器的 I/O 端口下命令。主通道基址 0x1F0,控制口 0x3F6,各寄存器按偏移区分(ata.hpp 里全列了):

cpp 复制代码
constexpr uint16_t ATA_PRIMARY_BASE = 0x1F0;
// 偏移:DATA=0, SECTOR_CNT=2, LBA_LOW=3, LBA_MID=4, LBA_HIGH=5, DRIVE=6, STATUS=7
constexpr uint8_t ATA_STATUS_BSY = 0x80;   // 忙
constexpr uint8_t ATA_STATUS_DRQ = 0x08;   // 数据就绪
constexpr uint8_t ATA_STATUS_RDY = 0x40;   // 驱动器就绪
constexpr uint8_t ATA_CMD_READ_PIO     = 0x20;  // LBA28 读
constexpr uint8_t ATA_CMD_READ_PIO_EXT = 0x24;  // LBA48 读

init 先做软件复位(控制口写 SRST 位)、选 master 盘、轮询等 RDY 置位,还要判一个坑:如果状态读出来是 0xFF,说明总线上根本没接硬盘(悬空总线拉到全 1),这时别再往下走。

read 是核心。它先按 LBA 大小自适应寻址------LBA 小于 28 位用 LBA28(把 lba 拆 4 字节、命令 0x20),否则用 LBA48(高低位各写一遍、命令 0x24)。下完命令,逐扇区轮询:delay_400n 之后 wait_data_readyBSY 清零且 DRQ 置位,然后从 16 位数据端口连续 inw 256 次,正好搬走一个扇区 512 字节。

这里有两个 ATA 特有的讲究。一是 400ns 延时 :ATA 规范要求下命令后、轮询状态前等 400 纳秒,否则可能读到命令还没生效时的旧状态。Cinux 的做法是读 4 次控制口(每次约 100ns),既满足延时、又不碰状态寄存器(读状态寄存器会清掉某些中断位)。二是 inw 读的是 16 位 :ATA 数据端口一次吐 2 字节,所以一个 512 字节扇区是 256 次 inw,不是 512 次 inb------这点写错,数据要么读一半、要么错位。

2. ELF64:解析头部、定位 PT_LOAD 段

elf_loader.hpp 把 ELF64 的标准结构搬进来。文件头 Elf64_Ehdr(64 字节)里有 magic、类型(ET_EXEC)、架构(EM_X86_64)、入口地址 e_entry、程序头表偏移 e_phoff;程序头 Elf64_Phdr(56 字节)描述一个段:类型 p_type、文件偏移 p_offset、目标地址 p_paddr、文件大小 p_filesz、内存大小 p_memsz

parse_elf_header 做最基本的校验:开头四个字节是不是 0x7F 'E' 'L' 'F'(magic)、是不是 64 位(ELF_CLASS_64)、小端、目标架构 x86-64、类型是不是可执行。这几项任一不对就返回 false。这个函数正是 008 两个 demo 之一要调的------拿 mini kernel 的 LBA 16 头几个字节去验,预期失败(它是裸二进制不是 ELF),用来证明"解析器能正确认出非 ELF"。

3. load_elf:拷 filesz、零填 BSS、返回 entry

真正的加载在 load_elf 里。它遍历程序头表,对每个 PT_LOAD 段做两件事:

text 复制代码
从 (镜像起点 + p_offset) 拷 p_filesz 字节 → p_paddr      // 段的实际内容
再从 (p_paddr + p_filesz) 起清零 (p_memsz - p_filesz) 字节  // BSS

为什么要区分 p_fileszp_memsz?因为 ELF 里一个段在文件中只存"有初值"的部分(p_filesz),但它在内存里要占 p_memsz 那么大------多出来的就是 BSS(未初始化全局变量),文件里不存、加载时由加载器清零。load_elf 最后返回 e_entry,也就是内核的入口地址------调用者拿到它,理论上就能跳过去。

这里有个安全细节:load_elfstaging_size 参数,用来校验"段数据 (p_offset + p_filesz) 没超出我们实际从磁盘读进来的字节数"。因为我们读盘是按 BIG_KERNEL_MAX_SECTORS(256KB 上界)读的,真实内核可能更小,不做这个边界检查就可能从缓冲区外读到垃圾。

4. big_kernel_loader:把 ATA + ELF 串成一条流水线

big_kernel_loader.cpp 就是把上面两节拼起来,逻辑非常直:

cpp 复制代码
uint64_t load_big_kernel(uint64_t disk_lba) {
    ata::read(disk_lba, BIG_KERNEL_MAX_SECTORS, (void*)BIG_KERNEL_LOAD_ADDR); // 读到 staging@16MB
    // 验 staging 开头是不是 ELF magic
    if (magic 不是 7F 45 4C 46) return 0;
    return elf_loader::load_elf((void*)BIG_KERNEL_LOAD_ADDR, staging_bytes);   // 解析+加载,返回入口
}

BIG_KERNEL_LOAD_ADDR = 0x1000000(16MB)是 staging 缓冲区------选这么高,是为了避开 mini kernel(在 0x20000)、bootloader 结构(<0x10000)和 PMM 管的可分配区。读盘、验 magic、加载,三步一气呵成,返回 big kernel 的入口地址。

再说一遍那个重要的边界:这个函数写好了、但 008 的 main 没有调用它。因为现在盘上 LBA 848 之后还没有真正的 big kernel,调了也是读到一堆零或垃圾、magic 校验失败。它要等 009 big kernel 真正被编出来、写进磁盘,才会被真正调用、真正完成接力。

5. main 的两个 demo:诚实说明 big kernel 未到

main.cpp 在 GDT/IDT/PMM/int $3 这些(沿用 006/007)之后,做这两步演示。第一步读 MBR:

cpp 复制代码
ata::read(0, 1, g_sector_buf);
uint16_t sig = g_sector_buf[510] | (g_sector_buf[511] << 8);
kprintf("[DEMO] MBR boot signature: 0x%04x %s\n", sig, sig == 0xAA55 ? "(VALID)" : "(INVALID)");

读第 0 扇区、看末两字节是不是 0xAA55------这正是 001 MBR 立的签名。看到 (VALID),说明 ATA 读盘这条路从头到尾通了(命令、轮询、inw、字节序都对)。

第二步读 mini kernel 所在的 LBA 16、试解析 ELF 头:

cpp 复制代码
ata::read(16, 1, g_sector_buf);
if (elf_loader::parse_elf_header(g_sector_buf)) { /* 是 ELF */ }
else { kprintf("No valid ELF header at LBA 16 (expected for flat binary)\n"); }

mini kernel 是 004objcopy -O binary 出来的裸二进制 (flat binary),没有 ELF 头,所以 parse_elf_header 返回 false 是预期的。这条 demo 的意义不是"找到 ELF",而是"证明解析器能正确地拒绝一个非 ELF",以及"再验一次读盘读到的是真实数据"。

main 最后打印 Milestone 008 complete. Waiting for big kernel (009+)... 然后 cli; hlt------一句话把这个 tag 的边界说清楚:家伙都造好了、也验证过了,就等 big kernel 入住。

调试现场

这一章没留 notes,但 ATA 和 ELF 各有几个典型坑。

ATA 头号坑是"悬空总线"。如果 init 后读状态寄存器得到 0xFF,说明这个通道根本没接硬盘(空插槽的总线被上拉到全 1)。继续往下发命令只会一直超时。Cinux 在 init 里专门判这个并报错。在 QEMU 里正常不会遇到,但移植到真机或换端口时会。

第二个是忘 400ns 延时或读错端口。下命令后立刻读状态寄存器,可能读到旧状态,DRQ 还没置位就以为"没数据"直接报错。修复就是那个读 4 次控制口的 delay_400n。判断:如果偶发性地"有时能读、有时超时",八成是时序。

第三个是数据宽度。误用 inb(8 位)读数据端口,一个扇区只读到一半、且奇偶字节错位。ATA 数据端口是 16 位的,必须 inw 读 256 次。

ELF 这边,坑在 magic 和段越界。magic 判错(比如只看前 3 字节漏了 0x7F)会放过非 ELF;段加载没做 p_offset+p_filesz <= staging_size 边界检查,就会从 staging 缓冲区外读到垃圾当段内容拷进去。load_elf 那个 staging_size 参数就是堵这个的。

验证

这一章的测试是整个 mini-kernel 卷里最重的,因为 ATA 命令编码、ELF 头解析全是纯逻辑,特别适合 host 单测。

host 单测(主力):

bash 复制代码
cmake --build build --target test_host

test_ata(五百多行)测命令编码和状态解析------给定 LBA/扇区数,验写进各寄存器的字节对不对、LBA28/48 的选择对不对;test_elf_loader(七百多行)测头解析和段加载------构造各种 ELF 头(合法的、magic 错的、class 错的)、各种 PT_LOAD 段(带 BSS 的、越界的),验加载行为;test_big_kernel_loader 测编排。这些都在 host 上跑,快且能在 CI 里反复磨。

QEMU 内核测试:

bash 复制代码
cmake --build build --target run-kernel-test

test_atatest_elf_loader 在真内核里跑,验它们在真硬件(QEMU 的 ATA 控制器)上行为正确。

量产看 demo:

bash 复制代码
cmake --build build --target run

串口依次出现 [INIT] ATA controller initialized[DEMO] Reading MBR (LBA 0)...MBR boot signature: 0xaa55 (VALID)Reading mini kernel header (LBA 16)...No valid ELF header at LBA 16 (expected for flat binary)、最后 Milestone 008 complete. Waiting for big kernel (009+)...0xAA55 (VALID) 和"expected for flat binary"这两行,就是这套加载流水线能干的铁证。

相关推荐
2501_914245931 小时前
C语言设计模式详解:从理论到实践的完整指南
c语言·开发语言·设计模式
Hazenix1 小时前
Go 指南:一篇文章速通 Golang
开发语言·后端·golang
灯澜忆梦1 小时前
GO_复合类型---指针
开发语言·后端·golang
Ulyanov1 小时前
雷达导引头Python仿真框架:GPU加速、6-DOF模型与半实物仿真接口
开发语言·python·雷达信号处理·雷达导引头
名字还没想好☜3 小时前
Go slice 的 append 陷阱:共享底层数组导致的数据串改
开发语言·后端·golang·go·slice
bitbrowser3 小时前
Claude 账号频繁被封?转向国内可直接使用的 AI 工具
开发语言·php
An_s3 小时前
机器学习python之识别图中物品信息
java·linux·开发语言
小雪_Snow3 小时前
JavaScript 中的三种常用事件
开发语言·前端·javascript
米罗篮3 小时前
矩阵快速幂 (Exponentiation By Squaring Applied To Matrices)
c++·线性代数·算法·矩阵