Into to Interrupt Driven I2C Communications with the TM4C123 Family of MCUs

Kyle Garland
10 min readOct 26, 2023

--

When writing software for bare metal systems, there will come a time when you must write efficient drivers to utilize the limited space and processor speed available to you, and blocking communications just wont do the trick. This article will introduce Interrupt Driven Communications using the TM4C123 Family of MCUs and will be part 1 in a series covering the most common types of communications protocols that we use in embedded systems. This article assumes a basic knowledge of the I2C peripheral.

Getting Started with the TM4C

The TM4C123xxxx MCU is a low cost MCU with a max clock speed of 80MHz that has a standard set of features and peripherals. To get started with the I2C peripheral, we can go to page 997 of the datasheet. The specific part number we are using is the TM4C123GH6PM, which is the standard MCU used for the Tiva C Series Launchpad dev kit. To gain a better understanding of I2C interrupts, we can go to page 1005 and learn when interrupts are generated. This is an important section where we can start to plan out our I2C driver and how to construct our ISR (Interrupt Service Routine).

Since we are in primary* mode, we care about the four following cases:

  • Primary Transaction Completed
  • Primary Arbitration Lost
  • Primary Transaction Error
  • Primary Bus Timeout

So what does this actually tell us? Section 16.3.3.1 gives a more detailed explanation of the generation of I2C interrupts and also some important information about checking for error conditions (Transaction Error and Arbitration Lost), and when an error condition occurs. These interrupt conditions are very useful when designing your ISR.

Exploring Interrupt Generation and Peripheral Initialization

After reading section 16.3.3, it still may be unclear when the I2C interrupts fire. The datasheet explains the primary device generates an interrupt upon data completion, but how does that help us understand these interrupts and ultimately understand how to write our ISR? We can use our handy dandy logic analyzer and write a basic I2C application to see how they work!

TI gives us a low level set of driver functions that abstract away direct register access, but is not a complete HAL; that we will have to design ourselves. The TiveWare Driverlib has separate documentation that can be used side by side with the TM4C datasheet, and one just needs to find the correct function that modifies the correct register.

Before we start, we need to initialize our I2C peripheral, enable I2C interrupts, and get a basic application working so we can test out when intterupts our generated. The following code segment shows a very basic I2C Driver that will read and write to an external sensor. I am using an OPT3001 optic sensor for the secondary device.

void TivaI2C::Initialize() {
kBaseAddress = I2C0_BASE;
IntEnable(INT_I2C0);

SysCtlPeripheralEnable(SYSCTL_PERIPH_I2C0);
while (!SysCtlPeripheralReady(SYSCTL_PERIPH_I2C0)) {
}
SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOB);
while (!SysCtlPeripheralReady(SYSCTL_PERIPH_GPIOB)) {
}
GPIOPinConfigure(GPIO_PB2_I2C0SCL);
GPIOPinConfigure(GPIO_PB3_I2C0SDA);

I2CMasterIntEnable(I2C0_BASE);
I2CMasterInitExpClk(I2C0_BASE, SysCtlClockGet(), true);

GPIOPinTypeI2CSCL(GPIO_PORTB_BASE, GPIO_PIN_2);
GPIOPinTypeI2C(GPIO_PORTB_BASE, GPIO_PIN_3);
}

The base Address is set to use the I2C0, and our pins are configured using Port B pins 2 and 3. To understand which pins are allowed to be alternately selected for a specific peripheral, we can look at table 16–1 on page 998 of the datasheet, but I have copied it below.

Here we see that I2C Bus 0 can be configured with pins from Port B, which we have set in our I2C Init function.

Now that our peripherals and interrupts are initialized, let’s write a quick blocking I2C write and read function. We do a blocking method because we want to see when the interrupts are generated so we can design our interrupt driven driver. Since this code a bit long, I’ve added it to the bottom of the article. Our interrupt handler is going to be very basic, where at first we clear the interrupt, and toggle a GPIO pin, which allows us to see exactly when an interrupt fires using our logic analyzer.

void I2C0Handler() {
I2CMasterIntClear(I2C0_BASE);
GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, GPIO_PIN_3);
GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, 0);
}

Analyzing the Interrupt Signals — Writing

We are finally ready to write some data to our optic sensor, as well as read the device ID, which is always a good place to start when writing a new sensor driver.

Writing Data to an Optic Sensor

The blue line is our clock, the green is the outdoing data, and the purple line is our GPIO pin that we toggle in our interrupt handler. During this transaction, we are writing 0xCE10 to the configuration register, which has an address of 0x01. We now have a better understanding of when the our interrupt fires (when there are no errors of course), and can start to piece together a non blocking write function!

Non-Blocking Write Function

First, we start with sending the secondary device address which is followed by some data, with no interrupt in between. The TivaWare driverlib gives us some handy functions to do this, so we can kick off an I2C transaction by sending the first 2 byte packet and let the interrupts handle the rest!

void TivaI2C::StartNonBlockingTransmission(uint8_t *data_in, uint8_t num_bytes,
uint8_t register_address,
uint8_t device_address) {
/* Only start transmission if bus isn't busy */
if (state == I2CDriverState::kIdle) {
i2c_control.num_write_bytes = num_bytes;
i2c_control.write_index = 0;
i2c_control.register_address = register_address;
state = I2CDriverState::kTransmitting;
std::memcpy(tx_data_buffer, data_in, num_bytes);
// Set device address, false for writing from primary to seconday
I2CMasterSlaveAddrSet(kBaseAddress, device_address, false);

// Start with register address we write to
I2CMasterDataPut(kBaseAddress, register_address);
// Send only one byte
if (num_bytes == 1) {
// initiate single byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_SEND);
} else {
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_START);
}
} else {
return;
}
}

First off, we check if our I2C bus is busy, and immediately return if it is. There are a few ways to design handling periodic transactions going out such as adding to a queue, which is not part of the scope of this article, maybe in another one later.

We then fill out an i2c_control structure which contains specific runtime information, and looks like this:

  struct I2C_Runtime_Data {
uint8_t num_write_bytes;
uint8_t num_read_bytes;
uint8_t read_index;
uint8_t write_index;
uint8_t register_address;
uint8_t device_address;
I2CDriverState state;
};

Example usage of this function in our optic sensor class can look something like this:

void OPT3001::deviceConfigNonBlocking() {
TxDataArray[0] = 0xCE;
TxDataArray[1] = 0x10;
m_i2c->StartNonBlockingTransmission(
TxDataArray, 2, static_cast<uint8_t>(Registers::CONFIGURATION),
deviceAddress);
}

TivaWare driverlib gives us some handly flags such as I2C_MASTER_CMD_SINGLE_SEND or I2C_MASTER_CMD_BURST_SEND_START which allows the peripheral to know whether to send a stop command after sending a start, or whether to send multiple bytes. Table 16.5 starting on page 1023 of the datasheet describes these flags, and the values are conveniently given to us in the i2c.h header file of the TiveWare Driverlib.

Now that we have started a transaction and sent our first packet (device address + register we want to write to), we know that an interrupt is going to fire immediately after the primary has completed that transaction, so we can start writing our callback for the writing case. We want to keep track of which stage we are in in the transaction process, so we design a simple state machine for the writing and reading case.

void I2C0Handler() {
I2CMasterIntClear(I2C0_BASE);
i2c_bus_0.I2CCallback();
}


void TivaI2C::I2CCallback() {
switch (state) {
case I2CDriverState::kIdle:

break;
case I2CDriverState::kTransmitting:
/* Case to write final byte */
if (i2c_control.write_index == i2c_control.num_write_bytes - 1) {
// put last byte into the fifo
I2CMasterDataPut(kBaseAddress, tx_data_buffer[i2c_control.write_index]);

// send last byte and then stop condition
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_FINISH);

state = I2CDriverState::kIdle;
} else {
I2CMasterDataPut(kBaseAddress, tx_data_buffer[i2c_control.write_index]);

// Initiate multi-byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_CONT);

/* increment write index only if we haven't reached the end */
i2c_control.write_index++;
}
break;

Our callback is called directly in our ISR after clearing the interrupt flag, and we enter the transmitting state, because we set our internal state to transmitting when we kicked off our I2C non blocking transaction.

First, we enter the else portion of the conditial because we have 2 bytes to write, and we haven’t written any yet. We keep track of how many bytes we are writing with a write index which is set to zero when we initialize our runtime structure above. When we are ready to write our last byte, we set the flag to I2C_MASTER_CMD_BURST_SEND_FINISH which sends the stop condition for us, and we change our state to idle. And that’s it! The writing portion is complete. One thing to note, is call the OPT3001::deviceConfigNoneBlocking() function back to back, the second transaction will fail becasue the bus is busy, so when configuring sensors, it’s good to do so at start up while using blocking communications.

Analyzing the Interrupt Signals — Reading

After finishing the transmitting portion of our callback function, it’s time to figure out how to get data back from our sensor. We will start off with querying the device ID register at address 0x7F, which should return 0x3001. Let’s check when our interrupts fire by writing a blocking read function.

As we can see there are three interrupts generated. When performing an I2C read, we first must write to the address we want to read from, send a repeated start, send the device address again with the read bit high, and then extract the data coming back from the sensor. So our first interrupt fires after sending our write transaction, which is similar to what we saw during the transmitting section above, and the next two interrupts fire after each data byte is sent back from the sensor. Since we have two stages of reading, I have broken added another mini state machine which determines what stage of the reading process we are in.

Similar to transmitting, we kick off a reading transaction as follows:

void TivaI2C::StartNonBlockingRead(uint8_t num_bytes, uint8_t register_address,
uint8_t device_address) {
/* Only start transmission if bus isn't busy */
if (state == I2CDriverState::kIdle) {
i2c_control.num_read_bytes = num_bytes;
i2c_control.read_index = 0;
i2c_control.register_address = register_address;
i2c_control.device_address = device_address;
state = I2CDriverState::kReceiving;
stage = I2CReceiveStage::kSendingAddress;
// Set device address, false for writing from primary to secondary
I2CMasterSlaveAddrSet(kBaseAddress, device_address, false);
// Start with register address we write to
I2CMasterDataPut(kBaseAddress, register_address);
// initiate single byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_SEND);
} else {
return;
}
}

Our function signature looks a little different, but we still build up our runtime structure in a similar way. We set the read index to 0, state to receiving, and our receiving stage to sending device address, which is the device address with our read bit high. That way when our interrupt fires after sending the first write to tell the device we want to read from address 0x7F, we know to kick off the read portion in our callback.

    case I2CDriverState::kReceiving:
// set mode for reading
switch (stage) {
case I2CReceiveStage::kIdle:
break;
case I2CReceiveStage::kSendingAddress:
I2CMasterSlaveAddrSet(kBaseAddress, i2c_control.device_address, true);

if (i2c_control.num_read_bytes == 1) {
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_RECEIVE);
} else {
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_RECEIVE_START);
}

stage = I2CReceiveStage::kReadingData;
break;
case I2CReceiveStage::kReadingData:
/* Reading last byte */
if (i2c_control.read_index == i2c_control.num_read_bytes - 1) {
rx_data_buffer[i2c_control.read_index] =
I2CMasterDataGet(kBaseAddress);
data_ready = true;
stage = I2CReceiveStage::kIdle;
} else {
rx_data_buffer[i2c_control.read_index] =
I2CMasterDataGet(kBaseAddress);

I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_RECEIVE_CONT);

i2c_control.read_index++;
}
break;

The receiving portion of the callback function is a bit more involved because of having to keep track of the internal receiving stage, but still is very similar to the transmitting portion. When the interrupt first fires, we know from our receiving stage to send the device address with the read bit high, and then when the subsequent interrupts fire, we know that data is ready and we can store in a local buffer. We increment our read index so we know when to finish, and on the last byte we set our state to idle, stage to idle, set our data ready flag to true, and extract the last byte of data. The purpose of the data ready flag is to allow us to copy the data to our sensor driver.

A basic usage of the reading function would be to cyclicly kick off an I2C reading transaction, and then call a method to extract the data at a later time. For example, say we wanted data from the optic sensor at a rate of 500ms. At 0ms, we send the non blocking read function, and then at 250ms, we call the getResult function which picks up the data, and then at 500ms we start the same process over.

void OPT3001::sendGetDataNonBlocking() {
m_i2c->StartNonBlockingRead(2, static_cast<uint32_t>(Registers::RESULT),
deviceAddress);
}

uint16_t OPT3001::getResult() {
m_i2c->ExtractData(RxDataArray);
conversionData = (uint16_t)(RxDataArray[0] << 8 | RxDataArray[1]);
return conversionData;
}

void TivaI2C::ExtractData(uint8_t *data_out) {
if (data_ready) {
std::memcpy(data_out, rx_data_buffer, i2c_control.num_read_bytes);
data_ready = false;
}
}

Keep your eye out for part 2 in the series where we analyze the SPI Peripheral of the TM4C123xxxx and build an interrupt driven SPI Driver!

*Primary and Secondary are used in place of Master and Slave in this article

As promised, the blocking functions for reading and writing.

bool TivaI2C::BlockingWrite(uint8_t *data_in, uint8_t num_bytes,
uint8_t device_address) {
// Set device address, false for writing from master to slave
I2CMasterSlaveAddrSet(kBaseAddress, device_address, false);

// put data into the fifo
I2CMasterDataPut(kBaseAddress, data_in[0]);

// Send only one byte
if (num_bytes == 1) {
// initiate single byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_SEND);

while (I2CMasterBusy(I2C0_BASE)) {
}
}
// Send multiple bytes
else {
// Initiate multi-byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_START);

while (I2CMasterBusy(I2C0_BASE)) {
}

// If num_bytes is 2, we immediately break and send a finish command with
// the last byte
for (uint8_t i = 1; i < num_bytes - 1; i++) {
// Put data into the fifo
I2CMasterDataPut(kBaseAddress, data_in[i]);

// Initiate multi-byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_CONT);

while (I2CMasterBusy(I2C0_BASE)) {
}
}

// put last byte into the fifo
I2CMasterDataPut(kBaseAddress, data_in[num_bytes - 1]);

// send last byte and then stop condition
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_SEND_FINISH);

while (I2CMasterBusy(I2C0_BASE)) {
}
}
return true;
}
bool TivaI2C::BlockingRead(uint8_t *data_out, uint8_t num_bytes,
uint8_t device_address, uint8_t register_address) {
// Send write to register you want to read from first
BlockingSingleWrite(register_address, device_address);

// set mode for reading
I2CMasterSlaveAddrSet(kBaseAddress, device_address, true);

if (num_bytes == 1) {
// single receive start
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_RECEIVE);

while (I2CMasterBusy(I2C0_BASE)) {
}

// store first byte into data array
data_out[0] = I2CMasterDataGet(kBaseAddress);
} else {
// Burst receive start
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_RECEIVE_START);

while (I2CMasterBusy(I2C0_BASE)) {
}

// Load First Byte of Data
data_out[0] = I2CMasterDataGet(kBaseAddress);

// store first byte into data array (high)
for (uint8_t i = 1; i < num_bytes - 1; i++) {
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_RECEIVE_CONT);

while (I2CMasterBusy(I2C0_BASE)) {
}

data_out[i] = I2CMasterDataGet(kBaseAddress);
}

I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_BURST_RECEIVE_FINISH);

while (I2CMasterBusy(I2C0_BASE)) {
}

// Store third byte into data array (lsb)
data_out[num_bytes - 1] = I2CMasterDataGet(kBaseAddress);
}
return true;
}

void TivaI2C::BlockingSingleWrite(uint8_t register_address,
uint8_t device_address) {
// Set slave address, false for writing from master to slave
I2CMasterSlaveAddrSet(kBaseAddress, device_address, false);

// Put data into the FIFO
I2CMasterDataPut(kBaseAddress, register_address);

// Initiate single byte transfer
I2CMasterControl(kBaseAddress, I2C_MASTER_CMD_SINGLE_SEND);

while (I2CMasterBusy(I2C0_BASE)) {
}
}

--

--