Skip to content

epic: bare-metal STM32 (Cortex-M) port via the dispatch_memory core + lwIP #447

Open

Description

@gg582

Background

The embedded track so far (#427 fat archive, #435 CWIST_TINY trimming, #436 amalgamation, #437 mbedTLS backend, #438 cross-compilation, #440 zero-allocation gate) targets embedded Linux (Raspberry Pi / Yocto / Buildroot class). The next step is MCUs with no OS: STM32 (Cortex-M4F/M7/H7) flashed bare-metal or under a small RTOS.

cwist_app_listen() cannot run there. It assumes POSIX processes and signals, pthreads, fd-based sockets with epoll/kqueue/io_uring, and server-sized socket buffers. CWIST already has a socket-free path, though:

  • cwist_app_dispatch_memory() / cwist_app_dispatch_memory_stream() (include/cwist/sys/app/app.h) parse a request from a memory buffer, route it through the mux and middleware, and serialize the response. No sockets and no event loop are involved.
  • The WASM builds already compile a socket-independent core (WASM_SRCS in the Makefile: sstring, arena/alloc, HTTP/1 parser/serializer, mux, middleware, app, cookie/session, query, JSON, template, validation), with CWIST_WASI_NO_SOCKETS / __EMSCRIPTEN__ guards in app.c, http.c, mem/* and others.

The plan is to treat bare-metal as another host that drives the dispatch core, the same way the WASM component adapter does, with lwIP (or a hardware TCP chip such as W5500) as the transport:

[ETH MAC + PHY (RMII) / W5500 SPI]
            │
     [lwIP raw API]  ── tcp_recv(): accumulate one framed request per conn
            │
  cwist_app_dispatch_memory()      HTTP/1.1 parse → trie router → handler
            │
     [lwIP tcp_write()]  ── serialized response (or the streaming variant)

Goal

On an STM32H7/F7-class board with built-in Ethernet, serve a CWIST app (routes, middleware, JSON responses) over HTTP/1.1, flashed as one firmware image, built from the amalgamation or the fat archive with arm-none-eabi-gcc.

Non-goals (for this epic)

  • cwist_app_listen() on bare metal (worker pool, C1M mode, io_uring)
  • HTTP/2, HTTP/3/QUIC, WebRTC, WebTransport, gRPC, DB/SQLite, NATS/Redis, OTel export
  • Low-end parts (STM32F0/F1/L0, under ~128 KB SRAM). Phase 0 measures the real floor.

Plan

Phase 0: feasibility spike (measure first)

  • Compile the WASM_SRCS core minus sqlite with arm-none-eabi-gcc -mcpu=cortex-m7 -mthumb --specs=nano.specs -ffunction-sections -fdata-sections and CWIST_TINY=1.
  • List every compile error and undefined symbol (pthread, clock_gettime, getrandom, stdio, getenv, file I/O, ...). That list is the scope for Phase 1.
  • Record .text/.data/.bss for a hello app linked with --gc-sections, plus peak stack from -fstack-usage. Note: the WASI build measured ~96 KB peak stack with sqlite, so this has to be checked without it before committing to SRAM targets.
  • Decide the reference board, e.g. NUCLEO-H743ZI2 (1 MB SRAM, Ethernet) with NUCLEO-F767ZI / F429ZI as stretch targets.

Phase 1: platform layer (CWIST_PLATFORM=baremetal)

  • A build-time guard, parallel to CWIST_WASI_NO_SOCKETS, that compiles out sockets, the listen loop, signals, fork, threads, and file I/O.
  • A small port-hook struct the firmware fills at init (new additive API, see the API note below):
    • monotonic time (ms/us), backed by SysTick/DWT
    • entropy, backed by the RNG peripheral. Required, not optional: it seeds siphash and generates session IDs, so there must be no silent fallback to a constant seed.
    • log sink (UART/SWO/RTT) in place of stdio
    • optional lock/unlock for RTOS builds, no-ops on bare metal
  • Single-threaded stand-ins for the log_async, scheduler, and GC background-thread paths.
  • Static assets come from a compiled-in table (the existing assets.c path) instead of the filesystem.

Phase 2: deterministic memory

Phase 3: transport adapter (cwist/port/lwip.h)

  • An lwIP raw-API adapter: per-connection receive buffer, request framing (headers + Content-Length; chunked decoding stays optional), dispatch, tcp_write/tcp_sent back-pressure for large responses using cwist_app_dispatch_memory_stream().
  • Keep-alive, connection limit, idle timeout, and 413/431 on oversize requests.
  • Later: a netconn/BSD-socket variant for FreeRTOS+lwIP, and a W5500 hardware-socket adapter.

Phase 4: TLS (optional)

Phase 5: build, example, CI

  • make CROSS_COMPILE=arm-none-eabi- CWIST_PLATFORM=baremetal CWIST_TINY=1 amalgamation (and fat-dist) produce an archive or single file that drops into a CubeIDE/CMake project.
  • example/stm32/: a CubeMX-generated lwIP project for the reference board serving /hello and a JSON route, with flashing steps for st-flash and STM32CubeProgrammer.
  • CI: an arm-none-eabi compile job plus a size budget gate, with thresholds set from the Phase 0 numbers.
  • CI: a QEMU (qemu-system-arm, Cortex-M machine) semihosting smoke that feeds fixed request bytes into cwist_app_dispatch_memory() and checks the response bytes. This covers the core on real Thumb code without needing network emulation. Testing over the network is a manual hardware check.

Phase 6: docs

  • docs/embedded/stm32.md: supported parts, memory sizing, port hooks, lwIP options (MEM_SIZE, TCP_WND, MEMP_NUM_TCP_PCB), and limitations. The Korean mirror goes in docs/ko/.

API note

Everything here is additive: new port hooks, a new adapter header, and new build knobs. No existing v4 API changes, so it can ship in a minor release.

Open questions

  • Should the RTOS (FreeRTOS + lwIP sockets) path be a first-class target alongside bare metal, or a follow-up?
  • CWIST_TLS=none: is TLS-less a supported knob today, or does CWIST_TINY still keep a TLS backend for JWT/HTTPS?
  • Which vendored pieces (cJSON, libttak) survive the trim? libttak's threading assumptions need checking in Phase 0.

Activity

  1. gg582 commented on Oct 25, 2026

    @gg582
    MemberAuthor

    Phase 0 spike results

    Verdict: feasible, with a lot of headroom. The socket-free core builds for Cortex-M7. A hello firmware driven only through cwist_app_dispatch_memory() serves /hello (text), /json (cJSON), and the 404 path correctly under emulation, and fits in ~44 KB of flash and ~32 KB of RAM.

    Harness: tools/stm32-spike/run.sh on the local branch spike/stm32-phase0 (off dev, not pushed yet). It has no changes to src/ or include/. Everything the platform is missing lives in tools/stm32-spike/shim/ (headers) and fw/port.c (startup, heap, ENOSYS stubs). Those two pieces are effectively the Phase 1 to-do list.

    Setup

    • arm-none-eabi-gcc 14.2.1, newlib-nano, -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard -Os -ffunction-sections -fdata-sections, linked with --gc-sections against an STM32H743-shaped memory map
    • Sources: the WASIP2_SRCS set minus sqlite/db, which is 46 translation units (CWIST + cJSON + libttak mem/sync)
    • Run: qemu-arm -cpu cortex-m7 (user mode) with semihosting for output. This executes real Thumb-2/M7 code, but it is not a board run: no NVIC/SysTick, and qemu provides the SP and FPU state.

    Measurements (hello + JSON route, -Os)

    bytes notes
    .text+.rodata (flash) 44,424 http.c 15.0K, cJSON 6.5K, app.c 5.2K, libttak mem 3.4K, assets 3.0K, sstring 3.0K, gc 2.9K
    .data + .bss (CWIST's share) ~1.7 K the 8 KB of .bss from port.c is the spike's oversized TLS block; the actual TLS image is 52 B
    heap high-water (sbrk) 22,296 flat across 1 / 3 / 100 iterations of 3 requests each, so no growth
    stack high-water (painted) 8,640 dominated by cwist_http_response_serialize()'s 8 KB header_buf[CWIST_HTTP_MAX_HEADER_SIZE]

    These numbers leave lwIP itself (typically 20–40 KB flash and 10–30 KB RAM depending on MEM_SIZE/pbufs) out. Even with lwIP added, the 192 KB-SRAM F4 parts (F407/F429) look viable, not just H7. The SRAM floor in the epic should drop from "H7-class" to "~128 KB, to be confirmed on hardware."

    The WASI note about a ~96 KB stack peak does not apply here. That figure came from the socket path (16 KB read buffer) plus sqlite. The dispatch-only path peaks at ~8.6 KB.

    What blocked the build (the Phase 1 scope)

    In the order hit, with counts from the first pass (39/46 TUs failed out of the box):

    1. pthread: newlib for arm-none-eabi has no thread API. libttak's ttak/compat/pthread.h already carries a single-threaded stub set, but only #if defined(__wasi__). Fix: extend that guard to a bare-metal macro. That needs an upstream libttak change; the spike reuses the stub via a shadowing header.
    2. clock_gettime / CLOCK_MONOTONIC, nanosleep, timegm, sched_yield: newlib does not declare them (no _POSIX_TIMERS/_POSIX_MONOTONIC_CLOCK). This becomes the monotonic-time port hook.
    3. Platform guards are spelled __wasi__ / __EMSCRIPTEN__, not capabilities: 53 sites in app.c, 14 in http.c, plus session.c, seq_auth.c, gc.c, and others. include/cwist/sys/wasi.h already documents the semantics ("__wasi__ = no fork/signals/rlimits/threads"; CWIST_WASI_NO_SOCKETS = no sockets), and building with -D__wasi__ plus a shim without <netdb.h> is what made all 46 TUs compile. Fix: introduce capability macros (CWIST_HAS_PROCESSES, CWIST_HAS_SOCKETS, CWIST_HAS_FS, ...) derived from the target in one header, and convert the guards. This is mechanical but touches many lines.
    4. Public headers pull in hosted-only headers unconditionally: http.h includes <netinet/in.h> and declares cwist_make_socket_ipv4(struct sockaddr_in *). app.h/http.h include cwist/core/db/sql.h, which includes <sqlite3.h> even with CWIST_DB=0. https.h includes <openssl/ssl.h>; this works with the mbedTLS OpenSSL shim, but only with mbedTLS headers present.
    5. Socket-path files compiled unconditionally: writer_fast.c (send/sendmsg/MSG_DONTWAIT), shutdown.c (shutdown, SA_RESTART), recv/send call sites in http.c, opendir/readdir in app.c (static-dir serving), and mmap/mprotect in gc.c. With --gc-sections none of these are reached on the dispatch path, but they still have to compile. In the spike they link to ENOSYS stubs.
    6. _Thread_local → __aeabi_read_tp: 10 objects use TLS (bind.c holds the bulk, 4 KB of .tbss on its own). Bare metal needs a TP/TLS block from the port. The spike uses one static block (single thread).
    7. 64-bit atomics → __atomic_*_8 (metrics.c, big_dumb_reply.c, http.c, libttak mem.c/shared.c): Cortex-M has no LDREXD/STREXD, so the port must provide these. The spike masks PRIMASK.

    Bugs and risks found

    • Date header full of NUL bytes when there is no wall clock. cwist_get_cached_date_header() caches against cached_sec = (time_t)-1. On a board with no RTC, time() returns -1, the cache never fills, and the response carries Date: followed by 29 NUL bytes (seen in the spike before the RTC stub was added). RFC 9110 §6.6.1 says an origin server without a reliable clock must not send Date. Phase 1 needs either a wall-clock hook or omitting Date when the clock is unset.
    • siphash seed silently degrades to predictable values. cwist_entropy_fill() falls back to clock_gettime + srand/rand when /dev/urandom cannot be opened, which is always the case on bare metal. That makes the hash-flooding defense guessable at boot. Phase 1's entropy hook must cover this path and fail closed. Session secrets (generate_secret()) already fail closed and use getentropy() under __wasi__, so wiring getentropy() to the RNG peripheral covers both paths.
    • The spike's ENOSYS stubs log when hit, and none were hit on the dispatch path.

    Phase 0 checklist

    • Compile the core with arm-none-eabi-gcc for Cortex-M7: 46/46 TUs with the shims; error inventory above
    • Size and stack of the hello firmware: 44.4 KB flash, 22.3 KB heap peak, 8.6 KB stack peak
    • Undefined-symbol inventory: newlib syscalls, __aeabi_read_tp, __atomic_*_8, plus the ENOSYS set in fw/port.c
    • Reference board decision: the numbers support keeping NUCLEO-H743ZI2 as reference and adding NUCLEO-F429ZI/F767ZI as validated targets
    • Run on real hardware and under qemu-system-arm with a real NVIC/SysTick (not done: qemu-system-arm is not installed on the spike machine)
  2. gg582 commented on Oct 27, 2026

    @gg582
    MemberAuthor

    Phase 2 prep: measured heap traffic on bare metal

    These are measurements, not changes. I counted allocations with -Wl,--wrap=malloc,calloc,realloc,free on the Cortex-M7 build (libcwist_baremetal.a at eb537f9d5, run under qemu-arm). Each case is 100 steady-state requests through cwist_app_dispatch_memory() after warm-up, with call sites resolved by addr2line.

    call site per request size note
    cwist_arena_create, arena struct 2 12 B request arena + response arena
    cwist_arena_create, generation buffer 2 8,192 B same two arenas: ~16 KB malloc'd and freed on every request
    cwist_http_response_serialize 1 ~70 B the buffer dispatch_memory hands back to the caller
    cwist_sstring_reserve (404 only) 1 17 B the default error body
    cJSON_* (JSON handler only) 5 6–256 B application handler code, not CWIST core
    • Totals: GET /hello 5 allocations and 16.5 KB per request; GET /missing 7; GET /json?… 11.
    • Leaks: every allocation is freed, and the heap high-water mark stays flat at 22 KB.

    Cause: the non-hosted arena (src/core/mem/arena.c, shared with WASM) is a plain malloc-backed bump buffer. The hosted arena recycles generations through a thread-local cache, and the hosted zero-alloc gate (#440) holds the socket path to 2 allocations per request. Bare metal and WASM have no such cache, so each request does a fresh pair of 8 KB malloc/free cycles. On a board that runs for months, that is exactly the fragmentation pattern this phase exists to remove.

    Proposed Phase 2 scope

    1. Recycle arenas on non-hosted targets. Add a single-threaded free list of arena structs and generation buffers to the non-hosted arena.c, capped at a small N. This removes 4 of the 5 core allocations per request on bare metal and on WASM. It changes the WASM build's behaviour on purpose; wasip2/component smoke covers it.
    2. Static default error bodies. Serve the 404 and other default-error bodies from static storage instead of a fresh sstring.
    3. Allocation-free lwIP responses. Serialize into a per-connection buffer that is reused across keep-alive requests, instead of a fresh cwist_http_response_serialize() buffer per response. cwist_http_serialize_headers() already writes into a caller buffer. dispatch_memory keeps its contract of returning a fresh buffer to the caller.
    4. Gate it. The smoke firmware counts allocations per steady-state request with --wrap. The size gate gets an allocs metric with budgets:
      • hello: 1, the buffer the API returns
      • hello-lwip and hello-tls: 0 per request after the connection is set up
    5. RAM knob. Make CWIST_ARENA_DEFAULT_GENERATION_BYTES overridable at build time, since 8 KB per arena is a lot for a 128 KB-SRAM part. First check what happens when a request outgrows one generation on the non-hosted bump arena. Today it returns NULL; the fallback needs to be verified, and the behaviour measured at 2–4 KB.

    Sizing tables for docs (bytes per connection, per TLS session, arena per request) move to Phase 6, using these numbers.

  3. added a commit that references this issue on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions