1. 项目缘起:当MCU的GPIO不够用时
做嵌入式开发的朋友,尤其是玩STM32的,应该都遇到过这个经典问题:项目做着做着,发现板子上的GPIO(通用输入输出)口不够用了。传感器要接几个,指示灯要控制一排,按键矩阵要扫描,可能还要预留几个口做调试或者未来扩展。这时候,看着STM32F4那颗芯片,虽然功能强大,但管脚资源终究是有限的,特别是当你的PCB板子已经画好,重新改版成本高昂或者时间不允许的时候。
我最近就遇到了这么一个情况。一个基于STM32F407的工控板项目,前期规划了数字量输入、输出、继电器控制以及状态显示。板子打样回来测试一切正常,结果客户临时增加需求,要求再扩展8路隔离数字输入和8路隔离数字输出。主控芯片的GPIO早已被分配殆尽,飞线都找不到地方。这时候,IO扩展芯片就成了救命稻草。
在众多IO扩展方案中,I²C和SPI接口的芯片是最常见的选择。I²C虽然只需要两根线,但在需要较高通信速率或更可靠通信的工业环境中,其开漏输出、需要上拉电阻、以及多主从模式下的仲裁机制,有时会带来一些不确定性。相比之下,SPI接口采用全双工、主从模式,通信速率高,时序简单可靠,在抗干扰方面通常表现更好。因此,我选择了Microchip公司的MCP23S17——一款通过SPI接口控制的16位IO扩展芯片。它正好能提供我需要的16个额外GPIO(可以配置为8输入+8输出),并且支持最高10MHz的SPI时钟,完全能满足实时性要求。
接下来的内容,就是我如何用STM32F4的硬件SPI,一步步驱动MCP23S17,并在这个过程中解决几个关键问题的完整记录。无论你是正在为GPIO资源发愁,还是想学习SPI设备驱动的实战细节,相信这篇分享都能给你带来直接的参考。
2. MCP23S17芯片核心机制剖析
在动手写代码之前,我们必须先吃透MCP23S17这颗芯片。它看起来简单,就是一个“SPI转GPIO”的桥梁,但内部的寄存器设计和配置逻辑,决定了我们能否稳定、高效地使用它。
2.1 地址寻址:如何与多个扩展芯片对话
MCP23S17支持在同一SPI总线上挂载最多8个设备。这是通过芯片的硬件地址引脚A2, A1, A0来实现的。这三个引脚接高电平(VDD)或低电平(VSS),就构成了一个3位的硬件地址。
芯片的完整设备操作码(Opcode)格式为:0100 A2 A1 A0 R/W。其中前4位0100是固定的,接着是3位硬件地址,最后1位是读写控制位(1为读,0为写)。例如,如果A2,A1,A0都接地(0),那么该芯片的写操作码就是0100 000 0,即0x40;读操作码是0100 000 1,即0x41。
注意:很多初学者容易忽略这一点,直接发送数据导致通信失败。你的第一个函数,就应该封装这个操作码的生成过程。我通常会定义一个宏或函数,根据硬件地址和读写方向,返回完整的操作码字节。
2.2 寄存器映射:控制一切的钥匙
MCP23S17的所有功能都通过读写一系列寄存器来实现。这些寄存器是8位的,但由于它有16个IO口(分为A组GPIOA和B组GPIOB),所以很多寄存器是成对出现的。理解几个最关键的寄存器是成功的关键:
IODIR (I/O Direction Register):方向寄存器
- 地址:0x00 (IODIRA), 0x01 (IODIRB)
- 功能:控制每个引脚是输入还是输出。对应位写
1,该引脚为输入(高阻抗);写0,则为输出。 - 实战要点:芯片上电后,所有引脚默认为输入状态(全1)。这意味着如果你不配置就直接当输出用,是驱动不了任何负载的。这是第一个必踩的“坑”。
GPIO (General Purpose I/O Port Register):数据寄存器
- 地址:0x12 (GPIOA), 0x13 (GPIOB)
- 功能:当引脚配置为输出时,向该寄存器写值可以控制引脚电平;当引脚配置为输入时,读取该寄存器可以获得引脚上的电平状态。
- 实战要点:写这个寄存器只会影响配置为输出的引脚,对输入引脚无效。读这个寄存器,返回的是引脚的实际物理电平,无论它被配置为输入还是输出。
GPPU (GPIO Pull-up Resistor Register):上拉电阻寄存器
- 地址:0x0C (GPPUA), 0x0D (GPPUB)
- 功能:当引脚配置为输入时,可以通过此寄存器独立使能内部约100kΩ的上拉电阻。这对于连接按键、开关等输入设备非常有用,可以省去外部上拉电阻。
- 实战要点:上拉电阻仅在输入模式下有效。在输出模式下使能上拉,其效果是不确定的,通常不建议这样做。
IPOL (Input Polarity Register):输入极性反转寄存器
- 地址:0x02 (IPOLA), 0x03 (IPOLB)
- 功能:如果某个输入引脚的外部信号是低电平有效(比如按键按下为低电平),但你的程序逻辑希望读到
1表示“有效”,那么可以设置该寄存器的对应位为1。这样,芯片会在内部将读取到的物理电平反转后再存入GPIO寄存器。 - 实战要点:这个功能非常实用,可以简化应用程序的逻辑判断,避免在代码中到处写“取反”操作。
OLAT (Output Latch Register):输出锁存器
- 地址:0x14 (OLATA), 0x15 (OLATB)
- 功能:这个寄存器反映了输出锁存器的当前状态。写GPIO寄存器实际上是在写OLAT。而读OLAT寄存器,返回的是输出锁存器的值,而不是引脚的实际物理电平。这在某些诊断场景下有用。
- 实战要点:通常我们操作GPIO寄存器即可。理解OLAT有助于明白芯片内部的数据流。
2.3 银行模式(BANK)与配置捷径
MCP23S17的寄存器地址映射有两种模式,由IOCON寄存器中的BANK位控制。
- BANK = 0 (默认):寄存器是交错排列的。例如,IODIRA地址0x00, IODIRB地址0x01, IPOLA地址0x02, IPOLB地址0x03,以此类推。这种模式下,A组和B组的对应寄存器地址是连续的。
- BANK = 1:寄存器是按功能分组的。所有A组寄存器在0x00-0x0A,所有B组寄存器在0x10-0x1A。
为什么这个很重要?因为BANK模式直接影响我们配置芯片的便捷性。在默认的BANK=0模式下,我们可以利用SPI的“连续写”功能,一次性配置整个芯片。例如,我们可以从地址0x00开始,连续发送多个字节,依次设置IODIRA, IODIRB, IPOLA, IPOLB, GPPUA, GPPUB等。这比单个寄存器读写效率高得多。
我强烈建议保持BANK=0的默认模式,并利用连续读写来优化你的驱动代码。在初始化时,通过一个SPI事务完成大部分寄存器配置,能显著提升效率并减少代码量。
3. STM32F4硬件SPI配置与驱动层封装
理解了芯片,接下来就是让STM32F4的SPI硬件动起来,并封装出稳定可靠的底层驱动函数。这里我选择使用STM32Cube HAL库,因为它兼容性好,但我会指出HAL库中需要特别注意的地方。
3.1 SPI外设初始化关键参数
假设我们使用STM32F407的SPI1(PA5-SCK, PA6-MISO, PA7-MOSI),片选信号CS我们用普通的GPIO(如PA4)来软件控制,这样更灵活。
在CubeMX中或代码中配置SPI1时,以下几个参数必须与MCP23S17匹配:
- 时钟极性(CPOL)与时钟相位(CPHA):这也就是常说的SPI模式。MCP23S17支持模式0和模式1。根据其数据手册时序图,模式0 (CPOL=0, CPHA=0)是最常用的。即时钟空闲时为低电平,在第一个时钟边沿(上升沿)采样数据。
- 数据大小(Data Size):必须为8位。
- 帧格式(Frame Format):Motorola格式(标准SPI)。
- 片选管理(NSS):设置为“软件控制”(Software NSS Management)。这样我们就可以用GPIO来控制CS引脚,在每次传输前拉低,传输后拉高。
- 波特率预分频器(Baud Rate Prescaler):根据你的系统时钟和所需速度设置。MCP23S17最高支持10MHz。对于STM32F4(系统时钟168MHz),选择SPI_BAUDRATEPRESCALER_16可以得到10.5MHz,是安全且快速的。如果布线较长或干扰大,可以适当降低速率,如使用SPI_BAUDRATEPRESCALER_32(约5.25MHz)。
初始化代码骨架如下:
// spi.c SPI_HandleTypeDef hspi1; void SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 全双工 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL = 0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA = 0 (注意HAL库定义) hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } } // 软件片选控制 #define MCP23S17_CS_GPIO_Port GPIOA #define MCP23S17_CS_Pin GPIO_PIN_4 #define MCP23S17_CS_LOW() HAL_GPIO_WritePin(MCP23S17_CS_GPIO_Port, MCP23S17_CS_Pin, GPIO_PIN_RESET) #define MCP23S17_CS_HIGH() HAL_GPIO_WritePin(MCP23S17_CS_GPIO_Port, MCP23S17_CS_Pin, GPIO_PIN_SET)关键细节:HAL库的CPHA定义。在标准SPI术语中,模式0是CPHA=0。但在STM32 HAL库的
SPI_InitTypeDef结构体里,CLKPhase成员设置为SPI_PHASE_1EDGE表示在第一个时钟边沿采样,这对应的是CPHA=0。很多人在此混淆,导致时序不对。记住:对于MCP23S17模式0,HAL库配置为CLKPhase = SPI_PHASE_1EDGE。
3.2 封装核心读写函数
有了SPI初始化和CS控制,我们就可以封装对MCP23S17的单字节和连续读写函数了。这是驱动层的核心。
// mcp23s17_driver.c // 根据硬件地址生成操作码 static uint8_t _get_opcode(uint8_t hw_addr, uint8_t rw) { // hw_addr: 0~7, rw: 0写/1读 return (0x40 | (hw_addr << 1) | rw); } /** * @brief 向MCP23S17指定寄存器写入一个字节 * @param hw_addr: 硬件地址 (0-7) * @param reg_addr: 寄存器地址 * @param data: 要写入的数据 * @retval HAL状态 */ HAL_StatusTypeDef MCP23S17_WriteByte(uint8_t hw_addr, uint8_t reg_addr, uint8_t data) { uint8_t tx_buf[3]; uint8_t rx_buf[3]; HAL_StatusTypeDef status; tx_buf[0] = _get_opcode(hw_addr, 0); // 写操作码 tx_buf[1] = reg_addr; tx_buf[2] = data; MCP23S17_CS_LOW(); status = HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 3, HAL_MAX_DELAY); MCP23S17_CS_HIGH(); return status; } /** * @brief 从MCP23S17指定寄存器读取一个字节 * @param hw_addr: 硬件地址 (0-7) * @param reg_addr: 寄存器地址 * @param p_data: 读取数据的存储指针 * @retval HAL状态 */ HAL_StatusTypeDef MCP23S17_ReadByte(uint8_t hw_addr, uint8_t reg_addr, uint8_t *p_data) { uint8_t tx_buf[3] = {0}; uint8_t rx_buf[3]; HAL_StatusTypeDef status; tx_buf[0] = _get_opcode(hw_addr, 1); // 读操作码 tx_buf[1] = reg_addr; MCP23S17_CS_LOW(); status = HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 3, HAL_MAX_DELAY); MCP23S17_CS_HIGH(); if (status == HAL_OK) { *p_data = rx_buf[2]; // 第三个字节是读取的数据 } return status; }为什么使用HAL_SPI_TransmitReceive而不是单独的Transmit和Receive?因为SPI是全双工通信,主设备发送数据的同时也在接收数据。即使我们只关心读取的数据,也需要发送一些东西(通常是0xFF或0x00)来产生时钟信号。TransmitReceive函数同时处理了发送和接收缓冲区,逻辑更清晰。在我们的写函数中,虽然不关心接收到的rx_buf,但这样写保持了代码对称性,且能捕获到SPI总线可能出现的错误。
3.3 高效连续读写函数
如前所述,利用BANK=0模式下地址连续的特性,我们可以实现高效的批量配置。
/** * @brief 向MCP23S17连续写入多个字节 (寄存器地址必须连续递增) * @param hw_addr: 硬件地址 * @param start_reg_addr: 起始寄存器地址 * @param p_data: 数据数组指针 * @param size: 要写入的数据字节数 * @retval HAL状态 */ HAL_StatusTypeDef MCP23S17_WriteBytes(uint8_t hw_addr, uint8_t start_reg_addr, uint8_t *p_data, uint16_t size) { uint8_t *tx_buf; HAL_StatusTypeDef status; uint16_t total_len = size + 2; // 操作码 + 起始地址 + 数据 tx_buf = (uint8_t*)malloc(total_len); if (tx_buf == NULL) return HAL_ERROR; tx_buf[0] = _get_opcode(hw_addr, 0); tx_buf[1] = start_reg_addr; memcpy(&tx_buf[2], p_data, size); MCP23S17_CS_LOW(); status = HAL_SPI_Transmit(&hspi1, tx_buf, total_len, HAL_MAX_DELAY); MCP23S17_CS_HIGH(); free(tx_buf); return status; } // 类似的连续读函数...这个函数在芯片初始化时非常有用,可以一次性设置好方向、上拉、极性等多个寄存器。
4. 应用层设计与实战:从初始化到完整控制
驱动层准备好后,我们就可以在应用层构建更易用的接口了。我的目标是封装出类似STM32 HAL库中HAL_GPIO_WritePin和HAL_GPIO_ReadPin那样简单的函数。
4.1 芯片初始化与配置流程
一个稳健的初始化流程应该包含以下步骤,我通常将其写成一个函数MCP23S17_Init:
- 延时等待电源稳定:上电后,给芯片一点时间(比如1ms)。
- 配置IOCON寄存器(可选但推荐):明确设置BANK=0,并可以根据需要设置其他选项,如是否使能硬件地址引脚内部上拉(
HAEN位在地址引脚全为0时需要注意)。 - 配置端口方向(IODIR):这是必须的。根据你的硬件设计,将连接LED、继电器的引脚设为输出,将连接按键、传感器的引脚设为输入。
- 配置输入上拉(GPPU):如果输入引脚需要上拉电阻,在此设置。
- 配置输入极性(IPOL):如果需要反转输入逻辑,在此设置。
- 设置输出初始值(GPIO/OLAT):将输出引脚设置为一个已知的、安全的初始状态(比如所有继电器断开,LED熄灭)。
这里给出一个初始化示例,假设我们将GPIOA的0-3引脚作为输出(控制LED),4-7引脚作为输入(带内部上拉的按键)。GPIOB暂时全部设为输入。
// mcp23s17_app.c #define MCP23S17_HW_ADDR 0 // 假设A2,A1,A0都接地 uint8_t MCP23S17_Init(void) { uint8_t config_data[22]; // 用于连续写入的缓冲区 uint8_t idx = 0; HAL_StatusTypeDef status; // 步骤1: 简单延时 HAL_Delay(10); // 步骤2: 配置IOCON寄存器 (地址0x0A和0x0B是同一个,BANK=0时访问0x0A) // 我们希望: BANK=0, SEQOP=0 (允许地址自增), HAEN=1 (使能硬件地址,如果用了的话) // IOCON默认值通常是0x00,我们明确设置一下。 status = MCP23S17_WriteByte(MCP23S17_HW_ADDR, 0x0A, 0x00); if (status != HAL_OK) return 0; // 步骤3 & 4 & 5: 使用连续写,一次性配置IODIR, IPOL, GPPU // 准备数据: IODIRA, IODIRB, IPOLA, IPOLB, GPPUA, GPPUB // 假设: GPIOA: 低4位输出(0), 高4位输入(1) -> 0xF0 // GPIOB: 全输入 -> 0xFF // IPOL全默认(不反转) -> 0x00, 0x00 // GPPU: GPIOA高4位使能上拉 -> 0xF0, GPIOB不上拉 -> 0x00 config_data[idx++] = 0xF0; // IODIRA config_data[idx++] = 0xFF; // IODIRB config_data[idx++] = 0x00; // IPOLA config_data[idx++] = 0x00; // IPOLB config_data[idx++] = 0x00; // 保留给GPINTENA (中断使能,暂不用) config_data[idx++] = 0x00; // 保留给GPINTENB config_data[idx++] = 0x00; // DEFVALA config_data[idx++] = 0x00; // DEFVALB config_data[idx++] = 0x00; // INTCONA config_data[idx++] = 0x00; // INTCONB config_data[idx++] = 0xF0; // GPPUA (A口高4位上拉) config_data[idx++] = 0x00; // GPPUB status = MCP23S17_WriteBytes(MCP23S17_HW_ADDR, 0x00, config_data, idx); if (status != HAL_OK) return 0; // 步骤6: 设置输出初始值 (GPIOA低4位输出低电平) uint8_t init_output = 0x00; // 全部输出低 status = MCP23S17_WriteByte(MCP23S17_HW_ADDR, 0x12, init_output); // 写GPIOA if (status != HAL_OK) return 0; return 1; // 初始化成功 }4.2 封装易用的GPIO操作函数
为了让上层应用完全不用关心寄存器地址,我们可以封装以下函数:
// mcp23s17_app.h typedef enum { MCP_PORT_A = 0, MCP_PORT_B = 1 } MCP23S17_Port_t; void MCP23S17_GPIO_WritePin(MCP23S17_Port_t port, uint8_t pin, uint8_t val); uint8_t MCP23S17_GPIO_ReadPin(MCP23S17_Port_t port, uint8_t pin); void MCP23S17_GPIO_WritePort(MCP23S17_Port_t port, uint8_t val); uint8_t MCP23S17_GPIO_ReadPort(MCP23S17_Port_t port);实现起来也很直接,以写单个引脚为例:
// mcp23s17_app.c void MCP23S17_GPIO_WritePin(MCP23S17_Port_t port, uint8_t pin, uint8_t val) { uint8_t reg_addr = (port == MCP_PORT_A) ? 0x12 : 0x13; // GPIOA/B寄存器地址 uint8_t current_port_val = 0; uint8_t new_port_val = 0; // 1. 先读取端口当前值 MCP23S17_ReadByte(MCP23S17_HW_ADDR, reg_addr, ¤t_port_val); // 2. 修改指定位 if (val) { new_port_val = current_port_val | (1 << pin); } else { new_port_val = current_port_val & ~(1 << pin); } // 3. 写回新值 MCP23S17_WriteByte(MCP23S17_HW_ADDR, reg_addr, new_port_val); }注意:这里采用了“读-改-写”的方式。虽然MCP23S17支持直接对单个输出位进行操作(通过设置
OLAT寄存器,但本质还是需要先读),但在多任务或中断环境下,直接操作整个端口可能因为竞态条件导致问题。对于简单的单线程应用,这样写没问题。如果系统复杂,可以考虑在驱动层加锁,或者确保对同一端口的操作是串行的。
4.3 实战中的问题排查与性能优化
在实际焊接好电路板,开始调试时,你可能会遇到以下问题:
问题一:SPI通信完全失败,读回的数据全是0xFF或0x00。
- 检查电源和地:确保MCP23S17的VDD和VSS连接正确且稳定。用万用表量一下电压。
- 检查硬件连接:SCK, MOSI, MISO, CS四根线是否接反、虚焊。特别注意:MCP23S17的MISO是主设备输入,要接到STM32的MISO引脚(通常是PA6),不要接错。
- 检查SPI模式:确认STM32的SPI模式与MCP23S17一致(模式0)。用逻辑分析仪或示波器抓取SCK和MOSI的波形,看第一个数据位(操作码的最高位
0)是否在CS拉低后、SCK第一个上升沿之前就稳定了。 - 检查片选时序:确保CS信号在每次完整的SPI传输(操作码+地址+数据)前后有明确的拉低和拉高。传输期间必须保持低电平。
问题二:可以写入,但读取的数据不对,或者输出引脚不受控。
- 检查寄存器地址:确保你读写的寄存器地址是正确的。再次核对数据手册的寄存器映射表(BANK=0模式下)。
- 检查引脚方向(IODIR):这是最常见的原因!输出没反应?先确认该引脚是否被配置成了输出(对应IODIR位为0)。输入读不到正确电平?确认是否配置为输入,并且上拉(如果需要)已使能。
- 检查硬件地址:如果你只挂了一片芯片,确保硬件地址引脚(A2,A1,A0)的接线与你代码中的
hw_addr一致。如果悬空,内部可能有上拉或下拉,导致地址不确定。
性能优化建议:
- 减少SPI事务次数:对于需要同时设置或读取多个引脚的状态,尽量使用
MCP23S17_GPIO_WritePort和MCP23S17_GPIO_ReadPort这类端口级函数,一次SPI传输完成16个引脚的操作,而不是逐个引脚操作。 - 使用DMA(如果SPI速率要求极高):对于需要极高速率刷新IO状态的场景(虽然不常见),可以考虑使用STM32的SPI DMA功能来传输数据。但需要注意,MCP23S17的SPI接口最高10MHz,对于大多数控制应用,使用查询或中断方式的HAL_SPI函数已经绰绰有余,引入DMA反而增加复杂性。
- 中断功能的使用:MCP23S17支持输入变化中断。你可以配置
GPINTEN寄存器来使能特定引脚的中断,当输入引脚电平发生变化时,INT引脚会输出信号给STM32的外部中断。这对于实时响应按键等事件非常有用,可以避免轮询。但这部分内容较多,需要配置多个寄存器(GPINTEN,DEFVAL,INTCON),并处理好STM32端的中断服务函数,是进阶应用。
5. 项目集成与稳定性考量
将MCP23S17集成到实际项目中,除了功能实现,更要考虑长期运行的稳定性。
5.1 硬件设计注意事项
- 电源去耦:在MCP23S17的VDD和VSS引脚附近,务必放置一个0.1uF的陶瓷电容,并尽量靠近芯片。这是保证数字芯片稳定工作的基本要求,可以滤除高频噪声。
- 信号线长度与上拉:如果SPI总线较长(超过10cm),考虑在SCK、MOSI、MISO线上串联一个小电阻(如22Ω-100Ω),以抑制信号反射。CS线一般不需要。MISO作为从设备输出,在高速或长线情况下,可以在STM32端加上一个弱上拉(如10kΩ),确保空闲时为高电平。
- 未用引脚处理:对于不使用的输入引脚,建议在软件中将其配置为输出并设置为低电平,或者配置为输入并使能内部上拉,避免引脚悬空引入噪声和额外功耗。
- 驱动能力:MCP23S17每个引脚的拉/灌电流最大为25mA,但所有端口的总电流有限制(详见数据手册)。驱动继电器、电机等感性负载时,务必使用三极管或MOS管隔离驱动,切勿直接用IO口驱动。
5.2 软件鲁棒性增强
- 增加通信超时与重试:在
MCP23S17_ReadByte/WriteByte函数中,不要无限期等待HAL_SPI函数。可以设置一个合理的超时时间,如果通信失败,进行有限次数的重试,并在重试失败后上报错误。 - 初始化状态验证:初始化函数完成后,可以增加一个简单的“回读测试”。例如,向一个已知的寄存器(如IODIR)写入一个特定值,再读回来比较,确保通信链路从一开始就是正常的。
- 错误处理与日志:驱动函数应返回明确的状态(如HAL_StatusTypeDef),应用层根据状态做出反应。在调试阶段,可以将错误信息通过串口打印出来,便于定位。
- 考虑多任务访问:如果你的系统有RTOS或多个中断可能同时访问MCP23S17,需要引入互斥锁(如FreeRTOS的
xSemaphore)来保护对SPI总线和芯片的访问,防止数据冲突。
5.3 扩展思考:级联与更复杂的系统
当16个IO还不够时,你可以轻松地级联多片MCP23S17。只需将它们的SPI总线(SCK, MOSI, MISO)并联,为每片芯片分配不同的硬件地址(通过A2,A1,A0引脚),并用不同的GPIO控制各自的CS片选信号。在软件上,你只需要在操作时指定正确的硬件地址即可。这种方式可以几乎无限扩展GPIO数量,只受限于你拥有的GPIO来控制CS线以及SPI总线的负载能力。
另一种思路是使用MCP23S17的中断功能。将所有扩展芯片的INT引脚通过一个与门(或直接连接,如果支持线或)接到STM32的一个外部中断引脚上。当任何一片芯片上的任何使能了中断的输入引脚状态变化时,STM32都能收到中断,然后在中断服务程序中,依次查询每片MCP23S17的INTF(中断标志)寄存器来确定是哪一片、哪一个引脚发生了变化,从而实现高效的事件驱动响应。
通过这个项目,我不仅解决了GPIO资源不足的燃眉之急,更深入理解了SPI通信的细节和IO扩展芯片的完整驱动方法。从芯片手册解读、寄存器配置,到SPI驱动封装、应用层设计,再到最后的调试和优化,整个过程是一个典型的嵌入式外设开发流程。希望这份详细的记录,能让你在遇到类似需求时,少走一些弯路,更快地让手中的芯片按照你的意愿工作起来。