☰
STM32F103驱动AT24C02 EEPROM:I2C读写实战与调试指南
2026/10/29 19:22:00 网站建设 项目流程

工作台上这块STM32F103最小系统板已经跟我好几年了,这次要给它加一个“断电不丢记忆”的小功能。翻了一圈手头元器件,选了AT24C02这颗经典EEPROM。STM32F103的I2C外设、AT24C02的读写时机、再加上两根线的开漏逻辑,这套组合几乎是每个单片机爱好者都会碰到的入门关卡。写这篇完整拆解,是因为很多新手照着网上的例程却能卡上几天:要么卡在I2C的ACK检测,要么读回来全是0xFF,要么页写写到一半数据乱掉。我打算从硬件接线、CubeMX配置、I2C时序、HAL库代码、串口验证、ST-Link调试一直讲到问题排查,把整个流程里所有容易踩的坑都摊开说清楚。不看理论手册也能跑通,但我会尽量把原理也讲明白,让后面换芯片、挂多设备时心里也有底。

1. 硬件准备:从最小系统板到AT24C02的接线

1.1 为什么选AT24C02做掉电存储

AT24C02是Atmel(现在归Microchip)出的一颗2Kbit串行EEPROM,换算下来是256字节。容量不大,但掉电数据不丢、可擦写100万次、单字节能独立读写,这几个特点让它非常适合存配置参数、校准值、设备序列号这类“小数据”。

你可能会问,为什么不用W25Q32这类SPI Flash?Flash的好处是容量大、价格低,但它按扇区擦除,想改一个字节得先把整个扇区读出来改完再擦写回去,麻烦。SD卡就更不用说,协议复杂、还要文件系统。EEPROM的优势就是“按字节来”,写入一个字节不牵连其他数据。I2C总线只需要SCL和SDA两根线,不占用太多GPIO,这在引脚紧张的项目里非常友好。

AT24C02的容量虽然小,但对标很多实际使用场景足够用了。比如做个温控器,要存目标温度、校准系数、累计运行时间,几十个字节搞定;做个传感器小模块,要存设备地址、量程范围,也是几个字节的事。等以后确实需要更大容量,直接换AT24C04/08/16,I2C协议几乎一样,驱动代码改动很小。

1.2 引脚接线与板载电源

AT24C02常见封装是SOP-8和DIP-8。我手头这块是面包板用的DIP封装,引脚排列很规整:1脚A0、2脚A1、3脚A2是地址选择脚,4脚GND,5脚SDA、6脚SCL是I2C总线,7脚WP是写保护,8脚VCC。我把A0/A1/A2全部接GND,这样芯片的7位从机地址就是0x50,后面加上读写位就变成0xA0(写)和0xA1(读)。WP接GND,让它允许写入,如果WP接VCC,芯片会被保护,写操作全被忽略,这是很多人读回全0xFF的原因之一。

VCC我直接接3.3V。AT24C02工作电压范围很宽,1.8V到5.5V都行,接3.3V和STM32F103的IO电平完全一致,最省事。市面上很多小模块会带一颗LDO或电平转换电路,但核心芯片本身用3.3V完全没问题。如果你用的是裸片,注意给VCC脚加一个0.1uF的旁路电容,靠近芯片放置,避免电源纹波在I2C读写时造成误码。

接线对应关系:SCL接STM32F103的PB6,SDA接PB7,这是I2C1的默认引脚。开发板的3.3V和GND分别接芯片VCC和GND。如果你用的是那种带排针的AT24C02模块,通常模块上已经配好了上拉电阻,直接飞线就行。如果是面包板自己搭,SCL和SDA上各加一颗4.7k电阻到VCC。这两个上拉电阻非常关键,后面单独说。

1.3 开漏模式、上拉电阻和电平转换

I2C总线设计上就是“开漏+上拉”。STM32的GPIO如果配置成推挽输出,高电平靠内部强驱动拉高,低电平靠内部强驱动拉低,单设备用没问题,但I2C总线上可能有多个主设备和多个从设备,大家都用推挽输出,一个设备想拉低、另一个想拉高,就会形成短路,轻则通讯错误,重则烧引脚。

开漏输出是另一个思路:GPIO输出逻辑1时,引脚处于高阻状态,放弃驱动;输出逻辑0时,内部MOS管导通,把引脚拉到GND。高电平完全靠外部的上拉电阻拉起来。这样总线上任何一个设备都能主动拉低,但谁都无法强制拉高,所有设备“线与”在一起也不会冲突。这也是为什么I2C协议里,设备回应ACK时会把SDA拉低,主机读最后1字节时则故意不拉低,释放SDA,让上拉电阻把它拉高,产生NACK。

CubeMX里配置I2C引脚时,会自动把PB6/PB7设置为开漏复用功能,不需要手动改GPIO模式,但也有不少初学者自己建工程时不小心把它配成推挽,结果通讯能通但偶尔卡死。开漏模式是I2C正常工作的必要条件。上拉电阻一般选4.7k或10k,总线速度快、设备多时用4.7k,慢速单设备用10k也行。STM32F103内部虽然有可配置的上拉电阻,但阻值偏大,带外部上拉更稳定,不要让MCU内部上拉替你扛。

关于5V转3.3V的话题,准确说应该是“如果AT24C02由5V供电,STM32F103的3.3V IO怎么和它对接”。STM32F103的IO标称5V耐压,很多5V供电的EEPROM模块直接接上去也能工作,因为TTL电平兼容,但这种做法并不严谨:SCL/SDA被上拉到5V,输入高电平虽然能被识别,可一旦MCU内部保护二极管导通,长期使用有风险。最稳妥的方法就是EEPROM也接3.3V,或者用一颗双向电平转换芯片(比如TXS0102)隔开。实际项目里为了兼容一些老旧5V器件,我会在总线上串33欧电阻再连5V上拉,但这只能算应急,新设计不要这么搞。

2. 工程搭建:STM32CubeMX配置I2C与串口

2.1 时钟与I2C参数配置

STM32CubeMX配置F103的I2C非常简单。新建工程,选择芯片型号后,先在System Core的RCC里把HSE设为Crystal/Ceramic Resonator,因为最小系统板上有8M无源晶振。然后在Clock Configuration里把HCLK调成72MHz,PLL锁相环会自动配置,APB1总线频率会是36MHz,而I2C1刚好挂在APB1上。I2C外设时钟源从PCLK1来,如果APB1分频不对,I2C时序也会跟着偏。

左侧Categories里找到Connectivity,打开I2C1,Mode选择I2C。右边Configuration里默认I2C Speed Mode是Standard Mode,速度100kHz,7位地址,这些不用改。100kHz是I2C标准模式下所有从设备都接受的速度,AT24C02支持100kHz和400kHz,但把总线速度调到400kHz意味着对PCB走线、上拉电阻、寄生电容都更敏感。第一次调通,100kHz是最稳的。

生成代码前别忘了同时打开USART1,后面要用来串口打印验证结果。USART1的模式设为Asynchronous,波特率115200、8位数据、1位停止位、无校验。PA9和PA10会自动配置为TX和RX。图中你会发现CubeMX已经自动把PB6、PB7设置为开漏复用输出,这说明它清楚I2C的要求。

2.2 生成代码与Keil工程调整

CubeMX生成的是HAL库工程,IDE选MDK-ARM,生成后直接打开Keil。如果你用的不是原厂ST芯片而是国产兼容芯片,MDK在下载固件包时会提示找不到Device,需要先装对应的Pack包。CubeMX项目里芯片型号选STM32F103C8,Pack可以从Keil官网或芯片厂商官网下载,安装后Restart Keil,设备列表里才会出现对应型号。

工程打开后,默认生成的main.c里已经有MX_GPIO_Init()、MX_I2C1_Init()、MX_USART1_UART_Init()这些初始化调用。I2C1的初始化里能看到:

static void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 = 0; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }

这里ClockSpeed=100000就是100kHz。OwnAddress1=0表示STM32作为主机时不需要设置自己的从机地址,但保留字段先放着不影响。NoStretchMode默认关闭,从机可以拉低SCL进行时钟拉伸,这是I2C协议允许的流控手段,AT24C02虽然不用时钟拉伸,但保持默认没问题。

2.3 ST-Link调试环境的几个必要条件

Keil里配置ST-Link调试器,道理上讲清楚一次就能省很多事。打开Options for Target,Debug页选择ST-Link Debugger,Settings里连接方式选SWD。SWD只需要SWDIO和SWCLK两根线,比JTAG占用引脚少很多,适合F103这种小封装芯片。如果你用的是正版ST-Link,MDK自带驱动,插上USB后设备管理器能看到STLink dongle;如果用的是某些兼容ST-Link,需要单独装驱动。

Flash Download里要勾选Reset and Run,这样烧录完程序自动运行。很多人烧完发现板子没反应,其实程序没跑,按一下复位键才运行,勾选这个选项就省了这个动作。

ST-Link在线调试时,常用的几个窗口在View菜单里:Watch窗口看变量变化,Disassembly窗口看汇编,Peripherals窗口看外设寄存器。调I2C最常用的是Watch窗口里观察hi2c1.ErrorCode和hi2c1.State,当HAL函数返回超时时,这两个字段能帮你缩小问题范围。

如果你在Keil里使用标准外设库而不是HAL,结构会不一样,但调试思路一样:F103的I2C硬件外设比较“娇气”,标准库时代很多人被它坑过,后来官方支持库升级和HAL库普及后,问题少了很多。只要初始化正确、开漏上拉到位,硬件I2C是够用的。如果你非要全寄存器自己写,也可以,但没必要在初学阶段跟硬件状态机较劲。

3. I2C时序拆解:AT24C02的读和写为什么这么设计

3.1 起始停止、地址帧和ACK

I2C只有两根线,所有信息靠时序表达。空闲时SCL和SDA都被上拉电阻拉高。主机要开始通信,先把SDA从高变低,此时SCL保持高,这个下降沿就是起始条件。通信结束,主机先把SDA拉低,再把SCL拉高,最后把SDA也拉高,这个上升沿就是停止条件。

传输数据时,SCL低电平期间SDA可以变化,SCL高电平期间SDA必须保持稳定。每一字节严格走8个位,然后第9个时钟周期留给ACK响应。从机收到1字节后,如果正常,会在第9个时钟把SDA拉低,表示ACK;如果它忙、或者地址不对、或者不想接收,就不会拉低,SDA被上拉电阻保持高电平,主机读到NACK。

AT24C02的7位地址是1010000,也就是0x50。I2C协议规定地址帧最后一位是读写方向位,所以主机发送0xA0就是“写=10100000”,发送0xA1就是“读=10100001”。很多新手直接写成0x50,硬件上根本不会应答,因为协议要求把地址左移一位再填方向位。

3.2 EEPROM的三种读/写操作

AT24C02支持字节写和页写。字节写时序是:起始条件 -> 发送0xA0 -> 内存地址 -> 写入数据 -> 停止条件。每步之间EEPROM都会回ACK。页写则是从一个起始地址开始连续写多个字节,EEPROM内部的地址指针会自动递增,但注意递增只在当前页内部进行,AT24C02页大小是8字节。

读操作分当前地址读、随机读取和连续读。当前地址读最简单:直接发0xA1,EEPROM会把内部地址指针指向的那个字节送出来。随机读取是标准读法:先发0xA0和内存地址,把地址“预置”进去,然后不停止,而是再发起一个重复起始条件,接着发0xA1,EEPROM才把对应地址的数据送出来。连续读就是随机读取后不回停止,一直接收字节,EEPROM内部地址自动递增,直到主机发NACK并停止。

这里有一个理解难点:为什么随机读要先发0xA0?因为读操作需要告诉EEPROM“我要读哪个地址”。这个地址本身是普通数据,发送它时总线方向是主机到从机,所以要带写方向。发完地址后再切方向读。重复起始条件的好处是标定“这是一个操作的延续”,避免前面那次写地址被当作一次完整写操作,但实际AT24C02对STOP之后重新START的方式也能接受,HAL库的MemRead函数就是标准重复起始实现,照着用就行。

3.3 写入周期与等待策略

EEPROM写数据进去不是瞬间的。字节写或页写结束后,芯片内部必须花时间把数据固化到存储单元,这个时间叫写周期,AT24C02典型2ms,最大5ms。在写周期内,芯片不响应任何外部命令,发地址给它,它不会回ACK。

等待写周期结束有两种常用方法。第一种是死等:每次写完就HAL_Delay(5)或10ms,简单粗暴,但浪费CPU时间。第二种更专业,叫“轮询ACK”:反复给EEPROM发0xA0地址,因为它在写周期内不回ACK,一旦写周期结束,它就会恢复正常应答。用HAL库的HAL_I2C_IsDeviceReady可以做这个事:

uint8_t AT24C02_WaitReady(void) { uint8_t retry = 0; while (HAL_I2C_IsDeviceReady(&hi2c1, 0xA0, 5, 100) != HAL_OK) { if (++retry > 20) return 1; /* 约几百毫秒还忙,判定异常 */ } return 0; }

这种方式效率高,而且能顺便确认EEPROM是否还在总线上。多字节连续写入时,轮询ACK比固定延时可靠得多,因为写周期长短受供电、温度影响,固定延时要么太保守要么不够稳。

4. HAL库读写代码实战

4.1 单字节读写

HAL库提供了HAL_I2C_Mem_Write和HAL_I2C_Mem_Read,这两个函数就是为“I2C从机内部有寄存器/内存地址”这种场景设计的,和EEPROM简直是绝配。

uint8_t AT24C02_WriteByte(uint16_t memAddr, uint8_t data) { return HAL_I2C_Mem_Write(&hi2c1, 0xA0, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); } uint8_t AT24C02_ReadByte(uint16_t memAddr) { uint8_t data = 0; HAL_I2C_Mem_Read(&hi2c1, 0xA0, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); return data; }

细心的你会问:读操作为什么设备地址也传0xA0,而不是0xA1?HAL库的HAL_I2C_Mem_Read内部会自动处理方向,它在发出的第一个地址帧会带写方向,重复起始条件后再发出的地址帧会带读方向。设备地址参数0xA0只是用来确定从机地址,和方向位的关系由库函数自己管理。早期HAL版本有的教程让你传0xA1也正常,原因是地址最后一位会被方向覆盖,传什么都绕不开7位从机地址0x50。但为了习惯统一,我都写0xA0,后面代码里读函数也都是0xA0。

我在这个函数后面加了一个返回值检查:如果HAL_I2C_Mem_Write返回HAL_OK,说明主机硬件层面把数据发出去且收到了ACK。如果返回HAL_BUSY或HAL_TIMEOUT,大概率是卡在某个等待标志上。很多例程直接忽略返回值,一旦出错后程序状态机卡死,后面再调I2C全都是超时,这是初学者最容易摔跟头的地方。

4.2 页写跨边界问题的处理

AT24C02每页8字节,这是很多读写异常的头号原因。页写时如果从地址0x07开始连续写3个字节,地址会跑到0x08吗?不会。AT24C02内部页地址由低3位控制,写到页尾0x07后,地址指针会回卷到该页开头0x00,继续写入数据就会覆盖原本页头部的数据。这不是ST芯片的bug,是EEPROM页写机制的固有行为。

所以多字节写入必须分页处理。我的做法是写一个带边界拆分的函数:

#define AT24C02_DEV_ADDR 0xA0 #define AT24C02_PAGE_SIZE 8 void AT24C02_WriteBytes(uint16_t memAddr, uint8_t *pData, uint16_t len) { while (len > 0) { uint8_t remainInPage = AT24C02_PAGE_SIZE - (memAddr % AT24C02_PAGE_SIZE); uint8_t chunkSize = len > remainInPage ? remainInPage : len; HAL_I2C_Mem_Write(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, pData, chunkSize, 100); AT24C02_WaitReady(); /* 等本轮页写完成 */ memAddr += chunkSize; pData += chunkSize; len -= chunkSize; } }

这个函数每次只写“当前页内剩余能写的字节数”,写完后地址指针前进,再继续下一页。比如从地址0x05写5字节,第一次chunkSize为3(因为0x05到0x07还剩3字节),写完等EEPROM就绪,下一轮从0x08开始再写剩余2字节,这样就不会发生回卷覆盖。

如果你用的是非标准AT24C02,页大小可能是16字节。国内很多兼容芯片实际页大小不一样,稳妥做法是翻数据手册确认。我不能保证所有标着AT24C02的芯片都是8字节页,因为市面上确实有兼容颗粒用了16字节页设计。判定方法很简单:往地址0开始写16字节,读回来看看0x07和0x08地址的数据是否都正确,如果0x08还是第一次写的内容、0x00以后被覆盖,就是8字节页;如果0x00到0x0F都正确,就是16字节页。

4.3 连续读与完整测试Demo

连续读没有页边界问题,因为读操作不受页写回卷限制,EEPROM内部地址计数器会不断递增,最大地址0xFF后回卷到0x00。连续读N字节只需要指定起始地址和长度:

uint8_t AT24C02_ReadBytes(uint16_t memAddr, uint8_t *pBuf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, pBuf, len, 100); }

但是注意,HAL_I2C_Mem_Read的len参数类型是uint16_t,F103平台一次最多读65535字节,实际AT24C02只有256字节,所以不用担心超长,只是内存长度要控制在256字节以内,别让地址指针回卷后读出意料之外的数据。

做一个完整测试Demo,写8字节测试数据到0x00,再读回来比对,用串口打印结果:

uint8_t writeBuf[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t readBuf[8] = {0}; AT24C02_WriteBytes(0x00, writeBuf, 8); memset(readBuf, 0, sizeof(readBuf)); AT24C02_ReadBytes(0x00, readBuf, 8); if (memcmp(writeBuf, readBuf, 8) == 0) printf("EEPROM Test OK\r\n"); else printf("EEPROM Test FAIL\r\n");

这样下来,单字节读、单字节写、多字节写、跨页拆分、连续读全覆盖了。测试通过后再去叠需求:存一个结构体、存一批校准表,驱动层不用再动。

5. 串口打印与ST-Link联合验证

5.1 printf重定向和串口接线

裸机程序里没有标准输出设备,想让STM32F103借助USART1打印数据,需要把printf的底层输出函数重定向到串口。在Keil工程里,勾选MicroLIB是一个快捷方式,然后添加:

#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

这样做以后,printf就会往USART1发送字符。硬件接线方面,STM32F103的PA9是USART1_TX,PA10是USART1_RX。用USB转TTL模块连接:PA9接模块RX,PA10接模块TX,GND再接GND。这里GND必须共地,否则串口电平没有公共参考点,收不到数据。之后打开串口助手,波特率设115200,就能看到打印内容。

5.2 Demo自检流程

我把整个自检流程写成一个函数,方便在main里跑:

void EEPROM_SelfTest(void) { uint8_t buf[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t read[8] = {0}; printf("===== AT24C02 Self Test =====\r\n"); AT24C02_WriteBytes(0x00, buf, 8); if (AT24C02_WaitReady() != 0) { printf("Device not ready!\r\n"); return; } AT24C02_ReadBytes(0x00, read, 8); if (memcmp(buf, read, 8) == 0) printf("Result: PASS\r\n"); else printf("Result: FAIL\r\n"); }

注意,如果第一次上电时EEPROM里是随机数据,写入后再读回校验是最直接的验证。但如果你想验证“掉电不丢”,需要烧完程序后断电再上电,直接读0x00处的8字节,看是不是之前写入的0x11...0x88。这需要程序里做一个判断,不要每次都覆盖写,比如第一次写后留在数据,第二次上电只读不写。

5.3 ST-Link在线调试观察点

在线调试比串口打印更接近硬件底层。ST-Link连接后,在main的EEPROM自检代码行打几个断点。当程序停在HAL_I2C_Mem_Write那行时,可以单步进去,观察hi2c1.Instance->CR1、CR2、SR1这些寄存器。

F103的I2C外设有两个状态寄存器SR1和SR2,很多标志位容易把人绕晕。HAL库内部其实在死等这些标志,你不需要全看懂,但有几个关键点要会看:如果停在I2C_WaitOnFlagUntilTimeout里,而且超时后函数返回超时,最可能是总线上没有ACK。这时候打开逻辑分析仪或者示波器看SCL和SDA波形,马上能定位:如果SDA始终高,说明EEPROM没有应答,地址不对、上拉缺失、芯片供电异常,三选一。

Peripherals窗口还能直接看GPIOB的ODR寄存器,确认PB6和PB7是不是开漏配置。如果发现MODER寄存器显示这两个引脚是通用推挽输出,就说明CubeMX配置没生效,回到工程里检查是不是用了别的引脚。调I2C用逻辑分析仪是最高效的,没有逻辑分析仪也可以让波形“变慢”:手动把SCL点几下,测量每个步骤前SDA的状态,虽然麻烦,但帮人建立时序感觉很有用。

6. 常见问题排查与心得速查

6.1 现象与原因对照

我把这几个月来在F103上跑AT24C02遇到过的问题整理成一张表,方便你对着排查。

现象可能原因解决方向
HAL_I2C_*返回HAL_TIMEOUTSCL/SDA没接上拉、EEPROM地址错误、WP拉高、芯片未供电检查上拉电阻4.7k,确认A0A1A2接地,WP接地,VCC有3.3V
写操作返回OK,读回全0xFF写周期等待不足、页写跨页覆盖、读地址错误、芯片被写入保护改用轮询ACK,检查页边界,确认读起始地址和写一致
总是卡在AT24C02_WaitReady里总线忙、上一个事务没成功停止、EEPROM不在线加长超时,检查停止条件是否正确,用示波器看波形
串口打印乱码波特率设置不一致、TX/RX接反、GND没共地核对115200,交换TX/RX,检查GND连接
I2C偶尔通讯失败,重新上电又好了上拉电阻太大、线太长、总线寄生电容高上拉改到2.2k或4.7k,缩短飞线,减少总线挂载设备
能读1字节,连续读总丢数据主机没有在最后字节回NACK、SCL速率过高确认HAL_I2C_Mem_Read的len正确,降到100kHz
引脚电流异常、板子发烫把I2C配成了推挽,多个设备互相打架CubeMX里确认开漏模式,重新生成代码

6.2 几个容易忽略的细节

第一个细节是设备地址到底用7位还是8位。很多教程互相打架,有人写0xA0,有人写0x50,还有人写0xA1读。核心逻辑是:芯片数据手册的地址表写的是7位0x50,但I2C总线协议要求地址字节中最低位是读写方向。只要你用的是HAL库的Mem系列函数,传0xA0最省心;如果你自己写寄存器操作,就要自己拼这8位。不要看到0xA0就以为是“8位地址0xA0”,它其实是“7位地址0x50左移一位再加写方向0”,要能一眼看穿。

第二个细节是EEPROM写周期内如果发起读操作,读回来的可能是旧值或垃圾值。AT24C02在内部写周期内不会回复ACK,正确的做法是等写周期结束再读。使用固定延时5ms在大多数情况下够,但环境温度低、电压偏低时写周期可能变长,所以我宁可用轮询ACK。

第三个细节是总线上挂多个I2C设备时的地址冲突。比如AT24C02默认0x50,SSD1306 OLED一般是0x3C,两者正好不冲突,可以同时挂。但如果两个EEPROM都设成A0A1A2接地,地址就冲突了,只能靠硬件跳线改A0/A1/A2引脚电平来区分。再者,I2C总线上的上拉电阻是所有设备共享的,挂的设备越多,等效上拉越弱,信号边沿越缓,所以要控制单条总线上的设备数量,一般不超过8个,再多就考虑I2C多路扩展器。

6.3 软件模拟I2C的备用方案

硬件I2C调不通的时候,很多人会选择GPIO模拟。F103的硬件I2C在标准库时代口碑确实不好,但那多半是标志位处理不当、中断优先级配置有问题造成的。HAL库用超时机制处理,稳定性已经好了很多。可如果你就是不想碰硬件状态机,或者想用任意两个GPIO驱动EEPROM,软件模拟I2C完全可行。

先在CubeMX里把PB6、PB7都设置为开漏输出,注意不是推挽。然后实现最基础的几个原语:

#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 #define SCL_H() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) static void I2C_Delay(void) { for (volatile int i = 0; i < 20; i++); } static void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); I2C_Delay(); } static void I2C_Stop(void) { SDA_L(); I2C_Delay(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); } static uint8_t I2C_WriteByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) SDA_H(); else SDA_L(); dat <<= 1; I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); /* 释放SDA,等从机ACK */ I2C_Delay(); SCL_H(); I2C_Delay(); uint8_t ack = (SDA_READ() == GPIO_PIN_RESET) ? 0 : 1; SCL_L(); I2C_Delay(); return ack; } static uint8_t I2C_ReadByte(void) { uint8_t dat = 0; SDA_H(); /* 开漏下置1即释放 */ for (uint8_t i = 0; i < 8; i++) { dat <<= 1; SCL_H(); I2C_Delay(); if (SDA_READ() == GPIO_PIN_SET) dat |= 0x01; SCL_L(); I2C_Delay(); } return dat; }

有了这几个原语,封装EEPROM读写就很直观了。写单字节:

uint8_t SoftEE_WriteByte(uint16_t memAddr, uint8_t data) { I2C_Start(); if (I2C_WriteByte(0xA0)) return 1; /* 地址NACK */ if (I2C_WriteByte(memAddr & 0xFF)) return 1; if (I2C_WriteByte(data)) return 1; I2C_Stop(); HAL_Delay(5); return 0; }

读单字节:

uint8_t SoftEE_ReadByte(uint16_t memAddr) { uint8_t data = 0; I2C_Start(); I2C_WriteByte(0xA0); I2C_WriteByte(memAddr & 0xFF); I2C_Start(); /* 重复起始 */ I2C_WriteByte(0xA1); data = I2C_ReadByte(); /* 读最后一个字节时要回NACK */ SDA_H(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); I2C_Stop(); return data; }

软件模拟的好处是每一步都能拆开看,调试起来非常直观,尤其适合没有逻辑分析仪的场合。坏处是CPU要一直参与位翻转,效率比硬件I2C低很多,但AT24C02这种慢速设备完全感受不到差异。如果你不想纠结F103硬件I2C的时序,又不介意多写几十行代码,这个方法作为备用很合适。

最后再分享一个小技巧:写完AT24C02驱动后,不要急着去调业务逻辑,先做一个只写地址0的字节、断电、重新上电、再读回的小实验。我在实际项目里用这个办法验证过很多板子,特别是手工焊接的模块,能第一时间暴露出虚焊、上拉电阻漏焊、WP脚误接高电平这类问题。EEPROM本身就是拿来存关键数据的,驱动层稳定了,上层才敢放心用。

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

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

立即咨询