Skip to content

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.

Control app (host)off-machine · Web Serial Control app (host) — Uploads compiled tests, sends manual jogs, and polls live data over the ProtoEmb serial protocol. Never a safety authority. Control app (host)off-machine · Web SerialUploads compiled tests, sends manualjogs, and polls live data over theProtoEmb serial protocol. Never asafety authority. IO_fullDuplexSerialSERIAL cog · free-run IO_fullDuplexSerial — Buffered full-duplex UART. Queues outbound bytes and drains the hardware RX into per-channel software buffers for other cogs. IO_fullDuplexSerialSERIAL cog · free-runBuffered full-duplex UART. Queuesoutbound bytes and drains the hardwareRX into per-channel software buffersfor other cogs. app_messageSlaveCOMMUNICATION cog · 100 Hz app_messageSlave — The host’s serial port: decodes incoming ProtoEmb messages and answers them by reading or writing the rest of the firmware — state, config, moves, samples. Uses the generated ProtoEmb codec; the older IO_protocol / IO_gcode parsers are off the runtime path. app_messageSlaveCOMMUNICATION cog · 100 HzThe host’s serial port: decodesincoming ProtoEmb messages and answersthem by reading or writing the rest ofthe firmware — state, config, moves,samples. Uses the generated ProtoEmbcodec; the older IO_protocol /IO_gcode parsers are off the runtimepath. IO_SDCardLOGGER cog · 1 kHz IO_SDCard — Generic SD-channel system: streams packed C structs to/from files via per-channel queues. Holds the test program (binary moves) and the recorded sample log — so a test runs with the host unplugged. IO_SDCardLOGGER cog · 1 kHzGeneric SD-channel system: streamspacked C structs to/from files viaper-channel queues. Holds the testprogram (binary moves) and therecorded sample log — so a test runswith the host unplugged. app_testManagementCONTROL cog · 1 kHz app_testManagement — Test-session lifecycle (IDLE → STARTING → RUNNING → ENDING). Replays the SD test program into the motion queue and gates manual jogs; ends the test on G122 / EOF, a sample limit, or a stop request. app_testManagementCONTROL cog · 1 kHzTest-session lifecycle (IDLE →STARTING → RUNNING → ENDING). Replaysthe SD test program into the motionqueue and gates manual jogs; ends thetest on G122 / EOF, a sample limit, ora stop request. app_monitorMONITOR cog · 1 kHz app_monitor — Aggregates force + position + setpoint + time into one sample row each tick, enforces the sample-profile limits, and queues rows to the SD log while a test runs. app_monitorMONITOR cog · 1 kHzAggregates force + position + setpoint+ time into one sample row each tick,enforces the sample-profile limits,and queues rows to the SD log while atest runs. app_controlCONTROL cog · 1 kHz app_control — The safety state machine (DISABLED → RESTRICTED → MANUAL → TEST). Each tick it folds faults, restrictions and operator requests into one gated output — motionEnabled — that the whole motion chain obeys. app_controlCONTROL cog · 1 kHzThe safety state machine (DISABLED →RESTRICTED → MANUAL → TEST). Each tickit folds faults, restrictions andoperator requests into one gatedoutput — motionEnabled — that thewhole motion chain obeys. app_motionCONTROL cog · 1 kHz app_motion — Pure motion executor. Pops one move off its queue and turns the G-code (G0/G1 linear, G4 dwell, G28 home, G123 waveform) into dev_servo commands. Knows nothing about test sessions. app_motionCONTROL cog · 1 kHzPure motion executor. Pops one moveoff its queue and turns the G-code(G0/G1 linear, G4 dwell, G28 home,G123 waveform) into dev_servocommands. Knows nothing about testsessions. app_gaugeframing helper · no cog app_gauge — Stateless framing layer over the sensors: reports jaw position (µm) and load (mN) in either the machine frame or the zeroed sample frame by subtracting latched offsets. app_gaugeframing helper · no cogStateless framing layer over thesensors: reports jaw position (µm) andload (mN) in either the machine frameor the zeroed sample frame bysubtracting latched offsets. dev_servoMOTOR cog · 1 kHz dev_servo — Closed-loop servo: a 1 kHz loop turns position / velocity commands into step + direction pulses, closed on the encoder, and reports arrival and stalls. app_motion hands it commands through a lock-protected request struct. dev_servoMOTOR cog · 1 kHzClosed-loop servo: turns position /velocity commands into step + dirpulses, closed on the encoder, andreports arrival and stalls. Commandsarrive in a lock-protected requeststruct written by app_motion. dev_forceGaugeFORCEGAUGE cog · free-run dev_forceGauge — Drives the ADS122U04 24-bit load-cell ADC over UART (IO_ADS122U04) and converts raw counts to millinewtons using the calibrated zero + scale from NVRAM. dev_forceGaugeFORCEGAUGE cog · free-runDrives the ADS122U04 24-bit load-cellADC over UART (IO_ADS122U04) andconverts raw counts to millinewtonsusing the calibrated zero + scale fromNVRAM. Motor + gantryhardware Motor + gantry — A closed-loop step/dir servo drive with its own encoder closes the position loop; the firmware only commands the ideal pulse train. Motor + gantryhardwareA closed-loop step/dir servo drivewith its own encoder closes theposition loop; the firmware onlycommands the ideal pulse train. Load cell + encoderhardware Load cell + encoder — The machine’s two senses: a strain-gauge load cell (read via the ADS122U04) and the servo position encoder. Load cell + encoderhardwareThe machine’s two senses: astrain-gauge load cell (read via theADS122U04) and the servo positionencoder. Primary cog — dev_cogManager · watchdog · dev_nvram8th core · 10 Hz supervisor Primary cog — dev_cogManager · watchdog · dev_nvram — Boots one P2 core per subsystem, watches each cog’s watchdog and CRC stack-canary, and persists the machine profile. A cog that stops kicking or crashes becomes a fault that forces the machine to DISABLED. Primary cog — dev_cogManager ·watchdog · dev_nvram8th core · 10 Hz supervisorBoots one P2 core per subsystem,watches each cog’s watchdog and CRCstack-canary, and persists the machineprofile. A cog that stops kicking orcrashes becomes a fault that forcesthe machine to DISABLED. Web Serial ProtoEmb frames test program test moves manual jog ★ addMove() → lock-free queue ★ move() / setVelocity() → staging (lock) step + dir pulses raw load + position counts → mN per-tick read sample rows host-polled READ_SAMPLE (live) motionEnabled machine force limit flags → abort
command / motion path sensor feedback safety gating & limits ★ the test → motion → stepper spine · hover or tab to any module for what it does

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_motion is a lock-free queue. Test moves and staged manual jogs are pushed with app_motion_addMove() onto a single-producer/single-consumer lib_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_servo is a lock-protected hand-off. app_motion calls dev_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, where app_motion caps 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 from app_monitor (whose flags also make app_testManagement stop 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

See Building the firmware.