ARTICLE DETAIL

资讯详情

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

STM32内存布局实战:Keil MDK .sct文件与堆栈精确定位指南

STM32内存布局实战:Keil MDK .sct文件与堆栈精确定位指南 搞嵌入式这些年如果说有什么问题能让我从“一脸淡定”瞬间变成“眉头紧锁”那一定是程序跑着跑着突然进了 HardFault或者 malloc 返回了一个 NULL 指针。大部分情况下罪魁祸首不是业务逻辑而是最不起眼的内存布局——具体来说就是 Keil MDK 中 .sct 文件分散加载文件和 STM32 堆栈内存区域的配置。这篇文章我就把 .sct 文件的原理、默认堆栈从哪来、怎么手动把栈和堆精确放在指定 RAM 地址以及各种常见坑一次讲明白。无论你是刚接触 STM32 的新手还是被栈溢出折磨过几次的老手这份实战经验都能直接抄作业。1. 先从两个“幕后黑手”说起Stack、Heap 与 .sct 的关系1.1 启动文件里的三块地皮很多人第一次接触 STM32 工程时都见过startup_stm32f103xe.s这种汇编文件但很少有人在里面逐行读。其实关于堆栈的“第一手信息”就藏在这个文件开头Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_spStack_Size后面跟着的0x00000400就是栈大小也就是 1KB。往下几行还能看到Heap_Size默认通常是0x00000200也就是 512B 的堆。这几行汇编干的事情是在内存里划出两块区域一块叫STACK一块叫HEAP。两个段都标记了NOINIT意思是上电后不用清零这个细节后面会展开讲。ALIGN3表示按 8 字节对齐这是 ARM 架构对栈指针的基本要求尤其当你用到浮点运算或者某些双字访问指令时栈顶不对齐很可能会触发异常。而__initial_sp这个标号就是传说中的“初始栈指针”。它在复位向量表里被放到第一个位置芯片上电后硬件会自动读取这个值赋给 SP 寄存器。也就是说栈顶在哪完全由__initial_sp决定。所以你看堆栈的“第一手配置”其实和 .sct 没有直接关系它是由启动文件定义的两个段决定的。真正的问题在于这两个段最后被放到了内存的什么位置以及怎么去干预这个位置。1.2 .sct 文件其实是“地契”用一句话解释 .sct 文件它是 ARM 链接器armlink / armclang用来决定“代码段、数据段、堆栈段最终分布在哪块地址”的脚本。你可以在 Keil MDK 里点开它默认长这样LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *(RO) } RW_IRAM1 0x20000000 0x00010000 { *(RW ZI) } }理解这个文件你只需要把握两组概念。第一组装载区Load Region和执行区Execution Region。LR_IROM1是装载区描述程序烧录在 Flash 里的原始布局ER_IROM1和RW_IRAM1是执行区描述程序运行时在内存中的实际布局。一般来说只读代码在 Flash 里原地执行所以ER_IROM1和LR_IROM1地址重合而可读写的变量、零初始化变量必须跑到 RAM 里执行所以单独有一个RW_IRAM1区。第二组段选择器。*(RO)表示“所有只读段”*(RW ZI)表示“所有可读可写段和零初始化段”。链接器在链接时会把启动文件里的STACK、HEAP段也当作普通的RW段或者ZI处理然后被*(RW ZI)这个通配符自动“抓”进RW_IRAM1区域。这就解释了很多人的疑惑为什么默认的 .sct 里找不到 Stack 和 Heap 的踪迹但程序能正常运行因为它们作为普通数据段被自动分配到 RAM 区域了并没有单独显式指定。栈顶地址由链接器根据所有 RW/ZI 段的布局自动排布通常是 RAM 末尾附近但不保证一定在末尾。1.3 默认布局的隐患既然启动文件里的STACK段是跟着 RW/ZI 一起被自动分配的那就会带来两个很直接的隐患。第一如果你的工程里定义了很多大数组、大缓冲区链接器会把它们和栈安排在一起栈顶位置会往前移动栈的可用空间可能被压缩。你写的Stack_Size是 1KB但栈底和上面的数据区一旦挨得太近一个稍微深一点的函数调用链就能把数据区踩穿或者反过来数组越界把栈干掉。这类问题最难查因为出错的位置往往离“案发现场”十万八千里。第二栈的位置每次编译可能都不同。只要有一个变量的定义顺序变化、新增一个 static 数组栈顶地址就会变。这在你做 Bootloader 跳转 App、App 和 Bootloader 共享一块内存做参数传递、或者用 MPU 做栈溢出保护的时候都是很麻烦的事。所以说理解 .sct 文件不仅是为了“让程序能跑”更多时候是为了“知道程序到底跑在什么位置”从而让内存分配变得可控。2. 先掌握最常用的调法直接改 Stack_Size / Heap_Size2.1 改哪两个宏怎么改如果你的项目需求只是“默认栈不够用想扩大一点”那 90% 的场景根本不需要动 .sct直接在启动文件里改两个宏就够了。Stack_Size EQU 0x00001000 ; 改为 4KB Heap_Size EQU 0x00000800 ; 改为 2KB改完之后编译器会自动把STACK段和HEAP段的体积变大链接器会在 RAM 里给它们腾出相应空间。这个过程没有额外的魔法本质上就是改了SPACE指令分配的内存大小。需要提醒一下Stack_Size和Heap_Size最好保持 8 的倍数尤其栈大小不要写成一个奇怪的数字比如0x00000401那会导致栈顶地址不对齐后面跑浮点或者使用某些指令时容易触发 UsageFault。另外一个容易掉坑的地方是启动文件里如果同时定义了__heap_base和__heap_limit这两个标号在 HEAP 段首尾它们会被 C 标准库的malloc用到。你只改了Heap_Size这两个标号也会跟着移动这是自动完成的不需要手动干预。但如果你的 .sct 或者启动文件里手动指定过堆的位置那就要反复确认这两者的地址是否对得上。2.2 改完如何验证改完不是编译过就完事了一定要打开生成的 .map 文件看一眼。用 Keil 打开工程后编译一次会在 Output 目录下生成一个.map后缀的文件。搜索关键词__initial_sp你会看到类似这样的内容__initial_sp 0x2000ff00 Data 4 startup_stm32f103xe.o(STACK)这行信息明确告诉你栈顶被放在了0x2000ff00。如果你的芯片 RAM 起始地址是0x20000000大小是 64KB结尾就是0x20010000那说明栈顶离 RAM 末尾还有一小段距离可能有其他变量占用了最后 256B。这种情况不算错但你要知道它存在。更推荐的做法是在调试模式下打开 Keil 的 Registers 窗口复位后看 SP 寄存器的值。SP 的值必须落在合法的 RAM 地址范围内而且必须是 4 字节对齐更严格是 8 字节对齐。如果 SP 显示0x00000000或者指向了 Flash 地址那基本可以断定复位向量表或者__initial_sp出了问题。2.3 为什么我劝你别在 Target 对话框里改 StackKeil MDK 的 Options for Target 对话框里有一个 Target 页面上面确实有 Stack 和 Heap 两个输入框。很多新手会在这里把 Stack 改成0x1000然后编译发现程序还是照样跑但看 map 文件栈大小没变就一脸懵。这里要说明白这个页面里的 Stack 和 Heap 数值在标准 STM32 启动文件 ARMCC 编译器的组合下对最终生成的固件镜像基本不起作用。它的主要用途是给调试器启动模拟环境时提供一个初始的堆栈视图属于“仿真层面”的设置不参与链接脚本的实际排布。真正决定栈大小的永远是你启动文件里Stack_Size这个宏以及 .sct 中划分给它的执行区大小。记住这一点能帮你少走很多弯路。3. 手动修改 .sct将堆栈精确定位到指定 RAM 地址3.1 明确需求什么时候需要动 .sct既然改宏那么简单为什么要去折腾 .sct根据我的经验以下场景你必须手动改分散加载文件你需要把栈固定放在 RAM 的末尾。栈是向下生长的放在 RAM 最末尾最安全不容易和后面的堆或数据区打架。你要在 Bootloader 和 App 之间共享一块固定地址的内存比如 OTA 升级时传递标志、固件信息栈的位置就不能随意浮动。你想用外部 SRAM 扩展内存把大堆放到外部 RAM但栈必须留在内部 RAM 里因为复位后外部内存控制器还没初始化CPU 没法在启动阶段用外部 RAM 做栈。你想用 MPU 把栈区域保护起来一旦越界立刻触发异常要求栈的地址范围绝对固定。在这些场景下单纯的“改宏”就无能为力了必须手动编辑 .sct。3.2 一个可直接抄的配置实例下面我以 STM32F103ZET6 为例它拥有 512KB Flash0x08000000开头和 64KB SRAM0x20000000开头大小0x10000。现在需求是把 4KB 栈固定放到 RAM 末尾也就是从0x2000F000开始把 4KB 堆放到栈前面也就是从0x2000E000开始剩下的区域留给普通变量。我建议的 .sct 写法如下LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *(RO) } RW_IRAM1 0x20000000 0x0000E000 { *(RW ZI) } MY_HEAP_REGION 0x2000E000 UNINIT 0x00001000 { startup_stm32f103xe.o(MY_HEAP) } MY_STACK_REGION 0x2000F000 UNINIT 0x00001000 { startup_stm32f103xe.o(MY_STACK) } }注意几个关键点。第一RW_IRAM1区域的大小从原来的0x00010000减小到了0x0000E000也就是 56KB因为末尾 8KB 已经被堆和栈预定了。如果你不缩小链接器可能把普通变量排到堆栈区域后面造成重叠。第二MY_HEAP_REGION和MY_STACK_REGION是我自定义的执行区名字后面跟着的UNINIT表示这块区域不需要在 C 启动阶段做清零。栈本身就该是不清零的你在栈上定义的局部变量本来就该是随机值清零反而多此一举。堆区域清零通常也没有意义malloc 会自己管理。第三每个执行区后面用花括号包着输入段选择器。startup_stm32f103xe.o(MY_HEAP)的意思是只把startup_stm32f103xe.o这个目标文件里名为MY_HEAP的段放到这个区域。这里要注意文件名的后缀必须是.o不是你源码里的.s。3.3 启动文件配合修改既然 .sct 里引用了MY_HEAP和MY_STACK这两个段名启动文件里的段名也得跟着改否则链接器一个符号都抓不到会直接报错。我建议启动文件里这样写Stack_Size EQU 0x00001000 Heap_Size EQU 0x00001000 AREA MY_STACK, NOINIT, READWRITE, ALIGN3 MY_Stack_Mem SPACE Stack_Size __initial_sp AREA MY_HEAP, NOINIT, READWRITE, ALIGN3 __heap_base MY_Heap_Mem SPACE Heap_Size __heap_limit PRESERVE8 THUMB注意原来的AREA STACK变成了AREA MY_STACKAREA HEAP变成了AREA MY_HEAP。__initial_sp、__heap_base、__heap_limit这些重要标号的声明位置保持不变。如果你需要其他文件引用__initial_sp有些库会用到记得在文件开头用EXPORT __initial_sp导出。编译前还要确认一件事工程选项里 Keep。如果启动文件被编译器优化掉了一般不会但小心为上检查 Options for Target - Linker - Misc controls不要乱加--remove之类把未引用段删掉的选项。启动文件里的段通常会被复位向量引用不会丢但如果你自己删过启动文件的部分内容要特别留意。3.4 用 map 文件检查分配结果编译完成后打开 .map 文件找到 Execution Region 相关的部分你会看到类似这样的输出Execution Region MY_STACK_REGION (Base: 0x2000f000, Size: 0x00001000, Max: 0x00001000, ABSOLUTE, UNINIT)Base 是0x2000f000Size 是0x00001000说明栈确实被放在了 RAM 末尾。再搜索__initial_sp__initial_sp 0x20010000 Data 4 startup_stm32f103xe.o(MY_STACK)栈顶是0x20010000正好是 64KB SRAM 的结尾地址。这个布局非常经典栈底在0x2000f000栈顶在0x20010000C 函数调用通过满递减栈向下生长初始 SP 指向最顶端第一个 push 操作会写到0x2000fffc不会越过 RAM 边界。到这一步你的堆栈就已经被“钉死”在指定地址了无论后续工程里怎么增删变量只要 RAM 整体不超限栈的位置都不会漂移。4. 进阶应用外部 RAM、CCM RAM 与中断栈4.1 把 Heap 丢到外部 SRAMStack 留在内部很多大项目会外挂一片 SRAM比如用 FSMC 接口挂一个 1MB 的外部 RAM地址范围通常在0x60000000之后。外部 RAM 空间大适合放 big buffer、GUI 显存、网络协议栈的收发缓冲区但不适合放 C 启动阶段的栈。原因在于复位向量执行、__main初始化 ZI 段这些操作都在 FSMC 外设初始化之前。如果你把栈放在外部 RAM芯片一上电 CPU 跑去外部 RAM 取栈上的数据而 FSMC 根本没使能那就直接 HardFault连调试器都可能连不上。所以稳妥的方案是栈留在内部 RAM堆可以放到外部 RAM。因为malloc通常在进入 main 函数之后、用户代码初始化外设之后才会被调用那时候外部 RAM 已经可用了。.sct 可以这样写LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *(RO) } RW_IRAM1 0x20000000 0x0000E000 { *(RW ZI) } MY_STACK_REGION 0x2000F000 UNINIT 0x00001000 { startup_stm32f103xe.o(MY_STACK) } EXTERNAL_HEAP_REGION 0x68000000 UNINIT 0x00100000 { startup_stm32f103xe.o(EX_HEAP) } }启动文件里把AREA MY_HEAP改成AREA EX_HEAP就行。为了让malloc正确使用这块外部堆__heap_base和__heap_limit的地址必须落在这个区域内。记得在进入 main 后先初始化 FSMC 相关的 GPIO、时序参数再首次调用 malloc否则堆访问的地址虽然映射到了总线但外部器件还没准备好读出来全是垃圾。4.2 CCM RAM 特性和栈放置权衡STM32F4 系列一部分芯片带 CCM RAM比如 STM32F407 有 64KB CCM地址在0x10000000。这块 RAM 的特点是CPU 可以直接访问速度快但不在系统总线上DMA 控制器完全摸不到它。如果你有对性能敏感的关键数据比如中断里频繁读写的标志位、音频处理中间缓冲区可以考虑放到 CCM RAM 里。但把栈放到 CCM RAM 要特别谨慎因为如果两个不同总线的外设或者 DMA 操作涉及栈空间一旦地址落在 CCM 区域DMA 写进来就直接失败或者写进错误的地方。我的建议是默认不要把主栈MSP放到 CCM RAM。原因是 CCM 不参与 DMA而很多 STM32 外设在启动时或者运行中可能触发 DMA 传输如果某个临时缓冲区在栈上且又传给了 DMA而这个栈偏偏在 CCM 里轻则数据错误重则 DMA 写入非法地址。除非你非常清楚整个工程里不会有 DMA 触碰 CCM 地址否则没必要冒这个险。如果你确实想把 CCM 用起来可以在 .sct 里单独划一个执行区专门放一些你主动指定的大数组CCM_RAM 0x10000000 UNINIT 0x00010000 { *(.ccm_data) }代码里用__attribute__((section(.ccm_data)))修饰变量例如uint8_t audio_buffer[4096] __attribute__((section(.ccm_data)));这样既能享受 CCM 的高速访问又不会让启动阶段依赖这块特殊内存。4.3 RTOS 工程里主栈与任务栈的关系跑了 RTOS 之后很多人会把启动文件里的Stack_Size改得很大以为这样任务栈就够了。其实这里有个误解RTOS 里的任务栈通常是通过xTaskCreate动态创建或静态数组分配给各个任务的任务切换时使用的是 PSP进程栈指针而不是启动文件配置的 MSP主栈指针。主栈MSP在这类工程里只承担三件事复位阶段的 C 启动代码、中断服务程序、RTOS 调度器内部的一些临界段操作。所以主栈大小不需要很大一般根据中断嵌套深度来定如果中断嵌套不深、每个 ISR 用的局部变量不多1KB 到 2KB 就够如果中断里调用 printf、浮点运算、或者有比较深的函数调用建议 4KB 起步。手动 .sct 在这种场景下的价值在于你可以把主栈固定在一个已知地址然后在 MPU 配置里给这块区域设置访问权限栈溢出时立刻触发 MemManage Fault。这在功能安全相关的项目里非常实用。5. 常见问题排查与避坑速查5.1 改完 .sct 编译报错 L6221E / L6220E最常见的错误之一是L6221E: Execution region MY_STACK_REGION size (0x00001000) exceeds limit (0x00000000).这种报错的第一反应不是去删代码而是去看 .map 文件确认最终布局。往往原因是你给RW_IRAM1划的空间太大把 RAM 末尾的地址也包含进去了再往后定义MY_STACK_REGION时栈区域的起点已经超过了 RAM 末尾或者和前一个区域重叠。解决办法把RW_IRAM1的 size 改小确保它结束地址严格小于堆区域的起始地址。你可以用十六进制算一下RAM 起点0x20000000大小0x0000E000结束地址是0x2000E000正好是堆的开始。这样三者无缝衔接不重叠不遗漏。还有一种是L6220E: Execution region MY_STACK_REGION has no execution attributes.这种多是 UNINIT 和 EMPTY 关键字写错位置。散列文件语法里UNINIT要跟在执行区地址和大小后面而且如果你的区域本身是空的里面没有任何输入段要写成EMPTY。我的写法里直接把启动文件的段抓进去了所以用UNINIT合适。5.2 运行莫名 HardFault怎么确认是不是栈溢出HardFault 的原因千千万栈溢出只是其中一种但绝对是最值得优先排查的一种。手头没有硬件调试器时你可以在栈空间里预先填充一个固定模式比如把所有栈内存填成0xCC 0xCC 0xCC 0xCC。在启动阶段或者 main 最开始把MY_STACK_REGION对应的地址范围用 memset 填满 0xCC。程序跑一段时间后从栈底往上扫描看最后一个不是 0xCC 的位置在哪里就能估算出栈实际用到多深。这个办法非常土但非常有效。我遇到过不少项目跑几天才复位一次用这个办法一测发现栈最深已经顶到栈底了硬生生把并发任务量降下来才稳定。如果你手头有 J-Link 或者 ST-Link用 Keil 调试时打开 Memory 窗口输入栈区域的地址也能直接看到栈里残留的 0xCC 被覆盖到了哪个位置。栈越接近栈底越危险。5.3 sct 改了好像没生效 / 被自动覆盖这个问题出现的频率极高。你在 Keil 里手动编辑了 .sct但编译后布局还是老样子原因几乎都是 Options for Target - Linker 页面里勾选了Use Memory Layout from Target Dialog。一旦这个勾是打上的Keil 会根据 Target 页面里的 IROM/IRAM 地址自动生成散列文件你手动改的 .sct 内容根本不会进入链接命令。正确操作是取消勾选然后通过Scatter File一栏的 Edit 按钮打开你自己保存的 .sct 文件。还有一个小细节当你取消自动生成后如果改变了 Target 页面里的 RAM 地址或者大小务必同步修改 .sct否则链接器还是按旧地址排布程序启动直接飞到不存在的 RAM 上表现就是 Keil 调试时 PC 指针乱跳或者上电就 HardFault。5.4 关于对齐、UNINIT 和 ZI 清零的细节启动文件中的ALIGN3意味着段起始地址按 8 字节对齐。.sct 里如果自定义执行区的起始地址没有按 8 字节对齐链接器会产生警告甚至在后续使用某些需要严格对齐的指令时出错。所以我在上面的配置里堆栈起始地址选的都是0x2000E000、0x2000F000这种天然 8 字节对齐的地址。UNINIT关键字的作用是跳过 C 启动阶段的 ZI 清零。如果你的堆栈区域没有加UNINIT链接器会在启动时把这块区域全部清零。栈清零本身问题不大但浪费了启动时间更关键的是如果这块区域里存了需要在软件复位后保持的数据清零就把它抹掉了。堆栈区域加UNINIT是行业惯例不用纠结。最后再分享一个小技巧。手动配置 .sct 时建议在工程里单独建一个.sct文本文件而不是在 Keil 自动生成的默认文件上反复改。这样你可以在每个工程里保留一份自己熟悉的模板需要时直接替换。文件注释尽量不要用中文因为编码格式不一致时某些版本 Keil 会把中文注释显示成乱码虽然不影响编译但排查时看着很难受。我个人在实际操作中的体会是堆栈配置其实没有想象中那么复杂关键是搞清楚“启动文件里的段定义”和“链接脚本里的区域分配”这两层关系。默认情况下你只需要改改宏一旦项目涉及 Bootloader、外部 RAM、RTOS 或内存保护手动编辑 .sct 就是必备技能。第一次配置好固化的堆栈区域之后整个内存布局就在你脑子里有了画面以后再排查 HardFault你会下意识地先打开 map 文件看一眼地址而不是像无头苍蝇一样在代码里乱翻。
返回列表