
搞双核项目最怕的不是代码逻辑写错而是编译链接阶段突然蹦出一行Unresolved Inclusion然后整个工程红成一片。这个报错在单核工程里比较少见一旦出现在 dual core 项目里往往就不是单纯补一个 include path 那么简单了。我最近在一个双核 MCU 项目上被这个问题卡了一整个下午排查下来发现同一个报错背后居然藏着四类完全不同的原因每一类都能把经验丰富的老手带沟里去。这篇文章就把我这次完整的排查链路、每一类问题的根因定位过程、以及最后沉淀下来的工程规范一起写出来给同样在双核项目里挣扎的朋友一个可以直接照着操作的参考。不管你是刚把双核工程跑起来的新手还是已经被这个报错折磨了几天的老油条这文章应该都能帮你省下不少时间。说到 Dual Core 项目现在主流的选择基本就是两类一类是像 STM32H747 这种 Cortex-M7 Cortex-M4 的异构双核另一类是像 i.MX RT1170 这类同样是 M7 M4 但外设资源更丰富的高端型号。这类芯片的特点是两个核心各自跑独立的固件却又共享同一片内存和外设地址空间所以工程组织、头文件依赖、链接脚本都跟单核时代完全不一样。Unresolved Inclusion这个报错在双核工程里高频出现本质上就是因为这种“两个独立工程 一份共享资源”的结构天然容易产生路径依赖和符号引用上的断层。1. 拆解报错Unresolved Inclusion 在不同编译阶段含义完全不同1.1 三种最常见的报错形态先说结论Unresolved Inclusion这个字面意思在不同工具链里指的根本不是同一件事。我这次遇到的报错虽然在 IDE 的问题栏里都归类为 Unresolved Inclusion但点进去看细节实际是三种完全不同的错误。第一种是编译器在预处理阶段找不到头文件报的是fatal error: xxx.h: No such file or directory。这种最常见纯粹就是 include path 没配全或者头文件路径写错了。单核工程里也会遇到处理起来也简单把缺失的那个目录加到编译选项里就行。第二种是链接阶段的undefined reference to xxx或者某些 IDE 里显示的链接器 Unresolved symbol。这种就不是头文件的问题了代码里明明#include成功了编译也过了但链接的时候说找不到符号定义。单核工程里遇到这种通常查查库有没有链进来、函数有没有定义就行但双核工程里事情就复杂了因为符号可能定义在另一个核的固件里。第三种是链接脚本相关的比如cannot open linker script file xxx.ld: No such file or directory或者更隐蔽的内存段覆盖问题。链接脚本里的INCLUDE指令指向的片段文件找不到也会被 IDE 归类为 inclusion 相关错误。双核工程为了统一管理内存布局经常让两个核的链接脚本include同一个公共片段这个路径一旦出问题报错就很莫名其妙。1.2 双核工程为什么会放大这类问题单核工程的 include 路径和链接关系是线性的工程目录下所有源文件参与编译所有编译产物参与链接一切都在一个 IDE 工程的视野范围内。但双核工程最常见的组织方式是拆成两个独立子工程——core0 工程和 core1 工程各自有各自的 include 路径、编译宏、链接脚本。问题就出在这两个子工程之间有公共代码、有共享头文件core0 的代码可能要引用 core1 那个核的某些硬件资源定义core1 的代码也可能要访问 core0 管理的共享内存。这种交叉引用如果处理不当就会出现一类很尴尬的情况core0 工程编译的时候include 路径里没有 core1 的目录core1 工程编译的时候链接脚本里没有覆盖 core0 导出的符号。单核时代根本不存在这个问题所以很多从单核切到双核的工程师第一次遇到Unresolved Inclusion都会懵。1.3 我这边的排查起点这次出问题的工程是 STM32H747M7 核跑主逻辑M4 核跑通信协议栈。两个核共用同一个.ioc配置CubeIDE 自动生成了两个子工程。报错出现在 core0 工程的编译日志里提示某个通信协议栈的头文件无法解析而这个头文件在 core1 工程里明明存在且正常编译。当时我第一反应是 core0 的 include path 少了 core1 的目录加上去之后重新编译发现报错还在。这就是双核工程坑人的地方——你以为是路径问题实际上路径问题只是表象背后还藏着三个完全独立的问题。后面我按照编译、链接、脚本、构建顺序四层逐一排查才算彻底搞清楚。2. include 路径排查共享头文件“同名不同源”才是最大的坑2.1 双核工程共享头文件的典型组织方式先说说正常的双核工程应该怎么组织目录。我见过比较规范的项目一般是下面这种结构project_root/ ├── common/ │ ├── inc/ │ │ ├── app_common.h │ │ └── mem_map.h │ ├── src/ │ └── linker/ │ ├── memory_regions.ld │ └── memory_regions.icf ├── core0/ │ ├── inc/ │ ├── src/ │ └── linker/ │ └── core0.ld └── core1/ ├── inc/ ├── src/ └── linker/ └── core1.ldcommon 目录放两个核都要用的公共代码和内存映射定义core0 和 core1 各自放私有代码。两个子工程的 include path 一般都需要包含common/inc这是最基本的要求。但这种结构有个隐患如果 core0 和 core1 各自的私有目录里出现了同名头文件比如两个目录下都有一份config.h那 include path 的顺序就决定了到底哪个文件生效。单核工程不存在这种冲突双核工程你一旦在 core0 的 include path 里加了 core1 的目录作为后备就很容易串味。2.2 从报错文件反查包含链比盲目补路径高效这次 core0 编译报错显示无法解析protocol_internal.h我的第一反应是把 core1 的目录加到 core0 的 include path 里。但实际上这个文件在 core0 工程里根本不该被直接包含它是 core1 内部实现用的私有头文件。core0 那边真正想包含的是 common 目录下的另一个同名文件或者某份公共头文件里条件编译引错了分支。这时候盲目加路径不是解决问题而是掩盖问题。正确做法是顺着报错文件反查包含链先打开报错的那个.c文件看它到底 include 了哪些头文件再打开这些头文件看是否又 include 了其他文件。很多头文件并不是直接 include 缺失的头文件而是隔了一层。GCC 工具链的话直接编译时加个-H参数或者--trace-includes编译器会把整个 include 树打出来。我用这个参数后立刻就看清楚了core0 工程里某个公共头文件app_common.h里面写死了#include protocol_internal.h而这个app_common.h是 core0 和 core1 共同使用的在 core1 工程里编译没问题到了 core0 工程里就炸了。这种“公共头文件直接包含私有头文件”的写法在单核工程里问题不大但在双核工程里就是定时炸弹。后来我让协议栈的头文件分开处理公共部分暴露接口私有部分只放在 core1 内部使用。2.3 预处理展开找出条件编译里被“带偏”的头文件还有一个比路径缺失更隐蔽的情况——条件编译导致实际 include 的文件跟你想的不是同一个。双核工程里经常会用宏来区分当前编译的是哪个核比如#if defined(CORE0) #include core0_config.h #elif defined(CORE1) #include core1_config.h #else #error No core defined #endif如果 core0 工程里的编译选项不小心定义了CORE1或者两个宏都定义了预处理器就会走进错误的分支包含一个当前工程根本不存在或不该包含的头文件。这类问题查 include path 是查不出来的因为路径配置看起来完全正常。我当时的排查方法是把预处理结果导出来看GCC 用-EIAR 用预处理器输出选项把展开后的文件内容拉出来直接看# line指令指向的实际文件路径立刻就明白预处理器到底包含了哪个文件。当时在项目里发现的问题就是core0 工程的编译器选项里同时定义了CORE0和CORE1因为有个同事图省事把一个公共的编译选项文件直接复制粘贴到了两个核的工程里里面宏定义是写死的没针对不同核做区分。预处理展开后两边都走进了CORE1分支而 core0 工程里根本没有core1_config.h的路径Unresolved Inclusion就出现了。2.4 这个阶段的实际收获排查完第一层我才意识到Unresolved Inclusion在编译阶段出现时不要一上来就补 include path。先做三件事查看报错文件的实际 include 链、检查宏定义是否把条件编译带偏了、确认公共头文件是否意外包含了某个核的私有头文件。这三步做完编译阶段的问题基本能排除掉八成。3. 链接脚本里的 include 指令内存映射文件解析失败3.1 链接脚本的 include 本质上是文件包含指令编译阶段的报错排除之后我重新编译 core0 工程编译顺利通过但链接阶段又挂了一次。这次报错是cannot open linker script file memory_regions.ld: No such file or directory。双核工程里链接脚本的 includeGCC 的INCLUDE指令IAR 的.icf文件里的 include 语法跟 C 语言里的#include不是一回事但有一点相同解析路径的规则很微妙。GCC 的ld在执行INCLUDE memory_regions.ld时会按照当前工作目录、-L参数指定的目录、以及链接脚本自身所在目录的顺序去查找。但很多 IDE 在调用链接器时当前工作目录是编译输出目录比如build/core0/而不是链接脚本所在的目录。所以经常会遇到一种让人抓狂的情况从命令行手动执行链接器时一切正常因为你在linker/目录下执行INCLUDE能找到文件但 IDE 一键构建时工作目录切到了build/目录链接器就找不到memory_regions.ld了于是报Unresolved Inclusion。3.2 工作目录不同导致的链接脚本找不到我在排查时先确认了链接脚本本身是存在的路径也没写错。打开构建日志发现链接器执行的命令行里工作目录确实是构建输出目录。GCC ld 的INCLUDE指令在找不到文件时还会尝试在-L参数指定的路径里面找所以我最初尝试在工程的链接选项里加了-L指定到 linker 目录确实能解决部分问题但 IAR 的.icf文件对这种写法支持不太好。最终稳妥的做法是在链接脚本里用相对路径但这个相对路径是相对于链接脚本所在目录的。GCC ld 文档里明确说INCLUDE指令中写的路径首先会在当前工作目录查找然后再按-L查找如果找不到会尝试链接脚本所在目录。这个行为在不同版本、不同 IDE 里略有差异最保险的写法是把公共链接片段跟两个核的链接脚本放在同一个目录下然后直接用文件名不加路径前缀工作目录的问题就能绕开。3.3 内存段没有覆盖某个目标文件的“假 undefined reference”链接脚本还有一个更隐蔽的坑双核工程的链接脚本里经常会把内存划分为 core0 专用区、core1 专用区和共享区。如果某一个核的链接脚本里内存段定义没有覆盖到某个源文件生成的.o文件链接器就会报类似undefined reference或者region FLASH overflowed by xxx bytes的错误。但这种报错有时候会被 IDE 归类为 Unresolved Inclusion因为 IDE 的错误解析器会把所有跟“找不到”相关的错误都归类到一起。比如一个变量声明在共享头文件里定义在 core1 的源文件里core0 的链接脚本只包含了 core0 自己的目标文件清单那 core0 链接时就会报undefined reference to xxx。排查这类问题最直接的手段是看 map 文件。生成 map 文件的方式是在链接选项里加-Wl,-Mapoutput.map然后打开 map 文件搜索这个符号看它到底有没有被链接进来落在哪个内存段。如果 map 文件里压根没有这个符号说明定义它的目标文件没有被链进当前内核的镜像这时候要去检查链接脚本、目标文件列表、以及--gc-sections是否把符号给淘汰掉了。4. 跨核符号引用定义在另一个核的固件里链接器永远找不到4.1 双核固件不是单个 ELF跨核互相调用必然链接失败这一层是整个排查过程中最耽误时间的。core0 工程里有一行代码直接调用了 core1 那边的函数名类似这样extern void protocol_stack_init(void); void app_main(void) { protocol_stack_init(); }链接时报undefined reference to protocol_stack_init。我一开始以为只是 lib 没链接进来折腾了半天才发现这个函数的定义在 core1 工程里而 core1 的固件是另一个独立的可执行镜像。GCC 链接 core0 时根本不可能去 core1 的编译输出里找符号哪怕两个固件烧录到同一颗芯片里链接器眼里它们就是两个互不相干的世界。这是双核项目跟单核项目最本质的区别。单核项目里所有源文件最终链接成一个 ELF 或 HEX所有符号互相可见双核项目大部分情况下是每个核独立构建独立链接各自生成各自的镜像两个镜像之间没有链接期的符号可见性。4.2 双核跨核调用的正确设计跨核调用函数这件事本身是有办法做到的但不是靠链接器。常见做法是通过共享内存放一个函数指针表core0 的启动代码从共享内存的固定地址加载指针然后调用或者用核间中断 IPC 加上消息注册机制。这些方案跟Unresolved Inclusion不直接相关但如果你在设计阶段没有这些机制却在代码里直接写extern引用另一个核的函数那链接期必然失败。我的建议是双核工程里把两个核的代码当成两个完全独立的程序来写跨核的调用一律通过通信机制完成不要互相链接对方的符号。如果项目确实需要合并成一个镜像有些 MCU 支持 AMP 模式下的单一可执行文件那是另一套复杂的链接脚本方案不再适合用两个独立的 IDE 工程来管理。4.3 共享 RAM 段变量的可见性与 --gc-sections跨核变量引用也是类似的情况。双核芯片通常有一块共享 SRAM两个核都能访问。但如果 core0 在代码里写extern uint32_t shared_var;而定义在 core1 里core0 链接时同样找不到。有些项目会把共享变量的定义放在 core0 的源文件里core1 通过地址访问。这时候如果链接脚本给共享变量分配了一段固定的地址比如用AT或手动给变量指定段但 core1 的链接脚本没有把这段地址映射到自己的内存空间就会出现地址无法解析的问题。还有一种是--gc-sections导致的GCC 链接器在开启--gc-sections后会删除未被引用的 section。如果某个源文件里的函数或变量没有在同一个 ELF 里被其他代码引用链接器会认为它是死代码直接不链接导致另一个核的符号缺失。这种情况在 IDE 里看着就是undefined reference但实际上这个符号的定义文件已经被编译进了某个库只是被链接器当成垃圾回收了。解决方法是对这些需要保留的符号使用__attribute__((used))或者在链接脚本里用KEEP()指令强制保留。4.4 用 nm、readelf 和 map 文件精确定位符号遇到跨核符号问题我建议按下面几步走# 查看目标文件或可执行文件的符号表确认符号是否存在、是否可见 nm build/core1/core1.elf | grep protocol_stack_init # 查看符号在各段中的分布情况 readelf -s build/core1/core1.elf | grep protocol_stack_init # 查看符号在链接后的虚拟地址和内存段 nm -n build/core0/core0.elf | grep protocol_stack_init如果nm输出显示符号是Uundefined说明这个符号在当前 ELF 里是被引用的但找不到定义如果输出是T或D说明定义在目标文件里有。再用readelf -s看符号的Ndxsection index如果Ndx是UND也是未定义的意思。map 文件是这些操作里最基础也最有效的工具。先确认 map 文件里有没有你要找的符号没有就说明目标文件没被链接进来有但地址不对就检查内存段分配。5. 构建顺序与工程依赖单独编译没问题全量编译就挂5.1 多工程工作区的依赖关系配置双核项目最常见的玩法是用 IDE 的 workspace 功能同时管两个工程。STM32CubeIDE 的 multi-project 模式、IAR 的 workspace、Keil 的多工程本质上是允许你在同一个工作区里配置工程间的依赖关系。这个依赖关系如果不配置就很容易出现一种情况全量构建时 core0 先编译但 core0 依赖的 core1 生成的某个库文件还没生成导致链接失败单独构建 core0 时core1 的库已经存在了上次全量构建留下的反而能通过。这种问题最迷惑人因为你没法稳定复现。我在这次项目里就遇到了类似情况。core1 工程会生成一个静态库供 core0 调用但 workspace 里没设置 core0 依赖 core1全量构建时有时候能过有时候过不了。解决办法是在 IDE 的工程依赖设置里把 core0 标记为依赖 core1让构建系统自动保证 core1 先构建完成。5.2 构建顺序之外还有头文件生成顺序比工程依赖更隐蔽的是头文件生成顺序。有些项目的头文件不是手写的而是由脚本生成的比如寄存器定义头文件、版本号头文件、内存地址映射头文件。core1 的构建脚本会先生成一份generated_addr.hcore0 的工程需要 include 这个文件但如果构建顺序没有明确约束core0 可能在文件生成之前就开始编译直接报Unresolved Inclusion。这类问题在 Makefile 工程里可以靠显式声明依赖关系来解决。如果是 IDE 工程就得确保生成步骤绑定在构建流程里且优先级正确。我后来把这个生成步骤从手动脚本改成了预构建步骤并且把生成目录加到 include path 中才算彻底根治。5.3 公共源文件列表的统一维护双核工程里 common 目录下的源码两个核可能都需要参与编译。但 IDE 工程文件里源文件列表是每个工程单独维护的。如果新增了一个公共源文件只加了 core0 工程core1 工程忘了加那 core1 代码里凡是引用这个文件中符号的地方链接时就会报 undefined。这个问题的排查效率特别低因为编译不会报错只有链接时报一堆undefined reference每个都能查到源头但你就是看不出来规律。我当时是用脚本对比了 core0 工程和 core1 工程的源文件列表才发现某个公共文件只出现在其中一个工程里。后来我在根目录维护了一份common_src.list两个核的构建脚本都从这份清单里读取源文件列表彻底杜绝了文件漏加的问题。使用 Makefile 的项目可以在 Makefile 里 include 同一个.mk文件IDE 项目则可以用 CMake 或者自定义脚本生成工程文件。6. 把这次踩坑经验固化成工程规范6.1 目录结构与 include 路径的推荐写法这一章写下来你会发现Unresolved Inclusion很少是单一原因往往是路径、宏定义、链接脚本、构建顺序几个问题叠加在一起。遇到这类报错别急着改配置先从报错上下文中判断属于编译阶段、链接阶段还是脚本阶段再对症下药。我这里整理了一份双核工程的自查清单直接照着顺序查基本能覆盖九成以上的问题场景报错文件是否直接出现在某个源文件的 include 行如果是确认这个头文件在哪个核的工程里是合法的。报错头文件是公共文件还是某个核的私有文件公共头文件里不能包含私有头文件。检查当前工程的编译宏确认条件编译是否包含到正确分支。检查链接脚本中INCLUDE指令指向的文件是否存在路径是否会被工作目录影响。搜索报错符号的定义位置确认它是否定义在另一个核的工程里。查看 map 文件确认符号是否被当前链接流程包含。检查工程依赖关系确认所依赖模块是否在之前已构建。对比两个核的源文件列表检查是否有公共文件只被其中一个核引用。每次遇到Unresolved Inclusion按这张清单走一遍比盲目在 include path 里加路径要快很多。6.2 我实际项目里最后用的几个工具参数最后分享一下我在排查过程中觉得特别有用的几个命令组合。如果你是 GCC 工具链这几个参数能帮你省不少时间# 编译时显示完整 include 树看头文件到底从哪个路径加载 arm-none-eabi-gcc -H -MM -c main.c # 预处理展开检查条件编译走了哪个分支 arm-none-eabi-gcc -E -dM -c main.c preprocessed.txt # 链接时显示详细过程包括链接脚本的解析情况 arm-none-eabi-gcc -Wl,--verbose -Wl,-Mapoutput.map ... # 查看符号表确认符号是否存在 arm-none-eabi-nm build/core0/core0.elf | grep undefined_symbolIAR 环境下对应的操作是--preprocessmacros查看展开后的宏--map生成映射文件--log all查看完整链接日志。这套排查思路和工具组合我在这次项目之后已经固化成团队的常态流程了。新来的同事遇到Unresolved Inclusion报错照着清单走一遍基本上不用我带着看也能定位问题。最后再分享一个小技巧排查这种跨工程、跨核的问题最好先把完整构建日志导出来存一份。很多 IDE 的构建窗口有行数上限早期报错被顶掉之后后面看到的所有报错都是连锁反应。把日志存下来之后查第一个真正的报错点往往一枪就能命中要害。