3、SPI接口GPIO扩展芯片详解:MCP23S17原理、寄存器映射与级联设计
好,咱们今天来聊聊MCP23S17这颗芯片。说实话,在SPI接口的GPIO扩展芯片里,它是我用得最多的一款。为什么?因为它皮实、便宜,而且级联起来特别方便。我记得有一次做工业控制板,需要控制64个继电器,主控芯片的IO口根本不够用。当时我第一反应就是:上MCP23S17,级联四片,搞定。
你可能会问,市面上那么多GPIO扩展芯片,为什么偏偏选它?嗯,咱们先看看它的核心参数,你就明白了。
3.1 MCP23S17的核心特性
MCP23S17是一颗16位GPIO扩展芯片。说白了,就是通过SPI总线,给你额外扩展出16个IO口。这16个口分成两组:PORTA和PORTB,每组8个。
我个人比较看重的几个特性:
- 工作电压范围宽:2.7V到5.5V。3.3V和5V系统都能用,不用额外加电平转换。
- 每个IO口独立配置:输入、输出、上拉电阻,都可以单独设置。不像有些芯片,一设就是一组。
- 中断输出:支持电平变化中断。这个在按键检测场景下特别有用。
- 级联能力:最多支持8片级联,也就是128个IO口。地址通过三个硬件引脚A0、A1、A2设置。
重要提醒:MCP23S17的SPI时钟最高支持10MHz。但实际项目中,我建议不要跑满。我一般控制在5MHz左右,尤其是线缆比较长的时候。跑太快容易出时序问题,别问我怎么知道的...
3.2 寄存器映射——你得记住的几个关键地址
MCP23S17的寄存器映射,其实不算复杂。但如果你不熟悉,第一次看数据手册可能会有点懵。我帮你梳理一下最常用的几个寄存器。
| 寄存器名称 | 地址(十六进制) | 功能描述 |
|---|---|---|
| IODIRA | 0x00 | PORTA方向寄存器,0=输出,1=输入 |
| IODIRB | 0x01 | PORTB方向寄存器 |
| GPIOA | 0x12 | PORTA数据寄存器,读/写IO电平 |
| GPIOB | 0x13 | PORTB数据寄存器 |
| GPPUA | 0x0C | PORTA上拉电阻使能,1=使能 |
| GPPUB | 0x0D | PORTB上拉电阻使能 |
| INTCONA | 0x08 | 中断控制寄存器A |
| GPINTENA | 0x04 | 中断使能寄存器A |
你看,其实常用的就这几个。我个人习惯把IODIR和GPIO这两个寄存器的地址背下来,因为每次初始化都要用到。其他的用到再查手册也不迟。
3.3 SPI通信协议——读写操作详解
MCP23S17的SPI通信,和普通SPI设备有点不一样。它有一个控制字节的概念。每次读写操作,必须先发一个控制字节,然后再发寄存器地址,最后才是数据。
控制字节的格式是这样的:
Bit 7: 操作码,固定为0
Bit 6-4: 芯片地址(对应A2、A1、A0引脚)
Bit 3-1: 保留位,写0
Bit 0: 读写标志,0=写,1=读
举个例子。假设你的芯片地址引脚A2=0、A1=0、A0=0,那么:
- 写操作的控制字节:0x40(0100 0000)
- 读操作的控制字节:0x41(0100 0001)
嗯,这里要注意。如果你级联了多片芯片,每片的地址必须不同。地址通过硬件引脚设置,上电后不能更改。所以设计PCB时,一定要把A0、A1、A2用跳线或电阻拉到VCC或GND,方便后期调整。
我的小技巧:在写驱动代码时,我习惯把控制字节定义成宏。比如:
#define MCP23S17_WRITE(addr) (0x40 | ((addr) << 1))
#define MCP23S17_READ(addr) (0x41 | ((addr) << 1))
这样代码看起来清爽多了,也不容易写错地址。
3.4 实战:初始化与读写代码
光说不练假把式。咱们直接上代码。下面是一个典型的MCP23S17初始化流程,我用的是STM32的HAL库。
// 假设CS引脚是PA4
#define MCP23S17_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET)
#define MCP23S17_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)
// 芯片地址,对应A2 A1 A0 = 000
#define MCP23S17_ADDR 0x00
// 写一个字节到指定寄存器
void MCP23S17_WriteReg(uint8_t reg, uint8_t data) {
uint8_t tx_buf[3];
tx_buf[0] = 0x40 | (MCP23S17_ADDR << 1); // 控制字节,写操作
tx_buf[1] = reg;
tx_buf[2] = data;
MCP23S17_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 3, 10);
MCP23S17_CS_HIGH();
}
// 从指定寄存器读一个字节
uint8_t MCP23S17_ReadReg(uint8_t reg) {
uint8_t tx_buf[3], rx_buf[3];
tx_buf[0] = 0x41 | (MCP23S17_ADDR << 1); // 控制字节,读操作
tx_buf[1] = reg;
tx_buf[2] = 0xFF; // 随便发个数据,为了读取
MCP23S17_CS_LOW();
HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 3, 10);
MCP23S17_CS_HIGH();
return rx_buf[2]; // 第三个字节才是读回来的数据
}
// 初始化函数
void MCP23S17_Init(void) {
// 设置PORTA和PORTB全部为输出
MCP23S17_WriteReg(0x00, 0x00); // IODIRA = 0x00
MCP23S17_WriteReg(0x01, 0x00); // IODIRB = 0x00
// 初始输出全部为低电平
MCP23S17_WriteReg(0x12, 0x00); // GPIOA = 0x00
MCP23S17_WriteReg(0x13, 0x00); // GPIOB = 0x00
}
你看,代码其实不复杂。核心就是控制字节的拼装。我刚开始用的时候,老是把控制字节的bit0搞反。后来养成了一个习惯:写操作控制字节末尾是0,读操作末尾是1。记住这个,基本就不会错了。
3.5 级联设计——如何扩展出128个IO口
级联是MCP23S17的一大亮点。你想想看,三根地址线,最多可以区分8个地址。每个芯片16个IO口,8片就是128个。这个数量,对于大多数嵌入式项目来说,绰绰有余了。
级联的硬件连接其实很简单:
- SPI总线共用:所有芯片的SCK、MOSI、MISO都连在一起。
- 片选信号共用:所有芯片的CS也连在一起。你没看错,是共用!
- 地址引脚独立:每片芯片的A0、A1、A2设置不同的电平组合。
为什么CS可以共用?因为MCP23S17是通过控制字节中的地址来区分芯片的。CS拉低后,所有芯片都会收到SPI数据,但只有地址匹配的那片才会响应。这个设计很巧妙,省了主控的CS引脚。
曾经踩过的坑:级联时,MISO引脚是开漏输出,需要外接上拉电阻。我有一回忘了加上拉,结果读回来的数据全是0xFF。查了半天,最后发现是MISO没上拉。建议每片芯片的MISO引脚都加一个4.7kΩ上拉电阻到VCC。
级联后的读写操作,和单芯片完全一样。只需要在控制字节中填入对应的芯片地址即可。比如:
// 操作地址为001的芯片(A2=0, A1=0, A0=1)
#define MCP23S17_ADDR_1 0x01
// 写操作
MCP23S17_WriteReg_WithAddr(MCP23S17_ADDR_1, 0x12, 0xFF); // 让PORTA全部输出高
3.6 中断与防抖——实际项目中的经验
MCP23S17支持中断输出。当某个配置为输入的引脚电平发生变化时,INT引脚会拉低。这个功能在按键检测中特别实用。
但有个问题:机械按键会有抖动。如果不做防抖处理,一次按键可能会触发多次中断。我在项目中用过两种方法:
- 硬件防抖:在按键引脚上加RC滤波电路。R取10kΩ,C取0.1μF,时间常数约1ms。简单粗暴,效果还行。
- 软件防抖:在中断服务函数中,延时10ms后再读取电平确认。代码实现如下:
// 中断回调函数(假设按键接在PORTA的bit0)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if(GPIO_Pin == MCP23S17_INT_PIN) {
// 延时10ms防抖
HAL_Delay(10);
// 读取PORTA的电平
uint8_t porta_val = MCP23S17_ReadReg(0x12);
// 检查bit0是否真的被按下(低电平有效)
if((porta_val & 0x01) == 0) {
// 执行按键处理逻辑
Key_Process();
}
}
}
我个人更倾向于软件防抖。因为硬件防抖需要额外元件,增加BOM成本。而且软件防抖的延时时间可以灵活调整,适应不同按键的机械特性。
3.7 小结
MCP23S17这颗芯片,说复杂也复杂,说简单也简单。你只要掌握了控制字节的拼装、常用寄存器的地址、以及级联的地址设置,基本就能上手了。
最后再啰嗦一句:数据手册是最好的老师。我每次用这颗芯片,都会把数据手册的寄存器映射表打印出来,放在手边。遇到问题,先翻手册,再上网搜。这个习惯帮我省了不少时间。
下一章,咱们聊聊I2C接口的GPIO扩展芯片。你会发现,虽然接口不同,但很多设计思路是相通的。