给吃灰的老x86 PC写一个裸机内核

介绍

家里角落里是不是也有一台吃灰的老电脑?装Windows跑不动,装Linux都嫌它配置寒酸。小编最近给它找了份新工作:不装任何操作系统,直接跑我自己写的“内核”。

本文从按下电源键的那一刻讲起:BIOS怎么把控制权交给我们的代码、怎么写一个引导扇区、怎么从16位实模式切换到32位保护模式、最后用C写一个能在屏幕上打印的迷你内核。全部代码加起来不到两百行,先在QEMU里跑通,再用U盘拿真机开刀。

先说明一下:这里的“内核”是带引号的——它不会调度进程也不会管理内存,只会在屏幕上打招呼。但麻雀虽小,从上电到你自己的C代码接管整台机器,这条链路它可是完整走了一遍。

从按下电源键说起

老PC用的是传统BIOS启动(Legacy Boot),这套流程几十年没变过:

  1. 上电后CPU从BIOS的固定地址开始执行,BIOS完成自检(POST);
  2. BIOS按启动顺序找到第一个可启动设备,把它的第1个扇区(512字节)原封不动搬到内存0x7C00处;
  3. 检查这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中运行
我们的“内核”在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上有挖不完的资料。

老电脑不是电子垃圾,是最好的裸机实验台。祝玩得开心!

Leave a Reply

Your email address will not be published. Required fields are marked *