从零构建STM32 HAL库下的IIC协议栈:时序解析与模块化设计实战
在嵌入式开发领域,IIC(Inter-Integrated Circuit)总线因其简洁的两线制设计和多主从架构,成为连接各类传感器的首选方案。然而,STM32硬件IIC外设的复杂性常常让开发者望而却步。本文将带你从时序基础出发,逐步构建一个高可靠、易移植的软件模拟IIC协议栈。
1. IIC协议核心时序单元解析
IIC通信的本质是通过精确控制SCL时钟线和SDA数据线的电平变化来传递信息。理解这些基础时序单元是构建协议栈的第一步。
1.1 起始与停止信号
起始信号(START)和停止信号(STOP)是IIC通信的"标点符号",它们定义了数据传输的开始和结束:
// 起始信号生成 void IIC_Start(void) { SDA_HIGH(); // 空闲状态 SCL_HIGH(); delay_us(4); // 保持时间tSU;STA SDA_LOW(); // 下降沿触发起始条件 delay_us(4); SCL_LOW(); // 钳住总线准备数据传输 } // 停止信号生成 void IIC_Stop(void) { SDA_LOW(); // 确保起始状态 SCL_LOW(); delay_us(4); SCL_HIGH(); // 先拉高时钟线 delay_us(4); SDA_HIGH(); // 上升沿触发停止条件 }注意:实际延时需根据MCU主频调整,标准模式下tSU;STA最小4.7μs
1.2 数据有效性规则
IIC协议规定,数据线SDA的电平变化必须发生在SCL为低电平期间,高电平期间必须保持稳定。这个特性使得我们可以用普通GPIO模拟时钟拉伸(Clock Stretching)效果:
| 时序阶段 | SCL状态 | SDA允许操作 |
|---|---|---|
| 数据准备 | 低电平 | 允许变化 |
| 数据采样 | 高电平 | 必须稳定 |
1.3 ACK/NACK应答机制
每个字节传输后的第9个时钟周期用于应答确认。从机通过拉低SDA表示ACK,保持高电平表示NACK:
uint8_t IIC_Wait_Ack(void) { SDA_INPUT_MODE(); // 切换为输入模式检测应答 SCL_HIGH(); delay_us(2); uint8_t ack = (GPIO_Read(SDA_PORT, SDA_PIN) == 0); SCL_LOW(); SDA_OUTPUT_MODE(); // 恢复输出模式 return ack; // 0:ACK, 1:NACK }2. HAL库下的GPIO抽象层设计
良好的硬件抽象是代码可移植性的关键。我们通过宏定义和函数指针实现硬件无关的接口:
2.1 引脚控制宏定义
// 硬件相关层 #define IIC_SCL_PORT GPIOB #define IIC_SCL_PIN GPIO_PIN_6 #define IIC_SDA_PORT GPIOB #define IIC_SDA_PIN GPIO_PIN_7 // 硬件抽象层 #define SDA_HIGH() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define SDA_LOW() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define SCL_HIGH() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define SCL_LOW() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT, IIC_SDA_PIN)2.2 动态模式切换
IIC协议要求SDA线在主机发送和接收时分别处于输出和输入模式。HAL库下的高效实现方式:
void IIC_SDA_Mode(GPIO_Mode mode) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = IIC_SDA_PIN; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; if(mode == GPIO_MODE_OUTPUT_PP) { GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(IIC_SDA_PORT, &GPIO_InitStruct); SDA_HIGH(); // 默认上拉 } else { GPIO_InitStruct.Mode = GPIO_MODE_INPUT; HAL_GPIO_Init(IIC_SDA_PORT, &GPIO_InitStruct); } }3. 协议栈的模块化封装
将离散的时序操作封装成完整的数据读写接口,是构建实用协议栈的关键步骤。
3.1 字节传输基础函数
// 发送单字节 void IIC_Send_Byte(uint8_t byte) { IIC_SDA_Mode(GPIO_MODE_OUTPUT_PP); for(uint8_t i=0; i<8; i++) { SCL_LOW(); delay_us(2); (byte & 0x80) ? SDA_HIGH() : SDA_LOW(); byte <<= 1; SCL_HIGH(); delay_us(4); } SCL_LOW(); // 为ACK周期准备 } // 接收单字节 uint8_t IIC_Read_Byte(uint8_t ack) { uint8_t byte = 0; IIC_SDA_Mode(GPIO_MODE_INPUT); for(uint8_t i=0; i<8; i++) { SCL_LOW(); delay_us(2); SCL_HIGH(); byte <<= 1; if(SDA_READ()) byte |= 0x01; delay_us(2); } // 发送ACK/NACK IIC_SDA_Mode(GPIO_MODE_OUTPUT_PP); ack ? SDA_HIGH() : SDA_LOW(); SCL_HIGH(); delay_us(4); SCL_LOW(); return byte; }3.2 完整读写接口
基于基础函数构建符合设备特性的高层接口:
// 带寄存器地址的写操作 uint8_t IIC_Write_Reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) { IIC_Start(); IIC_Send_Byte(dev_addr & 0xFE); // 写操作 if(IIC_Wait_Ack()) goto error; IIC_Send_Byte(reg_addr); if(IIC_Wait_Ack()) goto error; while(len--) { IIC_Send_Byte(*data++); if(IIC_Wait_Ack()) goto error; } IIC_Stop(); return 0; error: IIC_Stop(); return 1; } // 带寄存器地址的读操作 uint8_t IIC_Read_Reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint16_t len) { IIC_Start(); IIC_Send_Byte(dev_addr & 0xFE); // 写操作 if(IIC_Wait_Ack()) goto error; IIC_Send_Byte(reg_addr); if(IIC_Wait_Ack()) goto error; IIC_Start(); IIC_Send_Byte(dev_addr | 0x01); // 读操作 if(IIC_Wait_Ack()) goto error; while(len--) { *buf++ = IIC_Read_Byte(len ? 0 : 1); // 最后字节发NACK } IIC_Stop(); return 0; error: IIC_Stop(); return 1; }4. 实战:AT24C02 EEPROM驱动实现
以常见的AT24C02存储器为例,演示协议栈的实际应用。
4.1 设备特性适配
AT24C02有特殊的写入时序要求,需要特别注意:
- 页写入周期最长5ms
- 单次页写入不超过8字节
- 地址自动递增特性
#define EEPROM_ADDR 0xA0 #define PAGE_SIZE 8 #define WRITE_DELAY 5 // ms uint8_t EEPROM_Write_Page(uint16_t addr, uint8_t *data, uint8_t len) { if(len > PAGE_SIZE) return 1; uint8_t ret = IIC_Write_Reg(EEPROM_ADDR, addr, data, len); HAL_Delay(WRITE_DELAY); // 必须等待写入完成 return ret; } uint8_t EEPROM_Sequential_Read(uint16_t addr, uint8_t *buf, uint16_t len) { return IIC_Read_Reg(EEPROM_ADDR, addr, buf, len); }4.2 性能优化技巧
通过以下方法可以提升IIC通信可靠性:
时钟延时可调:根据实际波形调整延时参数
void IIC_Delay_Config(uint8_t speed) { // 0:标准模式(100kHz), 1:快速模式(400kHz) delay_us = speed ? 1 : 4; }错误重试机制:
#define MAX_RETRY 3 uint8_t EEPROM_Write_With_Retry(uint16_t addr, uint8_t *data, uint8_t len) { uint8_t retry = MAX_RETRY; while(retry--) { if(!EEPROM_Write_Page(addr, data, len)) { return 0; } } return 1; }波形调试建议:
- 使用示波器观察SCL/SDA信号
- 检查上升/下降时间是否符合规范
- 确认ACK/NACK响应位置
5. 进阶:协议栈的扩展设计
5.1 多设备管理
通过引入设备表实现动态管理:
typedef struct { uint8_t addr; uint8_t speed; uint16_t timeout; } IIC_Device; IIC_Device dev_list[] = { {0xA0, 0, 100}, // AT24C02 {0x78, 1, 50}, // OLED // ... }; uint8_t IIC_Device_Write(uint8_t dev_id, uint8_t reg, uint8_t *data, uint16_t len) { if(dev_id >= sizeof(dev_list)/sizeof(IIC_Device)) return 1; IIC_Delay_Config(dev_list[dev_id].speed); return IIC_Write_Reg(dev_list[dev_id].addr, reg, data, len); }5.2 中断驱动设计
通过GPIO中断实现事件驱动型IIC:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if(GPIO_Pin == SCL_PIN) { static uint8_t bit_count = 0; static uint8_t rx_data = 0; if(SCL_READ()) { // 上升沿 rx_data <<= 1; if(SDA_READ()) rx_data |= 0x01; if(++bit_count == 8) { iic_rx_buf[iic_rx_idx++] = rx_data; bit_count = 0; } } } }5.3 性能对比测试
软件IIC与硬件IIC的关键指标对比:
| 指标 | 软件IIC | 硬件IIC |
|---|---|---|
| 最大速率 | ~400kHz | 1MHz+ |
| CPU占用率 | 高 | 低 |
| 时序精确度 | 依赖延时精度 | 硬件保证 |
| 多主机支持 | 需自行实现仲裁 | 硬件支持 |
| 代码复杂度 | 中等 | 配置复杂 |
在实际项目中,对于OLED、EEPROM等低速设备,软件IIC因其灵活性和稳定性成为更优选择。而对于高速数据采集模块,则应优先考虑硬件IIC方案。