An Arduino core for the WCH CH32H417, implementing the ArduinoCore-API and building under PlatformIO. Baremetal on the WCH SDK, no RTOS.
Board: CH32H417QEU6-R0-1v1 (chip CH32H417QEU8).
This part has two RISC-V cores. The V3F (100 MHz, in-order) boots first; the V5F (400 MHz, out-of-order, 32 KB I-cache) is where your sketch runs. The V3F configures the clock tree, brings up a console, wakes the V5F and parks in Stop mode.
Both cores live in one ELF. Both prior ports to this silicon ship two images
merged by a script, because WCH's stock startup files for the two cores define
the same ~150 symbols and collide at link time. This core writes its own
startup with prefixed symbols, so one link produces one .bin.
setup() and loop() run on the V5F. setup1() and loop1() run on the V3F,
as in arduino-pico — though
the assignment is the other way round there, because here the boot core is the
slow one and a sketch that says nothing about cores should get the fast one.
Define neither and the V3F sleeps, which costs a single-core sketch nothing.
CH32H4.fifo carries words between the cores and CH32H4.mutexLock() wraps the
hardware semaphores.
| Region | Base | Size | Use |
|---|---|---|---|
| ITCM | 0x200A0000 |
128 KB | measured-hot code only, via __itcm_func |
| DTCM | 0x200C0000 |
256 KB | .data, .bss, stack, fast heap |
| Shared SRAM | 0x20100000 |
512 KB | DMA buffers, bulk heap |
| Flash | 0x08000000 |
960 KB | 32 KB V3F stub, 912 KB image, 16 KB EEPROM |
Code runs XIP from flash, behind the V5F's 32 KB instruction cache — which is
disabled at reset and which this core enables, worth a measured 145x. About
708 KB of heap is available to sketches:
_sbrk hands out DTCM first — zero-wait at 400 MHz — then continues into the
shared region, and newlib's allocator opens a second segment at the gap.
ITCM holds the V5F's trap vector table, which has to be in RAM for correctness
rather than speed, plus whatever is marked __itcm_func — currently
digitalWrite, digitalRead, millis, micros, delayMicroseconds and the
EXTI dispatch. See "Known gaps" for why that placement still matters.
All seven milestones are implemented, and everything below marked verified has been exercised on a CH32H417QEU6 with the SD card, Ethernet, an I2S amplifier and a WCH-Link attached.
| Milestone | State | |
|---|---|---|
| M1 | Boot, clocks, single ELF, core Arduino API | verified |
| M2 | Adafruit TinyUSB, Serial as USB CDC |
verified |
| M3 | Wire, SPI, EEPROM, Servo, Tone, I2S, ADCInput, DAC | verified — see the gaps below |
| M4 | setup1()/loop1() on the V3F, FIFO, HSEM mutexes |
verified |
| M5 | SD block layer, FatFs R0.16, the FS/File API, the classic SD shim |
verified |
| M6 | lwIP 2.2.1, on-chip Ethernet, TCP/UDP client and server, SNTP | verified |
| M7 | mbedTLS 3.6.7, AES on the ECDC block, entropy from the TRNG | verified |
WebServer and HTTPClient, over any Arduino Client |
verified | |
HTTPS: EthernetServerSecure, WebServerSecure, mutual TLS |
verified | |
RTC on LSI, LSE and HSE, wired into gettimeofday() |
verified | |
| LittleFS in the flash tail, and the EEPROM above it | verified | |
| Arduino IDE / arduino-cli build, networking and TLS included | verified | |
| Debugging both cores over OpenOCD and GDB | verified — see the gaps below |
python -m pytest tests runs all 382: 109 host-side — the tree, the
linker layout, the Arduino IDE build description, the debug configuration, the
Boards Manager index and a matrix of build configurations — and 273 on
hardware, against a connected board. The
hardware half is tests/hw; --ignore=tests/hw leaves the host-side half,
which needs no board. The hardware suite reprograms the part seventeen times —
once per sketch — and takes about seven minutes.
29 of the hardware tests skip on this bench: they are the SPI-slave and I2C-slave suites, which need jumpers between two of the chip's own peripherals that are not currently fitted.
Four things, three of them limited by the bench rather than by the code:
- I2S receive and slave mode. Receive needs a microphone and slave mode needs an external clock, and neither is attached. The transmit path is verified on both I2S blocks; the receive half-word ordering is carried from the MicroPython driver's measurements rather than re-measured here.
- Whether the I2S output sounds right. The tests confirm the divider, the DMA throughput and the underflow count, all of which are independent of the sample values — so the whole suite runs on silence. Judging the audio needs ears or a scope.
arduino-cli debugon Windows. OpenOCD and GDB were verified on hardware over TCP, on both cores, which is how the Arduino IDE drives them. Thearduino-cli debugsubcommand starts the server through GDB as a pipe instead, and that fails in the toolchain's GDB and then, with a GDB that can spawn one, inside OpenOCD. Neither is this core's to fix;debug/README.mdhas both diagnoses and the manual equivalent.- The Arduino IDE's own debugger UI. The mechanism it uses was read out of
the shipped
cortex-debugbundle and reproduced exactly — two consecutive ports,-c "gdb_port N", GDB on the one fortargetProcessor— and that reproduction works on hardware for both cores. Clicking Debug in the IDE itself has not been tried. - The ADC's and DAC's absolute accuracy. Rate, throughput, channel ordering, the internal reference and the die temperature are all checked, and the DAC is checked end to end by reading its own pad back through the ADC — PA4 is ADC4 and PA5 is ADC5, so that needs nothing wired. But both converters carry their own error, so this is not a linearity measurement, and there is no calibrated source on the bench to make one against.
Everything else that was once a gap is now covered by a test, including the negative cases that matter most — a TLS client that accepts a certificate from the wrong CA passes every positive test there is.
git clone --recursive https://github.com/Community-PIO-CH32V/arduino-core-ch32h4.gitlibraries/Adafruit_TinyUSB_Arduino is checked out on a fork branch
(ch32h417) that upstream Adafruit does not have. Upstream bundles TinyUSB
0.20, which has neither OPT_MCU_CH32H417 nor the USBFS device driver for this
part, so the library cannot build for this silicon at all. The fork swaps in
0.21 from maxgerhardt/tinyusb
(branch ch32h417-v2) and adds an ARDUINO_ARCH_CH32H4 branch to its
tusb_config.h dispatcher.
.gitmodules points at that branch, so git submodule update --init gets it.
Checking out upstream instead stops the build with "TinyUSB Arduino Library
does not support your core yet" — which is the dispatcher saying exactly
this.
--recursive matters: system/ch32h417lib carries the vendor SDK and
ArduinoCore-API the upstream API. python tools/check_tree.py says so plainly
if either is missing.
Then point a PlatformIO project at it:
[env:ch32h417]
platform = https://github.com/Community-PIO-CH32V/platform-ch32v.git#feature/ch32h4-arduino
board = ch32h417qeu6_evt_r0
framework = arduinoThe two LEDs need a jumper. They are fitted but not wired to the
microcontroller: LED1 sits behind a header pin under PE2 and LED2 under PE3, so
LED_BUILTIN (which is LED1) drives a pin connected to nothing until you
bridge one of them. Both pins have another default job — PE2 is SPI4's clock
and PE3 is what the SPI-slave test uses as chip select — so a board with the
LED jumpers on cannot also run SPI4 as a slave on the default pins.
Serial is USB CDC by default and Serial1 is USART1 on PA9/PA10. To swap
them — worth doing while debugging anything that can fault before USB
enumerates:
board_build.serial = uartC++ exceptions are a build option, off by default:
board_build.exceptions = enabledBoth settings build, link and run; a throw is caught on hardware. Enabling
costs roughly 30 KB of unwind tables, which stay in flash.
Link-time optimization is on by default, and worth 3–7% of flash — 24 KB off a TLS sketch. Turn it off for a backtrace that has not been inlined flat, or to rule LTO out as the cause of something:
board_build.lto = disabledNetworking and TLS are not options, and used to be. lwIP is
libraries/lwip and mbedTLS is libraries/mbedtls — ordinary Arduino
libraries, resolved because a sketch included LwipEthernet.h or
EthernetClientSecure.h, the same way any other library is. There is nothing
to set:
#include <LwipEthernet.h> // a TCP/IP stack
#include <EthernetClientSecure.h> // and TLS on top of itA sketch that includes neither pays for neither — no flash, no compile time —
and the link matrix asserts exactly that: minimal, ethernet and webserver
must contain no mbedTLS at all.
board_build.network and board_build.tls are gone. What kept them was that
both libraries needed a configuration header visible to every translation
unit including the sketch's — MBEDTLS_CONFIG_FILE, and lwIP's lwipopts.h
having to precede lwIP's own include directory — and a define that global can
only come from the build. Both forks carry their configuration where the
library itself looks for it, so nothing has to be told anything.
The forks exist for one more reason: arduino-cli compiles every directory under
a library's src/ recursively and cannot be told to skip one, and upstream
mbedTLS ships programs/ full of main(). Each ch32h4 branch prunes what
this core does not build and flattens the rest into the single include root an
Arduino library gets. README-ch32h4.md on each branch says what was changed
and why.
One thing that catches people, and is not a bug in the library: a board
cannot connect to its own address. lwIP here is built without
LWIP_NETIF_LOOPBACK, so a packet addressed to the board leaves by the
Ethernet port and nothing sends it back; the connection is refused. A sketch
that is both client and server would deadlock in any case, since handleClient()
only runs from loop().
Boards Manager. Add this to File ▸ Preferences ▸ Additional boards manager URLs, then install CH32H41x Boards:
https://raw.githubusercontent.com/Community-PIO-CH32V/ArduinoCore-CH32H4/main/package_ch32h4_index.json
That is the whole installation. The compiler, WCH's OpenOCD, wlink and — on
Windows — a Python for the two prebuild hooks all come down with it, declared
as tools in package_ch32h4_index.json. Nothing needs to be on PATH
beforehand. On Linux and macOS the hooks use python3 from PATH, which is
what arduino-pico does as well.
The index is generated by tools/makeindex.py, which computes every checksum
from the bytes it is describing rather than from a promise — it asks
PlatformIO's registry for the ones it already publishes and downloads the rest.
tools/makearchive.py builds the platform zip, from
git ls-files --recurse-submodules, because GitHub's own source archives
contain no submodules and five of this core's dependencies are submodules.
From a checkout, instead:
platform.txt and boards.txt build the same core through arduino-cli and the
IDE. Put the checkout where the IDE looks for third-party hardware —
<sketchbook>/hardware/ch32h4/ch32h4 — and the board appears as
ch32h4:ch32h4:ch32h417qeu6, with menus for the USB stack, the serial port,
C++ exceptions, link-time optimization and the filesystem size.
Networking and TLS work here too, and did not until lwIP and mbedTLS became
libraries: arduino-cli compiles cores/, variants/ and the libraries a
sketch includes, and nothing else, so a stack living under system/ was
invisible to it. A sketch that includes <LwipEthernet.h> and
<EthernetClientSecure.h> compiles in the IDE with nothing configured.
One thing the IDE cannot do for itself, so platform.txt runs them as
prebuild hooks, both needing Python on PATH:
tools/simplesub.pysubstitutes the chosen filesystem size into the variant's linker script, the same value PlatformIO's build script writes.tools/build_sdk.pycompilessystem/ch32h417libintolibch32h4sdk.a. arduino-cli only compilescores/andvariants/, and the vendor SDK is in neither. arduino-pico solves the same problem by committing a prebuiltlibpico.a; building it keeps binaries out of the repository and keeps the SDK on the same flags as everything else, including the ones the menus change.
boards.txt is generated by tools/makeboards.py — edit that and re-run it.
The generated file is committed, because the IDE reads it directly.
A checkout has no packaged toolchain, so compiler.path resolves to
{runtime.tools.riscv-wch-elf-gcc.path}, which does not exist until the core
is installed through a board manager index. For a local build, drop a
platform.local.txt next to platform.txt pointing at a copy of the
toolchain — PlatformIO's is the same xPack GCC 12.2.0:
compiler.path=C:/Users/you/.platformio/packages/toolchain-riscv/bin/
It is gitignored.
WCH's OpenOCD fork -- upstream has no wlinke adapter driver and no
wch_riscv target -- and riscv-wch-elf-gdb from the toolchain that built the
image. Both cores are debuggable, against the same ELF, which is what the
single-image design buys.
Which core is chosen by the Programmer, not by a board menu. Tools ▸
Programmer, or arduino-cli debug -P wchlink_v3f:
| Programmer | core |
|---|---|
wchlink_v5f (default) |
V5F — setup(), loop(), the sketch |
wchlink_v3f |
V3F — setup1(), loop1(), the boot stub |
One OpenOCD script brings up both cores; the programmer sets cortex-debug's
targetProcessor, and the debugger connects to that core's port. A menu would
have worked too, but a menu entry is part of the FQBN and would rebuild the
sketch on every switch, for a choice that does not change a byte.
debug/README.md is worth reading before changing anything there. Three
things in that file look like the lever that selects a core and are not, and
the port numbers are not 3333 — the IDE allocates a free pair somewhere in
50000–52000, so pinning a port in the OpenOCD script breaks it.
The session attaches rather than launches: the V5F is started by the V3F, not by the reset vector, so a front end that resets the part and expects to find the V5F running finds it halted at nothing. Upload first, then attach.
The order is Optimize for Debugging → Upload → Debug, and the IDE will not do the middle step for you: its Debug button neither builds nor uploads, and attaching to a stale image gives source that does not match the addresses on the board. Re-upload after toggling the option, which changes the binary.
Build with Optimize for Debugging (arduino-cli compile --optimize-for-debug) to get -Og -g3 instead of -Os. Without it there is
no debug information at all: a breakpoint on a function still works, and
nothing else does — no source lines, no locals, no arguments. With it, a
breakpoint in loop() shows the sketch's own line, and stepping into
delay() shows ms=1000.
arduino-cli debug itself does not work on Windows, and the configuration is
not the reason. It starts the server through GDB as a pipe, and that path is
blocked at both ends: the toolchain's GDB cannot spawn a pipe child at all
(it fails the same way on cmd /c echo hi), and a GDB that can — MounRiver's
17.1 — only moves the failure into OpenOCD, which exits without a message. The
same GDB drives this core over TCP perfectly. Start OpenOCD yourself and
connect to it; debug/README.md has the four lines.
python -m pytest tests --ignore=tests/hw -v # host-side: tree, linker, build
python -m pytest tests/hw -v # on hardware, over the console
python -m pytest tests -v # bothThe hardware suite flashes the board itself and needs the WCH-Link's VCP;
override the port with CH32_PORT. It checks the board boots once, before any
test runs, and ends the session if it does not — otherwise a dead board costs
every test its full timeout.
Most of this silicon's failures produce no error at all: a wrong RCC bus reads
back as zeroes, an alternate-function pin without its mux runs perfectly and
puts nothing on the wire, erased flash reads 0xE339E339 rather than 0xFF,
and --specs=nano.specs makes every throw reach std::terminate after a
clean link. docs/superpowers/specs/ records them and the design that routes
around them; the prior ports' PORTING.md and STATUS.md are the deeper
source, with three corrections noted in the spec.
The code written for this core is MIT, copyright the Community-PIO-CH32V
contributors. See LICENSE.
Most of what is in this repository is not that. The core is built on
ArduinoCore-API and bundles a network stack, a TLS library, two filesystems,
a USB stack and the vendor's peripheral SDK, each under its own terms —
LGPL-2.1 for several of them, so the repository as a whole is not
redistributable under MIT alone. The vendor SDK under system/ is
restricted by WCH to their own microcontrollers and is not a free-software
licence at all.
THIRD-PARTY.md lists every bundled component, grouped by what its licence
obliges you to do.