CH32V003

The CH32V003 is a 32-bit RISC-V microcontroller from WCH, built on the QingKe V2A core running the RV32EC instruction set at 48 MHz. It ships with 16 KB of flash and 2 KB of SRAM, a full peripheral set that includes UART, I2C, SPI, a 10-bit ADC with eight channels, two timers with PWM, a built-in analog comparator, and single-channel DMA. The operating voltage range spans 3.3V to 5V, and the chip is available in TSSOP20, SOP16, SOP8, and QFN20 packages.

At under $0.10 per unit in volume, the CH32V003 sits at the price point of an 8-bit PIC or AVR while delivering 32-bit performance on an open instruction set. It is the entry point for the Rovari platform and the chip you will spend the most time with in the 250-in-1 starter kit.

Rovari SDK: All examples on this page use the Rovari hardware abstraction layer (#include "rovari.h"). The SDK wraps the WCH standard peripheral library with clean functions like pin_mode(), uart_init(), and pwm_init(). When you need direct register access, the full hardware interface is still available underneath.

Every example below is provided in both C and C++ style. The C API uses standalone functions. The C++ API wraps the same hardware in lightweight objects like Gpio, Pwm, and Uart. Both compile to the same binary size. Choose whichever reads better to you.

Specifications

FeatureDetail
Core32-bit RISC-V (RV32EC) QingKe V2A
Clock48 MHz internal oscillator
Flash16 KB
SRAM2 KB
GPIOUp to 18 pins (package dependent)
ADC10-bit, 8 channels
Timers1 advanced 16-bit, 1 general purpose 16-bit
Communication1x USART, 1x I2C, 1x SPI
OtherAnalog comparator, DMA, watchdog
Voltage3.3V to 5V tolerant
PackagesTSSOP20, SOP16, SOP8, QFN20
Temperature-40°C to +85°C

Setup

All examples on this page are built and flashed using Rovari Studio, which bundles the RISC-V GCC toolchain, WCH-Link drivers, and the Rovari SDK. Select CH32V003 as your target, write your code in the editor, and press Run. The IDE handles compilation, linking, and flashing in a single step.

Hardware

  • CH32V003F4P6 development board (or bare chip on a breadboard)
  • WCH-LinkE programmer
  • USB cable
  • Breadboard and jumper wires

Wiring the Programmer

The CH32V003 uses a single wire debug interface. Connect three wires from the WCH-LinkE to the chip:

WCH-LinkECH32V003
SWDIOPD1
GNDGND
3V3VCC

Single wire only. The CH32V003 debug interface uses one data line (SWDIO on PD1), not the two wire SWD protocol used by ARM chips. Make sure your WCH-LinkE is set to single wire mode.

Pushbutton

Reading a physical button is the first step toward interactive hardware. This example configures one pin as an output driving an LED and another as an input with the internal pull-up resistor enabled. The button connects to ground, so pressing it pulls the pin low. The SDK handles debouncing internally.

Hardware

  • LED on PC1 via 1kΩ resistor
  • Pushbutton between PC2 and GND
  • No external pull-up needed

Code

app.rova
#include "rovari.h"

void app_init()
{
    pin_mode(PC1, Output);
    button_begin(PC2);
}

void app_run()
{
    if (button_pressed(PC2))
    {
        pin_toggle(PC1);
    }
}

Key Concepts

button_begin()
Configures the pin as input with internal pull-up and initializes the debounce state machine.
button_pressed()
Returns true once per press after debouncing. Will not return true again until the button is released and pressed again.
Active low
The pull-up holds the pin high. Pressing the button connects it to GND, pulling it low. The SDK inverts this so pressed() returns true when the button is down.
Button (C++)
Wraps a single button pin. Constructor enables the pull-up and debounce automatically. Call pressed() in your loop.

Millis

The delay() function blocks the entire CPU while it waits. That works for a single LED, but the moment you need to blink a light and read a sensor at the same time, blocking delays fall apart. The millis() function returns the number of milliseconds since boot, letting you schedule actions without blocking.

Hardware

  • LED on PC1 via 1kΩ resistor

Code

app.rova
#include "rovari.h"

uint32_t last_toggle = 0;

void app_init()
{
    pin_mode(PC1, Output);
}

void app_run()
{
    if (millis() - last_toggle >= 500)
    {
        last_toggle = millis();
        pin_toggle(PC1);
    }
}

Key Concepts

millis()
Returns a uint32_t count of milliseconds since boot. Driven by the SysTick timer, runs in the background with no CPU cost.
Elapsed time pattern
Subtract the last timestamp from the current millis(). When the difference exceeds your interval, act and update the timestamp. This pattern is safe across uint32_t rollover.

Why this matters: Every real project needs to do multiple things at once. Millis lets you blink an LED every 500ms while reading a sensor every 100ms and checking a button every 50ms, all in the same loop with no blocking.

Button Interrupt

Polling a button in a loop works, but it means the CPU is constantly checking. A hardware interrupt lets the chip react to the button press instantly, regardless of what the main loop is doing. The callback function runs as soon as the falling edge is detected on the pin.

Hardware

  • LED on PC1 via 1kΩ resistor
  • Pushbutton between PC2 and GND

Code

app.rova
#include "rovari.h"

void on_press()
{
    pin_toggle(PC1);
}

void app_init()
{
    pin_mode(PC1, Output);
    pin_mode(PC2, InputPullUp);

    attach_interrupt(PC2, Falling, on_press);
}

void app_run()
{
}

Key Concepts

attach_interrupt()
Registers a callback function on a GPIO pin for a given edge: Falling, Rising, or Change. The callback runs inside the interrupt handler.
Falling edge
Triggers when the signal goes from high to low. Since the button pulls the pin to GND on press, Falling is the correct edge for button detection.
Empty app_run()
The main loop does nothing. All the work happens in the interrupt. This is a valid pattern when the chip only needs to react to external events.
ISR rules
Keep interrupt callbacks short. Toggle a flag or a pin, then return. Never call delay() or uart_printf() inside an ISR.

PWM

Pulse Width Modulation lets you simulate analog output on a digital pin by rapidly switching it on and off at a controlled duty cycle. This is how you control LED brightness, motor speed, and servo position. The CH32V003 generates hardware PWM through its timer peripheral, so the signal runs at a precise frequency with zero CPU overhead after setup.

Hardware

  • LED on PD2 (TIM1 CH1) via 1kΩ resistor

Code

app.rova
#include "rovari.h"

void app_init()
{
    pwm_init(PD2, 1000);
}

void app_run()
{
    for (uint8_t i = 0; i < 255; i++)
    {
        pwm_write(PD2, i);
        delay(10);
    }

    for (int i = 255; i >= 0; i--)
    {
        pwm_write(PD2, (uint8_t)i);
        delay(10);
    }
}

Key Concepts

pwm_init(pin, freq)
Configures the timer and GPIO for PWM output at the given frequency in Hz. The pin must be connected to a timer channel (PD2 is TIM1 CH1).
pwm_write(pin, val)
Sets the duty cycle as a raw 0 to 255 value. 0 is fully off, 255 is fully on. The C++ writePct() method accepts 0 to 100 as a percentage instead.

ADC

The 10-bit ADC converts an analog voltage on a pin into a digital value from 0 to 1023. This example reads a potentiometer and prints the raw count and millivolt value over UART. The SDK provides both raw and calibrated millivolt reads in a single call.

Hardware

  • Potentiometer on PA1 (ADC channel 1)
  • WCH-Link for UART (TX=PD5, RX=PD6)

Code

app.rova
#include "rovari.h"

void app_init()
{
    uart_init(SERIAL1, 115200);
    adc_init(PA1);
    uart_println(SERIAL1, "ADC Reading");
}

void app_run()
{
    uint16_t raw = analog_read(PA1);
    uint16_t mv  = analog_read_mv(PA1);

    uart_printf(SERIAL1, "raw: %d  mv: %d\r\n", raw, mv);
    delay(500);
}

Key Concepts

adc_init(pin)
Enables the ADC clock, configures the pin as analog input, and runs the built-in calibration sequence.
analog_read()
Returns a raw 10-bit value from 0 to 1023 representing the voltage on the pin relative to VCC.
analog_read_mv()
Returns the voltage in millivolts, calculated from the raw count and the reference voltage. Avoids the need for float math in your application.
Adc (C++)
Object wrapper for a single ADC channel. Constructor calls adc_init(). Provides read() and readMv() methods.

UART

UART is the most common way to send text between a microcontroller and a computer. The CH32V003 has one hardware USART mapped to PD5 (TX) and PD6 (RX). This example initializes the port at 115200 baud, prints a greeting, and echoes back anything the user types.

Hardware

  • WCH-Link for UART (TX=PD5, RX=PD6)
  • 115200 baud, 8N1

Code

app.rova
#include "rovari.h"

void app_init()
{
    uart_init(SERIAL1, 115200);
    uart_println(SERIAL1, "CH32V003 UART Ready");
    uart_println(SERIAL1, "Type something and press Enter");
}

void app_run()
{
    char buf[32];
    int n = uart_read_line(SERIAL1, buf, sizeof(buf));

    if (n > 0)
    {
        uart_print(SERIAL1, "You said: ");
        uart_println(SERIAL1, buf);
    }
}

Key Concepts

uart_init(port, baud)
Enables the USART clock, configures TX as alternate function push-pull and RX as floating input, and sets the baud rate.
uart_read_line()
Non-blocking read. Returns 0 if no complete line is available yet. Returns the number of characters when a newline or carriage return is received.
uart_printf()
Formatted print, same syntax as standard printf. Supports %d, %u, %s, %x, and %ld. Does not support %f (use integer millivolt patterns instead).
Uart (C++)
Object wrapper around a USART port. Constructor initializes the hardware. Provides print(), println(), and readLine() methods.

SPI

SPI is a synchronous serial bus commonly used for fast communication with sensors, displays, and memory chips. This example drives an MCP41010 digital potentiometer, sweeping its wiper from 0 to 255. The chip select line is managed manually with a regular GPIO pin.

Hardware

  • MCP41010 digital potentiometer
  • SCK=PC5, MOSI=PC6, MISO=PC7
  • CS=PC3 (manual control)

Code

app.rova
#include "rovari.h"

#define MCP41010_CMD_WRITE 0x11

void app_init()
{
    spi_init(SPI_1, 1000000, 0, 0);
    pin_mode(PC3, Output);
    digital_write(PC3, High);
}

void app_run()
{
    for (uint8_t i = 0; i < 255; i++)
    {
        digital_write(PC3, Low);
        spi_write(SPI_1, MCP41010_CMD_WRITE);
        spi_write(SPI_1, i);
        digital_write(PC3, High);
        delay(10);
    }
}

Key Concepts

spi_init(port, speed, mode, order)
Configures the SPI peripheral. Speed is in Hz. Mode sets CPOL/CPHA (0 is the most common). Order is 0 for MSB first.
Manual CS
The chip select line is a regular GPIO you control yourself. Pull it low before a transaction, send your bytes, then pull it high. This gives full control over framing.
spi_write()
Sends one byte over MOSI and waits for the transfer to complete. SPI is full duplex, so every write also clocks in a byte on MISO (discarded here).
Spi (C++)
Object wrapper for the SPI port. Constructor configures the hardware. Provides write() and transfer() methods. Clock speed defaults to 1 MHz.

I2C

I2C is a two wire bus that lets you connect multiple devices on the same pair of lines. This example communicates with a 24LC16B EEPROM, writing a byte to an address, reading it back, and printing the result over UART to verify the data survived the round trip.

Hardware

  • 24LC16B EEPROM (address 0x50)
  • SCL=PC2, SDA=PC1
  • 4.7kΩ pull-ups on both lines
  • WCH-Link for UART (TX=PD5, RX=PD6)

Code

app.rova
#include "rovari.h"

#define EEPROM_ADDR 0x50

void app_init()
{
    uart_init(SERIAL1, 115200);
    i2c_init(I2C_1, 100000);
    uart_println(SERIAL1, "I2C EEPROM Test");
}

void app_run()
{
    static uint8_t val = 0;
    static uint8_t addr = 0;

    i2c_write_reg(I2C_1, EEPROM_ADDR, addr, val);
    delay(6);

    uint8_t rd = i2c_read_reg(I2C_1, EEPROM_ADDR, addr);

    uart_printf(SERIAL1, "W[%d]=%d R=%d %s\r\n", addr, val, rd,
                (rd == val) ? "OK" : "FAIL");

    val += 7;
    addr = (addr + 1) & 0x0F;
    delay(1000);
}

Key Concepts

i2c_init(port, speed)
Configures the I2C peripheral. Speed is in Hz, typically 100000 for standard mode or 400000 for fast mode.
i2c_write_reg()
Sends a start condition, the device address with write bit, the register address, and the data byte. Then sends a stop condition.
Write cycle delay
EEPROMs need time to physically burn each byte into memory. The 24LC16B requires up to 5ms per write. The 6ms delay here provides margin.
I2c (C++)
Object wrapper for the I2C bus. Constructor initializes the hardware. Provides writeReg() and readReg() for single-byte register access.

OLED Display

An SSD1306 OLED gives your project a visual output beyond blinking LEDs. The Rovari SDK includes a built-in driver that supports both 128x32 and 128x64 panels over I2C. The API uses a line-based text interface, so you can print formatted strings to specific rows without managing a frame buffer yourself.

Hardware

  • SSD1306 128x32 OLED module
  • SCL=PC2, SDA=PC1
  • 4.7kΩ pull-ups on both lines

Code

app.rova
#include "rovari.h"
#include "rovari_ssd1306.h"

void app_init()
{
    i2c_init(I2C_1, 100000);

    oled_init(I2C_1, OLED_128x32, SSD1306_I2C_ADDR);
    oled_set_orientation(OLED_ORIENT_180);

    oled_clear();
    oled_printf_line(0, "Rovari SDK");
    oled_printf_line(1, "CH32V003");
    oled_printf_line(2, "RISC-V 48MHz");
}

void app_run()
{
    static uint32_t count = 0;
    oled_printf_line(3, "Count: %lu", count);
    count++;
    delay(100);
}

Key Concepts

oled_printf_line(row, fmt)
Prints a formatted string to a specific row (0 to 3 for a 128x32 display). Clears the line before writing, so you can update a single row without redrawing the whole screen.
rovari_ssd1306.h
Separate driver header included alongside rovari.h. Add "SSD1306" to the libraries list in your project config to link it automatically.
Orientation
Many OLED modules are mounted upside down. OLED_ORIENT_180 flips the display so text reads correctly regardless of how the module is wired.
Oled (C++)
Object wrapper for the SSD1306 driver. Constructor takes the I2C bus and panel size. Call begin() to initialize, then use printLine() and clear().

Downloads

All source code for the examples on this page is available on GitHub. Clone the repository to get every project pre-configured for Rovari Studio with build files, Guvari blocks, and companion Python scripts included.

External Resources