Using the SPI peripheral as a DMA-fed CRC engine
While building the wire protocol for OpenServoCore , I ran into a small problem that was costing a lot of CPU. Every frame on the servo bus carries a CRC-16 checksum, the bus runs at 3 megabaud, and the chip doing the checking is a $0.16 CH32V006 with no CRC peripheral. It does have an SPI block though, and it turns out there is a perfectly good CRC engine hiding inside it.
This article is a how-to for commandeering that engine as a DMA-fed CRC coprocessor for data that has nothing to do with SPI. In my case that’s UART frames, but the engine doesn’t care where the bytes came from. Everything below is the current shipping design, verified against the code in the OpenServoCore repo , and as far as I know the trick works on every WCH CH32 chip with an SPI block. If you are new here, the project overview has the background.
TL;DR
The short version:
- Every SPI block in the CH32 family contains a CRC-16 unit with a programmable polynomial. It’s meant for checksumming SPI transfers, but it will happily checksum anything you feed it.
- Configure the SPI as master with software chip-select, map no pins, enable TX DMA, and stream your bytes into the data register. The TX CRC register accumulates a checksum of everything you feed it, at ~0.36 µs per byte and roughly zero CPU.
- 16-bit LSB-first mode computes CRC-16/ARC over your bytes in natural memory order, with no byte swapping and no staging copy.
- The checksum comes out of the register bit-reversed. You flip it once per frame, and there’s a section below on why.
- There is one real hazard. A pin remap places every function it names, used or not, so park SCK and MOSI on pins where they provably can’t hurt anything.
The Problem
A CRC (cyclic redundancy check) is the checksum at the end of a frame that tells you the bytes survived the wire. The servo protocol puts a CRC-16 on every frame, in both directions. The obvious implementation is a table-driven software loop, and that’s what I shipped first. On the bench it costs 635 ns per byte of pure CPU on the 48 MHz V006, plus a 512-byte table in flash.
At 3 megabaud a byte arrives about every 3.3 µs. So checksumming alone eats roughly a fifth of the CPU during traffic, on a chip that is also running a motor control loop. Worse, the reply path wants the checksum ready the instant the frame ends, and a catch-up loop over the whole frame at that point is too late. There’s gotta be a better way!
The V006 has no CRC peripheral. So I went digging through the datasheet for anything that could take this job off the CPU, and three registers in the SPI chapter caught my eye, a CRC polynomial register, a TX CRC register, and an RX CRC register.
The CRC Engine Hiding in the SPI Block
SPI (Serial Peripheral Interface) is the synchronous bus you normally use for flash chips and sensors. Real SPI links can be noisy, so the designers built a CRC feature into the block. Turn on the CRCEN bit and the hardware accumulates a running CRC of every data word the shifter sends, so two chips can verify a whole transfer in hardware.
Here is the whole trick. The TX CRC unit checksums everything the shifter sends, and the shifter does not care whether its output actually reaches a pin. So you set the block to master mode so it clocks itself, use software chip-select so it doesn’t need a select pin, route no signals to any pin, and point a DMA (direct memory access) channel at the data register so bytes flow from RAM into the shifter with no CPU in the loop. The bits still get “sent”, they just go nowhere.
You end up with a peripheral that eats a DMA stream and produces a CRC-16. The CPU’s whole involvement is a handful of register writes per frame.
Does Your Chip Have It?
I help maintain ch32-rs , and the register definitions there are generated from per-chip data files , so I could answer “which chips have this” by grepping the data instead of thirty datasheets. The whole CH32 line uses three SPI register layouts, and all three carry the identical CRC machinery:
| SPI register layout | Chips |
|---|---|
| v0 | CH32V002–V007 (including the V003 and V006), CH32L103, CH32X033/X035, CH643 |
| v3 | CH32F103, V103, V203/V208, V303/V305/V307, and the H41x’s SPI1–3 |
| h4 | CH32H41x’s higher-numbered SPI blocks |
All three layouts have the same three CRC registers and the same CRCEN bit, from the ten-cent V003 all the way up to the H417. There is a funny irony in the data, too. The only dedicated CRC peripheral in the family is on the big H4 chips, and it’s a fixed-polynomial CRC-32 with a word-wide feed and no DMA request line. For “checksum a byte stream while the CPU does something else”, the SPI block is the better CRC peripheral even on chips that have a real one. The WCH SPI block is also an obvious clone of the STM32F family’s, which carries the same CRC registers, so this likely works beyond WCH. I have only tested this on the CH32V006 and CH32V203 though.
Two things to check on your chip before counting on this. First, your DMA request map needs a line for SPI1_TX (on the V006 that’s DMA1 channel 3). Second, figure out where your remap options park the SPI pins. That one gets its own section below, because it bit me.
The Register Recipe
Here is the bring-up, straight from the shipping code (Rust with ch32-metapac , but it’s all register pokes, so it translates to C directly):
rcc::enable_spi1();
afio::set_spi_remap(1, 0b101); // park the pins somewhere safe FIRST (see below)
SPI1.ctlr1().write(|w| {
w.set_mstr(true); // master: the shifter clocks itself
w.set_ssm(true); // software chip-select...
w.set_ssi(true); // ...held high internally, no pin needed
w.set_br(SpiBaud::DIV_2); // engine speed = PCLK/2, 24 MHz here
w.set_dff(true); // 16-bit data frames
w.set_lsbfirst(true); // the magic setting, next section
});
SPI1.crcr().write(|w| w.set_crcpoly(0x8005));
SPI1.ctlr1().modify(|w| w.set_crcen(true)); // enable the calculator LAST
SPI1.ctlr2().write(|w| w.set_txdmaen(true));
SPI1.ctlr1().modify(|w| w.set_spe(true));
A few lines deserve a note:
- The polynomial register takes the plain non-reflected form,
0x8005. The LSBFIRST bit supplies the reflection, so don’t write the reflected0xA001twin here. - CRCEN goes on last, after the mode bits are locked in. Setting it resets the accumulator, and toggling it off and on again is also how you reset between frames.
- This init runs once at boot and the block stays live for the whole program.
Feeding it is just a normal memory-to-peripheral DMA arm. Point the channel at the SPI data register, 16-bit transfers on both sides, memory-increment on, and enable. To know when the checksum is ready you need all three of these:
fn drained() -> bool {
dma::remaining(CRC_FEED_CH) == 0 // DMA handed over every halfword
&& SPI1.statr().read().txe() // holding register emptied
&& !SPI1.statr().read().bsy() // shifter finished the last one
}
The DMA counter only says the words were handed over. TXE says the holding register drained into the shifter, and BSY clear says the shifter actually finished. Read the CRC register before BSY clears and you get a mid-frame value. Once drained, read TCRCR, flip the bits (next two sections), and that’s your checksum.
Which Mode Computes What
The reference manual is thin on what the CRC unit actually computes, and its description of 8-bit mode (“only the lower 8 bits participate”) can be read at least two ways. So instead of trusting my reading, I fingerprinted the silicon. There is a standard trick for identifying a CRC. Run the string 123456789 through it and look the result up in a CRC catalog.
Some quick background. For any given polynomial there are two families of CRC algorithm. One processes each byte high bit first, and the catalog calls the plain 0x8005 one CRC-16/BUYPASS, check value 0xFEE8. The other processes each byte low bit first (“reflected”), and that one is CRC-16/ARC, check value 0xBB3D. It’s the same polynomial, but the outputs are completely different. Most byte-stream protocols use the reflected family, and it’s no accident, UART hardware puts the low bit on the wire first.
I ran the engine’s modes over known vectors and compared. The winner is 16-bit frames with LSBFIRST set. In that mode each halfword shifts out low byte first, low bit first, which is exactly the bit order the reflected algorithm defines. That means you point the DMA at your byte buffer and do nothing else. The little-endian halfword load and the LSB-first shift line up so that the engine consumes your bytes in natural memory order and computes CRC-16/ARC.
MSB-first mode computes the non-reflected family, but it consumes each halfword high byte first, so a byte stream would need every pair swapped on the way in. That means staging a swapped copy of every frame, which is exactly the kind of busywork I’m trying to delete, and LSBFIRST mode gets rid of it entirely.
This finding actually settled the protocol’s CRC choice. I own both ends of the wire, so when the wire format was on the drawing board, I picked the flavor the silicon computes for free.
Why You Flip the Bits at the End
There is one catch. The value in TCRCR is the right checksum, but mirror-imaged. Feed 12345678 through the engine on the bench. The software CRC-16/ARC of those bytes is 0x3C9D, and the register reads 0xB93C. Write 0x3C9D in binary, reverse all 16 bits, and you get 0xB93C. That’s the fingerprint of what’s going on inside.
The engine’s internal shift register only shifts one direction, and it was laid out for MSB-first work. Run it in LSB-first mode and the data enters mirrored relative to that layout, so the entire computation happens in a mirror. The math is symmetric, so everything stays self-consistent, and the final state is the exact mirror image of the checksum the reflected algorithm would give you. The fix is one 16-bit bit-reversal per frame:
pub fn bitrev16(v: u16) -> u16 {
let mut x = v as u32;
x = ((x & 0x5555) << 1) | ((x >> 1) & 0x5555);
x = ((x & 0x3333) << 2) | ((x >> 2) & 0x3333);
x = ((x & 0x0f0f) << 4) | ((x >> 4) & 0x0f0f);
(x as u16).swap_bytes()
}
Three parallel swap stages plus a byte swap, about 40 cycles, no table needed. It runs once per frame, not per byte, so the cost is basically nothing compared to what the engine saves.
Feeding It From a UART Ring Buffer
The engine’s DMA appetite in 16-bit mode is an even start address and a whole number of halfwords. Real frames land at arbitrary offsets in a ring buffer with arbitrary lengths, so this section is the glue. There are three properties that make it all work.
A leading zero is free. This CRC flavor starts from an all-zero state, and shifting a 0x00 byte into an all-zero state leaves it all-zero. So prepending one zero byte to a feed does not change the checksum. My frames happen to sit in the receive ring right behind a break delimiter that reads as 0x00, so when a frame starts at an odd ring index, the feed just starts one byte earlier on the even 0x00 and the alignment problem goes away. If your frames don’t conveniently carry a zero in front, a memory-to-memory DMA copy into an aligned staging buffer costs about 0.125 µs per byte, still with no CPU in the loop.
An odd tail folds in software. If the covered span has an odd byte count, feed the even bulk through the engine and fold the final byte with the software CRC routine, seeded with the hardware result. Hardware and software are computing the same math, so a software step can continue a hardware sum mid-stream. One byte of software CRC per odd-length frame costs basically nothing.
The accumulator survives across DMA arms. As long as CRCEN stays set, separate DMA arms sum into one running CRC. This is what makes ring buffers workable, since a frame that wraps the ring edge just becomes two feeds. I benched this case specifically (one buffer split into two arms, compared against the same buffer in one arm), because the whole design leans on it.
Between frames, reset the accumulator by toggling CRCEN off and on. One warning from a bug I shipped. Leave the DMA channels alone during that reset. Disabling a channel mid-transfer freezes its counter at nonzero, and then every later readiness check spins to its full timeout. In-flight transfers drain on their own in microseconds if you just let them.
Also, alignment on this chip is stricter than it looks. The V006’s DMA quietly rounds an odd memory address down for 16-bit transfers rather than faulting. There is no error flag, you just get a checksum over bytes one off from the ones you meant. I found nothing about this in the manual and confirmed it on the bench, so build your feeds even-aligned by construction.
The Remap Trap
This trick uses zero pins, but it can still break your board. I know that sounds wrong, so let me explain, because this is the one part that can bite hardware.
Even though no SPI signal is used, the chip’s pin remap machinery still assigns them all a home. On the V006, the reset mapping puts SCK on PC5 and MOSI on PC6, and on my board those are two of the motor’s PWM pins. With the reset mapping in place, every CRC feed would blast a 24 MHz clock and data bits into the H-bridge gate drive. On a servo, that could have easily ended with actual smoke.
I had already been taught this lesson by a different peripheral. An earlier remap choice for the motor timer also placed the timer’s complementary outputs, which this board doesn’t use, and one of them landed on the servo bus pin. A function you never enable still drives its reset-state level through the pin mux. The bus sat clamped low at idle and rose the instant I disabled the timer. The rule I took away is that a remap places functions, used or not.
So the fix is to choose a remap that parks the unused SPI outputs where they are provably inert. On this board that’s remap 101, which puts SCK on PA1 and MOSI on PA2. Both of those run in analog mode here (they belong to the current-sense amplifier), and a pin in analog mode has its digital driver disconnected, so the parked functions physically cannot drive anything. MISO maps to an input, and chip-select never leaves the block thanks to software chip-select mode.
Two pieces of advice from this. In order to pick a safe remap, audit where every function of the remap lands, not just the ones you use. And don’t just trust a register readback to verify it. Prove it by feeding the engine while scoping the parked pins and watching the motor stay quiet. I have watched a debugger’s dump of the remap register disagree with behaviorally proven remap state on this chip, so I only trust the bench on this one.
The Numbers
| software table CRC | SPI engine | |
|---|---|---|
| wall time per byte | 0.635 µs | ~0.36 µs |
| CPU per byte | 0.635 µs | ~0 |
| flash / RAM | 512 B table | none |
| CPU per frame | scales with length | a few register writes + one bitrev16 |
At 3 megabaud the wire delivers a byte every 3.3 µs, so the engine outruns the wire roughly eight to one. The rest of the design leans on that margin. On receive, the engine chews through the frame while the frame is still arriving, so the pass/fail verdict is ready the moment the last byte lands. On transmit it allows something better. The reply starts leaving the chip before its own checksum exists, and the finished CRC is patched into the two trailing bytes before the transmit DMA gets there, because the engine computing it outruns the wire by construction. That pipeline is its own story, and the protocol article tells it.
Caveats
- The SPI block is fully tied up by this. You can’t checksum with it and talk to a flash chip at the same time. On a multi-SPI chip, borrow a block you’re not using.
- The polynomial register is 16 bits, so CRC-16 flavors only. No CRC-32 from this unit.
- There is one accumulator, so one running sum at a time. The firmware serializes RX verification and TX generation per exchange, which in practice costs nothing, but you can’t interleave two frames mid-sum.
- Bound your drain spin and treat an expired spin as a failed CRC. A frame that fails closed retries under the host’s normal contract, and I would much rather drop a frame than risk a wait that can wedge the wire.
- I never resolved what 8-bit mode computes, since 16-bit mode plus the tail fold covered everything. If you fingerprint it on your chip, I’d genuinely love to know.
Try It Out
The production code is small and self-contained if you want the real thing. The engine provider
has the init, feed, and drain logic, and the software flavor
has bitrev16, the tail fold, and the test vectors from this article, including the silicon fingerprint.
More generally, this is a good excuse to go spelunking in your own chip’s datasheet. Peripherals hide little engines like this all over the place, and the register-level features that never made it into the marketing table are usually the fun ones. If you find a CRC unit moonlighting somewhere else, or get this running on a chip I haven’t tried, let me know your results! All is welcome.