最近在折腾 STM32N6,这颗芯片和之前玩过的 M 系列有个很不一样的地方:内部直接集成了 Neural-ART NPU,跑 AI 模型不再依赖 CPU 纯算硬扛。我照着官方 Neural-ART 教程拉了一个示例工程,目标是在一个轻量分类模型上把推理结果通过串口打印出来。结果倒好,编译阶段一路绿灯,链接阶段突然报错:
undefined reference to MX_USART1_UART_Init这类错误对老手来说不算陌生,基本就是“函数声明能找到,但实现没被链接进来”。奇怪的是,我在工程里明明看到了 usart.c,函数也写得清清楚楚,为什么还是 undefined reference?这个坑我前前后后折腾了很久,最后定位到问题出在构建系统的源文件列表上——外设初始化文件压根没被收进编译目标。这篇文章把完整排查过程、修复操作,以及一套通用的 undefined reference 排查方法都整理出来。不管你是用 STM32CubeIDE,还是跟我一样用 CMake 命令行构建,都应该用得上。
1. 先搞懂错误本质:链接器到底在抱怨什么
1.1 undefined reference 不等于代码写错了
很多人看到 “undefined reference” 会下意识觉得是“函数没定义”。对,但也不全对。把编译和链接两个阶段拆开看就很清楚了:编译阶段,编译器拿.c文件做语法分析和代码生成,它只需要在头文件里看到函数的声明,知道“有这么个函数、参数长这样”就够了,所以main.c调用MX_USART1_UART_Init()时,只要包含了usart.h,编译就能过。真正的问题出在最后的链接阶段:链接器要把所有.o目标文件、静态库和启动文件拼成最终的可执行程序,这时它必须找到每一个被调用函数的具体实现,也就是符号对应的机器码。找遍所有输入文件都没找到,就报undefined reference。
打个比方:编译阶段就像你在一份 API 手册上看到了某函数的介绍,可以放心在代码里写调用;链接阶段则像你真正打电话,得确保对方手机开机、号码也有对应的终端。如果手册有记录,但电话打不通,那问题就不在“手册写错了”,而在“对方根本没接入网络”。对应到工程里,常见的原因是:源文件没参与编译、函数实现被条件编译关掉了、文件被构建系统排除,或者链接时库的顺序不对。
所以,看到这个报错不要急着怀疑是不是算法模型出了问题,也不要觉得是工具链坏了。这是一个纯粹的构建配置问题,排查路径非常固定,下面我会按顺序拆开讲。
1.2 为什么偏偏是 MX_USART1_UART_Init
MX_前缀是 STM32CubeMX 生成代码的标志。USART1表示目标外设是 USART1,UART_Init是初始化函数,合起来就是“初始化 USART1 的 CubeMX 生成函数”。在 Neural-ART 教程工程里,串口通常是用来打印推理结果、模型运行耗时和日志信息的。模型跑完 NPU 之后,分类结果、置信度、帧率这些数据都要靠串口送到 PC 端调试助手,所以 UART 几乎是所有 AI 示例工程的标配外设。
这里要特别注意:MX_USART1_UART_Init不是 HAL 库自带的函数,它是 CubeMX 根据你在.ioc文件里的外设配置动态生成的用户层代码。它的定义在Core/Src/usart.c里,声明在Core/Inc/usart.h里。如果链接器说找不到这个符号,问题大概率不是 ST 官方 HAL 库的问题,而是工程自己生成的外设初始化代码没有被完整纳入构建流程。
搞清这一点,后面的排查思路就清晰了:不要先去查 HAL 库的源码,先把“这个函数定义在哪个文件、这个文件是否被编译”这两件事弄清楚。
1.3 STM32N6 工程中这个符号的“出身”
STM32N6 的工程结构和老 M 系列有一点明显不同。老工程通常是 CubeMX 生成一个 IDE 工程,源文件集中在Core/Src,然后用 IDE 直接编译。N6 的 AI 示例工程很多时候是从 ST 的 Edge AI package 拉下来的,构建方式可能是 CMake、Makefile,甚至 ST 自己的命令行工具链。这类工程里,Core/Src/usart.c不一定被自动收集,尤其当构建脚本用的是显式源文件列表时,多一个少一个文件非常常见。
另外,N6 的教程工程为了封装模型处理逻辑,往往会把代码拆成App、Core、Middlewares多个目录。有的版本甚至会把外设初始化文件放到 App 层,比如App/app_usart.c。这种情况下,符号名可能变成APP_USART1_Init,或者文件位置变了但main.c里的调用还是旧的MX_USART1_UART_Init,名字对不上也会出现 undefined reference。
先理解符号的“出身”,再动手排查,比一股脑重装工具链高效得多。
2. 五步排查法:定位符号为什么没被链接进来
2.1 第一步:先确认定义真的存在
排查的第一步永远是搜代码。在工程根目录执行:
grep -rn "MX_USART1_UART_Init" --include="*.c" --include="*.h" .正常情况下会看到两种结果:一个在.h文件里的声明,一个在.c文件里的函数定义。如果连定义都搜不到,那就说明 CubeMX 生成代码时没把 USART 外设的初始化代码生成出来,或者生成到了别的目录。打开.ioc文件,在 Pinout & Configuration 里确认 USART1 是否已经配置为启用状态,然后在 CubeMX 里重新生成一次代码。
如果定义存在,但链接还是报错,先别急着看 IDE,用nm看一眼目标文件到底有没有把符号编译出来。假设你的构建输出目录是build:
arm-none-eabi-nm build/Core/Src/usart.o | grep MX_USART1如果输出类似00000000 T MX_USART1_UART_Init,说明目标文件里确实有这个定义;如果没有任何输出,说明你搜到的.c文件可能没有参与编译。我在实际排查中就遇到过一种情况:源码目录里有一个usart.c,但它根本没被 CMake 的源文件列表引用,所以编出来的usart.o压根不存在,自然搜不到符号。
2.2 第二步:确认定义没有被条件编译掐掉
还有一种情况是函数定义存在,但被预处理指令包住了,实际编译时被跳过。CubeMX 生成的usart.c里虽然一般不会给MX_USART1_UART_Init套#ifdef,但 HAL 库整体的外设模块可以裁剪。stm32n6xx_hal_conf.h中有一个HAL_UART_MODULE_ENABLED宏,如果它被注释掉或者没定义,HAL 层就不会编译 UART 相关代码,某些版本的生成逻辑甚至会把usart.c的内容也弱化。
检查方法很简单:打开stm32n6xx_hal_conf.h,搜HAL_UART_MODULE_ENABLED。正常情况下应该是:
#define HAL_UART_MODULE_ENABLED如果是/* #define HAL_UART_MODULE_ENABLED */这种注释状态,说明在 CubeMX 的 Advanced Settings 里把 UART 模块裁剪掉了。重新打开.ioc,把 UART 模块勾回来,重新生成代码。这里还要留意:不要只改宏,因为 CubeMX 重新生成时可能会覆盖掉其他手动修改,最好是走.ioc配置流程,而不是手改配置头文件。
2.3 第三步:确认源文件真的参与编译了
这一步是大多数人卡住的地方,也是我这次踩坑的核心原因。如果你用 STM32CubeIDE,右键项目,在 Project Explorer 里找到Core/Src/usart.c,看文件名上有没有“排除出构建”的灰色减号标记。如果有,右键文件 → Resource Configurations → Exclude from Build,取消勾选即可。
但更隐蔽的情况是:文件看起来在工程里,IDE 也能看到,但构建系统压根没把它编进去。CubeIDE 底层如果是 CMake 工程,源文件列表由CMakeLists.txt控制;如果是普通 Managed Build 工程,Eclipse 会从项目路径扫描源文件。后者一般不会漏,前者特别容易漏。
命令行构建的话,直接检查构建脚本。教程工程最常见的是 CMake 的显式源文件列表:
set(SOURCES Core/Src/main.c Core/Src/stm32n6xx_it.c App/ai_runtime.c )如果这个列表里没有Core/Src/usart.c,那链接器永远找不到这个函数。修复就是加一行:
set(SOURCES Core/Src/main.c Core/Src/usart.c Core/Src/stm32n6xx_it.c App/ai_runtime.c )如果工程用的是file(GLOB_RECURSE ...)方式收集源文件,那新增文件后没有重新运行 cmake 配置,也会导致文件没进构建。解决办法是删掉build目录,重新执行cmake -S . -B build,让 GLOB 重新扫描。
2.4 第四步:检查链接脚本和库依赖顺序
如果MX_USART1_UART_Init的实现被打包到了静态库里,比如官方的某个板级支持库.a,那链接顺序就变得非常关键。GNU ld 处理静态库时,原则是“遇到未解析符号才从库里抽取目标文件”。如果链接命令里,引用这个符号的目标文件排在库文件后面,符号可能就无法被解析。
典型报错场景是:
arm-none-eabi-gcc ... main.o libbsp.a -o app.elf有些情况下这样能过,有些情况下就必须把库放到引用它的人后面。最保险的办法是给链接器加组选项:
arm-none-eabi-gcc ... -Wl,--start-group main.o libbsp.a -Wl,--end-group -o app.elf--start-group会让链接器在库组内反复扫描,直到符号解析完成或没有新符号可以被解析。不过在我这次遇到的问题里,usart.c并不是库,而是被遗漏的源文件,所以更可能是源文件列表的问题,库顺序是后面才需要考虑的排查方向。
如果你在链接日志里看到了libneural-art.a之类的库,并且错误恰好出现在库后面的符号引用,那可以先尝试调整顺序,再用--start-group包住库组。不要一上来就改链接脚本,很多时候不是链接脚本的问题。
2.5 第五步:警惕 C/C++ 符号修饰差异
还有一个不太常见但一碰就容易懵的原因:C 和 C++ 的符号修饰规则不同。C++ 编译器会对函数名做 name mangling,比如void MX_USART1_UART_Init(void)在 GCC ARM C++ 编译下会变成_Z21MX_USART1_UART_Initv。如果调用方的文件被当成 C++ 编译,而被调用的定义文件被当成 C 编译,两边符号对不上,就会报 undefined reference。
用nm一眼就能看出来:
arm-none-eabi-nm build/App/main.o | grep MX_USART1 arm-none-eabi-nm build/Core/Src/usart.o | grep MX_USART1如果一边是U MX_USART1_UART_Init,一边是T _Z21MX_USART1_UART_Initv,那基本就是编译语言不统一。解决办法是给头文件加extern "C"保护:
#ifdef __cplusplus extern "C" { #endif void MX_USART1_UART_Init(void); #ifdef __cplusplus } #endif如果是 CubeMX 生成的头文件,新版本一般已经带了保护,但有些旧版本没有。另外,如果教程工程把工程拆成了安全区/非安全区两个子工程,也就是 TrustZone 场景,还要确认MX_USART1_UART_Init的调用方和定义方在同一个子工程里。跨工程访问外设初始化函数需要额外做符号导出或者放到共享库中,不能默认两边都能看到。
3. 针对 Neural-ART 教程工程的实操修复过程
3.1 复现我的失败现场
我这次用的是官方某个 STM32N6 AI package 里带的人脸检测示例,构建流程是 CMake。目录结构大概是:
project/ ├── CMakeLists.txt ├── App/ │ ├── main.c │ └── ai_runtime.c ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ ├── usart.c │ └── stm32n6xx_it.c └── Middlewares/ └── neural_art/执行:
cmake -S . -B build cmake --build build结果在链接阶段报错:
[ 90%] Linking C executable test_fd /usr/bin/ld: CMakeFiles/test_fd.dir/App/main.c.o: in function `main': main.c:(.text+0xa4): undefined reference to `MX_USART1_UART_Init' collect2: error: ld returned 1 exit status我第一时间在源码里搜索,usart.c确实存在,MX_USART1_UART_Init定义也清清楚楚。我用nm检查,却发现build目录下压根没有生成usart.o。这时才意识到,问题出在CMakeLists.txt的源文件列表。这个教程工程为了便于用户阅读,把主程序放在App/main.c,但外设初始化的usart.c还留在Core/Src,而 CMake 列表里只写了Core/Src/main.c和Core/Src/stm32n6xx_it.c,忘了加usart.c。
3.2 CubeIDE 工程里的操作路径
如果你不是在命令行用 CMake,而是在 STM32CubeIDE 里直接打开工程,处理路径稍有不同。先看 Project Explorer 里Core/Src/usart.c是否存在。文件存在时,确认它没有被标记为“排除出构建”。Eclipse 对这种文件会在文件图标上叠加一个减号,鼠标放上去会提示“Exclude from build”。
如果有排除标记,右键文件 → Resource Configurations → Exclude from Build,把勾去掉。如果文件根本不在工程树里,右键工程 → Refresh,让 IDE 重新扫描磁盘。如果扫描后还是没有,大概率是工程文件.project里的 source entry 配置不对,或者工程目录和源码目录不一致。这时候别死磕 IDE,打开.cproject和.project看 source entry 路径,把Core/Src目录加回来。
CubeIDE 还有一种情况:如果你使用 CubeMX 重新生成代码时选错了“Application Structure”,比如从“Advanced”切回“Basic”,生成的外设文件位置会变化。旧文件被留在磁盘上,但新的构建列表已经不再包含它。碰到这种“文件在但参与不了构建”的情况,最好的办法是备份自己的修改,然后用 CubeMX 重新生成一个干净的工程,再手动合并修改。
3.3 Makefile/CMake 工程里的处理路径
Makefile 工程相对好处理。打开 Makefile,找到C_SOURCES变量的定义,看里面有没有Core/Src/usart.c或者wildcard方式。STM32CubeMX 默认生成的 Makefile 会通过通配符自动收集Core/Src/*.c,一般不会漏。但教程工程的 Makefile 可能是手写的,源文件列表写得很随意,这种就要手动补。
C_SOURCES = \ Core/Src/main.c \ Core/Src/usart.c \ Core/Src/stm32n6xx_it.c改完之后执行make clean,再重新make。为什么要 clean?因为旧的main.o可能还残留着 undefined reference 的信息,如果 Makefile 没正确追踪头文件依赖,你可能改了构建列表但没有触发重新链接,这时候全量重建是最省心的。
CMake 工程更简单,把usart.c添加进 target 的源文件列表即可。但要注意:如果你用的是target_sources,要确保在正确的 target 下添加。比如:
add_executable(test_fd ${SOURCES}) target_include_directories(test_fd PRIVATE Core/Inc) target_sources(test_fd PRIVATE Core/Src/usart.c)添加后,建议rm -rf build再重新配置。因为 CMake 的配置缓存有时会保留旧的源文件列表,尤其当你不是从CMakeLists.txt的根目标添加时,增量配置不一定生效。删除 build 目录是最不费脑子的做法。
3.4 重新生成后的验证清单
修好构建列表后,重新编译。链接通过只是第一步,我建议按下面的清单做一遍验证,避免表面上修好了,烧到板子上又发现串口不工作:
- 确认编译日志里真的出现了
usart.c的编译命令。开着make VERBOSE=1或者 CMake 的CMAKE_VERBOSE_MAKEFILE=ON,看到usart.c被编译到目标文件,才说明文件真正参与了构建。 - 用
nm确认符号类型。arm-none-eabi-nm build/.../usart.o | grep MX_USART1,看到T MX_USART1_UART_Init表示定义存在,且是全局可链接符号。 - 烧录后打开串口调试助手,看有没有模型初始化日志。如果没有任何输出,先用调试器检查
huart1.gState是否是HAL_UART_STATE_READY,检查 USART1 的 GPIO 复用配置是否正确。 - 检查时钟树。USART1 在 STM32N6 上的时钟源可能来自某个 PLL 或 HSI,如果时钟树配置不对,波特率会偏移,串口打印乱码也是常有的事。
链接成功不代表外设配置成功,这是两件事,别混在一起。
4. 这类错误的常见变体和速查表
4.1 不只是 MX_ 函数,其他高频 undefined reference 变体
嵌入式里undefined reference太常见了,远不止MX_函数这一种。比较高频的几个:
undefined reference to main:整个工程的入口函数缺失。常见于启动文件选错、删了 main 函数、或者链接时没把包含 main 的目标文件加进来。undefined reference to HAL_UART_Transmit:这是 HAL 库函数缺失。原因通常是 HAL 库源文件没加入构建,或者stm32n6xx_hal_conf.h里裁剪了 UART 模块。undefined reference to _exit、undefined reference to __aeabi_*:这是编译器 runtime 库缺失。多半是工具链安装不完整,或者链接选项里少了--specs=nano.specs之类的参数。undefined reference to printf:常见于使用半主机模式时标准库实现不完整。嵌入式里一般用 retarget 重定向,或者勾选 MicroLib。
这些变体的排查思路和MX_USART1_UART_Init是相通的:先定位符号属于哪个源文件或库,再确认这个文件或库是否被链接器作为输入。不要去背每个符号的含义,掌握方法比记结论重要。
4.2 问题排查速查表
| 线索 | 可能原因 | 推荐操作 |
|---|---|---|
| grep 整个工程都搜不到函数定义 | CubeMX 没生成该外设代码,或文件在别的目录 | 打开.ioc,确认 USART 外设已启用,重新生成代码 |
usart.c存在,但文件上有灰色减号 | IDE 将文件排除出构建 | 右键文件,取消 Exclude from Build |
usart.c存在,但构建日志里没有它 | CMakeLists 或 Makefile 源文件列表遗漏 | 把文件路径加入构建列表,重新配置 |
| 只有声明没有定义 | 定义文件被重命名或移动 | 对比官方例程,统一函数名和路径 |
nm发现符号带_Z前缀 | C/C++ 编译语言不一致 | 给头文件加extern "C" |
| 链接命令里库顺序不对 | 静态库解析符号时序问题 | 使用-Wl,--start-group/--end-group |
HAL_UART_MODULE_ENABLED被注释 | 外设宏被裁剪,HAL 源码未编译 | 在 CubeMX 中恢复 UART 模块并重新生成 |
| 工程拆分为安全区/非安全区子工程 | 函数定义和调用不在同一侧 | 将定义移到调用方同一子工程,或通过接口导出 |
这张表基本覆盖了这类错误的 90% 场景。剩下的 10% 属于工具链本身的 bug 或工程缓存问题,一般通过 clean 和重新配置就能解决。
4.3 为什么教程工程最容易被这种坑到
Neural-ART 这类 AI 教程工程和普通外设例程不同,它同时涉及 CubeMX 生成代码、NPU 库、AI 模型文件、可能的 C++ 接口层,甚至还有 RTOS。工程结构比“点灯工程”复杂一个量级,构建配置很容易出问题。很多教程压缩包发布时,作者是为了在某个特定版本的工具链上演示,他可能忘了更新 CMakeLists,或者发出来的工程已经经过本地手工调整,换一台电脑、换一个工具链版本就露馅。
还有一个现实因素是:STM32N6 比较新,各种 AI package 的版本迭代很快。有时候你下载的是旧教程,配套的 CubeMX 版本却是新的,重新生成代码后文件结构变了,但教程里的构建脚本还是老一套,两边对不上就报错。所以我的建议是:先把官方压缩包存一份不动,然后另建目录做修改。一旦遇到奇怪的构建问题,就用git diff或者直接对比官方目录,重点看源文件列表差异,往往几秒就能发现问题。
5. 写到最后:三个实用小习惯
踩过这次坑之后,我给自己定了三条规矩。第一,从 GitHub 或者官方包拉教程工程时,先跑一遍构建,确认基础环境没问题,再做任何代码修改。如果这步就报链接错误,优先看源文件列表,别上来就怪编译器。第二,CubeMX 生成的外设代码,尽量不要手动重命名,比如把MX_USART1_UART_Init改成自己的InitUart之类的,除非你能保证同步修改所有声明、定义和头文件保护规则。保持默认链路,以后重新生成代码、升级版本都能省很多事。第三,凡是链接错误报的是MX_开头的符号,先怀疑构建配置,再去翻代码。这类函数是 CubeMX 自动生成的,几乎不会有逻辑问题,大概率是文件没有参与构建。
修完这个报错,后面真正让我头疼的其实是 NPU 模型推理性能和串口打印格式的调优。但反过来想,如果当时没有把链接错误这一套彻底研究透,后面遇到模型库链接失败、符号冲突的时候,我可能还会走很多弯路。希望这篇记录能帮你早点从“编译不过”的泥潭里跳出来,把时间花