<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aaron Qian</title>
    <description>The latest articles on DEV Community by Aaron Qian (@aaronqian).</description>
    <link>https://dev.to/aaronqian</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F110125%2F295476a5-bcc7-4e9a-8b47-33950bc265e3.jpeg</url>
      <title>DEV Community: Aaron Qian</title>
      <link>https://dev.to/aaronqian</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aaronqian"/>
    <language>en</language>
    <item>
      <title>CH32V006 Servo Controller Firmware - DXL 2.0 RX Timing</title>
      <dc:creator>Aaron Qian</dc:creator>
      <pubDate>Wed, 10 Jun 2026 00:07:35 +0000</pubDate>
      <link>https://dev.to/aaronqian/ch32v006-servo-controller-firmware-dxl-20-rx-timing-41bh</link>
      <guid>https://dev.to/aaronqian/ch32v006-servo-controller-firmware-dxl-20-rx-timing-41bh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://aaronqian.com/log/2026-06-10-dxl-rx-timing-ch32v006/" rel="noopener noreferrer"&gt;aaronqian.com&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a design note for anyone bringing up a Dynamixel 2.0 servo on the CH32V006. You don't need prior DXL or RISC-V expertise; read it top to bottom. Protocol reference throughout is the &lt;a href="https://docs.robotis.com/docs/dxl/protocol/protocol2" rel="noopener noreferrer"&gt;DYNAMIXEL Protocol 2.0 spec&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're new here, &lt;a href="https://github.com/OpenServoCore" rel="noopener noreferrer"&gt;OpenServoCore&lt;/a&gt; is my effort to turn cheap SG90 / MG90 servos into networked smart actuators with sensor feedback, cascade control, and a DYNAMIXEL-style TTL bus. This is the first post in a series on how the CH32V006 firmware meets DXL 2.0's timing budget on a $0.16 chip (qty. 1000+). Later posts will be linked here as they go live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CH32V006 Limitations:&lt;/strong&gt; DXL 2.0 does not require any idle period between the host's last byte and the servo's reply. The clean way to detect the host's wire-end is to use a chip class with proper UART hardware support, for example, STM32G0 / G4 class chips. They expose USART primitives that deliver the wire-end with zero publish latency, but they are also pricy. The V006 doesn't have anything equivalent, so we have to somehow have to do this in software-only regime built on the two USART flags it &lt;em&gt;does&lt;/em&gt; give us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Given software-only limitations, how does a DXL servo on the CH32V006 know &lt;strong&gt;precisely when the host's request ended on the wire&lt;/strong&gt;, across every baud rate from 9600 up to 3 Mbaud (the V006's USART ceiling)? Call that moment the &lt;strong&gt;wire-end&lt;/strong&gt;. It's the reference point the servo schedules its reply against, since the protocol's Return Delay Time (configurable from 2 µs) is measured from it, miss it and the reply lands in another servo's slot and causes bus contention.&lt;/p&gt;

&lt;p&gt;The CH32V006's USART offers two usable signals: a per-byte interrupt (RXNE) that fires exactly at each byte's stop bit, and a per-packet interrupt (IDLE) that fires once after the line has been quiet for 9 bit-times. However, RXNE is precise but expensive (100k IRQs/second at 1 Mbaud). IDLE is cheap but its publish latency, ~1 character time, exceeds short RDTs at low baud. The answer is to pick between them based on (baud, RDT). This post details what each signal looks like, and how to construct a strategy to determine which strategy to select dynamically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;p&gt;A few protocol terms recur throughout. The V006-specific acronyms (BRR, NDTR, PFIC, HCLK, etc.) are introduced inline where they first show up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DXL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DYNAMIXEL, Robotis's smart-servo bus and protocol. We speak DXL 2.0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RDT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Return Delay Time. Servo-side reply offset from the host's request-end, configurable 2–508 µs in 2 µs steps.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fast Sync / Bulk Read&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DXL 2.0 read variant where the host addresses many servos at once and all replies stitch into one coalesced Status frame.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coalesce&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The zero-idle-gap stitching above. What the last servo's fire deadline has to preserve.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wire-end&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The moment a byte's stop bit finishes clocking out. The actual "wire just went idle" event.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IDLE / RXNE / TC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;USART status flags. Line went idle / a byte arrived / TX shift register fully drained.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. What's on the wire
&lt;/h2&gt;

&lt;p&gt;DXL is a half-duplex serial bus, driven by a single host. Servos listens to host instruction packets and produce status packets. Since they all share a single physical DATA wrie, all nodes (host &lt;em&gt;and&lt;/em&gt; servos) have to ensure they don't step on each other. Otherwise it will cause bus contention and garbled bytes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9fzpfzpd0z24z50bjv33.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9fzpfzpd0z24z50bjv33.webp" alt="Host writes a request on the shared wire, then servo 1 and servo 2 reply in sequence, with RDT marking the gap between host-end and servo-1-start." width="797" height="101"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question the servo has to answer: &lt;strong&gt;after the host's request finishes, how long should I wait before replying?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer is a per-servo setting in the control table called &lt;strong&gt;Return Delay Time (RDT)&lt;/strong&gt;. Range: 2 to 508 microseconds, in 2 µs steps. The host writes it once during setup; the servo honors it on every reply.&lt;/p&gt;

&lt;p&gt;Why precision matters: in Sync Read and Bulk Read, the host addresses many servos at once and they reply back-to-back in pre-assigned time slots. A servo that's 200 µs late steps on the next servo's reply and corrupts the bus. RDT is a hard deadline.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;Fast&lt;/strong&gt; Sync/Bulk variants the slots stitch into a single coalesced Status frame with &lt;strong&gt;zero idle gap&lt;/strong&gt;, tightening the budget further. That case has its own architecture and its own write-up; this post stays with the foundation it sits on top of.&lt;/p&gt;

&lt;p&gt;The foundation is two things the servo has to do, accurately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detect the wire-end&lt;/strong&gt;, the moment the request's last byte finishes (specifically, when its stop bit clocks out).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule its reply&lt;/strong&gt; to start exactly RDT µs (plus any slot offset) after that moment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(2) is mechanical once you have a precise wire-end timestamp: arm a hardware timer. The hard part is (1), getting that timestamp precisely across the full DXL baud range, and that's what the rest of this post is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The CH32V006: what we have to work with
&lt;/h2&gt;

&lt;p&gt;For a dirt-cheap MCU, what is offers is not ideal for hardware based UART timing, but enough to make it work with some effort. Let's layout the relavent parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core.&lt;/strong&gt; QingKe V2A @ 48 MHz HCLK, single-cycle SRAM. Flash above 24 MHz takes 2 wait-states per fetch, which adds up inside a hot ISR. &lt;code&gt;qingke-rt&lt;/code&gt;'s &lt;code&gt;highcode&lt;/code&gt; feature adds a &lt;code&gt;.highcode&lt;/code&gt; section that boot-copies tagged functions into SRAM; we use it for the USART and SysTick handlers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;USART1.&lt;/strong&gt; This is the DXL bus, and we will be using the following IRQs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IDLE&lt;/strong&gt; fires when the line has been idle for &lt;strong&gt;9 bit-times&lt;/strong&gt; of mark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RXNE&lt;/strong&gt; fires when a byte's stop bit clocks in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TC&lt;/strong&gt; fires when the last byte of a TX has fully shifted out of the register and onto wire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three multiplex onto the USART1 vector, so we need to demux in the ISR code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DMA1 CH5&lt;/strong&gt; is wired to USART1_RX as a circular buffer. RX bytes stream into the buffer in the background, so the USART vector only has to handle IDLE / RXNE / TC, not byte copying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SysTick.&lt;/strong&gt; is a 32-bit free counter at 48 Mhz. We use this to schedule TX. The scheduler has to detect if the schedule is in the past, and if so, decided if it should send TX immediately, or it should just drop the response. Compare-match (CMP) mode raises an IRQ when &lt;code&gt;CNT == CMP&lt;/code&gt;, so writing a CMP value that's already in the past schedules the IRQ for the next 32-bit wrap, &lt;strong&gt;89 seconds away&lt;/strong&gt; at 48 MHz. So that's something to be taken account into as well.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The two USART flags
&lt;/h2&gt;

&lt;p&gt;The USART hardware hands us two different "the request ended" signals. Each has its own personality.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 RXNE: "a byte just arrived"
&lt;/h3&gt;

&lt;p&gt;Fires once for each byte received. The interrupt happens &lt;strong&gt;as the byte's stop bit clocks in&lt;/strong&gt;, so the timestamp is exactly the wire-end of that byte. Very precise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpca2awt9v1su2rmodvfp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpca2awt9v1su2rmodvfp.webp" alt="Three DATA bytes back-to-back, with the RXNE line pulsing once at the stop bit of each." width="800" height="115"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The catch is volume: at high baud you get a lot of these. At 3 Mbaud a stream of back-to-back bytes is 300,000 IRQs per second.&lt;/p&gt;

&lt;p&gt;(V006 quirk: with RX DMA on, the data register is read before the IRQ handler entries, so &lt;code&gt;STATR.RXNE&lt;/code&gt; always reads 0 inside the ISR; DMA wins the clear race. PFIC's pending bit latches per byte independently, so the IRQ still fires 1:1 per byte. Treat IRQ entry itself as "a byte arrived" and read the RX DMA cursor (NDTR, the channel's remaining-count register) for position.)&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 IDLE: "the wire went quiet"
&lt;/h3&gt;

&lt;p&gt;Fires once after the line has been idle for 9 bit-times. One interrupt per packet, regardless of packet length. Cheap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhub8kccasexwjpebq051.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhub8kccasexwjpebq051.webp" alt="Three DATA bytes, then the IDLE line asserts roughly one character-time after the last stop bit." width="800" height="106"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The catch is latency: the interrupt fires roughly one character time &lt;strong&gt;after&lt;/strong&gt; the wire actually went quiet. So if you use the IRQ-entry timestamp as the wire-end, you're systematically a character-time late.&lt;/p&gt;

&lt;p&gt;We fix the &lt;em&gt;value&lt;/em&gt; by &lt;strong&gt;backdating&lt;/strong&gt;: subtract &lt;code&gt;9 × BRR&lt;/code&gt; HCLK ticks from the IRQ-entry timestamp. That's straightforward. But there's a deeper problem: even with a correct wire-end value, the servo doesn't &lt;em&gt;find out&lt;/em&gt; about wire-end until ~1 char-time after the wire went quiet. For low bauds, this is too late to send the response back. We already missed the schedule.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Why one char-time of delay breaks low baud
&lt;/h2&gt;

&lt;p&gt;Here's how the reply scheduler is structured:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wait until the wire-end timestamp shows up from the end-wire timing layer.&lt;/li&gt;
&lt;li&gt;Arm SysTick CMP at &lt;code&gt;wire_end + RDT&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scheduler needs the wire-end timestamp to arrive &lt;strong&gt;before&lt;/strong&gt; the deadline. With IDLE-based timing, the timestamp shows up roughly one character time after the wire-end happened. That's the &lt;strong&gt;publish latency&lt;/strong&gt;, and it's baked into the IDLE flag; backdating fixes the value but not when you learn it.&lt;/p&gt;

&lt;p&gt;What happens if publish latency exceeds RDT? The deadline is already in the past by the time the scheduler tries to use it. SysTick CMP can't be scheduled in the past, so the only thing it can do is fire immediately, which technically violates the protocol.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concrete example: 9600 baud, RDT = 250 µs&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;quantity&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;char_time = 9 bits / 9600 bps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;937.5 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;publish latency (IDLE)&lt;/td&gt;
&lt;td&gt;937.5 µs (one char time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDT (the deadline)&lt;/td&gt;
&lt;td&gt;250.0 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;late by&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;687.5 µs&lt;/strong&gt;, every reply&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The servo can never make this deadline in IDLE mode. &lt;strong&gt;At high baud the same math is fine.&lt;/strong&gt; At 1 Mbaud the character time is 9 µs; with RDT = 250 µs the servo has ~241 µs of slack. IDLE is plenty fast.&lt;/p&gt;

&lt;p&gt;So the answer is baud-dependent: low baud needs the per-byte (RXNE) approach, high baud is happy with the per-packet (IDLE) approach.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The decision rule
&lt;/h2&gt;

&lt;p&gt;Use IDLE whenever it can meet the deadline; use RXNE when it can't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;char_time_us       = 9_000_000 / baud_hz       # publish latency of IDLE
rdt_us             = return_delay_2us × 2      # the deadline budget
pipeline_margin_us                             # headroom for ISR/dispatch work

use_rxne_framing = char_time_us + pipeline_margin_us &amp;gt; rdt_us
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;pipeline_margin_us&lt;/code&gt; term accounts for the work between "IDLE ISR publishes timestamp" and "scheduler reads it." Measure on hardware and add a few µs of headroom. ~20 µs is a reasonable starting point on V006.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 What does the rule pick?
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;pipeline_margin_us = 0&lt;/code&gt; to keep the numbers clean (a real margin nudges the boundary slightly toward more RXNE):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;baud&lt;/th&gt;
&lt;th&gt;char_time&lt;/th&gt;
&lt;th&gt;RDT = 2 µs&lt;/th&gt;
&lt;th&gt;RDT = 250 µs&lt;/th&gt;
&lt;th&gt;RDT = 508 µs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9600&lt;/td&gt;
&lt;td&gt;937.5 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;57600&lt;/td&gt;
&lt;td&gt;156.3 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;115200&lt;/td&gt;
&lt;td&gt;78.1 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 M&lt;/td&gt;
&lt;td&gt;9.0 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 M&lt;/td&gt;
&lt;td&gt;3.0 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Quick read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The minimum RDT (2 µs) always forces RXNE: no budget for publish latency.&lt;/li&gt;
&lt;li&gt;At a typical RDT of 250 µs, IDLE works from 57600 baud upward.&lt;/li&gt;
&lt;li&gt;Slow buses (below ~17.7 kbaud) need RXNE even at the max RDT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two strategies sit at opposite ends of the trade-off naturally: at low baud, bytes are slow and per-byte interrupts are cheap in total; at high baud, packets are short and per-packet interrupts are all you need.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. End-to-end timelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  6.1 IDLE mode (typical high-baud operation)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7q80yndfje8y97kquea.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7q80yndfje8y97kquea.webp" alt="Three close-packed request bytes, a ~1 char-time quiet stretch before IDLE fires, the RDT gap, then TX_EN asserts and two reply bytes go out." width="797" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three bytes arrive close-packed. RXNE pulses per byte but the end-wire timing layer ignores them. After ~9 bit-times of quiet the IDLE IRQ fires; the handler backdates &lt;code&gt;wire_end = now − 9·BRR&lt;/code&gt; and pushes &lt;code&gt;(bytes_added, wire_end)&lt;/code&gt; onto the IDLE-stamp queue. Main loop parses; dispatcher calls &lt;code&gt;request_complete(parsed_end)&lt;/code&gt;; scheduler arms SysTick CMP at &lt;code&gt;wire_end + RDT&lt;/code&gt;. SysTick fires → &lt;code&gt;fire_now()&lt;/code&gt; flips &lt;code&gt;TX_EN&lt;/code&gt;, enables DMA CH4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One interrupt per request.&lt;/strong&gt; Cheap and clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 RXNE mode (low-baud or minimum-RDT operation)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75922puv0e0rt7w7jaua.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75922puv0e0rt7w7jaua.webp" alt="Three spread-out request bytes with a RXNE pulse at each stop bit, RDT measured from the last RXNE, then TX_EN and the reply." width="797" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At low baud the bytes are spread out, and each RXNE fires far enough apart that per-byte work is cheap. The handler overwrites a single-cell snapshot &lt;code&gt;(rx_cursor, now)&lt;/code&gt; on every byte. Main loop parses; dispatcher reads the snapshot for &lt;code&gt;parsed_end&lt;/code&gt;'s position to get the wire-end tick; SysTick CMP arms at &lt;code&gt;wire_end + RDT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One interrupt per byte.&lt;/strong&gt; At 9600 baud a 14-byte request is 14 interrupts spread across 14.5 ms, barely a blip. At 1 Mbaud it would be 14 interrupts in 140 µs, far too much, which is why the rule never picks RXNE at high baud.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.3 Fast last-servo
&lt;/h3&gt;

&lt;p&gt;A third path, the Fast Sync/Bulk last-servo reply, has to snoop predecessor slots and CRC bytes it didn't generate. It's its own architecture and out of scope here.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Interrupt responsibilities
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;IRQ&lt;/th&gt;
&lt;th&gt;What it handles&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;USART1&lt;/td&gt;
&lt;td&gt;IDLE + RXNE + TC (all multiplexed)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.highcode&lt;/code&gt; (SRAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;SysTick CMP&lt;/td&gt;
&lt;td&gt;Reply deadline → fire TX&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.highcode&lt;/code&gt; (SRAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;DMA1_CH1&lt;/td&gt;
&lt;td&gt;ADC kernel pump (20 kHz)&lt;/td&gt;
&lt;td&gt;flash (cold path)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The TC branch of the USART1 vector is also where deferred state changes apply: a host-issued baud change can't retune the USART mid-reply, so it queues up and the TC handler re-tunes BRR and re-runs the IDLE-vs-RXNE framing decision once the reply has drained.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. The consumer side
&lt;/h2&gt;

&lt;p&gt;When the dispatcher finishes parsing a request, it asks the end-wire timing layer "when did this request's last byte hit the wire?" via &lt;code&gt;request_complete(parsed_end)&lt;/code&gt;. The lookup branches on the current end-wire timing mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In IDLE mode&lt;/strong&gt; the end-wire timing layer scans the queue for the entry whose cumulative byte count matches &lt;code&gt;parsed_end&lt;/code&gt;, returns that tick, and pops it. If no entry matches, return None: either the IDLE flag hasn't fired yet, or the queue overflowed and the matching entry was dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In RXNE mode&lt;/strong&gt; the end-wire timing layer reads the single-slot snapshot of the most recent byte's &lt;code&gt;(position, tick)&lt;/code&gt;. The consumer checks whether the position matches &lt;code&gt;parsed_end&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If yes, return the tick.&lt;/li&gt;
&lt;li&gt;If no (more bytes arrived between parse and lookup), return None.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Returning None is graceful degradation. Slot-timed callers (Sync, Bulk, Fast Read) &lt;strong&gt;must skip&lt;/strong&gt; when the answer is unknown; they can't safely fire without a precise timestamp. Direct unicasts &lt;strong&gt;may&lt;/strong&gt; proceed with an immediate fire, accepting the timing slip.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.1 Why a queue for IDLE but a single cell for RXNE
&lt;/h3&gt;

&lt;p&gt;Different timing regimes, different simplest solution.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IDLE mode&lt;/strong&gt; runs at high baud. Packets can arrive faster than the main loop polls: three Sync Reads in quick succession will pile three IDLE timestamps before the dispatcher gets to any of them. A small FIFO absorbs the burst. Depth 4 is plenty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RXNE mode&lt;/strong&gt; runs at low baud. Inter-byte time is at least ~17 µs (at 57600) and grows to ~100 µs at 9600. The dispatcher's parse-and-lookup is sub-microsecond. By the time the next byte's IRQ fires, the previous packet's lookup has long since completed. No concurrent stacking, so a single cell suffices.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  9. Where the V006 sits on the DXL 2.0 spec
&lt;/h2&gt;

&lt;p&gt;Plain unicast and non-coalesced Sync/Bulk replies pass the spec across the full DXL 2.0 baud range, using the timing mode the decision rule picks at each (baud, RDT) pair.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;baud&lt;/th&gt;
&lt;th&gt;char_time&lt;/th&gt;
&lt;th&gt;timing mode&lt;/th&gt;
&lt;th&gt;plain DXL reply (RDT 250 µs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9600&lt;/td&gt;
&lt;td&gt;937.5 µs&lt;/td&gt;
&lt;td&gt;RXNE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;57600&lt;/td&gt;
&lt;td&gt;156.3 µs&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;115200&lt;/td&gt;
&lt;td&gt;78.1 µs&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 M&lt;/td&gt;
&lt;td&gt;9.0 µs&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 M&lt;/td&gt;
&lt;td&gt;4.5 µs&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 M&lt;/td&gt;
&lt;td&gt;3.0 µs&lt;/td&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The harder case, Fast Sync/Bulk Read as the &lt;strong&gt;last&lt;/strong&gt; servo (zero-idle coalesce + CRC over the whole frame), runs into a separate ceiling driven by SysTick CMP → TX_EN fire latency rather than by RX detection. That case has its own write-up.&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>rust</category>
      <category>firmware</category>
      <category>robotics</category>
    </item>
    <item>
      <title>CH32V006 Servo Controller Dev Board (Rev. B) Fab Ready</title>
      <dc:creator>Aaron Qian</dc:creator>
      <pubDate>Tue, 09 Jun 2026 02:50:51 +0000</pubDate>
      <link>https://dev.to/aaronqian/osc-dev-v006-rev-b-bring-up-one-trace-cut-for-a-fresh-chip-bootstrap-32gd</link>
      <guid>https://dev.to/aaronqian/osc-dev-v006-rev-b-bring-up-one-trace-cut-for-a-fresh-chip-bootstrap-32gd</guid>
      <description>&lt;p&gt;When the first Rev B board came back from PCBWay, I hooked up the WCH-LinkE, fired up the debugger, and tried to connect. The debugger reported no target at all. The board was clearly powered (3.3 V rail LED on, no heat, no smoke), and the LinkE itself was alive, but whatever was on the other end of the SWD lines wasn't answering. Unlike Rev A where I failed my way to debug success, I didn't actually have to probe to know what was going on this time. It immediately hit me that the Rev B schematic puts &lt;code&gt;nRST&lt;/code&gt; and &lt;code&gt;OPN2&lt;/code&gt; on the same physical pin, and is tied to ground. This means the chip is held in reset forever, no way of changing &lt;code&gt;nRST&lt;/code&gt; to &lt;code&gt;GPIO&lt;/code&gt; via LinkE...&lt;/p&gt;

&lt;p&gt;If you're new here, &lt;a href="https://github.com/OpenServoCore" rel="noopener noreferrer"&gt;OpenServoCore&lt;/a&gt; is my effort to turn cheap MG90S-class servos into networked smart actuators with sensor feedback, cascade control, and a DYNAMIXEL-style TTL bus. The CH32V006 dev board is the firmware development platform for this project. Rev B is the second revision, &lt;a href="https://aaronqian.com/log/2026-04-29-osc-dev-v006-rev-b-announcement/" rel="noopener noreferrer"&gt;announced in April&lt;/a&gt; and fabricated and assembled by &lt;a href="https://www.pcbway.com/" rel="noopener noreferrer"&gt;PCBWay&lt;/a&gt; as a sponsored run.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom:&lt;/strong&gt; fresh Rev B board doesn't enumerate to the debugger. Chip held in reset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;nRST&lt;/code&gt; and &lt;code&gt;OPN2&lt;/code&gt; share a pin. Default function on a virgin chip is &lt;code&gt;nRST&lt;/code&gt;. &lt;code&gt;OPN2&lt;/code&gt; is wired to &lt;code&gt;ISNS-&lt;/code&gt;, the negative tap of the low-side shunt, which sits near GND. So the pin reads low and the MCU never leaves reset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workaround for the five sponsored boards:&lt;/strong&gt; scrape off the solder mask to reveal the &lt;code&gt;ISNS-&lt;/code&gt; copper trace, cut the trace, flash the USER option byte to remap &lt;code&gt;nRST → GPIO&lt;/code&gt;, patch the trace back with a 0Ω 0805 resistor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-rev fix:&lt;/strong&gt; added an &lt;code&gt;SB1&lt;/code&gt; solder bridge footprint on that segment. Close it after the option byte is flashed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status:&lt;/strong&gt; Rev B is validated. Published files include the solder bridge patch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Rev A Story Behind This
&lt;/h2&gt;

&lt;p&gt;Rev A had &lt;code&gt;ISNS-&lt;/code&gt; routed to &lt;code&gt;OPN0&lt;/code&gt;, which the V006's silicon refused to use as a differential negative input. The OPA fell back to single-ended with its negative tied internally to GND — effectively neutering the Kelvin sense traces and making bidirectional current sensing impossible. I &lt;a href="https://aaronqian.com/log/2026-04-29-osc-dev-v006-rev-b-announcement/" rel="noopener noreferrer"&gt;wrote up the rest of the Rev B delta back in April&lt;/a&gt; when the design was done but not yet built. The fix was to move &lt;code&gt;ISNS-&lt;/code&gt; to &lt;code&gt;OPN2&lt;/code&gt;, which is a valid OPA negative input but happens to share its package pin with &lt;code&gt;nRST&lt;/code&gt;. The trade was: lose hardware reset, gain proper differential current sense. The plan was to disable &lt;code&gt;nRST&lt;/code&gt; at provisioning by programming the USER option byte.&lt;/p&gt;

&lt;p&gt;That plan itself is fine, it just has a chicken-and-egg problem on a fresh chip that I totally didn't think about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Powering On
&lt;/h2&gt;

&lt;p&gt;The boards arrived in mid-May — five PCBA units, sponsored by PCBWay as a continuation of the Rev A run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3r6cx8dljavs614uygi4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3r6cx8dljavs614uygi4.webp" alt="Five Rev B boards arriving in ESD bags" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Five PCBA units, ESD-bagged, fresh from PCBWay.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Build and assembly quality was clean across all five, with no orientation surprises and no solder issues, and the pre-fab manufacturability review didn't catch anything this time around (Rev A had a pad-clearance call-out before fab; Rev B was quiet). When I plugged in USB-C, the 3.3 V rail LED came up immediately, and the silkscreen was correct everywhere I looked. From the outside everything looked right.&lt;/p&gt;

&lt;p&gt;Then I went to flash firmware, which is where I ran into the bootstrap problem I described above. To free up the shared pin for OPA use, the USER option byte has to be reprogrammed to set &lt;code&gt;nRST → GPIO&lt;/code&gt;, and the only way to do that is through the WCH-LinkE. But on a fresh chip whose &lt;code&gt;nRST&lt;/code&gt; pin is being held low through the &lt;code&gt;ISNS-&lt;/code&gt; path, the LinkE has nothing to talk to. The chip is held in reset by the very analog front end it's supposed to be sensing, and there's no way for me to reach it from the outside to fix the reason it's stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cut, Flash, Patch
&lt;/h2&gt;

&lt;p&gt;The way out is to break the path between the MCU pin and &lt;code&gt;ISNS-&lt;/code&gt; long enough to program the option byte. With &lt;code&gt;OPN2 / nRST&lt;/code&gt; floating, the MCU's own internal pull-up brings the line high, the chip releases reset, the WCH-LinkE can flash the USER option byte to &lt;code&gt;nRST → GPIO&lt;/code&gt;. After that, the pin is no longer a reset input, so reconnecting it to &lt;code&gt;ISNS-&lt;/code&gt; is harmless. The OPA can do its job.&lt;/p&gt;

&lt;p&gt;For the five sponsored boards, I did this by hand under a magnifier. The procedure I landed on, after a false start I'll mention in a moment, was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scrape the solder mask off the top of the &lt;code&gt;OPN2 ↔ ISNS-&lt;/code&gt; trace with a hobby knife to expose the bare copper underneath.&lt;/li&gt;
&lt;li&gt;Scrape harder across that exposed copper to take a tiny piece of the trace off and break continuity. The trick is keeping the cut narrow enough to avoid taking the surrounding ground pour with it.&lt;/li&gt;
&lt;li&gt;Verify isolation with the multimeter's continuity function.&lt;/li&gt;
&lt;li&gt;Plug in the WCH-LinkE and flash the USER option byte to &lt;code&gt;nRST → GPIO&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tin the two exposed copper stubs on either side of the cut, with generous flux.&lt;/li&gt;
&lt;li&gt;Tin the back pads of an 0805 0 Ω resistor.&lt;/li&gt;
&lt;li&gt;Position the resistor across the cut with tweezers so it sits on top of the tinned stubs.&lt;/li&gt;
&lt;li&gt;Hit it with hot air while pressing down lightly on the resistor. The scrape leaves the bare copper slightly concave, so a bit of downward pressure is needed for the solder to wick up under the resistor's pads.&lt;/li&gt;
&lt;li&gt;Let it cool, then measure continuity again to confirm the bridge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs6elm5bblsn2p2xd3h39.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs6elm5bblsn2p2xd3h39.webp" alt="Hot air aimed at the 0805 0 Ω resistor bridging the SB1 cut, mid-rework" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Step 8 in progress on one of the boards — hot air aimed at the 0805 0 Ω resistor sitting across the cut, with the OSC logo visible in the corner. Holding a small downward pressure on the resistor while the solder reflows is what gets the joint to actually take.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The false start was that I first tried bridging the cut with just a plain solder blob — the way the in-rev &lt;code&gt;SB1&lt;/code&gt; footprint is meant to close. It didn't take. I couldn't get a blob to anchor across the scraped gap reliably, and I think the reason is that the bare copper surface left by the knife is too uneven and too small to hold a clean blob. An 0805 sitting on top gives the solder a flat surface to pull against, which is why that version worked. The real &lt;code&gt;SB1&lt;/code&gt; footprint won't have this problem because the pads are properly-fabbed flat copper, not whatever shape I happen to expose with a knife. So a plain blob is the right close for &lt;code&gt;SB1&lt;/code&gt;; just not for a hand-scraped trace.&lt;/p&gt;

&lt;p&gt;Five boards, five surgeries, all came up cleanly afterward. One thing I'm watching for, though: on a couple of the boards I couldn't keep the scrape perfectly inside the trace, and I nicked the surrounding ground pour. The exposed area is small, but the analog signal on &lt;code&gt;ISNS-&lt;/code&gt; is small too — tens of millivolts at full-scale current — so a thinner local ground around the cut could in theory show up as a higher noise floor on the current sense from ground-bounce. I haven't characterized it yet, and it might not actually matter in practice. But it's another reason the properly-fabbed &lt;code&gt;SB1&lt;/code&gt; version is the cleaner long-term path: the bridge sits between intact ground-pour edges, with no exposed copper or thinned ground around the joint.&lt;/p&gt;

&lt;p&gt;It works, but "every board needs a trace cut on first power-on" is not a thing I want to put in a README. The right shape is a footprint that's already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The In-Rev Fix: SB1
&lt;/h2&gt;

&lt;p&gt;I added a 2-pad solder jumper, &lt;code&gt;SB1&lt;/code&gt;, on the &lt;code&gt;OPN2 ↔ ISNS-&lt;/code&gt; segment. Standard open-default solder-bridge footprint, so boards ship from fab with the bridge open by default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10ksfo0r3p974y8bwtbg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10ksfo0r3p974y8bwtbg.webp" alt="SB1 solder bridge footprint on the Rev B layout" width="800" height="665"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;SB1 sits between U2 and RS1, just left of the ISN- / ISN+ test points. Open from fab; close it with a small solder blob after flashing the option byte.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first-boot procedure on the board README is now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm &lt;code&gt;SB1&lt;/code&gt; is open.&lt;/li&gt;
&lt;li&gt;Program the USER option byte via the WCH-LinkE at &lt;code&gt;J4&lt;/code&gt; to set &lt;code&gt;nRST → GPIO&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Close &lt;code&gt;SB1&lt;/code&gt; with a small solder blob.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After this, the board runs as designed. One-time step per fresh chip, comparable to soldering on a header.&lt;/p&gt;

&lt;p&gt;This is small enough that I'm calling it an in-rev patch and not a Rev C. The schematic and layout were updated, the published Gerbers include the bridge, the existing Rev B tag still applies. The five sponsored boards I patched by hand are functionally equivalent to a freshly-fabbed board with &lt;code&gt;SB1&lt;/code&gt; closed. The two are the same Rev B as far as I'm concerned.&lt;/p&gt;

&lt;p&gt;The cost is one extra step on the bring-up procedure. The benefit is that I don't burn another sponsored fab run on a single-trace change. PCBWay was generous; spending the next slot on this would not have been a good use of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the Boards Themselves
&lt;/h2&gt;

&lt;p&gt;The five PCBA units PCBWay delivered are clean, with no assembly issues, no orientation flags, and no rework needed on anything I could see under the magnifier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vlxyf1hit5fkmvynta5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vlxyf1hit5fkmvynta5.webp" alt="Front side view of the OSC Dev V006 Rev B board" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb07szett1ahmty7d1ofi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb07szett1ahmty7d1ofi.webp" alt="Back side view of the OSC Dev V006 Rev B board" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Front (top) — components, silkscreen, logo, and QR code all clean. Back (bottom) — board name, OSC iconography, and the second QR code on the silkscreen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What stands out across both Rev A and Rev B is how consistent the finish and the process are. The black soldermask is even, the white silkscreen sharp enough that all the small text and the QR codes are legible without a loupe, and the two-tone OSC logo (silkscreen letters plus exposed-copper decorative iconography) came out crisp. The O is the part I keep coming back to — there's an encoder-disk embellishment inside that letter, with thin radial slots, and it's small enough that I honestly didn't expect a fab to hold the edges cleanly at this scale. PCBWay did. On the service side, the same pre-fab manufacturability review runs every time — it caught a real pad-clearance issue KiCad's own DRC missed on Rev A, and turned up nothing on Rev B — and the same in-assembly verification photos for orientation-sensitive parts arrived in my inbox during the Rev B build, with the same little paper "+" cutouts next to LED anodes I remember from last time. Replies come back in the time zone I'd expect for a partner in China, and the back-and-forth feels collaborative rather than transactional. The only thing they couldn't catch on Rev B was a design-level issue that requires understanding the analog front end, and that's on me, not them.&lt;/p&gt;

&lt;p&gt;What I want to credit specifically is that this is the &lt;em&gt;second&lt;/em&gt; sponsored fab run, not the first. Hardware sponsorships in my corner of the internet usually go one round: boards arrive, post goes up, both sides move on. PCBWay came back for Rev B anyway, after the Rev A bug saga (published in full, three embarrassing mistakes, magnet-wire surgery, the whole thing), and it wasn't conditional on anything. No editorial review on the Rev A post, no editorial review on this one. The arrangement, both times, has been: fab the boards, write whatever you want. For an open-source project that has to be honest about its bugs to stay credible, that's the only sponsorship shape I can actually accept. It's rarer than it should be.&lt;/p&gt;

&lt;p&gt;What the sponsorship also buys me concretely is the time and focus I'd otherwise spend on the parts that aren't OSC. Without it, every revision would mean shopping the BOM myself, hand-assembling the boards (or at minimum doing the QFP rework), and second-guessing build quality on every spin. PCBWay handles all of that. The boards arrive ready to run, the BOM gets sourced even when chip stock is tight (which it was for the V006 on Rev A), and the build quality is consistent enough that I can just trust it and move on to firmware. For a hobbyist project that runs on evenings and weekends, those compromises aren't marginal to skip — it's the difference between fighting the hardware and getting to write code. And PCBWay has now done this for me twice.&lt;/p&gt;

&lt;p&gt;Without PCBWay, OSC would look different. The dev board iteration cost would gate the shape of the project, not just its cadence, and the "fail in the open" posture I want this project to have would be much harder to keep. So: thanks. Plainly, no strings attached.&lt;/p&gt;

&lt;p&gt;If you want to fab your own Rev B, the easiest path is the &lt;a href="https://www.pcbway.com/" rel="noopener noreferrer"&gt;PCBWay plug-in for KiCad&lt;/a&gt; (install from KiCad's Plugin and Content Manager). Open this project, hit upload, the Gerbers and BOM go straight to your cart. If you'd rather not install KiCad, the design is also up as a &lt;a href="https://www.pcbway.com/project/shareproject/OSC_Dev_V006_Rev_B_OpenServoCore_Development_Board_CH32V006_0f6621d7.html" rel="noopener noreferrer"&gt;PCBWay community project&lt;/a&gt; — one click loads the validated files into the cart.&lt;/p&gt;

&lt;p&gt;One tip from my own mistake on this run: if you want the OSC logo's exposed-copper iconography to come out gold-on-black the way it was designed, pick &lt;strong&gt;Immersion Gold (ENIG)&lt;/strong&gt; for the surface finish. I accidentally left mine on &lt;strong&gt;HASL&lt;/strong&gt;, which is a silver-tin coating, and the iconography ended up silver instead of gold. It still looks fine — the two-tone shape carries the logo on its own — but ENIG is the choice if you want the look from the &lt;a href="https://aaronqian.com/log/2026-04-29-osc-dev-v006-rev-b-announcement/" rel="noopener noreferrer"&gt;Rev B announcement post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Either way, remember the first-boot procedure above. &lt;code&gt;SB1&lt;/code&gt; is open from fab on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Firmware never paused while Rev B was off being fabbed — DXL and comms have been the main thread of the last several weeks, since most of that work runs perfectly well on a Rev A board. What Rev B specifically unblocks is the OPA / current-sense path that Rev A literally couldn't do, so the cascade current loop can now move from "designed on paper" to "actually verified against working hardware."&lt;/p&gt;

&lt;p&gt;The DXL comms path is going well. DXL 2.0 with FAST commands at 3 Mbps is live on the bench.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8a1n67p1qhmrv2xd5zy9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8a1n67p1qhmrv2xd5zy9.webp" alt="Five Rev B boards chained on the DXL bus during comms testing" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All five Rev B boards daisy-chained on the DXL bus (G/V/D), with the scope hooked to DATA and DBG on the first chip in the chain while I'm exercising the protocol stack. Terminal is mid &lt;code&gt;yay -Syu&lt;/code&gt;, because that's how bench shots go.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The current implementation leans on software for RX / TX timing and TX scheduling, which works but couples the protocol layer to HSI fine-trim and a few other things I'd rather not be coupled to. I'm pivoting that whole stack to hardware-based timing — TIM2 input capture for RX edges, hardware-fired TX scheduling — so the protocol layer stops caring about microsecond-level CPU jitter. More articles about the DXL timing architecture coming soon, including why software timing hit a wall and what the hardware-timed version actually looks like.&lt;/p&gt;

&lt;p&gt;With Rev B validated and the DXL transport getting its hardware-timing rework, the cascade current loop is the next big block after that.&lt;/p&gt;

&lt;p&gt;The MCU pin is shared, and now there's a small bridge to make that explicit. A small price for a real differential OPA.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>embedded</category>
      <category>electronics</category>
      <category>ch32</category>
    </item>
    <item>
      <title>CH32V006 Servo Controller Dev Board (Rev. B) Designed</title>
      <dc:creator>Aaron Qian</dc:creator>
      <pubDate>Sat, 02 May 2026 02:34:12 +0000</pubDate>
      <link>https://dev.to/aaronqian/osc-dev-v006-rev-b-is-ready-1k0g</link>
      <guid>https://dev.to/aaronqian/osc-dev-v006-rev-b-is-ready-1k0g</guid>
      <description>&lt;p&gt;Rev A took board surgery to power on. Then I hit an &lt;a href="https://aaronqian.com/log/2026-04-25-rev-a-uart-rx-stuck-high/" rel="noopener noreferrer"&gt;RX line that refused to go LOW&lt;/a&gt;. Then I noticed a third defect I never wrote up: the differential current sensing on the OPA wasn't actually differential. Rev B is the respin that fixes all three, plus a handful of features I was going to need anyway.&lt;/p&gt;

&lt;p&gt;If you're new here, &lt;a href="https://github.com/OpenServoCore" rel="noopener noreferrer"&gt;OpenServoCore&lt;/a&gt; is my effort to turn cheap MG90S-class servos into networked smart actuators with sensor feedback, cascade control, and a DYNAMIXEL-style TTL bus. The CH32V006 dev board is the firmware development platform for this project. Rev B is the second revision of that board, routed this week and ready to fab.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Status: designed, not fabricated.&lt;/strong&gt; I've reviewed Rev B carefully and don't expect another Rev A-scale surprise. But the hardware hasn't been built or validated yet. If you want to fab one yourself, wait for the bringup post. Or fab it at your own risk knowing the design is unproven.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The big-ticket items.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VDD/VCC swap.&lt;/li&gt;
&lt;li&gt;UART RX contention. Added jumper on RX line to select between LinkE or TTL port.&lt;/li&gt;
&lt;li&gt;Various silkscreen label fixes.&lt;/li&gt;
&lt;li&gt;Battery polarity.&lt;/li&gt;
&lt;li&gt;V006 OPA pin mapping for differential current sensing, and as a result:

&lt;ul&gt;
&lt;li&gt;Reset pin &amp;amp; button gone, assigned as OPN2.&lt;/li&gt;
&lt;li&gt;Hardware fault trip via CMP2.&lt;/li&gt;
&lt;li&gt;5V instead of RST pin for WCH-LinkE port.&lt;/li&gt;
&lt;li&gt;Added VSYS sense to extra pin.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Added:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;External NTC connector with onboard / external select jumper.&lt;/li&gt;
&lt;li&gt;PWM servo header for system ID.&lt;/li&gt;
&lt;li&gt;Dual-mode encoder input (TIM2 quadrature or ADC analog).&lt;/li&gt;
&lt;li&gt;First silkscreen logo for the project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;KiCad project: &lt;a href="https://github.com/OpenServoCore/open-servo-core/tree/main/hardware/boards/osc-dev-v006" rel="noopener noreferrer"&gt;hardware/boards/osc-dev-v006&lt;/a&gt;. Full revision delta in the &lt;a href="https://github.com/OpenServoCore/open-servo-core/blob/main/hardware/boards/osc-dev-v006/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9wm90gi772ysueh3uzh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9wm90gi772ysueh3uzh.webp" alt="Rev B board render" width="800" height="802"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Interactive 3D model of the board is available on the &lt;a href="https://aaronqian.com/log/2026-04-29-osc-dev-v006-rev-b-announcement/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What's Fixed from Rev A
&lt;/h2&gt;

&lt;p&gt;The fixes are the boring half of the story. They're show-stoppers when they're broken, and invisible when they're right. Five things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VDD / VCC swap.&lt;/strong&gt; This is &lt;em&gt;the&lt;/em&gt; Rev A bug, the one that caused the &lt;a href="https://aaronqian.com/log/2026-04-03-ch32v006-dev-board-first-spin/" rel="noopener noreferrer"&gt;MCU surgery saga&lt;/a&gt;. Rev B has the schematic right. A fresh chip will power on without magnet wire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Top-row test-point silkscreen.&lt;/strong&gt; Every label was wrong on Rev A (the photo lives in the &lt;a href="https://aaronqian.com/log/2026-04-25-rev-a-uart-rx-stuck-high/" rel="noopener noreferrer"&gt;TX_EN debug post&lt;/a&gt;). The labels are now what the underlying nets actually are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encoder connector labels.&lt;/strong&gt; Same story, smaller surface area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JST-PH battery polarity.&lt;/strong&gt; Was reversed. Now it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TX_EN / UART RX contention.&lt;/strong&gt; This one came out of the &lt;a href="https://aaronqian.com/log/2026-04-25-rev-a-uart-rx-stuck-high/" rel="noopener noreferrer"&gt;scope-debug session&lt;/a&gt; where I figured out the half-duplex buffer was actively driving RX. Rev B adds JP2, a jumper that routes the MCU's RX either through the TTL buffer (DXL mode, normal operation) or floats it (LinkE plain-UART mode, with the LinkE driving RX directly via J4). The point is that UART now works on a bare chip with no firmware running, which is the property that &lt;code&gt;wchisp&lt;/code&gt; and similar tools actually require. Recovery-path peripherals shouldn't depend on firmware to function. A jumper is a small price for that property.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's New
&lt;/h2&gt;

&lt;p&gt;These are the additions, with the differential current sensing piece up front because it's the one Rev A literally can't do. The rest are features I folded in while the respin was happening anyway, mostly aimed at letting the dev board double as a system-ID rig later (motor constants, stock-servo characterization, that kind of thing). Same MCU, same motor driver, same shunt, same DXL bus. One board, three roles. The marginal cost of each was small enough that it would have been silly not to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Differential current sensing, actually differential this time
&lt;/h3&gt;

&lt;p&gt;This is the Rev A defect I haven't written up until now, and arguably the most serious one. &lt;code&gt;ISNS-&lt;/code&gt; was routed to &lt;code&gt;OPN0&lt;/code&gt;, which isn't a valid negative input for the V006's differential opamp. The only configuration the silicon would actually accept was one where the OPA's negative input was internally tied to ground. Which means the Kelvin sense traces on the GND side of the 10 mΩ shunt were doing nothing. The board was effectively single-ended, sensing the shunt against silicon GND, and could only see current flowing one direction. For a front end whose whole job is to feed a cascade current loop, that's a fatal flaw.&lt;/p&gt;

&lt;p&gt;Rev B fixes this. &lt;code&gt;ISNS-&lt;/code&gt; is now on &lt;code&gt;OPN2&lt;/code&gt; instead of &lt;code&gt;OPN0&lt;/code&gt;. &lt;code&gt;ISNS+&lt;/code&gt; stays on the OPA positive side via &lt;code&gt;OPP0&lt;/code&gt;. Because &lt;code&gt;OPN2&lt;/code&gt; shares its package pin with &lt;code&gt;nRST&lt;/code&gt;, I had to remove the reset button and free up the pin for analog use. This means the USER option byte gets programmed to disable reset-on-NRST at provisioning. See Pin Remap for the full domino chain that hangs off this one decision.&lt;/p&gt;

&lt;p&gt;What this actually buys is worth the trade-off though. Three things, in order of how big a deal each one is.&lt;/p&gt;

&lt;p&gt;The first is accuracy. The Kelvin sense traces I'd laid out on either side of &lt;code&gt;RS1&lt;/code&gt; finally do their job. The OPA differentially measures the voltage across the shunt, instead of measuring single-ended with the negative input bonded to ground. Without actually using the Kelvin traces, the reading will be polluted by voltage drops along the GND return path.&lt;/p&gt;

&lt;p&gt;The second, and the bigger deal, is bidirectional visibility. Rev A could only see current flowing &lt;em&gt;into&lt;/em&gt; the motor. With differential OPA setup, the board can see current flowing in the other direction too, such as back-EMF during deceleration, regen, freewheeling current through the diodes during PWM off-time. All of these produce reverse current through the shunt, and a correct PI current loop needs the signed integral of that signal, not a clipped one. I'm sure more uses will surface as the firmware gets real (better state estimation, that kind of thing). Back-EMF and regen are the ones I can name with confidence today.&lt;/p&gt;

&lt;p&gt;The third is that this is basically free. The shunt and MCU's internal opamp supports this with no extra BOM. The only cost is the hardware reset feature, which I can live without.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware overcurrent / fault protection (CMP2)
&lt;/h3&gt;

&lt;p&gt;The OPA output also feeds CMP2, the V006's internal comparator. The comparator can be configured to raise a fault on overcurrent and stop PWM generation without firmware runtime involvement. Even if the main loop is hung, the trip still fires through silicon.&lt;/p&gt;

&lt;p&gt;With a 10 mΩ shunt, 32x gain on the PGA in self-biased differential mode (VBEN=1, VBSEL=1), and DRV's 4 A peak, the shunt drops ±40 mV at full-scale current. The OPA output sits at a ~1.44 V bias and swings ±1.28 V around it, putting the absolute range at 0.16 V to 2.72 V. This is well inside the ADC's 3.3 V range. The CMP2 negative side can be configured to trip on ~85% of VDD, which is ~2.8 V. This means although it won't trip on this development board on overcurrent (the DRV8212P self-limits at 4 A first), it CAN detect motor shorts and catastrophic current surges. The dev board is designed to be flexible for testing different kinds of servos, but still offer some protection against magic smokes.&lt;/p&gt;

&lt;p&gt;For swap boards, the shunt can be sized so that the &lt;em&gt;fault threshold of the specific servo&lt;/em&gt; lands near the top of the OPA range. Same role as on the dev board: catch hardware failure, not normal operation. For example, the SG90 datasheet specs a 650 mA stall, but inrush can spike well above that on startup, so we want the trip set with margin, somewhere around ~1.2 A (~2× datasheet stall) to ride above normal operation while still catching motor shorts and jams beyond mechanical stall. With a 35 mΩ shunt (a common 1206 1W value, easy to source), the OPA swings ±1.33 V around the 1.44 V bias at 1.2 A, putting the output at ~2.77 V. That's right at the CMP2 trip threshold of ~2.8 V (VBCMPSEL=10). Normal stall (650 mA) lands at ~2.16 V on the OPA output, comfortably below the trip. Bidirectionally, the ADC sees from −1.30 A (negative rail) to +1.67 A (positive rail) before saturation, plenty of headroom for back-EMF and regen.&lt;/p&gt;

&lt;h3&gt;
  
  
  External NTC connector and JP1 source select
&lt;/h3&gt;

&lt;p&gt;The onboard NTC (&lt;code&gt;TH1&lt;/code&gt;) measures ambient board temperature. That was useful as a stand-in for the V003's lost internal temperature sensor in the first version. It's not what you want during motor-stress testing, where the temperature that matters is the temperature of the motor windings. Rev B adds an external NTC connector (J6) and a jumper (JP1) to pick between the two. Onboard for ordinary firmware-dev work, motor-attached for characterization runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  PWM servo header (J3)
&lt;/h3&gt;

&lt;p&gt;A standard 1×3 hobby servo header, driven from &lt;code&gt;IN1&lt;/code&gt;. With this connector, the encoder, and proper firmware, it opens up the possibility for servo characterization such as positional accuracy, slew rate, error percentage on repeated sweep / return-to-center tests. I have some fun ideas on setting up a rig based on this board to automatically ID a servo by streaming the data using the same single-wire UART protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dual-mode encoder input (J8)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ENCA&lt;/code&gt; and &lt;code&gt;ENCB&lt;/code&gt; are now pin-mapped to &lt;em&gt;both&lt;/em&gt; TIM2 and the ADC. Same 2×2 header, two acquisition modes, picked in firmware. The reason for this is that I didn't want to commit the dev board to one encoder strategy.&lt;/p&gt;

&lt;p&gt;In TIM2 mode, the connector takes a standard digital quadrature encoder, magnetic or optical, and counts edges in hardware with no MCU overhead. This is the default mode for motor-speed feedback during motor-ID runs and for any off-the-shelf encoder integration.&lt;/p&gt;

&lt;p&gt;In ADC mode, the same pins are sampled by the ADC, which means the connector also accepts ratiometric analog encoders. The specific use case in my head is something like the IR-quadrature sensor stack used in &lt;a href="https://github.com/adamb314/ServoProject" rel="noopener noreferrer"&gt;Adam Bäckström's ServoProject&lt;/a&gt;, where you read sin/cos directly off a pair of photodiodes, do sub-count interpolation in firmware, and end up with very high effective resolution from a homebrew flex-PCB sensor. Backlash-compensation work and the higher-precision experiments live here.&lt;/p&gt;

&lt;h3&gt;
  
  
  VSNS for direct VSYS sensing
&lt;/h3&gt;

&lt;p&gt;Rev A inferred the system voltage from &lt;code&gt;VSNA&lt;/code&gt; and &lt;code&gt;VSNB&lt;/code&gt; (the motor-terminal sense dividers). That's fine when the motor is running, but it breaks when it's idle. Rev B adds a dedicated &lt;code&gt;VSNS&lt;/code&gt; divider so the board measures &lt;code&gt;VSYS&lt;/code&gt; directly regardless of what the motor driver is doing.&lt;/p&gt;

&lt;h3&gt;
  
  
  WCH-LinkE 5 V as a fourth power input (J4 pin 6)
&lt;/h3&gt;

&lt;p&gt;The WCH-LinkE programmer has a &lt;code&gt;+5V&lt;/code&gt; line to the target board. Since we no longer have &lt;code&gt;nRST&lt;/code&gt;, this pin is now repurposed in Rev B as a power input, gated by an SS54 into the same OR network as USB-C, the JST battery, and the screw terminal. Now you can use LinkE as the sole power source for the board during development. No need for a separate USB-C cable.&lt;/p&gt;

&lt;h2&gt;
  
  
  PCB Art / Logo
&lt;/h2&gt;

&lt;p&gt;I delayed and delayed coming up with a logo for OpenServoCore because one, I'm not really good at graphic design, and two, I just kept on prioritizing PCB / firmware related tasks. But I somehow decided that it was time because I want to actually include a nice looking logo on the Rev B PCB. So, I designed one this week, and it came out better than I thought.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7w9ve8nx9nlr6r7kaeqq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7w9ve8nx9nlr6r7kaeqq.webp" title="O is an encoder disk, S is a servo disk mount, C is a servo horn sweeping" alt="Logo" width="800" height="1015"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The logo purposefully uses a two-tone design. This is because I can use one tone (white) as the silkscreen for the OSC letters themselves. And then use the golden color of exposed copper for the finer decorative symbols. It came out surprisingly well, given that I'm not a graphic designer.&lt;/p&gt;

&lt;p&gt;There's also a small QR code on the silkscreen that points at the docs page for this board. I'd been putting this off for the same reason. This is a board meant for other people, and "scan to read the docs" is pretty much the standard these days.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCU Pin Remap
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxppei9y3no5sqwouzobv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxppei9y3no5sqwouzobv.webp" title="Rev B pin assignments on the CH32V006F8P6. ISNS- now lands on OPN2 (the pin nRST used to occupy), ISNS+ on OPP0, ENCA/ENCB on dual-purpose TIM2/ADC channels, and STAT on a TIM1 channel." alt="CH32V006F8P6 Rev B pin assignments" width="800" height="602"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pin map is where the rest of the design rearranges itself around the OPN2 fix. Five things changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ISNS+&lt;/code&gt; / &lt;code&gt;ISNS-&lt;/code&gt; → &lt;code&gt;OPP0&lt;/code&gt; / &lt;code&gt;OPN2&lt;/code&gt;.&lt;/strong&gt; Fixes the Rev A defect where &lt;code&gt;ISNS-&lt;/code&gt; was on &lt;code&gt;OPN0&lt;/code&gt; and the OPA could only run single-ended. See Differential current sensing for the why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nRST&lt;/code&gt; removed.&lt;/strong&gt; It shares a pin with &lt;code&gt;OPN2&lt;/code&gt;, so the OPA fix forced this. The USER option byte gets programmed at provisioning to disable reset-on-NRST. The reset button and its RC debounce network are gone with it. Net pin budget: minus one reset, plus one working differential current sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ENCA&lt;/code&gt; / &lt;code&gt;ENCB&lt;/code&gt; selectable between TIM2 and ADC.&lt;/strong&gt; Same connector, two acquisition modes, picked in firmware. See Dual-mode encoder input for the why. Pin-map cost was finding channels that have &lt;em&gt;both&lt;/em&gt; TIM2 capture &lt;em&gt;and&lt;/em&gt; an ADC channel attached, which constrained the rest of the placement more than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STAT LED moved to a TIM1 channel.&lt;/strong&gt; Gives the LED PWM brightness and pattern control instead of binary on/off. With the remaining pins, one of them happens to be TIM1 CH4 for remap 7. So I'm assigning the STAT LED to this pin for brightness control / breathing etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Board renamed.&lt;/strong&gt; &lt;code&gt;servo-dev-board-ch32v006&lt;/code&gt; → &lt;code&gt;osc-dev-v006&lt;/code&gt;. Matches the project's naming convention now that the board family is filling in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Get It
&lt;/h2&gt;

&lt;p&gt;KiCad project lives in the OSC monorepo under &lt;a href="https://github.com/OpenServoCore/open-servo-core/tree/main/hardware/boards/osc-dev-v006" rel="noopener noreferrer"&gt;hardware/boards/osc-dev-v006&lt;/a&gt;. The full revision delta is in &lt;a href="https://github.com/OpenServoCore/open-servo-core/blob/main/hardware/boards/osc-dev-v006/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt; inside that directory.&lt;/p&gt;

&lt;p&gt;There is one more note on component availability. When the first revision went out, JLCPCB had no &lt;code&gt;CH32V006F8P6&lt;/code&gt; stock at all and PCBWay had to source the chip externally. Now both JLCPCB and LCSC have restocked the part. At the time of writing, there are a couple thousand of them, so if you are confident enough to fabricate one, it should be doable via JLCPCB now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I don't expect any more serious issues with Rev B. It should just be plug and play. My plan is to order Rev B, do one more pass of validation, and start writing code for the OSC firmware. Most of the bringup work is already done on Rev A. When Rev B arrives, I just need to tweak the register setup a bit and it should be good to go.&lt;/p&gt;

&lt;p&gt;The focus now shifts to firmware, fingers crossed.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>embedded</category>
      <category>electronics</category>
      <category>riscv</category>
    </item>
    <item>
      <title>CH32V006 Servo Controller Dev Board (Rev. A) UART Validation</title>
      <dc:creator>Aaron Qian</dc:creator>
      <pubDate>Sun, 26 Apr 2026 10:41:26 +0000</pubDate>
      <link>https://dev.to/aaronqian/my-rx-line-was-stuck-high-and-txen-was-the-fix-4hab</link>
      <guid>https://dev.to/aaronqian/my-rx-line-was-stuck-high-and-txen-was-the-fix-4hab</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;If you're new to the project, &lt;a href="https://github.com/OpenServoCore" rel="noopener noreferrer"&gt;OpenServoCore&lt;/a&gt; is my effort to turn cheap MG90S-class servos into networked smart actuators with sensor feedback, cascade control, and a DYNAMIXEL-style TTL bus. &lt;a href="https://github.com/OpenServoCore/tinyboot" rel="noopener noreferrer"&gt;tinyboot&lt;/a&gt; is the Rust bootloader that runs on those boards. It fits in the CH32V003's 1920-byte system flash and gives you CRC-validated OTA updates over UART, with trial boot and automatic rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For those of you who just want a summary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom:&lt;/strong&gt; UART RX on the Rev A V006 dev board wouldn’t go low. TX worked fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triage:&lt;/strong&gt; Scope showed only ~180 mV ripple on a 3.3 V line. Something was actively driving it high.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnosis:&lt;/strong&gt; The half-duplex TTL front-end was the culprit. &lt;code&gt;TX_EN&lt;/code&gt; wasn’t just “TX enable”, it controlled whether the buffer was actively driving RX.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still suggest you to at least skim through though. Those scope debug photos, videos, and schematic screenshots are probably still worth your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initial Troubleshooting
&lt;/h2&gt;

&lt;p&gt;The Rev A &lt;a href="https://github.com/OpenServoCore/open-servo-core/tree/main/hardware/boards/servo-dev-board-ch32v006" rel="noopener noreferrer"&gt;CH32V006 OpenServoCore dev board&lt;/a&gt; just came back from fabrication, and the first task was bringing the V006 port of tinyboot up on it. The V006's UART controller shares the same silicon as the V003, so you'd think the existing HAL driver would just work. But alas, things are never that simple... The &lt;code&gt;tinyboot&lt;/code&gt; CLI couldn't reach the flashed bootloader at all. Same silicon, same driver, but the V006 was completely silent on UART.&lt;/p&gt;

&lt;p&gt;To eliminate the possibility of other driver or init issues, I wrote a separate UART test app to dig into it more thoroughly. I first wrote a bare minimal app that just initialized UART using the same HAL driver and sent out "Hello world" periodically via the TX line. For the PC, I used &lt;a href="https://github.com/neundorf/CuteCom" rel="noopener noreferrer"&gt;CuteCom&lt;/a&gt;, a graphical serial terminal. Immediately I got garbage, but this is actually a good sign. It means the MCU is sending things out, just the clock needs some tweaking. And after some RCC tweaks, I was able to get the right clocks setup to receive TX with no issues. So far so good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing It Down
&lt;/h2&gt;

&lt;p&gt;The test app was happily transmitting bytes out over UART, but sending bytes back was a completely different story. The line was basically silent. It didn't matter how I tweaked the UART register settings or how many times I read and re-read the reference manual, I always got silence, nothing was reaching the chip. Given that the HAL code and the UART controller in the silicon are essentially the same as the V003, and that "Hello world" was coming out of the MCU's TX (proving clock init is sound), the only conclusion I could come up with was that the issue isn't in the firmware. It's in the hardware.&lt;/p&gt;

&lt;p&gt;So I moved on to the schematic and the PCB layout, looking for anything wrong: a swapped pull-up, an inverted wire, anything. The UART TX/RX connector I used for testing is routed directly from the MCU. The same TX/RX routes also branch off to a half-duplex DXL TTL front-end. I had a brief suspicion that this might have something to do with RX not working, but couldn't come up with a plausible theory to back it up, so I moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope to the Rescue
&lt;/h2&gt;

&lt;p&gt;When I ran out of ideas on why RX didn't work, I decided to look at the signal directly with a scope to isolate whether anything was actually reaching the MCU. I have a neat one liner for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;yes &lt;/span&gt;U | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/ttyACM0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bash command sends alternating zeros and ones to the MCU's RX line, making scoping easy. It works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;yes U&lt;/code&gt; repeatedly sends out &lt;code&gt;U\n&lt;/code&gt; to STDOUT. &lt;code&gt;U&lt;/code&gt; is &lt;code&gt;0x55&lt;/code&gt; in hex, and &lt;code&gt;0b01010101&lt;/code&gt; in binary, which produces alternating HIGHs and LOWs on the scope once the start and stop bits are taken into account.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tr -d '\n'&lt;/code&gt; strips &lt;code&gt;\n&lt;/code&gt; from the stream, so we have &lt;code&gt;UUUUUU&lt;/code&gt; forever.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt; /dev/ttyACM0&lt;/code&gt; pipes the whole stream into the TTY, in this case my WCH-LinkE serial port.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I hooked up the scope, blasted &lt;code&gt;UUUUUUUUU&lt;/code&gt; into the RX line, and what I saw was a square wave, but a teeny tiny one...&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxr41fuxl57ahhr2mc2e.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxr41fuxl57ahhr2mc2e.webp" title="PCB layout open on screen, scope in the middle, dev board with the programmer below." alt="Bench setup with the dev board, scope hooks on RX, and the PCB layout I kept re-checking" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zooming in on the scope:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhr7fcoip9f6oftabl7om.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhr7fcoip9f6oftabl7om.webp" title="Min 3.20 V, max 3.38 V. Less than 200 mV of amplitude on what should have been a 0 V to 3.3 V square wave." alt="Scope showing a roughly 180 mV square wave riding on top of 3.3 V instead of swinging rail to rail" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The waveform was there. The shape was right. But it was riding on top of 3.3 V with maybe 180 mV of amplitude. The maximum was 3.38 V, the minimum 3.20 V. Something was holding the line near the rail so hard that my USB UART adapter could only pull it down by a couple hundred millivolts. This is a big clue!&lt;/p&gt;

&lt;p&gt;To rule out the wiring, I touched RX directly to ground. The line snapped to 0 V cleanly. So the connection was fine. The driver just couldn't win against whatever was holding RX high.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the Schematics
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9b06218rzssfgnnjw0ox.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9b06218rzssfgnnjw0ox.webp" title="The DXL TTL front-end. Two tri-state buffers share a TX_EN gate: U4A drives RX from DATA when TX_EN is low, U4B drives DATA from TX when TX_EN is high. R14 pulls RX up, R15 pulls TX_EN down (default to listen), R16 pulls DATA up." alt="Three-block schematic of the half-duplex front-end: buffer power, the RX/TX direction-switching pair built on a 74LVC2G241, and the pull-up/pull-down resistors on RX, TX_EN, and DATA" width="799" height="222"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looking at the TTL DXL front-end again, the RX line goes through a tri-state buffer (&lt;code&gt;SN74LVC2G241&lt;/code&gt;) that implements half-duplex direction switching. &lt;code&gt;TX_EN&lt;/code&gt; selects the direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TX_EN&lt;/code&gt; low (default, held by a pulldown): the buffer routes &lt;code&gt;DATA&lt;/code&gt; to &lt;code&gt;RX&lt;/code&gt;. The MCU listens to the bus.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TX_EN&lt;/code&gt; high: the buffer routes &lt;code&gt;TX&lt;/code&gt; to &lt;code&gt;DATA&lt;/code&gt;. The MCU talks to the bus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is when I had my facepalm moment. I had been picturing the buffer as some kind of passive switch that just connects two wires together. But after thinking about it for a few minutes, I realized that's not the case at all.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;TX_EN&lt;/code&gt; is low, the buffer's output stage is &lt;em&gt;actively driving&lt;/em&gt; RX with whatever it sees on &lt;code&gt;DATA&lt;/code&gt;. And &lt;code&gt;DATA&lt;/code&gt; has its own 10K pullup, per the DXL TTL reference, so when nobody is talking on the bus, &lt;code&gt;DATA&lt;/code&gt; sits at 3.3 V. The buffer reads that, and pushes 3.3 V back out through its high-side MOSFET onto RX. Simplified, RX is pretty much hooked directly to 3.3 V through maybe 20 Ω of MOSFET resistance, acting as a &lt;strong&gt;strong&lt;/strong&gt; pull-up.&lt;/p&gt;

&lt;p&gt;So when I was sending bytes from the USB UART adapter into RX, I was not fighting a passive 10K pullup. I was fighting a CMOS push-pull output stage. The 74LVC2G241 has very low high-side R_DS(on) and 24 mA of drive. A typical USB UART chip's TX output is much weaker. The two formed a voltage divider, and the buffer won. The line stayed parked near 3.3 V, with my adapter pulling it down by a couple hundred millivolts every time it tried to send a 0. That was my "ripple."&lt;/p&gt;

&lt;p&gt;A hard short to ground bypasses that contention entirely, which is why poking RX with a ground clip snapped it cleanly to 0 V.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Absurd Workaround
&lt;/h2&gt;

&lt;p&gt;As absurd as it sounds, the workaround was to assert &lt;code&gt;TX_EN&lt;/code&gt;, while reading from RX. Why would I assert the &lt;em&gt;transmit&lt;/em&gt; enable when my problem was on the receive side? Because &lt;code&gt;TX_EN&lt;/code&gt; isn't really a transmit enable. From firmware's perspective it's asserted when you talk and deasserted when you listen. But electrically, it's a mux select that picks which buffer drives the bus. Treating those as the same thing is what set me up for confusion in the first place.&lt;/p&gt;

&lt;p&gt;With it held high, the &lt;code&gt;DATA&lt;/code&gt; to &lt;code&gt;RX&lt;/code&gt; buffer is disabled, RX falls back to its own pullup, and the USB UART adapter can drive it without a fight. Poking 3.3 V onto &lt;code&gt;TX_EN&lt;/code&gt; confirmed it in real time:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/2giFb58HLf4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Touching 3.3 V to TX_EN snaps the ripple into a clean rail-to-rail square wave. Release it and the line flat-lines back near 3.3 V. The buffer is the whole problem.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For those eagle-eyed viewers, yes. I'm touching the TP that says TX... But the electric trace is actually TX_EN.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6xoaj8t4ovukht7syvs0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6xoaj8t4ovukht7syvs0.webp" title="Top row: what the silkscreen says each test point is. Bottom row: what the test point is actually wired to. Apart from the GND endcaps and STAT, every label is wrong. The silkscreen never got updated after the nets were shuffled around during layout." alt="PCB layout view of the test point row. The top row of silkscreen labels reads GND, VIN, 3V3, EN, STAT, DBG, DATA, TX, RX, GND. The bottom row, which shows the nets actually connected to each test point, reads GND, +3V3, EN, DBG, STAT, RX, TX, TX_EN, DATA, GND. Almost none of the silkscreen labels line up with the underlying nets." width="797" height="108"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is another &lt;a href="https://aaronqian.com/log/2026-04-03-ch32v006-dev-board-first-spin/" rel="noopener noreferrer"&gt;embarrassing yet hilarious mistake&lt;/a&gt; that got its own story, where I somehow "fixed" the issue by creating magic smoke on my board.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Fix
&lt;/h2&gt;

&lt;p&gt;The workaround is assert &lt;code&gt;TX_EN&lt;/code&gt; in firmware, however, this is not a real fix. Drive &lt;code&gt;TX_EN&lt;/code&gt; high in the bootloader's startup code and only release it when the MCU genuinely wants to talk. It works, costs nothing, and ships today. But it means UART functionality on this board now depends on the firmware running correctly.&lt;/p&gt;

&lt;p&gt;The right fix should be in hardware by adding a jumper on the RX line. Shorted means TTL mode, where RX is wired through the buffer as before. Open means plain UART mode, where RX bypasses the buffer entirely and goes straight to the MCU. More involved, requires a board respin, and adds a small amount of inconvenience for the user. But the UART works without any firmware running.&lt;/p&gt;

&lt;p&gt;For Rev. B, I'm going with the jumper.&lt;/p&gt;

&lt;p&gt;The reason is &lt;code&gt;wchisp&lt;/code&gt;. That tool, and others like it, use the UART to read and write the CH32's Option Bytes. Those operations happen &lt;em&gt;outside&lt;/em&gt; of any firmware I write. If my UART hardware depends on my firmware to function, then a misconfigured Option Byte, a half-flashed bootloader, or a chip fresh from the factory can lock me out of the recovery path. The whole appeal of UART-based programming is that it works on a bare chip with nothing running. That property has to live in hardware, not in firmware.&lt;/p&gt;

&lt;p&gt;The rule I'm taking from this: recovery-path peripherals should not depend on firmware to function. For half-duplex TTL designs specifically, that means the RX line has to be usable as a standalone UART without anything running on the MCU. A jumper is a small price to ensure hardware recoverability.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;TX_EN&lt;/code&gt; is really a line selector, and buffers &lt;strong&gt;actively drives&lt;/strong&gt; outputs. I'll remember that one for a while.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>embedded</category>
      <category>electronics</category>
      <category>debugging</category>
    </item>
    <item>
      <title>tinyboot v0.4.0 Released — The API is Stable</title>
      <dc:creator>Aaron Qian</dc:creator>
      <pubDate>Thu, 23 Apr 2026 05:10:00 +0000</pubDate>
      <link>https://dev.to/aaronqian/tinyboot-v040-released-the-api-is-stable-2h76</link>
      <guid>https://dev.to/aaronqian/tinyboot-v040-released-the-api-is-stable-2h76</guid>
      <description>&lt;p&gt;If you've been following tinyboot, you might have noticed there was no announcement for v0.3.0. That release added CH32V103 support, but things were still in flux. Crate structure was shifting, APIs were changing, the protocol was being reworked. I didn't want to write up something that'd be outdated in two weeks. I wanted to wait until the dust settled. And with v0.4.0, we have finally arrived at this point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/OpenServoCore/tinyboot" rel="noopener noreferrer"&gt;OpenServoCore/tinyboot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Handbook: &lt;a href="https://openservocore.github.io/tinyboot" rel="noopener noreferrer"&gt;openservocore.github.io/tinyboot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Changelog: &lt;a href="https://github.com/OpenServoCore/tinyboot/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is tinyboot
&lt;/h2&gt;

&lt;p&gt;tinyboot is a minimal bootloader for resource constrained MCUs. It is written in Rust, and it fits in 1920 bytes of system flash, leaving all user flash free for your application, except a small page (64 bytes on V003) of user flash to store boot metadata. It gives you CRC-validated firmware updates over UART with trial boot and automatic fallback to bootloader service mode when trials run out. The kind of safe OTA update story you'd expect from a much larger bootloader, squeezed into the constraints of a $0.22 MCU.&lt;/p&gt;

&lt;p&gt;It's currently focused on the CH32 family, but the core is chip-agnostic and designed to be portable. If you're interested in bringing tinyboot to another chip family, see the porting section of the &lt;a href="https://openservocore.github.io/tinyboot/porting.html" rel="noopener noreferrer"&gt;handbook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm building it as part of &lt;a href="https://dev.to/projects/open-servo-core/"&gt;OpenServoCore&lt;/a&gt;, an open-source smart servo platform. tinyboot handles the OTA updates via the existing single wire UART (Dynamixel TTL), so you don't have to tear your robot apart and open up each servo, unsolder the board just to flash a new firmware.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform Support
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CH32V003&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CH32V00x&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;New in v0.4.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CH32V103&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CH32X03x&lt;/td&gt;
&lt;td&gt;Planned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  V00x Support
&lt;/h2&gt;

&lt;p&gt;The biggest addition in v0.4.0 is full support for the CH32V00x family: V002, V004, V005, V006, and V007. This is the release that matters most for OpenServoCore, because the OSC dev board runs on the CH32V006. Hardware validation for this release was done on the actual dev board.&lt;/p&gt;

&lt;p&gt;Getting here wasn't entirely smooth. I ran into a hardware issue where the RX line wouldn't work without driving the inverse TX_EN line, which took a scope session to figure out. But that's a story for another post. I'm just glad I was able to finally get RX to work and complete the hardware test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything Fits
&lt;/h2&gt;

&lt;p&gt;TX_EN support used to overflow system flash on some chip variants. That's not acceptable. TX_EN isn't optional for OpenServoCore. It's required for half-duplex RS-485 / DXL TTL communication.&lt;/p&gt;

&lt;p&gt;As of v0.4.0, all chip variants compile with TX_EN enabled and fit in system flash. The V103 was the outlier. It has a split flash layout, and I moved the UART transport into the second region to make it fit. That trick deserves its own post. Down the road, that second region also has enough room for a USB transport, so flashing via USB on V103 is a real possibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crate Restructure
&lt;/h2&gt;

&lt;p&gt;The previous three separate crates (&lt;code&gt;tinyboot-ch32-boot&lt;/code&gt;, &lt;code&gt;-app&lt;/code&gt;, &lt;code&gt;-hal&lt;/code&gt;) are now merged into a single &lt;code&gt;tinyboot-ch32&lt;/code&gt; crate with &lt;code&gt;boot&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, and &lt;code&gt;hal&lt;/code&gt; modules. There's also a new &lt;code&gt;tinyboot-ch32-rt&lt;/code&gt;, a minimal runtime because &lt;code&gt;qingke-rt&lt;/code&gt; is too large for system flash.&lt;/p&gt;

&lt;p&gt;This wasn't a planned refactor. It was the natural result of continuous iteration during tinyboot's early development. I'd rather get the architecture right early by sacrificing API stability than lock in the wrong abstractions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tinyboot-ch32&lt;/code&gt; is currently git-only and not published to crates.io. It depends on a git version of &lt;code&gt;ch32-metapac&lt;/code&gt; that includes flash fixes for the V00x family that haven't been released yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protocol and API Changes
&lt;/h2&gt;

&lt;p&gt;The wire protocol now uses 24-bit addresses instead of 32-bit, freeing the fourth byte for per-command flags. 24 bits is still 16MB of addressable space, more than enough for these MCUs. The standalone &lt;code&gt;Flush&lt;/code&gt; command is gone; it's now a flag on the final &lt;code&gt;Write&lt;/code&gt;. Cleaner on the wire, simpler in the dispatcher, improves the developer experience, and best of all no size increase due to zero-cost abstractions via Rust's union types.&lt;/p&gt;

&lt;p&gt;On the API side: &lt;code&gt;BootMode&lt;/code&gt; became &lt;code&gt;RunMode&lt;/code&gt; to separate the concept of boot flash region selection vs bootloader run mode (handoff or service). &lt;code&gt;BootClient&lt;/code&gt; was also removed after the crates merge. Boolean parameters in the public API became semantic enums (&lt;code&gt;Duplex::Half&lt;/code&gt; instead of &lt;code&gt;half_duplex: true&lt;/code&gt;). Last but not least, flash lock/unlock is now scoped per operation instead of manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fixes
&lt;/h2&gt;

&lt;p&gt;Two nasty half-duplex communication bugs, both found during hardware validation on the dev board:&lt;/p&gt;

&lt;p&gt;The dispatcher wasn't flushing the transport after sending a response. On a full-duplex UART you'd never notice, but on RS-485 / DXL TTL half-duplex the data just sits in the buffer and never goes out on the wire.&lt;/p&gt;

&lt;p&gt;The ring buffer wasn't resetting its head/tail pointers after flushing buffered writes to flash. The buffer was logically empty but the pointers were advanced, so subsequent writes would eventually wrap incorrectly. I didn't notice this before because I didn't do back-to-back &lt;code&gt;flash&lt;/code&gt; commands with the &lt;code&gt;tinyboot&lt;/code&gt; CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docs Overhaul
&lt;/h2&gt;

&lt;p&gt;Documentation has been completely rewritten for users instead of maintainers, and a user handbook has been created. When the architecture is changing every release, writing user-facing docs is a losing game. Now that things have stabilized, it actually makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Stable" Means
&lt;/h2&gt;

&lt;p&gt;To be clear, tinyboot is not production-grade. But as of v0.4.0, it's stable in the ways that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture stable&lt;/strong&gt;: no more big crate restructures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocol stable&lt;/strong&gt;: no more wire format changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Features stable&lt;/strong&gt;: all core features compile and fit on all supported chips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavior stable&lt;/strong&gt;: no obvious bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means I'm shifting focus. tinyboot satisfies all of OpenServoCore's needs, and my attention is moving to rewriting the OSC firmware. I'll still maintain tinyboot, and issues and PRs are welcome, but active feature development is pausing for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;USB transport for V103 (the second flash region has room).&lt;/li&gt;
&lt;li&gt;CH32X03x support eventually.&lt;/li&gt;
&lt;li&gt;But the immediate priority is OpenServoCore firmware.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>embedded</category>
      <category>bootloader</category>
      <category>ch32</category>
    </item>
  </channel>
</rss>
