news 2026/10/8 6:45:51

自己写一个内核(一):最小引导扇区

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自己写一个内核(一):最小引导扇区

我的专栏直达
《机器人学导论》专栏点击进入
《自己写一个内核》专栏点击进入

自己写一个内核(一):最小引导扇区

按下电源。

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:

十六进制怎么算十进制
0x101×1616
0x414×16 + 165(字符 A)
0x1001×256256
0x4004×2561024(1K)
0x7c007×4096 + 12×25631744

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位来历
AAH/ALAXEAXRAX累加器
BBH/BLBXEBXRBX基址
CCH/CLCXECXRCX计数
DDH/DLDXEDXRDX数据

四个宽度、四个家族,用"加宽换前缀、拆半加后缀"这一条基本就能对上号。(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 里早就写好了。

这段代码干什么,是看约定的:

  1. 先看AH里是几。0x0e表示"在光标处打印一个字符"。
  2. 再看AL里是几。里面是0x41,也就是字符 A。
  3. 于是把 A 写到屏幕上,然后跳回来,继续跑后面的代码。

所以前面那两条mov是在按 BIOS 的约定摆参数,int 0x10才是真正下命令。这个约定叫调用约定,后面每一步都要跟它打交道。

jmp $:原地打转

jmp是 jump,跳转。$表示"这一行自己的地址",jmp $就是跳到自己头上,原地打转,永远停在这。

这句看着多余,其实省不得。少了它,CPU 执行完int 0x10会继续往下走,一路走进后面的数据区、走出这 512 字节,跑进一片不知道是什么的内存里,行为完全不可预料。


三、为什么必须正好 512 字节

代码到jmp $就完了。数一下刚才那四条指令编出来多少字节:

汇编机器码字节数
mov ah, 0x0eB4 0E2
mov al, 'A'B0 412
int 0x10CD 102
jmp $EB FE2

一共 8 个字节。

汇编是人看的写法,机器码是 CPU 读的东西。nasm 干的活就是查表翻译:每条汇编换成固定的一串字节,一条对一条,没有别的花样。规律很直白——编号加数据:

  • B4是"往 AH 放数"的编号,0E是放进去的数
  • B0是"往 AL 放数"的编号,41是那个 A
  • CD是"中断"的编号,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-2
0001f0 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 横幅的头四个字符——一个值 = 一个字符 + 它的颜色:

值低字节字符高字节颜色
0x07530x53S0x07浅灰
0x07650x65e0x07浅灰
0x07610x61a0x07浅灰
0x07420x42B0x07浅灰

拼起来就是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()
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 6:43:19

混沌拓扑学(HDT)逻辑学 超级思维和认知惯性

摘要 深夜两点&#xff0c;你独自想通了一个困扰很久的问题&#xff0c;兴奋得想找人分享。可你翻遍通讯录&#xff0c;却找不到一个能听懂的人。那一刻的孤独&#xff0c;不是没人陪&#xff0c;而是没人能接住你的思考。 适应不是变强&#xff0c;是变窄。 超级思维不是思考更…

作者头像 李华
网站建设 2026/10/8 6:43:18

训练集准确率100%后模型还在学什么?验证损失与泛化能力解析

训练集正确率冲到 100% 的那一刻&#xff0c;很多人会下意识觉得“模型已经学到头了”&#xff0c;但如果你真的做过深度学习实战&#xff0c;应该会隐隐觉得不对劲&#xff1a;训练集的 loss 明明还在降&#xff0c;验证集准确率却开始掉头往下走。这不是 bug&#xff0c;而是…

作者头像 李华
网站建设 2026/10/8 6:42:01

TPS259483+PIC18F85J50:嵌入式电源路径保护方案实战

做嵌入式这些年&#xff0c;我越来越确认一件事&#xff1a;电源路径往往比业务功能更值得花时间&#xff0c;尤其当你面对的是 24V 工业控制器这种连续运行环境。TPS259483AYWPR 和 PIC18F85J50 这个组合&#xff0c;是我在一台嵌入式工业控制器上实际验证过的电源路径保护方案…

作者头像 李华
网站建设 2026/10/8 6:41:54

动态规划—买卖股票最佳时机

文章目录一、[题目](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/description/?envTypestudy-plan-v2&envIdtop-interview-150)二、My thinking三、动态规划3.1 动态规划算法3.2 算法步骤3.3 代码实现3.4 时间和空间复杂度四、总结一、题目 给定一个数…

作者头像 李华
网站建设 2026/10/8 6:41:41

渗透测试基础:安全测试概念与测试思路

渗透测试基础&#xff1a;安全测试概念与测试思路 前言 很多刚入行网络安全的新手&#xff0c;容易把渗透测试、漏洞扫描、安全测试、代码审计这几个概念混为一谈。不少初学者上来就直接拿工具扫站点&#xff0c;挖到几个 XSS、弱口令就认为自己掌握了渗透测试&#xff0c;其实…

作者头像 李华