ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从复位到main():STM32链接脚本与启动文件全解析

从复位到main():STM32链接脚本与启动文件全解析 很多用 CubeMX 的兄弟点一下生成就能得到一套能跑的工程从来没想过一个问题复位之后CPU 是怎么一步步走到main()的又是谁告诉链接器把代码放 Flash、把全局变量放 RAM 的我自己也是在第一次做 IAP Bootloader、需要把 App 整体搬到偏移地址时才被链接脚本轮流教育。回头复盘真正卡人的不是main()里写了什么而是main()之前那段看不见的路——复位向量、启动文件、链接脚本这三者配合不好程序要么复位后直接跑飞要么全局变量全是乱的。这篇文章就以 STM32F411 为对象从复位到main()完整走一遍并把链接脚本从头到脚写清楚。这篇文章适合三类人看一是刚转 GCC 工具链、对.ld文件一头雾水的嵌入式开发二是想做 IAP、App 和 Bootloader 分区的朋友三是程序莫名 HardFault、怀疑跟启动和链接有关的调试党。读完你至少能回答三个问题为什么链接脚本里FLASH要从0x08000000开始.data段为什么要写成RAM AT FLASH复位之后启动文件又是依据哪几个符号把全局变量初始化好的。1. 为什么需要一份链接脚本它在整个工程里管什么1.1 从编译到链接链接脚本到底在干啥很多人把编译和链接混为一谈。真正的流程是源码先被编译器翻译成汇编、再变成机器码但这一步产出的目标文件里代码和数据的地址还是相对的、尚未定下来的。链接器干的活就是把这些分散的目标文件合并在一起按你指定的规则把每段内容放到芯片内存的具体位置上同时解决跨文件的符号引用。这时候链接脚本就是链接器的施工图。它至少回答三个问题这块芯片有哪些可用内存区域分别在什么地址、多大每类内容代码、只读常量、全局变量、堆栈放在哪个区域哪些符号要固定地址或者暴露给启动代码。没有链接脚本IDE 也会带一套默认的所以你觉得没写也能跑。可一旦要定制比如 App 起始地址偏移、想把某个大数组放到特殊 RAM、想严格控制栈大小默认脚本就完全不够用了。1.2 谁最需要改链接脚本我把实际操作中的需求分了几类你可以对照一下做 IAP 或 Bootloader芯片里要放两个甚至更多固件App 不能从0x08000000启动必须把FLASH区域的ORIGIN改成比如0x08010000。内存吃紧或者要抠性能把关键数据段放到 CCM RAM如果芯片有的话或者给中断向量表预留特殊空间。精确控制栈和堆默认的栈区大小不满足任务嵌套或malloc使用需要调整_Min_Stack_Size/_Min_Heap_Size。需要把某些数据固化在固定地址比如存储设备参数、产品序列号的扇区需要在链接脚本里开辟独立命名段。我见过不少新人在 App 里没改链接脚本只把主程序main()函数改了结果一上电就 HardFault原因就是代码还在0x08000000和 Bootloader 重叠了。这不是代码逻辑问题是链接脚本没跟上需求。1.3 工具链与启动文件的配合关系一块芯片上电后最终从哪里取第一条指令是由硬件决定的。而这份从哪取、取什么的信息是启动文件和链接脚本一起搭建出来的。在 GCC 工具链里启动文件是汇编写的startup_stm32f411xe.s链接脚本则是.ld后缀的文本文件。两者通过特定的符号名沟通启动文件引用_estack、Reset_Handler、__main或_start链接脚本负责定义或导出这些符号。下面两节我先把 STM32F411 的内存和启动流程讲清楚再带你写这份.ld文件。2. 从复位到 main() 的启动全景先搞清楚芯片怎么睁眼2.1 Cortex-M4 复位后第一件事读向量表STM32F411 内核是 Cortex-M4它的复位行为很有规律CPU 复位释放后硬件会自动从地址0x00000000读取初始栈指针MSP再从0x00000004读取复位向量也就是第一条指令的地址。注意这里的0x00000000不一定是真实的 Flash 地址它取决于 BOOT 引脚的配置。默认 BOOT0 拉低时这片地址区域映射到主 Flash也就是说实际数据存储在0x08000000但 CPU 也能通过别名访问到。这就是为什么链接脚本必须把向量表放在 Flash 的最开头。向量表本身就是一串 32 位字第 0 个字是栈顶地址第 1 个字是复位向量后面依次是各类异常和中断的处理函数入口。物理上它必须从启动地址开始并且每一项对应一个中断。如果这段没放对位置CPU 取到的栈顶和复位地址就是垃圾值程序必然跑飞。这也是我一直强调 KEEP 指令必须加在向量表段上的原因——具体在第三节展开。2.2 哪些复位源会把芯片拉回起跑线很多初学者看到复位两个字只想到复位按键实际上嵌入式的复位源很多。F411 常见的复位源至少包括上电复位电源电压低于阈值时内部 POR 电路强制复位。外部复位NRST 引脚拉低触发这个引脚内部有上拉外部再接一个电容到地可以抗干扰。看门狗复位独立看门狗或窗口看门狗超时产生复位。软件复位通过SCB-AIRCR写入VECTKEY和SYSRESETREQ触发。低功耗复位从停止/待机模式唤醒时的复位。不管哪类复位CPU 都会重新走一遍向量表读取流程。这也是为什么调试单步时最稳妥的做法是在Reset_Handler入口下断点而不是在main()下断点——可以清晰看到系统初始化前后的状态变化。我之前踩过一个坑程序里用了软件看门狗但喂狗时机设置得不合理板子表现就是反复复位、偶尔能跑起来、偶尔又死看起来像硬件问题实际是复位源没梳理清楚。2.3 Reset_Handler、SystemInit、__main 到底按什么顺序执行有了上面基础再来看 STM32F411 官方启动文件里Reset_Handler的典型结构简化版Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP它做的事很简单先把数据段、BSS 段相关的初始化准备工作交给 C 库但在此之前调用SystemInit去配置 Flash 等待周期、时钟树、必要的电源选项。SystemInit是 STM32 标准外设库或 HAL 库提供的函数不同芯片实现不同但目标一致让 CPU 在跳到高级语言代码之前先把基础时钟环境准备好。接着跳转__main。这里要提醒新手__main不是 C 语言的main()它是 C 运行库提供的入口函数负责运行环境初始化。那__main到底做了什么说白了三件事把已经烧录在 Flash 里的.data段初始值搬运到 RAM 对应位置把.bss段清零初始化堆栈和标准库环境最终才调用你的main()。这三件大事的地址信息全部来自链接脚本导出的几个下划线符号。如果你用纯 GCC 的 crt0 而不是 ST 官方启动文件入口可能叫_start但原理类似。我得强调本节说的这些名字不是玄学它们直接影响你写的链接脚本能不能被读懂。3. 手把手写链接脚本MEMORY 与 SECTIONS 详解3.1 第一步用 MEMORY 命令画出芯片内存地图链接脚本最常见的形式就是从上往下依次写ENTRY、MEMORY、SECTIONS。先看内存部分ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }ENTRY(Reset_Handler)是告诉链接器程序的入口点是Reset_Handler这个符号。如果不写GNU ld 默认找_start但裸机工程里不一定有定义链接会告警。实际项目中显式写出来最省心。MEMORY定义了两块区域。FLASH的可读可执行属性rx很好理解——代码从这里读取执行。ORIGIN是起始地址STM32F411 的主 Flash 从0x08000000开始512KB 到0x08080000结束。RAM从0x20000000开始属性xrw表示可读可写可执行理论上你可以让代码在 RAM 里跑但实际很少这么干。关于 RAM 大小这里要留意F411 的 128KB SRAM 有些资料会拆成常规 SRAM 和 CCM RAM 两块CCM 在另一个地址域。你在参考手册上看到的是整体 SRAM 大小但链接脚本里写入的LENGTH必须对应你选的芯片并和编译器放置需求匹配。拿到不熟悉的型号第一件事就是核对参考手册内存映射表别盲信模板。对 F411 这类常规工程CubeMX 默认给的值多数时候够用但你想把全部 RAM 都用上就可能需要为 CCM 单独划一个MEMORY区域。3.2 第二步用 SECTIONS 把段内容放到正确区域这是整个链接脚本的核心也是刚开始最容易看晕的部分。我用一份精简但独立的脚本来说明SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text) *(.text*) . ALIGN(4); } FLASH .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } FLASH .data : { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH .bss : { . ALIGN(4); _sbss .; __bss_start__ _sbss; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; __bss_end__ _ebss; } RAM }逐个看。.isr_vector是向量表放在 Flash 最前面。它必须被KEEP()包住因为链接器的垃圾回收机制--gc-sections默认会移除没有被引用到的段而向量表通常没有任何代码显式引用它不 KEEP 的话可能被整个丢掉。我实际就遇到过一次某个工程为了减小体积开了垃圾回收结果复位后直接跑飞查了半天才发现向量表被链接器收走了那段经历非常酸爽。.text放编译出来的代码*(.text*)这种写法能把所有目标文件里的.text变体都归进来兼容不同编译选项生成的段名。.rodata放const修饰的只读数据比如字符串字面量、查表常量它们没必要复制到 RAM直接从 Flash 读取就好这也是裸机优化的基础能放.rodata的别放.data。3.3 第三步导出符号让启动文件能找到交接点链接脚本不只是排版工具它还负责定义很多下划线符号。启动文件里搬运.data、清零.bss时并不知道 RAM 具体地址全靠这些符号。.data段的写法是最讲究的。段内容声明在RAM AT FLASH意思是运行地址在 RAM但加载地址在 Flash。也就是说程序烧录后.data的初始值是躺在 Flash 里的等__main启动后要把它复制到 RAM。复制需要三个地址源地址、目标地址、结束地址。脚本里_sdata和_edata分别表示 RAM 中的目标地址_sidata表示 Flash 里的源地址。.data : { . ALIGN(4); _sdata .; ... _edata .; } RAM AT FLASH _sidata LOADADDR(.data);LOADADDR(.data)返回的是.data段的加载地址也就是它在 Flash 中的位置。启动代码看到_sidata、_sdata、_edata后就知道执行一次memcpy把数据从 Flash 搬到 RAM。.bss段没有初始值不需要从 Flash 拷贝只导出_sbss和_ebss启动代码据此清零。很多教程讲到这里都一笔带过但我建议你记住一个判断标准看到AT就是运行时在右、程序里在左、下载到左这个关系理解了这个链接脚本就通了八成。4. 从 startup 汇编到 main()链接脚本与启动代码的深层协作4.1 栈顶地址是怎么传给CPU 的一个很容易忽略但极其关键的点Cortex-M 系列没有由代码设置初始栈指针的过程栈顶值是 CPU 复位后自动从向量表第一个字读出来的。而第一个字的来源正是链接脚本导出的符号。常见做法_estack ORIGIN(RAM) LENGTH(RAM);这行定义放在MEMORY后面、SECTIONS前面。_estack被启动文件引用为向量表的首项。由于 RAM 地址从低到高增长栈是递减生长方向所以栈顶放在 RAM 最高地址是合理的出栈时地址向下递减就不会踩到已分配的变量区域。注意这要求你规划的堆和栈不要和.data、.bss重叠第四节会讲怎么在链接脚本里为它们预留空间。如果做多段重定位或者 RTOS 工程可能还会在启动代码里手动设置MRS和MSR操作 PSP但那属于更高级的话题。至少的标准工程里_estack的赋值方式和向量表里的DCD _estack就是链接脚本 启动汇编的第一个交接点。4.2 __main 的初始化动作与链接脚本的约定这里展开讲__main的工作因为它每个步骤都在找链接脚本导出的符号设置堆栈__main会读取启动时从向量表拿到的栈顶或重新初始化用户栈空间栈大小对应脚本里_Min_Stack_Size。搬移 RW 数据计算_sidata到_edata的源搬到_sdata起的目标区域。清零 ZI 数据从_sbss扫到_ebss全部写 0。调用库初始化比如__libc_init_arrayC 的全局构造函数就在这里被调用。跳转main()。所以如果你的链接脚本把_sdata和_edata导出得不对或者忘了给.bss段导出边界符号程序的表现会非常诡异——全局变量初始值是乱的、const数组内容读出来不对、main()里第一次操作静态变量直接 HardFault。这些都是链接脚本没有配合好启动文件的典型症状不是代码逻辑问题。4.3 堆和栈在链接脚本里圈地的正确姿势.bss段完之后通常还有一个特殊段._user_heap_stack : { . ALIGN(8); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; . . _Min_Stack_Size; . ALIGN(8); } RAM这是工程里常见的预留堆栈段。它本身不产生实际数据只是用符号把内存边界划出来。_Min_Heap_Size和_Min_Stack_Size是你在脚本顶部定义的值常见0x200和0x400单位是字节。PROVIDE ( end . )表示如果程序里没有定义end链接器就用这个值如果定义了以程序里的为准。这对_sbrk等 C 库函数找堆起点很有用。堆和栈的空间来自同一个预留段实际运行时它们相向生长堆从低地址往高地址栈从高地址往低地址。若两边都膨胀最终会撞上。裸机工程里我建议把_Min_Heap_Size设小一点除非你明确知道哪些库函数用了malloc和free。因为 newlib 的printf浮点支持、strtok这类函数在某些配置下会偷偷申请堆内存。我吃过亏堆设小了printf格式化数字直接异常栈设小了中断嵌套一深就 HardFault。4.4 一个简化版启动文件片段把流程串起来为了让协作关系更直观看一个去掉所有外设中断后的启动文件核心片段AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD _estack DCD Reset_Handler __Vectors_End __Vectors_Size EQU __Vectors_End - __Vectors这里DCD _estack就是把_estack符号的值作为第一个字写进向量表。链接完成后0x08000000处是栈顶0x08000004处是Reset_Handler的地址。CPU 一上电就按这个流程走所以启动文件和链接脚本任何一个环节错位都会表现在从复位到 main的路上。5. 实际项目里最常踩的坑常见问题与排查实录5.1 复位后直接 HardFault八成是向量表和启动入口问题表现烧录后程序能下载但一运行就进 HardFault或者看起来在空转。排查顺序用调试器看 PC 值卡在哪里。如果 PC 停在0x08000000附近且反汇编是乱码往往向量表没生效。确认isr_vector段是否被 KEEP。开了--gc-sections时特别容易出问题。确认_estack是否指向有效 RAM。如果把LENGTH写太大栈顶地址超出芯片实际 RAM 范围一压栈就总线错误。确认向量表首字和Reset_Handler地址是否匹配。用arm-none-eabi-objdump -s -j .isr_vector firmware.elf看内存内容。另外如果代码里用到浮点运算但启动文件没有使能 FPU也会在进入 float 指令时 HardFault。F411 自带 FPU但默认复位后 CPACR 可能没打开纯汇编启动文件里需要手动设置寄存器。链接脚本本身不管这件事但排查时要想到这一层。5.2 region overflowFlash 或 RAM 不够时怎么定位初学者看到region FLASH overflowed by xxx bytes往往懵。这类报错说明你放不下所有段。要定位是谁占了空间最直接的办法是看.map文件。链接完成后工程目录下会有 map 文件包含每个段的起始地址、结束地址和大小。按size从大到小排序通常能找到大头。处理手段有几个把编译器优化等级从-O0提到-Os体积立竿见影把大量只读表从.data移到.rodata避免它们在 RAM 里占两份空间如果 RAM 满了看看是否能启用 CCM 区域但要确认外设是否支持访问它。F411 的 CCM RAM 不能直接被 DMA 访问这是个容易踩的坑把 DMA 缓冲区放 CCM 后 DMA 就是不动。5.3 修改链接脚本做 IAP 偏移别忘了 VTOR做 Bootloader App 时链接脚本第一行就要改FLASH (rx) : ORIGIN 0x08008000, LENGTH 448K但只改链接脚本远远不够。App 中断向量表仍会被放在新的起始地址可是 CPU 复位后默认从0x08000000找向量表那是 Bootloader 的地方。所以 App 里必须在main()最早处设置向量表偏移SCB-VTOR 0x08008000;如果不设App 里任何中断触发都会去旧位置找向量结果一片混乱。更隐蔽的是 Flash 扇区对齐问题F411 的 Flash 扇区大小不一前几个扇区是 16KB。如果你把 App 放在 16KB 扇区的中间而不是起始边界擦除旧空间时可能把 App 自身擦掉。所以我做这类分区时偏移量一般取扇形边界的整倍数并且至少对齐 16KB。5.4 用 size、objdump 和 readelf 检查最终产物链接脚本写得对不对最终看的不是能编译过而是产物结构对不对。我常用的检查命令arm-none-eabi-size build/firmware.elf arm-none-eabi-objdump -h build/firmware.elf arm-none-eabi-readelf -l build/firmware.elfsize输出 text/data/bss 三个数字快速判断内存占用。objdump -h能看到每个段名字、LMA、VMA、大小检查.data的 LMA 在 Flash、VMA 在 RAM就是验证AT FLASH写没写对的最直观方法。readelf -l能看到加载视图确认哪些段真正需要烧录。我每次改动链接脚本后都会跑一遍objdump -h花十秒钟省掉调半天 bug 的时间。5.5 一个特殊问题全局变量初始化看起来丢了症状上电后某个全局变量应该是非零初值但实际是 0改成局部变量或常量又好了。排查方向要回到.data段的三个符号上。如果_sidata和_sdata被别的代码或链接脚本覆盖或者你手动实现了自己的初始化函数却没有正确使用这些符号__main拷贝时源地址就会出错。还有一种情况你在链接脚本里新加了一个自定义段比如放产品序列号的.serial但它没有定义_sdata之类的边界后续的段起始地址就错位了。这种问题最容易发生在手工魔改链接脚本之后我建议每次改完都重新生成 map 文件核对自定义段的 LMA/VMA 和相邻段不要凭感觉。写在最后的经验链接脚本这个东西说玄也玄说不玄也不玄。它本质上就是一张内存施工图告诉链接器芯片有什么资源、每段内容放哪里、把哪些专用符号导出来给启动代码用。只要抓住向量表必须在 Flash 头、.data有运行地址和加载地址两套、.bss要清零、栈顶来自_estack这几个核心点大部分问题都能迎刃而解。我个人实际操作的体会是不要怕手工去写.ld更不要全盘照抄别人的脚本。学习阶段最有效的办法是打开 STM32CubeMX 生成的链接脚本对照参考手册一行行读过去每个和AT都问一句为什么要这么写然后故意改错几个地方看链接器报什么错、烧录后表现什么现象。踩过几次坑之后你对从复位到 main()这条路的理解会比背一百遍启动流程都深。
返回列表