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 jakVirtualBox
,VMWare Workstation
czyHyper-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ą przezQEMU
możliwe jest uruchamianie i budowanie kontenerów na architekturę inną niż ta, na której uruchomiony jestDocker
.
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”
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
-
Zdalny dostęp do konta administratora
W pliku
/etc/ssh/sshd_config
(target
) należy ustawić opcję1
PermitRootLogin yes
-
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ć opcje1 2
PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2
-
Użytkownik systemu
host
musi mieć wygenerowane osobiste kluczessh
. 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 rozszerzeniepub
). -
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 kontaroot
)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.