简介面向GD32F30x系列嵌入式开发者的SPI单线双向模式例程包重点解决单数据线半双工通信下的引脚节省与收发时序控制问题适合需要熟悉GD32 SPI特殊工作模式的软硬件工程师。压缩包共132个文件以61个H头文件和50个C源码文件为主同时包含启动汇编、HEX/BIN固件、Keil工程配置、链接脚本等整体仅303KB结构紧凑便于对照学习。已有1308人学习下载。资源提供了完整的SPI单线双向模式初始化与收发实现涵盖时钟分频、模式寄存器配置、片选管理以及中断/DMA传输思路可直接导入工程验证也可作为定制外设通信的参考模板能显著缩短GD32F30x SPI特殊模式的上手时间。 做嵌入式开发这些年用GD32F30x做项目时遇到过不少看似小众、真到用的时候又很关键的功能SPI的单线双向模式就是其中一个。简单说这种模式只用一根数据线完成半双工通信标准四线SPI里的MOSI引脚会被复用成双向数据线MISO引脚直接释放出来干别的。很多从设备芯片只支持单线协议或者板子上引脚实在紧张这时候单线双向模式就能派上用场。这篇文章我会围绕GD32F30x的SPI单线双向模式把硬件接线、寄存器配置、收发方向切换的完整流程和实测中踩过的坑都整理出来给正在调SPI通信的同行做个参考。最早接触这个功能是在一个传感器采集板上。从设备是某颗温湿度传感器数据手册上明确写着只支持单线SPI读写标准四线模式根本不响应。当时第一反应是拿GPIO模拟时序但模拟SPI要频繁翻转引脚、卡时序代码写起来又长又容易受中断影响。后来翻了GD32F30x参考手册才发现SPI外设本身就支持单线双向模式只需要把控制寄存器里对应的位打开硬件自动完成收发根本不用自己模拟。这也是我写这篇文章的初衷很多功能手册里写得比较简略实际用起来门道不少把关键细节串起来能省很多调试时间。1. 单线双向模式到底是什么为什么需要它1.1 从标准四线SPI说起标准SPI接口通常有四根线SCK时钟、MOSI主机输出从机输入、MISO主机输入从机输出、CS片选。这种模式下主机和从机可以同时收发数据也就是全双工通信一次时钟周期内MOSI和MISO各传一位互不干扰。单线双向模式就完全不同了。它把MOSI引脚变成一根双向数据线同一时刻这根线上要么发数据、要么收数据不能同时进行属于半双工通信。MISO引脚在这种模式下不再参与数据传输可以配置成普通GPIO去控制别的外设。GD32F30x的SPI模块里控制这个行为的是SPI_CTL0寄存器中的BDOEN位置1之后进入双向模式数据方向由BDOE位决定BDOE为1时是发送方向BDOE为0时是接收方向。从全双工降到半双工通信速率理论上会打对折因为收发不能同时进行。但换来的是节省一根数据线在很多引脚紧张或者从设备本身只支持单线协议的场景里这个取舍非常划算。1.2 单线双向和I2C、UART这类总线有啥区别很多初学者会把SPI单线双向模式和I2C搞混因为两者都只有一根数据线。但底层逻辑差别很大SPI单线双向模式依然有独立的SCK时钟线数据线只是复用了一根收发节奏完全由主机时钟控制属于同步通信。I2C的数据线SDA和时钟线SCL都是开漏结构靠外部上拉电阻实现线与逻辑设备地址寻址每个设备有唯一地址。UART是异步通信没有时钟线收发两根线各自独立双方要提前约定好波特率。SPI单线双向模式本质上是“同步半双工”通信双方靠SCK同步数据线只有一根。正因为有时钟线存在它对时序的要求比UART严格但比I2C简单直接不需要设备地址片选线一拉低通信就开始了。1.3 什么场景下值得用单线双向模式在实际项目里我总结下来主要有三类场景会主动选择这个模式第一类从设备本身就只支持单线SPI。比如某些传感器、小容量EEPROM、特定型号的LCD驱动芯片数据手册里清清楚楚写着只有一根数据引脚。这时候要么用GPIO模拟要么用SPI外设的单线双向模式后者明显更省心。第二类板子引脚资源紧张。一个SPI接口如果只用单线双向模式MISO引脚就能省下来给其他功能用比如多挂一路UART、多接一个按键检测、甚至做LED控制。在QFN封装或者小尺寸PCB上多一个可用引脚往往意味着布线成本大幅下降。第三类需要做信号隔离的场景。SPI通信在工业环境里经常要加光耦或者数字隔离芯片每根信号线都要一个隔离通道。用单线双向模式可以把数据线从两根减到一根隔离器件数量直接少掉三分之一板子面积和成本都能降下来。2. 硬件连接与初始化参数配置2.1 接线方式这步错了后面全白搭GD32F30x的单线双向模式启用后数据线固定在MOSI引脚上。如果从设备是标准的四线SPI接口但你想用单线模式跟它通信需要把从设备的MOSI和MISO引脚短接在一起然后接到主机的MOSI引脚上。这里有个容易忽略的细节短接之后从设备在全双工模式下原本分开的发送和接收路径就合并到了一根线上。如果从设备不具备真正的单线通信能力只是强行把两根线短接那么主机发送时候从设备的MISO输出可能会和主机的MOSI输出打架轻则数据错误重则损坏引脚。所以用这个模式之前务必确认从设备确实支持单线协议或者确认从设备在某种配置下会把MISO置为高阻态。短接前最好拿示波器看一下从设备MISO引脚在空闲时是否输出如果是推挽输出就不要强行短接。供电和上拉也要注意。数据线空闲状态建议通过一个10kΩ电阻上拉到VCC保证在没有驱动的时候电平稳定避免误触发。SCK引脚尽量不要悬空主机不通信时把SCK设置为确定电平取决于CPOL配置从设备才不会产生误采样。2.2 GPIO初始化复用推挽还是开漏直接决定通信稳定性GPIO配置这块我踩过一次实实在在的坑。GD32F30x的SPI0默认引脚是PA4NSS、PA5SCK、PA6MISO、PA7MOSI使用单线双向模式时PA7被用作双向数据线。初始化GPIO时最稳妥的做法是把PA7配置为复用推挽输出模式同时内部使能上拉。但这里有个悖论推挽输出在发送方向没问题一旦切到接收方向如果外部从设备也是推挽输出两条推挽输出对顶就可能出现短路风险。所以更推荐的做法是把PA7配置为复用开漏输出外部接上拉电阻。开漏结构本身不主动输出高电平靠上拉电阻拉高这样无论哪一端在驱动低电平都不会出现两个强驱动源对顶的情况。SCK引脚保持标准复用推挽输出即可CS片选引脚看你的控制方式如果用软件片选把它配置成普通推挽输出GPIO就行。MISO引脚在单线模式下不再参与SPI通信建议配置为浮空输入避免悬空引脚引入额外干扰。2.3 时钟极性和相位按从设备手册来不要想当然SPI通信里四个模式由时钟极性CPOL和时钟相位CPHA决定GD32F30x的寄存器里对应CKPL和CKPH两个位。CPOL决定空闲时SCK是高还是低CPHA决定数据在SCK的上升沿还是下降沿被采样。这两位的组合必须和从设备的要求完全匹配否则数据全是乱的。我在调试时习惯直接用最慢的时钟和模式0CPOL0、CPHA0先验证通路因为绝大多数从设备都支持模式0。如果从设备手册明确写了只支持模式1或者模式3那就老老实实按手册配置不要靠猜。实在不确定可以先用逻辑分析仪抓一下从设备在标准模式下输出的波形看数据在哪个沿变化、哪个沿稳定再反推CPOL和CPHA。分频系数PSC的选择也值得一提。GD32F30x的SPI时钟源来自APB总线PSC寄存器是2的幂次分频可选2、4、8、16、32、64、128、256这八个档位。比如外设时钟是120MHz想得到1MHz左右的SPI时钟120分频不在档位上只能选128分频得到约937.5kHz或者选64分频得到1.875MHz。我的习惯是先选低速率档位128分频调通功能再逐步提高分频档位逼近从设备的极限速率图的就是稳。3. 收发实现与方向切换的完整流程3.1 初始化代码BDOEN位打开的时机初始化SPI单线双向模式和标准SPI差别不大关键在于寄存器写入顺序。我先给一段基于寄存器操作的参考代码void spi1_bi_dir_init(void) { /* 使能GPIOA和SPI1时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_SPI1); /* PA5-SCK推挽复用PA6-MISO浮空输入PA7-MOSI开漏复用 */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6); gpio_init(GPIOA, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, GPIO_PIN_7); /* 软件片选PA4作为普通GPIO输出 */ gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_4); gpio_bit_set(GPIOA, GPIO_PIN_4); /* SPI1复位保证从干净状态开始 */ spi_disable(SPI1); spi_parameter_struct spi_init_struct; spi_struct_para_init(spi_init_struct); spi_init_struct.trans_mode SPI_TRANSMODE_BDTRANSMIT; /* 单向发送还是接收视情况调整 */ spi_init_struct.device_mode SPI_MASTER; spi_init_struct.frame_size SPI_FRAMESIZE_8BIT; spi_init_struct.clock_polarity_phase SPI_CK_PL_HIGH_PH_2EDGE; spi_init_struct.nss SPI_SOFT_NSS; spi_init_struct.prescale SPI_PSC_128; spi_init_struct.endian SPI_ENDIAN_MSB; spi_init(SPI1, spi_init_struct); /* 使能双向模式 */ SPI_CTL0(SPI1) | SPI_CTL0_BDOEN; /* 默认设置为发送方向 */ SPI_CTL0(SPI1) | SPI_CTL0_BDOE; /* 最后使能SPI */ spi_enable(SPI1); }这段代码里有几个关键点一个是MISO引脚直接配置成浮空输入因为单线模式下它用不到另一个是PA7配成开漏复用配合外部上拉电阻避免方向切换时总线冲突。BDOEN位必须在SPI使能之前配置好这一点手册里写得明确实际操作时容易被遗漏。如果你用的是厂商标准外设库库函数里通常也会有对应的双向模式配置项但底层寄存器操作逻辑是一致的核心就是CTL0寄存器里BDOEN和BDOE这两位。3.2 发送方向不是写完数据寄存器就完事单线双向模式的发送和标准SPI发送在代码上很相似但有一个额外步骤确保BDOE位处于发送方向。发送的时候把要发送的字节写入数据寄存器硬件会自动产生SCK时钟并一位一位移出去。void spi1_bd_send(uint8_t data) { /* 确保处于发送方向 */ SPI_CTL0(SPI1) | SPI_CTL0_BDOE; /* 等待发送缓冲区为空 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_TBE) RESET); /* 发送数据 */ spi_i2s_data_transmit(SPI1, data); /* 等待发送完成 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_TRANS) RESET); /* 等待总线不忙确保最后一个位真正移出 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_BSY) SET); }这里必须等BSY标志位清零再返回。很多初学者只看TBE标志TBE表示数据已经从数据寄存器搬到了移位寄存器但移位寄存器还要靠SCK时钟一位一位送出去。如果这时候立刻切换方向数据线上可能还有数据没发完一切换方向总线状态就乱了。实测中遇到过一次发出去的数据最后一个字节总是不对排查半天发现就是没等BSY。3.3 接收方向先“假写”一次产生时钟接收方向是单线双向模式里最容易卡住的地方。配置好BDOE为0之后你可能会认为SPI硬件就会自动产生时钟开始接收但实际并不是这样。GD32F30x的SPI外设需要主机主动写入数据寄存器才能触发SCK时钟产生哪怕你并不想发送任何有效数据。这就是我前面说的“假写”操作。uint8_t spi1_bd_receive(void) { /* 切换为接收方向 */ SPI_CTL0(SPI1) ~SPI_CTL0_BDOE; /* 等待发送缓冲区为空然后假写一个字节产生时钟 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_TBE) RESET); spi_i2s_data_transmit(SPI1, 0xFF); /* 等待接收缓冲区非空 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_RBNE) RESET); return spi_i2s_data_receive(SPI1); }这个假写的字节内容通常无所谓推荐写0xFF因为从设备在8个SCK周期内会把自己的数据放到数据线上主机采样到的内容会覆盖掉假写的数据。第一次用这个模式的人十有八九会漏掉这一步我调试那会儿就对着数据手册发了好久的呆最后才发现问题出在“不发送就没有时钟”这个机制上。3.4 方向切换的完整时序顺序错了数据就全乱了单线双向模式最讲究的就是方向切换的时机。主机发送完一组数据想立刻切到接收方向去读从设备的响应中间必须留出足够的时间让从设备准备好。从设备收到命令后通常需要一点处理时间如果主机马上就切接收方向、马上产生时钟读数据从设备可能还没把响应数据放到线上读回来全是0xFF。我通常的做法是发送结束后等待BSY清空然后根据需要延时几百微秒具体时间看从设备手册再切换BDOE为接收方向。如果多次切换后出现通信错乱还可以尝试在方向切换后重新拉高再拉低一次CS片选强制从设备复位通信状态机。void spi1_bd_switch_to_receive(void) { /* 等总线完全空闲 */ while (spi_i2s_flag_get(SPI1, SPI_FLAG_BSY) SET); /* 小延时给从设备反应时间 */ delay_us(50); /* 切换方向 */ SPI_CTL0(SPI1) ~SPI_CTL0_BDOE; }这个50微秒是我调试过的几个从设备里面比较通用的值实际项目里要根据从设备数据手册的响应时间调整。有些高速设备只需要几微秒有些慢速设备要等几百微秒宁多勿少等太久只是影响一点吞吐量等太短数据就会错。3.5 硬件片选和软件片选怎么选很多人在SPI通信里纠结用硬件NSS还是软件片选。GD32F30x的SPI支持NSS硬件自动管理也支持完全由软件控制CS引脚。我的建议是单线双向模式下尽量用软件片选。原因很简单单线双向模式下的收发流程比标准模式复杂接收时要假写、发送时要等BSY、方向要切换这些操作很难和硬件NSS的自动时序完美配合。如果硬件NSS在主机没有操作时自动拉高从设备可能提前结束一次通信。软件片选把CS的控制权完全握在手里想什么时候拉低就什么时候拉低想拉低多久就多久调试起来直观很多。#define SPI1_CS_LOW() gpio_bit_reset(GPIOA, GPIO_PIN_4) #define SPI1_CS_HIGH() gpio_bit_set(GPIOA, GPIO_PIN_4)软件片选唯一要注意的是在方向切换期间尽量不要操作CS避免从设备把方向切换的中间状态误判为通信结束。我的代码里方向切换一般放在CS保持有效的区间内完成。4. 实测中常见的坑与排查记录4.1 收回来全是0xFF多半是假写没做如果主机读到的数据永远是0xFF先别急着怀疑从设备坏了。单线双向模式下最常见的原因就是接收前没有通过写数据寄存器产生SCK时钟。没有时钟从设备根本没有机会把数据放到数据线上主机读到的自然就是高电平代表的0xFF。排查方法很简单用示波器或者逻辑分析仪抓SCK引脚看看接收阶段有没有时钟波形。没有波形就检查代码里接收函数有没有执行假写操作有波形但数据还是全1就去查从设备供电和片选电平。4.2 数据错位、首字节丢失大概率是时钟极性问题接收到的数据每一位都是反的或者整体往左往右移了一位基本可以判定是CPOL和CPHA配置不对。GD32F30x的标准外设库可以用一行配置切换模式从模式0改到模式1或者模式3然后重新抓波形对比。还有一种情况是分频系数太高导致时钟频率超过从设备极限从设备采样不稳定表现出来就是偶发性的数据错位。这种情况把SPI时钟降下来比如从2分频改成8分频看问题是否消失就能定位到是否速率问题。4.3 数据线电平异常、芯片发烫检查开漏和上拉如果调试过程中发现数据线上的电平不是清晰的高和低而是有中间电平或者芯片有轻微发烫八成是数据线两边都是推挽输出对顶了。主机在发送方向时是开漏输出还好如果配成了推挽输出同时从设备的MISO又是推挽输出两根强驱动线短接在一起就会出现问题。解决办法是把主机数据线改成开漏复用输出外部加上拉电阻从设备那边如果没办法改配置就需要确认从设备MISO在不发送时是否是高阻态。4.4 排查工具优先用逻辑分析仪而不是示波器调SPI这种同步通信逻辑分析仪比示波器好用太多。示波器适合看信号质量、边沿陡峭度、过冲振铃但分析通信协议内容的时候逻辑分析仪直接解码出十六进制数据一眼就能看出哪个字节是对的、哪个字节丢了。我手头那台十几块钱的逻辑分析仪就够用采样率不需要太高SPI时钟几MHz以内都能轻松抓到。抓波形的时候把SCK、数据线、CS三根线都接上触发条件设成CS下降沿这样每次通信的完整过程都能记录下来排查效率非常高。5. 一点个人体会GD32F30x的SPI单线双向模式不是什么新鲜功能但在实际项目里能帮上大忙。它的核心魅力在于把一根数据线用到极致既能省引脚又能适配只支持单线协议的从设备代价是需要自己认真对待方向切换这件事。最后再分享一个小技巧第一次调通这种模式时别急着上高速率和复杂协议先用最低分频、固定发一个字节、读回一个字节的方式把通路验证清楚再逐步加功能。这个习惯帮我避开了很多莫名其妙的问题也希望能帮到你。本文还有配套的精品资源点击获取