说实话,spdlog 这库用起来是真的香,但“编译”这一步卡住过不少人。社区里到处都在说它是 header-only,拿过来 include 就能用,结果真到了项目里一编译,冒出来一堆链接错误、宏未定义、fmt 版本冲突,心态直接崩。我自己在 Windows 和 Linux 两个平台上都折腾过 spdlog 的编译,踩过的坑不算少,所以这篇就把整个编译处理的流程、CMake 开关、集成方式和报错排查链路完整串一遍。无论你只是想把 spdlog 编成静态库丢给项目用,还是想搞清楚那些 SPDLOG_COMPILED_LIB、SPDLOG_FMT_EXTERNAL 到底是什么意思,这篇文章都值得你花十分钟读完。
1. 先搞清楚一个关键问题:spdlog 到底要不要“编译”
1.1 header-only 模式是怎么回事
spdlog 默认确实支持 header-only,也就是说你把 include 目录加进工程,直接 #include <spdlog/spdlog.h>,就能正常创建 logger、写日志,不需要链接任何额外的库文件。它的实现思路很简单:所有模板代码、函数定义全部放在头文件里,编译器在每个翻译单元中直接展开实例化。对小型测试程序、工具脚本来说,这种模式极度方便,不需要处理任何构建依赖。
但方便是有代价的。每个包含了 spdlog 头文件的 .cpp 文件,都会独立编译一份完整的日志实现代码。假设你的项目有 50 个源文件,每个文件都 include 了 spdlog,那么同样的代码就被完整编译 50 遍。体现在构建体验上就是:全量编译时间明显拉长,增量编译时只要你动了和 spdlog 相关的公共头文件,基本等于全量重编。最终的二进制体积也会大一圈,因为每个编译单元里的日志代码副本都留在了目标文件里,链接器虽然能合并部分符号,但编译期的开销是实打实的。
1.2 compiled mode 的价值和适用场景
compiled mode(编译成库)就是解决上面那些问题的。spdlog 官方在源码里提供了 src/spdlog.cpp,里面显式实例化了所有核心代码。你用 CMake 构建时,他会把这段代码编译成独立的静态库或动态库,然后项目里只需要保留一份编译产物,头文件只负责声明接口。使用方需要额外定义一个 SPDLOG_COMPILED_LIB 宏,告诉库的头文件“我正在链接编译好的 spdlog,不要再按 header-only 的方式展开实现”。
这么做带来的直接好处有三点。第一,多源文件项目的编译时间大幅下降,我从 header-only 切到 compiled mode 之后,一个 40 个源文件的 C++ 项目全量编译时间从 6 分多钟降到了不到 2 分钟,提升是肉眼可见的。第二,动态库模式下,spdlog 的符号只存在于单独的 .dll 或 .so 文件中,多个子模块共享同一份日志实现,内部状态(比如全局注册表、sink 集合)保持一致,日志行为不会因为模块边界被拆散。第三,对嵌入式、游戏客户端这类对二进制体积敏感的场景,把日志库抽成独立模块,更容易控制最终产物体积和内存占用。
1.3 什么时候选哪种方案,我的建议
我个人的判断标准其实很简单。单文件工具、单元测试、快速原型,直接 header-only,没必要为编译一个库增加心智负担。正式的产品项目,只要源文件数量超过 20 个,或者存在多个动态库模块需要共享日志,一律编译成静态库或动态库。还有一种折中方案:项目本身用 header-only,但把 #include <spdlog/spdlog.h> 放进统一的预编译头文件(PCH)里,也能显著降低重复编译开销,这一招在 Windows + MSVC 环境下效果特别明显。
提示:spdlog 的官方 README 其实已经写清楚了,header-only 模式适合小项目和入门,严谨的生产环境建议使用 compiled mode。官方默认的 CMake 配置也倾向于编译成库,这一点从 CMakeLists.txt 里的默认选项就能看出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 编译前的准备工作:源码版本、工具链和依赖关系
2.1 源码获取方式与版本选择的坑
获取 spdlog 源码的方式主要有三种:GitHub Releases 页面下载压缩包、git clone 官方仓库(注意子模块)、或者通过 vcpkg / Conan 包管理器拉取。如果你只是要编译库文件,随便哪种都可以,但版本选择上有个细节容易被忽略:spdlog 的版本迭代中,CMake 最低版本要求、fmt 内部版本、C++ 标准要求都在变。比如 spdlog 1.12 之后要求 CMake 3.16+,而老版本的 1.9 只需要 CMake 3.10。如果你的构建服务器上 CMake 版本比较旧,直接拉最新版源码很可能第一步 configure 就报错。
所以我建议先确认两点:你本机 CMake 版本是多少,编译器支持到哪个 C++ 标准。然后去 GitHub Releases 挑一个与工具链匹配的版本,不要一上来就追最新。以我当时的使用经验,spdlog 1.11.0 是个非常稳定的版本,对 CMake 版本要求不高,fmt 内置版本也比较协调,如果项目没有特殊需求,从 1.11 或 1.12 入手最稳。
2.2 编译器与 C++ 标准的最低要求
spdlog 主体代码基于 C++11 编写,所以 GCC 4.8+、Clang 3.5+、MSVC 2015+ 理论上都能编译通过。但如果你开启了 SPDLOG_USE_STD_FORMAT 选项,也就是使用 C++20 标准库的 std::format 替代内置 fmt,编译器就必须完整支持 C++20,这基本意味着 GCC 13+、Clang 16+、MSVC 2022 17.4+。别小看这个选项,它直接决定了你用的语言标准。我见过同事在 GCC 11 上强行开启 std::format,结果一堆 std::format 相关报错,最后老老实实切回内置 fmt。
除了编译器版本,构建系统生成的工程类型也会影响后续集成方式。Windows 下用 Visual Studio 编译时,解决方案的 Platform Toolset 必须和你的主项目一致。比如你主项目用 v143 工具集,spdlog 编译时也必须是 v143,混用 v142 和 v143 编译出来的静态库,链接阶段大概率会出现符号不一致的报错。
2.3 fmt 库依赖的来龙去脉
spdlog 内部日志格式化依赖 fmt 库。市面上常见的误解是要额外安装 fmt,其实 spdlog 源码里已经内置了一份 fmt 的实现(在 include/spdlog/fmt/ 目录下),默认编译时用的就是这份内置版本,不需要你单独安装。CMake 里有一个 SPDLOG_FMT_EXTERNAL 选项,设置为 ON 后才使用外部安装的 fmt 库。
什么时候需要打开 SPDLOG_FMT_EXTERNAL?最典型的情况是你的项目本身已经用了某个版本的 fmt,并且这个版本和 spdlog 内置的 fmt 版本不一致。这种情况下如果 spdlog 也用内置版本,两套 fmt 符号同时在程序里,轻则编译警告,重则因为 ABI 不匹配产生诡异崩溃。解决思路就是让 spdlog 使用项目里的同一份 fmt,保证版本统一。但注意,外部 fmt 的版本不能太老,spdlog 官方会标明每个版本推荐的外部 fmt 版本范围,最好对齐到该版本附近,别差太远。
3. CMake 配置逐项拆解:每个开关背后的逻辑
3.1 最基础的编译命令,先把它跑通
假设你已经拿到了源码并解压到 spdlog 目录,进入源码根目录,执行下面这套命令:
bash复制cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DSPDLOG_BUILD_EXAMPLE=OFF \
-DSPDLOG_BUILD_TESTS=OFF \
-DSPDLOG_BUILD_BENCH=OFF \
-DSPDLOG_INSTALL=ON
cmake --build build --config Release -j4
-S . -B build 指定源码目录和构建目录,CMAKE_BUILD_TYPE 只在单配置生成器(比如 Makefile、Ninja)下有意义,Visual Studio 这种多配置生成器会忽略它,改用 --config Release 指定。SPDLOG_BUILD_EXAMPLE、SPDLOG_BUILD_TESTS、SPDLOG_BUILD_BENCH 这三个选项分别控制是否构建示例、测试和性能基准,编译库文件的时候全部关掉,能节省大量时间。SPDLOG_INSTALL=ON 会让 CMake 生成 install 目标,方便后面把产物安装到指定目录。
跑完之后,在 build 目录下应该能看到 libspdlog.a(或 spdlog.lib),如果你的 CMake 配置里打开了共享库选项,Windows 下还会生成 spdlog.dll 和导入库 spdlog.lib。到这一步,最简单的库文件就编出来了。
3.2 静态库还是动态库:SPDLOG_BUILD_SHARED 的选择
CMake 里控制动静库的开关是 SPDLOG_BUILD_SHARED,默认 OFF,也就是默认编译静态库。需要动态库时打开:
bash复制cmake -S . -B build -DSPDLOG_BUILD_SHARED=ON
怎么选?我的经验是:你的主程序是单个可执行文件,用静态库最简单,部署时不需要额外带 dll;如果项目拆成了多个 dll/so 模块,而且希望所有模块共享同一个日志实例,用动态库更合适,否则每个模块若静态链接一份 spdlog,各自的 logger 注册表是独立的,日志输出可能出现重复、顺序错乱这些奇怪现象。另外要注意,Windows 下编译动态库时,spdlog.dll 需要能被运行时找到,要么放到 exe 同目录,要么把构建目录加入 PATH,不然启动程序会提示找不到动态库。
3.3 与功能裁剪相关的选项:SPDLOG_USE_STD_FORMAT、SPDLOG_WCHAR_SUPPORT 等
除了动静库,CMake 配置中还有一批功能开关,按需开启即可:
| CMake 选项 | 作用 | 使用建议 |
|---|---|---|
SPDLOG_USE_STD_FORMAT |
用 C++20 的 std::format 替代内置 fmt |
确认编译器完整支持 C++20 后再开 |
SPDLOG_FMT_EXTERNAL |
使用外部 fmt 库而非内置版本 | 项目中已有 fmt 且需要统一版本时打开 |
SPDLOG_WCHAR_SUPPORT |
支持宽字符日志消息 | Windows 下处理中文或 Unicode 日志时打开 |
SPDLOG_ENABLE_WCHAR |
设置默认日志流为 wchar 模式 | 很少用,一般保持 OFF |
SPDLOG_NO_EXCEPTIONS |
禁用异常,出错时调用 abort | 嵌入式或无异常环境使用 |
SPDLOG_CLOCK_COARSE |
使用秒级低精度时钟,提升性能 | 对时间精度不敏感的场景可用 |
SPDLOG_NO_THREAD_ID |
不记录线程 ID,减少日志开销 | 性能极端敏感时才考虑 |
SPDLOG_NO_ATOMIC_LEVELS |
日志级别不再原子操作 | 基本可以不碰 |
SPDLOG_WCHAR_SUPPORT 与 SPDLOG_USE_STD_FORMAT 都必须保证“编译 spdlog 时”和“使用 spdlog 时”两边宏状态一致。简单说,宏开关的作用就像一个协议——库怎么编的,使用方就必须怎么定义。最常见的链接阶段报错,很多就是这一层协议对不上。
3.4 install 目标与安装路径
cmake --install 可以把编译产物安装到指定目录,这一步在准备把 spdlog 交给其他项目用时非常实用:
bash复制cmake --install build --prefix /path/to/install/spdlog
执行完,/path/to/install/spdlog 下会出现 include/ 和 lib/(或 bin/)目录。include/ 里是全部头文件,lib/ 里是库文件,同时还会有 CMake 包配置文件(spdlogConfig.cmake),这是后面 find_package(spdlog) 正常工作的基础。仓库项目里我一般习惯把第三方库统一安装到本地某个固定目录,比如 $HOME/dev/libs/,每个库一个独立前缀,然后在主项目里通过 CMAKE_PREFIX_PATH 指向它们,干净利落,不会污染系统级目录。
4. 双平台实测编译流程:Windows 和 Linux 各跑一遍
4.1 Windows + MSVC:图形界面和命令行都试一遍
Windows 下最省事的方式是利用 CMake GUI。打开 cmake-gui,源码路径填 spdlog 源码目录,构建路径指定为源码目录下的 build 文件夹。点 Configure,选择 Visual Studio 版本和平台(x64 一般没错),然后按需修改上面的选项。这里有个坑:第一次 Configure 时,如果源码目录路径里含中文或空格,CMake 可能报各种诡异错误,直接跳过,把源码放到全英文路径下重试即可。
Configure 成功后点 Generate 生成 .sln,再用 Visual Studio 打开 build 目录里的 spdlog.sln,切到 Release x64,右键 spdlog 项目点生成即可。命令行方式则干净很多:
bat复制cmake -S . -B build -DSPDLOG_BUILD_EXAMPLE=OFF -DSPDLOG_BUILD_TESTS=OFF -DSPDLOG_BUILD_SHARED=OFF -A x64
cmake --build build --config Release --parallel
-A x64 指定架构,--config Release 因为 VS 是多配置生成器,必须显式选配置。编完以后库文件在 build/Release/ 目录下,头文件还是在源码的 include/ 目录。如果选用 Debug 配置,生成的库文件名会带 d 后缀,比如 spdlogd.lib,链接时不要搞混。
4.2 Linux + GCC:一行命令的事但要注意 -fPIC
Linux 下编译流程几乎一样,区别在默认生成的库类型和编译选项。先看 CMake 是否默认开启了 CMAKE_POSITION_INDEPENDENT_CODE。如果你的 spdlog 静态库后续要链接进一个动态库(.so),那编译时必须加 -fPIC,否则链接器会报 recompile with -fPIC 的错误。我习惯在 CMake 配置时直接指定:
bash复制cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DSPDLOG_BUILD_EXAMPLE=OFF \
-DSPDLOG_BUILD_TESTS=OFF
cmake --build build -j$(nproc)
nproc 是查 CPU 核心数,Linux 下交叉编译时不要用这个,得手动指定编译线程数,否则编译主机负载直接拉满。
编译完成后 libspdlog.a 就在 build/ 下。如果你想编动态库,加 -DBUILD_SHARED_LIBS=ON 或者 -DSPDLOG_BUILD_SHARED=ON,生成 libspdlog.so。注意编译动态库时,链接主程序需要设置 LD_LIBRARY_PATH 指向 libspdlog.so 所在目录,或者用 ldconfig 配置加载路径。
4.3 macOS 和其他平台的情况
macOS 的流程与 Linux 基本一致,只是默认编译器是 AppleClang。需要注意的一点是,macOS 的动态库有 install_name 机制,编译出的 libspdlog.dylib 里会记录自己的安装路径,如果之后移动了位置,运行时可能找不到。临时解决方法是编译时设置 -DCMAKE_INSTALL_NAME_DIR=/absolute/path/to/libs,让它把绝对路径写进动态库里。
至于 Android NDK、iOS、嵌入式交叉编译,核心逻辑没有区别:用对应的 toolchain 文件配置 CMake,关掉测试和示例,按需打开 SPDLOG_NO_EXCEPTIONS 这类适应裸机环境的选项。嵌入式场景还经常要关闭 SPDLOG_CLOCK_COARSE 的依赖问题,因为低精度时钟函数在不同平台上名称不一致,这部分得根据目标平台查文档确认。
5. 编译产物怎么接进你的项目:四种集成方式对比
5.1 find_package 方式:标准做法
如果 spdlog 已经通过 install 步骤安装到了某个目录,主项目 CMakeLists 里直接写:
cmake复制find_package(spdlog REQUIRED)
target_link_libraries(your_target PRIVATE spdlog::spdlog)
CMake 会通过 spdlogConfig.cmake 自动设置 include 路径和宏定义,你不用手动加 SPDLOG_COMPILED_LIB,也不用操心头文件路径。这也是官方推荐的集成方式。前提是 CMake 能定位到 spdlog 的安装目录,通过 CMAKE_PREFIX_PATH 指定:
bash复制cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/install/spdlog
5.2 FetchContent 方式:构建时自动拉取
FetchContent 适合对“一键构建”要求高、不想手动管理第三方库源码的场景。在 CMakeLists 中声明:
cmake复制include(FetchContent)
FetchContent_Declare(
spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.12.0
)
FetchContent_MakeAvailable(spdlog)
target_link_libraries(your_target PRIVATE spdlog::spdlog)
首次构建会从 GitHub 拉取源码并自动编译,之后缓存在 build 目录里。这个方式的优点是不需要预先安装库,缺点是首次构建耗时取决于网络,且在无外网环境或需要离线构建时比较麻烦。公司内部搭建 Git 镜像的话另当别论。
5.3 add_subdirectory 方式:把源码放进主工程
把 spdlog 源码目录直接放在你的项目下,然后:
cmake复制add_subdirectory(third_party/spdlog)
target_link_libraries(your_target PRIVATE spdlog::spdlog)
这种方式要求源码目录始终存在,如果主项目用 Git 管理,建议把 spdlog 作为 submodule 引入,方便锁定版本。add_subdirectory 的优点是构建链完整,IDE 里能直接看到 spdlog 的目标,调试时可以跟进库内部;缺点是自己仓库的“体积”变大了,且一旦 spdlog 的 CMakeLists 内部有和主项目冲突的全局变量,查起来比较头疼。
5.4 手工指定头文件和库:最简单也最容易出问题
如果你不想用 CMake,直接在 IDE 里配项目,那就需要手动指定:头文件目录指向 include/,库文件目录指向编好的 lib/,然后链接 spdlog(静态库全名根据平台不同是 spdlog.lib 或 libspdlog.a)。编译宏那里务必手动加上 SPDLOG_COMPILED_LIB。很多人栽在这一步——库编好了、链接也写上了,但是忘了加宏,头文件依然按 header-only 模式展开,结果链接时出现一堆重复符号或未解析符号。
5.5 链接顺序与宏定义对齐的注意事项
不管用哪种方式,有两点永远不要忽略。第一,链接顺序:静态库之间存在依赖关系时,被依赖的库要放在依赖者的后面。如果主项目还用了其他依赖 fmt 的库,spdlog 的链接顺序需要反复调整几次,直到所有引用都能解析。第二,宏定义对齐:编译 spdlog 时用了 SPDLOG_WCHAR_SUPPORT,使用方也必须定义;用了 SPDLOG_USE_STD_FORMAT,使用方也要同步定义。这种对齐没有编译器强制检查,全靠自觉,一旦错位,运行期才暴露问题。
6. 编译期和链接期常见报错的完整排查链路
6.1 报错一:spdlog::logger 未定义 / 大量 LNK2019、LNK2001
这是典型的“库没链接进来”或“宏没定义”的组合错误。先说宏的问题。如果你在编译使用方代码时,没有定义 SPDLOG_COMPILED_LIB,头文件会尝试扩展所有模板实现,但你的程序并没有直接包含 spdlog 的 .cpp 源文件,于是链接时找不到符号,MSVC 报 LNK2019 无法解析的外部符号,GCC 报 undefined reference to spdlog::...。排查路径非常固定:
- 先确认链接命令里到底有没有 spdlog 库路径和库名,没有就补上。
- 再确认是否所有使用 spdlog 的源文件都定义了
SPDLOG_COMPILED_LIB。最好的做法不是每个文件写#define,而是在编译参数里全局加/D SPDLOG_COMPILED_LIB(MSVC)或-DSPDLOG_COMPILED_LIB(GCC/Clang)。 - 确认库文件格式匹配:64 位库配 64 位程序,MSVC 运行库
/MT与/MD不匹配也会报链接器冲突(LNK2038)。
我遇到过一种隐蔽情况:库文件确实链接了,编译时也没报错,但程序一启动就在日志初始化处崩溃。查到最后是头文件宏状态和库不一致——库是用 SPDLOG_WCHAR_SUPPORT 编的,但使用方没定义这个宏,导致类和函数的签名对不上,内存布局完全错乱。这种问题编译器不会报错,只能靠宏定义严格对齐规避。
6.2 报错二:fmt 版本冲突 / 与外部 fmt 同时链接导致重复符号
这个问题在大型项目里层出不穷。项目里已经有 fmt,spdlog 默认又带了一份内置 fmt,两套 fmt 符号同时存在,链接阶段可能不报错,但运行期格式化行为诡异,比如日志中的 {} 没有被替换,输出乱码。治本的办法是启用 SPDLOG_FMT_EXTERNAL=ON,让 spdlog 用外部 fmt。同时在 spdlog 编译时保证外部 fmt 版本在要求范围内。
如果已经启用了 SPDLOG_FMT_EXTERNAL 还是报 fmt 相关错误,大概率是两个原因:外部 fmt 版本过旧,缺少 spdlog 需要的接口;或者外部 fmt 的构建配置和 spdlog 不一致(比如一个 Debug、一个 Release)。此时把外部 fmt 升级到与 spdlog 匹配的版本,并用同样配置重新编译即可。
6.3 报错三:MSVC 运行库不匹配(LNK2038 / 运行时崩溃)
MSVC 的 /MT、/MTd、/MD、/MDd 是很多人从来没在意过的开关。简单说,/MT 是静态链接 C 运行时库,/MD 是动态链接。如果你的 spdlog 是用 /MD 编的,主程序却是 /MT,链接器大概率报 LNK2038 mismatch detected for RuntimeLibrary。这个问题没有捷径,全项目所有库的运行时库设置必须统一。Debug 和 Release 的库也不能混用,不要把 Release 编译的 spdlog 库丢给 Debug 版主程序去链接,行为完全不可预期。
6.4 报错四:SPDLOG_USE_STD_FORMAT 开启后编译失败
编译器版本不够老实时,开启这个选项会出现 std::format is not a member of std 之类的报错。解决办法就是升级编译器,或者在旧编译器上放弃 std::format,继续用内置 fmt。这里多说一句,SPDLOG_USE_STD_FORMAT 只是让你传的参数类型支持更自然的 std::format 语法,但如果你没有很强的理由,建议保持 OFF,因为内置 fmt 已经足够稳定且兼容性更好。
7. 编译时的性能优化和体积控制,几个实用技巧
编译成库之后,最直观的收益是项目编译时间下降。但 spdlog 库本身的编译还可以再做一轮优化。第一,编译 Release 版本时开启链接器优化:MSVC 下用 /LTCG,GCC 下用 -flto,这几项能让代码生成阶段做跨编译单元优化,日志代码的运行效率有一定提升。代价是库编译时间增长,属于一次性成本。第二,开启编译缓存:Windows 上用 /MP 多进程编译,Linux 上用 ccache 缓存编译结果。spdlog 库的编译过程重复率很高,缓存命中后速度提升非常明显。
体积控制方面,只链接你实际用到的 spdlog 功能。如果只用同步日志,不需要异步日志,那异步相关的代码不会编译进去,链接器也会自动剔除未引用代码。但如果你用的是静态库,建议开启函数级链接(MSVC 的 /Gy + /OPT:REF,GCC 的 -ffunction-sections -Wl,--gc-sections),否则整个 spdlog.lib 里所有目标文件都会被拖进可执行文件,体积白白变大。
另外一个小技巧:spdlog 编译时默认会带上 SPDLOG_INFO、SPDLOG_WARN 等宏的编译期日志级别过滤,这个不影响库本身的二进制,而是影响使用方代码。如果你在发布版本里想彻底去掉 debug 日志,可以在编译使用方代码时定义 SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO 或更高,日志调用就会被编译器裁剪掉,连参数构造都不会执行。这个宏只需要在使用方定义,不需要在 spdlog 库编译时定义。
注意:
SPDLOG_ACTIVE_LEVEL是编译期常量,改变后必须重新编译所有包含 spdlog 头文件的源文件才能生效。如果你只改了这一个宏,没有重编那些源文件,日志不会被裁剪,这是新手容易踩的坑。
8. 结尾:编译 spdlog 这件事,本质上是和自己项目的构建体系对齐
我在实际项目中编译 spdlog 的经历,最深的体会是:编译 spdlog 本身并不难,难的是让它与主项目的构建体系完全对齐。宏定义要一致、运行库要一致、C++ 标准要一致、整体架构(x86/x64/ARM)要一致,任何一个维度没对齐,最后都可能以链接错误或运行时崩溃的形式爆发出来。我的建议是,第一次接入时花半天时间把编译、安装、集成和验证的全流程走通,形成一套固定的脚本和文档,后续升级版本时照着执行就能大大降低风险。最后再分享一个很多人忽略的小细节:编译完后在同一份配置下写一个最小化的 demo,跑通 spdlog::info("hello {}", 42) 和文件输出,这一步能帮你确认整个链路是真的通了,而不是仅仅“链接成功”。
