VS Code配置自定义C++调试

方法是在launch.json中新增自己的调试,并在tasks.json中添加自定义的编译步骤。

自定义调试

tasks.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"version": "0.2.0",
"configurations": [
{
"name": "My Custom Debug",
"type": "cppvsdbg",
"request": "launch",
"program": "exe的路径",
"args": ["参数1", "参数2"],
"stopAtEntry": false,
"cwd": "你需要的cwd",
"environment": [],
"externalConsole": false,
"preLaunchTask": "Build My Custom Debug"
}
]
}

通过修改各个属性的值,可以改变常用的调试选项:

  • name:在调试菜单中展示的名字。
  • type:调试器,MSVC是cppvsdbg,gdb是cppdbg
  • program:启动的程序。
  • args:命令行参数。
  • cwd:相对地址的参考点。
  • preLaunchTask:编译的命令,需要在tasks.json中说明。不需要编译可以删除此项。

自定义编译

tasks.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": "2.0.0",
"tasks": [
{
"label": "Build My Custom Debug",
"type": "shell",
"command": "编译命令",
"options": {
"cwd": "",
}
}
]
}

另外在options下还可以配置envshell。参考Custom Tasks的文档

现代CMake入门

代码仓库:melonedo/cmake-lecture
讲义:现代CMake入门

C项目

由于庞大的历史遗留问题,C(或C++)项目的编译是老大难问题。吸取了C的教训,如go、rust一类新的语言都会尽可能地把编译程序的方法标准化。然而C的标准仅仅确定了本身的语言,对于代码如何编译为二进制文件、不同的二进制文件间如何复用等,只能借由C的霸主地位,成为操作系统/编译器的“基础知识”。

通常一个C项目包括以下几个部分:

  • 头文件.h:头文件是项目内部或者项目外部调用API的接口说明。
  • 源文件.c:源文件是项目接口的实现方法,二进制文件的来源。
  • 依赖:项目需要依赖的外部库,通常包括头文件和源文件。
  • 编译指令:这个项目需要的头文件的路径、源文件的路径、依赖的路径以及编译需要的特殊要求,都需要在编译指令中给出。

C项目生成的结果无非两种:可执行文件和库。除非是临时调试,可执行文件和库最终都将会被打包分发到最终用户的电脑中。对于可执行文件来说,代码是无关紧要的,我们只要保证拥有二进制文件即可,也就是需要分发可执行文件本身及依赖(或者给出安装依赖的方法)。而库的最终用户也还是其他的码农,因此需要包括对应的头文件及编译指令,也就是需要分发公共头文件、二进制文件、依赖和编译指令。

源文件通常不直接分发,而是编译为静态或动态链接库再分发。对于使用者,这两种库的区别主要是静态链接库需要的部分会被复制到目标文件中,而动态链接库需要把库本身随着目标一同分发。

管理C项目

管理这样一个C项目的方法也是多如牛毛。使用Windows的项目中经常会使用Visual Studio的项目进行管理,而*nix上的项目很多使用autoconf、make等工具管理,而各种IDE也都有自己的项目格式。由于上述的混乱局面,新世纪出现了CMake、meson、ninja、xmake等一系列工具用于为上述复杂的项目格式再提供一个抽象层,目前推广最成功的是CMake。

CMake并不直接调用编译器,而只是提供一个描述C项目的DSL。根据一个项目编译要求的描述,CMake调用Generator生成一个可供VS、make、Xcode等编译管理工具使用的项目,再用编译。接受CMake仅作为一个抽象层这一点对于理解CMake是非常关键的,虽然CMake有一个图灵完备的DSL,但是这个DSL仅仅是生成项目,而不能直接在项目中执行。

CMake基本语法

字符串

和常见shell类似,cmake中基本的数据类型只有字符串。字符串总是作为函数的参数,有三种表示方法,分别是无引号参数"引号参数"[[方括号参数]],各自有不同的语法,不细说。和shell类似但和C等常见的语言不同,name仅仅代表一个字符串,对应"name"或者[[name]],而不是一个变量的引用。

变量环境变量

变量和环境变量(曾)是CMake中非常重要的概念,使用set赋值,用"前缀${变量名}后缀"的形式可以把前缀、变量、后缀的内容连接为一个字符串。要注意的是,无引号参数${变量}的形式会把变量当作一个列表,各个成员作为多个参数,即splat。因此最好使用"${变量}"的形式防止一不小心变量被当成列表。

函数

和常见的语言一样,函数和宏是CMake中代码复用的方法。CMake中函数的特点是没有返回值,参数只能接收一大堆字符串,不能搞函数式什么的(直到2020年的3.18版本才有CALL的玩法)。要返回东西,可以用set(${返回的变量名} PARENT_SCOPE)或宏来解决。宏和函数的区别只是宏本身没有作用域,返回直接可以直接set,但中间变量也会泄露到调用函数中。函数调用不区分大小写。

参数格式

CMake中函数做的事情远远比通用编程语言中复杂,参数的格式也非常地繁复。因此CMake提供了一个标准的参数解析器cmake_parse_arguments,即除了前几个按照顺序的参数外,都不需要按照顺序给出,且参数间通过一些关键词分隔,没有特殊的语法。

对象和属性

虽然属性在CMake 3.0开始就已经实装,并成为现代CMake推荐使用的配置方法,很多新的特性也只支持属性,但很多复古的教程中还没有普及属性,因此本文将重点介绍属性。

一个对象的属性使用set_property赋值,并使用get_property获取。CMake中的对象包括了全局(GLOBAL),目录(DIRECTORY),目标(TARGET)等,如指定生成的VS解决方案的默认启动项可用set_property(DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT main)

生成器表达式

由于CMake DSL只能在描述项目时运行,此时一些信息还不能获取,因此可以采用生成器表达式$<...>把一些逻辑推移到最终生成项目时再使用。典型的例子是configuration此时可能是不确定的,并且同一个属性可能会被编译、连接、安装等多个环节运行,这些都需要用生成器表达式的方法区分。

用CMake描述一个C项目

了解CMake DSL显然不足以用于实际,于是让我们在几个简单的示例中体会CMake描述项目的方法。

生成可执行文件

CMake项目是一个包括了CMakeLists.txt的文件夹。首先新建一个文件夹,文件夹中新建CMakeLists.txt作为这个项目的描述,然后再在合适的目录结构中写入对应C代码。可执行文件的目录结构比较随意,我的选择是

1
2
3
4
hello
├──CMakeLists.txt
└──source
└──hello.cpp

hello.cpp的内容是

1
2
3
4
#include <iostream>
int main() {
std::cout << "Hello!" << std::endl;
}

CMakeLists.txt的内容是

1
2
3
cmake_minimum_required(VERSION 3.0)
project(hello LANGUAGES CXX)
add_executable(hello source/hello.cpp)

下面逐行讲解各CMake函数的作用

1
cmake_minimum_required(VERSION 3.0)

和C++一样,CMake保持完全的向后兼容性。完全兼容并不意味着所有的功能不能更新,CMake还是会在每个版本丰富现有的功能。因此我们需要在一个CMakeLists.txt的最开头写上cmake_minimum_required确定使用的版本。

1
project(hello LANGUAGES CXX)

project新建一个CMake项目。项目的名称hello会被用在IDE的项目名。
project的参数中最重要的是LANGUAGES,这个参数决定了启用的语言。可用的语言包括CCXXCUDAFORTRAN等,默认启用CCXX。这里我们只使用c++,因此手动指定了不用C,这样可以减少生成项目用的时间。在利用CMake编译CUDA程序中我介绍了使用CMake管理CUDA项目的方法,即在调用project时启用CUDA语言,然后一切和普通C项目没有区别。

1
add_executable(hello source/hello.cpp)

add_executable添加一个可执行文件,并列出编译需要的源文件。在后续也可以用target_sources补充需要的源文件。

生成项目并编译

至此我们已经说明了编译这个可执行文件的所有要求,可以开始编译了。最古老的编译指令是:

1
2
3
4
mkdir build
cd build
cmake ..
cmake --build . # 也可能直接就开始`make`了

如果CMake版本在3.13以上,则可以使用-B参数避免新建文件夹和cd的麻烦:

1
2
cmake -B build # CMake 3.13
cmake --build build

如果是VS,CMake还提供打开IDE项目的功能,可以使用熟悉的F5大法运行和调试:

1
cmake --open build # CMake 3.11

生成动态链接库

但都用上了这么复杂的管理工具了,还只生成普普通通的可执行文件有什么意思呢,何不试试生成个可以在其他语言中调用的动态链接库!

下面这个项目生成一个库libadd,提供一个函数add可以把两个整数相加。同时还定义了一个可执行文件使用上述的库。如果想把这个库整个打包,可以参考CMake导出及安装包模板

1
2
3
4
5
6
7
8
add
├──CMakeLists.txt
├──include
| └──add.h
└──source
├──add.c
├──test-add.c
└──test-add.py

add.h的内容是

1
2
3
4
5
6
#ifndef ADD
#define ADD
#include "add_export.h"
ADD_EXPORT int add(int a, int b);

#endif /* ADD */

add.c的内容是

1
2
#include "add.h"
int add(int a, int b) { return a + b; }

test-add.c的内容是

1
2
3
4
5
6
7
#include "add.h"
#include "stdio.h"

int main() {
printf("%d\n", add(1, 2));
return 0;
}

CMakeLists.txt的内容是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cmake_minimum_required(VERSION 3.0)
project(add LANGUAGES C)

# 动态链接库本身
add_library(add SHARED)
target_sources(add PRIVATE source/add.c)
target_include_directories(add PUBLIC include)

# 处理DLL_EXPORT
include(GenerateExportHeader)
generate_export_header(add)
target_include_directories(add PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

# 使用动态链接库
add_executable(test-add source/test-add.c)
target_link_libraries(test-add PRIVATE add)

# CMake的小功能,这么写可以指定VS启动项
set_property(DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT test-add)

说明动态链接库

下面三行指定了动态链接库的名称、包含路径和源文件。add_library(SHARED)已经表面了这个库是动态链接库,不需要再手动地指定fPIC之类的编译选项。

1
2
3
add_library(add SHARED)
target_sources(add PRIVATE source/add.c)
target_include_directories(add PUBLIC include)

处理DLL_EXPORT

Windows中符号默认是不导出到DLL中的,而Linux是默认导出。无论默认是什么,标注清楚什么符号是导出的总是好事。为此我们通常会编写一个XXX_export.h的头文件声明DLL_EXPORT之类的宏,CMake为我们提供了一个简单的方法生成:

1
2
3
4
5
# 这个功能由一个module提供,需要include
include(GenerateExportHeader)
generate_export_header(add)
# 跟上面一样,添加包含路径
target_include_directories(add PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

如果觉得这样太麻烦,可以把上述三行替换成set_target_properties(add PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON),并且头文件中省略ADD_EXPORT。这样会默认导出所有符号。

使用动态链接库

有了CMake的支持,使用动态链接库非常地简单:

1
2
add_executable(test-add source/test-add.c)
target_link_libraries(test-add PRIVATE add)

target_link_libraries并不是一个简单的添加链接的库的命令,实际上在CMake中是用于添加依赖。这里添加的依赖包括了一个C项目的所有部分,即包括了头文件、二进制文件、编译选项。也就是说,target_link_libraries(test-add PRIVATE add)实际上把add的dll文件、头文件包含路径及其他需要编译选项都纳入了test-add的编译要求中。

VS调试运行

1
2
3
cmake -B build
cmake --open build
# 在VS中F5

在python中调用

不妨试试在python中用ctypes来调用这个库。test-add.py的内容是

1
2
3
4
5
6
7
8
from ctypes import CDLL, c_int
# python搜索动态链接库的具体方法和系统有关,但给定完整路径总不会错。
libadd_path = "add.dll的完整路径"
libadd = CDLL(libadd_path)
libadd.add.argtypes = [c_int, c_int]
libadd.add.restype = c_int
print(libadd.add(1,2))
# 输出3

注:使用以下指令在cmake --build build --target run-python时自动填充libadd_path。test-add.py中改为libadd_path = r"${LIBADD_PATH}"

1
2
3
4
5
6
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/configure-python.cmake
"configure_file(${CMAKE_CURRENT_SOURCE_DIR}/source/test-add.py ${CMAKE_CURRENT_BINARY_DIR}/test-add.py)")
add_custom_command(OUTPUT test-add.py VERBATIM
COMMAND ${CMAKE_COMMAND} -D LIBADD_PATH=$<TARGET_FILE:add> -P ${CMAKE_CURRENT_BINARY_DIR}/configure-python.cmake)
find_package(Python3 COMPONENTS Interpreter)
add_custom_target(run-test-add-python COMMAND Python3::Interpreter test-add.py DEPENDS test-add.py)

对象、属性、初始化和继承

上文中使用了好几个target_XXX系列的函数,我留到现在再详细说明。在CMake中,只有属性是最终影响生成的结果的,变量和函数都只能间接地通过影响各种对象的属性来描述项目。参考cmake-properties(7),可以看到CMake有非常大量的属性可供配置,这些属性都对应了大大小小的功能。同样在这个页面可以看到,CMake中具有属性的对象包括全局、目录、目标、源文件等。

初始化

但很显然我们并不会关心大部分的属性,CMake默认提供的值已经足够合理。实际上,给各个属性提供的默认值来自一些变量。比如目标的C_STANDARD属性来自变量CMAKE_C_STANDARD。改变这个变量会改变所有目标要求的C标准。

但要注意上述默认值的填充发生在一个对象被创建时。通常这只和目标(target)有关,而目标的创建发生在add_executable或者add_library调用时。这意味着在创建了可执行文件后,如CMAKE_CXX_FLAGS之类的变量就不会再影响该目标的属性。

除了全局变量外,目录的属性也可以用来初始化目标的属性,这对应一系列以add_开头的函数,但这样同样是比较复杂的全局状态,不如后面的继承好用,因此不推荐使用。

继承

上面使用动态链接库的示例中我们可以看到,一个target_link_libraries就可以把一个库的几乎所有使用的要求都传递给用这个库的目标。这说明实际上CMake具有一定的继承机制。

CMake DSL中,target_XXX系列指定属性的命令,如target_link_options都会要求用户明确这个属性的继承要求,即PRIVATEPUBLIC或者INTERFACE。当一个属性是PRIVATE时,这个属性只对当前的目标生效;当一个属性是INTERFACE时,这个属性只对依赖了这个目标的对象生效;当一个属性是PUBLIC时,这个属性同时对当前对象和依赖了这个目标的对象生效。当使用target_link_libraries指定了一个目标的依赖时,这个依赖所有的INTERFACE或者PUBLIC属性都会自动成为这个目标的依赖,不需要额外的说明。

内部实现:这些函数修改的属性都包括两个版本,一个版本有INTERFACE_前缀,另一个版本没有。前一个版本储存INTERFACE的内容,后一个版本储存PRIVATE的内容,PUBLIC的内容会同时存储在两个版本中。
注:由于XXX_link_libraries用于声明依赖,如果真的要用来声明连接的库的话,使用$<LINK_ONLY:...>来限制。

由于有了继承,当我们想要使用一个库时,只需要用target_link_libraries声明依赖即可,不需要再重复地说明各种编译选项。

不过这个继承关系只限于给定的几个编译选项,如果要复用其他属性的话,可以自定义函数来初始化这些属性。

用继承控制语境

在用继承的方法来编写CMake普及之前,网上已经有大量的代码使用各种属性对应的初始化变量来编写CMake代码了。在CMake中使用全局变量的坏处和在通用编程语言中使用全局变量的影响是一样的,都不利于控制代码的副作用,因此建议把这样的代码改为直接使用属性和继承的方法编写。改变的方法也非常简单,只需要把对变量的修改改为对文件的修改即可。

第三方库

用继承的方式管理依赖实在是比用一大堆变量来得方便,尤其是这个依赖可以跨越项目的界限时。CMake支持将当前项目的目标和变量导出为XXXConfig.cmake,并和生成的二进制文件打包到一起。这些配置文件可以用find_package命令加载。而对于不使用CMake的项目,CMake以FindXXX的形式模拟上述的支持。CMake分发的FindXXX模组可见Find Modules

对于导出项目的方法,查看CMake导出及安装包模板

配置(configure)、导出(export)和编译(build)

CMake支持多种后端,这也意味着CMake需要支持多种后端选择配置(configuration)的方法。实际上需要支持的方法只有两种,在导出时决定要使用什么配置,如make,通过指定CMAKE_BUILD_TYPE变量来选择配置;在导出时导出所有支持的配置,如VS项目,这时候CMake会根据CMAKE_CONFIGURATION_TYPES变量的内容来导出所有的配置。

CMake在根据一个项目生成编译文件前,首先会运行项目的CMake代码,生成各种对象并确定对应的属性,同时也可以生成一些文件。当生成项目时,所有用户编写的CMake项目代码都已经执行完毕,只作为一个整体的数据。但此时要使用的配置还未确定,也就是说,一个CMake项目无法在CMake DSL运行期间知道使用的配置,而只能为所有的配置都描述对应的要求。

CMake在这个问题上的解决方法是多样的。一方面,CMake中很多的变量和属性都有带配置名字的变体,如代表导入的库的名字的属性IMPORTED_LOCATION有一个对应不同配置的版本IMPORTED_LOCATION_;另一方面,CMake专门提供了在导入时可以运行的CMake命令,即生成器表达式$<...>。这两个方法都可以达到在导出时做出运算的目的。

相关阅读

awesome-cmake

An Introduction to Modern CMake

Bottom-up CMake introduction

The Ultimate Guide to Modern CMake

It’s Time To Do CMake Right

CMake导出及安装包模板

代码:melonedo/cmake-lecture/export

生成库

目录结构:

1
2
3
4
5
6
7
8
9
10
eval_add
├──CMakeLists.txt
├──cmake
│ ├──installer.cmake
│ └──EvalAddConfig.cmake
├──include
│ └──eval_add
│ └──eval_add.h
└──source
└──eval_add.cpp

为了保证安装目录/usr/include头文件不会重名,可以给自己的项目单独建一个文件夹。这也反应在了目录结构中,即include下是eval_add,之后才是头文件。

这里简单写一个依赖boost的库,头文件没有包含boost相关,所以Boost::boost也是private。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
### eval_add/CMakeLists.txt ###
cmake_minimum_required(VERSION 3.13)
# 少写个C语言省点配置的时间
project(eval_add VERSION 0.0.1 LANGUAGES CXX)

# 提供CMAKE_INSTALL_INCLUDEDIR
include(GNUInstallDirs)
# 外部依赖
find_package(Boost REQUIRED COMPONENTS regex)

# 定义库,没什么特别的
add_library(eval_add SHARED source/eval_add.cpp)
target_link_libraries(eval_add PRIVATE Boost::dynamic_linking Boost::boost Boost::regex Boost::diagnostic_definitions)
if(MSVC)
target_compile_options(eval_add PRIVATE $<BUILD_INTERFACE:/utf-8>)
endif()

# 需要区分编译和安装,安装后包含路径只是一个简单的include
target_include_directories(eval_add
PUBLIC
$<BUILD_INTERFACE:${eval_add_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)

set_target_properties(eval_add PROPERTIES
CXX_STANDARD 11
WINDOWS_EXPORT_ALL_SYMBOLS ON # 懒得写__declspec
DEBUG_POSTFIX -debug # 添加一个后缀区分不同的configuration
)
# 见下
include(cmake/installer.cmake)

导出库

为了让别人使用自己的库,需要把当前的目标的依赖和各种编译要求导出到文件中,随着二进制文件和头文件一起打包。需要安装的内容包括:

  • 本项目的CMake文件XXXConfig.cmake。
  • 本项目的版本XXXConfigVersion.cmake。
  • 导出的目标的二进制文件。
  • 导出的目标的说明XXXTargets.cmake。实际上会根据configuration再生成一个XXXTargets-Relase/Debug.cmake。
  • 导出的变量写在XXXConfig.cmake。
  • 其他文件,如头文件、版权许可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
### eval_add/cmake/installer.cmake ###
include(GNUInstallDirs)
# 版本号为项目版本,生成版本文件
include(CMakePackageConfigHelpers)
write_basic_package_version_file(EvalAddConfigVersion.cmake
VERSION ${eval_add_VERSION}
COMPATIBILITY SameMajorVersion)
# 安装XXXConfig.cmake(见下)和对应的版本文件
install(FILES
${CMAKE_CURRENT_SOURCE_DIR}/cmake/EvalAddConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/EvalAddConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EvalAdd)
# 安装生成的二进制文件,用默认配置即可
install(TARGETS eval_add EXPORT EvalAddTargets)
# 安装本项目的目标
install(EXPORT EvalAddTargets
FILE EvalAddTargets.cmake
NAMESPACE EvalAdd:: # 命名空间
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EvalAdd)
# 安装头文件
install(DIRECTORY include/eval_add TYPE INCLUDE)

其中安装头文件的地方也可以通过设置目标的PUBLIC_HEADER属性实现,不过看起来似乎设计上不是通用的。

这个库的Config.cmake需要手写,内容是找到依赖以及包含上面的目标

1
2
3
4
5
### eval_add/cmake/EvalAddConfig.cmake ###
include(CMakeFindDependencyMacro)
# 似乎不需要写REQUIRED
find_dependency(Boost COMPONENTS regex)
include("${CMAKE_CURRENT_LIST_DIR}/EvalAddTargets.cmake")

导出变量

如果要导出变量,为了保证不出现绝对路径,需要使用configure_package_config_file来处理相对路径。首先写模板:

1
2
3
4
5
6
7
### eval_add/cmake/EvalAddConfig.cmake.in ###
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(Boost COMPONENTS regex)
include("${CMAKE_CURRENT_LIST_DIR}/EvalAddTargets.cmake")
set_and_check(EvalAdd_INCLUDE_DIR "@PACKAGE_CMAKE_INSTALL_INCLUDEDIR@")
check_required_components(EvalAdd)

为了把上面@PACKAGE_var@替换成var相对安装目录的路径,installer.cmake中不能直接用install(FILES)安装cmake/EvalAddConfig.cmake,而是需要先配置

1
2
3
4
5
6
configure_package_config_file(cmake/EvalAddConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/EvalAddConfig.cmake # 和下面一样
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EvalAdd # 和下面一样
PATH_VARS CMAKE_INSTALL_INCLUDEDIR)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/EvalAddConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/EvalAdd)

命令

1
2
cmake -B build -DCMAKE_INSTALL_PREFIX=/eval_add/root -DBOOST_ROOT=/boost/root
cmake --build build --target install

使用库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# $<TARGET_RUNTIME_DLLS:target>需要3.21
cmake_minimum_required(VERSION 3.21 REQUIRED)
project(use_eval_add VERSION 0.0.1 LANGUAGES CXX)

# 找到上面的库,注意此时Boost也需要找到,同样要设置BOOST_ROOT等变量
find_package(EvalAdd REQUIRED)

add_executable(main main.cpp)
# 这样就自动处理好了所有的间接依赖、包含路径、编译选项等
target_link_libraries(main EvalAdd::eval_add)

## 接下来是安装可执行文件

# 如果不执行后面的安装,把dll复制到生成目录,方便调试
add_custom_command(TARGET main POST_BUILD COMMAND_EXPAND_LISTS
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_RUNTIME_DLLS:main> $<TARGET_FILE_DIR:main>)

# 提供一个直接运行程序的方法:cmake --build build --target run-main
add_custom_target(run-main main)

# 安装应用
include(GNUInstallDirs)
# 默认就好
install(TARGETS main)
# 不确定怎么做,linux上设置一下RPATH似乎好点
set_target_properties(main PROPERTIES INSTALL_RPATH ${CMAKE_INSTALL_FULL_LIBDIR})
# 同样,可以复制dll到安装目录
install(FILES $<TARGET_RUNTIME_DLLS:main> TYPE BIN)

命令

1
2
3
cmake -B build -DEvalAdd_ROOT=/eval_add/root -DBOOST_ROOT=/boost/root
cmake --build build --target run-main
cmake --build build --target install

参考

Effective CMake: 〔YouTube〕 〔讲义〕

CMake + Conan: 3 Years Later: 〔Youtube〕 〔讲义〕

通过CMake调用Boost

Boost是C++中非常常用的库,Boost的官方文档中没有提到如何使用cmake调用,这里简单说明操作步骤。

从源代码安装Boost

包管理器很多都配备了Boost,但Windows上这并不容易,于是我们介绍从源代码安装的方法。

  1. 首先从官网下载源码并解压到路径path/to/boost_xxx(后面会用到)。
  2. path/to/boost_xxx目录中运行./bootstrap然后运行./b2 --prefix=install_dir install

至此已经安装完毕,其实做第二步主要是生成CMake模组文件。由于CMake对Boost的支持是内置的,最好CMake比对应版本的Boost晚发布,这样使用体验是最好的。如果CMake低于3.5,可以参考这个文章的方法

注:Windows下提供预编译的Boost。这个库的静态库命名似乎有点奇怪,需要手动关掉自动连接。

通过CMake调用Boost

以一个利用Boost::thread的简单程序为例,C++代码

1
2
3
4
5
6
7
8
// test.cpp
#include <boost/thread/thread.hpp>

int main() {
boost::thread t;
t.join(); // do nothing
return 0;
}

参考FindBoost的文档,对应的CMake模板

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.9)
project(boost_test)
find_package(Boost REQUIRED COMPONENTS thread)

add_executable(test test.cpp)
# 用CMake的时候不需要用#comment(lib, "...")自动连接,而且这样有时会自动给静态库加lib前缀,不适配某些版本(如1.74)的Windows下预编译库的名字。
target_link_libraries(test Boost::thread Boost::disable_autolinking Boost::diagnostic_definitions)

CMake生成的时候需要通过BOOST_ROOT变量找到刚才编译Boost的路径path/to/boost_xxx/install_dir(install_dir是前面--prefix=xxx配置的安装路径),如

1
2
3
# -B需要cmake 3.13以上
cmake -D BOOST_ROOT=path/to/boost_xxx/install_dir -B build
cmake --build build

目标名

纯头文件的目标统一是Boost::boost,其他的目标通常就是库的名字。path/to/boost/install_dir/lib/cmake里面没有的目标就是纯头文件库。

关于提示Please define _WIN32_WINNT or _WIN32_WINDOWS

可以在文件最开头加上

1
#include <SDKDDKVer.h>

或者用CMake预编译头的方法强制在每个源的最开头都加上这个文件:

1
2
3
if(MSVC)
target_precompile_headers(test PRIVATE <SDKDDKVer.h>)
endif()

虚继承与虚函数:模型、实现与发展

近日看LLVM的代码,看到了大量在简单的C++代码中不常见的多继承,也看到了不少虚继承,同时为了了解对应的ABI能否直接移植到Julia,不得不深入挖掘了“对象”的模型在C中的实现,故结合之前的一些观察作此文章备忘。

C++的对象模型

需求分析

对象指的是定义了行为,并可以储存行为所需要的数据的一种结构。由于C++是静态的语言,一个对象的所有的行为都在编译后几乎完全确定,仅剩多态相关的函数可能被后续代码修改。表达一类对象的行为和状态的载体是类型,在面向对象的语境中又叫做类(class)。由于我们会定义很多具有类似行为的类,为了复用这些代码,C++中允许一个类A继承另一个类B,使得A的行为可以基于B的行为表达,即表达A的行为时只需要表达A和B不同的部分即可。通常的代码中,这些行为需要引用该对象的一些成员用于存储状态,或者调用其他的成员函数,而C++非模板/宏的语法要求在为某一对象声明行为(函数)时,能够根据此函数声明的类型获取对应成员的信息,最简单的方法是将这些函数所对应的状态也必须在对应的类中一并说明。也就是说,类A继承类B时,必须同时继承类B的数据成员和函数成员,才能保证类B的函数可以使用在类A上。虽然不太常见,C++允许一个类继承多个类,获取这些类所有的行为和状态。这带来的问题是有些状态是定义在对象上,必须严格保持一个对象一组状态,C++的解决方法是使用虚继承,虚基类保证一个对象只有一个,代价是偏移地址只能在运行时读写虚表确定。

针对复杂继承关系,C++的解决方案是使用子对象模型。最终运行时一个对象必须有一个确定的类型,用以分配内存,这个对象称为完整对象。这个对象的类型如果继承了其他的类作为基类,那么这个完整对象对应的内存中的数据的一部分成为基类的子对象。C++要求存在于派生类的完整对象中的子对象和基类自身的完整对象几乎相同,唯一的区别是作为子对象时如果需要完整对象的信息(如RTTI),获取的是真正的完整对象的信息,而不是当前子对象的信息。

多态也是一个常见的要求,即允许相同的代码在传入不同的数据时可以做出不同的行为。如果不同的数据的类型在编译前已知(静态多态),可以通过函数重载(overloading)解决。而如果不同数据的类型需要在运行时才能确定(动态多态),那么唯一的方法是利用对象的虚函数在运行时选择代码,即动态分发(dynamic dispatch)。上述的动态多态仅仅局限于第一个参数,因此可以给每个设计的类型编写动态分发表,记录运行时的类型信息,用于函数调用等,这个表称为虚表(vtable)。

虚继承(virtual inheritance)关系是多态的另一个体现。相比于实继承要求基类的子对象必须位于派生类的子对象内部,虚继承为了保证一个完整对象中只有一个虚基的子对象,允许虚基子对象的位置相对派生类子对象的位置不确定使得虚基类的子对象具体位置不能只根据子对象的类型确定,而需要根据完整对象的类型确定,因此访问虚基类的成员时,同样需要访问虚表确定具体的偏移地址。

下面是这个上述模型中一些比较重要的概念。

子对象模型

子对象是C++实现过程中非常重要的模型,指的是通过取一个完整对象中的不同子对象,可以调用这个子对象的类型的方法;另一方面,这也要求编译器在编译一个对象的代码时,必须要考虑到当前的类可能是完整对象,也可能只是其他完整对象的子对象。特别是虚继承,这要求不假定虚基位于某个位置,而是通过查询虚表来确定。在涉及了复杂继承关系的情况下,这也要求调用虚函数和虚函数本身密切配合,在不同的子对象间不断转换。

this指针

C++,在一个类的成员函数中指示当前操作的(子)对象的方法是隐式传递一个this指针,这个指针的类型和定义当前的类相同。而一个完整对象中会包含复杂的子对象关系,这也反映在this的转换的复杂性上。当不需要涉及虚基时,转换只是对this的加减,而涉及虚基时,还需要读取内存,更加地复杂。从基类子对象转到派生类子对象和从派生类子对象转到子对象都是同理。

如果允许使用RTTI,甚至可以在一个完整对象之中毫不相关的两个子对象之间转换,这时候代价可能是巨大的,比如可能是先找到完整对象后搜索继承树确定目标子对象的位置。

虚表的位置

由于动态分发都局限于单个参数,因此这些的运行时特性都采用虚表来完成。C++的一个设计选择是把虚表存储于对象本身,使得指向复杂对象的指针仍然是简单指针,而对象的复杂特性由对象本身额外存储虚表来实现。由于子对象的复杂性,这意味着一个对象可能会需要多份虚表,保证总是可以查阅虚表动态分发。

Itanium C++ ABI中的实现方法

ABI负责将C++代码中的类翻译为机器可以执行的具体、底层的语义。根据WG21 proposal N4028 Defining a Portable C++ ABI,大部分编译器都采用Itanium ABI(也叫common vendor ABI),MSVC则采用另一种不公开的ABI。为了兼容,Clang可以采用上述两种ABI之一。

术语定义

以下定义都是针对类型为A的完整对象,即某一种特定的继承关系而言的。

  • 基:A继承于B,则B是A的基。视语境,可以简称为B是基。一个完整对象中可以有多个类型是B的子对象,如果需要区分会补充说明。
  • 完整对象:程序最终运行时构造出的对象。
  • 最终类(most derived class):一个完整对象不考虑继承关系时所属的类型。
  • 子对象(subobject):完整对象中类型是基类B的对象也是相对完整地,称为子对象。

基的分类

  • 虚基:C是A或A的基,若C虚继承于B,B是A的虚基。这C与A间具体是哪种继承关系无关。
  • 实基(non-virtual base):基B到A的继承链条中完全没有虚继承。
  • 虚基的实基:如字面意义,基B到A的继承链条中有虚继承,但B本身不直接被虚继承。
  • morally virtual base:虚基和虚基的实基在Itanium ABI中统称morally virtual base,即涉及虚继承的基。我不使用这个说法所以不翻译。
  • 首基(primary base):选做第一个的基,和派生子对象共享首地址(this)和虚表。

实基、虚基、虚基的实基包括了一个最终类的基的所有情况。

虚函数相关

  • 虚函数:行为根据最终类,而不是定义函数的类确定的函数。
  • 声明:在某个基B中声明一个函数是虚函数,也简称B声明虚函数。
  • 重写(override):在某个基B中重新定义了已经声明的虚函数,也简称B重写虚函数。

虚表

Itanium ABI中,虚表指针(也就是this)指向的表格正负偏移处都有数据,从高到低分别是:

  • 0及更大:虚函数入口地址。
  • -1: 完整对象的RTTI信息typeinfo*。比C++标准更严格,Itanium ABI保证此处的typeinfo的指针也是相等的,即实际上可以通过多态类的typeinfo的地址确定对象的类型是否相等。
  • -2: 当前子对象相对完整对象的偏移(offset to top),为正数。用于从子对象找到完整对象。
  • 更小:当前子对象距离各虚基子对象的偏移,一般为负数。
  • 更更小:虚基或虚基的实基调用时需要的子对象和当前子对象的偏移(vcall offset)。

表中除了RTTI信息和相对完整对象的偏移是固定的,其他内容都是数目不定的。Itanium ABI允许子类和首基共享虚表,因此子类新增的虚表内容将离虚表的0更远,保持首基的虚表内容完整。

当一个完整对象的最终类确定时,需要为最终类涉及的所有的基类子对象都编写一份虚表。由于一个最终类可能会有很多的基类,因此虚表的数量可能远多于类的数量。

虚基位置

完整对象的最终类确定时,将在各子对象对应的虚表中记录各虚基的位置。从虚表中加载出虚基的偏移,加上当前子对象的地址(this)就可以得到虚基的地址。要得到虚基B的实基C的地址,还要在这个地址的基础上再加上C相对B的地址的偏移。要注意不到最终连接的时候都不能确定当前的对象是完整对象,不能把当前的子对象当成完整对象来直接用固定的偏移寻址而不经过虚表。

虚函数位置

虚函数调用的规则则显得非常地折磨了。首先需要确定一个最终类中各个子对象的虚表中到底放了什么函数:

  • 这个类中声明为虚方法的虚函数。
  • 这个类中重写的函数。由于Itanium ABI要求调用时this指向重写函数的子对象,故这里重复写一遍有可能避免调整this
  • 虚基的虚表内容比较多,包括了虚基本身声明和重写的所有虚函数,以及虚基的实基声明或重写的所有虚函数。同时,由于this的调整可能需要从虚基子对象跳到其他子对象,还为这里包括的每个函数都记录一个vcall offset,用于动态调整this。虚基的虚基也是最终类的虚基,故相关的内容不需要记录。

注意如果选中了一个类作为首基,则直接派生类虚表将和首基共享,即序号较小的部分是首基的内容,而序号较大,即偏移的绝对值较大的部分是直接派生类的虚表。首基的虚表和派生类的虚表都按照上述规则分别计算内容,只不过一个类重写的函数如果在首基的虚表中,则派生类的虚表中省略此项,只需要保证一个子对象中重写的函数在这个子对象本身的虚表或共享部分的虚表即可。

因此,可以发现同一个虚函数体的入口实际上有多个备份,除了对子对象的调整不同外,这些函数都指向同一个函数体:

  • 声明虚函数的子对象的虚表包括此虚函数。
  • 重写了虚函数的子对象的虚表包括此虚函数,或者在和首基共享的部分包括。
  • 虚基和虚基的实基的所有声明或定义的虚函数都包含在虚基的虚表中。和上面同样,虚基B的虚基C也是完整对象的虚基,虚函数放在C自己的虚表。

虚函数调用流程

假设当前的子对象(不一定是完整对象)静态类型为A,要调用的虚函数在B中声明,最终重写的函数体位于C中,初步考虑调用A的基D中重写的版本。为了分析,首先要确立整个调用过程中的不变量:

  • 跳转到子对象的虚表中的虚函数时,保证this指向这个虚表对应的类型为D的子对象。
  • 执行虚函数体时,保证this指向这个函数体重写时的类型为C的子对象,即和静态调用的方法相同。

保证上述两个不变量需要在调用虚表时不断调整this,同时虚表中的入口不一定直接指向虚函数体,可能先跳转到一个调整this的代码再执行函数体。

转换代码:A->D

保证前一个不变量需要在调用虚函数前将当前的子对象转换为基D的子对象,也就是static_cast<D*>(this)

  • 如果基D是A的实基,将this加上一个固定的偏移即可
  • 如果基D是A的虚基,将this加上一个从虚表中读出的虚基偏移
  • 如果基D是A的虚基V的实基,将this加上一个从虚表中读出的虚基偏移转换为V的子对象,再加上一个固定的偏移从V的子对象转换为D的子对象

调整代码:D->C

保证后一个不变量需要在虚函数的入口处加入一段调整this的程序,再进入虚函数的本体。

  • D就是C:直接进入虚函数本体。
  • D是C的实基:先将this减去一个固定的偏移再进入。
  • D是C的虚基:this加上vcall offset再进入。
  • D是C的虚基V的实基:this首先减去一个固定偏移转换为V子对象,再加上V子对象的虚表中的vcall offset。

PS:这段代码称作thunk,也一样有mangled name

本质上vcall offset并不是必须的,实际上可以在连接时那每种情况的偏移地址都硬编码到调整代码中,但这样多段机器码通常比一个整数要长,是用大量指令缓存负担换少量的数据缓存负担,不太划算。

D的选择

根据上述的观察,任何一个重写了f的子对象都可以用来调用f,具体的选择只有运行时效率的区别。如果选择的D在连接后的确是最终运行的D,或者D不需要调整this,那么调用时都不会额外地产生太多的负担,而如果选错了,可能会多次地调整this,造成很大开销。

重写子对象虚表也包含虚函数带来的优化

至此可以解释前面为何重写了虚函数的虚表中要包含对应的虚函数。如果虚函数只列在声明虚函数的基子对象的虚表中,那么当前子对象就是最终重写了虚函数f的子对象,而声明f的实基不是首基,在调用前需要首先加上偏移调整this保证第一个不变量,而调用后又要把this减去同样的偏移,又回到了最初的this

这上面的分析都只针对对象完整建立后的行为,C++的对象未构造完时也可以使用虚函数,但此时虚函数重写的结果可能和完整对象的结果不同,又需要额外的复杂度,有兴趣直接参考原文。

MSVC中实现方法

虽然MSVC没有公开他的ABI,但通过未公开的编译指令/d1reportAllClassLayout/d1reportSingleClassLayoutXXX以及反汇编可以判断出使用的ABI。作为比较,这里也简单说明。

OpenRCE has some material on this: Reversing Microsoft Visual C++ Part II: Classes, Methods and RTTI

虚表

虚表中的内容肯定大差不差,主要的区别是MSVC中虚基偏移单独有一个指针vbptr,和虚函数(应该还包括RTTI等其他信息)的虚表指针vfptr不共用。同时,MSVC由于虚函数调用的不变量不同,不做重写子对象虚表也包含虚函数带来的优化,因此虚函数只位于声明了这个函数的子对象的虚表中。

虚函数调用

仍假设调用子对象A中调用B中声明的虚函数f,f最终在C中重写。f只存在B的虚表中,因此不存在选择重写对象D的可能。MSVC在虚函数调用过程中的不变量和Itanium ABI不同:

  • 跳转虚函数时,保证this指向声明f的基类B的子对象。
  • 执行虚函数体时,也保证this指向基类B的子对象。

第二个不变量在C是A的实基时很正常,由B的子对象可以算出C的子对象的位置,但如果C是A的虚基或虚基的实基,那么保证this指向一个虚基的子对象是不实用的,因此令this指向的位置是C类完整子对象中对应B的子对象的位置,即指向C子对象再加上一个固定偏移。

将A子对象转换到B子对象的过程是相似的。而从B子对象转换到C子对象时,MSVC中不使用vcall offset,所有的情况都直接硬编码一个偏移地址用于协调两个不变量。

其他语言的动态分发实现

JIT优化

Java的OOP模型和C++类似,但不允许多继承普通的类,只能多继承不能携带数据的接口(interface)。根据,在HotSpot虚拟机中,当使用来自基类的虚函数(VirtualCall)时,查虚表的方法和C++类似,即搜索_klass成员以获得类的实例,再从这个实例中搜索虚函数。而当使用来自接口的虚函数(InterfaceCall)时,虚拟机会根据_klass搜索整个继承树,找到这个接口的实例,再从接口实例中的itable找到函数。

但HotSpot是一个JIT虚拟机,可以在运行期改变代码,因此可以利用一些启发式来简化上述流程。根据调用一个虚函数的代码通常可以直接根据调用的位置推断出被调用的函数,即很多代码并不是需要动态分发,虚函数仅仅是提供接口留待后续补充,可以把虚函数调用替换为一个类型检查+静态调用,即:obj.f(...)可以根据连接时的最终类简化为

1
2
assert typeof(obj) == SomeType
obj.SomeType::f(...)

当断言失败后并不引起程序错误,而是修改代码,完整地调用虚函数。

字典型虚表

Python, javascript这些经典的动态语言中对这些OOP的实现更加地简单,即每个类都必须携带一个字典作为obj.attr时的虚表。由于这些语言没有明确的编译和运行的区别,除了一些内置的方法,虚表不能简单地用整数作为索引,而只能用字符串做索引。

由于数据和方法都只能存在字典中,因此不存在用整数索引时需要分配连续空间作为某类专用的问题,故这些语言中不存在this调整的问题,不同的类别间转换不需要任何的操作。根据一个成员的名称就可以确定成员,说明这些类中默认的继承方法是C++中少见的虚继承,所有的数据成员都是一个对象只有一个。python原生支持多继承,javascript虽然没有原生支持,但同样可以通过重写“成员”的概念来间接实现多继承。当然这些语言不要求静态确定某个方法或成员存在,即使不使用多继承,也可以通过直接在对象中定义相应成员来实现所需要的功能。

PS:字典本身的方法是如何实现的?这里没有鸡生蛋还是蛋生鸡的矛盾,CPython的一部分比较基本的属性固定存在,用偏移直接寻址。CPython中,一个Python对象PyObject开头总包含它的类型PyTypeObject(以及引用计数),这个类型中很多基本的方法直接定义在结构体中,如__dict____add__

虚表不放在对象里

Go和rust的解决方法是类似的,同样是编制虚表进行动态分发,这两个语言并不把虚表放在对象本身,而是定义了新的“动态”类型,指向动态类型的指针是指向类型信息(包括了虚表)和对象的两个指针。对于普通的数据类型,这些语言中和C++一样,都可以定义若干个静态的方法,但调用这些方法要求程序静态地确定类型,而不能动态分发,即数据本身是不携带类型信息的。而要支持动态分发需要将普通的类型转换为支持动态分发的类型,即额外地带上类型信息。

Go中支持动态分发的类型称为接口(interface)。一个接口的定义包括了若干个成员函数,当一个类型定义了这些成员函数时,这个类型自动满足接口的要求,可以转换为对应接口。从静态类型已知的数据转换到接口类型是发生在运行时根据类型计算并缓存的,这个过程实际上只是简单地根据接口的成员函数,编制对应的RTTI,并填入该接口的各个成员函数的具体实现。

Rust中支持动态分发的类型称为trait object。和go不同,rust中一个类型能够转换为的trait类型需要在类型声明时明确列出。允许转换时,trait object同样是由类型信息和对象本身的指针构成。

把虚函数限制在特定类型中,使得虚函数调用仅仅在用户显式使用动态分发时才启用,而虚函数内部本身对于使用的对象的最终类是清楚的,这使得函数可以很轻松地去虚拟化(devirtualize)。

更复杂的虚表

与以上所有的语言不同,Julia中动态分发是多分发(multiple dispatch),即动态分发时不仅第一个参数,所有的参数都参与动态分发。C++中静态分发也同样是需要考虑所有的参数的多分发,但动态分发时需要使用非常局限的虚函数方法。多分发使得搜索合适的方法非常地复杂,函数分发表不能简单地编成虚表,因此julia通常不会使用动态分发,而是尽可能地静态确定类型后使用静态分发。当无法确定静态类型时,类型会和对象一同存储,在调用函数时搜索函数的虚表确定所使用的方法。由于函数的参数包含了复杂的类型约束,julia会缓存根据约束算出的结果。

Julia Interpolation Cookbook

Interpolation is essential to metaprogramming in Julia, this post covers some of the tricky cases when interpolating exressions that I encounter.

Official manual

It is expected that you read the Program representation and the Expressions and evaluation sections in the official manual about metaprogramming before reading the content below. Also, the article about Julia ASTs in the manual is a good reference when you compose Expr objects by hand.

How to do splatting interpolation?

In short, use $(exprs...). But this isn’t really that easy. You must make sure splatting is interpolated in the right context, since the interpolated expressions are the same in every context.

For example, suppose exprs = (:a, :b, :c), :($(exprs...)) will result in syntax error because Julia does not understand what you want. Below is some common use cases:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
julia> exprs = (:a, :b, :c)
(:a, :b, :c)

julia> :($(exprs...);) # list of expressions
quote
a
b
c
end

julia> :($(exprs...),) # tuple
:((a, b, c))

julia> :(; $(exprs...)) # named tuple. (;a,b,c) is short for (a=a,b=b,c=c)
:((; a, b, c))

julia> :[$(exprs...)] # vector
:([a, b, c])

julia> :[$(exprs...);] # also vector
:([a; b; c])

julia> :[$(exprs...);;] # row vector (matrix)
:([a;; b;; c])

Note currently keyword arguments (and named tuple) can not be constructed with interpolation unless you use the short form above, so to construct a general keyword argument, you must use the Expr way:

1
2
3
4
5
6
7
julia> f = :(foo(1,2))
:(foo(1, 2))

julia> insert!(f.args, 2, Expr(:parameters, Expr(:kw, :a, 3)));

julia> f
:(foo(1, 2; a = 3))

How to include a literal Symbol in quotes?

1
2
3
4
5
julia> :($(Meta.quot(:a)) + b)
:(:a + b)
# or
julia> :($(QuoteNode(:a))+b)
:(:a + b)

They do result in different ASTs, but the difference is not significant for a single symbol.

How to include a $ in quotes?

The “uninterpolated” quote $a as in :($a + b) is valid input for macros, but is not documented. Let’s examine what it is first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
julia> a=1
1

# does not work
julia> :($a)
1

julia> macro dump1(ex) ex end
@dump1 (macro with 1 method)

# because `$a` is not valid expression alone
julia> @dump1 $a
ERROR: syntax: "$" expression outside quote around REPL[94]:1
Stacktrace:
[...]

julia> macro dump2(ex) dump(ex) end
@dump2 (macro with 1 method)

julia> @dump2 $a
Expr
head: Symbol $
args: Array{Any}((1,))
1: Symbol a

So we see that $a is Expr(:$, :a).

BFG批量修改git历史

git使用时经常会不慎提交一下不该提交的东西,比如把build文件夹整个提交了,或者把密码提交了。此时可以使用工具批量修改历史,再git push -f更改历史记录。

根据GitHub文档的建议,我们可以使用BFG来清理,如

1
2
3
4
cp repo-to-clean repo-to-clean2
cd repo-to-clean2
bfg --strip-blobs-bigger-than 100M --replace-text patterns.txt --delete-files YOUR-FILE-WITH-SENSITIVE-DATA
git push -f

中文

BFG并不直接支持utf,而是支持根据文件的头几个字母判断编码,需要设置变量file.encoding=UTF8提示编码。方法包括

  • 设置环境变量JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF8
  • 运行时使用java -D"file.encoding=UTF8" -jar bfg ...参数...直接定义变量

C、Make、CMake

本文已摆烂,请见现代CMake入门

CMake是一个C语言的项目管理器,用于简便地指明一个C项目构建(build)应用/库的方法,并生成编译脚本(如Makefile)或IDE项目文件(vs解决方案sln),用于最终编译C文件。
相比于其他的的项目管理器,CMake的优点是:

  • 懂C。相比于通用的make,CMake的项目组织方法和C的特性息息相关,并且内置了众多常见的编译选项,可以避免记忆常见的命令参数在不同环境下的组合。
  • 通用。一方面大量项目使用CMake管理,CMake众多C工具中的最大公约数,可以用于生成VS的项目,也可以支持VSCode,也可以在*nix环境中适应gcc或clang。同时新的工具如ninja也都会适配CMake生成器。
  • 可以导入其他项目。一般的cpp项目管理器都不支持导入现成的项目,要手动配置编译参数。虽然支持的项目没那么多,但CMake仍是自动配置参数的唯一希望。

C项目

要理解CMake,首先要知道C项目指的是什么。C项目包括源代码(源文件.c/.cpp和头文件.h)以及对应的编译选项,生成若干个目标(target)。目标指的是可直接执行的应用(executable),或可以被用于构建新目标的(library)。

  • 头文件:即.h.hpp,是代码中不编译而直接共享的部分,包含源文件的接口,以及不编译为机器码的内联函数等。
  • 源文件:即.c.cpp,包含可以被编译为机器码的各种函数,在编译后只作为参考,通常不共享。
  • 目标(二进制文件):由源文件编译出的机器码,可以直接执行,也可以连接到其他的目标中。
  • 编译选项:编译过程中各种各样的编译要求,有时只对当前的库的编译产生影响,有时存在以来,比如如果外部库中不启用RTTI,那么使用的库也不能使用RTTI,这是用编译选项控制的。

如果只要把代码编译成一个应用,那么最终分发的通常仅仅是一个可执行文件,如果有特殊的动态库的以来,还需要保证用户拥有正确的动态库。但如果要把代码编译成一个库,则需要分发的内容不只包括运行时需要的动态库,还包括了这个库的头文件、库文件和需要的编译选项,这是C库分发的比较困难的地方,也是CMake建立抽象层的主要根据。

直接输入命令

下面我们以一个简单的项目为例,分别说明编译C代码的指令、用Make生成的方法、以及用CMake生成的方法。

这个项目需要生成一个动态库libhello,其中函数hello()会在标准输出流打印”Hello”,然后编写一个C应用调用这个动态库。代码如下:

1
2
3
4
5
6
7
8
9
10
// inc/hello.h
void hello(void);

// src/hello.c
#include <stdio.h>
void hello(void) { puts("Hello"); }

// main.c
#include "hello.h"
int main() { hello(); }

在*nix环境中,我们可以使用gcc生成并调用动态库:

1
2
3
4
mkdir -p build
gcc -c -o build/hello.o -fpic -I inc src/hello.c
gcc -shared -o build/libhello.so build/hello.o
gcc -o build/main -I inc main.c -L build -l hello

在Windows中同样可以调用MSVC编译,但指令不尽相同。

Make

直接用命令编译的局限性很明显:更改编译指令必须找到对应的命令然后逐个更改,这样的代码重复没有必要。为此,我们可以把上述的各种选项都用变量的形式书写。同时,直接用命令时没有额外的软件分析文件间的依赖,在文件改动后要么重新编译整个项目,要么 手动找出需要重新编译的所有依赖,非常麻烦。这个问题的通常解决方法是使用Make脚本Makefile。

make系统是一个以文件为主体的编译管理系统,可以根据指定的规则,在依赖改变时,逐步地生成中间产品和最终产品。比如上面的例子可以使用Makefile来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CC ::= gcc
CPPFLAGS += -I inc
BUILD_DIR ?= build
LDFLAGS += -L $(BUILD_DIR)
LDLIBS += -l hello

all: $(BUILD_DIR)/main

$(BUILD_DIR)/main: $(BUILD_DIR)/main.o $(BUILD_DIR)/libhello.so

$(BUILD_DIR)/main.o: main.c inc/hello.h
$(CC) -c $(CPPFLAGS) $(CFLAGS) -o $@ $<

$(BUILD_DIR)/hello.o: src/hello.c inc/hello.h
$(CC) -c $(CPPFLAGS) $(CFLAGS) -fpic -o $@ $<

$(BUILD_DIR)/libhello.so: $(BUILD_DIR)/hello.o
$(CC) -shared -o $@ $^

Make的主体就是定义一系列的变量,以及定义一系列的生成规则生成各个文件。变量可以用$(variable)$x的形式引用,后一种形式只能用于单字符的变量名。另外make还提供一些特殊的自动变量,这些变量的值由规则确定,如$@表示产物,@<表示第一个依赖,@^表示所有的依赖。为了省略括号,这些变量都是单个字符,因此只能挑选符号。规则的形式是产物: 依赖1 依赖2 ...,后面一行以制表符\t开头,说明根据依赖产生产物运行的指令。产物和依赖都可以通配符选择多个文件。Make的使用时需要指定一个产物,将在产物不存在或者依赖更新后自动地生成产物,且自动地处理间接依赖,即规划运行顺序和分析依赖。同时,通过修改变量,可以方便地修改一整组文件的编译命令。

但make并不内置语言相关的知识,仅仅是针对常见的后缀名定义了一系列内置产生式,如由.cpp产生.o的规则为$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c,这个规则利用了变量,方便调节。

CMake

CMake则是一套专为C设计的产生系统,将根据用户提供以及程序内置的知识,生成编译工具可以使用的项目文件,从而用一套代码管理多组编译工具。

运行框架

虽然CMake是一个强大的C语言命令库,但是CMake的前端的设计是比较复杂的。CMake中一个项目用一个包含了CMakeLists.txt的文件夹代表。当配置这个项目时,CMake将根据项目的代码和用户环境要求,用描述式的方法确定每个目标生成的要求,这些要求用CMake中的目标(target)的属性(property),然后再把这些要求导出(export)到一个使用于某个编译工具的项目(以下称为编译项目)中。除了对象和目录的属性,这个过程中还涉及了来自用户输入、系统环境、中间状态的各种变量,这些变量包括环境变量(environment variable),普通变量(normal variable),缓存变量(cache variable)。

CMake脚本需要结合项目要求和用户环境,最终为各个目标配置合适的属性。为目标配置了属性后,这些属性将被导出到一个编译项目中。根据不同的配置(Release/Debug)或不同用途(编译/安装),一个目标可能会被多次导出;同时一个目标的属性可能会被不同的工具(编译、连接、包含)使用,因此使用CMake时要确保目标的各项属性以各种方式导出时都达到想要的效果。也就是说,CMake中不只要配置这些属性的含义,还要配置这些属性在不同语境下的含义。

类型

和Shell一样,CMake中基本类型只有字符串,数字、布尔类型都是用对应的字符串表示。由于字符串的基本地位,CMake中一串字符,如foo(a b c)调用后foo收到的是”a”, “b”, “c”三个字符串。

另外CMake中还有列表类型。当传递参数时,多个参数用空白分割,以一个列表的形式传入函数中。但列表本身只是一个用;分割的字符串,因此”a;b;c”也是一个列表。

参数

参数有三种形式,不带引号str、带引号"str"、带方括号[[str]]。不带引号使用时,如果参数是${变量}的形式,而且变量是一个列表,列表中的每个成员会分别作为函数的参数传递,即传递多个参数。另外两种形式保证传递的是一个参数。使用方括号时不能使用任何转义和变量。

变量

CMake中的变量不能简单地用等号赋值,使用方法如下:

类型 取值 设值
环境变量 $ENV{<var>} set(ENV{<var>} <value>...)
普通变量 ${<var>} set(<var> <value>... [PARENT_SCOPE])
缓存变量 ${<var>}$CACHE{<var>} set(<variable> <value>... CACHE <type> <docstring> [FORCE])

另外变量还有作用域和互相覆盖的问题。

属性

属性使用get_propertyset_property进行取值和设值,另外也有set_target_properties一类把参数类型写在函数名的形式。

函数和宏

cmake中可以使用函数来重用代码,根据调用的形式,函数会获得长度为ARGC的参数列表ARGV,其中已命名的参数会赋值到对应变量中,未命名的参数列表赋值到ARGN中。由于函数没有返回值,因此通常使用set(${output_variable} value PARENT_SCOPE)的方法来在调用处的作用域中赋值。宏和函数类似,但宏本身没有作用域,会像C一样展开到调用处。

复杂参数

CMake中很多函数的参数非常复杂,需要使用类似于关键字参数的形式。这些参数通常是由cmake_parse_arguments负责解析,因此参数的顺序通常是不重要的。

命令和变量

CMake同样提供直接操作命令和变量的方法。这不只可以用于编译C,还可以用于生成代码,比如调用脚本生成一个C文件。

1
2
3
4
5
6
7
8
9
10
11
add_custom_command(OUTPUT main
COMMAND gcc main.c -o main
DEPENDS main.c)

add_custom_command(OUTPUT main.c
COMMAND python generate-main.py
DEPENDS generate-main.py)

add_custom_target(greet
COMMAND ./main
DEPENDS main.c)

这样可以声明两个互相有依赖关系的命令,调用后者会生成main.c而调用前者会生成main。CMake(生成的项目)会根据声明的或自动判断的依赖来按顺序调用。

除了add_custom_command,CMake还有一个类似的函数add_custom_target,用于指定一个命令,而不自动利用生成的文件判断依赖关系,因此可以指定简单的命令向控制台输出或运行程序。

变量

同样地,CMake也提供利用变量来指定参数的方法,可以通过配置环境变量CFLAGS或者CMake变量CMAKE_C_FLAGS来达到和上面例子一样的效果。CMake默认生成的编译指令其实是一个类似于模板,具体内容会填入这些选项设定的参数。

CMake中定义一个变量有多种方法:

  • 调用时cmake时添加选项-DFLAG=value,如cmake -D CMAKE_CXX_FLAGS=-Ofast
  • 在CMakeLists.txt中用命令setoption指定。set更加通用,而option专门用于开关,还支持依赖。

使用变量时语法为${变量名}。要注意的是有些语句(如if)诞生于变量之前,可以直接用变量名的形式引用变量。

函数和属性

仅仅使用裸的命令和变量指定编译方法显然不利于复用,CMake提供的方法是使用一系列的函数来复用代码。

以下是一个函数的定义和调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 定义
function(create_executable name)
add_custom_command(OUTPUT hello.o
COMMAND ${C_COMPILER} -c hello.c
DEPENDS hello.c)
add_custom_command(OUTPUT main.o
COMMAND ${C_COMPILER} -c main.c
DEPENDS main.c)

add_custom_target(${name}
COMMAND ${C_COMPILER} main.o hello.o -o hello
DEPENDS main.o hello.o)
endfunction()

# 调用
create_executable(hello-target)

上面是一个简单的例子,可以看到通常CMake中函数的用途非常局限,仅仅是获取一些变量的值,并利用这些函数的值来调用其他的函数。

可以看到上述的函数的例子中,CMake的函数并没有返回值,而实际的使用中我们会希望获取函数计算的结果,一个简单的方法是利用宏。宏和函数不同,函数体中可以定义新的变量,这些变量的作用域可以不超出函数体;而宏中定义的变量会在调用宏的作用域可见,如果知道这些变量的名字,实际上可以用来返回结果。

下面的例子定义了宏compile,这个宏生成编译source_file的命令,并定义一个变量output_file,用于这个文件编译结果的路径。

1
2
3
4
5
6
7
8
9
10
11
# 定义
macro(compile source_file)
get_filename_component(output_file ${source_file} NAME_WE)
set (output_file ${output_file}.o)
add_custom_command(OUTPUT ${output_file}
COMMAND ${C_COMPILER} -c ${source_file}
DEPENDS ${source_file})
endmacro()

# 调用

属性

set_property get_property

target_compile_options

可以灵活地设定全局或者只针对若干目标指定,可以避免副作用互相影响。

生成器表达式(generator expression)

生成器表达式是CMake中一种特殊的函数,特点是可以获取表达式的语境信息,不需要特意传入参数,比如可以根据语境确定当前的语言。

比如如果要向CUDA的编译器nvcc传入参数

1
2
target_compile_options(enable_utf8 INTERFACE
$<$<BUILD_INTERFACE:$<COMPILE_LANGUAGE:CUDA>>: -Xcompiler=/utf-8 >)

$<COMPILE_LANGUAGE:CUDA>保证-Xcompiler=/utf-8只传给CUDA的编译选项,且$<BUILD_INTERFACE:...>说明这不作为源文件的路径。这些功能都不需要显示地传递参数。

编译模型

自己的库

只介绍最现代的“面向目标”的方法,add_include_directories、include_directories等不介绍。

add_executable/add_library

生成目标
“构造函数”

静态/动态库

不需要指定具体命令
INTERAFACE见下

target_include_directories

指定头文件(目录)
具体需要什么头文件在c代码中include

指定库,包括库的组成部分头文件、二进制文件、编译选项

target_compile_options

指定编译选项

INTERFACE/PRIVATE/PUBLIC

他有、私有、他有及私有

INTERFACE库

不生成文件,用于指定头文件目录和编译选项。添加的文件仅用于在ide中展示

别人的库

find_package

获得上述目标

FetchContent

可以下东西,但是目前还没解决

利用CMake编译CUDA程序

代码:melonedo/cmake-lecture/CUDA

CUDA教程中的编译方法通常是直接调用nvcc(windows需在x64 Native Tools Command Prompt中运行)或者在vs中CUDA集成,前者不太方便自动控制,后者比较麻烦,我尝试使用cmake生成。

CUDA编译的东西很多,这里只编译一个独立可执行文件。

VS集成

Building issues [no CUDA toolset found] · Issue #103 · mitsuba-renderer/mitsuba2 · GitHub

VS2019需要CUDA 10.1以上,另外在我的电脑上出现了能找到nvcc却找不到CUDA的问题,解决方案如上。

CUDA样例

来自An Even Easier Introduction to CUDA | NVIDIA Developer Blog。命名为main.cu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <math.h>

#include <iostream>
// Kernel function to add the elements of two arrays
__global__ void add(int n, float *x, float *y) {
for (int i = 0; i < n; i++) y[i] = x[i] + y[i];
}

int main(void) {
int N = 1 << 20;
float *x, *y;

// Allocate Unified Memory – accessible from CPU or GPU
cudaMallocManaged(&x, N * sizeof(float));
cudaMallocManaged(&y, N * sizeof(float));

// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}

// Run kernel on 1M elements on the GPU
add<<<1, 1>>>(N, x, y);

// Wait for GPU to finish before accessing on host
cudaDeviceSynchronize();

// Check for errors (all values should be 3.0f)
float maxError = 0.0f;
for (int i = 0; i < N; i++) maxError = fmax(maxError, fabs(y[i] - 3.0f));
std::cout << "Max error: " << maxError << std::endl;

// Free memory
cudaFree(x);
cudaFree(y);

return 0;
}

CMake模板

来自Building Cross-Platform CUDA Applications with CMake | NVIDIA Developer Blog。需要编译动态库的话见原文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cmake_minimum_required(VERSION 3.12 FATAL_ERROR)
project(cmake_and_cuda LANGUAGES CXX CUDA)

# INTERFACE库enable_utf8用于在msvc中启用utf-8
add_library(enable_utf8 INTERFACE)
if(MSVC)
# cmake的generator expression可以根据语言选择参数,编译c++时使用前者,CUDA使用后者
target_compile_options(enable_utf8 INTERFACE
$<$<BUILD_INTERFACE:$<COMPILE_LANGUAGE:CXX>>: /utf-8 >
$<$<BUILD_INTERFACE:$<COMPILE_LANGUAGE:CUDA>>: -Xcompiler=/utf-8 >
)
endif()

add_executable(test main.cu)

# 指定显卡架构,可传入列表指定多个架构。CMake 3.23以上可以用all或者all_major
set_target_properties(cuda-test PROPERTIES CUDA_ARCHITECTURES 75)

# 连接到INTERFACE库enable_utf8即可启用utf-8
target_link_libraries(test PRIVATE enable_utf8)
# 注:INTERFACE表示被连接到的目标使用,PRIVATE表示库自己使用,PUBLIC为前两者并集,即自己和被连接到的项目都可以。

基本上就是语言里加个CUDA就解决了。如果用VS,需要借助nvcc向msvc传选项/utf-8来避免编码问题,添加的方法来自passing flags to nvcc via CMake - #2,来自 qiyupei - CUDA Programming and Performance - NVIDIA Developer Forums。(居然是OSPP项目导师,巧了)

显卡架构分为虚架构和实架构,虚架构即使用的软件API版本,实架构代表硬件API版本,当虚版本低于当前电脑的硬件时CUDA将JIT生成需要的代码。可以参考StackOverflow问题differences between virtual and real architecture of cudaNVCC手册关于GPU编译的部分。关于自己的显卡是什么型号的可以参考维基:CUDA#GPUs supported

另有FindCUDAToolkit或者FindCUDA方法,不太清楚。

运行

可执行文件可以直接运行,如果需要profile可以nvprof 文件名