Bỏ qua để đến nội dung

Rust nhúng (embedded): vi điều khiển, no_std và Embassy

Rust nhúng (embedded Rust) nghĩa là viết Rust cho những chiếc máy tính nhỏ nằm bên trong đồ vật: bộ điều nhiệt, bàn phím, drone, cảm biến tưới cây. Những máy tính này gọi là vi điều khiển (microcontroller). Một con chip chứa bộ xử lý, vài trăm kilobyte bộ nhớ và các chân (pin) nói chuyện với đèn LED, nút bấm và cảm biến. Thường thì hoàn toàn không có hệ điều hành. Chương trình của bạn là thứ duy nhất chạy, từ lúc cấp điện đến lúc tắt điện.

Nghe có vẻ đáng sợ, nhưng cũng rất vui: bạn viết vài dòng, và một bóng đèn thật nhấp nháy trên bàn bạn. Trang này cho bạn thấy những gì làm được, nên chọn board và crate nào, và cách đưa chương trình đầu tiên lên chip. Trên đường đi, bạn sẽ luyện “cách nghĩ của dân nhúng” qua những ví dụ chạy được ngay trong trình duyệt.

Vì sao Rust rất hợp với lập trình nhúng

Phần tiêu đề “Vì sao Rust rất hợp với lập trình nhúng”

Hàng chục năm nay, code nhúng chủ yếu được viết bằng C. Rust mang đến vài thứ rất quan trọng trên một con chip tí hon:

  • Không có garbage collector. Không có gì tạm dừng chương trình để dọn bộ nhớ, nên thời gian chạy luôn dự đoán được. Điều đó quan trọng khi bạn phải phản ứng với một tín hiệu trong vài micro giây.
  • An toàn bộ nhớ mà không cần runtime. Các quy tắc quyền sở hữu (ownership) và mượn (borrowing) được kiểm tra lúc biên dịch, nên chúng không tốn gì trên chip. Cả những nhóm lỗi crash (con trỏ treo, data race giữa một ngắt và vòng lặp chính) đều bị bắt trước khi bạn nạp bất cứ thứ gì.
  • Trừu tượng không tốn chi phí (zero-cost abstractions). Một HAL cho bạn viết led.set_high() thay vì chọc vào địa chỉ bộ nhớ, và nó biên dịch ra đúng một lệnh ghi thanh ghi y như bạn tự viết tay.
  • Quyền sở hữu mô hình hóa phần cứng. Một chân là một giá trị. Khi nó đã được move vào một Output, không ai khác có thể lấy nó và vô tình dùng làm chân input. Trình biên dịch bắt buộc điều mà trước đây chỉ là một dòng chú thích trong file C.
  • Công cụ tuyệt vời. cargo, target của rustup và probe-rs thay thế cho rất nhiều IDE của hãng chip và makefile.

Nói thật về những chỗ còn gồ ghề: các hãng chip vẫn phát hành SDK và ví dụ bằng C trước tiên, nên với một số chip, hỗ trợ Rust còn mỏng hoặc chỉ do cộng đồng làm. Bạn vẫn cần đọc datasheet. Và một số crate chưa đến 1.0, thay đổi API giữa các bản phát hành, nên hãy luôn theo ví dụ cho đúng phiên bản bạn dùng.

  • Cảm biến và thiết bị nhỏ: trạm thời tiết, báo động độ ẩm đất, máy đếm bước chân.
  • Thiết bị USB: bàn phím tự làm, tay cầm chơi game, bộ điều khiển MIDI.
  • Thiết bị kết nối (IoT): cảm biến Wi-Fi hoặc Bluetooth với ESP32 gửi số đo lên server.
  • Robot và động cơ: robot dò line, điều khiển động cơ bước và servo, dải đèn LED và màn hình.
  • Hệ thống nghiêm túc: Oxide Computer đã viết Hubris (tiếng Anh), một hệ điều hành nhỏ cho vi điều khiển, hoàn toàn bằng Rust. Espressif, công ty đứng sau ESP32, tự duy trì hỗ trợ Rust và xuất bản The Rust on ESP Book (tiếng Anh).

Board nào dưới đây cũng là một board đầu tiên tốt. Nếu chưa quyết được, hãy lấy một Raspberry Pi Pico: nó rẻ, tài liệu rất đầy đủ, và ví dụ Embassy bên dưới được viết cho nó.

Board Chip Target Rust Vì sao hay Bắt đầu với
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Rẻ, phổ biến, hỗ trợ Embassy rất tốt embassy-rp, hoặc rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, hoặc RISC-V) thumbv8m.main-none-eabihf Mới hơn và nhanh hơn, có bộ tính toán dấu phẩy động embassy-rp (feature rp235xa), rp235x-hal
Board ESP32-C3 / ESP32-C6 Chip RISC-V của Espressif riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Có sẵn Wi-Fi và Bluetooth, hỗ trợ Rust chính thức esp-hal và công cụ esp-generate
STM32 Nucleo, “Black Pill” STM32 (ví dụ STM32F4) thumbv7em-none-eabihf Dòng chip khổng lồ dùng trong công nghiệp; board Nucleo có sẵn debug probe embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Có sẵn debug probe, LED, nút bấm và cảm biến trên board microbit-v2, cùng cuốn Discovery

micro:bit là board được dùng trong Embedded Rust Discovery book (tiếng Anh) chính thức, một khóa học miễn phí, từng bước cho người mới bắt đầu. ESP32 đời đầu và ESP32-S3 dùng lõi Xtensa thay vì RISC-V; với những chip đó, công cụ espup của Espressif cài một toolchain Rust đặc biệt. Đèn LED của Pico W nối với chip Wi-Fi chứ không phải một chân bình thường, nên nó cần một chương trình blinky khác (Embassy có ví dụ wifi_blinky cho nó).

Một chương trình Rust bình thường dùng thư viện chuẩn (std). std cần một hệ điều hành: file, luồng, heap, một terminal cho println!. Vi điều khiển không có thứ nào trong số đó. Vì vậy chương trình nhúng bắt đầu bằng hai dòng:

#![no_std] // không link thư viện chuẩn, chỉ dùng `core`
#![no_main] // không dùng điểm vào `fn main()` thông thường
  • #![no_std] nghĩa là bạn chỉ có core (tiếng Anh): số nguyên, Option, Result, iterator, slice, trait, định dạng chuỗi. Không có Vec, String, Box, file hay luồng. (Với một allocator, bạn có thể dùng lại Vec và những người bạn của nó qua crate alloc, nhưng nhiều project không bao giờ cần.)
  • #![no_main] cần thiết vì trên chip không có hệ điều hành nào gọi main. Một crate runtime như cortex-m-rt thiết lập bộ nhớ sau khi reset rồi nhảy vào hàm của bạn, hàm mà bạn đánh dấu bằng một attribute như #[cortex_m_rt::entry] hoặc, với Embassy, #[embassy_executor::main].
  • Bạn cũng phải nói rõ chuyện gì xảy ra khi panic, vì không có terminal nào để in ra. Các crate như panic-probe hay panic-halt lo việc này; bạn thêm một cái bằng use panic_probe as _;.

Không có Vec thì lưu một danh sách bằng gì? Crate heapless (tiếng Anh) cho bạn Vec, String và hàng đợi với sức chứa cố định chọn lúc biên dịch. Chúng nằm trên stack hoặc trong một static, nên không có heap và không có bất ngờ hết bộ nhớ. Một bộ đệm đầy đơn giản là trả lại giá trị cho bạn:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// Chứa vừa đúng 4 số đo. Bộ nhớ là một phần của biến:
// không heap, không allocator, và kích thước biết trước lúc biên dịch.
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// Một String với sức chứa cố định 16 byte
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

heapless cũng chạy trên PC, nên bạn có thể thử bằng cargo add [email protected] và cargo run (nó không có trên Playground). Các chuỗi được giữ tiếng Anh vì đây là kết quả thật của đúng đoạn code này, và String<16> tính sức chứa theo byte:

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

Các crate Rust nhúng xếp chồng lên nhau. Phần lớn thời gian bạn làm việc ở tầng HAL hoặc framework, nhưng biết bên dưới có gì sẽ giúp ích:

Tầng Nó là gì Ví dụ
PAC (Peripheral Access Crate) Được sinh ra từ file mô tả thanh ghi của chip. Mỗi thanh ghi và mỗi trường có một hàm tương đối an toàn. Rất cấp thấp. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer) Các kiểu dễ dùng cho một dòng chip: Output, Uart, I2c, timer. Xây trên một PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
Các trait embedded-hal Các trait dùng chung như OutputPin, I2c, SpiDevice và DelayNs. HAL implement chúng; driver dùng chúng. Phiên bản 1.0 đã ổn định. embedded-hal, embedded-hal-async
BSP (Board Support Package) Một HAL cộng với tên gọi cho các chân và linh kiện của một board cụ thể (“đèn LED”, “nút A”). rp-pico, microbit-v2
RTIC Framework cho ứng dụng điều khiển bằng ngắt. Các task gắn với ngắt và độ ưu tiên, và trình biên dịch kiểm tra dữ liệu dùng chung lúc build. rtic
Embassy Framework bất đồng bộ: một executor, timer, mạng và USB, cộng với các HAL riêng. Bạn viết các task async fn và .await các sự kiện phần cứng. embassy-executor, embassy-time, embassy-rp

Phép màu nằm ở hàng giữa. Một driver cảm biến nhiệt độ viết dựa trên trait I2c của embedded-hal chạy được trên Pico, ESP32 và STM32 mà không cần sửa, vì mỗi HAL đều implement cùng một trait. Bạn sẽ tự viết một driver như vậy ở bên dưới.

PC của bạn có bộ xử lý desktop x86 hoặc Arm; Pico có một Cortex-M0+. Vì vậy bạn biên dịch chéo (cross-compile): build trên một máy, cho một máy khác. rustup tải về thư viện core đã biên dịch sẵn cho chip:

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

Để chép chương trình lên chip và xem log của nó, bạn cần probe-rs (tiếng Anh) và một debug probe: một thiết bị USB nhỏ nối với các chân debug của chip. Board micro:bit và STM32 Nucleo có sẵn một cái. Với Pico, hãy dùng Raspberry Pi Debug Probe, hoặc một con Pico thứ hai nạp firmware miễn phí debugprobe. Cài probe-rs bằng script cài đặt của nó (xem trang cài đặt (tiếng Anh) cho Windows) hoặc bằng cargo:

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# hoặc, chậm hơn, build từ mã nguồn:
cargo install probe-rs-tools --locked
probe-rs list # hiển thị các probe mà nó thấy

Chưa có probe? Giữ nút BOOTSEL của Pico trong lúc cắm nó vào, và nó hiện ra như một ổ USB. Các công cụ như picotool hay elf2uf2-rs có thể chép chương trình lên nó theo cách đó. Tuy vậy, không có probe thì bạn sẽ không thấy log defmt.

Với ESP32-C3 hoặc C6, thường bạn hoàn toàn không cần probe: chip có sẵn USB, và công cụ espflash nạp chương trình qua cáp USB bình thường. Chạy cargo install esp-generate espflash --locked, rồi esp-generate, chọn chip và các tùy chọn, và cargo run --release sẽ build, nạp và hiển thị log.

“Blinky” là “Hello, world!” của lập trình nhúng: bật LED, chờ, tắt LED, chờ, mãi mãi. Đây là ví dụ blinky RP2040 chính thức của Embassy (tiếng Anh, có thêm chú thích). Nó cần một board thật và không chạy được trên Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // gửi log defmt tới probe
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // khi panic: in thông báo qua probe, rồi dừng
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Nhận quyền sở hữu mọi ngoại vi trên chip, đúng một lần
let p = embassy_rp::init(Default::default());
// LED của Pico nằm ở chân 25. PIN_25 được move vào `led`.
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

Vài điều cần để ý:

  • main là một async fn. Timer::after_millis(500).await không quay vòng chờ bận; executor cho chip ngủ đến khi timer kêu. Khi có thêm task (tạo qua Spawner), các task khác chạy trong lúc task này chờ. Nếu async/await còn mới với bạn, ý tưởng giống như Tokio trên PC, chỉ là không có hệ điều hành.
  • p.PIN_25 được move vào Output::new. Thử dùng p.PIN_25 lần thứ hai là trình biên dịch chặn bạn lại: quyền sở hữu, áp dụng cho phần cứng.
  • use defmt_rtt as _; và use panic_probe as _; import các crate chỉ vì những gì chúng thiết lập (kênh log và trình xử lý panic), không phải vì một cái tên nào.

Cách nhanh nhất để thấy nó nhấp nháy là dùng chính thư mục ví dụ chính thức: clone repository Embassy, rồi trong examples/rp chạy cargo run --bin blinky --release. Với project của riêng bạn, các phụ thuộc lấy từ Cargo.toml của ví dụ đó:

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # giữ thông tin debug cho probe-rs; nó không được nạp lên chip

Hai file nữa cho cargo biết cần build gì và chạy thế nào. .cargo/config.toml đặt target và khiến cargo run nạp chương trình qua probe-rs:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

Cuối cùng, chép memory.x (cho biết flash và RAM nằm ở đâu trên chip) và build.rs (truyền các linker script) từ cùng thư mục examples/rp. Sau đó cắm probe vào và chạy:

Terminal window
cargo run --release

cargo build cho thumbv6m-none-eabi, probe-rs nạp chương trình lên chip, LED bắt đầu nhấp nháy, và các thông báo led on! / led off! chảy vào terminal của bạn kèm dấu thời gian.

Vi điều khiển không có màn hình, và định dạng chữ bằng core::fmt tốn rất nhiều dung lượng flash. defmt (tiếng Anh, “deferred formatting”, định dạng trì hoãn) giải quyết cả hai. Các chuỗi định dạng được lưu trên PC của bạn, không phải trên chip; chip chỉ gửi một ID tí hon cùng các giá trị thô qua probe, và probe-rs biến chúng trở lại thành chữ dễ đọc.

use defmt::{info, warn, Format};
#[derive(Format)] // giống #[derive(Debug)], nhưng dành cho defmt
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` và `=i32` cho defmt biết chính xác kiểu, giúp tiết kiệm thêm dung lượng
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

Các mức log là trace!, debug!, info!, warn! và error!, giống hệt crate log. Thiết lập DEFMT_LOG trong .cargo/config.toml chọn mức nào được biên dịch vào; mọi thứ thấp hơn mức đó tốn đúng 0 byte.

Bên dưới mọi HAL, điều khiển phần cứng nghĩa là ghi số vào các thanh ghi (register): những địa chỉ bộ nhớ đặc biệt mà mỗi bit bật/tắt một thứ gì đó trên chip. Trên một chip điển hình, thanh ghi output GPIO 32 bit có một bit cho mỗi chân. Ở đây một u32 bình thường đóng vai thanh ghi đó, để bạn luyện bốn thao tác mà lập trình viên nhúng nào cũng dùng:

const LED: u32 = 25; // LED trên board Pico nằm ở chân 25
fn main() {
// Giả vờ u32 này là thanh ghi output GPIO của chip.
// Bit 0 điều khiển chân 0, bit 1 điều khiển chân 1, và cứ thế.
let mut gpio_out: u32 = 0b1000; // chân 3 đang bật sẵn
// Bật một bit: OR với một mask
gpio_out |= 1 << LED;
println!("LED bật: {gpio_out:#034b}");
// Đọc một bit: AND với một mask, rồi so sánh với 0
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED bật? {led_is_on}, chân 2 bật? {pin2_is_on}");
// Đảo một bit: XOR với một mask
gpio_out ^= 1 << LED;
println!("đảo bit: {gpio_out:#034b}");
// Xóa một bit: AND với mask đã đảo
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("chân 3 tắt: {gpio_out:#034b}");
println!("dạng hex: {gpio_out:#010x}");
}

Kết quả:

LED bật: 0b00000010000000000000000000001000
LED bật? true, chân 2 bật? false
đảo bit: 0b00000000000000000000000000001000
chân 3 tắt: 0b00000010000000000000000000000000
dạng hex: 0x02000000
▶ Tự chạy thử

Trong Rust, ! trên một số nguyên đảo mọi bit (C dùng ~ cho việc này). {:#034b} in giá trị ở dạng nhị phân với tiền tố 0b và đệm đủ 32 chữ số, để bạn nhìn bit 25 bật lên rồi tắt đi. Xem Toán tử để biết danh sách đầy đủ các toán tử thao tác bit.

Nhiều thanh ghi gói vài trường (field) vào một con số: vài bit cho thiết lập tốc độ, một bit cho “đã bật”. Để đổi một trường, bạn làm đọc-sửa-ghi (read-modify-write): xóa các bit của trường đó, rồi OR giá trị mới vào, để nguyên mọi bit khác:

// Một thanh ghi điều khiển UART bịa ra:
// bit 0 = bật
// bit 4..=6 = thiết lập tốc độ (một số 3 bit, từ 0 đến 7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// Xóa trường cũ, rồi đặt giá trị mới vào.
// Mọi bit khác giữ nguyên y như cũ.
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("ban đầu: {ctrl:#010b}, tốc độ = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("tốc độ 5: {ctrl:#010b}, tốc độ = {}", read_speed(ctrl));
// 9 không vừa trong 3 bit. Mask cắt bớt nó thay vì
// tràn sang các bit bên cạnh.
ctrl = write_speed(ctrl, 9);
println!("tốc độ 9?: {ctrl:#010b}, tốc độ = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("vẫn bật: {still_enabled}");
}

Kết quả:

ban đầu: 0b00100001, tốc độ = 2
tốc độ 5: 0b01010001, tốc độ = 5
tốc độ 9?: 0b00010001, tốc độ = 1
vẫn bật: true
▶ Tự chạy thử

Để ý rằng 9 lặng lẽ biến thành 1. Đó chính xác là loại lỗi mà PAC và HAL bảo vệ bạn: chúng cho mỗi trường một phương thức có tên riêng (thường kèm cả các giá trị có tên), nên bạn hiếm khi phải tự viết mask. Trên phần cứng thật, thanh ghi là một địa chỉ bộ nhớ cố định, được ghi bằng core::ptr::write_volatile bên trong code unsafe; PAC làm phần đó giúp bạn.

Thói quen nhúng tốt nhất bạn có thể học: viết logic dựa trên trait, không dựa trên một con chip cụ thể. Khi đó cùng một đoạn code chạy trên board với HAL thật, và chạy trên PC với linh kiện giả (“mock”) trong một bài test bình thường. Ở đây, StatusLed chỉ cần “thứ gì đó bật tắt được” và “thứ gì đó biết chờ”:

use std::cell::RefCell;
use std::rc::Rc;
// Hai khả năng mà một driver nháy đèn cần. Chúng phản chiếu các trait
// OutputPin và DelayNs của embedded-hal 1.0, rút gọn để chạy được trên Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// Driver không biết gì về chip nào cả. Nó chỉ dùng các trait.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Nháy `times` lần: bật trong `on_ms`, rồi tắt trong `off_ms`.
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- Đồ giả để test trên PC ---
// Cả hai đồ giả ghi vào cùng một log dùng chung, nên ta thấy được thứ tự.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("bật".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("tắt".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("chờ {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "bật");
assert_eq!(events[1], "chờ 100");
println!("mọi kiểm tra đều đạt");
}

Kết quả:

bật, chờ 100, tắt, chờ 400, bật, chờ 100, tắt, chờ 400
mọi kiểm tra đều đạt
▶ Tự chạy thử

Hai mock dùng chung một log qua Rc<RefCell<...>> (xem Con trỏ thông minh), nên bạn kiểm tra được thứ tự chính xác của các sự kiện. Trong một project thật, các dòng assert_eq! nằm trong một hàm #[test] và chạy bằng cargo test trên PC, không cần board (xem Kiểm thử và Kiểm thử nâng cao).

Trên board, bạn viết cùng driver đó dựa trên các trait thật của embedded-hal 1.0. Thay đổi duy nhất: các phương thức của chân trả về Result (một chân trên chip mở rộng I/O có thể lỗi), nên bạn chuyển lỗi lên bằng ?, và code hoàn toàn không cần std:

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

Output của Embassy implement OutputPin, và embassy_time::Delay implement DelayNs, nên StatusLed::new(led, embassy_time::Delay) chạy được trên Pico. Để test trên PC với các trait thật này, crate embedded-hal-mock (tiếng Anh) cung cấp sẵn chân giả, bus I2C và SPI giả.

Nhiều chip nhỏ, như Cortex-M0+ trong RP2040, không có bộ tính toán dấu phẩy động. Số thực (float) vẫn dùng được, nhưng bằng phần mềm: chậm hơn, và làm chương trình to hơn. Vì vậy code nhúng thường dùng số dấu phẩy cố định (fixed-point): số nguyên ở một đơn vị nhỏ hơn. Thay vì 25.25 độ, bạn lưu 25250 mili độ.

Một cảm biến nhiệt độ kiểu TMP102 gửi hai byte. 12 bit cao là một số có dấu, mỗi bước 1/16 °C:

// 12 bit cao là nhiệt độ theo bước 1/16 °C (0.0625 °C),
// dưới dạng số có dấu (bù hai).
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 giữ dấu. `>> 4` trên số có dấu cũng giữ dấu.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62.5 m°C, nên nhân với 625 rồi chia cho 10.
raw as i32 * 625 / 10
}
// In 25250 thành "25.250" mà không hề đụng đến float.
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // một căn phòng ấm
[0x00, 0x10], // bước nhỏ nhất trên 0
[0xFF, 0x80], // dưới điểm đóng băng một chút
[0xE7, 0x00], // một tủ đông
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Kết quả:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ Tự chạy thử

Ba chi tiết giúp đoạn code này chạy đúng. i16::from_be_bytes đọc hai byte thành một số có dấu, byte cao trước (“big-endian”), đó là cách phần lớn cảm biến gửi dữ liệu. >> 4 trên một số nguyên có dấu giữ nguyên dấu, nên nhiệt độ âm ra đúng. Và format_milli xử lý dấu riêng: -500 / 1000 bằng 0 trong phép chia số nguyên, nên thiếu bước đó thì -0.5 °C sẽ bị in thành 0.500.

Phép chia số nguyên cũng cắt bỏ phần lẻ: 62.5 m°C thành 62. Nửa mili độ chẳng quan trọng với cảm biến trong phòng, nhưng hãy luôn tự hỏi một đơn vị cho bạn độ chính xác đến đâu. Và để ý phạm vi giá trị: nhân một số đo lớn với một hệ số lớn có thể làm tràn i32 (xem Tràn số nguyên). Trên PC thì String không sao; trên chip bạn sẽ in bằng defmt hoặc ghi vào một heapless::String.

Dùng những thứ của std trong chương trình no_std. println!, Vec và String đến từ std, nên đơn giản là chúng không có ở đó:

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

Trình biên dịch báo rằng nó không tìm thấy macro println trong phạm vi này. Hãy dùng defmt::info! để ghi log, và heapless (hoặc crate alloc cùng một allocator) cho các collection.

Quên trình xử lý panic. Một chương trình no_std phải nói rõ panic thì làm gì. Không có crate như panic-probe, bạn sẽ nhận:

error: `#[panic_handler]` function required, but not found

Thông báo này nghĩa là cần một hàm #[panic_handler] nhưng không tìm thấy. Thêm panic-probe (hoặc panic-halt) vào Cargo.toml và use panic_probe as _; vào main.rs.

Ghi đè cả một thanh ghi. reg = 1 << 3 bật chân 3, và tắt mọi chân khác. Để đổi một bit, hãy dùng reg |= 1 << 3. Với các trường, hãy làm đọc-sửa-ghi như ở trên.

Làm theo một hướng dẫn cũ. Các crate nhúng thay đổi nhanh, và bài blog từ vài năm trước thường dùng embedded-hal 0.2 hoặc API Embassy cũ. Hãy bắt đầu từ thư mục ví dụ của đúng phiên bản crate mà bạn đang phụ thuộc.

Viết toggle(reg: u32, pin: u32) -> u32, hàm đảo một bit, và pins_on(reg: u32) -> Vec<u32>, hàm liệt kê mọi chân có bit bằng 1. Bắt đầu với các chân 0, 5 và 25 đang bật, in danh sách và số chân đang bật, rồi đảo chân 5 và 7 và in lại. Gợi ý: kiểm tra từng chân từ 0 đến 31 bằng một mask, và u32 có phương thức count_ones().

Xem lời giải
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("đang bật: {:?} ({} chân)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("đang bật: {:?} ({} chân)", pins_on(reg), reg.count_ones());
}

Kết quả:

đang bật: [0, 5, 25] (3 chân)
đang bật: [0, 7, 25] (3 chân)
▶ Tự chạy thử

XOR đảo một bit bất kể giá trị cũ của nó là gì, nên đảo chân 5 làm nó tắt, còn đảo chân 7 làm nó bật. count_ones() đếm các bit 1 giúp bạn, nên phần đó không cần vòng lặp.

Một ADC 12 bit cho số đo từ 0 đến 4095 ứng với 0 đến 3300 mV. Pin được nối qua một mạch chia áp làm điện áp giảm một nửa, nên điện áp thật gấp đôi điện áp đo được. Viết battery_mv(raw: u16) -> i32 và battery_percent(mv: i32) -> i32, trong đó 3000 mV là 0% và 4200 mV là 100%, không bao giờ dưới 0 hay trên 100. Thử với các số đo thô 2606, 2482, 2234 và 1861. Gợi ý: nhân trước rồi mới chia, để không mất độ chính xác, và hãy xem clamp.

Xem lời giải
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// ADC 12 bit (0..=4095) đo 0..=3300 mV, sau một mạch chia áp giảm điện áp một nửa
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("số đo thô {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

Kết quả:

số đo thô 2606 -> 4200 mV -> 100%
số đo thô 2482 -> 4000 mV -> 83%
số đo thô 2234 -> 3600 mV -> 50%
số đo thô 1861 -> 2999 mV -> 0%
▶ Tự chạy thử

Chia cho 4095 sau cùng giữ được độ chính xác: raw / 4095 * 6600 sẽ bằng 0 với mọi số đo dưới 4095. Tích lớn nhất, 4095 * 6600, khoảng 27 triệu, vừa thoải mái trong một i32.

  • Rust nhúng chạy trên vi điều khiển không có hệ điều hành. Không garbage collector, an toàn lúc biên dịch và trừu tượng không tốn chi phí khiến Rust rất hợp.
  • #![no_std] chỉ để lại cho bạn core; #![no_main] cho một crate runtime gọi hàm điểm vào của bạn; bạn phải chọn một trình xử lý panic. heapless cho bạn các collection có sức chứa cố định.
  • Các tầng: PAC (thanh ghi), HAL (một dòng chip), trait embedded-hal (dùng chung), BSP (một board), và các framework như RTIC và Embassy ở trên cùng.
  • Thêm target của chip bằng rustup target add, rồi nạp chương trình và xem log bằng probe-rs và một debug probe (hoặc espflash trên ESP32). defmt giữ cho việc ghi log thật nhỏ gọn.
  • Luyện tập trên PC: mask bit cho thanh ghi, driver viết dựa trên trait với linh kiện giả, và số nguyên dấu phẩy cố định thay cho float.

Kiểm tra nhanh

1. #![no_std] làm gì?

2. Tầng nào cho phép một driver cảm biến chạy trên Pico, ESP32 và STM32 mà không cần sửa?

3. Làm sao bật bit 3 của reg mà không đổi bit nào khác?

4. Vì sao code nhúng thường lưu 25.25 °C thành 25250 mili độ?

Điểm: 0 / 4

  • The Embedded Rust Book (tiếng Anh): phần giới thiệu chính thức về no_std, ngoại vi, thanh ghi và hệ sinh thái nhúng.
  • Discovery (micro:bit v2) (tiếng Anh): khóa học miễn phí cho người mới, dẫn bạn từng bước từ một đèn LED nhấp nháy đến cảm biến, trên BBC micro:bit.
  • The Embassy Book (tiếng Anh): bắt đầu với Embassy, executor và các HAL của nó hoạt động thế nào, và cách chạy các ví dụ chính thức.
  • The Rust on ESP Book (tiếng Anh): hướng dẫn của chính Espressif về esp-hal, esp-generate, espflash và các toolchain ESP32.
  • probe-rs installation (tiếng Anh): cách cài probe-rs trên mọi hệ điều hành và thiết lập debug probe.