Strona główna Budowanie aplikacji dla SBC w środowisku emulowanym
Post
Cancel

Budowanie aplikacji dla SBC w środowisku emulowanym

Komputery jednopłytkowe (ang. SBC — Single Board Computer) to uniwersalne urządzenia mikroprocesorowe pracujące pod kontrolą systemu operacyjnego. Zazwyczaj systemem tym jest Linux. Architektury komputerów jednopłytkowych mogą istotnie się różnić od architektur stosowanych dla komputerów osobistych. Niestety natywne uruchamianie kodu z wykorzystaniem GUI pracującego na systemie target jest często niewykonalne, głównie ze względu na ograniczony interfejs użytkownika i niezbyt duże zasoby urządzenia. Zazwyczaj problem ten rozwiązuje się przy użyciu narzędzi do kompilacji skrośnej.

W niniejszym opracowaniu przedstawiono podejście alternatywne bazujące na wykorzystaniu emulacji architektury urządzenia docelowego z wykorzystaniem QEMU i połączonej z hermetyzacją środowiska developerskiego w kontenerze Docker’a. Dzięki tej technice możliwe jest wygenerowanie kodu binarnego dla urządzenia docelowego bez korzystania z technik kompilacji skrośnej. Jako frontend dewelopera zaproponowano wykorzystanie programu VS Code oraz techniki DevContainers.

Stworzenie środowiska pracy skrośnej jest zadaniem żmudnym, zwłaszcza jeżeli w systemie muszą współegzystować środowiska przeznaczone dla różnych urządzeń docelowych. Oczywiście ideałem byłoby środowisko pracy natywnej na urządzeniu target, jednak opcja ta jest często niemożliwa do realizacji praktycznej ze względu na jego ograniczone zasoby. Okazuje się, że konteneryzacja w połączeniu z emulacją architektury pozwala na stworzenia środowiska budowy aplikacji bardzo zbliżonego tego ideału. Przedstawiona dalej konfiguracja bardzo przypomina pracę zdalną w tunelu SSH [1], jednak tym razem urządzenie docelowe nie jest w zaangażowane w proces budowania aplikacji. Oczywiście jest ono niezbędne do jej debugowania. Proces skrośnego debugowania opisano w podrozdziale “Uruchamianie skrośne” wspomnianego wcześniej opracowania [1].

Realizacja przedstawionego dalej scenariusza wymaga zainstalowania na systemie host Docker‘a i opcjonalnie QEMU. Pozostałe oprogramowanie zostanie umieszczone w kontenerach i może być łatwo usunięte z systemu jednym poleceniem.

  • QEMU to dostępne nieodpłatnie środowisko wirtualizacji systemów oraz dynamicznej emulacji architektur. Pierwsza opcja zapewnia możliwość uruchomienia pełnego systemu operacyjnego na wirtualnym urządzeniu. Innymi słowy, wirtualizator zapewnia abstrakcyjną warstwę dostępu do sprzętu. Szczególną cechą QEMU jest możliwość wirtualizacji systemów dla architektur innych niż architektura systemu goszczącego (popularne hypervisory takie jak VirtualBox, VMWare Workstation czy Hyper-V tego nie potrafią). Z kolei dynamiczna emulacja polega na tłumaczeniu w locie kodu binarnego z architektury systemu emulowanego na architekturę systemu goszczącego. Aplikacje uruchamiane w tym trybie korzystają bezpośrednio z jądra systemu goszczącego, w tym sprzętu dostępnego na tym systemie. Z punktu widzenia użytkownika, aplikacje uruchomione w tym trybie wyglądają identycznie jak aplikacje natywne, jedynie wykonują się wolniej ze względu na niezbędną warstwę translacji.

  • Docker to środowisko konteneryzacji zapewniające aplikacjom hermetyczne środowisko pracy (dostęp do usług jądra systemu, środowisko sieciowe, system plików), co umożliwia ich bezproblemową dystrybucję i pracę niezależnie od systemu operacyjnego gospodarza. W połączeniu z emulacją architektur zapewnianą przez QEMU możliwe jest uruchamianie i budowanie kontenerów na architekturę inną niż ta, na której uruchomiony jest Docker.

Idea budowy aplikacji z wykorzystaniem emulacji architektury zakłada

  • uruchomienie emulacji architektury systemu target (tego, na którym ma działać aplikacja) na systemie host (tego, na którym zostanie skompilowana),

  • przygotowanie kontenera dla architektury target, który zawiera jego główny system plików uzupełniony o natywne dla target narzędzia budowania aplikacji (kompilator, linker, biblioteki),

  • uruchomienie w emulowanym kontenerze kompilacji natywnej (czyli dla architektury target).

Aktywacja emulacji architektury systemu target

W systemie host konieczna jest aktywacja emulacji architektury systemu target. Można to osiągnąć, instalując pakiet qemu-user-static

1
apt install -y qemu-user-static

Alternatywnie, można skorzystać z gotowego kontenera

1
docker run --rm --privileged multiarch/qemu-user-static:register --reset

Listę emulowanych architektur zwraca komenda

1
ls  /proc/sys/fs/binfmt_misc

Przygotowanie kontenera dla architektury systemu target

W pustym katalogu należy umieścić archiwum z głównym systemem plików urządzenia target. Archiwum można utworzyć, pobierając, z zachowaniem uprawnień, pliki z karty SD zawierającej firmware urządzenia target (w poniższym przykładzie przyjęto, że po zamontowaniu w systemie host zawartość karty jest widoczna w katalogu ‘/media/akuku/a6a42a97-600f-4ed7-ab06-6a2a169c62f3/’). Archiwum ‘rootfs.tar’ buduje komenda

1
(cd /media/akuku/a6a42a97-600f-4ed7-ab06-6a2a169c62f3/ ; sudo tar cvf - *) | cat > ./rootfs.tar

Inspekcja zawartości archiwum komendą tar tf rootfs.tar | head -n 10 powinna wykazać wynik podobny do poniższego (brak dodatkowych elementów przed ‘bin’, ‘boot’, itd.)

1
2
3
4
5
6
7
8
9
10
bin
boot/
boot/boot.cmd
boot/orangepi_first_run.txt.template
boot/boot.scr
boot/orangepiEnv.txt
boot/vmlinuz-4.9.170-sun50iw9
boot/config-4.9.170-sun50iw9
boot/uInitrd
boot/boot.bmp

Zgodnie z zasadą *wszystko można, co nie można, byle z wolna i ostrożna*, główny system plików można również pobrać z działającego urządzenia.

W tym samym katalogu należy utworzyć plik Dockerfile o treści

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
FROM scratch

ARG USERNAME=orangepi
ARG USER_UID=1000
ARG USER_GID=1000

ADD rootfs.tar /

USER root
WORKDIR /root

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
    && apt-get -y install build-essential make gpiod

ENV HOME=/home/${USERNAME}
# [Optional] Customize environment for nonroot user
#RUN groupadd --gid "${USER_GID}" "${USERNAME}" && \
#    useradd --uid "${USER_UID}" --gid "${USER_GID}" --create-home "${USERNAME}" && \
#    apt-get update && \
#    apt-get -yq install sudo && \
#    echo "${USERNAME}" ALL=\(root\) NOPASSWD:ALL > "/etc/sudoers.d/${USERNAME}" && \
#    chmod 0440 "/etc/sudoers.d/${USERNAME}" 
#    && \
#    usermod -aG docker "${USERNAME}"

WORKDIR ${HOME}
USER ${USERNAME}

CMD uname -a

Kompilacja w kontenerze pozostawia pliki binarne w katalogu projektu. Właścicielem tych plików jest użytkownik (określony w kontenerze, tutaj orangepi), w którego imieniu została wykonana kompilacja. Zatem, aby uniknąć kłopotów z dostępem do plików, należy zadbać o to, aby UID i GID użytkownika systemu host i użytkownika w kontenerze były takie same. Wartości te sprawdza się poleceniem id wydanym w terminalu. W powyższym przykładzie przyjęto, że zarówno użytkownik ‘akuku’ systemu host oraz użytkownik ‘orangepi’ zdefiniowany w kontenerze mają UID i GID równe 1000.

Przedstawiony powyżej plik definiuje sposób budowy kontenera. Przed jej uruchomieniem należy sprawdzić, czy jest ona możliwa

1
2
docker buildx ls
docker buildx use default

Jeżeli docelowa platforma jest na liście, to do zbudowania kontenera wystarczy poniższa komenda

1
docker buildx build --platform linux/arm64 -t pzawad/orangepi-emucontainer .

wydana w katalogu zawierającym pliki ‘Dockerfile’ i ‘rootfs.tar’. Architekturę zbudowanego kontenera, wersję systemu operacyjnego oraz dostępność kompilatora należy oczywiście sprawdzić

1
2
3
4
docker image inspect pzawad/orangepi-emucontainer | grep Architecture
docker run -it --rm pzawad/orangepi-emucontainer bash -c 'uname -a'
docker run -it --rm pzawad/orangepi-emucontainer bash -c 'cat /etc/issue'
docker run -it --rm pzawad/orangepi-emucontainer bash -c 'gcc -v'

Kompilacja emulowana z wykorzystaniem kontenera

Kontener z narzędziami dla architektury docelowej pozwala na skompilowanie na systemie host kodu dla systemu target w trybie kompilacji natywnej. Katalog ze źródłami aplikacji jest mapowany do wnętrza kontenera, w którym wywoływany jest kompilator natywny dla platformy docelowej. Kompilator “widzi” kopię głównego systemu plików urządzenia target, dzięki czemu wszystkie pliki nagłówkowe i biblioteki dynamiczne są na właściwym miejscu. Komenda realizująca kompilację ma np. postać

1
docker run -it --rm --platform=linux/arm64 --name omgthisworks -v ${PWD}:/home/orangepi pzawad/orangepi-emucontainer make

Integracja z Visual Studio Code nie nastręcza trudności. Do pliku ‘.vscode/tasks.json’ wystarczy dodać zadanie postaci

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Native Build in container",
      "type": "shell",
      "command": " docker run -it --rm --platform=linux/arm64 --name omgthisworks -v ${workspaceFolder}:/home/orangepi pzawad/orangepi-emucontainer make",
      "group": {
        "isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task maps workspace into container and triggers native build inside."
    }
  ]
}

Debugowanie kodu

Natywne debugowanie kodu z wykorzystaniem GUI pracującego na systemie target jest często awykonalne, głównie ze względu na ograniczony interfejs użytkownika i niezbyt duże zasoby urządzenia. Z kolei zdalna praca w konsoli tekstowej z debuggerem gdb jest doznaniem ekstremalnym.

Rozwiązaniem problemu jest podzielenie zadania uruchamiania na dwa oddzielne procesy. Proces gdbserver pracuje na systemie target i odpowiada za krokowe wykonywanie uruchamianej aplikacji. Z kolei interfejs użytkownika uruchomiony na systemie host`` komunikuje się z serwerem poprzez sieć lub łącze szeregowe. Programem uruchomionym na systemie host jest gdb-multiarch (jest to wersja gdb, która “rozumie” różne architektury). Oba komponenty komunikują się za pomocą protokołu TCP/IP. W efekcie struktura komunikacji pomiędzy narzędziami jest następująca: interfejs GUI uruchomiony w systemie host komunikuje się lokalnie z gdb-multiarch, który z kolei poprzez sieć komunikuje się z gdbserver uruchomionym na systemie target. GUI odpowiedzialne za przeprowadzenie sesji uruchamiania musi poinformować gdb-multiarch o architekturze uruchamianego kodu i adresie serwera oczekującego na połączenie. W Visual Studio Code za konfigurację procesu uruchamiania odpowiedzialny jest plik *.vscode/launch.json*. Dla rozważanego projektu przyjmie on postać

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "GDB-REMOTE",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/main",
      "miDebuggerServerAddress": "opi.usb:1234",
      "miDebuggerPath": "/usr/bin/gdb-multiarch",
      "targetArchitecture": "arm64",
      "args": [],
      "stopAtEntry": true,
      "cwd": "${workspaceFolder}",
      "environment": [],
      "externalConsole": false,
      "MIMode": "gdb",
      "setupCommands": [
        {
          "description": "sets target architecture",
          "text": "-ex 'set architecture aarch64'",
          "ignoreFailures": true
        },
        {
          "description": "Enable pretty-printing for gdb",
          "text": "-enable-pretty-printing",
          "ignoreFailures": true
        }
      ]
    }
  ]
}

Sesję uruchamiania aktywuje się skrótem “F5” lub wybierając ikonę “żuczka” bug na pasku bocznym. Powyższe zadanie realizuje tylko komunikację lokalną z gdb-multiarch, tak więc każdorazowo przed rozpoczęciem sesji uruchamiania należy w terminalu Visual Studio Code wydać komendę

1
scp ./main root@opi.usb: && ssh -t root@opi.usb "gdbserver --once localhost:1234 ./main"

Zadanie można sobie znacznie ułatwić, dopisując odpowiednie cele do Makefile

1
2
3
4
5
debug: $(APPNAME)
  scp $(APPNAME) $(REMOTE): && ssh -t $(REMOTE) "gdbserver --once localhost:1234 ./$(APPNAME)"

run: $(APPNAME)
  scp $(APPNAME) $(REMOTE): && ssh -t $(REMOTE) "./$(APPNAME)"

Podsumowanie

Zaletą opisanego podejścia jest prawidłowe dynamiczne łączenie bibliotek bez żadnych dodatkowych zabiegów. Co więcej, nie jest konieczna instalacja narzędzi do kompilacji ani na systemie target, ani na systemie host. Kontener służący do tworzenia aplikacji może być łatwo dystrybuowany pomiędzy programistami. W efekcie procedurę przygotowania kontenera zawierającego oprogramowanie niezbędne do pracy nad aplikacja wystarczy przeprowadzić tylko raz. Ma to olbrzymie znaczenie w pracy grupowej gdzie hermetyczność narzędzi i powtarzalność procedur mają ogromne znaczenie.

Bibliografia

[1] PeJotZet “Jak kompilować i debugować aplikacje w C/C++ na komputery jednopłytkowe korzystając z VS Code”, 2023

Kruczki i sztuczki

Zdalny dostęp do konta administratora bez podawania hasła

  1. Zdalny dostęp do konta administratora

    W pliku /etc/ssh/sshd_config (target) należy ustawić opcję

    1
    
     PermitRootLogin   yes
    
  2. Logowanie bez konieczności podawania hasła

    • Serwer ssh musi zezwalać na uwierzytelnienie za pomocą kluczy publicznych. W pliku /etc/ssh/sshd_config (target) należy ustawić opcje

      1
      2
      
      PubkeyAuthentication   yes
      AuthorizedKeysFile    .ssh/authorized_keys    .ssh/authorized_keys2
      
    • Użytkownik systemu host musi mieć wygenerowane osobiste klucze ssh. Generacja komendą

      1
      
      ssh-keygen
      

      Opcją -t można wymusić typ klucza, a opcją -b jego długość. Wygenerowane klucze, prywatny i publiczny, umieszczone są w podkatalogu .ssh katalogu domowego (publiczny ma rozszerzenie pub).

    • Klucz publiczny należy umieścić na systemie target w pliku *.ssh/authorized_keys* na koncie docelowym. Wygodne kopiowanie zapewnia komenda (poniżej użyto konta root)

      1
      
      ssh-copy-id root@opi.usb
      

Pobranie głównego systemu plików z działającego urządzenia

Jeżeli główny system plików archiwizujemy z działającego urządzenia target, to przed operacją archiwizacji katalog główny należy zamontować w innym miejscu przy użyciu mount --bind i dopiero w nim wydać komendę archiwizacji. Wynik archiwizacji najlepiej od razu pobrać na system host

1
ssh root@opi.usb 'mkdir -p /tmp/rootfs && mount --bind / /tmp/rootfs && cd /tmp/rootfs/ && tar cf - *' > rootfs.tar

W powyższej komendzie opi.usb to nazwa sieciowa urządzenia target i może być zastąpiona przez jego numer IP.

Jeżeli nie zaznaczono inaczej materiały ob jęte są licencją CC BY 4.0 .