ESP32-S3 定时器中断

ESP32-S3 定时器中断 --- Rust 嵌入式入门教程

作者:CXi

开发板:ESP32-S3R8N8(嘉立创)

框架:esp-hal v1.1(Rust 官方嵌入式 HAL)


目录

  1. 项目简介
  2. 硬件准备
  3. 开发环境搭建
  4. 项目结构总览
  5. [Cargo.toml 依赖说明](#Cargo.toml 依赖说明)
  6. 完整代码逐行解析
  7. 核心概念详解
  8. 编译与烧录
  9. 运行效果
  10. 常见问题

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,初始化后再 replaceSome(外设)

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_cellembassy 框架,它们用更高级的抽象来简化这个模式。但理解底层原理对学习很有帮助。


8. 编译与烧录

8.1 编译

bash 复制代码
cd Rust_ESP32_Dome/examples/定时器
cargo build

8.2 烧录并运行

bash 复制代码
cargo run

cargo run 会自动完成三件事:

  1. 编译项目
  2. 通过 USB 将固件烧录到 ESP32-S3
  3. 启动 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 不闪 / 没有日志输出

  1. 检查 LED 引脚是否正确(嘉立创 S3 开发板一般是 GPIO48)
  2. 确认 USB 线支持数据传输(不是纯充电线)
  3. 尝试按一下开发板的 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 支持!

相关推荐
电气_空空1 小时前
基于 LabVIEW 的深海气密采水器测控系统
单片机·嵌入式硬件·毕业设计·labview
星夜夏空992 小时前
STM32单片机学习(37) —— PWR和BKP
stm32·单片机·学习
牛牛,牛2 小时前
榨干最后一微安:STM32 的低功耗设计与中断唤醒机制深度剖析
单片机·嵌入式硬件
星华云3 小时前
[STM32] SAR型ADC(逐次逼近型ADC)工作原理简介
stm32·单片机·嵌入式硬件
小娄~~3 小时前
时钟控制器原理
单片机·嵌入式硬件
望眼欲穿的程序猿4 小时前
按键控制 LED
嵌入式硬件·rust
天天爱吃肉82184 小时前
豆包 vs DeepSeek API 对比分析报告
android·java·大数据·开发语言·功能测试·嵌入式硬件·汽车
我命由我123455 小时前
RFID 技术极简理解
java·c语言·c++·嵌入式硬件·物联网·visualstudio·java-ee