静态库与动态库实操
需要预先学习:
1. 准备工作
这篇只用手写命令,不涉及 CMake、Meson、Bazel 这类构建系统。目标是把"每一步发生了什么"看清楚,而不是记住某个构建工具的配置写法。
1.1 示例工程
准备一个最小工程,目录结构如下:
project/
├── include/
│ └── add.h
├── src/
│ ├── add.cpp
│ └── main.cpp
└── build/
include/add.h:
#pragma once
int add(int a, int b);
int sub(int a, int b);
src/add.cpp:
#include "add.h"
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a - b;
}
src/main.cpp:
#include <cstdio>
#include "add.h"
int main()
{
std::printf("add(2, 3) = %d\n", add(2, 3));
std::printf("sub(2, 3) = %d\n", sub(2, 3));
return 0;
}
先确认工具链:
g++ --version
ar --version
ld --version
在 Windows 上确认 MSVC:
cl
lib
link
dumpbin
如果这些命令找不到,需要打开 “Developer Command Prompt for VS”,或者先运行 vcvars64.bat。
注:后面的命令默认在工程根目录
project/下执行。Linux 用/分隔路径,Windows 用\;MSVC 的命令行参数用/开头,路径用\更稳妥。两种写法不要混。
2. GCC / Linux 实操
2.1 静态库:从 .o 到 .a
编译目标文件
g++ -O2 -Wall -Wextra -Iinclude -c src/add.cpp -o build/add.o
参数说明:
| 参数 | 含义 |
|---|---|
-O2 |
优化级别。库的优化选项会影响 ABI 吗?通常不会,但 -fno-exceptions、-fno-rtti、-D_GLIBCXX_USE_CXX11_ABI 会影响 |
-Wall -Wextra |
打开常用警告,早发现接口问题 |
-Iinclude |
头文件搜索路径 |
-c |
只编译,不链接 |
-o build/add.o |
输出目标文件 |
检查目标文件:
file build/add.o
nm -C build/add.o
readelf -h build/add.o | head
nm -C 应该能看到:
0000000000000000 T add(int, int)
0000000000000020 T sub(int, int)
符号是全局的 T,说明其他翻译单元可以引用。
打包成静态库
ar rcs build/libadd.a build/add.o
ar 的命名规则是 lib + 库名 + .a。链接时用 -ladd,链接器会自动补上 lib 和 .a。
检查归档:
ar t build/libadd.a # 列出成员
nm -s build/libadd.a # 查看符号索引
size build/add.o # 查看节区大小
输出类似:
$ ar t build/libadd.a
add.o
$ nm -s build/libadd.a
Archive index:
_Z3subii in add.o
_Z3addii in add.o
add.o:
0000000000000000 T _Z3addii
0000000000000020 T _Z3subii
链接静态库
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_static
参数说明:
| 参数 | 含义 |
|---|---|
src/main.cpp |
先编译成 main.o,再参与链接 |
-Lbuild |
库搜索路径 |
-ladd |
链接 libadd.a 或 libadd.so |
-o build/app_static |
输出可执行文件 |
运行:
./build/app_static
add(2, 3) = 5
sub(2, 3) = -1
检查链接结果:
ldd build/app_static
nm -C build/app_static | grep -E 'add|sub'
ldd 里不应该出现 libadd,因为代码已经复制进可执行文件。nm 里能看到 add 和 sub 的本地定义:
0000000000001169 T add(int, int)
0000000000001181 T sub(int, int)
静态库与动态库同名时的选择
如果 build/ 下同时有 libadd.a 和 libadd.so,-ladd 默认选 .so。想强制静态链接,有两种写法:
# 写法一:在命令行上切换 Bstatic/Bdynamic
g++ -O2 -Iinclude src/main.cpp -Lbuild -Wl,-Bstatic -ladd -Wl,-Bdynamic -o build/app_static
# 写法二:直接指定文件名
g++ -O2 -Iinclude src/main.cpp build/libadd.a -o build/app_static
第一种写法里,-Wl,-Bstatic 之后的 -l 都按静态库找,直到 -Wl,-Bdynamic 恢复。注意 libc、libstdc++ 等系统库也要能静态找到,否则会报 cannot find -lstdc++ 之类。
第二种写法最直接,不依赖搜索顺序,适合脚本里固定链接某个文件。
完全静态链接
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -static -o build/app_static_full
-static 会把 libc、libstdc++ 也静态链接进来。检查:
ldd build/app_static_full
not a dynamic executable
体积会明显变大:
ls -lh build/app_static build/app_static_full
-rwxr-xr-x 1 user user 17K build/app_static
-rwxr-xr-x 1 user user 2.2M build/app_static_full
完全静态链接的注意事项在前一篇讲过:getaddrinfo、dlopen、NSS 等功能会退化,glibc 的许可证也要确认。
只静态链接 C++ 运行时
更常见的折中是:
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
-static-libgcc -static-libstdc++ -o build/app_static_runtime
这样只把 libgcc 和 libstdc++ 静态链进来,libc 仍然是动态的,部署时不用带 libstdc++.so.6。
2.2 动态库:从 .o 到 .so
编译位置无关代码
g++ -O2 -Wall -Wextra -fPIC -Iinclude -c src/add.cpp -o build/add_pic.o
-fPIC 是必须的。忘记加的报错:
/usr/bin/ld: build/add.o: relocation R_X86_64_32S against `.rodata' can not be used when making a shared object; recompile with -fPIC
-fPIC 和 -fpic 在 x86-64 上基本等价。嵌入式平台(ARM、MIPS)上 -fpic 的 GOT 更小,但有 32KB 限制,库大了要用 -fPIC。
生成动态库
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add_pic.o
参数说明:
| 参数 | 含义 |
|---|---|
-shared |
生成共享库 |
-Wl,-soname,libadd.so.1 |
把 soname 写进 .dynamic;-Wl, 表示把后面的参数传给链接器 |
-o libadd.so.1.0.0 |
真实文件名,带完整版本号 |
检查:
readelf -d build/libadd.so.1.0.0 | grep -E 'SONAME|NEEDED'
0x0000000000000001 (NEEDED) Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000e (SONAME) Library soname: [libadd.so.1]
SONAME 是 libadd.so.1,将来链接这个库的程序,DT_NEEDED 里记录的就是它。
创建链接名
链接器用 -ladd 找的是 libadd.so,不是 libadd.so.1。所以要建两个符号链接:
ln -sf libadd.so.1.0.0 build/libadd.so.1
ln -sf libadd.so.1 build/libadd.so
目录结构:
build/
├── libadd.so -> libadd.so.1
├── libadd.so.1 -> libadd.so.1.0.0
└── libadd.so.1.0.0
libadd.so:链接名,编译时需要,运行时不需要libadd.so.1:soname,运行时需要libadd.so.1.0.0:真实文件
链接动态库
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_shared
检查记录的是 soname 而不是文件名:
readelf -d build/app_shared | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libadd.so.1]
0x0000000000000001 (NEEDED) Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
运行
直接运行会失败:
./build/app_shared
./build/app_shared: error while loading shared libraries: libadd.so.1: cannot open shared object file: No such file or directory
因为 build/ 不在默认搜索路径里。三种解决办法:
1. 临时设置 LD_LIBRARY_PATH:
LD_LIBRARY_PATH=build ./build/app_shared
2. 写入 rpath,把库目录固定在可执行文件里:
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
-Wl,-rpath,'$ORIGIN' -o build/app_shared
$ORIGIN 表示可执行文件所在目录。单引号必须加,否则 shell 会把 $ORIGIN 展开成空。
检查:
readelf -d build/app_shared | grep -E 'RPATH|RUNPATH'
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
3. 安装到系统目录并更新缓存:
sudo install -m 755 build/libadd.so.1.0.0 /usr/local/lib/
sudo ln -sf libadd.so.1.0.0 /usr/local/lib/libadd.so.1
sudo ln -sf libadd.so.1 /usr/local/lib/libadd.so
sudo ldconfig
然后直接运行:
./build/app_shared
soname 与版本升级
假设你要发布 1.1.0,只是修了 bug,ABI 没变:
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.1.0 build/add_pic.o
ln -sf libadd.so.1.1.0 build/libadd.so.1
老程序不需要重新链接,因为 DT_NEEDED 里还是 libadd.so.1。
如果改了 ABI,比如删了一个导出函数、改了参数类型:
g++ -shared -Wl,-soname,libadd.so.2 -o build/libadd.so.2.0.0 build/add_pic.o
ln -sf libadd.so.2.0.0 build/libadd.so.2
新程序链接 libadd.so.2,老程序继续用 libadd.so.1,两个版本可以并存。
注:soname 只在你链接可执行文件时被读入
DT_NEEDED。如果库没有设置 soname,DT_NEEDED记录的是你链接时给的文件名(可能是libadd.so),升级时容易出问题。生产库一律设置 soname。
检查依赖树
ldd build/app_shared
linux-vdso.so.1 (0x00007ffd...)
libadd.so.1 => /home/user/project/build/libadd.so.1 (0x00007f...)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
ldd 会递归列出所有依赖。注意 ldd 实际上会运行动态加载器,对不可信的可执行文件有风险;更安全的做法是:
objdump -p build/app_shared | grep NEEDED
readelf -d build/app_shared | grep NEEDED
查看加载器实际搜索了哪些路径:
LD_DEBUG=libs ./build/app_shared
输出会列出每次 search path、trying file、calling init,排查"为什么加载了错误的库"非常有用。
pkg-config
自己写 -I、-L、-l 很容易漏。pkg-config 把编译和链接参数记录在 .pc 文件里,使用方一行命令就能拿到:
pkg-config --cflags --libs add
一个最小的 add.pc:
prefix=/usr/local
libdir=${prefix}/lib
includedir=${prefix}/include
Name: add
Description: A tiny add/sub library
Version: 1.0.0
Libs: -L${libdir} -ladd
Libs.private: -lm
Cflags: -I${includedir}
安装到 $(pkg-config --variable pc_path pkg-config) 包含的目录,或者设置:
export PKG_CONFIG_PATH=/path/to/pc:$PKG_CONFIG_PATH
Libs.private 是静态链接时才需要的额外依赖,pkg-config --static --libs add 会把它加进来。
2.3 C++ 接口的导出
名称修饰
C++ 编译器会把函数名、参数类型、命名空间编码进符号名,这叫名称修饰(name mangling)。GCC 的规则是 Itanium ABI:
int add(int a, int b); // _Z3addii
double add(double a, double b); // _Z3adddd
namespace math { int add(int, int); } // _ZN4math3addEii
class Calc { public: int add(int, int); } // _ZN4Calc3addEii
用 nm 看:
nm -C build/add.o
nm build/add.o
-C 会把修饰名还原成可读形式。链接器只看修饰后的名字,所以:
- C++ 调用 C 库:需要
extern "C",关闭修饰 - C 调用 C++ 库:需要导出一个
extern "C"的包装函数 - 不同编译器之间:修饰规则不同,C++ 符号基本不能互操作
extern "C" 包装
把 C++ 实现包在 extern "C" 里:
// add.cpp
#include "add.h"
namespace detail
{
int add_impl(int a, int b)
{
return a + b;
}
}
extern "C" int add(int a, int b)
{
return detail::add_impl(a, b);
}
头文件也要一致:
// add.h
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);
#ifdef __cplusplus
}
#endif
编译后符号是 add,没有修饰:
nm build/add.o | grep add
0000000000000000 T add
这样 C 程序、Python 的 ctypes、C# 的 DllImport、Rust 的 extern "C" 都能调用。
可见性控制
下面假设 add.cpp 已经按上一节改成 extern "C" 导出。如果保持 C++ 链接,导出符号会是 _Z3addii 这类修饰名,nm -D 的输出也会不一样。
Linux 的 ELF 默认把所有全局符号放进 .dynsym。一个 C++ 库可能导出几千个符号,包括模板实例、内联函数、内部辅助函数。这会带来:
.dynsym变大,加载变慢- 符号冲突概率变高
- 内部实现被外部看到,ABI 表面积失控
用 -fvisibility=hidden 把默认可见性改成隐藏,再给公开 API 加属性:
// add.h
#pragma once
#if defined(_WIN32)
#ifdef ADD_EXPORTS
#define ADD_API __declspec(dllexport)
#else
#define ADD_API __declspec(dllimport)
#endif
#else
#define ADD_API __attribute__((visibility("default")))
#endif
#ifdef __cplusplus
extern "C" {
#endif
ADD_API int add(int a, int b);
ADD_API int sub(int a, int b);
#ifdef __cplusplus
}
#endif
编译:
g++ -O2 -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -Iinclude \
-c src/add.cpp -o build/add_hidden.o
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add_hidden.o
检查导出符号:
nm -D --defined-only build/libadd.so.1.0.0 | c++filt
0000000000001109 T add
000000000000111b T sub
只剩两个公开函数。-fvisibility-inlines-hidden 会隐藏内联函数,减少模板实例的导出。
注:
-fvisibility=hidden也会隐藏异常类的type_info。如果异常要跨 DSO 传播,异常类必须显式标记为default可见性,否则catch可能匹配不上。
版本脚本
可见性属性只能控制"默认可见/隐藏",不能给符号打版本。要给符号打版本、同时隐藏其余符号,用版本脚本(version script)。版本脚本里的名字是修饰后的符号名;下面的例子假设 add.cpp 已用 extern "C" 导出。
# build/libadd.map
LIBADD_1.0 {
global:
add;
sub;
local:
*;
};
链接:
# 用默认可见性编译,导出哪些符号交给版本脚本决定
g++ -O2 -fPIC -Iinclude -c src/add.cpp -o build/add_capi.o
g++ -shared -Wl,-soname,libadd.so.1 \
-Wl,--version-script=build/libadd.map \
-o build/libadd.so.1.0.0 build/add_capi.o
检查:
readelf --dyn-syms -W build/libadd.so.1.0.0 | grep -E 'add|sub'
6: 0000000000001109 18 FUNC GLOBAL DEFAULT 12 add@@LIBADD_1.0
7: 000000000000111b 18 FUNC GLOBAL DEFAULT 12 sub@@LIBADD_1.0
@@ 表示默认版本。老程序如果链接的是 add@LIBADD_0.9,升级后仍然能绑定旧版本(前提是库里同时保留旧版本实现)。
版本脚本还有一个副作用:local: *; 会把所有没列出的符号藏起来,包括编译器生成的辅助符号。这正是我们想要的,但要确认没有被 dlsym 使用的符号被误藏。
导出 C++ 类的风险
直接导出 C++ 类,等于把下面这些东西都变成 ABI:
- 类的内存布局(成员顺序、对齐、虚表指针位置)
- 虚函数的顺序和数量
- 内联函数的实现
- 基类、虚继承、空基类优化
- 异常规范和
noexcept - 成员函数的调用约定
- 标准库成员的类型(
std::string的 ABI 可能随编译器版本变化)
只要其中任何一项变了,老二进制就可能崩溃。跨编译器(GCC 和 Clang 的 C++ ABI 大体兼容,但和 MSVC 完全不兼容)更是不要想。
如果必须导出类,至少做到:
- 用 Pimpl 把实现藏起来,头文件只暴露指针
- 所有成员函数非内联,定义放在
.cpp里 - 不在接口里出现 STL 类型
- 用工厂函数创建对象,用虚析构或
destroy()释放 - 固定编译器大版本和标准库 ABI
2.4 库的初始化与全局对象
库被加载时,除了映射代码和数据,还要执行初始化代码。初始化顺序出问题,往往表现为"单例是空的"“注册表少了一项”“程序在 main 之前就崩了”。
Linux 的构造函数
GCC 支持三种写法:
// 1. 属性写法
__attribute__((constructor))
void lib_init()
{
// 库加载时执行
}
__attribute__((destructor))
void lib_fini()
{
// 库卸载或进程退出时执行
}
// 2. 带优先级(0-100 保留给系统)
__attribute__((constructor(101)))
void early_init()
{
}
// 3. C++ 全局对象
struct Registry
{
Registry() { /* 构造 */ }
~Registry() { /* 析构 */ }
};
static Registry g_registry;
它们最终都会变成 .init_array / .fini_array 里的函数指针:
readelf -x .init_array build/libadd.so.1.0.0
Hex dump of section '.init_array':
0x00003dd0 70100000 00000000 80100000 00000000
两个 8 字节指针,指向两个初始化函数。加载器处理完重定位后,按数组顺序调用;.fini_array 按相反顺序调用。
静态库里的构造函数
静态库的成员只有在被引用时才会被链接进来。如果一个 .o 里只有一个全局对象、没有任何被引用的函数,它不会被拉进来,构造函数自然也不会执行:
// registry.cpp —— 只有全局对象,没有导出函数
struct AutoRegister
{
AutoRegister() { register_all(); }
};
static AutoRegister g_auto_register;
# 这样链接,registry.o 不会被拉进来
g++ main.o -lregistry -o app
# 强制拉入整个库
g++ main.o -Wl,--whole-archive -lregistry -Wl,--no-whole-archive -o app
更稳妥的做法是提供一个显式初始化函数,让主程序调用:
extern "C" void registry_init();
int main()
{
registry_init();
// ...
}
这样链接器能看到引用,成员一定会被拉进来,初始化时机也由你控制。
初始化顺序
C++ 标准只保证:
- 同一个翻译单元内,全局对象按定义顺序初始化
- 不同翻译单元之间,顺序未指定
静态链接时,实际顺序通常跟链接命令行上的目标文件顺序一致,但这不是标准保证的。动态链接时,顺序由依赖关系和加载顺序决定:
- 如果 A 依赖 B,B 先初始化
- 两个互不依赖的库,顺序取决于
DT_NEEDED顺序或dlopen顺序 - 循环依赖的顺序更不可预测
经典的静态初始化顺序问题(static initialization order fiasco):
// logger.cpp
Logger g_logger; // 全局对象,构造时机不确定
Logger& logger()
{
return g_logger;
}
// config.cpp
Config g_config(logger()); // 如果 g_logger 还没构造,这里拿到的是未初始化的对象
函数内静态对象(Meyers singleton)在 C++11 之后是线程安全且首次调用时构造的,能避开大部分顺序问题:
Logger& logger()
{
static Logger instance; // 第一次调用时构造,C++11 保证线程安全
return instance;
}
另一种做法是用 std::call_once 或显式的 init() 函数,把初始化时机从"加载时"推迟到"第一次使用"。
Windows 的初始化顺序
MSVC 的 CRT 在 DllMain 之前执行全局 C++ 对象的构造函数。顺序大致是:
- 加载器映射 DLL
- 应用基址重定位
- 解析导入表
- 调用 TLS 回调(
DLL_PROCESS_ATTACH) - CRT 初始化,执行全局构造函数
- 调用用户
DllMain(DLL_PROCESS_ATTACH)
多个 DLL 之间,依赖者先初始化被依赖者。没有依赖关系的 DLL,顺序由加载顺序决定,不要依赖它。
DLL_PROCESS_DETACH 的顺序与初始化相反:后加载的先卸载。进程退出时,lpReserved 非空,表示进程正在终止,此时不要做复杂的清理。
注:在全局构造函数里调用
exit()、创建线程、加载其他 DLL,都是高风险操作。静态库里的全局对象还可能因为成员没被拉取而根本不执行。初始化的第一原则是"简单",复杂逻辑放到显式的init()里。
2.5 运行时加载:dlopen
注:下面的例子假设动态库导出的是未修饰的
add。如果按前面的基础示例编译(C++ 链接),符号是_Z3addii,dlsym要传_Z3addii。可以用nm -D --defined-only build/libadd.so.1 | grep add确认真实符号名。
基本用法
// load.cpp
#include <dlfcn.h>
#include <cstdio>
int main()
{
void* handle = dlopen("libadd.so.1", RTLD_NOW | RTLD_LOCAL);
if (!handle)
{
std::fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
dlerror(); // 清除之前的错误状态
using AddFn = int (*)(int, int);
AddFn add = reinterpret_cast<AddFn>(dlsym(handle, "add"));
const char* error = dlerror();
if (error)
{
std::fprintf(stderr, "dlsym failed: %s\n", error);
dlclose(handle);
return 1;
}
std::printf("add(2, 3) = %d\n", add(2, 3));
dlclose(handle);
return 0;
}
编译:
g++ -O2 load.cpp -ldl -o build/load
运行时用 LD_LIBRARY_PATH 指定库目录:
LD_LIBRARY_PATH=build ./build/load
glibc 2.34 之后 dlopen 等函数并入了 libc,-ldl 可以省略;老系统需要显式链接 libdl。
dlopen 的标志
| 标志 | 含义 |
|---|---|
RTLD_LAZY |
延迟绑定,第一次调用时才解析函数符号 |
RTLD_NOW |
立即绑定,加载时解析所有符号;推荐,错误早暴露 |
RTLD_GLOBAL |
把库的符号加入全局作用域,后续加载的库可以用 |
RTLD_LOCAL |
符号只在本库和依赖内可见(默认) |
RTLD_NODELETE |
dlclose 时不卸载,保留代码和数据 |
RTLD_NOLOAD |
不加载新库,只在已加载时返回句柄;常用来检测某个库是否已加载 |
RTLD_DEEPBIND |
符号查找优先本库,避免被全局同名符号插入 |
RTLD_GLOBAL 常用于插件系统:主程序先加载基础库,插件再加载时能用到基础库的符号。但全局符号会互相污染,能不用就不用。
插件模式
插件接口用纯 C,避免 C++ ABI 问题:
// plugin_api.h
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
typedef struct plugin plugin;
plugin* plugin_create(void);
void plugin_destroy(plugin* p);
const char* plugin_name(plugin* p);
int plugin_run(plugin* p, int value);
#ifdef __cplusplus
}
#endif
插件实现:
// plugin_add.cpp
#include "plugin_api.h"
#include <cstring>
struct plugin
{
int calls;
};
extern "C" plugin* plugin_create(void)
{
return new plugin{0};
}
extern "C" void plugin_destroy(plugin* p)
{
delete p;
}
extern "C" const char* plugin_name(plugin*)
{
return "adder";
}
extern "C" int plugin_run(plugin* p, int value)
{
++p->calls;
return value + 1;
}
编译:
g++ -O2 -fPIC -shared -fvisibility=hidden -Iinclude \
-o build/libplugin_add.so plugin_add.cpp
加载器:
void* handle = dlopen("./libplugin_add.so", RTLD_NOW | RTLD_LOCAL);
auto create = reinterpret_cast<plugin* (*)(void)>(dlsym(handle, "plugin_create"));
auto destroy = reinterpret_cast<void (*)(plugin*)>(dlsym(handle, "plugin_destroy"));
auto run = reinterpret_cast<int (*)(plugin*, int)>(dlsym(handle, "plugin_run"));
plugin* p = create();
std::printf("%d\n", run(p, 41));
destroy(p);
dlclose(handle);
主程序符号导出
如果插件需要回调主程序的函数,主程序的符号必须进入 .dynsym。默认情况下,可执行文件的符号不会被动态导出(除了少数被动态库引用的)。用 -rdynamic:
g++ -O2 -rdynamic main.cpp -ldl -o build/app
等价于 -Wl,--export-dynamic。检查:
nm -D build/app | grep my_callback
RTLD_DEFAULT 与 RTLD_NEXT
dlsym 的第一个参数不一定是 dlopen 返回的句柄,还可以是两个特殊值:
RTLD_DEFAULT:从全局作用域开始查找,等价于"按正常符号解析顺序找"RTLD_NEXT:从当前库之后的库开始查找,常用于包装(interpose)系统函数
// 查找主程序或已加载库里的符号
auto fn = reinterpret_cast<AddFn>(dlsym(RTLD_DEFAULT, "add"));
RTLD_NEXT 的经典用法是写一个 malloc 包装:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <cstddef>
extern "C" void* malloc(std::size_t size)
{
using MallocFn = void* (*)(std::size_t);
static MallocFn real_malloc = nullptr;
if (!real_malloc)
{
real_malloc = reinterpret_cast<MallocFn>(dlsym(RTLD_NEXT, "malloc"));
}
return real_malloc(size);
}
编译成 .so,用 LD_PRELOAD 加载,就能在不修改程序的情况下统计或拦截内存分配。
注:真实的
malloc包装要小心递归——dlsym本身可能调用malloc,第一次解析时可能又进入你的包装函数。常见做法是用__libc_malloc作为后备、用一个静态标志位跳过早期分配,或者准备一块静态缓冲区应对dlsym期间的内存请求。上面的代码只展示结构,直接用于生产需要补上这些防护。
需要绑定特定符号版本时,用 GNU 扩展 dlvsym:
void* p = dlvsym(handle, "foo", "VERS_1.0");
dladdr 可以反查一个地址属于哪个模块:
#include <dlfcn.h>
#include <cstdio>
Dl_info info;
if (dladdr(reinterpret_cast<void*>(add), &info))
{
std::printf("module: %s\n", info.dli_fname);
std::printf("symbol: %s\n", info.dli_sname ? info.dli_sname : "(null)");
}
排查"这个函数到底来自哪个库"时很有用。
卸载的坑
dlclose 只是递减引用计数,计数归零才真正卸载。卸载时:
- 调用
.fini_array里的析构函数 - 解除映射
- 库里的
thread_local对象被销毁
不要在有线程还在库内执行时卸载。常见做法:
- 主程序退出前统一卸载
- 或者用
RTLD_NODELETE不卸载 - 或者在插件卸载前确保所有回调已注销、线程已 join
2.6 GCC 常见问题与诊断
undefined reference
build/main.o: In function `main':
main.cpp:(.text+0x1a): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status
按顺序排查:
- 库没链接:命令行少了
-ladd - 库顺序错:
-ladd放在了main.o前面 - 路径错:
-Lbuild没写或写错 - 名字修饰不匹配:C 头文件没有
extern "C",C++ 代码生成_Z3addii,C 库只有add - 库版本不对:链接到了另一个
libadd.so
诊断命令:
# 看目标文件里缺什么符号
nm -C build/main.o | grep ' U '
# 看库提供了什么符号
nm -C build/libadd.a | grep ' T '
nm -D --defined-only build/libadd.so | c++filt
# 让链接器打印符号解析过程
g++ ... -Wl,--trace-symbol=add
# 生成链接 map,看每个符号来自哪个文件
g++ ... -Wl,-Map=build/app.map
cannot find -ladd
/usr/bin/ld: cannot find -ladd: No such file or directory
原因通常是:
-L路径不对- 只有
libadd.so.1,没有链接名libadd.so - 库名拼错(
-ladd找的是libadd.so或libadd.a)
解决:
ls build/libadd*
ln -sf libadd.so.1 build/libadd.so
运行时找不到库
./app: error while loading shared libraries: libadd.so.1: cannot open shared object file: No such file or directory
排查:
# 看程序需要哪个 soname
readelf -d build/app_shared | grep NEEDED
# 看加载器实际搜了哪些路径
LD_DEBUG=libs ./build/app_shared 2>&1 | grep -E 'search path|trying file'
# 看库是否在缓存里
ldconfig -p | grep libadd
# 看 rpath
readelf -d build/app_shared | grep -E 'RPATH|RUNPATH'
patchelf --print-rpath build/app_shared
常见原因:
LD_LIBRARY_PATH没设或设错- rpath 里的
$ORIGIN没加引号,被 shell 展开成空 - 库装到了
/usr/local/lib,但/etc/ld.so.conf没包含它,或者没跑ldconfig - 程序要求的 soname 和你装的库文件名不一致
运行时 undefined symbol
./app: symbol lookup error: ./app: undefined symbol: _Z3addii
这说明库加载成功了,但某个符号在全局作用域里找不到。常见原因:
- 库的版本太老,没有新加的符号
- 符号被
-fvisibility=hidden藏起来了 - C++ 名称修饰不匹配
- 依赖库没加载(
DT_NEEDED缺失)
诊断:
# 看库导出了什么
nm -D --defined-only build/libadd.so | c++filt
# 看可执行文件需要什么
nm -D build/app_shared | grep ' U ' | c++filt
# 让加载器打印绑定过程
LD_DEBUG=bindings ./build/app_shared 2>&1 | grep add
# 检查所有未解析符号
ldd -r build/app_shared
relocation R_X86_64_32S ... recompile with -fPIC
完整报错:
/usr/bin/ld: build/add.o: relocation R_X86_64_32S against `.rodata' can not be used when making a shared object; recompile with -fPIC
原因:目标文件不是位置无关代码,里面有绝对地址,无法放进可任意加载的 .so。
解决:
g++ -O2 -fPIC -c src/add.cpp -o build/add.o
relocation R_X86_64_32 ... can not be used when making a PIE object
/usr/bin/ld: build/libadd.a(add.o): relocation R_X86_64_32 against `.rodata' can not be used when making a PIE object; recompile with -fPIE
原因:现代发行版默认生成 PIE 可执行文件,而你链接的静态库是用 -fno-PIC 编译的。
解决:
# 方案一:重新编译静态库,加 -fPIE(或 -fPIC)
g++ -O2 -fPIE -c src/add.cpp -o build/add.o
# 方案二:不生成 PIE
g++ -O2 -no-pie src/main.cpp build/libadd.a -o build/app
方案二适合无法重新编译的第三方静态库。代价是可执行文件失去 ASLR 的一部分保护。
multiple definition
/usr/bin/ld: build/b.o: in function `foo()':
b.cpp:(.text+0x0): multiple definition of `foo()'; build/a.o:a.cpp:(.text+0x0): first defined here
原因:
- 两个
.cpp都定义了同一个非inline函数 - 头文件里定义了非
inline的全局变量或函数,被多个翻译单元包含 - C 的暂定定义在
-fno-common下变成强定义
解决:
- 函数加
inline,或把定义移到.cpp - 变量加
extern声明,定义只放一个.cpp - 用匿名命名空间或
static限制内部链接 - 模板和
inline函数放进 COMDAT,链接器自动去重
GLIBC_2.xx not found
./app: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
原因:程序在新版本 glibc 上编译,运行环境是旧版本。glibc 向后兼容(老程序能在新 glibc 上跑),不向前兼容(新程序不能在老 glibc 上跑)。
解决:
- 在目标环境或更老的发行版上构建
- 用容器固定构建环境
- 静态链接 glibc(注意许可证和功能限制)
- 避免使用新版本才有的符号
GLIBCXX_3.4.xx not found
./app: /usr/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.29' not found
原因:编译用的 GCC 比运行环境的 libstdc++ 新。
解决:
# 方案一:静态链接 libstdc++
g++ ... -static-libstdc++ -static-libgcc
# 方案二:升级运行环境的 libstdc++
# 方案三:在旧环境编译
检查二进制需要的版本:
objdump -T build/app | grep GLIBCXX | sort -u
strings build/app | grep GLIBCXX
DSO missing from command line
/usr/bin/ld: build/app: undefined reference to `foo'
/usr/bin/ld: note: 'foo' is defined in DSO /lib/libbar.so so try adding it to the linker command line
原因:间接依赖没有显式链接。现代 ld 默认 --no-copy-dt-needed-entries,不会自动把依赖库的符号暴露给可执行文件。
解决:
g++ main.o -lbar -lfoo -o app
cannot allocate memory in static TLS block
./app: cannot allocate memory in static TLS block
原因:某个 dlopen 的库用了 initial-exec TLS 模型,但静态 TLS 空间不够。
解决:
- 用
-ftls-model=global-dynamic重新编译该库 - 在程序启动时尽早加载该库
- 增大 glibc 的静态 TLS 预留:
GLIBC_TUNABLES=glibc.rtld.optional_static_tls=8192 ./app
链接顺序与 --start-group
静态库有循环依赖时:
g++ main.o -Wl,--start-group -la -lb -Wl,--end-group -o app
链接器会在组内反复扫描。只对确实有循环依赖的库使用,否则链接时间会变长。
链接时包装:--wrap
如果不想用 LD_PRELOAD,而是想在链接时就替换某个符号,可以用 --wrap:
g++ main.o -Wl,--wrap=malloc -o app
#include <cstddef>
extern "C" void* __real_malloc(std::size_t size);
extern "C" void* __wrap_malloc(std::size_t size)
{
// 统计、日志、调试
return __real_malloc(size);
}
链接器把所有未定义的 malloc 引用改写成 __wrap_malloc,把 __real_malloc 解析成原始的 malloc。
几个限制:
- 只影响参与这次链接的目标文件;已经编译好的
.so内部的malloc调用不受影响 - C++ 符号要写修饰后的名字,通常配合
extern "C"使用 - 和
LD_PRELOAD的区别:--wrap是链接时的引用重写,LD_PRELOAD是运行时的全局符号插入
静态库里的符号没有被拉进来
静态库只按需取成员。如果库里的某个 .o 只包含静态构造函数(靠全局对象注册),没有任何符号被引用,它不会被链接进来。
解决:
g++ main.o -Wl,--whole-archive -lregistry -Wl,--no-whole-archive -o app
或者把注册入口做成显式函数,让主程序调用。
查看库的 ABI 信息
# 动态符号
nm -D --defined-only libadd.so | c++filt
# 动态段
readelf -d libadd.so
# 重定位
readelf -r libadd.so
# 程序头
readelf -l libadd.so
# 所有节区
readelf -S libadd.so
# 反汇编
objdump -d libadd.so
# 依赖
objdump -p libadd.so | grep NEEDED
# 符号版本
readelf --dyn-syms -W libadd.so | grep '@@'
-Wl,-Map=build/app.map 生成的 map 文件会列出:
- 每个输入文件贡献了哪些节区
- 每个符号最终落在哪个地址
- 哪个库成员被拉取,为什么被拉取
- 被丢弃的节区
排查"为什么可执行文件这么大""这个符号到底来自哪"时,map 文件比 nm 更直接。
3. MSVC / Windows 实操
3.1 工具链与命令
MSVC 的命令行工具主要有四个:
| 命令 | 作用 |
|---|---|
cl |
编译器驱动,负责编译,也能调用链接器 |
lib |
把 .obj 打包成静态库,或从 .def 生成导入库 |
link |
链接器,生成 exe 或 dll |
dumpbin |
查看 PE/COFF 文件的结构、符号、导入导出表 |
这些命令需要在 Developer Command Prompt for VS 里运行,或者先执行对应的 vcvars64.bat。普通的 cmd 或 PowerShell 默认找不到它们。
where cl
where lib
where link
where dumpbin
确认是 x64 工具链:
cl
输出开头会显示:
用于 x64 的 Microsoft (R) C/C++ 优化编译器
如果显示 x86,说明打开的是 32 位开发者命令提示符。32 位编译器生成的 .obj 不能和 64 位库链接,会报 LNK1112。
3.2 静态库
编译 .obj
cl /nologo /c /EHsc /O2 /MD /Iinclude src\add.cpp /Fo:build\add.obj
参数说明:
| 参数 | 含义 |
|---|---|
/nologo |
不打印版权信息 |
/c |
只编译,不链接 |
/EHsc |
启用 C++ 异常处理,假定 extern "C" 函数不抛异常 |
/O2 |
优化级别 |
/MD |
动态链接 CRT(推荐) |
/Iinclude |
头文件搜索路径 |
/Fo:build\add.obj |
输出目标文件 |
/EHsc 几乎是 C++ 项目的必备选项。不加它,编译器会警告 C4530: 使用了 C++ 异常处理程序,但未启用展开语义。
/EHa 会同时捕获 C++ 异常和 Windows 结构化异常(SEH),/EHsc 只捕获 C++ 异常。两者不要混用,同一个进程里的模块最好保持一致。/EHa 会让代码变慢,只有在确实需要捕获 SEH 时才用。
打包 .lib
lib /nologo /OUT:build\add.lib build\add.obj
检查:
lib /list build\add.lib
dumpbin /symbols build\add.lib
dumpbin /symbols 输出类似:
00A 00000000 SECT3 notype () External | ?add@@YAHHH@Z (int __cdecl add(int,int))
00B 00000000 SECT3 notype () External | ?sub@@YAHHH@Z (int __cdecl sub(int,int))
?add@@YAHHH@Z 是 MSVC 的名称修饰结果。YAHHH 里的 A 表示 __cdecl,H 表示 int。GCC 生成的是 _Z3addii,两套规则完全不同,所以 MSVC 和 GCC 的 C++ 目标文件不能直接互相链接。
链接静态库
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib /Fe:build\app_static.exe
也可以在源码里写链接指令:
#pragma comment(lib, "add.lib")
#pragma comment(lib, ...) 会往目标文件的 .drectve 节区里写一条链接器指令,链接时等价于在命令行上加 add.lib。它的路径相对于当前工作目录或 /LIBPATH,不适合跨目录的复杂工程;优点是源码自带依赖声明,不会忘记链接。
cl 会把 main.cpp 编译成 main.obj,然后把 main.obj 和 add.lib 一起交给 link.exe。
运行:
build\app_static.exe
add(2, 3) = 5
sub(2, 3) = -1
检查依赖:
dumpbin /dependents build\app_static.exe
app_static.exe 不应该依赖 add.dll,只依赖 KERNEL32.dll、VCRUNTIME140.dll、ucrtbase.dll 等系统或 CRT 库。
直接用 link 链接
cl 只是驱动,真正干活的是 link。可以分两步:
cl /nologo /c /EHsc /O2 /MD /Iinclude src\main.cpp /Fo:build\main.obj
link /nologo /OUT:build\app_static.exe build\main.obj build\add.lib
链接器参数:
| 参数 | 含义 |
|---|---|
/OUT:path |
输出文件 |
/SUBSYSTEM:CONSOLE |
控制台程序;默认根据入口点推断 |
/MACHINE:X64 |
目标架构 |
/LIBPATH:dir |
库搜索路径 |
/VERBOSE:LIB |
打印搜索了哪些库 |
/NODEFAULTLIB:name |
排除某个默认库 |
/OPT:REF |
删除未引用的函数和数据 |
/OPT:ICF |
合并相同的 COMDAT |
/INCREMENTAL:NO |
关闭增量链接 |
/DEBUG |
生成 PDB 调试信息 |
直接调用 link 的好处是参数清晰,排查链接问题时可以把 /VERBOSE:LIB 打开,看链接器到底在找什么。
强制链接整个静态库
和 GCC 的 --whole-archive 对应,MSVC 用 /WHOLEARCHIVE:
link /nologo /OUT:build\app.exe build\main.obj ^
/WHOLEARCHIVE:build\add.lib
它会把静态库的所有成员都链进来,包括没有被引用的成员。适合静态注册、插件表、需要完整导出符号的场景。也可以只强制某个符号:
link /nologo /OUT:build\app.exe build\main.obj build\add.lib ^
/INCLUDE:?add@@YAHHH@Z
/INCLUDE 强制把定义该符号的成员拉进来,比 /WHOLEARCHIVE 精确。
运行库选项 /MT 与 /MD
这是 MSVC 项目最容易出问题的地方之一。
| 选项 | CRT 链接方式 | 每个模块的堆 | 部署 |
|---|---|---|---|
/MT |
静态链接 LIBCMT.lib |
各自一份 | 不需要 VC++ Redistributable |
/MTd |
静态链接 LIBCMTD.lib |
各自一份 | Debug 版 |
/MD |
动态链接 MSVCRT.dll / ucrtbase.dll |
共享 | 需要 VC++ Redistributable |
/MDd |
动态链接 MSVCRTD.dll / ucrtbased.dll |
共享 | Debug 版 |
规则:
- 同一个进程里所有模块的
/MT、/MD必须一致。EXE 用/MD,DLL 用/MT,会在链接时得到LNK2038,或者运行时堆崩溃。 - Debug 和 Release 不能混。
/MTd和/MT是两个不同的 CRT,_ITERATOR_DEBUG_LEVEL也不同。 - 跨 DLL 传递 C++ 对象时,最好统一
/MD。这样malloc/free、new/delete走同一个 UCRT 堆。
LNK2038 的典型输出:
add.lib(add.obj) : error LNK2038: 检测到"RuntimeLibrary"的不匹配: 值"MT_StaticRelease"不匹配值"MD_DynamicRelease"
解决办法只有重新编译,不能靠链接选项绕过。
静态库的检查
lib /list build\add.lib
dumpbin /headers build\add.lib
dumpbin /symbols build\add.lib
dumpbin /headers 会显示 FILE HEADER VALUES 和 Archive member name。静态库的成员是普通 COFF 对象,带 .text、.data、.bss、.drectve、.debug$S 等节区。
dumpbin /symbols 里如果看到 __imp_ 前缀,说明这个 .lib 是导入库而不是静态库。
3.3 DLL
__declspec(dllexport)
Windows 的 DLL 默认不导出任何符号。必须在函数、变量或类上显式标记:
// add.cpp
__declspec(dllexport) int add(int a, int b)
{
return a + b;
}
__declspec(dllexport) int sub(int a, int b)
{
return a - b;
}
编译成 DLL:
cl /nologo /LD /EHsc /O2 /MD /Iinclude src\add.cpp /Fe:build\add.dll /link /IMPLIB:build\add.lib
/LD 的作用:
- 定义
_WINDLL宏 - 让
cl调用link /DLL - 生成
.dll、导入库.lib、导出文件.exp
检查导出:
dumpbin /exports build\add.dll
ordinal hint RVA name
1 0 00001010 add
2 1 00001020 sub
如果 dumpbin /exports 显示 no exports,说明忘了 __declspec(dllexport),或者 .def 文件没写对。
__declspec(dllimport)
使用 DLL 的一方需要 dllimport:
// add.h
#pragma once
#ifdef ADD_EXPORTS
#define ADD_API __declspec(dllexport)
#else
#define ADD_API __declspec(dllimport)
#endif
#ifdef __cplusplus
extern "C" {
#endif
ADD_API int add(int a, int b);
ADD_API int sub(int a, int b);
#ifdef __cplusplus
}
#endif
- 编译 DLL 时定义
ADD_EXPORTS,ADD_API展开为dllexport - 编译使用者时不定义,
ADD_API展开为dllimport
dllimport 的作用:
- 函数:编译器知道这个函数在别的 DLL 里,可以直接生成
call QWORD PTR [__imp_add],少一次跳转。不加dllimport也能工作,但会走导入库提供的 thunk。 - 数据:必须加。否则编译器会把
g_count当成当前模块内的变量,生成直接寻址,链接时报未解析,或者运行时读到错误的地址。 - 类:告诉编译器类的所有成员都在 DLL 里,可以优化访问;但类布局必须完全一致。
导出数据
// add.h
#ifdef __cplusplus
extern "C" {
#endif
ADD_API int add(int a, int b);
extern ADD_API int g_count;
#ifdef __cplusplus
}
#endif
// add.cpp
ADD_API int g_count = 0;
使用:
#include "add.h"
int main()
{
g_count = 10; // 通过 IAT 间接访问 DLL 里的变量
return 0;
}
dumpbin /exports build\add.dll 会多一项:
3 2 00003000 g_count
导出类
// calculator.h
#pragma once
#include "add.h"
class ADD_API Calculator
{
public:
Calculator();
~Calculator();
int add(int a, int b);
int sub(int a, int b);
private:
int state_;
};
__declspec(dllexport) 加在类上,会导出:
- 所有非内联成员函数
- 静态数据成员
- 虚函数表(vftable)和 RTTI 相关符号
这带来一堆 ABI 约束:类的成员布局、虚函数顺序、基类、std::string 等标准库成员的类型,都必须两边完全一致。所以跨 DLL 导出 C++ 类时,最好:
- 类里不要出现 STL 成员
- 成员函数不要内联
- 用工厂函数创建对象,用虚析构或
destroy()释放 - 两边用同一个 MSVC 大版本和同一套
/MD选项
如果只是想在 DLL 内部用 C++,对外暴露 C 接口,就不要导出类。用不透明句柄:
extern "C" {
typedef struct calculator calculator;
ADD_API calculator* calculator_create(void);
ADD_API void calculator_destroy(calculator* c);
ADD_API int calculator_add(calculator* c, int a, int b);
}
.def 文件
除了 __declspec(dllexport),还可以用模块定义文件:
LIBRARY add.dll
DESCRIPTION "add/sub library"
EXPORTS
add @1
sub @2
g_count DATA
LIBRARY:DLL 的内部名字,影响导入库记录的名字EXPORTS:导出列表@1:固定序号,老客户端按序号导入时保持兼容DATA:标记这是数据导出NONAME:只按序号导出,不导出名字(可以减小导出表,但客户端只能用GetProcAddress序号)add=?add@@YAHHH@Z:把修饰名导出成未修饰的别名
用 .def 生成导入库:
lib /nologo /def:build\add.def /machine:x64 /out:build\add.lib
lib /def 不会生成 DLL,只生成导入库。DLL 本身还是要用 link /DLL 生成。.def 的典型用途是:
- 导出未修饰的名字(避免
GetProcAddress找不到) - 固定序号
- 给已有 DLL 补一个导入库
导入库与 .exp
链接 DLL 时,MSVC 会自动生成:
add.dll:实际 DLLadd.lib:导入库,给使用者链接add.exp:导出文件,增量链接时用,通常不需要分发
导入库的内容不是代码,而是:
__imp_add等符号,指向 IAT 槽位addthunk,跳转到[__imp_add]__IMPORT_DESCRIPTOR_add等链接器指令,告诉链接器把add.dll写进导入表
可以用 dumpbin /symbols build\add.lib 看到这些符号。
名字修饰与调用约定
MSVC 的修饰规则和 GCC 完全不同,x86 和 x64 也不同:
| 语言/约定 | x86 符号 | x64 符号 |
|---|---|---|
C __cdecl |
_add |
add |
C __stdcall |
_add@8 |
add |
C __fastcall |
@add@8 |
add |
C++ __cdecl |
?add@@YAHHH@Z |
同左 |
C++ __stdcall |
?add@@YGHHH@Z |
同左 |
x64 只有一种调用约定,所以 C 函数没有前导下划线和 @N 后缀;但 C++ 名称修饰仍然存在。
extern "C" 的作用:
- 关闭 C++ 名称修饰
- x86 上仍然保留调用约定带来的修饰(
_add、_add@8) - x64 上得到干净的
add
如果 GetProcAddress 拿不到函数,先用 dumpbin /exports 看真实导出名,再决定传什么字符串。常见错误是导出了 _add@8,却用 "add" 去取。
用 .def 可以做别名:
EXPORTS
add=_add@8
这样导出名是 add,内部符号是 _add@8。
生成 DLL 的两种方式
方式一:__declspec(dllexport) + /LD
cl /nologo /LD /EHsc /O2 /MD /Iinclude /DADD_EXPORTS src\add.cpp ^
/Fe:build\add.dll /link /IMPLIB:build\add.lib
方式二:.def 文件
cl /nologo /LD /EHsc /O2 /MD /Iinclude src\add.cpp ^
/Fe:build\add.dll /link /DEF:build\add.def /IMPLIB:build\add.lib
方式一简单,适合纯 C++ 项目;方式二对导出名字和序号控制更精确,适合对外 SDK、跨语言调用、需要稳定序号的场景。
3.4 运行时加载:LoadLibrary
基本用法
#include <windows.h>
#include <cstdio>
int main()
{
HMODULE h = LoadLibraryW(L"build\\add.dll");
if (!h)
{
std::printf("LoadLibrary failed: %lu\n", GetLastError());
return 1;
}
using AddFn = int (*)(int, int);
auto add = reinterpret_cast<AddFn>(GetProcAddress(h, "add"));
if (!add)
{
std::printf("GetProcAddress failed: %lu\n", GetLastError());
FreeLibrary(h);
return 1;
}
std::printf("add(2, 3) = %d\n", add(2, 3));
FreeLibrary(h);
return 0;
}
LoadLibraryW接受宽字符路径;LoadLibraryA接受窄字符GetProcAddress返回FARPROC,需要转换成正确的函数指针类型FreeLibrary递减引用计数,归零时卸载GetLastError返回错误码,可以用FormatMessage转成文本
安全搜索路径
默认搜索顺序包含当前工作目录和 PATH,容易被 DLL 劫持。安全做法:
// 让 LoadLibraryEx 只搜索应用程序目录和 System32
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_APPLICATION_DIR |
LOAD_LIBRARY_SEARCH_SYSTEM32);
HMODULE h = LoadLibraryExW(L"add.dll", nullptr,
LOAD_LIBRARY_SEARCH_APPLICATION_DIR |
LOAD_LIBRARY_SEARCH_SYSTEM32);
或者直接传完整路径:
HMODULE h = LoadLibraryExW(L"C:\\Program Files\\MyApp\\add.dll", nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR |
LOAD_LIBRARY_SEARCH_SYSTEM32);
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR 表示搜索 DLL 所在目录,LOAD_LIBRARY_SEARCH_SYSTEM32 表示搜索 System32。两个标志都不能和 LOAD_WITH_ALTERED_SEARCH_PATH 混用。
GetProcAddress 与序号
按名字:
auto fn = reinterpret_cast<AddFn>(GetProcAddress(h, "add"));
按序号:
auto fn = reinterpret_cast<AddFn>(GetProcAddress(h, MAKEINTRESOURCEA(1)));
序号在 .def 里用 @1 固定。按序号导入的优点是快、名字不暴露;缺点是序号一旦变化,老客户端会调到错误的函数。成熟库只在末尾追加序号。
卸载
FreeLibrary 只是递减引用计数:
HMODULE h1 = LoadLibraryW(L"add.dll");
HMODULE h2 = LoadLibraryW(L"add.dll"); // 同一模块,计数 +1,返回同一个句柄
FreeLibrary(h1); // 计数 -1,DLL 还在
FreeLibrary(h2); // 计数归零,DLL 卸载
卸载时调用 DllMain(DLL_PROCESS_DETACH)。如果还有线程在 DLL 里执行,不要直接 FreeLibrary,用 FreeLibraryAndExitThread 从 DLL 内部退出。
如果希望模块常驻,可以把它钉在进程里:
HMODULE h = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN,
L"add.dll", &h);
带 PIN 标志加载的模块,之后调用 FreeLibrary 也不会卸载。适合那些注册了全局回调、不能被卸载的插件。
3.5 DllMain
四个通知
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved)
{
switch (reason)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
break;
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
| 通知 | 触发时机 |
|---|---|
DLL_PROCESS_ATTACH |
DLL 第一次被加载进进程(静态导入或第一次 LoadLibrary) |
DLL_THREAD_ATTACH |
进程创建新线程,且 DLL 没有调用 DisableThreadLibraryCalls |
DLL_THREAD_DETACH |
线程退出 |
DLL_PROCESS_DETACH |
DLL 被卸载,或进程退出 |
DLL_PROCESS_ATTACH 返回 FALSE 会让 LoadLibrary 失败(错误码 1114,ERROR_DLL_INIT_FAILED);如果是静态导入,进程直接启动失败。
能做什么,不能做什么
DllMain 在加载器锁(loader lock)下执行。加载器锁保护模块列表、导入表、TLS 等全局结构。在 DllMain 里做下面这些事,可能死锁或崩溃:
- 调用
LoadLibrary/FreeLibrary加载另一个 DLL - 创建线程并等待它结束
- 调用 COM 的
CoInitialize/CoCreateInstance - 等待一个可能正在加载 DLL 的线程
- 抛出跨模块的 C++ 异常
- 做耗时的初始化(文件 IO、网络、大量内存分配)
可以做的事:
- 初始化简单的全局变量
- 调用
DisableThreadLibraryCalls - 注册 TLS 回调
- 保存
hModule到全局变量
复杂初始化用延迟初始化:DLL 导出一个 init() 函数,由调用方在 LoadLibrary 之后显式调用;或者用 C++ 的 std::call_once 在第一次使用时初始化。
TLS 回调
如果需要在 DllMain 之前或线程创建时执行代码,可以用 TLS 回调:
void NTAPI MyTlsCallback(PVOID module, DWORD reason, PVOID reserved)
{
// reason: DLL_PROCESS_ATTACH / DLL_THREAD_ATTACH / ...
}
#ifdef _M_X64
#pragma comment(linker, "/INCLUDE:_tls_used")
#pragma const_seg(".CRT$XLB")
EXTERN_C const PIMAGE_TLS_CALLBACK my_tls_callback = MyTlsCallback;
#pragma const_seg()
#else
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma data_seg(".CRT$XLB")
EXTERN_C PIMAGE_TLS_CALLBACK my_tls_callback = MyTlsCallback;
#pragma data_seg()
#endif
TLS 回调在 DllMain 之前执行,能拿到进程/线程附加通知。不要在里面做重活。
线程局部变量
MSVC 用 __declspec(thread) 声明线程局部变量:
__declspec(thread) int tls_counter = 0;
每个线程有自己的一份 tls_counter,互不干扰。C++11 的 thread_local 在 MSVC 上底层也是 TLS。
DLL 里的 __declspec(thread) 有几个限制:
- Windows Vista 之前,运行时通过
LoadLibrary加载的 DLL 不能可靠使用__declspec(thread) - DLL 被加载时,已经存在的线程不一定能拿到新分配的 TLS 块,具体行为取决于系统版本和加载方式
- TLS 变量的构造/析构顺序和线程创建/退出顺序相关,不要在线程局部对象的析构函数里依赖其他 TLS 对象
如果 DLL 要在运行时加载,并且需要线程局部状态,优先用 thread_local 配合显式的 init(),或者用 TLS 回调管理生命周期。
3.6 延迟加载
延迟加载让某个 DLL 在第一次调用它的函数时才加载,而不是进程启动时。
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib ^
/link /DELAYLOAD:add.dll delayimp.lib /Fe:build\app_delay.exe
/DELAYLOAD:add.dll告诉链接器生成延迟导入表(.didat)delayimp.lib提供__delayLoadHelper2等运行时支持
第一次调用 add 时,helper 会:
- 调用
LoadLibrary("add.dll") - 用
GetProcAddress解析所有延迟导入 - 把地址写进 IAT
- 跳转到真实函数
如果加载失败,默认会抛出一个结构化异常。可以注册钩子自定义行为:
#include <delayimp.h>
FARPROC WINAPI MyDliNotify(unsigned reason, DelayLoadInfo* info)
{
if (reason == dliNotePreLoadLibrary)
{
// 返回自定义的 HMODULE
}
return nullptr;
}
ExternC PfnDliHook __pfnDliNotifyHook2 = MyDliNotify;
延迟加载适合:
- 可选功能(打印、更新、插件)
- 启动时不需要的 DLL
- 想缩短启动时间的场景
代价是第一次调用有额外开销,而且错误处理更复杂。
3.7 MSVC 常见问题与诊断
LNK2019
main.obj : error LNK2019: 无法解析的外部符号 "int __cdecl add(int,int)" (?add@@YAHHH@Z),函数 main 中引用了该符号
排查顺序:
- 是否把
add.lib放进了链接命令行 - 导出名是否匹配(
dumpbin /exports add.dll) - 是否用了
extern "C",两边的调用约定是否一致 - 库和程序是否同为 x86 或 x64
/MT和/MD是否一致- 是否链接了错误的库版本
诊断:
link /nologo /VERBOSE:LIB /OUT:build\app.exe build\main.obj build\add.lib
/VERBOSE:LIB 会打印链接器搜索了哪些库、加载了哪些成员。
LNK2001
main.obj : error LNK2001: 无法解析的外部符号 "int g_count" (?g_count@@3HA)
通常是数据成员或全局变量:
- 忘了在头文件里加
extern,导致每个包含它的.cpp都以为要定义 - 忘了
__declspec(dllimport) - 静态数据成员只声明没定义
- 模板静态成员没有实例化
LNK2005
LIBCMT.lib(init.obj) : error LNK2005: _initterm 已经在 MSVCRT.lib(init.obj) 中定义
原因:同时链接了静态 CRT 和动态 CRT。常见于:
- 一个库用
/MT编译,另一个用/MD - 项目属性里运行时库设置不一致
- 第三方
.lib自带/MT的 CRT
解决:统一 /MT 或 /MD,或者用 /NODEFAULTLIB 排除冲突的库(不推荐,容易掩盖问题)。
LNK1104
LINK : fatal error LNK1104: 无法打开文件 "add.lib"
原因:
- 路径写错
- 库还没生成
- 文件被另一个进程占用
- 权限不足
- 路径里有空格但没加引号
诊断:
dir build\add.lib
where add.lib
LNK2038
error LNK2038: 检测到"RuntimeLibrary"的不匹配: 值"MT_StaticRelease"不匹配值"MD_DynamicRelease"
或:
error LNK2038: 检测到"_ITERATOR_DEBUG_LEVEL"的不匹配: 值"0"不匹配值"2"
这是 MSVC 在目标文件里埋的"编译选项指纹"。/MT、/MD、Debug/Release、_ITERATOR_DEBUG_LEVEL 不一致时,链接器直接拒绝,避免运行时堆崩溃。
解决:所有目标文件和库用同一套选项重新编译。
LNK1112
fatal error LNK1112: 模块计算机类型“x64”与目标计算机类型“x86”冲突
32 位和 64 位混链。检查:
dumpbin /headers build\add.lib | findstr machine
dumpbin /headers build\main.obj | findstr machine
x64 显示 8664 (x64),x86 显示 14C (x86)。统一工具链和库架构。
0xc000007b
程序启动时弹出"应用程序无法正常启动 (0xc000007b)"。
常见原因:
- 32 位程序加载了 64 位 DLL,或反过来
- DLL 依赖的某个系统 DLL 架构不对
- DLL 文件损坏
诊断:
dumpbin /headers build\app.exe | findstr machine
dumpbin /headers build\add.dll | findstr machine
用 Dependencies 工具(Dependency Walker 的现代替代品)看依赖树和架构。
0x8007007e
LoadLibrary 返回 NULL,GetLastError() 是 126 (0x7E):找不到模块。
原因:
- DLL 不在搜索路径里
- DLL 依赖的另一个 DLL 找不到
- 路径里有非法字符
诊断:
dumpbin /dependents build\add.dll
用 Process Monitor 过滤 Operation is Load Image,看加载器尝试了哪些路径、哪个返回 NAME NOT FOUND。
GetProcAddress 返回 NULL
GetLastError() 通常是 127(找不到指定过程)。原因:
- 导出名不对(名称修饰、
__stdcall的@N、大小写) - 函数根本没导出(
dumpbin /exports显示 no exports) - 加载了错误版本的 DLL
- 用了序号但序号不对
诊断:
dumpbin /exports build\add.dll
把导出的名字原样复制到 GetProcAddress。如果是 C++ 修饰名,考虑用 extern "C" 或 .def 别名。
The procedure entry point ... could not be located
程序启动时弹窗:
无法定位程序输入点 add 于动态链接库 add.dll 上。
原因:EXE 链接时用的导入库和运行时加载的 DLL 版本不一致。EXE 期望一个新函数,旧 DLL 没有。
解决:
- 确保 DLL 和导入库是同一版本
- 用
dumpbin /exports对比导入表 - 部署时把正确的 DLL 放到应用目录
dumpbin 常用命令
dumpbin /headers build\add.dll :: PE 头、节区、DLL 特性
dumpbin /exports build\add.dll :: 导出表
dumpbin /imports build\app.exe :: 导入表
dumpbin /dependents build\app.exe :: 依赖的 DLL
dumpbin /symbols build\add.lib :: COFF 符号
dumpbin /disasm build\add.dll :: 反汇编
dumpbin /pdbpath build\add.dll :: PDB 路径
dumpbin /all build\add.dll :: 全部信息
dumpbin 的输出可以直接和 link /dump 互换,后者是同一个工具的不同入口。
4. 跨工具链与部署
4.1 MinGW 与 MSVC 互操作
MinGW-w64 可以生成 DLL,但它的导入库格式和 MSVC 不完全一样。常见做法:
MinGW 生成 DLL 和导入库:
g++ -O2 -shared -o build/add.dll src/add.cpp \
-Wl,--out-implib,build/libadd.dll.a
MSVC 使用 MinGW 的 DLL:
MSVC 不能直接链接 libadd.dll.a(它是 ar 格式,不是 COFF 导入库)。需要生成 .lib:
gendef build/add.dll
dlltool -d add.def -l build/add.lib -D build/add.dll
gendef 从 DLL 的导出表生成 .def,dlltool 从 .def 生成 MSVC 可用的导入库。pexports 是另一个可选工具。
MinGW 使用 MSVC 的 DLL:
dlltool -d add.def -l build/libadd.dll.a -D build/add.dll
或者用 g++ 直接链接 .dll(MinGW ld 会读取导出表),但导入库更稳。
4.2 C ABI 是通用语言
跨编译器、跨语言、跨平台时,唯一稳定的接口是 C ABI:
extern "C" {
int add(int a, int b);
}
注意:
- 调用约定:x86 上 MSVC 默认
__cdecl,MinGW 也默认__cdecl;如果用了__stdcall,两边的修饰规则可能不同。跨编译器时优先__cdecl。 - 名字修饰:
extern "C"关闭 C++ 修饰,但 x86 上仍有前导下划线。用.def或dlltool处理别名。 - 类型宽度:
long在 Windows 上是 32 位,在 Linux 64 位上是 64 位。接口里用int32_t、uint64_t、size_t时也要小心size_t的宽度。 - 结构体布局:默认对齐、packing、位域顺序都可能不同。跨编译器时显式指定对齐,避免位域。
- 内存分配:谁分配谁释放。不要把一个 CRT 的
malloc指针交给另一个 CRT 的free。 - 异常:不要跨 C ABI 抛 C++ 异常。
- STL 类型:不要出现在接口里。
4.3 32/64 位与运行库匹配
一个进程里所有模块的架构必须一致:
- x64 进程只能加载 x64 DLL
- x86 进程只能加载 x86 DLL
- ARM64 进程只能加载 ARM64 DLL
WOW64 可以让 32 位进程运行在 64 位 Windows 上,但进程内的 DLL 仍然全是 32 位。System32 在 32 位进程眼里会被重定向到 SysWOW64。
运行库也要匹配:
/MD的程序需要对应版本的 VC++ Redistributable/MT的程序自带 CRT,但每个模块一份,跨 DLL 传对象仍要小心- Debug 版需要 Debug CRT,不能发给最终用户
4.4 调试符号
Linux:
# 编译时带调试信息
g++ -g -O2 -fPIC -c src/add.cpp -o build/add.o
# 分离调试信息
objcopy --only-keep-debug build/libadd.so.1.0.0 build/libadd.so.debug
strip --strip-unneeded build/libadd.so.1.0.0
objcopy --add-gnu-debuglink=build/libadd.so.debug build/libadd.so.1.0.0
# GDB 里设置符号搜索路径
# (gdb) set debug-file-directory build
# (gdb) info sharedlibrary
Windows:
cl /nologo /c /EHsc /Od /MDd /Zi /Iinclude src\add.cpp /Fo:build\add.obj
link /nologo /DLL /DEBUG /OUT:build\add.dll build\add.obj /IMPLIB:build\add.lib
/Zi生成 PDB 调试信息/DEBUG让链接器生成 PDB/Od关闭优化,方便调试- PDB 文件名和路径可以用
dumpbin /pdbpath查看 - 发布时保留 PDB,但不要发给最终用户
4.5 静态库与动态库混用
一个项目里同时出现静态库和动态库很常见,问题往往出在"同一份静态库被链接进了多个模块"。
两份全局变量
app
├── libutil.a (复制一份 util 代码和数据)
└── libplugin.so
└── libutil.a (又复制一份 util 代码和数据)
exe 和 libplugin.so 各有一份 util_state。插件往自己的注册表里写,exe 查自己的注册表,结果就是"注册了但找不到"。
这不是链接错误,运行时也不会报错,只是两份状态各自为政。排查时容易怀疑"注册逻辑写错了",其实是链接模型的问题。
RTTI 与异常的双份
如果静态库里定义了跨边界使用的类型:
// util.h
struct Error
{
virtual ~Error() = default;
};
// util.cpp
void throw_error()
{
throw Error{};
}
libutil.a 同时被 exe 和 libplugin.so 链接后,两份 Error 的 type_info 可能不是同一个对象。dynamic_cast 和 catch 依赖 typeinfo 的比较,跨边界时可能匹配失败。
ELF 上 typeinfo 是弱符号,动态加载器有机会把两份合并成一份;但如果符号被 -fvisibility=hidden 隐藏,或者两个模块各自静态链接了 libstdc++,合并不一定发生。Windows 上 /MT 会让每个模块持有一份独立的 RTTI 对象,风险更高。
处理办法
1. 只链接一次。 让 exe 链接 libutil.a,插件通过 exe 导出的函数访问 util:
g++ -rdynamic app.o libutil.a -ldl -o app
插件里用 dlsym(RTLD_DEFAULT, "util_register") 找到 exe 的符号。Windows 上需要 exe 用 .def 或 __declspec(dllexport) 导出这些函数,或者干脆把 util 放进一个 DLL。
2. 把 util 做成共享库。 这是最省事的做法。所有模块链接同一个 libutil.so,加载器只有一份全局状态。
3. 让静态库无状态。 纯函数、不依赖全局变量、不持有缓存,那么两份副本也无所谓。这是设计 C 库时的常用原则。
4. 隐藏静态库的符号。 把第三方静态库链进 .so 时,不希望它的符号被导出、参与全局插入:
g++ -shared -o libplugin.so plugin.o -Wl,--exclude-libs,ALL
--exclude-libs,ALL 把来自静态归档的符号标记为隐藏。它不能消除"两份副本",但能减少符号冲突和意外插入。
5. 避免用 -Bsymbolic 掩盖问题。 -Bsymbolic 让库内部引用绑定到自己的定义,能让"两份副本"各自自洽,但 exe 和插件之间的状态仍然不通。它解决的是符号绑定,不是设计问题。
注:Linux 上可以用
LD_PRELOAD强制让某个共享库优先加载,从而"统一"符号;但这是运行时的权宜之计,不适合作为架构方案。静态库副本的问题最终要靠"只链接一次"或"无状态设计"解决。
4.6 LTO 与库
LTO(Link-Time Optimization,链接时优化)把优化从"单个翻译单元"推迟到"链接阶段",让编译器能看到跨文件、跨静态库的函数体,从而做内联、常量传播、死代码消除。
GCC 的 LTO
编译和链接都要加 -flto:
g++ -O2 -flto -Iinclude -c src/add.cpp -o build/add.o
g++ -O2 -flto -Iinclude src/main.cpp build/add.o -o build/app
静态库要用支持 LTO 的 ar:
gcc-ar rcs build/libadd.a build/add.o
# 或者
gcc-ranlib build/libadd.a
直接使用普通 ar 时,可能需要指定插件:
ar --plugin=$(gcc -print-file-name=liblto_plugin.so) rcs build/libadd.a build/add.o
如果库要发给不使用 LTO 的使用者,加 -ffat-lto-objects,让目标文件同时包含 LTO 中间表示和普通机器码:
g++ -O2 -flto -ffat-lto-objects -c src/add.cpp -o build/add.o
代价是目标文件变大,链接时仍然可以选择走 LTO 或普通路径。
-flto=auto 让链接器并行执行 LTO,适合大项目。
LTO 的边界
- 跨静态库可以优化:静态库最终会被链接进同一个可执行文件或
.so,LTO 能看到所有 IR - 跨
.so不能优化:每个.so是独立链接的,运行时才拼在一起 - 跨
dlopen的模块不能优化 - 内联函数和模板在 LTO 下更容易被合并和裁剪
LTO 会让"只被 dlsym 使用的符号"变得危险。编译器看不到任何 C++ 引用,可能把它当死代码删掉。解决办法:
extern "C" __attribute__((used, visibility("default")))
void plugin_entry()
{
}
或者用链接器的 --export-dynamic-symbol、--undefined 保留符号。
LTO 与调试
g++ -O2 -flto -g -Iinclude -c src/add.cpp -o build/add.o
g++ -O2 -flto -g src/main.cpp build/add.o -o build/app
编译和链接都要 -g,否则调试信息可能不完整。LTO 后的行号映射可能和源码对不上,调试体验会比普通编译差一些。
MSVC 的 LTCG
MSVC 的对应机制是 /GL(编译时生成全程序优化信息)加 /LTCG(链接时优化):
cl /nologo /c /EHsc /O2 /MD /GL /Iinclude src\add.cpp /Fo:build\add.obj
lib /nologo /OUT:build\add.lib build\add.obj
cl /nologo /EHsc /O2 /MD /GL /Iinclude src\main.cpp build\add.lib /link /LTCG /Fe:build\app.exe
/GL 生成的目标文件不能直接被不支持的链接器使用;静态库里的 /GL 对象在链接时由 /LTCG 处理。
LTO 的坑
- 编译器版本必须一致。GCC 的 LTO 中间表示不保证跨大版本兼容,用 GCC 12 编译的 LTO 对象不一定能被 GCC 13 链接
- 调试信息变差。变量可能被优化掉,断点位置可能偏移
- 链接变慢。LTO 把优化推迟到链接,链接时间会明显增加
- 符号可见性变化。LTO 可能把没有外部引用的符号内部化,导致
dlsym找不到 - 和
--gc-sections叠加。两者都做死代码消除,配置不当时可能删掉本该保留的入口
对第三方二进制库,不要假设它开了 LTO;对自己项目,先在 CI 里对比开启前后的体积、性能和调试体验,再决定是否默认开启。
4.7 库的裁剪与体积
库的体积来自四部分:代码、只读数据、异常/调试信息、符号表。裁剪要分别处理。
按函数切分节区
默认情况下,一个 .o 的所有函数在同一个 .text 节区里,链接器只能按"整个节区"决定去留。开启函数级节区:
g++ -O2 -ffunction-sections -fdata-sections -c src/add.cpp -o build/add.o
g++ -O2 -Wl,--gc-sections src/main.cpp build/add.o -o build/app
-ffunction-sections:每个函数一个.text.funcname-fdata-sections:每个变量一个.data.varname--gc-sections:从入口点出发做可达性分析,丢弃没被引用的节区
对静态库尤其有用:链接器拉进一个成员后,还能继续丢弃成员里没用的函数。
动态库的导出是根
--gc-sections 会把导出到 .dynsym 的符号当作根保留。但如果你用 -fvisibility=hidden 或版本脚本把符号藏起来,链接器可能认为某些函数没人用而删除。
需要保留的入口:
- 加
__attribute__((used)) - 在版本脚本里
global:列出 - 用
-Wl,--undefined=symbol强制保留 - 用
-Wl,--gc-keep-exported(较新的 binutils 支持)
删除调试信息和符号表
strip --strip-unneeded build/libadd.so.1.0.0
--strip-debug:只删调试节区--strip-unneeded:删链接不需要的符号,保留.dynsym--strip-all:连.dynsym一起删,动态库会废掉,不要用
保留独立调试文件:
objcopy --only-keep-debug build/libadd.so.1.0.0 build/libadd.so.debug
strip --strip-unneeded build/libadd.so.1.0.0
objcopy --add-gnu-debuglink=build/libadd.so.debug build/libadd.so.1.0.0
编译选项的取舍
| 选项 | 效果 | 代价 |
|---|---|---|
-Os |
优化体积 | 可能比 -O2 慢 |
-fno-exceptions |
去掉异常表和展开代码 | 不能抛异常,ABI 变化 |
-fno-rtti |
去掉 RTTI | 不能用 dynamic_cast/typeid,ABI 变化 |
-fno-asynchronous-unwind-tables |
去掉 .eh_frame |
异常和栈回溯失效 |
-gz |
压缩调试信息 | 调试器需要支持 |
-Wl,--build-id=none |
去掉 build-id | 调试符号定位变难 |
-s |
链接时删符号 | 不能调试 |
-fno-exceptions 和 -fno-rtti 只适合内部库或纯 C 接口库。公开的 C++ 库不要开,否则使用者无法正常使用异常和 RTTI,且 ABI 不兼容。
查看体积构成
size build/libadd.so
nm --size-sort -C build/libadd.so | tail -n 20
readelf -SW build/libadd.so | sort -k6 -n
bloaty 是更直观的工具:
bloaty build/libadd.so
它会按编译单元、节区、符号列出体积占比,适合定位"哪个模板实例把库撑大了"。
MSVC 的裁剪
cl /nologo /c /EHsc /O2 /MD /Gy /Iinclude src\add.cpp /Fo:build\add.obj
link /nologo /DLL /OPT:REF /OPT:ICF /OUT:build\add.dll build\add.obj /IMPLIB:build\add.lib
/Gy:函数级链接,每个函数单独 COMDAT/OPT:REF:删除未引用的函数和数据/OPT:ICF:合并相同的 COMDAT/GL+/LTCG:全程序优化,能进一步裁剪
/DEBUG 会保留调试信息,但不一定阻止 /OPT:REF;发布版可以同时开 /DEBUG 和 /OPT:REF,保留 PDB 用于事后分析。
4.8 部署清单
Linux
发布前逐项检查:
- soname 是否正确:
readelf -d libadd.so.1.0.0 | grep SONAME - 符号链接是否完整:
libadd.so、libadd.so.1、libadd.so.1.0.0三层 - 依赖是否齐全:
ldd app在干净环境里不出现not found - rpath 是否可移植:优先用
$ORIGIN,不要把构建机的绝对路径写死 - glibc 版本:构建机的 glibc 不能高于目标机
- libstdc++ 版本:同上,必要时
-static-libstdc++ - 调试信息:发布库
strip,调试文件单独归档 - 许可证:LGPL 库动态链接的分发义务
- 架构:
file确认 x86-64 / aarch64 正确 - 测试:在目标发行版的容器里跑一遍
# 检查依赖
readelf -d build/app | grep NEEDED
ldd build/app
# 检查 rpath
readelf -d build/app | grep -E 'RPATH|RUNPATH'
# 检查架构
file build/app build/libadd.so.1.0.0
# 检查符号
nm -D --defined-only build/libadd.so.1.0.0 | c++filt
如果发布后需要修改 rpath,可以用 patchelf:
patchelf --print-rpath build/app
patchelf --set-rpath '$ORIGIN/lib' build/app
chrpath 也能改,但只能把 rpath 改短,不能变长。
Windows
- 架构一致:exe 和所有 DLL 都是 x64 或都是 x86
- CRT 部署:
/MD需要 VC++ Redistributable,或改用/MT - 依赖齐全:
dumpbin /dependents app.exe里的每个 DLL 都能找到 - 不要依赖当前目录和 PATH:用应用目录 +
SetDefaultDllDirectories - Debug/Release 不混:发布包不能带
*d.dll、ucrtbased.dll - PDB 单独归档:不要随安装包发给最终用户
- manifest/SxS:需要并行程序集时,manifest 要随 exe 一起部署
- 代码签名:对外分发的 exe/DLL 做签名
- 干净机器测试:在没有装 Visual Studio 的虚拟机上跑一遍
dumpbin /dependents build\app.exe
dumpbin /headers build\app.exe | findstr machine
dumpbin /imports build\app.exe
用 Dependencies 工具查看依赖树,能直观看到哪个 DLL 缺失、哪个架构不对。
版本检查脚本
Linux 上可以在 CI 里跑:
set -e
readelf -d build/app | grep NEEDED
! ldd build/app | grep 'not found'
objdump -T build/app | grep GLIBC | sort -u
Windows 上可以用 PowerShell:
dumpbin /dependents build\app.exe
if ($LASTEXITCODE -ne 0) { exit 1 }
5. 排错速查表
| 现象 | 平台 | 常见原因 | 处理 |
|---|---|---|---|
undefined reference to 'add(int, int)' |
Linux | 没链接库、库顺序错、C/C++ 修饰不匹配 | 加 -ladd,调整顺序,加 extern "C" |
cannot find -ladd |
Linux | -L 路径错、缺 libadd.so 链接名 |
检查 -L,建符号链接 |
cannot open shared object file |
Linux | 运行时找不到 soname | 设 LD_LIBRARY_PATH、rpath、ldconfig |
symbol lookup error: undefined symbol |
Linux | 库版本旧、符号被隐藏、依赖缺失 | nm -D、ldd -r、LD_DEBUG=bindings |
version 'GLIBC_2.xx' not found |
Linux | 构建环境 glibc 比运行环境新 | 老环境构建、容器、静态链接 |
version 'GLIBCXX_3.4.xx' not found |
Linux | libstdc++ 版本旧 | -static-libstdc++、升级运行库 |
relocation R_X86_64_32S ... -fPIC |
Linux | 动态库目标文件不是 PIC | 加 -fPIC 重编 |
relocation R_X86_64_32 ... PIE |
Linux | 静态库不是 PIC/PIE | 重编 -fPIE,或 -no-pie |
multiple definition |
Linux | 重复定义、头文件里定义变量 | inline、extern、匿名命名空间 |
cannot allocate memory in static TLS block |
Linux | dlopen 的库用了 initial-exec TLS |
改 TLS 模型、提前加载、调大预留 |
LNK2019 |
Windows | 没链接 .lib、名字/修饰不匹配、架构不对 |
检查导入库、dumpbin /exports、架构 |
LNK2001 |
Windows | 数据符号未定义、缺 dllimport |
定义变量、加 dllimport |
LNK2005 |
Windows | 重复定义、/MT 与 /MD 混用 |
统一运行库、删除重复定义 |
LNK1104 |
Windows | 找不到 .lib |
检查路径、先生成库 |
LNK2038 |
Windows | RuntimeLibrary 或迭代器调试级别不匹配 |
统一 /MT//MD 和 Debug/Release |
LNK1112 |
Windows | x86/x64 混链 | 统一工具链和库架构 |
0xc000007b |
Windows | 32/64 位 DLL 混用、DLL 损坏 | dumpbin /headers 查架构 |
0x8007007e |
Windows | LoadLibrary 找不到模块或依赖 |
dumpbin /dependents、Process Monitor |
The procedure entry point ... |
Windows | DLL 版本与导入库不匹配 | 部署匹配版本的 DLL |
GetProcAddress 返回 NULL |
Windows | 导出名不对、没导出、序号错 | dumpbin /exports 对照 |
| DLL 加载了错误版本 | Windows | 搜索路径里有同名 DLL | 用完整路径、SetDefaultDllDirectories |
| 跨 DLL 释放内存崩溃 | Windows | /MT 堆隔离、CRT 不一致 |
统一 /MD、谁分配谁释放 |
dlopen 后调用崩溃 |
Linux | 库被 dlclose 卸载、线程还在库内 |
延迟卸载、RTLD_NODELETE、join 线程 |
| 静态库符号没被链接 | 两平台 | 成员没有被引用 | --whole-archive / /WHOLEARCHIVE |
| 可执行文件异常大 | 两平台 | 静态链接、调试信息、未裁剪 | -ffunction-sections --gc-sections、/OPT:REF、strip |
注:排错时先确定错误发生在哪个阶段——编译、链接、加载还是运行。四类错误的排查手段完全不同:编译看头文件和选项,链接看符号和库,加载看搜索路径和依赖,运行看 ABI 和内存。把阶段分清楚,能省掉大量猜测。
6. 完整命令清单
这一节把前面用到的命令集中列出,方便复制。所有命令都假设在工程根目录执行,源码结构同开头。
6.1 GCC 静态库
# 1. 编译
g++ -O2 -Wall -Wextra -Iinclude -c src/add.cpp -o build/add.o
# 2. 打包
ar rcs build/libadd.a build/add.o
# 3. 链接
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_static
# 4. 检查
ar t build/libadd.a
nm -C build/libadd.a
ldd build/app_static
6.2 GCC 动态库
# 1. 编译 PIC 目标文件
g++ -O2 -Wall -Wextra -fPIC -Iinclude -c src/add.cpp -o build/add.o
# 2. 生成动态库,写 soname
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add.o
# 3. 建链接名
ln -sf libadd.so.1.0.0 build/libadd.so.1
ln -sf libadd.so.1 build/libadd.so
# 4. 链接可执行文件,写 rpath
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
-Wl,-rpath,'$ORIGIN' -o build/app_shared
# 5. 检查
readelf -d build/libadd.so.1.0.0 | grep SONAME
readelf -d build/app_shared | grep NEEDED
ldd build/app_shared
nm -D --defined-only build/libadd.so.1.0.0 | c++filt
6.3 GCC 运行时加载
g++ -O2 load.cpp -ldl -o build/load
LD_LIBRARY_PATH=build ./build/load
6.4 MSVC 静态库
:: 1. 编译
cl /nologo /c /EHsc /O2 /MD /Iinclude src\add.cpp /Fo:build\add.obj
:: 2. 打包
lib /nologo /OUT:build\add.lib build\add.obj
:: 3. 链接
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib /Fe:build\app_static.exe
:: 4. 检查
lib /list build\add.lib
dumpbin /symbols build\add.lib
dumpbin /dependents build\app_static.exe
6.5 MSVC DLL
:: 1. 生成 DLL、导入库(需要源文件里用 __declspec(dllexport),或用 .def)
cl /nologo /LD /EHsc /O2 /MD /Iinclude /DADD_EXPORTS src\add.cpp ^
/Fe:build\add.dll /link /IMPLIB:build\add.lib
:: 2. 链接使用者
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib ^
/Fe:build\app_shared.exe
:: 3. 检查
dumpbin /exports build\add.dll
dumpbin /imports build\app_shared.exe
dumpbin /dependents build\app_shared.exe
6.6 MSVC 运行时加载
cl /nologo /EHsc /O2 /MD load.cpp /Fe:build\load.exe
build\load.exe
6.7 关键检查点
| 检查项 | GCC | MSVC |
|---|---|---|
| 目标文件类型 | file add.o |
dumpbin /headers add.obj |
| 静态库成员 | ar t libadd.a |
lib /list add.lib |
| 符号 | nm -C、nm -D |
dumpbin /symbols、/exports |
| 动态依赖 | readelf -d、ldd |
dumpbin /dependents、/imports |
| rpath | readelf -d、patchelf |
无(用应用目录/清单) |
| 架构 | file |
dumpbin /headers |
| 初始化顺序 | readelf -x .init_array |
DllMain、全局构造 |
| 符号可见性 | nm -D --defined-only |
dumpbin /exports |


