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.
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.
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.
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.
| endpoints | how the signal travels | what it costs |
|---|---|---|
| same thread | a plain struct cell | a field copy — no synchronisation at all |
| threads on one core | a wait-free channel | no lock, no spin; the first table on the Measured page |
| cores on one node | a slot in shared SRAM | plain stores and a sequence stamp — the fabric decides which discipline is legal |
| an endpoint is a bus | a DBC frame, encoded by the generated codec | bus time, at the frame's cycle rate |
| different nodes | that same frame, on the bus both nodes share | bus 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.
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.
| service | on the node (blobly_emb) | in the tester (blobly_net) |
|---|---|---|
| 0x19 DTC info | 01 · 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 · 0x85 | clear, durable before the answer; DTC setting on/off | diag:clear_dtcs, dtc_setting |
| 0x22 · 0x2E | multi-DID read; write gated by session and security | Diagnostics panel, Lua, target shell |
| 0x10 · 0x11 · 0x27 · 0x3E | default, extended and safety sessions; reset answered first, then performed | P2 / P2* read from the session answer and honoured |
| functional | one request, every node answers on its own id | uds.functional on CAN and on DoIP |
| 0x10 02 → boot | hand-off to the node's own bootloader: Ed25519 authentication (0x29), signed images | flash.program from a script, the Flash panel, or the CLI |
| DoIP | ISO 13400 entity on the gateway: routing activation with a tester allow-list, alive check, announcements; state-changing services need DoIP's own 0x27 unlock | DoIP 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.
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,
vcanand 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