Bit-bang vs. Hardware I2C: Running U8x8 on CH32V003
In a project with an OLED display and an STM32F103 microcontroller, a question arose: can we make this cheaper? What if we use the CH32V003? We don’t need many pins, so the SOP16 package will be enough. The main question is how it will work with the SSD1306 OLED.
Let’s find out.
As I wrote earlier, U8x8 should be easy to port and can work with
software I2C (bit-bang). Porting the code from CMSIS went very quickly —
a couple of cups of coffee, and I got the example working. Here is the
code for the u8x8_gpio_and_delay_ch32v function:
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_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;
}
The key detail when configuring the pins is to use open-drain mode (GPIO_Mode_Out_OD). While this might not be critical for the OLED display itself, some I2C devices can also pull the bus to ground. If the pin operates in a standard Push-Pull mode, it could cause a short circuit between VDD and GND.
With these settings, the software I2C worked perfectly:
However, I didn’t want to stop there. Out of curiosity, I continued my experiments and connected the U8x8 library using Hardware I2C as well. I intentionally left both methods in the code to clearly compare how much faster the hardware implementation is compared to “bit-banging”.
Moreover, I tested the display not only at the standard I2C frequency of 400 kHz but also overclocked the bus to 1600 kHz — which turned out to be the maximum frequency where the display controller still reliably receives data. Hmm, I might have been able to squeeze out even more if I resorted to overvolting :)
So, let’s take a look at the results of our comparison:
Here is what the numbers look like:
Software I2C (bit-bang) — output around 3 frames per second (332 ms to draw a single frame). This is a perfectly acceptable speed if you only need to display static information.
HW I2C at 400 kHz — delivered 12 frames per second (83 ms per frame). With this FPS, you can already build a proper UI, draw menus, and handle active elements.
HW I2C at 1600 kHz — pushed out a whopping 32 frames per second (31.6 ms per frame). At this speed, you can run smooth animations, play simple videos, or even make a retro console emulator (in monochrome, of course).
Alright, enough theory. Here is the ready-to-use C code for testing:
#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 kHzControlling 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.

Comments
Post a Comment