BLE 实战:广播与扫描

本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 25 篇。上一篇:USB 实战:串口与 HID 键盘,下一篇:以太网、设备元数据与身份,目录见文末。

BLE 实战:广播与扫描

导读

Wi-Fi 解决"有基础设施"的联网,BLE 解决"无基础设施"的短距交互:传感器节点对手机广播数据、信标(beacon)做室内定位、设备配网前的第一次握手。本篇覆盖 Ariel OS 的 BLE 栈,用两个互补的官方示例走通最常用的两个角色:ble-advertiser(外设角色,非连接广播)与 ble-scanner(中心角色,扫描周边设备)。

Ariel OS 的 BLE 建立在 TrouBLE 之上——Embassy 系的纯 Rust BLE Host 实现(对标 Zephyr 的 Bluetooth Host)。Ariel OS 的集成点在于:控制器(Controller)与硬件初始化由 OS 按芯片完成(ESP32 系走 esp-radio 的 BLE 控制器,nRF52/53 走 nrf-sdc,RP2040/235x + CYW43 走 cyw43 路径),栈的创建经 ariel_os::ble::ble_stack() 一站式提供,应用直接面对 trouble-host 的 API。这与网络篇的模式完全同构:OS 负责硬件 bring-up 与栈生命周期,应用负责协议业务。

主线板卡 ESP32-C6-DevKitC-1 完整支持 BLE(ble-esp feature,laze 上下文 provides has_ble_esp),两个示例都能原生运行——这与上一篇 USB 的"换板才能跑"形成对比,是 C6 上少有的"全功能直接跑"的无线能力。

版本基线:仓库 dev(0.6.0)。当前 BLE 的已知约束要提前交底:MTU 固定 27 字节(ATT 默认值,无 MTU 交换协商);设备地址默认是启动时随机生成的静态随机地址(不跨启动持久、当前不轮转);固定地址需 ble-config-static-address 模块 + CONFIG_BLE_STATIC_ADDRESS 环境变量。

为什么 BLE 从广播与扫描学起

BLE 的角色体系里,广播(Advertising)与扫描(Scanning)是一对最基础的交互:外设(Peripheral)周期性在三个广播信道发射数据包,中心(Central)被动监听并解析。这一对交互不需要建立连接——信标应用(iBeacon/Eddystone 风格)可以永远停留在广播态;配网、发现、 sensor 广播等 IoT 高频场景全是无连接广播。先掌握它,再谈 GATT 服务与连接管理,学习路径最短。

从工程视角,广播/扫描示例还有两个教学价值。其一,它们暴露了 TrouBLE 的编程模型:host.runner.run() 作为常驻的"协议栈引擎"必须一直跑,业务逻辑通过 join 与它并行、通过 EventHandler 回调接入——这是事件驱动与轮询的混合架构,理解它才能写出不卡死栈的业务代码。其二,广播包的 31 字节限制是 BLE 最著名的硬约束,AdStructure::encode_slice 的编码 API 是处理它的正确姿势。

启用 BLE:模块与依赖

laze 侧的模块体系:

  • ble:总开关,selects hwrng——BLE 配对与随机 nonce 依赖密码学随机数,第 20 篇的硬件 TRNG 在这里被强制启用。
  • ble-peripheral:外设角色能力(广播、可连接)。
  • ble-central:中心角色能力(扫描、发起连接)。
  • ble-config-static-address:固定设备地址(配合 CONFIG_BLE_STATIC_ADDRESS)。

esp32c6 上下文 provides has_ble_esp,选 ble-peripheral 或 ble-central 时自动级联。两个角色的应用通常只需要一个;同时选两个理论上可行(TrouBLE 支持双角色),但示例分开教学更清晰。

代码案例:BLE 广播器

Cargo.toml(ble-advertiser):

[dependencies]
ariel-os = { path = "../../src/ariel-os", features = ["time"] }
ariel-os-boards = { path = "../../src/ariel-os-boards" }
embassy-futures = { workspace = true }
trouble-host = { workspace = true }

laze.yml:

apps:
  - name: ble-advertiser
    env:
      global:
        executor_stacksize_required:
          - "16384"
    selects:
      - ble-peripheral

src/main.rs:

//! Adapted from the example in `trouble_host`.
#![no_main]
#![no_std]

use ariel_os::{log::info, reexports::embassy_time, time::Timer};
use embassy_futures::join::join;
use embassy_time::Duration;
use trouble_host::advertise::{
    AdStructure, Advertisement, AdvertisementParameters, BR_EDR_NOT_SUPPORTED,
    LE_GENERAL_DISCOVERABLE,
};

#[ariel_os::task(autostart)]
async fn run_advertisement() {
    info!("starting ble stack");
    let stack = ariel_os::ble::ble_stack().await;
    let mut host = stack.build();

    info!("Using address: {}", ariel_os::ble::current_address().await);

    let mut adv_data = [0; 31];

    let len = AdStructure::encode_slice(
        &[
            AdStructure::CompleteLocalName(b"Ariel OS BLE"),
            AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
        ],
        &mut adv_data[..],
    )
    .unwrap();

    info!("Starting advertising");

    let _ = join(host.runner.run(), async {
        let params = AdvertisementParameters {
            interval_min: Duration::from_millis(100),
            interval_max: Duration::from_millis(100),
            ..Default::default()
        };

        let _advertiser = host
            .peripheral
            .advertise(
                &params,
                Advertisement::NonconnectableScannableUndirected {
                    adv_data: adv_data.get(..len).unwrap(),
                    scan_data: &[],
                },
            )
            .await;

        loop {
            info!("Still running");
            Timer::after_secs(60).await;
        }
    })
    .await;
}

运行:

laze -C examples/ble-advertiser build -b esp32-c6-devkitc-1 run

案例 2:BLE 扫描器

laze.yml:selects: [ble-central]。src/main.rs:

//! Adapted from the example in `trouble_host`.
#![no_main]
#![no_std]

use core::cell::RefCell;

use ariel_os::{log::info, reexports::embassy_time, time::Timer};
use embassy_futures::join::join;
use embassy_time::Duration;
use heapless::Deque;
use trouble_host::{
    Host,
    connection::{PhySet, ScanConfig},
    prelude::{BdAddr, EventHandler, LeAdvReportsIter},
    scan::Scanner,
};

#[ariel_os::task(autostart)]
async fn run_scanner() {
    info!("starting ble stack");
    let stack = ariel_os::ble::ble_stack().await;

    let Host {
        central,
        mut runner,
        ..
    } = stack.build();

    let printer = Printer {
        seen: RefCell::new(Deque::new()),
    };
    let mut scanner = Scanner::new(central);
    let _ = join(runner.run_with_handler(&printer), async {
        let config = ScanConfig::<'_> {
            active: true,
            phys: PhySet::M1,
            interval: Duration::from_secs(1),
            window: Duration::from_secs(1),
            ..Default::default()
        };
        let mut _session = scanner.scan(&config).await.unwrap();
        // Scan forever
        loop {
            info!("scanning...");
            Timer::after_secs(1).await;
        }
    })
    .await;
}

struct Printer {
    seen: RefCell<Deque<BdAddr, 128>>,
}

impl EventHandler for Printer {
    fn on_adv_reports(&self, mut it: LeAdvReportsIter<'_>) {
        let mut seen = self.seen.borrow_mut();
        while let Some(Ok(report)) = it.next() {
            if !seen.iter().any(|b| b.raw() == report.addr.raw()) {
                info!("discovered: {:?}", report.addr);
                if seen.is_full() {
                    seen.pop_front();
                }
                seen.push_back(report.addr).unwrap();
            }
        }
    }
}

对联玩法:一块 C6 跑 ble-advertiser,另一块(C6 或任何 ble-central 支持的板)跑 ble-scanner,观察扫描器发现广播器地址;手机装 nRF Connect for Mobile 同样能看到 Ariel OS BLE 的广播。

逐段解析

ble_stack 与 host.build:栈的获取与解构

let stack = ariel_os::ble::ble_stack().await;
let mut host = stack.build();

ariel_os::ble::ble_stack() 返回 &'static mut BleStack(Stack<'static, BleController, DefaultPacketPool> 别名)。Book 的约束要划重点:只能从系统 executor 调用一次,重复调用 panic——栈的 packet pool 与控制器是全局单例,两个任务各调一次会拿到两个 &mut,违反 Rust 别名规则,Ariel OS 用 panic 直接拒绝。需要多任务共享 BLE 时,正确姿势是在一个任务里 build() 后拆分 Host 的字段分给各任务(scanner 示例的模式)。

stack.build() 产出 trouble_host::Host 结构,扫描器示例用解构取字段——Host { central, mut runner, .. }——而不是点访问,这暗示了 Host 的所有权语义:runner(栈引擎)要 mut 常驻运行,central/peripheral 是角色的操作入口,各字段可以 move 到不同任务。广播器用 host.runner + host.peripheral,扫描器解构后拿 central + runner,同一个结构的两种消费方式。

current_address() 返回当前设备地址(Future)。默认实现是启动时随机生成静态随机地址——BLE 静态随机地址的规范要求高两位为 1,Ariel OS 遵守;每次上电地址都变(不持久),隐私角度这是好事,但设备身份识别、白名单过滤就做不了了——固定地址走 ble-config-static-address + CONFIG_BLE_STATIC_ADDRESS='AA:BB:CC:DD:EE:FF' 格式(构建期读入,跨重启持久)。

广播数据:31 字节预算与 AdStructure

let mut adv_data = [0; 31];
let len = AdStructure::encode_slice(
    &[
        AdStructure::CompleteLocalName(b"Ariel OS BLE"),
        AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
    ],
    &mut adv_data[..],
).unwrap();

经典 BLE 广播载荷上限 31 字节(BLE 5 的扩展广播可更大,示例走经典路径)。AdStructure::encode_slice 把结构化的广播字段编码进 31 字节缓冲,返回实际长度 len。算一笔账:CompleteLocalName 字段 = 2 字节头(len + type)+ 13 字节 "Ariel OS BLE" = 15;Flags = 2 + 1 = 3;合计 18 字节,余量 13。名字再长一点就编码失败(.unwrap() 会 panic)——这正是 31 字节预算的直观教学。塞不下的数据走 scan response:示例的 Advertisement::NonconnectableScannableUndirected 声明"不可连接、可扫描",scan_data 是对扫描请求的响应载荷,与 adv_data 各自 31 字节。

Flags 的两个位是 BLE 广播的标配:LE_GENERAL_DISCOVERABLE(一般可发现模式)与 BR_EDR_NOT_SUPPORTED(声明不支持经典蓝牙 BR/EDR——单模 BLE 设备必设,主机靠它过滤双模设备)。

AdvertisementParameters 设置广播间隔 min/max 都为 100 ms——BLE 规范要求 min/max 成对,广播间隔越短被发现越快、越耗电;信标类应用通常取 100 ms 到 1 s。..Default::default() 补齐其余字段(广播类型、地址类型、信道映射等)。

NonconnectableScannableUndirected 的三个定语精确对应广播类型学:Nonconnectable(不响应连接请求)、Scannable(响应扫描请求即 scan response)、Undirected(不指定目标)。想改成"可被连接的外设",换 ConnectableUndirected 变体——广播器示例保持不可连接,把"连接后的 GATT 服务"留给后续主题。

runner.run:栈引擎与 join 结构

let _ = join(host.runner.run(), async { ... }).await;

host.runner.run() 是 TrouBLE 的核心循环:处理 HCI 事件、维护广播/连接状态机。它必须永远在跑,否则栈停摆。业务代码不能"await 完业务再跑 runner",也不能反过来——join 把两者绑成并行的 Future 集合,任一完成语义上组才完成(这里两者都是无限 Future,join 永不返回)。这个结构是 TrouBLE 应用的标准骨架:runner 在一个分支,业务在另一个分支,通信靠 Host 的字段(peripheral/central)与回调。

join 而非 spawn 的原因:embassy 任务数是编译期预算(pool_size),BLE 骨架把"引擎+业务"收敛在一个任务里更省资源;且两者生命周期同长,join 的语义恰好匹配。

扫描器:EventHandler 回调与去重

let _ = join(runner.run_with_handler(&printer), async {
    let config = ScanConfig::<'_> {
        active: true,
        phys: PhySet::M1,
        interval: Duration::from_secs(1),
        window: Duration::from_secs(1),
        ..Default::default()
    };
    let mut _session = scanner.scan(&config).await.unwrap();
    loop { info!("scanning..."); Timer::after_secs(1).await; }
}).await;

run_with_handler(&printer) 替代 run():runner 循环里每收到广播报告,调用 handler 的 EventHandler::on_adv_reports(it),迭代器 LeAdvReportsIter 逐个给出 AdvReport(含地址、RSSI、数据)。把事件处理放在 runner 分支而非业务分支轮询,避免了"业务任务读共享队列"的同步复杂度——handler 直接跑在 runner 上下文,借用检查天然隔离。

ScanConfig 的字段值得逐个读:active: true 是主动扫描(对 Scannable 广播发 SCAN_REQ 收 scan response——广播器示例的 scan_data 就是为它准备的;被动扫描只听广播包本身);phys: PhySet::M1 用 1M PHY(经典速率,兼容性最好);interval/window 是扫描调度的节拍——扫描每 interval 开一个 window 的接收窗,两者相等意味着连续扫描(最耗电、发现最快)。BLE 的"发现延迟 = 广播间隔 x 扫描占空比"决定了对联实验里几秒内看到设备。

Printer 的去重逻辑是嵌入式数据处理的小品:heapless::Deque<BdAddr, 128> 环形队列存已见地址,新地址不在队列才打印;队列满则 pop_front 淘汰最旧——固定内存、有界时延、无堆。广播设备会以广播间隔高频重复报告,不去重日志会刷屏。

scanner.scan(&config) 返回 ScanSession——会话对象存在期间扫描持续,drop 即停止。_session 绑定到 async 块内,配合无限 loop 达到"永久扫描"。这是 RAII 风格的资源管理在 BLE API 里的体现。

运行现象

ble-advertiser(ESP32-C6):

INFO starting ble stack
INFO Using address: 52:XX:XX:XX:XX:XX   (每次上电不同,高两位为 1 的静态随机地址)
INFO Starting advertising
INFO Still running    (每 60 秒一条,证明广播会话存活)

主机侧验证:nRF Connect(Android/iOS)或 bluetoothctl + bluetoothctl scan on(Linux BlueZ)看到名为 Ariel OS BLE 的设备;Python 的 bleak 库脚本可以持续抓广播并解码 CompleteLocalName。

ble-scanner(第二块 C6 或 nRF 板):

INFO starting ble stack
INFO discovered: 52:XX:XX:XX:XX:XX    (周边每一个广播设备,含手机/耳机/电视遥控)
INFO discovered: 3C:XX:XX:XX:XX:XX
INFO scanning...

手机蓝牙开闭、邻居的设备进出门都会体现在 discovered 列表里——BLE 扫描器是"看不见的无线电监控",对联实验中 advertising 板的地址出现即宣告双向打通。值得做的实验:把广播间隔从 100 ms 改到 1000 ms,测量扫描器的发现延迟变化;把 active 改 false,确认 scan response 不再被请求(抓不到 scan_data);给 advertising 板配静态地址后验证重启地址不变。

常见问题

Q1:ble_stack() 在第二个任务里调用导致 panic,多任务要用 BLE 怎么办?

成因:ble_stack() 返回 &'static mut BleStack,全局只允许一次——第二次调用违反独占借用,Ariel OS 直接 panic 保护。解决方案:所有 BLE 入口收敛到单一"BLE 管家"任务:在该任务里 ble_stack() → build() → 解构 Host,把 central/peripheral 分发给业务任务(可通过任务参数或全局静态传递),runner.run() 留在管家任务常驻;事件处理用 run_with_handler 的回调统一接收再分发(handler 内部可写 embassy-sync 的 Signal/Channel)。参考 ble-scanner 的解构模式,它是"一个 build 点、多消费者"的标准写法。

Q2:扫描器完全扫不到任何设备,包括明明在广播的手机?

成因排查顺序:扫描窗口配置(interval/window 若被改小成极短 window,占空比过低会发现极慢);PhySet 与对端不匹配(对端用 coded PHY 广播而扫描器只开 M1);主板天线/射频问题(开发板罕见);区域法规限制(极少见)。另外注意手机在屏幕熄灭时常降低广播频率甚至不广播(连接态的设备不广播)。解决方案:先扫一个已知活跃的信标(nRF Connect 自己也能开广播);恢复示例默认的 interval=window=1s、M1 PHY;对联验证两块 Ariel OS 板互通后再排查与手机的互通。

Q3:设备地址每次重启都变,主机侧配对/过滤失效?

成因:默认地址是启动时随机生成的静态随机地址,不持久化(Book 明示"currently not rotating"但也"not persisted")。解决方案:选 ble-config-static-address laze 模块(注意模块选择顺序惯例,它属于 ble 配置类模块)并设 CONFIG_BLE_STATIC_ADDRESS='AA:BB:CC:DD:EE:FF'(构建期环境变量,格式冒号分隔六段)。固定地址使主机侧可以白名单过滤、绑定识别。隐私权衡:固定地址可被长期追踪,公开部署设备要权衡(生产 BLE 设备通常配地址轮换,Ariel OS 当前未提供,属于已知差距)。

Q4:MTU 27 字节是不是太小,能不能传大点的数据?

成因:27 是 BLE 4.0 的 ATT 默认 MTU(23 octets ATT PDU + 4),Ariel OS 当前固定此值、未实现 MTU 交换(GATT 层的 Exchange MTU 流程)。单包有效载荷约 20 字节,GATT 长读/长写与可靠流都受限。解决方案:应用层分包——在广播或自有协议里定义分片(每片带序号),或改用适合小包的模型(广播里塞 Manufacturer Specific Data 的短遥测、或建立连接后多次 short write)。这是当前版本的硬约束(Book 明示 MTU fixed 27 bytes),后续版本可能放开;对传输大块数据(OTA 固件)的场景,现阶段应走 Wi-Fi/USB 路径而非 BLE GATT。

Q5:为什么 ble 模块强制依赖 hwrng?没有硬件 TRNG 的芯片怎么办?

成因:BLE 协议的密码学组件(配对密钥协商、连接 nonce、地址生成)要求高质量随机源;用弱随机源的 BLE 实现历史上出现过可预测密钥的真实漏洞(如早期蓝牙芯片的熵不足攻击)。ble 模块 selects hwrng 保证 TRNG 在栈启动前播种(第 20 篇的 construct_rng 在系统 init 里先行)。没有硬件 TRNG 的芯片当前不在 BLE 支持列表内——这是安全默认值优先于支持面广度的决策。如果应用还要密码学随机(TLS 种子等),crypto_rng() 与 BLE 共享同一播种池,不会有额外成本。

面试考点

考点 1:解释 TrouBLE 的 Host 结构中 runner 与 peripheral/central 的关系,为什么业务代码必须与 runner.run() 并行?

参考答案:runner 是 BLE Host 的引擎循环:驱动 HCI 与控制器交互、处理异步事件(广播报告、连接事件、GATT 请求)、推进协议状态机。peripheral/central 是角色操作句柄(advertise/scan/connect 的入口),它们内部与 runner 通过栈的共享状态通信。业务若独占执行(不并行 runner),协议事件无人处理——扫描收不到报告、连接维护停摆、控制器缓冲耗尽后栈死锁。join(runner.run(), async {...}) 是标准骨架:两者生命周期绑定,业务分支通过角色句柄发起操作、通过 EventHandler 接收事件。这个"引擎 + 句柄 + 回调"三角是嵌入式协议栈(TrouBLE、embassy-net 类似)的通用架构模式。

考点 2:BLE 广播的 31 字节预算如何分配?CompleteLocalName 与 Flags 各占多少?塞不下时有什么规范内的扩展机制?

参考答案:广播 PDU 的 payload 上限 31 字节,由多个 AD Structure 拼接:每个结构 = 1 字节 Length + 1 字节 Type + data。CompleteLocalName("Ariel OS BLE") = 2 + 13 = 15 字节;Flags = 2 + 1 = 3 字节;共 18,余 13。扩展机制:(1) scan response——可扫描广播(如示例的 NonconnectableScannableUndirected)允许对主动扫描者另行返回最多 31 字节的 scan_data,两段合计 62;(2) BLE 5 扩展广播——更长载荷、更多广播集,但兼容性与实现支持有别(Ariel OS 示例走经典路径);(3) 压缩本身——短名(ShortenedLocalName)、UUID 列表代替名字。工程上广播数据的每一字节都要预算,是受限协议设计的微型案例。

考点 3:对比 Ariel OS 的 BLE 地址策略(随机默认 + 可选静态)与生产 IoT 设备的地址管理实践,各有什么隐私与功能权衡?

参考答案:Ariel OS 默认启动时生成静态随机地址(高两位为 1,符合规范),不持久、不轮转。隐私优势:地址与设备硬件无关联、每次重启变化,被动追踪者无法跨重启关联设备。功能劣势:主机侧无法用地址做白名单/绑定,运维识别困难。可选静态地址(ble-config-static-address + 环境变量)换取可识别性,代价是固定身份可被长期追踪。生产实践通常更细: bond 时交换身份密钥(IRK)实现"可解析私有地址"(RPA)——地址周期轮转但已配对设备可解析,兼顾隐私与识别。Ariel OS 当前无 RPA(Book 标注为现状),要求识别性的部署应选静态地址并接受权衡,或等待路线图进展。

考点 4:扫描器示例用 heapless::Deque<BdAddr, 128> 做去重,这个设计的内存与时效特征是什么?换成 heap::Vec 或 HashSet 有何问题?

参考答案:Deque 定长 128 条 BdAddr(6 字节 + 对齐),静态分配约 1 KiB,上限确定、无失败路径外溢(满了 pop_front 淘汰最旧,"最近 128 个设备"语义)。时效特征:地址的保留时长取决于事件速率——设备密集环境(展会)淘汰很快,安静环境几乎永驻。线性查找 any() 在 128 条内是微秒级,可接受。换 heap::Vec:需要 alloc(Ariel OS 默认不开,开了也引入碎片与分配延迟),上限不确定;换 HashSet:heapless::HashSet 定长可行,但去重只需"在不在"加淘汰,Deque 的 FIFO 淘汰更简单且无哈希计算。这个案例体现 no_std 数据结构选择三原则:静态内存、有界性、淘汰策略明确。

考点 5:ble 模块 selects hwrng 的强制依赖传递了什么设计信号?对比"安全可选"的设计会有什么后果?

参考答案:信号是"安全不是可选项":BLE 的密钥协商、配对、nonce 全部依赖不可预测随机源,若 hwrng 可选,用户忘选时栈要么降级到弱熵(历史漏洞模式)要么运行时才失败(部署后才发现)。Ariel OS 把依赖编码进 laze 模块图,构建期强制 hwrng 就绪(TRNG 在系统 init 先于 BLE 栈播种),不存在"不安全但能编译"的路径。反例后果:历史上多款 IoT 芯片因 RNG 熵不足导致 TLS/BLE 密钥可预测被实际攻破;"安全可选"的设计把安全决策推给了最容易忽略它的应用作者。这与 CoAP 默认 OSCORE(第 23 篇)一脉相承——Ariel OS 的安全默认值哲学贯穿无线与网络子系统。

小结

本篇走通了 Ariel OS BLE 的第一对角色:广播器用 ble_stack() → build() → join(runner, 业务) 的标准骨架,把 31 字节的广播预算花在名字与 Flags 上;扫描器用 run_with_handler 的回调模型与 heapless 去重环形队列,把周边 BLE 环境尽收眼底。关键约束都已交底:MTU 27、地址默认随机且可经 CONFIG_BLE_STATIC_ADDRESS 固定、ble_stack() 全局唯一。ESP32-C6 对 BLE 的原生完整支持,让"广播-扫描对联"实验零门槛。

下一篇回到有线:以太网(STM32 内置 MAC 与 WIZnet 外置芯片)、设备元数据与硬件身份体系——USB 篇的 VID/PID 之后,看 Ariel OS 如何从芯片硅片上读出设备的"身份证"。

系列目录

  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-28 00:45  navysummer_hardware  阅读(9)  评论(0)    收藏  举报