简介:针对STM32平台通过GPIO模拟SPI时序驱动MAX7301端口扩展器的嵌入式开发场景,该资源面向需要在不支持硬件SPI或希望节省外设资源的项目中实现扩展IO控制的工程师。压缩包包含1个C语言源文件,体积仅3KB,代码轻量,涵盖驱动结构体定义、GPIO初始化配置、模拟SPI时钟及数据收发函数、寄存器读写与端口控制接口,可直接移植到常见STM32系列。源码中通过初始化推挽输出引脚、按位控制时钟与数据线来还原SPI时序,逻辑清晰、便于二次开发。已有556人学习浏览,具有较好的参考价值。借助这份驱动,开发者可快速理解软件SPI的实现方式与MAX7301的寄存器映射逻辑,掌握电平转换、中断处理及上下拉配置等关键功能,能够减少查阅数据手册和底层调试的时间,为项目快速验证和外设扩展提供便利。
1. 为什么要把STM32的IO软模拟成SPI去驱动MAX7301
做嵌入式项目时,遇到最多的问题不是主控性能不够,而是“GPIO不够用”。当板子上只剩四五个空闲引脚,设备却需要十几个开关量、按键和指示灯时,MAX7301这类SPI转GPIO扩展器几乎是现成的答案。但很多工程师选型时只看接口数量,等到画原理图才发现STM32的硬件SPI引脚被其他外设复用,或者硬件板卡已经定版不能再等改板。这个时候,用IO软件模拟SPI驱动MAX7301就成了唯一不动改板方案。实际调试里,SPI时序、16位命令字和读写寄存器顺序才是真正消耗时间的地方。下面直接用STM32F103做底板,把IO模拟SPI驱动MAX7301的时序、代码和测试方法一次讲清楚,不管后面用标准库还是HAL库,核心思路都一样。
2. 先把SPI时序和MAX7301的16位命令字立住
2.1 软件模拟SPI不是复制硬件时序,而是复刻采样点
SPI协议本身只有四条线:SCLK提供时钟,MOSI送数据,MISO收数据,CS负责片选。MAX7301作为从机,CS拉低后开始接收主机的16位数据包,CS拉高后结束一次访问。硬件SPI外设帮你处理了沿对齐和移位,换成GPIO模拟时,必须自己保证一个关键点:在SCLK上升沿之前把MOSI上的电平放稳,在上升沿之后再去读MISO。这就是SPI Mode 0的采样约定。许多MAX7301例程默认CPOL=0、CPHA=0,也就是空闲SCLK为低电平,数据在上升沿被采样。如果芯片手册标注的是其他模式,那就要把“先放数据再拉高时钟”改成“先拉时钟再放数据”的顺序。
下面是常用SPI模式参数对照,调试软模拟前先拿这颗芯片的PDF核对一遍,不要直接照抄某个开源工程的配置:
| SPI模式 | CPOL | CPHA | SCLK空闲电平 | 数据采样沿 | 数据输出沿 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 低 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 高 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 高 | 上升沿 | 下降沿 |
MAX7301这类SPI GPIO扩展器通常工作在Mode 0,所以驱动代码里能看到“先写MOSI再拉高SCLK”的习惯写法。但如果你把同一套驱动改成读取其他SPI传感器,这个顺序就要跟着模式变。IO模拟SPI最大的好处就是改起来成本低,核心只是把“采样点在哪个沿”这件事搞对。CS信号也要注意:MAX7301的CS是低有效,一次完整的寄存器访问期间CS不能抖动,也不能提前拉高。
2.2 MAX7301的16位命令字:读写方向由最高位决定
很多第一次调MAX7301的人会把它当成普通8位SPI Flash去写,连续发两次8位数据后,发现读回来的内容完全不对。原因在于MAX7301的SPI访问长度是16位,不是常见的8位。一次CS低电平周期里,主机必须完整给出16个SCLK,CS才能拉高。这16个SCLK对应的命令字通常可以拆成三段:最高位bit15表示读写方向,bit14到bit8共7位是寄存器地址,bit7到bit0共8位是要写入的数据。当bit15为0时,主机写寄存器,数据段有效;当bit15为1时,主机发起读请求,数据段通常填0。这个结构在不同版本的MAX7301手册里画法略有差异,但“最高位决定方向、地址在前、数据在后”的模型在绝大多数例程里都成立。
组装命令字时,最典型的一个错误是把寄存器地址放进低8位,把数据放进高8位。调试时如果把写寄存器后读回来的值固定是某个偏移,先去看命令字是不是拼反了。我自己习惯在头文件里用宏拼命令,而不是在调用处手动移位,这样可以减少重复代码:
#define MAX7301_CMD_WRITE(reg, val) \ (uint16_t)(((uint16_t)(reg) & 0x7Fu) << 8) | ((uint16_t)(val) & 0xFFu) #define MAX7301_CMD_READ(reg) \ (uint16_t)(0x8000u | (((uint16_t)(reg) & 0x7Fu) << 8))2.3 为什么读寄存器经常读到上一次的数
软模拟调通写时序后,很多人卡在读时序上。写一个寄存器后马上读回,读到的经常是上一次的值或者0xFF。这个现象在SPI从机里很常见:一部分从机支持全双工回环,命令字从MOSI进来的同时,MISO上已经在输出上一个命令对应的数据;另一部分从机则把读操作分成两步,第一次传输只是把读请求交给芯片,芯片在下一个CS周期才把目标寄存器内容放到MISO上。MAX7301在不同参考驱动里的表现也不完全一样,与其背结论,不如在调试时做一个对照实验:连续读同一个寄存器两次,看第一次和第二次返回值哪个稳定。
如果第一次读返回的是旧值,第二次才返回新值,读函数就要改成“两个CS周期,第一次发命令,第二次取数据”的思路。如果第一次读就正确,那就没必要多加一拍。这一点在后面第5章回读校验里会用到,这里先记住:MAX7301的16个SCLK必须连在一起发,你不能用两个8位SPI函数在CS低电平期间凑成16位,因为从机不会自动拼接半字节。CS拉低期间SCLK的个数严格是16,中间多一个少一个都会让字边界错位,后续寄存器访问全部跑飞。
2.4 位序和字节序一起决定数据对不对
除了采样沿和命令字格式,位序也会让代码看起来莫名其妙。MAX7301大多数例程按MSB first收发:先发bit15,最后发bit0。在STM32的HAL库硬件SPI里,数据长度通常按8位配置,反而容易把16位命令拆成高字节、低字节两次发送,若中间CS没有保持低电平,就会被从机当成两笔命令。软模拟没有这个限制,循环16次逐位发送即可。但要注意组装命令字时用的主机字节序:在Cortex-M3上,uint16_t在内存里按小端存储,如果你用指针或者共用体去拆高/低字节,先发送的往往是低字节,地址和数据位置就反了。最稳妥的做法是用移位拼接整个uint16_t,再用位掩码依次发送,这样代码既不依赖字节序,也不用关心编译器在内存里怎么摆放。
3. 用CubeMX配好GPIO,手动拉起STM32IO模拟SPI的读写函数
3.1 GPIO选型:避开PB3/PB4的JTAG停用问题
在STM32CubeMX里配置IO模拟SPI时,第一步是选一组没有复用冲突的引脚。常见的“CS、SCLK、MOSI、MISO”四根线,我习惯放在同一个GPIO端口上,比如PA4做CS,PA5做SCLK,PA7做MOSI,PA6做MISO。注意PA5和PA7同时是硬件SPI1的SCLK和MOSI,但这里我们不开启SPI外设,只是把它们当普通GPIO使用。若是选PB3/PB4,就要先在SYS调试配置里把JTAG降级成SWD,否则复位后PB3/PB4被调试逻辑占用,GPIO写高写低都不生效。我一般宁可多用PA口,少碰JTAG,这样下载调试时不容易把自己绕进去。
GPIO模式配置可以参考下面的表格:
| 引脚 | 功能 | GPIO模式 | 上下拉 |
|---|---|---|---|
| PA4 | MAX7301 CS | 推挽输出 | 无 |
| PA5 | MAX7301 SCLK | 推挽输出 | 无 |
| PA6 | MAX7301 MISO | 浮空输入 | 无 |
| PA7 | MAX7301 MOSI | 推挽输出 | 无 |
CS、SCLK、MOSI用推挽输出,MISO用浮空输入,不要开启内部上拉。MAX7301的MISO引脚由芯片内部驱动,外部上下拉反而可能影响信号边沿速率。CubeMX里配置好之后直接生成工程,SPI外设不要开,这里就是四根普通GPIO。初始化代码用HAL库写出来大概是下面这样:
#define MAX7301_GPIO_PORT GPIOA #define MAX7301_CS_PIN GPIO_PIN_4 #define MAX7301_SCLK_PIN GPIO_PIN_5 #define MAX7301_MISO_PIN GPIO_PIN_6 #define MAX7301_MOSI_PIN GPIO_PIN_7 void max7301_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = MAX7301_CS_PIN | MAX7301_SCLK_PIN | MAX7301_MOSI_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(MAX7301_GPIO_PORT, &gpio); gpio.Pin = MAX7301_MISO_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(MAX7301_GPIO_PORT, &gpio); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_SET); }这里把CS初始化为高电平,避免上电瞬间MAX7301被误选中。四个引脚的初始化顺序并不重要,但CS必须在MAX7301还没工作前处于释放状态。GPIO速度选GPIO_SPEED_FREQ_HIGH,这样SCLK翻转沿更陡,软模拟频率还能往上提一点;如果布线较长,沿太陡会引入振铃,反而要降速,这个后面第4章会讲怎么调。
3.2 一个小而完整的16位软SPI收发函数
软模拟SPI的核心不是模拟“一个字节”,而是模拟“一个完整的SCLK周期”。以Mode 0为例,每一位的发送顺序是:先设置MOSI电平,然后拉高SCLK,之后读MISO,最后拉低SCLK。下面这个函数把16位命令字发出去,同时把从机返回的16位读回:
uint16_t max7301_soft_spi_transfer(uint16_t cmd) { uint16_t rx = 0; for (int i = 15; i >= 0; i--) { if (cmd & (1u << i)) { HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_MOSI_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_MOSI_PIN, GPIO_PIN_RESET); } HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_SCLK_PIN, GPIO_PIN_SET); rx <<= 1; if (HAL_GPIO_ReadPin(MAX7301_GPIO_PORT, MAX7301_MISO_PIN) == GPIO_PIN_SET) { rx |= 1u; } HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_SCLK_PIN, GPIO_PIN_RESET); } return rx; }循环变量i从15递减到0,保证MSB先发送。SCLK拉高后立即读MISO,这个顺序对应Mode 0的上升沿采样。如果芯片手册要求下降沿采样,就把“读MISO”挪到SCLK拉低之后。rx每接收一位就先左移再或上当前采样值,这样16位数据在返回时已经恢复成uint16_t。CS的拉低和拉高不应放在这个函数里面,因为后面的读寄存器可能需要连续两个CS周期,把CS控制留在上层更灵活。
3.3 封装寄存器读写:命令字组装和连续读
有了底层传输函数,MAX7301的寄存器读写就只是命令字组装和CS控制。写寄存器时,CS拉低,发一个写命令,CS拉高。读寄存器时,先发读命令,CS拉高,再判断是否需要第二个CS周期取数据。最简单的一次读版本如下:
void max7301_write_reg(uint8_t reg, uint8_t val) { uint16_t cmd = MAX7301_CMD_WRITE(reg, val); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_RESET); max7301_soft_spi_transfer(cmd); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_SET); } uint8_t max7301_read_reg(uint8_t reg) { uint16_t cmd = MAX7301_CMD_READ(reg); uint16_t raw = 0; HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_RESET); raw = max7301_soft_spi_transfer(cmd); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_SET); return (uint8_t)(raw & 0xFFu); }提示:如果这个读函数读回来的是上一次写入的值或者固定0xFF,不要改命令字,改成连续两个CS周期:第一个CS周期发读命令,第二个CS周期再收数据。第二种读时序在很多SPI GPIO芯片上存在,属于正常现象。
当需要兼容“第二拍有效”的读时序时,把读函数改成下面这样即可:
uint8_t max7301_read_reg_u8(uint8_t reg) { uint16_t cmd = MAX7301_CMD_READ(reg); uint16_t raw = 0; HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_RESET); max7301_soft_spi_transfer(cmd); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_RESET); raw = max7301_soft_spi_transfer(cmd); HAL_GPIO_WritePin(MAX7301_GPIO_PORT, MAX7301_CS_PIN, GPIO_PIN_SET); return (uint8_t)(raw & 0xFFu); }两个CS周期的版本在初始化自检时很管用,它能把“读时序不对”和“寄存器配置不对”区分开。如果你手上已经有一个别人给的驱动包,优先看它读寄存器是不是两拍逻辑,很多移植问题都出在这里。
3.4 初始化序列里的方向与输出寄存器
MAX7301上电后不一定直接进入普通GPIO模式,很多这类芯片需要先写配置寄存器,退出shutdown或者开启内部时钟,然后逐端口配置方向寄存器,再写初始输出电平。初始化函数可以这样组织:
#define MAX7301_PORT_COUNT /* 端口数量,按你的芯片手册填,比如20或40 */ #define MAX7301_REG_CONFIG /* 配置寄存器地址,手册确认 */ #define MAX7301_REG_DIR(port) /* 方向寄存器地址映射,手册确认 */ #define MAX7301_REG_DATA(port) /* 数据寄存器地址映射,手册确认 */ #define MAX7301_DIR_OUT /* 方向值:输出 */ #define MAX7301_DIR_IN /* 方向值:输入 */ void max7301_init(void) { max7301_gpio_init(); max7301_write_reg(MAX7301_REG_CONFIG, 0x01); for (int port = 0; port < MAX7301_PORT_COUNT; port++) { max7301_write_reg(MAX7301_REG_DIR(port), MAX7301_DIR_OUT); max7301_write_reg(MAX7301_REG_DATA(port), 0x00); } }这里我故意没有把所有寄存器地址填成具体数字,因为MAX7301和MAX7031、不同批次芯片的寄存器映射存在差异。移植时第一件事就是把MAX7301_REG_CONFIG、MAX7301_REG_DIR(port)、MAX7301_REG_DATA(port)替换成数据手册里的值。软SPI底层只要收发正确,上层寄存器填错时现象通常是“某个端口不受控”而不是“整颗芯片没反应”,这也是定位方向寄存器配置错误的有效线索。
4. 把驱动跑起来:扩展IO口的例程、时钟参数与三个容易翻车的点
4.1 点亮一个LED并把按键状态读回来
初始化之后,读写函数就能当普通IO操作使用。下面这一段让P0输出高电平点亮LED,同时读取P1上的按键状态,按一下翻转P2的输出:
int main(void) { HAL_Init(); SystemClock_Config(); max7301_init(); max7301_write_reg(MAX7301_REG_DIR(0), MAX7301_DIR_OUT); max7301_write_reg(MAX7301_REG_DIR(1), MAX7301_DIR_IN); max7301_write_reg(MAX7301_REG_DIR(2), MAX7301_DIR_OUT); max7301_write_reg(MAX7301_REG_DATA(0), 0x01); while (1) { if (max7301_read_reg(MAX7301_REG_DATA(1)) & 0x01) { max7301_write_reg(MAX7301_REG_DATA(2), 0x01); } else { max7301_write_reg(MAX7301_REG_DATA(2), 0x00); } } }读按键时读取数据寄存器的bit0,但前提是把该端口配成输入。MAX7301这类芯片的输入引脚通常需要外部上拉或下拉,悬空时读到的值不稳定。写P2也一样,先配方向再写电平,顺序反了可能出现电平写不进去的假象。这里的MAX7301_REG_DATA(port)在不同驱动包里可能叫“输出寄存器”或“输入寄存器”,如果读输入和写输出用的是两个地址,改成对应的宏即可。
4.2 软件模拟SPI的速度怎么调:从500ns到5us
软模拟的最高速度由两个因素决定:GPIO翻转速度和SCLK高低电平之间的保持时间。HAL库函数调用本身会消耗几百纳秒,所以如果目标频率低于1MHz,通常不需要额外延时;如果希望降低对电磁干扰的敏感度,可以在SCLK翻转位置插入延时。我自己调试时习惯把SCLK高电平保持时间作为可调参数,方便在100kHz到1MHz之间切换。
| 目标SCLK频率 | SCLK高/低电平保持时间 | 适用场景 |
|---|---|---|
| 100kHz | 5us | 飞线较长、板上干扰大 |
| 500kHz | 1us | 常规排线连接 |
| 1MHz | 500ns | 板载短走线、信号完整性好 |
这里的保持时间指SCLK拉高到拉低之间的时长。IO模拟SPI不需要追求每秒几兆位,因为GPIO扩展器本身就是低频控制类操作,真正的开销在寄存器读写次数。一次写操作需要16个SCLK,以1MHz计算大约16us,用来处理按键、LED、继电器完全够用。如果发现读回来的值偶尔出错,先把速率降到100kHz,再一根一根量信号,往往比盲目加延时更有效。
4.3 三个容易翻车的点:CS被中断打断、JTAG复用、读时序少一拍
第一个翻车点是CS拉低期间被中断打断。假设MCU的定时器中断在16个SCLK还没发完时触发,而中断服务函数里也操作了同一个GPIO端口,SCLK波形中间会多出毛刺,MAX7301可能把一帧数据拆成两次解释。解决方法是发送过程中临时屏蔽可重入中断,或者用临界区包住整个CS低电平周期。注意这里不是把所有中断关掉,而是保证这个GPIO端口的数据不会被别的任务同时修改。
第二个翻车点是选了PB3/PB4却忘了在CubeMX中关闭JTAG。现象是IO写不进去、用万用表量不到电平变化,往往不是驱动问题而是引脚复用问题。前面3.1节已经提醒过,新手最容易在这里折腾半天。
第三个翻车点是读函数少了一次“空读”。前面2.3节说过,读回旧值时不一定要改命令字,而是补一个CS周期。判断方法很简单:连续读同一个寄存器两次,如果两次值不一样,说明读时序需要补拍;如果两次值一样,就维持一次读。
4.4 软件片选与硬件片选:MAX7301适合哪种
硬件片选由SPI外设自动控制,软件片选则是手动拉GPIO。MAX7301的CS可以用软件片选,因为一次访问就是16位,CS在整个传输期间保持低电平即可。硬件SPI片选通常在发送缓冲区空了以后立刻拉高,如果你的SPI外设配置了等待时间或中断,CS释放时机可能比预期偏早。软件片选则完全可控,最大优势是允许你在连续读写之间决定CS要不要重新拉高。对MAX7301这种严格依赖16位帧格式的从机,软件片选比硬件片选更直观,也更容易排查时序问题。
5. 用逻辑分析仪验证MAX7301的SPI时序,再给驱动加一道回读校验
5.1 逻辑分析仪抓时序的配置
软件模拟SPI最怕的是“代码看着对,波形不对”。调完底层驱动后,建议先不要接MAX7301,直接把逻辑分析仪的四个探头接到PA4、PA5、PA6、PA7上,配置SPI解码。采样率至少设成SCLK频率的4倍以上,协议参数选Mode 0,位宽16位,触发方式设为CS下降沿。抓一次写操作,重点检查CS拉低后到第一个上升沿之间是否有一段准备时间,以及CS拉高前第16个下降沿是否完整。如果看到帧长度不是16个SCLK,多半是循环次数写成了8或者CS控制被中断打断。逻辑分析仪上看懂帧结构,再去看寄存器值,能省掉大量猜测。
5.2 用回读校验区分“寄存器没配”和“SPI没握上”
初始化驱动后,可以加一个很小的自检函数:先往某个寄存器写0x5A,再读回来比较。这个校验能同时覆盖两种情况:
uint8_t max7301_reg_check(uint8_t reg, uint8_t expect) { uint8_t actual = 0; uint8_t older = 0; max7301_write_reg(reg, expect); actual = max7301_read_reg(reg); older = max7301_read_reg(reg); return (actual == expect) || (older == expect); }这段代码先写一个已知值,然后连续读两次。如果读时序是“第二拍有效”,第一次读会拿到旧值,第二次读会拿到新值;如果读时序是“同拍有效”,两次读都可能直接拿到新值。无论哪种情况,只要actual或older有一个等于期望值,说明SPI底层和寄存器地址基本是通的。如果两次都不对,先看逻辑分析仪波形,再检查寄存器地址映射,不要急着改驱动里的延时参数。
5.3 把软件延时可调参数做成宏
最后建议在驱动头文件里保留一个可调延时宏,这样切换不同主频的STM32时不用大改代码:
#ifndef SOFT_SPI_SCLK_HALF_PERIOD_US #define SOFT_SPI_SCLK_HALF_PERIOD_US 0u #endif在SCLK拉高后和拉低前各调用一次soft_spi_delay_us(SOFT_SPI_SCLK_HALF_PERIOD_US)。调试时把宏改成0,看能不能跑通;出现读取错误再逐步加到1、2、5,直到逻辑分析仪上的毛刺消失。用这个宏配合回读校验,基本能把MAX7301的软模拟驱动稳定移植到F1、F4、L4等不同主频的STM32上。
本文还有配套的精品资源,点击获取