知识门户

Back

从C语言到Rust:再也不用担心I2C总线被抢疯了!#

如果你以前用C语言写STM32,一定经历过这种”玄学”时刻:中断里调了I2C,主循环卡死了;两个传感器轮流读,数据偶尔乱掉。排查三天,最后发现是总线互斥没做好。
今天,我们用Rust来根治这个问题,而且全程不涉及晦涩的函数式编程,全是你看得懂的硬件操作


一、在C语言里,我们是怎么”凑合”共享I2C的?#

假设你有3个I2C设备:ADC(地址0x48)、DAC(地址0x60)、切档芯片(地址0x70)。它们都挂在同一条I2C总线(比如MCU的I2C1)上。

典型的C语言实现(使用FreeRTOS互斥量)#

这段C代码存在哪些隐患?#

  1. 全局耦合:所有函数都直接依赖 hi2c1i2c_mutex 这两个全局变量。如果后来你想换一个I2C总线,或在一份代码中支持多个总线,就得大量修改。
  2. 手动加锁/解锁易出错:假如 switch_channel 内部某个条件分支 return 了,就会忘记 xSemaphoreGive,导致后续所有I2C操作永久阻塞。
  3. 中断安全性缺失:如果某个中断服务程序(ISR)里也调用了这些函数,而主循环正持有锁,则ISR会等待锁,但ISR不能阻塞,于是系统直接硬故障。这种Bug在C语言里非常难复现和调试。
  4. 联动逻辑与驱动纠缠change_and_read 调用了 switch_channelread_adc,但这两个函数内部都各自加锁解锁,导致在联动期间锁被释放又重入,无法保证”切档+读取”是一个原子操作——如果另一个任务在这中间插入了DAC操作,数据就可能错乱。

这些都不是你代码写得不认真,而是C语言太”信任”你了,把一切并发责任都推给程序员。


二、Rust的第一道防线:你无法随意共享一个外设#

当你把I2C外设直接塞给两个驱动时,Rust编译器会毫不留情地报错:

let i2c = I2c1::new(...); 
let adc = AdcDriver::new(i2c); // i2c 被移动(move)到 adc
let dac = DacDriver::new(i2c); // ❌ 编译错误:i2c 的值已经被移动,无法再次使用
rust

Rust在告诉你:这个I2C外设只有一个主人(所有者)。如果你想让多个驱动都能用,你必须显式地选择一个共享策略——而不是像C语言那样,所有人都能拿到全局指针任意操作。

这就像是:C语言把厕所钥匙挂在门口,谁都能拿;Rust要求你到前台登记,说明”我借给谁,什么时间用”,并为你提供带锁的钥匙盒。


三、先搞懂一个关键概念:什么是 I2c trait?#

在Rust的 embedded-hal 生态中,有一个核心概念叫 trait(特征)。如果觉得这个词陌生,你可以把它理解为C语言里的函数指针结构体,或者更通俗地说——“USB接口规范”

类比:你的鼠标(驱动)不在乎你电脑上的是原生USB口还是USB扩展坞,只要它符合USB规范,插上去就能用。同理,一个Rust驱动(比如ADC驱动)不关心你传给它的是”真正的I2C硬件”还是”一个I2C代理”,只要它实现了 I2c 这个”规范”,驱动就能正常工作。

// 这是 embedded_hal 定义的 "规范"
pub trait I2c {
    fn read(&mut self, address: u8, buffer: &mut [u8]) -> Result<(), Error>;
    fn write(&mut self, address: u8, bytes: &[u8]) -> Result<(), Error>;
    // ...
}
rust

无论是你的MCU HAL提供的I2C外设,还是本文要介绍的 RefCellDeviceI2cDevice,它们都实现了这个 I2c trait。因此驱动可以一视同仁地接收它们。

你现在不需要深究trait的语法,只需记住三点

  1. Trait 是一种”约定”或”接口”。
  2. 多个不同的类型可以实现同一个trait。
  3. 你的驱动只依赖于trait,而不依赖具体类型,所以替换底层实现时驱动代码不用改

这就像C语言里你写 void read_adc(I2C_Ops *ops),传入不同的 ops 实现,函数内部用函数指针调用。但Rust的trait是编译时静态分发的,没有运行时开销。


四、两种共享策略,该怎么选?#

如果你觉得自己不确定该选哪个,用下面这个决策树对号入座即可:

┌─ 你的程序里是否使用了 RTOS(如 FreeRTOS)或 异步框架(如 Embassy)?

├─ 否 → 你只在 main 的 while(1) 循环里顺序执行 → 选 方案A(RefCellDevice)
│        (即使你用了中断,只要中断里不操作I2C,也算此类)

└─ 是 → 继续问:I2C 操作是否可能在硬件中断服务程序(ISR)里被调用?
        ├─ 否 → 选 方案B,用 NoopRawMutex(轻量,不关中断)
        └─ 是 → 选 方案B,用 CriticalSectionRawMutex(关中断保护)
plaintext

如果你只写了一个 while(1) 循环,没有启动RTOS,也没有在中断里调用I2C,那么你100%属于方案A的场景。

下面我们分别来看这两种方案,每次都对比C语言的做法,让你清楚Rust到底帮你解决了什么。


五、方案A详解:RefCellDevice(裸机单线程共享)#

适用场景#

  • 你的程序只有一个主循环(裸机,无RTOS)。
  • 所有I2C操作都在主循环中顺序执行。
  • 中断服务程序(ISR)中不会调用I2C。

5.1 Rust实现(仅需3步)#

5.2 映射到C语言:这相当于什么?#

如果要在C语言中实现同样的”运行时检查”机制,你可能会这样写:

// C语言手动的"借用检查"模拟
typedef struct {
    I2C_HandleTypeDef *hi2c;
    int is_borrowed;  // 0=空闲, 1=被借用
} I2cBus;

int i2c_try_borrow(I2cBus *bus) {
    if (bus->is_borrowed) return -1; // 已被占用,报错
    bus->is_borrowed = 1;
    return 0;
}

void i2c_return(I2cBus *bus) {
    bus->is_borrowed = 0;
}
c

然后你必须在每次操作前调用 i2c_try_borrow,操作后调用 i2c_return。漏掉一次,系统就可能崩溃。

Rust的 RefCell 帮你在编译时自动插入了这些检查,并且在借用结束时自动释放,不需要你手动调用任何函数。更重要的是,如果你在借用期间尝试再次借用,程序会立即 panic 并告诉你出错的位置,而不是像C语言那样悄悄产生数据错乱。

5.3 利害关系对比#

对比项C语言手动实现Rust的RefCellDevice
代码量需要手动编写标志位、检查函数,并在每个调用点加检查一行 RefCell::new + 一行 RefCellDevice::new
遗漏风险高(容易忘记调用归还函数)无(作用域结束自动释放)
冲突行为未定义(可能数据错乱)立即 panic,便于调试
中断安全不安全(标志位无法防止中断抢占)同样不安全(RefCell 不提供并发保护)
运行开销极小(一个标志位检查)极小(运行时借用检查)

结论:方案A适合裸机单线程,但如果你的程序里有任何并发(RTOS任务或中断抢占),就必须用方案B。


六、方案B详解:Mutex + I2cDevice(多任务安全共享)#

适用场景#

  • 你使用了RTOS(如FreeRTOS)或异步运行时(如Embassy)。
  • 多个任务可能同时请求I2C总线。
  • 你希望任务在总线被占用时能安全等待,而不是崩溃。

6.1 Rust实现(以Embassy为例)#

注意:如果你确定中断里也会调用I2C,那么把 NoopRawMutex 换成 CriticalSectionRawMutex,后者会在获取锁时全局关中断,确保原子性。

6.2 映射到C语言:这相当于什么?#

这就是你在FreeRTOS中所做的最标准做法——但Rust把互斥量和I2C句柄绑定在一个结构体里:

// C语言中,你通常是分开定义
I2C_HandleTypeDef hi2c1;
SemaphoreHandle_t i2c_mutex;

// 使用时:先取锁,再操作
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
HAL_I2C_Mem_Read(&hi2c1, ...);
xSemaphoreGive(i2c_mutex);
c

Rust的改进

  • 锁和总线被封装进 I2cDevice,它本身实现了 I2c trait。
  • 驱动调用它的方法时,内部自动先获取锁,再操作总线,最后释放锁
  • 你不再需要手动重复 Take/Give,且编译器保证你不会忘记释放。

6.3 利害关系对比#

对比项C语言(FreeRTOS互斥量)Rust(Mutex + I2cDevice)
锁与资源的绑定分离,容易误用(取了A锁却操作B总线)绑定,代理确保锁与总线一致
忘记解锁的风险高(尤其存在多个退出路径时)低(作用域自动释放,类似C++ RAII)
在中断中的行为可能导致死锁(如果ISR尝试Take)使用 NoopRawMutex 也会死锁;推荐 CriticalSectionRawMutex 强制关中断
等待机制阻塞任务,不占用CPU在异步环境中 .await 挂起任务,不阻塞其他任务
错误传播返回错误码需手动检查通过 Result 强制处理,否则编译警告

七、联动逻辑:在应用层组合,而不是纠缠在驱动里#

回到业务需求:切换档位后立即读取ADC

7.1 在C语言中,我们通常这么写(有隐患)#

void change_and_read(uint8_t ch, uint16_t *out) {
    switch_channel(ch);   // 内部加锁/解锁
    HAL_Delay(1);
    read_adc(out);        // 内部加锁/解锁
}
c

问题switch_channel 释放锁之后,read_adc 重新获取锁。在这两个操作之间,其他任务可能插入I2C操作(例如DAC输出),导致我们读取的ADC值可能不是”切档后立即”的状态,而是被其他任务干扰后的值。

7.2 正确的Rust做法:驱动无锁,应用层统一加锁#

架构原则

  • 驱动层AdcDriverDacDriver)只提供基础读写操作,不自行加锁
  • 应用层MyApp)持有总线代理,负责编排多个驱动操作并管理锁的范围。

7.3 最佳实践:完整的 main 函数骨架#

下面是一个可直接参考的完整代码结构(基于Embassy异步运行时):

这个骨架让你一目了然地看到:驱动、总线共享、应用逻辑各自在什么位置,彼此如何组合。


八、给新人的定心丸:遇到编译错误怎么办?#

常见错误1:use of moved value#

let i2c = I2c1::new(...);
let adc = AdcDriver::new(i2c);
let dac = DacDriver::new(i2c); // ❌ 编译错误
rust

映射到C语言:这就好比你把 hi2c1 的地址传给了ADC驱动,然后DAC驱动也想用同一个地址,但C语言允许这样做,导致两者可能互相覆盖。Rust 拒绝这种不明确的行为

解决方法:改用本文的 RefCellDevice(方案A)或 I2cDevice(方案B)来共享。

常见错误2:cannot borrow data in a &RefCell as mutable#

当你试图在异步 .await 点持有 RefCell 的借用时,可能触发运行时 panic

解决方法:换成基于 Mutex 的方案B。

常见错误3:类型不匹配(如 expected I2c, found RefCellDevice#

这种错误通常是因为你在驱动定义中写了具体的类型,而不是泛型。确保你的驱动定义是:

pub struct AdcDriver<I2C, const ADDR: u8> {  // 使用泛型 I2C
    i2c: I2C,
}
impl<I2C, const ADDR: u8> AdcDriver<I2C, ADDR>
where
    I2C: embedded_hal::i2c::I2c,  // 约束:必须实现 I2c trait
{ ... }
rust

这样无论是原始I2C还是代理,都可以传进去。


九、小细节:为什么驱动里用 const ADDR: u8 而不是字段?#

在驱动定义中,你可能会看到:

pub struct AdcDriver<I2C, const ADDR: u8> { ... }
rust

新人可能会问:“为什么不直接传一个 addr: u8 参数?”

解释const ADDR: u8 是”编译时确定的常量地址”,它不占用RAM空间,且在编译时直接被硬编码进汇编指令(MOV R0, #0x48),速度更快。你可以理解为C语言的 #define DEVICE_ADDR 0x48,但它是类型安全的,不会意外被修改。


十、总结:Rust让你把精力花在业务上,而不是并发Bug上#

问题C语言中的代价Rust中的解决方案
资源所有权不清全局变量满天飞,修改费力所有权系统,强制明确所有者
忘记解锁/释放死锁,随机卡死作用域结束自动释放,编译器保障
中断安全需要手动关中断,容易遗漏提供 CriticalSectionRawMutex 等明确选项
驱动与应用耦合驱动自行加锁,难以组合原子操作驱动无锁,应用层统一编排
多任务并发依赖RTOS信号量,手动管理异步Mutex,.await自动让出CPU

Rust并没有让你做更多的事,而是让你把以前”藏在心里”的并发策略,用代码清晰地表达出来。 这种表达力,让你在写代码时就能避免许多运行时的噩梦。


下一步#

  • 如果你的项目是裸机单线程,直接抄方案A的3步代码,再按”完整骨架”组织应用层。
  • 如果你使用RTOS或Embassy,采用方案B,注意根据是否在中断中使用I2C选择合适的 RawMutex
  • 不必一次消化所有细节。先把示例代码跑通,再逐步理解背后的设计思想。

把这篇文章收藏起来,下次调试I2C卡死的时候,你会感谢今天的选择。 😊


扩展阅读#

从C语言到Rust:再也不用担心I2C总线被抢疯了!
https://glinfei.space/blog/rust/%E5%85%B1%E4%BA%ABi2c
Author 甘霖飞
Published at 2026年9月3日
Comment seems to stuck. Try to refresh?✨