BMI160 SPI Kernel Driver
Time estimate: ~90+ minutes Prerequisites: Enable I2C, Device Tree and Drivers Reference
Learning Objectives
By the end of this tutorial you will be able to:
- Write an SPI kernel driver (comparing to the I2C driver from the MCP9808 tutorial)
- Implement a sysfs interface for accelerometer and gyroscope data
- Understand SPI transfer mechanics (read bit, full-duplex communication)
- Create a Device Tree overlay for an SPI device
Understanding Kernel SPI Drivers
Writing a kernel driver for an SPI device follows a well-defined pattern. First, you declare the
device in the device tree with a compatible string (e.g., "bosch,bmi160") and the SPI bus
parameters (chip select, max frequency). When the kernel boots and parses the device tree, it
looks for a loaded driver whose of_match_table contains that same compatible string. When a
match is found, the kernel calls your driver's probe() function, passing a handle to the
SPI device. Inside probe(), you initialise the hardware — verify the chip ID, run a power-on
sequence — and register interfaces (sysfs attributes, character device, or IIO channels) so that
user-space applications can read sensor data. The kernel handles bus locking, power management,
and device lifetime automatically. This is the same pattern used for I2C drivers; only the bus
helpers and transfer mechanics differ.
For a deeper look at device tree syntax, overlay compilation, and driver binding, see the Device Tree and Drivers Reference.
Course Source Repository
This tutorial references source files from the course repository. If you haven't cloned it yet on your Pi:
Source files for this tutorial are in ~/embedded-linux/drivers/bmi160_spi.c and ~/embedded-linux/overlays/bmi160-spi.dts.
1. SPI Background
SPI (Serial Peripheral Interface) is a synchronous, full-duplex bus commonly used for high-speed sensor communication. Compare it to I2C:
| Feature | SPI | I2C |
|---|---|---|
| Wires | 4 (MOSI, MISO, SCLK, CS) + 1 per extra device | 2 (SDA, SCL) shared |
| Speed | Up to 10+ MHz typical, 50+ MHz possible | 100 kHz / 400 kHz / 1 MHz standard |
| Addressing | Chip Select (CS) line per device — no address byte | 7-bit address on shared bus |
| Duplex | Full duplex — send and receive simultaneously | Half duplex — one direction at a time |
| Typical use | IMUs, displays, flash memory, ADCs | Temperature sensors, EEPROMs, RTCs |
| Complexity | More wires, simpler protocol | Fewer wires, more complex protocol |
SPI signal lines:
| Signal | Full Name | Direction | Purpose |
|---|---|---|---|
| MOSI | Master Out Slave In | Pi → Sensor | Data from master to slave |
| MISO | Master In Slave Out | Sensor → Pi | Data from slave to master |
| SCLK | Serial Clock | Pi → Sensor | Clock signal generated by master |
| CS | Chip Select | Pi → Sensor | Active LOW — selects the target device |
Tip
The key difference from I2C: in SPI, data flows in both directions simultaneously. When you send a register address on MOSI, the slave simultaneously sends data back on MISO. This is why SPI reads require a "dummy" transmit byte — you must clock data out to clock data in.
2. User-Space SPI vs Kernel Driver
After enabling SPI you get /dev/spidev0.0. You could talk to the BMI160 directly from Python:
import spidev
spi = spidev.SpiDev()
spi.open(0, 0)
spi.max_speed_hz = 1_000_000
chip_id = spi.xfer2([0x80, 0x00])[1] # 0xD1
This works — in fact the Python prototype tutorial does exactly this. So why write a kernel driver at all?
| Aspect | User-space (spidev) |
Kernel driver |
|---|---|---|
| Setup effort | 3 lines of Python | 500+ lines of C, device tree overlay, Makefile |
| Access from any language | Only the process that opens spidev | Any process via /dev/bmi160 or sysfs — shell, Python, C, Rust |
| Concurrent access | You must manage locking yourself | Kernel serialises access to the bus automatically |
| Init sequence | Every application must know the power-on sequence | Driver runs it once at boot — users just read data |
| Standard interface | Custom protocol per application | Standard sysfs/IIO — tools like iio_readdev work immediately |
| Permissions | Root or udev rule for raw SPI | Fine-grained: expose only what is needed |
| Hotplug and power management | Manual | Kernel handles suspend/resume, device removal |
| Overhead | Minimal — direct register access | Small — one kernel/user copy per read |
Rule of thumb:
- Prototype / one-off script →
spidevis faster to get running. - Product / shared sensor / multiple consumers → a kernel driver pays for itself.
- Learning → build both. This tutorial writes the driver; the Level Display: Python tutorial uses
spidev. Comparing the two gives you the engineering judgement to choose.
Note
The in-tree Linux kernel already ships a BMI160 IIO driver (drivers/iio/imu/bmi160/). We write our own simplified version here to learn the mechanics. In a real product you would use or extend the upstream driver.
3. BMI160 Register Map
The BMI160 is a 6-axis IMU (3-axis accelerometer + 3-axis gyroscope) from Bosch Sensortec. Key registers:
| Register | Address | Description | Expected Value |
|---|---|---|---|
CHIP_ID |
0x00 |
Device identification | 0xD1 |
DATA_GYR |
0x0C--0x11 |
Gyroscope X, Y, Z (16-bit each, little-endian) | Varies |
DATA_ACC |
0x12--0x17 |
Accelerometer X, Y, Z (16-bit each, little-endian) | Varies |
TEMPERATURE |
0x20--0x21 |
Temperature (16-bit, signed) | Varies |
ACC_CONF |
0x40 |
Accelerometer configuration (ODR, bandwidth) | 0x28 default |
GYR_CONF |
0x42 |
Gyroscope configuration (ODR, bandwidth) | 0x28 default |
CMD |
0x7E |
Command register (soft reset, power mode) | Write-only |
SPI read protocol: To read a register, the first byte transmitted is 0x80 | register_address. Setting bit 7 signals a read operation. The sensor responds on the next byte (clock one more byte of dummy data to receive the response).
SPI write protocol: To write a register, the first byte transmitted is register_address (bit 7 clear). The second byte is the value to write.
Tip
The BMI160 datasheet (BST-BMI160-DS000) contains the complete register map. You can find it at Bosch Sensortec.
4. Wiring
Connect the BMI160 breakout board to the Raspberry Pi SPI0 bus:
| BMI160 Pin | Connect to RPi | RPi Pin | Notes |
|---|---|---|---|
| VCC | 3.3V | Pin 1 | Power supply (3.3V only!) |
| GND | GND | Pin 6 | Ground |
| SCL | SCLK (GPIO11) | Pin 23 | SPI clock |
| SDA | MOSI (GPIO10) | Pin 19 | Data: Pi to sensor |
| SDO | MISO (GPIO9) | Pin 21 | Data: sensor to Pi |
| CS | CE0 (GPIO8) | Pin 24 | Chip select (active LOW) |
SPI uses different pins than I2C
Do not confuse SPI and I2C connections. I2C uses GPIO2 (SDA) and GPIO3 (SCL) on pins 3 and 5. SPI uses GPIO8--11 on pins 19, 21, 23, 24. Connecting to the wrong pins will not damage anything but the sensor will not respond.
Tip
If your BMI160 board has a pin labeled SDO and another labeled SDA, the SDA pin is MOSI (data in to sensor) and SDO is MISO (data out from sensor). Some breakout boards label these differently — check the board schematic.
5. Enable SPI
Enable the SPI interface on the Raspberry Pi:
Option A: Using raspi-config
Navigate to:
Option B: Edit config.txt directly
Add or uncomment:
Reboot:
After reboot, verify SPI devices exist:
Expected output:
Checkpoint
/dev/spidev0.0 and /dev/spidev0.1 should be listed. These correspond to SPI bus 0, chip select 0 and 1 respectively. Your BMI160 is on CE0, so you will use /dev/spidev0.0.
Stuck?
If /dev/spidev* does not appear:
- Check that
dtparam=spi=onis in/boot/firmware/config.txt - Run
lsmod | grep spi— you should seespi_bcm2835 - Check
dmesg | grep -i spifor error messages
6. Driver Code Walkthrough
The driver source is at src/embedded-linux/drivers/bmi160_spi.c. This section walks through the key parts.
Module Structure and Compatible String
static const struct of_device_id bmi160_spi_of_match[] = {
{ .compatible = "bosch,bmi160" },
{ }
};
MODULE_DEVICE_TABLE(of, bmi160_spi_of_match);
static struct spi_driver bmi160_spi_driver = {
.driver = {
.name = "bmi160_spi",
.of_match_table = bmi160_spi_of_match,
},
.probe = bmi160_probe,
.remove = bmi160_remove,
};
module_spi_driver(bmi160_spi_driver);
The compatible string must match exactly what you put in the Device Tree overlay. When the kernel finds a matching device, it calls probe().
SPI Read/Write Helpers
static int bmi160_spi_read_reg(struct spi_device *spi, u8 reg, u8 *val)
{
u8 tx[2] = { reg | 0x80, 0x00 }; /* bit 7 = read */
u8 rx[2] = { 0 };
struct spi_transfer t = {
.tx_buf = tx,
.rx_buf = rx,
.len = 2,
};
struct spi_message m;
int ret;
spi_message_init(&m);
spi_message_add_tail(&t, &m);
ret = spi_sync(spi, &m);
if (ret == 0)
*val = rx[1]; /* response is in second byte */
return ret;
}
static int bmi160_spi_write_reg(struct spi_device *spi, u8 reg, u8 val)
{
u8 tx[2] = { reg & 0x7F, val }; /* bit 7 clear = write */
return spi_write(spi, tx, 2);
}
Notice the key SPI mechanics:
- Read: Set bit 7 of the register address. Send 2 bytes, read the response from the second byte of
rx. - Write: Clear bit 7. Send register address followed by value.
spi_sync: Blocks until the SPI transfer completes. Safe in process context (likeprobe()), not in interrupt context.
Probe Function
static int bmi160_probe(struct spi_device *spi)
{
u8 chip_id;
int ret;
/* Verify chip ID */
ret = bmi160_spi_read_reg(spi, BMI160_REG_CHIP_ID, &chip_id);
if (ret || chip_id != 0xD1) {
dev_err(&spi->dev, "chip ID mismatch: got 0x%02x\n", chip_id);
return -ENODEV;
}
/* Soft reset */
bmi160_spi_write_reg(spi, BMI160_REG_CMD, 0xB6);
msleep(100);
/* Power on accelerometer and gyroscope */
bmi160_spi_write_reg(spi, BMI160_REG_CMD, 0x11); /* ACC normal mode */
msleep(50);
bmi160_spi_write_reg(spi, BMI160_REG_CMD, 0x15); /* GYR normal mode */
msleep(100);
/* Register sysfs attributes and character device... */
/* (details below) */
dev_info(&spi->dev, "BMI160 SPI driver probed (chip ID: 0x%02x)\n", chip_id);
return 0;
}
The probe sequence:
- Read CHIP_ID — verify the sensor is present and responding
- Soft reset — put the sensor in a known state
- Power on — accelerometer and gyroscope start in suspend mode after reset
- Register interfaces — create sysfs attributes and /dev/ node
Sysfs Attributes
static ssize_t accel_x_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
struct spi_device *spi = to_spi_device(dev);
u8 data[2];
s16 raw;
bmi160_spi_read_reg(spi, BMI160_REG_DATA_ACC_X_LSB, &data[0]);
bmi160_spi_read_reg(spi, BMI160_REG_DATA_ACC_X_MSB, &data[1]);
raw = (s16)((data[1] << 8) | data[0]);
return sprintf(buf, "%d\n", raw);
}
static DEVICE_ATTR_RO(accel_x);
This creates a sysfs file at /sys/class/bmi160/bmi160/accel_x. Reading this file triggers an SPI transfer to the sensor and returns the raw accelerometer value.
Similarly, accel_y, accel_z, gyro_x, gyro_y, gyro_z, and temperature attributes are defined.
Character Device
The driver also creates /dev/bmi160 for applications that need to read all axes at once:
static ssize_t bmi160_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
/* Read all 6 data registers (accel + gyro) in a burst */
/* Format as text and copy to user buffer */
}
static struct file_operations bmi160_fops = {
.owner = THIS_MODULE,
.open = bmi160_open,
.read = bmi160_read,
.release = bmi160_release,
};
Tip
Compare this to the MCP9808 driver: the structure is almost identical. The main differences are:
- Bus type:
spi_driverinstead ofi2c_driver - Read/write helpers: SPI requires bit 7 manipulation; I2C uses
i2c_smbus_read_byte_data() - Number of channels: BMI160 has 7 data channels (3 accel + 3 gyro + temperature) vs. MCP9808's single temperature channel
7. Makefile
Create the out-of-tree kernel module Makefile:
obj-m += bmi160_spi.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
obj-m— tells the build system to compilebmi160_spi.cas a loadable module (.ko)KDIR— points to the kernel headers for your running kernelM=$(PWD)— tells the kernel build system where your source code is
Tip
If you get an error about missing kernel headers, install them:
8. Device Tree Overlay
Create the overlay file bmi160-spi.dts:
/dts-v1/;
/plugin/;
/ {
compatible = "brcm,bcm2835";
fragment@0 {
target = <&spi0>;
__overlay__ {
status = "okay";
#address-cells = <1>;
#size-cells = <0>;
bmi160@0 {
compatible = "bosch,bmi160";
reg = <0>; /* CE0 (chip select 0) */
spi-max-frequency = <1000000>; /* 1 MHz */
};
};
};
};
Each field explained:
| Field | Value | Purpose |
|---|---|---|
target = <&spi0> |
SPI bus 0 | Apply overlay to the first SPI controller |
compatible = "bosch,bmi160" |
Driver match string | Must match the driver's of_match_table |
reg = <0> |
Chip select 0 | Uses CE0 (GPIO8) — use <1> for CE1 |
spi-max-frequency |
1000000 | Maximum SPI clock speed (1 MHz, conservative) |
Compile the overlay:
Copy to the overlays directory:
Enable it in config.txt:
Add at the end:
Checkpoint
The bmi160-spi.dtbo file should compile without errors from dtc. After copying and adding to config.txt, the overlay is ready to be loaded at next boot.
Stuck?
If dtc reports errors:
- Ensure
dtcis installed:sudo apt install device-tree-compiler - Check for missing semicolons or braces in the
.dtsfile - The
-@flag enables symbol resolution for overlays — do not omit it
9. Build, Load, Test
Build the module:
Verify the module info:
Reboot to apply the device tree overlay:
After reboot, load the module:
Check the kernel log:
Expected output:
Checkpoint
Three things confirm the module compiled and loaded:
makecompleted without errors andbmi160_spi.koexistsinsmodsucceeded (no error output)dmesgshows the probe message with chip ID0xd1
Stuck?
If insmod fails with No such device:
- The device tree overlay may not be loaded. Check
dmesg | grep overlay - Verify wiring — the driver reads CHIP_ID during probe and fails if it does not get
0xD1 - Try a manual SPI test first:
python3 -c "import spidev; s=spidev.SpiDev(); s.open(0,0); print(s.xfer2([0x80,0x00]))"should return[0, 209](209 = 0xD1)
10. Sysfs and Device Node
Verify the sysfs attributes:
Expected output:
Read accelerometer data:
cat /sys/class/bmi160/bmi160/accel_x
cat /sys/class/bmi160/bmi160/accel_y
cat /sys/class/bmi160/bmi160/accel_z
Check the character device:
If permissions are restricted (as expected for a new device):
Create a udev rule to make it accessible to users:
echo 'KERNEL=="bmi160", MODE="0666"' | sudo tee /etc/udev/rules.d/99-bmi160.rules
sudo udevadm control --reload
sudo udevadm trigger
Verify permissions changed:
Expected:
Now read from the character device:
Checkpoint
You should be able to read sensor data from both interfaces:
- Sysfs:
cat /sys/class/bmi160/bmi160/accel_xreturns a number - Character device:
cat /dev/bmi160returns all axes
11. Verify Data
Test that the sensor data is correct by tilting the sensor:
# Read accelerometer with the board flat
cat /sys/class/bmi160/bmi160/accel_x
cat /sys/class/bmi160/bmi160/accel_y
cat /sys/class/bmi160/bmi160/accel_z
With the sensor flat on a table, you should see:
accel_xandaccel_ynear 0 (no tilt)accel_znear 16384 (1g at default +/-2g range: 16384 LSB/g)
Tilt the sensor and re-read. The values should change:
# Tilt 90 degrees on X axis
cat /sys/class/bmi160/bmi160/accel_x # Should now read ~16384
cat /sys/class/bmi160/bmi160/accel_z # Should now read ~0
Read the gyroscope (should be near zero when stationary):
cat /sys/class/bmi160/bmi160/gyro_x
cat /sys/class/bmi160/bmi160/gyro_y
cat /sys/class/bmi160/bmi160/gyro_z
Read temperature:
Checkpoint
Data reads correctly if:
- Accelerometer Z reads approximately 16384 when flat (gravity)
- Values change when you tilt the sensor
- Gyroscope reads near zero when stationary
- Temperature reads a reasonable room temperature value
Stuck?
If all values read as 0 or -1:
- Check wiring, especially MISO (data from sensor to Pi)
- Verify the sensor is powered (measure 3.3V at VCC)
- Check
dmesgfor SPI transfer errors
If values seem wrong but non-zero, check the data format. The BMI160 returns 16-bit signed integers in little-endian order.
What Just Happened?
You implemented the complete SPI driver development workflow — the same workflow you used for the MCP9808 I2C driver, but adapted for a different bus:
| Step | MCP9808 (I2C) | BMI160 (SPI) |
|---|---|---|
| Bus helpers | i2c_smbus_read_byte_data() |
spi_sync() with bit 7 read flag |
| Device Tree target | &i2c1 |
&spi0 |
| Device addressing | reg = <0x18> (I2C address) |
reg = <0> (chip select number) |
| Speed config | Set by I2C bus | spi-max-frequency per device |
| Driver struct | struct i2c_driver |
struct spi_driver |
| Data channels | 1 (temperature) | 7 (3 accel + 3 gyro + temp) |
The fundamental pattern is the same: Device Tree describes the hardware, the driver provides the software interface, and the kernel binds them together at boot (or module load) time.
Custom Driver vs Mainline IIO
The driver you just built is a custom character device — you defined the sysfs attributes, the /dev/bmi160 interface, and the data format yourself. But the Linux kernel already ships a mainline BMI160 IIO driver (drivers/iio/imu/bmi160/) that exposes the same sensor through the standard IIO subsystem. When does each approach make sense?
When to Use the Mainline IIO Driver
- Standard raw data — you need accelerometer, gyroscope, and temperature readings, and IIO gives this out of the box
- Tool ecosystem —
iio_readdev,iio_info,libiio, and desktop screen rotation all work immediately - Buffered mode + DMA — high-rate capture without writing buffer management code (see IIO Buffered Capture)
- Portability — swap BMI160 for BMI270 or LSM6DSO by changing only the
compatiblestring in the Device Tree - Long-term maintenance — the upstream kernel community handles API changes, bug fixes, and power management
When a Custom Driver Makes Sense
- Specific ABI for one product — you ship one device + one application, and portability is not a goal
- Fused outputs in-kernel — you want gravity vector, orientation quaternion, step counting, or gesture events ("shake", "tap", "flip") as a single stream, not raw axes. (Caveat: many teams prefer fusion in userspace for easier iteration)
- Strict real-time behavior — hard latency/jitter bounds, deterministic buffering, prioritized IRQ handling, lock-free ring buffers, controlled scheduling
- Advanced FIFO/batching — custom packet framing, variable-rate batching, watermark IRQs, loss reporting, multi-sensor sync beyond IIO triggers
- Proprietary pipeline integration — custom calibration store, manufacturing test mode, secure sensor data path, device attestation
The Trade-off at a Glance
| Aspect | Custom chardev (/dev/bmi160) |
Mainline IIO (/sys/bus/iio/) |
|---|---|---|
| App-facing API | Exactly what you design | Generic channels + scale |
| Buffering | You implement it | kfifo + triggers + DMA free |
| Timestamps | You add them | Automatic iio_get_time_ns() |
| Tool support | Custom scripts only | iio_readdev, libiio, desktop rotation |
| Maintenance | You maintain across kernel versions | Upstream maintains |
| Sensor swap | Rewrite driver | Change compatible string |
| Custom features | Full control | Limited to IIO model |
| Lines of code | ~530 (this tutorial) | 0 (mainline) |
Best of Both Worlds
Even when building a custom chardev, you can adopt IIO conventions: expose _raw + _scale attributes, provide sampling_frequency, support buffered streaming with timestamps, and report FIFO watermark/overflow counters. Your application stays simple, and custom features remain available.
For the Level Display App
- If you only need raw tilt at 100–200 Hz: the mainline IIO driver is the cleanest path. See the IIO Buffered Capture tutorial.
- If you want pre-filtered gravity vector, "shake"/"tap" events, or specific timing guarantees: the custom chardev you built here makes sense.
For the full IIO architecture reference: IIO Subsystem.
Challenges
Challenge 1: Configurable ODR via sysfs
Add a sysfs attribute called odr (Output Data Rate) that allows the user to change the accelerometer sampling rate. Write to ACC_CONF register (0x40) to set the ODR bits. Support at least 25 Hz, 50 Hz, 100 Hz, and 200 Hz. Verify with: echo 100 > /sys/class/bmi160/bmi160/odr && cat /sys/class/bmi160/bmi160/odr.
Challenge 2: FIFO Burst Read
The BMI160 has a 1024-byte FIFO buffer (registers 0x24--0x26 for config, 0x46--0x47 for data). Implement a burst read mode that reads all buffered samples in a single SPI transaction. This is more efficient than polling individual registers. Measure the throughput improvement (samples per second) compared to single-register reads.
Challenge 3: Continuous Logging Script
Write a Python script that reads all 6 axes from /dev/bmi160 at 50 Hz and logs the values to a CSV file. Plot the data with matplotlib after recording 10 seconds of movement. This will be useful for system identification in later tutorials.
Deliverable
Submit the following:
| Item | Description |
|---|---|
| Compiled module | bmi160_spi.ko that loads without errors |
| Device tree overlay | bmi160-spi.dts and compiled bmi160-spi.dtbo |
| Sensor data screenshot | Terminal output of sysfs reads showing valid accel/gyro/temperature values |
| Character device test | Output of cat /dev/bmi160 showing all axes |
| Comparison with MCP9808 | Brief notes on what changed between the I2C and SPI driver workflows |