mlainez
The journey to running Nerves on a Kobo Clara e-reader
For a few years now, I’ve been hesitant to buy an e-reader. I dislike that most of them have closed ecosystems. I was waiting for a “hacker-friendly” device and had high hopes for the PineNote. Unfortunately, the price tag of the PineNote and the difficulty of getting my hands on one left me without a “cheap” alternative. The PineNote is also much more than an e-reader and significantly larger, while I was looking for an open, pocket-sized device.
Kobo is a Toronto-based company that has released several e-reading devices over the past decade, and they have had a small, active hacker community building apps and plugins for their products. Initially, their devices shipped with an internal SD card you could replace with another operating system after opening the device, but later models now run their software on eMMC. Since they offered a cheap 6-inch entry model, the Clara (BW and Color), I decided to give it a try and ordered the Clara Color.

Ever since I started porting Buildroot and Nerves to exotic devices (running Nerves on Android e-waste), each time I get a consumer device in my hands, the first question that pops into my head is:
Will it run Nerves?
This topic is about answering that question and documenting my journey to understand how the device works and how to access relevant information so I can maybe build a Nerves firmware for it. I have no clue if I will make it, but documenting the process and progress might be interesting to some people here.
I’ll be adding posts to this topic, at random intervals, with my progress in the hope of helping demystify what it takes to get Nerves and Elixir running on consumer products and to inspire others to try to do the same.
Most Liked
mlainez
Getting to know Clara
My first step is to figure out what information I can get from the device without opening it. The back cover shows that it’s a N367B model.
Since Kobo apparently has partnered with ifixit, I was able to find some guides showing the devices motherboards. There are two models the N367 and N367B.
We can clearly see a UART port on the top-left side of the N367 motherboard. A UART port is an asynchronous serial interface that uses separate TX (transmission) and RX (reception) lines for bidirectional communication.
On the right side of N367B motherboard, there seems to be something similar but without the same markings, so it would need some further investigation later on.
Why am I trying to figure out if there is a UART port somewhere? Because on most devices, this is where boot messages get printed. You can see what happens in the boot chain and, for linux based systems, the kernel messages, just like when your computer starts. This gives a lot of valuable information to create a bootable system later on. Some manufacturers use a UART port to flash the device before they leave the factory.
So, as finding a suitable UART port would require further investigation on my model and I didn’t want to open it up right away, I started trying to figure out if there was a way to stop the boot process and put the device in either fastboot mode or something else. Usually, you need to find a sequence of actions to perform when the device boots to get there.
Fastboot mode
I tried pressing the power button multiple seconds before pluging the usb cable, different combinations of pluging, unplugging, pressing the button etc… But it was not it.
Some devices sometimes require the host to “poll” the device with a fastboot command for them to interrupt their booting process. So,here’s how I managed to get it into fastboot.
- Turn off and unplug the Clara from your computer
- Launch the
fastboot getvar allcommand on your computer - Plug the Clara to your computer
- Quickly hold the power button until you see information in your host console
- Release the power button
- The Clara is now in fastboot mode
This is what you should get in your terminal:
➜ ~ fastboot getvar all
< waiting for any device >
(bootloader) hwcfg.PCB: [0] PCB=0x71
(bootloader) max-download-size: 0x1e00000
(bootloader) version: 0.5
all: Done!!
Finished. Total time: 0.003s
It doesn’t tell us much for now, but having the Clara in fastboot is useful if we want to flash existing partitions, or if we want to try a new boot image without flashing the emmc and potentially bricking it.
To get out of fastboot mode, just press the power button approximately 10 seconds.
Investigating the filesystem
When plugging the Clara to your computer, you can mount it as a mass storage device and put books on it. So let’s see what’s on that partition:
➜ KOBOeReader ls
fonts
➜ KOBOeReader ls -la
total 8364
drwxr-xr-x 7 marc marc 8192 jan 1 1970 .
drwxr-x---+ 3 root root 4096 nov 15 12:13 ..
drwxr-xr-x 2 marc marc 8192 nov 14 10:19 .adobe-digital-editions
drwxr-xr-x 3 marc marc 8192 aoû 28 11:10 fonts
drwxr-xr-x 11 marc marc 8192 nov 15 12:13 .kobo
drwxr-xr-x 49 marc marc 8192 nov 14 11:36 .kobo-images
➜ KOBOeReader
This kobo dot folder seems interesting, let’s check what’s in it:
➜ KOBOeReader ls -la .kobo
total 576
drwxr-xr-x 11 marc marc 8192 nov 15 12:13 .
drwxr-xr-x 7 marc marc 8192 jan 1 1970 ..
-rw-r--r-- 1 marc marc 25 nov 14 10:19 affiliate.conf
drwxr-xr-x 2 marc marc 8192 nov 14 11:34 assets
drwxr-xr-x 2 marc marc 8192 nov 14 11:35 audiobook
-rw-r--r-- 1 marc marc 19456 nov 15 12:06 BookReader.sqlite
drwxr-xr-x 2 marc marc 8192 nov 14 10:19 certificates
drwxr-xr-x 2 marc marc 8192 nov 14 11:34 custom-dict
-rw-r--r-- 1 marc marc 75 nov 14 10:19 device.salt.conf
drwxr-xr-x 2 marc marc 8192 nov 14 11:35 dict
drwxr-xr-x 2 marc marc 8192 nov 14 11:36 dropbox
-rw-r--r-- 1 marc marc 3072 nov 14 11:34 fonts.sqlite
drwxr-xr-x 2 marc marc 8192 nov 14 11:34 guide
drwxr-xr-x 2 marc marc 8192 nov 14 11:34 kepub
drwxr-xr-x 2 marc marc 8192 nov 14 10:19 Kobo
-rw-r--r-- 1 marc marc 427008 nov 15 12:13 KoboReader.sqlite
-rw-r--r-- 1 marc marc 105 nov 14 10:19 ssh-disabled
-rw-r--r-- 1 marc marc 82 nov 15 12:12 version
Hang on… What is this ssh-disabled file…
To enable ssh:
- Rename this file to ssh-enabled
- Reboot the device
- Connect via: ssh root@<device_ip>
Ok, this is exciting
Having ssh access as root would allow us to gather everything we need to run buildroot and Nerves on this thing.
SSH access
After renaming that file and ejecting the device, getting it’s IP from my dhcp server lease list, I was, as promised, able to connect to the Clara as root… ![]()
[root@kobo ~]# uname -a
Linux kobo 4.9.77 #1 SMP PREEMPT d226bc7bf-20250103T160218-B0103160930 armv7l GNU/Linux
[root@kobo ~]# cat /proc/cpuinfo
processor : 0
Processor : ARMv7 Processor rev 4 (v7l)
model name : ARMv7 Processor rev 4 (v7l)
BogoMIPS : 15.60
Features : half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm aes pmull sha1 sha2 crc32
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd03
CPU revision : 4
Hardware : MediaTek MT8110 board
Revision : 0000
Serial : 1234567890ABCDEF
[root@kobo ~]# ls -la /dev/disk/by-partlabel/
total 0
drwxr-xr-x 2 root root 280 Nov 15 12:21 .
drwxr-xr-x 7 root root 140 Nov 15 12:21 ..
lrwxrwxrwx 1 root root 15 Nov 15 12:21 UBOOT -> ../../mmcblk0p2
lrwxrwxrwx 1 root root 15 Nov 15 12:21 bl2 -> ../../mmcblk0p1
lrwxrwxrwx 1 root root 15 Nov 15 12:21 boot_a -> ../../mmcblk0p4
lrwxrwxrwx 1 root root 15 Nov 15 12:21 hwcfg -> ../../mmcblk0p6
lrwxrwxrwx 1 root root 15 Nov 15 12:21 ntxfw -> ../../mmcblk0p7
lrwxrwxrwx 1 root root 15 Nov 15 12:21 nvram -> ../../mmcblk0p3
lrwxrwxrwx 1 root root 16 Nov 15 12:21 recovery -> ../../mmcblk0p11
lrwxrwxrwx 1 root root 16 Nov 15 12:21 system_a -> ../../mmcblk0p10
lrwxrwxrwx 1 root root 15 Nov 15 12:21 tee_a -> ../../mmcblk0p5
lrwxrwxrwx 1 root root 16 Nov 15 12:21 userdata -> ../../mmcblk0p12
lrwxrwxrwx 1 root root 15 Nov 15 12:21 vendor -> ../../mmcblk0p9
lrwxrwxrwx 1 root root 15 Nov 15 12:21 waveform -> ../../mmcblk0p8
[root@kobo ~]#
This tells us already quite a lot of things:
- It’s running an old kernel, probably custom
- It’s an Armv7 32 bits
- It seems to rely on AB partitioning and has partitions dedicated to configuration, waveform information for the e-ink screen, etc…
I now need to understand a bit better how all of this is wired up, and also which drivers are used.
[root@kobo ~]# lsmod
wlan_drv_gen4m 1908365 0 - Live 0xbf14a000 (O)
wmt_cdev_bt 16871 0 - Live 0xbf141000 (O)
wmt_chrdev_wifi 12825 1 wlan_drv_gen4m, Live 0xbf138000 (O)
wmt_drv 1059215 4 wlan_drv_gen4m,wmt_cdev_bt,wmt_chrdev_wifi, Live 0xbf000000 (O)
lsmod doesn’t provide much info so I’m assuming there is a lot of stuff compiled within the kernel and some custom logic happening in the init proces. Time to dig into the filesystem and the different partitions! ![]()
mlainez
From a working buildroot image to a booting Nerves System
From my past experience porting Nerves on devices, when you reach the point where you have a working buildroot system, you have made 70% of the work already, and what’s left is to make sure you get a few things ready:
- Create your Nerves system project boilerplate, following the usual anatomy of a Nerves system
- Create a
nerves_defconfigwith the proper Nerves specific options and linking relevant parts to the Nerves System BR external tree config files and scripts - Move all packages you needed for your buildroot system to your Nerves System project
- Write the
fwupconfiguration for your device - Make sure your
fw_env.configfits yourfwupconfiguration
Although most of this can be “standardized”, there is always something specific about the devices we try to run Nerves on, and it can make it more or less complicated to get to a fully functioning system. But usually, just to see a system boot (not supporting all the board’s features for instance), you don’t need much.
In this case though, we can call my setup a bit “unorthodox”. I’m trying to run Nerves from a loop device created from a binary file in one of the ereader’s partitions… Moreover, I can’t change the kernel, so I’m stuck with a 4.9 Android kernel, and a provided toochain using GCC 4.9. So I expect some compilation issues when using a recent buildroot…
Getting all required packages to compile
Standards matter
The first thing that I stumbled upon when compiling the system were errors like these:
error: 'for' loop initial declarations are only allowed in C99 or C11 mode
for (size_t counter = 0; counter < length; counter++)
^
note: use option -std=c99, -std=gnu99, -std=c11 or -std=gnu11 to compile your code
This is due to the fact that our compiler supports the C89 open standard by default for C code, and in that standard, initial declaration in for loops need to be outside the for statement, like this:
size_t counter = 0;
for (counter; counter < length; counter++)
So you need to tell the compiler to use another standard. And the way to do that is to override the buildroot packages .mk files in our own nerves_system.
Although the solution was the same for several packages, it had to be done differently for some, since all packages makefiles are not exactly the same, may use different variables, etc…
An example of this can be found already in the buildroot system where I had to add eudev to add Wifi support.
Missing headers
Due to the fact the toolchain is using an old GCC 4.9 compiler, there are some features missing, and one of them was the lack of the getrandom function in sys/random.h.
This function is called by erlinit in the seedrng.c and it falls back to using /dev/randomin case of an issue (as far as I understood what it does). It means that I can just write a “stub” that will return -1 (fail) with a specific error type, and it should work.
static inline ssize_t getrandom(void *buf, size_t count, unsigned int flags)
{
(void)buf; (void)count; (void)flags;
errno = ENOSYS;
return -1;
}
Missing constants in sysroot headers
When compiling Vintage_net, it seemed that some constants were missing, I don’t have much clue what these constants do, but they were apparently added in later kernels. I was glad to have Claude in my life for this one tbh, and he gave me a nice patch to add to my system, in the form of an af_compat.mk file.
define AF_COMPAT_APPEND_HEADERS
for hdr in \
$(STAGING_DIR)/usr/include/linux/socket.h \
$(HOST_DIR)/arm-buildroot-linux-gnueabihf/sysroot/usr/include/linux/socket.h; do \
if [ -f $$hdr ] && ! grep -q "AF_KCM" $$hdr; then \
printf '%s\n' \
'#ifndef AF_KCM' \
'#define PF_KCM 41' \
'#define AF_KCM PF_KCM' \
'#endif' \
'#ifndef AF_IB' \
'#define PF_IB 27' \
'#define AF_IB PF_IB' \
'#define PF_MPLS 28' \
'#define AF_MPLS PF_MPLS' \
'#endif' >> $$hdr; \
"#endif" >> $$hdr; \
fi; \
done
endef
Patching Cairo
Another one I wouldn’t have been capable of figuring out is with Cairo and the fact that compiling with GCC 4.9 with the compile flag -O3 generates invalid instructions. But it seems that changing thi globally was not working so Claude figured out that the issue came from a very specific file and proposed a patch for it, that I still don’t fully understand… I am also not sure why Cairo is in every nerves_defconfig out there.
--- a/src/cairo-ft-font.c
+++ b/src/cairo-ft-font.c
@@ -38,6 +38,10 @@
* Carl Worth <cworth@cworth.org>
*/
+#if defined(__GNUC__) && __GNUC__ == 4
+#pragma GCC optimize ("O0")
+#endif
+
#define _DEFAULT_SOURCE /* for strdup() */
#include "cairoint.h"
Getting erlang to compile
There were a few options that needed to be added to the C flags, such as --disable-year2038 which is a problem with systems that represent time as 32-bit signed integers.
Indeed, the maximum date you can represent using this is January 19, 2038. The solution is to move to 64-bit which we can’t do here… So we need to disable it to avoid compilation errors.
I also had to explicitely tell erlang where to find supporting libraries for the compilation with erl_xcomp_sysroot.
Creating the fwup configuration
Since we are not running in a traditional setup, we don’t have a boot partition. It means out image looks like this:
# +----------------------------+
# | MBR |
# +----------------------------+
# | Firmware configuration data|
# | (formatted as uboot env) |
# +----------------------------+
# | p0*: Rootfs A (squashfs) |
# +----------------------------+
# | p0*: Rootfs B (squashfs) |
# +----------------------------+
# | p1: Application (f2fs) |
# +----------------------------+
This simplifies our configuration, so after some cleanup and setting up the environment variables to point to the right loop devices and making changes to the config files in /etc, I was ready to go to the next step.
# In fwup_include/fwup-common.conf
define(NERVES_FW_DEVPATH, "/dev/loop0")
define(NERVES_FW_APPLICATION_PART0_DEVPATH, "/dev/loop0p2")
# In erlinit.config
-m /dev/loop0p2:/root:f2fs:nodev:
Changing the init script
My image is now a bit more complex than before, and I need the original Kobo system to be able to read a squashfs partition. Since there are some modifications I will need at boot, I will also need to use overlayfs to make sure I can boot.
insmod /mnt/onboard/.buildroot/modules/4.9.77/kernel/fs/squashfs/squashfs.ko
insmod /mnt/onboard/.buildroot/modules/4.9.77/kernel/fs/f2fs/f2fs.ko
losetup -Pf "$img" || return 1
mount -o ro /dev/loop0p1 "$NEWROOT" || return 1
# --- tmpfs copy for writable root ---
mkdir -p "$MERGED"
mount -t tmpfs tmpfs "$MERGED" || return 1
cp -a "$NEWROOT/." "$MERGED/" || return 1
# --- pivot root ---
mkdir -p "$MERGED/.oldroot" "$MERGED/proc" "$MERGED/sys" "$MERGED/dev"
cd "$MERGED" 2>/dev/null || return 1
pivot_root . .oldroot >/dev/null 2>&1 || return 1
cd / 2>/dev/null || :
...
exec /sbin/init
The full scipt can be found here.
The first boot
It was now time to try it out… And after some tweaking in the init script, I finally saw this after manually doing a modprobe g_ether in my serial console and trying ssh nerves.local.
You’ll notice that a few things are not quite right. It seems some things are not read properly, and the application part is not mounted.
Whenever you see these “UNKNOWN”, and Valid (unknown) on the firmware line, this usually means that you didn’t configure your fw_env.config properly, or that the uboot_env part of your image is somehow corrupted, so you should double check that the partition offsets in your fwup-common.conf and your fw_env.config actually match. In my case, it was a misconfiguration.
Fixing the application partition mount issue was also rather easy, I didn’t use the right loop device in my erlinit.config
# In fw_env.config
/dev/loop0 0x2000 0x2000 0x200 16
I made sure the sizes were correct for the uboot_env variables, where Nerves stores information about the platform, architecture, serial, active partition, etc…
I also made a couple of changes to my erlinit.config to load the g_ether module before the Erlang VM starts and fix it with the correct loop device.
--pre-run-exec modprobe g_ether
-m /dev/loop0p2:/root:f2fs:nodev:
And after reuploading the image on that userdata partition, there I was, with a booting Nerves system ![]()
Next steps
I still have work to do in order to really use this device. The first step is to add Wifi support like I did in my buildroot system, as well as add all I need to be able to manipulate the eInk screen.
There is also a big limitation to my current setup. Using loop devices from files, you can’t rewrite the filesystem when the file is mounted, which means that the usual mix firmware upload doesn’t work and each time I want to update my firmware, I have to boot the ereader on the Kobo OS to mount it as a mass storage device and copy paste the full .img file generated with mix firmware.image. It’s cumbersome but it works.
Something I can try to fix this is resize the userdata partition on the device to free up space at the end of the eMMC. Then I can create an extra partition and flash a full disk image on it like I did on the fairphone 2 with kpartx. But that’s for later.
My current todo list is:
- Add Wifi initialization
- Allow the use of the eInk screen in pure Elixir
That’s a fun way to start 2026… ![]()
mlainez
Getting to a working buildroot system
Nerves can be seen as a “layer” on top of a Buildroot system. Buildroot is a build system that let’s you create a Linux root filesystem from scratch. It can also build the kernel and bootloader for you and packages it all in a nice image you can flash on an SD Card.
In order to have a working Linux device, you need at least three things:
- A bootloader: a piece of code that initializes minimal hardware support to be able to launch a kernel. There can be different levels of bootloaders but let’s stick to this definition for this post
- A kernel: the “code” that is being run first and manages processes, devices initialisation, memory management, …
- A root filesystem: directories and files containing the programs you want to use, the libraries they depend on, some OS initialisation scripts, user management, your custom programs, …
The root filesystem is also refered to as the “userspace”.
On our Kobo, we already have a bootloader and a kernel that we cannot change (because of Secure Boot), so the only thing we need is a root filesystem that the kernel will mount to run its init script.
We also need a way to avoid booting on the Kobo OS and boot our own system instead. And we want to do that as soon as we can.
What happens at boot
Once the kernel kicks in, it mounts the partition that has the rootfs, usually passed as root= in the kernel command line, and will try to run /init or /sbin/init from it. It will run this script as PID1, meaning as the first process.
On our device, the cmdline has root=/dev/mmcblk0p10which means the kernel will mount /dev/mmcblk0p10 as / and run /sbin/init.
We need to replace this /sbin/init by something else, so that it will allow us to “switch” to our alternative root filesystem and execute that system’s /sbin/init instead.
This is actually what happens in a Nerves system. Instead or running the buildroot system’s /sbin/init, Nerves subsitutes it with erlinit, which gets ran as PID1.
Replacing Kobo’s /sbin/init
We need our replacement init script to do several basic things:
- Mount all directories we need to be able to pivot to another system
- Wait for an event to decide if we boot the alternate system
- Mount our alternative system from and image somewhere on the userdata partition
- Pivot to this system and run it’s
/sbin/initas PID1
I’ve written this init script to do exactly that.
Here’s an extract of it, showing the most relevant parts. If a touch is detected on the screen during boot, we first try to mount our image file containing the alternative rootfs, then we use pivot_root to use that system’s root as / and have the old root mounted in .oldroot. We then move all the relevant mounts to the new system, unmount the old root and then execute the alternative rootfs /sbin/init as PID1.
mount -t ext4 -o loop,rw "$img" "$NEWROOT" >/dev/null 2>&1 || :
mountpoint -q "$NEWROOT" 2>/dev/null || \
mount -t ext4 -o loop,ro "$img" "$NEWROOT" >/dev/null 2>&1 || return 1
[ -x "$NEWROOT/sbin/init" ] || return 1
mkdir -p "$NEWROOT/.oldroot" "$NEWROOT/proc" "$NEWROOT/sys" "$NEWROOT/dev" >/dev/null 2>&1 || return 1
cd "$NEWROOT" 2>/dev/null || return 1
pivot_root . .oldroot >/dev/null 2>&1 || return 1
cd / 2>/dev/null || :
mount --move /.oldroot/dev /dev 2>/dev/null || :
mount --move /.oldroot/proc /proc 2>/dev/null || :
mount --move /.oldroot/sys /sys 2>/dev/null || :
umount -l /.oldroot 2>/dev/null || :
exec /sbin/init
Building an alternative rootfs
Now, we just need an image to boot from… You can find the buildroot external tree I used here.
The first step is to get to a system that compiles… And this is not always a walk in the park. Some of the issues along the way were due to the provided toolchain, kernel source, and Mediatek specific quirks.
1. Getting the kernel to compile (in order to have new modules in the rootfs)
The kernel is released as a .tar.zst-part-aa and .tar.zst-part-ab which is not really what buildroot expects. Moreover, inside this archive, there is also the Wifi driver source. So in order for buildroot to be able to use this, some gymnastics is needed in a Makefile extension.
2. Getting the Wifi drivers to compile
Next to that, still in the same makefile extension, I’ve added a LINUX_POST_BUILD_HOOKS += LINUX_BUILD_CONNECTIVITY_CMDS as a kernel post build hook to compile the Wifi drivers. but due to some require path issues, I had to create a patch to the wifi driver makefile…
3. Building an image
Since I’m not going to have much in my image, only the rootfs, I don’t need a partition table, so Buildroot will just generate a blob with an ext4 rootfs in it that I can directly mount as a loop device.
image buildroot-kobo.img {
hdimage {
partition-table-type="none"
}
partition rootfs {
image = "rootfs.ext4"
}
}
Running the image for the first time
I created an init script to start usb networking when my system is initialized, it helps debugging in an easy way without the quirky jumper wires when using UART.
The buildroot image needs to be placed in .buildroot/buildroot-kobo.img on the userdata partition, so I just need to start the Kobo in it’s original OS, make it act as a mass storage device, and simply copy paste my image in the right folder.
I have also created to init scripts to support the E-ink screen that I won’t detail here but after booting (and obviously many trials and errors that wouldn’t fit this article’s narrative), here’s what we have ![]()
Probably not the most impressive image you’ve ever seen but this is quite a win ![]()
We can connect to it with usb and see that indeed, we are now running buildroot.
# cat /etc/os-release
NAME=Buildroot
VERSION=2025.05.3-1-g85c588cdf9
ID=buildroot
VERSION_ID=2025.05.3
PRETTY_NAME="Buildroot 2025.05.3"
#
Now do Nerves!
The first step to porting Nerves on anything is to first have a working Buildroot system. Now that it’s done, I can move to the next step and try running Nerves on it. I hope I can find workarounds to any future roadblocks ahead of this. But so far so good. I’m running the latest buildroot, on a 4.9 Android kernel full of Mediatek patches so I’m sure I will have to figure some more stuff out.
Will the next picture show a working Nerves system?
Popular in Discussions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #hex












