CH32V003F4P6 - I2C bit banging - OLED display

So, after writing proper functions for the i2c communication and taming two different i2c devices, let’s take a step back and let’s implement a “bit-banged i2c”. Instead of using the nicely developed code from the previous lesson, I will show you how to bit-bang the i2c communication. I decided to do so because I wanted to drive a small 0.42” OLED display using my i2c code, but I could not make it work. So, I Googled for a little bit and found the datasheet of the display alongside some resources. I also searched for the display’s driver: SSD1306. By combining these resources, I wrote a rudimentary library for my display.

 

Bit-banged I2C functions

As I mentioned in the introduction, I found the datasheet for the display. Fortunately, there is some pseudocode provided with it that, after a bit of tweaking, works on the CH32V003F4P6 microcontroller and successfully starts the display.

The pseudocode was implemented with bit-banged i2c communication. Initially, I tried to use the proper i2c code that I implemented previously, but it did not work. So, this bit-banging i2c code was a good alternative.

First, we need to take care of the i2c pins. I use the same PC1 and PC2 pins which are the original i2c pins, however, I assign them as normal output pins instead of assigning them according to their alternate function. So, their initialization is really simple, we already learned these things in the previous lectures. The only difference is that I define the SDA pin as “OD” (Open Drain) which is a necessary configuration to make it work properly. The original i2c implementation also configures the SDA pin as OD.


Then, let’s look at the i2c communication. It is made for transferring a byte of data at a time, each time when the function is called. Consequently, I created a variable, called counter, that is initialized with the value of 8. Then a while loop is created which depends on this value which means that the loop will count 8 times. Once for each bit in the data byte.

Then, the if() block checks the MSB of the data byte by performing a bitwise AND operation with 0x80 (0b10000000). This determines the value of the SDA pin which is set accordingly.

Then a clock pulse is generated by toggling the SCL high, then after a brief delay, it is brought low.

Finally, the data byte is shifted to left by 1 bit. This moves the data byte’s next bit to the MSB position which prepares this bit for transmission in the following loop iteration.

For example, let’s say data = 0x7f or 0b01111111.

The first iteration will perform data & 0x80 → 01111111 & 10000000 = 0. This sets the SDA to 0, so the else block is performed and PC1 is set to low. Then, after toggling the clock line, the data byte is shifted left, so the original 01111111 becomes 11111110.

The second iteration (counter = 7) is data & 0x80 → 11111110 & 10000000 = 1. The SDA is set to high, the SCL is toggled and the data is shifted to the left again. Since the rest of the data bits are 1, the SDA bits will also become one until the rest of the iterations on the while loop. So, in the end, the SDA pattern will be 011111111 which matches the value of data (as it should).

 
void bitbangI2C_Init()
{
    GPIO_InitTypeDef GPIO_InitStructure = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC , ENABLE);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 ; 
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_30MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; 
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_30MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);
}
 
void I2C_BitBang(uint8_t data)
{
    uint8_t counter = 8; 

    while(counter--) 
    {
        if(data & 0x80) 
        {
            GPIO_WriteBit(GPIOC, GPIO_Pin_1, SET); 
        }
        else
        {
            GPIO_WriteBit(GPIOC, GPIO_Pin_1, RESET); 
        }
        Delay_Us(3);
        GPIO_WriteBit(GPIOC, GPIO_Pin_2, SET); 
        Delay_Us(3);
        GPIO_WriteBit(GPIOC, GPIO_Pin_2, RESET); 
        Delay_Us(3);
        data = data << 1;
    }
}
 

Now, we have the basic i2c transfer function (I2C_BitBang()), so we can implement the start, stop and acknowledgement functions.

The start, stop and acknowledge clock signals are shown in the SSD1306 datasheet.

SSD1306 driver start and stop conditions on the i2c bus

The start requires the SDA to be low and the SCL to be high. Then the SCL has to be broght low and we can start the transfer to the selected address. I decided to put the address in the argument of the function and define the variable right in the beginning of the code. The I2C_Start() function is not used too often, so it is not a big trouble to do it like this and it makes the whole code more flexible.

The stop is the opposite. The SDA must be low while the SCL is high and the SDA has to be brought high. No transfer is done, since we just stopped the i2c communication.

The acknowledgement requires the SDA and SCL to be high and then the SCL must go LOW.

SSD1306 driver acknowledgement condition on the i2c bus

 

Support my work, and buy the board using my affiliate link!

void I2C_Start(uint8_t address)
{
    GPIO_WriteBit(GPIOC, GPIO_Pin_1, RESET);
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_2, SET);
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_2, RESET);
    Delay_Us(3);
    I2C_BitBang(address);
    I2C_ACK();
}
void I2C_Stop()
{
    GPIO_WriteBit(GPIOC, GPIO_Pin_2, SET);
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_1, RESET); 
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_1, SET);
    Delay_Us(3);
}
void I2C_ACK()
{
    GPIO_WriteBit(GPIOC, GPIO_Pin_1, SET); 
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_2, SET); 
    Delay_Us(3);
    GPIO_WriteBit(GPIOC, GPIO_Pin_2, RESET);
    Delay_Us(3);
}
 

Now we can go towards more display-specific functions. I distinguish between data and command transfer for the sake of simplicity when it comes to coding. The only difference between the two codes is the first byte we send after the i2c communication is started.

The command transferring function starts with the recently introduced start function and then it sends a 0x00 number to the target. After acknowledging, it sends the command byte to the target, too. Then, another acknowledgement and the code stops the i2c communication.

The data transferring function is really similar, except the first transferred byte is 0x40 indicating that the following byte will be a (byte of) data.

Both functions have the i2caddress variable passed to the I2C_Start() function as a parameter. This variable is defined at the beginning of the code as a global variable.

uint8_t i2caddress = 0x78;

The i2caddress is a unique address which depends on the device.

 
void I2C_Write_Command(uint8_t command)
{
    I2C_Start(i2caddress);
    I2C_BitBang(0x00);
    I2C_ACK();
    I2C_BitBang(command);
    I2C_ACK();
    I2C_Stop();
}
void I2C_Write_Data(uint8_t data)
{
    I2C_Start(i2caddress);
    I2C_BitBang(0x40);
    I2C_ACK();
    I2C_BitBang(data);
    I2C_ACK();
    I2C_Stop();
}
 

OLED configuration

The following code is the initialization code for the 0.42” OLED display. All parameters can be understood by studying the datasheet and Table 9-1 Command Table.

In most cases, the default values can be used which are also described and explained in the datasheet. I don’t write down everything again, I just show here how to understand and interpret the datasheet.

All we need to do is to look at Figure 2. This flow chart shows the order of the registers and their values. So, I just simply created a function that writes the corresponding commands on the i2c bus (and thus sends it to the display).

Some commands are single-line commands. For example, the first and the last lines are the display OFF and display ON commands and they only consist of one line.

However, for example, the Set display offset command (0xD3) has two lines. The first command (0xD3) tells the display that we are about to set the display offset, and the second command or second byte we send right after it is the actual value. Since I don’t want any offset, I simply send a 0x00 command which means “no offset” (see, datasheet). Or, the contrast command (0x81) also expects a second value because the first command tells the display that we’re about to modify the contras and the following value is the actual value of the contrast. I set it to 0x20 which is 32 decimal. The max contrast value is 255 (0xFF), so my value is relatively low, but it works perfectly.

Although, there is one thing that is not part of the default configuration and it makes the display better.

Sending the 0xAD, and then the 0x30 commands to the display before turning it on (0xAF) makes it brighter.

I2C_Write_Command(0xAD);
I2C_Write_Command(0x30);
 
 
void initOLED() 
{
    I2C_Write_Command(0xAE);
    I2C_Write_Command(0xA8); 
    I2C_Write_Command(0x27); 
    I2C_Write_Command(0xD3); 
    I2C_Write_Command(0x00); 
    I2C_Write_Command(0x40); 
    I2C_Write_Command(0xA1); 
    I2C_Write_Command(0xC8);
    I2C_Write_Command(0xDA); 
    I2C_Write_Command(0x12); 
    I2C_Write_Command(0x81);
    I2C_Write_Command(0x20);
    I2C_Write_Command(0xA4); 
    I2C_Write_Command(0xA6); 
    I2C_Write_Command(0xD5); 
    I2C_Write_Command(0x80);
    I2C_Write_Command(0x8D);
    I2C_Write_Command(0x14); 
    I2C_Write_Command(0xAF);
}
 

Font library

Before developing any fonts for the display, we need to understand how we can draw on it.

The display is divided into pages and segments. A page of the specific display I use in this demo consists of 8 vertical pixels and 72 horizontal pixels (segments). Since the display has 40 vertical pixels, the total number of pages is 5. Each page can be addressed. Then, each segment can be addressed. So by knowing which page and which segment we toggle, we can control each individual pixels on the display.

Let’s say, we can squeeze in each character in a page (8 px tall) and in 5 segments (5 px wide). This way we just create 5x8 matrices which are filled in with zeroes and ones and wherever the matrix has the item “1”, the pixel is lit up. As shown in the pictures on the side, the numbering of the segments goes from left to right and the LSB is located at the top left corner. In this demonstration, I will populate the segments from LSB to MSB, so the first pixel of the selected segment is at D0, and the last pixel is at D7. You’ll see later that it makes sense.

So, now we just need to create a huge matrix that contains all the relevant characters. The most straightforward way is to implement the ASCII table.

For example, the 1st character is the space (“ “) which has the ASCII value of 0x20 and is represented by five zero bytes: {0x00, 0x00, 0x00, 0x00, 0x00}.

The second character is the exclamation mark (“!”) which has the ASCII value of 0x21 and is represented by the following bytes: {0x00, 0x00, 0x4f, 0x00, 0x00}. Here we can see that the middle column (segment 2) is 0x4F which is 01001111. So starting at the end and reading from right to left we can imagine the shape of the exclamation mark (“!”). Three pixels for the line part, then 2 pixels of gap and then the dot as a single pixel. In fact, this is how we fill in the segments. While we go from D0 → D7, we “read” the binary of the byte backwards. This is simplified in the display because we set the addressing mode, the segment remapping and the port scan direction accordingly.

Then let’s say we want to print capital K on the display. This can be represented with the following 5 bytes: {0x7f, 0x08, 0x014, 0x22, 0x41}. First, we convert them to binary numbers, so it is easier to see how they lit up the pixels (or leave them off). I show the binary values on the right side. Then starting from page 0, segment 0, we pass these bytes to the corresponding segments. 0x7f goes to segment 0, 0x08 to segment 1…etc. Segment 4 is the last segment of the letter.

 
 

GDDRAM of the SSD1306 display

Letter “K” printed on the OLED display. The 5×8 grid is drawn on the picture for a more illustrative image.

 

A block populated with pixels showing the letter “K”

0x7f = 01111111
0x08 = 00001000
0x14 = 00010100
0x22 = 00100010
0x41 = 01000001

Binary representation of the bytes that make up the letter “K”.

 

Printing a character

Now the display is configured, let’s print characters on it.

First, we have to jump to a specific page. I wrote the function in a way so it accepts the page number as a parameter. After the set page command (0x22) the actual page number (0xB0+page) is passed to the display.

Then the 0x04 command sets the right border of the display.

Since I want to dynamically print characters, I have to do the segment addressing accordingly. The default lower (0x11) and higher (0x0c) addresses are combined into a single number and then this single number is incremented by the column number. Then the results are decomposed into a new lower and higher segment address value and they are passed to the display.

Finally, a for() loop iterates over the 5 segments (remember, one character is displayed on 5 segments) and based on the letter we pass to the printCharacter() function, we send the corresponding “vector” and its i-th item to the display.

Then we add a horizontal offset by sending the 0x00 command.

 
void printCharacter(uint8_t page, uint8_t column, uint8_t letter)
{
    I2C_Write_Command(0x22); 
    I2C_Write_Command(0xb0+page); 
    I2C_Write_Command(0x04);

    uint16_t address = (0x11 << 4) | 0x0C; 
    address += column; 
    uint8_t newLower = address & 0x0F; 
    uint8_t newHigher = (address >> 4) & 0x0F;  

    I2C_Write_Command(newLower); 
    I2C_Write_Command(0x10 | newHigher); 

   for (uint8_t i = 0; i < 5; i++)
   {
       I2C_Write_Data(font[letter][i]);
   }

   I2C_Write_Data(0x00);
}
 

Printing a text

Now we can print one character at a time by manually addressing the column and row where we want to print it. We can build on this function and print actual texts. To make it fancier, I send the text to the microcontroller via USART, using interrupts.

So, first, let’s look at the USART code. I made almost the exact same code in my interrupt-related tutorial, go and check it for more details. What we can see is that if the interrupt detects a change in the USART_IT_RXNE register’s value, it reads USART1 and passes the value of the buffer to the receivedCharacter variable.

Then the next line checks if the received character is an endline character. If yes, then it places a null-terminate character in the buffer, resets the indexing of the buffer and changes the usart_completed flag to 1 which indicates to the code that we received all the characters from the serial port.

Otherwise, if a regular character comes from the USART, the buffer index is advanced and the character is placed in the buffer.

You can see that I used a ridiculously large buffer (120). The display has 5 lines and each line consists of 72 pixels. A character is 6 pixels (5 px + 1 offset), so in total, we can only display 5 x (72/6) = 5 x 12 = 60 characters. A smaller buffer would have been enough, but since I don’t need to worry about the memory just yet, I just assigned a random, large enough size to my buffer.

The printText() function accepts three arguments. It points at the buffer I just introduced and then the user can select where (which line and column) the text should start.

First, I pass the columnStart variable to an internal variable which will later on be manipulated as we introduce multiple lines during printing.

Then, the whole code is just iterating through the buffer until the null-terminate character is reached.

The code picks the character from the buffer and prints it on the serial terminal. The printing is not necessary, however, I wanted to see of the code behaves correctly. Then the code checks if the character’s ASCII value falls outside the ASCII table. This would be an invalid character that we cannot print on the OLED display, so then this iteration would jump over the rest of the code (continue).

If the character has a legit ASCII value, the code proceeds and the code determines the position of the font inside our ASCII table. The code subtracts 0x20 from the ASCII code of the character. If you remember, our ASCII table starts with the space (“ “) character whose ASCII is 0x20. This is the first character in the array that stores the “bitmaps” of each pixel and its index is zero. Since the index starts at zero, but the first item’s ASCII is 0x20, every character’s ASCII value must be shifted by 0x20 by subtracting 0x20 from them.

To make sure we did it right, a printf() prints the font position on the terminal. Sending a space (“ “) to the microcontroller should return 0.

Then the previously introduced printCharacter function is called and the corresponding parameters are passed to it.

Then, we need to prepare the next character’s position. The column is shifted up by 6. So the second character is being printed in segment 6. Shifting by 6 introduces an empty column between the adjacent characters.

Then the code checks if the new column value is below 72-6 which means that we can still print one more character before we reach the end of the display. If the column value is larger than 72-6, then the value of it is reset to the original starting point and the pageStart value is increased by one which means that the text is continued in a new line. I want to emphasize here that this is a super simple code, so it does not care if, for example, a word or a number is split into two. It just prints a continuous string of characters, that’s all.

Finally, if the pageStart is larger than 4, it would stop the printing because we ran out of space on the display. The display has 5 rows only, page 0 - page 4.

 
void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        receivedCharacter = USART_ReceiveData(USART1);

        if (receivedCharacter == '\n')
        {
            receivedBuffer[buffer_index] = '\0'; 
            buffer_index = 0;
            usart_completed = 1; 
        }
        else
        {
            if (buffer_index < 120 - 1) 
            {
                receivedBuffer[buffer_index++] = receivedCharacter;
            }
        }
    }
}
void printText(const uint8_t *receivedBuffer, uint8_t pageStart, uint8_t columnStart)
{
    uint8_t column = columnStart;

       for (uint8_t i = 0; receivedBuffer[i] != '\0'; i++) 
       {
          char currentChar = receivedBuffer[i]; 
          printf("Char received: %d\n", currentChar); 

          if (currentChar < 0x20 || currentChar > 0x7E)
          {
              printf("Skipping invalid character:  ", currentChar);
              continue; 
          }

          uint8_t fontPosition = (int)currentChar - 0x20;
          printf("Char operation: %d \n", fontPosition);

          printCharacter(pageStart, column, fontPosition);

          column += 6;

          if (column > 72 - 6)
          {
              column = columnStart; 
              pageStart += 1;      
          }

          if (pageStart > 4)
          {
              break; 
          }
       }
}

If you found this content useful, please consider joining my YouTube channel’s membership or leaving a donation.

Also, please consider using my affiliate links when buying relevant gadgets.

Previous
Previous

Miniature breadboard voltmeter

Next
Next

CH32V003F4P6 - I2C communication