《嵌入式工程师自我修养/C语言》系列——程序的编译、链接过程分析(简洁浓缩版)!
- 一、程序的编译
- 1.1 预编译指令 pragma
- 1.2 编译过程概述
- 1.3 符号表和重定位表
- 二、程序的链接
- 2.1 分段组装
- 2.2 符号决议
- 2.2.1 强符号与弱符号
- 2.2.2 GNU编译器的__attribute__((weak))属性
- 2.3 重定位
- 2.3.1 重定位的基本原理
- 2.3.2 重定位操作的流程
- 三、结语
快速学习嵌入式开发其他基础知识?>>>>>>>>> 返回专栏总目录 《嵌入式工程师自我修养/C语言》<<<<<<<<<
一、程序的编译
众所周知,程序的编译过程包括预处理、编译、汇编、链接,预处理过程中的很多预处理指令都很简单,这里就不赘述了(诸如#if #else #endif这种预处理指令),这里仅先介绍一种大家可能不太熟悉的预处理指令:#pragma。
1.1 预编译指令 pragma
尤其在C和C++编程中,预编译指令#pragma
充当着一个强大的工具,它允许我们向编译器传达特殊的指令来控制编译过程的各个方面。我们常用的几种用法列举如下:
- #pragma pack([n])
在结构体数据排布中,#pragma pack([n])
是一个常用的指令,用于指定结构体或联合体成员的对齐方式。默认情况下,编译器会按照特定的规则(通常是结构体最大成员的大小)对成员进行对齐,这可能会导致内存的浪费。通过使用#pragma pack
,我们可以减少这种浪费,优化内存的使用。
#pragma pack(push, 1) // 保存当前对齐,设置新的对齐为1字节
struct MyStruct {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
};
#pragma pack(pop) // 恢复之前的对齐设置
上面的代码段将结构体MyStruct
的对齐设置为1字节,这意味着不会有填充字节用于对齐,从而减小结构体的总大小。
- #pragma message(“string”)
在复杂的项目开发过程中,#pragma message
用于在编译时输出自定义的信息或提醒,它可以作为一种在代码中留下注释或提醒的手段。
#pragma message("Compiling the module...")
void myFunction() {
// ...
}
当编译包含这段代码的文件时,编译器会显示消息"Compiling the module…",这能够帮助开发者注意到特定的代码块或编译阶段。
- #pragma warning
对于想要控制特定警告的开发者来说,#pragma warning
是一个非常有用的指令。它可以允许你禁用或启用特定的警告,有助于保持代码的清洁和警告的相关性。
#pragma warning(disable : 4996) // 禁用4996警告
strcpy(dest, src);
#pragma warning(default : 4996) // 恢复4996警告的默认行为
这里,我们禁用了与编号为4996的警告相关的编译器警告,然后在需要的地方恢复了它。
- #pragma once
在大型项目中,防止头文件被多次包含是至关重要的,#pragma once
是一个非常高效的预编译指令来实现这一点。它告诉编译器只包含一次头文件,避免了传统宏定义的冗余和潜在的命名冲突。
#pragma once
// 声明和定义...
在这个例子中,只要包含这个头文件,编译器就会确保它在编译单元中只被包含一次。
#pragma
是开发者手中的一把瑞士军刀,无论是进行内存管理优化、代码诊断、抑制编译器警告还是头文件的管理,它都能发挥巨大的作用。尽管使用#pragma
需要对它们的影响有深入的了解,但合理利用它们可以在许多方面提高代码的质量和编译的效率。
Tip📌:每个编译器对#pragma
的支持可能略有不同,因此在跨编译器项目中使用时请务必检查兼容性。
1.2 编译过程概述
从C程序到可执行文件,整个编译过程如下所示,编译程序(如gcc、arm-linux-gcc)会调用不同的工具来完成不同阶段的任务。
- 预处理器:将源文件main.c经过预处理变为main.i;
- 编译器:将预处理后的main.i编译为汇编文件main.s;
- 汇编器:将汇编文件main.s编译为目标文件main.o;
- 链接器:将各个目标文件main.o、sub.o链接成可执行文件a.out。
最后生成的可执行文件a.out 其实也是目标文件(object file),唯一不同的是,a.out是一种可执行的目标文件。目标文件一般可以分为3种:可重定位的目标文件(relocatable files)、可执行的目标文件(executable files)和可被共享的目标文件(shared object files)。
Tip📌:汇编器生成的目标文件是可重定位的目标文件,是不可执行的,需要链接器经过链接、重定位之后才能运行。可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行。
从C源文件到汇编文件的转换,其实就是将C文件中的程序代码块、函数转换为汇编程序中的代码段,将C程序中的全局变量、静态变量、常量转换为汇编程序中的数据段、只读数据段。这已经是及其简洁的说法了,实际上编译过程可以分为以下6步,每一步都是可以深入探讨的,但我们没必要了解的那么深入,简单知道其作用即可,他们分别是:词法分析、语法分析、语义分析、中间代码生成、汇编代码生成、目标代码生成。
汇编文件中的汇编指令就是二进制指令的助记符,唯一的差异就是汇编语言的程序结构需要使用各种伪操作来组织。汇编文件经过汇编器汇编后,处理掉各种伪操作命令,就是二进制目标文件了。每一个c源文件经过汇编都会生成对应的目标文件,这些目标文件是不可执行的,属于可重定位的目标文件,它们要经过链接器重定位、链接之后,才能组装成一个可执行的目标文件。这些目标文件都是以零地址为链接起始地址进行链接的,比如上图中main.c 和 sub.c经过汇编生成的可执行文件main.o 和 sub.o,如果通过readelf -S main.o
这个-S
选项分别查看他们节头表的话,将发现两个文件的起始段信息都是从0地址开始往后偏移。在每个可重定位目标文件中,函数或变量的地址其实就是它们在文件中相对于零地址的偏移。
1.3 符号表和重定位表
在编译和链接程序的过程中,编译器将源代码编译成目标文件,每个目标文件都是假设自己从零地址开始。在链接过程中,链接器需要把多个目标文件合并成一个可执行文件,这时就需要更新文件中的地址引用,这个更新地址的过程称为重定位。
链接器如何知道哪些地址需要更新呢?答案是通过重定位表,这是一种记录了需要更新地址的符号信息的数据结构。每个目标文件都包含有自己的重定位表,在链接时,链接器会查看这些表,然后根据最终确定的内存布局来更新符号地址。(可以使用readelf -s=r main.o
查看目标文件的重定位表)
同时,每个目标文件还包含一个符号表,这个表列出了文件中所有的符号(如函数和变量名),无论这些符号是否需要重定位。链接器通过符号表来解析不同目标文件之间的符号引用关系。(可以使用readelf -s main.o
查看目标文件的符号表)
例如,如果main.o文件中的main函数调用了sub.o文件中的add和sub函数,在链接过程中,add和sub函数的地址可能会改变。链接器会在这个过程结束后,根据重定位表中的信息更新这些函数引用的地址。这就确保了在最终生成的可执行文件中,所有的函数和变量引用都指向正确的地址。
Tip📌:符号表本质上是一个结构体数组,在ARM平台下,定义在Linux内核源码的/arch/arm/include/asm/elf.h文件中。
在编译和链接程序的时候,符号表中的每个符号都有一个符号值和类型。符号值是符号在内存中的地址;类型描述了符号的性质和用途。这些类型包括:
符号类型 | 描述 |
---|---|
OBJECT | 表示变量 |
FUNC | 表示函数或可执行代码 |
FILE | 关联当前目标文件名称 |
SECTION | 与一个section有关,用于重定位 |
COMMON | 表示公用块数据对象,是全局弱符号 |
TLS | 变量存储在线程局部存储 |
NOTYPE | 符号类型未指定或未知 |
Tip📌:符号值既可以是绝对地址(通常在可执行目标文件中出现),也可以是相对地址(常在可重定位目标文件中出现)。
二、程序的链接
通过编译环节我们得到了各个文件对应的目标文件,每个目标文件都是由section构成的可重定位目标文件,其中还有两个重要的表:重定位表和符号表。本节将简述链接器具体如何应用这两个表。实际上,链接过程主要分三步:分段组装、符号决议和重定位。
2.1 分段组装
分段组装过程其实就是链接器将各个目标文件的各类型的段组装在一起,比如将各个目标文件的代码段放在一起,数据段放在一起,建立一个全局符号表收集各个目标文件中的符号信息(注意,此时全局符号表中各个符号的地址仍然是原来在各个目标文件中的地址,即都是以零地址作为起始地址的)
在链接程序时需要指定一个链接起始地址(因为链接生成的可执行文件最终是要被加载到内存中执行的),该地址一般也是程序要加载到内存中的地址。同时,还要考虑链接过程中各个section的排布顺序等方面,这些功能都是通过连接脚本实现的。
这个脚本文件里不仅规定了各个段的组装顺序、起始地址、位置对齐等信息,同时对输出的可执行文件格式、运行平台、入口地址等信息做了详细的描述。链接器就是根据链接脚本定义的规则来组装可执行文件的,并最终将这些信息以section的形式保存到可执行文件的ELF Header中。
一个简单的链接脚本示例如下所示,关于链接脚本的详细内容,将在另一篇文章中阐述,届时将在这里放置文章链接。
/* Specify the output format and the target architecture */
OUTPUT_FORMAT("elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start) /* Define the entry point of the executable */
/* Define the memory layout of the sections */
SECTIONS
{
. = 0x10000; /* Define the starting memory address for the code */
.text : {
*(.text) /* Include all .text sections from input files */
}
.data : {
*(.data) /* Include all .data sections from input files */
}
.bss : {
*(.bss) /* Include all .bss sections from input files */
}
}
在这个链接脚本中,我们定义了输出格式为elf64-x86-64
,目标架构为i386:x86-64
。脚本中ENTRY
指令指定了程序的入口点为_start
。在SECTIONS
块中,我们为.text
、.data
和.bss
三个部分指定了它们在内存中的起始地址和排布顺序。
Tips📌:
1. 不同的编译器、不同的操作系统,链接脚本的文件名后缀一般也不一样。
2. 在一个由带有MMU的CPU搭建的嵌入式系统中,程序的链接起始地址往往都是一个虚拟地址,程序运行过程中还需要地址转换,通过MMU将虚拟地址转换为物理地址,然后才能访问内存。
2.2 符号决议
在软件开发的链接阶段,程序中的各个符号(如变量和函数名)需要被合并以形成最终的可执行文件。符号冲突通常是指不同的对象或源文件中含有相同名称的全局符号。解决这种冲突的规则可以用一句俗语概括:“一山不容二虎,强弱可以共存,体积大者胜出”。为了更好理解这些概念,我们首先需要区分“强符号”与“弱符号”。
2.2.1 强符号与弱符号
强符号通常是指用户定义的全局变量和函数,因为它们有具体的值或实现。而弱符号则可能来自于未初始化的全局变量或者是一些特殊的编译器指令(如GNU编译器的__attribute__((weak))
)声明的变量和函数。在链接时,强符号与弱符号的处理有所不同,这导致了一些链接时的规则和行为。
——链接规则解释
-
一山不容二虎:这条规则指出如果两个强符号冲突(即两个强符号的名称相同),则链接会失败,因为链接器不允许有两个相同名称的强符号存在。这就像一山不能容纳两只虎一样,不可能有两个相同的实体共存。
-
强弱可以共存:当一个强符号和一个弱符号名称相同时,强符号会胜出,链接器会选择强符号,而忽略弱符号。这是因为强符号提供了明确的定义,而弱符号则相对模糊。
-
体积大者胜出:如果两个符号都是弱符号,并且都有相同的名称,链接器会选择那个体积更大的符号(即占用内存空间更多的符号),并且忽略体积较小的那个。如果它们大小相同,则任选其一。
——举例如下
假设我们有两个不同的源文件,分别定义了以下全局变量和函数:
源文件1:
int value; // 弱符号,因为未初始化的全局变量
void foo() {} // 强符号,因为是一个具体定义的函数
源文件2:
int value = 1; // 强符号,因为是初始化的全局变量
void foo() {} // 强符号,同样是一个具体定义的函数
在链接这两个源文件时,会发生以下情况:
- 对于变量
value
,源文件1中的是弱符号,而源文件2中的是强符号。根据“强弱可以共存”的规则,链接器会选择源文件2中的value
。 - 对于函数
foo
,两个源文件中都是强符号。由于“一山不容二虎”的规则,这将导致链接错误,除非某种方式可以解决这个冲突,比如静态链接库或者更改其中一个符号的名称。
2.2.2 GNU编译器的__attribute__((weak))属性
__attribute__((weak))
是GNU编译器提供的一个功能强大的属性,可以用于声明弱符号。这允许开发者为全局变量和函数提供默认的定义,同时还可以被其他模块中的强符号覆盖。这在创建库和模块化编程时非常有用,因为它允许用户扩展或修改默认行为,而无需修改原始库代码。
为了深入理解如何在实际编码中应用强弱符号规则,尤其是利用GNU编译器的__attribute__((weak))
属性,我们来扩展下上面的例子,进一步示范这个特性的用法。
——举例如下
假设我们现在有三个源文件,分别是main.c
,libdefault.c
和userlib.c
:
libdefault.c
是一个库文件,提供了一些默认的实现。userlib.c
是用户自定义的库,可能会提供一些默认实现的替代版本。main.c
是主程序,将使用这些库提供的功能。
源文件 libdefault.c:
#include <stdio.h>
void __attribute__((weak)) foo() {
printf("Default foo implementation\n");
}
int __attribute__((weak)) value = 42;
这里,foo
函数和value
变量都被声明为弱符号,意味着如果存在同名的强符号,它们可以被覆盖。
源文件 userlib.c (用户提供了自己的实现,但这不是必须的):
#include <stdio.h>
void foo() {
printf("User-defined foo implementation\n");
}
int value = 100;
在userlib.c
中,用户提供了foo
函数和value
变量的自定义实现,不使用__attribute__((weak))
,因此这两个符号都是强符号。
源文件 main.c:
#include <stdio.h>
extern void foo();
extern int value;
int main() {
foo();
printf("Value is %d\n", value);
return 0;
}
在main.c
中,主程序声明了对foo
函数和value
变量的外部引用,并在主函数中调用foo
和访问value
。
——链接和运行结果
当我们将这三个文件一起编译和链接时:
- 如果
userlib.c
被包含在编译过程中,那么它里面的foo
和value
将作为强符号,覆盖libdefault.c
中的弱符号定义。 - 如果
userlib.c
没有被编译链接,那么libdefault.c
中的弱符号实现将会被采用。
假设userlib.c
被包含,程序输出将是:
User-defined foo implementation
Value is 100
如果userlib.c
未被包含,输出将是:
Default foo implementation
Value is 42
这个例子展示了如何通过GNU编译器的__attribute__((weak))
属性来设计灵活的接口和可扩展的程序结构,允许易于维护和升级的同时,也提供了默认的行为实现。
2.3 重定位
经过符号决议,我们解决了链接过程中多文件符号冲突的问题。可执行文件的符号表中的每个符号虽然都确定下来
了,但是符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址已经发生了变化。当前重组后的文件中需要为这些符号指定新的地址,这就是重定位过程的工作。
重定位是编译链接过程中的一个关键步骤,它负责调整和转换目标文件中的符号引用,确保程序中的每个符号引用都指向正确的内存地址。经过符号决议后,代码和数据已经从它们最初的目标文件中被重新组织,可能被放置在内存中的不同位置。因此,链接器必须更新这些引用,以反映它们在内存中的实际位置。
2.3.1 重定位的基本原理
当链接器合并多个目标文件时,它会创建两个主要的表:符号表和重定位表。符号表包含了所有符号的名称和它们的属性(如是否为强符号或弱符号)。在链接过程中,符号决议会为每个全局符号赋予一个唯一的地址。重定位表则记录了所有需要更新内存地址的地方,这些位置在源代码中通常是相对引用(例如,从函数的开头到一个全局变量的偏移量)。
重定位表包含三个关键的信息:
- 偏移(Offset):需要重定位的代码或数据位置相对于段开始的偏移。
- 符号(Symbol):此重定位项对应到符号表中的符号。
- 类型(Type):重定位类型,指导链接器如何进行重定位。
2.3.2 重定位操作的流程
链接器进行重定位的基本步骤如下:
- 确定基地址:链接器为每个段(代码段、数据段等)确定一个基地址。
- 遍历重定位表:链接器遍历每个目标文件的重定位表,查找需要修正的引用。
- 计算新地址:对于每个表项,链接器根据符号表中的符号地址、重定位表中的偏移和确定的基地址来计算新地址。
- 更新引用:链接器将目标文件中的相对地址更新为实际的内存地址。
——举例如下
假设我们有两个简单的C文件,main.c
和library.c
,它们被编译成两个目标文件main.o
和library.o
。
源文件 library.c:
int shared_val = 10;
void library_function() {
// 函数实现
}
源文件 main.c:
extern int shared_val;
int main() {
return shared_val;
}
编译这两个文件将产生目标文件,这时shared_val
的地址还没有最终确定。链接器通过重定位表来识别main.c
中对shared_val
的引用,这个引用在main.o
中是一个基于零地址的偏移。
当链接器将main.o
和library.o
链接成一个可执行文件时,它可能决定将shared_val
放在地址0x10000
处。链接器会在main
函数中重定位对shared_val
的引用,将它从偏移更改为实际地址0x10000
。
通过符号表和重定位表的配合,链接器能够解决多个目标文件中的符号冲突,并确保所有符号引用在最终的执行文件中都正确指向它们的实际地址。重定位是确保代码和数据在内存中正确布置的关键过程,没有它,程序将无法正确运行。
三、结语
至此,整个编译链接过程就结束了,最终生成的目标文件就是可执行的目标文件了。实际上,编译链接过程每一环节都有很多要注意和学习的地方,本文只是简单的梳理了一个框架和比较重要的知识,还需要在以后的学习中不断扩充相关知识。
快速学习嵌入式开发其他基础知识?>>>>>>>>> 返回专栏总目录 《嵌入式工程师自我修养/C语言》<<<<<<<<<