Embedded devices
Run a no_std endpoint on your board's smoltcp stack.
This guide assumes your board already has a working smoltcp Device and configured Interface with an IP address and route. minip2p does not set up the MAC, PHY, DHCP, SLAAC, or interrupt controller. You supply the network device, monotonic timer, and hardware entropy.
An embedded endpoint runs the same TCP, Noise XX, Yamux, Identify, Ping, and application protocols as a hosted TCP endpoint. Only the I/O provider and driving loop change.
Add the dependency
Disable default features. smoltcp pulls in portable TCP and the shared embedded stack:
cargo add minip2p-rs --no-default-features --features smoltcp
Add only what you need:
| Feature | Adds |
|---|---|
pubsub |
Gossipsub, plus optional signed-beacon discovery |
portable-mdns |
Portable mDNS and the shared discovery book; already included by smoltcp |
portable-autonat |
AutoNAT probing over the portable endpoint |
portable-relay |
Relay-only circuit connections; includes portable AutoNAT |
None of these needs smoltcp: they configure the same PortableEndpoint over any transport you build yourself (Endpoint::portable(..).build(transport), or build_with_circuits over a CircuitTransport for relays). .smoltcp(stack) is the shortcut that builds TCP and mDNS on one stack for you.
The QUIC adapter needs std, so embedded endpoints cannot use QUIC or DCUtR. They can still use relay circuits.
What you supply
You own three things:
- A
smoltcp::phy::Deviceand configuredsmoltcp::iface::Interface. - A monotonic millisecond counter used to build
Nowvalues. - An
EntropySourcefrom a cryptographically secure hardware RNG or a securely seeded DRBG.
Do not use a predictable PRNG. Identity, Noise, and protocol nonces all depend on this entropy; if it fails, the operation must fail.
Wrap the network objects in one shared stack:
use minip2p::SmoltcpStack;
let device = board_network_device();
let interface = configured_smoltcp_interface(&mut device);
let stack = SmoltcpStack::new(device, interface);
SmoltcpStack is single-threaded on purpose. Clones let the TCP and mDNS adapters install sockets on the same interface, but the endpoint polls them serially. Do not hold stack.borrow_mut() while calling the endpoint.
Build the endpoint
Load or create an Ed25519 identity, inject entropy, add a TCP listener, and register application protocols before build:
use minip2p::Endpoint;
let identity = load_device_identity()?;
let entropy = HardwareEntropy::new(board_rng());
let mut endpoint = Endpoint::portable(&identity, entropy)
.agent_version("sensor-node/0.1.0")
.smoltcp(stack)
.listen("/ip4/0.0.0.0/tcp/4001")
.protocol("/example/sensor/1.0.0")
.build()?;
Prefer a real interface address over 0.0.0.0 when Identify and discovery should advertise something peers can dial right away. When DHCP or SLAAC changes the interface addresses, update the shared smoltcp interface through a SmoltcpStack clone you kept from before .smoltcp(stack); a wildcard listener follows the change on later polls, and Identify advertises the new address without another listen.
Optional services go on the same builder. The following example requires the smoltcp and pubsub features because signed discovery uses pubsub:
let mut endpoint = Endpoint::portable(&identity, entropy)
.smoltcp(stack)
.listen("/ip4/0.0.0.0/tcp/4001")
.mdns()
.discovery()
.build()?;
For this combination, install with cargo add minip2p-rs --no-default-features --features smoltcp,pubsub. mDNS does not use pubsub.
Drive time and network progress
No executor, no background thread. Sample the monotonic clock once, pass that same Now into the endpoint, handle every event, then use next_deadline to decide how long the board may sleep. Each poll runs one batch and returns its events in the same order as the hosted endpoint: swarm events, then capability events, then ConnectSettled terminals. poll(now) and shutdown(now) return Vec<EndpointEvent>; enabled services add EndpointEvent::Nat, EndpointEvent::Gossipsub, EndpointEvent::Discovery, and (with mDNS) EndpointEvent::MdnsFailed / MdnsRecovered variants. An mDNS carrier failure is one of those events, never a poll error: mDNS retries and the rest of the endpoint keeps running. In shutdown(now) mDNS goodbyes are best effort, so a failed one appears as MdnsFailed among the returned events:
use minip2p::{EndpointEvent, Now};
loop {
let now = Now::from_millis(monotonic_millis());
for event in endpoint.poll(now)? {
match event {
EndpointEvent::Discovery(event) => handle_discovery_event(event),
EndpointEvent::Gossipsub(event) => handle_pubsub_event(event),
other => handle_endpoint_event(other), // EndpointEvent is non-exhaustive
}
}
let wait_ms = endpoint
.next_deadline(now)
.map(|deadline| deadline.millis_until(now));
wait_for_network_irq_or_timer(wait_ms);
}
Wake early when the network stack gets a frame or transmit capacity frees up. Deadline::IMMEDIATE means poll again without sleeping; None means minip2p has no timer armed, but a network interrupt must still wake the loop.
Budget memory explicitly
Defaults aim at small systems; they are not a promise every board can afford the same concurrency. Set limits before build:
use minip2p::{SmoltcpConfig, TcpConfig};
let provider = SmoltcpConfig {
rx_buffer: 4 * 1024,
tx_buffer: 4 * 1024,
backlog: 1,
max_sockets: 4,
..SmoltcpConfig::default()
};
let transport = TcpConfig {
max_connections: 3,
max_buffered_send: 16 * 1024,
..TcpConfig::default()
};
let endpoint = Endpoint::portable(&identity, entropy)
.smoltcp(stack)
.smoltcp_config(provider)
.tcp_config(transport)
.listen("/ip4/0.0.0.0/tcp/4001")
.build()?;
The provider reserves rx_buffer + tx_buffer bytes per socket, up to max_sockets. Backlog sockets count toward that ceiling. Smaller buffers save RAM but hurt throughput on high-latency links; smaller socket and connection caps reject excess work instead of allocating past the budget.
Size the rest of the heap from counts, windows and queues; there is no memory budget behind them (ADR 0022). A connection can retain up to yamux.max_streams streams holding yamux.receive_window unacknowledged bytes each, its Yamux send caps (yamux.max_total_buffered_send, up to twice that while a queued write keeps a slice of its caller’s payload), and max_buffered_send of outbound bytes, and there are at most max_connections connections. The defaults (256 streams, a 256 KiB window, a 4 MiB send cap) are sized for hosts; shrink them to what the heap can actually spare. build rejects a Yamux configuration that cannot work.
Also budget stream payloads and pubsub queues. Treat backpressure as normal: wait for later polls instead of spinning or growing an unbounded host queue.
Bring-up checklist
- Confirm the interface can exchange ordinary IP and TCP traffic before adding libp2p.
- Keep one persistent Ed25519 secret per device when peer identity must survive reboot.
- Verify the entropy source’s startup and continuous health checks.
- Drive the endpoint after receive interrupts and at every reported deadline.
- Advertise a real interface address, not a wildcard bind address.
- Measure flash, static RAM, heap high-water mark, and stack use on the actual target with production feature flags.
- Test stalled peers, socket exhaustion, link loss, address changes, and long idle periods.
just check-nostd builds the portable crates for thumbv7em-none-eabi. The smoltcp end-to-end tests cover TCP, Noise XX, Yamux, Identify, Ping, streams, discovery, and mDNS across two in-memory network devices. Those checks prove portability and protocol behavior; they do not replace testing the real board driver, timer, RNG, and memory limits.
Next: Register a protocol or review the feature matrix.