Dlaczego Rust podbija świat systemów wbudowanych i low‑level lepiej niż klasyczne C

0
34
1/5 - (1 vote)

Nawigacja:

Dlaczego w ogóle szuka się następcy C w świecie low‑level

Historyczna pozycja C w systemach wbudowanych

Język C przez dekady był oczywistym wyborem do programowania low‑level. Dawał bezpośredni dostęp do pamięci, minimalny narzut runtime’u, proste mapowanie na kod maszynowy i kompilatory dostępne praktycznie na każdą architekturę – od małych mikrokontrolerów po wielkie serwery. W świecie systemów wbudowanych to nadal fundament niezliczonych sterowników, firmware’ów i systemów czasu rzeczywistego.

Przez długi czas argument „tak się od zawsze robi” wystarczał. Powstawały ogromne bazy kodu w C, często rozwijane latami przez różne zespoły. Na początku projekt jest mały – kilka plików, parę modułów. Po pięciu latach ten sam projekt to już setki tysięcy linii, obsługa sieci, kryptografia, aktualizacje OTA i integracja z chmurą. C, zaprojektowany do pisania kompilatorów i systemów operacyjnych w latach 70., nie był tworzony z myślą o takiej skali i złożoności.

Programiści C mają pełną swobodę: wskaźnik może wskazywać wszędzie, rzutowanie typów jest niemal nieograniczone, a odpowiedzialność za poprawne zarządzanie pamięcią spoczywa w 100% na człowieku. To, co dla małych, jednorazowych projektów było zaletą, w ogromnych, wieloletnich codebase’ach staje się źródłem kosztów i ryzyka.

Rosnące problemy: błędy pamięci i utrzymanie

Największym przeciwnikiem C w systemach wbudowanych nie jest brak możliwości, lecz koszt błędów. Błędy pamięci – wycieki, przepełnienia buforów, użycie po zwolnieniu – stanowią ogromny odsetek poważnych luk bezpieczeństwa. W systemach low‑level często nie ma wygodnego debugger’a, a crash objawia się tylko jako „czasem się wiesza po nocy”. Szukanie takiej usterki w kodzie C bywa koszmarem.

Dodając do tego współbieżność (wątki, przerwania, wielu producentów danych), dochodzą race condition i subtelne błędy synchronizacji. Kompilator C nie ma narzędzi, by wymusić bezpieczne użycie pamięci pomiędzy wątkami czy przerwaniami – najwyżej ostrzeże o oczywistych nadużyciach. Wszystko zależy od konwencji w zespole i dyscypliny programistów.

Utrzymanie dużej bazy kodu w C to ciągłe balansowanie między „nie ruszaj, bo działa” a „musimy dodać nową funkcję”. Refaktoryzacja bywa ryzykowna, bo nie ma silnego systemu typów, który zatrzyma niepoprawne modyfikacje. Zespół, który raz „przejechał się” na zmianach w starej bibliotece C, często boi się głębszych przeprojektowań – a to spowalnia rozwój.

Nowa presja: bezpieczeństwo, normy i złożoność

Świat embedded i low‑level bardzo się zmienił. Moduł w samochodzie komunikuje się z chmurą, aktualizuje firmware zdalnie i wykonuje kryptografię. Mały czujnik IoT wystawia API HTTPS, a nie tylko prosty UART. To już nie są odizolowane urządzenia – to elementy większych, często krytycznych systemów.

W automotive, medycynie czy przemyśle wymagania dotyczące bezpieczeństwa funkcjonalnego i cyberbezpieczeństwa rosną. Normy w stylu ISO 26262, IEC 61508 czy standardy branżowe jasno mówią: błędy pamięci to realne zagrożenie życia i zdrowia. Gdy firmware samochodu czy urządzenia medycznego wiesza się przez nieprawidłowo użyty wskaźnik, trudno zrzucić winę tylko na „ludzki błąd”.

Jednocześnie sama złożoność rośnie: RTOS, stos TCP/IP, TLS, aktualizacje OTA, integracja z kilkoma magistralami i sensorami. W takim środowisku język, który nie daje żadnych wbudowanych barier przeciw typowym błędom, zaczyna ciążyć. Narasta potrzeba narzędzia, które wciąż pozwala na niski poziom kontroli, ale na tyle mocno wspiera programistę, by typowe pułapki były blokowane jeszcze przed uruchomieniem.

Stąd pytanie, które zadaje sobie coraz więcej inżynierów: czy da się mieć język „tak nisko jak C”, ale z wbudowanym strażnikiem przed błędami pamięci? Rust jest jedną z pierwszych sensownych odpowiedzi na to pytanie.

Mikrokontroler na ciemnym tle obok śrubokręta
Źródło: Pexels | Autor: Tanha Tamanna Syed

Filozofia Rusta: kontrola jak w C, bezpieczeństwo jak w językach wysokiego poziomu

Bezpieczeństwo pamięci bez garbage collectora

Rust został zaprojektowany z bardzo ambitnym celem: gwarancje bezpieczeństwa pamięci bez garbage collectora. To oznacza: żadnych pauz na sprzątanie pamięci, pełna kontrola nad alokacją, a zarazem statyczne (na etapie kompilacji) sprawdzanie, że nie ma użycia po zwolnieniu, podwójnego zwolnienia czy jednoczesnego zapisu z kilku miejsc bez synchronizacji.

Zamiast GC Rust wprowadza model własności (ownership) i pożyczania (borrowing). Każdy zasób – bufor, struktura, port szeregowy – ma dokładnie jednego właściciela. Przekazanie go do innej funkcji zwykle oznacza przekazanie własności. Jeśli trzeba tylko skorzystać z danych, ale ich nie przejmować, używa się referencji: pożyczek tylko do odczytu albo do zapisu, ale kontrolowanych przez kompilator.

W rezultacie kompilator Rust może formalnie udowodnić, że w bezpiecznym (safe) kodzie nie dojdzie do klasycznych błędów pamięci. To nie jest magia w runtime – to matematyka na poziomie typów i reguł własności. Programista czasem musi „dogadać się” z kompilatorem, ale w zamian za to dostaje silne gwarancje, których C w ogóle nie próbuje dawać.

Ownership, borrowing i lifetimes jako atuty, nie kula u nogi

Wielu programistów C patrzy na pojęcia w stylu borrow checker czy lifetimes z nieufnością. Brzmi to jak skomplikowany system, który tylko przeszkadza w pracy. Po pierwszym kontakcie z błędami kompilacji typu „cannot borrow as mutable more than once at a time” łatwo dojść do wniosku, że Rust jest „za mądry”.

Po fazie początkowej okazuje się jednak, że te same reguły uczą lepszej architektury. Ownership wymusza jasność: kto naprawdę zarządza zasobem? Który moduł jest odpowiedzialny za zamknięcie portu, zwolnienie bufora czy zatrzymanie timera? Borrowing ogranicza ilość mutacji – preferuje strukturę, w której dane są mutowane w dobrze zdefiniowanych miejscach, zamiast być globalnie dostępne.

Czasy życia (lifetimes) to dodatkowa informacja dla kompilatora, opisująca, jak długo dane pozostają ważne. W embedded przekłada się to choćby na bezpieczeństwo względem wskaźników do stosu czy buforów w przerwaniach. Jeśli lifetime’y są poprawnie określone, trudno przypadkiem przechowywać referencję do danych, które już nie istnieją. To, co wcześniej trzeba było trzymać „w głowie”, staje się częścią systemu typów.

Zero-cost abstractions: wysoki poziom bez utraty wydajności

Rust jest językiem wysokiego poziomu w tym sensie, że oferuje bogate abstrakcje: generics, pattern matching, wyliczeniowe typy z danymi (enums z wariantami), iteratory, opcjonalne wartości, rezultaty operacji z informacją o błędzie. Klucz tkwi jednak w zasadzie zero-cost abstractions: jeśli można coś zrobić efektywnie w C, to analogiczna abstrakcja w Ruście powinna mieć taki sam lub zbliżony koszt w kodzie maszynowym.

Generics kompilują się do konkretnych instancji typów (monomorfizacja), więc nie ma tu narzutu w postaci wirtualnych wywołań, o ile nie używa się dynamicznej dyspozycji. Złożone struktury danych i algorytmy mogą być pisane „po ludzku”, a optymalizator LLVM zdejmuje nadmiar i inlinuje, gdzie potrzeba. To, co w C często wymaga ręcznego pisania makr, w Ruście jest typowane i bezpieczne.

Praktyczna różnica: w C programista często unika bardziej eleganckich konstrukcji z obawy przed narzutem. W Ruście można pozwolić sobie na czytelniejsze API, wiedząc, że kompilator zrobi, co trzeba, żeby kod maszynowy był kompaktowy i szybki. Dla embedded oznacza to mniejszą liczbę kompromisów między czytelnością a wydajnością.

Deterministyczny czas wykonania i brak pauz GC

Systemy czasu rzeczywistego i sterowniki sprzętowe nie tolerują niespodziewanych przerw na sprzątanie pamięci. Języki z garbage collectorem wnoszą niepewność: kiedy dokładnie nastąpi pauza? Da się ją ograniczyć, ale trudno wyeliminować. Rust z założenia nie ma GC. Alokacja i zwalnianie pamięci są deterministyczne i kontrolowane przez programistę – podobnie jak w C, ale bez błędów use‑after‑free w kodzie bez unsafe.

Dla RTOS lub systemów bare‑metal to ogromna zaleta. Funkcje obsługi przerwań, sterowniki, pętle główne – wszystko działa bez ryzyka, że nagle scheduler zostanie zatrzymany przez pracę garbage collectora. Jeśli unika się dynamicznej alokacji w krytycznych fragmentach (tak jak robi się to w C), czasy reakcji mogą być równie przewidywalne.

C ufa programiście, Rust wymusza dowód przy kompilacji

Filozoficznie różnica jest prosta: C zakłada, że wiesz, co robisz. Pozwala na wszystko, praktycznie nie wtrąca się w sprawy programisty. Czasem ostrzega, ale niczego nie zabrania. Rust jest bardziej wymagający: domaga się dowodu poprawności pewnych właściwości na etapie kompilacji. Jeśli takiego dowodu (w postaci poprawnego użycia ownership i borrowing) nie ma, kod się nie skompiluje.

Dla części inżynierów to bolesne. Konieczność ciągłego „tłumaczenia się” kompilatorowi bywa frustrująca. Jednak ten sam mechanizm sprawia, że wiele klas błędów nigdy nie trafia na urządzenie klienta. Walka jest na poziomie kompilacji, nie na poziomie produkcji. Z punktu widzenia firm rozwijających krytyczne systemy to ogromna zmiana jakościowa.

Bezpieczeństwo pamięci: gdzie dokładnie C zawodzi, a Rust przejmuje stery

Klasyczne błędy pamięci w C

Wystarczy przejrzeć historyczne raporty o lukach bezpieczeństwa w dużych projektach C, by zobaczyć powtarzający się wzorzec. Najczęstsze problemy to:

  • Use‑after‑free – wskaźnik używany po tym, jak pamięć została zwolniona.
  • Double free – to samo pole pamięci zwalniane dwa razy.
  • Out‑of‑bounds – wyjście poza granice tablicy czy bufora.
  • Dangling pointer – wskaźnik zachowany do danych, które już zniknęły (np. ze stosu).
  • Race conditions – jednoczesny dostęp z kilku wątków lub przerwań bez właściwej synchronizacji.

Wszystkie te błędy są formalnie ub_defined behavior. Kompilator C może wówczas wygenerować dowolny kod. Czasem błąd zadziała „na naszą korzyść” i nic złego nie widać na testach. Dopiero daleko w produkcji pojawia się dziwna, niepowtarzalna awaria – charakterystyczna dla niezdefiniowanego zachowania.

Taki rodzaj problemów jest szczególnie groźny w systemach wbudowanych. Nierzadko nie ma zdalnego logowania, a odtworzenie warunków z pola jest nierealne. Błąd pamięci może skutkować uszkodzeniem danych, zawieszeniem systemu lub – w skrajnym przypadku – fizycznym uszkodzeniem sprzętu.

Borrow checker jako automatyczny strażnik

Rust eliminuje całe klasy tych błędów w kodzie safe za pomocą borrow checkera i reguł własności. Jeśli obiekt ma jednego właściciela, nie ma możliwości użycia go po zwolnieniu – w ogóle nie ma instrukcji „free” w takim sensie jak w C. Zasób jest zwalniany automatycznie, gdy wychodzi z zakresu, a kompilator wie, który fragment kodu ma do niego jeszcze dostęp.

Jeśli potrzebne są dane współdzielone, Rust wymusza konkretny model: albo współdzielony odczyt (wiele niesmutowalnych referencji), albo jeden właściciel mutowalny. Nie można mieć jednocześnie wielu zapisujących referencji bez specjalnych struktur (mutexy, atomiki), które informują kompilator, że autor bierze odpowiedzialność za synchronizację.

Tam, gdzie w C zwyczajnie przekazuje się wskaźnik i liczy na to, że nikt nie użyje go po czasie, w Ruście trzeba jasno opisać, czy jest to pożyczka krótkotrwała, czy „przejęcie” zasobu. Jeśli logika jest niejasna, kompilator to wyłapie i poprosi o poprawę projektu danych. To bywa niekomfortowe, ale ratuje przed trudnymi do znalezienia błędami.

Błąd na etapie kompilacji zamiast awarii u klienta

Kluczowa różnica mentalna: błąd w Ruście częściej widzi się jako błąd kompilacji, a nie jako crash u użytkownika. Gdy kompilator odmawia zbudowania projektu z powodu niezgodności lifetime’ów czy podwójnej mutacji, wiadomo, że problem jest jeszcze na biurku programisty, a nie w terenie.

Ten przesunięty punkt detekcji błędów zmienia kulturę pracy. Zamiast szukać subtelnych race condition na docelowej płytce przez kilka tygodni, zespół spędza czas przed monitorem, dopasowując model danych do wymagań borrow checkera. Efekt końcowy to znacznie większa stabilność firmware’u, a więc mniej serwisu, mniej aktualizacji awaryjnych i lepsza reputacja produktu.

Strefa „unsafe” – potrzebne, ale pod kontrolą

Świadome użycie unsafe zamiast wszechobecnego ryzyka

W C całe środowisko jest w pewnym sensie „unsafe” – każde dereferencje wskaźnika, każdy rzut, każda gra z pamięcią może wyjść poza tor. Rust robi coś odwrotnego: domyślnie wszystko jest bezpieczne, a fragmenty wymagające niskopoziomowych sztuczek trzeba wyraźnie oznaczyć słowem kluczowym unsafe.

To ma kilka praktycznych konsekwencji. Po pierwsze, powstaje mapa ryzyka – przeglądając kod wiesz od razu, które sekcje dotykają granicy z UB. Można się tam zatrzymać na dłużej, lepiej je zreviewować, dodać testy na poziomie integracji. Po drugie, w unsafe wciąż działa cała reszta systemu typów: kompilator nadal pilnuje, żebyś nie pomylił typów, nie użył nieistniejącej funkcji czy nie przestawił argumentów.

Ciekawe jest też to, jak małe mogą być te „wyspy” unsafe. Dostęp do rejestru sprzętowego, konfiguracja kontrolera DMA, fragment assemblera inline – to często kilka–kilkanaście linii, które owija się w bezpieczne API. Reszta firmware’u może korzystać z gotowych, bezpiecznych funkcji, nie dotykając już gołych wskaźników. W efekcie, zamiast całego projektu w trybie „może wybuchnąć”, masz kilka wąsko zdefiniowanych miejsc, na których skupia się uwaga zespołu.

Reużywalne „bezpieczne szkiełko” na niebezpieczny kod

Dobrym obrazem jest idea szkiełka ochronnego w laboratorium. Niskopoziomowe operacje z natury są potencjalnie niebezpieczne, ale można je zamknąć w jednej kapsule i udostępnić na zewnątrz prosty, bezpieczny interfejs. W Ruście robi się to poprzez tworzenie modułów, które wewnętrznie korzystają z unsafe, a na zewnątrz wystawiają wyłącznie funkcje i typy safe.

Przykład z praktyki: zespół piszący sterownik magistrali SPI dla konkretnego mikrokontrolera umieszcza bezpośrednią obsługę rejestrów i konfigurację przerwań w jednym module. Ten moduł ma kilka funkcji unsafe, bo operuje na wskaźnikach do pamięci sprzętowej. Następnie tworzy warstwę wyżej – typ SpiBus z metodami write, transfer, set_baudrate, które są już w pełni bezpieczne. Aplikacja korzysta wyłącznie z SpiBus; nikt poza autorami sterownika nie musi wchodzić w strefę ryzyka.

To sprzyja też standaryzacji. Raz dobrze zaprojektowany, przejrzany i przetestowany moduł unsafe może być używany przez kolejne projekty. Zmniejsza się liczba miejsc, w których poszczególni inżynierowie „wymyślają koło na nowo” i popełniają te same błędy z pamięcią czy przerwaniami.

Zbliżenie na płytkę Raspberry Pi z portami USB i mikroukładami
Źródło: Pexels | Autor: Craig Dennis

Rust a wydajność i rozmiar kodu w systemach wbudowanych

Porównanie z C: nie tylko teoria, ale i praktyczne pomiary

Teoretycznie Rust obiecuje, że jego abstrakcje są „zero‑cost”. Naturalne pytanie brzmi: jak to wygląda na realnej płytce, z ograniczoną pamięcią flash i RAM? Zespoły, które migrują z C, często robią proste eksperymenty: tę samą funkcję filtrującą dane z ADC piszą w C i w Ruście, kompilują z podobnymi poziomami optymalizacji i porównują binarki oraz czasy wykonania.

W wielu takich porównaniach wynik jest zaskakująco wyrównany. Monomorfizacja generics sprawia, że tam, gdzie w C byłaby rozbudowana sieć makr i rzutów, w Ruście powstaje konkretny, wyspecjalizowany kod. Kompilator LLVM ma też bardzo dojrzałe optymalizacje, które potrafią usunąć nadmiarowe sprawdzenia czy kopiowania. Bywa, że implementacja w Ruście wychodzi nawet nieco mniejsza, bo jest prostsza strukturalnie i łatwiej ją zoptymalizować.

Oczywiście nie zawsze uda się uzyskać identyczny rozmiar binarki. Zdarza się, że rozbudowane typy wyliczeniowe z bogatymi wariantami czy szerokie użycie traitów wprowadza dodatkowy kod. W praktyce jednak różnice są na tyle małe, że mieszczą się w budżecie typowego mikrokontrolera, a zysk w jakości kodu i bezpieczeństwie często przeważa tę niewielką cenę.

Kontrola alokacji i stosu jak w C

W embedded każdy bajt RAM-u się liczy. Obawa, że „wysoki poziom” oznacza niekontrolowane alokacje na stercie, jest w pełni uzasadniona – tyle że Rust nie wymusza sterty wcale. Można pracować wyłącznie na statycznych buforach, strukturach na stosie i pamięci przydzielonej na etapie linkowania, dokładnie tak jak w C.

Typowe wzorce to korzystanie z const i static do konfiguracji i tablic, używanie struktur zawierających w sobie bufory (np. [u8; 256]) zamiast dynamicznych wektorów oraz przekazywanie referencji między modułami zamiast klonowania danych. Ponieważ Rust bardzo jasno rozróżnia własność i pożyczanie, o wiele łatwiej zobaczyć, gdzie faktycznie powstają kopie danych, a gdzie tylko referencje.

Jeżeli projekt potrzebuje sterty, bo przetwarza zmienną liczbę obiektów lub korzysta z kontenerów takich jak Vec czy HashMap, wciąż pozostaje pełna kontrola nad miejscem i sposobem alokacji. Można zdefiniować własny alokator, ograniczyć go do wybranego regionu pamięci i np. używać tylko w warstwie aplikacyjnej, a sekcje czasu rzeczywistego pozostawić sterty całkowicie pozbawione.

Optymalizacja na gorących ścieżkach: kiedy sięgnąć po ręczne sztuczki

Są sytuacje, gdy każda instrukcja w gorącej pętli ma znaczenie – filtry sygnału, dekodowanie protokołów w czasie rzeczywistym, wewnętrzne pętle ISR. Rust nie zamyka drogi do tej samej klasy niskopoziomowych optymalizacji, które stosuje się w C: ręczne rozbijanie pętli, korzystanie ze słów kluczowych podpowiadających kompilatorowi inline, a nawet wstawki assemblera.

Różnica leży w tym, że reszta kodu może pozostać „normalna”, czytelna i silnie typowana. Zamiast całego projektu pisanego w stylu „wszystko pod optymalizator”, tylko mały fragment jest zoptymalizowany ręcznie. Tam, gdzie trzeba, można użyć atrybutów takich jak #[inline(always)], a jeśli kompilator wciąż nie generuje idealnego asemblera, sięgnąć po asm! w strefie unsafe.

Przy okazji, narzędzia profilujące współpracujące z LLVM potrafią wygenerować bardzo przejrzyste raporty z mapowaniem na kod źródłowy Rusta. Diagnostyka „gorących miejsc” bywa więc wygodniejsza niż w klasycznym toolchainie C dla embedded, gdzie analiza często kończy się na surowym disassemberze.

Zespół programistów pracuje nad projektem Arduino na płytce stykowej
Źródło: Pexels | Autor: Youn Seung Jin

Rust bez standardowej biblioteki: jak wygląda „prawdziwy” embedded Rust

no_std – czyli Rust na gołej ziemi

W świecie wbudowanym standardowa biblioteka z obsługą plików, wątków systemowych czy dynamicznej alokacji niespecjalnie się przydaje. Rust jest na to gotowy: można go kompilować w trybie #![no_std], który sprawia, że kod korzysta wyłącznie z biblioteki podstawowej core oraz – opcjonalnie – z lekkiej alloc, jeżeli zdefiniowany jest własny alokator.

W trybie no_std nie ma println!, Vec bez alokatora, systemowych wątków czy plików. Zostaje natomiast kręgosłup języka: arytmetyka, typy prymitywne, iteratory, Result, Option, trait Copy i cała reszta przydatnych konstrukcji. To właśnie na tym poziomie buduje się biblioteki HAL, sterowniki i warstwę abstrakcji dla mikrokontrolera.

Dzięki temu Rust potrafi trafić zarówno na maleńki układ bez systemu operacyjnego, jak i na rozbudowaną platformę z Linuxem. Ten sam język, ten sam system typów, tylko inne zestawy bibliotek wokół. Dla inżyniera oznacza to możliwość przenoszenia nawyków i wiedzy między projektami o zupełnie różnej skali.

Punkt wejścia bez main(), wektory przerwań i start‑up

Na gołym mikrokontrolerze nie ma standardowego main() wywoływanego przez runtime systemu operacyjnego. Rust daje tu pełną elastyczność – można samodzielnie zdefiniować punkt wejścia, wektor przerwań i procedury startowe, korzystając z odpowiednich atrybutów i crate’ów wspierających.

Typowy projekt bare‑metal na ARM Cortex‑M używa crate’u, który dostarcza minimalny runtime: definiuje tablicę wektorów, podstawową obsługę resetu i opcjonalnie domyślne procedury dla niezaimplementowanych przerwań. Programista dostarcza własną funkcję oznaczoną np. #[entry], która pełni rolę main. Dla przerwań stosuje się podobne atrybuty, np. #[interrupt], przypinające funkcję do odpowiedniego wektora.

Co ciekawe, mimo że ten kod jest bardzo niskopoziomowy, nadal można korzystać z zalet Rusta: typowany dostęp do rejestrów, bezpieczne przekazywanie danych między ISR a pętlą główną, a nawet statyczne sprawdzanie zgodności konfiguracji zegarów czy pinów, jeśli biblioteka HAL jest odpowiednio sprytna.

Brak systemu operacyjnego, ale nie brak organizacji

Brak standardowej biblioteki nie oznacza braku struktury w kodzie. Rust sprzyja dzieleniu projektu na moduły i crate’y, co w embedded przekłada się na jasne wydzielenie odpowiedzialności: jeden crate obsługuje konkretne peryferium, inny udostępnia warstwę abstrakcji dla całej płytki, jeszcze inny zawiera logikę aplikacyjną.

Taki podział ułatwia testowanie. Moduły zależne wyłącznie od core i prostych interfejsów sprzętowych można testować na hostowym komputerze, bez prawdziwej płytki, używając symulowanych implementacji traitów. Dopiero cienka warstwa integracyjna musi być uruchamiana na fizycznym sprzęcie – reszta logiki może być sprawdzana znacznie szybciej w zwykłym środowisku CI.

Alokator na własnych zasadach

Jeżeli projekt mimo wszystko potrzebuje dynamicznej alokacji – na przykład do zarządzania buforami w sieciowej warstwie protokołu – Rust pozwala na wprowadzenie alokatora ściśle dopasowanego do urządzenia. Można zaimplementować prosty buddy allocator, pulę bloków o stałej wielkości lub inny wyspecjalizowany mechanizm i udostępnić go jako globalny alokator dla crate’u alloc.

W ten sposób konstrukcje takie jak Vec czy Box stają się dostępne także w środowisku bez standardowej biblioteki, ale wciąż pod kontrolą – z jasno określonym rozmiarem sterty i zasadami jej używania. Kombinacja no_std + własny alokator bywa złotym środkiem: tam, gdzie potrzeba elastycznych struktur danych, korzystamy z nich, a krytyczne fragmenty systemu trzymają się RAM-u statycznego lub stosu.

Dostęp do rejestrów, przerwań i sprzętu: warstwa HAL w Rust vs C

Typowany dostęp do rejestrów zamiast „magicznych liczb”

W C klasyczny dostęp do sprzętu polega na zdefiniowaniu struktury mapującej rejestry na pola i rzutowaniu wskaźnika na odpowiedni adres bazowy. W praktyce i tak często widzi się wszędzie „magiczne liczby”: *(volatile uint32_t*)(0x4000_1000) = 0x05;. Rust zachęca do czegoś innego – do generowania typowanego API dla rejestrów na podstawie oficjalnych opisów producenta (np. plików SVD).

Takie wygenerowane crate’y dostarczają struktury reprezentujące całe bloki peryferiów, z metodami w stylu usart1.cr1.modify(|_, w| w.ue().set_bit().m0().set_bit()). Każde pole ma swój typ, znane są dozwolone wartości, a nieprawidłowe kombinacje konfiguracji często da się wykryć już przy kompilacji. Zamiast zapamiętywać, że „bit 13 w tym rejestrze włącza parzystość”, korzysta się z nazwanych pól.

Z czasem takie API przestaje być tylko wygodą, a staje się dodatkową barierą bezpieczeństwa. Trudniej przypadkiem nadpisać cały rejestr, bo większość operacji ma formę modyfikacji wybranych pól. Łatwiej też refaktorować kod – zmiana konfiguracji zegarów czy mapowania pinów odbywa się w jednym miejscu, a nie w wielu rozproszonych definicjach makr.

HAL jako kontrakt między logiką a sprzętem

W języku C biblioteki producentów MCU często dostarczają zestaw funkcji i makr, które mają pełnić rolę HAL. Ich jakość bywa bardzo różna, a testowanie lub zastąpienie ich własną implementacją jest kłopotliwe. Rust idzie w inną stronę: silnie promuje interfejsy oparte na traitach, dzięki czemu warstwa HAL jest bardziej jak kontrakt niż jak twardo zakodowany zestaw funkcji.

Przykładowo, interfejs SPI może być opisany traitem z metodami transfer i write. Różne implementacje – jedna dla STM32, inna dla nRF, jeszcze inna dla symulatora na PC – mogą go implementować. Logika wyższego poziomu (np. sterownik wyświetlacza czy pamięci flash) widzi tylko trait, a nie szczegóły konkretnego mikrokontrolera. Podmiana sprzętu czy przeniesienie projektu na inną rodzinę MCU sprowadza się wtedy do dostarczenia nowej implementacji HAL.

Przerwania i współbieżność: mniej pułapek, więcej kontroli

Przerwania w systemach wbudowanych to trochę jak skakanie na trampolinie z lutownicą w ręku – da się, ale wystarczy drobny błąd, by zrobić krzywdę sobie albo płytce. W C zabezpieczenia przed wyścigami danych i zablokowaniem przerwania opierają się głównie na dyscyplinie programisty: krytyczne sekcje, makra blokujące IRQ, volatile, trochę szczęścia.

Rust podchodzi do tego inaczej. Model własności i pożyczania wymusza jasne odpowiedzi na pytania: kto ma dostęp do wspólnego bufora? Czy można go modyfikować równocześnie z ISR i pętli głównej? Bezpieczeństwo współbieżności nie wynika z najnowszego komentarza w dokumentacji, tylko z faktu, że kompilator odmawia wygenerowania kodu z niekontrolowanym współdzieleniem danych.

Typowy schemat wygląda tak: dane współdzielone między przerwaniem a kodem głównym lądują w strukturze chronionej – na przykład przez Mutex z biblioteki specyficznej dla mikrokontrolera, albo przez prosty Cell czy AtomicU32. W wielu aplikacjach da się tak dobrać struktury danych, aby ISR tylko sygnalizował zdarzenie (ustawiał flagę, wrzucał bajt do kolejki), a cięższa logika wykonywała się w wątku głównym. Rust dobrze „pilnuje”, żeby ten podział nie rozjechał się w trakcie rozwoju projektu.

Ogromny plus: współdzielone zmienne globalne przestają być domyślnym rozwiązaniem. Zamiast nich pojawiają się komponenty, które jasno deklarują, jakie dane są udostępniane między kontekstem przerwania i aplikacji, oraz jakie operacje są na nich dozwolone. Tam, gdzie trzeba użyć surowych wskaźników czy sekcji krytycznych, ląduje kod unsafe – i nie da się go nie zauważyć.

Modele współbieżności specyficzne dla embedded

Na mikrokontrolerze bez systemu operacyjnego nie ma pthreadów ani schedulerów w stylu POSIX, ale problemy współbieżności są jak najbardziej obecne: przerwania, DMA, kilka rdzeni, czasem własny mini‑RTOS. Rust pozwala modelować to wszystko na różne sposoby, nie narzucając jednej „jedynej słusznej” biblioteki.

Część projektów używa lekkich frameworków z kooperatywnym wielozadaniowością – na przykład prostych pętli głównych z kolejką zadań, napędzanych timerami i przerwaniami. Inne integrują się z RTOS‑ami pisanymi w C, udostępniając do nich bezpieczne bindingi. Pojawiają się też natywne dla Rusta mikrokontrolerowe modele tasków, gdzie zadania są zdefiniowane jako funkcje z konkretnymi priorytetami i zasobami współdzielonymi przez typowane mutexy.

Kluczowy element to fakt, że model bezpieczeństwa pamięci wciąż działa, niezależnie od wybranego podejścia. Próba trzymania dwóch mutowalnych referencji do tego samego bufora, bez odpowiedniego mechanizmu synchronizacji, kończy się błędem kompilacji. W C podobna sytuacja ujawniłaby się raczej dopiero podczas dziwnego zachowania na produkcyjnym urządzeniu.

Interakcja ze światem C: FFI jako most, nie kotwica

W wielu zespołach istnieje już ogromny kapitał w postaci bibliotek w C: stosy sieciowe, sterowniki, RTOS‑y, algorytmy DSP. Rust nie wymaga porzucania tego świata – przeciwnie, bardzo dobrze współpracuje przez FFI (Foreign Function Interface).

Od strony Rusta oznacza to deklaracje funkcji i typów oznaczone extern "C", które mają ten sam układ w pamięci i konwencję wywołań, co w C. Można więc spokojnie wołać funkcje RTOS‑a, przekazywać wskaźniki na bufory do stosu TCP/IP czy korzystać z zaawansowanych bibliotek kryptograficznych. Rust generuje bardzo przewidywalny kod maszynowy zgodny z ABI C, więc integracja jest stosunkowo prosta.

Dobrym nawykiem staje się „obudowywanie” takich zewnętrznych interfejsów cienką warstwą Rusta, która wystawia bezpieczne API. Surowe wskaźniki są chowane wewnątrz, a użytkownik dostaje w zamian referencje, typy wyliczeniowe i struktury, które trudno źle użyć. To trochę jak opakowanie ostrego noża w wygodną rączkę: ostrze zostaje, ale używa się go z większą precyzją.

Sporo projektów zaczyna od takiej hybrydy: stary, sprawdzony stos sieciowy w C, nowa logika aplikacyjna w Ruście. Z czasem wybrane fragmenty w C są przepisywane na Rusta – zwykle tam, gdzie pojawia się najwięcej błędów i najtrudniejsza logika.

Generowanie kodu z opisów sprzętu: od SVD do silnych typów

Producenci mikrokontrolerów dostarczają formalne opisy sprzętu: pliki SVD, XML‑e, sometimes własne formaty. W C rzadko wykorzystuje się je bezpośrednio – zazwyczaj efektem jest monolityczna biblioteka producenta, której niełatwo nadać własny kształt. Ekosystem Rusta chętnie sięga po automatyczne generowanie kodu na podstawie tych opisów.

Narzędzia typu „svd2rust” biorą opis peryferiów i zamieniają je w crate z silnie typowanym interfejsem. Każdy rejestr to struktura, każdy bit‑field to metoda lub enum, a całe peryferium ma przejrzyste API. Co ważne, ten kod jest potem wspólną bazą dla całej społeczności – zamiast dziesiątków niekompatybilnych bibliotek, powstaje jeden, wspólny „język” mówienia do danego MCU.

Efekt uboczny jest bardzo korzystny: drobne literówki w dokumentacji producenta, niejednoznaczności i niespójności wychodzą na wierzch, bo narzędzie musi je zinterpretować w sposób jednoznaczny. Z czasem generowane crate’y stają się często lepszą dokumentacją niż PDF producenta, bo pokazują sprzęt w formie, którą kompilator może zweryfikować.

Abstrakcje wysokiego poziomu nad HAL: sterowniki budowane jak klocki

Na szczycie warstwy HAL powstają sterowniki bardziej „ludzkie”: biblioteki dla konkretnych czujników, wyświetlaczy, pamięci, modułów radiowych. Dzięki traitom Rust sprzyja pisaniu sterowników, które nie są przywiązane do jednego MCU ani nawet do jednego konkretnego interfejsu.

Wyobraźmy sobie sterownik pamięci flash podłączonej po SPI. W klasycznym C często istnieje wersja „dla STM32”, „dla AVR” i jeszcze kilka modyfikacji wynikających z innych bibliotek HAL. W Ruście można zdefiniować sterownik jako typ działający na dowolnym obiekcie implementującym trait SPI oraz prostym interfejsie GPIO dla sygnału CS. Ten sam kod zadziała na STM32, nRF, RP2040 i w symulatorze uruchamianym na PC – wystarczy dostarczyć odpowiednie implementacje interfejsów.

Taka modularność ułatwia też testowanie. Sterownik flash można sprawdzić na komputerze, wgrywając do niego „pamięć” będącą zwykłym wektorem w RAM‑ie. Logika kasowania, zapisu, obsługi błędów jest ta sama, niezależnie od prawdziwego sprzętu. Dopiero cienka warstwa integracyjna, która podłącza go do konkretnych pinów mikrokontrolera, wymaga testów na fizycznej płytce.

Diagnostyka, logowanie i debugowanie bez „printf‑hell”

Debugowanie embedded kojarzy się z niekończącymi się seansami „printf na UART”, przeklejaniem heksów z terminala i próbą zgadnięcia, co właściwie się wydarzyło. Rust oferuje kilka wygodnych ścieżek, które pomagają wyrwać się z tej pułapki – przy czym wciąż można korzystać z prostych narzędzi, gdy sytuacja tego wymaga.

Makro core::fmt::Debug i związane z nim mechanizmy formatowania działają w trybie no_std, o ile ma się gdzie wysłać znaki (np. przez interfejs semihostingowy, UART albo RTT). Zamiast ręcznie wypisywać kolejne pola struktury, można po prostu zalogować cały obiekt w uporządkowanej formie. W połączeniu z crate’ami do lekkiego logowania powstaje wygodny system debugowania, który nie zamienia każdego pliku w śmietnik wywołań printf.

W wielu projektach Rust wykorzystuje się także do lepszej integracji z narzędziami debuggera. Mapowanie między adresem w pamięci a linią w kodzie jest bardzo dokładne, a nowoczesne front‑endy (VS Code, probe‑rs, itp.) potrafią pokazać elegancko nazwy funkcji, zmienne lokalne i stos wywołań. Skoro kompilator zna tyle informacji o strukturze programu, debugowanie przestaje być zgadywanką na podstawie samych rejestrów CPU.

Od prototypu na PC do firmware na mikrokontrolerze

Częsty scenariusz wygląda tak: najpierw trzeba „rozgryźć” algorytm – filtr, logikę sterowania, protokół binarny. Uruchamianie go od razu na mikrokontrolerze bywa powolne i uciążliwe. Rust pozwala wygodnie przesuwać granicę między światem PC i embedded.

Ten sam kod, który później wyląduje w firmware, może zostać napisany jako zwykła biblioteka używająca tylko core, z ewentualnym warunkowym wsparciem dla alloc. Na komputerze można otoczyć ją testami jednostkowymi i właściwościowymi, podać tysiące losowych wektorów wejściowych, zasymulować sytuacje ekstremalne. Kiedy logika dojrzeje, przeniesienie jej do projektu no_std sprowadza się często do zmiany kilku flag kompilacji i dostarczenia implementacji abstrakcyjnych interfejsów sprzętowych.

Różne warianty tego podejścia widać chociażby w projektach, gdzie dekoder protokołu jest budowany i uruchamiany jako narzędzie linii komend na PC (np. do analizy plików z logami), a potem ten sam kod trafia do firmware’u, które dekoduje pakiety w czasie rzeczywistym. Taki recykling logiki znacznie skraca czas od pomysłu do stabilnego rozwiązania na urządzeniu.

Bezpieczeństwo i aktualizacje OTA w świecie Rust‑embedded

Firmware w systemach wbudowanych coraz częściej trzeba aktualizować zdalnie: OTA, przez USB, przez interfejs serwisowy. Każda taka ścieżka to potencjalne ryzyko: korupcja pamięci flash, przyjęcie uszkodzonego obrazu, niespójne struktury danych. Rust może pomóc poskładać to w całość w sposób bardziej kontrolowany.

Mechanizmy typów sumarycznych (enum) i wyników (Result) sprzyjają budowaniu przejrzystych maszyn stanów dla procedur aktualizacji. Zamiast zestawu rozrzuconych flag i makr, pojawia się konkretny typ opisujący stany: „pobieranie”, „weryfikacja”, „zapis wstępny”, „przełączenie banku”, „powrót po błędzie”. Kompilator wymusza obsługę wszystkich przypadków, dzięki czemu trudniej pominąć jakiś scenariusz błędu.

W połączeniu z bezpiecznym użyciem wskaźników do struktur w pamięci nieulotnej daje to szansę na budowanie bardzo solidnych bootloaderów i menedżerów aktualizacji. Struktury opisujące layout w flashu można trzymać jako &'static referencje do zmapowanych adresów, a wszelkie operacje modyfikujące są opakowane w jasne, wąskie API. Ryzyko przypadkowego nadpisania niewłaściwego sektora spada drastycznie.

Rust na coraz mniejszych i większych układach

Przez długi czas Rust kojarzył się bardziej z serwerami i narzędziami developerskimi niż z mikrokontrolerami. To się szybko zmienia – od małych, skromnych MCU aż po duże SoC z Linuxem. Co istotne, ten sam język i te same nawyki programistyczne rozciągają się na cały ten zakres.

Na najmniejszych układach z ograniczoną pamięcią kompilator i biblioteki są konfigurowane tak, by generować bardzo małe binaria: rezygnacja z części funkcji języka, agresywna optymalizacja, brak RTTI, selektywne użycie traitów. Tymczasem na dużych systemach z Linuxem Rust współpracuje wygodnie z istniejącym światem C, tworząc bezpieczne serwisy użytkownika, demony, a nawet moduły jądra.

Dzięki temu zespoły mogą płynnie przemieszczać się między „gołym” mikrokontrolerem a dużym procesorem aplikacyjnym. Logika logowania, analizy danych, implementacje protokołów – wszystko to może być współdzielone lub współrozwijane między różnymi projektami, z minimalnym „tarciem” na granicy sprzętu.

Ekosystem narzędzi: build, testy i CI pod embedded Rust

Praca z firmwarem w Ruście wymaga wygodnego zestawu narzędzi, który nie będzie odstawał od dojrzałych toolchainów C. Rozwój w tym obszarze jest bardzo intensywny: od integracji z popularnymi debugerami, przez specjalizowane programatory, po narzędzia do symulacji i testów.

System budowania oparty na cargo pozwala w jednym miejscu opisać zależności, profile optymalizacji, funkcje warunkowe na różne platformy i konfiguracje sprzętu. Dla embedded szczególnie przydatne są profile release z ostrymi flagami optymalizacyjnymi oraz możliwość tworzenia osobnych feature’ów dla wariantów płytki czy odmiennych konfiguracji peryferiów.

Testy jednostkowe mogą być uruchamiane zarówno na hoście (dla kodu niezależnego od sprzętu), jak i na docelowej platformie – przy użyciu specjalnych frameworków, które potrafią odpalić krótki program testowy na MCU, zebrać wyniki i odnieść je do oczekiwań. Nic nie stoi też na przeszkodzie, by włączyć testy właściwościowe czy fuzzing dla parserów protokołów, jeszcze zanim jakikolwiek bajt trafi na magistralę urządzenia.

Ścieżka adopcji: od „jednego modułu w Ruście” do pełnego firmware

W wielu firmach przejście z C na Rusta nie następuje z dnia na dzień. Częściej zaczyna się od jednego modułu: nowego algorytmu, wrażliwej warstwy bezpieczeństwa, krytycznego bootloadera. Taki moduł powstaje w Ruście, a następnie jest dołączany do istniejącego projektu jako biblioteka, wywoływana z kodu w C.

Najczęściej zadawane pytania (FAQ)

Dlaczego w ogóle szuka się następcy C w systemach wbudowanych?

C przez lata świetnie sprawdzał się w małych, prostych projektach: kilka plików, jeden mikrokontroler, brak sieci i chmury. Gdy jednak firmware rośnie do setek tysięcy linii, dochodzą RTOS, TCP/IP, TLS, OTA i integracje z wieloma magistralami, swoboda C zaczyna boleć. Błędy pamięci, niejawne zależności między modułami i brak silnego systemu typów sprawiają, że każda większa zmiana jest ryzykiem.

Do tego dochodzi nowa rzeczywistość: normy bezpieczeństwa (ISO 26262, IEC 61508), wymagania cyberbezpieczeństwa i fakt, że urządzenia embedded są wpięte do sieci. Awaria przez przepełnienie bufora to już nie tylko „czasem się wiesza po nocy”, ale realne zagrożenie i koszt. Stąd rośnie potrzeba języka o podobnej kontroli jak C, ale z wbudowanymi zabezpieczeniami przed typowymi pułapkami.

Co sprawia, że Rust jest bezpieczniejszy od C w kontekście pamięci?

Rust wprowadza model własności (ownership) i pożyczania (borrowing), dzięki któremu kompilator statycznie pilnuje życia każdej zmiennej. W praktyce oznacza to, że w tzw. bezpiecznym kodzie nie da się skompilować użycia pamięci po zwolnieniu, podwójnego free ani jednoczesnego zapisu z wielu miejsc bez odpowiedniej synchronizacji. Te błędy są ucinane na etapie kompilacji, zanim program trafi na urządzenie.

W C cała odpowiedzialność leży na programiście: wskaźnik może pokazać „gdziekolwiek”, a kompilator zwykle nie ma narzędzi, by wykryć subtelne błędy. W Ruście reguły własności są częścią systemu typów. To trochę jak mieć drugiego inżyniera, który bez przerwy przegląda kod i blokuje wszystko, co może prowadzić do klasycznego crasha z pamięcią w tle.

Czym jest ownership, borrowing i lifetimes w Ruście, tak „po ludzku”?

Ownership to po prostu odpowiedź na pytanie: „kto sprząta po tym zasobie?”. Każdy obiekt ma jednego właściciela, który odpowiada za jego życie i śmierć. Kiedy przekazujesz bufor do innej funkcji „na własność”, stary właściciel przestaje mieć do niego dostęp – dzięki temu nie ma sytuacji, w której dwie części programu próbują zwolnić to samo.

Borrowing to pożyczka: możesz pożyczyć dane do odczytu wielu miejscom naraz albo do zapisu tylko jednemu na raz. Rust pilnuje, by podczas modyfikacji nikt inny nie czytał ani nie pisał tych samych danych. Lifetimes natomiast opisują, jak długo referencja jest ważna. W świecie embedded przekłada się to na bezpieczeństwo względem wskaźników do stosu, buforów w ISR czy struktur tworzonych tymczasowo – kompilator nie pozwoli zostawić „wiszącej” referencji do czegoś, co już nie istnieje.

Czy Rust ma garbage collector i jak wpływa to na systemy czasu rzeczywistego?

Rust nie ma garbage collectora. Zarządzanie pamięcią odbywa się deterministycznie dzięki ownership i lifetimes – wiadomo dokładnie, kiedy obiekt jest niszczony, a pamięć zwalniana. Nie ma więc losowych pauz na „sprzątanie”, które w systemach czasu rzeczywistego mogłyby zabić deadline’y.

Dla sterowników, RTOS-ów czy krytycznych pętli sterowania to kluczowe. Można pisać kod na wysokim poziomie, a jednocześnie mieć taką samą przewidywalność czasu wykonania jak w C. Jeśli jakaś część systemu musi być super deterministyczna, po prostu unika się tam dynamicznych alokacji – dokładnie tak, jak robi się to dziś w C.

Co oznacza, że Rust oferuje „zero-cost abstractions” i czy jest tak szybki jak C?

Zero-cost abstractions oznacza, że konstrukcje językowe (generics, iteratory, pattern matching, złożone typy) po skompilowaniu nie dokładają narzutu ponad to, co i tak napisałbyś ręcznie w C. Kompilator Rusta monomorfizuje generics, agresywnie inline’uje i optymalizuje kod przy pomocy LLVM, więc większość „ładnych” abstrakcji znika, zostawiając prosty, szybki kod maszynowy.

W praktyce Rust potrafi być tak szybki jak C, a czasem nawet szybszy, bo pozwala na bezpieczne stosowanie bardziej złożonych struktur danych i algorytmów, których w C unikano ze strachu przed narzutem lub trudnością implementacji. Programista nie musi wybierać między czytelnością a wydajnością – częściej dostaje oba te elementy jednocześnie.

Czy Rust nadaje się na bardzo małe mikrokontrolery i projekty „blisko sprzętu”?

Rust został zaprojektowany z myślą o pracy „blisko metalu”. Umożliwia pracę bez standardowej biblioteki (no_std), ma wsparcie dla pisania sterowników, obsługi przerwań i rejestrów pamięci-mapped, a generowany kod może być równie lekki jak w C. Istnieją już ekosystemy i paczki dla popularnych rodzin MCU, takich jak ARM Cortex-M.

Oczywiście, na ekstremalnie małych układach z kilkoma kilobajtami flasha i RAM-u konfiguracja projektu bywa bardziej wymagająca, a narzędzia są mniej „wypolerowane” niż dla C. Jednak dla dużej części współczesnych mikrokontrolerów, które obsługują sieć, kryptografię czy OTA, Rust jest już dziś realną i praktyczną alternatywą.

Czy przejście z C na Rust w istniejącym projekcie embedded ma sens?

W wielu przypadkach tak, ale zwykle nie robi się tego „na raz”. Typowy scenariusz to stopniowe przepisywanie nowych modułów (np. sieć, kryptografia, logika biznesowa) w Ruście, zostawiając krytyczne i stabilne sterowniki w C. Rust dobrze współpracuje z kodem C przez FFI, więc można mieszać oba światy.

Największą korzyść widać tam, gdzie dotąd najczęściej „boli”: w kodzie złożonym, współbieżnym i narażonym na ataki z zewnątrz. Po kilku takich modułach zespół zaczyna korzystać z przewidywalności refaktoryzacji i mniejszej liczby błędów pamięci, a C zostaje tam, gdzie faktycznie ma przewagę – w dojrzałym, bardzo niskopoziomowym kodzie, którego praktycznie się nie dotyka.

Najważniejsze punkty

  • C od lat jest fundamentem systemów wbudowanych i low‑level, ale był projektowany na znacznie prostsze czasy – dzisiejsze, wieloletnie projekty z setkami tysięcy linii kodu obnażają jego ograniczenia w utrzymaniu i skalowaniu.
  • Swoboda, jaką daje C (nieograniczone wskaźniki, rzutowania, ręczne zarządzanie pamięcią), w małych projektach bywa zaletą, lecz w dużych codebase’ach zamienia się w źródło kosztownych, trudnych do wykrycia błędów.
  • Błędy pamięci i współbieżności w C – wycieki, przepełnienia buforów, use‑after‑free, race condition – są główną przyczyną poważnych luk bezpieczeństwa, a ich diagnozowanie w środowisku embedded często przypomina szukanie igły w stogu siana.
  • Rosnące wymagania norm bezpieczeństwa (ISO 26262, IEC 61508 i podobne) oraz fakt, że urządzenia embedded są dziś połączone z siecią, chmurą i wykonują kryptografię, sprawiają, że język bez wbudowanych barier przed błędami pamięci zaczyna ciążyć całemu zespołowi.
  • Rust łączy kontrolę typową dla C (brak garbage collectora, precyzyjna kontrola alokacji, brak pauz GC) z silnymi, statycznymi gwarancjami bezpieczeństwa pamięci – kompilator potrafi udowodnić, że w tzw. safe kodzie nie wystąpią klasyczne błędy typu use‑after‑free czy podwójne zwolnienie.