1.0 SPI 简介

[软件模拟 SPI 库]

JRNitre/softwareSPI
1 更新于2025-04-25 09:04:53

SPI (Serial Peripheral Interface - 串行外设接口) 是 Motorola 开发的一种由四根通信线构成的一主多从通用数据总线。

[SPI 总线具有如下特性]

  • 同步&全双工的通讯模式
  • 支持总线挂载多设备

2.0 SPI 物理层

SPI 总线由四根线构成:

  • SCK(Serial Clock):时钟,用于提供时钟时序,数据的收发在时钟信号的上升沿或下降沿实现
  • MOSI(Master Output Slave Input):主机输出从机输入
  • MISO(Master Input Slave Output):主机输入从机输出
  • SS(Slave Select) :片选
值得注意的是,这里的名称并非标准,对于不同引脚名称的描述可能不同。

[一主多从]

SPI 通讯仅支持一主机多从机的通讯方式,不支持多主机多从机的通讯方式。

[SPI 片选]

  • 主机的数据输出连接到从机的数据输入
  • 从机的数据输出连接到主机的数据输入
  • 从机的片选控制引脚连接到主机负责控制片选的引脚上

[通讯引脚 IO 配置]

SPI 的输出引脚均配置为推挽输出;输入引脚配置为浮空或者上拉输入

3.0 SPI 协议层

3.1 数据传输原理

SPI 的主机与从机中,均包含一个串行移位寄存器,该位移寄存器每来一个时钟信号移位寄存器会向左移动一位。

主机和从机中的移位寄存器的时钟由主机提供,同时该时钟也通过 SCK 引脚进行输出。

而主机移位寄存器移出的数据通过 MOSI 引脚传输至从机,而从机移位寄存器移出的数据通过 MISO 引脚传输至主机。

通过如上过程,仅需要主机时钟驱动即可实现数据在两个通信单位之间传输。

因此,SPI 通讯的核心思想是通过时钟驱动的位移寄存器将数据在主机和从机之间进行置换。

3.2 SS 片选引脚

SPI 通过单独的片选引脚选择不同的从机设备,需要与谁通信就将对应 SS 引脚拉低电平状态即可。

同一时间仅能选择一个从机,如果多从机被同时选择可能会产生数据冲突问题。

3.3 SPI 时序基本单元

3.3.1 起始与终止信号

  • 起始信号:SS(片选) 从高电平切换到低电平
  • 结束信号:SS(片选) 从低电平切换到高电平

因此在数据传输的过程总,通讯单元之间的 SS 引脚需要始终保持低电平。

3.3.2 数据位的传输

SPI 通讯为了兼容不同芯片等之间的通讯,其对于时钟驱动的数据读写方式并未做规定,因此针对不同的模式 SPI 有 4 中不同的工作模式。

3.3.3 交换一个字节 (模式 0)

  • CPOL = 0:空闲状态时,SCK 为低电平
  • CPHA = 0:SCK 第一个边沿移入数据,第二个边沿移出数据

值得注意的是在这种模式下,在 SCK 的第一个时钟边沿到来前通讯双方就要移除数据,也就是说在 SS 产生下降沿的时候就要将数据移出。

在实际使用过程中,模式 0 最为常用,后续基于代码实现中以模式 0 实现。

3.3.4 交换一个字节 (模式 1)

  • CPOL = 0:空闲状态时,SCK 为低电平
  • CPHA = 1:SCK 第一个边沿移出数据,第二个边沿移入数据

3.3.5 交换一个字节 (模式 2&3)

模式 2 和模式 3 仅是相对于模式 0 和模式 1 将其 SCK 信号取反,其它并无差别。

4.0 SPI 代码实现

4.1 起始信号与结束信号

SPI 的实现方式对比 I2C 简单粗暴,所谓开始&结束信号只需要拉低&高 SS 片选引脚即可:

void spiStartSignal(void) {
    spIO_SS(0);
}

void spiStopSignal(void) {
    spIO_SS(1);
}

值得注意的是,如果是以模式 0 和模式 2 工作的话,在起始信号发出后 MOSI 就要马上将需要发送的数据搬到 MOSI 引脚上;因此这里调用 MOSI 的函数中是没有用于控制通讯速率的延迟的:

void spIO_SS(uint8_t bitValue) {    // SPI SS 片选信号
    if(bitValue != 0) {
        global_spiConfigure.IO_SS_H();
    }else{
        global_spiConfigure.IO_SS_L();
    }
}

4.2 交换一个字节数据

根据上述对 SPI 的描述,实现交换字节的逻辑也比较简单:

uint8_t spiSwapByte(uint8_t byte) {
    uint8_t recByte = 0x00;

    // 循环发送&读取数据
    for(int i = 0; i < 8; i++) {
        spIO_MOSI(byte & (0x80 >> i));
        // SCK 上升沿移入(读取)数据
        spIO_SCK(1);
        if(spIO_GetBitValue() == 1) {
            recByte |= (0x80 >> i);
        }
        // SCK 下降沿移出(写入)数据
        spIO_SCK(0);
    }
    return recByte;
}
  • 此处根据模式 0 来看,由于前边 SS 引脚已经发送了一个起始信号,因此这里 MOSI 需要马上发送第一位数据;byte & (0x80 >> i) 这里通过掩码的方式逐次取出指定的数据位用于发送。
  • 驱动 SCK 来到第一个边沿
  • 从 MISO 引脚读取一个字节数据并存储到缓存 (recByte) 中
  • 驱动 SCK 来到第二个边沿开始发送数据,一个循环结束。

[代码优化]

针对上述代码,如果我们根据 SPI 硬件层面实现原理来看可以优化代码如下:

uint8_t spiSwapByte(uint8_t byte) {
    // 循环发送&读取数据
    for(int i = 0; i < 8; i++) {
        // 发送最高位
        spIO_MOSI(byte & 0x80);
        // SCK 上升沿移入(读取)数据
        spIO_SCK(1);
        byte <<= 1;
        if(spIO_GetBitValue() == 1) {
            // 将接收到的数据存至 byte 的最低位
            byte |= 0x01;
        }
        // SCK 下降沿移出(写入)数据
        spIO_SCK(0);
    }
    return byte;
}

这其中与之前不同的是每次发送的都是 byte 的最高位,与 SPI 通讯主机中的位移寄存器工作原理相同,数据位被发送出去后,将 byte 整体左移一位,低位空出来的位置正好可以用于存储从机传来的数据。

因此在数据读取边沿将 byte 或等于 0x01 这样就可以将接收到的数据存储在 byte 的最低为,就像位移寄存器一样。

这样编程可以节省一个缓存变量,节约空间。

此外,不同的模式仅有奇数边沿读取还是偶数边沿读,以及时钟的正反相的区别,因此针对模式 0 的发送函数,对代码语句的简单排序即可得到其它模式下的发送函数。
// Mode 1 模式
uint8_t spiSwapByte_mode1(uint8_t byte) {
    uint8_t recByte = 0x00;

    // 循环发送&读取数据
    for(int i = 0; i < 8; i++) {
        spIO_SCK(1);
        spIO_MOSI(byte & (0x80 >> i));
        spIO_SCK(0);
        if(spIO_GetBitValue() == 1) {
            recByte |= (0x80 >> i);
        }
    }
    return recByte;
}

END 参考与声明

[参考]