HAL architecture — the peripheral abstraction layer¶
Status: Design record, trimmed to the minimal root HAL. The layer model, the
Option-C resolver seam, the boot order, and the conventions below are in the tree
(hal/ + all four boards). This record reflects the distilled HAL the reshape kept —
clock, pins, digital I/O (incl. edge-IRQ), TX UART, and the portable text console — and
documents the runtime pin-claim API the original design predated. The wider
peripheral set (SPI/I2C/ADC/PWM/GPT), the external-device layer, and the DET/error
module are _legacy-only and are called out where cut. Scope: HAL internals.
Companions: composability.md (the layering law: HAL never depends
on the kernel), board-contract.md (the board↔kernel boundary + the
pin-identity side of the pin surface), kernel-tiers.md (the arch
critical-section primitive the HAL borrows is the kernel’s, not the HAL’s).
1. Goals and non-goals¶
Goals — one portable peripheral API the kernel, examples, and applications call, with
hardware isolated behind it (no #ifdef PLAT_* in portable code); ISO 26262 intent as a
teacher (static config, bounded loops, defined error handling, clear module boundaries,
MISRA-amenable code); portability across ARM Cortex-M, RISC-V, MSP430, and a register-less
host build; grow on demand — start tiny, add modules only when a real need appears.
Non-goals — not a full AUTOSAR stack (borrow MCAL’s structure — module
decomposition, static config, a Std_ReturnType-style status, naming discipline — skip
ARXML/MemMap/BSW-scheduler weight); not a code generator yet (config is hand-written; the
struct formats are designed so a generator could emit them later).
2. The layered architecture¶
The HAL is not a single linear stack. arch/ and the MCAL both touch silicon but
serve different masters; the only dependency between them is thin (the MCAL borrows arch’s
critical-section primitive).
kernel / examples / applications
│
┌──────────────┴───────────────────────────────┐
│ tk_board (boot orchestration) │
│ tk_console (portable text console = tk_print) │ names: user LED, debug console
└──────────────┬───────────────────────────────┘
│
── MCAL ── on-chip peripheral mechanism
tk_mcu · tk_port · tk_dio · tk_dio_irq · tk_uart
│ borrows arch's critical section (IRQ/lock) only
── arch/<cpu> ── kernel CPU port: context switch, kernel tick,
THE critical-section primitive
│
silicon
Layer responsibilities
arch/<cpu>— the kernel’s downward port. Context switch, the kernel tick, the critical-section primitive. Not a peripheral HAL and never MCAL-ified.MCAL — on-chip peripheral mechanism:
tk_mcu,tk_port,tk_dio,tk_dio_irq,tk_uart. Pure hardware drivers.tk_board— the board bring-up entry (§4).tk_console— the portable text console (tk_print) overtk_uart, one implementation for every board.kernel / app — call the MCAL directly for mechanism,
tk_board_init()for one-call bring-up,tk_console_init()/tk_printfor text.
Cut from the minimal root (see
_legacy/docs/hal-architecture.md): the external-device layer (devices/— mcp2515/pn532/pcf8574/hd44780) and its AUTOSAR-ECU-abstraction framing. External chips are a grow-on-demand layer above the MCAL, absent at root.
3. The three durable rules¶
These resolve the apparent overlaps between arch/ and the MCAL:
The kernel tick and context switch stay in
arch/and are never exposed as MCAL. The scheduler heartbeat must never route through the application-facing HAL. (Who arms the tick — board vs arch — is board-contract.md §4 ★.)One critical-section primitive lives in
arch/. The MCAL borrows the nesting-safearch_irq_save()/arch_irq_restore()pair; it never invents its own interrupt masking. Its contract is mutual exclusion — on SMP that needs a spinlock, not just IRQ masking (§11). The flatarch_irq_disable/enablepair stays the kernel’s own cooperative-yield mask.tk_boardowns boot ordering. MCAL modules do not self-sequence; the board calls them in dependency order (§10).
Corollary: tk_board and any device code DELEGATE to the MCAL; they never reimplement a
peripheral. A board LED toggle calls tk_dio_flip_channel(); there is never a
board-level register poke. A peripheral operation belongs in the MCAL.
4. tk_board and tk_console — bring-up and text¶
tk_board_init() (each board implements it) initialises exactly the resources the board
owns before the kernel runs: the clock (tk_mcu), the pin table (tk_port/tk_dio), and
the UART instance(s) (tk_uart). Call it once at the top of main(). It is void —
the minimal root drops the legacy tk_status_t return; a dead clock source can’t hang boot
because each sub-init is best-effort with an internal bounded wait.
Which UART is the console is an application decision, not the board’s. tk_console_init(ch)
binds tk_print/tk_print_u32 to a UART channel; the console does no hardware init (the
board already brought the UART up). One implementation — hal/tk_console.c — serves every
board that provides tk_uart_write. Cross-core output is serialized by tk_console_lock/
tk_console_unlock (weak no-ops single-core; an SMP board overrides them with a hardware
spinlock — a §2 stud, not glue).
This is the ADR 0002 §8 rename in force:
tk_board_log→tk_print— the console is an application choice, so it is not “board logging.” The legacy debug-sink indirection (tk_board_set_debug_sink) is gone; binding the console to a channel is the whole mechanism now.
5. Settled decisions¶
# |
Decision |
Resolution |
|---|---|---|
1 |
Config: generated vs hand-written |
Hand-written tables; struct formats designed to be generator-emittable later. |
2 |
Minimal scope |
|
3 |
Dio↔Port coupling |
Option C — one source-of-truth pin table (owned by Port), reached by Dio through a single narrow resolver seam (§9). Not two duplicated tables; not raw struct-poking smeared through Dio. |
4 |
Types |
|
5 |
Naming |
Project-wide prefix |
6. Module contracts (minimal root)¶
Portable headers in hal/; per-board implementations + config in boards/<board>/.
Signatures verified against the root headers.
tk_mcu—tk_mcu_init()(clock tree; runs first),tk_mcu_clock_hz()(the frequency the arch consumes for the kernel tick). MCAL owns the clock; the arch queries it. (Minimal: no status/DET layer.)tk_port—tk_port_init(const tk_port_config_t* cfg). One-time pin setup (direction/mode/pull) from the board pin table. The config struct is opaque to portable code; each board defines its layout intk_port_cfg.h.tk_dio—tk_dio_read_channel/tk_dio_write_channel/tk_dio_flip_channel. The fast runtime path; pins must already be configured.voidwrite/flip hot path (an invalid channel is a programmer error, not a per-call branch);flipreturns the level after the flip.tk_dio_irq— a digital-input edge as an interrupt (the reactive complement to Dio’s read/write):tk_dio_irq_init(),tk_dio_irq_enable_channel/_pin(edge, cb, ctx),tk_dio_irq_disable_channel/_pin. The callback runs in interrupt context; it MAYtk_sem_post()to wake a thread (safe while unprivileged threads are off — the default build). A scarce detect unit per enabled pin (TK_ERR_BUSYwhen none is free). This is the canonical §2 callback stud (edge → registered handler), not a kernel dependency.tk_uart—tk_uart_init(),tk_uart_write(ch, data, len)(blocking, bounded per-byte wait). Instance-addressed — a second UART is a newTK_UART_CH_*row intk_uart_cfg.h, not an API change. (Minimal root is TX-only; the RX ring returns when a demo needs receive.)
6.1 The runtime pin-claim API (added since the original design)¶
The original HAL configured every pin from the board’s static table. The root adds a
runtime claim path so an application can grab a free header pin without editing board
code — the board publishes pin identity (labels in tk_board_pins.h, the table via
tk_*_cfg.h); the app declares the role at point-of-use:
tk_port_config_pin(uint8_t pin, tk_dir_t dir, tk_pull_t pull)— configure one pin by its SoC pin number at runtime, without touching the board table (tk_dir_t= IN/OUT,tk_pull_t= NONE/DOWN/UP).tk_dio_write_pin/tk_dio_read_pin/tk_dio_flip_pin(uint8_t pin, …)— the fast set/clear/in path for a pin claimed above, addressed by raw SoC pin number rather than a board channel.tk_uart_setup(ch, tx_pin, rx_pin, baud)— reconfigure a UART channel’s pins + baud at runtime (either line0xFFto leave it unconnected), overriding thetk_uart_cfg.hdefaults with no board edits.
This is the “board = pin facts, app = role at point-of-use” boundary; its board-side half (who owns identity, what “free” means) is board-contract.md §4.
7. The frozen public contract¶
The only things callers may depend on, and the only things kept stable: the symbolic
channel/pin IDs (TK_DIO_CH_*, TK_UART_CH_*) defined per board in the *_cfg.h headers;
the function signatures in the hal/ headers; and tk_status_t. Everything else — config
struct layout, whether Dio and Port share a table, how registers are accessed, the resolver
internals — is private and refactorable. Freezing the API (not the wiring) is what
protects existing functionality from internal change.
8. Status model — class vs detail¶
tk_status_t (hal/tk_status.h) is a small enum of failure CLASSES for control flow:
TK_OK(0) · TK_ERR_PARAM · TK_ERR_BUSY · TK_ERR_STATE · TK_ERR_TIMEOUT ·
TK_ERR_UNSUPPORTED · TK_ERR. It is intentionally coarse — the precise why (module +
api + code) is the DET path’s job, reported at the failure site.
The class constants are numbered to match
kernel/tk_det.hso HAL and kernel describe one conceptual model, yet live in separate headers on purpose (HAL code includestk_status.h, kernel code includestk_det.h) — the enum names would otherwise collide in one TU. The fullertk_error/DET reporting module (hook + per-module counters) is_legacy-only in the minimal root.
9. The Option-C resolver seam¶
Dio’s hot path resolves a channel to the hardware it needs through one board-private inline accessor — the single place that knows the pin-table layout and performs any integer→pointer cast (also the single home for the MISRA 11.4/11.6 int↔pointer deviation). The seam abstracts the register model completely; the same Dio API sits above radically different resolvers:
target |
what |
|---|---|
nRF52 |
pointers to OUTSET/OUTCLR/IN registers + mask (int→ptr cast) |
host |
a pointer to a simulated level cell (no cast at all) |
If the config representation changes, or a generator emits separate Dio tables, only
dio_resolve changes — callers and the public API do not. The runtime pin-claim path
(§6.1) resolves the raw SoC pin through the same register model, so it shares this seam.
10. Boot ordering¶
tk_board_init() is the single owner of the bring-up sequence, because only the board
knows its recipe:
main():
tk_board_init():
tk_mcu_init(); // 1. clock tree / PLL
tk_port_init(&cfg); // 2. pin direction / mux / pull
tk_uart_init(); // 3. board UART instance(s)
tk_console_init(ch); // 4. app binds the console to a UART channel (its choice)
tk_kernel_init(); tk_thread_create(...); tk_kernel_start();
// arch_timer_init() (the kernel tick) fires inside
// tk_kernel_start — it stays in arch/, off the MCAL path
11. Multi-core / SMP readiness¶
Nothing in hal/ needs to change for SMP — the contract is largely SMP-ready by
construction. The resolver seam is a pure, stateless function of the channel; config tables
are const (shared read-only); APIs are instance-addressed (distinct instances touch
distinct state). What needs care lives in arch/, not the HAL contract:
The critical-section primitive’s contract is “mutual exclusion”, its implementation target-dependent. Masking IRQs suffices on a uniprocessor; on SMP it does not — masking core A does nothing to core B, so an SMP port extends
arch_irq_save/restorewith a spinlock. (Proven at root:tk_console_lock/unlockweak→hardware-spinlock on the rp2040 SMP board.)Shared RAM state needs true atomics on SMP, not
volatile.Per-instance mutable driver state is race-free across different channels but not when one instance is shared by two cores — “do not share one instance across cores without external sync.”
12. Conventions¶
Prefix:
tk_on every public symbol; typestk_<...>_t, constantsTK_….Types:
<stdint.h>widths only. (Stack sizes are cells, not bytes — ADR 0001.)Bounded loops: every hardware wait has a timeout — no unbounded spins (a 26262 reflex).
No dynamic memory in the HAL: static
constconfig + static instance state.Register casts (int→pointer) confined to the resolver / register-access macros, where the MISRA deviation is documented.
tk_*_cfg.hfilenames are a load-bearing seam — do NOT board-prefix them. Apps include the generic name (tk_dio.hpulls intk_dio_cfg.h;tk_uart.hpulls intk_uart_cfg.h); the build selects the board with-Iboards/<board>(each board’sTK_BOARD_INCalso adds-Ihal). Every board ships that exact filename. This is the Option-C binding (decision #3) expressed as a filename — renaming tonrf52_dio_cfg.hwould force the app to name the board in its#includeand break one-source/many-board examples.
13. Portability evidence¶
One set of hal/ contracts, four targets, zero changes to hal/:
target |
register model |
result |
|---|---|---|
nRF52832 ( |
32-bit MMIO |
HAL app (blinky) runs on hardware |
RISC-V ( |
UART-only (no GPIO/clock tree) |
runs under qemu; |
RP2040 ( |
32-bit MMIO (SIO) |
runs on hardware |
host ( |
none (simulated) |
links and runs on the desktop |
The only things that differ across ports are the things designed to differ: the per-board config struct and the resolver internals. The host port — no registers at all — is the strongest proof the API is not secretly ARM-shaped.
14. Scope and roadmap (minimal root)¶
Kept at root — a synchronous, polled peripheral mechanism plus one interrupt source:
tk_mcu (clock), tk_port/tk_dio (read/write/flip + runtime pin-claim), tk_dio_irq
(edge→callback), tk_uart (TX, instance-addressed), tk_console (tk_print); the arch
context switch + kernel tick + arch_irq_save/restore.
Deferred to _legacy / grow-on-demand (present in the legacy HAL, cut from the minimal
root): tk_uart RX ring; tk_spi / tk_i2c (bus mechanism); tk_adc / tk_pwm /
tk_gpt (application timer, distinct from the kernel tick); the devices/ external-chip
layer (mcp2515/pn532/pcf8574/hd44780) and tk_can; the tk_error/DET reporting module; the
config generator. Each returns when a real consumer at root needs it — the hal/ contracts
above do not change to accommodate them (decision #1/#3).
Correction — MSP430 is NOT dropped. The legacy roadmap listed MSP430 as “off the roadmap” (the 512 B-RAM class judged below the floor for a stackful kernel). That is superseded:
arch/msp430/is a live CPU port (kernel_arch.h+arch_irq.h), and ADR 0001 explicitly places the MSP430X word-size/banking family in the “absorb” set. What remains true is a board-sizing caution, not an architecture drop: a 512 B-RAM part is tight for per-thread stacks, so no MSP430 HAL board ships at root yet. The arch is supported; a board awaits a target.