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/action which 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

ApproachComplexityReliabilityMaintenance
Stub librariesMediumMediumLow, but symbol updates may be needed on version bumps
Extract real sysrootLowHighMedium, need to track FreeBSD version
Native build on targetLowHighLow, but constrained by target resources
FreeBSD VM / CIMediumHighHigh, need to maintain VM environment
cross-rs--No official FreeBSD support
Conditional compilationLowHighLow, 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:

  • -f is required: Closes stdin/stdout/stderr. Without it, service start hangs because stdout never closes.
  • -r -R 10: Auto-restarts the process on exit after a 10-second delay. Equivalent to systemd’s Restart=always + RestartSec=10.
  • -P not -p: In -r mode, daemon has both a supervisor and a child process. -P records the supervisor PID; -p records the child’s. You need -P for service stop to 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/env for environment variables: rc.d has no equivalent to systemd’s Environment= 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

systemdrc.d
Service definitionDeclarative INI fileShell script
DaemonizationBuilt-inRequires daemon(8)
Auto-restartRestart=alwaysdaemon -r
Loggingjournald, structured queriesFiles (/var/log/)
User servicessystemctl --user, no sudoNot supported, requires sudo
Environment variablesEnvironment=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.