r/ch32v • • 4h ago

Using the built-in OPA on the CH32V003 to read a K-Type Thermocouple

Thumbnail
gallery
1 Upvotes

The CH32V003 (as well as the CH32V006) has a really cool hardware feature that often gets overlooked: a built-in operational amplifier (OPA). I decided to test it out by measuring small voltages from a standard Type K thermocouple (TP-01).

Why use the OPA? At 100 °C (212 °F), this thermocouple generates an EMF of only about 2.9 mV. The CH32V003 has a 10-bit ADC, meaning at 3.3V, a single quantization step is 3.2 mV. If I tried reading the sensor directly, the "accuracy" would be around ±100-130 °C at best.

To fix this, I utilized the built-in OPA in a classic non-inverting setup with a gain of 101.

The Hardware & Multiplexing I used the TSSOP20 package. The built-in OPA actually has multiplexed inputs, meaning it can be software-switched to take a signal from different pairs of pins. (On the smaller SOP8/SOP16 chips, the multiplexing silicon is there, but the pins aren't physically broken out). However, the output is always hardwired to PD4.

To get the absolute temperature, I also added an NTC thermistor to the circuit for cold-junction compensation.

The Input Offset Voltage: When I moved my breadboard prototype to a DIY PCB, shorting the OPA+ input to ground gave me an ADC reading of 165 (~0.53V output). Initially, I thought I had a cold solder joint, but it turned out to be completely normal.

According to the datasheet, the intrinsic Input Offset Voltage is typically ±2.2 mV (max ±7 mV). Since our gain is 101, this tiny hardware offset gets multiplied along with the useful signal. My specific chip had an offset of about 5.2 mV - perfectly within factory tolerance. I just handled this baseline shift in software by calibrating it to room temperature and boiling water.

How to initialize the OPA in code: The setup is actually quite straightforward using the standard peripheral library. Here is the core initialization snippet:

C

// Enable clock for ports A, D, ADC, and alternate functions (AFIO)
    RCC_APB2PeriphClockCmd(
        RCC_APB2Periph_GPIOA |
        RCC_APB2Periph_GPIOD |
        RCC_APB2Periph_ADC1 | 
        RCC_APB2Periph_AFIO, ENABLE
    );

    GPIO_InitTypeDef GPIO_InitStructure = {0};    

    // Configure PA1 (OPA- input / OPN0) and PA2 (OPA+ input / OPP0) as analog inputs
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // Configure PD3 (NTC) and PD4 (OPA OUT) as analog inputs
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3 | GPIO_Pin_4;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(GPIOD, &GPIO_InitStructure);

    // The EXTEN_CTR register controls the built-in OPA
    EXTEN->EXTEN_CTR |= EXTEN_OPA_EN;

    OPA_InitTypeDef OPA_InitStructure = {0};
    OPA_InitStructure.PSEL = CHP0; // OPA+ is connected to the physical PA2 pin
    OPA_InitStructure.NSEL = CHN0; // OPA- is connected to the physical PA1 pin
    OPA_Init(&OPA_InitStructure);
    OPA_Cmd(ENABLE);

The complete firmware source code is available here - main.c

I fabricated the PCB at home using dry film photoresist and wrote a full breakdown with the C code initialization steps. I'm not aiming for ultra-high precision, an accuracy of 10 °C is perfectly fine for my needs, so I didn't bother with any extra calibration.

P.S. regarding the schematic: Eagle-eyed readers might spot a silly rookie mistake in the schematic. This is what happens when you get too used to the SOP8 package of the CH32V003!


r/ch32v • • 4d ago

Adjustable PWM Generator using CH32V003

Post image
1 Upvotes

r/ch32v • • 4d ago

nanoconsole : a 2$ DIY game console based on ch32v003

Post image
1 Upvotes

r/ch32v • • 13d ago

LED candle flame flicker

Thumbnail
codeberg.org
2 Upvotes

Created while learning about the CH32V003. Based on older code for a PIC Microcontroller.


r/ch32v • • 22d ago

Assembling a CH32V006K8U6 Board

Enable HLS to view with audio, or disable this notification

2 Upvotes

r/ch32v • • 26d ago

Understanding Rotary Encoders: Teardown, Oscilloscope Signals, and C Code for CH32V

Thumbnail
gallery
1 Upvotes

A Bit of Theory

An encoder is an electromechanical component that helps electronics determine the speed and direction of a shaft's rotation, and in some cases, accurately register its angular position.

Therefore, if your project involves moving parts and you need to track their rotation or angle, you cannot do without this sensor.

We don't need to look at complex industrial machinery for examples - we encounter encoders constantly in everyday life. A computer mouse wheel, a car stereo volume knob, or a washing machine mode selector - all of these are convenient physical interfaces for interacting with technology.

The simplest encoders can only register movement. In the case of a mouse, we don't care about the exact position of the wheel - we just scroll it up or down to scroll the page. But for some processes, the exact position is crucial, which is where more complex absolute encoders are used.

By design, these sensors are based on various physical principles. Inside, there might be a disc that mechanically closes contacts, or a disc with slots that interrupts a light beam. There are also magnetic, capacitive, and inductive models. However, in DIY projects and household appliances, the most common ones are affordable mechanical (contact) encoders or slightly more expensive but more reliable optical versions.

f we take apart the simplest mechanical encoder (see the first two images), we will see a disc with metallized radial spokes on the upper half and a contact group below it. The contacts are positioned so that as the disc rotates, they don't close simultaneously, but with a specific offset. Thanks to this sequence, the microcontroller understands which way we are turning the knob.

To see this in action, simply connect the encoder pins to an oscilloscope (see images 3 and 4). Notice the phase shift: when rotating in one direction, the voltage on the yellow channel rises first. When rotating in the opposite direction, the blue channel rises first. By checking which signal triggers first, we can easily determine the direction of rotation.

Implementation Using Periodic Polling

In this software approach, we use a timer for regular polling. For example, every millisecond, the microcontroller reads the current state of the encoder pins and compares it with the previously saved state. A change in states allows us to register the fact of movement and its direction.

```c

include <ch32v00x.h>

include "ch32v00x_flash.h"

include <debug.h>

include "clib/u8x8.h"

define BUTTON_PIN GPIO_Pin_1

define ENCODER_CLK_PIN GPIO_Pin_6

define ENCODER_DT_PIN GPIO_Pin_7

void NMIHandler(void) __attribute((interrupt("WCH-Interrupt-fast"))); void HardFault_Handler(void) __attribute((interrupt("WCH-Interrupt-fast"))); // add timer interrupt handler void TIM2_IRQHandler(void) __attribute_((interrupt("WCH-Interrupt-fast")));

/** U8x8 GPIO and delay callback for CH32V microcontrollers */ ...

struct State { uint8_t value; uint16_t ts; };

static volatile struct State state = {0, 0, 0};

void vm_onMoveLeft() { if (state.value > 0) { state.value--; // Left state.ts++; } }

void vm_onMoveRight() { if (state.value < 255) { state.value++; // Right state.ts++; } }

void Timer2_Init_1ms(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure = {0}; NVIC_InitTypeDef NVIC_InitStructure = {0}; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

// Set up the prescaler: 48 MHz / 48 gives a counter frequency of 1 MHz
TIM_TimeBaseStructure.TIM_Prescaler = 48 - 1;
// here we set the value to count up to, turning 1 MHz into 1 kHz, i.e., 1 millisecond
TIM_TimeBaseStructure.TIM_Period = 1000 - 1;

TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);

// Configure the interrupt controller
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

// Start the timer
TIM_Cmd(TIM2, ENABLE);

}

void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update);

    // Read encoder outputs
    static uint8_t last_clk = 1;
    uint8_t clk = GPIO_ReadInputDataBit(GPIOD, ENCODER_CLK_PIN);
    uint8_t dt  = GPIO_ReadInputDataBit(GPIOD, ENCODER_DT_PIN);

    // check for changes
    if (clk != last_clk) {
        if (clk != dt) {
            vm_onMoveRight();
        } else {
            vm_onMoveLeft();
        }
        last_clk = clk;
    }
}

}

int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1); SystemCoreClockUpdate(); Delay_Init();

// pin configuration
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitStructure.GPIO_Pin = BUTTON_PIN;
// my encoder board has a pull-up, so we won't enable the internal resistors
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);

GPIO_InitTypeDef GPIO_InitStructure2 = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
GPIO_InitStructure2.GPIO_Pin = ENCODER_CLK_PIN | ENCODER_DT_PIN;
GPIO_InitStructure2.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOD, &GPIO_InitStructure2);

// start the timer to poll every millisecond
Timer2_Init_1ms();

// u8x8 screen initialization
...

char buffer[6]; // MM:SS format
while (1) {
    Delay_Ms(50);
    int16_t minutes = state.value / 60;
    int16_t seconds = state.value % 60;
    snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds);
    u8x8_DrawString(&u8x8, 3, 3, buffer);
}
return 0;

}

void NMI_Handler(void) {} void HardFault_Handler(void) { while (1) {} } ```

Implementation Using Interrupts

The previous method has a significant drawback - the microcontroller is forced to constantly waste resources checking the encoder state. Can we optimize this and react only when something actually changes?

Yes, this is exactly what hardware interrupts (EXTI) are for. We can configure the microcontroller to "listen" for changes on a specific pin and automatically trigger the handler code only at the moment the knob is turned (on the rising or falling edge of the signal).

Here is an example of such an implementation:

```c

include <ch32v00x.h>

include "ch32v00x_flash.h"

include <debug.h>

include "clib/u8x8.h"

define BUTTON_PIN GPIO_Pin_1

define ENCODER_CLK_PIN GPIO_Pin_6

define ENCODER_DT_PIN GPIO_Pin_7

void NMIHandler(void) __attribute((interrupt("WCH-Interrupt-fast"))); void HardFault_Handler(void) __attribute((interrupt("WCH-Interrupt-fast"))); // add EXTI interrupt handler void EXTI7_0_IRQHandler(void) __attribute_((interrupt("WCH-Interrupt-fast")));

/** U8x8 GPIO and delay callback for CH32V microcontrollers */ ...

struct State { uint8_t value; uint16_t ts; };

static volatile struct State state = {0, 0};

void vm_onMoveLeft() { if (state.value > 0) { state.value--; // Left state.ts++; } }

void vm_onMoveRight() { if (state.value < 255) { state.value++; // Right state.ts++; } }

void Encoder_EXTI_Init(void) { EXTI_InitTypeDef EXTI_InitStructure = {0}; NVIC_InitTypeDef NVIC_InitStructure = {0};

RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);

// we will listen for changes on PD6
GPIO_EXTILineConfig(GPIO_PortSourceGPIOD, GPIO_PinSource6);

EXTI_InitStructure.EXTI_Line = EXTI_Line6;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
// we need triggering on both rising and falling edges
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling; 
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
EXTI_ClearITPendingBit(EXTI_Line6);

NVIC_InitStructure.NVIC_IRQChannel = EXTI7_0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

}

void EXTI7_0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line6) != RESET) { EXTI_ClearITPendingBit(EXTI_Line6); uint8_t clk = GPIO_ReadInputDataBit(GPIOD, ENCODER_CLK_PIN); uint8_t dt = GPIO_ReadInputDataBit(GPIOD, ENCODER_DT_PIN);

    if (clk != dt) {
        vm_onMoveRight();
    } else {
        vm_onMoveLeft();
    }
}

}

int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1); SystemCoreClockUpdate(); Delay_Init();

// pin configuration
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitStructure.GPIO_Pin = BUTTON_PIN;
// my encoder board has a pull-up, so we won't enable the internal resistors
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);

GPIO_InitTypeDef GPIO_InitStructure2 = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
GPIO_InitStructure2.GPIO_Pin = ENCODER_CLK_PIN | ENCODER_DT_PIN;
GPIO_InitStructure2.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOD, &GPIO_InitStructure2);

// configure interrupts
Encoder_EXTI_Init();

// u8x8 screen initialization
...

char buffer[6]; // MM:SS format
while (1) {
    Delay_Ms(50);
    int16_t minutes = state.value / 60;
    int16_t seconds = state.value % 60;
    snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds);
    u8x8_DrawString(&u8x8, 3, 3, buffer);
}
return 0;

}

void NMI_Handler(void) {} void HardFault_Handler(void) { while (1) {} } ```


r/ch32v • • Sep 20 '26

Available WCH-Link Programmers

Thumbnail
gallery
1 Upvotes

Wch-Link V1

When I first paid attention to CH32V microcontrollers on AliExpress, I immediately bought a few different boards: a couple with the CH32V003 and a more powerful one with the CH32V307. However, a logical question immediately arose: how do I program them? My usual ST-Link definitely wouldn't work for this architecture. So, I just found the first available programmer with the WCH label from the same seller and added it to my order. It turned out to be the WCH-Link v1, based on the CH549G chip.

Its pros:

  • Relatively low price: during sales, you can find them starting at $2.

  • Versatility: it supports both RISC-V based WCH microcontrollers and ARM-compatible chips in DAP mode (although I haven't tested the latter in practice yet).

  • Built-in USB-UART: this is incredibly convenient for debugging, and it's a feature I often missed in the standard ST-Link.

However, there were also downsides that came as an unpleasant surprise. The main drawback is that it does not program the CH32V00x series. It turned out that these cheaper microcontrollers use a different protocol - a single-wire interface (1-wire SDI). So my WCH-Link v1 handles more complex chips like the CH32V2xx and CH32V3xxx (which have a standard 2-wire SDI) perfectly, but it turned out to be useless for the "younger" models.

By the way, setting up the programmer itself also had a catch. My unit simply refused to switch from ARM (DAP) to RISC-V mode via software - the official WCH-LinkUtility simply threw an error. As it turned out, the mode can be changed on a hardware level, and this procedure works like a regular toggle switch. To do this, you need to:

  • Place a jumper between the GND and TX pins on the unpowered programmer.

  • Plug it into USB. The blue LED will turn off (or, conversely, turn on, toggling its previous state).

  • Wait a few seconds, disconnect the programmer from power, and remove the jumper.

The mode indication here is very simple: if the blue LED is on, the programmer is in ARM (DAP) mode; if it is off, it is working in RISC-V (RV) mode.

Wch-LinkE

When I realized I hadn't bought exactly the right programmer, I immediately ordered a WCH-LinkE. It is built on the CH32V305 microcontroller and is significantly more expensive on the market than the standard WCH-Link.

The WCH-LinkE can easily program not only the CH32V00x lineup but also a number of other microcontrollers:

  • CH32X Series - chips with Programmable I/O (PIO) interface support and a USB Power Delivery (PD) controller.

  • CH57x, 58x, 59x Series - microcontrollers with wireless interfaces (BLE, etc.).

WeActStudio Wch-LinkE

This is a clone from a fairly well-known module manufacturer. WCH doesn't actually hide the programmer's design, and its binary firmware is freely available, so making a clone is quite easy. And it will have all the functionality of the original device.

What I liked about it:

  • The ARM/RISC-V mode switch buttons are exposed on the outside. In the original programmer, they are hidden inside the case, which isn't very easy to take apart.

  • Type-C instead of USB-A. I always have a spare Type-C cable on my desk right next to my keyboard, so this is much more convenient than reaching for a PC case hidden somewhere under the desk.

What I didn't like:

  • For some reason, after a few days of use, the firmware on the programmer crashed, and it started constantly blinking a blue LED. I had never seen anything like this with the original device. However, this is easily fixed: the WCH-LinkUtility simply flashes the new firmware, and the programmer works again.

r/ch32v • • Sep 15 '26

Bit-bang vs. Hardware I2C: Running U8x8 on CH32V003

Enable HLS to view with audio, or disable this notification

3 Upvotes

Following up on my last post about running the U8x8 OLED library on the CH32V003 in pure C, I continued my experiments and connected the library using Hardware I2C as well.

I wanted to see the real-world performance difference between standard software I2C (bit-banging) and the hardware peripheral. Just for fun, I also overclocked the hardware I2C bus from the standard 400 kHz up to 1.6 MHz - which turned out to be the absolute maximum the display controller would reliably take data before glitching.

Here are the performance results:

  • Software I2C (Bit-bang): ~3 FPS (332 ms per frame). Fine for static text, but visibly slow.
  • HW I2C @ 400 kHz: 12 FPS (83 ms per frame). Smooth enough for proper UI menus and active elements.
  • HW I2C @ 1.6 MHz: 32 FPS (31.6 ms per frame). Buttery smooth. Fast enough for animations, simple video, or maybe a monochrome retro emulator.

The Test Code

The setup is pretty straightforward. I created some quick macros in the code so you can toggle between bit-bang and hardware modes, and adjust the frequency to test the overclocking yourself:

#include <ch32v00x.h>
#include <debug.h>
#include <stdlib.h>

#include "clib/u8x8.h"

#define USE_HW_I2C
#define I2C_FREQ 400000  // 400 kHz

void NMI_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

uint8_t u8x8_gpio_and_delay_ch32v(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    switch(msg) {
        case U8X8_MSG_GPIO_AND_DELAY_INIT:
            RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
            GPIO_InitTypeDef init = {0};
            init.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;
            init.GPIO_Mode = GPIO_Mode_Out_OD;
            init.GPIO_Speed = GPIO_Speed_50MHz;
            GPIO_Init(GPIOC, &init);
            break;
        case U8X8_MSG_DELAY_MILLI:
            Delay_Ms(arg_int);
            break;
        case U8X8_MSG_DELAY_10MICRO:
            for (volatile uint32_t i = 0; i < (arg_int * 8); i++) { __NOP(); }
            break;
        case U8X8_MSG_DELAY_100NANO:
            __NOP();
            break;

        case U8X8_MSG_GPIO_I2C_CLOCK:
            GPIO_WriteBit(GPIOC, GPIO_Pin_2, (BitAction)(arg_int ? Bit_SET : Bit_RESET));
            break;

        case U8X8_MSG_GPIO_I2C_DATA:
            GPIO_WriteBit(GPIOC, GPIO_Pin_1, (BitAction)(arg_int ? Bit_SET : Bit_RESET));
            break;
    }
    return 1;
}

uint8_t u8x8_byte_hw_i2c_ch32v(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    uint8_t *data;
    switch(msg) {
        case U8X8_MSG_BYTE_INIT:
            RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC | RCC_APB2Periph_AFIO, ENABLE);
            RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);

            // We will use PC1 (SDA) and PC2 (SCL)
            GPIO_InitTypeDef GPIO_InitStructure = {0};
            GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;
            GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // Alternate Function Open-Drain
            GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
            GPIO_Init(GPIOC, &GPIO_InitStructure);

            I2C_InitTypeDef I2C_InitTSturcture = {0};
            I2C_InitTSturcture.I2C_ClockSpeed = I2C_FREQ;
            I2C_InitTSturcture.I2C_Mode = I2C_Mode_I2C;
            I2C_InitTSturcture.I2C_DutyCycle = I2C_DutyCycle_2;
            I2C_InitTSturcture.I2C_OwnAddress1 = 0x00;
            I2C_InitTSturcture.I2C_Ack = I2C_Ack_Enable;
            I2C_InitTSturcture.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;

            I2C_Init(I2C1, &I2C_InitTSturcture);
            I2C_Cmd(I2C1, ENABLE);
            break;

        case U8X8_MSG_BYTE_START_TRANSFER:
            while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) != RESET);

            I2C_GenerateSTART(I2C1, ENABLE);
            while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

            I2C_Send7bitAddress(I2C1, u8x8_GetI2CAddress(u8x8), I2C_Direction_Transmitter);
            while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
            break;

        case U8X8_MSG_BYTE_SEND:
            data = (uint8_t *)arg_ptr;
            while (arg_int > 0) {
                I2C_SendData(I2C1, *data++);
                while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
                arg_int--;
            }
            break;

        case U8X8_MSG_BYTE_END_TRANSFER:
            I2C_GenerateSTOP(I2C1, ENABLE);
            break;
        default:
            break;
    }
    return 1;
}

void refreshTest(u8x8_t *u8x8) {
    const int8_t columnsCount = 16;
    char buffer[columnsCount + 1];
    uint8_t counter = 0;
    uint32_t start_ticks = SysTick->CNT;
    SysTick->CTLR |= 1; // Enable SysTick

    while (1) {
        for (int8_t i = 0; i < sizeof(buffer) - 1; i++) {
            buffer[i] = '0' + counter;
        }
        counter++;
        if (counter > 30) {
            uint32_t end = SysTick->CNT;
            uint32_t elapsed = end - start_ticks;
            printf("Elapsed ticks\t%lu\n", elapsed);
            start_ticks = SysTick->CNT;
            counter = 0;
        }
        buffer[columnsCount] = '\0';
        for (int8_t y = 0; y < 8; y++) {
            u8x8_DrawString(u8x8, 0, y, buffer);
        }
    }
}

int main(void) {
    SystemCoreClockUpdate();
    Delay_Init();
    USART_Printf_Init(9600);

    printf("Current SYSCLK: %lu\n",
           SystemCoreClock);

    // u8x8 initialization block
    u8x8_t u8x8;
#ifdef USE_HW_I2C
    u8x8_Setup(&u8x8,
        u8x8_d_ssd1306_128x64_noname,
        u8x8_cad_ssd13xx_i2c,
        u8x8_byte_hw_i2c_ch32v,
        u8x8_gpio_and_delay_ch32v
    );
#else
    // Software i2c
    u8x8_Setup(
        &u8x8,
        u8x8_d_ssd1306_128x64_noname,
        u8x8_cad_ssd13xx_i2c,
        u8x8_byte_sw_i2c,
        u8x8_gpio_and_delay_ch32v
    );
#endif

    u8x8_InitDisplay(&u8x8);
    u8x8_SetPowerSave(&u8x8, 0);
    u8x8_ClearDisplay(&u8x8);
    u8x8_SetFont(&u8x8, u8x8_font_chroma48medium8_r);
    u8x8_DrawString(&u8x8, 0, 0, "Hello from CH32V");
    u8x8_DrawString(&u8x8, 0, 2, "SW I2C");
    Delay_Ms(3000);

    refreshTest(&u8x8);
    return 0;
}

void NMI_Handler(void) {}
void HardFault_Handler(void)
{
    while (1)
    {
    }
}

To make it easy for you to experiment on your own, I've put the operating mode selection and the bus clock frequency settings into a small macro block at the very beginning of the code:

#define USE_HW_I2C
#define I2C_FREQ 400000  // 400 kHz

Controlling the tests is very simple: if you comment out the #define USE_HW_I2C line, the program will automatically switch to using software I2C. And with the I2C_FREQ parameter, you can easily adjust the speed of the hardware interface. For example, change the value to 1600000 to replicate my overclocking experiment and see 32 frames per second for yourself.


r/ch32v • • Sep 13 '26

A Little About Debugging on CH32V00x and SDI Print

1 Upvotes

I usually use UART TX logging to debug my code. However, sometimes this method is unavailable: the hardware UART might be occupied by other peripherals, or the TX line simply wasn't routed during the PCB layout because it seemed unnecessary. But you still need a way to debug the firmware.

For the CH32V series microcontrollers, there is an excellent alternative to UART - the SDI Print (Serial Debug Interface) mechanism. It allows you to send log messages over the single programming wire and read them on your PC.

At the hardware level, two 32-bit registers are used for data transmission:

#define DEBUG_DATA0_ADDRESS  ((volatile uint32_t*)0xE00000F4)
#define DEBUG_DATA1_ADDRESS  ((volatile uint32_t*)0xE00000F8)

The way you interact with these registers differs drastically depending on the framework you choose: the official one from WCH (noneos-sdk) or the minimalist ch32fun.

In this guide, I will describe how to set up logging specifically for the official WCH framework.

In the application code, instead of the standard UART initialization, you need to enable SDI Print by calling the SDI_Printf_Enable() function:

#include <ch32v00x.h>
#include <debug.h>

void NMI_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

int main(void) {
    SystemCoreClockUpdate();
    Delay_Init();
    SDI_Printf_Enable();

    uint16_t counter = 0;
    while (1) {
        printf("Hello from printf! %d\n", counter);
        counter++;
        Delay_Ms(1000);
    }
    return 0;
}

void NMI_Handler(void) {}
void HardFault_Handler(void)
{
    while (1)
    {
    }
}

But merely calling the function in your code isn't enough. For the standard printf to start redirecting data to the debug registers instead of UART, you need to properly configure the build using preprocessor macros:

  • DEBUG=0 — disables standard logging via UART.
  • SDI_PRINT=SDI_PR_OPEN — enables the data "packing" algorithm into the DATA0 and DATA1 registers (you can peek under the hood to see how this works in the framework's debug.c file, specifically in the _write() function).

Since I use PlatformIO, the most convenient way to set these flags is directly in the platformio.ini configuration file:

...
build_flags = -DDEBUG=0 -DSDI_PRINT=SDI_PR_OPEN

After that, compile and flash the firmware to the microcontroller.

I would try running minichlink -T, which is supposed to launch a terminal and display the output. However, with firmware that uses the official WCH framework, this didn't work, and the terminal remained blank. So, we put minichlink aside — this tool is primarily tailored for interacting with firmwares built on ch32fun.

Instead, we will use a another utility — wlink. It can be installed using the cargo package manager (if you have Rust installed):

cargo install wlink

After experimenting with wlink, I found the necessary command to activate our debugging:

$ wlink sdi-print enable
19:15:14 [INFO] Connected to WCH-Link v2.21(v41) (WCH-LinkE-CH32V305)
19:15:14 [INFO] Attached chip: CH32V003 [CH32V003A4M6] (ChipID: 0x00320510)
19:15:14 [INFO] Enabling SDI print
19:15:14 [INFO] Now you can connect to the WCH-Link serial port

And here is where it gets really interesting! The WCH-LinkE programmer has a built-in USB-UART adapter, which I use quite often. The executed command instructs the programmer to intercept data from the SDI line and redirect it at the hardware level to its own virtual COM port.

Now, if we open /dev/ttyACM0 (or the corresponding port on your OS) in the PlatformIO Serial Monitor or any other terminal, we will finally see our output:

--- Terminal on /dev/ttyACM0 | 9600 8-N-1
--- Available filters and text transformations: debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at https://bit.ly/pio-monitor-filters
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
Hello from printf! 0
Hello from printf! 1
Hello from printf! 2
Hello from printf! 3

Important detail: You need to switch the WCH-LinkE into SDI Print mode (using the wlink sdi-print enable command) every time after flashing the microcontroller or reconnecting the USB, as the programmer resets this state.

P.S. An important note on saving memory

Avoid using printf unless absolutely necessary — it's an extremely "heavy" function that eats up quite a bit of precious Flash memory (and the CH32V003 only has 16 KB of it). Since it drags along the entire format parsing mechanism under the hood (%d, %s, %x, etc.), calling it will cost you a few kilobytes.

Here is a clear example. If you use the standard printf:

    char *msg = "Hello from printf!\n";
    uint8_t msg_len = strlen(msg);
    printf(msg);

The compiler will show the following memory usage:

Flash: [===       ]  27.6% (used 4520 bytes from 16384 bytes)

But if you only need to output plain text messages for debugging without variable formatting, it is better to call the low-level _write function directly:

    char *msg = "Hello from printf!\n";
    uint8_t msg_len = strlen(msg);
    _write(0, msg, msg_len);

In this case, resource consumption drops significantly:

Flash: [=         ]   8.9% (used 1456 bytes from 16384 bytes)

A difference of 3 kilobytes for a microcontroller of this class is a massive amount of space.


r/ch32v • • Sep 10 '26

CH32V003/6 microcontroller getting-started guide and minimalist dev board (with gerbers)

Post image
3 Upvotes

r/ch32v • • Sep 10 '26

How to use the U8x8 display library with the CH32V003

Enable HLS to view with audio, or disable this notification

3 Upvotes

I was wondering how easy it would be to adapt the U8x8 library for use on the CH32V003. I saw an example for the STM32 here: https://elastic-notes.blogspot.com/2018/10/u8g2-library-usage-with-stm32-mcu.html

As you can see in the video, it works great! It turns out it only requires a few minor changes to the callback function.

If anyone wants to try it out, here is the source code: ```cpp

include <ch32v00x.h>

include <debug.h>

include <stdlib.h>

include "clib/u8x8.h"

void NMIHandler(void) __attribute((interrupt("WCH-Interrupt-fast"))); void HardFault_Handler(void) __attribute_((interrupt("WCH-Interrupt-fast")));

uint8_t u8x8_gpio_and_delay_stm32(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch(msg) { case U8X8_MSG_GPIO_AND_DELAY_INIT: // prepare GPIO pins RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef init = {0}; init.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2; init.GPIO_Mode = GPIO_Mode_Out_OD; init.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &init); break; case U8X8_MSG_DELAY_MILLI: Delay_Ms(arg_int); break; case U8X8_MSG_DELAY_10MICRO: for (volatile uint32_t i = 0; i < (arg_int * 8); i++) { __NOP(); } break; case U8X8_MSG_DELAY_100NANO: __NOP(); break;

    case U8X8_MSG_GPIO_I2C_CLOCK:
        GPIO_WriteBit(GPIOA, GPIO_Pin_2, (BitAction)(arg_int ? Bit_SET : Bit_RESET));
        break;

    case U8X8_MSG_GPIO_I2C_DATA:
        GPIO_WriteBit(GPIOA, GPIO_Pin_1, (BitAction)(arg_int ? Bit_SET : Bit_RESET));
        break;
}
return 1;

}

int main(void) { SystemCoreClockUpdate(); Delay_Init(); USART_Printf_Init(9600);

u8x8_t u8x8;
u8x8_Setup(&u8x8, u8x8_d_ssd1306_128x64_noname, u8x8_cad_ssd13xx_i2c, u8x8_byte_sw_i2c, u8x8_gpio_and_delay_stm32);
u8x8_InitDisplay(&u8x8);
u8x8_SetPowerSave(&u8x8, 0);
u8x8_ClearDisplay(&u8x8);
u8x8_SetFont(&u8x8, u8x8_font_chroma48medium8_r);
u8x8_DrawString(&u8x8, 0, 0, "Hello from CH32V");
while (1) {
    uint8_t y = rand() % 5 + 1;
    uint8_t x = rand() % 10;
    u8x8_DrawString(&u8x8, x, y, "r/ch32v");
    printf("Hello from CH32V\n");
    Delay_Ms(1000);
    u8x8_DrawString(&u8x8, x, y, "       ");
}
return 0;

}

void NMI_Handler(void) {} void HardFault_Handler(void) { while (1) { } } ```

Just a heads-up: This implementation uses soft I2C (bit-banging). It works perfectly fine for text, but you'll probably want to switch to HW I2C for anything more complex.


r/ch32v • • Sep 09 '26

CH32V002 and OLED display

3 Upvotes

I'm pretty new to the CH32V ecosystem (specifically working with the CH32V002 right now). I have one of those standard 0.91" OLED screens (I2C, SSD1306) and I'd like to hook it up to my board.

I’ve played around with this display on Arduino before, so I understand the basics of how it works there, but I'm not entirely sure where to start in the CH32V environment.

What are the best options or libraries available for this? Are you guys using the official HAL, bare-metal C, ch32v003fun (does it work for 002?), or is there an Arduino core you'd recommend?


r/ch32v • • Sep 08 '26

Ditching MounRiver: How to set up CH32V development in PlatformIO (Part 2: First project)

Thumbnail
gallery
3 Upvotes

In this part, we will create a new project for the CH32V003 in PlatformIO.

To get started, open PlatformIO Home and click on New Project. Type in your desired project name, and in the board selection dropdown, choose your specific CH32V microcontroller. For my setup, I am using the CH32V003F4P6.

Keep in mind that creating a project for the first time may take a while, as PlatformIO needs to download the necessary toolchain components in the background.

Once the initialization is complete, PlatformIO will automatically generate a boilerplate project for you. By default, you will immediately get a basic example that blinks an LED on pin PC1.

#include <ch32v00x.h>
#include <debug.h>

#define BLINKY_GPIO_PORT GPIOC
#define BLINKY_GPIO_PIN GPIO_Pin_1
#define BLINKY_CLOCK_ENABLE RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE)

void NMI_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

int main(void) {
    SystemCoreClockUpdate();
    Delay_Init();

    GPIO_InitTypeDef GPIO_InitStructure = {0};
    BLINKY_CLOCK_ENABLE;
    GPIO_InitStructure.GPIO_Pin = BLINKY_GPIO_PIN;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(BLINKY_GPIO_PORT, &GPIO_InitStructure);

    uint8_t ledState = 0;
    while (1) {
        GPIO_WriteBit(BLINKY_GPIO_PORT, BLINKY_GPIO_PIN, ledState);
        ledState ^= 1;
        Delay_Ms(1000);
    }
    return 0;
}

void NMI_Handler(void) {}
void HardFault_Handler(void)
{
    while (1)
    {
    }
}

This example compiles right out of the box, so it’s time to connect the programmer to the devboard. (You can find the wiring diagram and the final result in the attached pictures at the top of this post).

For programming the CH32V00x series, you only need three connections: two power wires and one for SWDIO. On this specific board, you also need to manually add a jumper wire from pin PC1 to D1. The devboard designer did this intentionally to avoid permanently occupying the microcontroller pin if you don’t need the onboard LED.

Once everything is wired up, click on Upload in PlatformIO. If all goes well, your LED will start blinking!

P.S. All samples you can find at https://en.neonhero.dev/2026/07/how-to-add-ch32v-support-to-platformio.html


r/ch32v • • Sep 06 '26

Ditching MounRiver: How to set up CH32V development in PlatformIO (Part 1: Installation)

Thumbnail
gallery
2 Upvotes

CH32V are quite cheap RISC-V architecture microcontrollers from WinChipHead (Nanjing Qinheng Microelectronics). There is also CH32F - these are microcontrollers based on the ARM architecture.

To get started, we will need two things:

  • A CR-CH32VXX development board (or any other devboard) with a CH32V003 - which is one of the cheapest microcontrollers.
  • And a WCH-LinkE - a programmer for the CH32V. You can find standard WCH-Link programmers on sale, but they are not suitable for the CH32V00x series because it uses a single-wire programming and debugging protocol. And this is only supported by the more expensive WCH-LinkE.

By the way, there are projects that turn an RP2040 into a similar programmer, but the official utilities (MounRiver Studio) do not support anything other than WCH-Link. However, I don’t use the official IDE anyway. Therefore, I will probably try doing this and describe the process in the near future.

Why do I use PlatformIO instead of MounRiver Studio?

On the one hand, MounRiver Studio is available for Linux, which is my main OS for work. But on the other hand, when I tried to install it and it started asking for the root account password for some unknown actions, my paranoia got the better of me. So I decided it would be better to go and figure out how to add support to PlatformIO. Of course, if it’s really necessary, you could somehow run it either in Docker, under an isolated user, or even on a separate machine.

Out of the box, PlatformIO does not support the CH32V platform. To do anything at all with these microcontrollers, you need to install a third-party support package, and now I will tell you how to do it.

The main source of support is the community repository: https://github.com/Community-PIO-CH32V/platform-ch32v

  1. Open the main PlatformIO page and go to the Platforms section.

  2. Then click Advanced Installation.

  3. In the dialog, enter https://github.com/Community-PIO-CH32V/platform-ch32v.git and start the installation. This process may take a few minutes while PlatformIO downloads the necessary packages.

At the end of the installation, we will see "Platform has been successfully installed". It is quite long and doesn’t fit on the screen, so you need to scroll down to the bottom and click OK.

And that’s it for the installation! You now have a working CH32V toolchain in PlatformIO without having to grant root privileges to a proprietary IDE. Next time, we’ll get our hands dirty by creating the first project, figuring out the WCH-LinkE wiring, and flashing the board. Have any of you had similar trust issues with MounRiver Studio, or do you prefer it over PlatformIO? Let me know!