HSI trim: calibrating a crystalless MCU over the bus
Cheap microcontrollers run on internal RC oscillators, no crystal. On the CH32V006 that oscillator is called the HSI (High Speed Internal), and the datasheet promises ±1% frequency accuracy. The five chips on my bench measured up to 7,000+ ppm (parts per million) apart from each other. For most firmware that’s irrelevant, but for a servo bus where devices time replies off each other’s transmissions, it’s the difference between a working chain and garbled bytes.
This article documents how OpenServoCore calibrates a fleet’s clocks today, using nothing but the bus wire itself. There are no extra pins, no factory calibration step, and no per-chip fixture. It’s the current shipping design, code-verified and bench-measured. There are three pieces, and you can lift each one independently: a broadcast break train for absolute calibration at boot, a differential drift tracker that runs continuously off normal traffic, and the trim loop that turns error into oscillator steps.
If you want the background first, the protocol design article covers the bus this runs on, and the project overview has the full picture.
TL;DR
In a hurry? The whole design in four bullets:
- Most things on the bus don’t care about clock drift. UART framing tolerates percent-level mismatch. Servo-to-servo listening in coordinated reads is the only thing that cares, and the budget there is pairwise.
- A chip can’t measure its own clock. You need an external reference with a hardware anchor (broadcast UART breaks, spaced by the host’s crystal), plus differencing to cancel every systematic bias.
- Between calibrations, track drift passively. Baseline right after each trim decision, then read only shifts from that baseline, and everything constant cancels.
- Round-to-nearest is your deadband, and measure your actuator’s real gain from the corrections you apply. The datasheet’s ppm-per-step was off by up to 2x on my chips.
The rest of the article explains how each piece works and the gotchas I hit along the way.
When Clock Drift Actually Matters
This is worth establishing first, because the answer shapes the whole design. Most consumers of “accurate time” on this bus need nothing at all.
Host-to-servo communication doesn’t care. UART framing tolerates several percent of clock mismatch. I measured this on the V006. The oscillator cannot be de-tuned far enough to break framing or corrupt data at 3 Mbaud in either direction, and ±3.4% is the trim register’s full throw. The margin over a real chip’s ±1% is at least 3x, and lower baud rates only widen it.
Reply timing doesn’t care either. In OpenServoCore’s protocol, replies are event-driven. A servo replies when its frame is verified and its reply is staged, so there is no scheduled instant to hit. Without a timing grid, there is nothing for drift to skew.
Fleet simultaneity is fine too. “Apply all staged writes now” is just a broadcast event on the shared wire, so it doesn’t need synchronized clocks at all.
But servo-to-servo listening does care. In a coordinated read, servo k times its turn off the previous servo’s transmission. That is one uncalibrated RC oscillator receiving another. The error budget is pairwise. Two chips at opposite ends of the factory spread are 7k+ ppm apart, and at 3 Mbaud that garbles the tail bytes of every snooped frame. Trim the fleet so the worst pair sits under about 1.4k ppm and those errors go away. I verified cause and effect on the bench. Untrimmed chains log framing errors, and trimmed chains don’t.
So the requirement is to pull every chip toward a common reference, tightly enough that any pair is close. The host owns the only crystal on the bus, so the host is the reference. The problem is transporting the host’s crystal accuracy to each servo through nothing but a shared UART wire.
Why A Chip Can’t Measure Its Own Clock
There is a trap every first design falls into, which is to have the servo time incoming bytes and compare against nominal. But every measurement a chip makes runs on the clock being measured. Its timers, interrupt latency, and UART sampling all scale with the same unknown. Worse, the systematic biases (interrupt entry time, sampling offsets) are the same order of magnitude as the drift being measured, and they don’t cancel, because each end of a measured interval rides a different code path. The chip is basically trying to weigh a scale by putting the scale on itself.
Two things break the impasse, and this design uses both.
- An external reference with a hardware anchor. An event the host generates at crystal-exact spacing, which the servo can timestamp the same way every time.
- Differencing. Arrange the measurements so every systematic bias appears identically in both ends of a pair and vanishes in the subtraction.
Calibrating With A Break Train
The protocol frames everything with UART breaks (the protocol article covers why). A break is the line held low longer than any legal byte can manage. The break detector is the transport’s one receive interrupt, which makes it the perfect measurement anchor. Every break wakes the servo through the identical interrupt path.
Calibration is a broadcast instruction. The host announces “N gaps of exactly T microseconds”, then sends N+1 bare breaks with its crystal timing the spacing. Every servo on the bus (all of them at once, it’s a broadcast) timestamps its local clock at each break-wake entry.
Here is why this works. Both ends of every gap are stamped by the same interrupt path, so interrupt-entry latency cancels in each difference. That latency is the exact bias that poisons self-measurement. After the subtraction you’re left with pure clock skew plus sub-microsecond jitter, and averaging over the train crushes the jitter. Eight 400 µs gaps get you about ±260 ppm from about 4 ms of bus time, which is a tenth of the smallest trim step.
There are some hygiene rules, and each one was earned the hard way. All of them are visible in the shipped code:
- A per-gap gate. A measured gap must sit within 1/16 (about 6%) of the announced spacing. That is wider than any legal clock state (the full trim throw is ±3.4%) and far tighter than a missed or spurious break. A train with fewer than half its gaps valid decides nothing.
- Real breaks only. Each counted stamp must come with ring progress, because a real break deposits a byte in the receive buffer and a stale latched interrupt flag doesn’t. So freshness is judged from the data instead of from the flag.
- A watchdog. A train that goes silent for two gap-times is abandoned without a decision.
- Broadcast only, by contract. A unicast calibration would be acked, which puts the replier’s own break on the wire right where the train starts. So the whole fleet measures one train simultaneously instead.
- It doubles as a rescue path. Break detection is threshold-free across the entire trim range, so calibration works even on a chip whose trim register has been written all the way to a rail. In other words, it reaches below the layer that a bad trim can break.
Each of these rules excludes a whole class of bad samples structurally, instead of hoping filters catch them later. It’s also why a 48 MHz chip with no divider can run the whole loop in a few integer operations.
Tracking Drift Between Trains
Trains run at boot and whenever the host knows its own behavior changed, so there is no periodic timer involved. But RC oscillators move with temperature, so drift between trains still needs watching. The tracking is passive, from traffic that’s already on the bus, and this is my favorite trick in the whole design.
The servo stamps break-wakes anyway. Take two adjacent stamps that bracket exactly one CRC-verified, silent host instruction. “Silent” means a shape that draws no reply, because a reply would insert some servo’s turnaround (timed by its clock) into the span. For such a pair:
measured − expected = seam + drift × span
Here expected is the frame’s byte count times the nominal byte-time, and seam is the host’s inter-frame queuing delay. The seam is unknown, but it doesn’t change from frame to frame, and the whole scheme depends on that. You can’t measure the seam, but it turns out you don’t need to.
Right after every trim decision, the mean pair error over the next 32 pairs is captured as a baseline. That mean is the seam, because the clock was just freshly measured, so the drift term is known to be zero. From then on, 128-pair windows read drift as their shift from the baseline. Everything constant (the seam, the break detector’s latch offset, any residual path asymmetry) appears in both the baseline and the window, and dies in the subtraction. Whatever changed still gets through, and thermal drift is exactly that kind of change.
A sanity band of ±8,000 ppm discards verdicts no thermal process could produce. Those mean the host’s queuing behavior changed, and the answer to that is a fresh train instead of a trim step.
The Trim Loop
Both estimators feed one small controller. Two design decisions in it are worth stealing verbatim, both straight from the shipped code.
Round-to-nearest is the deadband. The correction is steps = round(error / step_effect), clamped to ±4 per decision. There is no separate deadband parameter. Rounding to the nearest whole step is already a half-step-wide deadband, which is the physical optimum a stepped actuator can hold anyway. The first window after boot takes a multi-step jump, and steady-state windows round to zero. So one rule handles both phases.
The step effect is self-measured. Datasheets give a nominal ppm-per-step. My real chips measured 1.4k to 3.2k ppm per step against a 2.5k nominal, off by up to 2x in either direction. A fixed constant either limit-cycles on coarse-step chips or under-trims fine-step ones. But every applied correction is a free experiment! The next window’s error shift, divided by the steps just taken, is a direct measurement of this particular chip’s actual gain. The loop starts from the nominal, replaces it with the measured value after the first correction (accepting only readings inside an 800 to 4,000 ppm/step plausibility band), and from then on its deadband is scaled to this chip. Convergence takes two trains by construction. The first one identifies the gain, and the second finishes the landing. That is why the boot guidance says to send at least two.
One warning for implementers, and I pinned this one with a dedicated regression probe. Get the sign convention right at exactly one place. The controller’s contract is “positive output slows the oscillator”, and the chip adapter owns the mapping to the real register’s direction. The V006’s trim register runs higher-equals-faster, so its adapter negates. Get that mapping wrong and the loop’s negative feedback turns positive. Instead of oscillating politely, the loop rails the register at the clamp.
What This Replaced
The Dynamixel-era implementation needed a vendor calibration instruction, a crystal-clocked timer measuring servo responses from the host side, and three compensation knobs per chip: a trim register value, a per-chip-family latency constant, and a per-chip sub-step residual. All of that existed because reply timing was scheduled against a microsecond grid, and every leftover error landed on the wire.
When the protocol redesign made replies event-driven, the grid vanished, and the reason for almost all of that apparatus went with it. Only the servo-to-servo pairwise budget from the top of this article stayed genuinely necessary, and the three small pieces above cover it. The lesson for me was to check whether a design change deletes the requirement before engineering the precision. That one took me a while to learn.
Try It Out
Everything above is running code in the OpenServoCore repo . The deterministic simulation tests exercise all of it. Trains converge, reject, and time out, the tracker follows injected mid-run drift, and constant seam-plus-skew cancels exactly. And the hardware tests de-tune real chips and watch them converge, including a probe that deliberately lies to the trim loop to pin down the sign-convention failure mode.
If you’re building something similar, steal whatever pieces fit. Let me know how it goes!