一些常用编译命令
生成汇编代码,-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寄存器中内容的保存与恢复问题,有两种策略:
- caller-saved(调用者保存):调用者保存,fun_A在调用func_B前保存寄存器rbx的值,调用完后恢复
- 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相反
汇编指令后缀
操作不同位数的字节,指令后缀不同,如下表
类型 | 后缀 | 操作字节数 |
---|---|---|
char | b | 1 |
short | w | 2 |
int | l | 4 |
long | q | 8 |
char* | q | 8 |
float | s | 4 |
double | l | 8 |
例如move指令有4个变种:
- movb: move byte
- movw: move word
- movel: move double word
- 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 数据。
参数寄存器
-
前六个整型参数会被传递到以下寄存器中:
- RDI
- RSI
- RDX
- RCX
- R8
- R9
-
浮点型参数:浮点型参数会被传递到 XMM0 到 XMM7 寄存器中。
-
额外参数:如果参数数量超过了寄存器能够传递的数量,额外的参数会通过栈来传递。
-
返回值:返回值通常会被存储在 RAX 寄存器中,如果返回值比 64 位更长,则会利用 RDX:RAX 寄存器对来存储。
汇编指令
move
- 64位处理器规定:任何对寄存器生成32位值的指令,都会把高位设为0
例如:
movl $-1 %eax
会将rax寄存器高32位设0
0扩展和符号位扩展
当源操作数数位小于目的操作数位数,需要对目的操作数剩余的字节进行0扩展
或者符号位扩展
0扩展指令以movz开头,z是zero的缩写:
指令 | 解释 |
---|---|
movzbw | move zero-extended byte to word |
movzbl | move zero-extended byte to double word |
movzwl | move zero-extended word to double word |
movzbq | move zero-extended byte to quad word |
movzwq | move zero-extended word to quad word |
符号位扩展指令以movs开头,s是sign的缩写
指令 | 解释 |
---|---|
movsbw | move sign-extended byte to word |
movsbl | move sign-extended byte to double word |
movswl | move sign-extended word to double word |
movsbq | move sign-extended byte to quad word |
movswq | move sign-extended word to quad word |
movslq | move sign-extended double to quad word |
cltq | moveslq %eax, %rax |
对比0扩展和符号扩展,符号扩展多一条movslq,也就是4字节到8字节到指令,因为 64位处理器规定:任何对寄存器生成32位值的指令,都会把高位设为0
的特性,movl会自动清零高位
程序栈和数据传输指令
程序栈是从高地址往低地址增长的,栈顶地址永远最小
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 D | D = D + 1 | increment |
DEC D | D = D - 1 | decrement |
NEG D | D = -D | negate |
NOT D | D = ~D | complement(取补) |
条件码
条件码的设置
subq %rax, %rdx
上面这条指令,ALU除了将寄存器中的数做运算,写入寄存器rdx以外,还会根据运算结果设置条件码寄存器
条件码寄存器维护了最近一条计算结果的属性,每个条件码都占1bit
常用的条件码有:
- CF:carry flag 进位标志,最新一条指令最高位进位了,置1,检查无符号数操作的一处
条件码 | 置1条件 | 作用 |
---|---|---|
CF | carry flag(进位标志),最新一条指令最高位进位 | 无符号数溢出,最高位会进位 |
ZF | zero flag(0标志),最新一条指令结果为0 | 判断计算结果为0 |
SF | sign flag(符号标志),最新一条指令结果小于0 | 判断计算结果小于0 |
OF | overflow flag(溢出标志),针对有符号数,最新一条指令结果正或者负溢出 | 判断有符号数计算结果溢出 |
一些运算指令设置了一些设置条件码寄存器的规则:
- xor:CF=0,OF=0
- INC\DEC: OF = 1, ZF = 1
此外,cmp指令和test指令也会设置条件码寄存器
cmpq %rax, %rdx
testq %rax, %rdx
cmp
根据两个操作数的差设置条件码,和subq的区别就是test不会更新目的寄存器test
和and
类似,同样也不更新目的寄存器,只更新条件码
条件码的使用
一个例子:
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)&~ZF | greater |
setge | ~(SF^OF) | greater or equal |
setl | SF^OF | less |
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)&~ZF | greater |
jge | ~(SF^OF) | greater or equal |
jl | SF^OF | less |
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
总结
通过此章节学习:
- 了解跳转指令
- 知道条件move指令存在,以及其比if else高效原因(无跳转)
- 知道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指令要做的事有:
- 将multistore函数的第一条指令的地址写入rip(741)
- 将返回地址压入栈中,也就是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对齐
总结
本章学习了:
- 什么是栈帧
- call和ret指令的原理,入栈出栈了什么
- 参数>6用栈传递,数据向8对齐