尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

双核MCU项目Unresolved Inclusion错误:从编译配置到链接排查指南

双核MCU项目Unresolved Inclusion错误:从编译配置到链接排查指南 上周有个朋友深夜发消息说他在调一块双核板子工程编译直接报出 Unresolved Inclusion代码看了半天也没发现哪行写错。我让他先把完整日志发过来扫了两眼就知道又是老问题双核工程里头文件路径、条件编译宏或者链接脚本只要有一处没对齐就会冒出这个报错。这种错误在单核项目里很少见但一上双核 MCU几乎人人都能遇到一次。这个内容主要聊的就是双核嵌入式项目里 Unresolved Inclusion 这个错误的完整排查思路。它会牵扯到编译器的头文件搜索机制、链接器的符号解析、双核的启动流程、甚至RTOS任务注册方式。适合正在用 ESP32、RP2040、STM32H7 这类双核芯片做开发或者刚开始把老项目迁移到双核架构的朋友参考。1. 双核项目里 Unresolved Inclusion 到底是什么1.1 双核项目的本质两个 CPU、两套上下文、同一套源码在进入错误排查之前得先把双核工程的基本形态讲清楚。很多人以为双核跟超线程类似实际差得很远。所谓双核 MCU常见的是下面三类主流平台SMP 对称多处理代表是 ESP32 系列两个核心跑同一个固件镜像共享内存和大部分外设FreeRTOS 调度器统一管理任务分配。AMP 非对称多处理代表是 RP2040两个 Cortex-M0 核心虽然共享内存但可以独立运行完全不同的程序核0 启动后手动拉起核1。异构双核代表是 STM32H7 系列Cortex-M7 Cortex-M4两个核心各自拥有偏重的算力场景通常相互独立编译、各自链接、运行不同的固件。我在实际项目里最常见的是后两种混合形态。不少团队会先在 STM32H7 上做异构双核然后发现很多问题再换到 ESP32 这种统一构建环境。而这些平台因为架构不同Unresolved Inclusion的表现形式和根因也千差万别。双核工程最核心的特征是同一条代码可能在两个编译上下文中被分别处理。也就是说同一个源文件在核0的编译链路里可能没有任何问题但在核1的编译链路里会因为宏定义不同、头文件搜索路径不同、链接脚本不同而报错。这不是语法错误而是工程组织层面的结构性问题。1.2 包含未解析在编译期和链接期是两回事Unresolved Inclusion这个报错字面上看像是头文件 include 出了问题但实际项目里它有两种截然不同的阶段必须分开讨论。编译期Compile Time层面的 Unresolved Inclusion是最直白的一种。编译器在处理.c文件时遇到#include xxx.h但根据当前的-I搜索路径和#include指令找不到对应的头文件于是报出类似这样的错误fatal error: core_b_config.h: No such file or directory 2 | #include core_b_config.h | ^~~~~~~~~~~~~~~~~ compilation terminated.这类错误定位相对简单因为编译器会明确告诉你是哪个头文件、在哪个源文件的哪一行。链接期Link Time层面的 Unresolved Inclusion则更加隐蔽。源文件全部编译通过目标文件.o也都生成完毕但链接器在把所有目标文件和库文件合并成最终固件时发现某个符号的引用没有任何实体定义。典型报错undefined reference to multicore_launch_core1或者旧版 GCC 下常见的信息ld: warning: cannot find entry symbol core1_entry; defaulting to 00000000这其实也是一种广义的 Inclusion 未解析你在代码里访问了一个函数但它没有被包含进最终链接产物中。双核工程因为有两套甚至多套编译目标这两类错误会交叉出现这也是排查困难的根本原因。1.3 为什么双核项目特别容易踩这个坑说个真实的数据我帮人排查过的双核工程编译问题里真正代码逻辑写错的不到两成剩下八成全是工程配置的问题。为什么第一双核意味着构建系统复杂度直接翻倍。单核工程只需要维护一套 include 路径、一套宏定义、一个链接脚本。双核工程则至少两套而且经常需要共享一些头文件、排除另一些头文件。一旦某个头文件在核0编译器下正常、核1编译器下报错开发者第一反应往往是代码有问题很难想到是构建配置的问题。第二不少芯片原厂的双核 SDK 本身就不成熟。有的 SDK 文档说支持双核但实际组件拆分很粗糙共享头文件和核专属头文件混在一起。CMake 或 Makefile 的模板漏洞很多需要开发者自己修补。第三开发者的思维惯性。大多数人的第一个 MCU 项目是单核的头文件 include 都是全局绝对路径或者统一的 include 目录根本不需要思考这个头文件是否应该被另一个核看到这个问题。到了双核这个思维惯性就成了最大障碍。2. 根因解析五种最常见的 Unresolved Inclusion 成因2.1 头文件搜索路径不完整核A找不到核B专属头文件这是最浅显但也最高频的一种。很多双核工程为了省事会把核0和核1的代码放在同一个项目目录下但头文件分开放project/ ├── CMakeLists.txt ├── core0/ │ ├── main.c │ └── include/ │ ├── core0_app.h │ └── common.h └── core1/ ├── main.c └── include/ ├── core1_app.h └── common.h在这个结构里core0/main.c如果需要引用core1/include下的某个头文件构建脚本里又没有把core1/include加进核0编译单元的搜索路径编译器立刻报 No such file or directory。为什么会有人跨核引用对方头文件通常是为了共享某个数据结构定义或通信协议。比如核0 要向核1 发送消息就必须知道core1定义的 mailbox 结构体的布局于是直接在core0/main.c里写#include mailbox_protocol.h而这个头文件放在了core1/include下面。这种耦合在逻辑上有道理但在工程层面很容易出问题。我的建议是双核之间共享的头文件必须抽离到一个独立的 common 目录而不是互为依赖。一旦出现核0的编译链路依赖核1的目录构建系统的可维护性会急剧下降。2.2 条件编译宏不一致同一个头文件两边内容不同这种问题比路径问题阴险得多。假设你有这样一个头文件#ifndef __PLATFORM_H__ #define __PLATFORM_H__ #if defined(CORE_0) #define CLOCK_FREQ_HZ 480000000 #define UART_IRQn 38 #elif defined(CORE_1) #define CLOCK_FREQ_HZ 240000000 #define UART_IRQn 39 #else #error Must define CORE_0 or CORE_1 #endif #endif在核0的编译参数里定义了CORE_0头文件解析得很顺利。核1的编译参数里如果忘了定义CORE_1那么预处理器直接走到#error整个编译失败。有些时候甚至不会走到#error而是静默地选择了错误的宏分支。比如核1编译时没有定义任何宏但头文件里缺省分支给了一组默认值那么核1拿到的配置和核0完全一样两个核心跑在相同的时钟配置下结果可能不是编译错误而是运行时莫名其妙的死机或外设错乱。这类问题最有效的排查手段是查看预处理后的文件。用 GCC 的-E选项可以直接看到头文件展开后的结果arm-none-eabi-gcc -E -DCORE_1 -I./include core1/main.c preprocessed.txt在preprocessed.txt里搜CLOCK_FREQ_HZ就能确认最终生效的是哪个分支。我处理过的几个双核项目里有至少三分之一的问题是某种宏在某个核的编译参数里缺失或者重复定义。2.3 构建系统没有按核心拆分源文件导致重复或缺失编译双核工程里源文件的编译归属是一个非常关键的配置但它也是最容易被忽视的。一种典型场景是重复编译。工程把所有.c文件都扔给两个核的编译任务结果每个核都包含了自己不该有的代码。比如core0_specific_driver.c被核1也编译进去了这个文件里可能直接操作了核0私有的硬件寄存器虽然编译能通过但静态检查或运行时会出问题。如果里面定义了与核1已有文件相同的全局符号还会直接触发链接警告甚至 multiple definition 错误。另一种典型场景是缺失编译。某些底层文件应该同时被两个核使用但构建脚本只给核0配上了。比如公共的内存管理工具、CRC校验库、通信协议栈核1的代码里调用了这些函数编译阶段没问题因为函数声明通过头文件拿到了链接阶段直接报undefined reference。要避免这类问题必须清楚列出每个核的源文件清单而不是让构建系统自动扫描全部源码。我在下面的实操章节会展示具体的 CMake 写法。2.4 跨核直接调用函数链接器根本找不到对方核心的代码这在 AMP 和异构双核平台上是重灾区。STM32H7 的核0和核1是各自独立链接的生成两个独立的固件文件。核0 的固件里根本不存在核1 的任何函数符号核1 的固件里也不存在核0 的函数。两个核只能通过共享内存、硬件邮件盒mailbox、或者核间中断来通信。换句话说从链接器的角度看核0和核1是两个完全不同的程序。如果你在核1的代码里写下这样的调用// core1/main.c #include core0_app.h void core1_main(void) { core0_start_engine(); // 期望调用核0里的函数 }链接core1.elf时core0_start_engine这个符号在哪里都找不到直接抛undefined reference。你需要把它改造成核间消息传递// core1/main.c #include ipc.h #include messages.h void core1_main(void) { ipc_send(MAILBOX_ID_0, MSG_START_ENGINE, NULL, 0); }核0这边注册一个消息处理回调收到MSG_START_ENGINE后执行真正的core0_start_engine。这是双核架构的硬边界不是靠改工程配置能绕过去的。2.5 运行时依赖没有注册逻辑上的 Inclusion 未完成还有一类问题编译和链接全部通过但程序一启动就崩溃或者功能不生效。这种广义的 Unresolved Inclusion 指的是代码里声明的某个功能模块没有被正确包含进系统运行流程。举一个非常常见的例子在 RTOS 里你创建了一个任务正常写法应该是xTaskCreate(core1_task_handler, core1_task, 1024, NULL, 5, task_handle);core1_task_handler这个函数确实存在也在源码里被编译了。但如果这个函数所在的文件在双核架构下没有被链接进最终固件链接器又恰好没有报错误比如函数被定义为弱符号或者链接脚本里发生了垃圾回收那么运行时会跳到空地址直接 HardFault。在 STM32H7 上还有一个更典型的场景核0 固件通过 OpenAMP 框架加载核1 固件但核1 的resource_table里没有正确声明共享内存的物理地址。加载器能够运行核1 的代码但核1 访问共享内存时地址解析失败外部表现就是某些中间的 包含关系 没有正确配置。3. 实操全过程从最小复现到彻底修复3.1 搭建一个最小复现工程让错误稳定出现与其对着大工程抓瞎不如构造一个最小复现。拿 CMake 双核裸机的工程来说先用最典型的目录结构dual_core_demo/ ├── CMakeLists.txt ├── common/ │ ├── app_protocol.h │ └── ipc.h ├── core0/ │ ├── CMakeLists.txt │ ├── main.c │ └── core0_specific.c └── core1/ ├── CMakeLists.txt ├── main.c └── core1_specific.c根目录的 CMakeLists 只负责定义两个子构建目标cmake_minimum_required(VERSION 3.16) project(dual_core_demo C ASM) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) add_subdirectory(core0) add_subdirectory(core1)core0/CMakeLists.txt 这样写add_executable(core0_fw main.c core0_specific.c ) target_compile_definitions(core0_fw PRIVATE CORE_0) target_include_directories(core0_fw PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} # 核0自己的头文件 ${CMAKE_SOURCE_DIR}/common # 共享头文件 )core1/CMakeLists.txt 这样写add_executable(core1_fw main.c core1_specific.c ) target_compile_definitions(core1_fw PRIVATE CORE_1) target_include_directories(core1_fw PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} # 核1自己的头文件 ${CMAKE_SOURCE_DIR}/common # 共享头文件 )现在模拟第一个典型错误core0/main.c里写了一行#include core1_specific.h而这个头文件在core1/目录下。由于core0_fw的 include 路径里没有../core1编译立即报错。这个最小复现的价值在于你会清楚地看到工程结构直接决定了错误的出现。只要把共享头文件移到common/问题立刻消失。很多大型项目里 Unresolved Inclusion 反复出现本质上是没有这样一个清晰的分层意识。3.2 用编译日志、预处理输出和符号表定位根因遇到实际报错时不要盯着 IDE 的错误窗口死磕。第一步永远是拿到完整、未被截断的编译日志。如果你用的是 Makefile 或 CMake务必先关闭并行编译避免信息交错make clean make VERBOSE1 21 | tee build.logCMake 环境可以用cmake --build build -j1 --verbose 21 | tee build.log在日志里按照源文件路径逐一核对该文件用了哪些头文件搜索路径。CMAKE 会打印出完整的编译命令里面的每个-I参数都值得检查。如果错误是链接阶段的undefined reference用nm和objdump直接检查符号表是最快的。假设报错的是core1.elf里缺少ipc_send先看核1 的目标文件里有没有arm-none-eabi-nm build/core1/CMakeFiles/core1_fw.dir/main.c.obj | grep ipc_send没有任何输出说明这个 obj 里没有该函数。再看整个核1链接产物arm-none-eabi-nm build/core1/core1_fw.elf | grep ipc_send如果整个 elf 里都没有说明ipc.c根本没有被编译进核1的链接列表或者ipc.c被编译成了核0的私有目标。预处理输出的用法也很有用。以核1为例查某个关键宏是否在头文件里按预期展开arm-none-eabi-gcc -E -DCORE_1 -I./common -I./core1 core1/main.c | grep APP_PROTOCOL_VERSION除了符号表和预处理链接映射文件map 文件也是双核排障的利器。CMake 生成 map 文件的方式是在链接选项里加-Wl,-Mapoutput.map。分析 map 文件时重点看两件事最终固件里包含了哪些.o文件有没有遗漏公共库文件关键内存段尤其共享内存段在两个核的 map 文件中的地址是否重叠或者冲突在 STM32H7 这类异构双核平台经常出现核0 的共享内存 SRAM 地址和核1 的配置地址错位这种问题在 map 文件里一目了然。3.3 按照根因逐步修改让双核工程恢复正常定位到根因之后修复有一定套路。不同根因有不同修法我按类型分别说。根因一头文件搜索路径不完整修改 CMake 里的target_include_directories把缺失的路径补上。但要注意补路径是治标更合理的做法是调整文件归属。如果确实有跨核共享的头文件移动到common/目录如果只是单核使用的头文件就坚决不给另一个核添加搜索路径。这样能保证双核的 include 边界清晰。根因二条件编译宏不一致统一管理宏定义。不要在源码里散落地#define CORE_0而是集中在构建配置里进行。用 CMake 的话把宏定义集中在一个顶层 option 里set(ACTIVE_CORE core0 CACHE STRING Which core to build) if(ACTIVE_CORE STREQUAL core0) add_subdirectory(core0) else() add_subdirectory(core1) endif()如果两个核必须同时编译也要确保每个核的target_compile_definitions分别定义且互不干扰。不要在一个公共变量里累积所有宏否则某次改动后某个核多带了一个不该有的宏排查起来相当头疼。根因三源文件归属错误把每个核的源文件列表精确化。CMake 里不要用file(GLOB_RECURSE ...)自动收集源文件因为这会盲目地把所有.c都拉进来。手动列出每个核的目标文件清单从源头上避免编译归属错乱set(CORE1_SOURCES main.c core1_specific.c ${CMAKE_SOURCE_DIR}/common/ipc.c ${CMAKE_SOURCE_DIR}/common/crc16.c )根因四跨核调用这是在代码层面做重写。将直接函数调用改成消息传递核心流程如下// 共享头文件 common/ipc.h typedef struct { uint32_t msg_id; uint32_t payload_len; uint8_t payload[64]; } ipc_message_t; void ipc_send(uint32_t target_core, ipc_message_t *msg); // core0 侧注册回调 void core0_on_msg(const ipc_message_t *msg) { switch (msg-msg_id) { case MSG_START_ENGINE: core0_start_engine(); break; default: break; } }核1 不再直接调用core0_start_engine()而是ipc_send(0, msg)。这个改动虽然会引入一些异步消息的复杂性但它是异构双核之间唯一的正规通信路径。根因五运行时注册缺失重点检查系统初始化阶段的注册顺序。RTOS 的任务创建、中断回调注册、IPC 消息 handler 注册都要确保发生在核心启动之前或初始化完成之后正确的时机。双核启动时两个核心有固定的启动时序核1 的初始化代码如果跑到核0 还没准备好的资源就会产生运行时崩溃。4. 常见报错速查表与双核工程避坑清单4.1 常见错误信息与对策对照表我整理了双核项目里最高频的几类 Unresolved Inclusion 报错连同原因和处置办法做成一个速查表方便大家直接按图索骥。错误信息出现阶段常见根因处置建议fatal error: xxx.h: No such file or directory编译include 路径缺失检查 -I 路径把跨核共享头文件移到公共目录#error Must define CORE_0 or CORE_1编译预处理条件编译宏未定义检查目标核的 compile_definitionsundefined reference to xxx链接源文件未加入该核编译列表检查该核的源文件清单补齐缺失文件multiple definition of xxx链接源文件被两个核心重复编译重新梳理源文件归属排除不该编译的目录cannot find entry symbol链接缺少启动文件或入口符号检查每个核的 startup 文件是否正确链接运行时 HardFault / 死机运行回调/任务/中断未注册检查初始化顺序和资源注册时机共享内存数据错乱运行两个核的链接脚本地址不一致对比两个 map 文件中的内存段分配这张表不必背下来关键是养成一个习惯拿到错误先判断阶段编译期报错优先查路径和宏链接期报错优先查源文件归属和符号表运行时报错优先查初始化和注册。4.2 双核工程的目录与代码组织建议经过几个项目的教训我现在基本固定了一套双核工程的推荐组织方式能避免大部分 Unresolved Inclusion 问题project/ ├── common/ # 两个核都可见的公共代码 │ ├── include/ │ │ ├── ipc.h │ │ └── app_protocol.h │ └── src/ │ ├── ipc.c │ └── crc16.c ├── core0/ │ ├── include/ # 只有核0可见 │ ├── src/ │ └── linker_script.ld └── core1/ ├── include/ # 只有核1可见 ├── src/ └── linker_script.ld这套结构最重要的规则是跨核共享的东西必须放 common不共享的东西必须放在各自核的私有目录下绝不允许互相 include。你可能会觉得这样很死板但双核项目到最后拼的不是代码能力而是工程边界管理能力。代码层面也需要一些约定。每个源文件开头统一用条件编译明确自己的运行目标#if !defined(CORE_0) !defined(CORE_1) #error This file must be compiled with CORE_0 or CORE_1 defined #endif有了这个保护一个源文件被错误地编译到另一个核时会立刻在编译阶段暴露而不是等到运行时才出问题。4.3 排查工具和命令小抄工欲善其事必先利其器。双核工程的排查工具跟单核相比并没有多出太多新东西但要会用得更精准。编译日志分析前面提过的--verbose是第一步看到完整的编译命令后重点检查-I参数、-D宏定义、源文件列表。很多问题在这一步就能定位。预处理文件检查# 生成预处理结果检查宏展开后的实际效果 arm-none-eabi-gcc -E -DCORE_1 -I./common -I./core1 core1/main.c -o main.i在main.i里搜关键宏、函数声明确认头文件里被条件编译隐藏的内容是否如预期。符号表检查# 查看某个目标文件中是否有指定符号 arm-none-eabi-nm core1.main.o | grep ipc_send # 查看整个固件中的未定义符号 arm-none-eabi-nm core1.elf | grep U 链接映射文件检查# 在链接选项里加 -Wl,-Mapoutput.map 后 grep -A 20 Memory Configuration output.map重点检查共享内存区域的地址在两个核的 map 中是否一致入口符号是否都指向正确的启动代码。运行时调试双核调试比单核复杂因为两个核同时在跑。最简单的起步手段是在每个核的入口处设置断点或打印确认每个核是否都进入了 main 函数。如果只有一个核进入另一个核卡死问题多半在启动文件或时钟配置。两个核都进入 main 后程序崩溃优先查 IPC 或共享内存初始化时机。4.4 我在实际项目中总结的避坑清单最后分享几条踩出来的经验虽然看起来都是小细节但每一条都让项目少走了很多弯路。第一不要把两个核的代码放在同一个编译目标里。哪怕用的是 ESP32 这种统一固件的 SMP 架构也要在源码层面用清晰的模块边界分割核0和核1的功能不要让某个函数在源文件里跨越太大。第二公共头文件里不要写static inline函数时依赖核专属宏。静态内联函数会在每个包含它的编译单元里展开如果展开时的宏环境不同同一个头文件会产生不同的行为。我见过最离谱的一次是一个内联的寄存器读取函数在核0展开时读的是一个外设在核1展开时读的完全是另一个外设地址。第三重视链接脚本的 diff。双核工程修改链接脚本后一定要同时对比两个核的改动。STM32H7 这类平台核0的链接脚本里RAM区域的起始地址如果改了一个字节而核1 没改共享内存的通信协议整个失效报错方式极难排查。第四用版本管理工具跟踪构建配置文件的每一次改动。CMakeLists.txt、Makefile、链接脚本、sdkconfig这类构建配置文件是双核工程里最关键的资产。很多 Unresolved Inclusion 问题不是一次写出来的而是某次改动引入的回归。没有版本记录你很难知道上一次编译好好的为什么这次突然报错。第五在 CI 里同时构建两个核的产物。如果团队有自动化构建环境务必把核0和核1的构建都纳入检查。只构建其中一个核另一个核长期处于断裂状态等最后集成时再集中爆发那种场面我见过太多次了。5. 几点心得体会以及后续可以继续深挖的方向我一开始接触双核项目时也走过弯路遇到 Unresolved Inclusion 总是下意识地觉得是代码写错了翻来覆去检查语法后面才发现大部分问题出在构建系统和工程组织上。双核开发最反直觉的地方在于它逼着你把编译这件事本身当成核心功能来设计和维护而不是把它当作一个理所当然的执行步骤。单核项目里你几乎不用关心 include 路径双核项目里这种粗心会被无数倍地放大。我个人现在排查这类问题的标准流程很简单先看报错阶段再列核心边界再查构建配置最后才怀疑代码逻辑。用这套流程走下来绝大多数问题都能在半小时内定位。如果双核工程的项目预算允许后续可以考虑往多核调试器方向投入一点精力。SMP 架构下用 JTAG 同时查看两个核的寄存器状态对排查运行时崩溃效果非常显著。AMP 架构下虽然没有统一调试视图但通过硬件邮箱和共享内存环形缓冲区搭一套简易的核间日志系统也能大幅提升诊断效率。这些都是双核项目里值得好好打磨的能力。
返回列表