跳转至

一、可执行文件格式对照表

平台 格式 全称 覆盖的文件
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 工具去掉库中的符号表和调试信息,减小体积:

strip --strip-unneeded libfoo.so     # 动态库的标准做法
strip --strip-debug   libfoo.a       # 静态库只能去调试信息!

三、链接方式

静态链接: 即静态库链接, 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>

文件内容参考:

{
global:
  api_function_name1;
  api_function_name2;
local: *;
};

4.3 全局关闭局部放开方案

4.3.1 要素一:全局默认改为隐藏(编译选项)

gcc -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -c src.c
  • -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)的目标文件是别人早已编好的、默认可见——会原样泄进导出面。链接时补:

gcc -shared *.o -Wl,--exclude-libs,ALL -o libmylib.so -lthird_a -lthird_b

--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