Skip to content

Latest commit

 

History

History
542 lines (411 loc) · 25.3 KB

13.rst

File metadata and controls

542 lines (411 loc) · 25.3 KB
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的区别有两个:

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

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

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就会一直执行 下去。

通用寄存器

几乎所有的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的定义是:

  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。就正好把三个数加到一起 去了。

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

写自己的汇编函数

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

在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;
}

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