一、前言
??众所周知,C语言中“编译”这个词其实已经被泛化了。很多时候,该词直接取代了整个从c文件到到可执行文件的流程,但为了笔者叙述方便,本人有必要在此先做一些说明。 ??C语言的整套流程,准确的说是预处理、编译、汇编以及链接四个过程。预处理完成头文件展开与宏替换,得到i文件;编译将i文件转换为汇编指令——s文件;汇编将s文件转换为o文件;链接将多个o文件联合得到最终的可执行文件。详细内容网上其余文章已有详细说明,此处不再赘述。 ??此外,由于处理器的发展,现在32、64位机C语言的int和long都是一样长度的,但是为了更好地说明,笔者假设,char、short、int、long分别是8、16、32以及64位的。 ??由于处理器众多,指令集也是五花八门。笔者采用的是intel的8086指令集进行说明。 ??笔者的文章内容主要是联合原c文件,分析“编译”这一过程所得的s文件的一些内容,更多的把重点放在编译的结果,而不是编译的过程上。由于笔者对C语言与汇编语言了解并不深入,若文章有不当之处,还望斧正。
二、相关工具
??为了更好地进行说明,笔者需要借用一些工具。这些工具如下:
-
gcc ??既然标题都跟gcc联系,那自然是离不开这个经典的编译器的。笔者使用的是版本是8.1.0,但一般来说,其实版本没差。gcc的安装网上到处都有,不再赘述。 -
Compiler Explorer ??其实是一个网站,这个网站能够对应编写的C程序给出相应的汇编代码,比较简介,非常好用。虽然也可以使用-S参数做编译过程,但是编译出来的s文件会臃肿得多,不利于分析。为了避开上述问题,笔者在叙述中将主要使用该网站。网址为https://godbolt.org/。
三、正文
1、案例一
案例说明
??虽然程序开始的经典案例都是hello world,但是在笔者看来,开篇分析字符串可能相对复杂些,故笔者使用了其它的案例,代码如下:
#include<stdio.h>
int add(long a,long b){
long c = a + b;
return c;
}
int main(){
long c = add(42949672961,42949672962);
printf("%ld",c);
return 0;
}
??源码的结构很简单,就是输入两个long的值,然后返回了int类型。主函数中,采用long类型的c接收返回值,然后打印到控制台上。 (注:如果在自己的32/64位机上,请将long改为long long,否则long与int的长度相同,会与案例相悖;如果使用的是笔者提供的网站,则按案例源码写即可。)
案例C代码分析
??首先留意add函数传入的参数值,4294967296X,为啥这么怪呢?这是因为,一个int型是32位的,2的32次方是4294967296(可以用电脑计算器或者python算一算),为了让值进入64位,所以拓展了一个十进制位。 ??观察add函数,函数体内创建一个long类型的变量c,其值为函数参数a、b之和。计算一下2的64次方,是18446744073709551616,显然这里不结果不满64位,没有问题。 ??那么,有意思的地方来了:add函数的返回值是int类型。显然,根据C语言的知识,这里会出现精度损失。尽管主函数里接收返回值的是long,但是在返回的时候,该值就已经损失精度了。 ??为了验证这一点,笔者运行了上述的代码,结果与猜测是一致的:
那么,问题来了。精度损失,到底损失在了哪个位置呢?
案例汇编代码分析
??Compiler Explorer给出了C源码对应的核心汇编代码:
add:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rdx, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rbp-32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
.LC0:
.string "%ld"
main:
push rbp
mov rbp, rsp
sub rsp, 16
movabs rax, 42949672962
mov rsi, rax
movabs rax, 42949672961
mov rdi, rax
call add
cdqe
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
mov rsi, rax
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
leave
ret
??.LC0助记符在这次的案例中显然没有什么用,可以不管。 ??首先从main助记符开始入手,第一步,创建了main函数栈,记录栈底指针等等工作。每一个函数开始时都会创建这样的栈,详细的内容可以参考《编译系统透视 图解编译原理》这本书。当然,即便您没有了解过这些,也不妨碍继续阅读。 ??完成函数栈创建后,两个大整数被MOV到了64位的寄存器rax里面,并分别转交给了rsi以及rdi。之后,便使用CALL指令调用add函数。 ??我们浏览add助记符,可以看到,rdi、rsi两个值分别被传入了rbp偏移量为24、32字节的位置。32与24恰好相差8字节,即一个long类型变量。这里首先出现了一个有意思的现象,在main函数处可以看到,add函数的第二个参数首先被MOV,这证实了教科书上写的规则——从右向左读入参数。但是,当进入了add函数后,寄存器又再次从rdi——即参数a——开始压栈,参数的顺序再次恢复了正常。
add:
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
main:
movabs rax, 42949672962
mov rsi, rax
movabs rax, 42949672961
mov rdi, rax
??QWORD和DWORD是好兄弟,分别表示四字与双字;PTR就是pointer的缩写,XWORD PTR [???]的工作就是指向了2X个字节所在的地址???,如果像该代码中使用了MOV指令,就意味着将/被移入2X个字节的值,地址就是[]内的内容。 ??紧接着,栈内的数据又分别MOV到了rdx与rax两个64位寄存器中,并将rdx的值ADD到rax中。显然,此时rax中装载的值是85899345923——即42949672961与42949672962之和。此后,rax的值被装入到了距离rbp偏移8字节的地址块中,又被重新MOV进rax里面。这一步看似很多余,实际上是由于add函数有一个变量c,这里将值——也就是rax——给了它,这个变量的位置恰好就是rbp-8。详细验证可以将long c = a + b;改成long c = 0; c = a + b;,就能看到MOV QWORD PTR [rbp-8], 0 这样的内容,此处就不演示了。 ??接着,add函数就进入ret了。相信大家发现一件事:从头到尾,add内部都没有做任何折损精度的事情,返回值也被装入了64位的寄存器rax里,那么,为什么最后的输出结果是3呢? ??让我们回到main中,CALL add指令之后。这里加入了一条至关重要的指令:CDQE。这条指令的解释是将双字变为四字,是什么意思呢?当前rax存放的是85899345923,二进制数为1010000000000000000000000000000000011,那么,它会将后32位的符号位给扩展到前32位中。这里显然,后32位符号位是0,自然在扩展后,rax就变为了0000000000000000000000000000000000011——与先前C程序的结果一致了。
案例汇编进一步分析与验证
??通过上面的分析,我们很容易得出一个猜想:add函数的返回值是什么,并不重要,计算溢出实质是CDQE指令造成的。接下来,我们就来验证这些内容。
- 尝试改变函数返回值,对照汇编代码
将C代码分别改成以下形式:
int add(long a,long b){
long c = a + b;
return c;
}
char add2(long a,long b){
long c = a + b;
return c;
}
short add3(long a,long b){
long c = a + b;
return c;
}
long add4(long a,long b){
long c = a + b;
return c;
}
上述代码的汇编内容如下:
add:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rdx, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rbp-32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
add2:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rdx, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rbp-32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
add3:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rdx, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rbp-32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
add4:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rdx, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rbp-32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
??经过对比,可以发现在add助记符内部,C代码中的返回值实际上没有起到影响,汇编指令均是一致的。
- 尝试删除CDQE指令再汇编、链接为可执行文件
这个测试以上文的C源码为准。由于网站不可以修改汇编代码,故笔者使用了自己的64位机。编写的代码如下:
#include<stdio.h>
long add(long long a,long long b){
long long c = a + b;
return c;
}
int main(){
long long c = add(42949672961,42949672962);
printf("%lld",c);
getchar();
return 0;
}
??该代码直接运行的结果如下: ??输入命令进行编译:
gcc -S test.c -o test.s
??s文件的内容如下:
.file "test.c"
.text
.globl add
.def add; .scl 2; .type 32; .endef
.seh_proc add
add:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
subq $16, %rsp
.seh_stackalloc 16
.seh_endprologue
movq %rcx, 16(%rbp)
movq %rdx, 24(%rbp)
movq 16(%rbp), %rdx
movq 24(%rbp), %rax
addq %rdx, %rax
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
addq $16, %rsp
popq %rbp
ret
.seh_endproc
.def __main; .scl 2; .type 32; .endef
.section .rdata,"dr"
.LC0:
.ascii "%lld\0"
.text
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
main:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
subq $48, %rsp
.seh_stackalloc 48
.seh_endprologue
call __main
movabsq $42949672962, %rdx
movabsq $42949672961, %rcx
call add
cltq
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
movq %rax, %rdx
leaq .LC0(%rip), %rcx
call printf
call getchar
movl $0, %eax
addq $48, %rsp
popq %rbp
ret
.seh_endproc
.ident "GCC: (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 8.1.0"
.def printf; .scl 2; .type 32; .endef
.def getchar; .scl 2; .type 32; .endef
??查看main中call指令之后的cltq指令。该指令与cdqe起着一样的作用。删除改行指令,继续输入命令进行汇编、链接,然后运行:
gcc test.s -o test.exe
./test.exe
??运行的结果为: ??可以看到,不进行字扩展,rax承载的就是正常运算的局部变量C,没有处理溢出等等情况,与真实数学运算结果是相同的。
2、案例一的小扩展
不考虑赋值
??将源码稍稍修改,改成这个样子:
#include<stdio.h>
int add(long a,long b){
long c = a + b;
return c;
}
int main(){
add(42949672961,42949672962);
return 0;
}
??主函数此时舍弃了add返回的内容。根据此前的分析,add函数没有做溢出时的处理,那么,此处主函数中也应当没有CDQE指令——毕竟不需要处理返回值嘛,谁愿意多此一举呢?查看汇编代码:
main:
push rbp
mov rbp, rsp
movabs rax, 42949672962
mov rsi, rax
movabs rax, 42949672961
mov rdi, rax
call add
mov eax, 0
pop rbp
ret
??主函数如我们猜想一致,没有进行自扩展,如果此时访问rax,依然能正常访问到正确的值。
add函数返回不额外显式使用变量c
??再将源码改成这个样子:
#include<stdio.h>
int add(long a,long b){
return a + b;
}
int main(){
long c = add(42949672961,42949672962);
return 0;
}
??这种写法显然更接近平时的习惯。然而,就是这小小的差别,汇编代码却发生了很大的改变:
add:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov rax, QWORD PTR [rbp-8]
mov edx, eax
mov rax, QWORD PTR [rbp-16]
add eax, edx
pop rbp
ret
??可以看到,到参数压栈为止,指令都还是一致的。但是,此后却出现了MOV edx,eax 与 ADD edx,eax 这样的操作32位寄存器的指令。也就是说,自return a+b;中的加法起,溢出的情况就已经发生了,这种情况下就不能够仅通过删除字扩展指令的方式计算正确结果,而需要调整其它略繁琐的内容了。
四、小结
??这次的案例,其实是我无意中发现,然后拓展出来的,算是一种无聊透顶的工作吧。但通过这个简单的add函数,发现了许多编译过程中的细节以及有意思的地方。希望这篇文章能够让有意探索gcc编译流程的人有些分析的思路吧。
|