从C语言到Rust:再也不用担心I2C总线被抢疯了!#
如果你以前用C语言写STM32,一定经历过这种”玄学”时刻:中断里调了I2C,主循环卡死了;两个传感器轮流读,数据偶尔乱掉。排查三天,最后发现是总线互斥没做好。
今天,我们用Rust来根治这个问题,而且全程不涉及晦涩的函数式编程,全是你看得懂的硬件操作。
一、在C语言里,我们是怎么”凑合”共享I2C的?#
假设你有3个I2C设备:ADC(地址0x48)、DAC(地址0x60)、切档芯片(地址0x70)。它们都挂在同一条I2C总线(比如MCU的I2C1)上。
典型的C语言实现(使用FreeRTOS互斥量)#
// 全局的I2C句柄和互斥量
I2C_HandleTypeDef hi2c1;
SemaphoreHandle_t i2c_mutex;
// ADC读取函数
void read_adc(uint16_t *val) {
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
HAL_I2C_Mem_Read(&hi2c1, 0x48, 0x00, I2C_MEMADD_SIZE_8BIT, (uint8_t*)val, 2, 100);
xSemaphoreGive(i2c_mutex);
}
// DAC写入函数
void set_dac(uint16_t val) {
uint8_t buf[2] = {val >> 8, val & 0xFF};
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
HAL_I2C_Mem_Write(&hi2c1, 0x60, 0x00, I2C_MEMADD_SIZE_8BIT, buf, 2, 100);
xSemaphoreGive(i2c_mutex);
}
// 切档函数
void switch_channel(uint8_t ch) {
xSemaphoreTake(i2c_mutex, portMAX_DELAY);
HAL_I2C_Mem_Write(&hi2c1, 0x70, 0x01, I2C_MEMADD_SIZE_8BIT, &ch, 1, 100);
xSemaphoreGive(i2c_mutex);
}
// 联动功能:切换档位并读取ADC
void change_and_read(uint8_t ch, uint16_t *out) {
switch_channel(ch);
HAL_Delay(1); // 等待信号稳定
read_adc(out);
}c这段C代码存在哪些隐患?#
- 全局耦合:所有函数都直接依赖
hi2c1和i2c_mutex这两个全局变量。如果后来你想换一个I2C总线,或在一份代码中支持多个总线,就得大量修改。 - 手动加锁/解锁易出错:假如
switch_channel内部某个条件分支return了,就会忘记xSemaphoreGive,导致后续所有I2C操作永久阻塞。 - 中断安全性缺失:如果某个中断服务程序(ISR)里也调用了这些函数,而主循环正持有锁,则ISR会等待锁,但ISR不能阻塞,于是系统直接硬故障。这种Bug在C语言里非常难复现和调试。
- 联动逻辑与驱动纠缠:
change_and_read调用了switch_channel和read_adc,但这两个函数内部都各自加锁解锁,导致在联动期间锁被释放又重入,无法保证”切档+读取”是一个原子操作——如果另一个任务在这中间插入了DAC操作,数据就可能错乱。
这些都不是你代码写得不认真,而是C语言太”信任”你了,把一切并发责任都推给程序员。
二、Rust的第一道防线:你无法随意共享一个外设#
当你把I2C外设直接塞给两个驱动时,Rust编译器会毫不留情地报错:
let i2c = I2c1::new(...);
let adc = AdcDriver::new(i2c); // i2c 被移动(move)到 adc
let dac = DacDriver::new(i2c); // ❌ 编译错误:i2c 的值已经被移动,无法再次使用rustRust在告诉你:这个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外设,还是本文要介绍的 RefCellDevice 和 I2cDevice,它们都实现了这个 I2c trait。因此驱动可以一视同仁地接收它们。
你现在不需要深究trait的语法,只需记住三点:
- Trait 是一种”约定”或”接口”。
- 多个不同的类型可以实现同一个trait。
- 你的驱动只依赖于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步)#
use core::cell::RefCell;
use embedded_hal_bus::i2c::RefCellDevice;
// 第1步:初始化I2C外设,并放入 RefCell
let i2c = I2c1::new(/* ... */);
let i2c_refcell = RefCell::new(i2c); // 把钥匙锁进盒子
// 第2步:为每个设备创建"代理"(Proxy)——相当于给每个人发一张门禁卡
let adc_proxy = RefCellDevice::new(&i2c_refcell);
let dac_proxy = RefCellDevice::new(&i2c_refcell);
let switch_proxy = RefCellDevice::new(&i2c_refcell);
// 第3步:把代理传给驱动(驱动只认 I2c trait,不关心它是不是代理)
let mut adc = AdcDriver::<_, 0x48>::new(adc_proxy);
let mut dac = DacDriver::<_, 0x60>::new(dac_proxy);
let mut switch = SwitchDriver::<_, 0x70>::new(switch_proxy);rust5.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为例)#
use embassy_sync::mutex::Mutex;
use embassy_sync::blocking_mutex::raw::NoopRawMutex; // 或 CriticalSectionRawMutex
use embassy_embedded_hal::shared_bus::asynch::i2c::I2cDevice;
use static_cell::StaticCell;
// 第1步:创建静态的 Mutex 容器(生命周期 'static)
static I2C_BUS: StaticCell<Mutex<NoopRawMutex, I2c<'static, ...>>> = StaticCell::new();
let i2c = I2c::new(/* ... */);
let i2c_mutex = I2C_BUS.init(Mutex::new(i2c));
// 第2步:为每个设备生成带锁的代理
let adc_bus = I2cDevice::new(i2c_mutex);
let dac_bus = I2cDevice::new(i2c_mutex);
let switch_bus = I2cDevice::new(i2c_mutex);
// 第3步:传给驱动(用法和方案A完全一样!)
let mut adc = AdcDriver::new(adc_bus);
let mut dac = DacDriver::new(dac_bus);
let mut switch = SwitchDriver::new(switch_bus);rust注意:如果你确定中断里也会调用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);cRust的改进:
- 锁和总线被封装进
I2cDevice,它本身实现了I2ctrait。 - 驱动调用它的方法时,内部自动先获取锁,再操作总线,最后释放锁。
- 你不再需要手动重复
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做法:驱动无锁,应用层统一加锁#
架构原则:
- 驱动层(
AdcDriver、DacDriver)只提供基础读写操作,不自行加锁。 - 应用层(
MyApp)持有总线代理,负责编排多个驱动操作并管理锁的范围。
// 驱动层:不包含任何锁逻辑,只操作 I2c trait
impl<I2C, const ADDR: u8> AdcDriver<I2C, ADDR>
where
I2C: embedded_hal_async::i2c::I2c, // 使用异步trait
{
pub async fn read(&mut self) -> Result<u16, Error> {
let mut buf = [0u8; 2];
self.i2c.write_read(ADDR, &[0x00], &mut buf).await?;
Ok(u16::from_be_bytes(buf))
}
}
// 应用层:统一加锁,保证原子性
struct MyApp<'a> {
adc: AdcDriver<I2cDevice<'a, ...>, 0x48>,
dac: DacDriver<I2cDevice<'a, ...>, 0x60>,
switch: SwitchDriver<I2cDevice<'a, ...>, 0x70>,
// 注意:这里不直接持有总线锁,而是通过驱动内部代理获得
}
impl<'a> MyApp<'a> {
pub async fn change_and_read(&mut self, ch: u8) -> Result<u16, Error> {
// 注意:每个驱动内部的代理已经封装了 Mutex,但它们是独立加锁的
// 如果我们想保证"切档+延时+读ADC"的原子性,需要在更上层加锁
// 方式1:让驱动提供"使用已锁总线"的方法(高级用法,略)
// 方式2:将联动逻辑放在一个单独的任务中,通过Channel串行化(推荐)
// 方式3:在应用层显式获取一个总线锁(假设驱动提供了 get_bus() 方法)
// 这里展示最常用的方式:单独创建一个"协调任务",通过消息传递串行化
// 具体实现见下方的"完整代码骨架"
unimplemented!()
}
}rust7.3 最佳实践:完整的 main 函数骨架#
下面是一个可直接参考的完整代码结构(基于Embassy异步运行时):
use embassy_executor::Spawner;
use embassy_time::Timer;
// ---------- 1. 定义应用结构体 ----------
struct MyApp {
adc: AdcDriver<...>,
dac: DacDriver<...>,
switch: SwitchDriver<...>,
}
impl MyApp {
// 联动函数:切档并读取ADC(注意原子性)
pub async fn change_and_read(&mut self, ch: u8) -> Result<u16, Error> {
// 直接调用驱动方法——如果驱动内部使用 I2cDevice(自带Mutex),
// 那么每次调用都会独立加锁/解锁。
// 如果担心两个操作之间被其他任务插入,可以改用 Channel 串行化。
self.switch.set_channel(ch).await?;
Timer::after_millis(1).await; // 等待信号稳定
self.adc.read().await
}
pub async fn set_dac(&mut self, val: u16) -> Result<(), Error> {
self.dac.write(val).await
}
}
// ---------- 2. 主函数 ----------
#[embassy_executor::main]
async fn main(spawner: Spawner) {
// 初始化硬件 (I2C, GPIO等)
let p = embassy_stm32::init(/* ... */);
let i2c = I2c::new(p.I2C1, p.PB6, p.PB7, 100.kHz(), Default::default());
// 创建共享总线(使用方案B的 Mutex)
let i2c_mutex = /* 参考第六节 */;
// 为每个设备生成代理
let adc_bus = I2cDevice::new(&i2c_mutex);
let dac_bus = I2cDevice::new(&i2c_mutex);
let switch_bus = I2cDevice::new(&i2c_mutex);
// 实例化驱动
let adc = AdcDriver::new(adc_bus);
let dac = DacDriver::new(dac_bus);
let switch = SwitchDriver::new(switch_bus);
// 构建应用层协调结构体
let app = MyApp { adc, dac, switch };
// 启动主任务(把 app 传进去)
spawner.spawn(app_task(app)).unwrap();
}
// ---------- 3. 主任务 ----------
#[embassy_executor::task]
async fn app_task(mut app: MyApp) {
loop {
// 每隔1秒切一次档并读取ADC
let value = app.change_and_read(2).await.unwrap();
log::info!("ADC value after switch: {}", value);
// 同时DAC也在独立更新
app.set_dac(2048).await.unwrap();
Timer::after_secs(1).await;
}
}rust这个骨架让你一目了然地看到:驱动、总线共享、应用逻辑各自在什么位置,彼此如何组合。
八、给新人的定心丸:遇到编译错误怎么办?#
常见错误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卡死的时候,你会感谢今天的选择。 😊
扩展阅读#
- embedded-hal 官方文档 ↗ - 了解所有硬件抽象trait
- embassy-rs 官网 ↗ - 异步嵌入式框架
- 嵌入式Rust书籍 ↗ - 官方入门指南