Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7258b32ead
|
||
|
|
12062d6bca
|
||
|
|
704624878e
|
Vendored
-2
@@ -7,5 +7,3 @@ Coreboot
|
||||
MIPI
|
||||
GPIO
|
||||
defconfig
|
||||
initrd
|
||||
pmOS
|
||||
|
||||
@@ -3,9 +3,6 @@ WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS build
|
||||
# ffmpeg is needed at build time by the strip-media-metadata module to remove
|
||||
# container/stream metadata from videos in public/img.
|
||||
RUN apk add --no-cache ffmpeg
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
+35
-85
@@ -1,5 +1,5 @@
|
||||
---
|
||||
date: '2026-09-13'
|
||||
date: '2026-08-30'
|
||||
title: 'Running Ubuntu on the Lenovo IdeaPad Duet'
|
||||
tags:
|
||||
- ubuntu
|
||||
@@ -21,14 +21,14 @@ Time for some firmware hacking to try and get Ubuntu to run there!
|
||||
(Image credits: [Lenovo](https://www.lenovo.com/fr/fr/p/laptops/lenovo/chromebooks/lenovo-ct-x636/zziczctct1x))
|
||||
::
|
||||
|
||||
I bought the device a while ago, during my first year at engineering school.
|
||||
I bought the device a while ago, during my first year at ENS Rennes.
|
||||
It was cheap (~100€), used but in really nice condition. I love 10" laptops for their portability, and this one could even work with a stylus for note-taking, a workflow I wanted to try (turns out it wasn't really for me).
|
||||
|
||||
I had also never used ChromeOS, and was really curious.
|
||||
At the time I was developing Android apps, and being able to code natively and run the app on-device sounded exciting.
|
||||
It turned out to be disappointing because the performance in ChromeOS was not really there, and I had to run everything Linux through a container, which felt awkward. I quietly stopped using the device.
|
||||
|
||||
A while later, I realized that [postmarketOS](https://postmarketos.org/) supported it. That meant a real Linux distribution, based on [Alpine Linux](https://alpinelinux.org/), with any DE (GNOME, KDE, etc.) and proper Linux tooling and performance. I had to try.
|
||||
A while later, I realized that [postmarketOS](https://postmarketos.org/) supported it. That meant a real Linux distribution, based on [Alpine Linux](https://alpinelinux.org/), with any DE (GNOME, KDE, etc) and proper Linux tooling and performance. I had to try.
|
||||
|
||||
Installing postmarketOS was really easy, thanks to the effort they put into the [wiki](https://wiki.postmarketos.org/wiki/Lenovo_IdeaPad_Duet_Chromebook_(google-krane)) and the [installation guide](https://wiki.postmarketos.org/wiki/Category:ChromeOS). Basically: put the device in 'developer mode', download an image, write it to a USB stick and boot from it using the 'developer mode' menu. You then land in a live postmarketOS image you can use to install to the eMMC.
|
||||
|
||||
@@ -102,7 +102,7 @@ Clearly, I needed to take a step back and actually understand how the machine bo
|
||||
Turns out the boot flow is a bit complicated on Chromebooks. Here are the schematics that Claude generated when explaining the chain to me. I find them quite clear.
|
||||
|
||||
::BlogImage{image="/img/duet-ubuntu/duet_firmware_chain.svg"}
|
||||
Firmware boot chain for Chromebooks. The boot ROM contains the FSBL, which loads Coreboot, which has [upstream support for the Duet](https://github.com/coreboot/coreboot/blob/main/src/mainboard/google/kukui/panel_krane.c). Then, Coreboot loads the ARM Trusted Firmware (not relevant to this analysis), which then loads [Depthcharge](https://chromium.googlesource.com/chromiumos/platform/depthcharge/), the bootloader for ChromeOS devices.
|
||||
Firmware boot chain for Chromebooks. The boot ROM contains the FSBL, which loads Coreboot, which has [upstream support for the Duet](https://github.com/coreboot/coreboot/blob/main/src/mainboard/google/kukui/panel_krane.c). Then, Coreboot loads the Arm Trusted Firmware (not relevant to this analysis), which then loads [Depthcharge](https://chromium.googlesource.com/chromiumos/platform/depthcharge/), the bootloader for ChromeOS devices.
|
||||
::
|
||||
|
||||
So Depthcharge, the ChromeOS bootloader, is also what shows the 'Developer Mode' menu that lets me pick between USB and internal storage at boot. This is really interesting. Now, what is the next step? What does Depthcharge actually load?
|
||||
@@ -115,7 +115,7 @@ That clears up the mystery! The `/boot` partition was never *actually* used at b
|
||||
|
||||
### EFI environment needed
|
||||
|
||||
Although this would be very easy, it would unfortunately not work. See, in this boot flow, there was never EFI-capable firmware involved. Neither EDK2 nor U-Boot is used: Depthcharge loads and boots the kernel directly from the FIT image. That works fine for the postmarketOS kernel, but not for the Ubuntu kernel, which is an EFI executable and expects an EFI environment. **[Ubuntu only boots via UEFI](https://ubuntu.com/hardware/docs/image-cookbook/explanation/bootflow/)**.
|
||||
Although this would be very easy, it would unfortunately not work. See, in this bootflow, there was never EFI-capable firmware involved. Neither EDK2 nor U-Boot is used: Depthcharge loads and boots the kernel directly from the FIT image. That works fine for the postmarketOS kernel, but not for the Ubuntu kernel, which is an EFI executable and expects an EFI environment. **[Ubuntu only boots via UEFI](https://ubuntu.com/hardware/docs/image-cookbook/explanation/bootflow/)**.
|
||||
|
||||
So, we need a UEFI-capable firmware. The easiest to get working would be U-Boot: EDK2 can be harder to work with, and U-Boot already has support for the `mt8183` SoC. That support is only present as a config for the 'Pumpkin' reference development board of the SoC, so we will need to do some work into U-Boot to be able to boot it. Our goal is to chainload U-Boot via Depthcharge, in order to get a UEFI environment, that will be able to boot Grub which will then boot our Ubuntu kernel:
|
||||
|
||||
@@ -179,7 +179,7 @@ Afterwards, debugging got easier: I started seeing backlight patterns (blinks) t
|
||||
|
||||
Finally, the stub was complete, and showed the color sequence imagined by Claude: red (early init), yellow (DTB parsed), green (LBIO parsed), blue (all good). This was actually completely faked by the agent, as early init, DTB and LBIO are all required to find the framebuffer, so it just parsed all and showed all colors in a sequence. In any case, drawing to the framebuffer now works!
|
||||
|
||||
::BlogVideo{video="/img/duet-ubuntu/duet-stub-na.mp4"}
|
||||
::BlogVideo{video="/img/duet-ubuntu/duet-stub.mp4"}
|
||||
*The final stub booted on the Duet*
|
||||
::
|
||||
|
||||
@@ -205,7 +205,7 @@ This was unexpectedly a **lot** harder than the framebuffer stub. It took 27 tri
|
||||
|
||||
The first flash went in the worst possible way, i.e. the screen was pitch black again. This is really bad, as I cannot know anything about what is wrong from the observation.
|
||||
|
||||
I suggested that the agent use the same kind of debugging we did before: show colored bands on the screen for every step. One in the stub (which was kept at first for the revival step), one as first step in U-Boot, etc. This helped a lot to pinpoint the issues to the specific lines of code involved.
|
||||
I suggested the agent to use the same kind of debugging we did before: show colored bands on the screen for every step. One in the stub (which was kept at first for the revival step), one as first step in U-Boot, etc. This helped a lot to pinpoint the issues to the specific lines of code involved.
|
||||
|
||||
It turned out that the issue was very early in U-Boot (the agent added something like 13 checkpoints, and I only saw the second one). Actually, the steps were a bit more embarrassing, with errors made by the agent thinking something was already in U-Boot when it was in the stub, and other issues, but we ended up finding the big one: an alignment problem.
|
||||
|
||||
@@ -275,9 +275,9 @@ Well, it seems that the kernel did not like it. After the end of the earlycon wh
|
||||
the kernel tries to set up the display for itself, finding it in this weird hacked state. At least that's my theory.
|
||||
In any case, the display shows weird things: blinking bands, logs disappearing, lines appearing here and there. Something is wrong.
|
||||
|
||||
Given the display hack and the fact that there was no way I was going to see kernel logs on there, it felt only natural to try and implement a *real* panel driver for U-Boot and drop the hack once and for all. So that is what I (or rather, the agent) did.
|
||||
Given the display hack and the fact that there was no way I was gonna see kernel logs on there, it felt only natural to try and implement a *real* panel driver for U-Boot and drop the hack once and for all. So that is what I (or rather, the agent) did.
|
||||
|
||||
### Panel driver
|
||||
## U-Boot panel driver
|
||||
|
||||
So.... we have to write a real panel driver. How do we do that? This was also the end of the weekend, and I was starting to feel tired. But I was set on starting the real panel driver work when convenient for me.
|
||||
|
||||
@@ -302,21 +302,18 @@ This required a small amount of work to bring up serial support in U-Boot: just
|
||||
After that, serial helped spot the issue with the panel driver: the agent had forgotten to set a property that gets read during initialization, leading to a NULL pointer dereference. An easy fix. Once that was done, the panel driver worked.
|
||||
I would have expected this to be much harder, but with serial the issues were easy to spot, and the agent mostly adapted work from the existing Coreboot panel driver (and the kernel one), which made the task easy for it (him?).
|
||||
|
||||
Technical details on the panel driver and the whole U-Boot saga are available in the "Cleaning up the U-Boot tree" section below. Before that, let's boot the kernel.
|
||||
Technical details on the panel driver and the whole U-Boot saga are available in the "Cleaning up, technical details" section below. Before that, let's boot the kernel.
|
||||
|
||||
## First kernel boot
|
||||
## Kernel
|
||||
|
||||
### A missing module
|
||||
|
||||
Finally, we can try booting our Ubuntu 7.0 kernel! Booting it with Grub went fine but then the kernel got stuck in initrd. Dracut dropped me into an emergency console, and it couldn't see any of the devices: screen, eMMC, nothing. It seems that we are lacking a module...
|
||||
|
||||
Indeed, after investigating, we had to modify the initrd to include: `mtk_pmic_wrap`, `mt6397`, `mt6358_regulator` modules. This allowed the other modules to work properly and detect all the devices.
|
||||
|
||||
This is an intricate problem of module dependencies, and it is unfortunately not solvable as-is by just adding rules in the kernel. The cleanest fix in the kernel is to set `MTK_PMIC_WRAP`, `MFD_MT6397`, `REGULATOR_MT6358` to `y` and not `m` when configuring the kernel. The less clean fix is to add hooks to `dracut` or `initramfs-tools` to force load them in the initrd, which is what I did here.
|
||||
Finally, we can try booting our Ubuntu 7.0 kernel!
|
||||
Booting it with Grub went fine
|
||||
|
||||
### The hang: fixing with an agent
|
||||
|
||||
Once the module issue was fixed, booting got to the initrd again, but the kernel output on screen got stuck once the boot console went down.
|
||||
, but the kernel output on screen got stuck once the boot console went down.
|
||||
It seems that the U-Boot display driver keeps control of the screen, so when the kernel wants to take over and bring the display up itself, it can't. That is ok, it is also something we can fix directly. We still have the rest of the logs over serial now (with the SuzyQ), so let's follow... wait... why did it stop there?
|
||||
|
||||
So it turns out that the kernel 'hangs' in the middle of initrd, sometimes before, sometimes after pivoting root and showing the login prompt. I've had a true nightmare debugging this. I was pretty sure it was a U-Boot issue (something going wrong in the handoff), but the agents were convinced that there were upstream kernel bugs everywhere. We tried many things in random order, including a lot of changes that weren't necessary.
|
||||
@@ -387,13 +384,17 @@ I started using the system, connected to Wi-Fi (which worked fine!), cleaned up
|
||||
I tried multiple things, but it turns out that the machine still seemingly hangs in some CPU-heavy tasks, or long-running tasks.
|
||||
At this point, it's a bit hard to reproduce: you have to use the machine for 20+ minutes before it shows up. Specific operations like `apt upgrade` seem to trigger it every time, though. I tried looking online, and it seems that the LKML has recent patches for that SoC.
|
||||
|
||||
For now, I don't want to try rebuilding another kernel. I want to try and clean up everything in the U-Boot code, which I first did with agents again. Adversarial reviewers found style issues, stale comments, etc. They also addressed some display performance issues. I made sure to clean up stale configs and build caches, then to rebuild with the `kukui-krane` defconfig. Below is a video of the final boot flow for Ubuntu on the device.
|
||||
Particularly, ["ASoC: mediatek: mt8183: Fix clock error handling"](https://lkml.org/lkml/2026/8/28/313) seems to aim at fixing some clock errors, most likely the ones that I see in red in the kernel boot log every time. This patch was sent only 2 weeks ago (!!) at the time of writing of this article, so it is definitely not in 7.2. Maybe this will improve things?
|
||||
|
||||
::BlogVideo{video="/img/duet-ubuntu/full-boot-na.mp4"}
|
||||
For now, I don't want to try rebuilding another kernel. I want to try and cleanup everything in the U-Boot code, which I first did with agents again. Adversarial reviewers found style issues, stale comments, etc. They also addressed some display performance issues. I made sure to clean up stale configs and build caches, then to rebuild with the `kukui-krane` defconfig. Below is a video of the final boot flow for Ubuntu on the device.
|
||||
|
||||
::BlogVideo{video="/img/duet-ubuntu/full-boot.mp4"}
|
||||
*Full boot chain on the display. Depthcharge kills the display, U-Boot brings it back up, Grub is loaded, EFI `exitBootServices` kills the display again, to allow the kernel to bring it back up again. Could be optimized, but working.*
|
||||
::
|
||||
|
||||
## Cleaning up the U-Boot tree
|
||||
## Cleaning up, technical details
|
||||
|
||||
*(Sorry, this one is a bit technical, and maybe not that interesting. Skip it if you don't care, stay if you want a deep dive into the U-Boot changes)*
|
||||
|
||||
After all of those debugging steps, the U-Boot tree was a bit of a mess: 43 commits on top of main. **43**.
|
||||
|
||||
@@ -427,11 +428,11 @@ That is a lot, so I started refactoring, squashing, dropping, and cleaning up al
|
||||
*Full commit series added to U-Boot at the end of the experimentation, after a cleanup phase.*
|
||||
::
|
||||
|
||||
This is all the changes that the agents added, grouped together when that made sense (for example, there were 8 commits for the DSI driver, with fix-up stuff and performance improvements, so they all got squashed together to make a final commit for the DSI driver). As you can see, there are also changes that aren't for the board itself, but to fix a more generic bug, like ["video: console: fix scrolling and cursor position on rotated consoles", sent upstream already](https://lore.kernel.org/u-boot/20260831163056.3854693-1-valentin.haudiquet@canonical.com/T/#u). Some of those changes might not be useful at all though, or even break other boards, so this will need a manual review. Let's do that.
|
||||
This is all the changes that the agents added, grouped together when that made sense (for example, there were 8 commits for the DSI driver, with fix-up stuff and performance improvements, so they all got squashed together to make a final commit for the DSI driver). As you can see, there are also changes that are there not directly for the board itself, but to fix a more generic bug, like ["video: console: fix scrolling and cursor position on rotated consoles", sent upstream already](https://lore.kernel.org/u-boot/20260831163056.3854693-1-valentin.haudiquet@canonical.com/T/#u). Some of those changes might not be useful at all though, or even break other boards, so this will need a manual review. Let's do that.
|
||||
|
||||
First, we need to walk through the generic upstream bug candidates, that have virtually nothing to do with MT8183:
|
||||
|
||||
- **#2**: a real bug in the U-Boot machinery for `OF_UPSTREAM` devicetree usage (which is used to sync the DTS of the board with the one in the upstream kernel). This is a specific case of the SoC having both a legacy and an upstream dt-bindings header. Really looks like a genuine bug, will be **sent as PATCH RFC upstream** after verifications.
|
||||
- **#2**: a real bug in the U-Boot machinery for `OF_UPSTREAM` devicetree usage (which is used to sync the DTS of the board with the one in the upstream kernel). Specific case of the SoC having both a legacy and an upstream dt-bindings header. Really looks like a genuine bug, will be **sent as PATCH RFC upstream** after verifications.
|
||||
- **#4**: already **sent upstream** to fix scrolling on weirdly rotated screens
|
||||
- **#5**: reporting video "damage" when the cursor is set visible. Damage was missing for this specific function, so when having the video console in "only refresh damaged parts" mode, showing the cursor would not work until the next update. Will be **sent upstream** as PATCH after verification.
|
||||
- **#6**: another simple bug fix, related to EFI console output string. This is just a re-sync of the cursor in the EFI console with the one in the U-Boot video console. This will also need evaluation to see if it is really needed, but another candidate for a standalone upstream patch.
|
||||
@@ -441,98 +442,47 @@ First, we need to walk through the generic upstream bug candidates, that have vi
|
||||
Then, we can tackle the MediaTek fixes (not specific to MT8183 SoC):
|
||||
- **#3**: this one was hard to understand for me at first. It only adds a
|
||||
`if(samplecount <= 1) return;`
|
||||
line in the MediaTek serial driver function that sets the baud rate. The agent added a five-line comment referencing our Coreboot firmware in this generic MediaTek driver, so something was wrong. But the commit itself was in fact right, it seems: the function is writing uninitialized data into serial controller registers without this. I'm not sure why this was never seen before, but it might be because on most boards, those registers have null initial value, and it happens that in this particular case it would write a zero. But with my board that has serial controller already initialized from Coreboot, something went wrong with that and it caused an issue. That seems like a nice catch, that will **need further testing before being forwarded upstream**.
|
||||
- **#16**: the SoC has a weird quirk: the MMC controller's DMA engine has 32-bit address registers. Given that we have 4 GiB of usable RAM on `krane` (and could have this much or more on other boards), with a non-zero memory-map base, addresses can go over the 32-bit unsigned integer maximum. So we need to split the memory at that point. What this patch does is that it only allows U-Boot allocations on the `0x0-0x1_0000_0000` memory range, but shows the whole memory map to the EFI consumers. This feels like a hack, technically I guess only the DMA engine should be restricted, but U-Boot has no mechanism to do that as far as I know, so I will keep it this way.
|
||||
line in the MediaTek serial driver function that sets the baud rate. The agent added a 5 lines comment referencing our Coreboot firmware in this generic MediaTek driver, so something was wrong. But the commit itself was in fact right, it seems: the function is writing unitialized data into serial controller registers without this. I'm not sure why this was never seen before, but it might be because on most boards, those registers have null initial value, and it happens that in this particular case it would write a zero. But with my board that has serial controller already initialized from Coreboot, something went wrong with that and it caused an issue. That seems like a nice catch, that will **need further testing before being forwarded upstream**.
|
||||
- **#18**: a simple one-line change to call `xhci_mtk_phy_shutdown()` on driver removal, so that the kernel can bring up the driver on its own later. Simple fix to verify and **send upstream**.
|
||||
|
||||
Now, we are left with the **board enablement** patches, that we want to send as one or multiple patch series upstream:
|
||||
- **#1**: defining the **defconfig** for our board and renaming `mt8183_pumpkin.c` to `mt8183.c`, as it becomes generic
|
||||
- **#8**: there were **missing clocks** in the SoC's clock driver, notably the specific clock that the display DSI driver will use. This adds support for those.
|
||||
- **#8**: there were **missing clocks** in the driver for the SoC, notably the specific clock that the display DSI driver will use. This adds support for those.
|
||||
- **#9**: this adds the missing **GPIO driver** for the SoC, which is needed to then be able to **reset/power enable the display**. Adapted from Depthcharge.
|
||||
- **#10**: this adds the "MIPI TX D-PHY driver" for the SoC. That gets a bit technical. "MIPI", "Mobile Industry Processor Interface", is an alliance (ARM, Intel, Nokia, TI, ST) that standardizes interfaces in mobile SoCs. So this driver is for a specific MIPI standard, for a Transmitter (TX), that does something on the physical layer (PHY). This driver was ported from the Linux kernel by the agent. This is the **physical layer for the transmitter that will be used by the next driver**.
|
||||
- **#10**: this adds the "MIPI TX D-PHY driver" for the SoC. That gets a bit technical. "MIPI", "Mobile Industry Processor Interface", is an alliance (ARM, Intel, Nokia, TI) that standardize interfaces in mobile SoCs. So this driver is for a specific MIPI standard, for a Transmitter (TX), that does something on the physical layer (PHY), with D being the Roman numeral 500, for the speed of that transmitter: 500 Mbps. This driver was ported from the Linux kernel by the agent. This is the **physical layer for the transmitter that will be used by the next driver**.
|
||||
- **#11**: this adds the "MIPI DSI host driver" for the SoC. DSI being **Display Serial Interface**, the protocol standard, defined by MIPI again, to drive displays. This specifies how pixels, commands (e.g. brightness), and video timing signals are sent to the screen.
|
||||
- **#12**: finally, this commit **unites them all** to add the display driver. It uses #9 to power on the display, then the DSI interface from #11 (that uses the #10 PHY driver and #8 clocks) to **drive that specific "boe_tv101wum" panel** present on the Duet. Adapted from Coreboot and verified against Linux kernel.
|
||||
- **#12**: finally, this commits **unites them all** to add the display driver. It uses #9 to power on the display, then the DSI interface from #11 (that uses the #10 PHY driver and #8 clocks) to **drive that specific "boe_tv101wum" panel** present on the Duet. Adapted from Coreboot and verified against Linux kernel.
|
||||
- **#13**: this **shuts down the display when EFI `ExitBootServices()` is called**, i.e. right before the kernel starts, to allow it to take over from an uninitialized display, the same kind of state that Depthcharge would have left the display in. This might need a change, as there might be a way to tell the kernel in what state we left the display, for it to use it for earlycon, and take over after. This **will need more investigation**.
|
||||
- **#14**: this one is another that left me a bit puzzled. It defines a `get_page_table_size()` function, that returns `SZ_256K`, inside the `mt8183.c` file. That is it. This is actually a function that is called during the generic initialization path, and the issue is that the default page table budget is not enough to map the Coreboot table and the framebuffer for the display. That seems to be the standard pattern to account for that, present in Apple platform files as well, so I left it as-is, but will need to check a bit more before trying to send it upstream.
|
||||
- **#15**: this one allows detecting the real memory present on the board from the Coreboot tables, instead of relying on the static "2 GiB" from the devicetree. The code is in the generic init path for the MT8183, which seems wrong to me, as this has a lot of Coreboot-specific paths. So I think this should be abstracted away in a Coreboot table driver that can extract information, and multiple different paths to check whether this driver should be used. **It will need a bit more work**.
|
||||
- **#19**: this commit adds a U-Boot-specific overlay for the `krane` devicetree, describing the SuperSpeed USB node of the device. Linux uses the more generic "MTU3" MediaTek driver for the USB node, which has multiple capabilities (Dual-Role OTG). U-Boot lacks this driver, so we manually add only the SuperSpeed USB part that is nested under the `mtu3` node in the upstream devicetree and thus invisible to U-Boot.
|
||||
- **#20**: this is a weird commit that **enables everything that was added before** (video, USB, PHY and DSI drivers, etc.) in the board **defconfig**. This will go or stay like this depending on how the series is split for upstream.
|
||||
- **#19**: this commit adds a U-Boot-specific overlay for the `krane` devicetree, describing the Super Speed USB node of the device. Linux uses the more generic "MTU3" MediaTek driver for the USB node, which has multiple capabilities (Dual-Role OTG). U-Boot lacks this driver, so we manually add only the Super Speed USB part that is nested under the `mtu3` node in the upstream devicetree and thus invisible to U-Boot.
|
||||
- **#20**: this is a weird commit that **enables everything that was added before** (video, usb, PHY and DSI drivers, etc) in the board **defconfig**. This will go or stay like this depending on how the series is split for upstream.
|
||||
|
||||
I guess this will need to be split between basic board support and video drivers. I don't want to waste the time of the maintainers with "AI slop" and code that does not follow the right conventions, so I will take extra care to read the U-Boot contributing documentation, and make sure all the patches are in a good shape before sending them. I also don't think they have an AI policy at the moment, but I plan anyway to rewrite all the patches myself with the AI output as input, and will of course disclose that all of this was AI-assisted.
|
||||
|
||||
## Fixing the kernel
|
||||
|
||||
### Getting rid of the hang
|
||||
|
||||
Now that we have a cleaner U-Boot tree, and have a global understanding of all the modifications, it is time to go back to that kernel hang debugging. And the issue might already be clearer now.
|
||||
|
||||
See, I was convinced from the beginning that this was a U-Boot issue, something going wrong in the handoff, particularly regarding memory, hence why we added commit #15 to U-Boot. But as we have seen right before, that commit is still doing weird things and would need more work. Let's try to analyze memory issues now.
|
||||
|
||||
I was able to reproduce the hang deterministically when running a stress benchmark program, that would try to use a large amount of memory. So this made me even more convinced that there was a memory map issue. The solution: compare memory maps on the postmarketOS kernel booted from Depthcharge, and the one from the Ubuntu kernel booted from U-Boot.
|
||||
|
||||
To look at kernel memory maps, I just used `sudo cat /proc/iomem`. I fed both outputs to the agent, and it immediately found the difference: a memory area, marked as reserved for the ARM Trusted Firmware from the postmarketOS side, but as regular usable memory from the Ubuntu side. Because of this, when programs are requiring more and more memory, the kernel will end up mapping that region and giving it to programs that will try writing on it, which will trigger some kind of security exception, that could very well end up freezing the OS. That really sounds like the bug. Finally.
|
||||
|
||||
The agent started implementing the fix for it directly, which was extracting the full memory map from Coreboot tables and trusting that. Given that this specific firmware memory region can depend on the firmware, it is not part of the upstream devicetree (that only describes the hardware). Because of this, it was missing in our EFI memory map, and needed this new Coreboot tables parsing.
|
||||
|
||||
After three unsuccessful attempts, the fourth flash was the win. I re-ran the stress program, and it completed without issues. Victory! Changes squashed with commit #15 (I knew it needed more work). A clean experience on the Duet. Almost.
|
||||
|
||||
### Bluetooth
|
||||
|
||||
Unfortunately, Bluetooth does not work on the upstream kernel. The postmarketOS kernel uses [a specific patch](https://gitlab.postmarketos.org/postmarketOS/pmaports/-/blob/main/device/community/linux-postmarketos-mediatek-mt81/mt8183-fix-bluetooth.patch) to restore functionality, but that is a revert of another upstream patch, so this likely won't be mainlined soon.
|
||||
|
||||
This can be seen in my boot video, as the kernel will spam
|
||||
|
||||
::BlogCode
|
||||
```
|
||||
Bluetooth: hci0: Frame reassembly failed (-84)
|
||||
```
|
||||
::
|
||||
|
||||
on each boot. Personally, I will experiment with adding that patch on top of the kernel, but I don't want to have to maintain a custom kernel build every time, so I'll mostly keep using the generic one for now.
|
||||
|
||||
I hope that someday, someone will do the work to have a patch that works upstream, and after a simple `apt upgrade`, I'll end up with working Bluetooth again.
|
||||
|
||||
## A bootable Ubuntu image
|
||||
|
||||
Now we can crystallize all the work into a bootable Ubuntu image, embedding this modified U-Boot in a first Depthcharge partition. The goal of this is that anyone can download the image, put it on a USB stick, and boot it using Depthcharge "USB boot" option, to get into a real Ubuntu on the Duet.
|
||||
Now that we understand most of the work, we can crystallize this into a bootable Ubuntu image, embedding this modified U-Boot in a first Depthcharge partition. The goal of this is that anyone can download the image, put it on a USB stick, and boot it using Depthcharge "USB boot" option, to get into a real Ubuntu on the Duet.
|
||||
|
||||
This might seem a bit hard, but it is actually very easy for me as I did this a couple times already for RISC-V boards for my job at Canonical, so I know exactly which tools to use to produce such an image. We need [ubuntu-image](https://github.com/canonical/ubuntu-image) and a **gadget file** that describes the partition of the image we want to build, as well as an **image definition** file that describes the version, packages and stuff we want in the image. See this [tutorial](https://ubuntu.com/hardware/docs/image-cookbook/tutorial/create_image/) for more details.
|
||||
|
||||
I created a Git repo with everything needed to build such an image: `uboot/` with all the patches, `krane-shim-loader/` with the shim loader and the script to create the Depthcharge payload from it and U-Boot, `image/` with the files described above as well as custom script to make the Depthcharge partition work well and test the image. This was also easily done, assisted by an agent.
|
||||
I decided to create a Git repo with everything to create such an image: `uboot/` with all the patches, `krane-shim-loader/` with the shim loader and the script to create the Depthcharge payload from it and U-Boot, `image/` with the files described above as well as custom script to make the Depthcharge partition work well and test the image. This was also easily done, assisted by an agent.
|
||||
|
||||
::BlogLink{link="https://github.com/vhaudiquet/krane"}
|
||||
::
|
||||
|
||||
If you own a Lenovo IdeaPad Duet, go to the repo, download the image in Releases, burn it to a USB stick and try it! You don't need anything more, only [put it in 'Developer Mode'](https://wiki.postmarketos.org/wiki/Category:ChromeOS#Enable_developer_mode_on_laptop_or_tablet_with_keyboard) (this will factory reset the device) and select 'USB boot' from the menu. Default password is `ubuntu`. *Disclaimer: this is not an official Ubuntu image in any way, all of this was done in my free time and unaffiliated with official Ubuntu or Canonical.*
|
||||
|
||||
::BlogImage{image="/img/duet-ubuntu/ubuntu-tablet.jpg"}
|
||||
*Full Ubuntu Desktop finally running on the Duet. Unfortunately, GNOME is even slower from the live USB image than it was with postmarketOS. I guess that comes from my slow USB stick and the lack of RAM...*
|
||||
::
|
||||
If you own a Lenovo IdeaPad Duet, go to the repo, download the image in Releases, burn it to a USB stick and try it!
|
||||
|
||||
## Conclusion
|
||||
|
||||
### Using AI for hardware bring-up/debugging
|
||||
|
||||
I saw many posts on X (Twitter) and other social networks of people having "Claude vibe-code them support for hardware x or y" everywhere. I was a bit skeptical of some of those posts. Most of them are about kernel drivers for a specific piece of hardware, and I can believe that, as the agent can automatically load/unload to test on-device, without human intervention required. When a datasheet and an out-of-tree driver exist, an agent can indeed fully adapt a driver for the running kernel, as we have seen with the work on the U-Boot panel driver (which still needed manual testing).
|
||||
|
||||
For some of the posts that blew up on my timeline, though, it seemed like OpenAI GPT-6 Astra or Claude Fable was able to one-shot (so, just a prompt, it worked alone, and then the user tested) full Linux support for devices that had none. I don't buy those. It took 10 flashes to get a stub that only draws solid colors on a screen, and 27 to get U-Boot to run at all. Models are also confidently wrong in ways only hardware reveals: Astra had me convinced I'd found the fix for the hang, and it wasn't, and one of the U-Boot patches made a symptom disappear by hiding a double call instead of fixing it. And that's before we get to reverse-engineering hardware support from scratch, which still takes a huge amount of human work.
|
||||
|
||||
The most interesting thing, though, is whether those AI-generated or AI-helped drivers and patches are maintainable and upstreamable. That is the core of the debate. If an unmaintained vendor kernel already exists, using AI to make it work on your kernel gains you nothing: you now own a second unmaintained kernel, and this one is your problem. This is why I will try to upstream all my patches to U-Boot, but that needs real software engineering skills, or a large amount of disrespect for the maintainers if you expect to send unreviewed AI-generated patches.
|
||||
|
||||
As we have seen with the previous U-Boot patches, it seems that some of them will need more work, and it isn't perfect. However, with multiple rounds of adversarial reviews from AI models, I believe the patches are better than what I would have produced alone. That is one of the strengths of the AI agents: even if you write all code by hand, they can find mistakes pretty well.
|
||||
|
||||
Furthermore, I would never have been able to write the U-Boot drivers here myself without help. First, I would never have the time to do that. Second, I would have had to read the Linux and Coreboot drivers multiple times, and would have probably missed a lot of data and introduced bugs. The quality of the agent's code seems really great, and it doesn't differ much from the other upstream drivers.
|
||||
|
||||
However, I can say all of that because I'm a software engineer. I have written patches for similar firmware projects. I know what makes good code, and I understand the underlying boot flow and necessities of what I want to accomplish, including what the right ways to do things are, and what the wrong ones are. That judgment is the part I don't think an agent replaces, and it's the part someone without the background would have to acquire first. With it, this is a formidable tool. Without it, you can generate a lot of plausible hardware code and have no way to tell which parts are wrong.
|
||||
|
||||
AI can be used to fill in the missing knowledge (e.g. boot flow on Chromebooks) and generate the code of precise things (i.e. a framebuffer driver stub that prints colors, integrating such driver in U-Boot, adapting a panel driver from Linux and Coreboot). The barrier to entry is lower than ever, but you have to put in the work, be determined to learn and try hard to understand what is going on, and still have prior coding experience to be able to review and maintain the output. I don't think someone prompting "I want to run Ubuntu on my Duet tablet" without any software engineering background or the will to learn and really spend time on it would have been able to make this work.
|
||||
|
||||
*Another important thing is that I'm talking about models from September 2026, and I have used mostly GLM-5.3-Flash here, a 'flash' model. Those experiments ended up costing approximately $15 to $20, I think. Of course, if I had spent $500 and ran Astra all the time (the Astra session alone cost $4, for only one output which was a link to the patch it found), the code quality might have been better, but it does not change some structural problems for a model to do things alone.*
|
||||
|
||||
### Next steps
|
||||
|
||||
Obviously, the immediate next step is to try and upstream all this U-Boot work, so that the bug fixes help everyone and support for the device is added upstream, which should both let anyone play with it and keep it maintained.
|
||||
Obviously, the immediate next step is to try and upstream all this U-Boot work, so that the bug fixes help everyone and support for the device is added upstream, which should both let anyone play with it and provides maintenance for the future.
|
||||
|
||||
Afterwards, I want to try, some day, to get rid of the annoying Depthcharge menu. This would mean flashing a whole new Coreboot + Depthcharge firmware image, and I think it is possible using the SuzyQ. This is a significant next step, and if I have the time to do it one day, it will deserve its own blog post.
|
||||
|
||||
Until then, see you later! :)
|
||||
|
||||
coreboot, fix errors, upstreaming
|
||||
@@ -1,268 +0,0 @@
|
||||
import { defineNuxtModule, useLogger } from '@nuxt/kit'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { readdir, writeFile, rename, unlink } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
/**
|
||||
* Strip-media-metadata module
|
||||
* ----------------------------
|
||||
* At build time, walks the *built* `public/` directory (the copy that Nitro
|
||||
* writes to `.output/public/`) and removes all embedded metadata — EXIF, ICC
|
||||
* profiles, XMP, IPTC and similar for images, and container/stream metadata
|
||||
* for videos.
|
||||
*
|
||||
* Source files under `public/` are never touched: only the artefacts that end
|
||||
* up in the generated site are cleaned, so the originals keep their metadata
|
||||
* for editing/authoring purposes.
|
||||
*
|
||||
* Images are re-encoded with `sharp` (already available as a transitive
|
||||
* dependency of `@nuxt/image`). Re-encoding is only performed when the image
|
||||
* actually carries metadata, so clean files are left byte-for-byte untouched.
|
||||
* Videos are remuxed losslessly with `ffmpeg -c copy -map_metadata -1` (which
|
||||
* also moves the moov atom to the front for faster web playback).
|
||||
*/
|
||||
|
||||
export interface StripMediaMetadataOptions {
|
||||
/** Master switch. Defaults to `true` (the hook only fires for production builds anyway). */
|
||||
enabled?: boolean
|
||||
/** Sub-directories of the public folder to process, e.g. `['img']`. */
|
||||
dirs?: string[]
|
||||
/** JPEG/TIFF/AVIF re-encode quality (1-100). */
|
||||
jpegQuality?: number
|
||||
/** WebP re-encode quality (1-100). */
|
||||
webpQuality?: number
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'tiff', 'tif', 'avif', 'gif'])
|
||||
const IMAGE_FORMAT: Record<string, string> = {
|
||||
jpg: 'jpeg',
|
||||
jpeg: 'jpeg',
|
||||
png: 'png',
|
||||
webp: 'webp',
|
||||
tiff: 'tiff',
|
||||
tif: 'tiff',
|
||||
avif: 'avif',
|
||||
gif: 'gif',
|
||||
}
|
||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm', 'mkv', 'm4v', 'avi'])
|
||||
|
||||
/** Recursively collect regular files under `dir`. */
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await walk(full))
|
||||
} else if (entry.isFile()) {
|
||||
out.push(full)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether the `ffmpeg` binary is available on PATH. */
|
||||
function hasFfmpeg(): boolean {
|
||||
try {
|
||||
return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the image carries any metadata we know how to strip. */
|
||||
function hasImageMetadata(meta: sharp.Metadata): boolean {
|
||||
return Boolean(
|
||||
(meta.exif && meta.exif.length)
|
||||
|| (meta.icc && meta.icc.length)
|
||||
|| (meta.xmp && meta.xmp.length)
|
||||
|| (meta.iptc && meta.iptc.length)
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-encode an image in place, dropping all metadata. Returns false if skipped or unsupported. */
|
||||
async function stripImage(
|
||||
file: string,
|
||||
jpegQuality: number,
|
||||
webpQuality: number,
|
||||
): Promise<boolean> {
|
||||
let meta: sharp.Metadata
|
||||
try {
|
||||
meta = await sharp(file).metadata()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!hasImageMetadata(meta)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ext = extname(file).slice(1).toLowerCase()
|
||||
const format = IMAGE_FORMAT[ext]
|
||||
if (!format) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// `.rotate()` auto-applies the EXIF orientation before the metadata is dropped,
|
||||
// so portraits taken on phones stay upright after EXIF removal.
|
||||
let pipeline = sharp(file, ext === 'gif' ? { animated: true } : undefined).rotate()
|
||||
switch (format) {
|
||||
case 'jpeg':
|
||||
pipeline = pipeline.jpeg({ quality: jpegQuality })
|
||||
break
|
||||
case 'png':
|
||||
pipeline = pipeline.png({ compressionLevel: 9 })
|
||||
break
|
||||
case 'webp':
|
||||
pipeline = pipeline.webp({ quality: webpQuality })
|
||||
break
|
||||
case 'tiff':
|
||||
pipeline = pipeline.tiff({ quality: jpegQuality })
|
||||
break
|
||||
case 'avif':
|
||||
pipeline = pipeline.avif({ quality: jpegQuality })
|
||||
break
|
||||
case 'gif':
|
||||
pipeline = pipeline.gif()
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
const buffer = await pipeline.toBuffer()
|
||||
await writeFile(file, buffer)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Remux a video in place losslessly, dropping all container metadata. */
|
||||
function stripVideo(file: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const ext = extname(file)
|
||||
const tmp = `${file}.nometa${ext}`
|
||||
const isMp4Like = ext === '.mp4' || ext === '.mov' || ext === '.m4v'
|
||||
const args = [
|
||||
'-y',
|
||||
'-i', file,
|
||||
'-map', '0',
|
||||
'-map_metadata', '-1',
|
||||
'-c', 'copy',
|
||||
...(isMp4Like ? ['-movflags', '+faststart'] : []),
|
||||
tmp,
|
||||
]
|
||||
const proc = spawn('ffmpeg', args, { stdio: 'ignore' })
|
||||
proc.on('error', () => resolve(false))
|
||||
proc.on('close', async (code) => {
|
||||
if (code !== 0) {
|
||||
await unlink(tmp).catch(() => {})
|
||||
return resolve(false)
|
||||
}
|
||||
try {
|
||||
await rename(tmp, file)
|
||||
resolve(true)
|
||||
} catch {
|
||||
await unlink(tmp).catch(() => {})
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function processDirectory(
|
||||
dir: string,
|
||||
jpegQuality: number,
|
||||
webpQuality: number,
|
||||
ffmpegAvailable: boolean,
|
||||
logger: ReturnType<typeof useLogger>,
|
||||
): Promise<{ stripped: number; skipped: number; failed: number }> {
|
||||
const files = await walk(dir)
|
||||
let stripped = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const file of files) {
|
||||
const ext = extname(file).slice(1).toLowerCase()
|
||||
try {
|
||||
if (IMAGE_EXTENSIONS.has(ext)) {
|
||||
const done = await stripImage(file, jpegQuality, webpQuality)
|
||||
done ? stripped++ : skipped++
|
||||
} else if (VIDEO_EXTENSIONS.has(ext)) {
|
||||
if (!ffmpegAvailable) {
|
||||
logger.warn(`ffmpeg not found — skipping video metadata strip for ${file}`)
|
||||
skipped++
|
||||
} else {
|
||||
const done = await stripVideo(file)
|
||||
done ? stripped++ : skipped++
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
logger.warn(`Failed to strip metadata from ${file}: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { stripped, skipped, failed }
|
||||
}
|
||||
|
||||
export default defineNuxtModule<StripMediaMetadataOptions>({
|
||||
meta: {
|
||||
name: 'strip-media-metadata',
|
||||
configKey: 'stripMediaMetadata',
|
||||
},
|
||||
defaults: {
|
||||
enabled: true,
|
||||
dirs: ['img'],
|
||||
jpegQuality: 92,
|
||||
webpQuality: 90,
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
if (!options.enabled) {
|
||||
return
|
||||
}
|
||||
// The `nitro:build:public-assets` hook is emitted by Nuxt right after
|
||||
// `copyPublicAssets()` has copied `public/` into `.output/public/`, and
|
||||
// before the server bundle / prerendering finalises. Editing files in the
|
||||
// output dir at this point means the generated site is cleaned while the
|
||||
// source `public/` is left untouched.
|
||||
nuxt.hook('nitro:build:public-assets', async (nitro) => {
|
||||
const logger = useLogger('strip-media-metadata')
|
||||
const publicDir = nitro.options.output.publicDir
|
||||
const ffmpegAvailable = hasFfmpeg()
|
||||
|
||||
const start = Date.now()
|
||||
let totalStripped = 0
|
||||
let totalSkipped = 0
|
||||
let totalFailed = 0
|
||||
|
||||
for (const sub of options.dirs ?? []) {
|
||||
const target = join(publicDir, sub)
|
||||
const { stripped, skipped, failed } = await processDirectory(
|
||||
target,
|
||||
options.jpegQuality ?? 92,
|
||||
options.webpQuality ?? 90,
|
||||
ffmpegAvailable,
|
||||
logger,
|
||||
)
|
||||
totalStripped += stripped
|
||||
totalSkipped += skipped
|
||||
totalFailed += failed
|
||||
}
|
||||
|
||||
if (totalStripped || totalFailed) {
|
||||
logger.success(
|
||||
`Stripped metadata from ${totalStripped} file(s)`
|
||||
+ (totalSkipped ? `, ${totalSkipped} already clean` : '')
|
||||
+ (totalFailed ? `, ${totalFailed} failed` : '')
|
||||
+ ` in ${Date.now() - start}ms`,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
+1
-14
@@ -34,22 +34,9 @@ export default defineNuxtConfig({
|
||||
'@nuxt/image',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@nuxtjs/seo',
|
||||
'nuxt-umami',
|
||||
// Local module: strip EXIF/ICC/XMP/... metadata from images & videos in
|
||||
// public/img at build time (operates on the generated .output/public copy,
|
||||
// never on the source files).
|
||||
'~~/modules/strip-media-metadata'
|
||||
'nuxt-umami'
|
||||
],
|
||||
|
||||
// Strip all embedded metadata (EXIF, ICC, XMP, IPTC, ...) from the images
|
||||
// and videos shipped under public/img. Only the build output is touched.
|
||||
stripMediaMetadata: {
|
||||
enabled: true,
|
||||
dirs: ['img'],
|
||||
jpegQuality: 92,
|
||||
webpQuality: 90,
|
||||
},
|
||||
|
||||
content: {
|
||||
experimental: {
|
||||
sqliteConnector: 'native',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB |
Reference in New Issue
Block a user