01 · 为什么汇编劝退人,以及只需要学多少:手搓内核的最小读写集
您多半已经遇上过这一幕了。您跟着某本书或者某个网页抄启动代码,人家写的是 mov rbp, rsp,您想看看自己的机器到底产出了什么,把编译的结果丢给 objdump,屏幕上出来的却是 mov %rsp,%rbp。做的是同一个动作,长的却是两副面孔,读起来的方向还正好相反。行话管搬进的那一头叫目的,管搬出的那一头叫源:书里写的目的在左,您机器上的源在左。不少人到这一步就把汇编归进了“天书”那一栏,再往后干脆放弃了。
这不是您的错觉,咱们当场把它复现出来。您拿手头的 Linux 机器,编一个不能再小的函数,-O0 是不优化的原样档,咱们要看的就是原样:
printf 'int Add(int a, int b) { return a + b; }\n' \
| g++ -x c++ -O0 -c -o add.o -
objdump -d add.o编译出来的指令长这样(左边那列十六进制的地址,咱们一会儿再回头收拾):
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,别的一概不动:
objdump -d -M intel add.o0000000000000000 <_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:
cat > one.s <<'EOF'
.text
movq $1, %rax
EOF.text 说的是“后面的内容是代码”,咱们以后还会常常见到它。movq $1, %rax 的意思,是把数字 1 放进名叫 %rax 的寄存器。$ 打头的是数字本身,% 打头的是寄存器,眼下认得它们俩就够用了。
您别急着往下翻,猜猜机器会给您变出几个字节?
as one.s -o one.o
objdump -d one.o0000000000000000 <.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 本身,把它摊在了四个字节里。至于它为什么躺成了这样的姿势,咱们马上就能看见。
咱们换个大的立即数,把躺的姿势暴露得更彻底:
cat > big.s <<'EOF'
.text
movabsq $0x123456789abcdef0, %rax
EOF
as big.s -o big.o
objdump -d big.o0000000000000000 <.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,它就会回您一句:
e1.s: Assembler messages:
e1.s:2: Error: operand type mismatch for `mov'在 AT&T 的这边,% 是跟着寄存器的,$ 是跟着立即数的,您把源也放到左边。您把这几样补齐,报错就消失了。
更阴的还有一种,它一个错都不给您报,字节却悄悄编歪了。您忘掉 %rax 的 %,写成了 movq $0x7c0, rax,as 也一声不吭地收下了:
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),尾巴上的括号是干什么的,咱们一步都没讲。您带着它走进下一篇,那副括号里的名堂,很快就会一项一项地摆开。