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) over tk_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_print for 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:

  1. 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 ★.)

  2. One critical-section primitive lives in arch/. The MCAL borrows the nesting-safe arch_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 flat arch_irq_disable/enable pair stays the kernel’s own cooperative-yield mask.

  3. tk_board owns 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

tk_mcu + tk_port/tk_dio (+ tk_dio_irq) + tk_uart (TX) + tk_console. Grow on demand.

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

<stdint.h> widths; no Std_Types aliases. One status type, tk_status_t.

5

Naming

Project-wide prefix tk_, snake_case, module-first (tk_dio_write_channel, TK_DIO_HIGH). One vocabulary, no exceptions.

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 in tk_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. void write/flip hot path (an invalid channel is a programmer error, not a per-call branch); flip returns 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 MAY tk_sem_post() to wake a thread (safe while unprivileged threads are off — the default build). A scarce detect unit per enabled pin (TK_ERR_BUSY when 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 new TK_UART_CH_* row in tk_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 line 0xFF to leave it unconnected), overriding the tk_uart_cfg.h defaults 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.h so HAL and kernel describe one conceptual model, yet live in separate headers on purpose (HAL code includes tk_status.h, kernel code includes tk_det.h) — the enum names would otherwise collide in one TU. The fuller tk_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 dio_resolve() returns

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:

  1. 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/restore with a spinlock. (Proven at root: tk_console_lock/unlock weak→hardware-spinlock on the rp2040 SMP board.)

  2. Shared RAM state needs true atomics on SMP, not volatile.

  3. 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; types tk_<...>_t, constants TK_….

  • 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 const config + static instance state.

  • Register casts (int→pointer) confined to the resolver / register-access macros, where the MISRA deviation is documented.

  • tk_*_cfg.h filenames are a load-bearing seam — do NOT board-prefix them. Apps include the generic name (tk_dio.h pulls in tk_dio_cfg.h; tk_uart.h pulls in tk_uart_cfg.h); the build selects the board with -Iboards/<board> (each board’s TK_BOARD_INC also adds -Ihal). Every board ships that exact filename. This is the Option-C binding (decision #3) expressed as a filename — renaming to nrf52_dio_cfg.h would force the app to name the board in its #include and 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 (nrf52832_pca10040)

32-bit MMIO

HAL app (blinky) runs on hardware

RISC-V (qemu_riscv32_virt)

UART-only (no GPIO/clock tree)

runs under qemu; led_toggle a legitimate no-op

RP2040 (rp2040_pico)

32-bit MMIO (SIO)

runs on hardware

host (boards/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.