This guide walks you through writing an I2C driver in Linux from scratch. Whether you are a beginner touching Linux kernel driver development for the first time or preparing for an embedded systems interview, you will find practical code, clear explanations, and real-world context here.
What Is an I2C Driver and Why Does It Matter?
If you have been working with microcontrollers or Linux-based embedded systems for a while, you have almost certainly bumped into I2C. It stands for Inter-Integrated Circuit, and it is one of the most popular communication protocols in the embedded world. Sensors, EEPROM chips, real-time clocks, display controllers a huge chunk of the hardware you interact with on a daily basis talks over I2C.
But here is the thing: most tutorials show you how to use I2C. Very few show you how to write an I2C driver from the ground up inside the Linux kernel. That is exactly what we are going to do today.
By the end of this guide, you will understand what an I2C driver is, how the I2C subsystem works in Linux, how to write a basic I2C client driver, how to read and write registers on a real device, and how to test everything using common kernel debugging tools. No hand-waving, no magic just real code and real explanations.
Understanding the I2C Protocol First
Before you write a single line of kernel code, you need to understand what I2C actually is at the hardware level. Trust me — this saves you hours of debugging later.
I2C is a synchronous, multi-master, multi-slave serial communication protocol. It uses just two wires: SDA (Serial Data) and SCL (Serial Clock). One device acts as the master and controls the clock. The other devices are slaves and respond when addressed.
Each slave device has a unique 7-bit address (or sometimes 10-bit). The master kicks off every transaction by sending the slave address along with a read/write bit. If the slave acknowledges, the data transfer begins. This is the core of how I2C works.
Why I2C Is So Common in Embedded Systems
I2C only needs two signal lines, which makes it incredibly hardware-friendly. You can chain multiple devices on the same bus as long as each has a unique address. This matters when board space and pin count are limited which is basically always in embedded design. Common devices you would connect over I2C include temperature sensors like the LM75, accelerometers like the MPU6050, OLED displays driven by the SSD1306, and I2C EEPROMs like the AT24C series. If you are learning how to write an I2C device driver, working with one of these is a great starting point
How the Linux I2C Subsystem Works
Linux has a very clean layered architecture for I2C. Understanding this architecture is crucial before you write your I2C driver. Here is how it breaks down:
The Three Layers You Need to Know
The first layer is the I2C adapter driver. This is the low-level driver that controls the physical I2C bus controller on your SoC or board. On a Raspberry Pi, BeagleBone, or any ARM-based platform, this is already written for you. You do not need to touch it unless you are writing drivers for custom silicon.
The second layer is the I2C core. This lives in drivers/i2c/i2c-core-base.c in the kernel source tree. It is the glue that sits between adapters and client drivers. It provides the API that you, as a driver writer, will call.
The third layer is the I2C client driver. This is what you write. A client driver talks to a specific I2C device — your sensor, your EEPROM, your display. This is the layer we focus on in this entire guide.
Key Data Structures in the I2C Subsystem
There are four structures you will use constantly when writing an I2C driver. Get comfortable with all of them.
struct i2c_adapter
This represents the physical I2C bus controller. Think of it as the hardware that drives the SDA and SCL lines. When your driver calls i2c_transfer(), it is using the adapter underneath. You typically do not define this — the platform or SoC driver does.
struct i2c_client
This represents the slave device sitting on the bus. It holds the device address, the adapter it belongs to, and the device name. When the kernel instantiates your device, it creates an i2c_client for you and passes it to your probe function.
struct i2c_client {
unsigned short flags;
unsigned short addr; /* 7-bit I2C address */
char name[I2C_NAME_SIZE];
struct i2c_adapter *adapter;
struct device dev;
int irq;
/* ... */
};
struct i2c_driver
This is the structure you define in your driver. It ties your probe, remove, and id_table together. When the kernel matches a device to your driver (via the id_table or device tree), it calls your probe function.
struct i2c_driver {
int (*probe)(struct i2c_client *client,
const struct i2c_device_id *id);
int (*remove)(struct i2c_client *client);
struct device_driver driver;
const struct i2c_device_id *id_table;
/* ... */
};
struct i2c_msg
This represents a single I2C message — a read or a write transaction on the bus. When you call i2c_transfer(), you pass an array of these messages. Each message has a slave address, flags, a byte count, and a buffer pointer.
struct i2c_msg {
__u16 addr; /* Slave address */
__u16 flags; /* 0 = write, I2C_M_RD = read */
__u16 len; /* Data length */
__u8 *buf; /* Data buffer */
};
Setting Up Your Development Environment
Before you write your I2C kernel module, you need a working kernel build environment. Here is what you need:
- A Linux machine (native or VM — Ubuntu 22.04 or later works great)
- Kernel headers or the full kernel source tree
- A Makefile for building out-of-tree modules
- Cross-compilation tools if you are targeting ARM (arm-linux-gnueabihf-gcc)
- An actual I2C device to test with — an MPU6050 or AT24C02 EEPROM works perfectly
Install Required Packages
- sudo apt update
- sudo apt install build-essential linux-headers-$(uname -r)
- sudo apt install i2c-tools # Very useful for testing
Verify Your I2C Bus
Before writing any driver code, check that your I2C bus is working and your device is detected. On a Raspberry Pi or BeagleBone:
i2cdetect -y 1
This command scans I2C bus 1 and prints a grid of detected device addresses. If your sensor shows up (say at address 0x68 for an MPU6050), you are good to go.
Writing Your First I2C Driver: Step by Step
Now we get to the real work. We are going to write a complete, working I2C client driver for a simple device. We will use the AT24C02 I2C EEPROM as our example because it is cheap, easy to get, and the register access pattern is straightforward. The full driver will have: module init/exit, an i2c_driver struct, a probe function, a remove function, basic read/write functions using i2c_transfer, and a sysfs interface so you can read/write from userspace.
Step 1: Include the Right Headers
#include <linux/module.h>
#include <linux/init.h>
#include <linux/i2c.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/fs.h>
#include <linux/device.h>
Step 2: Define the Device ID Table
The id_table tells the kernel which devices your driver supports. The kernel uses this to match your driver to a device — either from a platform file or from the device tree.
static const struct i2c_device_id myeeprom_id[] = {
{ "at24c02", 0 },
{ } /* Sentinel */
};
MODULE_DEVICE_TABLE(i2c, myeeprom_id);
Step 3: Write the Probe Function
The probe function is called by the kernel when it finds a matching device. This is where you initialize your device, allocate memory, and set up any interfaces (like sysfs or character device files). Think of probe as your driver’s constructor.
static int myeeprom_probe(struct i2c_client *client,
const struct i2c_device_id *id)
{
dev_info(&client->dev, "myeeprom: probing device at 0x%02x\n",
client->addr);
/* Check if adapter supports everything we need */
if (!i2c_check_functionality(client->adapter,
I2C_FUNC_SMBUS_BYTE_DATA)) {
dev_err(&client->dev, "Adapter does not support SMBus\n");
return -ENODEV;
}
return 0; /* Probe success */
}
Step 4: Write the Remove Function
The remove function is called when the device is removed or the driver is unloaded. Clean up whatever you allocated in probe. Release memory, unregister interfaces, and free IRQs if you grabbed any.
static int myeeprom_remove(struct i2c_client *client)
{
dev_info(&client->dev, "myeeprom: removing device\n");
return 0;
}
Step 5: Define the i2c_driver Struct
static struct i2c_driver myeeprom_driver = {
.driver = {
.name = "myeeprom",
.owner = THIS_MODULE,
},
.probe = myeeprom_probe,
.remove = myeeprom_remove,
.id_table = myeeprom_id,
};
Step 6: Module Init and Exit
These two functions register and unregister your driver with the I2C core. The module_i2c_driver macro is a shorthand that does exactly this — it expands into the init and exit boilerplate for you.
module_i2c_driver(myeeprom_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Raj Kumar <embeddedprep.com>");
MODULE_DESCRIPTION("Simple AT24C02 I2C EEPROM Driver");
Reading and Writing Registers Over I2C
Having a probe function is great, but your I2C driver needs to actually communicate with the device. Let us look at the two main ways to do that in the Linux kernel.
Method 1: Using SMBus Functions
SMBus is a subset of I2C, and Linux has a very clean API for it. If your device is SMBus-compatible (most simple sensors and EEPROMs are), use these. They are simpler and handle a lot of the transaction overhead for you.
/* Read a single byte from a register */
s32 i2c_smbus_read_byte_data(struct i2c_client *client, u8 command);
/* Write a single byte to a register */
s32 i2c_smbus_write_byte_data(struct i2c_client *client,
u8 command, u8 value);
Here is a real example. Say you want to read the temperature from an LM75 sensor (register 0x00):
s32 temp_raw = i2c_smbus_read_byte_data(client, 0x00);
if (temp_raw < 0) {
dev_err(&client->dev, "Failed to read temperature\n");
return temp_raw;
}
dev_info(&client->dev, "Raw temp: %d\n", temp_raw);
Method 2: Using i2c_transfer for Raw I2C Transactions
When SMBus functions are not enough — for example, when you need combined write-then-read transactions or non-standard protocols — you drop down to i2c_transfer. This gives you full control over every byte on the bus.
Here is how to write a register address and then read back two bytes from it:
int mydevice_read_register(struct i2c_client *client,
u8 reg, u8 *val, int len)
{
struct i2c_msg msgs[2];
int ret;
/* First message: write register address */
msgs[0].addr = client->addr;
msgs[0].flags = 0; /* Write */
msgs[0].len = 1;
msgs[0].buf = ®
/* Second message: read the data back */
msgs[1].addr = client->addr;
msgs[1].flags = I2C_M_RD; /* Read */
msgs[1].len = len;
msgs[1].buf = val;
ret = i2c_transfer(client->adapter, msgs, 2);
if (ret != 2) {
dev_err(&client->dev, "i2c_transfer failed: %d\n", ret);
return ret < 0 ? ret : -EIO;
}
return 0;
}
The key insight here: i2c_transfer returns the number of messages successfully transferred, not the number of bytes. So for 2 messages, you check ret == 2.
Adding Device Tree Support to Your I2C Driver
Modern Linux systems use the device tree to describe hardware. If you want your I2C driver to work on ARM platforms (Raspberry Pi, BeagleBone, i.MX boards, etc.), you need to add device tree support. This is not optional — it is the standard approach for any driver you want to upstream or deploy on real hardware.
Add an of_match_table
static const struct of_device_id myeeprom_of_match[] = {
{ .compatible = "atmel,at24c02" },
{ }
};
MODULE_DEVICE_TABLE(of, myeeprom_of_match);
Then add it to your i2c_driver struct:
.driver = {
.name = "myeeprom",
.of_match_table = myeeprom_of_match,
.owner = THIS_MODULE,
},
Device Tree Node Example
In your board’s .dts or .dtsi file, add a node like this to describe your I2C device:
&i2c1 {
status = "okay";
eeprom@50 {
compatible = "atmel,at24c02";
reg = <0x50>;
};
};
When the kernel boots and parses this device tree, it will instantiate an i2c_client for your device at address 0x50 and call your probe function automatically.
Writing the Complete Makefile
You need a Makefile to build your kernel module. Here is a clean, minimal one that works for any out-of-tree I2C driver:
obj-m += myeeprom.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
Build it with:
make
Load it with:
sudo insmod myeeprom.ko
Check kernel logs:
dmesg | tail -20
Testing and Debugging Your I2C Driver
Writing the code is half the battle. Testing and debugging is where you really learn what is happening. Here are the tools and techniques that actually work.
i2c-tools for Quick Hardware Verification
Before you even load your driver, use i2c-tools to verify communication with the device:
# Scan the bus for devices
i2cdetect -y 1
# Read a register directly
i2cget -y 1 0x50 0x00
# Write a value to a register
i2cset -y 1 0x50 0x00 0xAB
These tools bypass your driver entirely and talk to the hardware directly through the i2c-dev interface. If these work but your driver does not, the problem is in your driver code, not the hardware.
Using dmesg for Kernel Log Messages
Always use the dev_info, dev_warn, and dev_err macros in your driver rather than printk. They prefix your messages with the device name automatically, which makes log filtering much easier:
dev_info(&client->dev, “Driver probed at address 0x%02x\n”, client->addr);
dev_err(&client->dev, “Read failed with error %d\n”, ret);
Then check your messages:
dmesg | grep myeeprom
Using /sys/bus/i2c/devices
After your driver loads and probe succeeds, check the sysfs tree:
ls /sys/bus/i2c/devices/
cat /sys/bus/i2c/devices/1-0050/name
This tells you that the kernel has matched your driver to the device and probe was called successfully.
Common Errors and What They Mean
If probe returns -ENODEV, it means either the device is not on the bus or i2c_check_functionality failed. Run i2cdetect to verify the device is present.
If i2c_transfer returns -ETIMEDOUT, the device is not acknowledging. Check your wiring, pull-up resistors, and bus speed.
If i2c_transfer returns -EIO, there was a bus error. This often points to missing or wrong pull-up resistors on SDA and SCL lines.
If you see -EBUSY, something else already has the device — either another driver is bound to it or i2c-dev is holding it open.
Adding a Sysfs Interface to Your I2C Driver
Sysfs is the standard way to expose driver attributes to userspace in Linux. Adding a sysfs attribute to your I2C driver lets you read sensor data or write configuration values directly from the command line or a Python script — without writing a separate userspace application.
Define a Sysfs Attribute
static ssize_t temperature_show(struct device *dev,
struct device_attribute *attr,
char *buf)
{
struct i2c_client *client = to_i2c_client(dev);
s32 val = i2c_smbus_read_byte_data(client, 0x00);
if (val < 0)
return val;
return sprintf(buf, "%d\n", val);
}
static DEVICE_ATTR_RO(temperature);
Register the Attribute in Probe
ret = device_create_file(&client->dev, &dev_attr_temperature);
if (ret) {
dev_err(&client->dev, "Failed to create sysfs file\n");
return ret;
}
After loading, read the value from userspace:
cat /sys/bus/i2c/devices/1-0048/temperature
Full Working I2C Driver Example (Complete Code)
Here is the complete, minimal I2C driver putting everything together. You can use this as a template for any new I2C device driver you write:
#include <linux/module.h>
#include <linux/init.h>
#include <linux/i2c.h>
#include <linux/kernel.h>
#include <linux/slab.h>
struct mydevice_data {
struct i2c_client *client;
};
static ssize_t value_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
struct i2c_client *client = to_i2c_client(dev);
s32 val = i2c_smbus_read_byte_data(client, 0x00);
if (val < 0) return val;
return sprintf(buf, "%d\n", val);
}
static DEVICE_ATTR_RO(value);
static int mydevice_probe(struct i2c_client *client,
const struct i2c_device_id *id)
{
struct mydevice_data *data;
int ret;
if (!i2c_check_functionality(client->adapter,
I2C_FUNC_SMBUS_BYTE_DATA))
return -ENODEV;
data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
if (!data) return -ENOMEM;
data->client = client;
i2c_set_clientdata(client, data);
ret = device_create_file(&client->dev, &dev_attr_value);
if (ret) return ret;
dev_info(&client->dev, "mydevice probed at 0x%02x\n", client->addr);
return 0;
}
static int mydevice_remove(struct i2c_client *client)
{
device_remove_file(&client->dev, &dev_attr_value);
return 0;
}
static const struct i2c_device_id mydevice_id[] = {
{ "mydevice", 0 }, { }
};
MODULE_DEVICE_TABLE(i2c, mydevice_id);
static const struct of_device_id mydevice_of_match[] = {
{ .compatible = "myvendor,mydevice" }, { }
};
MODULE_DEVICE_TABLE(of, mydevice_of_match);
static struct i2c_driver mydevice_driver = {
.driver = {
.name = "mydevice",
.of_match_table = mydevice_of_match,
.owner = THIS_MODULE,
},
.probe = mydevice_probe,
.remove = mydevice_remove,
.id_table = mydevice_id,
};
module_i2c_driver(mydevice_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Raj Kumar");
MODULE_DESCRIPTION("Generic I2C Device Driver Template");
I2C Driver vs SPI Driver: Key Differences
A question that comes up a lot for beginners: when should you use I2C versus SPI, and how does the driver structure differ?
I2C uses two wires and supports multiple devices on the same bus. SPI uses four wires (MOSI, MISO, SCLK, CS) per device but is faster. For I2C device driver programming, addressing is built into the protocol. For SPI, you manage chip-select lines manually.
The driver structure in Linux is similar — both use probe/remove, both support device tree. The main difference is the bus-specific APIs: i2c_transfer and i2c_smbus_* for I2C, versus spi_transfer and spi_sync for SPI. If you understand one, picking up the other takes maybe a day.
Common I2C Driver Interview Questions
If you are studying for embedded Linux interviews, these are the questions you should be ready to answer about I2C driver development:
What is the difference between i2c_transfer and i2c_smbus_read_byte_data?
i2c_smbus_read_byte_data is a higher-level function that handles a standard SMBus read of one byte from a register. i2c_transfer is the low-level function that gives you direct control over the I2C messages sent on the bus. Use SMBus functions when they fit your device’s protocol; use i2c_transfer when you need custom multi-message transactions.
What does i2c_check_functionality do and why is it important?
i2c_check_functionality queries the adapter to see if it supports a specific set of features — like SMBus byte data, block reads, or I2C combined transactions. Calling it in probe before you do any transfers prevents cryptic failures later when you try to use a feature the adapter does not support.
What is devm_kzalloc and why should you use it?
devm_kzalloc is a device-managed version of kzalloc. Memory allocated with it is automatically freed when the device is removed or the driver is unbound. This prevents memory leaks in your driver without requiring explicit cleanup code in the remove function. Always prefer devm_ variants in modern kernel driver code.
How does the kernel match an I2C driver to a device?
The kernel uses three matching mechanisms: the i2c_device_id table (for platform-instantiated devices), the of_device_id table (for device tree nodes), and ACPI IDs. When a match is found, the kernel calls the driver’s probe function with the corresponding i2c_client.
Best Practices for Writing I2C Drivers in Linux
After writing several I2C drivers, you start to notice patterns that save you from bugs. Here are the practices worth adopting from day one:
- Always call i2c_check_functionality in probe — do not assume the adapter supports what you need.
- Use devm_ prefixed allocation functions (devm_kzalloc, devm_request_irq) to avoid resource leaks.
- Store per-device state in a private structure and use i2c_set_clientdata / i2c_get_clientdata to attach and retrieve it.
- Check return values from every i2c_smbus_ and i2c_transfer call — never ignore them.
- Use dev_err / dev_info with &client->dev rather than plain printk — it gives you device-specific log context.
- Handle -EPROBE_DEFER in probe if your driver depends on another driver (like a clock or regulator) that might not be ready yet.
- Add device tree support with an of_match_table even if you are initially using i2c_device_id — it makes your driver portable.
Registering an I2C Device Without Device Tree (Legacy Method)
On older kernels or bare-metal-style BSPs, devices are sometimes registered programmatically using i2c_board_info. You will see this in older platform files:
static struct i2c_board_info my_board_info[] = {
{
I2C_BOARD_INFO("at24c02", 0x50),
},
};
i2c_register_board_info(1, my_board_info, ARRAY_SIZE(my_board_info));
This approach is deprecated for new code. Use device tree on all modern Linux platforms. But you will encounter this in legacy codebases, so it is worth knowing.
Wrapping Up: What You Have Learned
If you followed this guide all the way through, you now understand the full picture of how to write an I2C driver in Linux. You know how the I2C protocol works at the hardware level, how the Linux I2C subsystem is layered, what the key data structures are (i2c_adapter, i2c_client, i2c_driver, i2c_msg), how to write probe and remove functions, how to perform read and write operations using both SMBus and raw i2c_transfer, how to add device tree support, how to test your driver with i2c-tools and dmesg, and how to expose your driver’s data through sysfs.
That is not a small amount of knowledge. Most embedded developers I know learned this over months of trial and error. Now you have it in one place.
The best next step from here: grab an I2C sensor (an MPU6050 is perfect), connect it to a Raspberry Pi or BeagleBone, and write a real driver from scratch using this guide as your reference. Nothing cements kernel driver concepts faster than actually debugging a real driver on real hardware.
| Next Steps: Check out our guides on SPI driver development, platform device drivers, and writing device tree overlays on embeddedprep.com. |
Mr. Raj Kumar is a highly experienced Technical Content Engineer with 7 years of dedicated expertise in the intricate field of embedded systems. At Embedded Prep, Raj is at the forefront of creating and curating high-quality technical content designed to educate and empower aspiring and seasoned professionals in the embedded domain.
Throughout his career, Raj has honed a unique skill set that bridges the gap between deep technical understanding and effective communication. His work encompasses a wide range of educational materials, including in-depth tutorials, practical guides, course modules, and insightful articles focused on embedded hardware and software solutions. He possesses a strong grasp of embedded architectures, microcontrollers, real-time operating systems (RTOS), firmware development, and various communication protocols relevant to the embedded industry.
Raj is adept at collaborating closely with subject matter experts, engineers, and instructional designers to ensure the accuracy, completeness, and pedagogical effectiveness of the content. His meticulous attention to detail and commitment to clarity are instrumental in transforming complex embedded concepts into easily digestible and engaging learning experiences. At Embedded Prep, he plays a crucial role in building a robust knowledge base that helps learners master the complexities of embedded technologies.
