An async no_std Bluetooth Low Energy host for ESP32 RISC-V chips: the Apache NimBLE host stack wrapped in safe
async Rust for Embassy, running against Espressif's BLE controller via esp-radio.
The point of this crate is not to be another Rust BLE stack. It is to offer a credible path to Bluetooth qualification for products written in Rust.
Rust's embedded ecosystem has no qualified BLE host. At the time of writing,
trouble is essentially the only pure-Rust BLE host - it is a good project,
but its feature coverage is still incomplete and, more importantly for commercial work, it is neither qualified nor
pre-qualified with the Bluetooth SIG. If you want to ship a Bluetooth product written in Rust and you need to satisfy a
qualification process, you currently have no obvious route.
This crate takes a different approach: rather than writing a host in Rust, it reuses components that already have a qualification track record and keeps the Rust contribution as thin and as clearly-bounded as possible.
┌─────────────────────────────────────────────────────────┐
│ Your application (Rust, Embassy) │
├─────────────────────────────────────────────────────────┤
│ esp-nimble-host ← this repo: safe async Rust │
│ wrappers + build integration │
├─────────────────────────────────────────────────────────┤
│ Apache NimBLE host (C, upstream + 3 disclosed patches) │ ◀── host stack with an
│ GAP · GATT · ATT · L2CAP · SM │ established record in
│ │ qualified products
├─────────────────────────────────────────────────────────┤
│ NimBLE Porting Layer (NPL) │ ◀── the glue: mapped onto
│ → esp-radio RTOS driver interface (esp-rtos) │ esp-rtos primitives
│ → ESP ROM functions │ and ROM functions
├─────────────────────────────────────────────────────────┤
│ HCI (H4 framing) │
├─────────────────────────────────────────────────────────┤
│ Espressif BLE controller blob (via esp-radio/esp-hal) │ ◀── pre-qualified
│ │ controller
└─────────────────────────────────────────────────────────┘
The argument, in short:
- The controller is Espressif's, used as-is through the
esp-hal/esp-radioecosystem. Espressif publishes qualified designs for its Bluetooth subsystems, so the controller is not something this project needs to re-qualify. - The host is Apache NimBLE, a stack widely used in products that have gone through Bluetooth qualification. Its protocol logic is compiled from upstream sources plus three disclosed patches (see Modifications to NimBLE) and is not reimplemented here.
- What this project actually adds is the NPL glue and the Rust API surface. NPL is a porting layer - timers,
mutexes, event queues, memory pools - not protocol behaviour. Because the qualifiable protocol logic sits above it,
modified only by the patches listed in Modifications to NimBLE, adapting NPL to
esp-rtosshould not, in principle, undermine an argument for reusing NimBLE's qualification in this configuration.
That is the reasoning. It is a design intended to keep qualification reachable, not a claim that qualification has happened.
This project is not qualified, not certified, and not pre-qualified. Nothing in this repository has been submitted to, reviewed by, or blessed by the Bluetooth SIG.
Specifically:
- The Bluetooth SIG has not been consulted. The rationale above is our own engineering judgement about why this composition should be qualifiable. It has not been tested against the SIG's actual process, and the SIG may simply disagree.
- Qualification is per end product. Reusing pre-qualified components does not make your product qualified. You are responsible for the qualification and listing of whatever you ship, including any required testing of the host/controller combination.
- It is on you to verify the component claims. Which Espressif QDIDs apply to your exact chip, module, and blob version - and what NimBLE's qualification status is for the version you build - are facts you must confirm from the SIG's listings and from Espressif, not from this README.
- This build patches NimBLE C sources (see Modifications to NimBLE). Two of the three patches touch the host proper, not just the porting layer, and one of those changes what the host sends: one more GATT request during characteristic discovery. They are modifications to the stack and you should treat them as something to disclose and discuss if you pursue qualification.
- Trademark and membership obligations are yours. Using Bluetooth technology and branding commercially carries Bluetooth SIG membership and licensing requirements independent of this code.
If you are heading toward a commercial launch, talk to the Bluetooth SIG and, ideally, a qualification consultant or an authorised test lab early. Treat this repository as a technical starting point that was built with qualification in mind - not as evidence of compliance.
Working today, exercised on hardware:
- Scanning / observer - active and passive discovery, multi-subscriber advertisement stream, raw AD payloads plus parsed advertisement fields.
- Central - connect, disconnect, connection-event stream (MTU changes, connection-parameter updates, disconnects), MTU exchange.
- GATT client - service / characteristic / descriptor discovery (all, or by service UUID), attribute read and write (with and without response, long writes handled against the negotiated MTU), notification and indication stream. A write without response that exceeds the MTU is sent as a long write, which is acknowledged.
- Pairing - Legacy passkey pairing (
BLE_SM_IOACT_INPUT/DISP). Enabled in the default configuration, with MITM protection.
Not implemented:
- Peripheral / broadcaster roles. There is no advertising API and no GATT server API.
nimble_sys/gatts.rscontains only groundwork. The NimBLE config defaults reflect this (peripheral = false,broadcaster = false), though the underlying C stack can be compiled with those roles enabled. - Extended advertising.
BLE_EXT_ADVis off;BLE_GAP_EVENT_EXT_DISCis logged and ignored. - ISO / LE Audio.
ble_transport_to_ll_iso_implreturnsBLE_HS_ENOTSUP. - Secure Connections pairing and bonding are compiled out by default; the SM options exist in the configuration but are not exercised.
Supported chips: ESP32-C6, ESP32-C61, ESP32-C5 - all RISC-V. There is no Xtensa support; the build hardcodes a RISC-V target for the C compilation.
There are no unit or integration tests in this crate; verification happens in consuming applications on real hardware.
- Rust nightly.
src/lib.rsuses#![feature(c_size_t)]. - A RISC-V bare-metal target, e.g.
riscv32imac-unknown-none-elf. clangon the build host, with RISC-V target support. NimBLE is cross-compiled withclang, not with GCC.- Network access on the first build.
build.rsdownloads the NimBLEnimble_1_9_0_tagtarball intoOUT_DIR. It is cached afterwards, butcargo cleanforces a re-download. The source tree is re-extracted from the cached tarball on every build-script run, so the patches always apply to pristine sources. - Access to
github.com/peeriot/esp-hal.esp-halandesp-radioare pulled over HTTPS from a fork (branchfeature/ble-host-npl-upstream) that carries theble-host-nplNPL implementation. This is the piece that makes NimBLE run onesp-rtos; upstreamesp-radiodoes not provide it.
[dependencies]
esp-nimble-host = { git = "https://github.com/peeriot/esp-nimble-host.git", features = ["esp32c6"] }Building the library on its own:
cargo +nightly clippy --target riscv32imac-unknown-none-elf --features esp32c6
cargo +nightly build --release --target riscv32imac-unknown-none-elf --features esp32c6Use --release for anything that runs on hardware - esp-hal warns about this, and the dev profile is slow enough to
disturb timing-sensitive peripherals.
Three long-running tasks must be up before any BLE API is touched, and the priorities they run at matter. The HCI transport has to outrank the host, which has to outrank the controller; if the transport is starved, controller-to-host packets back up and leak.
A working arrangement, as used in production:
| Task | Priority | Kind |
|---|---|---|
HCI transport (transport_task_rx + transport_task_tx) |
31 | dedicated OS thread running its own Embassy executor |
NimBLE host (host_task) |
30 | OS task |
| BLE controller | 29 | OS task, spawned by esp-radio |
| Application | 1 | main Embassy executor |
esp-rtos priorities range up to 31, and task_create silently clamps anything higher. The controller's default
priority is the maximum minus 2.
host_task runs nimble_port_run() and must not be spawned as an Embassy task - it does not yield to the async
executor. Spawn it as an OS task.
// Sketch - see a consuming application for a complete, compiling setup.
// 1. Start the RTOS.
esp_rtos::start(timg0.timer0, peripherals.FROM_CPU_INTR0);
// 2. HCI transport on a dedicated high-priority thread with its own executor.
extern "C" fn ble_transport_thread(_: *mut c_void) {
static EXECUTOR: StaticCell<Executor> = StaticCell::new();
let executor = EXECUTOR.init(Executor::new());
executor.run(|spawner| {
let bluetooth = unsafe { BT::steal() };
// controller_config() sets the controller's connection limit to the host's.
let connector = BleConnector::new(bluetooth, esp_nimble_host::controller_config()).unwrap();
let transport = HostTransport::new(connector); // initialises NimBLE
spawner.spawn(ble_transport_tx()).unwrap(); // -> transport_task_tx()
spawner.spawn(ble_transport_rx(transport)).unwrap(); // -> transport_task_rx(t)
});
}
unsafe {
esp_radio_rtos_driver::task_create(
"HCI transport", ble_transport_thread, core::ptr::null_mut(), 31, None, BLE_HCI_STACK,
);
// 3. The NimBLE host event loop as an OS task.
esp_radio_rtos_driver::task_create(
"BLE Host", esp_nimble_host::host_task, core::ptr::null_mut(), 30, None, BLE_HOST_STACK,
);
}
// 4. Wait for host/controller sync, then use the API.
esp_nimble_host::wait_for_sync().await;
// Annotate the types: the default mutex parameter is not used for inference.
let mut scanner: Scanner = Scanner::new();
scanner.start_scan(None)?;
// The subscriber borrows `scanner`, so drop it before stopping the scan.
let addr = {
let mut advs = scanner.subscribe()?;
loop {
if let WaitResult::Message(raw) = advs.next_message().await {
break raw.addr().clone(); // pick the device you want here
}
}
};
// NimBLE rejects a connection attempt while scanning (BLE_HS_EBUSY).
scanner.stop_scan()?;
let peripheral: Peripheral = Peripheral::new(addr);
peripheral.connect().await?;
peripheral.discover_all_services().await?;
// read / write / subscribe ...To receive notifications, subscribe with Peripheral::subscribe() and enable them on the remote device by writing
the CCCD via write_descriptor - there is no combined helper.
nimble-config.toml drives the compile-time configuration of the NimBLE stack: roles, maximum connections, HCI
transport buffer counts and sizes, the msys mbuf pool, GATT MTU and procedure limits, L2CAP, bonding storage, and the
security manager. build.rs turns it into MYNEWT_VAL_* C defines that override NimBLE's syscfg.h defaults.
The file is extensively commented, including RAM and flash cost estimates per option - read it before changing sizing. Defaults target a central + observer workload with up to 4 connections and legacy pairing with MITM protection.
To change the configuration from a consuming project, create a nimble-config.toml holding only the values you want
to change and point NIMBLE_CONFIG_DIR at its directory in the project's .cargo/config.toml:
[env]
NIMBLE_CONFIG_DIR = { value = ".", relative = true }The project file is merged over the bundled one key by key, so everything it leaves out keeps its bundled value.
Unknown sections or keys fail the build. The BLE controller keeps its own connection limit (2 by default in
esp-radio): create it with BleConnector::new(bt, esp_nimble_host::controller_config()) so it follows
connections.max_connections. NIMBLE_CONFIG_DIR must be an absolute path (hence relative = true) to a directory that
contains a nimble-config.toml, otherwise the build fails.
Changing this file changes what gets compiled: enabling security.legacy or security.sc additionally pulls in
ext/tinycrypt (security.sc also its P-256 ECDH and AES-CMAC), and disabling roles compiles that code out entirely.
With both security.legacy and security.sc off, the security manager is compiled out and
Peripheral::pair_with_passkey fails at runtime.
NimBLE is downloaded at build time rather than vendored, and build.rs applies three source patches before compiling.
All are applied by string match to a freshly extracted tree and deliberately panic unless the expected pattern
occurs exactly once, so a NimBLE version bump fails loudly instead of silently dropping a fix.
Two are memory-lifecycle fixes required to run NimBLE against the esp-radio NPL and do not change protocol behaviour:
porting/nimble/src/os_mempool.c- zero each memory-pool block on allocation. NimBLE threads its free-list pointer through the first bytes of a freed block; the NPL stores a heapEventpointer inble_npl_event.dummy, andble_npl_event_initskips initialisation whendummy != 0. Without this patch a recycled block looks already-initialised and the stale free-list pointer is eventually called as a function pointer, producing an Illegal Instruction crash.nimble/host/src/ble_hs.c- callble_npl_event_deinit()before returning an event block toble_hs_hci_ev_pool, otherwise the heapEventleaks on every recycle. As of NimBLE 1.9 this is the only such call site.
One changes what the host does on the air:
nimble/host/src/ble_gattc.c- fix an off-by-one in the end-of-discovery check of Discover All Characteristics, which drops a service's last characteristic when it occupies the service's final two handles. The host now sends one more Read By Type request in that case, as the procedure requires.
Two of the three patches modify the host, not the porting layer. If you pursue qualification, treat all of them as
material to disclose. See the doc comments in build.rs for the full rationale.
| Path | Contents |
|---|---|
src/lib.rs |
Scanner, HostTransport, HCI transport tasks, host sync, C FFI callbacks |
src/peripheral.rs |
Peripheral - connect, pair, GATT operations, GAP event dispatch |
src/discovery.rs, src/characteristic.rs, src/service.rs |
GATT client discovery and attribute access |
src/peripheral_operation.rs |
callback-to-async bridge for one-shot GATT procedures |
src/data.rs, src/error.rs |
addresses, advertisements, conversions, error taxonomy |
src/nimble_sys/ |
the FFI boundary - safe wrappers over the generated bindings |
src/libc.rs |
libc shims NimBLE links against (the rest come from tinyrlibc) |
nimble/ |
freestanding libc header stubs that shadow system headers during the cross-compile |
build.rs |
config generation, NimBLE download, patching, bindgen, C compilation |
nimble-config.toml |
compile-time NimBLE stack configuration |
prj/project.yml |
Mynewt newt descriptor, used only to regenerate syscfg reference values |