Firmware
architecture and example code for an I2C interface based on the Silicon
Labs SMBus peripheral.
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.)
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
Ttrans=1100 kHz×54 cycles=0.54 msTtrans=1100 kHz×54 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.
TCPU=(7×20 instructions)×120
ns=0.017 msTCPU=(7×20 instructions)×120 ns=0.017 ms
⇒ TCPUTtrans=0.017 ms0.54 ms=0.031⇒ TCPUTtrans=0.017
ms0.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.
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 theI2C_Statevariable. 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).
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
}
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.
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
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.