尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

嵌入式开发实战:链接器脚本划分Flash空间原理与STM32应用

嵌入式开发实战:链接器脚本划分Flash空间原理与STM32应用 1. 项目概述为什么嵌入式开发需要手动划分Flash空间如果你做过嵌入式开发尤其是基于ARM Cortex-M这类资源受限的MCU的项目大概率遇到过这样的困境代码量越来越大突然有一天编译链接时链接器Linker报错告诉你“.text”段代码段或者“.data”段已初始化数据段放不下了。更棘手的是你的应用可能还需要在Flash里开辟一块独立区域用来存储固件升级包、配置文件、日志或者产品序列号。这时候仅仅依赖IDE默认的链接脚本Linker Script是不够的你需要亲自操刀告诉链接器“这块Flash空间我要这么分。”这就是“使用链接器划分Flash空间”的核心价值。它不是一个炫技的工具而是嵌入式开发者从“会用IDE”到“理解系统底层”的关键一步。默认的链接脚本通常只做最基础的划分代码放这里数据放那里。但对于复杂的、需要OTA空中升级、双备份启动、或者有严格安全分区需求的产品你必须精确控制每一段代码、每一块数据在Flash中的物理地址。这个过程本质上是在编写一个“内存地图的施工蓝图”链接器则是按照你的蓝图将编译后的二进制“材料”放置到指定“位置”的施工队。掌握这项技能意味着你能突破默认内存布局的限制充分利用芯片的每一KB Flash。实现高级功能如Bootloader/App分区、A/B双备份固件、非易失性配置存储区。优化启动速度和执行效率通过将关键代码或数据放到更快的存储区域如ITCM。为团队协作和项目管理打下基础清晰的内存分区是大型、长期嵌入式项目的基石。接下来我将以一个典型的基于ARM Cortex-M3/M4的STM32项目为例手把手带你从理解原理到实战操作彻底掌握用链接器划分Flash空间的完整流程和避坑技巧。2. 核心概念解析链接脚本、存储段与符号在动手修改之前我们必须先理解三个核心概念链接脚本Linker Script、存储段Section和符号Symbol。这是你与链接器“对话”的语言。2.1 链接脚本内存布局的“宪法”链接脚本通常以.ld、.lds、.icf等为后缀是一个文本文件它用一套特定的描述语言精确地定义了内存区域Memory Region你的芯片有哪些物理存储空间比如Flash的起始地址和大小RAM的起始地址和大小可能还有CCM RAM、备份RAM等。存储段Section你的程序由哪些部分组成比如代码.text、只读数据.rodata、已初始化数据.data、未初始化数据.bss等。编译器会生成这些段。放置规则将指定的存储段放置到指定的内存区域并可以指定对齐方式。你可以把链接脚本想象成一座大楼的建筑图纸。图纸上定义了地下室RAM、一楼到十楼Flash每个区域是干什么的内存区域也定义了砖块代码、钢筋数据这些材料存储段应该分别运到哪个区域堆放。在STM32 CubeIDE或Keil MDK中链接脚本通常是自动生成并隐藏在工程配置背后的。但当你需要自定义分区时就必须直面它、修改它。2.2 存储段程序的“零部件”编译器在将你的C/C源代码转换成机器码.o目标文件时会按照类别将不同的内容分配到不同的“段”里。常见的段有.text存放所有可执行的程序代码函数体。.rodata存放只读的常量数据比如字符串常量、const修饰的全局变量。.data存放已初始化且初值非零的全局变量和静态变量。这个段的特点是其初始值需要从Flash拷贝到RAM中。.bss存放未初始化或初始化为零的全局变量和静态变量。这个段在程序启动时会被清零。.stack/.heap栈和堆空间。链接器的工作就是收集所有.o文件中的同名段比如把所有.o的.text段合并然后根据链接脚本的指示将它们摆放到最终的内存地址上。2.3 符号内存地址的“门牌号”在链接脚本和你的C代码中我们经常需要引用某个段或某个变量的起始地址、结束地址或大小。链接器在完成布局后会生成一些“符号”这些符号本质上是一个个地址值。在你的C代码中可以通过声明外部变量extern来引用这些符号从而知道某个自定义分区在内存中的具体位置。例如你可以在链接脚本中定义一个叫__config_flash_start的符号指向你自定义的配置存储区的开始地址。然后在C代码中extern char __config_flash_start;这样__config_flash_start就是该区域的指针。理解了这三者我们就有了划分Flash空间的理论基础。接下来我们进入实战环节。3. 实战为STM32项目创建自定义Flash分区假设我们有一个STM32F407的工程Flash总容量为1MB0x08000000 - 0x080FFFFF。现在需求是前64KB0x08000000 - 0x0800FFFF留给Bootloader。紧接着的896KB0x08010000 - 0x080FFFFF留给主应用程序App。在App区域的末尾划出最后4KB0x080FF000 - 0x080FFFFF作为一个独立的“参数存储区”用来存放系统配置。我们将基于GCC工具链Arm-none-eabi-gcc和对应的链接脚本.ld文件进行演示。其他工具链如IAR的.icfARMCC的.sct原理相通语法不同。3.1 步骤一解剖默认链接脚本首先找到你工程中的链接脚本。在STM32CubeIDE生成的Makefile工程中它通常叫STM32F407VGTx_FLASH.ld。我们用文本编辑器打开它会看到类似以下结构/* 定义内存区域 */ MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K FLASH (rx) : ORIGIN 0x8000000, LENGTH 1024K } /* 定义段如何放置 */ SECTIONS { /* .isr_vector段放在Flash最开始 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH /* 后续的.text, .rodata等段依次存放 */ .text : { *(.text) *(.text*) /* ... 其他规则 */ } FLASH /* .data段VMA在RAMLMA在Flash */ .data : AT ( __etext ) { . ALIGN(4); __data_start__ .; *(.data) *(.data*) . ALIGN(4); __data_end__ .; } RAM /* ... 其他段定义如.bss, ._user_heap_stack等 */ }关键点解读MEMORY定义了名为FLASH和RAM的两个内存区域并给出了它们的起始地址ORIGIN和长度LENGTH。SECTIONS定义了各个段的放置规则。FLASH表示该段输出到FLASH区域。链接器会按照SECTIONS中编写的顺序依次将段放入内存区域。. ALIGN(4);这是一个“位置计数器”.代表当前输出地址。ALIGN(4)表示将当前地址向上对齐到4字节边界。这对于ARM架构要求字对齐访问至关重要不对齐可能导致硬件错误。AT ( __etext )这是.data段的关键。它表示.data段的加载内存地址LMA Load Memory Address在Flash中一个叫__etext的符号之后__etext通常是.text等只读段的结束地址。程序启动时启动代码需要将这部分数据从Flash的LMA拷贝到RAM中的VMA虚拟内存地址即RAM指定的地址。注意默认脚本通常把整个Flash作为一个连续的FLASH区域来使用。我们的目标就是修改这个布局。3.2 步骤二重新规划MEMORY区域根据需求我们不能把1MB Flash当成一个整体了。我们需要在MEMORY命令中将其细分。MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K /* 将Flash拆分成三个独立区域 */ BOOT_FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K APP_FLASH (rx) : ORIGIN 0x08010000, LENGTH 896K PARAM_FLASH (r) : ORIGIN 0x080FF000, LENGTH 4K }这里我们定义了三个独立的Flash区域BOOT_FLASHAPP_FLASHPARAM_FLASH。属性(rx)表示可读可执行代码(r)表示只读用于存储参数。3.3 步骤三在SECTIONS中分配段到新区域现在我们需要修改SECTIONS部分将不同的段放到不同的区域。核心思想是Bootloader的代码和App的代码需要分别编译成两个独立的工程各有自己的链接脚本。这里我们以App工程的链接脚本为例演示如何让App的代码从0x08010000开始存放。我们需要找到放置.isr_vector和.text等核心代码段的地方修改其输出区域。SECTIONS { /* 1. 中断向量表必须放在APP_FLASH区域的起始处 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* 保持中断向量表 */ . ALIGN(4); } APP_FLASH /* 注意这里改为 APP_FLASH */ /* 2. 程序代码段紧随其后 */ .text : { . ALIGN(4); *(.text) /* .text sections (code) */ *(.text*) /* .text* sections (code) */ *(.glue_7) /* glue arm to thumb code */ *(.glue_7t) /* glue thumb to arm code */ *(.eh_frame) KEEP (*(.init)) KEEP (*(.fini)) . ALIGN(4); _etext .; /* 定义一个全局符号标记代码段结束 */ } APP_FLASH /* 同样输出到 APP_FLASH */ /* 3. 只读数据段 */ .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } APP_FLASH /* 4. 关键的一步为参数存储区定义一个独立的段 */ .param_flash : { . ALIGN(4); PROVIDE_HIDDEN (__param_flash_start .); KEEP(*(.param_flash)) /* 收集所有.input_section_name为.param_flash的数据 */ . ALIGN(4); PROVIDE_HIDDEN (__param_flash_end .); } PARAM_FLASH /* 输出到我们定义的参数Flash区域 */ /* 5. .data, .bss等段仍然放在RAM其LMA在APP_FLASH中紧随.rodata之后 */ /* ... 后续的.data, .bss, ._user_heap_stack等定义保持不变但要注意其LMA的计算可能因前面分区而受影响 */ /* __etext 可能需要在.param_flash定义前重新计算确保.data的LMA在APP_FLASH内连续 */ }关键修改解析.isr_vector和.text等段输出目标从FLASH改为了APP_FLASH。这确保了主应用程序的代码从0x08010000开始链接。新增.param_flash段我们创建了一个名为.param_flash的输入段。PROVIDE_HIDDEN创建了两个符号__param_flash_start和__param_flash_end用于在C代码中定位这个区域。KEEP指令确保即使该段的内容未被直接引用也不会被链接器优化掉。.data段的LMA.data段的加载地址AT指令指定的地址必须在APP_FLASH区域内且位于所有只读段.text,.rodata之后。通常链接器会自动计算但分区后需要检查默认的__etext符号是否仍然指向正确的位置即APP_FLASH内代码/只读数据的结束地址。如果出现问题可能需要显式地在此处定义一个符号来标记.data段的LMA。3.4 步骤四在C代码中使用自定义分区链接脚本定义好了“地盘”接下来就是在C代码里“入驻”。首先将数据放入自定义段。我们需要告诉编译器某些特定的变量应该被放到我们定义的.param_flash段而不是默认的.data或.rodata。/* 在C源文件中例如 app_config.c */ /* 方法1使用GCC的section属性 (适用于GCC/Clang) */ const uint32_t system_config[128] __attribute__((section(.param_flash))) { // ... 你的配置初始值 }; /* 方法2如果需要未初始化的保留空间 */ uint8_t log_buffer[1024] __attribute__((section(.param_flash))); /* 注意对于未初始化的数组其内容在Flash中是未定义的可能是0xFF需要在程序启动时初始化。*/ /* 方法3使用指针访问更灵活 */ /* 先在链接脚本中定义符号然后在C中声明 */ extern const char __param_flash_start[]; extern const char __param_flash_end[]; #define PARAM_FLASH_START ((uintptr_t)__param_flash_start) #define PARAM_FLASH_SIZE ((size_t)(__param_flash_end - __param_flash_start)) void save_config_to_flash(const void* data, size_t len) { if (len PARAM_FLASH_SIZE) return; // 注意这里需要对Flash进行擦写编程操作不能直接memcpy // 需要调用HAL_FLASH_Program等函数。 // 例如HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_FLASH_START, *(uint32_t*)data); }其次修改向量表偏移。对于Cortex-M中断向量表的位置由SCB-VTOR寄存器决定。我们的App中断向量表现在位于0x08010000因此必须在App的启动代码SystemInit或main最开始中设置VTOR。// 在main函数开始时调用 void SystemInit(void) { // ... 其他系统初始化 #ifdef VECT_TAB_OFFSET SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET; #endif }在编译器的预定义宏中如Makefile或IDE配置我们需要为App工程定义VECT_TAB_OFFSET为0x10000。这样当中断发生时CPU才知道去新的地址查找向量表。4. 高级技巧与避坑指南掌握了基本操作下面分享一些实战中积累的经验和容易踩的坑。4.1 分区对齐与填充地址计算的魔鬼细节Flash分区时起始地址和长度必须符合Flash扇区Sector的擦除边界。例如STM32F4的Flash扇区大小不一前4个16KB后续64KB或128KB。如果你定义的PARAM_FLASH区域起始于0x080FF000长度为4KB你必须确保这个4KB区域完全落在某个或某几个完整的扇区内否则进行擦写操作时会误擦其他数据。技巧使用链接脚本的ALIGN功能结合LENGTH定义确保区域边界对齐。PARAM_FLASH (r) : ORIGIN 0x080FF000, LENGTH ALIGN(4K) /* 确保长度是4K对齐 */另外如果某个段如.param_flash的实际内容很小但你想让它独占一个扇区以便独立擦写可以在段定义末尾使用FILL或. ORIGIN(PARAM_FLASH) LENGTH(PARAM_FLASH);来将位置计数器直接跳到该区域末尾避免链接器在后面放置其他内容。4.2 Bootloader与App的衔接跳转与通信实现Bootloader和App双分区后两个工程是独立编译的。它们之间需要约定好“合同”跳转地址Bootloader在完成升级或校验后需要跳转到App的入口地址即App中断向量表的第二个字复位向量。这个地址是APP_FLASH起始地址 4。通信协议Bootloader和App如何共享信息例如升级标志、App版本号等。通常会在两者之间预留一小块共享的Flash区域或使用参数存储区并定义严格的数据结构。重要这块共享区域必须放在两个工程的链接脚本中都明确排除不用于存放代码数据的位置并且双方访问的地址必须绝对一致。中断处理跳转到App前Bootloader需要禁用所有中断并设置好堆栈指针从App的向量表前两个字加载。4.3 调试与验证如何确认分区正确修改链接脚本后如何验证布局符合预期查看Map文件在链接器设置中生成.map文件。这个文件详细列出了每个段、每个符号的最终地址和大小。搜索你的自定义段名如.param_flash和符号名如__param_flash_start确认其地址是否在你规划的区域。分析生成的Hex/Bin文件使用arm-none-eabi-objdump或fromelf工具或者直接使用二进制查看器检查生成的文件。你会发现在App的bin文件中开头的地址不再是0x08000000而是0x08010000。同时参数存储区对应的数据应该位于文件末尾的某个位置。运行时检查在C代码中打印__param_flash_start等符号的地址看是否与预期相符。烧录与测试使用编程器或调试器将Bootloader和App的bin文件分别烧录到对应的Flash地址。然后上电运行测试Bootloader能否正常跳转到AppApp的中断能否正常响应参数存储区能否正常读写。4.4 常见链接错误与排查**regionAPP_FLASH overflowed by ... bytes**这是最直接的错误表示App的代码数据量超过了你在MEMORY中为APP_FLASH定义的长度。你需要优化代码或者调整分区大小。**undefined reference to__param_flash_start**在C代码中引用了链接脚本中定义的符号但链接脚本中拼写错误或者符号未被正确导出。确保链接脚本中使用的是PROVIDE或全局符号定义方式且C代码中extern声明匹配。程序运行异常HardFault首先检查VTOR设置是否正确。其次检查自定义分区是否破坏了.data段拷贝或.bss段清零的启动流程。启动代码中用于拷贝.data段的源地址LMA和长度计算可能依赖于链接脚本中的某些符号如_sidata,_sdata,_edata。自定义分区后需要确认这些符号的值仍然是正确的。最稳妥的方法是单步调试启动代码观察拷贝操作的内存地址。Flash编程失败检查自定义参数区的地址是否对齐到扇区边界。检查Flash解锁、擦除、编程的流程是否正确。确保在操作Flash前关闭了相关中断。5. 跨平台与工具链差异处理虽然原理相同但不同编译工具链的链接脚本语法各有不同IAR (.icf文件)语法更接近C语言。使用define region定义内存区域define block定义块initialize by copy等命令处理数据初始化。放置段使用place at或place in。define region APP_FLASH mem:[from 0x08010000 size 0xE0000]; define block PARAM_BLOCK { section .param_flash }; place in APP_FLASH { readonly }; place at end of APP_FLASH { block PARAM_BLOCK };ARM Compiler 6 (.scat文件)语法相对直观。使用LR_APP_FLASH 0x08010000 0xE0000定义加载区域ER_APP_FLASH 0x08010000 0xE0000定义执行区域。在执行区域内列出要放置的段。LR_APP_FLASH 0x08010000 0xE0000 { ER_APP_FLASH 0x08010000 0xE0000 { *.o (RESET, First) * (RO) } ;... 其他区域 }核心建议当你切换工具链时不要试图直接翻译链接脚本。而是先理解新工具链的官方文档和示例从默认脚本出发在其基础上进行修改。理解其“内存区域定义”和“段放置”的核心概念是如何表达的。6. 工程管理与自动化考量当项目变得复杂拥有多个目标Bootloader_Debug, Bootloader_Release, App_Debug, App_Release时手动管理多个链接脚本很容易出错。使用模板与预处理器可以编写一个基础的链接脚本模板.ld.in在其中使用预处理器宏如APP_OFFSETPARAM_START。在构建系统如CMake中通过configure_file命令根据不同的构建目标将宏替换成具体的数值生成最终的.ld文件。版本控制将链接脚本与源代码一同纳入版本控制。任何内存布局的变更都应被视为重要的修改需要评审和记录。文档化在项目Wiki或README中维护一份“内存映射图”清晰标注每个分区的地址、大小、用途、所属工程。这对于团队协作和后期维护至关重要。手动划分Flash空间起初可能会觉得繁琐但它是你深入理解嵌入式系统内存模型、掌握链接过程、实现复杂产品功能的必经之路。每一次对链接脚本的调整都是你对硬件资源的一次精确调度。当你看到自己规划的分区稳定运行Bootloader和App各司其职参数区数据在掉电后依然完好时这种对系统的掌控感正是嵌入式开发的乐趣所在。从今天起别再对那个神秘的.ld文件敬而远之打开它修改它让它为你的项目创造更多可能。
返回列表