《OpenFOAM 源码深度剖析》系列-第 2 章 wmake:OpenFOAM 自研构建系统与编译配置体系

2 阅读5分钟

第 2 章 wmake:OpenFOAM 自研构建系统与编译配置体系

OpenFOAM 拒绝使用 CMake、Autotools、Bazel 等主流构建系统,而是自研了一套名为 wmake 的构建系统。理解 wmake,才能理解"同一份源码为什么能在几十种平台/编译器/精度组合下被编译出来",也才能自己编写一个 OpenFOAM 库或求解器。

2.1 wmake 的设计目标

wmake/wmake 文件顶部的注释(第 18–47 行)直接给出了设计目标:

# Script
#     wmake
#
# Description
#     General, wrapped make system for multi-platform development.
#
#     Intermediate object and dependency files retain the tree structure
#     of the original source files, with its location depending on the
#     build context.
#
#       1. Building within the OpenFOAM project:
#          The tree is located under $WM_PROJECT_DIR/build/$WM_OPTIONS/
#
#       2. Building applications or libraries outside the OpenFOAM project:
#          The tree is located under its local Make/$WM_OPTIONS/

归纳为四条:

  1. 多平台:不依赖外部构建工具,只要有 POSIX shell + Make + C++ 编译器即可工作。
  2. 零配置:源码目录里只需一个 Make/ 子目录(内含 files 与 options 两个文件),wmake 自动完成其余一切。
  3. 隔离的中间产物:.o 与 .dep 文件不复用源码目录,而是镜像到独立的构建树里(build/$WM_OPTIONS/ 或 Make/$WM_OPTIONS/),从而支持同一份源码在不同 $WM_OPTIONS 下并存编译。
  4. 递归构建:wmake -all 自动遍历子目录,遇到 Allwmake 就执行。

2.2 平台标识:WM_OPTIONS 的组装

wmake 的一切都围绕一个字符串变量 WM_OPTIONS 展开。它由 etc/config.sh/settings 组装(第 127–134 行):

export WM_LABEL_OPTION="Int$WM_LABEL_SIZE"
export WM_OPTIONS="$WM_ARCH$WM_COMPILER$WM_PRECISION_OPTION$WM_LABEL_OPTION$WM_COMPILE_OPTION"

export FOAM_APPBIN="$WM_PROJECT_DIR/platforms/$WM_OPTIONS/bin"
export FOAM_LIBBIN="$WM_PROJECT_DIR/platforms/$WM_OPTIONS/lib"

以最常见的组合(etc/bashrc 默认值:WM_COMPILER=Gcc、WM_PRECISION_OPTION=DP、WM_LABEL_SIZE=32、WM_COMPILE_OPTION=Opt)为例,Linux x86_64 上 WM_ARCH=linux64,于是:

WM_OPTIONS = linux64   Gcc   DP   Int32   Opt
             └─┬──┘    └┬┘   └┬┘   └─┬─┘   └┬┘
             架构       编译器  精度   label   编译选项
           = linux64GccDPInt32Opt

逐段解释:

段取值来源含义
linux64uname -s + uname -m 决定平台架构(linux64/linuxARM64/darwin64/win64…)
GccWM_COMPILER编译器族(Gcc/Clang/Icc/Icx/Amd/Arm/Cray/Fujitsu/Nvidia/Mingw)
DPWM_PRECISION_OPTION浮点精度:DP(double)、SP(float)、SPDP
Int32WM_LABEL_SIZElabel(索引类型)位宽:Int32(int) / Int64(long)
OptWM_COMPILE_OPTION编译模式:Opt/Dbg/Debug/Prof

这个字符串同时决定了三样东西:头文件/库的安装路径(platforms/$WM_OPTIONS/{bin,lib})、中间产物的路径(build/$WM_OPTIONS/)、以及编译宏。最后一点尤其关键,见 2.6。

架构判定逻辑见 etc/config.sh/settings 第 32–123 行,对 Linux、Darwin、MinGW、SunOS 等做了分支。例如 Windows 交叉编译会设 WM_ARCH=win64(或 winARM64);在 Darwin 上若指定 WM_COMPILER=Gcc 但没给 version=,会被自动改写为 Clang(第 76–90 行)。

2.3 wmake 工具族

wmake/ 目录下除了主脚本 wmake,还有一整套配套脚本(Glob wmake/* 可见):

脚本职责
wmake主构建器
wmakeLnInclude生成 lnInclude/ 符号链接目录(扁平头文件)
wmakeLnIncludeAll全项目递归生成 lnInclude
wclean删除中间产物与出产物
wcleanLnIncludeAll清理所有 lnInclude
wrmo删除某个 .o
wrmdep删除依赖文件(触发重编)
wdep定位某个源文件对应的 .dep
wmakeCollect把子目录 Makefile 收集为单个 Makefile
wmake-with-bear配合 bear 生成 compile_commands.json(供 clangd/IDE 用)

以及 wmake/scripts/ 下的一众辅助脚本:wmakeFilesAndOptions、makeFiles、makeOptions、makeDepend、makeTargetDir、AllwmakeParseArguments,和一组 have_* 第三方库探测脚本。

2.3.1 自动生成 Make/files 与 Make/options

对于一个全新目录,wmakeFilesAndOptions(第 54–62 行)会:

if [ -e Make ]
then
    echo "$Script error: Make directory already exists" 1>&2
    exit 1
fi
mkdir Make
[ -e Make/files ]   || "${0%/*}/makeFiles"
[ -e Make/options ] || "${0%/*}/makeOptions"

它先扫描当前目录的源文件与子目录,再由 makeFiles 生成 Make/files(列出要编译的源文件与产物类型),由 makeOptions 生成 Make/options(列出 include 路径与链接库)。

2.4 Make/files 与 Make/options:两个"编译清单"

2.4.1 Make/files 的格式与产物类型

Make/files 用宏来区分产物:

dictionary.C
primitiveEntry.C
...
LIB = $(FOAM_LIBBIN)/libOpenFOAM

上面声明编译若干源文件并打成共享库 libOpenFOAM.so。产物类型由目标名决定(见 wmake/makefiles/general 第 191–278 行的 Make 规则):

目标产物触发方式
libso共享对象 .soLIB = .../libFoo(默认)
lib静态库 .a由 wmake lib 显式指定
libo单一 .o(把多个目标文件 ld -r 合并)由 wmake libo 指定,常用于解决循环依赖
exe可执行文件EXE = $(FOAM_APPBIN)/foo
objects仅编 .o,不链接用于语法检查

关键片段(wmake/makefiles/general 第 199–204 行,可执行文件链接规则):

$(EXE)$(EXT_EXE): $(OBJECTS)
	@$(WM_SCRIPTS)/makeTargetDir $(EXE)
	$(call QUIET_MESSAGE,link,$(EXE)$(EXT_EXE))
	$E $(LINKEXE) $(FOAM_EXTRA_LDFLAGS) $(OBJECTS) -L$(LIB_PLATFORMS) \
	    $(EXE_LIBS) $(PROJECT_LIBS) $(SYS_LIBS) \
	    $(LINK_LIBS) $(GLIBS) -o $(EXE)$(EXT_EXE)

第 238–243 行是共享库链接规则,注意它会 -L$(LIB_PLATFORMS)(指向 platforms/$WM_OPTIONS/lib):

$(LIB)$(EXT_SO): $(OBJECTS)
	@$(WM_SCRIPTS)/makeTargetDir $(LIB)
	$(call QUIET_MESSAGE,link,$(LIB)$(EXT_SO))
	$E $(LINKLIBSO) $(FOAM_EXTRA_LDFLAGS) $(OBJECTS) -L$(LIB_PLATFORMS) \
	    $(LIB_LIBS) $(GLIB_LIBS) -o $(LIB)$(EXT_SO)

2.4.2 Make/options 的格式

Make/options 提供五组变量(wmake/makefiles/general 第 27–33 行的注释给出定义):

EXE_INC = -I$(LIB_SRC)/finiteVolume/lnInclude \
          -I$(LIB_SRC)/meshTools/lnInclude
EXE_LIBS = -lfiniteVolume -lmeshTools
变量用途
EXE_INC可执行/库编译时的额外 include 路径与宏定义
EXE_LIBS可执行文件链接的库
LIB_LIBS共享库(.so)链接的库
LIBO_LIBS单对象库(.o)链接的库

注意 OpenFOAM 惯例:EXE_INC 里写 -I.../lnInclude,而不是写具体头文件路径——这正是 2.5 节"扁平头文件"策略的直接后果。

2.4.3 预处理:Make/{files,options} 会被 cpp 处理

一个容易被忽略的细节:Make/files、Make/options 并非直接被 Make 读取,而是先经 C 预处理器 cpp 过滤,再抽取。wmake/makefiles/files 第 52–54 行:

define PREFILTER_MAKE_OPTIONS
    @$(CPP) -Ulinux -Uunix $(GFLAGS) $1 | sed -e 's@   *@ @g' > $2
endef

也就是说,你可以在 Make/options 里写 #ifdef、#include、以及像 -DWM_LABEL_SIZE=$(WM_LABEL_SIZE) 这样的宏——它们会先被 cpp 展开。-Ulinux -Uunix 特意取消定义了 linux/unix 宏,避免把路径中的 x86_64-linux-gnu 误替换。

处理 Make/files 的那段(第 73–83 行)尤为巧妙:

$(SFILES): $(MAKE_DIR)/files
	$(call PREFILTER_MAKE_OPTIONS,$<,${FILES})
	# Extracted macro definitions.
	@sed -n -e '/=/p' $(FILES) > $(VARS)
	@echo "SOURCE = \\" > $(SFILES)
	@sed -e '/=/d; /^#/d; /^[ \t]*$$/d' -e 's@[ \t]*$$@ \\@' $(FILES) >> $(SFILES)
  • 含 = 的行被抽到 variables 文件(这就是 LIB = ...、EXE = ...);
  • 不含 =、非注释、非空的行被当作源文件列表,逐行加行尾续行符 \,写入 sourceFiles,形成 SOURCE = a.C \ b.C \ ...。

最终 Make/$WM_OPTIONS/{options,variables,sourceFiles} 被 wmake/makefiles/general 第 164–166 行 include 进来,并据此推导出对象文件列表:

include $(OBJECTS_DIR)/options
include $(OBJECTS_DIR)/variables
include $(OBJECTS_DIR)/sourceFiles
DEPENDENCIES=$(SOURCE:%=$(OBJECTS_DIR)/%.dep)
BASENAMES=$(basename $(SOURCE))
OBJECTS=$(BASENAMES:%=$(OBJECTS_DIR)/%.o)

2.5 lnInclude:扁平化头文件

OpenFOAM 的库内部文件是按功能分目录的(如 src/OpenFOAM/db/dictionary/dictionary.H),但对外只暴露一个 lnInclude/。wmakeLnInclude 会遍历库内所有 .H,在 lnInclude/ 下建立指向它们(相对路径)的符号链接。于是跨库引用只需:

PROJECT_INC = \
    -I$(LIB_SRC)/$(WM_PROJECT)/lnInclude \
    -I$(LIB_SRC)/OSspecific/$(WM_OSTYPE)/lnInclude

(wmake/makefiles/general 第 88–90 行)。这就是为什么所有库都在 src/Allwmake 开头执行 wmakeLnInclude -u(第 25–26 行)——必须先建立链接,再编译。

lnInclude 目录本身通过 .gitignore 排除,不进入版本库,属于"生成物"。

2.6 Rules:编译器与平台规则的组装

wmake/rules/ 是编译规则的核心。它分两级(见 wmake/rules/General/version 第 17–21 行):

ARCHITECTURE_RULES = $(WM_DIR)/rules/$(WM_ARCH)          # 例如 rules/linux64
COMPILER_FAMILY = $(shell echo "$(WM_COMPILER)" | sed -e 's/[-+.0-9~].*//')
DEFAULT_RULES   = $(ARCHITECTURE_RULES)$(COMPILER_FAMILY)  # 例如 rules/linux64Gcc
RULES           = $(ARCHITECTURE_RULES)$(WM_COMPILER)      # 例如 rules/linux64Gcc11
  • rules/General/:与平台无关的通用规则(general、standard、version、transform、flex、bison、lemon、ragel、moc、X…)。
  • rules/<arch>/:架构特定规则(如 rules/linux64/general)。
  • rules/<arch><compiler>/:平台+编译器特定规则(如 rules/linux64Gcc/c++、rules/linux64GccOpt/c++Opt、rules/linux64Gcc/general)。
  • rules/<arch><compiler><version>/:特定编译器版本的可覆盖规则(sinclude $(RULES)/general / $(RULES)/c++,来自 version 第 55–57 行)。

2.6.1 一条完整的编译命令是怎么拼出来的

以 GCC 为例,rules/linux64Gcc/c++(第 1–21 行)把各段拼成 c++FLAGS:

include $(GENERAL_RULES)/Gcc/c++     # 编译器族规则
c++ARCH    := -m64 -pthread
ifneq (,$(strip $(WM_COMPILE_OPTION)))
    sinclude $(DEFAULT_RULES)/c++$(WM_COMPILE_OPTION)   # 如 c++Opt / c++Debug
endif
c++FLAGS    = \
    $(c++ARCH) $(GFLAGS) $(c++WARN) $(c++OPT) $(c++DBUG) $(ptFLAGS) \
    $(FOAM_EXTRA_CXXFLAGS) $(LIB_HEADER_DIRS) -fPIC
Ctoo        = $(WM_SCHEDULER) $(CC) $(c++FLAGS) -c $< -o $@

而编译器族规则 rules/General/Gcc/c++(第 9–19 行)给出语言标准与告警:

CC         := g++$(COMPILER_VERSION) -std=c++17
c++ARCH    :=
c++OPT     := -O2
ptFLAGS    := -DNoRepository -ftemplate-depth=1000
c++WARN     = \
    -Wall -Wextra -Wold-style-cast \
    -Wnon-virtual-dtor -Wno-unused-parameter -Wno-invalid-offsetof \
    -Wno-attributes -Wno-unknown-pragmas

这里面有四个必须理解的要点:

  1. -std=c++17:v2606 已要求 C++17。
  2. -ftemplate-depth=1000:OpenFOAM 的模板嵌套极深(GeometricField<Type, fvPatchField, volMesh> 之上再叠表达式模板),默认模板深度不够,必须放大到 1000。
  3. -DNoRepository:告诉编译器/链接器"不需要显式实例化模板仓库",配合 .C 里已做的手工实例化(src/.../Make/files 常含 ? 或 .C 显式实例化)。OpenFOAM 通过在每个库的 files 里显式实例化所需的 Type(scalar/vector/tensor 等),避免链接期找不到模板符号。
  4. -fPIC(在 rules/linux64Gcc/c++ 中):所有库默认编译为位置无关代码,便于共享库链接。

2.6.2 链接规则与"符号裁剪"陷阱

rules/General/Gcc/link-c++(第 1–33 行)定义链接方式,并处理一个经典陷阱:

LINKLIBSO   = $(CC) $(c++FLAGS) -shared
LINKEXE     = $(CC) $(c++FLAGS)
...
# Default linker, assume ld - no dummy stub
else
    LINKLIBSO  += -Xlinker --add-needed -Xlinker --no-as-needed
    LINKEXE    += -Xlinker --add-needed -Xlinker --no-as-needed
endif

# Link dummy stub to resolve libPstream symbols used by libOpenFOAM
ifneq (,$(LINKEXE_STUB))
ifneq (,$(findstring -lOpenFOAM,$(PROJECT_LIBS)))
    LINKEXE += -L$(FOAM_LIBBIN)/dummy -lPstream
endif
endif

--no-as-needed 强制保留"看起来没被直接引用"的库依赖,否则在启用 --as-needed 的发行版上,libOpenFOAM 对 libPstream 的隐式依赖会被裁剪,导致运行期符号缺失。而 LINKEXE_STUB / dummy/libPstream 则专门用来在"纯串行"构建里,为 libOpenFOAM 中引用的 Pstream 符号提供一个空实现(dummy)。

2.6.3 MPI 与 OpenMP 规则

  • MPI:rules/General/mplib*(如 mplibOPENMPI、mplibINTELMPI、mplibHPMPI、mplibMSMPI)按 WM_MPLIB 选择。核心库 Pstream 通过 WM_MPLIB 决定编译哪个后端。
  • OpenMP:由 WM_COMPILE_CONTROL 的 +openmp / ~openmp 控制(wmake/makefiles/general 第 75–79 行):
ifeq  (,$(findstring ~openmp,$(WM_COMPILE_CONTROL)))
ifneq (,$(findstring +openmp,$(WM_COMPILE_CONTROL)))
SYS_LIBS        = $(LINK_OPENMP)
endif
endif

~openmp 优先级高于 +openmp("禁用优先"),这是 WM_COMPILE_CONTROL 的一贯约定。

2.7 WM_COMPILE_CONTROL:一个字符串控制一切

etc/bashrc 第 85–101 行列出了 WM_COMPILE_CONTROL 支持的开关,这是 OpenFOAM 构建最灵活的旋钮:

开关效果
+gold / +lld / +mold / +link-ld换链接器
~libz关闭 libz 压缩支持
~rpath关闭 macOS 的 rpath 处理
+openmp / ~openmp开关 OpenMP
+ccache用 ccache 加速重复编译
+strict / ++strict更严格的弃用告警(-DFOAM_COMPILE_STRICT / =2)
version=...编译器版本后缀(version=11 → g++-11)
~openfoam完全不链接 OpenFOAM 库(编译纯独立程序)

version= 的解析见 rules/General/version 第 29–33 行,用 sed 从字符串里抠出版本号,拼成 g++-11 之类。~openfoam 的生效见 wmake/makefiles/general 第 94–98 行:一旦启用,PROJECT_INC 与 PROJECT_LIBS 被清空。

2.8 wmake -all 的递归语义

wmake 顶部注释(第 34–37 行)明确:

When `wmake -all` is used, the following rules are applied:
1. If `Allwmake.override` exists, use it.
2. (OR) If `Allwmake` exists, use it.
3. (OR) descend into each sub-directory and repeat.

Allwmake.override 的优先级高于 Allwmake,这是给打包者/发行版留的"打补丁"入口。而对于纯应用目录(没有自定义 Allwmake),wmake -all 会退化为"遍历所有子目录逐个编译",其规则定义在 wmake/makefiles/apps:

.PHONY: $(FOAM_APPS)
$(FOAM_APPS):
	+@wmake -all $(TARGET) $@

2.9 构建元数据:Cvertoo 与 build-info

OpenFOAM 有一类特殊的 .Cver 源文件(见 rules/General/version),它们在编译前会被 wmake-build-info 处理,把 Git 提交哈希、分支、日期、API 等注入到源码字符串里,再送入编译器:

Cvertoo = \
    $(WM_SCRIPTS)/wmake-build-info -update -filter $< > $(@D)/$(<F).cc; \
    $(CC) $(c++FLAGS) -c $(@D)/$(<F).cc -o $@

这就是为什么每个 OpenFOAM 可执行文件都能自报身世(Using: OpenFOAM-com (2012)、Build: b830beb5ea-20210429)。而 WM_VERSION = OPENFOAM=2606(rules/General/general 第 2 行)会被展开成编译宏 -DOPENFOAM=2606,用于源码里的条件编译(例如区分不同的特性开关)。

wmake -build-info 子命令(wmake 帮助第 119 行)可查询或管理 {api, branch, build} 元信息,foamEtcFile -show-api / -show-patch 则给出脚本友好的一行输出。

2.10 与 CMake 路线的对比

维度wmakeCMake
依赖POSIX shell + Make + 编译器CMake + generator(Ninja/Make)
配置方式环境变量 WM_* + Make/{files,options}CMakeLists.txt
多平台平台字符串 WM_OPTIONS 直接编码进路径toolchain file + generator
头文件组织库内 lnInclude/ 符号链接target_include_directories
循环依赖用 libo(单对象)显式打破需拆库或 target_link_libraries 调序
元数据注入.Cver + wmake-build-infoconfigure_file
IDE 支持wmake-with-bear 生成 compile_commands.json原生支持

CMake 更"标准",wmake 更"自洽且零依赖"。选择后者,是 OpenFOAM 由来已久的历史与技术惯性,但也确实换来了"在任何 HPC 集群上,只要有一个 C++ 编译器就能构建"的极强可移植性。

2.11 一次完整的构建到底发生了什么

把上面所有要素串起来,当你在某个应用目录执行 wmake 时,实际流程是:

  1. wmake 读 Make/files 与 Make/options,交给 cpp 预处理,抽取源文件列表与产物声明,写入 Make/$WM_OPTIONS/{options,variables,sourceFiles}。
  2. 依据 wmake/rules/<arch><compiler>/* 组装 c++FLAGS、LINKEXE、LINKLIBSO。
  3. 以 wmake/makefiles/general 为主 Makefile,编译每个 .C 为 .o(路径镜像源码树),链接成 libso/exe/libo。
  4. 依赖由 wmkdepend(wmake/src/wmkdepend.cc,由 wmkdepend.rl + Ragel 生成)自动生成 .dep 文件,供增量编译。
  5. 产物落到 platforms/$WM_OPTIONS/{bin,lib};中间产物落在 build/$WM_OPTIONS/(项目内)或 Make/$WM_OPTIONS/(项目外)。

理解这条链路后,任何"编译找不到符号"、"改了头文件没重编"、"链接器裁剪了依赖"的问题,都能快速定位到 wmake 的对应环节。


本章要点回顾

  • WM_OPTIONS = 架构 + 编译器 + 精度 + label位宽 + 编译选项,它同时决定安装路径、中间产物路径、编译宏。
  • Make/files 声明编译什么、产物是什么(libso/lib/libo/exe);Make/options 声明头文件路径与链接库。
  • 两个文件都先经 cpp 预处理,因此支持宏与条件编译。
  • lnInclude/ 用符号链接实现扁平头文件,跨库引用只需 -I.../lnInclude。
  • 编译标配 -std=c++17 -O2 -DNoRepository -ftemplate-depth=1000 -fPIC,这是为 OpenFOAM 的深模板量身定制。
  • WM_COMPILE_CONTROL(+openmp/+gold/~openfoam/+strict/version=)是一站式编译开关。
  • 构建元数据用 .Cver + wmake-build-info 注入,实现可执行文件"自报身世"。