Skip to content

01 · 为什么汇编劝退人,以及只需要学多少:手搓内核的最小读写集 ​

您多半已经遇上过这一幕了。您跟着某本书或者某个网页抄启动代码,人家写的是 mov rbp, rsp,您想看看自己的机器到底产出了什么,把编译的结果丢给 objdump,屏幕上出来的却是 mov %rsp,%rbp。做的是同一个动作,长的却是两副面孔,读起来的方向还正好相反。行话管搬进的那一头叫目的,管搬出的那一头叫源:书里写的目的在左,您机器上的源在左。不少人到这一步就把汇编归进了“天书”那一栏,再往后干脆放弃了。

这不是您的错觉,咱们当场把它复现出来。您拿手头的 Linux 机器,编一个不能再小的函数,-O0 是不优化的原样档,咱们要看的就是原样:

bash
printf 'int Add(int a, int b) { return a + b; }\n' \
    | g++ -x c++ -O0 -c -o add.o -
objdump -d add.o

编译出来的指令长这样(左边那列十六进制的地址,咱们一会儿再回头收拾):

text
0000000000000000 <_Z3Addii>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	89 7d fc             	mov    %edi,-0x4(%rbp)
   7:	89 75 f8             	mov    %esi,-0x8(%rbp)
   a:	8b 55 fc             	mov    -0x4(%rbp),%edx
   d:	8b 45 f8             	mov    -0x8(%rbp),%eax
  10:	01 d0                	add    %edx,%eax
  12:	5d                   	pop    %rbp
  13:	c3                   	ret

然后您给 objdump 多递一个 -M intel,别的一概不动:

bash
objdump -d -M intel add.o
text
0000000000000000 <_Z3Addii>:
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
   7:	89 75 f8             	mov    DWORD PTR [rbp-0x8],esi
   a:	8b 55 fc             	mov    edx,DWORD PTR [rbp-0x4]
   d:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  10:	01 d0                	add    eax,edx
  12:	5d                   	pop    rbp
  13:	c3                   	ret

您看第二行,mov %rsp,%rbp 和 mov rbp,rsp 说的其实是同一件事,把 %rsp 里的值搬进 %rbp。您再盯着中间那列字节看,48 89 e5 两边是一模一样的,其实连一个比特都没变过,变的只是打印它们的人。连内存的写法都跟着换了脸,-0x4(%rbp) 摇身一变成了 DWORD PTR [rbp-0x4]。

从汇编到字节再回来的整条路,您在这一篇里会亲手走上一整圈,走完汇编就不再是天书了。

AT&T 不是为为难您 ​

两副面孔都是有来头的。Intel 出的处理器手册自家用一套写法,后来大家叫它 Intel 语法。贝尔实验室那一脉的 Unix 工具链又长出了另一套,它的东家是 AT&T,大家就叫它 AT&T 语法。笔者头一回跟书搓操作系统,那本书用的汇编器说的就是 Intel 方言,后来笔者回到 GCC 工具链,跟 AT&T 把交道重新打了一遍。两套笔者都摸过,它们真的只是皮不一样。

咱们往后要用的工具全在 GCC 家里:管编译的 gcc,管汇编的 as,管反汇编的 objdump。而 GNU 工具链就长在 AT&T 的方言里,gcc -S 吐出来的汇编是它,as 吃进去的也是它,objdump 默认打印的还是它。刚才两份输出咱们也亲眼比过了,-M intel 不过是让 objdump 换了副眼镜,机器里躺着的东西纹丝不动。

所以咱们把本位定在 AT&T,读的写的都按它来,免得咱们在脑内来回翻译。您将来在 C++ 里嵌那几行非汇编不可的指令时,GCC 往外吐的默认就是它。GCC 确实留了换方言的开关,咱们也不去碰,咱们就守着 AT&T 这门方言过日子,算是偷个懒的选择。

亲手把一行汇编变成字节 ​

咱们现在就把回路亲手跑通。您建一个只有两行的 one.s:

bash
cat > one.s <<'EOF'
.text
movq $1, %rax
EOF

.text 说的是“后面的内容是代码”,咱们以后还会常常见到它。movq $1, %rax 的意思,是把数字 1 放进名叫 %rax 的寄存器。$ 打头的是数字本身,% 打头的是寄存器,眼下认得它们俩就够用了。

您别急着往下翻,猜猜机器会给您变出几个字节?

bash
as one.s -o one.o
objdump -d one.o
text
0000000000000000 <.text>:
   0:	48 c7 c0 01 00 00 00 	mov    $0x1,%rax

您数一数,48 c7 c0 01 00 00 00 正好凑成了七个字节。咱们粗粗地按字节看看:开头的 48 声明“这趟操作数按 64 位算”。跟着的 c7 c0 是一对搭档,说的是“把一个立即数搬进 %rax”。立即数就是 $ 打头的那个数字本身。尾巴上的 01 00 00 00 就是数字 1 本身,把它摊在了四个字节里。至于它为什么躺成了这样的姿势,咱们马上就能看见。

咱们换个大的立即数,把躺的姿势暴露得更彻底:

bash
cat > big.s <<'EOF'
.text
movabsq $0x123456789abcdef0, %rax
EOF
as big.s -o big.o
objdump -d big.o
text
0000000000000000 <.text>:
   0:	48 b8 f0 de bc 9a 78 	movabs $0x123456789abcdef0,%rax
   7:	56 34 12

咱们停一停。您写的是 12 34 56 78 9a bc de f0,机器里躺的却是 f0 de bc 9a 78 56 34 12,顺序整个反过来了。头一回亲眼读到它的人,谁都会愣一下:我从 12 写起的,它怎么偏从 f0 开头呢?谜底在两头:您从大的一头起笔,机器从小的一头开列。x86 是小端的机器,数它最小的字节永远排在最前面,咱们这串数的末位 f0,就被请去了开头。以后您在内存窗口或者调试器里看见一串反着的字节,就知道是怎么回事了。

回路的另一半其实咱们早就在用了,objdump 干的就是反方向的活,收的是字节,吐的是汇编。

从 operand type mismatch 到不吭声的错码 ​

您遇到的头一种报错多半是 operand type mismatch,原因基本是把 Intel 写法喂给了 as。比如顺手写了 mov rax, 5 或者 mov eax, ebx,它就会回您一句:

text
e1.s: Assembler messages:
e1.s:2: Error: operand type mismatch for `mov'

在 AT&T 的这边,% 是跟着寄存器的,$ 是跟着立即数的,您把源也放到左边。您把这几样补齐,报错就消失了。

更阴的还有一种,它一个错都不给您报,字节却悄悄编歪了。您忘掉 %rax 的 %,写成了 movq $0x7c0, rax,as 也一声不吭地收下了:

text
   0:	48 c7 04 25 00 00 00 	movq   $0x7c0,0x0
   7:	00 c0 07 00 00

它把 rax 当成了一个普通的名字,编出来的意思是“把 0x7c0 写到 0 号地址上去”。您数一数,一句连错都不报的手滑,足足给您编出了 12 个字节,长度几乎翻了一倍,意思还整个拧了。而忘掉 $ 的下场也一样安静,movq 0x7c0, %rax 会替您从内存 0x7c0 处读一个数回来。防身的办法只有一条,您写完了就拿 objdump -d 对一眼,字节的数量和长相都合了预期,才算过了关。

您要是看见 file format not recognized,多半是把 .s 源文件直接喂给了 objdump。它只认 as 编出来的目标文件,中间的 as one.s -o one.o 一步都不能省。

还有一种最朴素的:as: command not found,那是您的机器还没装 binutils。您把它装好,as 和 objdump 就都回来了。

十六进制与寄存器,认脸就够 ​

现在咱们回头收拾搁下的那一列十六进制。为什么汇编的世界满眼都是 0x?因为一个字节恰好是两位的十六进制,咱们从 0x00 数到 0xff,正好数出了 256 个值。十进制的 255 得写三位,还看不出它跟一个字节的关系,而十六进制一眼就对应一个字节,当成速记法再合适不过了。刚才输出里左边的地址 0、4、7、a、d 也都是十六进制的,其中 a 对应的是 10,d 对应的是 13。您会数 0 到 f,就算出师了。

轮到寄存器了,咱们只求认个脸熟。头一棵要认识的树是 %rax 家族:64 位的名字是 %rax,低 32 位的名字是 %eax,低 16 位的名字是 %ax。而 %ax 还能再劈成两半,低 8 位的叫 %al,高 8 位的叫 %ah。它们其实是同一块地方的四个尺码,改的只是名字而已。同样的套路也在 %rbx 和 %rcx 还有 %rdx 身上原样重演。常客里还有几位值得咱们照个面,%rsi 和 %rdi 出身是“源”和“目的”的两根索引,如今常帮咱们递地址,%rbp 和 %rsp 看着栈的一进一出。段寄存器是单独的一家人,咱们常见的有 %cs 和 %ds 以及 %es 和 %ss,它们管的事咱们往后遇到了再拆。眼下混个脸熟已经完全够用了,真到用的时候,咱们是看着输出学的。

浮点、SIMD、宏,都欠着 ​

不讲的,咱们摆到桌面上说。手搓内核的路径上,咱们一路都碰不上浮点和 SIMD 还有 AVX,也就用不着学了。汇编宏的深水咱们不去蹚,保护模式(x86 从 16 位起步之后换上的 32 位运行模式)里的特权指令,用场都还离得远远的,轮到正文讲切换运行模式的那一篇再学。至于拿汇编写整个程序的活,那不在这一卷的盘子里:咱们的主语言是 C++,汇编只在机器必须亲自动手的那几行出场。这一卷瞄准的,是一套读写和排错都够用的最小家当,再多的就不背了。

两副面孔和一个来回 ​

同一列字节的两副面孔,咱们也亲眼见过了。不过咱们从头到尾都绕着一处走:Add 的输出里那行 mov %edi,-0x4(%rbp),尾巴上的括号是干什么的,咱们一步都没讲。您带着它走进下一篇,那副括号里的名堂,很快就会一项一项地摆开。

r04_protected_mode-1-gc50cfe1 · c50cfe1 · 2026-10-01