
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 regionER_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 imageLoad 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$$BaseER_IROM1 的起始地址Image$$ER_IROM1$$LimitER_IROM1 的结束地址不含Image$$ER_IROM1$$LengthER_IROM1 的长度Image$$RW_IRAM1$$BaseRW 数据在 RAM 的起始地址Image$$RW_IRAM1$$LimitRWZI 的结束地址Load$$RW_IRAM1$$BaseRW 初值在 Flash 中的起始地址Image$$RW_IRAM1$$ZI$$BaseZI 段的起始地址Image$$RW_IRAM1$$ZI$$LengthZI 段的长度有个常见需求是做内存自检要算整个 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 6armclangat属性被移除了。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_VERSIONsct 里就必须一模一样。我习惯用全大写加下划线避免和编译器自动生成的段名都是.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-CFSRConfigurable Fault Status Register地址0xE000ED28。这个寄存器把故障分成三类每一位都对应一个具体原因MMFSRbit 0-7内存管理错误DACCVIOL/IACCVIOL表示数据/指令访问违规。如果sct把某段代码放到了不可执行的区域比如某些芯片的 CCM RAM执行时会触发IACCVIOL。BFSRbit 8-15总线错误PRECISERR/IMPRECISERR/IBUSERR/UNSTKERR/STKERR。其中UNSTKERR和STKERR是中断进出栈失败出现这两个基本可以确定是栈溢出或者栈指针被踩。UFSRbit 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 起始地址。即使你已经手动指定了sctTarget 页面里的这个值仍然会影响 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总会碰到同一个需求换个工具链怎么写的问题。好在概念层面是共通的我把对照关系整理成一张表概念MDKarmlinkGCCldIARilink脚本文件.sct.ld.icf加载域Load RegionLOADADDR/ATregionplace in执行域Execution Region输出段 RAMplace 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。写清楚注释是给未来的自己省两个小时的排查时间。