CH32V003F4P6 - I2C communication

In this article, I continue the lecture series with a new topic. I will cover some basics of i2c communication and I will demonstrate some code where I communicate with a sensor by sending instructions to it and reading values from it. The CH32 code examples don’t have any transmit and receive functions implemented that we can find in the Arduino IDE as Wire.read() and Wire.write(), so one of the first things to do will be to implement these. Then the rest is easy because we just need to use these two functions to send and receive data from an i2c device.

 

The i2c communication consists of two lines: serial data line (SDA) and serial clock line (SCL or SCK). These lines are typically pulled up to the supply voltage (VCC) with a 10k or 4k7 resistor depending on whether the VCC is 5 V or 3.3 V. The communication is based on a master-slave arrangement where the master generates the clock signal and initiates the communication and the slave receives the clock and responds to the communication. There can be several slaves on the same line if they have a different i2c address. Some modules are addressable whereas others come with a baked-in address, so only one of them can be used per i2c line. This issue can be circumvented with an i2c multiplexer and some extra coding.

I chose two devices for this demonstration. I have fairly good experience with both of them already, I used them in multiple Arduino projects. One is the PCF8574 8-channel IO expander and the other is the AS5600 magnetic encoder. The IO expander is really easy to work with and we can write a library for it very easily, so it is a good chip to practice the basics of i2c. Although I won’t implement a whole library, I will just put all the relevant functions in the main.c file. And the AS5600 is a good “next step” after learning some basics using the PCF8574. I actually have a very popular video on the AS5600 encoder and its Arduino code. A few years ago I implemented the whole i2c functionality for Arduino, I just did not publish it as a library but only as a .ino file (article link). So, it is a great exercise to do it again but for the CH32 microcontroller family.

 

PCF8574 - 8-channel IO expander (Click for the affiliate link!)

AS5600 - Magnetic encoder (Click for the affiliate link!)

 

Basic i2c communication implementation

Before we write a code for a specific hardware, we need to be able to send and receive data via the i2c bus. So, apart from initializing the i2c peripheral, we also need to implement communication functions.

I won’t put the code of the initialization here, because it is basically the same that we can find in the examples. However, I implemented the send and receive functions (Arduino’s Wire.read() and Wire.write() analogues) because they can’t be found in the examples. Or, well, they are there but not in such a structured way that I present it here and there is a pretty important detail which is not mentioned in the example but it can pretty much render everything unusable if it is not taken into account.

To send a byte to an i2c device, the following steps should be done:

  1. The SendByte() function first waits for the i2c bus to become available.

  2. Then, it generates a starting condition on the I2C1 peripheral.

  3. Then it waits until it makes sure that the MCU (master) indeed successfully generated a start condition.

  4. Then it specifies the direction of the transfer which is in this case MCU → slave device. Also, there is an extremely important step here. The device address that we work with is stored on 8-bits. However, as the name of the function also suggests, it only sends 7-bits and the LSB (8-th bit) will be set later based on whether we are sending or receiving. So, it is very important to shift the address of the device left by one before passing it to this function, otherwise, the address will be invalid and the devices won’t communicate with each other.

  5. Then the code waits until the slave acknowledges that the address is correct and it is ready to receive data.

  6. Then the code waits until the TXE flag changes to SET which means that the i2c data register is empty and the master can send the next byte of data.

  7. Then the code finally sends one byte of data at a time.

  8. Finally, it waits until the byte is successfully transmitted and once it is done, a STOP is generated to stop the i2c communication on the bus.

The reading works fairly similarly. The code waits until the i2c line is no longer busy and then it starts the i2c communication. It selects the mode and then specifies the direction of the data transfer. When receiving, the direction must be specified as Receiver because now the microcontroller should get data from the i2c device.

Then, before receiving data from the i2c line, the code makes sure that the slave acknowledges that it must send data to the MCU and then it waits until the receive data register becomes empty. Finally, the communication is concluded and the function returns the received 8 bits of data.

 
void I2C_SendByte(uint8_t address, uint8_t data)
{
    while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) != RESET);

    I2C_GenerateSTART(I2C1, ENABLE);

    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

    I2C_Send7bitAddress(I2C1, address << 1, I2C_Direction_Transmitter);

    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));

    while (I2C_GetFlagStatus(I2C1, I2C_FLAG_TXE) == RESET);

    I2C_SendData(I2C1, data);

    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));

    I2C_GenerateSTOP(I2C1, ENABLE);
}
uint8_t I2C_ReceiveByte(uint8_t address)
{
    uint8_t receivedData; 

    while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) != RESET);

    I2C_GenerateSTART(I2C1, ENABLE);

    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

    I2C_Send7bitAddress(I2C1, address << 1, I2C_Direction_Receiver);

    while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));

    while (I2C_GetFlagStatus(I2C1, I2C_FLAG_RXNE) == RESET);

    receivedData = I2C_ReceiveData(I2C1);

    I2C_GenerateSTOP(I2C1, ENABLE);

    return receivedData;
}
 

PCF8574 - basic IO control

So, we have this IO expander which is a very simple tool. With only 2 pins (SDA and SCL) on the microcontroller, we can get 8 additional GPIO ports. The net gain is 6 GPIO ports because we had to use 2 pins for the i2c, but if there are multiple devices on the i2c bus, then it can be considered as 8 gained GPIO pins because we would’ve used i2c anyway.

The working principles are really simple. We can read or write these 8 GPIO pins (polling, toggling), and we can even use the interrupt pin on the GPIO expander to send an interrupt signal to the microcontroller when a riding or falling edge is detected on any of the port inputs when they are in input mode.

Further nice feature of this board that it is addressable and the board I use can be chained to another identical board. So, by using these features, we can chain up a total of 8 boards (8 possible addresses) and get 64 extra GPIO pins.

There is only one thing that needs a little extra attention. The pins of the expander are not good at sourcing current (see 8.2.1.1 in the datasheet). Therefore, we cannot directly drive LEDs with them well. However, it can sink current! So, if we want to switch LEDs with this board, all we have to do is connect the LED to the power supply’s positive rail through an appropriate current limiting resistor and then use one of the GPIO pins as the ground to finish the LED’s circuit. This connection is illustrated in the image next to this text block.

The code is actually pretty simple. After all, we already implemented all the necessary functions in this or in the previous lessons, we just need to cut and paste them together.

First of all, I wrote the function to be capable of receiving commands from the USART. My idea was to control the 8 GPIO ports and toggle them by sending the port number (0-7) to the microcontroller. We learned this very thing in an earlier lesson where we discussed the USART. Since I just want to use the functionalities of the GPIO expander, I did not bother implementing the interrupts on the USART. You can do it yourself based on the resources provided in the previous lesson.

The function is simple. First, it takes the received data from USART 1 and passes it to the receivedData variable. Then, this variable is first converted from ASCII value to a real integer by subtracting ‘0’ from it. This value after the conversion represents the bit number from 0-7 which is also the port (pin) number on the GPIO port. So when the number is 1, the pin 1 is manipulated. Together with the bit shifting to the left operation, we create an 8-bit mask (e.g. i = 1: 00000010). Then using the XOR operation (^) the result will flip the i-th bit which is in case the 1st bit. This basically toggles the i-th bit which is at the end will result in toggling the i-th pin because after the bit operation, the next line in the code sends the result to the PCF8574 GPIO expander via i2c. For clarity, when the bit is set to zero, the pin can sink current so the LED turns on. On the contrary, when the bit is set to 1, the pin can not sink current, so the LED turns off.

Similarly, we can read the pin configuration by reading the GPIO expander. We send a ReceiveByte() command to the GPIO expander which will then return a byte that contains the actual state of the 8 GPIO pins on the module. Then, with a simple for() loop and some bit shifting, we can print each individual bit. The way I do it is an “LSB first” printing which first prints the value of pin 0, then pin 1…, all the way up to pin 7.

In my example in the video, I just present the example through LEDs, but you can read a button press in the same way. Actually, I have an old Arduino tutorial where I read the pressed button from a keypad using the PCF8574 GPIO expander.

 

LED connected to the PCF8574 circuit in a sinking configuration. 

void toggleLEDUSART()
{
    if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) != RESET)
    {
        uint8_t receivedData = (USART_ReceiveData(USART1));
        IOConfig ^= (1 << (receivedData - '0'));
        I2C_SendByte(0x20, IOConfig); 
    }
}
void readIOExpander()
{
    uint8_t IOpins = I2C_ReceiveByte(0x20);
    for(int i = 0; i <8; i++) 
    {
        printf("%d ", (IOpins >> i) & 1);
    }
    printf("\n");
}
 

AS5600 - Magnetic encoder

I am not going to introduce the encoder and its working principles here too much because there’s a nice datasheet on the web and I also made several videos [1, 2, 3] about it, although for Arduino. In a nutshell, inside the chip’s body, there are four, carefully placed Hall sensors in each corner of the chip. Based on the different magnetic fields sensed by these Hall sensors, the chip can figure out the position of the diametrically magnetized magnet’s magnetic field over it and convert it into an angle value. This value, when converted into degree angles, is a 0-360° value, so this magnetic encoder is essentially an absolute encoder. It has a 12-bit resolution, so in ideal circumstances, it can recognize as small as 0.088° rotation.

Actually, it is fairly simple to read the sensor.

First, and foremost, we need to know the address of the chip. In every case, this chip has a fixed and unchangeable address which is 0x36. As a side note, this prohibits us from using several AS5600 chips on the same i2c bus, unless we use an i2c multiplexer.

Then, if we open the datasheet and look at the register map in Figure 21 we will see that the raw angle is available from two addresses: 0x0C and 0x0D where 0x0C represents the high-byte (8:11) and 0x0D represents the low-byte (0:7). So, we need to read them in quick succession, then combine them into one single number.

Despite the fact that they are bytes, the variable that we use for storing them must be a 16-bit integer and not an 8-bit integer. This is because, on the high byte, we need to perform a bit-shifting operation. The bits stored in the high-byte register correspond to the bit values of the 12-bit conversion result from bit 8 to bit 11. However, when they are read from the i2c and passed to the highByte variable, they are in bit 0:3 in the variable. So, before combining the low-byte and high-byte, the highByte variable must be shifted by 8-bits to the left so the 0:3 position where the values are initially stored becomes 8:11.

Click on the image for the affiliate link!

 

Click on the image for the affiliate link!

 
 
int16_t ReadRawAngleAS5600()
{
    uint8_t address = 0x36; 
    int16_t lowByte, highByte;
    int16_t rawAngle;

    I2C_SendByte(address, 0x0D); 
    lowByte = I2C_ReceiveByte(address); 
    printf("lowByte: %d\n", lowByte); 

    I2C_SendByte(address, 0x0C); 
    highByte = I2C_ReceiveByte(address);
    printf("highByte: %d\n", highByte);

    highByte = highByte << 8; 
    rawAngle = highByte | lowByte; 

    return rawAngle;
}
lowByte = 0000 0000 1000 0011
highByte = 0000 0000 0000 1111

highByte << 8 = 0000 1111 0000 0000

highByte      = 0000 1111 0000 0000
lowByte       = 0000 0000 1000 0011
-----------------------------------
highByte | lowByte = 0000 1111 1000 0011
Next
Next

Reflow hot plate update