開發環境:ubuntu 16.04
編譯器:arm-linux-gnueabi-gcc 5.4.0
一、導讀
前些天幫助同事做linux內核熱補丁,製作linux內核熱補丁需要修改後C文件編譯出來的xxx.o或xxx.obj文件,然後就發現不少工作幾年的同事,一直以爲編譯就是一步完成的,不知道編譯xxx.o是怎麼產生的,尤其是公司成熟的平臺都寫好的腳本一鍵編譯,很多人就更不瞭解編譯過程是怎麼進行的。
作爲一個嵌入式軟件開發者,雖然更多的時候是使用工具和調用API,但瞭解其原理還是必要的,在出現問題的時候不至於束手無策;但畢竟不是做工具,不需要精通每一個細節,在需要的時候再深入即可。
二、背景知識
1. CPU內部運行的時候,只有0和1組成的機器碼,無論是獲取指令還是數據,都是通過訪問指令或數據的地址來完成的,像我們定義的全局變量a,CPU最終是通過訪問a所在的地址來獲取a的值;同樣對函數的調用,也是跳轉到函數所在的地址。所以我們C文件裏定義的變量和函數,最終都是通過其地址訪問的。
2. 在整個編譯完成後,編譯器會爲全局變量和函數分配地址,這個也叫做編譯地址,編譯地址可在生成的xxx.map文件中查看。當CPU實際運行時,全局變量和函數所在的實際地址叫做運行地址,所以程序要正確的運行,就需要實際運行地址和編譯地址對應,否則可能會出錯(地址無關代碼不會出錯,像Uboot開始的一段代碼就地址無關)。比如編譯器將變給變量a分配0x100地址,那麼訪問a的指令都會到0x100來取值,如果運行時,運行地址和編譯地址不等,a被放到0x200,那麼原來訪問0x100的指令就會取錯值。
3. gcc編譯產生的xxx.o和執行文件,都是ELF文件格式中的其中一種(當然還有別的格式,可自行百度),因爲重點在編譯過程,所以可以只關注text、data、bss和relocation等section。
三、編譯過程概要
從一個C代碼文件到可執行文件需要經過以下四個過程。
o 預處理(Preprocessing)
o 編譯(Compilation)
o 彙編(Assembly)
o 鏈接(Linking)
gcc編譯選項
root@ubuntu:/home/share/test# arm-linux-gnueabi-gcc --help
-E Preprocess only; do not compile, assemble or link
-S Compile only; do not assemble or link
-c Compile and assemble, but do not link
-o <file> Place the output into <file>
二、編譯過程
下面只做靜態編譯的例子,爲了更加清晰的說明每個過程的變化,使用如下簡單代碼舉例
test.h:
#ifndef _TEST_H
#define _TEST_H
typedef unsigned int uint;
typedef unsigned char uchar;
static int sum(int a,int b);
#endif
test.c
#include "test.h"
#include "init.h"
int a = 4;
int b = 9;
int c;
int d;
int main(void)
{
init(c,d);
c = sum(a,b);
d = e + f;
return 0;
}
static int sum(int a,int b)
{
return a+b;
}
inti.h
#ifndef _INIT_H
#define _INIT_H
#ifndef A
#define A 0
#endif
#define B 0
extern int e;
extern int f;
extern void init(int a,int b);
#endif
init.c
#include "init.h"
int e = 4;
int f = 6;
void init(int a,int b)
{
a = A;
b = B;
}
1. 預處理(Preprocessing)
預處理主要是對#開頭的關鍵字進行處理,例如#include、#define、和#ifdef等等。輸入預處理指令arm-linux-gnueabi-gcc -E -P xxx.c -o xxx.i,其中-P爲去掉 行號 和 文件等信息,這樣更方便查看;
root@ubuntu:/test# arm-linux-gnueabi-gcc -E -P test.c -o test.i
test.i:
typedef unsigned int uint; //將test.h在此展開
typedef unsigned char uchar;
static int sum(int a,int b);
extern int e; //將init.h在此展開
extern int f;
extern void init(int a,int b);
int a = 4;
int b = 9;
int c;
int d;
int main(void)
{
init(c,d);
c = sum(a,b);
d = e + f;
return 0;
}
static int sum(int a,int b)
{
return a+b;
}
root@ubuntu:/test# arm-linux-gnueabi-gcc -E -P init.c -o init.i
Init.i:
extern int e; //將init.h在此展開
extern int f;
extern void init(int a,int b);
int e = 4;
int f = 6;
void init(int a,int b)
{
a = 0; //將A的值進行替換
b = 0; //將B的值進行替換
}
通過上面的例子看到,#ifdef和#define都被進行了替換,不存在了,然後將#include的文件直接展開到了test.c和init.c中,這也是爲什麼一般不在xxx.h中定義全局函數和變量,當有多個文件包含此頭文件時就會產生重複定義的錯誤。綜上,預處理只是對#開頭的行進行處理,其實也並不進行語法錯誤檢查(可以將變量定義改錯試試)。
2. 編譯(Compilation)
編譯就是把C文件編程彙編,編譯的過程中要對C代碼進行語法檢查,像少了分號;或者單詞拼寫錯誤等都會造成編譯錯誤。每個工程都有很多的.c文件組成,編譯過程是對每個文件單獨進行的,對於引用的其它文件定義的全局變量或者函數,其實是不知道具體在哪,也不知掉其它文件有沒有真正定義或者定義是否正確(比如第一個編譯的文件怎麼知道它調用的外部函數被定義沒?其它文件可還沒編譯呢)。調用外部變量或者函數的聲明,也只是告訴編譯器別的地方有定義,真正有沒有編譯器其實不知道。究竟有沒有定義要到鏈接(Linking)的時候才知道。下面對test.c進行編譯而不對init.c進行編譯。
root@ubuntu:/test# gcc -S test.i
test.c
main:
@ args = 0, pretend = 0, frame = 0
@ frame_needed = 1, uses_anonymous_args = 0
push {fp, lr}
add fp, sp, #4
ldr r3, .L3 //c
ldr r2, [r3]
ldr r3, .L3+4 //d
ldr r3, [r3]
mov r1, r3
mov r0, r2
bl init
ldr r3, .L3+8 //a
ldr r2, [r3]
ldr r3, .L3+12 //b
ldr r3, [r3]
mov r1, r3
mov r0, r2
bl sum
mov r2, r0
ldr r3, .L3
str r2, [r3]
ldr r3, .L3+16 //e
ldr r2, [r3]
ldr r3, .L3+20 //f
ldr r3, [r3]
add r3, r2, r3
ldr r2, .L3+4
str r3, [r2]
mov r3, #0
mov r0, r3
pop {fp, pc}
.L4:
.align 2
.L3:
.word c
.word d
.word a
.word b
.word e
.word f
.size main, .-main
.align 2
.syntax unified
.arm
.type sum, %function
sum:
@ args = 0, pretend = 0, frame = 8
@ frame_needed = 1, uses_anonymous_args = 0
@ link register save eliminated.
str fp, [sp, #-4]!
add fp, sp, #0
sub sp, sp, #12
str r0, [fp, #-8]
str r1, [fp, #-12]
ldr r2, [fp, #-8]
ldr r3, [fp, #-12]
add r3, r2, r3
mov r0, r3
sub sp, fp, #0
@ sp needed
ldr fp, [sp], #4
bx lr
由上面可知,對於編譯成彙編代碼後,對函數的調用,仍然是用標號(因爲還不知道最終地址),如bl init、bl sum,對變量的引用使用.L3 + offset 實現,其它架構可能有所不同,如x86仍然是直接使用a、b、c、d、e和f;
root@ubuntu:/test# gcc -S test.c -o test.s
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl d(%rip), %edx
movl c(%rip), %eax
movl %edx, %esi
movl %eax, %edi
call init
movl b(%rip), %edx
movl a(%rip), %eax
movl %edx, %esi
movl %eax, %edi
call sum
movl %eax, c(%rip)
movl e(%rip), %edx
movl f(%rip), %eax
addl %edx, %eax
movl %eax, d(%rip)
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
Ret
所以,編譯過程只對單個文件進行語法解析,引用的外部變量或者函數只要使用前聲明即可編譯通過(相當於告訴編譯器別的地方有定義),提示未定義的error是鏈接過程錯誤,像鏈接之前的test.i、test.s和test.o都是可以生成的。
3. 彙編(Assembly)
彙編就是將第2步編譯出來的彙編代碼(test.s)轉換成機器碼。前面無論是C還是彙編代碼,都是給人看的,真正在CPU執行的只有0和1組成的機器碼。彙編生成的.o文件爲ELF文件
root@ubuntu:/test# arm-linux-gnueabi-gcc -c test.s
root@ubuntu:/test# file test.o
test.o: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV), not stripped
查看生成的test.o
root@ubuntu:/test# arm-linux-gnueabi-readelf -S test.o
There are 11 section headers, starting at offset 0x368:
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 0000bc 00 AX 0 0 4
[ 2] .rel.text REL 00000000 0002d4 000038 08 I 9 1 4
[ 3] .data PROGBITS 00000000 0000f0 000008 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 0000f8 000000 00 WA 0 0 1
[ 5] .comment PROGBITS 00000000 0000f8 00003c 01 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 000134 000000 00 0 0 1
[ 7] .ARM.attributes ARM_ATTRIBUTES 00000000 000134 00002a 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 00030c 000059 00 0 0 1
[ 9] .symtab SYMTAB 00000000 000160 000150 10 10 13 4
[10] .strtab STRTAB 00000000 0002b0 000022 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
從上面的信息看出,test.o基地址從 00 00 00 00開始,包含的11個section,介紹其中四個;
.text :代碼段,也就是存放的是指令,函數編譯生成代碼就放在text段中
.rel.text :重定位信息,相當於告訴連接器哪些地方需要重定位
.data :定義的被初始化的全局變變量放在data段中
.bss :定義的未初始化全局變變量放在.bss段中
Text、data和bss段將在最後生成的test.map中查看,這裏重點查看rel.text
root@ubuntu:/home/share/test# arm-linux-gnueabi-readelf -r test.o
Relocation section '.rel.text' at offset 0x2d4 contains 7 entries:
Offset Info Type Sym.Value Sym. Name
00000020 0000121c R_ARM_CALL 00000000 init
00000074 00000f02 R_ARM_ABS32 00000004 c
00000078 00001002 R_ARM_ABS32 00000004 d
0000007c 00000d02 R_ARM_ABS32 00000000 a
00000080 00000e02 R_ARM_ABS32 00000004 b
00000084 00001302 R_ARM_ABS32 00000000 e
00000088 00001402 R_ARM_ABS32 00000000 f
test.c是單獨編譯成test.o的,因爲還不知道將來會分配到什麼地址上去,就先從00 00 00 00地址開始排布各個段(ARM架構),所以目前的地址都是臨時的。rel.text section中的信息,就相當於做了一個標記,告訴鏈接器將來這點位置的引用是要替換的。我們反彙編test.o
root@ubuntu:/home/share/test# arm-linux-gnueabi-objdump -d test.o
test.o: file format elf32-littlearm
Disassembly of section .text:
00000000 <main>:
0: e92d4800 push {fp, lr}
4: e28db004 add fp, sp, #4
8: e59f3064 ldr r3, [pc, #100] ; 74 <main+0x74> // c
c: e5932000 ldr r2, [r3]
10: e59f3060 ldr r3, [pc, #96] ; 78 <main+0x78> //d
14: e5933000 ldr r3, [r3]
18: e1a01003 mov r1, r3
1c: e1a00002 mov r0, r2
20: ebfffffe bl 0 <init>
24: e59f3050 ldr r3, [pc, #80] ; 7c <main+0x7c> //a
28: e5932000 ldr r2, [r3]
2c: e59f304c ldr r3, [pc, #76] ; 80 <main+0x80> //b
30: e5933000 ldr r3, [r3]
34: e1a01003 mov r1, r3
38: e1a00002 mov r0, r2
3c: eb000012 bl 8c <sum>
40: e1a02000 mov r2, r0
44: e59f3028 ldr r3, [pc, #40] ; 74 <main+0x74> //c
48: e5832000 str r2, [r3]
4c: e59f3030 ldr r3, [pc, #48] ; 84 <main+0x84> //e
50: e5932000 ldr r2, [r3]
54: e59f302c ldr r3, [pc, #44] ; 88 <main+0x88> //f
58: e5933000 ldr r3, [r3]
5c: e0823003 add r3, r2, r3
60: e59f2010 ldr r2, [pc, #16] ; 78 <main+0x78> //d
64: e5823000 str r3, [r2]
68: e3a03000 mov r3, #0
6c: e1a00003 mov r0, r3
70: e8bd8800 pop {fp, pc}
...
0000008c <sum>:
8c: e52db004 push {fp} ; (str fp, [sp, #-4]!)
90: e28db000 add fp, sp, #0
94: e24dd00c sub sp, sp, #12
98: e50b0008 str r0, [fp, #-8]
9c: e50b100c str r1, [fp, #-12]
a0: e51b2008 ldr r2, [fp, #-8]
a4: e51b300c ldr r3, [fp, #-12]
a8: e0823003 add r3, r2, r3
ac: e1a00003 mov r0, r3
b0: e24bd000 sub sp, fp, #0
b4: e49db004 pop {fp} ; (ldr fp, [sp], #4)
b8: e12fff1e bx lr
4. 鏈接(Linking)
鏈接就是要把前面階段生成的xxx.o給連起來做一定的處理。在此推薦一本不錯的書《linker and loader》,裏面對鏈接和加載過程進行詳細的講解。這裏只進行大致的介紹。
root@ubuntu:/test# arm-linux-gnueabi-ld -e main test.o init.o -o test
root@ubuntu:/home/share/test# arm-linux-gnueabi-readelf -S test
There are 9 section headers, starting at offset 0x484:
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 00010094 000094 0000f0 00 AX 0 0 4
[ 2] .data PROGBITS 00020184 000184 000010 00 WA 0 0 4
[ 3] .bss NOBITS 00020194 000194 000008 00 WA 0 0 4
[ 4] .comment PROGBITS 00000000 000194 00003b 01 MS 0 0 1
[ 5] .ARM.attributes ARM_ATTRIBUTES 00000000 0001cf 00002a 00 0 0 1
[ 6] .shstrtab STRTAB 00000000 00043f 000045 00 0 0 1
[ 7] .symtab SYMTAB 00000000 0001fc 0001e0 10 8 15 4
[ 8] .strtab STRTAB 00000000 0003dc 000063 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
這個時候我們發現可執行文件test也只有一個text、data和bss session,事實上是由test.o和init.o的text、data和bss session合併,
同時,發現rel.text沒有了,這事因爲鏈接過程中進行了重定位(relocation)。通俗的講重定位就是進行指令和數據進行轉換的過程,轉換後將使得運行時能訪問正確的指令和數據。反彙編test
00010094 <main>:
10094: e92d4800 push {fp, lr}
10098: e28db004 add fp, sp, #4
1009c: e59f3064 ldr r3, [pc, #100] ; 10108 <main+0x74>
100a0: e5932000 ldr r2, [r3]
100a4: e59f3060 ldr r3, [pc, #96] ; 1010c <main+0x78>
100a8: e5933000 ldr r3, [r3]
100ac: e1a01003 mov r1, r3
100b0: e1a00002 mov r0, r2
100b4: eb000025 bl 10150 <init>
100b8: e59f3050 ldr r3, [pc, #80] ; 10110 <main+0x7c>
100bc: e5932000 ldr r2, [r3]
100c0: e59f304c ldr r3, [pc, #76] ; 10114 <main+0x80>
100c4: e5933000 ldr r3, [r3]
100c8: e1a01003 mov r1, r3
100cc: e1a00002 mov r0, r2
100d0: eb000012 bl 10120 <sum>
100d4: e1a02000 mov r2, r0
100d8: e59f3028 ldr r3, [pc, #40] ; 10108 <main+0x74>
100dc: e5832000 str r2, [r3]
100e0: e59f3030 ldr r3, [pc, #48] ; 10118 <main+0x84>
100e4: e5932000 ldr r2, [r3]
100e8: e59f302c ldr r3, [pc, #44] ; 1011c <main+0x88>
100ec: e5933000 ldr r3, [r3]
100f0: e0823003 add r3, r2, r3
100f4: e59f2010 ldr r2, [pc, #16] ; 1010c <main+0x78>
100f8: e5823000 str r3, [r2]
100fc: e3a03000 mov r3, #0
10100: e1a00003 mov r0, r3
10104: e8bd8800 pop {fp, pc}
10108: 00020194 .word 0x00020194
1010c: 00020198 .word 0x00020198
10110: 00020184 .word 0x00020184
10114: 00020188 .word 0x00020188
10118: 0002018c .word 0x0002018c
1011c: 00020190 .word 0x00020190
00010120 <sum>:
10120: e52db004 push {fp} ; (str fp, [sp, #-4]!)
10124: e28db000 add fp, sp, #0
10128: e24dd00c sub sp, sp, #12
1012c: e50b0008 str r0, [fp, #-8]
10130: e50b100c str r1, [fp, #-12]
10134: e51b2008 ldr r2, [fp, #-8]
10138: e51b300c ldr r3, [fp, #-12]
1013c: e0823003 add r3, r2, r3
10140: e1a00003 mov r0, r3
10144: e24bd000 sub sp, fp, #0
10148: e49db004 pop {fp} ; (ldr fp, [sp], #4)
1014c: e12fff1e bx lr
00010150 <init>:
10150: e52db004 push {fp} ; (str fp, [sp, #-4]!)
10154: e28db000 add fp, sp, #0
10158: e24dd00c sub sp, sp, #12
1015c: e50b0008 str r0, [fp, #-8]
10160: e50b100c str r1, [fp, #-12]
10164: e3a03000 mov r3, #0
10168: e50b3008 str r3, [fp, #-8]
1016c: e3a03000 mov r3, #0
10170: e50b300c str r3, [fp, #-12]
10174: e1a00000 nop ; (mov r0, r0)
10178: e24bd000 sub sp, fp, #0
1017c: e49db004 pop {fp} ; (ldr fp, [sp], #4)
10180: e12fff1e bx lr
通過重定位,高亮部分已經被替換成的正確的地址。重定位會發生在三個時刻:
1、程序編譯鏈接接時
2、程序裝入內存時
3、程序執行時
像編譯的test文件,就是在鏈接時完成地址轉換。像程序裝入時和程序執行時的重定位,可查看《linker and loader》。
三、結語
整個編譯過程其實是很複雜,上面只是簡單說明了主要部分,像debug信息、符號表和鏈接器的重定位過程等很多部分並沒有涉及,想要精通的話,還是要學習編譯原理等專業書籍。