0%

C++技术深入学习的一次总结

文档背景

一次技术沟通中,发现与我做不同项目的同行,在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(代码方式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <windows.h>
#include <dbghelp.h>
#include <iostream>
#include <ShlObj.h>

#pragma comment(lib, "dbghelp.lib")

LONG WINAPI UnhandledExceptionHandler(EXCEPTION_POINTERS* pExceptionInfo) {
// 获取文档路径(SHGetFolderPath 已过时,新代码推荐 SHGetKnownFolderPath)
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;
}

注意:

  • EXCEPTION_EXECUTE_HANDLER 表示异常已被处理。对于空指针访问、栈溢出等不可恢复异常,继续执行会导致未定义行为。
  • 实际工程中,写完 dump 后应主动终止进程,例如调用 FatalAppExit(0, L"Application crashed")ExitProcess(1)
  • 如果希望保留系统默认的崩溃处理(如 WER),可以在写完 dump 后返回 EXCEPTION_CONTINUE_SEARCH

方法二:使用 Windows Error Reporting(WER)

无需代码,系统自带。崩溃时自动收集,保存在:

1
C:\ProgramData\Microsoft\Windows\WER\ReportQueue\

启用方法:注册表 HKLM\Software\Microsoft\Windows\Windows Error Reporting\LocalDumps

多进程崩溃收集

多进程场景(如主进程+子进程)可以由父进程作为调试器监控子进程,但更常见的工程实践是让子进程自己安装异常过滤函数生成 dump,父进程只负责监控子进程是否异常退出。

方案一:子进程自收集(推荐)

每个子进程在启动时调用 SetUnhandledExceptionFilter,崩溃时自己写 dump。父进程通过 WaitForSingleObject 监控子进程句柄,根据退出码判断是否正常退出。

1
2
// 子进程 main 中安装过滤函数(同单进程方案)
SetUnhandledExceptionFilter(UnhandledExceptionHandler);

方案二:父进程作为调试器监控

父进程使用 CreateProcess 配合 DEBUG_PROCESS 标志附加到子进程。收到 EXCEPTION_DEBUG_EVENT 时写 dump,收到 EXIT_PROCESS_DEBUG_EVENT 时退出监控循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <windows.h>
#include <dbghelp.h>
#include <string>

#pragma comment(lib, "dbghelp.lib")

void WriteMiniDumpForProcess(HANDLE hProcess, DWORD processId) {
std::string dumpPath = "child_dump_" + std::to_string(processId) + ".dmp";
HANDLE hFile = CreateFileA(dumpPath.c_str(), GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) return;

// 跨进程写入 dump 时,ExceptionPointers 指向目标进程地址空间,
// 无法在当前进程直接解析,所以传 NULL,保存进程快照。
MiniDumpWriteDump(hProcess, processId, hFile, MiniDumpNormal,
NULL, NULL, NULL);
CloseHandle(hFile);
}

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;
BOOL running = TRUE;
while (running && WaitForDebugEvent(&de, INFINITE)) {
DWORD continueStatus = DBG_CONTINUE;

switch (de.dwDebugEventCode) {
case EXCEPTION_DEBUG_EVENT:
// 忽略启动时系统自动发送的初始断点
if (de.u.Exception.ExceptionRecord.ExceptionCode != EXCEPTION_BREAKPOINT) {
WriteMiniDumpForProcess(pi.hProcess, de.dwProcessId);
continueStatus = DBG_EXCEPTION_NOT_HANDLED; // 让子进程按正常崩溃流程终止
running = FALSE;
}
break;
case EXIT_PROCESS_DEBUG_EVENT:
running = FALSE;
break;
}

ContinueDebugEvent(de.dwProcessId, de.dwThreadId, continueStatus);
}

CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
}

说明: 父进程作为调试器会独占子进程的调试端口,子进程自己无法再使用 SetUnhandledExceptionFilter。实际工程中方案一(子进程自收集)更灵活、dump 信息也更完整。

实际应用建议

场景 推荐方案 说明
客户端应用 SetUnhandledExceptionFilter 用户体验好,可自定义保存路径
服务端/守护进程 父进程监控 子进程崩溃不影响主进程
生产环境 WER + 日志 系统级别,无需额外代码

1.2 函数调用约定是指什么?

基本概念

调用约定(Calling Convention)是函数调用时的一套规则,包括:

  1. 参数传递顺序(从左到右 vs 从右到左)
  2. 栈清理责任(调用方 vs 被调方)
  3. 名字修饰规则(编译器内部标识)
  4. this 指针传递方式(C++ 成员函数)

常见调用约定

调用约定 参数传递 栈清理 名字修饰 备注
__cdecl 右→左压栈 调用方 _functionName C/C++ 32位默认
__stdcall 右→左压栈 被调方 _functionName@N Windows API
__fastcall ECX, EDX → 栈 被调方 @functionName@N 仅 x86
__thiscall ECX + 栈 被调方 C++ 成员函数修饰(如 ?method@Class@@QAEXXZ x86 成员函数默认,不可显式指定
__vectorcall XMM/YMM 寄存器 + 栈 被调方 与 fastcall/thiscall 类似 x86/x64 SIMD 优化

注意: x64(AMD64)架构下,Windows 只使用一种统一的 Microsoft x64 calling convention__cdecl/__stdcall/__fastcall 等关键字在 x64 上会被忽略;GCC/Clang 在 x64 Linux/macOS 上遵循 System V AMD64 ABI

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 显式指定调用约定
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 导出函数如果约定不匹配,会导致栈错误

1
2
3
4
5
6
7
8
9
// 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 信号槽默认是同步的(直接调用),跨线程时通过事件队列异步传递。信号发送时,参数会被拷贝到事件队列,由目标线程的事件循环处理。

传递方式

1
2
3
4
5
6
7
8
9
10
11
// 方式一:直接传递(栈上的局部变量需要注意生命周期)
emit mySignal(someData); // 如果是值类型,会自动拷贝

// 方式二:使用指针(需要手动管理生命周期)
emit mySignal(&someData); // 危险!对象可能在接收前被销毁

// 方式三:使用 QSharedPointer(推荐)
emit mySignal(QSharedPointer<MyData>::create(args));

// 方式四:Qt 5.7+ 使用 Qt::QueuedConnection 显式跨线程
emit mySignal(args); // 自动处理

跨线程传递示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// MyData.h
class MyData {
public:
int id;
QString name;
QVector<double> values; // 可以传递复杂数据结构
};

// 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 自动处理线程切换

重要: 自定义普通结构体/类不需要继承 QObject,但必须注册到 Qt 元对象系统才能跨线程传递。

1
2
3
4
5
6
7
8
9
10
11
12
// 在头文件中声明可拷贝的类型
struct MyData {
int id;
QString name;
QVector<double> values;
};

// 在头文件末尾或 .cpp 中注册
Q_DECLARE_METATYPE(MyData);

// 在 main() 或 connect 之前注册(跨线程 QueuedConnection 必需)
qRegisterMetaType<MyData>("MyData");

1.4 怎么加载不同路径下的 dll?

方法一:LoadLibrary 显式加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#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(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <windows.h>

int main() {
// 方法1:设置默认搜索路径(替换系统搜索路径)
SetDllDirectoryA("C:\\mylib\\dlls");

// 方法2:添加到搜索路径列表开头(推荐,保留系统路径)
// 需要先用 SetDefaultDllDirectories 启用用户目录搜索
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS |
LOAD_LIBRARY_SEARCH_USER_DIRS);
AddDllDirectory(L"C:\\mylib\\custom_dlls");
AddDllDirectory(L"C:\\program files\\myapp\\plugins");

// 之后 LoadLibrary 会先搜索这些路径
HMODULE h = LoadLibraryA("mydll.dll"); // 自动找到

return 0;
}

注意: AddDllDirectory 添加的路径默认不会生效,必须:

  1. 进程级调用 SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_USER_DIRS)(Windows 8+);或
  2. LoadLibraryEx 中显式指定 LOAD_LIBRARY_SEARCH_USER_DIRS 标志(Windows 7+ 推荐这种方式)。

Windows 7 示例:

1
2
DLL_DIRECTORY_COOKIE cookie = AddDllDirectory(L"C:\\mylib\\plugins");
HMODULE h = LoadLibraryExW(L"mydll.dll", NULL, LOAD_LIBRARY_SEARCH_USER_DIRS);

方法三:修改可执行文件目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#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。

1
2
3
4
5
6
// 仍需要在链接时提供 mydll.lib,并链接 delayimp.lib
#pragma comment(lib, "delayimp.lib")
#pragma comment(linker, "/delayload:mydll.dll")

// 使用方式一样,但首次调用时才加载 DLL
int result = Add(1, 2); // 首次调用时才触发 LoadLibrary

说明: 延迟加载不是不需要 .lib,而是不需要在运行初期就加载 DLL;链接时仍然需要 DLL 对应的导入库 .libdelayimp.lib

实际应用对比

方法 优点 缺点 注意
绝对路径 明确 不灵活,迁移麻烦 需自行处理路径
SetDllDirectory 简单全局生效 会替换默认 DLL 搜索顺序 不需要管理员权限
AddDllDirectory 安全,推荐 Windows 7+ 需配合 SetDefaultDllDirectoriesLoadLibraryEx 标志
延迟加载 开发简单,按需加载 运行时才报错 仍需要 DLL 的导入库 .libdelayimp.lib
环境变量 用户可配置 需设置环境变量 影响整个进程

1.5 this 指针是怎么传递的?

基本原理

this 指针是成员函数的隐含参数,在 C++ 中通常通过寄存器或栈传递。

不同调用约定的传递方式

1
2
3
4
5
6
7
8
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位 示例

1
2
3
4
5
6
7
class Calculator {
public:
int add(int a, int b) { return m_value + a + b; }

private:
int m_value;
};

32 位 MSVC(__thiscall)伪汇编示意:

1
2
3
4
5
; this 通过 ECX 传递,参数从右到左压栈
mov ecx, [this] ; this 指针放入 ECX
push b
push a
call Calculator::add ; 被调方清理栈(ret 8)

64 位 MSVC 伪汇编示意:

1
2
3
4
5
6
; this 作为第一个整型参数通过 RCX 传递
mov rcx, [this] ; this 指针放入 RCX
mov edx, a ; 第二个参数 RDX
mov r8d, b ; 第三个参数 R8
; 调用者负责分配 32 字节 shadow space
; 调用者负责清理栈

虚函数的 this 调整

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
virtual void vfunc() {}
};

class Derived : public Base {
public:
void vfunc() override {}
};

Base* p = new Derived();
// p 可能是指向 Derived 对象的 Base 子对象的指针
// 虚函数调用需要先调整 this 指针到完整的 Derived 对象

编译器实际生成的调用流程可简化理解为:

1
2
3
4
5
6
7
8
// 原始代码
p->vfunc();

// 伪代码示意
using VTable = void**; // 虚表是指针数组
VTable vtable = *(VTable*)p; // 从对象首部取虚表指针
void (*func)(void*) = (void(*)(void*))vtable[0]; // 取第 0 个虚函数地址
func(p); // 调用,p 即调整后的 this

注意: 多重继承时,this 指针可能需要根据基类子对象偏移进行调整,编译器会在虚表中插入调整 thunk(thunk)来完成。

实际应用

问题: 回调函数如何获取 this?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 错误的做法:普通函数没有 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位 Windows (LLP64) 64位 Linux/macOS (LP64) 变化?
char 1 1 1 不变
short 2 2 2 不变
int 4 4 4 不变
long 4 4 8 平台相关
long long 8 8 8 不变
float 4 4 4 不变
double 8 8 8 不变
pointer 4 8 8 变化
size_t 4 8 8 变化
ptrdiff_t 4 8 8 变化

变化的根本原因

  1. 指针变为 8 字节:为了支持 64 位地址空间(2^64 = 16EB 寻址能力)
  2. long 变为 8 字节(仅 Windows):Windows 采用 LLP64 模型,long 保持 4 字节;Linux/macOS 采用 LP64 模型,long 变为 8 字节

LP64 vs LLP64 模型

1
2
3
4
5
6
7
8
LP64 (Linux, macOS, BSD):
- long (Long) = 64-bit
- pointer = 64-bit

LLP64 (Windows):
- long long = 64-bit
- long = 32-bit (保持兼容)
- pointer = 64-bit

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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. 跨平台 int64_t 格式化(应包含 <cinttypes>,不要自己定义 PRId64)
#include <cinttypes>
int64_t value = 123456789;
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 的 C/C++ 运行时库(CRT)链接选项,命名规则如下:

  • 首字母 M = Multithreaded(多线程版本,现代程序都是这个)。
  • 第二个字母表示链接方式:
    • D = Dynamic,动态链接到 CRT DLL;
    • T = 使用静态 LIB 版本的 CRT。
  • 末尾小写 d = Debug 版本(带调试堆、额外检查)。

因此四个常见选项是:

  • /MD:Release + 动态链接 CRT。
  • /MDd:Debug + 动态链接 CRT。
  • /MT:Release + 静态链接 CRT。
  • /MTd:Debug + 静态链接 CRT。

选项对照表

编译选项 链接方式 典型运行时库 宏定义 特点
/MD 动态链接 vcruntime140.dllucrtbase.dllmsvcp140.dll _MT, _DLL 体积小,依赖 VC++ Redistributable
/MT 静态链接 libcmt.liblibvcruntime.liblibucrt.lib _MT 体积大,无外部 DLL 依赖
/MDd 动态链接(调试) vcruntime140d.dllucrtbased.dllmsvcp140d.dll _MT, _DLL, _DEBUG 调试版本
/MTd 静态链接(调试) libcmtd.liblibvcruntimed.liblibucrtd.lib _MT, _DEBUG 调试版本,静态链接

注意: VS2015 之后,CRT 被拆分为 vcruntime(VC 运行时)、ucrt(通用 C 运行时)和 msvcp(C++ 标准库)等部分;旧版(VS2015 之前)通常统称为 MSVCRxx.DLL

代码示例与影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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;
}

实际选择建议

场景 推荐选项 原因
普通客户端应用 /MD 体积小,多个模块共享 CRT 堆,便于统一更新安全补丁
独立工具/单文件分发 /MT 无需用户安装 VC++ Redistributable
插件/DLL 与宿主程序一致 必须统一,否则跨模块 malloc/free 会崩溃
开发调试 /MDd 与发布版 /MD 运行时一致,避免库混用问题
发布版 /MD 或 /MT /MD 更主流;/MT 适合对依赖敏感的场景

关键原则: 同一个进程内的所有模块(EXE + 全部 DLL)应使用相同的运行时库选项,尤其是不要混用 /MD 和 /MT。

常见问题

1. 运行时库不匹配

1
2
error LNK2005: "void * __cdecl operator new(unsigned int)" 
(??2@YAPAXI@Z) 已经在 LIBCMT.lib 中定义

解决:统一所有项目的链接选项

2. DLL 和 EXE 之间的内存分配

1
2
3
4
5
6
7
8
9
// DLL 中分配
void* AllocMem() {
return malloc(100); // 使用 MSVCRxx.DLL
}

// EXE 中释放(可能使用不同运行时库)
void FreeMem(void* p) {
free(p); // 可能崩溃!
}

解决:使用统一运行时库,或提供导出函数让 DLL 负责释放

3. 调试版本 vs 发布版本混用

1
MSVCR100D.dll (调试运行时) != MSVCR100.dll (发布运行时)

解决:确保加载的 DLL 与主程序使用相同的运行时库

项目配置位置

Visual Studio 中设置位置:

  • 项目属性C/C++代码生成运行时库

或通过命令行:

1
/MD  /MDd  /MT  /MTd