CSAPP——汇编

20 阅读9分钟

一些常用编译命令

生成汇编代码,-S 告诉编译器生成汇编代码,而不是目标文件或可执行文件,生成.s文件

gcc -Og -S mstore.c

将c代码编译成二进制文件.o,-c表示只进行编译,不进行链接,生成.o文件

gcc -Og -c mstore.c

反汇编,将机器代码反汇编成汇编代码

objdump -d mstor.o

寄存器与汇编

caller-saved/Callee-saved Register

func_A:
    movq $123, %rbx
    call func_B
    addq %rbx, %rax

func_B:
    addq $456 %rbx
    ret

上面这段代码,调用者和被调用者都用到了rbx寄存器,这个时候就涉及到rbx寄存器中内容的保存与恢复问题,有两种策略:

  1. caller-saved(调用者保存):调用者保存,fun_A在调用func_B前保存寄存器rbx的值,调用完后恢复
  2. callee-saved(被调用者保存):func_B在执行第一步保存rbx值,返回前恢复rbx的值

对于具体使用哪种策略,不同寄存器被定义成不同策略,具体如下:

  • callee saved:

    • rbx
    • rbp
    • r12\r13\r14\r15
  • caller saved:

    • r10
    • r11
    • rax
    • rdi
    • rsi
    • rdx
    • rcx
    • r8\r9

因为rbx是被调用者保存,所以func_B会是这样:

func_B:
    pushq %rbx
    addq $456 %rbx
    popq %rbx
    ret

pushq将栈指针下降并将rbx内容压入栈中,popq相反

汇编指令后缀

操作不同位数的字节,指令后缀不同,如下表

类型后缀操作字节数
charb1
shortw2
intl4
longq8
char*q8
floats4
doublel8

例如move指令有4个变种:

  1. movb: move byte
  2. movw: move word
  3. movel: move double word
  4. moveq: move quad word(传送4字)

寄存器分类

在x86-64架构中,有一些通用寄存器和特殊用途的寄存器,它们在处理器中扮演着不同的角色。以下是x86-64架构中常见的寄存器及其功能:

通用寄存器:

  • RAX:累加器,用于存储函数返回值和一般算术运算。
  • RBX:基址寄存器,通常用作数据指针。
  • RCX:计数器,通常用于循环计数和I/O 操作
  • RDX:数据寄存器,用于存储 I/O 操作的数据
  • RSI:源索引寄存器,通常用于字符串操作。
  • RDI:目的索引寄存器,通常用于字符串操作。
  • RBP:基址指针寄存器,用作栈帧指针, 目的是通过基地址加偏移量访问局部变量
  • RSP(stack-pointer):栈指针寄存器,指向当前栈顶。

扩展通用寄存器:

  • R8 - R15:在x86-64中引入的新通用寄存器,用于扩展寄存器数量,支持更多的数据处理。

特殊用途寄存器:

  • RIP:指令指针寄存器,存储下一条要执行的指令地址。
  • RFLAGS:标志寄存器,包含各种处理器状态标志,如进位标志、零标志、符号标志等。
  • CS, DS, SS, ES, FS, GS:代码段寄存器和数据段寄存器,用于存储各种段的基地址。
  • CR0 - CR4:控制寄存器,用于控制处理器的工作模式和系统特权级别。
  • MSR:模型特权级别寄存器,用于存储处理器的一些特殊功能。

 

浮点寄存器

  • XMM0 - XMM15:SSE(Streaming SIMD Extensions)寄存器,用于存储128位的浮点数和 SIMD 数据。
  • YMM0 - YMM15:AVX(Advanced Vector Extensions)寄存器,用于存储256位的浮点数和 SIMD 数据。
  • ZMM0 - ZMM31:AVX-512寄存器,用于存储512位的浮点数和 SIMD 数据。  

参数寄存器

  • 前六个整型参数会被传递到以下寄存器中:

    1. RDI
    2. RSI
    3. RDX
    4. RCX
    5. R8
    6. R9
  • 浮点型参数:浮点型参数会被传递到 XMM0 到 XMM7 寄存器中。

  • 额外参数:如果参数数量超过了寄存器能够传递的数量,额外的参数会通过栈来传递。

  • 返回值:返回值通常会被存储在 RAX 寄存器中,如果返回值比 64 位更长,则会利用 RDX:RAX 寄存器对来存储。

汇编指令

move

  • 64位处理器规定:任何对寄存器生成32位值的指令,都会把高位设为0

例如: movl $-1 %eax 会将rax寄存器高32位设0

0扩展和符号位扩展

当源操作数数位小于目的操作数位数,需要对目的操作数剩余的字节进行0扩展或者符号位扩展

0扩展指令以movz开头,z是zero的缩写:

指令解释
movzbwmove zero-extended byte to word
movzblmove zero-extended byte to double word
movzwlmove zero-extended word to double word
movzbqmove zero-extended byte to quad word
movzwqmove zero-extended word to quad word

符号位扩展指令以movs开头,s是sign的缩写

指令解释
movsbwmove sign-extended byte to word
movsblmove sign-extended byte to double word
movswlmove sign-extended word to double word
movsbqmove sign-extended byte to quad word
movswqmove sign-extended word to quad word
movslqmove sign-extended double to quad word
cltqmoveslq %eax, %rax

对比0扩展和符号扩展,符号扩展多一条movslq,也就是4字节到8字节到指令,因为 64位处理器规定:任何对寄存器生成32位值的指令,都会把高位设为0的特性,movl会自动清零高位

程序栈和数据传输指令

程序栈是从高地址往低地址增长的,栈顶地址永远最小 image.png

rsp寄存器的内容指向栈顶

压栈和弹栈

函数调用时常用压栈和弹栈指令

pushq指令压栈

pushq %rax
等同于下面两条指令
subq $8 %rsp 
movq %rax (%rsp)

pushq指令需要一个字节,下面两条指令需要8个字节,因此pushq可以节省内存

popq指令弹栈

pushq %rbx
将栈顶内容写入rbx中,等价于下面两条指令:
movq (%rsp) %rbx
addq $8 rsp

算数和逻辑运算指令

LEAQ

LEAQ为Load Effective Address(加载有效地址)的缩写

例如:

leaq 7(%rdx, %rdx, 4), %rax

计算地址 7 + 5 * %rdx,并将结果存储在寄存器 %rax 中,并不是读取这个地址的内容加载到寄存器

LEAQ指令用于乘法:

long scale(long x, long y) {
    long t = x + 4 * y + 12 * z;
    return t;
}

上面这段代码的乘法计算,就可以通过leaq计算

t = x + 4 * y + 12 * z; (函数参数 x in %rdi, y in %rsi, z in %rdx)

leaq(%rdi, %rsi, $4), %rax  // rax = x+4y
leaq(%rdx, %rdx, $2), %rdx  // rdx = 3z
leaq(%rax, %rdx, $4), %rax  // rax = x + 4y + 12z 
ret

这里将3z存在了rdx中,最后再乘以4得到结果,为什么不用下面这句直接得到结果?

leaq(%rax, %rdx, 12), %rax

因为比例因子只能是1、2、4、8中的一个,不能写12

一元操作指令
指令作用描述
INC DD = D + 1increment
DEC DD = D - 1decrement
NEG DD = -Dnegate
NOT DD = ~Dcomplement(取补)

条件码

条件码的设置
subq %rax, %rdx

上面这条指令,ALU除了将寄存器中的数做运算,写入寄存器rdx以外,还会根据运算结果设置条件码寄存器

条件码寄存器维护了最近一条计算结果的属性,每个条件码都占1bit

常用的条件码有:

  • CF:carry flag 进位标志,最新一条指令最高位进位了,置1,检查无符号数操作的一处
条件码置1条件作用
CFcarry flag(进位标志),最新一条指令最高位进位无符号数溢出,最高位会进位
ZFzero flag(0标志),最新一条指令结果为0判断计算结果为0
SFsign flag(符号标志),最新一条指令结果小于0判断计算结果小于0
OFoverflow flag(溢出标志),针对有符号数,最新一条指令结果正或者负溢出判断有符号数计算结果溢出

一些运算指令设置了一些设置条件码寄存器的规则:

  • xor:CF=0,OF=0
  • INC\DEC: OF = 1, ZF = 1

此外,cmp指令和test指令也会设置条件码寄存器

cmpq %rax, %rdx
testq %rax, %rdx
  • cmp根据两个操作数的差设置条件码,和subq的区别就是test不会更新目的寄存器
  • testand类似,同样也不更新目的寄存器,只更新条件码
条件码的使用

一个例子:

int comp(long a, long b) {
    return (a == b);
}
=====对应汇编=====
comp:
 (a in rsi, b in rdi)
 cmpq %rsi, %rdi   // 根据a - b结果,如果相等,ZF置1
 sete %al          // 用于根据ZF设置AL,如果ZF=1,设置al为1,为0则设0
 movezbl %al, %eax // al的结果写入eax并进行0扩展到双字
 ret
 

一个比较复杂的例子:

int comp(char a, long b) {
    return (a < b);
}
=====对应汇编=====
comp:
 (a in sil, b in dil。 sil 和 dil 是 ​​8 位寄存器​​,分别属于 rsi 和 rdi 寄存器的低 8 位部分)
 cmpq %sil, %dil   // 如计算a - b,置符号位
 setl %al          // 在小于时设置,判断小于需要根据SF^OF(异或)结果来获得,
 movezbl %al, %eax // al的结果写入eax并进行0扩展到双字
 ret
 

主要区别就是sete变成了setl,l是less的意思,表示在小于时设置,判断方法要考虑正负溢出情况,所以无法只根据SF判断

下面是一些set指令,原理都是根据符号位计算设置值

指令符号计算描述
setg~(SF^OF)&~ZFgreater
setge~(SF^OF)greater or equal
setlSF^OFless
setle(SF^OF)less or equal
总结
  • 知道了符号位寄存器的存在以及位的含义(CF、SF、OF、ZF)
  • 知道如何设置符号位——计算指令、cmp指令、test指令
  • 知道如何使用符号位——不直接读,用set各种指令,set指令根据符号位设置相应结果

跳转指令与循环

一段简单的if代码与其对应的汇编指令

long f(long x, long y) {
    if (x < y) {
        result = y - x;
    }
    else {
        result = x - y;
    }
}

// 汇编:
f:
    cmpq %rsi, %rdi
    jl .L4
    movq %rdi, %rax
    subq %rsi, %rax
    ret
  .L4:
    movq %rsi, %rax
    subq %rdi, %rax
    ret

jump指令跟上一节set类似,都是根据符号位执行跳转

指令符号计算描述 (signed)
jg~(SF^OF)&~ZFgreater
jge~(SF^OF)greater or equal
jlSF^OFless
jle(SF^OF)less or equal
条件move

对于上面的f函数,有一种替代方式:

long f2(long x, long y) {
    long rval = y - x;
    long eval = x - y;
    long ntest = x >= y;
    if (ntest)
        rval = eval;
    return rval;
}

这里又记录了y-x,又记录了x-y,为什么效率高呢?来看汇编:

f2:
    movq %rsi, %rdx
    subq %rdi, %rdx
    movq %rdi, %rax
    subq %rsi, %rax
    cmpq %rsi, %rdi
    cmovge %rdx, %rax
    ret

关键就是cmovge指令,满足条件[~(SF^OF)]时,进行数据传送,在这个例子中,只有当x >= y时,才满足条件,进行mov。为什么比if跳转效率高呢?这里涉及流水线分支预测,跳转指令会触发分支预测是否跳转,当分支预测失败,浪费大量时间,性能严重下降

switch与跳转表

在处理大量分支(如10,000个else-if或case)时,switch语句通常比if-else链更高效,原因如下:

if-else链: 编译器会按顺序逐个检查每个条件,直到找到匹配项。最坏情况下(如分支在末尾),需要比较10,000次,时间复杂度为 O(n)。

switch语句: 编译器会使用跳转表(Jump Table): 直接通过索引计算跳转到目标分支,时间复杂度为 O(1)。适用于连续的常量值(如case 1, 2, 3...)。

int switch_example(int x) {
    switch (x) {
        case 0: return 10;
        case 1: return 20;
        case 2: return 30;
        default: return -1;
    }
}

可能的汇编输出:

switch_example:
    ; 1. 检查输入是否在合法范围内
    cmp     eax, 2          ; 比较x和最大case值(2)
    ja      .default        ; 若x>2,跳转到default
    ; 2. 跳转表查找
    jmp     [.jump_table + eax*4]  ; 通过索引计算目标地址

.jump_table:
    dd      .case0         ; 地址数组,每个元素占4字节
    dd      .case1
    dd      .case2

.case0:
    mov     eax, 10
    ret
.case1:
    mov     eax, 20
    ret
.case2:
    mov     eax, 30
    ret
.default:
    mov     eax, -1
    ret
总结

通过此章节学习:

  1. 了解跳转指令
  2. 知道条件move指令存在,以及其比if else高效原因(无跳转)
  3. 知道switch连续的case下会使用跳转表,比if else快

过程(函数调用)

函数的栈帧(frame)

定义:函数调用在栈上的存储空间,注意是一次函数调用,不是一个函数,例如同一个函数的每次递归调用都有自己的栈帧

返回地址与call指令

函数P调用Q时,会把返回地址压入栈中,表明从Q返回后,从P的哪个位置继续执行。

这个压栈不是通过push,而是通过call指令实现

例如有下面代码:

<main>:
6fb:    callq 741 <multistore>
700:    mov (%rsp), %rdx
<multistore>:
741:    push %rbx
742:    mov %rdx, %rbx

call指令要做的事有:

  1. 将multistore函数的第一条指令的地址写入rip(741)
  2. 将返回地址压入栈中,也就是call的下一条指令的地址(700)

ret指令: 将返回地址弹出,写入rip(700)

参数传递

当参数数量大于6,后面的参数需要用栈来传递

例如下面函数:

void p(long a1, long* a1p,
        long a2, long* a2p,
        long a3, long* a3p,
        long a4, long* a4p)

a4会被放在rsp+8, a4p被放在rsp+16,需要注意数据大小都向8对齐

image.png

总结

本章学习了:

  1. 什么是栈帧
  2. call和ret指令的原理,入栈出栈了什么
  3. 参数>6用栈传递,数据向8对齐