news 2026/10/15 1:44:56

MDK警告L6989W:分散加载文件执行区为空的排查与修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MDK警告L6989W:分散加载文件执行区为空的排查与修复

做嵌入式开发的人对编译器的警告,大多已经练就了“视而不见”的本事——只要没报 Error,程序能烧进去能跑起来,就默认没事。我第一次在 MDK 的 Build Output 窗口里看到 L6989W 时也是这么想的。直到后来在某低功耗设备项目里,被一个“上电正常、唤醒后变量莫名丢失”的诡异问题折磨了两天,回头翻编译日志才反应过来,那个被我忽略的 L6989W 早就给出了线索。

这篇文章就围绕 MDK 的 Warning L6989W 展开。我会从链接器的工作机制讲起,把这条警告到底在说什么、哪些场景最容易触发、完整的排查链路是什么、怎么改才能彻底消掉,都过一遍。最后还会分享一些工程层面的预防手段,让这个警告别再反复出来刷存在感。如果你正在用自定义分散加载文件(.sct),或者刚把工程从 ARMCC 5 迁到 ARM Compiler 6,那这篇文章大概率能帮你省下几天的排查时间。

1. L6989W 出现时程序通常还能跑,但坑往往埋在“空”这个字上

先说说报错现场。在我的项目里,链接器输出的警告大意是这样的:

warning: L6989W: Execution region ER_RET_DATA with load region LR_IROM1 is empty

程序照常编译通过,hex 照常生成,代码也能烧进芯片正常跑。表面看一切都好,但注意最后那个词——empty。链接器在告诉你:有一个执行区域(Execution Region)里什么东西都没放进去。

当时的项目是一个低功耗计量设备,主控芯片内置了两块 SRAM,一块是断电即丢失的普通 RAM,另一块是电池域供电的保留 RAM,用来在休眠时保存关键状态。原厂 SDK 带来的分散加载文件里刚好划了一块区域留给这些保留数据,叫ER_RET_DATA。问题是,工程里确实声明了几个需要放进这块区域的变量,但链接器却报告这块区域是空的。

这就很有意思了:变量声明得好好的,为什么链接器认为区域是空的?

要理解这个问题,得先把 L6989W 的真实危害说清楚。它和编译错误不一样,不需要立即处理程序也能构建成功,所以在很多开发者的潜意识里,这就是个“可忽略项”。但从我那次经历来看,如果这个区域里有你预期的数据,而实际链接时它是空的,那么程序运行时访问到的地址可能根本没有正确映射,或者变量被默认丢弃,最终表现为:

  • 低功耗唤醒后,标记位、累计值、备份数据全部恢复成随机值或初始值;
  • 调试器里能看到变量,但变量地址没落在你预期的内存区间;
  • RTOS 工程里,某个任务的栈或者堆被链接到奇怪的位置,运行一段时间后无缘无故 HardFault;
  • 上电第一次运行正常,第二次软复位后行为就不对。

这些都是“你明明觉得没出事,实际上已经出事”的典型症状。最麻烦的地方在于,它不像语法错误那样直接报在源码行上,而是藏在链接阶段,涉及分散加载文件、段选择规则这些平时很少碰的东西。

我用一个表格快速概括不同触发场景下的危害程度:

触发场景表面现象实际风险
自定义保留 RAM 区域为空编译正常,烧录正常唤醒后关键数据丢失或乱值
堆栈区域为空程序一运行就异常栈指针指向无效内存,直接 HardFault
代码段区域为空下载后无法运行复位向量都没被链接进去
普通 .ANY 回退生效功能偶尔正常变量被放到意外地址,越界访问

在我那个项目里,问题属于第一类。后面我会把整个排查过程展开讲,先记住一个核心判断:L6989W 不是无缘无故出现的,它背后一定有一个“区域定义”和“实际输入段”没匹配上的环节。

2. 链接器说“空”到底在说什么:分散加载文件里的三个关键要素

要解掉 L6989W,绕不开分散加载文件(Scatter File)。MDK 工程里,默认情况下链接器会参照 Target 对话框里的 ROM/RAM 地址设置自动生成这个文件。但一旦你手动指定了 .sct 文件,就等于把内存布局的总指挥权交给了这份文本。

一份典型的 .sct 文件长这样:

LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } ER_RET_DATA 0x20010000 0x00002000 { *.o (RET_DATA) } }

要读懂链接器为什么会报区域为空,只需要抓住三个概念:加载区、执行区和选择器。

LR_IROM1是加载区(Load Region),它描述的是镜像烧录到 Flash 后的整体布局。ER_IROM1、RW_IRAM1、ER_RET_DATA是执行区(Execution Region),描述的是运行时各个段(Section)在内存中的位置。而*.o (RET_DATA)、.ANY (+RW +ZI)这些花括号里的内容,就是“选择器”——告诉链接器,哪些输入段应该被放进当前这个执行区。

L6989W 的触发机制,本质上就是某个执行区的选择器没有匹配到任何输入段。

拿上面的例子来说,ER_RET_DATA区域的选择器是*.o (RET_DATA),意思是“把所有 .o 文件里名为 RET_DATA 的段都放到这个区域”。如果代码里没有任何一个段叫这个名字,或者段名和这里写的不一致,链接器就只能把这个区域空在那里。编译不会失败,因为这种“空区域”并不违反链接规则,它只是给你一条警告。

我用一个生活化的类比来解释:加载区是一辆货车,执行区是仓库里提前划好的分拣区。选择器就是贴在货物上的标签。货车把货拉到仓库后,分拣员会按照标签把货物放进对应的分拣区。L6989W 等于分拣员在喊:“那个叫 RET_DATA 的分拣区一个箱子都没有!”可能是没人往货车上放这个标签的箱子,也可能是标签写错了,比如实际写的是RET_DATA而分拣区上写的是RET_DATA1。

所以,排查 L6989W 的视角必须集中在两件事上:一是执行区定义本身是否正确,二是到底有没有输入段能匹配上它的选择器。接下来我会用真实的排查步骤来演示。

3. 一次真实排查:从警告文本到根因的五个步骤

下面这条排查链路来自那个低功耗计量项目,每一步我都保留了当时的观察方法。照这个顺序走,基本能把绝大多数 L6989W 的根因揪出来。

3.1 第一步:把警告文本完整抄下来,别只记编号

L6989W 只是一个编号,真正有用的信息在后面那半句。我的项目里,警告写的是Execution region ER_RET_DATA with load region LR_IROM1 is empty。这一句已经告诉你了两个关键东西:出问题的执行区叫ER_RET_DATA,它所属的加载区是LR_IROM1。

先别急着改代码,先把 .sct 文件里ER_RET_DATA的定义找出来。我当时在工程里搜到了这段:

ER_RET_DATA 0x20010000 0x00002000 { *.o (RET_DATA) }

定义本身并不复杂,地址是0x20010000,长度0x2000,选择器指向名为RET_DATA的输入段。也就是说,链接器没找到任何一个RET_DATA段。

3.2 第二步:打开 .map 文件,验证区域是否真的为空

.map 文件是链接器生成的内存映射清单,能直接证明“区域为空”这句话。编译完工程后,在 Output 目录下找到.map文件,搜索ER_RET_DATA,会看到类似下面的内容:

Execution Region ER_RET_DATA (Exec base: 0x20010000, Load base: 0x00000000, Size: 0x00000000, Max: 0x00002000)

注意Size: 0x00000000,这就是“空”的铁证。如果区域里有内容,Size 应该是实际数据的大小,并且下方会列出每个符号的地址和名称,比如:

Execution Region ER_RET_DATA (Exec base: 0x20010000, Load base: 0x00000000, Size: 0x00000004, Max: 0x00002000) Address Size Symbol 0x20010000 0x4 wakeup_flag

看到 Size 为 0 之后,排查范围就可以缩小到“为什么没有输入段进入这个区域”。

3.3 第三步:检查代码里的段声明,核对命名和大小写

正常来说,要把变量放进自定义执行区,需要在 C 代码里用__attribute__((section("name")))做段声明。我在工程里搜了RET_DATA,找到的变量声明长这样:

__attribute__((section("RET_DATA"))) uint32_t wakeup_flag;

看到这行代码时,很多人会以为没问题:代码里声明了RET_DATA,.sct 里也写了RET_DATA,怎么还会匹配不上?问题出在工具链对段名的处理方式上。

3.4 第四步:区分 ARMCC 5 和 AC6 的段名差异

我那个工程原本是在 ARM Compiler 5 下开发的,后来项目组统一迁移到了 ARM Compiler 6。这个迁移过程本身就是 L6989W 的高发期。

ARMCC 5 对__attribute__((section("RET_DATA")))的处理比较宽松,生成的目标文件里段名就是RET_DATA。但 ARM Compiler 6(armclang)对 section 名字的处理更严格,在部分场景下段名会被记录成带前导点的形式,比如.RET_DATA,或者反过来你声明的是.RET_DATA而目标文件里没带点。不同版本的 armclang 对匿名 section 的命名规则也不太一样。

这就导致了一个非常隐蔽的错位:.sct 里的选择器写的是*.o (RET_DATA),但目标文件里实际生成的是.RET_DATA,链接器自然匹配不上,区域只能空着。

我当时确认的方法是在链接选项里临时加了一个--keep参数,把RET_DATA段强保留之后,再用fromelf --text -z命令(也可以直接看编译生成的 .o 文件的符号表)检查目标文件里的段名。一查就发现,实际段名就是.RET_DATA,前面多了一个点。

遇到这种问题,改法有两种:

第一种,改 .sct 文件,把选择器改成同时匹配两种命名:

ER_RET_DATA 0x20010000 0x00002000 { *.o (RET_DATA, .RET_DATA) }

第二种,统一工具链后,把代码里的声明统一成不带点的形式,并确保 .sct 只写一种。我更推荐第二种,因为第一种虽然能兼容,但掩盖了工程里混用工具链的问题,以后还会踩坑。

3.5 第五步:检查链接选项是否把段裁剪或者屏蔽了

还有一种情况,即使段名完全一致,区域依然为空——那就是链接器在优化时把你的段“优化”掉了。尤其是定义了变量但代码里从头到尾没引用它,链接器会认为它是无用段,直接丢弃。这时候需要检查 Target 选项卡里的链接选项:

  • --remove是否启用?默认启用时会移除未使用的段;
  • 有没有针对这个区域使用--keep保留符号?如果没保留,又没被引用,就会被裁掉。

我在测试时把wakeup_flag的引用临时删掉过,区域立刻变成了空,这是同样的道理。对于要固定映射到某个地址的变量,建议在分散加载文件里加--keep,或者在声明处做 noinit 处理,确保链接器不把它当垃圾回收。ARMCC/AC6 下可以这样写:

__attribute__((section("RET_DATA"), used, zero_init)) uint32_t wakeup_flag;

used属性可以提示编译器这个符号即使没被引用也要保留。不过要注意,zero_init会让它进 ZI 段,而used属性在部分编译器版本里对段裁剪的影响并不完全一样,最终还是要靠 .map 文件来验证。

4. 对症下药:L6989W 的三种高频根因与修复方案

在我接触过的各种 L6989W 案例里,真正的原因翻了天也就三类。我把每一类的判断依据和修复方案整理清楚了,按下面的顺序自查即可。

4.1 根因一:区域地址越界或与芯片内存映射冲突

这个是最容易被忽略的。很多人从别的工程复制一个 .sct 文件过来,只改了芯片型号,却没有核对地址和长度。比如某芯片的 SRAM 只有 64KB,地址范围是0x20000000 ~ 0x2000FFFF,但 .sct 里却写了一个执行区从0x20010000开始,长度0x2000。链接器在建立内存布局时发现这个区域根本不在任何合法内存空间内,于是即便有输入段也无法分配实际地址,最终给出 L6989W。

判断方法很简单:打开芯片数据手册,一页一页看 Memory Map,核对 .sct 里每个 LR 的基地址、每个 ER 的基地址和长度是否落在合法的 Flash/RAM 空间内。同时还要注意 RAM 是否被其他外设占用,比如某些芯片的 USB 控制器会占用一部分 RAM。

修复方案就是直接改地址:

ER_RET_DATA 0x20008000 0x00002000 { *.o (RET_DATA) }

改完重新编译,再打开 .map 文件确认 Size 不再是 0。

4.2 根因二:没有变量放进该区域,或变量被裁剪

第二种情况最常见。自定义执行区建起来了,但代码里压根没写对应的__attribute__((section("name"))),那这个区域自然就是空的。还有一种情况是写了,但变量声明成了非零初始值,导致它进了 RW 段,而 .sct 里那个区域的选择器只写了ZEROFILL或只写了+ZI,也会出现匹配不上。

具体表现可以分成三类,用表格整理:

代码声明段属性.sct 选择器应包含
__attribute__((section("X"), zero_init))ZI 段+ZI
__attribute__((section("X")))且赋非零初值RW 段+RW
__attribute__((section("X"), used))取决于是否初始化按实际属性

修复时先明确变量的用途。如果是掉电/休眠后需要保留的变量,建议声明成 zero_init,也就是不初始化,避免启动代码把它清零。声明示例:

__attribute__((section("RET_DATA"), zero_init)) uint32_t wakeup_count;

然后确保 .sct 里的选择器和你声明的段属性对得上。保守一点可以写成:

ER_RET_DATA 0x20008000 0x00002000 { *.o (RET_DATA, +RW, +ZI) }

如果变量被裁剪的问题持续存在,就在链接选项里加上--keep=RET_DATA,或者直接在 Option for Target 的 Misc Controls 里写:

--keep=ER_RET_DATA

4.3 根因三:工具链迁移导致的段命名规则变化

这个根因我在第四节已经详细拆解过,这里只给出完整的应对方案。如果你刚从 ARMCC 5 切到 AC6,或者团队里有人混用工具链版本,请按以下顺序处理:

  1. 统一编译器版本,最好在工程设置里锁定ARM Compiler 6.x;
  2. 制定规范:所有自定义段声明一律使用不带前导点的命名,比如RET_DATA,禁止写成.RET_DATA;
  3. .sct 文件里的选择器统一改成匹配带点和不带点的写法,过渡期内先兼容:
    *.o (RET_DATA, .RET_DATA)
  4. 用fromelf --text -z或直接查看目标文件符号表来验证实际段名;
  5. 全部验证通过后,再把选择器精简为只保留一种,减少以后的歧义。

我在实际项目里还遇到过大小写不一致的情况:代码里写的是ret_data,.sct 里写的是RET_DATA。链接器的选择器匹配对大小写是敏感的,这类错误 .map 文件里不会给出提示,只能靠肉眼比对。建议在工程里统一段名的大小写风格,比如全部大写。

5. 治本措施:让 L6989W 在后续工程里不再反复出现

修正完一次 L6989W 很简单,难的是让这类问题在下一个工程师手里不再出现。我在后面几个项目里总结了一套动作,每个新工程开工时都会执行一遍。

5.1 用统一头文件管理段声明

直接在源码里写__attribute__((section("RET_DATA")))看起来很直接,但工程变复杂后,段名很容易出现拼写不一致的问题。更稳妥的做法是抽出一个专门的段声明头文件,把所有自定义段名定义为宏:

#define RET_DATA_SECTION __attribute__((section("RET_DATA"), zero_init))

之后所有需要放进该区域的变量都统一用这个宏:

RET_DATA_SECTION uint32_t wakeup_count;

这样全局搜索能快速确认哪些变量放在哪个段,也方便在迁移工具链时统一修改。

5.2 编译后自动检查 .map 里的空区域

手动看 .map 文件太容易漏,尤其是工程大,区域多。我后面写了一个简单的脚本思路,放到编译后步骤里:

  • 第一步,解析.map文件,找出所有Execution Region ... Size: 0x00000000的行;
  • 第二步,把这些区域名和已知的可忽略列表比对;
  • 第三步,除了可忽略列表,任何空区域都视为链接警告,直接让构建系统标黄。

这个脚本可以用 Python 写,也可以直接用文本工具的过滤命令。核心价值在于把“人肉翻日志”变成“机器查问题”,避免 L6989W 再次偷偷溜过。

5.3 不轻易屏蔽警告,尤其是链接警告

有些开发者遇到 L6989W 后会直接在编译器选项里加--diag_suppress=6989把警告关掉。我强烈不建议这么做,原因很简单:链接警告不像源码警告,它直接反映内存布局的正确性。屏蔽掉一条 L6989W,等于关掉了链接器对你内存映射的最后一道监督。真要屏蔽也应该是临时的,并且在代码注释里写明为什么、什么时候移除。

5.4 代码审查时固定检查分散加载文件

最后一点是关于流程的。但凡工程改动涉及这几类情况,审查时一定要把 .sct 文件和 .map 文件列进必查项:

  • 更换或升级芯片型号;
  • 调整 RAM/Flash 分区;
  • 增加低功耗保留数据功能;
  • 从 ARMCC 5 迁移到 AC6;
  • 修改启动文件里的堆栈大小。

我在处理那个低功耗计量项目时,有个体会特别深:L6989W 这种问题,单独看每一条都不致命,但它往往是工程里更深层次问题的早期信号。地址写错、段名不匹配、变量被丢弃,这些问题如果放任不管,迟早会在运行时以更隐蔽的方式爆发。后来我养成了一个习惯,每次编译完先扫一眼 .map 文件里各个 Execution Region 的 Size,不为别的,就为确认每一块内存分区都装进了预期的东西。这个习惯帮我在后期几个项目里提前发现过好几处 RAM 规划错误,处理成本远低于等真机上电之后再去抓瞎。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 1:44:38

基于MP2642与PIC32的双串磷酸铁锂电池均衡系统设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:44:36

JFinal实战教程:从设计哲学到企业级架构演进

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:44:29

HART温度变送器转以太网:光伏车间测温改造方案与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:44:14

Python调用汉王OCR云接口:从签名鉴权到批量识别完整实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:43:57

JWT原理与实战:无状态鉴权的信任边界与工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:43:37

JDBC中的桥接模式:解耦抽象与实现的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华