运行库相关深入学习

关于运行库, 你可以仅仅理解静态库和动态库的原理, 也许够用, 但是如果深入进去的话, 大有可以说道的地方, 在我的认知中, 这不算是什么实用知识, 但是算一种”技术底蕴”吧.

CRT(C/C++ Runtime Library)

总所周知, 程序运行不只依靠自己的代码, 更依靠各种标准库.

你可以认为CRT就是C/C++运行库, 是环境, 是C标准库, 是C++标准库, 是Windows.h等等, 一定要把CRT和各种第三方动静态库区分开.

而CRT是可以选择链接方式的, 也就是说你可以使用静态库版本的CRT, 那就是**/MT**, 也可以使用动态库版本的CRT, 那就是**/MD**, 至于/MTd和/MDd是Debug版本的就不多说了.

我们可以在VS项目属性中设置运行库选项来配置链接方式, 不同的选择方式当然就对应了动静态库的优劣了.

动静态库对于CRT链接方式的正交选择

在我们的认知中, 动态库可能适合/MD, 静态库可能适合/MT,实际上也确实适合, 但是其实本身这种选择是正交的, 动静态库都可以选择/MD和/MT, 毕竟说到底这也只是在选择使用CRT的动静态罢了.

  • dll + /MT为例, 这种dll就会把一套静态的CRT放到自己的代码内部, 而自己dll内部的代码就会优先使用这套CRT, 可以做到自给自足, 并且也防止了CRT版本差异导致的冲突. 也是就是说, 当你不想依赖外部环境, 想让依赖高度内部化时, 这种选择是可行的.

EXE项目对于CRT链接方式的选择

这里有两种情况 :

  • 如果没有依赖其他第三方库, 有大家都有对应环境就用/MD, 想高度集成做绿色版就用/MT.
  • 如果有, 那么还有两种情况 :
    • 如果依赖项中存在静态库, 那么必须和静态库的链接方式相同. 毕竟如果静态库是/MT, CRT代码本来就有一份, 你设置/MD, 还要去找一个共享的CRTdll, 就必定会发生重复定义, 编都编不过.
    • 如果依赖项中只有动态库, 也最好和动态库的链接方式相同.但是存在一个例外, 如果动态库是/MT, 只要双方各自只管理自己的内存, 也就是说, 动态库接口中输出的内容是原始类型或POD的话, exe怎么选都可以, 因此和上面想表达的一样, 设计好接口的/MT动态库也是一种降低各种依赖风险, 实现高内聚的手段.

由链接方式不同导致堆管理产生的崩溃冲突

这个议题看起来很复杂, 并且各种解释也非常多, 但是从最基础的底层分析我认为最好.

众所周知, C/C++最核心的部分是内存管理, 而且是堆内存管理, 栈和全局根本不需要我们管, 而堆内存的管理十分自由, 可以是C风格手动申请手动释放, 也可以是C++风格的构造析构与RAII.

但这一切的一切的前提, 你需要知道是用什么申请释放/构造析构的, 那就是CRT里对应的代码.

那么在这个前提下, 我们假设一个最可能的情景, exe(/MD) + dll(/MT) :

  • 我们假设要使用malloc, 那么会用到msvcr140.
  • 我们还假设, dll中的函数会使用malloc申请内存, 然后把申请好内存的指针传出给exe, 然后exe使用完后手动释放. 这样看起来没什么问题, 有申请就有释放, 很合理, 但要结合我们上面的CRT来分析.
  • dll选择的/MT, 那么其内部本身就有一套msvcr140.lib的代码, 其直接调用里面的malloc去堆上申请内存并记录各种申请情况.
  • exe选择的/MD, 那么其会使用一个共享的msvcr140.dll, 这里就可以很明显发现问题了, 如果exe去用msvcr140.dll上的free释放dll内部msvcr140.lib申请的内存, msvcr140.dll就会发现自己根本没申请过这块内存, 你却让我free, 这般强人所难, 那就只好崩溃了.

至此我们应可以比较清楚地感知到由链接方式不同导致堆管理产生的崩溃冲突了.

那么我们也应该可以理解前面说的设计好接口的/MT动态库是什么意思了, 本质就是不要导出内存管理相关的内容, 只导出原始类型或POD.

胖动态库

这算是一种相对实用的动态库设计方式, 先不介绍, 先看一眼整体结构 :

1
2
3
4
5
6
7
8
9
10
11
[xxxxxx.exe] (链接方式任意)
|
| 链接
|
[你的动态库.dll] (/MT)
|
| 链接
|
[第三方库A.lib]
[第三方库B.lib] (/MT)
[第三方库C.lib]

这本质其实算是一种静态库的聚合, 但dll作为最后的一层中间层, 可以让最后呈现出的效果可以既有静态库的高内聚, 也有动态库的共享与接口导出等优势, 虽然你可以认为这样体积会很大(因此被称为胖动态库), 但这确实也是一种折中的方案.

反观其他情况 :

  • 假如你都用静态库, 最后体积会更大, 并且如果你这个库通用性比较强, 不能共享的劣势是很大的.
  • 假如你都用动态库, 那么总所周知会有DLL HELL的老大难问题, 其本质是因为不同动态库要求的CRT版本可能不同, 由于兼容性或覆盖的关系会导致崩溃, 这里不再细讲, 但这里胖动态库只有最后一层使用dll, 可以很好的解决这个问题.

C++动态库导出纯C接口

这算是前面崩溃问题的话题延续, 有了前面例子的铺垫, 这个习惯就很好理解了.

  • 假设动态库为在导出的接口中使用了std::string&(这种情况很场景), 这里假设是参数.

    1
    void ModifyString(std::string& str);

那么这种情况存在两层风险:

  • 第一层是C++标准库版本不同, 如果exe使用MSVC2022, 而dll使用MSVC2019, exe用2022构造出了std::string传给dll, dll用2019解析, 由于C++标准库的实现经常不向前兼容, 直接崩溃是非常常见的.

  • 第二层是前面说的崩溃情况, 就算是C++标准库版本相同, 假设dll是**/MT**的, 那么其自己会维护一套CRT代码, exe用别的CRT构造出了std::string传给dll, 分析之类的也许可以, 但是一旦dll向重新分配string的内存, 做出类似如下的操作 :

    1
    str += "a very long string...";

    那么dll中的C++标准库就会发现其不是自己申请的, 就不可能会有, 故而崩溃.

至此我们可以发现将C++标准库中内容作为导出接口的参数或返回值都是极其危险的.

而解决方式就是导出纯C接口, 并且只使用原始类型或POD, 这样上面的两个问题就不复存在了.

下面是例子 :

1
2
3
4
// dll接口
// 纯C接口,不涉及任何C++对象
extern "C" __declspec(dllexport) const char* get_message();
extern "C" __declspec(dllexport) void free_message(const char* msg);
1
2
3
4
5
6
7
8
9
10
11
// dll实现
// 内部仍可使用C++
extern "C" const char* get_message() {
std::string* msg = new std::string("Hello World"); // 在DLL内分配
return msg->c_str();
}
extern "C" void free_message(const char* msg) {
// 在DLL内释放,确保分配和释放在同一侧
// 需要一些技巧来获取并删除对应的std::string对象
// 例如:delete reinterpret_cast<std::string*>(const_cast<char*>(msg - offset));
}
1
2
3
4
5
// exe调用
// 即使是C++程序,也只处理C风格字符串
const char* message = get_message();
std::cout << message << std::endl;
free_message(message); // 调用DLL提供的释放函数

这里有一个非常有趣的事情是, 我上个月看一个佬直播用C++写MAA的框架库, 当时他就正在给自己的动态库导出纯C接口, 然后我看评论区有人在嘲讽用纯C接口干什么, 直接用C++就行了, 我当时细想也没想通为什么非要写纯C接口, 结果现在茅塞顿开之后, 只觉得很酷, 也许这就是我一开头说的”技术底蕴”吧.

by 天目中云


运行库相关深入学习
http://example.com/2025/11/16/运行库相关深入学习/
作者
天目中云
发布于
2025年11月16日
许可协议