Co się dzieje w Linux, kiedy uruchamiamy program napisany w języku Rust?

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:

  1. 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.
  2. 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.
  3. Optymalizacja: Po wygenerowaniu pośredniego kodu LLVM, kompilator przeprowadza różne optymalizacje, aby poprawić wydajność i zmniejszyć rozmiar końcowego pliku binarnego.
  4. 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:

  1. Ł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.
  2. 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.
  3. Ł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.
  4. 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ń.
  5. 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.

TUX - maskotka systemu Linux

About the author

Autor "BIELI" to zapalony entuzjasta otwartego oprogramowania, który dzieli się swoją pasją na blogu poznajlinuxa.pl. Jego wpisy są skarbnicą wiedzy na temat Linuxa, programowania oraz najnowszych trendów w świecie technologii. Autor "BIELI" wierzy w siłę społeczności Open Source i zawsze stara się inspirować swoich czytelników do eksplorowania i eksperymentowania z kodem.