简介:这份资源面向嵌入式开发初学者与需要扩展IO口的工程师,围绕STM32通过软件模拟IIC时序读写PCF8574八位并行IO扩展芯片展开,解决STM32硬件IIC资源不足或引脚受限时的外设扩展问题,可应用于智能家居、工业控制与物联网设备等场景。压缩包共134个文件,约3.05MB,以c与h源码文件为主,配合o、d、crf等编译中间文件,以及uvprojx、uvoptx工程配置、hex烧录文件、map映射与bat清理脚本,完整保留了Keil工程结构,便于直接编译与二次修改。资源已有1456人学习下载,热度较高。读者可从中获得GPIO初始化、起始停止信号、设备地址读写、应答处理等模拟IIC关键环节的参考实现,并借助固件库与用户代码目录快速搭建可运行的IO扩展方案,减少自行调试时序的试错成本。
1. 模拟 IIC 读 PCF8574:为什么老工程师宁愿软扛也不开硬件外设
去年帮朋友调一块工控板,STM32F103 的硬件 IIC 挂在 PCF8574 上,示波器看波形规规矩矩,可读回来的字节时不时错一位,重启又正常。换成 GPIO 模拟 IIC,十分钟跑通,连续跑三天没再翻车。这不是玄学,是 STM32 早期硬件 IIC 状态机在特定时序下的老毛病——总线仲裁、时钟拉伸、错误标志清除顺序,任何一环没伺候好就卡死。PCF8574 这颗 8 位 I/O 扩展芯片本身协议极简:写地址、写字节、读地址、读字节,没有寄存器地址概念,全靠 IIC 起始/停止和 ACK 撑起通信。用模拟 IIC 读它,等于把时序控制权完全攥在自己手里,SCL 拉高拉低的间隔、SDA 采样点、ACK 窗口,全由代码说了算。适合谁?手头是 F1/F4 老系列、硬件 IIC 踩过坑、或者需要把 PCF8574 挂在非标准引脚上的场景。这份资源就是围绕这套模拟时序展开的完整工程,从 GPIO 初始化到读写封装,再到中断输入捕获,能直接搬进你的 stm32 项目里。
2. 模拟 IIC 的时序骨架:从 GPIO 配置到 PCF8574 地址确认
2.1 为什么选开漏输出加外部上拉
模拟 IIC 的第一步不是写代码,是确认硬件上 SCL 和 SDA 有没有 4.7k 上拉电阻。PCF8574 的 IIC 引脚是开漏结构,STM32 这边也必须配成开漏输出,否则两边同时驱动总线会烧引脚。我一般用 GPIO_Mode_Out_OD,输出速度设 50MHz 足够,速度再高反而容易在长排线上振铃。上拉电阻取值有讲究:3.3V 系统常用 4.7k,总线电容大或者线长超过 20cm 就降到 2.2k,但静态电流会上去。曾经用 10k 上拉跑 100kHz,波形上升沿软得像山坡,读 PCF8574 时 ACK 位偶尔采错,换成 4.7k 立刻干净。
// GPIO 初始化:SCL=PB6, SDA=PB7,开漏输出 void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); // 空闲态拉高 }逻辑说明:开漏模式下写 1 实际是释放总线,靠外部上拉拉高;写 0 是强下拉。参数上,速度等级不影响 IIC 速率,只影响边沿斜率,50MHz 是平衡选择。注意初始化后必须把两根线都置高,否则第一次起始条件可能被从机误判。
2.2 起始、停止、应答:三个必须死记的时序窗口
PCF8574 对时序的容忍度其实不低,但模拟 IIC 的延时函数不能乱给。标准模式 100kHz 下,SCL 高电平至少 4.0μs,低电平至少 4.7μs。我习惯用 5μs 左右的微秒延时,配合 __NOP() 做粗调。起始条件是 SCL 高时 SDA 从高变低,停止相反。关键坑在于:拉低 SDA 之前必须确认 SCL 已经稳定在高电平,很多新手在 SCL 上升沿还没走完就动 SDA,从机识别成数据位而不是起始位。
// 微秒级延时,72MHz 系统下约 5μs void IIC_Delay(void) { uint8_t i = 60; while(i--); } // 起始信号:SCL 高时 SDA 下拉 void IIC_Start(void) { SDA_OUT(); SDA_H; SCL_H; IIC_Delay(); SDA_L; IIC_Delay(); // 起始条件建立 SCL_L; IIC_Delay(); // 钳住总线准备发数据 } // 停止信号:SCL 高时 SDA 上拉 void IIC_Stop(void) { SDA_OUT(); SCL_L; SDA_L; IIC_Delay(); SCL_H; IIC_Delay(); SDA_H; IIC_Delay(); // 停止条件建立 }参数说明:IIC_Delay 里的循环次数按主频调,72MHz 下 60 次大约 5μs,换成 168MHz 的 F4 要加到 140 左右。别用系统滴答定时器做这层延时,中断一进来时序就散了。应答位处理是读 PCF8574 时最容易翻车的地方:主机发完地址后要释放 SDA,在 SCL 高电平期间读 SDA 电平,低表示 ACK。我见过有人忘了把 SDA 切回输入模式,结果读到的永远是自己输出的高电平。
2.3 PCF8574 的地址计算与读写命令字
PCF8574 的 7 位地址是 0100xxx,其中 xxx 由 A2/A1/A0 三个引脚决定。比如 A2=A1=A0=0,地址就是 0x20;全接 VCC 就是 0x27。写操作命令字是地址左移一位加 0,读操作是地址左移一位加 1。注意 PCF8574 和 PCF8574A 的固定前缀不同,前者 0100,后者 0111,买芯片时看清楚型号,我吃过一次亏,板子焊好才发现地址对不上,飞线改 A2 才救回来。
#define PCF8574_ADDR 0x20 // A2=A1=A0=0 // 写一个字节到 PCF8574 uint8_t PCF8574_Write(uint8_t data) { IIC_Start(); IIC_Send_Byte(PCF8574_ADDR << 1); // 写命令 if (IIC_Wait_Ack()) { IIC_Stop(); return 1; } IIC_Send_Byte(data); if (IIC_Wait_Ack()) { IIC_Stop(); return 1; } IIC_Stop(); return 0; } // 从 PCF8574 读一个字节 uint8_t PCF8574_Read(void) { uint8_t data; IIC_Start(); IIC_Send_Byte((PCF8574_ADDR << 1) | 0x01); // 读命令 if (IIC_Wait_Ack()) { IIC_Stop(); return 0xFF; } data = IIC_Read_Byte(0); // 读完后发 NACK IIC_Stop(); return data; }逻辑说明:写操作先发地址加写位,等 ACK,再发数据字节,再等 ACK,最后停止。读操作先发地址加读位,等 ACK,然后释放 SDA 读 8 位,主机发 NACK 告诉从机结束,最后停止。参数上,IIC_Read_Byte 的参数 0 表示 NACK,1 表示 ACK,读最后一个字节必须 NACK,否则 PCF8574 会继续占着总线。返回值 0xFF 是错误标记,实际读到的 0xFF 和错误要区分,我一般加一个全局错误计数。
3. 把 PCF8574 的 8 位口用起来:输入捕获与输出驱动的代码落地
3.1 输出模式:驱动 LED 和继电器的电流边界
PCF8574 的 8 个口是准双向口,输出高电平时只能提供约 100μA 的拉电流,输出低电平能灌 25mA。这意味着直接驱动 LED 要接成灌电流方式:LED 阳极接 VCC,阴极串电阻接 PCF8574 引脚,输出 0 点亮。继电器模块如果自带光耦和驱动三极管,通常低电平触发,也能直接挂。但别指望它驱动蜂鸣器或者大电流负载,我试过直接推 5V 有源蜂鸣器,声音小得像蚊子叫,加一个 S8050 三极管才正常。
// 点亮 P0 口 LED,其余熄灭(灌电流接法) void LED_On(uint8_t pin) { uint8_t val = PCF8574_Read(); // 先读回当前状态 val &= ~(1 << pin); // 对应位清 0 PCF8574_Write(val); } // 全部熄灭 void LED_All_Off(void) { PCF8574_Write(0xFF); // 全部输出高,LED 无电流 }逻辑说明:PCF8574 没有方向寄存器,写 1 就是弱上拉输入,写 0 就是强下拉输出。所以读回当前值再改位是安全做法,避免误改其他口的状态。参数上,1<<pin 中 pin 取 0 到 7。注意上电默认全高,如果外部是灌电流 LED,上电瞬间全灭,符合预期;如果是高电平驱动的电路,上电会全亮一下,需要加反相或者改接法。
3.2 输入模式:按键读取与消抖的工程写法
读 PCF8574 的输入时,要先往对应口写 1,释放总线让外部电路决定电平。按键一端接 GND,另一端接 PCF8574 引脚,按下时读到 0。机械按键抖动 5 到 10ms,我一般用状态机消抖,而不是 delay 死等。下面这段代码每 10ms 调一次,连续两次读到相同值才确认。
// 按键扫描,每 10ms 调用一次 uint8_t Key_Scan(void) { static uint8_t key_old = 0xFF; uint8_t key_now = PCF8574_Read(); uint8_t key_val = 0; if (key_now != key_old) { // 变化了,但先不认,等下次 key_old = key_now; } else { // 连续两次相同,确认稳定 if (key_now != 0xFF) { key_val = key_now; } } return key_val; }逻辑说明:key_old 保存上次读值,key_now 是本次读值。只有两次相同才认为稳定,返回非 0xFF 表示有键按下。参数上,调用周期必须稳定在 10ms 左右,用定时器中断或者主循环软延时都行,但别放在 while 里裸跑。注意 PCF8574 的输入口没有锁存,按键按下期间读到的就是实时电平,长按会一直返回键值,需要上层做长按判断。
3.3 中断输出:利用 PCF8574 的 INT 引脚做事件触发
PCF8574 有一个 INT 引脚,当任何一个输入口电平变化时,INT 拉低。这个引脚可以接到 STM32 的外部中断 EXTI 上,实现按键唤醒或者事件触发,不用轮询。我一般把 INT 接 PA0,配置下降沿触发,中断里只置一个标志位,主循环再读 PCF8574 确认。注意 INT 是开漏输出,需要上拉电阻,PCF8574 模块上通常已经带了。
// EXTI0 中断服务函数 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { key_int_flag = 1; // 只置标志,不做耗时操作 EXTI_ClearITPendingBit(EXTI_Line0); } } // 主循环处理 if (key_int_flag) { key_int_flag = 0; uint8_t val = PCF8574_Read(); // 根据 val 判断哪个键动作 }逻辑说明:中断里只置标志,读 IIC 的操作放主循环,因为 IIC 读写要几毫秒,中断里做会阻塞其他中断。参数上,EXTI_Line0 对应 PA0,如果接其他引脚要改。注意 PCF8574 的 INT 在读取后会释放,如果主循环读得慢,INT 会一直低,不会重复触发,所以标志位处理完要确保读一次 PCF8574。
4. 避坑排查:模拟 IIC 读 PCF8574 的五个血泪现场
4.1 现象:读回数据全是 0xFF,写操作也没反应
原因:地址算错或者 SDA/SCL 接反。PCF8574 的 A2/A1/A0 如果悬空,内部有弱上拉,默认全 1,地址变成 0x27 而不是 0x20。另外有人把 SCL 和 SDA 焊反,波形看着有,但协议完全对不上。解决:先用万用表量 A2/A1/A0 对地电压,确认地址;再用示波器双通道看 SCL 和 SDA,起始条件必须是 SCL 高时 SDA 下降。
4.2 现象:能写不能读,读操作返回 0xFF 或 0x00
原因:读操作时 SDA 方向没切换。发送读命令后,主机必须把 SDA 从输出切到输入,释放总线。很多模拟 IIC 代码里 SDA_OUT 和 SDA_IN 宏没配对,读的时候还在输出模式,读到的是自己拉高的电平。解决:在 IIC_Read_Byte 开头调用 SDA_IN,读完调用 SDA_OUT。另外确认读命令字是 (addr<<1)|0x01,别写成 (addr<<1)|0x00。
4.3 现象:连续读写几次后总线死锁,SCL 被拉低不放
原因:从机在某个字节后没收到 ACK,或者主机在错误时机发了停止条件,PCF8574 进入未知状态。常见于延时太短,从机还没准备好。解决:加超时机制,每次 IIC_Wait_Ack 里做循环计数,超过 250 次就强制发停止条件并重新初始化 GPIO。我一般还会在初始化里加 9 个 SCL 脉冲,把从机的移位寄存器清空。
4.4 现象:按键读取偶尔跳变,INT 中断频繁触发
原因:PCF8574 的输入口没有施密特触发器,缓慢变化的电平会导致多次翻转。如果按键线长且没有滤波电容,INT 会抖动。解决:在按键两端并 100nF 电容,软件上 INT 中断里加 20ms 消抖,或者干脆不用 INT,改 10ms 轮询。我现在的习惯是:短按键用轮询,低功耗唤醒才用 INT。
4.5 现象:换一块板子就通信失败,同一份代码
原因:上拉电阻差异。有的 PCF8574 模块自带 10k 上拉,有的没有,有的用 2.2k。STM32 的开漏输出加上不同上拉,上升沿时间差很多。解决:用示波器量上升沿,超过 1μs 就把上拉降到 4.7k 或 2.2k。另外检查 VCC 电压,PCF8574 支持 2.5V 到 6V,但和 STM32 的 3.3V 混用时,上拉必须接 3.3V,接 5V 会通过 IIC 引脚灌电流进 STM32。
5. 进阶:把模拟 IIC 封装成可复用驱动并做超时自恢复
5.1 驱动分层:硬件层、协议层、设备层
写到第三个项目时,我把代码拆成三层。硬件层只负责 GPIO 翻转和延时,协议层实现起始、停止、收发字节、ACK,设备层针对 PCF8574 做读写封装。这样换到 AT24C02 或者 OLED 时,只改设备层。硬件层的延时函数用宏定义,方便换主频。
// 硬件层宏,换引脚只改这里 #define IIC_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define IIC_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define IIC_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define IIC_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define IIC_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) // 协议层超时等待 ACK uint8_t IIC_Wait_Ack_Timeout(void) { uint16_t timeout = 0; SDA_IN(); IIC_SCL_H(); IIC_Delay(); while (IIC_SDA_READ()) { if (++timeout > 250) { IIC_Stop(); return 1; // 超时 } IIC_Delay(); } IIC_SCL_L(); IIC_Delay(); return 0; }逻辑说明:宏定义把引脚操作抽象出来,换板子只改这几行。超时等待里,SDA 设为输入,SCL 拉高后读 SDA,如果从机拉低就是 ACK。超过 250 次循环约 1.25ms 还没响应,强制停止并返回错误。参数上,250 这个数按延时长度调,5μs 延时下约 1.25ms,足够 PCF8574 响应。
5.2 总线自恢复:9 个时钟脉冲的救砖操作
如果 PCF8574 因为干扰或者热插拔进入死锁,SCL 被从机拉低不放,这时候发任何起始条件都没用。标准做法是:主机切换 SDA 为输出并拉高,然后手动发 9 个 SCL 脉冲,每个脉冲后检查 SDA 是否释放。9 个脉冲对应一个字节加 ACK,能把从机移位寄存器清空。最后发一个停止条件,总线恢复。
// 总线死锁恢复 void IIC_Bus_Recovery(void) { uint8_t i; SDA_OUT(); IIC_SDA_H(); for (i = 0; i < 9; i++) { IIC_SCL_L(); IIC_Delay(); IIC_SCL_H(); IIC_Delay(); if (IIC_SDA_READ()) break; // SDA 释放了提前退出 } IIC_Stop(); IIC_Init(); // 重新初始化 GPIO }逻辑说明:循环里先拉低 SCL 再拉高,产生时钟脉冲。每次拉高后读 SDA,如果从机释放了 SDA,说明它已经退出死锁,提前跳出。最后发停止条件并重新初始化 GPIO,确保引脚状态正确。参数上,9 个脉冲是 IIC 规范推荐值,实际测试 3 到 5 个也能恢复大部分情况,但 9 个最稳。
5.3 验证方法:用逻辑分析仪抓一次完整读写
调完代码别急着收工,用逻辑分析仪抓一次完整波形。我一般看四个点:起始条件是否在 SCL 高时 SDA 下降;地址字节后第 9 个时钟 SDA 是否被从机拉低;读操作时主机是否在最后一个字节发 NACK;停止条件是否在 SCL 高时 SDA 上升。没有逻辑分析仪就用示波器双通道,触发设在 SDA 下降沿,也能看个大概。曾经有个项目读 PCF8574 偶尔多一个字节,抓波形发现是停止条件发早了,SCL 还没拉高就动了 SDA,从机误判成新的起始条件。
从那以后我每次焊好 PCF8574 板子,都先跑一遍总线恢复函数,再读一次全 0xFF 确认空闲态,最后才接负载。这个习惯帮我省了至少三次返工。希望帮到你。
本文还有配套的精品资源,点击获取