C - Generate Executable File
Generate Executable File
生成一个可执行文件的主要步骤有:预处理,编译,汇编,链接,那么这些步骤究竟干了什么,不同阶段生成的中间文件对我们有什么帮助吗?它们的内容是什么?本文将详细的深入从C语言源码到可执行文件生成的全过程
预备知识:编译原理
我们这里提到的可执行文件生成过程与编译原理中讲述的编译过程只是对一件事的两个角度的看法
编译原理的编译过程:
1. 词法分析,将源代码整理为词法单元,所谓词法单元是指标志符,关键字,常量,操作符以及标点符号等,并且词法分析阶段会忽略源代码中的注释内容
2. 语法分析,利用词法分析产生的词法单元并根据描述程序语言语法的形式化定义,来构造语法解析树
3. 中间代码生成,主要工作是将源码中的程序语言转换为另一种更为底层的语言(一般转换为汇编语言),并且该过程一般伴随着代码优化(机器代码级的优化一般比较困难)和语义分析(比如:类型检查)
4. 机器代码生成,将中间代码转换为适于特定平台的机器代码
此外在词法分析和语法分析阶段会生成符号表以供语义分析和机器代码生成时使用
示例代码
/* main.c */
#include "utils.h"
int count = 0;
int main(){
print_int(count);
return 0;
}
/* utils.h */
extern void print_int(int i);
/* utils.c */
void print_int(int i){
printf("I'm %d\n", i);
}
预处理-preprocessing
通常我们认为预处理主要的工作有:宏扩展,头文件包含,条件编译,下面将一一介绍这些主要功能并提到一些预处理阶段不太常见的工作
预处理的主要流程(按序进行):
-
trigraph replacement,将trigraph sequences替换为单个字符
-
line splicing,将行尾含有转义符
\
的行和下一行接起来 -
tokenization,按照语言语法将源码贪心的转换为token和空白(这里的token指符合语言语法的字符串),并将注释移除,即编译原理中的词法分析阶段
-
宏替换和预处理指令的执行,宏替换就是简单的字符串替换,预处理指令主要是指条件编译指令和头文件包含指令
头文件包含
-
include作用
include指令的作用在于插入被引用外部代码的信息(即头文件的内容),也就是说如果你的源文件中用到别处(例:标准库或者另一个文件)的函数的话,你需要将被引用函数对应的头文件include进来
-
include语法
#include <stdio.h> #include "mylist.h"
使用尖括号表示被引用的是标准库的头文件,预处理器会到默认的标准库include目录中查找该头文件,如果使用双引号则会将源码当前目录追加到include搜索目录的尾部,也就是说如果在标准库include目录中找不到的话,就到当前目录中查找
默认的include目录是可以通过配置文件,命令行选项或环境变量,以及makefile文件进行配置的,因为并非每个平台的标准库include目录路径都相同,这样保证了源码的可移植性
头文件的扩展名为.h,仅仅只是约定而已,事实上有的在某些特殊的场合你会看到被include的文件扩展名并非是.h
头文件常常要采取一些措施以保证自己只会被包含一次,(详解:如何保证头文件仅被包含一次?)
条件编译
条件编译指令有: #if, #else, #elif, #ifdef, #ifndef, #endif
,条件编译使得可以根据特定条件决定源码进行如何预处理,例如:
#ifdef __unix__
#include <unistd.h>
#elif defined _WIN32
#include <windows.h>
#endif
#if !(defined __LP64__ || defined __LLP64__) || defined _WIN32 && !defined _WIN64
/* compiling for 32-bit system */
#else
/* compiling for 64-bit system */
#endif
#if PYTHON_VERSION <= 2.7.0
/* error directive can output message while preprocessing */
#error python version less-equal than 2.7.0
#endif
宏扩展
-
宏定义
#define PI 3.1415 #define ROT(c) ((((c) - 'a')+13)%26 + ((c) - 'a'))
第一种形式仅仅只是简单的字符串替换,你当然可以将替换内容留空,第二种宏是带有参数的,要注意的是宏名跟包围参数的左括号之间不能有空格,同时强烈建议将替换内容整体以及其中的参数用括号围起来,这样做是为了避免宏替换后运算顺序混乱,一定要记住宏替换仅仅是字符串替换,不做类型检查,也不会计算表达式的值,一定要为宏指定辨识度到的名字,避免错将程序其他内容替换了。
-
删除宏
#undef PI
在指令之后宏名失效
-
宏嵌套
#define PI 3.14 #define CIRCLE(r) ((r)*(r)*PI)
-
宏内容连接
#define JOIN_STR(str1, str2) str1##str2
通过语法
##
可以将任意内容连接起来,不仅仅是变量名,例如: +, -,=等都可以被连接 -
预处理器自己预先定以的宏
当前文件名,
__FILE__
当前行号
__LINE__
C标准支持标记
__STDC__
,如果该宏定义为1表示系统的C语言实现符合标准C(即ANSI-C)规范C标准版本号
__STDC_VERSION__
,用数字字面量指定系统实现哪个标准版本的C语言C++标准标记
__cplusplus
当前日期
__DATE__
当前时间
__TIME__
-
不定参数宏
#define MESSAGES(...) fprintf(stderr, __VAR_ARGS__)
该特性是在C99中引入的,宏参数使用
...
表示可以接受不定数目的参数,可以从宏__VAR_ARGS__
获取传入的参数 -
不同的编译器,操作系统会有自己特有的宏定义,它们都是non-standard的
预处理的杂项
-
预处理时报错
#error there is something wrong
-
修改行号以及文件名
#line 274 "test.c"
该行之后的宏
__FILE__
改变,__LINE__
将以重新指定的行号计数 -
当前函数名变量
__func__
该变量在C99中引入的,但要注意的是该变量是编译器自动在函数内部生成的一个局部变量,不要同宏
__FILE__
混淆,__func__
变量在预处理后并不会改变 -
编译器特殊的预处理功能
在c99中引入了指令
#pragma
,允许预处理阶段执行非标准的由编译器自己实现的功能,例如在预处理阶段给出警告信息(前面提到的error信息会导致编译过程被终止),GNU提供了指令#warning "this is a warning"
,Microsoft提供了#pragma message("this is a warning")
预处理示例代码
$ gcc -E main.c -o main.i
$ gcc -E utils.c -o utils.i
现在应该新生成两个文件main.i
和utils.i
,你可以打开这两个文件(都是文本文件)看看,比源码多了很多内容,这是因为头文件插入造成的(主要是utils.c
中使用了stdio.h
头文件)
编译
该过程对应于编译原理中的语法分析和中间代码生成两个阶段,C语言的编译过程可以理解为将C语言转换为汇编语言,如果源代码中有语法错误则不能通过编译阶段(比如句尾分号的缺失,使用未定义的变量等)
编译示例代码
$ gcc -S main.i utils.i
或
$ gcc -S main.c utils.c
现在应该生成了两个新文件main.s
和utils.s
,打开这两个文件(都是文本文件)可以看到都是汇编代码
创建符号表
从编译原理的角度来看,编译阶段结束后已经创建了符号表(但显然我们编译后生成的汇编文件没有什么符号表,所以我们说从编译原理角度上来讲经过词法和语法分析后可以构建出符号表了,但是由于我们使用gcc将编译,汇编过程分开进行,所以实际上符号表的创建是在汇编阶段完成的),符号表简单来讲就是记录了当前文件的标识符信息(作用域,默认值等),符号表的创建对于后续的链接过程,以及调试程序都有极大的帮助
汇编
该过程对应编译原理中的机器代码生成阶段,该过程将汇编语言转换为机器语言以生成目标文件,一个源码文件对应一个目标文件
汇编示例代码
$ gcc -c main.s utils.s
或
$ gcc -c main.c utils.c
现在生成了文件main.o
和utils.o
,这两文件都是目标文件,目标文件可不是文本文件了,它是什么文件呢?
$ file main.o
=> main.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
从输出的信息可知目标文件是elf文件,其实linux下的目标文件是ELF(Executable and Linking Format)(windows下的目标文件是COFF(Common Object-File Format)),我们只要知道目标文件只是一种特殊格式的二进制文件即可,那么如何查看目标文件呢?linux中有一些通用的二进制文件查看工具,例如:hexdump -C main.o
,但你会发现并没有输出多少有用的信息,这是因为hexdump
只是以十六进制形式解读目标文件,它并不了解目标文件的格式所以无法解读出有用的信息,linux中的readelf和objdump是专门用于查看elf文件的工具,下面我们将通过这两个工具来了解目标文件的内容(建议先阅读ELF文件内容简介)
目标文件的内容构成
ELF Header
Program Header table(optional)
Section1
Section2
...
SectionN
Section Header table
查看file header:
$ objdump -f main.o
=> main.o: file format elf32-i386
architecture: i386, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x00000000
查看sections header:
$ objdump -h main.o
=> main.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000001d 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000054 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 00000000 00000000 00000054 2**2
ALLOC
3 .debug_abbrev 00000056 00000000 00000000 00000054 2**0
CONTENTS, READONLY, DEBUGGING
4 .debug_info 00000062 00000000 00000000 000000aa 2**0
CONTENTS, RELOC, READONLY, DEBUGGING
5 .debug_line 00000037 00000000 00000000 0000010c 2**0
CONTENTS, RELOC, READONLY, DEBUGGING
6 .debug_pubnames 00000025 00000000 00000000 00000143 2**0
CONTENTS, RELOC, READONLY, DEBUGGING
7 .debug_aranges 00000020 00000000 00000000 00000168 2**0
CONTENTS, RELOC, READONLY, DEBUGGING
8 .debug_str 0000004f 00000000 00000000 00000188 2**0
CONTENTS, READONLY, DEBUGGING
9 .comment 0000002d 00000000 00000000 000001d7 2**0
CONTENTS, READONLY
10 .note.GNU-stack 00000000 00000000 00000000 00000204 2**0
CONTENTS, READONLY
11 .debug_frame 00000034 00000000 00000000 00000204 2**2
CONTENTS, RELOC, READONLY, DEBUGGING
查看symbol table:
$ objdump -t main.o # 如果目标文件是动态链接库则,objdump -T main.o
=> main.o: file format elf32-i386
SYMBOL TABLE:
00000000 l df *ABS* 00000000 main.c
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000000 l d .debug_abbrev 00000000 .debug_abbrev
00000000 l d .debug_info 00000000 .debug_info
00000000 l d .debug_line 00000000 .debug_line
00000000 l d .debug_pubnames 00000000 .debug_pubnames
00000000 l d .debug_aranges 00000000 .debug_aranges
00000000 l d .debug_str 00000000 .debug_str
00000000 l d .note.GNU-stack 00000000 .note.GNU-stack
00000000 l d .debug_frame 00000000 .debug_frame
00000000 l d .comment 00000000 .comment
00000000 g O .bss 00000004 count
00000000 g F .text 0000001d main
00000000 *UND* 00000000 print_int
查看namespace:
$ nm main.o
=> 00000000 B count
00000000 T main
U print_int
查看relocation records:
$ objdump -r main.o # 如果目标文件是动态链接库则,objdump -R main.o
=> main.o: file format elf32-i386
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000a R_386_32 count
00000012 R_386_PC32 print_int
RELOCATION RECORDS FOR [.debug_info]:
OFFSET TYPE VALUE
00000006 R_386_32 .debug_abbrev
0000000c R_386_32 .debug_str
00000011 R_386_32 .debug_str
00000015 R_386_32 .debug_str
00000019 R_386_32 .text
0000001d R_386_32 .text
00000021 R_386_32 .debug_line
00000027 R_386_32 .debug_str
00000031 R_386_32 .text
00000035 R_386_32 .text
00000043 R_386_32 .debug_str
00000050 R_386_32 .debug_str
0000005d R_386_32 count
RELOCATION RECORDS FOR [.debug_line]:
OFFSET TYPE VALUE
0000002a R_386_32 .text
RELOCATION RECORDS FOR [.debug_pubnames]:
OFFSET TYPE VALUE
00000006 R_386_32 .debug_info
RELOCATION RECORDS FOR [.debug_aranges]:
OFFSET TYPE VALUE
00000006 R_386_32 .debug_info
00000010 R_386_32 .text
RELOCATION RECORDS FOR [.debug_frame]:
OFFSET TYPE VALUE
00000018 R_386_32 .debug_frame
0000001c R_386_32 .text
relocation表的创建
生成目标文件时,对于文件使用的外部标识符(即非本文件定义的变量,函数等)汇编器的处理是,将外部标识符信息记录在目标文件的特定区域中,记录的关键信息是在当前目标文件中使用外部标识符的地址,这样在后续的链接过程可以将找到的外部标识符正确地址填入
链接
从多个目标文件和库文件生成可执行文件,该过程的实质是合并多个目标文件的内容以及解决目标文件中对外部标识符的引用,比如对库函数的调用会导致对库文件的链接,也就说如果调用了未定义的函数的话,只有在linking阶段才会报错
$ gcc main.o utils.o
或
$ gcc main.c utils.c
现在生成了文件a.out
,该文件也是ELF文件,内容结构跟目标文件是相似的,使用objdump
查看该文件内容的方法跟查看目标文件是一样的
链接库的方式
statically linked
linking期间将目标文件依赖的外部库整合(复制)到可执行文件中,这样的话除非re-link,否则无论外部库怎样变化都不会影响到已经生成的可执行程序,比如C标准库的静态文件libc.a就可以被静态链接,这样做的好处是不必假设安装程序的机器上已经含有特殊的库文件,缺点是生成的可执行文件较大
dynamically linked
linking时并不将外部库直接整合到可执行文件中,而是将依赖外部库的需求写进去,这样在可执行文件载入内存时会有runtime linker负责加载提供外部库的,也就是说linking时期并没有解决外部依赖(准确来讲linking时解决了对外部标志符的引用,即对引用外部符号的有效性做了检验,但是外部库的功能代码并没有包含到当前可执行文件中)而仅仅只是将依赖到外部库的信息写入而已,C标准库也有动态版libc.so,好处有,首先可执行文件变小了,如果依赖的外部库更新,程序不会受到影响(只是对于linking时写入动态库的名称以及版本,在编译器和runtime linker之间应该特殊的约定),此外利用virtual memory的内存保护特性可以使得一个动态库可以同时被多个进程使用,节省了内存空间