laze 元构建系统:contexts、modules 与 builders
本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 3 篇。上一篇:环境搭建:Rust 工具链与 laze 构建系统,下一篇:第一个应用:从 minimal 到 hello-world,目录见文末。
laze 元构建系统:contexts、modules 与 builders
导读
上一篇把 laze 当成「装好就行」的工具,这一篇要把它拆开。Ariel OS 仓库根目录的 laze-project.yml 有两千五百多行,第一次打开会懵:这既不是 Cargo.toml,也不是 Makefile,而是一份描述「板卡 × 芯片 × 功能 × 工具链」四维矩阵的配置。看不懂它,你就没法回答下面这些实际问题:
- 为什么
-b espressif-esp32-c6-devkitc-1能编译出 RISC-V 镜像,而不是 ARM? - 为什么加了
selects: [network],Wi-Fi 代码就自动进来了? - 为什么
network-config-ipv4-static必须写在network前面? - 为什么同一个
flash任务,在 nRF 上走 probe-rs,在 ESP 上走 espflash? - 为什么应用要同时写
laze.yml和Cargo.toml,只写一个不行吗?
这一篇会按「先会用、再拆解、后排错」的顺序回答。所有 YAML 片段都来自仓库原文,不做简化改写。
为什么需要元构建系统
Cargo 的 feature 模型,撑不起板卡矩阵
Cargo 的 feature 是并集语义:多个依赖同时要求 foo,最终只开一次;feature 只能加,不能在依赖树里「减」。这套模型适合库,不适合系统配置。举个 Ariel OS 里的真实例子:日志传输有四种(debug channel、USB CDC-ACM、UART、自定义),它们互斥,同一时间只能选一个。用 Cargo feature 表达,你只能写四个 feature 然后在代码里 compile_error! 互斥检查;用户选错时得到的是编译错误,而不是「自动挑一个可用的」。
再比如「这块板有没有 LED」。Cargo 里没有「板」的概念,你只能给每块板定义一个 feature,然后在代码里 #[cfg(feature = "board_x")]。板子一多,feature 爆炸,且无法回答「哪些应用能在这块板上编」这个问题。
laze 的三个核心概念,正好补上这三块:
- context:配置的继承链,回答「这块板是什么芯片、什么架构、flash 多大、用什么 target」;
- module:带依赖、冲突、能力声明的功能单元,回答「开这个功能会连带开什么、和什么互斥、需要目标具备什么能力」;
- builder:可被
-b选中的顶层 context,回答「给哪块板编」。
三个概念的关系
用一句话概括:builder 是 context 的一种,context 通过 selects 拉起 module,module 通过 env 往 context 里注入变量,最终这些变量变成 cargo 的命令行参数和环境变量。
-b espressif-esp32-c6-devkitc-1
|
v
builder context (SBD target)
| parent
v
esp32-c6-wroom-1 -> esp32c6 -> esp -> ariel-os -> default
| | | |
| | | +-- selects: executor-default,
| | | ?logging, ?lto, ?stable ...
| | +-- selects: ?espflash, provides has_wifi_esp ...
| +-- env: RUSTC_TARGET, PROBE_RS_CHIP, PROBE_RS_PROTOCOL
+-- env: CHIP_NVM_SIZE_BYTES ...
每个 context 都可以有 selects、provides、env、tasks、rules。子 context 继承父 context 的一切,并按规则合并:列表型变量追加,标量覆盖,max() 取最大。
为什么不自己写 xtask
我见过不少团队用 xtask 替代这类工具,最后都退化成「一个只有作者看得懂的 shell 脚本集合」。原因不是能力不足,而是这件事的复杂度被低估了:你需要 context 继承、模块依赖求解、冲突检测、可选依赖、按目标裁剪、变量插值、任务图、增量构建、并行调度。laze 把这些做完了,代价只是多学一个 YAML schema。
反过来说,如果你的项目只有一块板、一套 feature,laze 是过度设计,纯 Cargo 加 .cargo/config.toml 就够。Ariel OS 面对的是几十块板、五种架构、上百个模块,不用元构建系统根本维持不住。
代码案例:一个应用的完整构建声明
最小声明:两份文件缺一不可
以 examples/blinky 为例。laze.yml:
apps:
- name: blinky
selects:
- has_leds
Cargo.toml:
[dependencies]
ariel-os = { path = "../../src/ariel-os", features = ["time"] }
ariel-os-boards = { path = "../../src/ariel-os-boards" }
两份文件分工明确:laze.yml 告诉构建系统「这个应用需要什么能力、允许在哪些目标上编」;Cargo.toml 告诉 cargo「依赖哪些 crate、开哪些 Cargo feature」。has_leds 是 laze 模块,它声明「这块板有 LED」这个能力;如果目标板没有这个能力,laze 会直接拒绝编译,而不是等到 Rust 代码里报「找不到 LedPeripherals」。
ariel-os 的 features = ["time"] 则是另一条路径:直接开 Cargo feature。官方注释说得很清楚,有些 feature 在 laze 里已经通过模块选了,这里再写一遍是「redundant but helps with interactive tools」,也就是让 rust-analyzer 在没跑 laze 的时候也能补全。
带资源约束的声明
examples/tcp-echo/laze.yml:
apps:
- name: tcp-echo
env:
global:
executor_stacksize_required:
- "16384"
selects:
- network
executor_stacksize_required 是一个「需求列表」,不是「设定值」。多个模块可以各自追加自己的需求,最终取最大值:
CONFIG_EXECUTOR_STACKSIZE: $(max (0, ${executor_stacksize_required}))
这就是为什么 wifi-esp 能把 ISR 栈需求推到 32768,而普通应用只要 8192。用「追加 + 取最大」而不是「覆盖」,避免了模块之间互相踩配置。
examples/http-client/laze.yml 展示了另一个维度:
apps:
- name: http-client
env:
global:
executor_stacksize_required:
- "65536"
selects:
- network
- random
conflicts:
- ram-small
# not enough ram
- context::esp32c2
- context::esp32s2
- context::stm32l475vg
conflicts 不是「关掉某个 feature」,而是「这个应用不允许和这些 context 同时存在」。context::esp32c2 这种写法表示「名为 esp32c2 的 context」,用来排除 RAM 不够的芯片。
依赖能力而不是板卡
examples/uart-echo/laze.yml 用的是 depends:
apps:
- name: example-uart-echo
depends:
- has_host_facing_uart
selects 是「我要这个模块」,depends 是「这个模块必须可用,否则别编我」。语义差别很小,但表达意图不同:前者是主动选择,后者是前置条件。has_host_facing_uart 是一个「能力声明」模块,本身不产生代码,只用于约束。
这种「按能力而不是按板卡」的写法,是 Ariel OS 支持几十块板还能保持示例可复用的关键。blinky 不写「我只支持 nRF52840-DK」,而是写「我需要一块有 LED 的板」;于是所有 SBD 里声明了 leds 的板都能跑它。
常用命令
# 在应用目录里构建
laze build -b espressif-esp32-c6-devkitc-1
# 从仓库根目录切换应用目录
laze -C examples/hello-world build -b espressif-esp32-c6-devkitc-1 run
# 覆盖变量
laze build -b espressif-esp32-c6-devkitc-1 -DLOG=info,ariel_os_rt=trace run
# 命令行选/禁模块
laze build -b espressif-esp32-c6-devkitc-1 --select probe-rs run
laze build -b espressif-esp32-c6-devkitc-1 --disable logging
# 列出本次选中的所有模块
laze build -b espressif-esp32-c6-devkitc-1 info-modules
# 给这块板编所有应用(CI 常用)
laze build --global -b espressif-esp32-c6-devkitc-1
# 在正确环境下跑任意 cargo 命令
laze build -b espressif-esp32-c6-devkitc-1 cargo tree -e features
laze build -b espressif-esp32-c6-devkitc-1 exec env | grep CONFIG_
注意 --select 只能加模块,不能替代 laze.yml 里的声明。out-of-tree 应用必须在自己的 laze-project.yml 里写 selects,否则每次构建都要手工加参数,CI 里必然漏。
逐段解析
contexts:继承链与变量合并
顶层 laze-project.yml 从 default 开始:
- name: default
env:
bindir: "${build-dir}/bin/${builder}/${app}"
CARGO:
- ${CARGO_WRAPPER}
- cargo
bindir 是产物目录,CARGO 是实际调用的命令(前面可以插 CARGO_WRAPPER,Ferrocene 就是靠这个套 criticalup run)。
第二层是 ariel-os,所有 Ariel OS 应用的基上下文:
- name: ariel-os
parent: default
selects:
- executor-default
- ?critical-section
- ?logging
- ?hw/device-identity
- ?lto
- ?xtensa
- ?stable
- ?nightly
env:
outfile: ${build-dir}/bin/${builder}/cargo/${RUSTC_TARGET}/${PROFILE_DIR}/${app}
PROFILE: release
PROFILE_DIR: $(if("${PROFILE}"=="dev", "debug", "${PROFILE}"))
PROBE_RS_PROTOCOL: swd
executor_stacksize_required_default: "8192"
isr_stacksize_required_default: "2048"
这里有三个细节值得记住。
第一,?logging 前面的问号表示可选依赖:如果别的地方没有选它,它不会被拉起;如果后续有模块选了它,也不会因为重复而报错。这让应用可以 --disable logging 而不会破坏依赖图。
第二,工具链的选择写成 ?xtensa、?stable、?nightly 三个可选,靠顺序决定优先级:Xtensa 芯片的 context 会 select xtensa,于是 stable 和 nightly 的可选依赖自然落空;普通芯片则落到 stable。这是一种「按可用性择一」的写法,比写一堆 if 干净。
第三,outfile 直接指向 cargo 的输出路径,不再复制 .elf。这是 laze 0.1.39 之后的行为,也是为什么旧版 laze 会找不到产物。
再往下是芯片族和具体芯片。以 C6 为例:
- name: esp
parent: ariel-os
selects:
- ?espflash
provides:
- has_device_identity
- has_hwrng
env:
esp_wifi_heapsize_required: $(72*1024)
RUSTFLAGS:
- -Clink-arg=-Tlinkall.x
- name: esp32c6
parent: esp
selects:
- riscv
provides:
- has_ble_esp
- has_wifi_esp
- has_usb_cdc_acm
- sw/benchmark
provides_unique: [c-function-abort]
env:
RUSTC_TARGET: riscv32imac-unknown-none-elf
PROBE_RS_CHIP: esp32c6
PROBE_RS_PROTOCOL: jtag
CARGO_TARGET_PREFIX: CARGO_TARGET_RISCV32IMAC_UNKNOWN_NONE_ELF
provides 是能力声明,可以被多个 context 同时提供;provides_unique 是独占能力,同一时间只能有一个提供者。c-function-abort 是 C 代码里的 abort() 实现,只能有一份,所以用 provides_unique。
RUSTFLAGS: -Clink-arg=-Tlinkall.x 是 ESP 特有的:esp-hal 的链接脚本需要一个总入口脚本把所有片段串起来。这个参数如果漏了,链接会报一堆符号未定义,而且错误信息完全看不出是链接脚本的问题。
builders:板卡名从哪来
-b 后面跟的不是芯片名,是 builder 名,也就是 SBD 文件里的 target 名。boards/espressif-esp32-c6-devkitc-1.yaml 里:
targets:
espressif-esp32-c6-devkitc-1:
chip: esp32-c6-wroom-1
target 名就是 builder 名,chip: 指向芯片 context。sbd-gen 在生成 ariel-os-boards 时,会为每个 target 生成一个 laze builder context,把 SBD 里的硬件信息变成 define_peripherals! 调用。
这也解释了两个常见困惑:
- 为什么不能写
-b esp32c6?因为esp32c6是芯片 context,不是 builder;它能被其他 context 引用,但不能直接传给-b。 - 为什么同一块板可能有多个 builder?比如 nRF5340-DK 有
nrf5340dk-app和nrf5340dk-net两个核,各自有独立的 flash 和 RAM,必须当成两个 builder。
builder 的继承链会把所有 env 合并,最终生成 --cfg context="..." 列表。源码里的 #[cfg(context = "esp")]、#[cfg(context = "riscv")]、#[cfg(context = "espressif-esp32-c6-devkitc-1")] 都是这么来的。这套机制让同一份代码可以按「架构 / 厂商 / 芯片 / 板卡」四个粒度做条件编译,而不需要引入几十个 Cargo feature。
modules:依赖、冲突、能力、环境
module 是 laze 里最有表达力的部分。一个模块可以同时拥有:
selects:依赖哪些别的模块(可带?表示可选);conflicts:和哪些模块互斥;provides/provides_unique:向别的模块声明自己提供什么能力;context:限制自己只在哪些 context 下可用;env.global.FEATURES:映射成--features=ariel-os/xxx;env.global.CARGO_ENV/RUSTFLAGS:注入环境变量或编译参数;tasks/rules:新增构建任务或规则。
看几个真实例子。
alloc 模块:
- name: alloc
help: Enables a heap, a global allocator and the `alloc` crate.
context: ariel-os
env:
global:
heapsize_required:
- "0"
CARGO_ENV:
- CONFIG_HEAPSIZE=$(${heapsize_required})
FEATURES:
- ariel-os/alloc
heapsize_required 是一个列表,初值 "0",其他模块可以继续追加;$(${heapsize_required}) 会把 "0 + 32768" 这样的表达式求值成数字传给 CONFIG_HEAPSIZE。FEATURES 最终被 var_options 拼成 --features=ariel-os/alloc。
logging 模块:
- name: logging
help: enables logging functionality
selects:
- logging-transport-default
- logging-facade-default
- ?panic-printing
它自己几乎不做事,只负责「选一个传输 + 选一个门面 + 可选地打印 panic」。真正的选择逻辑在 logging-transport-default 和 logging-facade-default 里,各自按优先级 ? 一串候选。这种「策略模块 + 默认选择模块」的写法,在 laze 里很常见。
defmt 与 log:
- name: defmt
help: Use the `defmt` crate as the logging facade
provides_unique:
- logging-facade
env:
global:
FEATURES:
- ariel-os/defmt
RUSTFLAGS:
- -Clink-arg=-Tdefmt.x
CARGO_ENV:
- DEFMT_LOG=info,${LOG}
- name: log
help: Use the `log` crate as the logging facade
provides_unique:
- logging-facade
env:
global:
FEATURES:
- ariel-os/log
CARGO_ENV:
- LOG_LEVEL=${LOG}
两者都 provides_unique: logging-facade,所以同时选会冲突。defmt 还要额外加 -Tdefmt.x 链接脚本,这是它的符号表段;log 不需要。
espflash 与 probe-rs:
- name: espflash
help: use `espflash` as runner
provides_unique:
- flashing-tool
env:
global:
CARGO_RUNNER: '"espflash flash --monitor"'
tasks:
flash:
cmd:
- espflash flash --after hard-reset ${out} $@
attach:
build: false
cmd:
- espflash monitor --elf ${out} $@
- name: probe-rs
help: use probe-rs as runner
selects:
- ?debug-console
provides_unique:
- embedded-test-runner
- flashing-tool
env:
global:
CARGO_RUNNER: "'probe-rs run ${PROBE_RS_PROTOCOL} --chip ${PROBE_RS_CHIP} --preverify'"
tasks:
flash-erase-all:
cmd:
- probe-rs erase --chip ${PROBE_RS_CHIP} --allow-erase-all $@
flash:
cmd:
- probe-rs download --chip ${PROBE_RS_CHIP} ${out} $@
- probe-rs reset --chip ${PROBE_RS_CHIP}
attach:
build: false
cmd:
- probe-rs attach --chip ${PROBE_RS_CHIP} ${out} $@
这是理解「同名任务为什么行为不同」的关键:任务不是全局唯一的,模块可以覆盖同名任务。最终用哪个,取决于哪个 flashing-tool 提供者被选中。ESP 的 context 默认 ?espflash,所以 flash 走 espflash;显式 --select probe-rs 时,probe-rs 成为提供者,flash 就变成 probe-rs download。
wifi-esp:
- name: wifi-esp
selects:
- alloc
- has_wifi_esp
- multi-core:
- core-affinity
- network
context:
- esp
provides_unique:
- network_device
env:
global:
isr_stacksize_required:
- "32768"
heapsize_required:
- ${esp_wifi_heapsize_required}
FEATURES:
- ariel-os/wifi-esp
这个模块一下子把 Wi-Fi 的连锁反应讲清楚了:Wi-Fi 需要堆(alloc,72 KiB)、需要更大的 ISR 栈(32768)、需要网络栈(network),在多核芯片上还要 core-affinity。如果只看代码,你很难知道开一个 Wi-Fi 会带来这么多构建期变化。
顺序敏感:laze 的解析不是声明式的
laze 按顺序解析 selects,这一点和 Cargo 的 feature 并集很不一样。官方文档明确标了三个「必须在前」:
network-config-*必须在network之前。因为network会选network-config-ipv4-default,而这个默认模块会按顺序尝试 DHCP、静态;如果你把静态配置写在network后面,默认已经选了 DHCP,再选静态就冲突了。logging-over-custom-transport必须在usb之前(usb-serial-logger示例的注释原话:Has to be selected before 'usb' is selected)。原因是usb会选usb-cdc-acm-esp,而自定义传输和默认传输互斥,顺序错了就冲突。- 日志传输必须在日志门面之前。
logging模块的注释写着Mandatory; the logging transport must be selected before the facade。
这类顺序约束是 laze 的设计代价:它换来了「按顺序择一」的能力,但也要求写配置的人理解解析顺序。我的建议是:把 selects 当成有依赖顺序的列表,而不是集合;不确定时,先看官方示例的写法,别自己重排。
变量、求值与 overrides
laze 的变量系统支持:
${var}插值;$(expr)表达式,比如$(max (0, ${executor_stacksize_required}))、$(72*1024);var_options定义列表如何拼接:FEATURES用--features=前缀加逗号连接,LOG用逗号连接,heapsize_required用" + "连接后求值;- 命令行
-DVAR=value覆盖,优先级最高。
-DLOG=info,ariel_os_rt=trace 就是覆盖 LOG 变量。defmt 模块把它拼进 DEFMT_LOG=info,${LOG},最终变成 DEFMT_LOG=info,info,ariel_os_rt=trace。重复的 info 不影响结果,这是 defmt 自己的解析逻辑。
栈大小的计算值得单独看一眼:
executor_stacksize_required:
- ${executor_stacksize_required_default}
CONFIG_EXECUTOR_STACKSIZE: $(max (0, ${executor_stacksize_required}))
默认 8192,任何模块或应用追加需求后取最大值。ISR 栈同理,默认 2048;如果执行器是 interrupt flavor,内核还会把执行器栈叠加到 .isr_stack 上:
const CONFIG_ISR_STACKSIZE: usize = usize_from_env_or!("CONFIG_ISR_STACKSIZE", 2048, ...);
#[cfg(feature = "executor-interrupt")]
{
const CONFIG_EXECUTOR_STACKSIZE: usize = usize_from_env_or!("CONFIG_EXECUTOR_STACKSIZE", 8192, ...);
CONFIG_ISR_STACKSIZE + CONFIG_EXECUTOR_STACKSIZE
}
这解释了一个常见现象:同样的应用,换成 executor-thread 后 RAM 占用会变——执行器栈从 ISR 栈里搬到了独立线程栈。
builds、rules 与 tasks 的区别
- rule:产生文件,参与依赖图,比如
LINK规则调用 cargo 生成${out}; - task:动作,不一定产生文件,比如
flash、reset、attach、size、tree; - builder:顶层 context,可以被
-b选中; - app:具体的应用声明,对应一个 cargo bin。
run 任务在 ariel-os 上下文里是 build: false,因为它不产生新文件,只调用 cargo run;flash 在 probe-rs 模块里默认会先构建(没有 build: false),因为它需要最新的 ${out}。这些细节决定了你跑 laze build flash 时会不会触发重新编译。
out-of-tree 与 imports
仓库内的示例用相对路径依赖 ../../src/ariel-os。out-of-tree 项目不能这么写,因为 Ariel OS 不在你的目录里。laze 的 imports 机制解决这个问题:在 laze-project.yml 里声明一个 git import(锁 commit),laze 会把仓库克隆到 build/imports,然后把 import 进来的 context 当成自己的 context 使用。
官方建议锁 commit 而不是分支,原因是 Ariel OS 的 API 在 0.x 阶段变化快,跟分支会出现「今天能编明天不能编」。这也是为什么本系列反复强调版本基线。
运行现象
info-modules:看清最终选择
laze -C examples/hello-world build -b espressif-esp32-c6-devkitc-1 info-modules
输出是按字母排序的模块名。具体列表随版本与板卡变化,结构大致如下(示意,不是逐字输出):
ariel-os
debug-console
defmt
esp
esp32-c6-wroom-1
esp32c6
espressif-esp32-c6-devkitc-1
executor-default
executor-interrupt
logging
logging-facade-default
logging-over-usb
logging-transport-default
panic-printing
riscv
stable
...
具体列表随版本变化,但你能从中确认三件事:日志传输选的是哪条、执行器是哪个 flavor、烧录工具是哪个。排查「为什么日志从 USB 出来了」这类问题时,先跑这条命令。
构建日志里的 cargo 命令行
laze 默认会打印它执行的命令。你能看到类似:
cargo build --profile=release --features=ariel-os/defmt,ariel-os/logging-over-usb ... --bin hello-world
以及一串环境变量。如果构建行为和你预期不符,把这行复制出来,去掉 laze 直接跑,往往能复现问题;反过来,也能确认某个 feature 到底有没有传进去。
模块冲突的报错
故意选两个互斥的模块:
laze build -b espressif-esp32-c6-devkitc-1 --select defmt --select log
会得到类似「both provide unique capability logging-facade」的错误。这类错误发生在解析阶段,不会进到 cargo,所以看到报错时不要怀疑代码。
--global 的批量构建
laze build --global -b espressif-esp32-c6-devkitc-1
会尝试构建这块板支持的所有应用。失败是正常的:blinky 需要 has_leds,C6 没有;storage 需要 sw/storage,C6 不支持;usb-serial 需要通用 USB,C6 没有。这条命令的正确用法是看「哪些应用应该在这块板上编过」,而不是要求全绿。CI 里通常配合 allowlist 使用。
常见问题
Q1:selects 和 Cargo features 到底该写哪个?
成因:两者作用域不同。selects 选的是 laze 模块,模块可能映射到 Cargo feature,也可能只做能力约束、任务覆盖、环境变量注入;Cargo feature 直接作用于 crate。写错地方会导致「本地能编,CI 不能编」或者「rust-analyzer 一片红」。
解决方案:默认写 selects,因为它是构建系统的真相来源。只有在需要 IDE 补全、或者某个 feature 没有被任何模块封装时,才在 Cargo.toml 里重复写 features = [...]。官方示例里 random 的注释就是这么解释的:laze 已经选了 random,Cargo 里再写一遍是为了交互工具。
Q2:为什么我把 network-config-ipv4-static 放到 network 后面,就报冲突?
成因:laze 顺序解析。network 会 select network-config-ipv4-default,后者按顺序尝试 ?network-config-ipv4-dhcp、?network-config-ipv4-static,默认落到 DHCP。你后面再显式选静态,两个模块都 provides_unique: network-config-ipv4,于是冲突。
解决方案:把静态配置写在 network 之前:
selects:
- network-config-ipv4-static
- network
这和 Cargo 的「顺序无关」直觉相反,必须记住。
Q3:--select probe-rs 之后,flash 为什么不再走 espflash?
成因:espflash 和 probe-rs 都 provides_unique: flashing-tool,同一时间只能有一个提供者。显式选 probe-rs 后,它成为提供者,同名 flash 任务被 probe-rs 模块的实现覆盖。CARGO_RUNNER 也随之从 espflash flash --monitor 变成 probe-rs run ...。
解决方案:这就是设计意图,不用「修」。需要临时切回 espflash,去掉 --select probe-rs 即可;注意切换 build 参数后要重新构建,因为 CARGO_RUNNER 变了。
Q4:-b 写芯片名报 unknown builder,板卡名到底怎么查?
成因:-b 只接受 builder,也就是 SBD 的 target 名,不是芯片名、不是目录名、不是产品营销名。esp32c6 是芯片 context,espressif-esp32-c6-devkitc-1 才是 builder。
解决方案:查 boards/*.yaml 的 targets: 键,或者查支持矩阵里的「Ariel OS name」列。仓库里还有一个 info-boards 任务,不过更快的办法是 ls boards/ 看文件名——文件名通常就是 builder 名(多 target 的板除外,比如 nRF5340-DK 的 nrf5340dk-app/nrf5340dk-net 在同一份或两份 YAML 里)。
Q5:?foo 和 foo 有什么区别,什么时候用哪个?
成因:foo 是硬依赖,找不到就报错;?foo 是可选依赖,能选就选,不能选也不报错。它适合「有就用,没有就算」的场景,比如 ?logging、?lto、?stable。
解决方案:自己写模块时,如果某个依赖是「增强」而非「必需」,用 ?;如果是功能成立的前提,用硬依赖。注意 ? 不是「禁用」——想禁用要用 conflicts 或 --disable。
Q6:改了 laze.yml 但构建行为没变
成因:三种可能。一是改的不是当前应用目录的 laze.yml(laze 默认作用于当前目录);二是模块选择被命令行 --select 覆盖;三是 cargo 增量缓存没意识到环境变量变了(少见,但改 RUSTFLAGS 时可能)。
解决方案:先 info-modules 确认选择,再 laze build ... tree 看依赖,最后才考虑清理 build/bin/<builder>/cargo。不要一上来就 rm -rf build,那会把所有板卡的缓存都清掉。
Q7:out-of-tree 项目里 ariel-os 的 context 找不到
成因:没有配置 import,或者 import 的路径/commit 写错。laze 不会自动去 GitHub 找 Ariel OS,它只知道当前项目和 import 进来的东西。
解决方案:用官方模板(ariel-os-hello 或 cargo generate)起步,它的 laze-project.yml 已经写好了 git import;自己搭的话,参考 laze 文档的 imports 章节,把仓库克隆到 build/imports,并锁 commit。不要把 Ariel OS 直接拷进项目目录再改路径,升级时会很痛苦。
面试考点
考点 1:laze 的 context 继承是怎么解析的?多个父 context 的变量如何合并?
参考答案:context 通过 parent 形成单继承链,从根到叶依次合并。列表型变量(FEATURES、RUSTFLAGS、selects、heapsize_required)追加;标量变量(RUSTC_TARGET、PROBE_RS_CHIP)后者覆盖前者;数值型需求(executor_stacksize_required)用 max() 取最大。最终每个 context 名都会变成 rustc 的 --cfg context="...",供源码做条件编译。
考点 2:provides 和 provides_unique 的区别是什么?各举一个 Ariel OS 的例子。
参考答案:provides 声明可被多方同时提供的能力,用于「只要有就行」,比如 has_wifi_esp、has_leds;provides_unique 声明独占能力,同一时间只能有一个提供者,用于互斥选择,比如 logging-facade(defmt 与 log 二选一)、flashing-tool(espflash 与 probe-rs 二选一)、network_device(Wi-Fi/以太网/USB 以太网/tuntap 择一)、c-function-abort(只能有一份 C 的 abort 实现)。
考点 3:为什么 laze 的模块解析是顺序敏感的?这带来什么风险?
参考答案:因为 laze 用「按顺序尝试可选依赖」来实现默认选择,比如 network 会先选 network-config-ipv4-default,后者按顺序尝试 DHCP 再静态。顺序决定谁先占用 provides_unique 能力。风险是配置的可交换性变差:同一组模块换个顺序可能冲突或得到不同结果。规避方法是遵守官方示例的顺序,把「配置类」模块放在「功能类」模块之前。
考点 4:Cargo feature 与 laze module 的语义差异是什么?为什么不能互相替代?
参考答案:Cargo feature 是并集、可叠加、由依赖图决定;laze module 是有序、可互斥、可带能力声明和环境注入的构建单元。Cargo 无法表达「二选一」、无法按板卡裁剪、无法注入 runner 和链接脚本;laze 无法替代 Cargo 的依赖解析与版本管理。二者是上下层关系:laze 决定「怎么编」,Cargo 决定「编什么」。
考点 5:flash 任务为什么会随模块变化?这种设计的优缺点是什么?
参考答案:任务定义在模块里,模块可以覆盖同名任务;最终生效的是被选中模块的实现。优点是同一套命令(laze build flash)在不同板卡上行为一致,用户不需要记住 espflash/probe-rs 的差异;缺点是行为隐式,排查时必须先确认哪个 flashing-tool 提供者被选中。Ariel OS 用 provides_unique 保证不会同时生效,避免歧义。
考点 6:如何保证 out-of-tree 项目的构建可复现?
参考答案:三件事。第一,laze import 锁 Ariel OS 的 commit ID,不用分支;第二,提交 Cargo.lock,锁定所有第三方 crate 版本;第三,把 laze.yml 里的 selects/conflicts 写全,不依赖命令行临时参数。这样同一 commit 在任何机器上解析出的模块集合、环境变量、依赖版本都一致。剩余的不确定性来自工具链版本(rustup、laze、espflash),CI 里应固定版本。
小结
laze 的三个概念可以这样记:context 回答「在哪编」,module 回答「开什么」,builder 回答「给谁编」。三者通过 selects、provides、env 串起来,最终生成一条带完整环境变量的 cargo 命令。
几个必须记住的工程细节:
-b只接受 builder(SBD target),不是芯片名;selects有序,配置类模块要放在功能类模块之前;provides_unique是互斥的机制,日志门面、烧录工具、网络设备都靠它;- 同名任务会被模块覆盖,
flash的行为取决于选中的烧录工具; - 栈大小和堆大小是「追加需求 + 取最大」,不是覆盖。
下一篇终于写代码:从 minimal 的三行程序开始,到 hello-world 的完整启动链,把「没有 main 函数」这件事彻底讲明白。
系列目录
- 系列总览:Ariel OS 设计理念与生态全景
- 环境搭建:Rust 工具链与 laze 构建系统
- laze 元构建系统:contexts、modules 与 builders
- 第一个应用:从 minimal 到 hello-world
- 板卡描述体系:boards yaml 与结构化配置
- 烧录与调试:probe-rs、RTT 与日志查看
- 日志系统:defmt 与 ariel-os-log
- 调试控制台:shell 与交互调试
- 异步执行器:Embassy 集成与任务宏
- 内存布局与全局分配器
- 时钟体系与启动流程
- 引脚与 GPIO:静态配置与运行时切换
- 多线程(上):抢占式线程与线程宏
- 多线程(下):Channel、Event 与互斥
- 线程与异步互操作:multicore 与定时器
- 低功耗与电源管理
- I2C 实战:总线扫描与设备通信
- SPI 与 UART 实战
- 传感器框架:sensor API 与注册表
- 随机数与熵源
- 网络栈(上):初始化、Wi-Fi 与 UDP
- 网络栈(下):TCP 客户端与回显服务
- 应用协议:HTTP 与 CoAP
- USB 实战:串口与 HID 键盘
- BLE 实战:广播与扫描
- 以太网、设备元数据与身份
- 持久化存储
- 测试:embedded-test 与硬件在环
- 原生目标、基准测试与 Ferrocene
- 收官:新增板卡支持、横向对比与路线图

浙公网安备 33010602011771号