本项目使用 SAME51J20A Curiosity Nano 开发板,实现了触摸控制呼吸灯,可以通过电容触摸模块实现左滑、右滑检测,从而控制 WS2812B LED 呼吸灯的速率,并且在 OLED 上显示当前速度。
硬件
SAME51J20A Curiosity Nano
开发板为 SAME51J20A Curiosity Nano,它包含一个 SAME51J20A MCU:
- Cortex-M4F 内核
- 120 MHz 运行频率
- 1024 KB Flash
- 256 KB SRAM
- I²C / SPI / UART / … 丰富外设
同时还有板载的调试器,到手即用,不需要买额外的调试器。
触摸模块
触摸模块使用基于TTP224 的四输入电容输入模块。
它有 4 个电容触摸输入,对应到 4 个 GPIO。右滑的时候,1 2 3 4 号引脚会依次变成高电平,左右就是 4 3 2 1。
WS2812B LED
LED 呼吸灯使用 WS2812B LED,比开发板上的 LED 效果更好,能实现更多的颜色。
OLED 屏幕
OLED 屏幕使用 SSD1306 驱动,I2C 接口,分辨率为128*64。
软件
模块
代码分成 5 个模块:
- Speed 模块:保存当前呼吸灯的速率,供其他模块获取和修改
- Tick 模块:保存当前的时间,基于 SysTick 实现,延时和呼吸灯都需要根据时间来实现
- Touch 模块:检测左滑和右滑,调用 Speed 模块更新速率
- WS2812B 模块:根据当前时间和速率,调节 LED 亮度实现呼吸灯
- OLED 模块:把当前速率显示在 OLED 上
Speed 模块
Speed 模块保存了一个速度字,其他模块可以调用Speed 模块的方法来获取速度、修改速度。
impl Speed {
fn new() -> Self {
Self {
speed: RefCell::new(1.0),
}
}
// 获取速度
fn get(&self) -> f64 {
*self.speed.borrow()
}
// 加快速度,最大 10
fn increase(&self) {
let new_speed = *self.speed.borrow() + 1.0;
if new_speed <= 10.0 {
self.speed.replace(new_speed);
}
}
// 减慢速度,最小 1
fn decrease(&self) {
let new_speed = *self.speed.borrow() - 1.0;
if new_speed >= 1.0 {
self.speed.replace(new_speed);
}
}
}
Tick 模块
Tick 模块类似 HAL_GetTick,记录了上电后经过的毫秒数。
首先配置 SysTick,让它 1ms 触发一次中断:
pub fn init_tick(mut syst: cortex_m::peripheral::SYST) {
syst.set_reload(120 * 1000); // 1ms, assuming clock is 120MHz
syst.set_clock_source(cortex_m::peripheral::syst::SystClkSource::Core);
syst.enable_counter();
syst.enable_interrupt();
}
在中断处理函数里,对全局变量 TICK 加1:
static TICK: AtomicU32 = AtomicU32::new(0);
#[exception]
fn SysTick() {
TICK.fetch_add(1, Relaxed);
}
其他模块可以通过 get_tick 获取已经经过的毫秒数,作为当前时间:
pub fn get_tick() -> u32 {
TICK.load(Relaxed)
}
任务
Touch 模块,WS2812B 模块,OLED 模块可以对应到 3 个独立的任务,每个任务都是循环:
下面分别介绍这三个任务。
WS2812B 任务
WS2812B 任务在一个循环里执行:
- 从 Speed 模块获取当前速率
- 从 Tick 模块获取当前时间
- 计算 LED 亮度
- 发送 WS2812B Reset 信号
- 发送 RGB 值
let mut ws2812b_loop = async {
loop {
// 获取速率
let speed = speed.get();
// 获取时间
let tick = get_tick() as f64 / 1000.0 * speed;
// 计算亮度
let brightness = (sin(tick) * 128.0 + 128.0) as u8;
// 要显示的 LED 颜色
let leds = [
RGB { r: 255, g: 0, b: 0 },
RGB { r: 0, g: 255, b: 0 },
RGB { r: 0, g: 0, b: 255 },
];
// 发送 Reset
ws2812b.reset().await;
// 发送 LED
for led in leds {
let rgb = led.with_brightness(brightness);
ws2812b.write_rgb(rgb);
}
}
};
其中 WS2812B 的 Reset 只需要 80us 时间的低电平,但是这里实际延时了 1ms,主要是顺便做任务切换:
pub async fn reset(&mut self) {
self.pin.set_low().unwrap();
delay_ms(1).await
}
OLED 任务
OLED 任务和 WS2812B 任务类似:
- 获取当前速率
- 清空缓冲
- 画图
- 刷写缓冲
- 延时
loop {
// 获取速率
let speed = speed.get() as u32;
// 清空缓冲
display.clear_buffer();
// 画一个长方形
Rectangle::new(Point::new(0, 0), Size::new(4 + 12 * speed, 16))
.draw_styled(&filled, &mut display)
.unwrap();
// 写数字
let speed_text = get_speed_text(speed);
Text::with_alignment(speed_text, Point::new(64, 32), text_style, Alignment::Right)
.draw(&mut display)
.unwrap();
// 刷写缓冲
if let Err(e) = display.flush() {
rprintln!("flush error: {:?}", e);
}
// 延时
delay::delay_ms(50).await;
}
Touch 任务
Touch 任务最为复杂,它的顶层逻辑是在循环里:
- 检测左滑
- 如果检测到左滑,就调用 Speed 模块降低速率
- 检测右滑
- 如果检查到右滑,就调用 Speed 模块加快速率
let mut touchpad_loop = async {
loop {
if touchpad.is_left_swipe().await { // 左滑
speed.decrease(); // 降低速率
}
if touchpad.is_right_swipe().await { // 右滑
speed.increase(); // 加快速率
}
}
};
其中的左滑和右滑检测是这样的:
右滑检测是需要依此检查 1 2 3 4 号引脚,例如检查 1 号引脚:
- 读 1 号引脚
- 如果 1 号引脚是高电平,说明手触摸了 1 号输入,循环读取 1 号电平,直到它变成低电平
pub async fn is_right_swipe(&mut self) -> bool {
let mut swipe = false;
// pin 1
if self.pin1.get().await { // 高电平
swipe = true;
while self.pin1.get().await {} // 循环直到低电平
}
if !swipe {
return swipe;
}
// pin 2
swipe = false;
if self.pin2.get().await { // 高电平
swipe = true;
while self.pin2.get().await {} // 循环直到低电平
}
if !swipe {
return swipe;
}
// pin 3
...
// pin 4
...
swipe
}
这里面的每次读引脚都是做了去抖的,每次去抖都是:读引脚,延时10ms,再读一次引脚。而且在检查到高电平的时候会进入循环,直到用户把手指移开。如果用户一直把手放在上面,就会阻塞其他任务的执行。所以这里不能使用循环来实现延时。
状态机
通常延时的实现方法:
- 循环:但是这样会阻塞,浪费了 CPU 时间而且不能执行其他任务
- 手写状态机:通过状态机实现延时,但是实现会很复杂,像上面的滑动检测需要非常多的状态,很难设计
有没有办法轻松愉快地写状态机呢?有的,就是让编译器自动生成状态机,在 Rust 里可以通过 async/await 实现。
首先实现延时:
struct Delay {
delay: u32,
prev_tick: u32,
}
impl Future for Delay {
type Output = ();
fn poll(
self: core::pin::Pin<&mut Self>,
cx: &mut core::task::Context<'_>,
) -> core::task::Poll<Self::Output> {
cx.waker().wake_by_ref();
let tick = get_tick(); // 获取当前时间
if tick - self.prev_tick >= self.delay { // 当前时间 - 开始时间 >= 延时时间
Poll::Ready(())
} else {
Poll::Pending
}
}
}
pub async fn delay_ms(n: u32) {
let delay = Delay {
delay: n,
prev_tick: get_tick(),
};
delay.await;
}
核心逻辑就是:获取当前时间,如果延时到了,就返回 Ready,如果没到就返回 Pending。可以把它想象成状态机里的一步状态转移,如果延时没到就返回 Pending,如果到了就返回 Ready。编译器会自动把它转换成一个状态机。
实现按键去抖动就可以像阻塞代码一样:
pub struct DebouncedPin<I: PinId> {
state: bool,
pin: Pin<I, Input<PullDown>>,
}
impl<I: PinId> DebouncedPin<I> {
pub fn new(pin: Pin<I, Input<PullDown>>) -> Self {
Self { state: false, pin }
}
pub async fn get(&mut self) -> bool {
let v1 = self.pin.is_high().unwrap(); // 读一次引脚
delay_ms(10).await; // 延时 10ms
let v2 = self.pin.is_high().unwrap(); // 再读一次引脚
if v1 == v2 && self.state != v1 {
self.state = v1;
}
self.state
}
}
核心逻辑都是:读一次引脚,延时 10ms,再读一次引脚,但是编译器会把他编译成一个状态机,而且里面会嵌套一个 delay 的状态机器。
回到最初的三个任务,其实编译器可以把它们转换成 3 个状态机:
图里每个圆圈表示一个状态机,状态机里可以不断嵌套状态机。要运行这些状态机,只要:
- 从列表里获取一个状态机
- 跳转到它当前的状态并执行代码
- 如果返回 Ready 就迁移到下一个状态继续执行,直到 Pending
- 如果返回 Pending 就回到第一步,获取下一个状态机去执行
这样就实现了在阻塞的时候,自动切换到一一个任务里。
相比RTOS,这样好处有:
- 不需要动态内存分配,编译器在编译的时候就计算出状态机的大小,可以把状态机直接放在栈上
- 共享函数调用栈,不需要为每一个任务单独分配栈空间
- 更快的任务切换,因为不需要为每个任务保存和恢复寄存器
当然它也有缺点,就是只能做协助式调度,不能抢占。如果一个任务阻塞了,其他任务就没办法被执行,所以写代码的时候需要特别注意,长时间的任务要时不时主动切换一下。
总结
在这次活动中,第一次尝试了非阻塞式的代码,第一次用 WS2812B 和 OLED,体验非常好。SAME51J20A Curiosity Nano 板载资源非常丰富,完全不担心 Flash 和 SRAM 会不够用。最后感谢硬禾、得捷、MicroChip,期待以后可以做更有趣的项目。