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. 配置之源:defconfig 与 configure.sh
构建 NuttX 的第一步永远是配置:
1 | $ cd nuttx |
这条命令背后发生了什么?入口是 tools/configure.sh,一个 362 行的 bash 脚本。
2.1 参数解析
configure.sh 接受 <board-name>:<config-name> 这种紧凑格式。它首先解析出 boarddir 和 configdir:
1 | # tools/configure.sh:149-155 |
然后构造配置路径:configpath 首先尝试 boards/<arch>/<chip>/<board>/configs/<config>。
1 | # tools/configure.sh:157 |
对于 qemu-armv7a:knsh,实际路径解析为:
1 | boards/arm/qemu/qemu-armv7a/configs/knsh |
如果此路径不存在,脚本依次尝试自定义路径(相对 TOPDIR)、绝对路径。
2.2 寻找 Make.defs——工具链的定义者
Make.defs 是构建系统的核心文件,它定义了交叉编译器、编译选项和链接脚本。configure.sh 按以下顺序搜索:
1 | # tools/configure.sh:174-195 |
这五个位置对应了开发板目录的常见布局变化——有些板子在 configs/ 下有独立的 Make.defs,有些则共享 scripts/ 下的通用文件。
一旦找到,脚本用符号链接将其安装到 NuttX 根目录:
1 | # tools/configure.sh:242-243 |
于是 $(TOPDIR)/Make.defs 指向了:
1 | boards/arm/qemu/qemu-armv7a/scripts/Make.defs |
打开这个文件(boards/arm/qemu/qemu-armv7a/scripts/Make.defs:23-25),它自身又引入三条关键链:
1 | include $(TOPDIR)/.config # Kconfig 输出(此时尚未生成!) |
注意这里的一个”先有鸡还是先有蛋”问题: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 | tools/configure.sh qemu-armv7a:knsh |
关键理解:**步骤 2 和步骤 5 是两种完全不同的”展开”**。process_config.sh 做的是纯文本级别的 #include 拼接——它不认识 Kconfig 语义,只管把一个 defconfig 及其 #include 的文件拼成一个大文件。而 Kconfig 引擎做的是语义级别的解析——select、depends on、default 的逻辑都在这一层。两者共同完成从板级最小配置到完整 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 | # tools/configure.sh:244-246 |
这里指定了五个 include 搜索路径:
boards/arm/qemu/common/configs(芯片家族的公共配置)boards/arm/qemu/qemu-armv7a/configs/common(板级公共配置)${configpath}即boards/arm/qemu/qemu-armv7a/configs/knsh(当前配置目录)../apps(apps 目录中的配置片段)../nuttx-apps(备选 apps 路径)
process_config.sh 做了什么?看核心函数 process_file()(tools/process_config.sh:22-68):
1 | process_file() { |
经过 process_config.sh 处理后,你得到的是一个文本级展开但未经 Kconfig 语义解析的中间产物——.config 包含了 defconfig 及其 #include 的所有片段拼接在一起,重复项以后者覆盖。但它还不知道 select、depends on、default 的含义。一个例子:如果 defconfig 写了 CONFIG_ARCH_ARM=y,它背后 select 出的 CONFIG_ARCH_HAVE_xxx 系列此时还不在 .config 里——得等到下一步 Kconfig 引擎(make olddefconfig)来补全。
最后,configure.sh 将原始 defconfig 备份到 $(TOPDIR)/defconfig,供后续检测配置变更:
1 | # tools/configure.sh:247-248 |
2.5 宿主环境设置与 Kconfig 语义展开:sethost.sh
上一步 process_config.sh 得到的 .config 只是一个文本拼接产物——select、depends on、default 这些 Kconfig 语义都还没生效。真正完成这一步的是 sethost.sh,它做了两件事。
第一,检测宿主环境。 NuttX 支持在 Linux、macOS、Windows、Cygwin、MSYS 等不同宿主上编译。Windows 原生环境需要反斜杠路径、del 代替 rm;Cygwin 需要路径转换。sethost.sh 通过 uname -s 检测当前 OS,然后调用 kconfig-tweak 写入对应的 CONFIG_HOST_LINUX=y 或 CONFIG_HOST_MACOS=y 或 CONFIG_HOST_WINDOWS=y。这些选项后续会影响 Config.mk 中路径分隔符、文件操作命令的选择。
第二,也是最关键的一步——调用 make olddefconfig。 这是整条配置流水线中第一次也是唯一一次运行 Kconfig 引擎。它以当前 .config(process_config 拼出来的 + sethost 写入的宿主信息)作为”旧配置”,走完整个 Kconfig 树的语义解析:
1 | .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 | /* tools/cfgdefine.c:294-356, generate_definitions() 的主循环体 */ |
即: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 | /* tools/cfgdefine.c:49-85, dequote_list[] 白名单 */ |
这些值会被 dequote_value() 函数去掉双引号再输出。
mkconfig 还在生成的 config.h 末尾添加了安全检查。对应 tools/mkconfig.c:96-112 中的 printf 调用:
1 | /* tools/mkconfig.c:96-112, mkconfig 主函数中写入 config.h 尾巴的部分 */ |
如果 defconfig 没有显式定义 RAM_END,它会自动计算。这是一个防御性设计。
3.3 配置脏标记(dirty tracking)
注:本节描述的是一个辅助性审计机制,不参与编译决策逻辑。急于理解构建流程的读者可以跳过,不影响后续章节的阅读。
你开发板子在跑,一切正常。半年后客户报了一个 bug,你想复现当年的固件。你从 Git 里找到 qemu-armv7a:knsh 的 defconfig,configure.sh + make,烧进去——结果行为不一样。因为你半年前跑 make menuconfig 改过一个选项,但没保存。
这就是 dirty tracking 要解决的问题:在固件里留下一个”免责声明”,告诉拿到这个固件的人:配置被人改过,别只看 defconfig 名字。
具体做法是,每次 make 生成 config.h 之前,构建系统比较当前 .config 和 configure.sh 当初保存的原始快照:
1 | # tools/Unix.mk:266-276 |
步骤:排除 CONFIG_BASE_DEFCONFIG 行生成临时文件 → 与 .config.orig 比较 → 不同则追加 -dirty,相同则去掉 -dirty → 覆盖 .config → 重新生成 config.h。
最终效果是,CONFIG_BASE_DEFCONFIG 的值会被编译进固件,并通过 /proc/version 暴露到运行时:
1 | nsh> cat /proc/version |
这个 -dirty 就是一条审计线索:
- 没有
-dirty→ 这个固件可以用对应的 defconfig 精确复现 - 有
-dirty→ 配置被改过,想复现必须找作者要.config,或者让他make savedefconfig提交
换句话说,dirty 不是技术机制的参与者——它不参与编译决策。它只是一个”路标”,告诉后来人这条路走歪了,要复现请留神。
至此,.config 已生成、Make.defs 已就位、config.h 已备妥。但谁来使用它们来驱动实际的编译?下一节将看到,NuttX 用了一个极其简洁的路由机制,将 .config 和 Make.defs 组装成一套可工作的编译流程。
4. 构建入口:Makefile 的路由机制
4.1 顶层 Makefile
顶层 Makefile(nuttx/Makefile:25-42)极其简洁——只做两件事:
1 | # nuttx/Makefile:25-42 |
如果 .config 不存在,打印帮助信息。如果存在,include .config 然后根据 CONFIG_WINDOWS_NATIVE 路由到 tools/Win.mk 或 tools/Unix.mk。对于 99% 的场景(Linux、macOS、WSL、Cygwin、MSYS),使用的是 Unix.mk。
4.2 Unix.mk 的 include 链
Unix.mk 被 include 后,会触发一系列 Make 变量和规则的求值。我们先梳理它的 include 链(按代码顺序):
1 | Makefile |
这条链的求值顺序至关重要:Unix.mk 首先 include $(TOPDIR)/Make.defs,而 Make.defs 内部又依次 include .config、Config.mk 和 Toolchain.defs。这意味着 当 Unix.mk 执行到 include tools/Directories.mk 时,所有 Kconfig 变量(CONFIG_NET、CONFIG_BUILD_FLAT 等)和所有编译宏(COMPILE、ARCHIVE 等)都已经被定义了。Directories.mk 利用这些变量来决定哪些目录参与编译,{Flat,Protected,Kernel}Libs.mk 利用它们来决定编译哪些库。
Config.mk 是编译基础设施的核心。它定义了所有你需要的构建宏:
- 编译宏:
COMPILE、COMPILEXX、ASSEMBLE、PREPROCESS、ARCHIVE、PRELINK - 依赖规则:
%.ddc、%.dds、%.ddp的 pattern rules - 路径工具:
INCDIR、DEFINE(调用tools/incdir和tools/define获取编译器特定的 flag 格式) - 平台适配:
HOSTCC、HOSTCFLAGS、DELIM(路径分隔符/或\)、KDEFINE = -D__KERNEL__ - 工具选择:
MKDEP、DIRLINK(link.sh或copydir.sh,取决于是否支持符号链接)
Config.mk 中的 COMPILE 宏(tools/Config.mk:304-308)展示了宏如何抽象编译:
1 | define COMPILE |
$($(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 | # 说明:这是演示坏设计的示意代码,不存在于 NuttX 仓库中 |
这是不可维护的。
5.2 解决方案:通用名 + 符号链接
NuttX 的答案是定义一个”通用名”命名空间,然后在 context 阶段把真实目录 symlink 进来。下表以 qemu-armv7a 为例展示映射关系:
1 | Generic Name Real Path (qemu-armv7a) |
这些 symlink 是在 context 目标的 .dirlinks 子目标中建立的(Unix.mk:295-369):
1 | # tools/Unix.mk:297-299 |
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 | # tools/Unix.mk:318-335 |
有 common 目录时:arch/arm/src/board → common/,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 | # tools/Unix.mk:84 |
如果用户没有 external/ 目录,EXTERNALDIR 被设为 dummy,Kconfig 会 include 一个空的 dummy/Kconfig。
同样的策略用于芯片和板子的 Kconfig:
- 使用官方芯片时,
CHIP_KCONFIG指向arch/dummy/dummy_kconfig(空文件) - 使用自定义芯片时,指向用户提供的真实
Kconfig
符号链接一旦就位,编译环境就真正”通用化”了。接下来的问题是:编译本身是如何排程的? 先做什么后做什么?依赖如何在 100+ 个目录间逐层传递?下面将看到 NuttX 构建的时间线是如何精确编排的。
6. 构建的三步舞:context → pass1/pass2 → link
完整的 make 构建流程由依赖关系驱动。下面是核心目标的依赖链:
1 | make |
这条链的实际执行顺序是:Make 首先解析依赖图,发现 $(BIN) 依赖 pass1 和 pass2,而这两个又依赖 pass1dep 和 pass2dep,后者又依赖 context。因此 context 最先执行——先建 symlink、生成 config.h、准备好所有环境。然后按 pass1dep → pass1、pass2dep → pass2 的顺序编译和链接。最后才执行 $(BIN) 的链接规则,生成 nuttx ELF 甚至 .hex、.bin 等附加格式。
6.1 Context 阶段:环境就绪
context 目标(Unix.mk:463)负责准备好一切”一次性”的东西:
- 构建 host 工具:
tools/incdir$(HOSTEXEEXT)(编译器 include 前缀检测工具) - 生成 config.h:调用
tools/mkconfig将.config转为 C 头文件 - 生成 version.h:调用
tools/mkversion从.version(Git 导出版本号)生成版本信息 - 建立 dirlinks:所有通用名符号链接
- 可选头文件拷贝:
math.h、float.h、stdarg.h、setjmp.h等——如果未使用工具链的默认头文件,则用 NuttX 的替代 - 递归 context:在所有
CONTEXTDIRS目录中执行make context
Directories.mk(tools/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 | # tools/Unix.mk:673-681 |
tools/mkdeps.c(1020 行)是一个完整的依赖生成器,支持 --dep-path(搜索路径)、--obj-path(输出目录映射)、--obj-suffix(对象文件扩展名)、--winnative(Windows 原生模式)。
依赖生成的 pattern rules 定义在 Config.mk:228-241:
1 | # tools/Config.mk:228-241 |
每个 .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 | # tools/LibTargets.mk:191-199 |
每个库的构建分两步:
- 编译步骤:递归
make -C <libdir> libX$(LIBEXT)——在库的源目录中编译所有源文件并归档 - 安装步骤:将编译好的
.a文件 install 到staging/目录(使用install -m 0644)
INSTALL_LIB 宏(Config.mk:434-438):
1 | define INSTALL_LIB |
为什么用一个 staging 目录? 最终链接时需要所有库的路径。与其记住 sched/libsched.a、mm/libmm.a、fs/libfs.a 等散布在各处的路径,不如统一安装到 staging/。链接命令只需引用 staging/libsched.a staging/libmm.a staging/libfs.a ...。
6.4 $(BIN):最终链接
所有库编译完毕后,$(BIN): pass1 pass2 执行最终链接(Unix.mk:553-604):
1 | $(BIN): pass1 pass2 |
它将接力棒交给架构 Makefile(例如 arch/arm/src/Makefile)。架构 Makefile 负责:
- 预处理链接脚本(
.ld→.ld.tmp,展开 C 预处理宏) - 调用 LD 链接
HEAD_OBJ + STARTUP_OBJS + 所有库 - 可选生成
nuttx.hex、nuttx.bin、nuttx.srec、uImage、反汇编.asm - 调用
POSTBUILD钩子(平台特定的后处理,如签名、打包)
架构链接器命令的核心(arch/arm/src/Makefile:211-248,简化):
1 | # arch/arm/src/Makefile 中的最终链接命令(简化) |
LINKLIBS 是从 NUTTXLIBS 中去掉了 staging/ 前缀的结果(Unix.mk:135)。因为链接时已经通过 -L 指定了 staging/ 路径。
上面描述的是 FLAT 模式下的编译流程——所有代码链接成一个二进制。但 pass1 和 pass2 的定义,以及 NUTTXLIBS 和 USERLIBS 的成员列表,在不同构建模式下完全不同。这正是下一节要拆解的:NuttX 如何用同一套 Makefile 支持三种截然不同的内存隔离模型。
7. 三种构建模式:FlatLibs.mk / ProtectedLibs.mk / KernelLibs.mk
构建模式的选择由 Kconfig 的 CONFIG_BUILD_FLAT、CONFIG_BUILD_PROTECTED、CONFIG_BUILD_KERNEL 决定。在 Unix.mk:124-130:
1 | ifeq ($(CONFIG_BUILD_PROTECTED),y) |
7.1 FLAT 模式(FlatLibs.mk)
FLAT 模式最简单——所有代码链接到一个 nuttx 二进制:
1 | # tools/FlatLibs.mk:30-158 |
所有库都在 NUTTXLIBS 中,USERLIBS 为空。EXPORTLIBS 包含全部库给 make export 做外部打包。
库的排列顺序是精心设计的——链接顺序影响符号解析(GNU LD 默认从左到右解析符号,后面的库可以引用前面的符号)。libsched 必须在最前,因为它包含 nx_start() 启动函数所需的核心符号。
7.2 PROTECTED 模式(ProtectedLibs.mk)
PROTECTED 模式区分内核空间和用户空间:
- NUTTXLIBS(内核):
libsched、libdrivers、libboards、libstubs(syscall stub)、libkc(内核 C 库)、libkmm(内核内存管理)、libkarch(内核架构) - USERLIBS(用户):
libproxies(syscall 代理)、libc、libmm、libarch、libxx、libapps - **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 | # tools/LibTargets.mk:34-35 (libkc 的内核编译) |
而用户库不带:
1 | # tools/LibTargets.mk:191-196 (libc 的用户编译) |
也注意 BINDIR=kbin——内核版输出到 libs/libc/kbin/,用户版输出到 libs/libc/bin/,避免编译产物互相覆盖。
模式选好、库列表确定,现在来看看每个库的 Makefile 内部是如何组织的——所有的源文件列表、编译规则、归档命令是如何从一个 200 多行的模板中衍生出来的。
8. 库的内部结构:以 libc 为例
每个库的 Makefile 遵循统一模式。以 libs/libc/Makefile 为例。
8.1 源文件聚合
顶层 Makefile 通过 include 各个子目录的 Make.defs 来聚合源文件:
1 | # libs/libc/Makefile:25-74 (前15个子目录, 共50+个) |
每个 */Make.defs 定义该子目录的源文件列表,用条件编译控制:
1 | # libs/libc/stdio/Make.defs:22-37 |
这些 CSRCS 变量在顶层被收集后,传给 VPATH 和构建规则。
8.2 Board.mk:板级库的通用模板
boards/Board.mk(136 行)是一个被所有板子 Makefile include 的公共模板。板子只需要定义 CSRCS、ASRCS,其余的逻辑(编译、归档、清理、依赖生成)都由 Board.mk 提供。
板子 Makefile 的典型写法(实际源码见 boards/arm/qemu/qemu-armv7a/src/Makefile:18-30):
1 | include $(TOPDIR)/boards/Board.mk |
Board.mk 使用 Config.mk 中定义的宏来编译:
1 | # boards/Board.mk:99-100 |
刚才多次用到的 COMPILE、ARCHIVE、INSTALL_LIB 等宏,都来自 tools/Config.mk。这个 813 行的文件堪称 NuttX 构建系统的”宏工厂”——它不仅是编译基础设施,还处理了编译器差异、平台差异、路径格式等底层适配。下一节我们深入这个文件,看它是如何实现”一次定义,到处使用”的。
9. Config.mk:构建基础设施的宏工厂
9. Config.mk:构建基础设施的宏工厂
tools/Config.mk 是整个构建系统的”标准库”。除了前面介绍过的 COMPILE、ASSEMBLE、INSTALL_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 | # tools/Config.mk:731-748 |
探测方式:用 $(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 | # tools/Config.mk:513-525 |
Windows 原生环境使用 del /f /q,POSIX 环境使用 rm -f。类似的宏还有 DELDIR、MOVEFILE、COPYFILE、CATFILE 等,每个都有 Windows/POSIX 两套实现。
9.3 递归通配符和工具宏
RWILDCARD(递归通配符)是一个纯 Make 实现的 find:
1 | # tools/Config.mk:598-600 |
它递归遍历目录树,匹配文件名模式。与 $(shell find ...) 相比,这是纯 Make 实现,跨平台且不需要外部命令。
TESTANDREPLACEFILE 是另一个精巧的工具(Config.mk:692-716)——只有当新文件和旧文件不同时才替换,避免不必要的时间戳更新(防止触发不必要的重编译):
1 | define TESTANDREPLACEFILE |
TESTANDREPLACEFILE 展示了 Config.mk 的宏设计哲学——用简单的 conditional move 避免不必要的文件更新触发的级联重编译。下面我们将看到这种”避免不必要工作”的哲学如何延伸到更高级的场景:当需要在最终链接前先构建一部分代码时,NuttX 提供的 BUILD_2PASS 机制。
10. 两个 Pass 的高级场景:BUILD_2PASS
在某些场景下,NuttX 需要先构建一部分代码,然后用其产出来参与最终链接。这就是 CONFIG_BUILD_2PASS:
1 | # tools/Unix.mk:554-568 |
Pass1 的输出(CONFIG_PASS1_OBJECT)是一个额外的链接对象,在最终链接时作为 EXTRA_OBJS 加入:
1 | # tools/Unix.mk:111-113 |
典型用例是 ROMFS 文件系统镜像——先在 pass1 中构建文件系统内容,生成一个 .o 文件(通过 xxd -i 将二进制数据转为 C 数组并编译),然后在 pass2 中将该对象链接进内核。
BUILD_2PASS 是针对 NuttX 内部元编程的需求。但当 NuttX 需要与外部世界交互时(例如作为 Zephyr 的子模块,或被裸机应用框架引用),构建系统必须提供一种方式”对外输出”。make export 就是为此而生。
11. make export:构建系统的对外接口
当 NuttX 与其他 RTOS 框架集成时(如 Zephyr 或 bare-metal 应用的构建系统),需要把编译好的库和头文件”导出”给外部使用。
1 | # tools/Unix.mk:818-819 |
tools/mkexport.sh(557 行)负责打包:
- 创建
nuttx-export/目录 - 复制所有头文件(
include/及架构相关头文件) - 复制所有
EXPORTLIBS中列出的库文件 - 复制链接脚本和构建配置
- 生成
scripts/Make.defs(工具链定义)供外部 Makefile 使用 - 打包为
nuttx-export.zip或.tar.gz
导出的内容因构建模式而异——FLAT 模式导出全部,PROTECTED 模式只导出用户库(通过 EXPORTLIBS = $(USERLIBS))。
导出和构建都会产生大量中间文件。NuttX 提供了一套精细的多级清理机制,确保不会残留任何过期产物——这就是构建系统中的”垃圾回收”。
12. 清理体系
NuttX 的清理有四级:
| 目标 | 效果 |
|---|---|
make clean |
删除 .o、.a、nuttx 二进制、staging/、中间文件 |
make clean_context |
删除 config.h、version.h、所有 dirlinks 和 context 产物 |
make distclean |
clean + clean_context + 删除 .config、Make.defs、defconfig |
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 | define SDIR_template |
这为每个目录(如 sched、drivers、fs 等)生成 sched_clean、drivers_clean 等目标,然后 subdir_clean 依赖所有这些目标。
至此,我们已经走完了 NuttX Make 构建系统的完整链路——从 configure.sh 到 make 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 构建的核心挑战:
- 三层抽象:
Makefile(路由)→Unix.mk(编排)→Config.mk(基础设施),职责清晰分离 - 通用名 symlink:
include/arch、include/arch/board、arch/<arch>/src/chip三个符号链接用一个构建脚本覆盖所有硬件 .config双重身份:既是 Make 变量源,又通过mkconfig转为 C 头文件,消除了配置文件格式的分裂- 双 pass 构建:
pass1(用户)和pass2(内核)的分离,用KDEFINE = -D__KERNEL__控制同源码的不同编译行为 - 条件驱动:
Directories.mk和FlatLibs.mk等文件用ifeq ($(CONFIG_XXX),y)精确控制哪些代码参与编译,未启用的特性零编译成本 - 自研工具链:
mkdeps、incdir、mkconfig、define.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 |