tags: 嵌入式,I2C,ISR,双缓冲,并发
category: 嵌入式开发
type: original
series: peripheral-practice
hook: I2C 从机收到下一帧时,上一帧为什么突然变了
data: 单缓冲: 生产者覆盖消费者 | 双缓冲: STOP 时一次性交接完整事务
I²C 从机接收总被覆盖?双缓冲要在 STOP 时交接
一句话: 中断负责收字节,主循环负责处理命令时,单缓冲无法同时保护"正在接收"和"等待消费"的两份数据;应在事务结束时一次性交接缓冲区和元数据。
适合谁读:I²C 从机、串口或其他中断接收路径出现偶发丢帧、数据错配的嵌入式开发者。
先画出两个所有者
先不要急着加 volatile。把接收区的写入者和读取者列出来:
| 对象 | 生产者 | 消费者 | 允许什么时候修改 |
|---|---|---|---|
| 当前接收缓冲区 | ISR | 无 | 当前事务进行中 |
| 已发布缓冲区 | 无 | 主循环 | 主循环处理期间只读 |
| 地址、长度、完成标志 | ISR 发布 | 主循环读取 | 事务结束时一次更新 |
如果 ISR 和主循环都能改同一块数组,主循环读到的可能是半帧。即使每个字节的读写是原子的,整帧仍然不是原子的。
双缓冲的最小结构
c
typedef struct {
uint8_t data[RX_CAPACITY];
uint16_t length;
uint16_t address;
} rx_frame_t;
static rx_frame_t rx_buffer[2];
static uint8_t rx_write_index;
static uint8_t rx_ready_index;
static bool rx_ready;
static bool rx_overflow;
ISR 只写当前缓冲区。收到 STOP 后,先补齐长度和地址,再一次性发布索引;主循环只消费已经发布的那一块。
text
ISR 写 A → STOP → 发布 A → 主循环读 A
ISR 改写 B → STOP → 发布 B → 主循环读 B
这样,主循环处理 A 时,ISR 可以准备 B,不会把 A 的内容改到一半。
STOP 和 REPEATED START 不是一回事
许多 I²C 主机先写寄存器地址,再用 REPEATED START 读取数据,中间没有 STOP。此时不能把"上一帧已经发布的地址"当成当前读取地址。
推荐为当前事务保留独立地址快照:
c
void on_address_phase(uint16_t address)
{
current_transaction_address = address;
}
void on_repeated_start_read(void)
{
tx_address = current_transaction_address;
start_read_response(tx_address);
}
STOP 适合提交"这一帧已经完整接收"的事实;REPEATED START 则表示总线方向变了,不代表前一帧的业务数据可以被重新解释。
缓冲区满了怎么办
双缓冲并不能让消费者无限变快。上一帧还没处理完,新帧又结束时,必须有明确策略:
| 策略 | 优点 | 风险 |
|---|---|---|
| 丢弃新帧 | 保留已发布的完整帧 | 需要记录溢出,主机要能重试 |
| 覆盖旧帧 | 不阻塞接收 | 主循环可能处理到被破坏的数据 |
| 增加队列 | 吞吐更高 | 需要容量、回收和背压策略 |
对配置命令,通常宁可丢弃并报告溢出,也不要静默覆盖一帧已经交给主循环的数据。
回归测试至少覆盖三条路径
text
连续两帧快速写入 → 第一帧内容不被第二帧改写
写地址 + REPEATED START + READ → 读取当前事务地址
主循环故意阻塞 → 溢出有记录,已发布帧仍完整
如果只有静态检查和编译证据,应写明"所有权交接路径已检查,真实主机互操作仍待验证"。不要把没有波形或抓包日志的场景写成已经复现的现场故障。
总结
- 单缓冲的问题不是少一个数组,而是生产者和消费者没有所有权边界。
- STOP 时发布完整帧,主循环只消费已发布缓冲区。
- REPEATED START 要使用当前事务地址快照。
- 缓冲区满时必须选择丢弃、覆盖或排队,不能让策略隐含在指针操作里。
有用的话点个收藏,下次 I²C 偶发错帧时,先问"这块缓冲区现在到底归谁"。