Hello! Welcome to your next lesson.
In our last session, we developed a polling-based SPI master driver and discussed why polling is a practical choice for a synchronous, master-controlled protocol. We concluded that while simple, its blocking nature is a significant drawback for many applications.
Today, we'll tackle a protocol with more intricate steps: I2C. The back-and-forth nature of I2C, with its addressing, acknowledgment checks, and direction changes, makes a simple polling loop inefficient and complex. A far more elegant and robust solution is an interrupt-driven state machine.
This lesson is designed to meet the learning outcome: Develop an interrupt-driven I2C master driver by implementing a state machine to handle I2C protocol events. This is a classic embedded interview topic and a powerful design pattern that you've likely encountered in complex drivers within the AUTOSAR MCAL layer. Mastering this demonstrates your ability to design scalable and efficient low-level software.
1. Why I2C Demands a More Advanced Approach
Unlike the straightforward data-out/data-in exchange in SPI, a typical I2C transaction is a multi-stage process. For example, to read a byte from a sensor register, the master must:
- Generate a START condition.
- Send the 7-bit slave address with the Write bit (R/W=0).
- Wait for and verify an ACK from the slave.
- Send the register address to read from.
- Wait for and verify another ACK.
- Generate a REPEATED START condition.
- Send the 7-bit slave address again, this time with the Read bit (R/W=1).
- Wait for and verify an ACK.
- Receive the data byte from the slave.
- Send a NACK (to signal the end of the read) followed by a STOP condition.
Trying to manage this sequence with blocking code would tie up the CPU for a long time and become a convoluted mess of polling loops. An interrupt-driven state machine, however, is a perfect fit. Each step in the protocol triggers an interrupt, and our Interrupt Service Routine (ISR) simply acts as an engine that advances the transaction from one state to the next without blocking the main application.
AN0011: I2C Master and Slave Operation
For a quick refresher on the I2C protocol's fundamentals, including its signals, START/STOP conditions, addressing, and ACK/NACK mechanism, please review the first section of this Silicon Labs application note.
Please read section 1, 'I2C Theory'. Focus on the sequence of events in a transaction (Figure 1.3 shows a simple write). This sequence is what we will model with our state machine.
2. Designing the State Machine Driver
To build our state machine, we need two key components: a way to define the states of the transaction and a control structure to hold the context (the current state, data buffers, etc.) for an ongoing transfer.
An excellent reference for this architecture is the NXP application note "I2C non-blocking communication," which we will use as a guide.
I2C non-blocking communication
This document outlines the data structures that form the foundation of our state machine. It defines the possible states of a transaction and the control structure that will hold all the necessary information for a non-blocking transfer.
Please read section 3.1.1, 'Data type structure, flags and faults'. Pay close attention to the tI2C_trm_stage enumeration, which defines our states, and the tI2C_com_ctr structure, which will serve as our context handle.
States and Control Structure
Based on the NXP note, our design will have:
-
State Enumeration: An
enumto define the possible stages of the I2C protocol. This makes the code self-documenting and easy to follow.typedef enum { I2C_STATE_IDLE, I2C_STATE_START, I2C_STATE_SEND_ADDR_WRITE, I2C_STATE_SEND_REG_ADDR, I2C_STATE_SEND_RESTART, I2C_STATE_SEND_ADDR_READ, I2C_STATE_READ_DATA, I2C_STATE_STOP, I2C_STATE_ERROR } I2C_State_t;(Note: I've adapted the names from the NXP note for clarity.)
-
Control Structure (Context Handle): A
structto hold all information related to a single I2C transaction. A pointer to this structure will be passed around, allowing our functions and ISR to access and modify the transaction's context.typedef struct { I2C_State_t state; // Current state of the transaction uint8_t slave_addr; // 7-bit slave address uint8_t reg_addr; // Register to access uint8_t *data_buffer; // Pointer to user data buffer uint32_t data_len; // Number of bytes to transfer uint32_t data_index; // Current position in the buffer volatile bool is_busy; // Flag to indicate a transaction is in progress // Add flags for read/write mode, error status etc. } I2C_Handle_t;This handle is crucial. It decouples the driver logic from a single global state, enabling you to manage multiple I2C peripherals or even queue transactions.
3. Driver Architecture and API
The driver can be split into a user-facing API and the internal interrupt-driven engine.
-
User API (
i2c_driver.h):void I2C_Init(void);Configures GPIOs, the I2C peripheral, and the NVIC.bool I2C_Master_Write(I2C_Handle_t *handle);Starts a non-blocking write transaction.bool I2C_Master_Read(I2C_Handle_t *handle);Starts a non-blocking read transaction.
-
Driver Implementation (
i2c_driver.c):-
The
I2C_Master_Write/Readfunctions: These functions are non-blocking. Their job is to:- Check if the driver is already busy (
handle->is_busy). If so, returnfalse. - Set the busy flag:
handle->is_busy = true;. - Populate the handle with the transaction details (slave address, data, etc.).
- Set the initial state (e.g.,
handle->state = I2C_STATE_START;). - Trigger the first hardware event: Generate the I2C START condition.
- Return
true. The ISR takes over from here.
- Check if the driver is already busy (
-
The ISR (
I2C1_EV_IRQHandler): This is the core of our state machine. Each time a hardware event completes (e.g., START sent, byte transferred, ACK received), this ISR is triggered.
-
4. The Interrupt Service Routine: The State Machine Engine
The ISR is where the magic happens. Its logic consists of two parts:
- Identify the event: Read the I2C peripheral's status register(s) to determine what just happened (e.g., "Start condition sent," "Address sent," "Byte received").
- Execute and transition: Based on the event and the
current_statefrom the handle, perform the next action and update the state.
I2C non-blocking communication
Now, let's look at the implementation of the state machine logic within the ISR. This document provides the full C code for the interrupt callback function, which contains the main switch statement that drives the transaction forward.
First, review the flowcharts in Section 3.1.2 ('Algorithm description') for a visual representation of the ISR logic for writing and reading. Then, study the code in section 5, which shows the I2C_isr_Callback function. Focus on how the switch statement handles each eI2C_trm_stage. Finally, look at section 6 to see how this callback is called from the actual ISR wrapper. Don't worry about the specific register names; focus on the logical flow.
Let's trace a simplified read operation through the ISR state machine, inspired by the NXP example:
-
Initial Call:
I2C_Master_Read()is called. It setsstate = I2C_STATE_START, setsis_busy = true, and generates a START condition. -
Interrupt 1: START Condition Sent
- ISR Entry: Hardware event
EV5(on STM32) signals START sent. - State:
I2C_STATE_START. - Action: Send the slave address with the WRITE bit (
slave_addr << 1 | 0). - Next State:
I2C_STATE_SEND_ADDR_WRITE.
- ISR Entry: Hardware event
-
Interrupt 2: Address Sent, ACK Received
- ISR Entry: Hardware event
EV6signals ADDR bit set. - State:
I2C_STATE_SEND_ADDR_WRITE. - Action: Send the register address to read from (
handle->reg_addr). - Next State:
I2C_STATE_SEND_REG_ADDR.
- ISR Entry: Hardware event
-
Interrupt 3: Register Address Sent, ACK Received
- ISR Entry: Hardware event
EV8_2signals TXE=1. - State:
I2C_STATE_SEND_REG_ADDR. - Action: Generate a REPEATED START condition.
- Next State:
I2C_STATE_SEND_RESTART.
- ISR Entry: Hardware event
-
Interrupt 4: REPEATED START Sent
- ISR Entry:
EV5again. - State:
I2C_STATE_SEND_RESTART. - Action: Send the slave address again, this time with the READ bit (
slave_addr << 1 | 1). - Next State:
I2C_STATE_SEND_ADDR_READ.
- ISR Entry:
-
Interrupt 5: Address Sent, ACK Received (Read Mode)
- ISR Entry:
EV6again. - State:
I2C_STATE_SEND_ADDR_READ. - Action: If we are reading more than one byte, enable ACK generation. If only one byte, disable ACK generation now. Read the data register (this clears flags and allows the reception to start).
- Next State:
I2C_STATE_READ_DATA.
- ISR Entry:
-
Interrupt 6, 7, ...: Data Byte Received
- ISR Entry:
EV7signals RXNE=1. - State:
I2C_STATE_READ_DATA. - Action:
- Read the byte from the data register and store it in
handle->data_buffer[handle->data_index++]. - If this is the second-to-last byte, disable ACK generation for the next byte.
- If this is the last byte, generate a STOP condition.
- Read the byte from the data register and store it in
- Next State: If last byte,
I2C_STATE_STOP. Otherwise, stay inI2C_STATE_READ_DATA.
- ISR Entry:
-
Transaction End:
- Inside the ISR, when the STOP is generated, the final actions are:
- Set
handle->state = I2C_STATE_IDLE;. - Set
handle->is_busy = false;.
The transaction is complete, and the driver is ready for a new one.
Test your understanding!
In the state machine, what should happen if the slave device does not respond with an ACK after its address is sent (a NACK)? Which part of the driver is responsible for detecting this, and what should the state transition be?
Show answer
The I2C hardware peripheral itself would detect the NACK condition and set a specific status flag (e.g., AF for Acknowledge Failure on an STM32). The ISR is responsible for checking this flag at the beginning of its execution, before processing the normal event flags.
If the NACK flag is set, the driver should immediately abort the current transaction. This involves:
- Generating a STOP condition to release the bus.
- Updating the handle:
handle->state = I2C_STATE_ERROR;andhandle->is_busy = false;. - Setting an error code in the handle to inform the application layer of the failure.
The driver should not proceed to the next state in the sequence. This error handling is critical for a robust driver.
5. Interview Preparation
An interviewer will want to confirm you can architect a driver, not just write lines of code. The state machine approach is a prime topic to discuss design patterns.
-
Question: "You need to write an I2C driver for a system with an RTOS. Would you choose polling, interrupts, or DMA? Explain your design for an interrupt-based driver."
- Your Answer: "For I2C, I would strongly prefer an interrupt-driven approach. Polling would block a task for the entire transaction, which is inefficient. DMA is an option for high-speed transfers, but the overhead of setting up DMA for small, frequent I2C transactions can be less efficient than a well-written ISR. My design would be a non-blocking, interrupt-driven state machine. I'd use a control
structor 'handle' to maintain the context of each transaction—storing the current state, slave address, data buffer, and length. The public API functions (I2C_Write,I2C_Read) would configure this handle, start the transaction by generating a START condition, and return immediately. The ISR would then handle all subsequent protocol events—addressing, ACKs, data transfer, and STOP—by transitioning through aswitchstatement based on the current state in the handle. This makes the driver efficient, non-blocking, and easy to integrate with an RTOS scheduler."
- Your Answer: "For I2C, I would strongly prefer an interrupt-driven approach. Polling would block a task for the entire transaction, which is inefficient. DMA is an option for high-speed transfers, but the overhead of setting up DMA for small, frequent I2C transactions can be less efficient than a well-written ISR. My design would be a non-blocking, interrupt-driven state machine. I'd use a control
-
Question: "Walk me through the state transitions in your ISR for a combined write-read I2C transaction, like reading from a sensor register. What are the key hardware events you're listening for?"
- Your Answer: "Certainly. The process begins with states for the write portion: START sent, then ADDRESS+WRITE sent. After the address is ACKed, the state transitions to send the register address. Once that's ACKed, the key step is a REPEATED START. The state machine then transitions to the read portion: sending ADDRESS+READ. After the ACK, we enter the data reception state. The ISR will be triggered for each byte received. A crucial detail here is handling the final byte: before reading the last byte, the hardware must be configured to send a NACK to signal the end of the read. After the last byte is read, the ISR generates the STOP condition and transitions the machine back to IDLE. The key hardware events are flags for 'Start/Repeated Start Sent', 'Address Sent', 'Transmit Buffer Empty', and 'Receive Buffer Not Empty'."
-
Question: "How does your state machine handle errors like an arbitration loss or a NACK from the slave?"
- Your Answer: "Error handling is a critical part of the ISR. At the top of the ISR, before checking for normal completion events, I'd check for error flags like 'Arbitration Lost' (
ARLO) or 'Acknowledge Failure' (AF). If any of these are set, the state machine bypasses the normal flow and transitions directly to an ERROR state. In this state, it would generate a STOP condition to release the bus, clear the busy flag in the control handle, and set an error status that the application layer can check. This ensures the driver fails gracefully and doesn't get stuck waiting for a response that will never come."
- Your Answer: "Error handling is a critical part of the ISR. At the top of the ISR, before checking for normal completion events, I'd check for error flags like 'Arbitration Lost' (
Conclusion
Today, we've designed a robust, interrupt-driven I2C master driver using a state machine. This pattern is a significant step up from simple polling and is fundamental to writing efficient, non-blocking drivers for complex, event-based protocols.
Key Takeaways:
- I2C's multi-stage protocol is a natural fit for an interrupt-driven state machine.
- A control structure (handle) is essential for managing the context of a transaction in a non-blocking way.
- The ISR acts as the engine, processing hardware events and transitioning the machine through its states (
START,ADDR,DATA,STOP, etc.). - Robust error handling (for NACKs, arbitration loss) is a critical part of the ISR logic.
Next Lesson Preview:
We will now move to another essential automotive protocol: CAN. In the next lesson, we will develop a CAN driver, including bit-timing calculation, message filtering, and handling transmission and reception. We'll explore the hardware features of a CAN controller, like mailboxes and filters, and see how they influence the driver's architecture.