前三章全是汇编。到这里,CPU 已经跑在 64 位长模式下了,但还是什么都做不了。
这一章要完成最关键的一步:把控制权从汇编交给 C。之后的内核全部用 C 写,汇编只在最必要的地方出现。
磁盘要怎么布局
512 字节的 Bootloader 放不下 C 内核,所以内核单独编译成一个二进制文件,放在磁盘的后续扇区(扇区,Sector,磁盘读写的最小单位,传统上每个 512 字节):
扇区 0(512B):Bootloader
扇区 1~8 :内核二进制
Bootloader 启动后,用 BIOS 的磁盘读取中断把扇区 1 开始的内容读到内存地址 0x10000,然后跳过去执行。
一个容易踩的坑:内核入口不在 0x10000
把内核编译好、加载到 0x10000,然后 call 0x10000——看起来没问题,实际上很可能直接崩。
用 objdump(反汇编工具,可以查看二进制文件的结构和函数地址)一查才发现:kernel_main 的实际地址是 0x10109,不是 0x10000。0x10000 开头是另一个函数 outb,跳过去执行完全是错的。
解决方案:加一个 entry.asm 作为内核最开头,内容只有一行:跳转到 kernel_main。然后在链接脚本(Linker Script,告诉链接器如何拼装各个目标文件、每段放在哪个地址)里把 entry.o 排在最前面,保证 _start 恰好落在 0x10000。
这样 Bootloader 跳到 0x10000,第一条指令就是 jmp kernel_main,稳了。
还有一个坑:栈
进入 64 位模式之后,rsp(栈顶指针寄存器)是未初始化的。C 函数一用栈就崩。
在跳入内核之前,要先设好栈:
mov rsp, 0x90000
选 0x90000 是因为这块内存没有被占用,往下增长也不会覆盖内核代码。
内核编译参数
内核不能用普通方式编译,要加几个关键选项:
| 选项 | 原因 |
|---|---|
-ffreestanding |
没有标准库(libc),不能依赖它 |
-fno-stack-protector |
栈保护需要额外运行时,内核里没有 |
-mno-red-zone |
红区(Red Zone,栈指针下方 128 字节的临时区域,用户态可用)在内核中断处理时会被破坏,必须关闭 |
-fno-pic |
关闭位置无关代码(PIC,Position Independent Code,地址运行时才确定),内核加载地址固定,不需要 |
少一个都可能出莫名其妙的问题。
磁盘镜像也有坑
用 cat boot.bin kernel.bin > myos.img 拼出来的镜像,会导致磁盘读取失败。
原因:BIOS 按扇区(512字节)读盘,cat 直接拼接不保证边界对齐。必须用 dd 来构造镜像:
dd if=/dev/zero of=myos.img bs=512 count=20 # 先创建空镜像
dd if=boot.bin of=myos.img bs=512 conv=notrunc # 写入 Bootloader
dd if=kernel.bin of=myos.img bs=512 seek=1 conv=notrunc # 从第1扇区写内核
seek=1 表示从第 1 扇区开始写,跳过 Bootloader 占的第 0 扇区。
跑起来
make && qemu-system-x86_64 -drive format=raw,file=myos.img -nographic -serial mon:stdio
输出:
Kernel loaded! Welcome to myOS
Now running in 64-bit C kernel.
这行字是从 C 代码里打出来的。启动链路彻底打通了:
BIOS → Bootloader → 保护模式 → 长模式 → kernel_main()
这一章之后
有了 C 内核,后面可以做更多事情了。下一章是中断处理——让内核能响应键盘输入、时钟信号、甚至程序异常,是操作系统最核心的机制之一。