问题描述
在写一个demo项目的过程中,重构了一个从yaml中读取配置项的类,将它的单例模式从直接实现改为从模板中继承、以及将yaml文件加载等异常处理抽象到一个基类上。但就是这么明确的改动,居然导致,原本可以跑起来的代码,出现了链接错误!
undefined reference to 'YAML::Node::IsDefined() const',符号未定义,是yaml-cpp库链接出现的问题
C:\WINDOWS\system32\cmd.exe /C "cd . && D:\env\msys2\mingw64\bin\g++.exe -w -g -lstdc++exp CMakeFiles/app.dir/src/main.cpp.obj -o bin\app.exe -Wl,--out-implib,bin\libapp.dll.a -Wl,--major-image-version,0,--minor-image-version,0 D:/env/msys2/opt/vcpkg/installed/x64-mingw-static/debug/lib/libyaml-cppd.a -lws2_32 -lmswsock -lkernel32 -luser32 -lgdi32 -lwinspool -lshell32 -lole32 -loleaut32 -luuid -lcomdlg32 -ladvapi32 && C:\WINDOWS\system32\cmd.exe /C "cd /D D:\test\crowserver\build && C:\Users\wddjwk\scoop\apps\cmake\3.31.7\bin\cmake.exe -E copy D:/test/crowserver/src/config.yaml D:/test/crowserver/build/bin/config.yaml""
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: CMakeFiles/app.dir/src/main.cpp.obj: in function `YAML::Node::operator bool() const':
D:/env/msys2/opt/vcpkg/installed/x64-mingw-static/include/yaml-cpp/node/node.h:61:(.text$_ZNK4YAML4NodecvbEv[_ZNK4YAML4NodecvbEv]+0x14): undefined reference to `YAML::Node::IsDefined() const'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: CMakeFiles/app.dir/src/main.cpp.obj: in function `YamlParser::YamlParser(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)':
D:/test/crowserver/src/config.h:18:(.text$_ZN10YamlParserC2ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE[_ZN10YamlParserC2ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE]+0x6f): undefined reference to `YAML::Node::operator=(YAML::Node const&)'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: CMakeFiles/app.dir/src/main.cpp.obj: in function `int YamlParser::GetOrDefault<int>(char const*, int)':
D:/test/crowserver/src/config.h:29:(.text$_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_[_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_]+0x44): undefined reference to `YAML::Node YAML::Node::operator[]<char const*>(char const* const&)'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: D:/test/crowserver/src/config.h:30:(.text$_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_[_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_]+0x83): undefined reference to `YAML::Node YAML::Node::operator[]<char const*>(char const* const&)'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: D:/test/crowserver/src/config.h:30:(.text$_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_[_ZN10YamlParser12GetOrDefaultIiEET_PKcS1_]+0x8f): undefined reference to `int YAML::Node::as<int>() const'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: CMakeFiles/app.dir/src/main.cpp.obj: in function `std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > YamlParser::GetOrDefault<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)':
D:/test/crowserver/src/config.h:29:(.text$_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_[_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_]+0x4b): undefined reference to `YAML::Node YAML::Node::operator[]<char const*>(char const* const&)'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: D:/test/crowserver/src/config.h:30:(.text$_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_[_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_]+0x8a): undefined reference to `YAML::Node YAML::Node::operator[]<char const*>(char const* const&)'
D:/env/msys2/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: D:/test/crowserver/src/config.h:30:(.text$_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_[_ZN10YamlParser12GetOrDefaultINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEET_PKcS7_]+0x9d): undefined reference to `std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > YAML::Node::as<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >() const'
collect2.exe: error: ld returned 1 exit status
ninja: build stopped: subcommand failed.
mingw32-make: *** [makefile:2: compile] Error 1
原因分析
过程不再赘述了,是一个很曲折的过程。因为,很难想象我的改动会导致什么链接错误。或许单例模式会沾点边,但是我用的是一个久经验证的模板单例
// Don't forget add `friend class Singleton<T>;` to your class.
template <typename T>
class Singleton : public Noncopyable {
public:
static T& Instance() {
static T instance;
return instance;
}
protected:
Singleton() = default;
virtual ~Singleton() = default;
};
问题出错的点在于
- 在补全yaml-cpp库的代码时,clangd自动为我引入了头文件,比如
#include <yaml-cpp/node/parse.h> - 既然是重构,看到clangd提示未使用的头文件,
yaml-cpp/yaml.h,一时兴起,给它删了 - 而
yaml-cpp/yaml.h这个总入口文件的缺失,直接导致了yaml-cpp/dll.h这个定义了YAML_CPP_API符号导入导出宏的文件缺失了 - 于是更进一步,我在编译代码时
YAML_CPP_API宏没有按照预期展开为__declspec(dllimport) - 最终,导致链接阶段符号未定义错误
- 还有个细节,其实我是用的mingw编译,链接的静态库,但是仍然碰到了符号导入导出的问题。这个是mingw编译环境太复杂了的缘故,它的静态库也会设计这个概念!
所以要想解决这个问题也很简单
以后使用这种库时,注意要引用人家给你定义好的入口头文件!
下面是更详细的分析与记录,如果没明白这个场景的读者可以继续往下看
什么是符号导入导出宏?
考虑一个动态库的编译和使用的整个过程与场景:
库编译阶段
编译代码为库文件时,你要告诉编译器,哪些符号是需要添加到导出符号表的。这个可以由MSVC的__declspec(dllexport)或者GCC的__attribute__((visibility("default")))来完成
库使用阶段
使用库时,你的源码只包含了你使用的库的头文件和你自己的源码,不包含库的实现。你需要告诉编译器,这些函数和类是我要导入 (Import) 的,它们不在这里,它们在某个外部的DLL里。请帮我生成特殊的‘存根代码,以便程序运行时能正确地找到它们。而这个是由MSVC的__declspec(dllimport)来完成
跨平台的场景
- 在Windows平台的DLL动态库上,默认所有符号都是不导出的,你必须要有导出的逻辑才能使用
- 在linux平台的SO动态库上,所有符号默认都是导出的,你可能需要显示隐藏一些符号,来保证源码的安全性,或者获得一些链接时优化
符号导入导出宏就是干这个的!下面是一个比较经典的导入导出宏定义,基本上所有的也都是这么个模式。比如yaml-cpp,再比如ePromisa的fastdds等等,跨平台的代码到处都会见到这种写法
#pragma once
// MY_LIB_API - 用于公开的类和函数
// MY_LIB_PRIVATE - 用于隐藏的、仅内部实现的符号 (在Linux/macOS上很有用)
//
// 关键的 "开关" 宏:
// 1. MY_LIB_BUILDING_DLL - 当我们*正在编译*这个库(DLL)时,由构建系统(CMake)定义。
// 2. MY_LIB_STATIC_DEFINE - 当我们*要静态链接*这个库时,由构建系统(CMake)定义。
#if defined(_WIN32) || defined(__CYGWIN__) // ------------------- Windows -------------------
#ifdef MY_LIB_STATIC_DEFINE
// 场景1:静态链接。不需要导入/导出。
#define MY_LIB_API
#define MY_LIB_PRIVATE
#else
// 场景2:动态链接 (DLL)
#ifdef MY_LIB_BUILDING_DLL
// 2a. 正在构建DLL:导出 (Export)
#define MY_LIB_API __declspec(dllexport)
#else
// 2b. 正在使用DLL:导入 (Import)
#define MY_LIB_API __declspec(dllimport)
#endif
#define MY_LIB_PRIVATE // 在Windows上,默认就是隐藏的
#endif
#elif __GNUC__ >= 4 // ------------------- Linux / macOS (GCC/Clang) -------------------
// GCC 4.0+ 支持 'visibility' 属性
#ifdef MY_LIB_STATIC_DEFINE
// 场景1:静态链接。
#define MY_LIB_API
#define MY_LIB_PRIVATE
#else
// 场景2:动态链接 (SO)
// 注意:Linux的逻辑和Windows相反。默认所有符号都是导出的。
// 'default' (导出) 和 'hidden' (隐藏) 是为了优化。
#define MY_LIB_API __attribute__((visibility("default")))
#define MY_LIB_PRIVATE __attribute__((visibility("hidden")))
#endif
#else // ------------------- 其他不支持的编译器 -------------------
#define MY_LIB_API
#define MY_LIB_PRIVATE
#endif
错误全过程分析
就以这个IsDefined函数为例:
-
Parse 0 库编译阶段:
在编译代码时,yaml-cpp的CMake文件中,会定义一个
yaml_cpp_EXPORTS宏,进而导致YAML_CPP_API这个宏展开为__declspec(dllexport),IsDefined()函数被标记为__declspec(dllexport)(导出)。编译器会生成这个函数的机器码,并给它贴上一个“我是导出的,供外部链接使用”的特殊标签,然后放进库文件中 -
Parse 1 我的代码编译阶段
-
预期的正确情况:
由于我不会定义
yaml_cpp_EXPORTS这个宏,最终我的代码里面,YAML_CPP_API会被展开为,__declspec(dllimport),然后编译器说OK,这个函数被标记为dllimport,意味着它不在本地,它的实体远在天边(在某个库里)。所以编译器不会尝试自己实现它,而是会在main.cpp.obj文件的外部符号列表里记下一笔:我需要一个**‘导入版’**的IsDefined函数 -
实际的错误情况:
由于我压根没导入
yaml-cpp/dll.h这个文件,也就没有定义那一堆符号导入导出宏,所以我的YAML_CPP_API是空的,它并没有把IsDefined函数定义为import,这个时候,编译器会说OK,这是一个普通函数。我先假定它的实现在别的某个.obj文件或库里,然后再它的外部符号表里记录了,这里需要一个普通的符号。结果等链接完了发现,没找到这个符号!!(因为实际上它在动态库里)所以就产生符号未定义错误了本质上是
main.cpp.obj在“索要”一个普通函数,而libyaml-cppd.a却在“提供”一个带特殊标记(导出/导入)的函数。链接器认为这两者不是同一个东西,于是报告“未定义的引用”。
-
QA
Q1. 为什么链接静态库 (.a) 还要管这个宏?
这是最关键的问题。你又说对了:纯粹的静态链接(把 .obj 打包成 .a,再解包合并到 .exe)确实不涉及导入/导出的概念。
那么,为什么你的 yaml-cpp(一个 .a 静态库)链接会失败?
答案是:你(和 vcpkg)构建的环境很复杂,这个 yaml-cpp 库很可能不是一个“纯粹”的静态库。
我们来分析两种最可能导致你出错的场景:
场景 A (最可能):符号命名不匹配 (Import/Export Mismatch)
你用的是 MinGW (g++) on Windows。这个环境必须同时能处理 Windows 的 DLL 和 Linux 的 .a 库。
- vcpkg 如何构建
libyaml-cppd.a?vcpkg的构建脚本非常智能。它在构建yaml-cpp时,为了让这个库既可以用于静态链接,也可能用于动态链接,它很可能在编译时定义了YAML_CPP_BUILDING_DLL。- 结果:
yaml-cpp的所有.cpp文件在编译时,YAML_CPP_API展开为__declspec(dllexport)。 - 关键点:当 MinGW g++ 看到
dllexport,它会给函数符号一个特殊的“修饰名”,我们姑且称之为_export_IsDefined。 - 这个
_export_IsDefined符号被编译并存储在libyaml-cppd.a中。
- 你的
main.cpp如何编译?(错误方式)- 你包含了
node/node.h(内部头文件)。 - 这个头文件没有
YAML_CPP_API宏。 - 你的代码调用
IsDefined(),编译器生成了一个对**“普通”函数**的引用,其修饰名为_normal_IsDefined。
- 你包含了
- 链接器
ld.exe工作:- 你的需求 (
main.cpp.obj): “我需要一个叫_normal_IsDefined的函数。” - 库的供给 (
libyaml-cppd.a): “我只有一个叫_export_IsDefined的函数。” - 链接器: “找不到匹配项!” ->
undefined reference
- 你的需求 (
- 如何修复? (正确方式)
- 你包含了
yaml.h(公共头文件)。 - 这个头文件正确设置了宏。它发现你没有定义
YAML_CPP_BUILDING_DLL,于是它将YAML_CPP_API定义为__declspec(dllimport)。 - 你的代码调用
IsDefined(),编译器看到dllimport,它生成了一个对**“导入”函数**的引用,其修饰名为_import_IsDefined。 - 链接器
ld.exe再次工作:- 你的需求: “我需要
_import_IsDefined。” - 库的供给: “我有
_export_IsDefined。” - 链接器 (MinGW 特有): “啊哈!我知道
_import_和_export_是一对儿!它们是匹配的!” -> 链接成功。
- 你的需求: “我需要
- 你包含了
小结:即使是静态库 .a,如果它在编译时被 dllexport “污染"了(这在复杂的构建系统中很常见),它内部的符号名就和“普通”的符号名不再匹配了。你必须使用那个能生成 dllimport 的公共头文件来匹配这些符号。
场景 B (也有可能):inline 不匹配
头文件的另一个“诡计”是 inline。
-
node/node.h (内部) 可能这样声明:
bool IsDefined() const; // 这是一个外部函数,需要链接
-
yaml.h (公共) 可能这样声明:
inline bool IsDefined() const { /* … 实现 … */ } // 这是一个内联函数,不需要链接
如果发生这种情况:
- 你包含
node/node.h,编译器为你生成一个外部引用,要求链接器提供IsDefined。 - 但
libyaml-cppd.a是用yaml.h编译的,IsDefined函数已经被内联到所有调用它的地方了,库里根本没有一个单独编译的IsDefined符号。 - 链接器: “你要的
IsDefined,库里根本没有!” ->undefined reference
Q2. 为什么有了 private 还要 MY_LIB_PRIVATE
符号导入导出宏那个文件,其实还定义了一个MY_LIB_PRIVATE宏,MY_LIB_API用来描述这是一个公共的api,而它则标识这是一个需要被隐藏的api。但是隐藏的话,我直接写成private的,外部不就访问不到了吗,为什么还需要整这个宏呢?
C++ 的 private: 关键字在编译时阻止了外部代码访问这个成员。my_app.exe 的代码如果试图调用一个 private 成员,根本无法通过编译。
MY_LIB_PRIVATE 解决的是一个完全不同层面的问题:链接时和运行时的二进制符号可见性。
C++ private:
- 谁在用:C++ 编译器。
- 目的:实现 C++ 语言的封装性。
- 作用:在编译期阻止你 编写 调用的代码。
MY_LIB_PRIVATE (即 __attribute__((visibility("hidden")))):
- 谁在用:链接器 (Linker) 和 动态加载器 (Dynamic Loader)。
- 目的:实现二进制优化和二进制封装。
- 作用:在链接期告诉链接器:“不要把这个函数/符号放到库的公共导出表里。”
为什么这个“隐藏”很重要?
这在 Linux/macOS (.so 文件) 上尤为重要,在 Windows (.dll) 上则相反。
- 在 Linux/macOS (GCC/Clang):
- 默认行为:导出所有符号。是的,默认情况下,你编译的
.so库会把你所有的(非static)函数,包括private成员函数,都作为公共符号导出。 - 风险:一个恶意的(或无知的)用户可以通过
dlsym等底层函数在运行时“手动”查找到你那个private函数的地址并强行调用它,完全绕过了 C++ 的private限制。 MY_LIB_PRIVATE的作用:visibility("hidden")告诉链接器:“把这个符号从公共列表中拿掉”。这提供了真正的二进制封装。- 最重要的:优化。当链接器知道一个函数是
hidden的,它就确定了“这个函数绝对不会被这个.so之外的任何代码调用”。这解锁了海量的优化,比如:- 更激进的内联 (Inlining):编译器可以在库内部跨文件自由地内联这个函数。
- 更快的链接 (LTO):链接时优化 (LTO) 更高效,因为它要处理的公共符号更少。
- 更快的加载:程序启动时,动态加载器需要解析的符号更少,加载
.so文件的速度更快。
- 默认行为:导出所有符号。是的,默认情况下,你编译的
- 在 Windows (MSVC):
- 默认行为:不导出任何符号。你必须用
__declspec(dllexport)显式标记你要导出的东西。 MY_LIB_PRIVATE的作用:在这种情况下,它通常展开为空,因为它什么也不用做(默认就是隐藏的)。
- 默认行为:不导出任何符号。你必须用
总结: private: 是给编译器看的 C++ 规则。MY_LIB_PRIVATE 是给链接器看的二进制指令,主要用于在 Linux/macOS 上实现性能优化和加强封装。