最近在做 keyBonk 这个项目时,遇到了一个卡了我将近六小时的 bug。回头想想还挺有意思的,于是打算写篇博客记录一下。
这是一个 C++ 的 Win32 项目,因为我写它的时候刚学 C++,参考 Win32 文档的风格较多,所以名义上是 C++,实际上更接近 C 风格。最近我打算引入一些 C++ 特性来重写部分模块,比如用 struct 统一管理资源:
namespace keybonk
{
struct resource_manager
{
public:
ULONG_PTR gdiplusToken = 0;
bool comInitialized = false;
wchar_t *fullIniFilePath = nullptr;
wchar_t *fullDebugFilePath = nullptr;
HWND hwnd = NULL;
HWND hwndAbout = NULL;
HWND hwndSetting = NULL;
bool Mute = false;
bool MuteMouse = false;
bool WindowPenetrate = false;
NOTIFYICONDATA nid = {};
wchar_t audioLibPath[MAX_PATH];
bool minimum = false;
HINSTANCE hInstance;
HHOOK KeyboardHook = nullptr;
HHOOK MouseHook = nullptr;
HBITMAP hBmp = nullptr;
HDC hdcScreen = nullptr;
HDC memDC = nullptr;
HBITMAP hOldBmp = nullptr;
int nCmdShow;
HRESULT hrMain;
std::optional<keybonk::background> bg_opt;
~resource_manager();
};
inline resource_manager global;
}
这个结构体集中管理了大量全局资源,并负责在析构时自动释放,从而解决中途抛异常或返回错误时资源泄漏的问题。global 是它的全局实例,为了在多个 .cpp 文件中避免重复定义,我使用了 inline 关键字来保证单例。
代码能跑起来,但以前正常的音频播放突然没声了。查看日志,我定位到 audioPlay.cpp 里的这段代码:
using keybonk::global;
debug::logOutPut("音频库路径:", global.audioLibPath);
输出却是:
音频库路径:
而把同样的代码放到 main.cpp 中,输出就正常了:
音频库路径:./bin/default/
也就是说,在 main.cpp 里 global.audioLibPath 能正常访问,在 audioPlay.cpp 里却不行。为什么?
我排查了很久,各种逻辑看起来都对,实在没招了,于是打开了 Trea。
当前项目遇到一个问题:
main.cpp可以正常使用global对象的audioLibPath,audioPlay.cpp却不行,可能是什么原因?
Trae 初步分析后猜测是 inline 的编译器 bug,建议改成 extern 形式。但改完之后问题依旧,说明这不是主因。
接着 Trae 继续分析,我也开始大量查资料。说实话,这个问题我甚至找不到一个准确的描述,只能写一大段提示词反复喂给 DeepSeek、豆包、Kimi……经过多轮分析后,我确信不能太指望 AI 了。
不过 Trae 有一句话倒是点醒了我:看地址。
于是我加了一段调试输出:
using keybonk::global;
debug::logOutPut("global:", &global, "audioLibPath:", &global.audioLibPath);
结果如下:
[程序启动] 2026-4-19 3:16:39
[main.cpp] keybonk::global 地址 = 0x7ff73bfa0040, audioLibPath 地址 = 0x7ff73bfa0498
[audioPlay.cpp] keybonk::global 地址 = 0x7ff73bfa0040, audioLibPath 地址 = 0x7ff73bfa02d8
global 地址相同,但 audioLibPath 的地址却不同——这很奇怪。我一时没想通,但 Trae 很快反应过来:问题出在 NOTIFYICONDAT 上!
注:当然它也不是那么聪明,我中途还让它查了一次预处理结果它才看出来,不过已经比我强多了
回顾资源管理类的成员排布(简化):
NOTIFYICONDATA nid = {}; // 任务栏通知区域图标状态
wchar_t audioLibPath[MAX_PATH];
bool minimum = false;
audioLibPath 紧跟在 nid 之后。而 nid的类型NOTIFYICONDATA实际上是个宏定义,它的大小取决于是否定义了 UNICODE 宏:
- 定义了
UNICODE,它实际上会变为NOTIFYICONDATAW,数据使用宽字符 - 未定义则变为
NOTIFYICONDATAA,使用窄字符
main.cpp 中在初期开发时就定义了 UNICODE,因此 nid 是宽字符版本,占用较大空间。
而 audioPlay.cpp 缺少 UNICODE 宏,于是 nid 被当作 ANSI 版本,比宽版本小了 448 字节。
由于两个 .cpp 文件对同一个结构体的内存布局理解不一致,导致 audioLibPath 在 audioPlay.cpp 中的偏移量计算错误,从而访问到了错误的内存地址。日志里看不到路径内容,自然也就无法播放音频了。
又是宏定义不一致惹的祸,说实话,这还挺简单的,但真的很难排查。前一阵子做 desktopDanmaku 做习惯了,默认自己在 Makefile 里写过 -DUNICODE,结果这次就栽在这个细节上了。