Saga della compilazione statica di codice C++ con Zig

cross compilazione con Zig
Images by Andrew Kelley licensed under CC BY 4.0

Zig è un nuovo linguaggio di programmazione il cui obiettivo è quello di essere “più pragmatico del C”, ovvero equivalente al C dal punto di vista delle prestazioni, ma con funzionalità e strumenti aggiuntivi al fine di aiutare a svolgere le attività di sviluppo giornaliere. Lo stesso trend lo troviamo anche in altri linguaggi di nuova generazione (Rust, Go, etc..) che, nonostante le varie differenze tra loro, forniscono strumenti “di serie” per lo sviluppo, come build system, package manager, profiler ed altro. Una buona panoramica di Zig è quella fornita dall’autore stesso, con il suo articolo Introduction to the Zig Programming Language. Nel mio articolo mi concentrerò proprio su un aspetto pragmatico di questo linguaggio, ovvero la cross-compilazione, usando quindi la funzionalità di toolchain, in particolare su codice C++.

Cross-compilazione

Molto spesso su progetti di lavoro embedded mi sono trovato a dover utilizzare una toolchain per generare codice per schede basate su architetture diverse da quella di sviluppo (generalmente ARM). Altre volte ho dovuto generare codice per Windows lavorando in ambiente Linux e le soluzioni a cui ho fatto ricorso sono state MinGW oppure sistemi di continuous integration come AppVeyor. Queste strategie però non sono mai state a costo zero, anzi spesso hanno richiesto tempo per creare configurazioni ad-hoc. Zig supporta in modo nativo svariate architetture, sia come host che come target. Ciò significa che posso, ad esempio, compilare su host Mac per target Window, in modo molto più semplice!

Zig può anche essere utilizzato sia con progetti Go che progetti Rust, che abbiano qualche dipendenza da codice C. In Go questo si può fare con CGO, però integrandosi opportunamente con Zig si può facilmente cross compilare un progetto Go. La stessa idea si può anche applicare per cross compilare un progetto Rust.

Linking statico

Un altro problema che ho dovuto spesso affrontare è quello delle dipendenze da librerie esterne. Lavorando con Go ho trovato molto utile avere degli eseguibili compilati staticamente, senza doversi portare dietro librerie utente. Questo non significa che non si abbiano dipendenze da librerie di sistema, che va benissimo in generale. Però, come pratica di sviluppo, trovo più semplice integrare le librerie utente all’interno del software, invece che usare l’approccio in stile Yocto, in cui si compila utilizzando un SDK contenente tutte le librerie dinamiche necessarie, al fine di far girare la propria applicazione sul sistema target. Una soluzione a questo problema può essere l’uso della libc musl, con lo scopo di ottenere binari con linking statico, evitando dipendenze esterne anche in caso di utilizzo di funzionalità di rete, come ad es. la risoluzione DNS. Siccome Zig permette di scegliere la libc da utilizzare ed integra la libreria musl al suo interno, il linking statico su codice C/C++ risulta molto più facile da ottenere!

Per questi motivi ho notato delle potenzialità su questo linguaggio, ma è davvero tutto così facile come sembra, quando abbiamo a che fare con progetti più complicati? Vediamo quali problemi ho dovuto affrontare per ottenere cross-compilazione e binari statici, avendo come dipendenza una libreria esterna C++17 che faccia uso di funzionalità di rete.

Passo 1: Compilare un sorgente Zig

Per iniziare ho voluto fare un programma di esempio in Zig (versione di riferimento v0.8.1), in ambiente Linux/Ubuntu, architettura x86_64, solo per capire come funzionasse la compilazione. Zig è anche un sistema di build, quindi si può inizializzare un progetto per avere una struttura di base fin da subito funzionante:

❯ mkdir helloworld
❯ cd helloworld
❯ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`
❯ zig build
❯ file zig-out/bin/helloworld
zig-out/bin/helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped
❯ du -h ./zig-out/bin/helloworld
560K    ./zig-out/bin/helloworld
❯ ./zig-out/bin/helloworld
info: All your codebase are belong to us.

Wow, il file binario prodotto non dipende da niente e la dimensione è 560K, proprio quello che volevo ottenere! Ovviamente questo è solo un programma di esempio, quindi cosa succederà se ci sono dipendenze esterne, come ad esempio una libreria C++?

Come banco di prova ho voluto prendere un progetto C++ sufficientemente complesso. In particolare ho preso fuurin, un mio progetto open-source che genera una libreria che implementa alcuni pattern di comunicazione. Per lo scopo di questo articolo non è importante sapere cosa faccia fuurin, serve solo come cavia. La libreria fuurin dipende a sua volta da ZeroMQ, ma non vorrei che l’utente finale vedesse questa dipendenza. Infatti ho vendorizzato ZeroMQ con la strategia git subtree, ottenendo un archivio statico libzmq.a (sotto Linux) come prodotto intermedio. Infine il progetto genera un archivio statico libfuurin_static.a e vorrei poter distribuire un eseguibile che non dipenda neanche da quest’ultimo, quindi ottenuto con linking statico. Come detto in precedenza, va bene che tale eseguibile dipenda da altre librerie di sistema, anche se mi piacerebbe minimizzare questo numero.

Perciò il passo successivo è stato quello di provare a chiamare codice C++ (tramite interfaccia C) da un sorgente Zig, per vedere il risultato.

Passo 2: Progetto Zig che include codice C++

Per integrare il codice della libreria fuurin in Zig ho dovuto creare un’interfaccia C verso codice C++, così da utilizzare tali funzioni dal sorgente Zig. Ecco un piccolo esempio che mostra l’utilizzo di questa interfaccia:


const std = @import("std");

const c = @cImport({
    @cInclude("fuurin/c/cbroker.h");
});

pub fn main() anyerror!void {
    var idb: c.CUuid = c.CUuid_createRandomUuid();
    var b: *c.CBroker = c.CBroker_new(&idb, "broker") orelse return;

    c.CBroker_stop(b);
    c.CBroker_wait(b);
    c.CBroker_delete(b);
}

In particolare si nota l’inclusione di codice C, tramite il file cbroker.h e successivamente la chiamata a funzioni C, ad esempio c.CBroker_new

Ho modificato il file build.zig, ottenuto tramite zig init-exe, in modo da effettuare due configurazioni aggiuntive:

  1. Compilazione del progetto C++ fuurin con CMake.
  2. Aggiunta del linking verso la libreria fuurin.

Questa è la versione finale del file build.zig:

const std = @import("std");

pub fn build(b: *std.build.Builder) !void {
    const target = b.standardTargetOptions(.{});
    const mode = b.standardReleaseOptions();

    const fuurin_setup = b.addSystemCommand(&[_][]const u8{
        "cmake", "-B", "fuurin/build", "-S", "fuurin",
    });
    try fuurin_setup.step.make();
    const fuurin_build = b.addSystemCommand(&[_][]const u8{
        "cmake", "--build", "fuurin/build",
    });
    try fuurin_build.step.make();

    const exe = b.addExecutable("main", "src/main.zig");
    exe.setTarget(target);
    exe.setBuildMode(mode);

    // Add fuurin lib
    exe.addIncludeDir("fuurin/build/install/include");
    exe.addLibPath("fuurin/build/install/lib");
    exe.linkSystemLibrary("c");
    exe.linkSystemLibrary("c++");
    exe.linkSystemLibrary("fuurin_static");

    exe.install();

    const run_cmd = exe.run();
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

Ho fatto in modo che il comando zig build proceda prima con la compilazione del progetto C++ (tramite l’invocazione di CMake) e successivamente con la compilazione del main.zig. Il file CMakeLists.txt di questo progetto “di interfacciamento” CMake è fatto così:

cmake_minimum_required(VERSION 3.16.3)

project(fuurin_lib)

include(ExternalProject)

ExternalProject_Add(fuurin
    GIT_REPOSITORY    https://github.com/mdamiani/fuurin.git
    GIT_TAG           master
    INSTALL_DIR       "${CMAKE_BINARY_DIR}/install"

    CMAKE_ARGS
        -D CMAKE_INSTALL_PREFIX=<INSTALL_DIR>
        -D CMAKE_BUILD_TYPE=RelWithDebInfo
        -D CMAKE_TOOLCHAIN_FILE=${CMAKE_SOURCE_DIR}/cross_zig.cmake
)

Si tratta di integrare la libreria fuurin come External Project, passando delle opzioni di configurazione. Una tra queste opzioni è la toolchain di compilazione, ovvero il file cross_zig.cmake, che vedremo più avanti, che ha lo scopo di usare zig c++ come compilatore.
La prima compilazione con zig build ha dato alcuni errori:

ld.lld: error: undefined symbol: operator new(unsigned long)
>>> referenced by arg.cpp:150 (/home/mirko/projects/fuurin/src/arg.cpp:150)

Mhmm non trova l’operatore new del C++. Questo problema l’ho risolto aggiungendo l’opzione di linking verso la libreria C++:

exe.linkSystemLibrary("c++");

Provando nuovamente a compilare vedo che sono rimasti ancora alcuni errori:

ld.lld: error: undefined symbol: zmq_ctx_new
>>> referenced by zmqcontext.cpp:59 (/home/mirko/projects/fuurin/src/zmqcontext.cpp:59)
>>>               zmqcontext.cpp.o:(fuurin::zmq::Context::Context()) in archive fuurin/build/install/lib/libfuurin_static.a

Questo errore è legato alla libreria esterna fuurin, dato che pare mancare il simbolo zmq_ctx_new, che fa parte del codice ZeroMQ. Ma questo codice non doveva essere già presente dentro libfuurin_static.a?
Infatti nel progetto esterno avevo indicato la dipendenza da libzmq.a:

target_link_libraries(fuurin_static PUBLIC zeromq_static)

Tuttavia analizzando i simboli presenti nell’archivio statico, si vede che tale simbolo non è definito:

❯ nm libfuurin_static.a | grep zmq_ctx_new
                 U zmq_ctx_new

In effetti però questa dipendenza entra gioco solo quando si effettua linking verso un eseguibile. Quindi non implica che il codice di ZeroMQ sia integrato dentro fuurin_static.

Passo 3: Unione di archivi statici di codice C++

Per eliminare la dipendenza dalla libreria libzmq.a ho dovuto operare sul file di configurazione del progetto esterno fuurin. In particolare è necessario aggiungere il codice oggetto di libzmq.a dentro l’archivo libfuurin_static.a. Ho scoperto che questa operazione non è facile come potrebbe sembrare. In generale esistono più soluzioni a questo problema:

  1. usare le Object Library di CMake: difficile da sfruttare quando si vendorizza un progetto e non abbiamo il controllo su come avviene la compilazione.
  2. chiamare manualmente ar oppure libtool: diventa complicato gestire tutte le architetture o compilatori che vogliamo supportare.
  3. il build system è in grado di invocare pkg-config per ottenere le librerie che servono: per fare questo devo distribuire anche la libreria interna vendorizzata.
  4. usare una libreria dinamica: in questo caso CMake dovrebbe integrare il codice dentro la libreria dinamica, ma dovremo distribuirla insieme agli eseguibili che la usano.

Però l’utilizzo della toolchain Zig ci viene in soccorso. Infatti, essendo disponibile su più sistemi operativi e supportando la cross-compilazione verso molte architetture, posso aggiungere un nuovo target che sfrutti ar, che esegue la fusione di tutti i codici oggetto. Abbiamo quindi scelto l’opzione 2, modificando il file CMakeLists.txt della libreria esterna fuurin:

add_custom_target(make-zeromq-obj-dir ALL
    COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/zeromq-obj"
)
add_custom_command(
    TARGET fuurin_static
    POST_BUILD
    COMMAND ${CMAKE_AR} -x   $<TARGET_FILE:zeromq_static>
    COMMAND ${CMAKE_AR} -qcs $<TARGET_FILE:fuurin_static> *.o
    COMMAND ${CMAKE_RANLIB}  $<TARGET_FILE:fuurin_static>
    WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/zeromq-obj"
)
add_dependencies(fuurin_static make-zeromq-obj-dir)

Tramite un comando di post build, si esegue un’estrazione del contenuto dell’archivio libzmq.a, per poi aggiungerlo alla libreria libfuurin_static.a. Controllando nuovamente l’archivio prodotto, si può verificare la presenza del simbolo zmq_ctx_new:

❯ nm libfuurin_static.a | grep zmq_ctx_new
                 U zmq_ctx_new
0000000000000040 T zmq_ctx_new

Infatti adesso il comando zig build compila con successo!

Però i problemi non sono ancora finiti…

❯ zig build
❯ ./zig-out/bin/main
Segmentation fault at address 0x0
/home/mirko/projects/fuurin/vendor/zeromq/src/ypipe.hpp:77:24: 0x2c7dde in write (/home/mirko/projects/fuurin/vendor/zeromq/src/mailbox.cpp)
        _queue.back () = value_;
                       ^
zsh: IOT instruction (core dumped)  ./zig-out/bin/main

Passo 4: Raffinamento della compilazione con Zig

Il problema precedente penso sia legato alla toolchain Zig, perché il progetto fuurin ha molti test e non ho mai visto un crash del genere. Inoltre eseguendo la stessa procedura in ambiente Mac OS X si ottiene un eseguibile che funziona correttamente, senza nessun crash. Proviamo ad analizzare con valgrind:

❯ valgrind --leak-check=full ./zig-out/bin/main
Segmentation fault at address 0x0
/home/mirko/projects/fuurin/vendor/zeromq/src/ypipe.hpp:77:24: 0x2c7dde in write (/home/mirko/projects/fuurin/vendor/zeromq/src/mailbox.cpp)
        _queue.back () = value_;
                       ^

L’indirizzo incriminato è 0x2c7dde, vediamo che cosa c’è nel codice assembly:

❯ objdump -dS zig-out/bin/main | grep -B4 2c7dde
  2c7dd1:       48 c1 e1 06             shl    $0x6,%rcx
        _queue.back () = value_;
  2c7dd5:       c5 fc 28 06             vmovaps (%rsi),%ymm0
  2c7dd9:       c5 fc 28 4e 20          vmovaps 0x20(%rsi),%ymm1
  2c7dde:       c5 fc 29 4c 08 20       vmovaps %ymm1,0x20(%rax,%rcx,1)

L’istruzione problematica è la vmovaps, che fa parte delle istruzioni AVX (Advanced Vector Extensions) per architetture x86. Essa ha requisiti di allineamento in memoria dei dati a cui accede e può generare un’eccezione di general protection (GP), causando il crash dell’applicazione.
Forse questo è un bug della toolchain, oppure dipende da qualche altra (mancata) configurazione a livello di build. Ho deciso allora di aggirare il problema, evitando di generare quelle istruzioni. Per farlo ho analizzato le architetture CPU supportate da Zig, con il comando zig targets. Ho notato che le istruzioni avx2 sono presenti per il tipo di CPU x86_64_v3, mentre sono assenti per la CPU x86_64_v2. Quindi ho forzato quest’ultima come CPU di compilazione, tramite il comando zig build -Dcpu=x86_64_v2 e la stessa opzione del compilatore anche nel file cross_zig.cmake, menzionato in precedenza:

set(CMAKE_SYSTEM_NAME Linux)

set(ZIG zig)
set(ZIG_FLAGS "-mcpu=x86_64_v2")

set(CMAKE_C_COMPILER    ${ZIG} cc)
set(CMAKE_CXX_COMPILER  ${ZIG} c++)
set(CMAKE_AR            ${ZIG} ar)
set(CMAKE_RANLIB        ${ZIG} ranlib)

set(CMAKE_C_FLAGS   "${ZIG_FLAGS}")
set(CMAKE_CXX_FLAGS "${ZIG_FLAGS}")

set(CMAKE_CXX_ARCHIVE_CREATE "${ZIG} ar qc  <TARGET> <LINK_FLAGS> <OBJECTS>")
set(CMAKE_CXX_ARCHIVE_APPEND "${ZIG} ar q   <TARGET> <LINK_FLAGS> <OBJECTS>")
set(CMAKE_CXX_ARCHIVE_FINISH "${ZIG} ranlib <TARGET>")

Effettivamente questa impostazione risolve il problema e, avviando l’eseguibile di prova, esso termina senza nessuna eccezione:

❯ ./zig-out/bin/main
❯ echo $?
0
❯ file zig-out/bin/main
zig-out/bin/main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped
❯ ldd zig-out/bin/main
        linux-vdso.so.1 (0x00007ffd9f52f000)
        libgtk3-nocsd.so.0 => /lib/x86_64-linux-gnu/libgtk3-nocsd.so.0 (0x00007fee981e1000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fee98093000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fee98071000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fee97e85000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fee9840b000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fee97e7e000)
        librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007fee97e73000)
        libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007fee97e6c000)
❯ du -h ./zig-out/bin/main
13M     ./zig-out/bin/main

Siamo arrivati ad un primo traguardo! Abbiamo compilato un progetto C++ complesso ed integrato tutto il codice oggetto “utente” nel file binario eseguibile. C’è soltanto dipendenza dalle librerie di sistema.

Passo 5: Proviamo con la libc musl

Nelle prove precedenti ho provato solo a compilare la libreria fuurin, ma ci sono anche degli esempi forniti a corredo, che generano degli eseguibili. Sul progetto esterno fuurin ho provato il flag -static e abilitata la creazione degli eseguibili di esempio. Dato che si tratta di un progetto CMake, ho aggiunto questa riga al file CMakeLists.txt principale:

set(CMAKE_EXE_LINKER_FLAGS "-static")

Successivamente ho provato a compilare il progetto:

❯ cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_EXAMPLES=1 ..
❯ make
... 
/home/mirko/projects/fuurin/vendor/zeromq/src/ip_resolver.cpp:721: warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking

❯ file examples/producer
examples/producer: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, with debug_info, not stripped

❯ du -h examples/producer
28M     examples/producer

Nonostante il binario risulti staticamente compilato, il compilatore ha emesso un warning (e non è l’unico), dicendo che l’eseguibile avrà bisogno comunque di una precisa versione della glibc usata per il linking. Quindi anche se l’eseguibile risulta senza dipendenze, potrei comunque avere problemi di compatibilità con il sistema su cui gira e ciò non è accettabile. A questo punto non c’è molto altro che si possa fare, a meno di non sostituire la glibc con qualcosa di simile, anche se non ho avuto mai voglia di introdurre queste dipendenze “a mano”, perché hanno un costo di manutenzione. Alcune soluzioni potrebbero essere:

Con Zig posso utilizzare la musl solo cambiando il target, ovvero un flag nel file di cross-compilazione cross_zig.cmake:

set(ZIG_FLAGS "-target x86_64-linux-musl")

Provando adesso a compilare, non c’è nessun warning!

❯ cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_EXAMPLES=1 -DCMAKE_TOOLCHAIN_FILE=<path_to>/cross_zig.cmake ..

❯ file examples/producer
examples/producer: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

❯ du -h examples/producer
14M     examples/producer

Volendo ricompilare l’esempio precedente che utilizzava funzioni C da codice Zig, il risultato è il seguente:

❯ zig build -Dtarget=x86_64-linux-musl
❯ ./zig-out/bin/main
❯ echo $?
0
❯ file ./zig-out/bin/main
./zig-out/bin/main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, with debug_info, not stripped
❯ musl-ldd ./zig-out/bin/main
        /lib/ld-musl-x86_64.so.1 (0x7f9a4441d000)
        libc.so => /lib/ld-musl-x86_64.so.1 (0x7f9a4441d000)
❯ du -h ./zig-out/bin/main
13M     ./zig-out/bin/main

È stato molto semplice e adesso ci sono meno dipendenze dalle librerie di sistema, confrontando col caso glibc.

Passo 6: Cross-compilazione per altre architetture

Il vantaggio di questa toolchain è anche legato alla possibilità di cambiare target con il flag -target che abbiamo già visto, per ottenere codice per molte altre architetture. Alcuni di questi target sono:

Cioè posso compilare il mio programma di test anche per Mac OS X da Linux!

❯ zig build -Dtarget=x86_64-macos-gnu
❯ file ./zig-out/bin/main
./zig-out/bin/main: Mach-O 64-bit x86_64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE|HAS_TLV_DESCRIPTORS>

Oppure posso scegliere di cross-compilare il software per l’architettura ARM della DevelBoard:

❯ zig build -Dtarget=arm-linux-musleabihf
ld.lld: error: undefined symbol: __clock_gettime64
>>> referenced by clock.cpp:272 (/home/mirko/projects/main/fuurin/build/fuurin-prefix/src/fuurin/vendor/zeromq/src/clock.cpp:272)
>>>               clock.cpp.o:(zmq::clock_t::clock_t()) in archive fuurin/build/install/lib/libfuurin_static.a

Ops, in questo caso la libreria fuurin compila senza errore, ma c’è un errore in fase di linking finale con il codice Zig. Non ho indagato a fondo, ma potrebbe essere legato a questa issue che sarà risolta nella versione Zig v0.10.
Come test finale, proviamo la cross-compilazione per Windows da Linux. Siccome la libreria fuurin non supporta di per sé questa modalità per dettagli tecnici, allora ho preso un esempio che faccia uso di Winsock:

❯ zig c++ -target i386-windows-gnu wsock.cpp -o wsock -lws2_32 -lmswsock -ladvapi32
❯ file wsock
wsock: PE32 executable (console) Intel 80386, for MS Windows
❯ du -h wsock
48K     wsock

Questa volta ha funzionato subito, senza problemi!

Conclusioni

Zig è ancora un linguaggio in fase di sviluppo e durante le mie prove mi sono imbattuto in alcuni problemi, che a dir la verità mi aspettavo. Però sono rimasto sorpreso dalla semplicità d’uso dei tool da riga di comando, ripensando alla mia esperienza con toolchain C/C++ “classiche”. In caso di errori, sono riuscito a trovare qualche workaround per andare avanti ed ho avuto talvolta riscontro con le issue aperte in fase di risoluzione. Sono anche riuscito a raggiungere l’obiettivo che mi ero prefisso di linking statico di codice C++ complesso, cioè che non fossero le solite 3 righe di sorgente a scopo didattico.
Per quanto sopra, anche se non si decide di utilizzare questo strumento come linguaggio di programmazione, può essere tuttavia utile come toolchain per la cross-compilazione di software scritto in altri linguaggi.

Scrivere codice è la tua passione?

Entra nel team Develer!

Lavora con noi