RP2040#
The RP2040 is Raspberry Pi's microcontroller, and the Raspberry Pi Pico is the
board it ships on: two Cortex-M0+ cores, no operating system, no radio on the
plain model, and a price under five dollars. pamoja's no_std crates run on
it over its own peripherals through rp2040-hal, the community's Rust hardware
layer for the chip. This page works through the Pico; the
hardware page has its card.
The programs here are Rust, and that is the chip rather than a gap. TypeScript, Python, and C# run on a host with an operating system, which the RP2040 does not have. From those languages a Pico is reached over the link it speaks, a serial line or a radio, and a part wired to a Linux board is driven directly, as the Raspberry Pi page shows in all four.
The board#
From the Pico datasheet: an RP2040 with 2 MB of flash, dual-core Cortex-M0+ at up to 133 MHz, 264 kB of SRAM, 26 multi-function 3.3 V GPIO on a 40-pin footprint with castellated edges, 3 of them ADC capable, two UARTs, two I2C controllers, two SPI controllers, 16 PWM channels, a 12-bit 500 ksps ADC, USB 1.1, and two programmable I/O blocks. The board's I/O voltage is fixed at 3.3 V, and the datasheet is plain that the pinout is by GP number, printed on the board, not by the header position.
Programming needs no debugger. Hold the BOOTSEL button while plugging the
board in and it appears as a USB mass-storage device; dragging a UF2 file onto
it writes the flash and restarts the board. picotool, Raspberry Pi's own
tool, does the same from the command line, and the program below names it as
the runner.
The board's own SDK fixes the default pins, and the program uses them:
| Bus | Signal | GP |
|---|---|---|
| I2C0 | SDA | GP4 |
| I2C0 | SCL | GP5 |
| SPI0 | SCK | GP18 |
| SPI0 | TX (MOSI) | GP19 |
| SPI0 | RX (MISO) | GP16 |
| SPI0 | CSn | GP17 |
| UART0 | TX | GP0 |
| UART0 | RX | GP1 |
| LED | on board | GP25 |
Wiring a BME280#
Wire the BME280's SDA to GP4, its SCL to GP5, VIN to the 3V3(OUT) pin, and
GND to any ground pin. To see the readings, a USB serial adapter on GP0 (the
board's TX, to the adapter's RX) and GP1 (RX, to the adapter's TX) at 115200
baud, with its ground joined to the board's. The chip answers at 0x76
unless the breakout's jumper moves it to 0x77.
The toolchain#
rustup target add thumbv6m-none-eabi
cargo install picotoolThe Cortex-M0+ is the thumbv6m-none-eabi target. The program's
.cargo/config.toml sets that target and names picotool as the runner, so
with the board in BOOTSEL mode cargo run loads and starts it.
The first program#
The program below is a complete package at
examples/boards/rp2040,
cross-compiled in CI on every change. It brings up the clocks from the board's
crystal, opens I2C0 on GP4 and GP5, hands it to the BME280 driver, and writes
a compensated reading over UART0 every two seconds, toggling the LED on each.
From examples/boards/rp2040/src/main.rs:
use hal::gpio::{FunctionI2C, Pin};
use hal::uart::{DataBits, StopBits, UartConfig, UartPeripheral};
use pamoja_sensors::bme280::{Bme280, I2C_ADDRESS_PRIMARY};
#[hal::entry]
fn main() -> ! {
{
const HEAP_SIZE: usize = 4 * 1024;
static mut HEAP_MEM: [core::mem::MaybeUninit<u8>; HEAP_SIZE] =
[core::mem::MaybeUninit::uninit(); HEAP_SIZE];
// SAFETY: the heap is initialized once, here, before anything allocates.
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
}
let mut pac = hal::pac::Peripherals::take().expect("the peripherals are taken once");
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.expect("the clocks come up from the crystal");
let sio = hal::Sio::new(pac.SIO);
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// I2C0 on the pins the board's own SDK calls its default I2C, GP4 and GP5, at the
// 400 kHz the BME280 accepts.
let sda: Pin<_, FunctionI2C, _> = pins.gpio4.reconfigure();
let scl: Pin<_, FunctionI2C, _> = pins.gpio5.reconfigure();
let i2c = hal::I2C::i2c0(
pac.I2C0,
sda,
scl,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
// UART0 on GP0 and GP1 carries the readings to a serial adapter; the LED on GP25
// toggles on each one.
let uart_pins = (pins.gpio0.into_function(), pins.gpio1.into_function());
let mut uart = UartPeripheral::new(pac.UART0, uart_pins, &mut pac.RESETS)
.enable(
UartConfig::new(115_200.Hz(), DataBits::Eight, None, StopBits::One),
clocks.peripheral_clock.freq(),
)
.expect("a valid UART configuration");
let mut led = pins.gpio25.into_push_pull_output();
// The timer is the driver's delay: the datasheet's start-up and measurement waits.
let mut timer = hal::Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let mut sensor = Bme280::i2c(i2c, I2C_ADDRESS_PRIMARY, timer);
sensor.init().expect("the BME280 answers on I2C0");
loop {
let measurement = sensor.measure().expect("a measurement");
let _ = writeln!(
uart,
"{:.2} C, {:.2} hPa, {:.2} % humidity\r",
measurement.celsius(),
measurement.hectopascals(),
measurement.relative_humidity_percent()
);
let _ = led.toggle();
timer.delay_ms(2000);
}
}Hold BOOTSEL, plug the board in, and run it from that directory:
cd examples/boards/rp2040
cargo run --releaseThree things above the snippet in the source are the chip's own requirements.
The first 256 bytes of flash hold a second-stage bootloader that configures the
flash chip, which rp2040-boot2 supplies and the linker script memory.x
places. A panic halts the core, through panic-halt. And the binary declares
a small heap through embedded-alloc: the core's own types hold an owned topic
and payload, so a no_std binary that links them needs an allocator, though
the driver itself never allocates.
A LoRa radio on SPI1#
The second program puts the Pico on the air with an SX1262. The Waveshare Pico-LoRa-SX1262 is a hat rather than a breakout: the Pico slots into its header, and the board wires SPI1 to the radio for you, along with its control lines and its antenna.
| Signal | GP | What it is |
|---|---|---|
| LoRa_CLK | GP10 | the SPI clock |
| LoRa_MOSI | GP11 | data to the radio |
| LoRa_MISO | GP12 | data from the radio |
| LoRa_CS | GP3 | chip select, active low |
| LoRa_BUSY | GP2 | high while the chip is busy |
| LoRa_RESET | GP15 | reset, active low |
| LoRa_DIO1 | GP20 | the interrupt line |
The board's own example code says what surrounds the chip, which no SPI read can tell you: a 1.7 V TCXO powered from DIO3, the antenna switch on DIO2, and the DC-DC regulator. Those three are the board description the program passes to the driver. The driver polls the chip's interrupt register rather than DIO1, so that line stays free.
From examples/boards/rp2040/src/bin/radio.rs:
use embedded_hal::spi::MODE_0;
use embedded_hal_bus::spi::ExclusiveDevice;
use hal::gpio::{FunctionSpi, Pin, PullNone};
use hal::uart::{DataBits, StopBits, UartConfig, UartPeripheral};
use pamoja_lora::LinkSettings;
use pamoja_radios::duty::DutyCycle;
use pamoja_radios::sx126x::config::{PowerAmplifier, TcxoVoltage, TxPower};
use pamoja_radios::sx126x::{Board, RadioConfig, Reception, Sx126x, DEFAULT_TCXO_SETTLE_US};
/// The channel this node uses, what it sends at, and the share of time it may hold the band.
const FREQUENCY_HZ: u32 = 868_100_000;
const OUTPUT_DBM: i8 = 14;
const DUTY_CYCLE_PERMILLE: u32 = 10;
/// How long the radio listens before it looks at its duty-cycle budget again, in microseconds.
const LISTEN_US: u64 = 10_000_000;
#[hal::entry]
fn main() -> ! {
let mut pac = hal::pac::Peripherals::take().expect("the peripherals are taken once");
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.expect("the clocks come up from the crystal");
let sio = hal::Sio::new(pac.SIO);
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// SPI1 on the pins the board wires to the radio: GP10 the clock, GP11 out, GP12 in. Two
// megahertz is far under the chip's 16 MHz limit and is what the reference drivers use.
let sck: Pin<_, FunctionSpi, PullNone> = pins.gpio10.reconfigure();
let mosi: Pin<_, FunctionSpi, PullNone> = pins.gpio11.reconfigure();
let miso: Pin<_, FunctionSpi, PullNone> = pins.gpio12.reconfigure();
let bus = hal::Spi::<_, _, _, 8>::new(pac.SPI1, (mosi, miso, sck)).init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
2.MHz(),
MODE_0,
);
// The chip select is a plain output the bus drives around each transaction, which is what
// an exclusive device does for a bus with one chip on it.
let timer = hal::Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let cs = pins.gpio3.into_push_pull_output();
let device = ExclusiveDevice::new(bus, cs, timer).expect("the chip select takes a level");
// What the board wires around the chip, from its own example code: a 1.7 V TCXO powered
// from DIO3, the antenna switch on DIO2, and the DC-DC regulator.
let board = Board::new(PowerAmplifier::HighPower)
.with_tcxo(TcxoVoltage::V1_7, DEFAULT_TCXO_SETTLE_US)
.with_dio2_rf_switch()
.with_dc_dc();
let busy = pins.gpio2.into_floating_input();
let reset = pins.gpio15.into_push_pull_output();
let mut radio = Sx126x::new(device, busy, reset, timer, board);
radio.init().expect("the SX1262 answers on SPI1");
// SF9 at 125 kHz, which is DR3 in the European plan, at 14 dBm.
let link = LinkSettings::new(9, 125_000);
let power = TxPower::for_output(PowerAmplifier::HighPower, OUTPUT_DBM);
radio
.configure(RadioConfig::new(FREQUENCY_HZ, link, power))
.expect("the chip takes the settings");
// UART0 on GP0 and GP1 carries what the node hears and sends.
let uart_pins = (pins.gpio0.into_function(), pins.gpio1.into_function());
let mut uart = UartPeripheral::new(pac.UART0, uart_pins, &mut pac.RESETS)
.enable(
UartConfig::new(115_200.Hz(), DataBits::Eight, None, StopBits::One),
clocks.peripheral_clock.freq(),
)
.expect("a valid UART configuration");
let _ = writeln!(uart, "beacon on {FREQUENCY_HZ} Hz at SF9, {OUTPUT_DBM} dBm\r");
// Each frame buys silence in proportion to its airtime, and the guard says when the next
// one may go out. The chip's own timer counts microseconds, which is what it counts in.
let mut duty = DutyCycle::new(DUTY_CYCLE_PERMILLE);
let mut buffer = [0u8; 255];
let mut reading = 0u32;
loop {
match radio.receive(&mut buffer, LISTEN_US) {
Ok(Reception::Frame { len, status }) => {
let heard = core::str::from_utf8(&buffer[..len]).unwrap_or("(not text)");
let _ = writeln!(
uart,
"heard {heard} at {} dBm, SNR {} dB\r",
status.rssi_dbm.round_db(),
status.snr_db.round_db()
);
}
Ok(Reception::Corrupt) => {
let _ = writeln!(uart, "heard a frame whose CRC failed\r");
}
Ok(Reception::Timeout) => {}
Err(_) => {
let _ = writeln!(uart, "the radio stopped answering\r");
}
}
let now_us = timer.get_counter().ticks();
if duty.ready(now_us) {
let mut frame = [0u8; 32];
let len = beacon(&mut frame, reading);
match radio.transmit(&frame[..len]) {
Ok(airtime_us) => {
duty.transmitted(now_us, &link, len);
let _ = writeln!(uart, "sent reading {reading} in {airtime_us} us on air\r");
reading += 1;
}
Err(_) => {
let _ = writeln!(uart, "the frame did not go out\r");
}
}
}
}
}
/// Writes a beacon into a buffer without allocating, and returns its length.
fn beacon(frame: &mut [u8; 32], reading: u32) -> usize {
let mut cursor = Cursor { frame, len: 0 };
let _ = write!(cursor, "pico reading {reading}");
cursor.len
}
/// A writer over a fixed buffer, so a `no_std` node can format a frame.
struct Cursor<'a> {
frame: &'a mut [u8; 32],
len: usize,
}
impl Write for Cursor<'_> {
fn write_str(&mut self, text: &str) -> core::fmt::Result {
for byte in text.as_bytes() {
if self.len == self.frame.len() {
return Err(core::fmt::Error);
}
self.frame[self.len] = *byte;
self.len += 1;
}
Ok(())
}
}cargo run --release --bin radioThe program never allocates: the frame is formatted into a fixed buffer, and the driver, the link arithmetic, and the duty-cycle guard all work in place. What it does allocate to the chip is time, through the same duty-cycle guard a gateway uses, counted on the RP2040's own microsecond timer.
Where next#
- Buses and links, for what each bus on the chip is for, and the bus layer guide for the traits and the scripted bus a driver is tested against with nothing plugged in.
- Sensor drivers and actuator drivers, for every shipped part.
- Your own device, for a part pamoja has never heard of.
Sources#
- Raspberry Pi Pico Datasheet, release 21, for the board's features, its I/O voltage, the BOOTSEL mode, and the pinout.
pico.hin the Raspberry Pi Pico SDK, for the board's default UART, I2C, SPI, and LED pins.rp2040-halon docs.rs, version 0.12, for the clocks, the I2C and UART drivers, and the timer that implements theembedded-haldelay, with the crate's own examples for the boot block and linker layout.