SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
主线课程 / SPI 完整样章

SPI:先读时序,再驱动一块屏幕

从异步计时的风险出发,逐步引出时钟、主从和片选;看懂一个字节怎样传输,再使用 HAL 完成轮询、中断与 DMA 实验。

前置:GPIO、UART、中断5 节核心内容逐边沿波形与片选实验
先想问题,再记名字这一章先追问:两端计时不同怎么办?谁提供时钟?一个主机能带几个设备?怎样选中其中一个?每个硬件名称都从这些问题中引出。配置和代码使用独立实验工程,不替换手表现有驱动。

第 1 节 · 从 UART 的计时问题,走到 SPI 的时钟、主从与片选

UART 章中,发送端决定每个位保持多久,接收端发现起始位后,按自己的时钟推算后面各位的读取时刻。双方设置相同波特率,是为了让这些读取点落在正确的数据位里。

名义时间相同,真实时间也完全一样吗?

不一定。假设发送端一个位实际持续 100 μs,接收端却按 105 μs 安排读取。开始时偏差较小,越往后,读取点越偏离原本的位置;偏得足够远时,就可能读到相邻位。(这两个数用于说明偏移,不是在给实际串口规定允许误差。)

UART 每个字节帧都会利用起始位重新对齐,在允许的时钟误差内能够正常通信。(这里提出的是它需要满足的条件,不是说异步通信一定容易出错。)

从各自计时的问题引出共享时钟
这是一条思考路径;UART 的采样偏移可回到 UART 章互动波形中观察。

能不能让双方按同一个节拍收发?

既然两端各自计时可能出现偏差,就让双方在通信时依据同一根时钟线来安排动作:什么时候准备当前位,什么时候读取,什么时候进入下一位,都按照约定的时钟边沿进行。这样,发送与接收就有了共同的节拍。

例如双方约定在上升沿读取:发送端要先把当前位放好,接收端在上升沿读取,再配合下一个周期继续。这个读取动作叫采样。连续两个 1 可以一直保持数据线为高,双方依照两个周期,就能把它们当成两个位处理。

SPI 的全称是 Serial Peripheral Interface,串行外设接口。它采用同步通信:双方依据共同的时钟节拍协调收发,不再只靠各自推算每个位的时间。(实际线路有传播延迟,数据仍须在采样前后保持足够稳定,速度也要满足器件要求。)

共用一根时钟线,用谁的时钟?

双方有了共同的节拍,还要确定由谁负责产生它。在本章的一次 SPI 通信中,选定一方驱动时钟线,双方再依据这根线协调收发。负责产生时钟的一方叫主机,配合这个时钟工作的一方叫从机;不能让两端同时随意驱动这根线。

角色负责什么不代表什么
主机 Master产生 SCK,并安排这次传输不代表只能发送
从机 Slave跟随收到的 SCK 收发数据不代表只能接收

时钟线常写作 SCK 或 SCLK。先记住:主机和从机的区分看谁掌握时钟,而不是谁提供业务数据。传感器虽然会把测量结果送出来,仍然可以是从机,由单片机提供读取它所需的时钟。

有了主从,数据线怎样命名?

从主机往从机送数据的线叫 MOSI:Master Out、Slave In,主机输出、从机输入。反方向叫 MISO:Master In、Slave Out,主机输入、从机输出。名称从主机角色出发,不是两边都把 MOSI 接自己的输入。

线主机端从机端
SCK输出时钟接收时钟
MOSI输出数据接收主机数据
MISO接收从机数据输出数据
GND按电路要求连接参考地与主机信号建立共同电压参考

暂时先画这些线,不急着写代码。与 UART 的 TX 接 RX 不同,SPI 两端的 MOSI 通常接 MOSI、MISO 接 MISO,因为名字已经同时包含了两端的方向。实际模块也可能使用 SDI、SDO 等标记,必须按它的输入、输出含义核对。

一个主机连接多个从机,怎样选中其中一个?

多个从机可以共用主机产生的 SCK,MOSI 也可以共用。这样一个单片机就能连接多个 SPI 设备。但新问题来了:两颗设备都看到时钟和数据,这个字节到底是发给谁的?

用单独的一根线选中设备

为每颗从机分别连接一根选择线,常叫 CS(Chip Select,片选),也可能标成 SS 或 NSS。本章使用常见的低有效约定:拉低某设备的 CS,选中它;保持高,则不选中它。有效电平最终以器件手册为准。

一个主机共用 SCK、MOSI、MISO 连接两个从机,并分别控制 CS_A、CS_B
交叉线只有标出连接点的位置才相连;图中省略电源和参考地,不是完整接线图。

选中 A 时,A 按时钟收发;B 不参与这次事务。多个从机共用 MISO 时,未选中的设备通常必须释放输出,使它不主动拉高或拉低,这叫高阻态;否则两颗器件同时驱动这根线,可能互相冲突。选模块时要确认它确实支持这种共享连接。

片选实验:相同数据,谁会收到?

从机 A
从机 B

只模拟本例的低有效片选选择。记录最多保留八个字节;不连接设备,也不模拟电气冲突电流。

CS 还可能标记一次操作的边界

一些从机在 CS 有效后等待命令,接着接收地址、参数或数据,最后在 CS 释放时结束本次操作。这一整段叫一次事务。事务可能包含多个字节,不能默认“发完一个字节就必须拉高 CS”。应该按照器件要求决定选中多久、何时切换。

本章先讲常见的独立片选连接;特殊串接方式不在这次实验范围内。多设备共享时还要分别满足它们的模式、速度和片选时序要求,而不是仅多接一根 CS 就自动兼容。

第 2 节 · 跟着时钟,传完一个字节

先固定一个例子:SCK 空闲为低,在上升沿采样,最高位先发送。模式名称稍后才介绍;现在只跟着动作走。

发送 0xA6,要依次放出哪些位?

0xA6 等于二进制 10100110。最高位记作 D7,最低位记作 D0。这里依次发送 D7 到 D0,也就是 1、0、1、0、0、1、1、0。UART 章的例子低位先发,不能把那里的位顺序直接搬过来。

  1. 先让 SCK 保持低,把 CS 拉低。
  2. 在 MOSI 放好 D7=1,并留出稳定时间。
  3. 主机让 SCK 从低变高,从机在这个上升沿读取 MOSI。
  4. SCK 回到低,准备下一位 D6=0,再留出稳定时间。
  5. 重复到 D0,八个上升采样沿就传完八位;按本次事务要求结束或继续。

接收端每收到一位,就把它放到相应位置;八位收齐后得到字节 0xA6。所谓“移位”,可以先理解为把已收部分向前挪一位,再放入新的一位,并不是让一整个数字瞬间出现在数据线上。

为什么同样八个时钟,能一边发、一边收?

MOSI 和 MISO 是两根不同的数据线。主机准备一位发送数据,从机也可以准备一位返回数据;在约定的采样边沿,从机读取 MOSI,主机读取 MISO。同样八个周期,两边各收到八位,这就是本例的全双工。

从机返回的内容由它自己的协议和状态决定,并不保证是刚收到字节的回显。比如这八个时钟可能用于接收命令,真正需要的测量数据要在后面的时钟中才能取到。

同一个采样时刻,两根数据线各传递一个方向的数据
真实两条路径同时工作;这张分行图用于区分方向,不表示先后完成。
只想读数据,为什么还要发送占位字节?从机需要 SCK 才能继续送位。在普通全双工主机配置中,主机可以发送一个约定的占位字节,同时产生时钟并接收返回数据。占位内容是否可用 0x00 或 0xFF,要看从机要求;只调用读取内存的动作不会凭空产生 SCK。

第 3 节 · 约定收发时序,再把约定填进 CubeMX

上一节传送一个字节时,我们采用的是这个顺序:先把数据放到 MOSI 上,时钟上升时让从机读取,再准备下一位。现在顺着这个过程看清两个问题,就能理解四种模式,不需要先背表格。

先放好数据,再让对方读

还是看 0xA6 的前两位:D7=1、D6=0。按上一节的约定,主机和从机这样配合:

  1. 时钟还低着:主机先把 MOSI 设为高,表示 D7=1。
  2. 时钟第一次上升:从机读取 MOSI,记下一位 1。这个“读取并记下”的动作就是采样。
  3. 时钟下降:主机在这一侧准备下一位,把 MOSI 改为低,表示 D6=0。
  4. 时钟再次上升:从机读取已经准备好的低电平,记下一位 0。
先准备首位,上升沿读取,在下降一侧准备下一位
先用这一个具体过程理解“准备数据”和“读取数据”是两个动作。真实输出变化会有延迟,图中用错开的位置表示数据先稳定再读取。

这样安排,是给数据留出稳定时间。如果接收端正准备读取,发送端却也在这附近改变电平,读到的可能是旧值,也可能是新值。双方需要事先约定:在哪个边沿读取,在哪个边沿附近准备下一位。

先看时钟:它也可以从高电平开始

前面一直让 SCK 不传数据时保持低。有些器件要求时钟空闲时保持高,主机就要配合它。于是一个周期的变化顺序也变了:

  • 空闲低:先上升,再下降,回到低。
  • 空闲高:先下降,再上升,回到高。
两种空闲电平下,一个周期第一和第二边沿的方向
“第一、第二”按一个周期内的先后顺序说;“上升、下降”按电平变化方向说。两种说法要区分。

所以,第一个边沿不一定是上升沿。它是时钟离开空闲电平的那次变化;第二个边沿则让时钟回到空闲电平。先分清这一点,后面就不会把采样方向记反。

给第一个设置取名:CPOL,时钟空闲电平

我们已经知道有“空闲低”和“空闲高”两种要求。SPI 把这个设置叫时钟极性 CPOL:

  • CPOL=0:空闲低,每个周期先上升、再下降。
  • CPOL=1:空闲高,每个周期先下降、再上升。

CPOL 只决定时钟从哪个电平开始,尚未决定接收端在哪次变化读取。

第二个设置:CPHA,在哪一个边沿读取?

知道一个周期有两个边沿后,双方再约定使用哪一个来采样。这个设置叫时钟相位 CPHA:

  • CPHA=0:第一个边沿就读取。因此首位数据必须提前准备好;第二个边沿附近再准备下一位。
  • CPHA=1:第一个边沿先推出当前位,第二个边沿才读取;之后每个周期都按这个次序继续。

先保持空闲低,只改变这一项,就能看出区别:CPHA=0 时上升沿读;CPHA=1 时上升侧先准备、下降沿再读。再换成空闲高,第一、第二边沿的方向又会反过来。判断采样方向时,先看 CPOL,再看 CPHA。

两种空闲电平,乘以两种读取时刻,就是四种模式

四种模式只是给四种组合编号,不是四套不同的数据内容。仍然发送同一个 A6,但主机必须使用从机要求的组合,让对方在正确时刻读取。

模式编号CPOL:空闲电平CPHA:采样次序所以实际在哪个边沿读?
Mode 00:低0:第一边沿上升沿
Mode 10:低1:第二边沿下降沿
Mode 21:高0:第一边沿下降沿
Mode 31:高1:第二边沿上升沿

比如器件手册要求“时钟空闲高,在第二边沿采样”:先得到 CPOL=1,再得到 CPHA=1,合起来就是 Mode 3。模式由器件要求决定;选项都能点选,不代表从机都支持。

再用慢动作,把刚才的推理走一遍

  1. 先看 Mode 0:初始 SCK 是低,点击一次“下一个时钟边沿”。第一个上升沿读取 D7。下一次下降不增加已采样位数,再一次上升才读取 D6。
  2. 换成 Mode 2,只改变空闲电平:仍然在第一边沿读,但第一边沿已经变成下降沿。比较两次初始 SCK 和第一个读取动作。
  3. 回到 Mode 0,再换 Mode 1,只改变采样次序:SCK 仍空闲低;第一次上升先准备数据,第二次下降才把已采样位数加一。最后再自行判断 Mode 3。

逐边沿观察:主机发 A6,从机返回 3C

橙色虚线标记采样沿。每按一次前进一步,共十六个边沿、八个周期;先按照下面的三个对照步骤观察,不急着同时改多个设置。数字显示的是已经采样部分的当前数值,收齐八位前不是完整字节。图形是理想时序,数据更新画在对应边沿处,真实器件有输出延迟和建立、保持时间。窄屏可横向滚动;慢动作不代表真实 SPI 速度。

扩展:数据要提前稳定多久?

接收端要求数据在采样前已经稳定一段时间,采样后也继续保持一段时间,分别称为建立时间和保持时间。具体数值查器件手册。本节先理解“准备和读取要错开”;时钟越快,留给输出变化、线路传递与稳定的时间越短,因此模式一致也不能保证任意速度都能正常通信。

到下一节配置 CubeMX 时,先根据器件要求确定这两个约定,再把它们对应到界面中的 Clock Polarity 和 Clock Phase。此时是在填写已经理解的含义,而不是先照抄一组名字。

现在把时序约定填进 CubeMX

现在才打开 CubeMX 的 Connectivity → SPI1。截图用于认识选项;独立实验采用主机、全双工、8 位、MSB First、Mode 0,并用普通 GPIO 控制一根低有效 CS。手表真实屏幕使用 SPI2,后面单独对照。

Mode:选择谁供时钟,以及数据往哪边走

SPI1 的模式下拉框,包含全双工、半双工与只收只发的主从选项
先找到 Full-Duplex Master;下面把这些英文选项与刚才的主从、数据线联系起来。 点击可查看原图。
选项含义本章怎样用
Disable不启用这路 SPI不选
Full-Duplex Master主机提供时钟,两根数据线分别收发本章选择,用于理解双向收发;OLED 实验只连接发送方向
Full-Duplex Slave作为从机,跟随外部时钟不是本次主机实验
Half-Duplex Master / Slave一根双向数据线分时发送或接收先认识名称,不与全双工混接
Receive Only / Transmit Only只接收或只发送的配置具体时钟与引脚行为依器件和配置,不把只接收理解为无需时钟

参数截图中,哪些值需要调整?

下图展示参数所在位置。图里的 Data Size 是 4 Bits,Prescaler 是 2,界面算出的速率是 80.0 MBit/s;它们不是本章基础实验要照抄的配置。先看表格中的“本章选择”,完成调整后再核对界面。

SPI 参数起始界面,显示 4 Bits、MSB First、分频 2、80 MBit/s、CPOL Low、CPHA 1 Edge
这张图用来定位选项。数据长度改为 8 Bits,并降低时钟;不是完整配置完成图。 点击可查看原图。
界面字段本章选择为什么
Frame FormatMotorola本章的普通 SPI 时序;暂不使用 TI 帧格式
Data Size8 Bits一个数据单元就是八位,才能对应本章的一字节例子
First BitMSB First先传 D7,再到 D0
Clock Polarity (CPOL)LowSCK 空闲低,即 CPOL=0
Clock Phase (CPHA)1 Edge第一个边沿采样,即 CPHA=0,不是 Mode 1
Prescaler (for Baud Rate)先选较大分频,例如 256;核对实际 SCK让初次观察更慢;仍要满足从机允许的时钟范围
NSS Signal TypeSoftware本章外部 CS 用普通 GPIO 手动控制
CRC Calculation / Autonomous ModeDisabled / Disable第一轮先观察固定字节,不增加自动 CRC 或自主触发

SPI 时钟通常由该外设的输入时钟分频得到。例如本截图按 160 MHz 的内核时钟计算,除以 2 是 80 MHz,除以 256 则是 625 kHz。这只是本例算式,不代表所有 SPI1 都用 160 MHz。先在界面核对实际显示的频率,时钟树章节再追踪来源。

这里的 Software NSS 不会替程序自动拉低外部 CS。截图中的 NSSP Mode、NSS Polarity 主要与外设 NSS 管理有关,不能拿它们代替 GPIO 片选操作。本章不使用硬件 NSS 输出脉冲;FIFO 阈值先用一数据项,IO Swap 关闭,其他高级间隔与就绪选项先按目标芯片基础配置核对,等需要时展开。

GPIO 与 NVIC:轮询、中断、DMA 的准备不同

在引脚图中确认 SPI1_SCK、SPI1_MOSI、SPI1_MISO 是所选芯片支持的引脚,并按 SPI 功能使用复用模式。本章代码另外约定 PB0 为 CS:普通推挽输出、初始高电平;它与其他章节的按钮接法是不同独立实验,不能同时接成按钮和片选。

SPI1 global interrupt 未勾选,显示优先级 5、子优先级 0
未勾选的状态可用于本章轮询实验;后面做中断与 STM32U5 普通 DMA 发送时,需要核对相应中断路径。 点击可查看原图。

轮询函数自己检查硬件状态,本章第一轮不依赖 SPI1 global interrupt。到了中断实验,才勾选并配置入口;优先级 5 只是图中取值,不是所有工程都必须用的数字。DMA 的 DMA 通道及 SPI 中断准备在DMA 发送部分列出。

代码示例的起点独立 HAL 工程已经能启动、HAL 毫秒时基正常,且 SPI 与 GPIO 初始化已与上述实验设置一致。需要由基础工程提供这些初始化,不把勾选界面当作已运行配置。本教程本次只编写说明和示例,不运行 Generate Code,不改手表固件,不要求现在烧录。

第 4 节 · 用 HAL 传输数据:从轮询到中断、DMA

下面围绕同一组发送数据逐步改变执行方式:先在函数里等发送结束,再让主循环继续执行,最后把重复搬运交给 DMA。引脚、字节含义与片选要求仍然沿用前面的知识。

先用轮询,完整走通一次发送

先认识管理对象和函数声明

SPI_HandleTypeDef 是 HAL 定义的 SPI 管理结构体类型。基础工程里的 hspi1 保存 SPI1 的配置和运行状态;&hspi1 把现有对象地址交给函数,不再自己定义一个同名对象。应用包含本工程的 spi.h,代码放在 SPI1 与 CS 初始化之后。

HAL_SPI_Transmit 从指定存储区取数据并发送;这是阻塞式调用,等正常完成、超时或出错后才返回。先看声明,不需要复制实现:

接口阅读 · 声明来自本工程 STM32U5 HAL1 头文件

HAL_StatusTypeDef HAL_SPI_Transmit(
    SPI_HandleTypeDef *hspi,
    const uint8_t *pData,
    uint16_t Size,
    uint32_t Timeout);
位置本例含义
1 · hspi&hspi1选择已初始化的 SPI1 管理对象
2 · pData&tx从变量 tx 的地址读取发送数据;const 表示通过此指针只读
3 · Size1传一个数据单元。本章 Data Size=8 Bits,所以就是一个字节
4 · Timeout100超时参数为 100 ms,不设置 SCK 频率,也不是传完后的固定延时

uint8_t 是无符号八位整数;uint16_t、uint32_t 分别是无符号 16、32 位整数。HAL_StatusTypeDef 是状态枚举:HAL_OK 表示本次本端正常完成,HAL_BUSY 表示操作忙,HAL_TIMEOUT 表示等待超时,HAL_ERROR 表示错误。status 是应用自己取的变量名,用于保存状态,不是收到的字节。

主动控制 CS,再发送一个字节

复用 GPIO 章的 HAL_GPIO_WritePin:三个参数依次是引脚组、引脚掩码和输出状态,没有返回值。GPIOB 选择 B 组,GPIO_PIN_0 选择 0 号引脚;RESET 输出低,SET 输出高。本例片选低有效,所以先 RESET 选中,再调用 SPI,结束后 SET 释放。

独立实验第一步 · 放在 main 初始化之后,执行一次;之后保留原主循环

uint8_t tx = 0xA6;

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_StatusTypeDef status = HAL_SPI_Transmit(&hspi1, &tx, 1, 100);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);

if (status != HAL_OK) {
    /* 在调试器观察 status,不把这次操作当作成功。 */
}

用逻辑分析仪接到已确认的 SCK、MOSI、CS 和 GND,按实际电平与信号频率选择采样条件。应看到 CS 有效期间八个周期、MOSI 依次为 10100110。SPI 外设会生成时钟,不需要在 C 中手动每一位拉高拉低。

本端 HAL_OK 不证明从机接受了命令;即使从机没有连接,发送侧也可能把波形输出完。普通 SPI 没有统一的逐字节应答位,是否成功要通过从机状态、返回数据或实际现象确认。

接收函数与同时收发函数有什么不同?

先阅读接口 · 同时收发接口作为后续读取 SPI 器件的准备

HAL_StatusTypeDef HAL_SPI_Receive(
    SPI_HandleTypeDef *hspi, uint8_t *pData,
    uint16_t Size, uint32_t Timeout);

HAL_StatusTypeDef HAL_SPI_TransmitReceive(
    SPI_HandleTypeDef *hspi, const uint8_t *pTxData,
    uint8_t *pRxData, uint16_t Size, uint32_t Timeout);

HAL_SPI_Receive 将收到的数据写入 pData 指向的存储区;本工程 U5 驱动会按当前接收配置启动硬件传输,不能把它理解成只读一个内存变量。不同 STM32 SPI 版本接收时的时钟处理不同;如果读取其他 SPI 器件时需要明确发出命令或占位内容,可以使用同时收发函数;本节 OLED 实践只需发送。

HAL_SPI_TransmitReceive 一次同时发送和接收。参数依次为:管理对象地址、发送区地址、接收区地址、单元数量、超时毫秒数;返回值仍是操作状态。发送区与接收区本例使用两个不同变量;接收区才放返回数据。

不想一直等在函数里:改用中断方式

轮询发送期间,SPI 硬件在移位,CPU 也在函数里反复检查状态。发送内容变多时,希望主循环继续做别的事,可以让 SPI 在需要处理或完成时产生中断。

先看与轮询版本不同的地方

中断发送接口 · IT 表示 interrupt

HAL_StatusTypeDef HAL_SPI_Transmit_IT(
    SPI_HandleTypeDef *hspi, const uint8_t *pData, uint16_t Size);

三个参数依次是管理对象地址、发送区地址和单元数量;它没有轮询版的 Timeout 参数。返回 HAL_OK 表示这笔中断传输已启动,不代表线上已经发完。后面真正完成时,HAL 会调用完成回调;启动失败时则不能等待一个必定会到来的完成通知。

接收版 HAL_SPI_Receive_IT 的三个参数是对象、接收区、数量;同时收发版 HAL_SPI_TransmitReceive_IT 的四个参数是对象、发送区、接收区、数量。它们同样返回启动状态;本节先只做发送,避免一下引入两套缓冲区。

从 NVIC 到 HAL 的入口要接好

启用 SPI1 global interrupt,并按中断章的方法设置合适优先级。SPI1_IRQHandler 是本路中断入口,没有参数和返回值;入口调用 HAL_SPI_IRQHandler,其参数是 SPI 管理对象地址,也没有返回值。它负责检查 SPI 来源、搬运所需数据和完成相应处理。

入口对照 · 通常在 stm32u5xx_it.c;已有入口时只核对,不重复定义

void SPI1_IRQHandler(void)
{
    HAL_SPI_IRQHandler(&hspi1);
}

完成通知先介绍,再使用

HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) 是发送完成回调;没有返回值,参数指向完成传输的 SPI 对象。接收完成叫 HAL_SPI_RxCpltCallback,同时收发完成叫 HAL_SPI_TxRxCpltCallback,参数与返回类型相同。HAL_SPI_ErrorCallback 用于错误通知。

本仓库关闭 SPI 动态回调注册,HAL 默认回调带 __weak。应用提供同名、同签名的普通全局实现即可替代,不加 __weak,也不声明 static。回调仍在中断路径中,不在里面长时间等待或调用 HAL_Delay。

下面用 static 数组保持发送区长期有效。tx_done 和 tx_fault 是中断写、主循环读的状态标志,使用 volatile 保证按要求访问;它不是锁。先只启动一笔传输,不在等待完成时重复提交。

中断发送第一部分 · 应用回调仅保留一份,实现位于 main 外部

/* 放在应用 C 文件的文件作用域。 */
static uint8_t tx_data[] = {0xA6, 0x3C, 0x55};
static volatile uint8_t tx_done = 0;
static volatile uint8_t tx_fault = 0;

void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi == &hspi1) {
        tx_done = 1;
    }
}

void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi == &hspi1) {
        tx_fault = 1;
    }
}

主循环中的 busy 是应用自己的事务标志,不是 HAL 内部状态。启动之前先拉低 CS,成功后让它继续保持低;收到完成标志才在主循环拉高。把 CS 紧跟在启动函数后拉高,会在数据还没发送完时提前结束事务。

中断发送第二部分 · 与上面的数组和回调配套,替换轮询实验

/* main 中:基础初始化完成后;本例独占 SPI1。 */
uint8_t busy = 1;
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_StatusTypeDef start_status = HAL_SPI_Transmit_IT(
    &hspi1, tx_data, sizeof(tx_data));
if (start_status != HAL_OK) {
    tx_fault = 1;
}

while (1) {
    if (busy != 0 && tx_fault == 0 && tx_done != 0) {
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
        busy = 0;
        /* 现在才允许按下一笔事务的规则改写 tx_data。 */
    }
    if (tx_fault != 0) {
        /* 停止提交新传输;在调试器观察 start_status 与 SPI 状态。 */
    }
    /* 继续其他普通工作;本例不自动重复发送。 */
}

正常情况下应看到 CS 持续有效、24 个时钟周期,完成后 tx_done 变成 1,主循环释放 CS。若启动失败或发生错误,本例停在可观察的故障状态,不声称已完成错误恢复。确认硬件已停止、整理片选和驱动状态之后,才能重新提交;不能只清掉错误标志就假装恢复。

函数返回了,数据也不能立即改中断路径后面还会读取 tx_data。数组必须一直有效,内容也要保持到完成。一个函数里创建普通局部数组,启动后立刻返回;或者一边传输一边改它,都可能使实际发送内容出错。非阻塞接口也不自带应用超时恢复,需要另外设计监测。

搬运工作多了:再请 DMA 小助手

中断方式让主循环不再一直等,但 CPU 仍会在中断处理中帮助 SPI 搬数据。要发送很多字节时,能不能找一个硬件助手接手这部分重复工作?

DMA 就是这样的“小助手”。以发送为例,CPU 先告诉它:数据在内存哪里、要送到哪个外设、搬多少。随后 DMA 按外设请求,把数据搬给 SPI,SPI 再按 SCK 把位送到引脚;接收时则把外设数据搬进内存。DMA 不只服务 SPI,也能服务其他适配的外设。

DMA 负责内存与外设之间的数据搬运,SPI 负责线上移位
助手比喻描述分工,不表示 DMA 替代 SPI 的时钟和移位功能;完整原理留到 DMA 章。
方式谁推进数据搬运主循环怎样知道结束
轮询CPU 在调用内部配合硬件推进检查函数返回状态
中断CPU 在中断处理期间配合硬件推进HAL 完成回调通知
DMADMA 按请求搬运,CPU 负责配置与完成、错误处理经 DMA / SPI 完成路径到 HAL 回调

先配置一笔普通 DMA 发送需要的条件

在独立 SPI1 的 DMA Settings 中,为发送添加对应 SPI1_TX 请求。界面与可选 DMA 控制器随芯片不同,本节不把某个通道号当成通用答案。先按这些含义核对:

设置本次普通发送要求它告诉助手什么
RequestSPI1_TX 对应的发送请求由哪个外设请求搬运,不选成接收请求
DirectionMemory to Peripheral从内存送到外设
Memory increment启用下一次取数组的下一项
Peripheral increment关闭外设数据位置不随每个字节增加
Data width源与目的按 8 位数据配置与本章 SPI 8 Bits 对齐
Mode / 数量一次有限传输,非循环搬完规定数量,不自动反复使用数组
关联与初始化DMA 已初始化并与 hspi1 的发送路径关联不是只添加了一个界面条目

在 STM32U5 中,GPDMA 还可能涉及节点和链表配置;先把它理解为描述搬运任务的配置。如果当前工程使用该界面,必须确认它是一笔有效、有限的任务并完成对象关联。这里先使用已经准备好的独立基础工程;节点结构与请求机制到 DMA 章再逐步拆开,不临时拼一段未说明的底层初始化。

DMA 通道中断与 SPI 中断都要核对

DMA 通道需要由它自己的中断入口交给 HAL_DMA_IRQHandler。这个函数接收对应 DMA 管理对象的地址,没有返回值,用于处理该通道状态;具体通道入口名和对象由基础工程的实际分配决定,不能把别人的通道号直接照抄。

本工程 STM32U5 HAL1 的普通非循环 SPI DMA 发送,还需要 SPI 中断完成收尾。驱动在 DMA 搬完后允许 SPI 的传输结束事件 EOT,再经 SPI 中断完成处理、调用应用回调。因此要同时核对 DMA 通道中断和中断发送部分的 SPI1 global interrupt、SPI1_IRQHandler。只开 DMA 中断而不接 SPI 入口,可能收不到预期完成回调。

这也说明:DMA 把数据送给外设,与最后一位已经从引脚发完,是两个时刻。不要在 DMA 刚搬完内存数据时就自行释放 CS。这里依据本章 U5 普通非循环驱动路径说明,其他系列或循环模式要重新核对。

使用方法:换启动函数,保留完成规则

先认识 DMA 发送接口,再修改中断发送部分示例

HAL_StatusTypeDef HAL_SPI_Transmit_DMA(
    SPI_HandleTypeDef *hspi, const uint8_t *pData, uint16_t Size);

它的三个参数依次是 SPI 对象地址、发送数据地址、单元数量;返回 HAL_StatusTypeDef,HAL_OK 只表示启动成功。仍没有轮询 Timeout 参数。接收版 HAL_SPI_Receive_DMA 使用对象、接收区、数量;同时收发版 HAL_SPI_TransmitReceive_DMA 使用对象、发送区、接收区、数量,需要相应收发 DMA 配置。

完成上述初始化和中断准备后,保留中断发送部分长期有效的 tx_data、tx_done、tx_fault、两个回调和主循环,只把启动语句替换为下面这一句。不要先启动 IT 再紧接着启动 DMA;它们是两次独立实验。

替换中断发送部分 start_status 的声明与启动语句,其余正常完成处理不变

HAL_StatusTypeDef start_status = HAL_SPI_Transmit_DMA(
    &hspi1, tx_data, sizeof(tx_data));

普通发送完成后仍到 HAL_SPI_TxCpltCallback,应用收到 tx_done 再释放 CS;错误路径仍要停止新提交并处理,不能自动假定 DMA 已停。先对照三字节的波形和完成标志,再用更长、内容已知的数组比较 CPU 能否在主循环继续工作。

先用起来,要守住三条规则发送缓冲区在完成前不修改、不失效;接收缓冲区在完成前不当作完整新数据使用;前一笔尚未收尾时不重复占用同一路 SPI 或修改 CS、D/C。DMA 不是无限快,也不使 SPI 时钟变快;短小传输有配置开销,未必值得使用它。

到 DMA 专章,再解释请求、通道、节点、总线争用和缓冲管理。GPIO 手动实现 SPI 也放在学过定时器后的回访实验,本章不使用来历不明的延时函数或空循环冒充准确时序。

第 5 节 · 用 SPI 点亮 OLED,再显示取模图片

固定字节实验之后,用真正看得见的画面检验 SPI。这里选用SSD1306、128×64、已配置为四线 SPI 的单色 OLED 模块。本节只做轮询显示,先清屏、画一个点,再显示取模图片;不先引入完整 UI 库。

参考 波特律动的 OLED 绘图与取模讲解认识“先画缓冲区,再刷新”的使用流程。该例程的板载屏是 I²C 接口,控制器也与本例不同;本节另提供 SPI 教学代码,不把它的接线和初始化直接照搬过来。

把 SSD1306 数据手册放在旁边一起读

使用 Solomon Systech · SSD1306 Rev 1.1(2008-04),由 Adafruit 托管厂商原文。下面链接按 PDF 阅读器页码跳转;主手册为 59 页,文件后附 6 页电荷泵应用说明。无需先通读全文,每遇到一个配置就按表查找。

本节要解决什么打开手册哪里回到代码找什么
确认四线 SPI 与 D/C§8.1.3,p.17~18;Figure 8-5 在 p.18write_bytes:is_data 控制 D/C,PB0 控制 CS
理解页与字节排列§8.7,p.25;GDDRAM 示意frame[8][128];Pixel 的 y/8 与 y%8
设置页寻址与起点§10.1.1~10.1.3,p.34;§10.1.13,p.37Init 的 20 02;Flush 的 B0|page、00、10
确认初始化含义§9 命令表,p.28 起;§10.1.12,p.37setup 数组按命令及参数分组;AE/AF 控制显示
核对电荷泵设置附录应用说明 §2.1(PDF p.62)8D 14;只适用于与本例供屏方案相符的模块

这份文件描述的是控制器芯片。实验尚未指定具体品牌的 OLED 模组,供电范围、接口焊盘和排针顺序仍需该模组的说明或原理图;不能将芯片引脚图直接当成模块排针图。

先确认手里的屏,接的是什么接口

OLED 是显示技术名称,不是一套统一通信协议。有些模块只引出电源、SCL、SDA 四个引脚,已经固定为 I²C;另一些模块引出 CS、D/C、RES 等,可以使用 SPI。即使外形、分辨率相同,也不能默认控制器和初始化相同。

本例假设使用供电和逻辑输入均支持本实验 3.3 V 的成品模块,内部电荷泵供屏使用,接口选择已经设为四线 SPI。电荷泵可以先理解为模块中为 OLED 工作准备显示电压的部分。若实际型号是 SH1106、CH1116、SSD1309 或 128×32 面板,先换成匹配的驱动和初始化,不能只改一个宽度数字。

OLED 模块常见标记接到独立实验板哪里作用
GND实验板 GND信号参考地
VCC模块说明允许的电源;本例 3.3 V先确认成品模块额定供电
D0 / CLK / SCK所选 SPI1_SCK 引脚主机输出时钟
D1 / DIN / MOSI所选 SPI1_MOSI 引脚主机向 OLED 发送数据
CSPB0,推挽输出,初始高低电平选中 OLED
DC / D/CPB1,推挽输出,初始高低表示命令,高表示显示数据
RES / RSTPB2,推挽输出,初始高低电平复位控制器

SPI1 延续前面 8 Bits、MSB First、Mode 0、软件 NSS 的设置,先用不高于 1 MHz 的 SCK,并核对模组要求。SSD1306 的 SPI 模式是写入路径,这个显示实验不连接 OLED 返回的 MISO。屏幕丝印有时也把 SPI 数据线写作 SDA,要结合接口选择和说明判断,不能只凭这三个字母认定是 I²C。

CS、D/C 和 RESET 为什么都需要?

CS 解决“这次选谁”。D/C 解决“这个字节该当成什么”:D/C 低时,控制器按命令及其参数解释字节;D/C 高时,把字节写到显示数据存储区。RESET 则让控制器回到确定的起点。设置引脚模式、复位、初始化,都要先于正式画图。

例如 0xAE 是关闭显示输出,0xAF 是打开显示输出;开显示不等于已经填好所有像素。初始化还要设置行数、寻址方式和供屏参数。本例的完整命令在教学驱动中逐项加了注释,第一轮先认清每组命令解决哪类配置,不要求背十六进制表。命令含义按 SSD1306 手册核对。

128×64 个黑白点,为什么只需 1024 字节?

单色屏每个像素用一个位表示亮或灭。128×64=8192 个像素;每字节八位,所以一整屏是 8192÷8=1024 字节。

本例把纵向相邻的八行看作一页:y=0~7 是第 0 页,y=8~15 是第 1 页,依次到第 7 页。每一页有 128 列,每列一个字节。字节的 bit 0 对应这组八行最上面的像素,bit 7 对应最下面的像素;先逐列向右放,再放下一页。

8×8 箭头按每列八位、最低位在上的方式打包为八个字节
原创小箭头用来检验取模方向。绿色像素为 1;看似同一张图,采用不同排列规则会得到不同数组。

例如点亮 (x=10,y=9):第 1 页、第 10 列的字节中,设置 bit 1。对应数组 frame[1][10],在 1024 字节连续排列中是下标 138。图案要出现在哪里,由这些像素位置决定,不由 C 数组的变量名决定。

先亲手发一条命令,再看看哪些代码总在重复

刚才已经知道 D/C 低表示命令。现在只发送 AE(关闭显示):先把 D/C 拉低,再把 CS 拉低,调用已经学过的 SPI 发送,最后释放 CS。这个片段用于看发送动作;屏幕完整初始化之前,不要求它出现图案。

对照教学驱动内部发送过程 · SPI1 与三个控制引脚已经初始化

uint8_t command = 0xAE;
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); /* D/C:命令 */
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); /* CS:选中 */
HAL_StatusTypeDef status = HAL_SPI_Transmit(&hspi1, &command, 1, 100);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);   /* CS:释放 */

如果要发送 AF 或其他命令,变的是 command,周围的控制线和发送动作一样;若发送像素,D/C 改为高,其他过程仍相似。每条命令都重复这些语句会遮住命令本身,于是把这段重复操作放进 write_bytes:第一个参数说明命令还是显示数据,第二个给字节地址,第三个给字节数,返回 HAL 的发送状态。

已有 oled_lesson.c 的发送封装 · 先对应刚才展开的 GPIO 与 HAL 调用

static HAL_StatusTypeDef write_bytes(uint8_t is_data,
                                    const uint8_t *data, uint16_t size)
{
    if (data == 0 || size == 0) {
        return HAL_ERROR;
    }
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1,
                      is_data ? GPIO_PIN_SET : GPIO_PIN_RESET);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
    HAL_StatusTypeDef status = HAL_SPI_Transmit(&hspi1, data, size, 100);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
    return status;
}

再回到前面的 frame 数组:把所有字节清零,就是清空画布;根据 x、y 改其中一个位,就是画点;把八页数组依次发出去,就是刷新。因此 Clear、Pixel、Flush 分别只是给这三个具体动作起名字,并没有换一种通信方式。Init 则把复位、设置参数、清屏和打开显示按顺序组合;Bitmap 把取模数组逐点写到同一块画布。

理解这些动作后,再使用下面五个函数。使用它们时可以随时回到源文件,找到刚才已经认识的 GPIO、SPI、数组与位运算。

先认识教学驱动的五个函数

本节提供 oled_lesson.h 和 oled_lesson.c。这是本教程为独立 SPI 实验写的封装,不是 HAL 自带函数,也不是波特律动库的同名替代品。把它们分别加入独立实验工程的头文件、源文件目录并参与构建,应用中包含 oled_lesson.h;需要的 spi.h、hspi1 由基础工程提供。

函数参数、返回值具体做什么
OLED_LessonInit()无参数;返回 HAL_StatusTypeDef复位,发送本例初始化,清空并刷新,然后打开显示
OLED_LessonClear()无参数,无返回值只把单片机内存画布清零,尚未刷新屏幕
OLED_LessonPixel(x, y, on)横坐标、纵坐标、是否点亮;无返回值x 为 0~127,y 为 0~63,on=1 点亮、0 熄灭;越界忽略
OLED_LessonFlush()无参数;返回 HAL_StatusTypeDef逐页设置位置,把 1024 字节画布通过 SPI 发给屏幕
OLED_LessonBitmap(x, y, width, height, data, size)左上角、宽高、数据地址与字节数;返回 HAL_StatusTypeDef按本节约定解码图模到内存画布,不自动刷新;不合格式返回 HAL_ERROR

这些返回状态沿用前面学过的 HAL 状态。HAL_OK 说明本端调用正常完成,不等于屏幕供电、控制器选择和画面内容全部正确。Pixel 和 Bitmap 改的是 MCU 的 RAM 缓冲区;Flush 才把画面送到控制器。

先在单片机 RAM 中修改画布,再通过 SPI 刷新到 OLED
显示不是每画一个点就必须发送一次。先在内存组织整幅画面,再统一刷新,更容易理解和扩展。

带着手册读一次 Flush:三条命令怎样确定起点?

打开上表的页寻址说明,再看 oled_lesson.c 的 Init 与 Flush。先在 Init 找到 0x20, 0x02:前者选择寻址方式,后者是它的参数。读命令表时先分清“命令”与“紧跟的参数”,不能把数组中每个数都当成独立命令。

接着跟踪循环中的 page=1:0xB0 | page 得到 B1,选第 1 页;0x00 和 0x10 分别将列起点的低、高四位设为零。然后才以 D/C 高发送这一页的 128 个字节。读完一页并不替本例选好下一页,所以 Flush 的循环再次发命令设置下一页起点。

练习:如果只想从第 1 页、第 10 列开始发送,先把 10 写成十六进制 0A,再分别组装页、低列、高列命令。结果应为 B1 0A 10;随后传输的一个数据字节仍代表这一列的八个像素,而不是八列。可以对照 p.34 的页寻址图检查自己的推导。

第一步:初始化、清屏,再点亮一个像素

先确认 SPI1、PB0/PB1/PB2 已初始化,HAL 的毫秒时基正常。驱动中的等待用已学过的 HAL_Delay,复位等待是本例保守设置,实际供电和复位要求仍以模块说明为准。以下代码放在 main 的基础初始化之后、原主循环之前:

第一步 · 初始化本身会清屏;再画一个点以观察完整流程

HAL_StatusTypeDef oled_status = OLED_LessonInit();
if (oled_status == HAL_OK) {
    OLED_LessonClear();
    OLED_LessonPixel(10, 9, 1);
    oled_status = OLED_LessonFlush();
}
/* 在调试器观察 oled_status,后面继续原来的主循环。 */

预期背景熄灭,在靠左上处出现一个小点。点很小,可改成一个 8×8 方块:两个循环让 x 从 0 到 7、y 从 0 到 7,逐点调用 Pixel,最后只调用一次 Flush。先把这一步跑通,再增加图片或汉字。

如果方向镜像或上下颠倒,先核对初始化中的扫描映射及面板安装方向;如果完全不亮,优先查供电、接口选择、复位、CS、D/C 和发送状态,不先怀疑字模。

第二步:推荐波特律动取模助手

推荐使用 波特律动 OLED 取模助手。它提供字体取模和图片取模,可以预览图案并生成 C 数据;这样不必手工为每个像素计算字节。取模就是把文字或图片,按某种像素排列约定转换成数字数组,不会自动替你完成接线和初始化。

  1. 先用本节原创的 8×8 箭头 BMP,进入工具的“图片取模”功能导入。图案有明确方向,适合检查镜像和位顺序。
  2. 保持 8×8、单色,不缩放成其他尺寸。让亮像素对应 1、背景对应 0;工具中可看到阴阳码等选项,需要结合预览确认。
  3. 按本节约定选择或转换排列:纵向八像素组成一个字节,bit 0 在上;先排同一页的各列,再排下一页。工具提供多种取模方式,不能只看名称相似就认定匹配。
  4. 导出纯像素数据,检查 8×8 应为八个字节,并与下面的数组比较。如果输出带 Image、Font 结构体、UTF-8 编码前缀等,那是另一种库的数据包装,不能整段当作本例 data 传入。
  5. 数组正确后,先把箭头画到固定坐标并刷新,最后再换自己的图片。

这张箭头按本例排列应得到的八个字节 · 放在文件作用域

static const uint8_t arrow8[] = {
    0x1C, 0x1C, 0x1C, 0x7F, 0x3E, 0x1C, 0x08, 0x00
};

下面每个参数都能对应到刚才的表:16、16 是图案左上角;8、8 是宽高;arrow8 是像素数组地址;sizeof(arrow8) 在这个实际数组处为八个字节。Bitmap 返回成功后再刷新:

第二步 · 若原作用域已有 oled_status,只赋值,不重复声明

/* 保留前面已成功完成的初始化;替换单点绘制部分。 */
OLED_LessonClear();
HAL_StatusTypeDef oled_status = OLED_LessonBitmap(
    16, 16, 8, 8, arrow8, sizeof(arrow8));
if (oled_status == HAL_OK) {
    oled_status = OLED_LessonFlush();
}

本例 Bitmap 按宽×向上取整(高÷8) 检查数据长度。例如 16×16 需要 32 字节,13×10 需要 26 字节,而不一定是像素总数直接除八;每页不足八行也占一个字节。它按 0、1 覆盖图案矩形中的像素,超出屏幕的目标像素裁掉。

汉字与图片的共同点,以及不能混用的部分

字体取模把一个字变成一小块点阵,图像取模把整张图变成点阵。第一次可先选一个 16×16 字,取得匹配排列的 32 字节像素,像图片一样画出来;显示多个字符时,再按字符编码找到对应点阵、依次移动坐标。

取模助手默认可生成适配波特律动 OLED 驱动的 Font 定义,可能在每个点阵之前包含字符编码。这种导出应搭配相应驱动的字体接口使用;本例只吃纯像素数组,不能把带编码的数据直接交给 Bitmap。字形数据与 I²C、SPI 传输无关,真正决定能否复用的是排列方式和绘图接口。

从这个实验接回中断、DMA 与手表彩屏

本例用轮询发送,是为了先把“命令、数据、画布、刷新”看清楚。驱动内部的页地址数组与部分命令变量是局部数据;不能只把 HAL_SPI_Transmit 改为 DMA 后缀就离开函数,也不能让异步传输尚未完成时继续切 D/C、CS。异步刷新需要把缓冲区、页次序和完成通知一起组织。

手表本身用 GC9A01 彩色 LCD,并非本例 SSD1306 单色 OLED。进入支线一后,沿同样的“配置 → 命令 → 数据”路径阅读,并结合 GC9A01 手册学习 RGB565 和矩形窗口。

接下来:继续主线,或进入屏幕与 UI 支线

如果想继续驱动手表彩屏,可以转到 支线一:GC9A01 屏幕实战,再按 七篇路线学习 LVGL。它从已有的 SPI 知识继续深入,不作为后面主线的前置要求。

主线则继续 I²C:SPI 常见一对多接法需要独立片选,能不能把“找谁”放进共用的数据中?下一篇由此引出设备地址,再区分设备内部的寄存器地址。

检查理解

HAL_SPI_Transmit_DMA 返回 HAL_OK,接下来哪个操作正确?

参考与继续阅读

本章的图解、代码实验与页面模型按教学顺序组织;项目事实以本仓库当前实现为准。完整来源见 资料索引。不同系列的时钟、引脚与 DMA 初始化不能直接互换。