← All lessons

The Second Implementation Is the Test of Your Abstractions

An Abstraction You Never Cross Is a Guess

For most of its life, uBixOS was an x86 operating system. It had a “machine-independent” layer and a “machine-dependent” layer, drawn along the lines every OS textbook tells you to draw them. fork, execve, the scheduler, the virtual-address layout — all nominally portable, sitting above an arch-specific layer of loaders and trap handlers.

I believed that boundary was real. It wasn’t. It was a guess — a line drawn on a diagram that had never once been tested, because there was only ever one thing on the other side of it. When your only target is x86, every x86 assumption is free to leak upward into the “portable” code, and nothing pushes back. The compiler won’t tell you. The tests won’t tell you. Everything works, so the abstraction looks clean.

Then I ported uBixOS to aarch64, and the line got tested for the first time.

What the Port Actually Surfaced

Porting an OS to a second architecture is the test that tells you whether your kernel was designed or just accreted. uBixOS was honest enough to admit it was a bit of both.

The moment there were two real implementations under the machine-independent layer, the leaks became visible — because now they conflicted. Code that assumed x86 page-table shapes, x86 syscall-argument marshaling, x86 stack layout, x86 time sources: all of it had quietly settled into the “portable” half, and aarch64 disagreed with every one of those assumptions. There was no arguing with it. The second implementation doesn’t have opinions; it just fails to boot.

So a large part of the port wasn’t writing ARM code at all — it was convergence. Pulling the genuinely arch-specific code (loaders, trap handlers, the register-level context switch) down where it belonged, and lifting the genuinely portable code (fork, execve, syscall dispatch, the user VA layout, time sources) up into a shared sys/kern that both architectures could stand on. The payoff was concrete: both arches now boot through the same /bin/init and run the same userland. But the deeper payoff was that the MI/MD boundary finally meant something, because two different machines were holding it to account.

Keeping the Second Implementation Alive on Purpose

Here’s the part I want to emphasize, because it’s the part most “we did a port” stories miss: I didn’t treat the port as a one-time event. I kept the second implementation load-bearing.

When I later dropped 32-bit i386 from the tree, I didn’t collapse back down to a single architecture. Master stays deliberately dual-target: aarch64 as the primary, and x86_64 kept explicitly as what I started calling the abstraction anchor — the second implementation whose entire job is to keep the MI/MD split honest going forward. Every new subsystem — SMP, the higher-half kernel, the filesystem pool, the whole self-hosting toolchain — has to come up on both. The instant a new x86_64 assumption tries to creep into the machine-independent core, aarch64 rejects it at build or boot time, and vice versa. The abstraction can’t rot, because it’s continuously re-tested by construction.

One target means your abstractions are a matter of opinion. Two targets means they’re a matter of fact.

The General Principle

A boundary you never cross is not an abstraction. It’s a hypothesis you’ve never run the experiment on.

This is true far outside kernels:

  • A “database-agnostic” data layer that has only ever talked to Postgres is coupled to Postgres. You just can’t see it yet. The first time you point it at SQLite or MySQL, the leaks — SQL dialect, transaction semantics, type coercion — show up all at once.
  • A “provider-agnostic” payments interface with exactly one implementation is Stripe’s API wearing a costume. The abstraction is real the day a second processor has to satisfy it.
  • A config system that’s “portable across environments” but has only ever run in one is full of that environment’s assumptions about paths, secrets, and clocks.

The lesson isn’t “always build for N backends up front” — that’s how you get speculative generality, abstraction for enemies you never fight. The lesson is subtler: an abstraction’s correctness is unknowable until a second implementation lives behind it, and it stays correct only as long as that second implementation stays alive. If portability actually matters to you, the cheapest way to guarantee it isn’t more discipline or more code review. It’s keeping a real second target in the build, all the time, so the machine tells you the truth instead of your intentions.

Twenty years of “machine-independent” code turned out to be full of machine-dependent assumptions. I only know that because something finally disagreed with it.

← All lessons