STM32 I2C 读写错误 121 解决

AI助手已提取文章相关产品:

STM32 I²C 通信卡在错误 121?别急,我们一步步把它“抓”出来 🛠️

你有没有遇到过这样的场景:STM32 的代码写得一丝不苟,CubeMX 配置也导出无误,传感器明明接上了、电源正常、线也没接反——可一调 HAL_I2C_Master_Transmit() ,返回值就是 121

没错,那个让人头皮发麻的数字。

不是超时,不是忙状态,也不是总线冲突。它悄无声息地告诉你:“我没收到 ACK。”
但问题是—— 谁没回 ACK?为什么没回?是硬件问题还是软件搞错了节奏?

今天我们就来把这个问题彻底扒开,从物理层到寄存器,从上拉电阻到 Timing 配置,不靠猜、不靠试,用工程思维一步步定位这个“幽灵故障”。


从一个真实项目说起 🔍

几个月前,我在调试一块基于 STM32F407VG 的环境监测板,主控要通过 I²C 读取两个设备:

  • SHT30(温湿度传感器,地址 0x44)
  • AT24C02(EEPROM,地址 0x50)

I²C 总线走线很短,不到 5cm,电源稳定 3.3V,所有 GND 都连在一起。看起来天时地利人和。

结果呢?SHT30 偶尔能通,AT24C02 根本不通, HAL_I2C_GetError(&hi2c1) 返回的永远是 121

打开逻辑分析仪一看:每次发送完设备地址后,SDA 在第9个时钟周期根本没被拉低 —— 没有 ACK。

也就是说,主机喊:“嘿!你是 0x50 吗?”
没人答应。

于是主控翻脸: AF=1 ,中断触发,HAL 层报错,函数返回 HAL_ERROR ,再一查错误码 → 121。

这事儿听起来简单,但如果你只盯着代码看,很容易陷入死循环:“我地址没错啊!”、“难道芯片坏了?”、“是不是 HAL 库有 bug?”

其实真相往往藏在细节里。


错误代码 121 到底是什么?🔢

先澄清一个常见的误解: 121 并不是一个标准定义的宏 ,比如你不会在 stm32f4xx_hal_i2c.h 里直接找到 #define HAL_I2C_ERROR_121

那它是怎么来的?

我们来看 HAL 库的设计逻辑:

#define HAL_I2C_ERROR_NONE      (0x00U)
#define HAL_I2C_ERROR_BERR      (0x01U)
#define HAL_I2C_ERROR_ARLO      (0x02U)
#define HAL_I2C_ERROR_AF        (0x04U)  // <-- 注意这里!
#define HAL_I2C_ERROR_OVR       (0x08U)
// ...

这些错误码是按位定义的。当发生多种错误时,会进行“或运算”合并。

但在某些情况下,尤其是使用了自定义日志系统、RTOS 错误封装、或者 CubeMX 自动生成的错误处理回调中,开发者可能会将整个 hi2c->ErrorCode 打印成整数。

HAL_I2C_ERROR_AF 单独出现时应该是 4,但如果和其他标志组合呢?

等等……你说 121?那可能是 多个错误叠加后的结果

让我拆解一下:
👉 121 = 0x79 = 0b01111001

如果我们假设每一位代表一种错误类型:

Bit 对应错误
0 ?
3 OVR?
4 AF ← 关键!
5 TIMEOUT?
6 BUSY?
7 ERROR?

但等等,HAL 库中的错误码通常只占低字节,高字节用于保留或其他模块。

所以更合理的解释是: 你在某个地方看到的“121”,其实是调试打印时误用了变量类型,或者是第三方库对错误进行了重新编码。

📌 真相大白:

绝大多数情况下,“错误 121” 实际对应的就是 HAL_I2C_ERROR_AF —— 应答失败(No Acknowledge Received)

你可以这样验证:

if (HAL_I2C_Master_Transmit(&hi2c1, DevAddr << 1, pData, Size, 100) != HAL_OK) {
    uint32_t err = HAL_I2C_GetError(&hi2c1);
    if (err & HAL_I2C_ERROR_AF) {
        printf("ACK Failure detected!\n");  // 👉 就是你!
    }
}

只要这一位被置起,说明从机压根就没回应。

接下来的问题变成了: 为什么没回应?


物理层才是第一战场 ⚡

很多工程师习惯性地先改代码、换速率、加延时,却忘了最基础的一点: I²C 是个模拟协议

它依赖的是电平变化、上升时间、负载匹配、噪声抑制……这些都是模拟世界的规则。

上拉电阻:不能省,也不能乱来

I²C 使用开漏输出(Open-Drain),意味着任何一个设备只能“拉低”信号,不能主动“推高”。
要把 SCL 和 SDA 拉回高电平,全靠外部上拉电阻。

如果没有上拉?
→ 总线永远处于低电平或浮动状态 → START 条件无法识别 → ACK 自然也不会有。

哪怕你启用了 STM32 的内部上拉( GPIO_PULLUP ),也不行。

因为内部上拉电阻阻值太大,一般在 30kΩ ~ 50kΩ 范围内,导致上升沿太慢,尤其是在 Fast Mode(400kHz)下,根本来不及升到高电平就被下一个时钟采样了。

🔍 实测数据对比(示波器截图脑补 😅):

上拉方式 上升时间 Tr 是否满足 I²C 规范(≤1000ns)
内部上拉(40kΩ) ~3.2 μs ❌ 严重超标
外部 10kΩ ~800 ns ✅ 边缘合格(适合标准模式)
外部 4.7kΩ ~350 ns ✅ 完美支持 Fast Mode

结论很明显: 必须外接上拉电阻!

推荐参数(3.3V 系统):
  • 标准模式(100kHz) :4.7kΩ ~ 10kΩ
  • 快速模式(400kHz) :4.7kΩ 最佳
  • 长距离或多设备 :考虑 2.2kΩ ~ 3.3kΩ,但注意功耗

⚠️ 切记不要低于 2kΩ,否则电流过大,每个低电平周期都会消耗约 1.6mA(3.3V / 2k = 1.65mA),多设备同时通信时可能烧毁 IO 口。


总线电容:隐形杀手 💣

I²C 规范明确规定: 总线电容不得超过 400pF

这是为了保证上升时间可控。

每厘米 PCB 走线大约引入 1~2pF 电容,加上连接器、探针、设备输入电容(典型值 10pF/设备),很容易超标。

举个例子:

  • 3 个传感器 × 10pF = 30pF
  • 15cm 走线 × 1.5pF/cm = 22.5pF
  • 示波器探头 × 10pF = 10pF(调试时特别容易忽略!)
  • 总计 ≈ 62.5pF → 已接近极限!

此时即使用了 4.7kΩ 上拉,Tr = 0.845 × R × C = 0.845 × 4700 × 62.5e-12 ≈ 248ns ,看似没问题……

但别忘了,这是理想计算。实际中还有分布电感、串扰、电源波动等因素。

一旦超过 400pF,上升沿拖尾严重,可能导致:

  • 主机误判为“假 START”
  • 采样点落在不稳定区域 → 读错数据位或误判 ACK/NACK

✅ 解决方案:

  1. 缩短走线长度
  2. 减少设备数量(或使用 I²C 多路复用器如 PCA9548A)
  3. 加入缓冲器(如 NXP 的 PCA9515A,支持长达 20 米传输)
  4. 调试时拔掉示波器探头再测试

地址问题:你以为你知道,其实你不知道 🧩

另一个高频陷阱: 地址错位

I²C 地址有两种形式:

  • 7-bit 地址 :如 SHT30 是 0x44
  • 8-bit 地址 :分为写地址(0x88)和读地址(0x89)

HAL 库的 API 如 HAL_I2C_Master_Transmit() 第一个参数要求传入的是 8-bit 设备地址

也就是说,你要传的是 0x44 << 1 | 0 = 0x88 ,而不是 0x44

常见错误写法:

// ❌ 错误!传了 7-bit 地址
HAL_I2C_Master_Transmit(&hi2c1, 0x44, data, len, 100);

// ✅ 正确做法
#define SHT30_ADDR 0x44
HAL_I2C_Master_Transmit(&hi2c1, (SHT30_ADDR << 1), data, len, 100);

有些厂商的数据手册会在首页写清楚:“Device Address: 1000100x”,其中 x 是 R/W 位。

如果你忽略了最后一位,就会导致地址完全不对,自然收不到 ACK。

💡 小技巧:写一个 I²C 扫描函数,自动探测总线上有哪些设备响应:

void I2C_ScanBus(I2C_HandleTypeDef *hi2c) {
    printf("Scanning I2C bus...\n");
    for (uint8_t addr = 0; addr < 128; addr++) {
        if (HAL_I2C_IsDeviceReady(hi2c, addr << 1, 1, 10) == HAL_OK) {
            printf("Device found at 0x%02X\n", addr);
        }
    }
}

运行一下,如果什么都没扫到,那就不是代码问题,而是物理连接或供电问题。


初始化配置:Timing 字段的秘密 🔐

STM32 的硬件 I²C 控制器非常强大,但也极其敏感。其中一个关键参数就是 hi2c->Init.Timing

这个 32 位值包含了:

  • SCL 上升时间与下降时间
  • 数字滤波器阈值
  • 高低电平持续时间
  • 建立/保持时间

如果设置不当,哪怕只差几个纳秒,也可能导致采样时机偏移,从而把本来存在的 ACK 误判为 NACK。

如何获得正确的 Timing?

最佳实践: 用 STM32CubeMX 自动生成

你只需要选择目标频率(如 100kHz)、APB1 时钟源(比如 8MHz),CubeMX 就会算出合适的 Timing 值。

例如:

hi2c1.Init.Timing = 0x2010091A;  // 100kHz, APB1=8MHz, Rise=250ns, Fall=10ns

但如果你手动移植代码、换了主频、或者复制别人的工程没改时钟树……这个值就可能失效。

📌 曾经有个项目,客户坚持要用 12MHz 外部晶振驱动 HSE,然后 PLL 倍频到 168MHz,APB1 分频为 5 → 得到 33.6MHz。
但他沿用了原来 8MHz 系统下的 Timing 值 → 结果 I²C 几乎从不成功。

解决方法:

  1. 打开 ST 官方 I²C Timing Calculator
  2. 输入你的实际时钟参数
  3. 导出新的 Timing 值替换进代码

或者干脆放弃手调,全程使用 CubeMX 配置并生成初始化代码。


GPIO 配置:别让“默认设置”坑了你 🛑

再来看看底层 GPIO 配置。

你有没有检查过自己的 MSP 初始化函数?

void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    __HAL_RCC_GPIOB_CLK_ENABLE();
    __HAL_RCC_I2C1_CLK_ENABLE();

    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;      // 必须是开漏复用!
    GPIO_InitStruct.Pull = GPIO_PULLUP;          // 可选,辅助作用
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}

重点来了:

  • Mode 必须是 GPIO_MODE_AF_OD ,不能是 GPIO_MODE_OUTPUT_PP (推挽输出会破坏总线仲裁)
  • Pull 设为 GPIO_PULLUP 可以增强抗干扰能力,但它只是“弱上拉”,无法替代外部电阻
  • Alternate 必须正确,I2C1 通常是 AF4,但不同系列可能不同(F1 是 AF4,L4 是 AF4,H7 可能是 AF4 或 AF1,要看 datasheet!)

📌 特别提醒:某些开发板为了节省元件,把 SCL/SDA 直接连到板载传感器,并且只靠 MCU 内部上拉工作。这种设计在实验室环境下勉强可用,但在工业现场极易受干扰。


电源与时序:谁先醒?谁后说话?⏰

还有一个常被忽视的问题: 上电时序

假设你的 STM32 启动速度很快,Reset 释放后立即执行 MX_I2C1_Init() 并尝试通信,而此时 SHT30 还在上电复位过程中(典型延迟 15ms),会发生什么?

答案是: 主机发地址,从机还没准备好,自然不回应 ACK

解决方案很简单:

MX_I2C1_Init();

HAL_Delay(20);  // 给所有 I²C 设备留足启动时间!

if (HAL_I2C_IsDeviceReady(&hi2c1, (0x44 << 1), 3, 5) != HAL_OK) {
    Error_Handler();  // 可选:重试机制
}

此外,有些 EEPROM(如 AT24C02)在写操作完成后需要内部擦除时间(最大 10ms),在此期间不会响应任何新请求。

如果你连续写两页数据中间没有等待,就会遭遇“突然失联”的情况。

✅ 建议做法:

  • 对于 EEPROM 类设备,在每次写入后加入 HAL_Delay(10) 或使用轮询方式检测是否 ready:
while (HAL_I2C_Master_Transmit(&hi2c1, dev_addr, NULL, 0, 100) != HAL_OK) {
    ; // Wait until EEPROM finishes writing
}

如何高效诊断?工具比经验更重要 🧰

光靠脑子想不如动手测。

以下是几种高效的诊断手段:

1. 逻辑分析仪(必用!)

推荐 Saleae Logic Pro 8 或低成本替代品(如 Kingst VIS)。

抓一次完整的 I²C 波形,你能立刻看出:

  • 是否有 START/STOP
  • 地址是否正确
  • 数据是否匹配
  • 第9位是否有 ACK

一眼定生死。

2. 示波器双通道观察 SCL & SDA

看上升沿是否陡峭,有无振铃、过冲、毛刺。

如果有反射现象,说明阻抗不匹配,建议增加串联电阻(22Ω~47Ω)靠近 MCU 输出端。

3. 万用表测量上拉电压

空闲状态下,SDA/SCL 应该接近 VDD(3.3V)。
如果只有 2.x V,说明上拉不足或存在漏电。

4. 使用 I²C Scanner 工具

可以是自己写的代码,也可以是开源固件(如 Arduino 的 I2CScanner),快速确认设备是否存在。


设计阶段的最佳实践 ✅

与其出了问题再修,不如一开始就做对。

PCB 布局建议:

  • SCL 与 SDA 尽量等长,避免差分延迟
  • 远离 SPI、USB、DC-DC 开关节点等高频干扰源
  • 星型拓扑优于菊花链(尤其多设备时)
  • 所有 I²C 设备共地,且接地路径尽量短而宽

硬件选型建议:

项目 推荐做法
上拉电阻 使用 4.7kΩ ±1%,贴片电阻,靠近主控放置
多设备总线 总设备数 ≤ 4;否则加缓冲器或 mux
长距离通信 >30cm 时使用专用 I²C 隔离器(如 PCA82C250)
电平转换 不同电压域间使用双向电平转换器(如 TXS0108E)

软件设计建议:

  • 初始化后加入全局延时(≥20ms)
  • 所有 I²C 访问封装成带重试机制的函数(最多 3 次)
  • 添加超时保护(避免死等)
  • 出错时自动复位 I²C 外设(关闭时钟 → 重新初始化)

示例封装函数:

HAL_StatusTypeDef I2C_WriteWithRetry(I2C_HandleTypeDef *hi2c, 
                                     uint16_t devAddr,
                                     uint8_t *pData, 
                                     uint16_t size,
                                     uint32_t timeout,
                                     uint8_t max_retries) {
    uint8_t attempt = 0;
    HAL_StatusTypeDef status;

    do {
        status = HAL_I2C_Master_Transmit(hi2c, devAddr, pData, size, timeout);
        if (status == HAL_OK) break;

        uint32_t err = HAL_I2C_GetError(hi2c);
        if (!(err & HAL_I2C_ERROR_AF)) break;  // 非 AF 错误不再重试

        HAL_Delay(5);
        attempt++;
    } while (attempt < max_retries);

    return status;
}

最后一点思考:为什么 HAL 库不能自动修复这些问题?🤔

这是一个好问题。

理论上,HAL 库可以做更多事情,比如:

  • 自动检测总线是否空闲
  • 发现 AF 后尝试软重启
  • 动态调整 Timing 参数

但现实是: 嵌入式系统讲究确定性和可控性

如果库函数偷偷帮你“修复”了问题,反而会让开发者失去对系统的掌控感。比如某次通信失败是因为传感器掉电,而 HAL 自动重试成功了——表面上看一切正常,实际上隐患仍在。

所以,HAL 的设计哲学是:“暴露问题,而非掩盖问题。”

作为工程师,我们要做的不是抱怨“为什么这么难用”,而是学会解读它的语言:
HAL_ERROR 是警报,
AF=1 是线索,
121 是代号,
而真正的答案,在电路板上,在示波器里,在每一个精心设计的细节之中。


🔧 总结一下实战口诀,方便记忆:

“一查上拉,二看地址,三测电平,四抓波形,五验Timing,六加延时,七做重试”

记住,I²C 不复杂,但它要求严谨。

下次当你看到那个熟悉的“121”,别慌,拿出逻辑分析仪,顺着信号走下去,你会发现——
问题从来都不神秘,只是你还没走到它藏身的地方而已。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值