编译¶
《程序员的自我修养--链接、装载与库》 - 俞甲子,石凡,潘爱民,第二、三章的读书笔记;以及《深入理解计算机系统》 - Randal E. Bryant - 第三版,第7.6.1章节的读书笔记。本文中的所有代码可在GitHub仓库中找到
生成一个可执行文件¶
C语言生成一个可执行文件需要4个步骤:
- 预处理(Preprocessing)
- 编译(Compilation)
- 汇编(Assembly)
- 链接(Linking)
下面以HelloWorld为例,分别介绍各步骤做了什么事情。
预编译¶
预编译过程通过gcc -E main.c -o main.i
命令,处理源代码中以#
开头的预编译指令。主要规则如下:
- 展开宏和头文件
- 删除注释
- 添加行号和文件名标识,以便编译器产生调试用的行号信息,例如
# 2 "main.c" 2
,表示main.c的第二行- 文件名后面的数字代表不同的意思,参考GCC手册
1
,表示新文件的开始2
,表示返回此文件3
,表示接下来的内容来自系统头文件,某些警告将被忽略4
,表示接下来的内容按C代码处理,即被extern "C"
修饰
- 文件名后面的数字代表不同的意思,参考GCC手册
编译¶
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,其命令是:gcc -S main.i -o main.s
。
汇编¶
汇编器将汇编代码转变成机器可执行的指令,称为目标文件(Object File)。我们可以调用汇编器as
完成,也可以通过GCC命令完成。
as main.s -o main.o
gcc -c main.s -o main.o
链接¶
GCC命令gcc main.o -o main
,通过链接器,将多个目标文件链接成一个可执行文件。此命令底层是依赖链接器ld
完成的,其完整命令如下:
ld -static /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginT.o \
-L/usr/lib/gcc/x86_64-linux-gnu/9 -L/usr/lib -L/lib main.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o \
-o main
目标文件¶
目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容与结构是很相似的。只是还没有经过链接的过程,其中可能有些符号或有些地址还没有调整。
Linux的ELF(Executable Linkable Format)是一种可执行文件的格式。除了目标文件(.o),动态链接库(.so)、静态链接库(.a)和可执行文件(.out)都是按照这种格式存储的。
ELF文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件 (Relocatable File) | 这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 | .o, .a |
可执行文件 (Executable File) | 这类文件包含了可以直接执行的程序,它的代表就是ELF可执行文件 | /bin/bash |
目标共享文件 (Shared Object File) | 这种文件包含了代码和数据,通过链接器可以和其他可重定位文件产生新的目标文件。或者和可执行文件结合,作为进程映像的一部分来运行 | .so |
核心转储文件 (Core Dump File) | 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 | core dump |
目标文件中的段¶
目标文件以段(Segment/Section)的方式,将不同属性的信息组织起来。例如,源代码编译后的机器指令经常放在.text
代码段(Code Section)中,而全局变量和局部静态变量经常放在数据段.data
(Data Section)。
以下面的代码为例,让我们来看看目标文件的内容。
int global_init_var = 84; // .data section
int global_uninit_var; // .bss section
void func1(int i) // .text section
{
printf("%d\n", i);
}
int main(void) // .text section
{
static int static_var = 85; // .data section
static int static_var2; // .bss section
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
return 0;
}
.text
段- 代码段,存储执行代码,如:
main
函数,func1
函数
- 代码段,存储执行代码,如:
.data
段- 数据段,存储以初始化的全局/局部变量,如:
global_init_var
,static_var
- 数据段,存储以初始化的全局/局部变量,如:
.bss
段(Block Started by Symbol)- 未初始化的全局/局部变量默认值都是0,为了节省空间,并没有放入数据段,而是在
.bss
段中预留了位置,.bss
段不占据任何空间
- 未初始化的全局/局部变量默认值都是0,为了节省空间,并没有放入数据段,而是在
.rodata
段- 只读数据段,存储只读变量,例如:
%d\n
字符串
- 只读数据段,存储只读变量,例如:
通过objdump
工具可以查看目标文件的内容,objdump -h main.o
打印"main.o"的各段的首部信息如下:
$ objdump -h main.o
main.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000064 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000a4 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000008 0000000000000000 0000000000000000 000000ac 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000ac 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002c 0000000000000000 0000000000000000 000000b0 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000dc 2**0
CONTENTS, READONLY
6 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .eh_frame 00000058 0000000000000000 0000000000000000 00000100 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
根据objdump
结果,可得到目标文件"main.o"的结构(如上图)。每个段的第2行表示段属性,如"CONTENTS"表示该段是否在文件中存在。
代码段¶
通过objdump -s -d main.o
可得到各段的内容,并打印反汇编内容。例如,下面就是大小为0x64的.text
段的二进制编码。通过反汇编可知,这些二进制编码就是func1
和main
函数的指令。
Contents of section .text:
0000 f30f1efa 554889e5 4883ec10 897dfc8b ....UH..H....}..
0010 45fc89c6 488d0500 00000048 89c7b800 E...H......H....
0020 000000e8 00000000 90c9c3f3 0f1efa55 ...............U
0030 4889e548 83ec10c7 45f80100 00008b15 H..H....E.......
0040 00000000 8b050000 000001c2 8b45f801 .............E..
0050 c28b45fc 01d089c7 e8000000 00b80000 ..E.............
0060 0000c9c3
数据段和只读数据段¶
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00
.data
段保存已经初始化的全局/局部静态变量,如例子中的:global_init_var
和static_var
。它们的值分别是84和85,对应于上面的54000000和55000000。在小端模式下,低字节保存在低地址中,因此0x54,0x00,0x00,0x00的存放顺序代表的是整型值84。
.rodata
段保存只读数据,如例子中的字符串:%d\n
,三个字符的ASCII码分别是:0x25,0x64,0x0A,对应于上面的25640a00。
其他段¶
常用段名 | 说明 |
---|---|
.comment | 存放的是编译器版本信息,比如字符串:"GCC: (Ubuntu 11.1.0-1ubuntu1~20.04) 11.1.0" |
.debug | 调试信息,在编译的时候加上-g,可以出现相关段 |
.dynamic | 动态链接信息 |
.hash | 符号哈希表 |
.line | 调试时的行号表 |
.note | 额外的编译信息,比如程序的公司名、发布版本号等 |
.strtab | String Table字符串表,用于存储ELF文件中用到的各种字符串 |
.symtab | Symbol Table符号表,包括:全局函数和变量(本模块定义/引用的),局部静态函数和变量 |
.shstrtab | Section String Table段名表 |
.plt .got | 动态链接的跳转表和全局入口表 |
.init .fini | 程序初始化与终结代码段,出现在C++代码中 |
自定义段¶
在全局变量或函数之前加上__attribute__((section("name")))
属性就可以把相应的变量或函数放到以"name"作为段名的段中。如:
// 将`global`全局变量放入"FOO"段名中
__attribute__((section("FOO"))) int global = 42;
// 将`foo`函数放入"BAR"段名中
__attribute__((section("BAR"))) void foo()
{
}
ELF文件结构¶
前面我们通过objdump
工具,大致了解了目标文件中常见的段。接下来我们来详细地看看ELF目标文件的结构。ELF目标文件主要包括:
- ELF文件头(ELF Header)
- 在文件最前部,描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等
- 各个段的内容
- ELF文件的主体内容
- 段表(Section Header)
- 描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性
readelf -h main.o
命令会读取"main.o"的ELF文件头,并打印如下信息:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1176 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 13
readelf -S main.o
命令会读取"main.o"的段表,并打印如下信息:
There are 14 section headers, starting at offset 0x498:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000064 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000378
0000000000000078 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 000000a4
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000ac
0000000000000008 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000ac
0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000b0
000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000dc
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.propert NOTE 0000000000000000 000000e0
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 00000100
0000000000000058 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 000003f0
0000000000000030 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 00000158
00000000000001b0 0000000000000018 12 12 8
[12] .strtab STRTAB 0000000000000000 00000308
000000000000006d 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 00000420
0000000000000074 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
结合上面打印的信息,我们可以画出"main.o"文件的结构如下:
链接的接口--符号¶
每个目标文件都会有一个相应的符号表(Symbol Table),记录了目标文件中所用到的所有符号。符号除了是常见的函数和变量外,还有可能是其他类型。通过nm
工具可以查看目标文件中的符号。
$ nm main.o
0000000000000000 T func1 # T, in text section
0000000000000000 D global_init_var # D, in data section
U _GLOBAL_OFFSET_TABLE_ # U, undefined
0000000000000000 B global_uninit_var # B, in BSS data section
000000000000002b T main
U printf
0000000000000004 d static_var.1 # d, in data section
0000000000000004 b static_var2.0 # b, in BSS data section
- 常见的符号类型
- 内部全局符号 - 定义在本目标文件的全局符号,可以被其他目标文件引用
- 外部全局符号 - 在本目标文件中引用的全局符号,却没有定义在本目标文件
- 段名 - 由编译器产生,它的值就是该段的起始地址
- 局部符号 - 只在编译单元内部可见
- 行号信息 - 目标文件指令与源代码行的对应关系
特殊符号¶
当用ld作为链接器产生可执行文件时,它会为我们定义很多特殊的符号,例如:
- __executable_start - 程序的起始地址
- etext, _etext, __etext - 代码段结束地址
- edata, _edata - 数据段结束地址
- end, _end - 程序结束地址
通过GDB调试相关代码,可得到如下信息。其中,起始地址0x555555554000
和结束地址0x555555558018
,分别映射在了相应的虚拟地址空间中。
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x555555554000 0x555555555000 0x1000 0x0 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
0x555555555000 0x555555556000 0x1000 0x1000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
0x555555556000 0x555555557000 0x1000 0x2000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
0x555555557000 0x555555558000 0x1000 0x2000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
0x555555558000 0x555555559000 0x1000 0x3000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
Executable Start 0x555555554000
Text End 0x555555555285 0x555555555285 0x555555555285
Data End 0x555555558010 0x555555558010
Executable End 0x555555558018 0x555555558018
C++符号修饰¶
为了支持C++函数重载,命名空间等复杂的特性,人们发明了符号修饰(Name Decoration)和符号改编(Name Mangling)机制。
函数签名 | 修饰后名称(符号名) |
---|---|
int func(int) | _Z4funci |
float func(float) | _Z4funcf |
int C::func(int) | _ZN1C4funcEi |
int C::C2::func(int) | _ZN1C2C24funcEi |
int N::func(int) | _ZN1N4funcEi |
int N::C::func(int) | _ZN1N1C4funcEi |
上表显示了代码中,不同函数的符号名。以_Z
开头,后面紧跟N
,然后是各名称空间和类的名字,每个名字前是名字字符串长度,再以E
结尾。参数列表紧跟在E
后面。c++filt
工具可以用来解析被修饰过的名称。
extern "C"¶
C++用extern "C"
关键字来声明或定义一个C的符号。修饰后,C++的名称修饰机制将不会起作用。常见的写法如下:
#ifdef __cplusplus
extern "C" {
#endif
void *memset (void *, int, size_t);
#ifdef __cplusplus
}
#endif
弱符号与强符号¶
我们经常会遇到符号重定义的错误。例如,目标文件A和目标文件B都定义了一个全局变量global,则链接时会报错:
b.o: multiple definition of `global`
a.o: first defined here
符号的定义分为强符号(Strong Symbol)定义和弱符号(Weak Symbol)定义。通过GCC修饰符__attribute__((weak))
可以定义弱符号,包括弱全局变量和弱函数。
例如,"weak.c"定义了弱变量bar
和弱函数foo
:
int __attribute__((weak)) bar = 3;
void __attribute__((weak)) foo(int a, int b)
{
printf("weak version foo(%d, %d) with bar %d, sizeof(bar) = %zu\n", a, b, bar, sizeof(bar));
}
bar
和强函数foo
:
#include <stdio.h>
long bar = 100;
void foo(int a, int b)
{
printf("strong version foo(%d, %d) with bar %ld, sizeof(bar) = %zu\n", a, b, bar, sizeof(bar));
}
Linux链接器使用如下规则处理多重定义的符号:
- 规则1:不允许有多个同名的强符号
- 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号
- 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个
# strong.o中强类型的`bar`变量和`foo`函数
> nm strong.o | egrep 'bar|foo'
0000000000000000 D bar # The symbol is in the initialized data section.
0000000000000000 T foo # The symbol is in the text (code) section.
> readelf -s strong.o | egrep 'bar|foo'
73: 0000000000000000 8 OBJECT GLOBAL DEFAULT 22 bar
74: 0000000000000000 62 FUNC GLOBAL DEFAULT 20 foo
# weak.o中弱类型的`bar`变量和`foo`函数
> nm weak.o | egrep 'bar|foo'
0000000000000000 V bar # The symbol is a weak object.
0000000000000000 W foo # The symbol is a weak symbol
> readelf -s weak.o | egrep 'bar|foo'
73: 0000000000000000 4 OBJECT WEAK DEFAULT 22 bar
74: 0000000000000000 61 FUNC WEAK DEFAULT 20 foo
# program1只包含了weak.o
> nm program1 | egrep 'bar|foo'
0000000000004010 V bar
0000000000001167 W foo
> readelf -s program1 | egrep 'bar|foo'
57: 0000000000004010 4 OBJECT WEAK DEFAULT 25 bar
66: 0000000000001167 61 FUNC WEAK DEFAULT 16 foo
# program1同时包含了weak.o和strong.o
> nm program2 | egrep 'bar|foo'
0000000000004018 D bar
00000000000011a4 T foo
> readelf -s program2 | egrep 'bar|foo'
58: 0000000000004018 8 OBJECT GLOBAL DEFAULT 25 bar
67: 00000000000011a4 62 FUNC GLOBAL DEFAULT 16 foo
COMMON符号¶
当编译器在编译某个模块时,遇到一个弱全局符号,比如说x
,它并不知道其他模块是否也定义了x
。如果是,它无法预测链接器该使用x
多重定义中的哪一个。所以编译器把x
分配成COMMON
,把决定权留给链接器。另一方面,如果x
被明确初始化,那么它是一个强符号,所以编译器可用很自信地将它分配成.data
(初始化为非零)或.bss
(初始化为零)。类似地,对于静态符号,编译器可以自信地把它们分配成.data
或.bss
。
例子"common"编译了两个模块main.o
和f.o
,其中:
f.o
定义了弱符号x
,存于COMMON
段main.o
定义了强符号x
、y
、z
和v
x
、y
、z
被初始化为非零,因此存于.data
段v
被初始化为零,因此存于.bss
段
> readelf -s f.o
...
73: 0000000000000008 8 OBJECT GLOBAL DEFAULT COM x
> readelf -s main.o
...
73: 0000000000000000 4 OBJECT GLOBAL DEFAULT 22 x
74: 0000000000000004 4 OBJECT GLOBAL DEFAULT 22 y
75: 0000000000000008 4 OBJECT GLOBAL DEFAULT 22 z
76: 0000000000000000 4 OBJECT GLOBAL DEFAULT 23 v
> readelf -S main.o
...
[22] .data PROGBITS 0000000000000000 000001d4
000000000000000c 0000000000000000 WA 0 0 4
[23] .bss NOBITS 0000000000000000 000001e0
0000000000000004 0000000000000000 WA 0 0 4
f.c
定义的x
是double
类型,而main.c
定义的x
是int
类型,这在链接时会触发警告。并且在运行时,f
函数对x
的写操作,会影响y
的值:
> gcc main.o f.o -o main
/usr/bin/ld: warning: alignment 4 of symbol `x' in main.o is smaller than 8 in f.o
> ./main
[main] &x=0x0x55892d153010, sizeof(x)=4
[main] &y=0x0x55892d153014, sizeof(y)=4
[main] &z=0x0x55892d153018, sizeof(z)=4
[main] &v=0x0x55892d153020, sizeof(v)=4
[main] before f(), x = 0x1, y = 0x2, z = 0x3
[f] &x=0x0x55892d153010, sizeof(x)=8
[f] x = -0.000000
[main] after f(), x = 0x0, y = 0x80000000, z = 0x3
调试信息¶
如果我们在GCC编译时加上-g
参数,在产生的目标文件里就会加上调试信息,会多出很多"debug"相关的段。例如,"main.c"的调式信息段如下:
[28] .debug_aranges PROGBITS 0000000000000000 0000303b
0000000000000030 0000000000000000 0 0 1
[29] .debug_info PROGBITS 0000000000000000 0000306b
00000000000000bc 0000000000000000 0 0 1
[30] .debug_abbrev PROGBITS 0000000000000000 00003127
0000000000000088 0000000000000000 0 0 1
[31] .debug_line PROGBITS 0000000000000000 000031af
000000000000005f 0000000000000000 0 0 1
[32] .debug_str PROGBITS 0000000000000000 0000320e
0000000000000136 0000000000000001 MS 0 0 1
strip
工具可以删除上述调试信息,同时strip
还会删除.symtab
段和.strtab
段,以节省空间。因此,strip
后的目标文件将无法使用readelf -s
查看符号表,同时也无法使用GDB打断点。但是,只是没有调试信息的目标文件是包含符号表的,并可以通过GDB打断点。