project sheet · signals across cores · rev 2026.07

The blobly
project

An exploration of multicore performance in a signal-based environment — where the application isn't a program with a main loop but a graph of periodic handlers exchanging signals, and the interesting question is what it costs to move one of those signals between two cores, thousands of times a second, without a lock and without ever handing back a torn value.

It is built as a working automotive stack, because that is the environment where the question is real: buses, cyclic frames, function blocks pinned to cores, and no dynamic memory anywhere. Sim-first on Linux, then the same generated code on silicon.

blobly_emb — the stack that runs the signals blobly_net — the tool that watches them measured — host benches + real boards
live blobly_net · trace chart · dumped 10 blocks from 2 cores · 593 records · 21 lanes
The blobly_net GUI showing a decoded CAN trace and a two-core flight-recorder chart: amber core-0 handler lanes above green core-1 lanes, with thread and idle lanes below.
A two-core flight recorder read off one dual-core board. Amber lanes are core 0 (Cortex-M7), green lanes are core 1 (Cortex-M4) — each core stamps its own free-running clock, and the dump carries the measured offset between them so the two run on one timeline (±608 µs bound here). Handler spans on top, OS threads and the derived idle lane below.
149ns
wait-free cross-core signal read, 64-byte record, saturating writer — zero torn reads
0.9ns
scheduler cost per handler dispatch — the data hop is the cost, not the scheduling
~2% / core
4 cores, 8 buses, 200 function blocks, ~4k signal ops every 10 ms
0bytes
dynamic allocation at runtime — no malloc, no collector, checked in CI
1.0

The question

What does a second core actually buy you when the unit of work is a signal?

A signal-based environment is one where the application is described as values rather than calls. 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 model makes distribution across cores look free: move a block to core 1, and the wiring changes underneath it. It isn't free, and the whole project is an attempt to measure the difference. Every crossing is a data hop, and a hop between two cores has to survive a writer that never waits, a reader that never blocks, caches that may not be coherent, and hardware that may not arbitrate exclusive access at all.

So the questions are concrete: how many nanoseconds does a crossing cost, when does the cheap transport start returning torn data, how much SRAM does safety cost, and how much of a real ECU's budget goes to signals rather than to the application. Those numbers are in §4.0.

This is a single-author research and learning project, not a product. There's no release, no compatibility promise, and plenty of it has only ever run on one bench. It is genuinely useful and genuinely early.

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 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; §4.1 is this number
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 three real boards under one file: a dual-core domain controller, a gateway bridging the buses, and an edge node with the IO — each with its own network-management, diagnostic and trace identity, each independently flashable.

4.0

What is measured

Three benches on the host, and the things that only silicon will tell you.

4.1  Cross-core transport — host, 2 pinned cores, 64-byte non-scalar record, 1 s per side
transportSRAMreadsns / optornnote
saturating writer — the worst case
seqlock216 2004 625.350safe, but the reader spins on retries
double6 404 140156.15612 686writer laps the reader — tears
triple6 701 371149.220wait-free and always safe
paced writer @ 100 µs — signals at realistic intervals
seqlock30 073 52933.250at interval rates all three are tear-free and equally fast — don't triple your SRAM by default
double28 127 86335.550
triple29 659 30733.720
4.2  Whole-system load — 4 cores, 8 CAN buses, 200 function blocks, 10 ms rate
corerolemicro-modelreal generated stack
08 buses + 50 blocks1.89 %~3.8 %
150 blocks1.32 %~1.5 %
250 blocks1.22 %~1.4 %
350 blocks1.23 %~1.5 %
footprint448 KB text · ~0.7 MB Pss

The right-hand column is the same load through the actual generated stack on real SocketCAN interfaces — config and blocks generated, eight bus-bridge threads polling for real. The IO core costs about 2.5× an application core, and the system sits at roughly fifty times headroom. Scheduling is not where the time goes: a handler dispatch is under a nanosecond, so a 32-handler tick is about 22 ns of Loom.

4.3  What silicon adds — the STM32H7 bench: H755 with Cortex-M7 + M4 in AMP over shared SRAM, and a single-core H735
findingmeasurementconsequence
exclusives don't arbitrate 162 torn / 200 000 the exchange-based triple buffer is not safe on this fabric; cross-core signals use plain stores with sequence stamps instead
cross-core bulk stream ~33 MB/s @ 256 B the M4's uncached store bandwidth is the wall, not the ring
two independent clocks ±608 µs bound each core stamps its own timer; the dump carries a measured offset so both cores land on one timeline
instruction cache off 18k … 183k iters/ms the same loop, swinging tenfold between builds: an M7 fetching from 3-wait-state flash makes throughput a link-address lottery — until the I-cache is switched on

The last two are the reason the project exists in hardware and not only in a simulator. Neither is visible on a host: on Linux the transports all behave, and the numbers in §4.1 would have been the whole story.

5.0

The repositories

Two codebases, one wire format between them: the target stack, and the host tool that drives and observes it.

blobly_net

public · MIT V github.com/MartenH/blobly_net ↗

The bus tester and observer. It exercises a system under test over CAN and CAN-FD, and over Ethernet with the automotive protocols that run on it — send and observe traffic, decode against a DBC, run diagnostics, script test cases, simulate ECUs, plot signals live, and read back on-board traces.

  • GUI and headless from the same engine — Lua scripts and CLI, no display needed
  • virtual first: two in-process CAN networks, or Linux vcan0, no hardware
  • DBC editor, system viewer, live multi-axis plots, target shell
  • reads the two-core flight recorder at the top of this page
  • protocol engine unit-tested in CI on every push
blobly_net running its simulation demo: two virtual CAN networks, a grouped decoded trace, and a live multi-axis plot of engine speed, vehicle speed and throttle position.
The sim-demo project with no hardware attached: two in-process CAN networks, the DBC-decoded trace, and live plots.

blobly_emb

opening soon V · ThreadX private while it settles

The embedded stack — the thing being measured. A non-AUTOSAR automotive runtime: you write function blocks, and the Loom generated from ecu.toml wires them across cores and to the bus. No dynamic allocation anywhere in the runtime.

  • lock-free signal channels: seqlock / double / triple, plus a cross-core variant
  • CAN & CAN-FD with DBC-driven codec, routing, E2E and SecOC protection
  • network management, non-volatile storage, ISO-TP, a signed secure boot chain
  • GPIO / ADC / PWM, Ethernet with DoIP and SOME/IP
  • an on-board flight recorder that spans both cores of an AMP pair
  • ThreadX on target, POSIX on host — the same generated application either way

Kept private a while longer while the interfaces settle. It will open here.

Where it runs

  • Host / sim — Linux, vcan and in-process buses, fork-per-core AMP model
  • STM32H735G-DK — single Cortex-M7, FDCAN, Ethernet: the gateway and networking node
  • NUCLEO-H755ZI-Q — Cortex-M7 + Cortex-M4 in AMP: the dual-core measurements
  • STM32H723 — the edge node with the IO
6.0

Status

Maturity varies a lot between features. Some of this runs on the bench daily; some of it has been exercised exactly once.

Working

  • Function-block model and config codegen — one file to the whole wiring
  • Lock-free multicore channels — host and AMP silicon, both measured
  • CAN / CAN-FD with DBC, extended ids, routing, E2E and SecOC
  • Network management and NM-gated transmission, sleep and wake on hardware
  • Secure boot — signed images, key-separated, verified on the board
  • Flight recorder — per-core rings, one dump, one corrected timeline
  • System composition — several ECUs from one file, cross-node wiring derived and validated at build time
  • Ethernet — DoIP and a first SOME/IP layer, live on silicon