
简介这是一份针对STC8G系列单片机I2C从模式开发的源码资源面向嵌入式初学者及需要实现主从通信的开发者重点演示中断模式下I2C从设备如何响应主机请求、收发数据并处理总线异常。包内仅含1个c文件压缩包大小1KB代码集中、便于快速移植到实际项目。目前已有2647人浏览学习适合正在学习I2C协议或准备在STC8G平台上完成传感器、EEPROM等外设通信的读者参考。文件内容涵盖I2C寄存器初始化、从机地址配置、中断服务函数编写、数据收发逻辑及错误恢复机制并以清晰注释的方式呈现便于逐段理解配置流程和通信时序。通过阅读和运行示例代码可以更直观掌握STC8G在I2C从模式下的编程要点减少自行调试底层寄存器的时间成本。 STC单片机软核实现I2C从模式这事我一直觉得值得专门写一篇文章。做这个需求的场景其实很多——主控板通过I2C总线读取传感器数据、OLED驱动、外挂EEPROM这些多数人都是拿单片机当主机但真正要做两机通信、或者让单片机被树莓派/STM32这类设备当外设访问时单片机就得反过来当从机了。STC系列里好几个型号没有硬件I2C即便部分STC15、STC8系列带了硬件I2C外设用起来也有引脚固定、时序不灵活的毛病。所以我这次用的是纯GPIO模拟软件I2C从机方案一个引脚做SCL、一个做SDA中断加状态机全搞定实测稳定跑过100kHz的标准模式100kbit/s场景这速度对绝大多数传感器和板间通信已经够用了。文章适合做单片机开发、需要在两个主控间加通信链路的人收藏前先确认自己的STC型号能跑中断然后直接对着代码抄。1. 设计思路与整体架构1.1 为什么要用软件模拟而不是硬件I2C说句实在话STC89C52这颗老芯片I2C功能压根就没有。到了STC15系列硬件I2C虽然出现了但它的寄存器配置、引脚映射和时序在调试时非常反直觉。芯片手册上那套I2C时序图真正调起数据来你会发现跟示波器上看到的对不上号而且硬件I2C一旦碰上总线上的延时问题纠错难度比软件模拟高一个量级。软件模拟最大的好处是完全可控。每一个SCL时钟、每一位SDA数据都由GPIO输出寄存器直接驱动节奏自己定调试时通过逻辑分析仪看到什么就是什么不会出现硬件外设内部状态机跑到一半卡死的情况。我用软件模拟跑I2C从机遇到最大SCL频率能稳定跑到115kHz超过这个值就会出现丢字节。所以如果你的主机是STM32这类可以配置主频的建议把I2C时钟调到100kHz以下。1.2 从机模式的整体状态机设计I2C从机模式核心是要实时检测总线上的起始、停止、应答、数据这些事件。软件模拟没办法像硬件一样自动监听总线所以我把整个从机逻辑设计成四个状态空闲态、地址接收态、数据接收/发送态、等待应答态。状态转换的关键在检测起始条件和停止条件。起始条件对应SCL高电平期间SDA出现下降沿停止条件对应SCL高电平期间SDA出现上升沿。我在SCL和SDA的IO引脚上都开启了上升沿/下降沿的双边沿中断通过标志位的组合来判断总线上发生了什么。这个设计比轮询方式强很多因为轮询会漏掉总线上的快速时序变化。中断机制下每个边沿都会触发一次处理虽然代码运行会有几条微秒的延迟但对100kHz的SCL来说完全能跟得上。1.3 引脚分配与硬件连接我用的型号是STC15W408ASSCL接P1.0SDA接P1.1。选这两个引脚纯粹是因为它们离定时器中断和外部中断的跳线近实际上任何普通GPIO都行。需要注意的一点是SDA引脚务必设置成准双向口模式不要用推挽输出否则在总线空闲时两条线都被主机的上拉电阻拉高从机的SDA输出如果恰好为低会产生总线冲突导致通信挂死。硬件连接上SCL和SDA各接一个4.7kΩ上拉电阻到VCC。如果从机离主机超过20cm上拉电阻可以换成2.2kΩ否则长线上SDA的上升沿爬坡太慢主机在采样时会读到不确定电平。引脚作用连接对象P1.0SCL主机SCL 4.7kΩ上拉P1.1SDA主机SDA 4.7kΩ上拉VCC/GND电源与主机共地2. 代码核心细节与实现难点2.1 起始条件与停止条件的捕获实现在软件模拟I2C从机里起始条件和停止条件是最容易出bug的地方。总线空闲时SCL和SDA都应该是高电平。主机发送起始条件时SDA会在SCL高电平期间被拉低。我的处理方式是开启SDA引脚的外部中断下降沿触发在中断里先读SCL的电平如果读到高说明这是起始条件如果读到低这个下降沿其实是数据传输过程中的位跳变需要忽略。停止条件同理用SDA的上升沿中断在中断里判断SCL是否为高。如果是说明主机结束了这次通信从机状态机复位到空闲态。这里有个陷阱实际通信时数据位的翻转也有上升沿如果不对SCL做判断从机会把数据位的边沿误判成停止条件导致状态错乱。我踩过这个坑数据总是收到一半就复位抓了半天波形才发现是误判。2.2 数据位的采样与字节组拼I2C协议是高位先传每位数据在SCL高电平期间保持稳定从机在SCL的高电平中间采样SDA。我用的是在SCL上升沿中断里做数据采样。每次SCL上升沿到来时读一次SDA引脚将读到的值按位拼接到缓冲区里。具体代码如下void I2C_Isr(void) interrupt 0 { if (SCL 1) { // SCL高电平期间的SDA变化 if (SDA 0) { i2c_status I2C_START; } } else { // SCL下降沿时准备接收/发送下一位 if (i2c_bit_count 8) { i2c_rx_byte (i2c_rx_byte 1) | SDA; i2c_bit_count; } else { // 收到完整字节进入ACK判断阶段 i2c_status I2C_ADDR_CHECK; } } }这个逻辑看着简单但执行起来有个性能要求从SCL上升沿到下一次SCL上升沿之间大约10us的时间100kHz时你需要完成上次中断的处理并回到主循环。所以整个中断函数里尽量不要调用延时函数甚至函数调用层级都要少。2.3 ACK应答的软件实现从机收到地址字节之后要判断地址是否匹配匹配则拉低SDA发送应答位不匹配则释放SDA保持高电平让主机收到非应答。关键是这个应答位必须在SCL低电平期间完成输出设置然后在SCL高电平期间保持稳定。地址判断和应答位发送我做了专门处理void I2C_SendACK(bit ack) { SDA ack ? 1 : 0; // 拉低表示ACK拉高表示NACK I2C_Delay(); // 等待建立时间 SCL 1; // 主机在SCL高电平期间采样ACK位 I2C_Delay(); SCL 0; // 释放时钟进入下一字节 SDA 1; // 释放SDA总线 }这里有一个关键注意点发送应答位之前SDA必须先拉低并且保持一小段时间保证SCL拉高后主机采样能看到稳定的低电平。这个延时我直接用了几条NOP指令不用函数调用省去入栈出栈的时间。2.4 时钟拉伸Clock Stretching与从机时效性的取舍这是从机模式最难处理的点。当主机发送数据特别快、从机处理不过来时标准I2C协议里有时钟拉伸机制允许从机拉低SCL迫使主机等待。但我用纯GPIO模拟时没有硬件I2C引擎来自动做这个操作只能通过中断优先级和快速处理来弥补。实测下来从机中断处理时间不能超过SCL高电平时间的一半否则主机端就会检测到超时错误。所以我的中断代码只做状态记录和位拼接真正的数据分析、业务逻辑全部放到主循环里对缓冲区做解析绝不在中断里跑延时或复杂计算。这个取舍非常关键如果你的从机还需要响应其他中断比如串口、定时器一定要把I2C中断优先级提到最高。3. 工程代码实现过程3.1 初始化和I/O配置先初始化IO口和中断。STC15系列中I/O口要设置成准双向模式同时打开SDA的外部中断。STC的中断系统比较特殊外部中断0和1默认是引脚触发但也支持上升沿/下降沿选择需要配置相关寄存器。#include STC15W4K32S4.h sbit SCL P1^0; sbit SDA P1^1; #define I2C_IDLE 0 #define I2C_START 1 #define I2C_ADDR_CHECK 2 #define I2C_RX_DATA 3 #define I2C_TX_DATA 4 #define I2C_STOP 5 volatile unsigned char i2c_status I2C_IDLE; volatile unsigned char i2c_rx_buffer[8]; volatile unsigned char i2c_rx_len 0; volatile unsigned char i2c_rx_index 0; volatile unsigned char i2c_tx_buffer[8]; volatile unsigned char i2c_tx_len 0; volatile unsigned char i2c_tx_index 0; bit i2c_slave_wr 0; // 0:读1:写 void I2C_Init(void) { P1M0 ~0x03; // P1.0/P1.1准双向口 P1M1 ~0x03; SCL 1; SDA 1; // 配置外部中断0下降沿触发 IT0 1; EX0 1; EA 1; i2c_status I2C_IDLE; }3.2 中断服务程序的完整代码中断函数是整个从机功能的心脏。我把中断设计得极短只处理起始条件确认和字节位拼接其余状态切换到主循环中完成。void EXT0_Isr(void) interrupt 0 { if (SCL 1 SDA 0) { // 起始条件SCL高SDA下降沿 i2c_status I2C_START; i2c_bit_count 0; i2c_rx_byte 0; i2c_rx_index 0; i2c_tx_index 0; } if (SCL 1 SDA 1) { // 停止条件SCL高SDA上升沿 i2c_status I2C_STOP; } if (SCL 0) { // SCL低电平期间准备下一位数据 // 这个分支主要是为了下一次上升沿做准备 return; } }这个中断只处理了起始和停止两种边沿那字节数据接收怎么处理呢我实际用的是SCL引脚的独立的上升沿中断或者用定时器不断扫描。这里为了保持代码统一推荐用下面这种方式把SCL上升沿中断也用上void INT0_Isr(void) interrupt 0 // 外部中断0,接SDA { // ... } void INT1_Isr(void) interrupt 2 // 外部中断1,接SCL { if (SCL 1) { // SCL上升沿采样SDA if (i2c_bit_count 8) { i2c_rx_byte (i2c_rx_byte 1) | SDA; i2c_bit_count; } else { // 第9个时钟处理ACK if (i2c_status I2C_START) { // 地址比较 if ((i2c_rx_byte 1) 0x50) { // 设备地址0x50 i2c_slave_wr i2c_rx_byte 0x01; I2C_SendACK(1); } else { I2C_SendACK(0); } } } } }3.3 多字节收发与状态机处理单个字节收发简单但真正项目中都是多字节连续传输。主机连续写多个数据时从机要区分是写地址、还是写数据、还是读数据。我定义了一个状态机来管理整个过程状态触发条件动作IDLE上电初始化等待起始条件ADDR收到起始条件后首个字节比较地址发送ACKWR_DATA地址匹配且写位接收字节存入缓冲区逐字节ACKRD_DATA地址匹配且读位发送缓冲区数据等待主机ACKSTOP收到停止条件复位状态机主循环里对状态机做轮询处理void I2C_Process(void) { if (i2c_status I2C_ADDR_CHECK) { if ((i2c_rx_byte 1) 0x50) { I2C_SendACK(1); i2c_status i2c_slave_wr ? I2C_RX_DATA : I2C_TX_DATA; i2c_rx_index 0; i2c_tx_index 0; } else { I2C_SendACK(0); i2c_status I2C_IDLE; } } if (i2c_status I2C_RX_DATA) { // 接收数据字节 i2c_rx_buffer[i2c_rx_index] i2c_rx_byte; if (i2c_rx_index 8) i2c_rx_index 0; } if (i2c_status I2C_STOP) { // 一次完整传输结束置标志给主循环做业务解析 i2c_frame_done 1; i2c_status I2C_IDLE; } }3.4 完整可编译的从模式示例把上面碎片合到一段可运行代码里地址、缓冲区大小都按常见配置写好了#include STC15W4K32S4.h sbit SCL P1^0; sbit SDA P1^1; #define I2C_SLAVE_ADDR 0x50 // 7位地址主机访问的8位地址为0xA0/0xA1 volatile unsigned char i2c_rx_buf[16]; volatile unsigned char i2c_rx_cnt 0; volatile unsigned char i2c_tx_buf[16]; volatile unsigned char i2c_tx_cnt 0; volatile bit i2c_rx_flag 0; volatile bit i2c_tx_flag 0; volatile unsigned char bit_count 0; volatile unsigned char rx_byte 0; volatile unsigned char byte_index 0; bit addr_matched 0; bit is_read_cmd 0; void I2C_Delay(void) { _nop_(); _nop_(); } void I2C_SendACK(bit ack) { if (ack) { SDA 0; } else { SDA 1; } I2C_Delay(); SCL 1; I2C_Delay(); SCL 0; SDA 1; } void I2C_Init(void) { P1M0 ~0x03; P1M1 ~0x03; SCL 1; SDA 1; IT0 1; // SDA下降沿触发 IT1 1; // SCL下降沿触发 EX0 1; EX1 1; EA 1; bit_count 0; rx_byte 0; byte_index 0; } void INT0_Isr(void) interrupt 0 // SDA变化中断捕捉起始/停止条件 { if (SCL 1 SDA 0) { // 起始条件 bit_count 0; rx_byte 0; byte_index 0; addr_matched 0; is_read_cmd 0; } else if (SCL 1 SDA 1) { // 停止条件 if (is_read_cmd) { i2c_tx_flag 1; // 主机读完了置标志 } else { i2c_rx_flag 1; // 主机写完了置标志 } } } void INT1_Isr(void) interrupt 2 // SCL变化中断采样数据位 { if (SCL 1) { // 上升沿采样SDA if (bit_count 8) { rx_byte (rx_byte 1) | SDA; bit_count; } else { // 第9位时钟 if (byte_index 0) { // 地址字节 if ((rx_byte 1) I2C_SLAVE_ADDR) { addr_matched 1; is_read_cmd rx_byte 0x01; I2C_SendACK(1); byte_index; } else { I2C_SendACK(0); } } else { if (addr_matched) { if (is_read_cmd 0) { // 写模式接收数据 i2c_rx_buf[byte_index - 1] rx_byte; I2C_SendACK(1); } else { // 读模式发送数据 SDA i2c_tx_buf[byte_index - 1]; // 发送缓冲区数据 I2C_Delay(); SCL 1; I2C_Delay(); SCL 0; SDA 1; } byte_index; if (byte_index 16) byte_index 0; } } bit_count 0; rx_byte 0; } } }4. 常见问题与调试经验4.1 收不到起始条件怎么办现象主机发数据从机完全没反应逻辑分析仪上看从机SDA引脚一直是高。优先检查SDA的外部中断是否配置正确。STC15系列的外部中断0默认是低电平触发需要改INT_CLKO寄存器或者PCON里的相关位才能配成边沿触发。如果中断进不去起始条件的检测自然无从谈起。另外还有个容易忽略的点SDA引脚上如果外接了比较大的电容比如寄生电容加探头电容超过100pFSDA下降沿会被拉缓到达引脚时已经不陡峭了可能导致中断检测不到边沿。调试时先断开探头用杜邦线直连主机排除负载影响。4.2 地址对了但数据错位现象能响应起始条件地址也匹配但收到的第一个数据字节少了高位或者数据整体向左移了一位。这个十有八九是SCL上升沿中断和起始条件中断发生了竞争。主机发完起始条件后紧接着的第一个SCL上升沿来的太快代码还在处理起始条件相关变量没来得及初始化bit_count导致第一个字节采样错位。解决办法在SCL的中断函数开头做一个状态判断如果是起始条件之后的第一个上升沿先延时几个微秒模拟和主机建立时间差不多的延时等状态变量准备好再采样。我这边的经验是在起始条件检测里加一个软件消抖标志防止同一时刻SDA中断和SCL中断同时触发。4.3 主机总线一直忙发送起始条件后没有回应可能出现从机SMA引脚被拉低卡死的现象。原因基本是在上一次通信中从机在ACK阶段输出了低电平但主机没有继续发时钟总线停在了那个状态从机自己也没有复位机制SDA一直被拉着。解决的方法是在从机开机后加一个总线复位逻辑如果检测到SDA为低超过一定时间就模拟9个时钟脉冲释放总线。void I2C_Bus_Reset(void) { unsigned char i; P1M0 | 0x01; // SCL设成推挽强制输出 SCL 0; for (i 0; i 9; i) { delay_us(5); SCL 1; delay_us(5); SCL 0; } P1M0 ~0x01; SCL 1; SDA 1; }4.4 STC单片机程序超出内存的判断做I2C从机时如果缓冲区开多了容易编译报错或者程序运行异常。STC15系列内部RAM最大只有1KB左右我试过开32字节的收发缓冲区没问题但64字节就会卡死在启动时因为堆栈溢出覆盖了变量。怎么判断程序有没有超内存编译时看Keil输出窗口的Program Size: dataxx.0这个data值就是内部RAM占用如果接近型号上限就要精简缓冲区。另一种方式是烧录后看芯片能不能正常跑起来如果复位后什么反应都没有多半就是内存不够导致堆栈覆盖了启动代码的初始化变量。现象可能性排查步骤完全无响应中断未开启检查EA、EX0、EX1是否置1收到错位数据SCL中断竞争起始条件后加延时总线卡死SDA被拉低执行总线复位函数程序运行异常RAM溢出检查data值精简数组4.5 调试工具和实测心得调试I2C从机我强烈建议准备一个逻辑分析仪不需要贵的那种淘宝几十块钱的8通道就能看清时序。用逻辑分析仪对比主机发送的地址字节和从机收到的数据能迅速定位是时序问题还是数据偏移问题。我实际调过的项目中从机给STM32主机提供温湿度数据和开关状态一个包总共6字节主机100ms读一次稳定运行了一个多月没有出现总线锁死。中间换过一次上拉电阻从10k换成4.7k发现原来在长线上丢数据的问题明显减少。最后提醒一点如果从机除了I2C还要处理按键、显示刷新这类任务建议把I2C中断优先级设置到最高否则在高负载下会出现主机等从机响应的超时错误。优先级配置在STC15里可以通过IP、IP2寄存器设置实测把外部中断0和1放到最高级能明显减少通信错误。本文还有配套的精品资源点击获取