做嵌入式开发,尤其是用 Keil MDK 做 ARM Cortex-M 项目的人,迟早会遇到这么一个时刻:Options for Target 里那两行 IRAM1、IROM1 已经装不下你的需求了,链接器开始报 L6406E、L6220E,或者程序能编译过但一跑就 HardFault。这时候你去翻资料,所有人都会告诉你一句话——去改 sct 分散加载文件。可真打开那份xxx.sct,密密麻麻的花括号、+RO、.ANY、EMPTY -0x400,看着像天书。我最早接触 sct 是在做一个带 IAP 升级和外部 SDRAM 缓存的项目,那会儿完全是照着别人的模板抄,抄完能跑就不敢动了,直到某次量产固件在客户现场偶发跑飞,才老老实实把这份脚本从头到尾啃了一遍。这篇文章就是那次啃完之后整理的,从"为什么需要它"讲到"怎么写、怎么调、怎么避坑",不管你是刚学会点灯的新手,还是已经在改链接脚本的老手,都能从里面找到能直接抄作业的东西。
1. 默认布局撑不住的时候,sct 才真正登场
1.1 Target 对话框里那两行 IRAM1 / IROM1 是怎么工作的
大多数人的 Keil MDK 工程都是从别人模板或者芯片厂商例程里复制出来的,Target 页面上 IROM1 填0x08000000、大小0x80000,IRAM1 填0x20000000、大小0x20000,然后一路勾着 "Use Memory Layout from Target Dialog" 编译到量产。这套配置之所以能撑很久,是因为它背后其实已经存在一份隐式的分散加载文件——链接器 armlink 会根据这两个区域自动生成一份脚本,把所有只读代码和常量丢进 IROM1,把所有已初始化变量和零初始化变量丢进 IRAM1。
这份自动生成的脚本只包含一条极其朴素的规则:全体成员按声明顺序往两个筐里塞,塞满为止。换句话说,它假设你的芯片只有一块连续 Flash、一块连续 RAM,而且所有代码和数据都可以混着放。这个假设在 F103C8 这种小容量芯片上永远成立,但只要你换一颗有 CCM RAM 的 F407、有 TCM 的 H7、或者外挂了 SRAM/SDRAM 的板子,它立刻就不成立了。你会在 map 文件里看到一堆.ANY被硬塞进 IRAM1,而那块空闲的 64KB CCM 从头到尾一个字节都没用上。
1.2 分散加载文件到底决定了什么
sct 的全称是 scatter-loading description file,中文一般叫分散加载文件或者分散加载描述文件,扩展名是.sct。它本质上是一份交给 armlink 的"内存分配说明书",告诉链接器三件事:第一,芯片上有哪些可用的物理内存块,各自的起始地址和容量是多少;第二,哪些目标文件、哪些段应该被放到哪一块里去;第三,哪些区域需要在启动阶段从 Flash 搬运到 RAM,搬运的源地址和目标地址分别是什么。
第三点是最容易被忽略、也最关键的一点。很多人以为 sct 只是"换个地方放变量",其实它还决定了__main里那段 scatter loading 代码的行为——启动文件跳转到__main之后,C 库会读取链接器生成的Region$$Table表,按照表里的记录逐条把 RW 段的初值从加载地址复制到执行地址,再把 ZI 区域清零,最后才跳到main()。你在 sct 里写的每一个RW_xxx区域,都会在启动阶段变成一次实质的内存拷贝操作。理解了这一点,后面那些"为什么变量放在 SDRAM 里会挂"的问题就都有答案了。
1.3 这几类项目,基本躲不开手写 sct
按我的经验,下面几类场景基本上没有绕过去的可能。第一种是芯片有多块物理上不连续的 RAM,比如 STM32F407 的 128KB 主 SRAM 加上 64KB CCM RAM,或者 STM32H743 的 DTCM、AXI SRAM、SRAM1~SRAM4 一大堆,你希望把中断栈、堆、DMA 缓冲区、算法中间变量分别放到不同的块里,避免互相踩踏。第二种是有外部存储器,比如挂了一颗 SDRAM 做图像帧缓存,或者挂了一颗 QSPI Flash 存字库,这些都需要在 sct 里显式声明区域并指定执行地址。
第三种是 IAP / Bootloader 场景,Boot 和 App 各自占用 Flash 的不同区段,App 的起始地址必须精确到 0x08004000 这种偏移上,而且中断向量表的偏移还需要在代码里配合SCB->VTOR一起改。第四种是产品需要在 Flash 里划出一块参数区、日志区或者备份区,这些区域不希望被编译器随机塞进代码里,必须用 sct 里的EMPTY或者带特定 section 名的执行域硬性锁定。第五种是安全相关需求,比如把某些包含密钥的代码段标记成+XO(execute-only,只可执行不可读),在支持的器件上防止被直接 dump 出来。
2. 把语法吃透:加载域、执行域与选择器
2.1 加载域与执行域,一字之差差了整个启动流程
sct 里最核心的两个概念就是加载域(Load Region,通常写成 LR_xxx)和执行域(Execution Region,通常写成 ER_xxx)。加载域描述的是"这段内容在烧录进芯片时躺在哪里",执行域描述的是"这段内容在程序运行时应该在哪里"。这两者地址相同的时候,不需要任何搬运;地址不同的时候,链接器会自动帮你生成复制代码,在启动阶段完成搬运。
看一个最常见的骨架:
LR_IROM1 0x08000000 0x00080000 { ; 加载域:起始 0x08000000,最大 512KB ER_IROM1 0x08000000 0x00080000 { ; 执行域:与加载域同址,无需搬运 *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) .ANY (+XO) } RW_IRAM1 0x20000000 0x00020000 { ; 执行地址在 RAM .ANY (+RW +ZI) } }这里RW_IRAM1的执行地址是 0x20000000,但它并没有自己的加载域嵌套写法,说明它的初值仍然保存在 LR_IROM1 这个加载域里,运行时由启动代码搬到 0x20000000。这就是"分散加载"这个名字的由来——同一份镜像,逻辑上被分散到了 Flash 和 RAM 两个地方。花括号的嵌套关系不能乱:执行域必须写在加载域内部,表示它的初值来自这个加载域;如果一个执行域压根不需要初始化(比如纯 ZI 区、栈、堆),它既可以写在加载域外面,也可以用EMPTY关键字声明。
注意:
*(InRoot$$Sections)这一行几乎是必须保留的。它包含了 C 库启动所需的那些特殊段,尤其是__scatterload相关的代码段。早期很多人手写 sct 时把它删了,结果编译链接通过、烧进去直接死在__main里,查半天查不出来。
2.2 选择器:把某个 .o 精确投放到指定区域
链接器靠"选择器"来决定具体哪些内容进哪个区域。选择器写在执行域的花括号里面,基本形式是匹配模式 (属性列表)。常见写法有这几种:
*.o (RESET, +First):所有目标文件里名字叫 RESET 的段,且强制排到区域最前面。Cortex-M 的中断向量表就靠这一行保证落在 Flash 起始位置。*(InRoot$$Sections):匹配 C 库启动相关的特殊段,不要动它。.ANY (+RO):把所有还没被分配出去的只读内容,按顺序填进这个区域。.ANY (+RW +ZI):把剩下的读写数据和零初始化数据都放进来。main.o (+RO):只把main.o这个目标文件的只读内容放进来,粒度精确到文件。*ARM_LIB* (+RO):匹配 C 库里的内容,通常用于把库函数单独隔离到某个区域。*(.ccmram):匹配所有名字叫.ccmram的段,这是你自己在 C 代码里用__attribute__((section(".ccmram")))定义的。
.ANY的分配顺序是有讲究的,链接器按照执行域在文件里出现的先后顺序,先把靠前的区域填满再填后面的。所以如果你有两个RW_IRAM1和RW_IRAM2,想让某个大数组优先落在 IRAM2,就得把 IRAM1 的规则写得更严格(比如只接收确定的小对象),而不是指望.ANY自己聪明。另外还有.ANY1、.ANY2这种带优先级编号的写法,用来解决多个.ANY在同一区域内的先后顺序问题,用到的场景不多,但知道有这么个东西,遇到怪异现象时能多一条排查方向。
2.3 属性关键字与保留区:+First、+Last、EMPTY、UNINIT
属性列表里的关键字看着多,常用的其实就几个。+First和+Last控制段在区域内的相对位置,最典型的就是中断向量表必须+First。+RO、+RW、+ZI、+XO分别对应只读、读写、零初始化、只可执行四种属性,其中+XO在支持 TrustZone 的器件上才有实际意义,普通器件上会被当作普通 RO 处理。
EMPTY用来声明一块"只占位、不放内容"的保留区,栈和堆就是这么划出来的。举个完整的例子:
RW_IRAM1 0x20000000 0x0001C000 { .ANY (+RW +ZI) } ARM_LIB_HEAP 0x2001C000 EMPTY 0x00000400 { ; 堆:16KB 里的 1KB } ARM_LIB_STACK 0x20020000 EMPTY -0x00004000 { ; 栈:贴着 RAM 顶端向上取 16KB }EMPTY -0x00004000这种负长度写法,意思是这块区域贴着给定地址向下排布,地址填的是区域的末端(高地址),链接器自己往前推算起点,省得你手动做减法。这个写法配合"栈从高地址向低地址生长"的硬件行为非常自然,__initial_sp会自动指向栈区的顶端。
注意:用标准库的时候,栈和堆通常还是由启动文件里的
Stack_Size、Heap_Size两个常量定义,sct 里再划ARM_LIB_STACK/ARM_LIB_HEAP容易出现两套定义打架的现象。只有切到 microlib 时,才必须由 sct 来指定栈堆保留区,这时候 uVision 自动生成的脚本里也会带上这两行。选库和写 sct 这两件事,得一起考虑。
UNINIT是另一个值得记住的关键字,它告诉链接器"这块区域启动时不要清零"。它的典型用途是做软复位保持——某些变量需要在看门狗复位之后仍然保留原值,只要把它们放到UNINIT标记的执行域里,__main的 ZI 清零流程就会跳过这块区域。这个技巧在做故障记录、复位原因统计的时候特别好用,比去啃 RTC 备份寄存器省事得多。
2.4 链接器会悄悄给你一堆符号
写完 sct 之后,链接器会自动生成一批形如Image$$xxx$$Base的符号,你在 C 代码里extern一下就能拿到区域的实际地址和长度。这是做内存管理、参数存储、自检功能的钥匙,常用的几个列在下面:
| 符号名 | 含义 |
|---|---|
Image$$RW_IRAM1$$Base | 执行域 RW_IRAM1 的起始地址 |
Image$$RW_IRAM1$$Limit | 执行域 RW_IRAM1 的结束地址(不含) |
Image$$RW_IRAM1$$Length | 执行域 RW_IRAM1 的总长度 |
Image$$RW_IRAM1$$ZI$$Base | 该区域中 ZI 部分的起始地址 |
Image$$RW_IRAM1$$ZI$$Limit | 该区域中 ZI 部分的结束地址 |
Load$$LR_IROM1$$Base | 加载域 LR_IROM1 的起始地址 |
Image$$ER_IROM1$$RO$$Length | ER_IROM1 中只读内容的总长度 |
有两点必须提醒。第一,符号名里的区域名必须和 sct 里写的一模一样,大小写敏感,写错了链接器不会报错,只会在使用处报未定义。第二,在 ARM Compiler 6 下引用这些符号时,要用extern uint32_t Image$$RW_IRAM1$$Base;然后取&Image$$RW_IRAM1$$Base,不能直接当数组名用,因为它本质上是个符号地址而不是变量。我见过不止一个人在这里翻车,编译过了但取到的值是垃圾。
3. 从算账到落地:完整写一份可用的 sct
3.1 先把 Flash 和 RAM 的账算清楚
动手写脚本之前,先做一张内存清单。打开芯片的参考手册,把 Memory Map 章节里的地址和容量抄下来,再结合自己的板子实际情况确认哪些块真正可用。有些 SRAM 块默认被某些外设占用,有些块在低功耗模式下会掉电,这些细节不搞清楚,脚本写得再漂亮也是白搭。
以 STM32F407ZGT6 为例,一张典型的清单长这样:
| 区域 | 起始地址 | 容量 | 特性 |
|---|---|---|---|
| 主 Flash | 0x08000000 | 1024 KB | 可读可执行,擦写粒度 2KB |
| SRAM1+SRAM2(连续) | 0x20000000 | 128 KB | 可被 DMA 访问 |
| CCM RAM | 0x10000000 | 64 KB | 内核直连,DMA 不可访问 |
| 备份 SRAM | 0x40024000 | 4 KB | 需开时钟,VBAT 供电 |
算账的时候还要留出余量:Bootloader 占 16KB,参数区占 16KB,那么 App 可用的 Flash 就是从 0x08004000 开始的 992KB;栈至少留 4KB,堆按 malloc 的实际使用量估,如果项目里用了文件系统或者网络协议栈,堆可能要 16KB 以上。这些数字不需要一次算准,但必须写下来,因为 sct 里所有的地址和长度都是从这张表里推导出来的。
3.2 一份 STM32F407 的完整脚本与逐行解读
下面这份脚本是我在一个实际项目里用过的简化版本,包含 Flash 分区、主 RAM、CCM RAM、栈堆保留区四个部分:
; ************************************************************* ; *** 分散加载描述文件 - STM32F407 + CCM RAM 分配示例 *** ; ************************************************************* LR_IROM1 0x08004000 0x000F0000 { ; 加载域:App 起始偏移 16KB ER_IROM1 0x08004000 0x000E0000 { ; 代码区:从 16KB 到 912KB *.o (RESET, +First) ; 中断向量表必须最前 *(InRoot$$Sections) ; C 库启动段,勿动 .ANY (+RO) ; 其余只读内容 } ER_PARAM 0x080E4000 0x00004000 { ; 参数区:固定 16KB,独立成域 *(.param_area) ; 只接收显式指定的参数段 } RW_IRAM1 0x20000000 0x0001C000 { ; 主 RAM 的 112KB .ANY (+RW +ZI) } RW_CCMRAM 0x10000000 0x00010000 { ; CCM RAM 全 64KB *(.ccmram) *(.ccmram_bss) } ARM_LIB_HEAP 0x2001C000 EMPTY 0x00004000 { ; 堆 16KB } ARM_LIB_STACK 0x20020000 EMPTY -0x00004000 { ; 栈 16KB,贴顶向下 } }逐段解释一下设计意图。加载域起始地址写成 0x08004000 而不是 0x08000000,是因为这段 Flash 前 16KB 留给了 Bootloader,App 只能从偏移处开始。ER_IROM1的长度故意比加载域小 64KB,腾出来的空间正好给参数区ER_PARAM用。参数区独立成一个执行域并且只接收.param_area这一个段名,好处是这块 16KB 的区域绝对不会被编译器随机塞代码进去,只要代码里没人定义.param_area段,它就是空的——而空的区域会被链接器完整保留在镜像里,正好用来放运行期写入的参数。
主 RAM 只给了 112KB 而不是全部 128KB,是因为剩下的 32KB 要留给栈和堆。CCM RAM 单独一个执行域,通过*(.ccmram)和*(.ccmram_bss)两个选择器精确匹配,只有显式声明了 section 属性的变量才会落进去。
对应的 C 代码写法是这样:
/* 放进 CCM RAM 的变量,注意 CCM 不能被 DMA 访问 */ __attribute__((section(".ccmram"))) float fft_in[1024]; __attribute__((section(".ccmram"))) float fft_out[1024]; /* 放进参数区的结构体,运行期可读写 */ __attribute__((section(".param_area"))) const uint8_t param_reserved[4096];注意:CCM RAM 的定位是"内核直连、DMA 不可达"。把 FFT 中间变量、栈、频繁访问的查找表放进去能明显提速,但千万别把 DMA 的收发缓冲区放进来,否则 DMA 传输看起来正常、数据永远是零,这个坑我在项目上见过两次,每次都能查半天。
3.3 多块内存怎么分:CCM、外部 SDRAM、双 Bank
拿到多块 RAM 之后,分配策略其实有一套通用套路。中断栈、主栈放主 RAM,因为它离 DMA 近、容量大;算法密集型的中间变量放 CCM 或者 DTCM,速度最快;DMA 收发缓冲区单独划一小块,最好按 Cache Line 对齐(如果芯片带 Cache),避免 Cache 一致性问题;大块的显示缓存、音频缓冲放外部 SDRAM,但前提是 SDRAM 控制器已经在启动阶段初始化好了。
这里有个非常经典的坑必须单独说:分散加载的搬运发生在__main里、main()之前。如果你把带初值的 RW 变量(比如int buf[1024] = {1,2,3})放进外部 SDRAM 的执行域,链接器会生成"从 Flash 复制到 SDRAM"的启动代码,而这段代码执行的时候,你的 FMC 控制器还没来得及配置,SDRAM 根本不可访问——结果是程序在启动阶段直接 HardFault,或者悄无声息地跑飞。正确的做法是让 SDRAM 区域只接收 ZI 段或者用UNINIT标记,并且在main()之后、使用这块内存之前,手动完成初始化。
外部 SRAM 也是同理。至于双 Bank Flash 的器件,可以把常量表、字库、音频数据放到 Bank2,代码放 Bank1,这样擦写 Bank2 的时候不影响 Bank1 上运行的代码,做 OTA 升级的时候特别有用。这类配置在 sct 里就是多加一个加载域而已,语法上没有任何新东西。
3.4 在 uVision 里挂上去并验证
脚本写完,接下来是挂载。步骤很固定:打开 Options for Target -> Linker 选项卡,把 "Use Memory Layout from Target Dialog" 前面的勾去掉,然后在 Scatter File 那一栏填上你的脚本路径,比如.\Project\app.sct。这一步做完,Target 页面里的 IRAM1、IROM1 配置就彻底失效了,链接器只认你的脚本,很多人改完设置忘了这一点,还在那边调 IRAM 大小,白折腾半天。
第一次操作有个小技巧值得记住:先不要急着写自己的脚本,把勾去掉之后直接编译一次,Keil 会在工程目录下生成一份与工程同名的 sct 文件,内容就是根据你原来 Target 配置推导出来的默认脚本。把这份文件另存一份作为基础,在它上面改,比对着空白文件从零写靠谱得多,也不会漏掉InRoot$$Sections这类必须保留的行。
验证环节同样重要。编译通过只说明语法没问题,还要看两样东西:一是 Build Output 里打印的 Program Size,Code、RO-data、RW-data、ZI-data四个数字和你预期的量级是否吻合,如果 ZI-data 突然暴涨,说明某块区域被.ANY意外塞满了;二是打开工程输出目录下的.map文件,搜索你的执行域名,可以看到每个区域的起止地址、已用大小、剩余空间,还能看到每个目标文件贡献了多少字节。想让报告更详细,可以在 Linker 的 Misc controls 里加一个--info=sizes,链接器会把每个目标文件的大小都列出来,做内存优化的时候非常有用。
4. 报错与跑飞:那些年踩过的坑
4.1 链接期报错速查表
sct 写错之后,链接器的报错信息其实是相当直白的,只是第一次见容易懵。下面这张表是我这些年积累下来的高频错误清单:
| 报错编号 | 典型信息 | 根本原因与处理方式 |
|---|---|---|
| L6220E | Execution region ER_IROM1 exceeds its limit | 执行域实际内容超过声明的长度,要么扩大区域,要么精简代码,注意.ANY会把多余内容硬塞进来 |
| L6406E | No space in execution regions with .ANY selector | 所有能接受.ANY的区域都满了,通常是某个区域长度写小了,或者忘记给某块 RAM 建执行域 |
| L6221E | Execution region overlaps with another | 两个执行域的地址区间重叠,回去核对起始地址和长度的加法,尤其是手算偏移的时候 |
| L6223E | Load region overlaps with another | 加载域重叠,IAP 场景下最容易出现,Boot 和 App 的边界必须严格对齐 |
| L6314W | No section matches pattern | sct 里写了*(.xxx)但代码里没人定义.xxx段,属于警告,但往往意味着你的 section 名字拼错了 |
| L6329W | Pattern only matches removed unused sections | 段被--remove优化掉了,如果确实需要保留,加-keep选项 |
| L6218E | Undefined symbol Image$$xxx$$Base | 引用的区域名和 sct 里不一致,检查大小写和拼写 |
修 L6220E 和 L6406E 的时候,有个思路建议优先试:先把报错涉及的执行域长度临时放大,看看能不能过,能过就说明纯粹是空间不够;如果放大之后报重叠错误,那就说明是地址规划错了,得回去重新做内存清单。这两种情况看起来都是"空间问题",但解决路径完全不同,先分清楚再动手,能省不少时间。
4.2 运行期跑飞、HardFault 与内存踩踏
链接期不报错,不代表运行期不出事。sct 相关的运行期故障,绝大多数都指向同一个根因:某块内存实际不可用或者被重复使用。中断向量表被搬到了错误的地址,一进中断就跳飞;栈被划到了 SDRAM 里但 SDRAM 还没初始化,第一次函数调用就崩;.ANY把堆之外的大数组分到了同样的地址区间,两个变量互相改写,表现为"某个变量莫名其妙变了值"。
排查这类问题的顺序我一般是这样走的。第一步,看 map 文件里各个执行域的实际地址和长度,确认没有任何两块内存地址区间相交。第二步,检查Image$$ARM_LIB_STACK$$Base和Limit的值是不是落在合法的 RAM 范围内,栈指针初始化是否正确,用调试器看一眼__initial_sp的实际取值。第三步,如果用了 CCM 或者外部 RAM,确认对应的硬件在访问之前已经初始化完毕。第四步,在最开始的怀疑点附近放几个魔数,用调试器观察是否被改写,定位踩踏范围。
调试器在这里帮不上太大忙的是 Cache 一致性问题。带 Cache 的 Cortex-M7、M55 器件,如果某块内存被 DMA 修改而 CPU 侧还读的是 Cache 里的旧值,现象就是"数据偶尔不对"。这类问题用 sct 解决的方式是把 DMA 缓冲区划到非 Cache 区域,或者配置 MPU 把对应区域设成 Write-Through / Non-Cacheable,光改 sct 是不够的,必须配合 MPU 一起做。
4.3 IAP / Bootloader 场景下的 sct 配合
IAP 场景下 sct 的写法有一套固定套路,但代码侧必须同步改,这是很多教程没讲清楚的地方。Boot 的 sct 按正常写法,起始地址 0x08000000,长度 16KB;App 的 sct 起始地址改成 0x08004000,同时加载域和第一个执行域都要改,长度相应减少。
代码侧要做两件事。第一,App 启动之后的第一件事就是重定位中断向量表:
/* App 入口处执行,APP_BASE 与 sct 中的起始地址保持一致 */ #define APP_BASE 0x08004000U SCB->VTOR = APP_BASE; __DSB(); __ISB();第二,如果 Boot 和 App 都用同一套中断向量表文件,要注意向量表在 Flash 中的绝对位置由RESET段的+First属性保证,只要ER_IROM1的起始地址改对了,+First会自动把向量表放到新的起始位置。如果这两处对不上,现象是 App 能跑但一进中断就复位,属于典型症状,看到了直接往这个方向查。
还有一个容易踩的细节是镜像填充。Boot 通过串口或者 CAN 接收固件时,往往按固定长度分块写入 Flash,如果编译出来的 bin 文件长度不是块大小的整数倍,最后一块就会写不全或者写到区域外面。解决办法是让链接器做填充,在 sct 里给对应执行域加一个+Last的填充段,或者用工具在 bin 生成之后补 0xFF。两种做法都行,我个人更偏向后者,因为填充值是可控的。
4.4 版本、工具链与安装渠道上的坑
从 MDK 5.36 往后,新建工程默认使用 Arm Compiler 6(AC6),老工程里用的 AC5 需要单独安装再手动切换。两者对 sct 的支持主体一致,但有几个差异点必须留意:AC6 对.ANY的分配顺序处理更严格,某些在 AC5 下"恰好能过"的写法在 AC6 下会直接报 L6406E;AC6 的段命名和优化策略有变化,--remove的默认行为更激进,容易被误删的段反而更多。老工程往 AC6 迁移的时候,第一件事应该是把 sct 里所有的.ANY都补上明确的属性,别依赖隐式分配。
另外还有一个实际问题——工具链的获取渠道。网上流传的某些网盘安装包版本不明、来源不清,有的被塞了额外的组件,有的授权文件被改过,装上之后编译出来的镜像体积莫名变大,或者链接阶段出现无法解释的行为。我个人的建议是固定用一个来源可靠、版本号明确的安装包,并且在团队内部记录清楚版本和补丁号,出问题时能快速对齐环境。链接脚本这种东西对不同编译器版本的敏感度,比大多数人想象的要高得多。
顺便说一句,网上偶尔会看到把 sct 这三个字母和其他领域缩写搞混的提问,那完全是两码事。本文说的 sct 只指 Arm 链接器使用的分散加载脚本文件,扩展名.sct,别的语境下的同名缩写跟这个话题没有任何关系,看到的时候别混。
5. 进阶用法:让 sct 承担更多职责
5.1 参数区、日志区与备份区的划分
前面提到过用独立执行域划参数区,这里把玩法再展开一点。产品里常见的非易失数据有三类:出厂标定参数、运行期配置、故障日志。这三类数据的更新频率、写入粒度、掉电保持要求都不一样,用 sct 把它们划到 Flash 的不同扇区里,可以避免"改一个参数把日志也擦了"这种尴尬。
做法是在 sct 里定义多个只接收特定段名的执行域,然后在 C 代码里用__attribute__((section(...)))把对应的结构体投进去。这样做还有额外的好处:参数区的起始地址可以通过Image$$ER_PARAM$$Base在代码里直接取到,不需要在代码里硬编码 0x080E4000 这种魔法数字,将来 Flash 分区调整了,改 sct 就行,C 代码一行都不用动。同理,参数区的长度也能拿到,写个运行时自检函数检查一下结构体大小有没有超过区域容量,编译期就能发现问题。
日志区通常只需要写、不需要读,用同样的方式划出固定长度的区域,配合一个环形缓冲的写入逻辑就行。需要注意的是 Flash 擦除粒度一般是 2KB 或者 4KB,日志区大小最好是擦除粒度的整数倍,否则跨扇区写入会很难处理。
5.2 与 map 文件、优化选项配合做内存体检
工程做到后期,内存吃紧是常态,这时候 sct 和 map 文件就该一起用了。我的习惯是每隔一段时间做一次"内存体检":打开 map 文件,看每个执行域的剩余空间,找出占用最大的几个变量和函数;对那些常年没人调用却占据几 KB Flash 的库函数,考虑用--remove或者干脆不链接那个库;对那些只在初始化阶段用一次的大数组,考虑加const丢到 Flash 里去。
这里有个小技巧值得分享:--info=sizes加上--info=summarysizes两个链接选项一起用,Build Output 里会输出一份按大小排序的目标文件清单和一份区域汇总,比翻 map 文件快得多。我曾经靠这个发现在某个项目里,一个第三方协议栈静态链接了 30 多 KB 的代码,而实际只用到其中三个函数,换成手动裁剪之后 Flash 直接省下 20%,成本比换芯片低多了。
优化选项也会影响 sct 的效果。开了 LTO(链接时优化)之后,一些原本独立的目标文件会被合并,.ANY的分配结果可能和预期不一样;-Ospace和-Otime对代码体积的影响有时能达到 10% 以上。这些选项和 sct 是互相影响的,调内存布局的时候最好固定住优化选项,不要一边改脚本一边改优化,否则问题定位会变成玄学。
还有一点,如果项目里同时有多个 Target(比如 Debug 和 Release 用不同的 sct),记得把脚本文件纳入版本管理,并且和工程的.uvprojx一起提交。我吃过一次亏:同事在本地改了 sct 但没有提交,另一个人的电脑上重新编译出来的固件行为的完全不一样,查了两天才发现是脚本文件的差异。链接脚本是构建产物的一部分,不是"环境配置",这个观念值得所有人都建立起来。
我个人在实际操作中的体会是,sct 这份文件最忌讳"抄完就不动"。它不像业务代码那样天天改,但每次芯片换型、分区调整、加入新外设,它都可能是那个最先出问题的环节。建议在每个项目的主分支里给它写一段注释头,标明每个区域的用途、容量、谁负责维护,下次再打开这份文件的时候,你或者你的同事能一眼看懂当初为什么这么分。