the embedded stack · public · MIT · V · ThreadX

blobly_emb

The embedded stack. You write function blocks, and the Loom generated from ecu.toml wires them across cores, to the bus and into a diagnostic server. No dynamic allocation anywhere in the runtime.

1.0

Signals, not calls

Describe the application as values, and let the configuration decide how they travel.

A function block declares the signals it reads and the signals it writes; a handler runs every 10 ms and is a pure function from one to the other. Nobody calls anybody. The wiring — which signal goes to which block, on which core, over which transport — is configuration, not code.

That is what the rest of the stack is built on. Move a block to another core, put a signal on a bus, add a DTC or make a value survive a power cycle, and the application code does not change. Only the configuration does, and the build checks it.

Distribution is not free, though. Every crossing between cores is a data hop that has to survive a writer that never waits and a reader that never blocks, on hardware that may not arbitrate exclusive access at all. The project began by measuring exactly that, and the numbers are on the Measured page.

It is early: one author builds it, the interfaces still move, and some of it has only run on one bench. The Status page says which parts.

2.0

The shape of it

One config file describes the endpoints and the signals between them. The generators turn that into the wiring; the runtime never learns anything at runtime.

An endpoint is either a partition (an isolated group of function blocks pinned to a core) or a bus. Declaring a signal's from and to is therefore enough to derive its route, and the derivation is machine-checkable: bus at either end means it is encoded onto the wire via the DBC; both ends on the same partition means a local cell with no synchronisation at all; both ends on different partitions means a cross-core channel, and only then does the transport choice matter.

# ecu.toml — the source of truth
[[partition]]
name = "sense"
core = 0

[[partition]]
name = "ctrl"
core = 1

[[signal]]
name   = "VehicleSpeed"
fields = { kph = "u16", valid = "bool" }
from   = "can0"     # a bus  -> COM + DBC
to     = "sense"

[[signal]]
name      = "Overspeed"
from      = "sense"   # core 0
to        = "ctrl"    # core 1 -> a crossing
transport = "triple"

From that, the build emits the CAN codec, the channel allocations, the port structs each function block sees, and the Loom — a static handler table per thread that snapshots inputs, calls the handler, and publishes outputs. There is one rule the whole runtime picture follows: the only thing that ever crosses between two threads is a channel.

Nothing in the generated code allocates. Fixed arrays, value structs and static tables only — no string, no map, no growable slice in any runtime path, enforced by a lint gate in CI. The whole 200-block system above links to a 448 KB binary (378 KB of code) and runs in about 0.7 MB of resident memory.

3.0

The system model

The same derivation, one level up: a system of ECUs described in one file, so a signal that crosses between boards is derived and checked rather than hand-wired twice.

A signal is declared once. Where its endpoints sit is what decides how it travels — and that is a ladder with no gap in it, from a struct field to a frame on a bus between two boards. Nothing in a function block changes as it moves: moving a partition changes what a signal costs, never what it means.

3.1  The transport ladder — derived from where the endpoints sit, never named in the application
endpointshow the signal travelswhat it costs
same threada plain struct cella field copy — no synchronisation at all
threads on one corea wait-free channelno lock, no spin; the first table on the Measured page
cores on one nodea slot in shared SRAMplain stores and a sequence stamp — the fabric decides which discipline is legal
an endpoint is a busa DBC frame, encoded by the generated codecbus time, at the frame's cycle rate
different nodesthat same frame, on the bus both nodes sharebus time, plus a gateway hop when the two nodes sit on different buses

The bottom rung is the point: cross-node communication was always modelled — it's a frame on a shared bus — it was just spelled out by hand on both sides. A system.toml names the buses (each with its DBC), the nodes and which buses each one sits on, the identities that make a node addressable, and the cross-bus routes. From that, one generator writes each node's config with its routes already resolved, and a validator checks the system the way the single-node checker checks one ECU — one writer per signal per bus, no colliding identities, coherent network-management clusters, and no consumer waiting on a signal nobody transmits where it can hear it.

# system.toml — the composition, not a second copy of the nodes
[bus.compute]  # the domain controller's bus
interface = "can0"
dbc       = "compute.dbc"

[bus.edge]     # the edge ECUs
interface = "can1"
dbc       = "edge.dbc"

# declared once — produced by one node, on one bus
[[signal]]
name     = "VehicleSpeed"
producer = "domain"
bus      = "compute"
frame    = "VehSpeedFrame"
cycle_ms = 100

# the gateway re-encodes it into the edge DBC's frame
[[route]]
gateway = "sysnode"
signal  = "VehicleSpeed"
from    = "compute"
to      = "edge"
# each node: its own ecu.toml, plus its identities
[[node]]
name  = "domain"       # H755, dual-core
ecu   = "nodes/domain/ecu.toml"
buses = ["compute"]
nm    = 0x13
diag  = { req = 0x7B0, rsp = 0x7B8 }
trace = 2

[[node]]
name  = "sysnode"      # H735, the gateway
ecu   = "nodes/sysnode/ecu.toml"
buses = ["compute", "edge"]
nm    = 0x11
diag  = { req = 0x7A0, rsp = 0x7A8 }
trace = 1

Delete that route and the build fails — the edge node consumes a signal no node transmits where it can hear it. That is the whole reason the system view exists: it lets the generator see every node at once, so a wiring mistake between two boards is a build error rather than a quiet silence on the bench.

The reference system is four real boards under one file: a dual-core domain controller, a gateway bridging two CAN buses that is also the DoIP entity, a zone node with the IO, and a telematics unit on SOME/IP over Ethernet. Each has its own network-management, diagnostic and trace identity and its own bootloader. A fifth node, the tester, is declared in the same file and played by blobly_net.

4.0

Faults and persistent data

A function block reports a fault by writing a port, and keeps a value across a power cycle by declaring it. There is no diagnostic API and no NvM call in application code.

The rule from §2.0 carries over: the application describes values, the configuration decides what happens to them. A [[fault]] names a DTC and where its result comes from. That is either a handler, which gets a generated out.fault.* field, or a received signal that times out or fails its E2E check, which needs no code at all. Debounce, enable conditions, confirmation, aging, freeze-frame snapshots and displacement are all configuration, and the fault memory runs on the comm thread with the ISO 14229 status byte.

# ecu.toml — a fault, where it comes from, and its memory
[[fault]]
name     = "SpeedImplausible"
dtc      = 0xC40100        # U0401-00
from     = "SteerLimiter.on_50ms"
debounce = { kind = "counter", fail = 3, pass = 3 }
confirm  = 1
aging    = 3
freeze   = [0xF1A0]        # snapshot = this DID

[[fault]]               # no code: the signal's own status
name   = "BrakeMsgTimeout"
dtc    = 0xC12100
signal = "BrakePressure"
on     = "timeout"

[fault_memory]
cycle = "power"

[nvm]                      # the journal behind it all
min_write_ms   = 1000
sector_records = 4096
// the handler reports; it never reads the DTC back
pub fn (mut fb SteerLimiter) on_50ms(
        inp ports.SteerLimiterIn,
        mut out ports.SteerLimiterOut) {
    out.fault.speed_implausible =
        if inp.vehicle_speed.kph > speed_plausible_kph {
            .failed
        } else {
            .passed
        }
}

The handler states this dispatch's result and keeps no latch; leaving the field untouched means not tested. Debouncing runs on the block's own thread, so a fault that crosses cores costs one more signal and nothing else.

# a value that survives a power cycle
[[signal]]
name      = "DriveMode"
fields    = { mode = "u32" }
from      = "powertrain"
to        = "powertrain"
transport = "double"
persist   = "now"        # or "shutdown"

# what a tester may read and write
[[did]]
id     = 0xF1A0
signal = "VehicleSpeed"  # live value

[[did]]
id    = 0x0102
bytes = "00"
write = { session = ["extended"], security = 1 }

# which services, in which session, behind which level
[uds.services]
"0x10 02" = { security = 1 }
"0x11"    = { sessions = ["extended"], security = 1 }
"0x2E"    = {}

Persistence is a property of a signal. With persist = "now" every write is journaled, rate-limited by min_write_ms. With "shutdown" the value is flushed at the network-management sleep edge. Either way it is restored before the first handler runs, so the block reads and writes its port exactly as before.

The fault memory lives in the same journal: status, snapshots and counters survive a power cycle, and a 0x14 clear is durable before the positive answer goes out.

DIDs are constants, live signals or RAM values. Every service is gated per session and security level, and 0x27 seeds come from the board's random number generator.

4.1  What a node serves, and how blobly_net reads it back
serviceon the node (blobly_emb)in the tester (blobly_net)
0x19 DTC info01 · 02 · 0A count, list and supported; 03 · 04 snapshots; 06 extended data (occurrence, aging, failed cycles)decoded into records with every status bit named; diag:dtcs, snapshot, extended, check.dtc
0x14 · 0x85clear, durable before the answer; DTC setting on/offdiag:clear_dtcs, dtc_setting
0x22 · 0x2Emulti-DID read; write gated by session and securityDiagnostics panel, Lua, target shell
0x10 · 0x11 · 0x27 · 0x3Edefault, extended and safety sessions; reset answered first, then performedP2 / P2* read from the session answer and honoured
functionalone request, every node answers on its own iduds.functional on CAN and on DoIP
0x10 02 → boothand-off to the node's own bootloader: Ed25519 authentication (0x29), signed imagesflash.program from a script, the Flash panel, or the CLI
DoIPISO 13400 entity on the gateway: routing activation with a tester allow-list, alive check, announcements; state-changing services need DoIP's own 0x27 unlockDoIP channels in the project, and a simulated entity for testing without hardware

Not yet: 0x78 response-pending on the node, a DID write that persists, and variant-coding parameters. Those are the next rungs of the same ladder.

5.0

Where it runs

The same generated application on a Linux host and on STM32H7 silicon: ThreadX on target, POSIX on host.

Where it runs

  • Host / sim — Linux, vcan and in-process buses, fork-per-core AMP model
  • STM32H735G-DK — single Cortex-M7, two FDCANs, Ethernet: the gateway and DoIP entity
  • NUCLEO-H755ZI-Q — Cortex-M7 + Cortex-M4 in AMP: the domain controller and the dual-core measurements
  • STM32H723 ×2 — the zone node with the IO, and the telematics unit on SOME/IP

What is in the stack

  • lock-free signal channels: seqlock / double / triple, plus a cross-core variant
  • CAN & CAN-FD with a DBC-driven codec, routing, AUTOSAR E2E Profile 1 and SecOC
  • a UDS server with fault memory, DIDs, sessions and security access
  • network management, ISO-TP, a per-node signed bootloader
  • GPIO / ADC / PWM, Ethernet with a DoIP entity and SOME/IP
  • an on-board flight recorder that spans both cores of an AMP pair