Dynamixel 2.0 servo side: RX timing on the CH32V006
This post documents what I learned implementing Dynamixel Protocol 2.0 on the servo side. OpenServoCore has since moved to its own wire protocol , but everything below stands on its own if you are building a Dynamixel-compatible device.
There is plenty of material online about talking to Dynamixel servos. Host libraries, ROS drivers, GUI tools, you name it. But there is almost nothing about being one. If you are putting a microcontroller inside a servo, building a custom sensor that lives on a Dynamixel bus, or emulating a Dynamixel device (what the Dynamixel2Arduino library calls a Slave), you are mostly on your own.
Well, I spent several months being one. This article and the next one document what that took, so you don’t have to rediscover it yourself. This one covers how a servo knows when the host stopped talking. That sounds trivial, but it turns out to be the foundation everything else stands on, and this time I’m going to walk through the actual build. Ring setup, interrupt handlers, formulas, and the code for each. The next one covers Fast Sync/Bulk Read, the protocol’s group-read mode.
All examples run on the CH32V006, a $0.16 RISC-V microcontroller, because that’s what OpenServoCore runs on. The code is real, lifted from the frozen DXL-era tree and trimmed down to teaching size, and the recipe transfers to any small chip with a UART, DMA, and a free-running timer. Keep the Dynamixel Protocol 2.0 reference manual handy, I will use its vocabulary (packets, instructions, status replies) without re-explaining it. And if you are new here, the project overview has the full picture.
TL;DR
If you just want the recipe, here it is:
- Receive everything through DMA into a circular ring, so the CPU only handles events instead of individual bytes. Never read the UART data register directly from the CPU.
- At every event that drains the ring, read the free-running clock once, first thing, and stash the reading together with which kind of event fired.
- Two formulas turn that reading into the packet’s end time. A byte-batch event gives you
now. A line-idle event gives younow − 10 × bit_time. - Always err on the late side. The protocol tolerates lateness everywhere, but replying early causes bus collisions.
- Don’t build per-byte hardware timestamping into a servo. I built one, and it works, but it costs half the CPU for precision nothing downstream can use. Save it for your bus analyzer.
Still suggest you read through though. The recipe is short but the reasons behind each step took me weeks.
The Protocol’s Timing Rules
Dynamixel is a half-duplex bus. One data wire, shared by the host and every servo. Only one device may drive the wire at a time, and everyone else listens. The host sends a request packet, and the addressed servo replies with a status packet.
The timing rule sounds simple enough. After the host’s last byte finishes, the servo waits its Return Delay Time (RDT, a per-servo register, settable from 0 to 508 µs in 2 µs steps), then starts its reply.
I’ll be honest about RDT. I don’t know why it exists, and I’m not sure anyone outside Robotis does. The common explanation is that it gives a slow host time to stop driving the shared wire and switch to listening. Maybe that’s true, I have no way to verify it. What I can tell you is that you can set it to zero and real buses keep working. A reply can’t be instant anyway, and published measurements of stock Dynamixel chains show replies arriving on the order of 150 µs after the request no matter what the register says. Treat RDT as a floor. Never reply before it, and don’t expect a prize for landing exactly on it.
Replying Late vs Replying Early
Here is the part I wish someone had told me, because I had it backwards for a while. This protocol tolerates late replies just fine, but it punishes early ones.
Hosts wait for replies (with a timeout). In group reads, each servo waits for the one before it. It has to, because RDT is per-servo and nobody on the bus knows anyone else’s. A servo can’t compute when its turn comes. It has to watch the previous reply go by, at minimum through the length field which says where that reply ends, and then take its turn. In fact, in a chained reply only the first servo’s RDT even takes effect. Everyone behind it times off what it observes on the wire, since its own register never enters the math.
If you reply late, everyone politely waits. You’ve cost the bus some throughput, nothing more. But if you reply early, before the host has released the wire or before your predecessor’s last byte in a group read, now two transmitters are driving one wire at once. You get collisions, garbage data, and retries.
You still need microsecond-grade wire-end detection though, and the reason is jitter. A known delay is harmless, you can just subtract a constant. But if your guess of “the wire just went quiet” wanders from shot to shot, you have to aim late by your worst case to stay safe. And that padding shows up on every reply and every hand-off. On a twelve-servo robot polled hundreds of times a second, those pads eat a real chunk of your bus budget.
For most instructions, that’s the whole story. Aim late, pay in small gaps, nothing breaks. But in the Fast group replies covered in part 2 , every servo’s block merges into one continuous packet, and the safety window shrinks to a single byte-time. That’s 3.33 µs at 3 Mbaud. If your jitter is bigger than that, you get a bus collision.
Before the build, let’s define two terms. The moment the last bit physically finished on the wire is the wire-end. The moment your code finds out about it is the publish time. The distinction matters because the UART is good at telling you a byte arrived, but not so good at telling you when it arrived. Whatever you learn late gets added straight to your turnaround time, and at low baud rates the numbers get ugly fast. One byte-time at 9600 baud is over a millisecond, and no correction math can give you back the time you spent not knowing.
Everything below is about getting a wire-end estimate with small error and even smaller scatter, for as close to zero CPU as possible.
The Circular DMA Ring
All reception goes through DMA into a circular ring buffer. The CPU never touches bytes as they arrive, it only handles events. Here is the whole setup on the V006 (runtime/init.rs
):
// USART1 RX -> a 256-byte ring via DMA1 channel 5
let cfg = dma::Config {
dir: Dir::FROMPERIPHERAL,
circ: true, // wraps forever, no re-arming
minc: true, // memory address walks the buffer
pinc: false, // peripheral address stays on the data register
size: Size::BITS8,
htie: true, // interrupt at half full...
tcie: true, // ...and at the wrap
pl: Pl::LOW,
};
dma::configure(Channel::CH5, &cfg, usart::data_addr(USART1), rx_buf_addr, 256);
dma::enable(Channel::CH5);
usart::set_dma_rx(USART1, true);
usart::set_idle_irq(USART1, true); // the third event source
Pick a power-of-two ring size so the wrap math stays cheap. 256 bytes comfortably holds the biggest request plus slack on this bus.
The DMA controller’s remaining-transfer counter (NDTR on this chip family, most vendors have an equivalent) tells you at any moment how many bytes have landed. That counter is the only thing the CPU ever reads about reception. The half-transfer and transfer-complete interrupts exist so the reader can never fall more than half a ring behind the hardware, and their handler does nothing but clear the flags and publish the new count.
One Free-Running Clock
You need a clock that never stops and never resets. On the V006 I used SysTick, which is a native 32-bit counter, clocked at the full 48 MHz core clock:
pub fn init() {
SYSTICK.cmp().write_value(u32::MAX); // park the compare, it never fires
SYSTICK.cnt().write_value(0);
SYSTICK.ctlr().write(|w| {
w.set_ste(true);
w.set_stclk(Stclk::HCLK); // 48 MHz, so 48 ticks per µs
});
}
pub fn now() -> u32 {
SYSTICK.cnt().read() // that's it. one register read
}
At 48 MHz a tick is about 21 ns and the 32-bit counter wraps every 89 seconds, so all the arithmetic below uses wrapping subtraction. After boot, nothing is ever allowed to reset this counter. That rule sounds bureaucratic now, but part 2 depends on it. A 16-bit timer stays phase-aligned with this clock there, so a deadline can be handed to hardware by truncation.
Useful numbers to keep in your head at 3 Mbaud on a 48 MHz clock. One bit is 16 ticks, and one UART character (start bit + 8 data bits + stop bit, 10 bits total) is 160 ticks.
The Drain Handlers
Three interrupt sources drain the ring. DMA half-transfer, DMA transfer-complete, and the UART’s IDLE interrupt, which fires after the line has been quiet for one character-time. The first two are the same case (a batch of bytes landed), so really there are two kinds of drain event.
There is one rule that makes the whole design work. The handler must read the clock first, once, and stash the reading together with which kind of event fired. Everything else the handler does can be slow, the stamp is already taken.
// DMA half-transfer / transfer-complete: a byte batch landed
pub fn on_rx_advance(&mut self) {
let _ = self.rx_dma.read_and_ack(); // clear HT/TC flags
let now = wire_clock::now(); // FIRST: the one clock reading
self.codec.publish(self.rx_dma.remaining()); // NDTR -> ring write position
self.codec.stash(now, PollSrc::ByteBatch);
self.codec.poll(); // parse whatever is in the ring
}
// USART IDLE: the wire has been quiet for one character-time
pub fn on_rx_idle(&mut self) {
let now = wire_clock::now();
self.codec.publish(self.rx_dma.remaining());
self.codec.stash(now, PollSrc::LineIdle);
self.codec.poll();
}
The stash is just a two-field tuple, (u32, PollSrc), overwritten on every drain. When the parser later completes a frame and the CRC checks out, it reads the most recent stash and converts it into the packet’s end time.
The Two Formulas
This is the heart of the article, and it fits in one match (packet_end.rs
):
fn packet_end(now: u32, src: PollSrc, ticks_per_bit: u32) -> u32 {
match src {
// The batch's last byte hit memory immediately before this handler
// ran, so the clock reading IS the wire-end, give or take entry.
PollSrc::ByteBatch => now,
// IDLE asserts one full character-time after the last stop bit.
// The wire-end actually happened one frame earlier. Back-date it.
PollSrc::LineIdle => now.wrapping_sub(10 * ticks_per_bit),
}
}
Why these two formulas are enough took me a while to accept, so let’s spell it out.
When the frame’s last byte arrives as part of a DMA batch, the batch interrupt fires as that byte lands in memory. The stamp taken at handler entry is late only by the interrupt entry itself, which measured well under a microsecond on this chip.
When the frame’s last byte does not fill the batch, no DMA interrupt fires for it. The line goes quiet, and one character-time later the UART raises IDLE. So an IDLE-drained frame’s stamp is late by exactly one frame, ten bit-times, no more and no less. Subtract it back out and the estimate is exact again. You found out late, but the corrected value is still exact.
I had also reserved a third term in this formula, a calibration constant for interrupt-entry latency, fully expecting to bench-tune it. After measuring on the bench, it shipped at zero. The byte-batch flavor already has near-zero latency by construction (the measured floor came out at +0.17 µs past the deadline at 3 Mbaud), so any nonzero compensation would have pushed byte-batch replies early. And as covered earlier, early is the dangerous direction. So make sure you measure before adding compensation.
Finding Frames In The Ring
The formulas date the end of a frame, but something still has to find the frames. A Dynamixel frame starts with the header pattern FF FF FD 00, and since payload bytes can legally contain that same pattern, the protocol adds byte stuffing on the transmit side so it can’t appear inside a payload. Your receiver gets to deal with both facts.
The shape that survived all my rewrites is a whole-frame classifier over the ring:
at every poll, look at the ring's read position:
copy up to 8 bytes (one request header) out of the ring into a
small stack buffer, straddling the wrap if needed, and probe them:
NeedMore -> not enough bytes yet, wait for the next drain
Junk { skip } -> these bytes cannot start a frame,
advance the reader by `skip`, probe again
Frame { total, id, instruction }
-> not my ID? skip `total` bytes as they arrive, cheaply
-> mine? copy the frame out as its slices land,
folding the CRC over each slice as you copy,
and deliver the verdict when byte `total` arrives
There are two details in there worth explaining. Probing a stack copy of the header means the wrap seam never complicates the parsing, the ring hands you a front slice and a back slice and the copy flattens them. And fusing the CRC fold into the copy loop means the verdict is ready the moment the last byte is, instead of costing a second pass over the frame.
Frames addressed to other servos get skipped without copying. You only pay full attention to your own traffic, which matters on a busy twelve-servo bus.
The Reply Deadline
With a packet-end estimate in hand, the reply deadline is one line:
reply_deadline = packet_end + RDT_ticks
Plus one guard that exists because of the IDLE formula. An IDLE-observed packet end is back-dated, which means the computed deadline can land in the past when RDT is very small. The chip physically could not have known about the wire-end any sooner than one character after it happened, so for IDLE-drained frames the effective RDT gets floored at one byte-time. That makes those replies late by up to one frame, on the harmless side, and it only happens on the drain flavor where nothing tighter was possible anyway.
What fires at the deadline, and how to get the CPU out of that path entirely, is a transmit side topic. That is most of part 2 .
The V006 Gotchas
Three quirks on this chip cost me real bench time. Check your own silicon for equivalents before trusting any of the above.
- The receive flag always reads as cleared. With RX DMA enabled, the DMA controller reads the UART data register before your handler can, and that read clears the RXNE status flag. So a handler can never gate on the flag. When I later needed a temporary per-byte wake (part 2 uses one to anchor group-read slots, and the clock-drift sampler shares it), the code gates on the interrupt-enable bit in the control register instead, which the firmware itself owns, and treats every non-IDLE entry inside that window as a byte wake.
- Never read the data register from the CPU while traffic can be mid-flight. A CPU read can steal the byte out from under the DMA, silently. The one place the firmware performs the status-then-data clear sequence (which this UART requires for clearing error and IDLE flags) is fenced to packet boundaries, where DMA has provably already drained the register.
- Keep the ring’s DMA interrupt handler publish-only. Clear the flags, publish the byte count, stash the stamp, and get out. Every cycle spent in that handler is jitter added to some other handler’s clock stamp, because all the bus-facing interrupts share one priority so their state mutations serialize.
What Got Deleted Along The Way
The shipped design is six short steps, but it didn’t start out that simple. Earlier revisions treated IDLE as a timing source and worked around its weaknesses. There was a per-byte interrupt mode for low baud rates, where IDLE’s one-frame latency looked fatal next to a small RDT. There was a decision rule that picked between modes based on baud and RDT. There was a tuning margin knob, and the entry-latency calibration constant I already mentioned. All of it got deleted, for one reason that took embarrassingly long to sink in.
Replying late is always legal. A reply that starts a few microseconds after wire-end + RDT, even a whole frame-time after, only costs a bit of throughput and breaks nothing. The low-baud “problem” the per-byte mode solved was never a problem in the first place. At 9600 baud, one frame of extra latency on a reply nobody can collide with is noise. I had built the whole mode split to chase a deadline the protocol never actually imposes.
The Numbers
At 3 Mbaud on the V006, wire excess (how much later than wire-end + RDT the reply actually starts) has a floor of +0.17 µs and a median around +5 µs. The gap between floor and median comes from other interrupts. A drain handler that enters behind another same-priority handler stamps late. Fine by me, that lands on the harmless side, and it is tiny compared to RDT anyway.
The estimate is good enough that the transmit side fires from it with hardware precision, and successor slots in Fast chains land with 0.125 µs of scatter using nothing but a cousin of this estimate (a clock reading back-projected through the ring’s byte count). More on both in part 2.
The Hardware Path I Built And Retired
Everything above works around the UART’s limitations. There is also a way to go underneath them, and I built it. Let a timer watch the wire directly. I’m documenting it in enough detail to replicate, and then I’ll tell you why the shipped servo design doesn’t use it.
The UART can’t hand a timer a “byte arrived” event. But the wire itself is just a voltage, and every UART byte begins with a falling edge (the start bit, idle-high dropping to zero). A general-purpose timer in input capture mode is perfect for this. When a pin changes, it writes down the timer’s counter value, in hardware, with zero software involved. That is a wire-truth timestamp with no publish latency at all.
Getting the signal to the timer is the first trick. On this chip family, one pin can legally feed two peripherals at once, so the same pad routes internally to both the UART’s receive input and the timer’s capture input. No extra pin, no board change. It worked exactly as hoped on the bench. Every falling edge on the receive line streams its timestamp into a memory ring via DMA, and the CPU wakes only when the ring is half full.
Converting Edges To Bytes
The timer gives you edge times, but the protocol deals in bytes. The number of edges per byte depends on the data. One at minimum (just the start bit), up to five for patterns like 0x55. Something has to convert edge timestamps into byte timestamps.
The design that worked is a small classifier built on one observation. If you know when byte i started, then byte i+1 must start exactly ten bit-times later (start bit + 8 data bits + stop bit). So walk the captured edges with a window test:
for each captured edge time t:
if t is 9 to 11 bit-times after the current anchor:
t is the next byte's start bit -> record it, move the anchor to t
else if t is less than 9 bit-times after the anchor:
t is a data-bit edge inside the current byte -> ignore it
else:
the line paused (packet gap) -> treat t as a fresh start bit
There are two properties that make this little loop trustworthy.
It re-anchors on every match. The window is always measured from the last observed start bit, not from arithmetic, so clock drift between your chip and the host never accumulates across a packet. Only the per-byte tolerance matters, and the window tolerates about ±10% of drift. That is roughly ten times worse than a real chip’s oscillator ever gets.
It also heals itself. A noise glitch inside a byte falls outside the window and gets ignored. A glitch in a gap steals the anchor for at most one byte before a real start bit re-anchors it. And because the classifier works purely from edge times, never byte values, corrupt data can’t poison the timing (and vice versa).
I also evaluated the obvious faster alternative. Edge count per byte is a function of the byte’s value, so a 256-entry lookup table can walk the edges about five times cheaper. I rejected it for one reason. It uses byte data to predict edge structure, so a single phantom edge desynchronizes it, it can only recover at a packet gap, and about 2.5% of glitch scenarios slip through its sanity check silently. Since this subsystem’s entire job is to be right, I decided to pay five times the cycles for the version that recovers on its own.
Why It Got Deleted
With the classifier running, every byte on the wire had a hardware-grade timestamp at every baud rate. Publish latency was gone. It is a genuinely satisfying design. Now here is what it cost on a 48 MHz chip, measured at 3 Mbaud with sustained traffic. The classifier walk alone ate about a quarter of the CPU at peak. The full receive path, classifier plus the byte-stream parsing on top, consumed roughly half the chip. Plus a few hundred bytes of RAM for the rings, and about three thousand lines of code.
I shipped it, measured everything, and then deleted it. Here’s the realization that killed it. The bus only ever sees one thing from you, and that is when your transmission starts. On the receive side, timing precision is only an input for computing that start time. So I audited every consumer of the per-byte timestamps against its actual tolerance, and every single one was already satisfied by the one-clock-reading-per-drain design. The reply deadline has tens of microseconds of RDT around it. The Fast slot anchor resolves from a byte count. Drift estimation wants multi-byte spans, not per-byte ticks. The hardware timestamps gave me precision nothing downstream could actually use, and it cost half the CPU to get it.
What I took away from this is that receive-side jitter never reaches the wire, because it only feeds arithmetic that can tolerate it. Transmit-side jitter on the other hand lands directly on the wire as your start bit. So if you are going to spend hardware on timing, spend it on the transmit path, and let software clock readings carry the receive side.
Edge capture didn’t die entirely though. It moved to where per-byte wire truth actually belongs, my bench bus analyzer. A measuring tool is supposed to be more precise than the thing it measures, so that instrument keeps exactly this design, and its code is alive in the same frozen tree under tools/uart-pirate
. It even chains two timers so each falling edge latches a hardware-atomic 32-bit timestamp, which is a nice trick on a chip with only 16-bit timers. So if you are building diagnostics, feel free to take this design. Just don’t put it in a servo.
Caveats
Everything here, including the full edge-capture design and its measurements, is preserved in the frozen DXL-era tree of the OpenServoCore repo. One honest disclaimer about that tree. The design notes in it are AI-drafted working notes that captured my thinking mid-flight, frozen unreviewed and never cleaned up for readers. The code is the real record, so skip the write-ups if you’re allergic to AI.
Also, the code excerpts above are trimmed for teaching. The real versions carry generics for the provider traits, wrap-safe arithmetic everywhere, and telemetry counters I’ve omitted. If they disagree, trust the tree.
What’s Next
Knowing when the request ended is the foundation, but the reply side is where the real difficulty lives. You will need a transmit start with zero CPU in the deadline path, and you will have to take your turn inside a shared reply where five other servos are counting on you. That story is in part 2: implementing Fast Sync/Bulk Read .