关于gcc汇编过程的一些事(一)

一、前言

  众所周知,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语言与汇编语言了解并不深入,若文章有不当之处,还望斧正。

二、相关工具

  为了更好地进行说明,笔者需要借用一些工具。这些工具如下:

  1. gcc
      既然标题都跟gcc联系,那自然是离不开这个经典的编译器的。笔者使用的是版本是8.1.0,但一般来说,其实版本没差。gcc的安装网上到处都有,不再赘述。

  2. 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,但是在返回的时候,该值就已经损失精度了。
  为了验证这一点,笔者运行了上述的代码,结果与猜测是一致的:
C代码运行结果

那么,问题来了。精度损失,到底损失在了哪个位置呢?

案例汇编代码分析

  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指令造成的。接下来,我们就来验证这些内容。

  1. 尝试改变函数返回值,对照汇编代码
    将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代码中的返回值实际上没有起到影响,汇编指令均是一致的。

  1. 尝试删除CDQE指令再汇编、链接为可执行文件
    这个测试以上文的C源码为准。由于网站不可以修改汇编代码,故笔者使用了自己的64位机。编写的代码如下:
//test.c
#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;
}

  该代码直接运行的结果如下:
C程序运行结果
  输入命令进行编译:

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

  运行的结果为:
去除cltq指令后的运行结果
  可以看到,不进行字扩展,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编译流程的人有些分析的思路吧。

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值