Miért van szükség cross-compilationra Rust projekteknél?

Ha embedded eszközre, Raspberry Pi-re, vagy egy másik architektúrájú szerverre fejlesztesz, gyakran nem praktikus (vagy egyáltalán nem lehetséges) magán a célgépen fordítani. Egy Raspberry Pi Zero-n percekig tarthat egy nagyobb crate fordítása, míg a fejlesztői gépeden ez másodpercek alatt lezajlik. Ráadásul CI/CD pipeline-okban is tipikus igény, hogy egyetlen build szerverről több platformra (x86_64, ARM, musl-alapú Alpine image-ek) is elő tudj állítani binárisokat.

A cross-compilation lényege, hogy a host gépeden (amin dolgozol) futó fordító a target platformra (amire fordítasz) generál végrehajtható kódot. A Rust erre kiválóan alkalmas, mert az LLVM backend natívan támogat rengeteg architektúrát, a rustc és a cargo pedig első osztályú target-kezeléssel rendelkezik.

Megjegyzés

A "host" és a "target" triple fogalmát érdemes megjegyezni: ezek olyan stringek, mint x86_64-unknown-linux-gnu vagy armv7-unknown-linux-gnueabihf, és pontosan leírják az architektúrát, a vendort, az OS-t és az ABI-t.

Target hozzáadása rustup segítségével

A rustup gondoskodik arról, hogy a szükséges standard könyvtár (core, alloc, std) elérhető legyen a célplatformhoz. Első lépésként nézzük meg, milyen targetek érhetők el:

rustup target list

Ez egy hosszú listát ad vissza, benne az általunk keresett ARM targetekkel is. Raspberry Pi 3/4 esetén (32 bites, hardware float támogatással) tipikusan a armv7-unknown-linux-gnueabihf target a megfelelő választás:

rustup target add armv7-unknown-linux-gnueabihf

Ellenőrizd, hogy sikeresen települt:

rustup target list --installed

Ez még nem elég ahhoz, hogy fordítani tudj – a rustup csak a Rust-specifikus standard könyvtárat telepíti, a linkelést végző C toolchain-t neked kell biztosítanod.

Linkerek és toolchain-ek beállítása különböző célplatformokhoz

Itt jön a lényeg, amit sokan alábecsülnek: a Rust maga csak object fájlokat generál, a végső bináris összeállításához (linkeléshez) egy platform-specifikus linkerre van szükség. Linuxos gazdagépen ARM célra a legegyszerűbb megoldás egy cross-toolchain telepítése a disztribúció csomagkezelőjével.

Debian/Ubuntu alatt például:

sudo apt update
sudo apt install gcc-arm-linux-gnueabihf

Ez települ egy arm-linux-gnueabihf-gcc nevű binárist, amely maga is egy teljes cross-toolchain (linker, headerek, stb.) – pontosan ezt fogja használni a Rust a linkelési fázisban.

Figyelem

Ha a célgépeden (pl. egy régebbi Raspbian image) régebbi glibc verzió fut, mint amit a build gépeden lévő toolchain generál, futásidőben GLIBC_2.XX not found hibát kaphatsz. Erre két megoldás van: vagy a célgéphez illő, régebbi glibc-t célzó toolchaint használsz, vagy áttérsz musl target-re (armv7-unknown-linux-musleabihf), ami statikusan linkeli a C könyvtárat, és teljesen független a célgép glibc verziójától.

Cargo config.toml testreszabása cross buildekhez

A Cargo-nak meg kell mondanod, melyik linkert használja az adott target esetén. Ezt a projekt gyökerében (vagy globálisan a home mappádban) elhelyezett .cargo/config.toml fájlban tudod beállítani:

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

Ha több targetet is kezelsz párhuzamosan, egyszerűen több szekciót adhatsz hozzá:

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

Ha a projekt build.rs scriptet is használ – például natív C kód compilálásához a cc crate segítségével –, érdemes ellenőrizni, hogy a build script maga is a helyes target információt kapja-e. Egy egyszerű build.rs, amely ezt logolja:

// build.rs
use std::env;

fn main() {
    let Ok(target) = env::var("TARGET") else {
        panic!("Nem sikerült lekérni a TARGET környezeti változót!");
    };

    if target.contains("arm") {
        println!("cargo:warning=ARM célplatformra fordulunk: {target}");
    }
}

Itt egy let-else konstrukciót használtam, amivel egyszerűen és olvashatóan tudjuk kezelni azt az esetet, ha a környezeti változó valamiért hiányzik.

Gyakorlati példa: Linuxról ARM célra fordítás

Nézzünk egy teljes, végigkövethető folyamatot. Tegyük fel, hogy egy egyszerű programot szeretnél futtatni egy Raspberry Pi 4-en, Linuxos fejlesztői gépről indulva.

Először hozz létre egy új projektet:

cargo new pi_hello
cd pi_hello

A src/main.rs tartalma legyen egyszerű, de mutassa meg, hogy tényleg más architektúrán futunk:

fn main() {
    let arch = std::env::consts::ARCH;
    let os = std::env::consts::OS;
    println!("Ez a bináris a '{arch}' architektúrán fut, '{os}' operációs rendszeren.");
}

Add hozzá a targetet és telepítsd a toolchaint (ha még nem tetted):

rustup target add armv7-unknown-linux-gnueabihf
sudo apt install gcc-arm-linux-gnueabihf

Hozd létre a .cargo/config.toml fájlt a projektben:

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

Ezután egyszerűen fordíthatsz:

cargo build --release --target=armv7-unknown-linux-gnueabihf

A kész bináris a target/armv7-unknown-linux-gnueabihf/release/pi_hello útvonalon jelenik meg. Innen már csak másolni kell a Raspberry Pi-re, például scp-vel:

scp target/armv7-unknown-linux-gnueabihf/release/pi_hello [email protected]:~/

A Pi-n futtatva a program ki fogja írni, hogy armv7 architektúrán, linux OS-en fut – ez a bizonyíték arra, hogy a cross-compilation sikeres volt.

Ha a kódodban platform-specifikus ágakra van szükség (mondjuk valamilyen alacsony szintű optimalizáció miatt), a cfg attribútumokkal tudsz elágazni fordítási időben:

#[cfg(target_arch = "arm")]
fn platform_specific() {
    println!("ARM-specifikus kódág fut.");
}

#[cfg(not(target_arch = "arm"))]
fn platform_specific() {
    println!("Nem ARM platformon vagyunk.");
}

fn main() {
    platform_specific();
}

Ez a mintázat különösen hasznos, ha SIMD utasításokat, vagy platform-specifikus syscall-okat használsz, és biztosítanod kell, hogy a kód más architektúrán is fordulóképes maradjon.

A "cross" eszköz mint alternatíva

Ha a linker telepítése és beállítása körülményesnek tűnik – különösen több target esetén –, érdemes megnézni a közösségi cross nevű eszközt. Ez Docker konténerekbe csomagolva biztosítja a szükséges toolchaineket, így neked nem kell manuálisan telepítened semmit a gazdagépeden, csak a Docker legyen elérhető:

cargo install cross
cross build --release --target=armv7-unknown-linux-gnueabihf

A cross parancs API-ja szinte megegyezik a cargo-éval, csak a linkelési/toolchain-problémákat a konténer oldja meg helyetted. Nagyobb, több targetet kezelő projekteknél ez sokat spórolhat az idődből.

Tipp

Ha CI pipeline-ban (GitHub Actions, GitLab CI) végzel cross buildeket, a cross eszköz különösen jól illeszkedik, mert nem kell a CI image-be minden egyes toolchaint manuálisan telepítened.

Gyakori buktatók

Néhány dolog, amire érdemes figyelni:

  • glibc verzió eltérés: ha a build gép újabb glibc-t céloz, mint amit a target futtat, futásidejű linker hibát kapsz. Musl target használatával ez elkerülhető.
  • OpenSSL és más natív függőségek: ha a projekt natív C könyvtárakra (pl. openssl-sys) támaszkodik, ezeket is cross-compilálni kell, vagy vendoring feature-t kell használnod, ha a crate ezt támogatja.
  • Hiányzó pkg-config beállítás: gyakori hiba, hogy a pkg-config a host rendszer könyvtárait találja meg a target helyett. Erre érdemes PKG_CONFIG_ALLOW_CROSS=1 környezeti változót beállítani, illetve target-specifikus PKG_CONFIG_PATH-t megadni.
  • GAT-okat vagy más újabb nyelvi feature-öket használó crate-ek: ezek fordítási időben ugyanúgy viselkednek cross build esetén is, tehát ha egy crate GAT-okat (Generic Associated Types) használ, az normálisan fordul cross target esetén is, amennyiben a target support megvan hozzá a standard könyvtárban.

Összefoglalás

A Rust cross-compilation támogatása igazán jól kidolgozott: a rustup target add paranccsal pillanatok alatt hozzáadhatod a szükséges standard könyvtárat, a Cargo config.toml fájljában pedig egyszerűen megadhatod, melyik linkert használja az egyes targetekhez. A legtöbb fejfájást a natív toolchain és a linker beállítása okozza – itt segít a disztribúciós csomagkezelő, vagy ha ez körülményes, a cross eszköz Docker-alapú megoldása. Gyakorolj egy kicsit egy Raspberry Pi-vel vagy egy másik ARM eszközzel, és hamarosan természetes rutinná válik a más platformra fordítás – a fejlesztői géped kényelméből kilépve sem kell lemondanod a gyors iterációról.