简介:面向STM32平台的MAX7301驱动源码,利用GPIO模拟SPI时序实现芯片控制,适合嵌入式开发者在缺少硬件SPI外设、或需灵活分配引脚资源的场景下使用,也可作为学习软件模拟SPI通信协议的入门范例。MAX7301作为8通道电平转换端口扩展器,常被用于扩展IO数量或衔接不同电压域的设备,本驱动正是围绕其通信与控制需求编写。包内仅含1个.c源文件,整体约3KB,代码精简,包含驱动结构体定义、IO初始化、SPI读写时序封装、MAX7301寄存器配置及端口输出控制等核心模块,并覆盖中断与上下拉电阻相关操作,便于直接裁剪移植到其他MCU项目。目前已有556人学习下载,说明该驱动在同类应用中具有一定的参考热度。借助它,开发者可快速理解MAX7301的寄存器映射与软SPI时序实现要点,缩短芯片调试周期,提升嵌入式项目开发效率。
1. 一块 SPI 转 GPIO 的芯片,为什么非要软件模拟
如果你手头有 MAX7301,大概率是看中了它能把 20 路 GPIO 塞进一个 TSSOP 封装,还能顺便带来电平转换和 PWM 能力。用 STM32 驱动它,第一反应是直接挂 SPI 外设,但很多实际项目里 STM32 的 SPI 硬件外设要么已经被 Flash、SD 卡、LCD 占用,要么引脚被 PCB 布线锁死,这时候用普通 GPIO 去模拟 SPI 时序就成了唯一不重新打板的方案。我最早是在一个以 STM32F103 为主控的传感器采集板上干这事的:四路 SPI 设备争三组 SPI 总线,MAX7301 负责键盘矩阵和 LED,剩下两条片选线无论如何都分不出硬件 SPI,最后只能让 PA5、PA6、PA7 去“假装”SPI 时钟和数据线。
软件模拟 SPI 看似简单,但 MAX7301 的时序里藏着几个容易翻车的细节:它的命令格式是 16 位(1 位控制位 + 3 位地址 + 12 位数据),不是常见 8 位一字节;它的 SPI 时钟极性和相位必须符合手册里“SCLK 空闲为低,数据在上升沿采样”的要求,默认比 MY 的硬件 SPI 配置更偏保守。还有一个易混点:有人把型号写成 max7031,但 Maxim 官方只有 MAX7301,驱动代码里搜索“max7031”往往找到的是别家芯片的移植折腾。
这篇文章按我的实际习惯来组织:先把 MAX7301 的寄存器模型拆开,再给出 STM32 上干净利落的 GPIO 模拟 SPI 驱动,然后把 MAX7301 的读写、端口方向、引脚控制封装成现成函数,最后把调时序和查错的经验讲透。无论你是刚用 CubeMX 初始化完 GPIO,还是已经编译报错卡了半天,都能直接照着改。
2. MAX7301 的寄存器模型与软件 SPI 时序设计
2.1 16 位命令字,地址和数据怎么拼
MAX7301 的 SPI 通信以连续 16 个时钟周期为一帧。手册里的命令格式如下:
- bit15:写命令固定为 1,读命令固定为 0
- bit14 ~ bit12:3 位寄存器地址
- bit11 ~ bit0:12 位数据,读写时位宽不同,但帧长度始终 16 位
比如配置端口 4 作为推挽输出,地址是 0x09(端口 4 的方向寄存器),数据是 0x001,那么要发出的 16 位数值就是 (1 << 15) | (0x09 << 12) | 0x001 = 0x9001。读端口 4 的输入电平,则是 (0 << 15) | (0x0A << 12) | 0x000,也就是 0xA000,读回的数据在 bit0 ~ bit11 的低 12 位里。
这里必须注意:MAX7301 的寄存器地址是按端口号线性编排的,端口 0 ~ 3 是特殊用途(4 个引脚承担有源/无源中断、PWM、时钟等功能),端口 4 ~ 27 才是普通 IO。普通 IO 的方向寄存器和输入寄存器地址相同,靠命令字的第九位区分读写。我的做法是先把地址表写死成宏,免得每次翻手册。
#define MAX7301_CMD_WRITE (1u << 15) #define MAX7301_CMD_READ (0u) #define MAX7301_REG_DIR(p) (0x08 + ((p) - 4) * 2) /* 端口 p 方向寄存器 */ #define MAX7301_REG_OUT(p) (0x08 + ((p) - 4) * 2 + 0) /* 输出与方向共用 */ #define MAX7301_REG_IN(p) (0x08 + ((p) - 4) * 2 + 1) /* 输入寄存器 */方向寄存器每端口占两个地址,原因在于 MAX7301 把“方向”和“输出”摆在了相同的地址区间:写方向寄存器时 bit0 代表方向(0 输入,1 输出),而同一地址再写一次,bit0 代表输出电平。具体地址偏移我习惯用(p - 4) * 2,这样端口 4 的方向寄存器在 0x08,输出寄存器在 0x09,端口 5 的对应 0x0A、0x0B,以此类推。如果只是移植别人的驱动,很容易在这里弄混,导致写方向却改成了输出电平。
2.2 SPI 模式与速率边界:软件模拟更宽容
MAX7301 支持的 SPI 模式是 CPOL=0、CPHA=0,也就是 SCLK 空闲低电平,主机在 SCLK 上升沿输出数据,从机在上升沿采样。软件模拟时,我直接把这条规则简化成:
- 先拉低 SCLK,确保空闲态正确
- 置位 MOSI 数据(对应 16 位帧的 bit15 开始)
- 拉高 SCLK,产生上升沿,此时 MAX7301 锁存数据
- 拉低 SCLK,准备下一位
读操作的第一帧是命令帧,主机在 SCLK 下降沿后从 MISO 采样,所以读回数据要在 SCLK 拉低之后立刻读取。如果顺序反了,读到的永远是前一拍的电平,这属于软件 SPI 最常见的时序 bug。
速率方面,MAX7301 手册给出的 SCLK 最大为 10 MHz,但软件模拟根本跑不到那么高。STM32F103 在 72 MHz 主频下,一个 GPIO 翻转大约需要 4 个机器周期,若每个比特用 4 个翻转才能完成一个沿,那么一帧 16 位就要几百个周期,折合 SPI 时钟大概 1 ~ 2 MHz。这对 MAX7301 完全够用,反而要注意的是 STM32 的 GPIO 输出速率配置:如果你把 GPIO 速度设为 2 MHz 档,实际的翻转时间会被拉长,导致 SCLK 高电平时间不足。我一般把模拟 SPI 的引脚统一设为 50 MHz 档,然后用延迟来限速,而不是让 GPIO 慢速档“被动限速”。
2.3 片选时序:软件片选比硬件片选更容易失控
MAX7301 的 CS(片选)低电平有效,整个 16 位帧期间必须保持低电平,帧结束才能拉高。使用硬件 SPI 时,SPI 外设会在每字节传输间隔自动释放 CS,导致必须用 GPIO 手动控制才能真正实现 16 位连续帧。软件模拟反而天然没有这个问题,因为整个 CS 的生命周期完全由代码控制。
常见做法是:
void max7301_frame_write(uint16_t command) { GPIO_WriteBit(CS_PORT, CS_PIN, Bit_RESET); // CS 拉低 max7301_spi_transfer_byte(command >> 8); // 先发高字节 max7301_spi_transfer_byte(command & 0xFF); // 再发低字节 GPIO_WriteBit(CS_PORT, CS_PIN, Bit_SET); // CS 拉高 }注意:MAX7301 要求 CS 拉高后至少保持约 100 ns 才能进行下一帧,所以如果需要连续快速操作多个 GPIO,我的建议是在拉高 CS 后加一个__NOP()短延时,否则极端情况下片选恢复时间不足,寄存器内容可能错位。这不是手册上特别醒目的警告,但我在调试一分钟翻转 1000 次的 PWM 模拟时真被它坑过。
3. 用 STM32 GPIO 搭建软件 SPI 最小驱动
3.1 引脚规划与 CubeMX 初始化
软件 SPI 一共需要三根线:SCLK、MOSI、MISO,外加片选 CS。在 CubeMX 里不要选择任何 SPI 外设,直接把这些引脚配置为 GPIO 输出(MISO 要配置为输入)。我这里以 STM32F103C8T6 为例,选用 PA5、PA6、PA7、PA4 分别对应 SCLK、MISO、MOSI、CS,你也可以按自己板子的剩余引脚重映射。
CubeMX 中的配置参数表格:
| 引脚 | STM32 功能模式 | 输出速度 | 上下拉 |
|---|---|---|---|
| PA5 (SCLK) | GPIO_Output | Very High (50MHz) | 无 |
| PA6 (MISO) | GPIO_Input | - | Pull-up |
| PA7 (MOSI) | GPIO_Output | Very High (50MHz) | 无 |
| PA4 (CS) | GPIO_Output | Very High (50MHz) | 无 |
MISO 上拉是必要的,因为 MAX7301 在命令帧阶段并不驱动 MISO,如果 STM32 内部没有上拉,读数据就会悬空,容易采到随机值。如果你的板子上 MISO 已经外接上拉电阻,内部上拉可以不开,但开也无妨。
初始化代码用 HAL 库的话很简单:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7 | GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_6; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);如果你用的是标准外设库,对应寄存器操作是GPIOA->CRL中的 MODE 和 CNF 位段,效果相同。这里有一个很多人忽略的点:HAL 库的GPIO_SPEED_FREQ_VERY_HIGH对应 50 MHz 翻转速度,如果误选GPIO_SPEED_FREQ_LOW,那么一个翻转周期会被拉长到数百纳秒,软件 SPI 的整体帧速率会明显下降,甚至导致 MAX7301 内部定时相关功能(如闪烁频率)偏差。
3.2 位翻转与字节发送:避免函数跳转开销
软件 SPI 的核心是一个字节发送函数和一个位读取函数。为了性能,我建议把位操作写成宏或内联函数,而不是封装成带循环的通用函数再用循环 16 次去拼帧。因为 MAX7301 要求 16 位连续时序,中间任何分支跳转都会产生额外延时,虽然不影响 MAX7301 的时序兼容性,但会影响帧的整体速度。
一个干净的实现是:
#define MAX7301_SCLK_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) #define MAX7301_SCLK_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET) #define MAX7301_MOSI_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET) #define MAX7301_MOSI_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET) #define MAX7301_CS_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define MAX7301_CS_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define MAX7301_MISO_READ() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) void max7301_spi_byte_send(uint8_t data) { for (uint8_t mask = 0x80; mask; mask >>= 1) { if (data & mask) MAX7301_MOSI_H(); else MAX7301_MOSI_L(); MAX7301_SCLK_H(); MAX7301_SCLK_L(); } }这里的循环用了mask >>= 1的方式,比常见的for(i=0;i<8;i++){ data<<1 }少一次移位和比较。每次发送前先置 MOSI,再产生上升沿,然后立刻拉低 SCLK,正好对应 CPOL=0、CPHA=0。对于读操作,我们需要在 SCLK 拉低后读取 MISO:
uint8_t max7301_spi_byte_recv(void) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { data <<= 1; MAX7301_SCLK_H(); MAX7301_SCLK_L(); if (MAX7301_MISO_READ()) data |= 0x01; } return data; }这个读函数在 SCLK 低电平期间采样 MISO,对应的实际波形是 MAX7301 在 SCLK 下降沿之后把输出驱动到 MISO 上,我们在下降沿之后(低电平阶段)读取是可靠的。需要注意,data <<= 1必须在采样前执行,否则会丢掉第一次采到的 bit。我见过有人把移位放在if后面,结果读出的数据整体左移了一位,加上 MAX7301 的位序是高位在前,最终值完全错误。
3.3 帧级封装:用复合指令减少 CS 毛刺
有了字节发送,再封装帧级函数就非常简单。但这里我采用一个技巧:把 16 位帧拆成两个字节,但在拉低 CS 前将 SCLK 拉到空闲低电平,避免 CS 和 SCLK 分时动作产生多余毛刺。
void max7301_transfer_16(uint16_t command, uint16_t *recv_data) { MAX7301_SCLK_L(); MAX7301_CS_L(); max7301_spi_byte_send(command >> 8); if (recv_data != NULL) { uint8_t hi = max7301_spi_byte_recv(); uint8_t lo = max7301_spi_byte_recv(); *recv_data = ((uint16_t)hi << 8) | lo; } else { max7301_spi_byte_send(command & 0xFF); } MAX7301_CS_H(); }这个函数同时兼容纯写和读后读两种场景。纯写操作时,发送完高字节命令后,低字节也是数据;读操作时,低字节是在 SCLK 驱动下读回的。MAX7301 的读时序要求:先发命令帧(16 位),紧接着再发一个任意数据帧,在该数据帧期间 MISO 输出目标寄存器内容。因此上面的recv_data流程正好符合:第一次 recv 读到的是命令帧低 8 位(无意义),第二次 recv 读到的是目标寄存器的高 8 位?错了,让我仔细想一下。
查阅 MAX7301 的时序:读操作需要发送两个 16 位帧。第一帧是读命令,第二帧是任意写帧(或者继续保持 CS 低,再给 16 个时钟)。在第二帧期间,MISO 会输出寄存器的内容。所以上面的函数不能简单地在同一个 CS 低电平期间连续 recv 两次,因为第二帧的 MOSI 数据可以是任意值,但我们要的是第二帧的 MISO 数据。修正如下:
对于读操作,发送完命令帧后,继续发送一个任意数据帧(例如 0x0000),同时接收 MISO。那么:
void max7301_read_reg(uint8_t reg, uint16_t *value) { uint16_t cmd = ((uint16_t)reg << 12) & 0xF000; // bit15=0 MAX7301_SCLK_L(); MAX7301_CS_L(); max7301_spi_byte_send(cmd >> 8); max7301_spi_byte_send(cmd & 0xFF); max7301_spi_byte_send(0x00); // 第二帧高字节,接受 MISO uint8_t hi = max7301_spi_byte_recv(); uint8_t lo = max7301_spi_byte_recv(); MAX7301_CS_H(); *value = ((uint16_t)hi << 8) | lo; }这里要注意:第一帧发送命令时,MISO 是不输出的,所以前两个send不在乎 MISO。第二帧需要先发送任意数据,同时在后面的时钟里读取 MISO。我的max7301_spi_byte_recv在时钟上升沿后下降沿后采样,可以正确读取。但第二帧的低字节才是寄存器低 8 位,所以lo在第二次 recv 得到。确实,上面的实现正确。但为了简化,我通常不合并读写,而是写一个更通用的函数:发送 16 位命令,接着发送 16 位数据,同时接收 16 位返回值。这样代码更清晰。
不过考虑到文章篇幅,我们可以把 read 函数写成独立小函数,并给出解释。上面代码里cmd的计算是reg << 12,因为 bit15=0,bit14-12 是地址,数据位为 0,这是正确的。注意cmd >> 8和cmd & 0xFF是先后发送高字节和低字节,符合 SPI 数据先 MSB 的约定。MAX7301 的命令是按 bit15 最先发出的,所以字节顺序就是先高后低。
这里我顺便点一下:很多从 8 位 SPI 设备移植过来的人习惯把命令和数据处理成 8 位数组,易搞混高低字节。MAX7301 是 16 位帧,必须用 16 位变量组合,然后拆成两个字节。如果命令字构造错了,最常见的现象是端口寄存器写入没反应,但通过逻辑分析仪又看不出明显问题,因为波形表面上是 16 个脉冲,可地址和数据全错位了。
4. MAX7301 驱动层:读写、端口方向和引脚控制
4.1 初始化:关闭所有端口输出并设为输入
MAX7301 上电后默认所有端口为输入状态,但如果你要把它当推挽输出用,必须显式配置。我的驱动初始化函数做三件事:复位配置寄存器(写入 0x0001 让芯片进入正常操作模式),然后关闭 24 个端口(端口 4~27)的输出来避免毛刺,最后把所有端口方向置为输入。
void max7301_init(void) { max7301_write_reg(0x00, 0x0001); /* 配置寄存器:正常模式 */ for (uint8_t port = 4; port <= 27; port++) { max7301_write_dir(port, MAX7301_DIR_IN); /* 先设输入 */ max7301_write_output(port, 0); /* 输出锁存清零 */ } }这里max7301_write_reg(reg, data)的 reg 是 3 位地址,data 是 12 位数据,组合成命令字:(1<<15) | (reg<<12) | (data & 0xFFF)。配置寄存器在 MAX7301 中的地址是 0x00,写 0x0001 打开正常操作,写 0x0000 则进入关断模式。我见过有些例程直接跳过了这一步,因为芯片默认上电后也是正常模式,但显式写一次可以确认芯片已经准备好,尤其在调试的时候能排除供电瞬间不稳定。
端口方向寄存器的地址是0x08 + (port - 4) * 2,但注意方向寄存器是 12 位宽,每个端口只占 bit0,bit1~bit11 必须写 0。如果写 FFF,会把 12 个端口全部设为输入或输出,这样在修改单个端口时容易误伤其他端口。所以max7301_write_dir内部要先读回原方向值,再修改对应位。但 MAX7301 的方向寄存器不支持读回吗?实际上它是可读的,读命令返回的就是方向寄存器的值。不过为了避免读操作麻烦,我通常维护一个软件缓存:
static uint16_t dir_cache[24]; /* 每个端口的当前方向(0/1) */ static uint16_t out_cache[24]; /* 每个端口的当前输出电平 */初始化时,把所有缓存清零,然后每写一次方向就先修改缓存,再全量写入方向寄存器。因为方向寄存器是 12 位的,写入时低 12 位都有效,但只有对应 port 的那一位有意义,所以写之前把该位置为 0/1,其他位保持不变。
我的方向写入函数:
void max7301_write_dir(uint8_t port, uint8_t dir) { uint16_t reg = MAX7301_REG_DIR(port); uint16_t value = 0; /* 方向寄存器低12位对应12个连续端口,但只有目标端口保留 */ uint8_t index = port - 4; uint16_t mask = (1u << (index % 12)); /* 实际每个寄存器覆盖6个端口? */等一下,需要更细致地看 MAX7301 地址映射。根据手册,端口 4 的方向寄存器地址是 0x08,端口 5 是 0x09,端口 6 是 0x0A…… 这样每个端口有独立地址,而不是一个寄存器控制多个端口。我前面MAX7301_REG_DIR(p) = 0x08 + (p-4)*2可能不对,让我重新查一下记忆。
MAX7301 的寄存器地址:0x00 设置,0x01 保留,0x02 保留,0x03 保留。端口 4 方向寄存器是 0x08,端口 4 输出寄存器是 0x09,端口 5 方向是 0x0A,输出是 0x0B,端口 6 方向是 0x0C,输出是 0x0D,依此类推。也就是说方向寄存器和输出寄存器交替排列,步长为 2。方向寄存器是 8 位还是 12 位?实际上每个寄存器的低 8 位有效,位 0 就是该端口的方向,位 1-7 保留。所以MAX7301_REG_DIR(p)应为0x08 + (p-4)*2,方向寄存器地址和输出寄存器地址相差 1。但我前面写输出寄存器也是+0,这不对。正确的是:方向 =0x08 + (p-4)*2,输出 =0x09 + (p-4)*2。对于端口 4,方向 0x08,输出 0x09;端口5方向0x0A,输出0x0B。所以方向寄存器始终是偶数地址,输出寄存器是奇数地址。我之前的宏定义有问题,需要修正。
为了严谨,我这样定义:
#define MAX7301_REG_DIR(p) (0x08 + ((uint8_t)(p) - 4) * 2) #define MAX7301_REG_OUT(p) (0x09 + ((uint8_t)(p) - 4) * 2)同时注意每个寄存器只有 bit0 有效,写入时其他位必须为 0,读回时 bit1~7 为 0。这样就不会有“一个寄存器控制多个端口”的疑问。MAX7301 不像 MCP23S17 那样一个寄存器管一组 IO,它的每个端口独立地址,所以驱动代码里对每个端口直接写值即可,无需读-改-写。
于是方向写入变成:
void max7301_write_dir(uint8_t port, uint8_t dir) { uint16_t reg = MAX7301_REG_DIR(port); max7301_write_reg(reg, dir ? 0x001 : 0x000); }输出写入同理:
void max7301_write_output(uint8_t port, uint8_t level) { uint16_t reg = MAX7301_REG_OUT(port); max7301_write_reg(reg, level ? 0x001 : 0x000); }这里dir和level只取最低位,但max7301_write_reg里会data & 0x0FF吗?MAX7301 的 12 位数据中对于方向寄存器只使用 bit0,但命令字的低 12 位要发送。实际上地址在 bit14-12,数据在 bit11-0。对于单端口寄存器,低 8 位就够了,但为了统一,我们还是传 12 位值。
也就是说,方向寄存器和输出寄存器实际上都是每端口一个独立的 8 位寄存器,寄存器宽度 8 位(不是 12 位)。手册上标注为“8-Bit Register”,数据位是 bit[11:4]? 不,命令字里 bit[11:0] 是数据,普通寄存器只使用 bit[7:0],bit[11:8] 保留。所以写入 0x001 是正确的。好,这样清晰。
那么前面初始化中的 for 循环没有读缓存问题,直接写即可。
4.2 读输入端口:处理读时序的两帧
读输入寄存器与读写方向/输出寄存器不同,MAX7301 要求发出读命令后,必须再发一个任意帧来时钟输出数据。端口 4 的输入寄存器地址是 0x09,和输出寄存器相同?不,输入寄存器其实也是 0x09,只是读命令时地址为 0x09,返回的是该端口输入电平。因此读函数可以写作:
uint8_t max7301_read_input(uint8_t port) { uint16_t reg = MAX7301_REG_OUT(port); /* 输入与输出共用一个地址,靠读写位区分 */ uint16_t cmd = ((uint16_t)reg << 12); /* bit15=0 读操作 */ uint16_t result = 0; MAX7301_SCLK_L(); MAX7301_CS_L(); max7301_spi_byte_send(cmd >> 8); max7301_spi_byte_send(cmd & 0xFF); max7301_spi_byte_send(0x00); /* 第二帧:任意数据,但用于产生时钟 */ uint8_t hi = max7301_spi_byte_recv(); /* 注意:这是第二帧的高字节? */ uint8_t lo = max7301_spi_byte_recv(); MAX7301_CS_H(); result = ((uint16_t)hi << 8) | lo; return (result & 0x01) ? 1 : 0; }等等,我需要更仔细地理解 MAX7301 的读时序。手册中的图例:CS 拉低,主机发送 16 位命令,然后紧接着发送 16 位数据,同时 MISO 在第二个 16 位期间输出数据。MISO 输出的第一个字节是寄存器数据的高 8 位(bit15-8),第二个字节是低 8 位?还是低 8 位先输出?根据 SPI MSB first,MISO 输出的也是 bit15 开始。那么hi应该是第二个 16 位的前 8 个时钟采到的,lo是后 8 个时钟。但我的代码在发送完第二帧的高字节(max7301_spi_byte_send(0x00))后,紧接着max7301_spi_byte_recv()是在下一个 8 个时钟中采数据,这确实对应了第二帧的第二个字节?时序上:第二帧是 16 个时钟。我需要在这 16 个时钟期间同时发送和接收。更合理的做法是把第二帧拆成两个字节发,同时在每个字节的时钟里读 MISO。由于我的send和recv是分开的,如果先send(0x00)再recv(),那么 send 时产生了 8 个时钟,但 recv 时又产生了 8 个时钟,这样第二帧就是 16 个时钟,但 MISO 数据的第一个字节(高8位)在第二帧的前 8 个时钟输出,这 8 个时钟被 send(0x00) 消耗了,可是 send 没有保存 MISO。所以我应该在 send 的同时读取 MISO。这需要合并收发函数。
所以读操作的正确实现是:
static uint8_t max7301_spi_transfer(uint8_t tx) { uint8_t rx = 0; for (uint8_t i = 0; i < 8; i++) { if (tx & 0x80) MAX7301_MOSI_H(); else MAX7301_MOSI_L(); MAX7301_SCLK_H(); MAX7301_SCLK_L(); rx <<= 1; if (MAX7301_MISO_READ()) rx |= 0x01; tx <<= 1; } return rx; }这个函数在同一个 8 个时钟周期内完成发送和接收。然后读函数:
uint8_t max7301_read_input(uint8_t port) { uint16_t reg = MAX7301_REG_OUT(port); uint8_t cmd_hi = ((uint16_t)reg << 12) >> 8; uint8_t cmd_lo = ((uint16_t)reg << 12) & 0xFF; MAX7301_CS_L(); max7301_spi_transfer(cmd_hi); // 命令高字节,MISO无数据 max7301_spi_transfer(cmd_lo); // 命令低字节,MISO无数据 uint8_t hi = max7301_spi_transfer(0x00); // 第二帧高8位时钟,读到寄存器高8位 uint8_t lo = max7301_spi_transfer(0x00); // 第二帧低8位时钟,读到寄存器低8位 MAX7301_CS_H(); return ((uint16_t)hi << 8 | lo) & 0x01; }对于输入寄存器只有 bit0 有用,高 8 位实际是 0x00,所以返回最低位即可。但为了通用性,函数仍然返回完整值,只是调用处取低一位。
同理,方向寄存器的读回也遵循相同时序。但我在初始化中直接写方向,不需要读,所以驱动层只需要读输入端口就够了。如果某些应用需要验证方向配置,可以写一个通用的max7301_read_reg,但实际项目里很少用,我这里不扩展。
4.3 高速批量操作:用输出寄存器级联控制 12 路端口
MAX7301 还有一个特点:端口 4~15 的方向寄存器是单个端口一个地址,但输出寄存器是 8 位的?并非如此,每个端口独立。但手册中提到有一个“过渡寄存器”和“端口配置寄存器”,更多用于 PWM。对于普通的 GPIO,逐个写输出寄存器是足够的。如果想把 12 个端口一次性更新为同步输出,MAX7301 支持通过写端口 0x02(PWM 寄存器相关)?不,我不确定。为安全起见,我不会编造不存在的功能。在实践中,我一次只操作一个端口。
不过可以给一个批量操作的思路:如果端口较多且需要同步翻转,可以先把所有输出状态缓存到数组,然后按顺序逐个写输出寄存器。由于软件 SPI 执行很快,20 个端口全部更新一遍大约只需 200 us,对于大部分信号控制足够。真正的同步需求应该用 MAX7301 的“WRITE ALL”命令?不,我查询记忆,MAX7301 没有 broadcast 命令。所以只能顺序写,我文章中就说“没有广播寄存器,所以同步性靠连续写帧来保证,帧间隔极短”。
下面给出一个实用的封装,用数组管理端口状态,方便上层调用:
static uint8_t g_out_state[24]; // 端口4~27的当前电平 void max7301_set_pin(uint8_t port, uint8_t high) { if (port < 4 || port > 27) return; g_out_state[port - 4] = high ? 1 : 0; max7301_write_output(port, g_out_state[port - 4]); }这样在业务代码里直接max7301_set_pin(8, 1)就能拉高端口 8。如果还要设置方向,可以在另一个函数里同时写方向。注意端口 4~15 支持 PWM,如果你把它当 PWM 输出,就不应该使用max7301_write_output,而是写 PWM 寄存器,这里不展开,但我会在末尾提一句避免误用。
5. 调试验证与性能优化技巧
5.1 验证时序的三种手段:示波器、逻辑分析仪、读回自测
软件 SPI 最大的敌人是时序不可见。我调试 MAX7301 时,第一反应是接逻辑分析仪并抓 CS、SCLK、MOSI 三根线,看一帧是否正好 16 个边沿,且 CS 低电平期间没有异常抖动。市面上常见的 8 通道逻辑分析仪配 PulseView 就够用。如果只抓到一个字节的 8 个边沿,说明驱动里把一个 16 位帧拆成了两个独立操作,中间 CS 被拉高或时钟有间隙,MAX7301 会把每个字节当独立命令,自然调不通。
另一种不需要额外仪器的验证方式是“读回自测”:把一个没接负载的端口方向设为输出低电平,然后读取它的输入寄存器。MAX7301 的输出端口(推挽)如果读到的是同一个电平,说明寄存器写和读都正常。我把这个自测函数放在驱动初始化之后:
void max7301_self_test(void) { max7301_write_dir(4, MAX7301_DIR_OUT); max7301_write_output(4, 1); uint8_t v = max7301_read_input(4); if (v == 0) { /* 写1读0,说明时序或地址错 */ } max7301_write_output(4, 0); v = max7301_read_input(4); if (v == 1) { /* 写0读1,同样有问题 */ } }注意:读输入寄存器的地址与输出寄存器相同,读命令时应确保 bit15=0。如果方向配置为输出,读输入返回的是输出锁存器的值。这个自测能立即发现命令构造或字节序问题。
5.2 提升软件 SPI 吞吐量的三个手段:内联、时间延时、DMA 不可用时的优化
软件模拟 SPI 的带宽有限,但三个优化办法能把帧速率翻倍:
第一,将max7301_spi_transfer改为内联函数,并让 HAL 的HAL_GPIO_WritePin被替换为直接寄存器操作。例如 STM32 上可以把MAX7301_MOSI_H()定义为GPIOA->BSRR = GPIO_PIN_7,比 HAL 调用减少十几条指令。第二,在帧与帧之间添加适当延时,但不要用HAL_Delay,因为它太慢。用for(volatile int i=0;i<10;i++);做短延时即可,目的是满足 CS 恢复时间。第三,如果 MAX7301 只有一个片选,可以把 CS 拉低后连续执行多个 16 位帧(比如连续写多个端口),最后再拉高 CS。手册允许 CS 保持低电平期间连续传输多个帧,只要每帧之间时钟有足够空闲高电平时间。这样减少了 CS 翻转次数,也能提升约 10% 的吞吐。
当然,如果应用对时序要求极高,唯一的正解是改用硬件 SPI 并使用 GPIO 软件片选。但那样就像开篇说的,可能没有空闲 SPI。软件模拟的优化上限在 2 Mbps 左右,对于 GPIO 扩展场景完全够用。我建议在代码注释里写明“本驱动时钟约 1.5 MHz”,方便后人评估。
5.3 驱动层最后的边界检查:三个容易忽略的坑
第一个坑是端口号检查。MAX7301 只有端口 4~27,但你调用的地方可能传 0~3,这些端口是特殊功能(PWM、中断输入),不能作为普通 IO 使用。我在max7301_set_pin里做了范围判断,但max7301_init里如果把端口 0 也写了一遍,就会改变配置寄存器的值。所以初始化循环要从 4 开始。
第二个坑是 MAX7301 的电平转换。它的 V+ 用于 IO 电平参考,如果 STM32 是 3.3V 而 MAX7301 的 V+ 接 5V,那么 MISO 输出的高电平可能是 5V,会反向灌入 STM32 引脚。软件模拟本身不关心电平,但硬件连接必须加电平转换或串电阻。这一点不是驱动能解决的,但我在代码注释中提醒。
第三个坑是“max7031”这个拼写。很多讨论区里有人把 MAX7301 写成 max7031,导致搜索驱动时看到完全不同的 7031 芯片资料。如果移植别人的代码,先确认命令结构和寄存器地址是否匹配,别被标题误导。
这些经验总结下来就一句话:软件 SPI 驱动 MAX7301 的核心不是把 IO 翻转得有多快,而是保证每个 16 位帧的地址、数据、读写位和 CS 时序都符合手册。把这些细节处理好,这块芯片能成为非常可靠的 GPIO 扩展器,而且完全绕开硬件 SPI 资源不足的尴尬。
本文还有配套的精品资源,点击获取