Authors: | Kenneth Lee |
---|---|
Version: | 0.1 |
Date: | 2022-11-07 |
Status: | Draft |
讨论了封装,我们开始学习一点汇编有关的知识吧。
我们说了,计算机只认识机器码,机器码就是数字,很难记,汇编是用文字代表这些数字, 这是为了人的方便,但它还是比C/C++这些语言更接近机器,基本上,我们可以认为,汇编 差不多就是机器语言,因为它们可以一一对应过去的。理解机器能“听懂”什么话,有助于 我们理解我们的高级语言为什么是那个样子的。
我们写的程序,编译出来二进制,可以用编译器的反编译工具恢复成汇编语言,这样我们就 能看到它作为汇编的样子。
我们前面讲Makefile 的时候,讲过两种编译的方法,分别是这样的::
g++ test.cpp -o test g++ -c test.cpp -o test.o g++ test.o -o test
前面这个编译成可执行程序(如果在Windows下叫test.exe),后面这个编译成一个中间文 件,等有很多个.o的时候,再链接成可以执行的文件。
test.o和test.exe的区别有两个:
- test.o只有你的test.cpp要求的内容,test.exe包含所有运行需要的内容,包括那些cout的实现
- test.o的所有符号的位置是没有确定的,test.exe的是确定的。
我们编译下面这个程序看看:
int a = 10;
int main(void) {
return a;
}
编译成.o::
g++ test.cpp -o test.o objdump -D test.o > test.S
第二条命令把obj文件(.o文件)dump成汇编(-D的作用),结果是这样的::
Disassembly of section .text: 0000000000000000 <main>: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # e <main+0xe> e: 5d pop %rbp f: c3 ret Disassembly of section .data: 0000000000000000 <a>: 0: 0a 00 or (%rax),%al
这个文件把程序分成一段段(section),我们看到每段的地址都是0,这是因为还没有链 接,没有确定这些代码都放在哪里。所以,都没有内容。
这里有两个段:
- .text,里面只有一个函数main,下面是它的代码,地址在0, 4, 5,这些内存位置上。 我们一会儿再去解释每条指令的具体含义,我先提醒其中一个特征:这里main访问了a, 但a还没有确定位置,所以那条mov 0x0(%rip), %eax里面本来是要找a的,但现在只是 放了一个0。
- .data段,这里就放了一个0a00,objdump也对这个东西反汇编了(or (%rax), %al), 但其实这个不是语句,这只是个数字10(原文是十六进制表达),但objdump也分不出 这个,所以也给你反汇编了。
我们再看看.exe的反汇编结果::
0000000000001129 <main>: 1129: f3 0f 1e fa endbr64 112d: 55 push %rbp 112e: 48 89 e5 mov %rsp,%rbp 1131: 8b 05 d9 2e 00 00 mov 0x2ed9(%rip),%eax # 4010 <a> 1137: 5d pop %rbp 1138: c3 ret Disassembly of section .data: 0000000000004000 <__data_start>: ... 0000000000004008 <__dso_handle>: 4008: 08 40 00 or %al,0x0(%rax) 400b: 00 00 add %al,(%rax) 400d: 00 00 add %al,(%rax) ... 0000000000004010 <a>: 4010: 0a 00 or (%rax),%al
如果你直接去看反汇编的结果,那里的内容比我这里多得多,因为g++把其他内容也链接进 来了,我只是继续给你看这个main和a在什么地方。
可以看到,现在main有了确定的位置,main在1129的位置上,而a在4010的位置上,main里面访问 a的位置也变成了mov 0x2ed9(%rip), %eax,它专门说了,这个地址就是4010的位置。
你的程序大致就是这么构成的,我们这里也不需要学习具体怎么写这些汇编,但大概知道 它的原理,有助于我们想明白编译器都在翻译成什么。我们这里就相当于你靠翻译来把你 说的中文翻译成英文,但你还是需要了解一些基本的英文文化,知道说英语的人都关心些 什么问题,这样你对翻译能翻译写什么有点了解,就更容易给翻译说清楚问题了。
我们平时用的个人电脑(PC)用的CPU叫x86,历史我们就不说了,它有32位和64位两种版 本,这个背后的历史我们也不说了,反正32位和64位基本上指一条指令能操作的数据最大 有多大,32位表示CPU一次能操作32位的数,64位表示CPU一次能操作64位的数。我们马上 就会看到这个数字怎么起作用的。
所以,现在你拿到的PC基本就是x86_64版本的,做这个东西主要有两家公司,Intel和AMD, 两者作出来的硬件的汇编不是完全一样的,但只要你不用很高级的功能,你认为它们是一 样的就可以了。
在C++的角度,基本上我们说的每个存储都是指内存,就算它临时不是内存,需要的时候也 可以变成内存来讨论。但对于机器来说,不是这样的。在硬件上,CPU和内存其实离得很远。 这样说吧,CPU做一个加法,不算头尾的准备时间,通常只是时钟跳一次,但如果要从内存 里面读一个数据进来,时钟得跳100次以上。所以编译器没事通常是轻易不去访问内存的。 所以CPU里面有一个概念,叫寄存器,如果你只是要反复计算,不需要内存,就都在寄存器 里面算。
但计算机用内存还是有原因的,CPU里面的寄存器很少,x86_64一般用来计算的,只有16 个。叫r0-r15。由于历史原因,前面八个有特别的名字:
编号 | 名字 | 备注 |
r0 | rax | 函数第一个参数或者函数返回值 |
r1 | rbx | |
r2 | rcx | |
r3 | rdx | |
r4 | rsi | |
r5 | rdi | |
r6 | rbp | 当前函数堆栈首地址 |
r7 | rsp | 栈顶指针 |
剩下的就叫r8-r15了。她们的长度都是64位。现在你知道那个x86_64的64是什么意思了。 通常我们C++里面的int的长度,就是这个字长的长度。但这个为了兼容,有些平台把int定 义成32位的,比较乱,如果你实在需要知道长度,还是要用sizeof()判断才能做准。如果 你一定要64位的,就用int64_t就比较保险了。
有些寄存器是隐含的,从指令上看不见,比如RPC,表示当前要执行的指令,CPU根据RPC的 值决定从哪里读指令去执行,执行完后会更新到下一条指令,这个寄存器一般不参与计算。
那如果你要算一个short或者一个char怎么办呢?——你可以换个名字去访问这个寄存器,比如 rax,你换成eax,它就是32位的,ax就是16位的,al就是8位的。硬件上这个东西还是64bit, 但用的时候只用其中一部分。
如果要128位怎么办呢?编译器就要给你算两次。比如要加两个128位的整数,编译器就要 分两次加,先用adc加低64位,如果有进位就会放到另一个叫rflag的寄存器中,在用add加 高位和rflag的进位标记,变成一个完整的128位加法。
寄存器这个东西,随着CPU的功能增加,也会增加,比如机器学习经常那个要用向量计算, 为了配合这种计算,现代的x86中还支持SIMD(但单指令多数据)指令,这些指令用一组称 为xmm0-xmm15的寄存器,这些寄存器256位,你可以把一组向量放到每个寄存器中,一次对 整个向量做加减乘除。
浮点数也是32位或者64位的,按理说其实浮点数都可以放在r0-r15中,但还是因为历史原因。 x86的浮点数是用另一组寄存器来表示的。那个原理是一样的,我们这里不深入探讨。
x86_64 CPU的原理就不外这样了:CPU里面有寄存器,CPU根据RIP(在通用的CPU科学中, 这个通常叫PC,Program Counter,用来保存下一条要执行的指令的地址。x86_64由于历 史原因,叫Instruction Pointer,其实是一个意思)寄存器的内容读指令,然后执行读 写内存或者进行计算,完成后更新RPC,读下一条指令,重复上面过程,CPU就会一直执行 下去。
几乎所有的CPU都有通用寄存器的概念,叫GPR,General Purpose Register。其实CPU中 有很多寄存器,为什么需要突出通用寄存器这个概念呢?因为这些寄存器通常是大部分指 令都可以访问的。比如你要做个加法,你写add rbx, rax。这里你要求把rax和rbx的值加 起来,放到rax中。其中的rax, rbx可以改成其他GPR,这个指令都是可以支持的。但如果 你说你要加RIP的值,那就不行了,要另外设计一个指令给你,专门把数据送过去,这种 就不算“通用寄存器”。GPR的作用,就是设计出来,专门用来支持各自通用计算的。
不是GPR的寄存器一般没那么灵活,需要专门的指令去改变,比如RIP,通常是你要跳转, 根据你要求跳转到什么地方(使用Jmp或者Jcc指令),自动修改的。
还有一些寄存器,是每次计算都要用的,但也不能直接放到计算指定的寄存器中,这些也 不算GPR。比如RFLAGS。这个用来记住一些中间状态的,也不是GPR。比如add rbx, rax这 个加法可能会进位,那么进位的结果就放在RFLAGS中。然后你如果执行adc rcx, rax,这 个加法除了加rcx外,还要加上RFLAGS的进位位。这种寄存器看来也是很多通用计算会用 到的,但它不能用来指定操作数,所以也不算是GPR。
有一些寄存器,在部分处理器里面不是GPR,部分处理器是GPR。比如RSP(Stack Pointer),是堆栈指针。它的功能是用来支持PUSH和POP指令的,如果你做PUSH,它就把 RSP改成RSP-8,然后把数据保存在新的RSP的地址上,这样就实现把数据写入堆栈的功能。 反过来你做POP,它就把RSP的数据读出来,然后把RSP改成RSP+8。这样实现了退出堆栈的 功能。这种寄存器,需要专门的PUSH和POP指令来使用。但你直接用来做add或者adc,也 是可以的,这种指令在x86_64中,就也是GPR,因为普通指令也能拿它来做普通计算。
寻址问题是谈CPU设计不可避免要谈的问题。我们这里解释一下原因。
CPU内部的存储主要就是寄存器,寄存器的数量很有限,所以大量的数据只能放在内存中。 所以。如果你使用内存来计算,理论上,你就要这样写指令::
add address1, address2, address3
我们这样写的时候感受不深,但如果你考虑一下这个指令真的变成数字,它有多麻烦:
首先add需要一个数字表示,比如用8位表示add,然后address1/2/3每个需要64bit,那么 这条指令就需要25个字节,很长。更麻烦的事情是,你学过计算机组成原理的话,CPU设 计取内存的时候是个很复杂的过程,而这个指令需要取3次内存,这样CPU就会很复杂。
所以,很多时候,CPU其实不允许你直接给定每个操作数的地址,通常只允许一个,这样 CPU就没有那么复杂。很多指令,就只允许你这样指定地址::
mov $1234, %rax ; 这叫立即数寻址,直接给定一个数字 mov %rbx, %rax ; 这叫寄存器寻址,给定一个寄存器 mov (-10), %rax ; 这叫RIP基变址,用RIP的地址加上编译来算内存地址 mov (%rbx), %rax ; 这叫相对地址寻址,靠寄存器指定地址 mov (%rbx+10), %rax ; 这叫基变址,相对寄存器有个偏移来寻址内存 mov (%rax+%rsi), %rax ; 这叫寄存器基变址 mov (%rax+%rsi+10), %rax ; 这叫也叫寄存器基变址 mov (%rax+%rsi*8+10), %rax, ; 这叫也叫寄存器基变址
这些名字每个平台都会不太一样,我这里起的名字也是随手起的,最后的命名还是要看手 册,我这里强调其中的原理而已。x86_64很多指令都支持多种寻址方式,因为它是变长指 令,现在更多的CPU只有内存读写指令(叫load/store指令,简称ld/st)才能有内存寻址, 其他指令,基本上都只能用寄存器进行运算。这样CPU的设计效率更高。
甚至x86也是先把这些指令分解成多条内部指令(叫“微码”)来实现的。
这里我们看一批指令,看看汇编这个语言的“文化”。
;普通算术 inc/dec ;i++和i-- add/adc ;加法和连进位加 sub/sbb ;减法和借位减法 idiv/div ;有/无符号除法 imul/mul ;有/无符号乘法 neg ;计算补码 ;跳转操作 call ;调用函数 ret ;函数返回 jcc/jmp ;有/无条件跳转 ;位操作 and/not/or/xor ;逻辑运算 bs[f|b] ;位扫描 b[t|tr|ts] ;位监测 rar ;算术位右移 sh[l|r] ;逻辑左右移 ;其他辅助指令 std/cld ;设置和清除RFLAGS寄存器的DF标志 mov[|sx|sxd|zx];数据移动,可指定符号扩展(比如从eax移动到rax中,多出来的位如何处理) cmov ;条件成立时mov cmp ;比较,结果写RFLAGS cpuid ;读CPU的类型 lea ;加载地址 push/pop ;堆栈操作 setcc ;有条件写字节 test ;检查条件 xchg ;交换 ;字符串优化 lods[b|w|d|q ;字符串加载 stos[b|w|d|q ;字符串写入 cmps[b|w|d|q] ;字符串比较 re[p|pe|pzpne|pnz] ;字符串重复
所有条件指令,比如jcc,cmov等,都可以跟一个条件后缀,比如A(Above)表示大于, AE(Above or Equal)表示大于等于,L(Less)表示小于,等等。
这里只是列出主流的指令,实际还有很多,我们学习计算机组成原理的时候就理解过了, 指令越多需要的电路面积越大,CPU的成本就越多。这对CPU设计者来说是个权衡。对我们 这些使用者来说,大多数时候,我们不会专门去记住这些指令,大概有个印象,用到的时 候去查手册,或者能看懂反汇编的结果就行。
现在我们一般不用汇编编程,所以,其实我们通常不会深入学习汇编编程的方法,我们更 多是要看懂汇编代码写的是什么,然后我们还能在关键的地方代替原来的C/C++代码写部 分关键的汇编就可以了。
我这里主要就是讲这个。比如,你不需要专门学习加法怎么写汇编,你只要写一个C的代 码,看看它的汇编怎么生成的就可以了。
我写一个这样的C程序来看:
int add(int a, int b, int c) {
return a+b+c;
}
int main(void) {
return add(1, 2, 3);
}
我们这样编译::
gcc -c test.c -o test.o objdump -S test.o > test.S
其实你还可以这样::
gcc -S -c test.c -o test.S
但这两种方法得到汇编是有区别的。前者是先生成汇编,用汇编器生成机器码,然后用 objdump反汇编回来。而后者是编译器生成汇编以后就停下来,这样会留下一些汇编器还 没有处理的伪指令在里面。所以前者的结果会更纯粹一些。
下面是前者的输出(我补充了注释)::
0000000000000000 <add>: 0: 55 push %rbp ; 把rbp写入堆栈 1: 48 89 e5 mov %rsp,%rbp ; rsp写入rbp(这个指令有点特别,目标寄存器在后面) 4: 89 7d fc mov %edi,-0x4(%rbp) ; 保存第一个参数edi 7: 89 75 f8 mov %esi,-0x8(%rbp) ; 保存第二个参数esi a: 89 55 f4 mov %edx,-0xc(%rbp) ; 保存第三个参数edx d: 8b 55 fc mov -0x4(%rbp),%edx ; 读回第一个参数到edx 10: 8b 45 f8 mov -0x8(%rbp),%eax ; 读回第二个参数到eax 13: 01 c2 add %eax,%edx ; eax+=edx 15: 8b 45 f4 mov -0xc(%rbp),%eax ; 更新第三个参数回内存 18: 01 d0 add %edx,%eax ; edx+=eax 1a: 5d pop %rbp ; 恢复rbp 1b: c3 ret ; 函数返回 000000000000001c <main>: 1c: 55 push %rbp ; 这是main函数的内容,我们不关心,我们只关心如何调用add的 1d: 48 89 e5 mov %rsp,%rbp 20: ba 03 00 00 00 mov $0x3,%edx ; 第三个参数,edx 25: be 02 00 00 00 mov $0x2,%esi ; 第二个参数,esi 2a: bf 01 00 00 00 mov $0x1,%edi ; 第一个参数,edi 2f: e8 00 00 00 00 call 34 <main+0x18> ; 调用add (位置让链接器决定) 34: 5d pop %rbp 35: c3 ret
这里严格保证你每次更新内存都被更新了,其实很多动作都是多余的,如果你用-O2来编 译,就会有不一样的结果::
0000000000000000 <add>: 0: 01 f7 add %esi,%edi ; 前两个相加,结果在edi(rdi)中 2: 8d 04 17 lea (%rdi,%rdx,1),%eax ; rdi+rdx*1 -> eax 5: c3 ret ; 函数返回 Disassembly of section .text.startup: 0000000000000000 <main>: 0: b8 06 00 00 00 mov $0x6,%eax ; 编译器预判到函数的结果是6,根本不生成调用,直接出结果 5: c3 ret
注:我们这里使用的是gas的语法,输出寄存器一般放后面,但更多的手册上,输出寄存 器是放第一个的,这个在平时使用的时候要注意区分。
我们总结一下前面这个代码告诉我们的两个信息。首先是调用是怎么工作的。你可以看 到,我们需要三个参数,这些参数固定放在edi, esi, edx寄存器里面,函数的返回值放 在eax里面(看长度,如果需要64位的返回,就放在rax里面)。这种习惯其实就是调用这 和被调用者之间的一个约定,说好是什么样的,大家就按一样的习惯用就行了。
在gcc中,对x86的调用习惯是:参数按这个顺序传递:RDI, RSI, RDX, RCX, R8, R9,输 入传入的参数超过这个数量,就写到堆栈中,被调用者自己从堆栈取。返回值用RAX传递。
call这个指令的行为是这样的:先把call后面指令的地址压栈,然后跳转到指定的程序入 口执行。ret则反过来,把堆栈里面的地址pop出来,然后跳转到这个地址上,这样就回到 call后面继续执行了。而寄存器的用法我们也知道了。
还有一个调用约定是决定是如果使用了某个寄存器,谁负责保存。这个gcc的定义是:
- r10, r11和所有的参数和返回值,都是caller save寄存器
- 剩下的都是callee save寄存器
什么意思呢?比如你的main调用了add,main在r10里面有一个有用的值,那么调用add前 你得自己保存一下,因为add可以用这个寄存器,导致main调用完add以后,原来有用的值 就没有了。你要保证它的值不变,main就要自己保存r10的值,这就叫caller-save,调用 一方负责保存。
反过来,比如你的main函数在r12里面方了一个有用的值,那么调用add之前你就不用保存 任何东西,因为它是callee-save的,add要不不要用这个寄存器,如果用了这个寄存器就 要主动保存,ret前要恢复。在上面的例子中,最典型的就是没有优化的时候,add里面对 rbp的使用,它就是先把值保存在堆栈中,然后才开始用的,等ret之前,会通过pop把保 存的数据恢复出来。
这是调用,我们再看看加法怎么做的。我们这里要求加三个数,但汇编只能加两个。所以 在未优化版本中,调用了两次add指令,先调用add %eax, %edx,然后再把add %eax, %edx, 函数返回值正好就是eax,所以加完以后,做ret就可以了。
优化版本就无所不用其极地找指令了。它用了lea,这个指令不是用来做加法的,它的主 要目的是用来加载一个地址用的,但我们前面讲过,基变址刚好就是一个加法,所以它在 加完第一步以后,就直接做了一个lea (%rdi,%rdx,1), %eax。就正好把三个数加到一起 去了。
掌握这个方法,更多的指令,都可以这样一点点编译,反汇编的方法了解更多的代码怎么 写了。
如果我们要自己写一个汇编程序,可以从写函数开始,下面把前面提到的那个add函数写 成汇编::
.text ;表示后面写的都放在叫.text的段中,gcc的约定是.text就是代码段,这就是个约定,只能记住 .global add ;这是告诉汇编器,add是个全局符号,和你在C里面用extern int add(int, int, int)类似 add: add %rsi,%rdi ;这是一个标记,以便说明一个位置,这里是add函数的代码入口 lea (%rdi,%rdx,1),%rax ;后面就是具体的代码本身了 ret
点开头的叫“伪指令”,这个东西不生成真的指令,只是告诉汇编器怎么工作而已。
这个很简单吧?你可以把数据放在.text中,别执行到就行,比如这样::
.text .global add add: add %rsi,%rdi mov aa(%rip), %rdx ;用当前rip作为偏移,读入aa的值到rdx中 lea (%rdi,%rdx,1),%rax ret ;这里已经返回了,后面的数据反正不会执行到 aa: .long 10 ;定义一个long长度(32位)的变量
你不喜欢把数据和代码段放在一起,也可以这样::
.text .global add add: add %rsi,%rdi mov aa(%rip), %rdx ;用当前rip作为偏移,读入aa的值到rdx中 lea (%rdi,%rdx,1),%rax ret ;这里已经返回了,后面的数据反正不会执行到 .data aa: .long 10 ;定义一个long长度(32位)的变量
如此而已。
上面的程序可以这样编译::
gcc -c asm_code.S -o asm_code.o
或者这样也行::
as asm_code.S -o asm_code.o
有了这个.o,你和其他c编译的.o一样编译就可以了。
更多时候,我们之需要在代码的其中几句关键的地方放汇编,所以gcc也允许你直接在C代 码中嵌入汇编。写法是这样的::
int add(int a, int b, int c) { asm("add %%rsi, %%rdi\n" "lea (%%rdi, %%rdx, 1), %%rax\n" "ret\n":::); }
这里调用add函数,但实现是自己写的汇编。你可以看到,我们相当于调用了一个内部的 字符串(先别管后面那几个冒号),字符串里面放着汇编代码,gcc编译这个程序的时候 除了生成自己的汇编,遇到这个asm函数的时候,就把中间的字符串整个放进去当作汇编 代码就行了,gcc不解释里面的内容,甚至回车都需要你主动写n。
%这个符号在asm的语法中有特殊含义(我们马上会看到),所以这里用%做escape,两个% 相当于一个。
这种程序如果编译出错了,你可以用gcc -S编译,看看出来的汇编代码是什么样的,就知 道错在什么地方了。
实际上我这里只是示意,这个程序这样写是不行的。因为如果你的asm语句前后都有其他 gcc生成的代码,你不知道哪些寄存器被用过,哪些没有被用过。你随手就写,可能那些 寄存器就被破坏了,所以,这个程序要这样写::
int add(int a, int b, int c) { int ret = a; asm volatile ( "add %[v2], %[ret]\n" "lea (%[ret], %[v3], 1), %[ret]\n" : [ret] "=r" (ret) : [v2] "r" (b), [v3] "r" (c) ); return ret; }
asm后面的vaolatile主要告诉gcc不要优化里面的汇编指令,这个一般都会加上,其实不 加也不见得会有问题,作为一个正经的例子,我们这里加上了。
冒号后面的是修饰符,第一段说明输出寄存器("=r"表示会被修改的寄存器),第二段说 明输入寄存器(r表示寄存器),(还可以补充第三段用来说明是否修改过内存之类的东 西),最后在汇编里面就不要指定绝对的寄存器了,直接使用%[name]这样的形式说明对 应哪个输入输出就可以了。每个变量声明中说明具体要访问那些变量,gcc会把那个变量 输入或者输出到需要的寄存器上的。
还有一种简化的写法,用第几个寄存器来表示,是这样的::
int add(int a, int b, int c) { int ret = a; asm volatile ( "add %1, %0 \n" "lea (%0, %2, 1), %0\n" : [ret] "=r" (ret) : [v2] "r" (b), [v3] "r" (c) ); return ret; }
汇编程序大概就是这些东西了,其他都可以看手册具体了解。