一次技术沟通中,发现与我做不同项目的同行,在 C++ 机制和操作系统 API 上特别有心得。交流后对我启发很大,把当时聊到的问题按”面试题 + 答案”的方式整理如下,方便速查和背诵。
一、语言机制
问题1:Windows 怎么收集 dump?单进程和多进程分别怎么做?
一句话答案: 进程内注册 SetUnhandledExceptionFilter,崩溃回调里调 MiniDumpWriteDump 写文件;多进程让每个子进程自收集,父进程只监控退出码;不想写代码就配 WER 的 LocalDumps 注册表。
单进程(代码方式)
1 |
|
要点:
- dump 类型:
MiniDumpNormal只有栈和模块信息(几十 KB);MiniDumpWithFullMemory含完整内存(体积大但 WinDbg 里能看堆数据)。 - UEF 的盲区:进程被调试器附加时不触发;
abort()、__fastfail等 CRT 主动终止不走 UEF。 - 回调里不要再返回
EXCEPTION_EXECUTE_HANDLER继续跑,崩溃后的进程状态不可信,写完就退。
单进程(免代码方式:WER)
注册表 HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps 下建三个值:
| 值 | 类型 | 含义 |
|---|---|---|
| DumpFolder | REG_EXPAND_SZ | dump 保存目录 |
| DumpCount | DWORD | 最多保留几个 |
| DumpType | DWORD | 1=mini dump,2=full dump |
崩溃时系统自动落盘,适合生产环境兜底。
多进程
- 推荐:每个子进程自己注册 UEF 自收集。父进程用
WaitForSingleObject等子进程句柄,看退出码判断是否正常结束。 - 备选:父进程以
DEBUG_PROCESS标志CreateProcess子进程,循环WaitForDebugEvent收EXCEPTION_DEBUG_EVENT时跨进程写 dump。缺点:父进程独占调试端口,子进程的 UEF 会失效(异常先给调试器),实现也复杂。
工程选型
| 场景 | 方案 |
|---|---|
| 客户端 | UEF 自收集,自定义路径和上报 |
| 服务/守护 | 子进程自收集 + 父进程监控拉起 |
| 生产兜底 | WER LocalDumps |
| 跨平台 | Crashpad / Breakpad 开源库 |
问题2:函数调用约定是什么?
一句话答案: 调用约定规定三件事——参数怎么传、栈谁清理、名字怎么修饰。x86 默认 __cdecl,x64 Windows 只有一种约定(关键字全被忽略)。
x86 常见约定
| 约定 | 参数传递 | 栈清理 | 名称修饰 | 用途 |
|---|---|---|---|---|
__cdecl |
右→左压栈 | 调用方 | _func |
C/C++ 默认;调用方清栈才能支持 printf 这类可变参数 |
__stdcall |
右→左压栈 | 被调方(ret N) |
_func@N |
Win32 API |
__fastcall |
前两个参数 ECX/EDX,其余压栈 | 被调方 | @func@N |
求快 |
__thiscall |
this 走 ECX,其余压栈 | 被调方 | C++ 修饰 | x86 成员函数默认,不可显式指定 |
x64
- Windows 只有一种 Microsoft x64 calling convention:前 4 个整数参数用 RCX/RDX/R8/R9,调用方预留 32 字节 shadow space,调用方清栈。
__cdecl/__stdcall/__fastcall写在 x64 代码里被编译器忽略,写了也白写。__vectorcall是独立的显式约定(SIMD 参数走 XMM),不是 x64 默认约定的别名。
不匹配的后果与对策
- 调用方和被调方约定不一致 → 栈不平衡 → 返回地址错位崩溃。hook/注入时代理函数必须与原函数约定一致。
- DLL 导出函数双方必须声明一致;
extern "C"去掉 C++ 名称修饰,配 .def 文件可再去掉_、@N修饰,让GetProcAddress用干净的名字找函数。
问题3:Qt 信号槽怎么传递自定义数据结构?跨线程呢?
一句话答案: 跨线程用 Qt::QueuedConnection,参数会被拷贝进目标线程的事件队列;自定义类型必须 Q_DECLARE_METATYPE + qRegisterMetaType,否则运行时报 “Cannot queue arguments of type”。
连接类型决定行为
| 类型 | 行为 |
|---|---|
Qt::DirectConnection |
信号发出时立即在发送方线程同步调用槽 |
Qt::QueuedConnection |
参数拷贝后入事件队列,在接收方线程的事件循环里异步执行 |
Qt::AutoConnection(默认) |
收发同线程 = Direct,跨线程 = Queued |
跨线程传自定义类型的完整步骤
1 | // 1. 头文件里声明可拷贝的类型. |
三个坑
- 先 emit 后 connect:信号发出时还没有连接,直接丢失。连接必须在发射之前建立。
- 跨线程传指针/引用:QueuedConnection 异步执行,等槽函数跑起来,发送方的局部变量可能已析构。跨线程一律传值或
QSharedPointer。 - 接收方线程没有事件循环:
QThread子类如果没调exec(),队列里的槽永远不会执行。
问题4:怎么加载不同路径下的 DLL?
一句话答案: 知道绝对路径直接 LoadLibrary;要加搜索目录用 AddDllDirectory 配合 LoadLibraryEx 的 LOAD_LIBRARY_SEARCH_USER_DIRS;SetDllDirectory 会替换默认搜索顺序,慎用。
方法速查
1 | // 方法一:绝对路径,最明确. |
要点
LoadLibrary的参数完整路径或仅文件名都可以:完整路径直接定位、绕过搜索顺序;只写文件名则走默认搜索顺序。相对路径依赖”当前目录”,可能被别的代码改掉,不推荐。- 不用把每个 DLL 都 LoadLibrary 一遍:你只加载自己直接调用的模块,它的依赖由加载器读导入表自动解析。同目录的多个 DLL 用 AddDllDirectory 加一次目录即可。
- 完整路径加载的 DLL,它的依赖不会优先从该路径找,仍走默认搜索顺序,除非用
LOAD_WITH_ALTERED_SEARCH_PATH标志或提前 AddDllDirectory。 - 默认搜索顺序(SafeDllSearchMode 开启):应用程序目录 → System32 → Windows 目录 → 当前目录 → PATH。当前目录排在 PATH 前面,也是 DLL 劫持的经典攻击面。
- 延迟加载(链接器
/delayload:mydll.dll):首次调用才加载 DLL,但链接时仍需要导入库.lib和delayimp.lib,不是完全脱离 lib。 - 不要用
SetCurrentDirectory改变相对路径基准——它是进程全局状态,多线程下不安全。
实战场景:A/B 两套同名 DLL 目录的切换测试
场景: 路径 A 和路径 B 下各有 100 个同名 DLL(比如新旧两版实现),要多次启动程序分别验证两套 DLL 功能是否正常。
错误思路: 逐个 LoadLibrary 完整路径。100 个 DLL 互相有依赖,枚举不完;而且依赖解析不看你 LoadLibrary 的路径,仍走默认搜索顺序,结果加载出”A 目录的 a.dll + B 目录的 b.dll”的混搭,测了个寂寞。
正确思路:控制搜索顺序,而不是控制每次加载。 启动时把选定目录插进搜索路径,之后所有加载(包括依赖解析)自动命中同一目录:
1 |
|
三个必须注意的前提:
- 隐式链接时 SetDllDirectory 来不及生效。 exe 如果链了 DLL 的 .lib(代码里直接调导出函数),DLL 在
main运行之前就被加载器装好了。解决办法:链接器 → 输入 → 延迟加载的 DLL,把相关 DLL 设成/delayload,首次调用才加载;或者放弃 .lib,全部显式 LoadLibrary + GetProcAddress。 - exe 所在目录不能放同名 DLL。
SetDllDirectory插入的位置在”应用程序目录”之后,exe 目录下有同名 DLL 会永远先命中,A/B 测试全部失效。 - 同进程内无法先后加载两个同名 DLL。 系统按模块名判重,第二次 LoadLibrary 直接返回第一次的句柄。所以 A/B 切换必须是”改注释 → 重新编译 → 重新启动进程”,想在单进程内热切换是不行的(真要热切换得用隔离的 Ldr 私有命名空间,远超测试需求)。
替代方案对比:
| 方案 | 切换方式 | 适用 |
|---|---|---|
| 注释常量 + 重编译(上面方案) | 改代码 | DLL 数量多、有依赖关系,推荐 |
| 命令行参数传路径 | 运行时不改代码 | 自动化测试脚本 |
| 两个 .bat 各带参数启动 | 双击切换 | 手动反复验证 |
问题5:this 指针是怎么传递的?
一句话答案: this 是成员函数的隐藏首参:x86 走 ECX(__thiscall),x64 Windows 走 RCX,x64 Linux(System V ABI)走 RDI。static 成员函数没有 this。
汇编视角(x64 Windows)
1 | ; obj.add(a, b) 等价于 add(&obj, a, b). |
面试延伸点
- 多重继承要调整 this:指向非首个基类的指针调虚函数时,编译器在虚表里插 thunk 先把 this 减去偏移,再跳到真正的函数。
- this 可以为 nullptr 而不崩:成员函数体内只要不访问成员变量、不调虚函数,
nullptr->func()实际能跑通(但属于未定义行为,别依赖)。这是经典面试陷阱题。 - 普通回调想拿到 this:用 lambda 捕获
[this]、std::bind,或写一个 static 函数接收对象指针再转发。
问题6:32 位、64 位下各数据类型多少字节?哪些变了?为什么?
一句话答案: 变的只有指针、size_t、long(仅 Linux/macOS);Windows 用 LLP64 模型(long 保持 4 字节),Linux/macOS 用 LP64 模型(long 变 8 字节)。
| 类型 | 32 位 | Win64 (LLP64) | Linux64 (LP64) |
|---|---|---|---|
| char / short / int | 1 / 2 / 4 | 1 / 2 / 4 | 1 / 2 / 4 |
| long | 4 | 4 | 8 |
| long long | 8 | 8 | 8 |
| float / double | 4 / 8 | 4 / 8 | 4 / 8 |
| 指针 | 4 | 8 | 8 |
| size_t / ptrdiff_t | 4 | 8 | 8 |
为什么这样设计
- 指针必须变 8 字节才能寻址 64 位空间;
size_t跟着指针走,否则表示不了大对象。 - Windows 让 long 保持 4 字节是兼容性选择:大量 Win32 代码假设
long和int同宽、DWORD 就是 long。Unix 世界没有这层包袱,long 直接升到 8。
移植守则
1 |
|
二、Windows / Visual Studio
问题7:VS 的 /MD、/MDd、/MT、/MTd 有什么区别?
一句话答案: 这是 CRT(C 运行时库)链接方式:/MD = 动态链接 CRT(依赖 vcruntime140.dll,需装 VC++ Redist),/MT = 静态链接 CRT(无外部依赖、体积大),末尾 d = Debug 版。同进程所有模块必须统一。
命名规则与对照
M = Multithreaded;第二字母 D = 动态 CRT、T = 静态 CRT;末尾 d = Debug。
| 选项 | 链接方式 | 依赖 | 适用 |
|---|---|---|---|
| /MD | 动态 | vcruntime140.dll、ucrtbase.dll、msvcp140.dll | Release 主流选择 |
| /MT | 静态 | 无(CRT 编进二进制) | 单文件分发、绿色软件 |
| /MDd | 动态 Debug | 带 d 后缀的 CRT DLL | Debug 对应 /MD |
| /MTd | 静态 Debug | 无 | Debug 对应 /MT |
必须统一的原因:跨模块堆崩溃
1 | // DLL 里(/MT 编译,有自己的 CRT 堆). |
- 每个静态链接 CRT 的模块都有自己独立的堆管理器,跨模块 malloc/free 直接崩。
- 就算都 /MD,CRT 版本不同(如 VS2015 配 VS2019 编译的模块混用)也可能出问题。
- 设计原则:谁分配谁释放,DLL 接口不透传内存所有权,导出对应的
FreeXxx函数。
配置位置
项目属性 → C/C++ → 代码生成 → 运行时库。
参考资料
- MSDN: MiniDumpWriteDump、SetUnhandledExceptionFilter、LocalDumps(WER)。
- MSDN: x64 Calling Convention、Calling Conventions(x86)。
- Qt 文档: Signals & Slots、QMetaType。
- 《程序员的自我修养——链接、装载与库》。