Throughout my career, I have worked extensively with microcontrollers and systems on chips (SoCs). These devices often need to communicate with peripherals using protocols such as UART, I2C, SPI, and 1-Wire (W1). These are short-range communication protocols primarily used for interfacing with peripheral devices.
Common peripherals in microcontroller-based projects include real-time clocks (RTC), digital-to-analog converters (DACs), and analog-to-digital converters (ADCs), among others. Whether you're a hobbyist or a professional engineer, mastering these communication protocols is essential for successfully working with embedded systems.
In this blog entry, I will focus on the I2C protocol and how to emulate its read and write functions. I2C is a widely used two-wire protocol that allows multiple devices to communicate over the same bus. Each I2C device has an address, which is either predefined by the manufacturer or, in some cases, configurable by the user or system architect.
When a microcontroller or microprocessor needs to read from or write to an I2C device, it sends the device’s address along with a read or write bit. It then specifies the registers address range it wants to access. Understanding this process is fundamental to effectively integrating I2C peripherals into embedded systems.
Description of I2C Sequence
To understand how I2C transmits data, we’ll focus on the logical sequence rather than the physical wiring aspects.
I2C communication begins with a Start condition , signaling to all connected devices that the master intends to communicate. This is followed by the Address frame , which can be either a 7-bit or 10-bit address, depending on the device specifications. Each device on the bus checks the incoming address and only responds if it matches its own; all other devices ignore the transmission.
Next, the Read/Write (R/W) bit is sent. This bit determines whether the master intends to write (0) or read (1) from the addressed device. The addressed device then responds with an ACK (Acknowledge) or NACK (Not Acknowledge) bit to confirm whether it has successfully recognized the request.
Once acknowledged, data transfer begins in 8-bit (1-byte) sequences. After each byte, the receiving device sends another ACK/NACK bit to indicate whether it successfully received the data. This process continues until all required data has been transmitted.
Finally, the communication ends with a Stop condition , signaling that the bus is now free for other devices.
I2C Data Transfer Sequence
- Start Condition – Signals the beginning of communication
- Address Frame – Specifies the target device (7-bit or 10-bit)
- Read/Write (R/W) Bit – Defines the direction of data transfer (0 = Write, 1 = Read)
- ACK/NACK Bit – Confirms receipt of the address by the device
- 8-bit Data Transmission – Data is sent in 8-bit sequences
- ACK/NACK Bit – Acknowledges each byte transfer
- Stop Condition – Ends the communication session
By following this structured approach, I2C enables efficient multi-device communication using just two wires.
Emulation of I2C
For this emulation, we will focus on reading and writing a single byte of data while covering the key components of the I2C protocol: the Address Frame, Read/Write Bit, and 8-bit Data Transmission.
In our approach, we will perform two 8-bit data transmissions :
- Register Address – Specifies which register we want to access within the device.
- Data – The actual data being written or read from the specified register.
Understanding Registers
If you're unfamiliar with registers, think of them as small, high-speed storage locations within hardware. They function similarly to RAM but are much faster and operate at a lower level. Registers are essential for configuring and controlling hardware devices.
Each I2C device has predefined register addresses, which are typically found in the device’s datasheet. However, for simplicity, our emulation will use a structured data representation instead of referencing a real datasheet. The goal is to ensure we correctly read and write data within our emulated environment.
I2C Data Structure
To emulate an I2C device, we will use a simple data structure that mimics the essential components of an I2C transaction. This structure will allow us to simulate reading from and writing to an I2C device.
Data Structure
The following structure represents the key elements involved in I2C communication:
- uint8_t: device_address
- uint8_t: register_address
- uint8_t: data
Write Sequence
To simulate writing data to an I2C device, we follow these steps:
-
Add the Write Bit to the Address
- The device address is bitwise shifted, and the write bit (0) is appended.
-
Initialize Local Variables
- Prepare the payload, including the device address and register to write to.
-
Set Up the Emulated I2C Data Structure
- Assign the device address and register address.
- Clear previous data before writing new data.
-
Mock the Hardware Write
- Since each embedded platform has unique I2C functions, we emulate this by verifying the address in our data structure and writing the data accordingly.
Read Sequence
To simulate reading data from an I2C device, we follow these steps:
-
Add the Read Bit to the Address
- The device address is bitwise shifted, and the read bit (1) is appended.
-
Initialize Local Variables
- Prepare the payload and send the read request to the emulated device.
-
Set Up the Emulated I2C Data Structure
- Assign the device address and register address.
- Ensure previous data is cleared before reading new data.
-
Mock the Hardware Write (For Register Selection)
- Before reading, an I2C device typically expects a write operation to set the register address.
- This step emulates sending the register address before reading data.
-
Mock the Hardware Read
- Read the data from the structure and verify that it matches the expected value.
Closing Note
This exercise is a great way to deepen your understanding of the I2C protocol and demonstrate your knowledge practically.
You can find my implementation on my GitHub account. The repository includes a development container (dev container) for easy setup, allowing you to run the project seamlessly in a pre-configured environment. Additionally, I've provided a CMakeLists.txt file, ensuring cross-platform compatibility while keeping the implementation purely in C for maximum portability.
Feel free to explore, run, and modify the code! 🚀
Top comments (0)