目录
地址和空间分配(Address and Storage Allocation)
本文介绍了从C语言程序到可执行文件的中间处理过程,包括了预编译(Prepressing),编译(Compilation),汇编(Assembly)和链接(Linking)四个阶段。
下面将以C语言程序 first.c 为例详细介绍
#include <stdio.h>
#define PI 3
int sum(int a, int b)
{
return a + b;
}
int main()
{
int a = 6;
int b = PI; //派
int c = sum(a,b);
printf("%d",c);
return 0;
}
预编译(Prepressing)
预编译是C语言编译过程的第一步,主要处理宏定义、条件编译指令和包含文件等。为后续的编译步骤准备源文件。
其主要的处理规则如下:
- 将所有的 “ #define ” 删除,展开所有宏定义。
- 处理 “ #include ” 指令,将被包含的文件插入到 “ #include ” 所在的位置,该过程可递归;预处理器会首先在当前目录中查找该头文件,如果找不到,再在系统标准头文件目录中查找。
- 处理所有条件编译指令,如“ #if ”,“ #ifdef ”,“ #elif ”,“ #else ”,“ #endif ”,根据宏是否已经定义来包含或排除代码。
- 删除所有注释。
- 添加行号和文件名标识,比如 # 2 "first.c" 2 ,# 3 "first.c",后者表示接下来的代码是原始文件 first.c 文件中的第三行。
-
保留所有的 #pragma 指令,传递给编译器,编译器可以根据该指令来执行各种操作。
在实际的操作过程中可以使用下面的指令实现C语言程序的预编译操作,预处理之后生成first.i文件。
gcc -E first.c -o first.i
预处理之后的 first.i 文件很长,因为包含了 stdio.h
头文件的全部信息,但核心是为了让编译器能更好地处理程序中的标准库函数调用和类型检查。
下面为上述 first.c 文件预处理后得到的 first.i 文件的一部分(虚线处省略很多 stdio.h
头文件的信息)。
# 1 "first.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "first.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
...........................................................
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
...........................................................
extern int fprintf (FILE *__restrict __stream,
const char *__restrict __format, ...);
extern int printf (const char *__restrict __format, ...);
...........................................................
# 2 "first.c" 2
# 3 "first.c"
int sum(int a, int b)
{
return a + b;
}
int main()
{
int a = 6;
int b = 3;
int c = sum(a,b);
printf("%d",c);
return 0;
}
可以看到文件经过预处理之后:
- 将#include<stdio.h>文件的内容插入到 first.i 文件的最前面
- 替换掉宏定义
- 删除了程序中的注释
- 添加行号和文件名标识
编译(Compilation)
编译过程就是把预处理完的 .i 文件进行一系列词法分析,语法分析,语义分析及优化生成相应的汇编代码文件。
在实际的操作过程中可以使用下面的指令实现C语言程序的编译操作,编译之后生成first.s文件。
gcc -S first.i -o first.s
词法分析
编译器会将预处理后的 .i 文件作为输入,将代码分解为词法单元,词法单元是程序的最小语法单元,包括关键字、标识符、字面量(包括数字,字符串等)和特殊符号(如加号,等号)。
该过程可以使用一个叫 lex 的程序实现词法扫描,按照此法规则将代码中的字符串分割成一个个记号。
以 first.c 程序为例,程序中的sum函数
,会被分解为 int,
sum,
(,
int,
a,
,,
int,
b,
),
{,
return,
a,
+,
b,
;,
}
等词法单元。
语法分析
语法分析器(Grammar Parser)会对扫描器产生的记号进行语法分析,产生语法树(Syntax Tree),该语法树就是以表达式为节点的树。
以 first.c 程序为例,生成的语法树的树状表示如下图:

根据Chat GPT的回答,可以使用下面方法得到程序编译时语法分析得到的语法树的树状表示,(但是我使用命令之后没有得到类似于first.c.003t.original.dot文件,只看到了first.c.003t.original文件,上述的语法树是使用Chat GPT生成的 .dot 文件转换成.png图片)
- 使用以下命令让 GCC 生成语法树的
.dot
文件:gcc -fdump-tree-original-graph first.c
- 为了将
.dot
文件转换为图片,你需要安装 Graphviz 工具。在 Ubuntu 系统上,可以使用以下命令安装:sudo apt-get install graphviz
- 使用以下命令将
.dot
文件转换为.png
图片:dot -Tpng first.c.003t.original.dot -o first.c.003t.original.png
语义分析
语义分析要检查程序的语义正确性。
编译器能分析的语义是静态语义
- 静态语义是在编译期间可以确定的语义
- 动态语义只有在运行期才能确定的语义
静态语义通常包括声明和类型的匹配,类型的转换。
经过语义分析阶段之后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
例如,在 first.c 中的 sum 函数里面的 return a + b;
中,要确保 a
和 b
都是 int
类型,并且 +
操作符可以应用于 int
类型。
以 first.c 程序为例,语义分析之后生成的语法树的树状表示如下图(.dot文件由Chat GPT生成):

优化
编译器编译的整个优化过程分为源代码优化,代码生成和目标代码优化三个阶段。
源代码优化
源代码优化是指在源代码级别的一个优化过程,需要在不改变程序语义的前提下,对源代码进行各种转换和调整,以提高程序性能、减少代码大小或提高代码可读性。
源代码优化的内容如下:
- 当程序中使用常量时,将常量的值传递到使用它的地方。
- 对表达式进行代数运算简化。
- 消除重复计算的子表达式。
- 消除程序中不会被执行的代码。
代码生成
将中间表示转换为目标代码,涉及指令选择、寄存器分配和指令调度。
目标代码优化
对生成的目标代码进行局部和全局优化,提高性能。目标代码优化的内容如下:
- 对一小段目标代码进行局部优化。
- 优化条件分支,减少分支预测失败的开销。
- 循环优化,包括循环展开,循环不变量代码移动,循环向量化等等。
可以使用 GCC 查看优化过程
- 源代码优化:使用
-O
选项启用不同级别的优化gcc -O2 -fdump-tree-optimized first.c
- 代码生成:使用 -S选项生成汇编代码
gcc -O2 -S first.c objdump -d first.o > first.asm
- 目标代码优化:可以使用objdump查看生成的目标代码(根据Chat GPT了解到可以使用c2c将汇编代码转化成C语言代码,但是尝试失败,倒在了安装c2c这一步)
gcc -O2 -c first.c objdump -d first.o objdump -d first > afterasm.txt //可以选择生成汇编文件以供查看
优化过程以 first.c 程序为例
- 使用
-O
选项启用优化。不同的优化级别有-O1
,-O2
,-O3
等,优化级别越高,优化越激进。 - 使用
-fdump-tree-optimized
选项可以输出优化后的中间表示。生成 first.c.231t.optimized.dot 文件。gcc -O2 -fdump-tree-optimized-graph first.c
dot -Tpng first.c.231t.optimized.dot -o first.c.231t.optimized.png
-
转化为图像文件可以直观地看到优化的结果。
-O2优化后得到的程序中间表示的直观图像 - 对比没有优化时的中间标识可以发现明显的区别
gcc -fdump-tree-optimized-graph first.c dot -Tpng first.c.231t.optimized.dot -o first.c.231t.optimized.png
没有优化前的程序中间表示的直观图像
下面为上述 first.c 文件经编译后得到的 first.s 文件。
.file "first.c"
.text
.globl sum
.type sum, @function
sum:
.LFB0:
.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 %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size sum, .-sum
.section .rodata
.LC0:
.string "%d"
.text
.globl main
.type main, @function
main:
.LFB1:
.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 $6, -12(%rbp)
movl $3, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call sum
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.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:
汇编(Assembly)
汇编是将汇编语言代码转换成机器代码的过程,每一个汇编语句几乎对应一条机器指令。整个过程由汇编器完成,将 .s 文件中的汇编指令翻译成二进制的机器指令,最终生产等的机器码和其他必要信息存储在 .o 文件中。
可以使用下面指令实现汇编工作:
gcc -c first.s -o first.o
as first.s -o first.o
汇编之后生成的目标文件将会在下一篇内容做详细解释,本篇只做大概描述。
汇编之后生成的目标文件含了机器码、符号表和重定位信息等等,为后续的链接过程提供了基础,是将 C 语言程序从源代码最终转换为可执行程序的重要中间步骤。在Linux中目标文件的结构如下图所示:

ELF目标文件格式的最前部是ELF文件头,包含了描述整个文件基本属性,比如ELF文件版本,目标机器型号,程序入口地址等。接下来就是ELF文件的各个段,主要有下面几个部分(不完全)
- .text 已编程序的机器代码
- .rodata 只读数据
- .data 已初始化的全局和静态C变量
- .bss 未初始化的全局和静态C变量
- .symtab 一个符号表,存放程序中定义和引用的函数和全局变量的信息
- .rel.text 一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时需要修改这些位置
在实际操作中,可以使用下面命令查看ELF文件各个段的基本信息(以 first.c 程序为例)
objdump -h first.o
查看ELF文件各个段的基本信息的另外一种方法(比上述方法更详细)
readelf -S first.o
除此之外在Linux上还可以使用工具查看ELF文件的二进制代码
- 使用 xxd 工具 这个命令会将
.o
文件的内容以十六进制和 ASCII 字符的形式显示出来,让你看到文件的二进制数据。xxd first.o
使用 hexdump 工具 这个命令也会以十六进制和 ASCII 字符的形式显示文件内容,与
xxd
类似,但输出格式可能略有不同。hexdump -C first.o
使用 objdump 工具 它会显示不同节的二进制数据,以十六进制表示,并给出相应的节信息,比如 .text 节,.data 节,.comment 节等等 的二进制数据,让你查看不同部分的二进制代码。
objdump -s first.o
使用 readelf 工具 这个命令会显示特定 节的二进制数据,以十六进制表示。(以 .text 节为例)
readelf -x.text first.o
链接(Linking)
链接是将多个目标文件(通常是编译后的文件,以.o或.obj 等为扩展名)和库文件组合在一起,生成一个可执行文件或库文件的过程。链接的过程主要包括了地址和空间分配,符号决议和重定位三个步骤。
下面链接指令主要将 first.o
和 second.o
两个目标文件与 C 标准库和数学库进行静态链接,生成可执行文件 outcome.out
(本文主要是静态链接)
ld -static first.o second.o -lc -lm -o outcome.out
地址和空间分配(Address and Storage Allocation)
将多个目标文件的不同节合并到可执行文件的相应节中,分配虚拟地址,为程序的运行做好内存布局。
以 first.c 程序为例:链接器会将 sum
函数和 main
函数的机器码合并到最终可执行文件的 .text 段中,对于其他标准库函数(如 printf
),如果使用静态链接,它们的代码也会被添加到 .text 段中,并分配相应的地址。其他段如 .data 段, .bss 段也同 .text 段一样被合并到一个相应的段中。
符号决议(Symbol Resolation)
每个目标文件都有一个符号表,记录了该文件中定义和引用的符号。链接器会遍历所有参与链接的目标文件和库文件的符号表,对于未定义的符号(如 printf
),它会在标准库(如 libc
)中查找。确保每个符号都有唯一的地址,避免重复定义和未定义符号。
重定位(Relocation)
根据重定位信息,调整函数调用和变量引用的地址,确保程序能正确跳转到相应的函数和访问相应的变量,无论是内部还是外部的函数和变量。
以 first.c 程序为例:
- 对于
sum
函数的调用:假设在main.o
中调用sum
的call
指令的相对地址是rel_sum
,而sum
函数在最终可执行文件中的起始地址为addr_sum
,链接器会根据重定位信息将call
指令的操作数修改为addr_sum
相对于调用点的正确相对地址。 - 对于
printf
函数的调用:假设printf
的地址为addr_printf
,链接器会根据重定位信息将call
指令的操作数修改为addr_printf
相对于调用点的正确相对地址。
通过这三个阶段,链接器将多个目标文件和库文件整合为一个可执行文件,使其可以在操作系统上正确运行,实现程序的预期功能。这个过程涉及到地址分配、符号管理和地址调整,是将程序从分散的代码和数据片段转换为完整可执行程序的关键步骤。
同目标文件一样,链接得到的可执行文件也可以使用工具查看文件的相关信息:
- 查看文件头信息:该命令会显示可执行文件的 ELF 头信息,包括文件类型(例如可执行文件、共享对象或核心文件)、机器架构(如 x86、ARM 等)、入口点地址、程序头表的偏移量和大小等。
readelf -h first
- 查看节头信息:该命令会列出可执行文件的节(section)信息,包括每个节的名称、类型、地址、偏移量、大小、标志等。
readelf -S first objdump -h first
- 查看符号表信息:可以找到程序中定义的函数和变量的符号,包括它们的地址,还可以看到引用的外部符号,以及来自库文件的符号。
readelf -s first
- 反汇编可执行文件:这个命令会将可执行文件的代码部分反汇编,将机器码转换为汇编指令。
objdump -d first
- 显示可执行文件各个段的大小:
size first
- 查看可执行文件特定节的二进制代码:你可以指定查看特定节的二进制内容,以16进制表示。
objdump -s -j.text first