在嵌入式裸机开发中,SPI(串行外设接口)是最常用的高速同步串行总线之一,广泛用于连接 Flash、加速度传感器、ADC、OLED 屏等外设。i.MX6ULL 作为 Cortex-A7 内核的工业级 MPU,内置了 4 路增强型可配置 SPI 外设(ECSPI),支持主机 / 从机模式、最高 60MHz 总线时钟能满足绝大多数 SPI 外设的驱动需求。
本文基于正点原子 i.MX6ULL-MINI 开发板,从零实现 ECSPI3 主机模式的裸机驱动,结合寄存器原理逐行拆解代码,详细讲解 SPI 驱动开发的核心逻辑与注意事项,最终实现通用的 SPI 全双工读写能力。
一、SPI 总线核心原理回顾
在讲解 i.MX6ULL 的 ECSPI 外设前,先明确 SPI 总线的核心特性,这是驱动代码设计的基础:
-
四线制硬件架构标准 SPI 采用 4 根信号线通信,主从机的连接关系固定:
SCLK:串行时钟线,主机输出,用于同步收发数据,时钟频率由主机配置;MOSI:主发从收线,主机发送数据、从机接收数据;MISO:主收从发线,从机发送数据、主机接收数据;CS:片选线,主机输出,有效电平与外设有关,同一时刻只能拉低一个从机的 CS 引脚,确保总线只有一个从机处于工作状态。
-
全双工通信特性 SPI 是全双工总线,发送和接收过程完全同步:主机在 SCLK 的驱动下,通过 MOSI 向从机移位发送 1 个 bit 的同时,会通过 MISO 从从机移位接收 1 个 bit。也就是说,每完成 8 个时钟周期(1 字节)的发送,必然同时完成 1 字节的接收,这也是 SPI 驱动通常只用一个读写函数就能完成收发的核心原因。
-
4 种 SPI 时序模式SPI 的时序由两个核心参数决定,必须和从机设备手册严格匹配,否则会出现采样错误:
CPOL(时钟极性):决定总线空闲时 SCLK 的电平状态。CPOL=0,空闲时 SCLK 为低电平;CPOL=1,空闲时 SCLK 为高电平。CPHA(时钟相位):决定数据采样的时钟沿。CPHA=0,在 SCLK 的第一个跳变沿采样数据;CPHA=1,在 SCLK 的第二个跳变沿采样数据。
两个参数组合出 4 种 SPI 模式,本文驱动默认配置为模式 3(CPOL=1,CPHA=1),适配 ADXL345 等绝大多数常用 SPI 传感器。
二、i.MX6ULL ECSPI 外设核心寄存器
i.MX6ULL 的 ECSPI 外设配置完全基于寄存器实现,驱动代码的本质就是对寄存器的正确配置。本文只讲解代码中用到的核心寄存器,完整寄存器说明可参考《i.MX6ULL 参考手册》第 20 章。
2.1 控制寄存器(CONREG)
该寄存器是 ECSPI 的核心配置寄存器,32 位有效,决定了外设使能、主从模式、时钟分频、突发长度等核心参数,代码中重点配置的位段如下:
表格
| 位段 | 位宽 | 代码配置 | 功能说明 |
|---|---|---|---|
| BURST_LENGTH | 31-20 | 7<<20 | 突发传输长度,值为 N 表示一次传输 N+1 个 bit,配置为 7 即一次传输 8bit(1 字节) |
| PRE_DIVIDER | 15-12 | 14<<12 | 预分频器,值为 N 对应 N+1 分频,配置为 14 即 15 分频 |
| POST_DIVIDER | 11-8 | 2<<8 | 后分频器,值为 N 对应 2^N 分频,配置为 2 即 4 分频 |
| CHANNEL_MODE | 7-4 | 1<<4 | 通道主从模式,bit0 对应通道 0,置 1 表示通道 0 配置为主机模式 |
| SMC | 3 | 1<<3 | 传输启动模式,置 1 表示向 TXFIFO 写入数据时自动启动 SPI 传输 |
| EN | 0 | 1<<0 | ECSPI 外设使能位,置 1 开启外设,置 0 关闭外设 |
2.2 配置寄存器(CONFIGREG)
该寄存器主要用于配置 SPI 时序模式、片选极性等参数,和从设备时序匹配的核心配置都在这个寄存器中:
表格
| 位段 | 位宽 | 代码配置 | 功能说明 |
|---|---|---|---|
| SCLK_POL | 7-4 | 1<<4 | 时钟极性配置,bit0 对应通道 0,置 1 表示 CPOL=1,空闲时 SCLK 为高电平 |
| SCLK_PHA | 3-0 | 1<<0 | 时钟相位配置,bit0 对应通道 0,置 1 表示 CPHA=1,第二个时钟沿采样数据 |
2.3 状态寄存器(STATREG)
该寄存器是只读寄存器,用于获取 ECSPI 的工作状态,驱动中通过该寄存器判断收发是否完成,重点用到两个位:
TE(bit0):TXFIFO 空标志位,置 1 表示发送 FIFO 为空,可写入新的发送数据;RR(bit3):RXFIFO 就绪标志位,置 1 表示接收 FIFO 中有有效数据,可读取。
2.4 数据寄存器
TXDATA:发送数据寄存器,向该寄存器写入数据,会将数据压入 TXFIFO,在 SMC=1 的模式下会自动启动 SPI 传输;RXDATA:接收数据寄存器,从该寄存器读取数据,会取出 RXFIFO 中的有效数据,同时自动清除 RR 标志位。
三、硬件设计与工程结构
3.1 硬件引脚复用
本文使用 i.MX6ULL 的 ECSPI3 外设,开发板上该外设的引脚与 UART2 引脚复用,具体映射关系如下,同时采用软件片选的方式,使用 GPIO1_IO20 作为 CS 引脚,比硬件片选更灵活:
表格
| 功能 | 芯片引脚 | 复用配置宏定义 |
|---|---|---|
| ECSPI3_MOSI | UART2_CTS_B | IOMUXC_UART2_CTS_B_ECSPI3_MOSI |
| ECSPI3_MISO | UART2_RTS_B | IOMUXC_UART2_RTS_B_ECSPI3_MISO |
| ECSPI3_SCLK | UART2_RX_DATA | IOMUXC_UART2_RX_DATA_ECSPI3_SCLK |
| SPI_CS (软件) | UART2_TX_DATA | IOMUXC_UART2_TX_DATA_GPIO1_IO20 |
3.2 工程文件结构
驱动工程分为两个文件,结构极简,便于移植和复用:
spi.h:头文件,驱动函数的外部声明;spi.c:源文件,ECSPI 初始化、核心读写函数的实现。
四、驱动代码逐行深度解析
4.1 头文件 spi.h
头文件仅做函数声明,对外暴露两个核心接口,符合 SPI 驱动的最小化设计原则:
c
运行
#ifndef __SPI_H__
#define __SPI_H__
// ECSPI初始化函数,完成引脚、时钟、模式的全部配置
extern void spi_init(void);
// SPI全双工读写函数,发送1个数据的同时接收1个数据
extern unsigned int spi_write_read(unsigned int data);
#endif // ! __SPI_H__
这里的spi_write_read是 SPI 驱动的核心,基于 SPI 全双工特性,一个函数即可完成发送、接收两种操作:
- 仅发送数据:调用函数后忽略返回值即可;
- 仅接收数据:向函数传入 0xFF(空数据),接收返回值即可。
4.2 初始化函数 spi_init ()
初始化函数分为引脚复用与电气属性配置 、软件片选 GPIO 配置 、ECSPI 核心寄存器配置三个部分,逐行解析如下:
c
运行
#include "fsl_common.h"
#include "fsl_iomuxc.h"
#include "MCIMX6Y2.h"
#include "spi.h"
void spi_init(void)
{
// 第一部分:引脚复用配置
IOMUXC_SetPinMux(IOMUXC_UART2_CTS_B_ECSPI3_MOSI, 0);
IOMUXC_SetPinMux(IOMUXC_UART2_RTS_B_ECSPI3_MISO, 0);
IOMUXC_SetPinMux(IOMUXC_UART2_RX_DATA_ECSPI3_SCLK, 0);
IOMUXC_SetPinMux(IOMUXC_UART2_TX_DATA_GPIO1_IO20, 0);
IOMUXC_SetPinMux是 NXP SDK 封装的引脚复用配置函数,第一个参数是引脚复用的宏定义,指定了引脚的功能;第二个参数是 SION 位(软件输入使能),这里配置为 0,关闭强制输入。- 前三个引脚分别配置为 ECSPI3 的 MOSI、MISO、SCLK 功能,第四个引脚配置为 GPIO1_IO20,用于软件片选。
c
运行
// 第二部分:引脚电气属性配置
IOMUXC_SetPinConfig(IOMUXC_UART2_CTS_B_ECSPI3_MOSI, 0x10B1);
IOMUXC_SetPinConfig(IOMUXC_UART2_RTS_B_ECSPI3_MISO, 0x10B1);
IOMUXC_SetPinConfig(IOMUXC_UART2_RX_DATA_ECSPI3_SCLK, 0x10B1);
IOMUXC_SetPinConfig(IOMUXC_UART2_TX_DATA_GPIO1_IO20, 0x10B1);
IOMUXC_SetPinConfig用于配置引脚的电气特性,所有 SPI 相关引脚统一配置为0x10B1,该值的二进制为0 0001 0000 1011 0001,对应 PAD 寄存器的 bit16~bit0,关键位拆解如下:bit16(HYS)=0:关闭迟滞比较器,SPI 引脚作为高速输出无需迟滞;bit12(PKE)=1:使能上下拉 / 状态保持器功能;bit11(ODE)=0:关闭开路输出,SPI 采用推挽输出模式;bit7-6(SPEED)=10:配置引脚速度为 100MHz 中速,满足 SPI 高速通信需求;bit5-3(DSE)=110:驱动能力配置为 R0/6,保证 SPI 信号的驱动强度;bit0(SRE)=1:开启高压摆率,加快电平跳变速度,减少高速信号的畸变。
c
运行
// 第三部分:软件片选GPIO配置
GPIO1->GDIR |= (1 << 20); // GPIO1_IO20配置为输出模式
GPIO1->DR |= (1 << 20); // GPIO1_IO20默认输出高电平,不选中从机
- SPI 片选默认低电平有效,这里初始化 GPIO 为输出模式,默认输出高电平,确保总线空闲时没有从机被选中。
- 软件片选的优势在于时序控制完全自主,不受 ECSPI 硬件通道限制,可适配任意 CS 引脚的硬件设计。
c
运行
// 第四部分:ECSPI核心寄存器配置
ECSPI3->CONREG = 0; // 先清零控制寄存器,避免默认值干扰
ECSPI3->CONREG |= (1 << 0); // 使能ECSPI3外设
// 配置突发长度、分频系数、主机模式、传输启动模式
ECSPI3->CONREG |= (7 << 20) | (14 << 12) | (2 << 8) | (1 << 4) | (1 << 3);
ECSPI3->CONFIGREG = 0; // 清零配置寄存器
// 配置SPI模式3:CPOL=1,CPHA=1
ECSPI3->CONFIGREG |= (1 << 20) | (1 << 4) | (1 << 0);
}
- 寄存器清零:裸机开发中外设初始化的通用规范,先清零寄存器,再按需配置,避免芯片默认值导致的未知问题。
- 时钟频率计算 :i.MX6ULL 的 ECSPI 根时钟固定为 60MHz,最终 SCLK 频率计算公式为:SCLK=(PRE_DIVIDER+1)×2POST_DIVIDER60MHz本文配置
PRE_DIVIDER=14、POST_DIVIDER=2,最终 SCLK 频率 = 60/(15×4)=1MHz,属于 SPI 通用安全频率,可根据从设备手册调整分频系数修改时钟。 - 模式配置 :
CONFIGREG的配置最终实现 SPI 模式 3,需和从设备的时序要求严格匹配,若从设备使用模式 0,只需将对应位清零即可。
4.3 全双工读写函数 spi_write_read ()
该函数是 SPI 驱动的核心,实现单字节的全双工收发,代码量极少但完全覆盖了 SPI 传输的完整流程,逐行解析如下:
c
运行
unsigned int spi_write_read(unsigned int data)
{
// 清除通道选择位,固定使用ECSPI3的通道0
ECSPI3->CONREG &= ~(3 << 18);
// 等待TXFIFO为空,确保上一次传输完成,避免FIFO溢出
while ((ECSPI3->STATREG & (1 << 0)) == 0);
// 写入待发送数据到TXFIFO,自动启动SPI传输
ECSPI3->TXDATA = data;
// 等待RXFIFO就绪,确保接收数据完成
while ((ECSPI3->STATREG & (1 << 3)) == 0);
// 读取接收数据,同时清除RR标志位
unsigned int ret = ECSPI3->RXDATA;
// 返回接收到的数据
return ret;
}
- 通道选择 :
CONREG的 bit19-18 是通道选择位,这里清零后固定使用通道 0,和初始化中的通道配置保持一致,避免通道选择错误。 - 发送前等待 :循环等待
TE位置 1,确保发送 FIFO 为空,上一次的数据已经完全发送,再写入新数据,防止 FIFO 溢出导致的数据丢失。 - 启动传输 :向
TXDATA写入数据后,由于初始化中SMC=1,ECSPI 会自动启动 SPI 传输,在 SCLK 的驱动下完成数据的移位收发。 - 接收等待 :循环等待
RR位置 1,确保接收 FIFO 中有有效数据。这里要注意,SPI 全双工特性决定了发送完成的同时,接收也必然完成,等待接收就绪就是等待整个传输流程结束。 - 数据读取 :读取
RXDATA寄存器获取接收数据,读操作会自动清除RR标志位,释放 RXFIFO 空间,为下一次传输做准备。
五、驱动使用示例
以 SPI 接口的 ADXL345 三轴加速度传感器为例,演示上述驱动的实际使用方法,该传感器使用 SPI 模式 3,和我们的驱动配置完全匹配。
5.1 片选控制宏定义
首先封装片选的控制宏,SPI 通信的核心规则是:通信前拉低 CS,通信全程保持 CS 低电平,通信结束后拉高 CS。
c
运行
// 拉低片选,选中ADXL345
#define ADXL345_CS_SELECT() GPIO1->DR &= ~(1 << 20)
// 拉高片选,取消选中
#define ADXL345_CS_DESELECT() GPIO1->DR |= (1 << 20)
5.2 寄存器读写函数
ADXL345 的 SPI 读写规则:
- 写寄存器:先发送寄存器地址(最高位为 0),再发送待写入的数据;
- 读寄存器:先发送寄存器地址(最高位为 1),再发送空数据(0xFF),读取返回的寄存器值。
c
运行
// ADXL345写寄存器函数
void adxl345_write_reg(unsigned char addr, unsigned char data)
{
ADXL345_CS_SELECT(); // 通信前拉低片选
spi_write_read(addr & 0x7F); // 发送写命令,最高位清0
spi_write_read(data); // 发送待写入数据
ADXL345_CS_DESELECT();// 通信结束拉高片选
}
// ADXL345读寄存器函数
unsigned char adxl345_read_reg(unsigned char addr)
{
unsigned char reg_val;
ADXL345_CS_SELECT();
spi_write_read(addr | 0x80); // 发送读命令,最高位置1
reg_val = spi_write_read(0xFF); // 发送空数据,读取寄存器值
ADXL345_CS_DESELECT();
return reg_val;
}
5.3 设备 ID 读取验证
ADXL345 的设备 ID 寄存器地址为 0x00,固定值为 0xE5,可通过读取该寄存器验证 SPI 驱动是否正常工作:
c
运行
#include "stdio.h"
int main(void)
{
// 必须先初始化系统时钟,打开所有外设时钟
init_clock();
// 初始化SPI驱动
spi_init();
// 读取ADXL345设备ID
unsigned char dev_id = adxl345_read_reg(0x00);
printf("ADXL345 Device ID: 0x%X\r\n", dev_id);
while(1)
{
// 后续业务逻辑
}
return 0;
}
若串口打印出ADXL345 Device ID: 0xE5,说明 SPI 驱动工作完全正常。
六、开发中的常见坑与注意事项
-
外设时钟必须提前使能i.MX6ULL 的所有外设都需要 CCM 时钟控制器使能后才能工作,若只配置 ECSPI 寄存器而不使能时钟,所有寄存器操作都会无效。裸机开发中建议在 main 函数开头先初始化 CCM,打开全部外设时钟。
-
SPI 模式必须与从设备严格匹配CPOL 和 CPHA 的配置错误是 SPI 通信失败的最常见原因,必须严格按照从设备数据手册的时序要求配置,哪怕只差一个参数,也会出现采样数据全 0 或全 0xFF 的问题。
-
片选时序的严格控制多字节读写过程中,CS 引脚必须全程保持低电平,不能中途拉高;每次完整的读写操作结束后,必须拉高 CS,让从机复位传输状态,否则从机会出现数据错位。
-
时钟频率不能超过从设备上限不同 SPI 从设备的最高时钟频率差异极大,比如 SPI Flash 最高可达 100MHz,而部分传感器仅支持 10MHz 以下的时钟。若通信出现偶发错误,可先降低 SPI 时钟频率验证。
-
数据位宽的匹配 初始化中
BURST_LENGTH配置为 7,对应 8bit 数据位宽,这是绝大多数 SPI 设备的标准配置。若使用 16bit 位宽的设备,需将该值改为 15(16bit),否则会出现数据移位错误。
七、总结
本文从零实现了 i.MX6ULL ECSPI3 的主机模式裸机驱动,基于 SPI 全双工特性设计了通用的读写接口,完整覆盖了引脚配置、寄存器时序、实际使用的全流程。