Nuttx - Build System

一份 defconfig 如何驱动编译出针对特定芯片的 OS 二进制?本文拆解 NuttX Make 构建系统的每一层——配置生成、symlink 路由、递归编译、依赖追踪、三种构建模式,以及最终的链接拼接。


1. 为什么需要这样一个构建系统?

嵌入式操作系统的构建面临三个核心难题:

硬件碎片化。 一个 OS 需要支持 ARM、RISC-V、x86、Xtensa 等十多种架构,每种架构下又有上百种芯片,每种芯片可能有数十个开发板。内核代码相同(sched/fs/net/),但启动代码、外设驱动、链接脚本、交叉编译器完全不同。

配置驱动的编译。 不是所有代码都需要编译——一个没有网络栈的 IoT 传感器不应编译 net/ 目录中的任何文件。配置系统必须精确控制哪些源文件参与编译、哪些头文件路径被包含、哪些编译选项被启用。

构建模式的多样性。 NuttX 支持三种运行模式:FLAT(单一地址空间)、PROTECTED(MPU 隔离)、KERNEL(MMU 虚拟内存)。同是 libc,在 FLAT 模式下编译一次,在 PROTECTED 模式下却需要编译两次——kernel 版本(带 -D__KERNEL__)和 user 版本(不带)。

NuttX 的 Make 构建系统通过分层抽象解决这些问题:它用”通用名”(generic names)消除硬件差异、用 Kconfig 约定配置、用递归 Make 组织编译。下面的源码追踪将展示每一层如何工作。


2. 配置之源:defconfigconfigure.sh

构建 NuttX 的第一步永远是配置:

1
2
$ cd nuttx
$ tools/configure.sh qemu-armv7a:knsh

这条命令背后发生了什么?入口是 tools/configure.sh,一个 362 行的 bash 脚本。

2.1 参数解析

configure.sh 接受 <board-name>:<config-name> 这种紧凑格式。它首先解析出 boarddirconfigdir

1
2
3
4
5
6
7
8
# tools/configure.sh:149-155
configdir=`echo ${boardconfig} | cut -s -d':' -f2`
if [ -z "${configdir}" ]; then
boarddir=`echo ${boardconfig} | cut -d'/' -f1`
configdir=`echo ${boardconfig} | cut -d'/' -f2`
else
boarddir=`echo ${boardconfig} | cut -d':' -f1`
fi

然后构造配置路径:configpath 首先尝试 boards/<arch>/<chip>/<board>/configs/<config>

1
2
# tools/configure.sh:157
configpath=${TOPDIR}/boards/*/*/${boarddir}/configs/${configdir}

对于 qemu-armv7a:knsh,实际路径解析为:

1
boards/arm/qemu/qemu-armv7a/configs/knsh

如果此路径不存在,脚本依次尝试自定义路径(相对 TOPDIR)、绝对路径。

2.2 寻找 Make.defs——工具链的定义者

Make.defs 是构建系统的核心文件,它定义了交叉编译器、编译选项和链接脚本。configure.sh 按以下顺序搜索:

1
2
3
4
5
6
7
8
# tools/configure.sh:174-195
src_makedefs=${TOPDIR}/boards/*/*/${boarddir}/configs/${configdir}/Make.defs
# 如果不存在,尝试:
src_makedefs=${TOPDIR}/boards/*/*/${boarddir}/scripts/Make.defs
# 还找不到,依次尝试:
src_makedefs=${configpath}/Make.defs
src_makedefs=${configpath}/../../scripts/Make.defs
src_makedefs=${configpath}/../../../common/scripts/Make.defs

这五个位置对应了开发板目录的常见布局变化——有些板子在 configs/ 下有独立的 Make.defs,有些则共享 scripts/ 下的通用文件。

一旦找到,脚本用符号链接将其安装到 NuttX 根目录:

1
2
3
# tools/configure.sh:242-243
ln -sf ${src_makedefs} ${dest_makedefs} || \
{ echo "Failed to symlink ${src_makedefs}" ; exit 8 ; }

于是 $(TOPDIR)/Make.defs 指向了:

1
boards/arm/qemu/qemu-armv7a/scripts/Make.defs

打开这个文件(boards/arm/qemu/qemu-armv7a/scripts/Make.defs:23-25),它自身又引入三条关键链:

1
2
3
include $(TOPDIR)/.config        # Kconfig 输出(此时尚未生成!)
include $(TOPDIR)/tools/Config.mk # 公共构建宏
include $(TOPDIR)/arch/arm/src/armv7-a/Toolchain.defs # arm-none-eabi-gcc 定义

注意这里的一个”先有鸡还是先有蛋”问题:Make.defs 需要 .config,但 .config 还没生成。实际构建时 include .config 发生在顶层 Makefile 之后——Makefile 先 include .config,再 include Make.defs。所以这条链是 Makefile → .config → Make.defs → Config.mk → Toolchain.defs

2.3 配置 Pipeline 全貌

在深入每个子步骤之前,先看清整条流水线的全景。下面是从 configure.sh 被调用到最终 .config 落地的完整链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
tools/configure.sh qemu-armv7a:knsh

├─ 1. 定位 defconfig + Make.defs ───────── 从 boards/ 目录树中找到配置

├─ 2. process_config.sh ───────────────── 文本级 #include 展开 + 去重
│ └── 输出: .config(片段拼接后的中间结果)

├─ 3. 注入 CONFIG_APPS_DIR / CONFIG_BASE_DEFCONFIG ── 合成 .config 尾部

├─ 4. sethost.sh ──────────────────────── 检测 OS,设置 CONFIG_HOST_*
│ └── 调用: make olddefconfig
│ │
│ └─ 5. Kconfig 引擎 (kconfig-conf) ───── 语义级解析!
│ │
│ ├── 读取整个 Kconfig 树(所有子目录的 Kconfig 文件)
│ ├── 以步骤 3 的 .config 为用户显式设定的基线
│ ├── 解析 select: CONFIG_ARCH_ARM=y → 自动选上 CONFIG_ARCH_HAVE_xxx
│ ├── 解析 depends on: 不满足的选项 → n
│ ├── 填入 default: 未显式声明的 → 取默认值
│ └── 输出: .config(完整的最终配置!)

└─ 6. 保存 .config.orig ───────────────── 去除 BASE_DEFCONFIG,供后续脏检测

关键理解:**步骤 2 和步骤 5 是两种完全不同的”展开”**。process_config.sh 做的是纯文本级别的 #include 拼接——它不认识 Kconfig 语义,只管把一个 defconfig 及其 #include 的文件拼成一个大文件。而 Kconfig 引擎做的是语义级别的解析——selectdepends ondefault 的逻辑都在这一层。两者共同完成从板级最小配置到完整 OS 配置的转换。

下面逐一拆解每个环节。

2.4 defconfig 的文本预处理:process_config.sh

为什么需要预处理? 很多芯片家族的开发板共享大量配置——比如所有 STM32 板子都启用 CONFIG_ARCH_CHIP_STM32=y,所有 QEMU 仿真板都禁用了真实硬件相关选项。如果不支持 #include,每个板子的 defconfig 都需要完整列出几百行配置,重复且难以维护。NuttX 的解决方式是让 defconfig 支持 #include 指令来复用配置片段,被 include 的配置可以被后面的定义覆盖(后者优先)。这是 NuttX 对 Kconfig 生态的一个重要扩展——参考官方文档 Documentation/implementation/make_build_system.rst 第 96-101 行的说明。

1
2
3
4
# tools/configure.sh:244-246
${TOPDIR}/tools/process_config.sh -I ${configpath}/../../common/configs \
-I ${configpath}/../common -I ${configpath} -I ${TOPDIR}/../apps -I ${TOPDIR}/../nuttx-apps \
-o ${dest_config} ${src_config}

这里指定了五个 include 搜索路径:

  1. boards/arm/qemu/common/configs(芯片家族的公共配置)
  2. boards/arm/qemu/qemu-armv7a/configs/common(板级公共配置)
  3. ${configpath}boards/arm/qemu/qemu-armv7a/configs/knsh(当前配置目录)
  4. ../apps(apps 目录中的配置片段)
  5. ../nuttx-apps(备选 apps 路径)

process_config.sh 做了什么?看核心函数 process_file()tools/process_config.sh:22-68):

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
process_file() {
local output_file="$1"
local input_file="$2"
local include_paths=("${!3}")
while IFS= read -r line || [ -n "$line" ]
do
if [[ $line == \#include* ]]; then
local include_file=$(echo $line | sed -E 's/#include [<"](.+)[">]/\1/')
# 在 include_paths 中递归搜索并展开
for path in "${include_paths[@]}"; do
local full_path="$path/$include_file"
if [ -f $full_path ]; then
process_file $output_file $full_path include_paths[@]
found=true
break
fi
done
else
# 去重:如果同 key 的配置已存在,先删除再追加(后者覆盖)
if [[ -n "$line" ]]; then
if [[ ! "$line" == \#* ]]; then
local key_config="$(echo "$line" | cut -d= -f1)="
sed -i.backup "/^$key_config/d" "$output_file"
fi
echo "$line" >> "$output_file"
fi
fi
done < "$input_file"
}

经过 process_config.sh 处理后,你得到的是一个文本级展开但未经 Kconfig 语义解析的中间产物——.config 包含了 defconfig 及其 #include 的所有片段拼接在一起,重复项以后者覆盖。但它还不知道 selectdepends ondefault 的含义。一个例子:如果 defconfig 写了 CONFIG_ARCH_ARM=y,它背后 select 出的 CONFIG_ARCH_HAVE_xxx 系列此时还不在 .config 里——得等到下一步 Kconfig 引擎(make olddefconfig)来补全。

最后,configure.sh 将原始 defconfig 备份到 $(TOPDIR)/defconfig,供后续检测配置变更:

1
2
3
# tools/configure.sh:247-248
install -m 644 ${src_config} "${backup_config}" || \
{ echo "Failed to backup ${src_config}" ; exit 10 ; }

2.5 宿主环境设置与 Kconfig 语义展开:sethost.sh

上一步 process_config.sh 得到的 .config 只是一个文本拼接产物——selectdepends ondefault 这些 Kconfig 语义都还没生效。真正完成这一步的是 sethost.sh,它做了两件事。

第一,检测宿主环境。 NuttX 支持在 Linux、macOS、Windows、Cygwin、MSYS 等不同宿主上编译。Windows 原生环境需要反斜杠路径、del 代替 rm;Cygwin 需要路径转换。sethost.sh 通过 uname -s 检测当前 OS,然后调用 kconfig-tweak 写入对应的 CONFIG_HOST_LINUX=yCONFIG_HOST_MACOS=yCONFIG_HOST_WINDOWS=y。这些选项后续会影响 Config.mk 中路径分隔符、文件操作命令的选择。

第二,也是最关键的一步——调用 make olddefconfig 这是整条配置流水线中第一次也是唯一一次运行 Kconfig 引擎。它以当前 .config(process_config 拼出来的 + sethost 写入的宿主信息)作为”旧配置”,走完整个 Kconfig 树的语义解析:

1
2
3
4
5
6
7
8
9
.config (文本拼接产物 + 宿主信息)

└── make olddefconfig ── Kconfig 引擎遍历所有 Kconfig 文件

├── 解析 select 链: CONFIG_ARCH_ARM=y → 自动选中 CONFIG_ARCH_HAVE_xxx
├── 解析 depends on: 不满足依赖的选项 → 强制为 n
├── 填入 default: 未显式声明的选项 → 取 Kconfig 默认值

└── 输出: .config(完整配置,所有选项都有确定值)

至此,.config 从几十行的 Board 选择,扩展为包含所有 Kconfig 默认值的完整配置——之后 make 中读到的每一个 CONFIG_* 变量都有了确定值。


3. .config 的双重身份:Make 变量与 C 头文件

.config 是 Kconfig 的输出文件,格式是简单的 KEY=VALUE 对。NuttX 的巧妙之处在于:同一个文件既被 Make 直接 include 为 Make 变量,又被转换成 C 头文件供源码使用。

3.1 作为 Make 变量的 .config

顶层 Makefile/workspace/nuttx/Makefile:33):

1
include .config

这行代码将 .config 中所有的 CONFIG_*=y/n/value 直接变成 Make 宏。于是你可以在任何 Makefile 中用 ifeq ($(CONFIG_NET),y) 做条件判断。GNU Make 的语法恰好兼容 Kconfig 的输出格式——CONFIG_NET=y 在 Make 中就是变量 CONFIG_NET 赋值 y

3.2 作为 C 头文件的 .config

但 C 代码不认识 CONFIG_NET=y——它需要 #define CONFIG_NET 1。这个转换由 tools/mkconfig 完成。

mkconfig 是一个 C 程序(tools/mkconfig.c:60-121),核心逻辑很简单:调用 generate_definitions()(实现在 tools/cfgdefine.c:294-356)逐行扫描 .config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* tools/cfgdefine.c:294-356, generate_definitions() 的主循环体 */
do
{
ptr = read_line(stream);
if (ptr)
{
parse_line(ptr, &varname, &varval);
if (varname)
{
varval = dequote_value(varname, varval);
if (!varval || strcmp(varval, "n") == 0)
printf("#undef %s\n", varname);
else if (strcmp(varval, "y") == 0)
printf("#define %s 1\n", varname);
else if (strcmp(varval, "m") == 0)
printf("#define %s 2\n", varname);
else
printf("#define %s %s\n", varname, varval);
}
}
}
while (ptr);

即:CONFIG_NET=y#define CONFIG_NET 1=n 或未定义 → #undef CONFIG_NET;带引号的字符串值(如 CONFIG_INIT_ENTRYPOINT="nsh_main")经过去引号处理后输出 #define CONFIG_INIT_ENTRYPOINT nsh_main

但有一个细节:去引号。某些配置值在 Kconfig 中被存储为带引号的字符串(如 CONFIG_INIT_ENTRYPOINT="nsh_main"),在 C 代码中必须去掉引号。cfgdefine.c:49-85 维护了一个去引号白名单 dequote_list[]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* tools/cfgdefine.c:49-85, dequote_list[] 白名单 */
static const char *dequote_list[] =
{
"CONFIG_INIT_ENTRYPOINT", /* Name of entry point function */
"CONFIG_PASS1_BUILDIR", /* Pass1 build directory */
"CONFIG_PASS1_TARGET", /* Pass1 build target */
"CONFIG_PASS1_OBJECT", /* Pass1 build object */
"CONFIG_INIT_ARGS", /* Argument list of entry point */
"CONFIG_INIT_SYMTAB", /* Global symbol table */
"CONFIG_INIT_NEXPORTS", /* Global symbol table size */
"CONFIG_EXECFUNCS_SYMTAB_ARRAY",/* Symbol table array used by exec[l|v] */
"CONFIG_EXECFUNCS_NSYMBOLS_VAR",/* Variable holding number of symbols */
"CONFIG_LIBC_ELF_SYMTAB_ARRAY", /* Symbol table array for elf loader */
"CONFIG_LIBC_ELF_NSYMBOLS_VAR", /* Variable holding symbol count */
"CONFIG_TTY_LAUNCH_ENTRYPOINT", /* Entry point from tty launch */
"CONFIG_TTY_LAUNCH_ARGS", /* Arguments for tty launch */
/* ... NxWidgets/NxWM and apps/ entries: CONFIG_NXWM_* , CONFIG_NSH_* ... */
NULL
};

这些值会被 dequote_value() 函数去掉双引号再输出。

mkconfig 还在生成的 config.h 末尾添加了安全检查。对应 tools/mkconfig.c:96-112 中的 printf 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* tools/mkconfig.c:96-112, mkconfig 主函数中写入 config.h 尾巴的部分 */
printf(
"\n/* Sanity Checks *****************************************/\n\n"
"/* If the end of RAM is not specified then it is assumed to be\n"
" * the beginning of RAM plus the RAM size.\n"
" */\n\n"
"#ifndef CONFIG_RAM_END\n"
"# define CONFIG_RAM_END (CONFIG_RAM_START+CONFIG_RAM_SIZE)\n"
"#endif\n\n"
"#ifndef CONFIG_RAM_VEND\n"
"# define CONFIG_RAM_VEND (CONFIG_RAM_VSTART+CONFIG_RAM_SIZE)\n"
"#endif\n\n"
"/* If the end of FLASH is not specified then it is assumed to be\n"
" * the beginning of FLASH plus the FLASH size.\n"
" */\n\n"
"#ifndef CONFIG_FLASH_END\n"
"# define CONFIG_FLASH_END (CONFIG_FLASH_START+CONFIG_FLASH_SIZE)\n"
"#endif\n\n"
"#endif /* __INCLUDE_NUTTX_CONFIG_H */\n");

如果 defconfig 没有显式定义 RAM_END,它会自动计算。这是一个防御性设计。

3.3 配置脏标记(dirty tracking)

注:本节描述的是一个辅助性审计机制,不参与编译决策逻辑。急于理解构建流程的读者可以跳过,不影响后续章节的阅读。

你开发板子在跑,一切正常。半年后客户报了一个 bug,你想复现当年的固件。你从 Git 里找到 qemu-armv7a:knsh 的 defconfig,configure.sh + make,烧进去——结果行为不一样。因为你半年前跑 make menuconfig 改过一个选项,但没保存。

这就是 dirty tracking 要解决的问题:在固件里留下一个”免责声明”,告诉拿到这个固件的人:配置被人改过,别只看 defconfig 名字。

具体做法是,每次 make 生成 config.h 之前,构建系统比较当前 .configconfigure.sh 当初保存的原始快照:

1
2
3
4
5
6
7
8
9
10
11
12
# tools/Unix.mk:266-276
include/nuttx/config.h: $(TOPDIR)/.config tools/mkconfig$(HOSTEXEEXT)
$(Q) grep -v "CONFIG_BASE_DEFCONFIG" "$(TOPDIR)/.config" > "$(TOPDIR)/.config.tmp"
$(Q) if ! cmp -s "$(TOPDIR)/.config.tmp" "$(TOPDIR)/.config.orig" ; then \
sed -e "/CONFIG_BASE_DEFCONFIG/ { /-dirty/! s/\"$$/-dirty\"/; }" "$(TOPDIR)/.config" > "$(TOPDIR)/.config.dirty" ; \
else \
sed "s/-dirty//g" "$(TOPDIR)/.config" > "$(TOPDIR)/.config.dirty"; \
fi
$(Q) rm -f "$(TOPDIR)/.config.tmp"
$(Q) $(call TESTANDREPLACEFILE, $(TOPDIR)/.config.dirty, $(TOPDIR)/.config)
$(Q) tools/mkconfig $(TOPDIR) > $@.tmp
$(Q) $(call TESTANDREPLACEFILE, $@.tmp, $@)

步骤:排除 CONFIG_BASE_DEFCONFIG 行生成临时文件 → 与 .config.orig 比较 → 不同则追加 -dirty,相同则去掉 -dirty → 覆盖 .config → 重新生成 config.h

最终效果是,CONFIG_BASE_DEFCONFIG 的值会被编译进固件,并通过 /proc/version 暴露到运行时:

1
2
nsh> cat /proc/version
NuttX version 12.x.x, base configuration qemu-armv7a/knsh-dirty

这个 -dirty 就是一条审计线索:

  • 没有 -dirty → 这个固件可以用对应的 defconfig 精确复现
  • -dirty → 配置被改过,想复现必须找作者要 .config,或者让他 make savedefconfig 提交

换句话说,dirty 不是技术机制的参与者——它不参与编译决策。它只是一个”路标”,告诉后来人这条路走歪了,要复现请留神。

至此,.config 已生成、Make.defs 已就位、config.h 已备妥。但谁来使用它们来驱动实际的编译?下一节将看到,NuttX 用了一个极其简洁的路由机制,将 .configMake.defs 组装成一套可工作的编译流程。


4. 构建入口:Makefile 的路由机制

4.1 顶层 Makefile

顶层 Makefilenuttx/Makefile:25-42)极其简洁——只做两件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# nuttx/Makefile:25-42
ifeq ($(wildcard .config),)
.DEFAULT default:
@echo "NuttX has not been configured!"
@echo "To configure the project:"
@echo " tools/configure.sh <config>"
else
include .config

ifeq ($(CONFIG_WINDOWS_NATIVE),y)
include tools/Win.mk
else
include tools/Unix.mk
endif
endif

如果 .config 不存在,打印帮助信息。如果存在,include .config 然后根据 CONFIG_WINDOWS_NATIVE 路由到 tools/Win.mktools/Unix.mk。对于 99% 的场景(Linux、macOS、WSL、Cygwin、MSYS),使用的是 Unix.mk

4.2 Unix.mk 的 include 链

Unix.mk 被 include 后,会触发一系列 Make 变量和规则的求值。我们先梳理它的 include 链(按代码顺序):

1
2
3
4
5
6
7
8
Makefile
└── tools/Unix.mk # main build orchestrator
├── $(TOPDIR)/Make.defs # Unix.mk:32 -> Makefile:32 chain
│ ├── $(TOPDIR)/.config # -> Kconfig output
│ ├── $(TOPDIR)/tools/Config.mk # -> macros & rules
│ └── $(TOPDIR)/arch/arm/src/armv7-a/Toolchain.defs
├── tools/Directories.mk # -> KERNDEPDIRS, USERDEPDIRS, etc.
└── tools/{Flat,Protected,Kernel}Libs.mk # -> NUTTXLIBS, USERLIBS

这条链的求值顺序至关重要:Unix.mk 首先 include $(TOPDIR)/Make.defs,而 Make.defs 内部又依次 include .configConfig.mkToolchain.defs。这意味着 当 Unix.mk 执行到 include tools/Directories.mk 时,所有 Kconfig 变量(CONFIG_NETCONFIG_BUILD_FLAT 等)和所有编译宏(COMPILEARCHIVE 等)都已经被定义了Directories.mk 利用这些变量来决定哪些目录参与编译,{Flat,Protected,Kernel}Libs.mk 利用它们来决定编译哪些库。

Config.mk 是编译基础设施的核心。它定义了所有你需要的构建宏:

  • 编译宏COMPILECOMPILEXXASSEMBLEPREPROCESSARCHIVEPRELINK
  • 依赖规则%.ddc%.dds%.ddp 的 pattern rules
  • 路径工具INCDIRDEFINE(调用 tools/incdirtools/define 获取编译器特定的 flag 格式)
  • 平台适配HOSTCCHOSTCFLAGSDELIM(路径分隔符 /\)、KDEFINE = -D__KERNEL__
  • 工具选择MKDEPDIRLINKlink.shcopydir.sh,取决于是否支持符号链接)

Config.mk 中的 COMPILE 宏(tools/Config.mk:304-308)展示了宏如何抽象编译:

1
2
3
4
5
define COMPILE
$(ECHO_BEGIN)"CC: $1 "
$(Q) $(CCACHE) $(CC) -c $(CFLAGS) $3 $($(strip $1)_CFLAGS) $1 -o $2
$(ECHO_END)
endef

$($(strip $1)_CFLAGS) 的含义是:对于源文件 foo.c,先查找 Make 变量 foo.c_CFLAGS,如果存在则追加这些特定的编译选项。这实现了每个文件级别的编译选项定制——不需要修改 Makefile 就可以给单个文件添加特殊 flag。

至此,include 链已经把所有构建变量和宏定义好了。但要真正编译源代码,我们还需要解决一个更根本的问题:Makefile 怎样在不知道具体芯片的情况下,写出正确的 include 路径和源文件引用? 答案藏在 NuttX 最优雅的符号链接机制中。


5. 通用名约定:用符号链接消除硬件差异

这是 NuttX 构建系统最优雅的设计。

5.1 问题

在内核代码中,你需要 #include <arch/chip.h>#include <arch/board/board.h>。但有效的 chip.h 文件取决于具体芯片:对于 STM32 它是 arch/arm/include/stm32/chip.h,对于 ESP32 它是 arch/xtensa/include/esp32/chip.h

如果每个源文件都用具体路径,那么所有 Makefile 中都需要类似这样的条件判断(示意,并非真实代码——NuttX 实际采用了下一节介绍的 symlink 方案):

1
2
3
4
5
6
7
# 说明:这是演示坏设计的示意代码,不存在于 NuttX 仓库中
ifeq ($(CONFIG_ARCH_CHIP_STM32),y)
INCLUDES += -Iarch/arm/include/stm32
else ifeq ($(CONFIG_ARCH_CHIP_ESP32),y)
INCLUDES += -Iarch/xtensa/include/esp32
# ... 每种芯片都要写一段
endif

这是不可维护的。

5.2 解决方案:通用名 + 符号链接

NuttX 的答案是定义一个”通用名”命名空间,然后在 context 阶段把真实目录 symlink 进来。下表以 qemu-armv7a 为例展示映射关系:

1
2
3
4
5
6
7
8
Generic Name      Real Path (qemu-armv7a)
──────────────── ──────────────────────────────────────────────────
include/arch → arch/arm/include
include/arch/board → boards/arm/qemu/qemu-armv7a/include
include/arch/chip → arch/arm/include/armv7-a
arch/arm/src/chip → arch/arm/src/armv7-a
arch/arm/src/board → boards/arm/qemu/qemu-armv7a/src
drivers/platform → boards/arm/qemu/../drivers (or drivers/dummy)

这些 symlink 是在 context 目标的 .dirlinks 子目标中建立的(Unix.mk:295-369):

1
2
3
4
# tools/Unix.mk:297-299
include/arch:
@echo "LN: $@ to $(ARCH_DIR)/include"
$(Q) $(DIRLINK) $(TOPDIR)/$(ARCH_DIR)/include $@

DIRLINK 变量在 Config.mk 中根据宿主平台选择(tools/Config.mk:196-212):Linux 上它是 tools/link.sh(创建符号链接),Windows 原生环境上是 tools/copydir.bat(复制目录),Cygwin/MSYS 上是 tools/copydir.sh

5.3 一个特殊案例:board 目录的两层结构

有些开发板共享一个 common 目录(如所有 STM32F4 Discovery 板共享 boards/arm/stm32/stm32f4discovery/common)。这时 symlink 需要两层:

1
2
3
4
5
6
7
# tools/Unix.mk:318-335
ifneq ($(BOARD_COMMON_DIR),)
ARCH_SRC_BOARD_SYMLINK=$(BOARD_COMMON_DIR)
ARCH_SRC_BOARD_BOARD_SYMLINK=$(BOARD_DIR)/src
else
ARCH_SRC_BOARD_SYMLINK=$(BOARD_DIR)/src
endif

有 common 目录时:arch/arm/src/boardcommon/arch/arm/src/board/board具体板子/src/。这使得板级代码可以用 #include "board/board.h" 访问具体板子头文件,用 #include "board.h" 访问公共头文件。

5.4 Dummy 机制

Kconfig 不支持”条件 include”——你不能写 if EXTERNALDIR_EXISTS: source "external/Kconfig"。NuttX 的解决方式是”dummy 重定向”:

1
2
# tools/Unix.mk:84
EXTERNALDIR := $(shell if [ -r $(TOPDIR)/external/Kconfig ]; then echo 'external'; else echo 'dummy'; fi)

如果用户没有 external/ 目录,EXTERNALDIR 被设为 dummy,Kconfig 会 include 一个空的 dummy/Kconfig

同样的策略用于芯片和板子的 Kconfig:

  • 使用官方芯片时,CHIP_KCONFIG 指向 arch/dummy/dummy_kconfig(空文件)
  • 使用自定义芯片时,指向用户提供的真实 Kconfig

符号链接一旦就位,编译环境就真正”通用化”了。接下来的问题是:编译本身是如何排程的? 先做什么后做什么?依赖如何在 100+ 个目录间逐层传递?下面将看到 NuttX 构建的时间线是如何精确编排的。


完整的 make 构建流程由依赖关系驱动。下面是核心目标的依赖链:

1
2
3
4
5
6
7
8
9
10
make
└── all: $(BIN) # Unix.mk:161
└── $(BIN): pass1 pass2 # Unix.mk:553
├── pass1: $(USERLIBS) # Unix.mk:541 -- depends on pass1dep
├── pass2: $(NUTTXLIBS) # Unix.mk:543 -- depends on pass2dep
└── context: # Unix.mk:463 -- prereq for everything
├── include/nuttx/config.h (mkconfig from .config)
├── include/nuttx/version.h (mkversion from .version)
├── .dirlinks (all symlinks)
└── $(CONTEXTDIRS)/.context (recursive context)

这条链的实际执行顺序是:Make 首先解析依赖图,发现 $(BIN) 依赖 pass1pass2,而这两个又依赖 pass1deppass2dep,后者又依赖 context。因此 context 最先执行——先建 symlink、生成 config.h、准备好所有环境。然后按 pass1dep → pass1pass2dep → pass2 的顺序编译和链接。最后才执行 $(BIN) 的链接规则,生成 nuttx ELF 甚至 .hex.bin 等附加格式。

6.1 Context 阶段:环境就绪

context 目标(Unix.mk:463)负责准备好一切”一次性”的东西:

  1. 构建 host 工具tools/incdir$(HOSTEXEEXT)(编译器 include 前缀检测工具)
  2. 生成 config.h:调用 tools/mkconfig.config 转为 C 头文件
  3. 生成 version.h:调用 tools/mkversion.version(Git 导出版本号)生成版本信息
  4. 建立 dirlinks:所有通用名符号链接
  5. 可选头文件拷贝math.hfloat.hstdarg.hsetjmp.h 等——如果未使用工具链的默认头文件,则用 NuttX 的替代
  6. 递归 context:在所有 CONTEXTDIRS 目录中执行 make context

Directories.mktools/Directories.mk:64)定义了 CONTEXTDIRS

1
CONTEXTDIRS = boards drivers fs $(APPDIR) $(ARCH_SRC) mm

这些是需要特殊预备工作的目录。例如 boards 的 context 可能创建符号链接,libs/libc 的 context 创建 bin/kbin/ 输出目录(为双版本编译准备)。

6.2 Pass1dep 和 Pass2dep:依赖生成

为什么需要自定义 mkdeps? 标准做法是使用 gcc -M 自动生成 .d 依赖文件,Make 再 -include 它们。但 NuttX 面向的不只是 GCC + Linux 环境——它还必须支持 Windows 原生(路径反斜杠)、Cygwin 跨编译(Windows 版编译器期望 Windows 路径但 Make 运行在 POSIX 下)、以及 ZDS-II、SDCC、Tasking 等不兼容 GCC -M 格式的嵌入式编译器。因此 NuttX 实现了自己的 tools/mkdeps.c(1020 行),统一了跨编译器、跨平台的依赖生成。这套机制的详细文档见 Documentation/implementation/make_build_system.rst 第 144-152 行。

依赖生成分两遍。第一遍处理用户空间的目录(USERDEPDIRS),第二遍处理内核空间的目录(KERNDEPDIRS),并且内核版本传入 KDEFINE(即 -D__KERNEL__):

1
2
3
4
5
6
7
8
9
10
# tools/Unix.mk:673-681
pass1dep: context tools/mkdeps$(HOSTEXEEXT) tools/cnvwindeps$(HOSTEXEEXT)
$(Q) for dir in $(USERDEPDIRS) ; do \
$(MAKE) -C $$dir depend || exit; \
done

pass2dep: context tools/mkdeps$(HOSTEXEEXT) tools/cnvwindeps$(HOSTEXEEXT)
$(Q) for dir in $(KERNDEPDIRS) ; do \
$(MAKE) -C $$dir EXTRAFLAGS="$(KDEFINE) $(EXTRAFLAGS)" depend || exit; \
done

tools/mkdeps.c(1020 行)是一个完整的依赖生成器,支持 --dep-path(搜索路径)、--obj-path(输出目录映射)、--obj-suffix(对象文件扩展名)、--winnative(Windows 原生模式)。

依赖生成的 pattern rules 定义在 Config.mk:228-241

1
2
3
# tools/Config.mk:228-241
%.ddc: %.c
$(Q) $(MKDEP) --obj-path $(OBJPATH) --obj-suffix $(OBJEXT) $(DEPPATH) "$(CC)" -- $(CFLAGS) -- $< > $@

每个 .c 文件产生一个 .ddc 文件,其中包含该源文件依赖的所有头文件路径。这些 .ddc 文件被收集为目录级的 Make.dep 文件,最终被 -include Make.dep 引入,实现增量编译。

这些都准备好之后,真正的编译——编译所有子系统库——就可以开始了。这是本节调度之舞的高潮。

6.3 Pass1 和 Pass2:编译库

pass1: $(USERLIBS)pass2: $(NUTTXLIBS) 的定义取决于构建模式。这两个变量在 FlatLibs.mk / ProtectedLibs.mk / KernelLibs.mk 中填充。

$(USERLIBS)$(NUTTXLIBS) 的元素是 staging/libxxx.a 形式的路径。每个库对应的构建规则在 LibTargets.mk 中定义。例如 libc 的用户版本:

1
2
3
4
5
6
7
8
9
10
# tools/LibTargets.mk:191-199
ifeq ($(CONFIG_BUILD_FLAT),y)
libs$(DELIM)libc$(DELIM)libc$(LIBEXT): pass2dep
else
libs$(DELIM)libc$(DELIM)libc$(LIBEXT): pass1dep
endif
$(Q) $(MAKE) -C libs$(DELIM)libc libc$(LIBEXT) EXTRAFLAGS="$(EXTRAFLAGS)"

staging$(DELIM)libc$(LIBEXT): libs$(DELIM)libc$(DELIM)libc$(LIBEXT)
$(Q) $(call INSTALL_LIB,$<,$@)

每个库的构建分两步:

  1. 编译步骤:递归 make -C <libdir> libX$(LIBEXT)——在库的源目录中编译所有源文件并归档
  2. 安装步骤:将编译好的 .a 文件 install 到 staging/ 目录(使用 install -m 0644

INSTALL_LIB 宏(Config.mk:434-438):

1
2
3
4
5
define INSTALL_LIB
$(ECHO_BEGIN)"IN: $1 -> $2 "
$(Q) install -m 0644 $1 $2
$(ECHO_END)
endef

为什么用一个 staging 目录? 最终链接时需要所有库的路径。与其记住 sched/libsched.amm/libmm.afs/libfs.a 等散布在各处的路径,不如统一安装到 staging/。链接命令只需引用 staging/libsched.a staging/libmm.a staging/libfs.a ...

6.4 $(BIN):最终链接

所有库编译完毕后,$(BIN): pass1 pass2 执行最终链接(Unix.mk:553-604):

1
2
3
4
$(BIN): pass1 pass2
$(Q) $(MAKE) -C $(ARCH_SRC) EXTRA_OBJS="$(EXTRA_OBJS)" LINKLIBS="$(LINKLIBS)" \
APPDIR="$(APPDIR)" EXTRAFLAGS="$(KDEFINE) $(EXTRAFLAGS)" $(BIN)
$(Q) echo $(BIN) > nuttx.manifest

它将接力棒交给架构 Makefile(例如 arch/arm/src/Makefile)。架构 Makefile 负责:

  1. 预处理链接脚本(.ld.ld.tmp,展开 C 预处理宏)
  2. 调用 LD 链接 HEAD_OBJ + STARTUP_OBJS + 所有库
  3. 可选生成 nuttx.hexnuttx.binnuttx.srecuImage、反汇编 .asm
  4. 调用 POSTBUILD 钩子(平台特定的后处理,如签名、打包)

架构链接器命令的核心(arch/arm/src/Makefile:211-248,简化):

1
2
3
# arch/arm/src/Makefile 中的最终链接命令(简化)
$(LD) $(LDFLAGS) $(EXTRA_LIBPATHS) -o $@ $(STARTUP_OBJS) $(HEAD_OBJ) \
--start-group $(LINKLIBS) --end-group $(EXTRA_OBJS) $(LDLIBS)

LINKLIBS 是从 NUTTXLIBS 中去掉了 staging/ 前缀的结果(Unix.mk:135)。因为链接时已经通过 -L 指定了 staging/ 路径。

上面描述的是 FLAT 模式下的编译流程——所有代码链接成一个二进制。但 pass1pass2 的定义,以及 NUTTXLIBSUSERLIBS 的成员列表,在不同构建模式下完全不同。这正是下一节要拆解的:NuttX 如何用同一套 Makefile 支持三种截然不同的内存隔离模型。


7. 三种构建模式:FlatLibs.mk / ProtectedLibs.mk / KernelLibs.mk

构建模式的选择由 Kconfig 的 CONFIG_BUILD_FLATCONFIG_BUILD_PROTECTEDCONFIG_BUILD_KERNEL 决定。在 Unix.mk:124-130

1
2
3
4
5
6
7
ifeq ($(CONFIG_BUILD_PROTECTED),y)
include tools/ProtectedLibs.mk
else ifeq ($(CONFIG_BUILD_KERNEL),y)
include tools/KernelLibs.mk
else
include tools/FlatLibs.mk
endif

7.1 FLAT 模式(FlatLibs.mk)

FLAT 模式最简单——所有代码链接到一个 nuttx 二进制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# tools/FlatLibs.mk:30-158
NUTTXLIBS = staging$(DELIM)libsched$(LIBEXT)
USERLIBS =

NUTTXLIBS += staging$(DELIM)libdrivers$(LIBEXT)
NUTTXLIBS += staging$(DELIM)libboards$(LIBEXT)
NUTTXLIBS += staging$(DELIM)libc$(LIBEXT)
NUTTXLIBS += staging$(DELIM)libmm$(LIBEXT)
NUTTXLIBS += staging$(DELIM)libarch$(LIBEXT)

ifeq ($(CONFIG_NET),y)
NUTTXLIBS += staging$(DELIM)libnet$(LIBEXT)
endif
ifeq ($(CONFIG_CRYPTO),y)
NUTTXLIBS += staging$(DELIM)libcrypto$(LIBEXT)
endif
# ... CONFIG_LIB_BUILTIN, CONFIG_LIBM, CONFIG_HAVE_CXX, CONFIG_NX,
# CONFIG_AUDIO, CONFIG_VIDEO, CONFIG_WIRELESS, CONFIG_LIBDSP,
# CONFIG_OPENAMP, CONFIG_ARCH_BOARD_COMMON conditionals follow ...
NUTTXLIBS += staging$(DELIM)libfs$(LIBEXT)
EXPORTLIBS = $(NUTTXLIBS)

所有库都在 NUTTXLIBS 中,USERLIBS 为空。EXPORTLIBS 包含全部库给 make export 做外部打包。

库的排列顺序是精心设计的——链接顺序影响符号解析(GNU LD 默认从左到右解析符号,后面的库可以引用前面的符号)。libsched 必须在最前,因为它包含 nx_start() 启动函数所需的核心符号。

7.2 PROTECTED 模式(ProtectedLibs.mk)

PROTECTED 模式区分内核空间和用户空间:

  • NUTTXLIBS(内核):libschedlibdriverslibboardslibstubs(syscall stub)、libkc(内核 C 库)、libkmm(内核内存管理)、libkarch(内核架构)
  • USERLIBS(用户):libproxies(syscall 代理)、libclibmmlibarchlibxxlibapps
  • **EXPORTLIBS = $(USERLIBS)**:只导出用户库

注意 libc vs libkc——同一个 libs/libc 编译两次:

  • 内核版(libkc):带 EXTRAFLAGS="$(KDEFINE) $(EXTRAFLAGS)"(即 -D__KERNEL__
  • 用户版(libc):不带 KDEFINE

内核和用户代码共享大量源代码,但通过 #ifdef __KERNEL__ 分叉行为。例如内存分配——内核使用 kmalloc(),用户使用 malloc(),虽然底层都调用同一个 mm_malloc()

7.3 KERNEL 模式(KernelLibs.mk)

KERNEL 模式类似 PROTECTED,但不编译任何用户空间应用:

  • NUTTXLIBS:仅内核库(同 PROTECTED 的内核部分)
  • USERLIBS:空
  • **EXPORTLIBS = $(USERLIBS)**:空

用户程序作为独立的 ELF 文件存在,由内核通过 ELF loader 加载到用户地址空间执行。构建系统只负责内核本身的编译。

7.4 KDEFINE:内核编译标志

KDEFINE 定义在 Config.mk:126

1
KDEFINE ?= ${DEFINE_PREFIX}__KERNEL__

DEFINE_PREFIX 通过调用编译器的探测确定——GCC 上是 -D,ZDS-II 上是 -define=,等等。最终 KDEFINE 通常是 -D__KERNEL__

LibTargets.mk 中,内核库的编译规则显式传递这个标志:

1
2
3
# tools/LibTargets.mk:34-35 (libkc 的内核编译)
libs$(DELIM)libc$(DELIM)libkc$(LIBEXT): pass2dep
$(Q) $(MAKE) -C libs$(DELIM)libc libkc$(LIBEXT) BINDIR=kbin EXTRAFLAGS="$(KDEFINE) $(EXTRAFLAGS)"

而用户库不带:

1
2
3
4
5
6
7
# tools/LibTargets.mk:191-196 (libc 的用户编译)
ifeq ($(CONFIG_BUILD_FLAT),y)
libs$(DELIM)libc$(DELIM)libc$(LIBEXT): pass2dep
else
libs$(DELIM)libc$(DELIM)libc$(LIBEXT): pass1dep
endif
$(Q) $(MAKE) -C libs$(DELIM)libc libc$(LIBEXT) EXTRAFLAGS="$(EXTRAFLAGS)"

也注意 BINDIR=kbin——内核版输出到 libs/libc/kbin/,用户版输出到 libs/libc/bin/,避免编译产物互相覆盖。

模式选好、库列表确定,现在来看看每个库的 Makefile 内部是如何组织的——所有的源文件列表、编译规则、归档命令是如何从一个 200 多行的模板中衍生出来的。


8. 库的内部结构:以 libc 为例

每个库的 Makefile 遵循统一模式。以 libs/libc/Makefile 为例。

8.1 源文件聚合

顶层 Makefile 通过 include 各个子目录的 Make.defs 来聚合源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# libs/libc/Makefile:25-74 (前15个子目录, 共50+个)
include aio/Make.defs
include assert/Make.defs
include audio/Make.defs
include builtin/Make.defs
include ctype/Make.defs
include dirent/Make.defs
include dlfcn/Make.defs
include errno/Make.defs
include eventfd/Make.defs
include fixedmath/Make.defs
include gdbstub/Make.defs
include grp/Make.defs
include gnssutils/Make.defs
include hex2bin/Make.defs
include inttypes/Make.defs

每个 */Make.defs 定义该子目录的源文件列表,用条件编译控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# libs/libc/stdio/Make.defs:22-37
CSRCS += lib_printf.c lib_sprintf.c lib_asprintf.c
CSRCS += lib_snprintf.c lib_vsprintf.c lib_vasprintf.c
CSRCS += lib_dprintf.c lib_vdprintf.c lib_vprintf.c
CSRCS += lib_perror.c lib_putchar.c lib_getchar.c lib_puts.c
CSRCS += lib_gets_s.c lib_gets.c lib_sscanf.c
CSRCS += lib_tempnam.c lib_tmpnam.c
CSRCS += lib_remove.c lib_renameat.c

ifeq ($(CONFIG_FILE_STREAM),y)
CSRCS += lib_fopen.c lib_fclose.c lib_fread.c lib_fseek.c
CSRCS += lib_fgetc.c lib_fputc.c lib_ftell.c lib_ftello.c
endif
ifneq ($(CONFIG_STDIO_DISABLE_BUFFERING),y)
CSRCS += lib_setvbuf.c lib_setbuf.c
endif

这些 CSRCS 变量在顶层被收集后,传给 VPATH 和构建规则。

8.2 Board.mk:板级库的通用模板

boards/Board.mk(136 行)是一个被所有板子 Makefile include 的公共模板。板子只需要定义 CSRCSASRCS,其余的逻辑(编译、归档、清理、依赖生成)都由 Board.mk 提供。

板子 Makefile 的典型写法(实际源码见 boards/arm/qemu/qemu-armv7a/src/Makefile:18-30):

1
2
3
4
5
6
include $(TOPDIR)/boards/Board.mk

CSRCS = qemu_boot.c qemu_irq.c
ifeq ($(CONFIG_BOARDCTL),y)
CSRCS += qemu_appinit.c
endif

Board.mk 使用 Config.mk 中定义的宏来编译:

1
2
3
# boards/Board.mk:99-100
$(COBJS) $(LINKOBJS): %$(OBJEXT): %.c
$(call COMPILE, $<, $@)

刚才多次用到的 COMPILEARCHIVEINSTALL_LIB 等宏,都来自 tools/Config.mk。这个 813 行的文件堪称 NuttX 构建系统的”宏工厂”——它不仅是编译基础设施,还处理了编译器差异、平台差异、路径格式等底层适配。下一节我们深入这个文件,看它是如何实现”一次定义,到处使用”的。


9. Config.mk:构建基础设施的宏工厂

9. Config.mk:构建基础设施的宏工厂

tools/Config.mk 是整个构建系统的”标准库”。除了前面介绍过的 COMPILEASSEMBLEINSTALL_LIB,还有几个关键设施。

9.1 编译器前缀检测

不同编译器使用不同的前置标志格式:

编译器 include 标志 define 标志
GCC/Clang -I / -isystem -D
ZDS-II -usrinc:'path' -define:
SDCC -I -D

NuttX 通过 tools/incdir.c(520 行)在运行期检测编译器的能力,并在 Config.mk 中保存结果:

1
2
3
4
5
6
7
# tools/Config.mk:731-748
ifeq ($(origin DEFINE_PREFIX),undefined)
DEFINE_PREFIX := $(subst X,,${shell $(DEFINE) "$(CC)" X 2> ${EMPTYFILE}})
endif
ifeq ($(origin INCDIR_PREFIX),undefined)
INCDIR_PREFIX := $(subst "X",,${shell $(INCDIR) "$(CC)" X 2> ${EMPTYFILE}})
endif

探测方式:用 $(CC) 尝试编译一个只定义宏 X 的单元,观察编译器输出的命令行格式。DEFINE_PREFIX 就是把 " -DX" 中的 -DX 提取出来。

实例:qemu-armv7a:knsh 编译时的前缀检测

qemu-armv7a:knsh 配置(CONFIG_ARCH_ARM=y, CONFIG_ARM_TOOLCHAIN_GNU_EABI=y)为例,编译器是 arm-none-eabi-gcc。运行 tools/incdir.sh "arm-none-eabi-gcc" X 会输出 " -I X",提取后得到 INCDIR_PREFIX=" -I ",于是 tools/Config.mk:758 中的 ARCHINCLUDES += ${INCSYSDIR_PREFIX}$(TOPDIR)$(DELIM)include 展开为 -I /workspace/nuttx/include

同样 tools/define.sh "arm-none-eabi-gcc" X 返回 " -D X"DEFINE_PREFIX=" -D "KDEFINE 最终为 -D__KERNEL__

9.2 路径分隔符和平台差异

Config.mk 对平台差异的抽象非常彻底。以 DELFILE(删除文件)为例:

1
2
3
4
5
6
7
8
9
10
# tools/Config.mk:513-525
ifeq ($(CONFIG_WINDOWS_NATIVE),y)
define DELFILE
$(foreach FILE, $(1), $(NEWLINE) $(Q) if exist $(FILE) (del /f /q $(FILE)))
endef
else
define DELFILE
$(Q) rm -f $1
endef
endif

Windows 原生环境使用 del /f /q,POSIX 环境使用 rm -f。类似的宏还有 DELDIRMOVEFILECOPYFILECATFILE 等,每个都有 Windows/POSIX 两套实现。

9.3 递归通配符和工具宏

RWILDCARD(递归通配符)是一个纯 Make 实现的 find

1
2
3
4
# tools/Config.mk:598-600
define RWILDCARD
$(foreach d,$(wildcard $1/*),$(call RWILDCARD,$d,$2)$(filter $(subst *,%,$2),$d))
endef

它递归遍历目录树,匹配文件名模式。与 $(shell find ...) 相比,这是纯 Make 实现,跨平台且不需要外部命令。

TESTANDREPLACEFILE 是另一个精巧的工具(Config.mk:692-716)——只有当新文件和旧文件不同时才替换,避免不必要的时间戳更新(防止触发不必要的重编译):

1
2
3
4
5
6
7
8
9
10
11
define TESTANDREPLACEFILE
if [ -f $2 ]; then \
if cmp -s $1 $2; then \
rm -f $1; \
else \
mv $1 $2; \
fi \
else \
mv $1 $2; \
fi
endef

TESTANDREPLACEFILE 展示了 Config.mk 的宏设计哲学——用简单的 conditional move 避免不必要的文件更新触发的级联重编译。下面我们将看到这种”避免不必要工作”的哲学如何延伸到更高级的场景:当需要在最终链接前先构建一部分代码时,NuttX 提供的 BUILD_2PASS 机制。


10. 两个 Pass 的高级场景:BUILD_2PASS

在某些场景下,NuttX 需要先构建一部分代码,然后用其产出来参与最终链接。这就是 CONFIG_BUILD_2PASS

1
2
3
4
5
6
7
8
9
10
11
# tools/Unix.mk:554-568
$(BIN): pass1 pass2
ifeq ($(CONFIG_BUILD_2PASS),y)
$(Q) if [ -z "$(CONFIG_PASS1_BUILDIR)" ]; then \
echo "ERROR: CONFIG_PASS1_BUILDIR not defined"; \
exit 1; \
fi
$(Q) $(MAKE) -C $(CONFIG_PASS1_BUILDIR) LINKLIBS="$(LINKLIBS)" \
USERLIBS="$(USERLIBS)" "$(CONFIG_PASS1_TARGET)"
endif
$(Q) $(MAKE) -C $(ARCH_SRC) ... $(BIN)

Pass1 的输出(CONFIG_PASS1_OBJECT)是一个额外的链接对象,在最终链接时作为 EXTRA_OBJS 加入:

1
2
3
4
# tools/Unix.mk:111-113
ifeq ($(CONFIG_BUILD_2PASS),y)
EXTRA_OBJS += $(CONFIG_PASS1_OBJECT)
endif

典型用例是 ROMFS 文件系统镜像——先在 pass1 中构建文件系统内容,生成一个 .o 文件(通过 xxd -i 将二进制数据转为 C 数组并编译),然后在 pass2 中将该对象链接进内核。

BUILD_2PASS 是针对 NuttX 内部元编程的需求。但当 NuttX 需要与外部世界交互时(例如作为 Zephyr 的子模块,或被裸机应用框架引用),构建系统必须提供一种方式”对外输出”。make export 就是为此而生。


11. make export:构建系统的对外接口

当 NuttX 与其他 RTOS 框架集成时(如 Zephyr 或 bare-metal 应用的构建系统),需要把编译好的库和头文件”导出”给外部使用。

1
2
3
# tools/Unix.mk:818-819
export: $(NUTTXLIBS)
$(Q) MAKE="${MAKE}" $(MKEXPORT) $(MKEXPORT_ARGS) -l "$(EXPORTLIBS)"

tools/mkexport.sh(557 行)负责打包:

  1. 创建 nuttx-export/ 目录
  2. 复制所有头文件(include/ 及架构相关头文件)
  3. 复制所有 EXPORTLIBS 中列出的库文件
  4. 复制链接脚本和构建配置
  5. 生成 scripts/Make.defs(工具链定义)供外部 Makefile 使用
  6. 打包为 nuttx-export.zip.tar.gz

导出的内容因构建模式而异——FLAT 模式导出全部,PROTECTED 模式只导出用户库(通过 EXPORTLIBS = $(USERLIBS))。

导出和构建都会产生大量中间文件。NuttX 提供了一套精细的多级清理机制,确保不会残留任何过期产物——这就是构建系统中的”垃圾回收”。


12. 清理体系

NuttX 的清理有四级:

目标 效果
make clean 删除 .o.anuttx 二进制、staging/、中间文件
make clean_context 删除 config.hversion.h、所有 dirlinks 和 context 产物
make distclean clean + clean_context + 删除 .configMake.defsdefconfig
make apps_clean / apps_distclean 仅清理 apps 目录

clean 的实现利用 SDIR_template 宏为每个已知目录生成对应的清理目标(Unix.mk:833):

1
$(foreach SDIR, $(CLEANDIRS), $(eval $(call SDIR_template,$(SDIR),clean)))

SDIR_template 定义在 Config.mk:725-729

1
2
3
4
define SDIR_template
$(1)_$(2):
+$(Q) $(MAKE) -C $(1) $(2) APPDIR="$(APPDIR)"
endef

这为每个目录(如 scheddriversfs 等)生成 sched_cleandrivers_clean 等目标,然后 subdir_clean 依赖所有这些目标。

至此,我们已经走完了 NuttX Make 构建系统的完整链路——从 configure.shmake clean。在结束之前,一个值得问的问题是:这套设计和 Linux Kernel 的 Kbuild 有何异同? 理解这个对比,能帮助有 Linux 内核经验的读者更快定位 NuttX 构建系统的独有特性。


13. 与 Linux Kernel 构建系统的比较

NuttX 的构建系统受到了 Linux Kernel Kbuild 的影响,但有很多独立的设计选择:

Aspect NuttX Linux Kernel
Kconfig toolchain Kconfig (same kconfig-frontends / kconfiglib) Kconfig
Build engine GNU Make (recursive) GNU Make (non-recursive, single pass)
Dependency generation Custom mkdeps tool gcc -M generates .cmd files
Config → C header mkconfig.c (custom) scripts/basic/fixdep + auto.conf
Cross-platform Built-in Windows native (Win.mk) Relies on Cygwin/WSL
Subdirectory inclusion Make.defs aggregating CSRCS Makefile + Kbuild + obj-y
Library model Each subsystem is .a static archive Direct object file linking (thin archive)

NuttX 独特的设计在于:

  • 通用名 + symlink 解决硬件碎片化(Linux 用 arch/ + mach- + plat- 的目录结构)
  • 自定义 mkdeps 解决多编译器多平台依赖生成
  • 双版本编译(libc vs libkc)解决了同源码内核/用户空间的差异编译

14. 总结

NuttX 的 Make 构建系统通过以下关键设计解决了嵌入式 OS 构建的核心挑战:

  1. 三层抽象Makefile(路由)→ Unix.mk(编排)→ Config.mk(基础设施),职责清晰分离
  2. 通用名 symlinkinclude/archinclude/arch/boardarch/<arch>/src/chip 三个符号链接用一个构建脚本覆盖所有硬件
  3. .config 双重身份:既是 Make 变量源,又通过 mkconfig 转为 C 头文件,消除了配置文件格式的分裂
  4. 双 pass 构建pass1(用户)和 pass2(内核)的分离,用 KDEFINE = -D__KERNEL__ 控制同源码的不同编译行为
  5. 条件驱动Directories.mkFlatLibs.mk 等文件用 ifeq ($(CONFIG_XXX),y) 精确控制哪些代码参与编译,未启用的特性零编译成本
  6. 自研工具链mkdepsincdirmkconfigdefine.sh 等 host 工具将编译器差异封装在统一的接口后

理解这套构建系统,是在 NuttX 上做移植、调试编译问题、或为此系统开发工具的基础。当你下一次看到 CPP: arm_head.S -> arm_head.o 那行输出时,你就知道背后经过了哪些文件、哪些转换。


15. 参考文件索引

文件 功能 核心行号
nuttx/Makefile 顶层路由入口 :25-42
tools/Unix.mk Unix 构建编排 :1-902
tools/Win.mk Windows 构建编排 对应文件
tools/Config.mk 构建基础设施宏 :1-813
tools/configure.sh 配置脚本 :1-362
tools/process_config.sh defconfig 预处理 :1-119
tools/sethost.sh 宿主环境设置 :1-235
tools/mkconfig.c .config → C 头文件 :1-121
tools/cfgdefine.c 配置定义的生成 :49-85, :294-356
tools/mkdeps.c 依赖生成工具 :1-1020
tools/incdir.c 编译器前缀检测 :1-520
tools/incdir.sh 编译器 include 前缀探测脚本 shell 版本
tools/define.sh 编译器 define 前缀探测脚本 shell 版本
tools/link.sh POSIX 符号链接包装
tools/copydir.sh 目录复制(MSYS/Cygwin)
tools/mkexport.sh 导出打包 :1-557
tools/Directories.mk 构建目录列表 :1-199
tools/FlatLibs.mk FLAT 模式库列表 :1-158
tools/ProtectedLibs.mk PROTECTED 模式库列表 :1-159
tools/KernelLibs.mk KERNEL 模式库列表 :1-145
tools/LibTargets.mk 每个库的构建规则 :1-257
tools/Makefile.host host 工具构建 :1-296
boards/Board.mk 板级库通用模板 :1-136
arch/arm/src/Makefile ARM 架构链接器 :1-301
boards/arm/qemu/qemu-armv7a/scripts/Make.defs 工具链定义示例 :1-42
libs/libc/Makefile C 库顶层 Makefile :25-74
libs/libc/stdio/Make.defs C 库 stdio 源文件定义 :22-37
Documentation/implementation/make_build_system.rst 官方构建系统文档 :1-607