back to archive

Making a WCH-Link clone program the unsupported CH32V003

The listing said CH32V003 not supported. The hardware was never the problem, the firmware was, so I replaced it.

A CH32V003 on a breadboard with a lit LED, wired to a WCH-Link clone, beside a laptop running the ch549-link web flasher mid-erase
on this page12 sections

I bought the chip and the programmer in the same order, and I did not read the programmer's product page properly. Not out of carelessness. I had decided to learn this thing one step at a time, starting from the chip, and a programmer is not a step, it is a cable you buy so the steps can start. Chip, breakout board, headers, a WCH-Link. Done, order placed.

The line was sitting there the whole time. "CH32V003 not supported", on the listing for the exact board I had just bought, and I only went back and read it after an evening of the tools refusing to see my target. The same page links WCH's own manual, so I opened that next, hoping the shop was being careful about a clone. Table 6, "Link supported chip model", first row:

Common chip models

WCH-Link

WCH-LinkE

WCH-DAPLink

CH32V003

no

yes

no

CH32V10x / V20x / V30x / CH569 / CH573 / CH583

yes

yes

no

CH32F10x / CH32F20x / CH579 / SWD chips

yes

yes

yes

JTAG chips

no

yes

yes

Not an ambiguous document. The plain WCH-Link is marked as unable to program the CH32V003, and only the WCH-LinkE gets a tick.

The shop and the manufacturer agree, and every guide online agrees with both of them: for a CH32V003 you want the WCH-LinkE. I had bought the other one.

What none of those documents say is why. That turned out to matter, because the reason is a missing software feature, not a missing capability. The board on my desk could do the job the whole time.

You are probably wondering how I got here

This project exists because of LLMs twice over, in opposite directions. My day job is software, and once ChatGPT and Claude got good I was shipping more of it, faster, with my hands on nothing physical. The hardware half of me went quiet for a couple of years. Then it came back, and the same tools are the reason I got anywhere.

It came back through YouTube first: a few months of Bitluni (including a couple of his weekly lives at midnight my time), then Artful Bytes, whose bare-metal playlist covers the parts most tutorials skip. Toolchain, linker script, the SDK inside a Docker image, debugging, CI. Around the same time I was elbow deep in a ₹249 smartwatch, which is where the last few posts came from.

I noticed Bitluni reaching for WCH RISC-V parts on the bigger builds. I learned RISC-V in college and then never touched it again, because every project since has been an ESP32 with the Arduino IDE on top, which is convenient and teaches you nothing. So: buy the cheapest RISC-V chip that exists, learn it from the registers up.

I asked ChatGPT which one that is and it said CH32V003, for the obvious reason that nothing else in the category is that cheap. Then I scraped my usual shop's product pages, pasted the lot in, and had it turn that into a cart. It also wrote out a staged plan, which I kept: make the chip alive, get any program to run, blink an LED, then go down through C to assembly to registers, then peripherals. That last part is the whole reason I picked a chip this small.

The chip it picked is the CH32V003. I ordered a handful from etstore.in with breakout boards, headers, and their WCH-Link clone at ₹245.44. The board has no model name of its own, only the shop's product code, which is the first hint that you are buying an anonymous clone rather than a product with a datasheet. The plan was to work up from the pins: power the chip, detect it, read its ID, erase, write, blink. The programmer was the one item in the cart I gave no thought to, because in that plan it is plumbing. The order arrived while I was at my home town, which gave me a weekend to think about it and not touch it. Back home I soldered a CH32V003F4P6 onto a breakout board with a reset button and a header, plugged the programmer in, and expected to be blinking an LED inside an hour.

The programmer, straight out of the bag. The marking on that SOP-16 is the only thing on the board that tells you what you actually bought, and I did not read it for another week.

The method was one question at a time, in a chat window, with a rule I set at the start: no library until I understood the layer under it. For Linux the answer to "how do I build for this thing" came back as ch32fun rather than WCH's own IDE, which was the right call and the last easy answer I got for a while.

The hour that did not happen

The board enumerates fine:

bash
1a86:8012 QinHeng Electronics WCH-Link

I built minichlink from ch32fun and asked it for chip info:

bash
$ ./minichlink -i
Warning: found at least one WCH-LinkE in ARM programming mode.
Attempting automatic switch to RISC-V. Will need a to re-attempt.
RISC-V command sent (4)

$ ./minichlink -C linke -i
RISC-V command sent (0)

No target, ever. The interesting part is that first message, so I went and read why it prints it. In minichlink/pgm-wch-linke.c, 1a86:8010 means the probe is in RISC-V mode and 1a86:8012 means it is in ARM mode. When the tool sees 8012 it writes four bytes to endpoint 0x02:

cpgm-wch-linke.c
// pgm-wch-linke.c
uint8_t rbuff[4] = { 0x81, 0xff, 0x01, 0x52 };

That is the WCH-LinkE mode switch. The repo's standalone rvmode.py sends exactly the same thing, and it is equally honest about the outcome:

plain
Searching for WCH-Link in ARM mode ...
SUCCESS: Connected to WCH-Link in ARM mode.
Switching WCH-Link to RISC-V mode ...
ERROR: Switching to RISC-V mode unsuccessful!

So the device answers to the descriptor of a WCH-Link and ignores the command a WCH-Link would obey. I did the usual rounds: a udev rule for 1a86:8012 so permissions were not the issue (they were not, and the PID stayed 8012 regardless), and the trick from ch32v003fun issue #227 of holding the button while plugging in. On this board that does not produce a WCH-LinkE in RISC-V mode. It produces no USB device at all, which was my first real clue that I was not holding the hardware everyone's instructions assume.

A detour through a Windows binary

WCH ship a Windows tool, WCH-LinkUtility, that can reflash the probe itself. I do not run Windows, but the package is just files, so I pulled it apart on Linux. Firmware_Link/ holds ten .bin images including FIRMWARE_CH549.bin, which is the first hard confirmation of what this board actually is. strings on the executable is more interesting than the manual:

plain
Set IAP mode...
Changing WCH-Link mode...
WCH-Link is at RISC-V mode!
WCH-Link is at ARM-WINUSB mode!
Failed:Can`t set to IAP!
%s\Firmware_Link\FIRMWARE_CH549.BIN

There is a mode-switch mechanism in there, it is CH549-aware, and it is not the four bytes minichlink sends. So I went looking for it: located the string at file offset 0x366f8c, mapped it to virtual address 0x76838c, found the code that pushes it, and landed two instructions later on a call into the import table.

plain
00432ee0:  mov  $0x76838c,%ecx      ; "Set IAP mode..."
           ...
00432ef8:  call 0x63f92e
0063f92e:  jmp  *0x764798           ; import thunk, resolved at load time

Which means the next step is resolving the import table by hand to find out which USB call that is, and then reconstructing the protocol around it. I stopped. Not because it was impossible, but because I was doing PE disassembly by hand to earn the right to send a packet, and the fun had drained out of it. I was one apt command away from installing Wine and hoping a Windows tool could see my USB device through it.

Stopping a rabbit hole is a technical decision, not a failure of nerve. I had been reverse engineering someone else's permission check instead of asking whether the permission mattered.

What I did instead was write the whole thing down. Everything I had established about the board, every command and its exact output, every procedure that did not apply and why, and a short list of instructions for whoever read it next. Then I handed that file to Claude Code and carried on there. Three of those instructions were the important ones, and they were aimed at me as much as at the model:

plainhandover-01.md
Do not tell me to flash WCH-Link firmware blindly.
Do not assume WCH-LinkE procedures apply to this CH549 clone.
Do not conflate "official WCH-Link supports CH32V003" with
"this clone supports CH32V003".

Writing that took twenty minutes and was the most useful twenty minutes of the project. Not because of the handover. Because listing what I actually knew, separated from what I had assumed, left one line in the "assumed" column that I had never tested: that the stock firmware refuses the CH32V003.

The test that changed the question

Everybody says it, the shop says it, the manual has a cross in the box. Nobody, including me, had put a packet on the wire to see what refusal looks like.

With the probe in RISC-V mode (1a86:8010), you select the target chip with a small command, 81 0c 02 <chip> <speed>, where 0x09 is CH32V003. It replies:

plain
>  81 0c 02 09 01
<  82 0c 01 01        looks like "accepted"

That looks like recognition. It is not. I sent it every chip byte I could think of, including values that correspond to nothing, and the reply never changed. The firmware rubber-stamps whatever you give it. Attach then fails the same way for a genuinely supported 2-wire part as for the CH32V003, because nothing in that exchange ever consulted a list.

So there was no permission check to defeat. There was no secret mode. There was a missing protocol.

What is actually missing

The CH32V003 is debugged over a single wire on PD1. One pin, no clock. The 2-wire SDI that WCH-Link firmware implements on the CH549 needs SWDIO plus SWCLK, and the second pin does not exist on the target. That is the whole of it. The manual itself gives it away two tables later: the pin connection table gives every other chip a SWDIO pin and a SWCLK pin, and lists the CH32V003 against a single pin, PD1. So the cross in Table 6 and the line in the shop listing are both accurate statements about the firmware that ships on the board, which only knows how to talk with two wires. The header is even silkscreened for ARM SWD.

The CH549 is the whole story. Everything else on here is a connector, a switch or an LED.
SWDIO and SWCLK, as the silkscreen promises. The SWIO firmware reuses that SWDIO pad and leaves SWCLK unconnected.

Which raises the only question worth asking: can this hardware bit-bang single-wire SWIO?

Arduino (known working)

CH549 (this board)

ATmega328P at 16 MHz

E8051 core at 48 MHz

62.5 ns per cycle

20.8 ns per cycle

Drives SWIO reliably

3 times finer timing resolution

arduino-ch32v003-swio already bit-bangs this protocol on a 16 MHz AVR. The CH549 runs an 8051 core at 48 MHz, where a nop is exactly one cycle, 20.8 ns. Speed was never the obstacle. The obstacle was that nobody had written the code.

At which point the project stopped being "learn RISC-V" and became "write 8051 firmware so I can start learning RISC-V", which is how these things always go.

Porting SWIO to an 8051

The protocol is pulse-width encoded. Everything is a low pulse on the same wire, and its length is the bit:

plain
T = 1 / 8 MHz = 125 ns

short pulse    T-4T  low    (125-500 ns)
long pulse    6T-64T low    (750 ns - 8 us)
between bits   T-16T high   (125 ns - 2 us)

I measured what my port actually emits, on-chip, with Timer0 clocked at Fsys rather than trusting my nop counting:

Pulse

Total

Low phase

In units of T

short

28 cycles (583 ns)

~10 cycles (208 ns)

1.7T

long

70 cycles (1458 ns)

~50 cycles (1042 ns)

8.3T

inter-bit high

~18 cycles (375 ns)

3T

Comfortably inside the windows, with room on both sides. The parts that were not comfortable were these.

The receive path cannot afford a function call

The target drives each bit low briefly and then releases the line. You release, wait, sample. How long you wait is the one parameter that decides whether any of this works:

Sample delay

Result

minimum (zero extra nops)

works

+2 nops

corrupt reads

more

no link at all

Zero is the only usable value, and it is the default in my firmware. The failure mode is worth memorising, because it reads as the opposite of what it is: if every debug register returns 0xffffffff, you are sampling too late, not too early. The line has already gone back high.

The same budget kills abstraction. I originally had the delay in a small function with a switch inside it. An LCALL, a RET and the dispatch cost tens of cycles, and the link died at every setting. It is a macro now, with a comment explaining why it must stay one.

Push-pull to talk, open-drain to listen, and a resistor that is not optional

Transmit drives both edges actively. Only the receive window switches the pin to open-drain with the internal pull-up, so the target can pull it low, and the code actively precharges the line high before releasing it.

plain
Pn_MOD_OC=0, Pn_DIR_PU=1  ->  push-pull
Pn_MOD_OC=1, Pn_DIR_PU=1  ->  open-drain + pull-up

The internal pull-up is not strong enough on its own. Without an external 1 kΩ to 3V3 on the line you get garbage chip IDs and failed writes, which look like a broken port rather than a weak edge. I confirmed it the boring way, by removing the resistor and refitting it.

Interrupts off, and a delay that lies

SWIO is cycle counted, so a USB interrupt in the middle of a bit corrupts the frame. The transaction sets EA = 0 on entry and EA = 1 on exit. That is about 70 µs of interrupts-off for a 32-bit transaction, which USB survives because the hardware NAKs on its own and the host simply retries.

The subtler one cost me an evening. USBSerial_write() waits for a busy flag by calling delayMicroseconds(5) up to 50000 times, which is meant to be a 250 ms timeout. If your delayMicroseconds() runs short, the wait expires early, the function returns 0, and the byte is silently dropped. One missing byte shifts the entire reply stream, so every value after it is plausible and wrong. I was debugging the wire protocol when the bug was in a timeout.

The toolchain, which is its own small story

SDCC compiles this. Ubuntu's package is 4.2.0; the vendored ch55xduino USB sources need 4.5.0. Rather than discover that again on a different machine, the build runs in a pinned Docker image and CI uses the same Dockerfile, so there is no path where the firmware gets built by a compiler nobody tested it on.

Four SDCC details that each cost time

Use ch5xx.h, not CH549.H. The vendor header is Keil syntax and fails under every SDCC --std- setting. Build with --model-small. --model large pushes locals into xdata, and the SWIO loop slows enough to break timing. Include stdint.h before ch5xx_usb.h, or you get "syntax error: token -> 'uint8_t'". EP*_ADDR is not defined in any source file. The values come from ch55xduino's boards.txt and are passed in as -D.

The host side: write no host software

The firmware could have spoken its own protocol with a matching Python script. It speaks ardulink over USB-CDC instead, which is what the Arduino SWIO project uses, and which stock minichlink already supports. The deciding axis was maintenance, not elegance. A custom protocol means a host tool I have to keep working against every future minichlink change; ardulink means zero host code, forever, and anyone with the standard toolchain can use the board without installing anything of mine.

bash
$ minichlink -C ardulink -c /dev/ttyACM0 -w blink.bin flash -b
Detected CH32V003
Flash Storage: 16 kB
Image written.

One USB-C cable, no adapter, no patched fork.

Getting the firmware onto the probe without Windows

The CH549 has a factory ISP bootloader in ROM. Hold the button as power arrives (the D+ pull-up is sampled only at reset, so holding it after plugging in does nothing) and it enumerates as 4348:55e0. Flashing over it is a vendor-specific bulk protocol, already documented by isp55e0. Because the bootloader is in ROM and cannot be erased, a bad flash is never fatal: unplug, hold button, plug in, reflash.

I reimplemented that protocol in JavaScript on WebUSB, so the probe can be flashed from a browser with no toolchain at all. That is the part I would actually recommend to someone else in my position, who bought the same clone and just wants it to work.

What the flasher does per image

  1. Identify and read config: CHIP_TYPE returns 0x49 for CH549. READ_CONFIG returns the UID, the bootloader version (big endian, unlike everything around it) and 12 bytes of config.
  2. Derive the XOR key from the UID: Flash payloads are obfuscated with an 8-byte key: every byte is the sum of the UID bytes masked to 8 bits, except the last, which has the chip type added to it. SET_KEY replies with a checksum that must match the sum of the key bytes.
  3. Write the config back unchanged: Exactly what READ_CONFIG returned, byte for byte. These bits are the one plausibly unrecoverable setting on the chip, and WCH's own firmware writes state there while running, so they are not even static between reads.
  4. Erase, then write in 56-byte chunks: Ascending offsets, each chunk encrypted. CH549 then requires one final empty write at an offset equal to the image length, which is the kind of detail you only get from someone else's working implementation.
  5. Verify and reboot: Same chunking, same encryption, then REBOOT. The board comes back as 1209:c550.

The flasher also carries WCH's own images, so the board can go back to stock in one click. Working out which images those are took a byte comparison, because the vendor package ships two families that look interchangeable and are not:

plain
WCH-Link_APP_IAP_XXX.bin  ==  [3072-byte IAP loader] + FIRMWARE_XXX.bin
                              0x0000 - 0x0BFF          0x0C00 onward

The WCH-Link_APP_IAP_*.bin images start with WCH's own IAP loader and belong at address 0. The bare FIRMWARE_*.bin images are applications that the IAP loader installs behind itself, and pushing one of those through the ISP bootloader gives you an application with nothing in front of it. Recoverable with the button, pointless to do. The manifest marks those entries as IAP-only and the flasher does not offer them.

That reversibility is the reason I was willing to overwrite the stock firmware at all. The board cost ₹245, but bricking it would have cost a week of waiting for another one.

Hardware is the slow part of iterating on a UI, so the flasher can also be driven against a simulated CH549 bootloader (tools/mockserve.py), including a deliberate mid-flash failure at a chosen offset. The simulator lives outside the dist build, so it never ships.

Step 1. The board in bootloader mode does not even have a name, just its manufacturer.
Step 2. Everything the factory bootloader will tell you, plus a note explaining why it cannot tell you which firmware is installed.
Step 3. The SWIO debugger and the two vendor images, each one an address-0 image that carries its own loader. Going back to stock is the same four steps.
Step 4. Erase, write, verify, reboot, then the command to use it and the reminder about the pull-up.

Chrome, Edge or Opera only, since WebUSB does not exist in Firefox or Safari.

The feature I deleted

The flasher originally tried to tell you which firmware was currently installed. Useful: you plug in a board of unknown provenance and it says "this is stock WCH-Link RISC-V" or "this is the SWIO debugger".

The bootloader has no read-flash command. The only window into flash contents is VERIFY_CODE, which answers one bit per chunk: does this match. Replay each known image, see which one is accepted. Clean idea, and it worked in testing.

Then it started reporting confident nonsense. On a board I knew was running WCH-Link_APP_IAP_RV.bin:

Sequence, one session unless noted

Result

rv alone, fresh power-up

match, all 45784 bytes

rv, rv, two successful verifies

both match

swio (a genuine miss), then rv

rv rejected at offset 0

... plus SET_KEY in between

still rejected

... plus CHIP_TYPE, READ_CONFIG, SET_KEY in between

still rejected

... plus a USB port reset

still rejected

... from a brand-new host process

still rejected

unplug and replug

truthful again

The first rejection latches. Not a session limit, since two successful verifies in a row are fine; it is specifically a failed compare that poisons every later compare until the chip is power-cycled. So a scanner that checks three images in list order can only ever report the first one it tries, and if that one misses, everything after it is a guaranteed false negative dressed up as evidence.

The first verify after power-up tells the truth. After one miss, the chip just says no to everything, and a list-order scan turns that into a confident wrong answer.

ERASE_CODE clears the latch, which is why normal flashing never sees this: a full erase, write and verify on a latched session works fine. Only the read-only identify path is impossible.

So I deleted it. What replaced it costs nothing: firmware that is running already announces itself in the USB descriptor.

USB ID

What it is

4348:55e0

factory ISP bootloader, button was held at power-up

1209:c550

this project's SWIO debugger

1a86:8010

stock WCH-Link, RISC-V mode

Plug the board in without holding the button and read the ID. No bootloader involved, nothing to latch.

Where these boards come from

Calling mine a clone suggests something furtive, and it is not. WCH publish a reference schematic for the WCH-Link, and hobbyists in China have been publishing their own versions of it for years. en.eeworld.com.cn alone carries several, and almost all of them ship the full set: schematic, PCB files and a firmware binary.

Design 54127. The silkscreen spells out the rule the stock firmware follows: blue LED on is ARM mode, off is RISC-V mode, hold the mode button while powering up to switch.
Design 78958. Same chip, different outline, and a DOWNLOAD button wired to the same ISP entry the web flasher uses.

Reading those was the point where the board stopped feeling like a mystery product. The hardware is documented, the pinout is fixed by WCH's own schematic, and every one of these builds inherits the same CH549 and therefore the same two-wire limitation. Nobody is hiding anything. The capability gap is entirely in the binary, and the binaries are sitting on those pages too.

It also means the firmware in this project is not locked to the board I happened to buy. Any of these designs, or any other board with a CH549 on it and the SWDIO pad wired to P1.1, can run it.

If you are buying one of these

The useful version of this post, for anyone about to place the same order: do not buy the board I bought. Not because it cannot be made to work, this whole post is about making it work, but because the two alternatives cost the same or less and need none of it.

Board

Price

Notes

WCH-Link, looks like the genuine article

₹610

I have not bought this one, so "looks original" is as far as I will go

The cheaper copy

₹235

Cheaper than the board I ended up with

The one I bought

₹245.44

CH549 inside, needs everything in this post

Read that second row again. The board that does the job out of the box was ten rupees cheaper than the board that does not. I did not save money by picking wrong, I just bought a different thing while thinking I was buying a programmer.

If you already own the CH549 one, that is what the flasher at the end of this post is for.

What the chat window was and was not good for

I started this to learn RISC-V from the registers up, and I did most of it with an LLM open in the next window, which some people would say is the same as not learning it. Worth being specific about instead of defending in the abstract.

It was good at the parts that are lookup with a shape: which cheap chip fits the goal, what a BOM should contain, what 81 0c 02 09 01 means in WCH's scheme, where the CH549's port mode bits live, what an 8051 does to your locals under --model-large. Protocol archaeology, where the answer exists in scattered sources and someone has to assemble it, is genuinely where these things earn their keep. Porting a known-good SWIO implementation onto a different core also went fast, because the hard part there is the timing, and the timing I measured on hardware rather than asked about.

It was bad in exactly one shape, and it is the expensive one: it will help you go deeper into the wrong hole, enthusiastically, for as long as you keep asking. Nothing suggested I stop disassembling the Windows tool. Nothing suggested I test the claim I had accepted. Both of those came from me writing down what I knew and noticing a gap. The guardrails in my handover file exist because the default answer to "this programmer will not detect my chip" is to flash something at it, and on a board whose bootloader you have not yet understood, that is how you turn a puzzle into a paperweight.

So: it compressed the weeks of reading into days, and it had nothing to do with the two decisions the project turned on. That seems like the honest split.

Where it stands

The swio firmware detects, writes, verifies and runs code on a CH32V003, driven by unmodified minichlink. The repo is structured so a second debugger is a folder under firmware/ plus an entry in the manifest generator, and the web flasher picks it up with no UI changes. A Telink SWS debugger is the obvious next one, since I have the smartwatch parts sitting right there.

Two things I would tell myself at the start. First, test the claim before reverse engineering the enforcement: an hour spent proving the firmware rubber-stamps every chip byte would have saved an evening inside a PE binary. Second, "unsupported" on a cheap board is a statement about what someone chose to write, and the gap between that and what the silicon can do is often just a weekend.

I set out to learn RISC-V and spent the whole time writing 8051 firmware. The programmer works now, so there is no excuse left. Time to actually sit down with the CH32V003.

e-labInnovations/ch549-link

Browser-based firmware flasher that turns a WCH-Link debug probe into a single-wire CH32V003 programmer.

ch32v003ch549wchlink
C 0 0MIT
CH549-Link
appv1.0.0
CH549-Link

Browser-based firmware flasher that turns a WCH-Link debug probe into a single-wire CH32V003 programmer.

Credit where it is due: ch32fun / minichlink for the host tooling, arduino-ch32v003-swio for the SWIO implementation this is a port of, ch55xduino for the SDCC USB-CDC stack and CH549 headers, and isp55e0 for the ISP protocol.

Mohammed Ashad

Browse the archive for more, or subscribe to get new posts in your inbox.

Discussion 0 comments

Be kind. I read everything but might take a day or two to reply.

no comments yet — be the first
// related

More from this category