返回文章列表

Rust 控制 Raspberry Pi GPIO 輸出與輸入

本文介紹如何使用 Rust 與 `rust_gpiozero` crate 控制 Raspberry Pi 的 GPIO,實作 LED 燈的開關和按鈕輸入偵測,並探討底層原理,包含 Sysfs 介面與 `/dev/gpiomem` 的使用,以及如何解決按鈕彈跳問題和進行跨平台編譯。

嵌入式系統 Rust

rust_gpiozero 套件簡化了 Rust 在 Raspberry Pi 上的 GPIO 控制,讓開發者能輕鬆操作硬體。透過 LED 與按鈕的範例,展示瞭如何設定 GPIO 腳位為輸出或輸入模式,並控制 LED 狀態和讀取按鈕輸入。文章也說明瞭如何使用內部上拉/下拉電阻來穩定輸入訊號,以及如何透過軟體去抖動處理來避免按鈕彈跳造成的誤觸發。此外,文章還深入 rust_gpiozero 底層,解釋了 Sysfs 虛擬檔案系統和 /dev/gpiomem 記憶體對映的運作方式,以及 rppal 套件如何利用這些機制來提升效能。最後,也提到了如何在 x86 架構主機上進行跨平台編譯,以縮短在 Raspberry Pi 上的編譯時間。

使用 Rust 控制 Raspberry Pi 的 GPIO 輸出與輸入

本章節將介紹如何使用 Rust 程式語言控制 Raspberry Pi 的 GPIO(General Purpose Input/Output)輸出與輸入。我們將使用 rust_gpiozero 這個 crate 來簡化 GPIO 的操作。

設定專案與安裝依賴

首先,在 Raspberry Pi 的桌面環境中開啟終端機,並建立一個新的 Rust 專案:

$ cargo new physical-computing

接著,進入專案目錄並新增 rust_gpiozero 這個依賴:

$ cd physical-computing
$ cargo add rust_gpiozero

你的 Cargo.toml 檔案應該會像這樣:

[dependencies]
rust_gpiozero = "0.1"

控制 LED 燈的輸出

首先,我們來控制一個 LED 燈的輸出。假設我們已經將 LED 連線到 Raspberry Pi 的 GPIO Pin 2。

// src/main.rs
use rust_gpiozero::*;

fn main() {
    let led = LED::new(2);
    led.on();
}

你可以執行 cargo run 來編譯並執行這個程式。如果電路連線正確,你應該會看到 LED 燈亮起來。

程式碼解析:

  • LED::new(2) 建立了一個新的 LED 物件,並將其連線到 GPIO Pin 2。在建立的過程中,rust_gpiozero 會自動將該 Pin 設定為輸出模式。
  • led.on() 將 GPIO Pin 2 的電壓設為高(3.3V),使 LED 燈亮起來。

切換 LED 燈的狀態

要讓 LED 燈閃爍,可以使用迴圈並在 led.on()led.off() 之間加入延遲。

// src/main.rs
use rust_gpiozero::*;
use std::thread::sleep;
use std::time::Duration;

fn main() {
    let led = LED::new(2);
    loop {
        println!("on");
        led.on();
        sleep(Duration::from_secs(1));
        println!("off");
        led.off();
        sleep(Duration::from_secs(1));
    }
}

程式碼解析:

  • 使用 loop 迴圈讓程式無限執行。
  • sleep(Duration::from_secs(1)) 使程式暫停 1 秒鐘,實作閃爍效果。

或者,你可以使用 rust_gpiozero 提供的 LED::blink() 方法來簡化閃爍的實作:

// src/main.rs
use rust_gpiozero::*;

fn main() {
    let mut led = LED::new(2);
    led.blink(1.0, 1.0);
    led.wait(); // 防止程式立即離開
}

程式碼解析:

  • led.blink(1.0, 1.0) 使 LED 燈每隔 1 秒鐘閃爍一次。
  • led.wait() 確保程式不會在 blink 方法執行完後立即離開。

讀取按鈕輸入

接下來,我們將介紹如何使用 GPIO 讀取按鈕的輸入。首先,我們需要了解 GPIO 的輸入模式以及內部上拉/下拉電阻的組態。

GPIO 輸入模式與內部電阻

當 GPIO Pin 設定為輸入模式時,它可以檢測電壓變化。然而,如果沒有適當的組態,GPIO Pin 可能會處於浮動狀態,導致誤觸發。Raspberry Pi 提供內部上拉和下拉電阻來穩定輸入電壓。

  • 內部下拉電阻:將 GPIO Pin 連線到地(GND),預設電壓為 0V。當按鈕被按下時,GPIO Pin 被連線到 3.3V 電源,電壓升高。
  • 內部上拉電阻:將 GPIO Pin 連線到 3.3V 電源,預設電壓為 3.3V。當按鈕被按下時,GPIO Pin 被連線到地(GND),電壓降低。

按鈕電路設計

這裡我們使用內部上拉電阻的組態。按鈕的一端連線到 GPIO Pin 4,另一端透過限流電阻連線到地。當按鈕被按下時,GPIO Pin 4 的電壓會降低到 0V。

// src/main.rs
use rust_gpiozero::*;

fn main() {
    let mut led = LED::new(2);
    let mut button = Button::new(4);
    loop {
        println!("wait for button");
        button.wait_for_press(None);
        println!("button pressed!");
        led.toggle();
    }
}

程式碼解析:

  • Button::new(4) 建立了一個新的按鈕物件,並將其連線到 GPIO Pin 4。
  • button.wait_for_press(None) 使程式等待按鈕被按下。
  • 當按鈕被按下時,led.toggle() 切換 LED 的狀態。

實作按鈕控制LED並解決按鈕彈跳問題

在嵌入式系統中,控制LED燈的開關狀態是一個基本的實作練習。而當加入按鈕控制後,如何處理按鈕的機械彈跳(bounce)成為了一個重要的課題。本章節將介紹如何使用Rust語言來實作按鈕控制LED,並探討如何解決按鈕彈跳的問題。

初始化LED和按鈕結構

首先,我們需要初始化一個LED結構和一個按鈕結構。按鈕結構的初始化函式Button::new()會將指定的GPIO腳位組態為使用上拉電阻。如果需要使用下拉電阻,可以使用Button::new_with_pulldown()函式。

use rust_gpiozero::*;
fn main() {
    let mut led = LED::new(2);
    let mut button = Button::new(4);
    loop {
        button.wait_for_press(None);
        led.toggle();
    }
}

在這個範例中,程式會無限期地等待按鈕被按下。當按鈕被按下後,button.wait_for_press(None)函式會傳回並繼續執行下一行程式碼,進而切換LED的開關狀態。

解決按鈕彈跳問題

然而,在實際操作中,按鈕的機械結構可能會導致短時間內的多重觸發。這是因為當按鈕被按下時,內部的金屬片可能會振動並反覆接觸,從而觸發多次button.wait_for_press()。為瞭解決這個問題,我們需要對電路進行去抖(debounce)處理。

use rust_gpiozero::*;
use std::time::{Duration, Instant};

fn main() {
    let mut led = LED::new(2);
    let mut button = Button::new(4);
    let mut last_clicked = Instant::now();
    loop {
        button.wait_for_press(None);
        if last_clicked.elapsed() < Duration::new(1, 0) {
            continue;
        }
        led.toggle();
        last_clicked = Instant::now();
    }
}

內容解密:

  1. last_clicked變數用於記錄上一次按鈕被按下的時間。
  2. button.wait_for_press(None)傳回時,程式會檢查自上次按鈕被按下以來是否已經過了至少1秒。如果時間間隔太短,則認為是按鈕彈跳並忽略此次事件。
  3. 如果時間間隔足夠長,則切換LED的狀態並更新last_clicked為當前時間。

跨平台編譯到樹莓派

由於樹莓派的CPU效能相對於主流桌面電腦較弱,直接在樹莓派上編譯Rust程式可能會比較慢。因此,我們需要在效能更強的主機上進行跨平台編譯。

首先,需要在x86架構的Linux主機上安裝ARM架構的編譯工具鏈,包括新增編譯目標和安裝連結器。

rustup target add armv7-unknown-linux-gnueabihf
sudo apt-get install gcc-10-multilib-arm-linux-gnueabihf

然後,需要在~/.cargo/config檔案中指定連結器:

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc-10"

最後,可以使用以下指令進行跨平台編譯:

cargo build --target=armv7-unknown-linux-gnueabihf

內容解密:

  1. rustup target add armv7-unknown-linux-gnueabihf指令用於新增ARMv7架構的編譯目標。
  2. 由於樹莓派OS預設是32位元系統,因此即使CPU支援64位元,也需要使用ARMv7相容的編譯目標。
  3. gcc-10-multilib-arm-linux-gnueabihf套件提供了ARM架構的連結器。
  4. ~/.cargo/config中指定連結器,以確保Cargo使用正確的連結器進行編譯。
  5. 使用--target引數指定編譯目標,以進行跨平台編譯。

8.4 GPIO程式碼運作原理

rust_gpiozero 套件抽象化了大部分設定GPIO腳位的複雜度,但你可能會好奇它在底層是如何運作的。如第8.2節所述,GPIO暫存器透過兩種不同的介面暴露:/dev/gpiomem 和 Sysfs。首先,讓我們來看看Sysfs是如何運作的。

Sysfs介面

Sysfs將GPIO暫存器以虛擬檔案的形式暴露出來。要開啟一個LED,你可以寫一個簡單的shell指令碼來使用Sysfs虛擬檔案:

# led-on.sh
echo "2" > /sys/class/gpio/export
echo "out" > /sys/class/gpio/gpio2/direction
echo "1" > /sys/class/gpio/gpio2/value

首先,你需要將腳位編號寫入檔案 /sys/class/gpio/export,這告訴Sysfs你想要使用指定的腳位:

echo "2" > /sys/class/gpio/export

一個新的檔案 /sys/class/gpio/gpio2 將會出現。然後,你可以透過寫入 inout 到檔案 /sys/class/gpio/gpio2/direction 來設定腳位的方向。在這個例子中,我們將其設定為輸出模式:

echo "out" > /sys/class/gpio/gpio2/direction

這有效地設定了控制GPIO 2模式的暫存器。將腳位設為高或低電平就像寫入1或0到 /sys/class/gpio/gpio2/value 檔案一樣簡單。

內容解密:

  • echo "2" > /sys/class/gpio/export:將GPIO 2匯出,使其可用於使用者空間程式。
  • echo "out" > /sys/class/gpio/gpio2/direction:設定GPIO 2為輸出模式。
  • echo "1" > /sys/class/gpio/gpio2/value:將GPIO 2設為高電平,從而開啟LED。

rppal套件與/dev/gpiomem

rust_gpiozero 套件在底層使用了一個更低階的套件 rppal 來與GPIO腳位互動。出於效能考慮,rppal 套件並不使用Sysfs介面,而是直接與 /dev/gpiomem 互動。/dev/gpiomem 是一個虛擬裝置,代表記憶體對映的GPIO暫存器。如果你對 /dev/gpiomem 呼叫 mmap() 系統呼叫,GPIO暫存器將被對映到指定的虛擬記憶體位址。你可以直接讀寫記憶體中的位元來控制暫存器。

為什麼使用/dev/gpiomem?

/dev/gpiomem 出現之前,你只能透過 /dev/mem 來存取與GPIO相關的記憶體位址。然而,/dev/mem 暴露了整個系統記憶體,需要root許可權才能存取。這使得每個與GPIO互動的程式都需要使用root許可權,帶來了安全隱患。因此,/dev/gpiomem 被建立出來,只暴露與GPIO相關的記憶體部分,而不需要特殊許可權。

rppal原始碼解析

rppal 的原始碼中,你可以看到它首先嘗試使用 /dev/gpiomem。如果出現任何錯誤,它會退回到使用 /dev/mem,但這需要root許可權。

程式碼範例:rppal的src/gpio/mem.rs

const PATH_DEV_GPIOMEM: &str = "/dev/gpiomem";
const GPFSEL0: usize = 0x00;
const GPSET0: usize = 0x1c / std::mem::size_of::<u32>();
const GPCLR0: usize = 0x28 / std::mem::size_of::<u32>();
const GPLEV0: usize = 0x34 / std::mem::size_of::<u32>();

pub struct GpioMem {}

impl GpioMem {
    // ...
    fn map_devgpiomem() -> Result<*mut u32> {
        // ...
        // 將/dev/gpiomem記憶體對映到偏移量0
        let gpiomem_ptr = unsafe {
            libc::mmap(
                ptr::null_mut(),
                GPIO_MEM_SIZE,
                PROT_READ | PROT_WRITE,
                MAP_SHARED,
                gpiomem_file.as_raw_fd(),
                0,
            )
        };
        Ok(gpiomem_ptr as *mut u32)
    }

    #[inline(always)]
    fn write(&self, offset: usize, value: u32) {
        unsafe {
            ptr::write_volatile(
                self.mem_ptr.add(offset),
                value
            );
        }
    }

    #[inline(always)]
    pub(crate) fn set_high(&self, pin: u8) {
        let offset = GPSET0 + pin as usize / 32;
        let shift = pin % 32;
        self.write(offset, 1 << shift);
    }

    #[inline(always)]
    pub(crate) fn set_low(&self, pin: u8) {
        let offset = GPCLR0 + pin as usize / 32;
        let shift = pin % 32;
        self.write(offset, 1 << shift);
    }

    pub(crate) fn set_mode(&self, pin: u8, mode: Mode) {
        let offset = GPFSEL0 + pin as usize / 10;
        let shift = (pin % 10) * 3;
        // ...
        let reg_value = self.read(offset);
        self.write(
            offset,
            (
                reg_value & !(0b111 << shift)) |
                ((mode as u32) << shift
            ),
        );
    }
}

內容解密:

  • map_devgpiomem():將 /dev/gpiomem 記憶體對映到虛擬記憶體中,使得可以直接操作GPIO暫存器。
  • write():向指定的偏移量寫入值,用於控制GPIO暫存器。
  • set_high()set_low():分別用於將指定的GPIO腳位設為高或低電平。
  • set_mode():設定指定GPIO腳位的模式,例如輸入或輸出模式。