政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
收录专栏: 机器学习智能硬件开发全解
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
前言
我们回顾一下上篇文章:
【机器学习智能硬件开发全解】(十)—— 政安晨:通过ARM-Linux掌握基本技能【C语言程序的编译过程】https://blog.csdn.net/snowdenkeke/article/details/136847809
在介绍链接过程之前,我们先总结和复习一下前面所学的知识:
在一个C项目的编译中,编译器以C源文件为单位,将一个个C文件翻译成对应的目标文件。生成的每一个目标文件都是由代码段、数据段、BSS段、符号表等section组成的。这些section从目标文件的零偏移地址开始按照顺序依次排放,每个段中的符号相对于零地址的偏移,其实就是每个符号的地址,这样程序中定义的变量、函数名等,都有了一个暂时的地址。
为什么说这些地址是暂时的呢?
因为在后续的链接过程中,这些目标文件中的各个section会重新拆分组装,每个section的起始参考地址都会发生变化,导致每个section中定义的函数、全局变量等符号的地址也要随之发生变化,需要重新修改,即重定位。这些函数、全局变量等符号同时被编译工具收集起来,放到一个符号表里,符号表也以section的形式被放置在目标文件中。
这些目标文件是不可执行的,它们需要经过链接器链接、重定位后才能运行。
咱们这篇文章将会接着上一篇文章继续分析编译之后的链接过程。
链接主要分为3个过程:分段组装、符号决议和重定位。
分段组装
(程序的链接过程)
链接过程的第一步,就是将各个目标文件分段组装。
链接器将编译器生成的各个可重定位目标文件重新分解组装:
将各个目标文件的代码段放在一起,作为最终生成的可执行文件的代码段;
将各个目标文件的数据段放在一起,作为可执行文件的数据段。
其他section也会按照同样的方法进行组装,最终就生成了一个如下图所示的可执行文件的雏形。
除了代码段、数据段的分解组装需要关注,还有一个重要的section需要我们了解一下:符号表。
链接器会在可执行文件中创建一个全局的符号表,收集各个目标文件符号表中的符号,然后将其统一放到全局符号表中。通过这步操作,一个可执行文件中的所有符号都有了自己的地址,并保存在全局符号表中,但此时全局符号表中的地址还都是原来在各个目标文件中的地址,即相对于零地址的偏移。
在链接过程中,不同的代码段如何组装?
这也是很讲究的。链接生成的可执行文件最终是要被加载到内存中执行的,那么要加载到内存中的什么地方呢?
一般来讲,程序在链接程序时需要指定一个链接起始地址,链接开始地址一般也就是程序要加载到内存中的地址。在链接过程中,各个段在可执行文件中的先后组装顺序也是一个需要考虑的问题,一个可执行程序肯定会有入口地址的,一般先执行的代码要放到前面。那么如何指定程序的链接地址和各个段的组装顺序呢?很简单,通过链接脚本就可以了。
链接脚本本质上是一个脚本文件。在这个脚本文件里,不仅规定了各个段的组装顺序、起始地址、位置对齐等信息,同时对输出的可执行文件格式、运行平台、入口地址等信息做了详细的描述。链接器就是根据链接脚本定义的规则来组装可执行文件的,并最终将这些信息以section的形式保存到可执行文件的ELF Header中。一个简单的链接脚本示例如下。
假如在一个嵌入式系统中,内存RAM的起始地址是0x60000000,我们在链接程序时,就可以在链接脚本中指定内存中的一个合法地址作为链接起始地址。
程序运行时,加载器首先会解析可执行文件中的ELF Header头部信息,验证程序的运行平台和加载地址信息,然后将可执行文件加载到内存中对应的地址,程序就可以正常运行了。
在Windows或Linux环境下编译程序,一般会使用编译器提供的默认链接脚本。程序员只需要关注程序功能和业务逻辑的实现就可以了,不需要关心这些底层是如何编译和链接的。
程序写好之后,点击图形界面上的Run按钮,或者使用gcc/make命令编译后即可运行。
如果你对链接脚本有兴趣,则可以使用下面的命令来查看链接器使用的默认链接脚本。
arm-linux-gnueabihf-ld --verbose
GNU ld (GNU Binutils for Debian) 2.40
Supported emulations:
armelf_linux_eabi
armelfb_linux_eabi
using internal linker script:
==================================================/* Script for -z combreloc */
/* Copyright (C) 2014-2023 Free Software Foundation, Inc.
Copying and distribution of this script, with or without modification,
are permitted in any medium without royalty provided the copyright
notice and this notice are preserved. */
OUTPUT_FORMAT("elf32-littlearm", "elf32-bigarm",
"elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SEARCH_DIR("=/usr/local/lib/arm-linux-gnueabihf"); SEARCH_DIR("=/lib/arm-linux-gnueabihf"); SEARCH_DIR("=/usr/lib/arm-linux-gnueabihf"); SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib"); SEARCH_DIR("=/usr/arm-linux-gnueabihf/lib");
SECTIONS
{
/* Read-only sections, merged into text segment: */
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x00010000)); . =
......
...
/* DWARF 3. */
.debug_pubtypes 0 : { *(.debug_pubtypes) }
.debug_ranges 0 : { *(.debug_ranges) }
/* DWARF 5. */
.debug_addr 0 : { *(.debug_addr) }
.debug_line_str 0 : { *(.debug_line_str) }
.debug_loclists 0 : { *(.debug_loclists) }
.debug_macro 0 : { *(.debug_macro) }
.debug_names 0 : { *(.debug_names) }
.debug_rnglists 0 : { *(.debug_rnglists) }
.debug_str_offsets 0 : { *(.debug_str_offsets) }
.debug_sup 0 : { *(.debug_sup) }
.gnu.attributes 0 : { KEEP (*(.gnu.attributes)) }
.note.gnu.arm.ident 0 : { KEEP (*(.note.gnu.arm.ident)) }
/DISCARD/ : { *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) }
}
==================================================
在嵌入式裸机环境下编译程序,尤其是编译ARM底层代码,很多时候我们要根据开发版的不同硬件配置、内存大小和地址,灵活指定链接地址,或者显示指定链接脚本,有时候甚至自己编写链接脚本。
U-boot源码编译的链接脚本U-boot.lds一般放在U-boot源码的顶层目录下。
Linux内核编译的链接脚本vmlinux.lds一般放在arch/arm/boot/compressed/目录下面。而对于ARM裸机程序开发,大多数IDE都会提供一些设置接口,如ADS1.2集成开发环境。如下图所示,在simple模式下,我们可以直接通过Debug Setting界面设置代码段、数据段的起始地址。
(代码段、数据段起始地址配置)
如下图所示,通过链接器的Layout选项,我们还可以设置程序的入口地址。
当一个嵌入式系统有多种存储配置(Flash、ROM、SDRAM、片内SRAM等),存在各种复杂的地址映射,程序需要加载到不同的RAM中运行时,通过上面的界面简单配置已无法满足我们的需求了。
ADS1.2集成开发环境还提供了另一种模式:Scattered模式,即采用分散加载,通过显式指定scatter.scf脚本来指示链接器完成链接过程。
分散加载脚本的格式示例如下:
不同的编译器、不同的操作系统,链接脚本的文件名后缀一般也不一样。
GCC编译器的默认链接脚本在/usr/lib/scripts目录下,而C-Free集成开发环境的默认链接脚本则在安装路径下的mingw/mingw32/lib/ldscripts下。
不同的编译器默认的链接地址也是不一样的。
在一个由带有MMU的CPU搭建的嵌入式系统中,程序的链接起始地址往往都是一个虚拟地址,程序运行过程中还需要地址转换,通过MMU将虚拟地址转换为物理地址,然后才能访问内存,这部分内容属于CPU硬件底层要关心的内容,和编译原理是不冲突的。
符号决议
一个公司的项目通常由多人组成的软件团队共同开发。
一个项目一般由产品经理定义功能需求,由架构师进行系统分析和模块划分,然后将各个模块的具体实现分配给不同的人员。开发人员在实现各自模块的编程中,可能会产生一个问题:位于不同模块或不同文件中的全局变量、函数可能存在重名冲突。
当这些全局变量在多个文件中定义时,链接器在链接过程中就会发现:各个文件中定义了相同的全局变量名或函数名,发生了符号冲突,那么最终的可执行文件中到底该使用哪一个呢?
不用担心,链接器早就料到会有这种情况,它有专门的符号决议规则来解决这种符号冲突。规则很简单,形象地概括一下,就是下面的3句话:
● 一山不容二虎。
● 强弱可以共存。
● 体积大者胜出。
编译器为了解决这种符号冲突,引入了强符号和弱符号的概念:函数名、初始化的全局变量是强符号,而未初始化的全局变量则是弱符号。
有了强符号和弱符号的概念后,再理解上面的三句话就比较清晰了:
在一个多文件的工程中,强符号不允许多次定义,否则就会发生重定义错误。强符号和弱符号可以在一个项目中共存,当强弱符号共存时,强符号会覆盖掉弱符号,链接器会选择强符号作为可执行文件中的最终符号。
比如,咱们再做两个文件:
// sub.c
int i = 20;
//main.c
int i;
int main(void)
{
printf("i = %d\n", i);
return 0;
}
使用gcc或arm-linux-gcc编译上面的两个源文件并运行 ( 这部分小伙伴们可以根据我以前的文章尝试一下,亲手操作总是会多很多收获 )。
通过程序运行结果你会看到,i变量的最终值为20,而不是0。链接器在进行符号决议时,选择了强符号(sub.c源文件中定义的i符号),丢弃了弱符号(main.c源文件中定义的未初始化的全局符号i)。
如果修改程序,将main.c文件中的i也赋一个初值,再去重新编译这两个源文件,就会发现链接器会报重定义错误,因为此时一个项目中出现了两个同名的强符号,一山不容二虎。
链接器也允许一个项目中出现多个弱符号共存。
在程序编译期间,编译器在分析每个文件中未初始化的全局变量时,并不知道该符号在链接阶段是被采用还是被丢弃,因此在程序编译期间,未初始化的全局变量并没有被直接放置在BSS段中,而是将这些弱符号放到一个叫作COMMON的临时块中,在符号表中使用一个未定义的COMMON来标记,在目标文件中也没有给它们分配存储空间。
在链接期间,链接器会比较多个文件中的弱符号,选择占用空间最大的那一个,作为可执行文件中的最终符号,此时弱符号的大小已经确定,并被直接放到了可执行文件的BSS段中。
编译好后,大家可以自己编译和分析上面的文件。
readelf -s main.o | grep i
结果看到:
通过readelf命令分别查看目标文件main.o和sub.o中的符号i,你会发现它们都被放置在了COMMON块中,大小分别标记为1和4,而最终生成的可执行文件a.out中,变量i则被放置在.bss段中,大小标记为4字节。
正常情况下,初始化的全局变量、函数名默认都是强符号,未初始化的全局变量默认是弱符号。
如果在项目中有特殊需求,我们也可以将一些强符号显式转化为弱符号。
GNU C编译器在ANSI C语法标准的基础上扩展了一系列C语言语法,如提供了一个__attribute__关键字用来声明符号的属性。通过下面的命令,可以将一个强符号转化为弱符号。
为了验证上面的命令是否成功地将一个强符号转化成了弱符号,我们来测试一下:
编译上面的两个源文件并运行,你会看到变量i的打印值为20。在main.c中虽然定义了一个初始化的全局变量,但是通过__attribute__属性声明将其显式转化为弱符号后,就避免了“一山不容二虎”的符号冲突,编译器不会报链接错误。
和强符号、弱符号对应的,还有强引用、弱引用的概念。
在一个程序中,我们可以定义多个函数和变量,变量名和函数名都是符号,这些符号的本质,或者说这些符号值,其实就是地址。在另一个文件中,我们可以通过函数名去调用该函数,通过变量名去访问该变量。我们通过符号去调用一个函数或访问一个变量,通常称之为引用(reference),强符号对应强引用,弱符号对应弱引用。
1. 在程序链接过程中,若对一个符号的引用为强引用,链接时找不到其定义,链接器将会报未定义错误;
2. 若对一个符号的引用为弱引用,链接时找不到其定义,则链接器不会报错,不会影响最终可执行文件的生成。
可执行文件在运行时如果没有找到该符号的定义才会报错。
利用链接器对弱引用的处理规则,我们在引用一个符号之前可以先判断该符号是否存在(定义)。这样做的好处是:
当我们引用一个未定义符号时,在链接阶段不会报错,在运行阶段通过判断运行,也可以避免运行错误。
举个例子,如果我们想实现一个视频解码模块,并最终封装成库的形式提供给应用程序开发者使用。
在模块实现的过程中,我们可以将提供给用户的一系列API函数声明为弱符号,这样做有两个好处:
一是当我们对库中的某些API函数的实现不是很满意,或者这些API存在bug,我们有更好的实现时,可以自定义与库函数同名的函数,直接调用它们而不会发生冲突。
二是在库的实现过程中,我们可以将某些扩展功能模块中还未完成的一些API定义为弱引用。
应用程序在调用这些API之前,要先判断该函数是否实现,然后才调用运行。这样做的好处就是未来发布新版本库时,无论这些接口是否已经实现,或者已经删除,都不会影响应用程序的正常链接和运行。
我们截取一段程序中的代码:
在上面的程序中,我们实现了一个解码库,并将解码库的函数接口声明为弱引用。
在main.c中,main()函数调用了解码库中的decode()函数,在调用之前我们先对弱符号的弱引用作了一个判断,这样做的好处是:无论在decode.c中decode()函数是否有定义,都不会影响程序的正常运行。
大家可以使用上面的命令单独编译main.c或者与decode.c文件一起编译,你会发现,我们的程序都可以正常运行。程序的运行结果也是从侧面验证了理论分析的正确。知行合一。
重定位
经过符号决议,我们解决了链接过程中多文件符号冲突的问题。
经过处理之后,可执行文件的符号表中的每个符号虽然都确定下来了,但是还存在一个问题:符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址都发生了变化。
在可执行文件中,各个段的起始地址都发生了变化,那么各个段中的符号地址也要跟着发生变化。编译器生成的各个目标文件,以零地址为起始地址放置各个函数的指令代码,各个函数相对于零地址的偏移就是各个函数的入口地址。
如下图中的main()函数和sub()函数,它们在原来各自的目标文件中,相对于零地址的偏移分别是0x10和0x30,main.o文件中代码段的大小为len,经过链接器分解后,所有目标文件的代码段组装在一起,原来目标文件的各个代码段的起始地址也发生了变化:此时main()函数和sub()函数相对于a.out文件头的地址也就变成了0x10和len+0x30。链接器在链接程序时一般会基于某个链接地址link_addr进行链接,所以最后main()函数和sub()函数的真实地址就变成了link_addr+0x10、link_addr+len+0x30。
程序经过重新分解组装后,无论是代码段,还是数据段,各个符号的真实地址都发生了变化。而此时可执行文件的全局符号表中,各个符号的值还是原来的地址,所以接下来还要修改全局符号表中这些符号的值,将它们的真实地址更新到符号表中。修改完毕后,当我们想通过符号引用去调用一个函数或访问一个变量时,就能找到它们在内存中的真实地址了。
(链接过程中的各符号地址变化)
链接器怎么知道哪些符号需要重定位呢?
不要忘了,在各个目标文件中还有一个重定位表,专门记录各个文件中需要重定位的符号。重定位的核心工作就是修正指令中的符号地址,是链接过程中的最后一步,也是最核心、最重要的一步,前面两步的操作,其实都是为这一步服务的。
在编译阶段,编译器在将各个C源文件生成目标文件的过程中,遇到未定义的符号一般不会报错,编译器会认为这些符号可能会在其他地方定义。在链接阶段,链接器在其他地方找不到该符号的定义,才会报链接错误。
编译器在链接阶段会搜集这些未定义的符号,生成一个重定位表,用来告诉链接器,这些符号在文件中被引用,但是在本文件中没有找到定义,有可能在其他文件或库中定义,“我就先不报错了,你链接的时候找找看”。
无论是代码段,还是数据段,只要这个段中有需要重定位的符号,编译器都会生成一个重定位表与其对应:.rel.text或.rel.data。
这些重定位表记录各个段中需要重定位的各种符号,并以section的形式保存在各个目标文件中。我们可以通过readelf或objdump命令来查看一个目标文件中的重定位表信息。
main.o的重定位表信息:
重定位表中有一个信息比较重要:需要重定位的符号在指令代码中的偏移地址offset,链接器修正指令代码中各个符号的值时要根据这个地址信息才能从茫茫的二级制代码中找到它们。
链接器读取各个目标文件中的重定位表,根据这些符号在可执行文件中的新地址,进行符号重定位,修改指令代码中引用这些符号的地址,并生成新的符号表。重定位过程中的地址修正其实很简单,如下所示:
至此,整个链接过程就结束了,我们跟踪的整个编译流程也就结束了。
最终生成的文件就是一个可执行目标文件。