简介:STC8G系列单片机I2C从模式实现代码资源,面向嵌入式开发者和单片机学习者,解决在中断模式下实现I2C从机通信的编程问题。压缩包仅1个文件,为i2c.c源程序,大小仅1KB,但完整覆盖I2C从模式关键环节:初始化配置、中断服务、数据收发与错误处理。代码基于STC8G硬件I2C模块,通过配置I2CON、I2STAT、I2DAT等寄存器并开启中断使能,实现从机地址匹配、数据读写及异常恢复机制。该程序采用中断驱动方式,通信过程不阻塞主循环,可有效降低CPU占用。已有2669人学习参考,对于希望快速掌握STC8G I2C从模式编程、降低底层寄存器调试成本的中高级开发者,是一份可直接研读和移植的实用样例。
1. STC8G I2C 从模式:硬件外设不等于拿来就能用
很多人把主机的 I2C 驱动改几个寄存器就丢到从机上,结果要么总线拉死,要么数据错位。STC8G 的硬件 I2C 外设虽然自带从机模式,但真正做到“从机”的坑比主机多不少:地址匹配、方向判断、数据寄存器预装载、状态标志清除,这些在芯片手册里分散在不同章节,拼起来才能跑通。我拆这份 STC8G I2C 从模式代码时,把寄存器配置、状态机设计、收发流程到逻辑分析仪验证都过了一遍,发现从机不是“换个方向”那么简单。这篇文章适合正在调 I2C 从机的单片机开发者,也适合刚接触 STC8G 硬件 I2C 的嵌入式初学者,按章节能直接落地。
2. 从机地址、引脚与时钟:先让总线认得出你
2.1 从机地址:I2CSLADR 的低位是广播位不是地址位
STC8G 的从机地址寄存器 I2CSLADR 位宽 8 位,高 7 位才是真正的从机地址,最低位是广播呼叫使能位,不是地址的一部分。配置地址时,如果直接把 7 位地址值赋给寄存器,实际生效的地址会被左移一位,结果主机怎么呼叫都不匹配。常见做法是I2CSLADR = (slave_addr << 1) | 0x00;,slave_addr 是你自己定义的 7 位地址,最低位写 0 表示不响应广播呼叫。如果项目希望从机响应 0x00 广播地址,再把最低位置 1。
这个位一旦设错,总线上两个从机还可能互相抢应答,现象非常隐蔽。初始化后可以读回寄存器确认写入值,确保 bit0 没有被无意置位。地址匹配中断只在应答周期触发,主机用 7 位地址还是 10 位地址也影响寄存器写法。STC8G 支持 7 位地址模式,10 位地址需要对 I2CSLADR 和 I2CCFG 的扩展位配合,实际项目里用到得少,我一般保持 7 位地址。广播呼叫使能位打开后,从机在收到 0x00 地址时也会产生地址匹配中断,如果一个总线上挂了多个从机且都开了广播响应,它们会同时进中断,对应用层不一定是好事,能关就关。
2.2 引脚切换:P_SW1 的 I2C_S 位影响你该看哪个波形
STC8G 的 I2C 引脚不是固定在一个引脚组上,P_SW1 寄存器的 I2C_S[1:0] 位决定 SCL/SDA 复用在哪一组引脚。不同封装和型号的默认引脚组不一样,有的默认在 P1.4/P1.3,有的在 P2.4/P2.3。这个配置属于复用开关,不在 I2C 模块寄存器里,新手容易漏配。我一般会在系统初始化最开始就设置P_SW1 &= 0xCF;,把引脚切到第一组,再根据实际硬件调整。逻辑分析仪抓波形时,必须先确认抓的是当前实际生效的引脚,否则看半天全是空波形。
引脚配置错误最常见的表现是从机不产生任何中断,但主机认为 SDA 一直为高。切引脚组的动作最好放在所有 I2C 初始化之前,因为部分型号在切换瞬间 SCL/SDA 电平会变化,万一此时主机已经开始通信,从机可能收到半个起始信号。切换后稍微延时几个指令周期再配置 I2CSLCR,给引脚复用逻辑一点稳定时间。这个延时不是必须的,但我在工程里习惯加一小段_nop_();序列,能减少上电瞬间误触发。
2.3 访问扩展 SFR:P_SW2 的 EAXSFR 是寄存器的开关
I2C 外设的寄存器组位于扩展 SFR 区域,比如 I2CCFG 在 0xFE80、I2CSLST 在 0xFE84,这些地址不能直接用标准 sfr 声明方式访问。Keil C51 工程里常见做法是用指向 xdata 的指针宏映射,但映射只是第一步,真正读写前必须把 P_SW2 的 EAXSFR 位置 1。如果漏了这一步,对 0xFE80 的操作会被路由到普通 XRAM,寄存器值根本写不进去,读回来全是随机数。下面是常用的映射和使能代码:
sfr P_SW1 = 0xA2; sfr P_SW2 = 0xBA; #define I2CCFG (*(unsigned char volatile xdata *)0xfe80) #define I2CMSCR (*(unsigned char volatile xdata *)0xfe81) #define I2CMSST (*(unsigned char volatile xdata *)0xfe82) #define I2CSLCR (*(unsigned char volatile xdata *)0xfe83) #define I2CSLST (*(unsigned char volatile xdata *)0xfe84) #define I2CSLADR (*(unsigned char volatile xdata *)0xfe85) #define I2CTXD (*(unsigned char volatile xdata *)0xfe86) #define I2CRXD (*(unsigned char volatile xdata *)0xfe87) void I2C_Reg_Access_Enable(void) { P_SW2 |= 0x80; // EAXSFR = 1,允许访问 0xFE00~0xFFFF 扩展SFR }先开访问开关再操作 I2C 寄存器,这个顺序别写反。代码里用 volatile 修饰映射指针,是为了防止编译器把寄存器读操作优化掉,尤其是 I2CSLST 这类需要“读后判断”的状态寄存器。P_SW1 和 P_SW2 是标准 SFR,地址固定,可以放心用 sfr 声明。访问完扩展 SFR 后不一定要立刻清 EAXSFR 位,因为后续还会频繁操作寄存器,反复置位反而增加代码量和出错概率。但如果代码同时使用 xdata 大数组,并且对访问速度敏感,可以考虑在 I2C 初始化之外关闭该位,避免扩展 SFR 区域和普通 XRAM 的访问路径混淆。我这里只建议保持开启,直到所有 I2C 相关外设都配置完。
3. 从机状态机与中断:把 I2CSLST 标志位按顺序用掉
3.1 三种中断的先后:地址匹配、接收数据、发送数据
I2CSLST 中几个核心标志位:I2CSS 是起始信号,I2CISADD 是地址匹配,I2CISDA 是接收完成,I2CISDB 是发送完成。从机每次通信至少会先来一次 I2CISADD,之后根据主机方向,要么进入接收流程反复触发 I2CISDA,要么进入发送流程反复触发 I2CISDB。把这些标志理解成“事件队列”,处理顺序直接影响数据对不对。
主机发“写地址”时,顺序是 I2CISADD,然后每个数据字节触发一次 I2CISDA,最后停止条件。主机发“读地址”时,顺序是 I2CISADD,然后每发送一个字节触发一次 I2CISDB,从机需要在 I2CTXD 里装载数据。重复起始同样触发 I2CISADD,这和初始起始在标志位上不区分,必须在状态机里自己记住上下文,这是我当时踩得最多的地方。I2CSS 标志通常用于检测总线上是否出现起始条件,调试时我常把它当作通信开始的监视点。但要注意,从机在停止条件时不一定会产生中断,这取决于 I2CSLCR 的总线空闲检测配置,如果应用需要在停止时做收尾,可以开总线空闲中断,或通过主机发送一个特定命令字节来收尾。
3.2 命令寄存器模式:不用猜方向的通用状态机
I2CSLST 里没有明显的读/写方向位,部分型号有,但位号在数据手册里各不相同。与其依赖不确定的寄存器位,不如设计成“命令寄存器模式”:主机总是先写一个命令字节,比如寄存器地址,从机根据这个命令准备数据,主机随后重复起始并读。这样从机在收到命令时就知道后边是读方向,不需要在地址匹配中断里猜方向。
为什么不建议在地址匹配时判断方向,原因有两个。一是地址匹配中断发生时,从机只知道自己被叫到了,不知道主机下一拍要做什么;二是部分型号确实把 R/W 信息放在状态位里,但位号不统一,换了芯片型号代码就要改。命令寄存器模式虽然多一个字节的传输开销,但对绝大多数传感器、存储芯片类的从机设备,这个代价完全可以接受。状态机我一般分成四个状态:IDLE、收到命令、准备数据、发送数据。IDLE 下地址匹配只是唤醒;收到命令时读取 I2CRXD 并准备响应数组;准备数据结束等待下一次地址匹配;发送数据状态在 I2CISDB 中继续装载下一字节。
3.3 清标志的姿势:读状态、处理数据、再写 0x00
STC8G 的 I2CSLST 标志位读出来后不会自动清零,需要软件写 0x00 清除。如果一次中断里同时出现多个标志,而代码一进来就清标志,后面的判断会丢失事件。正确顺序是:先读取 I2CSLST 保存到局部变量,接着处理已置位的分支,最后统一I2CSLST = 0x00;。特别注意 I2CRXD 必须在这个窗口期内读出,否则下一个字节到达后数据被覆盖,你看到的就是丢字节。
清标志顺序错了还有个特征:中断反复进入,程序像卡在中断里,因为中断标志一直挂起。I2CTXD 和 I2CRXD 是两个独立地址,写 I2CTXD、读 I2CRXD,但如果接收中断里没及时读走 I2CRXD,下一个字节到达时会覆盖,体现为连续收字节时隔一个丢一个。对策就是中断里第一时间取数据,不要在中断里做耗时解析。下面是一个处理框架:
void I2C_Isr(void) interrupt 24 { unsigned char sta = I2CSLST; // 先读状态 if (sta & 0x40) { // 地址匹配 i2c_phase = I2C_PHASE_ADDR; } if (sta & 0x20) { // 接收数据完成 i2c_cmd = I2CRXD; // 及时取走数据 prepare_response(i2c_cmd); i2c_phase = I2C_PHASE_CMD; } if (sta & 0x10) { // 发送数据完成 if (tx_len > 0) { I2CTXD = tx_buf[0]; tx_len--; tx_buf++; } else { I2CTXD = 0xFF; } } I2CSLST = 0x00; // 最后统一清标志 }这个框架的通用性在于:地址匹配只改变应用层状态,不直接操作 SDA,真正的数据交换靠接收和发送完成中断完成。代码里 i2c_phase 用于记录当前是否处于命令接收阶段,可以在重复起始到来时决定要不要预装载第一字节。I2CTXD 在零长度时写 0xFF,是为了主机继续读时能读到确定值,而不是随机总线电平。0x40、0x20、0x10 分别对应 I2CISADD、I2CISDA、I2CISDB 的掩码,不同型号如果位定义有差异,按芯片手册调整即可。中断号 24 是多数 STC8G 型号的映射,个别型号不同,以数据手册为准。
4. 从模式收发代码:数据寄存器读写顺序是命门
4.1 初始化:从机地址和中断配置一次到位
初始化代码要把第 2 章的扩展 SFR 开关、引脚切换、地址赋值、中断使能全部串起来。先开 EAXSFR,再切引脚,然后写 I2CCFG。从机模式下 I2CCFG 的时钟分频位不参与工作,SCL 速率完全由主机决定,所以这里直接写 0x00,不需要关心分频系数。I2CSLADR 赋值时一定记得左移一位,最低位留给广播使能。
sfr P_SW1 = 0xA2; sfr P_SW2 = 0xBA; #define I2CCFG (*(unsigned char volatile xdata *)0xfe80) #define I2CSLCR (*(unsigned char volatile xdata *)0xfe83) #define I2CSLST (*(unsigned char volatile xdata *)0xfe84) #define I2CSLADR (*(unsigned char volatile xdata *)0xfe85) #define I2CTXD (*(unsigned char volatile xdata *)0xfe86) #define I2CRXD (*(unsigned char volatile xdata *)0xfe87) #define I2C_SLAVE_ADDR 0x32 // 7 位从机地址 void I2C_Slave_Init(void) { P_SW2 |= 0x80; // 打开扩展 SFR 访问 P_SW1 &= 0xCF; // I2C 引脚组选择,清 I2C_S1/S0 I2CCFG = 0x00; // 从机模式,时钟分频不参与工作 I2CSLADR = (I2C_SLAVE_ADDR << 1) | 0x00; // 7 位地址,不使能广播 I2CSLST = 0x00; // 清从机状态寄存器 I2CSLCR = 0xB0; // 开 I2C 中断、接收中断、发送中断 EA = 1; // 开总中断 }I2CSLCR 的 0xB0 拆开看是 1011 0000,bit7 是 I2C 总中断使能,bit5 是接收数据中断使能,bit4 是发送数据中断使能。这样写的好处是地址匹配中断默认打开,数据收发中断也同时就绪。如果暂时只想接收不想发送,可以把 0xB0 改成 0xA0,减少发送中断触发。初始化完成后,I2C 外设就开始监听总线,任何匹配地址的呼叫都会进入中断,主循环不需要参与轮询。
4.2 中断服务函数:命令-数据-响应的完整状态流转
下面这段中断代码对应命令寄存器模式。主机先写命令字节,从机解析命令并准备好响应数据;主机重复起始后读,从机在地址匹配中断里预装载第一字节,后续通过发送完成中断连续送出数据。i2c_phase 是用来记录“命令是否已经收到”的状态位,没有它,地址匹配中断无法区分第一次呼叫和重复起始后的呼叫。
volatile unsigned char i2c_phase = 0; volatile unsigned char i2c_cmd = 0; volatile unsigned char tx_buf[8]; volatile unsigned char tx_len = 0; volatile unsigned char tx_idx = 0; volatile unsigned char regs[4]; void prepare_response(unsigned char cmd) { if (cmd >= 0x10 && cmd <= 0x13) { tx_buf[0] = regs[cmd - 0x10]; tx_len = 1; } else if (cmd == 0x20) { tx_buf[0] = regs[0]; tx_buf[1] = regs[1]; tx_len = 2; } else { tx_len = 0; } } void I2C_Isr(void) interrupt 24 { unsigned char sta = I2CSLST; if (sta & 0x40) { // 地址匹配 if (i2c_phase == 1) { // 命令已收到,本次是读方向 I2CTXD = tx_buf[0]; // 预装载第一个响应字节 tx_idx = 1; i2c_phase = 0; } } if (sta & 0x20) { // 接收数据完成 i2c_cmd = I2CRXD; // 立即读走,否则会被下一字节覆盖 prepare_response(i2c_cmd); i2c_phase = 1; } if (sta & 0x10) { // 发送数据完成 if (tx_idx < tx_len) { I2CTXD = tx_buf[tx_idx++]; } else { I2CTXD = 0xFF; // 无数据可发时给确定值 } } I2CSLST = 0x00; }prepare_response 根据命令字节填充发送缓冲区和长度。命令 0x10 到 0x13 分别对应内部寄存器 regs[0] 到 regs[3],命令 0x20 返回两个字节。这样设计的好处是应用层只需要维护 regs 数组,I2C 协议交互被隔离在中断函数里。tx_idx 记录下一个要发送的字节位置,发送完成中断里每次装载一字节。如果主机在中途停止读取,tx_idx 不会自动归零,下一次地址匹配中断会重新从 tx_buf[0] 开始,这个行为是符合预期的。
4.3 主循环与自检:没有主机时先看变量
主循环不需要轮询 I2C 状态,所有数据交互都在中断里完成。调试时可以在 I2C_Slave_Init 之后给 regs 数组赋几个已知值,然后用主机的读写命令去验证。下面是一个最小化的 main,适合上电后先用调试器观察变量变化:
void main(void) { I2C_Slave_Init(); regs[0] = 0x5A; regs[1] = 0xA5; while (1) { // I2C 数据交互全部由中断完成,主循环只处理应用逻辑 // 调试时可在这里查看 i2c_cmd、tx_buf、tx_len } }配合主机的读写时序:主机发送 START,输出 0x64,即 (0x32 << 1) | 0,表示写方向;然后发送命令字节 0x10;接着发送重复起始,输出 0x65,即 (0x32 << 1) | 1,表示读方向;从机应返回 0x5A。这套时序是验证从机代码最直接的参照,逻辑分析仪抓到的波形应该和这个流程完全一致。如果读回来不是 0x5A,问题多半出在 I2CTXD 预装载时机或者状态标志清除顺序上。
5. 常见问题排查:总线卡死、重复中断、数据错位的四个实战现场
5.1 从机无响应:初始化顺序反了
现象:主机发起通信,从机完全不应答,SDA 一直保持高电平,I2CSLST 里的地址匹配中断一直不来。
原因:最常见的是 P_SW2 的 EAXSFR 位没有置 1,导致 I2CSLADR 和 I2CSLCR 的写入实际落在普通 XRAM 上,从机地址根本没生效。其次是引脚组切错,SCL/SDA 不在你测量或者主机连接的那组引脚上。
解决:初始化函数第一行先执行P_SW2 |= 0x80;,再执行任何 I2C 寄存器操作。引脚配置用P_SW1 &= 0xCF;切到明确的一组,然后读回 P_SW1 确认值。如果还是无响应,拿逻辑分析仪抓 SCL/SDA,看主机发出的地址字节到底是 0x64 还是 0x32,地址左移的坑很容易在这里现形。
5.2 中断风暴:清标志不彻底
现象:程序不断进入 I2C 中断,主循环根本得不到执行,或者仿真器停下来时程序计数器停在中断入口附近。
原因:中断服务函数里一进来就写了I2CSLST = 0x00;,或者在处理完一个分支后清零,但同一时刻还有另一个标志位挂起。硬件认为中断条件仍在,于是反复触发。还有一种情况是 I2CRXD 没有及时读走,接收溢出标志 I2CRDO 置位后也会持续产生中断请求。
解决:中断里先unsigned char sta = I2CSLST;保存快照,处理完所有关心的分支后,最后统一执行一次I2CSLST = 0x00;。这一步放在函数末尾,保证所有事件都在同一次中断周期内被消费掉,清完标志后中断状态才真正归零。写代码时留意优化器会不会把中间的对 I2CSLST 的写操作合并,volatile 能避免这类玄学问题。
5.3 主机读回 0xFF:I2CTXD 预装载时机不对
现象:主机读操作能完成,ACK 也正常,但读回来的每个字节都是 0xFF,或者只有第一个字节错,后面字节对。
原因:地址匹配中断后,如果主机方向是读,硬件会立刻从 I2CTXD 取第一个字节放到 SDA 上。软件如果等发送完成中断才装载 I2CTXD,第一个字节已经过去了,主机只能采到总线空闲电平,也就是 0xFF。命令寄存器模式下,第一次呼叫是写方向,不会触发这个问题,问题往往出现在重复起始后的读方向地址匹配中断没有预装载。
解决:在地址匹配中断里判断当前状态,如果已经收到过命令字节,立刻执行I2CTXD = tx_buf[0];,把第一个响应字节备好。发送完成中断只装载后续字节,tx_idx 从 1 开始。如果每次都把 tx_idx 写成 0,第二个字节也会错位,我踩过一次,后来统一在地址匹配里初始化 tx_idx。
5.4 SCL 被拉死:从机状态机没复位
现象:总线上 SCL 一直为低,主机也无法再发起通信,复位主机无效,必须断电才能恢复。
原因:从机在通信中途收到异常电平时序时,状态机卡在了某个中间状态,持续把 SCL 拉低。这通常是主机侧时序毛刺或者从机代码在中断里耗时太长,导致从机错过了应答窗口,总线进入死锁。
解决:从机要支持总线错误恢复,检测到异常后复位自己的状态机。常见做法是给 I2CSLCR 的复位位置 1 再清 0:
void I2C_Bus_Recover(void) { I2CSLCR |= 0x40; // I2CRSST 写 1 复位从机状态机 I2CSLCR &= ~0x40; I2CSLST = 0x00; // 清状态和中断标志 }这个函数可以在检测到总线空闲超时时调用,也可以在应用层收到特定命令时手动触发。注意复位状态机并不能释放已经被外部器件拉低的 SCL,如果 SCL 一直为低,还需要主机侧主动发送时钟脉冲,让总线上的从机逐位释放。软件复位之后要重新检查 I2CSLST 是否归零,再继续后续通信。
6. 验证技巧:用逻辑分析仪反查从机时序
6.1 接线与解码设置:通道怎么分配、参数怎么填
逻辑分析仪是验证从机代码最快的手段,比示波器单次触发好用得多。接线时 SCL 接通道 0,SDA 接通道 1,地线共地。采样率设置成主机 I2C 时钟的 8 倍以上,比如主机跑 100 kHz,采样率至少 1 MHz,否则边沿细节抓不准。解码器选择 I2C,地址位宽设 7 位,地址显示格式选 Hex,这样主机发出的 0x64、0x65 和从机 ACK/NACK 会直接显示在波形上方。
抓完波形先看三件事:地址字节是否正确、ACK 位是否被从机拉低、读方向时数据字节是否和预期一致。重复起始在解码器里会显示为 Sr,这是确认命令寄存器模式是否生效的重要标记。如果 Sr 后紧跟的地址字节是 0x65,说明主机已经切到读方向,此时从机 I2CTXD 里必须已经有数据,否则大概率读回 0xFF。
6.2 低配自检:用 IO 翻转代替解码器
没有逻辑分析仪时,可以用一个空闲 IO 做自检。在地址匹配中断里翻转一次 IO,在接收完成中断里再翻转一次。用示波器看这个 IO 的脉冲宽度,能粗略估算两次中断之间的代码执行时间。如果脉冲宽度明显超出预期,说明中断服务函数里有耗时操作,需要把数据解析挪到主循环。
更好的低配做法是在从机里维护一个通信计数值,每成功接收一个命令字节自增,主循环里定时查询这个值。主机侧反复发送同一个命令,如果计数值稳定递增,说明从机中断链路是通的。从那以后,我每次写从机代码都强制自己先列一遍主机侧的预期时序,再在逻辑分析仪上核对每个地址字节和数据字节,尤其是重复起始后的第一个响应字节,必须亲眼看到它出现在正确位置。希望帮到你。
本文还有配套的精品资源,点击获取