I recently tried to build QEMU on macOS and make the result portable enough that it could still run after uninstalling the Homebrew packages used during the build, or I can move the whole QEMU directory to another machine. I don’t want the Homebrew build because it installs too many dependencies on the system, and I want a more self-contained bundle, not a system-wide installation.
The short version: fully static linking on macOS is not really the right target. macOS binaries still dynamically link against Apple system libraries such as libSystem. But for tools like QEMU, what I really wanted was not a “pure static binary”; I wanted a self-contained directory like this:
| |
The binaries in bin/ should load their non-system dependencies from ../lib (note that it’s relative, not absolute, so it will work even if the whole directory is moved), instead of from /opt/homebrew.
This post records the process, using QEMU as the example.
The original problem
I built QEMU roughly like this:
| |
The build worked. But after uninstalling some Homebrew dependencies, running qemu-img failed:
| |
This means the installed QEMU binary still contains an absolute dependency path:
| |
We can confirm that with:
| |
Example output:
| |
The /usr/lib and /System/Library entries are fine. They are macOS system libraries. The /opt/homebrew/... entries are the ones that make the binary depend on my local Homebrew installation.
Why not just build QEMU fully static?
On Linux, the natural thought is “just build a static binary”.
On macOS, that is not usually how things work. macOS does not support the same style of fully static userland binary that Linux users often expect. You can try to statically link some third-party libraries, but the final program will still dynamically link to Apple system libraries.
So the practical goal is:
Bundle third-party .dylib dependencies next to the program, and rewrite Mach-O load commands so the binary loads those local copies.
This is similar in spirit to what many .app bundles do, but here I wanted a plain CLI directory layout.
The tools involved
macOS Mach-O binaries can be inspected and patched with these tools:
| |
otool -L shows dynamic library load commands.
For example:
| |
might show:
| |
Those can be rewritten with:
| |
@executable_path means “the directory containing the executable being launched”.
So it will try to find libglib-2.0.0.dylib in ../lib/ relative to the executable, no longer looking in /opt/homebrew.
First attempt: patch direct dependencies only
My first attempt was simple:
| |
This found direct Homebrew dependencies used by QEMU binaries, such as:
| |
Then I copied those libraries into $PREFIX/lib and patched the binaries.
This fixed some errors, but not all of them.
Running qemu-img then failed with a different error:
| |
So I realized that it is not enough to patch only the binaries. The copied .dylib files have their own dependencies too.
In this case:
| |
libintl.8.dylib was not directly referenced by qemu-img. It was a transitive dependency of libglib.
The correct model
The dependency graph looks like this:
| |
So the bundling process must be recursive:
- Scan Mach-O files in
bin/. - Find its
/opt/homebrewdependencies. - Copy those
.dylibfiles intolib/. - Patch the binaries to refer to the bundled copies (
../lib). - Scan the copied
.dylibfiles. - Copy and patch their dependencies too.
- Repeat until no
/opt/homebrewdependencies remain.
Which paths should be used?
For executables in bin/, I use:
| |
For dylibs inside lib/, I use:
| |
@executable_pathis relative to the main executable being launched.@loader_pathis relative to the Mach-O file that is doing the loading. For dependencies between dylibs, this is what we want:1 2lib/libglib-2.0.0.dylib -> @loader_path/libintl.8.dylib
Since both files are in the same lib/ directory, this resolves correctly.
The final bundling script
I ended up writing a small Python script called macho-bundle-deps. You can find it here: https://github.com/charlie0129/dotfiles/blob/f15791054e517f6b5afc892312f1db73f331d475/bin/darwin/macho-bundle-deps. This is a permanent link to a specific commit, so you may want to check the latest version in the repository.
Usage:
| |
Or, from inside the QEMU prefix:
| |
The script does the following:
- finds Mach-O files from the input paths
- scans their dependencies with
otool -L - copies matching dependencies into
--lib-dir - patches executable dependencies to
@executable_path/../lib/... - patches bundled dylib dependencies to
@loader_path/... - patches copied dylib IDs with
install_name_tool -id - recursively processes newly copied dylibs
- reports an error if matching absolute dependency paths remain
Verifying the result
After running the bundler, I verify that no Homebrew paths remain:
| |
Now I can uninstall the dependencies from Homebrew, and the bundled QEMU still works:
| |
Notes and limitations
This is not the same as producing a fully static binary.
The result still depends on macOS system libraries, which is normal:
| |
It also does not magically make a binary portable across all macOS versions or CPU architectures. A binary built on Apple Silicon is still an arm64 Mach-O binary unless built otherwise.
This approach is mainly useful for making a local CLI tool directory self-contained with respect to Homebrew dependencies.
Why copying symlinks should be avoided
Homebrew often has dylib symlinks like:
| |
When bundling, prefer copying the symlink target, not the symlink itself.
In shell, that means:
| |
In Python, shutil.copy2(..., follow_symlinks=True) does the same thing.
This avoids ending up with a bundled symlink pointing to a non-existent file.
Final layout
After bundling, the QEMU directory looks like this:
| |
Now qemu-img no longer cares whether Homebrew’s glib, gettext, pcre2, or zstd packages are installed.
Conclusion
The main lesson is that macOS dependency bundling is graph traversal, not a one-shot patch.
Patching only the top-level binaries is not enough. You also need to patch the dylibs that you copied, and then patch the dylibs that those dylibs depend on.
The final strategy is:
| |
For executables:
| |
For bundled dylibs:
| |
And for the dylib’s own install name:
| |