The single structural decision that mattered most in this firmware was separating the display from the sensing. It sounds like housekeeping. It is the difference between a device whose readings you can trust and one that misleads you occasionally.
What goes wrong when they share a path
The naive loop reads the sensor, computes an orientation, draws the screen, repeats. It is the shape every embedded project starts with, and it works until the drawing takes real time.
Drawing a colour frame is not instant. Pixels go out over a serial bus, and while that is happening the loop is not reading the sensor. So the sampling interval stops being constant: short when little changed on screen, long when a lot did.
That irregularity is poison for orientation estimation. Integrating angular rate assumes you know how much time passed between samples. Get that wrong and error accumulates — and it is worst exactly when the screen is busiest, which is when the device is moving, which is when you actually need it.
The symptom is not a crash. It is a device that is subtly less trustworthy during movement, which is a much harder thing to notice and a much worse thing to ship.
The separation
Sensing, display, decision logic and health monitoring run as separate tasks. Sensing samples on its own schedule; when it has a result it hands it off through a queue and goes straight back to sampling. The display task picks up whatever is most recent and draws.
The property that matters: a slow frame now delays a frame, not a measurement. If the display falls behind, the user sees a slightly older number for a moment. The orientation estimate underneath is unaffected, because nothing about drawing can stall sampling.
Why a queue rather than a shared variable
Two tasks touching the same orientation value is a race. The display can read a structure halfway through the sensor task writing it and produce a frame combining old roll with new pitch — a reading that never existed.
That class of bug is rare, non-deterministic and nearly impossible to reproduce on demand. It is exactly what you do not want inside something people rely on.
A queue makes the handoff atomic: a complete reading goes in, a complete reading comes out, and there is no window where a half-written value is visible.
The queue is also deliberately shallow. If the display cannot keep up we want the newest reading, not a backlog of stale ones delivered late. Depth would buy smoothness at the price of showing the user where the handpiece was.
Budgets, so regressions announce themselves
A monitor task watches how long things take against defined limits — one for sensor-to-processing, a tighter one for display.
These are targets we hold the system to, not measurements of what it achieves, and we are careful not to publish them as performance figures. Their value is internal: when a change makes something slower, a budget is exceeded and it surfaces as a specific violation rather than as a vague sense that the device feels worse than it used to.
Without that, performance regressions get found by users.
What it costs
- Memory. Each task needs its own stack. On a small microcontroller that is a real budget, and four tasks is four allocations that have to be sized.
- Complexity. Priorities, queue depths and handoff points are all decisions, and getting one wrong produces timing bugs harder to debug than the single-loop code they replaced.
- You lose linear reasoning. "What happens next" is no longer the next line of code.
The lesson
Do this early. Retrofitting task separation into working single-loop firmware means untangling assumptions spread through everything — and you will be doing it while chasing the timing bug that made you decide to do it at all.
If a device both senses and displays, treat them as separate concerns from the first commit, even while the display is still a placeholder. The cost is small at the start and large later.