Skip to main content

<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.

#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 returns ALP_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 prior alp_init(). Do not race alp_deinit() against alp_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).

ArgumentTypeNotes
pin_iduint32_tPassed to alp_gpio_open(). Use ALP_E1M_GPIO_IO<N> from <alp/e1m_pinout.h>.
diralp_gpio_dir_tALP_GPIO_INPUT / ALP_GPIO_OUTPUT.
pullalp_gpio_pull_tALP_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, &reg, 1, rx, sizeof(rx));

alp_i2c_close(bus);

Config struct

FieldTypeNotes
bus_iduint32_tALP_E1M_I2C0ALP_E1M_I2C<N>.
bitrate_hzuint32_tBus speed. Capped by <alp/soc_caps.h> at _open().

Common operations

FunctionDescription
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

FieldTypeNotes
bus_iduint32_tStudio-resolved bus instance id (same id space as alp_i2c_open).
own_addr_7bituint8_tAddress this target answers on. Must be 0x08..0x77 — the reserved 7-bit ranges 0x000x07 / 0x780x7F are rejected with ALP_ERR_INVAL.
on_writealp_i2c_target_write_cb_tvoid(uint8_t byte, void *user) — a byte arrived. Required.
on_readalp_i2c_target_read_cb_talp_status_t(uint8_t *byte, void *user) — supply the outgoing byte; return ALP_OK to continue, any error to NAK / end. Required.
on_stopalp_i2c_target_stop_cb_tvoid(void *user) — STOP condition. Optional; may be NULL.
uservoid *Forwarded to every callback.
  • alp_i2c_target_open(cfg) returns an opaque alp_i2c_target_t *, or NULL with alp_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 open returns.
  • alp_i2c_target_close(tgt) is void, idempotent on NULL, 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
FunctionDescription
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

FieldTypeNotes
bus_iduint32_tALP_E1M_SPI0ALP_E1M_SPI<N>.
freq_hzuint32_tSCLK frequency.
modealp_spi_mode_tALP_SPI_MODE_0ALP_SPI_MODE_3.
bits_per_worduint8_tTypically 8; some SoCs support 16/32.
cs_pin_iduint32_tController-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

FieldTypeNotes
bus_iduint32_tStudio-resolved bus instance id.
modealp_spi_mode_tCPOL/CPHA — must match the external controller. Validated to 0..3.
bits_per_worduint8_tUsually 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 lenrx_len reports how many actually moved, always in bytes regardless of bits_per_word.

  • len must be > 0. Unlike controller-mode alp_spi_transceive (where len == 0 is a no-op ALP_OK), a target cannot stage a zero-length transfer — it returns ALP_ERR_INVAL.
  • Timeouts. timeout_ms == UINT32_MAX blocks until the controller completes the transfer (deasserts /CS) — call from a thread that may wait indefinitely. A finite timeout_ms bounds the wait, but on the Zephyr backend it needs CONFIG_SPI_ASYNC; a finite timeout on a sync-only build returns ALP_ERR_NOSUPPORT rather than silently blocking forever.
  • After a timeout the transfer stays armed. SPI slave hardware has no portable cancel, so after ALP_ERR_TIMEOUT the handle answers ALP_ERR_BUSY — and alp_spi_target_close refuses teardown — until the external controller finally clocks the pending transfer. Keep tx / rx valid until a later call on the handle stops returning ALP_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:

ConfigResult 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

Questions about this page? Discuss in Community Forum