diff --git a/README.md b/README.md index 632532e09..ce8f0260d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 634/640 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 639/646 docs translated ## 这是什么项目 diff --git a/code/examples/getting-started/03-first-program/CMakeLists.txt b/code/examples/getting-started/03-first-program/CMakeLists.txt new file mode 100644 index 000000000..6579c8aae --- /dev/null +++ b/code/examples/getting-started/03-first-program/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.20) +project(hello LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) diff --git a/code/examples/getting-started/03-first-program/main.cpp b/code/examples/getting-started/03-first-program/main.cpp new file mode 100644 index 000000000..9cd3f0c04 --- /dev/null +++ b/code/examples/getting-started/03-first-program/main.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + std::cout << "Hello, C++!\n"; + return 0; +} diff --git a/code/examples/getting-started/04-multi-file/CMakeLists.txt b/code/examples/getting-started/04-multi-file/CMakeLists.txt new file mode 100644 index 000000000..b03ea6050 --- /dev/null +++ b/code/examples/getting-started/04-multi-file/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) diff --git a/code/examples/getting-started/04-multi-file/greet.cpp b/code/examples/getting-started/04-multi-file/greet.cpp new file mode 100644 index 000000000..fbb0258f4 --- /dev/null +++ b/code/examples/getting-started/04-multi-file/greet.cpp @@ -0,0 +1,5 @@ +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} diff --git a/code/examples/getting-started/04-multi-file/greet.h b/code/examples/getting-started/04-multi-file/greet.h new file mode 100644 index 000000000..34bde228c --- /dev/null +++ b/code/examples/getting-started/04-multi-file/greet.h @@ -0,0 +1,4 @@ +#pragma once +#include + +std::string greet(const std::string& name); diff --git a/code/examples/getting-started/04-multi-file/main.cpp b/code/examples/getting-started/04-multi-file/main.cpp new file mode 100644 index 000000000..2c70736ac --- /dev/null +++ b/code/examples/getting-started/04-multi-file/main.cpp @@ -0,0 +1,7 @@ +#include "greet.h" +#include + +int main() { + std::cout << greet("world") << "\n"; + return 0; +} diff --git a/code/examples/getting-started/05-clangd/CMakeLists.txt b/code/examples/getting-started/05-clangd/CMakeLists.txt new file mode 100644 index 000000000..4d3d08ac3 --- /dev/null +++ b/code/examples/getting-started/05-clangd/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +# 让 CMake 在 build 目录生成 compile_commands.json,clangd 靠它看懂工程 +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) diff --git a/code/examples/getting-started/05-clangd/greet.cpp b/code/examples/getting-started/05-clangd/greet.cpp new file mode 100644 index 000000000..fbb0258f4 --- /dev/null +++ b/code/examples/getting-started/05-clangd/greet.cpp @@ -0,0 +1,5 @@ +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} diff --git a/code/examples/getting-started/05-clangd/greet.h b/code/examples/getting-started/05-clangd/greet.h new file mode 100644 index 000000000..34bde228c --- /dev/null +++ b/code/examples/getting-started/05-clangd/greet.h @@ -0,0 +1,4 @@ +#pragma once +#include + +std::string greet(const std::string& name); diff --git a/code/examples/getting-started/05-clangd/main.cpp b/code/examples/getting-started/05-clangd/main.cpp new file mode 100644 index 000000000..2c70736ac --- /dev/null +++ b/code/examples/getting-started/05-clangd/main.cpp @@ -0,0 +1,7 @@ +#include "greet.h" +#include + +int main() { + std::cout << greet("world") << "\n"; + return 0; +} diff --git a/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/CMakeLists.txt b/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/CMakeLists.txt new file mode 100644 index 000000000..f22713171 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.20) +project(hello_cmake LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) diff --git a/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/main.cpp b/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/main.cpp new file mode 100644 index 000000000..95e1dc8be --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/01-what-is-cmake/main.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + std::cout << "Hello, CMake!\n"; + return 0; +} diff --git a/code/examples/vol7/cmake-fundamentals/02-target/CMakeLists.txt b/code/examples/vol7/cmake-fundamentals/02-target/CMakeLists.txt new file mode 100644 index 000000000..89cc03764 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.20) +project(target_demo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# fmt:仅在本工程内部使用的极简"第三方库",真实工程会换成 find_package(fmt REQUIRED) +add_library(fmt STATIC fmt/fmt.cpp) +target_include_directories(fmt PUBLIC fmt) + +# mylib:对外暴露的库,公开头文件 include/mylib/mylib.h 用了 std::string +add_library(mylib STATIC src/mylib.cpp) +target_include_directories(mylib PUBLIC include) +# fmt 在这里写成 PRIVATE —— mylib.cpp 内部要用,但 mylib.h 完全不暴露 fmt +target_link_libraries(mylib PRIVATE fmt) + +# app:下游可执行,只链接 mylib,对 fmt 一无所知 +add_executable(app main.cpp) +target_link_libraries(app PRIVATE mylib) diff --git a/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.cpp b/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.cpp new file mode 100644 index 000000000..9931bd13b --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.cpp @@ -0,0 +1,2 @@ +#include "fmt.h" +// 真实 fmt 库这里有大量实现;演示用空翻译单元即可,只要能产生 fmt::format 的链接符号定义 diff --git a/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.h b/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.h new file mode 100644 index 000000000..c28deb2ac --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/fmt/fmt.h @@ -0,0 +1,12 @@ +#pragma once +#include + +namespace fmt { +// 仅供演示用的极简 fmt::format,真实工程用 find_package(fmt) 接入 +inline std::string format(const std::string& tmpl, const std::string& value) { + auto pos = tmpl.find("{}"); + if (pos == std::string::npos) + return tmpl; + return tmpl.substr(0, pos) + value + tmpl.substr(pos + 2); +} +} // namespace fmt diff --git a/code/examples/vol7/cmake-fundamentals/02-target/include/mylib/mylib.h b/code/examples/vol7/cmake-fundamentals/02-target/include/mylib/mylib.h new file mode 100644 index 000000000..2d80b786b --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/include/mylib/mylib.h @@ -0,0 +1,12 @@ +#pragma once +#include + +namespace mylib { + +/// @brief 把问候语格式化成带前缀的字符串 +/// @note 返回类型用 std::string —— 这是 mylib 公开 API 的一部分, +/// 下游 app 也必须看到完整的 std::string 定义, +/// 所以 对应的 include 路径属于 INTERFACE 需求 +std::string make_greeting(const std::string& name); + +} // namespace mylib diff --git a/code/examples/vol7/cmake-fundamentals/02-target/main.cpp b/code/examples/vol7/cmake-fundamentals/02-target/main.cpp new file mode 100644 index 000000000..cfcdf1ab0 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/main.cpp @@ -0,0 +1,8 @@ +#include "mylib/mylib.h" + +#include + +int main() { + std::cout << mylib::make_greeting("world") << '\n'; + return 0; +} diff --git a/code/examples/vol7/cmake-fundamentals/02-target/src/mylib.cpp b/code/examples/vol7/cmake-fundamentals/02-target/src/mylib.cpp new file mode 100644 index 000000000..6dff7ac86 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/02-target/src/mylib.cpp @@ -0,0 +1,13 @@ +#include "mylib/mylib.h" + +#include "fmt.h" + +namespace mylib { + +std::string make_greeting(const std::string& name) { + // fmt 是 mylib 内部实现细节,公开头文件 mylib.h 里看不到 fmt 的痕迹 + // 所以下游根本不需要知道 fmt 的存在 —— 这正是 fmt 应当为 PRIVATE 的理由 + return fmt::format("hello, {}!", name); +} + +} // namespace mylib diff --git a/code/examples/vol7/cmake-fundamentals/03-find-package/CMakeLists.txt b/code/examples/vol7/cmake-fundamentals/03-find-package/CMakeLists.txt new file mode 100644 index 000000000..6040d4ca8 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/03-find-package/CMakeLists.txt @@ -0,0 +1,48 @@ +# CMakeLists.txt —— find_package 与 cxx_std_NN 的现代写法 +# +# 这份文件演示两件事: +# 1. 用 target_compile_features 给 target 单独设 C++ 标准(现代写法) +# 2. 用 find_package 接入第三方库,拿到导入 target(现代写法) +# +# 配套文章: documents/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md + +cmake_minimum_required(VERSION 3.20) +project(find_package_demo LANGUAGES CXX) + +# ------------------------------------------------------------------ +# C++ 标准:绑在 target 上的现代写法 +# ------------------------------------------------------------------ +# target_compile_features(app PRIVATE cxx_std_20) 的含义是: +# "app 这个 target 要求至少 C++20" +# CMake 会根据编译器的默认标准决定是否要往编译命令里塞 -std 标志: +# - 编译器默认 < C++20: 自动加 -std=c++20(或 gnu++20) +# - 编译器默认 >= C++20: 不加标志(默认已经满足要求) +# 详见文章里 cxx_std_17/20/23 在 GCC 16 上的实测对比。 +# +# 配套还可以关掉编译器扩展(CXX_EXTENSIONS OFF),强制走纯 -std=c++NN +# 而不是带 GNU 扩展的 -std=gnu++NN: + +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_20) +set_target_properties(app PROPERTIES CXX_EXTENSIONS OFF) + +# ------------------------------------------------------------------ +# 第三方库:find_package 的现代写法(可选,需要系统或 vcpkg/Conan 装好 fmt) +# ------------------------------------------------------------------ +# 解开下面这段注释需要先有 fmt: +# - Linux: 系统包管理器装(如 apt install libfmt-dev) +# - 跨平台/可复现: 用 vcpkg 或 Conan,通过 toolchain 文件注入 +# +# find_package(fmt REQUIRED) 会去 CMAKE_PREFIX_PATH 下的 +# /lib/cmake/fmt/ 找 fmt-config.cmake, +# 找到后给你一个 fmt::fmt 的"导入 target"。 +# 这个 target 身上挂好了 INTERFACE_INCLUDE_DIRECTORIES / IMPORTED_LOCATION 等 +# 使用需求,你只需 target_link_libraries,include 路径和编译选项自动传过来。 +# +# find_package(fmt REQUIRED) +# add_executable(app_with_fmt main.cpp) +# target_link_libraries(app_with_fmt PRIVATE fmt::fmt) +# +# 反模式对照(别这么写): +# include_directories(${fmt_INCLUDE_DIRS}) # 目录级,污染所有 target +# target_link_libraries(app PRIVATE ${fmt_LIBRARIES}) # 变量风格,不传播使用需求 diff --git a/code/examples/vol7/cmake-fundamentals/03-find-package/main.cpp b/code/examples/vol7/cmake-fundamentals/03-find-package/main.cpp new file mode 100644 index 000000000..8bf39c2f0 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/03-find-package/main.cpp @@ -0,0 +1,17 @@ +// main.cpp —— 演示 target_compile_features(cxx_std_20) 设的最低标准 +// +// 这里用了一个 C++20 才有的语法:模板 lambda([](...) ...) +// 如果编译器实际拿到的标准低于 C++20,这一行就编不过 +// 用来证明 target_compile_features 真的把标准要求传到了编译命令里 + +#include +#include + +int main() { + // C++20: 显式模板参数列表的 lambda + auto add = [](T a, T b) { return a + b; }; + + std::cout << add(1, 2) << '\n'; + std::cout << add(std::string("a"), std::string("b")) << '\n'; + return 0; +} diff --git a/code/examples/vol7/cmake-fundamentals/04-presets/CMakeLists.txt b/code/examples/vol7/cmake-fundamentals/04-presets/CMakeLists.txt new file mode 100644 index 000000000..fe4fdb2b7 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/04-presets/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.21) +project(presets_demo LANGUAGES CXX) + +# 兜底:CMakePresets.json 已通过 cacheVariables 设了这几个值, +# 这里再 set 一遍是为了支持"不用 preset、直接 cmake -B build"的老式姿势 +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# 用 NDEBUG 区分 Debug / Release,方便验证 CMAKE_BUILD_TYPE 真的流到了编译命令 +add_executable(app main.cpp) diff --git a/code/examples/vol7/cmake-fundamentals/04-presets/CMakePresets.json b/code/examples/vol7/cmake-fundamentals/04-presets/CMakePresets.json new file mode 100644 index 000000000..8bde3d321 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/04-presets/CMakePresets.json @@ -0,0 +1,47 @@ +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 21, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_CXX_STANDARD": "17", + "CMAKE_CXX_STANDARD_REQUIRED": "ON", + "CMAKE_CXX_EXTENSIONS": "OFF" + } + }, + { + "name": "debug", + "displayName": "Debug (含 -g -O0)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release (含 -O3 -DNDEBUG)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + } + ] +} diff --git a/code/examples/vol7/cmake-fundamentals/04-presets/main.cpp b/code/examples/vol7/cmake-fundamentals/04-presets/main.cpp new file mode 100644 index 000000000..053d09ac9 --- /dev/null +++ b/code/examples/vol7/cmake-fundamentals/04-presets/main.cpp @@ -0,0 +1,10 @@ +#include + +int main() { +#ifdef NDEBUG + std::puts("release build (NDEBUG defined)"); +#else + std::puts("debug build (NDEBUG NOT defined)"); +#endif + return 0; +} diff --git a/code/examples/vol7/wsl-clangd/.clang-tidy b/code/examples/vol7/wsl-clangd/.clang-tidy new file mode 100644 index 000000000..fa64be466 --- /dev/null +++ b/code/examples/vol7/wsl-clangd/.clang-tidy @@ -0,0 +1,21 @@ +# .clang-tidy —— clang-tidy 配置(YAML) +# clangd 启动时会自动找工程根目录的 .clang-tidy 读进来, +# 配合 .clangd 里的 Diagnostics.ClangTidy 一起决定跑哪些 check。 +# 顺序:.clangd 的 Add/Remove 在 .clang-tidy 的 Checks 之后再叠加。 + +Checks: > + -*, + modernize-*, + bugprone-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -readability-identifier-length + +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +CheckOptions: + - key: readability-function-cognitive-complexity.Threshold + value: '25' +FormatStyle: file diff --git a/code/examples/vol7/wsl-clangd/.clangd b/code/examples/vol7/wsl-clangd/.clangd new file mode 100644 index 000000000..ba6e32985 --- /dev/null +++ b/code/examples/vol7/wsl-clangd/.clangd @@ -0,0 +1,53 @@ +# .clangd —— clangd 项目级配置(YAML) +# clangd 会沿源文件所在目录一路向上搜 .clangd,所有命中的片段按顺序合并, +# 越靠近源文件的片段优先级越高(可覆盖父目录的同名 key)。 +# 字段权威来源:https://clangd.llvm.org/config.html + +CompileFlags: + # Add:在 compile_commands.json 里每条命令后面追加这些 flag + # 想让 clangd 的报错和真编译一样严,就把它加上 + Add: [-Wall, -Wextra, -Wno-unused-parameter] + # Remove:干掉编译命令里某些 clangd 不该再跑一遍的 flag + # 典型场景:compile_commands 里有 -fsanitize=thread,clangd 不需要也不该重跑它 + Remove: [-Wno-unused-parameter, -fsanitize=thread] + # Compiler:把编译器可执行名替换成指定值 + # 不写就用 compile_commands.json 里原始的;写 clang++ 是让 clangd 用 Clang 自家 + # 驱动去 query 系统头/ABI,常见于交叉编译场景(原始编译器是 arm-none-eabi-g++) + Compiler: clang++ + +Index: + # Background: Build = 后台索引项目并落盘到 ~/.cache/clangd/index/ + # Skip = 不做后台索引(超大项目想省内存时关掉) + Background: Build + # StandardLibrary: Yes = 把标准库符号纳入索引,补全 std::xxx 才有结果 + StandardLibrary: Yes + # External:指向一个外部预构建好的索引文件,大项目用得上 + # External: + # File: /path/to/project-idx.riff + # MountPoint: /path/to/dep-source-tree + +InlayHints: + # 行内提示(灰色虚文字)。clangd 18+ 支持,18 之前这些 key 被忽略 + Enabled: Yes + ParameterNames: Yes # 函数调用处显示参数名 foo(/*name=*/"x") + DeducedTypes: Yes # 显示 auto 推导出的类型 auto /*= int*/ + Designators: Yes # 结构体初始化显示字段名 {.x=1, .y=2} + BlockEnd: Yes # 大块 } 后显示它属于哪个函数/命名空间 + +Diagnostics: + # ClangTidy:clangd 直接读 .clang-tidy 文件就能跑,这里再加一份项目级开关 + # 用于覆盖 .clang-tidy,或者按目录差异化(比如 tests/ 目录关掉某些 check) + ClangTidy: + Add: [modernize-*, bugprone-*, performance-*, readability-*] + Remove: [modernize-use-trailing-return-type, readability-magic-numbers] + # UnusedIncludes / MissingIncludes:clangd 自带的 include-cleaner + # Strict = 标记"include 了但没用上"和"用上了但没 include"两种问题 + # 想 IWYU 严格化再开;刚上手建议先 None,免得满屏是黄波浪线 + UnusedIncludes: Strict + MissingIncludes: Strict + # Suppress:屏蔽某些 clangd 内建诊断。常见值 '*'(全屏,慎用)或具体 code + Suppress: [unused-includes] + +Hover: + # ShowAKA:鼠标悬停在宏/typedef 展开后的"别名"上时,同时显示原始名 + ShowAKA: Yes diff --git a/code/examples/vol7/wsl-clangd/CMakeLists.txt b/code/examples/vol7/wsl-clangd/CMakeLists.txt new file mode 100644 index 000000000..427beb63b --- /dev/null +++ b/code/examples/vol7/wsl-clangd/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +# 把每个 .cpp 的完整编译命令导出到 build/compile_commands.json, +# clangd 拿到这份"翻译说明书"才能精确还原每个文件的编译视角 +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_executable(greeter main.cpp greet.cpp) +target_include_directories(greeter PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/code/examples/vol7/wsl-clangd/greet.cpp b/code/examples/vol7/wsl-clangd/greet.cpp new file mode 100644 index 000000000..fbb0258f4 --- /dev/null +++ b/code/examples/vol7/wsl-clangd/greet.cpp @@ -0,0 +1,5 @@ +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} diff --git a/code/examples/vol7/wsl-clangd/greet.h b/code/examples/vol7/wsl-clangd/greet.h new file mode 100644 index 000000000..d6d70b862 --- /dev/null +++ b/code/examples/vol7/wsl-clangd/greet.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +std::string greet(const std::string& name); diff --git a/code/examples/vol7/wsl-clangd/main.cpp b/code/examples/vol7/wsl-clangd/main.cpp new file mode 100644 index 000000000..147dc34f7 --- /dev/null +++ b/code/examples/vol7/wsl-clangd/main.cpp @@ -0,0 +1,17 @@ +#include +#include +#include + +#include "greet.h" + +int main() { + std::cout << greet("WSL") << '\n'; + + std::vector nums{1, 2, 3, 4, 5}; + int sum = 0; + for (int x : nums) { + sum += x; + } + std::cout << "sum = " << sum << '\n'; + return 0; +} diff --git a/code/examples/vol8/clangd-cross/.clangd b/code/examples/vol8/clangd-cross/.clangd new file mode 100644 index 000000000..ff4080506 --- /dev/null +++ b/code/examples/vol8/clangd-cross/.clangd @@ -0,0 +1,17 @@ +# 嵌入式 clangd 工程根配置 +# 详见 documents/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md +# +# 用法:把这个 .clangd 和 .vscode/settings.json 放到工程根目录。 +# 如果您的 compile_commands.json 里 executable 已经是 arm-none-eabi-g++ +# 绝对路径,且自带 -mcpu/-mthumb,这个 .clangd 其实可以省掉—— +# .vscode/settings.json 里的 --query-driver 就够了。 +# 这里是 compile_commands 不够干净时的兜底。 + +CompileFlags: + Compiler: arm-none-eabi-g++ + Add: + - -mcpu=cortex-m3 + - -mthumb + # 如果 query-driver 之后还有头找不到,补上 GCC 的 install 目录: + # --gcc-install-dir=/usr/lib/gcc/arm-none-eabi/16.1.0 + BuiltinHeaders: QueryDriver diff --git a/code/examples/vol8/clangd-cross/arm-none-eabi.cmake b/code/examples/vol8/clangd-cross/arm-none-eabi.cmake new file mode 100644 index 000000000..e1a4379c9 --- /dev/null +++ b/code/examples/vol8/clangd-cross/arm-none-eabi.cmake @@ -0,0 +1,22 @@ +# STM32F1 (Cortex-M3) 交叉编译 toolchain 文件片段 +# 用法:cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake +# +# 详见 documents/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md + +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_SYSTEM_PROCESSOR cortex-m3) + +# 指定交叉编译器(CMake 会把绝对路径写进 compile_commands.json) +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +set(CMAKE_CXX_COMPILER arm-none-eabi-g++) + +# 裸机环境跳过 try_compile 的运行检查,否则 ARM 可执行文件在本机跑不了 +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +# Cortex-M3 + Thumb 指令集,这些 flag 会随 compile_commands.json 透传给 clangd +set(MCU_FLAGS "-mcpu=cortex-m3 -mthumb") +set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}") +set(CMAKE_CXX_FLAGS_INIT "${MCU_FLAGS}") + +# 工程根 CMakeLists.txt 里也开 ON;这里再开一次保险 +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) diff --git a/documents/compilation/01-compilation-and-linking-overview.md b/documents/compilation/01-compilation-and-linking-overview.md index ed5b13f73..bae96176f 100644 --- a/documents/compilation/01-compilation-and-linking-overview.md +++ b/documents/compilation/01-compilation-and-linking-overview.md @@ -9,15 +9,16 @@ tags: - host - intermediate title: 深入理解C/C++的编译与链接技术:导论 -description: '' +description: '从 undefined reference 这个让人一激灵的报错出发,讲清编译与链接的底层机制:符号怎么生成、链接器怎么裁决、静态库和动态库到底差在哪。' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++的编译与链接技术:导论 ## 前言 -​ 这个是一个新的系列!是笔者本周打算系统深入开展研究的话题。具体来讲,我们会讨论和总结一系列的C/C++编程中,我们很有可能一带而过但是肯定被备受折磨的话题——编译与链接技术。我相信任何一个朋友都遇到过令人头疼的`undefined referenced`等问题,我相信看到这样的报错不少朋友会吓得一激灵(笔者前段时间就被模板实例化时的`undefined referenced`折磨过)。 +​ 这个是一个新的系列!是笔者本周打算系统深入开展研究的话题。具体来讲,我们会讨论和总结一系列的C/C++编程中,我们很有可能一带而过但是肯定被备受折磨的话题——编译与链接技术。我相信任何一个朋友都遇到过令人头疼的`undefined reference`等问题,我相信看到这样的报错不少朋友会吓得一激灵(笔者前段时间就被模板实例化时的`undefined reference`折磨过)。 -​ 解决这类问题,我相信不少朋友最开始的时候都是手忙脚乱的问AI,上网搜,但是鲜有人真正思考——为什么我们会有`undefined referenced`这类的错误呢?抛去那些咱们真的在构建系统中真忘记提供源代码文件的情况(我相信很多人也遇到过,笔者也是),很多情况时咱们真的有——起码真的是自己认为自己有的——提供了源文件且你甚至看到他链接了,但是就是链接失败了。 +​ 解决这类问题,我相信不少朋友最开始的时候都是手忙脚乱的问AI,上网搜,但是鲜有人真正思考——为什么我们会有`undefined reference`这类的错误呢?抛去那些咱们真的在构建系统中真忘记提供源代码文件的情况(我相信很多人也遇到过,笔者也是),很多情况时咱们真的有——起码真的是自己认为自己有的——提供了源文件且你甚至看到他链接了,但是就是链接失败了。 举个例子,比如说您在一个lib.c文件中编写了,并且将它制作成了一个静态库libutils。 @@ -58,7 +59,7 @@ collect2: error: ld returned 1 exit status ​ 这看起来太奇怪了,我们明明链接了libutils,他甚至都找到了我们的libutils(没有抱怨`/usr/sbin/ld: cannot find -lutils: No such file or directory`,这就是找到了),但是为什么会出错呢?而且就算没找到这个符号,为什么不在编译的时候就向我们抱怨呢?我认为,如果你像[`Beginner's Guide to Linkers`](https://www.lurklurk.org/linkers/linkers.html)的作者所说的那样,立马看到其中的问题的时候,我想这篇导论性质的《深入理解C/C++的编译与链接技术:导论》对您是没有新鲜东西的,我们随后才会真正细致的聊每一个细节,这里不会。 -​ **本篇博客可能需要您至少写过C语言程序(上面的问题尽管涉及到C++,但是本文的核心不在C++),如果您遇到过类似`undefined referenced`的错误而不知道如何解决,那更好了** +​ **本篇博客可能需要您至少写过C语言程序(上面的问题尽管涉及到C++,但是本文的核心不在C++),如果您遇到过类似`undefined reference`的错误而不知道如何解决,那更好了** ## 所以,我们写的变量和函数到底意味着什么? @@ -238,12 +239,12 @@ Summary 我们踢开其他乱七八糟的输出,实际上就是下表: -| `dumpbin` 输出 | 意义 | 类比 Linux `nm` | -| --------------------------------------------------- | ------------------------- | --------------- | -| `SECT4 notype () External \| _func` | 定义在 .text 中的外部函数 | `T _func` | -| `SECT3 notype External \| _g_initialized_var` | 定义在 .data 中的外部变量 | `D _g_initialized_var` | -| `UNDEF notype External \| _extern_func` | 未定义外部函数引用 | `U _extern_func` | -| `UNDEF notype External \| _extern_var` | 未定义外部变量引用 | `U _extern_var` | +| `dumpbin` 输出 | 意义 | 类比 Linux `nm` | +| ---------------------------------------------------- | ------------------------- | ------------------------- | +| `SECT4 notype () External \| _func` | 定义在 .text 中的外部函数 | `T _func` | +| `SECT3 notype External \| _g_initialized_var` | 定义在 .data 中的外部变量 | `D _g_initialized_var` | +| `UNDEF notype External \| _extern_func` | 未定义外部函数引用 | `U _extern_func` | +| `UNDEF notype External \| _extern_var` | 未定义外部变量引用 | `U _extern_var` | | `UNDEF notype External \| _un_g_initialized_var` | 未定义外部变量引用 | `U _un_g_initialized_var` | ## 解决我们不知道的符号:链接 @@ -411,7 +412,7 @@ collect2: error: ld returned 1 exit status ​ 您注意到了,还是一样,因为编译器相信**链接器可以正确的处理任何符号的关系**(他只能一分一分的编译文件!他管不了全局其他的源文件!**整个结果单元(包含可执行文件,动态库和静态库)的符号裁决由链接器决定**!这是笔者要再强调一次的!) -​ 所以,链接的时候,链接器发现两个文件中居然存在一模一样的符号定义。自然,定义是不一样,就像您即说A是1,又说A是2,唯一性被打破,贸然决定只会让程序变得不可控。所以,链接器自然一巴掌闪回来,不予通过!至少在今天的GNU工具链的默认行为下,您这样做智慧得到一个`multiple definition`。 +所以,链接的时候,链接器发现两个文件中居然存在一模一样的符号定义。自然,定义是不一样,就像您即说A是1,又说A是2,唯一性被打破,贸然决定只会让程序变得不可控。所以,链接器自然一巴掌闪回来,不予通过!至少在今天的GNU工具链的默认行为下,您这样做只会得到一个`multiple definition`。 ## 那链接器的作用就这样? @@ -577,3 +578,7 @@ int main() { ``` 重新编译并链接,程序就会成功运行,因为此时 `usage.o` 中引用的符号将是简单的 `int_max`,与 `libutils.a` 中提供的符号相匹配。 + +## 现代 CMake 视角 + +上面这些 `gcc -c`、`ar rcs`、`-l`/`-L`、`extern "C"`、`-fvisibility` 的手活儿,在今天的项目里基本都被 CMake 接管了。您写 `add_library(utils STATIC lib.c)`,CMake 自动调起 `ar` 打包成 `libutils.a`;`target_link_libraries(myapp PRIVATE utils)` 接管了 `-lutils` 和 `-L` 的拼装,还会按依赖拓扑算出正确的链接顺序——前面讲过的"链接器不走回头路"那条铁律,CMake 帮您排好了。要 C/C++ 混编也没问题,给 C 目标设 `set_target_properties(utils PROPERTIES POSITION_INDEPENDENT_CODE ON)`,或者直接 `add_library(utils SHARED ...)` 让 CMake 默认开 `-fPIC`,C++ 这边就能链上。符号可见性交给 `CXX_VISIBILITY_PRESET hidden`(等价全局 `-fvisibility=hidden`),只把真正要导出的接口用 `__attribute__((visibility("default")))` 放出来。动态库的运行期查找路径,则从手写 `LD_LIBRARY_PATH` 升级成 `CMAKE_INSTALL_RPATH` 配合 `$ORIGIN`,让 `.so` 跟着可执行文件走,部署不再靠改环境变量。换句话说,本篇讲的这些底层机制一个都没消失,只是被构建系统包成了一行声明式的配置。 diff --git a/documents/compilation/02-reuse-concept.md b/documents/compilation/02-reuse-concept.md index 689577ce2..24d571640 100644 --- a/documents/compilation/02-reuse-concept.md +++ b/documents/compilation/02-reuse-concept.md @@ -8,10 +8,11 @@ tags: - cpp-modern - host - intermediate -title: 深入理解CC++的编译与链接技术2:动态库静态库导论 -description: '' +title: 深入理解C/C++的编译与链接技术2:动态库静态库导论 +description: '从源码复用到二进制分发:静态库与动态库到底解决了什么问题,以及动态库在构建期和运行时各自发生了什么' +cpp_standard: [11, 14, 17, 20] --- -# 深入理解CC++的编译与链接技术2:动态库静态库导论 +# 深入理解C/C++的编译与链接技术2:动态库静态库导论 ## 什么是重用概念,跟我们的编译与链接技术有什么关系 @@ -138,6 +139,16 @@ int main() | 是否适合开发工作 | 适合:小型工具、嵌入式/单文件发布、无运行时依赖场景;方便离线/受限环境部署。 | 适合:大型项目、模块化设计、插件系统、需要热更新或减少重复内存/磁盘占用的场景;利于团队协作与库独立发布。 | | 其他值得一提的点 | - 安全/Bug 修复需重建并重新发布所有可执行。- 版权/许可证(如 GPL)在静态链接下可能带来更严格的义务。- 对运行时性能(调用)通常没有 PLT 费用。 | - 可以单独修复/替换库(快速补丁)。- 存在运行时劫持风险(LD_PRELOAD、RPATH 注入)和首次调用的延迟(lazy binding)。- 对平台 ABI/SONAME 管理和部署流程要求更高。 | +## 现代 CMake 视角 + +上面这些 `-fPIC`、`-shared`、`-Wl,-soname`、`-fvisibility=hidden`,在手敲命令行的年代确实得一项项自己拼。现代项目里这套基本都被 CMake 接管了,咱们写 CMakeLists 的时候很少再裸写这些 flag。 + +`add_library(foo SHARED ${FOO_SOURCES})` 直接生成 `.so`,CMake 默认就给 SHARED 目标加上 `-fPIC`,省去手抄;`add_library(foo STATIC ...)` 则自动调 `ar` 打 `.a` 包,等于把上一节的归档流程脚本化。客户端那边,`target_link_libraries(myapp PRIVATE foo)` 一行就把 `-lfoo`/`-L` 全接管了,CMake 还会自动把库的接口 include 目录、传递依赖一起串起来。 + +`-fvisibility=hidden` 在 CMake 里通过 `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)` 设置,配套 `VISIBILITY_INLINES_HIDDEN ON`,效果就是只导出你显式标了 `visibility("default")` 的符号——上一节讲的"减少 API 污染和符号冲突"用属性落地。 + +至于运行时 `LD_LIBRARY_PATH` 那套脏活,CMake 用 `CMAKE_INSTALL_RPATH` 和 `$ORIGIN` 接管:装到非标准目录时设置 `INSTALL_RPATH "$ORIGIN/../lib"`,可执行文件自带 rpath,loader 直接照着找,不用用户去 export 环境变量。SONAME/versioning 这类 ABI 管理相对薄一点,通常配合 `set_target_properties(... VERSION 1.0 SOVERSION 1)` 生成 `libfoo.so.1.0` + symlink,CMake 替你建好软链。一句话:这些底层机制没有消失,只是被构建系统封到了声明式的目标属性背后。 + # Reference 基本上都是源于这本书:《高级C/C++编译技术》 diff --git a/documents/compilation/03-creating-and-using-static-libs.md b/documents/compilation/03-creating-and-using-static-libs.md index abb912487..bbe61546a 100644 --- a/documents/compilation/03-creating-and-using-static-libs.md +++ b/documents/compilation/03-creating-and-using-static-libs.md @@ -8,16 +8,17 @@ tags: - cpp-modern - host - intermediate -title: 深入理解CC++的编译与链接技术3:如何制作和使用静态库 -description: '' +title: 深入理解C/C++的编译与链接技术3:如何制作和使用静态库 +description: '用 ar 把目标文件打包成 lib.a 静态库,搞清楚为什么库名必须 lib 起头、链接器如何按 -l 约定找库,以及在什么场景下该用静态库。' +cpp_standard: [11, 14, 17, 20] --- -# 深入理解CC++的编译与链接技术3:如何制作和使用静态库 +# 深入理解C/C++的编译与链接技术3:如何制作和使用静态库 在上一篇博客中,笔者就简单的提及了一下关于静态库和动态库的基本导论,笔者将链接放在这里: -> [深入理解CC++的编译与链接技术-CSDN博客](https://blog.csdn.net/charlie114514191/article/details/152921903) +> [深入理解C/C++的编译与链接技术-CSDN博客](https://blog.csdn.net/charlie114514191/article/details/152921903) > -> [深入理解CC++的编译与链接技术2:动态库静态库导论-CSDN博客](https://blog.csdn.net/charlie114514191/article/details/154828385) +> [深入理解C/C++的编译与链接技术2:动态库静态库导论-CSDN博客](https://blog.csdn.net/charlie114514191/article/details/154828385) 所以在之前,我们就简单的讲述了静态库的本质是什么。尽管,在今天,使用动态库作为代码的共享是一种更加基本的策略。但是处于完整,和笔者自己也喜欢用静态库打包一个只依赖于`C/C++`最基本运行时的人(其实笔者的确没有什么技术原因选择,纯粹是不太喜欢将一大坨可重定位文件直接塞给Linker) @@ -90,3 +91,7 @@ ar [操作码][修饰符] <归档文件名> <文件...> #### 潜在的符号冲突和版本管理问题 (Symbol Collisions) 如果我们将**多个版本**或**同名符号**的静态库链接到同一个可执行文件中,编译器/链接器会尝试解决,但风险很高(笔者没有记错的话,是按照符号强弱和等同下随机丢弃),这真的很危险,谁也不喜欢自己的程序猜猜乐。 + +## 现代 CMake 视角 + +上面这套手工 `ar rvs lib.a` 加 `-l`/`-L` 的流程,在现代项目里基本都被 CMake 接管了。一行 `add_library(Charlie STATIC src/foo.cpp src/bar.cpp)` 就会自动把源文件编译成 `.o`,再调用 `ar` 打包出 `libCharlie.a`——`STATIC` 关键字对应静态库,`SHARED` 对应动态库,不写就让 CMake 按 `BUILD_SHARED_LIBS` 开关二选一。链接那头也不用再手撸 `-l`/`-L`,`target_link_libraries(myapp PRIVATE Charlie)` 一句话搞定,CMake 会自动展开成 `-lCharlie` 并把库所在目录塞进 `-L`,本篇开头讲的那个 `lib` 前缀约定它在背后替你扛了。至于「分发简单化」「版本锁死」这些选静态库的理由依然成立,只是今天你不用再为了它们去手敲 `ar` 了。 diff --git a/documents/compilation/04-dynamic-libraries-1.md b/documents/compilation/04-dynamic-libraries-1.md index c91510d9c..036fd51d3 100644 --- a/documents/compilation/04-dynamic-libraries-1.md +++ b/documents/compilation/04-dynamic-libraries-1.md @@ -9,7 +9,8 @@ tags: - host - intermediate title: 深入理解C/C++编译与链接技术4:动态库A1:基本讨论之`-fPIC` -description: '' +description: '搞清楚动态库为什么必须用 -fPIC 编译:GOT/PLT 间接寻址让代码段可共享,以及静态库场景下也得带 -fPIC 的真实工程理由' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++编译与链接技术4:动态库A1:基本讨论之`-fPIC` @@ -56,10 +57,14 @@ description: '' 但是X86-64不是,还是可以不使用`-fPIC`编译出可用的动态库的,但是吧,共享的特性就丢掉了,而且加载速度变慢(加载的时候给所有的符号修正地址)。所以,如果我们严肃的想一想,笔者认为的结论是: -> **在今天,编译动态库是必须要带有-`fPIC`符号的,百利无一害(如果很担心轻微的性能损失当我没说,考虑的场景不一致)** +> **在今天,编译动态库是必须要带有 -`fPIC` 标志的,百利无一害(如果很担心轻微的性能损失当我没说,考虑的场景不一致)** #### `-fPIC`是动态库专属的嘛?能否在静态库下使用 `-fPIC`? 显然不是,否则,没有必要让这个标志独立出来。实际上,我们完全可以对准备编译成静态库的可重定位文件一样上`-fPIC`,这个非常的常见。 举个例子,笔者手头有一个比较大的工程,他是对每一个子模块都生成一个静态库,然后对这个目录下所有生成的静态库打包生成一个动态库,我们在之前的文章就有讨论过,静态库就是一组可重定位文件的简单集合,所以,很自然的我们就意识到,上述情况我们必须对这个静态库所包含的可重定位文件采用`-fPIC`标志的编译这些源文件。 + +## 现代 CMake 视角 + +上面这套「手动 `-fPIC` + `-shared`」的流程,今天基本都被 CMake 接管了。`add_library(foo SHARED foo.cpp)` 在 Linux 上会自动给编译器喂 `-fPIC`、给链接器喂 `-shared`,不用手动操心。更通用一点的是 `set(CMAKE_POSITION_INDEPENDENT_CODE ON)` 或 `set_target_properties(foo PROPERTIES POSITION_INDEPENDENT_CODE ON)`——这一条对静态库也生效,正好对应笔者上面那个「静态库被打进动态库」的真实场景:把静态库目标也开 PIC,再 `target_link_libraries(big_so PRIVATE foo)`,CMake 会确保下游动态库拿到的 `.o` 已经是位置无关的。至于 GOT/PLT 的那些间接寻址细节,CMake 不会替你抹掉——它只是把正确的 flag 准时递到编译器手上,底层 ELF 的玩法还是这一篇讲的这一套。 diff --git a/documents/compilation/05-dynamic-library-design.md b/documents/compilation/05-dynamic-library-design.md index c0bd07226..233d9eef7 100644 --- a/documents/compilation/05-dynamic-library-design.md +++ b/documents/compilation/05-dynamic-library-design.md @@ -9,7 +9,8 @@ tags: - host - intermediate title: 深入理解C/C++的编译链接技术6——A2:动态库设计基础之ABI设计接口 -description: '' +description: '讲清动态库 ABI 设计的底层坑:C++ 名称修饰跨编译器不通用的根因、静态对象初始化时序陷阱,以及如何用 C 风格导出接口和完整 ABI 头文件规避 ABI 对接麻烦' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++的编译链接技术6——A2:动态库设计基础之ABI设计接口 @@ -178,6 +179,10 @@ extern "C" void* allocate_buffer(size_t size); ``` +## 现代 CMake 视角 + +本篇讨论的 ABI 设计坑,在现代项目里大多被 CMake 这套构建系统接管了。`extern "C"` 仍然是您手写的活,但符号可见性可以用 `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)` 默认隐藏所有符号、再靠 `generate_export_header` 生成的宏按需导出,避免一不小心把内部 C++ 修饰符号全暴露给下游。`target_link_libraries(foo PUBLIC bar)` 替您把传递依赖、头文件路径和 `-l`/`-L` 一并串好,下游只需 link 一次。`add_library(foo SHARED)` 会自动给所有目标文件加 `-fPIC`,省去手敲。涉及跨平台 ABI 对接时,给动态库装好 `PUBLIC_HEADER` 属性,配合 `install(TARGETS ...)`,CMake 就会按 Unix 把头文件扔进 `include/`、Windows 处理好 `__declspec(dllexport/dllimport)` 的导入库分发,让您写的 C 风格导出接口真正落地成"一份头文件处处可用"。 + # Reference ## 确认名称 diff --git a/documents/compilation/06-symbol-visibility.md b/documents/compilation/06-symbol-visibility.md index be4450622..39c4e449a 100644 --- a/documents/compilation/06-symbol-visibility.md +++ b/documents/compilation/06-symbol-visibility.md @@ -9,7 +9,8 @@ tags: - host - intermediate title: 深入理解C/C++编译技术——动态库A3:聊一聊符号可见性 -description: '' +description: '聊一聊 ABI 层的符号可见性:用 nm/dumpbin 查导出符号,以及 GCC 的 -fvisibility、__attribute__((visibility))、#pragma visibility 和 MSVC 的 __declspec(dllexport/dllimport) 四种控制方式。' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++编译技术——动态库A3:聊一聊符号可见性 @@ -126,12 +127,16 @@ int api_minus(int a, int b); ```cpp #ifdef CCLOG_BUILD_SHARED -/* If we plan to exports sysbols to DLL, we need to decorate symbols by this */ +/* If we plan to exports symbols to DLL, we need to decorate symbols by this */ /* Others in case can use the symbols */ #define CCLOG_API __declspec(dllexport) #else -/* If we plan to import sysbols from DLL, we need to decorate symbols by this */ +/* If we plan to import symbols from DLL, we need to decorate symbols by this */ #define CCLOG_API __declspec(dllimport) -#end +#endif ``` + +## 现代 CMake 视角 + +上面这些 `-fvisibility=hidden`、`__attribute__((visibility))`、`-fPIC` 的手活儿,在用 CMake 管理的项目里基本都被构建系统接管了。`add_library(foo SHARED ...)` 默认就会给目标加上 `-fPIC`(静态库默认不加,需要时再 `set(CMAKE_POSITION_INDEPENDENT_CODE ON)`);想统一隐藏符号,给目标设 `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)`,CMake 就会自动把 `-fvisibility=hidden` 喂给编译器;同时配 `VISIBILITY_INLINES_HIDDEN ON` 把内联函数也藏起来。至于 Windows 上那套 `dllexport`/`dllimport` 来回切,CMake 提供了 `GenerateExportHeader`,一个宏就能生成跨平台的 `FOO_API` 宏——Linux 下展开成 `visibility` 属性,Windows 下根据编译期是构建库还是使用库自动展开成 `dllexport` 或 `dllimport`,省得自己手写 `#ifdef` 拼接。所以今天写库,这些底层修饰大多不用自己手敲,CMake target 的属性面板里一两行就配齐了。 diff --git a/documents/compilation/07-symbol-missing-and-runtime-loading.md b/documents/compilation/07-symbol-missing-and-runtime-loading.md index aa343fd91..d2a46f1db 100644 --- a/documents/compilation/07-symbol-missing-and-runtime-loading.md +++ b/documents/compilation/07-symbol-missing-and-runtime-loading.md @@ -9,7 +9,8 @@ tags: - host - intermediate title: 深入理解C/C++编译技术——动态库A4:链接时符号缺失行为与运行时动态加载 -description: '' +description: '跨平台对比链接时未定义符号的容忍度差异,并演示 dlopen/LoadLibrary 运行时动态加载与 C++ 插件工厂模式' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++编译技术——动态库A4:链接时符号缺失行为与运行时动态加载 @@ -17,7 +18,7 @@ description: '' ## 链接时符号缺失行为的平台差异 -这个很有趣,我们讨论的时在链接发生的时候,平台之间对存在未定义符号的容忍程度分析。在Windows上,动态库生成的时候,我们就已经要求不允许存在未定义符号,一旦发生未定义的符号,我们的工具链就会抱怨道找不到符号。 +这个很有趣,我们讨论的是在链接发生的时候,平台之间对存在未定义符号的容忍程度分析。在Windows上,动态库生成的时候,我们就已经要求不允许存在未定义符号,一旦发生未定义的符号,我们的工具链就会抱怨道找不到符号。 而在Linux上不会存在这样的事情。事实上,Linux的策略更加宽容,默认的情况下,我们允许符号未定义,直到上进程的时候,加载器会检查所有的依赖确保所有的重要符号都是被正确编址的。直到那个时候才会确认我们的程序是否真的存在重要的问题。 @@ -268,3 +269,7 @@ PluginAPI* create_plugin_api(void) { #### **Windows 的 `GetProcAddress` 失败怎么排查?** 检查导出名称(使用 `dumpbin /EXPORTS` 或 `nm`),检查调用约定是否匹配(`__stdcall` 会改变导出名),或是否使用了 C++ 名称修饰。建议 `__declspec(dllexport)` + `extern "C"`。 + +## 现代 CMake 视角 + +上面这一堆 `gcc -fPIC -shared`、`-Wl,-no-undefined`、`__declspec(dllexport)` 的手工活,在现代项目里基本都被 CMake 接管了。`add_library(mylib SHARED mylib.c)` 会自动给位置无关代码加 `-fPIC`,并按平台产出 `.so`/`.dll`/`.dylib`,`STATIC` 则走 `ar` 打包,您不再需要手敲这两个标志。Linux 上默认放行未定义符号那一套宽松策略,可以用 `set_target_properties(mylib PROPERTIES LINK_FLAGS "-Wl,--no-undefined")`(或 `CMAKE_SHARED_LINKER_FLAGS`)拧紧,复刻本文开头讲的严格检查。符号可见性方面,`CXX_VISIBILITY_PRESET hidden` + `VISIBILITY_INLINES_HIDDEN ON` 等价于给整个目标套 `-fvisibility=hidden`,然后您只在需要导出的工厂函数上拍 `__attribute__((visibility("default")))`(或 Windows 的 `__declspec(dllexport)`),导出表干净利落,跨平台写起来比满文件撒 `dllexport` 省心得多。至于运行时找库那条 `LD_LIBRARY_PATH` / `PATH` 折腾链,CMake 用安装期 `CMAKE_INSTALL_RPATH`(Linux 下配 `$ORIGIN` 让可执行文件去自己所在目录找 `.so`)和 Windows 上把 DLL 复制到可执行文件同目录这两招,把"装到哪儿能在哪儿找到"自动化了——您本文里 `export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH` 那一行,在规范的 CMake 工程里基本用不着手敲。 diff --git a/documents/compilation/08-library-search-logic.md b/documents/compilation/08-library-search-logic.md index 80658a61a..07252461d 100644 --- a/documents/compilation/08-library-search-logic.md +++ b/documents/compilation/08-library-search-logic.md @@ -9,7 +9,8 @@ tags: - host - intermediate title: 深入理解C/C++的编译与链接技术8:库文件检索逻辑 -description: '' +description: '讲清可执行文件在运行时按什么优先级顺序找到它依赖的动态库:LD_PRELOAD、RPATH/RUNPATH、LD_LIBRARY_PATH、ldconfig 缓存、系统默认目录,以及 Windows 的对应搜索规则' +cpp_standard: [11, 14, 17, 20] --- # 深入理解C/C++的编译与链接技术8:库文件检索逻辑 @@ -133,3 +134,7 @@ Windows 的可执行/装载器与 API(`LoadLibrary` / `LoadLibraryEx` / 自动 8. **如果启用了应用配置或 Side-by-side(SxS)/manifest 特性**,会优先解析 manifest 中声明的绑定版本或来自 WinSxS 的并行程序集。 重点是:**如果你使用了绝对路径或相对可执行文件路径,系统不会去 PATH 搜索**;反之如果只给了裸名 `foo.dll`,就会按上面顺序尝试。 + +## 现代 CMake 视角 + +上面这些手工 `export LD_LIBRARY_PATH`、改 `/etc/ld.so.conf.d`、`-Wl,-rpath` 的折腾,在用 CMake 管理的项目里基本都被接管了。`target_link_libraries(myapp PRIVATE foo)` 会替你转成 `-lfoo` 和正确的 `-L`;`add_library(foo SHARED)` 默认给目标加 `-fPIC`,静态库 `add_library(foo STATIC)` 则走 `ar` 打包。运行时检索那块,`set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")` 配合 `CMAKE_BUILD_WITH_INSTALL_RPATH` 把 `$ORIGIN` 写进 ELF 的 `DT_RUNPATH`,分发出去的可执行文件跟着自己的目录跑,根本不用用户去污染 shell 的 `LD_LIBRARY_PATH`。Windows 上则交给 `RUNTIME_OUTPUT_DIRECTORY` 把 DLL 摆到 `.exe` 旁边,正好命中"应用程序目录"那条优先级。换句话说,前面这些规则是底层事实,CMake 没改它们,只是把"该写哪个标志、该把库放哪"这件事变成了几行声明式配置。 diff --git a/documents/compilation/09-dynamic-library-details.md b/documents/compilation/09-dynamic-library-details.md index 7b18edc73..03587993e 100644 --- a/documents/compilation/09-dynamic-library-details.md +++ b/documents/compilation/09-dynamic-library-details.md @@ -8,10 +8,11 @@ tags: - cpp-modern - host - intermediate -title: 深入理解CC++的编译与链接技术9:动态库细节(完结) -description: '' +title: 深入理解C/C++的编译与链接技术9:动态库细节(完结) +description: '从 PIC、GOT/PLT 到符号介入,把动态库在运行时为什么"地址不确定"和现代链接器-装载器如何协作这件事讲透' +cpp_standard: [11, 14, 17, 20] --- -# 深入理解CC++的编译与链接技术9:动态库细节(完结) +# 深入理解C/C++的编译与链接技术9:动态库细节(完结) ## 前言 @@ -33,7 +34,7 @@ mov ds:0xBAD10000, eax; 写回操作 ``` -非常好,知道这个事情之后,我们要指出,函数调用的本质也是找到代码段的函数地址——比如说,咱们要调用一个平凡的add函数,就要告诉我们的call指令add哈桑农户在哪(也就说,我们要提供add函数入口点的代码段地址) +非常好,知道这个事情之后,我们要指出,函数调用的本质也是找到代码段的函数地址——比如说,咱们要调用一个平凡的add函数,就要告诉我们的call指令add函数在哪(也就说,我们要提供add函数入口点的代码段地址) ```cpp @@ -138,7 +139,7 @@ PLT 的好处: 3. 解析器在所有动态库中查找符号 foo 4. 更新 GOT[foo] = foo 的真实地址 5. 返回 foo -6. 之后的调用直接跳 GO[foo] +6. 之后的调用直接跳 GOT[foo] ------ @@ -238,3 +239,11 @@ Linux 下的动态链接器(ld-linux)采用了一套特定的规则来处理 这个事情要重复下!很多人认为:"我在 C++ 代码里把函数放在 `namespace MyLib { ... }` 里,或者我把代码编译成了 `libMyLib.so`,那么这个库就像一个独立的容器,里面的变量名 `count` 不会和外面冲突。" 但是实际上**链接器(Linker)是"符号类型盲(Type-blind)"和"结构盲"的。**我们都知道**C++ 命名空间只是语法糖:** 编译器通过**名字修饰(Name Mangling)** 将 `MyLib::foo()` 变成了字符串 `_ZN5MyLib3fooEv`。对于链接器来说,这只是一个长字符串。如果两个库碰巧生成了相同的修饰名(Mangled Name),冲突依然会发生。而**动态库不是命名空间:** 动态库只是文件组织形式。一旦被加载到进程内存,所有导出符号(Exported Symbols)都会进入一个平铺的、扁平的全局符号池(Global Symbol Table)。`libA.so` 里的全局变量 `g_context` 和 `libB.so` 里的 `g_context` 在链接器眼中就是同一个东西,除非你使用了 Visibility 隐藏或 Local 绑定。 + +## 现代 CMake 视角 + +上面这些 `-fPIC`、`-fvisibility=hidden`、`-Wl,-Bsymbolic`、`$ORIGIN` 之类的标志,今天基本都不用手敲了,CMake 把它们封到了几行 `add_library` / `set_target_properties` 里。 + +`add_library(foo SHARED)` 就等于替你做了两件事:自动给库内每个 `.o` 加上 `-fPIC`(SHARED 默认开),再用 `gcc -shared` 打成 `.so`,相当于自动跑了一遍前面说的 PIC 流程。符号可见性则交给 `CMAKE_CXX_VISIBILITY_PRESET hidden` 和 `CMAKE_VISIBILITY_INLINES_HIDDEN`:设上之后所有符号默认隐藏,只有你显式 `__attribute__((visibility("default")))` 标的接口才进动态符号表,正好对应上一节"符号可见性"那条最佳实践。`target_link_libraries` 接管了 `-l`/`-L`,依赖关系会被 CMake 自动传播(PUBLIC/PRIVATE/INTERFACE 三档),传递依赖里的重复符号问题靠这个就能少踩一大半。 + +剩下两个运行期的坑也有专门的家。`LD_LIBRARY_PATH` 那套"装完还要 export"的折腾,现在用 `CMAKE_INSTALL_RPATH` 配 `$ORIGIN` 让可执行文件自己记住 `.so` 在哪,部署到任何相对路径都能找到库;`-Wl,-Bsymbolic` 这种"我要库内部符号自己解析自己"的需求,通过 `target_link_options(foo PRIVATE "-Wl,-Bsymbolic")` 一样能挂上去。换句话说,链接器-装载器协作的底层机制没变,但今天你写的不再是 `gcc -shared -fPIC -Wl,-Bsymbolic -o libfoo.so ...`,而是 `add_library(foo SHARED)` 加几个 `set_target_properties`,剩下的脏活 CMake 替你干了。 diff --git a/documents/compilation/10-dynamic-lib-as-executable.md b/documents/compilation/10-dynamic-lib-as-executable.md index f6ad6657f..604ec5e18 100644 --- a/documents/compilation/10-dynamic-lib-as-executable.md +++ b/documents/compilation/10-dynamic-lib-as-executable.md @@ -8,10 +8,11 @@ tags: - cpp-modern - host - intermediate -title: 深入理解CC++的编译与链接技术(番外):动态库可以像可执行文件那样执行嘛? -description: '' +title: 深入理解C/C++的编译与链接技术(番外):动态库可以像可执行文件那样执行嘛? +description: '动态库 .so 为什么直接执行会段错误、libc 为什么又能优雅打印版本信息——从 ELF 入口点到手工 syscall 的完整拆解' +cpp_standard: [11, 14, 17, 20] --- -# 深入理解CC++的编译与链接技术(番外):动态库可以像可执行文件那样执行嘛? +# 深入理解C/C++的编译与链接技术(番外):动态库可以像可执行文件那样执行嘛? 我知道有朋友看到这个话题会下意识的发笑,会觉得笔者在胡言乱语。其实,笔者在最最开始的时候,也对这个事情一笑了之,觉得太荒唐。但是实际上,动态库是**可以像可执行文件那样执行的。** @@ -31,7 +32,7 @@ Segmentation fault (core dumped) /lib/libcrypt.so.2.0.0 我们第一个想法是——为什么?为什么事情会变成这样?答案很简单,在后续的博客中,笔者会强调,一般而言,以.so结尾的,一般是动态库(或者说共享库,笔者已经说明了在今天的操作系统中,可以不再刻意的区分共享库和动态库了) -> [深入理解CC++的编译与链接技术2:动态库静态库导论-CSDN博客](https://blog.csdn.net/charlie114514191/article/details/154828385) +> [深入理解C/C++的编译与链接技术2:动态库静态库导论-CSDN博客](https://blog.csdn.net/charliechen114514191/article/details/154828385) 很显然,当我们直接输入文件的绝对地址的时候,操作系统的bash会尝试将它当作一个可独立运行的程序,然而,这个跟我们的动态库的定义:包含一组函数和数据的**动态共享组件**是不一致的。由于共享库没有设计像普通程序那样的标准主入口点($\text{main}$ 函数),直接运行时,执行流很可能跳转到无效的内存地址。操作系统检测到这种**非法内存访问**(试图访问程序无权访问的内存区域)时,就会触发**段错误**。我想很多人看到这里的时候,已经确信我这篇博客中指出:动态库是**可以像可执行文件那样执行的**这个论点就是错误的。 @@ -222,8 +223,8 @@ Disassembly of section .text: 001b5230 65 20 73 65 65 3a 0a 3c 68 74 74 70 73 3a 2f 2f |e see:...| 001b5283 @@ -233,7 +234,7 @@ Disassembly of section .text: ## 我们可以干这档事情嘛? -拜托!当然可以啊!现在笔者就陪你干一票!但是会有点难,因为我们现在不可能依赖libc库,因为动态库的初始化跟咱们的可执行程序有不一致的地方,比如说不会主动的初始化CRunTime,没办法主动链接C库(当然笔者之前做dynamic linker指定过,发现没有用,而且代码崩在了stack函数跳转上,有点无能为力了,搞半天没搞定)等等。 +拜托!当然可以啊!现在笔者就陪你干一票!但是会有点难,因为我们现在不可能依赖libc库,因为动态库的初始化跟咱们的可执行程序有不一致的地方,比如说不会主动的初始化C Runtime,没办法主动链接C库(当然笔者之前做dynamic linker指定过,发现没有用,而且代码崩在了stack函数跳转上,有点无能为力了,搞半天没搞定)等等。 所以,现在我们可以搞一处了: @@ -345,3 +346,7 @@ int main() { Result of 1 + 2 = 3 ``` + +## 现代 CMake 视角 + +本篇演示的那条 `gcc -shared -fPIC -Wl,-e,direct_load_helper_main` 在现代项目里基本不会手敲,而是交给 CMake 接管。`add_library(cclib SHARED cclib.c)` 会自动给共享库加上 `-fPIC` 并产出 `.so`;`visibility("hidden")` 这一手符号可见性控制,对应 `set_target_properties(cclib PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)`,CMake 替你转成 `-fvisibility=hidden`。改入口点(`-Wl,-e`)属于相当少见的特殊需求,CMake 没有内置 target 属性直接覆盖,通常走 `target_link_options(cclib PRIVATE "-Wl,-e,direct_load_helper_main")` 显式塞给链接器。而另一头的可执行程序 `gcc main.c -o main ./libcclib.so`,对应 `add_executable(main main.c)` 加 `target_link_libraries(main PRIVATE cclib)`,链接路径和 `-lcclib` 全部由 CMake 根据 target 依赖图自动算出来,再也不用手挑 `-L`/`-l`。理解了底层 ELF 入口点和符号可见性的机制,回过头看这些 CMake 命令,就明白它们各自接管了哪一段原本要手写的链接器活儿。 diff --git a/documents/compilation/index.md b/documents/compilation/index.md index fcf233e64..0e707f96a 100644 --- a/documents/compilation/index.md +++ b/documents/compilation/index.md @@ -1,6 +1,6 @@ --- title: "编译与链接深入" -description: "深入理解 C/C++ 编译、链接、静态库、动态库" +description: "C/C++ 编译、链接、静态库、动态库、符号可见性的底层机制——懂了这些,链接报错会排查、库会设计、性能能优化" platform: host tags: - cpp-modern @@ -10,23 +10,21 @@ tags: # 编译与链接深入 -> 状态:已有内容 +这一卷讲编译器和链接器在背后到底干了什么:源码怎么变成可执行文件、静态库和动态库的差别、符号怎么被找到又怎么找不到、`undefined reference` 这类报错卡在哪一步。读完您能排查链接报错、设计自己的动态库 ABI、看懂 GOT/PLT 这类底层机制。 -## 概述 - -深入探讨 C/C++ 的编译、链接、静态库、动态库、符号可见性等核心概念。 +> 新手先看 [新手起步卷](/getting-started/) 把环境跑通。这一卷是机制深潜,适合已过卷一基础、想搞懂「为什么」的读者。配 [卷七·工程实践](/vol7-engineering/) 的 CMake 进阶一起读,机制和工具两不耽误。 ## 章节导航 - 编译与链接概述 - 复用的概念 - 创建和使用静态库 - 动态库(上) - 动态库设计 - 符号可见性 - 符号缺失与运行时加载 - 库搜索逻辑 - 动态库细节 - 动态库作为可执行文件 + 编译与链接导论:undefined reference 是怎么来的 + 复用的本质:从源码级到二进制级 + 静态库:用 ar 打包,用 -l/-L 链接 + 动态库(上):为什么必须有 -fPIC + 动态库设计:ABI 与跨工具链接口 + 符号可见性:控制动态库导出什么 + 符号缺失与运行时加载:dlopen 与 LoadLibrary + 库搜索逻辑:链接期与运行期怎么找库 + 动态库细节:PLT/GOT 延迟绑定与符号介入 + 番外:动态库能当可执行文件跑吗 diff --git a/documents/en/compilation/01-compilation-and-linking-overview.md b/documents/en/compilation/01-compilation-and-linking-overview.md index 8bd9d6180..45b7f73da 100644 --- a/documents/en/compilation/01-compilation-and-linking-overview.md +++ b/documents/en/compilation/01-compilation-and-linking-overview.md @@ -8,24 +8,19 @@ tags: - cpp-modern - host - intermediate -title: 'Understanding C/C++ Compilation and Linking: An Introduction' -description: '' -translation: - source: documents/compilation/01-compilation-and-linking-overview.md - source_hash: 444d119fde649b1365d37e1711b54516f531e260c9771cd7bd3c26518df51e66 - translated_at: '2026-06-24T00:25:35.030490+00:00' - engine: anthropic - token_count: 5806 +title: "A Deep Dive Into C/C++ Compilation and Linking: Introduction" +description: 'Start from the undefined reference error that makes you jump, and work out the underlying mechanics of compilation and linking — how symbols get produced, how the linker makes its calls, and where exactly static and dynamic libraries differ.' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking: Introduction +# A Deep Dive Into C/C++ Compilation and Linking: Introduction -## Preface +## Foreword -This is a new series! It is a topic I plan to explore systematically this week. Specifically, we will discuss and summarize a series of topics in C/C++ programming that we often gloss over but which frequently cause us grief—compilation and linking technologies. I believe everyone has encountered headaches like `undefined referenced` errors. I know seeing such errors can be quite daunting (I was recently tormented by `undefined referenced` errors during template instantiation). +This is a new series! It is a topic I plan to dig into systematically and in depth this week. Concretely, we are going to talk through and summarize a set of C/C++ topics that most of us gloss right over but that absolutely torture us along the way — compilation and linking. I believe every one of you has run into the headache that is `undefined reference`, and I bet a fair number of you flinch a little the moment you see it (I, for one, was just recently tortured by an `undefined reference` thrown during template instantiation). -When solving these problems, I believe many of us initially panic and ask AI or search the web, but few truly stop to think—why do we get `undefined referenced` errors in the first place? Leaving aside the times we genuinely forget to provide source files in the build system (which I know happens to many, myself included), there are many times when we really have—at least we think we have—provided the source file, and we can even see it being linked, yet the linking still fails. +When this kind of error shows up, I think most people, at least in the beginning, panic-ask an AI, panic-search the web, but very few actually stop to think — why do we even get errors like `undefined reference` in the first place? Setting aside the cases where we genuinely forgot to hand the source file to the build system (I know many of you have done this; I have too), a lot of the time we really do have it — at least we believe we have it — we did provide the source file, you even watched it link, and yet it just fails. -For example, suppose you write code in a file named `lib.c` and build it into a static library `libutils`. +For example, say you wrote this in a `lib.c` file and turned it into a static library `libutils`. ```c int int_max(int a, int b) { @@ -34,7 +29,7 @@ int int_max(int a, int b) { ``` -Subsequently, we immediately use `int_max` in a C++ file. +Then, right away, we use `int_max` in a C++ file: ```cpp // in usage usage.cpp @@ -49,7 +44,8 @@ int main() { ``` -Then, when we run this command expecting our program to compile successfully, we get a very strange error — +Then we hammer out that command, expecting our program to compile cleanly, and we get a very strange error — + ```cpp @@ -61,33 +57,33 @@ collect2: error: ld returned 1 exit status ``` -This looks strange. We clearly linked `libutils`, and the linker even found it (it didn't complain `/usr/sbin/ld: cannot find -lutils: No such file or directory`, which means it was found). So why did it fail? Furthermore, even if it couldn't find the symbol, why didn't it complain during compilation? I believe that if you can spot the problem immediately, as the author of the [`Beginner's Guide to Linkers`](https://www.lurklurk.org/linkers/linkers.html) suggests, then this introductory article, "Deep Dive into C/C++ Compilation and Linking Technology: Introduction," likely holds nothing new for you. We will discuss the details thoroughly later on, but not here. +This looks downright bizarre. We clearly linked `libutils` — it even found our `libutils` (no complaint about `/usr/sbin/ld: cannot find -lutils: No such file or directory`, which means it found it), so why the error? And even if the symbol really is missing, why didn't it complain at compile time? Look, if you are the kind of reader who, like the author of [`Beginner's Guide to Linkers`](https://www.lurklurk.org/linkers/linkers.html), spots the problem instantly, then this introductory "Deep Dive Into C/C++ Compilation and Linking: Introduction" has nothing new for you. We will get into the real fine details later, not here. -**This blog post assumes you have at least written C programs (although the issue above involves C++, the core of this article is not C++). It is even better if you have encountered errors like `undefined reference` and didn't know how to solve them.** +**This post assumes you have at least written some C (the problem above touches C++ but C++ is not the core of this article). If you have hit an `undefined reference` before and had no idea how to fix it, even better.** -## So, what do the variables and functions we write actually mean? +## So what do the variables and functions we write actually mean? -This question is not for **you**; we are asking **the computer**. To answer this series of questions you might never have thought of, we must first answer a prerequisite question: "How does the computer know about the things we find and can't find?" To phrase it more formally: how does the compiler toolchain collect and look up symbols? How does it transform them into a more manageable form (for example, mapping a function to an address the computer can find)? Those familiar with assembly will immediately grasp how functions work—once the function name is resolved to an address, we simply `call` that address, and the processor's execution flow jumps to that location to fetch instructions and execute the code. Ultimately, our first step is to understand: how do variables and functions, which express business logic in a way we understand, get transformed into addresses that tell the machine where things are located? What happens in the middle? **What do the variables and functions we write actually mean to the computer?** +This question is not aimed at *you* — this question is aimed at the *computer*. To answer that whole string of questions you might never have thought to ask, we first have to answer one question: "The things we find and fail to find — how does the computer even know about them?" Put more formally: how does the compiler toolchain collect and look up symbols? How does it then turn them into something easier to process? (For instance, we map a function to an address the machine can find, and at that point anyone who knows assembly immediately sees how a function works — once the function name becomes an address, you just `call` that address, and the CPU's instruction pointer jumps there, fetches the instruction, and starts running the code.) At the end of the day, our first step is this: the variables and functions we understand, the ones that carry business meaning — how do they get turned into addresses, into "this is where that thing lives" from the machine's point of view? What happens in the middle? **What do the variables and functions we write actually mean to a computer?** -Any computer science student can undoubtedly recite the four classic steps of a program from source code to running on an operating system: preprocessing, compilation, linking, and **execution** (You might ask, isn't execution obvious? Why mention it separately? Good question! We will discuss dynamic loading of dynamic libraries and startup loading in detail later). +Any computer science student can rattle off the four classic steps a program goes through from source file to running on the OS — preprocessing, compilation, linking, and **execution**. (Someone is bound to ask: isn't that obvious? Why call out execution separately? Good question! Dynamic loading and load-time linking of dynamic libraries is something we will talk about carefully.) -To answer the questions above effectively, we need to focus on the latter three stages (preprocessing is a **source-to-source transformation**, such as `#define` expansion and conditional compilation using `#if`, which we will not discuss here). +To answer the question above well, we need to focus on the last three (preprocessing is **a source-code-to-source-code transformation** — for example expanding `#define`s or selecting code via `#if` conditional compilation — and we are not going to discuss it here). -When writing C files—whether in tutorials from your favorite content creators, notes from expert blogs, or your university professor's droning lecture on ancient PPTs—you will be told that we are essentially doing two things: declarations and definitions. Our subjects of discussion are **global variables and functions**, and I must emphasize this point here. +When we write C files — whether it is the Bilibili course UP-zhus, the notes of senior bloggers, or your college professor sleepily reading off his years-old slides — they all tell you the same thing. Writing a C file, we are really only ever doing two things: declaring, and defining. The thing we are talking about is **global variables and functions**, and I have to stress that up front. -- What about local variables? Discussing them is meaningless here. They are served dynamically by the operating system backend after the program runs on the CPU—**assigned to specific registers or allocated memory, but they absolutely do not sit in the executable file on disk!** -- It is particularly worth mentioning that a **definition contains a declaration**. Don't quite understand? For example, if I tell you what A is, haven't I simultaneously told you that an A exists here? +- Local variables? Yeah, no point discussing them. Once the program is on the CPU, the OS backend serves them dynamically for your code — maybe a **specific register, maybe a chunk of memory, but they never sit on disk inside the executable!** +- One thing worth calling out specifically — a definition includes a declaration. Not clear? Example: once you have told me what A is, have you not also told me, at the same time, that an A exists here? -A declaration is simple; we are just loudly proclaiming that something exists here. You ask me what it is? What is its value? Sorry, I don't know; I can only tell you that it definitely exists, and the compiler must find it itself. +A declaration is simple. We are just loudly shouting that something exists here (). You ask me, what is it? What is its value? Sorry, I have no idea, all I can tell you is that this thing definitely exists — where it is, you, compiler, go find it yourself. -A definition is not difficult either; we associate a declaration (which might be the declaration we shouted about elsewhere, or an immediate declaration like `int a = 2`) with its implementation. This action is the **definition**. For global variables, this definition is data. For functions, it is our executable code. The definition of a global variable causes the compiler to allocate specific space for your variable in the resulting executable file. Naturally, it also includes the value you assigned, otherwise, why would you define it? +A definition is not hard either. We take a declaration (maybe one someone else shouted elsewhere, maybe an inline one like `int a = 2`) and we attach the actual stuff to that declaration. That act is a **definition**. For a global variable, that stuff is data. For a function, it is our executable code. A global variable's definition will make the compiler, when it later produces the executable, allocate concrete space for your variable. And of course, the value you assigned has to come along — otherwise what did you define it for? -We know that the relocatable objects generated after compilation expose function names and variables. When writing programs, we subconsciously assume they can be found (astute readers might interrupt me—found when? During compilation or during linking/execution? Don't worry, we'll get to that right away). In serious academic discussion, this is called **symbol visibility**. **Visible symbols are accessible!** This **accessibility of visible symbols** requires a dichotomous discussion: +We know that the relocatable object file produced after compilation (Locatable Objects) will expose function names and variables. When we write programs, we just take for granted that they can be found (a sharp reader immediately interrupts me — found when, at compile time, or at link/run time? Hold on, getting to it). In serious academic discussion this is called **symbol visibility**. **Visible symbols are accessible!** And this **accessibility of visible symbols** needs to be split into two cases: -- Accessibility during compilation—this refers to symbols in C programs that are **not modified by `static`, including global variables and functions**. If you have written C programs, you clearly know that after writing `static int a = 1;` and `static int max(int a, int b){return a > b ? a : b;}` in `a.c`, `b.c` cannot access them at all! You can try it yourself. -- Accessibility during execution—this refers to all global variables and functions, regardless of whether they are modified by `static`. Because they are stored in the executable file, once on the CPU, the operating system must allocate memory storage for the program's lifetime for all global variables and functions, `static` or not. Therefore, for the CPU, they exist for the life of the program. Thus, they are still global, only that some global variables must **only be accessible by specific code** (this is where `static` does its work). +- Compile-time accessibility — for example, in a C program, **any symbol not modified by `static`, including global variables and functions**. You have written C, so you obviously know that after writing global `static int a = 1;` and `static int max(int a, int b){return a > b ? a : b;}` in `a.c`, `b.c` cannot reach them at all. Try it yourself. +- Runtime accessibility — here I mean all global variables and functions, whether or not they are decorated with `static`. Because they are all stored in the executable, once on the CPU the OS has to allocate program-lifetime memory storage for every global variable and function whether it is `static` or not. So as far as the CPU is concerned, they are with the program for its whole life. They are still global; it is just that some globals can **only be accessed by specific code** (this is exactly where `static` does its work). -In other words, any **accessible global variable or function** must exist for the life of the program and needs to be placed in the program's executable file, occupying a certain amount of space (this is also why I said discussing only global variables and functions is meaningful). The rest of the content is completely irrelevant to our question. I have written a program here: +In other words, anything that is an **accessible global variable or function** must live alongside the program for its whole life and be placed into the program's executable, taking up some space (which is exactly why I said only global variables and functions are worth discussing). Everything else is completely unrelated to our question. I wrote a small program here: ```c // demo.c @@ -115,47 +111,49 @@ int main() { ``` -| Symbol | Category | Storage Class | Linkage | Typical Segment | Function | -| :--- | :--- | :--- | :--- | :--- | :--- | -| `un_g_initialized_var` | Variable definition | **Static** duration | **External** | **BSS** (Block Started by Symbol) | Uninitialized global variable, initialized to zero at runtime. | -| `g_initialized_var` | Variable definition | **Static** duration | **External** | **Data** (Initialized Data) | Initialized global variable. | -| `extern_var` | Variable declaration | N/A (Reference) | **External** | N/A (Expected to be defined in another file) | References a global variable defined in another compilation unit. | -| `un_init_local_var` | Variable definition | **Static** duration | **Internal** | **BSS** | Static variable with file scope, uninitialized, initialized to zero at runtime. | -| `init_local_var` | Variable definition | **Static** duration | **Internal** | **Data** | Static variable with file scope, initialized. | -| `local_func` | Function definition | **Function** | **Internal** | **Code** (.text) | Static function, can only be called within the current file. | -| `func` | Function definition | **Function** | **External** | **Code** (.text) | Regular function, available for other files to call. | -| `extern_func` | Function declaration | **Function** | **External** | N/A (Expected to be defined in another file) | References a function defined in another compilation unit. | +| Symbol | Category | Storage Class | Linkage | Typical Segment at Runtime | Function | +| ------------------- | ----------- | ---------------------------- | --------------------- | --------------------------------------------- | ----------------------------------------------------- | +| `un_g_initialized_var` | Variable definition | **Global** (`static` duration) | **External** (`External`) | **BSS** (Block Started by Symbol) | Uninitialized global variable, zero-initialized at runtime. | +| `g_initialized_var` | Variable definition | **Global** (`static` duration) | **External** (`External`) | **Data** (Initialized Data) | Initialized global variable. | +| `extern_var` | Variable declaration | N/A (reference) | **External** (`External`) | N/A (expected to be defined in another file) | References a global variable defined in another translation unit. | +| `un_init_local_var` | Variable definition | **Global** (`static` duration) | **Internal** (`Internal`) | **BSS** | File-scope static variable, uninitialized, zero-initialized at runtime. | +| `init_local_var` | Variable definition | **Global** (`static` duration) | **Internal** (`Internal`) | **Data** | File-scope static variable, initialized. | +| `local_func` | Function definition | **Function** | **Internal** (`Internal`) | **Code** (.text) | Static function, only callable within the current file. | +| `func` | Function definition | **Function** | **External** (`External`) | **Code** (.text) | Ordinary function, callable from other files. | +| `extern_func` | Function declaration | **Function** | **External** (`External`) | N/A (expected to be defined in another file) | References a function defined in another translation unit. | + +Have a think about the table above. If anything trips you up, go look it up yourself to make sense of it. -Take a moment to review the table above. If you find anything confusing, feel free to search for explanations to help you understand it. +## How the C compiler sees our files -## How the C Compiler Views Our Files +Let's get the C compiler moving. Note that your compile command must be -Let's get the C compiler working. Note that your compilation command must be ```cpp -gcc -c demo.c -o demo.o # 欸,注意可不要掉-c,标识只编译 +gcc -c demo.c -o demo.o # hey, do not drop the -c, that flag means compile only ``` -The compiler quietly compiles for a while and gives us the `demo.o` we wanted. So, what exactly is the compiler doing when compiling an entire C translation unit? +The compiler quietly chugs along for a bit and hands us the `demo.o` we wanted. So what is the compiler actually doing while it compiles this one C unit? -Whether you are using Apple Clang, GNU GCC, or Microsoft MSVC, they are all **compilers**. As you have seen, their main job is to convert C files from human-readable text (excluding "mountain-sea" code) into something the computer can understand. The compiler outputs the result as an object file. On UNIX platforms, these object files usually have an `.o` suffix; on Windows, they have a `.obj` suffix. +Whether you are on Apple clang, GNU gcc, or Microsoft's MSVC, they are all **compilers**, and the main job, as you can see, is to turn a C file from human-readable text (mountain of trash code aside) into something the machine can understand. The compiler produces the result as an object file. On UNIX platforms these usually carry a `.o` suffix; on Windows they carry a `.obj` suffix. -Interestingly, circling back to our main topic, our object files ultimately generate at least these two sections: +Interestingly, our object file — tying back to the topic above — at minimum ends up containing these two parts: -- **Machine code**: Machine code consists of specific instructions made of zeros and ones that the computer can understand. -- **Data from global variables**: These correspond to the definitions of global variables in the C file (for initialized global variables, the initial values must also be stored in the object file). +- Machine code: the specific instructions, the 0s and 1s the machine can read. +- Data evolved from global variables: this corresponds to the definitions of global variables in the C file (for initialized globals, the initial value of the variable also has to be stored in the object file). -Now, the question arises. If you look closely at `extern int extern_var;` and `extern int extern_func();`, those familiar with the `extern` keyword will immediately point out that something is wrong—Hmm? Your `extern_var` and `extern_func` aren't implemented at all. Did the compiler not notice this? +Now here is the thing. Look carefully at `extern int extern_var;` and `extern int extern_func();`. Anyone familiar with the `extern` keyword immediately flags something wrong — wait, your `extern_var` and `extern_func` have no definition at all, did the compiler not notice? -I will tell you this: it knows, but **C/C++ compiled languages allow you to have only declarations during compilation without requiring implementations!** I must emphasize this useful yet troublesome feature again: **C/C++ compiled languages allow you to have only declarations during compilation without requiring implementations!** So, when is this issue adjudicated to determine whether you intentionally placed these implementations elsewhere or simply carelessly omitted them? The answer is in the next stage: linking. We will discuss that later; for now, let's keep our focus on the compilation stage. +Here is what I am telling you: it knows. But **C/C++, as a compiled language, lets you get away with only declarations at compile time, no definitions required!** I have to stress this **handy but annoying** trait one more time: **C/C++, as a compiled language, lets you get away with only declarations at compile time, no definitions required!** So when does someone finally decide whether you are intentionally parking the definitions elsewhere, or you just carelessly forgot to write them? The answer is the next stage: linking. We will get to that. For now keep your eyes on the compile stage. ## nm, a handy command -Windows MSVC users, don't bother; you should be using `dumpbin` instead of `nm` (assuming you installed MSVC, or in other words, you are using Visual Studio to write code). However, here, I will discuss `nm` based on the System V output format. +Windows MSVC folks, do not bother. What you should be using is not `nm`, it is `dumpbin` (assuming you actually installed MSVC — what I mean is, you are writing code in Visual Studio). But here, I am going to discuss using `nm` with SystemV output format. + +How do we verify, on the executable we just got, the stuff we have been talking about? Simple — we pull out our `nm` tool and analyze it. Come on, let's try: -How do we verify the content discussed above using the resulting executable file? It is simple; we just take our `nm` tool and analyze it. Come on, give it a try: ```cpp @@ -177,14 +175,15 @@ un_init_local_var |0000000000000004| b | OBJECT|0000000000000004 ``` -Alright, let's take a closer look at this table. What we need to focus on is the **Class** column, as it explains the nature of the entries in this table. +All right, let's look at this table carefully. What you want to focus on is the Class column — it tells us what each entry is. + +- The U class marks an undefined reference, one of the "blanks" mentioned earlier. This object has two such entries: "fn_a" and "z_global". +- The t or T class marks the location of a code definition; the case of the letter tells you whether the function is local (t) or non-local (T) — i.e. whether it was originally declared `static`. Likewise, some systems may also show a section, e.g. `.text`. +- The d or D class marks an initialized global variable; again, the case tells you whether the variable is local (d) or non-local (D). If there is a section, it looks something like `.data`. +- For uninitialized global variables, you get b if it is static/local, or B or C if it is not. In this example the section might look like `.bss` or `*COM*`. -- The **U** class represents **Undefined references**, which corresponds to one of the "blanks" mentioned earlier. In this object, there are two classes: `fn_a` and `z_global`. -- The **t** or **T** class indicates where code is defined; the different classes specify whether the function is a local function (**t**) or a non-local function (**T**)—that is, whether the function was originally declared with `static`. Similarly, some systems might also display a section, such as `.text`. -- The **d** or **D** class represents initialized global variables; similarly, the specific class indicates whether the variable is a local variable (**d**) or a non-local variable (**D**). If a section is present, it resembles `.data`. -- For uninitialized global variables, if it is a static/local variable, it returns **b**; if not, it returns **B** or **C**. In this case, the section likely resembles `.bss` or `*COM*`. +Windows friends: you need to open the `x86 Native Tools Command Prompt for VS Insiders`, navigate to your target C file, and type `cl /c .c`. That tells MSVC to only compile our source file, and the resulting `.obj` is our relocatable object file. At that point we can use the `dumpbin` utility: -For those on Windows, you need to open the **x86 Native Tools Command Prompt for VS Insiders**, navigate to the directory containing your target C file, and enter `cl /c .c`. This instructs MSVC to compile only our source file, and the resulting `.obj` is our relocatable object file. At this point, we can use the `dumpbin` utility: ```cpp @@ -192,7 +191,8 @@ dumpbin /symbols .obj ``` -Let's check the symbols. Here, I will enumerate the results obtained (using the default toolchain in VS2026). +to view the symbols. Let me list out what I got (default toolchain under VS2026): + ```cpp @@ -237,19 +237,19 @@ Summary ``` -Let's strip away the other messy outputs; essentially, we are left with the following table: +Kicking aside all the other noisy output, what it actually boils down to is this table: -| `dumpbin` Output | Meaning | Analogy to Linux `nm` | -| --------------------------------------------------- | ----------------------------------------- | --------------------- | -| `SECT4 notype () External \| _func` | External function defined in `.text` | `T _func` | -| `SECT3 notype External \| _g_initialized_var` | External variable defined in `.data` | `D _g_initialized_var` | -| `UNDEF notype External \| _extern_func` | Undefined external function reference | `U _extern_func` | -| `UNDEF notype External \| _extern_var` | Undefined external variable reference | `U _extern_var` | -| `UNDEF notype External \| _un_g_initialized_var` | Undefined external variable reference | `U _un_g_initialized_var` | +| `dumpbin` output | Meaning | Analogous Linux `nm` | +| ---------------------------------------------------- | -------------------------------- | ------------------------- | +| `SECT4 notype () External \| _func` | External function defined in .text | `T _func` | +| `SECT3 notype External \| _g_initialized_var` | External variable defined in .data | `D _g_initialized_var` | +| `UNDEF notype External \| _extern_func` | Undefined external function reference | `U _extern_func` | +| `UNDEF notype External \| _extern_var` | Undefined external variable reference | `U _extern_var` | +| `UNDEF notype External \| _un_g_initialized_var` | Undefined external variable reference | `U _un_g_initialized_var` | -## Resolving Unknown Symbols: Linking +## Resolving the symbols we do not know about: linking -Now, let's take this a step further. In this step, we address the problem we left open in the section "How the C Compiler Views Our Files." We assume that these external symbols are actually defined in other files: +Now let's push the topic one step further. This step is exactly where we resolve the question we left hanging back in "How the C compiler sees our files". Let us assume that, in some other file, those external symbols really are defined: ```c // demo_extern.c @@ -260,9 +260,10 @@ int extern_func() { ``` -We compile these symbols into relocatable object files as well. The remaining task is to combine these files, which contain a mix of defined and undefined symbols, to **resolve the undefined parts (where only the name is known) in each file** (since our compiler successfully compiled these source files, we know that these symbols were declared, but their definitions have not yet been found). **This is exactly what we need to do during the linking process.** +These symbols likewise get compiled into a relocatable object file. What is left then is to take this mix — definitions here, undefined symbols there — and combine them, **resolving the indeterminate (name-only, definition-unknown) parts in every file** (our compiler compiled these source files fine, which means we declared these symbols, but we have not yet found their definitions). **That is what linking does.** + +Now, after compiling `demo_extern.c` into `demo_extern.o`, we use it to finish the last step of producing our executable: -Now, after compiling `demo_extern.c` into `demo_extern.o`, we use this object file to complete the final step of creating our executable file: ```cpp @@ -270,7 +271,8 @@ gcc demo_extern.o demo.o -o demo_exe ``` -The compilation, of course, passes smoothly. There is no doubt about that. +Compilation goes through cleanly, no surprises. + ```cpp @@ -312,7 +314,8 @@ un_init_local_var |0000000000004024| b | OBJECT|0000000000000004 ``` -Now let's look at the table. It has become quite complex, but that's okay. What we care about most is: +Now look — the table got a lot more complicated, but no worries, the bits we care about are: + ```cpp @@ -321,7 +324,8 @@ extern_var |0000000000004010| D | OBJECT|0000000000000004 ``` -We have finally found the content we are looking for. They are no longer uncertain UNDEF symbols, but defined functions and global variables. We can try removing the implementation of `extern_func`. +We have finally found what we were after. They are no longer indeterminate UNDEF entries — they are now properly defined functions and global variables. We can totally try removing the definition of `extern_func`. + ```cpp @@ -332,7 +336,8 @@ collect2: error: ld returned 1 exit status ``` -A familiar error has appeared! `undefined reference`, which means the linker is complaining that it cannot find the definition for `extern_func`. Let's take a closer look: +There is our old friend! `undefined reference` — it means the linker is complaining that it could not find the definition of `extern_func`. Let's look carefully: + ```cpp @@ -345,22 +350,22 @@ extern_var |0000000000000000| D | OBJECT|0000000000000004 ``` -You can see that `demo_extern` resolves the definition of `extern_var`, but the definition for `extern_func` is missing. Since we only provided these two files, the linker doesn't know where to find your `extern_func`, so it naturally throws this error. +As you can see, `demo_extern` provides the definition of `extern_var`, but the definition of `extern_func` is nowhere to be found, and we only handed the linker those two files. Naturally the linker has no idea where to go look for your `extern_func`, and so it throws this error. -We now understand a key function of the linker: resolving undefined symbols in the smallest possible executable (why "smallest"? We'll discuss that later). Any link where **you fail to provide the corresponding information defining the specific content** (like missing the source code for a used function) will fail! After the linker finishes its search, if any undefined symbols remain (that is, symbols with a Class of `U` in `nm` or `dumpbin`), the linker will raise an error telling you exactly which symbols are undefined. **The solution is quite simple at this point—find the relocatable files for these symbols (generally, build systems keep the source filename and relocatable filename identical, differing only in extension), and provide them during linking!** This is the **only way** to resolve `undefined reference` errors in scenarios without dynamic libraries. +We now understand the linker's key job — resolving the undefined-symbol problem of the minimum executable (why minimum? we will get to that later). Any link where **you failed to provide the concrete content of a definition** (you forgot to write the source code for some function you used) will fail! In the end, after the linker has searched around, as long as there is one undefined symbol left (i.e. any symbol whose Class is U in `nm` or `dumpbin`), the linker will throw an error and list every one of those undefined symbols for you. **At that point the fix is dead simple — find the relocatable file that contains those symbols (in most build systems the source file name and the relocatable file name match, only the suffix differs), and hand it to the linker at link time!** This is the **only** way to fix `undefined reference` in any non-dynamic-library compilation scenario. -Now that we've looked at the output from `nm`, we can answer the whole question: +Now that we have looked at the `nm` output, we can answer the whole question: -- **Q1:** How does the compiler toolchain collect and find symbols? How does it further transform them into a more manageable form? -- **A:** The answer is that the compiler compiles symbols into machine-understandable instructions, **mapping function symbols to an address**. For global variables, it maps a global variable to a specific access location within the data segment. -- **Q2:** **What do the variables and functions we write actually mean to the computer?** -- **A:** It's just associating our addresses with variables that have specific meaning to us; the name you choose doesn't matter. After processing by the compiler and linker, only a string of addresses remains for the computer—if you ask me what that is, I don't know! Ask `nm`! +- Q1: How does the compiler toolchain collect and look up symbols? How does it then turn them into something easier to process? +- A: The compiler compiles symbols into machine-readable instructions, and **maps each function symbol to an address**. For global variables, it maps each one to a concrete access location in the data section. +- Q2: **What do the variables and functions we write actually mean to a computer?** +- A: It just associates our addresses with our meaningfully-named variables — what you call them does not matter at all. After the compiler and linker are done with them, by the time they reach the computer, only a string of addresses is left. You ask me what that is — beats me! Go ask `nm`! -## Extra Topic: What if we have duplicate definitions? +## Side topic: what if we define the same thing twice? -The previous section mentioned that if the linker cannot find a definition for a symbol to connect with a reference to that symbol, it will give an error message. So, what happens if a symbol has two definitions during linking? +The last section said that if the linker cannot find a definition for a symbol to bind its references to, it gives an error. So what happens if, at link time, a symbol has two definitions? -I won't rush to give the answer; try it out yourself first. For example, restore the definition of `extern_func` in `demo_extern`, and then immediately modify our `demo.c` like this: +I am not going to give you the answer right away. Try it yourself first. For instance, restore the definition of `extern_func` in `demo_extern`, and at the same time modify our `demo.c` like so: ```c int un_g_initialized_var; @@ -375,7 +380,7 @@ static int local_func() { return 1; } -int extern_func() { // 拷贝一份定义到这里,return您随意,因为就不影响我们的结论 +int extern_func() { // copy a definition in here, return whatever you like, it does not affect the conclusion return 3; } @@ -383,7 +388,7 @@ int func() { return 2; } -// extern int extern_func(); <- 注释掉外部查找的强调关键字extern +// extern int extern_func(); <- comment out the extern that emphasizes external lookup int main() { return extern_var + extern_func(); @@ -391,7 +396,8 @@ int main() { ``` -We repeat the individual compilation and linking steps above. Soon, we encounter another error you might be familiar with: +We repeat the same separate-compile-then-link steps. Very quickly we get another error you have probably seen before: + ```cpp @@ -404,21 +410,22 @@ collect2: error: ld returned 1 exit status ``` -You might have noticed that it's the same result, because the compiler trusts that **the linker will correctly handle symbol relationships** (it can only compile files one by one! It cannot manage other source files globally! **The symbol resolution for the final compilation unit, including executables, dynamic libraries, and static libraries, is determined by the linker**! I must emphasize this point again!). +Notice — same as before, because the compiler trusts that **the linker can correctly handle any symbol relationship** (it can only compile files one at a time! It cannot see the rest of the source files! **The symbol adjudication for the entire result unit — executable, dynamic library, static library — is decided by the linker!** I have to stress this one more time.) + +So at link time the linker finds that two files contain an identical symbol definition. Naturally, the definitions disagree — it is as if you said A is 1 and also said A is 2. Uniqueness is broken, and picking one arbitrarily would just make the program's behavior uncontrollable. So the linker slaps you right back and refuses to let it through. At least under the GNU toolchain's default behavior today, doing this gets you a `multiple definition`. -Therefore, during linking, the linker discovers that there are identical symbol definitions in two files. Naturally, the definitions conflict—just like saying A is 1, and then also saying A is 2. Uniqueness is broken, and making an arbitrary decision would only make the program uncontrollable. Consequently, the linker rejects this immediately! At least with the default behavior of the GNU toolchain today, doing this will only earn you a `multiple definition` error. +## And that is all the linker does? -## Is that all the linker does? +I asked it like that, so obviously that is not all — right? When you see me hammering on this point over and over, do you feel the question forming: -Since I'm asking this, it obviously isn't that simple, is it? I wonder if, while seeing me repeatedly emphasize this sentence, you've felt this: +- Why is it that **C/C++, as a compiled language, lets you get away with only declarations at compile time, no definitions required**? Why not force you to know everything right away? What a pain. -- Why is it that **C/C++ compiled languages allow you to have only declarations without definitions during compilation**! Why isn't it required to know immediately? It seems like such a hassle. +Think about it calmly for a second. Say I ask you to drop a letter off at the post office. You obviously would not interrupt me with "shut up buddy, first carry the post office over here so I can see the letter and then I will deliver it for you." Far more likely, you would picture an imaginary post office in your head — "all right, I need to go to a place called the post office to drop off a letter." You would then naturally go look for it somewhere else. It is the exact same idea here. We carve out the unresolved symbols and we manage and promise them ourselves — they will show up where they are supposed to. **That is your responsibility, not the compiler's.** With that, we can keep digging: -Think about it calmly for a moment. Let me give you an example. If I ask you to go to the post office to mail a letter, you certainly wouldn't interrupt me: "Shut up, pal. Bring the post office here first, and once I see the letter, I'll help you mail it." Instead, you would visualize a hypothetical post office in your mind, "Okay, I need to go to a place called a post office to mail a letter." You would then look for the letter elsewhere. It's the same principle. We leave symbols unresolved and pending; we manage and promise that they will appear in the right places—**this is your responsibility, not the compiler's**. Now, we can continue our question: +- So, besides handing over source code, can we hand over other forms of information? -- So, besides providing source code, can we provide information in other forms? +Ooh, nice catch. If you looked carefully at what I did just now: -Hey! Excellent observation. If you look closely at what I did here... ```cpp @@ -428,84 +435,84 @@ Hey! Excellent observation. If you look closely at what I did here... ``` -Have you noticed that the linking step we discussed doesn't seem to care about the source files? After all, we search for undefined symbols in relocatable files (`*.o`). So, couldn't we prepare a collection of relocatable files and a set of symbol declaration files in advance? Then, when programming, we wouldn't need to reinvent the wheel. We could simply **inform the compiler via these declaration files that we guarantee these symbols exist**, **generate our own relocatable files during compilation**, and finally **combine these pre-prepared relocatable files with our own during linking to produce an executable file**? +Did you notice that the linking step has, basically, nothing to do with the source files anymore? After all, we look for undefined symbols in the relocatable files (`*.o`). So could we, ahead of time, prepare a whole bunch of relocatable files plus a set of symbol declaration files, and then when we program we would not have to keep reinventing the wheel — we could just **at programming time use those declaration files to tell the compiler "I promise these symbols exist,"** at compile time **produce our own relocatable files by compiling,** and then **at link time combine those pre-prepared relocatable files with our own relocatable files into an executable?** -Congratulations! You have reinvented the concepts of libraries and interface programming! Now you know what header files are for! They are simply files containing symbol declarations. And for those thousands of relocatable files, let's not leave them scattered; let's **bundle them together into a library**. How about that? Of course we can! You have just reinvented the historically famous **static library**. I'm getting a bit excited, but I need to reorganize the concepts we've introduced: +Congratulations! You just reinvented the concepts of libraries and interface-based programming! Now you know what header files are for! They are a set of symbol declaration files! And those thousands of relocatable files — instead of leaving them scattered around, let's **bundle them up into a library**, shall we? Of course! And with that you have invented history's **famous static library**. I am a little excited, but I need to lay the concepts out cleanly: -- **Header files**: These are symbol declaration files that **place the symbol declarations we guarantee to exist**. -- **Static libraries**: These contain the specific definitions of these symbols (all or part of them; the remaining unresolved symbols might depend on other libraries—interesting, right?). +- Header files: i.e. symbol declaration files, **containing the declarations of symbols whose existence we vouch for.** +- Static library: the concrete definitions of those symbols (all of them, or some of them — the unresolved ones might depend on other libraries, fun, right?) -So my point is—the linker can also link libraries. I didn't just say static libraries; there are dynamic libraries too. Let's talk about static libraries first. +So what I am saying is — the linker can also link libraries. I did not say static library specifically. There are dynamic libraries too. Let's do static first. -## Static Libraries: Our Symbol Library +## Static libraries: our symbol library -We can use AR (on Linux or Unix systems) or the Lib tool to bundle all relocatable files into a static library. +We can use `ar` (on Linux or UNIX systems) or the `LIB` tool to gather all the relocatable files into a static library. -> A quick note on details: +> A quick word on the details: > -> - On **UNIX** systems, the command to generate a static library is usually **`ar`**, and the resulting library file usually has an **`.a`** extension. These library files usually start with **"lib"** as a prefix, and when passed to the linker, the **`"-l"`** option is used followed by the library name (without the prefix and extension). For example, **`"-lfred"`** will select the **`libfred.a`** file. (Historically, static libraries also required a program called **`ranlib`** to build a symbol index at the beginning of the library. Nowadays, the **`ar`** tool usually handles this automatically.) -> - On **Windows** systems, static libraries have a **`.LIB`** extension and are generated by the **`LIB`** tool. This can be confusing because "**import libraries**" also use the same extension, which merely contain a list of what is available in a DLL. +> - On **UNIX** systems, the command used to produce a static library is usually **`ar`**, and the resulting library file usually carries the **`.a`** extension. These library files typically also take **"lib"** as a prefix, and when handed to the linker you use the **`-l`** option followed by the name of the library (without the prefix and the extension). For example, **`-lfred`** would select the **`libfred.a`** file. (Historically, static libraries also needed a program called **`ranlib`** to build a symbol index at the start of the library. These days, **`ar`** usually does this work itself.) +> - On **Windows**, static libraries carry the **`.LIB`** extension and are produced by the **`LIB`** tool. This can get confusing though, because "**import libraries**" use the same extension, and an import library only contains a list of what is available in a given DLL. -During the linking phase, when we provide a static library to the linker, the linker holds a table of unresolved symbols and dives into the static library to find these symbols one by one (for example, if symbol A is missing and it is in `Obj1.o`, we will link the entirety of `Obj1.o` in) until all undefined symbol problems are resolved. +For the link stage, when we hand the linker a static library, the linker at that point holds a table of not-yet-resolved symbols, dives into the static library, and pulls those symbols out one by one (for example, symbol A is missing, and it lives in `Obj1.o`, so we pull in all of `Obj1.o`), until we have resolved all the undefined-symbol problems. -Please note the **granularity** of extracting content from the library: if the definition of a specific symbol is needed, the **entire object file** containing that symbol definition is included. This means the process can be "one step forward, one step back"—the newly added object file might resolve an undefined reference, but it will likely bring a whole new set of its own undefined references for the linker to resolve. +Pay attention to the **granularity** of what gets pulled out of the library: if a definition for a particular symbol is needed, the **entire object file** that contains that symbol's definition gets pulled in. This means the process can be "one step forward, one step back" — a newly pulled-in object file might resolve an undefined reference, but it will very likely also bring a whole new set of its own undefined references for the linker to then resolve. -The [`Beginner's Guide to Linkers`](https://www.lurklurk.org/linkers/linkers.html) contains an excellent example, which I have placed below for you to read: +[`Beginner's Guide to Linkers`](https://www.lurklurk.org/linkers/linkers.html) has an excellent example, which I will reproduce below for you to read. -Suppose we have the following object files, and the link command line includes **`a.o`**, **`b.o`**, **`-lx`**, and **`-ly`**. +Suppose we have the following object files, and the link line contains **`a.o`**, **`b.o`**, **`-lx`**, and **`-ly`**. -| File | **a.o** | **b.o** | **libx.a** | **liby.a** | -| -------------- | ---------- | ------- | -------------------------------------- | ---------------------------- | -| **Objects** | a.o | b.o | x1.o, x2.o, x3.o | y1.o, y2.o, y3.o | -| **Definitions**| a1, a2, a3 | b1, b2 | x11, x12, x13; x21, x22, x23; x31, x32 | y11, y12; y21, y22; y31, y32 | -| **Undefined References** | b2, x12 | a3, y22 | x23, y12; y11; y21 | x31 | +| File | **a.o** | **b.o** | **libx.a** | **liby.a** | +| ------------------ | ---------- | ------- | -------------------------------------- | ---------------------------- | +| **Objects** | a.o | b.o | x1.o, x2.o, x3.o | y1.o, y2.o, y3.o | +| **Definitions** | a1, a2, a3 | b1, b2 | x11, x12, x13; x21, x22, x23; x31, x32 | y11, y12; y21, y22; y31, y32 | +| **Undefined refs** | b2, x12 | a3, y22 | x23, y12; y11; y21 | x31 | 1. **Processing `a.o` and `b.o`:** - - The linker will resolve references to `b2` and `a3`. - - At this point, the undefined references remaining are **`x12`** and **`y22`**. + - The linker resolves the references to `b2` and `a3`. + - At this point, the undefined references left are **`x12`** and **`y22`**. 2. **Processing `libx.a`:** - - The linker checks the first library `libx.a` and finds it can pull in **`x1.o`** to satisfy the `x12` reference. - - However, pulling in `x1.o` also brings new undefined references `x23` and `y12`. (The undefined list is now: `y22`, `x23`, and `y12`). - - The linker is still processing `libx.a`, so the `x23` reference is easily satisfied by pulling in **`x2.o`**. - - But this adds `y11` to the undefined list. (The undefined list is now: `y22`, `y12`, and `y11`). - - No other object files in `libx.a` can resolve these remaining symbols, so the linker moves on to `liby.a`. + - The linker checks the first library, `libx.a`, and finds it can pull in **`x1.o`** to satisfy the `x12` reference. + - However, pulling in `x1.o` also brings new undefined references `x23` and `y12`. (The undefined list is now: `y22`, `x23`, and `y12`.) + - The linker is still working through `libx.a`, so the `x23` reference is easily satisfied by pulling in **`x2.o`**. + - But that also adds `y11` to the undefined list. (The undefined list is now: `y22`, `y12`, and `y11`.) + - No other object file in `libx.a` can resolve these remaining symbols, so the linker moves on to `liby.a`. 3. **Processing `liby.a`:** - - In a similar flow, the linker will pull in **`y1.o`** and **`y2.o`**. - - Pulling in `y1.o` adds a reference to `y21`, but since `y2.o` is being pulled in anyway, this reference is easily resolved. - - The final result is: all undefined references are resolved, and some (but not all) object files from the libraries are included in the final executable file. + - Same flow — the linker will pull in **`y1.o`** and **`y2.o`**. + - Pulling in `y1.o` adds a reference to `y21`, but since `y2.o` is being pulled in anyway, that reference is easily resolved. + - The end result: all undefined references have been resolved, and some (not all) of the object files in the libraries have been included in the final executable. -#### The Importance of Link Order +#### The importance of link order -Note that if (for example) `b.o` also had a reference to `y32`, the situation would be different. +Note that if (say) `b.o` also had a reference to `y32`, things would go differently. -- The way `libx.a` is linked would remain the same. +- The way `libx.a` links would stay the same. - When processing `liby.a`, the linker would also pull in **`y3.o`** to resolve `y32`. -- Pulling in `y3.o` adds **`x31`** to the unresolved symbol list. -- At this point, the linker has **finished** processing `libx.a`, so it cannot find the definition for this symbol (in `x3.o`), resulting in a **link failure**. This example clearly illustrates the importance of link order (`libx.a` before `liby.a`). That is to say, the linker does not go backward. When linking, you must clearly define that the dependencies of programming symbols must be progressive dependencies, not circular ones—don't make trouble for yourself! +- Pulling in `y3.o` adds **`x31`** to the unresolved list. +- By that point the linker has already **finished** processing `libx.a`, so it cannot find that symbol's definition (which lives in `x3.o`), and the **link fails**. This example cleanly shows why link order matters (`libx.a` before `liby.a`). In other words, the linker does not backtrack. When you link, you must lay out a clear, layered dependency among your symbols — strictly forward dependencies, no circular ones. Do not make trouble for yourself! -## Dynamic Libraries/Shared Libraries +## Dynamic libraries / shared libraries -Of course, for now, you can simply understand this as a dynamic library. Strictly speaking, there is a slight difference between the two, but in an introduction, being too strict will only scare people away. +For now you can simply think of it as a dynamic library. Strictly speaking, the two are slightly different, but in an introduction being that rigorous right out of the gate would just scare people off. -Dynamic libraries exist primarily to solve a major drawback of static libraries—every executable program holds a copy of the same code. If every executable file contained a copy of functions like `printf` and `fopen`, it would take up a significant amount of unnecessary disk space. +Dynamic libraries exist mostly to fix one obvious flaw of static libraries — every executable carries its own copy of the same code. If every executable contained a copy of functions like `printf` and `fopen`, that would eat a huge amount of disk space for no good reason. -> You can do an interesting experiment: statically link the C library and see how large it is. Please look up the specific instructions yourself; my result was several hundred MB. +> You can run a fun experiment: statically link the C library and see how big it gets. Look up the exact command yourself; on my machine the result was several hundred MB. -Of course, you might say—"I have money; I can add SSDs freely." That's not the most serious problem. The most serious problem is—if the provider's code has a bug, you are done—all the code is written into the executable file, and you cannot use this executable file at all—until someone else spends months compiling it for you! +Of course, you might say — I have money, I can just throw SSDs at it. That is not the worst part. The worst part is this: if the provider's code has a bug, you are cooked — all of that code is hard-baked into the executable, and you cannot use that executable at all — not until somebody else waits a few months, finishes recompiling, and hands you a new one! -To solve these troublesome problems, shared libraries/dynamic libraries appeared (usually represented by the `.so` extension, `.dll` on Windows computers, and `.dylib` on Mac OS X). At this point, the linker adopts an "IOU" approach and defers the payment of the IOU to the moment the program actually runs. Ultimately, it means: if the linker finds that the definition of a symbol exists in a shared library, it will not include the definition of that symbol in the final executable file. Instead, the linker records the name of the symbol in the executable file and which library it should come from. +To solve these painful problems, shared libraries / dynamic libraries showed up (usually denoted with the `.so` extension, `.dll` on Windows, `.dylib` on Mac OS X). At this point the linker takes an "IOU" approach and defers payment of the IOU to the moment the program actually runs. The bottom line: if the linker sees that a symbol's definition lives in a shared library, it will not include that symbol's definition in the final executable. Instead, the linker records, inside the executable, the name of the symbol and which library it is supposed to come from. -When the program runs, the operating system arranges for these remaining linking tasks to be completed "just in time" for the program to run. Before the main function runs, a smaller version of the linker (usually called `ld.so`) checks these "IOUs" and immediately completes the final stage of linking—pulling in the library code and connecting all the code. This means that no executable file has a copy of the `printf` code. If a new, fixed version of `printf` is available, you only need to change `libc.so` to plug it in—the next time any program runs, it will be picked up. +When the program runs, the OS arranges for the remaining linking work to be done "just in time" so the program can run. Before `main` runs, a smaller version of the linker (usually called `ld.so`) checks those "IOUs" and immediately finishes the last phase of linking — pulling in the library code and wiring everything together. That means none of the executables has a copy of the `printf` code. If a new, fixed version of `printf` becomes available, you just swap in the new `libc.so` — and the next time any program runs, it gets picked up. -Shared libraries also have another major difference in how they work compared to static libraries, which is reflected in the granularity of linking. If a specific symbol is extracted from a specific shared library (e.g., `printf` in `libc.so`), the entire shared library is mapped into the program's address space. This is starkly different from the behavior of static libraries, where only the specific object containing the undefined symbol is extracted. +There is one more major way shared libraries differ from static libraries, and it shows up in the granularity of linking. If you pull a particular symbol (say `printf` from `libc.so`) out of a particular shared library, the **entire** shared library gets mapped into the program's address space. This is drastically different from the static library behavior, where only the specific object that contains the undefined symbol gets pulled out. -We will stop here regarding shared libraries. I have a nearly 300-page book called "Advanced C/C++ Compilation Techniques" on hand that specifically discusses dynamic/shared library technology. This is enough to show how complex this topic is. We will discuss it carefully in a later blog post. For the introduction, we will stop here. +We will leave shared libraries at that for now. I have on hand a nearly-300-page book, *Advanced C/C++ Compiling Techniques*, that is dedicated entirely to dynamic / shared library technology. That alone tells you how complicated this topic is. We will get into it carefully in later posts. For the introduction, that is enough. -## Other Topics: What About C++? +## Other topics: what about C++? -#### C++ Name Mangling +#### C++ name mangling -Going back to this `usage.cpp`: +Back to this `usage.cpp`: ```cpp // in usage usage.cpp @@ -520,7 +527,8 @@ int main() { ``` -When we use the `int_max(int a, int b)` function in the C++ file **`usage.cpp`**, the C++ compiler (`g++`) does not simply map the function name to `int_max` like a C compiler would. To support features not found in C, such as **function overloading**, **namespaces**, and **class member functions**, the C++ compiler performs complex encoding on the function names in the source code. This process is called **name mangling**. +When you use the `int_max(int a, int b)` function inside the C++ file **`usage.cpp`**, the C++ compiler (`g++`) does not simply map the function name to `int_max` the way a C compiler would. To support features C does not have — **function overloading**, **namespaces**, **class member functions**, and so on — the C++ compiler performs a complex encoding of the function name from the source code. This process is called **name mangling**. + ```cpp @@ -528,13 +536,14 @@ int int_max(int a, int b); ``` -When the `g++` compiler generates the **`usage.o`** object file, it expects the linker to find a mangled symbol. For example, in a GCC/Linux environment, it might look for a symbol like **`_Z7int_maxii`** (the specific mangling result varies by compiler and platform, but it is **definitely not** a simple `int_max`). +When the `g++` compiler produces the **`usage.o`** object file, it expects the linker to find a mangled symbol — for example, in a GCC/Linux environment it might look for something like **`_Z7int_maxii`** (the exact mangling varies by compiler and platform, but it is **definitely not** a plain `int_max`). + +#### The symbol name in a C library -#### Symbol Names in C Libraries +The catch is that the static library **`libutils.a`** was produced by a **C compiler** (usually `gcc` or `cc`) compiling **`lib.c`**. The C compiler **does not perform name mangling**. So inside **`libutils.a`**, the symbol name for the `int_max` function is simply **`int_max`** (or with an underscore prefix, like `_int_max`). -The problem is that the static library **`libutils.a`** is generated by compiling the **`lib.c`** file with a **C compiler** (typically `gcc` or `cc`). C compilers **do not perform name mangling**. Therefore, in **`libutils.a`**, the symbol name for the `int_max` function is simply **`int_max`** (or possibly with an underscore prefix, like `_int_max`). +You can already see the problem coming: -You will see the issue immediately. ```cpp @@ -542,30 +551,34 @@ g++ usage.cpp -L. -lutils -o usage ``` -1. **`g++`** compiles `usage.cpp` to generate `usage.o`, which contains an **undefined reference** to a **mangled name** (e.g., `_Z7int_maxii`). -2. The **linker** (`ld`) goes to work. It looks for `int_max` in `usage.o`, but only finds a requirement for `_Z7int_maxii`. -3. The linker searches for `_Z7int_maxii` in **`libutils.a`**, but the symbol existing in the library is **`int_max`**. -4. The linker cannot find a matching symbol, so it reports an error: `undefined reference to 'int_max(int, int)'` (Note: the error message displays the C++ style function signature, but the linker is actually looking for its mangled version). +1. **`g++`** compiles `usage.cpp` and produces `usage.o`, which contains an **undefined reference** to the **mangled name** (e.g. `_Z7int_maxii`). +2. The linker (`ld`) gets to work, looks in `usage.o` for `int_max`, but only finds a need for `_Z7int_maxii`. +3. The linker looks inside **`libutils.a`** for `_Z7int_maxii`, but the symbol in the library is **`int_max`**. +4. The linker cannot find a matching symbol, so it reports the error: `undefined reference to 'int_max(int, int)'` (note: the error message shows the C++-style function signature, but what the linker is actually hunting for is its mangled version). -#### Solution: Using `extern "C"` +#### The fix: use `extern "C"` -To solve this problem, we need to tell the C++ compiler: **"Hey, this function was compiled with a C compiler, don't mangle its name!"** We only need to use the **`extern "C"`** linkage specifier around the **function declaration** in the C++ file: +To fix this you need to tell the C++ compiler: **"Hey, this function was compiled by a C compiler, do not mangle its name!"** All you have to do is wrap the **function declaration** in the C++ file with the **`extern "C"`** linkage specifier: ```cpp // in usage usage.cpp #include -// 使用 extern "C" 告诉 C++ 编译器,这个函数的符号名要按照 C 语言的方式处理 -// 即不进行名称修饰,直接查找 'int_max' +// Use extern "C" to tell the C++ compiler that this function's symbol name +// should follow C rules — no mangling, look up plain 'int_max' directly. extern "C" int int_max(int a, int b); int main() { int a = 1, b = 2; std::cout << "max in (" << a << ", " << b << "): " << int_max(a, b) << "\n"; - return 0; // 补充返回语句 + return 0; // added the return statement } ``` -Recompile and link, and the program will run successfully, because the symbol referenced in `usage.o` will now be the simple `int_max`, matching the symbol provided in `libutils.a`. +Recompile and link, and the program runs successfully, because now the symbol referenced in `usage.o` is the plain `int_max`, which matches what `libutils.a` provides. + +## A modern CMake perspective + +All this hand-rolled `gcc -c`, `ar rcs`, `-l`/`-L`, `extern "C"`, `-fvisibility` work has, in today's projects, basically been taken over by CMake. You write `add_library(utils STATIC lib.c)` and CMake automatically calls `ar` to pack it into `libutils.a`; `target_link_libraries(myapp PRIVATE utils)` takes over the assembly of `-lutils` and `-L`, and it also works out the correct link order from the dependency topology — that "the linker does not backtrack" rule from earlier, CMake has lined it up for you. Mixing C and C++ is no problem either: set `set_target_properties(utils PROPERTIES POSITION_INDEPENDENT_CODE ON)` on the C target, or just `add_library(utils SHARED ...)` and let CMake turn on `-fPIC` by default, and the C++ side can link against it. Symbol visibility goes to `CXX_VISIBILITY_PRESET hidden` (equivalent to a global `-fvisibility=hidden`), and you only let the interfaces you genuinely want to export out with `__attribute__((visibility("default")))`. The runtime lookup path for dynamic libraries graduates from a hand-written `LD_LIBRARY_PATH` to `CMAKE_INSTALL_RPATH` paired with `$ORIGIN`, so the `.so` travels along with the executable and deployment no longer leans on tweaking environment variables. In other words, not one of the underlying mechanisms this post talks about has gone away — they have just been wrapped by the build system into a single line of declarative config. diff --git a/documents/en/compilation/02-reuse-concept.md b/documents/en/compilation/02-reuse-concept.md index 5e537e4b9..5e3778e12 100644 --- a/documents/en/compilation/02-reuse-concept.md +++ b/documents/en/compilation/02-reuse-concept.md @@ -8,21 +8,15 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation and Linking: Part 2 — Introduction to Dynamic - and Static Libraries' -description: '' -translation: - source: documents/compilation/02-reuse-concept.md - source_hash: ac892f17702982af7ed7b4f2f00149d2ced4f07cfa0348a188e76ba2afeae68c - translated_at: '2026-06-16T03:27:20.866022+00:00' - engine: anthropic - token_count: 1664 +title: "A Deep Dive into C/C++ Compilation and Linking, Part 2: An Introduction to Static and Dynamic Libraries" +description: 'From source reuse to binary distribution: what problems static and dynamic libraries actually solve, and what really happens at build time and runtime for a dynamic library' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking: Part 2 — Introduction to Static and Dynamic Libraries +# A Deep Dive into C/C++ Compilation and Linking, Part 2: An Introduction to Static and Dynamic Libraries -## What is Reuse, and How Does It Relate to Compilation and Linking? +## What reuse even is, and what it has to do with compilation and linking -Reuse is everywhere, and I'm sure no one would disagree. The reuse we discuss here is the reuse of code. In C++ programming, we can already see a glimpse of this: +Reuse is everywhere, and I'd like to believe nobody seriously disagrees. The reuse we're talking about here is just reusing code. You can already catch a glimpse of this in plain C++: ```cpp template @@ -49,102 +43,112 @@ int main() ``` -For example, the template code and function code above mean we don't have to copy code repeatedly every time we perform addition or compress whitespace in strings. Looking at it this way, code reuse appeared way back in the era when C was dominant. However, I believe this level of code reuse isn't very advanced yet—because this reuse involves source code distribution. In other words, to use your own or someone else's code masterpiece, you have to frantically search for their source files, ensure all dependencies are present, and then add them to your project for compilation. I believe you noticed the problem—in many cases, we simply cannot obtain the source code (trade secrets, if you know, you know). In this situation, we naturally have to consider a lower level of code reuse. That is binary-level distribution. This is the role of static and dynamic libraries, and it is a prerequisite for the several reuse methods at the machine code distribution level that we will discuss later. +Take the template and the plain function above: thanks to them, we don't have to copy-paste the same add and string-trimming logic every time we call them. So in that sense, code reuse has been around since the era when C ruled the world. But I'd argue this level of reuse still isn't all that high-level, because it's source-level distribution. In other words, if you want to reuse a piece of your own past work, or somebody else's masterpiece, you have to dig up their source files in a sweaty scramble, make sure every dependency is in place, and then pull it all into your own project to compile. And here, I'm sure you've already spotted the problem: in plenty of cases, you simply can't get the source code at all. (Trade secrets — you know how it is.) When that happens, we naturally start thinking about a lower-level kind of reuse. That's binary-level distribution. That's what static and dynamic libraries are for, and it's also the prerequisite for the next few chapters where we'll dig into machine-code-level reuse techniques. -## What is a Static Library? +## So what is a static library? -Static libraries might be much simpler than you think. We know that after the compiler pre-processes and compiles source files, we obtain relocatable files. Previously, these relocatable files were directly combined into an executable file. Now, we can change our approach: these common relocatable files can be collected into a library. The next time we look for symbols, we simply link to this library. This way, we hide the source code and can distribute it at the binary level. However, there is a problem—how do we use it? We always need available symbols to tell us the exact entry point. Just like knowing a library has a function that compresses string whitespace, but if we don't know what it's called, we can't use it. So, it's obvious. Possessing these binary files alone is completely insufficient; we need to meet other conditions—that is—exported header files for our programming use. +A static library is probably way simpler than you think. We know that after the compiler finishes preprocessing and compiling a source file, you get a relocatable object file. Previously, those relocatable files would just be packed straight into an executable. Now we can flip the idea around: these generic relocatable files can be bundled up into a library of their own, and next time we need a symbol, we just link against that library. Now we've hidden the source away and we're distributing at the binary level. But there's a catch: how do we actually use it? We always need some kind of available symbol to tell us the real entry point. It's like knowing there's a function in the library that trims whitespace off a string, but if we don't know what it's called, we can't call it. So the conclusion is pretty obvious: just having those binary files is nowhere near enough. We still need one more thing — an exported header file we can program against. -The two figures below illustrate the role of static libraries well. +The two figures below do a decent job of showing what a static library does. ![static_library](./compilation-linking-2-reuse-concept/static_library.png) -But this introduces a new problem. In reality, the code for `libfoo` is identical, yet there are two copies. We don't always want this kind of hard copying. If `libfoo` is small, it's fine; hard drive capacity is relatively inexpensive these days, so we can say redundancy has its advantages. However, in more cases, if `libfoo` has an important security update and we want all software to reload it on the next startup, static libraries seem powerless. Because they simply shifted distribution from the more difficult source code distribution to binary distribution, without solving the more important issue of "load when use." So it doesn't seem elegant. Therefore, in reality, static libraries are not used very widely (I personally rarely use static libraries). +But this introduces a new problem. In reality, libfoo's code is exactly the same in two places, and there are now two copies of it. Sometimes we really don't want this kind of hard copy. If libfoo is small it's fine, and disk space isn't all that expensive anymore, so we can sort of call it redundancy-as-an-advantage. But more often, if libfoo ships an important security update and we want every piece of software to pick it up on its next launch, the static library looks pretty helpless. All it really did was shift distribution from the harder source-distribution model over to binary distribution. It does absolutely nothing about the much more important "load it when you use it" problem. So it's just not that elegant. In practice, static libraries aren't used all that widely (I barely use them myself, either). -## Dynamic Libraries +## Dynamic libraries -So the problem lies in the fact that we performed a deep copy of all binary code, rather than a shallow copy at the reference level. If we allow a portion of the symbols in the executable code to be lazily loaded and determined (this requires us to have a loader that can dynamically load and modify the addresses of these undefined symbols to the real shared symbol addresses), we naturally think—we've reached the library level, so let's go a step further and simply turn this code into purely shareable code. When they need to be available, we load them, and subsequently all executable programs needing this library can safely and directly use this shared code segment without having to clumsily copy a copy themselves. This greatly saves our memory space. This sharing characteristic also allows us to say that a dynamic library is also a shared library (shared code inevitably requires dynamic loading to re-modify shared symbol addresses, so in this context, shared libraries and dynamic libraries are completely interchangeable, and no one deliberately distinguishes them today). +So the real problem is that we deep-copied every binary chunk instead of doing a reference-level shallow copy. If we let some symbols in an executable be lazily resolved at load time (which means we need a loader that can dynamically load and patch those undefined-symbol addresses to point at the real, shared symbol addresses), then the natural thought is: we've already gone to the trouble of making a library, let's go all the way and just turn this code into purely shareable code. When it's needed, load it, and then every executable that depends on this library can calmly use the shared code segment directly, without having to awkwardly keep its own copy. That saves a ton of memory. This shared nature is also why people call dynamic libraries "shared libraries" (shared code inherently has to be loaded dynamically and have the shared symbol addresses patched, so in this sense shared library and dynamic library are completely interchangeable terms; nobody really splits hairs today). -Of course, deeper characteristics of dynamic libraries—for example, to ensure that any executable program needing this library can successfully load symbols inside, we compile all symbols using the `-fPIC` method (Position Independent Code). This makes it very convenient for the loader to perform relocation. +Of course, there's a deeper property of dynamic libraries. So that any executable needing this library can smoothly load its symbols, we compile all the symbols with `-fPIC` (Position Independent Code), which makes life a lot easier for the loader when it does relocations. -## Overview: How Do Dynamic Libraries Actually Work? +## Overview: so how do dynamic libraries actually pull this off? -### Building a Dynamic Library (From Source Code to `libfoo.so` / Versioned `libfoo.so.1.0`) +### Building a dynamic library (from source to `libfoo.so` / a versioned `libfoo.so.1.0`) -Goal: Generate a `.so` that can be dynamically loaded by clients and shared by multiple processes, ensuring clear ABI management (via SONAME/versioning). +Goal: produce a `.so` that clients can dynamically load and that multiple processes can share, with a well-defined ABI (managed through SONAME/versioning). -This is factually almost identical to building an executable, except no startup headers are added. Beyond that, we need to ensure a few basic key points: +It's actually almost identical to building an executable, just without the startup header. Beyond that, we need to nail down a few essentials: -- **Must use Position Independent Code (PIC)**: `-fPIC` (or `-fpic`) is used to generate code that can run at any address (function memory access uses relative addresses or via GOT). Not using PIC will cause the linker/runtime to generate relocation conflicts or non-relocatable segments. -- **Use `-shared` to generate a shared object**: The linker marks the type as a dynamic library (ELF type = DYN). -- **Set SONAME**: Specify the ABI name via the linker option `-Wl,-soname,libfoo.so.1` (the client records the SONAME in DT_NEEDED). The actual file is usually `libfoo.so.1.0`, providing symlinks `libfoo.so.1 -> libfoo.so.1.0` and `libfoo.so -> libfoo.so.1` (convenient for `-lfoo` during development). -- **Control exported symbols (visibility / version script)**: By default, global symbols are exported. You can use GCC `-fvisibility=hidden` + `__attribute__((visibility("default")))` to mark interfaces needing export, or use a linker version script to control the symbol table, reducing API pollution and lowering symbol conflict risks. -- **Optional: Symbol versioning**: Used to support different versions of symbols within the same SONAME, facilitating compatibility management (requires a linker version script). +- **Must use position-independent code (PIC)**: `-fPIC` (or `-fpic`) generates code that can run at any address (function memory accesses use relative addresses or go through the GOT). Skipping PIC will cause the linker / runtime to hit relocation conflicts or non-relocatable segments. +- **Use `-shared` to produce a shared object**: the linker marks the type as a dynamic library (ELF type = DYN). +- **Set the SONAME**: the linker option `-Wl,-soname,libfoo.so.1` declares the ABI name (the client records this SONAME in DT_NEEDED). The actual file is usually `libfoo.so.1.0`, with symlinks `libfoo.so.1 -> libfoo.so.1.0` and `libfoo.so -> libfoo.so.1` (handy for `-lfoo` during development). +- **Control exported symbols (visibility / version script)**: by default every global symbol is exported. You can use GCC `-fvisibility=hidden` plus `__attribute__((visibility("default")))` to mark the interfaces you actually want to export, or use a linker version script to control the symbol table, which cuts down on API pollution and lowers the risk of symbol clashes. +- **Optional: symbol versioning**: lets you support multiple versions of a symbol within the same SONAME, handy for compatibility management (requires a linker version script). -### Building the Client Executable (Based on "Trusting Library ABI/SONAME") +### Building the client executable (on the basis of "trusting the library's ABI/SONAME") -Here, "trusting" means the client trusts the dynamic library's ABI/interface (header files, SONAME, symbol semantics) during construction to not break its expectations. The relationship between the build phase and runtime, and the generated ELF fields, is critical. +"Trusting" here means the client believes, at build time, that the dynamic library's ABI/interface (headers, SONAME, symbol semantics) won't break what it expects. The relationship between the build phase and runtime, and which ELF fields get produced, is critical. -#### What Happens at Link Time (Building the Client) +#### What happens at link time (building the client) -- The client uses header file declarations (`foo.h`) and `-lfoo` to link against the corresponding shared library (or the library's development symlink `libfoo.so`). +- The client uses the header declaration (`foo.h`) and links against the corresponding shared library with `-lfoo` (or the library's dev symlink `libfoo.so`). - The linker will: - 1. Merge the client's own code and object files into an executable file (ELF type = EXEC or DYN (Position Independent Executable)). - 2. **Verify**: Attempt to resolve undefined references (in the case of dynamic linking, the linker usually utilizes the dynamic symbol tables of the specified shared libraries to satisfy these references; if not found, it reports an undefined reference error). - 3. **Do not copy library code**: Unlike static linking, the linker does not copy `.o` code into the executable; instead, it records dependencies in `DT_NEEDED` (recording the library's SONAME) and generates necessary relocations/PLT placeholders. -- Result: The executable contains dynamic segment entries like `DT_NEEDED: libfoo.so.1`, but does not contain the library's implementation code. + 1. Merge the client's own code with the object files into an executable (ELF type = EXEC, or DYN for a position-independent executable). + 2. **Verify**: try to resolve undefined references (for dynamic linking, the linker usually satisfies these against the dynamic symbol table of the specified shared libraries; if it can't find them, you get an undefined reference error). + 3. **Not copy the library code**: unlike static linking, the linker does not copy the `.o` code into the executable; instead it records the dependency in `DT_NEEDED` (recording the library's SONAME) and generates the necessary relocations / PLT stubs. +- Result: the executable contains dynamic-segment entries like `DT_NEEDED: libfoo.so.1`, but no actual library implementation code. -### Runtime Loading and Symbol Resolution (Specific Behavior of the Dynamic Linker / Loader) +### Runtime loading and symbol resolution (what the dynamic linker / loader actually does) -This is the most complex and critical part — at runtime, the `ld.so` (or the corresponding platform's loader) assembles everything into a runnable process address space and resolves symbol references. The details are explained step-by-step below. +This is the most complex and most critical part: at runtime `ld.so` (or the platform's loader) stitches everything together into a runnable process address space and resolves symbol references. Step by step and mechanism by mechanism: -#### Startup Phase — From Kernel to Dynamic Linker +#### Startup phase — from the kernel to the dynamic linker -1. **Kernel loads the executable**: The kernel reads the ELF header -> If the `INTERP` segment exists in the ELF (which is true for most dynamic executables, with a value like `/lib64/ld-linux-x86-64.so.2`), the kernel first maps the dynamic linker into the process address space, then maps the executable's PT_LOAD segments, but does not directly run the executable's `_start`. -2. **Dynamic linker (ld.so) starts execution**: It is responsible for parsing `DT_NEEDED`, finding actual library files, recursively loading dependencies and performing relocation, executing initialization (constructors), and finally handing control over to the executable's entry point (`_start` -> `main`). +1. **The kernel loads the executable**: the kernel reads the ELF header -> if the `INTERP` segment exists in the ELF (almost every dynamic executable has one, with a value like `/lib64/ld-linux-x86-64.so.2`), the kernel first maps the dynamic linker into the process address space, then maps the executable's PT_LOAD segments, but does not directly run the executable's `_start`. +2. **The dynamic linker (ld.so) takes over**: it's responsible for parsing `DT_NEEDED`, locating the actual library files, recursively loading dependencies, performing relocations, running initializers (constructors), and finally handing control over to the executable's entry point (`_start` -> `main`). -#### Mapping (mmap) Library Files +#### Mapping (mmap) the library files -- The loader reads the ELF Program Headers (PT_LOAD) of each dependency `.so`, mapping executable segments (text) as executable read-only, and data segments as read-write, etc.; it also handles page alignment and segment protection (mmap + mprotect). -- Each library is generally mapped only once (multiple processes can share the same physical pages, provided the pages are read-only/shared). +- The loader reads each dependency `.so`'s ELF Program Headers (PT_LOAD), mapping the executable segment (text) as executable-and-read-only and the data segment as read-write, etc.; it also handles page alignment and segment protection (mmap + mprotect). +- Each library is generally mapped only once (multiple processes can share the same physical pages, as long as those pages are read-only / shared). #### Relocations -There are several types of relocations, falling into two important categories: +There are several relocation types, falling into two important categories: -- **Relocations not requiring symbol lookup** (e.g., RELATIVE type): These can be adjusted directly based on the base address (for position independent code, the runtime adds the library base address to the relative offset), usually processed in batches during the startup phase for speed. -- **Relocations requiring symbol lookup** (e.g., R_X86_64_JUMP_SLOT / R_*_GLOB_DAT, etc.): These require searching for the corresponding definition location based on the symbol name (which may be in the executable or other libraries). +- **Relocations that don't need a symbol lookup** (e.g. the RELATIVE type): these can be adjusted directly by the base address (for position-independent code, at runtime the library base address is added to the relative offset), usually processed in a batch during startup, which is fast. +- **Relocations that need a symbol lookup** (e.g. R_X86_64_JUMP_SLOT / R_*_GLOB_DAT, etc.): these need to search by symbol name for the corresponding definition location (which may be in the executable or in another library). -#### Symbol Lookup Order (Default ELF Search Rules, Roughly) +#### Symbol lookup order (the default ELF search rules, roughly) -For resolving a specific symbol (e.g., function `foo`), the loader's search order is usually: +To resolve a given symbol (say the function `foo`), the loader's lookup order is usually: 1. The executable's global symbol table (executable overrides). -2. Traverse each loaded library's dynamic symbol table in the order of the DT_NEEDED list, looking for the first matching global/weak symbol (Note: actual rules are affected by ELF version, runtime flags, RTLD_LOCAL/RTLD_GLOBAL, symbol visibility, etc.). -3. If symbol versioning exists, the version tag must match. -4. If loaded using `dlopen` with `RTLD_GLOBAL`, symbols from these libraries may participate in the resolution of subsequent libraries; `RTLD_LOCAL` does not participate in other subsequent resolutions. +2. Walk each loaded library's dynamic symbol table in DT_NEEDED order, looking for the first matching global/weak symbol (note: the actual rules are affected by ELF version, runtime flags, RTLD_LOCAL/RTLD_GLOBAL, symbol visibility, etc.). +3. If symbol versioning is in play, the version tag has to match as well. +4. If a library was loaded via `dlopen` with `RTLD_GLOBAL`, its symbols can participate in resolving later libraries; with `RTLD_LOCAL`, they don't participate in any subsequent resolution. -> **Important**: **Symbols in the executable take priority** over shared libraries (this is called symbol interposition), so the executable can "override" functions in the library (this is also the basis for `LD_PRELOAD` to replace function implementations). +> Important: **symbols in the executable take priority** over those in shared libraries (this is what's called symbol interposition), so an executable can "override" functions in a library (this is also the foundation of how `LD_PRELOAD` can swap out a function's implementation). ![dynamic_library](./compilation-linking-2-reuse-concept/dynamic_library.png) -The figure above clearly explains the specific process. +That figure above lays the whole flow out clearly. -## Some Comparisons +## Some comparisons -I've compiled a comparison table for your reference: +Let me tidy this into a comparison table you can reference: -| Comparison Item | Static Library | Dynamic Library (Shared / .so/.dll/.dylib) | -| ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| Binary File Nature | `.a` / `.lib`: An archive of several `.o` object files; copies target code into the executable during linking. | `.so` / `.dll` / `.dylib`: A shared object loadable at runtime, usually Position Independent Code (PIC), with SONAME/version info. | -| Executable Integration (Link & Run) | Resolves at link time and copies needed target code into the executable (static binding); runtime does not depend on the library file. | Records `DT_NEEDED` (or equivalent) at link time; at runtime, the dynamic linker maps and relocates/resolves symbols in the process address space (dynamic binding, allows real-time replacement/loading). | -| Impact on Executable Size | Increases executable size (contains actual copies of library code); multiple executables will repeatedly contain the same code. | Smaller executable (only records dependencies); multiple processes share the same read-only/shared pages of the library; runtime occupies extra memory for mapping, GOT, and PLT. | -| Portability | Simple deployment: Executables are usually self-contained (easier to port under same architecture/ABI), but still affected by system/kernel/CRT. | Deployment depends on runtime environment: Requires appropriate shared library versions, loader, search paths (rpath/LD_LIBRARY_PATH/ldconfig); Cross-distro/platform compatibility is more sensitive. | -| Ease of Integration | Simple linking config (direct `-l` / -L or merging .o), no need to consider runtime loading; however, version upgrades require recompiling all clients. | More complex build and deployment (requires `-fPIC`, SONAME, rpath, symbol visibility, version scripts, etc.); but supports runtime replacement, plugins, dlopen, and allows replacing just the library file during upgrades. | -| Ease of Binary Processing/Conversion | Packing/checking/merging is intuitive (`ar`, `nm`, `objdump`); reversing or replacing local symbols is harder (requires re-linking). | Generating and controlling exported symbols is more complex (symbol versioning, visibility); runtime relocation & symbol resolution mechanisms are complex; but runtime `dlopen/dlsym` provides flexible extension capabilities. | -| Suitable for Development | Suitable for: Small tools, embedded/single-file distribution, scenarios without runtime dependencies; convenient for offline/restricted environment deployment. | Suitable for: Large projects, modular design, plugin systems, scenarios needing hot updates or reducing duplicate memory/disk usage; beneficial for team collaboration and independent library release. | -| Other Points Worth Mentioning | - Security/Bug fixes require rebuilding and redistributing all executables.- Copyright/Licenses (like GPL) may impose stricter obligations under static linking.- Usually no PLT overhead for runtime performance (calls). | - Can fix/replace library individually (quick patches).- Risks of runtime hijacking (LD_PRELOAD, RPATH injection) and latency on first call (lazy binding).- Higher requirements for platform ABI/SONAME management and deployment workflows. | +| Aspect | Static library | Dynamic library (Shared / .so/.dll/.dylib) | +| --- | --- | --- | +| Nature of the binary file | `.a` / `.lib`: a bundle of several `.o` object files in archive form; at link time the object code is copied into the executable. | `.so` / `.dll` / `.dylib`: a shared object that can be loaded at runtime, usually position-independent code (PIC), carrying SONAME/version info. | +| Integration with the executable (linking and running) | Resolved at link time and the needed object code is copied into the executable (static binding); at runtime it no longer depends on the library file. | At link time `DT_NEEDED` (or equivalent) is recorded; at runtime the dynamic linker maps it and relocates / resolves symbols in the process address space (dynamic binding, can be replaced/loaded on the fly). | +| Effect on executable size | The executable gets bigger (it contains an actual copy of the library code); multiple executables will carry the same code redundantly. | The executable stays small (only the dependency is recorded); multiple processes share the same library's read-only/shared pages; at runtime extra memory is used for the mapping and for GOT/PLT. | +| Portability | Simple deployment: the executable is usually self-contained (easier to port within the same arch/ABI), but still affected by the system/kernel/CRT. | Deployment depends on the runtime environment: you need the right library version, loader, and search path (rpath/LD_LIBRARY_PATH/ldconfig); cross-distro/platform compatibility is more sensitive. | +| How easy it is to integrate | Link configuration is simple (a direct `-l` / `-L`, or just merge the `.o` files), no need to worry about runtime loading; but a version bump means recompiling every client. | Build and deployment are more involved (you need `-fPIC`, SONAME, rpath, symbol visibility, version scripts, etc.); but it supports runtime replacement, plugins, and dlopen, and an upgrade can just swap the library file. | +| How easy the binary is to manipulate/convert | Packaging/inspecting/merging is fairly straightforward (`ar`, `nm`, `objdump`); reverse-replacing or swapping out individual symbols is harder (needs a re-link). | Generating and controlling exported symbols is more complex (symbol versioning, visibility), and the runtime relocation & symbol-resolution mechanism is complex; but `dlopen/dlsym` at runtime gives you flexible extension. | +| Suitability for development work | Good fit: small tools, embedded / single-file releases, runtime-dependency-free scenarios; handy for offline / restricted environments. | Good fit: large projects, modular designs, plugin systems, anything needing hot updates or reduced duplicated memory/disk footprint; good for team collaboration and independent library releases. | +| Other things worth noting | - A security/bug fix requires rebuilding and re-releasing every executable. - Licensing (e.g. GPL) may carry stricter obligations under static linking. - Usually no PLT overhead on calls at runtime. | - You can patch/replace the library alone (fast hotfixes). - There's a runtime hijacking risk (LD_PRELOAD, RPATH injection) and a delay on first call (lazy binding). - Demands more from the platform ABI/SONAME management and the deployment process. | + +## The modern CMake perspective + +All those `-fPIC`, `-shared`, `-Wl,-soname`, `-fvisibility=hidden` flags — back in the days of hand-typing command lines, you really did have to spell each one out yourself. In modern projects this stuff has basically all been taken over by CMake, and when we write CMakeLists we rarely write these flags raw anymore. + +`add_library(foo SHARED ${FOO_SOURCES})` produces a `.so` directly, and CMake adds `-fPIC` to SHARED targets by default, saving you the hand-copying; `add_library(foo STATIC ...)` calls `ar` to pack up a `.a` for you, basically scripting the whole archive flow from the previous section. On the client side, `target_link_libraries(myapp PRIVATE foo)` takes over `-lfoo` / `-L` in one line, and CMake will even string together the library's interface include directories and transitive dependencies for you. + +`-fvisibility=hidden` shows up in CMake as `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)`, paired with `VISIBILITY_INLINES_HIDDEN ON`. The effect is that only the symbols you explicitly tagged `visibility("default")` get exported — the "reduce API pollution and symbol clashes" idea from the previous section, now landed through attributes. + +As for the runtime `LD_LIBRARY_PATH` grunt work, CMake takes that over with `CMAKE_INSTALL_RPATH` and `$ORIGIN`: when installing to a non-standard directory, you set `INSTALL_RPATH "$ORIGIN/../lib"`, the executable carries its own rpath, and the loader just follows it, no need for the user to go exporting environment variables. SONAME/versioning is on the thinner side, usually paired with `set_target_properties(... VERSION 1.0 SOVERSION 1)` to generate `libfoo.so.1.0` plus the symlinks, with CMake setting up the soft links for you. One sentence: none of these underlying mechanisms went away, they just got tucked away behind declarative target properties by the build system. # Reference -Basically derived from this book: *Advanced C/C++ Compilation Technology* +Most of this is drawn from the book: *Advanced C/C++ Compilation Techniques* (《高级C/C++编译技术》) diff --git a/documents/en/compilation/03-creating-and-using-static-libs.md b/documents/en/compilation/03-creating-and-using-static-libs.md index 4670454e7..afe349dac 100644 --- a/documents/en/compilation/03-creating-and-using-static-libs.md +++ b/documents/en/compilation/03-creating-and-using-static-libs.md @@ -8,88 +8,90 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation and Linking Part 3: How to Create and Use - Static Libraries' -description: '' -translation: - source: documents/compilation/03-creating-and-using-static-libs.md - source_hash: 994ba6406ea27e83d4acd93cbf12656c3fd61db3683f1fc2eefe3320aa388f29 - translated_at: '2026-06-16T03:26:52.565296+00:00' - engine: anthropic - token_count: 877 +title: "A Deep Dive into C/C++ Compilation and Linking, Part 3: How to Build and Use Static Libraries" +description: 'Use ar to pack object files into a lib.a static library, get clear on why the library name has to start with lib, how the linker finds the library through the -l convention, and when you should actually reach for a static library.' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking Part 3: How to Create and Use Static Libraries +# A Deep Dive into C/C++ Compilation and Linking, Part 3: How to Build and Use Static Libraries -In the previous blog post, I briefly introduced the basics of static and dynamic libraries. Here are the links: +In the last post I briefly touched on the basic theory behind static and dynamic libraries. I'll drop the links here: -> [Deep Dive into C/C++ Compilation and Linking - CSDN Blog](https://blog.csdn.net/charlie114514191/article/details/152921903) +> [A Deep Dive into C/C++ Compilation and Linking — CSDN blog](https://blog.csdn.net/charlie114514191/article/details/152921903) > -> [Deep Dive into C/C++ Compilation and Linking 2: Intro to Dynamic and Static Libraries - CSDN Blog](https://blog.csdn.net/charlie114514191/article/details/154828385) +> [A Deep Dive into C/C++ Compilation and Linking, Part 2: An Introduction to Static and Dynamic Libraries — CSDN blog](https://blog.csdn.net/charlie114514191/article/details/154828385) -So, we have previously covered the essence of static libraries. Although using dynamic libraries is a more fundamental strategy for code sharing today, for the sake of completeness—and because I personally prefer using static libraries to package code that depends only on the most basic runtime (I don't have a strong technical reason for this, I just don't like dumping a massive pile of relocatable files directly into the linker)—let's discuss this further. +So earlier on we already talked through what a static library actually is at its core. Even though today, shipping code through dynamic libraries is the more default strategy, I want to cover static libraries anyway for completeness — and also because I personally like packing anything that only depends on the bare-bones `C/C++` runtime into a static library. (Honestly I don't have some deep technical reason for the choice; I just don't enjoy dumping a giant pile of relocatable files straight onto the linker.) -## How to Create a Static Library? +## So how do you actually make a static library? -### The `ar` Tool +### The `ar` tool -A natural question arises: we have learned the basic principles of static libraries (an organic combination of several relocatable files), but how do we create one? The answer is a small yet powerful tool—`ar` (Archiver). +So a pretty natural question comes up: last time we learned the basic idea behind a static library (an organic bundle of several relocatable files), but how do you actually build one? The answer is a small but powerful tool called `ar` (Archiver). -Let me briefly introduce `ar`! It is a tool used to create, modify, and extract **archive files**. These files usually end with the `.a` extension (where 'a' stands for archive). The most common use is packaging object files (`.o` files) to create **static link libraries**. On Linux, if we decide to name a library `demo`, the generated library will typically be `libdemo.a`. +Let me give `ar` a quick intro. It's a tool for creating, modifying, and extracting **archive files**. These archives usually end in `.a` (the *a* stands for archive), and the most common use is to bundle up object files (`.o` files) into a **static library**. On Linux, we like to — for a static library at least — say we decide the library's name is going to be `Charlie`. Then the file we generate is generally `libCharlie.a`. -You might wonder why it must start with `lib`. Isn't generating `demo.a` more intuitive? The core reason is: **this is dictated by the working conventions of the linker we will use later.** Most often, when we compile and link objects, we dispatch `ld` to link target libraries and relocatable files. Generally, high-level build tools use `-L` to specify the search directory and `-l` (lowercase L) to find the library. For example, when we try to provide a `math` static library at a known path to `main.c`, we might write: +Some of you might be puzzled: why does it have to start with `lib`? Wouldn't `Charlie.a` be way more intuitive? Right, so the core reason is this: **it's a working convention the linker relies on when we come back around to do the linking**. Most of the time, when `gcc`/`g++` is getting ready to link against some target, it dispatches `ld` to link the target libraries and relocatable files, and the upper-layer build tools are in the habit of using `-L` to set the folder search path together with `-l` (that's a lowercase L) to find the library. For example, when we want to feed `main.c` the well-known `math` static library, we'd write something like: + + +```cpp + +gcc main.c -lmath -```bash -gcc main.c -L./lib -lmath -o app ``` -The linker does not directly look for a file named `math`. Instead, following conventions, it attempts to find a file named **`libmath.a`** (static library) or **`libmath.so`** (dynamic library). Simply put: +The linker isn't going to go looking for a file literally named `math`. Instead, following the convention, it tries to find a file named **`libmath.a`** (static library) or **`libmath.so`** (dynamic library). Put simply: -- The name following the `-l` parameter (`math` in this example) is called the "library name". -- The linker automatically adds the prefix `lib` to this name. -- Then, based on the situation (and priority), it adds `.a` (static library) or `.so` (dynamic library) suffixes to form the complete filename. +- The name after the `-l` flag (`math` in this example) is called the "library name". +- The linker automatically prepends the prefix `lib` to that name. +- Then, depending on the situation (and the priority order), it appends `.a` (static library) or `.so` (dynamic library) and so on, to build the full filename. -Therefore, **naming the library file in the `libname.a` format is to actively cater to the linker's automatic search mechanism**. If the library file is not named in this format, the linker cannot find it via the convenient `-l` option. You would have to link by specifying the full path to the library file, which is clumsy and inconvenient. This also leads to a serious problem that we will revisit when discussing dynamic libraries (it doesn't matter for static libraries, as they are packaged into the target file). +So **naming your library file `lib.a` is you proactively playing along with the linker's auto-lookup mechanism**. If you don't name the file this way, the linker can't find it through the convenient `-l` option, and you're stuck with the clumsy fallback of pointing it at the library's full path by hand, which is really annoying. There's also a nastier problem hiding in here, and we'll dig it back up when we get to dynamic libraries (static libraries don't care; their code just gets packed into the target file anyway). -### Common `ar` Commands +### Some common `ar` command forms -The basic syntax of `ar` is relatively simple; it requires an **operation code** (similar to a main command) and some **modifiers** to specify specific behaviors. +The basic syntax of `ar` is fairly simple. It wants an **operation code** (think of it as a main command) and some **modifiers** to spell out the exact behavior. + +```bash +ar [operation code][modifiers] -```text -ar -operation modifiers archive_name member_list ``` -| **Operation Code** | **Description** | **Common Modifiers** | **Example Command** | -| ------------------ | ------------------------------------------------------------------------------- | -------------------- | -------------------------- | -| **r** | **Insert/Replace**: Adds files to the archive. If a file with the same name exists, it replaces it. | `v` (verbose) | `ar r libdemo.a file1.o` | -| **t** | **List**: Displays the list of files contained in the archive. | `v` (verbose) | `ar t libdemo.a` | -| **x** | **Extract**: Extracts (unpacks) files from the archive. | `v` (verbose) | `ar x libdemo.a file1.o` | +| **Code** | **Description** | **Common modifier** | **Example** | +| -------- | --------------------------------------------------------------------------- | ------------------- | ------------------------------- | +| **`r`** | **Insert / replace**: adds files to the archive. If a same-named file already exists in the archive, it gets replaced. | `v` (verbose) | `ar rv libmy.a file1.o file2.o` | +| **`t`** | **List**: shows the list of files contained in the archive. | `v` (verbose) | `ar t libmy.a` | +| **`x`** | **Extract**: pulls (unpacks) files out of the archive. | `v` (verbose) | `ar xv libmy.a` | -> Checking the man page is always a good idea: [ar(1) - Linux man page](https://linux.die.net/man/1/ar) +> Reading the man page is always a good idea: [ar(1) - Linux man page](https://linux.die.net/man/1/ar) ### What about Windows? -This is actually handled by the MSVC toolchain. However, few people do this manually on Windows; most people delegate the task to the massive IDE: Visual Studio, or like me, use lightweight Visual Studio Code and delegate to CMake. For specific details, you can check the detailed logs of CMake compilation. I won't expand on this here due to space constraints. +This part is really handled by the MSVC toolchain, but honestly very few people do it by hand anymore. On Windows, almost everybody delegates to the giant IDE, Visual Studio — or, like me, they prefer the lighter Visual Studio Code and let CMake handle it. You can dig into the verbose CMake build log to see the actual details; I'm not going to expand on it here, mostly for space reasons. + +## So where do we actually use static libraries? + +I thought about it for a while, pooled together my own shallow engineering experience (you could almost call it none at all) and the bits of material I've read, and honestly, today, static libraries are almost entirely replaceable by dynamic ones. But in these scenarios, a static library is clearly the better fit. I tend to use static libraries more in embedded work, so I'll frame it that way: -## Where Do We Use Static Libraries? +- **Simpler distribution:** you only ship one executable, no need to drag along a pile of `.dll` (Windows) or `.so` / `.dylib` (Linux/macOS) files. +- **Version lock:** when you need to **absolutely guarantee** your program is using a specific version of a library and won't get messed with by whatever other versions happen to live on the user's system. +- **Small tools or embedded systems:** in environments with strict limits on file count, or on dynamic-linking support. -I thought about this carefully, combining my shallow engineering experience (which is practically non-existent) with the materials I've read. Actually, today static libraries can almost be replaced by dynamic libraries. However, in these scenarios, using static libraries is clearly more appropriate. Since I use static libraries more in embedded development, I will frame it this way: +## And on the flip side, reasons not to use a static library -- **Simplified Distribution:** You only need to distribute one executable file, without carrying a bunch of `.dll` (Windows) or `.so`/`.dylib` (Linux/macOS) files. -- **Version Locking:** You need to **absolutely guarantee** that your program uses a specific version of a library, free from interference by other versions on the user's system. -- **Small Tools or Embedded Systems:** In environments where the number of files or dynamic linking support is strictly limited. +Looking back at the last post, we already explained how a static library actually works. So it's easy to come up with the first reason not to use one: -## Conversely, Reasons Not to Use Static Libraries +#### Executable bloat -Reviewing the previous blog, we explained how static libraries work. So, it is easy to think of the first reason not to use them: +When you care about **reusing an interface**, going static obviously makes every library and executable that depends on it blow up in size (Executable Bloat). So **for anything whose whole purpose is to expose a functional interface to other dependencies — a module that's otherwise fully standalone — please use a dynamic library**. In that case we want the code dependency to live exactly once, and let the OS and loader sort out all the symbol mapping. That's clearly the better call. -#### Executable Bloat +#### Updates force a recompile and a re-release (Hot Reloading Request) -When focusing on **interface reuse**, using static libraries obviously leads to a sizeable increase in the size of all libraries and executables that depend on them (Executable Bloat). Therefore, **for any module intended to provide functional interfaces to other dependencies and remain independent, please use a dynamic library**. In this case, we keep the code dependency in a single copy and let the operating system and loader automatically coordinate all symbol mapping relationships, which is clearly better. +In scenarios that care about **hot updates**, going static obviously doesn't make sense. For instance, sometimes it's awkward to just swap out the whole executable, and we'd rather only update one sub-dependency — say a library we use gets a vulnerability found by an enthusiastic open-source programmer who reports it back to you in time. In other words, once we find a security hole in the library, or a bug that needs fixing, going static means we have to **recompile and redistribute the entire application** (static linking has turned that code into part of the body, not just a dependency you swap). -#### Updates Require Recompilation and Redistribution (Hot Reloading Request) +#### Potential symbol collisions and version-management headaches (Symbol Collisions) -In scenarios focusing on **hot reloading**, using static libraries is clearly unreasonable. For example, when it is inconvenient to replace the entire executable file directly, but we only need to update a sub-dependency (for instance, a library we use has a vulnerability discovered by an enthusiastic open-source programmer and promptly reported to us)—meaning we found a security vulnerability or a bug in the library—with a static library, we must **recompile and redistribute the entire application (static linking makes this code part of the main body rather than a required dependency)**. +If we link **multiple versions** of a static library, or libraries with **same-named symbols**, into a single executable, the compiler / linker will try to sort it out, but the risk is high (if I'm remembering right, it goes by symbol strong/weak rules, and on a tie it just drops one at random). It really is dangerous — nobody likes their program playing a guessing game. -#### Potential Symbol Collisions and Version Management Issues +## The modern CMake perspective -If we link **multiple versions** of static libraries or libraries with **identical symbol names** into the same executable, the compiler/linker will attempt to resolve them, but the risk is high (if I recall correctly, it discards them randomly based on symbol strength and equality). This is really dangerous; no one likes to play a guessing game with their program. +This whole hand-rolled `ar rvs lib.a` plus `-l` / `-L` flow has basically been taken over by CMake in modern projects. One line, `add_library(Charlie STATIC src/foo.cpp src/bar.cpp)`, automatically compiles the source files into `.o` and then calls `ar` to pack out `libCharlie.a` — the `STATIC` keyword maps to a static library, `SHARED` maps to a dynamic library, and if you leave it off CMake picks one based on the `BUILD_SHARED_LIBS` switch. On the linking side you don't have to hand-write `-l` / `-L` anymore either; `target_link_libraries(myapp PRIVATE Charlie)` gets it done in one shot, and CMake auto-expands that into `-lCharlie` and stuffs the library's directory into `-L`. That `lib` prefix convention from the start of this post? It's quietly carrying that load for you behind the scenes. As for "simpler distribution" and "version lock" — those reasons to pick static still hold; it's just that today you don't have to type `ar` by hand for them anymore. diff --git a/documents/en/compilation/04-dynamic-libraries-1.md b/documents/en/compilation/04-dynamic-libraries-1.md index ee62d52d6..78f0ae302 100644 --- a/documents/en/compilation/04-dynamic-libraries-1.md +++ b/documents/en/compilation/04-dynamic-libraries-1.md @@ -8,65 +8,63 @@ tags: - cpp-modern - host - intermediate -title: 'In-depth Understanding of C/C++ Compilation and Linking 4: Dynamic Libraries - A1: Basic Discussion on `-fPIC`' -description: '' -translation: - source: documents/compilation/04-dynamic-libraries-1.md - source_hash: b035c5b652786dbf2edbb5e094d0cc2100f2250e17c9a5b34394b4f20092feaa - translated_at: '2026-06-24T00:24:41.582619+00:00' - engine: anthropic - token_count: 481 +title: 'Deep Dive into C/C++ Compilation and Linking, Part 4: Dynamic Libraries A1, the Basic Discussion around `-fPIC`' +description: 'Get straight on why dynamic libraries must be compiled with -fPIC: GOT/PLT indirection is what lets the code segment be shared, plus the real engineering reason a static library sometimes has to carry -fPIC too.' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking: Part 4 - Dynamic Libraries A1: Basic Discussion on `-fPIC` +# Deep Dive into C/C++ Compilation and Linking, Part 4: Dynamic Libraries A1, the Basic Discussion around `-fPIC` ## Preface -I have been quite exhausted lately, juggling a pile of tasks while preparing to start work. I finally found a moment to catch my breath and continue updating this series. +Things have been pretty tiring lately, juggling a pile of stuff and getting ready to start a new job, so these past few days I finally got a little breather and picked this blog series back up. -This article focuses on the basics of dynamic libraries. Specifically, we will discuss how to create dynamic libraries (primarily on Linux; using the MSVC toolchain at the command line on Windows is rather tedious, and mature build systems already handle the details there, so I won't go into detail on building dynamic libraries on Windows), as well as issues related to symbol name mangling. +This piece mostly covers the basics of dynamic libraries. In particular, how you actually build one (focused on Linux; on Windows the MSVC toolchain is honestly a bit punishing from the command line, and a lot of mature build systems have already papered over the basic details, so I won't go into building dynamic libraries on Windows in depth here), along with a few questions around symbol decoration and mangling. -## How to Create Dynamic Libraries on Linux +## How to Create a Dynamic Library on Linux -Creating a dynamic library isn't difficult, but it generally requires following these steps: +Creating a dynamic library is not that hard, but you basically have to follow a couple of steps: -- The integrated binary relocatable files must be compiled with the Position Independent Code flag (`-fPIC`). -- Link these PIC relocatable files and pass the `-shared` flag. +- The relocatable object files that go into it have to be compiled with the position-independent flag (`-fPIC`, i.e. flags Position Independent Code). +- Gather those PIC relocatable object files together, and pass the `-shared` flag at link time. ## Let's Talk About `-fPIC` -This option is quite interesting. Of course, there isn't much to say about the `-shared` option; it simply tells the compiler/linker to produce a dynamic library. However, why must these relocatable files be compiled as position-independent code? +This option is interesting. The `-shared` option has nothing much to say about it, it just plainly tells the compiler/linker to link a dynamic library. But why do those relocatable files have to be compiled as position-independent code? -In the book *Advanced C/C++ Compilation*, three progressive questions are raised: +In *Advanced C/C++ Compiling Techniques*, three progressively deeper questions are raised: - What is `-fPIC`? -- Is `-fPIC` mandatory for creating dynamic libraries (`.so`)? -- Is `-fPIC` used only when compiling dynamic libraries? +- Do you have to use `-fPIC` to build a dynamic library (`.so`)? +- Is `-fPIC` only ever used when building dynamic libraries? -Below, I have summarized the book's arguments, combined with my own perspectives. +Below, I'll lay out the book's reasoning, mixed with a bit of my own take. #### What is `-fPIC`? -`-fPIC` stands for **Position-Independent Code**. In other words, the generated machine instructions **do not rely on a fixed load address**. They can be loaded into any memory location at runtime without modifying the code itself. This aligns perfectly with our understanding of dynamic libraries. Ultimately, we export symbols from a dynamic library for use by third-party applications or other libraries. Therefore, we cannot assign a fixed mapping address to these dynamic library symbols. Instead, at load time, we dynamically assign an offset address mapped to the user's process address space to achieve symbol reuse. To break it down: +`-fPIC` stands for `Position-Independent Code` (generating position-independent code). In other words, the machine instructions that come out **do not depend on a fixed load address**, and at runtime they can be loaded into any memory location without the code itself having to be patched. That lines up nicely with how we intuit a dynamic library to work. In the end, we always want a dynamic library to export its symbols for other third-party applications or libraries to use, so obviously we cannot pin an absolute mapping address onto those dynamic library symbols ahead of time. Instead, when it gets reused, a relative offset is handed out dynamically and mapped into the consumer process's address space, which is what makes symbol reuse possible in the first place. Step by step: -- `-fPIC` causes symbols to use **relative addresses** rather than absolute addresses for mapping. -- Global variables are accessed indirectly via a **GOT (Global Offset Table)**. -- Function calls are made through jumps via a **PLT (Procedure Linkage Table)**. +- `-fPIC` makes the compiler map symbols through **relative addresses** rather than absolute ones. +- Global variables are accessed indirectly through the **GOT (Global Offset Table)**. +- Function calls jump through the **PLT (Procedure Linkage Table)**. ------ -#### **Is `-fPIC` mandatory for creating dynamic libraries (.so)?** +#### **Do you have to use `-fPIC` to build a dynamic library (`.so`)?** -Strictly speaking, not necessarily. Of course, if we consider that 32-bit PCs are virtually extinct today (forgive my ignorance; I haven't seen a physical 32-bit PC in years, though I have dabbled a bit with MCUs), one might hold an affirmative attitude toward the proposition above. +Honestly, and said very seriously, not necessarily. Of course, if we are talking about today, where 32-bit PCs are basically on their way out (forgive my ignorance, I have genuinely never seen a physical 32-bit PC, though I have fiddled with MCUs a tiny bit), then we can probably affirm the proposition above. -Let's think about this: modern dynamic libraries and shared libraries are synonymous, with multiple processes sharing the code segment of the dynamic library. It is perfectly reasonable for the code to reside at any virtual address for different processes. Otherwise, the loader would have to perform **relocation patching** on the code during loading, preventing the code segment from being shared and slowing down the loading process. +Let's think about it. In modern terms "dynamic library" and "shared library" are synonyms: several processes want to share a dynamic library's code segment. For different processes, requiring that the code be droppable at any virtual address is perfectly reasonable. Otherwise the loader has to do **relocation patching** on the code at load time, which means the code segment can no longer be shared and loading gets slower. -However, on x86-64, it is still possible to compile usable dynamic libraries without `-fPIC`. However, you lose the benefits of sharing, and loading becomes slower (since addresses for all symbols must be fixed at load time). Therefore, if we think seriously about it, my conclusion is: +But x86-64 is not like that, you can still build a working dynamic library without `-fPIC`. It's just that you lose the sharing property, and loading gets slower (every symbol's address has to be fixed up at load time). So thinking about it seriously, my conclusion is: -> **Today, compiling dynamic libraries must include the `-fPIC` flag. The benefits far outweigh the drawbacks (unless you are worried about negligible performance overhead, which implies a different scenario).** +> **Today, compiling a dynamic library must carry the `-fPIC` flag, it does nothing but good. (If you are really worried about that tiny performance hit, pretend I said nothing, you are optimizing for a different scenario.)** -#### Is `-fPIC` exclusive to dynamic libraries? Can we use `-fPIC` with static libraries? +#### Is `-fPIC` exclusive to dynamic libraries? Can a static library use `-fPIC`? -Obviously not; otherwise, there would be no need to make this flag independent. In fact, we can absolutely apply `-fPIC` to relocatable files intended to be compiled into static libraries. This is very common. +Obviously not, otherwise there would be no reason to break this flag out on its own. In practice, we can absolutely also slap `-fPIC` onto relocatable files that are destined to be a static library, and this is very common. -For example, I have a large project on hand that generates a static library for each sub-module, and then packages all generated static libraries in a directory into a single dynamic library. As we discussed in previous articles, a static library is simply a collection of relocatable files. Therefore, it naturally follows that in the scenario described above, we must compile the source files contained in these static libraries with the `-fPIC` flag. +For example, I have a fairly large project on hand where each submodule first builds a static library, and then all the static libraries generated under that directory get packaged into a single dynamic library. We discussed earlier that a static library is just a simple collection of relocatable files, so naturally we realize that in this situation we **must** compile the source files for the relocatable files inside that static library with the `-fPIC` flag. + +## A Modern CMake Perspective + +The whole "manually `-fPIC` plus `-shared`" flow above is basically taken over by CMake today. `add_library(foo SHARED foo.cpp)` on Linux automatically feeds `-fPIC` to the compiler and `-shared` to the linker, no manual fiddling needed. More general is `set(CMAKE_POSITION_INDEPENDENT_CODE ON)` or `set_target_properties(foo PROPERTIES POSITION_INDEPENDENT_CODE ON)`, and this one also applies to static libraries, which lines up exactly with the "static library gets packed into a dynamic library" scenario I mentioned above: turn PIC on for the static library target too, then `target_link_libraries(big_so PRIVATE foo)`, and CMake makes sure the `.o` the downstream dynamic library receives is already position-independent. As for the GOT/PLT indirection details, CMake does not paper those over for you, it just hands the right flag to the compiler on time. The underlying ELF mechanics are still exactly what this piece has been talking about. diff --git a/documents/en/compilation/05-dynamic-library-design.md b/documents/en/compilation/05-dynamic-library-design.md index dbaf89d02..168704f22 100644 --- a/documents/en/compilation/05-dynamic-library-design.md +++ b/documents/en/compilation/05-dynamic-library-design.md @@ -8,43 +8,37 @@ tags: - cpp-modern - host - intermediate -title: 'In-depth Understanding of C/C++ Compilation and Linking Technologies 6 — A2: - Dynamic Library Design Basics — ABI Interface Design' -description: '' -translation: - source: documents/compilation/05-dynamic-library-design.md - source_hash: 8974278b432cc3da8a20980a1d79444c095a87d9b4ba3684ca0c50ce008c0617 - translated_at: '2026-06-24T00:25:08.933724+00:00' - engine: anthropic - token_count: 2093 +title: 'Deep Dive into C/C++ Compilation and Linking, Part 6 — A2: Dynamic Library Design Basics, the ABI Design Interface' +description: 'Get clear on the low-level pain of dynamic library ABI design: why C++ name mangling does not port across compilers, the static-object initialization-order trap, and how a C-style export interface plus a complete ABI header file lets you sidestep the ABI hookup mess.' +cpp_standard: [11, 14, 17, 20] --- -# In-Depth Understanding of C/C++ Compilation and Linking Technology 6 – A2: Dynamic Library Design Basics – ABI Interface Design +# Deep Dive into C/C++ Compilation and Linking, Part 6 — A2: Dynamic Library Design Basics, the ABI Design Interface -## Introduction +## Preface -In this blog post, I attempt to summarize and categorize some of the more important technical points in the **design** of dynamic libraries, such as the design and export of binary interfaces. +In this post I'm trying to pull together some of the more important technical points on the **design** side of dynamic libraries — things like the design and export of the binary interface. -## So, why bring up the binary interface? +## So, how come we're dragging the binary interface into this -Fundamentally, the ultimate goal of designing a dynamic library (which I believe we must always keep in mind) is to reuse our code for others to use. Therefore, we must consider the details of code collaboration. In a blog post a long time ago, we simplified the abstract concept of a dynamic library to specifying a number of exported symbols, written in header files or dedicated export files, serving as an **interface** for other users to know how to call the target functionality, alongside the underlying hidden details of machine code. +At its core, the whole end goal of designing a dynamic library (and I do think this is something you have to keep firmly in mind) is to hand our code over to other people for them to reuse. So the details of how that code collaboration actually works are exactly what we have to think about. Way back in an earlier post we already boiled the abstract concept of "dynamic library" down to this: a set of exported symbols written down in a header file or a dedicated export file, so other users know how to call into the target functionality — that's the **interface** — plus a bunch of hidden concrete machine code behind it. -However, we know that what is written in human-readable files, such as function names under classes and global variable names in header files, is indeed an interface, but we obviously know this does not constitute a **binary interface**. It seems we have always been accustomed to the idea that as long as we export specified symbols and provide the machine code for the implementation, everything is fine. But, due to the free nature of C++ (note, I didn't say C; in fact, this problem erupts intensely in reusable libraries written in C++), the **transformation from human-readable APIs to machine-compatible ABIs handled by compilers from different vendors is inconsistent!** This has created a series of issues that are no laughing matter. Below, I enumerate why and under which circumstances our C++ symbol export and ABI matching produce serious inconsistencies, causing trouble in software construction. +But here's the thing. We know that the function names and global variable names sitting under various classes inside a human-readable file (say, a header file) really are an interface, but we also clearly know that's not a **binary interface**. For the longest time we've all sort of gotten used to the idea that as long as we exported the right symbols and shipped the concrete machine code, everything was hunky-dory. Except, thanks to C++'s freewheeling nature (and notice I did not say C — in practice this problem blows up almost entirely on reusable libraries written in C++), the **path from the human-readable API to the machine-to-machine ABI that each compiler vendor produces** is not consistent! And that births a whole series of problems that are not even a little bit funny. Let me lay out, point by point, exactly which situations make our C++ symbol export and ABI hookup go badly inconsistent and turn software builds into a headache. -#### More complex naming rules +#### A more complicated naming scheme -The mapping from C++ functions to linker symbols is decided by the compiler vendor. Although standards exist to constrain compiler vendors to generate as universal symbols as possible, unfortunately, taking g++ and MSVC as examples, there are still gaps. This means that the same symbol lookup and mapping rules prevent a project using the MSVC compiler from directly using a library built with the g++ compiler without pain (my other meaning is, if we don't adopt some methods, we need to obtain the source code and recompile; the methods we discuss later will finally allow us to avoid this approach). +The mapping from a C++ function down to a linker symbol is decided by the compiler vendor. Sure, there are some standards out there nudging compiler vendors toward producing symbols that are as portable as possible, but unfortunately, taking g++ and MSVC as the example, there is still a gap — so much so that an MSVC-built project can't painlessly drop its symbols straight into a g++-built project (and by that I also mean: without taking some measures, we'd have to grab the source and recompile. The methods we get to later on are exactly what finally let us dodge that move). -Readers might ask: How did this happen? Actually, we can easily think of a series of code like this: +You might be asking: how does that happen? Well, it's pretty easy to picture a chunk of code like this: ```c++ -// 在C++中,我们很喜欢将一些方法放置到类中, -// OOP就是推介我们这样做的! +// In C++, we love sticking methods inside classes, +// OOP literally recommends we do this! class Foo { public: void someFunc(int a, const char* b); }; -// 或者,我们喜欢放置一些工具类的函数到单独的命名空间中 +// Or, we like putting utility-style functions into a dedicated namespace namespace charlies_tools { std::vector split(const std::string& waited_splits, const char ch); std::vector split(const std::string& waited_splits, const std::string_view sp_view); @@ -52,9 +46,10 @@ namespace charlies_tools { ``` -As C++ programmers, we naturally use these features to avoid symbol-level conflicts and improve readability in software engineering. +As C++ programmers, we reach for these features completely naturally — they sidestep a bunch of symbol-level collisions and make the code read better in a real software-engineering context. + +Let's see what the symbol names look like coming out of g++: -Let's examine the symbol names generated by the g++ compiler: ```text @@ -64,7 +59,8 @@ Let's examine the symbol names generated by the g++ compiler: ``` -Next, let's look at what MSVC produces: +And now here's what MSVC spits out: + ```text @@ -74,15 +70,15 @@ Next, let's look at what MSVC produces: ``` -In reality, we can see that the symbols written into the relocatable file look completely different. This indicates that we cannot use our symbols in a generic way. Furthermore, features like function overloading allow us to use the same function name with different parameter lists within a single object file. Consequently, our toolchain has to go to great lengths to handle these complexities. +Honestly you can see the symbols written into the relocatable file look absolutely nothing alike, which tells us straight up that we can't portably share these symbols across the two. On top of that, we've got overloading and a whole pile of features that let us offer the same function name with different parameter lists and have them all coexist in one object file — and that means our toolchain has to bend over backwards to sort all of it out. -This modification is known as **name mangling**. Great, now we have to deal with these annoying issues. +This decoration is called **name mangling**. Great. Now we get to deal with this mess. -#### Static Data Initialization +#### Static-storage initialization -In C, data is often considered to be *trivial* (aha, I prefer C too; at least it's predictable). Due to legacy code conventions, we are accustomed to initializing these variables during the linking phase. However, in C++, we know that this data can be objects, which implies the existence of constructor calls. If these objects are initialized under **order-independent conditions** (meaning the objects do not have dependencies, such that static object A must be initialized before static object B), then it isn't an issue. The real problem arises with order-dependent static objects. Since the CPU executes the program, there are often no fixed constraints on the initialization order of these objects, which can easily lead to random program crashes. +In C, our data can mostly be treated as trivial (honestly, I get why somebody would prefer C too — at least it's controllable). For legacy reasons we've gotten into the habit of initializing those variables back at link time. But in C++, as we know, that data can be an object, which means there's a constructor call involved. Now, if all those objects are **under conditions where initialization order doesn't matter** (meaning, none of them form a dependency — we don't have to insist that static object A get initialized before static object B), then it's honestly fine. The scary case is when you do have order-dependent static objects, because once the program is running on the CPU, the initialization order for those objects has no fixed constraint, and that's a really easy way to give yourself random crashes. -Fortunately, this problem is easy to handle. We know that the initialization of data scattered freely in the data segment is uncertain. However, if we place the object inside a function, it is initialized only when execution reaches that point. Therefore, if static object A indeed must be initialized before static object B, we can do the following: +Of course, this one is pretty easy to handle. We know the initialization order of data scattered across the data segment is uncertain, but if we tuck it inside a function, then we only initialize the object at the moment execution actually reaches it. So if static object A really does have to be initialized before static object B, we can do something like: ```cpp static void init_a_and_b() { @@ -97,11 +93,11 @@ auto dummy = [](){ ``` -## So, How to Design a Less Troublesome Binary Interface +## So, how do you design a binary interface with fewer headaches -#### Designing C-Style Export Interfaces +#### Design a C-style export interface -Of course, you do not need to strictly follow C naming conventions to avoid conflicts like a C programmer would. The point here is to avoid exporting the distinct ABI symbol rules characteristic of C++. The solution is to decorate the symbols you decide to export with the `extern "C"` identifier. +Now, you don't have to actually go full C programmer and start dodging collisions using C naming conventions — what I mean here is just: don't export the C++-flavored ABI symbol rules that differ all over the place. The trick is to decorate the symbols you've decided to export with the `extern "C"` marker. ```cpp @@ -117,52 +113,53 @@ extern "C"{ ``` -This makes the interface presented to the linker much cleaner. +That way the interface the linker ends up seeing looks a whole lot cleaner. -#### Header Files Providing Complete ABI Declarations +#### Ship a header file with a complete ABI declaration -Here, a "**header file providing complete ABI declarations**" refers to a header file (`.h`) that contains all necessary declarations, enabling the compiler to **fully understand** the interface of a library or module. This allows it to: +By "**a header file with a complete ABI declaration**" I mean a header file (`.h`) that carries all the declarations the compiler needs to **fully understand** a library's or module's interface, so it can: -1. **Correctly compile** code that calls the library. -2. **Correctly generate** machine code that interacts with the functions in the library. +1. **Correctly compile** the code that calls into the library. +2. **Correctly generate** the machine code that talks to the functions inside the library. -The core of this "complete ABI declaration" is that it includes not only function names but also all details that affect binary-level interaction. Therefore, we use the term "header file providing complete ABI declarations." Let's discuss what such a header file contains: +The heart of this "complete ABI declaration" is that it isn't just the function names — it covers every detail that affects interaction at the binary level. That's exactly why we say things like "ship a header file with a complete ABI declaration." So let's walk through what such a header actually contains: -##### Function Declarations +##### Function declarations -This is the most basic part. It tells the compiler the function's name, return type, and parameter types. +This is the most basic part. It tells the compiler the function's name, its return type, and its parameter types. ```cpp -// 不完整的声明 - 只知道名字和类型,但可能隐藏问题 +// Incomplete declaration - you only know the name and types, +// but problems can hide underneath int do_something(int a, int b); -// 更完整的声明 - 增加了extern "C"和异常规范 +// A more complete declaration - adds extern "C" and a noexcept spec extern "C" int do_something(int a, int b) noexcept; ``` -##### Type Definitions +##### Type definitions -If we use custom structs or classes in an interface, their memory layout must be well-defined. +If the interface uses a custom struct or class, its memory layout has to be pinned down explicitly. ```cpp -// 完整的结构体声明,编译器能确定其大小和内存布局 +// Complete struct declaration - the compiler can pin down its size and memory layout struct MyData { int id; double value; char name[32]; }; -// 函数使用这个结构体 +// A function that uses this struct extern "C" void process_data(const MyData* data); ``` -If the header file does not contain the full definition of `MyData`, the compiler does not know the size of `sizeof(MyData)`, and cannot correctly allocate stack space or pass arguments for the `process_data` function call. +If the header file doesn't carry the complete definition of `MyData`, the compiler has no idea what `sizeof(MyData)` is, and it can't correctly allocate stack space or pass arguments for the call to `process_data`. -##### Macro and Constant Definitions +##### Macros and constant definitions -Used to define magic numbers or configurations used in the interface. +These are for the magic numbers or configuration values used inside the interface. ```cpp #define MAX_BUFFER_SIZE 1024 @@ -174,24 +171,28 @@ extern "C" int initialize_lib(int buffer_capacity = MAX_BUFFER_SIZE); ##### Including other headers -If a declaration depends on other types (such as `size_t` from the standard library or custom types), we need to include the corresponding headers. +If a declaration depends on other types (like the standard library's `size_t`, or a custom type), you need to pull in the matching headers. ```cpp -#include // 为了使用 size_t +#include // so we can use size_t extern "C" void* allocate_buffer(size_t size); ``` +## A modern CMake perspective + +Most of the ABI-design pain covered in this piece gets taken over by the CMake build system in modern projects. The `extern "C"` part is still hand work on your end, but symbol visibility can be driven by `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)` to hide every symbol by default, then export only the ones you want through the macros that `generate_export_header` spits out — so you don't accidentally leak all your internal C++ mangled symbols downstream. `target_link_libraries(foo PUBLIC bar)` strings together the transitive dependencies, header search paths, and `-l` / `-L` for you, so the downstream side only has to link once. `add_library(foo SHARED)` automatically feeds `-fPIC` to every object file, saving you the typing. When the ABI hookup has to be cross-platform, set the `PUBLIC_HEADER` property on the dynamic library and pair it with `install(TARGETS ...)`; on Unix CMake drops the headers into `include/`, and on Windows it handles the import-library side of `__declspec(dllexport/dllimport)`. That's what actually turns the C-style export interface you wrote by hand into "one header, usable everywhere." + # Reference -## Verifying Names +## Confirming the names -If you would like to see the symbol differences produced by the MSVC and g++ compilers firsthand, we will explain how the results above were generated. +If you want to see the symbol difference between the MSVC compiler and g++ with your own eyes, let me walk through how I produced the results above. -We used MSVC compiler version 19.44.35217 and g++ version 15.2.1. +The MSVC compiler version I used is 19.44.35217, and the g++ version is 15.2.1. -We saved the sample code above into a file named `test.cpp`. +Let's drop the sample code above into test.cpp: ```cpp #include @@ -213,7 +214,8 @@ void charlies_tools::split(const std::string& waited_splits, const std::string_v ``` -Then, on a Linux machine, we use the `-c` flag to compile `test.cpp` into machine code only: +Then, on a Linux machine, use the `-c` flag to translate test.cpp into machine code only: + ```bash @@ -221,7 +223,8 @@ g++ -c test.cpp -o test_name ``` -Then, we use the `nm` command to inspect the ABI. +Then use `nm` to inspect the ABI: + ```text @@ -232,9 +235,10 @@ Then, we use the `nm` command to inspect the ABI. ``` -This yields the results listed in the main text. +And that's the result I quoted in the body of the post. + +For MSVC, you need to open the VS Developer Prompt to initialize the MSVC toolchain environment. Same as before, let's say you've saved the code to test.cpp; then, using the `cl` compiler and passing a compile-only flag plus the latest C++ standard flag, you'll get the following output: -For MSVC, we need to open the VS Developer Prompt to initialize the MSVC toolchain environment. Then, assuming we have saved the code to `test.cpp`, we can use the `cl` compiler with the compile-only flag and the latest C++ standard flag to obtain the following output: ```text @@ -252,7 +256,8 @@ test.cpp ``` -Next, we use the `dumpbin` utility to obtain the following: +Then, using the `dumpbin` little tool, you get: + ```text diff --git a/documents/en/compilation/06-symbol-visibility.md b/documents/en/compilation/06-symbol-visibility.md index 091e48001..f50336be0 100644 --- a/documents/en/compilation/06-symbol-visibility.md +++ b/documents/en/compilation/06-symbol-visibility.md @@ -8,94 +8,135 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation Technology — Dynamic Libraries A3: Discussing - Symbol Visibility' -description: '' -translation: - source: documents/compilation/06-symbol-visibility.md - source_hash: c611694e844be24e2b55b6a8d46b5ea620a65c311e178f2702c25890e864bdcf - translated_at: '2026-06-16T03:27:02.371993+00:00' - engine: anthropic - token_count: 1010 +title: "Deep Dive into C/C++ Compilation — Dynamic Libraries A3: Let's Talk Symbol Visibility" +description: 'A chat about symbol visibility at the ABI layer: inspecting exported symbols with nm/dumpbin, and the four ways to control it — GCC''s -fvisibility, __attribute__((visibility)), #pragma visibility, and MSVC''s __declspec(dllexport/dllimport).' +cpp_standard: [11, 14, 17, 20] --- -# Understanding C/C++ Compilation Technology — Dynamic Libraries A3: A Discussion on Symbol Visibility +# Deep Dive into C/C++ Compilation — Dynamic Libraries A3: Let's Talk Symbol Visibility -Some readers might find this concept strange—what exactly is symbol visibility? Is it related to the C++ keywords `private` or `public`? It is worth noting that it is not; the latter are basic features provided by language syntax and compiler checks. Here, we discuss symbol visibility at a more aggressive level, referring to visibility at the symbol ABI (Application Binary Interface) level. +Some of you reading along might be wondering — what exactly *is* symbol visibility? Is it the same as those C++ keywords, `public` or `private`? Worth pointing out: no, it isn't. Those two are a baseline feature handed to you as a package deal by the language syntax and the compiler's checks. What we're discussing here, symbol visibility, is something more aggressive — it refers to visibility at the ABI layer of a symbol. -#### Tips: How to View ABI Symbols +#### Tips: How to Inspect ABI Symbols -> Veterans can skip this section +> Veterans can skip this one. -Since some readers might be encountering this type of article for the first time, they may not yet know how to "view visible symbols contained in a given relocatable object file, an executable composed of such files, or a library." I plan to supplement this guide with instructions on how to perform this basic operation on major Windows and Linux platforms. +Since some of you might be landing on this article for the first time and may not yet be clear on how to pull off "inspect the visible symbols contained in a given relocatable object file, or in an executable / library file built from relocatable files", I'm planning to take a moment here and fill in how to do this basic operation on the major Windows and Linux platforms. -##### GNU/Linux Platform +##### GNU/Linux -It is very simple; we only need to use the `nm` tool. Suppose we have a library file `libfoo.so` ready for inspection. Entering the following command will do the trick. +Simple enough, we just reach for the `nm` tool. Say we have a library file `libsome_helpers.so` ready to inspect — punch in the command below and you're done. + + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ nm -D libsome_helpers.so +00000000000010e9 T add + w __cxa_finalize@GLIBC_2.2.5 + w __gmon_start__ + w _ITM_deregisterTMCloneTable + w _ITM_registerTMCloneTable +00000000000010fd T minus -```bash -nm -D libfoo.so ``` -##### Windows Platform +##### Windows + +This one's easy too. Say I want to inspect `CCWidget.dll` — to see its exported symbols, it's `dumpbin /EXPORTS CCWidgets.dll` + + +```cpp + +D:\NewQtProjects\CCWidgetLibrary\build\Desktop_Qt_6_10_0_MSVC2022_64bit-Release\widgets>dumpbin /EXPORTS CCWidgets.dll +Microsoft (R) COFF/PE Dumper Version 14.44.35217.0 +Copyright (C) Microsoft Corporation. All rights reserved. + +Dump of file CCWidgets.dll + +File Type: DLL -This is straightforward. Suppose I intend to check `CCWidget.dll`. To view the exported symbols, use: + Section contains the following exports for CCWidgets.dll + + 00000000 characteristics + FFFFFFFF time date stamp + 0.00 version + 1 ordinal base + 481 number of functions + 481 number of names + + ordinal hint RVA name + + 1 0 00002F50 ??0AnimationConfig@animation@CCWidgetLibrary@@QEAA@$$QEAU012@@Z + 2 1 00002F80 ??0AnimationConfig@animation@CCWidgetLibrary@@QEAA@AEBU012@@Z + 3 2 00002F50 ??0AnimationConfig@animation@CCWidgetLibrary@@QEAA@XZ + 4 3 00002FD0 ??0AnimationSession@animation@CCWidgetLibrary@@QEAA@$$QEAU012@@Z + 5 4 00003010 ??0AnimationSession@animation@CCWidgetLibrary@@QEAA@AEBU012@@Z + 6 5 00003050 ??0AnimationSession@animation@CCWidgetLibrary@@QEAA@XZ + 7 6 00012E00 ??0AppearAnimation@animation@CCWidgetLibrary@@QEAA@PEAVQWidget@@@Z + 8 7 000184E0 ??0CCBadgeLabel@@QEAA@PEAVQWidget@@@Z + 9 8 00014130 ??0CCButton@@QEAA@AEBVQIcon@@AEBVQString@@PEAVQWidget@@@Z + 10 9 000141F0 ??0CCButton@@QEAA@AEBVQString@@PEAVQWidget@@@Z、 + ... -```powershell -dumpbin /EXPORTS CCWidget.dll ``` -## How Do Mainstream Toolchains Control Symbol Visibility? +## How Do the Mainstream Toolchains Control Symbol Visibility? -Returning to the main topic, how do mainstream toolchains control symbol visibility? Let's discuss them separately. +So back on topic — how do the mainstream toolchains control symbol visibility? Let's split it up and take them one at a time. -#### How to Control Symbol Visibility under GNU/Linux +#### How to Control Symbol Visibility Under GNU Linux -##### Method 1: Directly Passing `-fvisibility` to the Compiler to Control All Symbol Exports +##### Way 1: Pass -fvisibility Straight to the Compiler to Control Export of All Symbols -The first method is the most brute-force approach. Suppose we have a private dependency project and do not want to expose any symbols at all. In this case, we can pass `-fvisibility` to gcc/g++ during compilation. By default, for the GNU C/C++ toolchain, **any symbol without explicit visibility modifiers or specifications is public**. That is, `default`. If we want to hide them, we need to specify `hidden` when generating the dynamic library, causing all symbols not to be exported. I haven't used this personally, but I have found documentation on its usage. +The first way is the bluntest. Say we have a private dependency project that we absolutely don't want to expose any symbols from — at compile time we can hand `-fvisibility` to gcc/g++. By default, the GNU C/C++ toolchain treats **any symbol that hasn't been given any visibility decoration or an explicit visibility** as public. That is, `-fvisibility=default`. If we want to hide them, then in the step that builds the dynamic library we need to set it to `-fvisibility=hidden`, and all the symbols will go un-exported. I haven't actually used this one myself, for what it's worth — just dug up that the usage exists. -##### Method 2: The Most Common Method: Using Attributes +##### Way 2: The Most Common Approach — Using `__attribute__((visibility(< "default" | "hidden" >)))` + +I really like specifying it this way. Taking a simple logging library I threw together as a toy project as the example: for every API I plan to make public at the ABI layer, I force `__attribute__((visibility("default")))` on it; conversely, any symbol that shouldn't be used gets slapped with `__attribute__((visibility("hidden")))`. -I prefer this method of specification. Taking a simple logging library I wrote as a toy project for example: for all APIs planned to be public at the ABI level, I explicitly specify `__attribute__((visibility("default")))`. Conversely, for any symbol that should not be used, I apply `__attribute__((visibility("hidden")))`. ```cpp -#define API_EXPORT __attribute__((visibility("default"))) -#define API_LOCAL __attribute__((visibility("hidden"))) -class API_EXPORT Logger { - // ... -}; +#ifdef CCLOG_BUILD_SHARED +#define CCLOG_API __attribute__((visibility("default"))) +#define CCLOG_PRIVATE_API __attribute__((visibility("hidden"))) +#else +#define CCLOG_API +#define CCLOG_PRIVATE_API +#endif -void API_LOCAL internal_helper(); ``` -##### Method 3: Modifying a Group of Aggregated Symbols +##### Way 3: Decorating a Cluster of Aggregated Symbols with `#pragma visibility push/pop` -If you really need to handle visibility modifications for a massive number of symbols but don't want to add macros to each symbol one by one as in the example above, you can use the compiler's preprocessor directives. +Say you've genuinely got a huge pile of symbols on your hands whose visibility you need to flip, and you don't want to glue the macro I used as an example above onto them one symbol at a time — you can reach for the compiler's preprocessing directive. ```cpp -#pragma GCC visibility push(default) -// ... public symbols ... -#pragma GCC visibility pop +#pragma visibility push("hidden") + +int private_api_add(int a, int b); +int api_minus(int a, int b); + +/* Remember to pop for preventing the leak of unwanted visibility decorations */ +#pragma visibility pop -#pragma GCC visibility push(hidden) -// ... internal symbols ... -#pragma GCC visibility pop ``` -#### How Windows MSVC Handles This +#### How Windows MSVC Does It -Unfortunately, exporting symbols from Windows DLLs involves a relatively complex decoration mechanism. That is, symbols intended for export need to be decorated with `__declspec(dllexport)`, and when using these symbols, we need to mark them with `__declspec(dllimport)`. +Bad news here — exporting symbols from a Windows DLL dynamic library comes with a comparatively fussy decoration mechanism. That is, every symbol you plan to export needs to be decorated with `__declspec(dllexport)` to be exported; and then when we go to use those symbols, we still have to tag them with `__declspec(dllimport)`. ```cpp -// In the DLL header -#ifdef BUILDING_DLL - #define API_PUBLIC __declspec(dllexport) +#ifdef CCLOG_BUILD_SHARED +/* If we plan to exports symbols to DLL, we need to decorate symbols by this */ +/* Others in case can use the symbols */ +#define CCLOG_API __declspec(dllexport) #else - #define API_PUBLIC __declspec(dllimport) +/* If we plan to import symbols from DLL, we need to decorate symbols by this */ +#define CCLOG_API __declspec(dllimport) #endif -class API_PUBLIC Widget { - // ... -}; ``` + +## From a Modern CMake Perspective + +All that hand-rolled `-fvisibility=hidden`, `__attribute__((visibility))`, `-fPIC` legwork — in a project managed by CMake, the build system has basically taken it off your hands. `add_library(foo SHARED ...)` slaps `-fPIC` onto the target for you by default (static libraries don't get it by default; reach for `set(CMAKE_POSITION_INDEPENDENT_CODE ON)` when you need it). Want to hide symbols across the board? Set `set_target_properties(foo PROPERTIES CXX_VISIBILITY_PRESET hidden)` on the target and CMake will feed `-fvisibility=hidden` to the compiler for you; pair it with `VISIBILITY_INLINES_HIDDEN ON` and the inline functions get tucked away too. As for that whole `dllexport` / `dllimport` back-and-forth on Windows, CMake ships `GenerateExportHeader` — one macro generates a cross-platform `FOO_API` for you: on Linux it expands to the `visibility` attribute, and on Windows it auto-expands into `dllexport` or `dllimport` depending on whether, at compile time, you're building the library or using it, saving you from hand-writing the `#ifdef` plumbing yourself. So if you're writing a library today, most of this low-level decoration is something you don't have to type by hand — a line or two in the CMake target's property panel and you're all set. diff --git a/documents/en/compilation/07-symbol-missing-and-runtime-loading.md b/documents/en/compilation/07-symbol-missing-and-runtime-loading.md index f89a91470..ccf5c4d01 100644 --- a/documents/en/compilation/07-symbol-missing-and-runtime-loading.md +++ b/documents/en/compilation/07-symbol-missing-and-runtime-loading.md @@ -8,60 +8,54 @@ tags: - cpp-modern - host - intermediate -title: 'In-depth Understanding of C/C++ Compilation Technology — Dynamic Libraries - A4: Link-Time Symbol Missing Behavior and Runtime Dynamic Loading' -description: '' -translation: - source: documents/compilation/07-symbol-missing-and-runtime-loading.md - source_hash: 2f848caece654c8136cefb8c2fc7d988f0af62905153ff32c6c990871169e250 - translated_at: '2026-06-24T00:25:31.082918+00:00' - engine: anthropic - token_count: 1424 +title: 'Deep Dive into C/C++ Compilation — Dynamic Libraries A4: Undefined-Symbol Behavior at Link Time and Runtime Dynamic Loading' +description: 'A cross-platform comparison of how tolerant each platform is about undefined symbols at link time, plus a walkthrough of runtime dynamic loading with dlopen / LoadLibrary and a C++ plugin factory pattern.' +cpp_standard: [11, 14, 17, 20] --- -# In-Depth Understanding of C/C++ Compilation Technology—Dynamic Libraries A4: Link-Time Symbol Missing Behavior and Runtime Dynamic Loading +# Deep Dive into C/C++ Compilation — Dynamic Libraries A4: Undefined-Symbol Behavior at Link Time and Runtime Dynamic Loading -This blog post is particularly important. Here, we plan to discuss the behavior on different platforms (Windows and GNU/Linux) when undefined symbols exist during the generation of our executable files or when other library files depend on them. We will also cover the fairly significant topic of programming for runtime dynamic library loading. +This post is going to matter a bit more. What I'm planning to talk through here is how the different platforms (Windows and GNU/Linux) behave when an executable we're building, or another library, depends on a symbol that's left undefined; and then the more interesting topic, which is the programming side of dynamically loading a dynamic library at runtime. -## Platform Differences in Link-Time Symbol Missing Behavior +## Platform differences for undefined symbols at link time -This is quite interesting. We are discussing the tolerance levels of different platforms for undefined symbols when linking occurs. On Windows, when a dynamic library is generated, undefined symbols are strictly prohibited. Once an undefined symbol appears, our toolchain will complain that it cannot find the symbol. +This one's genuinely interesting. What we're talking about is, at the moment linking actually happens, how tolerant each platform is of leaving a symbol undefined. On Windows, the moment you produce a dynamic library, you're already required to have zero undefined symbols. The instant an undefined symbol shows up, your toolchain starts complaining that it can't find the symbol. -This is not the case on Linux. In fact, Linux's strategy is more permissive. By default, we allow symbols to remain undefined until the process is launched. At that point, the loader checks all dependencies to ensure all essential symbols are correctly resolved. It is only then that we confirm whether our program truly has critical issues. +On Linux, nothing of the sort happens. In fact, Linux's policy is far more permissive; by default, we let symbols stay undefined all the way up to the point the process is launched, at which point the loader goes through every dependency and checks that every important symbol actually gets addressed. Only then does it confirm whether our program really has a serious problem. -Of course, if you prefer this strict checking, there is a way: pass the `-Wl,-no-undefined` option when compiling the relocatable files to instruct the subsequent linker to report errors. +Of course, if you want this kind of strict checking, there is a way: when you're producing the relocatable object, pass `-Wl,-no-undefined` to steer the linker's error-reporting behavior down the line. -## What is Runtime Dynamic Loading? +## What is runtime dynamic loading? -Officially, runtime dynamic loading refers to a program loading a shared library (shared object / dynamic library / DLL) **at runtime** on demand, finding the required symbols (functions, variables), and then calling them. In the author's opinion, **this is a key implementation mechanism for plugin systems**. Because now: +Officially speaking, runtime dynamic linking (dynamic loading) means a program loads a shared library (shared object / dynamic library / DLL) on demand at runtime, looks up the symbols it needs (functions, variables), and then calls them. In my view, this is one of the important implementation mechanisms behind plugin systems, because now: -- We can load plugins dynamically, loading different functional modules (internationalization, rendering backends, drivers, etc.) at runtime based on configuration. -- The above features allow us to load only the dependencies we need, saving some space. -- Furthermore, it supports hot-swapping/extending at runtime. At the very least, we can extend functionality without recompiling the main program. +- We can load plugins dynamically, pulling in different functional modules at runtime based on configuration (internationalization, rendering backends, drivers, and so on). +- The above property means we can load only the dependencies we actually need, saving a bit of space. +- And we get hot-swap / extension support at runtime; at the very least, we can extend functionality without recompiling the main program. -## Many Benefits, But Are There Drawbacks? +## Lots of upsides, but any trouble? -There certainly are. We need to be much more careful with error handling. After all, we will face a series of troublesome issues, such as mismatched symbols or loading failures. It is also recommended to create a unified management class to handle these exported symbols. There is a reason for this: the beauty of plugins is that they can be installed and uninstalled at any time. After unloading, we must absolutely avoid continuing to call their functions or accessing their static resources. The author suggests creating a function wrapper object similar to `QPointer` that includes an expiration mechanism to access them. +There really is some. Our error handling has to get more careful, since we end up with a whole string of annoying problems, things like the symbol not matching, the load failing, and so on. I'd also suggest you build a single manager class to handle these exported symbols, and there's a reason for that: the whole point of a plugin is that it can be installed and uninstalled at any time, and once it's unloaded, you absolutely must not keep calling its functions or touching its static resources. I think you could build something like a function-wrapping object with an expire mechanism, similar in spirit to Qt's `QPointer`, to access it through. -## Some System-Level APIs +## Some system-level APIs -Here is a list of some system-level APIs. +Here's a quick rundown of some of the system-level APIs: - `void *dlopen(const char *filename, int flag);` - - Common `flag` values: `RTLD_LAZY` (lazy symbol resolution), `RTLD_NOW` (resolve all required symbols immediately), `RTLD_LOCAL` (symbols are local), `RTLD_GLOBAL` (symbols can be resolved by subsequently loaded libraries) -- `void *dlsym(void *handle, const char *symbol);` Returns a pointer to the function/variable -- `int dlclose(void *handle);` Unloads the library -- `char *dlerror(void);` Retrieves error description (implementations that are not thread-safe may return a static string) + - Common `flag` values: `RTLD_LAZY` (defer symbol resolution), `RTLD_NOW` (resolve every needed symbol immediately), `RTLD_LOCAL` (keep symbols local), `RTLD_GLOBAL` (symbols can be picked up by libraries loaded afterwards) +- `void *dlsym(void *handle, const char *symbol);` returns a pointer to a function or variable +- `int dlclose(void *handle);` unloads +- `char *dlerror(void);` fetches the error description (a non-thread-safe implementation may return a static string) -Windows equivalents: +The Windows equivalents: -- `HMODULE LoadLibrary(LPCSTR lpFileName);` Of course, there is also an EX version. Here, the author suggests you head over to Microsoft's MSDN documentation to find out more: [LoadLibraryExW function (libloaderapi.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/zh-cn/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryexw) +- `HMODULE LoadLibrary(LPCSTR lpFileName);` there's also the Ex version; I'll point you over to Microsoft's MSDN docs if you want to dig in: [LoadLibraryExW function (libloaderapi.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/zh-cn/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryexw) - `FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);` - `BOOL FreeLibrary(HMODULE hModule);` -- `DWORD GetLastError(void);` + `FormatMessage` to get a readable string +- `DWORD GetLastError(void);` plus `FormatMessage` to get a readable string -## Minimal C Dynamic Library + Program (Linux) — C-Style Function Export +## A minimal C dynamic library + program (Linux) — exporting C-style functions -For example, the author has written a simple dynamic library +For example, I wrote a simple dynamic library: ```c // mylib.c @@ -77,7 +71,7 @@ const char *hello(void) { ``` -On Linux, we build a shared library like this +On Linux, we build the dynamic library like this: ```bash @@ -89,7 +83,7 @@ gcc -o main main.c -ldl ``` -Next, we write a `main.c` to use it: +Then we write a `main.c` that uses it: ```c // main.c @@ -124,7 +118,7 @@ int main(void) { ``` -**Run** +**Run it** ```bash @@ -206,7 +200,7 @@ int main(void) { ``` -**Run (in the same directory as the DLL or add the DLL to PATH)** +**Run it (in the same directory as the DLL, or add the DLL's directory to PATH)** ```cmd set PATH=%CD%;%PATH% @@ -214,9 +208,11 @@ main_win.exe ``` -## C++ Plugin Interfaces and extern "C" Factories (Recommended Practice) +------ + +## C++ plugin interfaces and the `extern "C"` factory (the recommended approach) -When we need to export C++ objects or classes, a common strategy is to export a factory function (`extern "C"`) that returns an opaque pointer, or to export a `struct` function table (interface table), to avoid C++ name mangling issues. +When you need to export C++ objects or classes, the common strategy is to export a factory function (`extern "C"`) that returns an opaque pointer, or to export a `struct` full of function pointers (an interface table), so that C++ name mangling doesn't get in the way. ```c // plugin.h @@ -239,7 +235,7 @@ PluginAPI* create_plugin_api(void); ``` -### plugin_impl.c (Plugin Implementation) +### plugin_impl.c (the plugin implementation) ```c // plugin_impl.c @@ -262,14 +258,18 @@ PluginAPI* create_plugin_api(void) { ``` -The main program only needs to obtain the `PluginAPI*` via `dlsym(h, "create_plugin_api")` to seamlessly call plugin functions, without worrying about C++ name mangling. +The main program just needs to grab the `PluginAPI*` through `dlsym(h, "create_plugin_api")`, and it can call into the plugin's functions seamlessly, without ever having to care about C++ name mangling. + +## Problems I've hit, and the debugging tricks I've picked up along the way + +#### **Why can't `dlsym` find the function I wrote in C++?** -## Issues I Encountered and Troubleshooting Techniques +I got bitten by this back when I was hand-rolling a PDF viewer and starting to build out its plugin system. As I talked about in an earlier post, the C++ compiler mangles symbol names (name mangling). The natural fix is to export a C-style interface through `extern "C"`, or use the function-table approach I mentioned above. -### **Why can't `dlsym` find my function in C++?** +#### **How do you debug a failing `GetProcAddress` on Windows?** -When I was hand-writing a PDF viewer and preparing to implement a plugin system, I ran into this issue. As discussed in my previous blog posts, C++ compilers perform name mangling on symbol names. The natural solution is to export a C-style interface using `extern "C"`, or use the solution mentioned above. +Check the exported names (using `dumpbin /EXPORTS` or `nm`), check whether the calling convention matches (`__stdcall` will rewrite the exported name), and check whether C++ name mangling is in play. I'd recommend going with `__declspec(dllexport)` paired with `extern "C"`. -### **How to troubleshoot `GetProcAddress` failures on Windows?** +## The modern CMake view -Check the exported names (using `dumpbin /EXPORTS` or `nm`), verify that the calling conventions match (`__stdcall` changes the exported name), or check if C++ name mangling is being used. I recommend using `__declspec(dllexport)` + `extern "C"`. +All of that hand-typed `gcc -fPIC -shared`, `-Wl,-no-undefined`, `__declspec(dllexport)` stuff is, in a modern project, basically taken over by CMake. `add_library(mylib SHARED mylib.c)` will add `-fPIC` for position-independent code for you and produce a `.so` / `.dll` / `.dylib` depending on the platform; `STATIC` then goes through `ar` for packaging, and you no longer have to type these two flags by hand. As for Linux's permissive default of letting undefined symbols slide, you can tighten it back up with `set_target_properties(mylib PROPERTIES LINK_FLAGS "-Wl,--no-undefined")` (or `CMAKE_SHARED_LINKER_FLAGS`) to reproduce the strict checking I talked about at the start of this post. On the symbol-visibility side, `CXX_VISIBILITY_PRESET hidden` paired with `VISIBILITY_INLINES_HIDDEN ON` is equivalent to slapping `-fvisibility=hidden` over the entire target; then you only drop `__attribute__((visibility("default")))` (or, on Windows, `__declspec(dllexport)`) onto the factory functions you actually want to export, and the export table comes out clean. Writing that cross-platform is far less of a headache than sprinkling `dllexport` all over the file. As for the runtime library-search chain, that whole `LD_LIBRARY_PATH` / `PATH` song and dance, CMake automates the "wherever it gets installed is where it can be found" part with install-time `CMAKE_INSTALL_RPATH` (on Linux, pair it with `$ORIGIN` so the executable goes looking for its `.so` in its own directory) and, on Windows, the trick of copying the DLL next to the executable. That line of yours, `export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH`, in a properly structured CMake project you basically never have to type by hand. diff --git a/documents/en/compilation/08-library-search-logic.md b/documents/en/compilation/08-library-search-logic.md index cb88fe63a..50091607a 100644 --- a/documents/en/compilation/08-library-search-logic.md +++ b/documents/en/compilation/08-library-search-logic.md @@ -8,78 +8,72 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation and Linking: Part 8 — Library File Search - Logic' -description: '' -translation: - source: documents/compilation/08-library-search-logic.md - source_hash: 653d580380abeecf42980549a4ed90d728173508f3bbe9d6c2830b4e43a59d7b - translated_at: '2026-06-24T00:25:49.615673+00:00' - engine: anthropic - token_count: 1227 +title: "Deep Dive into C/C++ Compilation and Linking, Part 8: Library Search Logic" +description: 'How an executable actually finds the dynamic libraries it depends on at runtime, in priority order: LD_PRELOAD, RPATH/RUNPATH, LD_LIBRARY_PATH, the ldconfig cache, system default directories, and the corresponding search rules on Windows.' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking 8: Library File Search Logic +# Deep Dive into C/C++ Compilation and Linking, Part 8: Library Search Logic -## Introduction +## Intro -Now, we need to discuss the matter of locating library files. Locating library files refers to how an executable file that depends on other dynamic libraries finds those other dynamic libraries. +Now we need to talk about how libraries get located. "Locating a library" means — given an executable that depends on a bunch of other dynamic libraries besides itself, how does it actually go about finding those other dynamic libraries? -This is not a trivial issue. If you think about it carefully, in modern software engineering, we can hardly escape the use of library files. For example, software we make or use integrates third-party libraries into products, or in package management models, to ensure a given piece of software runs correctly, we need to locate the correct library files at runtime. +This is not a small question. Think about it: in modern software engineering we basically can't escape using libraries. For instance, software we build, or software we use, will integrate third-party libraries into the product, or — in the package-manager style — to make a given program run correctly we have to locate the right library files at runtime. -It is almost exactly like this. +That's basically it. ## Naming Rules -Dynamic libraries on Linux follow naming conventions. If you pay attention, you will find that all static libraries satisfy `lib + + .a`. In this case, we only need to tell the linker the `` part, and the linker will automatically search for `lib.a` according to other rules. +Dynamic libraries on Linux follow a naming convention. If you pay attention you'll notice that all static libraries are `lib + + .a`. At that point we just need to tell the linker the `` part, and the linker will go look for `lib.a` automatically following the rest of its rules. -Dynamic libraries are slightly more complex. Because dynamic libraries support hot-swapping (meaning software can be released without recompiling from scratch), the naming rules are actually a bit more complex. Simply put: +Dynamic libraries are a tiny bit more complicated, because they have the hot-swap property (you can ship a new version without rebuilding the whole program from scratch), so the naming rule ends up a little more involved. Put simply: `lib + + .so + ` -Similarly, we only provide the `` part, and the linker will automatically search for it according to other rules. +Same as before — we only provide the `` part, and the linker figures out the rest. -`` is worth discussing separately. Generally speaking, the version number is sufficient: `..

`, which stands for Major, Minor, and Patch version numbers. This is the specific name. There is also something called `soname`, which is the dynamic library name containing only the major version information. For example, the `soname` of `libz.so.1.2.3.4` is `libz.so.1`. This is an example from *Advanced C/C++ Compilation Technology*. +The `` is worth a section of its own. Generally a version number is enough: `..

`, that is, major, minor, and patch. That's the concrete name. Then there's also the thing called the soname — the dynamic-library name that only keeps the major version. So: the soname of `libz.so.1.2.3.4` is `libz.so.1`. This example comes from *Advanced C/C++ Compilation Techniques*. -## Runtime Dynamic Library Location Rules +## On the dynamic-library lookup rules at startup -Now we need to talk about the runtime location rules for dynamic library files. Specifically, you might be interested in the runtime dynamic library location rules on Linux. Here is the explanation. When running a dynamically linked program on Linux, a component called the **dynamic linker/loader** (usually `ld-linux.so` / `ld.so`) is responsible for finding and loading the shared libraries (`.so`) required by the executable. The search rules for dynamic libraries look complex, but there are actually clear priorities and a few common "control points": `LD_PRELOAD`, `RPATH`/`RUNPATH` embedded in the executable, the environment variable `LD_LIBRARY_PATH`, system configuration (`/etc/ld.so.conf.d` + `ldconfig`), and system default paths (like `/lib`, `/usr/lib`). +Now we need to get into the runtime lookup rules for dynamic libraries. Specifically, people probably care most about the Linux runtime lookup rules, so let me lay them out. When you run a dynamically linked program on Linux, a component called the **dynamic linker / loader** (usually `ld-linux.so` / `ld.so`) is the one responsible for finding and loading the shared libraries (`.so`) that the executable needs. The lookup rules look complex, but they actually have a clear priority order and a handful of familiar "control points": `LD_PRELOAD`, the `RPATH`/`RUNPATH` baked into the executable, the `LD_LIBRARY_PATH` environment variable, system config (`/etc/ld.so.conf.d` + `ldconfig`), and the system default paths (like `/lib`, `/usr/lib`). -In the following section, here is what you need to understand: **when the dynamic linker needs to resolve a dependency** (i.e., the dependency name does not contain `/`), it usually searches in the following order (simplified): +Here's what you need to keep in mind: **when the dynamic linker has to resolve a dependency** (i.e. the dependency name doesn't contain a `/`), it generally searches in this order (simplified): -1. Libraries specified by `LD_PRELOAD` (loaded first, used for symbol overriding/injection). -2. If the executable contains `DT_RPATH` and does not contain `DT_RUNPATH`, use the `DT_RPATH` path (Note: `DT_RPATH` is deprecated but still supported). -3. The environment variable `LD_LIBRARY_PATH` (**ignored for non-setuid/setgid executables**). -4. If the executable contains `DT_RUNPATH`, use `DT_RUNPATH` (and when `DT_RUNPATH` exists, `DT_RPATH` is generally ignored). -5. The cache maintained by ldconfig `/etc/ld.so.cache`, and "trusted directories" like `/lib` and `/usr/lib` (as well as architecture-specific `/lib64`, `/usr/lib64`). -6. (If nothing is found above) It will ultimately fail and report an error (e.g., `ld.so: cannot find ...`). +1. `LD_PRELOAD`-listed libraries (loaded first, used for symbol override / injection). +2. If the executable has `DT_RPATH` and no `DT_RUNPATH`, the `DT_RPATH` paths are used (note: `DT_RPATH` is deprecated but still supported). +3. The `LD_LIBRARY_PATH` environment variable (**ignored for non-setuid/setgid executables**). +4. If the executable has `DT_RUNPATH`, that's used (and when `DT_RUNPATH` is present, `DT_RPATH` is generally ignored). +5. The cache maintained by ldconfig at `/etc/ld.so.cache`, plus `/lib`, `/usr/lib` (and the arch-specific `/lib64`, `/usr/lib64`) — the so-called "trusted directories". +6. (If nothing above matched) it ultimately fails with an error (e.g. `ld.so: cannot find ...`). -> Note: The details of the order above (especially the interaction between `RPATH` and `RUNPATH`) are influenced by the linker implementation and linker options (such as `--enable-new-dtags`, which enables the `-R` or `-rpath` linker directive options). +> Note: the exact details of the ordering above — especially the interaction between `RPATH` and `RUNPATH` — depend on the linker implementation and linker options (like `--enable-new-dtags`, which is what enables the `-R` / `-rpath` linker directives). ------ -## Detailed Explanation (Item Breakdown) +## In detail (each item expanded) -#### LD_PRELOAD (On-demand "Injection" or Symbol Overriding) +#### LD_PRELOAD (inject or override symbols, on demand) -`LD_PRELOAD` is an environment variable that can specify one or more shared libraries to be forcibly loaded into the process **before the normal search**, thereby allowing the interception/replacement of symbols (functions). However, this is rare and generally not recommended unless you know what you are doing :) +`LD_PRELOAD` is an environment variable that lets you specify one or more shared libraries to be force-loaded into the process **before** the normal search, so you can intercept / replace symbols (functions). Honestly this is pretty rare, and generally not recommended unless you know exactly what you're doing :) ------ -#### DT_RPATH and DT_RUNPATH (i.e., "rpath / runpath") +#### DT_RPATH and DT_RUNPATH (i.e. "rpath / runpath") -At link time, one or more runtime library search paths can be written into the dynamic segment (`.dynamic`) of the executable or shared library. The corresponding ELF tags are `DT_RPATH` and `DT_RUNPATH`. Historically, `DT_RPATH` was introduced early with the usage of "precedence over environment variables," but later `DT_RUNPATH` (new-dtags) was introduced. The meaning of `DT_RUNPATH` is: **it is searched after `LD_LIBRARY_PATH`**, meaning `LD_LIBRARY_PATH` can override paths in RUNPATH; whereas `DT_RPATH` in some implementations/historically takes precedence over `LD_LIBRARY_PATH` (i.e., it is harder to override). +At link time you can write one or more runtime library search paths into the dynamic section (`.dynamic`) of the executable or shared library; the corresponding ELF tags are `DT_RPATH` and `DT_RUNPATH`. The historical `DT_RPATH` was introduced early, with the semantics of "takes priority over the environment variable". Later `DT_RUNPATH` (new-dtags) was introduced, and its meaning is: **it's searched after `LD_LIBRARY_PATH`**, meaning `LD_LIBRARY_PATH` can override paths in RUNPATH; whereas `DT_RPATH`, in some implementations / historically, takes priority over `LD_LIBRARY_PATH` (i.e. it's harder to override). -Another important behavioral difference: **DT_RPATH is effective for transitive dependencies**, whereas **DT_RUNPATH may not be used to find transitive dependencies** (i.e., when executable -> libA -> libB, the behavior of RUNPATH in some cases will not provide a path for finding libB, while RPATH will). This causes some combinations that worked with RPATH under older linkers to result in "cannot find indirect dependency" errors when using RUNPATH (new-dtags). +Another important behavioral difference: **DT_RPATH works for transitive dependencies**, whereas **DT_RUNPATH may not be used to look up transitive dependencies** (meaning, when you have executable -> libA -> libB, RUNPATH's behavior in certain cases won't provide a path for finding libB, while RPATH will). This is exactly why some combinations that ran fine under an older linker with RPATH start showing "can't find indirect dependency" errors once they're built with RUNPATH (new-dtags). -In my current Linux experience, I rarely encounter this, so in more test environments, I suggest adopting the following solution: +In my own Linux experience I've genuinely run into this very rarely, so for most testing scenarios I'd say the recommendation below is the safe bet. ------ -#### LD_LIBRARY_PATH (This is an Environment Variable) +#### LD_LIBRARY_PATH (this one's an environment variable) -`LD_LIBRARY_PATH` is a list of runtime library search paths used by the dynamic linker at specific stages (see order). It is very commonly used to temporarily override system paths or test new versions of libraries. **Similarly**, setuid / setgid executables will ignore this variable (for security reasons). +`LD_LIBRARY_PATH` is a list of runtime library search paths that the dynamic linker uses at a particular stage (see the ordering above). It's extremely common as a way to temporarily override system paths or test a new version of a library. **Same deal**: setuid / setgid executables ignore this variable (for security reasons). -The trouble with environment variables is that they easily interfere with all shells that set this variable. It is not recommended to rely on `LD_LIBRARY_PATH` in production environments for a long time, because it affects all child processes started through that shell and is less maintainable than system configuration (ldconfig). +The trouble with environment variables is they're really easy to leak into everything else spawned from the shell that has them set. I wouldn't recommend leaning on `LD_LIBRARY_PATH` long-term in production — it affects every child process started from that shell, and it's nowhere near as maintainable as the system config (ldconfig). ```bash export LD_LIBRARY_PATH=/opt/foo/lib:/home/you/sw/lib:$LD_LIBRARY_PATH @@ -91,52 +85,56 @@ export LD_LIBRARY_PATH=/opt/foo/lib:/home/you/sw/lib:$LD_LIBRARY_PATH #### ldconfig, /etc/ld.so.conf.d, and ld.so.cache -System administrators typically inform `ldconfig` about which directories the system dynamic linker should trust by placing library directories in `/etc/ld.so.conf` or `/etc/ld.so.conf.d/*.conf`. `ldconfig` scans these directories and generates a binary cache at `/etc/ld.so.cache` (to improve lookup speed), while simultaneously creating symbolic links (libXXX.so -> libXXX.so.VERSION). The dynamic linker reads this cache to accelerate lookups. +Sysadmins usually tell `ldconfig` which directories the system dynamic linker should trust, by dropping a library directory into `/etc/ld.so.conf` or `/etc/ld.so.conf.d/*.conf`. `ldconfig` scans those directories and produces a binary cache at `/etc/ld.so.cache` (to speed up lookups), and at the same time creates the symlinks (`libXXX.so` -> `libXXX.so.VERSION`). The dynamic linker reads that cache to make lookups fast. Common operations: ```bash -# 把新目录加入配置(以 root) +# Add a new directory to the config (as root) echo "/opt/foo/lib" > /etc/ld.so.conf.d/foo.conf -# 重建缓存 +# Rebuild the cache sudo ldconfig -# 查看缓存内容 +# Inspect the cache contents ldconfig -p | grep foo ``` ------ -#### System Default Directories (Trusted Directories) +#### System default directories (trusted directories) -The dynamic linker typically searches `/lib`, `/usr/lib` (and `/lib64`, `/usr/lib64` on 64-bit systems) by default. These are referred to as "trusted directories." `ldconfig` also processes these directories. Even if a path is not added to `ld.so.conf`, placing a library in these directories usually allows it to be found (provided the architecture bits, ABI, and version match). +The dynamic linker usually searches `/lib` and `/usr/lib` (and on 64-bit systems, `/lib64` and `/usr/lib64`) by default — these are the "trusted directories". `ldconfig` processes them too. Even if you haven't written a path into `ld.so.conf`, dropping a library into these directories will usually get it found (just watch the arch bits, the ABI, and the version match). -## What About Windows? +## So what about us on Windows? -The Windows executable loader and APIs (`LoadLibrary` / `LoadLibraryEx` / automatic loading via the import table) define a specific search order and security improvements. +Windows' executables / loader and APIs (`LoadLibrary` / `LoadLibraryEx` / auto-loading via the import table) define their own search order and security improvements. -Generally speaking, there are two approaches in Windows: implicit (import table) and explicit (runtime API). +Generally speaking, Windows has two flavors: implicit (import table) and explicit (runtime API). -**Implicit loading** refers to the system loader resolving the executable's Import Table when the process starts or a module is loaded. The system attempts to locate and map each `DLL` into the process's address space. Developers specify dependencies during the linking phase (e.g., `kernel32.dll`, `mydll.dll`), and loading is completed automatically by the system at process startup. +**Implicit loading** means the executable's Import Table gets resolved by the system loader at process startup or when a module is loaded — the system tries to find each `DLL` and map it into the process address space. The developer specifies the dependencies at link time (e.g. `kernel32.dll`, `mydll.dll`), and the loading is done automatically by the system at process startup. -**Explicit loading** refers to code manually loading a DLL at runtime using APIs like `LoadLibrary` or `LoadLibraryEx`, and then retrieving function pointers with `GetProcAddress`. Explicit loading allows control over search behavior through parameters (for example, using flags like `LOAD_LIBRARY_SEARCH_USER_DIRS`). +**Explicit loading** means the code uses APIs like `LoadLibrary` / `LoadLibraryEx` to manually load a DLL at runtime, and then grabs function pointers with `GetProcAddress`. Explicit loading lets you control the search behavior through parameters (e.g. flags like `LOAD_LIBRARY_SEARCH_USER_DIRS`). -#### Default Search Order (Conceptual Order) +#### Default search order (conceptual order) -> Note: The Windows search order varies slightly depending on the OS version and configuration, and the system provide settings that affect this order (explained below). Here is a common conceptual order (focusing on priority): +> Note: Windows' search order has subtle differences across OS versions and configurations, and the system provides settings that influence this order (covered below). For now, here's a conceptual, commonly-seen order (the point is just to understand the priorities): -When a process requests to load a DLL named `foo.dll` (without an absolute path), the system typically searches in the following order (conceptual): +When a process asks to load a name like `foo.dll` (with no absolute path specified), the system generally searches in this order (conceptual): -1. **Full path explicitly specified by the caller** (if calling `LoadLibrary("C:\\path\\foo.dll")`, that path is loaded directly, bypassing the search). -2. **The loader checks if it is an entry in "KnownDLLs"** (KnownDLLs are a set of trusted system libraries registered in the system; the existing system version is prioritized). -3. **Application Directory**: The directory where the executable (.exe) resides (usually prioritized over system directories, though this is influenced by settings like SafeDllSearchMode). -4. **System Directory** (typically `%SystemRoot%\System32`). -5. **Windows Directory** (typically `%SystemRoot%`). -6. **Current Working Directory** (depends on SafeDllSearchMode; if "Safe Search Mode" is enabled, the current directory is moved lower in priority). -7. **Directories listed in the PATH environment variable** (in order). -8. **If application configuration or Side-by-side (SxS)/manifest features are enabled**, the system prioritizes resolving the binding version declared in the manifest or parallel assemblies from WinSxS. +1. **An explicit full path from the caller** (if you call `LoadLibrary("C:\\path\\foo.dll")`, that path is loaded directly — no search happens). +2. **The loader first checks whether it's an entry in "KnownDLLs"** (KnownDLLs is a set of trusted system libraries registered in the system; the already-present system version is preferred). +3. **The application directory (Executable directory)**: the directory the executable (`.exe`) lives in (this usually takes priority over the system directories, subject to settings like SafeDllSearchMode). +4. **The system directory** (usually `%SystemRoot%\System32`). +5. **The Windows directory** (usually `%SystemRoot%`). +6. **The current working directory** (depends on SafeDllSearchMode; if "safe search mode" is on, the current directory gets pushed further back). +7. **The directories listed in the PATH environment variable** (in order). +8. **If application config or Side-by-side (SxS) / manifest features are enabled**, the binding version declared in the manifest, or the side-by-side assembly from WinSxS, takes precedence. -The key point is: **if you use an absolute path or a path relative to the executable, the system will not search the PATH**; conversely, if only a bare name like `foo.dll` is provided, the system attempts the search in the order listed above. +The key point: **if you use an absolute path or a path relative to the executable, the system does not go searching PATH**; conversely, if you only hand it a bare name like `foo.dll`, it tries the order above. + +## From a modern CMake perspective + +All that manual fussing — `export LD_LIBRARY_PATH`, editing `/etc/ld.so.conf.d`, threading `-Wl,-rpath` — basically gets taken off your hands in a project managed by CMake. `target_link_libraries(myapp PRIVATE foo)` turns into `-lfoo` plus the right `-L` for you; `add_library(foo SHARED)` slaps `-fPIC` on the target by default, while `add_library(foo STATIC)` goes through `ar` for packing. On the runtime-lookup side, `set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")` together with `CMAKE_BUILD_WITH_INSTALL_RPATH` writes `$ORIGIN` into the ELF's `DT_RUNPATH`, so the executable you ship runs alongside its own directory and the user never has to pollute their shell's `LD_LIBRARY_PATH`. On Windows you point `RUNTIME_OUTPUT_DIRECTORY` at where the `.exe` is, dropping the DLLs right next to it, hitting the "application directory" priority rule dead-on. In other words, all the rules above are the low-level facts — CMake doesn't change any of them; it just turns "which flag to write, which folder to drop the library into" into a couple of lines of declarative config. diff --git a/documents/en/compilation/09-dynamic-library-details.md b/documents/en/compilation/09-dynamic-library-details.md index 8548413ec..8020e0027 100644 --- a/documents/en/compilation/09-dynamic-library-details.md +++ b/documents/en/compilation/09-dynamic-library-details.md @@ -8,39 +8,33 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation and Linking: Part 9 – Dynamic Library Details - (Finale)' -description: '' -translation: - source: documents/compilation/09-dynamic-library-details.md - source_hash: 315ba24b7cf9d2848735ab94d5d5acf600605787f7b91efd2692f588cac62b3d - translated_at: '2026-06-16T03:28:05.668438+00:00' - engine: anthropic - token_count: 1653 +title: "Deep Dive into C/C++ Compilation and Linking, Part 9: Dynamic Library Details (Finale)" +description: 'From PIC, GOT/PLT, to symbol interposition, properly explaining why dynamic libraries have "indeterminate addresses" at runtime and how the modern linker-loader collaboration actually works' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking Techniques 9: Dynamic Library Details (Finale) +# Deep Dive into C/C++ Compilation and Linking, Part 9: Dynamic Library Details (Finale) -## Introduction +## Foreword -Next, let's discuss the details of dynamic libraries. Generally speaking, engineering development might not involve this level of detail, but knowing how dynamic libraries work is better than not knowing. Therefore, combining "Advanced C/C++ Compilation Technology," I will revisit some details of dynamic libraries. +Next up, we're going to talk through the details of dynamic libraries. Honestly, day-to-day engineering work rarely drags you into this stuff, but knowing how dynamic libraries actually tick is better than not knowing. So here I'm leaning on *Advanced C and C++ Compiling* to walk through some of the finer points of dynamic libraries one more time. -## **8.1 The Necessity of Resolving Memory Addresses** +## **8.1 Why Resolving Memory Addresses Is Necessary** -Before rushing ahead, let's supplement a few assembly instructions. +Don't rush ahead just yet, let me throw in a bit more assembly. -Obviously, we know that the basic model of modern computers is the Turing machine; we know where the operands are, fetch them for calculation, and put them back. +The basic model of a modern computer is, obviously enough, a Turing machine. We know where the operands live, we fetch them, do the math, and put them back. -Taking X86 as an example, we need to know the address of the memory operand so that we can transfer data back and forth between memory and the CPU. +Take x86 as the example. We need to know the address of a memory operand, otherwise we can't move data back and forth between memory and the CPU. ```cpp -mov eax, ds:0xBAD10000 ; 将地址0xBAD10000装载到eax中 -add eax, 0x1 ; 装载值自增 -mov ds:0xBAD10000, eax; 写回操作 +mov eax, ds:0xBAD10000 ; load address 0xBAD10000 into eax +add eax, 0x1 ; increment the loaded value +mov ds:0xBAD10000, eax; write it back ``` -Very good. Knowing this, we must point out that the essence of a function call is also finding the address of the function in the code segment—for example, if we want to call an ordinary `add` function, we must tell our `call` instruction where the `add` function is (that is, we must provide the code segment address of the `add` function's entry point). +Great. With that out of the way, here's the point I want to make: a function call boils down to the same thing — finding the function's address in the code segment. Say we want to call a plain old `add` function, we have to tell our `call` instruction where `add` is (in other words, hand it the code-segment address of `add`'s entry point). ```cpp @@ -53,195 +47,205 @@ main: ``` -Of course, sometimes we also use relative addresses for calls, which is slightly more convenient. +Of course, sometimes we `call` a relative address instead, which is a bit more convenient. -## Common Issues in Reference Resolution +## Common Problems in Reference Resolution -Let's look at the simplest situation! Suppose an executable file can only work further after loading a single dynamic library. These things are obvious: +Let's look at the simplest case. Say the executable can only do real work after loading a single dynamic library. The following things are pretty self-evident: -- The client binary provides a portion of the process memory map with a fixed and predictable address range. -- Only after dynamic loading is complete does it become a valid part of the process. -- When the executable calls one or more function implementations provided by the dynamic library (such as the library's interface), a connection is naturally established at this time. +- The client binary provides a fixed, pre-determinable address range in the process memory map +- Only after dynamic loading is finished does that range become a valid part of the process +- Only when the executable calls one or several feature implementations exposed by the dynamic library (its interface, say) does the connection get wired up naturally -From the basic situation above, we can know one thing: the core problem of dynamic libraries is that **the location of library code at runtime is indeterminate**. Whether it is Windows DLLs, Linux .so, or macOS dylib, they all have one thing in common: **dynamic libraries cannot determine their final load address during the compilation phase.** +From the above, one thing becomes clear: the heart of the dynamic-library problem is that **the library code's location is indeterminate at runtime**. Whether it's a Windows DLL, a Linux `.so`, or a macOS dylib, they all share one trait: **a dynamic library cannot fix its final load address at compile time.** -Why? Mainly for these reasons: +Why? Mostly for these reasons: -#### **(1) Address conflicts may occur between multiple dynamic libraries** +#### **(1) Multiple dynamic libraries can collide on addresses** -Assume two .so files both want to map to the 0x400000 area in virtual memory; this will cause a conflict. -To avoid conflict, the operating system's loader must re-select a suitable base address. +Suppose two `.so` files both want to map into the `0x400000` region of virtual memory. That's a collision. + +To avoid it, the OS loader has to pick a fresh, suitable base address. #### **(2) ASLR (Address Space Layout Randomization)** -Modern operating systems enable address randomization for security, so dynamic libraries load at different addresses every time. -This means: compilers and linkers cannot assume that dynamic libraries will run at a fixed address. +Modern OSes turn on address randomization for security, so a dynamic library lands at a different address every time it loads. + +That means: the compiler and linker cannot assume the dynamic library will run at a fixed address. -#### **(3) The same dynamic library loads at different locations in different processes** +#### **(3) The same dynamic library loads at different positions in different processes** -The address spaces of processes are independent of each other, and the loading location of the library in each process can be completely different. +Process address spaces are independent, and the library's load position can be completely different in each one. -## Address Conversion is the Solution +## Translating Addresses Is the Solution -#### Case: We just want to use exported binary symbols +#### Case: We really do want to use the exported binary symbols -For example, if we just want to use those exported symbols, such as interfaces provided by the library—``create_window``, ``init_all``, ``deinit_all``, etc.—this is using exported binary symbols. At this time, the client program obviously needs to know immediately where the successful load address is, rather than the dynamic library's original symbol address (they are offset from zero!). Therefore, in the past, it was obviously impossible for the linker to complete all symbol resolution work directly. The determination of symbol addresses must be determined by the loader together. +Say we genuinely want to use those exported symbols, the ones the library hands us — `create_window`, `init_all`, `deinit_all`, that kind of interface. This is using exported binary symbols, and clearly the client program needs to know right away where the successfully loaded address is, not the dynamic library's original symbol address (those are offset from 0!), so the old approach of letting the linker resolve everything upfront obviously doesn't cut it anymore. Pinning down the symbol address has to be a joint effort with the loader. #### Case: Calling your own private symbols -Regardless, some private symbols cannot be found by the client program, but there is a more severe problem—if these symbols are called by exported symbols, what should be done then? +Either way, some private symbols can't be found by the client program at all. But there's a thornier problem — what if those symbols are being called by the *exported* symbols? Now what? -## Linker-Loader Cooperation—Old Technology +## Linker–Loader Collaboration: The Old Technique -Now let's talk carefully about linker-loader cooperation. After understanding all the constraints described earlier, we can establish cooperation between the linker and the loader based on the following rules: +Now let's talk carefully about linker–loader collaboration. Once we understand all the constraints above, we can frame the collaboration between linker and loader with these rules: -- The linker identifies the limitations of its own symbol resolution. -- The linker accurately counts invalid symbol references, prepares reference fix-up hints, and embeds these hints into the binary file. -- The loader accurately follows the linker's relocation hints and performs fix-ups based on these hints after completing address translation. +- The linker recognizes the limits of its own symbol resolution. +- The linker tallies up the references that will break, prepares relocation hints, and embeds those hints in the binary. +- The loader faithfully follows the linker's relocation hints and patches things up after completing the address translation. -### Linker identifies the limitations of its own symbol resolution +### The Linker Recognizes the Limits of Its Own Symbol Resolution -When creating a dynamic library, in addition to clearly distinguishing the relationship between different parts of the code, the linker also needs to accurately identify which symbol references will fail when the code segment is loaded into different address ranges. +When building a dynamic library, the linker has to do more than clearly sort out the relationships between different chunks of code — it also has to identify, accurately, which symbol references would break if the code segment were loaded at a different address range. -First, unlike executable files, the address range of the dynamic library memory mapping starts from zero. When processing executable files, the linker will mostly not set the start point of the address range to zero. Secondly, before the loading stage, if the linker finds that the addresses of certain symbols cannot be resolved, it will stop resolving and instead use temporary values to fill the unresolved symbols (usually obviously wrong values, such as 0). However, this does not mean that the linker will completely abandon the symbol resolution task. On the contrary, it will only give up dealing with those symbols that really cannot be figured out. +First, unlike an executable, a dynamic library's memory map starts from zero. When the linker processes an executable, in most cases it does not set the start of the address range to zero. Second, before the load stage, if the linker finds it can't resolve some symbol's address, it stops trying to resolve it and instead fills the unresolved symbol with a placeholder (usually a blatantly wrong value like 0). But that doesn't mean the linker gives up on symbol resolution entirely. It only gives up on the symbols it genuinely can't handle. -### Next step: Linker accurately counts invalid symbol references, prepares fix-up hints +### Next Step: The Linker Tallies the Broken References and Prepares Fix-up Hints -We can fully know which resolved references will fail due to loader address translation. Whenever an assembly instruction requires an absolute address, the reference in the instruction will be invalid. At the completion of the link stage of dynamic library construction, the linker can identify where absolute addresses appear and let the loader know this information through some methods. To provide linker-loader cooperation support, the linker will reserve some hints for the loader. These hints point out to the loader how to fix errors caused by address translation during dynamic loading. The binary format specification supports some new sections specifically reserved for this type of hint. In addition, specific simple syntax is designed to facilitate the linker to accurately point out the action the loader needs to perform. +We can fully tell which resolved references will be invalidated by the loader's address translation. As long as an assembly instruction needs an absolute address, the reference inside it will break. During the link stage that finishes building the dynamic library, the linker can flag the spots where absolute addresses appear and, through some mechanism, let the loader know about them. To support this linker–loader collaboration, the linker reserves a set of hints for the loader, pointing out how to fix the errors caused by address translation during dynamic loading. The binary format spec accommodates this with new sections dedicated to holding such hints. There's also a specific, simple syntax designed so the linker can state precisely what action the loader needs to perform. -These sections are called "relocation sections" in the binary file, where the `.rel.dyn` section is the oldest relocation section. Generally speaking, the linker writes relocation hints into the binary file so that the loader can read these hints. These hints specify the addresses that the loader needs to patch after completing the final memory map layout of the entire process, and the correct actions the loader needs to perform to correctly patch unresolved references. +These sections are called "relocation sections" in the binary, and `.rel.dyn` is the oldest of them. Generally, the linker writes the relocation hints into the binary so the loader can read them. The hints specify the addresses the loader needs to patch — once the final memory-map layout of the entire process is settled — and the correct action the loader must take to properly fix up the unresolved references. -### Loader accurately follows linker relocation hints +### The Loader Faithfully Follows the Linker's Relocation Hints -The last stage belongs to the loader. The loader reads the dynamic library created by the linker, reads the loader segments in the dynamic library (each segment holds multiple linker sections), and places all data into the process memory map, stored near the original executable file code. +The last stage belongs to the loader. The loader reads the dynamic library produced by the linker, reads the loader segments inside the library (each segment holds several linker sections), and places all of it into the process memory map, near the original executable's code. -Finally, the loader locates the `.rel.dyn` section, reads the hints reserved by the linker, and patches the original dynamic library code according to these hints. After the patching is completed, we are ready to use the memory map to start the process. Compared to handling basic tasks, we need to provide the loader with more information when handling dynamic library loading. +Finally, the loader locates the `.rel.dyn` section, reads the hints the linker left behind, and patches the original dynamic library code according to those hints. Once patching is done, the memory map is ready to be used to start the process. Compared to the basic tasks, when it comes to dynamic library loading we have to feed the loader a lot more information. -## Modern Linker-Loader Cooperation Implementation Technology: PLT/GOT +## Modern Linker–Loader Collaboration: PLT/GOT -#### Internal mechanism of GOT / PLT +#### The Inner Workings of GOT / PLT -GOT (Global Offset Table) is used to allow code to not rely on fixed addresses, but to fetch the final address from the table. Of course, this obviously requires us to compile our code with `-fPIC` (do you understand now why Step 1 for dynamic libraries is to use PIC (Position Independent Code)!) +The GOT (Global Offset Table) exists so code doesn't depend on a fixed address, but instead pulls the final address out of a table. Of course, this obviously requires us to compile our code with `-fPIC` (do you now get why step one of building a dynamic library is to use PIC, position-independent code?). -Now, our call becomes similar to ``call [GOT + foo]``. For this, when the address of `foo` is determined, the `foo` entry in the GOT is written as the actual address. This way we update it directly. +Now our call turns into something like `call [GOT + foo]`, so once `foo`'s address is pinned down, the `foo` entry in the GOT gets overwritten with the real address. That way we've updated it directly. -PLT combines with GOT to implement lazy binding: +PLT, combined with GOT, implements lazy binding: -- First function call → PLT jumps to resolver → Updates GOT → Jumps directly to correct address (no more resolving) +- First call to a function → PLT jumps to the resolver → updates the GOT → next time jumps straight to the correct address (no more resolution) Benefits of PLT: - Speeds up program startup -- Resolves symbols only when needed +- Resolves symbols only when they're actually needed ------ -## **Detailed Explanation of Lazy Binding Process** +## **Lazy Binding, Step by Step** -Simply put, lazy binding means not actually setting the GOT table address until the very last moment; before that, it polls and resolves all determined symbols. +Put simply, lazy binding means we hold off on really setting the GOT entry until the very last moment, and until then we keep polling to resolve the symbols. -1. `call foo` → Jump to `PLT[foo]` -2. `PLT[foo]` calls resolver `_dl_runtime_resolve` -3. Resolver searches for symbol `foo` in all dynamic libraries -4. Update `GOT[foo]` = real address of `foo` +1. `call foo` → jump to `PLT[foo]` +2. `PLT[foo]` calls the resolver `_dl_runtime_resolve` +3. The resolver hunts for the symbol `foo` across all the dynamic libraries +4. Update `GOT[foo]` = the real address of `foo` 5. Return to `foo` -6. Subsequent calls jump directly to `GOT[foo]` +6. Subsequent calls jump straight to `GOT[foo]` ------ ## Duplicate Symbols in Dynamic Linking -In static linking, if two global symbols with the same name appear, the linker usually reports an error directly (Multiple Definition Error). But in the world of **dynamic linking**, the rules are completely different. This is why it is worth discussing separately. +In static linking, if two global symbols share the same name, the linker usually just bails with an error (Multiple Definition Error). But in the world of **dynamic linking**, the rules are completely different. That's why this deserves its own section. #### Duplicate Symbol Definitions -In large projects, we often link multiple third-party libraries. Suppose your program links `libA.so` and `libB.so`. Coincidentally, the developers of both libraries defined a global function `void init()` or a global variable `int g_config`. +In a large project we frequently link against several third-party libraries. Suppose your program links `libA.so` and `libB.so`, and by coincidence both libraries' authors defined a global function `void init()` or a global variable `int g_config`. -When your main program starts and loads these two libraries, there will be two symbols named `init` in memory. +When your main program starts up and loads both libraries, there will be two symbols named `init` sitting in memory. #### Why does this happen? -1. **Common naming**: Used overly generic names (like ``utils``, ``log``, ``init``) without using ``static`` to limit the scope. -2. **Diamond Dependency**: The project depends on library A and library B, and both A and B internally statically link the same base library C (such as an old version of OpenSSL). This results in C's symbols having a copy in both A and B. -3. **Header file implementation**: Defined global variables or non-inline functions in header files, which were included by multiple ``.c/.cpp`` files. +1. **Common names**: using overly generic names (like `utils`, `log`, `init`) without `static` to limit the scope. +2. **Diamond dependency**: the project depends on library A and library B, and A and B each statically link the same base library C (an older OpenSSL, say). That leaves C's symbols with one copy inside A and another inside B. +3. **Header-file implementations**: defining a global variable or a non-inline function in a header file that then gets included by multiple `.c/.cpp` files. ------ ## Default Handling of Duplicate Symbols -The dynamic linker under Linux (`ld-linux`) adopts a specific set of rules to handle such conflicts, usually referred to as **Symbol Interposition**. +Linux's dynamic linker (`ld-linux`) follows a specific set of rules to handle this kind of conflict, generally known as **symbol interposition**. #### Rule: First Match Wins -By default, the dynamic linker uses a **Breadth-First Search (BFS)** order to find symbols. It binds to the **first** matching symbol found in the Global Symbol Table and **ignores** all subsequent symbols with the same name. +By default, the dynamic linker searches for symbols in **breadth-first (BFS)** order. It walks the global symbol table in order, binds to the **first** matching symbol it finds, and **ignores** every same-named symbol after that. -#### Load Order Decides Everything +#### Load order decides everything -This means that **Link Order** or **Load Order** determines whose code the program actually calls. +What this means is that **link order** or **load order** decides whose code your program actually calls. -Assume ``app`` depends on ``libA`` and ``libB``, and both have ``func()``: +Suppose `app` depends on `libA` and `libB`, and both define `func()`: -- If the link command is ``gcc main.c -lA -lB``: When the main program calls ``func()``, it usually links to ``libA``'s version. -- **Dangerous situation**: If code inside ``libB`` calls ``func()``, following ELF's global symbol binding rules, ``libB`` will also call ``libA``'s ``func()``! This is called "symbol hijacking." ``libB`` thinks it is calling its own code, but actually runs into ``libA``, which can lead to logic errors or even crashes. +- If your link command is `gcc main.c -lA -lB`: when the main program calls `func()`, it usually binds to `libA`'s version. +- **The dangerous case**: if code inside `libB` calls `func()`, by ELF's global symbol binding rules `libB` will also end up calling `libA`'s `func()`! This is called "symbol hijacking." `libB` thinks it's calling its own code but actually jumps into `libA`, which causes logic errors or even crashes. -> **Application Scenario:** The ``LD_PRELOAD`` environment variable utilizes exactly this mechanism. By preloading a library containing ``malloc`` implementations, we can override libc's standard ``malloc``, thereby implementing memory leak detection tools (like Valgrind or jemalloc). +> **Use case:** the `LD_PRELOAD` environment variable leans on exactly this mechanism. By preloading a library that implements `malloc`, we can override libc's standard `malloc`, which is how memory-leak detection tools (Valgrind, jemalloc) get built. ------ -## Handling Duplicates During Dynamic Library Linking +## Handling Duplicate Symbols When Linking Dynamic Libraries -Since the default behavior is so dangerous, how can we protect our symbols from being hijacked when developing dynamic libraries, or avoid hijacking others? +Since the default behavior is this dangerous, how do we protect our own symbols from being hijacked (or avoid hijacking someone else's) when developing a dynamic library? -#### 1. Linker parameter: ``-Bsymbolic`` +#### 1. The linker flag: `-Bsymbolic` -When compiling a dynamic library, you can use the linker parameter ``-Wl,-Bsymbolic``. +When compiling a dynamic library, you can pass the linker flag `-Wl,-Bsymbolic`. -- **Function**: Forces the dynamic library to prioritize resolving global symbol references within itself. -- **Effect**: If ``libB`` is compiled with this parameter, then when ``libB`` internally calls ``func()``, it will definitely call ``libB``'s own version and will not be overridden by ``libA`` or the main program. +- **What it does:** forces the dynamic library to resolve its own global symbol references internally first. +- **Effect:** if `libB` was compiled with this flag, then when code inside `libB` calls `func()`, it is guaranteed to call `libB`'s own version, never the one overridden by `libA` or the main program. #### 2. Symbol Visibility -This is a best practice for modern C++ development. Through GCC/Clang's ``-fvisibility=hidden`` parameter, all symbols are hidden by default, and only required interfaces are exported. +This is the modern C++ best practice. With GCC/Clang's `-fvisibility=hidden` flag, you hide all symbols by default and only export the interfaces you actually need. -- **Code Example**: +- **Code example:** - ````C - // 只有标记了 DEFAULT 的符号才会被导出到动态符号表 + ```C + // Only symbols marked DEFAULT get exported to the dynamic symbol table __attribute__((visibility("default"))) void public_api(); - // 即使是全局函数,在外部看来也是不可见的,避免冲突 + // Even though this is a global function, it's invisible from the outside, avoiding conflicts void internal_helper(); - ```` + ``` -#### 3. Scope control of ``dlopen`` +#### 3. Scope Control with `dlopen` -If using ``dlopen`` to manually load a library, you can specify the ``RTLD_LOCAL`` flag (this is the default). This causes the loaded library's symbols **not** to enter the global symbol table, thereby avoiding affecting other libraries. +If you load libraries manually with `dlopen`, you can pass the `RTLD_LOCAL` flag (which is the default). That keeps the loaded library's symbols **out of** the global symbol table, so it can't interfere with other libraries. ------ -### A Few Classic Examples +### A Few Classic Cases -#### Custom Memory Allocator +#### Custom Memory Allocators -Many high-performance services (like Redis, MySQL) will link ``jemalloc`` or ``tcmalloc``. +A lot of high-performance services (Redis, MySQL) link against `jemalloc` or `tcmalloc`. -- **Phenomenon**: These libraries define the same ``malloc``, ``free``, ``realloc`` symbols as Glibc. -- **Mechanism**: Because they are explicitly linked or preloaded, their symbols rank before Glibc in the global table. -- **Result**: All memory allocations for the entire process (including other third-party libraries depending on Glibc) are automatically forwarded to ``jemalloc``. This is a benign, intentional symbol conflict. +- **Symptom:** these libraries define the same `malloc`, `free`, `realloc` symbols as glibc. +- **Mechanism:** since they're explicitly linked or preloaded, their symbols sit ahead of glibc's in the global table. +- **Result:** every memory allocation in the entire process — including third-party libraries that depend on glibc — automatically gets routed to `jemalloc`. This is a benign, intentional symbol conflict. -#### C++ STL Version Conflict +#### C++ STL Version Clashes -This is a malignant case. +This one is the malignant case. -- **Scenario**: The main program is compiled with GCC 4.8 and depends on ``libStdOld.so``; the plugin is compiled with GCC 9.0 and depends on ``libStdNew.so``. -- **Problem**: The internal implementation of ``std::string`` or ``std::vector`` may differ in different versions, but their symbol names (Mangled Name) may remain consistent through partial compatibility, or conflicts may occur. -- **Consequence**: When objects are passed across libraries, due to different memory layouts but identical symbols, the program may exhibit Undefined Behavior (UB), usually manifesting as inexplicable Segfaults. +- **Scenario:** the main program is compiled with GCC 4.8 and depends on `libStdOld.so`; a plugin is compiled with GCC 9.0 and depends on `libStdNew.so`. +- **Problem:** the internal implementation of `std::string` or `std::vector` may differ between versions, but their symbol names (mangled names) may stay consistent through partial compatibility, or outright collide. +- **Consequence:** when objects get passed across libraries, the memory layout differs but the symbol is the same, so the program can hit undefined behavior — usually showing up as a baffling segfault. ------ -#### Tip: Linking Does Not Provide Any Namespace Inheritance +#### Tip: No Namespace Inheritance in Linking + +This one is worth repeating! A lot of people think: "I put my function inside `namespace MyLib { ... }` in C++ code, or I compiled my code into `libMyLib.so`, so now the library acts like an isolated container, and the variable name `count` inside it won't clash with anything outside." + +But in reality **the linker is "type-blind" and "structure-blind."** We all know **a C++ namespace is just syntactic sugar:** the compiler turns `MyLib::foo()` into the string `_ZN5MyLib3fooEv` through **name mangling**. To the linker, that's just a long string. If two libraries happen to generate the same mangled name, the collision still happens. And **a dynamic library is not a namespace:** a dynamic library is just a way of organizing files. The moment it gets loaded into a process's memory, every exported symbol dumps into one flat, global symbol pool (the Global Symbol Table). The global variable `g_context` in `libA.so` and `g_context` in `libB.so` are the exact same thing in the linker's eyes — unless you've hidden them with visibility or bound them as local. + +## Through a Modern CMake Lens + +All those flags — `-fPIC`, `-fvisibility=hidden`, `-Wl,-Bsymbolic`, `$ORIGIN` and friends — you basically never type by hand anymore. CMake has packed them into a few lines of `add_library` / `set_target_properties`. -This needs to be repeated! Many people think: "I put the function in ``namespace MyLib { ... }`` in my C++ code, or I compiled the code into ``libMyLib.so``, so this library is like an independent container, and the variable name ``count`` inside won't conflict with the outside." +`add_library(foo SHARED)` basically does two things for you: it automatically adds `-fPIC` to every `.o` inside the library (SHARED turns it on by default), then uses `gcc -shared` to bundle them into a `.so`, which amounts to automatically running through the PIC flow we just talked about. Symbol visibility is handed off to `CMAKE_CXX_VISIBILITY_PRESET hidden` and `CMAKE_VISIBILITY_INLINES_HIDDEN`: once you set those, every symbol is hidden by default, and only the interfaces you explicitly tag with `__attribute__((visibility("default")))` make it into the dynamic symbol table — exactly matching the "symbol visibility" best practice from the previous section. `target_link_libraries` takes over `-l`/`-L`, and dependency relationships get propagated by CMake automatically (the three tiers PUBLIC/PRIVATE/INTERFACE); a good chunk of the duplicate-symbol pain from transitive dependencies gets sidestepped just by leaning on that. -But in reality, **the Linker is "Symbol Type-blind" and "Structure-blind."** We all know **C++ namespaces are just syntactic sugar**: The compiler turns ``MyLib::foo()`` into the string ``_ZN5MyLib3fooEv`` via **Name Mangling**. For the linker, this is just a long string. If two libraries happen to generate the same Mangled Name, conflicts will still occur. And **dynamic libraries are not namespaces**: Dynamic libraries are just a file organization form. Once loaded into process memory, all exported symbols enter a flat, global symbol pool. The global variable ``g_context`` in ``libA.so`` and ``g_context`` in ``libB.so`` are the same thing in the linker's eyes, unless you use Visibility hiding or Local binding. +The two remaining runtime pitfalls also have proper homes. That whole "you have to `export` after installing" dance with `LD_LIBRARY_PATH` — nowadays you pair `CMAKE_INSTALL_RPATH` with `$ORIGIN` so the executable remembers itself where the `.so` lives, and it'll find the library no matter what relative path you deploy it to. And a need like `-Wl,-Bsymbolic` for "I want my library to resolve its internal symbols against itself" can be hooked up just the same through `target_link_options(foo PRIVATE "-Wl,-Bsymbolic")`. In other words, the underlying linker–loader mechanism hasn't changed, but what you write today isn't `gcc -shared -fPIC -Wl,-Bsymbolic -o libfoo.so ...`, it's `add_library(foo SHARED)` plus a couple of `set_target_properties`, and CMake takes care of the dirty work for you. diff --git a/documents/en/compilation/10-dynamic-lib-as-executable.md b/documents/en/compilation/10-dynamic-lib-as-executable.md index 5c4d6fd94..05f0b5da1 100644 --- a/documents/en/compilation/10-dynamic-lib-as-executable.md +++ b/documents/en/compilation/10-dynamic-lib-as-executable.md @@ -8,212 +8,333 @@ tags: - cpp-modern - host - intermediate -title: 'Deep Dive into C/C++ Compilation and Linking (Bonus): Can Dynamic Libraries - Be Executed Like Executables?' -description: '' -translation: - source: documents/compilation/10-dynamic-lib-as-executable.md - source_hash: 6e132ffb5494a28d5f02b2d94d1894c7dddf3313b093d570498af15271e8f174 - translated_at: '2026-06-16T03:28:12.214179+00:00' - engine: anthropic - token_count: 2829 +title: 'Deep Dive into C/C++ Compilation and Linking (Side Note): Can a Dynamic Library Be Executed Like an Executable?' +description: 'Why running a .so directly segfaults, while libc happily prints its version info — a full walkthrough from ELF entry points to hand-rolled syscalls' +cpp_standard: [11, 14, 17, 20] --- -# Deep Dive into C/C++ Compilation and Linking (Bonus): Can Dynamic Libraries Be Executed Like Executables? +# Deep Dive into C/C++ Compilation and Linking (Side Note): Can a Dynamic Library Be Executed Like an Executable? -I know some friends might subconsciously laugh at this topic and think I am talking nonsense. Actually, in the very beginning, I also laughed it off, thinking it was too absurd. However, in reality, dynamic libraries **can indeed be executed like executable files.** +I know some of you reading this will laugh out loud and think I've lost my mind. Honestly, the very first time I came across this, I laughed it off too — it just sounded absurd. But the truth is, a dynamic library **can be executed like an executable.** -Some people might immediately throw a `Segment Fault` at me, telling me I am spouting nonsense. You can switch to the `/lib` directory yourself, find a library you like, for example, I have my eye on `libcurl` and `libcrypt`, and we can try to execute it directly. +Someone is going to throw a Segmentation Fault in my face and tell me I'm full of it. You can `cd` into `/lib` yourself, pick a library you like — I went with libcurl and libcrypt — and just try running it. + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcurl.so +Segmentation fault (core dumped) /lib/libcurl.so +[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcurl.so.4.8.0 +Segmentation fault (core dumped) /lib/libcurl.so.4.8.0 +[charliechen@Charliechen runaable_dynamic_library]$ /lib/libcrypt.so.2.0.0 +Segmentation fault (core dumped) /lib/libcrypt.so.2.0.0 -```text -$ /lib/x86_64-linux-gnu/libcurl.so.4.8.0 -Segmentation fault (core dumped) ``` -Our first thought is—why? Why did things turn out this way? The answer is simple. In subsequent blog posts, I will emphasize that generally speaking, files ending in `.so` are usually dynamic libraries (or shared libraries; I have already explained that in today's operating systems, we no longer need to strictly distinguish between shared libraries and dynamic libraries). +Our first thought is — why? Why does it end up like this? The answer is simple. In a later post I'll stress that, generally, anything ending in `.so` is a dynamic library (or shared library — as I've said before, on modern operating systems you don't really need to distinguish between "shared" and "dynamic" anymore). + +> [深入理解C/C++的编译与链接技术2:动态库静态库导论-CSDN博客](https://blog.csdn.net/charliechen114514191/article/details/154828385) -> [Deep Dive into C/C++ Compilation and Linking 2: Intro to Dynamic and Static Libraries - CSDN Blog](https://blog.csdn.net/charlie114514191/article/details/154828385) +Clearly, when you hand bash an absolute path like that, it tries to treat the file as a standalone program. That clashes with what a dynamic library actually is: a **dynamically shared component** bundling a set of functions and data. A shared library isn't designed with a standard main entry point ($\text{main}$) the way a regular program is, so when you run it directly, the execution flow can easily jump to an invalid memory address. When the OS detects this kind of **illegal memory access** — an attempt to read memory the program has no right to touch — it triggers a **segmentation fault**. I imagine a lot of you reading this have already made up your minds: my claim that "a dynamic library **can be executed like an executable**" must be wrong. -Obviously, when we directly input the absolute path of the file, the operating system's shell attempts to treat it as an independently runnable program. However, this is inconsistent with our definition of a dynamic library: a **dynamic shared component** containing a set of functions and data. Since shared libraries are not designed with a standard main entry point (the `main` function) like ordinary programs, when run directly, the execution flow is likely to jump to an invalid memory address. When the operating system detects this **illegal memory access** (attempting to access a memory area that the program has no right to access), it triggers a **segmentation fault**. I think many people, upon seeing this, are convinced that the point I made in this blog post—that dynamic libraries **can be executed like executable files**—is wrong. +Except it isn't. Let's try running the C library again: -However, that is not the case. We can try executing the C library again: +```cpp -```text -$ /lib/x86_64-linux-gnu/libc.so.6 -GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.4) stable release version 2.35. -Copyright (C) 2022 Free Software Foundation, Inc. +[charliechen@Charliechen runaable_dynamic_library]$ /lib/libc.so.6 +GNU C Library (GNU libc) stable release version 2.42. +Copyright (C) 2025 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -Compiled by GNU CC 11.3.0. +Compiled by GNU CC version 15.2.1 20250813. libc ABIs: UNIQUE IFUNC ABSOLUTE -Default branch protection: none -... +Minimum supported kernel: 4.4.0 +For bug reporting instructions, please see: +. + ``` -Hmm? This is very different from what we thought. This time, the C library not only didn't Segfault, but even printed a very distinctive string and exited gracefully! Very mysterious, right? Don't worry, I will take you step by step to explore exactly what happened. +Huh? That's nothing like what we expected. This time the C library didn't segfault — it printed a very recognizable string and exited gracefully. Pretty mysterious, right? Don't worry, I'll walk you through exactly what happened, step by step. -## So, What Actually Happened? +## So, What's Actually Going On? -It's simple. Let's start like this—since this involves the start of program execution, obviously friends familiar with the ELF file format will point out—perhaps our trick lies in the address pointed to by the ELF Header. It's almost easy to guess—it must be that the entry point of `libc`'s ELF Header is **inconsistent** with general component-purpose libraries like `libcurl`. So, the tool for viewing ELF header information is the famous `readelf` tool. +Simple. Let's start here — since this whole thing is about where program execution begins, anyone who knows the ELF format is going to point out that the trick must be hiding in the address the ELF Header points to. It's almost too easy to guess: libc's ELF Header must point to an entry point that's **different** from a component-purpose library like libcurl. And the tool for peeking at ELF headers is the famous `readelf`. -We need to emphasize a basic piece of knowledge about the ELF format—all ELF files (executables and shared libraries) have an "entry point," which is where the CPU starts executing instructions. In other words, it tells the CPU's execution flow (the value of EIP or RIP on x86-64) a definite initial value. +Quick ELF refresher — every ELF file (executable or shared library) has an "entry point," which is where the CPU starts executing instructions. Put another way, it gives the CPU's instruction pointer (EIP or RIP on x86-64) a concrete starting value. -```text -$ readelf -h /lib/x86_64-linux-gnu/libcurl.so.4.8.0 -... -Entry point address: 0x12710 -... -``` +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ readelf -h /lib/libcurl.so +ELF Header: + Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 + Class: ELF64 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: DYN (Shared object file) + Machine: Advanced Micro Devices X86-64 + Version: 0x1 + Entry point address: 0x0 + Start of program headers: 64 (bytes into file) + Start of section headers: 945200 (bytes into file) + Flags: 0x0 + Size of this header: 64 (bytes) + Size of program headers: 56 (bytes) + Number of program headers: 11 + Size of section headers: 64 (bytes) + Number of section headers: 28 + Section header string table index: 27 -```text -$ readelf -h /lib/x86_64-linux-gnu/libc.so.6 -... -Entry point address: 0x27834 -... ``` -Oh ho, now isn't the truth revealed? If we try to treat `libcurl` as an executable file, the operating system's loader reads the ELF Header and passes general checks, then sets the jump address to `0x12710`. Ah ha, isn't that accessing a null pointer? +Ha, mystery solved, right? If we try to treat `/lib/libcurl.so` as an executable, the OS loader reads it, runs its usual checks, and then sets the jump address to `0x0`. And there you go — that's a null pointer dereference. -This is exactly the same nature as doing this: +This is exactly the same thing as doing this: ```cpp + +#include + int main() { - return ((void (*)())0)(); + printf("Jumping to address 0x0...\n"); + void (*func)() = (void (*)())0x0; + func(); } + ``` -Compile and execute it, and you get exactly: +Compile and run it, and you get exactly: + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ gcc dump.c -o dump +[charliechen@Charliechen runaable_dynamic_library]$ ./dump +Jumping to address 0x0... +Segmentation fault (core dumped) ./dump -```text -Segmentation fault (core dumped) ``` -So what about our libc library? +So how about our libc? + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ readelf -h /lib/libc.so.6 +ELF Header: + Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00 + Class: ELF64 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - GNU + ABI Version: 0 + Type: DYN (Shared object file) + Machine: Advanced Micro Devices X86-64 + Version: 0x1 + Entry point address: 0x27830 + Start of program headers: 64 (bytes into file) + Start of section headers: 2145632 (bytes into file) + Flags: 0x0 + Size of this header: 64 (bytes) + Size of program headers: 56 (bytes) + Number of program headers: 16 + Size of section headers: 64 (bytes) + Number of section headers: 64 + Section header string table index: 63 -```text -$ readelf -h /lib/x86_64-linux-gnu/libc.so.6 -... -Entry point address: 0x27834 -... ``` -Hmm? It's really different. Don't worry, with just an address, we know nothing. The next step is to bring out our `objdump` magic to see the details: - -> Friends might ask me, why not `nm`? Well, for dynamic libraries, `nm` exposes the addresses of externally exported symbols. Generally speaking, you can't find what exactly corresponds to the EntryPoint. But don't worry, we still have a trick, which is using `objdump` to look at the disassembly. - -```text -$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -A 20 "27834" -... -0000000000027834 <__libc_start@@GLIBC_2.34>: - 27834: 48 8d 3d a5 d8 18 00 lea 0x18d8a5(%rip),%rdi # 1b50e0 <_dl_discover_osversion+0x2b0> - 2783b: 48 8d 35 57 d8 18 00 lea 0x18d857(%rip),%rsi # 1b5099 <_rtld_global+0x2d9> - 27842: 31 c0 xor %eax,%eax - 27844: e9 07 00 00 00 jmp 27850 <__libc_start@@GLIBC_2.34+0x1c> - 27849: 0f 1f 84 00 00 00 00 nop %eax,0x0(%rax) - 27850: bf 01 00 00 00 mov $0x1,%edi - 27855: ba e3 01 00 00 mov $0x1e3,%edx - 2785a: be 5a d8 18 00 mov $0x18d85a,%esi - 2785f: b8 01 00 00 00 mov $0x1,%eax - 27864: 0f 05 syscall -... +Huh, so it really is different. Hold your horses, though — all we've got is `0x27830`, which tells us nothing on its own. Next step: bring out the big gun, `objdump`, and look at the details. + +> Someone might ask, why not `nm`? Well, for dynamic libraries, `nm` only shows you the addresses of exported symbols — you generally won't find what the entry point actually maps to. Don't worry, we've got another trick up our sleeve: disassemble with `objdump`. + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ objdump -d /lib/libc.so.6 --start-address=0x27830 --stop-address=0x27860 + +/lib/libc.so.6: file format elf64-x86-64 + +Disassembly of section .text: + +0000000000027830 : + 27830: f3 0f 1e fa endbr64 + 27834: 55 push %rbp + 27835: bf 01 00 00 00 mov $0x1,%edi + 2783a: ba e3 01 00 00 mov $0x1e3,%edx + 2783f: 48 8d 35 5a d8 18 00 lea 0x18d85a(%rip),%rsi # 1b50a0 <__nptl_version@@GLIBC_PRIVATE+0x2b2d> + 27846: 48 89 e5 mov %rsp,%rbp + 27849: e8 d2 6c 0e 00 call 10e520 <__write@@GLIBC_2.2.5> + 2784e: 31 ff xor %edi,%edi + 27850: e8 7b d8 0b 00 call e50d0 <_exit@@GLIBC_2.2.5> + 27855: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1) + 2785c: 00 00 00 + 2785f: 90 nop + ``` -Don't rush. Now, let's use our memory recall technique. Starting from `0x27834`, the code attempts to do these things: +No need to rush. Let's dig into our memory now. Starting from `0x27834`, here's what the code is trying to do: + +> [x64.syscall.sh](https://x64.syscall.sh/) — the syscall table reference, dropping it here for you. + +- Put `0x01` into `edi` — that's the first argument the syscall needs. + +- Then the third argument goes into `edx`. Come on, that's just the string length — decimal **483**. -> [x64.syscall.sh](https://x64.syscall.sh/), I've put the Syscall table here. +- Hold on, we still need to put the string address into `rsi`, which is the second argument. Notice the instruction is `lea` (Load Effective Address), which adds the offset to the address right after the current instruction. So you can't just go look up `0x18d85a` directly — you have to add the current instruction's offset. -- Put `0x01` into `edi`. Here, the first parameter required by the system call is placed. -- Then put the third parameter into `edx`. Come on, isn't that just the length of the string? Decimal **483**. -- Don't worry, we also need to place the string address in `rsi` later, which is the second parameter. Notice that—the instruction is `lea` (Load Effective Address), which adds the offset to the address after the current instruction. So we can't directly look for `0x18d85a`, but we must add the offset of the current instruction. + Quick refresher: how does objdump arrive at `1b50a0`? The current instruction's base address is `0x2783f`, and the instruction itself is `48 8d 35 5a d8 18 00`, which is 7 bytes long. So the next instruction is at `0x2783f + 7 = 0x27846`. Add the given offset, and you get `0x27846 + 0x18d85a = 0x1b50a0`. OK, we've confirmed objdump isn't lying to us (not that it probably ever would!). - Reviewing this, how did `objdump` calculate `0x1b50a0`? First, the base address of the current instruction is at: `0x27834`. The length of the instruction itself is `be 5a d8 18 00`, totaling 7 bytes. So the next instruction is at `0x27834 + 0x7 = 0x2783b`. Adding the given offset address, that is—`0x2783b + 0x18d85a = 0x1b5095`. Wait, let's recheck the `objdump` output. The comment says `# 1b5099`. Let's re-calculate. `0x2783b + 0x18d85a` = `0x1b5095`. The `mov` is at `2785a`. `2785a + 0x5` (length of mov) = `2785f`. `2785f + 0x18d85a` = `0x1b5099`. OK, we are confident `objdump` didn't lie to us (mostly, of course it wouldn't!). +Want to verify the bytes are really there? -Want to see if it's really put there? +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ hexdump -C -s 0x1b50a0 -n 483 /lib/libc.so.6 +001b50a0 47 4e 55 20 43 20 4c 69 62 72 61 72 79 20 28 47 |GNU C Library (G| +001b50b0 4e 55 20 6c 69 62 63 29 20 73 74 61 62 6c 65 20 |NU libc) stable | +001b50c0 72 65 6c 65 61 73 65 20 76 65 72 73 69 6f 6e 20 |release version | +001b50d0 32 2e 34 32 2e 0a 43 6f 70 79 72 69 67 68 74 20 |2.42..Copyright | +001b50e0 28 43 29 20 32 30 32 35 20 46 72 65 65 20 53 6f |(C) 2025 Free So| +001b50f0 66 74 77 61 72 65 20 46 6f 75 6e 64 61 74 69 6f |ftware Foundatio| +001b5100 6e 2c 20 49 6e 63 2e 0a 54 68 69 73 20 69 73 20 |n, Inc..This is | +001b5110 66 72 65 65 20 73 6f 66 74 77 61 72 65 3b 20 73 |free software; s| +001b5120 65 65 20 74 68 65 20 73 6f 75 72 63 65 20 66 6f |ee the source fo| +001b5130 72 20 63 6f 70 79 69 6e 67 20 63 6f 6e 64 69 74 |r copying condit| +001b5140 69 6f 6e 73 2e 0a 54 68 65 72 65 20 69 73 20 4e |ions..There is N| +001b5150 4f 20 77 61 72 72 61 6e 74 79 3b 20 6e 6f 74 20 |O warranty; not | +001b5160 65 76 65 6e 20 66 6f 72 20 4d 45 52 43 48 41 4e |even for MERCHAN| +001b5170 54 41 42 49 4c 49 54 59 20 6f 72 20 46 49 54 4e |TABILITY or FITN| +001b5180 45 53 53 20 46 4f 52 20 41 0a 50 41 52 54 49 43 |ESS FOR A.PARTIC| +001b5190 55 4c 41 52 20 50 55 52 50 4f 53 45 2e 0a 43 6f |ULAR PURPOSE..Co| +001b51a0 6d 70 69 6c 65 64 20 62 79 20 47 4e 55 20 43 43 |mpiled by GNU CC| +001b51b0 20 76 65 72 73 69 6f 6e 20 31 35 2e 32 2e 31 20 | version 15.2.1 | +001b51c0 32 30 32 35 30 38 31 33 2e 0a 6c 69 62 63 20 41 |20250813..libc A| +001b51d0 42 49 73 3a 20 55 4e 49 51 55 45 20 49 46 55 4e |BIs: UNIQUE IFUN| +001b51e0 43 20 41 42 53 4f 4c 55 54 45 0a 4d 69 6e 69 6d |C ABSOLUTE.Minim| +001b51f0 75 6d 20 73 75 70 70 6f 72 74 65 64 20 6b 65 72 |um supported ker| +001b5200 6e 65 6c 3a 20 34 2e 34 2e 30 0a 46 6f 72 20 62 |nel: 4.4.0.For b| +001b5210 75 67 20 72 65 70 6f 72 74 69 6e 67 20 69 6e 73 |ug reporting ins| +001b5220 74 72 75 63 74 69 6f 6e 73 2c 20 70 6c 65 61 73 |tructions, pleas| +001b5230 65 20 73 65 65 3a 0a 3c 68 74 74 70 73 3a 2f 2f |e see:...| +001b5283 -```text -$ xxd -s 0x1b5099 -l 64 /lib/x86_64-linux-gnu/libc.so.6 -... -0001b5099: 474e 5520 4320 4c69 6272 6172 7920 2855 GNU C Library (U -0001b50a9: 6275 6e74 7520 474c 4942 4320 322e 3335 buntu GLIBC 2.35 -... ``` -Enough! The subsequent analysis is obviously putting `0` as the argument for `exit` into `edi` and exiting gracefully. +That's enough! The rest of the analysis is obvious: put `0` into `edi` as the argument to `exit`, and exit gracefully. -## Can We Do This Sort of Thing? +## Can We Pull Off the Same Trick? -Come on! Of course we can! Now, I will accompany you to do this job! But it will be a bit difficult because we can't rely on the libc library now. The initialization of dynamic libraries is inconsistent with our executable programs. For example, it won't actively initialize the C Runtime, it can't actively link the C library (of course, I previously specified a dynamic linker and found it useless, and the code crashed on the stack function jump; I was a bit helpless and couldn't figure it out after a long time), etc. +Come on, of course we can! Let me walk you through doing it ourselves. It's going to be a little tricky, though, because we can't lean on libc this time. A dynamic library's initialization differs from a normal executable's — for instance, it won't initialize the C runtime for you, and you can't just link against the C library (I did try specifying a dynamic linker earlier, to no avail — the code blew up on a stack function jump, and after banging on it for ages I just couldn't get it working), and so on. -So, now we can make one: +So here's what we can cobble together: ```cpp -// mylib.c + +#define NOT_API __attribute__((visibility("hidden"))) + +long NOT_API syscall_write(int fd, const char* buf, unsigned long len) { + long ret; + asm volatile( + "syscall" + : "=a"(ret) + : "a"(1), "D"(fd), "S"(buf), "d"(len) // 1 is sys_write + : "rcx", "r11", "memory"); + return ret; +} + +void NOT_API syscall_exit(int code) { + asm volatile( + "syscall" + : + : "a"(60), "D"(code) // 60 is sys_exit + : "memory"); +} + +unsigned long NOT_API ccstrlen(const char* s) { + unsigned long i = 0; + while (s[i]) + i++; + return i; +} + int add(int a, int b) { - return a + b; + return a + b; } -void _start() { - add(1, 2); - // exit gracefully - __asm__ __volatile__( - "movq $60, %%rax;" // syscall number for exit is 60 - "xorq %%rdi, %%rdi;" // status 0 - "syscall;" - : // no output - : // no input - : "%rax", "%rdi" - ); +void NOT_API _printf(const char* msg) { + syscall_write(1, msg, ccstrlen(msg)); } + +int NOT_API direct_load_helper_main() { + _printf("Hey! Welcome CCLibrary! " + "These is a dynamic library helps math calculations\n"); + _printf("Current Version is 0.1.0\n"); + _printf("You can process add by using the library!\n"); + + // Must Call these to remind linux + // to clear the stack + syscall_exit(0); +} + + ``` -Compile this code: +Compile it with: ```bash -gcc -shared -fPIC -o libmylib.so mylib.c +gcc -shared -fPIC -o libcclib.so cclib.c -Wl,-e,direct_load_helper_main + ``` -Execute it, and you get the result! +Run it, and there's your result: + +```bash +[charliechen@Charliechen runaable_dynamic_library]$ ./libcclib.so +Hey! Welcome CCLibrary! These is a dynamic library helps math calculations +Current Version is 0.1.0 +You can process add by using the library! -```text -$ ./libmylib.so -$ echo $? -0 ``` -Interested readers can follow my previous analysis to walk through the process again. +If you're curious, you can walk through the same analysis I did above on this library yourself. -So the question is, can our other executable programs use this code like using a library? Yes, they can. We just need to move the visible `add` symbol into a header file: `cclib.h` +So here's the question: can our other executables use this code the way they'd use any other library? Yep. Let's pull the visible `add` symbol out into a header, `cclib.h`: ```cpp -// cclib.h -#ifndef CCLIB_H -#define CCLIB_H + +#pragma once int add(int a, int b); -#endif ``` -And in `main.c`, do this just like our general library programming: +And in `main.c`, do the usual library-style thing: ```cpp -// main.c -#include + #include "cclib.h" +#include int main() { - printf("1 + 2 = %d\n", add(1, 2)); - return 0; + int result = add(1, 2); + printf("Result of 1 + 2 = %d\n", result); } -``` -```bash -gcc main.c -L. -lmylib -o test_app + ``` -No pressure at all! +No sweat at all! + +```cpp + +[charliechen@Charliechen runaable_dynamic_library]$ gcc main.c -o main ./libcclib.so +[charliechen@Charliechen runaable_dynamic_library]$ ./main +Result of 1 + 2 = 3 -```text -$ ./test_app -1 + 2 = 3 ``` + +## Through the Lens of Modern CMake + +That `gcc -shared -fPIC -Wl,-e,direct_load_helper_main` line from this post is something you basically never hand-type in a modern project — you hand it to CMake instead. `add_library(cclib SHARED cclib.c)` automatically adds `-fPIC` and produces the `.so`; the `visibility("hidden")` symbol-visibility trick maps to `set_target_properties(cclib PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)`, which CMake turns into `-fvisibility=hidden` for you. Overriding the entry point (`-Wl,-e`) is a pretty unusual need, and CMake has no built-in target property to set it directly — you typically feed it to the linker explicitly via `target_link_options(cclib PRIVATE "-Wl,-e,direct_load_helper_main")`. On the other side, the executable `gcc main.c -o main ./libcclib.so` becomes `add_executable(main main.c)` plus `target_link_libraries(main PRIVATE cclib)`, where CMake works out the link paths and `-lcclib` from the target dependency graph — no more hand-picking `-L` and `-l`. Once you understand the underlying ELF entry-point and symbol-visibility mechanics, looking back at these CMake commands, you can see exactly which chunk of the linker's job each one takes off your hands. diff --git a/documents/en/getting-started/01-editor-and-compiler.md b/documents/en/getting-started/01-editor-and-compiler.md new file mode 100644 index 000000000..52544a84d --- /dev/null +++ b/documents/en/getting-started/01-editor-and-compiler.md @@ -0,0 +1,101 @@ +--- +title: "What's an Editor, What's a Compiler—Two Things to Nail Down Before You Write a Line of Code" +description: "Before you touch the keyboard, let's clear up two basics: what software you write in, and how the code you write turns into something that runs" +chapter: 14 +order: 1 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 8 +--- + +# What's an Editor, What's a Compiler—Two Things to Nail Down Before You Write a Line of Code + +You want to learn C++, but hold off on typing code for a minute. Two things need to be clear first: what software you write code in, and how the code you write turns into a program that actually runs. It sounds like stating the obvious, but if these two stay fuzzy, everything that comes after—installing the tools, figuring out error messages—gets muddy along with them. So let's get them straight. + +## Code Is Just Plain Text + +Let's look at what the simplest C++ code looks like: + +```cpp +#include + +int main() { + std::cout << "你好,C++!" << std::endl; + return 0; +} +``` + +You save this as a file with a `.cpp` extension, say `main.cpp`. If you're curious, double-click it in Windows Notepad (or right-click, "Open with," and pick Notepad), and you'll see the exact same content. Plain and simple, a `.cpp` file is just plain text—a bunch of English characters and a few symbols, no different in nature from the words you'd type in Notepad. + +But if you actually tried writing code in Notepad, a few things would drive you up the wall. You mistype `int` as `itn` and Notepad says nothing—you only find out when the code won't run. Keywords like `int`, `return`, `include` are all black, just like ordinary words, so your eyes skim past without catching what matters. Long names like `std::cout` have to be typed out letter by letter every single time; Notepad gives you zero help. + +That's why nobody writes code in Notepad. Writing code takes a special kind of software, and that software is called an **editor**. + +## An Editor and an IDE Aren't the Same Thing + +```mermaid +flowchart LR + A["Editor vscode
light, cross-platform"] -->|install C++ ext| B["does the IDE's job"] + C["IDE
Visual Studio"] --> D["out of the box
edit+compile+debug"] +``` + + +An editor is software built for writing code. It beats Notepad in a few ways. First, syntax highlighting—keywords get colored, `int` in blue, strings in green, and the structure jumps out at you. Second, autocomplete—you type `std::co`, a little box pops up suggesting `cout`, hit Tab and it fills in. Third, errors get flagged red—a typo like `itn` gets a red underline on the spot, no waiting for a compile. + +There are plenty of editors out there, but this tutorial sticks with **vscode** (full name Visual Studio Code, made by Microsoft). The reasons are practical: it's free, it runs on Windows/Linux/Mac, it has the most extensions, and the moment you search for help online you'll find more tutorials than you can read. If you're already using something else, installing vscode to follow along won't cost you anything. + +Then there's a category of software called an **IDE** (Integrated Development Environment), which is easy to confuse with an editor. An IDE bundles "write code, compile, debug, run" all into one package, ready to use out of the box, no assembling pieces yourself. Microsoft's Visual Studio (note: not the same thing as vscode, the names look alike but they're different products) is an IDE, and it's a popular way to write C++ on Windows. CLion is another one, from JetBrains, and it costs money. + +Strictly speaking, vscode is an editor—fresh out of the install it only does highlighting and autocomplete, and compiling is something you have to figure out yourself. But its magic is in **extensions** (think of them as plugins)—once you install the C++-related extensions, the editor can do most of what an IDE does. That's exactly how we'll use it later on. So don't let the line "vscode is an editor, not an IDE" scare you; in practice the difference isn't as big as the wording makes it sound. + +::: details Click to see: So which do you pick, an editor or an IDE? + +- Editors (like vscode): light, flexible, cross-platform, but you need extensions to make them complete. +- IDEs (like Visual Studio): heavy, ready to use out of the box, strong debugger, but tied to a platform (VS is mainly a Windows story). +- If you're a beginner and genuinely unsure, just pick vscode—this tutorial is built around it, and following the install once is the least hassle. +::: + +## The Compiler: Translating Code Into a Program + +Here's the thing you need to understand: the `.cpp` you write is for humans to read. The computer can't actually run it. + +A computer only runs programs it recognizes—on Windows, that's `.exe` files. The software you double-click to open every day, your browser, your chat app, they're all `.exe`. A `.cpp` is a pile of English characters; an `.exe` is the computer's native language. The two sides don't speak the same tongue. So there has to be a translation step in the middle, turning `.cpp` into `.exe`. + +The software that does this translation is called a **compiler**, and the act of translating is called **compiling**. + +There are a few common C++ compilers: + +- **MSVC**: Microsoft's own, comes bundled with Visual Studio, smooth for writing C++ on Windows. +- **GCC**: Open source from the GNU project, used a lot on Linux. On Windows you usually install it through a package called **MinGW**. +- **Clang**: Another open-source compiler. Its error messages are friendlier than GCC's, which spares newcomers a headache. + +All three can compile standard C++ code. The differences are mostly in error-message style, performance, and some edge-case behavior. This tutorial goes the MinGW (that is, GCC) route on Windows, because it's free, lightweight, and plays nicely with vscode. The MSVC route means installing all of Visual Studio, which is big, and the bar is higher for a complete beginner. Once you're comfortable, you can switch any time. + +::: details Click to see: How to check from the command line whether your computer has a compiler +Open Windows' "Command Prompt" (search `cmd` in the Start menu), type the following, and hit Enter: + +```bash +g++ --version +``` + +If a version line pops up (something like `g++ (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 13.2.0`), then MinGW's GCC is already installed. If it says something like "'g++' is not recognized as an internal or external command," it's not installed—and the next article is where we install it. + +The MSVC command is `cl`, and Clang's is `clang++`. Same idea. +::: + +## Writing C++ Takes Two Things + +Stitch the previous two sections together and it's clear. Writing C++ needs two things: + +One is an **editor**, where you type code, edit code, and read error messages. We use vscode. + +The other is a **compiler**, where you feed the `.cpp` you just typed and it spits out a runnable `.exe`. On Windows we use the GCC that MinGW provides. + +In the next article we'll install vscode and a compiler, and along the way pick up a build tool called CMake—because once your code grows, a compiler alone isn't enough, and CMake helps you organize a bunch of `.cpp` files and compile them together. diff --git a/documents/en/getting-started/02-install-toolchain.md b/documents/en/getting-started/02-install-toolchain.md new file mode 100644 index 000000000..bb2ed2dc4 --- /dev/null +++ b/documents/en/getting-started/02-install-toolchain.md @@ -0,0 +1,209 @@ +--- +title: "Install the Three Things You Need to Write C++" +description: "Set up vscode, the MinGW compiler, and CMake on Windows from scratch, with screenshots and verification at every step" +chapter: 14 +order: 2 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 12 +--- + +# Install the Three Things You Need to Write C++ + +## Opening + +Last time we agreed you need two pieces of software: an editor (vscode) and a compiler. There's actually a third thing you need: a build tool called CMake. + +Let me explain what CMake does first. As we write more C++, a project won't be just one .cpp file. It might be five, six, a dozen files, spread across different folders. At that point, typing out the compile commands one by one will drive you crazy. CMake takes care of this stuff for you. You write one config file that tells it which files are in the project and what program to produce, and it handles the rest. We'll actually use it next time; for now we just need to install it. + +This whole article is hands-on, step by step, with a screenshot for every step. Once these three are installed, we can write the first real, runnable program in the next article. + +## The Windows Path (Recommended) + +If your system is Windows 10 or Windows 11, follow this section. Three steps, in order. + +### Step 1: Install vscode + +vscode is a free editor from Microsoft. From here on, this is where we'll type our code. + +Open your browser and go to . + +There's a big blue button in the middle of the page that says "Download for Windows". Click it. If your browser doesn't start downloading automatically, it'll take you to a download picker page. Pick the "Windows" option and you'll get a `.exe` installer. + +Once it's downloaded, double-click `VSCodeUserSetup-x64-x.x.x.exe`. The installer looks like any other installer, just keep hitting Next. The screen to watch out for is this one: + +Tick all of these (especially "Add to PATH", which you absolutely must check or you'll have headaches later): + +- On the "Select Additional Tasks" screen, tick "Add 'Open with Code' action to Windows Explorer file context menu" +- Tick "Add 'Open with Code' action to Windows Explorer directory context menu" +- Tick "Register Code as an editor for supported file types" +- Tick "Add to PATH" (the most important one) + +The remaining options (whether to put a shortcut on the desktop, some of the right-click menu items) are up to you. + +::: details Click to see: What if I forgot to tick "Add to PATH"? +Don't panic. The easy way out is to add vscode's install folder to the system PATH by hand. But the even easier way is: uninstall and reinstall, and tick the box this time. Reinstalling takes two minutes, faster than wrestling with PATH. +::: + +When it's done, press the Win key on your keyboard (the one with the Windows logo). You should see the vscode icon in the Start menu. + +Click it. If you see a welcome page, you're done. + +### Step 2: Install the Compiler (Going the MinGW-w64 Route) + +The compiler is the program that translates the .cpp you write into a .exe. There are several C++ compilers that work on Windows. We're going with MinGW-w64, which is essentially the famous GCC compiler from Linux, ported to Windows. + +Why pick this one? Two reasons. First, it's the same toolchain as the Linux environments we'll touch later in this series, so the command-line habits you build here carry over everywhere. Second, if you eventually want to go the embedded route (which this series also covers), GCC is the mainstream choice, so getting familiar with it early does no harm. + +Microsoft has its own compiler called MSVC (the Visual Studio family), which is also perfectly good. The differences between the two are tucked into a collapsible box below; we won't dig in here, just get MinGW installed first. + +The least painful way to install MinGW is through a tool called MSYS2. MSYS2 is basically a package manager. Think of it as an app store, like the one on your phone, except it installs command-line tools for programmers, and you operate it from the command line. + +Open your browser and go to . + +There's a download link on the page pointing to an installer named something like `msys2-x86_64-xxxxxxxx.exe` (the filename contains a date, so it's normal if yours looks different). Download it and double-click to run. + +The installer asks you to pick an install path. **I strongly recommend leaving it at the default `C:\msys64`**, don't change it. We're going to add things to the system PATH later, and having the path baked in is just easier. If you install somewhere else, every path from here on has to change too, and that's where mistakes creep in. + +Keep hitting Next until it finishes. Once it's done, you'll see a few new MSYS2 entries in the Start menu. + +::: warning Here's the trap beginners fall into most +There are several MSYS2 entries in the Start menu: "MSYS2 MINGW64", "MSYS2 UCRT64", "MSYS2 CLANG64", "MSYS2", and so on. + +**Open "MSYS2 UCRT64" specifically**, don't open "MSYS2" (the plain one). We're installing the UCRT64 build of GCC, and it only works properly inside the UCRT64 terminal. Open the wrong one and after you install, the commands won't be found. +::: + +After opening the UCRT64 terminal you'll see a command-line window with purple text. Type this line in (get the capitalization, spaces, and hyphens right), then hit Enter: + +```bash +pacman -S mingw-w64-ucrt-x86_64-gcc +``` + +`pacman` is the command that drives the MSYS2 "app store". `-S` means "install (sync)", and that long string after it is the name of the package to install. + +The first time you install something, pacman will ask whether to continue and whether the package is the right one. Type `Y` and Enter to confirm. It'll download a few tens of megabytes, give it a moment. + +Once it's installed, we have to tell Windows about this new compiler. That means telling the system's PATH variable where it lives. What's PATH? Think of it as the system's "address book of frequently used folders". Any address written in there, the system can find the programs inside directly, without you spelling out the full path every time. + +Press the Win key, search for "environment variable", and click "Edit the system environment variables". + +In the window that pops up, there's an "Environment Variables" button in the bottom right. Click it. In the "System variables" list in the lower half, find the row named `Path` (note: `Path`, not `PATHEXT`), and double-click it. + +In the list that appears, click "New" and enter this line (assuming you used the default path when installing MSYS2): + +```text +C:\msys64\ucrt64\bin +``` + +Hit OK all the way out to close every window and save. + +Now let's verify it worked. **You have to open a brand new command-line window for this.** You just changed PATH, and the old window won't pick up the change automatically, you must open a fresh one. + +Press Win+R, type `cmd`, hit Enter, and a Command Prompt opens (the one with the black background). Type: + +```bash +g++ --version +``` + +If you see output like this (the exact version number may be newer), you're set: + +```text +g++ (Rev2, Built by MSYS2 project) 16.1.0 +Copyright (C) 2025 Free Software Foundation, Inc. +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +``` + +If you get something like "'g++' is not recognized as an internal or external command", the PATH isn't set right. Go back and check three things: is the path written as `C:\msys64\ucrt64\bin` (a lot of people miss the `\ucrt64\` in the middle), is anything misspelled, and did you actually open a new cmd window. + +### Step 3: Install CMake + +Last one. Open your browser and go to . + +The page lists installers for several platforms. Under the Windows section, find `Windows x64 Installer` and download that `.msi` file (the filename looks like `cmake-x.y.z-windows-x86_64.msi`). + +Double-click the `.msi`. Keep hitting Next through the installer, and pay attention on this screen: + +It asks whether to add CMake to the system PATH. **Pick the second option, "Add CMake to the system PATH for all users"** (add it to the system PATH for everyone). The first option leaves it out by default, the third only adds it for the current user. The middle one is the least hassle. + +Keep going and finish the install. + +Verify it. **Same rule, open a fresh cmd window** (the old window's PATH hasn't refreshed). Type: + +```bash +cmake --version +``` + +See a version number printed, and you're set: + +```text +cmake version 4.4.2 + +CMake suite maintained and supported by Kitware (kitware.com/CMake). +``` + +::: details Click to see: Installing CMake from the command line works too +If you prefer the command line, you can also install CMake from inside the MSYS2 UCRT64 terminal with `pacman -S mingw-w64-ucrt-x86_64-cmake`. Done that way, CMake lands under `C:\msys64\ucrt64\bin`, right alongside the GCC you just installed, so you don't need to touch PATH separately. Pick one of the two methods, don't do both. +::: + +## Install the C++ Extensions for vscode + +The three main pieces are in place. Now let's give vscode two "extensions". An extension is basically a plugin for vscode that adds extra features. + +Open vscode. In the column of icons on the far left, find the one made of four little squares (hovering over it shows "Extensions") and click it. Or just press `Ctrl+Shift+X`. + +In the search box at the top, search for each of these two names, find the matching extension, and click "Install": + +The first is C/C++. This is the official extension from Microsoft, and it gives you code completion, go-to-definition, error highlighting, that sort of thing. We won't touch its settings until article 5, but installing it now costs you nothing. + +The second is CMake Tools. Also official from Microsoft, it's what makes vscode play nicely with CMake. We'll use it in the next article when we write our first program. + +Once both are installed, the blue status bar at the bottom of the vscode window gets a few CMake-related buttons (the current build type, a build button, things like that). Seeing those means the extensions are live. + +::: details Click to see: How to install on Linux (Ubuntu/Debian family) +That covers the Windows main line. If you're on a Linux machine, the whole thing installs with one command, far less fuss than Windows. + +Open a terminal and type this (it installs the compiler, CMake, and debugger all at once): + +```bash +sudo apt update && sudo apt install -y build-essential cmake ninja-build gdb +``` + +The `build-essential` package contains the GCC compiler, `cmake` is the build tool, `ninja-build` is a faster build backend that CMake often pairs with, and `gdb` is the debugger we'll need later for tracking down problems. `sudo` means "run with admin privileges" and it'll ask for your password. + +For vscode, go to , download the `.deb` package, and double-click to install (or from the command line, `sudo apt install ./code_*.deb`). + +Verify the same way as on Windows: + +```bash +g++ --version +cmake --version +``` + +If you see version numbers, you're set. The C/C++ and CMake Tools extensions still need to be installed inside vscode, that part has nothing to do with the OS. +::: + +::: details Click to see: How are MSVC and MinGW really different, and which should you pick? +There are two main C++ compiler families on Windows: Microsoft's own MSVC (the Visual Studio family), and the MinGW route we're taking here (the Windows port of GCC). + +Short version: both work, both can compile Windows programs, and for day-to-day learning the differences barely matter. But a few points are worth knowing. + +The debugger differs. MSVC pairs with Microsoft's own debugger; MinGW pairs with GDB. This series uses GDB a lot later on, because the embedded track also uses GDB, so the habits line up. + +C++ standard support and pace differ. MSVC ships ahead on some new features, GCC ahead on others, they trade the lead. For the beginner stage it makes no difference. + +Size differs. The full Visual Studio install is a dozen-plus GB. MinGW plus MSYS2 is one or two GB and you're set. When you're just starting out, lighter is easier. + +Command-line habits differ. MSVC leans toward the Windows-native world (the cl.exe compiler, linker setup that has nothing in common with Linux), while MinGW matches GCC on Linux and macOS. Every command and CMake config later in this series assumes GCC, so MinGW is the smoother path. + +If later on you end up doing Windows desktop app development, or you need to call Windows-specific APIs (Direct3D, for instance), that's the time to install Visual Studio and pick up MSVC. The detailed comparison and how to switch between them lives in vol1/ch00, the article dedicated to setting up a Windows environment. +::: + +Three things are now installed: the vscode editor, the MinGW compiler, and the CMake build tool, plus the two C++ extensions inside vscode. In the next article we'll write our first C++ program inside vscode, actually run it, and see how that line `Hello, World!` turns from code into letters on the screen. diff --git a/documents/en/getting-started/03-first-program.md b/documents/en/getting-started/03-first-program.md new file mode 100644 index 000000000..1760a185f --- /dev/null +++ b/documents/en/getting-started/03-first-program.md @@ -0,0 +1,224 @@ +--- +title: "Your First C++ Program: Getting Hello to Run in vscode" +description: "Build a project from scratch in vscode, write main.cpp and CMakeLists.txt, configure, build, and run, until Hello actually prints to the screen" +chapter: 14 +order: 3 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - CMake +reading_time_minutes: 15 +--- + +# Your First C++ Program: Getting Hello to Run in vscode + +## Opening + +Environment all set up, right? (If not, go back to article 2 — vscode, MinGW, CMake, and those two extensions all have to be installed.) This time we'll do something with a bit of ceremony: write your first C++ program by hand, actually run it, and make it spit out `Hello, C++!` on the screen. + +The whole thing is clicking buttons inside vscode. You won't type a single command. (The command-line version is in a collapsible box at the end — open it if you're curious.) We'll walk the full mini-project loop: make a folder, write code, write the CMake config, configure, build, run. Sounds like a lot of steps, but each one is a single click. Follow along once and you'll have the routine down. + +## Step 1: Make a project folder + +First, find somewhere to put the code you write. Don't just dump files on the desktop or the root of the C drive — within two days it'll be a mess. Give each project its own folder. + +On the desktop (or wherever you like, something like `D:\code\`), right-click and create a new folder. Name it `hello`. Short, all lowercase, no spaces. Those three rules apply to every name you'll ever give a file in code, so get used to them now. + +Once the folder is there, open vscode. Click the menu `File → Open Folder`, in the popup pick the `hello` folder you just made, and click "Select Folder". + +After it opens, vscode shows an Explorer panel on the left, with `hello` as its title and nothing underneath — empty, because it's an empty folder. That's exactly right. We'll fill it from scratch. + +::: tip "Open Folder" isn't a pointless step +vscode isn't like Notepad. It thinks in terms of "projects". You have to tell it "I'm going to work inside the hello folder from now on", and only then does it wire up extensions, CMake, debugging, and everything else to that folder. You can drag a `.cpp` into vscode and edit it, sure, but the CMake pipeline further on won't work. So every time you start a new project, the first step is always "Open Folder". +::: + +## Step 2: Create main.cpp + +To the right of the `hello` title in the Explorer panel on the left, there's a row of small icons. Hover over them. The first one, which looks like a blank page with a plus sign, is "New File" (the tooltip says `New File`). Click it. + +After you click, a small input box appears in the panel asking for a filename. Type `main.cpp` and press Enter. + +Why `main`, and why the `.cpp` extension? `main` is the conventional name — the entry point of a C++ program (where execution starts) lives in this file, and everyone names it that way, so when you talk to other people nothing gets lost in translation. `.cpp` is the standard suffix for C++ source files; the moment a compiler sees `.cpp` it knows to compile it as C++. + +After Enter, the main editing area opens `main.cpp` (empty, of course), and `main.cpp` also shows up as a new entry in the Explorer on the left. + +## Step 3: Paste the code in + +Copy this whole snippet and paste it into `main.cpp`: + +```cpp +#include + +int main() { + std::cout << "Hello, C++!\n"; + return 0; +} +``` + +Once it's pasted, the code in the editor turns colorful — keywords like `int`, `return`, `#include` get one color, and the string `"Hello, C++!\n"` gets another. That's syntax highlighting, which we mentioned last time. It's the editor doing its job. + +A quick word on what this code does. You don't have to memorize any of it yet — just get a feel for the shape. + +The first line, `#include `, pulls in C++'s built-in "input/output" toolkit. The name `iostream` splits into input output stream, and it's what handles "read stuff from the keyboard" and "write text to the screen". + +The `int main()` in the middle is the entry point. When a C++ program runs, it always starts executing from the first line of the `main` function, no exceptions. The braces `{}` wrap what the program actually does. + +`std::cout << "Hello, C++!\n";` writes text to the screen. You can think of `std::cout` as the codename for "the screen" object, and `<<` as an arrow that says "push this in", pushing the text on the right into the screen to be displayed. The `\n` is a newline character — after printing, it moves the cursor to the next line. + +`return 0;` tells the operating system "this program finished normally, no errors". 0 means OK, anything non-zero means something went wrong. We'll use that convention later; for now just remember 0 is good. + +## Step 4: You also need a CMakeLists.txt + +Code's written. You might be thinking, "now I just hit that triangle run button and it'll go, right?" + +No. This step trips up a lot of beginners, so let me explain why first. + +The run button that comes with vscode (or pressing `F5`) doesn't know which compiler you want to use, which file to compile, or what to name the resulting program — it knows nothing. Hand it a `main.cpp` and it just stares at you blankly. We need to write a separate "instruction manual" that tells it all of this. The file that manual goes in is called `CMakeLists.txt`, and CMake is what reads it. + +(Some of you will ask: didn't article 1 say the compiler can compile directly? Right — calling `g++ main.cpp` on the command line does work. But vscode's graphical buttons go through the CMake pipeline. Since we're going to click buttons in vscode, we follow CMake's rules. CMake also handles multi-file projects, as we'll see in article 4.) + +Next to `main.cpp`, use the same "New File" icon from Step 2 to create another file named `CMakeLists.txt` (mind the capitalization: `C`, `M`, `a`, `k`, `e` are uppercase, the `L` in `Lists` is uppercase, the rest lowercase, with the `.txt` suffix). CMake dictates this name exactly. One letter off and it won't recognize it. + +Paste this in: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(hello LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) +``` + +What these five lines mean, translated line by line: + +The first line, `cmake_minimum_required(VERSION 3.20)`, says "the CMake running this project must be at least version 3.20". 3.20 is a fairly old floor — almost every machine meets it. CMake uses this line to check whether your installed CMake is new enough. + +The second line, `project(hello LANGUAGES CXX)`, says this project is named `hello` and the language is C++ (`CXX` is CMake's codename for C++; plain C is `C`, C++ is `CXX`). + +The fourth line, `set(CMAKE_CXX_STANDARD 17)`, says "use the C++17 version of the standard". C++ has kept evolving over the years — there's C++11, 14, 17, 20, 23, each newer one adding more features. 17 is a stable version that almost any project can use, so we start with 17. + +The fifth line, `set(CMAKE_CXX_STANDARD_REQUIRED ON)`, says "the standard above isn't a suggestion, it's a hard requirement". If the compiler doesn't support C++17, it errors out instead of quietly dropping back to an older standard — because if it silently downgraded, you wouldn't know, and debugging the mess later would be a nightmare. + +The last line, `add_executable(hello main.cpp)`, is the most important one. `add_executable` means "produce an executable program". The first argument inside the parentheses, `hello`, is the name of the program to produce, and the second, `main.cpp`, is the source file to compile. Read as a whole: compile `main.cpp` into an executable program named `hello` (on Windows that's `hello.exe`). + +## Step 5: Pick a kit + +Remember that extra chunk that showed up in vscode's bottom status bar after you installed the two extensions in article 2? Time to use it. + +Click the bit in the status bar that says `No Kit Selected`. A small list pops up. Or press `Ctrl+Shift+P` to open the command palette, type `CMake: Select a Kit`, and press Enter — same thing. + +The list shows every compiler vscode managed to find on your machine. You should see an entry something like `GCC 16.1.0 x86_64-w64-mingw32` or `GCC x.x.x ucrt64` (the exact version depends on which MinGW you installed). Pick that GCC one. + +Once selected, the text in that part of the status bar changes to something like `GCC 16.1.0`, showing which compiler is currently active. + +"Kit" is the CMake Tools extension's term. You can think of it as a "toolbox" — it tells CMake Tools "use this compiler from now on". You only have to pick once; the next time you open this project it remembers. + +::: warning What if there's no GCC in the list +If the list has no GCC at all, just entries like `Visual Studio`, that means the MinGW step in article 2 didn't install properly, or it installed but the PATH isn't set right and CMake Tools can't find it. Go back and check Step 2 of article 2. Focus on whether `C:\msys64\ucrt64\bin` was actually added to the system PATH, and whether you restarted vscode after changing the PATH (PATH changes need a vscode restart to take effect). + +There's usually also an `[Unspecified]` entry at the very bottom of the list, meaning "don't specify". Don't pick that one — we want to explicitly select GCC. +::: + +## Step 6: Configure + +With the kit picked, the next step is called "configure". Click the `Configure` text in the status bar, or run `CMake: Configure` from the command palette. + +After you click, an output panel pops up at the bottom of vscode and text starts scrolling, something like this: + +It runs for a bit and stops. As long as there's no red text and you see `Configuring done` and `Generating done` at the end, it worked. + +What does configure do? CMake reads your `CMakeLists.txt` and, following the instructions inside, generates a pile of "build files" (a `build` subfolder appears under `hello`, and everything goes in there). This step **has not compiled your code yet** — it's CMake doing prep work, lining up which compiler to use, which files to compile, and what to produce. The actual compile happens next. + +::: tip When to re-run configure +From now on, any time you change `CMakeLists.txt` (say, adding a new source file), you have to re-run configure so CMake picks it up. Just editing a `.cpp` file does not require reconfiguring — CMake notices that on its own. +::: + +## Step 7: Build + +Configured. Now click the `Build` button in the status bar, or run `CMake: Build` from the command palette. + +The output panel scrolls again, but with different content this time — the compiler is actually working now. You'll see lines like `Building CXX object ... main.cpp.o` and `Linking CXX executable hello.exe`. The last line shows a success message. + +At this point your `main.cpp` has actually been translated into `hello.exe`, sitting in the `hello\build\` folder. Next step: run it. + +::: warning If it errored out +The most common error is "compiler not found" or a wrong compiler path — go back to Step 5 and pick the kit again. Another common one is mistyping the `main.cpp` filename (something like `mian.cpp`), which CMake then can't find. Error messages usually tell you which line went wrong, so read them and match them up. After fixing, click `Build` again. +::: + +## Step 8: Run + +There's a triangle play button in the status bar — that's the `Run` button. Be careful not to click the one next to it with the little bug icon; that's the `Debug` button, which drops you into debug mode. We don't need that yet. + +Click the run button. A terminal panel pops up at the bottom of vscode (if it doesn't, press `` Ctrl+` `` to bring it up), and inside it prints a single line: + +```text +Hello, C++! +``` + +There it is — your first C++ program is actually running. That one line went from code to characters on the screen, through the whole "write code → configure → build → run" pipeline. Every C++ program you write from here on uses this same routine. + +## Step 9: Tweak it and run again + +Getting it to run once doesn't mean you're fluent. Let's change the code and run it again, to lock the loop in. + +Go back to `main.cpp`, and change that `Hello, C++!` line to whatever you want to say, like this: + +```cpp +#include + +int main() { + std::cout << "我学会了写 C++!\n"; + return 0; +} +``` + +Save it (`Ctrl+S`). After saving, the little white dot next to the filename in the status bar disappears, which means the change is on disk. + +Then just click the `Run` button in the status bar. CMake Tools automatically rebuilds first (it noticed the `.cpp` changed) and then runs. This time the terminal prints: + +```text +我学会了写 C++! +``` + +From now on, editing code is just these moves: change, save, run. The configure and build steps in between are wired up for you automatically by CMake Tools. + +::: details Click to see: How to do it on the command line +Those buttons you've been clicking are really just running a few commands underneath. Let's run them by hand in the vscode terminal so you can see what the buttons are doing. + +Open the vscode terminal (menu `Terminal → New Terminal`, or the shortcut `` Ctrl+` ``). The first build takes three steps: + +```bash +cmake -B build +cmake --build build +.\build\hello.exe +``` + +The first, `cmake -B build`, is "configure" — it generates the build files in the `build` folder (`-B` specifies the output directory). + +The second, `cmake --build build`, is "build" — it actually calls the compiler and turns `main.cpp` into `hello.exe`. + +The third, `.\build\hello.exe`, is "run" — it just executes that `.exe`. + +After that, whenever you change code, you only rerun the last two (the second step automatically recompiles only the files that changed, then you run the third). + +If you're on Linux (the apt route from the collapsible box in article 2), the run command is slightly different: + +```bash +cmake -B build +cmake --build build +./build/hello +``` + +Two differences: on Linux, executables don't have to carry the `.exe` suffix (CMake produces `hello` instead of `hello.exe` by default), and when you run it the path separator is a forward slash `/` with a `./` prefix. +::: + +Your first C++ program is running. From code to that line on the screen, you've walked the whole pipeline once. This loop — make a project, write code, write CMakeLists, configure, build, run — is something you'll use over and over. Run it a few more times and it'll be second nature. + +Next time we'll grow the project: one `.cpp` isn't enough anymore, so we'll look at how to organize several files and make them work together. diff --git a/documents/en/getting-started/04-multi-file-cmake.md b/documents/en/getting-started/04-multi-file-cmake.md new file mode 100644 index 000000000..8ba76e921 --- /dev/null +++ b/documents/en/getting-started/04-multi-file-cmake.md @@ -0,0 +1,232 @@ +--- +title: "The Project Grows — Multiple Files, and Why CMake Shows Up" +description: "Grow the single-file hello from part 3 into three files, and use CMake for real on a multi-file project for the first time" +chapter: 14 +order: 4 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - CMake +reading_time_minutes: 15 +--- + +# The Project Grows — Multiple Files, and Why CMake Shows Up + +## Opening + +Last time we got the first C++ program running inside vscode, and the terminal dutifully printed `Hello, C++!`. But that whole project was just one `main.cpp` with every line of code crammed into a single file. Real projects are never that small. The moment you try to write something serious, the line count climbs, and keeping it all in one file turns into a mess even you can't read. + +This time we'll grow the project from "one file" to "three files", and we'll put CMake to real use instead of just dropping its name like we did in part 3. Once a three-file project builds, you'll see exactly what CMake is buying you. + +## Why split into files at all + +Let's settle the question first: do we have to split files, or is it optional? + +It's optional, but try stuffing everything into `main.cpp` and once you're past two or three hundred lines you'll feel the chaos. Hunting for a function means scrolling forever. You change one thing and worry about breaking another. Functions pile on top of each other until you can't see the shape of the code anymore. As the file grows, your blood pressure tends to climb first when debugging. + +The common way to split is one file per "kind of feature". For this part we'll build the simplest possible "say hello" feature and put it in its own two files, `greet.cpp` and `greet.h`. `main.cpp` only handles the main flow. Each file minds its own job, and the borders stay clean. + +::: details Click to open: what's the deal with .cpp and .h +In C++, one feature usually gets split into two files: a `.h` (header file) and a `.cpp` (implementation file). + +The `.h` holds the "declaration". It tells the other files "I have this thing, and here's what it looks like". The `.cpp` holds the "definition", meaning how that thing actually does its work. + +When another file wants to use this feature, it `#include`s that `.h`, basically grabbing the "promise note" so it knows what it's allowed to call. How the `.cpp` implements things? The caller doesn't care. The compiler wires it up at link time (we'll get to that below). + +It looks fussy, but the payoff is real: change how a feature is implemented, and as long as the "promise note" (the `.h`) didn't change, the other files that call it don't need to be recompiled at all. Once you have a lot of files, the time saved adds up fast. +::: + +## Here's what the three files look like + +Let's make a new project folder called `greeter` (a little "say hello" program) and put three files inside. You can close the hello project from part 3 if you like, and start fresh in a clean directory. + +Create three files with these names and contents. First, `greet.h`. This is the header file, and it declares what the `greet` function looks like: + +```cpp +#pragma once +#include + +std::string greet(const std::string& name); +``` + +The `#pragma once` line is the header file's "don't include me twice" switch. It tells the compiler "count this file only once during the whole build. If somebody includes it a second time, skip it". Without this line, if two files both included `greet.h`, the compiler would copy its contents in twice and then throw a "duplicate definition" error at you. + +The middle line, `#include `, pulls in the standard library's string type. The `greet` function uses `std::string`, so we have to tell the compiler what that is first. + +The last line is the function declaration: there's a function called `greet` that takes a `std::string` (named name) and returns a `std::string`. Note the semicolon at the end and the absence of curly braces. This is the "promise note". It says the function exists but says nothing about how it works. + +Now `greet.cpp`. This file does the implementation: + +```cpp +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} +``` + +The first line, `#include "greet.h"`, pulls in that promise note we just wrote. Note the double quotes `""` instead of angle brackets `<>`: double quotes mean "a header you wrote yourself in this project", angle brackets mean "a system or standard library header". It's a convention, don't mix them up. + +Below that is the function definition: it concatenates `"Hello, "`, the name passed in, and `"!"` and returns the result. This is "making good on the promise", telling the compiler exactly how this function does its work. Now we get the curly braces, and inside them is the code that actually does the job. + +Finally, edit `main.cpp` to call this function: + +```cpp +#include +#include "greet.h" + +int main() { + std::cout << greet("world") << "\n"; + return 0; +} +``` + +`main.cpp` also includes `greet.h`. It wants to use the `greet` function, so it has to grab the promise note first and learn what the function takes in and spits out. Then it calls `greet("world")` and hands the returned string to `std::cout` to print. + +Here's a metaphor to help it stick. `greet.h` is a promise note ("there's a function called `greet`, it takes a name, it returns a sentence"). `greet.cpp` is the promise being kept (exactly how the string gets assembled). `main.cpp` is the person using it (grabs it and goes, doesn't care about the details). Three files, each with its own job. + +## Hand-compiling gets old fast, enter CMake + +The three files are ready. Now the question: how do we compile them into one `.exe`? + +Back in the single-file project, the one line that mattered in our `CMakeLists.txt` was: + +```cmake +add_executable(hello main.cpp) +``` + +This line means "produce an executable program called `hello`, with source file `main.cpp`". Now we have three files. Just list them all on this line: + +```cmake +add_executable(greeter main.cpp greet.cpp) +``` + +That pulls `greet.cpp` in too. Changing this one line is enough, nothing else needs to move. The full `CMakeLists.txt` looks like this: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) +``` + +Save those four lines (plus one blank line) as `CMakeLists.txt`, in the project root, sitting next to the three `.cpp` and `.h` files. + +Watch the file name capitalization: it's `CMakeLists.txt`, with capital C and capital L, ending in `.txt` not `.cmake`. CMake looks for that exact name by default. Get one letter wrong and it won't find it. + +## Run it + +Four files ready, let's run it. The flow is exactly the same as last time. + +Step one, save all your files. In vscode hit `Ctrl+K` then `S` (or menu File → Save All) and save everything you changed. The trap every beginner falls into is editing a file, not saving it, and then watching the build compile the old contents and wondering why nothing changed. + +Step two, configure. Click the "Configure" button in vscode's bottom status bar (or search `CMake: Configure` in the command palette). CMake will scan `CMakeLists.txt` and prepare the build files. If this step passes, a `build` folder shows up in your project directory. + +Step three, build. Click "Build" in the status bar (or `CMake: Build`, shortcut `F7`). This is the actual compile. You'll see a stream of output in the terminal. When you spot `[100%]` and `greeter.exe`, it's done. + +Step four, run. Click "Run" in the status bar (or `CMake: Run Without Debugging`, shortcut `Shift+F5`). + +The terminal prints: + +```text +Hello, world! +``` + +At this point the three-file project runs. `main.cpp` calls the `greet` function implemented in `greet.cpp`, the function assembles the string and returns it, and `main` prints it. The simplest possible multi-file collaboration. + +## What CMake actually does for you + +```mermaid +flowchart LR + A["main.cpp"] --> C["CMake"] + B["greet.cpp"] --> C + C --> D["greeter.exe"] +``` + + +Let's stop and think. Without CMake, how would we turn these three files into an `.exe`? You'd have to type something like this on the command line (don't actually run it, this is just so you can see it): + +```text +g++ main.cpp greet.cpp -o greeter +``` + +Three files, you can still about remember that. But say the project has ten or twenty `.cpp` files. That command becomes a long string of file names, and forgetting one means a link error. And every time you change one file, you'd have to rerun the whole command, recompiling the files you didn't even touch, wasting time for no reason. + +The two headaches CMake takes off your plate are exactly these: + +Which files to compile, and who depends on whom. As long as you list the file names on the `add_executable` line, CMake lines everything else up. `main.cpp` includes `greet.h`, so CMake figures out on its own that `main.cpp` depends on `greet.cpp`, and it wires them together at link time. You don't have to lift a finger. + +Whether a change means rebuilding everything. CMake works out "you only changed `greet.cpp` this time, so only recompile that one, reuse the previously built versions of the others". Once the file count grows, this saves you a real chunk of time. + +Adding files to the project later comes down to one move: append a file name to the end of the `add_executable` line. Say you add `farewell.cpp`. Change it to `add_executable(greeter main.cpp greet.cpp farewell.cpp)`, click Configure + Build again, and the new file is in. You never have to memorize a single compile command. CMake handles it all. + +## What each line of CMakeLists means + +Let's translate it line by line, so you have a mental model. + +```cmake +cmake_minimum_required(VERSION 3.20) +``` + +States "the minimum CMake version this project needs is 3.20". CMake itself is old (it's been around since 2000), but a few of the things this tutorial uses need at least 3.20. Set the version too high and an old CMake will refuse to run and tell you straight up. Set it too low and you might be fine for half the build and then crash on some specific command. Setting a floor is the safe move. + +```cmake +project(greeter LANGUAGES CXX) +``` + +States "this project is called `greeter`, and the language is C++". The `CXX` in `LANGUAGES CXX` is CMake's code name for C++ (C is `C`, C++ is `CXX`, because a plus sign isn't legal in a variable name). Once you declare the language, CMake goes off to find a matching compiler (in our case, the g++ we installed). + +```cmake +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +``` + +Read these two together. They control "which version of the C++ standard to use". `CMAKE_CXX_STANDARD 17` sets it to C++17. `CMAKE_CXX_STANDARD_REQUIRED ON` means "this standard is a hard requirement". If your compiler is too old and doesn't support C++17, the build fails outright instead of quietly dropping back to an older standard and compiling along (that quiet downgrade is the worst kind, the build passes but the behavior is off, and you only find out after debugging for ages). + +```cmake +add_executable(greeter main.cpp greet.cpp) +``` + +This last line is the one that matters most. It tells CMake "produce an executable program called `greeter`, with source files `main.cpp` and `greet.cpp`". The executable name (`greeter`) and the file names (`main.cpp greet.cpp`) don't have to match. Call it `greeter` if you want, it's your call. The resulting `.exe` will be `greeter.exe`. The `.h` header doesn't belong on this line, it gets pulled into the `.cpp` files through `#include`, and CMake finds it on its own. + +## How to do it from the command line + +If you'd rather skip the mouse clicks, the command line works too. Open a terminal in the project root (the folder that holds `CMakeLists.txt`): + +::: details Click to open: how to do it from the command line +First, open a terminal. On Windows, hit Win+R and type `cmd`. Or, the smoother way: in vscode go to menu Terminal → New Terminal, which opens one right in the project directory. Make sure it's the "MSYS2 UCRT64" terminal (the one we set up in part 2), not a plain cmd. The plain cmd can't find `cmake` or `g++`. + +First command, configure (`-B build` means "put the build files in the `build` subdirectory", so the project root stays clean): + +```bash +cmake -B build +``` + +Second command, build: + +```bash +cmake --build build +``` + +After it finishes, the executable lives at `build/greeter.exe` (Windows) or `build/greeter` (Linux/macOS). Run it directly: + +```bash +./build/greeter +``` + +The terminal still prints `Hello, world!`. Clicking buttons and typing commands run the same CMake underneath, the result is identical. + +The first time you run `cmake -B build`, it asks which "generator" to use and detects your compiler, and prints a screenful of information. When you see `Generating done` at the end, configuration is done and you can move on to build. +::: + +The three-file project runs, and CMake has taken over the annoying chores of "which files to compile, who depends on whom, whether a change needs a rebuild". From here on, no matter how big the project gets, you just keep adding names to the `add_executable` line. + +But you may have already noticed an annoyance. In `main.cpp`, click on the `greet` function name wanting to jump to its definition and look at the implementation, and nothing happens. Sometimes `#include "greet.h"` in your code has a red squiggly line under it, even though it compiles fine and the line just won't go away. That's vscode still not knowing where `greet.h` lives or what the `greet` function looks like. We'll fix that in the next part and make the editor catch up. diff --git a/documents/en/getting-started/05-vscode-clangd.md b/documents/en/getting-started/05-vscode-clangd.md new file mode 100644 index 000000000..79c5c5e70 --- /dev/null +++ b/documents/en/getting-started/05-vscode-clangd.md @@ -0,0 +1,246 @@ +--- +title: "Making vscode Understand Your Code: Install clangd, Watch the Red Lines Vanish" +description: "You got the three-file build working in Part 4, but the editor is still painting red squiggles everywhere and won't jump to a function when you click it. Three steps to install clangd and make vscode smart." +chapter: 14 +order: 5 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - clangd +reading_time_minutes: 12 +--- + +# Making vscode Understand Your Code: Install clangd, Watch the Red Lines Vanish + +## Opening + +In Part 4 we got the three-file project building, and that moment when the terminal printed `Hello, world!` probably felt pretty good. But once you start writing a few more lines in vscode, you'll most likely run into some annoying stuff. + +The `#include ` line keeps drawing a red wavy underline, even though the build passes. You hold `Ctrl` and click on the `greet` function name, wanting to jump to its definition for a look, and the cursor just blinks and goes nowhere. You type `std::` and no completion list pops up, so you're stuck typing every letter by hand. + +You didn't write anything wrong. The compiler (g++) says it's all fine. The problem is that vscode hasn't "understood" your project yet. It doesn't know where the `greet` function lives, doesn't know what can come after `std::`, so it can't help you. In this part we fix it in three steps and make the editor smart along with you. + +## Why this happens + +Let's clear one thing up first: vscode itself doesn't actually understand C++. + +vscode is a general-purpose editor. It can write Python, write web pages, write JSON; anyone can plug things into it. Out of the box it carries no "understanding" of any single language. That has to come from extensions (you can think of them as plugins). Back in Part 2, when we set up the environment, you installed an extension called C/C++. That's the official one from Microsoft. Once it's in, vscode understands a little C++: it can highlight, complete, and debug. + +The catch is that the C/C++ extension only "understands" so much. It has its own logic for analyzing C++ code, and the accuracy is just okay. On any project that's even slightly complex it tends to get things wrong, painting red lines on code that compiles fine, or jumping to the wrong place. You've probably already felt the sting of being "scolded for nothing." + +The common practice in the C++ community nowadays is to swap in a stronger tool to handle the "make the editor understand the code" job. That tool is called clangd. + +clangd comes from the LLVM project (an open-source compiler toolchain, the same kind of thing as GCC) and does exactly one job: make editors understand C++ code. Its analysis engine is the same one the Clang compiler uses, so it's a notch more accurate than the C/C++ extension, and its jump-to-definition, completion, and error reporting are all more reliable. In this part we swap it in. + +## Three steps to fix it + +Fixing this takes three steps. We'll go one at a time. + +### Step 1: Make CMake generate a "translation cheat sheet" + +clangd needs a file called `compile_commands.json` to understand your project. The name is long, don't bother memorizing it. Think of it as a "translation cheat sheet": it records, for every `.cpp` file in the project, which compiler is used, which C++ standard, and which headers get pulled in. With this cheat sheet in hand, clangd knows how to interpret each line of your code. + +You don't write this file by hand. You just let CMake spit it out on the side. Open the `CMakeLists.txt` of the `greeter` project from Part 4, and add one line right below the `project` line: + +```cmake +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +After the addition, the full `CMakeLists.txt` looks like this: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) +``` + +::: tip Plain-English translation +`CMAKE_EXPORT_COMPILE_COMMANDS ON` means "when you configure, also drop a compile_commands.json into the build directory on the side." This switch is off by default, so you have to turn it on yourself. +::: + +Save `CMakeLists.txt`, then click "Configure" in vscode's bottom status bar (you need to reconfigure for it to regenerate). After configuring, the `build` folder of your project will contain a new `compile_commands.json` file. That's the cheat sheet clangd wants. + +::: warning You need the CMake Tools extension to click the status bar +If your status bar has no Configure button, you missed the CMake Tools extension back in Part 2. Go install it, then reopen vscode. Running `CMake: Configure` from the command palette (`Ctrl+Shift+P`) triggers the same action. +::: + +### Step 2: Install the clangd extension + +The cheat sheet is ready. Now bring in the real "reader." + +In vscode, click the extensions icon on the left activity bar (the one with four squares, shortcut `Ctrl+Shift+X`), and type `clangd` in the search box. You'll see an extension published by LLVM, named simply clangd. Click Install. + +::: warning Before installing the extension, make sure you have the clangd program on your machine +The extension is just a "remote control." The thing that actually does the work is a program on your computer called `clangd` (sometimes `clangd.exe`). Installing only the extension without the program is like having a remote with no TV. Nothing turns on. + +The fastest way to check whether the program is on your machine is to open a terminal and run `clangd --version`: + +- If it prints a version string (like `clangd version 18.x.x`), you have it. Skip ahead to Step 3. +- If you get "is not recognized as an internal or external command" or "command not found", it isn't installed. Use the collapsible box below to install it. + +Windows users take note: in Part 2 we installed the MSYS2 + g++ toolchain, and that doesn't include clangd. clangd ships with the LLVM bundle, so you have to install it separately. +::: + +::: details Click to see: how to install the clangd program on each platform +There are two routes on Windows. + +The first route continues from the MSYS2 setup in Part 2, and is the least hassle. Open the "MSYS2 UCRT64" terminal (the one from Part 2) and run: + +```bash +pacman -S mingw-w64-ucrt-x86_64-clang-tools-extra +``` + +After it finishes, clangd lives in `C:\msys64\ucrt64\bin`, the same directory as the g++ from Part 2. The PATH is already set up, so you can use it right away. + +The second route is to install the standalone LLVM bundle using winget (built into Windows 10 and later). Open PowerShell or cmd and run: + +```bash +winget install LLVM.LLVM +``` + +After it finishes, LLVM's tools land in `C:\Program Files\LLVM\bin`. This path isn't on the system PATH by default, so either add it to PATH (so the terminal can find clangd from anywhere) or, once the vscode clangd extension is installed, manually point the extension's setting at the clangd.exe path. The extension usually finds it on its own; set it manually only if it can't. + +Pick one of the two routes. If you use scoop or chocolatey, the commands are `scoop install llvm` and `choco install llvm` respectively. + +On Linux (Debian/Ubuntu family), just use apt: + +```bash +sudo apt install clangd +``` + +On Fedora it's `sudo dnf install clang-tools-extra`, and on Arch it's `sudo pacman -S clang`. + +On macOS, use Homebrew: + +```bash +brew install llvm +``` + +::: tip There's a gotcha on macOS +The `clang` that ships with macOS (from Xcode Command Line Tools) doesn't come with clangd. Just having the system clang isn't enough. You have to `brew install llvm` to get the full LLVM, then add `/opt/homebrew/opt/llvm/bin` (Apple Silicon) or `/usr/local/opt/llvm/bin` (Intel) to PATH, or the terminal still won't find clangd. +::: +::: + +### Step 3: Turn off the C/C++ extension's code understanding + +This is the easiest step to skip, and the most important. + +Right now there are two extensions in vscode both trying to analyze your C++ code: the C/C++ extension from Part 2, and the clangd you just installed. With both working at once they'll fight each other. The completion list might pop up twice, jump-to-definition might land in two different places, and red lines get drawn all over. We split the labor: clangd handles "understanding the code" (completion, jump-to-definition, error reporting), and the C/C++ extension stays on debugging duty (we'll need it in Part 6; clangd doesn't do debugging). + +What you need to do is turn off the C/C++ extension's code-understanding feature. Open the settings page: menu File → Preferences → Settings, or just `Ctrl+,`. In the search box type `C_Cpp: Intelli Sense Engine` (IntelliSense is the English name for "smart hints"), and change its value from the default `Default` to `disabled`. + +If clicking through the settings page feels like a hassle, you can also edit the config file directly. Create a `.vscode` folder in the project root, put a `settings.json` inside it, with this content: + +```json +{ + "C_Cpp.intelliSenseEngine": "disabled" +} +``` + +::: tip The two ways are equivalent +Editing the settings page changes vscode's global config (applies to all projects); writing `settings.json` changes this project's config (applies only to the current project). Either works for beginners. The advantage of `settings.json` is that it travels with the project. Open this project on another computer, and the setting is still there. +::: + +After the change, you should see a `clangd` label in vscode's bottom-right status bar (before it might have said `C/C++` or `C/C++ IntelliSense`). That tells you clangd is now in charge of code understanding. + +## See the magic + +With the three steps done, reopen `main.cpp` (or just click somewhere in the editor to make it refresh). You'll most likely see all of these happen at once: + +The red wavy underline on the `#include ` line is gone. + +You hold `Ctrl` and click the `greet` function name, and the cursor zips over to the line where the function is defined in `greet.cpp`. + +Inside `main` you type `std::`, and a completion list pops up showing things from the standard library like `cout`, `endl`, and `vector`. + +Before, vscode couldn't read it. Now it can. The whole difference is one `compile_commands.json` plus one clangd. + +## What just happened, really + +```mermaid +flowchart LR + A["CMakeLists.txt"] -->|configure| B["CMake"] + B --> C["build/compile_commands.json"] + C -->|clangd reads| D["understands code
complete/jump/diagnose"] +``` + + +Let's step back and walk through the whole story. + +The clangd program is, at its core, an assistant that "reads code on behalf of the editor." To do its job it needs to know two things: which C++ standard your code uses (C++17? C++20?), and which headers each `.cpp` pulls in. Without those, it's working in the dark. It can't even recognize what `std::string` is, so naturally it just paints the code full of red lines. + +These two pieces of information are exactly what the compiler already used once during the build. When CMake configured, it had already settled on C++17 and already knew that `main.cpp` includes `greet.h`. That `CMAKE_EXPORT_COMPILE_COMMANDS ON` line tells CMake to copy this compilation information down verbatim and write it out as a `compile_commands.json` file that clangd can read. + +The first thing clangd does after starting up is search upward from the `.cpp` file you opened, looking for `compile_commands.json`. If it finds one, it reads it in. With this cheat sheet, it knows exactly how to interpret each file, so completion, jump-to-definition, and error reporting are all accurate. Without that file, or if the file is out of date (you changed `CMakeLists.txt` but didn't reconfigure), clangd gets confused and the red lines come back. + +So from now on, when you hit "compiles fine but clangd paints red lines," your first reaction shouldn't be to doubt your code. Click Configure again and let CMake refresh the cheat sheet. + +## Collapsible: what compile_commands.json looks like + +You don't need to understand every field. Just skim it to get the idea. Open `build/compile_commands.json` and inside you'll find a JSON array, one entry per `.cpp` file: + +```json +[ + { + "directory": "D:/code/greeter/build", + "command": "C:\\msys64\\mingw64\\bin\\c++.exe ... -std=gnu++17 ... D:/code/greeter/main.cpp", + "file": "D:/code/greeter/main.cpp" + }, + { + "directory": "D:/code/greeter/build", + "command": "... D:/code/greeter/greet.cpp", + "file": "D:/code/greeter/greet.cpp" + } +] +``` + +What the three fields mean: + +`directory` is the directory the file was compiled in, usually your `build` folder. `command` is the full compile command, containing the compiler path, `-std=gnu++17` (the C++ standard used), and all the header search paths. clangd uses this to reconstruct the compiler's point of view. `file` is the source file this record corresponds to. + +Once clangd reads it in, it's effectively "standing where the compiler stands" and re-reading your code, so what it can determine matches the compiler: anything that compiles won't get a red line. + +## Collapsible: reconfiguring from the command line + +::: details Click to see: how to do it from the command line +If you prefer typing commands, the configuration is the same as before. Open a terminal in the project root: + +```bash +cmake -B build +``` + +CMake will re-read `CMakeLists.txt` (this time carrying that `EXPORT_COMPILE_COMMANDS` line) and refresh the contents of the `build` directory, including `compile_commands.json`. + +If you'd rather not change `CMakeLists.txt`, you can also pass a temporary flag on the configure command to achieve the same effect: + +```bash +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +The effect is the same as writing `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)` in `CMakeLists.txt`. The difference is that the latter travels with the project (it still works on another computer), while the former only applies to this one configure run. In this tutorial we recommend writing it into `CMakeLists.txt` so it's done once and for all. +::: + +## clangd or the C/C++ extension + +By now you might be wondering: so what's the C/C++ extension there for? Can I uninstall it? + +The division of labor in this tutorial goes like this: code understanding (highlighting, completion, jump-to-definition, error reporting) belongs to clangd, because it's more accurate; debugging (breakpoints, stepping, inspecting variables) belongs to the C/C++ extension, because that part is more mature, and Part 6 is dedicated to it. The two extensions split the work and each mind their own area, so they don't fight. That's why Step 3 only turned off the C/C++ extension's IntelliSense and didn't ask you to uninstall it. + +::: tip Aligning with the older articles +In the old vol1 articles of this tutorial, we once recommended using the C/C++ extension for code understanding. clangd has matured over the past few years, and once its accuracy surpassed the C/C++ extension, the community broadly switched to clangd. Treat this article as the current one. That section in the old article is outdated. +::: + +Up to this part, your vscode has had two skills: it can build (the CMake Tools from Part 3 and Part 4, in charge of turning `.cpp` into `.exe`), and it can understand code (the clangd from this part, in charge of completion, jump-to-definition, and error reporting). The two foundations for writing C++ smoothly are both laid. + +From here you can head in several directions: if you want to know how to debug your code step by step when something goes wrong, the next part covers debugging; if you want to write a few more lines and see what C++ can actually do, you can start poking into the main volumes. The foundation is solid. Now you build on top. diff --git a/documents/en/getting-started/06-where-next.md b/documents/en/getting-started/06-where-next.md new file mode 100644 index 000000000..55e4fb0c4 --- /dev/null +++ b/documents/en/getting-started/06-where-next.md @@ -0,0 +1,50 @@ +--- +title: "It Works — So Where Next" +description: "The getting-started volume is done. Pick your next step by goal: learn the syntax, dig into CMake, dig into compiling and linking, or go embedded — each path points to a specific volume" +chapter: 14 +order: 6 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 3 +--- + +# It Works — So Where Next + +By this point you've worked through everything the getting-started volume set out to do: part 2 got vscode, a compiler, and CMake installed; part 3 ran your first hello inside vscode; part 4 grew the project into multiple files and put CMake to real use for the first time; part 5 made vscode actually understand your code (click a function and jump to it, make the red squiggles go away). A C++ environment now sits in front of you — it compiles, it autocompletes, it jumps to definitions. That's everything this volume owed you, done. + +Where to go next depends on what you're after. Four roads are laid out below. Pick the one that matches what you have in mind and walk down it. + +## If you want to nail down C++ first + +The getting-started volume sorted out "the environment runs." It never touched a single line of real C++ syntax — what a variable is, how to write a loop, how to define a function, what a class is, we haven't said a word about any of that. That's the real capital you spend to write C++, and it's the foundation every later volume stands on. + +Your next stop is [Volume 1 · Fundamentals](/vol1-fundamentals/), going from C++'s most basic syntax all the way up to object orientation and templates. This volume is the main line. Whatever direction you end up going, you can't get around it. Grind through Volume 1 first, then talk about the rest. + +## If you want to understand CMake and build systems + +In the getting-started volume you only learned "copy a CMakeLists, click the button, it runs." What CMake is actually doing behind the scenes, why there are two steps called "configure" and "generate," why the word `target` shows up everywhere, what `add_executable` and `target_link_libraries` are each responsible for — none of that got unpacked. + +For the answers, head to [Volume 7 · Engineering Practice](/vol7-engineering/). That's where the CMake material lives, going from a single target up to multi-module organization and how to pull in external dependencies. One word of warning, though: Volume 7 assumes you already know basic C++ syntax, so even if engineering is what you really want, run through Volume 1 first — otherwise you'll get stuck partway in. + +## If you want to understand compiling and linking + +You may have already run into a few odd things in part 4: you only changed one file, so why does CMake rebuild just that one and leave the others alone; every so often an `undefined reference` pops up and the error looks terrifying; people also chat about static libraries and dynamic libraries as if they were two completely different things. Underneath all of it runs the same machinery — compiling and linking. + +To get that machinery straight, go to [Compilation and Linking, In Depth](/compilation/). It walks from "what the compiler turns a `.cpp` into" to "how the linker stitches a pile of fragments into an `.exe`," and makes the difference between static and dynamic libraries, and exactly which step an `undefined reference` gets stuck on, all clear. It goes fairly deep, so newcomers are advised to grind through Volume 1 first — otherwise it'll scare you off. + +## If you want to do embedded, program microcontrollers + +A lot of folks come here for embedded — they want their code running on a chip the size of a fingernail like an STM32, lighting LEDs, reading sensors, driving motors. Honest talk: most embedded work out there is done in C, not C++. But modern C++ has a place in embedded too, with its own payoffs (type safety, zero-overhead abstraction, RAII for resource management), and this tutorial's embedded track takes the C++ route, in [Volume 8 · Domains](/vol8-domains/). + +But the embedded track has a real threshold: you need C++ syntax first (Volume 1), plus some grasp of building and toolchains (the cross-compiling part of Volume 7), and the resources on a chip are tight and finicky. So the prerequisite is to lay the groundwork from Volume 1 through Volume 7 first. Don't dive straight into the chip, or you'll get stuck hanging in midair. + +## The getting-started volume drops you off here + +The getting-started volume walks you up to the great door of C++, presses the key into your hand, and points at the door. The real C++ journey starts in [Volume 1](/vol1-fundamentals/). diff --git a/documents/en/getting-started/index.md b/documents/en/getting-started/index.md new file mode 100644 index 000000000..7acb72b66 --- /dev/null +++ b/documents/en/getting-started/index.md @@ -0,0 +1,29 @@ +--- +title: "Getting Started" +description: "A track for absolute beginners: from knowing what an editor is, to installing the tools and running your first C++ program, to making vscode understand your code." +platform: host +tags: + - cpp-modern + - host + - beginner + - 入门 +--- + +# Getting Started + +This track is for complete beginners who have never written a line of code. We start from "what is an editor" and work up to getting your first C++ program actually running and making vscode understand your code. Every step is a click, every step has a screenshot placeholder, and anything command-line lives behind a fold. + +By the end, you'll have a working C++ setup on Windows (vscode, a compiler, CMake), a multi-file project building, and an editor that does completion, jump-to-definition, and diagnostics right. After that, head to Volume 1 for the language itself, Volume 7 to go deeper on builds, or Volume 8 for embedded work — this track just gets you to the door. + +> For the detailed multi-route setup (MSVC vs MinGW, vcpkg, Linux), see the environment chapter of [Volume 1 · Fundamentals](/en/vol1-fundamentals/). This track walks a single painless route and skips the comparisons. + +## Chapters + + + What Is an Editor, What Is a Compiler + Installing the Three Things You Need to Write C++ + Your First C++ Program — Getting Hello to Run in vscode + When the Project Grows — Multiple Files, and CMake Shows Up + Making vscode Understand Your Code — Install clangd, Watch the Red Lines Vanish + It Runs — Where Next + diff --git a/documents/en/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md b/documents/en/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md new file mode 100644 index 000000000..c4969e31c --- /dev/null +++ b/documents/en/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md @@ -0,0 +1,247 @@ +--- +title: "What Is CMake — The Two-Stage Pipeline of a Build System Generator" +description: "Get CMake's role as a build system generator straight: what the configure and build stages each do, and how to pick between the Make, Ninja, and Visual Studio generators" +chapter: 7 +order: 1 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 18 +prerequisites: + - "vol1 ch00: 第一个程序" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# What Is CMake — The Two-Stage Pipeline of a Build System Generator + +Back in the first-program article of volume one, we dropped CMake's name and had you copy out five lines of `CMakeLists.txt` to get the project running. At the time I left a note saying "we'll use `g++` for now and bring in CMake properly in a later chapter" (03-first-program.md, line 119). That promise has been outstanding for several volumes. This article is here to pay it back. + +But today isn't about teaching you to type commands. Anyone can type `cmake -B build`. What we want to nail down is what CMake is actually doing behind the scenes: why there are two steps, "configure" and "build"; how it relates to `g++`; why the same project can produce both a Makefile and `build.ninja`. Get this straight in your head, and later when you study targets, `find_package`, and cross-compilation, it won't feel like you're memorizing incantations. + +## CMake Isn't a Compiler — It's a Build System Generator + +Let's tear down the most fundamental misunderstanding first. + +A lot of people's first reaction to CMake is "it's a compiler" or "it replaces `g++`." Neither. CMake doesn't compile a single line of code itself. What it actually does: read your `CMakeLists.txt`, and based on the current platform and toolchain, generate files for other build systems (Makefile, `build.ninja`, Visual Studio's `.sln`), and then let Make, Ninja, or MSBuild — the "real build tools" — invoke the compiler. + +In one line: **CMake generates files that compile code.** It's a layer on top of build systems. The industry calls it a "build system generator," or put another way, a "meta build system." + +::: details Where does "meta build system" come from? +An ordinary build system (Make/Ninja) directly describes "which source files to compile, how to link." A meta build system sits one level up: it doesn't describe the build process directly, it describes "what the structure of this project is," and then translates that into files the corresponding build system can read, based on your currently selected toolchain. CMake, Meson, and Bazel all live in this layer. +::: + +Why does C++ have this extra layer that Rust and Go don't? It goes back to ISO. The C++ standards committee only governs the language itself (syntax, the standard library) and has never dictated how toolchains are organized or what build files should look like. The result: MSVC on Windows, GCC on Linux, `arm-none-eabi-g++` on embedded, each with its own compiler options and project format. Rust and Go ship as "language + official toolchain (`cargo`/`go`)" and never had this problem. + +CMake exists to paper over this legacy: you write one `CMakeLists.txt`, and it spits out a Visual Studio project on Windows, a Makefile on Linux, or Ninja files on a machine that wants speed. Describe the sources once, get the build files appropriate to the platform. + +## The Two-Stage Pipeline: configure and build + +Once you understand what CMake is, that confusing "why do I type the command twice" question answers itself. CMake's workflow splits naturally into two stages. + +The first stage is called **configure**. In this stage CMake reads your `CMakeLists.txt`, finds the compiler, checks whether it runs, what version it is, records the results in `CMakeCache.txt`, and finally writes out the build files. Note: this stage **does not compile a single line of your code**. It's just "putting up the scaffolding." + +The second stage is called **build**. This is where the compiler actually gets invoked, compiling each source file into a `.o` and then linking them into an executable or library. + +Let's look at real configure output. Below is what I get running `cmake -B build -G Ninja` on a minimal project on my machine (GCC 16.1.1, CMake 4.4.0): + +```text +$ cmake -B build -G Ninja +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-demo/build +``` + +Let's go line by line. The first six lines are all CMake "taking stock": identifying the compiler version (`GNU 16.1.1`), probing ABI info, confirming the compiler actually works, and gathering which compile features it supports. This information gets used later. For example, if you wrote `set(CMAKE_CXX_STANDARD 17)` in your `CMakeLists.txt`, CMake needs to know whether the current compiler actually supports C++17, and if not, error out at you immediately. Then `Configuring done` means stock-taking is finished, `Generating done` means the build files have been written to disk, and the last line tells you where they landed. + +Notice there's not a single `Building CXX` line in this whole process. That's configure: it sets the stage, it doesn't perform. + +Now look at build. `cmake --build build` is the unified entry point, you type it the same way whether the underlying tool is Make or Ninja: + +```text +$ cmake --build build +[1/2] Building CXX object CMakeFiles/hello.dir/main.cpp.o +[2/2] Linking CXX executable hello +``` + +`[1/2]`, `[2/2]` are Ninja's progress markers, meaning "step one of two, step two of two." The first step compiles `main.cpp` into an object file, the second links it into `hello`. This is where `g++` is actually running. + +Why split it into two steps at all? The key is that **the performance characteristics are different**. configure is slow — it has to restart the process, re-take stock, regenerate all the build files. But configure only needs to re-run when you've changed `CMakeLists.txt`, added new files, or switched Generator. build is fast — it does incremental compilation, only recompiling files that changed. So in your daily dev loop, configure runs occasionally, build runs countless times. + +Let's measure it on the same minimal project to see the cache's effect on configure speed: + +```text +$ rm -rf build && time cmake -B build -G Ninja > /dev/null +cmake -B build -G Ninja > /dev/null 0.09s user 0.08s system 93% cpu 0.183 total + +$ time cmake -B build -G Ninja +-- Configuring done (0.0s) +-- Generating done (0.0s) +cmake -B build -G Ninja 0.01s user 0.00s system 90% cpu 0.017 total +``` + +A cold configure takes 0.183 seconds, a cached second configure only 0.017 seconds — ten times faster. This minimal project is too small to really feel it, but in a real project it's normal for the first configure to take a dozen seconds and the second to take a fraction of a second. That's why it's worth pulling configure out as its own stage with a cache. Otherwise every time you compile one line of code you'd have to re-take stock of everything, and nobody could stand that. + +## Generator: Make or Ninja + +CMake abstracts "which kind of build files to generate" into a concept called a **Generator**. You pick one with the `-G` flag at configure time, and CMake generates the corresponding set of files. + +The three most common generators: + +Unix Makefiles is the default option. On Linux/macOS, if you don't specify `-G`, this is what you get. It produces a `Makefile` driven by the `make` command. The oldest, most universal option — every Unix system ships `make`. Its downside is speed: `make` is a 1970s design, and its dependency checking and parallel scheduling are not modern. + +Ninja is the modern recommendation. It produces `build.ninja`, driven by the `ninja` command. Ninja was designed specifically "to be generated by a meta build system": low startup overhead, aggressive parallel scheduling, fast incremental builds. The cost is that you have to install `ninja` separately (the package is usually just called `ninja` or `ninja-build`). + +Visual Studio is what you use for IDE integration on Windows (`-G "Visual Studio 17 2022"`). It produces `.sln` and `.vcxproj` files you can open directly in Visual Studio and F5-debug. If you don't care about the IDE experience, Ninja works fine on Windows too. + +The only difference in the command is the `-G` argument. Let's run the same project with both generators and see what each produces: + +```text +cmake -B build -G Ninja # pick Ninja +cmake -B build-make -G "Unix Makefiles" # pick Make +``` + +The two configure commands print nearly identical output (both go through that "detect compiler, Configuring done" sequence); the difference is in the generated build files. Let's directly compare what lands in each `build/` directory: + +```text +$ ls build/ # Ninja output +build.ninja +cmake_install.cmake +CMakeCache.txt +CMakeFiles +hello + +$ ls build-make/ # Make output +cmake_install.cmake +CMakeCache.txt +CMakeFiles +hello +Makefile +``` + +The Ninja side has an extra `build.ninja`, the Make side has an extra `Makefile`. `CMakeCache.txt`, `CMakeFiles/`, and `cmake_install.cmake` are present in both; they're CMake's own infrastructure. + +My advice: default to Ninja for local development. It's not just a little faster, and `build.ninja` is far cleaner than a `Makefile` (cat both files and you'll see). Unless your environment can't install `ninja`, there's no reason to fall back to Make. When we get to cross-compilation and CI later, Ninja is also the more comfortable choice. + +## Out-of-source builds: don't pollute the source directory + +CMake recommends a build style called **out-of-source build** (keeping the source tree and the build tree separate). The idea: all build artifacts — object files, executables, `CMakeCache.txt`, the generated build files — get dumped into a `build/` subdirectory, and the source directory stays clean. + +The `-B` in `cmake -B build` is exactly for this: it tells CMake "put the build tree under `build/`". The directory layout looks like this: + +```text +cmake-demo/ +├── CMakeLists.txt # you write this, goes in git +├── main.cpp # you write this, goes in git +└── build/ # CMake generates this, goes in .gitignore + ├── CMakeCache.txt + ├── CMakeFiles/ + ├── build.ninja + ├── cmake_install.cmake + └── hello # the final executable +``` + +The benefit is direct: in the source directory you won't see a single `.o` file, no `a.out`, no temporary artifacts. Want to wipe and start over? One `rm -rf build/` and the source is untouched. Want to package a release? The source directory is nothing but clean source files, no need to painstakingly pick out what should and shouldn't be archived. + +::: details In-source builds work too, but don't +CMake also lets you run `cmake .` directly in the source directory (called an in-source build), which generates a Makefile and a pile of `CMakeFiles/` right there in the current directory. It looks convenient, but once the source directory is polluted, `git status` turns into a wall of red and cleaning it up means hunting down files one by one. Newer CMake versions even restrict this: by default it refuses to configure twice in the same directory, to keep you from messing up the source tree. Get into the `-B build` habit and save yourself the pain later. +::: + +Here we need to single out **`CMakeCache.txt`**. It's the key to the speedup in that "cold start vs cached" comparison earlier. On the first configure, CMake stores all its findings in there: the compiler path, the compiler version, the Generator choice, the variables you set via `-D`, the results of various feature probes. On the next configure, CMake reads the cache first and reuses anything that hasn't changed, skipping the re-probing. + +Open it up and have a look — it's a key-value format, with the important fields looking like this: + +```text +//Path to CXX compiler. +CMAKE_CXX_COMPILER:FILEPATH=/usr/sbin/c++ + +//Name of CMake project. +CMAKE_PROJECT_NAME:STATIC=hello_cmake + +//Name of generator. +CMAKE_GENERATOR:INTERNAL=Ninja +``` + +Notice the `CMAKE_GENERATOR` line — it remembers the generator you picked. So a second configure doesn't need `-G Ninja` again; CMake knows to keep using Ninja. This is also why sometimes when you want to switch generators, just typing `-G` doesn't work and CMake keeps using the old one: `CMakeCache.txt` has it locked in, and you need `rm -rf build/` to start fresh. + +Does `CMakeCache.txt` go in git? Absolutely not. It's tightly bound to the machine environment (compiler paths, absolute paths are all in there), and committing it guarantees a conflict on every machine. Put the entire `build/` directory in `.gitignore` and be done with it. + +## The minimal project's three pieces + +With the principles out of the way, let's land on a minimal project that actually runs. A legal `CMakeLists.txt` needs at least three lines: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(hello_cmake LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) +``` + +The first line, `cmake_minimum_required(VERSION 3.20)`, declares the minimum CMake version this project requires. Any CMake older than this bails out with an error on seeing this line. Its real job isn't just version checking — it also flips CMake into the "policy compatibility mode" for that version, ensuring that behavior changes in newer CMake don't silently affect old projects. `3.20` is a safe lower bound: released in 2021, installable on mainstream distros. + +The second line, `project(hello_cmake LANGUAGES CXX)`, names the project `hello_cmake` and declares it uses C++ (`CXX`). This `project()` line is what triggers the "detect compiler, probe ABI" stock-taking you saw in the configure output earlier — `LANGUAGES CXX` tells CMake "I need a C++ compiler," and only then does CMake go off hunting for `g++`/`clang++`/`MSVC`. + +The third line, `add_executable(hello main.cpp)`, is what actually tells CMake "build an executable called `hello`, with source `main.cpp`". After this line gets baked into `build.ninja`, the build stage turns into `g++ main.cpp -o hello`. + +The two lines in the middle, `set(CMAKE_CXX_STANDARD 17)` and `set(CMAKE_CXX_STANDARD_REQUIRED ON)`, set the default C++ standard. The first asks to compile as C++17, the second asks to "error out if the compiler doesn't support it, rather than silently downgrading." We'll come back to these when we cover targets — the more modern way is `target_compile_features()`, but for now this is enough. + +The accompanying `main.cpp`: + +```cpp +#include + +int main() +{ + std::cout << "Hello, CMake!\n"; + return 0; +} +``` + +Three commands run the whole pipeline: + +```text +$ cmake -B build -G Ninja && cmake --build build && ./build/hello +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-demo/build +[1/2] Building CXX object CMakeFiles/hello.dir/main.cpp.o +[2/2] Linking CXX executable hello +Hello, CMake! +``` + +That last line, `Hello, CMake!`, is the output of running `./build/hello`. The first command puts up the scaffolding, the second actually compiles and links, the third executes. No matter how big the project gets later, the skeleton stays the same. + +## Accompanying example + +The minimal project from this article can be run directly from the repo's examples directory: + +```text +code/examples/vol7/cmake-fundamentals/01-what-is-cmake/ +├── CMakeLists.txt +└── main.cpp +``` + +cd into that directory and copy the three commands above to reproduce all the output. + +That covers CMake's role, the two-stage pipeline, choosing a generator, and out-of-source builds, and we've gotten the minimal project running. The next article tackles a more practical problem: when the project has more than one `main.cpp`, needs to be split into multiple modules, and needs to reuse third-party libraries, how do you manage "which headers this target uses, which library it links, what compile options it turns on"? That brings up CMake's core mental model — the **target** — and why you shouldn't keep using the global `include_directories()` "imperative" style, and should move to the `target_include_directories()` "object-oriented" style instead. diff --git a/documents/en/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md b/documents/en/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md new file mode 100644 index 000000000..918b53205 --- /dev/null +++ b/documents/en/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md @@ -0,0 +1,338 @@ +--- +title: "The Target Mental Model — Treat a Target as an Object, PUBLIC/PRIVATE/INTERFACE Are Usage Requirements" +description: "Explain what a target really is, why the target_* commands are member methods, how PUBLIC/PRIVATE/INTERFACE propagate, and why directory-level commands are an anti-pattern" +chapter: 7 +order: 2 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 20 +prerequisites: + - "vol7 ch00 01: CMake 是什么——构建系统生成器的两段式流水线" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# The Target Mental Model — Treat a Target as an Object, PUBLIC/PRIVATE/INTERFACE Are Usage Requirements + +In the previous article we got a minimal project running, with just one line of real work in `CMakeLists.txt`: `add_executable(hello main.cpp)`. I never gave a name to the thing that line produces. This article hands you that name: **target**. + +The word "target" shows up everywhere in the CMake docs, gets crowned the number-one concept in every "modern CMake" tutorial, and the community even has a catchphrase for it: think in targets, not variables. Why does modern CMake lift it so high, and why is the `include_directories()` you copied from an old tutorial already an anti-pattern? This article explains it all the way through. This is the watershed between modern CMake and old-style CMake. Once you cross it, reading any `CMakeLists.txt` afterwards stops feeling like reciting incantations. + +## Treat a Target as an Object + +"Target" is not an abstract metaphor. It is, literally, a data structure CMake keeps internally. The fastest way to understand it is to think of it as a C++ object. + +`add_executable(app main.cpp)` and `add_library(mylib STATIC src/mylib.cpp)` are **constructors**. They create a target object, name it `app` or `mylib`, and record which source files it is built from and whether it should compile into an executable or a library. From that line onward, the names `app` and `mylib` are "alive" in CMake's world, and every later configuration works by treating that name as a handle. + +Once created, you give it include search paths, tell it which libraries to link, and switch on compile options. These operations correspond to a family of commands that all start with `target_*`: + +```cmake +target_include_directories(mylib PUBLIC include) +target_link_libraries(mylib PRIVATE fmt) +target_compile_options(mylib PRIVATE -Wall -Wextra) +target_compile_features(mylib PUBLIC cxx_std_17) +``` + +These `target_*` commands are **member methods**. They all do the same thing under the hood: take the target's name and attach a property to that target object. `target_include_directories(mylib PUBLIC include)` translates to "for the object `mylib`, push `include` into its include-path property." + +The things hanging off the target (include paths, the list of linked libraries, compile options, the C++ standard requirement) are its **member variables**. Each target manages its own, without bothering the others. + +::: details What a target actually is inside CMake +Strictly speaking, a target is a named collection of properties maintained by CMake. You can read the properties attached to it during the configure stage with `get_target_property(v mylib INCLUDE_DIRECTORIES)`. The hands-on section later in this article will use exactly this command to crack the target open and show us. A target is not a black box. +::: + +Why does this "object thinking" matter? Because it nails down the scope of any configuration. `target_include_directories(mylib PUBLIC include)` touches only the properties of `mylib`, leaving every other target in the project untouched. That is exactly the core distinction coming next: old-style CMake is "global pollution," modern CMake is "target-private." + +## Usage Requirements: The PUBLIC/PRIVATE/INTERFACE Three States + +Having the target object alone is not enough. What actually lets modern CMake leap forward is how it models **usage requirements**. The phrase sounds mystical, but it boils down to one sentence: the configuration a target needs when it is compiling itself may differ from what it needs when someone else links against it. CMake uses three keywords to separate the two cases. + +PRIVATE means "I need it for my own compile, but whoever links me does not." For example, `mylib` calls the third-party library `fmt` internally for string formatting, but `fmt` leaves no trace in `mylib`'s public header. Downstream users linking `mylib` have no idea `fmt` exists, and naturally do not need `fmt`'s include path. In that case `fmt` is PRIVATE to `mylib`. + +INTERFACE means "I do not need it myself, but whoever links me does." A typical case is a header-only library. It has no `.cpp` of its own to compile, so the "self-use" half is empty; but the moment downstream includes its headers, it needs the corresponding include path and C++ standard requirement. Here every configuration goes into INTERFACE. + +PUBLIC means "both sides: I use it, and so does whoever links me." The most common case is a type that appears directly in the public header. If the return type of `mylib.h` is `std::string`, then once downstream links `mylib`, the compiler has to find the include path where `` lives in order to parse that return type. `mylib` itself needs that path when compiling its `.cpp`, and downstream needs it when linking `mylib`. That is PUBLIC. + +Splitting these three states along "self-use / others-use" sits on top of a simple truth table: + +| Keyword | Used when compiling self | Also used when others link | +|--------|:---:|:---:| +| PRIVATE | Yes | No | +| INTERFACE | No | Yes | +| PUBLIC | Yes | Yes | + +Memorize this table. It fits every `target_*` command you will ever read. + +### A Concrete Example: fmt Is PRIVATE, Is INTERFACE + +Definitions alone are not enough; let us drop down to code. The project below has three targets: a minimal `fmt` (standing in for a third-party formatting library), a `mylib` static library that exposes an outward-facing API, and a downstream `app` executable. `mylib` uses `fmt::format` internally, but its public header uses only `std::string`. + +The public header of `mylib`, `include/mylib/mylib.h`: + +```cpp +#pragma once +#include + +namespace mylib { + +/// @brief 把问候语格式化成带前缀的字符串 +/// @note 返回类型用 std::string —— 这是 mylib 公开 API 的一部分, +/// 下游 app 也必须看到完整的 std::string 定义, +/// 所以 对应的 include 路径属于 INTERFACE 需求 +std::string make_greeting(const std::string& name); + +} // namespace mylib +``` + +The implementation of `mylib`, `src/mylib.cpp`: + +```cpp +#include "mylib/mylib.h" + +#include "fmt.h" + +namespace mylib { + +std::string make_greeting(const std::string& name) { + // fmt 是 mylib 内部实现细节,公开头文件 mylib.h 里看不到 fmt 的痕迹 + // 所以下游根本不需要知道 fmt 的存在 —— 这正是 fmt 应当为 PRIVATE 的理由 + return fmt::format("hello, {}!", name); +} + +} // namespace mylib +``` + +The three key lines in `CMakeLists.txt` that attach properties to `mylib`: + +```cmake +add_library(mylib STATIC src/mylib.cpp) +target_include_directories(mylib PUBLIC include) +target_link_libraries(mylib PRIVATE fmt) +``` + +`include` is written as PUBLIC: `mylib` needs to find `mylib/mylib.h` when compiling its own `.cpp` (self-use), and downstream has to find `mylib/mylib.h` to include it after linking `mylib` (others-use). Both halves hold, so it is PUBLIC. + +`fmt` is written as PRIVATE: `mylib.cpp` calls `fmt::format` internally (self-use), but `mylib.h` carries no `fmt` symbol and downstream never needs to see `fmt.h` (not others-use), so it is PRIVATE. + +### Flip PRIVATE to PUBLIC, Watch Downstream Get "Infected" + +Explaining concepts in the abstract never sticks. Let us get our hands dirty and change `fmt` from PRIVATE to PUBLIC, and see what happens to `app`. + +First configure the project (using the Make generator, because its `flags.make` file lists the include paths each target actually receives in plain, readable form; Ninja splits the flags into other files to support C++ modules, which is awkward to read by eye): + +```text +$ cmake -S . -B build -G "Unix Makefiles" +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +``` + +Right now `mylib` declares `fmt` as PRIVATE. Look at the include flags CMake generated for each of the three targets: + +```text +$ cat build/CMakeFiles/mylib.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include -I/tmp/cmake-target-demo/fmt + +$ cat build/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include +``` + +Read this line by line. `mylib` gets two paths: its own `include` (PUBLIC) plus `fmt` (PRIVATE, also needed when compiling itself). `app` gets only one path, `include`, because it links only `mylib` and therefore inherits `mylib`'s PUBLIC part (which is `include`); `fmt` is `mylib`'s PRIVATE and does not cross over. `app` knows nothing about `fmt`. That is exactly the encapsulation we want. + +What if `app`'s `main.cpp` sneaks in an `#include "fmt.h"` now? The compiler cannot find that header and dies immediately. I tried it: + +```text +$ cmake --build build --target app +[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o +FAILED: CMakeFiles/app.dir/main.cpp.o +/tmp/cmake-target-demo/main.cpp:2:10: fatal error: fmt.h: No such file or directory + 2 | #include "fmt.h" + | ^~~~~~~ +compilation terminated. +``` + +That is the physical meaning of PRIVATE: the encapsulation is real, not lip service. + +Now change one line, from `target_link_libraries(mylib PRIVATE fmt)` to `target_link_libraries(mylib PUBLIC fmt)`, reconfigure, and look at `app`'s include flags again: + +```text +$ sed -i 's/target_link_libraries(mylib PRIVATE fmt)/target_link_libraries(mylib PUBLIC fmt)/' CMakeLists.txt +$ cmake -S . -B build -G "Unix Makefiles" > /dev/null +$ cat build/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include -I/tmp/cmake-target-demo/fmt +``` + +`app` changed nothing at all, yet because upstream `mylib` flipped `fmt` from PRIVATE to PUBLIC, `app` magically gained a `-I.../fmt`. Now `app` does not have to `find_package(fmt)` itself, does not have to write `target_link_libraries(app PRIVATE fmt)` itself, and can simply `#include "fmt.h"` and compile. + +This is the **propagation** of usage requirements: PUBLIC lets configuration seep downstream along the link graph, while PRIVATE locks configuration inside the target. This "automatic propagation" is the root reason modern CMake can write complex dependency relationships so cleanly. As long as you correctly mark each dependency public or private, downstream picks up exactly the configuration it should, automatically, with a single link. + +::: warning Do not use PUBLIC as a universal patch +Reading this far you might be tempted: if PUBLIC hands downstream the configuration automatically, why not mark every dependency PUBLIC and be done with it? Please do not. PUBLIC means leaking your internal implementation details downstream. The moment downstream starts depending on the `fmt` path you exposed, the day you swap `fmt` for `std::format`, or upgrade and change the path, downstream breaks with it. Encapsulation is breathing room for the future; the more PUBLIC you sprinkle, the less room you leave yourself to refactor. The rule: if PRIVATE works, do not reach for PUBLIC. +::: + +### What Is That LINK_ONLY in INTERFACE_LINK_LIBRARIES? + +There is a detail worth expanding on here. I dug into `mylib`'s internal properties with `get_target_property` (with `fmt` configured as PRIVATE): + +```text +mylib.INCLUDE_DIRECTORIES = /tmp/cmake-target-demo/include +mylib.INTERFACE_INCLUDE_DIRECTORIES = /tmp/cmake-target-demo/include +mylib.LINK_LIBRARIES = fmt +mylib.INTERFACE_LINK_LIBRARIES = $ +``` + +Notice the last line. PRIVATE is supposed to mean "downstream has no idea fmt exists," so why does `fmt` show up in `INTERFACE_LINK_LIBRARIES`? + +There is a subtle but sensible distinction here: PRIVATE encapsulates the **include path** (downstream does not need `fmt.h` at compile time), but the **link relationship** cannot be hidden. `mylib` is a static library, and its `.o` files reference `fmt::format` symbols. When the linker finally turns `app` into an executable, it has to be able to find `libfmt.a` to fill those symbols in, or it throws `undefined reference`. So CMake uses the generator expression `$` to say "fmt participates in linking for downstream, but not in compilation." That explains why you do not see `-I.../fmt` in `app`'s `flags.make` (the include path did not cross over), yet `app` still links into a working executable (the link relationship did cross over). PUBLIC/PRIVATE controls the propagation of configuration, not the link graph itself. + +## Why Directory-Level Commands Are an Anti-Pattern + +Once target privacy is clear, going back to old-style CMake makes it obvious why the modern CMake crowd uniformly boycotts these commands. + +Old-style CMake uses directory-level, global commands: + +```cmake +# 老式 CMake 写法,现代项目里见一次就该重构 +include_directories(include) +include_directories(fmt) +add_definitions(-DUSE_FMT) +add_compile_options(-Wall) +``` + +The semantics of `include_directories(include)` are "every target in the current `CMakeLists.txt` directory and its subdirectories gets `-Iinclude`, no exceptions." `add_definitions(-DUSE_FMT)` works the same way: every target gets the `-DUSE_FMT` macro defined. + +In a small project you cannot see the flaw. Scale up and it falls apart. Picture a project with `mylib`, `tests`, `benchmarks`, and `tools` (four or five targets). You write `add_compile_options(-Wall -Wextra -Werror)` in the top-level `CMakeLists.txt` intending to turn on strict warnings for the main library, and the third-party Catch2 code under `tests/` inherits `-Werror` too, flooding the build with red. Now you have to dig into `tests/CMakeLists.txt` and remember a pile of workaround incantations to turn `-Werror` back off there. + +Or imagine `mylib` uses `fmt` internally, and to save effort you write `include_directories(fmt)` at the top level. Now `tools`, a target that should have no idea `fmt` exists, also picks up `-Ifmt`. The day its source code accidentally `#include "fmt.h"` it still compiles, and the encapsulation is silently broken. When a maintainer later tries to swap out `fmt`, they have no way to tell which targets used `fmt` on purpose and which got it stained on by a global command. + +Modern CMake solves both problems with target-level commands. `target_include_directories(mylib PRIVATE fmt)` bolts `fmt`'s path tightly inside the `mylib` target. It neither leaks to `tools` nor to downstream `app` (because PRIVATE). Each target carries its own configuration boundary; whoever owns the dependency declares it, and the dependency graph stays legible and traceable. + +Side-by-side comparison: + +```cmake +# 老式(目录级,全局污染) +include_directories(include) +add_definitions(-DMYLIB_EXPORTS) + +# 现代(target 级,边界清晰) +target_include_directories(mylib PUBLIC include) +target_compile_definitions(mylib PRIVATE MYLIB_EXPORTS) +``` + +The migration rule is straightforward: swap every `include_directories()` for `target_include_directories()`, every `add_definitions()` for `target_compile_definitions()`, every `add_compile_options()` for `target_compile_options()`, and prefix each command with a specific target name. It is the cheapest single step for dragging an old project into modern CMake. + +::: details Can I still set the C++ standard with a variable at the top level? +You will see many `CMakeLists.txt` files write `set(CMAKE_CXX_STANDARD 17)` at the top. That is also a directory-level (global) setting; it assigns the `CXX_STANDARD` property to every target under the current directory. This usage is still acceptable today, because for most projects the C++ standard is genuinely a project-wide global property. But the more modern, more precise form is `target_compile_features(mylib PUBLIC cxx_std_17)`, which turns the C++ standard into a target usage requirement too: linking `mylib` downstream automatically inherits the C++17 requirement. The next article, on `find_package`, will come back to compare the two forms. +::: + +## Hands-On: Tear Down a Two-Target Project + +Let us assemble everything from above. We will use a complete, runnable project to demonstrate the two-target setup of a `mylib` static library plus an `app` executable, and see how PUBLIC/PRIVATE actually flows in a real build. The full project lives at `code/examples/vol7/cmake-fundamentals/02-target/`, with this layout: + +```text +02-target/ +├── CMakeLists.txt +├── fmt/ +│ ├── fmt.h # 模拟第三方库的极简实现 +│ └── fmt.cpp +├── include/ +│ └── mylib/ +│ └── mylib.h # mylib 公开头文件 +├── src/ +│ └── mylib.cpp # mylib 实现 +└── main.cpp # app 可执行 +``` + +The complete `CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(target_demo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# fmt:仅在本工程内部使用的极简"第三方库",真实工程会换成 find_package(fmt REQUIRED) +add_library(fmt STATIC fmt/fmt.cpp) +target_include_directories(fmt PUBLIC fmt) + +# mylib:对外暴露的库,公开头文件 include/mylib/mylib.h 用了 std::string +add_library(mylib STATIC src/mylib.cpp) +target_include_directories(mylib PUBLIC include) +# fmt 在这里写成 PRIVATE —— mylib.cpp 内部要用,但 mylib.h 完全不暴露 fmt +target_link_libraries(mylib PRIVATE fmt) + +# app:下游可执行,只链接 mylib,对 fmt 一无所知 +add_executable(app main.cpp) +target_link_libraries(app PRIVATE mylib) +``` + +Reading this config bottom-up makes the intent clearer. `app` declares only "I link `mylib`," nothing else. `mylib` exposes its own `include` directory as PUBLIC, so downstream gets that path automatically when linking it; it locks `fmt` into PRIVATE, so downstream had better not find out `fmt` is in use. `fmt` exists as a STATIC library in its own right, with `include` as its own PUBLIC (so `mylib` picks up the `fmt.h` path when linking it). + +Three steps to bring the project up: + +```text +$ cmake -S . -B build -G Ninja && cmake --build build && ./build/app +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-target-demo/build +[1/6] Building CXX object CMakeFiles/fmt.dir/fmt/fmt.cpp.o +[2/6] Linking CXX static library libfmt.a +[3/6] Building CXX object CMakeFiles/mylib.dir/src/mylib.cpp.o +[4/6] Linking CXX static library libmylib.a +[5/6] Building CXX object CMakeFiles/app.dir/main.cpp.o +[6/6] Linking CXX executable app +hello, world! +``` + +The six-step order reveals the dependency graph. `fmt` builds first (steps 1-2, it depends on nothing), `mylib` builds next (steps 3-4, it depends on `fmt`), and `app` builds last (steps 5-6, it depends on `mylib`). Ninja orders everything by dependency automatically; you do not lift a finger. + +The final line, `hello, world!`, is what `app` prints. In `main.cpp` it only does `#include "mylib/mylib.h"`, yet the compiler finds that header, because `mylib` marked `include` as PUBLIC and `app` inherited `-I.../include` when it linked `mylib`. + +If we want to verify this inheritance is really happening, the most direct way is to look at the include flags `app` actually received. Configure once with the Make generator and read `app.dir/flags.make`: + +```text +$ cmake -S . -B build-mk -G "Unix Makefiles" > /dev/null +$ cat build-mk/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include +``` + +`app` never wrote a single line of `target_include_directories`, yet `-I.../include` is sitting right there in its compile command. That is the work PUBLIC usage requirements do quietly behind your back. The `fmt` path is absent, because `mylib` marked `fmt` as PRIVATE, and the encapsulation is airtight. + +## Companion Example + +The two-target project from this article builds directly out of the repository's example directory: + +```text +code/examples/vol7/cmake-fundamentals/02-target/ +├── CMakeLists.txt +├── fmt/ +│ ├── fmt.h +│ └── fmt.cpp +├── include/mylib/mylib.h +├── src/mylib.cpp +└── main.cpp +``` + +Step into that directory and run the same three commands from the previous section to reproduce every line of output. To feel the PUBLIC/PRIVATE propagation firsthand, change `target_link_libraries(mylib PRIVATE fmt)` to PUBLIC, reconfigure, and run `cat build-mk/CMakeFiles/app.dir/flags.make | grep INCLUDES` again to see the `-I.../fmt` line `app` gained out of thin air. + +That should land the target object, the `target_*` family of member methods, and the three-state PUBLIC/PRIVATE/INTERFACE usage requirements on solid ground. The next article tackles a more practical problem: in a real project `fmt` is not hand-written by us; you bring it in from the system or vcpkg/Conan with `find_package(fmt)`. We will see what the namespaced target like `fmt::fmt` that `find_package` hands back actually is, and how the PUBLIC/INTERFACE configuration on it flows automatically into your project. We will also circle back to a question left open here: for setting the C++ standard, is the directory-level form `set(CMAKE_CXX_STANDARD 17)` better, or the target-level form `target_compile_features(mylib PUBLIC cxx_std_17)`? diff --git a/documents/en/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md b/documents/en/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md new file mode 100644 index 000000000..719edac96 --- /dev/null +++ b/documents/en/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md @@ -0,0 +1,359 @@ +--- +title: "Dependencies and the C++ Standard — Modern Ways to Write find_package and cxx_std_NN" +description: "Work through the three ways to set the C++ standard, why hand-stuffing flags is an anti-pattern, how find_package brings in a third-party library's usage requirements via imported targets, and how to diagnose it when a package is not found" +chapter: 7 +order: 3 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 18 +prerequisites: + - "vol7 ch00 02: Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求" +related: + - "CMakePresets.json——从 cmake -D 老式到 --preset 可复现" + - "交叉编译与 CMake" +--- + +# Dependencies and the C++ Standard — Modern Ways to Write find_package and cxx_std_NN + +In the previous article we worked targets and usage requirements all the way through, with hands-on tests of how the PUBLIC/PRIVATE/INTERFACE three states propagate along the link graph. This one picks up two concrete questions you hit constantly in real projects: how do you tell CMake you want C++20, and how do you link a third-party library in. Search the web and you get answers for both instantly, but the old-style recipes are still floating around in tons of tutorials, and copying them plants landmines. We will run every recipe once and see clearly why some of them belong in the wastebasket. + +## Three ways to set the C++ standard, which one is right + +For setting the C++ standard, you see three styles coexisting in the CMake world. Let us take them one at a time, put the code on the table first, then explain why. + +First, attached to a target: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_20) +``` + +Second, a directory-level variable, the one we used to get the project running back in the getting-started volume [getting-started/04](/getting-started/04-multi-file-cmake): + +```cmake +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +``` + +Third, jamming `-std=c++20` straight into the compile flags: + +```cmake +string(APPEND CMAKE_CXX_FLAGS " -std=c++20") # anti-pattern, do not copy +``` + +CMake officially sanctions the first two; the third is an anti-pattern. Below we use real test output to explain why. + +### `target_compile_features` is "minimum requirement" semantics + +`target_compile_features(app PRIVATE cxx_std_20)` translated into plain English reads: when the `app` target compiles, the C++ standard must not be lower than C++20. Note the wording. It is "must not be lower than," not "must equal exactly." + +That semantics matters. CMake takes this requirement and compares it against the compiler's default standard, then decides based on the comparison whether to inject a `-std` flag into the compile command. Let us test this with GCC 16.1.1, whose default standard is `gnu++20` (run `g++ -dM -E -x c++ /dev/null | grep __cplusplus` and you see `202002L`, which is C++20). The `CMakeLists.txt` below creates three targets that ask for 17, 20, and 23 respectively: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(feat_test LANGUAGES CXX) + +add_executable(app_cxx17 main.cpp) +target_compile_features(app_cxx17 PRIVATE cxx_std_17) + +add_executable(app_cxx20 main.cpp) +target_compile_features(app_cxx20 PRIVATE cxx_std_20) + +add_executable(app_cxx23 main.cpp) +target_compile_features(app_cxx23 PRIVATE cxx_std_23) +``` + +Using the Make generator with `CMAKE_VERBOSE_MAKEFILE` on, look at the command CMake actually sends to `g++` for each target: + +```text +$ cmake -S . -B build -G "Unix Makefiles" -DCMAKE_VERBOSE_MAKEFILE=ON > /dev/null +$ cmake --build build --target app_cxx17 2>&1 | grep "/c++" +/usr/sbin/c++ -MD -MT ... -c .../main.cpp +$ cmake --build build --target app_cxx20 2>&1 | grep "/c++" +/usr/sbin/c++ -MD -MT ... -c .../main.cpp +$ cmake --build build --target app_cxx23 2>&1 | grep "/c++" +/usr/sbin/c++ -std=gnu++23 -MD -MT ... -c .../main.cpp +``` + +Read it line by line. The target asking for 17 has no `-std` in its compile command: the compiler default is already 20, which is higher than 17, so CMake judges the requirement satisfied and adds no flag. The one asking for 20 also has none: the default is 20, spot on. Only the one asking for 23 sprouts a `-std=gnu++23`: the default of 20 is not enough, so CMake proactively bumps it to 23. + +That is the beauty of "minimum requirement" semantics. When you write `cxx_std_20` you are declaring "this code uses C++20 features, anything below 20 will not compile," and CMake adds flags as needed. It will never secretly lower the default 20 down to 17. Move to an older compiler whose default is `gnu++17` (GCC 11, say), and the same `CMakeLists.txt` makes CMake automatically add `-std=gnu++20`. One configuration, correct standard across compiler versions. + +::: details What is that gnu++ thing, can I drop it +`gnu++20` is GCC's "C++20 plus GNU extensions" dialect; the pure-standard spelling is `c++20`. The difference is that the former lets you use GCC-only toys like `typeof` and zero-length arrays, which hurts portability. CMake defaults to `gnu++NN` for old-code compatibility, but you can force it back to pure `c++NN` by setting `CXX_EXTENSIONS OFF` on the target: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_23) +set_target_properties(app PROPERTIES CXX_EXTENSIONS OFF) +``` + +In testing, with the same `cxx_std_23` requirement and `CXX_EXTENSIONS OFF` turned on, the compile flag changes from `-std=gnu++23` to `-std=c++23`: + +```text +$ cmake --build build --target app 2>&1 | grep "/c++" +/usr/sbin/c++ -std=c++23 -MD -MT ... -c .../main.cpp +``` + +For new projects I recommend defaulting it to OFF. Behavior is more predictable across compilers. +::: + +`cxx_std_NN` can also go PUBLIC, reusing the usage-requirement propagation we covered in the previous article. A library that itself requires C++20 hands that requirement down automatically to whoever links it: + +```cmake +target_compile_features(mylib PUBLIC cxx_std_20) +``` + +When downstream links `mylib`, CMake sees `cxx_std_20` sitting in `INTERFACE_COMPILE_FEATURES` and automatically raises downstream's standard a notch. This is the biggest advantage of the target-level style over the directory-level style: the standard requirement rides the target, propagates automatically along the dependency graph, and you stop rewriting `set(CMAKE_CXX_STANDARD 20)` in every downstream project. + +### The directory-level `set(CMAKE_CXX_STANDARD)`: works, but has a ceiling + +We used the second style back in the getting-started volume. It looks like this: + +```cmake +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +``` + +These three lines scope over "every target in the current `CMakeLists.txt` and its subdirectories," essentially assigning a default to the `CXX_STANDARD` property of every target under that directory. `CMAKE_CXX_STANDARD_REQUIRED ON` is mandatory. It tells CMake "if the compiler cannot reach this standard, error out," otherwise, when the compiler is too old, CMake silently degrades and compiles past it. It compiles, but the behavior is wrong, and you debug for a long time before finding the root cause here. + +::: warning Forgetting `CMAKE_CXX_STANDARD_REQUIRED ON` silently degrades +CMake defaults `CMAKE_CXX_STANDARD_REQUIRED` to `OFF`, meaning "if the compiler does not support this standard, try anyway." The result is that you write `set(CMAKE_CXX_STANDARD 20)`, the compiler tops out at 17, and CMake does not error. It just quietly compiles with 17. You use a C++20 `concept` or a template lambda, it fails to compile, but the error does not point at "the standard got degraded." It points at the specific syntax line, and you go around the long way before tracing it back. So `CMAKE_CXX_STANDARD` and `CMAKE_CXX_STANDARD_REQUIRED ON` have to be written as a pair. +::: + +This style is still acceptable today, because in the vast majority of projects the C++ standard is one "project-wide uniform" value. But it has two spots where it loses to the target-level style. First, it does not propagate with the target: someone linking your library does not automatically inherit the standard requirement. Second, its scope is directory-level, which is fundamentally a global setting just like the `include_directories()` from the previous article. Once the project gets complicated, it stops being precise enough. + +Migration advice: for new projects, prefer `target_compile_features(mylib PUBLIC cxx_std_NN)` and make the standard a usage requirement of the target. Old projects can keep using `set(CMAKE_CXX_STANDARD)` without breaking anything; swap it out the next time you refactor. + +### Hand-stuffing `-std=c++20`: an anti-pattern, do not write it + +The third style looks the most "direct," and you see it all over old tutorials on the web: + +```cmake +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20") +# or +string(APPEND CMAKE_CXX_FLAGS " -std=c++20") +add_executable(app main.cpp) +``` + +In testing it does put `-std=c++20` into the compile command: + +```text +$ cmake -S . -B build -G "Unix Makefiles" -DCMAKE_VERBOSE_MAKEFILE=ON > /dev/null +$ cmake --build build --target app 2>&1 | grep "/c++" +/usr/sbin/c++ -std=c++20 -MD -MT ... -c .../main.cpp +``` + +Looks fine. The problems are all behind it. First, this line bypasses CMake's standard management. CMake keeps an internal table of "which standards each compiler knows, and which flag each standard maps to," and both `target_compile_features` and `set(CMAKE_CXX_STANDARD)` walk that table. When you hand-stuff `-std=c++20`, CMake does not know you set the standard, so the `CMAKE_CXX_STANDARD` variable stays empty. Downstream trying to read it for its own logic gets an empty value, and the standard-propagation chain through the dependency graph is severed. + +Second, it is inconsistent across platforms. GCC and Clang use `-std=c++20`; MSVC uses `/std:c++20`. Hardcoding the GCC-style flag breaks the moment you move to an MSVC project. CMake's standard management papers over that difference for you: write `cxx_std_20`, and CMake picks the right flag for the platform itself. + +Finally, it is completely detached from `CXX_EXTENSIONS` and `CMAKE_CXX_STANDARD_REQUIRED`, which means you are bypassing the whole abstraction and rolling your own. The moment a single line like `set(CMAKE_CXX_FLAGS ... -std=...)` shows up in a `CMakeLists.txt`, it flags that the file is still written in old-style CMake thinking. + +Migration rule: delete every place that hand-stuffs `-std=`, and replace it with the first or second style above. + +## find_package: how to link a third-party library + +The C++ standard business settled, on to third-party libraries. In a real project, a library like `fmt` is not something we hand-write. It comes in from the system or a package manager, and the command CMake gives you is `find_package`. + +The modern style is two lines and done: + +```cmake +find_package(fmt REQUIRED) +target_link_libraries(app PRIVATE fmt::fmt) +``` + +What `find_package(fmt REQUIRED)` does is go look in a few standard locations (`/lib/cmake/fmt/` under `CMAKE_PREFIX_PATH`, and so on) for a `fmt-config.cmake` (also called a package configuration file), and execute it once found. This config file ships with fmt itself, and it knows where fmt's headers are, where the library files are, and which compile options the link needs. After it runs, your project gains a target called `fmt::fmt` out of thin air. + +This `fmt::fmt` is an **imported target**. Imported targets differ from the ordinary targets we covered in the previous article. They are not built inside your project; someone else built them, packed them into a config file, and `find_package` carried them in. They carry the same usage-requirement properties: `INTERFACE_INCLUDE_DIRECTORIES`, `INTERFACE_COMPILE_DEFINITIONS`, `IMPORTED_LOCATION`, and friends. The moment you write `target_link_libraries(app PRIVATE fmt::fmt)`, those properties flow onto `app` automatically, just like PUBLIC did in the previous article. + +Let us peel a real `fmt::fmt` open and look. The local machine has fmt 12.2.0 installed, and its config file sits at `/usr/lib/cmake/fmt/fmt-config.cmake`. The key lines that create `fmt::fmt` (from `fmt-targets.cmake`) look like this: + +```cmake +add_library(fmt::fmt SHARED IMPORTED) +set_target_properties(fmt::fmt PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include" + ... +) +``` + +`SHARED IMPORTED` tells CMake this is an imported target for a dynamic library. `INTERFACE_INCLUDE_DIRECTORIES` is its public header path. The `CMakeLists.txt` below finds fmt and then prints a few of its properties out: + +```cmake +find_package(fmt REQUIRED) +foreach(prop TYPE INTERFACE_INCLUDE_DIRECTORIES INTERFACE_COMPILE_DEFINITIONS INTERFACE_COMPILE_FEATURES) + get_target_property(v fmt::fmt ${prop}) + message(STATUS "fmt::fmt.${prop} = ${v}") +endforeach() +``` + +Run configure once: + +```text +$ cmake -S . -B build +-- fmt::fmt.TYPE = SHARED_LIBRARY +-- fmt::fmt.INTERFACE_INCLUDE_DIRECTORIES = /usr/include +-- fmt::fmt.INTERFACE_COMPILE_DEFINITIONS = FMT_SHARED +-- fmt::fmt.INTERFACE_COMPILE_FEATURES = cxx_std_11 +``` + +Read it line by line. `TYPE` is SHARED_LIBRARY, a dynamic library. `INTERFACE_INCLUDE_DIRECTORIES` is `/usr/include`, the source of the path downstream uses to include ``. `INTERFACE_COMPILE_DEFINITIONS` is `FMT_SHARED`, a crucial signal: when fmt is built as a dynamic library, downstream linking it must define `FMT_SHARED` to import the symbols correctly. `INTERFACE_COMPILE_FEATURES` is `cxx_std_11`, fmt declaring that it needs at least C++11 itself. + +You link with one line `target_link_libraries(app PRIVATE fmt::fmt)`, and those four properties become part of `app`'s compile environment automatically. Let us look at the compile command `app` actually receives: + +```text +$ cmake -S . -B build -G Ninja > /dev/null && cmake --build build -v 2>&1 | grep "/c++" +[1/2] /usr/sbin/c++ -DFMT_SHARED -MD -MT ... -c .../main.cpp +``` + +Note the `-DFMT_SHARED` that appears out of nowhere. `app`'s `CMakeLists.txt` never wrote that line; it comes from `fmt::fmt`'s `INTERFACE_COMPILE_DEFINITIONS`. `/usr/include` is a system default path so it does not show up explicitly in the command, but install fmt to a non-standard path (say `/opt/fmt`) and that `-I/opt/fmt/include` will appear automatically. The link stage is the same. Look at the actual link command: + +```text +[2/2] : && /usr/sbin/c++ ... CMakeFiles/app.dir/main.cpp.o -o app /usr/lib/libfmt.so.12.2.0 && : +``` + +`/usr/lib/libfmt.so.12.2.0` is the real library file path that `fmt::fmt`'s `IMPORTED_LOCATION` resolves to. CMake does the entire dirty job for you, finding headers, passing compile macros, finding the library file. You only have to write the name `fmt::fmt`. + +That is the fundamental advantage of imported targets over the old style: they package the library's "usage requirements" into one object. You link once, every configuration that should come along arrives in place, and when the library upgrades or moves path you do not change a line of code. + +### The old style: the `${fmt_INCLUDE_DIRS}` variable flavor + +Another recipe you see in old tutorials online looks like this: + +```cmake +find_package(fmt REQUIRED) +include_directories(${fmt_INCLUDE_DIRS}) # anti-pattern +add_executable(app main.cpp) +target_link_libraries(app ${fmt_LIBRARIES}) # anti-pattern +``` + +`include_directories(${fmt_INCLUDE_DIRS})` is the directory-level global command from the previous article, polluting every target under the current directory. The `${fmt_LIBRARIES}` variable style relies on the config file writing the library list into a variable, which you then read out by hand and pass to `target_link_libraries`. The problem is that this style does not propagate usage requirements at all. `fmt_LIBRARIES` is only a list of library names; it carries no `-DFMT_SHARED`, no `INTERFACE_INCLUDE_DIRECTORIES`, no `cxx_std_11`. Miss one and it either fails to compile or behaves wrong. + +Worse, these variable names follow no unified convention. fmt might use `fmt_LIBRARIES`, OpenCV might use `OpenCV_LIBS`, Boost might use `Boost_LIBRARIES`, and every library you bring in forces you to look up which variables its config file provides. Imported targets, by contrast, are uniformly namespaced as `LibName::LibName`. Once you find that `::`-bearing name in the docs, you link once and you are done. + +Migration rule: delete every `include_directories(${X_INCLUDE_DIRS})`, and change every `target_link_libraries(app ${X_LIBRARIES})` into `target_link_libraries(app PRIVATE X::X)`. The precondition is that the library's config file provides an imported target. Mainstream libraries today (fmt, spdlog, Catch2, nlohmann_json, and friends) all do. + +::: warning What if the library does not provide an imported target +A handful of old libraries, or config files you hand-wrote yourself, may only provide `${X_INCLUDE_DIRS}` variables and no `X::X` imported target. In that case you have two options. One, build an INTERFACE library yourself as a wrapper: + +```cmake +find_package(OldLib REQUIRED) +add_library(OldLib::OldLib ALIAS OldLib::OldLib) # does not work, OldLib is not a target +# correct approach: build an interface target that wraps the variables +add_library(oldlib_wrapper INTERFACE) +target_include_directories(oldlib_wrapper INTERFACE ${OldLib_INCLUDE_DIRS}) +target_link_libraries(oldlib_wrapper INTERFACE ${OldLib_LIBRARIES}) +target_link_libraries(app PRIVATE oldlib_wrapper) +``` + +Now downstream uniformly links `oldlib_wrapper`, and configuration propagates outward from this one place. Two, pester the library author to update the config file, or just switch libraries. +::: + +## What to do when the package is not found + +The diagnostic path when `find_package` errors has a fixed playbook. First look at what a real error looks like. The `CMakeLists.txt` below asks for a library that does not exist at all: + +```cmake +find_package(NonExistentPkg 9.9.9 REQUIRED) +``` + +Configure dies outright, and CMake reports: + +```text +CMake Error at CMakeLists.txt:4 (find_package): + By not providing "FindNonExistentPkg.cmake" in CMAKE_MODULE_PATH this + project has asked CMake to find a package configuration file provided by + "NonExistentPkg", but CMake did not find one. + + Could not find a package configuration file provided by "NonExistentPkg" + (requested version 9.9.9) with any of the following names: + + NonExistentPkg.cps + nonexistentpkg.cps + NonExistentPkgConfig.cmake + nonexistentpkg-config.cmake + + Add the installation prefix of "NonExistentPkg" to CMAKE_PREFIX_PATH or set + "NonExistentPkg_DIR" to a directory containing one of the above files. + +-- Configuring incomplete, errors occurred! +``` + +That error message carries a lot. Let us break it apart. The first paragraph says "you did not provide `FindNonExistentPkg.cmake` in `CMAKE_MODULE_PATH`," meaning CMake first searched in "Module mode" for a built-in or user-provided `FindX.cmake` and found nothing. The second paragraph says "the package configuration file provided by `NonExistentPkg` was not found either," listing the file names it tried, where `.cps` is the CPS (CMake Package Specification) format introduced in CMake 3.29, and `.cmake` is the classic format. The third paragraph hands you the diagnostic path. + +Going by what the error suggests, the common reasons `find_package` cannot find a package are these, ordered by how often they show up: + +First, the library is not installed at all. Most common. Confirm the library actually exists on the system first. On Linux, query with the package manager (`apt list --installed | grep fmt`, `pacman -Qs fmt`); on Windows, check `vcpkg list`; on macOS, check `brew list`. If it is not installed, install it, and when you do, watch for whether you need a `-dev` or `-devel` suffixed development package, because some distros split the runtime library and the headers apart. Install only the runtime and `find_package` still cannot find it. + +Second, it is installed but `CMAKE_PREFIX_PATH` is not set. The library sits in a non-standard path (you ran `make install` into `/opt/fmt`, or vcpkg installed into `~/vcpkg/installed/x64-linux`). CMake only searches a few standard locations like `/usr` and `/usr/local` by default, so naturally it cannot find it. The fix is to add `-DCMAKE_PREFIX_PATH=/opt/fmt` at configure time, or set the `CMAKE_PREFIX_PATH` environment variable. The next article on CMakePresets will pin this kind of `-D` into JSON. + +Third, the vcpkg or Conan toolchain file was not injected. After these two package managers install a library, the library lives in a directory they manage themselves (vcpkg's `installed/`, Conan's `~/.conan2/`), not in the system standard paths. They hand you a toolchain file. You pass it in at configure time via `-DCMAKE_TOOLCHAIN_FILE=/vcpkg.cmake`, and that toolchain file automatically points `CMAKE_PREFIX_PATH` at the libraries it installed. Forget to hook the toolchain in, and the install was wasted. `find_package` still cannot find it. This is the trap beginners hit the most. + +Fourth, the library is installed but provides no config file. For example the system has an old fmt 5.x, from before fmt shipped `fmt-config.cmake`. Back then there was only a Module-mode lookup file like `FindFMT.cmake` (or even nothing). In that case `find_package(fmt)` runs in Config mode and finds nothing, so you either upgrade the library, write a `FindX.cmake` yourself, or bridge through pkg-config. + +::: details The two lookup modes of find_package +`find_package(X)` walks two modes by default, Module first then Config. + +Module mode looks for `FindX.cmake`, a file whose name starts with `Find`. These files are written by CMake itself (over a hundred built-in `FindX.cmake` files for common libraries), or provided by you under `CMAKE_MODULE_PATH`. Common in old-style code, because back then many libraries did not ship their own config files and relied on CMake-community-maintained Modules as a bridge. + +Config mode looks for `X-config.cmake` or `XConfig.cmake` (CMake 3.29+ also looks for `.cps` files), files whose name starts with the library name. These files are installed by the library author and ship with the library, so they are more accurate than community-maintained Modules. Modern mainstream libraries (fmt, spdlog, Catch2, Boost 1.70+, and so on) all ship their own Config files, so `find_package` in practice mostly runs in Config mode. + +CMake defaults to Module first then Config. You can force only one with `find_package(X CONFIG)` or `find_package(X MODULE)`. For new projects I recommend writing `CONFIG` explicitly. The behavior is clearer, and it avoids a stale built-in `FindX.cmake` getting picked up before the library's own config file, which would make the behavior inconsistent. +::: + +## Hooking up vcpkg / Conan in one sentence + +We have not said yet where third-party libraries come from. Libraries installed by system package managers (apt, pacman, brew) tend to be old, inconsistent across platforms, and not necessarily installable on CI, so for serious projects you generally do not use them. The two mainstream package managers in the C++ world are vcpkg and Conan. What they do is build the library for you, install it into their own directory, and then hand you a toolchain file so CMake's `find_package` can find it. + +The key piece of usage is one configure argument: + +```text +cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake +``` + +Libraries installed by vcpkg all live under `/installed/`, and its toolchain file automatically points `CMAKE_PREFIX_PATH` there. So in your project `find_package(fmt REQUIRED)` works just the same as with a system-installed library, no difference. Conan works the same way; the toolchain file it generates is called `conan_toolchain.cmake`. We leave the details of this mechanism, how to write the manifest file, how to pin versions, and how it hooks into the CMakePresets article coming next, for a later package-management topic. The one thing to remember here: after the library is installed, injecting the toolchain file is the step that lets CMake find it. + +## The companion example + +The example project for this article lives in the repo at `code/examples/vol7/cmake-fundamentals/03-find-package/`, structured like this: + +```text +03-find-package/ +├── CMakeLists.txt # target_compile_features + an optional find_package section +└── main.cpp # uses a C++20 template lambda to prove the standard took effect +``` + +The three core lines of `CMakeLists.txt`: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_20) +set_target_properties(app PROPERTIES CXX_EXTENSIONS OFF) +``` + +Three steps to run it: + +```text +$ cmake -S . -B build -G Ninja && cmake --build build && ./build/app +3 +ab +``` + +`main.cpp` uses a template lambda that only exists from C++20 onward (`[](T a, T b) { return a + b; }`) to prove `cxx_std_20` really did propagate the standard requirement into the compile command. If you want to feel the "minimum requirement" semantics in your own hands, change `cxx_std_20` to `cxx_std_23`, reconfigure, and look at the compile command with `cmake --build build -v`. You will see CMake automatically add a `-std=c++23`. + +## What comes next + +By here, the three ways to set the C++ standard and `find_package`'s imported-target mechanism have all landed on real code. Our configure command in this article has grown into something like: + +```text +cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake -DCMAKE_PREFIX_PATH=/opt/fmt ... +``` + +Once the `-D` list grows long, problems start: mistype a variable name and configure does not error, it just silently runs an empty config; teammates keep asking each other what to fill in for the vcpkg path; on CI you change one option and a PR goes red across the board. The next article covers `CMakePresets.json`, the mechanism CMake 3.19 brought in to pin all these scattered `-D` flags, the generator choice, and the toolchain injection into one JSON file, slimming the command down to a single `cmake --preset debug`. diff --git a/documents/en/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md b/documents/en/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md new file mode 100644 index 000000000..d6f1cf654 --- /dev/null +++ b/documents/en/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md @@ -0,0 +1,310 @@ +--- +title: "CMakePresets.json: From the cmake -D Old Way to Reproducible --preset" +description: "A thorough walkthrough of how CMakePresets.json pins the old -D workflow into version control: configurePresets/buildPresets/testPresets, hidden + inherits composition, and per-user overrides via CMakeUserPresets.json" +chapter: 7 +order: 4 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 16 +prerequisites: + - "vol7 ch00 01: CMake 是什么——构建系统生成器的两段式流水线" + - "vol7 ch00 02: Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# CMakePresets.json: From the cmake -D Old Way to Reproducible --preset + +In the previous two pieces every configure command we typed looked the same: `cmake -B build -G Ninja`. In a real project that line is rarely that short. Once you add a build type, a toolchain file, and a few cache variables, the command balloons into something like this: + +```text +cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DVCPKG_TARGET_TRIPLET=x64-linux \ + -DCMAKE_CXX_STANDARD=20 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +Once a command gets this long, things start going wrong. I have personally stepped on this: I copied `CMAKE_BUILD_TYPE` as `CMAKE_BUILD-TYPE`, configure did not error, silently produced an empty config, and the resulting binary shipped with a pile of debug symbols. Teammates kept asking each other "what did you put for the vcpkg path". In CI the command got embedded into YAML, and changing one option turned a PR red across the board. CMake 3.19 introduced `CMakePresets.json`, which folds all those `-D` flags, the generator choice, and the build directory scattered across the command line into one JSON file. The command then shrinks to a single `cmake --preset debug`. This piece covers how to use it, and how it hooks into the vcpkg toolchain and the VSCode CMake Tools extension. + +## Why Presets: Four Pains of the -D Old Way + +Before we touch the JSON, let's nail down why this is worth doing. Going back to the commands we used in the previous pieces, let's pick apart what's wrong with the -D approach one pain at a time. + +First, the command is long and easy to mistype. That line above is over 130 characters spanning several lines. Get one letter wrong in `CMAKE_BUILD_TYPE` or `CMAKE_TOOLCHAIN_FILE` and CMake will not complain. It silently writes the unknown variable into the cache, and you end up with a build tree that "looks configured but actually set nothing". The problem usually only surfaces at runtime. I once burned half a day tracking down the `CMAKE_BUILD-TYPE` typo (underscore typed as a hyphen). + +Second, it is not reproducible. The command lives only in your terminal history. Switch machines, open a new terminal window, or come back to this project two weeks later and the command is gone. You have to retype it from memory. Even if you remember roughly, the parameter order, whether a particular `-D` was on, you would not bet on any of it. + +Third, the team ends up each typing their own. Same project, A uses `Release`, B uses `RelWithDebInfo`, C forgets to set `CMAKE_BUILD_TYPE` at all. Three machines produce three binaries with different behavior. A bug reproduces on B's machine, vanishes on A's, and the post-mortem shows build type mismatch. This kind of back-and-forth is nearly the norm in projects without conventions. + +Fourth, it is hard to pin down in CI. The CI script has to copy the command verbatim into YAML, and every `-D` is a potential spelling trap. Changing one compile option means editing it in two places (local command + CI YAML), and over time they will inevitably drift. + +The presets mechanism exists to take these four pains head on. Write "which `-D` flags, which generator, which build directory" into `CMakePresets.json`, check that file into version control, and the team and CI share one configuration. Locally you run `cmake --preset debug`, in CI you also run `cmake --preset debug`, the command is identical on both sides, and the build behavior is reproducible. + +## CMakePresets.json Structure + +The top level of `CMakePresets.json` has three categories of presets, one for each stage of the CMake workflow: + +`configurePresets` corresponds to `cmake --preset`, and pins the configure-stage `-D` flags, the generator, and `binaryDir`. This is the most heavily used category. + +`buildPresets` corresponds to `cmake --build --preset`, and pins build-stage arguments like `--target`, `--config`, and the parallelism. Added in schema version 2 (CMake 3.20). + +`testPresets` corresponds to `ctest --preset`, and pins the test-stage filter, output format, and so on. Also introduced in schema version 2. + +Let's look at a complete minimal working example first, then break the fields down. The `CMakePresets.json` below is the one I used while writing and verifying this piece: one hidden `base` preset sets the common fields, and two presets `debug` and `release` that inherit it each set `CMAKE_BUILD_TYPE`: + +```json +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 21, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_CXX_STANDARD": "17", + "CMAKE_CXX_STANDARD_REQUIRED": "ON", + "CMAKE_CXX_EXTENSIONS": "OFF" + } + }, + { + "name": "debug", + "displayName": "Debug (含 -g -O0)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release (含 -O3 -DNDEBUG)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + } + ] +} +``` + +Field by field. The top-level `version` is **the JSON schema version**, not the CMake version. It currently goes up to 9 (introduced in CMake 3.27). 3 is a sensible floor: it covers the full basic capability of `configurePresets` + `buildPresets` + `testPresets` and is natively supported from CMake 3.21 on. The schema version and `cmakeMinimumRequired` are two different things: the former declares "which version of the schema this JSON was written against", the latter declares "how recent a CMake you need at minimum to run this JSON". A CMake older than that minimum refuses to touch `CMakePresets.json` outright, which keeps an old CMake from silently carrying on after failing to parse a new field. + +`configurePresets` is an array, and each element is one preset. The `base` preset has a few key fields. + +`name` is the unique identifier of the preset, and it is what follows `cmake --preset`. + +`hidden: true` means this preset cannot be used directly by `--preset`, and it does not show up in the `--list-presets` output. It exists only as a base class for other presets to inherit. We will verify this in a moment with `cmake --preset base`, and CMake will refuse it on the spot. + +`generator` and `binaryDir` pin down `-G` and `-B` respectively. Note that `binaryDir` is written as `${sourceDir}/build/${presetName}`, which involves two layers of macro expansion: `${sourceDir}` is the absolute path of the project root, and `${presetName}` is the name of the current preset (for example `debug` or `release`). The upside is that each preset lands in its own build directory, `build/debug` and `build/release` do not interfere, and switching build type does not require `rm -rf build` to start over. + +`cacheVariables` is the pinned `-D`. Each `key: value` pair is equivalent to `-Dkey=value`. The value can be a string, a boolean, `null` (meaning the `UNINITIALIZED` type), or an object with a `type` field (for precise control over the cache variable type). + +Next, how `debug` and `release` inherit from `base`. `inherits: "base"` means "this preset pulls in every field of `base` and overrides a portion of them itself". Here it overrides only `cacheVariables.CMAKE_BUILD_TYPE`: `debug` sets it to `Debug`, `release` to `Release`. The common fields on `base` like `generator`, `binaryDir`, and `CMAKE_CXX_STANDARD` are inherited as-is. + +`inherits` accepts a single string or an array of strings. When the array case has multiple parent presets supplying the same field, **the one earlier in the array wins**. That is different from how C++ resolves multiple-inheritance ambiguity: CMake has a deterministic order here. + +The `buildPresets` section is straightforward: each build preset is bound to a configure preset through its `configurePreset` field. `cmake --build --preset debug` then knows to run the build in the `binaryDir` of `build/debug`, without you writing `cmake --build build/debug` yourself. + +::: details Which schema version should I pick? +The official documentation walks the schema version from 1 up to 9. Which one to pick depends on which new features you actually need. version 1 (CMake 3.19) has only `configurePresets`, no build/test presets; version 2 (3.20) adds `buildPresets`/`testPresets`; version 3 (3.21) brings the `cmakeMinimumRequired` field and more lenient macro expansion. Beyond that, the changes are mostly patches for advanced scenarios like CI integration and conditional includes. My default is 3: it covers the vast majority of project needs and guarantees parsing from CMake 3.21+ onward. +::: + +## In Practice: Real Output From configure to build + +Reading the JSON is not satisfying enough, so let's run it. The minimal project (the `CMakeLists.txt` + `main.cpp`) that pairs with this `CMakePresets.json` lives in the repo at `code/examples/vol7/cmake-fundamentals/04-presets/`. First, see which presets CMake recognizes: + +```text +$ cmake --list-presets +Available configure presets: + + "debug" - Debug (含 -g -O0) + "release" - Release (含 -O3 -DNDEBUG) +``` + +`--list-presets` lists every non-hidden configure preset along with its `displayName`. Note that `base` does not show up, blocked by `hidden`. If you insist on `cmake --preset base`, CMake errors outright: + +```text +$ cmake --preset base +CMake Error: Cannot use hidden configure preset in /tmp/cmake-presets-demo: "base" +``` + +That is exactly the semantics of a hidden preset: base class only, never used directly. The design keeps a teammate from accidentally reaching for a "half-configured" preset. + +Run the `debug` preset: + +```text +$ cmake --preset debug +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-presets-demo/build/debug +``` + +The last line is the key evidence: the build files landed in `build/debug`. The `${sourceDir}/build/${presetName}` macro expansion did its job. Run `release` next and the build directory is `build/release`; the two do not interfere: + +```text +$ ls build/ +debug release +``` + +Now run the build through a build preset: + +```text +$ cmake --build --preset debug +[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o +[2/2] Linking CXX executable app +``` + +`cmake --build --preset debug` is equivalent to `cmake --build build/debug`, but you do not have to remember what `binaryDir` looks like. The preset remembers it for you. + +Just running it cleanly is not enough. Let's verify that `CMAKE_BUILD_TYPE` from `cacheVariables` actually flowed into the compile command. In `main.cpp` I dropped in an `#ifdef NDEBUG` to tell the two builds apart. First, look at the flags the `release` binary actually received, by digging into `build.ninja`: + +```text +$ grep FLAGS build/release/build.ninja | head -2 + FLAGS = -O3 -DNDEBUG -std=c++17 + FLAGS = -O3 -DNDEBUG + +$ grep FLAGS build/debug/build.ninja | head -2 + FLAGS = -g -std=c++17 + FLAGS = -g +``` + +`release` gets `-O3 -DNDEBUG`, `debug` gets `-g`, and `-std=c++17` shows up on both sides (from `CMAKE_CXX_STANDARD` on `base`). That nails down the causal chain between `CMAKE_BUILD_TYPE: Debug/Release` written in the preset and the actual compiler flags. The two binaries produce matching output when run: + +```text +$ ./build/debug/app +debug build (NDEBUG NOT defined) + +$ ./build/release/app +release build (NDEBUG defined) +``` + +One `CMakePresets.json`, two presets, two independent build trees, two binaries with different behavior, and the commands are as short as `cmake --preset debug` / `cmake --preset release`. Set that against the 130-plus-character -D command from earlier, and the gap is right there. + +## CMakeUserPresets.json: Per-User Overrides + +`CMakePresets.json` is shared by the team and goes into version control. But some things are inherently "machine-local": where vcpkg is installed, whether you have ASan on locally, or me wanting to add a temporary preset to experiment with some flag. Writing those into `CMakePresets.json` pollutes the team configuration. When someone else pulls, either the path is not found or some option that should not be on is suddenly on. + +CMake's answer is `CMakeUserPresets.json`. It lives in the same directory as `CMakePresets.json`, has an identical structure, but its semantics are "personal override": + +```text +project root/ +├── CMakePresets.json # in git, shared by the team +├── CMakeUserPresets.json # in .gitignore, local only +├── CMakeLists.txt +└── ... +``` + +Presets defined in `CMakeUserPresets.json` are merged with those in the main file and shown together. Crucially, **a preset in UserPresets can inherit a hidden preset from the main file**. On my machine I added an `asan` preset that inherits `base` from the main file and layers an ASan flag on top: + +```json +{ + "version": 3, + "configurePresets": [ + { + "name": "asan", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-fsanitize=address -fno-omit-frame-pointer" + } + } + ] +} +``` + +Run `--list-presets` again: + +```text +$ cmake --list-presets +Available configure presets: + + "asan" + "debug" - Debug (含 -g -O0) + "release" - Release (含 -O3 -DNDEBUG) +``` + +`asan` shows up, on equal footing with `debug` and `release`. A direct `cmake --preset asan` runs cleanly, and the build directory lands at `build/asan` automatically: + +```text +$ cmake --preset asan +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-presets-demo/build/asan +``` + +::: warning CMakeUserPresets.json must go into .gitignore +The official documentation says outright that it "should NOT be checked in". Its whole premise is "every machine has different paths", and once it goes into git, conflicts are guaranteed. The first thing to do when starting a new project is add `CMakeUserPresets.json` to `.gitignore`, before a colleague's PR shows up carrying their own vcpkg path to torment you. +::: + +## IDE Integration: VSCode CMake Tools + +Beyond the command line, the place presets really land is the IDE. The VSCode CMake Tools extension reads `CMakePresets.json` natively. The status bar lists the available configure presets and build presets, and clicking one switches, no command typing required. + +clangd benefits indirectly too. Once CMake Tools has picked a preset, it runs the corresponding configure automatically, and the generated `compile_commands.json` gets picked up by clangd to power completion and jump-to-definition in the editor. Because the preset pins every `-D` and the generator, the compile environment the IDE sees is identical to the command line and to CI. That is the biggest advantage of presets over "the IDE maintaining its own configuration": a single source of truth. + +The Remote-WSL case is just as smooth: `CMakePresets.json` travels into the WSL filesystem with the source, and the CMake Tools on the VSCode Remote side reads it directly. No need to configure it once on the Windows side and again on the WSL side. + +## Hooking Up Cross-Compilation + +By this point you can probably smell the natural fit between presets and cross-compilation. The heart of cross-compilation is the `-DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake` flag, plus a pile of target-board cache variables. Those are exactly what presets are best at pinning down. + +`CMakePresets.json` has a dedicated `toolchainFile` field, cleaner than stuffing it into `cacheVariables`: + +```json +{ + "name": "f407-debug", + "inherits": "base", + "toolchainFile": "${sourceDir}/cmake/arm-none-eabi-gcc.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "ARM_CORTEX_M": "M4F" + } +} +``` + +After that, a single `cmake --preset f407-debug` completes the cross-compilation configuration, and anyone who pulls the repo can reproduce the same toolchain setup. How this mechanism cooperates with `arm-none-eabi-g++`, the sysroot, and the cortex-m link script is something we expand on in detail in the vol7 cross-compilation piece. + +## Companion Example + +The project scaffold for this piece can be run straight from the example directory in the repo: + +```text +code/examples/vol7/cmake-fundamentals/04-presets/ +├── CMakeLists.txt +├── main.cpp +└── CMakePresets.json +``` + +Once you are in that directory, run `cmake --list-presets`, `cmake --preset debug`, `cmake --build --preset debug`, and `./build/debug/app` in order to reproduce every output in this piece. To verify the propagation of `cacheVariables`, change `debug` to `release`, rerun, and compare `FLAGS = -O3 -DNDEBUG` in `build/release/build.ninja` against `FLAGS = -g` in `build/debug/build.ninja`. + +That covers the structure of presets, the hidden + inherits combination, per-user overrides via CMakeUserPresets.json, and IDE integration, all backed by real output that verifies the `${presetName}` macro expansion and the propagation of `CMAKE_BUILD_TYPE`. The next piece tackles a question vol7 has been carrying for a while: when the target board moves from x86 Linux to an ARM Cortex-M device like the STM32F407, how do you write `CMakeLists.txt`, what does the toolchain file look like, and how do presets hook into them? That is, the full cross-compilation pipeline. diff --git a/documents/en/vol7-engineering/cpp-development-on-wsl.md b/documents/en/vol7-engineering/cpp-development-on-wsl.md index bc11dbcab..edd20bda0 100644 --- a/documents/en/vol7-engineering/cpp-development-on-wsl.md +++ b/documents/en/vol7-engineering/cpp-development-on-wsl.md @@ -1,150 +1,506 @@ --- +title: "C++ Engineering on WSL — vscode + clangd in depth, with full debugging" +description: "The deeper follow-up to the getting-started clangd piece: install the full WSL2 toolchain, push .clangd to full power, wire up launch.json/tasks.json for debugging, and explain Remote-WSL's client/server architecture and how clangd finds compile_commands.json" chapter: 1 -difficulty: intermediate order: 6 platform: host -reading_time_minutes: 5 +difficulty: intermediate +cpp_standard: [17, 20] tags: -- cpp-modern -- host -- intermediate -title: Developing Generic C++ Host Applications on WSL Quickly -description: '' -translation: - source: documents/vol7-engineering/cpp-development-on-wsl.md - source_hash: 61db3c65723a774079854184c14f17581b960335a81089f1e83ccbbab85f633c - translated_at: '2026-06-16T04:08:13.681582+00:00' - engine: anthropic - token_count: 1025 + - host + - cpp-modern + - intermediate + - clangd +reading_time_minutes: 20 +prerequisites: + - "起步卷篇 5: 让 vscode 看懂您的代码——装 clangd" +related: + - "CMake 是什么——构建系统生成器的两段式流水线" + - "CMakePresets.json——从 cmake -D 老式到 --preset 可复现" --- -# Quickly Developing General C++ Host Programs on WSL -## Preface +# C++ Engineering on WSL — vscode + clangd in depth, with full debugging + +For serious C++ work on Windows, the smoothest combo today is **WSL2 + vscode + clangd**. This piece sets the whole stack up in one pass: a full Linux toolchain, clangd pushed to full power, and a working `launch.json` + `tasks.json` debug pipeline. If you came over from [getting-started piece 5](/getting-started/05-vscode-clangd), where three steps installed clangd and killed the red squiggles, this is the deeper dig: what every field in `.clangd` actually does, how clang-tidy plugs into clangd, what to do when background indexing crawls on a big project, and how gdb shows a `std::vector` once your breakpoint hits. + +## Why WSL + +Windows does ship C++ toolchains. MSVC and MinGW both work. But read this tutorial through volume 7 and you'll notice every command-line example, every `CMakeLists.txt` snippet, every bit of terminal output assumes Linux. Run it natively on Windows and the tools still work, but every step goes through a "translation" layer: `g++` becomes `g++.exe`, path separators flip, the sysroot path for `arm-none-eabi-g++` has to be reset. WSL2 wipes out that translation entirely. -I distinctly remember writing a blog post like this before, but I can no longer find it. As I am about to launch a new modern C++ analysis tutorial, I plan to use this post to archive the environment setup process. +WSL2 is Microsoft's real Linux kernel running inside Windows (not an emulator). For our C++ engineering use case it gives three direct wins. -> **Note:** This guide uses **WSL2 + Ubuntu** as an example. Commands are run in PowerShell / Windows Terminal (Administrator) or WSL bash. If you choose another distro (Debian, Fedora, etc.), please replace `apt` commands with the appropriate package manager. -> -> I will not teach how to install WSL here; there are plenty of tutorials available online. +The Linux toolchain is the most complete. `gcc`, `gdb`, `make`, `cmake`, `ninja-build`, `clangd`, `clang-tidy`, `valgrind`, `binutils` all land in one `apt` command, and the versions stay current. Volume 6 covers AddressSanitizer, volume 7 covers cross-compilation, and on native Windows those tools either need a detour through MSYS2 or just don't exist. ------- +It matches production. The C++ projects we write will mostly run on Linux servers. Having the dev environment be Linux too means the "works on my machine, crashes on the server" class of environment-mismatch bugs simply never gets a chance to exist. -## Prerequisites +WSL2 performance is close to native. WSL2 uses a real Linux kernel in a lightweight VM, totally different from WSL1's syscall translation. Filesystem IO and process scheduling are close to native Linux speed, and compile times aren't far off a real Linux box. That's the key upgrade from WSL1, and the reason everyone doing C++ now defaults to WSL2. -- **Windows 10/11** (Latest updates recommended); WSL2 is recommended for better performance (and is the default for new installations). You can use `wsl --install` to install WSL and common distributions in one step. ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/install?utm_source=chatgpt.com)) -- Install **Visual Studio Code** on Windows (download from [https://code.visualstudio.com](https://code.visualstudio.com/)). -- A Microsoft account and administrator privileges are required to enable virtualization features (Hyper-V / Virtual Machine Platform) if necessary. +::: warning Don't put the project under `/mnt/c` +WSL2 reaches the Windows filesystem (`/mnt/c/...`) over the 9P protocol, which is about an order of magnitude slower on IO. Put the project in WSL's own filesystem (under `~/projects/`) and both configure and build get noticeably faster. I missed this the first time and a mid-sized project took 40 seconds to configure; after moving it under `~/` it dropped to 4. +::: + +## Installing WSL2 and the C++ toolchain + +WSL2 installs in one PowerShell (admin) command: + +```powershell +wsl --install +``` -## First Steps in WSL: Update System and Install Basic Build Tools +That enables the required Windows feature (Virtual Machine Platform), downloads the default Ubuntu distribution, and installs it. Reboot once after it finishes, launch Ubuntu, and the first run asks for a username and password. If you want a different distro (Debian, Fedora), `wsl --list --online` shows the options and `wsl --install -d ` installs a specific one. -Open Windows Terminal -> Select Ubuntu (or your installed distro) to enter the shell, then run: +Once inside Ubuntu, refresh the system packages and pull in the full C++ toolchain in one shot: ```bash sudo apt update && sudo apt upgrade -y -sudo apt install -y build-essential cmake git gdb +sudo apt install -y build-essential cmake ninja-build gdb clangd clang-tidy clang-format +``` + +`build-essential` is Debian/Ubuntu's C/C++ meta-package, and pulling it in brings `gcc`/`g++`/`make`. `cmake` is the build-system generator (covered in volume 7 ch00/01), `ninja-build` provides `ninja` (faster than `make`, the default generator in this tutorial), `gdb` is the debugger. The last three are the LLVM toolchain: `clangd` is clang's LSP server (what lets vscode understand your code), `clang-tidy` is the static analyzer, `clang-format` is the formatter. + +::: details A few useful extras to grab while you're at it + +```bash +# valgrind memory checking (used in volume 6's memory-safety chapter) +sudo apt install -y valgrind + +# ccache to speed up rebuilds (especially worth it on CI and large projects) +sudo apt install -y ccache + +# Several build tools that go with cmake +sudo apt install -y ninja-build + +# Inspect what symbols live in a build artifact and which shared libs it depends on +sudo apt install -y binutils ``` -`build-essential` includes `gcc`/`g++`, `make`, and other packages, and is a standard build dependency on Debian/Ubuntu. Refer to community documentation for installation details. +::: ------- +After installing, check the versions to confirm everything landed. Here's the output on my machine: -## Install VS Code on Windows and Enable the Remote - WSL Extension +```text +$ gcc --version | head -1 +gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0 -1. Download and install Visual Studio Code on Windows. -2. Open VS Code and navigate to the **Extensions** panel. Search for and install: - - **Remote - WSL** (or the official extension named *WSL*) — This allows you to open and run VS Code directly within the WSL environment (the editor runs on Windows, but extensions/execution run on WSL). VS Code has official documentation and tutorials for WSL development. (This extension is a lifesaver). -3. Recommended installations (the corresponding server extensions will be automatically installed in the WSL context later): - - **C/C++ (ms-vscode.cpptools)**: The official Microsoft C/C++ extension, providing IntelliSense, debugging, and code navigation. **Note:** This extension conflicts with `clangd`. If you prefer the Clang toolchain, do not install this; instead, install `clangd` and `clang-tidy`. - - **CMake Tools** (or the C/C++ Extension Pack) — Used for CMake project management, configuration, building, and switching kits. If you don't use CMake, VS Code has a plethora of other plugins you can search for. I personally prefer CMake. - - **CodeLLDB** (if you prefer the `lldb` debugger). - - **clang-format** support, GitLens (to enhance Git experience), EditorConfig, etc. +$ cmake --version | head -1 +cmake version 3.28.3 ------- +$ ninja --version +1.11.1 -## Opening a Project in WSL using VS Code (Truly "Developing under Linux") +$ gdb --version | head -1 +GNU gdb (Ubuntu 14.1-0ubuntu3.1) 14.1 -1. In Windows, open VS Code, press `Ctrl+Shift+P` -> input `WSL: Connect to WSL` (or navigate to your project directory in the Ubuntu terminal and run `code .`, which will open the VS Code window on WSL). -2. VS Code will automatically install the necessary server components in WSL. A green indicator in the bottom-left corner will show **WSL: Ubuntu**, indicating the current window is connected to WSL. +$ clangd --version +clangd version 18.1.3 +Features: linux +Platform: x86_64-pc-linux-gnu +``` + +::: tip Always install clangd alongside the toolchain +A common newbie mistake is to install only the vscode clangd extension and forget the `clangd` binary. The extension is just a remote control; the `clangd` binary is what actually does the work. Extension without binary means a remote with no TV. `clangd --version` printing a version number is the only proof it's actually installed. +::: + +Ubuntu 24.04's apt ships clangd 18.x, which is plenty (InlayHints, include-cleaner, External index, all there). If you insist on chasing the latest, adding LLVM's official apt source gets you 19/20, but for this tutorial it's unnecessary. + +## vscode Remote-WSL: editor on Windows, work in WSL + +The way vscode does C++ is a client/server architecture: the vscode UI runs on Windows, the processes doing the real work run in WSL, and the Remote-WSL extension bridges the two. Get this architecture straight, or you won't be able to diagnose any later problem. -> When VS Code opens in the WSL context, the Extensions panel on the left will prompt you to install extensions "Install in WSL:Ubuntu" (meaning the extension runs in the WSL environment rather than Windows). It is recommended to install C/C++ and CMake Tools in WSL (click "Install in WSL: Ubuntu"). +On the Windows side, do two things: ------- +- Install vscode (download from [code.visualstudio.com](https://code.visualstudio.com), normal next-next-next) +- In the vscode extension marketplace, search `WSL` (publisher Microsoft) and install it -## Creating a Minimal CMake + C++ Project and Building/Debugging in VS Code +With that done, there are two ways to open a project that lives in WSL: -Create project files in the WSL home directory: +First way, in the command palette (`F1` or `Ctrl+Shift+P`) type `Remote-WSL: New Window`, which spins up a new vscode window connected to WSL. + +Second way, in a WSL terminal, `cd` into the project directory and type: ```bash -mkdir -p hello_cmake/src -cd hello_cmake +code . ``` -Create a new file `src/main.cpp`: +The `code` command is injected into WSL's PATH automatically once the Remote-WSL extension is installed. It launches the Windows-side vscode and treats the current directory as the workspace. -```cpp -#include +::: details Why `code .` works at all +Remote-WSL drops a `code` shell script into WSL (usually at `/usr/bin/code`). That script talks to the Windows-side vscode and tells it to launch and connect back. The first run pulls a vscode server component from Windows into WSL (`~/.vscode-server/`), and that server is the process that actually runs extensions, terminals, and language servers. Subsequent opens are instant. +::: + +Once connected, look at the bottom-left corner of the vscode window. You should see a green or blue badge reading `WSL: Ubuntu`. That means every file operation, terminal, and extension in this window is running in WSL. + +Now the biggest trap for newcomers: **vscode extensions install on both sides**. Windows-side extensions handle UI (themes, icons, keybindings); WSL-side extensions handle the Linux work (code understanding, debugging, building). Once Remote-WSL connects, the extensions panel splits into "LOCAL - INSTALLED" (Windows side) and "WSL: UBUNTU - INSTALLED" (WSL side). The clangd, C/C++, and CMake Tools extensions you want all have to go into the WSL column (click "Install in WSL: Ubuntu" next to each). + +```text +Extensions panel (after connecting to WSL) +├── LOCAL - INSTALLED ← Windows side: themes, icons, Remote-WSL itself +│ ├── Remote - WSL ✓ +│ ├── Material Icon Theme +│ └── ... +└── WSL: UBUNTU - INSTALLED ← WSL side: install clangd / C/C++ / CMake Tools here + ├── clangd ← code understanding (completion / jump-to-def / errors) + ├── C/C++ ← debugging (keep cppdbg, turn IntelliSense off) + └── CMake Tools ← CMake configure / build / kit selection (optional) +``` + +The clangd extension has to go on the WSL side. It calls the `clangd` binary inside WSL, it reads `compile_commands.json` from inside WSL, all of it is on the Linux side. Install it on the Windows side by mistake and it'll go looking for `clangd.exe` on Windows, which it absolutely will not find. + +## clangd configuration in depth + +[Getting-started piece 5](/getting-started/05-vscode-clangd) installed clangd and killed the red squiggles, but covered only three steps: turn on `CMAKE_EXPORT_COMPILE_COMMANDS`, install the extension, switch off the C/C++ extension's IntelliSense. This piece fills in the rest: how clangd finds compile_commands, what every field in `.clangd` does, how clang-tidy plugs in, how to turn on include-cleaner. + +### Where compile_commands.json comes from + +clangd's work depends on a file called `compile_commands.json`. This is the Compilation Database format defined by the Clang community: one record per `.cpp` in the project, recording the full command used to compile it, the compiler path, the `-std=` standard, all the `-I` header search paths. With that file, clangd can "stand where the compiler stands" and look at the code, knowing which header `std::vector` comes from and which features are available under `-std=c++17`. + +CMake makes this trivial, one line. After `project()` in `CMakeLists.txt`, add: +```cmake +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +Or, if you'd rather not touch `CMakeLists.txt`, pass `-DCMAKE_EXPORT_COMPILE_COMMANDS=ON` on the configure command line. After configure, `build/compile_commands.json` is generated. + +::: warning This switch only works for Makefile / Ninja generators +`CMAKE_EXPORT_COMPILE_COMMANDS` only emits `compile_commands.json` when you're using a Makefile or Ninja generator. The Visual Studio generator (`-G "Visual Studio 17 2022"`) and the Xcode generator don't support it. In WSL we default to Ninja, so this is a non-issue. +::: + +After configure, `build/compile_commands.json` looks like this (real output from my machine): + +```json +[ + { + "directory": "/home/user/wsl-clangd/build", + "command": "/usr/bin/c++ -I/home/user/wsl-clangd -std=c++17 -o CMakeFiles/greeter.dir/main.cpp.o -c /home/user/wsl-clangd/main.cpp", + "file": "/home/user/wsl-clangd/main.cpp", + "output": "/home/user/wsl-clangd/build/CMakeFiles/greeter.dir/main.cpp.o" + } +] +``` + +One entry per `.cpp`. The `command` field is the load-bearing one: clangd parses it to get the compiler, the standard, the header paths, then understands the code from that viewpoint. So if you change `CMakeLists.txt` (say, adding a new `target_include_directories`), you have to reconfigure to refresh `compile_commands.json`, or clangd keeps using the old viewpoint, never learns the new header path, and the red squiggles come back. + +### How clangd finds compile_commands.json + +The official behavior is: clangd takes the source file you're editing, walks up its directory chain looking for `compile_commands.json`, and uses the first one it finds. So if your source is at `~/proj/src/foo.cpp`, clangd checks in this order: + +```text +~/proj/src/compile_commands.json +~/proj/compile_commands.json +~/compile_commands.json +~/.../compile_commands.json +``` + +clangd 16 added one more rule: at each directory along the way, it also peeks at that directory's `build/` subdirectory for a `compile_commands.json`. This was added specifically as a convenience for CMake projects, since CMake writes the file into `build/` by default and clangd knows to look there. + +I tested on clangd 22 with the source in `src/` and `compile_commands.json` in `build/`, no symlink at the project root, and clangd still found it: + +```text +I[11:23:59.322] Loading compilation database... +I[11:23:59.323] Loaded compilation database from /tmp/clangd-search-test/build/compile_commands.json +``` + +So in the default case you don't need to do anything. But two situations still call for pointing at it manually. + +First situation: you use multiple build directories (say `build-debug/` and `build-release/`), and clangd can't tell which to pick and may bounce between them. Pin it down in `.clangd`: + +```yaml +CompileFlags: + CompilationDatabase: build-debug +``` + +The `CompilationDatabase` field takes a directory path (relative to the project root), or `Ancestors` (the default behavior, walk up + peek into `build/`), or `None` (turn it off, fall back only). + +Second situation: an old clangd (15 or earlier) doesn't have the "peek into `build/`" rule and really only walks parent directories looking for `compile_commands.json` at the root. In that case the project root needs a symlink: + +```bash +ln -sf build/compile_commands.json compile_commands.json +``` + +When clangd walks up, it hits the symlink at the root and follows it to the real file in `build/`. New clangd doesn't need this, but it does no harm and keeps old clangd happy. + +### The .clangd config file, field by field + +`.clangd` is clangd's project-level config, YAML format, placed at the project root. clangd walks up the source file's directory chain looking for `.clangd`, merges every hit in order, and the one closest to the source file wins. The config below is what I use in practice (also in the repo at `code/examples/vol7/wsl-clangd/.clangd`); I'll walk through each section and what it does: + +```yaml +CompileFlags: + Add: [-Wall, -Wextra, -Wno-unused-parameter] + Remove: [-fsanitize=thread] + Compiler: clang++ + CompilationDatabase: build +``` + +The `CompileFlags` section post-processes the compile command out of `compile_commands.json`. `Add` appends flags to every command: `-Wall -Wextra` makes clangd's diagnostics as strict as a real compile, and `-Wno-unused-parameter` lets it ignore the kind of parameter that has to exist but doesn't get used (typical in callbacks). `Remove` wipes flags via wildcard; the canonical case is `-fsanitize=thread` showing up in `compile_commands.json` (TSan, covered in volume 5), which clangd doesn't need to re-run and re-running it produces bizarre diagnostics. `Compiler` swaps the compiler executable name for a specified value; writing `clang++` makes clangd use Clang's own driver to probe system headers and ABI, which is especially handy in cross-compilation (when the original compiler is `arm-none-eabi-g++` and clangd can't find the sysroot, swapping in `clang++` with `--query-driver` fixes it). `CompilationDatabase` was covered above, the directory holding compile_commands. + +```yaml +Index: + Background: Build + StandardLibrary: Yes +``` + +The `Index` section governs clangd's index. `Background: Build` turns on the background index (the thing grinding away the first time you open a project), and the index lands on disk under `~/.cache/clangd/index/` and gets reused next time you open the same project, so it doesn't start from scratch. `StandardLibrary: Yes` folds the standard library symbols into the index, so typing `std::` actually completes `vector`, `cout`, and friends. Both are on by default; spelling them out is just for explicitness. + +```yaml +InlayHints: + Enabled: Yes + ParameterNames: Yes + DeducedTypes: Yes + Designators: Yes + BlockEnd: Yes +``` + +`InlayHints` is clangd 18+'s inline hints, gray dashed text rendered right inside the code line. `ParameterNames: Yes` shows the parameter name at call sites, `greet(/*name=*/"WSL")`, so you don't have to keep flipping back to the declaration to check what a parameter is called. `DeducedTypes: Yes` shows the type `auto` deduced, `auto /*= int*/ sum`. `Designators: Yes` shows field names in aggregate initialization, `Point{/*.x=*/1, /*.y=*/2}`. `BlockEnd: Yes` shows what a closing `}` belongs to (which function, which namespace), so the `}` at the end of a multi-thousand-line function is no longer a mystery. The vscode clangd extension doesn't enable this group by default; turning it on lifts code readability a noticeable step. + +```yaml +Diagnostics: + ClangTidy: + Add: [modernize-*, bugprone-*, performance-*, readability-*] + Remove: [modernize-use-trailing-return-type, readability-magic-numbers] + UnusedIncludes: Strict + MissingIncludes: Strict + Suppress: [unused-includes] +``` + +The `Diagnostics` section governs the red/yellow squiggles. `ClangTidy.Add/Remove` makes clangd run clang-tidy checks right in the editor, no terminal needed. With `modernize-*` on, writing `NULL` prompts a suggestion to use `nullptr`, writing `for (int i = 0; i < v.size(); ++i)` prompts a suggestion to use a range-based for. `Remove` silences noisy checks: `modernize-use-trailing-return-type` forces the `auto foo() -> int` style, the community has argued about it for years, and most projects don't want it. `UnusedIncludes: Strict` and `MissingIncludes: Strict` turn on clangd's built-in include-cleaner, flagging both "included but unused" and "used but not included". **When you're new to a project, leave these two off first**, or one toggle lights the screen up with yellow squiggles and makes you want to uninstall clangd outright. `Suppress` silences a specific diagnostic code, more precise than toggling a check. + +```yaml +Hover: + ShowAKA: Yes +``` + +The `Hover` section governs the mouse-hover tooltip. `ShowAKA: Yes` makes typedef/using aliases show the underlying type on hover, so hovering over `size_type` reveals `std::size_t` underneath. + +### Key items in the clangd extension's settings.json + +The `.clangd` file controls the clangd program's behavior. The vscode clangd extension has its own set of options in `settings.json`. Below are the key items that pair with `.clangd` (the full version is at `code/examples/vol7/wsl-clangd/.vscode/settings.json`): + +```json +{ + "C_Cpp.intelliSenseEngine": "disabled", + "clangd.arguments": [ + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--all-scopes-completion", + "--function-arg-placeholders", + "--pch-storage=disk", + "--inlay-hints", + "--j=4" + ], + "clangd.onConfigChanged": "restart" +} +``` + +`C_Cpp.intelliSenseEngine: disabled` is the core step from piece 5, switching off the C/C++ extension's code understanding so clangd owns it. `clangd.arguments` is the command-line args clangd starts with. `--background-index` explicitly turns on the background index, `--clang-tidy` turns on the clang-tidy integration (paired with `.clangd`'s `Diagnostics.ClangTidy` and the `.clang-tidy` file), `--header-insertion=iwyu` makes completion auto-add the `#include`, `--all-scopes-completion` lets completion cross namespace boundaries (you can complete global symbols from inside a namespace), `--function-arg-placeholders` makes function completion carry parameter placeholders, `--pch-storage=disk` writes PCH to disk to save memory, `--inlay-hints` enables the inline hints (clangd 18+), `--j=4` is the background parallelism. + +`clangd.onConfigChanged: restart` is the load-bearing one: when you change `.clangd`, clangd restarts itself and picks up the new config. Without it, every `.clangd` edit needs a manual `Ctrl+Shift+P` → `clangd: Restart language server` to take effect. + +### Background Index: a slow first open on a big project is normal + +Open a project with tens of thousands of lines and clangd will pin the status bar spinning for several minutes after startup. That's the background index running: it's parsing every source file, extracting symbols and reference relationships, and writing them to `~/.cache/clangd/index/`. After the first run, the index gets reused and the second open is fast. + +To verify it's actually doing work, look at the clangd output panel (`View → Output → clangd`); you'll see logs like this: + +```text +I[15:32:11.456] Indexing xxx.cpp +I[15:32:11.612] Indexed preamble symbols: 1240 +I[15:32:11.738] Background: 1450 indexed, 0 dirty +``` + +If the project is genuinely huge (something like Chromium), the index can eat several GB of memory. If your machine can't take it, turn off the background index with `Background: Skip` or `--background-index=0`. The cost is slower cross-file jumps and completion, since no cross-file index gets built. For most projects, leaving it on is fine. + +### clang-tidy integration + +clangd's built-in clang-tidy integration puts static checks directly in the editor, no terminal switching. The way it works: + +Drop a `.clang-tidy` file (YAML) at the project root listing which checks to enable: + +```yaml +Checks: > + -*, + modernize-*, + bugprone-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -readability-identifier-length +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +FormatStyle: file +``` + +The first item in `Checks`, `-*`, turns off all default checks; after that, globs like `modernize-*` turn groups on. The `-` prefix means off. `HeaderFilterRegex` decides which headers clang-tidy inspects; `.*` means all of them, and you'd narrow it to a regex matching only your own headers if third-party libraries generate too much noise. + +clangd reads this file automatically at startup. With `--clang-tidy` on in `settings.json`, every line you edit, clangd runs the relevant clang-tidy checks alongside, and problems get drawn as yellow/red squiggles in the editor. + +I tested a snippet that triggers `readability-identifier-length`: + +```text +$ cat tidy_demo.cpp +#include int main() { - std::cout << "Hello from WSL!" << std::endl; + int big = 1000000000; + long narrowed = big; + int* p = nullptr; // ← name too short, under 3 chars gets flagged by the check return 0; } + +$ clang-tidy -p build tidy_demo.cpp +... tidy_demo.cpp:5:10: warning: variable name 'p' is too short, + expected at least 3 characters [readability-identifier-length] + 5 | int* p = nullptr; + | ^ ``` -Create `CMakeLists.txt`: +The same diagnostic shows up in vscode as a yellow squiggle under the variable name `p`, with `[readability-identifier-length]` on hover. With the clangd integration, you don't open a terminal; you write the code and the problem just appears. -```cmake -cmake_minimum_required(VERSION 3.10) -project(HelloWSL) +### include-cleaner -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED True) +clangd's built-in include-cleaner (no external clang-tidy needed) targets exactly two include pathologies: included but unused, and used but not included. The switches live in the `Diagnostics` section of `.clangd`: -add_executable(hello_wsl src/main.cpp) +```yaml +Diagnostics: + UnusedIncludes: Strict # None = off, Strict = strict on + MissingIncludes: Strict ``` -Build (in the WSL terminal or VS Code's integrated terminal): +My advice: **for new projects, turn it on from day one** so include hygiene is clean from the source; **for taking over an old project, start with `None`**, since legacy code carries heavy include baggage and flipping to Strict lights up the screen with yellow and robs you of judgment. Tidy the code first, then turn it on. -```bash -mkdir build && cd build -cmake .. -cmake --build . +include-cleaner also supports IWYU pragmas, written in headers to instruct the tool: + +```cpp +#include // IWYU pragma: export +#include "detail_helpers.h" // IWYU pragma: keep ← don't flag this even if unused ``` -If you installed and are using the **CMake Tools** extension: Open the project root directory. The extension will provide **Build** and **Debug** buttons in the status bar at the bottom. You can click these to build or debug, and select different kits (gcc/clang) and build directories. +`export` says "this header includes `` on behalf of users, so users don't need to include it themselves"; `keep` says "don't mark this include as unused". Both pragmas see heavy use in large libraries to suppress false positives from include-cleaner. + +### clangd or the C/C++ extension (aligned with the getting-started piece) + +At this point you might ask: should I uninstall the C/C++ extension? No. Consistent with [getting-started piece 5](/getting-started/05-vscode-clangd): + +- clangd handles "understanding the code": completion, jump-to-def, errors, hover, inlay hints, clang-tidy. Accurate. +- The C/C++ extension stays for "debugging": breakpoints, stepping, variable inspection, call stack. Its `cppdbg` debugger is the most mature gdb/lldb solution in vscode. + +So `C_Cpp.intelliSenseEngine: disabled` switches off the C/C++ extension's code understanding; the extension itself stays installed. The two divide labor and don't fight. The debugging below uses the C/C++ extension's `cppdbg`. ------- +## Debug configuration: launch.json -## Configuring Debugging in VS Code (Using gdb from ms-vscode.cpptools) +Once the project builds and clangd can jump around, the last link is debugging: set breakpoints, step, inspect variables. This section finishes the spot where the original draft cut off at "switch to the debug panel and click". -Create `.vscode/launch.json` in your project directory (using the `cpptools` generator): +Debugging C++ in vscode goes through `.vscode/launch.json`. Here's a complete, working config (also in the repo at `code/examples/vol7/wsl-clangd/.vscode/launch.json`), using the C/C++ extension's `cppdbg` + gdb: ```json { "version": "0.2.0", "configurations": [ { - "name": "(gdb) Launch", + "name": "(gdb) Launch greeter", "type": "cppdbg", "request": "launch", - "program": "${workspaceFolder}/build/hello_wsl", + "program": "${workspaceFolder}/build/greeter", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb", + "miDebuggerPath": "/usr/bin/gdb", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } - ] + ], + "preLaunchTask": "build" } ] } ``` -The `"program"` field requires the file path to your application. `${workspaceFolder}` refers to the directory you currently have open in VS Code. Since the build output is placed in the `build` folder, you will find your generated application there. +Field by field. `type: cppdbg` is the debugger type the C/C++ extension provides, driving gdb over gdb's MI protocol. `program` is the full path to the executable you're debugging; `${workspaceFolder}` is the project root vscode currently has open. `MIMode: gdb` paired with `miDebuggerPath: /usr/bin/gdb` tells it to use the gdb inside WSL. `preLaunchTask: build` runs a task named `build` (defined in tasks.json below) before F5 fires; if the build fails, debugging doesn't start, saving you from debugging a stale binary. + +The `-enable-pretty-printing` in `setupCommands` is the key item. Without it, when you break on a `std::vector v{1,2,3,4,5}`, the variables panel shows a pile of raw members (`_M_start`, `_M_finish`, `_M_end_of_storage`, those libstdc++ internal pointers) and you have no way to tell the vector actually holds `{1,2,3,4,5}`. With it on, gdb uses its Python pretty-printers to format it into something readable. Here's the real gdb output comparison on my machine: + +```text +(gdb) print nums # nums is std::vector{1,2,3,4,5} + +Without pretty-printing: $1 = {_M_impl = {_M_start = 0x555..., _M_finish = ..., _M_end_of_storage = ...}} +With pretty-printing: $1 = std::vector of length 5, capacity 5 = {1, 2, 3, 4, 5} +``` + +Once `setupCommands` is wired up, the variables panel shows the readable second form. This is the step newbies miss most often: debugging works but variables are unreadable, so the breakpoint might as well not be there. + +::: tip CodeLLDB as an alternative +If you prefer lldb, install the CodeLLDB extension (`vadimcn.vscode-lldb`) plus `sudo apt install lldb` in WSL, and switch launch.json to `"type": "lldb"`. CodeLLDB doesn't go through the MI protocol; it drives lldb directly, starts faster, and renders C++ types more nicely (no pretty-printing config needed, it's built in). This tutorial standardizes on gdb, though, so the examples below all assume gdb. +::: + +With that configured, click in the gutter to the left of `main.cpp` line 14 (the `for (int x : nums)` line) to set a red breakpoint, then press `F5`. vscode first runs the `build` task to recompile, then launches gdb to load `build/greeter`, and stops at the breakpoint. The Run and Debug panel on the left shows the call stack, variables, breakpoints, and watch. Expand `nums` in the variables panel and you get `std::vector of length 5, capacity 5 = {1, 2, 3, 4, 5}`, and `sum` is the current accumulated value. `F10` steps over, `F11` steps into, `F5` continues. + +## tasks.json build tasks + +The `preLaunchTask: build` in launch.json needs a matching task. Tasks live in `.vscode/tasks.json`: + +```json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "cmake", + "args": [ + "--build", + "${workspaceFolder}/build", + "--config", + "Debug", + "--parallel" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": ["$gcc"] + }, + { + "label": "configure", + "type": "shell", + "command": "cmake", + "args": [ + "-S", "${workspaceFolder}", + "-B", "${workspaceFolder}/build", + "-G", "Ninja", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON" + ], + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [] + }, + { + "label": "rebuild", + "dependsOn": ["configure", "build"], + "dependsOrder": "sequence", + "group": "build", + "problemMatcher": [] + } + ] +} +``` + +Three tasks, each with a job. `build` runs the incremental build (`cmake --build build`, with Ninja underneath); it's the default build task (`isDefault: true`), so `Ctrl+Shift+B` triggers it directly. `configure` runs on first build or after you've changed `CMakeLists.txt`, reconfiguring once to refresh `compile_commands.json`. `rebuild` uses `dependsOrder: sequence` to run configure then build in order, all in one shot. + +`problemMatcher: ["$gcc"]` makes vscode parse the compiler output, turning errors/warnings into clickable items in the Problems panel, where a click jumps to the corresponding line. This is vscode's built-in `$gcc` matcher, which matches the gcc/clang error format. + +The chain triggered by F5 in launch.json is: run the `build` task → build succeeds → launch gdb to load `build/greeter` → run to the breakpoint and stop. The whole debug loop closes up, with no need to flip over to a terminal and type `cmake --build` each time. + +## Where this leaves you + +With WSL2 + vscode + clangd + cppdbg all wired up, your C++ engineering environment is barely distinguishable from what a seasoned Linux developer uses: accurate completion, fast jumps, strict errors, and debugging that can actually show a vector. From here, reading volume 7 ch00's CMake series (the target mental model, CMakePresets.json) and volume 6's memory safety (AddressSanitizer + valgrind), all the commands go straight into the WSL terminal and the output matches what's in the articles. -If you use `tasks.json` to define custom build tasks, ensure the `"preLaunchTask"` name matches; however, if you use CMake Tools, it automatically creates and manages build tasks and debug configurations, which is usually more convenient. In this case, simply switch to the VS Code **Run and Debug** view and click the **Start Debugging** button (or press F5). +Every config file that goes with this piece (`.clangd`, `.clang-tidy`, `.vscode/settings.json`, `launch.json`, `tasks.json`) lives in the repo at `code/examples/vol7/wsl-clangd/`. Clone it and it runs out of the box. The CMake project is minimal and reproducible: `cmake -B build -G Ninja && cmake --build build` produces `build/greeter`, and F5 drops you into the debugger. diff --git a/documents/en/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md b/documents/en/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md new file mode 100644 index 000000000..f0c15f1f7 --- /dev/null +++ b/documents/en/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md @@ -0,0 +1,327 @@ +--- +title: "Embedded clangd: making VS Code understand cross-compiled code" +description: "Port the host-platform clangd you set up in three steps to an arm-none-eabi-g++ cross project and the editor drowns you in red squiggles. This piece digs into the root cause and hands you a copy-pasteable query-driver and .clangd config." +chapter: 14 +order: 6 +platform: stm32f1 +difficulty: intermediate +cpp_standard: [17, 20] +tags: + - stm32f1 + - 嵌入式 + - intermediate + - clangd + - 交叉编译 +reading_time_minutes: 16 +prerequisites: + - "Chapter 14: 第1篇 从零搭建 STM32 开发工具链" + - "Chapter 14: CMake 配置篇" +related: + - "让 vscode 看懂您的代码——装 clangd,红线消失" + - "交叉编译和CMake简单指南" +--- + +# Embedded clangd: making VS Code understand cross-compiled code + +## Opening + +In the getting-started volume, piece 5, we set up clangd for the host platform, and the flow was short: ditch Microsoft's C/C++ extension, install the clangd extension, feed it `compile_commands.json`, and your code gets smart—jump-to-definition and completion all in one go. The end of that piece hammered one point home: clangd "understands" your code because of what it reads from `compile_commands.json`, every single compile command—which compiler, which flags, where `-I` points, what the target platform is. + +Take that same setup into an embedded project, and odds are you'll be staring at the screen within seconds. + +Open `main.c`, and the very first line `#include "stm32f1xx.h"` gets a red squiggly. `HAL_GPIO_WritePin` and the rest of the HAL functions are nowhere to be found. `stdint.h`, `core_cm3.h`, one after another, all painted red—clangd looks blind. Meanwhile `cd build && ninja` builds fine, you flash the board, and the LED blinks. The compiler clearly knows about these headers. Why doesn't clangd? + +This piece is the cure. We'll tear the root cause apart, then hand you a config you can copy verbatim. The repo's `code/stm32f1-tutorials/*/.vscode/settings.json` has been using this setup the whole time, but there's never been a doc explaining what it actually does. This is that doc. + +## Why everything goes red: clangd is making paths up + +First, recall why the host setup works. In a host project, the compile command clangd sees looks like this: + +```text +/usr/bin/g++ -std=c++20 -I/home/you/proj/include main.cpp +``` + +The compiler is `g++`, and clangd can work with that command because it knows `g++`'s header layout inside out—`/usr/include/c++/14`, `/usr/include`, that whole set of standard paths is baked in, ready to use. + +Move to an embedded project, and the compile command clangd reads from `compile_commands.json` becomes this: + +```json +{ + "directory": "/home/you/proj/build", + "command": "/usr/sbin/arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb -I.../Drivers/CMSIS/Device/ST/STM32F1xx/Include main.cpp -c -o CMakeFiles/main.dir/main.cpp.o", + "file": "../main.cpp" +} +``` + +Notice the compiler changed from `g++` to `arm-none-eabi-g++`. Here's the catch: clangd is built on clang, and it **does not know where this GNU cross-compiler keeps its headers internally**. What it knows about GCC's built-in path layout, it inferred from the local `g++`. ARM's newlib headers, ARM's libstdc++ headers, the CMSIS `core_cm3.h`—none of it is on its radar. + +So what does it do? Since version 14, for a compiler it "doesn't recognize," clangd falls back to a fictional toolchain called **BareMetal** (target `arm-none-eabi`). This fictional toolchain fabricates a pile of paths that typically look like this: + +```text +clang-runtimes/arm-none-eabi/include +clang-runtimes/arm-none-eabi/include/c++ +clang-runtimes/arm-none-eabi/share +``` + +Go `find` that under the repo root, under `/usr/lib`, anywhere—`clang-runtimes/arm-none-eabi` doesn't exist. It's a path clangd hallucinated as "where this ought to live." The system headers `stdint.h`, the CMSIS header `core_cm3.h`—they aren't in these phantom paths, so clangd can't find them, and everything goes red. + +::: warning The disease isn't that clangd is dumb +The root cause is that clangd **never asked the actual cross-compiler** where its headers live. It's overlaying its own built-in, clang-runtime-based guess onto a GCC toolchain, and GCC's header layout is a completely different beast from the clang runtime directory. The guess is wrong, the paths are empty, the headers are gone. +::: + +One more knife-twist. Even if clangd guessed the paths, it still wouldn't know the cross-compiler's built-in macros. Let's check what `arm-none-eabi-g++` predefines when no `-mcpu` is given: + +```bash +$ arm-none-eabi-g++ -E -dM -xc++ /dev/null | grep -E "__ARM_ARCH|__arm__|__thumb__" +#define __ARM_ARCH_ISA_ARM 1 +#define __ARM_ARCH_ISA_THUMB 1 +#define __ARM_ARCH_4T__ 1 +#define __ARM_ARCH 4 +#define __arm__ 1 +``` + +Notice it defaults to `__ARM_ARCH_4T__`, ARMv4, not the ARMv7-M that Cortex-M3 actually is. Cortex-M3 is ARMv7-M, Thumb-2 only—nothing like this default target. Headers like `core_cm3.h` and `cmsis_gcc.h` branch on macros like `__ARM_ARCH_7M__` to pick a code path. clangd parses without those macros, gets a result that doesn't match what the real compiler produces, and trips the occasional `#error` inside a header. The screen gets even redder. + +## query-driver: make clangd actually ask the compiler + +clangd has a mechanism that's exactly the cure for this. It's called **query-driver**. + +The principle is blunt: instead of guessing, clangd **actually executes the compiler you specified**, running this command: + +```bash +arm-none-eabi-g++ -E -xc++ -v /dev/null +``` + +That tells the cross-compiler to preprocess an empty file and print verbose info. GCC dumps its internal header search paths and built-in macro definitions to stderr. Let's run it locally (`arm-none-eabi-g++ 16.1.0`): + +```text +#include "..." search starts here: +#include <...> search starts here: + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0 + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0/arm-none-eabi + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0/backward + /usr/lib/gcc/arm-none-eabi/16.1.0/include + /usr/lib/gcc/arm-none-eabi/16.1.0/include-fixed + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include +End of search list. +``` + +These paths **actually exist**. `/usr/arm-none-eabi/include` is newlib's C headers, `/usr/.../include/c++/16.1.0` is the libstdc++ that ships with newlib. clangd grabs these as system headers, pairs them with the `-mcpu=cortex-m3 -mthumb -I.../Drivers/...` it read from `compile_commands.json`, feeds all of that to its internal clang, and the code parses correctly. + +::: warning Why it's off by default +query-driver means letting clangd **execute an arbitrary binary**. Picture this: you clone a project of unknown provenance, its `.clangd` says `Compiler: /tmp/evil.sh`, and clangd happily runs that thing as a "compiler" the moment it starts. That can't happen silently. So clangd refuses query-driver by default; you have to explicitly allowlist which compiler paths may be executed. This is a security call, not a bug. +::: + +### The three-piece config + +To make query-driver actually take effect, three places have to be set up together. Let's go one by one. + +### Piece one: add --query-driver to VS Code's clangd.arguments + +Open the project's `.vscode/settings.json` and add the `--query-driver` argument: + +```json +{ + "clangd.arguments": [ + "--query-driver=/usr/sbin/arm-none-eabi-g++,/usr/sbin/arm-none-eabi-gcc" + ] +} +``` + +After the equals sign comes a **comma-separated list of absolute paths**, and globs (`*`, `?`) are supported. clangd will only execute compilers whose path matches one of these globs; everything else is refused. Here we've allowlisted `arm-none-eabi-g++` and `arm-none-eabi-gcc`, covering both C++ projects and pure-C projects. + +::: warning Use absolute paths here, not command names +`--query-driver` has to be absolute paths or globs over absolute paths. Writing `--query-driver=arm-none-eabi-g++` does nothing—clangd doesn't search `PATH`; it just decides there's no match and refuses to run. Where the toolchain lives on your machine varies, so adjust the path (we'll get to why the repo uses `/usr/sbin/` below). +::: + +### Piece two: CompileFlags.Compiler and BuiltinHeaders in the project-root .clangd + +Allowlisting alone isn't enough. clangd also needs to know "this project should be parsed with `arm-none-eabi-g++`," and "its built-in headers come from query-driver, not from clangd's own." Create a `.clangd` file at the project root: + +```yaml +CompileFlags: + Compiler: arm-none-eabi-g++ + Add: + - -mcpu=cortex-m3 + - -mthumb + BuiltinHeaders: QueryDriver +``` + +Line by line, here's what these four do. + +`Compiler: arm-none-eabi-g++` tells clangd: in this project's compile commands, **replace** the executable with `arm-none-eabi-g++` (any name resolvable on PATH works; absolute path not required). That way, even if `compile_commands.json` says something else (a relative path CMake produced, say), clangd forces the cross-compiler. + +`Add` **appends** these flags to every compile command. Embedded projects usually already have `-mcpu=cortex-m3 -mthumb` written into CMake, so `compile_commands.json` carries them, and this line is somewhat redundant—but it's safer to keep, because older CMake scripts don't always propagate both flags to every target. `-mcpu=cortex-m3` sets the target to Cortex-M3; `-mthumb` forces the Thumb instruction set. Both are non-negotiable. + +`BuiltinHeaders: QueryDriver` is the punchline. It switches the source of built-in headers (`stdint.h`, `stddef.h`, the ones GCC ships) from "the `clang-runtimes/...` phantom paths clangd makes up" to "the real paths query-driver pulls from the compiler." Those red squiggles from before are mostly healed by this single line. + +### Piece three: compile_commands.json has to carry the cross flags + +Configuring clangd alone isn't enough; the `compile_commands.json` it reads has to be the cross-compiled version too. The next piece covers this in detail, but in one sentence: CMake uses a toolchain file plus `CMAKE_EXPORT_COMPILE_COMMANDS`, and the resulting JSON carries `-mcpu=cortex-m3/-mthumb/-I.../Drivers/...` natively. clangd reads that, knows it's compiling for ARM, and stops guessing toward the host side. + +## Where compile_commands.json comes from + +The big difference between an embedded project's CMake and a host project's is that the embedded one passes a **toolchain file**. That file looks like this (`arm-none-eabi.cmake`): + +```cmake +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_SYSTEM_PROCESSOR cortex-m3) + +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +set(CMAKE_CXX_COMPILER arm-none-eabi-g++) + +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +set(MCU_FLAGS "-mcpu=cortex-m3 -mthumb") +set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}") +set(CMAKE_CXX_FLAGS_INIT "${MCU_FLAGS}") +``` + +`CMAKE_SYSTEM_NAME Generic` tells CMake "the target has no OS" (bare metal). `CMAKE_C_COMPILER` / `CMAKE_CXX_COMPILER` pick the cross-compiler. The `CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY` line is easy to miss—by default CMake builds a try-run executable during configure to validate the compiler, but an ARM executable produced by the cross-compiler can't run on the host, so the try-run fails. This line switches it to a static library and skips the run. + +Add one line to the project-root `CMakeLists.txt`: + +```cmake +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +Remember to pass the toolchain when you configure: + +```bash +cmake -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +::: details Full build commands (collapsible) + +```bash +# Wipe the old build and re-configure so compile_commands.json is the cross version +rm -rf build +cmake -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +ninja -C build + +# Check whether the generated commands carry -mcpu +grep -m1 "mcpu" build/compile_commands.json +``` + +::: + +In the generated `build/compile_commands.json`, the `command` for every `.cpp` carries `-mcpu=cortex-m3 -mthumb`. clangd reads that, knows the target is Cortex-M3 with the Thumb instruction set, and combined with the newlib headers from query-driver, the whole parsing loop closes. + +::: warning Don't let CMake eat the flags +Some CMake templates write `-mcpu=cortex-m3 -mthumb` as `target_compile_options(... PRIVATE -mcpu=cortex-m3 -mthumb)`, which is correct and lands in `compile_commands.json`. But if you write it via `add_compile_options` layered with `interface`, or stash it in `CMAKE__FLAGS` and let a generator expression swallow it, the flags might not propagate. After configuring, always run `grep "mcpu" build/compile_commands.json` to verify. If the flag didn't make the JSON, clangd doesn't get it. +::: + +## The config the project already ships + +All that theory out of the way—the repo's `code/stm32f1-tutorials/` projects already have this set up. Take `0_start_our_tutorial`; its `.vscode/settings.json` is just five lines: + +```json +{ + "clangd.arguments": [ + "--query-driver=/usr/sbin/arm-none-eabi-g++,/usr/sbin/arm-none-eabi-gcc" + ] +} +``` + +`1_led_control`, `2_button_control`, `3_uart_logger`—their `.vscode/settings.json` is identical. **This is what the repo itself uses; just copy it.** + +One detail to clear up: why is the path `/usr/sbin/` and not `/usr/bin/`? + +It depends on how the toolchain got installed. This machine uses the MSYS2-style package manager (WSL2 + pacman), and the `arm-none-eabi-gcc` package puts the actual compiler under `/usr/sbin/`, while `/usr/bin/` holds the more commonly used tools. A quick `ls` confirms: + +```bash +$ ls -l /usr/sbin/arm-none-eabi-g++ +-rwxr-xr-x 2 root root 1.7M arm-none-eabi-g++ 16.1.0 + +$ which arm-none-eabi-g++ +/usr/sbin/arm-none-eabi-g++ +``` + +::: details Ubuntu / Arch / Homebrew paths all differ + +| Platform | Typical path | +|---|---| +| MSYS2 / WSL2 + pacman | `/usr/sbin/arm-none-eabi-g++` | +| Ubuntu apt (`gcc-arm-none-eabi` package) | `/usr/bin/arm-none-eabi-g++` | +| Arch pacman | `/usr/bin/arm-none-eabi-g++` | +| macOS Homebrew | `/opt/homebrew/bin/arm-none-eabi-g++` | + +Run `which arm-none-eabi-g++` and paste the output into `--query-driver`. If you're not sure, just use a glob: `--query-driver=/usr/*/arm-none-eabi-g*,/opt/*/arm-none-eabi-g*` covers the common spots. + +::: + +Note that this `.vscode/settings.json` **only configures query-driver** and ships no `.clangd`. The reason is that the `command` field in these projects' `compile_commands.json` already spells out `/usr/sbin/arm-none-eabi-g++` directly (CMake generated it with an absolute path). clangd sees the executable is the ARM toolchain, query-driver is on, and the header paths come straight from GCC. A `.clangd` with `BuiltinHeaders: QueryDriver` is essentially redundant once query-driver is on—clangd defaults to substituting the queried headers for its own built-ins. The `Compiler:` and `Add: [-mcpu...]` lines in a `.clangd` are the fallback you reach for only when `compile_commands.json` isn't clean enough, when the executable path or flags are wrong. + +## sysroot and --gcc-install-dir + +With the setup above, the red squiggles vanish in most cases. But every now and then a stray header still can't be found—typically when your `compile_commands.json` **carries no sysroot**, and clangd can't locate part of the newlib headers on its own. In that case, you patch in the sysroot. + +`--gcc-install-dir` is a clang flag that flat-out tells it "the GNU toolchain's libstdc++ lives in this directory," and clang derives header locations from there. Append it in `.clangd`: + +```yaml +CompileFlags: + Compiler: arm-none-eabi-g++ + Add: + - -mcpu=cortex-m3 + - -mthumb + - --gcc-install-dir=/usr/lib/gcc/arm-none-eabi/16.1.0 + BuiltinHeaders: QueryDriver +``` + +Or do it the old way with `-isystem` to explicitly add a system header directory (newlib's C headers, for instance): + +```yaml +CompileFlags: + Add: + - -isystem/usr/arm-none-eabi/include +``` + +When troubleshooting, crank up clangd's logging and see where it actually looks for headers: + +```text +View → Command Palette → Clangd: Open Log +``` + +Or pick clangd in VS Code's Output panel and search the log for `Search starts here` to check whether the search paths clangd ends up with actually cover the newlib directory. You should see a line like: + +```text +Query driver arm-none-eabi-g++ for include paths +``` + +That means query-driver really ran. If it's missing, the `--query-driver` glob probably didn't match, and clangd silently skipped the query. Go back to `.vscode/settings.json` and check the path. + +::: warning clangd has to be new enough +query-driver and `BuiltinHeaders: QueryDriver` need clangd **17 or newer**. The `gcc-install-dir` flag needs clangd **18+** (underneath, clang 18 has to recognize it). This machine runs clangd 19 with no problem; the clangd 14 or 15 that ships with older distros won't make this work, and you'll get stuck in the mystical state of "the config looks right but the log is dead silent." Run `clangd --version` first. +::: + +## Verifying + +After configuration, close VS Code and reopen it (or Command Palette → `Clangd: Restart language server`), then open `main.c`. Here's what you should see: + +1. The red squiggly on the first line `#include "stm32f1xx.h"` is gone. The file `stm32f1xx.h` lives under `Drivers/CMSIS/Device/ST/STM32F1xx/Include/`, and once query-driver supplies the sysroot plus CMake's `-I`, clangd finds it. +2. Hold `Ctrl` and click `HAL_GPIO_WritePin`; the cursor jumps to its declaration in `stm32f1xx_hal_gpio.h`. +3. Type `HAL_`, and the completion list pops up with `HAL_GPIO_WritePin`, `HAL_Delay`, `HAL_Init`, and friends. +4. The clangd log shows the line `Query driver arm-none-eabi-g++ for include paths`. + +At this point the cross-project clangd experience is on par with host projects—red squiggles gone, jump-to-definition and completion all there. + +::: details Verification checklist (consult this when something breaks) + +- [ ] `clangd --version` is 17+, ideally 18+ +- [ ] The `--query-driver` path glob matches the output of `which arm-none-eabi-g++` +- [ ] `grep "mcpu" build/compile_commands.json` returns something—confirms the cross flags made the JSON +- [ ] The clangd log shows `Query driver ... for include paths` +- [ ] `arm-none-eabi-g++ -E -xc++ -v /dev/null` prints the real include paths +- [ ] `.clangd`'s `Compiler:` and `BuiltinHeaders: QueryDriver` are correct (only needed when compile_commands isn't clean enough) + +::: + +## Closing + +The full-red meltdown clangd throws on embedded projects traces back to one thing: by default it never asks the actual cross-compiler where its headers live. The host-platform setup from getting-started piece 5 hid this, because clangd already knows the host compiler natively. The moment you switch to the ARM toolchain, you have to explicitly make clangd query the driver, pull in GCC's real paths, and close the parsing loop. + +The next piece links up with vol7's [A short guide to cross-compilation and CMake](/vol7-engineering/01-cross-compilation-and-cmake), which approaches cross-compilation from the host angle as an engineering discipline (multi-target builds, reusing toolchain files). This piece is the clangd-specific chapter on the embedded line, filling in the IDE side of things. If you haven't read [getting-started piece 5: install clangd, kill the red squiggles](/getting-started/05-vscode-clangd), it's worth going back to first—the "why" of this piece builds directly on the "how" of piece 5. diff --git a/documents/getting-started/01-editor-and-compiler.md b/documents/getting-started/01-editor-and-compiler.md new file mode 100644 index 000000000..c5e93c3f8 --- /dev/null +++ b/documents/getting-started/01-editor-and-compiler.md @@ -0,0 +1,121 @@ +--- +title: "编辑器、编译器是什么——写代码前先搞清楚两件事" +description: "动手敲代码之前,先把两个最基础的概念说清:用什么软件写、写完怎么变成能跑的程序" +chapter: 14 +order: 1 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 5 +--- + +# 编辑器、编译器是什么——写代码前先搞清楚两件事 + +您打算学 C++,但先别急着敲代码。有两件事得先搞清楚——用什么软件写代码、写完的代码怎么变成能跑的程序。听起来像废话,可这两件事要是含糊,后面装软件、报错排查全跟着乱。这一篇就把这两个最基础的问题说透。 + +## 代码就是纯文本 + +咱们先看一段最简单的 C++ 代码长什么样: + +```cpp +#include + +int main() { + std::cout << "你好,C++!" << std::endl; + return 0; +} +``` + +![截图:一段 C++ 代码的样子](images/notepad_cpp.png) + +这段代码存成一个文件,扩展名是 `.cpp`,比如叫 `main.cpp`。您要是好奇,用 Windows 自带的记事本双击打开它(或者右键「打开方式」选记事本),能看到一模一样的内容。说白了,`.cpp` 文件本质就是纯文本——一串英文字符加几个符号,跟您在记事本里敲的几个字没本质区别。 + +但您要是真拿记事本写代码,会发现几个让人崩溃的事。您把 `int` 打成 `itn`,记事本一声不吭,等代码跑不起来才发现。关键词 `int`、`return`、`include` 全是黑色,跟普通文字一个样,眼睛扫半天抓不住重点。`std::cout` 这种长名字,每回都得自己一个个字母敲全,记事本不会给您任何提示。 + +所以没人用记事本写代码。写代码得用专门的软件——这种软件叫**编辑器**。 + +## 编辑器和 IDE 不是一回事 + +```mermaid +flowchart LR + A["编辑器 vscode
轻、跨平台"] -->|装 C++ 扩展| B["能干 IDE 的活"] + C["IDE
Visual Studio"] --> D["开箱即用
编辑+编译+调试一体"] +``` + +编辑器是写代码的专用软件,比记事本强在几个地方。第一,语法高亮——给关键词上色,`int` 蓝的、字符串绿的,一眼能看出结构。第二,自动补全——您敲 `std::co`,它弹个小框提示 `cout`,按 Tab 就补全。第三,错误标红——`itn` 这种笔误当场画红线,不用等编译。 + +市面上编辑器不少,咱们这套教程统一用 **vscode**(全称 Visual Studio Code,微软出品)。理由很实在:免费、Windows/Linux/Mac 都能装、插件最多、网上教程一搜一大把。您要是已经在用别的,跟着这套教程装一个 vscode 也不亏。 + +> 嘿嘿,笔者正在写这篇教程的Markdown文档截图 +![vscode 编辑器界面](images/vscode.png) + +还有一类软件叫 **IDE**(集成开发环境),跟编辑器容易混。IDE 把「写代码、编译、调试、运行」全打包在一起,开箱即用,不用您自己一样样拼。微软的 Visual Studio(注意,跟 vscode 不是同一个东西,名字像但产品不同 ,笔者一般跟同事交流都是——你VSCode(读作V, S, Code)怎么个反应呢?)就是 IDE,Windows 上写 C++ 很流行。CLion 是另一个,JetBrains 家的,要收费。 + +VS长这样,这是笔者在造一个简单的GUI框架的时候的截图: + +![Visual Studio IDE 界面](images/vs.png) + +vscode 严格说是个编辑器,刚装好它只会高亮和补全,编译得自己想办法。但它的妙处在于「**扩展**」(extension,可以理解成插件)——装上 C++ 相关的扩展后,编辑器能干 IDE 大部分活。咱们后面就是这么用它。所以您别被「vscode 是编辑器不是 IDE」这句话吓到,实际用起来差别没字面那么大。 + +::: details 点开看:编辑器和 IDE 到底选哪个 + +- 编辑器(vscode 这类):轻、灵活、跨平台,要装扩展才完整。 +- IDE(Visual Studio 这类):重、开箱即用、调试器强,但绑平台(VS 主力在 Windows)。 +- 新手实在拿不准就 vscode——这套教程也是按 vscode 走的,跟着装一遍最省事。 +::: + +## 编译器:把代码翻译成程序 + +到这里有个关键的事得点破:您写的 `.cpp` 是给人看的,**计算机其实跑不了。**, 就这个事情我要多强调好几次!计算机从来只认识0和1,他真的看不懂你写的一大堆给人看的东西! + +计算机只会跑它自己认识的程序,Windows 上就是 `.exe` 文件——您平时双击就开起来的那些软件,比如浏览器、QQ,腾讯视频等等全是 `.exe`,也就是二进制的程序,这些计算机才认识。 + +> 我知道有人要准备雄起了——我是Linux用户!不吃你们exe这套,好吧,那ELF多少本质也算二进制程序的 + +`.cpp` 是一堆英文字符,`.exe` 是计算机的母语,两边语言不通。所以中间得有个翻译过程,把 `.cpp` 翻译成 `.exe`。 + +干这个翻译活的,叫**编译器**。翻译这个动作,叫「**编译**」。 + +```mermaid +flowchart LR + A["main.cpp
源代码,纯文本"] -->|编译| B["编译器
gcc / clang / cl"] + B --> C["hello.exe
可执行程序"] +``` + +常见的 C++ 编译器有这么几家: + +- **MSVC**:微软自家的,Visual Studio 里自带,Windows 上写 C++ 很顺手。 +- **GCC**:GNU 项目开源的,Linux 用得多,Windows 上常通过一个叫 **MinGW** 的包来装。 +- **Clang**:另一款开源编译器,报错信息比 GCC 友好,新手看着不头疼。 + +这仨都能编译标准的 C++ 代码,差别主要在报错风格、性能和一些边缘行为。咱们这套教程在 Windows 上走 MinGW(也就是 GCC)这条线,因为它免费、轻量、跟 vscode 配合顺手。MSVC 那条线要装一整个 Visual Studio,体积大,对纯小白来说门槛偏高。等您用熟了,想换随时换。 + +::: details 点开看:命令行怎么看自己电脑有没有编译器 +打开 Windows 的「命令提示符」(开始菜单搜 `cmd`),敲下面这行回车: + +```bash +g++ --version +``` + +什么叫没有安装呢? + +要是蹦出一行版本号(比如 `g++ (x86_64-posix-seh-rev0, Built by MinGW-W64 project) XX.Y.Z`),说明装过 MinGW 的 GCC 了。要是提示「'g++' 不是内部或外部命令(或者是它的英文版本)」,那就是没装——下一篇咱们就装。 + +MSVC 的命令叫 `cl`,Clang 叫 `clang++`,同理。 +::: + +## 写 C++ 要两样东西 + +把上面两段拼起来就清楚了。写 C++ 离不开两样: + +一样是**编辑器**,您在它里头敲代码、改代码、看错误提示。咱们用 vscode。 + +另一样是**编译器**,您敲完的 `.cpp` 喂给它,它吐出能跑的 `.exe`。咱们在 Windows 上用 MinGW 提供的 GCC。 + +下一篇咱们就把 vscode 和编译器都装上,顺手再装个叫 CMake 的构建工具——代码一多,光有编译器不够使,CMake 帮咱们把一堆 `.cpp` 文件组织起来一起编译。 diff --git a/documents/getting-started/02-install-toolchain.md b/documents/getting-started/02-install-toolchain.md new file mode 100644 index 000000000..2f1c0824d --- /dev/null +++ b/documents/getting-started/02-install-toolchain.md @@ -0,0 +1,278 @@ +--- +title: "装好写 C++ 要用的三样东西" +description: "Windows 下从零装好 vscode、MinGW 编译器、CMake,每一步都有截图位和验证" +chapter: 14 +order: 2 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 12 +--- + +# 装好写 C++ 要用的三样东西 + +## 开场 + +上一篇咱们明确了要装两样东西:编辑器(vscode)和编译器。其实还得再来一样——构建工具,名字叫 CMake。 + +先说清楚 CMake 是干嘛的。咱们以后写 C++,一个项目不会只有一个 .cpp 文件,可能五六个、十几个,还得分文件夹放。这时候手动敲命令一个个编译会疯掉。CMake 就是帮咱们管这些事的,您写一份配置文件告诉它「项目里有哪几个文件、要生成什么程序」,剩下的事它来。具体怎么用,下一篇咱们就上手,现在先把它装上。 + +这一篇全是手把手,每一步都有截图位。装完三样东西,咱们下一篇就能写出第一个能跑的程序。 + +## Windows 路线(推荐) + +如果您的系统是 Windows 10 或 Windows 11,跟着这一节走就行。三步,按顺序来。 + +### 步骤 1·装 vscode + +vscode 是微软做的一个免费的编辑器,咱们以后写代码就在它里面敲。 + +打开浏览器,访问 。 + +页面正中有个蓝色的大按钮,写着「Download for Windows」,点它。如果您的浏览器没有自动开始下载,它会跳到一个下载选择页,选「Windows」那一项,下到一个 `.exe` 安装包。 + +下载完,双击运行 `VSCodeUserSetup-x64-x.x.x.exe`。安装器长得跟普通软件差不多,一路下一步。这里要留意的是这一屏: + +请把这几项都勾上(尤其是「Add to PATH」,这个一定要勾,不勾后面会麻烦): + +- 在「Select Additional Tasks」这一屏里,勾选「Add "Open with Code" action to Windows Explorer file context menu」 +- 勾选「Add "Open with Code" action to Windows Explorer directory context menu」 +- 勾选「Register Code as an editor for supported file types」 +- 勾选「Add to PATH」(**最重要**) + +剩下几项(要不要在桌面建快捷方式、要不要加右键菜单的某些项)随您喜欢。我反正是加了,因为偶尔干活懒得开CMD或者是Powershell。 + +::: details 点开看:装的时候忘了勾「Add to PATH」怎么办 +别慌。最省事的办法是把 vscode 自己的安装路径手动加到系统 PATH 里,但更省事的办法是:卸载重装一遍,这次记得勾。重新装一遍两分钟的事,比折腾 PATH 快。但是温馨提醒一下您,之后您从事计算机的工作,改PATH那是同事都懒得说的基本功。学习计算机最好现在就学会折腾。 +::: + +装完之后,按一下键盘上的 Win 键(就是带 Windows 图标那个键),开始菜单里应该能看到 vscode 的图标。 + +点开它,看到一个欢迎页面,就算装好了。 + +### 步骤 2·装编译器(走 MinGW-w64 这条路) + +编译器就是把您写的 .cpp 翻译成 .exe 的那个程序。Windows 下能用的 C++ 编译器有好几种,咱们这里走 MinGW-w64 这条路——它本质是 Linux 上那个著名的 GCC 编译器移植到 Windows 的版本。 + +为什么选它?两个原因。第一,跟咱们这套教程后面会用到的 Linux 环境是一套东西,命令行操作习惯完全一致,学一遍到处能用。第二,后面如果您想往嵌入式方向走(这套教程也覆盖),GCC 是主流,提前熟悉没坏处。 + +微软自家也有个编译器叫 MSVC(Visual Studio 那一套),也很好用。两种的区别咱们放在折叠盒里,这里不展开,先把 MinGW 装上。 + +装 MinGW 最省心的办法是借助一个叫 MSYS2 的工具。MSYS2 本质是一个「包管理器」——您可以把它理解成一个软件商店,跟手机上的应用商店差不多,只不过它装的是给程序员用的命令行工具,而且是用命令行操作的。 + +打开浏览器,访问 。 + +![截图:MSYS2 官网首页,找到下载安装包的链接](images/download_msys2.png) + +页面上有个下载链接,指向 `msys2-x86_64-xxxxxxxx.exe` 这样的安装包(文件名里带日期,您下的时候日期不一样是正常的)。下下来,双击运行。 + +先记得点击一下next,然后会让你选一个路径: + +![MSYS2 安装器选安装路径,默认 C:\msys64](images/msys-install-path-selection.png) + +安装器会让您选安装路径。**强烈建议用默认路径 `C:\msys64`**,不要改。后面咱们要往系统 PATH 里加东西,路径写死了方便。如果您装到了别的地方,后面所有路径都得跟着改,容易出错。 + +一路下一步装完。装完之后,开始菜单里会多出几个 MSYS2 开头的图标。 + +::: warning 这里有个新手最容易踩的坑 +开始菜单里有好几个 MSYS2 入口:「MSYS2 MINGW64」「MSYS2 UCRT64」「MSYS2 CLANG64」「MSYS2」等等。 + +**请打开「MSYS2 UCRT64」这一个**,别开成「MSYS2」(那个最朴素的)。咱们装的是 UCRT64 版本的 GCC,必须在 UCRT64 终端里才能正常用。开错了,后面装完会发现命令找不到。 +::: + +打开 UCRT64 终端后,会看到一个紫色字体的命令行窗口。在里面敲这一行命令(注意大小写、空格、连字符都要对),然后回车: + +```bash +pacman -S mingw-w64-ucrt-x86_64-gcc +``` + +::: details 点开看:您可能的输出? + +我还真遇到过有人问下面这个美刀符号啥意思的,我想了想,额,您就认为是计算机的shell给您的一个前导的提示符,看到这个加上后面一闪一闪的光标,计算机就是在静候您的输出。 + +但是并不是总是这样的,比如说我的配置过,就是这样的~ + +![alt text](images/shell_zsh.png) + +```bash +CharlieChen@DESKTOP-65DBAA7 UCRT64 ~ +$ echo "Hello!" # 测试一下能不能用, 这个是bash命令,您学习Linux的话,这个是必须会的 +Hello! + +CharlieChen@DESKTOP-65DBAA7 UCRT64 ~ +$ pacman -S mingw-w64-uart-x86_64-gcc +error: target not found: mingw-w64-uart-x86_64-gcc +# 上面这行笔者手滑了:打成了 uart(串口),正确是 ucrt(Windows 10 的 C 运行时)。 +# 看到 target not found 先怀疑包名拼错——pacman 找不到这个名字的包就会这么报 + +CharlieChen@DESKTOP-65DBAA7 UCRT64 ~ +$ pacman -S mingw-w64-ucrt-x86_64-gcc +resolving dependencies... +looking for conflicting packages... + +Packages (17) mingw-w64-ucrt-x86_64-binutils-2.46-4 + mingw-w64-ucrt-x86_64-crt-14.0.0.r92.g818fa6510-1 + mingw-w64-ucrt-x86_64-gcc-libs-16.1.0-5 mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1 + mingw-w64-ucrt-x86_64-gmp-6.3.0-2 + mingw-w64-ucrt-x86_64-headers-14.0.0.r92.g818fa6510-1 + mingw-w64-ucrt-x86_64-isl-0.27-1 mingw-w64-ucrt-x86_64-libiconv-1.19-1 + mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r92.g818fa6510-1 + mingw-w64-ucrt-x86_64-mpc-1.4.1-1 mingw-w64-ucrt-x86_64-mpfr-4.2.2-3 + mingw-w64-ucrt-x86_64-tzdata-2026b-1 + mingw-w64-ucrt-x86_64-windows-default-manifest-6.4-4 + mingw-w64-ucrt-x86_64-winpthreads-14.0.0.r92.g818fa6510-1 + mingw-w64-ucrt-x86_64-zlib-1.3.2-2 mingw-w64-ucrt-x86_64-zstd-1.5.7-2 + mingw-w64-ucrt-x86_64-gcc-16.1.0-5 + +Total Download Size: 68.98 MiB +Total Installed Size: 490.23 MiB + +:: Proceed with installation? [Y/n] +# 这里的意思是让你输入一个 y,问你要不要下载。实际上是要的 +# 输入 y 回车,起来一会回来就会安装完毕 +``` + +现在我们可以试一下了! + +![alt text](images/msys2_g++.png) + +::: + + + +`pacman` 就是 MSYS2 这个「软件商店」的操作命令,`-S` 是「安装(sync)」的意思,后面那一长串是要装的包的名字。 + +第一次装东西时,pacman 会问您要不要继续、下载的包对不对,输入 `Y` 回车确认就行。它会下载几十兆的东西,等一会儿。 + +装完之后,咱们得让 Windows 系统认识这个新装的编译器——也就是把它所在的位置告诉系统的 PATH 变量。PATH 是什么?您可以理解成系统的一个「常用地址本」,凡是写在里面的地址(文件夹路径),系统都能直接找到里面的程序,不用每次都写完整路径。 + +按 Win 键,搜索「环境变量」,点「编辑系统环境变量」。 + +弹出的窗口右下角有个「环境变量」按钮,点它。在下方的「系统变量」列表里找到名为 `Path` 的那一行(注意是 `Path` 不是 `PATHEXT`),双击它。 + +在弹出的列表里点「新建」,输入这一行(如果您装 MSYS2 时用了默认路径,就是这个): + +```text +C:\msys64\ucrt64\bin +``` + +一路「确定」把所有窗口关掉,保存。 + +现在验证一下装好了没有。**这一步要开一个全新的命令行窗口**——刚才改了 PATH,旧的窗口不会自动刷新,必须重开。 + +按 Win+R,输入 `cmd`,回车,打开一个命令提示符(黑色背景那个)。敲: + +```bash +g++ --version +``` + +如果看到类似下面这样的输出(具体版本号可能更新),就成了: + +```text +➜ g++ --version +g++.exe (Rev5, Built by MSYS2 project) 16.1.0 +Copyright (C) 2026 Free Software Foundation, Inc. +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +``` + +如果看到「'g++' 不是内部或外部命令」之类的报错,说明 PATH 没设对。回去检查三件事:路径是不是写成了 `C:\msys64\ucrt64\bin`(很多人漏掉中间的 `\ucrt64\`)、有没有拼错、是不是开了新的 cmd 窗口。 + +### 步骤 3·装 CMake + +最后一样。打开浏览器,访问 。 + +页面会列出多个平台的安装包。找到 Windows 一栏下的 `Windows x64 Installer`,下载那个 `.msi` 文件(文件名类似 `cmake-x.y.z-windows-x86_64.msi`)。 + +双击运行 `.msi`。安装器一路下一步,到了这一屏要特别留意: + +会问您 CMake 要不要加进系统 PATH。**选第二项「Add CMake to the system PATH for all users」**(给所有用户加进系统 PATH)。第一项默认是不加,第三项只给当前用户加,咱们选中间这个最省事。 + +继续下一步装完。 + +验证一下。**同样要开一个全新的 cmd 窗口**(旧窗口的 PATH 没刷新)。敲: + +```bash +cmake --version +``` + +看到版本号输出就成: + +```text +➜ cmake --version +cmake version 4.4.1 + +CMake suite maintained and supported by Kitware (kitware.com/cmake). +``` + +::: details 点开看:命令行装 CMake 也不是不行 +如果您更喜欢用命令行,也可以在 MSYS2 UCRT64 终端里 `pacman -S mingw-w64-ucrt-x86_64-cmake` 装。但这样装出来的 CMake 路径在 `C:\msys64\ucrt64\bin` 下,跟刚装的 GCC 一起,不用再单独改 PATH。两种装法二选一,别重复装。 +::: + +## 装 vscode 的 C++ 扩展 + +三样主体软件装好了,最后再给 vscode 装两个「扩展」(extension)。扩展可以理解成 vscode 的插件,给它加上额外功能。 + +打开 vscode。在窗口最左边那一列图标里,找一个由四个小方块组成的图标(鼠标放上去会显示「Extensions」),点它。或者直接按快捷键 `Ctrl+Shift+X`。 + +在顶部搜索框里分别搜这两个名字,找到对应的扩展,点「Install」安装: + +第一个是 C/C++。这是微软官方做的扩展,提供代码补全、跳转定义、错误提示这些功能。咱们在篇 5 才会动它的设置,但先装上不亏。 + +第二个是 CMake Tools。也是微软官方的,专门让 vscode 配合 CMake 用。下一篇咱们写第一个程序就会用到它。 + +两个都装好之后,vscode 窗口最下方蓝色的状态栏里会多出一些跟 CMake 相关的按钮(比如显示当前构建类型、构建按钮之类的)。看到这些就说明扩展生效了。 + +::: details 点开看:用 Linux(Ubuntu/Debian 系)怎么装 +Windows 主线讲完了。如果您手头是 Linux 机器,整套东西命令行一条命令就装完,比 Windows 省心得多。这也是为什么我喜欢干活在WSL或者是自己的Linux笔记本。一点不耽误事情! + +打开终端,敲这一行(一次性把编译器、CMake、调试器都装齐): + +```bash +sudo apt update && sudo apt install -y build-essential cmake ninja-build gdb +``` + +`build-essential` 这个包里就含了 GCC 编译器,`cmake` 是构建工具,`ninja-build` 是个更快的构建后端(CMake 常配它),`gdb` 是调试器,后面排查问题用得上。`sudo` 是「以管理员权限运行」,会让您输密码。 + +vscode 去 下 `.deb` 安装包,双击装(或者命令行 `sudo apt install ./code_*.deb`)。 + +验证办法跟 Windows 一样: + +```bash +g++ --version +cmake --version +``` + +能看到版本号就成了。C/C++ 和 CMake Tools 两个扩展同样要在 vscode 里装,跟系统无关。 +::: + +::: details 点开看:MSVC 和 MinGW 到底差在哪,怎么选 +Windows 上的 C++ 编译器主要有两套:微软自家的 MSVC(Visual Studio 那一套),和咱们这里走的 MinGW(GCC 的 Windows 移植版)。 + +简单说:两套都能写、都能编译出 Windows 程序,日常学习差别不大。但有几个点值得留意。 + +调试器不同。MSVC 配的是微软自家的调试器,MinGW 配的是 GDB。咱们这套教程后面用 GDB 多,因为嵌入式那一套也用 GDB,习惯一致。 + +C++ 标准跟进度不同。MSVC 在某些新特性上跟进更快一点,GCC 在另一些上更快,互有领先。对入门阶段没影响。 + +体积不同。装整套 Visual Studio 要十几个 GB(笔者的工作吃了几十个GB,因为横跨了不同版本的VS),MinGW 加 MSYS2 一两个 GB 就够。咱们刚开始学,装个轻量的省事。 + +命令行习惯不同。MSVC 偏 Windows 原生那套(cl.exe 编译器、链接器配置跟 Linux 完全不一样),MinGW 跟 Linux/macOS 上的 GCC 一致。咱们这套教程后面的命令、CMake 配置都假设是 GCC,所以走 MinGW 最顺。 + +如果以后您做 Windows 桌面应用开发、要调 Windows 专属 API(比如 Direct3D),那时再上 Visual Studio 装 MSVC 也不迟。详细的对比和切换方法,咱们放在 vol1/ch00 那篇专门讲 Windows 环境搭建的文章里。 +::: + +三样东西装好了:编辑器 vscode、编译器 MinGW、构建工具 CMake,再加上 vscode 里两个 C++ 扩展。下一篇咱们就在 vscode 里写出第一个 C++ 程序,让它真正跑起来,看看那行 `Hello, World!` 是怎么从代码变成屏幕上的字的。 + + +::: details 点开看:想参考更详细的环境搭建 + +- [超详细 VSCode 安装教程(Windows)](https://zhuanlan.zhihu.com/p/678737903) —— VSCode 下载安装每一步都配图,装 vscode 卡住的话对着这个看 +- [MSYS2+VSCode:Windows 下接近 Linux 的 C/C++ 编程环境搭建](https://zhuanlan.zhihu.com/p/1982834714722194966) —— 比本篇更全的搭建(一路到 clangd、lldb 调试、zsh 美化),想一次配满的看这个 +::: diff --git a/documents/getting-started/03-first-program.md b/documents/getting-started/03-first-program.md new file mode 100644 index 000000000..4a07d2282 --- /dev/null +++ b/documents/getting-started/03-first-program.md @@ -0,0 +1,222 @@ +--- +title: "您的第一个 C++ 程序——在 vscode 里跑通 hello" +description: "在 vscode 里从零建项目、写 main.cpp 和 CMakeLists.txt、配置生成运行,把 Hello 真正跑出来" +chapter: 14 +order: 3 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - CMake +reading_time_minutes: 15 +--- + +# 您的第一个 C++ 程序——在 vscode 里跑通 hello + +## 开场 + +环境装好了吧?(没装请回篇 2,vscode、MinGW、CMake 还有那两个扩展都得装齐。)这一篇咱们干一件有仪式感的事——亲手写出第一个 C++ 程序,让它真跑起来,在屏幕上吐出 `Hello, C++!`。 + +全程在 vscode 里点按钮,一行命令都不用敲(命令行那一套放在文末折叠盒里,想看再点开)。中间会经历一个完整的小项目流程:建文件夹、写代码、写 CMake 配置、配置、生成、运行。听上去步骤不少,其实每一步就点一下按钮,跟着走一遍您就摸清套路了。 + +## 步骤 1·建个项目文件夹 + +先找个地方放您写的代码。别直接在桌面或者 C 盘根目录里堆文件,过两天就乱成一锅粥。咱们专门建个文件夹,每个项目一个。 + +在桌面(或者您顺手的位置,比如 `D:\code\` 这种)右键新建一个文件夹,起名叫 `hello`。名字短、全小写、不带空格——这三条以后写代码起名字都管用,先养成习惯。 + +文件夹建好之后,打开 vscode。点菜单「文件 → 打开文件夹」(英文界面是 `File → Open Folder`),在弹出的窗口里选中刚才那个 `hello` 文件夹,点「选择文件夹」。 + +打开之后,vscode 左侧会出现一个资源管理器面板,标题就是 `hello`,下面空空荡荡,啥也没有——因为这是个空文件夹。这就对了,咱们从零开始往里塞东西。 + +::: tip 「打开文件夹」这一步不是多此一举 +vscode 跟记事本不一样,它认的是「项目」。您得告诉它「我接下来在 hello 这个文件夹里干活」,它才会把扩展、CMake、调试这些功能都接到这个文件夹上。直接拖一个 `.cpp` 进 vscode 也能编辑,但后面 CMake 那一套用不起来。所以每次开新项目,第一步都是「打开文件夹」。 +::: + +## 步骤 2·新建 main.cpp + +左侧资源管理器面板的标题 `hello` 右边,有一排小图标。把鼠标放上去,第一个长得像一张白纸加个加号的,是「新建文件」(鼠标悬停会显示 `New File`)。点它。 + +点完之后,面板里会出现一个让您输文件名的小输入框。敲 `main.cpp`,回车。 + +为什么叫 `main`、为什么后缀是 `.cpp`?`main` 是约定俗成的名字,C++ 程序的入口(程序从这里开始跑)就放在这个文件里,大家都这么起,您跟人交流不会被绊。`.cpp` 是 C++ 源代码文件的标准后缀,编译器一看到 `.cpp` 就知道要按 C++ 来编译。 + +回车之后,主编辑区会打开 `main.cpp` 这个文件(当然是空的),左侧资源管理器里也多出了 `main.cpp` 这一项。 + +## 步骤 3·把代码粘进去 + +把下面这段代码完整复制,粘进 `main.cpp`: + +```cpp +#include + +int main() { + std::cout << "Hello, C++!\n"; + return 0; +} +``` + +粘好之后,编辑器里的代码会变成彩色的——`int` `return` `#include` 这种关键词一种颜色,`"Hello, C++!\n"` 这种字符串另一种颜色。这叫语法高亮,上一篇提过,编辑器干的就是这活。 + +简单说几句这段代码在干啥,您现在不用全记住,混个脸熟就行。 + +第一行 `#include ` 是把 C++ 自带的「输入输出」工具包拉进来用。`iostream` 这个名字拆开看就是 input output stream(输入输出流),管的就是「从键盘读东西」和「往屏幕写字」。 + +中间那个 `int main()` 是程序的入口。C++ 程序跑起来,都是从 `main` 这个函数的第一行开始执行的,没有例外。花括号 `{}` 里头就是程序实际要干的事。 + +`std::cout << "Hello, C++!\n";` 是往屏幕输出字。`std::cout` 您可以理解成「屏幕」这个对象的代号,`<<` 是「往里送」的箭头,把右边那串字送进屏幕显示出来。`\n` 是换行符,让输出完之后光标挪到下一行。 + +`return 0;` 是告诉操作系统「这个程序正常跑完了,没出错」。0 表示正常,非 0 表示有问题——这个约定以后会用到,现在知道 0 是好的就行。 + +## 步骤 4·还得配一个 CMakeLists.txt + +代码写完了,您可能想:「现在点那个三角形运行按钮不就跑了?」 + +不行。这一步会把很多新手卡住,笔者先说清楚为什么。 + +vscode 自带的那个运行按钮(或者按 `F5`)不知道您要用哪个编译器、要编译哪个文件、要生成什么名字的程序——它什么都不知道。您光给它一个 `main.cpp`,它一脸懵。咱们得另外写一份「说明书」,把这些事全告诉它。这份说明书要写的文件,叫 `CMakeLists.txt`,CMake 就是读它的。 + +(有人会问:那篇 1 不是说编译器直接编译就行吗?对,命令行里直接调 `g++ main.cpp` 是能编出来。但 vscode 那套图形化按钮走的是 CMake 这条线,咱们既然要在 vscode 里点按钮,就照 CMake 的规矩来。CMake 还能管多文件项目,篇 4 咱们就知道了。) + +在 `main.cpp` 旁边,用步骤 2 那个「新建文件」图标,再建一个文件,名字叫 `CMakeLists.txt`(注意大小写,`C` `M` `a` `k` `e` 都大写,`Lists` 的 `L` 大写,剩下小写,后缀 `.txt`)。这个名字是 CMake 规定死的,差一个字母它都不认。 + +把下面这段粘进去: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(hello LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) +``` + +这五行是什么意思,逐行给您翻译: + +第一行 `cmake_minimum_required(VERSION 3.20)` 是说「跑这个项目的 CMake 版本至少得是 3.20」。3.20 是个挺老的下限,绝大多数机器都满足。CMake 拿这句话去检查您装的 CMake 版本够不够。 + +第二行 `project(hello LANGUAGES CXX)` 说这个项目名字叫 `hello`,用的语言是 C++(`CXX` 是 CMake 里 C++ 的代号,C 是 `C`,C++ 是 `CXX`)。 + +第四行 `set(CMAKE_CXX_STANDARD 17)` 说「用 C++17 这个版本的标准」。C++ 这几年一直在演进,C++11、14、17、20、23 都有,越新加的特性越多。17 是个稳的版本,绝大多数项目都至少能用它,咱们起步就用 17。 + +第五行 `set(CMAKE_CXX_STANDARD_REQUIRED ON)` 说「上面那个标准不是建议,是硬要求」。如果编译器不支持 C++17,直接报错,不让它悄悄降级到老标准——降级了您还不知道,后面踩坑踩不明白。 + +最后一行 `add_executable(hello main.cpp)` 是最关键的一行。`add_executable` 是「生成一个可执行程序」的意思,括号里第一个 `hello` 是生成的程序名字,第二个 `main.cpp` 是要编译的源文件。这行整体的意思就是:把 `main.cpp` 编译成一个叫 `hello` 的可执行程序(Windows 上就是 `hello.exe`)。 + +## 步骤 5·选试剂盒(kit) + +还记得篇 2 装完那两个扩展后,vscode 底部状态栏多出来的那一块吗?现在该用它了。 + +鼠标点状态栏那一块写着「No Kit Selected」或者「未选择试剂盒」的字样(英文界面是 `No Kit Selected`)。点它会弹出一个小列表。或者按 `Ctrl+Shift+P` 打开命令面板,输入 `CMake: Select a Kit` 回车,效果一样。 + +弹出的列表里,会列出 vscode 在您电脑上找到的所有编译器。您应该能看到一个类似 `GCC 16.1.0 x86_64-w64-mingw32` 或者 `GCC x.x.x ucrt64` 的条目(具体版本号取决于您装的那版 MinGW)。选这个 GCC 的。 + +选好之后,状态栏那块字会变成 `GCC 16.1.0` 之类,显示当前选中的是哪个编译器。 + +试剂盒是 CMake Tools 扩展的术语,您可以理解成「工具箱」——告诉 CMake Tools「以后编译就用这个编译器」。这一步只需选一次,以后再打开这个项目,它都记着。 + +::: warning 列表里看不到 GCC 怎么办 +要是列表里压根没有 GCC,只有一些 `Visual Studio` 之类的条目,说明篇 2 那一步 MinGW 没装好,或者装好了但 PATH 没设对,CMake Tools 找不到它。回去检查篇 2 的步骤 2,重点看 `C:\msys64\ucrt64\bin` 这个路径有没有正确加进系统 PATH,还有是不是重启了 vscode(改完 PATH 要重启 vscode 才生效)。 + +列表最下面通常还有一行 `[Unspecified]`,这是「不指定」的意思。先别选这个,咱们要明确指定 GCC。 +::: + +选择好了,好消息就是现在的话Select A Kit完事了之后自动会开始丝滑的配置: + +![alt text](images/cmake_config_auto.png) + +配置这一步在干什么?CMake 读您的 `CMakeLists.txt`,根据里头的说明,生成一堆「构建文件」(在 `hello` 文件夹下会多出来一个 `build` 子文件夹,东西都塞在那里面)。这一步**还没有编译您的代码**,只是 CMake 在做准备工作——把用哪个编译器、编译哪些文件、生成什么这些事都安排好。真正的编译在下一步。 + +::: tip 配置什么时候要重跑 +以后您只要改了 `CMakeLists.txt`(比如加了一个新源文件),就得重跑一次配置,CMake 才会重新认。光改 `.cpp` 文件不用重跑配置,CMake 自动发现。 +::: + +## 步骤 6·生成(构建) + +配置完了,点状态栏的「生成」按钮(英文 `Build`)。或者命令面板 `CMake: Build`。 + +![alt text](images/build_vscode.png) + +这次输出窗又跑字,但内容不一样了——是编译器真的在干活。您能看到类似 `Building CXX object ... main.cpp.o`、`Linking CXX executable hello.exe` 这样的行。最后一行会显示「生成已完成」或者类似的成功提示。 + +到这一步,您的 `main.cpp` 真正被翻译成 `hello.exe` 了。它就躺在 `hello\build\` 文件夹下。下一步就是把它跑起来。 + +::: warning 万一报错了 +最常见的报错是找不到编译器,或者编译器路径不对——回到步骤 5 重新选试剂盒。还有一种常见的是 `main.cpp` 文件名打错了(比如打成了 `mian.cpp`),CMake 找不到文件。报错信息一般会直接写出来哪一行出问题,对照看就行。改完之后重新点「生成」。 +::: + +## 步骤 8·运行 + +状态栏上有个三角形播放键,是「运行」按钮(英文 `Run`)。注意别点到旁边那个带小虫子图标的——那是「调试」按钮(Debug),会进调试模式,先不用它。 + +点运行按钮。vscode 下方的终端面板会弹出来(如果没弹,按 `` Ctrl+` `` 调出来),里头会打印一行字: + +```text +Hello, C++! +``` + +到这儿,您的第一个 C++ 程序真跑起来了。这一行字从代码变成屏幕上的字符,走完了「写代码 → 配置 → 生成 → 运行」一整套流程,您以后写的每一个 C++ 程序,套路都是这一套。 + +## 步骤 9·改改再跑 + +光跑通一次不算熟。咱们改改代码再跑一遍,把循环走顺。 + +回到 `main.cpp`,把那行 `Hello, C++!` 改成您想说的别的,比如: + +```cpp +#include + +int main() { + std::cout << "我学会了写 C++!\n"; + return 0; +} +``` + +保存(`Ctrl+S`)。注意保存之后,状态栏的文件名旁边那个小白点会消失,说明改动已经落盘。 + +然后直接点状态栏的「运行」按钮。CMake Tools 会自动先重新生成(因为它发现 `.cpp` 变了),再跑。终端里这次会打印: + +```text +我学会了写 C++! +``` + +以后改代码就是这套动作:改完保存、点运行。中间的配置、生成 CMake Tools 都替您自动接上了。 + +::: details 点开看:命令行怎么做 +上面点的那些按钮,底层其实就是跑几条命令。咱们在 vscode 终端里手动跑一遍,您就知道按钮背后在干什么。 + +打开 vscode 终端(菜单「终端 → 新建终端」,或快捷键 `` Ctrl+` ``)。第一次构建,分三步: + +```bash +cmake -B build +cmake --build build +.\build\hello.exe +``` + +第一条 `cmake -B build` 就是「配置」——在 `build` 文件夹下生成构建文件(`-B` 指定输出目录)。 + +第二条 `cmake --build build` 就是「生成」——真正调用编译器,把 `main.cpp` 编译成 `hello.exe`。 + +第三条 `.\build\hello.exe` 就是「运行」——直接执行那个 `.exe`。 + +以后改完代码,只需要重跑后两条(第二条会自动只重编改过的文件,第二条完了再第三条)。 + +如果您在 Linux 上(比如篇 2 折叠盒里那条 apt 路线),运行的命令稍微不一样: + +```bash +cmake -B build +cmake --build build +./build/hello +``` + +差别就两点:Linux 上可执行文件不强制带 `.exe` 后缀(CMake 默认生成 `hello` 而不是 `hello.exe`),执行时路径分隔符用正斜杠 `/`、前缀 `./`。 +::: + +您的第一个 C++ 程序跑起来了,从代码到屏幕上那行字,全程走通了一遍。这套流程(建项目、写代码、写 CMakeLists、配置、生成、运行)以后会反复用,您多跑几次就熟了。 + +下一篇咱们把项目变大——一个 `.cpp` 不够写了,多个文件怎么组织、怎么让它们互相配合。 diff --git a/documents/getting-started/04-multi-file-cmake.md b/documents/getting-started/04-multi-file-cmake.md new file mode 100644 index 000000000..ea90f0437 --- /dev/null +++ b/documents/getting-started/04-multi-file-cmake.md @@ -0,0 +1,237 @@ +--- +title: "项目变大——多个文件怎么办,引出 CMake" +description: "把篇 3 的单文件 hello 扩成三个文件,第一次正儿八经用 CMake 管一个多文件工程" +chapter: 14 +order: 4 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - CMake +reading_time_minutes: 15 +--- + +# 项目变大——多个文件怎么办,引出 CMake + +## 开场 + +上一篇咱们在 vscode 里跑通了第一个 C++ 程序,终端老老实实打印出 `Hello, C++!`。但那个工程就一个 `main.cpp`,所有代码全挤在一个文件里。真实的项目不可能这么小。稍微写点正经东西,代码量一上来,全塞一个文件里会乱到您自己都看不下去。 + +这一篇咱们就把工程从「一个文件」扩到「三个文件」,顺手把上一篇只是提了一句名字的 CMake 真正用起来。等三个文件的工程跑通了,您就知道 CMake 到底帮了什么忙。 + +## 为什么要分文件 + +先说清楚为啥非得分文件,不分不行吗。 + +不分也行,但您试试把所有代码塞进 `main.cpp`,写到两三百行就能体会到那种乱:找某个函数得满屏滚条,改一处怕牵连另一处,函数和函数之间挤成一坨,眼睛扫不到结构。文件一长,调试的时候血压会先上来。 + +常见的拆法是按「一类功能」分一个文件。这一篇咱们就做一个最简单的「打招呼」功能,单独放在 `greet.cpp` 和 `greet.h` 两个文件里,`main.cpp` 只管主流程,谁干谁的活清清楚楚。 + +::: details 点开看:.cpp 和 .h 是怎么回事 +C++ 里一个功能通常拆成两个文件:一个 `.h`(头文件,header),一个 `.cpp`(实现文件)。 + +`.h` 里放的是「声明」,告诉别的文件「我这儿有这么个东西,长这个样子」。`.cpp` 里放的是「定义」,也就是具体这个东西怎么干活。 + +别的文件要用这个功能,就 `#include` 那个 `.h`,相当于把「承诺书」拿过来看一眼,知道自己能调什么。至于 `.cpp` 里怎么实现的,调用方根本不关心,链接的时候(咱们下面会讲到)编译器会自己接上。 + +这套机制看着啰嗦,但好处实在:改动一个功能的实现,只要「承诺书」(`.h`)没变,调用它的别的文件根本不用重新编译。文件一多,省下来的时间非常可观。 +::: + +## 三个文件长这样 + +咱们新建一个工程文件夹,叫 `greeter`(打招呼的小程序),里面放三个文件。先把篇 3 那个 hello 工程关了也行,重新开一个干净的目录。 + +新建三个文件,文件名和内容如下。先看 `greet.h`,这是头文件,声明 `greet` 这个函数长什么样: + +```cpp +#pragma once +#include + +std::string greet(const std::string& name); +``` + +`#pragma once` 这一行是头文件的「防重复包含」开关。意思是「这个文件在整个编译过程里只算一次,谁要是 include 了第二回,直接跳过」。要是没这行,万一两个文件都 include 了 `greet.h`,编译器会把里面的内容抄两遍,然后报「重复定义」的错给您看。 + +中间那行 `#include ` 把标准库的字符串类型拿进来。`greet` 函数要用到 `std::string`,得先告诉编译器这是个啥。 + +最后一行是函数声明:有个叫 `greet` 的函数,吃一个 `std::string`(名字 name),返回一个 `std::string`。注意结尾是分号,没有大括号,这是「承诺书」,只说有这么个函数,不说怎么干。 + +再看 `greet.cpp`,这个文件负责实现: + +```cpp +#include "greet.h" + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} +``` + +第一行 `#include "greet.h"` 把刚才那个承诺书拿过来。注意这里用双引号 `""` 而不是尖括号 `<>`:双引号是「项目里您自己写的头文件」,尖括号是「系统/标准库的头文件」,这是个约定,别写反。 + +下面是函数定义:把 `"Hello, "`、传入的名字、`"!"` 三个字符串拼一起返回。这就是「兑现承诺」,告诉编译器这个函数具体怎么干活。这时候才有大括号,里面是真正干活的代码。 + +最后改 `main.cpp`,调用这个函数: + +```cpp +#include +#include "greet.h" + +int main() { + std::cout << greet("world") << "\n"; + return 0; +} +``` + +`main.cpp` 里也 include 了 `greet.h`,它要用 `greet` 这个函数,得先把承诺书拿过来,知道这个函数吃啥、吐啥。然后调 `greet("world")`,把返回的字符串丢给 `std::cout` 打印出来。 + +打个比方帮您记:`greet.h` 是张承诺书(「有个叫 `greet` 的函数,吃一个名字,返回一句话」),`greet.cpp` 是兑现承诺(具体怎么拼字符串),`main.cpp` 是拿来用的人(拿来就用,不关心细节)。三个文件各司其职。 + +## 手动编译太累,CMake 上场 + +三个文件准备好了。问题来了:怎么把它们编译成一个 `.exe`? + +上一篇单文件工程的时候,咱们的 `CMakeLists.txt` 里关键的就一行: + +```cmake +add_executable(hello main.cpp) +``` + +这行的意思是「生成一个叫 `hello` 的可执行程序,源文件是 `main.cpp`」。现在三个文件,只要把这行的源文件列全: + +```cmake +add_executable(greeter main.cpp greet.cpp) +``` + +就把 `greet.cpp` 也加进去了。改这一行就够,别的都不用动。完整的 `CMakeLists.txt` 长这样: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) +``` + +把这四行(加一个空行)存成 `CMakeLists.txt`,放在工程根目录里,跟三个 `.cpp` / `.h` 文件平级。 + +注意文件名大小写:是 `CMakeLists.txt`,大写的 C 和 L,结尾是 `.txt` 不是 `.cmake`。CMake 默认就找这个名字,写错一个字母它都认不出来。 + +## 跑通 + +四个文件齐了,开始跑。流程跟上一篇一模一样: + +第一步,保存所有文件。在 vscode 里按 `Ctrl+K` 再按 `S`(或者菜单 File → Save All),把改过的文件全存一遍。新手最容易踩的坑就是改了文件没存,编译的还是旧内容,然后对着「为什么没生效」怀疑人生。 + +第二步,配置。点 vscode 底部状态栏的「Configure」按钮(或者命令面板搜 `CMake: Configure`)。CMake 会扫一遍 `CMakeLists.txt`,准备构建文件。这一步过了的话,工程目录里会冒出一个 `build` 文件夹。 + +第三步,生成。点状态栏的「Build」(或者 `CMake: Build`,快捷键 `F7`)。这一步是真正编译,能看到终端刷一串输出。看到 `[100%]` 和 `greeter.exe` 字样,就是编完了。 + +第四步,运行。点状态栏的「Run」(或者 `CMake: Run Without Debugging`,快捷键 `Shift+F5`)。 + +终端会打印: + +```text +Hello, world! +``` + +到这一步,三个文件的工程就跑通了。`main.cpp` 调了 `greet.cpp` 里实现的 `greet` 函数,函数拼好字符串返回,`main` 把它打印出来。一个最简单的多文件协作。 + +## CMake 到底帮了什么忙 + +```mermaid +flowchart LR + A["main.cpp"] --> C["CMake"] + B["greet.cpp"] --> C + C --> D["greeter.exe"] +``` + +咱们停下来想想,要是不用 CMake,这三个文件怎么编译成 `.exe`?得自己在命令行敲一条类似这样的命令(不用真敲,这里只是让您看一眼): + +```text +g++ main.cpp greet.cpp -o greeter +``` + +三个文件还勉强能记住。可要是项目有十个、二十个 `.cpp`,这条命令得列一长串文件名,每次漏一个就链接报错;改了某一个文件,又得把整条命令重跑一遍,把没改过的文件也重新编译一遍,白白浪费时间。 + +CMake 帮咱们管的就是这两件麻烦事: + +哪几个文件要编译、它们之间谁依赖谁——您只要在 `add_executable` 那一行把文件名列清楚,剩下 CMake 排队。`main.cpp` include 了 `greet.h`,CMake 自己看出来 `main.cpp` 依赖 `greet.cpp`,链接的时候自动接上,不用您操心。 + +改了一个文件要不要全部重编——CMake 会算出来「这次只改了 `greet.cpp`,那就只重编它一个,别的直接用上次编好的」。文件一多,这个能省一大把时间。 + +以后再往工程里加文件,操作就一句:在 `add_executable` 那行末尾追加一个文件名。比如加个 `farewell.cpp`,改成 `add_executable(greeter main.cpp greet.cpp farewell.cpp)`,重新点 Configure + Build,新文件就进来了。您不用记任何编译命令,CMake 全包了。 + +## CMakeLists 每一行什么意思 + +逐行翻译一遍,您心里有个数: + +```cmake +cmake_minimum_required(VERSION 3.20) +``` + +声明「这个工程要用的 CMake 最低版本是 3.20」。CMake 自己版本很老(2000 年就有了),但这套教程里用的几个写法至少得 3.20 才支持。版本写高了,老版本 CMake 跑不动会直接报错告诉您;写低了,可能用到一半才在某些命令上炸。设个底线最稳。 + +```cmake +project(greeter LANGUAGES CXX) +``` + +声明「这个工程叫 `greeter`,用的语言是 C++」。`LANGUAGES CXX` 里的 `CXX` 是 CMake 对 C++ 的代号(C 是 `C`,C++ 是 `CXX`,因为加号在变量名里不合法)。声明了语言,CMake 才会去找对应的编译器(咱们装的 g++)。 + +```cmake +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +``` + +这两行一起看,管的是「用哪个版本的 C++ 标准」。`CMAKE_CXX_STANDARD 17` 设成 C++17。`CMAKE_CXX_STANDARD_REQUIRED ON` 的意思是「这个标准是硬要求」——要是您装的编译器太老、不支持 C++17,就直接报错,而不是偷偷降级成更老的标准偷偷编下去(那种「偷偷降级」最坑,编过了但行为不对,调半天才发现)。 + +```cmake +add_executable(greeter main.cpp greet.cpp) +``` + +最后这行最关键:告诉 CMake「生成一个可执行程序叫 `greeter`,源文件是 `main.cpp` 和 `greet.cpp`」。可执行程序的名字(`greeter`)和文件名(`main.cpp greet.cpp`)之间不用一致,您愿意叫 `greeter` 就叫 `greeter`。最后生成的 `.exe` 就叫 `greeter.exe`。`.h` 头文件不用列在这里,它通过 `#include` 进 `.cpp`,CMake 自己能找到。 + +## 命令行怎么做 + +要是您不想点鼠标,全用命令行也行。在工程根目录(`CMakeLists.txt` 所在那一层)打开终端: + +::: details 点开看:命令行怎么做 +先开终端。Windows 上按 Win+R 输入 `cmd`,或者更顺手的方式:在 vscode 里菜单 Terminal → New Terminal,会直接在工程目录开一个。请确认开的是「MSYS2 UCRT64」终端(篇 2 装的那个),不是普通的 cmd——普通 cmd 里找不到 `cmake` 和 `g++`。 + +第一条命令,配置(`-B build` 意思是「构建文件放到 `build` 子目录里」,省得把工程根目录弄乱): + +```bash +cmake -B build -S . +``` + +第二条命令,编译: + +```bash +cmake --build build -j +``` + +编完之后,可执行文件在 `build/greeter.exe`(Windows)或 `build/greeter`(Linux/macOS)。直接跑: + +```bash +./build/greeter +``` + +终端照样打印 `Hello, world!`。鼠标点按钮和敲命令,背后跑的是同一套 CMake,结果一样。 + +第一次跑 `cmake -B build` 时它会问您用哪个「生成器」、检测编译器,刷一屏信息。看到末尾 `Generating done`,就是配置好了,可以接着 build。 +::: + +三个文件的工程跑通了,CMake 也帮咱们把「哪些文件要编译、谁依赖谁、改了要不要重编」这几件麻烦事管起来了。往后工程再大,往 `add_executable` 那行加名字就是。 + +但您可能已经发现一个烦心事:在 `main.cpp` 里点 `greet` 这个函数名,想跳到它的定义去看一眼实现,跳不过去;代码里的 `#include "greet.h"` 有时候还画着红波浪线,明明能编译过去,红线就是不消。这是 vscode 还不知道 `greet.h` 在哪、`greet` 函数长啥样——下一篇咱们就治这个,让编辑器也跟着聪明起来。 + + +::: details 点开看:CMake 想再多学一点 + +- [菜鸟教程 · CMake 入门](https://www.runoob.com/cmake/cmake-tutorial.html) —— 别因为「菜鸟」俩字就嫌弃,对零基础确实友好,CMake 是什么、CMakeLists 怎么写讲得清楚 +::: diff --git a/documents/getting-started/05-vscode-clangd.md b/documents/getting-started/05-vscode-clangd.md new file mode 100644 index 000000000..5fda0f024 --- /dev/null +++ b/documents/getting-started/05-vscode-clangd.md @@ -0,0 +1,254 @@ +--- +title: "让 vscode 看懂您的代码——装 clangd,红线消失" +description: "篇 4 跑通了,但代码里到处画红线、点函数跳不过去。这篇三步装上 clangd,把 vscode 变聪明" +chapter: 14 +order: 5 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - clangd +reading_time_minutes: 12 +--- + +# 让 vscode 看懂您的代码——装 clangd,红线消失 + +## 开场 + +篇 4 咱们把三个文件的工程跑通了,终端打印 `Hello, world!` 那一刻大概率挺爽。但您接下来在 vscode 里多写几行,多半会撞上几个烦心事: + +代码里的 `#include ` 时不时画着红波浪线,明明编译能过;按住 `Ctrl` 点 `greet` 这个函数名,想跳到它的定义看一眼,光标闪一下没反应;敲 `std::` 也不弹出补全列表,全靠自己一个字母一个字母手打。 + +这不是您写错了,编译器(g++)都说没问题。是 vscode 还没「看懂」您的项目。它不知道 `greet` 这个函数在哪、不知道 `std::` 后面能跟哪些东西,所以帮不上忙。这一篇咱们三步治好它,让编辑器跟着聪明起来。 + +## 为什么会这样 + +先把一件事说清楚:vscode 这个软件本身,其实不懂 C++。 + +vscode 是个通用编辑器,能写 Python、写网页、写 JSON,谁都能往里塞东西。它出厂不带任何一门语言的「理解能力」,得靠扩展(extension,您可以理解成插件)来补。篇 2 装环境的时候您装过一个叫 C/C++ 的扩展,那是微软官方出的,装上之后 vscode 就能懂一点 C++ 了,能高亮、能补全、能调试。 + +问题在于,这个 C/C++ 扩展「懂」得有限。它自己有一套分析 C++ 代码的逻辑,准头一般,碰到稍微复杂点的项目就经常判断错,把能编译过的代码画上红线,或者跳转到错误的地方。您可能已经体会过这种「明明没错却被骂」的憋屈。 + +C++ 社区现在的普遍做法是:换一个更强的工具来管「让编辑器看懂代码」这件事,那个工具叫 clangd。 + +clangd 是 LLVM 项目(一个开源的编译器工具链,跟 GCC 是同类东西)做的,专门干一件事:让编辑器看懂 C++ 代码。它用的分析引擎就是 Clang 编译器那套,准头比 C/C++ 扩展好一截,跳转、补全、报错都更靠谱。咱们这一篇就把它换上。 + +## 三步治好 + +治这个毛病分三步,咱们一步一步来。 + +### 步骤 1:让 CMake 生成一份「翻译说明书」 + +clangd 要靠一份叫 `compile_commands.json` 的文件才能看懂您的项目。这名字长,您先不用记。它本质上是一份「翻译说明书」,记录项目里每个 `.cpp` 文件用什么编译器、用什么 C++ 标准、引用了哪些头文件。clangd 拿到这份说明书,才知道该怎么理解您的每一行代码。才能给您丝滑的代码提示。 + +这份文件不用您手写,让 CMake 顺手吐出来就行。打开篇 4 那个 `greeter` 工程的 `CMakeLists.txt`,在 `project` 那行下面加一行: + +```cmake +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +加完之后,完整的 `CMakeLists.txt` 长这样: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(greeter LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(greeter main.cpp greet.cpp) +``` + +::: tip 一行白话翻译 +`CMAKE_EXPORT_COMPILE_COMMANDS ON` 的意思是「配置的时候,顺手在 build 目录里生成一份 compile_commands.json」。这个开关默认是关的,所以得手动开。 +::: + +保存 `CMakeLists.txt`,然后点 vscode 底部状态栏的「Configure」(重新配置才会重新生成)。配置完之后,工程里的 `build` 文件夹里会多出一个 `compile_commands.json` 文件。这就是 clangd 要的「说明书」。 + +::: warning 装了 CMake Tools 扩展才能点状态栏 +如果您状态栏没有 Configure 按钮,说明篇 2 漏装了 CMake Tools 扩展。回去补上,然后重开 vscode。命令面板(`Ctrl+Shift+P`)搜 `CMake: Configure` 也能触发同样的动作。 +::: + +### 步骤 2:装 clangd 扩展 + +说明书有了,现在请真正的「读者」上场。 + +在 vscode 里点左侧活动栏的扩展图标(四个方块那个,快捷键 `Ctrl+Shift+X`),搜索框里输 `clangd`。您会看到一个发布者是 LLVM 的扩展,名字就叫 clangd。点 Install 装上。 + +::: warning 装扩展前,先确认本机有 clangd 这个程序 +扩展只是个「遥控器」,真正干活的是您电脑上那个叫 `clangd` 的程序(有时候叫 `clangd.exe`)。光装扩展、没装程序,等于有遥控器没电视,开不起来。 + +判断本机有没有这个程序,最快的方式是开个终端敲 `clangd --version`: + +- 能打印一串版本号(比如 `clangd version 18.x.x`),说明有,直接进步骤 3。 +- 提示「不是内部或外部命令」「command not found」,说明没装,照下面的折叠盒装上。 + +Windows 用户注意:篇 2 咱们装的是 MSYS2 + g++ 那套工具链,里面没有 clangd。clangd 跟着 LLVM 这个大包走,得单独装。 +::: + +::: details 点开看:各平台怎么装 clangd 程序 +Windows 上有两条路。 + +第一条路接着篇 2 的 MSYS2 装,最省事。打开「MSYS2 UCRT64」终端(篇 2 装的那个),敲: + +```bash +pacman -S mingw-w64-ucrt-x86_64-clang-tools-extra +``` + +装完 clangd 就在 `C:\msys64\ucrt64\bin` 下,跟您篇 2 装的 g++ 在同一个目录,PATH 已经配好了,直接能用。 + +第二条路是装独立的 LLVM 包,用 winget(Win10 之后系统自带)。开 PowerShell 或 cmd,敲: + +```bash +winget install LLVM.LLVM +``` + +装完之后 LLVM 的工具会进 `C:\Program Files\LLVM\bin`。这个路径默认不在系统的 PATH 里,您要么把它加进 PATH(让终端在任何地方都能找到 clangd),要么等会儿装好 vscode 的 clangd 扩展后,在扩展设置里手动指一下 clangd.exe 的路径。扩展通常能自己找到,找不到再手动指。 + +两条路二选一就行。用 scoop 或 chocolatey 的读者,命令分别是 `scoop install llvm` 和 `choco install llvm`。 + +Linux(Debian/Ubuntu 系)直接用 apt: + +```bash +sudo apt install clangd +``` + +Fedora 系是 `sudo dnf install clang-tools-extra`,Arch 系是 `sudo pacman -S clang`。 + +macOS 用 Homebrew: + +```bash +brew install llvm +``` + +::: tip macOS 有个坑 +macOS 自带的 `clang`(来自 Xcode Command Line Tools)不带 clangd。光有系统 clang 不够,必须 `brew install llvm` 装完整 LLVM,然后还要把 `/opt/homebrew/opt/llvm/bin`(Apple Silicon)或 `/usr/local/opt/llvm/bin`(Intel)加进 PATH,否则终端还是找不到 clangd。 +::: +::: + +### 步骤 3:关掉 C/C++ 扩展的代码理解 + +这是最容易被忽略、但最关键的一步。 + +现在 vscode 里有两个扩展都想帮您分析 C++ 代码:篇 2 装的 C/C++ 扩展、刚装的 clangd。两个一起干活会打架,补全列表可能弹两份、跳转可能跳到不一样的地方、红线画得到处都是。咱们让它们分工:clangd 管「看懂代码」(补全、跳转、报错),C/C++ 扩展留着管调试(后面篇 6 会用到,调试那块 clangd 不管)。 + +> 笔者补充一下,现在的clangd插件会自己检查一下,发现有微软的intelliSense会问你你要不要Disabled掉他,选择是! + +要做的就是关掉 C/C++ 扩展的代码理解功能。打开设置页:菜单 File → Preferences → Settings,或者直接 `Ctrl+,`。搜索框里输 `C_Cpp: Intellisense Engine`( IntelliSense 是「智能提示」的英文叫法),把它的值从默认的 `Default` 改成 `disabled`。 + +如果您嫌点设置页麻烦,也可以直接改配置文件。在工程根目录建一个 `.vscode` 文件夹,里面放一个 `settings.json`,内容是: + +```json +{ + "C_Cpp.intelliSenseEngine": "disabled" +} +``` + +::: tip 两种写法等价 +设置页改的是 vscode 的全局配置(所有项目都生效);写 `settings.json` 改的是这个工程的配置(只对当前工程生效)。新手两种都行,写 `settings.json` 的好处是跟着工程走,换台电脑打开同一个工程,设置还在。 +::: + +改完之后,您应该能在 vscode 右下角状态栏看到一个 `clangd` 字样(之前可能是 `C/C++` 或 `C/C++ IntelliSense`),这就说明现在管代码理解的是 clangd 了。 + +## 见证奇迹 + +三步做完,重新打开 `main.cpp`(或者随便点一下编辑区让它刷新)。您大概率会看到这几件事同时发生: + +`#include ` 那行的红波浪线消失了。 + +按住 `Ctrl` 点 `greet` 这个函数名,光标嗖地跳到了 `greet.cpp` 里函数定义那一行。 + +在 `main` 函数里敲 `std::`,弹出一个补全列表,列出了 `cout`、`endl`、`vector` 这些标准库的东西。 + +之前 vscode 看不懂,现在看懂了。区别就这一份 `compile_commands.json` 加一个 clangd。 + +## 刚才到底发生了什么 + +```mermaid +flowchart LR + A["CMakeLists.txt"] -->|configure| B["CMake"] + B --> C["build/compile_commands.json"] + C -->|clangd 读| D["看懂代码
补全/跳转/报错"] +``` + + +咱们退一步,把这事的来龙去脉理一遍。 + +clangd 这个程序,本质上就是个「替编辑器读代码」的助手。它得知道两件事才能干活:您的代码用的是什么 C++ 标准(C++17?C++20?)、每个 `.cpp` 引用了哪些头文件。不知道这两样,它两眼一抹黑,连 `std::string` 是什么都认不出来,自然只能把代码画满红线。 + +这两样信息,正好编译器在编译的时候全都用过一遍:CMake 配置时已经定好了用 C++17、已经知道 `main.cpp` include 了 `greet.h`。`CMAKE_EXPORT_COMPILE_COMMANDS ON` 那一行,就是让 CMake 把这些编译信息原样抄一份,写成 clangd 能读的 `compile_commands.json` 文件。 + +clangd 启动后做的第一件事,就是从您打开的 `.cpp` 文件往上找 `compile_commands.json`,找到就读进来。有了这份说明书,它就精确知道每个文件该怎么理解,补全、跳转、报错全都准。少了这份文件,或者文件过时(您改了 `CMakeLists.txt` 但没重新 Configure),clangd 就会糊涂,红线又会回来。 + +所以以后您要是遇到「明明能编译、clangd 却报红线」,第一反应不是怀疑代码,是重新点一下 Configure,让 CMake 把说明书刷新一遍。 + +## 折叠盒:compile_commands.json 长啥样 + +您不用读懂它的每个字段,扫一眼有个概念就行。打开 `build/compile_commands.json`,里面是个 JSON 数组,每个 `.cpp` 文件占一项,当然,这个是样例的输出哈,别碰这个文件,你也不应该编辑他! + +```json +[ + { + "directory": "D:/code/greeter/build", + "command": "C:\\msys64\\mingw64\\bin\\c++.exe ... -std=gnu++17 ... D:/code/greeter/main.cpp", + "file": "D:/code/greeter/main.cpp" + }, + { + "directory": "D:/code/greeter/build", + "command": "... D:/code/greeter/greet.cpp", + "file": "D:/code/greeter/greet.cpp" + } +] +``` + +三个字段的意思: + +`directory` 是编译这个文件时所在的目录,通常是您的 `build` 文件夹。`command` 是完整的编译命令,里面有编译器路径、`-std=gnu++17`(用的 C++ 标准)、所有头文件搜索路径,clangd 靠这个还原编译器的视角。`file` 就是这条记录对应的源文件。 + +clangd 读进来,就相当于「站在编译器的位置」重新看了一遍您的代码,所以它能判断的事情跟编译器一致:能编译过的就不会画红线。 + +## 折叠盒:命令行重新配置 + +::: details 点开看:命令行怎么做 +要是您习惯敲命令,配置和之前一样,在工程根目录开终端: + +```bash +cmake -B build +``` + +CMake 会重新读 `CMakeLists.txt`(这次带着那行 `EXPORT_COMPILE_COMMANDS`),刷新 `build` 目录里的内容,包括 `compile_commands.json`。 + +不想改 `CMakeLists.txt` 的话,也可以在配置命令里临时加一个参数达到同样效果: + +```bash +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +效果跟在 `CMakeLists.txt` 里写 `set(CMAKE_EXPORT_COMPILE_COMMANDS ON)` 一样,区别只是后者跟着工程走(换台电脑也生效),前者只在这次配置生效。咱们教程推荐写进 `CMakeLists.txt`,一劳永逸。 +::: + +## clangd 还是 C/C++ 扩展 + +到这儿您可能有个疑问:那 C/C++ 扩展是装了干嘛的,是不是可以卸了? + +咱们这套教程的分工是这样的:代码理解(高亮、补全、跳转、报错)归 clangd 管,因为它准;调试(断点、单步、看变量)归 C/C++ 扩展管,因为调试这块它做得成熟,篇 6 会专门讲。两个扩展分工,各管一摊,不打架(所以步骤 3 只关掉 C/C++ 扩展的 IntelliSense,没让您卸载它)。 + +::: tip 跟旧文章对齐 +这套教程的 vol1 老文章里,当年推荐过用 C/C++ 扩展管代码理解。clangd 这几年成熟了、准头超过 C/C++ 扩展之后,社区普遍换成 clangd 了。以这篇为准,老文章里那段过时了。 +::: + +到这一篇为止,您的 vscode 已经有两样本事了:会编译(篇 3、篇 4 装的 CMake Tools,管怎么把 `.cpp` 变成 `.exe`),会看懂代码(这篇装的 clangd,管补全、跳转、报错)。写 C++ 顺手的两个地基都铺好了。 + +接下来您可以往好几个方向走:想知道代码写错了怎么一步步调试的,去看下一篇讲调试的;想多写几行、看看 C++ 到底能干啥的,可以开始翻正文卷了。地基打牢了,往上盖楼就是。 + + +::: details 点开看:clangd 想再多学一点 + +- [VS Code 插件 clangd 的用法](https://www.cnblogs.com/newtonltr/p/18867195) —— clangd 安装配置详解(LSP 工作原理 + compile_commands.json 怎么用) +::: diff --git a/documents/getting-started/06-where-next.md b/documents/getting-started/06-where-next.md new file mode 100644 index 000000000..50458336a --- /dev/null +++ b/documents/getting-started/06-where-next.md @@ -0,0 +1,50 @@ +--- +title: "跑通了——然后去哪" +description: "起步卷干完,按目标挑下一步:学语法、啃 CMake、挖编译链接、做嵌入式,四条路各自指向哪一卷" +chapter: 14 +order: 6 +platform: host +difficulty: beginner +cpp_standard: [17, 20] +tags: + - host + - 入门 + - 基础 + - beginner + - 工具链 +reading_time_minutes: 3 +--- + +# 跑通了——然后去哪 + +到这儿,您已经走完了起步卷的全部活:篇 2 把 vscode、编译器、CMake 装齐,篇 3 在 vscode 里跑通第一个 hello,篇 4 把项目扩成多文件、第一次正儿八经用 CMake 管工程,篇 5 让 vscode 真正看懂您的代码(点函数能跳、红线能消)。一套写 C++ 的环境摆在面前,能编译、能补全、能跳转。这套起步卷的活,到此干完了。 + +接下来去哪,看您的目标。下面四条路,挑最贴您想法的那条往下走就行。 + +## 想先把 C++ 学扎实 + +起步卷解决的是「环境能跑」,没碰一句正经的 C++ 语法——什么叫变量、循环怎么写、函数怎么定义、类是什么,咱们一个字还没讲。这些才是写 C++ 的本钱,也是后面所有卷的地基。 + +下一步去 [卷一·基础入门](/vol1-fundamentals/),从 C++ 最基础的语法一路学到面向对象、模板。这一卷是主线,无论您最后想做哪个方向,都绕不开它。先把卷一啃完,再谈别的。 + +## 想搞懂 CMake 和构建系统 + +起步卷里您只学了「照抄一段 CMakeLists、点按钮、能跑」。CMake 背后到底在干什么、为什么有「配置」和「生成」两步、`target` 这个词为啥到处出现、`add_executable` 和 `target_link_libraries` 各管哪一摊——这些都没展开。 + +想知道答案,去 [卷七·工程实践](/vol7-engineering/)。那里有 CMake 进阶的内容,从单个 target 讲到多模块组织、外部依赖怎么拉进来。不过提醒一句:卷七默认您已经会基本 C++ 语法,所以哪怕您更想搞工程,也建议卷一先过一遍,不然看着会卡。 + +## 想懂编译和链接的原理 + +您在篇 4 可能已经碰到过几个奇怪现象:明明只改了一个文件,CMake 为啥只重编它、别的文件不动;偶尔冒出来一条 `undefined reference`,报错长得吓人;还有人聊什么静态库、动态库,听着像两个完全不同的东西。这些底下都是同一套机制——编译和链接。 + +要把这套机制弄明白,去 [编译与链接深入](/compilation/)。那里从「编译器把一个 `.cpp` 翻成什么」讲到「链接器怎么把一堆碎片拼成 `.exe`」,静态库和动态库的区别、`undefined reference` 到底卡在哪一步,都讲得透。讲得比较深,新手建议先把卷一啃完再来,不然容易劝退。 + +## 想做嵌入式,写单片机 + +不少朋友奔着嵌入式来——想让自己的代码跑在 STM32 这种指甲盖大的芯片上,点灯、读传感器、驱动电机。先说句实话:嵌入式这行大多数项目用的是 C,不是 C++。但现代 C++ 在嵌入式里也能用、还有它的好处(类型安全、零开销抽象、RAII 管资源),咱们这套教程的嵌入式线就走 C++ 路线,在 [卷八·领域应用](/vol8-domains/)。 + +但嵌入式这条线门槛不低:您得先会 C++ 语法(卷一),还得懂点构建和工具链(卷七的交叉编译那部分),芯片上的资源又紧又刁。所以前置条件是卷一到卷七的基础先打牢,别一上来就扎进芯片,容易卡在半空。 + +## 起步卷就送到这儿 + +起步卷是把您送到 C++ 这扇大门口,钥匙塞到手里、门指给您看。真正的 C++ 旅程,从 [卷一](/vol1-fundamentals/) 开始。 diff --git a/documents/getting-started/images/build_vscode.png b/documents/getting-started/images/build_vscode.png new file mode 100644 index 000000000..00b419482 Binary files /dev/null and b/documents/getting-started/images/build_vscode.png differ diff --git a/documents/getting-started/images/cmake_config_auto.png b/documents/getting-started/images/cmake_config_auto.png new file mode 100644 index 000000000..3c8213cf6 Binary files /dev/null and b/documents/getting-started/images/cmake_config_auto.png differ diff --git a/documents/getting-started/images/download_msys2.png b/documents/getting-started/images/download_msys2.png new file mode 100644 index 000000000..45bdf0ebf Binary files /dev/null and b/documents/getting-started/images/download_msys2.png differ diff --git a/documents/getting-started/images/msys-install-path-selection.png b/documents/getting-started/images/msys-install-path-selection.png new file mode 100644 index 000000000..61328c952 Binary files /dev/null and b/documents/getting-started/images/msys-install-path-selection.png differ diff --git a/documents/getting-started/images/msys2_g++.png b/documents/getting-started/images/msys2_g++.png new file mode 100644 index 000000000..0e177d7e7 Binary files /dev/null and b/documents/getting-started/images/msys2_g++.png differ diff --git a/documents/getting-started/images/notepad_cpp.png b/documents/getting-started/images/notepad_cpp.png new file mode 100644 index 000000000..59682f61b Binary files /dev/null and b/documents/getting-started/images/notepad_cpp.png differ diff --git a/documents/getting-started/images/shell_zsh.png b/documents/getting-started/images/shell_zsh.png new file mode 100644 index 000000000..33973cebd Binary files /dev/null and b/documents/getting-started/images/shell_zsh.png differ diff --git a/documents/getting-started/images/vs.png b/documents/getting-started/images/vs.png new file mode 100644 index 000000000..4c95f0a70 Binary files /dev/null and b/documents/getting-started/images/vs.png differ diff --git a/documents/getting-started/images/vscode.png b/documents/getting-started/images/vscode.png new file mode 100644 index 000000000..105c644b4 Binary files /dev/null and b/documents/getting-started/images/vscode.png differ diff --git a/documents/getting-started/index.md b/documents/getting-started/index.md new file mode 100644 index 000000000..8888f20df --- /dev/null +++ b/documents/getting-started/index.md @@ -0,0 +1,31 @@ +--- +title: "新手起步" +description: "面向零基础读者的起步卷:从认识编辑器、装环境到跑通第一个 C++ 程序、让 vscode 看懂代码" +platform: host +tags: + - cpp-modern + - host + - beginner + - 入门 +--- + +# 新手起步 + +嘿!欢迎来到现代C++! + +这套起步卷是给从没写过代码的纯小白准备的。从「编辑器是什么」讲起,到把第一个 C++ 程序真正跑起来、让 vscode 看懂您的代码,全程鼠标点、每步配截图、命令行进折叠盒。 + +读完这一卷,您能在 Windows 上独立搭好写 C++ 的环境(vscode、编译器、CMake),跑通一个多文件工程,并且让编辑器的补全、跳转、报错都好使。后面想学 C++ 语法去卷一,想搞懂构建深入去卷七,想做嵌入式去卷八——起步卷只负责把您送到门口。 + +> 想看详细的多路线环境搭建(MSVC vs MinGW 对比、vcpkg、Linux),去 [卷一·基础入门](/vol1-fundamentals/) 的环境搭建章。起步卷只走一条最省心的快车道,不展开对比。 + +## 章节导航 + + + 编辑器、编译器是什么 + 装好写 C++ 要用的三样东西 + 您的第一个 C++ 程序——在 vscode 里跑通 hello + 项目变大——多个文件怎么办,引出 CMake + 让 vscode 看懂您的代码——装 clangd,红线消失 + 跑通了——然后去哪 + diff --git a/documents/index.md b/documents/index.md index f60364db7..4ea95c83a 100644 --- a/documents/index.md +++ b/documents/index.md @@ -9,19 +9,25 @@ hero: tagline: "不止于语法速查 —— 从基础到工程实战的一条完整现代 C++ 路径" actions: - theme: brand - text: 开始学习 + text: 零基础起步 + link: /getting-started/ + - theme: alt + text: 直接学语法 link: /vol1-fundamentals/ - theme: alt text: 查看路线图 link: /roadmap/ - - theme: alt - text: C++ 速查 - link: /cpp-reference/ - theme: alt text: GitHub link: https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP features: + - title: "新手起步" + details: "从没写过代码也能跟上:装环境、跑通第一个 C++ 程序、让 vscode 看懂代码。六篇手把手,全程鼠标点、命令行进折叠盒。" + icon: '' + link: /getting-started/ + linkText: 开始阅读 + - title: "卷一 · 基础入门" details: "从零开始,系统化学习 C++ 基础知识。适合零基础读者。" icon: '' diff --git a/documents/vol1-fundamentals/ch00/02-setup-windows.md b/documents/vol1-fundamentals/ch00/02-setup-windows.md index e736aeab9..be987e01f 100644 --- a/documents/vol1-fundamentals/ch00/02-setup-windows.md +++ b/documents/vol1-fundamentals/ch00/02-setup-windows.md @@ -280,7 +280,9 @@ int main() ## 第四步——在 VS Code 里配置开发环境 -不管你用了哪条编译器路线,VS Code 都是一个很不错的轻量级编辑器选择。我们需要安装以下几个扩展:**C/C++**(Microsoft 出品,提供语法高亮、IntelliSense、调试支持)和 **CMake Tools**(CMake 项目管理和构建)。如果你习惯用中文界面,再加一个 Chinese Language Pack 就行。 +不管你用了哪条编译器路线,VS Code 都是一个很不错的轻量级编辑器选择。我们需要安装以下几个扩展:**C/C++**(Microsoft 出品,提供语法高亮、调试支持)和 **CMake Tools**(CMake 项目管理和构建)。如果你习惯用中文界面,再加一个 Chinese Language Pack 就行。 + +> 代码补全、跳转这些「IntelliSense」功能,这套教程推荐另装 **clangd** 扩展来管(比 C/C++ 扩展准),C/C++ 扩展留着管调试就行。详细做法见 [新手起步卷 · 装 clangd](/getting-started/05-vscode-clangd)。 CMake Tools 扩展会自动检测系统中的编译器。安装好扩展后打开我们的项目目录,VS Code 底部状态栏会出现一个 "Kit" 选择项,点击它就能选择要用的编译器——如果你同时装了 MSVC 和 MinGW,这里可以切换。选好之后 CMake Tools 会自动配置项目,状态栏上会显示构建配置和编译器信息。 diff --git a/documents/vol1-fundamentals/ch00/index.md b/documents/vol1-fundamentals/ch00/index.md index d47ed43b3..68450df3d 100644 --- a/documents/vol1-fundamentals/ch00/index.md +++ b/documents/vol1-fundamentals/ch00/index.md @@ -9,6 +9,8 @@ description: "搭建 C++ 开发环境,编写并运行第一个程序" 本章适合完全没有 C++ 经验的读者。如果你已经有趁手的工具链,可以跳过环境搭建,直接从第一个程序开始。 +> 只想最快跑起来、不想纠结选哪条路线?看 [新手起步卷](/getting-started/),一条最省心路线、手把手六篇搞定。这一章是多路线详细版(MSVC vs MinGW、vcpkg、Linux),供你想搞懂每步为什么、或要做对比时查阅。 + ## 本章内容 diff --git a/documents/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md b/documents/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md new file mode 100644 index 000000000..bc83a4e86 --- /dev/null +++ b/documents/vol7-engineering/ch00-cmake-fundamentals/01-what-is-cmake.md @@ -0,0 +1,247 @@ +--- +title: "CMake 是什么——构建系统生成器的两段式流水线" +description: "讲透 CMake 作为构建系统生成器的定位,configure 与 build 两段式流水线各自干什么,以及 Make/Ninja/Visual Studio 三种 Generator 怎么选" +chapter: 7 +order: 1 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 18 +prerequisites: + - "vol1 ch00: 第一个程序" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# CMake 是什么——构建系统生成器的两段式流水线 + +卷一第一个程序那篇,咱们点过 CMake 的名字,也照着抄过五行 `CMakeLists.txt` 把工程跑通。当时笔者留了一句「我们这里先用 `g++`,等后面的章节再正式引入 CMake」(03-first-program.md 第 119 行)。这句话欠了好几卷,这一篇正是来兑现的。 + +但今天不是来教您怎么敲命令的——`cmake -B build` 谁不会敲。咱们要搞清楚的是 CMake 在背后到底干了什么:为什么有「配置」和「生成」两步、它跟 `g++` 到底是什么关系、为什么同一个工程既能产出 Makefile 又能产出 `build.ninja`。把这些想透,后面学 target、学 `find_package`、学交叉编译才不会觉得是在背咒语。 + +## CMake 不是编译器,它是构建系统生成器 + +这一节先把最根本的误解拆掉。 + +很多人第一次接触 CMake 的反应是「它是个编译器」或者「它替代了 `g++`」。都不是。CMake 自己一行代码都不编译。它真正干的事情是:读您写的 `CMakeLists.txt`,根据当前平台和工具链,生成别的构建系统文件——Makefile、`build.ninja`、Visual Studio 的 `.sln`——然后由 Make、Ninja 或 MSBuild 这些「真正的构建工具」去调编译器。 + +一句话:**CMake 生成能编译代码的文件。** 它是构建系统之上的一层,业内叫「构建系统生成器」(build system generator),或者换个说法叫「元构建系统」(meta build system)。 + +::: details 元构建系统这个词哪来的 +普通构建系统(Make/Ninja)直接描述「编译哪些源文件、怎么链接」。元构建系统再往上一层:它不直接描述编译过程,而是描述「这个项目的结构是什么」,再根据您当前选的工具链,翻译成对应构建系统能读的文件。CMake、Meson、Bazel 都属于这一层。 +::: + +为什么 C++ 比 Rust 和 Go 多出来这么一层?根子在 ISO。C++ 标准委员会只管标准语言本身(语法、标准库),从来不管工具链怎么组织、构建文件长什么样。结果就是 Windows 上 MSVC 一套、Linux 上 GCC 一套、嵌入式平台 `arm-none-eabi-g++` 又一套,每家都有自己的编译选项和工程格式。Rust 和 Go 是「语言 + 官方工具链(`cargo`/`go`)」打包发行的,根本没这个问题。 + +CMake 解决的就是这个历史遗留:让您写一份 `CMakeLists.txt`,它在 Windows 上吐 Visual Studio 工程,在 Linux 上吐 Makefile,在想要速度的机器上吐 Ninja。源码描述一份,构建文件因地制宜。 + +## 两段式流水线:configure 和 build + +理解了 CMake 的定位,那个让人困惑的「为什么 CMake 要敲两次命令」就顺理成章了。CMake 的工作流天然分成两段。 + +第一段叫 **configure(配置)**。这一段里 CMake 读您的 `CMakeLists.txt`,检测编译器在哪、能不能跑、什么版本,把结果记进 `CMakeCache.txt`,最后生成构建文件。注意:这一段**没有编译您的一行代码**。它只是在「搭脚手架」。 + +第二段叫 **build(构建)**。这一段才真正调编译器,把源文件一个个编成 `.o`、再链接成可执行文件或库。 + +咱们来看真实的 configure 输出。下面是笔者在本机(GCC 16.1.1、CMake 4.4.0)对一个最小工程跑 `cmake -B build -G Ninja` 的结果: + +```text +$ cmake -B build -G Ninja +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-demo/build +``` + +逐行看。前六行全是 CMake 在「摸底」:识别编译器版本(`GNU 16.1.1`)、探 ABI 信息、确认编译器能正常工作、收集它支持的编译特性。这些信息后面要用——比如您在 `CMakeLists.txt` 里写了 `set(CMAKE_CXX_STANDARD 17)`,CMake 就得知道当前编译器到底支不支持 C++17,不支持就立刻报错给您。然后 `Configuring done` 表示摸底完成、`Generating done` 表示构建文件已经写盘,最后一行告诉您文件落在哪。 + +注意整个过程没有一行 `Building CXX`。这就是 configure:它只搭台,不唱戏。 + +再看 build。`cmake --build build` 是统一入口,不管底层是 Make 还是 Ninja 都这么敲: + +```text +$ cmake --build build +[1/2] Building CXX object CMakeFiles/hello.dir/main.cpp.o +[2/2] Linking CXX executable hello +``` + +`[1/2]`、`[2/2]` 是 Ninja 的进度标记,意思是「两步里的第一步、第二步」。第一步编译 `main.cpp` 成目标文件,第二步链接成 `hello`。这才是真正在跑 `g++`。 + +为什么非要分两步?关键在**性能特征不一样**。configure 慢——它要重启进程、重新摸底、重新生成所有构建文件。但 configure 只在您改了 `CMakeLists.txt`、加新文件、切换 Generator 时才需要重跑。build 快——它做的是增量编译,只重编动过的文件。所以日常开发循环里,configure 偶尔跑一次,build 跑无数次。 + +咱们用同一个最小工程实测一下,看缓存对 configure 速度的影响: + +```text +$ rm -rf build && time cmake -B build -G Ninja > /dev/null +cmake -B build -G Ninja > /dev/null 0.09s user 0.08s system 93% cpu 0.183 total + +$ time cmake -B build -G Ninja +-- Configuring done (0.0s) +-- Generating done (0.0s) +cmake -B build -G Ninja 0.01s user 0.00s system 90% cpu 0.017 total +``` + +冷启 configure 0.183 秒,命中缓存的二次 configure 只要 0.017 秒,差了十倍。这个最小工程体量太小看不出感觉,但真实工程里第一次 configure 要十几秒、二次只要零点几秒是常态。这就是为什么把 configure 拆出来、配上缓存是有意义的——不然每次编一行代码都得重新摸一遍底,谁受得了。 + +## Generator:选 Make 还是 Ninja + +CMake 把「生成哪种构建文件」这件事抽象成了一个叫 **Generator(生成器)** 的概念。您在 configure 时通过 `-G` 参数选一个,CMake 就生成对应那一套文件。 + +三个最常用的 Generator: + +Unix Makefiles 是默认选项,Linux/macOS 上不指定 `-G` 就是它。它生成 `Makefile`,由 `make` 命令驱动构建。最老牌、最通用、所有 Unix 系统都自带 `make`。缺点是慢——`make` 是上世纪七十年代的设计,依赖检查和并行调度都不够现代。 + +Ninja 是现代推荐选项。它生成 `build.ninja`,由 `ninja` 命令驱动。Ninja 是专门为「被元构建系统生成」而设计的,启动开销小、并行调度激进、增量构建快。代价是要单独装一份 `ninja`(包名通常就叫 `ninja` 或 `ninja-build`)。 + +Visual Studio 是 Windows 上 IDE 集成用的选项(`-G "Visual Studio 17 2022"`)。它生成 `.sln` 和 `.vcxproj`,能直接在 Visual Studio 里打开、F5 调试。不追求 IDE 体验的话,Windows 上一样能用 Ninja。 + +命令差异就一个 `-G` 参数。下面对同一个工程分别用两种 Generator 跑一遍,看它们各自生成什么: + +```text +cmake -B build -G Ninja # 选 Ninja +cmake -B build-make -G "Unix Makefiles" # 选 Make +``` + +两种 configure 命令的输出长得几乎一样(都是那段「检测编译器、Configuring done」),区别在生成出来的构建文件不一样。咱们直接对比两个 `build/` 目录下的产物: + +```text +$ ls build/ # Ninja 生成的 +build.ninja +cmake_install.cmake +CMakeCache.txt +CMakeFiles +hello + +$ ls build-make/ # Make 生成的 +cmake_install.cmake +CMakeCache.txt +CMakeFiles +hello +Makefile +``` + +Ninja 那边多了个 `build.ninja`,Make 这边多了个 `Makefile`。`CMakeCache.txt`、`CMakeFiles/`、`cmake_install.cmake` 两边都有,是 CMake 自己的基础设施。 + +笔者的建议:本机开发一律默认 Ninja。它快得不只是一点点,而且 `build.ninja` 比 `Makefile` 简洁得多(您 `cat` 一下两份文件就知道)。除非环境里装不上 `ninja`,否则没理由退回 Make。后面讲到交叉编译、CI 时,Ninja 也是更顺手的选择。 + +## out-of-source 构建:别污染源码目录 + +CMake 默认推荐一种构建方式叫 **out-of-source 构建**(分离源码树和构建树)。意思是:所有构建产物——目标文件、可执行文件、`CMakeCache.txt`、生成的构建文件——都堆进一个 `build/` 子目录,源码目录保持干净。 + +`cmake -B build` 这个 `-B` 参数就是干这个的,它告诉 CMake「构建树放 `build/` 下」。目录结构长这样: + +```text +cmake-demo/ +├── CMakeLists.txt # 您写的,进 git +├── main.cpp # 您写的,进 git +└── build/ # CMake 生成的,进 .gitignore + ├── CMakeCache.txt + ├── CMakeFiles/ + ├── build.ninja + ├── cmake_install.cmake + └── hello # 最终可执行文件 +``` + +这种结构的好处很直接:源码目录里看不到一个 `.o` 文件,看不到 `a.out`,看不到临时产物。想清掉重来?`rm -rf build/` 一条命令,源码纹丝不动。想发版打包?源码目录里全是干净的源文件,不用费劲挑出哪些该归档哪些不该。 + +::: details in-source 构建也能跑,但别这么干 +CMake 也允许直接在源码目录里 `cmake .`(叫 in-source 构建),会直接在当前目录生成 Makefile 和一堆 `CMakeFiles/`。看起来省事,但一旦源码目录被污染,`git status` 一片红,清理起来要逐个找。CMake 新版本对此还有限制:默认拒绝在同一目录二次 configure,避免把源码树搞乱。养成 `-B build` 的习惯,省得后面受罪。 +::: + +这里要专门说说 **`CMakeCache.txt`** 这个文件。它就是前面那段「冷启 vs 缓存」里加速二次 configure 的关键。第一次 configure 时,CMake 把所有摸底结果存进去:编译器路径、编译器版本、Generator 选择、您通过 `-D` 设的变量、各种特性检测结果。下一次 configure 时,CMake 先读缓存,没变的东西直接复用,省掉重新摸底的时间。 + +打开看看,里面是键值对格式,关键字段长这样: + +```text +//Path to CXX compiler. +CMAKE_CXX_COMPILER:FILEPATH=/usr/sbin/c++ + +//Name of CMake project. +CMAKE_PROJECT_NAME:STATIC=hello_cmake + +//Name of generator. +CMAKE_GENERATOR:INTERNAL=Ninja +``` + +注意 `CMAKE_GENERATOR` 这一行——它记住了您选的 Generator。所以二次 configure 不用再写 `-G Ninja`,CMake 自己知道接着用 Ninja。这也是为什么有时候您想换 Generator 时,光敲 `-G` 没用、CMake 还在用旧的——`CMakeCache.txt` 把它锁住了,得 `rm -rf build/` 清掉重来。 + +`CMakeCache.txt` 进 git 吗?绝对不进。它跟机器环境强绑定(编译器路径、绝对路径都在里头),进了 git 保证每台机器都冲突。`build/` 整个目录进 `.gitignore`,一了百了。 + +## 最小工程三件套 + +讲完原理,咱们落到一个能跑的最小工程上。一个合法的 `CMakeLists.txt` 至少要有三行: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(hello_cmake LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(hello main.cpp) +``` + +第一行 `cmake_minimum_required(VERSION 3.20)` 声明本工程需要的最低 CMake 版本。低于这个版本的 CMake 看到这行直接报错退出。这一行的真正作用不只是版本检查——它还会触发 CMake 进入对应版本的「策略兼容模式」(policy),确保新版本 CMake 的行为变更不会悄悄影响老工程。`3.20` 是个稳妥的下限,2021 年发布,主流发行版都能装上。 + +第二行 `project(hello_cmake LANGUAGES CXX)` 给工程起名 `hello_cmake`,并声明要用 C++(`CXX`)。`project()` 这一行触发的就是前面 configure 输出里那段「检测编译器、探 ABI」的摸底——`LANGUAGES CXX` 告诉 CMake「我需要 C++ 编译器」,CMake 才会去满世界找 `g++`/`clang++`/`MSVC`。 + +第三行 `add_executable(hello main.cpp)` 是真正告诉 CMake「造一个叫 `hello` 的可执行文件,源码是 `main.cpp`」。这一行配置进 `build.ninja` 后,build 阶段就会变成 `g++ main.cpp -o hello`。 + +中间那两行 `set(CMAKE_CXX_STANDARD 17)` 和 `set(CMAKE_CXX_STANDARD_REQUIRED ON)` 是设置默认 C++ 标准。前者要求用 C++17 编译,后者要求「编译器不支持就报错而不是降级」。这两行以后讲到 target 时还会回来——更现代的写法是 `target_compile_features()`,现在先这么写够用。 + +配套的 `main.cpp`: + +```cpp +#include + +int main() +{ + std::cout << "Hello, CMake!\n"; + return 0; +} +``` + +三步命令跑通整个流水线: + +```text +$ cmake -B build -G Ninja && cmake --build build && ./build/hello +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-demo/build +[1/2] Building CXX object CMakeFiles/hello.dir/main.cpp.o +[2/2] Linking CXX executable hello +Hello, CMake! +``` + +最后一行 `Hello, CMake!` 就是 `./build/hello` 跑出来的。第一条命令搭脚手架、第二条命令真正编译链接、第三条命令执行。这条流水线以后不论工程多大,骨架都一样。 + +## 配套示例 + +本文的最小工程可以从仓库示例目录直接跑: + +```text +code/examples/vol7/cmake-fundamentals/01-what-is-cmake/ +├── CMakeLists.txt +└── main.cpp +``` + +进入该目录后,照搬上面那三步命令即可复现全部输出。 + +到这里咱们把 CMake 的定位、两段式流水线、Generator 选择、out-of-source 构建讲透了,也跑通了最小工程。下一篇要解决一个更实际的问题:当工程不止一个 `main.cpp`、还要拆成多个模块、还要复用第三方库的时候,怎么管理「这个 target 用哪些头文件、链接哪个库、开什么编译选项」。这就引出 CMake 的核心心智模型——**target**——以及为什么不该再用全局的 `include_directories()` 这种「命令式」写法,而该转向 `target_include_directories()` 这种「面向对象」的写法。 diff --git a/documents/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md b/documents/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md new file mode 100644 index 000000000..dfbb735c4 --- /dev/null +++ b/documents/vol7-engineering/ch00-cmake-fundamentals/02-target-and-usage-requirements.md @@ -0,0 +1,338 @@ +--- +title: "Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求" +description: "讲透 target 是什么、target_* 命令为什么是成员方法、PUBLIC/PRIVATE/INTERFACE 三态怎么传播,以及为什么目录级命令是反模式" +chapter: 7 +order: 2 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 20 +prerequisites: + - "vol7 ch00 01: CMake 是什么——构建系统生成器的两段式流水线" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求 + +上一篇咱们跑通了一个最小工程,`CMakeLists.txt` 里就一行真正干活的 `add_executable(hello main.cpp)`。当时它那一行命令制造出来的东西,笔者一直没给名字。这一篇就把这个名字交出来:**target**。 + +target 这个词,在 CMake 官方文档里反复出现,在所有「现代 CMake」教程里被奉为头号概念,社区还有句口头禅叫 think in targets not variables(围绕 target 想,别围绕变量想)。凭什么现代 CMake 把它捧这么高,又为什么您照着老教程抄的 `include_directories()` 已经是反模式,这篇就把它讲透。这是现代 CMake 和老式 CMake 的分水岭,跨过去,后面看任何 `CMakeLists.txt` 都不会觉得是在背咒语。 + +## 把 target 当一个对象 + +target 不是一个抽象比喻,它就是 CMake 内部的一个数据结构。理解它最快的方式,是把它想成一个 C++ 对象。 + +`add_executable(app main.cpp)` 和 `add_library(mylib STATIC src/mylib.cpp)` 这两行命令,是**构造函数**。它们造出一个 target 对象,给它起名 `app` 或 `mylib`,记下它由哪些源文件构成、要编成可执行还是库。从这一行开始,`app` 和 `mylib` 这两个名字就在 CMake 的世界里「活」了,您后面所有配置都拿这个名字当 handle(句柄)去操作。 + +造出来之后呢?您要给它加头文件搜索路径、告诉它链接哪个库、开哪些编译选项。这些操作对应的就是一堆 `target_*` 开头的命令: + +```cmake +target_include_directories(mylib PUBLIC include) +target_link_libraries(mylib PRIVATE fmt) +target_compile_options(mylib PRIVATE -Wall -Wextra) +target_compile_features(mylib PUBLIC cxx_std_17) +``` + +这些 `target_*` 命令是**成员方法**。它们干的事情本质都一样:拿着 target 的名字,往这个 target 对象上挂属性。`target_include_directories(mylib PUBLIC include)` 翻译过来就是「给 `mylib` 这个对象,往它的 include 路径属性里塞一个 `include`」。 + +而 target 身上挂的那些东西(include 路径、链接库列表、编译选项、C++ 标准要求),就是它的**成员变量**。每个 target 各自管各自的,互不打扰。 + +::: details target 在 CMake 内部到底是什么 +严格说,target 是 CMake 维护的一组属性的集合。它身上挂的属性可以在 configure 阶段用 `get_target_property(v mylib INCLUDE_DIRECTORIES)` 取出来看。下面实战那一节就会拿这个命令扒给咱们看,target 在内部不是黑盒。 +::: + +为什么这套「对象思维」重要?因为它把配置的范围限定死了。`target_include_directories(mylib PUBLIC include)` 只动 `mylib` 一个 target 的属性,不影响工程里其他任何 target。这正是接下来要讲的核心区别:老式 CMake 是「全局污染」,现代 CMake 是「target 私有」。 + +## 使用需求:PUBLIC/PRIVATE/INTERFACE 三态 + +光有 target 这个对象还不够。真正让现代 CMake 脱胎换骨的,是它对**使用需求(usage requirements)**的建模。这个词听着玄,其实就一句话:一个 target 在被自己编译时、和被别人链接时,要求的配置可能不一样。CMake 用三个关键字把这两种情况区分开。 + +PRIVATE 表示「我自己编译要用,但别人链接我不需要」。比如 `mylib` 内部实现里调用了第三方库 `fmt` 做字符串格式化,但 `mylib` 的公开头文件里完全看不到 `fmt` 的痕迹,下游链接 `mylib` 的人根本不知道 `fmt` 存在,自然也不需要 `fmt` 的头文件路径。这时候 `fmt` 对 `mylib` 就是 PRIVATE。 + +INTERFACE 表示「我自己不用,但别人链接我需要」。一个典型场景是 header-only 库(纯头文件库),它自己没有 `.cpp` 要编译,所以「自己用」这条是空的;但下游只要包含它的头文件就得有对应的 include 路径和 C++ 标准要求。这时候所有配置都进 INTERFACE。 + +PUBLIC 表示「两者都要,自己用加上别人也需要」。最常见的就是公开头文件里直接出现的类型。比如 `mylib.h` 的返回类型是 `std::string`,那下游链接 `mylib` 之后,编译器为了解析这个返回类型,必须能找到 `` 所在的 include 路径。这条路径 `mylib` 自己编 `.cpp` 时要用,下游链接 `mylib` 时也要用,这就是 PUBLIC。 + +把这三态的「自己用 / 别人用」拆开来,背后是一个简单的事实表: + +| 关键字 | 自己编译时用 | 别人链接时也用 | +|--------|:---:|:---:| +| PRIVATE | 是 | 否 | +| INTERFACE | 否 | 是 | +| PUBLIC | 是 | 是 | + +记住这张表,后面看任何 `target_*` 命令都套得上。 + +### 一个具体例子:fmt 是 PRIVATE, 是 INTERFACE + +光定义不够,咱们落到代码上。下面这个工程有三个 target:一个极简的 `fmt`(模拟第三方格式化库)、一个对外暴露的 `mylib` 静态库、一个下游 `app` 可执行文件。`mylib` 的实现内部用 `fmt::format`,但公开头文件只用了 `std::string`。 + +`mylib` 的公开头文件 `include/mylib/mylib.h`: + +```cpp +#pragma once +#include + +namespace mylib { + +/// @brief 把问候语格式化成带前缀的字符串 +/// @note 返回类型用 std::string —— 这是 mylib 公开 API 的一部分, +/// 下游 app 也必须看到完整的 std::string 定义, +/// 所以 对应的 include 路径属于 INTERFACE 需求 +std::string make_greeting(const std::string& name); + +} // namespace mylib +``` + +`mylib` 的实现 `src/mylib.cpp`: + +```cpp +#include "mylib/mylib.h" + +#include "fmt.h" + +namespace mylib { + +std::string make_greeting(const std::string& name) { + // fmt 是 mylib 内部实现细节,公开头文件 mylib.h 里看不到 fmt 的痕迹 + // 所以下游根本不需要知道 fmt 的存在 —— 这正是 fmt 应当为 PRIVATE 的理由 + return fmt::format("hello, {}!", name); +} + +} // namespace mylib +``` + +`CMakeLists.txt` 里给 `mylib` 挂属性的关键三行: + +```cmake +add_library(mylib STATIC src/mylib.cpp) +target_include_directories(mylib PUBLIC include) +target_link_libraries(mylib PRIVATE fmt) +``` + +`include` 写成 PUBLIC:`mylib` 自己编 `.cpp` 时要找 `mylib/mylib.h`(自己用),下游链接 `mylib` 后也要找 `mylib/mylib.h` 来包含它(别人用),两条都满足,所以是 PUBLIC。 + +`fmt` 写成 PRIVATE:`mylib.cpp` 内部要调 `fmt::format`(自己用),但 `mylib.h` 里没有 `fmt` 的任何符号,下游根本不需要看到 `fmt.h`(别人不用),所以是 PRIVATE。 + +### 改 PRIVATE 成 PUBLIC,看下游怎么被「传染」 + +讲概念最怕空对空。咱们直接动手把 `fmt` 从 PRIVATE 改成 PUBLIC,看下游 `app` 会发生什么。 + +先把工程配起来(用 Make 这个 Generator,因为它的 `flags.make` 文件能把每个 target 实际拿到的 include 路径清清楚楚列出来,Ninja 那边为了支持 C++ 模块把标志拆到别的文件里了,肉眼读不顺): + +```text +$ cmake -S . -B build -G "Unix Makefiles" +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +``` + +现在 `mylib` 把 `fmt` 写成 PRIVATE。看 CMake 给三个 target 各自生成的 include 标志: + +```text +$ cat build/CMakeFiles/mylib.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include -I/tmp/cmake-target-demo/fmt + +$ cat build/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include +``` + +逐字读。`mylib` 拿到两条路径:自己的 `include`(PUBLIC)加上 `fmt`(PRIVATE 自己编时也要)。`app` 只拿到一条 `include`,因为它只链接了 `mylib`,于是继承了 `mylib` 的 PUBLIC 部分(也就是 `include`),而 `fmt` 是 `mylib` 的 PRIVATE,没传过来。`app` 对 `fmt` 一无所知,这正是咱们想要的封装。 + +如果这时候 `app` 的 `main.cpp` 偷偷写一行 `#include "fmt.h"` 会怎样?编译器找不到这个头文件,直接挂掉。笔者实测过: + +```text +$ cmake --build build --target app +[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o +FAILED: CMakeFiles/app.dir/main.cpp.o +/tmp/cmake-target-demo/main.cpp:2:10: fatal error: fmt.h: No such file or directory + 2 | #include "fmt.h" + | ^~~~~~~ +compilation terminated. +``` + +这就是 PRIVATE 的物理含义:封装是真的,不是口头说说。 + +现在动一行,把 `target_link_libraries(mylib PRIVATE fmt)` 改成 `target_link_libraries(mylib PUBLIC fmt)`,重新 configure,再看 `app` 的 include 标志: + +```text +$ sed -i 's/target_link_libraries(mylib PRIVATE fmt)/target_link_libraries(mylib PUBLIC fmt)/' CMakeLists.txt +$ cmake -S . -B build -G "Unix Makefiles" > /dev/null +$ cat build/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include -I/tmp/cmake-target-demo/fmt +``` + +`app` 什么都没改,只因为上游 `mylib` 把 `fmt` 从 PRIVATE 改成 PUBLIC,`app` 就凭空多出了一条 `-I.../fmt`。现在 `app` 不用自己 `find_package(fmt)`、不用自己写 `target_link_libraries(app PRIVATE fmt)`,直接 `#include "fmt.h"` 就能编过。 + +这就是使用需求的**传播**:PUBLIC 把配置沿链接图向下游渗透,PRIVATE 把配置封锁在 target 内部。这种「自动传播」是现代 CMake 能把复杂依赖关系写得这么干净的根因。您只要正确标注每个依赖的公私有,下游链接一次就自动拿到该拿的全部配置。 + +::: warning 别用 PUBLIC 当万能补丁 +看到这里您可能心动了:既然 PUBLIC 能让下游自动拿到配置,那把所有依赖都写 PUBLIC 不就省事了?千万别。PUBLIC 等于把内部实现细节泄露给下游,下游一旦依赖了您暴露出去的 `fmt` 路径,您哪天想把 `fmt` 换成 `std::format`、或者升级版本改路径,下游就跟着炸。封装是给未来留余地,PUBLIC 用得越多,重构空间越小。原则是:能 PRIVATE 就别 PUBLIC。 +::: + +### INTERFACE_LINK_LIBRARIES 里那个 LINK_ONLY 是什么 + +讲到这儿有个细节值得展开。笔者用 `get_target_property` 扒过 `mylib` 的内部属性(把 `fmt` 配成 PRIVATE 的情况下): + +```text +mylib.INCLUDE_DIRECTORIES = /tmp/cmake-target-demo/include +mylib.INTERFACE_INCLUDE_DIRECTORIES = /tmp/cmake-target-demo/include +mylib.LINK_LIBRARIES = fmt +mylib.INTERFACE_LINK_LIBRARIES = $ +``` + +注意最后一行。PRIVATE 不是「下游完全不知道 fmt 存在」吗,怎么 `INTERFACE_LINK_LIBRARIES` 里又出现了 `fmt`? + +这里有个微妙但合理的区分:PRIVATE 封装的是 **include 路径**(下游编译时不需要 `fmt.h`),但**链接关系**是封不住的。`mylib` 是静态库,它的 `.o` 文件里引用了 `fmt::format` 的符号,链接器在最终把 `app` 链成可执行时,必须能找到 `libfmt.a` 把这些符号补上,不然链接器报 `undefined reference`。所以 CMake 用一个生成器表达式 `$` 表示「fmt 对下游仅参与链接、不参与编译」。这就解释了为什么您在 `app` 的 `flags.make` 里看不到 `-I.../fmt`(include 没传过来),但 `app` 还是能正常链接出可执行文件(链接关系传过来了)。PUBLIC/PRIVATE 控制的是配置的传播,不是链接图本身。 + +## 为什么目录级命令是反模式 + +理解了 target 私有性,再回头看老式 CMake 的写法,就明白它们为什么被现代 CMake 圈子一致抵制。 + +老式 CMake 用的是目录级、全局的命令: + +```cmake +# 老式 CMake 写法,现代项目里见一次就该重构 +include_directories(include) +include_directories(fmt) +add_definitions(-DUSE_FMT) +add_compile_options(-Wall) +``` + +`include_directories(include)` 的语义是「当前 `CMakeLists.txt` 目录及子目录里**所有** target,统统加上 `-Iinclude`」。`add_definitions(-DUSE_FMT)` 同理,所有 target 都会被定义 `-DUSE_FMT` 宏。 + +这种写法在小工程里看不出毛病,工程一大就崩。想象一个项目里有 `mylib`、`tests`、`benchmarks`、`tools` 四五个 target,您在顶层 `CMakeLists.txt` 写了一行 `add_compile_options(-Wall -Wextra -Werror)`,本意是给主库开严格警告,结果 `tests` 子目录下用 Catch2 写测试的那堆第三方代码也继承了 `-Werror`,编译一片红。您再去 `tests/CMakeLists.txt` 里想办法把 `-Werror` 关掉,又得记一堆绕过的写法。 + +再想象 `mylib` 内部用 `fmt`,您图省事在顶层写了 `include_directories(fmt)`,结果 `tools` 那个本来不该知道 `fmt` 的 target 也拿到了 `-Ifmt`,它的源码哪天不小心 `#include "fmt.h"` 也能编过,封装被悄悄打穿了。维护者回头想换掉 `fmt`,根本不知道哪些 target 是有意用 `fmt`,哪些是被全局命令顺带沾染的。 + +现代 CMake 用 target 级命令解决这两个问题。`target_include_directories(mylib PRIVATE fmt)` 把 `fmt` 的路径死死锁在 `mylib` 这个 target 内部,既不会泄漏到 `tools`,也不会泄漏到下游 `app`(因为 PRIVATE)。每个 target 自带一份配置边界,谁的依赖谁负责声明,依赖图清晰可追溯。 + +写法对照: + +```cmake +# 老式(目录级,全局污染) +include_directories(include) +add_definitions(-DMYLIB_EXPORTS) + +# 现代(target 级,边界清晰) +target_include_directories(mylib PUBLIC include) +target_compile_definitions(mylib PRIVATE MYLIB_EXPORTS) +``` + +迁移规则也直白:把所有 `include_directories()` 换成 `target_include_directories()`、`add_definitions()` 换成 `target_compile_definitions()`、`add_compile_options()` 换成 `target_compile_options()`,每个命令前面都加上具体 target 的名字。这是把老工程拉进现代 CMake 最低成本的一步。 + +::: details 顶层还能不能用变量设 C++ 标准 +您会看到很多 `CMakeLists.txt` 顶层写 `set(CMAKE_CXX_STANDARD 17)`。这其实也是一种目录级(全局)设置,它把 `CXX_STANDARD` 属性赋给当前目录下所有 target。这种用法目前还能接受,因为 C++ 标准对绝大多数工程而言就是「全工程统一」的全局属性。但更现代、更精确的写法是 `target_compile_features(mylib PUBLIC cxx_std_17)`,把 C++ 标准也变成 target 的使用需求,下游链接 `mylib` 自动继承 C++17 要求。下一篇讲 `find_package` 时会再回来对比这两种写法。 +::: + +## 实战:拆一个双 target 工程 + +把前面讲的拼起来。咱们用一个完整可跑的工程,演示 `mylib` 静态库 + `app` 可执行的双 target 配置,看 PUBLIC/PRIVATE 在真实构建里到底怎么流。完整工程在 `code/examples/vol7/cmake-fundamentals/02-target/`,结构如下: + +```text +02-target/ +├── CMakeLists.txt +├── fmt/ +│ ├── fmt.h # 模拟第三方库的极简实现 +│ └── fmt.cpp +├── include/ +│ └── mylib/ +│ └── mylib.h # mylib 公开头文件 +├── src/ +│ └── mylib.cpp # mylib 实现 +└── main.cpp # app 可执行 +``` + +完整 `CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(target_demo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# fmt:仅在本工程内部使用的极简"第三方库",真实工程会换成 find_package(fmt REQUIRED) +add_library(fmt STATIC fmt/fmt.cpp) +target_include_directories(fmt PUBLIC fmt) + +# mylib:对外暴露的库,公开头文件 include/mylib/mylib.h 用了 std::string +add_library(mylib STATIC src/mylib.cpp) +target_include_directories(mylib PUBLIC include) +# fmt 在这里写成 PRIVATE —— mylib.cpp 内部要用,但 mylib.h 完全不暴露 fmt +target_link_libraries(mylib PRIVATE fmt) + +# app:下游可执行,只链接 mylib,对 fmt 一无所知 +add_executable(app main.cpp) +target_link_libraries(app PRIVATE mylib) +``` + +读这份配置,从下往上看更清楚思路。`app` 只声明「我链接 `mylib`」,其它什么都没说。`mylib` 把自己的 `include` 目录 PUBLIC 出去,下游链接我时自动拿到这条路径;把 `fmt` 锁在 PRIVATE,下游别想知道我用 `fmt`。`fmt` 自己作为一个 STATIC 库存在,`include` 是它自己的 PUBLIC(这样 `mylib` 链接它时能拿到 `fmt.h` 路径)。 + +三步命令把工程跑通: + +```text +$ cmake -S . -B build -G Ninja && cmake --build build && ./build/app +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-target-demo/build +[1/6] Building CXX object CMakeFiles/fmt.dir/fmt/fmt.cpp.o +[2/6] Linking CXX static library libfmt.a +[3/6] Building CXX object CMakeFiles/mylib.dir/src/mylib.cpp.o +[4/6] Linking CXX static library libmylib.a +[5/6] Building CXX object CMakeFiles/app.dir/main.cpp.o +[6/6] Linking CXX executable app +hello, world! +``` + +六步的顺序里能看出依赖图。`fmt` 先编(步骤 1-2,它不依赖别人),`mylib` 后编(步骤 3-4,它依赖 `fmt`),`app` 最后编(步骤 5-6,它依赖 `mylib`)。Ninja 自动按依赖关系排好序,您什么都不用管。 + +最后那一行 `hello, world!` 是 `app` 跑出来的。它在 `main.cpp` 里只 `#include "mylib/mylib.h"`,编译器却能找到这个头文件,靠的就是 `mylib` 把 `include` 标成 PUBLIC,下游 `app` 链接 `mylib` 时自动继承了 `-I.../include`。 + +如果咱们想验证这条继承真的在起作用,最直接的办法是看 `app` 实际拿到的 include 标志。换成 Make 这个 Generator configure 一次,读 `app.dir/flags.make`: + +```text +$ cmake -S . -B build-mk -G "Unix Makefiles" > /dev/null +$ cat build-mk/CMakeFiles/app.dir/flags.make | grep INCLUDES +CXX_INCLUDES = -I/tmp/cmake-target-demo/include +``` + +`app` 没有自己写过一行 `target_include_directories`,但它的编译命令里硬是有 `-I.../include`。这就是 PUBLIC 使用需求在背后默默干的活。`fmt` 那条路径没出现,因为 `mylib` 把 `fmt` 标成了 PRIVATE,封装得严严实实。 + +## 配套示例 + +本文的双 target 工程在仓库示例目录可直接构建: + +```text +code/examples/vol7/cmake-fundamentals/02-target/ +├── CMakeLists.txt +├── fmt/ +│ ├── fmt.h +│ └── fmt.cpp +├── include/mylib/mylib.h +├── src/mylib.cpp +└── main.cpp +``` + +进入该目录后,照搬上一节那三步命令即可复现全部输出。想动手感受 PUBLIC/PRIVATE 的传播,把 `target_link_libraries(mylib PRIVATE fmt)` 改成 PUBLIC,重新 configure,再 `cat build-mk/CMakeFiles/app.dir/flags.make | grep INCLUDES`,看 `app` 凭空多出来的那条 `-I.../fmt`。 + +到这里,target 这个对象、`target_*` 这一族成员方法、PUBLIC/PRIVATE/INTERFACE 这三态使用需求,应该都落到了实处。下一篇要解决一个更实际的问题:真实工程里 `fmt` 不是咱们手写的,得用 `find_package(fmt)` 从系统或 vcpkg/Conan 里把第三方库接进来,`find_package` 给咱们返回的 `fmt::fmt` 这种带命名空间的目标到底是什么、它身上挂的 PUBLIC/INTERFACE 配置怎么自动流到您的工程里。同时还会回到一个悬而未决的问题:设置 C++ 标准,到底是 `set(CMAKE_CXX_STANDARD 17)` 这种目录级写法好,还是 `target_compile_features(mylib PUBLIC cxx_std_17)` 这种 target 级写法好。 diff --git a/documents/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md b/documents/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md new file mode 100644 index 000000000..c62706410 --- /dev/null +++ b/documents/vol7-engineering/ch00-cmake-fundamentals/03-find-package-and-cxx-standard.md @@ -0,0 +1,359 @@ +--- +title: "依赖与 C++ 标准——find_package 和 cxx_std_NN 的现代写法" +description: "讲透 C++ 标准的三种设法为什么手动塞 flag 是反模式,find_package 怎么通过导入 target 把第三方库的使用需求带过来,以及找不到包时怎么排查" +chapter: 7 +order: 3 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 18 +prerequisites: + - "vol7 ch00 02: Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求" +related: + - "CMakePresets.json——从 cmake -D 老式到 --preset 可复现" + - "交叉编译与 CMake" +--- + +# 依赖与 C++ 标准——find_package 和 cxx_std_NN 的现代写法 + +上一篇咱们把 target 和使用需求讲透了,PUBLIC/PRIVATE/INTERFACE 三态怎么沿链接图传播也跑过实测。这一篇接两个工程里最常踩的具体问题:怎么告诉 CMake 您要用 C++20,怎么把第三方库链接进来。这俩问题网上一搜全是答案,但老式写法还在大量教程里流传,照抄会埋坑。咱们把每种设法都跑一遍,看清为什么有的写法该扔进故纸堆。 + +## C++ 标准的三种设法,哪个对 + +设 C++ 标准这事,CMake 圈子里能看到三种写法同时存在。咱们一个个来,先把代码摆出来再讲为什么。 + +第一种,绑在 target 上: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_20) +``` + +第二种,目录级变量,起步卷 [getting-started/04](/getting-started/04-multi-file-cmake) 里咱们就是用这个把工程跑起来的: + +```cmake +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +``` + +第三种,直接往编译标志里塞 `-std=c++20`: + +```cmake +string(APPEND CMAKE_CXX_FLAGS " -std=c++20") # 反模式,别抄 +``` + +前两种 CMake 官方都认,第三种是反模式。下面用实测数据说清楚为什么。 + +### `target_compile_features` 是「最低要求」语义 + +`target_compile_features(app PRIVATE cxx_std_20)` 这一行翻译成人话:app 这个 target 编译时,C++ 标准不能低于 C++20。注意措辞,是「不能低于」,不是「必须正好等于」。 + +这个语义很关键,CMake 拿到这条要求后会拿编译器的默认标准去做对比,根据对比结果决定要不要往编译命令里塞 `-std` 标志。咱们用 GCC 16.1.1 实测一遍,它默认标准是 `gnu++20`(用 `g++ -dM -E -x c++ /dev/null | grep __cplusplus` 能看到 `202002L`,对应 C++20)。下面这份 `CMakeLists.txt` 造了三个 target,分别要求 17、20、23: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(feat_test LANGUAGES CXX) + +add_executable(app_cxx17 main.cpp) +target_compile_features(app_cxx17 PRIVATE cxx_std_17) + +add_executable(app_cxx20 main.cpp) +target_compile_features(app_cxx20 PRIVATE cxx_std_20) + +add_executable(app_cxx23 main.cpp) +target_compile_features(app_cxx23 PRIVATE cxx_std_23) +``` + +用 Make 这个 Generator,开 `CMAKE_VERBOSE_MAKEFILE`,看每个 target 实际发到 `g++` 的命令: + +```text +$ cmake -S . -B build -G "Unix Makefiles" -DCMAKE_VERBOSE_MAKEFILE=ON > /dev/null +$ cmake --build build --target app_cxx17 2>&1 | grep "/c++" +/usr/sbin/c++ -MD -MT ... -c .../main.cpp +$ cmake --build build --target app_cxx20 2>&1 | grep "/c++" +/usr/sbin/c++ -MD -MT ... -c .../main.cpp +$ cmake --build build --target app_cxx23 2>&1 | grep "/c++" +/usr/sbin/c++ -std=gnu++23 -MD -MT ... -c .../main.cpp +``` + +逐字读。要求 17 的 target,编译命令里没有 `-std`:因为编译器默认已经是 20,比 17 高,CMake 判定要求满足,不再加标志。要求 20 的也没有:默认就是 20,正中下怀。要求 23 的才冒出一条 `-std=gnu++23`:默认 20 不够,CMake 主动给升到 23。 + +这就是「最低要求」语义的妙处。您写 `cxx_std_20` 是在声明「这份代码用了 C++20 特性,低于 20 编不过」,CMake 会按需补标志,绝不会把默认的 20 偷偷降到 17。换台默认是 `gnu++17` 的老编译器(比如 GCC 11),同样一份 `CMakeLists.txt`,CMake 就会自动给加 `-std=gnu++20`。一份配置,跨编译器版本都能拿到正确的标准。 + +::: details 那个 gnu++ 是什么,能去掉吗 +`gnu++20` 是 GCC 的「C++20 加 GNU 扩展」方言,对应纯标准的写法是 `c++20`。两者区别是前者允许用 `typeof`、零长数组这些 GCC 私货,写出来的代码可移植性差。CMake 默认走 `gnu++NN` 是为了兼容老代码,您可以在 target 上设 `CXX_EXTENSIONS OFF` 把它逼回纯 `c++NN`: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_23) +set_target_properties(app PROPERTIES CXX_EXTENSIONS OFF) +``` + +实测,同样要求 cxx_std_23,开了 `CXX_EXTENSIONS OFF` 之后编译命令从 `-std=gnu++23` 变成 `-std=c++23`: + +```text +$ cmake --build build --target app 2>&1 | grep "/c++" +/usr/sbin/c++ -std=c++23 -MD -MT ... -c .../main.cpp +``` + +新工程建议默认开 OFF,跨编译器行为更可预测。 +::: + +`cxx_std_NN` 还能 PUBLIC 出去,复用上一篇讲的使用需求传播机制。一个库自己要求 C++20,下游链接它自动继承这个要求: + +```cmake +target_compile_features(mylib PUBLIC cxx_std_20) +``` + +下游链接 `mylib` 时,CMake 看到 `INTERFACE_COMPILE_FEATURES` 里有 `cxx_std_20`,会自动给下游也提一级标准。这是 target 级写法相对目录级写法的最大优势:标准要求跟着 target 走,沿依赖图自动传,您不用在每个下游工程里再写一遍 `set(CMAKE_CXX_STANDARD 20)`。 + +### 目录级写法 `set(CMAKE_CXX_STANDARD)`:能用,但有上限 + +第二种写法咱们在起步卷用过,长这样: + +```cmake +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +``` + +这三行的作用域是「当前 `CMakeLists.txt` 及其子目录里的所有 target」,本质上是给目录下所有 target 的 `CXX_STANDARD` 属性赋默认值。`CMAKE_CXX_STANDARD_REQUIRED ON` 是必须的,它告诉 CMake「编译器达不到这个标准就报错」,否则编译器太老时 CMake 会偷偷降级编过去,编过了但行为不对,调试半天才发现根因在这。 + +::: warning 漏写 `CMAKE_CXX_STANDARD_REQUIRED ON` 会偷偷降级 +CMake 默认 `CMAKE_CXX_STANDARD_REQUIRED` 是 `OFF`,意思是「编译器不支持这个标准也尽量编」。结果是您写 `set(CMAKE_CXX_STANDARD 20)`,编译器最高只到 17 时,CMake 不报错,默默拿 17 编下去。您用了 C++20 的 `concept`、模板 lambda,编不过才报错,但报错信息不会指向「标准被降级」,而是指向具体的语法行,绕一大圈才查到根因。所以 `CMAKE_CXX_STANDARD` 和 `CMAKE_CXX_STANDARD_REQUIRED ON` 必须配套写。 +::: + +这种写法目前还能接受,因为绝大多数工程的 C++ 标准就是「全工程统一」一个值。但它有两处不如 target 级写法:一是它不会随 target 传播,下游链接您的库,标准要求不会自动过去;二是它的作用域是目录级,本质上跟上一篇讲的 `include_directories()` 一样属于全局设置,工程一复杂就不够精确。 + +迁移建议:新工程优先用 `target_compile_features(mylib PUBLIC cxx_std_NN)`,把标准也变成 target 的使用需求;老工程继续用 `set(CMAKE_CXX_STANDARD)` 不影响功能,等下次重构再换。 + +### 手动塞 `-std=c++20`:反模式,别这么写 + +第三种写法看着最「直接」,网上老教程里随处可见: + +```cmake +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20") +# 或者 +string(APPEND CMAKE_CXX_FLAGS " -std=c++20") +add_executable(app main.cpp) +``` + +实测它确实能让编译命令里出现 `-std=c++20`: + +```text +$ cmake -S . -B build -G "Unix Makefiles" -DCMAKE_VERBOSE_MAKEFILE=ON > /dev/null +$ cmake --build build --target app 2>&1 | grep "/c++" +/usr/sbin/c++ -std=c++20 -MD -MT ... -c .../main.cpp +``` + +看着没问题,问题全在背后。首先这一行绕过了 CMake 的标准管理。CMake 内部维护着一份「编译器认识哪些标准、每个标准对应哪个标志」的对照表,`target_compile_features` 和 `set(CMAKE_CXX_STANDARD)` 都走这张表。您手动塞 `-std=c++20`,CMake 不知道您设了标准,于是 `CMAKE_CXX_STANDARD` 这个变量还是空,下游想读它来做判断就拿到空值,依赖图里的标准传播整条链断掉。 + +其次它跨平台不一致。GCC 和 Clang 用 `-std=c++20`,MSVC 用 `/std:c++20`, flags 写死了 GCC 风格,换个 MSVC 工程这套就编译报错。CMake 的标准管理替您抹平了这个差异,您只要写 `cxx_std_20`,CMake 自己挑对应平台的标志。 + +最后它跟 `CXX_EXTENSIONS`、`CMAKE_CXX_STANDARD_REQUIRED` 这些机制完全脱节,等于绕过整套抽象自己手搓。一份 `CMakeLists.txt` 里只要出现一行 `set(CMAKE_CXX_FLAGS ... -std=...)`,就标志着这份配置还是老式 CMake 思维。 + +迁移规则:把所有手动塞 `-std=` 的地方删掉,换成上面第一种或第二种写法。 + +## find_package:怎么链接第三方库 + +C++ 标准的事清楚了,接下来讲第三方库。真实工程里 `fmt` 这种库不是咱们手写的,要从系统或包管理器里接进来,CMake 给的命令是 `find_package`。 + +现代写法两行就完事: + +```cmake +find_package(fmt REQUIRED) +target_link_libraries(app PRIVATE fmt::fmt) +``` + +`find_package(fmt REQUIRED)` 干的事是去几个标准位置(`CMAKE_PREFIX_PATH` 下的 `/lib/cmake/fmt/` 等)找一份 `fmt-config.cmake`(也叫包配置文件),找到后执行它。这个配置文件是 fmt 自己装的,它知道 fmt 的头文件在哪、库文件在哪、链接需要哪些编译选项。执行完之后,您的工程里凭空多出一个叫 `fmt::fmt` 的 target。 + +这个 `fmt::fmt` 是个**导入 target**(imported target)。导入 target 跟咱们上一篇讲的普通 target 不一样,它不是您工程里造出来的,是别人造好了打包进 config 文件、`find_package` 时搬进来的。它身上同样挂着 `INTERFACE_INCLUDE_DIRECTORIES`、`INTERFACE_COMPILE_DEFINITIONS`、`IMPORTED_LOCATION` 这些使用需求属性。您 `target_link_libraries(app PRIVATE fmt::fmt)` 一链接,这些属性就像上一篇讲的 PUBLIC 那样自动流到 `app` 上。 + +咱们扒一个真实的 fmt::fmt 看看。本机装了 fmt 12.2.0,它的 config 文件在 `/usr/lib/cmake/fmt/fmt-config.cmake`,里面创建 `fmt::fmt` 的关键几行(来自 `fmt-targets.cmake`)长这样: + +```cmake +add_library(fmt::fmt SHARED IMPORTED) +set_target_properties(fmt::fmt PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include" + ... +) +``` + +`SHARED IMPORTED` 告诉 CMake 这是个动态库的导入 target。`INTERFACE_INCLUDE_DIRECTORIES` 是它的公开头文件路径。下面这份 `CMakeLists.txt` 找到 fmt 之后,把它的几个属性打出来: + +```cmake +find_package(fmt REQUIRED) +foreach(prop TYPE INTERFACE_INCLUDE_DIRECTORIES INTERFACE_COMPILE_DEFINITIONS INTERFACE_COMPILE_FEATURES) + get_target_property(v fmt::fmt ${prop}) + message(STATUS "fmt::fmt.${prop} = ${v}") +endforeach() +``` + +跑一次 configure: + +```text +$ cmake -S . -B build +-- fmt::fmt.TYPE = SHARED_LIBRARY +-- fmt::fmt.INTERFACE_INCLUDE_DIRECTORIES = /usr/include +-- fmt::fmt.INTERFACE_COMPILE_DEFINITIONS = FMT_SHARED +-- fmt::fmt.INTERFACE_COMPILE_FEATURES = cxx_std_11 +``` + +逐条读。`TYPE` 是 SHARED_LIBRARY,是动态库。`INTERFACE_INCLUDE_DIRECTORIES` 是 `/usr/include`,下游包含 `` 的路径来源。`INTERFACE_COMPILE_DEFINITIONS` 是 `FMT_SHARED`,这是个关键信号,因为 fmt 编成动态库时,下游链接它必须定义 `FMT_SHARED` 才能正确导入符号。`INTERFACE_COMPILE_FEATURES` 是 `cxx_std_11`,fmt 自己声明它至少要 C++11。 + +您一行 `target_link_libraries(app PRIVATE fmt::fmt)` 链上去,这四条属性自动变成 `app` 的编译环境。咱们看 `app` 实际拿到的编译命令: + +```text +$ cmake -S . -B build -G Ninja > /dev/null && cmake --build build -v 2>&1 | grep "/c++" +[1/2] /usr/sbin/c++ -DFMT_SHARED -MD -MT ... -c .../main.cpp +``` + +注意命令里凭空冒出来的 `-DFMT_SHARED`。`app` 的 `CMakeLists.txt` 里没写过这一行,它来自 `fmt::fmt` 的 `INTERFACE_COMPILE_DEFINITIONS`。`/usr/include` 是系统默认路径所以没显式出现在命令里,但您把 fmt 装到非标准路径(比如 `/opt/fmt`),那条 `-I/opt/fmt/include` 就会自动冒出来。链接阶段也一样,看实际链接命令: + +```text +[2/2] : && /usr/sbin/c++ ... CMakeFiles/app.dir/main.cpp.o -o app /usr/lib/libfmt.so.12.2.0 && : +``` + +`/usr/lib/libfmt.so.12.2.0` 是 `fmt::fmt` 的 `IMPORTED_LOCATION` 解析出来的真实库文件路径。CMake 全程替您把「找头文件、传编译宏、找库文件」这套脏活干完,您只管写一个 `fmt::fmt` 的名字。 + +这就是导入 target 相对老式写法的根本优势:它把库的「使用需求」打包成一个对象,您链接一次,所有该带的配置自动到位,库升级换路径您也不用改一行代码。 + +### 老式写法:`${fmt_INCLUDE_DIRS}` 变量风格 + +网上老教程里常见的另一种写法长这样: + +```cmake +find_package(fmt REQUIRED) +include_directories(${fmt_INCLUDE_DIRS}) # 反模式 +add_executable(app main.cpp) +target_link_libraries(app ${fmt_LIBRARIES}) # 反模式 +``` + +`include_directories(${fmt_INCLUDE_DIRS})` 是上一篇讲过的目录级全局命令,污染当前目录下所有 target。`${fmt_LIBRARIES}` 这种变量写法依赖 config 文件把库列表写进一个变量,您手工读出来再传给 `target_link_libraries`。问题在于这种写法完全不传播使用需求:`fmt_LIBRARIES` 只是个库名列表,不带 `-DFMT_SHARED`,不带 `INTERFACE_INCLUDE_DIRECTORIES`,不带 `cxx_std_11`,您漏一个就编不过或者行为不对。 + +更要命的是这套变量名没有统一规范。fmt 用的可能是 `fmt_LIBRARIES`,OpenCV 用的可能是 `OpenCV_LIBS`,Boost 用的可能是 `Boost_LIBRARIES`,您每接一个库就得查它的 config 文件提供哪些变量。导入 target 则统一是 `库名::库名` 的命名空间,您只要在文档里找到这个带 `::` 的名字,链接一次就齐活。 + +迁移规则:把所有 `include_directories(${X_INCLUDE_DIRS})` 删掉,把所有 `target_link_libraries(app ${X_LIBRARIES})` 改成 `target_link_libraries(app PRIVATE X::X)`。前提是您接的库 config 文件提供了导入 target,现在主流库(fmt、spdlog、Catch2、nlohmann_json 等)都提供。 + +::: warning 库没提供导入 target 怎么办 +少数老库或者自己手写的 config 文件可能只提供 `${X_INCLUDE_DIRS}` 变量、没有 `X::X` 这种导入 target。这种情况下您有两个选择。一是自己造一个 INTERFACE 库当封装: + +```cmake +find_package(OldLib REQUIRED) +add_library(OldLib::OldLib ALIAS OldLib::OldLib) # 不行,OldLib 不是个 target +# 正确做法:造一个 interface target 把变量包进去 +add_library(oldlib_wrapper INTERFACE) +target_include_directories(oldlib_wrapper INTERFACE ${OldLib_INCLUDE_DIRS}) +target_link_libraries(oldlib_wrapper INTERFACE ${OldLib_LIBRARIES}) +target_link_libraries(app PRIVATE oldlib_wrapper) +``` + +这样下游统一链接 `oldlib_wrapper`,配置从这一处向外传播。二是劝库作者更新 config 文件,或者直接换库。 +::: + +## 找不到包怎么办 + +`find_package` 报错时的排查路径是有固定套路的。先看真实报错长什么样。下面这份 `CMakeLists.txt` 找一个根本不存在的库: + +```cmake +find_package(NonExistentPkg 9.9.9 REQUIRED) +``` + +configure 直接挂掉,CMake 报的错是: + +```text +CMake Error at CMakeLists.txt:4 (find_package): + By not providing "FindNonExistentPkg.cmake" in CMAKE_MODULE_PATH this + project has asked CMake to find a package configuration file provided by + "NonExistentPkg", but CMake did not find one. + + Could not find a package configuration file provided by "NonExistentPkg" + (requested version 9.9.9) with any of the following names: + + NonExistentPkg.cps + nonexistentpkg.cps + NonExistentPkgConfig.cmake + nonexistentpkg-config.cmake + + Add the installation prefix of "NonExistentPkg" to CMAKE_PREFIX_PATH or set + "NonExistentPkg_DIR" to a directory containing one of the above files. + +-- Configuring incomplete, errors occurred! +``` + +这段报错信息量大,咱们拆开看。第一段说「您没在 `CMAKE_MODULE_PATH` 里提供 `FindNonExistentPkg.cmake`」,意思是 CMake 先按「Module 模式」找了一遍自己内置的或您提供的 `FindX.cmake` 文件,没找到。第二段说「`NonExistentPkg` 提供的包配置文件也没找到」,列了它要找的几个文件名,其中 `.cps` 是 CMake 3.29 引入的 CPS(CMake Package Specification)新格式,`.cmake` 是经典格式。第三段给的是排查路径。 + +按报错提示,`find_package` 找不到包的常见原因有这么几个,按出现频率排: + +第一,库根本没装。最常见,先确认系统里到底有没有这个库。Linux 用包管理器查(`apt list --installed | grep fmt`、`pacman -Qs fmt`),Windows 看 `vcpkg list`,macOS 看 `brew list`。没装就先装上,装的时候留意有没有装 `-dev` 或 `-devel` 后缀的开发包,因为有些发行版把运行时库和头文件分开卖,只装运行时库 `find_package` 也找不到。 + +第二,装了但 `CMAKE_PREFIX_PATH` 没设。库装在非标准路径(自己 `make install` 到 `/opt/fmt`,或者 vcpkg 装在 `~/vcpkg/installed/x64-linux`),CMake 默认只搜 `/usr`、`/usr/local` 这几个标准位置,自然找不到。解决方法是 configure 时加 `-DCMAKE_PREFIX_PATH=/opt/fmt`,或者设环境变量 `CMAKE_PREFIX_PATH`。下一篇讲 CMakePresets 时会把这种 `-D` 固化进 JSON。 + +第三,vcpkg / Conan 的 toolchain 文件没注入。这两个包管理器装库之后,库都装在它们自己管的目录里(vcpkg 是 `installed/`,Conan 是 `~/.conan2/`),不进系统标准路径。它们给您一个 toolchain 文件,configure 时通过 `-DCMAKE_TOOLCHAIN_FILE=/vcpkg.cmake` 传进去,这个 toolchain 文件会自动把 `CMAKE_PREFIX_PATH` 指向它装的库。忘了挂 toolchain,库装了也白装,`find_package` 还是找不到。这是新手最常踩的坑。 + +第四,库装了但没提供 config 文件。比如系统装的是 fmt 5.x 这种老版本,那时 fmt 还没提供 `fmt-config.cmake`,只有 `FindFMT.cmake` 这种 Module 模式的查找文件(甚至什么都没有)。这种情况 `find_package(fmt)` 走 Config 模式找不到,您要么升级库,要么自己写一个 `FindX.cmake`,要么用 pkg-config 桥接。 + +::: details find_package 的两种查找模式 +`find_package(X)` 默认走两种模式,先 Module 后 Config。 + +Module 模式找的是 `FindX.cmake`,文件名以 `Find` 开头。这种文件是 CMake 自己写的(内置了一百多个常见库的 `FindX.cmake`),或者您放在 `CMAKE_MODULE_PATH` 里提供的。老式写法常见,因为当年很多库自己不提供 config 文件,靠 CMake 社区维护的 Module 桥接。 + +Config 模式找的是 `X-config.cmake` 或 `XConfig.cmake`(CMake 3.29+ 还找 `.cps` 文件),文件名以库名开头。这种文件是库作者自己装的,跟库一起发出来,准确性比社区维护的 Module 高。现代主流库(fmt、spdlog、Catch2、Boost 1.70+ 等)都自带 Config 文件,所以 `find_package` 实际大多走 Config 模式。 + +CMake 默认先 Module 后 Config,您可以用 `find_package(X CONFIG)` 或 `find_package(X MODULE)` 强制只走一种。新工程建议显式写 `CONFIG`,行为更明确,也避免 CMake 内置的某个老 `FindX.cmake` 抢在库自己的 config 文件前面被找到,行为不一致。 +::: + +## vcpkg / Conan 一句话衔接 + +第三方库从哪来,咱们前面一直没说。系统包管理器(apt、pacman、brew)装的库版本老、跨平台不一致、CI 上未必能装,做正经工程一般不用它。C++ 生态里两个主流的包管理器是 vcpkg 和 Conan,它们干的事是替您把库编好、装到自己的目录,然后给您一个 toolchain 文件,让 CMake 的 `find_package` 能找到。 + +用法上的关键就一行 configure 参数: + +```text +cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake +``` + +vcpkg 装的库都在 `/installed/` 下,它的 toolchain 文件会自动把 `CMAKE_PREFIX_PATH` 指过去,于是您工程里 `find_package(fmt REQUIRED)` 照常工作,跟系统装的没区别。Conan 思路类似,生成的 toolchain 文件叫 `conan_toolchain.cmake`。这套机制的细节、manifest 文件怎么写、版本怎么锁、和下一篇 CMakePresets 怎么挂上钩,咱们留给后续包管理专题展开。这里您只要记住一件事:库装好之后,注入 toolchain 文件这一步是 CMake 能找到库的关键。 + +## 配套示例 + +本文的示例工程在仓库 `code/examples/vol7/cmake-fundamentals/03-find-package/`,结构如下: + +```text +03-find-package/ +├── CMakeLists.txt # target_compile_features + 可选 find_package 段 +└── main.cpp # 用 C++20 模板 lambda 验证标准生效 +``` + +`CMakeLists.txt` 的核心三行: + +```cmake +add_executable(app main.cpp) +target_compile_features(app PRIVATE cxx_std_20) +set_target_properties(app PROPERTIES CXX_EXTENSIONS OFF) +``` + +三步跑通: + +```text +$ cmake -S . -B build -G Ninja && cmake --build build && ./build/app +3 +ab +``` + +`main.cpp` 里用了一个 C++20 才有的模板 lambda(`[](T a, T b) { return a + b; }`),用来证明 `cxx_std_20` 真的把标准要求传到了编译命令里。想动手验证「最低要求」语义,把 `cxx_std_20` 改成 `cxx_std_23`,重新 configure,再用 `cmake --build build -v` 看编译命令,您会看到 CMake 自动加了一条 `-std=c++23`。 + +## 接下来 + +到这里 C++ 标准的三种设法、`find_package` 的导入 target 机制都落到了代码上。咱们这篇里 configure 命令已经长成这样: + +```text +cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake -DCMAKE_PREFIX_PATH=/opt/fmt ... +``` + +`-D` 一长就开始出问题:抄错变量名 configure 不报错默默走空配置、同事之间互相问 vcpkg 路径填啥、CI 里改一个选项 PR 一片红。下一篇讲 `CMakePresets.json`,CMake 3.19 引入的机制把这些散在命令行里的 `-D`、Generator 选择、toolchain 注入统一固化进一个 JSON 文件,命令瘦成 `cmake --preset debug` 一行。 diff --git a/documents/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md b/documents/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md new file mode 100644 index 000000000..f96ad0e8b --- /dev/null +++ b/documents/vol7-engineering/ch00-cmake-fundamentals/04-cmake-presets.md @@ -0,0 +1,310 @@ +--- +title: "CMakePresets.json——从 cmake -D 老式到 --preset 可复现" +description: "讲透 CMakePresets.json 怎么把 -D 老式姿势固化进版本控制:configurePresets/buildPresets/testPresets 三类、hidden+inherits 组合、CMakeUserPresets.json 个人覆盖" +chapter: 7 +order: 4 +tags: + - host + - cpp-modern + - intermediate + - CMake +difficulty: intermediate +platform: host +cpp_standard: [17, 20] +reading_time_minutes: 16 +prerequisites: + - "vol7 ch00 01: CMake 是什么——构建系统生成器的两段式流水线" + - "vol7 ch00 02: Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求" +related: + - "交叉编译与 CMake" + - "编译器选项" +--- + +# CMakePresets.json——从 cmake -D 老式到 --preset 可复现 + +前面两篇咱们 configure 时敲的命令都长一个样:`cmake -B build -G Ninja`。真实工程里这一行通常远远不止这么短。带 build type、带 toolchain 文件、带几个缓存变量之后,命令会膨胀成下面这种样子: + +```text +cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DVCPKG_TARGET_TRIPLET=x64-linux \ + -DCMAKE_CXX_STANDARD=20 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +这种命令一长就开始出问题。笔者自己就踩过:把 `CMAKE_BUILD_TYPE` 抄成 `CMAKE_BUILD-TYPE`,configure 不报错,默默走空配置,编出来的二进制带一堆调试符号;同事之间互相问「你那个 vcpkg 路径填啥」;CI 里把这条命令嵌进 YAML,改一个选项就 PR 一片红。CMake 3.19 引入了 `CMakePresets.json`,把这些散在命令行里的 `-D`、Generator 选择、构建目录统一固化进一个 JSON 文件,最后命令瘦成 `cmake --preset debug` 一行。这篇就讲怎么用它,以及它和 vcpkg toolchain、VSCode CMake Tools 怎么挂上钩。 + +## 为什么需要 Presets:-D 老式的四个痛点 + +在动手写 JSON 之前,先把「为什么这件事值得做」说透。咱们对照前面几篇用过的命令,逐一拆 -D 老式姿势的毛病。 + +第一是命令长、容易抄错。上面那条命令 130 多个字符,跨多行。`CMAKE_BUILD_TYPE`、`CMAKE_TOOLCHAIN_FILE` 这些 key 名一个字母不对,CMake 都不会报错——它把不认识的变量静默写进缓存,然后您拿到的就是一份「看起来配过、实际啥也没设」的构建树,问题往往要等到运行时才暴露。笔者在 `CMAKE_BUILD-TYPE`(下划线打成了连字符)这个笔误上花过半天排查。 + +第二是不可复现。命令只活在您的终端 history 里。换台机器、换个终端窗口、或者半个月后回来继续这工程,命令早就没了,只能凭记忆重敲一遍。哪怕记得大概,参数顺序、某个 `-D` 是不是开了,谁也不敢打包票。 + +第三是团队各敲各的。同一个工程,A 用 `Release`、B 用 `RelWithDebInfo`、C 忘了指定 `CMAKE_BUILD_TYPE`,三台机器编出来三份行为不同的二进制。bug 在 B 那里复现,到 A 那里就消失,回头一查是 build type 不一致——这种扯皮在没规范的项目里几乎是常态。 + +第四是 CI 难固化。CI 脚本要把命令完整复制进 YAML,每个 `-D` 都是潜在的拼写陷阱。改一个编译选项要在两个地方(本地命令 + CI YAML)同步改,时间一长必然漂移。 + +Presets 这套机制就是冲着这四个痛点来的。把「用哪些 `-D`、用哪个 Generator、构建到哪个目录」写进 `CMakePresets.json`,这个文件进版本控制,团队和 CI 共享同一份配置。本地敲 `cmake --preset debug`,CI 里也是 `cmake --preset debug`,命令两边一字不差,构建行为可复现。 + +## CMakePresets.json 结构 + +`CMakePresets.json` 的顶层有三大类预设,对应 CMake 工作流的三段: + +`configurePresets` 对应 `cmake --preset`,固化 configure 阶段的 `-D`、Generator、`binaryDir`。这是最常用的一类。 + +`buildPresets` 对应 `cmake --build --preset`,固化 build 阶段的 `--target`、`--config`、并行度等参数。schema version 2(CMake 3.20)才加入。 + +`testPresets` 对应 `ctest --preset`,固化测试阶段的 filter、输出格式等。同样是 schema version 2 引入。 + +咱们先看一个完整的最小可用示例,再拆字段。下面这份 `CMakePresets.json` 是笔者为本文实测用的,一个 hidden 的 `base` preset 设通用项,两个继承它的 `debug` 和 `release` 分别设 `CMAKE_BUILD_TYPE`: + +```json +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 21, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_CXX_STANDARD": "17", + "CMAKE_CXX_STANDARD_REQUIRED": "ON", + "CMAKE_CXX_EXTENSIONS": "OFF" + } + }, + { + "name": "debug", + "displayName": "Debug (含 -g -O0)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release (含 -O3 -DNDEBUG)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + } + ] +} +``` + +逐字段拆。顶层 `version` 是 **JSON schema 的版本号**,不是 CMake 的版本号。当前最高到 9(CMake 3.27 引入),3 是个稳妥的下限,覆盖了 `configurePresets` + `buildPresets` + `testPresets` 全部基础能力,CMake 3.21 起原生支持。schema 版本和 `cmakeMinimumRequired` 是两件事:前者声明「这份 JSON 按哪个版本的 schema 写」,后者声明「跑这份 JSON 至少要 CMake 多新」。低于这个版本的 CMake 看到 `CMakePresets.json` 直接拒绝,避免老 CMake 解析不了新字段却默默继续。 + +`configurePresets` 是个数组,每个元素是一个 preset。`base` 这个 preset 有几个关键字段。 + +`name` 是 preset 的唯一标识,`cmake --preset` 后面跟的就是它。 + +`hidden: true` 表示这个 preset 不能被 `--preset` 直接使用,也不会出现在 `--list-presets` 输出里,它只作为基类被别的 preset 继承。咱们马上用 `cmake --preset base` 实测,CMake 会直接报错挡住。 + +`generator` 和 `binaryDir` 分别固化了 `-G` 和 `-B`。注意 `binaryDir` 写成 `${sourceDir}/build/${presetName}`,这里有两层宏展开:`${sourceDir}` 是工程根目录的绝对路径,`${presetName}` 是当前 preset 的名字(比如 `debug`、`release`)。这样写的好处是不同 preset 各自落到独立的构建目录,`build/debug` 和 `build/release` 互不干扰,切换 build type 不用 `rm -rf build` 重来。 + +`cacheVariables` 是 `-D` 的固化。每一项 `key: value` 就等价于 `-Dkey=value`。值可以是字符串、布尔、`null`(表示 `UNINITIALIZED` 类型),也可以是带 `type` 字段的对象(精确控制缓存变量类型)。 + +接下来看 `debug` 和 `release` 怎么继承 `base`。`inherits: "base"` 表示「这个 preset 把 `base` 的所有字段继承过来,自己再覆盖一部分」。这里只覆盖了 `cacheVariables.CMAKE_BUILD_TYPE`:`debug` 设成 `Debug`,`release` 设成 `Release`。`generator`、`binaryDir`、`CMAKE_CXX_STANDARD` 这些 `base` 上的通用字段原封不动继承下来。 + +`inherits` 接受单个字符串或字符串数组。数组场景下,多个父 preset 提供同名字段时,**数组里靠前的优先**——这点和 C++ 多继承的歧义处理不一样,CMake 是有确定性顺序的。 + +`buildPresets` 部分简单:每个 build preset 通过 `configurePreset` 字段绑定到一个 configure preset。`cmake --build --preset debug` 就知道去 `build/debug` 这个 `binaryDir` 下执行构建,不用再写 `cmake --build build/debug`。 + +::: details schema version 选几合适 +官方文档里 schema 版本一路从 1 涨到 9。选哪个取决于您要用的新特性。version 1(CMake 3.19)只有 `configurePresets`,没有 build/test presets;version 2(3.20)补齐 `buildPresets`/`testPresets`;version 3(3.21)加入 `cmakeMinimumRequired` 字段和更宽松的宏展开。再往后主要是给 CI 集成、条件化 include 等高级场景打补丁。笔者的默认选择是 3,能覆盖绝大多数工程需求,同时保证 CMake 3.21+ 就能解析。 +::: + +## 用起来:从 configure 到 build 的真实输出 + +光看 JSON 不过瘾,咱们跑一遍。这份 `CMakePresets.json` 配套的最小工程(`CMakeLists.txt` + `main.cpp`)在仓库 `code/examples/vol7/cmake-fundamentals/04-presets/` 下。先看 CMake 能识别出哪些 preset: + +```text +$ cmake --list-presets +Available configure presets: + + "debug" - Debug (含 -g -O0) + "release" - Release (含 -O3 -DNDEBUG) +``` + +`--list-presets` 把所有非 hidden 的 configure preset 列出来,连同 `displayName` 一起。注意 `base` 没出现——它被 `hidden` 挡住了。如果硬要 `cmake --preset base`,CMake 直接报错: + +```text +$ cmake --preset base +CMake Error: Cannot use hidden configure preset in /tmp/cmake-presets-demo: "base" +``` + +这正是 hidden preset 的语义:只做基类,不直接用。这个设计避免了团队成员误用「只配了一半」的 preset。 + +跑 `debug` preset: + +```text +$ cmake --preset debug +-- The CXX compiler identification is GNU 16.1.1 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/sbin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-presets-demo/build/debug +``` + +最后一行是关键证据:构建文件落到了 `build/debug`。`${sourceDir}/build/${presetName}` 这层宏展开起作用了。再跑 `release`,构建目录是 `build/release`,两个互不干扰: + +```text +$ ls build/ +debug release +``` + +接着用 build preset 跑构建: + +```text +$ cmake --build --preset debug +[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o +[2/2] Linking CXX executable app +``` + +`cmake --build --preset debug` 等价于 `cmake --build build/debug`,但您不用记 `binaryDir` 长啥样,preset 帮您记着。 + +光跑通不够,咱们验证一下 `cacheVariables` 里的 `CMAKE_BUILD_TYPE` 真的流到了编译命令里。在 `main.cpp` 里笔者埋了一行 `#ifdef NDEBUG` 区分两种 build。先看 `release` 的二进制实际拿到的 flags,去 `build.ninja` 里扒: + +```text +$ grep FLAGS build/release/build.ninja | head -2 + FLAGS = -O3 -DNDEBUG -std=c++17 + FLAGS = -O3 -DNDEBUG + +$ grep FLAGS build/debug/build.ninja | head -2 + FLAGS = -g -std=c++17 + FLAGS = -g +``` + +`release` 拿到 `-O3 -DNDEBUG`,`debug` 拿到 `-g`,`-std=c++17` 两边都有(来自 `base` 的 `CMAKE_CXX_STANDARD`)。这就把 preset 里写的 `CMAKE_BUILD_TYPE: Debug/Release` 和实际编译器参数之间的因果链坐实了。两个二进制跑出来的输出也对得上: + +```text +$ ./build/debug/app +debug build (NDEBUG NOT defined) + +$ ./build/release/app +release build (NDEBUG defined) +``` + +一份 `CMakePresets.json`,两个 preset,两条独立的构建树,两种行为不同的二进制,命令只有 `cmake --preset debug` / `cmake --preset release` 这么短。和前面那条 130 多字符的 `-D` 老式命令对比,差距摆在这儿。 + +## CMakeUserPresets.json:个人覆盖 + +`CMakePresets.json` 是团队共享的,进版本控制。但有些东西天生就是「本机独有」——比如 vcpkg 装在哪个目录、本地是不是开了 ASan、笔者自己想加一个临时的 preset 试验某个 flag。这些写进 `CMakePresets.json` 会污染团队配置,别人 pull 下来要么路径找不到、要么莫名其妙开了不该开的选项。 + +CMake 给的解法是 `CMakeUserPresets.json`。它和 `CMakePresets.json` 放同一个目录,结构完全一样,但语义是「个人覆盖」: + +```text +项目根/ +├── CMakePresets.json # 进 git,团队共享 +├── CMakeUserPresets.json # 进 .gitignore,只在本机 +├── CMakeLists.txt +└── ... +``` + +`CMakeUserPresets.json` 里定义的 preset 和主文件里的 preset 会被合并展示。更关键的是,**UserPresets 里的 preset 能继承主文件里的 hidden preset**。笔者在本机加了一个 `asan` preset,继承主文件里的 `base`,叠一层 ASan flag: + +```json +{ + "version": 3, + "configurePresets": [ + { + "name": "asan", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-fsanitize=address -fno-omit-frame-pointer" + } + } + ] +} +``` + +再 `--list-presets` 看看: + +```text +$ cmake --list-presets +Available configure presets: + + "asan" + "debug" - Debug (含 -g -O0) + "release" - Release (含 -O3 -DNDEBUG) +``` + +`asan` 出现了,跟 `debug`、`release` 平起平坐。直接 `cmake --preset asan` 跑通,构建目录自动落到 `build/asan`: + +```text +$ cmake --preset asan +-- Configuring done (0.2s) +-- Generating done (0.0s) +-- Build files have been written to: /tmp/cmake-presets-demo/build/asan +``` + +::: warning CMakeUserPresets.json 必须进 .gitignore +官方文档原话是「should NOT be checked in」。它的存在前提就是「每台机器路径不同」,进了 git 必然冲突。新建工程时第一件事就是把 `CMakeUserPresets.json` 加进 `.gitignore`,别等同事 PR 里带着他自己的 vcpkg 路径来折磨您。 +::: + +## IDE 集成:VSCode CMake Tools + +命令行之外,preset 真正落地的地方往往是 IDE。VSCode 的 CMake Tools 扩展原生读 `CMakePresets.json`,状态栏直接列出可选的 configure preset 和 build preset,点一下就切换,不用敲命令。 + +clangd 也间接受益。CMake Tools 选了 preset 之后会自动跑对应的 configure,生成的 `compile_commands.json` 会被 clangd 拉去给编辑器做补全和跳转。preset 里固化了所有 `-D` 和 Generator,意味着 IDE 里看到的编译环境跟命令行、跟 CI 完全一致——这是 preset 相比「IDE 自己管一套配置」最大的优势:单一数据源。 + +Remote-WSL 场景下也顺手:`CMakePresets.json` 跟着源码进 WSL 文件系统,VSCode Remote 端的 CMake Tools 直接读,不用在 Windows 端和 WSL 端各配一遍。 + +## 衔接交叉编译 + +到这里您可能已经嗅到 preset 和交叉编译的天然契合。交叉编译的核心就是 `-DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake` 这条 `-D`,再加上一堆和目标板相关的缓存变量。这些恰好是 preset 最擅长固化的东西。 + +`CMakePresets.json` 给了专门的 `toolchainFile` 字段,比塞进 `cacheVariables` 更规范: + +```json +{ + "name": "f407-debug", + "inherits": "base", + "toolchainFile": "${sourceDir}/cmake/arm-none-eabi-gcc.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "ARM_CORTEX_M": "M4F" + } +} +``` + +之后 `cmake --preset f407-debug` 一行就完成交叉编译配置,团队里任何人 pull 下来都能复现同一份工具链设定。这个机制怎么和 `arm-none-eabi-g++`、sysroot、cortex-m 链接脚本配合,咱们在 vol7 交叉编译篇详细展开。 + +## 配套示例 + +本文的工程脚手架可以从仓库示例目录直接跑: + +```text +code/examples/vol7/cmake-fundamentals/04-presets/ +├── CMakeLists.txt +├── main.cpp +└── CMakePresets.json +``` + +进入该目录后,依次 `cmake --list-presets`、`cmake --preset debug`、`cmake --build --preset debug`、`./build/debug/app`,即可复现本文全部输出。想验证 `cacheVariables` 的传播,把 `debug` 改成 `release` 重跑一遍,对比 `build/release/build.ninja` 里的 `FLAGS = -O3 -DNDEBUG` 和 `build/debug/build.ninja` 里的 `FLAGS = -g`。 + +到这里咱们把 preset 的结构、hidden+inherits 组合、CMakeUserPresets.json 个人覆盖、IDE 集成都落到了实处,也用真实输出验证了 `${presetName}` 宏展开和 `CMAKE_BUILD_TYPE` 传播。下一篇要解决一个 vol7 一直欠着的问题:当目标板从 x86 Linux 换成 STM32F407 这种 ARM Cortex-M 设备时,`CMakeLists.txt` 怎么写、toolchain 文件长什么样、preset 怎么和它们挂上钩——也就是交叉编译的完整链路。 diff --git a/documents/vol7-engineering/ch00-cmake-fundamentals/index.md b/documents/vol7-engineering/ch00-cmake-fundamentals/index.md new file mode 100644 index 000000000..79d648126 --- /dev/null +++ b/documents/vol7-engineering/ch00-cmake-fundamentals/index.md @@ -0,0 +1,22 @@ +--- +title: "CMake 基础" +description: "CMake 的定位、两段式流水线、target 心智模型、find_package 与 C++ 标准、CMakePresets——从照抄到看懂" +chapter: 7 +order: 0 +tags: + - host + - cpp-modern + - intermediate + - CMake +--- + +# CMake 基础 + +本子卷从「CMake 到底是什么」讲起,落到 target 心智模型、依赖管理、可复现配置。目标是让读者不再照抄 `CMakeLists.txt`,而是理解每一条命令背后的设计意图。 + + + CMake 是什么——构建系统生成器的两段式流水线 + Target 心智模型——把 target 当对象,PUBLIC/PRIVATE/INTERFACE 是使用需求 + 依赖与 C++ 标准——find_package 和 cxx_std_NN 的现代写法 + CMakePresets.json——从 cmake -D 老式到 --preset 可复现 + diff --git a/documents/vol7-engineering/cpp-development-on-wsl.md b/documents/vol7-engineering/cpp-development-on-wsl.md index 798cfc8c0..16da864b2 100644 --- a/documents/vol7-engineering/cpp-development-on-wsl.md +++ b/documents/vol7-engineering/cpp-development-on-wsl.md @@ -1,158 +1,506 @@ --- +title: "在 WSL 上做 C++ 工程化——vscode + clangd 深入 + 完整调试" +description: "起步卷篇 5 装上 clangd 那篇的深入版:把 WSL2 工具链装满、把 .clangd 配到满血、把 launch.json/tasks.json 调试链配通,讲清 Remote-WSL 的客户端/服务端架构和 compile_commands.json 怎么被 clangd 找到" chapter: 1 -difficulty: intermediate order: 6 platform: host -reading_time_minutes: 5 +difficulty: intermediate +cpp_standard: [17, 20] tags: -- cpp-modern -- host -- intermediate -title: 快速在WSL上开发一般的C++上位机程序 -description: '' + - host + - cpp-modern + - intermediate + - clangd +reading_time_minutes: 20 +prerequisites: + - "起步卷篇 5: 让 vscode 看懂您的代码——装 clangd" +related: + - "CMake 是什么——构建系统生成器的两段式流水线" + - "CMakePresets.json——从 cmake -D 老式到 --preset 可复现" --- -# 快速在WSL上开发一般的C++上位机程序 -## 前言 +# 在 WSL 上做 C++ 工程化——vscode + clangd 深入 + 完整调试 -笔者绝对记得我曾经写过这类博客,但是我找不到了,这边马上要准备起一个新的现代C++分析教程,所以这个博客笔者计划用来存档下作为环境配置的一部分。 +在 Windows 上做正经 C++ 工程化,目前最顺手的组合是 **WSL2 + vscode + clangd**。这一篇把这套组合一次性配满:装好 Linux 工具链、把 clangd 用到满血、把 launch.json 和 tasks.json 调试链配通。如果您刚从 [起步卷篇 5](/getting-started/05-vscode-clangd) 过来,那边三步装上 clangd 把红线消掉了,本篇就接着往下挖——`.clangd` 配置文件每一项到底干什么、clang-tidy 怎么和 clangd 挂上、大项目后台索引慢该怎么治、调试断点打上之后 gdb 怎么显示 `std::vector`。 -> 说明:本文以 **WSL2 + Ubuntu(常见)** 为例;命令在 PowerShell / Windows Terminal(管理员)或 WSL 的 bash 中运行。若你选用其他 distro(Debian、Fedora 等),apt 的部分需改为相应包管理器。 -> -> WSL咋安装不教了,这个网上大把教程。 +## 为什么是 WSL ------- +Windows 自带的 C++ 工具链不是没有,MSVC、MinGW 都能跑。但您顺着本教程读到卷七,会发现所有命令行示例、所有 `CMakeLists.txt` 片段、所有终端输出都默认 Linux 环境。直接在 Windows 上跑,工具能跑通,但每一步都要过一道"翻译":`g++` 变成 `g++.exe`、路径分隔符变、`arm-none-eabi-g++` 的 sysroot 路径要重设。WSL2 把这道翻译直接抹掉。 -## 准备与前置条件 +WSL2 是微软搞的、跑在 Windows 里的真 Linux 内核(不是模拟器)。它对咱们这种做 C++ 工程化的场景有三个直接好处。 -- Windows 10/11(建议最新更新);推荐开启 WSL2(性能更好、默认新安装即为 WSL2)。可用 `wsl --install` 一步安装 WSL 和常用发行版。([Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/install?utm_source=chatgpt.com)) -- 在 Windows 端安装 Visual Studio Code(从 [https://code.visualstudio.com](https://code.visualstudio.com/) 下载并安装)。 -- 有一个微软账户/管理员权限以便在必要时启用虚拟化功能(Hyper-V / Virtual Machine Platform)。 +Linux 工具链最完整。`gcc`、`gdb`、`make`、`cmake`、`ninja-build`、`clangd`、`clang-tidy`、`valgrind`、`binutils` 一条 `apt` 命令全装上,版本还跟得上。本教程卷六讲 AddressSanitizer、卷七讲交叉编译,这些工具在原生 Windows 上要么得绕路装 MSYS2、要么干脆没有。 -## 首次进入 WSL:更新系统并安装基础编译工具 +跟生产环境一致。咱们写的 C++ 工程以后多半跑在 Linux 服务器上。开发环境就是 Linux,意味着「我本机能跑、上服务器就崩」这类环境差异问题从一开始就不存在。 -打开 Windows Terminal -> 选择 Ubuntu(或你安装的 distro),进入 shell,然后运行: +WSL2 性能接近原生。WSL2 走的是真 Linux 内核 + 轻量虚拟机路线,跟 WSL1 那套系统调用翻译完全不同。文件系统 IO、进程调度都接近原生 Linux 性能,编译速度跟真机 Linux 没差太多。这一点是 WSL2 相比 WSL1 的关键升级,也是为什么现在做 C++ 开发都默认 WSL2。 -```bash +::: warning 别在 `/mnt/c` 下做工程 +WSL2 访问 Windows 文件系统(`/mnt/c/...`)要走 9P 协议,IO 慢一个数量级。把工程放在 WSL 自家文件系统(`~/projects/` 下),configure 和 build 都快得多。笔者第一次没注意这点,一个中型工程 configure 跑了 40 秒,挪进 `~/` 之后 4 秒。 +::: + +## 装 WSL2 和 C++ 工具链 + +WSL2 的安装在 PowerShell(管理员)里一行命令搞定: + +```powershell +wsl --install +``` + +这条命令会启用需要的 Windows 功能(Virtual Machine Platform)、下载默认的 Ubuntu 发行版、装好。装完重启一次,启动 Ubuntu,第一次会让你设用户名和密码。如果您想用别的发行版(Debian、Fedora),`wsl --list --online` 看可选列表,`wsl --install -d <名字>` 装指定的。 -# 更新系统包索引与系统 +进 Ubuntu 之后,先把系统包刷到最新,然后一把装齐 C++ 工程化的全套工具: + +```bash sudo apt update && sudo apt upgrade -y +sudo apt install -y build-essential cmake ninja-build gdb clangd clang-tidy clang-format +``` + +`build-essential` 是 Debian/Ubuntu 那套对 C/C++ 的元包,装上就带 `gcc`/`g++`/`make`。`cmake` 是构建系统生成器(卷七 ch00 01 专门讲过)、`ninja-build` 提供 `ninja`(比 `make` 快、是本教程的默认 Generator)、`gdb` 是调试器。后面三个属于 LLVM 工具链:`clangd` 是 clang 出的 LSP 服务器(让 vscode 看懂代码)、`clang-tidy` 是静态检查、`clang-format` 是格式化。 -# 安装 C/C++ 常用工具(gcc/g++、make 等) -sudo apt install -y build-essential gdb cmake ninja-build pkg-config +::: details 顺手装几个有用的 -# 建议安装 clang/clang-format(可选) -sudo apt install -y clang clang-format +```bash +# valgrind 内存检查(卷六内存安全那卷会用) +sudo apt install -y valgrind + +# ccache 加速重编(CI 上和大型项目特别值) +sudo apt install -y ccache -# (可选)安装额外工具:python 用于一些构建脚本、ccache 等 -sudo apt install -y python3 python3-pip ccache +# 跟 cmake 一起用的几种 build 工具 +sudo apt install -y ninja-build +# 看 build 产物里有什么符号、依赖哪些动态库 +sudo apt install -y binutils ``` -`build-essential` 包含 gcc/g++、make 等,是在 Debian/Ubuntu 上非常常用的构建必备包。安装命令和说明见常用社区文档。 +::: ------- +装完验一下版本,确认都齐了。下面是笔者本机的输出: -## 在 Windows 上安装 VS Code,并启用 Remote - WSL 扩展 +```text +$ gcc --version | head -1 +gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0 -1. 在 Windows 下载并安装 Visual Studio Code。 -2. 打开 VS Code,打开扩展(Extensions)面板,搜索并安装: - - **Remote - WSL**(或名为 *WSL* 的官方扩展)——允许你直接在 WSL 环境中打开与运行 VS Code(编辑器会在 Windows,但扩展/运行在 WSL 上)。VS Code 官方有 WSL 开发文档与教程。(这个插件是真神) -3. 推荐再安装(后面在 WSL context 也会自动安装对应服务端扩展): - - **C/C++ (ms-vscode.cpptools)**:微软官方的 C/C++ 扩展,提供 IntelliSense、调试、代码导航等。注意,这个插件会跟clangd打架,如果你更喜欢Clang蔟的工具链,这个不要安装。安装Clangd, Clang-tidy才是你需要的 - - **CMake Tools**(或 C/C++ Extension Pack)— 用于 CMake 项目管理、配置、构建、切换 kit 等。如果你不是,VSCode有一大堆插件,这个需要您自己搜索了。笔者是喜欢用CMake - - **CodeLLDB**(若你偏好 lldb 调试器) - - **clang-format** 支持、GitLens(增强 Git 体验)、EditorConfig 等 +$ cmake --version | head -1 +cmake version 3.28.3 ------- +$ ninja --version +1.11.1 -## 在 WSL 中用 VS Code 打开项目(真正 "在 Linux 下开发") +$ gdb --version | head -1 +GNU gdb (Ubuntu 14.1-0ubuntu3.1) 14.1 + +$ clangd --version +clangd version 18.1.3 +Features: linux +Platform: x86_64-pc-linux-gnu +``` -1. 在 Windows 中打开 VS Code,按 `F1` -> 输入 `Remote-WSL: New Window`(或在 Ubuntu 终端进入项目目录后执行 `code .`,这会在 WSL 上打开 VS Code 窗口)。 -2. VS Code 会在 WSL 中自动安装必要的服务器组件,并在 "左下角绿色区域" 显示 `WSL: `,表示当前窗口已连接到 WSL。 +::: tip clangd 一定要和工具链一起装 +新手经常忘了装 `clangd` 这个程序,只装了 vscode 的 clangd 扩展。扩展只是「遥控器」,真正干活的是 `clangd` 这个二进制。光装扩展、没装程序,等于有遥控器没电视。`clangd --version` 打出版本号才算装上。 +::: -> 当 VS Code 在 WSL context 下打开时,左侧的 Extensions 面板会提示你"安装到 WSL:Ubuntu"的扩展(即扩展会安装在 WSL 环境而不是 Windows)。推荐把 C/C++、CMake Tools 等在 WSL 上安装(点击"Install in WSL: Ubuntu")。 +Ubuntu 24.04 的 apt 源里 clangd 是 18.x,已经够用(行内提示 InlayHints、include-cleaner、External 索引这些特性都齐)。如果您非要追新版,加 LLVM 官方 apt 源能装到 19/20,对本教程来说没必要。 ------- +## vscode Remote-WSL:编辑器在 Windows、活儿在 WSL -## 创建一个最小 CMake + C++ 项目并在 VS Code 中构建/调试 +vscode 跑 C++ 这事,背后是客户端/服务端架构:vscode 界面跑在 Windows,真正干活的进程跑在 WSL 里,中间靠 Remote-WSL 这个扩展连起来。这个架构搞不清楚,后面所有问题都没法定位。 -在 WSL 的Home目录里创建项目文件: +在 Windows 上做两件事: + +- 装 vscode(从 [code.visualstudio.com](https://code.visualstudio.com) 下,正常下一步) +- 在 vscode 扩展市场搜 `WSL`(发布者 Microsoft),装上 + +装好之后,有两种方式打开 WSL 里的工程: + +第一种,命令面板(`F1` 或 `Ctrl+Shift+P`)输 `Remote-WSL: New Window`,会拉起一个连着 WSL 的 vscode 新窗口。 + +第二种,在 WSL 终端里 cd 到工程目录,敲: ```bash -mkdir -p ~/projects/hello_cmake && cd ~/projects/hello_cmake +code . +``` + +`code` 这个命令是 Remote-WSL 扩展装上之后自动注入到 WSL 的 PATH 里的。它会让 Windows 那边的 vscode 打开,并把当前目录当成工作区。 + +::: details 为什么 `code .` 能用 +Remote-WSL 在 WSL 里放了一个 `code` 的 shell 脚本(通常在 `/usr/bin/code`),它干的事是跟 Windows 那边 vscode 通信,让 vscode 启动并连过来。第一次跑会从 Windows 拉一个 vscode server 组件到 WSL(`~/.vscode-server/`),这个 server 才是真正跑扩展、跑终端、跑语言服务器的进程。后续打开秒开。 +::: + +连上之后,看 vscode 窗口左下角,应该有个绿色或蓝色的标记写着 `WSL: Ubuntu`。这表示当前窗口的所有文件操作、终端、扩展都跑在 WSL 里。 +接下来这一点是新手的最大坑:**vscode 的扩展分两边装**。Windows 那边的扩展管 UI(主题、图标、快捷键),WSL 那边的扩展管 Linux 上的活儿(代码理解、调试、构建)。Remote-WSL 连上之后,扩展面板会分成「LOCAL - INSTALLED」(Windows 端)和「WSL: UBUNTU - INSTALLED」(WSL 端)两栏。您要装的 clangd、C/C++ 扩展、CMake Tools,都得装到 WSL 那栏(点扩展旁边的「Install in WSL: Ubuntu」)。 + +```text +扩展面板(连上 WSL 之后) +├── LOCAL - INSTALLED ← Windows 端:主题、图标、Remote-WSL 自身 +│ ├── Remote - WSL ✓ +│ ├── Material Icon Theme +│ └── ... +└── WSL: UBUNTU - INSTALLED ← WSL 端:这里装 clangd / C/C++ / CMake Tools + ├── clangd ← 代码理解(补全/跳转/报错) + ├── C/C++ ← 调试(留 cppdbg,关 IntelliSense) + └── CMake Tools ← CMake 配置/构建/选 kit(可选) ``` -新建文件 `CMakeLists.txt`: +clangd 这个扩展必须装到 WSL 端。它要调 WSL 里的 `clangd` 二进制,要走 WSL 里的 `compile_commands.json`,全在 Linux 这边。装错到 Windows 端,它去找 Windows 上的 `clangd.exe`,铁定找不到。 + +## clangd 深入配置 + +[起步卷篇 5](/getting-started/05-vscode-clangd) 把 clangd 装上、把红线消掉了,但只讲了三步:开 `CMAKE_EXPORT_COMPILE_COMMANDS`、装扩展、关 C/C++ 扩展的 IntelliSense。本篇把后面的事补齐——clangd 怎么找 compile_commands、`.clangd` 配置文件每一项干什么、clang-tidy 怎么挂、include-cleaner 怎么开。 + +### compile_commands.json 怎么来 + +clangd 干活要靠一份叫 `compile_commands.json` 的文件。这是 Clang 社区定义的 Compilation Database 格式,里面是项目里每个 `.cpp` 一条记录,记下编译它用的完整命令:编译器路径、`-std=` 标准、所有 `-I` 头文件搜索路径。clangd 拿到这份文件,才能「站在编译器的位置」看代码,知道 `std::vector` 该去哪个头文件找、`-std=c++17` 下哪些特性可用。 + +CMake 配合这件事特别顺,一行搞定。在 `CMakeLists.txt` 里 `project()` 之后加: ```cmake -cmake_minimum_required(VERSION 3.10) -project(hello_cmake LANGUAGES CXX) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` -set(CMAKE_CXX_STANDARD 17) -add_executable(hello main.cpp) +或者不想改 `CMakeLists.txt`,configure 时命令行加 `-DCMAKE_EXPORT_COMPILE_COMMANDS=ON` 也行。configure 完之后,`build/compile_commands.json` 就生成了。 +::: warning 这个开关只对 Makefile / Ninja Generator 生效 +`CMAKE_EXPORT_COMPILE_COMMANDS` 只在用 Makefile 或 Ninja 这两类 Generator 时才吐 `compile_commands.json`。Visual Studio Generator(`-G "Visual Studio 17 2022"`)和 Xcode Generator 不支持。WSL 里咱们默认用 Ninja,没这个问题。 +::: + +configure 完,`build/compile_commands.json` 长这样(笔者本机的真实输出): + +```json +[ + { + "directory": "/home/user/wsl-clangd/build", + "command": "/usr/bin/c++ -I/home/user/wsl-clangd -std=c++17 -o CMakeFiles/greeter.dir/main.cpp.o -c /home/user/wsl-clangd/main.cpp", + "file": "/home/user/wsl-clangd/main.cpp", + "output": "/home/user/wsl-clangd/build/CMakeFiles/greeter.dir/main.cpp.o" + } +] ``` -新建 `main.cpp`: +每个 `.cpp` 一项。`command` 字段是最关键的,clangd 解析它得到编译器、标准、头文件路径,然后照着这个视角去理解代码。所以您改了 `CMakeLists.txt`(比如新加一个 `target_include_directories`)之后,得重新 configure 让 `compile_commands.json` 刷新,不然 clangd 还在用旧的视角,新加的头文件路径它不知道,红线又回来。 -```cpp -#include +### clangd 怎么找 compile_commands.json + +这件事的官方行为是:clangd 拿到您正在编辑的源文件,沿着它所在目录一路向上找 `compile_commands.json`,找到第一个就用。也就是说,您的源文件在 `~/proj/src/foo.cpp`,clangd 会依次找: + +```text +~/proj/src/compile_commands.json +~/proj/compile_commands.json +~/compile_commands.json +~/.../compile_commands.json +``` + +clangd 16 之后还多了一条规则:沿途每一级目录里,它也会瞄一眼那个目录下的 `build/` 子目录有没有 `compile_commands.json`。这是专门为 CMake 项目加的便利——CMake 默认把这份文件写在 `build/` 里,clangd 知道这一点,会主动去翻。 + +笔者在 clangd 22 上实测,把源文件放 `src/`、`compile_commands.json` 放 `build/`,工程根没有任何软链,clangd 一样能找到: + +```text +I[11:23:59.322] Loading compilation database... +I[11:23:59.323] Loaded compilation database from /tmp/clangd-search-test/build/compile_commands.json +``` + +所以默认情况下不用您操心。但有两个场景还是要手动指一下。 + +第一个场景,您用了多个 build 目录(比如 `build-debug/` 和 `build-release/`),clangd 不知道挑哪个,可能在两个之间横跳。这种在 `.clangd` 里直接指定: + +```yaml +CompileFlags: + CompilationDatabase: build-debug +``` +`CompilationDatabase` 字段可以是个目录路径(相对工程根),也可以是 `Ancestors`(默认行为,向上找 + 翻 `build/`)或 `None`(关掉,只用 fallback)。 + +第二个场景,老 clangd(15 及更早)没那套「翻 `build/` 子目录」的规则,真的只会沿父目录找根上的 `compile_commands.json`。这种情况下工程根得有个软链: + +```bash +ln -sf build/compile_commands.json compile_commands.json +``` + +clangd 向上找时就能在工程根命中这个软链,跟着读到 `build/` 里那份真文件。新 clangd 不需要这步,但留着没坏处,对老 clangd 兼容。 + +### .clangd 配置文件逐项 + +`.clangd` 是 clangd 项目级配置,YAML 格式,放工程根目录。clangd 沿源文件目录向上找 `.clangd`,所有命中的片段按顺序合并,越靠近源文件的优先级越高。下面这份是笔者实战用的配置(同时存在仓库 `code/examples/vol7/wsl-clangd/.clangd`),逐段讲它每项干什么: + +```yaml +CompileFlags: + Add: [-Wall, -Wextra, -Wno-unused-parameter] + Remove: [-fsanitize=thread] + Compiler: clang++ + CompilationDatabase: build +``` + +`CompileFlags` 段对 `compile_commands.json` 里的编译命令做加工。`Add` 在每条命令后追加 flag——`-Wall -Wextra` 让 clangd 的报错和真编译一样严,`-Wno-unused-parameter` 让它跟项目里 callback 那种必须有但用不上的参数放过。`Remove` 用通配符干掉 flag,典型场景是 `compile_commands.json` 里有 `-fsanitize=thread`(卷五讲过 TSan),clangd 不需要重跑它、跑了反而报奇怪的 diagnostics。`Compiler` 把编译器可执行名替换成指定值,写 `clang++` 是让 clangd 用 Clang 自家驱动去探系统头和 ABI,交叉编译场景下特别有用(原始编译器是 `arm-none-eabi-g++`、clangd 探不到 sysroot 时换成 `clang++` 配 `--query-driver` 就能解决)。`CompilationDatabase` 上面讲过,指 compile_commands 所在目录。 + +```yaml +Index: + Background: Build + StandardLibrary: Yes +``` + +`Index` 段管 clangd 的索引。`Background: Build` 是开后台索引(首次打开项目慢就是它在干活),索引落盘在 `~/.cache/clangd/index/` 下,下次打开同一项目复用,不用从头来。`StandardLibrary: Yes` 把标准库符号纳入索引,您敲 `std::` 才能补全出 `vector`、`cout` 这些。这两项默认就是开的,写出来是为了显式说明。 + +```yaml +InlayHints: + Enabled: Yes + ParameterNames: Yes + DeducedTypes: Yes + Designators: Yes + BlockEnd: Yes +``` + +`InlayHints` 是 clangd 18+ 的行内提示,灰色虚文字直接显示在代码行内。`ParameterNames: Yes` 在函数调用处显示参数名 `greet(/*name=*/"WSL")`,省得来回切到声明看参数叫啥。`DeducedTypes: Yes` 显示 `auto` 推导出来的类型 `auto /*= int*/ sum`。`Designators: Yes` 在结构体聚合初始化时显示字段名 `Point{/*.x=*/1, /*.y=*/2}`。`BlockEnd: Yes` 在大段 `}` 后面显示它属于哪个函数/命名空间,几千行函数尾部那个 `}` 不再是迷。这一组是 vscode clangd 扩展默认不开的,开了之后代码可读性提升一个台阶。 + +```yaml +Diagnostics: + ClangTidy: + Add: [modernize-*, bugprone-*, performance-*, readability-*] + Remove: [modernize-use-trailing-return-type, readability-magic-numbers] + UnusedIncludes: Strict + MissingIncludes: Strict + Suppress: [unused-includes] +``` + +`Diagnostics` 段管红线/黄线。`ClangTidy.Add/Remove` 让 clangd 直接在编辑器里跑 clang-tidy 检查,不用手动开终端。`modernize-*` 一开,您写 `NULL` 它提示用 `nullptr`、写 `for (int i = 0; i < v.size(); ++i)` 它提示换成范围 for。`Remove` 把噪音 check 关掉——`modernize-use-trailing-return-type` 强制要求 `auto foo() -> int` 这种写法,社区吵了好多年,多数项目不要。`UnusedIncludes: Strict` 和 `MissingIncludes: Strict` 开启 clangd 内置的 include-cleaner,标记「include 了但没用上」和「用上了但没 include」两种问题。**刚上手的项目这两项先关掉**,老项目一开会满屏黄波浪线,会让人想直接卸 clangd。`Suppress` 屏蔽具体诊断 code,比改 check 更精准。 + +```yaml +Hover: + ShowAKA: Yes +``` + +`Hover` 段管鼠标悬停提示。`ShowAKA: Yes` 让 typedef/using 别名悬停时同时显示原始类型,`size_type` 悬停能看到底下是 `std::size_t`。 + +### clangd 扩展的 settings.json 关键项 + +`.clangd` 文件管的是 clangd 这个程序的行为,vscode clangd 扩展还有一组自己的设置在 `settings.json` 里。下面这份是和 `.clangd` 配套的关键项(仓库 `code/examples/vol7/wsl-clangd/.vscode/settings.json` 里有完整版): + +```json +{ + "C_Cpp.intelliSenseEngine": "disabled", + "clangd.arguments": [ + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--all-scopes-completion", + "--function-arg-placeholders", + "--pch-storage=disk", + "--inlay-hints", + "--j=4" + ], + "clangd.onConfigChanged": "restart" +} +``` + +`C_Cpp.intelliSenseEngine: disabled` 是篇 5 那一步的核心——把 C/C++ 扩展的代码理解关掉,让 clangd 独占。`clangd.arguments` 是 clangd 启动时的命令行参数。`--background-index` 显式开后台索引、`--clang-tidy` 开 clang-tidy 集成(配合 `.clangd` 的 `Diagnostics.ClangTidy` 和 `.clang-tidy` 文件)、`--header-insertion=iwyu` 接受补全时自动补 `#include`、`--all-scopes-completion` 让补全跨越当前 namespace(您在某个命名空间里也能补全局符号)、`--function-arg-placeholders` 函数补全带参数占位符、`--pch-storage=disk` PCH 落盘省内存、`--inlay-hints` 启用行内提示(clangd 18+)、`--j=4` 后台并行度。 + +`clangd.onConfigChanged: restart` 这条很关键:您改了 `.clangd` 之后,clangd 自动重启加载新配置。不开这个的话,改 `.clangd` 得手动 `Ctrl+Shift+P` 跑 `clangd: Restart language server` 才生效。 + +### Background Index:大项目第一次开慢是正常的 + +打开一个几万行的项目,clangd 启动后会盯着状态栏转圈几分钟甚至十几分钟。这是后台索引在跑:它在解析所有源文件、抽取符号和引用关系、写到 `~/.cache/clangd/index/` 落盘。第一次跑完之后,索引复用,第二次打开就快了。 + +验证它确实在干活,看 clangd 的输出面板(`View → Output → clangd`),能看到这样的日志: + +```text +I[15:32:11.456] Indexing xxx.cpp +I[15:32:11.612] Indexed preamble symbols: 1240 +I[15:32:11.738] Background: 1450 indexed, 0 dirty +``` + +如果项目特别大(比如 Chromium 这种),索引吃内存几个 G,您机器扛不住可以关后台索引,`Background: Skip` 或 `--background-index=0`。但代价是跨文件跳转和补全变慢,因为没建跨文件索引。多数项目开着没问题。 + +### clang-tidy 集成 + +clangd 内置的 clang-tidy 集成让静态检查直接进编辑器,不用切终端。它的工作方式是这样的: + +工程根放一个 `.clang-tidy` 文件(YAML 格式),写要开哪些 check: + +```yaml +Checks: > + -*, + modernize-*, + bugprone-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -readability-identifier-length +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +FormatStyle: file +``` + +`Checks` 第一项 `-*` 关掉所有默认 check,后面再 `modernize-*` 这种 glob 逐组开。`-` 前缀是关。`HeaderFilterRegex` 决定 clang-tidy 检查哪些头文件——`.*` 是所有,对第三方库噪音多就改成自己工程的头文件正则。 + +clangd 启动时会自动读这个文件。`settings.json` 里 `--clang-tidy` 开了之后,您每改一行代码、clangd 都会顺手跑相关的 clang-tidy check,问题直接画成黄线/红线在编辑器里。 + +笔者实测了一段代码触发 `readability-identifier-length`: + +```text +$ cat tidy_demo.cpp +#include int main() { - std::cout << "Hello from WSL C++ world!\n"; - int x = 42; - std::cout << "x = " << x << std::endl; + int big = 1000000000; + long narrowed = big; + int* p = nullptr; // ← 名字太短,3 字符以下被 check 拦 return 0; } +$ clang-tidy -p build tidy_demo.cpp +... tidy_demo.cpp:5:10: warning: variable name 'p' is too short, + expected at least 3 characters [readability-identifier-length] + 5 | int* p = nullptr; + | ^ ``` -构建(在 WSL 终端或 VS Code 的终端中): +同样的诊断在 vscode 里就是 `p` 那个变量名下面一条黄波浪线,鼠标悬停显示 `[readability-identifier-length]`。clangd 集成版不用您开终端,写完代码问题直接出现。 -```bash -mkdir -p build && cd build -cmake .. -G "Ninja" # 如果你安装了 ninja;否则用默认 make: cmake .. -cmake --build . -./hello +### include-cleaner + +clangd 内置的 include-cleaner(不依赖外部 clang-tidy)专门治 include 的两个毛病:include 了但没用上、用上了但没 include。开关在 `.clangd` 的 `Diagnostics` 段: + +```yaml +Diagnostics: + UnusedIncludes: Strict # None = 关, Strict = 严格开 + MissingIncludes: Strict +``` + +笔者建议:**新项目一开始就开**,include 关系从源头干净;**接手老项目先 `None`**,老代码 include 历史包袱重,一开 Strict 满屏黄线会让人失去判断力。先理顺代码再开。 +include-cleaner 还支持 IWYU pragma,写在头文件里给工具下指令: + +```cpp +#include // IWYU pragma: export +#include "detail_helpers.h" // IWYU pragma: keep ← 即便没用上也别警告 ``` -如果你安装并使用 **CMake Tools** 扩展:打开项目根目录,扩展会在底部状态栏提供 `Configure`、`Build` 按钮,点击即可;并可以选择不同的 kit(gcc/clang)与构建目录。 +`export` 是「我这个头替使用者 include 了 ``,使用者不用再 include」;`keep` 是「这条 include 别给我标记成 unused」。大型库里这两种 pragma 用得多,避免 include-cleaner 误报。 ------- +### clangd 还是 C/C++ 扩展(跟起步卷对齐) -## 在 VS Code 中配置调试(使用 ms-vscode.cpptools 的 gdb) +到这里您可能问:C/C++ 扩展是不是该卸了?不行。和 [起步卷篇 5](/getting-started/05-vscode-clangd) 的口径一致: -在项目的 `.vscode` 目录下创建 `launch.json`(使用 cpptools 的 `cppdbg`): +- clangd 管「看懂代码」——补全、跳转、报错、悬停、行内提示、clang-tidy。准。 +- C/C++ 扩展留「调试」——断点、单步、看变量、调用栈。它带的 `cppdbg` 调试器是 vscode 上调 gdb/lldb 最成熟的方案。 + +所以 `C_Cpp.intelliSenseEngine: disabled` 关的是 C/C++ 扩展的代码理解,扩展本身不卸。两个分工不打架。下面调试试的就是 C/C++ 扩展的 `cppdbg`。 + +## 调试配置:launch.json + +工程能编、clangd 能跳转之后,最后一个环节是调试:打断点、单步、看变量。本篇这一段把原稿戛然而止在「切换到调试栏点击」那句话的地方补完。 + +vscode 调 C++ 走 `.vscode/launch.json`。这里给一份完整可用的配置(仓库 `code/examples/vol7/wsl-clangd/.vscode/launch.json` 里也是它),用 C/C++ 扩展的 `cppdbg` + gdb: ```json { - "version": "0.2.0", - "configurations": [ - { - "name": "Debug Hello (gdb)", - "type": "cppdbg", - "request": "launch", - "program": "${workspaceFolder}/build/hello", - "args": [], - "stopAtEntry": false, - "cwd": "${workspaceFolder}", - "environment": [], - "externalConsole": false, - "MIMode": "gdb", - "miDebuggerPath": "/usr/bin/gdb", - "setupCommands": [ - { "description": "Enable pretty-printing", "text": "-enable-pretty-printing", "ignoreFailures": true } - ], - "preLaunchTask": "CMake: build" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "(gdb) Launch greeter", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/greeter", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "miDebuggerPath": "/usr/bin/gdb", + "setupCommands": [ + { + "description": "Enable pretty-printing for gdb", + "text": "-enable-pretty-printing", + "ignoreFailures": true + } + ], + "preLaunchTask": "build" + } + ] } +``` + +逐字段说。`type: cppdbg` 是 C/C++ 扩展提供的调试器类型,靠 gdb 的 MI 协议驱动 gdb。`program` 是要调的可执行文件全路径,`${workspaceFolder}` 是 vscode 当前打开的工程根目录。`MIMode: gdb` 配 `miDebuggerPath: /usr/bin/gdb` 告诉它走 WSL 里的 gdb。`preLaunchTask: build` 是按下 F5 之前先跑一个叫 `build` 的 task(下面 tasks.json 里定义),build 失败就不启动调试,省得调一个旧版本的二进制。 + +`setupCommands` 里的 `-enable-pretty-printing` 是关键。不开它,您断点上看一个 `std::vector v{1,2,3,4,5}`,变量面板显示的是一堆原始成员(`_M_start`、`_M_finish`、`_M_end_of_storage` 这种 libstdc++ 内部指针),完全看不出 vector 里是 `{1,2,3,4,5}`。开了之后 gdb 用 Python pretty-printer 把它格式化成可读形式。下面是笔者本机的真实 gdb 输出对比: +```text +(gdb) print nums # nums 是 std::vector{1,2,3,4,5} + +没开 pretty-printing: $1 = {_M_impl = {_M_start = 0x555..., _M_finish = ..., _M_end_of_storage = ...}} +开了 pretty-printing: $1 = std::vector of length 5, capacity 5 = {1, 2, 3, 4, 5} ``` -"program"需要填写你的应用程序的文件路径,`${workspaceFolder}`就是当前你开VSCode的目录,这里的构建放到了build下,你进这里就能看到你生成的应用程序了。 +vscode 里把 `setupCommands` 配上之后,变量面板显示的就是后者那种可读形式。这一步新手最容易漏:能调但变量看不懂,断点打了等于没打。 + +::: tip CodeLLDB 备选 +如果您偏好 lldb,装 CodeLLDB 扩展(`vadimcn.vscode-lldb`)+ WSL 里 `sudo apt install lldb`,launch.json 改用 `"type": "lldb"`。CodeLLDB 不走 MI 协议、直接驱动 lldb,启动更快、对 C++ 类型显示更友好(不用配 pretty-printing,自带)。但本教程统一用 gdb,下面例子都基于 gdb。 +::: + +配好之后,在 `main.cpp` 第 14 行(`for (int x : nums)` 那行)左侧边栏点一下打个红点断点,按 `F5`。vscode 先跑 `build` task 重新编一次,编完启动 gdb 加载 `build/greeter`,跑到断点处停住。左侧「运行和调试」面板能看到调用栈、变量、断点、监视。变量面板里 `nums` 展开是 `std::vector of length 5, capacity 5 = {1, 2, 3, 4, 5}`,`sum` 是当前的累计值。按 `F10` 单步步过、`F11` 单步步入、`F5` 继续运行。 + +## tasks.json 构建任务 + +launch.json 里那个 `preLaunchTask: build` 需要一个对应的 task。task 在 `.vscode/tasks.json` 里定义: + +```json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "cmake", + "args": [ + "--build", + "${workspaceFolder}/build", + "--config", + "Debug", + "--parallel" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": ["$gcc"] + }, + { + "label": "configure", + "type": "shell", + "command": "cmake", + "args": [ + "-S", "${workspaceFolder}", + "-B", "${workspaceFolder}/build", + "-G", "Ninja", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON" + ], + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [] + }, + { + "label": "rebuild", + "dependsOn": ["configure", "build"], + "dependsOrder": "sequence", + "group": "build", + "problemMatcher": [] + } + ] +} +``` + +三个 task 分工。`build` 跑增量构建(`cmake --build build`,底层 Ninja),它是默认 build task(`isDefault: true`),所以 `Ctrl+Shift+B` 直接触发它。`configure` 第一次或改了 `CMakeLists.txt` 之后跑,重新 configure 一次刷新 `compile_commands.json`。`rebuild` 用 `dependsOrder: sequence` 顺序跑 configure 再跑 build,一把梭。 + +`problemMatcher: ["$gcc"]` 这一项让 vscode 解析编译器输出,把报错/warning 转成「问题」面板里的可点击条目,点一下跳到对应行。这是 vscode 内置的 `$gcc` 模式,匹配 gcc/clang 的报错格式。 + +launch.json 按 F5 触发的链是:跑 `build` task → build 成功 → 启动 gdb 加载 `build/greeter` → 跑到断点停。整个调试循环就这么闭环了,不用每次手动切终端敲 `cmake --build`。 + +## 到这里 + +WSL2 + vscode + clangd + cppdbg 这一套配齐之后,您手上的 C++ 工程化环境跟一个资深 Linux 开发者用的几乎没差别:补全准、跳转快、报错严、调试能看 vector。后面读卷七 ch00 的 CMake 系列(target 心智模型、CMakePresets.json)、卷六的内存安全(AddressSanitizer + valgrind),命令都是直接在 WSL 终端里敲,跟文章里的输出对得上。 -如果你用 `tasks.json` 自定义 build 任务,确保 `preLaunchTask` 名称一致;但若使用 CMake Tools,它会自动创建并管理构建任务/调试配置,通常更方便。这样的话,你切换到VSCode的调试栏上点击 +配套的所有配置文件(`.clangd`、`.clang-tidy`、`.vscode/settings.json`、`launch.json`、`tasks.json`)都存在仓库 `code/examples/vol7/wsl-clangd/` 下,clone 下来直接能跑。CMake 工程最小可复现,`cmake -B build -G Ninja && cmake --build build` 出 `build/greeter`,按 F5 进调试。 diff --git a/documents/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md b/documents/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md new file mode 100644 index 000000000..90cb9d096 --- /dev/null +++ b/documents/vol8-domains/embedded/00-env-setup/06-clangd-for-cross-compilation.md @@ -0,0 +1,327 @@ +--- +title: "嵌入式 clangd——让 vscode 看懂交叉编译的代码" +description: "把 host 平台三步装好的 clangd 搬到 arm-none-eabi-g++ 交叉工程里就满屏红线。这篇讲透根因,给出 query-driver 与 .clangd 的完整可粘贴配置" +chapter: 14 +order: 6 +platform: stm32f1 +difficulty: intermediate +cpp_standard: [17, 20] +tags: + - stm32f1 + - 嵌入式 + - intermediate + - clangd + - 交叉编译 +reading_time_minutes: 16 +prerequisites: + - "Chapter 14: 第1篇 从零搭建 STM32 开发工具链" + - "Chapter 14: CMake 配置篇" +related: + - "让 vscode 看懂您的代码——装 clangd,红线消失" + - "交叉编译和CMake简单指南" +--- + +# 嵌入式 clangd——让 vscode 看懂交叉编译的代码 + +## 开场 + +起步卷篇 5 咱们给 host 平台装过 clangd,流程很短:卸掉微软的 C/C++ 扩展、装 clangd 扩展、`compile_commands.json` 一喂,代码就聪明了,跳转补全一条龙。那篇结尾还特地强调一句:clangd 之所以能「看懂」代码,靠的是它从 `compile_commands.json` 里读到的每一条编译命令——用什么编译器、加了哪些 flag、`-I` 指向哪、目标平台是什么。 + +您把同一套搬到嵌入式工程里,大概率当场就懵。 + +打开 `main.c`,第一行 `#include "stm32f1xx.h"` 就一根红波浪线;`HAL_GPIO_WritePin` 这种 HAL 函数全找不着;`stdint.h`、`core_cm3.h` 一个个都画着红,clangd 像瞎了一样。偏偏您 `cd build && ninja` 编译能过、烧到板子上 LED 也能闪。编译器明明认识这些头,clangd 怎么就不认? + +这篇就是治这个的。咱们把根因拆透,再给一份可以直接抄走的配置。仓库里 `code/stm32f1-tutorials/*/.vscode/settings.json` 一直在用这套配置,只是从来没文档讲过它在干什么——这篇把它讲清楚。 + +## 为什么会全红:clangd 在自己造路径 + +先回忆 host 平台那套为什么能跑。host 工程里 clangd 看到的编译命令长这样: + +```text +/usr/bin/g++ -std=c++20 -I/home/you/proj/include main.cpp +``` + +编译器是 `g++`,clangd 拿着这条命令就能干活,因为它对 `g++` 的头文件布局了如指掌——`/usr/include/c++/14`、`/usr/include` 这一套标准路径它内置了,直接拿来用。 + +换到嵌入式工程,`compile_commands.json` 里 clangd 看到的编译命令变成了这样: + +```json +{ + "directory": "/home/you/proj/build", + "command": "/usr/sbin/arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb -I.../Drivers/CMSIS/Device/ST/STM32F1xx/Include main.cpp -c -o CMakeFiles/main.dir/main.cpp.o", + "file": "../main.cpp" +} +``` + +注意编译器从 `g++` 换成了 `arm-none-eabi-g++`。问题来了:clangd 自己是基于 clang 的,它**不认识这个 GNU 交叉编译器内部把头文件装在哪**。它对 GCC 的内置路径布局,是从本机 `g++` 那里推断出来的,arm 的 newlib 头、arm 的 libstdc++ 头、CMSIS 的 `core_cm3.h`,它一个都不知道。 + +那它会怎么做?clangd 在 14 版本之后,对这类「我不认识的编译器」,默认会套用一个叫 **BareMetal** 的「假想 toolchain」(target 是 `arm-none-eabi`)。这个假想 toolchain 会自己造一堆路径,典型长这样: + +```text +clang-runtimes/arm-none-eabi/include +clang-runtimes/arm-none-eabi/include/c++ +clang-runtimes/arm-none-eabi/share +``` + +您去仓库根目录、去 `/usr/lib`、去任何地方 `find` 一下,都找不到 `clang-runtimes/arm-none-eabi` 这个目录——因为它根本不存在,是 clangd 推测出来的「按理说应该在这」的路径。系统头 `stdint.h`、CMSIS 头 `core_cm3.h` 不在这些假路径里,clangd 自然找不到,于是全画红。 + +::: warning 病根不在 clangd 笨 +根因是 clangd **没去问真正的交叉编译器**「你的头文件装在哪」。它在用自己内置的、基于 clang runtime 目录的猜测去套一个 GCC 工具链,而 GCC 的头文件布局和 clang runtime 完全是两套东西。猜错了,路径全是空的,头全找不到。 +::: + +补一刀。clangd 哪怕猜到了路径,它也不知道交叉编译器内置的那些宏。咱们验证一下 `arm-none-eabi-g++` 在没指定 `-mcpu` 时内置哪些宏: + +```bash +$ arm-none-eabi-g++ -E -dM -xc++ /dev/null | grep -E "__ARM_ARCH|__arm__|__thumb__" +#define __ARM_ARCH_ISA_ARM 1 +#define __ARM_ARCH_ISA_THUMB 1 +#define __ARM_ARCH_4T__ 1 +#define __ARM_ARCH 4 +#define __arm__ 1 +``` + +注意它默认是 `__ARM_ARCH_4T__`、ARMv4,而不是 Cortex-M3 对应的 ARMv7-M。Cortex-M3 是 ARMv7-M、只支持 Thumb-2 指令,和这个默认 target 完全不是一回事。`core_cm3.h`、`cmsis_gcc.h` 这些头会检查 `__ARM_ARCH_7M__` 之类的宏来决定走哪条代码路径,clangd 不带这些宏去解析,解析出来的结果和真实编译的不一样,有些头里的 `#error` 就会触发,屏幕更红。 + +## query-driver:让 clangd 真去问编译器 + +clangd 有个机制正好治这个,叫 **query-driver**。 + +它的原理很直接:clangd 不再自己瞎猜,而是**真的去执行您指定的编译器**,跑这么一条命令: + +```bash +arm-none-eabi-g++ -E -xc++ -v /dev/null +``` + +这是让交叉编译器做一次「预处理空文件并打印详细信息」的操作。GCC 会把内部的头文件搜索路径、内置宏定义全部吐到 stderr 里。咱们本机跑一下(`arm-none-eabi-g++ 16.1.0`): + +```text +#include "..." search starts here: +#include <...> search starts here: + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0 + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0/arm-none-eabi + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include/c++/16.1.0/backward + /usr/lib/gcc/arm-none-eabi/16.1.0/include + /usr/lib/gcc/arm-none-eabi/16.1.0/include-fixed + /usr/lib/gcc/arm-none-eabi/16.1.0/../../../../arm-none-eabi/include +End of search list. +``` + +这些路径才是**真实存在**的——`/usr/arm-none-eabi/include` 是 newlib 的 C 头,`/usr/.../include/c++/16.1.0` 是 newlib 配套的 libstdc++ 头。clangd 把这些路径抓过来当系统头,加上从 `compile_commands.json` 读到的 `-mcpu=cortex-m3 -mthumb -I.../Drivers/...` 一起喂给内部的 clang,代码就解析对了。 + +::: warning 为什么默认不开 +query-driver 等于让 clangd **执行任意二进制**。设想一下:您 clone 一个来历不明的工程,它 `.clangd` 里写了 `Compiler: /tmp/evil.sh`,clangd 一启动就把这玩意儿当编译器跑一遍——这事不能让它默默发生。所以 clangd 默认拒绝 query-driver,必须由您显式 allowlist 哪些编译器路径可以执行。这是安全考虑,不是 bug。 +::: + +### 配置三件套 + +要让 query-driver 真正生效,需要三处一起配。咱们一件一件来。 + +### 第一件:VS Code 的 clangd.arguments 加 --query-driver + +打开工程的 `.vscode/settings.json`,加上 `--query-driver` 参数: + +```json +{ + "clangd.arguments": [ + "--query-driver=/usr/sbin/arm-none-eabi-g++,/usr/sbin/arm-none-eabi-gcc" + ] +} +``` + +等号后面是一串**逗号分隔的绝对路径**,支持 glob(`*`、`?`)。clangd 只会执行路径匹配这串 glob 之一的编译器,别的全拒。这里咱们 allowlist 了 `arm-none-eabi-g++` 和 `arm-none-eabi-gcc` 两个,正好覆盖 C++ 工程和纯 C 工程。 + +::: warning 这里写绝对路径,不是命令名 +`--query-driver` 必须是绝对路径或绝对路径的 glob,写成 `--query-driver=arm-none-eabi-g++` 是不生效的——clangd 不去 `PATH` 里找,会直接判定无匹配、拒绝执行。本机环境装的位置不同,路径要相应改(下面会讲仓库里为什么是 `/usr/sbin/`)。 +::: + +### 第二件:工程根 .clangd 配 CompileFlags.Compiler 和 BuiltinHeaders + +光 allowlist 还不够。clangd 还得知道「这个工程要用 `arm-none-eabi-g++` 来解析」,以及「它的内置头要走 query-driver 而不是 clangd 自己的」。在工程根目录建一个 `.clangd` 文件: + +```yaml +CompileFlags: + Compiler: arm-none-eabi-g++ + Add: + - -mcpu=cortex-m3 + - -mthumb + BuiltinHeaders: QueryDriver +``` + +逐行解释这四行在干嘛。 + +`Compiler: arm-none-eabi-g++` 告诉 clangd:这个工程的编译命令,把 executable 这一项**替换**成 `arm-none-eabi-g++`(写在 PATH 里能找到的名字就行,不需要绝对路径)。这样即便 `compile_commands.json` 里写的是别的(比如 CMake 给的相对路径),clangd 也会强制用这个交叉编译器。 + +`Add` 是给所有编译命令**追加**的 flag。嵌入式工程通常 CMake 里已经写了 `-mcpu=cortex-m3 -mthumb`,`compile_commands.json` 里就自带,这行其实有点冗余——但写上更稳,因为有些 CMake 老脚本不一定把这俩 flag 透传到每个 target。`-mcpu=cortex-m3` 决定 target 是 Cortex-M3,`-mthumb` 强制走 Thumb 指令集,这俩缺一不可。 + +`BuiltinHeaders: QueryDriver` 是点睛之笔。它把内置头(`stdint.h`、`stddef.h` 这些 GCC 自带的头)的来源从「clangd 自己造的 `clang-runtimes/...` 假路径」切换到「通过 query-driver 问编译器拿到的真实路径」。前面那一地红波浪线,主要就是被这一行治好的。 + +### 第三件:compile_commands.json 要有交叉 flags + +光配 clangd 不够,它读的 `compile_commands.json` 也得是交叉编译版的。这点下一篇讲,这里先放一句话:CMake 用 toolchain 文件 + `CMAKE_EXPORT_COMPILE_COMMANDS`,生成的 json 自带 `-mcpu=cortex-m3/-mthumb/-I.../Drivers/...`,clangd 读到就知道这是给 arm 编的,不会再往 host 那边猜。 + +## compile_commands.json 从哪来 + +嵌入式工程的 CMake,和 host 工程最大的区别是要传一个 **toolchain 文件**。这个文件长这样(`arm-none-eabi.cmake`): + +```cmake +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_SYSTEM_PROCESSOR cortex-m3) + +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +set(CMAKE_CXX_COMPILER arm-none-eabi-g++) + +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +set(MCU_FLAGS "-mcpu=cortex-m3 -mthumb") +set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}") +set(CMAKE_CXX_FLAGS_INIT "${MCU_FLAGS}") +``` + +`CMAKE_SYSTEM_NAME Generic` 告诉 CMake「目标没有操作系统」(裸机),`CMAKE_C_COMPILER` / `CMAKE_CXX_COMPILER` 指定交叉编译器。`CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY` 这行容易漏——默认 CMake 配置时编译一个试运行的 exe 来验证编译器,但交叉编译器编出来的 ARM 可执行文件在本机跑不了,试运行会失败,这行改成静态库跳过运行。 + +工程根 `CMakeLists.txt` 里再开一行: + +```cmake +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +configure 时记得带上 toolchain: + +```bash +cmake -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +::: details 完整构建命令(可折叠) + +```bash +# 清掉旧 build 再重新 configure,确保 compile_commands.json 是交叉版的 +rm -rf build +cmake -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +ninja -C build + +# 看一眼生成的命令里有没有 -mcpu +grep -m1 "mcpu" build/compile_commands.json +``` + +::: + +生成的 `build/compile_commands.json` 里,每个 `.cpp` 的 `command` 都自带 `-mcpu=cortex-m3 -mthumb`,clangd 一读就知道目标是 Cortex-M3、Thumb 指令集,再配合 query-driver 拿到的 newlib 头,整个解析链条就闭环了。 + +::: warning 别让 CMake 把 flag 吃掉 +有些 CMake 模板把 `-mcpu=cortex-m3 -mthumb` 写成 `target_compile_options(... PRIVATE -mcpu=cortex-m3 -mthumb)`,这是对的,会进 `compile_commands.json`。但如果写成 `add_compile_options` 还套了 `interface`、或者写进 `CMAKE__FLAGS` 但被 generator 表达式吞了,就有可能不透传。配完之后一定 `grep "mcpu" build/compile_commands.json` 验证一下,flag 没进 json,clangd 就拿不到。 +::: + +## 沉淀项目已有的配置 + +讲了半天原理,其实仓库里 `code/stm32f1-tutorials/` 下每个工程都已经配好了。挑 `0_start_our_tutorial` 这个工程看,`.vscode/settings.json` 就这五行: + +```json +{ + "clangd.arguments": [ + "--query-driver=/usr/sbin/arm-none-eabi-g++,/usr/sbin/arm-none-eabi-gcc" + ] +} +``` + +`1_led_control`、`2_button_control`、`3_uart_logger` 这几个工程,`.vscode/settings.json` 内容一模一样。**仓库自己在用的就是这套,照抄即可。** + +这里有个细节要讲清楚:为什么路径是 `/usr/sbin/` 而不是 `/usr/bin/`? + +这跟工具链装的方式有关。本机用的是 MSYS2 风格的包管理(WSL2 + pacman),`arm-none-eabi-gcc` 这个包把编译器实际装在 `/usr/sbin/` 下,`/usr/bin/` 下通常是别的常用工具。`ls` 验证一下: + +```bash +$ ls -l /usr/sbin/arm-none-eabi-g++ +-rwxr-xr-x 2 root root 1.7M arm-none-eabi-g++ 16.1.0 + +$ which arm-none-eabi-g++ +/usr/sbin/arm-none-eabi-g++ +``` + +::: details Ubuntu / Arch / Homebrew 路径都不一样 + +| 平台 | 典型路径 | +|---|---| +| MSYS2 / WSL2 + pacman | `/usr/sbin/arm-none-eabi-g++` | +| Ubuntu apt(`gcc-arm-none-eabi` 包) | `/usr/bin/arm-none-eabi-g++` | +| Arch pacman | `/usr/bin/arm-none-eabi-g++` | +| macOS Homebrew | `/opt/homebrew/bin/arm-none-eabi-g++` | + +`which arm-none-eabi-g++` 跑一下,把输出填进 `--query-driver` 就行。拿不准就写 glob:`--query-driver=/usr/*/arm-none-eabi-g*,/opt/*/arm-none-eabi-g*`,把几个常见路径都覆盖上。 + +::: + +注意这套 `.vscode/settings.json` **只配了 query-driver**,没配 `.clangd`。原因是这些工程的 `compile_commands.json` 里 `command` 字段已经直接写明了 `/usr/sbin/arm-none-eabi-g++`(CMake 用绝对路径生成的),clangd 一看 executable 是 arm 工具链,又开了 query-driver,头文件路径就自动从 GCC 那里拿到了,`BuiltinHeaders: QueryDriver` 这种 `.clangd` 配置其实是省了——query-driver 开启之后,clangd 默认就用 query 到的头替代自己的 builtin。`Compiler:` 和 `Add: [-mcpu...]]` 这种 `.clangd` 配置,是当您的 `compile_commands.json` 不够干净、executable 路径或 flag 不对时才需要补的兜底。 + +## sysroot 和 --gcc-install-dir + +配完上面这套,大部分情况红线就消了。但偶尔会有一两个头还是找不到——典型场景是您的 `compile_commands.json` 里**没带 sysroot**,clangd 自己解析时找不到 newlib 的部分头。这种情况要补一下 sysroot。 + +`--gcc-install-dir` 是 clang 的一个 flag,直接告诉它「GNU 工具链的 libstdc++ 装在这个目录」,clang 会从那里推算头文件位置。在 `.clangd` 里追加: + +```yaml +CompileFlags: + Compiler: arm-none-eabi-g++ + Add: + - -mcpu=cortex-m3 + - -mthumb + - --gcc-install-dir=/usr/lib/gcc/arm-none-eabi/16.1.0 + BuiltinHeaders: QueryDriver +``` + +或者用老办法 `-isystem` 显式补一个系统头目录(比如 newlib 的 C 头): + +```yaml +CompileFlags: + Add: + - -isystem/usr/arm-none-eabi/include +``` + +排查的时候,让 clangd 把日志打详细一点,看它实际去哪找头: + +```text +View → Command Palette → Clangd: Open Log +``` + +或者在 VS Code 输出面板选 clangd,日志里搜 `Search starts here`,看 clangd 实际拿到的搜索路径有没有覆盖到 newlib 的目录。日志里能看到这样一行: + +```text +Query driver arm-none-eabi-g++ for include paths +``` + +说明 query-driver 真的执行了;如果看不到这行,多半是 `--query-driver` 的 glob 没匹配上,clangd 静默跳过了 query。回到 `.vscode/settings.json` 检查路径写对了没。 + +::: warning clangd 版本要够新 +query-driver 和 `BuiltinHeaders: QueryDriver` 这套,需要 clangd **17 或更高**。`gcc-install-dir` 这个 flag 需要 clangd **18+**(底层 clang 18 才认)。本机用 clangd 19 没问题,老发行版自带的 clangd 14、15 跑不通这套,会卡在「配置都对但日志没动静」的玄学状态。`clangd --version` 先确认。 +::: + +## 验证 + +配完之后,关掉 VS Code 重开(或者 Command Palette → `Clangd: Restart language server`),打开 `main.c`。该看到的几件事: + +1. 第一行 `#include "stm32f1xx.h"` 的红波浪线消失,`stm32f1xx.h` 这文件本来就在 `Drivers/CMSIS/Device/ST/STM32F1xx/Include/` 下,query-driver 拿到 sysroot + CMake 给的 `-I` 之后 clangd 找得到。 +2. 按住 `Ctrl` 点 `HAL_GPIO_WritePin`,光标跳到 `stm32f1xx_hal_gpio.h` 里的声明。 +3. 敲 `HAL_`,补全列表弹出来,列出 `HAL_GPIO_WritePin`、`HAL_Delay`、`HAL_Init` 这些。 +4. clangd 日志里能看到 `Query driver arm-none-eabi-g++ for include paths` 这一行。 + +到这一步,交叉工程的 clangd 体验就和 host 工程持平了——红线消失、跳转补全一条龙,该有的都有。 + +::: details 验证清单(出问题对照) + +- [ ] `clangd --version` 是 17+,最好 18+ +- [ ] `--query-driver` 路径 glob 匹配上 `which arm-none-eabi-g++` 的输出 +- [ ] `grep "mcpu" build/compile_commands.json` 有结果,确认交叉 flag 进了 json +- [ ] clangd 日志里出现 `Query driver ... for include paths` +- [ ] `arm-none-eabi-g++ -E -xc++ -v /dev/null` 能输出真实的 include 路径 +- [ ] `.clangd` 里 `Compiler:`、`BuiltinHeaders: QueryDriver` 写对(只在 compile_commands 不够干净时需要) + +::: + +## 收尾 + +clangd 在嵌入式工程的全红,根因在于它默认没去问真正的交叉编译器头文件装在哪——这事起步卷篇 5 那套 host 配置掩盖掉了,因为 host 编译器 clangd 内置就认识。一旦换成 arm 工具链,得显式让 clangd 去 query driver,把 GCC 的真实路径拉过来,解析链条才闭环。 + +下一篇咱们会接到 vol7 的 [交叉编译和 CMake 简单指南](/vol7-engineering/01-cross-compilation-and-cmake),那是站在 host 角度讲交叉编译的工程化(多目标构建、toolchain 文件复用);本篇是嵌入式这条线上的 clangd 专章,把 IDE 这一段补齐。如果您还没看过 [起步卷篇 5:装 clangd,红线消失](/getting-started/05-vscode-clangd),建议先回去看一遍,这篇的「为什么」是建立在篇 5 的「怎么做」之上的。 diff --git a/documents/vol8-domains/embedded/00-env-setup/index.md b/documents/vol8-domains/embedded/00-env-setup/index.md index 135b99bee..fa2e2253a 100644 --- a/documents/vol8-domains/embedded/00-env-setup/index.md +++ b/documents/vol8-domains/embedded/00-env-setup/index.md @@ -22,3 +22,7 @@ tags: - [环境搭建(四):WSL2 USB 透传](04-wsl2-usb.md) — 让 ST-Link 穿越虚拟化边界 - [第5篇:调试进阶篇](05-debugging-guide.md) — 从 printf 到完整 GDB 调试环境 + +## IDE 配置 + +- [第6篇:嵌入式 clangd](06-clangd-for-cross-compilation.md) — 让 vscode 看懂交叉编译的代码,query-driver 配置详解 diff --git a/scripts/check_quality.py b/scripts/check_quality.py index 79aaa3c44..2130f7531 100644 --- a/scripts/check_quality.py +++ b/scripts/check_quality.py @@ -126,7 +126,11 @@ def normalize_link(link_url: str, source_file: Path, root: Path) -> str: if not link_url: return '' try: - resolved = (source_file.parent / link_url).resolve() + if link_url.startswith('/'): + # VitePress site-root absolute path (e.g. /compilation/, /getting-started/05-vscode-clangd) + resolved = (root / link_url.lstrip('/')).resolve() + else: + resolved = (source_file.parent / link_url).resolve() return str(resolved.relative_to(root)) except (ValueError, RuntimeError): return link_url @@ -247,9 +251,11 @@ def check(self, filepath: Path, content: str, if not normalized.endswith('.md') and not ext: normalized_md = normalized + '.md' if normalized_md in self.file_index: - report.warnings.append(Issue(filepath, line_num, 'warning', - 'internal_link', - f"Missing .md extension: [{text}]({url})")) + # VitePress clean-URL style (/path without .md) is valid, not a warning + continue + # Try as directory (VitePress /path/ → path/index.md) + normalized_idx = normalized.rstrip('/') + '/index.md' + if normalized_idx in self.file_index: continue report.errors.append(Issue(filepath, line_num, 'error', diff --git a/scripts/tags.py b/scripts/tags.py index 831b69bce..da618c190 100644 --- a/scripts/tags.py +++ b/scripts/tags.py @@ -43,7 +43,7 @@ # Embedded specific '嵌入式', '单片机', '外设管理', '寄存器', '链接器', - '交叉编译', '工具链', 'CMake', + '交叉编译', '工具链', 'CMake', 'clangd', # General '基础', '入门', '进阶', '实战', '优化', '工程实践', diff --git a/site/.vitepress/config/nav.ts b/site/.vitepress/config/nav.ts index e263ce2f4..17ad54a93 100644 --- a/site/.vitepress/config/nav.ts +++ b/site/.vitepress/config/nav.ts @@ -1,6 +1,7 @@ import type { DefaultTheme } from 'vitepress' export const navZh: DefaultTheme.NavItem[] = [ + { text: '新手起步', link: '/getting-started/' }, { text: '基础与特性', items: [ @@ -21,6 +22,7 @@ export const navZh: DefaultTheme.NavItem[] = [ { text: '卷五 · 并发编程', link: '/vol5-concurrency/' }, { text: '卷六 · 性能优化', link: '/vol6-performance/' }, { text: '卷七 · 工程实践', link: '/vol7-engineering/' }, + { text: '编译与链接', link: '/compilation/' }, ], }, { @@ -29,7 +31,6 @@ export const navZh: DefaultTheme.NavItem[] = [ { text: '卷八 · 领域应用', link: '/vol8-domains/' }, { text: '卷九 · 开源项目学习', link: '/vol9-open-source-project-learn/' }, { text: '卷十 · 课程与演讲笔记', link: '/vol10-open-lecture-notes/' }, - { text: '编译与链接', link: '/compilation/' }, { text: '实战项目', link: '/projects/' }, ], }, @@ -46,6 +47,7 @@ export const navZh: DefaultTheme.NavItem[] = [ ] export const navEn: DefaultTheme.NavItem[] = [ + { text: 'Getting Started', link: '/en/getting-started/' }, { text: 'Fundamentals', items: [ @@ -66,6 +68,7 @@ export const navEn: DefaultTheme.NavItem[] = [ { text: 'Vol.5 Concurrency', link: '/en/vol5-concurrency/' }, { text: 'Vol.6 Performance', link: '/en/vol6-performance/' }, { text: 'Vol.7 Engineering', link: '/en/vol7-engineering/' }, + { text: 'Compilation & Linking', link: '/en/compilation/' }, ], }, { @@ -74,7 +77,6 @@ export const navEn: DefaultTheme.NavItem[] = [ { text: 'Vol.8 Domain Applications', link: '/en/vol8-domains/' }, { text: 'Vol.9 Open Source Projects', link: '/en/vol9-open-source-project-learn/' }, { text: 'Vol.10 Courses & Talks', link: '/en/vol10-open-lecture-notes/' }, - { text: 'Compilation & Linking', link: '/en/compilation/' }, { text: 'Projects', link: '/en/projects/' }, ], }, diff --git a/site/.vitepress/config/sidebar.ts b/site/.vitepress/config/sidebar.ts index 3b3a860bf..08f776f00 100644 --- a/site/.vitepress/config/sidebar.ts +++ b/site/.vitepress/config/sidebar.ts @@ -135,6 +135,7 @@ function enSidebar(): DefaultTheme.Sidebar { export function buildSidebar(): DefaultTheme.Sidebar { const sidebar: DefaultTheme.Sidebar = { + '/getting-started/': volumeSidebar('getting-started', '/getting-started'), '/vol1-fundamentals/': volumeSidebar('vol1-fundamentals', '/vol1-fundamentals'), '/vol2-modern-features/': volumeSidebar('vol2-modern-features', '/vol2-modern-features'), '/vol3-standard-library/': volumeSidebar('vol3-standard-library', '/vol3-standard-library'), diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index 43ac231fa..0a9f42a02 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -228,18 +228,17 @@ html[data-font-size='xxlarge'] { } } -/* Code Blocks */ +/* Code Blocks + 字号统一走 --vp-code-font-size 变量:代码(code)与行号(line-numbers-wrapper) + 都引用它,必须同步——否则两者行盒高度不同,会逐行累积错位(行号越往下越偏离代码行)。 + 用 rem 覆盖 VitePress 默认的 0.875em,避免 em 在本 div 内被 font-size 二次缩放。 */ .vp-doc div[class*='language-'] { border-radius: 8px; font-size: 0.96rem; - /* 代码块 0.75rem→0.86rem≈13.8px,随正文等比放大 */ + --vp-code-font-size: 0.96rem; line-height: 1.6; } -.vp-doc div[class*='language-'] code { - font-size: 0.96rem; -} - .vp-doc :not(pre)>code { font-size: 0.78em; padding: 0.15em 0.35em;