4、SPI 总线与传感器通信:SPI 协议基础、Zephyr SPI API、使用 SPI 读取传感器数据(如 LSM6DSO)

说到传感器通信,I2C 和 SPI 是两大主力。我个人其实更喜欢 SPI,为什么?因为它快,而且全双工,没有地址帧的拖沓。你想想看,一个加速度计要输出 6 轴数据,用 I2C 得一个个寄存器读,用 SPI 呢?一次 burst 传输全搞定。

这一章我们就拿 LSM6DSO 这个六轴惯性传感器开刀。它支持 SPI 和 I2C,但咱们今天只聊 SPI。我会带你从协议基础走到 Zephyr 的 API,最后手撸一个读取加速度和角速度的 demo。

4.1 SPI 协议基础:四根线搞定一切

SPI 全称 Serial Peripheral Interface,说白了就是主从设备之间用四根线通信。我刚开始学的时候总觉得它比 I2C 复杂,其实不然——它只是线多,逻辑反而简单。

  • SCLK:时钟线,由主机产生。频率决定了通信速度。
  • MOSI:主机输出,从机输入。数据从主机流向从机。
  • MISO:主机输入,从机输出。数据从从机流向主机。
  • CS:片选线,低电平有效。你要跟哪个设备说话,就把它的 CS 拉低。

这里有个关键点:SPI 是全双工的。什么意思?就是主机发数据的同时,从机也在发数据。你发一个字节,从机同时回一个字节。所以读操作其实是个「假读」——你得先发一个 dummy 字节,才能把从机的数据换回来。

避坑指南:我曾经在调试 LSM6DSO 时,读寄存器一直返回 0xFF。查了半天,发现是 SPI 模式没配对。LSM6DSO 要求 SPI 模式 0(CPOL=0, CPHA=0),也就是时钟空闲为低,数据在第一个边沿采样。如果你配成了模式 3,它根本不搭理你。

SPI 有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。对于 LSM6DSO,你只需要记住模式 0 就行。

模式 CPOL CPHA 空闲时钟 采样边沿
0 0 0 低电平 上升沿
1 0 1 低电平 下降沿
2 1 0 高电平 下降沿
3 1 1 高电平 上升沿

4.2 Zephyr SPI API:从设备树到代码

在 Zephyr 里用 SPI,第一步不是写代码,而是配设备树。我习惯先把硬件连接搞清楚,再动手。LSM6DSO 的 SPI 接口一般接在 SPI1 或 SPI2 上,CS 引脚可以任意指定。

设备树里这样配:

/ {
    lsm6dso_spi: &spi1 {
        status = "okay";
        cs-gpios = &gpioa 4 GPIO_ACTIVE_LOW;

        lsm6dso: lsm6dso@0 {
            compatible = "st,lsm6dso";
            reg = <0>;
            spi-max-frequency = <10000000>;
            label = "LSM6DSO";
        };
    };
};

注意 reg = <0> 表示这是 SPI 总线上的第 0 个设备,对应 CS 片选。如果你接了多个传感器,reg 值要递增。

代码里怎么用?Zephyr 提供了 spi_transceive() 这个核心 API。它的原型长这样:

int spi_transceive(const struct device *dev,
                   const struct spi_config *config,
                   const struct spi_buf_set *tx_bufs,
                   const struct spi_buf_set *rx_bufs);

嗯,参数有点多。但说白了就是:你给我一个 SPI 控制器、配置、发送缓冲和接收缓冲,它帮你完成一次全双工传输。

我的习惯:我会把 SPI 读写封装成两个小函数。读的时候发一个 dummy 字节,写的时候直接发数据。这样上层代码看起来清爽很多。

4.3 实战:读取 LSM6DSO 的 WHO_AM_I 寄存器

拿到一个新传感器,我第一件事就是读它的 WHO_AM_I 寄存器。LSM6DSO 的这个寄存器地址是 0x0F,读出来应该是 0x6C。如果对不上,说明通信有问题。

SPI 读操作有个规矩:第一个字节是地址,最高位要置 1 表示读。所以读 0x0F 时,你实际要发 0x8F。

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/spi.h>

#define LSM6DSO_WHO_AM_I     0x0F
#define LSM6DSO_WHO_AM_I_VAL 0x6C

static const struct device *spi_dev;
static struct spi_config spi_cfg;

static int lsm6dso_read_reg(uint8_t reg, uint8_t *val)
{
    uint8_t tx_buf[2] = {reg | 0x80, 0x00};
    uint8_t rx_buf[2] = {0x00, 0x00};

    const struct spi_buf tx_bufs = {
        .buf = tx_buf,
        .len = 2
    };
    const struct spi_buf rx_bufs = {
        .buf = rx_buf,
        .len = 2
    };
    const struct spi_buf_set tx = {
        .buffers = &tx_bufs,
        .count = 1
    };
    const struct spi_buf_set rx = {
        .buffers = &rx_bufs,
        .count = 1
    };

    int ret = spi_transceive(spi_dev, &spi_cfg, &tx, &rx);
    if (ret != 0) {
        return ret;
    }

    *val = rx_buf[1];
    return 0;
}

void main(void)
{
    spi_dev = DEVICE_DT_GET(DT_NODELABEL(lsm6dso));
    if (!device_is_ready(spi_dev)) {
        printk("SPI device not ready\n");
        return;
    }

    spi_cfg.frequency = 10000000;
    spi_cfg.operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8);
    spi_cfg.slave = 0;

    uint8_t who_am_i;
    if (lsm6dso_read_reg(LSM6DSO_WHO_AM_I, &who_am_i) == 0) {
        printk("WHO_AM_I = 0x%02X\n", who_am_i);
        if (who_am_i == LSM6DSO_WHO_AM_I_VAL) {
            printk("LSM6DSO detected!\n");
        } else {
            printk("Unexpected ID\n");
        }
    } else {
        printk("SPI read failed\n");
    }
}

这段代码跑通了,恭喜你,SPI 通信已经打通了。接下来就可以放心大胆地读加速度和角速度了。

4.4 批量读取:加速度和角速度数据

LSM6DSO 的加速度数据在 0x28~0x2D 寄存器,角速度在 0x22~0x27。每个轴占 2 个字节,所以读一次加速度需要 6 个字节。

SPI 支持 burst 传输——你只要把地址发一次,然后连续发 dummy 字节,从机就会连续吐出数据。这比 I2C 爽多了。

static int lsm6dso_read_accel(int16_t *x, int16_t *y, int16_t *z)
{
    uint8_t tx_buf[7] = {0x28 | 0x80, 0, 0, 0, 0, 0, 0};
    uint8_t rx_buf[7] = {0};

    const struct spi_buf tx_bufs = { .buf = tx_buf, .len = 7 };
    const struct spi_buf rx_bufs = { .buf = rx_buf, .len = 7 };
    const struct spi_buf_set tx = { .buffers = &tx_bufs, .count = 1 };
    const struct spi_buf_set rx = { .buffers = &rx_bufs, .count = 1 };

    int ret = spi_transceive(spi_dev, &spi_cfg, &tx, &rx);
    if (ret != 0) return ret;

    *x = (int16_t)(rx_buf[1] | (rx_buf[2] << 8));
    *y = (int16_t)(rx_buf[3] | (rx_buf[4] << 8));
    *z = (int16_t)(rx_buf[5] | (rx_buf[6] << 8));

    return 0;
}

注意:LSM6DSO 的数据是小端格式,低字节在前。如果你用大端 MCU,记得交换字节序。我当年在 STM32 上踩过这个坑,读出来的加速度值忽大忽小,后来发现是字节序搞反了。

4.5 性能考量:SPI 时钟与数据速率

SPI 能跑多快?LSM6DSO 最高支持 10 MHz。但实际能跑多少,还得看你的板子布线。我建议从 1 MHz 开始试,稳定了再往上提。

数据输出速率(ODR)是另一个关键参数。LSM6DSO 的加速度最高支持 6.66 kHz,角速度最高 6.66 kHz。但 SPI 读取频率必须高于 ODR,否则数据会丢。

举个例子:如果你设 ODR 为 1 kHz,那么每 1 ms 就要读一次数据。SPI 传输 6 个字节在 10 MHz 下只需要 4.8 µs,绰绰有余。但如果你用低端 MCU 跑 RTOS,任务调度延迟可能成为瓶颈。

我的建议:把 SPI 读取放在高优先级线程里,或者用 DMA 方式。Zephyr 支持 SPI DMA,配置起来也不复杂。数据来了直接进内存,CPU 只管处理,效率高很多。

好了,SPI 这块就聊到这儿。下一章我们讲 I2C,到时候你会看到两种总线的差异有多明显。但说实话,只要 SPI 玩熟了,I2C 就是小菜一碟。