Raspberry Pi Peripheral
Sometimes you just need a real-time MCU to control an operation, but you want the full-blown OS capabilities of an MPU as well. There are many ways to handle this situation, but the one I want to discuss here is implementing a slave I2C interface in an MCU which you can use from a Raspberry Pi.
Register Access
Control of many peripherals involves manipulating and reading registers. Since this is a commonly understood approach, I will show you how to set up register access to your peripheral over I2C.
For your peripheral to be useful, the registers have to be planned out in terms of what functions they trigger and what data they present. That part will be up to you.
To standardize and simplify the layout, the approach I present will use 32-bit registers laid out in a simple array, and any individual register can be one of:
- A read-only register whose value can't be changed from the Pi,
- A simple write register which can be changed by the Pi but doesn't trigger an action, or
- A trigger register that takes a value and causes a call-back action on the MCU.
I2C Basics
I won't go into too much detail on I2C here. There are some ways it can become complicated, but we will focus on a simple approach found in many devices. Our peripheral will be called the 'slave' end of the I2C connection, and the Raspberry Pi is the 'master'. Our peripheral will have a 7-bit address, and in the first part of an I2C transaction the master writes the address to the I2C bus along with an 8th bit that gives a "read" or "write" direction.
Register Write
The way our interface will work for register writes is that the first byte after the address byte will be used as a register address, and following data bytes (up to 4) will be written to the register in least-significant first byte order.
If the Pi sends less than a full word of 4 bytes, the MCU will treat the upper bytes as being 0, so if you want to write an 8-bit value to the register you can send just the I2C address byte, the register address byte, and the data byte. If you want to write more data, send more bytes.
Register Read
To read a register, the Pi should first issue a write with a single byte of data containing the register address, and then it should read up to 4 bytes, in least-significant first order, which are the value of the register. Similarly with writes, if the Pi only wants an 8-bit value it can only read one data byte.
Simplifications
The I2C specification allows the Pi to send multiple segments. In our case, for a register read it can send the write section with the byte of data for the register address, then issue a "restart" and send the read section and the MCU will start putting the register data on the bus.
Another simplification we will use is that the register address stays programmed until you change it with another write command. So if you want to read a register multiple times, you don't have to write the address every time.
Another simplification we could do, but we won't here, is to handle writes and reads longer than 4 bytes by auto-incrementing the register address, so for instance you could update 3 registers by sending the i2c address byte, the initial register address, and then twelve bytes of data which would be written to three consecutive registers. As I said we won't do that here, in fact we won't allow writes & reads longer than 4 bytes of register data.
MCU Basics
What I decided on was an 8 SOIC package of the STM32G0 family. I got an 8-SOIC template from Adafruit (
SMT Breakout PCB for SOIC-8, MSOP-8 or TSSOP-8 - 6 Pack!). But since it is much simpler to start with a dev board, I picked the STM32G0 Nucleo-32 (the Nucleo-G031K8), and that is what I will describe here.
Cube MX Configuration
I use the STM32Cube IDE, and new projects start by picking an MCU variant, or a Nucleo board as a base. So I start with the Nucleo-G031K8 and setup a project, which brings up the CubeMX configuration.
I'm only going over the I2C settings here, so setup the rest of the project per your needs. If you want to use the serial debug over USB feature of the Nucleo, it uses the PA2 and PA3 pins and UART2.
I use the I2C1 device, and set it to I2C mode. Since we will be using it as a slave, the timing values aren't important, but you need to setup the "Slave Features" block, and in particular the Primary slave address value to be the address to which you want your slave device to respond.
Here I've set it to 0x40. For this discussion we won't be using general call or dual address, so they are disabled. "Clock no stretch" is an advanced feature but works pretty well so leave it in stretch mode (Disabled).
Your GPIO settings should be PA9 for SCL and PA10 for SDA. There are other options that will work on the Nucleo, but that's what I picked.
In the NVIC settings, you want to enable the interrupt provided.
Main software
This I2C interface will respond to interrupts, so the first thing we have to do in the main routine is enable "listen" mode on the interface.
/* USER CODE BEGIN 2 */
HAL_I2C_EnableListen_IT(&hi2c1);
/* USER CODE END 2 */Later we will add a block to the main loop that checks for triggered callbacks.
i2c_slave.c
Most of our handling is done by implementing callback functions, and we will do this in a file called i2c_slave.c.
Listen Complete
When the listen interrupt occurs, we simply need to reenable it. This is an odd way of doing things, but it works so here we go. Basically there are other interrupts enabled by the Listen operation and we need them to work.
void HAL_I2C_ListenCpltCallback (I2C_HandleTypeDef *hi2c)
{
HAL_I2C_EnableListen_IT (hi2c);
}Address Complete
When we receive a matching slave address cycle, the HAL calls HAL_I2C_AddrCallback and provides the address and the direction of the transfer. This is the first key step in processing the request, and we handle it by checking the direction and issuing a transfer operation as appropriate. If the Pi is transmitting data (i.e. doing a write), we need to receive the register address and up to 4 additional data bytes. If the Pi is doing a read, we need to transmit up to 4 data bytes from the last addressed register.
NOTE: we could attempt to receive 5 bytes of data here, the register address plus four bytes of register value, but if the Pi transmits less than 5 bytes we would get an Ack Failure and we wouldn't keep track of the data that we did get. If we receive one byte at a time we can keep track of the received data and handle partial writes.
void HAL_I2C_AddrCallback (I2C_HandleTypeDef *hi2c, uint8_t TransferDirection, uint16_t AddrMatchCode)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(AddrMatchCode);
if (TransferDirection == I2C_DIRECTION_TRANSMIT) {
i2c_state = I2C_RX_REGISTER_ADDRESS;
HAL_I2C_Slave_Sequential_Receive_IT (hi2c, ®isterAddress, 1, I2C_FIRST_FRAME);
} else {
registerValue = register_read();
HAL_I2C_Slave_Sequential_Transmit_IT (hi2c, (uint8_t*) ®isterValue, REG_SIZE, I2C_FIRST_AND_LAST_FRAME);
}
}
Slave Receive Complete
When the sequential receive operation completes successfully, the completion callback (HAL_I2C_SlaveRxCpltCallback) is called. Since we are receiving one byte at a time of a multi-byte operation, we need to keep track of how many bytes have come in. And since the register address is a separate one-byte read, we need to keep track of the state - is the next byte we receive going to be the register address, or one of the data bytes.
After receiving 4 data bytes, we know we are done with the transfer and we call our register_update() function to process the data.
void HAL_I2C_SlaveRxCpltCallback (I2C_HandleTypeDef *hi2c)
{
if (i2c_state == I2C_RX_REGISTER_ADDRESS) {
i2c_state = I2C_RX_DATA;
registerByteCount = 0;
registerValue = 0;
HAL_I2C_Slave_Sequential_Receive_IT (hi2c, (uint8_t*) ®isterValue, 1, I2C_NEXT_FRAME);
} else {
registerByteCount++;
if (registerByteCount < REG_SIZE)
{
int option = (registerByteCount == REG_SIZE-1) ? I2C_LAST_FRAME : I2C_NEXT_FRAME;
HAL_I2C_Slave_Sequential_Receive_IT(hi2c, ((uint8_t*) registerValue) + registerByteCount, 1, option);
} else {
i2c_state = I2C_IDLE;
register_update ();
}
}
}
Error Callback
As mentioned, if the Pi terminates the read or write before it is complete, we will get an "Ack Fault" error, which is passed to the error callback. We may also get other errors, like a bus error if someone is misusing the bus, but we can ignore those. If we get a bus error after receiving data, our design goal says we process that data like a full register write. If we get an error after only receiving the register address, we just keep the register address for later use (it was stored directly by the sequential receive function).
void HAL_I2C_ErrorCallback (I2C_HandleTypeDef *hi2c)
{
uint32_t errorcode = HAL_I2C_GetError (hi2c);
if (errorcode == HAL_I2C_ERROR_AF) { // AckFlag error
if (i2c_state == I2C_RX_DATA && registerByteCount > 0) {
register_update();
}
}
i2c_state = I2C_IDLE;
HAL_I2C_EnableListen_IT (hi2c);
}
Wiring
NOTE: The Pi uses 3.3V interfacing. If you are using a 5V MCU do not connect directly to the 3.3V logic as shown here without using level conversion of some kind. The Nucleo-G031K8 is 3.3V i/o so we can hook it up directly.
You will need three connections to the RPi, well probably 4 including power. If your device is self-powered, you need the I2C clock (SCL), I2C data (SDA) and ground. If you used RTC1 with PA9 and PA10 on the Nucleo-G031K8, then the pinout is shown below, otherwise sort out the MCU connections for SCL and SDA for your circuit.
Connection RPi Pin Nuc32 Pin
SCL 5 D5 CN3-8
SDA 3 D4 CN3-7
Ground 6 GND CN4-2
If your device needs power, you can pull 3.3V from Pi pin 1.
Testing
The Pi includes I2C functions we will use for testing i2cdetect and i2ctransfer.
The i2cdetect function runs through a range of I2C addresses on an I2C bus and attempts to contact each one. If your MCU project is working, it will respond to its own address like below.
pi@mcl:~ $ i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- 45 -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- --
Here the MCU is address 0x45, and there's another device at address 0x70.
To read a register, we need to write the register address and then read the value, which we can do with the i2ctransfer function.
pi@mcl:~ $ i2ctransfer -y 1 w1@0x45 1 r4
0x00 0x00 0x20 0x32
The -y tells it we don't need to confirm the operation. The 1 is the bus number. The w1@x45 says to write one byte of data to I2C address 0x45, and the next one is the byte of data to write (the register address). Then the r4 says to read back 4 bytes of data. The result is to read register 1 (the part number) and display the value.
To write a register, you write the register address and the data all in one operation.
pi@mcl:~ $ i2ctransfer -y 1 w2@0x45 4 7
Here the w2 means to send two bytes of data, the register address (4) and the data value 1.
We can check by reading it back.
pi@minicanlab:~ $ i2ctransfer -y 1 r4@0x45
0x07 0x00 0x00 0x00
The full code for this example is available on GitHub: https://github.com/dwinant/I2C-register-demo
Comments
Post a Comment