A minimal MirageOS unikernel that boots on macOS / Apple silicon under Solo5's hvt tender
mirageos ocaml unikernel
OCaml 100%

README.md

mirage-hello #

A minimal MirageOS unikernel that boots on macOS / Apple silicon under Solo5's hvt tender, backed by Apple's Hypervisor.framework instead of Linux/KVM.

            |      ___|
  __|  _ \  |  _ \ __ \
\__ \ (   | | (   |  ) |
____/\___/ _|\___/____/
Solo5: Bindings version v0.12.0
Solo5: Memory map: 64 MB addressable:
Solo5:   reserved @ (0x0 - 0xfffff)
Solo5:       text @ (0x100000 - 0x24ffff)
Solo5:     rodata @ (0x250000 - 0x28ffff)
Solo5:       data @ (0x290000 - 0x55ffff)
Solo5:       heap >= 0x560000 < stack < 0x4000000
2026-08-08T00:32:14-00:00: [INFO] [application] hello from MirageOS on Apple silicon (4)
2026-08-08T00:32:14-00:00: [INFO] [application] hello from MirageOS on Apple silicon (3)
2026-08-08T00:32:15-00:00: [INFO] [application] hello from MirageOS on Apple silicon (2)
2026-08-08T00:32:15-00:00: [INFO] [application] hello from MirageOS on Apple silicon (1)
Solo5: solo5_exit(0) called

The whole unikernel is the two files in this repo: config.ml describes the device graph (nothing but a job here) and unikernel.ml logs four times, half a second apart.

Why the forks #

Upstream Solo5 and ocaml-solo5 build only on Linux and the BSDs. Running this needs two forks:

  • tsirysndr/solo5 — adds an hvt backend on Apple's Hypervisor.framework, plus the portability work to build the tender on a Mach-O host.
  • tsirysndr/ocaml-solo5 — makes the OCaml→Solo5 cross-compiler build on a macOS host.

Both are pinned from source below; neither is on opam yet.

Requirements #

  • Apple silicon (M-series). There is no x86_64 Hypervisor.framework backend.
  • macOS 11 or later.
  • Xcode command line tools: xcode-select --install
  • opam: brew install opam

Step by step #

1. Install the toolchain dependencies #

Apple ships no ELF linker or objcopy, and no bare-metal aarch64 compiler runtime. Homebrew supplies all three:

brew install lld llvm aarch64-elf-gcc

llvm is keg-only, so put it on PATH for the rest of these steps:

export PATH="$(brew --prefix llvm)/bin:$PATH"

2. Clear inherited C flags #

If your shell exports CPPFLAGS or CFLAGS — Homebrew's openjdk sets CPPFLAGS, for instance — they leak into ocamlfind ocamlopt, which rejects them and fails the build on clock_stubs.o:

unset CPPFLAGS CFLAGS LDFLAGS

3. Create an opam switch #

ocaml-solo5 wants OCaml 5.4.1 or 5.5.0:

opam switch create solo5-mirage ocaml-base-compiler.5.4.1
eval $(opam env --switch=solo5-mirage --set-switch)

4. Pin the forks #

git clone -b hvf-macos-aarch64  https://github.com/tsirysndr/solo5.git
git clone -b macos-hvf-aarch64  https://github.com/tsirysndr/ocaml-solo5.git

opam pin add solo5       ./solo5       --kind=path -y
opam pin add ocaml-solo5 ./ocaml-solo5 --kind=path -y

Building ocaml-solo5 compiles a full OCaml cross-compiler; expect it to take a while.

Note — a path pin copies the tree without .git, so Solo5 cannot derive its version. If the pin fails with version.h.distrib: Not found, run ./scripts/gen_version_h.sh include/version.h.distrib inside the clone once.

5. Install mirage #

opam install mirage

6. Build the unikernel #

mirage configure -t hvt
make

This produces dist/hello.hvt, a statically linked aarch64 ELF binary. It is not a macOS executable — it only runs inside the tender.

7. Run it #

solo5-hvt --mem=64 dist/hello.hvt

Troubleshooting #

hv_vm_create() failed: 0xfae94001 — the tender lost its code signature. Hypervisor.framework refuses to create a VM without the com.apple.security.hypervisor entitlement, and entitlements only count as part of a signature, so anything that rewrites the binary (strip, some installers) drops it. Re-sign from the Solo5 checkout:

codesign --force --sign - \
    --entitlements tenders/hvt/solo5-hvt.entitlements \
    $(which solo5-hvt)

ocamlopt: unknown option '-I/opt/homebrew/...' — see step 2.

llvm-objcopy: command not found — see step 1; llvm is keg-only and not linked into /opt/homebrew/bin.

configure.sh: ERROR: ld.lld is not LLVM LLD — lld is missing from PATH, or an older Solo5 checkout is being used whose version check does not recognise Homebrew's banner.

Known limitations #

These come from the Solo5 macOS backend, not from this example:

  • The tender does not drop privileges. macOS has no equivalent of seccomp, pledge(2) or capsicum(4), so the sandboxing Solo5 applies on other hosts is absent here.
  • --net: only accepts the @<fd> form. macOS has no TAP device, so frames have to be shuttled by a helper such as vmnet-helper or passt over a socket passed to the tender. Named interfaces return ENOTSUP.
  • There is no solo5-hvt-debug; the gdb and dumpcore backends are unported.

Going further #

config.ml is where a real unikernel grows. Adding a device — a block store, a network stack, a console — means adding it to the job's signature there and re-running mirage configure. See the MirageOS docs and mirage-skeleton for worked examples.