
1. 项目概述为什么嵌入式开发需要手动分区Flash如果你在嵌入式开发中尤其是使用ARM Cortex-M这类资源受限的MCU时只满足于让IDE比如Keil、IAR或者STM32CubeIDE自动帮你生成代码和内存布局那么你很可能还没遇到过真正的“硬骨头”。当你的项目代码量膨胀需要同时容纳Bootloader、应用程序、配置文件、OTA升级包甚至是文件系统时你就会发现那个默认的、模糊的“Flash空间”根本不够用或者说用起来非常别扭。这时候“通过链接器脚本Linker Script手动分区Flash”就从一项高级技能变成了必须掌握的生存技能。简单来说链接器脚本就是告诉编译后的链接器“嘿我们的芯片Flash物理地址是从0x08000000开始的总共512KB。请把我们的代码、数据按照我画好的格子精确地放到指定的位置。” 这个“画格子”的过程就是分区Partitioning。它解决的远不止是“代码放哪里”的问题更是关乎系统架构的可靠性、可维护性和可扩展性。比如一个典型的IoT设备可能需要前16KB放不可篡改的Bootloader接着256KB放主应用程序A再预留256KB给未来OTA更新的应用程序B最后64KB用来存储设备参数和日志。没有精确的分区这些模块就会在内存里“打架”轻则功能异常重则设备“变砖”。因此掌握链接器脚本分区意味着你从“代码编写者”进阶为“系统架构师”能够真正掌控你的硬件资源设计出稳定、高效且易于升级的嵌入式固件。这不仅是技术能力的体现更是应对复杂项目需求的基石。2. 核心概念解析链接器脚本与存储器映射在深入实操之前我们必须把几个关键概念掰扯清楚。很多人觉得链接器脚本很神秘其实它就是一个带有特定语法的“布局描述文件”。2.1 链接器脚本到底是什么你可以把整个编译链接过程想象成建造一座城市你的固件。编译器Compiler负责生产标准的“建筑模块”.o目标文件比如居民楼代码、仓库初始化的数据、空地未初始化的数据。链接器Linker则是总规划师和施工队它的任务是把这些散落的模块搬运到一片叫做“存储器”的物理土地上并组装成可以运行的城市。链接器脚本.ld文件就是这张“城市规划图”。它明确规定了土地规划MEMORY命令这片土地有哪些区域比如Flash区只读存放代码和常量叫什么名字从哪里开始多大RAM区可读可写存放变量又是什么情况区域划分SECTIONS命令在每个规划好的区域里如何摆放不同的建筑模块例如.text段代码必须放在Flash区.data段已初始化的全局变量需要先在Flash存初始值上电后拷贝到RAM.bss段未初始化的全局变量只需要在RAM里预留出空地。没有这张图链接器就会使用内置的默认规则往往把所有东西都堆在起始地址附近这显然无法满足我们精细分区的需求。2.2 理解存储器的层次Flash vs RAM这是嵌入式分区的基础知识但至关重要Flash闪存非易失性存储器。断电后内容不丢失。通常用于存储程序代码.text只读数据.rodata如const常量、字符串字面量。需要初始值的全局/静态变量.data的初始镜像这些变量的初始值保存在Flash上电后由启动代码复制到RAM。自定义的数据区如参数区、日志区、文件系统。特点写入编程速度慢擦除按扇区Sector或页Page进行寿命有限擦写次数。RAM随机存取存储器易失性存储器。断电后数据丢失。用于程序运行时的堆栈Stack/Heap。全局/静态变量.data, .bss。函数调用时的局部变量。特点读写速度快可字节寻址。分区操作主要针对的是Flash因为它的内容在烧录后就相对固定我们需要提前规划好每一部分的用途和边界。RAM的布局虽然也能微调但通常更关注堆栈大小是否足够而不是精细分区。2.3 关键术语Section段、Symbol符号、Address地址Section段是链接器操作的基本单位。编译器会生成一系列标准的段如.text,.data,.bss,.rodata。我们也可以在代码中通过__attribute__((section(“自定义段名”)))将变量或函数放到自定义的段里。链接器脚本的任务就是安排这些段的存放位置。Symbol符号通常指变量或函数的地址。在链接器脚本中我们可以定义一些特殊的符号比如_estack表示栈顶地址_smy_data和_emy_data表示我们自定义数据区的起始和结束地址。这些符号可以在C代码中声明为extern变量来使用从而实现C代码“知道”某个分区在哪、有多大。Address地址绝对的物理地址或相对于某个内存区域的偏移地址。分区本质上就是定义各个段的起始和结束地址。注意不同的编译工具链GCC/ARMCC/IAR其链接器脚本语法虽有相似之处但细节差异很大。本文将以最常用的GNU Arm Embedded ToolchainGCC的LD语法为例进行讲解其原理通用于其他平台但具体语法需要查阅对应手册。3. 链接器脚本语法精要与分区策略设计现在我们打开一个典型的GCC链接器脚本比如STM32CubeIDE生成的STM32Fxxx_FLASH.ld它看起来可能很复杂但核心结构只有两块MEMORY和SECTIONS。3.1 MEMORY命令定义你的硬件资源地图MEMORY命令用于描述目标芯片上实际存在的、可供链接器使用的内存区域。这是分区的基础。MEMORY { /* 定义一块名为“FLASH”的区域属性为‘r’可读、‘x’可执行。 起始地址ORIGIN为0x08000000这是STM32 Flash的通常起始地址。 长度LENGTH为512K。 */ FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K /* 定义一块名为“RAM”的区域属性为‘r’可读、‘w’可写、‘x’可执行。 起始地址为0x20000000这是STM32 RAM的通常起始地址。 长度为128K。 */ RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }在这个基础上我们就可以进行分区了。比如我们要把512K的Flash分成Bootloader区、App区、参数区MEMORY { /* Bootloader区域占用起始的32K */ BOOTLOADER (rx) : ORIGIN 0x08000000, LENGTH 32K /* 主应用程序区域紧接Bootloader占用接下来的448K */ APPLICATION (rx): ORIGIN 0x08008000, LENGTH 448K /* 0x08000000 32K 0x08008000 */ /* 参数存储区域占用最后的32K */ PARAMS (r) : ORIGIN 0x08078000, LENGTH 32K /* 0x08000000 512K - 32K 0x08078000 */ RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }关键点地址计算必须精确后一个区域的ORIGIN必须是前一个区域的ORIGIN LENGTH。手动计算时务必小心使用计算器并转换为十六进制校验。地址重叠会导致链接错误。属性很重要(rx)表示可读、可执行代码区(r)表示只读参数区(xrw)表示可执行、可读、可写RAM。这关系到链接器是否允许将特定段放入该区域。3.2 SECTIONS命令布置你的“建筑模块”SECTIONS命令告诉链接器如何将输入的目标文件.o中的各个段Section输出并放置到我们定义的内存区域Memory Region中。一个基础的.text段放置示例SECTIONS { /* .isr_vector段非常重要它包含中断向量表必须放在Flash的起始位置。 对于STM32这就是0x08000000。我们把它放到BOOTLOADER区域的最前面。 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* KEEP确保即使该段未被引用也不会被优化掉 */ . ALIGN(4); } BOOTLOADER /* 指定输出到BOOTLOADER内存区域 */ /* .text段存放程序代码。我们将其放入APPLICATION区域 */ .text : { . ALIGN(4); *(.text) /* 所有输入文件的.text段 */ *(.text*) /* 所有类似.text.xxx的段 */ *(.glue_7) /* 一些工具链生成的辅助段 */ *(.glue_7t) *(.eh_frame) . ALIGN(4); /* 定义一个符号_etext标记.text段的结束地址可用于代码中 */ _etext .; } APPLICATION /* .rodata段存放只读数据也放在APPLICATION区域Flash */ .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } APPLICATION }对于.data和.bss段情况特殊一些因为它们最终需要位于RAM但.data的初始值需要从Flash加载。/* .data段在Flash中存放初始值LOADADDR(.data)在RAM中定义运行时位置ADDR(.data) */ .data : { . ALIGN(4); _sdata .; /* 在RAM中.data段的开始地址 */ *(.data) *(.data*) . ALIGN(4); _edata .; /* 在RAM中.data段的结束地址 */ } RAM AT FLASH /* RAM 表示VMA运行时地址在RAM AT FLASH 表示LMA加载地址在FLASH */ /* 在链接器脚本中记录.data段在Flash中的加载地址供启动代码拷贝使用 */ _sidata LOADADDR(.data); /* .bss段未初始化数据只需在RAM中预留空间启动代码会将其清零 */ .bss : { . ALIGN(4); _sbss .; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM3.3 实现自定义分区以参数存储区为例假设我们在MEMORY中定义了一个PARAMS区域现在想创建一个名为.params的段专门用来存放设备序列号、校准参数等。第一步在链接器脚本中定义段SECTIONS { /* ... 其他标准段 ... */ /* 自定义参数段放置在之前定义的PARAMS内存区域 */ .params : { . ALIGN(4); /* 4字节对齐这对Flash擦写和访问效率很重要 */ _sparams .; /* 定义参数区起始地址符号 */ KEEP(*(.params)) /* 收集所有被放入.params段的数据 */ . ALIGN(4); _eparams .; /* 定义参数区结束地址符号 */ } PARAMS }第二步在C代码中将变量放入该段/* 在C文件中定义一个结构体并指定其链接到“.params”段 */ typedef struct { uint32_t serialNumber; float calibrationFactor; uint8_t deviceTag[20]; } DeviceParams_t; /* 使用GCC的section属性 */ const DeviceParams_t myDeviceParams __attribute__((section(.params))) { .serialNumber 0x12345678, .calibrationFactor 1.05f, .deviceTag MyEmbeddedDevice, };第三步在C代码中访问分区边界可选但推荐/* 声明链接器脚本中定义的符号这些符号是地址不是变量本身 */ extern const void* _sparams; extern const void* _eparams; void print_params_info(void) { printf(Params section starts at: 0x%p\n, _sparams); printf(Params section ends at: 0x%p\n, _eparams); printf(Params section size: %lu bytes\n, (uint32_t)_eparams - (uint32_t)_sparams); }通过以上三步我们就成功地在Flash的特定位置PARAMS区域创建了一个专有的参数存储区。烧录后myDeviceParams变量的数据将精确地存放在0x08078000开始的地址上。即使主应用程序升级覆盖APPLICATION区域这个参数区的内容也可以被保留前提是Bootloader或App的擦写操作避开了该区域。4. 实战为BootloaderApp应用创建完整分区方案这是一个非常经典且实用的场景。我们设计一个方案32KB Bootloader 448KB Application 32KB Params。4.1 链接器脚本完整实例Application.ld这是主应用程序的链接器脚本。它需要知道Bootloader已经占用了前32KB所以自己的起始地址是0x08008000。/* 定义内存区域注意APPLICATION的起始地址已偏移 */ MEMORY { APPLICATION (rx) : ORIGIN 0x08008000, LENGTH 448K /* 512K - 32K - 32K */ PARAMS (r) : ORIGIN 0x08078000, LENGTH 32K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } /* 入口点通常为Reset_Handler */ ENTRY(Reset_Handler) /* 定义堆栈大小 */ _Min_Heap_Size 0x200; /* 512 bytes */ _Min_Stack_Size 0x400; /* 1 KB */ SECTIONS { /* 中断向量表。注意对于应用程序它的向量表必须放在APPLICATION区域的起始处。 但芯片上电后默认从0x08000000执行那里是Bootloader的向量表。 Bootloader会跳转到0x08008000App的Reset_Handler。因此App的向量表是“偏移后的”。 有些Bootloader设计会重映射向量表这里我们按偏移处理。 */ .isr_vector_app : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } APPLICATION /* 代码段 */ .text : { . ALIGN(4); *(.text) *(.text*) *(.glue_7) *(.glue_7t) *(.eh_frame) . ALIGN(4); _etext .; } APPLICATION /* 只读数据段 */ .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } APPLICATION /* ARM特定段 */ .ARM.extab : { *(.ARM.extab* .gnu.linkonce.armextab.*) } APPLICATION .ARM : { __exidx_start .; *(.ARM.exidx*) __exidx_end .; } APPLICATION /* .data段初始化的全局/静态变量 */ .data : { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM AT APPLICATION /* 运行时在RAM初始值在APPLICATION区域Flash中App的代码区后面 */ _sidata LOADADDR(.data); /* 初始值在Flash中的加载地址 */ /* .bss段未初始化的全局/静态变量 */ .bss : { . ALIGN(4); _sbss .; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM /* 用户堆栈段 */ ._user_heap_stack : { . ALIGN(8); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; . . _Min_Stack_Size; . ALIGN(8); } RAM /* 自定义参数区 */ .params : { . ALIGN(4); _sparams .; KEEP(*(.params)) . ALIGN(4); _eparams .; } PARAMS /* 移除调试信息减小体积 */ /DISCARD/ : { libc.a ( * ) libm.a ( * ) libgcc.a ( * ) } /* 检查应用程序代码是否超出了为其分配的区域 */ .assert_app_size : { . ALIGN(4); _app_end .; ASSERT(_app_end ORIGIN(APPLICATION) LENGTH(APPLICATION), ERROR: Application section overflowed into PARAMS region!) } }这个脚本的几个要点APPLICATION区域起始于0x08008000避开了Bootloader的0x08000000。App有自己的.isr_vector_app段。在双固件系统中通常只有Bootloader的中断向量表是有效的位于0x08000000App的中断需要先跳转到Bootloader再由Bootloader转发或重映射向量表。这里定义它主要是为了在App内部保持结构的完整性实际中断入口由Bootloader管理。.data段的AT APPLICATION表明其初始值存放在Application的Flash区域中而不是整个Flash的起始。最后的.assert_app_size是一个技巧。它定义了一个符号_app_end并使用ASSERT宏进行链接时检查。如果App的代码/数据总量超过了APPLICATION区域的大小链接会立即报错提示“溢出到PARAMS区域”这能有效防止固件覆盖参数区的悲剧。PARAMS区域被独立定义App的.params段被明确放置于此。4.2 Bootloader链接器脚本要点Bootloader.ldBootloader的链接器脚本更简单它认为自己独占整个Flash前32KB但它的设计必须“知道”App和Params的存在以便正确跳转和擦写。MEMORY { BOOTLOADER (rx) : ORIGIN 0x08000000, LENGTH 32K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } /* 必须知道App的入口地址这是一个硬编码的约定 */ APPLICATION_ENTRY 0x08008000; /* App的Reset_Handler地址 */ SECTIONS { /* Bootloader的中断向量表必须放在0x08000000 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } BOOTLOADER /* ... 其他.text, .data, .bss段定义均放入BOOTLOADER区域 ... */ /* 在Bootloader的C代码中可以通过extern引用APPLICATION_ENTRY来跳转 */ }Bootloader的主要任务初始化硬件、检查是否需要升级如通过串口接收新固件、如果不需要则跳转到APPLICATION_ENTRY地址即0x08008000执行App。如果需要升级则擦写APPLICATION区域的Flash将新固件写入。4.3 在IDE中配置与构建以STM32CubeIDE为例为Bootloader和App创建独立的工程。在每个工程的“Project Properties - C/C Build - Settings - Tool Settings - MCU GCC Linker - General”下指定各自的链接器脚本文件Bootloader.ld或Application.ld。在“MCU GCC Linker - Miscellaneous”中确保没有其他链接器脚本被额外添加。分别编译两个工程会生成两个独立的.elf和.bin或.hex文件。烧录时先烧录Bootloader.bin到0x08000000再烧录Application.bin到0x08008000。可以使用STM32CubeProgrammer的“Download to specific address”功能或者将两个bin文件合并后再烧录。实操心得在开发阶段我强烈建议使用J-Link或ST-Link的调试器配合IDE的“Run”或“Debug”功能直接加载App工程。此时调试器会通过Bootloader区域即使里面是空的或旧的Bootloader将App直接下载到0x08008000开始的位置并可能临时修改向量表偏移寄存器VTOR来让App的中断正确响应这非常方便调试。但在生产烧录或测试完整升级流程时一定要使用合并后的或分步烧录的完整镜像。5. 高级技巧与常见问题排查掌握了基础分区后一些高级技巧和“坑”能让你事半功倍。5.1 确保关键函数/变量不被优化链接器在链接时如果发现某个函数或变量从未被引用可能会将其优化掉-gc-sections选项。对于像中断向量表、自定义段中的初始化数据这可能是灾难性的。方法1在链接器脚本中使用KEEP()如上文示例KEEP(*(.isr_vector))。方法2在C代码中使用__attribute__((used))__attribute__((used)) const DeviceParams_t myDeviceParams __attribute__((section(.params))) {...};方法3在Makefile或IDE链接器选项中谨慎使用-gc-sections。虽然它可以显著减小体积但需要配合KEEP来保护关键段。5.2 处理分散加载与复杂数据初始化对于.data段已初始化全局变量链接器脚本只负责安排它们在Flash中的存储位置LMA和在RAM中的运行位置VMA。实际的拷贝工作是由启动文件startup_*.s中的汇编代码完成的。你需要检查并确保启动代码中用于拷贝的源地址_sidata和目标地址_sdata,_edata与你的链接器脚本中定义的符号一致。如果修改了链接器脚本中这些符号的名字必须同步修改启动文件。5.3 地址对齐ALIGN的重要性Flash擦写和RAM访问通常有对齐要求如4字节、8字节、甚至128字节对齐。. ALIGN(4);这条语句在链接器脚本中频繁出现它确保当前地址计数器是4的倍数。不对齐可能导致硬件异常或性能下降。对于自定义的参数区对齐到Flash的擦除扇区大小如STM32F4的4KB是很好的实践便于独立擦写。5.4 调试与验证如何确认分区是正确的查看生成的Map文件在链接器选项中添加-Wl,-Mapoutput.map编译后会生成一个.map文件。用文本编辑器打开搜索你的自定义段名如.params你可以看到它被分配的确切地址和大小。这是最权威的验证方式。使用objdump工具arm-none-eabi-objdump -h your_application.elf可以列出所有段Section的详细信息包括VMA运行时地址、LMA加载地址和大小。在代码中打印符号地址如前文所示通过extern声明链接器脚本中的符号并打印可以在运行时确认。通过调试器查看内存在IDE的调试模式下直接查看Memory窗口输入你为分区设定的地址如0x08078000看其中是否是你预设的数据。5.5 常见链接错误与解决方案**regionFLASH overflowed by ... bytes**这是最常见的错误表示分配的内容超过了MEMORY中定义的区域长度。**解决方案**优化代码体积或重新调整分区大小。务必使用前文提到的ASSERT进行主动检查。**undefined reference to_sdata‘**在启动文件中引用了链接器脚本中未定义的符号。**解决方案**检查链接器脚本中是否正确定义了_sdata,_edata,_sidata,_sbss,_ebss等符号并确保其拼写与启动文件完全一致。程序运行后变量值不正确很可能是.data段从Flash到RAM的拷贝过程出错。解决方案检查启动文件中的拷贝循环逻辑确认使用的源地址和目标地址符号是否正确用调试器对比Flash中初始值地址和RAM中变量地址的内容。跳转到App后HardFaultBootloader跳转到App后立即发生硬件错误。排查步骤检查App的链接器脚本中APPLICATION区域的起始地址是否正确。检查Bootloader跳转前是否禁用了自己的中断是否将堆栈指针MSP设置为了App向量表的第一个字即App的初始栈顶一个典型的跳转代码如下typedef void (*pFunction)(void); pFunction JumpToApplication; uint32_t JumpAddress; /* 1. 检查目标地址App起始地址是否有合法的栈指针通常检查是否在RAM范围内 */ if (((*(__IO uint32_t*)APPLICATION_ENTRY) 0x2FFE0000) 0x20000000) { /* 2. 禁用所有中断 */ __disable_irq(); /* 3. 设置App的堆栈指针 */ __set_MSP(*(__IO uint32_t*)APPLICATION_ENTRY); /* 4. 获取App的Reset_Handler地址向量表第二个字并跳转 */ JumpAddress *(__IO uint32_t*)(APPLICATION_ENTRY 4); JumpToApplication (pFunction)JumpAddress; JumpToApplication(); }检查App工程中是否将中断向量表正确偏移对于Cortex-M3/M4可以通过设置SCB-VTOR APPLICATION_ENTRY;在App的SystemInit或早期初始化中来实现。如果不设置App的中断会错误地跳转到Bootloader的向量表去。手动进行Flash分区是深入理解嵌入式系统内存布局的必经之路。它要求开发者对硬件地址空间、编译链接过程和启动流程有清晰的认识。虽然初次设置会有些繁琐但一旦完成你将获得对固件布局的完全掌控力能够设计出更健壮、更灵活的系统。从简单的参数分区到复杂的A/B双备份OTA其底层支撑都离不开链接器脚本的精确规划。多动手实践多查看生成的map文件遇到问题耐心从链接错误、启动流程、内存内容这几个方面排查你会逐渐发现这项技能带来的巨大便利和可靠性提升。