想知道一段C语言写的代码对应生成的汇编语言代码是什么?那么需要了解:
1)一些基本的编译过程原理
2)常用的寄存器有哪些,专门来做哪些事
3)分析C语言代码对应的堆栈情况
C的汇编代码是一个或多个cpp文件通过编译器处理而成的,而一个编译器通常要通过词法分析,语法分析,语义分析才能够生成汇编代码。以gcc为例,一个cpp文件同通过编译器生成汇编代码(*.s)文件,再通过汇编器生成出机器能够识别的指令代码(*.o)文件,最后同通过链接器,将多个指令文件合成一个大指令文件(*.out) 供机器去执行。
C的汇编码生成是一个复杂的算法。但是,我们可以同通过执行过程中的堆栈情况和寄存器使用情况来反推出汇编码是什么。汇编码也可以模拟出堆栈情况和寄存器使用情况来推测出C的代码是什么,当然这个反汇编过程可以保留好程序逻辑,数据结构,但是保留不住源代码的变量、引用名称。
一个 .out 的文件执行映像如下图,符号 “_brk”表示bss段的结束,机器加载文件通常从文件头开始加载,加载到bss段 _brk标志结束。
栈区:栈区是向低地址扩展的,是一块连续的内存的区域。栈顶的地址和栈的最大容量是操作系统给程序预先规定好的,大小在进程分配时是确定的。
堆区:堆区是向高地址扩展的,是不连续的内存区域(这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的是动态分配的),因为会手动分配内存通常会预留大一些,大小不固定。
由于栈区是向低地址扩展,当int数据类型第一个压栈时其地址表示为 -4(%ebp) ,再压一个8字节double类型,其地址表示为 -12(%ebp). ,其中ebp 表示 ebp寄存器(扩展基址指针寄存器)。
再Linux 中写好 .c文件并编译出 汇编文件的命令例子 gcc -O0 -S test.c -o test.s ,其中-O0代表不去优化(缺省默认)
在smtest.c中写入
#include
#include
void main(){
int a,b,c;
a=2; b=3;c=4;
c= c+a*3;
printf("%d",c);
}
编译出来的 test.s 的汇编码为
.file "smtest.c"
.text
.section .rodata
.LC0:
.string "%d"
.text
.globl main
.type main, @function
main:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $2, -12(%rbp)
movl $3, -8(%rbp)
movl $4, -4(%rbp)
movl -12(%rbp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
addl %eax, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE6:
.size main, .-main
.ident "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
我们最主要关注的是main代码段的汇编码
下表展示的是16bit寄存器 , 32bit寄存器的前缀是 e, 64bit的寄存器前缀是 r。例如 bp/ebp/rbp
CFI寄存器编号 | 16bit寄存器名称 | 常用用途 | ||
通用寄存器 | 1 | AX=(AH,AL) | 累加器 | 常用于乘、除法和函数返回值 |
2 | BX=(BH,BL) | 基址地址寄存器 | 常用于内存数据的地址 | |
3 | CX=(CH,CL) | 计数寄存器 | 常用于循环指令的循环计数 | |
4 | DX=(DH,DL) | 数据寄存器 | 常用在字乘法和除法指令中,作辅助累加器(即存放乘积或被除数的高16位)和在输入输出指令中存放16位的端口地址 | |
5 | SP | 堆栈顶指针寄存器 | 其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。通过PUSH和POP指令控制指针移动 | |
6 | BP/FP | 堆栈基址寄存器 | 其内存放着一个指针,该指针永远指向当前函数的栈帧的底部地址。 | |
7 | SI | 源变址寄存器 | 在串处理指令中,SI用作隐含的源串地址 | |
8 | DI | 目的变址寄存器 | 在串处理指令中,DI用做隐含的目的串地址 | |
专用寄存器 | IP/PC | 指令寄存器 | 保存CPU即将执行的一条指令的偏移地址 | |
CS | 代码段寄存器 | 用来存放内存代码段区域的入口地址 | ||
DS | 数据段寄存器 | 用来存放内存数据段区域的入口地址 | ||
SS | 堆栈段寄存器 | 用来存放内存堆栈段区域的入口地址 | ||
ES | 附加数据段寄存器 | 常在串处理当作DS寄存器来备用 | ||
FS | FS辅助段寄存器 | 作为段寄存器备用,常被操作系统用于指向当前活动线程的TEB结构(线程结构) | ||
GS | GS辅助段寄存器 | 作为段寄存器备用,在Windows中,该GS寄存器用于管理线程特定的内存。linux内核用于GS访问cpu特定的内存 |
所谓的栈帧,就是一段代码块所对应的栈区域。同栈帧下的变量,对象的生命周期都是一样的。把栈比作一栋楼,则栈帧表示连续好几层楼。栈是由栈帧构成的,越靠近栈顶的栈帧,其栈帧内 变量的生命周期越短,内存越早释放。栈帧的起始地址通常由BP寄存器保存,在X86 CPU 通常由FP寄存器保存。
3)分析C语言代码对应的堆栈情况
CFI全称是Call Frame Instrctions, 即调用框架指令。CFI提供的调用框架信息, 为实现堆栈回绕(stack unwiding)或异常处理(exception handling)提供了方便, 它在汇编指令中插入指令符(directive), 以生成DWARF可用的堆栈回绕信息。CFI调用栈帧信息,编译器用于描述函数中发生的事情的方式。CFA调用栈帧地址,表示调用函数的时的堆栈指针位置,在前一个调用框架中调用当前函数时的栈顶指针。例如A方法调用了B方法,之后调用了C方法,B和C方法都调用了D方法,结果在执行过程中,D方法抛出了异常。那么怎么样才可以知道D方法是B抛出的还是C抛出的呢?这个需要一个记录,主要用于记录栈帧的起始地址。
可以把CFA 看成是一个数据结构 ,它的成员包含了一个地址,还有一个对标的寄存器
指令符的意义链接如下:
https://sourceware.org/binutils/docs/as/CFI-directives.html#CFI-directives
列出几个:
.cfi_startproc 表示每个函数的开头标志。它初始化一些内部数据结构。对应的用.cfi. endproc 来表示关闭函数的标志。
.cfi_def_cfa_offset 16 距离栈帧的距离16,在此偏移后的地址用CFA的基址寄存器保存,程序刚开始的默认基址寄存器是 5号SP寄存器
.cfi_offset [6,-16] 把6号寄存器BP 的值保存在CFA - 16 处
.cfi_def_cfa_register 6 CFA的基址寄存器改用6号寄存器保存,同时原先寄存器的值也挪到6号,
在每个C函数、代码块的入口处,编译后的代码会完成如下功能:
C代码: int a,b,c; 对应的汇编码
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
对应步骤内容:,其中xxxx表示执行main函数前的堆栈内容
0)进入main函数后,首先栈中被压栈的第一个元素是 IP寄存器的内容,main方法的指令地址。之后压栈rbp,此时的rbp是上一个栈帧地址 ,CFA 地址也是上一个栈帧的位置 。压栈完后,SP指针在上图的BP处。
1)略:在CFI框架中,CFA指向的寄存器不变。但CFA地址变更为 CFA指向寄存器位置偏移16个字节。意味者CFA地址位于上图BP处还往高地址16个字节的 位置。
2)略:在CFI框架中,将6号寄存器rbp的值保存在 CFI框架的CFA - 16 对应的位置。 即 上图BP位置。
3)把栈顶地址赋值给BP寄存器,建立栈帧。 此时的BP寄存器存的是上图BP位置的栈地址,而上图栈中的BP存的是上一个栈帧的栈地址。
4)略:在CFI框架中,把CFA指向的寄存器改为6号寄存器 rbp
5)将rsp往低地址偏移16个字节,预分配16个字节的内存空间
movl $2, -12(%rbp) # a = 2
movl $3, -8(%rbp) # b = 3
movl $4, -4(%rbp) # c = 4
movl -12(%rbp), %edx # temp1 = a
movl %edx, %eax # temp2 = temp1
addl %eax, %eax # temp2 = temp2 + temp2 =4
addl %edx, %eax # temp2 = temp2 + temp1 =6
addl %eax, -4(%rbp) # c = c +temp2 =10
因为大部分的程序,都加了优化编译选项。在栈的使用方面都作出了些许变化。例如,x86-64引入了一个新的特性, 可以使用栈顶之外128字节的地址,即不用直接先分配空间,而是先使用空间再在适当的时机分配。x86-64遵循ABI规则。
带函数跳转的汇编码
#include
#include
int fun2(int fa2){
return fa2 *10;
}
int fun(int fa1, int fb1){
int cc=40;
int ret = fun2(fa1)+fb1*cc;
return ret;
}
void main(){
int a,b,c;
a=2; b=3;c=4;
c= fun(a,b);
printf("%d",c);
}
生成处来的汇编文件如下图
.file "smtest.c"
.text
.globl fun2
.type fun2, @function
fun2:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl -4(%rbp), %edx
movl %edx, %eax
sall $2, %eax
addl %edx, %eax
addl %eax, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE6:
.size fun2, .-fun2
.globl fun
.type fun, @function
fun:
.LFB7:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $24, %rsp
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl $40, -8(%rbp)
movl -20(%rbp), %eax
movl %eax, %edi
call fun2
movl -24(%rbp), %edx
imull -8(%rbp), %edx
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE7:
.size fun, .-fun
.section .rodata
.LC0:
.string "%d"
.text
.globl main
.type main, @function
main:
.LFB8:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $2, -12(%rbp)
movl $3, -8(%rbp)
movl $4, -4(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call fun
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE8:
.size main, .-main
.ident "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
入口在main函数:在传参的过程中(a,b)-> (fa1,fb1) 的过程中,越右侧的参数越先入栈
#....fun:....
...
pushq %rbp
movq %rsp, %rbp
subq $24, %rsp
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
...
# main
...
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call fun
...
每个函数入口都会 pushq %rbp 用来保存栈帧,但是并不是每个函数都会预先分配内存 ,例如 fun2 中就没有 类似 subq $24, %rsp 。由于fun2是程序对应的最后一个栈帧,且访问栈外地址不超过128个字节。这样退栈就可以不需要做多余的操作。反正最后一个栈帧的栈顶地址也没啥用。
当执行到fun2 结束前:其栈内情况大致如下图所示
注:leave指令将ebp的值赋给esp,将栈顶元素退给ebp寄存器 ,等价于:
movl %ebp %esp
popl %ebp
RET指令则是将栈顶的返回地址弹出到IP寄存器然后按照EIP此时指示的指令地址继续执行程序。
所以在 fun2 完成 popq %rbp 和 ret 指令时, rbp 寄存器存的时②的地址,上图标记的中间的BP处 , rsp在fb1的地址处。
之后 fun1 完成 leave 和 ret 时 , rbp 寄存器存的时①的地址,上图标记的第一个的BP处 , rsp在a的地址处。
最后执行完main的 leave 和 ret。