🗓️ 2025-11-17 🎖️ C++踩坑 🗂️ C++笔记 🏷️ #C++

问题描述

在写一个demo项目的过程中,重构了一个从yaml中读取配置项的类,将它的单例模式从直接实现改为从模板中继承、以及将yaml文件加载等异常处理抽象到一个基类上。但就是这么明确的改动,居然导致,原本可以跑起来的代码,出现了链接错误!

Tldr

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

问题出错的点在于

Bug
  1. 在补全yaml-cpp库的代码时,clangd自动为我引入了头文件,比如#include <yaml-cpp/node/parse.h>
  2. 既然是重构,看到clangd提示未使用的头文件,yaml-cpp/yaml.h,一时兴起,给它删了
  3. yaml-cpp/yaml.h这个总入口文件的缺失,直接导致了yaml-cpp/dll.h这个定义了YAML_CPP_API符号导入导出宏的文件缺失了
  4. 于是更进一步,我在编译代码时YAML_CPP_API宏没有按照预期展开为__declspec(dllimport)
  5. 最终,导致链接阶段符号未定义错误
  6. 还有个细节,其实我是用的mingw编译,链接的静态库,但是仍然碰到了符号导入导出的问题。这个是mingw编译环境太复杂了的缘故,它的静态库也会设计这个概念!

所以要想解决这个问题也很简单

Note

以后使用这种库时,注意要引用人家给你定义好的入口头文件!


下面是更详细的分析与记录,如果没明白这个场景的读者可以继续往下看

什么是符号导入导出宏?

考虑一个动态库的编译和使用的整个过程与场景:

库编译阶段

编译代码为库文件时,你要告诉编译器,哪些符号是需要添加到导出符号表的。这个可以由MSVC的__declspec(dllexport)或者GCC的__attribute__((visibility("default")))来完成

库使用阶段

使用库时,你的源码只包含了你使用的库的头文件和你自己的源码,不包含库的实现。你需要告诉编译器,这些函数和类是我要导入 (Import) 的,它们不在这里,它们在某个外部的DLL里。请帮我生成特殊的‘存根代码,以便程序运行时能正确地找到它们。而这个是由MSVC的__declspec(dllimport)来完成

跨平台的场景

符号导入导出宏就是干这个的!下面是一个比较经典的导入导出宏定义,基本上所有的也都是这么个模式。比如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函数为例:

  1. Parse 0 库编译阶段:

    在编译代码时,yaml-cpp的CMake文件中,会定义一个yaml_cpp_EXPORTS宏,进而导致YAML_CPP_API这个宏展开为__declspec(dllexport)IsDefined() 函数被标记为 __declspec(dllexport) (导出)。编译器会生成这个函数的机器码,并给它贴上一个“我是导出的,供外部链接使用”的特殊标签,然后放进库文件中

  2. Parse 1 我的代码编译阶段

    1. 预期的正确情况:

      由于我不会定义yaml_cpp_EXPORTS这个宏,最终我的代码里面,YAML_CPP_API会被展开为,__declspec(dllimport),然后编译器说OK,这个函数被标记为 dllimport,意味着它不在本地,它的实体远在天边(在某个库里)。所以编译器不会尝试自己实现它,而是会在 main.cpp.obj 文件的外部符号列表里记下一笔:我需要一个**‘导入版’**的 IsDefined 函数

    2. 实际的错误情况:

      由于我压根没导入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 库。

  1. 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 中。
  2. 你的 main.cpp 如何编译?(错误方式)
    • 你包含了 node/node.h (内部头文件)。
    • 这个头文件没有 YAML_CPP_API 宏。
    • 你的代码调用 IsDefined(),编译器生成了一个对**“普通”函数**的引用,其修饰名为 _normal_IsDefined
  3. 链接器 ld.exe 工作:
    • 你的需求 (main.cpp.obj): “我需要一个叫 _normal_IsDefined 的函数。”
    • 库的供给 (libyaml-cppd.a): “我只有一个叫 _export_IsDefined 的函数。”
    • 链接器: “找不到匹配项!” -> undefined reference
  4. 如何修复? (正确方式)
    • 你包含了 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

如果发生这种情况:

  1. 你包含 node/node.h,编译器为你生成一个外部引用,要求链接器提供 IsDefined
  2. libyaml-cppd.a 是用 yaml.h 编译的,IsDefined 函数已经被内联到所有调用它的地方了,库里根本没有一个单独编译的 IsDefined 符号。
  3. 链接器: “你要的 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

MY_LIB_PRIVATE (即 __attribute__((visibility("hidden")))):

为什么这个“隐藏”很重要?

这在 Linux/macOS (.so 文件) 上尤为重要,在 Windows (.dll) 上则相反。

总结: private: 是给编译器看的 C++ 规则。MY_LIB_PRIVATE 是给链接器看的二进制指令,主要用于在 Linux/macOS 上实现性能优化加强封装

Comment