Forking the Nerves system, and enabling HAB inside a Buildroot build

Three real bugs blocked the first three attempts: a nerves package version whose error message points nowhere near the cause, a missing .tool-versions, and the same SPL SRAM overflow track A hit. Plus the arm64 header field that is at offset 0x10, not 0x4.

The fork is a copy, and copying is the easy part. What took three attempts was getting a HAB-enabled Nerves system to build at all.

The fork

rsync -a --delete \
--exclude='_build/' --exclude='deps/' --exclude='doc/' \
--exclude='.nerves/' --exclude='nerves/' --exclude='.fetch/' \
--exclude='*.ez' --exclude='erl_crash.dump' \
../nerves_system_iotdin_imx8p/ src/nerves_system_iotdin_imx8p_secure/

Everything excluded is a build artefact — regenerated, never meaningful to fork. The original repository is never edited in place.

Bug one: the toolchain was not pinned

elixir --version
# must read: Elixir 1.19.5 (compiled with Erlang/OTP 28)

If it reads anything else, a .tool-versions is missing or wrong. This sounds like housekeeping and is not: a Nerves build that runs on a different OTP than the system expects fails much later, with an error about something else entirely.

Pin it in the project rather than in your shell, so the next person — including you in six months — gets it automatically.

Bug two: the nerves version, and an error that points nowhere

This is the one that cost the most, because the failure names a module that has nothing to do with the cause.

** (UndefinedFunctionError) function Nerves.Artifact.BuildRunners.Local.build/3 is undefined

The dependency resolver picks nerves 1.15.0 by default. On this project that reproduces the error above, every time, on the very first build.

# mix.exs
{:nerves, "~> 1.14.2", runtime: false, override: true}

1.14.2 is the exact version both of this project's already-working sibling repositories pin in their own mix.lock. Matching a proven combination beats trusting the resolver's latest output — and when a dependency's own internals throw UndefinedFunctionError, suspect the version before you suspect your code.

While you are in mix.exs, two more things have to change from the placeholder state a fresh fork is in:

# the target list — this board's real target name, not the template's placeholder
# the system dependency — the FORK from above, never an official Nerves system
{:nerves_system_iotdin_imx8p_secure, path: "...", runtime: false, nerves: [compile: true]}

Pointing at an official system here is a silent way to build something that boots and is not your system at all.

Bug three: the same SPL overflow, exactly as predicted

Turning on HAB in the fork's U-Boot defconfig produced — precisely as flagged when it happened in track A — the same linker failure:

ld.bfd: region `.sram' overflowed by 10440 bytes

Same cause, same fix, verified fresh rather than assumed:

- CONFIG_SPL_MAX_SIZE=0x26000
+ CONFIG_SPL_MAX_SIZE=0x2C000

There is something quietly satisfying about a bug you have already solved reappearing in a different build system and yielding to the same fix. It is also the strongest argument for writing failures down in the first place: this one cost an afternoon in track A and ten minutes here.

A fourth bug, found only once the build got that far

With HAB compiling, the build then failed on a missing packaging file — an artefact the fork needed that the original had never required, because the original never produced a signed bootloader.

Worth naming as a category: each bug in this sequence was only reachable once the previous one was fixed. There is no way to find bug three while bug two is still failing. Builds that fail early hide everything behind them, and "it failed again" is not the same as "it failed the same way".

The arm64 header field that is not where the guide says

One detail from signing the kernel, because it is the kind of thing that silently produces a wrong answer rather than an error.

An arm64 Image header declares its own size. That field is 8 bytes at offset 0x10 — not 4 bytes at 0x4, which is what a truncated example in a guide had me believe first time around:

import struct
with open(image, 'rb') as f:
data = f.read(64)
magic = struct.unpack_from('<I', data, 0x38)[0]
assert magic == 0x644d5241, f'not an arm64 Image: {magic:#x}'
image_size = struct.unpack_from('<Q', data, 0x10)[0]

Check the magic at 0x38 first. Reading the wrong offset gives you a plausible-looking number rather than a failure, and a plausible-looking number is how you sign the wrong range.

Never hard-code the load address

The signing script takes it as an argument, read fresh from the fork's own uboot.env:

./sign_kernel.sh <input-Image> <loadaddr-hex> <output-Image>

Because it is genuinely different between the two tracks on the same board:

Load address
Track A, stock Linux 0x40400000
Track B, this Nerves system 0x40480000

Hard-code it and you get a valid signature over the wrong memory range — which fails at boot with an error about authentication, sending you to look at your keys rather than at a number you copied from a different post.

Next: signing the kernel in this build, where checking rather than assuming paid off immediately.