What it's about and who it's for
In this article, I'm going to write about I2C (IIC / 2Wire), a communication interface between chips and sensors on boards.
It's very simple to implement. When developing circuits, only two wires (lines) are required: a clock line (SCL) and a data line (SDA). However, the number of supported "slaves" is limited to 127, and the transfer speed is quite low compared to, say, SPI.
I'd like to understand how this interface works because I need it for my project. I can't say anything about it yet, because I might not even be able to implement it. There are so many variables to consider and a lot to learn.
But here's the crux of the matter. I have the chip I need, the LIS2DH12TR (accelerometer). There aren't many tutorials for it, really. There's only a datasheet and a register map for it. Or rather, there's one library, but it's for the LIS2DH12TR chip. My project is in pure C, so there isn't one. And I don't plan on rewriting the library.
Besides, I might want to use a different chip if this one doesn't work, and there might not be anything for it. So, it was decided to figure out the I2C interface at all costs and communicate with the target chip without any libraries. For now, we'll use the MPU6050 and ATmega328P as an example.
The goal is to understand how it all works and put it into practice by reading and writing a couple of bytes of information to a couple of registers. Let's get started.
About I2C in General, Theory
And I'll start, of course, with the theory. I'll discuss the format of the packets being sent. How these packets move between devices, how these devices understand what to send and where to send it, and, most importantly, how to read these packets—all of this will be covered in this chapter.
Roles in I2C, the Communication Process
In order to communicate with other sensors, you need to understand how I2C works in general. And how packets are exchanged. Yes, packets. That's where you can start.
Each packet, one byte long, is sent one at a time over the SDA line. Moreover, it can travel in both directions. From master to slave and from slave to master. Below I'll explain the terminology used:
- Masteris the device that initiates and terminates transmission. It also generates the synchronization signal.
- Slaveis the device addressed by the master.
- Transmitter is the device that transmits data to the bus.
- Receiver is the device that reads data from the bus.
Based on the existing roles, we can conclude that communication is possible in two ways. First, there is a master/transmitter, which transmits data to a slave/receiver. Second, there is a master/receiver, which receives data from a slave/transmitter.
In practice, the roles are constantly changing. I'm getting ahead of myself a bit, but for the master to read anything, it first has to specify (i.e., write the appropriate register), and then switch to the receiver state and begin reading the transmitted data from the slave. But let's take things step by step.
Setting the Bitrate
Before doing anything, you need to set the clock rate at which communication between the master and slave will occur. The bitrate is stored in a special register, TWBR (Two Wired Bitrate Register).
The bitrate is not stored as absolute numbers, but as special values. For example, for an ATmega328P microcontroller with a clock rate of 16 MHz, if you need to achieve a transmit clock rate of 400 kHz, you need to set TWBR to 12. Where does 12 come from? From a formula found in the documentation.

In code it will look like this:
Once the bit rate has been initialized, you can begin configuring and preparing for actual communication between devices.
Transmitter Master Mode
In this mode, the master device writes data to the previously specified register. What does a typical data exchange between a master and slave look like in this mode? Let's take a look.
Here are the symbols:
- Start - Transmission start condition
- Stop - Transmission end condition
- Adr - Slave address
- RAdr - Register address
- +W - Write bit
- Ack - Signal received
- Data - Data
Write sequence of one bit.
| Master | Start | Adr+W | RAdr(Data) | Data | Stop | |||
| Slave | Ack | Ack | Ack |
Write sequence of two or more bits.
| Master | Start | Adr+W | RAdr(Data) | Data | Data | Stop | ||||
| Slave | Ack | Ack | Ack | Ack |
First, the Start signal is generated. This is done by writing the TWINT, TWSTA, and TWEN bits to the TWCR register. Like this: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)).
In this expression (1 << TWINT), we bitwise shift 1 (i.e., 0000 0001) 7 steps to the left. Why 7? Simply TWINT = 7. The result is 1000 0000. The same thing happens with the other expressions (1 << TWSTA) and (1 << TWEN). In the second case, we move the 1 to the 5th bit, and in the third, to the 2nd bit. Then we simply combine (bitwise OR) all the resulting binary numbers. Like this:
1000 0000
|
0010 0000
|
0000 0100
The resulting number should be 1010 0100.
Next, according to the table, you need to send the device (slave) address and the read/write bit. In this case, write.
How do you do this? To do this, you need to save the slave address in the TWDR register and append the write bit to the end. Like this: TWDR = (0x68 << 1) | 0. Here, 0x68 is the MPU6050 address specified in the documentation. We bitwise shift it left by one bit. That is, 0110 1000 became 1101 0000 . Then we add the write bit - 0.
After storing the address and write bit in TWDR, we need to send it to the bus via SDA (more on this in the chapter on connecting I2C). To do this, we write the following bits to TWCR, like this: TWCR = ((1 << TWINT) | (1 << TWEN)). This results in 1000 0100.
If a slave device with the specified address exists, it intercepts the SDA line and pulls it low (i.e., sends a 0, or Ack signal). If not, the SDA line remains untouched and the NAck signal is received, or a 1.
After confirming that a slave device with this address exists, a byte of information is sent. The first byte sent is not always the register address. For most sensors and other complex devices, this is the case. The register address is sent first, and then the data to be written. But if it's something simple, like some ADCs, they receive the data immediately. To be sure, you need to check the relevant documentation.
For example, when working with the MPU6050, you first need to specify the address of the register you want to write to. And only then specify the data you want to write.
This is how it's done.
Writing and sending data to a slave
We repeat the writing (as in the code block) until a Stop or Repeated Start signal is sent. This is done as follows.
To generate a Stop signal, write the following to the control register: TWCR = ((1 << TWINT) | (1 << TWSTO) | (1 << TWEN)). The result should be 1001 0100.
To generate a Repeated Start signal, write the following to the control register: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)). The result should be 1010 0100.
Master Receiver Mode
In this mode, the master device receives data from the slave. How does it do this? And how does it specify what exactly needs to be read? To do this, it first needs to specify the address of the desired register (Transmitter Master Mode), which it specifies by writing to this register. After this, the mode switches to Receiver Master mode. Here's what this sequence looks like in the diagram:
Here are the legend:
- Start - Transmission start condition
- Stop - Transmission end condition
- Adr - Slave address
- RAdr - Register address
- +W - Write bit
- +R - Read bit
- Ack - Signal received
- NAck - Signal not received
- Data - Data
Read sequence of one bit.
| Master | Start | Adr+W | RAdr | Start | Adr+R | NAck | Stop | ||||
| Slave | Ack | Ack | Ack | Data |
Reading sequence of several bits
| Master | Start | Adr+W | RAdr | Start | Adr+R | Ack | Nack | Stop | |||||
| Slave | Ack | Ack | Ack | Data | Data |
At the beginning, as in transmission mode, the Start signal is generated. This is done by writing the TWINT, TWSTA, and TWEN bits to the TWCR register. Like this: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)).
In this expression (1 << TWINT), we bitwise shift 1 (that is, 0000 0001) 7 steps to the left. Why 7? Simply TWINT = 7. The same thing happens with the other expressions (1 << TWSTA) and (1 << TWEN). In the second case, we shift 1 to the 5th bit, and in the third, to the 2nd bit. Then we simply concatenate (bitwise OR) all the resulting binary numbers. The result should be 1010 0100.
Next, according to the table, we need to send the device (slave) address and the read/write bit. Since I'm working with an MPU6050, the address will be 0x68 + 0 bit for writing.
How do I do this? To do this, store the slave address in the TWDR register and append the write bit to the end. Like this: TWDR = (0x68 << 1) | 0. Here, 0x68 is the MPU6050 address specified in the documentation. We bitwise shift it left by one bit. So, 0110 1000 becomes 1101 0000 . Then we add the write bit - 0.
After storing the address and the read/write bit in TWDR, we need to send it to the bus via SDA (more on this in the chapter on connecting I2C). To do this, we write the following bits to TWCR, like this: TWCR = ((1 << TWINT) | (1 << TWEN)). This gives 1000 0100.
If a slave device with the specified address exists, it intercepts the SDA line and pulls it low (that is, sends a 0, or Ack signal). If not, the SDA line remains untouched and a NAck signal, or 1, is received.
After confirming that a slave device with the specified address exists, a byte of information is sent. The first byte sent is not always the register address. For most sensors and other complex devices, this is true. The register address is sent first, and then the data to be written. However, if it's something simple, such as some ADCs, they receive data immediately. To be sure, you need to check the relevant documentation.
So, when working with the MPU6050, you first need to specify the address of the register you want to read. Send it, and wait for a response.
The above sequence looks like this:
If the data with the register address was sent successfully (an Ack signal was received), then the master needs to be switched to receiver mode. This is done using the Repeated Start signal. It's done exactly the same way as a regular Start signal: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN))
Next, we send the device (slave) address and the read bit. Since I'm working with an MPU6050, the address will be 0x68 + 1, the read bit. It's done like this: TWDR = (0x68 << 1) | 1.
Sending the Slave's address for reading
After receiving the Ack signal in response, we can begin reading the transmitted data. The master device then sends either an Ack or NAck signal. The master—that's you, my dear reader—chooses which signal to send. It's crucial not to get it wrong here. After all, if you respond incorrectly, the data line from the Slave to the Master will get stuck in an infinite wait loop.
Note: when the last byte of data is read, we must send a NAck signal. Otherwise, only an Ack signal. If we need to read the first and last bytes from the Slave register, we immediately send a NAck signal.
If this is the first and last byte, to receive the data and respond with an NAck signal, we do it like this: TWCR = (1<<TWINT) | (1<<TWEN).
If this is the first or any other byte, but definitely not the last, and we want to read more after it, we request the data and send an Ack, like this: TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWEA). This means we specifically set the TWEA bit; more about it in the chapter on registers.
Then, we need to wait until the data appears in the TWDR register. This is done like this:
Once we've finished reading and sent the corresponding NAck signal, we need to stop data transmission, either with a Stop or Repeated Start signal. This is done in the same way as writing:
To generate a Stop signal, write the following to the control register: TWCR = ((1 << TWINT) | (1 << TWSTO) | (1 << TWEN)). The result should be 1001 0100.
To generate a Repeated Start signal, write the following to the control register: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)). The result should be 1010 0100.
This sequence of actions is necessary when reading data from a connected device via I2C. Yes, it's much more complicated and confusing than writing, but it is what it is.
Response Codes
In this chapter, I've collected all possible response codes and their meanings. Since each of them is unique and there are some common codes for different modes, they will all be under one chapter.
| 0x8 | TW_START | The communication start condition (Start) was successfully met |
| 0x10 | TW_REP_START | The Repeated Start condition was successfully met. |
| 0x18 | TW_MT_SLA_ACK | Adr+W byte was sent and Ack signal was received |
| 0x20 | TW_MT_SLA_NACK | Adr+W byte was sent and NAck signal was received |
| 0x28 | TW_MT_DATA_ACK | 1 byte of data was sent and an Ack signal was received. |
| 0x30 | TW_MT_DATA_NACK | 1 byte of data was sent and an NAck signal was received. |
| 0x38 | TW_MT_ARB_LOST или TW_MR_ARB_LOST | The slave device at the specified address Adr+W/+R is currently busy communicating with another Master. |
| 0x40 | TW_MR_SLA_ACK | Adr+R byte was sent and Ack signal was received |
| 0x48 | TW_MR_SLA_NACK | Adr+R byte was sent and NAck signal was received |
| 0x50 | TW_MR_DATA_ACK | 1 byte of data was received and an Ack signal was sent. |
| 0x58 | TW_MR_DATA_NACK | 1 byte of data was received and an NAck signal was sent. |
| Respnse code | Macros | Description |
|---|
To find and verify the response code after each step, we need to read the TWSR register. Here, by each step, I mean the action immediately after clearing the TWINT bit in TWCR. In code, it looks like this: while (!(TWCR & (1 << TWINT)));. This essentially indicates that the state of the TWSR register has changed, and we can check it and find out.
For example, like this: (TWSR & 0xF8) != 0x18
In this case, if an incorrect code is returned, we'll enter the procedure and, for example, send a warning via the UART protocol to the console on the developer's computer.
You can also include the util/twi.h header so that you can write more human-friendly names (macros) instead of status codes. In the practical example below, I didn't do this because I wanted you, my reader, to understand and see their explicit use.
Registers used on the Master side (ATmega328P):
TWCR (Two Wired Control Register) - a communication control register. This register controls the start and end of communication. It controls and holds the bus busy while a transfer is in progress. It also generates the Ack signal.
TWDR (Two Wired Data Register) - a register where data is stored. This register stores the data sent by the slave when reading, and the master also writes its own data to it, including the slave's address.
TWSR (Two Wired Status Register) - a register responsible for monitoring the communication status between the slave and the master. It also reports any errors that occurred during data transmission or reception.
TWBR (Two Wired Bit Rate Register) - a register that stores the master's clock rate (divisor), allowing you to determine the data transfer frequency.
About connecting slaves via I2C
Now that we know how data transfer works via this protocol, we need to understand how to connect sensors, probes, and other devices to the microcontroller as slaves so we can communicate and exchange data with them.
The main advantage over, say, the SPI interface is the number of wires required in the circuit. Only two are needed. This doesn't include the voltage and ground lines, of course. In general, a clock line (SCL) and a data line (SDA) are required. Below is a diagram:

On AVR devices, such as Arduinos, the pins for communicating with other devices via I2C are labeled A5 and A4. Specifically, for SCL it's A5, and for SDA it's A4.
Let's Talk, or How to Work with Peripherals via I2C
Preparation (Headers and Structures)
So, that's a little theory. If you understand the essence of this protocol, it's not that complicated. Let me show you with an example. We'll be communicating with an acceleration and rotation sensor—in short, with an MPU-6050 accelerometer.
We'll also need to output the data somewhere to see if the code is working at all. You can use the built-in library and the Arduino Serial class, but in the code examples, I'll use my own implementation of the UART interface (I'll write about this interface someday).
Our goal in this chapter is simply to read data from the gyroscope and accelerometer. This chip also has a temperature sensor, but we'll ignore it and disable it. So, where do we start? Connecting the headers.
Next, we'll define a structure for storing sensor data. Let's call it MPU6050.
The function for receiving data is currently empty and does nothing. I'll fill it in a bit later, but for now, we're just setting the stage. Note that we're storing the sensor data in int16_t, as the documentation for this sensor's registers indicates two bytes for each axis.

We've completed the preparatory part, so let's move on to the part where we configure the I2C protocol itself.
Configuring the I2C protocol + a few abstractions
Now I'm going to configure the protocol and sensor so that we can exchange data and determine whether we've actually connected to the MPU6050 sensor. To do this, in the setup function, we write:
We'll ignore the first line for initializing the UART data exchange interface today and move on. You may have noticed that I've abstracted the I2C interface slightly into four functions: i2c_init, i2c_start, i2c_read, and i2c_write.
About a small extension to the I2C interface (abstraction)
And, of course, I'll go over them all. With your permission, I won't describe the interface itself, as I did at the beginning, but you'll find helpful hints here and there throughout the code that should help you understand the process.
i2c_init does everything described in the chapter on setting the bitrate, plus it sets the TWEN flag in TWCR to activate the interface.
i2c_start sets the condition for starting communication (START, REPEATED START) and sends the address of the device with which communication must be established.
i2c_write sends a byte of data over the bus and, accordingly, waits for a signal to return (Ack or NAck).
i2c_read reads data from the bus and returns it if everything went successfully and no flag appeared in the TWSR register.
This function also allows you to specify whether this will be the last read or not. If so (the last read), we remove the TWEA flag from TWCR.
Let's take a closer look at the initialization code.
We pass the read address, which we calculated and saved in a macro, to the i2c_start function. Like this:
Next, we send the address of the register we want to write to. In our case, it's register 0x6B: [i2c_write(0x6B);] This is the register that controls power supply to the sensor. Then, we write the data that should be written to the previously specified address.
I write the following: [i2c_write(0x00 | 1 << 3);] Where 3 is the bit position at the specified address. What does that even mean?

Look, we're actually sending 0000 1000. The sleep bit, set to 0, wakes up the sensor, and setting the 3rd bit to 1 disables the temperature sensor.
And the last thing I do during initialization is read the sensor ID itself, just in case.
We should receive 0x68 in response, which is the sensor address. If you have this sensor, of course.
We've completed the initial setup and verification of the I2C protocol. Note that I didn't call i2c_stop because it's unnecessary. We need to continue reading data from the bus, so there's no point in letting it go. It's exactly the same as with the restart to read the sensor ID. We simply sent a REPEATED START signal, and communication resumed.
Reading the data from the sensor
We need to receive fresh data from the sensor, so reading it alone won't do the trick. We'll be constantly reading and updating it. Here's how you can do it in a loop:
After setting the initial address (0x3B) in write mode, we immediately switch to read mode. The first byte we read will be the byte in register 0x3B. With each read, the sensor's internal counter will increment by 1. Therefore, after reading address 0x3B, we read address 0x3C, and so on until we respond with a NAck signal.
After successfully reading the entire sequence, we complete the transfer and release the bus using the i2c_stop function. We simply set the corresponding TWSTO flag: TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);.
Let's write a complete implementation of the getData function for receiving data from the sensor.
Let me start with a note. Since the addresses for each acceleration, temperature, and gyroscope value are stored sequentially, the code is much simpler and we don't have to constantly change the read address. Which is convenient.
Also, the sensor data is stored in two bytes, so we need to make two consecutive reads and combine them into a single variable with an int16_t integer value. To do this, we shift the first byte forward by half, or 8 bits, so that the data from the second read fits.
Finally, we simply read by sending a NAck signal to the connected sensor.
And just like that, we've successfully read data from the sensor using only the built-in registers, documentation, and the internal logic of the devices. Your console output might look something like this:

Notice that the temperature hasn't changed, even though I touched it. This means we've successfully disabled the temperature sensor. Also, look at WHO_AM_I. It's 0x70. This is normal, as described above.
In conclusion
In conclusion, I'd like to say that programming and working with devices this way is much more interesting and, I would say, "more reliable." After all, you won't get confused in your own implementation. Plus, you gain a deeper understanding of interfaces and the operation of microcontrollers and other devices. Another big advantage of this approach is now you can read any datasheet and, with no guide, set up any chip with any microcontroller together.
I hope I've helped you understand the I2C interface, and now you can connect any device and read its data in minutes.