GD32 SPI — SPI 主从全双工轮询

GD32 GD32F30x_Firmware_Library V3.0.3 SPI SPI 主从全双工轮询 示例教学

SPI — SPI 主从全双工轮询

固件库: GD32F30x_Firmware_Library V3.0.3
芯片: GD32F30x(以 GD32F307 评估板为例)
源文件: GD32F30x_Firmware_Library_V3.0.3/Examples/SPI/SPI_master_slave_fullduplex_polling/main.c
配套例程: SPI_master_slave_fullduplex_polling


功能简介

本示例把同一块板子上的两个 SPI 外设连在一起,一个当"主机"(SPI0),一个当"从机"(SPI2),用杜邦线把它们的信号线交叉对接,让两者互相发送一数组数据,再校验收到的内容是否和对方发出去的一致。

  • SPI0 作为主机:主动产生时钟,发送 spi0_send_array[](10 字节:0xA1~0xAA),并接收从机回的数据。
  • SPI2 作为从机:跟着主机的时钟走,发送 spi2_send_array[](10 字节:0xB1~0xBA),并接收主机回的数据。
  • 因为是全双工,主机发 1 字节的同时也从从机收 1 字节;收完后用 memory_compare() 比对:
    • 从机 SPI2 收到的 == 主机发出的 → LED2 亮
    • 主机 SPI0 收到的 == 从机发出的 → LED3 亮

为什么要"自己和自己通信"?真实项目里 SPI 主机是 MCU、从机是外部芯片。但评估板上没有现成从芯片时,用另一路 SPI 当从机是验证 SPI 通信逻辑最省事的办法——你完全掌控双方时序,出问题好定位。

本示例默认 SPI_CRC_ENABLE = 0(关闭 CRC 校验),走最简单的 10 字节纯收发逻辑,最适合入门。


硬件准备

1. 开发板

  • GD32F30x 评估板一块(例程按 GD32F307C EVAL 板编写,LED2/LED3 由 gd_eval_led_init 初始化)。

2. 杜邦线(关键!)

把板上的 SPI0 和 SPI2 用线交叉对接。SPI2 例程里做了引脚重映射(GPIO_SPI2_REMAP),所以 SPI2 用的是 PC 口。具体接法:

主机 SPI0 从机 SPI2(重映射后) 说明
PA3 PA4 NSS(片选):主机用 PA3 当软件片选,去选从机的 NSS(PA4)
PA5 (SCK) PC10 (SCK) 时钟
PA6 (MISO) PC11 (MOSI) 主机收 = 从机发
PA7 (MOSI) PC12 (MISO) 主机发 = 从机收

注意 MISO / MOSI 的接法:主机 MOSI(PA7) 接从机"数据输入脚"——在重映射后从机 SPI2 的数据输入脚是 PC12(例程把它配成了 AF_PP 输出)。主机的 MISO(PA6) 接从机的数据输出脚 PC11(配成 IN_FLOATING 输入)。一句话:把"主发"对"从收",“主收"对"从发”

3. 串口 / 调试器(可选)

本例用 LED 指示结果,不需要串口。想看具体收发数组可用调试器看 spi0_receive_array[] / spi2_receive_array[]


完整代码(带中文注释)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
#include "gd32f30x.h"
#include "gd32f307c_eval.h"

/* 是否开启 CRC 校验,本示例教学默认关闭 */
#define SPI_CRC_ENABLE             0
/* 收发数组长度 */
#define ARRAYSIZE                  10

/* 用宏控制主机片选 NSS(PA3):拉高=取消选中,拉低=选中从机 */
#define SET_SPI0_NSS_HIGH          gpio_bit_set(GPIOA, GPIO_PIN_3);
#define SET_SPI0_NSS_LOW           gpio_bit_reset(GPIOA, GPIO_PIN_3);

/* 待发送数据:主机 SPI0 发 0xA1~0xAA,从机 SPI2 发 0xB1~0xBA */
uint8_t spi0_send_array[ARRAYSIZE] = {0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA};
uint8_t spi2_send_array[ARRAYSIZE] = {0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA};
/* 接收缓冲区 */
uint8_t spi0_receive_array[ARRAYSIZE];
uint8_t spi2_receive_array[ARRAYSIZE];

/* 函数声明 */
void rcu_config(void);
void gpio_config(void);
void spi_config(void);
ErrStatus memory_compare(uint8_t* psrc, uint8_t* pdst, uint16_t length);

/*!
    \brief      main function
*/
int main(void)
{
    /* 收发/接收计数 */
    uint8_t send_n = 0, receive_n = 0;

    /* 初始化 LED2 和 LED3,用于指示收发是否一致 */
    gd_eval_led_init(LED2);
    gd_eval_led_init(LED3);

    /* 外设时钟使能 */
    rcu_config();
    /* GPIO 复用功能配置 */
    gpio_config();
    /* SPI 参数配置 */
    spi_config();

    /* 先拉高 NSS,确保从机未被选中 */
    SET_SPI0_NSS_HIGH

    /* 使能两个 SPI(注意:先使能从机,再使能主机也行,顺序不强制) */
    spi_enable(SPI2);
    spi_enable(SPI0);

    /* 拉低 NSS,正式选中从机,开始通信 */
    SET_SPI0_NSS_LOW

    /* ---------- 关闭 CRC 时的主逻辑 ---------- */
#if SPI_CRC_ENABLE
    /* (此处为 CRC 分支,略) */
#else
    /* 一字节一字节地收发:主机和从机同时各发各的、各收各的 */
    while(send_n < ARRAYSIZE){
        /* 等从机发送缓冲空,塞入从机要发的数据 */
        while(RESET == spi_i2s_flag_get(SPI2, SPI_FLAG_TBE));
        spi_i2s_data_transmit(SPI2, spi2_send_array[send_n]);
        /* 等主机发送缓冲空,塞入主机要发的数据 */
        while(RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_TBE));
        spi_i2s_data_transmit(SPI0, spi0_send_array[send_n++]);

        /* 等从机收到数据,读走 */
        while(RESET == spi_i2s_flag_get(SPI2, SPI_FLAG_RBNE));
        spi2_receive_array[receive_n] = spi_i2s_data_receive(SPI2);
        /* 等主机收到数据,读走 */
        while(RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_RBNE));
        spi0_receive_array[receive_n++] = spi_i2s_data_receive(SPI0);
    }

    /* 全部发完,拉高 NSS,结束通信 */
    SET_SPI0_NSS_HIGH

    /* 校验:从机收到的 应等于 主机发出的 */
    if(memory_compare(spi2_receive_array, spi0_send_array, ARRAYSIZE)) {
        gd_eval_led_on(LED2);      /* 一致 → LED2 亮 */
    } else {
        gd_eval_led_off(LED2);
    }

    /* 校验:主机收到的 应等于 从机发出的 */
    if(memory_compare(spi0_receive_array, spi2_send_array, ARRAYSIZE)) {
        gd_eval_led_on(LED3);      /* 一致 → LED3 亮 */
    } else {
        gd_eval_led_off(LED3);
    }
#endif /* enable CRC function */

    /* 死循环,停在这里方便观察 LED */
    while(1);
}

/*!
    \brief      时钟配置:打开用到的外设时钟
*/
void rcu_config(void)
{
    rcu_periph_clock_enable(RCU_GPIOA);   /* SPI0 引脚在 PA 口 */
    rcu_periph_clock_enable(RCU_GPIOC);   /* 重映射后的 SPI2 引脚在 PC 口 */
    rcu_periph_clock_enable(RCU_SPI0);    /* 主机 SPI0 */
    rcu_periph_clock_enable(RCU_SPI2);    /* 从机 SPI2 */
    rcu_periph_clock_enable(RCU_AF);      /* 引脚重映射必须开 AF 时钟 */
}

/*!
    \brief      GPIO 配置
*/
void gpio_config(void)
{
    /* SPI0(主机)引脚:SCK/PA5、MOSI/PA7 设为复用推挽输出;MISO/PA6 浮空输入 */
    gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_7);
    gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6);
    /* 主机片选 NSS 用普通 GPIO(PA3) 软件控制,推挽输出 */
    gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_3);

    /* 开启 SPI2 引脚重映射,重映射后 SPI2 用 PC10/11/12 */
    gpio_pin_remap_config(GPIO_SPI2_REMAP, ENABLE);
    /* SPI2(从机)引脚:SCK/PC10、MOSI/PC12 浮空输入(这些是"从机的数据输入脚") */
    gpio_init(GPIOC, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10 | GPIO_PIN_12);
    /* 从机数据输出脚 MISO/PC11 设为复用推挽 */
    gpio_init(GPIOC, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_11);
    /* 从机 NSS(PA4) 浮空输入,由主机 PA3 拉低来选中 */
    gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_4);
}

/*!
    \brief      SPI 参数配置
*/
void spi_config(void)
{
    spi_parameter_struct spi_init_struct;

    /* ---- SPI0(主机)参数 ---- */
    spi_init_struct.trans_mode           = SPI_TRANSMODE_FULLDUPLEX;  /* 全双工 */
    spi_init_struct.device_mode          = SPI_MASTER;               /* 主机 */
    spi_init_struct.frame_size           = SPI_FRAMESIZE_8BIT;        /* 8 位一帧 */
    spi_init_struct.clock_polarity_phase = SPI_CK_PL_LOW_PH_1EDGE;   /* 模式0:CPOL=0,CPHA=0 */
    spi_init_struct.nss                  = SPI_NSS_SOFT;             /* 软件控制 NSS */
    spi_init_struct.prescale             = SPI_PSC_256;              /* 分频256,降速便于观察 */
    spi_init_struct.endian               = SPI_ENDIAN_MSB;           /* 高位先传 */
    spi_init(SPI0, &spi_init_struct);

    /* ---- SPI2(从机)参数 ---- */
    /* 复用同一结构体,只改两项:从机模式 + 硬件 NSS */
    spi_init_struct.device_mode = SPI_SLAVE;
    spi_init_struct.nss         = SPI_NSS_HARD;   /* 从机用硬件 NSS:NSS 被拉低才工作 */
    spi_init(SPI2, &spi_init_struct);
}

/*!
    \brief      比较两个数组是否完全一致
*/
ErrStatus memory_compare(uint8_t* psrc, uint8_t* pdst, uint16_t length)
{
    while(length--){
        if(*psrc != *pdst){
            return ERROR;   /* 有任意一字节不同就返回错误 */
        }
        psrc++;
        pdst++;
    }
    return SUCCESS;
}

代码分段讲解

1. 宏与全局数组

1
2
3
4
#define SPI_CRC_ENABLE  0
#define ARRAYSIZE       10
#define SET_SPI0_NSS_HIGH  gpio_bit_set(GPIOA, GPIO_PIN_3);
#define SET_SPI0_NSS_LOW   gpio_bit_reset(GPIOA, GPIO_PIN_3);
  • SPI_CRC_ENABLE 控制是否启用 CRC 校验,教学版设为 0,走简单分支。
  • SET_SPI0_NSS_* 把"片选拉高/拉低"封装成宏。SPI 通信必须先拉低 NSS 选中从机,结束后拉高
  • 两个发送数组:主机发 0xA1~0xAA,从机发 0xB1~0xBA。它们内容不同,正好用来验证"收到的确实是对方发的"。

2. 时钟与 GPIO 配置

SPI 的引脚是复用功能(AF),不是普通 GPIO,所以:

  • SPI0 的 SCK/MOSI 配成 GPIO_MODE_AF_PP(复用推挽);MISO 配 IN_FLOATING(浮空输入,由对方驱动)。
  • gpio_pin_remap_config(GPIO_SPI2_REMAP, ENABLE) 把 SPI2 从默认引脚挪到 PC10/11/12,重映射必须开 RCU_AF 时钟,这一点常被忘记。
  • 主机 NSS(PA3) 只是个普通输出脚,由软件拉高拉低,所以配 OUT_PP
  • 从机 NSS(PA4) 配 IN_FLOATING,因为它被配成了 SPI_NSS_HARD——由 SPI 硬件自动检测这个引脚是否被拉低。

为什么主机的 MOSI(PA7) 接的是从机的 PC12(配成 AF_PP),而主机的 MISO(PA6) 接的是从机的 PC11(配成 IN_FLOATING)? 因为对"从机 SPI2"来说,PC12 是它的数据输入(DI)、PC11 是它的数据输出(DO)。主机 MOSI(主发)对接从机 DI,主机 MISO(主收)对接从机 DO。名字叫 MOSI/MISO 是站在主机视角;接到从机时要看从机那端是 DI 还是 DO。

3. SPI 参数配置

1
2
spi_init_struct.clock_polarity_phase = SPI_CK_PL_LOW_PH_1EDGE;  // 模式 0
spi_init_struct.prescale             = SPI_PSC_256;            // 大幅分频
  • 主从必须使用相同的 CPOL/CPHA(模式),否则采样错位、数据全错。本例两者都用同一结构体初始化,天然一致。
  • SPI_PSC_256 把 SPI 时钟分得极低(APB 时钟 / 256),仅为方便观察;真实项目里在从机允许范围内尽量取高分频值以提高速率。
  • 主机 SPI_NSS_SOFT,从机 SPI_NSS_HARD:这是 SPI 主从对接的常见搭配——主机用任意 GPIO 当片选,从机用硬件 NSS 引脚感知是否被选中。

4. 主循环:收发成对出现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
while(send_n < ARRAYSIZE){
    while(RESET == spi_i2s_flag_get(SPI2, SPI_FLAG_TBE));
    spi_i2s_data_transmit(SPI2, spi2_send_array[send_n]);
    while(RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_TBE));
    spi_i2s_data_transmit(SPI0, spi0_send_array[send_n++]);

    while(RESET == spi_i2s_flag_get(SPI2, SPI_FLAG_RBNE));
    spi2_receive_array[receive_n] = spi_i2s_data_receive(SPI2);
    while(RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_RBNE));
    spi0_receive_array[receive_n++] = spi_i2s_data_receive(SPI0);
}

这是 SPI 全双工的本质体现:

  1. 等两个 SPI 的 TBE(发送缓冲空),分别把各自要发的数据写进发送寄存器。主机写数据的同时会产生 8 个 SCK 时钟,从机跟着这个时钟把它的数据也移位出来。
  2. 等两个 SPI 的 RBNE(接收缓冲非空),把各自收到的数据读走。
  3. 因为一帧 8 位里主机发 1 字节、从机也回 1 字节,所以"发"和"收"永远成对,循环里先 TBE 后 RBNE 各两行。

为什么从机也要等 TBE 才发? 从机没有自己的时钟,它的发送也是靠主机提供的 SCK 来移位。主机每发一个字节就产生 8 个时钟,从机借这 8 个时钟把自己的发送寄存器移出去、把主机的移进来。所以只要主机在发,从机就能收发,二者节奏完全由主机时钟同步。

5. 结束与校验

1
2
3
4
5
SET_SPI0_NSS_HIGH                      // 拉高 NSS,通信结束
if(memory_compare(spi2_receive_array, spi0_send_array, ARRAYSIZE))
    gd_eval_led_on(LED2);              // 从机收对 → LED2
if(memory_compare(spi0_receive_array, spi2_send_array, ARRAYSIZE))
    gd_eval_led_on(LED3);              // 主机收对 → LED3

拉高 NSS 告诉从机"别再通信了"。然后用逐字节比较判断互收是否成功。两个 LED 都亮代表主从收发完全正确。


实验现象

  1. 下载程序并复位后,接线正确的前提下:
    • LED2 亮:表示 SPI2 从机收到的数据 == 主机 SPI0 发出的 0xA1~0xAA
    • LED3 亮:表示 SPI0 主机收到的数据 == 从机 SPI2 发出的 0xB1~0xBA
  2. 两个 LED 都亮 = 全双工收发成功。
  3. 若用调试器暂停,可观察 spi2_receive_array[] 应为 {0xA1..0xAA}spi0_receive_array[] 应为 {0xB1..0xBA}

因为是"同一块板子内部两个 SPI 互连",只要接线对、配置对,几乎必然成功,非常适合拿来建立 SPI 的直观认识。


常见问题

Q1:两个 LED 都不亮 / 只有一个亮,收的数据对不上?
最常见原因是接线接反了。重点检查:主机 MOSI(PA7) 必须接从机的数据输入脚(重映射后是 PC12),主机 MISO(PA6) 必须接从机的数据输出脚(PC11)。把 MOSI/MISO 当成"同名直连"是最常见的错接。

Q2:主从的 CPOL/CPHA 要怎么配?
必须完全一致。本例主从都用 SPI_CK_PL_LOW_PH_1EDGE(模式 0)。若你后续接外部芯片(如 W25Q Flash),要先查手册它的 SPI 模式是多少,把 MCU 配成同一个。

Q3:为什么从机用 SPI_NSS_HARD,主机用 SPI_NSS_SOFT?
这是 SPI 主从的常规搭配。主机随便挑一个 GPIO(本例 PA3)用软件拉高拉低当片选;从机的 NSS 引脚(PA4)被硬件监控——只有它被拉低,从机才认为"被选中"并开始通信。若从机也用软件 NSS,则它不会去检测外部 PA4 电平。

Q4:分频 SPI_PSC_256 这么慢,能调快吗?
可以。256 只是为了教学便于观察。真实项目中在从机允许的最高速率内尽量取小分频以提高吞吐。注意 SPI2 在 APB1 上、SPI0 在 APB2 上,二者挂载总线不同,但本例主从分频一致即可。

Q5:为什么我接了线还是不通,但仿真看发送数组没问题?
检查三件事:① RCU_AF 时钟是否打开(用到重映射必须开);② gpio_pin_remap_config(GPIO_SPI2_REMAP, ENABLE) 是否调用;③ NSS 是否先在 spi_enable 之前/之后被拉低。SPI 必须在 NSS 为低期间通信。

Q6:开启 CRC(SPI_CRC_ENABLE = 1)后逻辑有什么不同?
开启后,循环里会先发前 9 个字节,最后再各发 1 个字节并追加发送 CRC 值,然后双方各收 1 个数据字节 + 1 个 CRC 字节,最后读 SPI_FLAG_CRCERR 标志判断 CRC 是否出错。CRC 用于在噪声环境下检测数据对错,教学阶段建议先关掉理解基础收发。


小结

本示例完整展示了 SPI 主从全双工轮询的核心套路:

  1. 开时钟RCU_GPIOx + RCU_SPIx + 用到重映射时的 RCU_AF
  2. 配 GPIO:SCK/MOSI 复用推挽,MISO 浮空输入;NSS 主机用普通 GPIO。
  3. 配 SPI 参数:模式(全双工/主从)、帧长、CPOL/CPHA、分频、NSS 软/硬、MSB/LSB——主从模式必须一致
  4. 拉低 NSS → 轮询 TBE 发、轮询 RBNE 收(成对)→ 拉高 NSS
  5. 校验:比较收/发数组,用 LED 等直观指示结果。

记住 SPI 的"三件套"——四线、全双工、主从同模式,你就掌握了它的灵魂。


拓展应用

掌握这个基础例程后,你可以往这些方向走:

  • 驱动 SPI Flash(如 W25Q64 / W25Q128):这是 SPI 最经典的应用。把例程里的从机换成 Flash 芯片,按芯片手册的指令集(写使能→擦除→页编程→读)就能做出"掉电不丢数据"的存储模块。
  • 驱动 LCD / OLED 屏(如 ST7735、SSD1306 用 SPI 接口):用 SPI 高速刷屏,比用 GPIO 模拟快得多。
  • 驱动 ADC / DAC(如 ADS1256、MCP4822):用 SPI 读取高精度采样值或输出模拟电压。
  • 接 SD 卡 / TF 卡:在 SPI 模式下读写文件系统(配合 FatFs)。
  • 把轮询升级为 DMA:当数据量大(如连续采 1024 点波形、刷整屏图像)时,改用 SPI_master_slave_fullduplex_dma 例程,让数据自动搬运、CPU 去干别的事。
  • 一主多从:保留一个主机 SPI,用多根 NSS 分别选通多个从机(Flash、LCD、传感器),实现单总线扩展多个外设。

想进一步提速或减轻 CPU 负担,建议直接阅读同目录的 SPI_master_slave_fullduplex_dma 例程。

世界是你们
使用 Hugo 构建
主题 StackJimmy 设计