Rust to nowoczesny język programowania, który zdobywa coraz większą popularność dzięki swojej wydajności, bezpieczeństwu pamięci i wszechstronności. Zrozumienie, co dzieje się „pod maską” podczas uruchamiania programu napisanego w Rust, może pomóc programistom lepiej wykorzystać jego możliwości. W tym wpisie przedstawimy analizę techniczną działania programu skompilowanego w języku Rust w systemie Linux, aby wyjaśnić, jak działa ten proces.

Kompilacja programu w języku programowania Rust
Zanim program Rust zostanie uruchomiony, musi zostać skompilowany do kodu maszynowego.
Warto wiedzieć, że cargo/rustc
domyślnie używa LLVM jako back-endu codegen
i prawdopodobnie będzie tak w przewidywalnej przyszłości.
Jednak trwają prace nad obsługą używania rustc
z alternatywnymi back-endami. rustc_codegen_cranelift
podłącza rustc
do cranelift
i może być domyślnie używany w przyszłości do kompilacji debugowania, ponieważ ma tendencję do szybszego tworzenia kompilacji niż LLVM. rustc_codegen_gcc
używa GCC jako back-endu kompilatora i ma na celu rozszerzenie zakresu celów, do których rustc
może kierować.
Istnieje również rust-gpu, czyli dedykowany back-end rustc
, który generuje SPIR-V, aby móc uruchomić Rust na kartach graficznych i akceleratorach GPU.
Kompilacja w języku programowania RUST składa się z kilku etapów:
- Analiza składniowa i semantyczna: Kompilator Rust (
rustc
) najpierw analizuje kod źródłowy, sprawdzając jego składnię i semantykę. W tym etapie kompilator sprawdza, czy kod jest poprawny i zgodny z regułami języka. - Generowanie pośredniego kodu LLVM: Rust używa infrastruktury kompilacyjnej LLVM (ang. Low Level Virtual Machine) do generowania pośredniego kodu. LLVM umożliwia optymalizację kodu na różnych etapach kompilacji i generowanie kodu maszynowego dla różnych architektur.
- Optymalizacja: Po wygenerowaniu pośredniego kodu LLVM, kompilator przeprowadza różne optymalizacje, aby poprawić wydajność i zmniejszyć rozmiar końcowego pliku binarnego.
- Generowanie kodu maszynowego: W końcowym etapie kompilator generuje kod maszynowy specyficzny dla docelowej architektury (np. x86_64 dla większości komputerów PC).

Powyższy obrazek pokazuje dwa kroki kompilacji – realna kompilacja do plików obiektów oraz łączenie (ang. linking) plików obiektów w jeden plik wykonywalny. W systemie Linux jest to plik ELF (więcej w poniższym opisie uruchamiania programów w Rust).
Jak zoptymalizować rozmiary plików binarnych w Rust
Oto kilka tricków, jak zmniejszyć program w Rust z ~4 MB do 14 kB ! Tak, jest to możliwe. Po pierwsze musimy zrozumieć, że programy kompilowane w Rust mają dwa tryby DEBUG i RELEASE. Debug zawiera informacje dla debuggera i zawsze będzie większy od RELEASE, który służy do wydawania programów bez informacji dla debuggera. Tutaj widać program i jego rozmiar w trybie mało oszczędnym:

Dodatkowo możemy wykorzystując zmiany w pliku dystrybucyjnym i budowania projektu Cargo.toml, – który określa zasady kompilacji – zmienić nieco parametry, na korzyść rozmiaru pliku.

W końcu kiedy zaczniemy definiować na niższym poziomie, jak będzie działał program opisując to stosownie w kodzie, możemy zejść do ~14 kB z rozmiarem pliku. Jeśli zastanawia Was to, kiedy ma to znaczenie, już wyjaśniam: dla pisania programów w języku Rust na systemy wbudowane, gdzie pamięć programu oraz RAM należy zwyczajnie oszczędzać, bo jest tych zasobów znacznie mniej niżeli w typowym komputerze osobistym – PC (ang. Personal Computer).
Uruchamianie programu Rust
Po procesie kompilacji mamy program binarny, który w terminalu możemy sprawdzić poleceniami hd (hexdump)
oraz file
, t.j. poniżej na załączonym obrazku:

Jak widzimy z powyższego obrazka plik wykonywalny w Linux posiada format ELF (ang. Executable and Linkable Format). Można również podejrzeć przy pomocy programu objdump
na Linux, gdzie startuje nasz program w pliku binarnym ELF:
$ objectdump -d ./hello-world

Warto wspomnieć tutaj od razu, że czasami w programach pisanych w języku Rust możemy spodziewać się punktu wejścia w pliku binarnym ELF w sekcji _start – zależy to od kodu i sposobu kompilacji programu. Poniżej przykładowa sekcja _start
:

Warto wspomnieć – co widać na powyższym obrazku – że program podczas startu przekazuje sterowanie do biblioteki libc i wywołuje w tym eclu metodę __libc_start_main
w biblitece libc
.
Do podglądu nagłówków plików binarnych ELF może też posłużyć kolejny ważny program readelf
z Linuxa.
$ readelf -h ./hello-world

Zależności w programach binarnych w systemie Linux odgrywają kluczową rolę w uruchamianiu i funkcjonowaniu aplikacji. Są to zewnętrzne biblioteki, pliki i zasoby, których programy wymagają do poprawnego działania. Zarządzanie zależnościami jest istotne zarówno dla programistów, jak i użytkowników, ponieważ wpływa na stabilność, wydajność i bezpieczeństwo systemu. Dzięki funkcjom bibliotecznym nie musimy przepisywać wielokrotnie kodu, po prostu wywołujemy funkcję z biblioteki, która już została napisana.
Poniżej omówimy, jak identyfikować zależności w skompilowanym programie poleceniem ldd
.
W efekcie zobaczymy powiązane systemowe lub użytkowe dynamiczne biblioteki (pliki z rozszerzeniem *.so w Linux, *.dynlib w MacOS i słynne *.dll w M$):
$ ldd ./hello-world

Podsumowanie kroków uruchamiania programów Rust
Po skompilowaniu programu Rust do pliku binarnego, można go uruchomić w systemie Linux. Proces uruchamiania programu składa się z kilku kluczowych kroków:
- Ładowanie pliku binarnego: Kiedy użytkownik uruchamia program, jądro systemu Linux ładuje plik binarny do pamięci. Proces ten obejmuje wczytywanie segmentów programu (np. kodu, danych) z pliku wykonywalnego do odpowiednich obszarów pamięci.
- Inicjalizacja procesu: Jądro tworzy nowy proces dla uruchamianego programu. Proces ten otrzymuje unikalny identyfikator procesu (PID) i odpowiednie zasoby, takie jak przestrzeń adresowa pamięci, deskryptory plików itp.
- Ładowanie zależności: Jeśli program korzysta z dynamicznych bibliotek (np. libstd w przypadku Rust), jądro ładuje te biblioteki do pamięci i wiąże je z procesem. Rust statycznie linkuje większość zależności, co oznacza, że niektóre biblioteki mogą być wbudowane w plik wykonywalny, co zmniejsza zależność od zewnętrznych bibliotek.
- Inicjalizacja runtime: Rust, podobnie jak inne nowoczesne języki programowania, ma swój runtime. Runtime Rust zajmuje się zarządzaniem pamięcią, obsługą wątków, paniką (panic), itp. W tej fazie inicjalizowane są również globalne zmienne i przygotowywany jest stos wywołań.
- Uruchomienie funkcji głównej (main): Po zakończeniu inicjalizacji, program przechodzi do funkcji głównej (
main
), która jest punktem wejścia do aplikacji. Funkcja ta wykonuje kod napisany przez programistę.
Zarządzanie pamięcią
Jednym z kluczowych aspektów działania programów Rust jest zarządzanie pamięcią. Rust zapewnia bezpieczeństwo pamięci dzięki systemowi pożyczek (borrowing) i własności (ownership). Podczas uruchamiania programu Rust, runtime Rust zajmuje się alokacją i dealokacją pamięci zgodnie z regułami języka.
- Alokacja pamięci: Rust używa sterty (heap) i stosu (stack) do zarządzania pamięcią. Alokacja pamięci na stosie jest szybka i zarządzana automatycznie przez kompilator. Alokacja pamięci na stercie jest zarządzana przez biblioteki standardowe (np.
Box
,Vec
) i musi być jawnie dealokowana, gdy nie jest już potrzebna. - Bezpieczeństwo pamięci: Dzięki systemowi pożyczek i własności, Rust zapobiega błędom związanym z zarządzaniem pamięcią, takim jak null pointer dereference czy use-after-free. Kompilator Rust sprawdza te reguły podczas kompilacji, co eliminuje wiele błędów już na etapie tworzenia kodu.
Obsługa wątków
Rust oferuje nowoczesne mechanizmy zarządzania wątkami, które są łatwe w użyciu i bezpieczne. Programy Rust mogą tworzyć wątki, które są zarządzane przez system operacyjny.
- Tworzenie wątków: Rust umożliwia tworzenie wątków za pomocą standardowej biblioteki (np.
std::thread
). Każdy wątek ma swoje własne zasoby i może wykonywać kod równolegle. - Bezpieczeństwo współbieżności: Rust zapobiega typowym problemom związanym z współbieżnością, takim jak warunki wyścigu (race conditions), dzięki systemowi własności. Dane mogą być przekazywane między wątkami tylko wtedy, gdy jest to bezpieczne i zgodne z regułami języka.
Obsługa paniki (panic)
Rust ma mechanizm obsługi błędów zwany paniką (panic
). Panika występuje, gdy program napotka sytuację, z której nie może się poprawnie wyjść, np. nieprawidłowy indeks w tablicy.
- Zachowanie przy panice: Kiedy występuje panika, program Rust domyślnie kończy swoje działanie i wypisuje komunikat o błędzie. Wątek, który wywołał panikę, jest zakończony, a zasoby są zwalniane.
- Obsługa paniki: Programiści mogą przechwytywać panikę i obsługiwać ją w kontrolowany sposób za pomocą funkcji takich jak
std::panic::catch_unwind
. Dzięki temu program może próbować odzyskać z paniki i kontynuować działanie.
Narzędzia do debugowania i profilowania
Rust oferuje różne narzędzia do debugowania i profilowania programów, co pozwala programistom na dokładną analizę działania aplikacji.
- GDB: Rust integruje się z narzędziem GDB (GNU Debugger), które umożliwia debugowanie kodu Rust na poziomie źródła.
- LLDB: Alternatywnie, można używać LLDB, który jest debuggerem z projektu LLVM.
- Profilowanie: Narzędzia takie jak
perf
mogą być używane do profilowania wydajności programów Rust, co pozwala na identyfikację wąskich gardeł i optymalizację kodu.
Podsumowanie
Uruchamianie programu napisanego w języku Rust w systemie Linux to złożony proces obejmujący ładowanie pliku binarnego, inicjalizację procesu, zarządzanie pamięcią, obsługę wątków, obsługę błędów i wiele innych. Dzięki zaawansowanym mechanizmom zarządzania pamięcią i współbieżnością, Rust zapewnia wysoką wydajność i bezpieczeństwo. Programiści mogą korzystać z narzędzi do debugowania i profilowania, aby zoptymalizować swoje aplikacje i lepiej zrozumieć ich działanie.
Mam nadzieję, że ten wpis na bloga pomoże w zrozumieniu technicznych aspektów uruchamiania programów Rust w systemie Linux.