一、可执行文件格式对照表¶
| 平台 | 格式 | 全称 | 覆盖的文件 |
|---|---|---|---|
| Windows | PE | Portable Executable | .exe、.dll、.sys(驱动)、.efi |
| Linux/Android/BSD | ELF | Executable and Linkable Format | 可执行文件(无后缀)、.so、.o、内核模块 .ko |
二、符号表¶
| 概念 | 一句话定义 | 通俗类比 |
|---|---|---|
| 完整符号表 | 这个二进制里所有函数/变量的"名字↔地址"总账,含纯内部使用的 | 公司全员通讯录(含每个工位分机) |
| 动态符号表 | 只记跨模块边界符号的精简账本:我对外提供的 + 我需要外部提供的 | 公司前台名片盒(对外窗口岗+供应商联系单,合订) |
| 导出表 | "我对外提供哪些接口"的清单:名字 + 在我体内的位置 | 门口挂的服务项目价目表 |
| 导入表 | "我需要外部提供什么"的清单:缺哪些符号、从哪些库拿、拿到后填到哪 | 采购清单(+送货地址) |
四者的逻辑关系:导出表和导入表是"方向视角",动态符号表是这两个视角的信息源之一,完整符号表是包含前述一切、外加全部内部细节的超集。平台差异只在于:把哪个视角做成独立实体、把哪个留作逻辑筛选。
ELF(linux) / PE(windows) 符号结构对照
| Linux(ELF:.so / 可执行文件) | Windows(PE:.dll / .exe) | |
|---|---|---|
| 完整符号表 | 实体:.symtab 节(+.strtab) | 不在最终文件里:链接时就分离到外置 PDB 文件 |
| ├ 谁用 | 调试器、addr2line 解栈 | WinDbg、VS 调试器 |
| ├ 交付时 | strip 删除;剥前拷贝留档(.sym) | 天生不用剥;PDB 内部留存、不随产品发 |
| 动态符号表 | 实体:.dynsym 节(+.dynstr+.gnu.hash),映射进内存、动态段指针可达 | 无此合订实体——职能被拆成下面两张独立表 |
| ├ 谁用 | 动态链接器每次加载必用;崩溃栈 | —(对应职能由导出表+导入表分担) |
| 导出表 | 非实体:动态符号表中"已定义+全局可见"条目的逻辑子集;范围由 version script / visibility 圈定 | 实体:PE 内部的 Export Directory 结构;范围由 dllexport / .def 圈定。另派生导入库 .lib(给链接器的离线索引,非导出表本身) |
| 导入表 | 非实体,三件拼合:依赖库清单(动态段 DT_NEEDED)+缺失符号(动态符号表 UND 条目)+回填账本(重定位表→GOT/PLT) | 实体:PE 内部的 Import Directory(按 dll 分组的符号清单)+IAT(回填槽位表) |
| ├ 特色 | 记名不记源:符号按全局加载顺序搜索,谁先提供算谁的(LD_PRELOAD 劫持由此而来) | 按库定向:每个符号钉死"来自哪个 dll" |
本章节,最终为了导出的结论是,发版的so需要通过strip命令去除内部符号表,仅保留动态符号表。但要留档strip前的库,可用于排查崩溃堆栈的分析。
Linux 下用 binutils 的 strip 工具去掉库中的符号表和调试信息,减小体积:
三、链接方式¶
静态链接: 即静态库链接, windows的.lib和linux的.a。编译时程序拷贝进exe中。 隐式动态链接: windows的.lib+.dll和linux的.so。编译时通过-l指定,进程启动时加载器绑定。 显示动态链接:windows的.dll和linux的.so。运行时,通过windows的LoadLibrary 和 linux的dlopen+dlsym加载。
Windows 下这两个文件的分工,一句话版本:.dll 是运行时用的"正品",.lib 是构建期用的"辅助文件"。但 .lib 有个大坑:作为静态库时.lib等同于.a。在隐式动态链接时,则可以理解为导出表。
四、接口可见性¶
工业界的完全体其实是三管齐下:-fvisibility=hidden(拿优化收益)+ --exclude-libs,ALL(堵静态库)+ version script(最终白名单审计+版本化)——三道门互为冗余,任何一道漏了都有下一道兜底。
4.1 背景¶
Linux 上编译动态库时,所有全局符号默认都进入动态符号表的导出子集——内部类的每个方法、没加 static 的辅助函数,全部对外可见可链接。后果:
| 问题 | 说明 |
|---|---|
| ABI 面失控 | 使用方可能链接你的内部函数;日后重构内部实现即破坏"兼容性" |
| 符号冲突 | 你的内部符号可能与宿主进程或其他库同名,按加载顺序互相劫持(ELF 符号插入机制) |
| 加载变慢 | 动态符号表越大,动态链接器启动时的解析与重定位越多 |
| 逆向更易 | 内部函数名对外可见,等于送出实现结构图 |
| 优化受限 | 编译器必须假设符号可能被外部替换,库内调用走 PLT 间接、放弃内联 |
Windows 恰好相反:DLL 默认什么都不导出,必须 __declspec(dllexport) 显式放行。编译期可见性方案本质上就是把这种"白名单"模型带到 Linux。
下文只考虑linux平台控制输出符号可见性的配置。
4.2 version script 方案¶
C接口形式实现的全局函数,通过命令参数和接口文件实现外部接口的可见性。
#cmake
set_target_properties(issvpr PROPERTIESLINK_FLAGS "-Wl,--version-script,<filename>")
#cmd
g++ -shared *.o -Wl,--version-script,f -o <libname>
文件内容参考:
4.3 全局关闭局部放开方案¶
4.3.1 要素一:全局默认改为隐藏(编译选项)¶
-fvisibility=hidden:本编译单元所有符号默认 hidden,链接成 so 时不进导出子集;-fvisibility-inlines-hidden(C++):内联成员函数、模板实例同样隐藏(它们不被上一开关完全覆盖,需单独指定)。
4.3.2 要素二:公开 API 逐个放行(导出宏)¶
跨平台导出宏的标准样板(公开头文件中):
/* mylib_export.h —— 三分支:编库 / 用库 / 静态链接 */
#if defined(_WIN32)
#if defined(MYLIB_STATIC) /* 编/用静态库:无需修饰 */
#define MYLIB_API
#elif defined(MYLIB_BUILDING) /* 正在编 DLL */
#define MYLIB_API __declspec(dllexport)
#else /* 正在用 DLL */
#define MYLIB_API __declspec(dllimport)
#endif
#else
#if __GNUC__ >= 4
#define MYLIB_API __attribute__((visibility("default")))
#else
#define MYLIB_API
#endif
#endif
/* mylib.h —— 对外头文件 */
#include "mylib_export.h"
#ifdef __cplusplus
extern "C" { /* C API:符号名不做 C++ 修饰,跨编译器 ABI 稳定 */
#endif
MYLIB_API int mylib_init(const char* config_path);
MYLIB_API int mylib_process(const void* data, int len);
#ifdef __cplusplus
}
#endif
构建库时由构建系统注入 -DMYLIB_BUILDING;使用方直接 include,自动落到 import/普通分支。要点:
- 在声明处标注即可(头文件),实现处可标可不标;
visibility("default")里的 "default" 指"恢复为默认可见性等级"(即可导出),不是"取默认行为"——语义上就是白名单放行;- 若导出 C++ 类,把属性标到类上:
class MYLIB_API Widget { ... };(整类方法+typeinfo 一起放行)。
4.3.3 要素三:堵住静态库的泄漏口(链接选项)¶
-fvisibility=hidden 只影响你自己编译的代码。链接进来的预编译静态库(.a)的目标文件是别人早已编好的、默认可见——会原样泄进导出面。链接时补:
--exclude-libs,ALL 表示"凡是来自静态库的符号一律不导出"(也可指定具体库名,逗号分隔)。
4.3.4 最小可复现示例¶
/* mylib.c */
#include "mylib.h"
static int helper_static(void) { return 1; } /* static:本来就不可见 */
int helper_global(void) { return 2; } /* 全局但未标宏:将被隐藏 */
MYLIB_API int mylib_init(const char* p) { (void)p; return helper_global(); }
# 不加可见性控制(默认全导出):
gcc -fPIC -shared mylib.c -DMYLIB_BUILDING -o libmylib.so
nm -D libmylib.so | grep -v ' U '
# T helper_global ← 内部函数泄漏
# T mylib_init
# 加上可见性控制:
gcc -fPIC -shared -fvisibility=hidden mylib.c -DMYLIB_BUILDING -o libmylib.so
nm -D libmylib.so | grep -v ' U '
# T mylib_init ← 只剩白名单
4.4 CMake 集成写法¶
add_library(mylib SHARED src/mylib.c)
target_compile_definitions(mylib PRIVATE MYLIB_BUILDING) # 注入"我在编库"开关
set_target_properties(mylib PROPERTIES
C_VISIBILITY_PRESET hidden # ≈ -fvisibility=hidden(C)
CXX_VISIBILITY_PRESET hidden # ≈ -fvisibility=hidden(C++)
VISIBILITY_INLINES_HIDDEN ON) # ≈ -fvisibility-inlines-hidden
target_link_options(mylib PRIVATE -Wl,--exclude-libs,ALL)
CMake 还提供 include(GenerateExportHeader) + generate_export_header(mylib),可自动生成上文的导出宏头文件,免手写样板。
五、项目内模块可见性¶
5.1 这套机制是什么,不是什么¶
CMake 的 target_include_directories / target_link_libraries / target_compile_definitions 上的
PUBLIC / PRIVATE / INTERFACE 关键字,解决的是项目内多模块开发时的依赖卫生问题:
每个模块声明"哪些东西是我对外接口的一部分,哪些是我的实现细节", 构建系统据此决定属性沿依赖链的传播范围,脱节的依赖在第一次编译时就报错。
它是构建期的君子协定(防"不小心",防不了"故意"——下游改一行 CMakeLists 就能绕过), 与对外接口隔离设计(防"故意",交付期的物理边界)是两回事,见文末对比。
三个关键字回答同一个问题:"这个属性给谁用?"
| 关键字 | 我自己编译用 | 传播给链接我的人 | 典型场景 |
|---|---|---|---|
PRIVATE |
✓ | ✗ | 实现细节(默认首选) |
PUBLIC |
✓ | ✓ | 暴露在我公开头文件里的东西 |
INTERFACE |
✗ | ✓ | header-only 库(自己没有编译产物) |
统一判断口诀:打开自己的公开头文件看一眼——
- 头文件里
#include了谁的头 → 那个库PUBLIC - 头文件里
#ifdef了哪个宏(宏影响 API 形状)→ 那个宏PUBLIC - 只在 .cpp 里出现的库/宏/路径 →
PRIVATE
5.2 完整 Demo¶
彩色打印程序,四个 target,三条 target_* 命令的关键字各有用武之地:
目录约定:public/ 放对外头文件,include/ 放内部共享头文件,src/ 放实现。
demo/
├── CMakeLists.txt
├── app/
│ └── main.cpp # 最终用户,只认识 printer
├── color/
│ ├── public/color/color.h # 对外 API 头(含一个 PUBLIC 宏开关)
│ ├── include/
│ │ └── rgb_convert.h # 内部中间件头,仅库内 .cpp 共享
│ └── src/
│ ├── color.cpp
│ └── rgb_convert.cpp
├── timeutil/
│ ├── timeutil.h
│ └── timeutil.cpp # printer 的私有依赖
└── printer/
├── public/printer/printer.h # 对外 API 头
└── src/printer.cpp # printer 没有内部共享头,故无 include/
命名约定说明:开源社区更常见的是"
include/即公开目录"(fmt、gtest 都如此), 内部头直接和 .cpp 混在 src/ 里。本文采用 public/(对外)+ include/(对内) 的公司级约定,内外物理分明。机制上叫什么名字无所谓——真正决定可见性的 是 CMake 里哪个目录标了 PUBLIC、哪个标了 PRIVATE,目录名只是给人看的。
依赖关系与可见性一图流:
app ──PRIVATE──> printer ──PUBLIC──> color ✓ app 能 include color.h、感知 COLOR_ENABLE_BRIGHT 宏
│ └─(内部) rgb_convert.h ✗ 外部看不见(include/ 是 PRIVATE 目录)
└────PRIVATE──> timeutil ✗ app 看不见 timeutil.h
└────PRIVATE 宏 PRINTER_TIME_FORMAT ✗ 不泄漏给 app
5.2.1 color/public/color/color.h(对外头文件)¶
#pragma once
enum class Color { Red, Green, Blue };
const char* colorCode(Color c);
// 这个宏出现在公开头文件里,直接决定 API 的形状(多不多一个函数)。
// 因此该宏必须对"用 color 的人"同样可见 → CMake 里标 PUBLIC。
#ifdef COLOR_ENABLE_BRIGHT
const char* brightColorCode(Color c); // 高亮色版本
#endif
5.2.2 color/include/rgb_convert.h(内部中间件头)¶
#pragma once
// 内部工具:把 RGB 分量拼成 ANSI 真彩色转义码。
// 供 color 库内部多个 .cpp 共享,不属于对外 API。
const char* rgbToAnsi(int r, int g, int b);
5.2.3 color/src/rgb_convert.cpp¶
#include "rgb_convert.h"
#include <cstdio>
const char* rgbToAnsi(int r, int g, int b) {
static char buf[32]; // demo 从简,非线程安全
std::snprintf(buf, sizeof(buf), "\033[38;2;%d;%d;%dm", r, g, b);
return buf;
}
5.2.4 color/src/color.cpp¶
#include "color/color.h"
#include "rgb_convert.h" // 内部头:能找到它,靠的是 PRIVATE 的 include/ 搜索路径
const char* colorCode(Color c) {
switch (c) {
case Color::Red: return rgbToAnsi(205, 49, 49);
case Color::Green: return rgbToAnsi(13, 188, 121);
case Color::Blue: return rgbToAnsi(36, 114, 200);
}
return "";
}
#ifdef COLOR_ENABLE_BRIGHT
const char* brightColorCode(Color c) {
switch (c) {
case Color::Red: return rgbToAnsi(241, 76, 76);
case Color::Green: return rgbToAnsi(35, 209, 139);
case Color::Blue: return rgbToAnsi(59, 142, 234);
}
return "";
}
#endif
5.2.5 timeutil/timeutil.h / timeutil.cpp¶
// timeutil.h
#pragma once
#include <string>
std::string timestamp(const char* fmt); // fmt 形如 "%H:%M:%S"
// timeutil.cpp
#include "timeutil.h"
#include <ctime>
std::string timestamp(const char* fmt) {
char buf[32];
std::time_t t = std::time(nullptr);
std::strftime(buf, sizeof(buf), fmt, std::localtime(&t));
return buf;
}
5.2.6 printer/public/printer/printer.h(对外头文件)¶
#pragma once
#include <string>
#include <color/color.h> // 公开头引用了 color → color 必须是 PUBLIC 依赖
// 注意:这里没有 timeutil.h,也没有 PRINTER_TIME_FORMAT
class Printer {
public:
void print(const std::string& msg, Color c);
};
5.2.7 printer/src/printer.cpp¶
#include "printer/printer.h"
#include "timeutil.h" // 只在实现里用 → timeutil 是 PRIVATE 依赖
#include <iostream>
#ifndef PRINTER_TIME_FORMAT // 只在本 .cpp 里用的宏 → PRIVATE
#define PRINTER_TIME_FORMAT "%H:%M:%S" // 没从 CMake 传进来时的兜底
#endif
void Printer::print(const std::string& msg, Color c) {
std::cout << timestamp(PRINTER_TIME_FORMAT) << " "
<< colorCode(c) << msg << "\033[0m" << std::endl;
}
5.2.8 app/main.cpp¶
#include <printer/printer.h>
#include <iostream>
int main() {
Printer p;
p.print("hello", Color::Green);
// COLOR_ENABLE_BRIGHT 是 color 的 PUBLIC 宏,
// 顺着 app → printer → color 传播到这里,本分支会被编译进去:
#ifdef COLOR_ENABLE_BRIGHT
std::cout << brightColorCode(Color::Red) << "bright red" << "\033[0m\n";
#endif
// PRINTER_TIME_FORMAT 是 printer 的 PRIVATE 宏,这里看不见:
#ifdef PRINTER_TIME_FORMAT
std::cout << "leaked!\n"; // 永远不会被编译进去
#endif
return 0;
}
5.2.9 CMakeLists.txt¶
cmake_minimum_required(VERSION 3.15)
project(visibility_demo CXX)
set(CMAKE_CXX_STANDARD 17)
# ============ color ============
add_library(color
color/src/color.cpp
color/src/rgb_convert.cpp
)
target_include_directories(color
PUBLIC color/public # 对外头目录:用户由此 #include <color/color.h>
PRIVATE color/include # 内部头目录:rgb_convert.h 只有 color 自己可见
)
# 宏出现在公开头文件的 #ifdef 里,决定 API 形状。
# 若只标 PRIVATE:color.cpp 编译出了 brightColorCode,
# 但用户看到的头文件里该声明被预处理掉 → 库和用户对 API 的理解不一致。
target_compile_definitions(color PUBLIC COLOR_ENABLE_BRIGHT)
# ============ timeutil ============
add_library(timeutil timeutil/timeutil.cpp)
target_include_directories(timeutil PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/timeutil)
# ============ printer ============
add_library(printer printer/src/printer.cpp)
target_include_directories(printer PUBLIC printer/public)
target_link_libraries(printer
PUBLIC color # printer.h 引用了 color/color.h → 契约的一部分
PRIVATE timeutil # 只有 printer.cpp 用 → 实现细节
)
# 时间格式只影响 printer.cpp 的编译 → PRIVATE,不泄漏、改格式不触发下游重编
target_compile_definitions(printer PRIVATE "PRINTER_TIME_FORMAT=\"%H:%M:%S\"")
# ============ app ============
add_executable(app app/main.cpp)
target_link_libraries(app PRIVATE printer)
构建运行:
cmake -B build && cmake --build build
./build/app
# 12:30:05 hello (绿色)
# bright red (高亮红 —— PUBLIC 宏传播的证据)
# 注意没有 "leaked!" (PRIVATE 宏被隔离的证据)
5.3 四个验证实验¶
| # | 操作 | 结果 | 说明的问题 |
|---|---|---|---|
| 1 | 什么都不改,直接编译运行 | app 输出 bright red;不输出 leaked! | PUBLIC 宏沿 app→printer→color 传播;PRIVATE 宏止步于 printer |
| 2 | 在 main.cpp 加 #include "rgb_convert.h" |
编译错误:No such file or directory | PRIVATE 的 include/ 目录不传播,内部中间件头对外不可达 |
| 3 | 在 main.cpp 加 #include "timeutil.h" |
编译错误:No such file or directory | PRIVATE 依赖的头文件路径止步于 printer |
| 4 | 把 printer 的 PUBLIC color 改成 PRIVATE color |
printer 自身编译通过,app 编译失败:找不到 color/color.h | 代码事实(printer.h 引用了 color)与构建声明矛盾时,编译器当场抓出 |
实验 4 的两种修法对应两种设计决策:
- 承认暴露:改回 PUBLIC ——"color 就是 printer API 的一部分";
- 消除暴露:改造 printer.h 不再引用 color(前置声明/Pimpl),然后理直气壮地 PRIVATE。
5.4 target_compile_definitions 的关键字判断¶
和头文件、链接库同一条口诀,但宏的后果更隐蔽,单独强调:
- 宏出现在公开头文件里(
#ifdef影响声明的存在与形状)→ 必须 PUBLIC。 否则库和用户以不同的宏环境预处理同一个头文件,看到两个不同的 API, 轻则链接错误,重则 ODR 违规(同一个类在两边布局不同)导致未定义行为。 - 宏只影响 .cpp 的编译 → PRIVATE。
好处:不污染下游(
NOMINMAX、_USE_MATH_DEFINES、-ffast-math这类 PUBLIC 泄漏会悄悄改变别人代码的编译语义,极难排查); 改宏值只重编本模块,不触发下游重编风暴。
5.5 与"对外接口可见性设计"的区别¶
| 项目内模块可见性 | 对外接口隔离 | |
|---|---|---|
| 作用时机 | 构建期(同一源码树内) | 交付期(SDK/库发布) |
| 本质 | 君子协定:声明依赖边界,脱节即编译报错 | 物理边界:内部之物在发布包里不可达 |
| 防什么 | 防"不小心"(蹭依赖、偷用内部头、宏泄漏) | 防"故意"(用户 extern 硬链内部符号) |
| 绕过难度 | 改一行 CMakeLists 即可绕过 | 头文件物理不存在、符号不导出,无法绕过 |
| 手段 | PUBLIC/PRIVATE/INTERFACE 关键字 | ① install() 只安装公开头(内部头根本不发布)② 动态库 CXX_VISIBILITY_PRESET hidden + 导出宏(GenerateExportHeader)③ SONAME/符号版本脚本 |
两者的关系:PUBLIC/PRIVATE 负责"声明"边界(开发期可见、可自动传播,配合
install(EXPORT) 还能原样传递给 find_package 的外部用户);install 裁剪 +
符号可见性负责"执行"边界(物理上不可达)。只有声明没有执行,是纸面隔离;
只有执行没有声明,项目内部早在交付前就先烂掉了。
补充两点:
- 静态库(.a) vs 动态库(.so):本文的头文件可见性、链接需求两层对两者完全一致;
但符号隐藏(visibility=hidden)只在 .so 边界真实生效——.a 只是 .o 的打包,
链接它时 hidden 不拦截符号解析,
extern硬链总能成功。这是商业 SDK 倾向只发 .so 的原因之一。 - 防呆清单(这套关键字在项目内每天防住的事故): ① 偷用他人内部头(重构时爆雷) ② 蹭传播来的依赖(未声明的依赖,上游清理 PUBLIC 时爆雷) ③ 宏/编译选项外泄改变下游代码语义 ④ 全局 include 目录导致同名头文件张冠李戴 ⑤ 内部头改动触发全项目重编 共同病根:实际依赖与声明依赖脱节——关键字让脱节在当天的编译里报错, 而不是留给半年后接手的人考古。
六、预编译¶
TODO