做嵌入式这行时间长了,手头总会积压一些"历史包袱":客户几年前的板子、实验室传下来的教学箱、比赛仓库里一堆stm32f10x_gpio.c文件的老工程。这些工程大多建立在标准外设库(Standard Peripheral Library,下称标准库或 SPL)之上,代码风格直白,一个GPIO_Init()就能看清寄存器被怎么配置,读起来不费劲。可问题在于,原厂早就停止维护这套库,配套的 IDE 也停留在需要授权、只能在单一操作系统上跑的老版本。另一边,STM32CubeIDE 免费、跨平台、内置 GCC 和 GDB,调试体验好太多。于是"在 STM32CubeIDE 里继续用标准库"就成了一个很实际的需求:既想保留老工程的代码资产和熟悉的编程模型,又想用上现代的编辑器和调试器。这篇就把我在这条路上踩过的坑、配过的参数、验证过的工程结构完整摊开讲,从工程骨架怎么搭、文件怎么挑、宏怎么定义,到启动文件和链接脚本怎么对齐、printf 怎么重定向、HardFault 怎么定位,一步步来。适合手里有 F103C8T6 最小系统板、想摆脱老工具链又不想重写代码的人。
1. 先想清楚:为什么要在 CubeIDE 里用标准库
1.1 三套库的定位差异与选择逻辑
把标准库、HAL、LL 放在一起对比,很多争论其实源于没分清它们各自的封装层次。标准库是原厂在 2011 年前后主推的一套外设驱动,本质上是把寄存器操作包装成XXX_Init()、XXX_Cmd()这样的函数族,一个外设对应一对.c/.h,结构体成员名基本就是参考手册里的位域名,改一行代码能立刻对应到寄存器。
| 维度 | 标准外设库 SPL | HAL 库 | LL 库 |
|---|---|---|---|
| 封装层次 | 寄存器 + 薄函数封装 | 面向功能的高度封装 | 接近寄存器的内联封装 |
| 代码体积 | 小,按需引入外设文件 | 大,依赖链深 | 中,常与 HAL 混用 |
| 上手曲线 | 需要看参考手册 | 需要看例程 | 需要看参考手册 |
| 可读性 | 直观,配置结构体一览无余 | 抽象层多,跳转深 | 直观但偏底层 |
| 典型存量 | 教学、比赛、老工业项目最多 | 新项目主流 | 性能敏感场景 |
| 维护状态 | 停止更新 | 持续更新 | 持续更新 |
我个人的判断标准很朴素:如果工程已经存在、代码量超过几千行、且只跑在 F1 系列上,那就没有理由为了"用新库"去重写。HAL 的价值在于跨系列可移植性和图形化配置,但它的抽象层也带来一个副作用——出问题时调用栈很深,初学者不容易一眼看出是时钟没开还是引脚复用没配对。标准库反而把这些问题暴露在最浅的一层。
另一个常被忽略的点是代码尺寸。F103C8T6 只有 64KB Flash、20KB RAM。一个只做串口收发 + 定时器 + 几个 IO 的小固件,用标准库裁剪完可以压到 8KB 以内,换成 HAL 不加任何优化往往要 15KB 起步。这不是说 HAL 臃肿,而是它的通用性必然带来冗余。资源紧的场合,这个差距是实打实的。
1.2 CubeIDE 承载标准库的可行性拆解
先把结论摆出来:完全可行,而且核心工作量比想象中小。原因在于 STM32CubeIDE 的底层无非是 Eclipse CDT 加一套 ARM 交叉编译工具链(arm-none-eabi-gcc)再加 GDB 调试前端。它并不关心你往工程里塞的是什么源码,只要满足三个条件——能被 GCC 编译、有合法的启动文件和链接脚本、能被调试器识别出符号表——它就能构建、下载、单步。
标准库本身是纯 C 代码,编译器兼容性这一关几乎是白送的。真正的障碍集中在三个地方:
第一是启动文件。原厂随标准库发布的startup_stm32f10x_md.s是给 ARMCC 编译器写的汇编,语法里满是AREA、EXPORT、DCD这类伪指令,GCC 的汇编器一个字都不认。必须换成 GNU 语法版本,也就是.section、.global、.word那一套。这个文件如果找不到,链接阶段会直接报undefined reference to '_estack'之类的一堆错误。
第二是链接脚本。CubeIDE 用的是.ld文件描述存储器布局,而老工程用的是分散加载文件.sct。这两者不通用的,得重新写或者借一个。好消息是 F103C8T6 的存储布局极其标准——Flash 从0x08000000起 64KB,RAM 从0x20000000起 20KB,手写也就十几行。
第三是中断向量表命名。标准库的中断服务函数命名规范是XXX_IRQHandler,和 HAL 完全一致,这一点反而省事。但向量表里那些弱定义的默认处理函数、Reset_Handler里调用的初始化顺序,需要自己对齐。
把这三件事捋顺,剩下的就是纯粹的工程管理问题了。
1.3 方案选型:三条路走哪条
我试过三种搭法,各有代价,先把结论说在前面:
- 方案 A:从零新建空工程手动添加。干净,但容易漏文件、漏路径,第一次搭通常要折腾两三个小时。
- 方案 B:把 MDK 工程整个搬过来。不可行。
.uvprojx是私有格式,CubeIDE 没有导入器,只能手工对照着重建。 - 方案 C:借壳。先用 CubeMX 生成一个最小 HAL 工程,把
startup_stm32f103xb.s和STM32F103C8TX_FLASH.ld这两个文件"抠"出来,然后清空 HAL 相关内容,塞入标准库源码。这是我目前一直在用的做法,快、稳、几乎零试错。
方案 C 的关键洞察在于:启动文件和链接脚本这两样东西与"用哪套外设库"完全没有关系,它们只跟芯片型号和工具链有关。既然 CubeMX 能针对 F103C8T6 生成一组合法的 GCC 启动文件和.ld文件,那就直接拿来用,何必自己从头敲一遍汇编。后面第 3 章会把"抠出来之后怎么清场"讲透。
2. 工程骨架怎么搭才不返工
2.1 用 CubeMX 生成一个"只有骨架"的最小工程
打开 CubeIDE,新建 STM32 Project,芯片搜索框输入STM32F103C8,选中 C8T6 那一项。关键的一步在工程设置里:Project Name 随便起,比如f103_spl_demo,Application Structure 选择 Basic,Toolchain 自动就是 STM32CubeIDE。
进入引脚配置界面后,什么都不用配。不要开 GPIO,不要开 USART,不要开任何外设。唯一值得顺手做一下的是 SYS 里的 Debug 选项设成 Serial Wire,省得后面下载时手忙脚乱。时钟配置页保持默认,HCLK 会停在 8MHz 的内部 RC 上,这反而有利于先验证最小系统能否跑起来。
生成代码之后,你会得到一个自带 HAL 的完整工程。此时先别急着删东西,先把两个宝贝文件找出来复制到桌面备份:
Core/Startup/startup_stm32f103xb.sSTM32F103C8TX_FLASH.ld
第一个是 GNU 汇编语法的启动文件,定义了向量表、栈顶地址、Reset_Handler以及__libc_init_array的调用序列。第二个是链接脚本,里面明明白白写着FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 64K和RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K。这两段数值就是 C8T6 的真实资源,不用改。
注意:
startup_stm32f103xb.s里的xb指的是 128KB Flash 的中容量后缀,而 C8T6 是 64KB。这不是错误,因为启动文件本身不感知 Flash 大小,向量表布局对所有 F103 中容量型号都一样,真正的容量约束写在.ld文件里。所以复用它是完全安全的。
2.2 清场:把 HAL 的痕迹彻底抹掉
拿到骨架后,新建一个空白工程(File > New > STM32 Project 太啰嗦,直接建 C Project 更干脆),然后把目录结构规划好。我习惯的布局是这样的:
f103_spl_demo/ ├── CMSIS/ │ ├── core_cm3.c │ ├── core_cm3.h │ ├── stm32f10x.h │ ├── system_stm32f10x.c │ └── system_stm32f10x.h ├── Library/ │ ├── inc/ (stm32f10x_gpio.h 等) │ └── src/ (stm32f10x_gpio.c 等) ├── Startup/ │ └── startup_stm32f103xb.s ├── User/ │ ├── main.c │ ├── stm32f10x_conf.h │ ├── stm32f10x_it.c │ └── stm32f10x_it.h └── STM32F103C8TX_FLASH.ld这个结构的好坏不体现在美观上,而体现在后续的可维护性上。CMSIS目录放的是内核和外设寄存器定义,几乎不改;Library放外设驱动,按需增删;User放业务代码。分清楚之后,将来把这个工程复制到别的项目上,只需要替换User目录,库部分原封不动。
清场环节最容易出问题的是身份文件。原 HAL 工程里的main.c会自动调用HAL_Init()、SystemClock_Config(),stm32f1xx_it.c里引用了stm32f1xx_hal.h,还有syscalls.c、sysmem.c负责_sbrk和系统调用桩函数。这几个文件必须处理:
main.c整个重写,不要留任何 HAL 调用。stm32f1xx_it.c删掉,换成标准库风格的stm32f10x_it.c(内容基本为空,只留几个异常处理函数的空壳)。syscalls.c保留,它是 GCC 环境下的运行时支撑,跟 HAL 没关系,负责给printf、malloc提供底层实现。但里面的_write需要改成串口输出。sysmem.c也保留,_sbrk的堆管理实现是 newlib 必需的。
2.3 从标准库压缩包里精确挑文件
标准库压缩包解开后有几十个文件,全加进去能编译,但会让构建变慢、Flash 变大,更重要的是配置头文件里一堆无关外设会让编译告警淹没真正的问题。我的做法是按需引入,先只加最小集合,跑通点亮 LED 之后再逐个补。
必选的核心文件有五个:core_cm3.c/h提供 Cortex-M3 内核的 NVIC、SysTick 内联函数;stm32f10x.h是总头文件,定义了所有寄存器映射;system_stm32f10x.c/h负责SystemInit()和系统时钟;stm32f10x_conf.h是外设头文件的汇总入口。
外设驱动按需添加。做最小验证时,stm32f10x_rcc.c(时钟使能)和stm32f10x_gpio.c(引脚配置)是绕不开的,其余先不加。
提示:
stm32f10x_conf.h里有一堆#include "stm32f10x_xxx.h",没引用对应.c文件时,这些头文件本身不会造成链接错误,只会让编译变慢一点点。但有一个例外——assert_param的展开依赖USE_FULL_ASSERT宏,如果你开了全断言但没实现assert_failed,链接阶段会报错。初期建议直接把USE_FULL_ASSERT注释掉。
3. 编译配置:路径、宏、启动文件、链接脚本
3.1 头文件路径怎么填才不出No such file
CubeIDE 的路径配置在 Project > Properties > C/C++ Build > Settings > Tool Settings > MCU GCC Compiler > Include paths。这里有个坑:它不支持相对路径通配符,也不能靠-I递归查找子目录。也就是说,你有几个放头文件的目录,就得老老实实加几条。
对这个工程来说,需要的路径是:
../CMSIS ../Library/inc ../UserLibrary/src里的.c文件会#include "stm32f10x_gpio.h",编译器会去Library/inc找,路径对了就没问题。这里建议用 workspace 相对路径(界面上点 "Add..." 后选 Workspace 或 File system,用${ProjDirPath}/../CMSIS这种写法更稳)。绝对路径虽然当下能用,但只要工程换个目录就废了,团队协作时尤其要避免。
3.2 两个宏定义决定了半个工程的成败
在 MCU GCC Compiler > Preprocessor 里,必须加上这两个定义:
| 宏名 | 取值 | 作用 |
|---|---|---|
STM32F10X_MD | 无值 | 告诉头文件当前是中容量芯片,决定 Flash 大小、外设基址、中断数量 |
USE_STDPERIPH_DRIVER | 无值 | 让stm32f10x.h自动包含stm32f10x_conf.h |
第一个宏如果写错,后果非常隐蔽。比如误写成STM32F10X_HD,stm32f10x.h会认为你有 512KB Flash 和更多外设,编译能过,但中断向量表长度、RCC_CFGR相关宏都会错位,运行起来就是莫名其妙的 HardFault。C8T6 的 64KB Flash 属于 Medium Density,STM32F10X_MD是唯一正确选择。
第二个宏如果漏掉,stm32f10x.h结尾那段#ifdef USE_STDPERIPH_DRIVER的包含逻辑就不生效,编译器会抱怨GPIO_InitTypeDef未定义——明明你已经在stm32f10x_conf.h里引入了stm32f10x_gpio.h。这个报错很具迷惑性,因为头文件确实存在、路径也确实对。
顺便可以再加一个HSE_VALUE=8000000,明确告诉系统外部晶振是 8MHz。如果你的板子上其实是 12MHz 晶振却用了默认值,PLL 倍频算出来的主频就会偏离 72MHz,串口波特率会跟着错,现象是能发数据但全是乱码。这类问题排查起来能耗掉一整天。
3.3 启动文件与链接脚本的对齐细节
把之前备份的startup_stm32f103xb.s和STM32F103C8TX_FLASH.ld放进工程,然后在 Properties > C/C++ Build > Settings > MCU/MPU GCC Linker > General 里,把 Linker Script 指向STM32F103C8TX_FLASH.ld。有些版本需要在 Command line pattern 里手动追加-T"${ProjDirPath}/STM32F103C8TX_FLASH.ld",没生效的话检查一下这里。
.ld文件里有几个关键符号必须和启动文件对应,否则链接会报undefined reference:
| 符号 | 含义 | 由谁定义 |
|---|---|---|
_estack | 栈顶地址,初始 MSP 值 | ld 文件末尾_estack = ORIGIN(RAM) + LENGTH(RAM) |
_sidata | 初始化数据的 Flash 源地址 | ld 的.data段定义 |
_sdata/_edata | .data段在 RAM 的起止 | ld 的.data段定义 |
_sbss/_ebss | .bss段在 RAM 的起止 | ld 的.bss段定义 |
_Min_Stack_Size | 最小栈大小 | ld 文件开头定义,CubeMX 默认0x400 |
Reset_Handler在跳进main之前,会先用这几个符号把.data从 Flash 拷贝到 RAM、把.bss清零,然后调用SystemInit()配置时钟,再调用__libc_init_array()执行 C++ 全局构造和__attribute__((constructor))函数,最后才bl main。这个顺序不能乱,尤其SystemInit必须在main之前,否则你还没开时钟就去配外设,GPIO 写进去没有任何反应。
注意:Stack Size 建议从
0x400(1KB)往上调到0x800。标准库的printf如果用了浮点格式化,newlib 的栈消耗会明显增加,1KB 在深调用链下容易栈溢出,表现是主循环跑着跑着突然进 HardFault,而且复现不稳定。RAM 只有 20KB,800 字节的栈还算宽裕。
3.4 优化选项对调试体验的直接影响
Debug 配置下,Optimization 建议设成-Og,不要用-O0。-O0会导致变量全部落在栈上,单步时变量值刷新慢,而且代码体积大。-Og是为调试优化的级别,变量可见性和执行流都还算清晰,同时编译产物更小。
另外建议勾上-ffunction-sections -fdata-sections,并在 Linker 里加-Wl,--gc-sections。这套组合能把没被引用的函数和数据段直接裁掉,标准库里那些你没用到的外设驱动不会进 Flash。实测下来,一个只跑 GPIO 和 RCC 的固件,加与不加这组参数的体积差能到 3~4KB。
-Wall建议常开。标准库是老代码,在 GCC 新版本下会产生一些警告,比如core_cm3.c里的内联汇编约束符、stm32f10x_xxx.c里的一些类型比较。这些警告大多无害,但留着它们能让你在真正出问题时更容易发现异常——比如某个函数被隐式声明了,-Wall会直接告诉你。
4. 时钟、中断、打印、下载:把链路跑通
4.1 SystemInit 到底做了什么
很多人以为SystemInit()只是个空壳,其实它干的活不少。默认的system_stm32f10x.c里,SystemInit()最终会走到SetSysClock(),而SetSysClock()里又会根据SYSCLK_FREQ_72MHz这个宏决定是否调用SetSysClockTo72()。
SetSysClockTo72()的流程是:先开 HSE 并等待就绪,然后配置 Flash 预取和等待周期(72MHz 下必须设FLASH_ACR_LATENCY_2,即 2 个等待周期,这个如果漏了,取指会出错,现象是程序跑飞或者进 HardFault),接着配 PLL 的倍频系数(8MHz × 9 = 72MHz),切换系统时钟源到 PLL,最后更新SystemCoreClock变量。
这里最容易踩的坑是晶振不起振。如果你板子上的 8MHz 晶振虚焊、或者负载电容不匹配,HSE 永远等不到就绪位,SetSysClockTo72()会在while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) == RESET)里死循环。表现就是程序下载进去之后不跑,调试器一暂停,发现停在system_stm32f10x.c的某个 while 里。
遇到这种情况,先用调试器确认RCC->CR的 HSERDY 位,如果一直不清零,就检查硬件。软件层面有个应急方案:把SetSysClockTo72()换成 HSI 内部 RC 直接跑 8MHz,先验证逻辑对不对,再回头修晶振。改法是在system_stm32f10x.c里把#define SYSCLK_FREQ_72MHz那行注释掉,系统就会走默认的 HSI 路径。
4.2 中断向量与 stm32f10x_it.c 的配合方式
标准库的中断处理函数不是自动生成的,需要在stm32f10x_it.c里手工实现。比如要用 TIM2 的更新中断,你得先在stm32f10x_it.c里加:
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); /* 业务逻辑 */ } }然后在main里通过NVIC_Init()使能对应的中断通道。这里有两处必须自己注意:一是函数名的拼写必须和启动文件里向量表的名字严格一致,大小写都不能差,否则链接时不会报错(因为启动文件里是弱定义的Default_Handler),但中断一来就直接跳到Default_Handler的死循环里。二是TIM_ClearITPendingBit不能忘,忘了就是中断进一次之后再也出不来,主循环被彻底饿死。
调试中断问题有个很实用的小技巧:在Default_Handler里打个断点,如果程序在那儿停住了,说明有中断没被正确接管。CubeIDE 的 Expressions 窗口里可以展开NVIC->ISER看哪个中断通道被使能了,对比你预期的位,能快速定位是配置写错了还是函数名拼错了。
中断优先级分组也别忽略。标准库默认的NVIC_PriorityGroup_0到_4五档,如果用了 FreeRTOS 或需要嵌套中断,必须显式调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2),并且这个调用要在任何NVIC_Init()之前。顺序错了,后面配的抢占优先级都会被当成 0。
4.3 printf 重定向的两种实现路径
GCC 环境下printf最终会调用_write(),所以重定向的核心就是自己实现一个_write。如果保留了 CubeMX 生成的syscalls.c,里面已经有一个空实现的_write,直接改写它即可:
int _write(int file, char *ptr, int len) { int i; for (i = 0; i < len; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ptr[i]); } return len; }注意这里等的是USART_FLAG_TXE(发送数据寄存器空)而不是USART_FLAG_TC(发送完成)。两者的区别在于,TXE 置位表示可以往里写下一个字节,TC 置位表示最后一个字节已经从移位寄存器移出线路。用 TXE 吞吐效率更高,用 TC 更保险(防止最后一个字节还没发完就关串口)。调试场景下用 TXE 就够。
如果不想依赖syscalls.c,也可以直接重写fputc——但那是 ARMCC 的路子,GCC 下不生效。这一点很多从 MDK 迁过来的朋友会犯迷糊:MDK 里#pragma import(__use_no_semihosting)加fputc是标准做法,CubeIDE 里必须换成_write。
另外一个值得知道的方案是 ITM/SWO。SWO 引脚(PB3)在 F103 上是可用的,通过调试器直接输出,不占用串口。配置方式是在.ld或者启动文件里保留 ITM 相关符号,然后在 Debug Configuration 里开启 SWV,实现_write时直接往ITM->PORT[0].u8写。好处是不占硬件串口、速度快、跟调试器一起用;坏处是需要一根完整的 SWD 排线且调试器支持 SWO。串口方案更通用,我一般先用串口把功能跑通,性能敏感时再切 ITM。
4.4 下载与调试配置的几个必改项
新建的调试配置(Run > Debug Configurations)默认会生成一个 STM32 C/C++ Application 项,但有几个选项默认值不适合裸板调试:
Reset behaviour 里的 Reset mode 建议选Connect under reset。这个选项会在连接前先拉低 NRST 引脚再释放,能避开那些"程序跑飞后 SWD 引脚被复用、调试器连不上"的尴尬。尤其是 F103C8T6 这种最小系统板,PB3/PB4/PA13/PA14 复用情况复杂,选这个选项能省不少事。
如果下载后程序不跑,先看 BOOT0 跳线。BOOT0 接高电平会从系统存储器启动(也就是进内置 Bootloader),现象就是下载成功但复位后毫无反应。最小系统板上通常有个跳线帽,确认它接在 0 那一侧。
还有一点:CubeIDE 的 ST-LINK GDB Server 默认下载完成后会执行reset并停在main之前。如果你希望下载即运行,勾掉 Debugger 页里的 "Stop on main"(不同版本叫法略有差异)。这个选项是个人习惯问题,但如果你在main开头配了外设,停在那儿单步往下走会比直接跑起来更容易观察初始化过程中的寄存器变化。
5. 问题排查实录:那些报错到底在说什么
5.1 编译与链接阶段的典型报错对照
| 报错信息 | 根因 | 处理方式 |
|---|---|---|
undefined reference to 'SystemInit' | 没加system_stm32f10x.c,或该文件里SYSCLK_FREQ_xx相关宏把实现裁掉了 | 确认.c在编译列表,检查system_stm32f10x.c顶部宏定义 |
undefined reference to '_estack' | 链接脚本没生效,或用了 ARMCC 版启动文件 | 检查 Linker Script 路径,换成 GNU 语法.s |
multiple definition of 'main' | 清理不彻底,旧main.c还在编译 | 删除或从构建中排除冗余文件 |
undefined reference to 'GPIO_Init' | stm32f10x_gpio.c没加进编译 | 补文件,或检查是否被 gc-sections 裁掉 |
undefined reference to '__libc_init_array' | 链接时没带标准启动文件,或链接顺序异常 | 确认启动文件参与链接 |
cannot find -lc或 newlib 报错 | Toolchain 未正确选择 | Project 属性里确认 MCU 是 Cortex-M3 |
warning: implicit declaration of function 'xxx' | 对应外设头文件没被stm32f10x_conf.h引入 | 在 conf 头里补 include |
这张表里最值得展开的是_estack那条。刚开始接触时很容易以为是自己汇编写错了,其实九成情况是链接脚本根本没被用上,或者路径里带空格导致 GCC 没解析到。判断方法很简单:构建完成后去Debug/目录下找.map文件,看里面有没有_estack的定义地址。如果找不到,问题一定在链接脚本。
5.2 运行阶段的"玄学"问题定位法
程序下载进去了但不跑,这类问题最难查,因为它不给你任何文字提示。我的排查顺序固定是这三步:
第一步,暂停调试器,看程序计数器停在哪里。如果停在Default_Handler的while(1),说明有中断发生了但没有对应的处理函数——要么函数名拼错,要么某个不该开的中断被意外使能了。可以看NVIC->ISPR(挂起寄存器)判断是哪个通道。如果停在SystemInit里,八成是外部晶振问题,回看 4.1 节。如果停在HardFault_Handler,跳到第二步。
第二步,读 HardFault 状态寄存器。在 Expressions 窗口里手动加三个表达式:SCB->CFSR、SCB->HFSR、SCB->BFAR。CFSR 的低字节如果是0x82,通常是访问了非法地址;如果是0x01,是未对齐访问;如果 HFSR 的 bit30 置位而 CFSR 是 0,多半是向量表被改坏了。这几项读出来基本能定位到方向,再结合调用栈往回推就有希望。
第三步,怀疑栈溢出。20KB RAM 的芯片,如果你开了大的局部数组、或者printf里用了%f,栈很容易冲掉.bss区。验证方法是在.ld里把_Min_Stack_Size调大试一下,如果问题消失,基本可以确诊。长期方案是减少深调用链和大局部变量,而不是一味加栈。
还有一种情况值得单独提:程序在调试器里单步跑得好好的,一按全速运行就死在启动阶段。这种往往是调试器的 "Run to main" 和真实复位时序不一致导致的,把 Reset mode 改成 Connect under reset 通常能解决。
5.3 串口打印乱码的三层排查
串口输出乱码是一个非常高频的问题,排查可以从三层往下推。
第一层看时钟。printf的波特率依赖于PCLK2,而PCLK2又来自SystemCoreClock。如果 HSE 没起振,SystemClock会退回到 HSI 的 8MHz,而你代码里按 72MHz 算波特率预分频,实际输出波特率就会偏差 9 倍,收到的一定是乱码。用调试器读一下SystemCoreClock变量的实际值,一秒就能确认。
第二层看串口模块本身的配置顺序。标准库的初始化顺序必须是:先RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE)开时钟,再配 GPIO 复用推挽输出,然后USART_Init(),最后USART_Cmd()。如果先USART_Cmd再USART_Init,有可能在使能状态下改了配置寄存器的位,行为不确定。
第三层才是看 PC 端。终端软件的数据位、校验位、停止位要和代码里一致,USB 转串口模块的 VCC 电平要匹配(3.3V 和 5V 混接会让信号边缘变差,高速率下容易错)。这种问题不常见,但一旦遇到,往往前面两层都查遍了也没找到。
5.4 踩过的几个坑,写出来省得你重走
一是不要混用 HAL 和标准库的stm32f10x.h。有些朋友想着"HAL 里有些外设写起来方便,标准库里有代码不能删",两边头文件一起包含。结果RCC->APB2ENR这类寄存器在两边都有定义,宏展开后名称相同但类型不同,编译可能过,运行时绝对出问题。二选一,不要骑墙。
二是**stm32f10x_it.c里不要 include 太多东西**。这个文件的定位是只放中断函数,业务逻辑全放main.c或者单独模块。我见过把整个应用都塞进_it.c的工程,几百行中断里调各种状态机,后期维护起来简直是噩梦,而且中断上下文里跑耗时逻辑会导致主循环饿死。
三是标准库压缩包里的stm32f10x_conf.h最好自己维护一份。原版把所有外设头都引入了,编译时间大约多一倍。我的做法是清空它,只留实际用到的几个,注释掉其余。这样将来加外设时改一行就行,同时编译告警也干净很多。
四是在 CubeIDE 里慎用中文路径。工程路径里带中文会导致 GCC 找不到某些文件,报错信息还特别隐晦(常常只显示No such file or directory但不说是哪个文件)。养成用纯英文加下划线的习惯,能避免掉一大类莫名其妙的问题。
我自己在做几个 F103C8T6 的小项目时,已经把这套骨架固化成模板了:CubeMX 生成一次,提取启动文件和链接脚本,清场,替换标准库,配好宏和路径,之后所有新项目都是复制粘贴加改User目录。整个流程第一次大概要花两三个小时,熟手之后十五分钟能出一个能点亮 LED 的空工程。标准库那些"老"和"过时"的评价,在真正需要精确定位问题、需要读懂每一行代码在干什么的场合,反而不构成缺点——它足够薄、足够透明、足够让你在一张纸上把寄存器的状态画清楚。这也是为什么到现在,我手上那些跑在 F103 上的量产小固件,仍然是一份标准库的骨架在支撑。