汇编基础讲解,寄存器、指令、函数栈
ref
- 很好的入门视频教程,基础寄存器和基础指令讲得好,https://www.bilibili.com/video/BV12M4m1o7f6
- 简化了很多细节,但可以粗略入门,https://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html
- 也是一个简化版,但是比上一个详细,https://www.cnblogs.com/alexlance/p/17432758.html
- 从汇编角度详细讲解函数调用栈和有栈协程,https://www.yigegongjiang.com/2023/stackForFunc/
- 将各种语言在线转为汇编的工具网站,https://godbolt.org/
前言
本文重在记录基础知识和函数调用栈,并不全面。
认识汇编有两大实用好处:
- 更好理解CPP的一些概念(例如右值引用等);
- 方便GDB调试core文件查找非预期bug;
字节序
小端存储,值的低字节部分存放在内存空间的低位,值的高字节部分存放在内存空间的高位。
内存
汇编(或者说计算机地址总线)的寻址基本单位是字节(Byte),所以32位寄存器的寻址空间是2的32次方字节(4GB)(额外提一句,64位寄存器的寻址空间实际上没有2的64次方字节)。
如图所示,栈从高字节向低字节生长,堆从低字节向高字节生长。
寄存器
寄存器有四个分类:
- 通用目的寄存器
- 段寄存器
- 标志寄存器
- 指令指针寄存器
本文关注通用目的寄存器,通用目的寄存器的大部分可以混用。
16位通用目的寄存器
ax, bx, cx, dx, si, di, sp, bp,一共8个:
- sp(stack pointer)是栈顶指针寄存器,非常重要;
- bp(pointer to data on the stack)是栈基址指针寄存器,64位里不再需要bp;
- ax, bx, cx, dx可以只使用其中的高(high)8位或者低(low)8位:
- ah, al, bh, bl, ch, cl, dh, dl;
32位通用目的寄存器
将16位通用目的寄存器拓展(Extend)到32位,EAX, EBX, ECX, EDX, ESI, EDI, ESP, EBP。
64位通用目的寄存器
将32位通用目的寄存器拓展到64位,并额外添加了8个寄存器。
RAX, RBX, RCX, RDX, RSI, RDI, RSP, RBP, R8, R9, R10, R11, R12, R13, R14, R15。R前缀取自Rigister。
RAX
RAX是一个比较特殊的寄存器:
- 用于返回函数返回值,被调函数调用结束之前会把返回值写入RAX中,主调函数从RAX取出返回值。
- RAX可以访问直接的内存地址单元,其他寄存器需要通过寄存器存放内存地址来和内存地址进行数据交换(具体见MOV指令小节)。
RBP
rbp寄存器用于指向当前栈的基址,实际上也是上一个调用栈的栈顶地址
RSP
rsp寄存器用于指向当前栈顶的地址,实际的汇编语言里可能是出于优化目的rsp不一定始终指向栈顶(在leave指令中有示例解释改现象)
传参常用寄存器
以主调函数调用被调函数func(1,2,3,4,5,6)为例:
主调函数所在的栈依次使用使用RDI、RSI、RDX、RCX、R8、R9寄存器向被调函数的栈传参
mov r9d, 6
mov r8d, 5
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
如果被调函数参数超过6个,以主调函数调用被调函数func(1,2,3,4,5,6,7,8)为例:
主调函数把超出6个的参数按照逆序压入栈中,被调函数使用当前被调函数栈的rbp+正偏移的方式寻址主调函数栈中压栈的函数参数。
push 7
mov r9d, 6
mov r8d, 5
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
如果被调函数参数小于6个,但是传递给被调函数的参数是当前主调函数的局部变量,那么被调函数也是通过使用当前被调函数栈的rbp+正偏移的方式寻址主调函数栈中压栈的函数参数。
同名寄存器的关系
同名寄存器如RAX EAX AX AH AL是同属一个寄存器RAX。
RAX, RBX, RCX, RDX可以取低16位的高8位和低8为,其他12个寄存器只能取低8位、低16位、低32位和64位:
- r8b表示低8位,r8w表示低16位,r8d表示低32位;
- 1 word = 2 byte
- 1 double word = 4 byte
段寄存器
段寄存器CS, DS, SS, ES, FS, GS,每个都保存着16位段选择子(这词难懂,不用在意),用于标识内存中特定的段。其中CS指向代码段,SS指向栈段, DS, ES, FS, GS指向数据段。
内存被分为不同的段,通过访问段基址+偏移方式访问内存,注意内存物理上连续,分段是CPU寻址方式。
16位8086CPU的地址总线是20位,但寄存器和数据总线是16位。为了不浪费多余的4位地址总线便引入段概念来寻址:段地址*16 + 偏移地址。
32位8086CPU数据总线和地址总线位数一致,分段寻址不再必要,段的作用改成是施加内存保护,并引入内存分页机制、虚拟内存等概念,统称保护模式。(出于向下兼容, 16位寻址方式保留,称作实模式,提供直接访问物理内存的手段)
64位8086CPU下,段概念继续弱化,内存变成平坦模式,即无段式内存,所有对内存的访问都在同一个地址空间进行,段保护被弱化,强调对页的保护。
标志寄存器
不感兴趣。
指令指针寄存器
指令指针寄存器存储的是CPU即将执行的下一条指令的地址,用ip/eip/rip指代(16, 32, 64位)
指令
汇编指令有两种风格:intel风格和AT&T风格。
- intel风格典型风格是目标操作数在左,源操作数在右
- 例1,mov eax, ebx,是把ebx的值赋值给eax
- 例2,add eax, ebx,是把eax和ebx的值相加赋值给eax
- 例3,MOV RAX, QWORD PTR DS:[RBX+0X10] ,访问RBX+0x10的内存地址上的16字节的值,并写入到RAX
- AT&T风格是目标操作数在右,源操作数在左
- 例1,mov %eax, %ebx,是把eax的值复制给ebx
- 例2,sub $0x8, %rsp, 把rsp的值减去16进制立即数0x8
- 例3,sub 8, %rsp, 把rsp的值减去10进制立即数8
- 例4,sub $8, %rsp, 同上,把rsp的值减去10进制立即数8
- 例5, movl -0x50(%rbp), %eax, 从 rbp 寄存器指向的内存地址加上 -0x50 偏移量(负号表示减)的地方读取一个 32 位的值,并将其存储到 eax 寄存器中,movl的l表示这是一个 32 位操作。
MOV指令
此小节主要使用intel风格示例讲解MOV指令访问寄存器和内存地址的规则和限制,比较琐碎,可以不看。
使用立即数给寄存器赋值
MOV RCX, 0xFFFFFFFFFFFFFFFF // 给寄存器RCX赋值立即数,立即数值为0xFFFFFFFFFFFFFFFF,RCX会变成0xFFFFFFFFFFFFFFFF
// 严格来说立即数应该用FFFFFFFFFFFFFFFFh来表示,这里使用0xFFFFFFFFFFFFFFFF只是方便阅读
MOV ECX, 0xFFFFFFFFFFFFFFFF // 报错,ECX是32位,立即数64位,超限
MOV ECX, 0xFFFFFFFF // 给寄存器ECX赋值立即数,立即数值为0xFFFFFFFF,RCX会变成0x00000000FFFFFFFF
// 给64位寄存器的低32位整体赋值时,高32位会被清0,如果给给64位寄存器的低8位赋值时,高32位不会被清0
MOV ECX, 0xFFFFFFFF // 给低32位赋值,RCX值为0x00000000FFFFFFFF,高位会被置零
寄存器相互赋值,只有ABCD之间可以高低错位赋值,其他寄存器之间不能错位赋值
MOV SPL, BPL // 把RBP的低8位赋值给RSP的8位
MOV AH, CL // 把RCX低8位赋值给RAX低16位里的高8位
MOV AH, CH // 把RCX低16位里的高8位赋值给RAX低16位里的高8位
MOV AL, R8B // 把R8低8位赋值给RAX低8位
MOV AH, R8B // 报错,只有ABCD四个寄存器可以相互之间高低位错位赋值
MOV RCX ECX // 报错,两个寄存器小不匹配,寄存器之间赋值必须大小匹配
MOV EAX, EAX // 看似没有做什么,实际上RAX的高32位被清0了
MOV AH, SPH // 错误,除了ABCD可以访问低16位的高8位,其他寄存器都不可以
寄存器和寄存器指向的内存单元之间相互赋值
MOV EAX, DWORD PTR DS:[RCX] // 把RCX指向的内存地址里的4字节值赋值给EAX,RAX的高32位会被清0
MOV QWORD PTR DS:[RCX], RDX // 把RDX的8字节的值赋值给RCX指向的内存地址
MOV QWORD PTR [RCX], RDX // 默认也是寻址DS段的内存地址,把RDX的8字节的值赋值给RCX指向的内存地址
使用偏移来访问内存单元
MOV RAX, QWORD PTR DS:[RBX+0X10] //访问RBX+0x10的内存地址
MOV QWORD PTR DS:[RBX+0X10], RAX //访问RBX+0x10的内存地址
MOV QWORD PTR DS:[RBX+RAX+0x10], RCX //访问RBX+RAX+0x10的内存地址
MOV QWORD PTR DS:[RAX+RCX*8+0x10], R10 //访问RAX+RCX*8+0x10的内存地址
只有RAX(EAX AX AH AL)可以给直接内存地址赋值,其他寄存器只能访问寄存器指向的内存地址
MOV RAX, QWORD PTR DS:[0x23878AE00000] //把直接的内存地址0x23878AE00000上16字节的值赋值给RAX
MOV DWORD PTR DS:[0x23878AE00000], EAX //把EAX的4字节的值赋值给直接的内存地址0x23878AE00000上,RAX高32位清0
MOV RBX, QWORD PTR DS:[0x23878AE00000] //报错,只有RAX(EAX AX AH AL)可以和直接和直接的内存地址相互赋值,
// 其他的寄存器需要通过寄存器来指向内存地址来相互赋值
//换句话说只有0号寄存器可以直接和内存地址通信
MOV QWORD PTR DS:[0x23878AE00000], RBX //报错,原因同上
立即数只能给32位的内存地址单元赋值
MOV [0x23878AE00000] 0x45464154 //报错,立即数的大小是ambiguous不确定的
MOV DWORD PTR DS:[0x23878AE00000] 0x45464154 //报错,不可以直接对64位的地址赋值立即数
MOV QWORD PTR DS:[0x23878AE00000] 0x45464154 //报错,原因同上
MOV QWORD PTR DS:[0x12345678] 0x45464154 //给内存地址0x12345678赋值一个16字节大小的值45464154
MOV RAX 0x23878AE00000
MOV QWORD PTR DS:[RAX], 0X1 //如果要把立即数赋值给64位内存地址,需要使用寄存器中转内存地址
PUSH/POP
PUSH
push 源操作数
push指令先把rsp寄存器值减去操作数的字节数,以提高栈顶,然后使用mov指令把操作数赋值给0x0(%rsp), 假设操作数大小时8 Byte,作用同下:
sub $8, %rsp
mov 源操作, 0x0(%rsp)
push指令有3个作用:
- 保存寄存器的值,函数调用时,使用push指令将当前寄存器值压入栈保留;
- 参数传递,函数参数可以通过栈传递,当被调用的栈通过RBP+偏移的方式寻址到在上一个栈中入栈的函数参数;
- 保存返回地址,CALL指令会自动地把下一条指令的地址(返回地址,即RIP的值)压栈;
POP
pop 目标操作数
pop指令先把,然后使用mov指令把0x0(%rsp)地址上的值赋值给目标操作数,然后给rsp加上操作数的字节数, 假设操作数大小时8 Byte,作用同下:
mov 0x0(%rsp), 目标操作数
add $8, %rsp
pop指令有3个作用:
- 恢复寄存器的值,函数返回时使用pop指令将此前保存的寄存器值出栈,恢复寄存器原值;
- 恢复返回地址,函数执行完毕后,ret指令回隐式地把返回地址pop出栈到RIP寄存器;
CALL/RET
CALL
call 目标地址
call指令用于调用函数,作用是把rip的值(被调函数的返回地址)压栈,并把rip更新为目标地址的值。
RET
ret
ret指令作用是把被压栈的原rip的值(被调函数的返回地址)出栈到rip寄存器。
LEAVE
leave
leave放在ret指令之前, 用于调整rsp和 rbp,作用同下:
mov %rbp, %rsp //将rbp的值赋值给rsp,rsp恢复主调函数的栈顶
pop %rbp //出栈主调函数的栈基址到rbp,注意这条指令实际上也给rsp做了改动
leave指令不是必须要出现在ret指令之前的,rsp的值也不是一定要指向栈顶的,如下所示
int sum2(int a, int b) {
// sum2函数没有leave, 因为sum2没有局部变量,因此sum的栈是空的
// 所以ebp和esp值是相等的,无须使用leave把rbp的值赋值给rsp,直接使用pop rbp恢复上一个栈的基址即可
return a + b;
}
int main(void) {
// main函数没有leave,即使main函数有局部变量,而且main函数的栈也久了这个局部变量
//但是出于优化并没有修改rsp的值,所以rsp和rbp的值是相等的无需leave
int size_long = sizeof(long);
//int p = sum2(1,2);
return 0;
}
sum2(int, int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 8
mov eax, 0
pop rbp
ret
函数调用栈
如图所示,从上往下依次是内核区、栈区、共享数据区 (动态库)、堆区、数据区、代码区、留置区。这些区域的划分都是虚拟内存,并非物理内存。越往上内存地址越大,越往下内存地址越小,本小节研究函数调用栈(栈区)。
上图的栈的组成分别是:上个调用栈RBP、留置内存区域、局部变量、下个调用栈的参数(优先使用RDI RSI RDX RCX R8 R9传参) 和 当前调用栈的下一个指令执行地址即RIP值 组成。
让人疑惑的留置内存区域究竟是什么?实际上是栈是按照16字节的整数倍来给局部变量预留栈的局部变量空间的,尚未使用的栈空间就是留置内存区域,而函数参数(在函数参数实际上有两种,一是当前栈向下一个栈传递的超过6个之外的参数需要当前栈空间存放,二是当前栈需要开辟空间存放上一个栈通过寄存器传递给当前栈的实参)则是占据多少字节就预留多少栈空间,使用https://godbolt.org/查看汇编代码可以自行验证,如下是验证中的一个示例(具体看汇编的注释):
int mul(int x, int y) {
int sum = x + y;
return sum;
}
int sum(int a, int b, int c, int d, int e, int f, int g, int h) {
int s = mul(a, b);
int div = g / h;
int sub = a - b;
int t = s + c + d + e + f + g + h;
int res = t + s;
int useless1 = 0;
int useless2 = 0;
return res;
}
int main(void) {
int useless1 = 0;
int useless2 = 0;
int useless3 = 0;
int useless4 = 0;
int useless_sum = useless1 + useless2 + useless3 + useless4;
const int size_int = sizeof(int);
const int size_long = sizeof(long);
const int size_longlong = sizeof(long long);
int p = sum(1,2,3,4,5,6,7,8);
return 0;
}
mul(int, int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi
mov DWORD PTR [rbp-24], esi
mov edx, DWORD PTR [rbp-20]
mov eax, DWORD PTR [rbp-24]
add eax, edx
mov DWORD PTR [rbp-4], eax
mov eax, DWORD PTR [rbp-4] // 把计算结果放到eax返回
pop rbp
ret
sum(int, int, int, int, int, int, int, int):
push rbp
mov rbp, rsp
sub rsp, 56 // sum有7个局部变量,所以需要预分配32Byte的栈空间
// sum需要在栈上保存6个寄存器传递的实参,需要6*4=24Byte的栈空间,所以一共是32+24=56
mov DWORD PTR [rbp-36], edi // 在栈上存放上一个栈通过寄存器传递的实参a
mov DWORD PTR [rbp-40], esi
mov DWORD PTR [rbp-44], edx
mov DWORD PTR [rbp-48], ecx
mov DWORD PTR [rbp-52], r8d
mov DWORD PTR [rbp-56], r9d // 在栈上存放上一个栈通过寄存器传递的实参f
mov edx, DWORD PTR [rbp-40]
mov eax, DWORD PTR [rbp-36]
mov esi, edx
mov edi, eax
call mul(int, int)
mov DWORD PTR [rbp-4], eax
mov eax, DWORD PTR [rbp+16] // 访问上一个栈传给当前栈的实参g
cdq
idiv DWORD PTR [rbp+24] //访问上一个栈传给当前栈的实参h
mov DWORD PTR [rbp-8], eax
mov eax, DWORD PTR [rbp-36]
sub eax, DWORD PTR [rbp-40]
mov DWORD PTR [rbp-12], eax
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-44]
add edx, eax
mov eax, DWORD PTR [rbp-48]
add edx, eax
mov eax, DWORD PTR [rbp-52]
add edx, eax
mov eax, DWORD PTR [rbp-56]
add edx, eax
mov eax, DWORD PTR [rbp+16]
add edx, eax
mov eax, DWORD PTR [rbp+24]
add eax, edx
mov DWORD PTR [rbp-16], eax
mov edx, DWORD PTR [rbp-16]
mov eax, DWORD PTR [rbp-4]
add eax, edx
mov DWORD PTR [rbp-20], eax
mov DWORD PTR [rbp-24], 0
mov DWORD PTR [rbp-28], 0
mov eax, DWORD PTR [rbp-20] // 把计算结果放到eax返回
leave
ret
main:
push rbp
mov rbp, rsp
sub rsp, 48 // main函数一共有9个局部变量,所以会分配(16*3)48个Byte的栈空间
// 如果去除一个局部变量,例如int useless_sum,剩下8个局部变量
// 此条汇编指令就会变成sub rsp, 32
mov DWORD PTR [rbp-4], 0
mov DWORD PTR [rbp-8], 0
mov DWORD PTR [rbp-12], 0
mov DWORD PTR [rbp-16], 0
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add edx, eax
mov eax, DWORD PTR [rbp-12]
add edx, eax
mov eax, DWORD PTR [rbp-16]
add eax, edx
mov DWORD PTR [rbp-20], eax
mov DWORD PTR [rbp-24], 4
mov DWORD PTR [rbp-28], 8
mov DWORD PTR [rbp-32], 8
push 8 // 参数压栈多余的两个参数,会使得栈继续增长,rsp 需要减去 8个字节
push 7 // 汇编里的整形常量的长度取决于sum函数形参类型int, 而int的长度是4个字节
mov r9d, 6
mov r8d, 5
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
call sum(int, int, int, int, int, int, int, int)
add rsp, 16
mov DWORD PTR [rbp-36], eax // sum函数的返回值在eax里返回
mov eax, 0
leave
ret
函数参数和返回值
一般使用RAX来承载函数的返回值,使用RDI RSI RDX RCX R8 R9和栈空间传递参数,但是如果参数或者返回值是个类或者结构体,超过了寄存器大小,或者函数参数和返回值是引用和指针,汇编该怎么处理呢?
请看https://www.cnblogs.com/yiwanfengweng/articles/18618155
进阶有栈协程
待老夫学习了协程后写相关总结。