这两天把一个 STM32N657 的启动工程从 STM32CubeMX 导出来,编译的时候一切正常,到了链接阶段直接弹出一排 undefined reference,FSBL.elf 生成失败。第一眼看到这个报错,我以为是工具链或者启动文件出了问题,折腾了半天环境变量,最后才发现问题出在一个特别容易忽略的地方:CubeMX 生成的工程里,压根就没有编译我代码里调用的那几个 HAL 驱动源文件。这篇文章就把这个问题的完整链路拆开讲一遍,包括为什么会出现、怎么定位、以及最直接的修复和预防办法。对正在用 STM32CubeMX 做 STM32N657(N6 系列)FSBL 工程的开发者,应该能少走不少弯路。
1. undefined reference 读法:链接器到底在抱怨什么
1.1 先看一个典型报错长什么样
用 STM32CubeMX 生成 FSBL 工程后,在 STM32CubeIDE 里直接点击 Build,控制台会打出一段类似这样的内容:
c:/st/stm32cubeide_1.14.1/stm32cubeide/plugins/com.st.stm32cube.ide.mcu.externaltools.gnu-tools-for-stm32.11.3.rel1.../arm-none-eabi/bin/ld.exe: FSBL.elf: in function `main': C:/workspace/n657_fsbl/Core/Src/main.c:130: undefined reference to `HAL_ETH_Init' collect2.exe: error: ld returned 1 exit status make: *** [Makefile:177: FSBL.elf] Error 1我第一次看到这个报错时,注意力全被最后一行的make: *** Error 1吸引过去了,以为是 Makefile 出了问题。后来把中间那行undefined reference to 'HAL_ETH_Init'单独拎出来看,才意识到方向完全错了。undefined reference的意思非常直白:链接器在整个工程编译后的所有目标文件里,都找不到HAL_ETH_Init这个符号的定义。
1.2 链接阶段到底在做什么
这里可以打一个比方。每个.c文件编译后得到一个.o目标文件,就像每个工匠各自完成了一批零件。编译阶段,工匠只需要看到图纸上写了"零件 A 的接口长这样",也就是头文件声明,就可以把零件做出来。但到了链接阶段,设计总师要把所有零件组装成整机时,发现图纸上写了"此处安装传动齿轮",可交付物清单里压根没有这个齿轮。于是总师就喊:"undefined reference,这个符号没人提供。"
链接器不关心头文件里有没有声明,它只认实现。错误信息里出现的那个函数名,就是最关键的线索。特别需要注意的是,链接器不会直接告诉你"缺少 stm32n6xx_hal_eth.c 文件",它只会说"找不到 HAL_ETH_Init 的定义"。从函数名到源文件名的这一步映射,需要我们自己去完成。
1.3 编译通过但链接失败,是最有信息量的信号
很多人会忽略一个关键事实:所有.c文件都能编译过去,说明语法没有问题,头文件路径也能找到。真正的问题只可能出在"某个函数被调用了,但对应的实现文件没有参与构建"。
这和编译错误有本质区别:
| 现象 | 本质 | 典型报错 |
|---|---|---|
| 编译失败 | 语法错误、找不到头文件、类型不匹配 | fatal error: stm32n6xx_hal.h: No such file or directory |
| 链接失败 | 声明存在但定义缺失、实现文件未参与构建 | undefined reference to 'HAL_ETH_Init' |
如果你看到的是编译失败,优先去查 include 路径、宏定义、语法。如果看到的是链接失败,优先去查构建系统里到底包含了哪些源文件。方向对了,排查时间能缩短一半以上。
2. 缺文件的根源:CubeMX 生成 FSBL 工程时的 HAL 筛选机制
2.1 FSBL 工程的"瘦身"逻辑
FSBL 是 First Stage Boot Loader 的缩写,在启动链上承担的是最早期硬件初始化的角色。STM32N657 这类 N6 系列 MCU 没有内部大容量 Flash,BootROM 必须从外部存储介质引导,FSBL 先初始化时钟、电源、外部存储(QSPI/OSPI Flash 或 DDR),再把真正的应用程序从外部存储搬运到 RAM 中执行。
正因为职责单一,CubeMX 在为 FSBL 工程挑选 HAL 驱动文件时,策略比普通应用工程保守得多。它不会把整个 HAL 库一股脑塞给你,而是按照当前图形界面上配置的外设,自动生成一份"必要文件清单"。这份清单的判定依据,是你在 CubeMX 图形界面里勾选的外设,而不是你在源代码里调用了哪些 HAL 函数。
2.2 "Copy only the necessary library files" 是第一个关键嫌疑
在 STM32CubeMX 的 Project Manager -> Code Generator 页面,有一个选项叫 "Copy only the necessary library files":
- 勾选:CubeMX 只复制与当前外设配置相关的 HAL 源文件到工程目录,工程很精简,但一旦代码里手动调用了某个图形界面里没有配置的外设,链接阶段就会缺文件。
- 不勾选:CubeMX 会把整个 HAL 库的源文件都复制进工程,几乎不会出现本文这种问题,代价是工程目录变大,编译时间也会变长。
FSBL 工程踩坑概率高的另一个原因在于,很多 FSBL 初始化代码本身就是通过用户代码直接操作寄存器或调用 HAL 函数完成的。比如你想在 FSBL 里加一段以太网诊断输出,但 CubeMX 的 Pinout 页面里根本没有配置 ETH 外设,那么生成出来的工程里就不会有 stm32n6xx_hal_eth.c。等链接器听到你调用 HAL_ETH_Init,自然就报 undefined reference。
2.3 N657 的 HAL 驱动文件拆得更细,问题更容易暴露
STM32N657 属于 STM32N6 系列,HAL 驱动的分文件粒度比老一代 F 系列要细得多。F 系列里一个 stm32f1xx_hal.c 可能涵盖了很多通用逻辑,但 N6 系列把 RCC、RCCEx、DMA、DMAMUX、Cortex、EXTI 等模块都拆成了独立的源文件。
这是好事,工程裁剪更灵活;但坏处是依赖链条变长了。举个我实际遇到的例子:ETH 驱动看起来只是少了一个 stm32n6xx_hal_eth.c,但 ETH 的 MSP 初始化函数可能会调用 HAL_GPIO_Init、HAL_RCC_ETH_CLK_ENABLE、HAL_DMA_Init 等底层接口。如果底层这些模块的源文件也没在构建列表里,链接器就会继续抛出下一批 undefined reference。于是错误列表越积越长,看起来像是一场大爆炸,实际只是少了一两个"源头文件"。
2.4 版本不匹配也会放大问题
还有一个容易被忽略的因素:CubeMX 版本与 STM32N6 固件包版本不匹配。N657 是相对较新的器件,老版本 CubeMX 的 MCU 数据库里可能没有完整的 N657 HAL 文件清单,生成时可能跳过一部分驱动文件;或者固件包下载不完整,驱动目录里本身就少了某个文件。
我建议先用支持 N657 系列的新版 CubeMX,然后在 Help -> Manage Embedded Software Packages 里确认 STM32N6 系列固件包已经完整安装。版本对齐后,很多生成层面的"灵异事件"会自行消失。
3. 完整排查链路:从报错符号一路追到缺失的 .c 文件
3.1 第一步:抓全错误,而不是只看 IDE 列出的头两条
STM32CubeIDE 的 Problems 面板有时只显示前几个错误,会掩盖全貌。最稳妥的方法是直接看构建日志,或者干脆在命令行里编译一遍。
如果你的工程是 CubeMX 生成的 Makefile 工程,在工程目录下可以直接执行:
cd build make 2>&1 | tee build_log.txt grep "undefined reference" build_log.txt如果是在 STM32CubeIDE 里,打开 Console 视图,把完整输出复制出来。我见过有人只看 IDE 顶部弹窗里的两三条错误,然后就被带到沟里去了。完整错误列表才是后续定位的基础。
假设输出是这样的:
undefined reference to `HAL_ETH_Init' undefined reference to `HAL_ETH_ReadPHYRegister' undefined reference to `HAL_GPIO_Init' undefined reference to `HAL_DMA_Init'看到这些符号时,第一反应不是去改代码,而是把这些符号收集起来,统一做映射。
3.2 第二步:把符号映射回源文件
使用 grep 在固件包 HAL 驱动目录里搜索函数名,通常能直接搜到定义所在的源文件:
grep -rn "HAL_ETH_Init" /path/to/STM32Cube_FW_N6/Drivers/STM32N6xx_HAL_Driver/Src/输出会指向 stm32n6xx_hal_eth.c。这个映射关系还可以整理成一张常用表,方便下次直接使用:
| 未定义符号示例 | 通常所在 HAL 源文件 | 常见连带缺失 |
|---|---|---|
| HAL_RCC_OscConfig | stm32n6xx_hal_rcc.c | stm32n6xx_hal.c |
| HAL_UART_Init / HAL_UART_Transmit | stm32n6xx_hal_uart.c | stm32n6xx_hal_gpio.c |
| HAL_ETH_Init / HAL_ETH_TransmitFrame | stm32n6xx_hal_eth.c | stm32n6xx_hal_gpio.c、stm32n6xx_hal_dma.c |
| HAL_SD_Init / HAL_SD_ReadBlocks | stm32n6xx_hal_sd.c | stm32n6xx_hal_gpio.c、stm32n6xx_hal_dma.c |
| HAL_DMAEx_MultiBufferStart | stm32n6xx_hal_dma_ex.c | stm32n6xx_hal_dma.c、stm32n6xx_hal_dmamux.c |
| HAL_RCCEx_PeriphCLKConfig | stm32n6xx_hal_rcc_ex.c | stm32n6xx_hal_rcc.c |
表里的文件名以实际固件包结构为准,不同小版本可能略有差异,但映射思路是通用的。有一个非常有价值的细节:如果一个源文件里的多个函数同时出现在报错列表里,比如 HAL_ETH_Init 和 HAL_ETH_ReadPHYRegister 都是 undefined,那么基本可以锁定就是 stm32n6xx_hal_eth.c 没参与构建。
3.3 第三步:核对构建系统里的源文件列表
确认了缺失的源文件之后,打开工程目录下的 Makefile,找到C_SOURCES变量。CubeMX 生成的 Makefile 里,这个变量列出了所有参与编译的 C 文件:
C_SOURCES = \ Core/Src/main.c \ Core/Src/stm32n6xx_hal_msp.c \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal.c \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_rcc.c \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_gpio.c \ ... # 注意:里面没有 stm32n6xx_hal_eth.c如果确实没有,就基本坐实了"源文件未加入构建"这个结论。如果你用的是 STM32CubeIDE 打开的工程,构建系统同样维护了一份参与编译的文件列表,只是隐藏在 IDE 的工程配置里,本质逻辑和 Makefile 完全一样。
3.4 第四步:回到 CubeMX 核对外设配置
最后一步是追溯根因。打开.ioc文件(CubeMX 的工程配置源文件),在 Pinout & Configuration 页面里检查你代码中调用的外设是否真的被启用了。
比如我踩过的场景:代码里写了 HAL_ETH_Init,但 CubeMX 的 Connectivity 分类下 ETH 根本没有勾选,引脚分配表里也没有 ETH 的功能映射。这时候就清楚了,不是 CubeMX 生成逻辑出了 bug,而是配置和代码脱节了。还有一种情况:外设确实配置了,但重新生成工程时用户代码被 CubeMX 保留,而外设配置已经被改动过,旧代码调用了一个已经不存在的外设接口,同样会报 undefined reference。
4. 修复实操:手动补 HAL 源文件与回炉 CubeMX 两种方案对比
4.1 方案 A:手动补源文件,三步完成
最快的解决办法,是把缺失的 HAL 源文件加入构建系统,具体分三步:
第一步,把源文件复制到工程目录。如果 CubeMX 生成了完整的 HAL 驱动目录但只是文件没进编译列表,这一步可以省略;如果整个文件都不在工程里,就从固件包复制过来:
cp /path/to/STM32Cube_FW_N6/Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_eth.c \ Drivers/STM32N6xx_HAL_Driver/Src/第二步,修改 Makefile,在 C_SOURCES 变量末尾追加缺失文件:
C_SOURCES += \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_eth.c \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_gpio.c \ Drivers/STM32N6xx_HAL_Driver/Src/stm32n6xx_hal_dma.c第三步,重新编译:
make clean && make如果你用的是 STM32CubeIDE,直接改工程文件列表比较绕,最快的方式还是在 Makefile 工程模式下改 C_SOURCES。CubeIDE 本质上也会调用 make 任务,改完 Makefile 后刷新工程即可。
4.2 方案 B:回炉 CubeMX 重新生成,一劳永逸
如果缺失的外设确实需要在 FSBL 里使用,我更推荐回炉 CubeMX 重新生成,这样工程结构和代码保持一致,以后重新生成也不会再次踩坑。
正确顺序是这样的:
- 打开
.ioc文件; - 在 Pinout & Configuration 页面把缺失外设完整配置起来,包括引脚分配、外设模式、DMA 通道、NVIC 中断等;
- 到 Clock Configuration 页面确认外设时钟已经使能;
- 在 Project Manager -> Code Generator 里检查 "Copy only the necessary library files" 是否勾选。如果你不想再折腾依赖链问题,可以直接把勾选去掉,让 CubeMX 把整个 HAL 库源文件都复制进来;
- 点击 Generate Code,生成时选择备份用户代码,确保
/* USER CODE BEGIN */和/* USER CODE END */之间的代码不被覆盖; - 重新编译。
注意:重新生成会覆盖 Makefile 里所有手动追加的内容。如果你之前手动改过 C_SOURCES,生成前最好记下来,生成后补回去。
4.3 依赖不是一层,是一棵树
手动补文件最忌讳的是"补了一个就跑",因为 HAL 驱动之间的依赖是一棵完整的树。还是拿 ETH 举例:
- 直接用到的:stm32n6xx_hal_eth.c
- ETH MSP 会用到的:stm32n6xx_hal_gpio.c、stm32n6xx_hal_dma.c
- 时钟使能:stm32n6xx_hal_rcc.c
- 中断与内核相关:stm32n6xx_hal_cortex.c
我的做法是:每补完一轮就重新编译一遍,让链接器告诉你下一批缺失的符号。每轮补齐的数量会快速收敛,直到全部通过。不要试图一次性把所有 HAL 文件都加进去,那样不仅编译时间变长,还可能因为个别模块内部的宏配置冲突,引发新的编译错误。
4.4 验证:确认符号确实从预期源文件链入
链接通过不代表万事大吉,特别是 FSBL 这种关键启动代码,建议再验证一下符号来源。生成 map 文件后,搜索一下 HAL_ETH_Init:
grep "HAL_ETH_Init" build/FSBL.map看到它被标记在stm32n6xx_hal_eth.o里,才算真正放心。还有一点要提醒:FSBL 的链接脚本和普通应用工程不同,如果外部 Flash 或 RAM 的地址范围配错,链接器会报region overflow或bad address错误。这种错误和源文件缺失无关,别混为一谈。
5. 治本方案:CubeMX 配置习惯与 FSBL 工程的版本管理
5.1 先把 CubeMX 和新固件包的版本对齐
STM32N657 刚发布时,不少同事还在用旧版 CubeMX,打开工程直接生成,结果文件列表千奇百怪。STM32CubeMX 对新增 MCU 的支持,是在版本更新中逐步完善的。一定要先确认当前 CubeMX 版本能识别 STM32N657 系列,再开始建工程。
固件包的安装位置在 Help -> Manage Embedded Software Packages,搜索 STM32N6 系列,点击 Install 安装完整版。版本对齐之后,生成工程的驱动文件列表才会和 STM32CubeMX 的预期一致。
5.2 生成工程前先列一张外设清单
FSBL 的职责范围其实很小。以 STM32N657 为例,启动阶段真正需要的外设通常就这些:
- 时钟与电源:RCC、PWR
- 外部存储:QSPI/OSPI 或 FMC(取决于启动介质)
- 基础 I/O:GPIO
- 调试日志:UART
- 安全相关:TF-M、Crypto(如果用安全启动)
我的习惯是,在打开 CubeMX 之前,先在纸上列出 FSBL 实际要用到的外设清单,然后在 Pinout & Configuration 里一个一个核对。绝大多数 CubeMX 生成文件缺失的问题,根源都是"配置阶段漏了外设"。代码写完之后再补配置,不仅效率低,还容易漏掉依赖项。
5.3 "Copy only the necessary library files" 的取舍
这个选项值得单独拿出来说。很多团队为了工程体积和编译速度,会一直勾选它。但在 FSBL 这种特殊工程里,我更建议在开发初期取消勾选,让 CubeMX 把整个 HAL 库都复制进来。两种选择的对比是这样的:
| 选项状态 | 工程体积 | 编译时间 | 缺文件风险 | 适用场景 |
|---|---|---|---|---|
| 勾选(只复制必要文件) | 小 | 快 | 较高 | 熟悉 HAL、工程裁剪明确 |
| 不勾选(复制全部 HAL 文件) | 大 | 稍慢 | 低 | 快速试板、FSBL 开发初期 |
我个人在 FSBL 工程上倾向"宁可大一点,也要稳"。等启动链路完全跑通后,再考虑裁剪也不迟。
5.4 用版本管理把 .ioc 和 Makefile 管起来
最后说一个团队协作里常见的问题。CubeMX 工程的源文件列表是由.ioc文件决定的,而不是由 Makefile 决定的。一旦有人手动改了 Makefile,下次重新生成时改动就会被覆盖,而且几乎没有痕迹。
建议的做法是:
.ioc文件必须有版本管理,作为整个工程的唯一配置源;- Makefile 等生成产物,如果手改过,要么提交后记录在 commit message 里,要么写一个小的 patch 脚本,生成后自动打补丁;
- 团队成员不要在别人已经重新生成过的中间态上继续开发,避免两个人同时改
.ioc导致生成结果不一致。
我在实际项目里,会在 CubeMX 重新生成后立刻编译一次并提交。所有手动添加的源文件或代码改动,都放进/* USER CODE */区域或独立的 patch 里,这样即使重新生成多次,也能快速复现同一份构建结果。
最后说一点个人习惯:遇到 STM32N657 这类新系列 FSBL 工程链接失败时,我不会急着去加文件。先把所有 undefined reference 收集下来,对照 HAL 驱动源文件一个个定位,很快就能判断出是 CubeMX 没识别到外设、还是依赖文件缺失、还是生成选项太激进。这种问题看起来吓人,实际补上文件编译过就没事了,但如果你没搞懂生成机制,就算这次手动加好了,下次重新生成工程还是会原地翻车。记住一个原则:CubeMX 生成的工程,源文件列表由配置决定,不是由代码决定。想少踩坑,就在生成前把外设配置完整,生成后再碰代码。