<alp/peripheral.h> — Core Buses
The most-used header in the SDK. <alp/peripheral.h> provides handle-based wrappers for the four core bus classes: GPIO, I²C, SPI, and UART.
Header
#include <alp/peripheral.h>
#include <alp/e1m_pinout.h> // for ALP_E1M_* instance IDs
Examples that target both official EVKs can open pins via the portable BOARD_* aliases from <alp/board.h> instead of raw ALP_E1M_* IDs — see Cross-EVK portability.
SDK lifecycle
Applications SHOULD call alp_init() once at startup, before the first alp_*_open call, and alp_deinit() at teardown.
if (alp_init() != ALP_OK) {
return -1;
}
// ... open peripherals, run the app ...
alp_deinit(); // close every open handle first — deinit does not
Both are [ABI-EXPERIMENTAL] (v0.9 new) and return alp_status_t. Today the call is thin — the backend registry is linker-section based and needs no runtime setup, so every current backend works even if you skip it — but future backends (bridge links, clock bring-up, vendor HAL init) are allowed to rely on it, so portable code must not skip it.
alp_init()is idempotent (a second successful call is a no-op that returnsALP_OK) and safe to call concurrently — an atomic once-guard runs the bring-up exactly once.alp_deinit()does not close peripheral handles for you; close them first. It is idempotent and safe to call without a prioralp_init(). Do not racealp_deinit()againstalp_init()or in-flight peripheral calls — the deinit/re-init sequence is single-threaded by contract.
Every examples/peripheral-io/* program now calls alp_init() first.
GPIO
alp_gpio_t *led = alp_gpio_open(ALP_E1M_GPIO_IO1);
if (led == NULL) {
int err = alp_last_error();
return err;
}
alp_gpio_configure(led, ALP_GPIO_OUTPUT, ALP_GPIO_PULL_NONE);
alp_gpio_write(led, 1); // drive high
alp_gpio_write(led, 0); // drive low
bool value;
alp_gpio_read(led, &value);
alp_gpio_close(led);
Input with pull-up + IRQ
alp_gpio_t *btn = alp_gpio_open(ALP_E1M_GPIO_IO0);
alp_gpio_configure(btn, ALP_GPIO_INPUT, ALP_GPIO_PULL_UP);
void on_press(alp_gpio_t *pin, void *user) { /* ... */ }
alp_gpio_irq_enable(btn, ALP_GPIO_EDGE_FALLING, on_press, NULL);
Configuration
alp_gpio_open(pin_id) takes the studio-resolved pin id directly (there is no config struct); direction and pull are set afterwards with alp_gpio_configure(pin, dir, pull).
| Argument | Type | Notes |
|---|---|---|
pin_id | uint32_t | Passed to alp_gpio_open(). Use ALP_E1M_GPIO_IO<N> from <alp/e1m_pinout.h>. |
dir | alp_gpio_dir_t | ALP_GPIO_INPUT / ALP_GPIO_OUTPUT. |
pull | alp_gpio_pull_t | ALP_GPIO_PULL_NONE / ALP_GPIO_PULL_UP / ALP_GPIO_PULL_DOWN. |
I²C
alp_i2c_t *bus = alp_i2c_open(&(alp_i2c_config_t){
.bus_id = ALP_E1M_I2C0,
.bitrate_hz = 400000u, // 100k / 400k / 1M / 3.4M (per-SoC)
});
uint8_t buf[2] = { 0x00, 0x42 };
alp_i2c_write(bus, /* addr */ 0x50, buf, sizeof(buf));
uint8_t reg = 0x00;
uint8_t rx[16];
alp_i2c_write_read(bus, 0x50, ®, 1, rx, sizeof(rx));
alp_i2c_close(bus);
Config struct
| Field | Type | Notes |
|---|---|---|
bus_id | uint32_t | ALP_E1M_I2C0 … ALP_E1M_I2C<N>. |
bitrate_hz | uint32_t | Bus speed. Capped by <alp/soc_caps.h> at _open(). |
Common operations
| Function | Description |
|---|---|
alp_i2c_write(bus, a, buf, n) | Write n bytes. |
alp_i2c_read(bus, a, buf, n) | Read n bytes. |
alp_i2c_write_read(bus, a, tx, n, rx, m) | Repeated-start: write n, read m. |
I²C target (slave) mode
:::info [ABI-EXPERIMENTAL] — v0.9 new
The target-mode surface may change in any minor release; it is not covered by the stable-ABI contract that the controller-mode I²C calls carry.
:::
Target mode makes this MCU answer on the bus as an addressable device — a "register-mapped peripheral" from the external controller's point of view. The callbacks are byte-granular and mirror the wire protocol, so a register-file slave is a ~20-line state machine on top of them. All three callbacks run in ISR context — keep the work minimal (update a state machine, stash into a volatile buffer) and defer anything substantive to a thread / workqueue.
#include <alp/peripheral.h>
static volatile uint8_t regs[16];
static void on_write(uint8_t byte, void *user) { /* controller sent a byte */ }
static alp_status_t on_read(uint8_t *byte, void *user) {
*byte = 0x42; // byte to drive back onto the bus
return ALP_OK; // any error NAKs / ends the transfer
}
static void on_stop(void *user) { /* controller ended the transaction */ }
alp_i2c_target_t *tgt = alp_i2c_target_open(&(alp_i2c_target_config_t){
.bus_id = ALP_E1M_I2C0,
.own_addr_7bit = 0x42, // must be 0x08..0x77
.on_write = on_write, // required
.on_read = on_read, // required
.on_stop = on_stop, // optional; may be NULL
.user = NULL,
});
if (tgt == NULL) {
int err = alp_last_error(); // ALP_ERR_INVAL / NOSUPPORT / NOT_READY / ...
return err;
}
// ... callbacks fire as the controller talks to us ...
alp_i2c_target_close(tgt); // no callback fires after this returns
Config struct
| Field | Type | Notes |
|---|---|---|
bus_id | uint32_t | Studio-resolved bus instance id (same id space as alp_i2c_open). |
own_addr_7bit | uint8_t | Address this target answers on. Must be 0x08..0x77 — the reserved 7-bit ranges 0x00–0x07 / 0x78–0x7F are rejected with ALP_ERR_INVAL. |
on_write | alp_i2c_target_write_cb_t | void(uint8_t byte, void *user) — a byte arrived. Required. |
on_read | alp_i2c_target_read_cb_t | alp_status_t(uint8_t *byte, void *user) — supply the outgoing byte; return ALP_OK to continue, any error to NAK / end. Required. |
on_stop | alp_i2c_target_stop_cb_t | void(void *user) — STOP condition. Optional; may be NULL. |
user | void * | Forwarded to every callback. |
alp_i2c_target_open(cfg)returns an opaquealp_i2c_target_t *, orNULLwithalp_last_error()set —ALP_ERR_INVAL(NULL cfg/callbacks, reserved address),ALP_ERR_NOSUPPORT(backend or controller driver has no target mode),ALP_ERR_NOT_READY(bus alias unset),ALP_ERR_BUSY(address slot already taken),ALP_ERR_OUT_OF_RANGE,ALP_ERR_NOMEM.- Prime any state the callbacks read (register files, counters) before calling — the callbacks start firing as soon as
openreturns. alp_i2c_target_close(tgt)isvoid, idempotent onNULL, and guarantees no callback fires after it returns.
:::note Availability
Requires controller-driver target support (Zephyr: CONFIG_I2C_TARGET plus a driver that implements target_register). Drivers without it fail open() with ALP_ERR_NOSUPPORT (or ALP_ERR_NOT_READY when the bus alias is unset) — applications must degrade cleanly. On native_sim the emulated controller accepts the registration but nothing external ever drives it, so callbacks never fire there.
:::
Register-file I²C target
<alp/i2c_regfile.h> ships the classic register-file state machine once, so applications stop re-pasting it on top of the byte-granular target callbacks. It exposes a caller-owned RAM buffer as a register-mapped I²C target:
- controller write, byte 0 → latches the register pointer (taken modulo the file length, EEPROM-style wraparound);
- controller write, bytes 1.. → store into the buffer at the pointer, auto-increment with wraparound;
- controller read → streams the buffer from the pointer, auto-increment with wraparound;
- STOP → re-arms "next written byte is the pointer" for the following transaction.
The buffer is caller-owned: firmware publishes state with plain (volatile) stores and observes controller writes by reading it back — no extra API between the ISR callbacks and the application thread. Availability tracks alp_i2c_target_open exactly, and it is [ABI-EXPERIMENTAL] (v0.9 new).
#include <alp/i2c_regfile.h>
static volatile uint8_t regs[32];
regs[0] = 0xA5; // prime an ID register BEFORE open
alp_i2c_regfile_t *rf;
alp_status_t rc = alp_i2c_regfile_open(ALP_E1M_I2C0, /* own_addr_7bit */ 0x42,
regs, sizeof(regs), &rf);
if (rc != ALP_OK) {
return rc; // ALP_ERR_INVAL / NOSUPPORT / NOT_READY / NOMEM
}
// Make regs[0..1] read-only ID/status, regs[2..31] controller-writable:
alp_i2c_regfile_set_write_window(rf, /* first */ 2, /* count */ 30);
// "Is the controller talking to us at all?"
alp_i2c_regfile_stats_t st;
alp_i2c_regfile_stats(rf, &st); // st.writes_seen, st.reads_seen
alp_i2c_regfile_close(rf); // idempotent; buffer stays caller-owned
| Function | Description |
|---|---|
alp_i2c_regfile_open(bus_id, addr, regs, len, &out) | Expose regs[0..len-1] as a target on bus_id at addr (0x08..0x77). Returns alp_status_t; ALP_ERR_INVAL on NULL regs/out, len == 0, or a bad address; ALP_ERR_NOMEM; otherwise the alp_i2c_target_open failure code. regs must stay valid until close. |
alp_i2c_regfile_set_write_window(rf, first, count) | Restrict controller writes to count registers from first; everything else becomes read-only. count == 0 makes the whole file read-only; reads are never restricted. Out-of-window writes are dropped silently but the pointer still auto-increments. Call it right after open, before controller traffic. |
alp_i2c_regfile_stats(rf, &out) | Snapshot the traffic counters alp_i2c_regfile_stats_t { uint32_t writes_seen; uint32_t reads_seen; } — payload bytes received (register-pointer bytes excluded) and bytes streamed out. ISR-updated, so a mid-transaction snapshot may be one byte stale. |
alp_i2c_regfile_close(rf) | Unregister and release. void, idempotent on NULL; no callback touches the buffer after it returns. |
SPI
alp_spi_t *spi = alp_spi_open(&(alp_spi_config_t){
.bus_id = ALP_E1M_SPI1,
.freq_hz = 8000000u,
.mode = ALP_SPI_MODE_0,
.bits_per_word = 8,
.cs_pin_id = ALP_E1M_GPIO_IO5,
});
uint8_t tx[4] = { 0xAA, 0x55, 0xC3, 0x3C };
uint8_t rx[4];
alp_spi_transceive(spi, tx, rx, sizeof(tx));
alp_spi_close(spi);
Config struct
| Field | Type | Notes |
|---|---|---|
bus_id | uint32_t | ALP_E1M_SPI0 … ALP_E1M_SPI<N>. |
freq_hz | uint32_t | SCLK frequency. |
mode | alp_spi_mode_t | ALP_SPI_MODE_0 … ALP_SPI_MODE_3. |
bits_per_word | uint8_t | Typically 8; some SoCs support 16/32. |
cs_pin_id | uint32_t | Controller-driven chip-select pin, or ALP_SPI_NO_CS for none. |
SPI target (slave) mode
:::info [ABI-EXPERIMENTAL] — v0.9 new
The target-mode surface may change in any minor release; it is not covered by the stable-ABI contract that the controller-mode SPI calls carry.
:::
Target mode makes this MCU the clocked side of the bus: the external controller owns SCK + /CS and decides when — and how many — bytes move. Because SPI is full-duplex and the target cannot see a command before it must respond within the same transfer, the surface is transfer-based: preload a TX response, block until the controller clocks a transfer, inspect what arrived, preload the next reply. No cs_pin_id is configured on the target side — the controller drives /CS.
#include <alp/peripheral.h>
alp_spi_target_t *tgt = alp_spi_target_open(&(alp_spi_target_config_t){
.bus_id = ALP_E1M_SPI1,
.mode = ALP_SPI_MODE_0, // must match the controller (0..3)
.bits_per_word = 8, // 0 defaults to 8; max 32
});
if (tgt == NULL) {
int err = alp_last_error(); // ALP_ERR_INVAL / NOSUPPORT / NOT_READY / ...
return err;
}
uint8_t tx[4] = { 0xDE, 0xAD, 0xBE, 0xEF };
uint8_t rx[4];
size_t rx_len;
alp_status_t rc = alp_spi_target_transceive(tgt, tx, rx, sizeof(tx), &rx_len,
UINT32_MAX); // wait forever
// rc == ALP_OK: the controller clocked rx_len bytes (0..sizeof(tx))
alp_spi_target_close(tgt); // returns ALP_ERR_BUSY if a transfer is armed
Config struct
| Field | Type | Notes |
|---|---|---|
bus_id | uint32_t | Studio-resolved bus instance id. |
mode | alp_spi_mode_t | CPOL/CPHA — must match the external controller. Validated to 0..3. |
bits_per_word | uint8_t | Usually 8. 0 defaults to 8; max 32. > 32 rejects with ALP_ERR_INVAL. Must match the controller. |
alp_spi_target_transceive
alp_status_t alp_spi_target_transceive(alp_spi_target_t *bus,
const uint8_t *tx, // NULL = drive idle pattern
uint8_t *rx, // NULL = discard input
size_t len, // bytes; must be > 0
size_t *rx_len, // bytes clocked; may be NULL
uint32_t timeout_ms);
Stages one target-side transfer and waits for the controller to clock it. tx is driven onto data-out; whatever the controller drives lands in rx. The controller may clock fewer bytes than len — rx_len reports how many actually moved, always in bytes regardless of bits_per_word.
lenmust be> 0. Unlike controller-modealp_spi_transceive(wherelen == 0is a no-opALP_OK), a target cannot stage a zero-length transfer — it returnsALP_ERR_INVAL.- Timeouts.
timeout_ms == UINT32_MAXblocks until the controller completes the transfer (deasserts /CS) — call from a thread that may wait indefinitely. A finitetimeout_msbounds the wait, but on the Zephyr backend it needsCONFIG_SPI_ASYNC; a finite timeout on a sync-only build returnsALP_ERR_NOSUPPORTrather than silently blocking forever. - After a timeout the transfer stays armed. SPI slave hardware has no portable cancel, so after
ALP_ERR_TIMEOUTthe handle answersALP_ERR_BUSY— andalp_spi_target_closerefuses teardown — until the external controller finally clocks the pending transfer. Keeptx/rxvalid until a later call on the handle stops returningALP_ERR_BUSY. - One transfer at a time. A second thread calling while a transceive is in flight gets
ALP_ERR_BUSY.
Returns ALP_OK / ALP_ERR_INVAL / ALP_ERR_NOT_READY / ALP_ERR_BUSY / ALP_ERR_TIMEOUT / ALP_ERR_IO / ALP_ERR_NOSUPPORT.
alp_spi_target_close(tgt) now returns alp_status_t: ALP_OK on release (or a NULL / already-closed no-op), or ALP_ERR_BUSY — freeing nothing — while a thread is blocked in a transceive on the handle or a timed-out transfer is still armed. Retry after the in-flight transfer completes.
:::note Availability
Requires controller-driver slave support (patchy across Zephyr SoC drivers; needs CONFIG_SPI_SLAVE). Backend-level absence fails open() with ALP_ERR_NOSUPPORT / ALP_ERR_NOT_READY; driver-level absence instead surfaces on the first transceive as ALP_ERR_NOSUPPORT (the Zephyr backend cannot probe it at open). On native_sim the emulated controller accepts a slave-mode open but nothing external ever clocks a transfer — degrade cleanly and bound your waits either way.
:::
UART
alp_uart_t *uart = alp_uart_open(&(alp_uart_config_t){
.port_id = ALP_E1M_UART0,
.baudrate = 115200u,
.data_bits = 8,
.stop_bits = 1,
.parity = ALP_UART_PARITY_NONE,
});
const char *msg = "hello\n";
alp_uart_write(uart, (const uint8_t *)msg, strlen(msg));
uint8_t buf[64];
alp_uart_read(uart, buf, sizeof(buf), 100); // up to 64 bytes, 100 ms timeout
alp_uart_close(uart);
RX ring buffer (interrupt-driven)
When the consumer can't keep up, enable a caller-supplied ring buffer:
#include <alp/peripheral.h>
static uint8_t rx_ring_storage[256];
alp_uart_rx_ringbuf_t *ring =
alp_uart_rx_ringbuf_attach(uart, rx_ring_storage, sizeof(rx_ring_storage));
if (ring == NULL) {
return alp_last_error(); // ALP_ERR_NOSUPPORT on builds without the ring path
}
// Later, pull what's there without polling:
size_t got;
alp_uart_rx_ringbuf_pop(ring, buf, sizeof(buf), &got);
// At teardown, detach before closing the port:
alp_uart_rx_ringbuf_detach(ring);
CONFIG_ALP_SDK_UART_RX_RINGBUF=y enables the ring path. See the uart-rx-ringbuf example.
Capability validation
Every _open() cross-checks the requested config against <alp/soc_caps.h>. Example failures:
| Config | Result on a 12-bit SoC |
|---|---|
alp_adc_open(.resolution_bits = 16) | NULL + alp_last_error() == ALP_ERR_OUT_OF_RANGE |
alp_i2c_open(.bitrate_hz = 5_000_000) | NULL + OUT_OF_RANGE (SoC max 3.4M) |
alp_uart_open(.port_id = ALP_E1M_UART9) | NULL + NOSUPPORT (instance not routed) |
E1M portability
Instance IDs come from <alp/e1m_pinout.h> and are portable across every conformant SoM. The companion ALP_E1M_<CLASS>_COUNT macros tell you how many instances the E1M standard guarantees:
for (size_t i = 0; i < ALP_E1M_I2C_COUNT; i++) {
alp_i2c_t *bus = alp_i2c_open(&(alp_i2c_config_t){
.bus_id = ALP_E1M_I2C0 + i,
.bitrate_hz = 100000u,
});
/* ... */
}
Apps that stay within the portable bound work on every E1M SoM, present and future.
See also
- Quick start — concrete walk-through
- Pinout — signal classes per pad
<alp/pwm.h>— PWM channels<alp/adc.h>— analog sampling- Examples: gpio-button-led
- Examples: i2c-scanner