03 · build

Building from source on honor

stable/15 + clang only. The patch pipeline. Why every other combination silently breaks.

I cross-build aarch64 kernels on honor, a FreeBSD 15.0-RELEASE amd64 box with 16 cores and 672 GB free on zroot. A full buildkernel is about 5 minutes on a cold cache; an incremental rebuild after touching one driver file is around 18 seconds. The phone itself could in theory build its own kernel, but cross-building from a real machine is the only sane workflow and you’d be insane to do it any other way. ● working

This essay is about the build host, the source tree’s structure, and the deploy step. It’s not about the ports or world — we run an unmodified Honeyguide userland and only ship a custom kernel.

The build host

honor is reachable as ssh honor. It runs the in-tree toolchain (clang 19.1.7 as of 15.0-RELEASE) and that’s the only toolchain we use. The FreeBSD source lives at ~/pine64-freebsd/honeyguide/freebsd-src. Object files go to ~/pine64-freebsd/honeyguide/obj.clang so they don’t collide with anything else. The clone is on the stable/15 branch and is reset hard every time the patch pipeline runs (more on that below).

There’s a second clone of FreeBSD in ~/drm-subtree that holds the out-of-tree DRM driver subtree (the panfrost + Mali stack). The patch pipeline copies it on top of the freebsd-src clone before applying our overlays.

Why stable/15 specifically

We tried main (16-CURRENT). Multiple bring-up modules wouldn’t link against the stock kernel — KASAN was on by default at one point, and the SDIO bus method ABI shifted under us. We tried releng/14. The drm-subtree was missing IRQ infrastructure we needed and the panfrost backport hadn’t happened. stable/15 is the sweet spot: post-15.0-RELEASE with active patch flow, ABI stable enough that our patches apply for weeks at a time without rework.

When 15.1 cuts, we’ll move. Until then, stable/15 is where the build host lives.

Why clang specifically

FreeBSD ships its own clang in-tree. Use it. Don’t reach for aarch64-unknown-freebsd15.0-gcc14 from packages, even though it’s right there and the toolchain works on every other arm64 cross-build target on the planet.

I burned three days on this in a previous bring-up. The honeyguide repo’s cross-build.sh wraps gcc14 because that’s what the original author used; do not run it. mise run build-kernel is hardwired to clang and that’s the only command you should be invoking.

The repo layout

This repo (pine64-freebsd, the one you’re reading the site for) is the source of truth for everything we change. Its structure:

The patch pipeline

mise run patch does, in order:

  1. honeyguide/patch.shgit reset --hard on freebsd-src, then copies Honeyguide overlays and applies Honeyguide patches.
  2. cp -r ~/drm-subtree/* freebsd-src/sys/dev/drm/ — drops in the full DRM driver stack.
  3. cp -r ../src/* freebsd-src/ — copies our overlays. These override drm-subtree files where we have local fixes.
  4. for f in patches/**/*.patch; do patch ... — applies our unified diffs.

The git reset --hard in step 1 is destructive. Anything you edit directly inside freebsd-src/ on honor is wiped on the next patch run. Do not edit there. Edit in this repo locally, git push, git pull on honor, then mise run patch. There is no other supported workflow. I’ve lost work twice ignoring this rule.

The build

╞═ Build and deploy a kernel ═╡
# 1. Edit locally in src/ or patches/, commit, push.
git add src/ patches/
git commit -m "fix: dwc3_gadget TX completion advances ring"
git push origin master

# 2. On honor: pull and run the patch + build pipeline.
ssh honor 'cd ~/pine64-freebsd && git pull origin master'
mise run patch          # ~10s
mise run build-kernel   # ~18s incremental, ~5 min cold

# 3. Pipe kernel from honor through the local laptop to the phone.
ssh honor 'cat ~/pine64-freebsd/honeyguide/obj.clang/usr/home/jadams/pine64-freebsd/honeyguide/freebsd-src/arm64.aarch64/sys/PINEPHONE_PRO/kernel' \
  | ssh pinephone 'sudo tee /boot/kernel/kernel > /dev/null'

# 4. Reboot phone. Watch on FT232 serial.
ssh pinephone 'sudo reboot'

The pipe-through-laptop step looks ugly but it’s the right answer. honor is on a residential network behind NAT; the phone is on USB-Ethernet attached to my laptop. Direct host-to-host is not routable. ssh honor 'cat …' | ssh pinephone 'tee …' is two SSH connections, both initiated from the laptop, with the kernel binary streaming through stdout/stdin. Throughput is whatever the slower of the two SSH links provides (usually the laptop-to-phone leg over USB CDC ECM, which is fine).

For module-only rebuilds, mise run build-module netgraph/bluetooth/h4frame will rebuild a single module without touching the kernel. Same deploy pattern: pipe through, drop into /boot/kernel/, kldload on the phone.

What can break

A mise run patch failure usually means an upstream stable/15 change conflicts with one of our patches in patches/. Read the reject file, regenerate the diff, push, retry. This happens roughly once per month on average.

A mise run build-kernel failure with a weird linker error is sometimes the gcc14 ghost — make sure you actually used the mise wrapper and not someone’s helpful cross-build.sh. Otherwise it’s usually a real C error in something I just changed.

A successful build that boots but where a driver doesn’t attach is usually one of: forgot to add the device to PINEPHONE_PRO kernel config, forgot a DRIVER_MODULE, or — the classic — module built with the wrong toolchain. Check kldstat, check dmesg | grep <driver>, check that your driver has a device_attach log line at all.

The kernel object path in step 3 of the recipe is hideous because it embeds MAKEOBJDIRPREFIX and the canonical source path. Tab-completion is your friend.