简介:这是一份面向嵌入式初学者与STM32开发者的SPI双板通信例程,聚焦两块STM32板子之间通过SPI协议进行主从数据交互的完整实现,并附带液晶显示相关代码,帮助读者理解同步串行通信在实际项目中的落地方式。资源包共60个文件,约450KB,包含c与h源码、uvproj工程文件、map与lst编译列表、hex与axf可执行文件、txt说明及htm文档等,覆盖从工程配置到编译输出的完整链路。例程对SPI工作模式、CPOL与CPHA组合、CR1/CR2/SR/DR等寄存器配置以及GPIO复用、时钟分频、收发流程和错误处理均给出详细注解,便于对照硬件机制逐步调试。目前已有3086人学习下载,适合希望掌握主从通信、多板交互与液晶驱动基础的读者参考实践。
1. 两块板子之间的 STM32 SPI 通信:从「连上了」到「传得稳」差在哪
两块 STM32 板子用 SPI 对接,是嵌入式里最容易被低估的一件事。很多人第一次接线,主机发一个字节,从机收到,串口打印出来,觉得大功告成;可一旦把速率提到 10 MHz 以上、把线拉到 20 cm、或者改成连续收发几百字节,数据就开始错位、丢包、甚至整帧偏移。问题往往不在「SPI 会不会用」,而在时钟极性、片选时序、DMA 与中断的配合、以及两块板子之间的地电位差这些细节上。
这篇笔记面向的是手里已经有两块 STM32 开发板、想跑通一块做主一块做从、并且希望这套通信能稳定复用到真实项目里的工程师。我会按「先定角色和模式 → 再搭最小可跑工程 → 然后处理连续传输和错误恢复 → 最后讲怎么验证和压测」的顺序展开,中间穿插我踩过的坑和参数取值依据。读完你应该能自己搭出一套可复现的双板 SPI 通信,并且知道哪些参数不能随便改。
2. 先定角色与模式:主机从机、四线三线、CPOL/CPHA 怎么选
SPI 通信能不能一次跑通,八成取决于动手写代码之前有没有把角色和模式定清楚。两块 STM32 之间没有谁天然是主机,必须人为指定一块为主、一块为从,而且这个角色在通信过程中不能动态切换,否则总线会进入不确定状态。常见做法是主机负责产生 SCK 和 NSS,从机只负责在 SCK 边沿采样和输出数据。
2.1 主机从机的职责划分与 NSS 管理
主机端要配置 SCK、MOSI、NSS 为复用推挽输出,MISO 为浮空或上拉输入;从机端 SCK、MOSI、NSS 为输入,MISO 为复用推挽输出。NSS 的管理方式直接决定通信可靠性:软件 NSS 模式下,主机用普通 GPIO 拉低从机片选,时序可控,适合单从机场景;硬件 NSS 模式下,NSS 由 SPI 外设自动控制,多从机时更省心,但要求从机 NSS 在每帧之间正确拉高,否则从机可能把多帧当成一帧。
我一般会在主机端把 NSS 配成软件控制,因为这样可以在两次传输之间插入精确的延时,方便观察波形。从机端则把 NSS 配成硬件输入,让 SPI 外设自己判断帧边界。如果从机 NSS 一直拉低,从机会认为总线一直有效,接收移位寄存器可能把噪声也当成数据。
2.2 CPOL/CPHA 四种模式与双板匹配
SPI 有四种模式,由 CPOL(时钟极性)和 CPHA(时钟相位)组合而成。CPOL=0 表示空闲时 SCK 为低,CPOL=1 表示空闲时 SCK 为高;CPHA=0 表示第一个边沿采样,CPHA=1 表示第二个边沿采样。两块板子必须使用完全相同的模式,否则采样点错位,读到的数据会整体偏移一位。
| 模式 | CPOL | CPHA | 采样边沿 | 典型从机器件 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 第一个上升沿 | 多数 Flash、传感器 |
| Mode 1 | 0 | 1 | 第二个下降沿 | 部分 ADC |
| Mode 2 | 1 | 0 | 第一个下降沿 | 少数射频模块 |
| Mode 3 | 1 | 1 | 第二个上升沿 | 部分显示屏 |
两块 STM32 之间我通常选 Mode 0,因为示波器上最容易看,空闲低电平、上升沿采样,逻辑分析仪抓出来一目了然。如果从机是现成模块,必须以模块手册为准,不能凭感觉选。
2.3 数据宽度与位序:8 位还是 16 位,MSB 还是 LSB
STM32 的 SPI 支持 8 位和 16 位数据宽度,位序可配 MSB First 或 LSB First。两块板子必须一致。常见做法是统一用 8 位、MSB First,这样和大多数协议兼容。如果传输的是 16 位 ADC 采样值,可以在主机端拆成两个字节发送,从机端再拼起来,而不是直接开 16 位模式,因为 16 位模式下 NSS 和 DMA 的配合更容易出问题。
提示:模式、数据宽度、位序这三项,建议在代码里用宏定义集中管理,两块板子引用同一份头文件,避免手改漏改。
3. 搭最小可跑工程:寄存器配置、HAL 初始化与第一帧收发
定好模式之后,下一步是让两块板子真正交换一个字节。这一章我用 HAL 库来写,因为 HAL 的初始化结构体把关键参数都暴露出来了,适合对照检查;如果你用寄存器或 LL 库,参数含义是一样的,只是写法不同。
3.1 主机端 SPI 初始化代码与参数说明
/* 主机 SPI 初始化:Mode 0, 8 位, MSB First, 软件 NSS */ SPI_HandleTypeDef hspi1; void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; /* 主机模式 */ hspi1.Init.Direction = SPI_DIRECTION_2LINES; /* 全双工 */ hspi1.Init.DataSize = SPI_DATASIZE_8BIT; /* 8 位数据 */ hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; /* CPOL=0 */ hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; /* CPHA=0 */ hspi1.Init.NSS = SPI_NSS_SOFT; /* 软件 NSS */ hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_64; /* 先低速 */ hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; /* MSB First */ hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }这段代码里最需要关注的是BaudRatePrescaler。它决定 SCK 频率,公式是f_SCK = f_PCLK / prescaler。假设 SPI1 挂在 APB2 上,PCLK 为 72 MHz,分频 64 后 SCK 约 1.125 MHz。第一次调试一定先用低速,等逻辑分析仪确认波形无误后再逐步提高。NSS设为软件模式后,需要另外配置一个 GPIO 作为片选,在发送前拉低、发送后拉高。
3.2 从机端初始化与中断接收
/* 从机 SPI 初始化:Mode 0, 8 位, MSB First, 硬件 NSS */ SPI_HandleTypeDef hspi2; uint8_t rx_byte; void MX_SPI2_Init(void) { hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_SLAVE; /* 从机模式 */ hspi2.Init.Direction = SPI_DIRECTION_2LINES; hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_LOW; /* 必须与主机一致 */ hspi2.Init.CLKPhase = SPI_PHASE_1EDGE; /* 必须与主机一致 */ hspi2.Init.NSS = SPI_NSS_HARD_INPUT; /* 硬件 NSS 输入 */ hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; /* 从机忽略 */ hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; if (HAL_SPI_Init(&hspi2) != HAL_OK) { Error_Handler(); } /* 开启接收中断,收到一个字节进一次回调 */ HAL_SPI_Receive_IT(&hspi2, &rx_byte, 1); } /* 接收完成回调:把数据存起来,重新挂接收 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) { ringbuf_push(rx_byte); /* 存入环形缓冲区 */ HAL_SPI_Receive_IT(&hspi2, &rx_byte, 1); } }从机的BaudRatePrescaler在从机模式下不起作用,SCK 完全由主机提供,但 HAL 结构体要求填一个值,填 2 即可。从机用中断接收而不是轮询,是为了避免主循环忙等导致丢字节。每收到一个字节就重新挂一次接收,这是 HAL 的用法要求,漏掉这一步只能收到第一帧。
3.3 主机发送第一帧并验证
/* 主机发送一个字节,片选手动控制 */ void Master_SendByte(uint8_t data) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 拉低片选 */ HAL_SPI_Transmit(&hspi1, &data, 1, 100); /* 发送,超时 100ms */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); /* 拉高片选 */ }发送函数里片选拉低和拉高之间就是完整的一帧。HAL_SPI_Transmit的第三个参数是数据长度,第四个是超时时间,单位毫秒。第一次调试时,主机发 0x55,从机收到后通过串口打印出来,如果打印的是 0x55,说明模式、位序、接线都对了。如果收到 0xAA 或 0x2A,多半是 CPHA 选错或 MISO 接触不良。
注意:两块板子必须共地。只接 SCK、MOSI、MISO、NSS 而不接地,通信会时好时坏,这是最常见的翻车点之一。
4. 连续传输与 DMA:从单字节到整帧数据不丢包
单字节通了之后,真实场景往往是主机连续发几十上百字节,从机要完整收下来。这时候轮询和单字节中断都不够用,必须上 DMA 或者至少用带缓冲的中断接收。
4.1 主机 DMA 发送与从机 DMA 接收配置
/* 主机:DMA 发送一帧数据 */ uint8_t tx_buf[64]; void Master_SendFrame(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit_DMA(&hspi1, buf, len); /* DMA 搬运,CPU 解放 */ /* 等待发送完成,实际项目可用回调或信号量 */ while (hspi1.State != HAL_SPI_STATE_READY) { } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); } /* 从机:DMA 接收一帧数据 */ uint8_t rx_buf[64]; void Slave_StartRecv(void) { HAL_SPI_Receive_DMA(&hspi2, rx_buf, 64); /* 挂 64 字节接收 */ } /* 从机接收完成回调 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) { process_frame(rx_buf, 64); /* 整帧处理 */ HAL_SPI_Receive_DMA(&hspi2, rx_buf, 64); /* 重新挂载 */ } }主机用HAL_SPI_Transmit_DMA之后,CPU 可以去干别的,但片选不能立刻拉高,必须等 DMA 传输完成。上面用轮询State的方式最简单,实际项目里更推荐用HAL_SPI_TxCpltCallback回调里拉高片选,避免阻塞。从机用HAL_SPI_Receive_DMA挂一帧长度,收满触发回调,再重新挂载,形成连续接收。
4.2 传输长度、超时与片选时机的配合
DMA 模式下最容易出问题的是片选时机。如果主机在 DMA 还没发完就拉高片选,从机会提前结束接收,导致最后一两个字节丢失。反过来,如果从机 DMA 还没挂好,主机就开始发,前几个字节会丢。常见做法是主机在拉低片选后插入一个短延时(几微秒),确保从机已经进入接收状态;从机则在初始化完成后立刻挂载第一次 DMA 接收。
超时参数在 DMA 模式下不像轮询那么关键,但HAL_SPI_Transmit_DMA内部仍有状态机,如果 DMA 通道被占用,函数会返回HAL_BUSY。这时候要检查 DMA 通道是否和别的外设冲突,比如 SPI1_TX 和某个定时器共用 DMA1_Channel3,就会互相抢。
4.3 环形缓冲区与帧头帧尾校验
连续传输时,从机收到的数据流没有天然边界,必须靠协议来切分。最简单的方式是加帧头和帧尾,比如 0xAA 0x55 开头、0x0D 0x0A 结尾,中间是有效载荷。从机在 DMA 回调里扫描缓冲区,找到完整帧再交给上层处理。
/* 从环形缓冲区中提取完整帧 */ typedef struct { uint8_t buf[256]; uint16_t head; uint16_t tail; } ring_t; int extract_frame(ring_t *r, uint8_t *out, uint16_t *out_len) { /* 简化版:查找 0xAA 0x55 开头、0x0D 0x0A 结尾 */ for (uint16_t i = r->tail; i != r->head; i = (i + 1) % 256) { if (r->buf[i] == 0xAA && r->buf[(i + 1) % 256] == 0x55) { /* 找到帧头,继续找帧尾 */ for (uint16_t j = i + 2; j != r->head; j = (j + 1) % 256) { if (r->buf[j] == 0x0D && r->buf[(j + 1) % 256] == 0x0A) { *out_len = j - i - 2; /* 拷贝有效载荷到 out */ for (uint16_t k = 0; k < *out_len; k++) { out[k] = r->buf[(i + 2 + k) % 256]; } r->tail = (j + 2) % 256; return 0; } } } } return -1; /* 没有完整帧 */ }这段代码的关键是环形缓冲区的 head 和 tail 指针,以及帧头帧尾的扫描逻辑。实际项目里我会再加一个长度字段和 CRC 校验,防止噪声伪造帧头。帧头帧尾的选择要避开数据中可能出现的值,如果载荷里本来就有 0xAA 0x55,就需要转义或者改用长度字段。
5. 避坑与排查:双板 SPI 最常见的五类翻车
这一章记录我在双板 SPI 调试中遇到过的典型问题,每条按现象、原因、解决来写,方便你对照排查。
5.1 现象:从机收到的数据整体偏移一位
原因:主机和从机的 CPHA 不一致。主机用 Mode 0(第一个边沿采样),从机用 Mode 1(第二个边沿采样),从机采样点比主机输出晚半个周期,读到的就是错位数据。
解决:用逻辑分析仪同时抓 SCK 和 MOSI,数一下数据在哪个边沿稳定。两块板子的CLKPolarity和CLKPhase必须逐字一致。改完模式后重新上电,不要只复位,因为部分 STM32 的 SPI 外设需要重新初始化才生效。
5.2 现象:低速正常,提高 SCK 后开始丢字节
原因:杜邦线太长或没有共地,高速时信号反射和地弹导致采样错误。另外从机中断优先级太低,SCK 太快时中断还没响应,下一个字节已经来了。
解决:先把 SCK 降回 1 MHz 确认功能正常,再逐步提高。线长控制在 10 cm 以内,SCK 和 GND 尽量靠近。从机接收中断优先级设为较高,或者改用 DMA 接收。如果必须长线,考虑加串联电阻或降低速率。
5.3 现象:主机发 64 字节,从机只收到 63 或 62 字节
原因:片选拉高的时机比最后一个 SCK 边沿早,最后一个字节没来得及移完。或者从机 DMA 接收长度和主机发送长度不一致。
解决:主机在HAL_SPI_Transmit_DMA完成回调里再拉高片选,不要在调用后立刻拉高。从机 DMA 接收长度必须大于等于主机发送长度,建议两边用同一个宏定义帧长。如果长度可变,协议里带长度字段,从机先收长度再收载荷。
5.4 现象:通信一段时间后从机不再响应,复位才恢复
原因:从机接收中断里没有重新挂载接收,或者 DMA 传输错误后没有重新初始化。HAL 库在出错后会进入HAL_SPI_STATE_ERROR,不再响应。
解决:在HAL_SPI_ErrorCallback里打印错误码,根据错误码判断是溢出还是模式错误。溢出错误(OVR)通常是从机没及时读走数据,需要提高中断优先级或改用 DMA。在回调里重新调用HAL_SPI_Receive_IT或HAL_SPI_Receive_DMA恢复接收。
5.5 现象:两块板子单独测试都正常,对接后主机读 MISO 全是 0xFF
原因:从机 MISO 没有配置成复用推挽输出,或者从机根本没有被片选选中。另外如果从机供电和主机不同,MISO 电平可能不匹配。
解决:检查从机 MISO 的 GPIO 初始化,必须是GPIO_MODE_AF_PP。用万用表量从机 NSS 引脚,主机拉低时应该接近 0V。如果两块板子供电不同,确认电平兼容,必要时加电平转换。主机读 MISO 前先确认从机已经上电并初始化完成。
6. 验证与压测:用逻辑分析仪和误码率把通信钉死
功能跑通只是第一步,真正要投入项目,得知道这套通信在什么条件下会出错。我一般会用逻辑分析仪抓波形,再写一个简单的误码率测试,把速率、线长、连续传输时长这几个变量跑一遍。
6.1 逻辑分析仪抓包与解码设置
逻辑分析仪接 SCK、MOSI、MISO、NSS 四根线,采样率至少是 SCK 的 10 倍。以 1 MHz SCK 为例,采样率设 10 MHz 以上。解码器选 SPI,配置 CPOL、CPHA、位序、数据宽度,和代码里一致。抓到的数据应该和主机发送的缓冲区逐字节对应。
如果解码出来的数据有规律地偏移,先检查解码器配置,再检查代码。逻辑分析仪是黑匣子,配置错了会误导你改代码。我习惯先用一个固定模式(比如 0x00 到 0x3F 递增)发送,这样一眼就能看出哪一位开始错。
6.2 误码率测试脚本与判定标准
主机端写一个循环,发送已知序列,从机收到后比对,统计错误字节数。测试至少跑 10 万字节,误码率低于 1e-6 才算稳定。
/* 主机:发送递增序列,共 100000 字节 */ void Ber_Test_Master(void) { uint8_t pattern = 0; for (uint32_t i = 0; i < 100000; i++) { Master_SendByte(pattern); pattern++; if (pattern == 0) pattern = 1; /* 避开 0,方便观察 */ } } /* 从机:比对接收序列 */ uint32_t err_count = 0; uint8_t expect = 0; void Ber_Test_Slave_Check(uint8_t recv) { if (recv != expect) { err_count++; } expect++; if (expect == 0) expect = 1; }跑完之后打印err_count,如果为 0,说明在当前速率和线长下通信可靠。如果有错误,记录错误位置,看是随机分布还是集中在某一段。随机分布多半是信号完整性问题,集中分布可能是缓冲区溢出或片选时序问题。
6.3 速率、线长、供电的边界测试
我一般会做三组测试:第一组固定线长 10 cm,SCK 从 1 MHz 逐步提到 18 MHz,找到误码率突变的拐点;第二组固定 SCK 为拐点以下一档,线长从 10 cm 加到 30 cm,看误码率变化;第三组用不同供电方式(同一电源、分别供电但共地、分别供电不共地)对比。拐点速率和线长的关系能帮你判断这套方案在真实设备里的余量。
提示:压测时从机不要开串口打印,串口会占用 CPU 和中断,影响 SPI 接收。把错误计数存在变量里,测试结束后一次性打印。
6.4 一个我常用的收尾习惯
每次调通一套双板 SPI,我会把模式、分频、线长、误码率结果记在一个表格里,下次换板子或换线材时直接对照。这个习惯帮我省了很多重复排查的时间。SPI 本身不复杂,复杂的是两块板子之间的物理层和时序配合,把这些边界记下来,比记住某一段代码更有用。希望帮到你。
本文还有配套的精品资源,点击获取