老PC从U盘启动裸机内核,到底行不行?

介绍

在上一篇文章里,我们从引导扇区开始写了一个会打招呼的裸机“内核”,在QEMU里跑得很开心,最后把“拿真机开刀”的几个坑留了下来。这一篇就来填坑,回答一个具体的问题:老PC到底能不能从U盘启动我们自己写的裸机内核?

先把答案放这:能。大约2003年以后、BIOS里有USB启动选项的机器基本都可以。但上一篇那个镜像直接dd进U盘,在不少真机上会翻车——不是BIOS压根不认这个U盘,就是读盘读出一堆垃圾。这篇文章给引导程序做两个小升级,把这两个坑都填平。

U盘启动的原理:BIOS的“模拟大法”

先想一个问题:BIOS又不懂文件系统,也没有什么“USB启动协议”,它是怎么从U盘启动的?答案是模拟(emulation):BIOS用自己内置的USB驱动把U盘伪装成一块普通硬盘(USB-HDD模式)或软盘(USB-FDD模式),然后照常提供INT 13h读盘服务、照常执行“搬运第一个扇区到0x7C00”的老流程。对我们的引导程序来说,U盘“看起来”就是一块硬盘。

听上去很美,但两个坑正是藏在这套模拟里:

  • 坑一:CHS几何参数是BIOS现编的。U盘本来就没有柱面、磁头这些东西,模拟成“硬盘”时的几何参数全靠BIOS即兴发挥,不同BIOS编出来的还不一样。上一篇我们用CHS方式读盘,一旦BIOS编的参数和我们想的对不上,读出来的就是垃圾,屏幕一黑到底。
  • 坑二:不少BIOS认分区表。它们要在U盘的第一个扇区里看到一张像样的MBR分区表,才承认这是“可启动硬盘”。上一篇的镜像里没有分区表,这类BIOS会直接跳过U盘,连机会都不给。

步骤

升级一:改用INT 13h扩展读(LBA)

治CHS的办法很直接:不用CHS。1990年代中期之后的BIOS都提供“INT 13h扩展”,可以用LBA(逻辑块地址)直接说“从第几个扇区开始读几个”,完全绕开几何参数。顺便理清计数方式:内核在磁盘的“第2个扇区”,用CHS的说法是柱面0、磁头0、扇区2(CHS的扇区从1数),换成LBA就是LBA 1(LBA从0数,LBA 0是引导扇区自己)。

用法分两步:先用功能41h探测扩展是否可用,可用的话就用功能42h读。42h的参数不再塞寄存器,而是填一个16字节的“参数包”(Disk Address Packet):

    ; 先问BIOS一句:支持INT 13h扩展吗?
    mov ah, 0x41
    mov bx, 0x55aa
    mov dl, [BOOT_DRIVE]
    int 0x13
    jc use_chs                  ; 不支持,退回老式CHS读
    cmp bx, 0xaa55
    jne use_chs

    ; 支持扩展:用LBA方式读,不用管BIOS现编的几何参数
    mov si, dap
    mov ah, 0x42
    mov dl, [BOOT_DRIVE]
    int 0x13
    jc disk_error

    ...

; ---- 扩展读的参数包(Disk Address Packet)----
dap:
    db 0x10                     ; 包大小:固定16字节
    db 0                        ; 保留
    dw 16                       ; 要读的扇区数
    dw KERNEL_ADDR              ; 目标地址:偏移
    dw 0                        ; 目标地址:段
    dq 1                        ; 起始LBA:内核在LBA 1

注意我们保留了上一篇的CHS读法作为兜底(use_chs分支):万一碰上真·上古BIOS不支持扩展(多见于软盘模拟模式),就退回老办法,两头都照顾到。

升级二:给引导扇区塞一张分区表

MBR的512字节其实是有规定格局的:前446字节归引导代码,接下来64字节是4个分区表项(每个16字节),最后2字节是魔数0xAA55。我们的引导代码离446字节还远,正好把分区表补上,专治坑二:

; ---- 分区表:有些BIOS要看到它才肯把U盘当可启动硬盘 ----
times 446-($-$$) db 0           ; 引导代码区到此为止(446字节)
    ; 分区1:可启动,类型0x0C(FAT32 LBA),LBA 2048起,共2048个扇区
    db 0x80                     ; 0x80 = 可启动标志
    db 0x20, 0x21, 0x00         ; 起始CHS(按255磁头/63扇区折算,仅供老BIOS参考)
    db 0x0c                     ; 分区类型
    db 0x41, 0x01, 0x00         ; 结束CHS(同上)
    dd 2048                     ; 起始LBA
    dd 2048                     ; 扇区数(1MB)
    times 48 db 0               ; 分区2、3、4:不用,清零
dw 0xaa55                       ; 引导扇区魔数

这个分区基本上是“演给BIOS看”的:它从LBA 2048开始,而我们的内核放在LBA 1到16——藏在分区开始之前的空隙里(现代分区工具管这段叫MBR gap,GRUB也把自己的一部分塞在这里,我们算是蹭了个正规位置)。分区里并没有真的FAT文件系统,BIOS也不会去检查;讲究的话,你可以之后真的把它格式化成FAT放点文件,两不耽误。

完整引导程序

把两个升级并进上一篇的boot.asm,就得到了boot_usb.asm。保护模式切换、GDT和打印函数与上一篇完全相同,内核的kernel_entry.asm和kernel.c一个字都不用改:

[org 0x7c00]
[bits 16]

KERNEL_ADDR equ 0x8000          ; 内核会被加载到这个物理地址

    ; BIOS不保证段寄存器的初始值,先自己清零
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7c00

    mov [BOOT_DRIVE], dl        ; BIOS把启动盘号放在DL里,先存起来

    mov si, msg_real
    call print_string

    ; 先问BIOS一句:支持INT 13h扩展吗?
    mov ah, 0x41
    mov bx, 0x55aa
    mov dl, [BOOT_DRIVE]
    int 0x13
    jc use_chs                  ; 不支持,退回老式CHS读
    cmp bx, 0xaa55
    jne use_chs

    ; 支持扩展:用LBA方式读,不用管BIOS给U盘现编的几何参数
    mov si, dap
    mov ah, 0x42
    mov dl, [BOOT_DRIVE]
    int 0x13
    jc disk_error
    jmp load_ok

use_chs:                        ; 兜底:老式CHS读(和上一篇相同)
    mov ah, 0x02
    mov al, 16
    mov ch, 0
    mov dh, 0
    mov cl, 2
    mov dl, [BOOT_DRIVE]
    mov bx, KERNEL_ADDR
    int 0x13
    jc disk_error

load_ok:
    mov si, msg_load
    call print_string

    ; 进入保护模式(和上一篇完全相同)
    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

; ---- 扩展读的参数包(Disk Address Packet)----
dap:
    db 0x10                     ; 包大小:固定16字节
    db 0                        ; 保留
    dw 16                       ; 要读的扇区数
    dw KERNEL_ADDR              ; 目标地址:偏移
    dw 0                        ; 目标地址:段
    dq 1                        ; 起始LBA:内核在LBA 1(LBA从0数,0是引导扇区)

[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:平坦模型(和上一篇完全相同)----
gdt_start:
    dq 0
gdt_code:
    dw 0xffff, 0x0000
    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
DATA_SEG equ gdt_data - gdt_start

BOOT_DRIVE   db 0
msg_real     db "[boot] USB boot, hello!", 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

; ---- 分区表:有些BIOS要看到它才肯把U盘当可启动硬盘 ----
times 446-($-$$) db 0           ; 引导代码区到此为止(446字节)
    ; 分区1:可启动,类型0x0C(FAT32 LBA),LBA 2048起,共2048个扇区
    db 0x80                     ; 0x80 = 可启动标志
    db 0x20, 0x21, 0x00         ; 起始CHS(按255磁头/63扇区折算,仅供老BIOS参考)
    db 0x0c                     ; 分区类型
    db 0x41, 0x01, 0x00         ; 结束CHS(同上)
    dd 2048                     ; 起始LBA
    dd 2048                     ; 扇区数(1MB)
    times 48 db 0               ; 分区2、3、4:不用,清零
dw 0xaa55                       ; 引导扇区魔数

制作镜像,先在QEMU里验证

nasm -f bin boot_usb.asm -o boot_usb.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_usb.bin kernel.bin > usb.img
truncate -s 2M usb.img          # 4096个扇区,正好把分区表里声明的分区装下

做完可以先让fdisk验一眼——它把我们手搓的分区表当成正经分区表认了:

$ fdisk -l usb.img
Disk usb.img: 2 MiB, 2097152 bytes, 4096 sectors
Disklabel type: dos

Device     Boot Start   End Sectors Size Id Type
usb.img1   *     2048  4095    2048   1M  c W95 FAT32 (LBA)

再进QEMU跑一圈,内核照常点亮(QEMU的SeaBIOS支持INT 13h扩展,走的正是新的LBA路径):

qemu-system-i386 -drive format=raw,file=usb.img

写进U盘,真机走起

老规矩:/dev/sdX一定要用lsblk确认成你U盘的设备名,写错盘神仙也救不回来。

lsblk
sudo dd if=usb.img of=/dev/sdX bs=1M conv=fsync

然后按这张清单伺候真机:

  1. 开机狂按F2/F12/Del进BIOS设置(不同厂商按键不同,开机画面上一般有提示);
  2. 有Legacy/UEFI选项的话选Legacy(真·老机器没这个选项,天生Legacy);
  3. 启动顺序里把USB-HDD排到第一;有的BIOS会把U盘直接列在硬盘列表里,那就在硬盘顺序里把它提到最前;
  4. 保存重启。顺利的话,屏幕上会出现那两行熟悉的问候;
  5. 不顺利的话:USB-HDD不行就换USB-FDD、USB-ZIP模式挨个试;还不行就换个U盘(越小越老的U盘反而越容易被老BIOS认出来)。

最后的兜底方案:如果机器实在太老(大约2003年以前),BIOS压根没有USB启动这一项,可以用PLoP Boot Manager曲线救国——把它刻成光盘或写进软盘,从光盘/软盘启动后,它用自带的USB驱动接管,再把控制权交给U盘。老机器多半有光驱,这条路基本都走得通。

总结

回到标题的问题:老PC从U盘启动裸机内核,到底行不行?行。BIOS支持USB启动的机器(大约2003年以后),配上这篇的两个升级——LBA读盘防几何参数翻车、分区表防BIOS不认盘——就是一个比较靠谱的组合;更老的机器还有PLoP兜底。本文的代码小编在QEMU 6.2里完整验证过(fdisk的输出也是真的),真机部分是排坑清单,每台老机器的BIOS都有自己的脾气——如果你手头正好有一台吃灰的老伙计,欢迎在评论区报告战况。

下一篇要是有机会,就该让这个内核学会响应键盘中断了——毕竟一直自言自语也不是个事儿。

Leave a Reply

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