在之前我们学习了嵌入式开发中的相关知识点,今天我们来看看链接器。我们在平时的开发中,源文件被编译后生成目标文件(.o 文件) 时,这些目标文件时如何存在于最终的可执行程序呢?那么此时就需要链接器来出场了。
我们首先来看看链接器的意义:它的主要作用是把各个模块之间相互引用的部分进行一个处理,使每个模块之间能够进行正确的衔接,进而使得最终的可执行文件能够正常运行。示例图如下
其实我们在平时生成的目标文件中,具备以下几个特征:
a> 各个段没有具体的起始地址,只有段大小信息;
b> 各个标识符没有实际地址,只有段中的相对地址;
c> 段和标识符的实际地址需要链接器具体确定。
那么链接器的主要工作是干嘛的呢?主要做的工作是将目标文件和库文件整合为最终的可执行程序。体现在 a> 合并各个目标文件中的段(.text, .data, .bss);b> 确定各个段和段中标识符的最终地址(也就是我们所说的重定位)。我们下来以代码为例来进行分析。
func.c 源码
#includeint* g_pointer; void func() { g_pointer = (int*)"D.T.Software"; return; }
test.c 源码
#includeint g_global = 0; int g_test = 1; extern int* g_pointer; extern void func(); int main(int argc, char *argv[]) { printf("&g_global = %p\n", &g_global); printf("&g_test = %p\n", &g_test); printf("&g_pointer = %p\n", &g_pointer); printf("g_pointer = %p\n", g_pointer); printf("&func = %p\n", &func); printf("&main = %p\n", &main); func(); return 0; }
我们来编译成目标文件,看看它们生成的目标文件的内容
我们看到在 func.o 中有一个 func 函数是位于 Test 段的,起始位置是 0 处,还有个 g_pointer,但是它的类型是未知的,只能看出它的大小为 4;在 test.o 中,地址都是起始处的地址。那么我们在平时的程序编写中,都是有地址的,这个地址究竟是谁分配的呢?幕后主谋便是链接器了,在平时的编译中,它是编译器自动进行编译链接的。因此,在现在我们进行手动编译后,必须得手动链接才能生成正确的可执行程序。下来我们来进行链接工作
我们看到在进行最后的链接后,它们的地址都被正确的分配了。
下来我们来看一个有趣的问题,main() 函数是第一个被调用执行的函数吗?在刚接触编程的时候,老师就告诉我们,main() 函数是 C 程序的入口,都是以它为其实地址来进行整个程序的运行的。那么问这个问题是否是个无聊的问题呢?这是一家公司的面试题。我们就来看看一些深层次的东西(我们是在 gcc 环境下,因此下面全部是基于 gcc 环境来进行说明的)。
在默认情况下:1、程序加载后,_start() 是第一个被调用执行的函数;2、_start() 函数准备好参数后立即调用 __libc_start_main() 函数;3、__libc_start_main() 初始化运行环境后调用 main() 函数执行。注:_start() 函数的入口地址就是代码段(.text)的起始地址!我们以代码为例来进行分析说明
program.c 源码
#include#include int main() { printf("D.T.Software\n"); exit(0); }
我们来看看编译生成的最终的目标文件以及反汇编生成的最终代码
我们打开 result.txt 文件,来查找下 main 函数的入口地址
我们看到 main 函数的入口地址是 804841d,我们再次在此文件中查找这个入口地址是在哪里被调用的
我们看到是在 _start 函数中调用的 main 函数的入口地址。由此可见,我们前面分析的全部是正确的。我们接着向下看,我们在 result.txt 文件中,接着向前看。在最开始的地方调用的是 __libc_start_main 函数,下来我们继续来讲讲这个函数的作用:1、调用 __libc_csu_fini() 函数(完成必要的初始化操作);2、启动程序的第一个线程(主线程),main() 为线程入口;3、注册 __libc_csu_init() 函数(程序运行终止时被调用)。我们看到在 _start() 函数开始之后,先后 push 了两个地址:0x80484b0 和 0x8048440,我们来搜搜这两个地址
我们看到分别调用了 __libc_csu_fini() 和 __libc_csu_init() 函数,它们的作用我们也在上面刚刚说过了,利用这两个函数来完成 __libc_start_main() 函数的必要操作。最后便是 main() 函数的启动了。我们再次来梳理下整个启动的流程,如下
那么我们看到程序是由 _start() 函数开始执行的,那么我们是不是可以自定义入口函数呢?主要保证在它内部调用的地址是我们指定的自定义函数就行。这种操作是存在的,gcc 提供 -e 选项用于在链接时指定入口函数,但是注意的是在自定义入口函数时必须要使用 -nostartfiles 选项进行链接。示例代码如下
program.c 源码
#include#include int program() // Entry Function { printf("D.T.Software\n"); exit(0); }
我们来看看编译。链接,运行的结果
我们看到在第一次加 -e 选项后,没有加 -nostartfiles 选项便导致链接错误了。-e 后面的便是我们自己指定的入口函数,其实质上是将入口函数 _start() 替换成我们自己指定的函数。那么我们来思考下,链接器到底是根据什么原则来完成具体的工作呢?为何如此神奇,可以让我们自己指定入口地址呢?答案便是链接脚本了。下来我们来看看链接脚本到底是啥玩意。
链接脚本,简单来说,它是用于描述链接器处理目标文件和库文件的方式。它的主要作用有以下几点:
a> 合并各个目标文件中的段;
b> 重定位各个段的起始地址;
c> 重定位各个符号的最终地址。
下面是一些重要的链接选项
那么链接脚本的本质是什么呢?它是引导链接器执行的一条规则。在整个编译阶段中所处的地位关系如下
那么链接脚本长的什么样子呢?是不是很难认识呢?起始并不是。对 Linux 底层熟悉的同学应该见过一些链接脚本,就是后缀名为 .lds 的文件。它的大致结构如下
在编写链接脚本的时候,有几点注意事项我们得注意下:1、各个段的链接地址必须符合具体平台的规范;2、链接脚本中能够直接定义标识符并指定存储地址;3、链接脚本中能够指定源代码中标识符的存储地址;在 Linux 中,进程代码段(.text)的合法起始地址为[0x08048000, 0x08049000]。下来我们来写个链接脚本体验下
test.c 源码
#includeint s1; extern int s2; int main() { printf("&s1 = %p\n", &s1); printf("&s2 = %p\n", &s2); return 0; }
test.lds 源码
SECTIONS { .text 0x08048400: { *(.text) } . = 0x01000000; s1 = .; . += 4; s2 = .; .data 0x0804a800: { *(.data) } .bss : { *(.bss) } }
我们来编译看看结果
我们看到 s1 的地址是我们指定的 0x01000000,而 s2 则是我们进行 + 操作之后的地址。在默认情况下,链接器认为程序应该加载进入同一个存储空间;但是在嵌入式系统中,如果存在多个存储空间,必须使用 MEMORY 进行存储区域定义。因为在嵌入式系统中,内存是很宝贵的。我们一般都是将 RAM 映射到 flash 中进行运行(通过 MMU)。示例代码如下
其中 MEMORY 命令的属性定义如下
还有一种就是 ENTRY 命令来指定入口点。我们来进行代码分析说明
test.c 源码
#include#include int program() { printf("D.T.Software\n"); exit(0); }
test.lds 源码
ENTRY(program) SECTIONS { .text 0x08048400: { *(.text) } }
我们在链接脚本里指定 program 为程序的入口点,我们来看看程序运行的结果
我们看到已经成功运行了。我们下来来看看链接信息
我们看到 .text 段的起始地址便是我们指定的地址,我们用 nm 来查看下信息可执行文件的信息,这个地址也是 program 函数的入口地址。那么我们再来看看 gcc 默认的链接地址什么呢?命令是 ld --verbose ,我们将它的结果保存到 default.lds 中,我们来查看下它的信息
我们看到里面记录了很多的信息,其中默认的入口函数是 _start,段的起始地址是 0x08048000,剩下的信息我们就不分析了。通过对链接器的学习,总结如下: