夜雨聆风学习资料网

ARTICLE · 1026715

源码一模一样,一个能跑一个不行,真相出来真的绷不住了!

源码一模一样,一个能跑一个不行,真相出来真的绷不住了!

源码一模一样,一个能跑一个不行,真相出来真的绷不住了!

一个文件没加进 Keil 工程,也没有报错, SPI 直接哑了。

这篇把过程完整还原一遍,包括我走的每一步弯路。


它明明在 D 盘跑得好好的

上周我把一个工程复制了一份,从 D 盘搬到 E 盘。

原来的工程在D:\Desktop\GD260809,跑得挺好。我复制了一份到E:\ShareFile...\GD260809,准备在副本上改点东西。

结果副本一烧进去,SPI 读回来的数据全是 0x00。

不是乱码,不是偶尔错一两个字节,是干干净净的一片零。屏幕上一片空白。

我当时的第一反应是:"拷坏了?"

于是我把整个工程删了,重新复制了一遍。

一样的现象。


第一次怀疑:我怀疑代码

这很正常——数据不对,第一反应肯定是"代码有问题"。

我把两个工程的关键源文件一个一个拉出来做字节级对比:

spi.c、spi.h、gpio.c、main.c、main.h、Fontlib.c、screen.c、stm32f1xx_hal_msp.c。

全部一致,一个字节都不差。

到这一步我心里其实有点发毛了。

因为我做过很多年 STM32,见过"代码不对所以现象不对",也见过"硬件坏了所以现象不对"。但代码完全一样、现象完全不一样,这就说明我一开始怀疑的方向就不对。

代码是清白的。

那问题在哪?


第二次怀疑:我开始怀疑板子

代码没问题,那就往硬件想。

这是我的兵荒马乱阶段。我做了这些事:

  • • 换了一块开发板 —— 一样是 0x00
  • • 换了一根杜邦线 —— 一样
  • • 拿示波器夹 SCK —— 没波形
  • • 把 D 盘那份工程重新烧进同一块板子 —— 好了

到这里,结论就很硬了:

同一块板子、同一根线、同一个传感器,D 盘的工程能跑,E 盘的工程不跑。

那问题只能在这两个工程的"不一样的地方"。

而我已经确认过:源代码,一模一样。


转折点:我把怀疑对象从"代码"换成了"构建"

那天我在椅子上坐了一会儿,然后脑子里蹦出一句话:

"代码没错,那会不会……我根本就没编译那份代码?"

这句话听起来有点蠢,但它是我整个排查里最重要的一步。

因为我突然意识到一件事:源码放在磁盘上,和源码被编译进固件,是两件完全不同的事。

你以为你改了代码,实际可能是编译器根本没看你那个文件。这种情况在"手动复制工程"的时候特别容易发生——因为 Keil 的工程文件里记着一份"清单",记录哪些文件参与编译。你复制文件夹的时候,只要工程文件里的清单没对上,就会出现"文件在、但不参与编译"的情况。

于是我打开了那个通常没人会看的文件:.uvprojx。

它是 XML 格式,一坨一坨的标签,很难读。我就用最笨的办法,两边一起打开,一行一行对。

然后我看到了这个:

D工程(能跑的),Application/User/Core 这个组里,有这个文件:stm32f1xx_hal_msp.c

E 工程(不跑的),同一个组里,没有这个文件。

我盯着这两份清单看了好一会儿。

就是这个。但说实话,那一刻我还没完全确定——因为文件明明在磁盘上躺着,好好地放在 Core/Src 目录里。磁盘上有它、Keil 工程里没有它,这两个状态可以同时存在,而我以前从没意识到这一点。


我不放心,又查了两处

一个孤证不够。我又去翻了另外两个地方。

第一处是 .lnp 文件(Keil 生成的链接清单,列出所有参与链接的目标文件):

E 工程的链接清单里,确实没有 stm32f1xx_hal_msp.o。

反过来,我还在里面看到一个意外的名字:stm32f1xx_it_1.o。

那个 _1 后缀让我明白了——这个工程以前被人手工改过、复制过、重命名过。而手工复制,正是"文件漏加"这件事的最高发场景。

第二处是 .map 文件,也就是这次排查真正的判决书。

.map 文件会记录每一个函数最终来自哪个 .o 文件、占多少字节。我把 HAL_MspInit 这个函数在两个工程里都查了一遍。

E 工程(坏的)长这样:


HAL_MspInit 0x080009b1 

Thumb Code 2 stm32f1xx_hal.o(i.HAL_MspInit)


D 工程(好的)长这样:


HAL_MspInit ... 

Thumb Code 52 stm32f1xx_hal_msp.o(i.HAL_MspInit)


看两个地方就够了:

来源 .o
大小
E 工程
stm32f1xx_hal.o
(HAL 库)
2 字节
D 工程
stm32f1xx_hal_msp.o
(我自己的文件)
52 字节

2 字节。

在 Thumb 指令集里,2 字节刚好够一条指令——BX LR,也就是"直接返回"。

说白了就是:这个函数被调用了,然后什么都没干,直接回来了。

如果它是空函数呢?它就是空函数。

这里就是整个问题的真相。


为什么"少一个文件"能让 SPI 直接哑掉

要讲清楚这件事,得先解释 HAL 库里的一个机制:__weak。

你可以把它理解成一份带备胎的合同:

HAL 库里给每个需要用户自己实现的函数,都准备了一份"空壳版本",而且标记为弱定义(__weak)。这个弱定义的意思是:

"如果用户提供了一份真正的实现,就用用户的;如果用户没提供(或者像这次一样,提供了但没被编译进去),那就用我这个空壳顶上,不报错。"

所以链接器看到的情况是:用户没有实现 HAL_MspInit。

它不报错。它不警告。它非常礼貌地把库里那个 2 字节的空函数塞了进去,然后一切照常链接,一切照常烧录,一切照常运行。

这就是这个坑最阴险的地方:

  • • 编译:通过 ✅
  • • 链接:通过 ✅
  • • 烧录:成功 ✅
  • • 运行:什么都不做 ❌

系统给你的所有反馈都是"正常",只有结果不对。

那这个空掉的 HAL_MspInit 到底该干什么活呢?

在 STM32F103 上,我要把 SPI1 重映射到 PB3 / PB4 / PB5 三个脚上。而这三个脚里的 PB3 和 PB4,出厂默认是 JTAG 调试口(PB3 = JTDO,PB4 = JNTRST)。

也就是说,这两个脚在默认状态下根本不属于你,它们是留给调试器的。你要拿它们当 SPI 用,必须先办这么几件事:

要做的事
为什么
打开 AFIO 时钟
不打开,引脚重映射的配置寄存器你根本写不进去
关掉 JTAG、只保留 SWD
不关,PB3 / PB4 永远被 JTAG 占着
使能 SPI1 重映射
前三步做完,这一句才真正生效

这三件事,我全都写在 stm32f1xx_hal_msp.c 里了。

代码写得没错。

但它没被编译。

所以那些寄存器配置,一行都没有执行。

于是链条就串起来了——而它是一根直线:


E 工程 .uvprojx 缺少 

stm32f1xx_hal_msp.c│▼编译时找不到用户实现的 

HAL_MspInit│▼链接器静默选中

HAL 库里的 __weak 空桩

(2 字节,什么都不做)│▼AFIO 时钟没开,

SWJ 配置没执行│▼PB3 / PB4 仍然被 JTAG 占着│▼__HAL_AFIO_REMAP_SPI1_ENABLE() 

虽然调用了,但引脚切不过去│▼SPI1 的 SCK / MISO 不工作│▼读回数据全是 0x00


最讽刺的是最后几行。

我在代码里确实调用了"使能重映射"这个函数,那一行代码执行了,语法没错,函数也进去了。但前两步的前提没做,所以这一句就像拿到了钥匙,却发现门被人从里面锁上了。


修复只花了两分钟

真正修起来,比排查快得多。

在 Keil 的工程树里,右键 Application/User/Core 组:

Add Existing Files to Group...

选中 Core/Src/stm32f1xx_hal_msp.c → Add

然后全量重编译(Rebuild,不要用增量编译)。

我坚持全量的原因,是我在第三步发现的那些残留 .o:增量编译可能复用输出目录里的旧产物,而那个目录里本来就躺着不该有的东西。既然已经怀疑到构建环节,就干脆让它从零重来一遍,别给"看起来改了、其实没改"留机会。

修完之后,HAL_MspInit 在 .map 里就变回了一行让人安心的记录:


HAL_MspInit ... 

Thumb Code 52 stm32f1xx_hal_msp.o(i.HAL_MspInit)


从 2 字节变回 52 字节。

SPI 立刻正常了。


我在这件事上记住的三件事

第一件:文件在磁盘上 ≠ 文件参与编译。

这是我最需要改的一个思维惯性。以前我看到 Core/Src 里有这个文件,就默认它"在工程里"。

顺带说个细节,很多人会把两种情况搞混:

  • • 文件没加进工程组:工程树里根本看不到它。(我这次是这种)
  • • 文件加了但被排除编译(Exclude from Build):工程树里看得到,但图标是灰的,或者带个排除标记。

两种都是"不参与编译",但排查动作完全不同。前者要去 Add Existing Files,后者要去右键文件取消排除。别看到灰色就以为找到问题了,看清是哪一种。

第二件:编译链接成功 ≠ 行为正确。

__weak 这个机制,是我见过的"最礼貌的坑"。它不骂你,不拦你,什么都通过,然后在运行时给你一片 0x00。

所以我现在的习惯是:凡是"我明明写了这个函数"但行为不对,就去 .map 里查它的来源和大小。 大小不对,基本就实锤了。

第三件:__weak 之外,"有旧产物"也会骗你。

编译输出目录里残留的旧 .o 文件,可能被继续链接进去,让你误以为"我改的东西生效了"。这也是我这次要求自己用 Rebuild 而不是增量编译的原因。


这张表我建议你存下来

以后再遇到"源码明明一样、行为就是不一样"的情况,按这个顺序走:

顺序
查什么
查的目的是
1
逐文件对比源码
先确认代码本身没问题(排除掉最容易怀疑的方向)
2
.uvprojx
确认该编译的文件是不是都进了工程
3
.lnp
确认链接清单里 .o 是否齐全、有没有重复残留
4
.map
确认关键函数的实际来源,抓出被顶上的 __weak 空桩

四步里,第四步是杀手锏。前三步都是在缩小范围,只有 .map 会直接告诉你答案。

还有一句话我想放在最后:

代码对,不代表工程对。

这句话看着像废话,但真到出问题的时候,大多数人会抱着代码看一整天——因为"我看得懂代码,我看不懂工程文件"。

我这次也是坐到椅子上,才想到要把怀疑对象换个方向。


最后聊两句:

你有没有遇到过这种"复制一个工程就坏掉"的情况?

或者更宽一点——有没有哪个问题,你查了半天代码,最后发现压根不是代码的问题?

评论区说说看,我把典型的几个挑出来,一起往下拆。

因为这类问题,光看结论记不住,非得自己也栽一次才真的记住。

相关学习资料