☰
STC8G单片机I2C硬件从模式:中断状态机与避坑实战
2026/10/26 17:31:50 网站建设 项目流程

简介:这份代码面向使用 STC8G 系列单片机开发 I2C 从机通信的嵌入式学习者与工程师,聚焦中断方式下从模式收发流程的实现,适合已了解 I2C 时序、需要把寄存器配置与中断服务程序落地到具体工程中的读者。压缩包共 1 个文件,为 1 份 C 语言源码,体积约 1KB,内容紧凑,便于直接加入现有工程对照阅读。当前已有 2674 人学习下载,说明其在同类 STC 单片机 I2C 从模式实践中具有一定参考价值。代码围绕从机地址设置、I2C 控制/状态/数据寄存器操作、起始与停止条件识别、数据接收与发送、应答处理等关键环节展开,并涉及总线异常与数据溢出时的标志清除和恢复思路。读者可据此理解中断触发后如何依据状态码分支处理通信事务,减少主循环轮询开销,并以此为起点修改地址、对接主机读写逻辑,完成传感器、EEPROM 等外设的从机端联调。

1. 从一块只会"喂数据"的小板子说起:单片机 I2C 从模式的真实用处

主控那边只会当主机,两根线 SCL/SDA 拉到你手上这块 STC8G 小板子上,要求每 10ms 把四路电压、两路温度和两个开关量交出去——这就是单片机 I2C 从模式最典型的落地场景:你的板子不发起任何时序,只负责被叫到就应答。这份 STC8G I2C 从模式代码要干的活,就是把"被叫到"这件事做得足够稳:地址比对交给硬件、收发交给中断、数据组织交给主循环,三层分开,谁也不拖谁。适合两类人看:一类是主控 I2C 口不够用、想拿一颗几毛钱的片子当从设备的;另一类是已经用过软件模拟从机、被时序和中断延迟折腾过,想换成硬件从机但一直没跑通的。

2. 硬件从机的架构定位:STC8G 的 I2C 外设在从机模式下到底给了什么

先把定位说清楚,后面代码才不会写成玄学。STC8G 的 I2C 是片上外设,同一套寄存器既能当主机也能当从机,靠 I2CCFG 里的 MSSL 位切换。切到从机之后,硬件替你干了三件事:起始条件识别、7 位地址比对、字节级的移位收发。你要干的是三件:配好地址和事件使能、在中断里按方向取放数据、把数据从"中断现场"安全地交给主循环。

这个分工决定了代码的整体形状——从机代码的主体不是"流程",而是"状态机 + 缓冲区"。因为从机永远是被动的,你不知道主机什么时候来,也不知道它这次是读还是写,唯一能依靠的就是中断里那几个标志位和方向位。

2.1 从机模式占用的寄存器地图

把寄存器摊开看,从机侧真正要动的只有六个,其余都是给主机模式用的,从机模式下配了也没意义(比如 MSSPEED 那几位分频值)。

寄存器从机模式下管什么我初始化时给的值
I2CCFGENI2C 使能、MSSL 选主/从0x80(使能 + 从机)
I2CSLADR7 位从机地址,放在 bit7~bit1SLAVE_ADDR << 1
I2CSLCRSTART/收/发/STOP 四类事件使能 + SLRST 释放0xF1
I2CSLSTSLIF 事件标志、传输方向位、ACK 控制位中断里读快照,按位清
I2CTxD从机要发给主机的字节中断里写
I2CRxD从机刚从主机收到的字节中断里读

I2CSLCR 那四个使能位是整个从机逻辑的开关:ESTAI 管起始条件、ERXI 管接收、ETXI 管发送、ESTOI 管停止条件。它们本身就是事件级开关,不需要再去别的寄存器开第二道,最后把总中断 EA 打开就行。最低位 SLRST 是从机状态机的释放位,上电后必须让它从复位态出来,否则你地址写得再对,硬件也不会应答。

提示:写 I2CSLADR 时最容易翻车的是左移。硬件比对的是高 7 位,第 0 位是 R/W,不参与比较。有的例程直接写 8 位地址常量,看起来能跑,其实是因为那个常量恰好等于左移后的值。

2.2 四类事件和一条隐式状态机

从机的运行轨迹永远是这五步循环:START → 地址字节 → 若干数据字节(每个字节带一个 ACK/NACK)→ STOP(或者重复 START)→ 回到空闲。硬件把 START 和 STOP 单独拎出来做事件,中间那段字节流按方向分成"收"和"发"两路。

由此可以得出一个很重要的结论:从机中断里做的事,必须区分"这是地址字节还是数据字节"。地址字节到来时,硬件已经完成比对,你只需要记下方向;数据字节到来时,才真正读写缓冲区。区分办法有两种,一是靠 START 事件把phase标志置 1,二是靠 STOP 事件把phase复位。这两种都要,少一个就会出现"第二帧数据被当成地址"的错位。

至于时钟拉伸,从我翻到的寄存器页看,从机侧没有开放通用的 SCL 拉低手段。也就是说主机给你多少时间,你就得在多少时间内响应完。400k 速率下一个字节约 20μs,中断里但凡有个浮点运算或者长循环,数据就丢了——这条约束会直接影响后面缓冲区的设计。

2.3 硬件从机和软件模拟从机的取舍

很多人第一反应是"用定时器 + 双边沿中断模拟一个从机不就完了",我一开始也这么想,后来放弃了。对比一下很清楚:

维度硬件 I2C 从机定时器 + 边沿中断模拟
速率上限手册标称 400k100k 就很吃力
CPU 占用每字节一次中断每个边沿一次中断,约 18 次/字节
地址过滤硬件比对,不匹配不产生中断软件判断,白吃一次地址中断
抗干扰硬件移位,边沿抖动容忍度高抖一下就丢位
代码量寄存器配置 + 一个状态机,约 120 行位时序状态机,200 行起步

模拟方案唯一的好处是引脚随便挑。如果引脚能挪到硬件 I2C 那两根上,就没理由不用硬件从机。我吃过一次亏:模拟从机在主机发重复 START 的时候判错了状态,抓包看是地址被吃掉了半个位,查了两天才发现是中断里被另一个高优先级任务挤掉了几微秒。

3. 从机初始化:地址、引脚、事件使能三件事一次做对

初始化这段代码不长,但它是后面所有问题的源头。我一般按"物理层 → 地址 → 事件 → 缓冲"的顺序写,写完先在中断里打一个计数器,确认从机确实被叫到了,再往下写业务。

3.1 先把两根线的电平搞对

I2C 是开漏总线,这一点在 STC8G 上不能省。引脚模式寄存器 PxM1/PxM0 的组合里,M1=1、M0=0 是开漏输出,正好符合 I2C 的需要;外部再各挂一只 4.7k 上拉到 3.3V。线长超过 20cm 或者挂的设备多,就把上拉换成 2.2k,别死守 4.7k——示波器上看一眼上升沿,如果从 0 爬到 0.7VDD 要花掉 1μs 以上,400k 就基本没戏了。

/* 引脚模式:开漏输出。具体引脚号按你手上型号的引脚切换表核实 */ P3M1 |= 0x0C; /* P3.2/P3.3 的 M1 位置 1 */ P3M0 &= ~0x0C; /* M0 位清 0 -> M1=1,M0=0 开漏输出 */ P3 |= 0x0C; /* 输出锁存器置 1,开漏时才不会被内部拉低 */

这三行的逻辑是:M1/M0 决定引脚类型,而开漏输出的高电平靠外部上拉,所以输出锁存器必须先写 1,否则内部那只下拉管一直导通,总线被死死摁在低电平上。这一步漏掉的现象特别典型——主机发什么都是 NACK,示波器上 SDA 一直是 0.2V。

3.2 初始化函数:六个寄存器写到位

#include <STC8G.H> #define SLAVE_ADDR 0x28 /* 7 位从机地址,跟你主控那边约定的一致 */ #define I2C_BUF_LEN 32 /* 单帧最大字节数,按协议留够余量 */ volatile uint8_t rx_buf[I2C_BUF_LEN]; volatile uint8_t tx_buf[I2C_BUF_LEN]; volatile uint8_t rx_idx, tx_len, tx_idx; volatile uint8_t g_cmd; /* 一帧的第一个字节,当命令字用 */ volatile uint8_t g_frame_len; /* 一帧收了多少字节 */ volatile uint8_t g_frame_flag; /* 一帧结束标志,主循环消费 */ void I2C_Slave_Init(void) { /* 1. 地址:7 位地址放高 7 位,最低位是 R/W,不参与硬件比对 */ I2CSLADR = (uint8_t)(SLAVE_ADDR << 1); /* 2. 使能 I2C 外设,MSSL=0 选从机模式 */ I2CCFG = 0x80; /* 3. 四类事件全开,SLRST 置 1 让从机状态机从复位态释放出来 */ I2CSLCR = 0xF1; /* 4. 清状态、清索引,避免上电残留 */ I2CSLST = 0x00; rx_idx = 0; tx_idx = 0; tx_len = 0; g_frame_flag = 0; EA = 1; /* 事件级开关已经在 I2CSLCR 里打开,这里只开总中断 */ }

参数说明几个关键点。SLAVE_ADDR << 1这里左移一次就够,别在调用处再移一次;I2CCFG = 0x80的低 6 位是主机模式的分频,从机模式下不参与,写 0 最省事;I2CSLCR = 0xF1里的 0xF0 是四个事件使能,0x01 是 SLRST。有的例程把 SLRST 单独放在初始化最后一行置 1,作用一样,目的都是让从机状态机进入待命。

注意:EA = 1要放在所有外设配置之后。反过来写的话,配置过程中引脚电平的毛刺可能触发一次假中断,进 ISR 时缓冲索引还是随机值。

3.3 地址、广播地址和一个容易忽略的边界

7 位地址空间里 0x00 是通用呼叫地址(general call),0x01~0x07 和 0x78~0x7F 是保留段。如果你的从机需要响应广播,就得在地址匹配之外另做处理——硬件比对只认真实地址,广播地址要软件认。

另一个边界是同地址冲突。总线上挂了两颗地址一样的从机,主机写的时候两颗会同时 ACK,SDA 上就出现一个器件拉高一个拉低的对抗,波形上表现为某个位电平不对、数据错乱。这种问题在代码层查一辈子也查不出来,只能靠"一颗一颗拔掉试"或者用带地址扫描的主机工具过一遍。我现在的习惯是:板子上每颗 I2C 从机的地址都写在丝印旁边,焊之前先对一遍。

4. 中断服务函数逐段拆解:一次读写在从机侧到底走了哪几步

这段是整个项目的核心,也是最容易写错的地方。我的写法是"先取快照、再清标志、最后分派",三步不能换顺序。原因很实在:I2CSLST 里的标志位和方向位在同一个寄存器里,你要是先写后读,方向位可能已经被你写没了。

4.1 中断入口的三条纪律

第一条,进中断第一件事是把 I2CSLST 整个读到局部变量里,后面所有判断都用这个快照,不再二次读寄存器。第二条,清标志只清事件位,别图省事写I2CSLST = 0,那会把 ACK 控制位一起清掉,结果就是从机永远回 NACK。第三条,中断里不许调用任何可能阻塞的函数,包括延时、串口打印、EEPROM 写。

/* 事件位掩码集中在这里,换头文件版本或换型号只改这一块 */ #define M_SLIF 0x80 /* 从机事件挂起 */ #define M_ACKO 0x20 /* 从机要回的 ACK:1=ACK,0=NACK */ #define M_DIR 0x10 /* 方向:1=主机读(从机发),0=主机写(从机收) */ #define M_STOP 0x01 /* 停止条件事件 */

这四个掩码我建议你第一版别信我,也别信任何例程——先写一个"探针版"中断,把 I2CSLST 的原始值通过串口按十六进制打出来,然后让主机分别做一次"只写地址"、"写地址+1 字节"、"读地址+读 1 字节"、"发 STOP"四组动作,四组各是什么值一目了然。这半小时省不掉,我每次换型号都老老实实做一遍。

4.2 五条路径:START、地址、收、发、STOP

void I2C_Slave_Isr(void) interrupt 24 { uint8_t st; st = I2CSLST; /* 1) 取快照 */ if ((st & M_SLIF) == 0) return; /* 不是从机事件,直接退出 */ I2CSLST = st & 0x0F; /* 2) 只清高 4 位事件位,低 4 位配置位保留 */ if (st & M_STOP) /* 路径一:STOP,一帧结束 */ { g_frame_len = rx_idx; /* 把这一帧的长度交给主循环 */ g_frame_flag = 1; rx_idx = 0; tx_idx = 0; return; } if (st & M_DIR) /* 路径二:主机读,从机往 I2CTxD 塞数据 */ { if (tx_idx < tx_len) I2CTxD = tx_buf[tx_idx++]; else I2CTxD = 0xFF; /* 越界补 0xFF,下面同时回 NACK 让主机停 */ if (tx_idx < tx_len) I2CSLST |= M_ACKO; /* 还有数据,回 ACK 让主机继续读 */ else I2CSLST &= ~M_ACKO; /* 数据发完,回 NACK 收尾 */ } else /* 路径三:主机写,从机从 I2CRxD 取数据 */ { uint8_t d = I2CRxD; if (rx_idx == 0) /* 一帧的第一个字节当命令字 */ { g_cmd = d; rx_idx = 1; } else if (rx_idx < I2C_BUF_LEN) { rx_buf[rx_idx - 1] = d; rx_idx++; } if (rx_idx < I2C_BUF_LEN) I2CSLST |= M_ACKO; /* 还有空间,回 ACK */ else I2CSLST &= ~M_ACKO; /* 缓冲满,回 NACK,让主机知道这帧没吃下 */ } }

逐段说逻辑。开头的st & M_SLIF判断是防误入,I2C 中断号在某些配置下可能被别的源共用,多一道判断不亏。I2CSLST = st & 0x0F这句是把高 4 位写 0 来清事件标志,低 4 位原样写回,这样 ACK 控制位不会被顺手清掉。

STOP 分支里把rx_idx存进g_frame_len再清零,是为了让主循环拿到一个完整的帧长度,而不是一个随时被中断改掉的变量。这一点看起来小题大做,但这是"数据偶尔少一个字节"这类问题的根源。

方向分支里,主机读的时候从机必须提前把数据放进 I2CTxD,硬件会在下一个 SCL 下降沿把它移出去。所以tx_buf和tx_len必须在主机来读之前就准备好——通常在收到命令字的那一帧里准备,也就是"写命令 → 读结果"这种两段式交互。

关于 ACK 控制:I2CSLST |= M_ACKO是告诉硬件"下一个字节我收下了"。如果缓冲区满了还回 ACK,主机就会继续发,数据静默丢失,抓包看波形完全正常,只有对数据的时候才发现少了一截。回 NACK 至少主机会感知到,重发或者报错,比静默丢好排查一百倍。

4.3 主循环怎么安全取走数据

中断里只做搬字节,业务处理全放主循环,这是硬规矩。但主循环取数据的时候要注意原子性:g_frame_flag、g_frame_len、rx_buf这三个东西,中断随时可能改前两个。

void main(void) { uint8_t len, cmd, i; uint8_t work_buf[I2C_BUF_LEN]; I2C_Slave_Init(); while (1) { if (g_frame_flag) { EA = 0; /* 拷贝期间关中断,防止边拷边改 */ len = g_frame_len; cmd = g_cmd; for (i = 0; i + 1 < len && i < I2C_BUF_LEN; i++) work_buf[i] = rx_buf[i]; g_frame_flag = 0; EA = 1; if (len > 0) handle_frame(cmd, work_buf, len - 1); } } }

这里的EA = 0只包住了几次内存拷贝,几十个机器周期,不会影响 I2C 响应——因为下一次字节到来至少要等主机那边几个微秒。但如果你的handle_frame里有耗时操作(比如写 EEPROM、驱动继电器),一定放在EA = 1之后,否则下一个字节来了中断进不去,直接丢数据。

5. 避坑与排查:从机不应答、数据错位、跑几分钟就死的现场记录

从机代码的坑有个共同特点:症状在总线上,根因在代码里,中间隔着一层硬件,光看代码看不出来。下面五条是我自己踩过的,按"现象 → 原因 → 解决"记下来,遇到类似症状可以直接对号入座。

5.1 五条血泪记录

第一条,主机发 START 之后从机毫无反应,示波器上 SDA 一直是高。

原因是地址没对上。最常见的是 I2CSLADR 忘了左移,或者

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询