Strona główna Jak kompilować i debugować aplikacje w C/C++ na komputery jednopłytkowe korzystając z VS Code
Post
Cancel

Jak kompilować i debugować aplikacje w C/C++ na komputery jednopłytkowe korzystając z VS Code

Komputery jednopłytkowe (ang. SBCSingle 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. W rezultacie przygotowanie aplikacji w postaci binarnej na urządzenie docelowe (target) na komputerze osobistym (host) wymaga wielu, często dość skomplikowanych, zabiegów. Uwaga niniejszego opracowania skupiona jest na realizacji różnych scenariuszy pracy z wykorzystaniem edytora VS Code i eliminujących konieczność instalacji i konfiguracji środowiska graficznego na SBC.

Producenci urządzeń klasy SBC nieustannie przekonują potencjalnych nabywców, że urządzenia te mogą funkcjonować jako ubogie wersje komputerów osobistych, jednak nie znam nikogo, kto używałby ich w ten sposób. Tak naprawdę siłą komputerów tego typu jest dostępność niskopoziomowych interfejsów takich, jak linie GPIO czy magistrale I2C, SPI przy jednoczesnej dostępności wielu serwisów sieciowych. Jądro systemu operacyjnego zainstalowanego na takim urządzeniu zapewnia abstrakcyjny interfejs dostępu sprzętu, dzięki czemu aplikacje wyglądają w zasadzie tak samo, niezależnie od platformy sprzętowej wykorzystanej do realizacji urządzenia SBC.

Idealną sytuacją byłoby, gdyby aplikacje dla komputera jednopłytkowego można było tworzyć, budować i uruchamiać bezpośrednio na nim. Komfortowa praca wymaga jednak uruchomienia konsoli graficznej (instalacji oprogramowania oraz doposażenia w dodatkowy sprzęt w postaci myszy, klawiatury i monitora), zapewnienia odpowiedniej przestrzeni dyskowej i wystarczającego rozmiaru pamięci RAM. Scenariusz taki jest możliwy, gdy SBC ma zasoby porównywalne z komputerami PC. W zdecydowanej większości przypadków tak nie jest.

We wszystkich opisanych dalej scenariuszach jedynym interfejsem programisty będzie Visual Studio Code – doceniony przez programistów i wysoce konfigurowalny edytor. Mechanizm rozszerzeń w edytorze umożliwia jego uzupełnienie o funkcje budowania i uruchamiania aplikacji co de facto przekształca go w zintegrowane środowisko pracy. VS Code jest dostępny nieodpłatnie na wszystkie popularne systemy operacyjne. Pracę z projektami C/C++ znacznie ułatwia C/C++ Extension Pack. Dalej przyjęto, że rozszerzenie to jest zainstalowane. Rozważane są scenariusze pracy w których kod aplikacji jest budowany na urządzeniu docelowym lub komputerze PC.

1. Kompilacja natywna

Opisane w tym punkcie scenariusze wymagają instalacji w systemie target narzędzi umożliwiających budowanie aplikacji, np.

1
2
apt install -y build-essentials make
apt install -y libgpiod-dev

VS Code jest wykorzystywane jedynie do zapewnienia programiście wygodnego interfejsu graficznego.

1.1 Praca zdalna z wykorzystaniem Code Server

Instalacja w Visual Studio Code rozszerzenia Remote SSH umożliwia pracę zdalną na dowolnym koncie SSH, tak jak gdyby był to komputer lokalny. W procesie automatycznej konfiguracji zdalnego konta na systemie target jest instalowany i uruchamiany Code Server. Otwarcie zdalnej sesji przebiega następująco:

  • po uruchomieniu Visual Studio Code wystarczy wybrać zielony przycisk “connect-button” w lewym dolnym rogu ekranu

  • z palety komend wybrać “Connect to Host”,

  • w polu adresu wpisać root@opi.usb (“root” można zamienić na dowolne inne konto, opi.usb" jest nazwą sieciową systemu target i może być zamieniona na stosowny numer IP),

Rozszerzenie Remote SSH automatycznie zainstaluje na zdalnym koncie oprogramowanie Code Server (w katalogu ‘.vscode’ na zdalnym koncie) umożliwiające egzekucję i odbieranie wyników komend wydawanych w tunelu SSH. Praca w takiej konfiguracji jest bardzo wygodna i w zasadzie niczym nie różni się od pracy lokalnej. Jedyna różnica polega na tym, że źródła aplikacji i narzędzia ich kompilacji muszą być obecne na systemie target. Na systemie host nie trzeba instalować nić oprócz Visual Studio Code. Niestety, przedstawione rozwiązanie ma dwie (poważne) wady:

  • code-server wspiera tylko architektury 64-bitowe: amd64 i arm64, co na wyklucza zastosowanie tego podejścia na wielu dostępnych na rynku SBC,

  • system target powinien być wyposażony w 1GB RAM i dwurdzeniowy CPU.

1.2 Praca zdalna w tunelu SSH

Poprzednia metoda nie jest uniwersalna ze względu na wymagania stawiane przez Code Server. Element ten można wyeliminować z poprzedniego schematu. Tym razem pliki źródłowe są edytowane na systemie host i przed kompilacją kopiowane na system target, gdzie są kompilowane za pomocą natywnych narzędzi. W toku dalszego wywodu zostanie przyjęto następującą strukturę podkatalogów w katalogu projektu

1
2
3
4
5
6
.vscode/ -> katalog z plikami konfiguracyjnymi IDE
obj/ -> katalog pomocniczy dla make
src/ -> pliki źródłowe projektu
src/include/
src/main.c
Makefile 

Powyższy projekt można zbudować natywnie, korzystając z następującego ‘Makefile

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Compiler settings - Can be customized.
CC = gcc
CXX = g++
CPPFLAGS =
CFLAGS = -std=c11 -g -Wall -fdiagnostics-color=always
CXXFLAGS = -std=c++11 -g -Wall -fdiagnostics-color=always
LDFLAGS = -lpthread -lgpiod

# Makefile settings - Can be customized.
APPNAME = main
EXT = .c
SRCDIR = src
OBJDIR = obj
REMOTE = root@opi.usb
REMOTEPWD = /root/RemotePowerButton

SRC = $(wildcard $(SRCDIR)/*$(EXT))
OBJ = $(SRC:$(SRCDIR)/%$(EXT)=$(OBJDIR)/%.o)
DEP = $(OBJ:$(OBJDIR)/%.o=%.d)

RM = rm

all: $(APPNAME)

# Copy sources to target
copysrc:
  scp -p -r ./src/ ./obj/ ./Makefile $(REMOTE):$(REMOTEPWD)

# Builds the app
$(APPNAME): $(OBJ)
  $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

# Creates the dependecy rules
%.d: $(SRCDIR)/%$(EXT)
  @$(CPP) $(CFLAGS) $(CPPFLAGS) $< -MM -MT $(@:%.d=$(OBJDIR)/%.o) >$@

# Includes all .h files
-include $(DEP)

# Building rule for .o files and its .c/.cpp in combination with all .h
$(OBJDIR)/%.o: $(SRCDIR)/%$(EXT)
  $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ -c $<

# Cleans complete project
.PHONY: clean
clean:
  -$(RM) $(OBJ) $(DEP) $(APPNAME)

# Cleans only all files with the extension .d
.PHONY: cleandep
cleandep:
  -$(RM) $(DEP)

W powyższym pliku cel ‘copysrc’ odpowiada za skopiowanie na system target struktury katalogów i plików niezbędnych do zbudowania aplikacji.

Plikiem konfiguracyjnym mówiącym Visual Studio Code jak należy dany projekt budować, jest plik ‘.vscode/tasks.json’. Każdy projekt może mieć kilka zadań, do których odwołujemy się przez nazwę nadaną w polu ‘label’. Zadania budowania konfiguruje się w menu ‘Terminal’. Konfiguracja zadania, które kopiuje pliki źródłowe na system target, a następnie zdalnie wykonuje na nim polecenie ‘make’ wygląda następująco (plik ‘.vscode/tasks.json’):

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 on Target",
      "type": "shell",
      "command": "make copysrc ; ssh root@opi.usb '(cd /root/RemotePowerButton; make)'",
      "group": {
          "kind": "build",
          "isDefault": true
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task copies project sources to target and then it builds them natively there."
    }
  ]
}

Przyjęto, że zdalnym katalogiem projektu jest ‘/root/RemotePowerButton’. Procedurę budowania aktywuje się skrótem klawiszowym ‘Ctrl+Shift+B’.

Edytor Visual Studio Code jest wyposażony w mechanizm IntelliSense bardzo ułatwiający pisanie kodu źródłowego poprzez zastosowanie zaawansowanego mechanizmu podpowiedzi nt. zdefiniowanych zmiennych, dostępnych funkcji, akceptowanych przez nie argumentów itp. IntelliSense czerpie swoją wiedzę z analizy treści plików nagłówkowych. W przedstawionej tutaj konfiguracji problem polega na tym, że IntelliSense korzysta z plików nagłówkowych w systemie host, które mogą być zupełnie odmienne od odpowiednich plików w systemie target. Aby tę niefortunny stan rzeczy naprawić, należy pliki nagłówkowe z systemu target udostępnić IntelliSense. Procedura jest dwuetapowa:

  1. pliki nagłówkowe z systemu target, muszą być widoczne w systemie host,

  2. IntelliSense musi być poinformowane, gdzie się te pliki znajdują.

Punkt 1. można zrealizować na dwa sposoby:

Metoda “brutalnej siły” ma tę wadę, że przynajmniej co do zasady, powinna być powtórzona po każdorazowej aktualizacji oprogramowania na systemie target. Metodę elegancką opisano w punkcie “Kruczki i sztuczki”.

Następnie, korzystając z pliku ‘.vscode/c_cpp_properties.json’ należy poinformować IntelliSense, które pliki powinny być analizowane

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
    "configurations": [
        {
            "name": "Target Rootfs",
            "includePath": [
                "${workspaceFolder}/src/``",
                "${workspaceFolder}/rootfs/``"
            ],
            "defines": [ // symbols resolved by the preprocessor that are unknown to IntelliSense
                "NULL=0" // I suspect that "#     define NULL 0" in types.h is accepted by cpp, but IGNORED by IntelliSense 
            ],
            "compilerPath": "",
            "cStandard": "c11",
            "cppStandard": "c++11",
            "intelliSenseMode": "linux-gcc-arm64"
        }
    ],
    "version": 4
}

Zagadnienie udostępnienia właściwych plików nagłówkowych będzie się przewijało we wszystkich opisywanych dalej konfiguracjach. W wersji korzystającej z plików udostępnionych za pomocą NFS katalog ‘rootfs’ może być po prostu linkiem symbolicznym do katalogu, w którym widoczne są pliki urządzenia target.

2. Kompilacja skrośna

Proces budowania aplikacji złożony jest dwóch zasadniczych etapów:

  • kompilacji, czyli przekształcenia kodu źródłowego na kod binarny dla docelowej architektury,

  • łączenia (ang. liniking), czyli zebrania wielu fragmentów kodu binarnego w jedną aplikację.

Kompilacji podlega kod źródłowy stworzony przez twórcę oprogramowania wraz z kodem zawartym w plikach nagłówkowych bibliotek. Łączeniu podlega kod binarny wyprodukowany w procesie kompilacji z kodem binarnym bibliotek. Kompilator czerpie z plików nagłówkowych informację o kodzie, który będzie dostępny na etapie łączenia, zatem oba te elementy muszą pozostawać w pełnym synchronizmie. Sam proces łączenia ma dwie odmiany. W łączeniu statycznym wymuszanym na linkerze opcją -static kod binarny aplikacji oraz wspomagający go kod z bibliotek są ‘sklejane’ w jeden plik. Tak zbudowana aplikacja jest samowystarczalna, jednak jej kod wynikowy może być bardzo duży (np. użycie funkcji printf implikuje dołączenie znacznego fragmentu standardowej biblioteki C). Bez opcji -static linker pracuje w trybie łączenia dynamicznego, w którym kod binarny aplikacji jest uzupełniany informacją o nazwie i wersji biblioteki, o którą powinien być uzupełniony w momencie uruchomienia. Dzięki takiemu podejściu kod wynikowy aplikacji jest stosunkowo mały, a biblioteki wykorzystywane przez wiele aplikacji są ładowane do pamięci tylko raz. Niestety, w momencie uruchomienia aplikacja nie jest samodzielna jak w przypadku łączenia statycznego, gdyż system, w którym jest uruchamiana, musi być uzupełniony o biblioteki współdzielone niezbędne do działania aplikacji (z dokładnością do wersji biblioteki, aby zapewnić zgodność interfejsu biblioteki zakładanego podczas kompilacji). Ostatecznie budowanie skrośne wymaga spełnienia następujących warunków:

  1. kompilator musi wytwarzać kod binarny zgodny z architekturą urządzenia target,

  2. przetwarzane przez kompilator pliki nagłówkowe muszą być zgodne z kodem binarnym łączonym przez linker,

  3. na etapie uruchamiania, dla programów łączonych dynamicznie, w systemie target muszą być zainstalowane dokładnie te same biblioteki, które były przyjęte na etapie łączenia. Mówiąc krótko, architektura kodu, deklaracje zawarte w kodach nagłówkowych i biblioteki podczas łączenia i uruchamiania aplikacji muszą do siebie pasować.

Podstawowym problemem skrośnego tworzenia oprogramowania jest konstrukcja środowiska gwarantującego spełnienie warunków 1-3. Ich poprawne spełnienie wymaga istotnej ingerencji w oprogramowanie zainstalowane na systemie host. Zestaw narzędzi umożliwiających przygotowanie kodu binarnego na architekturę inną niż architektura systemu na którym wykonywania jest kompilacja nazywa się toolchain‘em. Nazwa ta podkreśla fakt, że skrośne budowanie aplikacji wymaga zharmonizowanego użycia wielu narzędzi.

Skrośne budowanie aplikacji jest żmudne i podatne na błędy

2.1 Instalacja kompilatora skrośnego w systemie host

Budowanie skrośne jest stosunkowo proste, gdy dla systemu host istnieje gotowy pakiet zawierający zestaw narzędzi do skrośnego budowania aplikacji (tzw. toolchain) oraz podstawowe biblioteki dla architektury docelowej

1
apt list "crossbuild*"

Jego instalacja umożliwia bezproblemową kompilację prostych aplikacji. Problem koegzystencji został rozwiązany poprzez zastosowanie konwencji nazewniczej polegającej na poprzedzeniu każdego programu wchodzącego w skład toolchainu prefiksem opisującym platformę docelową w formacie (łącznik na końcu prefiksu jest jego integralną częścią)

<arch>-<os>-<lib>-
<arch>-<vendor>-<os>-<lib>-

Na przykład typowymi prefiksami dla architektury ARM64 są aarch64-linux-gnu- lub aarch64-unknown-linux-gnu-

Łączenie statyczne

Szansa, że wersje bibliotek zainstalowanych w ‘crossbuild’ pokrywają się z bibliotekami w środowisku uruchomiania, jest niewielka, zatem konieczne jest uwolnienie się od warunku 3, poprzez wymuszenie łączenia statycznego np.

1
PREFIX=aarch64-linux-gnu- ; ${PREFIX}gcc -c -g -Wall -o main.o main.c ; ${PREFIX}gcc -static main.o

Kompilator i linker skrośny ‘wiedzą’, gdzie w systemie host zainstalowano pliki stosowne dla architektury docelowej, co znacznie upraszcza składnię komend niezbędnych do skutecznego zbudowania aplikacji. Aplikacja zbudowana jak wyżej może być uruchomiona na dowolnym systemie zgodnym z architekturą zdefiniowaną w zmiennej PREFIX. Jednak za prostotę płacimy cenę: po pierwsze, aplikacja jest stosunkowo duża i po drugie, uzupełnienie zależności o bibliotekę, której brak w pakiecie ‘crossbuild`’ wymaga ręcznej kompilacji skrośnej biblioteki i ujęcia jej `explicite* w wywołaniach kompilatora i linkera.

Łączenie dynamiczne

Budowanie skrośne wykorzystujące łączenie dynamiczne jest bardziej złożone, gdyż na systemie host muszą znajdować te same biblioteki dynamiczne/współdzielone co na systemie target. Spełnienie punktu 3 implikuje również instalację plików nagłówkowych skojarzonych z tymi bibliotekami. Katalog zawierający te elementy jest nazywany sysroot. Zarówno kompilator skrośny, jak i linker skrośny muszą być poinformowane o jego położeniu. Zawartość sysroot ekstrahuje się z głównego systemu plików systemu target. Wymóg utrzymania synchronizmu wymusza aktualizację jego kopii na systemie host po zmianie firmware na urządzeniu target.

1
PREFIX=aarch64-linux-gnu- ; SYS=rootfs ;${PREFIX}gcc --sysroot ${SYS} -c -g -Wall -o main.o main.c ; ${PREFIX}gcc --sysroot ${SYS} main.o

Wartość zmiennej PREFIX jest używana do rozmieszczania plików w drzewie katalogów na etapie przygotowywania rootfs. Jednocześnie jest ona wykorzystywana przez kompilator skrośny do poszukiwania plików. Zatem łączenie dynamiczne aby prefiks kompilatora był taki sam jak dla rootfs.

Spełnienie tego warunku jest konieczne do bezproblemowej kompilacji i łączenia w trybie dynamicznym

Inną, bardziej elegancką metodą zapewnienia widoczności plików z urządzenia target jest ich udostępnienie z wykorzystaniem protokołu NFS.

Poniżej przedstawiono definicje zadań kompilacji statycznej i dynamicznej w Visual Studio Code. Dla kompilacji dynamicznej przyjęto założenie, że system plików urządzenia target jest widoczny w katalogu ‘rootfs/’.

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
33
34
35
36
37
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "CrossBuild with static linkage",
      "type": "shell",
      "command": "make PREFIX=aarch64-linux-gnu- STATIC=-static",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task creates static application produced with cross compiler installed on host."
    },
    {
      "label": "CrossBuild with dynamic linkage",
      "type": "shell",
      "command": "make PREFIX=aarch64-linux-gnu- SYSROOT=rootfs/",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task creates dynamically linked application produced with cross compiler installed on host. Target's rootfs is visible in rootfs/ folder."
    }
  ]
}

2.2 Konteneryzacja środowiska kompilacji skrośnej

Instalowanie w systemie host kilku środowisk skrośnych prowadzi do dość sporego zamieszania w organizacji plików, zwłaszcza jeżeli narzędzia te pochodzą z równych źródeł. Atrakcyjną alternatywą jest zamknięcie tych środowisk w kontenerach Docker‘a. Zaletą jest o wiele łatwiejsze zarządzanie i całkowity brak interferencji pomiędzy tak zorganizowanymi środowiskami. W ramach projektu Dockcross zamknięto w kontenerach narzędzia kompilacji na wiele platform docelowych. Ich zawartość obejmuje narzędzia do budowania aplikacji oraz standardową bibliotekę C (zrealizowaną jako GNU C, musl lub uClib). Korzystanie z kontenerów jest wyjątkowo proste: instalacja sprowadza się do jednej komendy, a budowanie aplikacji wymagaja kosmetycznego retuszu pliku Makefile. Dostępne typy architektur można sprawdzić w repozytorium GitHub projektu https://github.com/dockcross/dockcross lub na https://hub.docker.com/.

Instalację środowiska realizuje komenda

1
2
docker run --rm dockcross/arch-name > ./dockcross
chmod +x ./dockcross

W jej wyniku na system host zostanie pobrany odpowiedni kontener, a w katalogu projektu utworzony skrypt dockcross umożliwiający korzystanie z jego zawartości. Jeżeli kontener ma być używany również w innych projektach to skrypt należy przenieść do katalogu znajdującego się na ścieżce dostępu i zmienić jego nazwę na taką, która informuje o nazwie architektury. Parametry linii komend tego skryptu traktowane są jak linia poleceń, którą należy przekazać do kontenera. Jednocześnie bieżący katalog jest mapowany do wnętrza kontenera, co powoduje, że pliki projektu są dostępne dla kompilatora w katalogu roboczym kontenera. Na przykład, aby sprawdzić jak nazywa się kompilator umieszczony w kontenerze wystarczy wydać komendę

1
./dockcross bash -c 'echo $CC'

Powyższa komenda pozwala na ustalenie wartości zmiennej PREFIX używanej we wcześniejszych przykładach. Na przykład dla kontenera dockcross/linux-arm64 przyjmie ona wartość aarch64-unknown-linux-gnu-. W rezultacie komenda realizująca kompilację skrośną wymaga prostego uzupełnienia o wywołanie skryptu

1
./dockcross bash -c 'PREFIX=aarch64-unknown-linux-gnu- ; ${PREFIX}gcc -c -g -Wall -o main.o main.c ; ${PREFIX}gcc -static main.o -o myapp'
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": "Containerized CrossBuild with static linkage",
      "type": "shell",
      "command": "./dockcross bash -c 'make PREFIX=aarch64-unknown-linux-gnu- STATIC=-static'",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task creates static application produced with cross compiler installed dockcross container (https://github.com/dockcross/dockcross)."
    }
  ]
}

Niestety, z dużym prawdopodobieństwem kompilator zawarty w kontenerze będzie niekompatybilny z kompilatorem użytym do wytworzenia rootfs. W efekcie łączenie w trybie dynamicznym nie będzie możliwe.

3. Debugowanie skrośne

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 (w dalszej części pojawią się konfiguracje dla łącza sieciowego). 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

debug: $(APPNAME)
  scp $(APPNAME) $(REMOTE): && ssh -t $(REMOTE) "gdbserver --once localhost:1234 ./$(APPNAME)"

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

Podsumowanie

Kompilacja skrośna własnego kodu nastręcza wielu problemów. Jeszcze więcej problemów pojawia się podczas kompilacji cudzych aplikacji. Profesjonalnie napisane aplikacje są wyposażone w mechanizm budowy dostosowujący je do środowiska kompilacji. Zazwyczaj budowanie aplikacji poprzedzonej jest uruchomieniem skryptu ./configurewygenerowanym przez środowisko automake. Zadaniem skryptu jest wykrycie dostępności pewnych składników systemie, ustawienie odpowiednich flag kompilacji i przygotowanie pliku Makefile dla programu make. Schemat ten działa poprawnie gdy system na którym ma być wykonywania aplikacja jest identycznie skonfigurowany (w sensie dostępności wykrywanych składników) z systemem na którym wykonywana jest aplikacja. W przypadku kompilacji skrośnej założenie to nie jest spełnione. Skrypt ./configure po prostu odpytuje niewłaściwy system. W efekcie ustawione opcje kompilacji są niepoprawne, co skutkuje błędami kompilacji, lub co gorsza, wygenerowaniem niewłaściwie działającego kodu. Ręczny dobór odpowiednich przełączników i adaptacja obcego kodu źródłowego jest zazwyczaj doznaniem ekstremalnym.

W zasadzie wszystkie opisane problemy można rozwiązać stosując [kompilację w trybie emulowanym](../../post/2023-08-13-emulated-building-for-sbc/).

Wady i zalety

Budowanie natywne

zalety wady
możliwość tworzenie kodu łączonego dynamicznie konieczność instalacji narzędzi deweloperskich na urządzeniu target
  tworzenie kodu wymaga bezpośredniego dostepu do urządzenia docelowego

Budowanie skrośne

zalety wady
kompilacja na urządzeniu host kod łączony dynamicznie może być przygotowany tylko w specyficznych sytuacjach
możliwość hermetyzacji środowiska w kontenerze  

Przydatne pliki konfiguracyjne

Makefile na wszystkie okazje

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# Compiler settings - Can be customized.
PREFIX=
STATIC=
CC = $(PREFIX)gcc
CXX =$(PREFIX)g++
CPPFLAGS =
CFLAGS = -std=c11 -g -Wall -fdiagnostics-color=always
CXXFLAGS = -std=c++11 -g -Wall -fdiagnostics-color=always
# LDFLAGS = $(STATIC) -lpthread -lgpiod 
LDFLAGS = $(STATIC) 

ifdef SYSROOT
    CFLAGS += --sysroot=$(SYSROOT)
    CXXFLAGS += --sysroot=$(SYSROOT)
    LDLAGS += --sysroot=$(SYSROOT)
endif

# Makefile settings - Can be customized.
APPNAME = main
EXT = .c
SRCDIR = src
OBJDIR = obj
REMOTE = root@opi.usb
REMOTEPWD = /root/RemotePowerButton

SRC = $(wildcard $(SRCDIR)/*$(EXT))
OBJ = $(SRC:$(SRCDIR)/%$(EXT)=$(OBJDIR)/%.o)
DEP = $(OBJ:$(OBJDIR)/%.o=%.d)

RM = rm -f

all: $(APPNAME)

# Copy sources to target
.PHONY: copysrc
copysrc:
  scp -p -r ./src/ ./obj/ ./Makefile $(REMOTE):$(REMOTEPWD)

.PHONY: debug
debug: $(APPNAME)
  scp $(APPNAME) $(REMOTE): && ssh -t $(REMOTE) "gdbserver --once localhost:1234 ./$(APPNAME)"

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

# Builds the app
$(APPNAME): $(OBJ)
  $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

# Creates the dependecy rules
%.d: $(SRCDIR)/%$(EXT)
  @$(CPP) $(CFLAGS) $(CPPFLAGS) $< -MM -MT $(@:%.d=$(OBJDIR)/%.o) >$@

# Includes all .h files
-include $(DEP)

# Building rule for .o files and its .c/.cpp in combination with all .h
$(OBJDIR)/%.o: $(SRCDIR)/%$(EXT)
  $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ -c $<

# Cleans complete project
.PHONY: clean
clean:
  $(RM) $(OBJ) $(DEP) $(APPNAME)

# Cleans only all files with the extension .d
.PHONY: cleandep
cleandep:
  $(RM) $(DEP)

Wszystkie definicje zadań ``.vscode/tasks.json`’

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "CrossBuild with static linkage",
      "type": "shell",
      "command": "make PREFIX=aarch64-linux-gnu- STATIC=-static",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task creates static application produced with cross compiler installed on host."
    },
    {
      "label": "CrossBuild with dynamic linkage",
      "type": "shell",
      "command": "make PREFIX=aarch64-linux-gnu- SYSROOT=/mnt/sysroot",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task creates dynamically linked application produced with cross compiler installed on host. Targets rootfs is visible in /mnt/sysroot."
    },
    {
      "label": "Build Natively on Target",
      "type": "shell",
      "command": "make copysrc ; ssh root@opi.usb '(cd /root/RemotePowerButton; make)'",
      "group": {
        //"isDefault": true,
        "kind": "build"
      },
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
          "$gcc"
      ],
      "detail": "This task copies project sources to target and then it builds them natively there."
    }
  ]
}

Konfiguracja IntelliSense w ‘.vscode/c_cpp_properties.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
    "configurations": [
        {
            "name": "NativeOverSSH",
            "includePath": [
                "${workspaceFolder}/src/``",
                "${workspaceFolder}/rootfs/``"
            ],
            "defines": [ // symbols resolved by the preprocessor that are unknown to IntelliSense
                "NULL=0" // I suspect that "#     define NULL 0" in types.h is accepted by cpp, but IGNORED by IntelliSense 
            ],
            "compilerPath": "",
            "cStandard": "c11",
            "cppStandard": "c++11",
            "intelliSenseMode": "linux-gcc-arm64"
        }
    ],
    "version": 4
}

Konfiguracja sesji uruchamiania w ‘.vscode/launch.json

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
        }
      ]
    }
  ]
}

4. Kruczki i sztuczki

4.1 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ę

     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

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

      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
      

4.2 Udostępnianie głównego systemu plików poprzez NFS

System target

  1. Instalacja serwera NFS.

    1
    
     apt install -y nfs-kernel-server
    
  2. Udostępnienie katalogu ‘/usr’ w trybie tylko do odczytu

    Zatrzymanie serwera

    1
    
     systemctl stop nfs-kernel-server
    

    Aktualizacja ‘/etc/exports’

    1
    
     /usr *(ro,sync,no_subtree_check)
    

    Uruchomienie serwera

    1
    2
    
     exportsfs -ra
     systemctl start nfs-kernel-server
    

System host

  1. Utworzenie punktu montowania

    1
    
     mkdir -p /mnt/sysroot/usr
    
  2. Montowanie zdalnego folderu na żądanie. W ‘/etc/fstab’

    1
    
     opi.usb:/usr /mnt/sysroot/usr  nfs noauto,nofail,noatime,nolock  0 0
    

    Montowanie komendą

    1
    
     sudo mount /mnt/sysroot/usr
    

    Kompilator należy poinformować, że katalogiem ‘sysroot’ jest ‘/mnt/sysroot’.

P.S. Chętnie się dowiem jak prosto udostępnić "/" w którym są linki symboliczne np. bin->/usr/bin``.

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