Technical Article

Implementing I2C with an EFM8 Microcontroller, Part 2

January 10, 2016 by Robert Keim

Firmware architecture and example code for an I2C interface based on the Silicon Labs SMBus peripheral.

Firmware architecture and example code for an I2C interface based on the Silicon Labs SMBus peripheral.

Previous Article in This Series

Supporting Information

The I2C State Machine

In the previous article, we discussed the importance of implementing I2C firmware in the form of a carefully organized state machine, where the progression from state to state corresponds to the sequence of events required for a particular I2C transaction. We also emphasized the fact that the SMBus interrupt flag is the key to ensuring proper interaction between hardware events and firmware routines. Just so you don’t have to switch back and forth between this article and the previous article, here are the event-sequence and state-machine diagrams for master/write and master/read operations:

(Note: In some, perhaps all, of the EFM8 reference manuals, the master/write flowchart has a typo indicating that the R/W bit should be set to 1, instead of 0, for a write operation. The above diagram has been corrected.)

Polling or Interrupts?

It is certainly possible to implement this flowchart by polling (i.e., “manually” checking) the SMBus interrupt flag and continuing with I2C execution as soon as the flag is set by hardware. This approach can be somewhat simpler, but overall, polling is thoroughly inferior to an interrupt-driven architecture. First of all, interrupt-based techniques encourage the designer to write reliable, well-structured, extensible code. Furthermore, polling is inefficient because the processor is unavailable for other tasks during the entire transaction; this is particularly problematic with I2C because of the low clock frequencies often used with this protocol.

For example, let’s say you have a master/write transaction in which you need to transmit 5 bytes to a slave device. That’s a total of 6 bytes after we include the “slave address + R/W” byte. Each byte requires 9 clock cycles (8 for the byte itself and 1 for ACK/NACK), for a total of 54 clock cycles. Let’s say you use a 100 kHz clock, which (according to the official I2C specification) is actually the maximum permissible clock frequency when operating in “standard mode.” The total time required for this transaction will be

\[T_{trans}=\frac{1}{100\ kHz}\times54\ cycles=0.54\ ms\]

This is not very long by human standards, but your EFM8 microcontroller running at 25 MHz can execute most instructions in 120 ns or less. Let’s say that the “firmware intervention” portion—e.g., checking the ACK/NACK bit, accessing a data array, clearing the interrupt flag—of each event in the transaction requires about 20 assembly instructions. We can make a rough processing-time estimate by assuming that this 6-byte transaction will require 7 of these 20-instruction processor interventions.

\[T_{CPU}=\left(7\times20\ instructions\right)\times120\ ns=0.017\ ms\]

\[\Rightarrow\ \frac{T_{CPU}}{T_{trans}}=\frac{0.017\ ms}{0.54\ ms}=0.031\]

Thus, only about 3% of the total transaction time is required for I2C-related processor execution. In other words, 97% of the transaction time would be available for other processing tasks if you used an interrupt-driven architecture. This improvement in efficiency is especially important in this age of high-performance, low-power embedded devices in which a single microcontroller may need to interface with multiple devices while also communicating with a host and minimizing power consumption.

From Flowchart to Code

The detailed I2C flowcharts provided by Silicon Labs make it fairly easy to translate from diagram to firmware. Most of the I2C action takes place in the interrupt service routine (ISR), as follows:

//-----------------------------------------------------------------------------
// SMBUS0_ISR
//-----------------------------------------------------------------------------
//
// SMBUS0 ISR Content goes here. Remember to clear flag bits:
// SMB0CN0::SI (SMBus Interrupt Flag)
//
//-----------------------------------------------------------------------------
SI_INTERRUPT (SMBUS0_ISR, SMBUS0_IRQn)
{
	SFRPAGE_SAVE = SFRPAGE;
	SFRPAGE = SMB0_PAGE;

	switch(I2C_State)
	{
		//Master Read===================================================
		case ...
			...	
			...
			...

		//Master Write===================================================
		case ...
			...
			...
			...
	}

	SFRPAGE = SFRPAGE_SAVE;
}

This excerpt shows the overall structure of the ISR. Note that the SFR (special function register) page is saved at the beginning of the ISR and restored at the end. This is good practice, though it is only strictly necessary if your device does not incorporate automatic SFR save/restore into the interrupt-handling procedure. The rest of the ISR is composed of code blocks corresponding to the events in whichever I2C transaction types need to be handled. The appropriate code block is executed based on the value of the I2C_State variable. We use preprocessor definitions to help us keep track of the various states:

#define MstR_STA_SENT 1
#define MstR_ADDR_SENT 2
#define MstR_READ_BYTE 3
#define MstR_DATA_READY 4

#define MstW_STA_SENT 10
#define MstW_ADDR_SENT 11
#define MstW_BYTE_SENT 12

Notice that the transaction type—in this case, master/write or master/read—is included as an integral characteristic of the state. This is why we can use only one switch statement to implement firmware routines for various types of I2C transactions. However, with this arrangement your one comprehensive switch statement can become a little unwieldy, so it is a good idea to use comments to visually organize the distinct sections (e.g., master/read, master/write, slave/read, slave/write) within your ISR.

The following thoroughly commented code excerpts provide guidance on how to implement master/read and master/write functionality.

switch(I2C_State)
{
	//Master Read===================================================
		//start condition transmitted
	case MstR_STA_SENT:
		SMB0CN0_STA = 0;	//clear start-condition bit
		SMB0CN0_STO = 0;	//make sure that stop-condition bit is cleared
		SMB0DAT = (I2C_SlaveAddr<<1)|BIT0;	//combine slave address with R/nW = 1
		I2C_State = MstR_ADDR_SENT;		//set state variable to next state
		SMB0CN0_SI = 0;	//clear interrupt flag
		break;

		//master transmitted "address + R/W" byte
	case MstR_ADDR_SENT:
		if(SMB0CN0_ACK == I2C_NACK)	//if slave did not ACK
		{
			//cancel transmission and release bus, as follows:
			SMB0CN0_STO = 1;	//transmit stop condition
			I2C_State = IDLE;	//set current state as IDLE
		}
		else	//if slave ACKed
		{
			if(I2C_NumReadBytes == 1)	//if only one byte will be read
			{
				//master NACKs next byte to say "stop transmitting"
				SMB0CN0_ACK = I2C_NACK;
			}
			else	//if more than one byte will be read
			{
				//master ACKs next byte to say "continue transmitting"
				SMB0CN0_ACK = I2C_ACK;
			}
			RcvdByteCount = 0;	//this variable will be an index for storing received bytes in an array
			I2C_State = MstR_READ_BYTE;	//set next state
		}
		SMB0CN0_SI = 0;	//clear interrupt flag
		break;

		//master received a byte
	case MstR_READ_BYTE:
		I2C_RcvData[RcvdByteCount] = SMB0DAT;	//store received byte
		RcvdByteCount++;	//increment byte counter (which is also the array index)
		SMB0CN0_SI = 0;	//clear interrupt flag

		if(RcvdByteCount == I2C_NumReadBytes)	//if this was the final byte
		{
			//release bus, as follows:
			SMB0CN0_STO = 1;	//transmit stop condition
			SMB0CN0_SI = 0;	//clear interrupt flag
			I2C_State = MstR_DATA_READY;	//this state tells the while loop in main() that the received data is ready
		}
		else if(RcvdByteCount == (I2C_NumReadBytes-1))	//if the next byte is the final byte
		{
			SMB0CN0_ACK = I2C_NACK;	//master NACKs next byte to say "stop transmitting"
		}
		else
		{
			SMB0CN0_ACK = I2C_ACK;	//master ACKs next byte to say "continue transmitting"
		}
		break;
}
switch(I2C_State)
{
	//Master Write===================================================
		//start condition transmitted
	case MstW_STA_SENT:
		SMB0CN0_STA = 0;	//clear start-condition bit
		SMB0CN0_STO = 0;	//make sure that stop-condition bit is cleared
		SMB0DAT = (I2C_SlaveAddr<<1);	//combine slave address with R/nW = 0
		I2C_State = MstW_ADDR_SENT;	//set state variable to next state
		SMB0CN0_SI = 0;	//clear interrupt flag
		break;

		//master transmitted "address + R/W" byte
	case MstW_ADDR_SENT:
		if(SMB0CN0_ACK == I2C_NACK)	//if slave did not ACK
		{
			//cancel transmission and release bus, as follows:
			SMB0CN0_STO = 1;	//transmit stop condition
			I2C_State = IDLE;	//set current state as IDLE
		}
		else	//if slave ACKed
		{
			SMB0DAT = *I2C_WriteBufferPtr;	//write first byte to SMBus data register
			I2C_State = MstW_BYTE_SENT;	//set next state
		}
		SMB0CN0_SI = 0;	//clear interrupt flag
		break;

		//master transmitted a byte
	case MstW_BYTE_SENT:
		if(SMB0CN0_ACK == I2C_NACK)	//if slave NACKed
		{
			//stop transmission and release bus, as follows:
			SMB0CN0_STO = 1;	//transmit stop condition
			I2C_State = IDLE;	//set current state as IDLE
		}
		//if slave ACKed and this was the final byte
		else if(I2C_WriteBufferPtr == I2C_FinalWriteAddress)
		{
			SMB0CN0_STO = 1;	//transmit stop condition
			I2C_State = IDLE;	//set current state as IDLE
		}
		//if slave ACKed and this was not the final byte
		else
		{
			I2C_WriteBufferPtr++;	//increment pointer that points at data to be transmitted
			SMB0DAT = *I2C_WriteBufferPtr;	//write next byte to SMBus data register
		}
		SMB0CN0_SI = 0;	//clear interrupt flag
		break;
}

In the master/read procedure, note that the ACK/NACK response is set (via the ACK/NACK bit in the SMBus control register) before the relevant byte is received. Thus, this particular implementation should be used with the hardware ACK functionality enabled (refer to the previous article for more information on hardware ACK).

Not Just an ISR . . .

The state machine in the SMBus ISR is definitely the center of attention in an I2C implementation, but you still need to initiate the transaction and set up the necessary variables. There are various ways to do this, some more elegant or sophisticated or extensible than others. The following code demonstrates one effective, convenient approach.

unsigned char I2C_State = IDLE;	//state variable is initialized to IDLE
unsigned char I2C_SlaveAddr;	//global variable for current slave address
unsigned char I2C_NumReadBytes;	//number of bytes to be read
unsigned char idata *I2C_WriteBufferPtr;	//pointer to bytes to be transmitted
unsigned char I2C_FinalWriteAddress;	//ISR uses this to determine which byte is the final byte

//these "transaction arrays" contain all the information needed for a particular I2C transaction
unsigned char idata SLAVE1_Tx_EnableSensing[4] = {SLAVE1_ADDR, 2, 0x42, 0x10};
unsigned char idata SLAVE1_Tx_SetReadFirstRegAddr[3] = {SLAVE1_ADDR, 1, 0x40};
unsigned char idata SLAVE2_Tx_SetReadTempData[3] = {SLAVE2_ADDR, 1, 0x50};
unsigned char idata SLAVE2_Rx_TempData[3] = {SLAVE2_ADDR, 9};


void I2C_MasterWrite(unsigned char* PtrtoCmdBuffer)	//function argument is simply the name of the transaction array
{
	I2C_State = MstW_STA_SENT;	//first state is "start condition generated"
	I2C_SlaveAddr = PtrtoCmdBuffer[0];	//copy the slave address from the transaction array to the global variable
	I2C_WriteBufferPtr = PtrtoCmdBuffer + 2;	//set the address of the first data byte in the transaction array
	I2C_FinalWriteAddress = I2C_WriteBufferPtr + (PtrtoCmdBuffer[1] - 1);	//set the final address based on the number of bytes to be transmitted

	SFRPAGE = SMB0_PAGE;
	SMB0CN0_STA = 1;	//initiate the transaction by setting the start-condition bit
}

void I2C_MasterRead(unsigned char* PtrtoCmdBuffer)	//function argument is simply the name of the transaction array
{
	I2C_State = MstR_STA_SENT;	//first state is "start condition generated"
	I2C_SlaveAddr = PtrtoCmdBuffer[0];	//copy the slave address from the transaction array to the global variable
	I2C_NumReadBytes = PtrtoCmdBuffer[1];	//copy the number of bytes to be read from the transaction array to the global variable

	SFRPAGE = SMB0_PAGE;
	SMB0CN0_STA = 1;	//initiate the transaction by setting the start-condition bit
}

I2C_SMBus_Example_Code.zip

The key to this strategy is the “transaction arrays.” One array is prepared for every specific I2C transaction needed in any particular application; the array holds all the relevant information for one transaction, and this information is passed to the ISR via global variables. In this example, the arrays are formatted as follows: For master/write operations, we use {slave address, number of bytes to be transmitted, first data byte, second data byte, third data byte, . . .}. For master/read operations, it is {slave address, number of bytes to read}. You simply call the I2C_MasterWrite() or I2C_MasterRead() function with the appropriate transaction-array identifier as the single argument.

Note that the transaction arrays and the pointer are declared with the “idata” keyword. This ensures 1) that the arrays are stored in the EFM8’s internal RAM and 2) that the compiler knows that the pointer is intended to point at variables stored in internal RAM. You have to be a little careful here because data in the internal RAM can be addressed with only one byte, but data in the “external” (though often physically on-chip) RAM is addressed with two bytes. Thus, a one-byte pointer variable could not properly address data in external RAM. The Keil compiler that comes with Simplicity Studio should be able to sort this out and perform the proper pointer initializations and conversions, but it is better to really understand what you’re doing and fine-tune the code accordingly.

The Real Thing

In the midst of all this firmware development, let’s not forget that the goal is to generate actual electrical signals. Below are some I2C scope captures. The upper trace is the clock and the lower trace is the data line. You will notice some unexpected narrow pulses that occur when the clock is low (i.e., the inactive clock state). These occur because the master stops driving the data line in order to allow the slave to ACK or NACK. The data line is held low by the slave as it ACKs, but then the slave stops driving the data line when the clock returns to the inactive state. This means that neither master nor slave is driving the data line during this clock-low period, and consequently the signal floats up to logic high before it is driven low again by the master.

A complete transaction: start bit, address + R/W, two data bytes, stop bit

 

Start bit

 

Stop bit

 

Stop bit followed immediately by a start bit for a new transaction

Conclusion

The information presented in this series should help you to effectively translate flowcharts and event sequences into robust, extensible EFM8 firmware. The example code given above provides most of what you will need for I2C master functionality, and you can use this code in conjunction with the slave-functionality diagrams (found in the EFM8 reference manuals) to develop firmware for slave/read and slave/write operations.

3 Comments
  • GoExtreme March 04, 2018

    Is there a way you could show how to make these oleds screen that are popular on arduino. The ones with ssd1306 driver i2c.

    Like. Reply
  • RK37 March 05, 2018

    Hi GoExtreme,

    I recommend that you post this question in our forum (https://forum.allaboutcircuits.com/). Starting a forum thread is a great way to get help with your projects and designs.

    Like. Reply
  • S
    sweetymoon789 September 09, 2020

    Hi, If I want to send all these four arrays to LCD, unsigned char idata SLAVE1_Tx_EnableSensing[4] = {SLAVE1_ADDR, 2, 0x42, 0x10};
    unsigned char idata SLAVE1_Tx_SetReadFirstRegAddr[3] = {SLAVE1_ADDR, 1, 0x40};
    unsigned char idata SLAVE2_Tx_SetReadTempData[3] = {SLAVE2_ADDR, 1, 0x50};
    unsigned char idata SLAVE2_Rx_TempData[3] = {SLAVE2_ADDR, 9}

    How should I call these in the ISR?

    Like. Reply