实战:基于STM32的I2C协议驱动设计与串行存储器访问
1. I2C协议基础与STM32开发环境搭建
I2C(Inter-Integrated Circuit)是飞利浦公司开发的一种简单、双向二线制同步串行总线。它只需要两根线就能实现设备间的通信:串行数据线(SDA)和串行时钟线(SCL)。在嵌入式系统中,I2C因其简单的硬件连接和灵活的通信方式而广受欢迎。
我第一次接触I2C是在一个温湿度传感器项目中,当时被它简洁的硬件设计所吸引。相比SPI需要4根线,I2C只需要2根线就能实现多设备通信,这在PCB布线空间紧张时特别有用。不过I2C的软件实现比硬件实现要复杂一些,这也是为什么很多开发者会选择硬件I2C外设的原因。
在STM32F103上搭建开发环境需要以下步骤:
- 安装Keil MDK-ARM开发环境(建议使用5.25以上版本)
- 安装STM32F1xx系列设备支持包
- 配置工程时选择正确的芯片型号(如STM32F103C8T6)
- 添加必要的库文件,包括CMSIS核心库和STM32标准外设库
我建议初学者使用STM32CubeMX工具来初始化项目,它可以自动生成初始化代码,省去很多配置工作。不过为了深入理解I2C协议,我们这次选择用寄存器级编程来实现GPIO模拟I2C,这样能更好地掌握协议细节。
2. GPIO模拟I2C时序的实现细节
硬件I2C外设虽然方便,但在某些特殊情况下(比如需要精确控制时序或调试协议细节时),用GPIO模拟I2C反而更有优势。我在一个工业项目中就遇到过硬件I2C不稳定的情况,最终改用GPIO模拟解决了问题。
2.1 基本信号生成
I2C协议有四种基本信号:起始条件、停止条件、数据有效和应答信号。用GPIO模拟这些信号需要注意时序要求:
// SCL引脚控制函数 void I2C_GPIO_W_SCL(uint8_t BitValue) { GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue); Delay_us(5); // 标准模式100kHz对应5us延时 } // SDA引脚控制函数 void I2C_GPIO_W_SDA(uint8_t BitValue) { GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue); Delay_us(5); } // 起始条件生成 void I2C_Start(void) { I2C_GPIO_W_SDA(1); // 先拉高SDA I2C_GPIO_W_SCL(1); // 再拉高SCL Delay_us(5); I2C_GPIO_W_SDA(0); // SDA在SCL高电平时拉低 Delay_us(5); I2C_GPIO_W_SCL(0); // 最后拉低SCL完成起始条件 }这里有个容易出错的地方:起始条件必须在SCL高电平时SDA产生下降沿。我在第一次实现时就搞反了顺序,导致设备无法识别起始信号。
2.2 字节传输与应答处理
发送一个字节需要将数据位从高位到低位依次放到SDA线上,并在每个时钟周期保持稳定:
void I2C_SendByte(uint8_t Byte) { uint8_t i; for (i = 0; i < 8; i++) { // 取出最高位先发送 I2C_GPIO_W_SDA(Byte & (0x80 >> i)); Delay_us(2); // 数据建立时间 I2C_GPIO_W_SCL(1); // 拉高时钟 Delay_us(5); // 时钟高电平保持时间 I2C_GPIO_W_SCL(0); // 拉低时钟 Delay_us(3); // 时钟低电平保持时间 } // 接收应答位 I2C_GPIO_W_SDA(1); // 释放SDA线 Delay_us(2); I2C_GPIO_W_SCL(1); Delay_us(5); if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11)) { // 收到NACK printf("NACK received!\n"); } I2C_GPIO_W_SCL(0); }在实际项目中,我发现很多I2C设备对时序要求并不严格,但AT24C系列EEPROM对时序特别敏感。如果延时设置不当,很容易出现写入失败的情况。建议在调试时用逻辑分析仪抓取波形,确保时序参数符合规格书要求。
3. AT24Cxx系列EEPROM驱动开发
AT24C02是常见的I2C接口EEPROM,容量为2Kbit(256字节)。这个系列还包括AT24C01/04/08/16等型号,容量从1Kbit到16Kbit不等,它们使用相同的指令集,只是地址位数不同。
3.1 设备地址与寻址方式
AT24Cxx的7位设备地址高4位固定为1010,低3位由A2/A1/A0引脚电平决定。如果这三个引脚都接地,那么设备地址就是0x50(写地址0xA0,读地址0xA1)。
#define EEPROM_ADDRESS 0xA0 // 写地址 #define EEPROM_PAGE_SIZE 8 // AT24C02页大小为8字节AT24C02内部采用分页存储结构,每页8字节。连续写入时如果跨页,地址会自动回绕到页首,导致数据被覆盖。我在一个数据记录项目中就遇到过这个问题,后来通过分页写入解决了。
3.2 关键操作函数实现
随机读取函数:
uint8_t EEPROM_ReadByte(uint16_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(EEPROM_ADDRESS); // 发送写地址 I2C_SendByte(addr >> 8); // 发送地址高字节(AT24C02不需要) I2C_SendByte(addr & 0xFF); // 发送地址低字节 I2C_Start(); // 重复起始条件 I2C_SendByte(EEPROM_ADDRESS | 0x01); // 发送读地址 data = I2C_ReceiveByte(); // 读取数据 I2C_SendAck(1); // 发送NACK结束读取 I2C_Stop(); return data; }页写入函数:
void EEPROM_PageWrite(uint16_t addr, uint8_t *buf) { uint8_t i; I2C_Start(); I2C_SendByte(EEPROM_ADDRESS); I2C_SendByte(addr >> 8); // 地址高字节 I2C_SendByte(addr & 0xFF); // 地址低字节 for (i = 0; i < EEPROM_PAGE_SIZE; i++) { I2C_SendByte(buf[i]); // 写入一页数据 } I2C_Stop(); Delay_ms(5); // 必须等待写入完成 }这里有个重要细节:AT24C02完成内部写入需要约5ms时间,这段时间内不会响应新的指令。如果在这期间尝试访问EEPROM,设备会返回NACK。我在调试时曾误以为是通信故障,后来加入延时后问题解决。
4. 系统集成与数据验证
完成底层驱动后,我们需要将其集成到系统中,并通过OLED显示验证数据完整性。这个环节往往能发现很多潜在问题。
4.1 测试程序设计
一个完整的测试程序应该包含以下功能:
- 写入测试数据到EEPROM
- 从相同地址读取数据
- 比较写入和读取的数据是否一致
- 在OLED上显示测试结果
void EEPROM_Test(void) { uint8_t writeData[8] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0}; uint8_t readData[8]; uint8_t i, error = 0; // 写入测试数据 EEPROM_PageWrite(0x00, writeData); // 延迟确保写入完成 Delay_ms(10); // 读取数据 for (i = 0; i < 8; i++) { readData[i] = EEPROM_ReadByte(i); if (readData[i] != writeData[i]) { error = 1; } } // OLED显示结果 OLED_ShowString(1, 1, "EEPROM Test:"); if (error) { OLED_ShowString(2, 1, "Result: FAIL"); } else { OLED_ShowString(2, 1, "Result: PASS"); } // 显示读取的数据 for (i = 0; i < 4; i++) { OLED_ShowHexNum(3+i, 1, readData[i*2], 2); OLED_ShowHexNum(3+i, 4, readData[i*2+1], 2); } }4.2 常见问题排查
在实际调试中,我遇到过以下几种典型问题及解决方法:
设备无响应:
- 检查I2C上拉电阻(通常4.7kΩ)
- 确认设备地址正确
- 用示波器检查信号质量
数据写入后读取错误:
- 确保写入后留有足够延时(5ms以上)
- 检查是否跨页写入导致数据覆盖
- 验证电源电压是否稳定
随机性通信失败:
- 缩短I2C总线长度
- 降低通信速率
- 添加总线保护电路
记得有一次,我的EEPROM在实验室测试完全正常,但到了现场就频繁出错。后来发现是现场环境电磁干扰严重,通过在SDA和SCL线上增加100pF的滤波电容解决了问题。
通过这个项目,我深刻体会到嵌入式开发中"细节决定成败"的道理。I2C协议看似简单,但要把驱动做稳定可靠,需要考虑时序、电气特性、异常处理等诸多因素。建议开发者在完成基本功能后,还要进行长时间的压力测试,确保在各种异常条件下系统都能稳定工作。