Series A Nerves system for the IOT-DIN gateway Part 2 of 5 · all parts

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.