| 我的专栏 | 直达 |
|---|---|
| 《机器人学导论》专栏 | 点击进入 |
| 《自己写一个内核》专栏 | 点击进入 |
自己写一个内核(一):最小引导扇区
按下电源。
CPU 醒过来,干的第一件事不是加载操作系统——它先去跑 BIOS。BIOS 是焊在主板 ROM 里的程序,出厂写死,掉电不丢。它把硬件点一遍,然后把磁盘最开头的 512 字节搬进内存0x7c00,跳过去。
跳过去之后,跑的就是自己的代码了。
从这一秒起,这 512 字节就是全部地盘。这一篇要做的,就是在这块地盘上,让屏幕出现一个A。
后面还长:512 字节装不下东西,所以要读更多扇区;16 位模式不够用,所以要切保护模式;汇编写着太累,所以要换成 C。再往后还有分页、多任务。这条路从今天这 8 行汇编开始。
开工前:需要两样东西。
Linux 一行搞定:
sudo apt install nasm qemu-system-x86(Fedora 用dnf)。Windows 去 nasm.us 和 qemu.org 下载安装包,装完之后把安装目录加进 PATH 就行。另外 Windows 上没有
make、rm这些命令,后面 Makefile 那一段跳过也可以,直接手敲那两条命令,效果一样。验证:
nasm -v和qemu-system-i386 --version,能打出版本号就成。
一、先看结果:屏幕上出现一个 A
代码就这几行:
[bits 16] [org 0x7c00] start: mov ah, 0x0e mov al, 'A' int 0x10 jmp $ times 510 - ($ - $$) db 0 dw 0xaa55存成boot.asm,敲两条命令:
nasm-fbin boot.asm-oos.img qemu-system-i386-driveformat=raw,file=os.img屏幕上出现一个A。
原理下面会一行一行讲,包括times和dw那两行。先把命令敲下去看一眼,再回来读,也来得及。
顺手把命令存起来(可以跳过)
不想每次手敲的话,可以建一个叫Makefile的文件:
AS = nasm QEMU = qemu-system-i386 all: os.img os.img: boot.asm $(AS) -f bin boot.asm -o os.img run: os.img $(QEMU) -drive format=raw,file=os.img clean: rm -f os.img之后make就编译,make run就运行,make clean就清场。
规则只有一条:冒号前面是名字,冒号后面是它依赖的东西,命令写在下一行、用 Tab 开头。run: os.img的意思是"要跑run,得先有os.img"。
(rm那一行是 Linux 写法,Windows 下要用 Git Bash 或者 MSYS2 才能跑。)
二、这 8 行,一行一行看
[bits 16]:告诉汇编器,现在只有 16 位
CPU 刚上电是实模式,这个模式只认 16 位指令。这行是写给 nasm 看的,它不产生任何机器码,只是在说"接下来按 16 位编译"。
[org 0x7c00]:告诉汇编器,代码住在哪
先说0x是什么。
平时数数用十进制,逢十进一。电脑用十六进制,逢十六进一——数字只有 0 到 9 十个,不够十六个用,就拿字母顶上:A=10、B=11、C=12、D=13、E=14、F=15。
0x是记号,表示"后面这串是十六进制"。
换算看每个位的权重。从右往左,第一位是 1,第二位是 16,第三位是 256,第四位是 4096——每次乘 16:
| 十六进制 | 怎么算 | 十进制 |
|---|---|---|
0x10 | 1×16 | 16 |
0x41 | 4×16 + 1 | 65(字符 A) |
0x100 | 1×256 | 256 |
0x400 | 4×256 | 1024(1K) |
0x7c00 | 7×4096 + 12×256 | 31744 |
0x7c00就是 BIOS 放代码的位置,这个地址是固定的。
而[org 0x7c00]的含义是:告诉 nasm"这段代码会被放在0x7c00",后面所有跟地址有关的计算都从这个基点算起。
这一行在代码里没出现地址的时候,其实看不出效果。这一篇还没有地址,所以就算写错也不会有反应——它要等到下一篇读磁盘,才会真正开始起作用。
mov ah, 0x0e和mov al, 'A':往格子里放数
指令干活得有地方放东西。CPU 里的格子叫寄存器,这一篇只用到两个:AH和AL。
它俩是AX的两半——AX是 16 位,可以拆成两个 8 位用,高的一半叫AH(H = High),低的一半叫AL(L = Low)。
这套名字不是乱码,是 Intel 定的规则:同一个家族,加宽就换前缀,拆半就加后缀。
| 家族 | 8位 | 16位 | 32位 | 64位 | 来历 |
|---|---|---|---|---|---|
| A | AH/AL | AX | EAX | RAX | 累加器 |
| B | BH/BL | BX | EBX | RBX | 基址 |
| C | CH/CL | CX | ECX | RCX | 计数 |
| D | DH/DL | DX | EDX | RDX | 数据 |
四个宽度、四个家族,用"加宽换前缀、拆半加后缀"这一条基本就能对上号。(SI、DI、SP、BP 这几个是另一类,没有 8 位的一半,只有 16/32/64 三种宽度,用到的时候再说。)
mov就是 move,放数:
mov ah, 0x0e:把 14 放进AH。mov al, 'A':把字符 A 放进AL。
但 CPU 不认"A"这个形状,只认数字。美国人给字母排过号,叫 ASCII,大写A是 65。所以'A'这个写法,nasm 编译的时候会把它换成 65——写'A'、写0x41、写65,编出来是同一个东西。
回头看前面那张表:0x41 = 4×16 + 1 = 65。对上了。
int 0x10:喊 BIOS 干活
int是 interrupt,中断。执行到它,CPU 会跳去 BIOS 的 10 号中断服务程序——一段现成的代码,BIOS 里早就写好了。
这段代码干什么,是看约定的:
- 先看
AH里是几。0x0e表示"在光标处打印一个字符"。 - 再看
AL里是几。里面是0x41,也就是字符 A。 - 于是把 A 写到屏幕上,然后跳回来,继续跑后面的代码。
所以前面那两条mov是在按 BIOS 的约定摆参数,int 0x10才是真正下命令。这个约定叫调用约定,后面每一步都要跟它打交道。
jmp $:原地打转
jmp是 jump,跳转。$表示"这一行自己的地址",jmp $就是跳到自己头上,原地打转,永远停在这。
这句看着多余,其实省不得。少了它,CPU 执行完int 0x10会继续往下走,一路走进后面的数据区、走出这 512 字节,跑进一片不知道是什么的内存里,行为完全不可预料。
三、为什么必须正好 512 字节
代码到jmp $就完了。数一下刚才那四条指令编出来多少字节:
| 汇编 | 机器码 | 字节数 |
|---|---|---|
mov ah, 0x0e | B4 0E | 2 |
mov al, 'A' | B0 41 | 2 |
int 0x10 | CD 10 | 2 |
jmp $ | EB FE | 2 |
一共 8 个字节。
汇编是人看的写法,机器码是 CPU 读的东西。nasm 干的活就是查表翻译:每条汇编换成固定的一串字节,一条对一条,没有别的花样。规律很直白——编号加数据:
B4是"往 AH 放数"的编号,0E是放进去的数B0是"往 AL 放数"的编号,41是那个 ACD是"中断"的编号,10是喊 10 号EB是"跳转"的编号,FE表示往回跳
8 字节,离 512 还差 504 个字节。而这 504 个字节不能空着——因为 BIOS 认不认这个扇区,是看末尾两个字节的。
最后两行就是干这个的:
times 510 - ($ - $$) db 0 dw 0xaa55拆开看:
$$是这一段代码的起始地址(这里就是0x7c00)$是当前地址(写到哪算到哪)
所以$ - $$就是"已经写了多少字节"。510 - ($ - $$)就是"还差多少能凑到 510"。而times N db 0的意思是"把db 0重复 N 遍",也就是填 N 个零。
合起来:一直填零,填到第 510 个字节为止。
然后dw 0xaa55写下去两个字节,正好把第 510、511 两格占满,凑出完整的 512。
0xaa55这两个字节在磁盘上按小端存放,实际写进去的顺序是55 AA。BIOS 读磁盘第一个扇区,只看最后两字节是不是55 AA:
- 是→ 把这 512 字节搬到
0x7c00,跳进去执行 - 不是→ 当这里没有可启动的东西,跳过
这样两行的分工就清楚了:
times ...:撑满 512 字节,少一个字节扇区就不完整dw 0xaa55:贴合格证,没有它 BIOS 根本不理
这就是"引导扇区"这四个字的全部含义:正好 512 字节,末尾必须是 55 AA,放在磁盘最开头。
四、三处容易出岔子的地方
一、少了jmp $。CPU 会继续往下冲进数据区,结果不可预料。这是最隐蔽的一处——它不会报错,是直接跑飞。
二、少了times或者dw 0xaa55。少任何一个,BIOS 都不认。表现不是黑屏,而是明确提示找不到可启动设备。看到这句提示,回到这一节查就行。
三、[org]写错。这一篇还看不出来,下一篇一开始读磁盘,所有地址会整体错位。先留个印象就好。
五、万一没出现 A
这种情况先不急着改代码。分清是文件不对还是代码不对——这是两件事,分开查快得多。
先看文件
stat-c%s os.img应该输出512。不是 512,问题就在times或dw那两行。
再看末尾两个字节:
od-Ax-tx1z os.img|tail-20001f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa >..............U.< 000200第一列是地址:0x1f0是第 496 字节,往后数 14 个零正好到第 510 字节——然后55 aa。只要最后两字节不是55 aa,BIOS 就不会理。
(顺便,hexdump在一些精简的 Linux 上默认没装。od是 coreutils 自带的,任何 Linux 上都有,所以这里用od。想看纯十六进制可以用od -A x -t x1 os.img | tail -2。)
再看 QEMU 说了什么
QEMU 启动时不会一声不吭:
- 它打印了找不到启动设备之类的话→ 它没认 55 AA,回上一步。
- 它什么都没说,窗口停在那→ 它认了,问题在代码本身。
最后进内存看
想看更细的,把 QEMU 的监视器挂上:
qemu-system-i386-driveformat=raw,file=os.img-monitorstdio出现(qemu)提示符后,依次敲:
xp/2hx 0x7dfe → 看那两格,应该是 0xaa55 xp/8xb 0x7c00 → 看机器码,应该是 8 个字节 xp/4hx 0xb8000 → 看显存最开头(BIOS 的横幅)正常的话,第一条会回:
00007dfe: 0xaa55 0x0000第二条会回:
00007c00: 0xb4 0x0e 0xb0 0x41 0xcd 0x10 0xeb 0xfe这 8 个字节,就是前面那张表里的机器码,原样躺在内存里。看到这一行,说明编译没问题、加载地址也没问题。剩下要查的就只有代码逻辑了。
A 到底出现在哪
它其实不在屏幕左上角。
在 QEMU 里跑起来,屏幕上完整的样子是这样:
0 |SeaBIOS (version 1.17.0-debian-1.17.0-1) 1 | 2 | 3 |iPXE (https://ipxe.org) 00:03.0 CA00 PCI2.10 ... 4 | ... 7 |Booting from Hard Disk... 8 |A ← 打印出来的 A 在这里BIOS 先打了自己的横幅,把光标推到了第 7 行末尾;回车换行后光标落到第 8 行,A就打印在光标当前的位置。
所以它是贴着左边,但不是第一行。照"左上角"去找,第一眼容易以为没成功;沿着最左边一列往下看会快一些。
屏幕是可以直接读出来的
屏幕上有什么,显存里就有什么。
文本模式下,显存从物理地址0xB8000开始,屏幕上每个字符占 2 个字节:低字节是字符的 ASCII 号,高字节是颜色。所以 80×25 的一屏正好是 4000 字节。
在 QEMU 监视器里敲:
xp/4hx 0xb8000会回:
000b8000: 0x0753 0x0765 0x0761 0x0742这四个值就是 BIOS 横幅的头四个字符——一个值 = 一个字符 + 它的颜色:
| 值 | 低字节 | 字符 | 高字节 | 颜色 |
|---|---|---|---|---|
0x0753 | 0x53 | S | 0x07 | 浅灰 |
0x0765 | 0x65 | e | 0x07 | 浅灰 |
0x0761 | 0x61 | a | 0x07 | 浅灰 |
0x0742 | 0x42 | B | 0x07 | 浅灰 |
拼起来就是SeaB。
想直接看那个A:它在行 8 列 0,换算地址是0xB8000 + (8×80 + 0)×2 = 0xB8500,敲xp/4hx 0xb8500,第一个值就是0x0741。
手算地址比较麻烦。下面这个脚本把整屏打出来,还顺便标出字符在第几行第几列:
python3 看屏幕.py os.img脚本全文放在文末附录里。后面开始写滚动输出、控制光标的时候,某个字符到底落在哪一格会经常需要确认——眼睛估不准,读显存是准的。
六、下一段路
512 字节,8 行汇编,屏幕上一个A。
这是和这台机器说的第一句话。
但 512 字节实在太小——装不下一个内核,甚至装不下几句像样的话。所以下一篇只有一件事:怎么把磁盘上更多的代码读进来。这就是"两段式引导"。
路已经开了,接着走。
下一篇见。
附录:看屏幕.py
在 QEMU 窗口里看一眼,能看出A有没有出来。这个脚本处理的是另一类问题:这个A究竟落在第几行第几列、颜色属性是什么、它后面那一格是不是空的——这些眼睛估不准,读内存是准的。
文本模式下,屏幕内容就铺在物理地址0xB8000上,一个字符两个字节。
这个脚本就干一件事:起一次 QEMU,把0xB8000那一屏抠出来,翻译成文字打出来。
存成看屏幕.py,和os.img放在同一个目录,然后运行:
python3 看屏幕.py os.img跑出来是这样:
0 |SeaBIOS (version 1.17.0-debian-1.17.0-1) 1 | 2 | 3 |iPXE (https://ipxe.org) 00:03.0 CA00 PCI2.10 ... 4 | ... 7 |Booting from Hard Disk... 8 |A ... 24 | 按 ASCII 号找字符(A = 0x41): 'A' -> 行 8 列 0,显存地址 0xB8500,颜色 0x07最后那行是重点:不用去找、不用去数,它直接说出字符在第几行第几列。后面写终端程序,判断"写对没有"就靠它。
脚本只用到 Python 自带的模块,不需要额外安装。
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""把 QEMU 里的屏幕内容原样打出来。 用法: python3 看屏幕.py os.img # 启动 os.img,等 6 秒,打印屏幕 python3 看屏幕.py os.img 10 # 启动慢就调大等待秒数 原理:文本模式下,屏幕上的每个字符就摆在显存里,物理地址从 0xB8000 开始。 每个字符占 2 字节——低字节是字符的 ASCII 号,高字节是颜色属性。 所以 80x25 的一屏 = 80 * 25 * 2 = 4000 字节。 屏幕内容就在这块内存里,可以直接读出来。 """importosimportshutilimportsubprocessimportsysimporttime IMG=sys.argv[1]iflen(sys.argv)>1else"os.img"WAIT=float(sys.argv[2])iflen(sys.argv)>2else6.0VGA=0xB8000COLS,ROWS=80,25SIZE=COLS*ROWS*2# 不同平台、不同安装方式给出的可执行文件名不一样,挨个试一遍QEMU=next((namefornamein("qemu-system-i386","qemu-system-i386.exe","qemu-system-x86_64","qemu-system-x86_64.exe")ifshutil.which(name)),None,)defdump(img,wait,workdir):"""起 QEMU,把显存抠成一个文件,返回文件路径。"""proc=subprocess.Popen([QEMU,"-drive","format=raw,file="+os.path.abspath(img),"-display","none","-monitor","stdio"],cwd=workdir,stdin=subprocess.PIPE,stdout=subprocess.PIPE,# 监视器的回显也收走,别弄脏输出stderr=subprocess.PIPE,# QEMU 的报错要接住,不然出错时只剩一句含糊提示)time.sleep(wait)# 等它启动完,把 A 打出来ifproc.poll()isnotNone:# QEMU 自己先退了:路径错、参数错都会这样sys.exit("QEMU 启动后就退出了:\n"+proc.stderr.read().decode("utf-8","replace"))# pmemsave 的文件名用相对路径——绝对路径会被监视器当成表达式解析而报错。# 因为 cwd 已经设成了 workdir,写相对名就等于落在那里。proc.stdin.write(("pmemsave 0x%x %d vga.bin\nquit\n"%(VGA,SIZE)).encode())proc.stdin.flush()try:proc.wait(timeout=15)exceptsubprocess.TimeoutExpired:proc.kill()out=os.path.join(workdir,"vga.bin")ifnotos.path.exists(out):sys.exit("没拿到显存。QEMU 说:\n"+proc.stderr.read().decode("utf-8","replace"))returnoutdefmain():ifQEMUisNone:sys.exit("PATH 里没找到 qemu-system-i386。装好之后确认安装目录加进了 PATH,\n""验证办法:命令行敲 qemu-system-i386 --version,能出版本号就行。")ifnotos.path.exists(IMG):sys.exit("找不到 %s"%IMG)# 在 os.img 所在目录里干活:%TEMP% 里带中文时,QEMU 在 Windows 上可能写不进去workdir=os.path.dirname(os.path.abspath(IMG))path=dump(IMG,WAIT,workdir)data=open(path,"rb").read()cells=[(data[i],data[i+1])foriinrange(0,len(data)-1,2)]forrinrange(ROWS):line="".join(chr(c)if32<=c<127else"."forc,_incells[r*COLS:(r+1)*COLS])print("%2d |%s|"%(r,line))print()print("按 ASCII 号找字符(A = 0x41):")fori,(c,a)inenumerate(cells):ifc==0x41:r,col=divmod(i,COLS)print(" 'A' -> 行%2d 列%2d,显存地址 0x%05X,颜色 0x%02x"%(r,col,VGA+i*2,a))os.remove(path)if__name__=="__main__":main()