盘古开天辟地!咱们写了个C语言源文件,那从源文件到可执行程序这中间又发生了什么?编译,连接这些概念又是什么意思?带着对这些问题的好奇,我查了一些资料。其中,主要参考的是《程序员的自我修养》这本书和一些网上的博客。linux
在windows
下常常只须要单击Run
或者Debug
就能够运行一个C语言程序,这种便利隐藏了背后的复杂机制,而我想知道这背后到底发生了什么。c++
本文所使用的系统是ubuntu
,但这些概念也适用于windows
下。程序员
假如咱们写了一个很简单的helloworld.c
程序:ubuntu
#include <stdio.h> int main(int argc, char *argv[]) { printf("Hello,World!\n"); return 0; }
咱们都知道运行命令windows
gcc helloworld.c -o helloworld
即可以对这个文件进行编译,并命名可执行文件为helloworld
。而后运行sass
./helloworld Hello,World!
即可以执行该文件,可是这背后又经历了什么呢?bash
注意:函数
本文并非一篇严谨的探讨编译过程的文章,只是我对这个问题了解过程的一个梳理。code
在预处理阶段,咱们能够简单理解就是处理以"#"开始的那些预处理指令,好比说:ip
#define,#include,#if,#elif,#else,#endif
预处理器会按照这些指令的意义进行处理,将#define
定义的宏进行替换展开,将#include
包含的文件总体替换进来。
能够运行命令
gcc -E helloworld.c -o helloworld.i
来获得通过预处理后的文件,检查能够发现预处理确实帮咱们把#include
的文件包含进来了,另外在文件中还包含了一些行号信息,以便以后程序出错提示错误所在的位置。
这一步是将上一步获得的*.i
进行编译,获得汇编代码,能够运行命令
gcc -S helloworld.i -o helloworld.s
来获得通过汇编后的文件,该文件的其中一部分以下:
main: ... leaq .LC0(%rip), %rcx call puts ...
正好对应咱们在主程序中调用的函数printf
,因而咱们知道在这一步是生成了汇编文件。
这一步是将上一步的汇编代码汇编为具体的机器代码,能够运行命令
gcc -c helloworld.s -o helloworld.o
生成的helloworld.o
能够称为目标文件,下面咱们对目标文件来检查,帮助理解连接
过程。
上一步中生成的是目标文件,但这个目标文件尚未通过连接,也就是它其中的一些符号还没法肯定,好比说在上面的printf
咱们就没法肯定在哪里去寻找这个函数的具体定义,经过头文件stdio.h
咱们只是知道了它的定义形式,知道如何去调用它,可是具体执行的时候是须要代码的,那么去哪里找呢?寻找printf
并将它的地址写入到咱们的程序中就是连接的做用。
咱们在系统中常常打交道的文件有
Windows
下的.exe
,或者linux
下/bin/bash
文件Windows
下的.dll
,或者linux
下.so
文件在Linux
下可使用命令file
来查看文件的具体格式,让咱们运行
$ file helloworld.o helloworld.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
那么具体来讲,目标文件到底包含什么呢?首先必定会包含代码,其次是数据(定义的变量),除此之外,咱们还关心的是文件中包含的符号表,它是咱们后续执行连接最重要的内容了。
运行命令
$ readelf -S helloworld.o
能够查看咱们目标文件的段表,关于段表的详细介绍请查看《程序员的自我修养》这本书。
There are 13 section headers, starting at offset 0x2d8: 节头: [号] 名称 类型 地址 偏移量 大小 全体大小 旗标 连接 信息 对齐 [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000022 0000000000000000 AX 0 0 1 [ 2] .rela.text RELA 0000000000000000 00000228 0000000000000030 0000000000000018 I 10 1 8 [ 3] .data PROGBITS 0000000000000000 00000062 0000000000000000 0000000000000000 WA 0 0 1 [ 4] .bss NOBITS 0000000000000000 00000062 0000000000000000 0000000000000000 WA 0 0 1 [ 5] .rodata PROGBITS 0000000000000000 00000062 000000000000000d 0000000000000000 A 0 0 1 [ 6] .comment PROGBITS 0000000000000000 0000006f 000000000000002c 0000000000000001 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 0000000000000000 0000009b 0000000000000000 0000000000000000 0 0 1 [ 8] .eh_frame PROGBITS 0000000000000000 000000a0 0000000000000038 0000000000000000 A 0 0 8 [ 9] .rela.eh_frame RELA 0000000000000000 00000258 0000000000000018 0000000000000018 I 10 8 8 [10] .symtab SYMTAB 0000000000000000 000000d8 0000000000000120 0000000000000018 11 9 8 [11] .strtab STRTAB 0000000000000000 000001f8 000000000000002e 0000000000000000 0 0 1 [12] .shstrtab STRTAB 0000000000000000 00000270 0000000000000061 0000000000000000 0 0 1
咱们关心的是上述段表中的2
号段表:.rela.text
可重定位表。正如咱们以前所说的,在连接阶段要对可重定位文件中的一些符号进行重定位,因此咱们必须了解哪些符号须要进行定位,而.rela.text
就是用来记录相应的符号。
其中,符号表中会包含几种符号:
咱们先运行命令
$ nm helloworld.o U _GLOBAL_OFFSET_TABLE_ 0000000000000000 T main U puts
来查看咱们的目标文件中的符号表,能够看到咱们两个符号main
和puts
。之因此不是printf
多是编译中进行了改变。
让咱们运行另一个命令来详细查看符号表:
$ readelf -s helloworld.o Symbol table '.symtab' contains 12 entries: Num: Value Size Type Bind Vis Ndx Name ...... 9: 0000000000000000 34 FUNC GLOBAL DEFAULT 1 main 10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_ 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
又看到了咱们熟悉的两个符号,因为main
是在本文件中定义的因此它的类型是FUNC
函数,且Ndx=1
能够得知位于代码段,而puts
因为未定义,因此Ndx=UND(undefine)
,所以经过符号表咱们即可以得到哪些符号是在本文件中定义的,哪些符号是须要进行重定位的。
上面咱们知道了符号表的存在,下面咱们详细说明下连接的过程。
假设咱们有了两个文件,a.c
和b.c
。例子来自于《程序员的自我修养》。
/* a.c */ extern int shared; int main(){ int a=100; swap(&a, &shared); return 0; } /* b.c */ int shared = 1; // default is global variable, can be accessed by external program void swap(int *a, int *b){ *a ^= *b ^= *a ^= *b; // swap value }
首先使用gcc
编译这两个文件
$ gcc -c a.c b.c
而后咱们会获得两个文件a.o
,b.o
,分别查看这两个文件的符号表
$ readelf -s a.o Symbol table '.symtab' contains 13 entries: Num: Value Size Type Bind Vis Ndx Name ...... 8: 0000000000000000 81 FUNC GLOBAL DEFAULT 1 main 9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap $ readelf -s b.o Symbol table '.symtab' contains 10 entries: Num: Value Size Type Bind Vis Ndx Name ...... 8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 shared 9: 0000000000000000 75 FUNC GLOBAL DEFAULT 1 swap
因而,咱们能够看出,在a.o
中只定义了一个全局符号main
,而shared
和swap
都是未定义,而在b.o
中,shared
和swap
则是定义了的。
咱们将采用的连接命令为
$ ld a.o b.o -e main -o ab
main
做为主函数入口而后查看分配先后地址的分配状况
$ objdump -h a.o a.o: 文件格式 elf64-x86-64 节: Idx Name Size VMA LMA File off Algn 0 .text 00000051 0000000000000000 0000000000000000 00000040 2**0 CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE 1 .data 00000000 0000000000000000 0000000000000000 00000091 2**0 CONTENTS, ALLOC, LOAD, DATA ...... $ objdump -h b.o b.o: 文件格式 elf64-x86-64 节: Idx Name Size VMA LMA File off Algn 0 .text 0000004b 0000000000000000 0000000000000000 00000040 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .data 00000004 0000000000000000 0000000000000000 0000008c 2**2 CONTENTS, ALLOC, LOAD, DATA ......
我尝试了好几遍运行命令
$ ld a.o b.o -e main -o ab
可是都提示一个错误
a.o:在函数‘main’中: a.c:(.text+0x4b):对‘__stack_chk_fail’未定义的引用
不知道为何,因而我只好使用命令
$ gcc a.o b.o -o ab
可是生成后的文件和做者的就不太同样了,以下
节: Idx Name Size VMA LMA File off Algn ...... 13 .text 00000222 0000000000000560 0000000000000560 00000560 2**4 ...... 22 .data 00000014 0000000000201000 0000000000201000 00001000 2**3 CONTENTS, ALLOC, LOAD, DATA 23 .bss 00000004 0000000000201014 0000000000201014 00001014 2**0 ALLOC 24 .comment 0000002b 0000000000000000 0000000000000000 00001014 2**0 CONTENTS, READONLY
可是仍然是能够看出VMA(虚拟内存地址)已经被赋值了,而在以前的a.o
和b.o
中都是没有赋值的。
到这一步的意思是通过连接,咱们将两个目标文件合成到一个文件中了,而且每一个函数都有本身的相对地址,这时候咱们就能够给每个符号赋予地址了。
运行命令
$ readelf -s ab
来查看符号表,只列出相关的内容
Symbol table '.symtab' contains 66 entries: Num: Value Size Type Bind Vis Ndx Name 59: 000000000000066a 81 FUNC GLOBAL DEFAULT 14 main 62: 00000000000006bb 75 FUNC GLOBAL DEFAULT 14 swap 65: 0000000000201010 4 OBJECT GLOBAL DEFAULT 23 shared
咱们能够看出相关符号已经被赋予了具体的地址空间,也就是咱们完成了连接过程。
在完成上述过程后,咱们运行命令来反汇编查看
$ objdump -d ab 000000000000066a <main>: 66a: 55 push %rbp 66b: 48 89 e5 mov %rsp,%rbp 66e: 48 83 ec 10 sub $0x10,%rsp 672: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 679: 00 00 67b: 48 89 45 f8 mov %rax,-0x8(%rbp) 67f: 31 c0 xor %eax,%eax 681: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp) 688: 48 8d 45 f4 lea -0xc(%rbp),%rax 68c: 48 8d 35 7d 09 20 00 lea 0x20097d(%rip),%rsi # 201010 <shared> 693: 48 89 c7 mov %rax,%rdi 696: b8 00 00 00 00 mov $0x0,%eax 69b: e8 1b 00 00 00 callq 6bb <swap> # <swap> 6bb 6a0: b8 00 00 00 00 mov $0x0,%eax 6a5: 48 8b 55 f8 mov -0x8(%rbp),%rdx 6a9: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx 6b0: 00 00 6b2: 74 05 je 6b9 <main+0x4f> 6b4: e8 87 fe ff ff callq 540 <__stack_chk_fail@plt> 6b9: c9 leaveq 6ba: c3 retq
注意到swap
以及变量shared
的地址已经被正确地赋值给了程序,做为对比咱们查看下在连接以前程序的内容
$ objdump -d a.o a.o: 文件格式 elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 10 sub $0x10,%rsp 8: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax f: 00 00 11: 48 89 45 f8 mov %rax,-0x8(%rbp) 15: 31 c0 xor %eax,%eax 17: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp) 1e: 48 8d 45 f4 lea -0xc(%rbp),%rax 22: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 29 <main+0x29> 29: 48 89 c7 mov %rax,%rdi 2c: b8 00 00 00 00 mov $0x0,%eax 31: e8 00 00 00 00 callq 36 <main+0x36> 36: b8 00 00 00 00 mov $0x0,%eax 3b: 48 8b 55 f8 mov -0x8(%rbp),%rdx 3f: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx 46: 00 00 48: 74 05 je 4f <main+0x4f> 4a: e8 00 00 00 00 callq 4f <main+0x4f> 4f: c9 leaveq 50: c3 retq
咱们要注意的是偏移22
和偏移31
分别对应着shared
和swap
的调用,而第二列的十六进制表明这条指令,每一个指令的后四个字节为地址,能够看出这些地址都是0
,这说明在文件a.o
中,因为没法肯定具体的地址,此时编译器只是将其赋了一个特殊的地址0x0
,而后在最后的连接阶段再完成正确的地址赋值。
咱们还能够运行命令
$ objdump -r a.o a.o: 文件格式 elf64-x86-64 RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 0000000000000025 R_X86_64_PC32 shared-0x0000000000000004 0000000000000032 R_X86_64_PLT32 swap-0x0000000000000004 000000000000004b R_X86_64_PLT32 __stack_chk_fail-0x0000000000000004
其中的offset
描述了要重定位的位置。
事实上,在《程序员的自我修养》这本书中做者对于细节的探讨很深刻,要想彻底理解掌握实在太难。
我主要想总结下关于连接部分。大概的过程就是: