Gcc 编译的背后
- 前言
- 预处理
- 简述
- 打印出预处理之后的结果
- 在命令行定义宏
- 编译(翻译)
- 简述
- 语法检查
- 编译器优化
- 生成汇编语言文件
- 汇编
- 简述
- 生成目标代码
- ELF 文件初次接触
- ELF 文件的结构
- 三种不同类型 ELF 文件比较
- ELF 主体:节区
- 汇编语言文件中的节区表述
- 链接
- 简述
- 可执行文件的段:节区重排
- 链接背后的故事
- 用 ld 完成链接过程
- C++ 构造与析构:crtbegin.o 和 crtend.o
- 初始化与退出清理:crti.o 和 crtn.o
- C 语言程序真正的入口
- 链接脚本初次接触
- 参考资料
前言
平时在 Linux 下写代码,直接用 gcc -o out in.c 就把代码编译好了,但是这背后到底做了什么呢?
如果学习过《编译原理》则不难理解,一般高级语言程序编译的过程莫过于:预处理、编译、汇编、链接。
gcc 在后台实际上也经历了这几个过程,可以通过 -v 参数查看它的编译细节,如果想看某个具体的编译过程,则可以分别使用 -E,-S,-c 和 -O,对应的后台工具则分别为 cpp,cc1,as,ld。
下面将逐步分析这几个过程以及相关的内容,诸如语法检查、代码调试、汇编语言等。
预处理
简述
预处理是 C 语言程序从源代码变成可执行程序的第一步,主要是 C 语言编译器对各种预处理命令进行处理,包括头文件的包含、宏定义的扩展、条件编译的选择等。
以前没怎么“深入”预处理,脑子对这些东西总是很模糊,只记得在编译的基本过程(词法分析、语法分析)之前还需要对源代码中的宏定义、文件包含、条件编译等命令进行处理。这三类的指令很常见,主要有 #define,#include和 #ifdef ... #endif,要特别地注意它们的用法。
#define 除了可以独立使用以便灵活设置一些参数外,还常常和 #ifdef ... #endif 结合使用,以便灵活地控制代码块的编译与否,也可以用来避免同一个头文件的多次包含。关于 #include 貌似比较简单,通过 man 找到某个函数的头文件,复制进去,加上 <> 就好。这里虽然只关心一些技巧,不过预处理还是隐藏着很多潜在的陷阱(可参考《C Traps & Pitfalls》)也是需要注意的。下面仅介绍和预处理相关的几个简单内容。
打印出预处理之后的结果
$ gcc -E hello.c
这样就可以看到源代码中的各种预处理命令是如何被解释的,从而方便理解和查错。
实际上 gcc 在这里调用了 cpp(虽然通过 gcc -v 仅看到 cc1),cpp 即 The C Preprocessor,主要用来预处理宏定义、文件包含、条件编译等。下面介绍它的一个比较重要的选项 -D。
在命令行定义宏
$ gcc -Dmacro hello.c
这个等同于在文件的开头定义宏,即 #define macro,但是在命令行定义更灵活。例如,在源代码中有这些语句。
#ifdef DEBUG
printf("this code is for debugging\n");
#endif
如果编译时加上 -DDEBUG 选项,那么编译器就会把 printf 所在的行编译进目标代码,从而方便地跟踪该位置的某些程序状态。这样 -DDEBUG 就可以当作一个调试开关,编译时加上它就可以用来打印调试信息,发布时则可以通过去掉该编译选项把调试信息去掉。
编译(翻译)
简述
编译之前,C 语言编译器会进行词法分析、语法分析,接着会把源代码翻译成中间语言,即汇编语言。如果想看到这个中间结果,可以用 gcc -S。需要提到的是,诸如 Shell 等解释语言也会经历一个词法分析和语法分析的阶段,不过之后并不会进行“翻译”,而是“解释”,边解释边执行。
把源代码翻译成汇编语言,实际上是编译的整个过程中的第一个阶段,之后的阶段和汇编语言的开发过程没有什么区别。这个阶段涉及到对源代码的词法分析、语法检查(通过 -std 指定遵循哪个标准),并根据优化(-O)要求进行翻译成汇编语言的动作。
语法检查
如果仅仅希望进行语法检查,可以用 gcc 的 -fsyntax-only 选项;如果为了使代码有比较好的可移植性,避免使用 gcc 的一些扩展特性,可以结合 -std 和 -pedantic(或者 -pedantic-erros )选项让源代码遵循某个 C 语言标准的语法。这里演示一个简单的例子:
$ cat hello.c
#include <stdio.h>
int main()
{
printf("hello, world\n")
return 0;
}
$ gcc -fsyntax-only hello.c
hello.c: In function ‘main’:
hello.c:5: error: expected ‘;’ before ‘return’
$ vim hello.c
$ cat hello.c
#include <stdio.h>
int main()
{
printf("hello, world\n");
int i;
return 0;
}
$ gcc -std=c89 -pedantic-errors hello.c #默认情况下,gcc是允许在程序中间声明变量的,但是turboc就不支持
hello.c: In function ‘main’:
hello.c:5: error: ISO C90 forbids mixed declarations and code
语法错误是程序开发过程中难以避免的错误(人的大脑在很多情况下都容易开小差),不过编译器往往能够通过语法检查快速发现这些错误,并准确地告知语法错误的大概位置。因此,作为开发人员,要做的事情不是“恐慌”(不知所措),而是认真阅读编译器的提示,根据平时积累的经验(最好总结一份常见语法错误索引,很多资料都提供了常见语法错误列表,如《C Traps & Pitfalls》和编辑器提供的语法检查功能(语法加亮、括号匹配提示等)快速定位语法出错的位置并进行修改。
编译器优化
语法检查之后就是翻译动作,gcc 提供了一个优化选项 -O,以便根据不同的运行平台和用户要求产生经过优化的汇编代码。例如,
$ gcc -o hello hello.c # 采用默认选项,不优化
$ gcc -O2 -o hello2 hello.c # 优化等次是2
$ gcc -Os -o hellos hello.c # 优化目标代码的大小
$ ls -S hello hello2 hellos # 可以看到,hellos 比较小, hello2 比较大
hello2 hello hellos
$ time ./hello
hello, world
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ time ./hello2 # 可能是代码比较少的缘故,执行效率看上去不是很明显
hello, world
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ time ./hellos # 虽然目标代码小了,但是执行效率慢了些
hello, world
real 0m0.002s
user 0m0.000s
sys 0m0.000s
根据上面的简单演示,可以看出 gcc 有很多不同的优化选项,主要看用户的需求了,目标代码的大小和效率之间貌似存在一个“纠缠”,需要开发人员自己权衡。
生成汇编语言文件
下面通过 -S 选项来看看编译出来的中间结果:汇编语言,还是以之前那个 hello.c 为例。
$ gcc -S hello.c # 默认输出是hello.s,可自己指定,输出到屏幕`-o -`,输出到其他文件`-o file`
$ cat hello.s
cat hello.s
.file "hello.c"
.section .rodata
.LC0:
.string "hello, world"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $4, %esp
movl $.LC0, (%esp)
call puts
movl $0, %eax
addl $4, %esp
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)"
.section .note.GNU-stack,"",@progbits
不知道看出来没?和课堂里学的 intel 的汇编语法不太一样,这里用的是 AT&T 语法格式。如果想学习 Linux 下的汇编语言开发,下一节开始的所有章节基本上覆盖了 Linux 下汇编语言开发的一般过程,不过这里不介绍汇编语言语法。
在学习后面的章节之前,建议自学旧金山大学的微机编程课程 CS 630,该课深入介绍了 Linux/X86 平台下的 AT&T 汇编语言开发。如果想在 Qemu 上做这个课程里的实验,可以阅读本文作者写的 CS630: Linux 下通过 Qemu 学习 X86 AT&T 汇编语言。
需要补充的是,在写 C 语言代码时,如果能够对编译器比较熟悉(工作原理和一些细节)的话,可能会很有帮助。包括这里的优化选项(有些优化选项可能在汇编时采用)和可能的优化措施,例如字节对齐、条件分支语句裁减(删除一些明显分支)等。
汇编
简述
汇编实际上还是翻译过程,只不过把作为中间结果的汇编代码翻译成了机器代码,即目标代码,不过它还不可以运行。如果要产生这一中间结果,可用 gcc -c,当然,也可通过 as 命令处理汇编语言源文件来产生。
汇编是把汇编语言翻译成目标代码的过程,如果有在 Windows 下学习过汇编语言开发,大家应该比较熟悉 nasm 汇编工具(支持 Intel 格式的汇编语言),不过这里主要用 as 汇编工具来汇编 AT&T 格式的汇编语言,因为 gcc 产生的中间代码就是 AT&T 格式的。
生成目标代码
下面来演示分别通过 gcc -c 选项和 as 来产生目标代码。
$ file hello.s
hello.s: ASCII assembler program text
$ gcc -c hello.s #用gcc把汇编语言编译成目标代码
$ file hello.o #file命令用来查看文件类型,目标代码可重定位的(relocatable),
#需要通过ld进行进一步链接成可执行程序(executable)和共享库(shared)
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
$ as -o hello.o hello.s #用as把汇编语言编译成目标代码
$ file hello.o
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
gcc 和 as 默认产生的目标代码都是 ELF 格式的,因此这里主要讨论ELF格式的目标代码(如果有时间再回顾一下 a.out 和 coff 格式,当然也可以先了解一下,并结合 objcopy 来转换它们,比较异同)。
ELF 文件初次接触
目标代码不再是普通的文本格式,无法直接通过文本编辑器浏览,需要一些专门的工具。如果想了解更多目标代码的细节,区分 relocatable(可重定位)、executable(可执行)、shared libarary(共享库)的不同,我们得设法了解目标代码的组织方式和相关的阅读和分析工具。下面主要介绍这部分内容。
BFD is a package which allows applications to use the same routines to
operate on object files whatever the object file format. A new object file
format can be supported simply by creating a new BFD back end and adding it to
the library.
binutils(GNU Binary Utilities)的很多工具都采用这个库来操作目标文件,这类工具有 objdump,objcopy,nm,strip 等(当然,我们也可以利用它。如果深入了解ELF格式,那么通过它来分析和编写 Virus 程序将会更加方便),不过另外一款非常优秀的分析工具 readelf 并不是基于这个库,所以也应该可以直接用 elf.h 头文件中定义的相关结构来操作 ELF 文件。
下面将通过这些辅助工具(主要是 readelf 和 objdump),结合 ELF 手册来分析它们。将依次介绍 ELF 文件的结构和三种不同类型 ELF 文件的区别。
ELF 文件的结构
ELF Header(ELF文件头)
Program Headers Table(程序头表,实际上叫段表好一些,用于描述可执行文件和可共享库)
Section 1
Section 2
Section 3
...
Section Headers Table(节区头部表,用于链接可重定位文件成可执行文件或共享库)
对于可重定位文件,程序头是可选的,而对于可执行文件和共享库文件(动态链接库),节区表则是可选的。可以分别通过 readelf 文件的 -h,-l 和 -S 参数查看 ELF 文件头(ELF Header)、程序头部表(Program Headers Table,段表)和节区表(Section Headers Table)。
文件头说明了文件的类型,大小,运行平台,节区数目等。
三种不同类型 ELF 文件比较
先来通过文件头看看不同ELF的类型。为了说明问题,先来几段代码吧。
/* myprintf.c */
#include <stdio.h>
void myprintf(void)
{
printf("hello, world!\n");
}
/* test.h -- myprintf function declaration */
#ifndef _TEST_H_
#define _TEST_H_
void myprintf(void);
#endif
/* test.c */
#include "test.h"
int main()
{
myprintf();
return 0;
}
下面通过这几段代码来演示通过 readelf -h 参数查看 ELF 的不同类型。期间将演示如何创建动态链接库(即可共享文件)、静态链接库,并比较它们的异同。
编译产生两个目标文件 myprintf.o 和 test.o,它们都是可重定位文件(REL):
$ gcc -c myprintf.c test.c
$ readelf -h test.o | grep Type
Type: REL (Relocatable file)
$ readelf -h myprintf.o | grep Type
Type: REL (Relocatable file)
根据目标代码链接产生可执行文件,这里的文件类型是可执行的(EXEC):
$ gcc -o test myprintf.o test.o
$ readelf -h test | grep Type
Type: EXEC (Executable file)
用 ar 命令创建一个静态链接库,静态链接库也是可重定位文件(REL):
$ ar rcsv libmyprintf.a myprintf.o
$ readelf -h libmyprintf.a | grep Type
Type: REL (Relocatable file)
可见,静态链接库和可重定位文件类型一样,它们之间唯一不同是前者可以是多个可重定位文件的“集合”。
静态链接库可直接链接(只需库名,不要前面的 lib),也可用 -l 参数,-L 指定库搜索路径。
$ gcc -o test test.o -lmyprintf -L./
编译产生动态链接库,并支持 major 和 minor 版本号,动态链接库类型为 DYN:
$ gcc -Wall myprintf.o -shared -Wl,-soname,libmyprintf.so.0 -o libmyprintf.so.0.0
$ ln -sf libmyprintf.so.0.0 libmyprintf.so.0
$ ln -sf libmyprintf.so.0 libmyprintf.so
$ readelf -h libmyprintf.so | grep Type
Type: DYN (Shared object file)
动态链接库编译时和静态链接库类似:
$ gcc -o test test.o -lmyprintf -L./
但是执行时需要指定动态链接库的搜索路径,把 LD_LIBRARY_PATH 设为当前目录,指定 test 运行时的动态链接库搜索路径:
$ LD_LIBRARY_PATH=./ ./test
$ gcc -static -o test test.o -lmyprintf -L./
在不指定 -static 时会优先使用动态链接库,指定时则阻止使用动态链接库,这时会把所有静态链接库文件加入到可执行文件中,使得执行文件很大,而且加载到内存以后会浪费内存空间,因此不建议这么做。
经过上面的演示基本可以看出它们之间的不同:
- 可重定位文件本身不可以运行,仅仅是作为可执行文件、静态链接库(也是可重定位文件)、动态链接库的 “组件”。
- 静态链接库和动态链接库本身也不可以执行,作为可执行文件的“组件”,它们两者也不同,前者也是可重定位文件(只不过可能是多个可重定位文件的集合),并且在链接时加入到可执行文件中去。
- 而动态链接库在链接时,库文件本身并没有添加到可执行文件中,只是在可执行文件中加入了该库的名字等信息,以便在可执行文件运行过程中引用库中的函数时由动态链接器去查找相关函数的地址,并调用它们。
从这个意义上说,动态链接库本身也具有可重定位的特征,含有可重定位的信息。对于什么是重定位?如何进行静态符号和动态符号的重定位,我们将在链接部分和《动态符号链接的细节》一节介绍。
ELF 主体:节区
下面来看看 ELF 文件的主体内容:节区(Section)。
ELF 文件具有很大的灵活性,它通过文件头组织整个文件的总体结构,通过节区表 (Section Headers Table)和程序头(Program Headers Table 或者叫段表)来分别描述可重定位文件和可执行文件。但不管是哪种类型,它们都需要它们的主体,即各种节区。
在可重定位文件中,节区表描述的就是各种节区本身;而在可执行文件中,程序头描述的是由各个节区组成的段(Segment),以便程序运行时动态装载器知道如何对它们进行内存映像,从而方便程序加载和运行。
下面先来看看一些常见的节区,而关于这些节区(Section)如何通过重定位构成不同的段(Segments),以及有哪些常规的段,我们将在链接部分进一步介绍。
可以通过 readelf -S 查看 ELF 的节区。(建议一边操作一边看文档,以便加深对 ELF 文件结构的理解)先来看看可重定位文件的节区信息,通过节区表来查看:
默认编译好 myprintf.c,将产生一个可重定位的文件 myprintf.o,这里通过 myprintf.o 的节区表查看节区信息。
$ gcc -c myprintf.c
$ readelf -S myprintf.o
There are 11 section headers, starting at offset 0xc0:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000018 00 AX 0 0 4
[ 2] .rel.text REL 00000000 000334 000010 08 9 1 4
[ 3] .data PROGBITS 00000000 00004c 000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 00004c 000000 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 00004c 00000e 00 A 0 0 1
[ 6] .comment PROGBITS 00000000 00005a 000012 00 0 0 1
[ 7] .note.GNU-stack PROGBITS 00000000 00006c 000000 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 00006c 000051 00 0 0 1
[ 9] .symtab SYMTAB 00000000 000278 0000a0 10 10 8 4
[10] .strtab STRTAB 00000000 000318 00001a 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
用 objdump -d 可看反编译结果,用 -j 选项可指定需要查看的节区:
$ objdump -d -j .text myprintf.o
myprintf.o: file format elf32-i386
Disassembly of section .text:
00000000 <myprintf>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: 83 ec 0c sub $0xc,%esp
9: 68 00 00 00 00 push $0x0
e: e8 fc ff ff ff call f <myprintf+0xf>
13: 83 c4 10 add $0x10,%esp
16: c9 leave
17: c3 ret
用 -r 选项可以看到有关重定位的信息,这里有两部分需要重定位:
$ readelf -r myprintf.o
Relocation section '.rel.text' at offset 0x334 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
0000000a 00000501 R_386_32 00000000 .rodata
0000000f 00000902 R_386_PC32 00000000 puts
.rodata 节区包含只读数据,即我们要打印的 hello, world!
$ readelf -x .rodata myprintf.o
Hex dump of section '.rodata':
0x00000000 68656c6c 6f2c2077 6f726c64 2100 hello, world!.
没有找到 .data 节区, 它应该包含一些初始化的数据:
$ readelf -x .data myprintf.o
Section '.data' has no data to dump.
也没有 .bss 节区,它应该包含一些未初始化的数据,程序默认初始为 0:
$ readelf -x .bss myprintf.o
Section '.bss' has no data to dump.
.comment 是一些注释,可以看到是是 Gcc 的版本信息
$ readelf -x .comment myprintf.o
Hex dump of section '.comment':
0x00000000 00474343 3a202847 4e552920 342e312e .GCC: (GNU) 4.1.
0x00000010 3200 2.
.note.GNU-stack 这个节区也没有内容:
$ readelf -x .note.GNU-stack myprintf.o
Section '.note.GNU-stack' has no data to dump.
.shstrtab 包括所有节区的名字:
$ readelf -x .shstrtab myprintf.o
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
0x00000010 002e7368 73747274 6162002e 72656c2e ..shstrtab..rel.
0x00000020 74657874 002e6461 7461002e 62737300 text..data..bss.
0x00000030 2e726f64 61746100 2e636f6d 6d656e74 .rodata..comment
0x00000040 002e6e6f 74652e47 4e552d73 7461636b ..note.GNU-stack
0x00000050 00 .
符号表 .symtab 包括所有用到的相关符号信息,如函数名、变量名,可用 readelf 查看:
$ readelf -symtab myprintf.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS myprintf.c
2: 00000000 0 SECTION LOCAL DEFAULT 1
3: 00000000 0 SECTION LOCAL DEFAULT 3
4: 00000000 0 SECTION LOCAL DEFAULT 4
5: 00000000 0 SECTION LOCAL DEFAULT 5
6: 00000000 0 SECTION LOCAL DEFAULT 7
7: 00000000 0 SECTION LOCAL DEFAULT 6
8: 00000000 24 FUNC GLOBAL DEFAULT 1 myprintf
9: 00000000 0 NOTYPE GLOBAL DEFAULT UND puts
字符串表 .strtab 包含用到的字符串,包括文件名、函数名、变量名等:
$ readelf -x .strtab myprintf.o
Hex dump of section '.strtab':
0x00000000 006d7970 72696e74 662e6300 6d797072 .myprintf.c.mypr
0x00000010 696e7466 00707574 7300 intf.puts.
从上表可以看出,对于可重定位文件,会包含这些基本节区 .text, .rel.text, .data, .bss, .rodata, .comment, .note.GNU-stack, .shstrtab, .symtab 和 .strtab。
汇编语言文件中的节区表述
为了进一步理解这些节区和源代码的关系,这里来看一看 myprintf.c 产生的汇编代码。
$ gcc -S myprintf.c
$ cat myprintf.s
.file "myprintf.c"
.section .rodata
.LC0:
.string "hello, world!"
.text
.globl myprintf
.type myprintf, @function
myprintf:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
subl $12, %esp
pushl $.LC0
call puts
addl $16, %esp
leave
ret
.size myprintf, .-myprintf
.ident "GCC: (GNU) 4.1.2"
.section .note.GNU-stack,"",@progbits
是不是可以从中看出可重定位文件中的那些节区和汇编语言代码之间的关系?在上面的可重定位文件,可以看到有一个可重定位的节区,即 .rel.text,它标记了两个需要重定位的项,.rodata 和 puts。这个节区将告诉编译器这两个信息在链接或者动态链接的过程中需要重定位, 具体如何重定位?将根据重定位项的类型,比如上面的 R_386_32 和 R_386_PC32。
到这里,对可重定位文件应该有了一个基本的了解,下面将介绍什么是可重定位,可重定位文件到底是如何被链接生成可执行文件和动态链接库的,这个过程除了进行一些符号的重定位外,还进行了哪些工作呢?
本文详细解析了GCC编译器的编译流程,包括预处理、编译、汇编和链接四个阶段。深入探讨了语法检查、代码优化、汇编语言生成及ELF文件结构等内容。
编译及汇编&spm=1001.2101.3001.5002&articleId=105105201&d=1&t=3&u=ea3aa0fb8b4d4c34ace5ea407fed722e)
1543

被折叠的 条评论
为什么被折叠?



