By Armstrong Subero | rvembedded.com

I recently ported Apache NuttX RTOS to the WCH CH32V307, a RISC-V microcontroller running the QingKe V4F core at 144MHz. As far as I can tell this is the first time NuttX has been brought up on any WCH chip. The result is a fully POSIX-compliant "Linux Lite" environment running on a $3 RISC-V MCU. If you never worked with NuttX let me tell you it blows Zephyr and FreeRTOS out of the park. With NuttX you get a full POSIX compliant shell running on a microcontroller, and it makes writing applications and debugging much, much easier.  I recently had the pleasure of porting a STM32H7IIT6 chip to NuttX for a product I was working on, I simply had to clone an exsiting port, change a few register definitions, run some tests and that was it. For the CH32V307 though? 

It was not straightforward.

The CH32V307 is a capable chip, and considering the price point it is arguably one of the best RISC-V chips on the market. It has 256KB Flash, 64KB RAM, USB HS with built-in PHY, 10M Ethernet with integrated PHY, CAN, 8x UART and more. On paper it looks like an STM32F2 competitor. Under the hood though WCH did something that makes porting any standard RISC-V RTOS a real adventure: they basically took their ARM Cortex-M silicon design and swapped in a RISC-V CPU core. It's extremely easy to swap it in once you had the pleasure of working with an ARM part before and the result is a chip that looks like an ARM MCU to the peripheral side and looks like RISC-V to the software side but the interrupt controller is neither standard RISC-V nor standard ARM. Thats where the fun begins.

 

The PFIC: Not a PLIC, Not a CLIC, Not Quite an NVIC

The single biggest challenge in this port was the Programmable Fast Interrupt Controller (PFIC). Standard RISC-V chips use either a PLIC (Platform-Level Interrupt Controller) or a CLIC (Core-Local Interrupt Controller). NuttX has well-tested support for both. The CH32V307 uses neither.

WCH's PFIC is essentially a reimplemented ARM NVIC mapped at 0xE000E000, the same base address as a Cortex-M NVIC. It has enable set/clear registers, pending set/clear registers, priority registers, all laid out almost identically to what you'd see on an STM32. But its connected to a RISC-V core that expects standard RISC-V interrupt semantics.

This means:

  • Theres no PLIC claim/complete cycle. The PFIC auto-clears pending on entry similar to how the ARM NVIC works.
  • mcause directly gives you the interrupt number. No indirection through a claim register.
  • The vector table uses jump instructions at each entry, more like ARM than standard RISC-V trap handling.

For NuttX which expects either PLIC or CLIC semantics in its RISC-V arch layer this meant I couldn't just configure an existing interrupt path. I had to write a new one from scratch.

 

The mret Trap: Where Interrupts Go to Die

Heres a quirk that cost me hours. The QingKe core tracks an internal "in interrupt" state. When an interrupt fires the core sets this state. To properly exit an interrupt you MUST execute mret. You can't just re-enable MIE in mstatus and return, the core will think its still servicing an interrupt and the next interrupt that fires will corrupt state.

NuttX's exception_common handler ends with mret so this is handled... mostly. The problem came from MPIE (Machine Previous Interrupt Enable). On standard RISC-V when you enter an interrupt handler the hardware copies MIE to MPIE and clears MIE. When mret executes it copies MPIE back to MIE effectively re-enabling interrupts. I was looking at how the ESP32C3 and the SiFive port handled things but the CH#2V307 was a bit trickier.

On the CH32V307 if you let mret re-enable interrupts at the wrong time, specifically before NuttX's scheduler has finished its context switch, you get random memory corruption on task switches. The fix was adding a trampoline in the assembly startup that clears MPIE before entering exception_common:

_irq_common:
  /* Clear MPIE so mret won't auto-re-enable interrupts */
  li      t0, (1 << 7)      /* MPIE is bit 7 of mstatus */
  csrc    mstatus, t0
  j       exception_common

 

NuttX's scheduler handles interrupt re-enablement via the restored thread's saved mstatus, not via MPIE. This issue was also documented by Matthew Tran in his Zephyr port and confirmed in several FreeRTOS ports. Its a fundamental WCH quirk that will bite anyone porting an RTOS to these chips.

 

The ecall Problem

Another WCH-specific behaviour: ecall can fire even when interrupts are disabled unless you explicitly clear the INESTEN bit in the INTSYSCR register. On standard RISC-V ecall is a synchronous exception and should only fire when software executes the ecall instruction. On WCH's core the preemption behaviour is different.

NuttX uses ecall for system calls. If ecall can preempt at unexpected times you get re-entrant exception handlers and stack corruption. The fix was straightforward once identified, disable hardware nesting in the startup code.

 

The PLL Trap: D8 vs D8C and Why Your Baud Rate is Wrong

When I first got UART output it was garbage. Turned out the CH32V307VCT6 is a D8C variant where the PLL multiplier lookup table is completely remapped compared to the D8 variant. On D8, a PLL multiplier of x18 uses field value 0xF. On D8C, x18 is field value 0x0. Using the wrong encoding gives you 128MHz instead of 144MHz and every peripheral clock derived from it is wrong. Wrong baud rates everywhere, and I couldn't even start diagnosing the real interrupt problems until the serial output was clean.

 

SysTick: The Interrupt That Wouldn't Fire

After getting basic boot working I hit a wall with SysTick. NuttX needs a system tick timer for scheduling. The CH32V307 has a SysTick peripheral built into the PFIC at the same offset as an ARM SysTick.

I configured STK_CTLR with all the right bits (STE, STIE, STCLK, STRE), verified the reload value was correct, confirmed the counter was running by reading STK_CNTL but the interrupt never fired.

The problem? SysTick on WCH sits at PFIC vector 12. On most RISC-V chips internal interrupts like SysTick are controlled purely through CSRs (mie, mip). On the CH32V307 you need to enable them through the PFIC's IENR registers AND configure the CSRs. I was doing one but not the other.

The real breakthrough came from digging into WCH's SDK core_riscv.h where I found:

 

RV_STATIC_INLINE void __enable_irq()

    {

        __asm volatile ("csrw 0x800, %0" : : "r" (0x6088) );

  }

CSR 0x800 is a WCH-specific register, GINTENR (Global Interrupt Enable Register). Its not in any standard RISC-V spec. Writing 0x6088 to it enables interrupts, writing 0x6000 disables them. Without writing the correct value to this non-standard CSR no interrupts would fire regardless of mstatus.MIE or PFIC configuration.

 

Getting to 144MHz

The CH32V307 boots from an 8MHz internal HSI oscillator. To hit the full 144MHz you need to configure the PLL chain: enable HSE, wait for ready, configure PLL with HSE as source multiply by 18 (8MHz x 18 = 144MHz), set Flash wait states BEFORE switching to higher clock, set AHB/APB prescalers, enable PLL, wait for lock, switch SYSCLK source to PLL.

Getting this wrong gives you a chip that either runs at the wrong frequency (subtle timing bugs in UART, SPI, everything) or hard locks entirely. The clock tree is very similar to the STM32F1/F2 series which isn't surprising given WCH's design heritage.

 

The NuttShell Moment (pun intended) 

After about a week of debugging, reading Matthew Tran's Zephyr blog posts, studying RT-Thread's BSP, staring at PFIC register dumps over UART and writing a lot of bare-metal debug prints I got this on the serial console:

[START] NuttX CH32V307 boot

[BSS] cleared

[DATA] copied

[CLK] 144MHz PLL

[BOOT] nx_start

NuttShell (NSH) NuttX-12.x.x nsh>

POSIX-compliant NuttX running on a WCH RISC-V chip. Full shell, file system support, task management, the works. A "Linux Lite" on a $3 microcontroller.

 

What This Means for the CH32V Ecosystem

NuttX brings something unique to the CH32V platform. Unlike FreeRTOS (minimal POSIX), Zephyr (different API model) or RT-Thread (primarily Chinese documentation), NuttX gives you full POSIX compliance, mature file system support, a real network stack (relevant for CH32V307's built-in Ethernet), a well-established driver model and an active Apache project with global community.

The CH32V307 is a seriously capable chip for its price point. Adding NuttX support opens it up to developers coming from Linux embedded who want the familiar POSIX API without the overhead of a full Linux kernel.

 

Update — February 18, 2026: The source code for this port is now publicly available on GitHub: github.com/ArmstrongSubero/nuttx-ch32v307. This includes the complete PFIC driver, clock configuration, UART driver, SysTick timer, startup assembly with the MPIE trampoline fix, and board support for the CH32V307-EVT. An upstream pull request to Apache NuttX is in progress.

 


Armstrong Subero is an embedded systems engineer and author of four books with Springer/Apress including Programming PIC Microcontrollers with XC8 (1st and 2nd editions), Programming Microcontrollers with Python and Codeless Data Structures and Algorithms. He specializes in RISC-V embedded systems and runs rvembedded.com.