Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

I2C Master Driver with State Machine

Recommended resources

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:

  1. Generate a START condition.
  2. Send the 7-bit slave address with the Write bit (R/W=0).
  3. Wait for and verify an ACK from the slave.
  4. Send the register address to read from.
  5. Wait for and verify another ACK.
  6. Generate a REPEATED START condition.
  7. Send the 7-bit slave address again, this time with the Read bit (R/W=1).
  8. Wait for and verify an ACK.
  9. Receive the data byte from the slave.
  10. 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.

I2C Interview Questions and Basic Diagram
A basic overview of I2C connections and answers to common introductory questions. The master initiates all communication with START/STOP conditions, and communication flows via the SCL (clock) and SDA (data) lines.

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:

  1. State Enumeration: An enum to 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.)

  2. Control Structure (Context Handle): A struct to 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/Read functions: These functions are non-blocking. Their job is to:

      1. Check if the driver is already busy (handle->is_busy). If so, return false.
      2. Set the busy flag: handle->is_busy = true;.
      3. Populate the handle with the transaction details (slave address, data, etc.).
      4. Set the initial state (e.g., handle->state = I2C_STATE_START;).
      5. Trigger the first hardware event: Generate the I2C START condition.
      6. Return true. The ISR takes over from here.
    • 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:

  1. 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").
  2. Execute and transition: Based on the event and the current_state from 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:

  1. Initial Call: I2C_Master_Read() is called. It sets state = I2C_STATE_START, sets is_busy = true, and generates a START condition.

  2. 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.
  3. Interrupt 2: Address Sent, ACK Received

    • ISR Entry: Hardware event EV6 signals 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.
  4. Interrupt 3: Register Address Sent, ACK Received

    • ISR Entry: Hardware event EV8_2 signals TXE=1.
    • State: I2C_STATE_SEND_REG_ADDR.
    • Action: Generate a REPEATED START condition.
    • Next State: I2C_STATE_SEND_RESTART.
  5. Interrupt 4: REPEATED START Sent

    • ISR Entry: EV5 again.
    • 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.
  6. Interrupt 5: Address Sent, ACK Received (Read Mode)

    • ISR Entry: EV6 again.
    • 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.
  7. Interrupt 6, 7, ...: Data Byte Received

    • ISR Entry: EV7 signals 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.
    • Next State: If last byte, I2C_STATE_STOP. Otherwise, stay in I2C_STATE_READ_DATA.
  8. 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:

  1. Generating a STOP condition to release the bus.
  2. Updating the handle: handle->state = I2C_STATE_ERROR; and handle->is_busy = false;.
  3. 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 struct or '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 a switch statement based on the current state in the handle. This makes the driver efficient, non-blocking, and easy to integrate with an RTOS scheduler."
  • 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."

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.