Saga della compilazione statica di codice C++ con Zig
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:
- Compilazione del progetto C++ fuurin con CMake.
- 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:
- usare le Object Library di CMake: difficile da sfruttare quando si vendorizza un progetto e non abbiamo il controllo su come avviene la compilazione.
- chiamare manualmente
ar
oppurelibtool
: diventa complicato gestire tutte le architetture o compilatori che vogliamo supportare. - 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. - 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:
- eglibc: serve soprattutto per i sistemi embedded, però funzionerà se compilo per sistemi desktop come Windows?
- dietlibc: ottimizzata per dimensioni piccole e per architetture Linux.
- musl-gcc: semplice wrapper per compilare con musl, ma come faccio se voglio cross-compilare?
- Altro… Zig!
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:
- x86_64-linux-musl
- x86_64-linux-gnu
- x86_64-macos-gnu
- x86_64-windows-gnu
- i386-windows-gnu
- arm-linux-musleabihf
- … ed altri ancora
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.
Vuoi saperne di più su Zig? Leggi anche l’articolo Loop di eventi asincroni con Zig
Scrivere codice è la tua passione?
Entra nel team Develer!