How to Implement an I2C Device Driver
Time estimate: ~90+ minutes Prerequisites: Enable I2C, Device Tree and Drivers
Learning Objectives
By the end of this tutorial you will be able to:
- Explain the Linux kernel module lifecycle (init, probe, read, exit)
- Build and load a kernel module (.ko) for an I2C sensor
- Write a character device driver step by step — from skeleton to working
/dev/node - Create a Device Tree overlay to bind a driver to hardware
- Read sensor data through a
/dev/device node - Set up udev rules for device permissions
Device Trees and the Kernel/User-Space Boundary
On a microcontroller you hard-code pin numbers and bus addresses into your firmware. Linux separates that hardware description into a Device Tree -- a data structure passed to the kernel at boot that tells it which buses exist, what devices are connected, and at which addresses. Each Device Tree node has a compatible string (e.g., "mcp9808") that the kernel matches against registered drivers. Overlays extend the base tree at runtime, so you can add a sensor without recompiling the kernel.
Once the kernel matches a DT node to a driver, the driver's probe() function runs: it initialises the hardware and registers a user-space interface -- typically a character device in /dev/ or attributes in /sys/. Applications in user space never touch hardware registers directly; they open the device file and call read() or write(), and the kernel's driver performs the actual I2C transaction safely. This boundary protects the system: a bug in your application cannot corrupt the bus or crash the kernel.
In this tutorial you will create both sides of the binding: a Device Tree overlay that describes where the MCP9808 sensor is, and a kernel module that provides the how -- the code that reads the temperature register and exposes it through /dev/mcp9808.
For deeper reading see the Device Tree and Drivers reference page.
Why Write a Kernel Driver?
In the Enable I2C tutorial you read the MCP9808 temperature sensor from Python using smbus. That works — so why go through the effort of writing a kernel driver?
| Aspect | User-space I2C (smbus / i2c-tools) |
Kernel driver |
|---|---|---|
| Setup effort | Minimal — pip install smbus |
Must compile a .ko, write a DT overlay |
| Concurrent access | No inter-process locking — sequences can interleave (see below) | Driver provides mutex protection for multi-step sequences |
| Standard interface | Custom script, custom API | Standard /dev/ node — any language can open() + read() |
| udev integration | None — you must know the bus number and address | Device appears automatically in /dev/ with configurable permissions |
| Power management | Manual — your script runs 24/7 | Kernel can suspend/resume the device |
| Other kernel consumers | Not possible — only your user-space script sees the data | hwmon, IIO, thermal subsystems can use the driver |
Rule of thumb: for quick prototyping → user-space I2C. For production embedded systems or when multiple applications need the sensor → kernel driver.
The I2C Concurrency Problem
Opening /dev/i2c-1 does not lock the bus or the device. Multiple processes can open the same device file simultaneously. The kernel serialises individual I2C transfers at the bus level (bytes don't collide on the wire), but it does not prevent interleaving of higher-level sequences across multiple system calls.
Most sensors require multi-step operations — for example, "write register address" then "read data". If two scripts run concurrently, their steps can interleave:
Script A: set register pointer → 0x10
Script B: set register pointer → 0x20 ← sneaks in between A's calls
Script A: read 2 bytes → reads from 0x20, not 0x10!
This doesn't physically damage the sensor, but it causes wrong reads, inconsistent data, or unexpected NACKs.
User-space workarounds:
- Combined transaction — put write+read into a single
I2C_RDWRioctl so the bus adapter executes them atomically: - Advisory file lock — use
flock()so all cooperating scripts serialise access: This is advisory — it only works if every script uses the same locking scheme. - Single daemon — centralise all sensor access in one process, expose data over a socket or D-Bus.
Kernel driver advantage: a driver can hold a struct mutex across the entire read sequence (set register, read data, decode). Every user-space caller goes through file_operations.read(), so the driver's mutex guarantees atomicity without requiring cooperation between applications. Note: the kernel doesn't add this automatically — you implement it in the driver. In this tutorial's simple MCP9808 driver, i2c_smbus_read_word_swapped() performs the write+read as a single bus transaction, which is sufficient. For sensors requiring multi-step configuration sequences, you would add an explicit mutex_lock()/mutex_unlock() around the critical section.
Theory: Key Kernel Structures
Before writing code, understand the four structures every I2C character device driver uses.
Driver Lifecycle
| Stage | Function | Purpose |
|---|---|---|
| Load module | module_init() |
Register the I2C driver with the kernel |
| Find device | probe() |
Device Tree matched — initialise hardware, create /dev/ node |
| Device in operation | file_operations |
User-space interaction (open, read, release) |
| Unload module | module_exit() |
Unregister I2C driver, free resources |
struct file_operations
Defines how user-space programs interact with your device:
static const struct file_operations mcp9808_fops = {
.owner = THIS_MODULE,
.open = mcp9808_open,
.read = mcp9808_read,
.release = mcp9808_release,
};
When a program calls read() on /dev/mcp9808, the kernel invokes your mcp9808_read() function.
struct cdev and Major/Minor Numbers
A character device (struct cdev) connects your file_operations to a device number. The kernel identifies devices by a major number (which driver) and minor number (which instance). alloc_chrdev_region() asks the kernel to assign a free major number automatically.
struct i2c_driver
Tells the I2C subsystem about your driver:
static struct i2c_driver mcp9808_i2c_driver = {
.driver = { .name = "mcp9808", .of_match_table = mcp9808_of_match },
.probe = mcp9808_probe,
.remove = mcp9808_remove,
.id_table = mcp9808_id,
};
The compatible string in of_match_table must match the Device Tree node — this is how the kernel knows to call your probe() when the hardware is present.
Hands-on: Build the Driver Step by Step
You will build the MCP9808 driver incrementally. Each step adds one concept, and you can compile and test after every step.
Course Source Repository
This tutorial references source files from the course repository. If you haven't cloned it yet on your Pi:
The complete driver source is at ~/embedded-linux/drivers/mcp9808.c. The steps below walk through building it from scratch — the repository copy is there as a reference if you get stuck.
Prepare the Build Environment
# Create a working directory
mkdir -p ~/mcp9808-driver && cd ~/mcp9808-driver
# Make sure kernel headers are installed
sudo apt install -y linux-headers-$(uname -r)
Create the Makefile:
obj-m += mcp9808.o
KDIR ?= /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
Step 1: Skeleton Module
Start with the absolute minimum — a module that loads and unloads.
Create mcp9808.c:
#include <linux/module.h>
#include <linux/init.h>
static int __init mcp9808_init(void)
{
pr_info("mcp9808: module loaded\n");
return 0;
}
static void __exit mcp9808_exit(void)
{
pr_info("mcp9808: module unloaded\n");
}
module_init(mcp9808_init);
module_exit(mcp9808_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("MCP9808 Temperature Sensor Driver");
Build and test:
Checkpoint — Skeleton Works
You should see mcp9808: module loaded and mcp9808: module unloaded in dmesg.
Step 2: I2C Driver Registration
Replace the manual init/exit with proper I2C driver registration. The kernel will call probe() when it finds a matching Device Tree node, and remove() when the driver is unloaded.
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/of.h>
static int mcp9808_probe(struct i2c_client *client)
{
dev_info(&client->dev, "mcp9808: probe called (addr=0x%02x)\n",
client->addr);
return 0;
}
static void mcp9808_remove(struct i2c_client *client)
{
dev_info(&client->dev, "mcp9808: remove called\n");
}
static const struct i2c_device_id mcp9808_id[] = {
{ "mcp9808", 0 },
{ }
};
MODULE_DEVICE_TABLE(i2c, mcp9808_id);
static const struct of_device_id mcp9808_of_match[] = {
{ .compatible = "mcp9808" },
{ }
};
MODULE_DEVICE_TABLE(of, mcp9808_of_match);
static struct i2c_driver mcp9808_i2c_driver = {
.driver = {
.name = "mcp9808",
.of_match_table = mcp9808_of_match,
},
.probe = mcp9808_probe,
.remove = mcp9808_remove,
.id_table = mcp9808_id,
};
module_i2c_driver(mcp9808_i2c_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("MCP9808 Temperature Sensor Driver");
Key changes:
- module_i2c_driver() replaces manual module_init/module_exit — it generates both automatically
- i2c_device_id[] matches by name (for old-style binding)
- of_device_id[] matches by compatible string (for Device Tree binding)
- probe() receives the struct i2c_client — your handle to the I2C device
Build it with make. Before we can test probe(), the kernel needs to know the MCP9808 exists on the I2C bus — that requires a Device Tree overlay.
Checkpoint — I2C Driver Compiles
make should produce mcp9808.ko without errors. Don't load it yet — probe() won't fire until we create the Device Tree overlay.
Device Tree Overlay
The driver code is ready, but the kernel still doesn't know where the MCP9808 is connected. A Device Tree overlay tells it: "there is a device with compatible = "mcp9808" on I2C bus 1 at address 0x18."
First, confirm the sensor's I2C address using i2c-tools:
You should see the MCP9808 at address 0x18:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- 18 -- -- -- -- -- -- --
...
Tip
If the address is different (e.g., 0x19, 0x1A), the MCP9808 address pins (A0–A2) are pulled high. Use the address you see here in the DT overlay below.
Create mcp9808-overlay.dts:
Paste this:
/dts-v1/; // Device Tree Source format version 1
/plugin/; // Marks this file as a DT overlay plugin
/ {
compatible = "brcm,bcm2835"; // Targets all Raspberry Pi models (Broadcom BCM2835/6/7/1)
fragment@0 {
target = <&i2c1>; // Apply this overlay to the i2c1 controller (usually on GPIO 2/3)
__overlay__ {
#address-cells = <1>; // I2C addresses are 1 byte
#size-cells = <0>; // No memory-mapped size, so size-cells is 0
mcp9808@19 { // Create a device node at I2C address 0x18
compatible = "mcp9808"; // Must match the driver's i2c_device_id[]
reg = <0x19>; // I2C address of the device
};
};
};
};
Set I2C device address
Set the proper I2C device address in the Device Tree overlay config. Modify from 19 to 18 in the node and the reg also
Tips for Customizing
If you're using multiple sensors or want to rename the node (e.g., temp1@18), that's totally doable.
|Field|Meaning|
|---|---|
|`compatible`|Must match the driver's name (`i2c_device_id[]`) in your kernel module|
|`i2c1`|Refers to the second I2C bus (first is `i2c0`, usually for firmware use)|
|`reg = <0x18>`|Set to your device's I2C address (`i2cdetect -y 1` helps verify it)|
Compile and Install the Overlay
dtc -@ -I dts -O dtb -o mcp9808.dtbo mcp9808-overlay.dts
sudo cp mcp9808.dtbo /boot/firmware/overlays/
Add to /boot/firmware/config.txt:
Reboot:
Checkpoint — Overlay Compiled and Installed
After reboot, dmesg | grep mcp9808 may not show anything yet (we haven't loaded the driver). But i2cdetect -y 1 should show 0x18 (not UU yet, since no driver has claimed it).
Test the Driver with the Overlay
Now load your Step 2 driver:
You should see:
The kernel matched the DT node's compatible = "mcp9808" to your driver's of_match_table and called probe(). Verify with:
Address 0x18 now shows UU — the kernel driver owns this device.
Unload for now:
Checkpoint — Probe Fires
dmesg shows probe called (addr=0x18). From this point on, every time you modify the driver, the cycle is: make → sudo insmod mcp9808.ko → test → sudo rmmod mcp9808.
Step 3: Read Temperature in Probe
Let's verify we can talk to the sensor. Read the temperature register in probe() and print it to the kernel log:
static int mcp9808_probe(struct i2c_client *client)
{
s32 raw;
int temp;
dev_info(&client->dev, "mcp9808: probe called (addr=0x%02x)\n",
client->addr);
/* Read 2-byte temperature register (register 0x05) */
raw = i2c_smbus_read_word_swapped(client, 0x05);
if (raw < 0) {
dev_err(&client->dev, "Failed to read temperature: %d\n", raw);
return raw;
}
/* Mask out flag bits (upper 3 bits are alert flags) */
raw &= 0x1FFF;
/* Convert to millidegrees Celsius (0.0625°C per LSB) */
if (raw & 0x1000)
temp = (raw - 0x2000) * 625 / 10; /* Negative temperature */
else
temp = raw * 625 / 10;
dev_info(&client->dev, "Temperature: %d.%04d °C\n",
temp / 10000, abs(temp % 10000));
return 0;
}
Info
i2c_smbus_read_word_swapped() reads a 16-bit value and byte-swaps it — the MCP9808 sends MSB first (big-endian) but SMBus returns LSB first. This is the kernel equivalent of the byte-swap you did in the Python script.
Checkpoint — Temperature in Kernel Log
Rebuild and load: make && sudo insmod mcp9808.ko. Then dmesg | grep mcp9808 should show the temperature (e.g., Temperature: 26.7500 °C). Unload with sudo rmmod mcp9808 before the next step.
Step 4: Character Device Registration
Now add a /dev/mcp9808 device node so user-space programs can read the temperature. Add the character device setup in probe():
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/of.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/uaccess.h>
struct mcp9808_data {
struct i2c_client *client;
struct cdev cdev;
dev_t devnum;
struct class *class;
struct device *device;
};
static struct mcp9808_data *mcp9808_dev;
/* file_operations will be added in Step 5 */
static const struct file_operations mcp9808_fops = {
.owner = THIS_MODULE,
};
static int mcp9808_probe(struct i2c_client *client)
{
int ret;
mcp9808_dev = devm_kzalloc(&client->dev, sizeof(*mcp9808_dev),
GFP_KERNEL);
if (!mcp9808_dev)
return -ENOMEM;
mcp9808_dev->client = client;
/* 1. Allocate a device number */
ret = alloc_chrdev_region(&mcp9808_dev->devnum, 0, 1, "mcp9808");
if (ret < 0) {
dev_err(&client->dev, "Failed to allocate chrdev region\n");
return ret;
}
/* 2. Initialise and add the character device */
cdev_init(&mcp9808_dev->cdev, &mcp9808_fops);
mcp9808_dev->cdev.owner = THIS_MODULE;
ret = cdev_add(&mcp9808_dev->cdev, mcp9808_dev->devnum, 1);
if (ret < 0) {
dev_err(&client->dev, "Failed to add cdev\n");
goto err_cdev;
}
/* 3. Create device class and device — this creates /dev/mcp9808 */
mcp9808_dev->class = class_create("mcp9808");
if (IS_ERR(mcp9808_dev->class)) {
ret = PTR_ERR(mcp9808_dev->class);
goto err_class;
}
mcp9808_dev->device = device_create(mcp9808_dev->class, NULL,
mcp9808_dev->devnum, NULL,
"mcp9808");
if (IS_ERR(mcp9808_dev->device)) {
ret = PTR_ERR(mcp9808_dev->device);
goto err_device;
}
dev_info(&client->dev, "mcp9808 registered as /dev/mcp9808\n");
return 0;
err_device:
class_destroy(mcp9808_dev->class);
err_class:
cdev_del(&mcp9808_dev->cdev);
err_cdev:
unregister_chrdev_region(mcp9808_dev->devnum, 1);
return ret;
}
The error-handling goto chain is a standard Linux kernel pattern — if step 3 fails, it undoes steps 2 and 1.
Checkpoint — /dev/mcp9808 Exists
Rebuild and load: make && sudo insmod mcp9808.ko. Then ls -la /dev/mcp9808 should show a character device. Unload with sudo rmmod mcp9808 before the next step.
Step 5: Implement read()
This is the core — when a user runs cat /dev/mcp9808, the kernel calls your read() function:
static ssize_t mcp9808_read(struct file *file, char __user *buf,
size_t count, loff_t *offset)
{
s32 raw;
int temp;
char temp_str[32];
int len;
if (*offset > 0)
return 0; /* Already read — return EOF */
/* Read temperature register */
raw = i2c_smbus_read_word_swapped(mcp9808_dev->client, 0x05);
if (raw < 0)
return raw;
raw &= 0x1FFF;
/* Convert to millidegrees */
if (raw & 0x1000)
temp = (raw - 0x2000) * 625 / 10;
else
temp = raw * 625 / 10;
/* Format as "XX.XXXX\n" */
len = snprintf(temp_str, sizeof(temp_str), "%d.%04d\n",
temp / 10000, abs(temp % 10000));
if (count < len)
len = count;
if (copy_to_user(buf, temp_str, len))
return -EFAULT;
*offset += len;
return len;
}
static const struct file_operations mcp9808_fops = {
.owner = THIS_MODULE,
.read = mcp9808_read,
};
Key points:
- copy_to_user() safely copies data from kernel space to user space — you cannot use memcpy() because the user buffer may be paged out
- The *offset check prevents re-reading: after one read(), subsequent calls return 0 (EOF) until the file is closed and reopened
- snprintf() formats the temperature as a human-readable string
Checkpoint — cat /dev/mcp9808 Works
Rebuild and load: make && sudo insmod mcp9808.ko. Then sudo cat /dev/mcp9808 should print the temperature (e.g., 26.7500). We'll fix the permissions with a udev rule later.
Step 6: Cleanup in remove()
When the module is unloaded, free everything in reverse order of allocation:
static void mcp9808_remove(struct i2c_client *client)
{
device_destroy(mcp9808_dev->class, mcp9808_dev->devnum);
class_destroy(mcp9808_dev->class);
cdev_del(&mcp9808_dev->cdev);
unregister_chrdev_region(mcp9808_dev->devnum, 1);
dev_info(&client->dev, "mcp9808: driver removed\n");
}
The order matters: destroy the device node first (so user-space cannot access it), then the class, then the cdev, then the number.
Checkpoint — Clean Unload
Rebuild with the full code, then: make && sudo insmod mcp9808.ko → verify /dev/mcp9808 exists and sudo cat /dev/mcp9808 prints temperature → sudo rmmod mcp9808 → verify /dev/mcp9808 is gone and dmesg | tail shows the removal message.
Complete Driver
Here is the final mcp9808.c with all steps combined:
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/of.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/uaccess.h>
struct mcp9808_data {
struct i2c_client *client;
struct cdev cdev;
dev_t devnum;
struct class *class;
struct device *device;
};
static struct mcp9808_data *mcp9808_dev;
static ssize_t mcp9808_read(struct file *file, char __user *buf,
size_t count, loff_t *offset)
{
s32 raw;
int temp;
char temp_str[32];
int len;
if (*offset > 0)
return 0;
raw = i2c_smbus_read_word_swapped(mcp9808_dev->client, 0x05);
if (raw < 0)
return raw;
raw &= 0x1FFF;
if (raw & 0x1000)
temp = (raw - 0x2000) * 625 / 10;
else
temp = raw * 625 / 10;
len = snprintf(temp_str, sizeof(temp_str), "%d.%04d\n",
temp / 10000, abs(temp % 10000));
if (count < len)
len = count;
if (copy_to_user(buf, temp_str, len))
return -EFAULT;
*offset += len;
return len;
}
static const struct file_operations mcp9808_fops = {
.owner = THIS_MODULE,
.read = mcp9808_read,
};
static int mcp9808_probe(struct i2c_client *client)
{
int ret;
mcp9808_dev = devm_kzalloc(&client->dev, sizeof(*mcp9808_dev),
GFP_KERNEL);
if (!mcp9808_dev)
return -ENOMEM;
mcp9808_dev->client = client;
ret = alloc_chrdev_region(&mcp9808_dev->devnum, 0, 1, "mcp9808");
if (ret < 0)
return ret;
cdev_init(&mcp9808_dev->cdev, &mcp9808_fops);
mcp9808_dev->cdev.owner = THIS_MODULE;
ret = cdev_add(&mcp9808_dev->cdev, mcp9808_dev->devnum, 1);
if (ret < 0)
goto err_cdev;
mcp9808_dev->class = class_create("mcp9808");
if (IS_ERR(mcp9808_dev->class)) {
ret = PTR_ERR(mcp9808_dev->class);
goto err_class;
}
mcp9808_dev->device = device_create(mcp9808_dev->class, NULL,
mcp9808_dev->devnum, NULL,
"mcp9808");
if (IS_ERR(mcp9808_dev->device)) {
ret = PTR_ERR(mcp9808_dev->device);
goto err_device;
}
dev_info(&client->dev, "mcp9808 registered as /dev/mcp9808\n");
return 0;
err_device:
class_destroy(mcp9808_dev->class);
err_class:
cdev_del(&mcp9808_dev->cdev);
err_cdev:
unregister_chrdev_region(mcp9808_dev->devnum, 1);
return ret;
}
static void mcp9808_remove(struct i2c_client *client)
{
device_destroy(mcp9808_dev->class, mcp9808_dev->devnum);
class_destroy(mcp9808_dev->class);
cdev_del(&mcp9808_dev->cdev);
unregister_chrdev_region(mcp9808_dev->devnum, 1);
dev_info(&client->dev, "mcp9808: driver removed\n");
}
static const struct i2c_device_id mcp9808_id[] = {
{ "mcp9808", 0 },
{ }
};
MODULE_DEVICE_TABLE(i2c, mcp9808_id);
static const struct of_device_id mcp9808_of_match[] = {
{ .compatible = "mcp9808" },
{ }
};
MODULE_DEVICE_TABLE(of, mcp9808_of_match);
static struct i2c_driver mcp9808_i2c_driver = {
.driver = {
.name = "mcp9808",
.of_match_table = mcp9808_of_match,
},
.probe = mcp9808_probe,
.remove = mcp9808_remove,
.id_table = mcp9808_id,
};
module_i2c_driver(mcp9808_i2c_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("MCP9808 Temperature Sensor Driver");
The sensor of the MCP9808 documentation.
Install the Driver Permanently
Once you have verified the complete driver works (insmod → cat /dev/mcp9808 → rmmod), install it so it loads automatically at boot.
Check Module Info
linux@eslinux:~/mcp9808-driver $ modinfo mcp9808.ko
filename: /home/linux/mcp9808-driver/mcp9808.ko
license: GPL
description: MCP9808 Temperature Sensor Driver
author: Your Name
srcversion: 7E4548216D745A7EDB4F521
alias: i2c:mcp9808
depends:
name: mcp9808
vermagic: 6.6.51+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64
Install it properly
Copy it into the system modules directory:
Update module dependencies:
Read temperature
Read the temperature with:
The permission denied:
The user has not permission to read the file:
Make Device Accessible to Users (udev rule)
Create a udev rule so any user can read the sensor. The file must end in .rules — udev ignores files without this extension:
# Note: the filename must end in .rules
RULE="/etc/udev/rules.d/99-mcp9808.rules"
echo 'KERNEL=="mcp9808", MODE="0666"' | sudo tee "$RULE"
sudo udevadm control --reload
sudo udevadm trigger
Verify the permissions changed:
Warning
If permissions are still crw-------, check the filename: ls /etc/udev/rules.d/99-mcp* — it must be 99-mcp9808.rules, not 99-mcp9808 (without extension).
Now try again read the temperature with:
Now the driver is working properly.
What Just Happened?
You implemented the complete driver development workflow for an I2C sensor, step by step:
- Skeleton module —
module_init/module_exitto verify the build system works - I2C driver registration —
struct i2c_driverwithprobe()/remove(), matched bycompatiblestring - Temperature reading —
i2c_smbus_read_word_swapped()to talk to the sensor, same decoding logic as the Python script in Enable I2C - Character device —
alloc_chrdev_region,cdev_init,class_create,device_create→/dev/mcp9808appears - User-space interface —
file_operations.readwithcopy_to_user()lets any program read the temperature - Device Tree overlay — tells the kernel where the hardware is
- udev rule — automatic device permissions
This is the same workflow used for any Linux I2C driver, whether it's a temperature sensor, accelerometer, or display controller. The Device Tree describes the hardware, and the driver provides the software interface.