Uwaga! pełen kod tego wpisu dostępny jest tutaj

Pewnego pięknego dnia starając się nie pozwolić córce na zabawę starym kablem, który wyciągała z uciechą z szafy ukłułem się w palec pinem z zakurzonej maliny. Przypomniał mi się post, który niedawno czytałem o sterowaniu z rusta czujnikiem wilgoci i temperatury. Hmm a gdyby tak…

Na początek skrócona lekcja elektroniki i prosty projekt. Bardzo polecam przejście przez dowolną serię dobrze opisanych eksperymentów przed zabawą z maliną. Wiedza na temat tego co się dzieje i jak działa prąd zwiększy bezpieczeństwo (nasze i delikatnych układów scalonych) oraz zapewni dużo większą satysfakcję z całego procesu.

Zakupy i przygotowania… Link to heading

Dalej drobna lista zakupów:

  • Raspberry pi (właściwie dowolny model, ja posiadam 2B)
  • karta pamięci zgodna z wymaganiami maliny
  • karta rozszerzeń GPIO
  • Taśma 40-pin do karty GPIO
  • płytka stykowa
  • przewody męsko męskie
  • dioda led
  • rezystor o odpowiedniej wartości w moim przypakdu 4,7kohm (potem zamieniony na 330)

W pierwszej kolejności należy zadbać o to, żeby na karcie pamięci pojawił się zainstalowany aktualny system. Można podejść do tego zgodnie z instrukcją producenta. Podstawowe dane logowania to pi i hasło raspberry. Za pierwszym razem musimy się zalogować przy użyciu wyjścia hdmi i klawiatury fizycznej. Aby umożliwić wygodną dalszą pracę warto zadbać o możliwość zdalnego połączenia poprzez włączenie usługi ssh na malinie.

Dodatkowo zakładam, że kod napisany w rust będę uruchamiał na swoim laptopie z linuxem. Możliwe jest wykonanie tego samego procesu używając dowolnego systemu a nawet na samej malinie. Wybieram model pracy z kompilacją na swoim laptopie ze względu na czas kompilacji i obecność wszystkich wymaganych narzędzi.

Wymagane narzędzia do pracy z kodem w rust:

  • narzędzia do kompilacji rust
  • dowolny edytor tekstu, polecam VScode z wtyczką Rust Analyzer
  • ssh i scp do połączenia zdalnego i przesłania skompilowanego projektu

Czas zakasać rękawy Link to heading

Do maliny podłączamy taśmę gpio jak na zdjęciu:

A następnie taśmę do płytki rozszerzeń i zamontować ją na płytce stykowej. Pełen układ jest widoczny poniżej:

Oraz na zdjęciu

  • Pin GPIO23 jest połączony czerwonym przewodem z szyną dodatnią
  • PIN GND jest połączony z szyną ujemną
  • dioda LED (krótsza nóżka powinna być połączona z ujemną szyną)
  • rezystor o wartości 4,7K ohm (taki miałem pod ręką)
  • przewód zamykający obwód

Aby przetestować układ możemy uruchomić skrypt (na malinie):

cat <<EOF > led.py
from gpiozero import LED

from time import sleep

led = LED(23)

while True:
   led.on()
   sleep(1)
   led.off()
   sleep(1)
EOF
python led.py

Dioda powinna zacząć migać z przerwami 1 sekundy. Aby przerwać program należy nacisnąć klawisze CTRL+C. W tym momencie dioda przestanie się palić.

Ale co właściwie się stało Link to heading

Aby zrozumieć co właściwie się dzieje można przeczytać kod źródłowy, dokumentację albo… Hmmm, zaimplementować całość ręcznie w C (po raz pierwszy w życiu).

Wejście wyście ogólnego przeznaczenia - GPIO to cyfrowy interfejs komunikacji między elementami mikroprocesora a urządzeniami peryferyjnymi jak nasza dioda. Interfejs ten jest dostępny dla procesora jako zakres adresów w pamięci.

Istnieją dwa sposoby komunikacji:

  • /dev/mem (oraz bardziej bezpieczny /dev/gpiomem)
  • sysfs - pseudo system plików dostarczany wraz z jądrem linuksa

Ostatni sposób jest bardzo prosty i polega na manipulowaniu plikami:

echo 23 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio23/direction 
echo 1 > /sys/class/gpio/gpio23/value
echo 0 > /sys/class/gpio/gpio23/value

Za zapalanie naszej diody odpowiada w tym przypadku kernel. Jednak wadą jest brak kontroli nad momentem wykonania operacji. Nie ma to wielkiego znaczenia kiedy chcemy zapalać diodę LED, ale może mieć ogromne znaczenie, jeżeli nasze układy staną się bardziej złożone i zależne od czasu.

Aby sterować w naszym programie pinem GPIO użyjemy pierwszego sposobu przy użyciu pliku /dev/gpiomem. W pierwszej kolejnośći należy otworzyć jeden ze wskazanych plików i użyć wywołania systemowego mmap które spowoduje, że system odwzoruje ten plik w przestrzeni adresowej pamięci procesu.

Od tego momentu plik z perspektywy naszego programu wygląda jak zwykła tablica bajtów, nie musimy wykorzystywać innych wywołań systemowych do odczytu czy zapisu.

Ufff… Dużo gadania, ale czy ten super prosty skrypt pythonowy też musiał się tak męczyć? Żeby to sprawdzić bez wczytywania się w dokumentację biblioteki czy kodu możemy wykorzystać system operacyjny.

pi@raspberrypi:~ $ python led.py 
^CTraceback (most recent call last):
  File "led.py", line 11, in <module>
    sleep(1)
KeyboardInterrupt
closing _devices_shutdown
pi@raspberrypi:~ $ python led.py 
this is weird
^Z
[2]+  Zatrzymano              python led.py
pi@raspberrypi:~ $ lsof -p $(pidof python)
COMMAND  PID USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
python  4178   pi  cwd    DIR  179,2     4096 263116 /home/pi
python  4178   pi  rtd    DIR  179,2     4096      2 /
python  4178   pi  txt    REG  179,2  2984816 271183 /usr/bin/python2.7
python  4178   pi  mem    REG  179,2    42908  20121 /usr/lib/python2.7/dist-packages/RPi/_GPIO.arm-linux-gnueabihf.so
python  4178   pi  mem    REG  179,2  3031504 274369 /usr/lib/locale/locale-archive
python  4178   pi  mem    REG  179,2  1296004    427 /lib/arm-linux-gnueabihf/libc-2.28.so
python  4178   pi  mem    REG  179,2   464392    452 /lib/arm-linux-gnueabihf/libm-2.28.so
python  4178   pi  mem    REG  179,2   108168    514 /lib/arm-linux-gnueabihf/libz.so.1.2.11
python  4178   pi  mem    REG  179,2     9796    511 /lib/arm-linux-gnueabihf/libutil-2.28.so
python  4178   pi  mem    REG  179,2     9768    435 /lib/arm-linux-gnueabihf/libdl-2.28.so
python  4178   pi  mem    REG  179,2   130416    491 /lib/arm-linux-gnueabihf/libpthread-2.28.so
python  4178   pi  mem    REG  179,2    19876  19899 /usr/lib/python2.7/dist-packages/spidev.arm-linux-gnueabihf.so
python  4178   pi  mem    REG  179,2    17708 272881 /usr/lib/arm-linux-gnueabihf/libarmmem-v7l.so
python  4178   pi  mem    REG  179,2   138604    352 /lib/arm-linux-gnueabihf/ld-2.28.so
python  4178   pi  mem    CHR  247,0            1116 /dev/gpiomem
python  4178   pi    0u   CHR  136,0      0t0      3 /dev/pts/0
python  4178   pi    1u   CHR  136,0      0t0      3 /dev/pts/0
python  4178   pi    2u   CHR  136,0      0t0      3 /dev/pts/0
python  4178   pi    3u   CHR  247,0      0t0   1116 /dev/gpiomem

Włączamy nasz skrypt i naciskamy CTRL+Z wstrzymując proces poprzez wysłanie sygnału SIGSTOP. Następnie używając polecenia lsof -p $(pidof python) znajdujemy listę plików otwartych przez proces. Ha! /dev/gpiomem znajduje się na liście!

W takim razie spróbujemy ręcznie używając C:

#include <stdio.h>    // for printf
#include <fcntl.h>    // for open
#include <sys/mman.h> // for mmap
#include <unistd.h>

#define GPCLR0 0x28
#define GPSET0 0x1C
#define GPLEV0 0x34

/* 
    Sprawdza stan pina o numerze 23, ustawia stan na wysoki po czym zmienia na niski

    https://www.raspberrypi.org/documentation/hardware/raspberrypi/bcm2835/BCM2835-ARM-Peripherals.pdf
    https://www.cs.uaf.edu/2016/fall/cs301/lecture/11_09_raspberry_pi.html
    https://elinux.org/RPi_GPIO_Code_Samples
 */
int main()
{
    int fd = open("/dev/gpiomem", O_RDWR);
    if (fd < 0)
    {
        printf("Error opening /dev/gpiomem");
        return -1;
    }

    unsigned int *gpio = (unsigned int *)mmap(0, 4096,
                                              PROT_READ + PROT_WRITE, MAP_SHARED,
                                              fd, 0);

    int gpio_num = 23;

    // offset w bajtach pomiędzy kolejnymi elementami w tablicy
    // 32 bity = 8 * 4 bajty
    int u32_offset = 4;

    int FSEL_SHIFT = (gpio_num) / 10;

    // każdy pin ma przypisane 3 bity
    // 000 -> input
    // 001 -> output
    // 010 i wyżej -> alternate functions (zależne od numeru pinu)
    //
    // więcej w rozdziale 6.2
    gpio[FSEL_SHIFT] &= ~(7 << (((gpio_num) % 10) * 3)); // zawsze przed ustawieniem na output musimy ustawić na input
    gpio[FSEL_SHIFT] |= (1 << (((gpio_num) % 10) * 3));  // output

    // GPLEV0 piny 0 - 31, ten kod nie obsłuży pin > 31
    // Odczytuje stan pina gpio_num poprzez odczytanie bitu gpio_num rejestru GPLEV0
    int state = (gpio[GPLEV0 / u32_offset] >> gpio_num) & 1;
    printf("status is %i\n", state);

    while (1==1)
    {
        sleep(1);

        gpio[GPSET0 / u32_offset] |= 1 << gpio_num;
        printf("set to high\n");

        sleep(1);

        gpio[GPCLR0 / u32_offset] |= 1 << gpio_num;
        printf("set to low\n");
    }

    return 0;
}

Układ scalony BCM2835 posiada 41 rejestrów, każdy z nich ma 32 bity. Aby mieć do nich dostęp w pierwszej kolejności otwieramy plik /dev/gpiomem

int fd = open("/dev/gpiomem", O_RDWR);

Oraz używamy mmap aby mieć możliwość operowania na jego zawartości:

    unsigned int *gpio = (unsigned int *)mmap(0, 4096,
                                              PROT_READ + PROT_WRITE, MAP_SHARED,
                                              fd, 0);

Zgodnie z dokumentacją aby odczytać stan danego pina (w naszym przypadku 23) należy odczytać odpowiedni bit rejestru GPLEV0 który posiada adres 0x 7E20 0034 (offset 0x34):

int state = (gpio[GPLEV0 / u32_offset] >> gpio_num) & 1;

Jeżeli chcemy zmienić stan danego pina musimy najpierw zmienić jego tryb na wyjściowy. Zgodnie z dokumentacją każdy z 54 pinów posiada przynajmniej dwie funkcje. Przykładowo, żeby ustawić tryb pracy pina 23 (a pozostałych na domyślną wartość 000) należy ustawić wartość rejestru GPFSEL2 (rejestr dla pinów 20-29) na 001

00000000000000000000001000000000

W kodzie przed ustawieniem trybu wyjściowego najpierw ustawiam na tryb wejściowy podążając za poradami z przykładów. Następnie aby ustawić wartość pina na wysoką należy ustawić odpowiedni bit w rejestrze GPSET{n} gdzie n==0 dla pinów 0-31.

gpio[GPSET0 / u32_offset] |= 1 << gpio_num;

Aby zgasić naszą diodę należy ustawić odpowiedni bit innego rejestru: GPCLR{n} gdzie n==0 dla pinów 0-31:

gpio[GPCLR0/u32_offset] |= 1 << gpio_num;

Plik możemy skompilować na malinie używając bibliotek dostarczanych razem z systemem operacyjnym:

pi@raspberrypi:~ $ gcc led.c -o led
pi@raspberrypi:~ $ ./led 

Co jeżeli nie jestem wielkim fanem C? Link to heading

To już w sumie 3 różne sposoby na wykonywanie tej samej - mało potrzebnej czynności. Co jeżeli manipulowanie plikami nam nie odpowiada, nie chcemy tracić zalet rozwiązania z C a języki interpretowane zachęcają nas tak samo jak głaskanie jeża pod włos? Pora na kolejną technologię!

Aby rozpocząć projekt na maszynie na której chcemy budować kod należy stworzyć nowy projekt:

# tworzymy nowy projekt w katalogu rusty_led
cargo new rusty_led --bin
cd rusty_led

# instalacja rustup powoduje zainstalowanie bibliotek dla naszego środowiska
# aby zainstalować biblioteki dla raspberry pi należy wykonać polecenie:
rustup target add armv7-unknown-linux-gnueabihf
# install arm linker
# todo sprawdzić czy to jest potrzebne
sudo apt-get install -qq gcc-arm-linux-gnueabihf
tree .

Musimy dodać w ~/.cargo/config następującą treść:

mkdir .cargo
cat <<EOF > .cargo/config
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
EOF

Następnie możemy uruchomić kompilację:

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

Gotowy plik wykonywalny jest dostępny w pliku target/armv7-unknown-linux-gnueabihf/debug/rusty_led. Aby go wykonać na malinie musimy go wysłać na malinę. Zakładając, że znamy adres ip maliny (w moim przypadku 192.168.8.103) oraz, że skonfigurowaliśmy odpowiednie klucze prywatne i publiczne możemy go przesłać używając polecenia:

# przesyłamy plik
scp target/armv7-unknown-linux-gnueabihf/debug/rusty_led [email protected]:~/
# uruchamiamy skompilowany plik wykonywalny
ssh [email protected] './rusty_led'

#   output:
#   Hello, world!

Teraz należy dodać zależność do biblioteki która umożliwi nam używanie GPIO. Zeby to zrobić starczy dopisać do pliku Cargo.toml w sekcji [dependencies]:

rppal = "0.11.3"

A treść pliku main.rs zamienić:

use std::error::Error;
use std::thread;
use std::time::Duration;

use rppal::gpio::Gpio;
use rppal::system::DeviceInfo;

fn main() -> Result<(), Box<dyn Error>> {
    println!("Działam na {}.", DeviceInfo::new()?.model());

    let mut pin = Gpio::new()?.get(23)?.into_output();
    pin.set_reset_on_drop(true);

    loop {
        pin.set_high();
        thread::sleep(Duration::from_millis(500));
        pin.set_low();
        thread::sleep(Duration::from_millis(500));
    }

    Ok(())
}

Po skompilowaniu, przesłaniu i wykonaniu programu na malinie osiągamy analogiczny efekt do kodu z pythona i C. Jeżeli zaglądniemy dokładniej w kod źródłowy biblioteki rppal to zauważymy pewne podobieństwa do programu, który napisaliśmy w C:

    fn map_devgpiomem() -> Result<*mut u32> {
        // Open /dev/gpiomem with read/write/sync flags. This might fail if
        // /dev/gpiomem doesn't exist (< Raspbian Jessie), or /dev/gpiomem
        // doesn't have the appropriate permissions, or the current user is
        // not a member of the gpio group.
        let gpiomem_file = OpenOptions::new()
            .read(true)
            .write(true)
            .custom_flags(O_SYNC)
            .open(PATH_DEV_GPIOMEM)?;

        // Memory-map /dev/gpiomem at offset 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,
            )
        };

        if gpiomem_ptr == MAP_FAILED {
            return Err(Error::Io(io::Error::last_os_error()));
        }

        Ok(gpiomem_ptr as *mut u32)
    }

Hmmm pomijając o wiele ładniejszą kontrolę błędów kod jest właściwie taki sam! Wykorzystuje crate (biblioteka w środowisku rusta) libc do wywołania w bloku unsafe tego samego wywołania systemowego. Podobnie jest w przypadku pozostałych fragmentów, przykładowo ustawienia wartości pin:

    const GPSET0: usize = 0x1c / std::mem::size_of::<u32>();

    // some parts omitted

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

Jest jeden problem, zachowanie naszego układu elektronicznego po wyjściu z programu jest niedeterministyczne (podobnie jak w przypadku programu w C). Przez wyjście mam na myśli naciśnięcie CTRL+C czyli wysłanie sygnału SIGINT. Obydwa programy (C i rust) nie czyszczą w żaden sposób stanu, dioda zostaje włączona jeżeli w momencie wysłania sygnału była właśnie w takim stanie.

Czemu tak jest? Jedynie biblioteka w pythonie którą używaliśmy posiada taką funkcjonalność. Fragment kodu który za nią odpowiada można znaleźć w pliku devices.py

def _devices_shutdown():
    if Device.pin_factory is not None:
        with Device.pin_factory._res_lock:
            reserved_devices = {
                dev
                for ref_list in Device.pin_factory._reservations.values()
                for ref in ref_list
                for dev in (ref(),)
                if dev is not None
            }
        for dev in reserved_devices:
            dev.close()
        Device.pin_factory.close()
        Device.pin_factory = None

Bardzo sympatyczna funkcjonalność tylko co jeżeli jej nie chcemy? Okazuje się, że nie jest to łatwe, o czym można poczytać tu czy tu.

Co jednak jeżeli chcemy dodać podobną funkcjonalność do naszego nowego kodu w rust? Zgodnie z dokumentacją crate rppal pin powinien zostać przywrócony do stanu oryginalnego w momencie kiedy zgodnie z zasadami własności obiekt zostaje porzucony. Ale co właściwie oznacza porzucenie?

Rust posiada innowacyjne podejście do zarządzania pamięcią poprzez wprowadzenie modelu własności. Zarządzanie pamięcią nie jest jak w przypadku C czy C++ zarządzane ręcznie przez programistę, czy jak w javie czy go przez osobny obiekt nazywany garbage collection. O momencie zwalniania pamięci decyduje zestaw reguł, które kompilator częsciowo jest w stanie wywnioskować sam na podstawie ogólnych zasad lub z użyciem parametrów przekazywanych przez programistę w nieoczywistych przypadkach.

use std::thread;
use std::time::Duration;

struct HasDrop{
    pub name: u32
}

// 
impl Drop for HasDrop {
    fn drop(&mut self) {
        println!("Dropping {}", self.name);
    }
}

fn main() {
    {
        let _x = HasDrop{name: 1};
    } // _x zostaje porzucone w tym miejscu
    let _y = HasDrop{name: 2};
    thread::sleep(Duration::from_millis(5000));
} // _ y w tym miejscu jeżeli wcześniej nie wysłany zostanie SIGINT
$ cargo -q run --example drop
Dropping 1
Dropping 2
$ cargo -q run --example drop
Dropping 1
^C
# po uzyskaniu SIGINT brak wpisu o wykonaniu metody drop na y

Model ten daje ogromne korzyści i może być wykorzystywana również do ciekawych zastosowań, jak na przykład oddawanie połączenia do bazy danych do pooli po jego wykorzystaniu bez pisania niepotrzebnego kodu. W ten sam sposób zorganizowane jest przywracanie stanu oryginalnego dla pinów w rppal:

            fn drop(&mut self) {
                if !self.reset_on_drop {
                    return;
                }

                if let Some(prev_mode) = self.prev_mode {
                    self.pin.set_mode(prev_mode);
                }

                if self.pud_mode != PullUpDown::Off {
                    self.pin.set_pullupdown(PullUpDown::Off);
                }
            }

Jednak jak udowodniliśmy wyżej metoda drop nie jest wołana w sytuacji kiedy przyczyną wyjścia z programu był sygnał SIGINT. Czy jesteśmy w stanie coś z tym zrobić? Zgodnie z sugestią autora biblioteki musimy ręcznie obsłużyć odpowiedni sygnał. W pierwszej kolejności dodajemy nową bibliotekę do Cargo.toml:

ctrlc = "3.1.4"

A następnie modyfikujemy nasz program:

use std::error::Error;
use std::thread;
use std::time::Duration;

use rppal::gpio::Gpio;
use rppal::system::DeviceInfo;

use std::sync::{Arc, Mutex};

//https://docs.golemparts.com/rppal/0.11.2/rppal/gpio/struct.OutputPin.html#note

fn main() -> Result<(), Box<dyn Error>> {
    println!("Działam na {}.", DeviceInfo::new()?.model());

    let mut pin = Gpio::new()?.get(23)?.into_output();
    let closed = Arc::new(Mutex::new(false));

    let closed_handler = closed.clone();
    ctrlc::set_handler(move || {
        println!("received Ctrl+C!");
        println!("set to closed");
        *closed_handler.lock().unwrap() = true;
    })?;

    while !*closed.lock().unwrap() {
        pin.set_high();
        thread::sleep(Duration::from_millis(500));
        pin.set_low();
        thread::sleep(Duration::from_millis(500));
    }

    println!("setting to low");
    pin.set_low();
    Ok(())
}

Dodaliśmy zmienną closed zabezpieczoną Arc - mądrym wskaźnikiem pozwalającym na dzielenie fragmentu pamięci pomiędzy wątkami i Mutex który zabezpiecza do niego dostęp. Czemu nie dodamy po prostu ustawienia stanu pina na niski w handlerze sygnału?

    ctrlc::set_handler(move || {
        println!("received Ctrl+C!");
        pin.set_low();
    })?;

Odpowiedź brzmi ponieważ kompilator rusta nam na to nie pozwoli. Otrzymamy błąd:

error[E0382]: borrow of moved value: `pin`
  --> src/main.rs:26:9
   |
15 |     let mut pin = Gpio::new()?.get(23)?.into_output();
   |         ------- move occurs because `pin` has type `rppal::gpio::pin::OutputPin`, which does not implement the `Copy` trait
...
19 |     ctrlc::set_handler(move || {
   |                        ------- value moved into closure here
...
22 |         pin.set_low();
   |         --- variable moved due to use in closure
...
26 |         pin.set_high();
   |         ^^^ value borrowed here after move

error: aborting due to 2 previous errors

Czyli zadziałają zasady własności które ochronią nas przed błędem, który nie zostałby wychwycony w analogicznym kodzie w C. Na czym polega błąd? Kod wykonywany po otrzymaniu sygnału SIGINT jest wykonywany w innym wątku niż pętla while co oznacza, że potencjalnie wystąpiłby wyścig i zachowanie programu mogło by być różne.

Wnioski Link to heading

Nauczyliśmy się w jaki sposób można sterować pinami maliny oraz wielu sposobów zapalania diody LED. Z moich doświadczeń z budowania tak prostego ukłądu wynika, że potrzebna jest duża uwaga, żeby nie popełnić błędu. Łatwiej jest znaleźć i rozwiązać błąd jeżeli taki się pojawi w prostszym układzie. Wraz ze zwiększaniem się rozmiarów kodu sterującego coraz bardziej zaawansowanymi układami prawdopodobieństwo błędów się powiększa.

Dlatego w mojej ocenie używanie narzędzi jak rust ma ogromną przewagę nad C, w którym brakuje silnego typowania oraz obsługi błędów. Po drugiej stronie jest python, który bardzo pasuje do maliny, natomiast jego użyteczność zmniejsza się na mniejszych układach, gdzie dostępna pamięć jest zdecydowanie większym ograniczneniem.