在 Windows 下开发一个 C 程序,一般都会用到集成开发环境(Integrated Development Environment,IDE),如 VC++6.0、C-Free、Visual Studio、Keil等。IDE 界面友好、使用方便。新建一个工程/源文件,编辑程序,点击界面上的 Run 按钮,然后我们编写的程序就可以运行了。至于程序是如何编译和运行的,我们无须操心,因为 IDE 已经为我们封装好了:IDE 集程序编译器、工程管理器、编译器、汇编器、链接器、调试器、二进制工具、库、头文件于一身,留给用户的使用接口就是创建一个工程,编写代码,运行代码。程序员只需要关注自己要实现的业务逻辑和功能代码即可,至于顶层是如何编译运行的,不用关心。
嵌入式开发和桌面开发不太一样:处理器平台和软件生态碎片化、多样化。为了提高性价比,不同的嵌入式系统往往采取灵活的配置:不同的 CPU 平台、不同大小的存储、不同的启动方式、导致我们在编译程序时,有时候不仅要考虑一个嵌入式平台的内存、存储器的地址空间,还要考虑将我们的程序代码“烧写”到什么地方、加载到内存什么地址、如何执行。这就要求嵌入式工程师必须了解在程序运行的背后,它们是如何编译、链接和运行的。有了这些理论支撑,我们才可能灵活地根据硬件平台的差异取完成软件层面的编译优化和配置。
// main.c
#include <stdio.h>
#include "sub.h"
int global_val = 1;
int uninit_val;
int main(void)
{
int a, b;
static int local_val = 2;
static int uninit_local_val;
a = add(2, 3);
b = sub(5, 4);
printf("a = %d\n", a);
printf("b = %d\n", b);
return 0;
}
// sub.c
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a - b;
}
// sub.h
int add(int a, int b);
int sub(int a, int b);
在上面的程序中,我们创建了两个 C 程序源文件,在 main.c
中定义了项目的入口函数 main()
,在 main()
函数中我们调用了 add()
和 sub()
函数对数据进行加、减运算。add()
和 sub()
函数在 sub.c
文件中定义,并在 sub.h
头文件中声明。以上就是一个典型的 C 程序的项目中多文件的组织原则:可以把 sub.c
看做一个模块,定了很多 API 函数供其他模块调用,并将这些 API 的声明封装在 sub.h
头文件中,如果其他模块想要调用 sub.c
中的函数,则要先 #include "sub.h"
这个头文件,然后就可以直接使用了。如果想让上面的程序在 ARM 平台上运行,则需要使用 ARM 交叉编译器将 C 源程序编译生成 ARM 格式的二进制可执行文件:arm-linux-gnueabihf-gcc -o a.out main.c sub.c
。将生成的二进制文件复制到ARM平台上就可以直接运行了。
查看 a.out 中的 ELF HEADER 信息,可能根据编译器版本的不同会有不同的结果。该命令会得到 a.out 可执行文件所运行的平台、软件版本、程序入口地址、program headers、section header 等信息。
jiaming@jiaming-pc:~/Documents/CSDN_Project$ readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: ARM
Version: 0x1
Entry point address: 0x102f5
Start of program headers: 52 (bytes into file)
Start of section headers: 8764 (bytes into file)
Flags: 0x5000402, Version5 EABI, hard-float ABI, <unknown>
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 8
Size of section headers: 40 (bytes)
Number of section headers: 38
Section header string table index: 35
从 ELF HEADER 的输出信息中我们可以看到该可执行文件具有 38 个段(Number of section headers: 38
),具体有哪些段?使用命令 readelf -S a.out
,查看 a.out 中的 SECTIONS HEADERS 信息:
jiaming@jiaming-pc:~/Documents/CSDN_Project$ readelf -S a.out
There are 38 section headers, starting at offset 0x223c:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 00010134 000134 000019 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 00010150 000150 000020 00 A 0 0 4
[ 3] .note.gnu.build-i NOTE 00010170 000170 000024 00 A 0 0 4
[ 4] .hash HASH 00010194 000194 000028 04 A 5 0 4
[ 5] .dynsym DYNSYM 000101bc 0001bc 000050 10 A 6 1 4
[ 6] .dynstr STRTAB 0001020c 00020c 000043 00 A 0 0 1
[ 7] .gnu.version VERSYM 00010250 000250 00000a 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0001025c 00025c 000020 00 A 6 1 4
[ 9] .rel.dyn REL 0001027c 00027c 000008 08 A 5 0 4
[10] .rel.plt REL 00010284 000284 000020 08 AI 5 12 4
[11] .init PROGBITS 000102a4 0002a4 00000c 00 AX 0 0 4
[12] .plt PROGBITS 000102b0 0002b0 000044 04 AX 0 0 4
[13] .text PROGBITS 000102f4 0002f4 00019c 00 AX 0 0 4
[14] .fini PROGBITS 00010490 000490 000008 00 AX 0 0 4
[15] .rodata PROGBITS 00010498 000498 000014 00 A 0 0 4
[16] .ARM.exidx ARM_EXIDX 000104ac 0004ac 000008 00 AL 13 0 4
[17] .eh_frame PROGBITS 000104b4 0004b4 000004 00 A 0 0 4
[18] .init_array INIT_ARRAY 000204b8 0004b8 000004 00 WA 0 0 4
[19] .fini_array FINI_ARRAY 000204bc 0004bc 000004 00 WA 0 0 4
[20] .jcr PROGBITS 000204c0 0004c0 000004 00 WA 0 0 4
[21] .dynamic DYNAMIC 000204c4 0004c4 0000e8 08 WA 6 0 4
[22] .got PROGBITS 000205ac 0005ac 000020 04 WA 0 0 4
[23] .data PROGBITS 000205cc 0005cc 000010 00 WA 0 0 4
[24] .bss NOBITS 000205dc 0005dc 00000c 00 WA 0 0 4
[25] .comment PROGBITS 00000000 0005dc 00002d 01 MS 0 0 1
[26] .ARM.attributes ARM_ATTRIBUTES 00000000 000609 000033 00 0 0 1
[27] .debug_aranges PROGBITS 00000000 000640 0000b0 00 0 0 8
[28] .debug_info PROGBITS 00000000 0006f0 000420 00 0 0 1
[29] .debug_abbrev PROGBITS 00000000 000b10 000183 00 0 0 1
[30] .debug_line PROGBITS 00000000 000c93 000267 00 0 0 1
[31] .debug_frame PROGBITS 00000000 000efc 000044 00 0 0 4
[32] .debug_str PROGBITS 00000000 000f40 0002da 01 MS 0 0 1
[33] .debug_loc PROGBITS 00000000 00121a 0000bb 00 0 0 1
[34] .debug_ranges PROGBITS 00000000 0012d8 000068 00 0 0 8
[35] .shstrtab STRTAB 00000000 001340 00016c 00 0 0 1
[36] .symtab SYMTAB 00000000 0014ac 000800 10 37 99 4
[37] .strtab STRTAB 00000000 001cac 000590 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
y (purecode), p (processor specific)
SECTIONS HEADERS 中有我们常见的 .text
、.data
、.rodata
、.bss
等内容。一个可执行文件通常由不同的段构成,每个段用一个 SECTION HEADER 来描述:包括段名、段的类型、段的起始地址、段的偏移和段的大小等,将这些 SECTION HEADER 集中放到一起,就是 SECTION HEADER TABLE,可以使用命令 readelf -S
查看。section header table 自身也是以一个 section 的形式存储在可执行文件中的。
C 程序中定义的函数、变量、未初始化的全局变量经过编译后放置在不同的段中:函数翻译成二进制指令放在代码段中,初始化的全局变量和静态局部变量放在数据段…
知道了可执行文件的基本构成,我们也就知道了程序编译的大概流程,将 C 程序中定义的函数、变量,挑挑拣拣、加以分类,分别放置在可执行文件的代码段、数据段和 BSS 段中。程序中定义的一些字符串、printf 函数打印的字符串常量则放置在只读数据段 .rodata 中,如果程序在编译时设置为 debug 模式,则可执行文件中还会有一个专门的 .debug section,用来保存可执行文件中每一条二进制指令对应的源码位置信息,根据这些信息,GDB 调试器就可以支持源码级的单步调试,否则你单步执行的都是二进制指令,可读性不高,不方便调试。在最后的环节,编译器还会在可执行文件中添加一些其它 Section(.init section),这些代码来自 C 语言运行库的一些汇编代码,用来初始化 C 程序运行所依赖的环境,如内存堆栈的初始化等。
从 C 程序到可执行文件,整个编译过程并不是一气呵成、一步完成的,而是环环相扣、多步执行的。程序的整个编译流程主要分为以下几个阶段:预处理、编译、汇编、链接。 每个阶段需要调用不同的工具去完成,上一阶段的输出作为下一阶段的输入,步步推进。
在一个多文件的 C 项目中,编译器是以 C 源文件为单位进行编译的。在编译的不同阶段,编译程序会调用不同的工具来完成不同阶段的任务,在编译器安装路径的 bin 目录下,你会看到各种各样的工具,gcc 在程序编译过程中会分别调用它们,常见的工具有预处理器、编译器、汇编器、链接器。
最后生成的 a.out 也是目标文件,但它是一种可执行的目标文件,目标文件一般可以分为 3 种:
- 可重定位的目标文件(relocatable files)
- 可执行的目标文件(executable files)
- 可被共享的目标文件(shared object files)
汇编器生成的目标文件是可重定位的目标文件,是不可执行的,需要链接器经过链接、重定位之后才能运行。可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行。