1. 那个藏在 Target 选项里的链接期开关
很多人用了几年 MDK,代码之外的东西从来没碰过。直到某天 Flash 装不下、RAM 不够用,或者要做一个带 Bootloader 的双区固件,才会第一次听说sct这个缩写。它就是scatter loading file,中文一般叫"分散加载文件",本质上是一份写给链接器 armlink 的内存分配说明书:哪块地址放什么、谁排在前面、哪些段不初始化、栈堆留多大,全在里面。
顺便说一句,搜这个词的时候经常能看到"RNA 和 sct 是什么区别"这种提问,那纯粹是三个字母撞车——生物学里的 RNA 和嵌入式里的 scatter file 没有任何关系,别被搜索结果带偏。
sct文件的存在感极低,是因为 MDK 默认帮你生成了一份。只要你在Options for Target → Target页面里勾着Use Memory Layout from Target Dialog,μVision 就会根据你填的 IROM1 / IRAM1 地址范围,在编译时生成一份临时的.sct丢到Objects目录下。打开 Build Output 窗口,你能看到一行类似--scatter "Objects\demo.sct"的命令,这就是它的调用现场。对于 90% 的常规工程,自动生成的那份完全够用。
但剩下的 10% 场景,自动生成的方案就解决不了了。举几个我实际遇到过的:
- 外扩 SDRAM 或使用 CCM RAM。STM32F4 的 CCM RAM 只有 CPU 能访问,DMA 碰不到,默认链接脚本不会把它算进去,你得手动划一块出来专门放 DMA 缓冲区。
- 做 IAP / OTA 升级。App 固件的起始地址要从
0x08000000挪到0x08008000甚至更靠后,中断向量表也要跟着搬。 - 变量必须钉死在某个物理地址。比如和另一个核共享内存、或者给固件版本号、序列号留一段固定的 Flash 区域。
.noinit段。系统复位后想保留一段 RAM 里的内容不清零,比如记录重启原因、做 CRC 校验标志。- 调试期想榨干 RAM。
.ANY的默认分配顺序偶尔会把大数组放到不合适的区域,导致无谓的空间浪费。
这些需求有一个共同点:它们都超出了"填两个地址框"的表达能力。所以必须把那个勾去掉,自己接管这个文件。
1.1 从工程里挖出自动生成的 sct 长什么样
在动手改之前,先看基线。做法很简单:勾选状态不变,Build 一次,然后去Objects目录找同名.sct。以一颗 512KB Flash + 128KB RAM 的 Cortex-M4 为例,内容大致是这样:
LR_IROM1 0x08000000 0x00080000 { ; load region size_region ER_IROM1 0x08000000 0x00080000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) .ANY (+XO) } RW_IRAM1 0x20000000 0x00020000 { ; RW data .ANY (+RW +ZI) } }短短十行,信息量不小。LR_IROM1是load region,地址0x08000000,长度0x80000;它内部套了两个execution region:ER_IROM1和RW_IRAM1。前者的基地址和 LR 完全一样,说明这段内容烧在哪就在哪跑;后者地址在 RAM 里,说明这个区域的内容运行时在 RAM,但烧写时必须先存在 Flash 中。
*.o (RESET, +First)这行的作用是:把任何目标文件里名叫RESET的输入段排到最前面,+First保证它占据区域起始地址。这就是为什么启动文件里的向量表永远在0x08000000——startup_xxx.s里有一句AREA RESET, DATA, READONLY,段名对上了。
*(InRoot$$Sections)是 C 库的保留段,包含__main等初始化代码。这一行千万别漏,漏了大概率在链接期就报未定义符号,或者在运行期连main()都进不去。
.ANY (+RO)和.ANY (+XO)负责收纳剩下的只读段和执行段,.ANY (+RW +ZI)收纳所有读写数据和零初始化数据。.ANY这个选择器的含义是"我信任链接器帮我挑位置",它会在所有带.ANY的执行域里选一个空间合适的塞进去。
1.2 手动接管后的第一个必做动作
把那个勾去掉之后,MDK 不会自动生成任何东西,--scatter参数直接消失,链接器会退回默认的内存布局规则。所以去掉勾的第一件事,就是把刚才从Objects里挖出来的内容复制一份存成xxx.sct,放进工程目录,然后在Options for Target → Linker → Scatter File里指向它。
这一步看着废话,但真有人直接去掉勾就去改代码,结果编译一堆莫名其妙的地址错误。顺序上的坑,能提前踩就别现场踩。
另外提醒一点:.sct文件本身不参与编译,改了它 MDK 不一定会重新链接。实测下来,μVision 对.sct的修改有识别,但偶尔偷懒。稳妥做法是改完之后手动Rebuild,别只点 Build。我有一次改完区域大小点了 Build,链接器用的还是缓存,排查了半小时才发现是增量编译的锅。
2. 语法拆到骨头:load region、execution region 和选择器
sct的语法说复杂也复杂,说简单也就三层结构。把它当成"区域套区域"的嵌套盒子,瞬间就通了。
2.1 一个 load region 能装下多个 execution region
这是整个分散加载机制最核心的概念区分,很多讲不清的资料就是在这里含糊过去的。
Load region(加载域)描述的是"镜像怎么烧"。它就是最终生成的.axf/.bin在 Flash 里占据的连续地址范围。你在 MAP 文件里看到的 "Load Region" 那一段,就是它。
Execution region(执行域)描述的是"代码怎么跑"。它是运行时刻的真实地址分布。
这两者在地址上可以完全不同,而sct的威力恰恰来源于此。最常见的用法是:Flash 里从0x08000000开始依次摆放 RO 代码和 RW 初值,这段在 Flash 里是连续的,所以用一个load region 包住;但 RW 的运行时地址在 RAM 的0x20000000,于是这个 load region 内部套了两个execution region,一个对应 Flash,一个对应 RAM。
结构上就长这样:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00070000 { ; 跑在 Flash *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { ; 跑在 RAM .ANY (+RW +ZI) } }那 Flash 里给 RW 初值留的那块空间在 MAP 里是什么?是 LR 的一部分,被 armlink 自动追加在ER_IROM1之后。Load$$RW_IRAM1$$Base这个链接符号就指向那块空间的起点。启动代码在跑.data段拷贝的时候,源地址就是取的这个符号。
注意:
sct里的长度字段是可以省略的。写ER_IROM1 0x08000000 { ... }链接器会自己算。但在多区域、特别是需要精确控制边界的工程里,我强烈建议把长度写全。省略长度时,armlink 可能会把两个区域算重叠,直到运行期才炸,那时候就难查了。
2.2 地址、长度和那几个反复出现的属性
执行域的完整语法形式是:
执行域名 基地址 [属性列表] [最大长度] { 输入段描述... }属性列表里常用的就这么几个,我按使用频率排一下:
| 属性 | 作用 | 典型场景 |
|---|---|---|
EMPTY | 只占地址空间,不分配任何输入段 | 栈、堆、预留缓冲区 |
UNINIT | 该区域的 ZI 不被启动代码清零 | 复位不丢的重启标志、RAM 日志区 |
ALIGN n | 区域起始地址按 n 字节对齐 | DMA 缓冲区、FFT 表 |
FIXED | 地址固定,放不下就报错 | 精确定位的外部器件缓冲区 |
ABSOLUTE | 绝对地址,不参与重定位 | 与硬件寄存器映射配合 |
EMPTY和UNINIT是最容易混淆的一对。EMPTY说的是"这块地址我不放东西",UNINIT说的是"这块地址放东西,但别清零"。比如说想在 RAM 里留一段 4KB 给栈,用EMPTY;想留一段 200 字节记录重启信息,用UNINIT,并且要在代码里显式声明段名。
ALIGN值得单独说一句。Cortex-M 系列的 DMA 通常不要求地址对齐(不同芯片要求不一),但像 STM32 的某些外设 DMA、以及带 cache 的 M7 内核做 cache line 操作时,对齐就是硬要求。ER_SDRAM 0xC0000000 ALIGN 32 { ... }这种写法能把整个区域按 32 字节对齐,省得在代码里到处加__attribute__((aligned(32)))。
2.3 选择器:*、.ANY和模块名到底怎么选
输入段描述由"模块选择器 + 输入段选择器"两部分组成,中间用空格隔开,长这样:模块 (段名, +First)。理解选择器的匹配规则,是手写sct不翻车的前提。
模块选择器的写法:
*—— 匹配所有目标文件。这是个通配符,会匹配上你自己的.o,也会匹配上 C 库的.o。*.o—— 只匹配.o,通常比裸*更安全。startup_stm32f407xx.o—— 精确匹配某个文件。-l*—— 匹配库名。
输入段选择器的写法:
(RESET)—— 匹配名为RESET的输入段。(+RO)—— 匹配所有只读属性的段。(+RW +ZI)—— 同时匹配读写段和零初始化段。.ANY—— 这是个特例,它必须出现在选择器位置,表示"剩下的交给你"。
关于.ANY和*的区别,我见过太多人写反。简单分辨法:*是动词,意思是"必须放在这里";.ANY是名词,意思是"随便放哪都行"。在同一个执行域里写*(+RW),等价于把这个区域变成所有 RW 数据的唯一去处,一旦放不下就直接报 L6406E。而.ANY (+RW)则给链接器留了在多个.ANY区域之间腾挪的余地。
+First和+Last用来控制排序。向量表必须+First,因为它要占据区域的$$Base。有些工程会把 CRC 校验表或者 boot 参数放+Last,方便通过$$Limit反推位置。
还有个容易忽略的点:*(InRoot$$Sections)这一行在多个执行域里都可以出现,但只有第一个生效。C 库初始化代码只能放一份,重复写不会报错,但会让你误以为分散了。
3. 把 RW 数据搬进 RAM:镜像地址与运行地址分离之后
这一节讲原理,但绝对是实用的原理。因为 90% 的"改完 sct 程序跑飞"都出在这块。
3.1 为什么烧写地址和运行地址可以不一样
RW_IRAM1的执行地址是0x20000000,但掉电后 RAM 里什么都没有。芯片上电那一刻,RAM 里的g_some_global = 5这个初值不可能凭空出现,它必须被"搬"进去。
搬运的源头就是镜像。armlink 把RW_IRAM1区域的初值内容按顺序追加在ER_IROM1的末尾,存放在 Flash 里,这块空间属于 load region 但不属于任何一个执行域的运行时地址范围。MAP 文件里的 "Load Region LR_IROM1" 段落会把它列出来,标签是 "RW data"。
理解了这个机制,你就能明白为什么改sct的时候必须同时改 load region 的长度。如果 LR 的长度写死了0x80000,而 ER_IROM1 的代码量长到快顶满,那 RW 初值就无处可放,链接器会直接甩 L6220E 给你。
3.2 真正干活的是 C 库,不是你写的代码
启动流程里这段搬运不是main()之前你手写的东西,而是 C 库的__main干的。展开一下调用链:
Reset_Handler → __main (C 库入口,非用户 main) → __scatterload (读 sct 生成的搬移表) → __scatterload_copy (拷贝 RW 初值) → __scatterload_zeroinit (ZI 清零) → __rt_entry → main()__scatterload用的那张"搬移表"就是 armlink 根据sct生成的,存在 Flash 里,每一项记录"从哪搬、搬到哪、搬多少"。所以在sct里改地址,链接器会重新生成这张表,搬运逻辑自动跟着变,不需要改一行 C 代码。
这也是为什么手动改sct相对安全:你只描述结果(哪个段放哪),不描述过程。
3.3 用 MAP 文件和链接符号做验收
改完sct之后,别急着烧板子。先看 MAP 文件。生成 MAP 的方式是Options for Target → Linker里勾上Generate Map file,或者直接在链接器 Misc controls 里加--map。生成的.map文件放在Objects目录(或你指定的 Listings 目录)。
打开后找这几个关键段落:
第一段:Memory Map of the image
Load Region LR_IROM1 (Base: 0x08000000, Size: 0x00012a3c, Max: 0x00080000, ...) Execution Region ER_IROM1 (Exec base: 0x08000000, Load base: 0x08000000, ...) Execution Region RW_IRAM1 (Exec base: 0x20000000, Load base: 0x08012a3c, ...)注意RW_IRAM1那一行,Exec base 和 Load base 不是一回事,前者是0x20000000,后者紧跟在ER_IROM1的结尾。这两个值不相等,才说明搬运机制真的启用了。如果两个值一样,那就意味着你把 RW 也放在 Flash 里了——能跑,但每次写变量都在擦写 Flash,那是另一个故事。
第二段:Image Symbol Table 里的$$符号
armlink 会自动生成一批链接期符号,命名规则固定,可以直接在 C 代码里用extern引用:
| 符号 | 含义 |
|---|---|
Image$$ER_IROM1$$Base | ER_IROM1 的起始地址 |
Image$$ER_IROM1$$Limit | ER_IROM1 的结束地址(不含) |
Image$$ER_IROM1$$Length | ER_IROM1 的长度 |
Image$$RW_IRAM1$$Base | RW 数据在 RAM 的起始地址 |
Image$$RW_IRAM1$$Limit | RW+ZI 的结束地址 |
Load$$RW_IRAM1$$Base | RW 初值在 Flash 中的起始地址 |
Image$$RW_IRAM1$$ZI$$Base | ZI 段的起始地址 |
Image$$RW_IRAM1$$ZI$$Length | ZI 段的长度 |
有个常见需求是做内存自检,要算整个 RAM 区已经被用了多少。这时候Image$$RW_IRAM1$$Limit就是最有用的那个符号——它给的是所有 RW/ZI 数据的结束地址,往后的空间全是空闲的。
用法上有个小细节:这些符号在 C 里声明成extern uint32_t Image$$RW_IRAM1$$Base;之后,取地址才对,写&Image$$RW_IRAM1$$Base。直接当变量读会拿到段里的内容,不是段地址。这个坑我见过至少五个人踩。
4. 栈和堆:为什么ARM_LIB_STACK的长度要写成负数
栈和堆的分配,是手写sct时第二个高频翻车点。
4.1 Cortex-M 的栈是满递减的
Cortex-M 的栈指针SP初始值指向栈顶(高地址),压栈时先减后存。所以描述一块栈区,你需要说明的是"栈顶在哪,往下留多深"。
ARM 的链接器为此专门约定了两个保留名:ARM_LIB_STACK和ARM_LIB_HEAP。写进去之后,C 库会自动把它们和__initial_sp/__heap_base/__heap_limit这些符号挂钩,启动代码不用改。
写法有两种,效果一样,但含义相反:
; 写法一:给基址和负长度,基址就是栈顶 ARM_LIB_STACK 0x20020000 EMPTY -0x00000400 { } ; 写法二:给基址和正长度,基址是栈底,栈顶在基址+长度处 ARM_LIB_STACK 0x2001FC00 EMPTY 0x00000400 { }我个人更推荐第一种。因为 Cortex-M 的SP初值必须等于栈顶,写成0x20020000一眼就能对上芯片 RAM 的最高地址,后面跟着的-0x00000400明确表达了"这是往下 1KB"。第二种写法容易让人误以为栈是从0x2001FC00往上长的。
堆的写法就正常了,因为堆是从低往高分配的:
ARM_LIB_HEAP 0x2001F000 EMPTY 0x00000800 { }提示:
ARM_LIB_STACK和ARM_LIB_HEAP是 ARM 标准 C 库(AC5 / AC6 的 armclang 标准库)识别的保留名。如果你用的是Microlib,情况就不一样了——Microlib 不走这套约定,它的__initial_sp通常由启动文件里的 EQU 定义直接给出。我在 STM32 工程里切成 Microlib 之后 sct 里的栈定义不生效,查了挺久才想明白是库的差异。用 Microlib 的时候,记得去startup_xxx.s里确认__initial_sp的值和 sct 的规划一致,否则两边打架。
4.2 栈大小到底该给多少
这是个没法拍脑袋的问题,给出太小会 HardFault,给太大浪费 RAM。我的经验是分两步走。
第一步,粗估。把所有可能深递归、或者带大局部数组的函数排查一遍,取最深的那条调用链。局部数组是最容易失控的,一个uint8_t buf[2048]放在函数里,就相当于给这条链路加了 2KB 栈需求。我的习惯是:超过 256 字节的局部数组,一律改成static或全局,从根上避免栈炸。
第二步,实测。armlink 有一个--callgraph选项,在Options for Target → Linker → Misc controls里加上它,重新链接后会在工程目录生成 HTML 格式的调用图。里面会给出每个函数的栈开销估计,以及静态分析推断出的最大栈深度。这个工具对函数指针调用和递归是无效的,结果只能当参考,但它能帮你发现"某个函数莫名其妙占 1KB 栈"这类意外。
真实项目里的做法:先用--callgraph给个下限,然后乘以 1.5 到 2 倍的安全余量,再跑一轮压力测试(把所有中断同时打开、往最深路径跑)。测的时候有个技巧——在sct里把栈区后面紧跟的那块 RAM 填成固定魔数0xDEADBEEF,跑完看魔数被踩掉多少,就知道真实峰值了。
4.3 堆要不要留
如果工程里完全不用malloc/free/calloc,堆可以留 0(甚至不写ARM_LIB_HEAP)。但要注意:即使你不主动调malloc,某些 C 库函数(比如printf的某些实现、sprintf的大缓冲)也可能隐式申请堆内存,还有init阶段的 locale 相关分配。所以我的默认做法是留 512 字节到 1KB 的堆,纯粹当保险。
嵌入式项目里长期用malloc是个危险习惯,内存碎片在跑几天之后会变成随机崩溃,而且极难复现。要是需要动态内存,我一般自己写一个固定块大小的内存池,用 sct 里划出的一块固定区域,完全不依赖 C 库的堆。
5. 硬定位:把变量和函数钉在指定地址
需求场景很实在:固件版本号要放在固定 Flash 地址供上位机读取、单片机双核共享内存、DMA 描述符必须落在特定 RAM 段。这类需求都得靠 sct 加编译器属性配合完成。
5.1 AC5 的at和 AC6 的变化
Arm Compiler 5(也就是 MDK 5.36 之前默认自带的那个armcc)里,定点定位写得非常直白:
__attribute__((at(0x20000000))) uint32_t g_shared_flag; __attribute__((at(0x0800F000))) const uint8_t g_version[16] = "V1.2.3";编译器会把变量放进.ARM.__at_0x20000000这样的特殊段,链接器看到段名里的地址就直接照办,不需要你在sct里写任何东西。
到了 Arm Compiler 6(armclang),at属性被移除了。AC6 的等价写法是把地址编进段名:
__attribute__((section(".ARM.__at_0x20000000"))) uint32_t g_shared_flag; __attribute__((section(".ARM.__at_0x0800F000"))) const uint8_t g_version[16] = "V1.2.3";.ARM.__at_这个前缀是链接器识别的魔法字符串,看到它就会自动创建一个对应的执行域,地址从段名里解析。这是一个隐式约定,段名里必须用 12 位十六进制、必须带0x前缀,少写一位或者多写空格都会静默失效,然后变量被丢到默认区域去,代码照跑,只是地址不对。我就遇到过版本号没生效的情况,最后发现是段名写成0x800F000少了0x0。
注意:
at属性和.ARM.__at_段名都只能用在单个变量上,别指望把一个结构体数组按字节拆到不同地址。另外这两种方式定位的区域不能被.ANY覆盖,如果 sct 里已经有一块区域覆盖了同样的地址,链接器会报 L6221E 地址重叠。
5.2 自定义 section + sct 挂载:我更推荐的做法
.ARM.__at_的问题是所有地址信息都藏在 C 代码里,地址规划散落各处,工程大了以后很难维护。我的习惯是反过来:让 C 代码只声明逻辑段名,地址统一在sct里管。
C 代码侧:
/* 注意:这里用 4 字节对齐,名字用大写加下划线,和 sct 里的写法严格一致 */ __attribute__((section("SHARED_BUF"), aligned(4))) uint8_t g_shared_buf[256]; __attribute__((section("FW_VERSION"), used)) const char g_fw_version[] = "V1.2.3-built-20240501";sct侧:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } ER_VERSION 0x0807F000 FIXED 0x00001000 { *FW_VERSION } RW_IRAM1 0x20000000 0x0001FC00 { .ANY (+RW +ZI) } ER_SHARED 0x2001FC00 UNINIT 0x00000400 { *SHARED_BUF } }这里有几个关键点值得拆开说。
第一,*FW_VERSION里的*是模块选择器,FW_VERSION是段名。段名大小写敏感,C 代码写的是FW_VERSION,sct 里就必须一模一样。我习惯用全大写加下划线,避免和编译器自动生成的段名(都是.text、.data、.bss这类小写点开头)撞车。
第二,FIXED和UNINIT一起用在这里是有意为之。FIXED让ER_VERSION必须落在0x0807F000,如果长度超了 4KB,链接器直接报错,不会偷偷挪位置。UNINIT让ER_SHARED不被启动代码清零,适合放跨复位的共享数据。
第三,used属性不能漏。g_fw_version如果没有任何代码引用它,编译器在开启优化时会把它当死代码删掉,段自然就不存在了,链接器找不到FW_VERSION就报 L6218E。加used是告诉编译器"别管我有没有人用,留着"。
第四,aligned(4)建议显式加上。.ANY分配的区域天然满足对齐,但自定义段不一定,尤其是有 DMA 或者原子访问需求的时候。
5.3ABSOLUTE和FIXED的差别
这两个属性在不同资料里容易被混着讲,实际差别很大。
FIXED约束的是区域的位置:这个区域必须放在这个基地址,放不下就报错。它不改变区域内符号的寻址方式,编译器照样可以用 PC 相对寻址。
ABSOLUTE约束的是符号的性质:区域内的符号被当作绝对的、不可重定位的地址常量来处理。典型用途是配合define symbol把某个值和硬件寄存器地址绑定:
ER_REG 0x40000000 ABSOLUTE 0x00000100 { *REG_SPACE }老实说,在 Cortex-M 的常规应用里ABSOLUTE用得非常少,因为外设寄存器一般直接用指针访问,不需要链接器介入。真正需要它的是位置无关代码(PIC)或者需要把代码段作为数据表基址的场景。大多数情况下你需要的只是FIXED。
6. 报错和炸机现场:链接期和运行期要分开看
sct改坏了,症状分两类:一类是链接器当场翻脸,一类是板子跑着跑着挂了。这两类的排查思路完全不同。
6.1 链接期的报错,读懂比瞎改重要
armlink 的错误码都是Lxxxx格式,常见的几个我列一下,附上排查方向:
| 错误码 | 含义 | 排查方向 |
|---|---|---|
L6220E | 执行域超出长度限制 | 检查区域长度够不够,或者.ANY是否把内容挤过来了 |
L6221E | 两个执行域地址重叠 | 对照 MAP 里的地址范围,检查是不是手写基址算错了 |
L6406E | .ANY选择器没地方放了 | RAM 总量不足,或者某个区域留太长,重新分配 |
L6218E | 未定义符号 | 段名拼错、漏了used属性、或者缺源文件 |
L6915E | 半主机相关函数被引用 | 用了printf但没重定向,或者__use_no_semihosting冲突 |
L6769E | 段无法分配到任何区域 | 该段的属性没有任何选择器覆盖到 |
L6406E是最常见的一个,完整信息通常长这样:
.\Objects\demo.axf: Error: L6406E: No space in execution regions with .ANY selector matching main.o(.bss).翻译过来就是:main.o里的.bss段想找个.ANY区域塞,所有带.ANY (+RW +ZI)的区域都满了。这个报错的解决思路有两条:要么扩大 RAM 区域,要么去查是谁把 RAM 吃掉了。
查 RAM 消耗,MAP 文件里的 "Image component sizes" 段落是最好用的:
Code (inc. data) RO Data RW Data ZI Data Debug 45210 1802 1236 484 18236 ...ZI Data那一列的 18236 字节,全是零初始化数据,也就是全局/静态变量的未初始化部分。如果这个数字大得离谱,八成是某个u8 big_buffer[8192]忘了加const,被当成全局数组塞进 RAM 了。
我之前遇到过一个更隐蔽的:一个const修饰的查找表,看着应该是 RO Data,结果出现在 ZI Data 里。查了半天发现是代码里用了结构体数组做初始化,而结构体成员里有指针,链接器因为指针需要重定位,把这个表挪到了 RW 区。这种情况要么改成索引访问,要么把它显式挂到 RO 区域去。
6.2 运行期的崩溃,从 CFSR 开始查
改完sct之后如果板子直接进 HardFault,别急着改回来。先看SCB->CFSR(Configurable Fault Status Register,地址0xE000ED28)。这个寄存器把故障分成三类,每一位都对应一个具体原因:
- MMFSR(bit 0-7):内存管理错误,
DACCVIOL/IACCVIOL表示数据/指令访问违规。如果sct把某段代码放到了不可执行的区域(比如某些芯片的 CCM RAM),执行时会触发IACCVIOL。 - BFSR(bit 8-15):总线错误,
PRECISERR/IMPRECISERR/IBUSERR/UNSTKERR/STKERR。其中UNSTKERR和STKERR是中断进出栈失败,出现这两个基本可以确定是栈溢出或者栈指针被踩。 - UFSR(bit 16-31):用法错误,
DIVBYZERO/UNALIGNED/INVSTATE/INVPC。UNALIGNED出现说明代码里有非对齐访问,可能和sct里段对齐设置有关。
我的做法是在 HardFault 处理函数里把这几个寄存器打印出来,同时抓SCB->HFSR和当前SP、LR。有了这些信息,用addr2line反查地址对应的函数,定位速度能快十倍。
针对sct改动引起的崩溃,我总结了一份排查清单:
- 栈区是不是被别的段覆盖了?在 MAP 里找
ARM_LIB_STACK,看它的地址范围和RW_IRAM1有没有重叠。 - 搬运源地址对不对?检查
Load$$RW_IRAM1$$Base是不是落在 load region 的地址范围内。 UNINIT段有没有被误当成普通 ZI?如果启动时发现保留数据被清掉了,用UNINIT重新标注。- 中断向量表地址是否更新?只要 App 起始地址变了,
VTOR必须跟着改。
6.3 Bootloader 场景的三处一致性检查
做 IAP 升级的时候,App 固件的起始地址从0x08000000挪到0x08008000,一共涉及三个地方的修改,缺一个都会出问题:
第一处,sct文件里 LR 和 ER 的基地址。LR_IROM1 0x08008000 0x00078000 { ER_IROM1 0x08008000 0x00070000 { ... } },注意 load region 的长度要按剩余空间缩小,别还写着0x80000,那样会越界。
第二处,Options for Target → Target页面的 IROM1 起始地址。即使你已经手动指定了sct,Target 页面里的这个值仍然会影响 MDK 生成的一些辅助信息。地址不一致时,MDK 的调试器在下载时可能会警告。
第三处,代码里对SCB->VTOR的赋值。通常写在system_xxx.c的SystemInit()里,或者用VECT_TAB_OFFSET宏控制:
#define VECT_TAB_OFFSET 0x8000U SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;三处都改了,中断才能正常响应。我见过有人只改了两处,烧进去之后能跑主循环,但一开定时器中断就死——因为中断向量表还在跳转到旧地址。
顺带说一句,如果 Bootloader 和 App 里有同名段(比如都定义了RAM_CODE段),sct里的选择器会同时匹配两边。这种情况下要给段名加前缀区分,比如BOOT_RAM_CODE和APP_RAM_CODE。
7. 和其他工具链的对照:概念是通的,写法不一样
如果只做 MDK 项目,这一节可以跳过。但只要碰过 GCC 或者 IAR,总会碰到"同一个需求换个工具链怎么写"的问题。好在概念层面是共通的,我把对照关系整理成一张表:
| 概念 | MDK(armlink) | GCC(ld) | IAR(ilink) |
|---|---|---|---|
| 脚本文件 | .sct | .ld | .icf |
| 加载域 | Load Region | LOADADDR/AT> | region+place in |
| 执行域 | Execution Region | 输出段> RAM | place in RAM |
| 段放置 | *(+RO) | *(.text*) | ro section |
| 段符号 | Image$$X$$Base | _sdata/_edata | __section_begin |
| 栈堆 | ARM_LIB_STACK | _estack/_Min_Heap_Size | __ICFEDIT_size_cstack__ |
用 GCC 的时候有个坑值得提醒:GCC 的链接脚本里,.data的AT>和>分别指定加载地址和运行地址,跟 armlink 的 LR / ER 概念对应,但 GCC 需要你自己写_sidata、_sdata、_edata、_sbss、_ebss这些符号,然后在启动汇编里手写拷贝循环——不像 MDK 那样由 C 库的__scatterload自动完成。从 MDK 转到 GCC 的人,第一次写.ld经常会漏掉拷贝代码,结果所有带初值的全局变量都是 0。
反过来说,如果你在 GCC 项目里待久了再回 MDK,会觉得sct的语法其实很克制——它不需要你描述搬移过程,只描述内存布局,剩下的交给库。这也是我一开始说的那句话:sct是协议,不是程序。
至于 IAR 的.icf,语法风格更接近声明式,define region、define block、place in三段式,比sct啰嗦一些但可读性更好。三者文档都值得翻一遍,不是为了全用,而是为了在换平台的时候能迅速找到对应关系,不至于从零开始。
最后再分享一个小技巧:把sct文件纳入版本管理,并且在文件头用注释写清楚每块区域的用途、尺寸和设计依据。我经手的项目里,sct是最容易被后人随意改动的文件之一——因为它看起来只是一堆数字。等半年后 Flash 或 RAM 不够用了,没人记得当初为什么给某个区域留了 4KB。写清楚注释,是给未来的自己省两个小时的排查时间。