链接器的神秘面纱

链接器的神秘面纱,第1张

链接器的神秘面纱

文章目录

一、链接器的作用

1.1 编译阶段目标文件的组成:1.2 链接器的作用: 二、代码重定位三、程序的启动过程四、链接器的工作方式

4.1 指定链接起始地址4.2 指定存储区域4.3 指定代码入口

一、链接器的作用

源文件被编译成目标文件之后,如何变成最后的可执行文件?

链接器的作用是把各个模块之间相互引用的部分处理好,使各个模块之间能够正确衔接。 1.1 编译阶段目标文件的组成:

各个段没有具体的起始地址,只有段大小各个标识符没有实地址,只有相对地址段和标识符的实地址需要链接器指定 1.2 链接器的作用:

将目标文件和库文件整合成目标可执行程序合并各个目标文件中的段(*.txt *.bss *.data)确定各个段中标识符的目标地址(重定向) 二、代码重定位

下面使用一段代码编译成中间文件,然后使用nm工具查看里面的段信息和符号信息。

nm命令是linux下自带的特定文件分析工具,一般用来检查分析二进制文件、库文件、可执行文件中的符号表,返回二进制文件中各段的信息。

#include 
#include "module.h"

error_t module_timer (system_state_t _state)
{
    return 0;
}

error_t module_memory (system_state_t _state)
{
    return 0;
}

void module_registration_entry ()
{
    (void) module_register ("Timer", MODULE_TIMER, OS_LEVEL, module_timer);
    (void) module_register ("Memory", MODULE_HEAP, OS_LEVEL, module_memory);
}

int main ()
{
    module_registration_entry ();
    printf ("System is going to be upn");
    if (0 != system_up ()) {
        printf ("Error: system cannot be upn");
        return -1;
    }
    printf ("nSystem is going to be downn");
    system_down ();
    return 0;
}

nm中的标识符的含义:

标识描述A符号的值是绝对值,不会被更改B或b未被初始化的全局数据,放在.bss段D或d已经初始化的全局数据G或g指被初始化的数据,特指small objectsI另一个符号的间接参考Ndebugging 符号p位于堆栈展开部分R或r属于只读存储区S或s指为初始化的全局数据,特指small objectsT或t代码段的数据,.test段U符号未定义W或w符号为弱符号,当系统有定义符号时,使用定义符号,当系统未定义符号且定义了弱符号时,使用弱符号。?unknown符号

下面是main.o的一些符号信息,module_register 没有在main.c中定义,在编译阶段不确定具体地址,module_timer显示了函数相对于text段的相对地址,如果是全局变量那也只是得到相对于bss或者data段的相对地址。

> nm build/main.o
00000000000000e9 T main
0000000000000058 T module_memory
                 U module_register                
00000000000000b0 T module_registration_entry
0000000000000000 T module_timer
                 U puts
                 U system_down
                 U system_up

代码链接完成之后,所有的符号都有了具体地址,重定向代码就是链接器的主要工作之一:

> nm build/target 
000000000040180a T main
0000000000401779 T module_memory
0000000000401316 T module_register
00000000004017d1 T module_registration_entry
0000000000401721 T module_timer
                 U puts@@GLIBC_2.2.5
0000000000401658 T system_down
00000000004014a8 T system_up
三、程序的启动过程

main函数是第一个被执行的函数吗?

程序加载后,第一个被执行的是_start()函数_start()函数准备好之后调用libc_start_main()libc_start_main准备好运行环境之后再运行main函数_start()函数的入口地址就是代码段起始地址

为了证明上述结论,借助反汇编查找main函数地址:

> objdump -S build/target > target.s
000000000040180a 
:

然后在target.s中查找 40180a ,下面的代码中告诉我们main函数的地址被放入到寄存器中,除了main()函数之外,还有__libc_csu_fini()和__libc_csu_init(),这个三个函数地址共同作为参数被传递到__libc_start_main()中进行调用。

__libc_start_main()的作用:

调用__libc_csu_init()完成必要的初始化启动主线程,入口为main注册__libc_csu_fini函数,在程序结束时被调用

0000000000401060 <_start>:
  401060:	f3 0f 1e fa          	endbr64 
  401064:	31 ed                	xor    %ebp,%ebp
  401066:	49 89 d1             	mov    %rdx,%r9
  401069:	5e                   	pop    %rsi
  40106a:	48 89 e2             	mov    %rsp,%rdx
  40106d:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp
  401071:	50                   	push   %rax
  401072:	54                   	push   %rsp
  401073:	49 c7 c0 a0 1f 40 00 	mov    $0x401fa0,%r8        # <__libc_csu_fini>
  40107a:	48 c7 c1 30 1f 40 00 	mov    $0x401f30,%rcx       # <__libc_csu_init>
  401081:	48 c7 c7 0a 18 40 00 	mov    $0x40180a,%rdi       # 
401088: ff 15 62 2f 00 00 callq *0x2f62(%rip) # 403ff0 <__libc_start_main@GLIBC_2.2.5> 40108e: f4 hlt 40108f: 90 nop

通过上面还不能证明第一个被调用的函数就是_start,因此还需要借助objdump查看段信息,下面列出了代码段的起始地址,电脑将代码段拷贝到内存中之后会将PC指针指向代码段起始地址然后开始运行,因此_start()才是第一个被执行的函数。

> objdump -h build/target 
build/target:     文件格式 elf64-x86-64

    段名称         段大小    虚拟地址          加载地址                     对齐
Idx Name          Size      VMA               LMA               File off  Algn
 12 .text         00000f45  0000000000401060  0000000000401060  00001060  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

最后再看一下程序是如何执行的:

四、链接器的工作方式

上一节中我们分析了一个程序的启动过程,入口函数是_start,然后创建了一个线程,这样的执行过程是依据什么规则来进行链接的呢?

描述这个规则的文件就是链接脚本,它的功能如下:

合并目标文件中的段重定位段起始地址重定位符号的最终地址

使用 ld --verbose 可以查看默认链接脚本,前面分析的程序启动过程,正是按照这个方式进行链接的。

4.1 指定链接起始地址

在链接脚本中可以指定段的起始链接地址,也可以在链接脚本中定义变量并指定变量的地址:

SECTIONS
{
  
  .text 0x2000000:   
  {
    *.text
  }

  
  . = 0x8000000;
  
  g_flag = . ;

  .data 0x3000000:
  {
    *.data
  }

  
  .bss :
  {
    *.bss
  }
}

注意事项:

各个段的链接地址必须符合平台规范,不符合规范链接出的程序不能正常执行链接脚本能直接定义标识符的存储地址链接脚本能指定源代码中标识符的存储位置

通过指定链接脚本之后,我们再来看一下对应的段起始地址是否发生了变化:

> objdump -h build/target 

Idx Name          Size      VMA               LMA               File off  Algn
 12 .text         00000f45  0000000002000000  0000000002000000  00002000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 14 .rodata       000002a1  0000000002001000  0000000002001000  00003000  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 23 .data         00000010  0000000003000000  0000000003000000  00005000  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 24 .bss          00000668  0000000003000020  0000000003000020  00005010  2**5
                  ALLOC

在测试代码中打印了在链接脚本中的全局变量:

# include 

extern int g_flag;

int main()
{
    printf("g_flag addr:%lxn", (long unsigned int)&g_flag);
}

结果如下:

g_flag addr:8000000
4.2 指定存储区域

默认情况下应用程序加载在同一个存储空间运行,但是在一些嵌入式系统中,存在多个存储空间(RAM存储数据,Flash存储代码),需要使用MEMORY指定存储区域。

下面是STM32CubeMx生成的Makefile工程,由于STM32的存储器分为了三个不同的部分,并且互相之间不连续,需要使用MEMORY指定存储区域:

MEMORY
{
RAM(xrw):ORIGIN = 0x20000000, LENGTH = 128K
CCMRAM(xrw):ORIGIN = 0x10000000, LENGTH = 64K
FLASH(rx):ORIGIN = 0x80000000, LENGTH = 512K
}

下面是MEMORY的属性说明:

标识说明R只读W可读写X可执行A可分配I可初始化L已初始化!属性反转

根据功能划分将不同的段放入到了不同的存储器中:

SECTIONS
{
  
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) 
    . = ALIGN(4);
  } >FLASH

  
  .text :
  {
    . = ALIGN(4);
    *(.text)           
    *(.text*)          
    *(.glue_7)         
    *(.glue_7t)        
    *(.eh_frame)

    KEEP (*(.init))
    KEEP (*(.fini))

    . = ALIGN(4);
    _etext = .;        
  } >FLASH

  
  .rodata :
  {
    . = ALIGN(4);
    *(.rodata)         
    *(.rodata*)        
    . = ALIGN(4);
  } >FLASH
  
  
  .data : 
  {
    . = ALIGN(4);
    _sdata = .;        
    *(.data)           
    *(.data*)          

    . = ALIGN(4);
    _edata = .;        
  } >RAM AT> FLASH

这里的AT指令是用于指定存储的位置,如果没有AT指令,那么存储这段数据的地址就是对应的链接地址,当程序段链接地址和存储地址不一致的时候,可以使用AT指定,在程序运行之后需要将对应的段从存储地址加载到链接地址。

中断向量表,代码段,数据段初始值,只读数据段都被放到了FLASH中,程序运行之后从RAM中访问数据,因此在访问之前需要把这段数据从FLASH(LMA)加载到RAM(VMA)中,做数据段的初始化,然后再执行后面的程序。

像这种情况下VMA和LMA就是不一致的,前面我们看到的Linux上的VMA和LMA是一致的,这是因为程序的加载地址就是运行时的虚拟地址,而不需要像嵌入式设备中一样需要把数据从一个地址搬到另一个地址,然后再开始运行程序。

4.3 指定代码入口

前面我们看到了程序的入口为_start,我们可以查看默认的链接脚本中有这么一行:

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64",
	      "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)

这一句在 *** 作系统将程序从硬盘加载到内存中之后,将会把PC指针指向_start的地址,然后开始执行。

我们也可以看一下STM32的链接脚本入口为ResetHandler,意味着如果从内部bootrom执行完成之后,将跳转到Reset_Handler开始运行:

ENTRY(Reset_Handler)

这里将名为.isr_vector的段写入了FLASH起始位置0x80000000,

SECTIONS
{
.isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) 
    . = ALIGN(4);
  } >FLASH
}

下面是.isr_vector的部分定义,最前面的4字节并不是Reset_Handler,而是栈顶指针,内部bootrom将0x80000004处存储的Reset_Handler地址取出,然后进行跳转:

   .section  .isr_vector,"a",%progbits
  .type  g_pfnVectors, %object
  .size  g_pfnVectors, .-g_pfnVectors
    
    
g_pfnVectors:
  .word  _estack
  .word  Reset_Handler
  .word  NMI_Handler
  .word  HardFault_Handler
  .word  MemManage_Handler
  .word  BusFault_Handler
  .word  UsageFault_Handler

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5711764.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存