ESP32-S3 定时器中断 --- Rust 嵌入式入门教程
作者:CXi
开发板:ESP32-S3R8N8(嘉立创)
框架:esp-hal v1.1(Rust 官方嵌入式 HAL)
目录
1. 项目简介
本教程将带你用 Rust 在 ESP32-S3 上实现一个最经典的嵌入式入门案例:
每 1 秒触发一次定时器中断,翻转板载 LED 的状态。
虽然功能简单,但它完整覆盖了嵌入式开发的几个核心知识点:
- GPIO 输出控制
- 定时器配置与中断
- 全局资源共享与互斥(
critical_section) - 裸机程序的执行流程
2. 硬件准备
| 项目 | 说明 |
|---|---|
| 开发板 | 嘉立创 ESP32-S3R8N8(8MB Flash、8MB PSRAM) |
| 板载 LED | GPIO48(大多数嘉立创 S3 开发板都接在这个引脚) |
| 下载线 | USB Type-C(同时用于供电和烧录) |
💡 如果你用的是其他 ESP32-S3 开发板,LED 引脚可能不同,请查阅原理图修改代码中的
GPIO48。
3. 开发环境搭建
3.1 安装 Rust
bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
3.2 安装 Xtensa 工具链(ESP32-S3 专用)
ESP32-S3 使用的是 Xtensa 架构,不是 Rust 默认支持的,需要额外安装:
bash
# 安装 espup(Espressif 官方工具链管理器)
cargo install espup
# 一键安装 Xtensa 工具链
espup install
安装完成后,重新打开终端或执行:
bash
source ~/export-esp.sh
3.3 安装 probe-rs(烧录工具)
bash
cargo install probe-rs --features cli
3.4 克隆项目
bash
git clone https://github.com/cx693/Rust_ESP32_Dome.git
cd Rust_ESP32_Dome/examples/定时器
4. 项目结构总览
定时器/
├── .cargo/
│ └── config.toml # Cargo 编译配置(目标芯片、烧录器)
├── src/
│ ├── lib.rs # 库入口(声明 no_std)
│ └── bin/
│ └── main.rs # 主程序(全部逻辑在这里)
├── build.rs # 构建脚本(链接脚本配置)
├── Cargo.toml # 依赖声明
└── rust-toolchain.toml # 锁定 Rust 工具链版本
对初学者来说,你只需要关注 src/bin/main.rs,其他文件是 esp-hal 模板自动生成的。
5. Cargo.toml 依赖说明
toml
[dependencies]
esp-hal = { version = "~1.1.0", features = ["esp32s3", "unstable"] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32s3"] }
critical-section = "1.2.0"
panic-rtt-target = "0.2.0"
rtt-target = "0.6.2"
| 依赖 | 作用 |
|---|---|
esp-hal |
ESP 芯片的硬件抽象层,提供 GPIO、定时器等驱动 |
esp-bootloader-esp-idf |
提供 IDF bootloader 启动流程和 esp_app_desc! 宏 |
critical-section |
跨中断的安全互斥原语(关中断保护共享资源) |
panic-rtt-target |
panic 时通过 RTT 输出错误信息 |
rtt-target |
RTT 调试打印(替代串口 println) |
💡 什么是 RTT?
RTT(Real-Time Transfer)是 Segger 提出的一种调试输出技术,通过内存缓冲区实现零延迟打印,比串口更可靠、更快。需要配合 J-Link 或内置 USB-JTAG 使用。
6. 完整代码逐行解析
6.1 文件头与导入
rust
#![no_main] // 告诉编译器:没有标准入口点(裸机没有 OS)
#![no_std] // 告诉编译器:不使用标准库(没有堆、没有文件系统等)
use core::cell::RefCell;
use critical_section::Mutex;
use esp_hal::{
gpio::{Level, Output, OutputConfig}, // GPIO 输出相关
handler, main, // 中断处理和主函数宏
time::Duration, // 时间单位
timer::{Timer as _, timg::TimerGroup}, // 定时器
};
use panic_rtt_target as _; // panic 处理(通过 RTT 输出)
use rtt_target::{rprintln, rtt_init_print}; // RTT 打印宏
#![no_main] 和 #![no_std] 是嵌入式 Rust 的两个固定标记:
no_std--- 芯片上没有操作系统,不能用std(没有println!、没有Vec、没有文件 I/O)no_main--- 入口函数不是标准的fn main(),而是由#[main]宏指定
Timer as _ --- 导入 Timer trait 但不给它命名。这样做是为了让 timer.start()、timer.clear_interrupt() 等方法可用,而不会和具体类型名冲突。
6.2 全局静态资源
rust
static LED: Mutex<RefCell<Option<Output<'static>>>> =
Mutex::new(RefCell::new(None));
static TIMER: Mutex<RefCell<Option<esp_hal::timer::timg::Timer<'static>>>> =
Mutex::new(RefCell::new(None));
为什么需要这么复杂的类型? 让我们逐层拆解:
Mutex<RefCell<Option<T>>>
│ │ │
│ │ └─ Option: 初始为 None,初始化后才变成 Some(外设)
│ └─ RefCell: 运行时借用检查(内部可变性)
└─ Mutex: 关中断实现互斥访问
在 Rust 中,中断处理函数和 main 函数是"两个独立的执行上下文"。如果它们同时操作同一个 GPIO,就可能产生数据竞争。Mutex 保证同一时刻只有一个能访问。
为什么用 Option? 因为 Rust 的 static 变量必须在编译期确定值,而外设(GPIO、定时器)需要在运行时初始化。所以先放 None,初始化后再 replace 为 Some(外设)。
6.3 中断处理函数
rust
#[handler] // esp-hal 的宏,标记这是中断处理函数
fn timer_handler() {
critical_section::with(|cs| { // 进入临界区(临时关中断)
// ① 清除中断标志(必须!否则中断会一直重复触发)
if let Some(timer) = TIMER.borrow(cs).borrow().as_ref() {
timer.clear_interrupt();
}
// ② 翻转 LED 电平
if let Some(led) = LED.borrow(cs).borrow_mut().as_mut() {
led.toggle();
}
}); // 离开临界区(恢复中断)
}
执行流程:
定时器计数到 0
↓
CPU 自动跳转到 timer_handler
↓
critical_section 暂时关闭所有中断
↓
┌─ 清除中断标志(防止重复触发)
├─ 翻转 LED
└─ 恢复中断
↓
返回 main 继续执行
⚠️
clear_interrupt()必须做! 如果不清除中断标志,CPU 会认为中断还没处理完,离开处理函数后立刻再次触发,陷入无限循环。
6.4 主函数
rust
#[main] // esp-hal 的入口宏(完成芯片初始化后调用此函数)
fn main() -> ! { // -> ! 表示永不返回(裸机程序没有"退出"概念)
rtt_init_print!();
rprintln!("定时器中断示例 - 每1秒翻转LED");
// 初始化 esp-hal(时钟、GPIO 复用等底层配置)
let peripherals = esp_hal::init(esp_hal::Config::default());
peripherals 是什么? 它是一个"外设单例",包含了芯片上所有可用的外设(GPIO、定时器、SPI、I2C 等)。Rust 的所有权系统保证每个外设只能被一个变量持有,从编译期就杜绝了"两个地方同时操作同一个外设"的问题。
6.5 第一步:初始化外设
rust
// GPIO48 设为输出模式,初始低电平(LED 灭)
let led = Output::new(peripherals.GPIO48, Level::Low, OutputConfig::default());
// TimerGroup 0:ESP32-S3 有两组定时器(TIMG0、TIMG1)
// 每组包含多个定时器,这里取 TIMG0 的 timer0
let timg0 = TimerGroup::new(peripherals.TIMG0);
let timer = timg0.timer0;
6.6 第二步:配置定时器
rust
// 自动重载:到期后自动重新计时(否则只触发一次就停了)
timer.enable_auto_reload(true);
// 装载值:定时器从 1 秒开始倒数,到 0 时触发中断
timer.load_value(Duration::from_secs(1)).unwrap();
// 绑定中断处理函数
timer.set_interrupt_handler(timer_handler);
6.7 第三步:启动(关键的竞态防护)
rust
// 整个操作在 critical_section 内完成
critical_section::with(|cs| {
// 将外设移入全局静态变量
LED.borrow(cs).replace(Some(led));
TIMER.borrow(cs).replace(Some(timer));
// 现在中断处理函数已经能访问到外设了,安全地启动
if let Some(timer) = TIMER.borrow(cs).borrow().as_ref() {
timer.enable_interrupt(true); // 开启中断
timer.start(); // 启动定时器
}
});
为什么必须放在 critical_section 里?
想象一下如果不关中断:
main: 移动 LED 到全局变量...
↓ ← 此时定时器中断触发!
ISR: 读取 TIMER → None!(还没来得及放进去)
↓ ← panic 或者跳过操作
main: 移动 TIMER 到全局变量...
main: 启动定时器...
关中断后,"放资源"和"启动"变成一个不可分割的原子操作,避免了这种竞态条件。
6.8 主循环
rust
rprintln!("定时器已启动,LED 每秒翻转一次");
// 裸机程序的 main 不能返回,CPU 在此空转等待中断
loop {}
}
loop {} 就是死循环。CPU 不需要做任何事,所有工作都由中断驱动。这是嵌入式编程的经典模式------事件驱动。
7. 核心概念详解
7.1 中断 vs 轮询
| 模式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 轮询 | loop { if 时间到了 { 翻转LED } } |
简单 | CPU 一直在忙,浪费功耗 |
| 中断 | 定时器到了自动触发处理函数 | CPU 可以空转/休眠,省电 | 代码稍复杂 |
本例使用中断模式 ------CPU 大部分时间在 loop {} 里空转,定时器到期时硬件自动打断 CPU 执行处理函数。
7.2 critical_section 原理
正常执行流:
main() → 正在操作 LED → 正在操作 TIMER → ...
中断插入:
main() → 正在操作 LED → 【定时器中断!】→ ISR() → ...
↑ 这里如果 main 还没操作完,
ISR 又去操作同一个资源,就出问题了
critical_section::with 的做法很简单:临时关闭中断,执行完再恢复。这样 ISR 就不可能在操作中途插入。
7.3 Mutex<RefCell<Option<T>>> 模式
这是 Rust 嵌入式开发中最常见的"全局共享外设"模式:
rust
// 声明
static LED: Mutex<RefCell<Option<Output<'static>>>> = Mutex::new(RefCell::new(None));
// main 中初始化并放入
critical_section::with(|cs| {
LED.borrow(cs).replace(Some(led));
});
// ISR 中取出并操作
critical_section::with(|cs| {
if let Some(led) = LED.borrow(cs).borrow_mut().as_mut() {
led.toggle();
}
});
💡 在更复杂的项目中,你可能会看到
static_cell或embassy框架,它们用更高级的抽象来简化这个模式。但理解底层原理对学习很有帮助。
8. 编译与烧录
8.1 编译
bash
cd Rust_ESP32_Dome/examples/定时器
cargo build
8.2 烧录并运行
bash
cargo run
cargo run 会自动完成三件事:
- 编译项目
- 通过 USB 将固件烧录到 ESP32-S3
- 启动 RTT 日志输出
💡 确保开发板已通过 USB 连接到电脑,且
probe-rs能识别到设备。
8.3 只看日志(不重新烧录)
bash
cargo run -- --no-flashing
9. 运行效果
烧录成功后,你会看到:
终端输出:
定时器中断示例 - 每1秒翻转LED
定时器已启动,LED 每秒翻转一次
板载 LED: 每隔 1 秒亮灭交替。
LED: ●━━━━━━━━━━━●━━━━━━━━━━━●━━━━━━━━━━━●
时间: 0s 1s 2s 3s
亮 灭 亮 灭
10. 常见问题
Q: 编译报错 crate name is not a valid ASCII identifier
Cargo.toml 中的 name 不能用中文,改成英文即可:
toml
name = "timer-example"
Q: LED 不闪 / 没有日志输出
- 检查 LED 引脚是否正确(嘉立创 S3 开发板一般是 GPIO48)
- 确认 USB 线支持数据传输(不是纯充电线)
- 尝试按一下开发板的 RST 按键
Q: 怎么改闪烁频率?
修改 load_value 的参数:
rust
timer.load_value(Duration::from_millis(500)).unwrap(); // 500ms = 0.5秒
timer.load_value(Duration::from_secs(2)).unwrap(); // 2秒
Q: 怎么用其他 GPIO?
rust
// 例如改用 GPIO2
let led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
参考资料
作者:CXi
项目地址: https://github.com/cx693/Rust_ESP32_Dome
如果这篇教程对你有帮助,欢迎点个 ⭐ Star 支持!