搞嵌入式的老哥们对Keil MDK都不陌生,但说句实话,很多人从建工程到跑完项目,都没打开过工程里那个以.sct结尾的文件。这东西平时确实不起眼,一旦你遇到HardFault、程序一跑就飞、或者想把内存挪到外部SDRAM时,能不能看懂它、改对它,基本决定了你是花十分钟解决问题,还是花一晚上反复试错。这篇文章就围绕Keil MDK中.sct文件怎么手动配置STM32的堆栈内存区域,把原理、语法、实际改法和踩坑经验一次讲透。
适合谁看?搞STM32的嵌入式开发、刚接触分散加载机制、或者正在折腾Bootloader和App共存、想把Heap放到外部存储器的朋友,这篇都是按实战路子写的。有基础的可以直接跳到第三节看实操,纯新手的建议从头到尾过一遍,把内存布局的底子打牢。
1. 为什么要手动折腾.sct文件
很多人在Keil里建工程,点击编译就能烧录运行,根本没意识到链接阶段发生了什么。其实编译器生成的目标文件只是零散的代码和数据,最终怎么摆放、放在哪个地址、哪段进Flash、哪段进RAM,全部由链接脚本决定。在Keil MDK里,这个脚本就是.sct文件,全称叫Scatter File,分散加载描述文件。它的地位相当于耕地时的分界线,哪块地种什么、边界在哪,写清楚农活才不会乱。
1.1 默认情况下,STM32的堆栈是怎么分配的
按默认配置走的时候,Keil会通过Target选项卡里的IROM1、IRAM1起始地址和大小,替你自动生成一份.sct文件。这份文件通常长这样:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) .ANY (+XO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }这里LR_IROM1是加载域,告诉链接器你的固件准备放在哪块Flash区域;ER_IROM1是执行域,说明代码在运行时从哪里取指;RW_IRAM1则规定了可读写数据和零初始化数据放到哪段RAM。默认情况下,片内RAM从0x20000000开始全部交给RW_IRAM1管理,而堆栈大小则藏在启动文件startup_stm32xxxx.s里:
Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 Heap_Mem SPACE Heap_Size也就是说,默认情况下栈和堆都在RW_IRAM1这个大区域里,启动文件先定义栈、再定义堆,栈顶__initial_sp指向RAM的最高处,堆往上增长、栈往下增长。这样做的好处是简单,Keil自动为你安排好了,坏处是:一旦堆栈彼此无界限,堆溢出和栈溢出会静默地互相踩踏,然后你的程序就开始出现各种玄学Bug。
1.2 什么场景下必须自己动手改
既然默认配置能用,为什么要去动它?我个人的判断标准很简单:当默认内存布局已经开始限制你的功能落地时,就该认真考虑手写.sct了。下面是几个我实际遇到过、也帮别人排查过的典型场景。
第一类是内存不够用,需要外扩存储。比如跑LVGL界面、用CANopen协议栈、或者做音频缓冲,片内RAM只有64KB甚至20KB,根本塞不下。此时很多人会挂一片外部SRAM或SDRAM,但默认链接脚本根本不知道这些外设的存在,你需要亲手写一段新的执行域,把大数据量的缓冲区或堆空间放到外部存储器的地址范围里。
第二类是Bootloader和App同时存在的项目。这是业内常见做法,Bootloader放在Flash起始地址,App放在之后偏移的位置。如果你只改了Target选项卡里的IROM1起始地址,忘了同步RVCT链接脚本,用旧.sct文件链接出来的App的向量表和中断向量还是从0x08000000开始,运行时必然出错。这种问题是固执地使用“自动生成”配置时最容易踩的坑。
第三类是功能安全或内存保护需求。需要让栈区、堆区、关键数据段占据固定地址,并且互相隔离,不让编译器随意摆放。一旦明确了地址范围,之后写MPU保护规则也容易得多。
第四类是特殊应用,比如需要把一段只读数据放到片内Flash的某一固定区域,或者把某个函数固定到RAM中执行。这已经不是单纯调堆栈的问题,但仍然绕不开.sct。本质上,当你需要精确控制代码和数据的物理分布时,就必须从“让链接器随意安排”切换到“我来规定每个区域放什么”。
2. .sct文件的核心语法拆解
看懂.sct文件不需要背语法手册,核心就三条:什么是加载域,什么是执行域,以及怎么用选择器把目标文件或段放到指定执行域。这三条吃透了,绝大多数修改场景都能独立搞定。
2.1 一个标准STM32的.sct长什么样
拿STM32F103ZET6这种典型芯片举例,默认生成的.sct我已经在上一节展示过了。再看得细致一点,整个文件就两大块:
LR_IROM1 0x08000000 0x00080000 { ; 加载域:起点0x08000000,最大8MB?不,是0x80000 = 512KB ER_IROM1 0x08000000 0x00080000 { ; 执行域:代码段 *.o (RESET, +First) ; 复位向量最优先 *(InRoot$$Sections) ; 根区段 .ANY (+RO) ; 所有只读段 .ANY (+XO) ; 所有只读执行段 } RW_IRAM1 0x20000000 0x00010000 { ; 执行域:RAM中的RW和ZI数据 .ANY (+RW +ZI) } }注释里那两个0x00080000和0x00010000分别对应Flash和RAM空间的大小,数值多大取决于你选的具体型号。0x00080000对于512KB Flash的芯片正好是512KB,代表ER_IROM1最大能用到这个边界;0x00010000是64KB RAM。注意这里写的是最大长度,不是必须把所有空间都用满。
LR_IROM1这个加载域的名字可以随便取,Keil默认用LR_加区域名。加载域解决的是“烧录到哪”的问题,执行域解决的是“运行时在哪”的问题。对STM32来说,代码通常原地执行,所以加载地址和执行地址是同一个,这也是为什么ER_IROM1和LR_IROM1的起始地址一致。但某些场合可以把代码从Flash拷贝到RAM中执行,这时加载域和执行域的地址就会不同。
2.2 加载域、执行域,以及那几个关键字
理解分散加载,要抓住一个核心逻辑:加载域描述烧录映像,执行域描述运行映像。启动流程里,__main会调用__scatterload,把需要搬运的数据从加载域复制到执行域,把需要清零的ZI段初始化成0,然后才跳转到__main真正的主函数。这一步是所有.sct配置文件发挥作用的底层机制。
代码里那个+First叫放置属性,意思是这个输入段要放在所有段的最前面。STM32上CPU一上电就从Flash的0x08000000读取向量表,所以复位向量必须放在最前面,也就是RESET段加+First。类似还有+Last,表示放到最后。
大小参数后面还可以加属性。常见的有FIXED,表示强制加载地址等于执行地址,不搬运;UNINIT,表示这个区域不需要启动时清零,适合存放栈、堆或掉电保持数据。实际操作里,给堆栈区域手动分配地址时,UNINIT几乎是必用的,否则链接器会认为这些区域需要初始化,你会在Map文件里看到一堆莫名其妙的多余动作。
2.3 对象选择器、属性与放置规则
.sct文件里真正决定“哪个.o文件、哪个段放哪里”的,是那些选择器。平时最常遇到的写法有三种。
第一种是*.o (RESET, +First),意思是任意目标文件里的RESET段,放到执行域最前面。星号*是通配符,匹配所有目标文件。
第二种是.ANY (+RO)。.ANY是ARM链接器里一个比较特殊的通配符,代表“没有被其他更具体规则选中的任何输入段”。链接器根据优先级把段塞进执行域,如果某个段没有被更强的规则指定,就会自动分配到任意一个标了.ANY的执行域里,前提是空间足够。
第三种是object.o (SectionName),精确指定某个目标文件里的某个Section放到某个执行域。例如在启动文件里定义了STACK段和HEAP段,你就可以这么写:
RW_STACK 0x20001000 UNINIT 0x00000400 { startup_stm32f103xe.o (STACK) } RW_HEAP 0x20001400 UNINIT 0x00001000 { startup_stm32f103xe.o (HEAP) }这种写法就是手动把堆栈内存区域从默认的RW_IRAM1大池子里摘出来,钉在你指定的RAM地址上。后面的UNINIT告诉链接器:这块区域上电不用清零,直接当裸内存用。
搞清楚了选择器,再看一份复杂的.sct文件就不会发怵了。复杂只是简单规则的组合。
3. 手把手实操:把堆栈内存区域掌握在自己手里
理论看再多,不如动手写一遍。下面我用一个完整案例演示怎么从零开始手动配置.sct,把栈放到指定RAM地址、把堆放到外部SDRAM,再展示Bootloader和App分区怎么写。每一步都写实操细节,方便直接对着敲。
3.1 第一步:让Keil把控制权交出来
Keil默认是自动生成.sct的,想手动改,得先取消自动生成。操作路径:Options for Target -> Linker选项卡,找到Use Memory Layout from Target Dialog这个复选框,把它取消勾选。下面那个编辑框里原来灰色的.sct路径会变亮,你可以点Edit直接改,也可以点右侧的文件夹图标指定已经写好的.sct文件。
这一步有个细节值得提:很多人取消勾选后,发现编译报错找不到__initial_sp,或者启动文件里的堆栈符号不在Map文件里。出现这种情况,多半是路径问题,或者.sct文件里没有把启动文件包含进去。建议在自己工程目录下建一个scatter文件夹,把.sct放进去,然后直接在Linker选项卡里填相对路径或绝对路径,路径中尽量不要有中文和空格。
取消自动生成后,Target选项卡里的IROM1、IRAM1就只是给你看的参考值,不再参与链接过程。这既是解放也是坑:如果你忘了同步优先级,改成手动后再也看不到意外提示,程序哪里被覆盖,全靠自己查。所以一旦切到手动模式,所有内存布局的规划都要心里有数。
3.2 第二步:从启动文件里找到堆栈定义的源头
手动配置.sct,不代表启动文件里的堆栈定义可以删掉。恰恰相反,启动文件里的Stack_Size和Heap_Size定义仍然是堆栈内存区域的源头,.sct只是把这两个Section放到指定地址。
以STM32F103的标准启动文件为例,有几行代码你必须认识:
Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 Heap_Mem SPACE Heap_SizeAREA STACK, NOINIT, READWRITE, ALIGN=3定义了一个名为STACK的段,NOINIT表示不进行初始化,READWRITE表示可读写,ALIGN=3表示8字节对齐。栈顶标签__initial_sp在这个段的末尾,它在启动代码中被加载到SP寄存器。
这里有个理解错误很常见:以为栈大小和堆大小只能在启动文件里改,改完就完事。其实启动文件定义的是“这个Section有多大”,而.sct决定的是“这个Section放到哪个地址”。如果你想精确控制栈的位置,只在启动文件里把Stack_Size调大是没用的,链接器照样会在RAM里找地方放它,你可能根本不知道它放哪了。要是项目对栈地址有硬性要求,比如配合MPU做内存保护,就得两个地方一起改:启动文件定义大小,.sct定义位置。
3.3 第三步:动手写第一版自定义.sct
假设用的还是STM32F103ZE,片内Flash 512KB从0x08000000开始,片内RAM 64KB从0x20000000开始。我想做三个改动:一是给栈分配4KB并固定到RAM起始处;二是给堆分配8KB,放到栈后面;三是把剩下的RAM还给普通全局变量和局部变量用。
于是写一个custom.sct:
; 自定义分散加载脚本 LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) .ANY (+XO) } RW_STACK 0x20000000 UNINIT 0x00001000 { startup_stm32f103xe.o (STACK) } RW_HEAP 0x20001000 UNINIT 0x00002000 { startup_stm32f103xe.o (HEAP) } RW_IRAM1 0x20003000 0x0000D000 { .ANY (+RW +ZI) } }这几个数字是怎么算的?RW_STACK从0x20000000开始,长度0x1000就是4KB,正好占0x20000000 ~ 0x20000FFF。RW_HEAP从0x20001000开始,长度0x2000是8KB,一直延伸到0x20002FFF。剩下的RAM从0x20003000到0x20010000,还有0xD000也就是52KB,交给普通全局变量。
写这个.sct有几个容易犯错的地方。
第一,startup_stm32f103xe.o这个名字必须和实际启动文件名完全一致。如果启动文件叫startup_stm32f103.s,那编译后的目标文件就是startup_stm32f103.o,你写错一个字符,链接器直接报L6218E: Undefined symbol或者干脆什么段都没匹配上。稳妥起见,可以在Build Output窗口看编译日志里生成的目标文件名。
第二,UNINIT必须加。如果不加,链接器会把STACK和HEAP当成需要初始化的ZI区,生成一堆你根本不需要的清零代码,而且Map文件里会看到它给这两个区域加上了奇怪的属性。加UNINIT表示“这块上电就是一块干净内存,谁来用谁负责”。
第三,栈的大小必须是8字节对齐的倍数。启动文件里写了ALIGN=3,也就是8字节对齐。在手动编写.sct时,你设定的区域起始地址和size也尽量保持8字节对齐,否则个别编译优化选项下会出现对齐问题。
3.4 实战变体:堆放到外部SDRAM
ST官方的很多板子外部挂了SDRAM,地址范围比如0x68000000到0x68000000 + 0x00200000,也就是2MB。问题来了:默认链接脚本根本不知道外部SDRAM的存在,就算你在初始化代码里把SDRAM控制器配置好了,malloc还是只能在片内RAM里找空间。
解决思路很简单:在初始化SDRAM之后,把堆段放置到SDRAM地址范围。做法是在.sct里新增一个RW_HEAP_SDRAM执行域:
RW_HEAP_SDRAM 0x68000000 UNINIT 0x00200000 { startup_stm32f103ze.o (HEAP) }然后启动文件里要把Heap_Size调大到0x00200000。同时,主程序里如果用到malloc或_sbrk,内存分配器就会从SDRAM起始地址开始分配,天然避开了片内RAM的紧张局面。
但这里必须提醒一句:外部SDRAM在系统上电时还没完成初始化。如果启动代码里或者__main初始化流程里就有人调用malloc申请堆内存,而SDRAM控制器还没配置好,那访问就会直接HardFault。我的常规做法是:在main函数实现SDRAM初始化库函数后,再第一次调用malloc,或者干脆在进入main后主动调用一次malloc预分配再释放,把底层堆管理器的“第一次尝试访问”提前触发。当然,如果SDRAM初始化代码本身比较靠后,某个中间环节用了动态内存,这个方案就不太合适,你可能要把堆分成两段,一段放片内RAM,一段放SDRAM,按需选用。
另外还要想清楚:Heap_Size一旦改到2MB,即使你不用它,启动文件里也会定义这么大的Section。如果.sct没同步扩大对应执行域,链接器会报空间不足;但如果你的SDRAM只有2MB,把整个堆塞进去也会挤压其他数据。所以更细的做法是把堆进一步细分,用#pragma arm section或者多个HEAP段来控制分配。
3.5 实战变体:Bootloader与App的Flash/RAM分区
Bootloader和App双工程的玩法,是最考验分散加载能力的场景。我给Bootloader分配前64KB Flash,App从0x08010000开始,各用各的RAM区域。Bootloader的.sct如下:
LR_IROM1 0x08000000 0x00010000 { ER_IROM1 0x08000000 0x00010000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00004000 { .ANY (+RW +ZI) } }App的.sct如下:
LR_IROM1 0x08010000 0x00070000 { ER_IROM1 0x08010000 0x00070000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20004000 0x0000C000 { .ANY (+RW +ZI) } }这里的数字也花点心思:Bootloader占用Flash前64KB,0x08000000 + 0x10000 = 0x08010000;App总大小不能超过512KB-64KB=448KB,也就是0x70000字节。RAM分配上,Bootloader只用低16KB,App用从0x20004000开始的高位RAM,避免两边共享RAM时变量互相污染。
两个工程对应的启动文件也要改,核心是处理中断向量表的偏移。Bootloader程序里如果在跳转到App之前需要开启中断,就必须在App的启动文件里用类似VECT_TAB_OFFSET的宏把向量表偏移到0x08010000;如果是FLASH基地址再加偏移,就设置偏移量为0x10000。这件事和.sct是一整套的,很多人只改了Flash地址、忘了改向量表偏移,最后App一跑就复位,原因就在这里。
另外,App工程链接完成后,你烧录固件时不能用包含Bootloader区的整片Flash镜像直接烧,也不能用默认从0x08000000开始的烧录地址烧App。正确的做法是:用烧录工具把App的bin或hex烧写到0x08010000。用Keil自带的FLM算法时,你在Options for Target的Utilities设置里要勾选跳过0x08000000开头的区域,或者直接指定Flash起始地址。这些经验单独看都不难,但凡是做过一次量产的人,都会明白这套配置出问题时有多折腾。
4. 验证配置与常见坑排查实录
.sct改完,编译通过,不代表万事大吉。程序按不按你设想的地址跑,还要通过工具验证。这一节整理了我自己常用的验证方法,以及这些年遇到的典型排查案例,写成速查表,方便你对照。
4.1 怎么确认自己的配置真的生效了
最直观的验证方法是看Keil生成的Map文件。编译完成后,在Build Output窗口双击Linker的Log,或者到List文件夹里打开.map文件,搜索Memory Map of the image,能看到每段代码和数据最终放在哪个地址。如果你写的是RW_STACK,那里会明确列出RW_STACK 0x20000000 0x00001000这样的信息。如果找不到,说明你的STACK段根本没有匹配到指定的执行域。
另外,调试时可以直接看寄存器。在Keil的Debug模式下,打开寄存器窗口,看SP寄存器的值是不是0x20001000(对应栈顶,如果是4KB栈那么栈从0x20000000开始,栈底是0x20001000)。如果SP的值和你预期的栈顶不一致,说明启动文件里的__initial_sp被别的机制覆盖了,比如链接器根据RW段重新定位过。
还有一个隐藏技巧:在启动文件的__initial_sp定义后加一个测试断点,或者在main第一行断下来后,打开Memory窗口输入0x20000000,观察栈区域有没有正在被使用。用手动填充0xAA的土办法也可以,启动之前在Memory窗口把整个栈区域填成0xAA,程序跑一段时间后再看栈区,被覆盖的部分就是实际用掉的栈深度。这是老派但非常好用的经验做法。
4.2 常见链接错误和排查思路
手动改.sct之后,链接器报错的概率会明显上升。很多错误并不是语法问题,而是你对内存布局的计算失误。挑几个高频的整理成表格。
| 错误提示 | 原因 | 解决思路 |
|---|---|---|
| L6220E: Execution region RW_IRAM1 cannot cover the address | 某一执行域区域不够大,段塞不下 | 执行域空间比实际内容小,把长度改大,或者把一些段挪到其他区域 |
| L6218E: Undefined symbol __initial_sp | 启动文件没有被链接进工程,或STACK段没被匹配到 | 确认.sct里有.ANY (+RW +ZI),且启动文件参与编译链接 |
| L6314W: Execution region overlaps with another region | 两个执行域地址范围重叠 | 检查手动分配的所有区域的起始地址和大小,按十六进制手动计算边界 |
| L6920E: Execution region RW_HEAP cannot be placed in load region | 手动分配区域时,UNINIT属性或加载域设置不对 | 给堆栈执行域加上UNINIT,并放在正确的加载域内 |
| 程序上电后直接HardFault | SDRAM未初始化就访问堆区,或栈顶地址不对 | 不要把堆放到未初始化外部存储器,或者确保初始化时序足够早 |
| App跳转后复位 | App向量表偏移未设置 | 在App启动文件中设置VECT_TAB_OFFSET,或用函数设置中断向量表地址 |
排查技巧上,我最常用的是先看编译Build Output窗口里的错误信息提示在哪一行,然后用Map文件对照每个区域的起始地址和大小。手动算地址时建议不要用十进制,十六进制看得更清楚,我自己就吃过0x10000和0x01000多打一个0的亏,一发现RAM被占用得乱七八糟,查了半小时。
4.3 关于堆栈设置的一个容易被忽略的细节
很多人认为只要改启动文件里的Stack_Size和Heap_Size就行,但这只改了大小,没改位置。如果你配合外部RAM用malloc,却不修改.sct,链接器的默认规则仍然会把所有RW、ZI数据压到片内RAM上,堆的大小即使改到很大,也只是在片内RAM里挤占空间。这个观念不扭转,之后做复杂项目就会处处碰壁。
另外,当你给栈分配独立区域后,建议在启动代码里检查栈指针。因为编译器可能在入口代码里使用栈,初始化栈和进入C环境之前,SP必须指向有效RAM。给栈选地址时,一定不能选到被其他ZI段占用的区域,否则你连__main都进不去,直接跑飞。
4.4 一个典型的调试现场
我帮人查过一个很典型的案例:他的STM32F407程序一运行到某个函数内部,就随机HardFault,而且只在开优化后出现。查他的工程,发现关闭了“Use Memory Layout from Target Dialog”,却从网上抄了一份别人写的.sct,把栈安排到了0x20000000起始,但没加UNINIT。链接器认为这块区域需要初始化,就往里面塞了清零代码。程序启动时,栈还没真正用起来,清零动作倒没事;一旦运行到那个函数,局部变量多,栈指针不断往低地址压,直接压进了本质上是ZI数据区域的“栈区”,和全局变量互相覆盖,问题就彻底引爆了。
解决方式很简单:给栈区域加上UNINIT,同时把栈的起始地址和内存里其他数据区域彻底隔开。这个案例让我意识到,网上很多代码片段只讲一半,坑都在细节里。
5. 手动配置.sct的几点心得
从第一次把.sct改成自定义内容到现在,前前后后折腾过不少工程。分享几个个人觉得很有用的心得。
先给自己留“逃生门”。不要一上来就删掉Keil默认自动生成的配置。我的习惯是先把默认生成的那份.sct另存一份,命名成default_scatter_backup.sct放在工程目录下。改成手动配置之后,哪天新工程出问题,对比一下手写版和备份版,往往一眼就能看出是哪块内存区域分配不合理。这个习惯帮我省了很多排查时间。
其次,尽量把栈放到RAM的高地址段,而不是低地址段。为什么?栈是向下生长的,如果放在RAM低地址,栈增长时可能直接越过RAM边界跑到外设地址区,那种错误很难排查。而把栈放到RAM高端,栈向下增长时天然向RAM内部走,一般不会越界。所以很多芯片的默认链接脚本把栈放在RAM末尾是有道理的,不要为了追求“看起来整齐”而随便把栈钉在RAM起始处。
第三,改动.sct时,一次只改一件事。很多人喜欢把堆栈位置、Flash偏移、对外部存储器的使用一次性全改完,结果编译一报错,根本不知道是哪里出了问题。我自己的做法是:先改一个区域,编译看Map文件,确认无误后再改下一个。虽然慢一点,但每次改动的影响范围都清清楚楚。
最后再分享一个排查小技巧:打开.sct发现完全看不懂时,先对着它的格式写成中文注释。把每个区域的用途、起始地址、占用大小标注出来;写完这份标注,基本就能定位到问题。别小看这种笨办法,很多看似玄学的链接错误,最后都出在你自己对内存布局理解有偏差上。