MCP23017 I2C扩展IO实战:输入测试程序与寄存器配置详解

发布时间:2026/9/8 8:24:55
MCP23017 I2C扩展IO实战:输入测试程序与寄存器配置详解 简介面向51单片机开发者的MCP23017 I²C IO扩展芯片输入测试例程演示如何通过I²C总线控制16位GPIO实现端口输入读取与状态验证适合学习I²C通信协议、寄存器配置及IO扩展应用的入门与进阶开发者。压缩包共30个文件整体约80KB主要包括C源文件、头文件、Keil工程文件、HEX烧录文件以及编译过程生成的调试文件便于对照工程理解代码组织与编译流程也可直接烧录到单片机快速验证。已有1184人学习资源覆盖初始化、寄存器配置、输入状态读取等关键环节并包含中断与输出切换的测试视角能帮助读者在LED控制、传感器数据采集等实际项目中灵活扩展IO通道降低硬件设计复杂度。文件结构清晰源文件、I²C驱动与主控模块划分明确便于二次开发与移植。 去年做一款工业控制面板时需要同时采集32路拨码开关和12路按键主控STM32F103的GPIO一下子见底了。翻遍芯片手册最终选了Microchip的MCP23017来做IIC IO扩展16路输入只占两根I2C引脚硬件三天搞定输入采集一次性跑通。这篇就把MCP23017输入测试程序的完整思路、寄存器配置和踩过的坑捋一遍给做单片机扩展IO的朋友提供一份可以直接抄作业的例程参考。1. 为什么是MCP23017一次IO不够用带来的选型过程1.1 真实场景还原先交代一下当时的处境项目里有一个操作面板上面有16位拨码开关、8个独立按键还有4个旋转编码器需要输入接入加起来超过30路输入。STM32F103C8T6虽然号称有37个GPIO但扣除晶振、下载口、串口调试、I2C外接存储这些固定占用真正能自由支配的引脚不到20个怎么分配都不够。当时考虑过的方案有三条换更大封装的MCU、用74HC165并转串扩展输入、用I2C IO扩展芯片。换MCU牵扯到整个硬件改版项目周期不允许74HC165虽然便宜但每个芯片只扩展8路并行输入而且需要多根控制线布线麻烦最后选中MCP23017一片就能扩展16路IO只消耗I2C的SCL和SDA两根线8片级联下来最多128路完全满足面板输入采集需求。1.2 MCP23017的关键特性和选型对比MCP23017是Microchip推出的16位I2C IO扩展芯片两个8位端口GPA和GPB每个引脚都可以独立配置为输入或输出。和同类的PCF8574相比MCP23017的优势非常明显PCF8574只有8路且不带寄存器级中断控制MCP23017支持每个引脚的独立中断使能、自带内部弱上拉GPPU寄存器还支持I2C地址复用扩展一颗芯片的地址选择引脚A2/A1/A0可以组合出8个不同地址一条I2C总线上最多挂8片。另外一个容易被忽略的点是MCP23017的工作电压范围是1.8V到5.5V这意味着3.3V系统还是5V系统都能直接使用I2C引脚本身带逻辑电平转换能力对于大多数MCU来说不用额外加电平转换芯片。输入模式下每路IO都支持内部上拉省掉了一堆外部贴片电阻这在面板按键采集场景里非常实用。2. 输入测试前的硬件准备接线和I2C地址不容马虎2.1 引脚说明和最小系统接线MCP23017的引脚看起来多真正接起来并不复杂。一个最小输入测试系统只需要7根连线引脚功能连接说明VDD电源正接3.3V或5V对地加0.1uF去耦电容VSS电源地接GNDSCLI2C时钟接MCU的SCL需外部上拉电阻SDAI2C数据接MCU的SDA需外部上拉电阻A0/A1/A2地址选择接GND为0接VDD为1默认全接地RST复位引脚低电平复位默认接VDD或通过10k电阻上拉INTA/INTB中断输出输入测试不用可悬空使用中断时接MCU外部中断如果像我一样只用输入功能测试INTA和INTB可以直接不接。RST引脚必须处理接VDD或者串10k上拉到VDD都行悬空的话芯片上电后可能处于不定状态我第一次测试时就因为忘记接RST导致芯片完全不应答。2.2 地址配置与多片扩展MCP23017的I2C地址是7位固定高4位是0100低3位由A2/A1/A0引脚电平决定。地址计算公式是0x20 (A22) (A11) A0。全部接地就是0x20A0接VDD就是0x21以此类推8种组合对应0x20到0x27。这里有一个特别容易搞混的细节MCU的I2C外设地址处理方式不同。比如STM32的HAL库I2C地址是8位格式需要在7位地址基础上左移一位所以0x20实际要填成0x40。而Arduino的Wire库直接使用7位地址0x20就行。我在两种平台上都写过第一次用STM32时填了0x20I2C通信一直超时排查了半天才想起来HAL库要的是8位地址。2.3 容易被忽视的硬件细节上拉电阻和电平匹配I2C总线上的SCL/SDA是开漏结构必须要有上拉电阻才能正常工作。常见做法是两根线各接一个4.7kΩ电阻到VDD总线长度短的话10kΩ也可以用实测下来4.7kΩ在100kHz和400kHz模式下都很稳定。如果I2C线拉得比较长或者线上挂的设备多上拉电阻要适当减小到2.2kΩ3.3kΩ否则上升沿变缓会导致通信错误。还有一个电平匹配问题MCP23017的IO引脚电平跟随VDD假设MCU是3.3V、MCP23017用5V供电输入引脚读到5V高电平虽然芯片手册说I2C引脚兼容5V但普通IO引脚直接接5V还是有一定风险。安全做法是保持两者电压一致都用3.3V或者都用5V。我见过有人在3.3V系统里给MCP23017供5V结果按键输入引脚读到异常高电平的案例实际上就是电平不匹配导致的钳位问题。3. 输入模式的核心寄存器逐位拆解3.1 IODIR、GPPU、GPIO三个寄存器决定输入通路MCP23017的寄存器遵循一个规律每个8位端口都有独立的A组和B组寄存器默认情况下BANK位为0A组寄存器地址在前B组紧接着在后。输入测试只需要关注几个关键寄存器寄存器地址作用IODIRA / IODIRB0x00 / 0x01IO方向控制1为输入0为输出复位默认全1GPPUA / GPPUB0x0C / 0x0D内部上拉使能1开启0关闭GPIOA / GPIOB0x12 / 0x13读该寄存器获取引脚电平写该寄存器输出电平IPOLA / IPOLB0x02 / 0x03输入极性翻转默认0为正常极性配置输入模式的顺序其实非常简单IODIR写1让引脚变成输入GPPU写1开启内部上拉然后直接读GPIO寄存器就能拿到电平。注意IODIR的复位值本身就是全1也就是所有引脚上电默认就是输入状态理论上不写IODIR直接读GPIO也能读到数据但为了程序清晰起见还是建议显式配置一遍。关于IPOL寄存器很多初学者不明白它的作用。它可以把输入电平做逻辑翻转比如某个引脚外部接的是高电平有效信号置1后读回来的值就从1变成0省去在代码里做取反操作。输入测试程序不建议用这个功能保持默认0让GPIO寄存器直接反映引脚真实电平方便调试时对照万用表测量。3.2 关于IOCON默认值就够了这里有个小坑IOCON寄存器地址0x0A控制芯片的配置模式默认值0x00也就是BANK0、SEQOP0。这个默认值对绝大多数应用是友好的因为BANK0意味着寄存器按GPA、GPB连续排列可以一次连续写多个寄存器。真正要注意的是SEQOP位bit5。它控制内部地址指针是否自动递增默认0表示递增。这本来是好事允许连续写入多个寄存器但如果某段程序意外把IOCON写成0x20SEQOP变成1地址指针不再自动递增后续连续读写就会出问题。比如你往IODIRA写入后紧接着想写IODIRB第二个字节会因为地址没递增而写到IODIRA里数据全部错乱。排查这类问题很头疼我建议在初始化函数里显式写一遍IOCON 0x00把芯片状态固定下来避免受到上电时序或外部干扰的影响。3.3 中断输入与普通输入的区别如果只是做按键扫描轮询MCP23017的中断功能可以完全不用管。但如果输入信号比较稀疏、MCU又需要低功耗待机轮询就不合适了这时要用到GPINTEN、DEFVAL、INTCON三组寄存器配合INTA/INTB引脚。GPINTEN使能对应引脚的中断DEFVAL设置引脚的比较基准值INTCON决定是按照电平变化触发还是按照与DEFVAL比较触发。简单说INTCON0时引脚电平从高到低或从低到高的任何变化都会触发中断INTCON1时只有引脚电平变得和DEFVAL设定值一致时才触发。中断触发后读INTCAP寄存器可以捕获触发时的引脚状态这个功能在做边沿检测时特别有用。说实话对于输入测试程序这个目标中断是锦上添花。先把基本的轮询读引脚跑通再考虑中断应用不迟。我在实际项目里是先用轮询把面板所有按键状态读回来验证接线和寄存器配置无误后再根据功耗需求引入中断机制。4. 完整输入测试程序STM32 HAL和Arduino双版本4.1 STM32 HAL库版本两步配置一条命令读16位以STM32F103 HAL库为例I2C外设配置为100kHz标准模式MCP23017地址填0x400x20左移一位。初始化函数如下#define MCP23017_ADDR (0x20 1) // I2C 8位地址格式 void MCP23017_Input_Init(I2C_HandleTypeDef *hi2c) { uint8_t config[2]; uint8_t iocon 0x00; // 显式复位IOCON确保BANK0、SEQOP0 HAL_I2C_Mem_Write(hi2c, MCP23017_ADDR, 0x0A, I2C_MEMADD_SIZE_8BIT, iocon, 1, 100); // IODIRA0xFF, IODIRB0xFF全部配置为输入 config[0] 0xFF; config[1] 0xFF; HAL_I2C_Mem_Write(hi2c, MCP23017_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, config, 2, 100); // GPPUA0xFF, GPPUB0xFF全部开启内部上拉 HAL_I2C_Mem_Write(hi2c, MCP23017_ADDR, 0x0C, I2C_MEMADD_SIZE_8BIT, config, 2, 100); }上面这个写法利用了BANK0模式下寄存器连续排列的特性一次写两个字节先写0x00地址处的IODIRA第二个字节自动落到IODIRB。读16路输入也是一样uint16_t MCP23017_ReadAllInput(I2C_HandleTypeDef *hi2c) { uint8_t data[2]; HAL_I2C_Mem_Read(hi2c, MCP23017_ADDR, 0x12, I2C_MEMADD_SIZE_8BIT, data, 2, 100); return (uint16_t)((data[1] 8) | data[0]); // 高字节是GPB低字节是GPA }从0x12 GPIOA开始连续读两个字节第一个字节是GPA状态第二个字节是GPB状态组合成16位整型数返回。这样主函数里只需要一句uint16_t key MCP23017_ReadAllInput(hi2c1);就能拿到全部16路输入的状态配合按键消抖逻辑就能直接投入项目使用。4.2 Arduino Wire库版本多字节连续写入的正确姿势Arduino环境下使用Wire库代码会更紧凑。初始化时把IODIR、IPOL、GPINTEN、DEFVAL、INTCON一次性连续写入然后单独设置GPPU#include Wire.h #define MCP23017_ADDR 0x20 void MCP23017_Init() { // 连续写入0x00~0x09共10个寄存器 Wire.beginTransmission(MCP23017_ADDR); Wire.write(0x00); // 起始寄存器IODIRA Wire.write(0xFF); // IODIRA 全部输入 Wire.write(0xFF); // IODIRB 全部输入 Wire.write(0x00); // IPOLA 正常极性 Wire.write(0x00); // IPOLB 正常极性 Wire.write(0x00); // GPINTENA 关闭中断 Wire.write(0x00); // GPINTENB 关闭中断 Wire.write(0x00); // DEFVALA Wire.write(0x00); // DEFVALB Wire.write(0x00); // INTCONA Wire.write(0x00); // INTCONB Wire.endTransmission(); Wire.beginTransmission(MCP23017_ADDR); Wire.write(0x0C); // 起始寄存器GPPUA Wire.write(0xFF); // GPPUA 打开内部上拉 Wire.write(0xFF); // GPPUB 打开内部上拉 Wire.endTransmission(); } uint16_t MCP23017_ReadAll() { Wire.beginTransmission(MCP23017_ADDR); Wire.write(0x12); // 起始寄存器GPIOA Wire.endTransmission(); Wire.requestFrom(MCP23017_ADDR, 2); // 连续读2字节 uint8_t gpioa Wire.read(); uint8_t gpiob Wire.read(); return (uint16_t)((gpiob 8) | gpioa); }注意读操作之前要用一次beginTransmission把内部地址指针指到GPIOA然后再requestFrom读两字节。不要图省事在requestFrom里直接指定寄存器地址Wire库没有这个重载指针不指对读回来的数据就是错的。4.3 按键消抖逻辑怎么加MCP23017引脚直接接按键到GND时按键按下引脚读到0松开引脚被内部上拉拉高读到1。机械按键不可避免会有抖动简单可靠的消抖方法是连续多次采样状态稳定后才认为有效uint16_t key_stable 0xFFFF; uint16_t key_raw 0xFFFF; uint8_t debounce_cnt 0; void Key_Scan(void) { uint16_t key_new MCP23017_ReadAllInput(hi2c1); if (key_new ! key_raw) { key_raw key_new; debounce_cnt 0; } else if (debounce_cnt 5) { debounce_cnt; } else { key_stable key_new; // 连续5次采样一致状态稳定 } }这个逻辑在10ms周期定时器里调用按键状态大约50ms后更新一次手感合适也不会因为抖动产生误动作。比起延时消抖这种非阻塞方式更适合和RTOS或者主循环搭配使用。5. 实测中踩到的坑和不看手册发现不了的行为5.1 为什么读回来全是0xFF输入测试最常见的现象是GPIO寄存器读回来全是0xFF按键怎么按都没变化。遇到这种情况先别怀疑芯片坏了按顺序排查先用万用表量按键引脚电平按键按下时引脚有没有真正被拉到GND再看GPPU寄存器配置是否生效如果内部上拉没打开引脚悬空时读到的电平就是随机的恰好为高电平的概率很大最后检查按键和MCP23017是不是共地很多新手把按键接在另一个电源地上电平永远拉不下来。还有一个隐蔽的问题如果用的是我之前说的GPPU内部上拉它的上拉能力很弱实际阻值在100kΩ级别长线连接时耦合噪声很容易把电平拉低或拉高。面板输入这类场景建议在按键引脚到VDD之间外部加10kΩ上拉电阻抗干扰能力会明显提升。5.2 地址到底该填0x20还是0x40这个坑绝大多数人都踩过包括我自己。MCP23017手册里写的地址是7位格式0x20但STM32 HAL库的I2C地址参数要求8位格式也就是要左移一位变成0x40。Arduino的Wire库直接用0x20。如果在STM32里填了0x20I2C通信会一直返回HAL_TIMEOUT因为发送的从机地址不对芯片根本不响应。稳妥的判断方法是看初始化时HAL_I2C_Mem_Write的返回值返回HAL_OK说明通信正常如果返回HAL_ERROR或者HAL_TIMEOUT优先怀疑地址格式问题。可以在I2C总线上挂一个逻辑分析仪抓一下起始条件后面的第一个字节MCP23017的7位地址0x20在总线上的实际数据是0x40看到0x20就说明地址填错了。5.3 连续读写和芯片内部地址指针的微妙关系MCP23017内部有一个地址指针每次读或写操作后会自动递增。这个特性方便了连续多字节访问但也埋了不少雷。比如你用HAL_I2C_Mem_Read读取GPIOA时只读了一个字节内部指针停在了0x13GPIOB下次直接再发读命令数据是从0x13读出来的而不是你以为的0x12。所以任何一次读操作之前都建议用Mem_Read或beginTransmission明确指定起始寄存器地址不要依赖上一次操作后指针的位置。我在测试程序里特意把读地址固定写在每次读操作前面就是为了杜绝这种隐性问题。5.4 逻辑分析仪是排查I2C问题最快的手段做I2C调试逻辑分析仪几乎可以说是必备工具。便宜的8通道USB逻辑分析仪配上开源软件就能把SCL/SDA波形完整抓下来直接解析出地址、寄存器、数据内容比用示波器逐个数边沿高效太多了。实际排查时重点看几个位置起始条件后ACK位是否存在没有ACK说明从机地址不对或者芯片没上电寄存器地址字节是否正确不对说明发送的数据次序错了最后一个停止条件是否正常异常停止会导致芯片内部状态卡住。我调试MCP23017时用逻辑分析仪抓过一次波形发现是SDA线的上拉电阻虚焊导致数据线偶尔被拉低这种问题用万用表很难定位但波形上一眼就能看出来。最后再分享一个经验MCP23017性能稳定、资料齐全但它的I2C地址、寄存器地址、HAL库地址参数这三样东西极易混淆建议在代码里用宏定义把三个概念分开写清楚注释里标明各自格式。等到16路输入全部能够稳定读回再考虑中断扩展或者多芯片级联一次只引入一个变量排查问题时就不会顾此失彼。本文还有配套的精品资源点击获取