Repository navigation
epic: bare-metal STM32 (Cortex-M) port via the dispatch_memory core + lwIP #447
Description
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_SRCSin the Makefile: sstring, arena/alloc, HTTP/1 parser/serializer, mux, middleware, app, cookie/session, query, JSON, template, validation), withCWIST_WASI_NO_SOCKETS/__EMSCRIPTEN__guards inapp.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_SRCScore minus sqlite witharm-none-eabi-gcc -mcpu=cortex-m7 -mthumb --specs=nano.specs -ffunction-sections -fdata-sectionsandCWIST_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/.bssfor 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
- monotonic time (
- 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.cpath) instead of the filesystem.
Phase 2: deterministic memory
- Build on feat(mem): deterministic memory mode #432/ci(mem): zero-allocation gate for the hot path #440: a caller-provided static arena (a linker-section buffer) behind the allocator hooks. Configurable caps for max request size, header count/size, and body size.
- Run the zero-allocation-after-init gate against the bare-metal config.
- A sizing table (bytes per connection, arena per request, stack) generated from the measurements.
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_sentback-pressure for large responses usingcwist_app_dispatch_memory_stream(). - Keep-alive, connection limit, idle timeout, and
413/431on oversize requests. - Later: a netconn/BSD-socket variant for FreeRTOS+lwIP, and a W5500 hardware-socket adapter.
Phase 4: TLS (optional)
- Reuse the mbedTLS backend (feat(tls): pluggable TLS backend with an mbedTLS option for embedded targets #437) with an MCU
mbedtls_config.h: entropy from the RNG hook, and the hardware crypto accelerators (CRYP/HASH/PKA) where the part has them. - Measure the RAM cost of one TLS session and document it. HTTPS is likely to be practical only on H7-class parts.
Phase 5: build, example, CI
-
make CROSS_COMPILE=arm-none-eabi- CWIST_PLATFORM=baremetal CWIST_TINY=1 amalgamation(andfat-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/helloand a JSON route, with flashing steps forst-flashand STM32CubeProgrammer. - CI: an
arm-none-eabicompile 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 intocwist_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 indocs/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 doesCWIST_TINYstill 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
gg582 commentedon Oct 25, 2026
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.shon the local branchspike/stm32-phase0(offdev, not pushed yet). It has no changes tosrc/orinclude/. Everything the platform is missing lives intools/stm32-spike/shim/(headers) andfw/port.c(startup, heap, ENOSYS stubs). Those two pieces are effectively the Phase 1 to-do list.Setup
arm-none-eabi-gcc14.2.1, newlib-nano,-mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard -Os -ffunction-sections -fdata-sections, linked with--gc-sectionsagainst an STM32H743-shaped memory mapWASIP2_SRCSset minus sqlite/db, which is 46 translation units (CWIST + cJSON + libttak mem/sync)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)
.text+.rodata(flash).data+.bss(CWIST's share).bssfromport.cis the spike's oversized TLS block; the actual TLS image is 52 Bcwist_http_response_serialize()'s 8 KBheader_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):
ttak/compat/pthread.halready 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.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.__wasi__/__EMSCRIPTEN__, not capabilities: 53 sites inapp.c, 14 inhttp.c, plus session.c, seq_auth.c, gc.c, and others.include/cwist/sys/wasi.halready 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.http.hincludes<netinet/in.h>and declarescwist_make_socket_ipv4(struct sockaddr_in *).app.h/http.hincludecwist/core/db/sql.h, which includes<sqlite3.h>even withCWIST_DB=0.https.hincludes<openssl/ssl.h>; this works with the mbedTLS OpenSSL shim, but only with mbedTLS headers present.writer_fast.c(send/sendmsg/MSG_DONTWAIT),shutdown.c(shutdown,SA_RESTART), recv/send call sites inhttp.c,opendir/readdirinapp.c(static-dir serving), andmmap/mprotectingc.c. With--gc-sectionsnone of these are reached on the dispatch path, but they still have to compile. In the spike they link to ENOSYS stubs._Thread_local→__aeabi_read_tp: 10 objects use TLS (bind.cholds the bulk, 4 KB of.tbsson its own). Bare metal needs a TP/TLS block from the port. The spike uses one static block (single thread).__atomic_*_8(metrics.c,big_dumb_reply.c,http.c, libttakmem.c/shared.c): Cortex-M has noLDREXD/STREXD, so the port must provide these. The spike masks PRIMASK.Bugs and risks found
cwist_get_cached_date_header()caches againstcached_sec = (time_t)-1. On a board with no RTC,time()returns -1, the cache never fills, and the response carriesDate: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 sendDate. Phase 1 needs either a wall-clock hook or omittingDatewhen the clock is unset.cwist_entropy_fill()falls back toclock_gettime+srand/randwhen/dev/urandomcannot 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 usegetentropy()under__wasi__, so wiringgetentropy()to the RNG peripheral covers both paths.Phase 0 checklist
arm-none-eabi-gccfor Cortex-M7: 46/46 TUs with the shims; error inventory above__aeabi_read_tp,__atomic_*_8, plus the ENOSYS set infw/port.cqemu-system-armwith a real NVIC/SysTick (not done:qemu-system-armis not installed on the spike machine)tools(stm32-spike): Phase 0 Cortex-M7 feasibility harness (#447) (#448)
feat(port): bare-metal Cortex-M build on capability guards (#447 Phas…
cherry-pick: WASI component pipeline (#446), bare-metal port (#448, #449
feat(port): serve a CWIST app over lwIP's raw TCP API (#447 Phase 3) (#…
cherry-pick: lwIP adapter (#452) and Redis rate limiting (#453) to ma…
ci(baremetal): Cortex-M7 smoke and size budget gate (#447 Phase 5) (#455
feat(port): HTTPS over the lwIP adapter with mbedTLS 3.6 (#447 Phase …
gg582 commentedon Oct 27, 2026
Phase 2 prep: measured heap traffic on bare metal
These are measurements, not changes. I counted allocations with
-Wl,--wrap=malloc,calloc,realloc,freeon the Cortex-M7 build (libcwist_baremetal.aateb537f9d5, run under qemu-arm). Each case is 100 steady-state requests throughcwist_app_dispatch_memory()after warm-up, with call sites resolved by addr2line.cwist_arena_create, arena structcwist_arena_create, generation buffercwist_http_response_serializedispatch_memoryhands back to the callercwist_sstring_reserve(404 only)cJSON_*(JSON handler only)GET /hello5 allocations and 16.5 KB per request;GET /missing7;GET /json?…11.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
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.cwist_http_response_serialize()buffer per response.cwist_http_serialize_headers()already writes into a caller buffer.dispatch_memorykeeps its contract of returning a fresh buffer to the caller.--wrap. The size gate gets anallocsmetric with budgets:hello: 1, the buffer the API returnshello-lwipandhello-tls: 0 per request after the connection is set upCWIST_ARENA_DEFAULT_GENERATION_BYTESoverridable 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.
cherry-pick: bare-metal CI gate (#455) and lwIP HTTPS (#457) to main (#…