Skip to content

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:

cd ~
git clone https://github.com/OE-KVK-H2IoT/embedded-linux.git

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 scriptspidev is 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.

Read CHIP_ID:   TX: [0x80, 0x00]  →  RX: [xx, 0xD1]
Write CMD:      TX: [0x7E, 0xB6]  →  (soft reset command)
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

sudo raspi-config

Navigate to:

Interface Options → SPI → Yes → Finish

Option B: Edit config.txt directly

sudo vim /boot/firmware/config.txt

Add or uncomment:

dtparam=spi=on

Reboot:

sudo reboot

After reboot, verify SPI devices exist:

ls /dev/spidev*

Expected output:

/dev/spidev0.0  /dev/spidev0.1
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=on is in /boot/firmware/config.txt
  • Run lsmod | grep spi — you should see spi_bcm2835
  • Check dmesg | grep -i spi for 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 (like probe()), 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:

  1. Read CHIP_ID — verify the sensor is present and responding
  2. Soft reset — put the sensor in a known state
  3. Power on — accelerometer and gyroscope start in suspend mode after reset
  4. 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_driver instead of i2c_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 compile bmi160_spi.c as a loadable module (.ko)
  • KDIR — points to the kernel headers for your running kernel
  • M=$(PWD) — tells the kernel build system where your source code is
Tip

If you get an error about missing kernel headers, install them:

sudo apt install linux-headers-$(uname -r)


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:

dtc -@ -I dts -O dtb -o bmi160-spi.dtbo bmi160-spi.dts

Copy to the overlays directory:

sudo cp bmi160-spi.dtbo /boot/firmware/overlays/

Enable it in config.txt:

sudo vim /boot/firmware/config.txt

Add at the end:

dtoverlay=bmi160-spi
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 dtc is installed: sudo apt install device-tree-compiler
  • Check for missing semicolons or braces in the .dts file
  • The -@ flag enables symbol resolution for overlays — do not omit it

9. Build, Load, Test

Build the module:

make

Verify the module info:

modinfo bmi160_spi.ko

Reboot to apply the device tree overlay:

sudo reboot

After reboot, load the module:

sudo insmod bmi160_spi.ko

Check the kernel log:

dmesg | grep -i bmi160

Expected output:

[   XX.XXXXXX] bmi160_spi spi0.0: BMI160 SPI driver probed (chip ID: 0xd1)
Checkpoint

Three things confirm the module compiled and loaded:

  1. make completed without errors and bmi160_spi.ko exists
  2. insmod succeeded (no error output)
  3. dmesg shows the probe message with chip ID 0xd1
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:

ls /sys/class/bmi160/bmi160/

Expected output:

accel_x  accel_y  accel_z  gyro_x  gyro_y  gyro_z  temperature

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:

ls -la /dev/bmi160

If permissions are restricted (as expected for a new device):

crw------- 1 root root 237, 0 ... /dev/bmi160

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:

ls -la /dev/bmi160

Expected:

crw-rw-rw- 1 root root 237, 0 ... /dev/bmi160

Now read from the character device:

cat /dev/bmi160
Checkpoint

You should be able to read sensor data from both interfaces:

  1. Sysfs: cat /sys/class/bmi160/bmi160/accel_x returns a number
  2. Character device: cat /dev/bmi160 returns 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_x and accel_y near 0 (no tilt)
  • accel_z near 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:

cat /sys/class/bmi160/bmi160/temperature
Checkpoint

Data reads correctly if:

  1. Accelerometer Z reads approximately 16384 when flat (gravity)
  2. Values change when you tilt the sensor
  3. Gyroscope reads near zero when stationary
  4. 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 dmesg for 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 ecosystemiio_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 compatible string 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

Course Overview | Next: Level Display: Python →