给吃灰的老x86 PC写一个裸机内核
介绍
家里角落里是不是也有一台吃灰的老电脑?装Windows跑不动,装Linux都嫌它配置寒酸。小编最近给它找了份新工作:不装任何操作系统,直接跑我自己写的“内核”。
本文从按下电源键的那一刻讲起:BIOS怎么把控制权交给我们的代码、怎么写一个引导扇区、怎么从16位实模式切换到32位保护模式、最后用C写一个能在屏幕上打印的迷你内核。全部代码加起来不到两百行,先在QEMU里跑通,再用U盘拿真机开刀。
先说明一下:这里的“内核”是带引号的——它不会调度进程也不会管理内存,只会在屏幕上打招呼。但麻雀虽小,从上电到你自己的C代码接管整台机器,这条链路它可是完整走了一遍。
从按下电源键说起
老PC用的是传统BIOS启动(Legacy Boot),这套流程几十年没变过:
- 上电后CPU从BIOS的固定地址开始执行,BIOS完成自检(POST);
- BIOS按启动顺序找到第一个可启动设备,把它的第1个扇区(512字节)原封不动搬到内存0x7C00处;
- 检查这512字节的最后两个字节是不是魔数0xAA55,是的话就跳过去执行——从这一刻起,机器就归你了。
所以“写内核”的第一步,其实是写好这512字节的引导扇区。也正因为这套流程简单直接,老电脑反而是最好的裸机实验台:自带BIOS中断可以用,自带VGA文本模式可以打印,连显卡驱动都省了。(现代UEFI机器是另一套玩法,本文不涉及。)
准备工具
- 一台能Legacy/BIOS启动的老x86 PC(差不多2012年以前的机器都可以);
- 一个可以牺牲的U盘(会被整个抹掉,别用装着东西的!);
- 一台Linux开发机,装好汇编器、编译器和模拟器。
sudo apt install nasm gcc-multilib qemu-system-x86
QEMU用来在动真机之前把代码调通——毕竟裸机上没有调试器,只有黑屏和不黑屏两种状态。本文的代码小编都在QEMU 6.2上实际跑过,文中的输出和截图都是真实运行结果。
步骤
第一步:先写个会打招呼的引导扇区
先来体验一下“机器归你了”的感觉。新建hello.asm:
[org 0x7c00] ; BIOS会把我们加载到这个地址
[bits 16] ; 上电时CPU处于16位实模式
xor ax, ax ; BIOS不保证DS的值,自己清零
mov ds, ax
mov si, msg
mov ah, 0x0e ; INT 10h功能0Eh:TTY方式打印字符
.loop:
lodsb ; 从[DS:SI]读一个字符到AL,SI自增
or al, al
jz .halt ; 遇到0结束
int 0x10
jmp .loop
.halt:
jmp $ ; 原地打转,我们的第一个“死循环内核”
msg db "Hello, bare metal world!", 0
times 510-($-$$) db 0 ; 填充到510字节
dw 0xaa55 ; 最后2字节是引导扇区魔数
编译成纯二进制,直接扔给QEMU:
nasm -f bin hello.asm -o hello.bin qemu-system-i386 -drive format=raw,file=hello.bin
QEMU窗口里,SeaBIOS的启动信息后面会跟着一行我们的问候:
Booting from Hard Disk... Hello, bare metal world!
就这么二十来行汇编,没有操作系统、没有libc,屏幕上就有字了——打印的脏活是BIOS的INT 10h中断帮我们干的。
第二步:完整的引导程序——读盘、进保护模式
打招呼只是开胃菜。一个真正的引导程序还要干两件事:把内核从磁盘读进内存(BIOS只帮你搬第一个512字节),以及把CPU从16位实模式切到32位保护模式(不然C编译器生成的32位代码没法跑)。完整的boot.asm如下:
[org 0x7c00]
[bits 16]
KERNEL_ADDR equ 0x8000 ; 内核会被加载到这个物理地址
; BIOS不保证段寄存器的初始值,先自己清零
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00 ; 栈从0x7C00往下长,不会踩到我们自己
mov [BOOT_DRIVE], dl ; BIOS把启动盘号放在DL里,先存起来
mov si, msg_real
call print_string
; 用BIOS中断把内核从磁盘读到内存
mov ah, 0x02 ; INT 13h功能2:读扇区(CHS方式)
mov al, 16 ; 读16个扇区(8KB,对我们的小内核绰绰有余)
mov ch, 0 ; 柱面0
mov dh, 0 ; 磁头0
mov cl, 2 ; 从第2个扇区开始(第1个是引导扇区自己)
mov dl, [BOOT_DRIVE]
mov bx, KERNEL_ADDR ; 读到 es:bx = 0x0000:0x8000
int 0x13
jc disk_error ; CF=1表示读盘失败
mov si, msg_load
call print_string
; 进入保护模式:关中断 -> 加载GDT -> 置位CR0.PE -> 远跳刷新流水线
cli
lgdt [gdt_descriptor]
mov eax, cr0
or eax, 1
mov cr0, eax
jmp CODE_SEG:protected_mode_start
disk_error:
mov si, msg_disk_err
call print_string
jmp $
; 实模式下的打印函数:SI = 0结尾的字符串
print_string:
xor bx, bx
.loop:
lodsb
or al, al
jz .done
mov ah, 0x0e
int 0x10
jmp .loop
.done:
ret
[bits 32]
protected_mode_start:
mov ax, DATA_SEG ; 重新装载各数据段寄存器
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000 ; 给内核一个像样的栈
call KERNEL_ADDR ; 跳进内核!
jmp $
; ---- GDT:平坦模型,代码段和数据段都是0到4GB ----
gdt_start:
dq 0 ; 第0项必须为空
gdt_code:
dw 0xffff, 0x0000 ; limit 0-15, base 0-15
db 0x00, 10011010b, 11001111b, 0x00
gdt_data:
dw 0xffff, 0x0000
db 0x00, 10010010b, 11001111b, 0x00
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
CODE_SEG equ gdt_code - gdt_start ; = 0x08
DATA_SEG equ gdt_data - gdt_start ; = 0x10
BOOT_DRIVE db 0
msg_real db "[boot] Hello from real mode!", 13, 10, 0
msg_load db "[boot] Kernel loaded, entering protected mode...", 13, 10, 0
msg_disk_err db "[boot] Disk read error :(", 13, 10, 0
times 510-($-$$) db 0 ; 填充到510字节
dw 0xaa55 ; 引导扇区魔数,少了它BIOS不认
几个值得说明的地方:
- BIOS把启动盘号放在DL里传给我们,读盘时要原样传回给INT 13h;
- INT 13h功能2用的是最古老的CHS寻址,读磁盘开头几个扇区没问题;个别BIOS对U盘的几何参数处理得比较奇怪,读不出来可以改用扩展读(功能42h),这个留给感兴趣的读者;
- 进保护模式的“三件套”:cli关中断、lgdt装载GDT、置位CR0的PE位,最后用一个远跳转刷新流水线并装载新的代码段;
- GDT用的是最省心的平坦模型:代码段和数据段都是0到4GB;
- 切到保护模式之后,BIOS中断就再也用不了了——想打印?下一节直接写显存。
第三步:真正的“内核”——C语言登场
在继续之前,先理清一个容易迷糊的点:C代码是什么时候、在哪里被编译的?答案是——在你的开发机上、制作镜像的时候。GCC把kernel.c提前编译成32位x86机器码,链接成不带任何格式头的纯机器码文件kernel.bin,再拼进磁盘镜像。老电脑上从头到尾都没有编译器,更没有什么“运行C程序的环境”——引导程序只是把第2扇区开始的那串字节搬到0x8000,然后一个call跳过去。对CPU来说那就是一串普通指令,它根本不知道、也不关心这些指令是C编译出来的还是手写的汇编。
整条流水线画出来是这样:
编译期(开发机上):
boot.asm --nasm--> boot.bin
kernel_entry.asm --nasm--> kernel_entry.o ─┐
kernel.c --gcc---> kernel.o ─┴--ld--> kernel.bin
boot.bin + kernel.bin --cat--> os.img
运行期(老PC上,没编译器什么事):
BIOS --> boot.bin(在0x7C00)--INT 13h读盘--> kernel.bin(在0x8000)
--call 0x8000--> kernel_main()
知道了这一点,内核的结构就好理解了:一个汇编入口加一个C文件。为什么还要一个汇编入口?因为C编译器并不保证kernel_main会被放在二进制文件的哪个位置,而引导程序是“盲跳”到0x8000的——所以我们用链接顺序保证kernel_entry.asm排在最前面,让0x8000处的第一条指令正好是call kernel_main。顺便,C函数返回之后总得有人接盘,入口里的hlt死循环就是干这个的:
[bits 32]
extern kernel_main
global _start
section .text
_start:
call kernel_main
.hang:
hlt ; kernel_main返回后就地休眠
jmp .hang
接下来终于可以告别汇编了。保护模式下没有BIOS帮忙,打印要直接往VGA文本显存0xB8000写:每个字符占2字节,低字节是ASCII码,高字节是颜色(0x0F就是黑底白字)。kernel.c:
#define VGA_MEM ((volatile unsigned short *)0xb8000)
#define VGA_COLS 80
#define VGA_ROWS 25
#define COLOR 0x0f /* 黑底白字 */
static void clear_screen(void)
{
for (int i = 0; i < VGA_COLS * VGA_ROWS; i++)
VGA_MEM[i] = (COLOR << 8) | ' ';
}
static void print_at(const char *s, int row, int col)
{
volatile unsigned short *p = VGA_MEM + row * VGA_COLS + col;
while (*s)
*p++ = (COLOR << 8) | *s++;
}
void kernel_main(void)
{
clear_screen();
print_at("Hello from my kernel!", 10, 29);
print_at("(32-bit protected mode, no OS, all mine)", 12, 20);
}
编译参数有讲究:-ffreestanding告诉GCC这是不依赖任何操作系统和标准库的独立环境,-fno-pie和-fno-stack-protector关掉需要运行时支持的特性,-m32生成32位代码。
第四步:链接、拼盘、开机!
nasm -f bin boot.asm -o boot.bin nasm -f elf32 kernel_entry.asm -o kernel_entry.o gcc -m32 -ffreestanding -fno-pie -fno-stack-protector -O2 -c kernel.c -o kernel.o ld -m elf_i386 -Ttext 0x8000 --oformat binary -o kernel.bin kernel_entry.o kernel.o cat boot.bin kernel.bin > os.img truncate -s 8704 os.img qemu-system-i386 -drive format=raw,file=os.img
解释一下最后几步:ld的-Ttext 0x8000让代码认为自己位于0x8000(和引导程序读入的位置一致),–oformat binary输出不带任何格式头的纯机器码;cat把引导扇区和内核按顺序拼成磁盘镜像——引导扇区占第1扇区,内核从第2扇区开始,正好和boot.asm里读盘的参数对上;truncate把镜像补齐到17个扇区(8704字节),免得BIOS读到镜像外面去报错。
小编这套代码编译出来,boot.bin正好512字节,kernel.bin是4216字节。QEMU里跑起来是这个样子:

第五步:拿真机开刀
QEMU里跑通了,就可以伺候真机了。把镜像用dd写进U盘。注意!/dev/sdX一定要换成你U盘的实际设备名(用lsblk确认),写错盘神仙也救不回来:
sudo dd if=os.img of=/dev/sdX bs=512 conv=fsync
然后插到老电脑上,开机按F2/F12/Del进BIOS,启动模式选Legacy(如果有这个选项的话),启动顺序里选USB-HDD,保存重启。顺利的话,你会在真实的屏幕上看到那两行熟悉的问候——那一瞬间还是挺感动的。
真机上常见的几个坑:
- 有些BIOS对U盘启动模式很挑剔,USB-HDD不行就换USB-FDD、USB-ZIP挨个试试;
- 个别BIOS要求U盘上有分区表才肯认它是可启动设备,遇到了可以给镜像补一个MBR分区表再写入;
- 如果卡在“Disk read error”,多半是BIOS给U盘模拟的CHS几何参数和我们读盘用的对不上,换成INT 13h扩展读(AH=42h,按LBA寻址)基本都能解决。
总结
从0x7C00处的第一条指令,到kernel_main里的第一行C代码,加起来不到两百行,一台“电子垃圾”就变成了完全听你指挥的裸机。当然,现在这个“内核”只会打招呼——接下来能玩的还多着呢:设置IDT响应键盘中断、自己写个printf、管理内存、甚至做任务切换……每一步都是一个大坑,osdev.org上有挖不完的资料。
老电脑不是电子垃圾,是最好的裸机实验台。祝玩得开心!