做嵌入式开发尤其是用 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、大小0x80000IRAM1 填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 名的执行域硬性锁定。第五种是安全相关需求比如把某些包含密钥的代码段标记成XOexecute-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$$LengthER_IROM1 中只读内容的总长度有两点必须提醒。第一符号名里的区域名必须和 sct 里写的一模一样大小写敏感写错了链接器不会报错只会在使用处报未定义。第二在 ARM Compiler 6 下引用这些符号时要用extern uint32_t Image$$RW_IRAM1$$Base;然后取Image$$RW_IRAM1$$Base不能直接当数组名用因为它本质上是个符号地址而不是变量。我见过不止一个人在这里翻车编译过了但取到的值是垃圾。3. 从算账到落地完整写一份可用的 sct3.1 先把 Flash 和 RAM 的账算清楚动手写脚本之前先做一张内存清单。打开芯片的参考手册把 Memory Map 章节里的地址和容量抄下来再结合自己的板子实际情况确认哪些块真正可用。有些 SRAM 块默认被某些外设占用有些块在低功耗模式下会掉电这些细节不搞清楚脚本写得再漂亮也是白搭。以 STM32F407ZGT6 为例一张典型的清单长这样区域起始地址容量特性主 Flash0x080000001024 KB可读可执行擦写粒度 2KBSRAM1SRAM2连续0x20000000128 KB可被 DMA 访问CCM RAM0x1000000064 KB内核直连DMA 不可访问备份 SRAM0x400240004 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 留给了 BootloaderApp 只能从偏移处开始。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 SizeCode、RO-data、RW-data、ZI-data四个数字和你预期的量级是否吻合如果 ZI-data 突然暴涨说明某块区域被.ANY意外塞满了二是打开工程输出目录下的.map文件搜索你的执行域名可以看到每个区域的起止地址、已用大小、剩余空间还能看到每个目标文件贡献了多少字节。想让报告更详细可以在 Linker 的 Misc controls 里加一个--infosizes链接器会把每个目标文件的大小都列出来做内存优化的时候非常有用。4. 报错与跑飞那些年踩过的坑4.1 链接期报错速查表sct 写错之后链接器的报错信息其实是相当直白的只是第一次见容易懵。下面这张表是我这些年积累下来的高频错误清单报错编号典型信息根本原因与处理方式L6220EExecution region ER_IROM1 exceeds its limit执行域实际内容超过声明的长度要么扩大区域要么精简代码注意.ANY会把多余内容硬塞进来L6406ENo space in execution regions with .ANY selector所有能接受.ANY的区域都满了通常是某个区域长度写小了或者忘记给某块 RAM 建执行域L6221EExecution region overlaps with another两个执行域的地址区间重叠回去核对起始地址和长度的加法尤其是手算偏移的时候L6223ELoad region overlaps with another加载域重叠IAP 场景下最容易出现Boot 和 App 的边界必须严格对齐L6314WNo section matches patternsct 里写了*(.xxx)但代码里没人定义.xxx段属于警告但往往意味着你的 section 名字拼错了L6329WPattern only matches removed unused sections段被--remove优化掉了如果确实需要保留加-keep选项L6218EUndefined 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长度 16KBApp 的 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 6AC6老工程里用的 AC5 需要单独安装再手动切换。两者对 sct 的支持主体一致但有几个差异点必须留意AC6 对.ANY的分配顺序处理更严格某些在 AC5 下恰好能过的写法在 AC6 下会直接报 L6406EAC6 的段命名和优化策略有变化--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 里去。这里有个小技巧值得分享--infosizes加上--infosummarysizes两个链接选项一起用Build Output 里会输出一份按大小排序的目标文件清单和一份区域汇总比翻 map 文件快得多。我曾经靠这个发现在某个项目里一个第三方协议栈静态链接了 30 多 KB 的代码而实际只用到其中三个函数换成手动裁剪之后 Flash 直接省下 20%成本比换芯片低多了。优化选项也会影响 sct 的效果。开了 LTO链接时优化之后一些原本独立的目标文件会被合并.ANY的分配结果可能和预期不一样-Ospace和-Otime对代码体积的影响有时能达到 10% 以上。这些选项和 sct 是互相影响的调内存布局的时候最好固定住优化选项不要一边改脚本一边改优化否则问题定位会变成玄学。还有一点如果项目里同时有多个 Target比如 Debug 和 Release 用不同的 sct记得把脚本文件纳入版本管理并且和工程的.uvprojx一起提交。我吃过一次亏同事在本地改了 sct 但没有提交另一个人的电脑上重新编译出来的固件行为的完全不一样查了两天才发现是脚本文件的差异。链接脚本是构建产物的一部分不是环境配置这个观念值得所有人都建立起来。我个人在实际操作中的体会是sct 这份文件最忌讳抄完就不动。它不像业务代码那样天天改但每次芯片换型、分区调整、加入新外设它都可能是那个最先出问题的环节。建议在每个项目的主分支里给它写一段注释头标明每个区域的用途、容量、谁负责维护下次再打开这份文件的时候你或者你的同事能一眼看懂当初为什么这么分。