ChCore 构建系统实现思路

目录:

读研期间的一个工作是为实验室的 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.shdocker_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_COMMANDBUILD_COMMANDBINARY_DIRINSTALL_DIR 等属性,还可以通过 CMAKE_ARGSCMAKE_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 中通过如下代码来添加 libchcoreuserlandkernel 子项目:

# 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)

于是,在 chbuildclean 子命令中就可以通过 cmake --build $cmake_build_dir --target clean-all 来清理所有子项目的构建临时文件。根项目的 build 目录则直接在 chbuildclean 子命令函数中通过 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 命令的目标地址没有使用绝对路径,而是使用了相对的 includelib。这些相对路径相对的是在根项目 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_ramdiskchcore_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_sourceskernel.img 添加源文件。

比较值得介绍的是通过 configure_file 来从模板生成文件,可以在模板文件中通过 ${var_name} 引用 CMake 变量。结合配置系统,可以尽量减少相关文件中写死的内容。在 kernel 子项目中,这个技巧用于生成 incbin.Slinker.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)

工具链文件

libchcoreuserlandkernel 子项目中,都没有任何设置构建工具链(C 编译器命令名等)的内容,这些内容应该放在独立的、通过 CMAKE_TOOLCHAIN_FILE 指定的 工具链文件 中。其实工具链文件里的内容放在 CMakeLists.txt 也能正常工作,但是放在工具链文件中,CMake 可以在 configure 项目前首先通过测试项目来检查工具链是否可以正常使用。

新的构建系统提供了两个工具链文件:userland.cmakekernel.cmake,都在 scripts/build/cmake/Toolchains 目录中。在根项目中添加各子项目时,为 libchcoreuserland 指定了 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.cmakekernel.cmake 工具链文件在设置完 C 编译器等工具链命令后,会包含 _common.cmake。这个文件是工具链文件的共用部分,主要工作是从 C 编译器推导出编译目标体系结构(通过 execute_process 运行 gcc -dumpmachine),并设置到 CHCORE_ARCH cache 变量,然后再把所有 CHCORE_ 开头的 cache 变量添加为编译选项,以便在 C 语言代码中进行条件编译。这里绝大部分 cache 变量都是从配置文件读入的,更多细节会在后面配置系统的 配置的传递 部分详细介绍。

包含完 _common.cmake 之后,两个工具链文件分别设置了 CMAKE_SYSTEM_NAMECMAKE_SYSTEM_PROCESSOR。这会告知 CMake 当前项目正在进行跨平台编译,并指导 CMake 使用正确的 sysroot、链接器行为等。在 userland.cmake 工具链中指定了 CMAKE_SYSTEM_NAMEChCore,这个系统相关的跨平台构建行为配置在 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.cmakeLoadConfigAsk.cmakeLoadConfigAbort.cmake 分别实现了使用默认值、交互式询问用户、中断构建流程三种配置加载策略,LoadConfig.cmake 则是它们的通用部分。

DumpConfig.cmake 是一个特殊的 initial cache,用于把 CMake cache 中的配置值同步回 .config。之所以需要 DumpConfig.cmake,是因为在通过“使用默认值”或“交互式询问用户”策略加载配置后,CMake cache 中可能包含 .config 所没有填写的配置,需要把这些配置同步到 .config,以保证 .config 始终反映构建系统实际使用的配置。

下面着重介绍 LoadConfigDefault.cmakeDumpConfig.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_configinclude(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=raspi3CHCORE_KERNEL_TEST:BOOL=ON 在此处产生的效果相当于下面 C 预处理指令:

#define CHCORE_PLAT "raspi3"
#define CHCORE_KERNEL_TEST

为了在代码中更方便地判断当前处理器架构和硬件平台(因为 #if 无法对字符串进行比较),对 CHCORE_ARCHCHCORE_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 的最佳实践,最终效果基本达到了我理想的状态。

评论