It compiles. It still won't package.
Four packaging bugs between a Nerves system that builds and a firmware that flashes — plus the kconfig hang that looks exactly like a working build.
The whole system compiled — toolchain, Linux 6.6.23, U-Boot, ARM Trusted Firmware, OP-TEE, a
35 MB SquashFS — and then mix firmware failed at the very last step, four separate times,
each fix revealing the next.
None of them were kernel, U-Boot or device-tree problems. All four were packaging plumbing in my own files. That is worth saying first, because for a while I was convinced I had a hardware description problem, and I was looking in entirely the wrong place.
Before the four, though, three things that cost me time earlier in the build.
A stray environment variable hijacking the build
A leaked MIX_EXS in my shell made mix deps.get and mix firmware resolve the wrong
project. The symptom is confusing: dependencies resolve fine, but the system path dependency
is simply absent from mix deps, and Nerves then cannot set $NERVES_SYSTEM.
unset MIX_EXS
export MIX_TARGET=iotdin_imx8p
The first line is not optional. I now start every build with it.
Related, and embarrassing: twice my edits to the application's mix.exs silently did not
land. The result was an app with no system dependency and a build that died with
"Environment variable $NERVES_SYSTEM is not set". The lesson is to verify rather than
assume:
grep iotdin_imx8p ../../compulab_cs_gw/mix.exs
should show the path dependency:
{:nerves_system_iotdin_imx8p,
path: "../nerves_system_iotdin_imx8p", runtime: false, targets: :iotdin_imx8p}
and @all_targets must include :iotdin_imx8p.
Never run mix firmware without stdin
This is the one I would most want to tell someone before they start.
U-Boot's make syncconfig reads standard input. If a symbol is missing or new in the
.config, kconfig prompts interactively. With no stdin attached — a background job, a
log-to-file invocation, CI — it blocks forever.
It looks completely alive. >>> uboot custom Building.... prints an endless row of dots. I
watched it for a long time believing a kernel was compiling.
Three checks distinguish a hang from real work:
uptime # load ~0 means hung; a real -j25 build sits above 20
ps -ef | grep syncconfig # deepest child is scripts/kconfig/conf --syncconfig, state S
If ps shows conf --syncconfig in state S with wchan pipe_read, and there is no cc1
or gcc anywhere in the tree, and nothing new has been written under build/uboot-custom for
several minutes, it is wedged. Load average is the fastest tell.
The fix is one redirect:
mix firmware </dev/null 2>&1 | tee -a firmware-build.log
</dev/null gives those reads an immediate EOF, so kconfig takes the default instead of
waiting for a human who is not there.
Don't hand-curate the U-Boot defconfig
The reason kconfig had a question to ask in the first place: my hand-written
uboot.defconfig was missing CONFIG_SYS_LOAD_ADDR, so the build stopped on
Address in memory to use by default (SYS_LOAD_ADDR) [] (NEW)
Curating that file by hand was a mistake. It is now a verbatim copy of CompuLab's own
configs/iotdin-imx8p_defconfig with a small documented delta at the bottom. Their config is
authoritative for their board; my judgement about which symbols matter is not.
One thing in that delta is worth explaining. CompuLab's defconfig enables the EFI capsule
options, so U-Boot builds the host tool tools/mkeficapsule, which includes <uuid/uuid.h>.
On a clean build host that header is not present and the build dies:
fatal error: uuid/uuid.h: No such file or directory
You can install uuid-dev. I disabled EFI_CAPSULE_*, EFI_SECURE_BOOT and
TOOLS_MKEFICAPSULE instead, because this system updates through fwup A/B slots and will
never use EFI capsules. Fewer host dependencies is worth more to me than an unused feature.
Also: after changing that defconfig, editing the file is not enough. Wipe the package build directory so it reconfigures. Other packages keep their stamps, so it is cheap:
rm -rf nerves_system_iotdin_imx8p/.nerves/artifacts/*/build/uboot-custom
Then the four packaging bugs
One: rel2fw.sh: No such file or directory
mix firmware runs $NERVES_SYSTEM/scripts/rel2fw.sh. A published Nerves system ships a
scripts/ directory at its root. My locally built path-dependency system had none, so
packaging exited 127 before doing anything.
I chased this into the Nerves internals to understand why.
Nerves.Artifact.BuildRunners.Local.build/3 calls checkout/1, which does a
File.cp_r! of the package path into the build path — and for a Buildroot system that build
path is the artifact directory, the same place mix firmware later looks for
scripts/rel2fw.sh.
So the artifact directory is essentially a copy of my system repository plus Buildroot's
output. Whatever is in my repository root gets copied in. Neither my repository nor the
reference one ships scripts/ — those live only in nerves_system_br. The build itself
works because create-build.sh resolves $NERVES_SYSTEM to the nerves_system_br package
directory, which does have them. Only the later lookup in the artifact directory fails.
That reads like a version-expectation mismatch between nerves and nerves_system_br about
who provides scripts/ in a finalised artifact. I proved the theory with a symlink:
ln -sfn deps/nerves_system_br/scripts <artifact>/scripts
Packaging then advanced to the next bug, which confirmed the diagnosis. The symlink is still a stopgap — it survives normal rebuilds because the artifact directory persists, but a clean rebuild wipes it. This is the one item from this whole exercise I have not properly closed.
Two: a host-compiled NIF poisoning the target rootfs
scrub-otp-release.sh: ERROR: Unexpected executable format for
'.../nerves_logging-0.2.4/priv/kmsg_tailer'
Got: readelf:Advanced Micro Devices X86-64;0x0
Expecting: readelf:AArch64;0x0
nerves_logging's kmsg_tailer port binary had been compiled for x86-64 instead of AArch64.
Earlier in the build those elixir_make dependencies had been compiled in a host context —
the line Not crosscompiling. To test locally... was sitting in my log where I had not
looked — and the host binary got cached in the target build directory.
Nerves is right to refuse this. A host binary in a target rootfs would fail at runtime on the device, far away from any useful error message. Catching it at packaging time is the correct behaviour.
The fix is to force-recompile the polluted native dependencies for the target:
unset MIX_EXS; export MIX_TARGET=iotdin_imx8p
mix clean
mix deps.compile nerves_logging nerves_uevent --force
mix firmware </dev/null
mix clean only clears the application's _build. The expensive Buildroot artifact lives in
the system repository's .nerves/ and is untouched, so this is minutes, not an hour.
Three: Cannot include '.../fwup_include/fwup-common.conf'
My fwup.conf includes files from a fwup_include/ directory that was never copied into the
artifact's images/. The cause was that my post-build.sh was still the no-op stub I had
created on day one and forgotten.
It now builds ops.fw from fwup-ops.conf and copies the include directory:
cp -rf "$NERVES_DEFCONFIG_DIR/fwup_include" "$BINARIES_DIR"
Four: fwup: can't open '.../images/uboot-env.bin'
fwup.conf writes a uboot-env.bin resource that was never generated. My defconfig had:
BR2_PACKAGE_HOST_UBOOT_TOOLS_ENVIMAGE=y
without the parent:
BR2_PACKAGE_HOST_UBOOT_TOOLS=y
So olddefconfig silently dropped the sub-option and mkenvimage never ran. Silently is the
important word — nothing warns you.
You can confirm a dropped option by grepping the resolved config rather than your defconfig:
grep UBOOT_TOOLS <artifact>/.config
# BR2_PACKAGE_HOST_UBOOT_TOOLS is not set
The general lesson, and the most transferable thing in this post: when a Buildroot
sub-option appears to be ignored, check that its parent is enabled in the resolved .config,
not in the file you wrote. Buildroot will drop a child of a disabled parent without comment.
Result
With those four fixed, mix firmware ends with Firmware built successfully! 🎉 and produces
a 45 MB bundle. Worth verifying rather than trusting:
fwup -m -i _build/iotdin_imx8p_dev/nerves/images/compulab_cs_gw.fw
which confirms platform=iotdin_imx8p, arch=arm, and all tasks present — complete,
upgrade.a, upgrade.b, provision.
Both of the risks I had written down at the start were resolved at build time. binman
assembled the imx-boot container on the first complete U-Boot build with no intervention,
which was the risk I had been most worried about.
I had a flashable firmware. Getting it onto the board turned out to be a separate problem entirely, because the vendor's flashing tool does not work on my laptop.