开发环境: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信息、符号表和链接器的重定位过程等很多部分并没有涉及,想要精通的话,还是要学习编译原理等专业书籍。