搞嵌入式的老哥们对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, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE, ALIGN3 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, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE, ALIGN3 Heap_Mem SPACE Heap_SizeAREA STACK, NOINIT, READWRITE, ALIGN3定义了一个名为STACK的段NOINIT表示不进行初始化READWRITE表示可读写ALIGN3表示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字节对齐的倍数。启动文件里写了ALIGN3也就是8字节对齐。在手动编写.sct时你设定的区域起始地址和size也尽量保持8字节对齐否则个别编译优化选项下会出现对齐问题。3.4 实战变体堆放到外部SDRAMST官方的很多板子外部挂了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 FlashApp从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前64KB0x08000000 0x10000 0x08010000App总大小不能超过512KB-64KB448KB也就是0x70000字节。RAM分配上Bootloader只用低16KBApp用从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并放在正确的加载域内程序上电后直接HardFaultSDRAM未初始化就访问堆区或栈顶地址不对不要把堆放到未初始化外部存储器或者确保初始化时序足够早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发现完全看不懂时先对着它的格式写成中文注释。把每个区域的用途、起始地址、占用大小标注出来写完这份标注基本就能定位到问题。别小看这种笨办法很多看似玄学的链接错误最后都出在你自己对内存布局理解有偏差上。
