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.