起因
学习过程中, 准备从网上使用一个使用 CMake 管理的 C++ 库项目, 这个项目将构建为一个动态链接库 (DLL).
在 Windows 上, 动态链接库本身可以完成编译链接过程且不报错, 而调用该库的程序能够完成编译, 但是在链接过程提示 "未定义符号 (undefined symbols)" (经过确认为动态库里的符号).
经过检查, 链接使用的命令没有问题, 那么就意味着链接器没有找到这些符号, 但是为什么会出现这样的情况, 以及该如何解决呢?
这说明, 某个阶段不报错并不意味着这个阶段没问题啊.
一个简单的例子
接下来用一个简单的例子来说明下这个问题. 项目源代码参考 cpp-multi-file-demo 仓库.
使用到的 Date 库
例子中有一个简单的 Date 库, 代码如下:
#ifndef DATE_HPP
#define DATE_HPP
class Date {
int y;
int m;
int d;
bool is_leap(int year) {
return (year % 4 ==0 && year % 100 != 0) || year % 400 == 0;
}
public:
Date(int y_, int m_, int d_) : y(y_), m(m_), d(d_) {}
int get_days();
};
#endif // DATE_HPP
#include "Date.hpp"
int Date::get_days()
{
int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
if (is_leap(this->y)) {
days[2] = 29;
}
auto cnt = 0;
for (int i = 1; i < this->m; ++i) {
cnt += days[i];
}
cnt += this->d;
return cnt;
}
稍微修改该 CMake, 使得 add_library 声明一个生成动态链接库 (即 SHARED 共享库) 的目标
默认为
STATIC, 即静态链接库; 除此之外还有OBJECTS类型的库目标. 使用库目标可以更好的处理代码之间的依赖和复用, 不过本篇内容中不详细展开.
project(My-Date)
cmake_minimum_required(VERSION 3.13)
set(CMAKE_CXX_STANDARD 14)
add_library(Date SHARED "Date.hpp" "Date.cpp")
target_include_directories(Date PUBLIC .)
调用 Date 库
之后调用这个库.
这里是将 Date 库作为子目录引入 (使用 add_subdirectory 命令), 这样 CMake 便能识别到其中定义的库目标 (library target), 即 Date, 并且能够直接使用相应的库目标名.
在项目构建时, Date 库作为依赖, 将先被从源码构建 (而不是使用现成的库), 之后再被链接到调用该库的目标.
project(CPP-Multi-File-Demo)
cmake_minimum_required(VERSION 3.13)
# 子目录 Date 中的 CMakeLists.txt 中包含生成 Date 这个目标, 引入后便可以使用
add_subdirectory(Date)
# 创建一个名为 demo 的生成可执行文件的目标, 使用 demo.cpp 源文件
add_executable(demo "demo.cpp")
# 将 demo 和 "Date" 库进行链接
target_link_libraries(demo Date)
#include <iostream>
#include "Date.hpp"
int main() {
int y, m, d;
std::cin >> y >> m >> d;
Date d1(y, m, d);
std::cout << d1.get_days();
}
问题复现
为了更好地说明问题, 体现跨平台的特性, 这里分别在 Linux 和 Windows 上进行测试.
在 Ubuntu 上构建
首先尝试在 Ubuntu 上构建.
将项目下载到本地, 按照上述修改 Date/CMakeLists.txt 中的 add_library 命令为生成 SHARED 共享库目标.

使用的过程就是一个基本的 CMake 项目的构建流程:
cd project-folder
mkdir build
cd build
cmake ..
cmake --build .
通过 cmake --build . 的输出可以得知, 首先编译 Date.cpp 得到 Date.cpp.o, 并链接得到了 C++ 共享库 libDate.so; 之后编译 demo.cpp 得到 demo.cpp.o, 并经过链接得到可执行程序 demo; 整个过程没有问题, 最后得到的可执行文件能够正常执行.
在 Windows 上构建
首先打开我们的 Visual Studio 老大哥.
默认 Visual Studio 使用的是自家的 MSVC 编译器. 可以看到结果是生成失败, "fatal erroe LNK1104: 无法打开文件 Date/Date.lib".
经过检查发现文件夹里确实没有 .lib 文件 (但是有 .dll 文件), 说明 MSVC 根本没生成 LIB 文件.
根据经验, 我们知道 Windows 上开发时使用的 (动态链接) 库文件是 lib 和 dll 成对配套的, lib 文件起到一个索引的作用, dll 中存放真正的代码. 既然这里没有生成, 那问题也就好解决了, 基本上搜索一下就能出来.

之后换用 Clang for MSVC, 虽然输出了 Date.lib, 但是还是提示找不到符号.

原因和解决办法
通过查询可知, 在 Linux 上动态库默认是导出所有符号的, 而 Windows 则不然, 不显式声明则默认不导出符号, 此外还需要单独的 LIB 文件来配套动态链接库使用.
详情可以参考微软的文档 Exporting from a DLL - Microsoft Docs, 或者自行搜索相关关键词 (如 "Windows" "DLL" "symbols" 等).
这里介绍一个简单的解决方法, 即在 CMake 进行 Configure 的过程中, 传递 -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE 这样的一个选项, 就会和 Linux 上一样, 默认导出所有符号了.
CMake 传递选项的方法
下面介绍几个常用 IDE 中添加 CMake 选项的方式.
要添加的选项就是 -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE.
Visual Studio Code + CMake Tools
图形化界面的添加方法如下:

这个选项也可以设置成目录的 (而非全局的), 也就是在工作目录下新建一个 .vscode 目录, 在其中新建一个 settings.json, 并确保其中有一个键名为 cmake.configureArgs 的列表, 其中包含相应的要传递给 CMake 的选项.
文件 .vscode/settings.json 的内容类似如下:
{
"cmake.configureArgs": [
"-DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE",
"-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake"
]
}
Visual Studio
确保在 Visual Studio 中打开的是一个 CMake 项目.
在 "项目" 菜单里选择 "<项目名> 的 CMake 设置", 之后找到 "命令参数", 并在其中添加要使用的选项.

这会在 CMake 项目下生成一个 CMakeSettings.json 文件. 在上述图形界面里的修改都会同步存储到这个文件里.
CLion
CLion 的配置也是类似,在 "文件 (Files)" → "设置 (Settings)" → "构建, 执行, 部署 (Build, Execution, Deplyment)" → "CMake" 中, 在某个配置中的 CMake options 中添加要传递给 CMake 的选项.

使用动态链接库的问题
在笔者测试时, 在 Windows 上构建完成后执行 demo 时, 还会出现找不到 DLL (动态链接库) 的问题. 毕竟动态链接是在程序运行时装载, 所以尽管生成可执行程序时能够成功链接, 但是运行时还是有可能失败.
至于原因, 猜测 Windows 上可执行文件链接到库时使用的是相对路径; 而在 Linux 上则似乎不用额外处理. 因此下面暂时只讨论 Windows 上的处理办法.
解决方式也就是让可执行文件能够找到库文件, 也就是保证 DLL 的搜索路径里存在需要的文件.
一般来说, 手动将生成的 DLL 库复制到 demo 所在的目录下, 或者在 CMake 中添加一个 install 命令, 将生成的库文件安装到可执行文件装载时能够找到的目录之后再执行, 就能够解决问题了.
若没有设置 CMAKE_INSTALL_PREFIX 变量, 则 CMake 默认会安装到系统的目录去, 在 Windows 上就是 %ProgramFiles%, 而由于一般情况下用户开启的 IDE 程序没有管理员权限, 安装到该目录时将会由于权限不足而失败.
按理说, 共享库确实应该安装到一个 "共享" 的地方. 但是由于自己调试期间写的程序可能不标准, 安装到系统目录去一般也不是啥好事, 因此一般要特别处理这些问题, 比如手动复制库文件或者修改 CMake 的安装前缀.
Visual Studio
在 Visual Studio 中, 会默认设置 CMAKE_INSTALL_PREFIX 变量 (也就是 install 命令的默认路径前缀) 为项目目录下的 out/install/. 所以可以一并安装可执行文件目标, 这样可执行程序就会复制到安装后的库所在的目录下.
Windows 下 CMake 在
install时, 会把 DLL 文件和可执行程序都放在CMAKE_INSTALL_PREFIX下的bin目录里, 而把 LIB 文件放在lib目录下, 因此使用install命令安装可执行程序和库, 二者会处于同一目录下.
install(TARGETS Date demo)
Visual Studio 在添加了 install 目标的情况下, 会出现一个带有 "(安装)" 字样的运行选项, 点击即可进行安装并直接运行安装后的可执行程序.
其他 IDE 或开发环境
Windows 上其他的 IDE 推荐在项目的 CMakeLists.txt 中设定 CMAKE_INSTALL_PREFIX 变量 (因为可以获取到 PROJECT_SOURCE_DIR 变量, 以便将内容安装在项目当前目录下). 这里以项目目录下的 install 为例.
set(CMAKE_INSTALL_PREFIX ${PROJECT_SOURCE_DIR}/install)
...
install(TARGETS Date demo)
之后运行相应的 "安装" 目标 (或者 install ALL 安装所有目标), 把文件都安装到同一个目录下, 总之确保可执行文件依赖的 DLL 文件都能够被找到.
这样之后可执行程序便应当能够正常运行. 不过需要注意执行的是安装目录里的可执行文件, 而不是构建目录里的.
为了方便, 可以在 IDE 中进行一些配置.
Visual Studio Code 的 CMake Tools 插件暂时不清楚该如何配置, 不过可以通过命令行手输命令执行可执行程序, 比较自由, 而且也不是特别麻烦.
CLion 中可以参考如下方式, 修改 "Run/Debug Configuration" 中的 "Working directory" 并添加 "Before launch" 的任务来实现:
- 修改 "Working directory": 切换目录, 使得配置执行的是 "安装" 目录里的可执行程序;
- 添加 "Before launch" 的任务: 确保执行可执行程序之前, 已经将库文件和可执行程序复制到了 "安装" 目录下.

至此, 问题就应该全部解决了.
总结
总结就是, 尽量少用动态链接库...
Last modified on 2022-07-10