13. 学习一点汇编的知识

13.1. 综述

讨论了封装,我们开始学习一点汇编有关的知识吧。

我们说了,计算机只认识机器码,机器码就是数字,很难记,汇编是用文字代表这些数字,这是为了人的方便,但它还是比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的区别有两个:

  1. test.o只有你的test.cpp要求的内容,test.exe包含所有运行需要的内容,包括那些cout的实现

  2. 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,这是因为还没有链接,没有确定这些代码都放在哪里。所以,都没有内容。

这里有两个段:

  1. .text,里面只有一个函数main,下面是它的代码,地址在0, 4, 5,这些内存位置上。我们一会儿再去解释每条指令的具体含义,我先提醒其中一个特征:这里main访问了a,但a还没有确定位置,所以那条mov 0x0(%rip), %eax里面本来是要找a的,但现在只是放了一个0。

  2. .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的位置。

你的程序大致就是这么构成的,我们这里也不需要学习具体怎么写这些汇编,但大概知道它的原理,有助于我们想明白编译器都在翻译成什么。我们这里就相当于你靠翻译来把你说的中文翻译成英文,但你还是需要了解一些基本的英文文化,知道说英语的人都关心些什么问题,这样你对翻译能翻译写什么有点了解,就更容易给翻译说清楚问题了。

13.2. x86_64的CPU抽象

我们平时用的个人电脑(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就会一直执行下去。

13.2.1. 通用寄存器

几乎所有的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,因为普通指令也能拿它来做普通计算。

13.2.2. 操作数寻址问题

寻址问题是谈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也是先把这些指令分解成多条内部指令(叫“微码”)来实现的。

13.3. 指令介绍

这里我们看一批指令,看看汇编这个语言的“文化”。

;普通算术
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设计者来说是个权衡。对我们这些使用者来说,大多数时候,我们不会专门去记住这些指令,大概有个印象,用到的时候去查手册,或者能看懂反汇编的结果就行。

13.4. 学习汇编

现在我们一般不用汇编编程,所以,其实我们通常不会深入学习汇编编程的方法,我们更多是要看懂汇编代码写的是什么,然后我们还能在关键的地方代替原来的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的定义是:

  1. r10, r11和所有的参数和返回值,都是caller save寄存器

  2. 剩下的都是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。就正好把三个数加到一起去了。

掌握这个方法,更多的指令,都可以这样一点点编译,反汇编的方法了解更多的代码怎么写了。

13.5. 写自己的汇编函数

如果我们要自己写一个汇编程序,可以从写函数开始,下面把前面提到的那个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一样编译就可以了。

13.6. 在C/C++中嵌入汇编程序

更多时候,我们之需要在代码的其中几句关键的地方放汇编,所以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;
}

汇编程序大概就是这些东西了,其他都可以看手册具体了解。