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 并集很不一样。官方文档明确标了三个「必须在前」:

  1. network-config-* 必须在 network 之前。因为 network 会选 network-config-ipv4-default,而这个默认模块会按顺序尝试 DHCP、静态;如果你把静态配置写在 network 后面,默认已经选了 DHCP,再选静态就冲突了。
  2. logging-over-custom-transport 必须在 usb 之前(usb-serial-logger 示例的注释原话:Has to be selected before 'usb' is selected)。原因是 usb 会选 usb-cdc-acm-esp,而自定义传输和默认传输互斥,顺序错了就冲突。
  3. 日志传输必须在日志门面之前。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 函数」这件事彻底讲明白。

系列目录

  1. 系列总览:Ariel OS 设计理念与生态全景
  2. 环境搭建:Rust 工具链与 laze 构建系统
  3. laze 元构建系统:contexts、modules 与 builders
  4. 第一个应用:从 minimal 到 hello-world
  5. 板卡描述体系:boards yaml 与结构化配置
  6. 烧录与调试:probe-rs、RTT 与日志查看
  7. 日志系统:defmt 与 ariel-os-log
  8. 调试控制台:shell 与交互调试
  9. 异步执行器:Embassy 集成与任务宏
  10. 内存布局与全局分配器
  11. 时钟体系与启动流程
  12. 引脚与 GPIO:静态配置与运行时切换
  13. 多线程(上):抢占式线程与线程宏
  14. 多线程(下):Channel、Event 与互斥
  15. 线程与异步互操作:multicore 与定时器
  16. 低功耗与电源管理
  17. I2C 实战:总线扫描与设备通信
  18. SPI 与 UART 实战
  19. 传感器框架:sensor API 与注册表
  20. 随机数与熵源
  21. 网络栈(上):初始化、Wi-Fi 与 UDP
  22. 网络栈(下):TCP 客户端与回显服务
  23. 应用协议:HTTP 与 CoAP
  24. USB 实战:串口与 HID 键盘
  25. BLE 实战:广播与扫描
  26. 以太网、设备元数据与身份
  27. 持久化存储
  28. 测试:embedded-test 与硬件在环
  29. 原生目标、基准测试与 Ferrocene
  30. 收官:新增板卡支持、横向对比与路线图
posted @ 2026-10-27 00:47  navysummer_hardware  阅读(11)  评论(0)    收藏  举报