Bazel & CMake

2021-11-10
12 min read

Bazel 简介

参考:官方文档 / 《Core Bazel》 / 《Begin Bazel》 / Common C++ Build Use Cases / awesomebazel.com

可以从 github 上下载 bazel 的二进制安装包(msi、sh 等)进行安装

Bazel 由 Google 开源,Bazel 适用于大型项目与云编译,其特点是编译速度快,Google 使用内部版本的 Bazel 编译亿行代码级别的项目

C++ 引入外部依赖的方式可以大致分为两类:二进制和源码依赖。前者的问题是 ABI 兼容,一旦更换平台与系统,所有的二进制文件都需要手动重新编译;后者的缺点是第一次构建的时候需要编译所有代码,比较耗时。Google 推崇第二种依赖方式,所以才会那么在乎 Bazel 的编译速度

Bazel 也有一些缺陷,比如工具链重(同时依赖 Python、JVM 等工具)、对 IDE 不友好等,不过这些问题官方也提供了一些解决办法,比如 IDE 插件支持。随着发展,bazel 也在不断完善

阅读本文之前请先阅读官方文档的快速入门教程,比如 Build a C++ Project / Build a Java Project 等,大致了解一下 Bazel 的功能和组成

Bazel 与 Python 有一定的关联,因为 Python 的语法对于编译系统而言太过复杂也太灵活,所以 Bazel 对 Python 语法进行了简化

相关概念

A rule is a function, and a target is a function call

Bazel 中规则(Rule)就是一个函数,Target 就是规则的调用,规则的调用会产生有效的输出

Bazel 提供了很多内置规则(例如:genrule、cc_binarycc_libraryjava_binarypy_library等),在 BUILD 文件中可以直接使用

注意下文中对函数(function)、规则(rule)等词语的使用,Bazel 中这些术语对应的工具在使用上有一定的区别。bazel 中的函数可以认为是所有可执行代码块;规则 rule 也是函数,拥有函数的大部分特性,但 rule 受到了更多的约束,比如下面介绍的 load 是函数但不是规则,load 用于从其他文件载入规则,load 不能用于规则中

Loading/Analysis/Execution

Bazel 的执行分为三个阶段:

  1. Loading,加载 Bazel 脚本文件(WORKSPACE、BUILD、bzl 文件)
  2. Analysis,解析所有脚本文件从而生成 Action Graph。解析过程包含外部资源下载,本地资源准备等
  3. Execution,执行 Action Graph 以生成目标文件

动作图(Action Graph)

  1. 动作(action)是 Bazel 中最小的执行单元,action 一般用于将输入文件(部分场景下可能没有输入文件)转换为合适的输出文件,比如将 c/cpp 文件转换为 .o 或 .a 文件。action 至少有一个输出文件。BUILD 文件中的一个 target 对应一个或多个动作,Bazel 会自动将 target 解析为动作并在执行阶段执行
  2. 动作图(Action Graph)是 Bazel 执行过程的抽象,动作图一般是有向无环图(如上图所示),其节点是 action,节点的输入是子节点的输出。Bazel 在编译源码时一般从动作图叶子节点开始执行 action,直到完成指定的目标 xx(bazel build xx
  3. Skylark/Starlark。Bazel 使用 Starlark 语言(也就是 BUILD/.bzl 文件中使用的语言)描述动作图(一般是编译过程,也可以是其他行为,比如打包、下载、上传等)。Starlark 的语法是 Python 语法的一个子集,因为 Starlark 语言的存在,Bazel 有着非常强的扩展性,但扩展时也提高了对使用者的要求

动作图可以有多个输出,执行 Bazel 时我们可以指定一次输出多个目标中的一个或者多个,例如下面的 BUILD 文件就有三个 target 可以被指定

load(":archive.bzl", "archive") # 从 archive.bzl 文件载入 archive 规则(函数)

archive( # target 1,规则的调用被称为 target,也就是一个目标
  name="documentation", 
  files=["README.md",],
  out="documentation.zip",
)
archive(name="release", ...) # target 2
archive(name="all", ...) # target 3

使用下面的命令执行上面的 BUILD 文件可以获得多个或者一个 target 的结果

bazel build documentation release all # 一次生成 doc/releasse/all 三个目标
bazel build release # 只生成 release 这一个目标

缓存(Cache)

执行 Action Graph 过程中由 Actions 生成的目标文件会被集中缓存。再次编译时,只有依赖文件被修改,目标文件才会被更新,这一点和 Make 是一致的。Bazel 可以设置远程缓存与分布式编译环境

Bazel 编译生成的缓存文件一般保存在项目目录下以 bazel-* 为名称的目录下,所以尽量不要在项目中创建以 bazel-* 为名称的文件夹,避免和缓存文件夹冲突

规则与目标(Rules & Targets)

创建文件 archive.bzl 并保存下述代码到文件中,如此我们便定义了一个规则:archive。archive 用来将指定文件打包为 zip 文件。bazel 语法的细节后文会做简单介绍

def _archive(ctx): # 定义方法,与 Python 类似,前缀为 _ 的函数一般只有当前文件可见
  out_file = ctx.actions.declare_file(ctx.attr.out) # out 是一般属性,保存在 attr 中
  args = ctx.actions.args() # args 保存 action 使用的参数,这里的 action 是 zip 命令

  args.add(out_file)             # zip action 的输出文件名
  # ctx.files 对象类型类似于 Python 中的 Dict[str, List[File]]
  args.add_all(ctx.files.files)  # Bazel 对参数有重组行为,细节见下文

  ctx.actions.run(          # 注册 action 到 Action Graph
    executable="zip",       # 需要执行的命令
    arguments=[args],       # action(zip 命令) 使用的参数
    inputs=ctx.files.files, # inputs/outputs 是 Bazel 使用的参数,比如判断文件是否存在
    outputs=[out_file])
	# action 可以返回多个结果,所以这里返回的是一个数组
  return [DefaultInfo(files=depset([out_file]))]

archive = rule( # 定义规则
  implementation = _archive,
  attrs = {
     # 规则的参数声明需要放在 attrs 字典中,参数有名称和类型,使用内置 attr 对象指定参数类型
     # 因为 allow_files=True,所以 files 会被保存到 ctx.files 对象中
    "files": attr.label_list(allow_files=True),
    "out": attr.string(mandatory=True),
  }
)

# another rules ...

下面是使用规则 archive 的方式

# 使用 load 函数从 archive.bzl 文件中载入 archive 规则
# load 支持一次载人多个规则,也支持在载入规则时使用别名 
# load(":archive.bzl", "archive", "balabala", alias_name="balala") 
load(":archive.bzl", "archive") 

archive( # 调用规则
  name="documentation", 
  files=["README.md",],
  out="documentation.zip",
)
规则上下文与参数

规则是函数,调用规则的时候需要传参数给规则,Bazel 中规则的参数保存在规则的上下文对象中(Context Object)。规则使用单一对象保存参数的方式和 C/C++ 使用形参标识参数有一定的区别,如上面的 _archive 函数。ctx 对象中可以保存多种不同的数据,细节可以参考官网

参数的重组

bazel 会将传递给规则的参数按照类型进行重组,比如单文件参数(allow_single_file=True)会被保存到 ctx.file 对象中、多文件参数(allow_files=True)会被保存在 ctx.files 对象中,其他参数会被保存到 ctx.attr 对象中。单/多文件参数在 ctx.attr 对象中也是可见的,但部分函数只能使用 ctx.file 或者 ctx.files 对象

ctx.actions 对象用于添加 action,ctx.args 是 action 的参数对象,action 需要的参数需要保存在 args 中

内置规则

Bazel 提供了很多内置(native)规则,内置规则默认是全局可以见的,不需要使用 load 方法导入。Bazel 为 Java、Python 和 C++ 内置了很多规则,所以开发这些语言编写的项目一般不需要再扩展 bazel 规则

内置规则的细节可以参考官网资料。下面介绍几个常用的内置规则

genrule

官方简介:genrule 使用用户定义的 bash 命令生成一个或多个文件

小型项目使用常规 bazel 语法进行编译,其语法比较麻烦,所以 bazel 就提供了 genrule 这样一个内置规则,方便小型项目的编译,示例代码如下

genrule(
  name="documentation",
  srcs=["src/README.md",],
  outs=["documentation.zip",],
  cmd="zip $(OUTS) $(SRCS)"
)

# 使用 genrule 可以很方便实现上面的打包函数 archive
def archive(name, files, out):
    native.genrule(name=name, outs=[out], srcs=files, cmd="zip $(OUTS) $(SRCS)")
glob

官方简介:glob 是一个辅助性函数,用于寻找满足指定模式的文件,并返回排序后的文件路径列表

glob 函数使用正则表达式自动导入符合要求的文件,可以减少重复工作。glob 函数在分析阶段被调用

srcs = glob([
  "src/main/java/**/*.java" # 包括子文件夹中的文件
])

WORKSPACE & BUILD & .bzl

Bazel 通过判断根目录下是否有 WORKSPACE 或 WORKSPACE.bazel 文件来判断当前目录是否是 bazel 工程项目。WORKSPACE 文件除了能标识项目,也能引入外部的依赖

BUILD 文件用于标识 Package 的根目录,每一个 Package 目录下包含一个 BUILD 文件

bzl 文件用于保存 bazel 规则和方法的 Starlark代码,以供 BUILD/WORKSPACE 文件使用。BUILD/WORKSPACE 文件不允许定义与编写规则代码,只允许使用

下图左侧是一个本地 bazel 工程,右侧是其依赖的非本地工程,在 WORKSPACE 中引入的外部工程将在 bazel 运行期间下载到本地的缓存文件夹中。BUILD 文件引用本地和外部依赖的方式被称为 label,label 的语法规则如下

label

bazel 中的 label 有如下规则

示例:load('@bazel_tools//tools/build_defs/repo:git.bzl', 'git_repository')

  1. 第一部分用于标识使用的外部项目(需包含 WORKSPACE),如果使用当前 WORKSPACE 内的依赖,这一部分可以忽略

  2. 第二部分用于标识 Package 的根目录,如果使用当前 Package 中的 bazel 文件,则这一部分可以忽略。这里需要注意的是:BUILD 文件引入存在的 target 时,Package 目录从当前 BUILD 所在的根目录开始

  3. 第三部分用于标识具体的 bazel 文件,前两部分都不存在的情况下,第三部分使用当前 Package 中的 target

label 有一些简写的方式,比如只包含第二部分,那么 BUILD 文件名默认与第二部分最后一段同名,比如 //some/folder 等价于 //some/folder:folder

使用 build 命令编译项目时也可以使用 label 指定需要编译的 target,例如:bazel build src:target

外部依赖使用示例

bazel 内置了一些伪外部 WORKSPACE,比如 bazel_tools,使用上面介绍的语法可以直接使用。bazel_tools 包含了一些拉取外部依赖的方法,比如的 http_archivehttp_archive官方的解释,主要功能是下载 bazel 仓库(以压缩包的形式下载并自动解压)并允许后续导入仓库中的 target 到当前项目。完整的项目代码可以参考这里

包的可见性(Package Visibility)

为了实现代码和模块之间的隔离,默认情况下 Package 只能看到自己 BUILD 内的 target。Bazel 提供了一些机制控制 Package 中 target 的可见性

文件级别

使用下面两种方式可以直接控制当前 BUILD 中所有 target 对外的可见性

package(default_visibility = ["//visibility:public"]) # BUILD 文件内所有 target 全局可见
package(default_visibility = ["//src:__pkg__"]) # 基于目录的部分可见
target 级别

控制每个 target 的可见性

java_proto_library(    
	name = "transmission_object_java_proto",    
  deps = [":transmission_object_proto"],
  visibility = ["//src:__pkg__"],
)

Bazel 常用方法与命令

常用规则

编译命令

build/run/clean

bazel build src:test / all / ...

执行当前命令将在 bazel-bin/* 下生成可执行文件

bazel 支持一次编译指定 BUILD 文件中的所有 target: bazel build src:all ,这里的 all 不是 BUILD 内的 target,而是 bazel 内建的一个 tag,用于表示所有 target

bazel 也支持一次编译当前目录和其子目录(递归所有目录)下所有 target:bazel build ...

bazel run src:test

当前命令将生成对应的可执行文件并执行,bazel 的输出如下

bazel clean

当前命令将删除项目目录下的 bazel 缓存文件,即由 bazel 生成的以 bazel- 为前缀的目录。bazel clean will clean everything

其他工具

  1. 宏。宏将多个规则打包放在一起,对宏的调用将触发宏内多个规则的调用。细节请参考官网
  2. 环境变量的导入。Bazel 在编译过程中可以访问系统的环境变量,本文不做介绍,细节请参考其他资料

Bazel 的执行参数

--workspace_status_command

当前参数可以指定一个可执行文件或者脚本,可执行文件与脚本的返回值会被 bazel 捕获,bazel 可以使用这些返回值做一些特殊处理。可执行文件与脚本会在 bazel 构建项目前执行

CMake 简介

CMake 是十分出名的开源代码编译管理工具,因为出现的时间比较早,很多项目默认使用 CMake 管理代码编译。Bazel 官方也提供了 Bazel 项目转 CMake 的工具。因为 Bazel 的一些特性,不少项目同时提供了 Bazel 和 CMake 编译文件

CMake 相比于 Bazel 一个特点是继承了 Linux 环境下的一些编译理念和工具,比如 make、autotools 等,对于习惯使用 Makefile 的用户而言更亲切一些。学习 CMake 可以参考 渐进式 CMake 项目示例 / An Introduction to Modern CMake / 《CMake Cookbook》 & 中文翻译 & code / CMake V3.22 官方文档

cmake 是编译文件生成工具。使用 cmake 进行编译管理的项目可以使用 cmake 生成 visual studio 的项目文件;可以生成 Linux 下的 Makefile 文件;可以生成 codeblock 项目文件等等。cmake 在生成对应编译工具的文件之后才可以进行编译。我们可以通过配置或者控制台命令选择生成器来决定要生成什么形式的编译文件

最简单的 Cmake 文件如下所示:

# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(hello LANGUAGES CXX)

add_executable(hello-world hello-world.cpp)

使用 cmake 进行组织的项目一般使用如下编译流程

mkdir build && cd build
cmake ..
make -j7  # 或者:cmake --build .

cmake 的执行流程如下:

常用命令

  1. cmake --help,查看选项和 cmake 可用的生成器,比如 Visual Studio 17 2022、Unix Makefiles 等
  2. cmake -H. -Bbuild,创建 build 目录并在 build 目录中执行 cmake
  3. cmake --build . --target help / cmake --build . --target <target-name>,查看与选择编译目标。在 makefile 为编译工具的项目里使用第一个命令可以查看 make targets
  4. cmake -H. -Bbuild -G 'Unix Makefiles',指定生成器

常用工具

Code Package

cmake 可以生成 objs 或者动态/静态链接库 ,cmake 中的 add_library 可以生成 object、static、shared(DSO,dynamic shared object) 等文件;使用 add_library 也可以导入(IMPORT)外部已经存在的项目

# generate an object library from sources
add_library(message-objs
  OBJECT
    Message.hpp
    Message.cpp
  )

# this is only needed for older compilers
# but doesn't hurt either to have it
set_target_properties(message-objs
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1 # enable -fPIC
    OUTPUT_NAME "message" # 设置文件名
  )

cmake 可以将不同功能保存到不同 .cmake 文件中以方便工程的扩展与管理,使用 include 可以引入 cmake 文件

同一个 cmake 项目中可能多个子项目,比如包含源码的 src 和 包含测试代码的 test,使用 add_subdirectory 可以实现子模块管理,每个子模块中都有自己的 CMakeLists.txt 文件

Condition

cmake 支持类似于 c/c++ 中的宏开关功能:状态(conditons)。用户可以在控制台指定某些变量的值,例如:cmake -D USE_LIBRARY=ON ..

cmake 内置了很多 conditon,比如可以使用 condition 选择编译器和编译类型(release/debug)。condition 的指定可以在控制台也可以在 cmake 文件中,示例如下:

  1. cmake -D CMAKE_CXX_COMPILER=clang++ .. ,选择编译器

  2. cmake -D CMAKE_BUILD_TYPE=Debug 可以用于控制编译类型

  3. cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" 可以用于编译选项的修改,示例

  4. 设置语言的标准,参考

    set_target_properties(animals
      PROPERTIES
        CXX_STANDARD 14
        CXX_EXTENSIONS OFF
        CXX_STANDARD_REQUIRED ON
        POSITION_INDEPENDENT_CODE 1
      )
    
  5. 其他内置 condition 可以参考官网 ,示例可以参考这里

第三方工具与库的导入

cmake 内置了一些常用三方工具的导入方法(比如在编译时导入 ptyhon 头文件与库文件),可以使用命令 cmake --help-module-list 来判断 cmake 对哪些第三方工具提供了支持

使用 cmake 提供的工具可以判断系统是否已经安装必须的库,比如判断 BLAS&LAPACK 是否存在。cmake 同时也可以判断编译系统是否支持 OpenMP 功能,结合已有功能,使用 Eigen 时可以灵活开启优化

cmake 支持手动第三方模块的扩展,细节可以参考这里

单元测试

ctest 是 cmake 自带的测试工具

cmake 可以方便的集成 Catch2、gtest、boost-test 等测试工具。这里有个 Catch2 测试的例子

编译完代码后执行:ctest -v 可以执行单元测试,使用 -vv 将显示更为详细的内容;使用 --rerun-failed 可以执行上次失败的测试

其他功能

  1. 环境判断,包括系统、编译器、CPU 架构&指令集。选择性可以开启一些功能,比如为 Eigen 开启矢量命令
  2. 自定义命令、安装等等

Cmake DSL

cmake 支持一些复杂的语法,比如 foreach,while 等,简单的使用可以参考这里

示例

cpp-template 是一个使用 cmake 和 catch2 的 c++ 项目模板,可以使用这个项目作为 C++ 工程的起始环境

ModernCppStarter 是一个更完整也更复杂的 cmake 起始环境