Notes on cross-compiling a Rust project from Linux (x86_64) to FreeBSD x86_64 using cargo-zigbuild, and the pitfalls I ran into along the way. The project is a Discord bot that depends on crates like sysinfo, tikv-jemallocator, and sea-orm (SQLite).
Background
Zig ships with a multi-platform libc and cross-compilation toolchain. cargo-zigbuild leverages Zig as the linker to enable Rust cross-compilation — usually much more convenient than setting up a cross toolchain yourself. That said, targeting FreeBSD comes with some unique gotchas.
Setup
# Add the FreeBSD target
rustup target add x86_64-unknown-freebsd
# Install cargo-zigbuild (if you haven't already)
cargo install cargo-zigbuild
Problem 1: Missing FreeBSD System Libraries
Compilation itself went fine, but the link step failed with:
error: unable to find dynamic system library 'geom' using strategy 'no_fallback'
The sysinfo crate on FreeBSD needs to link against FreeBSD base system libraries: libkvm, libprocstat, libgeom, libdevstat, and others. Zig’s toolchain only provides libc — none of these system libraries are included.
Solution: Create Stub Shared Libraries
These libraries only need real implementations at runtime; at link time you just need symbol resolution to succeed. The fix is to create minimal stub .so files using Zig:
mkdir -p freebsd-stubs && cd freebsd-stubs
# Libraries where real symbols are needed (sysinfo actually calls these)
cat > stub_kvm.c << 'EOF'
void *kvm_openfiles(void) { return 0; }
int kvm_close(void) { return 0; }
void *kvm_getprocs(void) { return 0; }
void *kvm_getargv(void) { return 0; }
void *kvm_getenvv(void) { return 0; }
int kvm_getswapinfo(void) { return 0; }
EOF
cat > stub_procstat.c << 'EOF'
void *procstat_open_sysctl(void) { return 0; }
void procstat_close(void) {}
void *procstat_getfiles(void) { return 0; }
void procstat_freefiles(void) {}
EOF
# Empty stub (just needs to exist so the linker can find the .so)
cat > stub_empty.c << 'EOF'
void __stub(void) {}
EOF
Compile with -nostdlib -ffreestanding to avoid pulling in a dependency on FreeBSD’s libc, and set the correct soname:
# Libraries with actual symbols — set soname to match the real FreeBSD library name
zig cc -target x86_64-freebsd -shared -nostdlib -ffreestanding \
-Wl,-soname,libkvm.so.7 -o libkvm.so stub_kvm.c
zig cc -target x86_64-freebsd -shared -nostdlib -ffreestanding \
-Wl,-soname,libprocstat.so.1 -o libprocstat.so stub_procstat.c
# Libraries that just need to exist
for lib in geom rt util execinfo memstat devstat; do
zig cc -target x86_64-freebsd -shared -nostdlib -ffreestanding \
-o lib${lib}.so stub_empty.c
done
Why Does soname Matter?
If you pass the .so file’s full path directly to the linker, the resulting binary will have the build machine’s absolute path baked into its NEEDED section:
NEEDED: /home/user/project/freebsd-stubs/libkvm.so # won't be found on the target
With a proper soname set, the NEEDED entry records the soname instead of the file path:
NEEDED: libkvm.so.7 # resolves correctly on FreeBSD
NEEDED: libprocstat.so.1
Problem 2: Zig Linker’s --as-needed Behavior
After configuring .cargo/config.toml to point at the stub library search path, most symbols resolved fine — but kvm_close and procstat_close kept showing up as undefined symbol.
The culprit is Zig’s linker enabling --as-needed. When processing -lkvm, if the linker hasn’t seen any unresolved references to that library yet, it skips it. Since kvm_close is called from a Drop impl, its reference appears late in the link process — by which point -lkvm has already been skipped.
Solution: Pass the .so File Path Directly
Passing the .so file as an explicit input to the linker (rather than going through -l lookup) bypasses the --as-needed filtering:
# .cargo/config.toml
[target.x86_64-unknown-freebsd]
rustflags = [
"-L", "native=/path/to/freebsd-stubs",
"-C", "link-arg=/path/to/freebsd-stubs/libkvm.so",
"-C", "link-arg=/path/to/freebsd-stubs/libprocstat.so",
]
Note that only libkvm.so and libprocstat.so need to be passed explicitly here — the other libraries resolve fine through -L. This is also why soname matters: link-arg passes the full path, but the soname ensures the binary records the correct library name.
Result
$ cargo zigbuild --target x86_64-unknown-freebsd --release
Finished `release` profile [optimized] target(s)
$ file target/x86_64-unknown-freebsd/release/dc-bot
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /libexec/ld-elf.so.1, FreeBSD-style
$ readelf -d target/x86_64-unknown-freebsd/release/dc-bot | grep NEEDED
NEEDED: libkvm.so.7
NEEDED: libprocstat.so.1
NEEDED: libm.so.5
NEEDED: libthr.so.3
NEEDED: libc.so.7
Every dynamically linked library is part of the FreeBSD base system — no extra packages needed on the target machine.
Alternative Approaches
The stub library approach works, but it’s a hack. Here are a few alternatives worth considering:
1. Extract Real Libraries from FreeBSD
The “correct” solution — grab the actual FreeBSD .so files to use as a sysroot:
# Extract base.txz from a FreeBSD release image
fetch https://download.freebsd.org/releases/amd64/15.0-RELEASE/base.txz
mkdir -p freebsd-sysroot
tar -xf base.txz -C freebsd-sysroot ./usr/lib/
# Point to the extracted libraries
export LIBRARY_PATH=/path/to/freebsd-sysroot/usr/lib
No stub writing required, and the symbols and sonames are authentic. The downside is downloading ~200 MB of base.txz and having to track the FreeBSD version of your target machine. For CI/CD pipelines or multi-version deployments, this is the most solid option.
2. Build Natively on the Target Machine
The simplest “just don’t cross-compile” option:
# Install Rust on FreeBSD
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Build directly
cargo build --release
No cross-compilation headaches at all. The catch: Oracle Cloud free-tier FreeBSD instances only have 1 GB of RAM, and compiling a Rust project will almost certainly OOM. You can work around it with swap or CARGO_BUILD_JOBS=1, but builds will be painfully slow. If your target has enough resources (say, 4 GB+), this is honestly the least-friction option.
3. FreeBSD jail / VM for Cross-Compilation
Run compilation inside a FreeBSD VM or jail on your dev machine, then ship the binary:
- bhyve / VirtualBox / QEMU: Run a FreeBSD VM locally, build there, scp the binary to the target.
- CI approach: GitHub Actions has
cross-platform-actions/actionwhich can spin up a FreeBSD VM — good for automation.
You get the native build experience without being constrained by the target machine’s resources. The trade-off is maintaining a FreeBSD VM environment.
4. cross-rs
cross is the more mature cross-compilation tool in the Rust ecosystem, based on Docker containers. However, cross does not officially support FreeBSD targets — FreeBSD’s license prohibits distributing its sysroot inside a Linux container. There are unofficial community FreeBSD Docker images, but maintenance quality varies.
5. Avoid crates That Require System Libraries
Attack the root cause — if your dependency on sysinfo isn’t core functionality, conditionally disable it on FreeBSD:
#[cfg(not(target_os = "freebsd"))]
use sysinfo::System;
Or gate it behind a feature flag. With this approach the FreeBSD build doesn’t need to link against those system libraries at all, and cross-compilation becomes just as straightforward as targeting Linux. The cost is missing that functionality on FreeBSD.
Comparison
| Approach | Complexity | Reliability | Maintenance |
|---|---|---|---|
| Stub libraries | Medium | Medium | Low, but symbol updates may be needed on version bumps |
| Extract real sysroot | Low | High | Medium, need to track FreeBSD version |
| Native build on target | Low | High | Low, but constrained by target resources |
| FreeBSD VM / CI | Medium | High | High, need to maintain VM environment |
| cross-rs | - | - | No official FreeBSD support |
| Conditional compilation | Low | High | Low, but functionality is missing |
For personal projects, the stub approach is good enough. For production or team use, extracting the sysroot or using a CI-based approach is more reliable.
Deployment: FreeBSD rc.d Service
FreeBSD doesn’t have systemd — it uses the traditional rc.d system. Here’s /usr/local/etc/rc.d/dc_bot:
#!/bin/sh
# PROVIDE: dc_bot
# REQUIRE: LOGIN NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="dc_bot"
rcvar="dc_bot_enable"
: ${dc_bot_enable:="NO"}
pidfile="/home/freebsd/bot/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -R 10 -P ${pidfile} -u freebsd \
-o /var/log/${name}.log \
/usr/bin/env RUST_LOG=dc_bot=debug \
/home/freebsd/bot/dc-bot -c /home/freebsd/bot/config.json \
-d /home/freebsd/bot/sqlite.db"
load_rc_config $name
run_rc_command "$1"
A few gotchas:
-fis required: Closes stdin/stdout/stderr. Without it,service starthangs because stdout never closes.-r -R 10: Auto-restarts the process on exit after a 10-second delay. Equivalent to systemd’sRestart=always+RestartSec=10.-Pnot-p: In-rmode,daemonhas both a supervisor and a child process.-Precords the supervisor PID;-precords the child’s. You need-Pforservice stopto work correctly.- pidfile in the user’s home directory: After dropping privileges with
-u, the process can’t write to/var/run/. - Use
/usr/bin/envfor environment variables: rc.d has no equivalent to systemd’sEnvironment=directive.
Enable and start:
sudo sysrc dc_bot_enable=YES
sudo touch /var/log/dc_bot.log && sudo chown freebsd:freebsd /var/log/dc_bot.log
sudo service dc_bot start
Comparison: systemd vs rc.d
| systemd | rc.d | |
|---|---|---|
| Service definition | Declarative INI file | Shell script |
| Daemonization | Built-in | Requires daemon(8) |
| Auto-restart | Restart=always | daemon -r |
| Logging | journald, structured queries | Files (/var/log/) |
| User services | systemctl --user, no sudo | Not supported, requires sudo |
| Environment variables | Environment= | Needs /usr/bin/env or a wrapper script |
systemd gives you more out of the box; rc.d is more transparent but requires handling more details yourself.