The CH32V307 is WCH's flagship RISC-V microcontroller, at least it was before we got the CH32H4 devices, however it's still quite powerful, a QingKe V4F core running at 144 MHz with 256 KB flash, 64 KB RAM, hardware FPU, Ethernet, and USB. It's a capable chip, but WCH's official toolchain is MounRiver Studio, a patched Eclipse IDE with a custom GCC 8.x that includes WCH-specific extensions.

I'm building Rovari Studio, a custom IDE for RISC-V embedded development as part of the Rovari Platform. It uses standard GCC 15.2.0 (riscv-none-elf-gcc) instead of MounRiver's patched compiler. The target: get FreeRTOS, LVGL v9.5, and a capacitive touch panel (FT6336U) all running together on the CH32V307, compiled with a stock toolchain. The idea was that users simply click a box and it all works nicely. 

 

The Hardware

  • CH32V307 (RV32IMAFC, 144 MHz, 256K/64K)
  • ST7796S 480x320 TFT display (SPI + DMA)
  • FT6336U capacitive touch controller (bit-bang I2C)
  • FreeRTOS v10.4.6 from WCH EVT
  • LVGL v9.5.0
  • GCC 15.2.0 riscv-none-elf

So in all to get them nicely, there were six fixes I had to look at: 

 

Fix 1: The Missing Startup Bit (0x7880, not 0x7800)

This was the hardest bug. It took hours to find and the root cause was a single missing bit.The WCH startup assembly configures mstatus before jumping to main(). The original Rovari startup used the WCH EVT pattern:

 

li t0, 0x6088

csrs mstatus, t0  /* bit-SET: preserves existing bits, adds new ones */

 

This sets FS=01 (FPU initial), MPIE=1, and MIE=1 (global interrupts on). The csrs instruction preserves whatever the hardware set at reset and adds these bits on top. FreeRTOS crashed with this startup because MIE=1 means interrupts fire during xTaskCreate, before FreeRTOS has initialized its data structures. The heap allocation gets corrupted by a TIM7 interrupt mid-operation.

The WCH EVT FreeRTOS example uses a different pattern:

 

li t0, 0x7800

csrw mstatus, t0 /* WRITE: replaces all bits */

 

The csrw does a clean write. 0x7800 = FS=11 (FPU dirty), MPP=11 (machine mode). FreeRTOS worked with this! But then touch stopped working. And LVGL's timer callbacks never fired. And millis() returned 0 forever. The problem: 0x7800 in binary is 0111 1000 0000 0000. Bit 7 (MPIE) is not set. On RISC-V, the mret instruction copies MPIE to MIE. If MPIE=0, then after mret, MIE=0. Global interrupts are never enabled.

FreeRTOS appeared to work because xPortStartFirstTask() enables interrupts internally before the first task runs. But TIM7 (the millis/micros tick), LVGL's lv_tick callback, and every other interrupt-driven peripheral silently failed. The WCH Delay_Ms() function masked this problem beautifully. It uses SysTick in polling mode (yes its true!!, spinning on a status register flag), not interrupts. So lcd_init() and touch_init() worked fine with their hardware reset delays. The display rendered. Touch init completed. Everything looked normal. But nothing interrupt-driven worked.

I only discovered this when LVGL touch stopped responding. LVGL uses lv_tick_set_cb(millis) for its time source. With millis() stuck at 0, LVGL thought no time had passed. It never polled the input device. Touch callbacks never fired.

The fix: 0x7880. One bit. MPIE=1. After mret, MIE=1. Everything works.

 

li t0, 0x7880

csrw mstatus, t0

 

Bit field breakdown:

  • Bits 14:13 (FS) = 11: FPU enabled
  • Bits 12:11 (MPP) = 11: Machine mode
  • Bit 7 (MPIE) = 1: This is the one that was missing
  • Bit 3 (MIE) = 0: Interrupts off during startup (safe, enabled by mret)

 

Fix 2: ABI Override (ilp32, not ilp32f)

The CH32V307 has a hardware FPU, so Rovari Studio compiles with -march=rv32imafc -mabi=ilp32f by default. MounRiver compiles FreeRTOS with -march=rv32imacxw -mabi=ilp32 (soft float ABI, plus WCH's proprietary xw compressed instruction extension).

The FreeRTOS PFIC port was written for ilp32. The context switch in portASM.S only saves integer registers. With ilp32f, function arguments use FPU registers that don't get saved/restored across context switches. Corruption ensues.

The fix: when FreeRTOS is enabled, override the entire build to use -march=rv32imac_zicsr -mabi=ilp32. The _zicsr extension is required by GCC 15 for CSR instructions (older GCC included them implicitly). Standard GCC doesn't support WCH's xw extension, so we drop it (it's just a minor code size optimization).

This means FreeRTOS projects use software float. If you need fast float math in FreeRTOS tasks, you'd need to modify portASM.S to save/restore FPU registers during context switch.

 

Fix 3: SysTick CMP 64-bit Cast

The QingKe SysTick CMP register is 64-bit. The FreeRTOS port writes it as:

 

SysTick->CMP = configCPU_CLOCK_HZ / configTICK_RATE_HZ;

 

The right side is uint32_t / uint32_t = uint32_t. Under ilp32 with GCC 15, the implicit widening from 32-bit to 64-bit was miscompiled. The register read back as 1398 instead of the expected 288000 (144 MHz / 500 Hz). Ticks fired at the wrong rate (effectively instant), making vTaskDelay() return immediately.

The fix: explicit cast.

SysTick->CMP = (uint64_t)(configCPU_CLOCK_HZ / configTICK_RATE_HZ);

 

Fix 4: Interrupt Attribute

The WCH port declares SysTick_Handler with:

void SysTick_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

 

"WCH-Interrupt-fast" is a MounRiver GCC extension that generates code for WCH's hardware register stacking (PFIC feature). Standard GCC 15 doesn't recognize it, silently falls back to generating a normal function, and the handler returns with ret instead of mret. The machine state gets corrupted and the board resets.

The fix:

void SysTick_Handler(void) __attribute__((interrupt("machine")));

GCC 15 does warn about the unrecognized attribute, but it's easy to miss among other warnings.

 

Fix 5: portmacro.h Interrupt Macros

The original WCH portmacro.h disables/enables interrupts by writing fixed values to mstatus:

#define portDISABLE_INTERRUPTS()  __asm volatile( "csrw mstatus,%0" ::"r"(0x7800) )
#define portENABLE_INTERRUPTS()   __asm volatile( "csrw mstatus,%0" ::"r"(0x7888) )

 

csrw replaces ALL mstatus bits. Every time FreeRTOS enters a critical section, it nukes the entire register and writes a fixed value. Any hardware-specific bits set by the QingKe core are permanently lost.

The fix: atomic bit operations that only toggle MIE (bit 3):

 

#define portDISABLE_INTERRUPTS()  __asm volatile( "csrc mstatus, 0x8" )
#define portENABLE_INTERRUPTS()   __asm volatile( "csrs mstatus, 0x8" )

 

Fix 6: xPortSetInterruptMask

Same problem as Fix 5, but in port.c:

 

/* Original - nukes mstatus */
__asm volatile("csrrw %0, mstatus, %1":"=r"(uvalue):"r"(0x7800));

/* Fixed - only clears MIE, preserves everything else */
__asm volatile("csrrc %0, mstatus, 0x8":"=r"(uvalue));

 

csrrc atomically reads mstatus (for later restore) and clears only the specified bits.

 

Lessons Learned

One bit matters. The difference between 0x7800 and 0x7880 is bit 7. That single bit controls whether interrupts ever enable after boot. The chip appeared to work without it because WCH's delay functions use polling, not interrupts.

csrw is dangerous on RISC-V. Destructive writes to mstatus work in controlled environments (like MounRiver where everything is compiled with matching assumptions). In a mixed environment with third-party drivers, always prefer csrs/csrc/csrrc/csrrs to modify only the bits you need.

Don't trust "it works" without testing the full stack. FreeRTOS blinked LEDs and printed to UART. GPIO and printf don't use interrupts on this platform (polling SPI and UART). The real test was LVGL, which depends on millis(), which depends on TIM7 interrupts, which depend on MIE being set.

Read the ISA spec, not just the vendor examples. The RISC-V privileged spec clearly states that mret copies MPIE to MIE. If MPIE=0, MIE stays 0. The WCH EVT example had 0x7800 and it worked for them because their FreeRTOS example doesn't use millis() or any interrupt-driven tick outside of FreeRTOS's own SysTick.

 


Armstrong Subero is an Embedded R & D Engineer, published author (four books with Springer/Apress). He loves RISC-V and builds the Rovari platform from Trinidad and Tobago.

Learn, build and ship with RISC-V from Anywhere. Thats the Rovari Way. 

Follow the project: rvembedded.com · @rvembedded