我已经用C和C ++进行了很长时间的编程,所以我熟悉用户的链接过程:预处理器扩展每个.c文件中的所有原型和宏,然后单独编译成它自己的目标文件,所有目标文件和静态库都链接到一个可执行文件中。
但是我想更多地了解这个过程:链接器如何链接目标文件(它们包含什么?)?将声明但未定义的函数与其他文件中的定义匹配(如何?)?转换成程序存储器的确切内容(上下文:微控制器)?
理想情况下,我正在寻找基于以下简单示例的流程正在进行的详细逐步描述。既然看起来没有任何地方可以说,那就是以这种方式回答的人的名声和荣耀。
的main.c
#include "otherfile.h"
int main(void) {
otherfile_print("Foo");
return 0;
}
otherfile.h
void otherfile_print(char const *);
otherfile.c
#include "otherfile.h"
#include <stdio.h>
void otherfile_print(char const *str) {
printf(str);
}
答案 0 :(得分:10)
printf非常复杂,非常糟糕,对于微控制器问候世界的例子来说,闪烁的LED更好,但这是微控制器特有的。这足以链接。
two.c
unsigned int glob;
unsigned int two ( unsigned int a, unsigned int b )
{
glob=5;
return(a+b+7);
}
one.c
extern unsigned int glob;
unsigned int two ( unsigned int, unsigned int );
unsigned int one ( void )
{
return(two(5,6)+glob);
}
的start.s
.globl _start
_start:
bl one
b .
建立一切。
% arm-none-eabi-gcc -O2 -c one.c -o one.o
% arm-none-eabi-gcc -O2 -c two.c -o two.o
% touch start.s
% arm-none-eabi-gcc -Wall -O2 -nostdlib -nostartfiles -ffreestanding -c one.c -o one.o
% arm-none-eabi-gcc -Wall -O2 -nostdlib -nostartfiles -ffreestanding -c two.c -o two.o
% arm-none-eabi-as start.s -o start.o
% arm-none-eabi-ld -Ttext=0x10000000 start.o one.o two.o -o onetwo.elf
现在让我们看......
arm-none-eabi-objdump -D start.o
...
00000000 <_start>:
0: ebfffffe bl 0 <one>
4: eafffffe b 4 <_start+0x4>
它不是编译器/汇编程序的工作来处理外部引用,所以分支链接到一个是不完整的,他们选择使它成为0的bl,但他们可能只是让它完全未编码,它取决于关于如何通过目标文件在编译器,汇编器和链接器之间进行通信的工具链的作者。
同样在这里
00000000 <one>:
0: e92d4008 push {r3, lr}
4: e3a00005 mov r0, #5
8: e3a01006 mov r1, #6
c: ebfffffe bl 0 <two>
10: e59f300c ldr r3, [pc, #12] ; 24 <one+0x24>
14: e5933000 ldr r3, [r3]
18: e0800003 add r0, r0, r3
1c: e8bd4008 pop {r3, lr}
20: e12fff1e bx lr
24: 00000000 andeq r0, r0, r0
函数2和全局变量glob的地址都是未知的。请注意,对于未知变量,编译器生成需要全局显式地址的代码,以便链接器只需要填写地址,glob也是.data而不是.text。
00000000 <two>:
0: e59f3010 ldr r3, [pc, #16] ; 18 <two+0x18>
4: e2811007 add r1, r1, #7
8: e3a02005 mov r2, #5
c: e0810000 add r0, r1, r0
10: e5832000 str r2, [r3]
14: e12fff1e bx lr
18: 00000000 andeq r0, r0, r0
这里全局在.data中也没有,所以链接器必须放置.data及其中的内容然后填写地址。
所以这里我们将它们全部链接在一起,gnu链接器需要一个定义了_start的入口点标签(main是标准bootstrap所需的extern地址,我没有使用它,所以我们没有得到一个主要的未找到错误)。因为我没有使用链接器脚本,所以gnu链接器按照它们在命令行中定义的顺序放置二进制文件中的项目,因为我需要首先启动微控制器,因为我正在控制启动。我在这里也使用非零来进行演示......
10000000 <_start>:
10000000: eb000000 bl 10000008 <one>
10000004: eafffffe b 10000004 <_start+0x4>
10000008 <one>:
10000008: e92d4008 push {r3, lr}
1000000c: e3a00005 mov r0, #5
10000010: e3a01006 mov r1, #6
10000014: eb000005 bl 10000030 <two>
10000018: e59f300c ldr r3, [pc, #12] ; 1000002c <one+0x24>
1000001c: e5933000 ldr r3, [r3]
10000020: e0800003 add r0, r0, r3
10000024: e8bd4008 pop {r3, lr}
10000028: e12fff1e bx lr
1000002c: 1000804c andne r8, r0, ip, asr #32
10000030 <two>:
10000030: e59f3010 ldr r3, [pc, #16] ; 10000048 <two+0x18>
10000034: e2811007 add r1, r1, #7
10000038: e3a02005 mov r2, #5
1000003c: e0810000 add r0, r1, r0
10000040: e5832000 str r2, [r3]
10000044: e12fff1e bx lr
10000048: 1000804c andne r8, r0, ip, asr #32
Disassembly of section .bss:
1000804c <__bss_start>:
1000804c: 00000000 andeq r0, r0, r0
所以链接器开始放置第一个项目start.o,它大致通过放置那里的东西来确定需要多大的东西。那两条指示。它们需要8个字节,所以理论上第二项是one.o接下来是0x10000008。这意味着可以完成start.s中bl的编码,以使用正确的相对地址(_start + 8,这是执行时pc的值,因此偏移为零,pc + 0是编码)
链接器大致将one.o放入它正在构建的二进制文件中,并且它必须将地址解析为两个和全局,因此它必须放置两个.o然后找出它的结尾放在哪里这种情况.bss不是.data,因为我没有预先启动变量。
两个标签位于0x10000030所以它将bl两个编码为一个()用于该相对偏移量,它还将glob放在1000804c由于某种原因(我没有完全定义ram是哪里所以gnu链接器会做的事情像这样)。尽管有这个原因,这就是链接器为该全局变量定义了home的地方,并且链接器填充了需要glob的地址,one()和two()都需要填充。
因此编译器(汇编器)和链接器最终必须产生可用的二进制文件,编译器(汇编器)倾向于担心使位置无关的机器代码并为链接器留下足够的信息,以便它具有机器代码它必须填写一个未解决的外部列表。编译器随着时间的推移有所改进,一个简单的模型就是拥有一个地址位置,就像它们在全局变量地址上面所做的那样,链接器计算绝对地址并填充它在上面,他们没有对函数调用进行编码,因为它可以使用绝对地址为1和2。相反,它使用pc相对寻址。这意味着链接器必须知道bl指令的机器代码编码。当前一代的gnu链接器知道了很多,并且可以做一些很酷的事情,解决手臂到拇指和背部,它不习惯知道的东西(你不需要编译拇指交互工作,链接器负责它)。
因此,链接器接受包含数据的二进制blob,并将它们链接在一起成为一个二进制文件。它首先需要知道二进制文件中各种内容的实际地址。如何告诉链接器这是特定于链接器的,而不是所有C / C ++工具链的全局内容。 Gnu链接器脚本本身就是一种编程语言。这些不一定是物理地址或虚拟地址,它只是代码的地址空间,无论它处于何种模式(虚拟或物理)。一旦链接器知道它的地址,基于链接器规则(再次是特定于链接器),它开始将这些各种二进制blob放入这些地址空间。然后它通过并解析外部/全局地址。它不是在上面,但可以是一个迭代过程。例如,如果函数two()位于内存中的某个地址,该地址无法通过单个pc相关指令访问(比如说我们将一个接近零且两个接近0xF0000000)那么编写链接器的那个有两个选择,简单的选择是简单地声明它不能编码/实现那么远的分支和纾困并且gnu链接器确实或仍然这样做。或者另一个解决方案是链接器修复问题。链接器可以在pc相对分支链接的范围内添加几个字的数据,并且那些少量的数据字是蹦床,例如加载到寄存器中的绝对地址,然后是基于寄存器的分支或者可能是聪明的pc相对如果蹦床在范围内(在0x10000000到0xF0000000的情况下不起作用),则分支。如果链接器必须添加这几个单词,那么这可能意味着某些二进制blob必须移动以为这几个单词腾出空间,现在这些二进制blob中的所有地址现在也必须移动。因此,您必须在所有二进制blob上进行另一次传递,解析填写答案的所有新地址以及相对确定您是否仍然可以访问所有内容的pc。添加这几个单词可能会使某些东西可以通过pc-relative达到,现在无法访问,现在需要解决方案(错误或补丁)。
对于像x86这样的可变长度指令集,单个源文件的汇编程序本身必须经历更多这些回转esp,其中寻址是一个很大的模糊。我建议你自己尝试制作一个只支持一些指令但只支持其中一些指令的简单汇编程序。并解析和编码指令并将其与现有的调试汇编程序(如gnu汇编程序)进行比较。test.s
ldr r1,locdat
nop
nop
nop
nop
nop
b over
locdat: .word 0x12345678
top:
nop
nop
nop
nop
nop
nop
over:
b top
正确答案是
00000000 <locdat-0x1c>:
0: e59f1014 ldr r1, [pc, #20] ; 1c <locdat>
4: e1a00000 nop ; (mov r0, r0)
8: e1a00000 nop ; (mov r0, r0)
c: e1a00000 nop ; (mov r0, r0)
10: e1a00000 nop ; (mov r0, r0)
14: e1a00000 nop ; (mov r0, r0)
18: ea000006 b 38 <over>
0000001c <locdat>:
1c: 12345678 eorsne r5, r4, #120, 12 ; 0x7800000
00000020 <top>:
20: e1a00000 nop ; (mov r0, r0)
24: e1a00000 nop ; (mov r0, r0)
28: e1a00000 nop ; (mov r0, r0)
2c: e1a00000 nop ; (mov r0, r0)
30: e1a00000 nop ; (mov r0, r0)
34: e1a00000 nop ; (mov r0, r0)
00000038 <over>:
38: eafffff8 b 20 <top>
与该活动和链接器的工作有相似之处。你也可以根据上面的文件或类似方法设计一个简单的链接器,提取二进制blob和其他信息,然后开始将它们放在你想要的任何地址空间中。
任何一个都是相当简单的编程任务,但是相当有教育意义。拥有可以产生答案的现有工具链,您可以找出出错的地方或如何得到正确的答案。