老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
然后按这张清单伺候真机:
- 开机狂按F2/F12/Del进BIOS设置(不同厂商按键不同,开机画面上一般有提示);
- 有Legacy/UEFI选项的话选Legacy(真·老机器没这个选项,天生Legacy);
- 启动顺序里把USB-HDD排到第一;有的BIOS会把U盘直接列在硬盘列表里,那就在硬盘顺序里把它提到最前;
- 保存重启。顺利的话,屏幕上会出现那两行熟悉的问候;
- 不顺利的话: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都有自己的脾气——如果你手头正好有一台吃灰的老伙计,欢迎在评论区报告战况。
下一篇要是有机会,就该让这个内核学会响应键盘中断了——毕竟一直自言自语也不是个事儿。