1. 现象复盘:下载成功,板子却像没烧一样
1.1 客户描述里的关键线索
前两天帮客户维护一块老板子,主控是 STM32F103C8T6,功能简单得很:上电点两盏 LED,跑一个串口上报逻辑。代码是两年前的旧工程,之前一直跑得好好的。结果客户换了台新电脑,重新装上最新版 Keil,编译、下载都提示成功,可一断电重启,板子就愣在那里,一点反应没有。
这个现象最迷惑人的地方在于:烧录工具明确显示 Flash Download Successful,点调试也能连上芯片,但程序就是不跑。如果你也遇到过类似的情况,大概率第一反应是怀疑硬件坏了、晶振没起振、BOOT0 配置不对,甚至怀疑芯片被锁死。我当时也把这一整套全查了一遍,最后才发现问题根本不是硬件,而是出在 Keil 新版本的工具链上。
这篇文章我会把完整的排查过程写出来,重点讲一个特别隐蔽、但近几年越来越常见的坑——新版 Keil 的默认编译器从 AC5 换成了 AC6,旧代码在 AC6 的优化下会出现"编译能过、运行就跑飞"的情况。同时也会给出详细的解决方法和一份"烧录后不运行"的通用排查清单,适合正在被 Keil 升级折腾的工程师,也适合刚入门 STM32 的开发者提前避坑。
1.2 烧录阶段的检查清单:先排除掉低级错误
在怀疑编译器之前,我还是按老规矩把基础问题先过了一遍。这个步骤很重要,不要跳过,因为"烧录后不运行"有相当一部分是低级错误导致的,排查顺序错了会浪费时间。
- 供电检查:万用表量 VCC 和 GND,3.3V 正常,芯片表面没有异常发热。
- 复位电路:NRST 引脚电压 3.3V,没有一直被拉低;手动按复位键,现象依旧。
- BOOT0/BOOT1:BOOT0 为低电平,确认是从主 Flash 启动,不是进了系统存储器 Bootloader。
- 晶振:示波器看 OSC_IN 引脚,8MHz 晶振有波形,说明时钟没出问题。
硬件这条路走完,我再回到烧录环节。Keil 下载后提示成功,不代表你烧进去的就是你以为的那个程序。这句话看起来是废话,但实际排查里很关键。保险起见,我用 ST-Link Utility 把芯片 Flash 内容整个读出来,和本地 hex 文件做了一次逐字节对比,完全一致。这说明程序确实烧进去了,而且没有校验错误。
到这里基本可以下结论:硬件没有问题,烧录没有问题,问题出在程序本身在板子上的运行行为变了。但是代码一个字没改,为什么换个 Keil 版本行为就变了?这就引出真正的核心矛盾——编译器换了。
2. 旧代码"带病运行"被 AC6 优化放大
2.1 新版本 Keil 默认编译器换成了什么
Keil MDK 从 5.14 版本开始就集成了 ARM Compiler 6(简称 AC6),但很长一段时间里默认编译器还是 ARM Compiler 5(简称 AC5)。直到 MDK 5.37 左右,新装的 Keil 默认选项变成了 AC6。很多老工程师升级 Keil 后并不会注意到这个变化,因为新建工程或者打开旧工程时,界面长得几乎一模一样,编译下载的按钮也在同一个位置。
但两者底层差异非常大。AC5 是 ARM 自家的 armcc 编译器,AC6 基于 LLVM/Clang 架构,从编译策略、优化逻辑到代码生成方式完全是两套体系。AC6 的编译速度快、代码密度高,这是它的优势;但它对未定义行为的处理方式比 AC5"激进"得多,这一点恰恰是旧工程出问题的根源。
有个很形象的说法:AC5 像一位经验丰富但性格保守的老会计,账面上有模糊不清的地方,它宁可先按最保守的方式圈出来,不敢乱动;AC6 像一位效率极高的新人会计,遇到模糊不清的地方,会直接按自己认为最优的规则处理,如果账本身有歧义,它就会算出一本和你想的不太一样的账。
2.2 容易暴雷的三类老代码写法
结合我这几年帮别人排查的经验,从 AC5 切到 AC6 后频繁翻车的代码主要有三类。
第一类:未初始化就直接使用的局部变量。很多老工程师习惯在函数里先声明一个局部数组或变量,不初始化就直接用,因为 AC5 编译后栈上的残留数据往往恰好是 0,程序能凑合跑。AC6 对栈空间的复用更积极,局部变量可能落在之前被其他函数污染过的内存区域,导致随机值、越界、死循环甚至 HardFault。这类 bug 最坑的地方在于:同一个 hex,这次烧进去能跑,下次重新编译可能又不行,时好时坏。
第二类:中断服务函数和主循环共享的变量没加 volatile。这是嵌入式开发的老生常谈,但很多老工程确实没有做。在 AC5 的低优化等级下,编译器每次读变量都老老实实从内存取,碰巧掩盖了问题。AC6 在 -O1 以上优化等级下,如果编译器发现"这个变量在主循环里没有被写",它会把变量值直接缓存到寄存器里,这样中断里更新了变量,主循环读到的还是旧值。表现出来就是:程序没有死,但业务逻辑卡住了,板子看起来像完全没反应。
第三类:依赖未定义行为的写法。典型的有几种:有符号整型溢出、数组下标越界后碰巧访问到相邻合法地址、字符串结尾没补 '\0' 就调用 strlen、函数里用到了隐式类型转换。这些写法在 AC5 下大概率能跑出"正确"结果,但在 AC6 下编译器的优化会改变这些场景的最终效果。
2.3 为什么编译不报错,运行却出问题
这一点是很多人想不通的:代码有问题,为什么编译器不报错?
因为 C 标准把上面这些情况归为未定义行为(Undefined Behavior)。对于未定义行为,编译器不需要报错,也不保证任何行为。AC5 只是"碰巧"生成了一种符合你预期的机器码,AC6 按照自己的优化规则生成了另一种机器码。两种机器码都是合法的,但运行结果一个正常、一个不正常。
所以,当"烧录后不运行"发生在升级工具链之后,别急着怀疑硬件,先想想你的编译器版本是不是变了。我遇到过一个 LED 闪烁工程,代码里用了一个未初始化的局部变量做软件延时计数,而不是用for空循环。AC5 下编译运行正常,AC6 下变量初始值变成了一个很大的栈残留值,延时时间从几十毫秒变成了几秒钟,LED 很久才闪一下,客户以为程序没跑。
3. 从 HardFault 到源代码:我的定位过程
3.1 第一步:让调试器告诉你程序到底在哪
排查"烧录后不运行"最忌讳的就是对着代码瞎猜。正确的做法是接上 ST-Link 或 J-Link,让芯片跑起来,然后在调试模式下暂停,看一眼程序计数器 PC 停在哪里。
我当时的操作是:Keil 里进入 Debug 模式,全速运行一会儿后点击暂停。发现 PC 停在HardFault_Handler里,这说明程序其实已经跑起来了,但很快就进了硬件错误中断。代码里没有任何业务逻辑能跳到这里,显然是因为某条指令触发了总线错误、未对齐访问或非法指令。
下一步是查看 Fault Report。Keil 在 Debug 模式下可以通过 Peripherals > Core Peripherals > Fault Reports 打开故障报告窗口,重点看 CFSR(可配置故障状态寄存器)里的三个子段:MMFSR(存储管理故障)、BFSR(总线故障)、UFSR(用法故障)。我这个板子报的是 BFSR 里的 PRECISERR 位置位,说明发生了一次精确的 bus fault,能直接定位到出错地址和指令。
3.2 第二步:从反汇编和调用栈回溯源头
Keil 的 Fault Report 会给出出错地址 BFAR,这个地址指向访问失败的内存位置。但更有效的是看崩溃点对应的源代码。
打开 Disassembly 窗口,结合寄存器窗口里 LR(链接寄存器)的值,就能找到是在哪个函数、哪条指令上跑飞了。我当时看到的崩溃指令是在一个字符串解析函数的strlen调用附近,看起来像是因为局部缓冲区越界把后续数据覆盖了。
为了确认调用关系,我又在HardFault_Handler入口设置了一个断点,然后查看 Call Stack + Locals 窗口。这个方法非常实用:在 HardFault_Handler 里打断点,命中断点之后,调用栈窗口通常会保留进入 HardFault 之前已经在栈上的函数调用链。从调用链上能看出来是哪个业务函数在什么上下文中触发了问题。
3.3 第三步:用优化等级做 A/B 验证
到这一步,我已经能隐约感觉到是编译器优化的问题了,但还差一个决定性证据。最直接的方法就是切换优化等级做 A/B 测试。
打开 Options for Target > C/C++ (AC6) 选项卡,把 Optimization 从默认的-O1改成-O0,重新编译烧录,LED 立刻恢复正常闪烁。然后把优化等级调回-O1,问题复现。如此反复两次,基本可以锁定:这个"不运行"不是硬件问题,而是 AC6 优化导致的代码行为改变。
这里要补充一个操作细节:切换优化等级后,一定要用 Rebuild all targets 全部重新编译,不能只点 Build。因为 AC5 和 AC6、不同优化等级生成的中间文件格式不一样,Keil 有时不会自动清理旧的 .o 文件,混合编译会产生更诡异的问题。清一次工程中间文件再全量编译,是最稳妥的。
4. 收拾烂摊子:三种解决方案怎么选
4.1 方法一:切回 AC5,成本最低但要注意版本
如果你的项目是老代码、老逻辑,而且没有立刻迁移到 AC6 的计划,那切回 AC5 是性价比最高的方案。
在 Keil 里打开 Options for Target > Target 选项卡,找到 ARM Compiler 下拉框,选择 "Use default compiler version 5",然后 Rebuild all targets。老版本 Keil(MDK 5.36 及更早)默认内置了 AC5,切换很简单。
但有一点要提醒:MDK 5.37 之后的某些版本,默认安装包里已经不再自带 ARM Compiler 5,你打开下拉列表可能只看到 "Use default compiler version 6" 和一个灰色的 V5 选项。这种情况下需要单独下载安装 ARM Compiler 5.06 Update 7(build 960)的离线补丁包。装完后回到 Keil 里刷新一下,下拉框就会出现 V5 的选项。
Ulink 和 ST-Link 的调试器设置不需要动,切换的是编译器本身,对下载算法没有影响。不过切换后同样建议全量 Rebuild 一次。
4.2 方法二:用 AC6 修正代码,长期更健康
如果你的工程还需要长期维护,或者已经开始用 HAL 库、新中间件,那直接切换到 AC6 并把代码里的隐患清掉,才是更健康的方向。
需要做的关键修正包括:
- 所有中断和主循环共享的标志位、状态变量,一律加上
volatile修饰。 - 局部变量声明时就初始化,尤其是数组、结构体、指针。
- 用
memset或显式赋值把缓冲区清空,字符串操作前保证'\0'结束符存在。 - 关闭编译器对严格别名的默认假设,在 Options > C/C++ (AC6) > Misc Controls 里加上
-fno-strict-aliasing。这个选项能降低部分指针相关优化带来的风险。 - 开发阶段可以把 Optimization 临时设为
-O0或-Og,保证调试体验,发布时再调回-O1、-O2验证一轮。
改代码的过程中建议按模块推进,不要一次性大改。比如先修中断相关的 volatile,编译烧录确认没问题,再修其他部分,这样如果引入了新问题,能快速定位到刚改的那一块。
4.3 方法三:单个函数做局部降级
如果你的代码很小,只有个别函数在 AC6 高优化下行为异常,又暂时不想动代码逻辑,可以用编译器属性给单个函数关优化。
在函数定义前加上:
__attribute__((optimize("O0"))) void some_problem_function(void) { // 原有逻辑 }这样只有这个函数以 O0 优化等级编译,其他文件保持全局优化等级。这个方法适合用来临时止血,但不建议大量使用,因为 Keil 对 optimize 属性的支持不如 GCC 那么完善,而且治标不治本。长期来说,把代码本身修正确才是正路。
5. 还有哪些"烧录后不运行"的经典元凶
编译器优化是升级之后的重点怀疑对象,但不是唯一原因。为了让你排查时能一口气走完,我把其他几种常见原因也整理成一份快速对照表。
| 原因 | 典型表现 | 确认方式 | 处理办法 |
|---|---|---|---|
| Reset and Run 未勾选 | 下载后程序停在复位状态,手动按复位才运行 | Options > Utilities > Settings > Flash Download 页面 | 勾选 "Reset and Run" |
| Flash 编程算法选错 | 下载提示成功但程序完全不运行,或运行到一半丢失 | 读回 Flash 内容与 hex 对比 | 在 Flash Download 中选择匹配芯片型号的 FLM 算法 |
| BOOT0 引脚电平不对 | 上电后程序区完全不执行,但调试能连上 | 万用表测量 BOOT0 电压 | 确保 BOOT0 拉低,从主 Flash 启动 |
| ST-Link 固件被新版自动升级 | 升级后下载异常或下载后不复位 | 在 ST-Link Utility 中查看固件版本 | 更新 Keil 或回退 ST-Link 固件 |
| 分散加载文件地址错乱 | 工程带 Bootloader 时 APP 段地址和链接地址不一致 | 查看 Build Output 里的 sct 文件内容 | 在 Target 选项卡里正确设置 IROM1 起始地址和大小 |
| 复位电路异常 | 上电瞬间 NRST 毛刺导致芯片复位挂在循环里 | 示波器抓上电时 NRST 波形 | 检查复位电容电阻参数,必要时换复位芯片 |
这里特别说一下第一项,因为很多新手会卡在这上面。Keil 的 Flash Download 设置里有一个 "Reset and Run" 选项,如果没勾选,程序下载完会停留在复位状态,需要手动按一下复位键才运行。对于量产和现场维护场景,这几乎是灾难性的体验。升级 Keil 后这个选项有时会恢复到默认状态,一定要检查。
另外,验证 Flash 内容不一定非要用 ST-Link Utility。你手头有 J-Flash 就用 J-Flash,有 OpenOCD 就用 OpenOCD,甚至 STM32CubeProgrammer 也行。工具虽然不同,但核心理念一样:程序编译下载成功后,从芯片里读出来的内容必须和 hex 文件一致。如果一致,问题在运行阶段;如果不一致,问题在烧录阶段。这一条判断直接决定了你在哪半边排查。
6. 写在最后:一次升级引发的经验沉淀
这次排查大概花了我半天时间,其中一小半浪费在怀疑硬件上。回过头看,如果我第一时间就检查 Keil 的编译器版本和优化等级,十分钟就能定位问题。
经历过这次之后,我自己形成了一个习惯:任何工具链升级,都不要直接拿正在交付的工程开刀。先装好新版本,新建一个最简单的 LED 闪烁工程编译烧录一遍,确认环境没问题后,再打开旧工程查看编译器的默认配置。如果编译器默认选项变了,先在工程里显式锁定原有的编译器版本和优化等级,再做后续操作。版本管理工具在这种时候尤其重要,升级前一个 commit,出了问题随时能回退。
还有一个小技巧:如果遇到"烧录后不运行",按"硬件 → 烧录 → 运行"三个步骤走。硬件看供电、复位、BOOT0,烧录看算法、校验、Flash 内容,运行看 PC 指针、Fault 寄存器、调用栈。绝大多数问题都能在这三步里现出原形。
这套排查方法我后来又用过两次,一次是一块 GD32 板子,一次是一块 AT32 板子,症状一模一样——换新版本 Keil 后烧录不跑,最后都指向 AC6 优化。如果你也换了新版本之后遇到类似的诡异现象,别急着换板子,先从编译器版本和优化等级查起,大概率能帮你省下半天时间。