Tuesday, April 28, 2026

Native vs Cross Compilation: a Love Story, a War Story, and a Debugging Nightmare

Let’s talk about something that has caused more arguments than tabs vs spaces, more confusion than ./configure && make && make install, and more gray hair than undefined behavior:

native vs cross compilation

This is not just a technical topic. This is culture. This is history. This is… mild trauma for distro maintainers.

In the beginning, there was only native

To understand the present landscape, we have to look back to the 90s, when the foundations were laid. So…

A long time ago in a galaxy far, far away…

(read: before everything was ARM), life was simple.

You had:

  • one machine
  • one architecture
  • one compiler

You wrote code, you compiled it, you ran it.

All on the same machine.

echo -e '#include <stdio.h>\nint main(){printf("hello\\n");}' | cc -x c - -o hello && ./hello
No "build vs host vs target." No sysroots. No cross toolchains.

Just vibes… No CI, No containers… Just you, your compiler, and a questionable amount of confidence.

Then came the rise of the Linux distributions

Early Linux was not a “distribution” in the modern sense.

It was:

  • a kernel
  • plus a pile of userland tools (mostly from GNU) (Ever wondered why GNU Linux? This would leave the place for another war story: what is Linux? The kernel or the complete system? But we need to focus, this story will be for another time.)
  • manually assembled by whoever was brave enough

Installing Linux in the early 90s was less “setup” and more “ritual”.

But Linux grew, and things changed:

  • more users
  • more software
  • more versions
  • more dependencies

Suddenly, maintainers were not compiling one program.

They were compiling:

  • hundreds → then thousands → then tens of thousands of packages

And not just once, but:

  • for every release
  • for every update
  • for every security fix

At this point, distributions like Slackware, Debian, and later Red Hat appeared with a mission:

Take all this chaos and make it installable, updatable, and consistent.

Then things started to evolve, the panorama quickly changed and new requirements became necessary.

Binary distribution

Distributions don’t ship source (only).

They ship:

  • precompiled binaries

So they must ensure:

  • the binary you download is:
    • correct
    • consistent
    • built in a known environment

Later you will need a few more of these, but we were not at the point yet.

Reproducibility becomes a necessity, not a luxury

Here is the key shift: Distributions needed to be able to rebuild the same software reliably, “it works on my machine” was no more enough.

It seems trivial:

Given this source, we can rebuild the exact package again.

If you’re wonder why this requirement, here’s a few reasons:

  • Debugging - User reports “Program crashes” and Maintainer needs to:
    • rebuild the exact binary
    • reproduce the issue
    • inspect symbols, offsets, behavior
  • Security - Security fixes require:
    • rebuilding packages
    • ensuring nothing unexpected changed
  • Updates and upgrades - When updating:
    • package A depends on B
    • B changes ABI subtly
    • A must be rebuilt consistently

Reproducibility is not the default state… you have to actively remove sources of entropy.

For example, even changing only the source filename can be enough to break bit-for-bit identity:

$ echo -e '#include <stdio.h>\nint main(){printf("hello\\n");}' >1.c $ echo -e '#include <stdio.h>\nint main(){printf("hello\\n");}' >2.c $ gcc 1.c -o 1 $ gcc 2.c -o 2 $ md5sum 1 2 6aa0460c6e9d029b5c40f27c86fadfa3 1 ada5719e11d8b6a17f020353f23f12d6 2 $ hexdump -C 1 >1.hex $ hexdump -C 2 >2.hex $ diff 1.hex 2.hex 258c258 < 000026d0 65 6e 74 72 79 00 31 2e 63 00 63 72 74 65 6e 64 |entry.1.c.crtend| --- > 000026d0 65 6e 74 72 79 00 32 2e 63 00 63 72 74 65 6e 64 |entry.2.c.crtend|

Same code, same compiler, different hashes. In this instance, the mere difference in filenames caused the compiler to embed unique strings into the binary, breaking bit-for-bit identity.

Once reproducibility became a requirement, the question was no longer how to build, but where and under what conditions to build.

The solution: controlled build environments

There were times when builds were done by the “build computer”. A machine developers used to build the new software. It was untouchable, update or change anything could break the reproducibility of the software. It was evidently not a scalable solution, if only because that eventually broke. To solve all of this, distributions invented:

  • build roots (clean environments)
  • packaging systems (RPM, DEB)
  • build farms (many machines building packages)
  • strict dependency tracking

The idea was:

Every package is built in a clean, controlled environment.

This is where tools like:

come into play.

Then Linux escaped x86

Linux did not stay on x86.

It spread like a highly portable virus (in a good way):

Year Arch
1993 Dec Alpha
1995 Sparc
1995 MIPS
1996 M68k
1996 PowerPC
1998 ARM

Now we had a problem:

What if your build machine is x86, but your target is ARM?

Buying one machine per architecture works… until it doesn’t. And by the way, a starting architecture, likely has not any native environment… system does not exist yet.

Enter cross-compilation

Before even moving forward, have you ever stopped to consider what cross compilation actually is?

If you compile a package using a container running Alpine Linux for your Fedora, are you cross compiling things?

In common usage, people often reserve “cross-compilation” for different architectures. In toolchain terms, however, changing the ABI (e.g. glibc vs musl) is already enough.

Real-world example: I once hit an underlinking issue with sem_init() on MIPSEL caused by symbol versioning differences in glibc. Everything compiled fine, nothing obvious was missing, but the binary failed at runtime in ways that made no sense until you looked at the symbol versions.

That’s the kind of problem you only get when ABI assumptions don’t actually match reality.

The Three Pillars of a Platform

In toolchain development, a “Platform” is defined by a triplet that includes more than just the CPU:

  1. Architecture (ISA): e.g., x86_64, arm64, riscv64.
  2. Operating System: e.g., linux, windows, darwin, none (bare metal).
  3. C Library / Environment (ABI): e.g., gnu (glibc), musl, uclibc, msvc.

If any of these three differ between the machine doing the work and the machine running the code, you are technically in the “cross-compilation landscape.”

Back to the original question, even if you are on the same physical CPU, and even the same OS, containers share the kernel, a compiler running on an Alpine Linux system (which uses musl) targeting a Fedora system (which uses glibc), you are technically cross compiling.

If not convinced yet, here are a few points to consider:

  • The Linker needs to look for different library paths.
  • The Headers define structures (like struct stat) differently.
  • The Startup Code (crt0.o) that initializes the program before main() is unique to each C library.

Now it is safe to name cross compilation

In a nutshell, Cross-compilation means:

I build here, but I target somewhere else.

You now have:

  • a compiler that produces aarch64 binaries on x86_64
  • a sysroot with aarch64 libraries
  • a growing sense of complexity

Which means you can:

aarch64-linux-gnu-gcc hello.c -o hello

Congratulations, you’ve successfully built a binary you can’t run, can’t test, and aren’t entirely sure is correct.

Oops.

The religion war begins

Two camps emerge:

Native camp

  • Reality is the best test.
  • If it runs, it works.
  • Just build on the target.

Cross camp

  • Define your environment properly.
  • Stop relying on luck.
  • Your build system is broken, not cross.

Both are right.

Both are wrong.

Both have scars.

At some point, every engineer becomes opinionated about this. Not because they studied it, because they suffered through it.

Linux Distribution prefer Native

Most major distributions prefer native builds:

  • Fedora / RHEL → Koji + Mock
  • Debian → buildd network
  • Ubuntu → Launchpad

Why?

Not because cross is evil, but because:

Native builds tolerate messy software better.

And let’s be honest:

A lot of upstream software is messy.

Is there any foundation why native is better than cross?

Let’s see the Native supporters claim:

  • Cross compilation produces inferior grade quality executables.
  • Cross compilation can fail to configure.
  • Cross compilation can use wrong configuration and you only realize it when binary won’t work or crashes.
  • Cross compile implies Library Pollution.
  • Native compilation allows in place testing.
  • Can be situation you need to distribute intermediate products of the build, cross compilation does produce no usable artifacts.

And also Cross supporters’ claims:

  • It is not feasible to have a native builder of all the architecures you want to support.
  • In embedded world target are typically very small machines, build on them will take forever.
  • Cross builds can give the same exact result as Native compilation.
  • No bonds or ties, you can build anywhere, for anything.

Let’s address these claims and let’s see how things really are.

Do cross builds produce same binaries as native builds?

Short answer:

They can, but it won’t typically happen.

It can be actually demonstrated that if cross and native run:

  • same toolchain
  • controlled environment
  • And you add the secret sauce (compiler flags)

The result can be bitwise identical binaries.

Here comes the fun part: same toolchain and controlled environment are not enough to have bitwise identical executable. You might need to add the ‘secret sauce’ and control sources of non-determinism to approach reproducible builds.

But the important point is that for “identical.text and .data same toolchain and controlled environment can be enough.

Common reasons why binaries can differ despite same toolchain and controlled environment are:

  • Timestamps differences: build time embedded is embedded in ELF comments and __DATE__, __TIME__ could be used by both compiler and usercode.
  • Hostnames / usernames: embedded in debug info and sometimes in build notes.
  • Compiler fingerprints: Compilers are like Dogs, they use to pee where the step, which means: linker version, build IDs, etc.
  • File ordering: This one is subtle and evil. make collects object files, filesystem returns them in some order, and linker consumes them in that order. Change file creation order leads to changes in binary layout.
  • Parallel build non-determinism: modern builds are heavily parallelized (make -j, LTO, linker jobs). Jobs don’t always finish in the same order, and neither does the linker. Sometimes, your binary depends on which core was faster that day.

If you want identical binaries, you need to remove as many sources of entropy as possible.

Part of the entropy can be handled with compiler and linker flags, using what I called the secret sauce.

Syndrome Cure Notes
Timestamps SOURCE_DATE_EPOCH Instead of using “Now,” Compilers will use the timestamp you provide.
Timestamps -Wno-builtin-macro-redefined Allows you to manually redefine __DATE__ and __TIME__ via -D flags if you can’t use SOURCE_DATE_EPOCH
Polluting sections -fno-ident Tells the compiler not to generate the .ident or .comment section containing the compiler version.
Build Paths -fdebug-prefix-map=/old/=/new/ Replaces absolute paths in debug info and in __FILE__ with a generic string.

But some sources of non-determinism live in the build system itself, and require discipline, not just flags.

Does this really exist cross compiler configuration problem?

Before we can understand why people distrust cross-compilation, we need to talk about a legendary creature Autotools.

Specifically:

autoconf automake ./configure

If you’ve ever typed:./configure && make …you’ve already met it.

So, what problem Autotools was solving?

To understand that, we need to go back to the 90s when there was no:

  • standardized Linux
  • consistent libc behavior
  • reliable compiler features
  • unified system APIs

Instead, you had a nice zoo:

  • different UNIX systems
  • different compilers
  • different headers
  • different kernel quirks

Back then it was common to answering questions like:

  • Does this system have strlcpy?
  • Is long 32 or 64 bits?
  • Does malloc(0) behave sanely?
  • Is fork() broken here?

And the answer was often: Maybe

Autotools solved the problem by saying:

Don’t guess. Ask the system.

But instead of asking politely, they do this:

  • Generate a tiny C program
  • Compile it
  • Run it
  • Observe the result
  • Decide how to build the real software

Example:

int main() { return sizeof(long) == 8 ? 0 : 1; }

If it runs and returns 0 then we know long is 64-bit.

It was clever, for its time.

Because documentation was unreliable, headers lied and systems were inconsistent.

But as you might guess, this model does not meet cross compilation because you build on x86, but your target is ARM, to name one.

Autotools would compile a test program… …and then tries to run it.

This is the origin of some native supporters’ claims about about configuration.

Question is: are we still at that point?

The answer? Spin the wheel, because Autotools has evolved, but it hasn’t entirely escaped its roots.

Current autotool can:

  • use cached answers: Instead of running code the code above it can use ac_cv_sizeof_long=8
  • Cross-aware configuration: by telling it --host=aarch64-linux-gnu it disables some runtime checks, switches to safer assumptions
  • Replace execution with knowledge: many checks were rewritten to inspect headers and use known architecture traits

But it still, sometimes, when execution fails, just… guesses…

and here’s where subtle bugs, wrong assumptions, architecture-specific breakage come.

When the system can’t answer at build time, we can either guess… or we can simulate the answer. Therefore, sometimes if you want to cross build, you need also to emulate using qemu.

Qemu enables autotools to:

  • Probe executables can run successfully under emulation
  • Configuration becomes more accurate and less dependent on assumptions
  • Complex or runtime-dependent checks become feasible again

This effectively restores the original Autotools probing model, even in cross environments.

Library pollution, what is this thing.

Native supporters calims cross builds to suffer from library pollution.

During a cross build, the build system accidentally links against host libraries instead of target libraries.

So you think you built for ARM (or another target), but headers came from one place, libraries came from another and the result is inconsistent or outright broken.

Making it less “pollution” and more “you accidentally linked against the wrong universe”.

Library pollution is usually discussed in the context of cross-compilation, but the underlying issue is broader:

it’s really about insufficient isolation between dependency domains.

At its core, library pollution happens whenever: the compiler and linker pick up inconsistent headers and libraries from different environments.

That can occur in:

  • cross builds (host vs target)
  • native builds (system vs custom prefixes, multiple versions, etc.)

The difference is not whether it happens, but how it fails.

The real root cause: isolation failure

The key variable is: > how well your build environment is isolated

This can involve:

  • sysroots
  • prefix separation (/usr vs /usr/local vs /opt/…)
  • environment variables
  • build system discipline

When isolation breaks, pollution becomes possible…

regardless of cross/native.

But then, why only the cross build gets the spotlight?

Cross builds make the problem obvious because architectures differ and binaries are often not runnable on the host. So when pollution happens linker may fail immediately or binary won’t even start or crashes instantly.

In practice the failure is loud and early… And you can’t deploy your package.

But beware that if happen on native builds thing can be even worse because headers and libraries are often compatible enough to compile, ABI differences may be subtle and symbol versions may partially match.

The result is silent inconsistency, executable can work, and perhaps only break on a few corner cases.

Cross vs Native pollution chart:

Aspect Cross build Native build
Architecture mismatch Yes No
Build failure likelihood High Low
Runtime failure Immediate Delayed / conditional
Debug difficulty Moderate Very high
Typical symptom Won’t start / crashes instantly Sporadic crashes

Intermediate prducts of the building

In a native build, all generated tools, scripts, and metadata are produced for the same environment in which they will later be used. This makes it possible to package and reuse the entire build context, such as kernel headers, symbol information, and helper binaries… for downstream tasks like out-of-tree module compilation.

In contrast, cross builds split the world into host and target components.

While the final binaries target the desired architecture, many intermediate tools are built for the host and are not portable. This makes the resulting build artifacts harder to redistribute or reuse in a different environment, especially without reconstructing the original build setup.

The concrete example for the Kernel (few other may exist) When you build something like the Linux kernel, you’re not just producing:

  • vmlinuz / *Image

You’re also generating a whole ecosystem of intermediate tools and metadata, such as:

  • scripts/ utilities (e.g. modpost, genksyms)
  • generated headers (include/generated/*)
  • configuration state (.config, Module.symvers)
  • build glue for modules (kbuild infrastructure)

These are not optional, they are required for:

building out-of-tree modules later

Some of these artifacts are binaries targeting the build architecture. Because in users’ world OOT modules are built on the machine that is going to use them cross build artifacts are not usable.

Testing

Testing is one of the weak, if not the weakest, point of the cross build. In native build, it’s easy just the test and you’re done.

On the Cross builds, on the other hand, testing is possible, but you need to work a little.

The use of QEMU user-mode emulation, in particular qemu-user-static, provides a practical way to execute binaries built for a foreign architecture directly on a host system. This capability is especially valuable in cross-compilation workflows, where executing target binaries is otherwise not possible.

In a traditional cross-compilation setup, test binaries produced during the build process cannot be executed because they target a different architecture than the host. This limitation affects both:

  • Test suites (unit and integration tests)
  • Build-time probing, such as those performed by GNU Autoconf

By integrating QEMU user-mode with the Linux binfmt_misc mechanism, foreign binaries can be executed transparently. When combined with a target root filesystem (sysroot), this allows:

  • Running test suites for the target architecture without dedicated hardware
  • Executing Autotools-generated probe binaries, enabling runtime checks (e.g. via AC_RUN_IFELSE) that would otherwise fail during cross-compilation

As a result, builds can behave similarly to native builds, reducing the need for manual overrides such as cached configuration variables.

Emulation Performance and setup Considerations

Execution under QEMU user-mode is generally slower than native execution due to instruction translation. However:

  • The overhead is often acceptable for build-time testing.
  • In some cases, the difference is negligible compared to overall build time.
  • When targeting inherently slower architectures, the perceived slowdown may be less significant, and in some constrained comparisons the host may still outperform real hardware.

Despite this, performance-sensitive or timing-dependent tests may exhibit different behavior under emulation.

While QEMU simplifies execution, it introduces additional setup requirements:

Configuration of binfmt_misc to associate foreign binaries with the appropriate QEMU interpreter Provision of a complete target root filesystem, including: * Dynamic linker * Shared libraries * Correct filesystem layout

This effectively shifts complexity from build configuration (e.g. Autotools cross-compilation issues) to environment preparation.

Summary of Cross vs Native build

This is a comparison synthesis between the two approaches. No judgement is given by choice, native vs build is a religion, and I don’t want to be hostile to any.

Issue Native Synthetic Cross Notes
Artifact Reproduciblity Reproducible = Reproducible Both build methods can lead to reproducible artifacts.
Configuration problems Mature <== Minor problems exist Historically Autotool is using host execution go guess the configuration. In cross environment this is not possible, butnewer versions of autotool support cross build enviroment through caching. Other approaches use qemu-user-static to make some complicated autotool setup work on cross environments.
Binary Artifacts Quality Same = Same If setup correctly artifact can be bitwise identical.
Build Enviroment Pollution Bad Setup = Bad Setup Not a Cross build specific issue. it is more a sysroot isolation problem. though, failing this, can have worse consequences in Native than cross.
Build Product Testing Easy <== Complex Test a build product, means executes it under some conditions. Native enviroment execute the artifact is trivial, in cross enviroment it is not, unless you cheat with qemu-user-static.
Build performance (speed) Variable ==> Optimal It is a fact, some minor architectures are slow. Iot devices can target slow architectures where build can be challenging.
Per architecture builder Issues ==> none A Linux distro targeting many architecures using Native compilation, needs to have at least a worker per architecure, and this scales bad.

Linux build systems overview

As last step, lets see ta some of more common build system that distro uses to generate linux.

System / Tool Used by Native / Cross Core tech Notes
Koji Fedora, RHEL, CentOS Native (per-arch workers) RPM, Mock, chroot, distributed scheduler Centralized build farm; builds SRPMs on target-arch machines
Mock Fedora/RHEL (via Koji) Native (isolated) chroot, RPM Creates clean build roots; prevents host contamination
Open Build Service openSUSE, SLE Mostly native (multi-arch workers), supports cross RPM/DEB, chroot, distributed builds Public multi-distro build platform
Debian buildd network Debian Native dpkg, debhelper, buildd daemons Distributed autobuilders per architecture
Launchpad Ubuntu Native dpkg, chroot/containers CI + packaging + build farm
Arch Build System Arch Linux Native (mostly) PKGBUILD, makepkg Simple recipe-based builds; less centralized
Portage Gentoo Native (user builds), supports cross ebuild, bash Source-based; builds happen on user machine
Buildroot Embedded (various) Cross (primary) Make, Kconfig Builds full rootfs + toolchain; minimal, deterministic
Yocto Project / BitBake Embedded, industrial distros Cross (primary) BitBake, metadata layers Highly configurable, large-scale embedded builds
OpenWrt build system OpenWrt Cross Make, custom infra Router-focused; cross toolchains + package feeds
Nix NixOS, others Hybrid (native + cross) functional DSL, isolated store Strong reproducibility model
GNU Guix Guix System Hybrid (native + cross) Scheme, functional builds Similar to Nix, emphasizes purity

How’s the future?

Newer ecosystems like Rust or Go make cross-compilation feel almost… reasonable.

They remove a lot of the historical friction, but they don’t change the fundamentals. You still need to control your environment, your dependencies, and your assumptions. The difference is that now the toolchain fails less creatively.

Native vs cross is still a debate. The difference is that now we have better tools… and the same old problems.

Tuesday, March 10, 2026

When a Homelab Migration Awakens Your Inner Networker

My blog has never been particularly homogeneous. This post is probably weirder than usual.

These days most people know me for work around Linux kernel safety and the Elisa Community. That’s the professional side. But like many engineers, I also have a homelab where I experiment, break things, and occasionally learn something useful while pretending it’s just procrastination.

This story comes from when I decided my network needed an upgrade.

And it unexpectedly reminded me why networking used to be my field in the first place.

The Pascal Incident

To explain why I even cared about this migration, we need to go back a bit.

I originally started as a Assembly-Pascal programmer.

In 1997 I had a small disagreement with my computer architecture professor about whether Pascal could be used for low-level work.

Naturally, the only rational response was to write an arcade machine emulator in Pascal.

This one: a Space Invaders emulator. Which I proudly demonstrated to the professor. I still remember that conversation.

Me: Hey professor, look what I built using Pascal. If used properly, you don’t actually need C.

To be fair, this was only partially true. I did use the Pascal compiler to build the executable, but most of the low-level work ended up written as large blocks of inline assembly inside the Pascal source. Anyway, I showed it to the professor.

The professor watched the screen for a moment.

Professor: Well yes. Nice game.

That was not the reaction I expected.

Me: Look, I didn’t make this game. Someone in Japan did… back in 1978.

At that point the professor looked mildly annoyed.

Professor: Then what exactly are you showing me today?

Which honestly left me unarmed.

I would have liked to answer: the emulator, you idiot… But I didn’t. For the record it neither improved my grade nor impressed the professor. But the experiment left me with a fascination for understanding systems by emulating them. Which later led to another emulator.

Instead of arcade hardware, I tried to emulate a Cisco router.

That project became my computer engineering master’s thesis: a Cisco 2500 router emulator, again written in Pascal… the last time before burying it forever.

Reverse engineering parts of the router firmware to understand the hardware architecture forced me to dive into routing logic…

That was basically my accidental entry point into network engineering. So yes, my networking career technically started with a router emulator written in Pascal. Which probably explains a few things.

Back to the Homelab

Fast forward a few decades.

The task that triggered this story looked simple enough:

Replace an old Cisco 3560, sitting in my lab and running almost continuously for 15 years with something newer… a Ruckus ICX-7150, and finally enter the gigabit era.

Which mostly meant replacing one relic with a slightly younger relic. The 3560 was already ancient. The ICX-7150 is not ancient yet… but it’s already end-of-sale, so it’s clearly on the right trajectory.

At first glance the CLI felt familiar. Cisco and Ruckus commands look similar enough that you instinctively think:

“Ah yes. I know exactly what this does.”

That confidence usually lasts about five minutes. After that you discover that while the commands look similar, the behavior is not. Part of the reason is historical.

This network used to be entirely Cisco. Phones, switches, everything. In that environment life was easy. Phones learned their VLAN via CDP, everything powered correctly, and nobody asked too many questions.

The moment I started replacing pieces, I discovered which parts of that convenience were standards, and which parts were simply Cisco assumptions.

Some of that I expected. Some of it I discovered the hard way.

The Troubleshooting Story

The setup was simple:

  • Cisco 7940 phones
  • SCCP firmware
  • Asterisk
  • configuration via TFTP
  • DHCP instead of CDP

The goal was to run the phones in a vendor-neutral network.

Initially everything worked. Phones booted and registered. Then they started doing something strange. They would run normally for a while…

disconnect… then reconnect.

Phone logs showed events like:

Code:8103

Packet captures showed:

  • SCCP keepalive retransmissions
  • TCP resets
  • reconnect cycles

Then another symptom appeared: occasionally the phone did not respond to ARP requests. That pointed suspicion directly at the phone. Old 79xx firmware is known to be… quirky. So the investigation went there first.

The Firmware Detour

The phones were running:

P00308000100

Which triggered the usual Cisco firmware archaeology. Finding firmware for older Cisco devices is a bit like looking for historical artifacts: you know it exists, you’re just not sure where.

Eventually a newer firmware image was located and installed.

The upgrade worked… And the problem remained.

So the firmware theory collapsed.

At this point the investigation had already consumed a fair amount of time… and it was still focused on the phone.

The Turning Point

The turning point came after a longer packet capture session. Something about the ARP traffic looked wrong. So the next step was obvious: check what the switch actually saw. A mirror session was configured on the ICX and the phone port was captured.

The mirrored traffic showed:

  • unicast traffic on VLAN 2
  • broadcast traffic from the phone

But something was missing.

The ARP broadcast from Asterisk never appeared. To confirm this, traffic was captured in several places:

  • the ICX mirror
  • a VLAN-2 host
  • a Cisco mirror upstream

The result was consistent. The ARP broadcast existed on the Cisco side, but never reached the ICX access port. At that moment the investigation pivoted. This was no longer a phone problem.

It was a switch problem.

Finding the Culprit

To confirm the path, the Cisco 3560 uplink was mirrored. The ARP request was clearly visible leaving the Cisco trunk:

Who has 10.1.5.57? Tell 10.1.5.1

But the broadcast never appeared on the ICX.

The packet was disappearing between the switches.

Attention turned to the ICX uplink configuration:

vlan 2 tagged ethernet 1/2/2 ! interface ethernet 1/2/2 dual-mode

VLAN configuration looked correct: VLAN 2 tagged 1/2/2

Various things were tested:

  • VLAN membership
  • DHCP snooping
  • spanning tree
  • broadcast filtering

Nothing changed.

The Fix

Eventually attention returned to one small detail on the ICX uplink:

interface ethernet 1/2/2 dual-mode

In FastIron (Brocade/Ruckus) this is roughly equivalent to the native VLAN on a Cisco trunk.

And in fact, if you come from a Ruckus-native background, that line probably would not raise any alarms at all.

The only reason it caught my attention is precisely because I wasn’t thinking like a Ruckus engineer.

So, mostly out of prejudice rather than conviction, I tried removing it.

Immediately:

  • ARP broadcasts appeared
  • VLAN-2 hosts received them
  • the phone stopped disconnecting

The note I wrote at the time was simply:

was that! no dual-mode on 1/2/2 solved the problem.

And that closed the investigation.

Which strongly suggests the real root cause was not the configuration itself, but likely a firmware bug in the switch triggered by that combination of trunking behavior.

NOTE: On Brocade/Ruckus FastIron switches, the dual-mode command is used to allow a port that carries tagged VLANs to also accept and transmit untagged traffic. Unlike Cisco trunks, which always include a native VLAN by default, a FastIron port with tagged VLANs normally sends only tagged frames unless dual-mode is enabled. The command itself takes no arguments and has remained syntactically stable across FastIron versions; it simply enables the ability for the port to carry untagged frames. The actual native (PVID) VLAN is determined separately by the port’s untagged VLAN membership, which can be changed under the interface with vlan-config move untagged vlan <vid>. For engineers familiar with Cisco, the combination of dual-mode plus the port’s untagged VLAN is functionally equivalent to configuring a native VLAN on a trunk.

A Note About AI

One final note.

I have been skeptical about AI tools for quite a while and avoided using them in my workflow.

This debugging session, and a few others, are changing my mind a bit.

The AI suggested ICX commands I didn’t know, reminded me of Cisco syntax I had not used in years, and helped narrow the search space.

Without that assistance I would probably have spent days digging through documentation.

Instead the whole problem unraveled in one night.

Which, for a homelab experiment, is an excellent outcome; and significantly better than spending a weekend reading switch manuals.

Friday, February 6, 2026

Cheri & Fil-C, are they the C memory-safe solution?

Looking at the past

Pointers Were Never Meant to Be Just Numbers

(And C Didn’t Kill That Idea… We Did)

Ask a C programmer what a pointer is, and you’ll almost certainly hear:

“It’s just a memory address.”

That statement is true in practice, but historically wrong.

The idea that a pointer is “just a number” is not a law of computing. It is the result of a long chain of economic and engineering decisions that happened to align in the 1970s and 1980s.

Before that, and increasingly again today, a pointer was understood as something richer: a reference with rules, a capability, a guarded object.

And crucially: C did not invent flat memory. It merely adapted itself to it extremely well.

Before C: When Pointers Carried Meaning

Early computer systems had no illusion that memory access was harmless.

Machines such as the Burroughs B5000, the Unisys 1100/2200 series, and later the Lisp Machines all treated pointers as structured entities:

  • bounds-checked
  • tagged
  • validated by hardware
  • often unforgeable

A pointer was not an integer you could increment freely. It was closer to a capability, a permission to access a specific object.

This wasn’t academic purity. It was a necessity:

  • multi-user systems
  • shared memory
  • batch scheduling
  • safety over speed

These machines enforced correctness by design.

C Did Not “Flatten” Memory… It Adapted to It

It’s tempting to say:

“C introduced flat memory and unsafe pointers.”

That’s not quite true.

C was designed on the PDP-11, a machine that already had:

  • a flat address space
  • no hardware memory tagging
  • no segmentation protection at the language level

C didn’t invent this model… It embraced it.

But here’s the key point that often gets missed:

C was explicitly designed to be portable across architectures with very different memory models.

And that includes machines that did not have flat memory.

C on Non-Flat Architectures: The Forgotten Chapter

C was successfully implemented on:

  • segmented machines
  • descriptor-based systems
  • capability-like architectures

Including Unisys systems, where pointers were not simple integers.

As documented in historical work (and summarized well in begriffs.com – “C Portability”), early C compilers adapted to the host architecture rather than forcing a universal memory model.

On Unisys systems:

  • pointers were implemented using descriptors
  • arithmetic was constrained
  • bounds and access rules were enforced by hardware
  • the compiler handled translation

This worked because the C standard never required pointers to be raw addresses.

It required:

  • comparability
  • dereferenceability
  • consistent behavior

Not bit-level identity.

Even Henry Rabinowitz warned, in Portable C, that assuming pointer arithmetic behaved like integer arithmetic was already non-portable, even in the late 1980s.

So what changed?

The Real Shift: Economics, Not Language Design

The shift didn’t come from C.

It came from:

  • cheap RAM
  • fast CPUs
  • simple pipelines
  • RISC philosophy
  • UNIX portability

Flat memory was faster to implement and easier to optimize.

Once x86 and similar architectures dominated, the hardware stopped enforcing:

  • bounds
  • provenance
  • validity

And since C mapped perfectly onto that model, it became the dominant systems language.

From that point on:

  • pointers became integers
  • safety became a software problem
  • memory bugs became a security industry

Not because C demanded it, but because the hardware no longer helped.

The Long Detour We Are Now Undoing

For decades, the industry tried to patch this with:

  • ASLR
  • stack canaries
  • DEP
  • sanitizers
  • fuzzers

All useful. None fundamental.

They treat symptoms, not causes.

Which brings us back, full circle, to the idea that started this story:

A pointer should carry metadata.

A Short Detour: Even x86 Wasn’t Always Flat

Before moving forward, it’s worth correcting one more common simplification.

Even the architecture most associated with “flat pointers”, x86, did not start that way.

In real mode, x86 used segmented addressing:

physical_address = segment × 16 + offset

This meant:

  • pointers were effectively split into two components
  • address calculation wasn’t trivial
  • different segments could overlap
  • the same physical memory could be referenced in multiple ways

It wasn’t a capability system, there were no bounds or permissions, but it was a reminder that pointer arithmetic was never universally “just an integer add.”

What changed wasn’t the hardware’s ability to support structure.

What changed was that:

  • segmentation was seen as inconvenient
  • flat addressing was faster
  • compilers and operating systems optimized for simplicity

By the time protected mode and later 64-bit mode arrived, segmentation had been mostly sidelined. The industry standardized on:

Flat memory + software discipline

That decision stuck.

And that’s the world CHERI and FIL-C are now challenging.

Are CHERI and FIL-C fixing the problem?

The Common Idea Behind CHERI and FIL-C

At first glance, CHERI and FIL-C look very different.

One changes the CPU. The other changes the compiler.

But conceptually, they start from exactly the same premise:

A pointer is not an address. A pointer is authority.

Everything else follows from that.

The Shared Heritage: Capability Thinking

Both CHERI and FIL-C descend from the same historical lineage:

  • Burroughs descriptors
  • Lisp machine object references
  • Capability-based operating systems
  • Hardware-enforced segmentation

The core idea is simple:

A program should only be able to access memory it was explicitly given access to.

That means a pointer must carry:

  • where it points
  • how far it can go
  • what it is allowed to do
  • whether it is still valid

In other words: metadata.

The only real disagreement between CHERI and FIL-C is where that metadata lives.

Cheri: Making Capabilities a Hardware Primitive

CHERI takes the most direct route possible.

It says:

“If pointers are capabilities, the CPU should understand them.”

So CHERI extends the architecture itself.

A CHERI pointer (capability) contains:

  • an address (cursor)
  • bounds
  • permissions
  • a validity tag

The tag is critical:

  • it is stored out-of-band
  • it cannot be forged
  • it is cleared automatically if memory is corrupted
  • the CPU refuses to dereference invalid capabilities

This means:

  • no buffer overflows
  • no out-of-bounds accesses
  • no forged pointers
  • no accidental privilege escalation

And all of this happens without software checks.

The hardware enforces it.

This is not “fat pointers” in the C++ sense. This is architectural memory safety.

Importantly, CHERI preserves C semantics:

  • pointers still look like pointers
  • code still compiles
  • performance is predictable

But the machine simply refuses to execute illegal memory operations.

It’s the return of the capability machine, this time built with modern CPUs, caches, and toolchains.

By design, CHERI enforces only what can reasonably belong to the instruction set, leaving higher-level memory semantics to software.

FIL-C: Capability Semantics Through Compiler and Runtime

FIL-C starts from the same premise:

“C pointers need metadata.”

But instead of changing the hardware, it changes the compiler and runtime.

This choice allows FIL-C to enforce stronger guarantees than CHERI, but at the cost of changing not only pointer representation, but also object lifetime and allocation semantics.

In FIL-C:

  • pointers become InvisiCaps
  • bounds are tracked invisibly
  • provenance is preserved
  • invalid accesses trap

From the programmer’s point of view:

  • it’s still C
  • code still compiles
  • the ABI mostly stays intact

From the runtime’s point of view:

  • every pointer has hidden structure
  • every access is validated
  • dangling pointers are detected

FIL-C and CHERI start from the same idea: pointers as capabilities, but deliberately apply it at very different semantic depths.

At this point, the similarity ends.

While CHERI limits itself to enforcing spatial safety and capability integrity, FIL-C necessarily goes further. In order to provide temporal safety, FIL-C must change the object lifetime model itself.

In FIL-C, deallocation is decoupled from object lifetime: freed objects are often quarantined, delayed, or kept alive by a garbage collector. Memory reuse is intentionally conservative, because temporal safety cannot coexist with eager reuse.

This is not an implementation choice but a semantic requirement, and it has consequences that go well beyond pointer representation.

The Reality Check: It’s a Runtime, Not Just a Compiler

It is important to distinguish FIL-C from a simple “safe compiler”. Because it enforces temporal safety via an object lifetime model, it must bypass standard allocators like glibc. This means the high-performance concurrency optimizations (arenas, caches) that developers expect are gone, replaced by a managed runtime.

Furthermore, because FIL-C requires this complex runtime support, it is currently a user-space tool. Using it to build, for example, a kernel like Linux is architecturally unfeasible.

For standard userspace applications, the trade-offs can be summarized as follows:

Aspect CHERI FIL-C
Enforcement Hardware Software
Pointer metadata In registers & memory In runtime structures
Performance 2%-5% overhead 50%–200% overhead
Deployment Requires new hardware Works today

The higher cost of FIL-C is not primarily due to pointer checks, but to the changes required in allocation, lifetime management, and runtime semantics to enforce temporal safety.

CHERI makes the CPU safe. FIL-C makes the language safe.

Same Idea, Two Execution Models

CHERI and FIL-C are not competitors. They are two implementations of the same philosophy.

They both assert that:

  • pointer provenance matters
  • bounds must be enforced
  • safety must be deterministic
  • memory errors are architectural, not stylistic

They differ only in where that logic lives.

You can think of it this way:

  • CHERI -> capabilities in silicon
  • FIL-C -> capabilities in software
  • MTE -> capabilities with probabilities

Different tradeoffs. Same destination.

Why This Matters Now: The ELISA Perspective

At first glance, CHERI and FIL-C may look like academic exercises or long-term research projects. But their relevance becomes much clearer when viewed through the lens of ELISA.

ELISA exists for a very specific reason: Linux is increasingly used in safety-critical systems.

That includes:

  • automotive controllers
  • industrial automation
  • medical devices
  • aerospace and avionics
  • robotics and energy infrastructure

And Linux, for all its strengths, is still fundamentally:

A large C codebase running on hardware that does not enforce memory safety.

The Core Tension ELISA Faces

ELISA’s mission is not to redesign Linux.

It is to:

  • make Linux usable in safety-critical contexts
  • support certification efforts (ISO 26262, IEC 61508, etc.)
  • improve predictability, traceability, and robustness
  • do this without rewriting the kernel

That creates a fundamental tension:

  • Linux is written in C
  • C assumes unsafe pointers
  • Safety standards assume bounded, analyzable behavior

Most current ELISA work focuses on:

  • process isolation
  • static analysis
  • restricted subsets of C
  • coding guidelines
  • runtime monitoring
  • testing and verification

All valuable. All necessary.

But none of them change the underlying truth:

The C memory model is still unsafe by construction.

Why Cheri and FIL-C Enter the Conversation

CHERI and FIL-C do not propose rewriting Linux.

They propose something more subtle:

Making the existing C code mean something safer.

This matters because they address a layer below where most safety work happens today.

Instead of asking:

  • “Did the developer write correct code?”

They ask:

  • “Can the machine even express an invalid access?”

That’s a fundamentally different approach.

CHERI in the ELISA Context

CHERI is interesting to safety engineers because:

  • It enforces memory safety in hardware
  • Violations become deterministic faults, not undefined behavior
  • It supports fine-grained compartmentalization
  • It aligns well with safety certification principles

But CHERI is also realistic about its scope:

  • It requires new hardware
  • It requires a new ABI
  • It is not something you “turn on” in existing systems

Which means:

Cheri is not a short-term solution for ELISA, but it is a reference model for what correct looks like.

It provides a concrete answer to the question: “What would a memory-safe Linux look like if we could redesign the hardware?”

FIL-C in the ELISA Context

FIL-C sits at the opposite end of the spectrum.

It:

  • runs on existing hardware
  • keeps the C language
  • enforces safety at runtime
  • integrates with current toolchains

This makes it immediately relevant as:

  • a verification tool
  • a debugging platform
  • a reference implementation of memory safety
  • a way to experiment with safety properties on real code

But it also comes with trade-offs:

  • performance overhead
  • increased memory usage
  • reliance on runtime checks

So again, not a drop-in replacement, but a valuable experimental lens.

The Direction Is Clear (Even If the Path Is Long)

The real contribution of CHERI and FIL-C is not that they make C safer by rewriting it.

It is that they show memory safety can be improved by changing the semantics of pointers, while leaving existing code largely untouched.

This distinction matters.

Large systems like Linux cannot realistically be rewritten. Their value lies in the fact that they already exist, have been validated in the field, and continue to evolve. Any approach that requires wholesale code changes, new languages, or a redesigned programming model is unlikely to be adopted.

CHERI and FIL-C take a different approach. They act below the source level:

  • redefining what a pointer is allowed to represent
  • enforcing additional semantics outside application logic
  • turning undefined behavior into deterministic failure

In doing so, they demonstrate that memory safety can be introduced beneath existing software, rather than imposed on top of it.

That insight is more important than either implementation.

It shows that the path forward for Linux safety does not necessarily run through rewriting code, but through reintroducing explicit authority, bounds, and permissions into the way memory is accessed, even if this is done incrementally and imperfectly.

Looking Forward

Neither CHERI nor FIL-C is something Linux will adopt tomorrow.

CHERI depends on hardware that is not yet widely available and will inevitably influence ABIs, compilers, and toolchains. FIL-C works on current hardware, but with overheads that limit its use to specific contexts.

What they offer is not an immediate solution, but a reference direction.

They suggest that meaningful improvements to Linux safety are possible if we focus on:

  • enforcing memory permissions more precisely
  • narrowing the authority granted by pointers
  • moving checks closer to the hardware or runtime
  • minimizing required changes to existing code

This leaves room for intermediate approaches: solutions that do not redefine the language, but instead use existing mechanisms, such as the MMU, permission models, and controlled changes to pointer usage, to incrementally reduce the scope of memory errors.

In that sense, CHERI and FIL-C are less about what to deploy next and more about what properties future solutions must have.

They help clarify the goal: make memory access explicit, bounded, and enforceable… without rewriting Linux to get there.

Sunday, January 25, 2026

When Clever Hardware Hacks Bite Back: A Password Keeper Device Autopsy

Or: how I built a USB password keeper that mostly worked, sometimes lied, and taught me more than any success ever did.

I recently found these project files buried in a folder titled “Never Again.” At first, I thought they didn’t deserve a blog post. Mostly because the device has a mind of its own, it works perfectly when I’m just showing it off, but reliably develops stage fright the moment I actually need to log in. This little monster made it all the way to revision 7 of the PCB. I finally decided to archive the project after adding a Schmitt trigger : the component that was mathematically, logically, and spiritually supposed to solve the debouncing issues and save the day.

Spoiler: it didn’t.

Instead of a revolutionary security device, I ended up with a zero-cost, high-frustration random number generator built from whatever was lying in my junk drawer. It occasionally types my password correctly, provided the moon is in the right phase and I don’t look at it too directly. And yet… here we are.

The Idea That Seemed Reasonable at the Time

A long time ago, when “password manager” still meant a text file named passwords.txt, I had what felt like a good idea:

Build a tiny device that types passwords for me.

No drivers. No software installation. Just plug it in, press a button, and it types the password like a keyboard. From a security point of view, it sounded brilliant:

  • The OS already trusts keyboards
  • No clipboard
  • No background process
  • No software attack surface If it only types, it can’t be hacked… right?

(Yes. That sentence aged badly.)

Constraints That Created the Monster

This was not a commercial project. This was a “use what’s on the desk” project.

So the constraints were self-inflicted:

  • MCU: ATtiny85 (cheap, tiny, limited)
  • Display: HD44780 (old, everywhere, slow)
  • USB: bitbanged (no hardware USB)
  • GPIOs: basically none
  • PCB: single-sided, etched at home
  • Budget: close to zero

The only thing I had plenty of was optimism.

Driving an LCD With One Pin (Yes, Really)

The first problem: The ATtiny85 simply does not have enough pins to drive an HD44780 display.

Even in 4-bit mode, the display wants more pins than I could spare. So I did what any reasonable person would do:

I multiplexed everything through one GPIO using RC timing.

By carefully choosing resistor and capacitor values, I could:

  • Encode clock, data, and select signals
  • Decode them on a 74HC595
  • Drive the display using time-based signaling

It worked. Mostly. But it was also:

  • Sensitive to temperature
  • Sensitive to component tolerances
  • Sensitive to how long the board had been powered on
  • Fundamentally analog pretending to be digital

Lesson #1:

If your protocol depends on analog behavior, you don’t really control it.

Abusing USB HID for Fun and (Almost) Profit

This is the part I still like the most.

The problem A USB

keyboard is input-only. You can’t send data to it.

So how do you update the password database?

The bad idea

Use the keyboard LEDs.

  • Caps Lock
  • Num Lock
  • Scroll Lock

They’re controlled by the host. And yes: you can read them from firmware.

The result

I implemented a synchronous serial protocol over HID LEDs.

  • Clocked
  • Deterministic
  • Host-driven
  • No timing guessing
  • No race conditions

And surprisingly: This was the most reliable part of the whole project. It was slow, sure. But passwords are small. And since the clock came from the host, it was rock solid.

Lesson #2:

The ugliest hack is sometimes the most reliable one.

The Part Nobody Warns You About: Scancodes

The update tool was a small Linux application that sent password data to the device.

Here’s the catch:

Keyboards don’t send ASCII. They send scancodes. And:

  • PS/2 scancodes ≠ USB HID scancodes
  • Layout matters
  • Locale matters
  • Shift state matters

So the database wasn’t a list of characters. It was a list of HID scancodes.

That means:

  • The device was layout-dependent
  • The database was architecture-dependent
  • Portability was not free

This is one of those details nobody tells you until you trip over it yourself.

Lesson #3:

Text is an illusion. Keyboards don’t speak ASCII.

The USB Problem I Couldn’t Outsmart

Now for the real failure. The ATtiny85 has no USB hardware.

So USB had to be:

  • Bitbanged
  • Cycle-perfect
  • Timed in software
  • Extremely sensitive to clock drift

Sometimes it worked. Sometimes it didn’t enumerate. Sometimes it worked once and never again. Sometimes it depended on the USB host.

This wasn’t a bug. This was physics.

Lesson #4:

USB is not forgiving, and bitbanging it is an act of optimism.

The Hardware (Yes, It Actually Exists)

Despite everything:

  • I built two physical units
  • I etched the PCB myself (single-sided)
  • I assembled them by hand
  • They worked... Most of the time.

I still have them. They still boot. Sometimes.


Repository Structure (For the Curious)

The project is split into three parts:

Repository Structure (For PCB & Schematics)

  • Single-layer board
  • Home-etched
  • All compromises visible
  • No hiding from physics

Host-Side Tool

  • Linux-based
  • Sends HID scancodes
  • Talks to the device via LED protocol
  • No ASCII anywhere

Firmware

  • Arduino-based
  • Third-party bootloader
  • USB bitbanging
  • Display driving
  • HID handling

What Actually Failed (and What Didn’t)

Failed

  • USB reliability
  • Display robustness
  • Timing assumptions
  • Environmental tolerance

Worked

  • HID LED protocol
  • Password logic
  • Conceptual design
  • Learning value

The irony

  • The part that looked insane... worked.
  • The part that looked standard... didn’t.

Wednesday, January 14, 2026

hc: an agentless, multi-tenant shell history sink (because you will forget that command)

For a long time, my daily workflow looked like this:
SSH into a server… do something clever… forget it… SSH into another server… regret everything.

I work in an environment where servers are borrowed from a pool. You get one, you use it, and sooner or later you give it back. This sounds efficient, but it creates a very specific kind of pain: every time I landed on a “new” machine, all my carefully crafted commands in the history were gone.

And of course, the command I needed was that one. The long one. The ugly one. The one that worked only once, three months ago, at 2 a.m.

A configuration management tool could probably handle this. In theory. But my reality is a bit messier.

The servers I use are usually borrowed, automatically installed, and destined to disappear again. I didn’t want to “improve” them by leaving behind shell glue and half-forgotten tweaks. Not because someone might reuse them, but because someone else would have to clean them up.

On top of that, many of these machines live behind VPNs that really don’t want to talk to the outside world or the collector living in my home lab. If SSH works, I’m happy. If it needs anything more than that, it’s already too much.

I wanted something different:

  • no agent
  • no permanent changes
  • no files left behind
  • no assumptions about the remote network

In short: leave no trace.

How hc was born

This is how hc (History Collector) started.

The very first version was a small netcat hack in 2023. It worked… barely. But the idea behind it was solid, so I kept iterating. Eventually, it grew into a proper Go service with a SQL backend… (Postgres for today)

The core idea of hc is simple:

The remote machine should not need to know anything about the collector.

No agent. No configuration file. No outbound connectivity.
Instead, the trick is an SSH reverse tunnel.

From my laptop, I open an SSH session like this:

  • a reverse tunnel exposes a local port on the remote machine
  • that port points back to my hc service
  • from the remote shell’s point of view, the collector is just 127.0.0.1

This was the “aha!” moment.

Because the destination is always localhost, the injected logging payload is always identical, no matter which server I connect to. The shell doesn’t know it’s talking to a central service… and it doesn’t care.


Injecting history without leaving scars

When I connect, I inject a small shell payload before starting the interactive session. This payload: - generates a session ID - defines helper functions - installs a PROMPT_COMMAND hook - forwards command history through the tunnel

Nothing is written to disk. When the SSH session ends, everything disappears.

A typical ingested line looks like this:

20240101.120305 - a1b2c3d4 - host.example.com [cwd=/root] > ls -la

This tells me:

  • when the command ran
  • from which host
  • in which directory
  • and what I actually typed

It turns out this is surprisingly useful when you manage many machines and your memory is… optimistic.

Minimal ingestion, flexible transport

hc is intentionally boring when it comes to ingestion… and I mean that as a compliment.

On the client side, it’s just standard Unix plumbing:

  • nc for plaintext logging on trusted networks
  • socat for TLS when you need encryption

No custom protocol, no magic framing. Just lines over a pipe.

This also makes debugging very easy. If something breaks, you can literally cat the traffic.

Multi-tenancy without leaking secrets

Security became more important as hc grew.

I wanted one collector, multiple users, and no accidental data mixing. hc supports:

  • TLS client certificates
  • API keys

For API keys, I chose a slightly unusual format:

]apikey[key.secret]

The server detects this pattern in memory, uses it to identify the tenant, and then removes it immediately. The stripped command is what gets stored, both in the database and in the append-only spool.

This way: - secrets never hit disk - grep output never leaks credentials - logs stay safe to share

Searching is a different problem (and that’s good)

Ingestion and retrieval are intentionally separate.

When I want to find a command, hc exposes a simple HTTP(S) GET endpoint. I deliberately chose GET instead of POST because it plays nicely with the Unix philosophy.

Example:

wget \ --header="Authorization: Bearer my_key" \ "https://hc.example.com/export?grep1=docker&color=always" \ -O - | grep prune

This feels natural. hc becomes just another tool in the pipeline.

Shell archaeology: BusyBox, ash, and PS1 tricks

Working on hc also sent me down some unexpected rabbit holes.

For example: BusyBox ash doesn’t support PROMPT_COMMAND. Last year, I shared a workaround on Hacker News that required patching the shell at source level.

Then a user named tyingq showed me something clever:
you can embed runtime-evaluated expressions inside PS1, like:

PS1="\$(date) $ "

That expression is executed every time the prompt is rendered.

I’m currently experimenting with this approach to replace my previous patching strategy. If it works well enough, hc moves one step closer to being truly zero-artifact on every shell.

Where to find it (and what’s next)

You can find the source code, and BusyBox research notes.

Right now, I’m working on:

  • a SQLite backend for single-user setups
  • more shell compatibility testing
  • better documentation around

injection payloads

If you have opinions about:

  • the ]apikey[ stripping logic
  • using PS1 for high-volume logging
  • or weird shells I should test next

…I’d genuinely love to hear them.

Sunday, September 14, 2025

Schrödinger’s test: The /dev/mem case

Why I Went Down This Rabbit Hole

Back in 1993, when Linux 0.99.14 was released, /dev/mem made perfect sense. Computers were simpler, physical memory was measured in megabytes, and security basically boiled down to: “Don’t run untrusted programs.”

Fast-forward to today. We have gigabytes (or terabytes!) of RAM, multi-layered virtualization, and strict security requirements… And /dev/mem is still here, quietly sitting in the kernel, practically unchanged… A fossil from a different era. It’s incredibly powerful, terrifyingly dangerous, and absolutely fascinating.

My work on /dev/mem is part of a bigger effort by the ELISA Architecture working group, whose mission is to improve Linux kernel documentation and testing. This project is a small pilot in a broader campaign: build tests for old, fundamental pieces of the kernel that everyone depends on but few dare to touch.

In a previous blog post, “When kernel comments get weird”, I dug into the /dev/mem source code and traced its history, uncovering quirky comments and code paths that date back decades. That post was about exploration. This one is about action: turning that historical understanding into concrete tests to verify that /dev/mem behaves correctly… Without crashing the very systems those tests run on.

What /dev/mem Is and Why It Matters

/dev/mem is a character device that exposes physical memory directly to userspace. Open it like a file, and you can read or write raw physical addresses: no page tables, no virtual memory abstractions, just the real thing.

Why is this powerful? Because it lets you:

  • Peek at firmware data structures,
  • Poke device registers directly,
  • Explore memory layouts normally hidden from userspace.

It’s like being handed the keys to the kingdom… and also a grenade, with the pin halfway pulled.

A single careless write to /dev/mem can:

  • Crash the kernel,
  • Corrupt hardware state,
  • Or make your computer behave like a very expensive paperweight.

For me, that danger is exactly why this project matters. Testing /dev/mem itself is tricky: the tests must prove the driver works, without accidentally nuking the machine they run on.

STRICT_DEVMEM and Real-Mode Legacy

One of the first landmines you encounter with /dev/mem is the kernel configuration option STRICT_DEVMEM.

Think of it as a global policy switch:

  • If disabled, /dev/mem lets privileged userspace access almost any physical address: kernel RAM, device registers, firmware areas, you name it.
  • If enabled, the kernel filters which physical ranges are accessible through /dev/mem. Typically, it only permits access to low legacy regions, like the first megabyte of memory where real-mode BIOS and firmware tables traditionally live, while blocking everything else.

Why does this matter? Some very old software, like emulators for DOS or BIOS tools, still expects to peek and poke those legacy addresses as if running on bare metal. STRICT_DEVMEM exists so those programs can still work: but without giving them carte blanche access to all memory.

So when you’re testing /dev/mem, the presence (or absence) of STRICT_DEVMEM completely changes what your test can do. With it disabled, /dev/mem is a wild west. With it enabled, only a small, carefully whitelisted subset of memory is exposed.

A Quick Note on Architecture Differences

While /dev/mem always exposes what the kernel considers physical memory, the definition of physical itself can differ across architectures. For example, on x86, physical addresses are the real hardware addresses. On aarch64 with virtualization or secure firmware, EL1 may only see a subset of memory through a translated view, controlled by EL2 or EL3.

The main function that the STRICT_DEVMEM kernel configuration option provides in Linux is to filter and restrict access to physical memory addresses via /dev/mem. It controls which physical address ranges can be legitimately accessed from userspace by helping implement architecture-specific rules to prevent unsafe or insecure memory accesses.

32-Bit Systems and the Mystery of High Memory

On most systems, the kernel needs a direct way to access physical memory. To make that fast, it keeps a linear mapping: a simple, one-to-one correspondence between physical addresses and a range of kernel virtual addresses. If the kernel wants to read physical address 0x00100000, it just uses a fixed offset, like PAGE_OFFSET + 0x00100000. Easy and efficient.

But there’s a catch on 32-bit kernels: The kernel’s entire virtual address space is only 4 GB, and it has to share that with userspace. By convention, 3 GB is given to userspace, and 1 GB is reserved for the kernel, which includes its linear mapping.

Now here comes the tricky part: Physical RAM can easily exceed 1 GB. The kernel can’t linearly map all of it: there just isn’t enough virtual address space.

The extra memory beyond the first gigabyte is called highmem (short for high memory). Unlike the low 1 GB, which is always mapped, highmem pages are mapped temporarily, on demand, whenever the kernel needs them.

Why this matters for /dev/mem: /dev/mem depends on the permanent linear mapping to expose physical addresses. Highmem pages aren’t permanently mapped, so /dev/mem simply cannot see them. If you try to read those addresses, you’ll get zeros or an error, not because /dev/mem is broken, but because that part of memory is literally invisible to it.

For testing, this introduces extra complexity:

  • Some reads may succeed on lowmem addresses but fail on highmem.
  • Behavior on a 32-bit machine with highmem is fundamentally different from a 64-bit system, where all RAM is flat-mapped and visible.

Highmem is a deep topic that deserves its own article, but even this quick overview is enough to understand why it complicates /dev/mem testing.

How Reads and Writes Actually Happen

A common misconception is that a single userspace read() or write() call maps to one atomic access to the underlaying block device. In reality, the VFS layer and the device driver may split your request into multiple chunks, depending on alignment and boundaries, in this case.

Why does this happen?

  • Many devices can only handle fixed-size or aligned operations.
  • For physical memory, the natural unit is a page (commonly 4 KB).

When your request crosses a page boundary, the kernel internally slices it into:

  1. A first piece up to the page boundary,
  2. Several full pages,
  3. A trailing partial page.

For /dev/mem, this is a crucial detail: A single read or write might look seamless from userspace, but under the hood it’s actually several smaller operations, each with its own state. If the driver mishandles even one of them, you could see skipped bytes, duplicated data, or mysterious corruption.

Understanding this behavior is key to writing meaningful tests.

Safely Reading and Writing Physical Memory

At this point, we know what /dev/mem is and why it’s both powerful and terrifying. Now we’ll move to the practical side: how to interact with it safely, without accidentally corrupting your machine or testing in meaningless ways.

My very first test implementation kept things simple:

  • Only small reads or writes,
  • Always staying within a single physical page,
  • Never crossing dangerous boundaries.

Even with these restrictions, /dev/mem testing turned out to be more like diffusing a bomb than flipping a switch.

Why “success” doesn’t mean success (in this very specific case)

Normally, when you call a syscall like read() or write(), you can safely assume the kernel did exactly what you asked. If read() returns a positive number, you trust that the data in your buffer matches the file’s contents. That’s the contract between userspace and the kernel, and it works beautifully in everyday programming.

But here’s the catch: We’re not just using /dev/mem; we’re testing whether /dev/mem itself works correctly.

This changes everything.

If my test reads from /dev/mem and fills a buffer with data, I can’t assume that data is correct:

  • Maybe the driver returned garbage,
  • Maybe it skipped a region or duplicated bytes,
  • Maybe it silently failed in the middle but still updated the counters.

The same goes for writes: A return code of “success” doesn’t guarantee the write went where it was supposed to, only that the driver finished running without errors.

So in this very specific context, “success” doesn’t mean success. I need independent ways to verify the result, because the thing I’m testing is the thing that would normally be trusted.

Finding safe places to test: /proc/iomem

Before even thinking about reading or writing physical memory, I need to answer one critical question:

“Which parts of physical memory are safe to touch?”

If I just pick a random address and start writing, I could:

  • Overwrite the kernel’s own code,
  • Corrupt a driver’s I/O-mapped memory,
  • Trash ACPI tables that the system kernel depends on,
  • Or bring the whole machine down in spectacular fashion.

This is where /proc/iomem comes to the rescue. It’s a text file that maps out how the physical address space is currently being used. Each line describes a range of physical addresses and what they’re assigned to.

Here’s a small example:

00000000-00000fff : Reserved 00001000-0009ffff : System RAM 000a0000-000fffff : Reserved 000a0000-000dffff : PCI Bus 0000:00 000c0000-000ce5ff : Video ROM 000f0000-000fffff : System ROM 00100000-09c3efff : System RAM 09c3f000-09ffffff : Reserved 0a000000-0a1fffff : System RAM 0a200000-0a20efff : ACPI Non-volatile Storage 0a20f000-0affffff : System RAM 0b000000-0b01ffff : Reserved 0b020000-b696efff : System RAM b696f000-b696ffff : Reserved b6970000-b88acfff : System RAM b88ad000-b9ff0fff : Reserved b9fd0000-b9fd3fff : MSFT0101:00 b9fd0000-b9fd3fff : MSFT0101:00 b9fd4000-b9fd7fff : MSFT0101:00 b9fd4000-b9fd7fff : MSFT0101:00 b9ff1000-ba150fff : ACPI Tables ba151000-bbc0afff : ACPI Non-volatile Storage bbc0b000-bcbfefff : Reserved bcbff000-bdffffff : System RAM be000000-bfffffff : Reserved

By parsing /proc/iomem, my test program can:

  1. Identify which physical regions are safe to work with (like RAM already allocated to my process),
  2. Avoid regions that are reserved for hardware or critical firmware,
  3. Adapt dynamically to different machines and configurations.

This is especially important for multi-architecture support. While examples here often look like x86 (because /dev/mem has a long history there), the concept of mapping I/O regions isn’t x86-specific. On ARM, RISC-V, or others, you’ll see different labels… But the principle remains exactly the same.

In short: /proc/iomem is your treasure map, and the first rule of treasure hunting is “don’t blow up the ship while digging for gold.”

The Problem of Contiguous Physical Pages

Up to this point, my work focused on single-page operations. I wasn’t hand-picking physical addresses or trying to be clever about where memory came from. Instead, the process was simple and safe:

  1. Allocate a buffer in userspace, using mmap() so it’s page-aligned,
  2. Touch the page to make sure the kernel really backs it with physical memory,
  3. Walk /proc/self/pagemap to trace which physical pages back the virtual address in the buffer.

This gives me full visibility into how my userspace memory maps to physical memory. Since the buffer was created through normal allocation, it’s mine to play with, there’s no risk of trampling over the kernel or other userspace processes.

This worked beautifully for basic tests:

  • Pick a single page in the buffer,
  • Run a tiny read/write cycle through /dev/mem,
  • Verify the result,
  • Nothing explodes.

But then came the next challenge: What if a read or write crosses a physical page boundary?

Why boundaries matter

The Linux VFS layer doesn’t treat a read or write syscall as one giant, indivisible action. Instead, it splits large operations into chunks, moving through pages one at a time.

For example:

  • I request 10 KB from /dev/mem,
  • The first 4 KB comes from physical page A,
  • The next 4 KB comes from physical page B,
  • The last 2 KB comes from physical page C.

If the driver mishandles the transition between pages, I’d never notice unless my test forces it to cross that boundary. It’s like testing a car by only driving in a straight line: Everything looks fine… Until you try to turn the wheel.

To properly test /dev/mem, I need a buffer backed by at least two physically contiguous pages. That way, a single read or write naturally crosses from one physical page into the next… exactly the kind of situation where subtle bugs might hide.

And that’s when the real nightmare began.

Why this is so difficult

At first, this seemed easy. I thought:

“How hard can it be? Just allocate a buffer big enough, like 128 KB, and somewhere inside it, there must be two contiguous physical pages.”

Ah, the sweet summer child optimism. The harsh truth: modern kernels actively work against this happening by accident. It’s not because the kernel hates me personally (though it sure felt like it). It’s because of its duty to prevent memory fragmentation.

When you call brk() or mmap(), the kernel:

  1. Uses a buddy allocator to manage blocks of physical pages,
  2. Actively spreads allocations apart to keep them tidy,
  3. Reserves contiguous ranges for things like hugepages or DMA.

From the kernel’s point of view:

  • This keeps the system stable,
  • Prevents large allocations from failing later,
  • And generally makes life good for everyone.

From my point of view? It’s like trying to find two matching socks in a dryer while it is drying them.

Playing the allocation lottery

My first approach was simple: keep trying until luck strikes.

  1. Allocate a 128 KB buffer,
  2. Walk /proc/self/pagemap to see where all pages landed physically,
  3. If no two contiguous pages are found, free it and try again.

Statistically, this should work eventually. In reality? After thousands of iterations, I’d still end up empty-handed. It felt like buying lottery tickets and never even winning a free one.

The kernel’s buddy allocator is very good at avoiding fragmentation. Two consecutive physical pages are far rarer than you’d think, and that’s by design.

Trying to confuse the allocator

Naturally, my next thought was:

“If the allocator is too clever, let’s mess with it!”

So I wrote a perturbation routine:

  • Allocate a pile of small blocks,
  • Touch them so they’re actually backed by physical pages,
  • Free them in random order to create “holes.”

The hope was to trick the allocator into giving me contiguous pages next time. The result? It sometimes worked, but unpredictably. 4k attempts gave me >80% success. Not reliable enough for a test suite where failures must mean a broken driver, not a grumpy kernel allocator.

The options I didn’t want

There are sure-fire ways to get contiguous pages:

  • Writing a kernel module and calling alloc_pages().
  • Using hugepages.
  • Configuring CMA regions at boot.

But all of these require special setup or kernel cooperation. My goal was a pure userspace test, so they were off the table.

A new perspective: software MMU

Finally, I relaxed my original requirement. Instead of demanding two pages that are both physically and virtually contiguous, I only needed them to be physically contiguous somewhere in the buffer.

From there, I could build a tiny software MMU:

  • Find a contiguous physical pair using /proc/self/pagemap,
  • Expose them through a simple linear interface,
  • Run the test as if they were virtually contiguous.

This doesn’t eliminate the challenge, but it makes it practical. No kernel hacks, no special boot setup, just a bit of clever user-space logic.

From Theory to Test Code

All this theory eventually turned into a real test tool, because staring at /proc/self/pagemap is fun… but only for a while. The test lives here:

github.com/alessandrocarminati/devmem_test

It’s currently packaged as a Buildroot module, which makes it easy to run on different kernels and architectures without messing up your main system. The long-term goal is to integrate it into the kernel’s selftests framework, so these checks can run as part of the regular Linux testing pipeline. For now, it’s a standalone sandbox where you can:

  • Experiment with /dev/mem safely (on a test machine!),
  • Play with /proc/self/pagemap and see how virtual pages map to physical memory,
  • Try out the software MMU idea without needing kernel modifications.

And expect it still work in progress.