The integration of peripheral devices in embedded Linux systems is rarely a plug-and-play experience. In low-level hardware contexts, devices such as sensors, EEPROMs, DACs, or touchscreen controllers communicate using serial buses like I²C (Inter-Integrated Circuit) or SPI (Serial Peripheral Interface). The Linux kernel does not auto-discover such devices the way PCIe or USB peripherals might be, and so it requires developers to explicitly define and manage these devices through well-structured platform drivers. Modern Linux systems have gradually shifted from legacy static configuration mechanisms to a dynamic, data-driven approach using the Device Tree, a hardware description format that enables hardware abstraction without recompiling the kernel. Writing a platform driver that properly interfaces an I²C or SPI device through Device Tree bindings is a vital skill for embedded developers and kernel programmers, particularly in a world increasingly composed of headless, compact, and deeply embedded systems.
To begin, it’s important to understand the rationale for platform drivers and why I²C and SPI devices demand a specialized approach. Platform devices, by design, do not have standard self-enumerating buses. In the case of an I²C device, the kernel cannot probe the bus for every possible address and driver combination due to the risk of causing undefined behavior on the bus. Instead, each device must be defined explicitly in the system’s Device Tree source (.dts) file, which maps the physical hardware’s layout and properties in a format that the Linux kernel understands. These definitions do not themselves constitute drivers; rather, they are declarations that inform the kernel what device exists at which address on which bus, what driver to bind it to, and what configuration parameters are needed.
When writing a platform driver for an I²C device, the process typically starts by implementing a struct i2c_driver and registering it using i2c_add_driver. This structure must include function pointers for probe, remove, and optionally suspend/resume operations. The probe() function is the heart of the driver’s logic, where device initialization occurs once the kernel has matched the Device Tree node to the driver using compatible strings. The compatible string acts as a handshake — it must exactly match what is declared in the .dts file, often following a vendor,device-name format, such as "myvendor,mydevice".
Inside the probe() function, the driver uses helper functions like devm_ioremap, i2c_smbus_read_byte_data, or regmap APIs to configure registers, initialize device-specific data structures, and create kernel interfaces (e.g., sysfs entries or input devices). Once initialized, the kernel knows how to interact with the hardware via this driver abstraction, and user-space tools or applications can interface with the device through standard APIs, or via custom IOCTLs, sysfs entries, or /dev nodes created by the driver.
A typical Device Tree entry for an I²C device might look like the following:
&i2c1 {
touchscreen@48 {
compatible = "myvendor,mytouchscreen";
reg = <0x48>;
interrupt-parent = <&gpio1>;
interrupts = <17 IRQ_TYPE_EDGE_FALLING>;
reset-gpios = <&gpio2 5 GPIO_ACTIVE_LOW>;
};
};In this case, the kernel is told that on the i2c1 bus, there exists a device at address 0x48 which should be handled by a driver supporting the compatible string "myvendor,mytouchscreen". It also declares that the device uses an interrupt line and a reset GPIO, which must be acquired and controlled by the driver. In your C driver code, you’d use devm_gpiod_get or devm_request_threaded_irq to claim and use these resources.
SPI devices follow a remarkably similar pattern. The key difference lies in the nature of the SPI bus, which is full-duplex and includes chip-select lines. The SPI framework uses struct spi_driver instead of struct i2c_driver, and the initialization and data transfer mechanisms involve functions like spi_sync, spi_write, or spi_read. While I²C is generally used for simpler, low-speed devices, SPI is often favored for high-speed communication with devices like flash memory chips, high-resolution ADCs, or display controllers.
A Device Tree entry for an SPI device might resemble:
&spi0 {
flash@0 {
compatible = "myvendor,myflashchip";
reg = <0>;
spi-max-frequency = <50000000>;
spi-cpol;
spi-cpha;
};
};As with I²C, the reg value here represents the chip-select line, and optional properties like clock polarity (spi-cpol) and phase (spi-cpha) are parsed by the SPI subsystem and passed to the driver.
To access these properties in your SPI platform driver, you would again use the .probe() callback. Within it, the spi_device structure contains fields such as max_speed_hz and mode, which can be set based on what the device requires. You can register the driver with module_spi_driver() macro, which simplifies the insertion and removal logic via __init and __exit kernel macros.
A deeper layer of this integration involves writing the appropriate of_match_table entries in your driver. This table is what the kernel uses during Device Tree parsing to match nodes with drivers:
static const struct of_device_id my_driver_of_match[] = {
{ .compatible = "myvendor,mydevice", },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_driver_of_match);This entry ensures that the compatible string declared in the .dts file maps cleanly to the driver during kernel boot or module insertion. Once matched, the kernel invokes the probe() function and the device begins functioning as expected.
While this flow is relatively straightforward for basic devices, real-world drivers are rarely so simple. Robust implementations include power management (runtime_pm, suspend/resume hooks), error recovery mechanisms, support for multiple versions of the hardware, and proper resource cleanup on failure paths. Using devm_ (device-managed) functions is highly recommended, as it significantly reduces the likelihood of resource leaks, particularly when writing drivers for systems with constrained memory or critical uptime requirements.
Developers often use commands such as the following to debug their platform drivers during development:
dmesg | grep mydriver
ls /sys/bus/i2c/devices/
cat /sys/bus/spi/devices/spi0.0/modalias
hexdump /dev/spidev0.0
i2cdetect -y 1These commands help ensure the device is properly enumerated, the driver has bound correctly, and the hardware is responding. In some cases, you might want to recompile the Device Tree or load it dynamically using U-Boot’s fdt commands or tools like fdtoverlay.
Beyond the basics, developers also need to consider kernel version compatibility. Over time, the kernel has added abstractions like regmap, which simplifies register access for both I²C and SPI devices. Regmap introduces caching, endianness handling, and register abstraction layers that can significantly reduce boilerplate in your driver. You define a regmap_config struct, and initialize the regmap in your probe function:
regmap = devm_regmap_init_i2c(client, ®map_config);For complex devices that support multiple sub-functions (like an I²C-attached PMIC with voltage regulators, clocks, or GPIOs), the driver may register multiple sub-devices via mfd_add_devices, splitting functionality across kernel subsystems (regulator, clk, gpio, etc.).
Another crucial concept is that of device tree bindings, which must be well-documented and submitted alongside the driver when contributing to the mainline Linux kernel. Bindings are defined in YAML format under Documentation/devicetree/bindings/, and describe how Device Tree properties map to driver expectations. Poorly defined bindings lead to bugs, mismatches, or long-term maintenance difficulties.
Device Tree overlays are increasingly used in runtime-reconfigurable systems such as Raspberry Pi or BeagleBone. Overlays allow developers to modify or append new nodes to the existing Device Tree blob without recompiling the base kernel or rebooting the system. On Raspberry Pi OS, for instance, these overlays are managed via /boot/config.txt, where enabling an I²C sensor might involve:
dtoverlay=mydevice,i2c_address=0x48This dynamic nature enables rapid prototyping and modular hardware design without requiring deep kernel hacks.
Despite the many facilities available to platform driver developers, the learning curve remains steep. Testing strategies often involve logic analyzers, GPIO toggling, and debug prints in the kernel log. Proper error handling, interrupt edge detection, race condition mitigation, and performance optimizations (e.g., DMA transfers for SPI) require careful tuning. As you mature your driver, be sure to follow kernel coding style, use printk_ratelimited, and avoid polling loops where interrupts suffice.
Once development is complete, consider writing a modinfo entry and installing your driver with:
insmod my_driver.ko
modprobe my_driverAnd use:
lsmod | grep my_driverTo verify the module is loaded. For deployment, your driver may need to be built into the kernel or loaded automatically at boot via initramfs or udev rules. You might also define module aliases to enable auto-loading based on modalias strings in sysfs:
MODULE_ALIAS("of:N*T*Cmyvendor,mydevice");For developers targeting Yocto-based embedded distributions, it’s common to write a BitBake recipe that compiles your driver as an out-of-tree kernel module, includes the Device Tree overlay, and sets up installation hooks for automated testing or QA pipelines.
As embedded Linux continues to dominate sectors like automotive, industrial IoT, robotics, and consumer electronics, mastering the mechanics of platform driver development will remain a cornerstone of kernel-level engineering. The synergy of I²C/SPI protocols, Device Tree descriptors, and tightly bound kernel drivers represents a clear example of the Linux philosophy in action: modular, customizable, and scalable by design.
In conclusion, writing platform drivers for I²C and SPI devices using Device Tree bindings is not only a technically rewarding pursuit but a deeply practical one for anyone working in embedded Linux. It fosters a strong understanding of both hardware-level protocols and the software interfaces that abstract them. With rigorous testing, thoughtful abstraction, and adherence to community standards, such drivers can power reliable, maintainable systems across a vast range of use cases — from the smallest sensor module to mission-critical industrial controllers.
