duduru

C语言编译流程分析

C语言编译流程

预处理.i
编译.s
汇编.o
链接.exe
流程功能
预处理头文件展开,宏替换,去掉注释
编译将预处理生成的文件转换成汇编文件
汇编将汇编文件转换成二进制文件
链接将函数库中相应代码组合到目标文件中

流程分析

编译器对工程的文件依次编译,工程中包括main.c,test.c,test.h。
mian.c

#include "test.h"
#define TEST_NUM 5
void main()
{
  int a,b,num;
  a = TEST_NUM;
  b = 5;
  num = test(a,b);
}

test.c

#include "test.h"

int test(int a,int b)
{
  return a + b;
}

test.h

int test(int a,int b);

以main.c为例,经过预处理(gcc -E main.c -o main.i)过后可以看到头文件、宏定义被替换。
main.i

# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "main.c"
# 1 "test.h" 1
int test(int a,int b);
# 2 "main.c" 2

void main()
{
  int a,b,num;
  a = 5;
  b = 5;
  num = test(a,b);
}

这里其实就解决了我们遇到的头文件重复包含的问题。
我们在编写头文件时,通常是下面的这种格式:

#ifndef __xx_H
#define __xx_H
...
#endif

在了解预处理后,我们便很容易知道为什么要这么写。假设main.c中包含了test1.h和test2.h两个头文件,而这两个头文件又同时需要用到加法功能,所以他们都包含了add.h。如果不使用上述代码块,就会发现在main.i中出现了两次add.h中的声明,形成代码冗余。

经过预处理生成.i文件后,接下来就是进行编译(gcc -S main.i -o main.s)生成.s文件。

	.file	"main.c"
	.text
	.def	___main;	.scl	2;	.type	32;	.endef
	.globl	_main
	.def	_main;	.scl	2;	.type	32;	.endef
_main:
LFB0:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5
	andl	$-16, %esp
	subl	$32, %esp
	call	___main
	movl	$5, 28(%esp)
	movl	$5, 24(%esp)
	movl	24(%esp), %eax
	movl	%eax, 4(%esp)
	movl	28(%esp), %eax
	movl	%eax, (%esp)
	call	_test			;
	movl	%eax, 20(%esp)	;
	nop
	leave
	.cfi_restore 5
	.cfi_def_cfa 4, 4
	ret
	.cfi_endproc
LFE0:
	.ident	"GCC: (MinGW.org GCC-8.2.0-3) 8.2.0"
	.def	_add;	.scl	2;	.type	32;	.endef

生成的汇编代码,我们暂时只关注call _test和movl %eax, 20(%esp)部分(.xxx为伪指令,作用是告诉编译器该如何操作,并不是程序的一部分),这两条指令的含义分别是跳转到test函数的首地址和将test(a,b)的返回值给num,对应.c的num = test(a,b)语句。

之所以关注这一条语句,是因为.i在转换成汇编语言的过程中,编译器在碰到不认识的变量,函数,类…,首先是查找它有无声明,如果没有,则直接报错;如果有,则根据对应的定义空出一定的存储空间并进行指令转化。比如在编译到此条语句时,编译器碰到了不认识的函数test(),它会首先查找声明,发现在声明中存在,并且还具有返回值,所以,形成在此处的汇编指令就是call _test和movl …。其中的_test以符号形式存在,并不是test()函数入口地址。(test()函数在test.c中定义,此处仅仅是编译main.c,传入test()入口地址这件事交给链接器来做)

同时这也解释了为什么声明时要指明变量类型,编译器不知道类型就不知道怎么替代c代码。上述例子中的test()函数如果返回值为空,我们的汇编代码就不会出现movl …的赋值操作。

编译结束后,来到汇编(gcc -c main.s -o main.o)阶段,生成.o的对象文件,转化成了二进制表示的机器语言,同时,他还会生成一张重定位表,记录函数、变量及其对应的地址等。至此,main.c生成了它的对象文件main.o,同样的,test.c经过相同的过程也生成了test.o。

重定位表:
Alt

进入链接阶段,编译器会扫描所有的.o文件,先查找main函数,然后根据main函数中的代码执行流程来组织代码结构。当碰到之前保留的符号(_test)时,会去所有的.o文件重定位表中查找相应符号对应的地址,找到后会将.o中的符号替换为地址,例如call _test被替换成call 0x00345678。

到此,编译工作结束。

其实,理解编译的基本原理,很多工程上遇到的问题就迎刃而解了。比如一个工程中的模块化编程(led.c,motor.c,sensor.c…),我们这样编写不仅是遵循“高内聚低耦合”的原则,从编译的角度来看,修改某一处的代码只会影响它所在的.c文件,重新编译的也只有那个.c文件,这样的编程思路极大加快了项目的编译速度。

参考文章:
为什么C语言会有头文件
汇编中bss,data,text,rodata,heap,stack

posted on 2022-08-21 21:32  duduru  阅读(0)  评论(0)    收藏  举报  来源

导航