编译链接的应用

Compiler(编译器)

简单陈列下 C/C++ 编译器

GNU GCC

Clang

MSVC

Configure/Build Tools(构建工具)

Makefile

《跟我一起学 Makefile》

根据 Makefile 生成 compile_commands.json

工具:compiledb

使用示例

简单多文件:main.c hello.c fun.c function.h 为例

项目目录结构如下所示

.
|----Makefile
|----main.c
|----hello.c
|----fun.c
|----function.h

Version 1.0

Hello: main.c hello.c fun.c
    gcc -o Hello main.c hello.c fun.c lm

Version 2.0

CC = gcc
TARGET = Hello
OBJ = main.o hello.o fun.o

$(TARGET): $(OBJ)
    $(CC) -o $(TARGET) $(OBJ)
   
  main.o: main.c function.h
      $(CC) -c main.c
      
  hello.o: hello.c function.h
      $(CC) -c hello.c
      
  fun.o: fun.c function.c
      $(CC) -c fun.c

CMake

使用示例

以一个简单的多文件项目为例

演示环境使用 vscode,为了 cmake 的支持,需要安装两个插件:

项目目录结构如下所示

.
├── CMakeLists.txt
├── include
│   └── A.hpp
├── lib
│   └── A.cpp
└── src
    └── main.cpp
// A.hpp
#ifndef A_H
#define A_H

class A {
private:
  int a_;
  int b_;

public:
  A(int a, int b);

  int Add();
};

#endif_ /* A_H */_
// A.cpp
#include "A.hpp"

A::A(int a, int b) : a_(a), b_(b) {
}

int A::Add() {
  return a_ + b_;
}
// main.cpp
#include <iostream>

#include "A.hpp"

int main() {
  int a = 1, b = 2;
  A c{a, b};
  std::cout << "a + b: " << c.Add() << std::endl;
  return 0;
}

我们的目标很简单,将 A.cpp 编译成库文件,在 main.cpp 中链接该库文件,最终生成一个可执行文件。

CMakeLists.txt 如下所示

cmake_minimum_required(VERSION 3.10.0)
project(example VERSION 0.1.0 LANGUAGES C CXX)

add_library(libA lib/A.cpp)
target_include_directories(libA PUBLIC include)

add_executable(example src/main.cpp)
target_link_libraries(example PRIVATE libA)

使用 cmake 进行目标构建时相当直观,add_executable 用于生成可执行文件,add_library 用于生成库文件。

仅以 add_library 做一说明,add_executable 的解读方式是类似,不再赘述。

add_library(<name> [<type>] [EXCLUDE_FROM_ALL] <sources>...)

  • <name> 指待生成的 TARGET 的名称
  • <type> 指定库的类型,静态库/共享库(默认)
  • <sources> 指定源文件

构建命令如下所示

mkdir build && cd build
cmake ..
make
./main

不出意外的话,此时你已经能看到 a + b: 3 的输出信息了。

配合 clangd 提供 Intelligent

clangd 是 C++ 语言的 lsp

clangd 需要 compile_commands.json 来提供 intelligence support。

compile_commands.json 是一种用于描述如何编译单个文件的 JSON 格式文件,广泛应用于 C/C++ 开发环境中。它通常被编辑器、构建工具和静态分析工具用来理解项目的编译选项,以便提供代码补全、语法检查、重构支持等功能。此文件一般由构建系统生成。

使用 CMake 生成 compile_commands.json 的方法:

# 第一种
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# 第二种
# 命令行参数指定 cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1

据其行为表现观测(先叠甲,不一定对),clangd 检测语言所需支持版本的方式仅仅是从里面查找 -std 参数的值。

现有如下两种方式可以影响 compile_commands.json 中的版本:

# 第一种
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD 17)

# 第二种
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

可见性

Examples of when PUBLIC/PRIVATE/INTERFACE should be used in cmake

PRIVATE PUBLIC INTERFACE

大体来说,权限关键词只有当构建目标为库文件时才有实际意义,因为它们的作用是控制库的依赖和头文件的暴露情况。将可执行文件作为依赖的情况几乎是没有的,因此当构建可执行文件时,将权限设置为 PRIVATE 即可。

  • target_include_directories(<target> [PRIVATE | PUBLIC | INTERFACE] <item>)

控制构建目标的所依赖库的头文件的可见性。

  • target_include_libraries(<target> [PRIVATE | PUBLIC | INTERFACE] <item>)

控制构建目标的所依赖库的链接时的可见性。

  1. 若一个库 libA 的内部实现依赖 libB,并且对于 libA 使用者只需要调用 libA本身提供的 API,那么就可以将权限设置为 PRIVATElibA的使用者将接触不到 libB的 API。
  2. 若一个库 libA 的内部实现依赖 libB,并且对于 libA 使用者除调用 libA本身提供的 API 之外还需要调用 libB提供的 API,那么就可以将权限设置为 PUBLIClibA的使用者也能获取到 libB的 API。(常见的一种情况是 libB是一个比较大的库,比如 Qt)

对曾经疑问的回答:

Q: 静态库如果已经把所有目标文件都打包好了,权限是不是没有意义了。

A: 静态库和共享库的区别,要从链接生成可执行文件的角度看。权限控制的仅仅是库的依赖对库的使用者的可见性,无关静态库或共享库。

导入依赖

find_package

find_package(<PackageName> [<version>] [REQUIRED] [COMPONENTS <components>...])

find_package 有两种搜索模式:

Module mode

这是 CMake 默认的方式,它会查找 <PackageName>Config.cmake 或者 Find<PackageName>.cmake 文件。

Config mode

一些库提供自己的 CMake 配置文件(通常是 <lowercaseName>Config.cmake 或者 <PackageName>Targets.cmake),这些文件通常与库一起分发,并且当你安装这个库的时候也会被安装到特定位置。在这种情况下,你可以直接使用 find_package(<PackageName>),其中 <PackageName> 是库的名称。

FAQ

在导入 bupt_can 队库的过程中遇到 find_package 找不到对应库文件的问题,尽管已经加上了 REQUIRED 参数 cmake 也没有报错。这个帖子遇到了和我类似的问题,所以这并非 cmake 的问题,而是我使用的问题。

现代的 CMake 以 Target 这一概念为核心进行可执行文件以及库文件的构建,队库中 CMakeListst.txt 对库文件的安装部分,仅仅导出了 target,所以当使用老的方式进行导入时会出错。

一个典型的老式 CMakeListst.txt 案例:

cmake_minimum_required(VERSION 3.8)
project(motion_controller)

set(EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c99")

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(rclcpp REQUIRED)

add_executable(turtle_painter src/turtle_painter.cpp)

target_include_directories(turtle_painter PRIVATE ${rclcpp_INCLUDE_DIRS}
                                                  ${geometry_msgs_INCLUDE_DIRS})

target_link_libraries(turtle_painter ${rclcpp_LIBRARIES}
                      ${geometry_msgs_LIBRARIES})

Macros

  • CMAKE_SOURCE_DIR
  • CMAKE_BINARY_DIR
  • CMAKE_CXX_EXTENSIONS

该变量告诉 CMake 采用更加通用的编译参数,比如这个开关打开,传递给 GCC 的参数就会是 -std=c++14 而不是 -std=gnu++14。

CMakePresets

ROS 构建工具

由于 ROS 中使用的 catkin_make 和 ament_cmake 本质上都是 cmake 套壳,由是记录于此。

ROS1

catkin make 是 ROS1 中对 CMake 构建做的一层 wrapper catkin_tools 是更为推荐使用的工具。

正确姿势

以下是目录结构示例:

./your_workspace
├── build
├── devel
├── logs
└── src

./src
├── insert_cc.sh
├── rm_control
│   ├── Doxyfile
│   ├── LICENSE
│   ├── README.md
│   ├── rm_common
│   ├── rm_control
│   ├── rm_dbus
│   ├── rm_gazebo
│   ├── rm_hw
│   ├── rm_msgs
│   ├── rm_referee
│   └── rm_vt
└── rm_controllers
    ├── Doxyfile
    ├── LICENSE
    ├── README.md
    ├── gpio_controller
    ├── mimic_joint_controller
    ├── rm_calibration_controllers
    ├── rm_chassis_controllers
    ├── rm_controllers
    ├── rm_gimbal_controllers
    ├── rm_orientation_controller
    ├── rm_shooter_controllers
    ├── robot_state_controller
    └── tof_radar_controller

在 workspace 目录下先安装依赖:

rosdep install --from-paths . --ignore-src

进行编译构建:

catkin build

接下来请在 src 目录下创建一个脚本,它的作用是为 build 目录下各个 package 的 compile_commands.json 创建软链接置于 src 目录下各个 package 中。如此一来,vscode + clangd lsp 就能为项目正确地提供智能提示了。

GPT 秘制小脚本内容如下:

#!/bin/bash

# 设置工作空间路径为当前目录的上一级
WORKSPACE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$WORKSPACE_ROOT/build"
SRC_DIR="$WORKSPACE_ROOT/src"

echo "Searching for compile_commands.json files in: $BUILD_DIR"

# 遍历 build 目录下所有 compile_commands.json
find "$BUILD_DIR" -mindepth 2 -maxdepth 2 -name "compile_commands.json" | while read -r json_path; do
    package_name=$(basename "$(dirname "$json_path")")

    echo "Looking for package: $package_name"

    # 在 src 目录下递归查找同名 package 文件夹
    match_path=$(find "$SRC_DIR" -type d -name "$package_name" | head -n 1)

    if [ -n "$match_path" ]; then
        # 目标链接路径
        link_path="$match_path/compile_commands.json"

        # 如果已有链接或文件,删除
        if [ -L "$link_path" ] || [ -f "$link_path" ]; then
            rm -f "$link_path"
        fi

        # 创建软链接
        ln -s "$json_path" "$link_path"
        echo "Linked: $json_path$link_path"
    else
        echo "Could not find $package_name in src/, skipping."
    fi
done
FAQ

使用 catkin_tools 自动生成 compile_commands.json 时的问题

ROS2

ament_cmake 是对原生 cmake 在 ROS2 构建中做的拓展 参考链接

库的安装

以 rclcpp 的 install 为例

install(
  TARGETS ${PROJECT_NAME} EXPORT ${PROJECT_NAME}
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
)

# Export old-style CMake variables
ament_export_include_directories("include/${PROJECT_NAME}")
ament_export_libraries(${PROJECT_NAME})

# Export modern CMake targets
ament_export_targets(${PROJECT_NAME})

ament_export_dependencies(
  builtin_interfaces
  libstatistics_collector
  rcl
  rcl_interfaces
  rcl_yaml_param_parser
  rcpputils
  rcutils
  rmw
  rosgraph_msgs
  rosidl_dynamic_typesupport
  rosidl_runtime_c
  rosidl_runtime_cpp
  rosidl_typesupport_cpp
  statistics_msgs
  tracetools
)
msg, srv

msg 和 srv 是两种分别描述了 ROS2 topic 和 service 信息格式的描述文件,ros2 提供了根据描述文件生成对应 C++ 代码的功能,这使得我们能够便利地在 ROS2 中自定义消息格式。

在过去,由于在编译时遇到了“生成的 srv 不能够同时被导入”的问题,我选择沿袭祖传代码的做法,专门新建一个功能包仅用于提供 msg/srv。每次新添加 msg/srv 后,都需要先单独使用 colcon build --packages-select bupt_interfaces 先构建它,再使用 colcon build 编译其他包。

现在我忍无可忍

不妨参照 ROS2 官方的实例进行模仿,我选取的参照对象是 turtlesim

只需要在该 package 中依赖该 msg/srv 的 target 后加上下面这句,就能正确配置。

rosidl_target_interfaces(turtlesim_node ${PROJECT_NAME} "rosidl_typesupport_cpp")

这指示我们,为了更熟悉 ROS2 的构建,需要挖掘 ament_cmake 在背后做的事,也正好帮助学习 CMake。

ROS 项目中包含非 ROS 包依赖

cmake_minimum_required(VERSION 3.15)
project(mi_motor)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# find dependencies_
find_package(ament_cmake REQUIRED)
# uncomment the following section in order to fill in further dependencies_
# manually. find_package(<dependency> REQUIRED)_
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
find_package(bupt_can REQUIRED)

add_executable(motor_node src/motor_node.cpp)
target_include_directories(motor_node PRIVATE include)
target_link_libraries(motor_node PUBLIC bupt_can)

ament_target_dependencies(motor_node PUBLIC rclcpp std_msgs)

ament_package()

分别用原生的 target_link_librariesament_target_dependencies 即可,但是要保证它们有一致的关键字签名,因为 ament_target_dependencies() 不打算支持 PRIAVTE,因此我们使用 PUBLIC

PkgConfig

这里以导入 glib 库为例

Pkgconfig 实际上也是将写有库配置信息的 pc 文件安装到相应目录,才能在查找时进行导入。

cmake_minimum_required(VERSION 3.22.1)
project(DNS-Relay VERSION 0.1.0 LANGUAGES C)

find_package(PkgConfig REQUIRED)
pkg_check_modules(GLIB REQUIRED glib-2.0)

add_executable(DNS-Relay src/main.c)
target_include_directories(DNS-Relay PRIVATE ${GLIB_INCLUDE_DIRS})
target_link_libraries(DNS-Relay ${GLIB_LIBRARIES})

xmake

配置智能提示

官方教程 仅以 vscode + clangd 为例

首先安装必要的插件

创建控制台项目

xmake create -l c -t console ./test
cd ./test
code .

配置 clangd 对 compile_commands.json 的搜索目录

这一步的必要性在于,clangd 插件对编译数据库的默认搜索目录为 buildworkspace 根目录,因此在使用 CMake 时可以无感提供智能提示。然而 xmake 插件会在 xmake.lua 配置文件保存时自动生成 compile_commands.json.vscode 目录下,这导致了配置的差异。

# 创建 .clangd 配置文件
touch .clangd
# 在 .clangd 加入以下内容
CompileFlags:
  CompilationDatabase: .vscode/

https://github.com/clangd/coc-clangd/issues/182#issuecomment-771543697

build, run, debug 支持

xmake 插件为构建、运行、调试提供了全面的 GUI 按键支持

使用示例

vcpkg

头文件支持

由于头文件并不直接参与目标构建,似乎没有办法为头文件添加依赖使其得到 clangd 智能提示。幸运的是,clangd 的解析具有传染性,尽管没有”正规“的方法为其添加依赖,但是可以通过”跳转“的方式使头文件被缓存。具体来说,可以先在某一个源代码文件中 include 该头文件,然后 ctrl+click 跳转到该头文件中,clangd 就会自动支持该头文件的智能提示。

Compilation(编译)

PL(Programming Language)

Concept

Translation Unit(翻译单元)

翻译单元和链接 (C++) https://en.wikipedia.org/wiki/Translation_unit_(programming)

外部链接和内部链接描述的是对象(不是 OOP 中的对象)在翻译单元间的可见性。

Linking

链接的详细规范请参照 System V ABI

静态库与共享库的一大区别是静态库仅仅将目标文件进行打包,加上一些索引,并未进行实际的链接过程。

Skills

C/C++ 混合编译

在 C++ 中,为了支持函数重载(Function Overloading),编译器会对函数名进行修饰(Name Mangling),以便在链接阶段区分同名但参数不同的函数。

extern "C" 的作用是告诉 C++ 编译器,将指定的代码按照 C 语言的规则进行编译,避免名称修饰,从而实现与 C 语言代码的兼容。

需要在 C 语言的声明(函数)处进行修饰

#ifdef __cplusplus
extern "C" {
#endif

void crcInit(void);
crc crcSlow(unsigned char const message[], int nBytes);
crc crcFast(unsigned char const message[], int nBytes);

#ifdef __cplusplus
}
#endif