The day I learned Mach-O segments matter
Last year I shipped a Go binary that worked fine on every Linux host we tested it on, and then segfaulted immediately on the Intel Mac I use for local testing. The error message was unhelpful (“Segmentation fault: 11”). The binary was the same source code, just built for darwin/amd64 instead of linux/amd64. That afternoon taught me more about Mach-O than I ever wanted to know.
The setup: we have a small agent that embeds a shared library (a native blob, linked via cgo). On Linux, we build it statically against musl and ship a single ELF binary that runs anywhere. On macOS, static linking is… fraught. libSystem is effectively required to be dynamic, because Apple explicitly does not ship a static libc and they can change the syscall ABI between releases. So our Mac build had a dynamic link to libSystem.dylib.
The binary worked on my colleague’s Mac and on the CI Mac. It didn’t work on mine. I was on a slightly older OS (Monterey vs their Ventura). The crash happened before main — it was something the dynamic linker was doing.
First tool: otool -L binary. This lists the dynamic libraries a Mach-O file wants:
$ otool -L ./agent
./agent:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
/opt/local/lib/libssl.3.dylib (compatibility version 3.0.0, current version 3.0.0)
That second line was the problem. libssl.3.dylib from /opt/local — a path from my colleague’s MacPorts install. My machine didn’t have MacPorts; it had Homebrew, which installs OpenSSL at /opt/homebrew/opt/openssl@3/lib/. The dyld couldn’t find the library, and the process failed to load.
This raised a more interesting question: why was there a path in the binary at all? When you link a dynamic library into a Mach-O, the LC_LOAD_DYLIB load command records the install name of that library. This is metadata baked into the dylib itself at link time, via -install_name. If the dylib was built with -install_name @rpath/libssl.3.dylib, you’d get @rpath/libssl.3.dylib in the dependent binary, and dyld would search each LC_RPATH in the binary in order. But libssl.3.dylib from MacPorts had been built with -install_name /opt/local/lib/libssl.3.dylib, the absolute path. So the binary had that path baked in.
The deeper answer to “where does this path come from” lies in Mach-O load commands. Every Mach-O file has a header, followed by a list of load commands. You can see them all with otool -l:
$ otool -l ./agent | grep -A 2 LC_LOAD_DYLIB
cmd LC_LOAD_DYLIB
cmdsize 80
name /usr/lib/libSystem.B.dylib (offset 24)
--
cmd LC_LOAD_DYLIB
cmdsize 80
name /opt/local/lib/libssl.3.dylib (offset 24)
And LC_RPATH commands:
$ otool -l ./agent | grep -A 2 LC_RPATH
# (nothing - no rpath set)
If we’d set an rpath, we could have made the binary relocatable. The fix, for our case, was twofold:
- Rewrite the binary’s recorded dylib path with
install_name_tool:
install_name_tool -change /opt/local/lib/libssl.3.dylib @rpath/libssl.3.dylib ./agent
install_name_tool -add_rpath /usr/local/lib ./agent
install_name_tool -add_rpath /opt/homebrew/opt/openssl@3/lib ./agent
install_name_tool -add_rpath /opt/local/lib ./agent
- Actually, the real fix: ship the dylib with the binary.
We ended up bundling libssl.3.dylib alongside the binary in a lib/ directory and using @executable_path/../lib/libssl.3.dylib as the install name, which means “look for this library at a path relative to the executable.” This is how most macOS apps work.
Other Mach-O concepts I picked up in that afternoon:
- Segments and sections. A Mach-O has segments (
__TEXT,__DATA,__LINKEDIT, etc.) each of which has sections. The__TEXTsegment is read-only executable code.__DATAis mutable. On macOS, there’s also__DATA_CONSTfor stuff that’sconstbut needs dynamic relocation fixup — this is a security hardening feature. - Code signing. macOS requires binaries to be signed, at least ad-hoc, to run. On Apple Silicon, this is enforced for all binaries. If you use
install_name_toolorcodesignafter-the-fact modifications, you’ll need to re-sign:
codesign --force --sign - ./agent # ad-hoc sign
- Stripped vs not.
stripremoves the symbol table. On Mach-O, if you strip too aggressively, the linker can’t even load the binary.strip -xis usually safer. - Universal binaries. A “fat” Mach-O that contains multiple architectures, e.g., x86_64 and arm64.
lipois the tool for creating and inspecting them.file binarytells you if a binary is universal or single-arch.
For reference, ELF is a whole separate story. On Linux, you’d use readelf -d to see dynamic dependencies and patchelf to modify rpaths. The concepts are similar (rpath, dynamic linker, symbol resolution) but the tooling is different.
Since that day, I’ve added a CI step that runs otool -L on every Mac binary we produce and fails the build if any library path starts with /opt/local, /opt/homebrew, or /usr/local/Cellar. Those are all paths that won’t exist on most developers’ machines. The only allowed paths are @executable_path/..., @rpath/..., and /usr/lib/ or /System/... (which are Apple-provided and universally available).
Shared libraries on macOS are a lot more opinionated than on Linux. Once you learn the rules, it’s fine. Before you learn them, they can eat a day.