Firmware¶
The firmware runs on a Parallax Propeller 2 and is written in C, compiled
with the FlexC compiler via PlatformIO. It lives in Firmware/MaDCore/.
It is built from ~20 small modules, each prefixed with its layer
(app_control.c, dev_servo.c, IO_protocol.c, lib_timer.c). The map below
is the fastest way to see how they fit together — and how a test flows from the
host all the way down to a motor pulse.
How the modules fit together¶
Two data paths run through the firmware in opposite directions:
- a command / motion path (teal) carries a test or a manual jog down from the host to the motor, and
- a sensor-feedback path (amber) carries force and position up from the load cell back to the log and the host.
Hover (or keyboard-tab) over any module to see what it does and which of the
eight cores it runs on. The ★ edges are the spine this page is really about:
app_testManagement feeds app_motion, which feeds dev_servo.
Notice that app_motion knows nothing about tests and app_testManagement
knows nothing about pulses — each module does one job and hands off to the next.
That separation is what lets the same app_motion execute both a manual jog and a
full test program. The two walkthroughs below trace each path in detail.
From a command to a step¶
This is the spine of the machine. A move starts as a host request and ends as a pulse on a wire, crossing up to five of the eight cores on the way down:
flowchart TB
HOST["Control app (host)"]
FDS["IO_fullDuplexSerial<br/><small>SERIAL cog</small>"]
MS["app_messageSlave<br/><small>COMMUNICATION cog</small>"]
SD["IO_SDCard · /gcode/*.bin<br/><small>LOGGER cog</small>"]
TM["app_testManagement<br/><small>CONTROL cog</small>"]
MO["app_motion<br/><small>CONTROL cog</small>"]
CO["app_control · safety gate<br/><small>CONTROL cog</small>"]
ST["dev_servo<br/><small>MOTOR cog</small>"]
GANTRY["Motor + gantry"]
HOST -->|Web Serial| FDS --> MS
MS -->|"manual jog (staged)"| TM
MS -->|upload program| SD -->|binary moves| TM
TM -->|"addMove() — lock-free queue, same cog"| MO
MO -->|"moveTo()/setVelocity() — request struct, cross-cog lock"| ST
CO -.->|motionEnabled| MO
ST -->|step + dir pulses| GANTRY
Two things make this work:
app_testManagement → app_motionis a lock-free queue. Test moves and staged manual jogs are pushed withapp_motion_addMove()onto a single-producer/single-consumerlib_staticQueue. It needs no lock because both ends run on the CONTROL cog — manual jogs that arrive on the COMMUNICATION cog are first parked in a request slot and only enqueued from the CONTROL cog, so the queue only ever has one writer.app_motion → dev_servois a lock-protected hand-off.app_motioncallsdev_servo_moveTo()/dev_servo_setVelocity(), which write a request struct guarded by the servo's lock; the MOTOR cog snapshots it on every 1 kHz control tick and drives the pulse hardware, closed on the encoder. This edge does need a lock because it crosses cores.
The CONTROL cog runs its three modules in a fixed order every tick —
app_testManagement → app_motion → app_control — so a move is queued, executed,
and then re-checked against the safety state within the same millisecond. All of
it is gated by app_control_motionEnabled(): drop that bit (fault, restriction,
or a disable request) and the queue is emptied and the stepper stopped.
For how a single G123 waveform record becomes a trajectory that dev_servo
generates and tracks to within a micron — and why the cycles start at a peak
rather than at the centre — see
waveform motion control.
From the load cell to the chart¶
Feedback runs the other way. The load cell and encoder are sampled on their own cores, framed into engineering units, aggregated, and then fan out to three consumers:
flowchart TB
ADC["IO_ADS122U04<br/><small>24-bit load-cell ADC</small>"]
FG["dev_forceGauge · counts → mN<br/><small>FORCEGAUGE cog</small>"]
ENC["IO_positionFeedback<br/><small>encoder steps → µm</small>"]
GA["app_gauge · machine/sample frame"]
AM["app_monitor · aggregate + limits<br/><small>MONITOR cog</small>"]
SD["IO_SDCard · /test/*.bin<br/><small>LOGGER cog</small>"]
TM["app_testManagement<br/><small>(abort the test)</small>"]
CO["app_control<br/><small>(over-tension fault)</small>"]
HOST["Control app (host)"]
ADC --> FG --> GA
ENC --> GA --> AM
AM -->|sample rows| SD
AM -.->|limit flags| TM
GA -.->|machine force| CO
GA -->|"host polls READ_SAMPLE"| HOST
A detail worth knowing: the live data the host charts is pulled, not pushed.
The host polls with a READ_SAMPLE request and app_messageSlave answers it by
reading app_gauge (and the motion setpoint) live at request time.
app_monitor's aggregated sample row is a separate product — it feeds the
SD log and the sample-profile limit check, which is what actually aborts a
running test if force or displacement leaves its envelope.
The layering rule¶
Underneath the runtime wiring, the modules obey a strict layered architecture: each layer may only call the layer directly below it.
flowchart TB
APP["<b>APP</b> — application logic & state machines<br/>(control, motion, monitor, gauge, notifications, test management)"]
DEV["<b>DEV</b> — device drivers<br/>(stepper, force gauge, cog manager, NVRAM, watchdog)"]
IO["<b>IO</b> — protocols & peripheral drivers<br/>(full-duplex serial, ADS122U04, SD card, position feedback)"]
LIB["<b>Library</b> — pure utilities<br/>(timer, static queue, CRC/muldiv) — no upward deps"]
HAL["<b>HAL</b> — hardware abstraction<br/>(GPIO, serial, pulse-out, locks)"]
HW["<b>HW</b> — MCU registers (Propeller 2)"]
APP --> DEV --> IO --> LIB --> HAL --> HW
GEN["<b>Generated/</b> — ProtoEmb codec (from YAML, do not edit)"]
IO -.uses.-> GEN
This separation is enforced by convention and by pio check (MISRA C:2023 +
CERT). APP/DEV/IO never include low-level MCU headers — they go through the HAL —
and configuration lives in Config/ subfolders.
The host link
The host link is the generated ProtoEmb codec, driven by
app_messageSlave over IO_fullDuplexSerial. G-code is compiled to binary
records on the host, and the firmware replays those records (see
below).
Eight cores, one job each¶
The Propeller 2 has 8 independent cores ("cogs"), and MaD uses every one. The
primary core boots and supervises the rest; dev_cogManager gives each subsystem
its own core on a named channel, with stack-canary protection and watchdog
integration.
| Cog / channel | Modules it runs | Rate | Job |
|---|---|---|---|
| Primary (boot core) | dev_nvram · dev_cogManager · watchdog |
10 Hz | Boots & supervises the other 7 cogs; persists the machine profile |
MONITOR |
app_monitor (+ app_gauge, IO_positionFeedback) |
1 kHz | Aggregate samples, enforce limits, queue the SD log |
MOTOR |
dev_servo |
1 kHz | Closed-loop servo: step + direction pulses, closed on the encoder |
COMMUNICATION |
app_messageSlave · app_notification |
100 Hz | Host serial port: decode & answer ProtoEmb messages |
CONTROL |
app_testManagement → app_motion → app_control |
1 kHz | Test lifecycle, motion execution, safety state |
LOGGER |
IO_SDCard |
1 kHz | Stream packed records to/from the SD card |
FORCEGAUGE |
dev_forceGauge (+ IO_ADS122U04) |
free-run | Sample the load-cell ADC, convert to mN |
SERIAL |
IO_fullDuplexSerial |
free-run | Buffered full-duplex UART |
A free-running channel (0 Hz in the config) loops as fast as it can rather
than at a fixed rate — MOTOR, FORCEGAUGE and SERIAL rely on their pulse/UART
hardware for timing. Channels are declared in
DEV/Config/dev_cogManager_config.c with the
DEV_COGMANAGER_CHANNEL_CREATE_INIT/RUN macros, and the comment on the CONTROL
channel spells out the rule the spine depends on:
Order matters: testManagement feeds motion's queue, then motion executes, then control evaluates state.
The primary core never sleeps a worker itself — its watchdog and
cog-manager only flag a stuck or crashed cog; app_control reads those flags
(watchdog_isAllAlive(), dev_cogManager_isAllRunning()), raises a fault, and
forces the machine to DISABLED. That is what turns "a cog stopped responding"
into "motion is off."
Locking discipline
HAL locks are not reentrant, and a module must never call another
module's API while holding its own lock — this prevents both self-deadlock
and cross-cog ABBA deadlocks. Library data structures (e.g. the static
queue) are unsynchronised by contract; the owning module adds a lock only when
its topology needs one. The motion spine shows both patterns in two lines:
app_testManagement → app_motion is lock-free (single producer/consumer on
one cog), while app_motion → dev_servo is lock-protected (it crosses to
the MOTOR cog).
The control state machine¶
app_control owns the machine state (DISABLED, RESTRICTED, MANUAL,
TEST) and gates all motion through app_control_motionEnabled(). See the
theory of operation for the diagram, and
Faults and
Restrictions for the full lists.
- Faults force
DISABLED. They are recomputed from live inputs every tick (cog liveness, watchdog, the ESD inputs, the servo loop and force gauge being alive), so they clear when the cause does. The servo stall is the one that latches: only a motion-disable request clears it. - Restrictions select
RESTRICTED, whereapp_motioncaps the feedrate at the profile's restricted velocity: machine tension, the end stops, the door and, while a test runs, the sample's force and length limits mirrored fromapp_monitor(whose flags also makeapp_testManagementstop the test).
Tests run from SD, host-free¶
When a test starts, the host has already compiled the motion profile into a
stream of binary move records (app_motion_move_t — each tagged with its
G-code opcode G0/G1/G4/G28/G123… in a one-byte field) and uploaded them
to the SD card's /gcode channel. app_testManagement then replays those records
straight into the motion queue, so the test runs without the host attached —
the basis of the safety model. The trailing
G122 record marks end-of-program; app_testManagement sees it, drains the
queue, and ends the session.
Because the firmware streams the records it stored — rather than re-parsing text — nothing in the SD → queue → playback pipeline has to understand G-code syntax.
Build targets¶
The firmware builds for hardware and for the host-side test rig:
| PlatformIO environment | Purpose |
|---|---|
MaD |
The firmware (FlexC), one build for every board: the protocol on P30/P32 (the control port), programming and the debug log on P62/P63 (J1). This image is also what the SIL emulator executes. |
native_test |
Unity unit tests on the host, against a mock HAL |