5 February 2025
I’m not giving up on the CH32X line just yet. I remembered last night that I do, in fact, have a CH32X035C8T6 development board in stock in the lab. This is the LQFP48 package, so no remapping need be done for the I2C lines.
The board is the “official” WCH CH32X035C-R0-1v2. It is largely similar to the other board with a smaller package, except is does have an extra push button mounted, labelled “S1/KEY”. The schematic, however, shows it connected to PA21/RSTN, so perhaps it can be configured as a reset button.
The first thing to do is to attach the WCH-LinkE device programmer. I built a custom cable to connect +5V, ground, SWCLK & SWDIO, TX & RX for USART1 and PA21/RSTN. I also connected LED1 to PA0 using an additional jumper.
Now to see if the default MRS2 application will blink the LED for me. And it does. It also prints a debug message on the serial console, so this verifies most of the wiring on the new programming cable.
Next I want to see if the “reset” button actually acts like a reset button, or if further configuration is required. It works! Well, that’s going to save me a bit of time.
And just to be sure, I’ll run the EVT example HOST_KM, making sure to change the ‘device’ setting to the C8 variant. Good news: it also works. It detected when I plugged in the i8 wireless dongle and responded to keypresses on the i8, as well.
Now on to hooking up the OLED module, which was where things became irksome yesterday. I was able to re-use one of the OLED module test cables from another project, so I didn’t have to build one from scratch.
To test the OLED, I need only send it the “DISPLAY ON” command. To get there, a few things have to happen. First I have to initialize the GPIO pins SCL/PA10 and SDA/PA11 correctly, then enable the I2C1EN peripheral clock, as well as some setup for the I2C peripheral itself.
I have created a new MRS2 project called C8-SH1106 in the CH32X035 folder to get the OLED up and working again. I hope that Future Me does not confuse this “C8-SH1106” with the one I wrote for the 203. I’m leaving most of the supplied software in place.
Initializing the GPIO pins has alerted me to the fact that the GPIO pins of the CH32X035 devices are different from the CH32V parts I have used previously. I had noticed before that only one ‘output speed’ configuration was declared in the EVT examples, that being 50 MHz, which ocrresponds to the fastest of the options for the other devices. I did not look to see if that was an oversight or omission in the EVT code at the time. As I was going to configure the pins as ‘alternate function, open-drain’ outputs, I see that this is not an option here. There is a mode for ‘alternate function, push-pull mode’, with a note, “I2C automatic open drain”. Well, that’s what we’re here to find out, I guess.
Enabling the peripheral clock for the I2C port using the vendor-supplied SDK is easy enough:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
I only had to refer the to RM to find out which bus the I2C peripheral was on before invoking the correct command.
Now ‘sending a one-byte command to the OLED via I2C’ sounds straight-forward, doesn’t it? Even assuming that both the GPIO pins and the I2C peripheral are all properly initialized, the process is far from straight-forward.
It’s obviously true that I2C, as a protocol, does work. And even though I have very specific examples of my own code that successfully works using these OLED modules, it never ceases to amaze me how complex the actual interaction can be when dealing with the bare metal.
Here is the outline of how to ‘send a one-byte command’ to the SH1106 OELD controller chip (after initialization):
Step 1: Wait for the I2C bus to be ‘not busy’.
It’s not complicated at all. There is a single bit in the STAR2 status register called, not enigmatically, ‘BUSY’. If this bit is set, the bus is busy. Wait your turn. If the bit is clear, the bus is not busy. Do as you will.
The vendor-supplied SDK has a function to get the value of a single status flag. You pass in the pointer to the peripheral and a bit mask identifying the flag you want. Since this chip only has one I2C peripheral, I’m pretty sure I’m sending the right value here: I2C1. Other chips have two I2C peripherals. This one has only one. The function call looks like this:
while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) == SET) {
// wait for bus to be not busy
}
I tend to split up while() loops across multiple lines as it makes it easier for the debugger to show me where it is, precisely. Additionally, it reminds me that there probably ought to be a timeout coded in there, so that this “forever loop” doesn’t hang the system. It can also just as easily be coded as:
while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) == SET);
My home-grown code just reads the BUSY status bit directly:
while(I2C1->BUSY == true); // wait for bus to not be busy
Step 2. Generate a START condition on the bus.
Having determined, one way or another, that the I2C bus is ready for some traffic, we begin a transmission by setting the START condition. This creates a special condition on the bus that lets all the attached devices know that something is about to happen. To do this, all that is required is to set the START bit in the CTLR1 control register. The WCH SDK has a function to do this:
I2C_GenerateSTART(I2C1, ENABLE); // generate START condition
It simply sets or clears the START bit in CTLR1 based on the ENABLE or DISABLE parameter passed. My way of doing this is even simpler:
I2C1->START = ENABLE; // set the START condition
Doing this triggers a state change within the I2C peripheral. Before proceeding, you have to wait until the status bits line up in the requisite order. The SDK calls it:
I2C_EVENT_MASTER_MODE_SELECT
My code calls it I2C_STATUS_MASTER_MODE. In either case, it’s simply the combination of the BUSY, MSL and SB flags from the two status registers.
BUSY STAR2, bit 1: 1 = I2C bus is now officially 'busy’
MSL STAR2, bit 0: Master mode (1) vs slave mode (0)
SB STAR1, bit 0: 1 = start bit has been transmitted on the bus
When we have determined that these values all align, it’s time to move on to the next step.
[Breaking News] I just received an answer to my question about changing the base of the values in the Debug view for the registers. Just hover your cursor over the label (not the value) and a tool tip appears with all of the various formats. Why choose, you ask? Can’t we have them all? Yes! Yes, we can.
This is going to be a big help when I’m trying to identify single bits within registers in the future. It happens more than one might think!
Back to the step-by-step guide to I2C function. We’ve set the START condition on the bus, and we have to wait until this is reflected in the peripheral status bits. We can use the SDK function like this:
// wait for peripheral to enter master mode
while(I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT) == NoREADY);
The I2C_CheckEvent() function returns either READY or NoREADY. This just means that the bit pattern of status flags passed in as the second argument matches the current status bit in the peripheral’s status registers. We can proceed to the next step now.
Step 3. Send the device address and direction bit
Every device on the I2C bus has an address. It can be either 7 bits long or 10 bits long. The OLED module we’re using has a 7 bit address. It seems to be fixed at 0x3C on this module. The controller chip itself has an option for using 0x3D as the address, but the signal that controls that is not brought out to any sort of convenient spot on the module itself, so we’re kinda stuck with 0x3C.
The address that we want to communicate with is sent one bit at a time onto the bus, along with another bit that indicates if we’re wanting to write to (0) or read from (1) the device. The 7 address bits are scooched up to the top of the outgoing byte and the direction bit is tacked onto the end. So if you’re actually looking at the bus using a protocol decoder or logic analyzer, you might see 0x78 going out. That’s perfectly correct.
At this point, all we have to do is send the combined device address (shifted left one bit) along with the direction bit out onto the bus by writing to the DATAR register. The SDK has a function for that:
I2C_Send7bitAddress()
But all you need is to shove the shifted address and direction bit out the DATAR register.
// send device address for writing
I2C_SendData(I2C1, (SH1106_I2C_ADDRESS << 1) | I2C_WRITE);
Where I have previously #define’d the following values in the code:
#define I2C_WRITE 0
#define I2C_READ 1
#define SH1106_I2C_ADDRESS 0x3C
This does the needed shifting and combining of bits. My version of the code is predictably simple:
I2C1->DATAR = (SH1106_I2C_ADDRESS << 1) | I2C_WRITE; // send address to write
As before, we now need to wait until the sun and the moon and the status bits at night align in the proper way. The SDK refers to this combination as:
I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED
My previous code used:
I2C_STATUS_MASTER_TRANSMITTER_MODE
Either way, you’re looking for the BUSY, MSL, ADDR, TXE and TRA flags
BUSY STAR2, bit 1: 1 = I2C bus is now officially 'busy'
MSL STAR2, bit 0: Master mode (1) vs slave mode (0)
ADDR STAR1, bit 1: Address sent and matched
TXE STAR1, bit 7: Transmit register is empty
TRA STAR2, bit 2: Data transmitted
And once you’ve seen these bits are all set, it’s time to actually start talking to the now-addressed OLED module.
Note that if, for whatever reason, the OLED module is not powered up and on the bus, then you’re going to wait a long time. The ADDR bit in STAR1 is only set when the addressed device ‘acknowledges’ the address as part of the protocol. No OLED, no ACK. Here is a really good place to put a timeout or some other code to handle the very real possibility of the OLED not being connected properly.
The ADDR bit is also a good way to write a ‘bus scanner’ program that loops through all the valid I2C addresses and sees who responds with an ACK and who doesn’t. The whole point of the I2C bus was to be able to connect several devices together and talk back and forth using a minimum number of wires.
So can we send the ‘DISPLAY ON’ command, already? No! We cannot. That’s not how one talks to a SH1106 OLED controlled chip.
First, we have to send a ‘control byte’. It only has two interesting bits in it. One is called the ‘continuation bit’ and the other is the ‘D/-C’ bit. If the D/-C bit is cleared (0), then the next byte is a command for the controller chip. If it is set (1), then it is data to be written to the display memory.
I use these values to indicate which bits are set or not in the control byte:
// SH1106 control byte
#define SH1106_NO_CONTINUATION 0x00
#define SH1106_CONTINUATION 0x80
#define SH1106_COMMAND 0x00
#define SH1106_DATA 0x40
Step 4. Send the SH1106 control byte
Since we only want to send a single command byte, we can populate the contol byte as:
I2C_SendData(I2C1, SH1106_NO_CONTINUATION | SH1106_COMMAND); // send control byte
Now we need to wait for this combination of status flags:
// wait for control byte to be transmitted
while(I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED) == NoREADY);
This is a combination of TRA, BUSY, MSL, TXE and BTF flags
TRA STAR2, bit 2: Data transmitted
BUSY STAR2, bit 1: 1 = I2C bus is now officially 'busy'
MSL STAR2, bit 0: Master mode (1) vs slave mode (0)
TXE STAR1, bit 7: Transmit register is empty
BTF STAR1, bit 2: End of byte send flag
Step 5. Send the command
Finally, we can send the eight bits we’ve worked so hard to prepare for.
#define SH1106_COMMAND_DISPLAY_ON 0xAF
The code looks pretty familiar by now:
// send command to turn on display
I2C_SendData(I2C1, SH1106_COMMAND_DISPLAY_ON);
// wait for command to be transmitted
while(I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED) == NoREADY);
Step 6. Set the STOP condition
To finish a transmission (or end a reception), we set the STOP condition on the bus:
I2C_GenerateSTOP(I2C1, ENABLE); // set STOP condition
Since it’s just a single bit in the control register CTLR1, bit 9, we can just set it directly:
I2C1->STOP = ENABLE; // set STOP condition
Compile & run, and we are rewarded with a screen of random garbage. But it’s infitely more interesting than what it previously was.
But I wanted to get this all written out so that Future Me does not spend as much time scratching his head and wondering what was I thinking ??? when he looks at this code again next year.
And yes, that’s all “all” you need to do to get the OLED turned on and showing tiny dots. Never mind that the screen is both backwards and upside down. There are commands to fix that, too.
What’s really interesting to me at the moment, though, is that this is working at all, since there are no pull-up resistors attached to the bus lines. Also, I’m not sure exactly how fast the SCL line is wiggling at the moment. I told it 400 KHz, but have yet to verify that. I have been able to push these displays up to 1 MHz in the past. Tomorrow will be a good time to explore these ideas.