@(C語(yǔ)言)[code]
用一段簡(jiǎn)單的代碼,探討下從C代碼到最終可執(zhí)行文件的編譯過(guò)程荠诬,追根究底汁蝶。
偶爾了解下底層,也就沒(méi)那么多莫名其妙了窃躲。
工作原因有時(shí)候會(huì)用python寫寫測(cè)試工具变秦,感受到其快速實(shí)現(xiàn)應(yīng)用的便利,但由于偏底層開發(fā)框舔,主力語(yǔ)言依然是C蹦玫。對(duì)于開發(fā)語(yǔ)言沒(méi)有什么優(yōu)劣概念,在特定的情景下哪種實(shí)現(xiàn)更佳就用哪種刘绣,工具合適才是最好的樱溉。
個(gè)人開發(fā)環(huán)境 ubuntu 14.04
編譯的作用
相比python,lua等腳本語(yǔ)言解釋執(zhí)行方式纬凤,編譯C是為了提高程序的運(yùn)行效率福贞。把對(duì)用戶友好的語(yǔ)言文本編譯成對(duì)機(jī)器友好的特定指令直接執(zhí)行,而不是執(zhí)行時(shí)一條一條通過(guò)解釋器解析執(zhí)行停士,很大地提高了執(zhí)行的效率挖帘。對(duì)應(yīng)C主要用于底層,系統(tǒng)層次恋技,追求高性能表現(xiàn)拇舀,亦或者,平臺(tái)資源限制蜻底。
編譯的過(guò)程
gcc 的編譯流程分為四個(gè)步驟:
計(jì)算機(jī)系統(tǒng)設(shè)計(jì)基本原則:層次化和抽象骄崩。
編寫一個(gè)最簡(jiǎn)單的程序 hello.c,以此為例薄辅,看看各個(gè)過(guò)程做了什么事情要拂。
#include<stdio.h>
#define NUM(x) ((x) + 1)
int main(void)
{
printf("Hello world %d\\\\r\\\\n", NUM(1));
return 0;
}
預(yù)處理(Pre-Processing)
預(yù)處理主要完成的工作:
- 根據(jù)
#if
后面的條件決定需要編譯的代碼 - 將源文件中
#include
格式包含的文件直接復(fù)制到編譯的源文件中 - 用實(shí)際值替換用
#define
定義的字符串
對(duì)源代碼進(jìn)行預(yù)處理操作
$ gcc -E hello.c -o hello.i
使用編輯器打開輸出hello.i,一看嚇一跳站楚,原本7脱惰、8的代碼變成800多行
截取開頭結(jié)尾如下
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
...
...
int main(void)
{
printf("Hello world %d\\\\r\\\\n", ((1) + 1));
return 0;
}
我打開文件 stdio.h 對(duì)比發(fā)現(xiàn),hello.i 文件開頭多出來(lái)的一大堆東西窿春,就是stdio.h 經(jīng)過(guò)#if
條件選擇后留下的(包括其他包含文件的展開拉一,同理)采盒。同時(shí)在最下面看到熟悉的printf函數(shù)中定義的宏被直接
替換成對(duì)應(yīng)的文本。
在這里提出兩個(gè)問(wèn)題
- 預(yù)處理宏展開可能陷入死循環(huán)?
我修改了了代碼, 宏里面調(diào)用了自己舅踪,并且沒(méi)有遞歸退出條件
#include<stdio.h>
#define NUM(x) (NUM(x) + 1)
int main(void)
{
printf("Hello world %d\\\\r\\\\n", NUM(1));
return 0;
}
輸出hello.i可以看到纽甘,宏展開遇到自己就會(huì)停止良蛮,避免陷入死循環(huán)
int main(void)
{
printf("Hello world %d\\\\r\\\\n", (NUM(1) + 1));
return 0;
}
-
include 包含頭文件重復(fù)?
預(yù)處理會(huì)直接把對(duì)應(yīng)的頭問(wèn)題展開抽碌,如果包含的頭文件本身包含了自己,是否也會(huì)陷入死循環(huán)? 簡(jiǎn)單編寫文件測(cè)試
inc.h 文件
#include "inc.h"
inc.c 文件
#include "inc.h"
int main(void)
{
return 0;
}
預(yù)處理結(jié)果出錯(cuò)决瞳,提示如下:
inc.h:1:17: error: #include nested too deeply
#include "inc.h"
說(shuō)明對(duì)于文件的展開是可能出現(xiàn)重復(fù)货徙,遞歸的,也說(shuō)明了為什么在每個(gè)被包含的頭文件皮胡,需要添加如下代碼段痴颊。
#ifndef _XXX__XXX
#define _XXX_XXX
#endif
編譯(Compiling)
這一環(huán)節(jié),是把C代碼轉(zhuǎn)換為匯編代碼并根據(jù)需求進(jìn)行一定程度的優(yōu)化處理屡贺。
執(zhí)行命令進(jìn)行編譯
$ gcc -S hello.i -o hello.s
# gcc -S 實(shí)際調(diào)用cc1蠢棱,所以也可以直接使用cc1編譯
生成hello.s (AT&T 格式)
這代碼初看起來(lái)晦澀難懂,再細(xì)細(xì)看起來(lái)甩栈,還是很難懂泻仙。
.file "hello.c"
.section .rodata
.LC0:
.string "Hello world %d\\\\r\\\\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $2, %esi # 編譯器直接替換為宏 NUM(1) 的結(jié)果
movl $.LC0, %edi # 設(shè)置字符串保存的地址
movl $0, %eax
call printf
# 調(diào)用printf子例程,只有一個(gè)參數(shù)的printf gcc
# 會(huì)把它替換成_puts提高效率量没, 加-fno-builtin 取消
movl $0, %eax # main return 0
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4"
.section .note.GNU-stack,"",@progbits
編譯器的優(yōu)化
編譯會(huì)有一個(gè)中間過(guò)程玉转,進(jìn)行優(yōu)化(前端)后再最終輸出匯編代碼(后端), gcc 可以通過(guò)以下命令查看, 感覺(jué)不是給人類看的殴蹄。
$ gcc -S -fdump-rtl-expand hello.c
使用clang(<-編譯器)也可以查看輸出中間過(guò)程:
$ clang-3.5 -S -emit-llvm hello.c
clang 輸出的可讀性更強(qiáng)究抓,可以大概看出程序的面貌(因?yàn)檫@個(gè)程序很簡(jiǎn)單...)
; ModuleID = 'hello.c'
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"
@.str = private unnamed_addr constant [17 x i8] c"Hello world %d\\\\0D\\\\0A\\\\00", align 1
; Function Attrs: nounwind uwtable
define i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, i32* %1
%2 = call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([17 x i8]* @.str, i32 0, i32 0), i32 2)
ret i32 0
}
declare i32 @printf(i8*, ...) #1
attributes #0 = { nounwind uwtable "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "unsafe-fp-math"="false" "use-soft-float"="false" }
!llvm.ident = !{!0}
!0 = metadata !{metadata !"Ubuntu clang version 3.5.0-4ubuntu2~trusty2 (tags/RELEASE_350/final) (based on LLVM 3.5.0)"}
我嘗試在hello.c 的源代碼中添加一個(gè)無(wú)用的循環(huán)
for (int i = 0; i < 10; ++i) {
i = i;
}
然后分別用以下兩個(gè)條命令編譯,查看輸出中間文件.ll (使用clang是因?yàn)檩敵鼋Y(jié)果比較適合閱讀)
# 默認(rèn)不優(yōu)化處理 -O0
$ clang-3.5 -S -emit-llvm hello.c
# 開啟代碼優(yōu)化
$ clang-3.5 -O3 -S -emit-llvm hello.c
第一種不優(yōu)化情況下袭灯,編譯器老老實(shí)實(shí)把我寫的"沒(méi)啥作用"的代碼原原本本的編譯出來(lái).
第二種進(jìn)行了優(yōu)化刺下, 那段代碼不見了......
我想起工作上遇到的,使用for 進(jìn)行簡(jiǎn)單延時(shí)匹配一些硬件操作的時(shí)序稽荧,悲劇了.
(輸出結(jié)果我就不貼上來(lái)了怠李。)
中間層優(yōu)化是和體系代碼無(wú)關(guān)的情況下進(jìn)行的,優(yōu)化后再調(diào)用對(duì)應(yīng)體系的后端生成匯編代碼蛤克。 M中體系都可以共用中間層優(yōu)化捺癞,而不是M中體系重新實(shí)現(xiàn)M中優(yōu)化。
匯編(Assembling)
這一步驟相對(duì)簡(jiǎn)單构挤,將匯編代碼轉(zhuǎn)換為對(duì)應(yīng)的機(jī)器執(zhí)行指令髓介,由于這一步丟失的信息很少,所以可以通過(guò)反匯編把機(jī)器碼還原為匯編代碼筋现,但是再進(jìn)一步還原到高級(jí)語(yǔ)言就不可能了唐础。
$ gcc -c hello.s -o hello.o
# 可以直接調(diào)用匯編器 as
$ as hello.s -o hello.o箱歧。
使用objdump
對(duì)生成的ELF進(jìn)行反匯編
$ objdump -S hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: be 02 00 00 00 mov $0x2,%esi
9: bf 00 00 00 00 mov $0x0,%edi
e: b8 00 00 00 00 mov $0x0,%eax
13: e8 00 00 00 00 callq 18 <main+0x18> # 看這里
18: b8 00 00 00 00 mov $0x0,%eax
1d: 5d pop %rbp
1e: c3 retq
看到 13行, 原本call printf 的那句被替換為一個(gè)跳轉(zhuǎn)一膨,而且跳轉(zhuǎn)到下一條指令呀邢。因?yàn)閜rintf是一個(gè)外部調(diào)用,這個(gè)地址需要下一步鏈接的時(shí)候才能確定豹绪,這時(shí)候只是一個(gè)占位价淌。
鏈接(Linking)
主要是在不同模塊間對(duì)符號(hào)進(jìn)行重定位
在ELF文件 hello.o 里保存一張重定位表(relocation table),保存了其他地方的函數(shù)瞒津、變量(統(tǒng)稱符號(hào))的名字和地址蝉衣。
可以通過(guò)readelf
讀取出來(lái)
$ readelf --relocs hello.o
Relocation section '.rela.text' at offset 0x5a0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000a 00050000000a R_X86_64_32 0000000000000000 .rodata + 0
000000000014 000a00000002 R_X86_64_PC32 0000000000000000 printf - 4
Relocation section '.rela.eh_frame' at offset 0x5d0 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
可以看到,匯編后巷蚪, printf的地址還是空的病毡,沒(méi)有填寫上對(duì)應(yīng)的地址。
使用nm
可以查看文件的符號(hào)定義, 可以看到 "U", 表示該符號(hào)未定義屁柏。
$ nm hello.o
0000000000000000 T main
U printf
printf 是在lib.a庫(kù)(由多個(gè).O文件打包就成了.a庫(kù))里面實(shí)現(xiàn)所啦膜,所以查看下里面的定義,可以看到具體是到printf.o這個(gè)文件淌喻。
$ objdump -t /usr/lib/x86_64-linux-gnu/libc.a | grep "printf"
...
printf.o: file format elf64-x86-64
0000000000000000 g F .text 000000000000009e __printf
0000000000000000 *UND* 0000000000000000 vfprintf
0000000000000000 g F .text 000000000000009e printf
...
而當(dāng)我手動(dòng)嘗試鏈接的時(shí)候僧家,又被提示一堆未定義,而這些工作gcc會(huì)自動(dòng)遞歸查找去解決似嗤。
$ gcc -static hello.c
$ ./a.out
Hello world 2
$ du -h a.out
856K a.out
$ nm a.out | grep " printf"
0000000000407ea0 T printf
編譯后執(zhí)行啸臀,發(fā)現(xiàn)一切正常,printf已經(jīng)定義了烁落,但是一個(gè)簡(jiǎn)單的程序竟然是856K....
$ gcc hello.c
$ ./a.out
Hello world 2
$ du -h a.out
12K a.out
$ nm a.out | grep " printf"
U printf@@GLIBC_2.2.5
采用動(dòng)態(tài)加載的模式編譯乘粒,應(yīng)用體積減小了很多,但是看到printf提示未定義伤塌,標(biāo)記改了灯萍,表示是一個(gè)動(dòng)態(tài)鏈接。
通過(guò)file
也可以查看執(zhí)行文件是否動(dòng)態(tài)鏈接
dynamically linked 和 statically linked
$ gcc hello.c
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=8bdbcefb6289597b2123017d2678b11a6f742f23, not stripped
$ gcc -static hello.c
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=25ff17d24016dd4a453a5ac53e3a3fee0f00a5ec, not stripped
這就是動(dòng)態(tài)鏈接庫(kù)的好處了每聪,把共用的代碼加載到系統(tǒng)旦棉,每個(gè)程序需要用到時(shí)候直接調(diào)用,而不需要都包含到每個(gè)可執(zhí)行文件中药薯,減少開銷绑洛。在執(zhí)行的時(shí)候,通過(guò)加載器獲取實(shí)際地址執(zhí)行童本。
其實(shí)動(dòng)態(tài)鏈接庫(kù)是不知道自己會(huì)被加載到內(nèi)存哪個(gè)位置的真屯,所以對(duì)于這個(gè)種鏈接,程序在執(zhí)行的時(shí)候穷娱,才能獲取到實(shí)際的地址绑蔫,涉及到GOT和PLI运沦。
GOT中的信息需要在動(dòng)態(tài)鏈接庫(kù)被程序加載后立刻填寫正確。這就給采用動(dòng)態(tài)鏈接庫(kù)的程序在啟動(dòng)時(shí)帶來(lái)了一定額外開銷配深,從而減緩了啟動(dòng)速度携添。ELF采用了做延遲綁定的做法來(lái)解決這一問(wèn)題÷ㄒ叮基本思想就是通過(guò)增加另外一個(gè)間接層烈掠,使得函數(shù)第一次被用到時(shí)才進(jìn)行綁定,這就是PLT(Procedure Linkage Table)的作用澜共。