☰
从BIOS中断到屏幕输出:手写操作系统引导扇区实战
2026/10/23 8:11:51 网站建设 项目流程

从零开始编写操作系统,最难熬的不是后面的内存管理、任务调度,而是最开始的“两眼一抹黑”:屏幕全黑,你完全不知道自己的代码到底执行了没有、执行到哪一步、卡死在哪个指令上。这时候,BIOS 中断就是第一根救命稻草。它自带一套完整的底层服务,让你连驱动都不用写,就能在屏幕上打出字符。这篇文章就围绕 BIOS 中断和屏幕显示,讲清楚实模式下怎么把字符和字符串输出到屏幕,顺便把引导扇区、中断调用、模拟器调试这些基本功一起串起来。适合刚开始碰操作系统、对汇编有兴趣但没写过实模式代码的同学,也适合想复习底层机制的老手。

1. 为什么第一步是 BIOS 中断:先搞懂实模式里的那套服务机制

1.1 你面对的是一个“老古董”环境:实模式

开机之后、进入你的代码之前,CPU 跑在实模式下。这个模式跟八十年代初期的处理器几乎一样:物理地址空间只有 1MB,寻址靠“段寄存器左移 4 位 + 偏移量”,没有分页、没有多任务、没有特权级保护,更没有虚拟内存。你的操作系统内核还躺在磁盘里,设备驱动一个都没装载,显卡驱动、键盘驱动、磁盘驱动统统不存在。

听起来很原始,但正因为原始,很多东西反而好理解。你写的第一段引导代码,就是从物理地址0x7C00开始执行的。BIOS 在自检之后,会把启动设备的第一个扇区(512 字节)加载到0x7C00,检查末尾两个字节是不是0x55AA,如果是,就跳过去执行。这就是整个引导过程的起点。

在这个阶段,主板固件(传统 BIOS)其实已经帮你干了很多活。它会初始化内存、显卡、键盘、磁盘控制器,并且把这些设备的标准处理程序安装到内存最前面的中断向量表里。你在引导代码里能直接调用这些“现成的服务”,不需要自己从头写驱动。

1.2 中断不只是“打断”,而是一张服务菜单

很多新手看到“中断”两个字,脑子里浮现的是硬件随时打断你执行流程,所以下意识不敢碰。实际上,你在引导阶段写的INT 10h,更像是在拨打电话叫服务,而不是真的被某种“突发事件”打断。

CPU 里有一张中断向量表,位于物理地址0x00000到0x00400之间,一共 256 项,每项 4 字节,装着一个中断处理程序的入口地址。BIOS 在开机自检时已经把这张表填好了。当你执行INT 10h,CPU 会先压入标志寄存器和返回地址,然后从向量表第0x10项取出显示服务程序的入口,跳过去执行,最后通过IRET指令恢复现场,回到你的代码里继续跑。

INT 10h是视频显示服务的中断号,功能码放在AH寄存器里。比如:

  • AH=0x00设置显示模式
  • AH=0x02设置光标位置
  • AH=0x0E电传方式输出单个字符
  • AH=0x13输出一串字符

这就是一张挂在墙上的服务菜单。你想用哪个功能,就把对应的功能码填进AH,然后把参数填进其他寄存器,再调一次INT 10h就行。

1.3 为什么引导阶段非它不可

你手上只有 512 字节的引导扇区,没有磁盘驱动、没有文件系统、没有中断处理程序,甚至没有自己的显卡初始化代码。想往屏幕上输出字符,其实只有两条路:要么直接操作显卡硬件(端口访问 + 显存写入),要么调用 BIOS 中断。前者要求你把 VGA 文本模式的端口寄存器、显存布局、光标控制全部搞清楚,代码量不小;后者一行INT 10h就能搞定,而且 BIOS 内部会处理大部分细节。

所以在引导阶段,几乎所有人的选择都是先依赖 BIOS 中断来做最原始的调试输出。等后面内核越来越大,需要完全控制硬件的时候,再写自己的驱动,把这些依赖一项项拆掉。换句话说,BIOS 中断不只是“用来显示字符的工具”,它同时是你验证工具链、验证引导流程、验证代码能否真正跑起来的最小可用接口。

2. 屏幕显示的两条路线:BIOS 中断和直接写显存怎么选

2.1 路线一:直接写0xB8000显存

在 VGA 文本模式下,显卡会把显存映射到物理地址0xB8000。这里每个字符占两个字节:低字节是 ASCII 码,高字节是显示属性(前景色、背景色、闪烁等)。比如黑底白字就是属性字节0x07。

你想显示字符,就像操作普通内存一样写这个地址:

mov ax, 0xb800 mov es, ax mov byte [es:0], 'A' ; 第一行第一列显示“A” mov byte [es:1], 0x07 ; 黑底白字

这条路线的好处是速度快、无依赖、可控性强。你完全掌握显存布局,想在哪里显示就写哪里,不会受 BIOS 内部逻辑影响。但坏处也很明显:光标位置要自己维护,换行、滚动要自己写逻辑,屏幕满了还得把几十行文本搬来搬去。对于只有 512 字节的引导扇区来说,这套逻辑写起来挺占空间的。

2.2 路线二:调用INT 10h的显示服务

INT 10h的最大优势,是把光标、滚动、换行这类脏活都封装好了。你用AH=0x0E输出一个字符,它自己会推进光标;到行尾自动换行;滚到屏幕底部自动上卷。你根本不用管显存地址在哪儿,只需要告诉它“输出什么字符”就够了。

代价是什么呢?第一,慢。BIOS 内部要查表、判断模式、读状态、搬显存,一条INT指令背后可能有几十上百条指令在执行。第二,只能在实模式下用。一旦你的内核切到保护模式或者长模式,传统 BIOS 中断基本就不可用了,那时候必须自己写驱动。第三,不同厂商的 BIOS 实现有些细节差异,模拟器上跑得好好的,真机上可能表现不同。

2.3 引导阶段的实际选型:我为什么先用INT 10h

我把两种方案都试过,引导阶段直接用显存方案完全可行,尤其是你只想输出一个“Kernel loaded”之类的一行字,甚至比调中断还干净。但综合来看,对刚开始写 OS 的人,我还是推荐先走INT 10h。

原因很实在:代码量少,不容易错,也不依赖你把显存那套概念吃透。引导扇区代码本身就容易出各种低级问题,如果再叠一层“自己维护光标”的复杂度,排查起来很痛苦。先用中断把链路跑通,确认编译、链接、加载、跳转都没问题,再回头研究显存方案也不迟。我自己的实际做法是:引导阶段只用INT 10h做最原始的调试输出,等内核初始化 VGA 驱动时再彻底切换到显存直写。这样每步只引入一个新的复杂点,出了问题也好定位。

3. 手把手实现第一个屏幕输出:引导扇区代码实战

3.1 工具链准备:NASM 加 QEMU 就够了

写引导扇区代码,我用 NASM 做汇编器,用 QEMU 做模拟器。NASM 的语法是 Intel 风格,对新手比较友好,而且nasm -f bin可以直接生成裸二进制文件,不需要链接器。QEMU 可以直接加载裸镜像,也可以随时按Ctrl+Alt+2切到监视器查看寄存器状态,调试体验很好。

安装方式各家平台不太一样,但基本都是包管理器一条命令的事。这里就不再啰嗦,默认你已经准备好了。

这个阶段的代码不需要任何库,不需要头文件,也不需要链接脚本。纯粹的.asm文件,编译成.bin,走起。

3.2 最小可运行的引导扇区代码:输出一个字符

先来个最小的:屏幕上显示一个字符A。代码量很短,但里面每一行都有讲究。

org 0x7c00 mov ax, 0 mov ds, ax mov es, ax ; 初始化栈,栈顶放在 0x7c00 往下 mov ss, ax mov sp, 0x7c00 ; 用 INT 10h 的 0x0E 功能输出一个字符 'A' mov ah, 0x0e mov al, 'A' int 0x10 halt: cli hlt jmp halt times 510-($-$$) db 0 dw 0xaa55

编译运行:

nasm -f bin boot.asm -o boot.bin qemu-system-i386 -drive format=raw,file=boot.bin

如果一切正常,QEMU 窗口左上角会出现一个白色字符A。看到这个A出现,意味着你的整个工具链、引导扇区格式、BIOS 中断调用全部通了,这一步是整个操作系统开发里最重要的里程碑之一。

3.3 逐行解读:这些代码为什么是这么写

先说org 0x7c00。这是一个关键指令。它告诉汇编器:我们的代码被加载到物理地址0x7C00执行,所有标签(比如halt)的地址都要按这个基址计算。如果漏掉这一行,后面的jmp halt就会跳到一个错误地址,程序大概率直接飞掉。

再看段寄存器。mov ax, 0和mov ds, ax把数据段设为 0,这样后面用DS:SI访问内存时,偏移量就是物理地址。es设 0 是为了INT 10h的0x13功能,虽然这个例子里没用,但我习惯在引导开始就统一清一遍。

栈也要初始化。mov ss, ax和mov sp, 0x7c00把栈顶设在0x7C00,栈向下生长,不会覆盖我们的引导代码。为什么必须设?因为INT 10h内部会使用栈,CPU 执行INT时也要压入返回地址。如果没有栈,中断调用一进来就崩了。这是新手特别容易忽略的点。

mov ah, 0x0e是选功能码,mov al, 'A'是准备字符,int 0x10触发显示服务。字符串末尾最后用times 510-($-$$) db 0填充,让整个文件到 512 字节前 2 字节的位置,最后dw 0xaa55是引导扇区签名。少了签名,BIOS 根本不会认为这是一个可引导扇区。

3.4 从单字符到字符串:循环打印任意文本

单字符输出只是热身,实际调试时你肯定想输出字符串。用INT 10h的0x0E配合循环,可以逐字符打印:

org 0x7c00 start: mov ax, 0 mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 mov si, msg mov ah, 0x0e print_loop: lodsb ; 把 DS:SI 指向的字节加载到 AL,SI 自动加 1 or al, al ; 判断字符是否为 0 jz halt ; 是 0,说明字符串结束 int 0x10 ; 输出 AL 中的字符 jmp print_loop halt: cli hlt jmp halt msg db "Hello, OS World!", 0x0d, 0x0a, 0 times 510-($-$$) db 0 dw 0xaa55

这里约定字符串以0结尾,lodsb每次取一个字符并自动递增 SI,判断到0就停下。注意我在msg末尾放了回车0x0d和换行0x0a,这样输出完会换行,模拟器里看起来更清晰。

如果你想让字符串带颜色,或者一次性输出而不逐字符循环,可以用INT 10h的0x13功能,后面会细说。

4. 从单字符到字符串:封装自己的输出函数与迷你终端

4.1 逐字符循环和一次性输出,怎么选

AH=0x0E适合逐字符处理,因为你要在循环里判断每个字符,控制符可以单独处理,逻辑灵活。但如果你只是想一次性把一行文本打满,或者想带上彩色属性,更合适的是INT 10h的AH=0x13功能。

AH=0x13的参数是这样的:

  • ES:BP指向要输出的字符串
  • CX是字符串长度
  • DH是行号,DL是列号
  • BH是页码,通常填 0
  • BL是显示属性(比如0x07是黑底白字)
  • AL=0或AL=1时,字符串每个字符只占 1 字节;AL=2或AL=3时,每个字符后面带一个属性字节
  • AL的奇偶还决定写完是否移动光标:AL=1和AL=3会移动光标,AL=0和AL=2不会

我觉得初学者用AL=1最省心:写完之后光标自动到字符串末尾,下次继续输出不会覆盖内容。

4.2 封装一个print_string,代码比想象中短

有了0x13,写一个简单的输出函数非常容易。我把它做成一个小型的“引导终端”——设置文本模式、清屏、在指定位置输出字符串:

org 0x7c00 start: mov ax, 0 mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; 设置 80x25 彩色文本模式,顺便清屏 mov ax, 0x0003 int 0x10 ; 在第一行第一列输出 line1 mov dh, 0 mov dl, 0 mov bp, line1 mov cx, line1_len call print_string ; 在第二行第十列输出 line2 mov dh, 1 mov dl, 10 mov bp, line2 mov cx, line2_len call print_string halt: cli hlt jmp halt ; 打印 ES:BP 指向、CX 长度的字符串 ; 使用 INT 10h AH=0x13,AL=01 写后移动光标,BL=0x07 黑底白字 print_string: mov ax, 0x1301 mov bx, 0x0007 ; BH=0 页码,BL=0x07 黑底白字 int 0x10 ret line1 db "Hello, OS World!", 0x0d, 0x0a line1_len equ $ - line1 line2 db "BIOS INT 10h works.", 0x0d, 0x0a line2_len equ $ - line2 times 510-($-$$) db 0 dw 0xaa55

print_string函数只做了一件事:把AX设为0x1301、BX设为0x0007,然后触发INT 10h。行号和列号在调用前通过DH/DL传入,字符串地址和长度通过BP/CX传入。这段代码编译运行后,屏幕上第一行会显示Hello, OS World!,第二行缩进 10 列显示BIOS INT 10h works.。

有一个细节值得注意:ES:BP指向字符串,所以我在start一开始就把ES清零了。如果你在其他地方改了ES,记得在这里重新设置,否则字符串地址会指错。

4.3 调试技巧:QEMU 监视器和 Bochs 断点怎么配合

屏幕上有输出之后,下一步就是学会怎么“看”代码在干什么。QEMU 有一个很实用的功能:运行中按Ctrl+Alt+2切到监视器(monitor),按Ctrl+Alt+1切回窗口。在监视器里输入info registers,可以看到所有寄存器的当前值;输入x /10bx 0x7c00,可以查看内存内容;还可以用si单步执行汇编指令。

如果你需要更精细的调试,比如想在INT 0x10调用前停下来看看参数,可以上 Bochs。Bochs 自带调试器,能直接查看中断向量表、设置指令断点、观察每次中断调用。不过 Bochs 的配置比 QEMU 麻烦一点,需要写一个bochsrc文件。我的建议是:先用 QEMU 跑通流程,实在要追一个莫名其妙的问题再切 Bochs,没必要一上来就在调试器上花太多时间。

5. 新人最常踩的坑:排查指南与防坑清单

5.1 QEMU 黑屏没输出的排查顺序

如果你照着上面的代码敲完,QEMU 窗口一片黑,那大概率不是INT 10h的问题,而是更前面的引导链路出了问题。我踩过几次,总结出这样一个排查顺序:

  1. 先确认编译命令。nasm -f bin有没有写?如果写成了-f elf,生成的是 ELF 文件,不是裸二进制,QEMU 加载进去自然什么都不对。
  2. 确认文件确实是 512 字节。用xxd boot.bin | tail -1看一下最后两字节是不是55 aa。注意小端序,内存里是55 AA,但在十六进制文件里看到的是aa 55。
  3. 确认 QEMU 命令没写错。直接用qemu-system-i386 -drive format=raw,file=boot.bin最稳,避免-fda之类的参数歧义。
  4. 确认org 0x7c00没漏。漏掉它,标签跳转全乱,程序大概率直接跑飞。
  5. 确认栈初始化了。没有栈,INT 10h一进一出,返回地址都存在非法位置,程序直接炸。

这套顺序能解决九成的“屏幕没输出”。

5.2 引导扇区大小和签名的坑

times 510-($-$$) db 0这行代码,新手容易误解。它的作用是:从当前代码结束的位置,往后填充510 - (当前位置 - 文件开头)个字节的0,直到文件总长度正好到 512 字节前 2 字节的位置。如果你的代码超过了 510 字节,NASM 会报错,提示你填充数量是负数。这时候不是去删times指令,而是要想办法精简代码,或者把引导扇区做成多级加载器。

dw 0xaa55也别写反。dw表示一个 16 位数据,x86 采用小端序存储,所以内存里的字节顺序是55 AA。如果你写dw 0x55aa,效果其实一样,因为0x55aa的小端序就是aa 55,这是正的。真正要避免的是混淆db 0x55, 0xaa和dw 0xaa55,前者在内存里是55 AA,后者也是,二者等价,但别写成dw 0xaa55然后又期待内存里是AA 55。

5.3 实模式编程的几个隐性规则

写实模式代码,有几个规则文档里不太会明说,但实际每一条都会坑到人。

第一,不要假设INT调用不会破坏寄存器。BIOS 中断服务程序内部会用哪些寄存器,不同实现不一样。安全起见,你自己的关键数据要优先放变量或者栈里,而不是放在BX、CX这种通用寄存器里。尤其是字符串地址,最好每次调用前重新加载。

第二,段寄存器的状态不要猜。有些 BIOS 会在自检后把DS设成不同的值,所以引导代码开头统一清零段寄存器是最稳妥的。我自己常写的固定动作就是:DS/ES/SS清零,SP设到0x7C00。

第三,能停在实模式就别做多余的事。别在引导代码里尝试切换到保护模式又切回来,纯粹给自己找麻烦。引导阶段的任务是验证链路、加载内核,保持简单。

5.4 常见问题速查

症状大概率原因解决办法
QEMU 窗口全黑、直接退出boot.bin 不是合法引导扇区检查 512 字节和 55AA 签名
有字符但全是乱码段寄存器值不对在代码开头重新清零 DS/ES
字符显示了一部分就卡死字符串没有 0 结尾给msg末尾补0
运行后 QEMU 不断重启栈没初始化导致 INT 返回地址丢失初始化SS:SP
编译报times负值代码超过 510 字节精简代码或改用多级引导
真机上屏幕显示异常不同 BIOS 的INT 10h实现差异用更基础的0x0E功能,或转显存直写

这个表基本覆盖了新手前两周会遇到的绝大部分问题。

写引导扇区这一个月,我的体会是:这一课表面在讲“屏幕显示”,实际上是在练“如何在极端受限的环境里写可靠的代码”。512 字节的上限,实模式 1MB 的地址空间,BIOS 中断那套固定的调用约定,每一条都是约束,也都是让你养成好习惯的契机。等你后面写自己的 VGA 文本模式驱动时,回看这段用INT 10h输出的字符串,就会明白当初为什么要在设备抽象层上花那么多心思。别嫌这一步慢,这是整个操作系统内核从混沌走向有序的必经之路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询