[开发工具] 嵌入式C中链接器咋知道程序运行地址的?

[复制链接]
 楼主| DKENNY 发表于 2025-8-4 23:05 | 显示全部楼层 |阅读模式
本帖最后由 DKENNY 于 2025-8-4 23:04 编辑

#技术资源# #申请原创# @21小跑堂
前言
      在嵌入式C开发中,链接器(linker)是个幕后大佬,负责把一堆目标文件(.o 文件)和库文件捏成一个能跑的可执行文件,还得给代码和数据安排好内存地址。很多人好奇:链接器咋就这么神,提前知道程序跑起来时地址在哪儿?这些地址是物理地址还是虚拟地址?这篇文章就和大家分享一下。
1d1a9243e3a879f4cbcab551e34922a1

一、链接器是干啥的?
      链接器是编译的最后一环,活儿挺多:
      - 符号解析:把代码里的符号(比如函数 main() 或者全局变量 g_count)跟具体的内存地址绑上。
      - 地址分配:给程序的代码段(.text)、数据段(.data.bss)安排内存地址。
      - 生成可执行文件:整出一个能在硬件上跑的文件,比如 ELF、HEX 或 BIN 格式,里头带上内存布局的“地图”。
      - 处理重定位:如果程序需要动态调整地址,链接器还会生成个重定位表,记下哪些地方得改。
      在嵌入式系统里,链接器靠一个叫链接脚本(linker script)的东西干活。这脚本就像个“导航”,告诉链接器内存咋分,代码放哪儿,数据搁哪儿。比如,下面是个 ARM Cortex-M 微控制器的链接脚本样例:

  1. MEMORY
  2. {
  3.   FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K  /* 代码放 Flash */
  4.   SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K  /* 数据放 SRAM */
  5. }
  6. SECTIONS
  7. {
  8.   .text : { *(.text) } > FLASH        /* 代码扔 Flash 里 */
  9.   .rodata : { *(.rodata) } > FLASH    /* 只读数据也放 Flash */
  10.   .data : { *(.data) } > SRAM AT > FLASH  /* 初始化数据放 SRAM,启动时从 Flash 拷过来 */
  11.   .bss : { *(.bss) } > SRAM           /* 未初始化数据放 SRAM */
  12. }
    图1:链接器的工作流程
00e45282c8e2145c3f68970219980dc5

     这脚本里,ORIGIN 是内存的起点地址,LENGTH 是区域大小,SECTIONS 告诉链接器把啥放哪儿。这些地址通常跟硬件的内存布局一模一样。

二、链接器为啥能“掐指一算”知道运行时地址?
     链接器能“预言”程序运行时地址,主要是因为嵌入式系统的内存布局简单、固定,基本没啥花样。咱一条条拆开说:

1. 硬件内存布局固定得像钉子
     嵌入式系统的硬件内存布局是死的,早就定好了。比如 APM32F4 微控制器:
     - Flash(存代码)从 0x08000000 开始。
     - SRAM(存变量、堆栈)从 0x20000000 开始。
     - 外设寄存器(比如 GPIO、UART)也在固定地址(像 0x40000000)。
     这些地址在芯片手册里写得清清楚楚,链接器通过链接脚本直接照着硬件的“地图”把代码和数据塞到对应位置。

      图2:嵌入式系统的内存布局
ad3fc8f88085affacadc9a98a5318d6c

2. 静态链接,地址一次定死
     嵌入式系统一般用静态链接,啥意思?就是程序在链接时就把所有地址定好了,不像动态链接那样到运行时还得再算。比如:
     - 你写了个函数 main(),链接器给它安排个 Flash 地址,比如 0x08000100
     - 全局变量 int g_count = 10;,链接器可能给它 SRAM 的 0x20000004
     因为没动态库(DLL)捣乱,链接器定的地址就是程序跑起来时实打实的地址,加载到硬件就直接用。

3. 没虚拟内存,简单粗暴
     大部分嵌入式系统是裸机(bare-metal)或者跑个简单的实时操作系统(RTOS,比如 FreeRTOS)。这些系统:
     - 没内存管理单元(MMU),不支持虚拟内存,地址就是物理地址。
     - 没地址空间随机化(ASLR),地址不会乱跳。
     - 要实时性,固定地址跑得快,省事儿。
     所以,链接器分配的地址直接就是硬件的物理地址,程序跑起来时一点不差。

4. 启动代码和加载过程不搞乱
     嵌入式系统有段启动代码(startup code)或者引导加载程序(bootloader),负责把程序从 Flash 搬到 SRAM 或者直接跑。这些代码严格按链接脚本的地址干活:
     - 启动代码(比如 Keil 的 startup.s 或 GCC 的 crt0.s)会初始化向量表、复制 .data 段到 SRAM、清零 .bss 段。
     - 比如,.data 段可能存在 Flash 里,但启动时会被拷到 SRAM 的 0x20000000
     因为这过程死板得很,链接器定的地址跟运行时地址完全对得上。

      图3:启动代码的地址初始化过程
61c81bf71c2fd9ec8bb3a88aacc5150e

三、物理地址还是虚拟地址?
     链接器到底给的是物理地址还是虚拟地址?得看你的系统是啥情况。咱分开来聊。

1. 物理地址:嵌入式系统的主流
     在大多数嵌入式系统(比如 ARM Cortex-M、PIC、AVR 微控制器)里,链接器给的是物理地址。为啥?
     - 没MMU:这些芯片没内存管理单元,压根儿不支持虚拟内存,地址就是硬件的真实地址。
     - 内存布局固定:链接脚本里的地址(比如 0x08000000)直接对应芯片的 Flash 或 SRAM。
     - 实时性要紧:物理地址省去了映射的麻烦,程序跑得快,适合时间敏感的场景。
     - 简单好调试:直接用物理地址,烧录固件、调试都方便。
      举个栗子:用 APM32F103 微控制器,链接器把代码扔到 Flash 的 0x08000000,变量放 SRAM 的 0x20000000。程序烧到 Flash 后,微控制器直接从这些地址跑代码、读变量,实打实的物理地址。

2. 虚拟地址:复杂系统的机制
     如果你的嵌入式系统跑的是复杂操作系统(比如嵌入式 Linux 或 Windows CE),链接器可能给虚拟地址
     - 有MMU:像 ARM Cortex-A 系列的处理器有 MMU,能把虚拟地址映射到物理地址。链接器给的地址是虚拟的,跑的时候操作系统通过页表把虚拟地址转成物理地址。
     - 多任务环境:每个程序可能有自己的虚拟地址空间。比如,程序 A 和程序 B 都用虚拟地址 0x10000000,但 MMU 会把它们映射到不同的物理地址。
     - 动态链接:如果系统支持动态链接库,链接器给的地址是虚拟的,实际地址得等加载器(比如 Linux 的 ld.so)在运行时算。
      举个栗子:在 Raspberry Pi 上跑嵌入式 Linux,链接器可能给程序分配虚拟地址 0x10000000。程序跑起来时,Linux 内核用 MMU 把这个地址映射到物理内存(比如 DDR RAM 的某个区域)。

     图4:物理地址 vs 虚拟地址
a17ffc86327746f6806bb959ce5d1d00

3. 咋判断是物理地址还是虚拟地址?
     想知道链接器给的是啥地址,试试这几招:
     - 看硬件:查芯片手册,看有没有 MMU。Cortex-M 没 MMU,肯定是物理地址;Cortex-A 有 MMU,可能用虚拟地址。
     - 瞅链接脚本:脚本里的 `ORIGIN` 地址跟芯片手册的内存布局对得上?对得上就是物理地址。
     - 看系统:裸机或简单 RTOS 用物理地址;嵌入式 Linux 这种复杂系统多半用虚拟地址。
     - 查工具链:看看编译器和链接器选项,比如 GCC 的 -fPIC(位置无关代码)可能涉及虚拟地址。

四、特殊情况:地址不固定咋办?
     有时候,链接器给的地址不一定是最终地址,得靠点“后招”调整:
      1. 位置无关代码(PIC)  
     链接器生成位置无关代码,程序加载到哪儿都能跑,用相对地址或者基址寄存器(比如程序计数器 PC)算实际地址。这种**在嵌入式系统不常见,因为费资源,但动态加载的场景可能用。
      2. 运行时重定位
      链接器可能生成个重定位表,记下哪些符号的地址得改。启动代码或加载器加载程序时,根据实际内存地址调整。比如,程序可能被挪到 SRAM 的不同位置,重定位表就派上用场。
      3. Bootloader  
     很多嵌入式系统用 Bootloader 把程序从 Flash 搬到 SRAM 跑。Bootloader 可能根据情况(比如内存分配)调整地址,但这些调整还是基于链接器脚本的“蓝图”。
      举个栗子:低功耗设备可能把代码从 Flash(0x08010000)拷到 SRAM(0x20001000)跑快点。链接器脚本会注明运行地址是 SRAM 的,Bootloader 负责搬运。

五、地址对不上咋整?常见坑和解决办法
     链接器通常很靠谱,但有时候也会翻车,地址对不上,程序跑不起来。常见坑有:
     - 链接脚本写错了:比如 APM32F4 的 Flash 是 0x08000000,你写成 0x00000000,程序肯定崩。
     - 硬件改了,脚本没跟上:换了个芯片,内存布局变了,链接脚本还用老的,地址肯定错。
     - 工具链配置乱了:比如没指定正确的目标架构(像 -mcpu=cortex-m4),链接器可能用错地址。
     - 向量表偏移:ARM Cortex-M 的中断向量表地址可能被 VTOR 寄存器改了,但链接脚本没跟上,程序启动就挂。
     - 动态加载:如果系统支持动态加载,运行时地址可能跟链接时不一样(不过嵌入式系统里少见)。

      解决办法
     - 翻芯片手册,核对内存布局。
     - 仔细检查链接脚本的 MEMORYSECTIONS
     - 用调试器(像 J-Link 或 ST-Link)看看程序加载的地址和符号表。
     - 确认启动代码有没有把 .data.bss 段初始化好。

      图5:地址不匹配的常见错误
275c1d9a5a1440c2bdee1f6f3426045a

六、 实际场景举例
     咱来看两个实际场景,讲讲链接器咋干活:

      场景 1:APM32F4 裸机系统
     - 硬件:APM32F407,Flash 从 0x08000000,SRAM 从 0x20000000
     - 工具链:ARM GCC + GNU LD。
     - 链接脚本
  1.   MEMORY
  2.   {
  3.     FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 2M
  4.     SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 192K
  5.   }
  6.   SECTIONS
  7.   {
  8.     .text : { *(.text) } > FLASH
  9.     .data : { *(.data) } > SRAM AT > FLASH
  10.     .bss : { *(.bss) } > SRAM
  11.   }
     - 咋分配
        - 函数 main() 放 Flash 的 0x08000100
        - 全局变量 g_count 放 SRAM 的 0x20000004
        - 启动代码把 .data 从 Flash 拷到 SRAM,.bss 清零。
      - 结果:链接器给的是物理地址,程序烧到 Flash 后直接从 0x08000000 跑,变量用 SRAM 的地址。

      场景 2:Raspberry Pi 跑嵌入式 Linux
      - 硬件:Raspberry Pi 4,ARM Cortex-A72,有 MMU。
      - 系统:嵌入式 Linux。
      - 链接脚本:工具链默认的,基于虚拟地址(比如 0x10000000)。
      - 咋分配
        - 链接器给程序分配虚拟地址,比如代码从 0x10000000
        - Linux 内核加载时,用 MMU 把虚拟地址映射到物理内存(比如 DDR RAM)。
        - 如果有动态链接库,加载器 ld.so 会在运行时搞定地址。
      - 结果:链接器给的是虚拟地址,实际物理地址由 Linux 内核的 MMU 决定。

七、为啥嵌入式系统爱用物理地址?
      嵌入式系统偏爱物理地址,有几个原因:
      - 硬件限制:好多微控制器没 MMU,根本玩不了虚拟地址。
      - 实时性:物理地址省去映射开销,程序跑得快,适合时间敏感的场景。
      - 简单省事儿:直接用物理地址,开发、调试、烧固件都方便。
      - 硬件固定:内存和外设的地址是硬件定的,链接器得跟上。

八、咋确认链接器给的是物理地址还是虚拟地址?
      想搞清楚链接器给的是啥地址,试试这几招:
      - 查硬件手册:看芯片有没有 MMU,内存布局是啥。
      - 看链接脚本ORIGIN 地址跟手册对得上就是物理地址。
      - 看系统:裸机或简单 RTOS 肯定是物理地址;嵌入式 Linux 可能是虚拟地址。
      - 查工具链:看有没有 -fPIC 这种选项,涉及虚拟地址的可能。

九、总结
      在嵌入式C里,链接器靠链接脚本给程序安排内存地址。裸机或简单 RTOS 系统(像 ARM Cortex-M)里,链接器给的是物理地址,因为没 MMU,地址直接对应硬件的 Flash 或 SRAM,程序跑起来一点不差。跑嵌入式 Linux 这种复杂系统的,链接器给的是虚拟地址,实际物理地址靠 MMU 映射。
      链接器能“掐指一算”运行时地址,靠的是嵌入式系统内存布局固定、静态链接省事儿、链接脚本指路。这套机制简单高效,特别适合资源少、要实时的嵌入式环境。



您需要登录后才可以回帖 登录 | 注册

本版积分规则

59

主题

104

帖子

16

粉丝
快速回复 在线客服 返回列表 返回顶部