CH58x 芯片 SPI 的使用说明 ...... 矜辰所致
前言
最近遇到 SPI 的驱屏需求,正好之前也没有写过 CH58x SPI 使用的文章,那么就借此机会来补充一篇 SPI 的使用说明。
所以本文内容就是说明一下 CH58x 芯片 SPI 的使用 。
沁恒蓝牙系列专栏目录:
【导航】沁恒 RISC-V 蓝牙 入门教程目录 【快速跳转】
.
我是矜辰所致,全网同名,尽量用心写好每一系列文章,不浮夸,不将就,认真对待学知识的我们,矜辰所致,金石为开!
目录
- 前言
- [一、 基础说明](#一、 基础说明)
-
- [1.1 SPI 协议基础](#1.1 SPI 协议基础)
- [1.2 CH58x SPI 说明](#1.2 CH58x SPI 说明)
- [1.3 引脚说明](#1.3 引脚说明)
- 二、使用说明
-
- [2.1 示例测试](#2.1 示例测试)
- [2.2 示例流程说明](#2.2 示例流程说明)
- [2.3 应用说明](#2.3 应用说明)
-
- [2.3.1 一些关键寄存器](#2.3.1 一些关键寄存器)
- [2.3.2 单字节全双工](#2.3.2 单字节全双工)
- [2.3.3 从机全双工接收示例](#2.3.3 从机全双工接收示例)
- [2.3.4 阻塞形式收发](#2.3.4 阻塞形式收发)
- [2.3.5 数据发送时间](#2.3.5 数据发送时间)
- [2.3.6 非阻塞形式收发](#2.3.6 非阻塞形式收发)
-
- [2.3.6.1 DMA 中断发送](#2.3.6.1 DMA 中断发送)
- [2.3.6.2 DMA 中断接收](#2.3.6.2 DMA 中断接收)
- [2.4 非阻塞主从通讯示例](#2.4 非阻塞主从通讯示例)
-
- [2.4.1 主机示例](#2.4.1 主机示例)
- [2.4.2 从机示例](#2.4.2 从机示例)
- 结语
一、 基础说明
1.1 SPI 协议基础
SPI(Serial Peripheral Interface,串行外设接口)是一种高速、全双工、同步通信总线,常用于短距离通讯,主要应用于 EEPROM、FLASH、实时时钟、AD 转换器、还有数字信号处理器和数字信号解码器之间。
SPI的4根线:

SDO/MOSI -- 主设备数据输出,从设备数据输入;
SDI/MISO -- 主设备数据输入,从设备数据输出;
SCLK -- 时钟信号,由主设备产生;
CS/SS -- 从设备使能信号,由主设备控制。当有多个从设备的时候,因为每个从设备上都有一个片选引脚接入到主设备机中,当我们的主设备和某个从设备通信时将需要将从设备对应的片选引脚电平拉低或者是拉高(一般来说是低电平选中)。
SPI 以主从方式工作,通常有一个主设备和一个或多个从设备。通信由主设备发起,主设备通过 CS 选择要通信的从设备,然后通过 SCLK 给从设备提供时钟信号,数据通过 MOSI 输出给从设备,同时通过 MISO 接收从设备发送的数据。
SPI 的 有四种模式:
模式0:CPOL=0, CPHA=0 -> 空闲时SCLK为低电平,数据在第一个边沿(即上升沿)采样。
模式1:CPOL=0, CPHA=1 -> 空闲时SCLK为低电平,数据在第二个边沿(即下降沿)采样。
模式2:CPOL=1, CPHA=0 -> 空闲时SCLK为高电平,数据在第一个边沿(即下降沿)采样。
模式3:CPOL=1, CPHA=1 -> 空闲时SCLK为高电平,数据在第二个边沿(即上升沿)采样。
不同的从设备可能在出厂时由工厂配置为某种通信模式,无法改变,所以我们得根据从设备的通讯模式来设置主设备的模式。
比如我们常用的 SPI Flash W25QXX 系列,可以在文档资料中看到其支持的工作模式:

1.2 CH58x SPI 说明
对于 CH585 来说,在芯片手册 SPI 章节有整体特性说明:

这里面需要注意:
- CH58x 的 SPI 只支持 模式 0 和 模式 3
- 时钟频率最高主频的一半
- SPI1 不支持从机模式,不支持 DMA
这里还要额外说明一个:就是 CH58x SPI 只有单字节支持全双工模式,多字节只能半双工( 只有一个 FIFO ,要么设置为接收,要么设置为发送 ) 。
文档后面还有图示说明:

在章节最后还有 SPI 的使用说明,对于使用步骤也有详细的说明,比如主机模式配置和数据发送,如下(更多内容自行查看手册):

这里不需要死记这个文字,强烈建议结合官方 EVT 中 SPI 示例去理解这个步骤,示例中就是按照这个步骤进行的函数设计。
1.3 引脚说明
CH585 的 SPI 引脚(CH584 只有 SPI0,没有 SPI1):
SPI0(主机+从机,带 DMA)
| 信号 | 默认 | 重映射 |
|---|---|---|
| CS | PA12 | PB12 |
| SCK | PA13 | PB13 |
| MOSI | PA14 | PB14 |
| MISO | PA15 | PB15 |
1.主机模式下 CS 可以用任意 GPIO,这里的 CS 是把芯片当作从机来用的,需要其他主机拉低这个引脚,表示把芯片的 SPI 选中。
2.SPI0 重映射档走 PB12-15,而 PB14/PB15 同时是仿真调试口 TCK/TIO (虽然并不建议开仿真,但还是注意一下)。
- 重映射切换
GPIOPinRemap(ENABLE,RB_PIN_SPI0)。
SPI1(仅主机、无 DMA、无重映射)
| 信号 | 引脚 |
|---|---|
| SCK1 | PA0 |
| MOSI1 | PA1 |
| MISO1 | PA2 |
| CS | 任意 GPIO |
二、使用说明
2.1 示例测试
官方 SPI 示例包含了主机和从机的示例代码,测试时候自己通过 #if 1 和 #if 0 设置一下需要的功能。
说明:官方示例都是半双工的方式,并且都是阻塞的方式。
测试连接:引脚直连不需要交叉!
主机 SCK(PA13) → 从机 SCK(PA13)
主机 MOSI(PA14) → 从机 MOSI(PA14) 直连,别交叉!
主机 MISO(PA15) ← 从机 MISO(PA15)
主机 CS (PA12) → 从机 CS (PA12)
主机 GND ↔ 从机 GND
示例比较简单,测试效果直接看打印结果,如下图 :

之前提到过,CH58x SPI 单字节可以全双工的,但是示例还是按照半双工的方式写的,在下面的应用说明章节,我们会有单字节全双工的说明 。
2.2 示例流程说明
主机:
根据示例,基本流程(主机)可概括为下面步骤:
- 配 GPIO:CS 默认高(输出)、SCK/MOSI 输出;
c
GPIOA_SetBits(GPIO_Pin_12);
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14, GPIO_ModeOut_PP_5mA);
- SPI0_MasterDefInit():主机默认初始化(分频=4、模式0、FIFO 方向=发送、清 FIFO、关 DMA);
c
void SPI0_MasterDefInit(void)
{
R8_SPI0_CLOCK_DIV = 4; // 主频时钟4分频
R8_SPI0_CTRL_MOD = RB_SPI_ALL_CLEAR;
R8_SPI0_CTRL_MOD = RB_SPI_MOSI_OE | RB_SPI_SCK_OE;
R8_SPI0_CTRL_CFG |= RB_SPI_AUTO_IF; // 访问BUFFER/FIFO自动清除IF_BYTE_END标志
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE; // 不启动DMA方式
}
-
(可选)SPI0_CLKCfg(div):配 SPI 时钟,分频系数按从器件上限选(最高 Fsys/2);
-
CS 拉低:开始一次 SPI 事务;
-
调库函数收发;
发送(单字节):
SPI0_MasterSendByte(0x55);接收(单字节):
i = SPI0_MasterRecvByte();发送(多字节):
SPI0_MasterTrans(spiBuff, 8);接收(多字节):
SPI0_MasterRecv(spiBuffrev, 8);发送(DMA):
SPI0_MasterDMATrans(spiBuff, 12);接收(DMA):
SPI0_MasterDMARecv(spiBuffrev, 12); -
CS 拉高:结束事务。
注意:示例里库函数的收发(包括 DMA)都是阻塞发送的:
- 单字节靠
while(!(R8_SPI0_INT_FLAG & RB_SPI_FREE));等; - 多字节靠
while(!(R8_SPI0_INT_FLAG & RB_SPI_IF_CNT_END));要使用非阻塞发送需要使用 DMA 中断方式,下文会有说明。
从机:
- 配 GPIO:都配成输入上拉 (从机 MISO 在发送时由 SPI 硬件自动切输出,GPIO 配输入上拉不影响)。
c
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15, GPIO_ModeIN_PU);
- SPI0_SlaveInit():从机默认初始化;
- 调库函数收发;
发送(单字节):SPI0_SlaveSendByte(0x55);
接收(单字节):i = SPI0_SlaveRecvByte();
发送(多字节):SPI0_SlaveTrans(spiBuff, 8);
接收(多字节):SPI0_SlaveRecv(spiBuffrev, 8);
发送(DMA):SPI0_SlaveDMATrans(spiBuff, 12);
接收(DMA):SPI0_SlaveDMARecv(spiBuffrev, 12);
与主机一样,示例库函数中的收发都是阻塞收发的 。
2.3 应用说明
先简单概括一下一些说明:
- EVT 示例的主机和从机都是半双工形式;
- EVT 发送和接收都是阻塞形式;
- 使用 DMA 传输会自动使用 FIFO;
- 单字节模式主机从机都支持全双工,本章节会说明;
- 从机接收也能实现全双工,(不使用 FIFO 接收,此情况没有缓存,主机发送太快会导致接收不全,示例非全双工接收使用 DMA 无此问题),本章节也会说明;
- 本章节接收都使用中断方式;
- 发送可以使用 DMA 中断方式,本章节会有说明
- 实际应用中:
单字节:建议使用全双工方式 ;
多字节:根据应用实际情况选择,中断方式和示例的阻塞方式并没有哪一种更好,只有哪一种更加合适 ,但是都要使用 FIFO 。
这里有几篇文章可以参考一下,博主下面也会测试各种方式并给出说明:
2.3.1 一些关键寄存器
这里记录一些寄存器主要是收发完成判断的寄存器,我们在应用中会需要判断收发完成以进行下一次传输:
| 名称 | 功能 | 说明 |
|---|---|---|
| RB_SPI_IF_DMA_END | DMA 完成标志位(仅SPI0 支持) | 1:已完成; 0:未完成。 ① DMA完成,不代表数据传输完成。 |
| RB_SPI_IF_CNT_END | SPI全部字节传输完成标志位 , 发送数据,可作为发送完成标志 | 1:全部字节传输完成; 0:传输未完成。 |
| RB_SPI_FREE | 当前 SPI 是否空闲状态位 , 示例中单字节收发使用此标志位判断是否完成 | 1:当前SPI空闲; 0:当前SPI非空闲 |
| R8_SPI0_FIFO_COUNT | SPI0收发FIFO计数寄存器 (只读,0~8) | FIFO 批量收发时判断"是否还能写/已有数据可读" |
关于 ① DMA完成,不代表数据传输完成 的解释,下图可以很直观的说明:

- 当作为主机使用 DMA 发送的时候: 不能只等 DMA_END 就拉高 CS:此时 FIFO 里最多还有 8 个字节没上线,硬拉 CS 会截掉尾部数据。必须等 CNT_END,把
RB_SPI_IF_CNT_END当成发送结束的判断 。 - 当作为从机使用 DMA 接收的时候,不能只等 CNT_END 就配下一块:CNT_END 只说明字节都进 FIFO 了,DMA 可能还没把最后几个搬进 RAM,必须等 DMA_END。
2.3.2 单字节全双工
全双工是指作为主机的时候,发送的同时可以接收,在 CH58x 上,单字节是可以全双工的,直接使用如下的函数:
c
uint8_t SPI0_Byte_Send(uint8_t tx)
{
R8_SPI0_BUFFER = tx;
while (!(R8_SPI0_INT_FLAG & RB_SPI_FREE));
return (R8_SPI0_BUFFER);
}
写个简单的示例测试一下,主机使用这个函数循环发送数据,同时打印发送时候收到的数据,测试代码如下:
主机端:
c
int main()
{
uint8_t i;
uint8_t send_data = 0x01;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
/* 配置串口调试 */
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
GPIOA_SetBits(GPIO_Pin_12);
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14, GPIO_ModeOut_PP_5mA);
SPI0_MasterDefInit();
while(1){
CS_LOW;
i = SPI0_Byte_Send(send_data);
CS_HIGH;
send_data++;
if(send_data > 250)send_data = 1;
PRINT("receive %x\n",i);
DelayMs(200);
}
从机端:
从机必须使用中断接收,不能在主循环里面判断标志位,会导致主机不能及时接收到从机返回的数据。不影响从机接收数据。
c
void SlaveInit(void)
{
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15, GPIO_ModeIN_PU);
SPI0_SlaveInit();
R8_SPI0_CTRL_MOD &= ~RB_SPI_FIFO_DIR; // FIFO 当发送队列(回包用)
R8_SPI0_FIFO = back_data; // 预填首字节回复
SPI0_ITCfg(ENABLE, RB_SPI_IE_BYTE_END); // 每交换完一个字节进一次中断
PFIC_EnableIRQ(SPI0_IRQn);
}
int main()
{
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
/* 配置串口调试 */
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
/* 设备模式 */
PRINT("1.spi0 mul slave mode \n");
SlaveInit();
while (1)
{
if (g_rx_flag)
{
g_rx_flag = 0;
PRINT("slave recv %x\n", g_rx);
}
}
}
__INTERRUPT
__HIGH_CODE
void SPI0_IRQHandler(void)
{
if (R8_SPI0_INT_FLAG & RB_SPI_IF_BYTE_END)
{
R8_SPI0_INT_FLAG = RB_SPI_IF_BYTE_END; // 清标志
g_rx = R8_SPI0_BUFFER; // ★趁 CS 还低,立刻读收来的字节
R8_SPI0_FIFO = back_data++;
if (back_data >= 255) back_data = 0xAA; // 回包序列
g_rx_flag = 1;
}
}
测试结果如下:

2.3.3 从机全双工接收示例
从机全双工接收的实现思路,就是把 FIFO 设置为发送,往 FIFO 写数据作为回复的数据,通过 R8_SPI0_BUFFER 获取收到的数据。
本文暂时不讨论从机的首字节为命令模式或数据流模式
在上面单字节的接收部分,已经展示了一下从机中断接收,这样可以接收不固定长度的数据,如果知道数据长度,可以使用 DMA 方式接收 。
上面是单字节收发演示,下面扩展成按 CS 边沿分帧、中断累积的不定长接收,直接上代码:
c
#include "CH58x_common.h"
volatile uint8_t g_rx = 0, g_rx_flag = 0;
volatile uint8_t back_data = 0xAA;
#define FRAME_MAX 64
volatile uint8_t g_frame[FRAME_MAX];
volatile uint16_t g_len = 0;
volatile uint8_t g_frame_done = 0;
void SlaveInit(void)
{
//和上面一样
}
int main()
{
uint8_t i;
uint8_t send_data = 0x01;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
/* 配置串口调试 */
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
/* 设备模式 */
PRINT("spi0 slave: per-frame receive demo\n");
SlaveInit();
GPIOA_ITModeCfg(GPIO_Pin_12, GPIO_ITMode_RiseEdge); // CS 上升沿判帧
PFIC_EnableIRQ(GPIO_A_IRQn);
while (1)
{
if (g_frame_done) {
g_frame_done = 0;
PRINT("RX frame len=%d: ", g_len);
for (uint16_t k = 0; k < g_len; k++)
PRINT("%02X ", g_frame[k]);
PRINT("\n");
g_len = 0;
}
}
}
__INTERRUPT
__HIGH_CODE
void SPI0_IRQHandler(void)
{
if (R8_SPI0_INT_FLAG & RB_SPI_IF_BYTE_END)
{
R8_SPI0_INT_FLAG = RB_SPI_IF_BYTE_END;
if (g_len < FRAME_MAX)
g_frame[g_len++] = R8_SPI0_BUFFER;
R8_SPI0_FIFO = back_data++;
if (back_data >= 255) back_data = 0xAA; // 回包序列
}
}
/* CS(PA12) 上升沿 = 一帧结束 */
__INTERRUPT
__HIGH_CODE
void GPIOA_IRQHandler(void) {
if(R16_PA_INT_IF & GPIO_Pin_12) {
R16_PA_INT_IF = GPIO_Pin_12; //clear it flag
if (g_len) g_frame_done = 1;
}
}
这样我们使用上面主机发送,从机打印数据如下:

再次声明,需要高速接收还是老实使用 DMA 方式(只能半双工),原因请看下文。
2.3.4 阻塞形式收发
阻塞形式参考 EVT 示例,示例中演示了:
-
单字节发送
SPI0_MasterSendByte(0x55); -
FIFO 连续发送
SPI0_MasterTrans(spiBuff, 8); -
DMA 发送
SPI0_MasterDMATrans(spiBuff, 12);
这里我们使用上面的从机全双工接收示例读取一下,如下图:

出现上面这种原因,第一想到的就是 DMA 发送速度过快,上面的接收处理方式来不及接收,因为示例使用 DMA 接收的方式可以正常的接收数据。 下面我们可以通过逻辑分析仪来验证一下我们的猜想。
2.3.5 数据发送时间
为了验证上面的测试猜想,我们使用逻辑分析仪来抓取一下示例发送的数据。
我们先看看周期:
示例主频为 62.4MHz ,SPI 时钟为主频的4分频:
SCK = 62.4 ÷ 4 = 15.6 MHz,周期 64.1 ns。
.
但是我逻辑分析仪使用的是 100MHz 的分辨率,每 10ns 一个采样点,会导致周期会有误差,这是逻辑分析仪的测量量化误差。

在这个主频为 15.6 MHz 的时候,理论上一个字节:
一个字节时间≈ 64.1 × 8 = 512.8 ns ≈ 0.513 µs
实际应用中整体开销肯定还需要考虑,开始的 CS 拉高拉低,轮询标志位等时间,我们这里主要讨论多字节传输的情况下:
- 多字节 DMA/FIFO 背靠背:每字节理论上就 ≈0.513 µs;
实际上CH58x 的SPI DMA FIFO 传输两个 byte 之间会插入一个系统时钟周期的时间 。
正是因为这种方式速度相对快,上面我们的接收会漏掉数据。 - 软件 FIFO 阻塞(MasterTrans):CPU 填 FIFO 有循环间隙,实际每字节 >0.513 µs 。
正式因为这种方式每次都需要 CPU 填数据,导致间隙比较大时钟长,我们上面才能正常接收。
验证结果如逻辑分析仪图:
DMA 发送:

FIFO 连续发送:

那测试到这里,我们知道在使用沁恒蓝牙芯片的时候,为了加快程序运行速度,可以把程序搬运到 RAM 里面跑,于是我又测试了一下在 SPI0_MasterRecv 函数前面加上 __HIGH_CODE:

结果如下,只有那么一点点改善,还是有明显的间隙:

上面的测试说明了,说明了不同方式的发送速度,也解释了我们之前从机接收的问题。
实际应用用什么方式还是需要看需求,虽然上面看起来间隔那么大,但是间隔也只有几百 ns 的时间 ,在大多数应用中,都没有太大影响。
2.3.6 非阻塞形式收发
非阻塞形式,需要使用 DMA + 中断的收发形式 。
测试 SPI 的时候,两块板子之间或者逻辑分析仪与测试板子间的共 GND 很重要! 之前串口通讯,两块板子都通过 USB 线连接在同一个 HUB 上,是没有问题的,但是 SPI 一定要单独共地。
2.3.6.1 DMA 中断发送
直接上代码:
c
#include "CH58x_common.h"
#define FRAME_LEN 256
#define CS_LOW GPIOA_ResetBits(GPIO_Pin_12)
#define CS_HIGH GPIOA_SetBits(GPIO_Pin_12)
__attribute__((aligned(4))) static uint8_t g_txBuf[FRAME_LEN]; // DMA 缓冲必须 4 字节对齐
volatile uint8_t g_tx_done = 0; // 1 = 一帧发完
void DebugInit(void)
{
//...
}
/*==============================================================================
* 非阻塞 DMA 发送:调用后立即返回,发完由 SPI0 中断把 g_tx_done 置 1
*
* @param p : 发送缓冲首地址(必须 4 字节对齐)
* @param len : 字节数,最大 4095(R16_SPI0_TOTAL_CNT 只有 12 位)
*============================================================================*/
void spi0_dma_send(uint8_t *p, uint16_t len)
{
g_tx_done = 0;
CS_LOW; // 整帧一次拉低,罩住全部字节
R8_SPI0_CTRL_MOD &= ~RB_SPI_FIFO_DIR; // FIFO 当【发送】队列:RAM → FIFO → MOSI
R32_SPI0_DMA_BEG = (uint32_t)p; // 内存起始地址(源)
R32_SPI0_DMA_END = (uint32_t)(p + len); // 内存结束地址(不含),长度 = END - BEG
R16_SPI0_TOTAL_CNT = len; // 写长度 → 启动本次传输
R8_SPI0_INT_FLAG = RB_SPI_IF_CNT_END | RB_SPI_IF_DMA_END; // 先写 1 清旧标志
R8_SPI0_INTER_EN |= RB_SPI_IE_CNT_END; // ★只开 CNT_END
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE; // 置 DMA 使能启动
}
__INTERRUPT
__HIGH_CODE
void SPI0_IRQHandler(void){
if( R8_SPI0_INT_FLAG & RB_SPI_IF_CNT_END) { //total cnt transform end
R8_SPI0_INTER_EN &= ~RB_SPI_IE_CNT_END; //disable dma
R8_SPI0_INT_FLAG = RB_SPI_IF_CNT_END; //clear flag
CS_HIGH; // 整个事务真正结束后才拉高 CS
g_tx_done = 1;
}
}
int main(void)
{
uint16_t i;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
PRINT("spi0 master: DMA + INT send, %d bytes/frame\n", FRAME_LEN);
GPIOA_SetBits(GPIO_Pin_12); // CS 默认高
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14, GPIO_ModeOut_PP_5mA);
SPI0_MasterDefInit(); // 主机默认初始化(模式0、分频4=15MHz)
PFIC_EnableIRQ(SPI0_IRQn); // ★必须开总中断,否则 ISR 永不进
for(i = 0; i < FRAME_LEN; i++)
g_txBuf[i] = (uint8_t)i; // 发 00..FF 共 256 字节
while(1)
{
spi0_dma_send(g_txBuf, FRAME_LEN); // 非阻塞启动,立刻返回
while(!g_tx_done)
{
// 中断漏了的兜底,自己写超时
}
PRINT("host send done, %d bytes\n", FRAME_LEN);
DelayMs(500); // 帧间留空隙,给从机重装 DMA
}
}
使用逻辑分析仪观察结果如下:

就如前文关键寄存器章节提到的,我们代码中把 RB_SPI_IF_CNT_END 当成发送结束的判断,确保一次数据发送完成 。
2.3.6.2 DMA 中断接收
上面完成了 DMA 中断发送,我们写一个从机中断接收的代码,配合测试一下:
c
/********************************** (C) COPYRIGHT *******************************
* File Name : Main_slave_dma_recv.c
* Description : SPI0 从机 DMA FIFO 接收 demo,固定 256 字节/帧(可靠,不丢包)
* 配套主机:Main_host_dma_int.c
*******************************************************************************/
#include "CH58x_common.h"
#define FRAME_LEN 256
__attribute__((aligned(4))) static uint8_t g_rxBuf[FRAME_LEN]; // DMA 目标缓冲需 4 字节对齐
volatile uint8_t g_rx_done = 0;
void DebugInit(void)
{
//...
}
/*==============================================================================
* 装好一次 DMA 接收:调用后立即返回,收满 len 字节由 DMA_END 中断把 g_rx_done 置 1
*
* @param p : 接收缓冲首地址(必须 4 字节对齐,FIFO_DIR=1 时它是【目的】)
* @param len : 字节数,最大 4095(R16_SPI0_TOTAL_CNT 只有 12 位)
*============================================================================*/
void My_SlaveDMARecv(uint8_t *pbuf, uint16_t len)
{
g_rx_done = 0;
R8_SPI0_CTRL_MOD |= RB_SPI_FIFO_DIR; // ★FIFO 当【接收】队列:SCK → 移位 → FIFO → DMA → RAM
R32_SPI0_DMA_BEG = (uint32_t)pbuf; // 内存起始地址(目的)
R32_SPI0_DMA_END = (uint32_t)(pbuf + len); // 内存结束地址(不含)
R16_SPI0_TOTAL_CNT = len;
R8_SPI0_INT_FLAG = RB_SPI_IF_CNT_END | RB_SPI_IF_DMA_END; // 先写 1 清旧标志
R8_SPI0_INTER_EN |= RB_SPI_IE_DMA_END; // 重新开中断(ISR 里是一次性自关)
// R8_SPI0_INTER_EN |= RB_SPI_IE_CNT_END;
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE;
}
/*==============================================================================
* SPI0 中断:从机 DMA 接收的完成标志是 DMA_END
*============================================================================*/
__INTERRUPT __HIGH_CODE
void SPI0_IRQHandler(void)
{
if(R8_SPI0_INT_FLAG & RB_SPI_IF_DMA_END) // DMA 已把一帧搬进 RAM = 收满
{
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE; // 先关 DMA
R32_SPI0_DMA_END = R32_SPI0_DMA_NOW + 0x100; // ★打破 NOW==END,否则 DMA_END 清不掉
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END; // 写 1 清标志
R8_SPI0_INTER_EN &= ~RB_SPI_IE_DMA_END; // 一次性自关,杜绝重复进入
g_rx_done = 1; // 通知主循环
}
}
int main()
{
uint16_t i;
uint32_t idle = 0;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
PRINT("spi0 slave: DMA FIFO recv, %d bytes/frame\n", FRAME_LEN);
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15, GPIO_ModeIN_PU);
SPI0_SlaveInit();
PFIC_EnableIRQ(SPI0_IRQn);
My_SlaveDMARecv(g_rxBuf,FRAME_LEN); // 第一帧
while(1)
{
if(g_rx_done)
{
g_rx_done = 0;
PRINT("RX frame len=%d: ", FRAME_LEN);
for(i = 0; i < FRAME_LEN; i++)
PRINT("%02X ", g_rxBuf[i]);
PRINT("\n");
/* 注意:这里打印 256 字节耗时约几十 ms,这段时间从机未武装;
* 主机帧间隔要大于该时间(本示例主机 DelayMs(500),足够)。 */
My_SlaveDMARecv(g_rxBuf, FRAME_LEN); // 等下一帧
idle = 0;
}
else if(++idle > 3000000ul) // ★长时间没收到:打印"到底有没有字节进来"
{
idle = 0; // moved=0 → 一个字节都没进(SCS/SCK 没到从机)
PRINT("idle: moved=%lu/%d TOTAL_CNT=%u IF=0x%02X IE=0x%02X CFG=0x%02X\n",
(unsigned long)(R32_SPI0_DMA_NOW - (uint32_t)g_rxBuf), FRAME_LEN,
R16_SPI0_TOTAL_CNT, R8_SPI0_INT_FLAG, R8_SPI0_INTER_EN, R8_SPI0_CTRL_CFG);
}
}
}
配合上面主机代码测试打印如下:

2.4 非阻塞主从通讯示例
实际的应用,主机和从机通讯,一般可能是先发送命令消息,然后再进行通讯,所以我们作为主机来说,除了发送,还得实现接收,从机除了接收也得实现发送,这就要求我们在发完以后修改 FIFO 的方向。这里我直接给一个示例参考,主机和从机都使用 DMA 中断方式。
2.4.1 主机示例
c
#include "CH58x_common.h"
#define CMD_LEN 4 // 命令/地址字节数(按你的从机协议改)
#define DATA_LEN 100 // 一次读取的字节数
#define CS_LOW GPIOA_ResetBits(GPIO_Pin_12)
#define CS_HIGH GPIOA_SetBits(GPIO_Pin_12)
__attribute__((aligned(4))) static uint8_t g_cmdBuf[CMD_LEN]; // DMA 缓冲必须 4 字节对齐
__attribute__((aligned(4))) static uint8_t g_rxBuf[DATA_LEN];
volatile uint8_t g_tx_done = 0; // 1 = 命令发完
volatile uint8_t g_rx_done = 0; // 1 = 数据收满
/* 调试串口:SPI0 默认脚与例程惯用的打印脚冲突,改用 UART1 输出 */
void DebugInit(void)
{
GPIOB_SetBits(GPIO_Pin_13);
GPIOPinRemap(ENABLE, RB_PIN_UART1);
GPIOB_ModeCfg(GPIO_Pin_12, GPIO_ModeIN_PU);
GPIOB_ModeCfg(GPIO_Pin_13, GPIO_ModeOut_PP_5mA);
UART1_DefInit();
}
/*==============================================================================
* 第一段:主动发送命令。完成看 CNT_END
*============================================================================*/
void master_send_cmd(uint8_t *p, uint16_t len)
{
g_tx_done = 0;
R8_SPI0_CTRL_MOD &= ~RB_SPI_FIFO_DIR; // 发送方向
R32_SPI0_DMA_BEG = (uint32_t)p;
R32_SPI0_DMA_END = (uint32_t)(p + len);
R16_SPI0_TOTAL_CNT = len; // 写长度即启动
R8_SPI0_INT_FLAG = RB_SPI_IF_CNT_END | RB_SPI_IF_DMA_END; // 先清旧标志
R8_SPI0_INTER_EN |= RB_SPI_IE_CNT_END; // 只等 CNT_END
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE;
}
/*==============================================================================
* 第二段:主动接收数据块。完成看 DMA_END
*============================================================================*/
void master_recv_data(uint8_t *p, uint16_t len)
{
g_rx_done = 0;
R8_SPI0_CTRL_MOD |= RB_SPI_FIFO_DIR; // 接收方向
R32_SPI0_DMA_BEG = (uint32_t)p;
R32_SPI0_DMA_END = (uint32_t)(p + len);
R16_SPI0_TOTAL_CNT = len;
R8_SPI0_INT_FLAG = RB_SPI_IF_CNT_END | RB_SPI_IF_DMA_END;
R8_SPI0_INTER_EN = RB_SPI_IE_DMA_END; // ★只开 DMA_END
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE;
}
/*==============================================================================
* SPI0 中断:按硬件方向位 FIFO_DIR 分派
*============================================================================*/
__INTERRUPT
__HIGH_CODE
void SPI0_IRQHandler(void)
{
if(R8_SPI0_INT_FLAG & RB_SPI_IF_DMA_END)
{
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE;
R32_SPI0_DMA_END = R32_SPI0_DMA_NOW + 0x100; // 打破 NOW==END,否则 DMA_END 清不掉,只需要在RB_SPI_IF_DMA_END 里面清
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END;
R8_SPI0_INTER_EN &= ~(RB_SPI_IE_DMA_END | RB_SPI_IE_CNT_END);
if(R8_SPI0_CTRL_MOD & RB_SPI_FIFO_DIR) g_rx_done = 1;
}
if(R8_SPI0_INT_FLAG & RB_SPI_IF_CNT_END)
{
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE;
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END;
R8_SPI0_INTER_EN &= ~(RB_SPI_IE_DMA_END | RB_SPI_IE_CNT_END);
if(!(R8_SPI0_CTRL_MOD & RB_SPI_FIFO_DIR)) g_tx_done = 1;
}
}
/* 等标志 + 超时诊断,绝不死等 */
static uint8_t wait_flag(volatile uint8_t *fp, const char *tag)
{
uint32_t to = 0;
while(!*fp)
{
if(++to > 4000000ul)
{
PRINT("ERR: %s timeout, FLAG=0x%02X IE=0x%02X CFG=0x%02X\n",
tag, R8_SPI0_INT_FLAG, R8_SPI0_INTER_EN, R8_SPI0_CTRL_CFG);
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE;
CS_HIGH;
return 0;
}
}
return 1;
}
int main(void)
{
uint16_t i;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
PRINT("spi0 master: DMA + INT, send cmd then recv data\n");
/* GPIO:PA12=CS(输出) PA13=SCK(输出) PA14=MOSI(输出)
* ★ 主机模式不要配置 PA15(MISO),复位默认浮空输入即主机需要的状态 */
GPIOA_SetBits(GPIO_Pin_12);
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14, GPIO_ModeOut_PP_5mA);
SPI0_MasterDefInit();
PFIC_EnableIRQ(SPI0_IRQn);
/* 命令内容按你的从机协议填,这里假设 0x03 + 24bit 地址 */
g_cmdBuf[0] = 0x03;
g_cmdBuf[1] = 0x11;
g_cmdBuf[2] = 0xFF;
g_cmdBuf[3] = 0xDD;
while(1)
{
/* ---------- 帧1:发命令(独立一次 CS)---------- */
CS_LOW;
master_send_cmd(g_cmdBuf, CMD_LEN); // 发命令,立刻返回
wait_flag(&g_tx_done, "send");
DelayMs(1); //给从机留出"解析命令 + 装好返回数据"的时间, 实际应用不需要等待,可以不需要这一步直接读取! ★ */
master_recv_data(g_rxBuf, DATA_LEN); // 收数据,立刻返回
wait_flag(&g_rx_done, "recv"); // 等 DMA_END
CS_HIGH;
PRINT("host RX: ");
for(i = 0; i < DATA_LEN; i++)
PRINT("%02X ", g_rxBuf[i]);
PRINT("\n");
DelayMs(500);
}
}
2.4.2 从机示例
c
#include "CH58x_common.h"
#define CMD_LEN 4 // 命令字节数(与主机一致)
#define DATA_LEN 100 // 返回数据字节数(与主机一致)
__attribute__((aligned(4))) static uint8_t g_cmdBuf[CMD_LEN]; // 收命令用,4 字节对齐
__attribute__((aligned(4))) static uint8_t g_txBuf[DATA_LEN]; // 发数据用,4 字节对齐
volatile uint8_t g_rx_done = 0; // 1 = 命令收满
volatile uint8_t g_tx_done = 0; // 1 = 数据发完
/* 调试串口:SPI0 默认脚与例程惯用的打印脚冲突,改用 UART1 输出 */
void DebugInit(void)
{
GPIOB_SetBits(GPIO_Pin_13);
GPIOPinRemap(ENABLE, RB_PIN_UART1);
GPIOB_ModeCfg(GPIO_Pin_12, GPIO_ModeIN_PU);
GPIOB_ModeCfg(GPIO_Pin_13, GPIO_ModeOut_PP_5mA);
UART1_DefInit();
}
/*==============================================================================
* ★顺序照官方 SPI0_SlaveDMARecv:清标志 → 开中断 → 写 TOTAL_CNT → 置 DMA_ENABLE;
* 从机【不能】对 TOTAL_CNT 先写 0 再写长度(靠主机时钟计数,写 0 会产生伪中断)。
*============================================================================*/
static void slave_arm_rx(void)
{
R8_SPI0_CTRL_MOD = RB_SPI_ALL_CLEAR; // 清 FIFO+计数,消除残字节
R8_SPI0_CTRL_MOD = RB_SPI_MISO_OE | RB_SPI_MODE_SLAVE | RB_SPI_FIFO_DIR; // 收方向
R32_SPI0_DMA_BEG = (uint32_t)g_cmdBuf;
R32_SPI0_DMA_END = (uint32_t)(g_cmdBuf + CMD_LEN);
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END; // 先清旧标志
R8_SPI0_INTER_EN = RB_SPI_IE_DMA_END; // ★收完成看 DMA_END
R16_SPI0_TOTAL_CNT = CMD_LEN;
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE; // 武装,等主机 SCK
}
/*==============================================================================
* ★从机发送完成看 CNT_END。
*============================================================================*/
static void slave_arm_tx(void)
{
R8_SPI0_CTRL_MOD = RB_SPI_ALL_CLEAR; // 清 FIFO+计数
R8_SPI0_CTRL_MOD = RB_SPI_MISO_OE | RB_SPI_MODE_SLAVE; // 发方向(FIFO_DIR=0)
R32_SPI0_DMA_BEG = (uint32_t)g_txBuf;
R32_SPI0_DMA_END = (uint32_t)(g_txBuf + DATA_LEN);
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END;
R8_SPI0_INTER_EN = RB_SPI_IE_CNT_END; // ★发完成看 CNT_END
R16_SPI0_TOTAL_CNT = DATA_LEN;
R8_SPI0_CTRL_CFG |= RB_SPI_DMA_ENABLE;
}
/*==============================================================================
* SPI0 中断:完全按硬件方向位 FIFO_DIR 分派
*============================================================================*/
__INTERRUPT
__HIGH_CODE
void SPI0_IRQHandler(void)
{
uint8_t f = R8_SPI0_INT_FLAG;
uint8_t dir = (uint8_t)(R8_SPI0_CTRL_MOD & RB_SPI_FIFO_DIR);
if(dir) // 接收段
{
if(f & RB_SPI_IF_DMA_END)
{
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE; // 关 DMA
R32_SPI0_DMA_END = R32_SPI0_DMA_NOW + 0x100; // ★打破 NOW==END,否则标志清不掉
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END;
R8_SPI0_INTER_EN &= ~(RB_SPI_IE_DMA_END | RB_SPI_IE_CNT_END);
g_rx_done = 1;
}
}
else // 发送段
{
if(f & RB_SPI_IF_CNT_END)
{
R8_SPI0_CTRL_CFG &= ~RB_SPI_DMA_ENABLE;
R8_SPI0_INT_FLAG = RB_SPI_IF_DMA_END | RB_SPI_IF_CNT_END;
R8_SPI0_INTER_EN &= ~(RB_SPI_IE_DMA_END | RB_SPI_IE_CNT_END);
g_tx_done = 1;
}
}
}
int main(void)
{
uint16_t i;
HSECFG_Capacitance(HSECap_18p);
SetSysClock(SYSCLK_FREQ);
DebugInit();
PRINT("Start @ChipID=%02X\n", R8_CHIP_ID);
PRINT("spi0 slave: DMA + INT, recv cmd then send data\n");
/* 从机四根脚全部输入上拉(都由主机驱动):
* PA12=SCS PA13=SCK PA14=MOSI PA15=MISO */
GPIOA_ModeCfg(GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15, GPIO_ModeIN_PU);
GPIOA_SetBits(GPIO_Pin_15);
GPIOA_ModeCfg(GPIO_Pin_15, GPIO_ModeOut_PP_5mA);
SPI0_SlaveInit(); // 从机默认初始化(含 MISO_OE)
PFIC_EnableIRQ(SPI0_IRQn); // ★必须开总中断
slave_arm_rx(); // 等主机发第一帧
while(1)
{
if(g_rx_done) // 命令收满
{
g_rx_done = 0; // ★消费标志
for(i = 0; i < DATA_LEN; i++) // 按命令准备返回数据
g_txBuf[i] = (uint8_t)(g_cmdBuf[0] + i);
slave_arm_tx(); // ★先切发送方向并装好,再打印(打印慢)
PRINT("slave got cmd: %02X %02X %02X %02X -> reply %02X..%02X\n",
g_cmdBuf[0], g_cmdBuf[1], g_cmdBuf[2], g_cmdBuf[3],
g_txBuf[0], g_txBuf[DATA_LEN - 1]);
}
if(g_tx_done) // 数据回完
{
g_tx_done = 0;
slave_arm_rx(); // ★先切回收方向并武装,再打印
PRINT("slave sent %d bytes\n", DATA_LEN);
}
}
}
上面主从测试结果如下:

结语
本文介绍了 CH58x SPI 的使用,因为它只有一个 FIFO ,同一时间要么给发送数据使用,要么给接收数据使用,使用起来稍微有那么一点点特殊,所以除了 EVT 示例的说明,文章还给出了一些其他的方式。在实际应用中不同的方式并不存在绝对的好与坏,只有合不合适,能不能满足需求。比如大数据块的传输,建议 DMA 方式,小数据的控制命令,单字节的更直观明了。
之后博主会对 SPI 做一些实际的应用测试,比如 SPI Flash 的通讯,SPI 屏幕驱动的应用等。
好了,本文就到这里。谢谢大家!