C++沉思(1)
| 日期 | 版本 | 作者 | 变更 |
|---|---|---|---|
| 2025-12-18 | V1.0 | Lex | 初稿 |
文档背景
一次技术沟通中,发现与我做不同项目的同行,在C++的机制和操作系统 API 上特别有心得,而这是我近两年的工作和学习中用不上,也没有深入学的内容,交流后对我有很大启发,很过瘾。
这些内容解释了很多原理,填补了我的认知空白,也间接指导了我后续的实践,非常开心有这样的交流,后面我拉了个微信群,只拉取了 C++ 开发在里面,把靠谱的 C++ 都拉进群。
术语解释
- Dump(转储文件):进程崩溃时保存的内存快照,可用于事后调试分析
- 调用约定:函数参数传递顺序、栈清理责任、名字修饰方式的统一规范
- 信号槽:Qt 的一种对象间通信机制,类似于观察者模式
- DLL(动态链接库):Windows 系统的共享库,可在运行时加载
- this 指针:指向当前对象的指针,是成员函数的隐含参数
- MD/MT:Visual Studio 的运行时库链接方式
参考资料
- 《Windows 高级编程》
- MSDN: MiniDumpWriteDump
- 《程序员的自我修养》
1 语言机制
1.1 Windows 怎么收集 dump,单进程崩溃怎么执行,多进程崩溃怎么收集?
基本概念
Dump 文件是进程崩溃时内存状态的快照,包含线程信息、堆栈信息、模块信息等。Windows 提供 DbgHelp.dll 来生成 Dump 文件。
单进程崩溃收集
方法一:使用 SetUnhandledExceptionFilter(代码方式)
#include <windows.h>
#include <dbghelp.h>
#include <iostream>
#include <ShlObj.h>
#pragma comment(lib, "dbghelp.lib")
LONG WINAPI UnhandledExceptionHandler(EXCEPTION_POINTERS* pExceptionInfo) {
// 获取文档路径
TCHAR szPath[MAX_PATH];
SHGetFolderPath(NULL, CSIDL_PERSONAL, NULL, 0, szPath);
// 生成文件名(带时间戳)
SYSTEMTIME st;
GetSystemTime(&st);
TCHAR szFileName[MAX_PATH];
wsprintf(szFileName, TEXT("%s\\crash_%04d%02d%02d_%02d%02d%02d.dmp"),
szPath, st.wYear, st.wMonth, st.wDay,
st.wHour, st.wMinute, st.wSecond);
// 创建 Dump 文件
HANDLE hFile = CreateFile(szFileName, GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile != INVALID_HANDLE_VALUE) {
MINIDUMP_EXCEPTION_INFORMATION mdei;
mdei.ThreadId = GetCurrentThreadId();
mdei.ExceptionPointers = pExceptionInfo;
mdei.ClientPointers = FALSE;
// 写入 Dump
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
hFile, MiniDumpNormal, &mdei, NULL, NULL);
CloseHandle(hFile);
std::cout << "Dump saved to: " << szFileName << std::endl;
}
return EXCEPTION_EXECUTE_HANDLER;
}
int main() {
SetUnhandledExceptionFilter(UnhandledExceptionFilter);
// 触发崩溃测试
int* p = nullptr;
*p = 1; // 空指针访问崩溃
return 0;
}
方法二:使用 Windows Error Reporting(WER)
无需代码,系统自带。崩溃时自动收集,保存在:
C:\ProgramData\Microsoft\Windows\WER\ReportQueue\
启用方法:注册表 HKLM\Software\Microsoft\Windows\Windows Error Reporting\LocalDumps
多进程崩溃收集
多进程场景(如主进程+子进程)需要父进程监控子进程:
#include <windows.h>
#include <tlhelp32.h>
#include <dbghelp.h>
// 创建子进程并监控
void CreateAndMonitorProcess(const char* exePath) {
STARTUPINFOA si = {sizeof(si)};
PROCESS_INFORMATION pi;
if (CreateProcessA(exePath, NULL, NULL, NULL, FALSE,
DEBUG_PROCESS, NULL, NULL, &si, &pi)) {
DEBUG_EVENT de;
while (WaitForDebugEvent(&de, INFINITE)) {
if (de.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) {
// 子进程退出,收集 Dump
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS,
FALSE, de.dwProcessId);
if (hProcess) {
char dumpPath[MAX_PATH];
sprintf(dumpPath, "child_dump_%d.dmp", de.dwProcessId);
HANDLE hFile = CreateFileA(dumpPath, GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, 0, NULL);
if (hFile != INVALID_HANDLE_VALUE) {
MiniDumpWriteDump(hProcess, de.dwProcessId, hFile,
MiniDumpNormal, NULL, NULL, NULL);
CloseHandle(hFile);
}
CloseHandle(hProcess);
}
}
ContinueDebugEvent(de.dwProcessId, de.dwThreadId,
DBG_CONTINUE);
}
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
}
实际应用建议
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 客户端应用 | SetUnhandledExceptionFilter | 用户体验好,可自定义保存路径 |
| 服务端/守护进程 | 父进程监控 | 子进程崩溃不影响主进程 |
| 生产环境 | WER + 日志 | 系统级别,无需额外代码 |
1.2 函数调用约定是指什么?
基本概念
调用约定(Calling Convention)是函数调用时的一套规则,包括:
- 参数传递顺序(从左到右 vs 从右到左)
- 栈清理责任(调用方 vs 被调方)
- 名字修饰规则(编译器内部标识)
- this 指针传递方式(C++ 成员函数)
常见调用约定
| 调用约定 | 参数传递 | 栈清理 | 名字修饰 | 备注 |
|---|---|---|---|---|
__cdecl |
右→左 | 调用方 | _functionName |
C/C++ 默认 |
__stdcall |
右→左 | 被调方 | _functionName@N |
Windows API |
__fastcall |
ECX, EDX → 栈 | 被调方 | @functionName@N |
快速调用 |
__thiscall |
ECX + 栈 | 被调方 | C++ 编译器决定 | 成员函数默认 |
__vectorcall |
XMM 寄存器 | 被调方 | functionName@@ |
SIMD 优化 |
代码示例
// 显式指定调用约定
int __cdecl cdeclFunc(int a, int b); // 调用方清理栈
int __stdcall stdcallFunc(int a, int b); // 被调方清理栈(Windows API)
int __fastcall fastcallFunc(int a); // 用 ECX, EDX 传参
// 混用可能导致栈不平衡崩溃
#ifdef _MSC_VER
#define MYAPI __stdcall
#else
#define MYAPI __cdecl
#endif
extern "C" int MYAPI MyExportFunction(int param);
实际应用
问题: DLL 导出函数如果约定不匹配,会导致栈错误
// DLL 导出(被调用方)
extern "C" __declspec(dllexport) int __stdcall Add(int a, int b) {
return a + b;
}
// 调用方必须匹配
typedef int(__stdcall *AddFunc)(int, int);
HMODULE h = LoadLibrary("mydll.dll");
AddFunc fn = (AddFunc)GetProcAddress(h, "Add@8"); // @8 表示参数占用8字节
1.3 Qt 的信号槽怎么传递数据结构?例如跨线程的时候如何传递?
基本原理
Qt 信号槽默认是同步的(直接调用),跨线程时通过事件队列异步传递。信号发送时,参数会被拷贝到事件队列,由目标线程的事件循环处理。
传递方式
// 方式一:直接传递(栈上的局部变量需要注意生命周期)
emit mySignal(someData); // 如果是值类型,会自动拷贝
// 方式二:使用指针(需要手动管理生命周期)
emit mySignal(&someData); // 危险!对象可能在接收前被销毁
// 方式三:使用 QSharedPointer(推荐)
emit mySignal(QSharedPointer<MyData>::create(args));
// 方式四:Qt 5.7+ 使用 Qt::QueuedConnection 显式跨线程
emit mySignal(args); // 自动处理
跨线程传递示例
// MyData.h
class MyData {
public:
int id;
QString name;
QVector<double> values; // 可以传递复杂数据结构
Q_OBJECT
};
// Worker 线程
class Worker : public QObject {
Q_OBJECT
public slots:
void doWork(const MyData& data) {
// 在线程中处理数据
qDebug() << "Processing:" << data.name;
}
signals:
void workDone(const MyData& result);
};
// 主线程
class MainWindow : public QWidget {
Q_OBJECT
public:
void startTask() {
MyData data;
data.id = 1;
data.name = "Test";
data.values = {1.0, 2.0, 3.0};
// 跨线程发送信号,参数会被自动拷贝/序列化
// Qt::QueuedConnection 会将参数放入事件队列
Qt::ConnectionType type = Qt::QueuedConnection;
// 方法一:直接 emit,Qt 自动处理跨线程
// 内部会使用 Qt::QueuedConnection
emit startWorkSignal(data); // data 会被拷贝
// 方法二:显式指定队列连接
connect(this, &MainWindow::startWorkSignal,
m_worker, &Worker::doWork, Qt::QueuedConnection);
// 方法三:使用 Q_ARG 队列参数(Qt 4 风格)
QMetaObject::invokeMethod(m_worker, "doWork",
Qt::QueuedConnection, Q_ARG(MyData, data));
}
signals:
void startWorkSignal(const MyData&);
private:
Worker* m_worker;
};
关键点总结
| 场景 | 处理方式 | 注意事项 |
|---|---|---|
| 基础类型(int/QString) | 自动拷贝 | 默认即可 |
| 自定义类型 | 需要注册到 Qt 元对象系统 | 使用 Q_DECLARE_METATYPE |
| 指针/引用 | 谨慎使用 | 考虑生命周期 |
| 大数据量 | 移动语义/QSharedPointer | 减少拷贝开销 |
| 跨线程 | Qt::QueuedConnection | 自动处理线程切换 |
重要: 自定义类型需要注册:
// 注册自定义类型
Q_DECLARE_METATYPE(MyData);
// 在 connect 前调用(如果跨线程)
qRegisterMetaType<MyData>("MyData");
1.4 怎么加载不同路径下的 dll?
方法一:LoadLibrary 显式加载
#include <windows.h>
typedef int(*AddFunc)(int, int);
int main() {
// 方法1:绝对路径
HMODULE h = LoadLibraryA("C:\\mylib\\mydll.dll");
// 方法2:相对路径(相对于当前工作目录)
h = LoadLibraryA(".\\mydll.dll");
// 方法3:使用环境变量路径
const char* envPath = getenv("MYDLL_PATH"); // 用户设置环境变量
if (envPath) {
std::string dllPath = std::string(envPath) + "\\mydll.dll";
h = LoadLibraryA(dllPath.c_str());
}
if (h) {
AddFunc fn = (AddFunc)GetProcAddress(h, "Add");
if (fn) {
int result = fn(1, 2);
}
FreeLibrary(h);
}
return 0;
}
方法二:SetDllDirectory / AddDllDirectory(推荐)
#include <windows.h>
int main() {
// 方法1:设置默认搜索路径(替换系统搜索路径)
SetDllDirectoryA("C:\\mylib\\dlls");
// 方法2:添加到搜索路径列表开头(推荐,保留系统路径)
AddDllDirectory(L"C:\\mylib\\custom_dlls");
AddDllDirectory(L"C:\\program files\\myapp\\plugins");
// 之后 LoadLibrary 会先搜索这些路径
HMODULE h = LoadLibraryA("mydll.dll"); // 自动找到
return 0;
}
方法三:修改可执行文件目录
#include <windows.h>
int main() {
// 获取可执行文件所在目录
char path[MAX_PATH];
GetModuleFileNameA(NULL, path, MAX_PATH);
// 去掉文件名,获取目录
std::string dir = path;
size_t pos = dir.rfind('\\');
if (pos != std::string::npos) {
dir = dir.substr(0, pos);
}
// 添加到 DLL 搜索路径
std::string dllPath = dir + "\\dlls";
SetDllDirectoryA(dllPath.c_str());
return 0;
}
方法四:延迟加载(Delay Load)
Visual Studio 项目属性 → 链接器 → 输入 → 延迟加载的 DLL
// 编译时不需要 .lib 文件,运行时加载
#pragma comment(linker, "/delayload:mydll.dll")
// 使用方式一样,但首次调用时才加载
int result = Add(1, 2); // 首次调用时才 LoadLibrary
实际应用对比
| 方法 | 优点 | 缺点 |
|---|---|---|
| 绝对路径 | 明确 | 不灵活,迁移麻烦 |
| SetDllDirectory | 简单全局生效 | 需管理员权限修改 |
| AddDllDirectory | 安全,推荐 | Windows 7+ |
| 延迟加载 | 开发简单 | 运行时才报错 |
| 环境变量 | 用户可配置 | 需设置环境变量 |
1.5 this 指针是怎么传递的?
基本原理
this 指针是成员函数的隐含参数,在 C++ 中通常通过寄存器或栈传递。
不同调用约定的传递方式
class MyClass {
public:
// 隐含实现类似:void __thiscall method(MyClass* this, int a, int b)
void method(int a, int b);
// static 函数没有 this
static void staticMethod(int a);
};
| 调用约定 | this 传递位置 | 说明 |
|---|---|---|
__thiscall |
ECX 寄存器 | 32位 MSVC 默认 |
| x64 调用约定 | RCX 寄存器 | 64位默认 |
| GCC/Clang | RDI 寄存器 | 64位 System V ABI |
32位 vs 64位 示例
class Calculator {
public:
int add(int a, int b) { return a + b; }
private:
int m_value;
};
// 32位汇编(MSVC):
// mov ecx, [esp+4] ; this 指针放入 ecx
// mov eax, [ecx] ; 读取 m_value
// 64位汇编(MSVC):
// mov rcx, rdx ; this 指针(第一个隐藏参数)
// mov eax, [rcx] ; 读取 m_value
虚函数的 this 调整
class Base {
public:
virtual void vfunc() {}
};
class Derived : public Base {
public:
void vfunc() override {}
};
Base* p = new Derived();
// p 可能是指向 Derived 对象的 Base 子对象的指针
// 虚函数调用需要先调整 this 指针到完整的 Derived 对象
编译器会生成类似代码:
// 原始代码
p->vfunc();
// 实际生成
void* adjusted_this = p; // 可能需要调整
((void(*)(void*))(*(void**)(*(void**)adjusted_this)[0])(adjusted_this);
实际应用
问题: 回调函数如何获取 this?
// 错误的做法:普通函数没有 this
void callback() {
// 无法访问成员变量
}
// 正确做法:使用 lambda / std::bind / 成员函数指针
class MyClass {
public:
void init() {
// 方式1: lambda(推荐)
auto cb = [this]() { handleCallback(); };
registerCallback(cb);
// 方式2: std::bind
auto cb2 = std::bind(&MyClass::handleCallback, this);
registerCallback(cb2);
// 方式3: 成员函数指针
using Callback = void(*)(MyClass*);
Callback fn = &MyClass::staticCallback;
fn(this);
}
void handleCallback() { /* 可以访问 this */ }
static void staticCallback(MyClass* p) { p->handleCallback(); }
};
1.6 32位、64位系统,int、long 是多少字节,哪些数据类型的字节变了?哪些不变?为什么不变?
数据类型字节大小
| 类型 | 32位 | 64位 | 变化? |
|---|---|---|---|
| char | 1 | 1 | 不变 |
| short | 2 | 2 | 不变 |
| int | 4 | 4 | 不变 |
| long | 4 | 8 | 变化 |
| long long | 8 | 8 | 不变 |
| float | 4 | 4 | 不变 |
| double | 8 | 8 | 不变 |
| pointer | 4 | 8 | 变化 |
| size_t | 4 | 8 | 变化 |
| ptrdiff_t | 4 | 8 | 变化 |
变化的根本原因
- 指针变为 8 字节:为了支持 64 位地址空间(2^64 = 16EB 寻址能力)
- long 变为 8 字节(仅 Windows):Windows 采用 LLP64 模型,long 保持 4 字节;Linux/macOS 采用 LP64 模型,long 变为 8 字节
LP64 vs LLP64 模型
LP64 (Linux, macOS, BSD):
- long (Long) = 64-bit
- pointer = 64-bit
LLP64 (Windows):
- long long = 64-bit
- long = 32-bit (保持兼容)
- pointer = 64-bit
代码示例
#include <iostream>
#include <cstdint>
int main() {
std::cout << "sizeof(int): " << sizeof(int) << std::endl; // 4
std::cout << "sizeof(long): " << sizeof(long) << std::endl; // 4(Win) / 8(Linux)
std::cout << "sizeof(long long): " << sizeof(long long) << std::endl; // 8
std::cout << "sizeof(void*): " << sizeof(void*) << std::endl; // 4 / 8
std::cout << "sizeof(size_t): " << sizeof(size_t) << std::endl; // 4 / 8
std::cout << "sizeof(ptrdiff_t): " << sizeof(ptrdiff_t) << std::endl;
// 使用固定宽度整数避免移植问题
int64_t i64 = 123; // 始终 8 字节
int32_t i32 = 123; // 始终 4 字节
uint64_t u64 = 456;
// size_t 是最安全的数组索引类型
std::vector<int> vec(10);
for (size_t i = 0; i < vec.size(); ++i) {} // 避免警告
return 0;
}
实际注意事项
// 1. 跨平台代码避免使用 long 存储指针
long addr = (long)p; // 32位可以,64位溢出
intptr_t addr = (intptr_t)p; // 正确:始终能存指针
// 2. 格式化输出
printf("%zu\n", size); // size_t 用 %zu
printf("%td\n", diff); // ptrdiff_t 用 %td
printf("%lld\n", llval); // long long 用 %lld
// 3. Windows/Linux 差异
#ifdef _WIN32
#define PRId64 "lld"
#else
#define PRId64 "ld"
#endif
printf("%" PRId64 "\n", value);
// 4. 内存对齐
struct Data {
char c; // 1 + 7 padding
void* p; // 8 (64位)
};
// 32位: 1+3 + 4 = 8
// 64位: 1+7 + 8 = 16
2 Windows/Visual Studio 相关
2.1 VS 中的 MD、MTD 这些调用选项的区别是什么?
基本概念
MD / MT 是 Visual Studio 链接器选项,决定运行时库(Runtime Library)的链接方式:
- M = Multithreaded(多线程)
- D = Dynamic linking(动态链接)
- T = Static linking(静态链接)
- D = Debug(调试版本)
选项对照表
| 编译选项 | 链接方式 | 运行时库 | 宏定义 | 特点 |
|---|---|---|---|---|
| /MD | 动态链接 | MSVCRxx.DLL | _MT, _DLL |
运行时依赖 DLL |
| /MT | 静态链接 | LIBCMT.lib | _MT |
静态链接,无外部依赖 |
| /MDd | 动态链接(调试) | MSVCRxxD.DLL | _MT, _DLL, _DEBUG |
调试版本 |
| /MTd | 静态链接(调试) | LIBCMTD.lib | _MT, _DEBUG |
调试版本 |
代码示例与影响
// test.cpp
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("Hello, World!\n");
// 某些函数在 debug 版本有额外检查
int* p = (int*)malloc(100);
if (!p) {
return 1; // /MDd 下会进入调试 allocator
}
free(p);
return 0;
}
实际选择建议
| 场景 | 推荐选项 | 原因 |
|---|---|---|
| 分发可执行文件 | /MT | 无需用户安装 VC++ 运行库 |
| 插件/DLL | /MD | 与主程序共享运行时 |
| 开发测试 | /MTd | 快速链接,独立调试 |
| 发布版 | /MT | 减少依赖,简化部署 |
常见问题
1. 运行时库不匹配
error LNK2005: "void * __cdecl operator new(unsigned int)"
(??2@YAPAXI@Z) 已经在 LIBCMT.lib 中定义
解决:统一所有项目的链接选项
2. DLL 和 EXE 之间的内存分配
// DLL 中分配
void* AllocMem() {
return malloc(100); // 使用 MSVCRxx.DLL
}
// EXE 中释放(可能使用不同运行时库)
void FreeMem(void* p) {
free(p); // 可能崩溃!
}
解决:使用统一运行时库,或提供导出函数让 DLL 负责释放
3. 调试版本 vs 发布版本混用
MSVCR100D.dll (调试运行时) != MSVCR100.dll (发布运行时)
解决:确保加载的 DLL 与主程序使用相同的运行时库
项目配置位置
Visual Studio 中设置位置:
- 项目属性 → C/C++ → 代码生成 → 运行时库
或通过命令行:
/MD /MDd /MT /MTd
---
# 2 Windows/Visual Studio 相关
## 2.1 VS 中的 MD、MTD 这些调用选项的区别是什么?
### 基本概念
MD / MT 是 Visual Studio 链接器选项,决定运行时库(Runtime Library)的链接方式:
- **M** = **M**ultithreaded(多线程)
- **D** = **D**ynamic linking(动态链接)
- **T** = **S**tatic linking(静态链接)
- **D** = **D**ebug(调试版本)
### 选项对照表
| 编译选项 | 链接方式 | 运行时库 | 宏定义 | 特点 |
|----------|----------|----------|--------|------|
| /MD | 动态链接 | MSVCRxx.DLL | `_MT`, `_DLL` | 运行时依赖 DLL |
| /MT | 静态链接 | LIBCMT.lib | `_MT` | 静态链接,无外部依赖 |
| /MDd | 动态链接(调试) | MSVCRxxD.DLL | `_MT`, `_DLL`, `_DEBUG` | 调试版本 |
| /MTd | 静态链接(调试) | LIBCMTD.lib | `_MT`, `_DEBUG` | 调试版本 |
### 代码示例与影响
```cpp
// test.cpp
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("Hello, World!\n");
// 某些函数在 debug 版本有额外检查
int* p = (int*)malloc(100);
if (!p) {
return 1; // /MDd 下会进入调试 allocator
}
free(p);
return 0;
}
实际选择建议
| 场景 | 推荐选项 | 原因 |
|---|---|---|
| 分发可执行文件 | /MT | 无需用户安装 VC++ 运行库 |
| 插件/DLL | /MD | 与主程序共享运行时 |
| 开发测试 | /MTd | 快速链接,独立调试 |
| 发布版 | /MT | 减少依赖,简化部署 |
常见问题
1. 运行时库不匹配
error LNK2005: "void * __cdecl operator new(unsigned int)"
(??2@YAPAXI@Z) 已经在 LIBCMT.lib 中定义
解决:统一所有项目的链接选项
2. DLL 和 EXE 之间的内存分配
// DLL 中分配
void* AllocMem() {
return malloc(100); // 使用 MSVCRxx.DLL
}
// EXE 中释放(可能使用不同运行时库)
void FreeMem(void* p) {
free(p); // 可能崩溃!
}
解决:使用统一运行时库,或提供导出函数让 DLL 负责释放
3. 调试版本 vs 发布版本混用
MSVCR100D.dll (调试运行时) != MSVCR100.dll (发布运行时)
解决:确保加载的 DLL 与主程序使用相同的运行时库
项目配置位置
Visual Studio 中设置位置:
- 项目属性 → C/C++ → 代码生成 → 运行时库
或通过命令行:
/MD /MDd /MT /MTd