Authoring a Nerves system by hand
Why I wrote a custom Nerves base image file by file for a CompuLab IOT-DIN-IMX8PLUS instead of forking a sibling board's system.
I wanted to run Elixir on a CompuLab IOT-DIN-IMX8PLUS — an industrial gateway that lives on a DIN rail in a cabinet, takes 24 V, and is expected to sit there for years without anyone looking at it. There is no published Nerves system for this board. There is one for a sibling board from the same vendor, which is close but not the same hardware.
So the first real decision was what to do about that, and I want to explain it before any of the technical detail, because everything afterwards follows from it.
Fork it, or write it
The fast option is to fork the sibling system and change what looks wrong. The slow option is
to author every file myself — nerves_defconfig, the kernel config, the U-Boot config,
fwup.conf, the rootfs overlay — using the sibling system as read-only reference and the
actual hardware documentation as the source of truth.
I chose the slow option. Not out of principle: a fork hides which values are deliberate and which are inherited. When a board does not boot, that distinction is the whole debugging problem. If I have written each line, then every value is one I can defend or change. If I have forked, then half the file is somebody else's answer to a slightly different question, and I cannot tell which half.
That decision cost me a week and it was correct. Parts two through four of this series are almost entirely me discovering things about my own files. Every one of those discoveries would have been harder to reason about had I inherited the file.
The board
Built around NXP's i.MX8M Plus: four Cortex-A53 cores, a Cortex-M7 for real-time work, and an NPU nobody is using yet. 4 GB of LPDDR4 on the system-on-module. That last number matters much more than it looks like it does — see part four.
Console is /dev/ttymxc1 at 115200 8N1, on the micro-USB labelled DBG. Storage is a 32 GB
eMMC at /dev/mmcblk2, with the two 4 MB hardware boot partitions that turn out to matter a
great deal.
What a Nerves system actually consists of
This is the part that is hard to picture until you have written one. A Nerves system is not a program — it is a Buildroot configuration plus the handful of files that tell Buildroot what hardware it is targeting:
nerves_system_iotdin_imx8p/
├── nerves_defconfig # the Buildroot config: what goes in the image
├── mix.exs # platform (nerves_system_br), arch (aarch64/cortex_a53)
├── linux/
│ ├── linux-6.6.defconfig # the kernel config — 1463 lines
│ └── 0001-squashfs-*.patch # required for a read-only rootfs
├── uboot/
│ ├── uboot.defconfig # the bootloader config
│ └── uboot.env # the default U-Boot environment
├── fwup.conf # how a firmware image is laid out and written
├── fwup_include/
│ ├── fwup-common.conf # partition layout, A/B slots
│ ├── provisioning.conf
│ └── fwup-ops.conf # factory reset, revert, validate, status
└── rootfs_overlay/etc/
├── erlinit.config # which console the Erlang VM talks on
├── fw_env.config # where U-Boot's environment lives
└── boardid.config # serial-number source for the hostname
Every one of those files is a decision about this board. That is why forking hides so much: a fork gives you twenty files full of someone else's answers, and no way to tell which were deliberate.
The kernel config, and one that would have bitten me
The kernel defconfig is 1463 lines, and I did not write it from nothing. I based it on
CompuLab's own in-tree compulab_v8_defconfig at the pinned 6.6.23 commit, plus a
documented 19-line Nerves addendum.
The interesting part is what I didn't use. There is also a kernel config fragment in the vendor's Yocto meta-layer, at 6.6.3, and it looks like the obvious starting point. I diffed both before choosing, and the fragment was missing several drivers this board needs:
CONFIG_USB_NET_QMI_WWAN # the LTE modem's QMI interface
CONFIG_TI_ADS1015 # the ADC expansion module
CONFIG_RTC_DRV_ABX80X # the on-board AB1805 real-time clock
Starting from the fragment would have produced a system that booted, looked fine, and quietly
had no modem and no ADC. The in-tree v8 config already enables all of those, along with
USB_SERIAL_FTDI_SIO, USB_SERIAL_OPTION, GPIO_PCA953X with IRQ support, CAN_FLEXCAN,
MWIFIEX_SDIO, and TCG_TIS_I2C_INFINEON for the TPM.
My 19-line addendum adds only what Nerves itself needs and the base was missing:
CONFIG_F2FS_FS=y # the expanding application-data partition
CONFIG_USB_WDM=y # /dev/cdc-wdm0, the QMI control channel
CONFIG_SQUASHFS_ZSTD=y # the read-only rootfs
The lesson: diff the candidate configs before you pick one. Two plausible starting points differed by exactly the drivers that would have been hardest to debug as absent, because a missing driver does not error — the device simply never appears.
The U-Boot config, and a placeholder that was quietly wrong
uboot/uboot.defconfig is based on CompuLab's authoritative configs/iotdin-imx8p_defconfig
at the pinned commit. Taking it verbatim and adding on top caught a real defect: the file I
had started from carried a sibling board's target:
- CONFIG_TARGET_IOT_GATE_IMX8PLUS=y
+ CONFIG_TARGET_IOTDIN_IMX8P=y
Along with the right device tree:
CONFIG_DEFAULT_DEVICE_TREE="imx8mp-iotdin"
CONFIG_OF_LIST="imx8mp-iotdin"
A wrong target symbol does not fail to build. It builds a bootloader for a board you do not have.
The divergence I chose deliberately
The factory keeps the U-Boot environment in the eMMC hardware boot partition
mmcblk2boot0, at offset 0x3f0000. I moved it to the user area, at the same byte
offset:
# rootfs_overlay/etc/fw_env.config
/dev/mmcblk2 0x3F0000 0x20000 0x200 256
Not because the factory is wrong, but because Nerves' whole OTA model assumes fwup manages
the user area. Keeping the env there means fwup and fw_setenv agree about where it lives,
and A/B updates work the standard way. The cost is a documented divergence from factory that
I have to remember — which is exactly why it is written into the file itself and not just into
my head.
The thing I had wrong, and had to correct
This one is worth stating plainly because I built a whole plan on the wrong assumption.
fwup does not write imx-boot, and never touches the eMMC boot partitions. It writes
only the user area, /dev/mmcblk2.
The bootloader is flashed to mmcblk2boot0 separately, once, on the initial bring-up —
and OTA never touches it again. I had assumed a firmware update carried the bootloader with
it, and had started sketching how fwup would set the eMMC ext_csd boot-enable bit as part
of an upgrade.
None of that work was needed. Boot-partition activation is a one-time bring-up concern, not a per-update one. Realising that deleted a whole class of imagined problem — and it also means an OTA update can never brick the bootloader, which is a reassuring property to get for free.
The partition layout
/dev/mmcblk2 (user area, 29.2 GB)
├─ MBR
├─ U-Boot env raw @ block 8064 (0x3F0000), 128 KB
├─ rootfs A 512 MB ┐ squashfs, read-only
├─ rootfs B 512 MB ┘ the inactive one receives the update
└─ app data f2fs, expands to fill the rest
/dev/mmcblk2boot0 (4 MB) imx-boot — written once by uuu, never by OTA
/dev/mmcblk2boot1 (4 MB) redundant copy
Standard Nerves A/B: an update writes the inactive slot and reboots into it, and a bad image reverts automatically. Application data survives because it is a separate partition.
Where this leaves things
Every file authored, every version pinned, nothing built yet. The next post is the first build — which did not work, for reasons that were entirely my own and took a while to see.