Build detail · every stage, in order
A supervisory controls stack for a microcontroller-based concept car, taken end-to-end through the software V-model — requirements, architecture, model-in-the-loop, processor-in-the-loop, run-on-board — to show what a formal embedded software process actually costs and produces.
01 — Premise
Mechanical engineers moving into software-defined vehicles inherit a discipline nobody handed them a process for. This project treats that gap as the actual problem: not "can a rover follow a line," but "what does the full formal path from requirement to flashed binary look like, and where does it break?"
The deliverable is a concept car — an Arduino Nano 33 IoT on a MotorCarrier board, driving DC motors with wheel-speed encoder feedback through a PID controller — developed in both virtual and physical form and verified against written acceptance criteria at every stage.
The engineering claim is the process, not the vehicle. Every artifact a production automotive team would be asked for was produced: a requirements specification with traceable IDs, a layered architecture decomposition, UML block and class diagrams, a plant model derived from kinematics, three distinct test stages, and a verification table closing each requirement.
02 — Process
Each design activity on the left arm was bound to a verification activity on the right arm before implementation started. I mapped a specific tool to every box — this figure is the reason the project is worth rerunning, because each right-hand box is a job that a CI system could run and nobody was running.
REQRequirements engineering — classified, ID-tagged, constraint-boundSYSSystem design — plant equations, kinematics, variable setARCHSoftware architecture — layered decomposition, UMLFUNCFunctional design — motor curve, controller structureIMPLImplementation — Simulink models, generated CMILModel-in-the-Loop — plant and controller in simulationPILProcessor-in-the-Loop — generated C on target, host-tetheredROBRun-on-Board — untethered, physical trackVEREvaluation — per-stage verification and validationVERVerification table — requirements closed or not03 — Requirements
Requirements were classified functional or non-functional, tagged SR for "software requirement" so a mixed mechanical/software artifact stays filterable, and layered as system, component, or software level. Constraints — including licensing and toolchain limits — were recorded alongside the requirement rather than discovered later.
| ID | Requirement | Acceptance criterion | Level |
|---|---|---|---|
| SR-F-01 | System modelling in MATLAB / Simulink | Create and simulate dynamic models spanning ≥10 mechanical and electrical component types | System |
| SR-F-02 | PID control for motor position | Hold motor position within ±1° | Component |
| SR-F-03 | Simulink ↔ Arduino support package compatibility | Locate Arduino block library and compile for target | System |
| SR-F-04 | Follow a prescribed path in simulation | Desired versus simulated concept-car behaviour match within ±1% | Software |
| SR-F-05 | Text-based programming in MATLAB | Support ≥100 custom functions and scripts | System |
| SR-F-06 | Visual programming in Simulink | ≥50 pre-built blocks plus custom block creation | System |
| SR-F-07 | Apply signal filtering to sensor input | Handle noisy encoder feedback within the simulation host's capability | Software |
Extract of the full specification (Table 7). The complete table, including non-functional requirements and per-row constraints, ships in the repository as the source spreadsheet.
A MATLAB release update moved a folder on the critical path and silently broke the link to the Arduino support library. Several days of debugging with MathWorks support produced a requirement that had never been written down: the Arduino library and the Simulink library must remain compatible for the duration of the project. Toolchain version pinning is not an implementation detail — it is a requirement, and it surfaced only because something broke.
04 — Design
The design was decomposed iteratively — an initial split, then a layered redecomposition — before any model was drawn. Differential-drive kinematics were derived into a plant model with a documented variable set, and a DC gear motor was characterised experimentally to give the controller a real response curve to work against.
Rather than assume a transfer function, the motor was driven across its PWM range and its response recorded, producing the curve the controller was later tuned against. The characterisation scripts and captured response data are in the repository.
motorResponse data, not traced.The controller does not talk to the motors directly. The Nano is I²C master to a co-processor on the MotorCarrier board that runs the motor loop, so part of the control problem is deciding what belongs above that bus and what belongs below it. Everything above it is the work described here; everything below it is a vendor device with a fixed interface.
Below the bus the interface is fixed by someone else, which makes reading it part of the design work rather than an afterthought. The dependency was documented as a class diagram before any control model was built, so the constraints it imposes — fixed-point gain types, a control loop running on the co-processor's own schedule, encoder counts rather than angles — were known while the architecture was still movable.
Interfaces were captured as UML rather than described in prose, and the diagrams were written as PlantUML source so they live in version control as text instead of as a binary drawing. Three of the diagrams and their source are reproduced below.
The model-in-the-loop pipeline as three stereotyped rectangles — signal creation, kinematics, visualisation. Deliberately coarse: this is the diagram that fixes the boundaries before any block is placed.
@startuml skinparam rectangle { BackgroundColor<<Input>> White BackgroundColor<<Calculation>> White BackgroundColor<<Visualization>> White Dimension 300x200 } rectangle "Create velocity and angular velocity signals" <<Input>> as input rectangle "Vehicle Kinematics" <<Calculation>> as calculation rectangle "Visualization of the simulated path" <<Visualization>> as visualization input -right-> calculation calculation -right-> visualization @enduml
The encoder chain, as classes. A wheel-speed signal is integrated over time into an angular position in degrees — the quantity the position controller actually closes on.
%UML Source code for Encoder UML diagram @startuml !define RECTANGLE class RECTANGLE WheelSpeed { +input: inherit auto } RECTANGLE DiscreteTimeIntegrator { +integrate(input: inherit auto): inherit auto } RECTANGLE Degrees { +output: inherit auto } WheelSpeed --> DiscreteTimeIntegrator : input DiscreteTimeIntegrator --> Degrees : output @enduml
The closed-loop distance controller. Setpoint from the timing chart, subtract measured distance, error into the PID, output converted to per-wheel velocity and split across the two motors. Angular velocity enters as a separate input to the conversion so heading and distance stay independently commandable.
Source code for distanceClosedloop_hw diagram @startuml skinparam rectangle { BackgroundColor<<Subsystem>> LightBlue BorderColor<<Subsystem>> DarkBlue } rectangle "Input\n(Timing Chart)" as input rectangle "Actual distance" as actualDistance rectangle "PID controller" as pidController rectangle "Angular velocity" as angularVelocity rectangle "Convert to wheel velocity" <<Subsystem>> as convertToWheelVelocity rectangle "LeftMotor" as leftMotor rectangle "RightMotor" as rightMotor input -down-> actualDistance : Subtract actualDistance -down-> pidController : Error pidController -down-> convertToWheelVelocity angularVelocity -right-> convertToWheelVelocity convertToWheelVelocity -down-> leftMotor convertToWheelVelocity -down-> rightMotor @enduml
05 — Implementation
Each stage keeps the same controller and swaps out how much of the world is real. Simulation first, then real generated code on the real chip with a host watching, then the tether cut.
Plant model and controller run entirely in simulation. Commanded linear and angular velocity are generated from a timing chart, pushed through differential kinematics, and the resulting path is plotted against the intended track. Nothing here touches hardware, so an error is a modelling error and nothing else — which is the whole point of doing it first.
SR-F-04.Simulink generates C, the C is compiled for the SAMD21, and the binary runs on the board while Simulink stays attached over XCP-on-serial in external mode. Signals stream back live, so the PID constants can be changed and their effect watched without reflashing.
This stage is where the tuning happened, and where the cost of the manual loop shows up: 59 recorded trials to develop, tune, and test the controller — each one a human deciding what to change next.
Embedded Coder emits a bare-metal single-rate scheduler. The interesting part is not the maths — it is that overrun detection, interrupt masking, and external-mode transport are all generated, and all of it is code that has to compile and link on the target before anyone can test anything.
volatile int IsrOverrun = 0; static boolean_T OverrunFlag = 0; void rt_OneStep(void) { /* re-entrancy guard: the step did not finish before the next tick */ if (OverrunFlag++) { IsrOverrun = 1; OverrunFlag--; return; } #ifndef _MW_ARDUINO_LOOP_ interrupts(); #endif distanceClosedloop_hw_step(); #ifndef _MW_ARDUINO_LOOP_ noInterrupts(); #endif OverrunFlag--; }
Whitespace and identifiers restored for readability — Embedded Coder ships obfuscated symbol names and collapsed formatting in this configuration. The control flow is unchanged.
The hardware contract is a generated header. Bare metal, no RTOS; external mode over XCP on a 115200 serial link; I²C at 100 kHz to reach the MotorCarrier. Every one of these is a value that must match the physical setup or nothing runs.
#define MW_TARGETHARDWARE Arduino Nano 33 IoT #define MW_RTOS Baremetal #define MW_MULTI_TASKING_MODE 1 #define MW_SCHEDULER_INTERRUPT_SOURCE 0 /* external mode: live signal streaming back to Simulink */ #define MW_EXTMODE_CONFIGURATION XCP on Serial #define MW_EXTMODE_COMPORTBAUD 115200 #define MW_EXTMODE_SIGNALBUFFERSIZE 8192 /* I2C link to the MotorCarrier co-processor */ #define MW_I2C_I2C0BUSSPEEDHZ 100000
Firmware flashed, host disconnected. The car drives a taped physical track under its own power, making every command and correction from the on-board binary — no PC, no operator, not an RC car. The path itself is a Stateflow chart: a sequence of linear and angular velocity commands against time.
06 — Results
Three run-on-board trials, same track, same starting position. Leaving the marked boundary is a failure. Completing the course in bounds would be 100%; the figures below are how far the car got before going out of bounds. Between trials, PID constants and the linear and angular speed targets were adjusted and the firmware redeployed.
| Trial | Result | Track completed |
|---|---|---|
| 1 | Fail — left path | 30% |
| 2 | Fail — left path | 50% |
| 3 | Fail — left path | 70% |
Monotone improvement across three redeployments, and no fourth trial. The tuning loop was manual: change constants, rebuild, reflash, walk the track, watch with human eyes, decide pass or fail. Each iteration cost enough that three was the budget.
The controller was not wrong. The tuning process was the bottleneck. Six independent PID variables across two controllers, explored by hand, with a binary pass/fail judged by a person standing over a track — 59 tethered trials to build it, and only three untethered data points to validate it. The project reaches this conclusion on its own and says so in the Outlook.
The experiment could be enhanced by writing a script to automate the PID tuning process … having data from more trials, and numerical data on the degree of path deviation rather than just a pass/fail decision, would produce more information for tuning and optimizing the controller.
— my own writeup, at the time
That sentence is the brief for the second half of this project. The same build, run as a team pipeline →
07 — Provenance
This project sits on top of vendor hardware libraries and a commercial modelling toolchain. Nothing borrowed is reproduced here — dependencies are named, not vendored, and the boundary is drawn explicitly so there is no ambiguity about authorship.
| Artifact | Origin | In the repository |
|---|---|---|
| Simulink models — MiL, PiL, run-on-board, control panel | Written for this project | Yes |
| Stateflow chart — track path state logic | Written for this project | Yes |
| Requirements specification and verification table | Written for this project | Yes |
| UML / SysML sources (PlantUML), architecture diagrams | Written for this project | Yes |
| Motor characterisation scripts and captured response data | Written for this project | Yes |
| Figures, timing charts, test procedure, recorded trial results | Produced for this project | Yes |
| Generated C and target configuration headers | Embedded Coder output from the models above | Yes — labelled as generated |
| UML class diagram of the vendor control interface | Drawn for this project to document a dependency | Yes — diagram only, not the library |
| Arduino MotorCarrier and device libraries | Arduino AG, LGPL-2.1 | No — named as a dependency |
| MATLAB, Simulink, Stateflow, Embedded Coder, Arduino support package | MathWorks, commercial licence | No — named as a dependency |
| Arduino Engineering Kit course material and datasheets | Arduino AG / component vendors | No — not redistributable |
Not by memory. Every candidate file was hashed with SHA-256 and compared against the stock Arduino Engineering Kit Rev 2 distribution; a file whose hash appears in the kit is stock, wherever it happens to sit in my folders. Eight files came back stock and were excluded — including one that reads as mine by filename, distanceClosedloop_hw.slx.r2020b, which turned out to be byte-identical to the kit's Exercise 3 model.
The dates then separate cleanly, and they describe what this project actually was: stock files date to December 2020 – June 2021; everything I built dates to December 2024 – January 2025, when the design was migrated onto a 2025 toolchain and block behaviour, support-package layout and code generation had all moved underneath it. The vendor libraries themselves were used exactly as supplied. What was rewritten is the control layer above them.
The generated C for the two run-on-board models no longer exists. Those build directories still hold the object files, the makefile, and the linked .elf and .bin — but the .c and .h were cleaned up after the build. The firmware that actually drove the car can still be executed and can no longer be read.
Nothing was lost through carelessness; that is simply what a local build directory does. It is also precisely the failure a pipeline removes, by archiving generated sources against the commit that produced them.