什么是 spi

和上一篇文章的 i2c 总线一样,spi(serial peripheral interface,串行外设接口)也是设备与设备间通信方式的一种。spi 是一种全双工(数据可以两个方向同时传输)的串行通信总线,由摩托罗拉于上个世纪 80 年代开发[1],用于短距离设备之间的通信。spi 包含 4 根信号线,一根时钟线 sck(serial clock,串行时钟),两根数据线 mosi(master output slave input,主机输出从机输入)和 miso(master input slave output,主机输入从机输出),以及一根片选信号 cs(chip select,或者叫 ss,slave select)。所谓的时钟线就是一种周期,两台设备数据传输不能各发各的,这样就没有意义,因此需要一种周期去对通信进行约束;数据线就是按照 mosi 和 miso 的中文翻译理解即可;片选信号用于主设备选择 spi 上的从设备,i2c 是靠地址选择设备,而 spi 靠的是片选信号,一般来说要选择哪个从设备只要将相应的 cs 线设置为低电平即可,特殊情况需要看数据手册。下图展示了一个 spi 主设备和三个 spi 从设备的示意图。

图源:wikipedia

spi 还有一个重要的概念就是时钟的极性(cpol,clock polarity)和相位(cpha,clock phase),对其这里不过多解释,我们只需要知道极性和相位的组合构成了 spi 的传输模式(spi mode)。在数据手册中,只要是 spi 通信协议的,一定会给出传输模式,我们根据数据手册进行设置即可。spi 的传输模式是有固定编号的,下表给出了各个模式,常用的模式有 mode0 和 mode3。

spi mode cpol cpha
mode0 0 0
mode1 0 1
mode2 1 0
mode3 1 1

该时序图显示了时钟的极性和相位。图源:wikipedia

spi 相比较 i2c 最大的优点就是传输速率高,并且数据在同一时间内可以双向传输,这都得益于它的两根输入和输出数据线。当然缺点也很明显,比 i2c 多了两根线,这就要多占用两个 io 接口。而且 spi 采用 cs 线去选择设备,不像 i2c 有寻址机制,如果你有很多个 spi 设备需要连接的话 io 接口的占用数量是相当高的。

在 raspberry pi 的引脚中,引出了两组 spi 接口。但有意思的是,在 raspbian 中 spi-1 是被禁用的,你需要修改一些参数去启用 spi-1。spi 接口的引脚编号如下图所示。

  提示

如何在 raspbian 上开启 spi-1?(在 win10 iot 上 spi-1 是开启的)

1. 使用编辑器打开 /boot/config.txt ,如:
sudo nano /boot/config.txt 2. 添加
dtoverlay=spi1-3cs 并保存 3. 重启

raspberry pi b+/2b/3b/3b+/zero 引脚图

相关类

spi 操作的相关类位于 system.device.spisystem.device.spi.drivers 命名空间下。

spiconnectionsettings

spiconnectionsettings 类位于 system.device.spi 命名空间下,表示 spi 设备的连接设置。

public sealed class spiconnectionsettings
{
    // 构造函数
    // busid 是 spi 的内部 id
    // chipselectline 是 cs pin 的编号(在 raspberry pi 上,spi-0 对应 0 和 1,spi-1 对应 2)
    public spiconnectionsettings(int busid, int chipselectline);

    // 属性
    // spi 传输模式
    public spimode mode { get; set; }
    // spi 时钟频率
    public int clockfrequency { get; set; }
    // cs 线激活状态(即高电平选中设备还是低电平选中设备)
    public pinvalue chipselectlineactivestate { get; set; }
}

unixspidevice 和 windows10spidevice

unixspidevicewindows10spidevice 类位于 system.device.spi.drivers 命名空间下。两个类均派生自抽象类 spidevice,分别代表 unix 和 windows10 下的 spi 控制器,使用时按照所处的平台有选择的进行实例化。这里以 unixspidevice 类为例说明。

public class unixspidevice : spidevice
{
    // 构造函数
    // 需要传入一个 spiconnectionsettings 对象
    public unixspidevice(spiconnectionsettings settings);

    // 方法
    // 从从设备中读取一段数据,数据长度由 span 的长度决定
    public override void read(span<byte> buffer);
    // 从从设备中读取一个字节的数据
    public override byte readbyte();

    // 全双工传输,即主从设备同时传输
    // writebuffer 为要写入从设备的数据
    // readbuffer 为要从从设备中读取的数据
    // 需要注意的是 writebuffer 和 readbuffer 需要长度一致
    public override void transferfullduplex(readonlyspan<byte> writebuffer, span<byte> readbuffer);
    
    // 向从设备中写入一段数据,通常 span 中的第一个数据为要写入数据的寄存器的地址
    public override void write(readonlyspan<byte> buffer);
    // 向从设备中写入一个字节的数据,通常这个字节为寄存器的地址
    public override void writebyte(byte value);
}

spi 的通信步骤

  1. 初始化 spi 连接设置 spiconnectionsettings

    一般情况下,我们只需要配置 spi 的 id,cs 的编号,时钟频率和 spi 传输模式。其中像时钟频率、传输模式等设置都来自于设备的数据手册。比如要使用 raspberry pi 的 spi-0 去操作一个时钟频率为 5 mhz,spi 传输模式为 mode3 的设备,代码如下:

    spiconnectionsettings settings = new spiconnectionsettings(busid: 0, chipselectline: 0)
    {
        clockfrequency = 5000000,
        mode = spimode.mode3
    };
  2. 读取和写入

    读取和写入与 i2c 类似,这里不再过多赘述,详见上一篇博客,这里只提供一个代码示例。唯一要说明的就是使用全双工通信 transferfullduplex() 时,要求写入的数据和读取的数据长度要一致,并且能否使用也需要看设备是否支持。比如从地址为 0x00 的寄存器中向后连续读取 8 个字节的数据,并且向地址为 0x01 的寄存器写入一个字节的数据,代码如下:

    // 读取
    sensor.writebyte(0x00);
    span<byte> readbuffer = stackalloc byte[8]; 
    sensor.read(readbuffer);
    
    // 写入
    span<byte> writebuffer = stackalloc byte[] { 0x01, 0xff }; 
    sensor.write(writebuffer);
    
    // 全双工读取
    span<byte> writebuffer = stackalloc byte[8]; 
    span<byte> readbuffer = stackalloc byte[8];
    writebuffer[0] = 0x00;
    sensor.transferfullduplex(writebuffer, readbuffer);

加速度传感器读取实验

本实验选用的是三轴加速度传感器 adxl345 ,数据手册地址: 。

传感器图像

硬件需求

名称 数量
adxl345 x1
杜邦线 若干

电路

  • vcc – 3.3 v
  • gnd – gnd
  • cs – cs0 (pin24)
  • sdo – spi0 miso (pin21)
  • sda – spi0 mosi (pin19)
  • scl – spi0 sclk (pin23)

代码

  1. 打开 visual studio ,新建一个 .net core 控制台应用程序,项目名称为“adxl345”。
  2. 引入 system.device.gpio nuget 包。
  3. 新建类 adxl345,替换如下代码:

    public class adxl345 : idisposable
    {
        #region 寄存器地址
        private const byte adlx_power_ctl = 0x2d;      // 电源控制地址
        private const byte adlx_data_format = 0x31;     // 范围地址
        private const byte adlx_x0 = 0x32;              // x轴数据地址
        private const byte adlx_y0 = 0x34;              // y轴数据地址
        private const byte adlx_z0 = 0x36;              // z轴数据地址
        #endregion
    
        private spidevice _sensor = null;
    
        private readonly int _range = 16;               // 测量范围(-8,8)
        private const int resolution = 1024;            // 分辨率
    
        #region spisetting
        /// <summary>
        /// adx1345 spi 时钟频率
        /// </summary>
        public const int spiclockfrequency = 5000000;
    
        /// <summary>
        /// adx1345 spi 传输模式
        /// </summary>
        public const spimode spimode = system.device.spi.spimode.mode3;
        #endregion
    
        /// <summary>
        /// 加速度
        /// </summary>
        public vector3 acceleration => readacceleration();
    
        /// <summary>
        /// 实例化一个 adx1345
        /// </summary>
        /// <param name="sensor">spidevice</param>
        public adxl345(spidevice sensor)
        {
            _sensor = sensor;
    
            // 设置 adxl345 测量范围
            // 数据手册 p28,表 21
            span<byte> dataformat = stackalloc byte[] { adlx_data_format, 0b_0000_0010 };
            // 设置 adxl345 为测量模式
            // 数据手册 p24
            span<byte> powercontrol = stackalloc byte[] { adlx_power_ctl, 0b_0000_1000 };
    
            _sensor.write(dataformat);
            _sensor.write(powercontrol);
        }
    
        /// <summary>
        /// 读取加速度
        /// </summary>
        /// <returns>加速度</returns>
        private vector3 readacceleration()
        {
            int units = resolution / _range;
    
            // 7 = 1个地址 + 3轴数据(每轴数据2字节)
            span<byte> writebuffer = stackalloc byte[7];
            span<byte> readbuffer = stackalloc byte[7];
    
            writebuffer[0] = adlx_x0;
            _sensor.transferfullduplex(writebuffer, readbuffer);
            span<byte> readdata = readbuffer.slice(1);      // 切割空白数据
    
            // 将小端数据转换成正常的数据
            short accelerationx = binaryprimitives.readint16littleendian(readdata.slice(0, 2));
            short accelerationy = binaryprimitives.readint16littleendian(readdata.slice(2, 2));
            short accelerationz = binaryprimitives.readint16littleendian(readdata.slice(4, 2));
    
            vector3 accel = new vector3
            {
                x = (float)accelerationx / units,
                y = (float)accelerationy / units,
                z = (float)accelerationz / units
            };
    
            return accel;
        }
    
        /// <summary>
        /// 释放资源
        /// </summary>
        public void dispose()
        {
            _sensor?.dispose();
            _sensor = null;
        }
    }
  4. program.cs 中,将主函数代码替换如下:

    static void main(string[] args)
    {
        spiconnectionsettings settings = new spiconnectionsettings(busid: 0, chipselectline: 0)
        {
            clockfrequency = adxl345.spiclockfrequency,
            mode = adxl345.spimode
        };
        unixspidevice device = new unixspidevice(settings);
    
        using (adxl345 sensor = new adxl345(device))
        {
            while (true)
            {
                vector3 data = sensor.acceleration;
    
                console.writeline($"x: {data.x.tostring("0.00")} g");
                console.writeline($"y: {data.y.tostring("0.00")} g");
                console.writeline($"z: {data.z.tostring("0.00")} g");
                console.writeline();
    
                thread.sleep(500);
            }
        }
    }
  5. 发布、拷贝、更改权限、运行

效果图

  备注

下一篇文章将谈谈 pwm 的使用。