读研期间的一个工作是为实验室的 ChCore 操作系统重写了新的构建系统——ChBuild,主要包括各级 CMake 脚本、配置系统和构建入口脚本。目前构建系统已经跟随 第二版 ChCore Lab 开源,所以现在可以尝试分享一下思路。如果你不了解 ChCore Lab,也没有关系,这里主要是想粗浅地介绍一些 CMake 很有趣且有用的特性和技巧,可以只看关于这些的内容。
下面的讨论基于 ChCore Lab v2 的 lab5
分支,因为这里包含了比较完整的操作系统代码结构。在阅读之前,建议你首先理解 Modern CMake By Example 中的绝大部分内容。
旧系统的问题
尽管和 ChCore 主线不完全一样,但你可以在 ChCore Lab v1 的 lab5
分支 看到旧版的 ChCore 构建系统的缩影。
主要存在的问题包括:
- 以
scripts/docker_build.sh
作为构建入口,只支持利用预先提供的 Docker 映像创建容器,并在容器中采用硬编码的工具链构建,无法支持在不同的本地环境中构建
- 构建用户态程序、RamDisk 和内核的逻辑分散在不同的 shell 脚本,难以统一对构建行为进行配置(例如对用户态程序和内核统一传入某些 CMake 变量),难以维护
- CMake 项目层级混乱,比如根目录
CMakeLists.txt
实际上在控制 kernel
的构建
- 各子项目 CMake 脚本代码混乱,没有采用现代 CMake 的最佳实践
- 没有比较方便可用的配置系统,无法在一个配置文件中控制整个系统的构建行为
因此,要解决这些问题,对新的构建系统提出了以下要求:
- 构建过程应当可以在 Docker 容器中进行,也可以在本地环境进行,允许较为方便地切换构建工具链
- 在统一的根级别 CMake 项目中管理子项目,不再把不同子项目的构建逻辑分散到不同的 shell 脚本
- 在各级 CMake 脚本中采用现代 CMake 最佳实践
- 支持通过类似 Linux 内核的层级
Kconfig
文件声明构建系统的配置项,通过单个 .config
文件配置整个构建行为,通过类似 make menuconfig
的命令提供 TUI 配置面板
入口脚本
新的构建入口脚本名为 chbuild
,是一个 Bash 脚本。
在旧的构建系统中,构建入口脚本 scripts/build.sh
(由 scripts/docker_build.sh
创建 Docker 容器后调用)实际上只能用于“构建”整个系统,不包含任何类似 Linux 内核的 make defconfig
(创建默认配置文件)、make clean
(清空构建临时文件)等功能。我希望在新的构建入口中通过子命令的形式提供不同的子功能。在 shell 脚本中,实现子命令其实非常简单,只需要定义子命令对应的函数,然后在脚本入口处把第一个参数当作函数名称来调用,如下:
# chbuild
build() {
_check_config_file # 辅助函数加下划线,以免用户不小心调用到
_echo_info "Building..."
# ...
}
clean() {
_echo_info "Cleaning..."
# ...
}
distclean() {
clean # 子命令也可以调用其它子命令
rm -rf $config_file
}
_print_help() {
echo "..."
}
_main() {
case $1 in
help | --help | -h)
_print_help
exit
;;
-*)
_echo_err "$self: invalid option \`$1\`\n"
break
;;
*)
if [[ "$1" == "_"* || $(type -t "$1") != function ]]; then
# 避免用户试图调用辅助函数或不是函数的东西
_echo_err "$self: invalid command \`$1\`\n"
break
fi
$@ # 第一个参数作为要调用的子命令函数,剩余参数则传入函数
exit
;;
esac
# 没有子命令成功运行
_print_help
exit 1
}
_main $@ # 调用入口 _main 函数并传入脚本的所有参数
同时,我希望用户可以在 chbuild
脚本的参数中指定要在本地环境运行还是在 Docker 容器中运行子命令。并且,我希望在 Docker 容器中运行子命令时,chbuild
不需要再调用其它脚本,而是直接在容器中用相同的参数启动自身。也就是说,不再需要区分 build.sh
和 docker_build.sh
,无论要不要在 Docker 容器中构建,都使用 chbuild
作为入口。这听起来可能有点绕,直接来看看如何实现(注意 _main
函数和上面的区别):
# chbuild
_docker_run() {
if [ -f /.dockerenv ]; then
# 如果已经在 Docker 容器中,直接把参数作为子命令运行
$@
else
# 否则,启动 Docker 容器,并运行自身
test -t 1 && use_tty="-t"
docker run -i $use_tty --rm \
-u $(id -u ${USER}):$(id -g ${USER}) \
-v $(pwd):/chos -w /chos \
ipads/chcore_builder:v1.3 \
$self $@
fi
}
_main() {
run_in_docker=true # 默认在 Docker 容器中运行子命令
while [ $# -gt 0 ]; do
case $1 in
help | --help | -h)
_print_help
exit
;;
--local | -l)
# --local 参数用于指定在本地环境运行子命令
run_in_docker=false
;;
-*)
_echo_err "$self: invalid option \`$1\`\n"
break
;;
*)
if [[ "$1" == "_"* || $(type -t "$1") != function ]]; then
_echo_err "$self: invalid command \`$1\`\n"
break
fi
if [[ $run_in_docker == true ]]; then
# 如果要在 Docker 容器中运行子命令,则通过 _docker_run 辅助函数进行
_docker_run $@
else
# 否则直接调用
$@
fi
exit
;;
esac
shift
done
_print_help
exit 1
}
_main $@
于是,用户就可以通过 ./chbuild --local build
在本地环境构建 ChCore,通过 ./chbuild build
在 Docker 容器中构建 ChCore。搭配后面的配置系统,可以实现更好的本地环境跨平台构建支持。
根项目
旧的构建系统中,根项目实际上是 kernel
子项目,没有真正的根项目,对子项目的控制分散在不同的 shell 脚本中,scripts/compile_user.sh
用于调用 user
子项目的 CMake 构建,scripts/build.sh
用于调用 kernel
子项目的 CMake 构建。
在翻阅 CMake 文档的过程中,我发现了 CMake 内置的 ExternalProject 模块。这个模块的 ExternalProject_Add
命令可以把一个子目录或远程 Git 仓库添加为一个“外部项目”,同时配置它的 CONFIGURE_COMMAND
、BUILD_COMMAND
、BINARY_DIR
、INSTALL_DIR
等属性,还可以通过 CMAKE_ARGS
和 CMAKE_CACHE_ARGS
属性来传入 CMake 参数和 cache 变量(也就是命令行调用 cmake
命令时可以传入的 -D
参数)。它不仅可用于添加 CMake 项目,也可以用来添加 Makefile 或是其它构建系统管理的项目。总之,这个功能非常适合用来在 ChCore 根项目中管理各子项目,这样就可以全程使用 CMake,简化构建系统(尤其是配置系统)的实现。
由于 ExternalProject_Add
这个名字显得太把自己的子项目当外人了,我把它重新定义成了 chcore_add_subproject
:
# scripts/build/cmake/Modules/SubProject.cmake
macro(chcore_add_subproject)
ExternalProject_Add(${ARGN})
endmacro()
于是,可以在 ChCore 根目录的 CMakeLists.txt
中通过如下代码来添加 libchcore
、userland
和 kernel
子项目:
# CMakeLists.txt
set(_common_args
-DCMAKE_MODULE_PATH=${CMAKE_MODULE_PATH}
-DCHCORE_PROJECT_DIR=${CMAKE_CURRENT_SOURCE_DIR})
set(_libchcore_source_dir ${CMAKE_CURRENT_SOURCE_DIR}/libchcore)
set(_libchcore_build_dir ${_libchcore_source_dir}/_build)
set(_libchcore_install_dir ${_libchcore_source_dir}/_install)
# ...
chcore_add_subproject(
libchcore
SOURCE_DIR ${_libchcore_source_dir}
BINARY_DIR ${_libchcore_build_dir}
INSTALL_DIR ${_libchcore_install_dir}
CMAKE_ARGS
${_common_args}
-DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
-DCMAKE_TOOLCHAIN_FILE=${_cmake_script_dir}/Toolchains/userland.cmake
BUILD_ALWAYS TRUE)
chcore_add_subproject(
userland
SOURCE_DIR ${_userland_source_dir}
BINARY_DIR ${_userland_build_dir}
INSTALL_DIR ${_userland_install_dir}
CMAKE_ARGS
${_common_args}
-DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
-DCMAKE_TOOLCHAIN_FILE=${_cmake_script_dir}/Toolchains/userland.cmake
DEPENDS libchcore userland-clean-incbin
BUILD_ALWAYS TRUE)
chcore_add_subproject(
kernel
SOURCE_DIR ${_kernel_source_dir}
BINARY_DIR ${_kernel_build_dir}
INSTALL_DIR ${_kernel_install_dir}
CMAKE_ARGS
${_common_args}
-DCHCORE_USER_INSTALL_DIR=${_userland_install_dir} # used by kernel/CMakeLists.txt to incbin cpio files
-DCMAKE_INSTALL_PREFIX=<INSTALL_DIR>
-DCMAKE_TOOLCHAIN_FILE=${_cmake_script_dir}/Toolchains/kernel.cmake
DEPENDS userland kernel-clean-incbin
BUILD_ALWAYS TRUE)
可以看到,通过 ExternalProject 模块可以非常简单而清晰地添加一个 CMake 子项目并传入指定参数、设置 CMAKE_TOOLCHAIN_FILE
工具链文件、设置子项目和其它 target 间的依赖关系等。
根项目中还通过 custom target 的形式提供了子项目的 clean 动作:
# CMakeLists.txt
add_custom_target(
libchcore-clean
COMMAND /bin/rm -rf ${_libchcore_build_dir}
COMMAND /bin/rm -rf ${_libchcore_install_dir})
add_custom_target(
userland-clean
COMMAND /bin/rm -rf ${_userland_build_dir}
COMMAND /bin/rm -rf ${_userland_install_dir})
add_custom_target(
kernel-clean
COMMAND /bin/rm -rf ${_kernel_build_dir}
COMMAND [ -f ${_kernel_install_dir}/install_manifest.txt ] && cat
${_kernel_install_dir}/install_manifest.txt | xargs rm -rf || true)
add_custom_target(
clean-all
COMMAND ${CMAKE_COMMAND} --build . --target kernel-clean
COMMAND ${CMAKE_COMMAND} --build . --target userland-clean
COMMAND ${CMAKE_COMMAND} --build . --target libchcore-clean)
于是,在 chbuild
的 clean
子命令中就可以通过 cmake --build $cmake_build_dir --target clean-all
来清理所有子项目的构建临时文件。根项目的 build 目录则直接在 chbuild
的 clean
子命令函数中通过 rm -rf $cmake_build_dir
来 clean。这里的理念是,谁负责控制一个(子)项目的构建过程,谁就负责这个(子)项目的 clean 过程。
子项目和工具链文件
这部分跟 ChCore 操作系统本身的相关性比较强,如果你不了解或者不感兴趣,其实可以跳到 配置系统。
libchcore
子项目
libchcore
子项目用于构建 LibChCore,即对 ChCore 内核系统调用接口和一些关键系统服务 IPC 接口的封装库(产物是 libchcore.a
和相关头文件),以及 crt0(产物是 crt0.o
)。其实这个子项目的 CMake 相关内容只有一个 libchcore/CMakeLists.txt
,没有太多值得介绍的内容,主要是可以通过 install
命令安装 target 文件、目录、其它文件到指定目标路径:
# libchcore/CMakeLists.txt
add_library(chcore STATIC ...)
install(TARGETS chcore LIBRARY DESTINATION lib)
install(
DIRECTORY include/chcore include/arch/${CHCORE_ARCH}/chcore
DESTINATION include
FILES_MATCHING
PATTERN "*.h")
add_custom_target(
chcore_crt0 ALL
COMMAND
${CMAKE_C_COMPILER} -c
-I${CMAKE_CURRENT_SOURCE_DIR}/include/arch/${CHCORE_ARCH}
-I${CMAKE_CURRENT_SOURCE_DIR}/include -o
${CMAKE_CURRENT_BINARY_DIR}/crt0.o
${CMAKE_CURRENT_SOURCE_DIR}/crt/crt0.c)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/crt0.o DESTINATION lib)
这些 install
命令的目标地址没有使用绝对路径,而是使用了相对的 include
和 lib
。这些相对路径相对的是在根项目 chcore_add_subproject
时通过 CMAKE_INSTALL_PREFIX
参数所指定的安装目录 ${_libchcore_install_dir}
(见前面)。
当根项目 build 时,通过 chcore_add_subproject
添加的子项目会被 configure、build、install。同时,子项目间有依赖关系,于是可以保证在 build userland
子项目时,libchcore
子项目已经将 LibChCore 的头文件和静态库以及 crt0.o
安装到 ${_libchcore_install_dir}
目录,因而在 userland
子项目中可以正确的包含 LibChCore 头文件、链接 LibChCore 静态库和 crt0.o
。
userland
子项目
userland
子项目用于构建用户态系统服务和应用程序。基本逻辑是添加一些全局的编译和链接选项(因为需要应用到该子项目的所有 target),然后通过 add_subdirectory
一层层包含下去。
除此之外,该子项目还需要在一些系统服务和应用程序构建完成之后,将它们打包成 CPIO 格式的 RamDisk,这是比较有趣的地方,来看代码:
# userland/CMakeLists.txt
# 第一块
set(_ramdisk_dir ${CMAKE_CURRENT_BINARY_DIR}/ramdisk)
file(REMOVE_RECURSE ${_ramdisk_dir})
file(MAKE_DIRECTORY ${_ramdisk_dir})
add_custom_target(
ramdisk.cpio ALL
WORKING_DIRECTORY ${_ramdisk_dir}
COMMAND find . | cpio -o -H newc > ${CMAKE_CURRENT_BINARY_DIR}/ramdisk.cpio)
# 第二块
function(chcore_copy_target_to_ramdisk _target)
add_custom_command(
TARGET ${_target}
POST_BUILD
COMMAND cp $<TARGET_FILE:${_target}> ${_ramdisk_dir})
add_dependencies(ramdisk.cpio ${_target})
set_property(GLOBAL PROPERTY ${_target}_INSTALLED TRUE)
endfunction()
function(chcore_copy_all_targets_to_ramdisk)
set(_targets)
chcore_get_all_targets(_targets)
foreach(_target ${_targets})
get_property(_installed GLOBAL PROPERTY ${_target}_INSTALLED)
if(${_installed})
continue()
endif()
get_target_property(_target_type ${_target} TYPE)
if(${_target_type} STREQUAL SHARED_LIBRARY OR ${_target_type} STREQUAL
EXECUTABLE)
chcore_copy_target_to_ramdisk(${_target})
endif()
endforeach()
endfunction()
# 第三块
add_subdirectory(servers)
add_subdirectory(apps)
第一块首先删除已经存在的 RamDisk 临时目录,然后重新创建,接着定义 ramdisk.cpio
custom target,行为就是把 RamDisk 临时目录打包成 CPIO 文件。
第二块定义了两个 CMake 函数:chcore_copy_target_to_ramdisk
和 chcore_copy_all_targets_to_ramdisk
。前者用于把一个 target 的产物拷贝到 RamDisk 临时目录,实现上就是为这个 target 添加一个 POST_BUILD
(构建后)custom command,在其中进行拷贝。由于拷贝需要先于 ramdisk.cpio
target 的打包操作,因此还需要通过 add_dependencies
添加依赖关系。后者用于把调用处可见的所有 target 的产物拷贝到 RamDisk 临时目录,实际上就是通过 chcore_get_all_targets
获得 target 列表,然后对其中没有单独调用过前者的 target 调用前者。
第三块是包含下级 CMakeLists.txt
,进而递归地包含到 userland
的所有 CMakeLists.txt
,在其中的某些地方会调用第二块定义的函数。比如:
# userland/apps/lab5/CMakeLists.txt
add_executable(...)
add_executable(...)
chcore_copy_all_targets_to_ramdisk()
kernel
子项目
kernel
子项目用于构建内核映像文件 kernel.img
。逻辑非常简单,首先创建 kernel.img
target,然后为其设置一些编译链接选项和包含目录,接着一级一级包含下面的所有模块的 CMakeLists.txt
,在其中通过 target_sources
为 kernel.img
添加源文件。
比较值得介绍的是通过 configure_file
来从模板生成文件,可以在模板文件中通过 ${var_name}
引用 CMake 变量。结合配置系统,可以尽量减少相关文件中写死的内容。在 kernel
子项目中,这个技巧用于生成 incbin.S
和 linker.ld
:
# kernel/incbin.tpl.S
.section .rodata
.align 4
.globl __binary_${binary_name}_start
__binary_${binary_name}_start:
.incbin "${binary_path}"
__binary_${binary_name}_end:
.globl __binary_${binary_name}_size
__binary_${binary_name}_size:
.quad __binary_${binary_name}_end - __binary_${binary_name}_start
# kernel/CMakeLists.txt
macro(_incbin _binary_name _binary_path)
set(binary_name ${_binary_name})
set(binary_path ${_binary_path})
configure_file(incbin.tpl.S incbin_${_binary_name}.S)
unset(binary_name)
unset(binary_path)
target_sources(${kernel_target} PRIVATE incbin_${_binary_name}.S)
endmacro()
_incbin(root ${CHCORE_USER_INSTALL_DIR}/${CHCORE_ROOT_PROGRAM})
# kernel/arch/aarch64/boot/linker.tpl.ld
SECTIONS
{
. = TEXT_OFFSET;
img_start = .;
init : {
${init_objects}
}
# ...
}
# kernel/arch/aarch64/boot/CMakeLists.txt
add_subdirectory(${CHCORE_PLAT}) # 包含后 `init_objects` 变量为 boot 模块目标文件列表
string(REGEX REPLACE ";" "\n" init_objects "${init_objects}")
configure_file(linker.tpl.ld linker.ld.S)
工具链文件
在 libchcore
、userland
和 kernel
子项目中,都没有任何设置构建工具链(C 编译器命令名等)的内容,这些内容应该放在独立的、通过 CMAKE_TOOLCHAIN_FILE
指定的 工具链文件 中。其实工具链文件里的内容放在 CMakeLists.txt
也能正常工作,但是放在工具链文件中,CMake 可以在 configure 项目前首先通过测试项目来检查工具链是否可以正常使用。
新的构建系统提供了两个工具链文件:userland.cmake
和 kernel.cmake
,都在 scripts/build/cmake/Toolchains
目录中。在根项目中添加各子项目时,为 libchcore
和 userland
指定了 userland.cmake
工具链文件,为 kernel
指定了 kernel.cmake
工具链文件。ChCore Lab 中这两者内容其实很接近,但在 ChCore 主线中则有更多不同。这里只放一下 kernel.cmake
工具链的部分代码:
# scripts/build/cmake/Toolchains/kernel.cmake
# Set toolchain executables
set(CMAKE_ASM_COMPILER "${CHCORE_CROSS_COMPILE}gcc")
set(CMAKE_C_COMPILER "${CHCORE_CROSS_COMPILE}gcc")
# ...
include(${CMAKE_CURRENT_LIST_DIR}/_common.cmake)
# Set the target system (automatically set CMAKE_CROSSCOMPILING to true)
set(CMAKE_SYSTEM_NAME "Generic")
set(CMAKE_SYSTEM_PROCESSOR ${CHCORE_ARCH})
userland.cmake
和 kernel.cmake
工具链文件在设置完 C 编译器等工具链命令后,会包含 _common.cmake
。这个文件是工具链文件的共用部分,主要工作是从 C 编译器推导出编译目标体系结构(通过 execute_process
运行 gcc -dumpmachine
),并设置到 CHCORE_ARCH
cache 变量,然后再把所有 CHCORE_
开头的 cache 变量添加为编译选项,以便在 C 语言代码中进行条件编译。这里绝大部分 cache 变量都是从配置文件读入的,更多细节会在后面配置系统的 配置的传递 部分详细介绍。
包含完 _common.cmake
之后,两个工具链文件分别设置了 CMAKE_SYSTEM_NAME
和 CMAKE_SYSTEM_PROCESSOR
。这会告知 CMake 当前项目正在进行跨平台编译,并指导 CMake 使用正确的 sysroot、链接器行为等。在 userland.cmake
工具链中指定了 CMAKE_SYSTEM_NAME
为 ChCore
,这个系统相关的跨平台构建行为配置在 scripts/build/cmake/Modules/Platform/ChCore.cmake
文件中定义,由于 ChCore 用户态程序的构建行为和 Linux 基本一致,因此这里直接包含了 CMake 内置的 Platform/Linux
,可以在 /usr/share/cmake-x.xx/Modules/Platform/Linux.cmake
或 代码仓库 中看到后者的内容。kernel.cmake
工具链中则指定系统为 Generic
,因为内核实际上并不是任何操作系统上的应用程序,设置为 Generic
会让 CMake 不对内核的运行环境做任何假设,因此做更少的构建行为配置。其实这里设置这两个变量的实际用处不算大,因为相关子项目中已经对链接选项进行了配置,且都不会链接 C 标准库、系统中安装的第三方库等,之所以设置主要是为了保持优雅。
配置系统
配置系统是 ChCore 新构建系统的精髓之一,与 ChCore 架构本身没有什么关系,不需要了解 ChCore Lab 也可以看看。
config.cmake
和 .config
文件
从用户(ChCore 的开发者和构建者)角度来看,新的配置系统对外表现为两个部分,分别是层级的 config.cmake
配置声明文件和根目录的 .config
配置文件。
层级的 config.cmake
配置声明文件与 Linux 内核的 Kconfig
文件类似:
.
├── kernel
│ └── config.cmake
├── userland
│ └── config.cmake
└── config.cmake
从根目录 config.cmake
开始的每一级 config.cmake
中,可通过 chcore_config_include
命令包含下一级 config.cmake
文件,形成树状结构;通过 chcore_config
命令声明该层级的配置项,每个配置项包括名称、类型、默认值和描述四项内容。例如根目录 config.cmake
部分内容如下:
# config.cmake
chcore_config(CHCORE_CROSS_COMPILE STRING "" "Prefix for cross compiling toolchain")
chcore_config(CHCORE_PLAT STRING "" "Target hardware platform")
chcore_config(CHCORE_VERBOSE_BUILD BOOL OFF "Generate verbose build log?")
chcore_config_include(kernel/config.cmake)
chcore_config_include(userland/config.cmake)
这里 chcore_config_include
命令比较简单,实际上是一个内部调用 CMake 内置 include
命令的宏:
# scripts/build/cmake/Modules/CommonTools.cmake
macro(chcore_config_include _config_rel_path)
include(${CMAKE_CURRENT_LIST_DIR}/${_config_rel_path})
endmacro()
chcore_config
命令则稍微复杂一些,是配置系统的核心,运用了一些技巧,下个小节会详细说明。
根目录的 .config
配置文件是单个扁平的文件,与 Linux 内核的 .config
文件类似,形如:
# .config
CHCORE_CROSS_COMPILE:STRING=aarch64-linux-gnu-
CHCORE_PLAT:STRING=raspi3
CHCORE_VERBOSE_BUILD:BOOL=OFF
用户可以通过 ./chbuild defconfig
生成默认的 .config
文件,其中包含目前所声明的所有配置项的默认值,也可以通过 ./chbuild menuconfig
或者手动编辑该文件来修改配置项的值。在构建时,构建系统会读取该配置文件中的值,并设置到 CMake cache 变量,从而控制构建行为。
配置的加载
加载 .config
文件应该在 ChCore 根项目的 configure 阶段开始之前完成,因为 configure 阶段即运行 CMakeLists.txt
时,已经需要使用配置值。一个 naive 的思路是直接在 chbuild
脚本中读取并解析其内容,将解析出的 (key, type, value)
三元组构造成 CMake -D
参数序列,例如 -DCHCORE_CROSS_COMPILE:STRING=aarch64-linux-gnu-
。如果只是单纯读取用户已经填写的配置,这个思路是可行的,但我不想满足于此,我希望实现:
- 对于
config.cmake
中声明了,但 .config
中没有填写的配置项,根据情况采取三种不同的策略来处理,分别是:
- 使用默认值:直接将配置值设为
config.cmake
中声明的默认值
- 交互式询问用户:在命令行询问用户是否需要使用默认值,若不使用,则要求输入一个值
- 中断构建流程:直接停止构建
- 对于
.config
中填了,但实际上没在任何 config.cmake
中声明的配置项(可能是已经删除的旧配置项),过滤掉,不传入子项目
- 尽量少地编写 shell 脚本,因为 shell 脚本比 CMake 脚本更容易写错、更难维护
经过一番搜寻,我发现 CMake 的 initial cache 功能可以用来实现这些要求。该功能允许通过 cmake
命令的 -C
参数指定一个 CMake 脚本,并在 configure 之前首先运行这个脚本,以填充 CMake cache,也就是设置一系列 cache 变量。在 initial cache 脚本中,可以使用完整的 CMake 语法,也就是说,可以通过 include
包含其它 CMake 脚本、通过 file(READ ...)
读取文件内容、通过 macro
/function
定义宏/函数等。
于是,我决定利用这个功能,在 initial cache 脚本中加载 .config
文件。这带来的另外一个好处是,在 chbuild
脚本中只需切换 -C
参数的值,就可以很方便地切换配置加载策略,如下:
# chbuild
cmake_script_dir="scripts/build/cmake"
cmake_init_cache_default="$cmake_script_dir/LoadConfigDefault.cmake"
cmake_init_cache_ask="$cmake_script_dir/LoadConfigAsk.cmake"
_config_default() {
cmake -B $cmake_build_dir -C $cmake_init_cache_default
}
_config_ask() {
cmake -B $cmake_build_dir -C $cmake_init_cache_ask
}
menuconfig() {
_check_config_file
_config_default # 采用“使用默认值”策略加载配置并 configure 根项目
# ...
}
build() {
_check_config_file
_config_ask # 采用“交互式询问用户”策略加载配置并 configure 根项目
# ...
}
具体的 initial cache 文件如下:
scripts/build/cmake
├── LoadConfig.cmake
├── LoadConfigDefault.cmake
├── LoadConfigAsk.cmake
├── LoadConfigAbort.cmake
└── DumpConfig.cmake
LoadConfigDefault.cmake
、LoadConfigAsk.cmake
、LoadConfigAbort.cmake
分别实现了使用默认值、交互式询问用户、中断构建流程三种配置加载策略,LoadConfig.cmake
则是它们的通用部分。
DumpConfig.cmake
是一个特殊的 initial cache,用于把 CMake cache 中的配置值同步回 .config
。之所以需要 DumpConfig.cmake
,是因为在通过“使用默认值”或“交互式询问用户”策略加载配置后,CMake cache 中可能包含 .config
所没有填写的配置,需要把这些配置同步到 .config
,以保证 .config
始终反映构建系统实际使用的配置。
下面着重介绍 LoadConfigDefault.cmake
和 DumpConfig.cmake
,其它 initial cache 只是略有不同。
首先看 LoadConfigDefault.cmake
:
# scripts/build/cmake/LoadConfigDefault.cmake
macro(chcore_config _config_name _config_type _default _description)
if(NOT DEFINED ${_config_name})
# config is not in `.config`, set default value
set(${_config_name}
${_default}
CACHE ${_config_type} ${_description})
endif()
endmacro()
include(${CMAKE_CURRENT_LIST_DIR}/LoadConfig.cmake)
它首先定义了 chcore_config
宏,行为是,当 ${_config_name}
也就是配置名称所对应的 CMake cache 变量不存在时,设置该 cache 变量为配置项所声明的默认值。还记得在 config.cmake
文件中声明配置项的时候使用的 chcore_config
命令吗,config.cmake
中传入的配置名称、类型、默认值、描述四个参数,就是这个宏的四个参数。不过,我们并不能说 config.cmake
中用的就是这里定义的宏,后面你会逐渐理解这一点。
随后它 include 了 LoadConfig.cmake
,该文件主要内容如下:
# scripts/build/cmake/LoadConfig.cmake
# 第一块
if(EXISTS ${CMAKE_SOURCE_DIR}/.config)
# Read in config file
file(READ ${CMAKE_SOURCE_DIR}/.config _config_str)
string(REPLACE "\n" ";" _config_lines "${_config_str}")
unset(_config_str)
# Set config cache variables
foreach(_line ${_config_lines})
if(${_line} MATCHES "^//" OR ${_line} MATCHES "^#")
continue()
endif()
string(REGEX MATCHALL "^([^:=]+):([^:=]+)=(.*)$" _config "${_line}")
if("${_config}" STREQUAL "")
message(FATAL_ERROR "Invalid line in `.config`: ${_line}")
endif()
set(${CMAKE_MATCH_1}
${CMAKE_MATCH_3}
CACHE ${CMAKE_MATCH_2} "" FORCE)
endforeach()
unset(_config_lines)
else()
message(WARNING "There is no `.config` file")
endif()
# 第二块
# Check if there exists `chcore_config` macro, which will be used in
# `config.cmake`
if(NOT COMMAND chcore_config)
message(FATAL_ERROR "Don't directly use `LoadConfig.cmake`")
endif()
# 第三块
macro(chcore_config _config_name _config_type _default _description)
if(DEFINED ${_config_name})
# config is in `.config`, set description
set(${_config_name}
${${_config_name}}
CACHE ${_config_type} ${_description} FORCE)
else()
# config is not in `.config`, use previously-defined chcore_config
# Note: use quota marks to allow forwarding empty arguments
_chcore_config("${_config_name}" "${_config_type}" "${_default}"
"${_description}")
endif()
endmacro()
# 第四块
# Include the top-level config definition file
include(${CMAKE_SOURCE_DIR}/config.cmake)
第一块是在加载和解析 .config
文件,比较直白。首先读取文件内容,然后用正则从每一行中提取配置名称、类型、值三元组,通过 set(... CACHE ... FORCE)
设置为 cache 变量。此时 .config
中的所有配置都已经进入了 CMake cache。
第二块检查是否定义了 chcore_config
命令。这是为了避免不小心在 chbuild
中直接使用 LoadConfig.cmake
作为 initial cache,要求必须在 LoadConfigDefault.cmake
等文件中定义了 chcore_config
宏后再 include(LoadConfig.cmake)
。
第三块定义了一个新的 chcore_config
宏。这里运用了 一个 CMake 技巧,当重复定义宏/函数时,旧的宏/函数名称会被加上下划线。也就是说,定义了新的 chcore_config
之后,可以通过 _chcore_config
调用到上一次(在 LoadConfigDefault.cmake
中)定义的 chcore_config
。这个宏的作用是,在后面 include 根目录的 config.cmake
时,如果配置名称对应的 cache 变量已经定义(也就是出现在 .config
中了),则为其设置变量描述(description),否则调用先前定义的 chcore_config
,也就是执行 LoadConfigDefault.cmake
中设置 cache 变量为默认值的逻辑。之所以要设置 cache 变量的描述,是为了在之后的 menuconfig
中显示声明配置项时的描述。
第四块是包含(也就是执行)根目录的 config.cmake
文件,该文件进而会递归地通过 chcore_config_include
包含到所有的 config.cmake
,并调用上面第三块中定义的 chcore_config
宏。根据前面已经说明的逻辑,该过程中,遇到 .config
中已填写的配置项时,会设置 cache 变量的描述,遇到没有填写的配置项时,会设置 cache 变量为所声明的默认值。
再来看 DumpConfig.cmake
:
# scripts/build/cmake/DumpConfig.cmake
set(_config_lines)
macro(chcore_config _config_name _config_type _default _description)
# Dump config lines in definition order
list(APPEND _config_lines
"${_config_name}:${_config_type}=${${_config_name}}")
endmacro()
include(${CMAKE_SOURCE_DIR}/config.cmake)
string(REPLACE ";" "\n" _config_str "${_config_lines}")
file(WRITE ${CMAKE_SOURCE_DIR}/.config "${_config_str}\n")
这个 initial cache 不需要包含 LoadConfig.cmake
,而只需要定义一个 chcore_config
,然后直接包含根目录 config.cmake
。这里的逻辑是把所有声明的配置项在 CMake cache 中实际设置的值 append 到 _config_lines
,随后写入 .config
文件。其实通过 cmake -B build -L -N | grep ^CHCORE_ > .config
命令可以更快地做到这件事,但无法保留配置项声明的顺序,对用户不是很友好。
到这里,如果你经常写 C 语言,尤其经常写宏的话,应该已经明白 config.cmake
文件其实应用了类似 C 语言中的 X-Macros 技巧。通过定义不同的 chcore_config
命令,再 include 根目录 config.cmake
,实现了同一组 config.cmake
文件在不同地方 include 时产生不同的行为。
配置的传递
配置加载后首先进入根项目的 cache,由于各子项目都是独立的“外部”CMake 项目,不能直接访问根项目的 cache 变量,因此根项目还需要在添加子项目时传递配置内容。为了收集所有配置内容,以便在 chcore_add_subproject
时传入,再次使用了 X-Macro 技巧,将所有配置名称、类型和配置值拼成 -D
参数序列,放到 _cache_args
变量中:
# CMakeLists.txt
# Construct cache args list for subprojects (kernel, libchcore, etc)
macro(chcore_config _config_name _config_type _default _description)
if(NOT DEFINED ${_config_name})
message(FATAL_ERROR "...")
endif()
list(APPEND _cache_args
-D${_config_name}:${_config_type}=${${_config_name}})
endmacro()
include(${CMAKE_CURRENT_SOURCE_DIR}/config.cmake)
这里定义 chcore_config
并 include(config.cmake)
,而不是遍历所有 CHCORE_
开头的 cache 变量,是为了实现前面所希望的,过滤掉 .config
中填写了、但实际已不在任何 config.cmake
中声明的配置项。如果不需要过滤,也可以采用类似下面 chcore_dump_chcore_vars
函数的方式(VARIABLES
改成 CACHE_VARIABLES
):
# scripts/build/cmake/Modules/CommonTools.cmake
function(chcore_dump_chcore_vars)
get_cmake_property(_variable_names VARIABLES)
list(SORT _variable_names)
foreach(_variable_name ${_variable_names})
string(REGEX MATCH "^CHCORE_" _matched ${_variable_name})
if(NOT _matched)
continue()
endif()
message(STATUS "${_variable_name}: ${${_variable_name}}")
endforeach()
endfunction()
把所有配置项拼成 -D
参数序列后,在 chcore_add_subproject
时通过 CMAKE_CACHE_ARGS
属性即可传入子项目:
# CMakeLists.txt
chcore_add_subproject(
libchcore
# ...
CMAKE_CACHE_ARGS ${_cache_args})
chcore_add_subproject(
userland
# ...
CMAKE_CACHE_ARGS ${_cache_args})
chcore_add_subproject(
kernel
# ...
CMAKE_CACHE_ARGS ${_cache_args})
这样,所有配置项就已经进入了子项目的 cache,也就是可以在子项目的 CMake 脚本中访问,例如:
# kernel/CMakeLists.txt
if(CHCORE_KERNEL_TEST)
add_subdirectory(tests)
endif()
但这还不够,我希望把这些配置传递给 C 代码,从而可以通过 #ifdef
等预处理指令来进行条件编译:
#ifdef CHCORE_KERNEL_TEST
some_test();
#endif /* CHCORE_KERNEL_TEST */
旧系统中,这是通过各子项目独立添加 definition 实现的,可维护性非常差。新系统则在 CMake 工具链文件中实现:
# scripts/build/cmake/Toolchains/_common.cmake
# Convert config items to compile definition
get_cmake_property(_cache_var_names CACHE_VARIABLES)
foreach(_var_name ${_cache_var_names})
string(REGEX MATCH "^CHCORE_" _matched ${_var_name})
if(NOT _matched)
continue()
endif()
get_property(
_var_type
CACHE ${_var_name}
PROPERTY TYPE)
if(_var_type STREQUAL BOOL)
# for BOOL, add definition if ON/TRUE
if(${_var_name})
add_compile_definitions(${_var_name})
endif()
elseif(_var_type STREQUAL STRING)
# for STRING, always add definition with string literal value
add_compile_definitions(${_var_name}="${${_var_name}}")
endif()
endforeach()
# Set CHCORE_ARCH_XXX and CHCORE_PLAT_XXX compile definitions
string(TOUPPER ${CHCORE_ARCH} _arch_uppercase)
string(TOUPPER ${CHCORE_PLAT} _plat_uppercase)
add_compile_definitions(CHCORE_ARCH_${_arch_uppercase}
CHCORE_PLAT_${_plat_uppercase})
这里首先遍历所有 CHCORE_
开头的 cache 变量,如果类型是 BOOL
,则根据其真值决定要不要添加同名的 definition,也就是可以在 C 代码里通过 #ifdef
判断其真值;如果类型是 STRING
则一定会添加该 definition,值是配置值字符串。举个例子,.config
中的配置 CHCORE_PLAT:STRING=raspi3
和 CHCORE_KERNEL_TEST:BOOL=ON
在此处产生的效果相当于下面 C 预处理指令:
#define CHCORE_PLAT "raspi3"
#define CHCORE_KERNEL_TEST
为了在代码中更方便地判断当前处理器架构和硬件平台(因为 #if
无法对字符串进行比较),对 CHCORE_ARCH
和 CHCORE_PLAT
不仅定义了字符串,还定义了表示具体架构和平台的空 definition。比如在 AArch64 架构和树莓派 3 平台,这里添加的 definition 相当于:
#define CHCORE_ARCH "aarch64"
#define CHCORE_PLAT "raspi3"
#define CHCORE_ARCH_AARCH64
#define CHCORE_PLAT_RASPI3
menuconfig
子命令
配置系统的另一个需求是让 ./chbuild menuconfig
子命令实现类似 Linux 内核 make menuconfig
的 TUI 配置面板。由于已经全面采用了 CMake cache 变量和 initial cache 功能,一个自然的想法是复用 ccmake
命令。
这里其实有一些不够优雅的地方,因为在 ccmake
提供的配置面板中,需要按 C 键(Configure)来把修改的配置值刷到 CMake cache 中,也就是保存配置。这和一般直觉中的 S 键(Save)不同,但是没有找到好的修改办法,只能在运行 ccmake
命令之前输出一些红字提示用户。最终实现如下:
# chbuild
cmake_init_cache_dump="$cmake_script_dir/DumpConfig.cmake"
_sync_config_with_cache() {
cmake -N -B $cmake_build_dir -C $cmake_init_cache_dump >/dev/null
}
menuconfig() {
_check_config_file
_config_default
echo
_echo_warn "Note: In the menu config view, press C to save, Q to quit."
read -p "Now press Enter to continue..."
ccmake -B $cmake_build_dir # 复用 ccmake 提供的 TUI 配置面板
_sync_config_with_cache # 同步 CMake cache 回 .config
_echo_succ "Config saved to \`$config_file\` file."
}
前面提到 DumpConfig.cmake
initial cache 用于把 CMake cache 中的配置同步回 .config
文件。这里用户在 ccmake
TUI 面板中修改配置后,也需要进行这个同步操作,才能把修改反映到 .config
。
总结
尽管构建系统和代码本身其实没有很大的直接关系,但我相信一个优雅的构建系统仍然非常重要,因为它会极大地影响开发者的体验。一个优质的构建系统可以让开发者更方便、更舒适地为系统扩充功能。
在重写 ChCore 构建系统时,我的理念是在入口层面提供与 Linux 内核相似的体验,而下面的实现则尽量充分利用 CMake 的一切可利用的特性,并遵循现代 CMake 的最佳实践,最终效果基本达到了我理想的状态。
ChCore 构建系统实现思路