Lập trình nhúng, Rust đã có những gì?

Rust là một ngôn ngữ hiện đại dành cho lập trình hệ thống và rất phù hợp cho việc lập trình nhúng. Nếu bạn làm trong lĩnh vực điện tử, IoT và muốn thử áp dụng Rust mà đang phân vân vì chưa biết có đủ "đồ chơi" hay chưa, có thể tham khảo bài viết dưới đây.

Ferris

Trước hết, nhắc lại những ưu điểm khiến Rust đáng để thử. Sau khi dùng Rust thì tôi thấy:

  • Giúp tăng năng suất làm việc. Sự chặt chẽ của Rust khiến lập trình viên tránh bug từ sớm, và giảm đáng kể thời gian, công sức test đi test lại. Thông thường, người lập trình nhúng hay phải nạp xuống board thường xuyên để chạy thử. Nhưng với Rust, tôi có thể viết code chay liền tù tì cả tháng mà không cần nạp xuống board. Ở AgriConnect Khi tôi bắt tay vào viết những dòng code đầu tiên của dự án thì thực ra đội điện tử chỉ mới xong bản vẽ chứ chưa làm mạch. Khi mạch thử nghiệm vừa hàn xong thì code cũng gần xong. Do làm việc từ xa nên tôi cũng không trực tiếp nạp code mà nhờ bạn khác trên văn phòng kéo code từ Git về, build, nạp, quan sát log và báo lại lỗi để tôi sửa tiếp. Tính chất này của Rust có lợi cho việc phát triển song song giữa đội phần cứng và đội lập trình.

  • Không những giúp tránh bug do sai sót của người lập trình, Rust còn hỗ trợ hiệu quả cho việc debug, truy vết lỗi. Phần lớn các hàm đều trả về Result, là kiểu dữ liệu chỉ thị "có khả năng xảy ra lỗi, và nếu xảy ra thì lỗi thuộc kiểu gì". Rust bắt buộc phải xử trí lỗi khi gặp Result, Rust có phương tiện ghi log thuận tiện, giúp việc theo dõi, debug đỡ nhọc nhằn hẳn. Bên cạnh Result, Rust có toán tử ? để dừng sớm hàm khi gặp lỗi, và trả lỗi về cho nơi gọi hàm bên ngoài, khiến việc xử trí lỗi gọn gàng, không cồng kềnh.

Lập trình nhúng thực ra có nhiều mức độ, do sự đa dạng về chip điều khiển chính (vi điều khiển / vi xử lý), dẫn đến sự đa dạng về môi trường chạy: có hay không có hệ điều hành, hệ điều hành gầy hay béo, nếu có hệ điều hành thì chạy trong kernel land (nhân) hay user land (ứng dụng), vì vậy bài viết này sẽ bị sót vài cái tên.

Nếu chia về mức độ bám sát phần cứng, có hai nhóm:

a) Nhóm sử dụng được thư viện chuẩn (std). Đây cũng tương ứng với nhóm sử dụng được thư viện C chuẩn (libc) nếu bạn viết bằng C/C++. Nhóm này có các ngã rẽ sau:

  • Lập trình ứng dụng Linux, chạy trên các board như Raspberry Pi, BeagleBone, trên router wifi.
  • Lập trình cho chip ESP32-xx. Đây hình như là dòng chip duy nhất ở cấp "vi điều khiển" được implement thư viện chuẩn, nhờ sự đầu tư về mặt phần mềm của cty Espressif.

Trong nhóm này, bên cạnh thư viện chính khai thác vi xử lý (ví dụ esp-idf-hal cho ESP32), bạn sử dụng được hầu hết các thư viện phát hành trên crates.io, trừ thư viện nào dành riêng cho Mac OS, Windows thôi.

b) Nhóm không sử dụng được thư viện chuẩn, thường được gọi là "bare metal". Nhóm này chỉ sử dụng được thư viện core và những thư viện nào trên crates.io được đánh dấu no-std.

Nhóm này có các hướng đi sau:

  • Lập trình driver, module cho nhân Linux.
  • Lập trình các vi điều khiển dòng ARM Cortex-M như của STM32, Nordic, Raspberry Pi Pico.
  • Lập trình ESP32-xx. Dòng vi điều khiển này thú vị vì được cộng đồng làm luôn hai bộ SDK cho hai kiểu chơi.
  • Lập trình các board Arduino.

Khi lập trình cho môi trường này, thông thường bạn sẽ chỉ được dùng biến cấp phát trong bộ nhớ stack, không dùng được những kiểu dữ liệu cần cấp phát bộ nhớ trong heap, như Vec, String. Tuy nhiên nếu nín nhịn không được thì bạn có thể làm thêm vài bước (định nghĩa global allocator), để có thể sử dụng bộ nhớ heap thông qua thư viện alloc. Tuy nhiên khi được sử dụng alloc rồi thì không có nghĩa là bạn được dùng std.

Nhóm này thì thường lập trình theo framework nào đó, ví dụ Embassy, kết hợp với những thư viện khai thác vi xử lý, như embassy-stm3 và bên dưới nữa. Tất nhiên không ai ngăn cản bạn dẹp framework qua một bên và làm việc trực tiếp với thanh ghi 😸.

Với ESP32 thì bạn sẽ dùng esp-hal thay vì esp-idf-hal. Khác nhau là esp-idf-hal gọi vào bộ ESP-IDF bên dưới (ngôn ngữ C), còn esp-hal thì hoàn toàn được viết bằng Rust. Do các kĩ sư của Espressif đã implement thư viện C chuẩn cho ESP-IDF nên esp-idf-hal được lợi là dùng được std (trừ module std::fs liên quan đến filesystem).

Sau đây ta sẽ khảo sát một vài ưu điểm của các thư viện Rust trong lĩnh vực nhúng này.

Sự tương hợp

Một ưu điểm của hệ sinh thái Rust trong lĩnh vực nhúng, là tính tương hợp, liên thông cao. Giữa muôn màu muôn vẻ các loại vi điều khiển, có một nhu cầu bức thiết là làm sao để thư viện viết ra cho chip này, có thể chạy được trên chip khác mà không phải sửa đổi nhiều. Cụ thể hơn, giả sử bạn chế tạo ra một cảm biến đo nhiệt độ. Muốn dễ bán được hàng thì bạn phải cung cấp thư viện để đọc dữ liệu của nó. Làm sao để thư viện đó chạy trên ESP32 được mà chạy trên STM32 cũng được. Nhờ tính năng trait của Rust, cộng đồng đã ngồi lại với nhau và thiết kế ra một thư viện chung, embedded-hal, định nghĩa những trait để code của các thiết bị khác nhau có thể tương thích với nhau. Một ví dụ là thư viện đọc cảm biến SHT3x này, embedded-sht3x không cần chứa code cụ thể cho vi điều khiển nào cả, nhưng có thể chạy được trên ESP32, STM32 và thậm chí cả Linux.

Trong link vừa rồi, bạn có thể thấy code mẫu cho Linux:

use embedded_sht3x::{Repeatability::High, Sht3x, DEFAULT_I2C_ADDRESS};
use linux_embedded_hal as hal;

fn main() -> Result<(), embedded_sht3x::Error<hal::I2CError>> {
    // Create the I2C device from the chosen embedded-hal implementation,
    // in this case linux-embedded-hal
    let mut i2c = match hal::I2cdev::new("/dev/i2c-1") {
        Err(err) => {
            eprintln!("Could not create I2C device: {}", err);
            std::process::exit(1);
        }
        Ok(i2c) => i2c,
    };
    if let Err(err) = i2c.set_slave_address(DEFAULT_I2C_ADDRESS as u16) {
        eprintln!("Could not set I2C slave address: {}", err);
        std::process::exit(1);
    }

    // Create the sensor and configure its repeatability
    let mut sensor = Sht3x::new(i2c, DEFAULT_I2C_ADDRESS, hal::Delay {});
    sensor.repeatability = High;

    // Perform a temperature and humidity measurement
    let measurement = sensor.single_measurement()?;
    println!(
        "Temperature: {:.2} °C, Relative humidity: {:.2} %",
        measurement.temperature.celcius(),
        measurement.relative_humidity
    );
    Ok(())
}

Còn dưới đây là code cho ESP32 mà tôi viết:

use embedded_sht3x::Sht3x;
use esp_idf_svc::hal::delay::Delay;
use esp_idf_svc::hal::i2c::{I2cConfig, I2cDriver};
use esp_idf_svc::hal::sys::EspError;
use log::{info, warn};

pub fn init_sht3x<'d>(peri: SHT3xBlock) -> Result<Sht3x<I2cDriver<'d>, Delay>, EspError> {
    let config = I2cConfig {
        baudrate: Hertz(100_000),
        ..Default::default()
    };
    info!("To setup i2c for SHT3x {:?}", config);
    let drv = I2cDriver::new(peri.i2c, peri.pin_sda, peri.pin_scl, &config)?;
    let sensor = Sht3x::new(
        drv,
        embedded_sht3x::DEFAULT_I2C_ADDRESS,
        Delay::new_default(),
    );
    Ok(sensor)
}

pub fn collect_sensor_data(
    sensors: &mut Sensors,
    app_config: &AppConfig,
) -> Result<SensorDataMessage, AppError> {
    let mut message = SensorDataMessage::default();
    if let Some(ref mut sensor) = sensors.sht3x {
        info!("Read temperature from SHT3x...");
        match sensor.single_measurement() {
            Ok(t) => {
                info!("Temperature: {t:?}");
                message.temperature = Some(t.temperature);
                message.humidity = Some(t.humidity);
            }
            Err(embedded_sht3x::Error::BadCrc) => {
                warn!("Error reading SHT3x: Bad CRC.");
            }
            Err(embedded_sht3x::Error::I2c(e)) => {
                warn!("Error reading SHT3x: {}.", e.cause());
            }
        };
    };
    //  Đọc các cảm biến khác...
    Ok(message)
}

Có thể thấy rằng, hai đoạn code trên cùng dùng một struct Sht3x từ thư viện embedded_sht3x, nhưng truyền đối số khác nhau một xíu, code Linux thì truyền đối số thuộc kiểu linux_embedded_hal::I2cdevlinux_embedded_hal::Delay, còn code ESP32 thì truyền esp_idf_hal::I2cDriver, esp_idf_hal::delay::Delay. Đây là code cho ESP32 có sử dụng thư viện chuẩn, còn nếu lập trình bare metal cho ESP32 thì sẽ dùng kiểu esp_hal::i2c::I2Cesp_hal::delay::Delay.

Tại sao embedded_sht3x::Sht3x có thể chấp nhận đối số một cách rộng rãi như vậy. Ta có thể xem định nghĩa của nó trong tài liệu như sau:

impl<I2C, D> Sht3x<I2C, D>
where
    I2C: embedded_hal::i2c::I2c,
    D: embedded_hal::delay::DelayNs,
{
    pub fn new(i2c: I2C, address: SevenBitAddress, delay: D) -> Self
}

Định nghĩa trên có nghĩa là hàm Sht3x::new có thể nhận biến i2c thuộc bất cứ kiểu nào miễn là nó implement trait embedded_hal::i2c::I2c, nhận biến delay thuộc bất cứ kiểu nào miễn là nó implement trait embedded_hal::delay::DelayNs.

Và bây giờ, nhảy đến tài liệu của esp_idf_hal::i2c::I2cDriver, ta thấy nó implement trait embedded_hal::i2c::I2c:

ESP32 I2C

Nhảy đến tài liệu của linux_embedded_hal::I2cdev, ta thấy nó cũng implement trait embedded_hal::i2c::I2c:

Linux I2C

Thú vị, phải không nào.

Thêm nữa, dù là thư viện khai thác vi điều khiển nào, std hay no-std, cách thiết kế API cũng na ná nhau. Ví dụ, đều có một struct độc bản tên Peripherals chứa các khối ngoại vi của vi xử lý đó. Đây là của esp-idf-hal (dành cho ESP32, có std):

pub struct Peripherals {
    pub pins: Pins,
    pub uart0: UART0,
    pub uart1: UART1,
    pub i2c0: I2C0,
    pub i2s0: I2S0,
    ...
}

Đây là của embassy-stm32 (dành cho STM32, theo framework Embassy):

pub struct Peripherals {
    pub ADC1: ADC1,
    pub DAC1: DAC1,
    pub DMA1: DMA1,
    pub DMA2: DMA2,
    pub PA0: PA0,
    pub PA1: PA1,
    pub PA2: PA2,
    ...
}

Ta tiếp tục khảo sát cách Rust giúp chúng ta tránh bug sớm như thế nào. Lấy ví dụ đoạn code setup UART cho board STM32 mà tôi đã viết như sau:

use embassy_stm32::mode::Async;
use embassy_stm32::usart::{Config, ConfigError, Uart};
use embassy_stm32::peripherals::{
    PA10, PA9, USART1, DMA1_CH4, DMA1_CH5
};

use crate::types::{GsmModemBlock, Irqs};


pub struct GsmModemBlock {
    pub uart: USART1,
    pub pin_tx: PA9,
    pub pin_rx: PA10,
    pub dma_tx: DMA1_CH4,
    pub dma_rx: DMA1_CH5,
}

pub fn init_uart_for_gsm_modem<'d>(p: GsmModemBlock) -> Result<Uart<'d, Async>, ConfigError> {
    let mut config = Config::default();
    // Simcom auto-baudrate does not work with our board.
    config.baudrate = 115200;
    Uart::new(p.uart, p.pin_rx, p.pin_tx, Irqs, p.dma_tx, p.dma_rx, config)
}

Trong các bộ thư viện lập trình ngôn ngữ C, các biến đại diện cho chân sẽ là kiểu số nguyên (dù có được định nghĩa thành hằng, qua #define thì vẫn là kiểu uint8_t / uint32_t thôi). Điều đó sẽ không ngăn chặn được việc người lập trình truyền vào một con số ngoài giá trị mong đợi. Ví dụ đây là code do STM32Cube sinh ra, định nghĩa các kênh DMA:

#define DMA_CHANNEL_0                 0x00000000U    /*!< DMA Channel 0 */
#define DMA_CHANNEL_1                 0x02000000U    /*!< DMA Channel 1 */

Nếu người lập trình viên vô tình truyền vào 0x01000000 thì trình biên dịch không bắt được, đến khi nạp code xuống board, cho chạy thì mới đụng lỗi.

Bên Rust lại khác, mỗi chân đều được định nghĩa thành một "kiểu dữ liệu", nên nếu lập trình viên truyền giá trị nào khác với giá trị mong đợi, thì sẽ không khớp kiểu luôn, trình biên dịch sẽ bắn lỗi và không chịu biên dịch. Ví dụ xem khai báo của hàm Uart::new (nhìn ký hiệu hơi đáng sợ tí):

STM32 Uart

Ta thấy biến rx bắt buộc phải implement trait RxPin. Sau khi bấm vào link tài liệu của trait này thì ta thấy:

impl RxPin<UART4> for PC11
impl RxPin<UART5> for PD2
impl RxPin<USART1> for PA10
impl RxPin<USART1> for PB7
impl RxPin<USART2> for PA3
impl RxPin<USART3> for PB11
impl RxPin<USART3> for PC11

Có nghĩa là ta chỉ được phép truyền vào biến thuộc một trong hai kiểu, cho mỗi khối UART. Giả sử ta sẽ dùng USART1, và ta chọn PA10 cho rx, thế thì, rx_dma sẽ nhận gì, ta lại bấm vào link tài liệu của RxDma:

impl RxDma<UART4> for DMA2_CH3
impl RxDma<USART1> for DMA1_CH5
impl RxDma<USART2> for DMA1_CH6
impl RxDma<USART3> for DMA1_CH3

Ta thấy ngay rằng với USART1 thì chỉ có thể truyền biến thuộc kiểu DMA1_CH5. Nếu cố ý truyền DMA khác vào thì trình biên dịch sẽ không chịu biên dịch. Không cách nào làm sai được.

Hỗ trợ đa nhiệm

Nếu lập trình cho nền tảng nào có std thì triển khai tính năng đa nhiệm trơn tru rồi, ví dụ trên ESP32 ta có thể dùng std::thread để lập trình đa luồng y như trên desktop, server. Mỗi tội ESP-IDF chưa implement std::sync::RwLock, dùng để sắp xếp việc đọc / ghi dữ liệu chung giữa nhiều luồng. Tuy nhiên, trong lúc chờ có RwLock thì std::sync::Mutex vẫn đủ dùng.

Trong môi trường no-std (bare metal) thì rất may là Rust cũng hỗ trợ bạn lập trình đa nhiệm tận răng. Ví dụ framework Embassy thiết kế nhiều các hàm ở dạng async, dùng với từ khóa async / await. Chương trình của bạn sẽ tạo nhiều tác vụ, và trong khi tác vụ này ngồi chờ có điều kiện để chạy tiếp (await) thì vi xử lý sẽ nhảy sang thực thi tác vụ kia. Điều thú vị của Embassy là, khi mà các tác vụ đều đang rảnh (do chờ I/O chẳng hạn) thì Embassy đẩy vi xử lý vào chế độ ngủ luôn, tranh thủ tiết kiệm năng lượng. Nhờ vậy mà firmware viết bằng Rust của bạn tự dưng chạy ít tốn pin hơn.

Đây là ví dụ một đoạn code tôi viết cho STM32, dùng Embassy:

async fn report_load_statuses<'d>(
    gsm_uart_tx: &mut UartTx<'d, Async>,
    gsm_receiver: &GsmMessagesChanReceiver<'d>,
    app_config: &AppConfig,
    load_controllers: &LoadControllers<'d>,
    health: &mut Health<'d>,
    app_state: &mut AppState,
) -> Result<(), AppError> {
    if app_state.gsm_modem_state < GsmModemState::InternetReady {
        warn!("GSM modem is not ready yet!");
        return Ok(());
    }
    let mut message = LoadStatusReportMessage::from(load_controllers);
    if app_state.gsm_modem_state == GsmModemState::InternetReady {
        enable_mqtt_on_gsm_modem(gsm_uart_tx, gsm_receiver, &mut app_state.gsm_incoming_messages).await?;
        app_state.gsm_modem_state = GsmModemState::MqttReady;
    }
    info!("To get signal strength");
    message._gsm_signal_strength = get_gsm_signal_strength(gsm_uart_tx, gsm_receiver)
        .await
        .inspect_err(|e| warn!("Failed to get GSM signal strength. {}.", e))
        .ok()
        .flatten();
    let Health {
        watchdog,
        battery_monitor,
    } = health;
    info!("To get battery level");
    let battery_level = battery_monitor.read_as_percent().await;
    info!("Battery level: {} %", battery_level);
    message._battery_level = Some(battery_level);
    info!("Message to publish: {:?}", message);
    let publish_time = send_data_via_gsm_modem(
        gsm_uart_tx,
        message,
        gsm_receiver,
        app_config,
        &mut app_state.gsm_incoming_messages,
    )
    .await?;
    app_state.last_publish = publish_time;
    watchdog.pet();
    Ok(())
}

Đoạn code này có giao tiếp với module Simcom để gửi dữ liệu qua mạng điện thoại. Có thể thấy await là lúc đang chờ phản hồi của Simcom, hoặc đang chờ lấy mẫu tín hiệu analog (đo mức pin). Những lúc ấy, nếu các tác vụ khác cũng đang await luôn thì CPU sẽ tranh thủ "chợp mắt" một chút.

Tuy nhiên, khi thiết kế board STM32 để dùng với Embassy thì đừng quên đưa chân reset ra cổng ST-Link. Lý do là một số mẫu ST32M có trục trặc khi vào giấc ngủ, nó sẽ không tương thích với giao thức truyền log RTT mà Embassy ưa dùng. Lúc đó công cụ theo dõi phía PC (probe-rs) sẽ cần chân reset để khắc phục.

Trong địa hạt "bare metal" thì Rust còn có một framework khác, RTIC, khá ngầu với những ưu điểm sau:

  • Efficient and data race free memory sharing
  • Deadlock free execution
  • Minimal scheduling overhead
  • Highly efficient memory usage

Tuy nhiên framework này không được nhắc đến nhiều lắm, có lẽ vì nó đem vào nhiều kết quả nghiên cứu học thuật, khiến người dùng khó hiểu.

Công cụ

Không như các bộ SDK, framework truyền thống, hệ sinh thái Rust không xây dựng riêng phần mềm code editor / IDE nào cả, mà chỉ phát triển công cụ để tích hợp vào những editor / IDE có sẵn. Điều này có lợi vì thường các editor / IDE đi kèm các bộ SDK (như STM32CubeMX, Arduino IDE) không xịn bằng các editor / IDE trung lập (như VS Code, Vim, Zed, Jetbrain). Vói Rust, ta có rust-analyzer dùng để cung cấp tính năng gợi ý code, xem nhanh tài liệu vắn tắt, giải thích kiểu.

rust-analyzer autocomplete

Bất cứ trình soạn thảo / IDE nào hỗ trợ giao thức LSP là có thể dùng rust-analyzer. Mặc dù bạn có thể dùng VS Code, tôi thì ưa dùng Helix vì chạy nhẹ và những tính năng sửa đổi theo khối (có thể xem trong videobài viết này).

Có lẽ bạn đã thấy một bức tranh hơi rõ ràng cho chuyến phiêu lưu với Rust rồi nhỉ. Nếu vậy hãy vào trang https://rustup.rs/ và tải về đi thôi. Nếu đã có Rust rồi mà bạn bối rối không biết khởi tạo dự án như thế nào, yên tâm, các framework kể trên dều có dự án mẫu cho bạn copy. Ngoài ra hãy giữ vững tinh thần vì thời gian đầu, Rust rất khó học, nhưng "thao trường đổ mồ hồi thì chiến trường bớt đổ máu", bạn sẽ thấy công học rất xứng đáng.