ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

重新写一遍单片机程序(分层与接口)------第四篇 把IIC驱动重做一遍 基于stm32f103

重新写一遍单片机程序(分层与接口)------第四篇 把IIC驱动重做一遍 基于stm32f103

前言

‌ 上一篇结尾说下一篇写 IIC,这篇就来写 IIC。

‌ 动手前翻旧代码,发现了一件挺尴尬的事:旧 IIC 驱动里,硬件 I2C 那条路是个空壳。

‌ 设备层的 Init、Read、Write 里都有这么一段分支:

if (self->i2c < I2C_3_ANALOG) { /* 什么都没写 */ } else if (self->i2c >= I2C_3_ANALOG && self->i2c < MAX_I2C) { AnalogI2cWriteMemory(self->i2c, slaveAddr, regAddr, data, length, I2C_TIMEOUT_TIMES); } return result;

‌result一直是I2C_ERROR_NONE。也就是说,凡是配成I2C_1、I2C_2的设备,读写调用都会直接返回成功,一个字节都没发出去。

‌ 而仓库里三个用 IIC 的模块,EEPROM、LIS2DH12TR 加速度计、AHT20 温湿度,配的全是I2C_1。按这份代码看,它们从来没跟芯片通过一次话。

‌ 真正有实现的是软件模拟那一条,I2C_3_ANALOG,PD14 / PD15 两个脚来回拉。这次重做,把两条路都补成真的:硬件 I2C 用标准外设库那套事件轮询,软件模拟沿用旧代码的位翻转,两条路给同一个设备类当后端。

‌ 板子还是野火指南者,STM32F103VE,板载 AT24C02 挂在 PB6 / PB7 上,7 位地址 0x50。

‌ 这篇的文字也是 AI 帮忙整理的。

1 旧 IIC 驱动里的三个问题

1.1 一张表里混着两种实现

‌ 旧板级表把硬件 I2C 和软件模拟塞进了同一个数组:

#define I2C_HARDWARE_MAPPING_TABLE \ {\ {{RCC_APB1PeriphClockCmd,RCC_APB1Periph_I2C1,I2C1,(100*1000),0x56,I2C_AcknowledgedAddress_7bit},\ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOB,GPIOB,GPIO_Pin_6}, \ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOB,GPIOB,GPIO_Pin_7}}, \ {{RCC_APB1PeriphClockCmd,RCC_APB1Periph_I2C2,I2C2,(100*1000),0x56,I2C_AcknowledgedAddress_7bit},\ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOB,GPIOB,GPIO_Pin_10},\ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOB,GPIOB,GPIO_Pin_11}},\ {{NULL,0,NULL,0,0,0},\ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOD,GPIOD,GPIO_Pin_15}, \ {RCC_APB2PeriphClockCmd,RCC_APB2Periph_GPIOD,GPIOD,GPIO_Pin_14}}, \ }

‌ 前两项是硬件外设,第三项是两条普通 GPIO。表项类型却是一模一样的I2cParmConfig_t,软件模拟那一路把硬件字段全填了 0 和 NULL。

‌ 上层按i2c < I2C_3_ANALOG判断走哪条分支。这个枚举值本身成了分派依据,加一路模拟 I2C 就得改设备层。

1.2 硬件分支是空的

‌ 除了分派分支为空,硬件初始化的返回值也没人看。I2CHardWareInit配好了时钟、引脚、外设,但设备层的I2CInit根本没调用它,只在硬件分支里留了个空括号。

‌ 旧代码里还有一个I2CHardWareWriteMemory,函数体是:

I2cErrorCode_t I2CHardWareWriteMemory(I2cType_t i2c) { if (i2c >= I2C_3_ANALOG) { return I2C_ERROR_TYPE; } return I2C_ERROR_NONE; }

‌ 参数表里连数据指针和长度都没有,直接返回成功。

‌ 原工程(AT32F435 那一版)的情况更有意思。它把雅特力官方的 I2C 应用层库i2c_application.c整份引进来了,两千四百多行,i2c_memory_read/i2c_memory_write的轮询、中断、DMA 三套实现都齐。但设备层里这几个调用全被注释掉了,那两千多行从头到尾没执行过一行。移植到 F103 的时候这份文件没带过来,空分支就留在了原地。

1.3 运算符优先级

‌ 模拟 I2C 里处理 10 位地址的代码:

if (slaveAddr & 0xff00) { AnalogI2cSendByte(i2c, (slaveAddr & 0xff00 >> 8)); AnalogI2cSendByte(i2c, (slaveAddr & 0xff) | 0x00); }

‌>>的优先级比&高,slaveAddr & 0xff00 >> 8实际算的是slaveAddr & 0xff。高字节永远取不出来,10 位地址设备发出去的第一个字节是错的。

‌ 同样的写法在AnalogI2cReadMemory里也有。

‌ 另外三个模块的从机地址约定也不统一:

#define SLAVE_EEPROM_ADDR (0xA0) #define SLAVE_LIS2DH12TR_ADDR (0x30) #define SLAVE_AHT20_ADDR 0x38

‌ 0xA0 和 0x30 是含读写位的 8 位形式,0x38 是 7 位形式。驱动的slaveAddr | 0x01只会对前两个碰巧成立,第三个会在总线上发一个错误的地址。

‌ 这次新接口只说 7 位地址,读写位由实现拼。

2 接口:一次传输到底是什么

‌ 新的 ops_i2c 表放在ops/ops_i2c.h,四个方法:初始化、反初始化、写、读。

typedef struct { OpsStatus_t (*Init)(uint8_t busId, const OpsI2cPin_t* pins, uint8_t pinCount, uint32_t speedHz); void (*DeInit)(uint8_t busId); OpsStatus_t (*Write)(uint8_t busId, uint8_t slaveAddr, const uint8_t* prefix, uint16_t prefixLength, const uint8_t* data, uint16_t length); OpsStatus_t (*Read)(uint8_t busId, uint8_t slaveAddr, const uint8_t* txData, uint16_t txLength, uint8_t* rxData, uint16_t rxLength); }OpsI2cOps_t; const OpsI2cOps_t* OpsI2cGetOps(void);

‌Write比常见的 I2C 接口多一个前缀段。原因是寄存器式写要发「从机地址 + 寄存器地址 + 数据」,这三段必须在同一次总线事务里完成,中间不能有停止条件。如果接口只收一段数据,设备层就得在栈上拼一个临时缓冲,或者调两次写(第二次会重新发起始条件,AT24C02 会把数据当成新的地址字节)。

‌ 前缀段把这件事交给实现:

/* 设备层 */ return self->Hw->Write(self->BusId, slaveAddr, regBuffer, regLength, data, length); /* 芯片层按顺序发:起始、地址(写)、前缀段、数据段、停止 */

‌ 没有临时缓冲,也没有长度上限。

‌Read的语义是「起始 + 地址(写) + 发送段 + 重复起始 + 地址(读) + 接收段 + 停止」。发送段就是寄存器地址,可以是 NULL / 0,此时就是直接读。重复起始放在芯片实现里完成,中间不产生停止条件。

‌ 接口约定从机地址一律 7 位,addr << 1和读写位都由实现拼。

‌ 头文件里还写死了几条:总线级超时由实现兜底,失败时总线必须回到空闲;业务级重试和器件时序属于上层,比如 EEPROM 的写周期,实现不等待;四个方法都只能在任务上下文调用。

3 硬件实现

‌chip/ops_i2c.c用 STM32F10x 标准外设库。两路总线先做成一张小表:

typedef struct { I2C_TypeDef* Instance; ChipClock_t Clock; }ChipI2cHw_t; static const ChipI2cHw_t ChipI2cHw[OPS_CHIP_I2C_COUNT] = { { I2C1, { CHIP_CLOCK_BUS_APB1, RCC_APB1Periph_I2C1 } }, { I2C2, { CHIP_CLOCK_BUS_APB1, RCC_APB1Periph_I2C2 } }, };

3.1 等事件,顺便看 AF

‌ 标准库的 I2C 是事件驱动的,每一步都要等对应的状态组合。等事件的函数长这样:

static OpsStatus_t ChipI2cWaitEvent(I2C_TypeDef* i2c, uint32_t event) { uint32_t timeout = CHIP_I2C_TIMEOUT; while (I2C_CheckEvent(i2c, event) != SUCCESS) { if (I2C_GetFlagStatus(i2c, I2C_FLAG_AF) != RESET) { return OPS_ERR_HW; } if (0u == timeout) { return OPS_ERR_TIMEOUT; } timeout--; } return OPS_OK; }

‌ 从机不应答的时候,地址字节发完会置 AF 标志,而期望的事件永远不会来。如果只靠超时计数,从机没接、地址写错这几种情况每次都要等满几十毫秒。这里每次循环顺手看一眼 AF,直接返回硬件错误,快得多。

‌ 超时值是个常量,200000次循环。它只跟 CPU 主频有关,不涉及任何调度器,芯片层也就不需要知道有没有 RTOS。

3.2 出错恢复

‌ F1 的 I2C 有个出了名的脾气:一旦状态机卡在半路,BUSY 位可能一直不清,之后的传输全部失败。每次出错都做三件事:

static void ChipI2cAbort(uint8_t busId) { const ChipI2cHw_t* hw = &ChipI2cHw[busId]; I2C_GenerateSTOP(hw->Instance, ENABLE); I2C_SoftwareResetCmd(hw->Instance, ENABLE); I2C_SoftwareResetCmd(hw->Instance, DISABLE); ChipI2cConfigure(hw, ChipI2cSpeed[busId]); }

‌ 先补一个停止条件,再软件复位外设状态机,然后按原来的速度重新配置。每路当前的速度存在文件私有的数组里,Abort 的时候不用再问上层。

‌ 这一步只把外设拉回可用状态。如果从机真的把 SDA 拉死不放,软件复位也救不了,得用 GPIO 打九个时钟让它释放——这次没做,留给以后真碰上再说。

3.3 写

‌ 写的流程就是标准的四步:起始、地址、数据、停止。

static OpsStatus_t ChipI2cWrite(uint8_t busId, uint8_t slaveAddr, const uint8_t* prefix, uint16_t prefixLength, const uint8_t* data, uint16_t length) { I2C_TypeDef* i2c; OpsStatus_t ret; if (busId >= OPS_CHIP_I2C_COUNT) { return OPS_ERR_PARAM; } if (slaveAddr > 0x7Fu) { return OPS_ERR_PARAM; } if ((NULL == prefix) && (0u != prefixLength)) { return OPS_ERR_NULL; } if ((NULL == data) && (0u != length)) { return OPS_ERR_NULL; } i2c = ChipI2cHw[busId].Instance; I2C_GenerateSTART(i2c, ENABLE); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_MODE_SELECT); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } I2C_Send7bitAddress(i2c, (uint8_t)(slaveAddr << 1), I2C_Direction_Transmitter); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } ret = ChipI2cSendBytes(i2c, prefix, prefixLength); if (OPS_OK == ret) { ret = ChipI2cSendBytes(i2c, data, length); } if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } I2C_GenerateSTOP(i2c, ENABLE); return OPS_OK; }

‌ChipI2cSendBytes是一个字节一个字节发、每个字节等 EV8:

static OpsStatus_t ChipI2cSendBytes(I2C_TypeDef* i2c, const uint8_t* data, uint16_t length) { OpsStatus_t ret; uint16_t i; for (i = 0u; i < length; i++) { I2C_SendData(i2c, data[i]); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_BYTE_TRANSMITTED); if (OPS_OK != ret) { return ret; } } return OPS_OK; }

3.4 读,尾巴要提前处理

‌ 读比写麻烦,第一版还写错了,上板跑出来是超时。先把两个坑说清楚。

‌ 接收不能用事件检查。标准库的I2C_EVENT_MASTER_BYTE_RECEIVED里包含 BUSY 位,最后一个字节收完之后 STOP 生效、BUSY 清零,最后一次检查必然失败。接收段只等 RXNE 就够了:

static OpsStatus_t ChipI2cWaitFlag(I2C_TypeDef* i2c, uint32_t flag) { uint32_t timeout = CHIP_I2C_TIMEOUT; while (I2C_GetFlagStatus(i2c, flag) == RESET) { if (0u == timeout) { return OPS_ERR_TIMEOUT; } timeout--; } return OPS_OK; }

‌ 第二个坑是停止条件的时机。第一版在"还剩两个字节"的时候就把 ACK 关了、STOP 预约上,想的是最后一个字节收 NACK 后自然停止。实际跑起来,STOP 会在倒数第二个字节的 ACK 位之后生效,最后一个字节根本发不出来,等 RXNE 等到超时。正确的顺序是:读完倒数第二个字节后关 ACK,最后一个字节收完读走,再生成停止条件。

‌ 完整的读:

static OpsStatus_t ChipI2cRead(uint8_t busId, uint8_t slaveAddr, const uint8_t* txData, uint16_t txLength, uint8_t* rxData, uint16_t rxLength) { I2C_TypeDef* i2c; OpsStatus_t ret; uint16_t remaining; if (busId >= OPS_CHIP_I2C_COUNT) { return OPS_ERR_PARAM; } if (slaveAddr > 0x7Fu) { return OPS_ERR_PARAM; } if (NULL == rxData) { return OPS_ERR_NULL; } if (0u == rxLength) { return OPS_ERR_PARAM; } if ((NULL == txData) && (0u != txLength)) { return OPS_ERR_NULL; } i2c = ChipI2cHw[busId].Instance; /* 阶段一:起始 + 从机地址(写) + 发送段 */ I2C_GenerateSTART(i2c, ENABLE); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_MODE_SELECT); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } I2C_Send7bitAddress(i2c, (uint8_t)(slaveAddr << 1), I2C_Direction_Transmitter); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } ret = ChipI2cSendBytes(i2c, txData, txLength); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } /* 阶段二:重复起始 + 从机地址(读) + 接收段 */ I2C_GenerateSTART(i2c, ENABLE); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_MODE_SELECT); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } I2C_Send7bitAddress(i2c, (uint8_t)(slaveAddr << 1), I2C_Direction_Receiver); ret = ChipI2cWaitEvent(i2c, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); if (OPS_OK != ret) { ChipI2cAbort(busId); return ret; } if (1u == rxLength) { /* 只收一个字节:读之前就要关 ACK 并预约 STOP */ I2C_AcknowledgeConfig(i2c, DISABLE); I2C_GenerateSTOP(i2c, ENABLE); ret = ChipI2cWaitFlag(i2c, I2C_FLAG_RXNE); if (OPS_OK == ret) { rxData[0] = I2C_ReceiveData(i2c); } } else { remaining = rxLength; while (remaining > 0u) { /* 读完倒数第二个字节后给最后一个字节关 ACK,让它回 NACK; 最后一个字节读走之后再生成停止条件 */ if (1u == remaining) { I2C_AcknowledgeConfig(i2c, DISABLE); } ret = ChipI2cWaitFlag(i2c, I2C_FLAG_RXNE); if (OPS_OK != ret) { break; } *rxData = I2C_ReceiveData(i2c); rxData++; remaining--; if (0u == remaining) { I2C_GenerateSTOP(i2c, ENABLE); } } } /* 恢复 ACK 供下次使用 */ I2C_AcknowledgeConfig(i2c, ENABLE); if (OPS_OK != ret) { ChipI2cAbort(busId); } return ret; }

‌ 收一个字节的分支要单独处理。只收一个的时候没有「倒数第二个字节」这个时机,只能在读之前就关 ACK、预约 STOP,这个顺序反倒是对的,上板验证过。

3.5 初始化

‌ 初始化按一整路入参:总线号、引脚数组、速度。引脚角色是OPS_I2C_ROLE_SCL/OPS_I2C_ROLE_SDA,复用编号留给 GPIO 实现:

static OpsStatus_t ChipI2cInit(uint8_t busId, const OpsI2cPin_t* pins, uint8_t pinCount, uint32_t speedHz) { /* ...参数与引脚角色检查... */ gpio = OpsGpioGetOps(); if (NULL == gpio) { return OPS_ERR_UNSUPPORTED; } ret = gpio->InitI2c(busId, pins, pinCount); if (OPS_OK != ret) { return ret; } if (0u == speedHz) { speedHz = CHIP_I2C_DEFAULT_SPEED; } ChipI2cSpeed[busId] = speedHz; hw = &ChipI2cHw[busId]; ChipClockEnable(hw->Clock); I2C_SoftwareResetCmd(hw->Instance, ENABLE); I2C_SoftwareResetCmd(hw->Instance, DISABLE); ChipI2cConfigure(hw, speedHz); return OPS_OK; }

‌ 上电或者上次异常之后可能残留 BUSY,所以配置之前先软件复位一次。

4 设备类和两种后端

4.1 板级表

‌ 板级表只写「板子上怎么接」:

typedef enum { I2C_EEPROM = 0, /* 板载 AT24C02:SCL = PB6 / SDA = PB7 */ I2C_MAX, }BoardI2cId_t; typedef struct { OpsPin_t Scl; OpsPin_t Sda; uint8_t BusId; uint32_t SpeedHz; }BoardI2cConfig_t; extern const BoardI2cConfig_t BoardI2cConfig[I2C_MAX];
const BoardI2cConfig_t BoardI2cConfig[I2C_MAX] = { [I2C_EEPROM] = { { OPS_PORT_B, OPS_PIN(6) }, { OPS_PORT_B, OPS_PIN(7) }, 0u, 100000u }, };

‌ 从机地址不进板级表。一条总线可以挂好几个从机,地址是调用的参数。

4.2 一个设备类,两张方法表

‌ 设备类对外只有三个方法:

typedef struct { OpsStatus_t (*Read)(DevI2c_t* self, uint8_t slaveAddr, uint16_t regAddr, uint8_t* data, uint16_t length); OpsStatus_t (*Write)(DevI2c_t* self, uint8_t slaveAddr, uint16_t regAddr, const uint8_t* data, uint16_t length); OpsStatus_t (*Delete)(DevI2c_t* self); }DevI2cOps_t;

‌ 后端在创建时选:

typedef enum { DEV_I2C_BACKEND_HARDWARE = 0, /* 硬件外设后端 */ DEV_I2C_BACKEND_SOFT, /* GPIO 位翻转后端 */ DEV_I2C_BACKEND_MAX, }DevI2cBackend_t;

‌ 创建时按后端挂不同的方法表:

DevI2c_t* I2cCreate(I2cId_t id, DevI2cBackend_t backend) { const BoardI2cConfig_t* config; const OpsGpioOps_t* gpio; const OpsI2cOps_t* hw = NULL; const OsMemOps_t* mem; DevI2c_t* self; OpsStatus_t ret = OPS_OK; if (id >= (I2cId_t)I2C_MAX) { return NULL; } if (backend >= DEV_I2C_BACKEND_MAX) { return NULL; } /* 设备层自己取能力,上层不需要装配 */ mem = OsMemGetOps(); if (NULL == mem) { return NULL; } gpio = OpsGpioGetOps(); if (NULL == gpio) { return NULL; } if (DEV_I2C_BACKEND_HARDWARE == backend) { hw = OpsI2cGetOps(); if (NULL == hw) { return NULL; } } config = &BoardI2cConfig[id]; self = (DevI2c_t*)mem->Alloc((uint32_t)sizeof(DevI2c_t)); if (NULL == self) { return NULL; } self->Id = id; self->Backend = backend; self->Scl = config->Scl; self->Sda = config->Sda; self->BusId = config->BusId; self->SpeedHz = config->SpeedHz; self->Gpio = gpio; self->Hw = hw; if (DEV_I2C_BACKEND_HARDWARE == backend) { OpsI2cPin_t pins[2]; self->Ops = &I2cHardwareOps; pins[0].Role = OPS_I2C_ROLE_SCL; pins[0].Pin = config->Scl; pins[1].Role = OPS_I2C_ROLE_SDA; pins[1].Pin = config->Sda; ret = hw->Init(config->BusId, pins, 2u, config->SpeedHz); } else { self->Ops = &I2cSoftOps; ret = gpio->Init(config->Scl, OPS_GPIO_OUT_OD); if (OPS_OK == ret) { ret = gpio->Init(config->Sda, OPS_GPIO_OUT_OD); } if (OPS_OK == ret) { /* 总线回空闲:两条线都放高 */ (void)gpio->Write(config->Scl, 1u); (void)gpio->Write(config->Sda, 1u); } } if (OPS_OK != ret) { mem->Free(self); return NULL; } return self; }

‌ 能力表都是设备层自己取的,上层拿到的就是一句I2cCreate(I2C_EEPROM, DEV_I2C_BACKEND_HARDWARE)。

‌ 硬件后端的方法只是把寄存器地址拼好,再交给芯片层:

static uint8_t BuildRegAddr(uint16_t regAddr, uint8_t buffer[2]) { if (regAddr > 0xFFu) { buffer[0] = (uint8_t)(regAddr >> 8); buffer[1] = (uint8_t)(regAddr & 0xFFu); return 2u; } buffer[0] = (uint8_t)regAddr; return 1u; } static OpsStatus_t I2cHardwareWrite(DevI2c_t* self, uint8_t slaveAddr, uint16_t regAddr, const uint8_t* data, uint16_t length) { uint8_t regBuffer[2]; uint8_t regLength; if (NULL == self) { return OPS_ERR_STATE; } if ((NULL == data) && (0u != length)) { return OPS_ERR_NULL; } regLength = BuildRegAddr(regAddr, regBuffer); return self->Hw->Write(self->BusId, slaveAddr, regBuffer, regLength, data, length); }

‌ 16 位寄存器地址发两个字节,高字节在前。这套拼接硬件和软件两个后端共用。

4.3 软件模拟那一条

‌ 软件后端的全部实现都在设备层里,用的是 GPIO 接口,不碰任何芯片符号。

‌ 延时沿用旧代码的空循环,变量加了volatile,防止编译器在优化的时候把整个循环删掉:

static void I2cSoftDelay(void) { volatile uint32_t i; for (i = 0u; i < 10u; i++) { } }

‌ 起始条件和停止条件:

static void I2cSoftStart(DevI2c_t* self) { I2cSoftSdaHigh(self); I2cSoftSclHigh(self); I2cSoftDelay(); I2cSoftSdaLow(self); I2cSoftDelay(); I2cSoftSclLow(self); I2cSoftDelay(); } static void I2cSoftStop(DevI2c_t* self) { I2cSoftSdaLow(self); I2cSoftDelay(); I2cSoftSclHigh(self); I2cSoftDelay(); I2cSoftSdaHigh(self); I2cSoftDelay(); }

‌ 发一个字节,第九个时钟读应答:

static uint8_t I2cSoftWriteByte(DevI2c_t* self, uint8_t data) { uint8_t i; uint8_t ack; for (i = 0u; i < 8u; i++) { if (0u != (data & 0x80u)) { I2cSoftSdaHigh(self); } else { I2cSoftSdaLow(self); } I2cSoftDelay(); I2cSoftSclHigh(self); I2cSoftDelay(); I2cSoftSclLow(self); I2cSoftDelay(); data <<= 1; } /* 第 9 个时钟:释放 SDA,由从机拉低表示应答 */ I2cSoftSdaHigh(self); I2cSoftDelay(); I2cSoftSclHigh(self); I2cSoftDelay(); ack = I2cSoftSdaRead(self); I2cSoftSclLow(self); I2cSoftDelay(); return ack; }

‌ 读一个字节:

static uint8_t I2cSoftReadByte(DevI2c_t* self) { uint8_t i; uint8_t data = 0u; I2cSoftSdaHigh(self); /* 释放 SDA,交给从机驱动 */ for (i = 0u; i < 8u; i++) { data <<= 1; I2cSoftSclHigh(self); I2cSoftDelay(); if (0u != I2cSoftSdaRead(self)) { data |= 0x01u; } I2cSoftSclLow(self); I2cSoftDelay(); } return data; }

‌ 这里有个偷懒但正确的做法:SDA 一直配成开漏输出,读的时候直接读引脚电平。F1 的输入数据寄存器在开漏输出模式下仍然反映引脚的实际电压,所以不用来回切输入输出方向。旧代码就是这么干的,这次照搬。

‌ 完整的一次写:

static OpsStatus_t I2cSoftWrite(DevI2c_t* self, uint8_t slaveAddr, uint16_t regAddr, const uint8_t* data, uint16_t length) { uint8_t regBuffer[2]; uint8_t regLength; uint16_t i; if (NULL == self) { return OPS_ERR_STATE; } if ((NULL == data) && (0u != length)) { return OPS_ERR_NULL; } regLength = BuildRegAddr(regAddr, regBuffer); I2cSoftStart(self); if (0u != I2cSoftWriteByte(self, (uint8_t)(slaveAddr << 1))) { I2cSoftStop(self); return OPS_ERR_HW; } for (i = 0u; i < regLength; i++) { if (0u != I2cSoftWriteByte(self, regBuffer[i])) { I2cSoftStop(self); return OPS_ERR_HW; } } for (i = 0u; i < length; i++) { if (0u != I2cSoftWriteByte(self, data[i])) { I2cSoftStop(self); return OPS_ERR_HW; } } I2cSoftStop(self); return OPS_OK; }

‌ 完整的一次读,中间有一次重复起始:

static OpsStatus_t I2cSoftRead(DevI2c_t* self, uint8_t slaveAddr, uint16_t regAddr, uint8_t* data, uint16_t length) { uint8_t regBuffer[2]; uint8_t regLength; uint16_t i; if (NULL == self) { return OPS_ERR_STATE; } if (NULL == data) { return OPS_ERR_NULL; } if (0u == length) { return OPS_ERR_PARAM; } regLength = BuildRegAddr(regAddr, regBuffer); I2cSoftStart(self); if (0u != I2cSoftWriteByte(self, (uint8_t)(slaveAddr << 1))) { I2cSoftStop(self); return OPS_ERR_HW; } for (i = 0u; i < regLength; i++) { if (0u != I2cSoftWriteByte(self, regBuffer[i])) { I2cSoftStop(self); return OPS_ERR_HW; } } /* 重复起始,切到读方向 */ I2cSoftStart(self); if (0u != I2cSoftWriteByte(self, (uint8_t)((slaveAddr << 1) | 0x01u))) { I2cSoftStop(self); return OPS_ERR_HW; } for (i = 0u; i < length; i++) { data[i] = I2cSoftReadByte(self); if ((uint16_t)(i + 1u) < length) { I2cSoftSendAck(self, 0u); /* 还要继续读,应答 */ } else { I2cSoftSendAck(self, 1u); /* 最后一个字节不应答 */ } } I2cSoftStop(self); return OPS_OK; }

5 PC 测试

‌ 设备层在 PC 上测,硬件侧全部换成假表。这次多了一张假的 I2C 表,和之前的假 GPIO、假内存表一起链接:

{ "name": "dev_i2c", "source": "tools/tests/test_dev_i2c.c", "mocks": ["tools/tests/mocks/mock_ops_i2c.c", "tools/tests/mocks/mock_ops_gpio.c", "tools/tests/mocks/mock_os_mem.c"], "impl": ["Demo/User/device/dev_i2c.c", "Demo/User/board/board_i2c.c"], "include": ["Demo/User", "tools/tests/mocks"], },

‌ 假 I2C 表记录每次调用的参数,还能预设返回值:

typedef struct { unsigned InitCount; unsigned DeInitCount; unsigned WriteCount; unsigned ReadCount; uint8_t LastBusId; uint32_t LastSpeedHz; uint8_t LastPinCount; OpsI2cPin_t LastPins[2]; uint8_t LastPrefix[MOCK_I2C_MAX_BYTES]; uint16_t LastPrefixLength; uint8_t LastData[MOCK_I2C_MAX_BYTES]; uint16_t LastDataLength; /* ... */ uint8_t Available; /* 0 时 OpsI2cGetOps() 返回 NULL */ OpsStatus_t InitResult; OpsStatus_t WriteResult; OpsStatus_t ReadResult; }MockI2c_t;

‌Available这个字段让「实现层没提供能力」也能测:OpsI2cGetOps()返回 NULL,创建必须失败。板子上造不出这种场景。

‌ 用例覆盖了这些:硬件后端的创建与 Init 参数、8 位和 16 位寄存器地址的拼接、读写透传、错误码原样返回、软件后端创建时两次开漏初始化、软件后端的写和读(假 GPIO 返回应答)、从机不应答时返回硬件错误、参数校验(空指针、长度 0、只写地址)、板级 id 与后端越界、内存申请失败、初始化失败回滚、两种对象的删除。

‌ 软件后端在 PC 上跑位翻转,从机应答是假的:把假 GPIO 的读值设成 0,所有读都是应答。这样至少能证明状态机不会卡死、错误路径能返回。

‌ 加上之前的三套,现在一共 5 个套件、265 项断言。

6 上板验证

‌ 验证任务做四件事:硬件后端写读、软件后端交叉读、软件后端写读、无效地址的错误处理。

static void I2cVerifyTask(void* parameter) { DevI2c_t* i2c; uint8_t writeData[8] = { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 }; uint8_t readData[8]; uint8_t mismatch; uint8_t i; (void)parameter; ModifySystemLogLevel(LOG_LEVEL_INFO); i2c = I2cCreate(I2C_EEPROM, DEV_I2C_BACKEND_HARDWARE); if (NULL == i2c) { LogPrintf(LOG_LEVEL_ERROR, "I2C HW create failed"); } else { if (OPS_OK == i2c->Ops->Write(i2c, 0x50, 0x0000, writeData, 8)) { LogPrintf(LOG_LEVEL_INFO, "I2C HW write ok"); } else { LogPrintf(LOG_LEVEL_ERROR, "I2C HW write FAIL"); } vTaskDelay(pdMS_TO_TICKS(10)); memset(readData, 0, sizeof(readData)); if (OPS_OK != i2c->Ops->Read(i2c, 0x50, 0x0000, readData, 8)) { LogPrintf(LOG_LEVEL_ERROR, "I2C HW read FAIL"); } else { mismatch = 0; for (i = 0; i < 8; i++) { if (readData[i] != writeData[i]) { mismatch++; } } LogPrintf(LOG_LEVEL_INFO, "I2C HW read mismatch=%d", (int)mismatch); } /* ...无效地址 0x51 的错误处理与总线恢复... */ (void)i2c->Ops->Delete(i2c); } /* 软件后端:交叉读硬件刚写的 8 个字节,再写另一组数据读回 */ /* ... */ LogPrintf(LOG_LEVEL_INFO, "I2C verify done"); vTaskDelete(NULL); }

‌ 硬件写完之后要延时 10 毫秒再读。AT24C02 收到停止条件后开始内部写周期,这段时间它不应答,驱动不等这个,等不等由调用方决定。

‌ 验证里还专门读了一次 0x51。板载 EEPROM 的地址是 0x50,0x51 上没有器件,读它必然收不到应答。这一步看两件事:驱动返回错误而不是卡死,以及出错之后总线还能不能继续用。

‌ 两条后端用的是同一组引脚 PB6 / PB7。硬件后端用完删掉,软件后端重新把这两个脚配成普通开漏输出,再跑一遍同样的读写。

‌ 第一次上板的结果是这样:

I2C HW write ok I2C HW read FAIL I2C HW NACK returned error ok I2C HW bus stuck after NACK I2C SOFT cross mismatch=0 I2C SOFT write ok I2C SOFT read mismatch=0 I2C SOFT NACK returned error ok I2C SOFT bus still alive

‌ 软件那条路全对,交叉读也一致,说明数据确实写进了 EEPROM,硬件写的那条路也是通的。硬件读失败了。

‌ 为了定位,在验证任务里加了两条打印:一条直接调芯片层读一个字节,另一条把设备层读 8 字节的返回码打出来。返回码是统一的OpsStatus_t,3 表示超时,4 表示从机没应答。改动之后:

I2C HW read1 ret=0 val=11 I2C HW read FAIL ret=3

‌ 读一个字节成功,值是 0x11,正是写进去的第一个字节。起始、地址、重复起始、收字节、NACK、停止这一整套时序都没问题。超时只出现在多字节那一段。

‌ 原因就是前面说的第二个坑:STOP 预约得太早,落在倒数第二个字节的 ACK 之后,最后一个字节没发出来。改成读完倒数第二个字节再关 ACK、最后一个字节读走再设 STOP,重新下载:

I2C HW write ok I2C HW read1 ret=0 val=11 I2C HW read mismatch=0 I2C HW NACK returned error ok I2C HW bus still alive I2C SOFT cross mismatch=0 I2C SOFT write ok I2C SOFT read mismatch=0 I2C SOFT NACK returned error ok I2C SOFT bus still alive I2C verify done

‌ 两条后端都通过。硬件写的数据用软件后端读出来是mismatch=0,反过来软件写的数据硬件也能读。无效地址返回错误,之后总线还能继续用,软件复位那一步有效。

结语

‌ 这一路的代码量比串口小,麻烦在两条后端要维持一致的行为:同样的寄存器地址拼接、同样的错误码、同样的 7 位地址约定。硬件那条还有 F1 的勘误要照顾,出错之后必须把外设拉回空闲,不然下一次调用直接挂死。

‌ 代价是每个操作都要在芯片层和软件层各维护一份时序。好在两边的接口收得很窄,读写的语义完全一样,上层换后端只改一个枚举值。软件模拟的位延时是空转,速度比硬件慢一截,只适合低速器件或者引脚被占用的场景。

‌ 总线恢复只做到了软件复位这一层。NACK 之后外设能拉回空闲,这个验证过了;如果从机把 SDA 彻底拉死不放,软件复位也救不了,得用 GPIO 打九个时钟,这次没做。等真碰上再加。

‌ 下一篇写 SPI。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进