简介:面向 STM8S 嵌入式开发者的 I2C 读写 EEPROM 工程示例包,基于硬件 I2C 接口实现 24CXX 系列存储器读写,适合学习 I2C 协议、驱动开发及数据存取应用的入门与移植参考。资源共 68 个文件,包含 32 个头文件与 31 个 C 源文件,覆盖主程序、BSP 驱动及 STM8S 标准外设库,另附 IAR 工程文件、说明文档和清理脚本,整体压缩包 216KB,结构紧凑便于下载学习。压缩包内代码模块划分清晰:Bsp 目录提供 I2C、定时器、串口等底层驱动,Libraries 目录集成标准外设库,配合 main.c 可直接对照调用;ReadMe 和清理编译文件的批处理脚本也便于快速上手、复用与二次开发。已有 398 人学习下载,适合嵌入式初学者与进阶开发者参考借鉴。通过该工程可理解 STM8S 硬件 I2C 的初始化、读写时序、错误处理与中断机制,为后续项目存储方案提供可运行参考,并在实际板卡上验证数据写入与读取流程。
1. 别再用GPIO模拟I2C了:STM8S硬件外设读写EEPROM,代码反而简单
手里有台小仪表,调了三天I2C读写EEPROM,用IO口模拟时序,一开中断就花屏,写进去的数据读回来乱码。后来把方案换成STM8S自带的硬件I2C外设,SCL和SDA时序交给芯片去管,代码量减了一半,问题一次解决。这份STM8S_I2C读写EEPROM(硬件)工程正是这条路的完整落地:寄存器直接操作,不依赖库封装,从初始化、单字节读写、页写、随机地址读到重复起始,把驱动AT24C系列EEPROM的流程全部跑通。适合第一次在STM8S上碰硬件I2C的开发者,也适合产品里做掉电存储、对时序稳定性有要求的工程师。拆完后最大的感触是:硬件I2C不难,难的是那些不查手册根本发现不了的事件标志处理。
2. 硬件I2C机制拆解:从外设事件标志到AT24C地址映射
2.1 硬件I2C和软件模拟的本质差别
软件模拟I2C是拿两个GPIO口手动拉高拉低SCL和SDA,每一位时序都是CPU算出来的,好处是引脚随便选,坏处也明显:速率上不去,中间夹几个中断还会把时序拉歪,EEPROM这类对时序窗口有要求的从设备很容易翻车。硬件I2C则把这套电平翻转交给外设,CPU把数据写进I2C_DR,外设自动在SCL上打时钟、在SDA上移数据。
STM8S的I2C外设通过SR1、SR3寄存器上报事件,编程模型是典型的事件驱动。写方向的事件链是SB(起始位已建立)→ ADDR(地址已发送且收到应答)→ TXE(发送数据寄存器空,可以写下一字节);读方向是SB → ADDR → RXNE(接收数据寄存器满)。麻烦的是这些标志的清除方式各不相同,ADDR必须连着读SR1和SR3才能清掉,跟STM32上读SR1再读SR2一个道理。工程源码里所有事件都手工轮询,没有库函数代劳,所以能看清整条状态机。
2.2 AT24C设备地址:从A0引脚到页选择的演变
AT24C02是最常见的256字节EEPROM,8字节一页。设备地址由固定的“1010”前缀加A2、A1、A0三个硬件引脚电平拼成,最低位是读写方向位。三脚都接地时写地址0xA0,读地址0xA1。这也是为什么一条I2C总线上最多能挂8片地址不冲突的AT24C02,每片靠引脚电平错开。
容量往上走,地址规则跟着变。AT24C16没有A0~A2引脚,设备地址最低3位从引脚选择变成页选择。到了AT24C128/256,设备地址又回到固定前缀,但片内地址改成了2字节指针,页缓冲也涨到64字节。所以写驱动前第一件事是翻数据手册:设备地址是几位、字地址是1字节还是2字节、页缓冲多大,这三个参数定了,代码结构基本就定了。
2.3 工程目录与分层
工程文件不多,但分层很清楚。
| 文件 | 职责 |
|---|---|
| main.c | 演示调用,先页写再读回,跑通整条链路 |
| i2c.c / i2c.h | 最底层硬件I2C驱动,主模式事件轮询 |
| eeprom.c / eeprom.h | 针对EEPROM的操作封装,单字节、页、连续读 |
| stm8s_it.c | 中断弱定义,本工程以轮询为主,中断占位 |
这样的分法最直接的好处是:以后换AT24C系列里的其他型号,动手的只有eeprom.c,I2C底层不用碰。下面拆代码也按这个顺序走。
3. 核心代码逐段拆解:一个读写作如何走通事件链
3.1 I2C初始化:时钟频率和GPIO开漏
直接看初始化函数。
void I2C_Init_HW(void) { unsigned int ccr = 0; I2C_CR1 &= ~0x01; // PE=0,先关闭外设再配参数 I2C_FREQR = 16; // I2C外设输入时钟16MHz // 标准模式100kHz,CCR = Fmaster / (2 * Fscl) // 16MHz / 200kHz = 80 ccr = 16000000UL / (2 * 100000UL); I2C_CCRH = (unsigned char)(ccr >> 8); I2C_CCRL = (unsigned char)(ccr & 0xFF); I2C_TRISE = 17; // 1000ns/62.5ns + 1 // PB4=SCL, PB5=SDA, 配成开漏输出 PB_DDR |= 0x30; // 方向 = 输出 PB_CR1 &= ~0x30; // CR1=0,开漏 PB_CR2 |= 0x30; // 高速斜率 I2C_CR1 |= 0x01; // PE=1,使能外设 }初始化最容易被误解的是I2C_FREQR。它不是I2C总线频率,而是I2C外设的输入时钟频率,单位MHz。STM8S要靠这个值计算各种时序参数,填错的话后面CCR算出来的实际波特率会偏。CCR公式是主时钟除以两倍的总线时钟,16MHz对100kHz算出来80,写入CCRL,CCRH保持0即可。TRISE根据最大上升时间换算,标准模式100kHz配17。
GPIO必须开漏而不是推挽。I2C总线需要线与逻辑,推挽输出会在外部器件拉低SDA时产生短路电流,这一点和软件模拟时的习惯完全不同。这里PB4/PB5是常见的外设引脚映射,具体型号要先查数据手册确认SCL/SDA在哪个端口,别照抄。
3.2 单字节写:完整的事件链
uint8_t I2C_WriteByte(uint8_t dev_addr, uint8_t mem_addr, uint8_t data) { uint16_t timeout = 5000; I2C_CR2 |= 0x01; // 发出START while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return 1; I2C_DR = dev_addr; // 写方向从机地址 timeout = 5000; while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return 2; (void)I2C_SR1; // 读SR1再读SR3清ADDR (void)I2C_SR3; I2C_DR = mem_addr; // 写入目标地址 timeout = 5000; while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return 3; I2C_DR = data; // 发送数据 timeout = 5000; while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return 4; I2C_CR2 |= 0x02; // 发送STOP return 0; }这段代码本质是四步状态机:起始完成 → 地址发送完成 → 字地址写入 → 数据写入。每次等待前都重置timeout,一旦对应标志位没在时限内置起就返回错误码。返回值1~4分别代表起始失败、地址失败、字地址失败、数据失败,这种分级错误码在调试时非常有用。
注意那两行(void)I2C_SR1; (void)I2C_SR3;不是多余代码。ADDR位的清除方式是“先读SR1、再读SR3”,两个动作缺一不可。少读SR3的话,外设会认为地址还没处理完,下一次通信直接卡住。这是STM8S硬件I2C最容易踩的坑,后面避坑章节还会专门讲。参数里dev_addr是包含读写位的设备地址,比如AT24C02接地后传0xA0;mem_addr是EEPROM内部字地址,AT24C02范围0x00~0xFF。
3.3 随机地址读:重复起始的用法
uint8_t I2C_ReadByte(uint8_t dev_addr, uint8_t mem_addr) { uint16_t timeout = 5000; uint8_t data = 0xFF; I2C_CR2 |= 0x01; // START while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return data; I2C_DR = dev_addr; // 写方向 while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return data; (void)I2C_SR1; (void)I2C_SR3; // 清ADDR I2C_DR = mem_addr; // 字地址 while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return data; I2C_CR2 |= 0x01; // 重复起始 while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return data; I2C_DR = dev_addr | 0x01; // 读方向地址 while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return data; (void)I2C_SR1; (void)I2C_SR3; I2C_CR2 &= ~0x08; // ACK=0,最后一字节不应答 while (!(I2C_SR1 & I2C_SR1_RXNE) && --timeout); if (timeout != 0) data = I2C_DR; I2C_CR2 |= 0x02; // STOP return data; }随机读的关键在第二次起始。完整流程是:先按写方向把设备地址和字地址发出去,再用重复起始把方向翻成读。不能在发完字地址后直接写0xA1,那样外设会把0xA1当数据发出去;也不能用STOP加START替代重复起始,中途释放总线会被其他主设备抢走,多机场景下会出乱子。
另外一个细节是ACK清零的时机。单字节读只有一字节数据,主机在读数据之前就要把I2C_CR2的ACK位清零,从机收到NACK后就不再占用SDA。如果这里不清,从机会继续发下一字节,总线时序就乱了。这条在连续读里更明显,下一节展开。
3.4 页写与连续读:数据结构化
uint8_t I2C_PageWrite(uint8_t dev_addr, uint8_t mem_addr, uint8_t *buf, uint8_t len) { uint8_t i = 0; uint16_t timeout = 5000; // AT24C02页缓冲只有8字节,跨页会回卷覆盖 if (len == 0 || (mem_addr % 8) + len > 8) return 1; I2C_CR2 |= 0x01; // START while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return 2; I2C_DR = dev_addr; // 设备地址 while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return 2; (void)I2C_SR1; (void)I2C_SR3; I2C_DR = mem_addr; // 起始字地址 while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return 2; for (i = 0; i < len; i++) { I2C_DR = buf[i]; timeout = 5000; while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return 3; if (I2C_SR3 & 0x02) // AF应答失败 return 4; } I2C_CR2 |= 0x02; // STOP,触发内部写周期 return 0; }页写省的是总线开销:EEPROM内部有页缓冲,连续写8字节只需要一个起始、一个设备地址、一个字地址,比8次单字节写少发一大堆地址。代价是页缓冲只有8字节,跨页时地址会回卷到当前页开头,把刚写进去的数据覆盖掉。所以驱动入口做了防护,len为空或者页内偏移加len超过8就拒绝执行。
AF位的检查放在每个字节发送之后。从机没应答时AF会置1,此时继续发数据没有意义,直接返回错误。STOP发出后EEPROM进入内部写周期,典型耗时5ms,这期间芯片不响应任何总线操作,调用方要在页写之后做延时,否则马上读回的是旧数据。
uint8_t I2C_ReadNBytes(uint8_t dev_addr, uint8_t mem_addr, uint8_t *buf, uint8_t len) { uint8_t i = 0; uint16_t timeout = 5000; I2C_CR2 |= 0x01; while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return 1; I2C_DR = dev_addr; while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return 2; (void)I2C_SR1; (void)I2C_SR3; I2C_DR = mem_addr; while (!(I2C_SR1 & I2C_SR1_TXE) && --timeout); if (timeout == 0) return 2; I2C_CR2 |= 0x01; // 重复起始到读方向 while (!(I2C_SR1 & I2C_SR1_SB) && --timeout); if (timeout == 0) return 2; I2C_DR = dev_addr | 0x01; while (!(I2C_SR1 & I2C_SR1_ADDR) && --timeout); if (timeout == 0) return 2; (void)I2C_SR1; (void)I2C_SR3; for (i = 0; i < len; i++) { if (i == len - 1) I2C_CR2 &= ~0x08; // 最后一个字节前关ACK timeout = 5000; while (!(I2C_SR1 & I2C_SR1_RXNE) && --timeout); if (timeout == 0) return 3; buf[i] = I2C_DR; } I2C_CR2 |= 0x02; // STOP return 0; }连续读的ACK策略是关键。前len-1个字节要保证ACK=1,告诉从机继续发;最后一个字节前关掉ACK,从机收到NACK才释放SDA。顺序一旦反了,要么读到多余字节,要么总线卡在RXNE一直等。理解了这段代码,再回头改任意容量的EEPROM都只是参数问题。
主函数里调用时,记得先写后延时再读:
int main(void) { uint8_t wr[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t rd[8] = {0}; I2C_Init_HW(); if (I2C_PageWrite(0xA0, 0x00, wr, 8) == 0) { delay_ms(10); // 等待内部写周期结束 I2C_ReadNBytes(0xA0, 0x00, rd, 8); } while (1); }dev_addr传0xA0对应A0~A2全部接地的AT24C02,读方向会在驱动内部加1变成0xA1。delay_ms(10)是给EEPROM内部写周期兜底,产品里更讲究的做法是做ACK轮询,等从机第一次回NACK之后再次请求直到ACK。
4. 避坑记录:STM8S硬件I2C常见的五个翻车现场
4.1 现象一:程序卡在SB标志,永远等不到起始完成
仿真器一跑,程序停在等待SB的位置,SCL波形要么恒定高电平要么完全没有。原因最常见的是SCL和SDA上缺了外部上拉电阻。STM8S的I2C引脚是开漏结构,外设只能把线拉低,不能主动拉高,总线的高电平靠外部上拉电阻提供。单片机GPIO内部的弱上拉在这个场合带不动,尤其总线电容稍大一点,上升沿就不达标。
解决:在SCL和SDA上各接一个4.7kΩ电阻到3.3V,快速模式400kHz时换成2.2kΩ。接完用示波器看,总线上应该能看到清晰的方波。
4.2 现象二:写入成功,读出来全是0xFF
排查顺序:先用逻辑分析仪看有没有ACK,确认从机在不在线上。排除硬件后就是地址问题。AT24C02的设备地址由A0/A1/A2引脚决定,代码里发0xA0的前提是这三个脚全部接地。有人跳线接成高电平,代码还是0xA0,从机自然不应答,总线读上来的数据就是0xFF。
还有一种情况是字地址越界。AT24C02只有256字节,地址超过0xFF照样会“写成功”,但数据落到了回卷后的位置,读出来当然不是预期值。解决:万用表量引脚电平,对照数据手册把dev_addr算一遍,地址越界在驱动里加范围检查。
4.3 现象三:页写数据错乱,前几个字节被后几个覆盖
一次页写超过8字节,写入数据乱套,重复执行后只剩最后几个字节。原因:AT24C02的页缓冲只有8字节,页内地址写到111后再写就自动回卷到本页开头,而不是自动滚到下一页。很多新手不知道这个限制,写满8字节后又继续写,结果把本页开头的旧数据覆盖了。
解决:驱动里加跨页保护,页写前判断len和页内偏移,就是前面I2C_PageWrite函数入口那个return 1的检查。大块数据就拆成多次页写,每次最多8字节,或者按目标芯片的page size重新定义宏。
4.4 现象四:ADDR标志清不掉,第二次通信就卡住
第一次写完正常,第二次写从机不应答,状态寄存器一直显示ADDR。原因:ADDR标志不是读一下SR1就能清的。STM8S里清ADDR必须按“读SR1再读SR3”的顺序,两个动作都要做。少读SR3的话,外设会认为地址还没处理完,后续的总线事件全部排队阻塞。
解决:在每次地址发送后固定写两行(void)I2C_SR1; (void)I2C_SR3;。别嫌多余,这是芯片手册要求的清除时序。我在这个坑上浪费过一个下午,后来把这两行编成习惯,再没犯过。
4.5 现象五:写周期没结束,紧跟着读回旧数据
写完一个字节马上读同一地址,读出来还是上一次的旧值,加一个100毫秒延时又好了。原因:EEPROM收到STOP之后需要内部写周期tWR,普遍为5ms左右,这段时间芯片不响应总线也不能提供新数据。
解决:工程里写后delay_ms(10)最省事。追求效率就做ACK轮询——重复发设备地址,从机在写周期结束前会回NACK,等到返回ACK就说明可以继续操作。这也是产品里常用的处理方式。
5. 二次开发怎么做:换大容量EEPROM、超时复位、抽通用驱动
5.1 换AT24C128只需要改地址长度
把片内地址从1字节改成2字节,是容量扩展的主要动作。AT24C128是16KB容量,页缓冲64字节。设备地址还是1010加A2/A1/A0,但字地址要拆成高字节和低字节先后发出。改动集中在eeprom.c,I2C底层完全不用碰。写函数里把mem_addr从uint8_t换成uint16_t,发送时先发高位再发低位。
uint8_t AT24C_WriteByte_16bit(uint8_t dev_addr, uint16_t mem_addr, uint8_t data) { // 前序START、设备地址、清ADDR的流程与之前一致 I2C_DR = dev_addr; // 设备地址 // 等待ADDR、清ADDR I2C_DR = (uint8_t)(mem_addr >> 8); // 地址高字节 // 等待TXE I2C_DR = (uint8_t)(mem_addr & 0xFF); // 地址低字节 // 等待TXE I2C_DR = data; // 等待TXE,检查AF I2C_CR2 |= 0x02; // STOP return 0; }同时把页大小宏从8改成64。上层调用完全不用动,驱动内部两个宏控制一切。
5.2 给每一个等待加超时兜底
STM8S的I2C外设一旦总线被拉死,标志位可能永远不置位,程序就死等。产品代码不能裸等,我给每个while循环都加了5ms超时,超时就调用软件复位函数:
void I2C_Recover(void) { I2C_CR2 |= 0x80; // SWRST=1,复位I2C外设 I2C_CR2 &= ~0x80; I2C_Init_HW(); // 重新初始化 }然后在所有失败分支向上抛错误码,主循环根据错误码决定重启还是报警。这个兜底加上之后,总线上就算有从机死机拖住SCL,系统也能在几毫秒内恢复。
5.3 驱动层抽象
最后我习惯把所有函数统一成一套标准接口:eeprom_write(dev_addr, addr, buf, len)和eeprom_read(...)。底层是AT24C02还是AT24C256,对上层来说只是两个宏:页大小和地址宽度。以后接RTC、温湿度传感器,只需要用同样的事件轮询套路,换设备地址就能跑通。从那以后,我每换一颗新的从设备芯片,第一件事就是翻开数据手册的地址结构表和页大小说明,把这两页纸吃透再动驱动代码,I2C的返工率直线降了下来。希望帮到你。
本文还有配套的精品资源,点击获取