【问题标题】:How does a linker work exactly (microcontroller context)?链接器如何准确工作(微控制器上下文)?
【发布时间】:2014-10-05 14:37:09
【问题描述】:

我已经用 C 和 C++ 编程很长时间了,所以我作为用户熟悉链接过程:预处理器扩展每个 .c 文件中的所有原型和宏,然后分别编译成它自己的目标文件,并且所有目标文件与静态库一起链接到一个可执行文件中。

但是我想了解更多有关此过程的信息:链接器如何链接目标文件(它们到底包含什么?)?将已声明但未定义的函数与其在其他文件中的定义相匹配(如何?)?翻译成程序存储器的确切内容(上下文:微控制器)?

应用示例

理想情况下,我希望根据以下简单示例,详细分步说明该流程正在执行的操作。由于它似乎没有在任何地方被提及,因此以这种方式回答的人将获得名声和荣耀。

main.c

#include "otherfile.h"

int main(void) {
   otherfile_print("Foo");

   return 0;
}

其他文件.h

void otherfile_print(char const *);

其他文件.c

#include "otherfile.h"
#include <stdio.h>

void otherfile_print(char const *str) {
   printf(str);
}

【问题讨论】:

  • 这是我读到的第一件事,这基本上就是我在第一段中所说的(更粗略的我必须承认)。我正在寻找更多详细信息。
  • See: Assemblers and Loader,作者大卫·所罗门。

标签: c compiler-construction linker embedded microcontroller


【解决方案1】:

printf 非常复杂,对于微控制器你好世界的例子来说非常糟糕,闪烁的 LED 更好,但它特定于微控制器。这足以进行链接。

两个.c

unsigned int glob;
unsigned int two ( unsigned int a, unsigned int b )
{
    glob=5;
    return(a+b+7);
}

一个.c

extern unsigned int glob;
unsigned int two ( unsigned int, unsigned int );
unsigned int one ( void )
{
    return(two(5,6)+glob);
}

开始.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

函数二和全局变量 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 是标准引导程序所需的外部地址,我没有使用它,所以我们不会收到 main not found 错误)。因为我没有使用链接器脚本,所以 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 粗略地放入它正在构建的二进制文件中,它必须将地址解析为 2 和全局地址,因此它必须放置 two.o,然后找出结尾的位置这种情况下 .bss 不是 .data 因为我没有预先初始化变量。

两个的标签位于 0x10000030,因此它将 bl 二合一()编码为该相对偏移量,它还出于某种原因将 glob 置于 1000804c(我没有完全定义 ram 的位置,所以 gnu 链接器会做事像这样)。尽管有原因,这是链接器为该全局变量定义主地址的地方,并且链接器需要填写 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 相关的内容现在无法访问,现在需要解决方案(错误或补丁)。

单个源文件的汇编程序本身必须经历更多的这些回转,尤其是对于像 x86 这样的寻址非常模糊的可变长度指令集。我建议自己尝试制作一个简单的汇编程序,它只支持一些指令但其中一些分支。并对指令进行解析和编码,并将其与现有的已调试汇编程序(如 gnu assembler)进行比较。

测试.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 和其他信息,然后开始将它们放置在您想要的任何地址空间中。

其中任何一个都是相当简单的编程任务,但也很有教育意义。拥有可以生成答案的现有工具链,您可以找出哪里出错或如何获得正确答案。

【讨论】:

  • 感谢详尽的回答,这几乎是我所期待的。我完全同意不使用 printf(),这也只是为了具有来自外部库的功能。虽然它非常密集,所以澄清一下:所以 .o 文件实际上只是具有不完整分支和变量地址的汇编文件;我不太明白链接器如何在其他 .o 文件中找到正确的地址,它与“符号”有关吗?
  • 取决于目标文件格式以及编译器/汇编器和链接器如何在它们之间传递此类信息。库几乎相同,除了根据我对库的经验,它不包括整个库,但链接器提取它需要的函数,所以 printf 会导致一堆其他的。库也可以是这样的,而不是静态代码,所有需要添加的代码,而不是可以找到共享库(.so,.dll等)的存根
  • 同样的处理,尽管必须有解决方案,例如 printf 函数可能会在运行时尝试查找它需要的所有其他库而不是编译时间(如果与动态库链接),但这可能会有所不同与操作系统或工具链。对于如何解决问题没有通用规则,在编译器系列或操作系统中,组合可能具有您必须或应该遵守的规则,但是链接器和编译器/汇编器如何相互通信随着时间的推移而变化,并且在工具链中。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-07-20
  • 1970-01-01
  • 1970-01-01
  • 2010-10-12
  • 2016-03-14
  • 2015-03-07
  • 2013-06-28
相关资源
最近更新 更多