← the series

Why your program won't run on my machine

Every download page asks you the same quiet questions. Mac or Windows? Intel or Apple Silicon? You've answered them so many times you don't notice anymore. But sit with it for a second and it's strange. The app is finished software. It exists. Why does it matter what machine it lands on? Meanwhile the same afternoon, someone emails you a Python script written on a five-year-old Windows laptop and it runs on your Mac without a single question being asked. By the end of this part, the finale of the series, both facts will feel obvious.

Machine code is a dialect

From part 3: an ahead-of-time compiler's last act is producing machine code, instructions in the native format of a specific CPU family. The key word is specific. x86-64 chips, the Intel and AMD kind, speak one instruction format. ARM64 chips, the Apple Silicon and phone kind, speak a completely different one. Different encodings, different registers, different everything. "Add these two numbers" isn't one universal sentence in silicon. Each family spells it its own way.

So a binary compiled for x86-64 is, to an Apple Silicon chip, not slow or awkward. It's gibberish. The proof this wall is real: when Apple switched Mac chips from Intel to ARM, they had to ship Rosetta 2, an entire translation layer whose only job is converting x86-64 machine code into ARM64 machine code so old apps could keep running. When the vendor has to build a live translator between its own computers, you know machine code doesn't travel.

Same chip, still won't run

Here's the part that surprised me more. Take two machines with the same CPU, a Linux box and a Mac both on ARM64, and a binary built for one still won't run on the other. If machine code were the whole story, it should. It isn't, because what you ship isn't loose machine code. It's an executable, and an executable is a package.

An executable file contains machine code, baked-in data, an import table naming needed libraries, and metadata for the loader. The whole package comes in an OS specific format: ELF on Linux, Mach-O on macOS, PE on Windows. The OS loader reads all of it before the CPU executes anything. your_app · one executable file machine code · the actual instructions data · strings and constants baked into the program import table · "I need these libraries at runtime" metadata · entry point, layout, permissions the whole package comes in an OS-specific format: ELF on Linux · Mach-O on macOS · PE (.exe) on Windows the OS loader has to read and approve all of this before the CPU sees one instruction
An executable is machine code plus everything the operating system needs to load it. Wrong format, and the OS won't even start.

Inside that package: the machine code, yes, but also your program's baked-in data, a table naming every library it expects to find at runtime, and metadata telling the operating system where everything sits and where execution should begin. And the package format itself is an OS convention: Linux wants ELF, macOS wants Mach-O, Windows wants PE, the thing behind .exe. Before your program runs, the OS loader has to parse this package, wire up the libraries, and set the whole thing up in memory. Hand macOS an ELF file and it isn't confused about the machine code inside. It never gets that far. It's like receiving a package addressed in a postal system your country doesn't use.

And it goes deeper than packaging. A running program constantly asks its operating system for things: open this file, give me memory, send these bytes. Each OS offers different services, spelled differently, through different libraries. Your binary isn't just built for a CPU. It's built into a standing relationship with one operating system, and that relationship is baked in at compile time.

Reading a target triple

All of this is why "compiled" always secretly means "compiled for somewhere," and why Rust names its build targets the way it does. x86_64-unknown-linux-gnu reads as: x86-64 CPU, no particular vendor, Linux, GNU-flavored system libraries. aarch64-apple-darwin: ARM64 CPU, Apple, Darwin, which is the OS family under macOS. Every part of that string is one of the walls we just met. Architecture, platform, operating system, library conventions. A "target" is the full address of a place software can live, and a native binary is compiled for exactly one address.

The two ways out

So how does anyone ship software to more than one address? Everything in this series funnels into this one fork. There are exactly two strategies, and you already know both.

Two portability strategies side by side. Rebuilding: one source compiled three times into three native binaries, one per platform. Carrying a VM: one bytecode artifact shipped everywhere, with each machine running a VM built for that machine. portability by rebuilding Rust, C, Go one source binary for Linux x86-64 binary for macOS ARM64 binary for Windows x86-64 compile N times, ship N artifacts, each one native and fast portability by carrying a VM Java, Python one bytecode artifact Linux box with its own VM Mac with its own VM Windows PC with its own VM ship one artifact. every machine carries a VM that somebody else compiled for it two answers to the same wall. neither is free. both work.
Rebuild per target, or make every machine carry a translator. Every portable language you know picked one of these, or mixed them.

Strategy one, rebuild for every address. That's Rust, C, Go. Cross the walls at compile time: build once per target, ship a native binary to each, and every copy runs at full speed with no baggage. The cost is on you, the builder: N platforms, N builds, and the download page has to ask its little questions.

Strategy two, move the problem to the other side. That's the bytecode play from part 4, and now you can see what it's really for. Compile to instructions for an imaginary machine, and ship that one artifact everywhere. The walls didn't go anywhere. They got handled by whoever built the VM for each platform, once, so that every developer after them could skip it. "Write once, run anywhere" decoded: we already compiled the hard part for every machine, so you don't have to. And your Python script that ran without questions? Same trick. The person who installed Python on each machine installed the per-platform part. Your .py file rides on top, portable because it never gets closer to the metal than CPython does.

The modern remix of this idea is WebAssembly: a bytecode designed to run in every browser, and increasingly everywhere else. And who's the most enthusiastic adopter? Rust, the poster child of ahead-of-time compilation, happily compiling to bytecode for a virtual machine. The categories from the tutorial aren't just leaky at this point. The "compiled language" ships bytecode and the "interpreted languages" run JIT compilers. Everyone is borrowing everyone's strategy, because they were always just strategies.

The model you should have been taught

That's the bottom of the rabbit hole, so let's climb back up and count what we found. A linter isn't a worse compiler, it answers a different question (part 1). Your file is characters, and everything you think of as code is a structure the toolchain rebuilds from text every time (part 2). "Compiling" is a pipeline of lowerings through in-between forms, not one arrow (part 3). Bytecode is machine code for an imaginary machine, and VMs, interpreters, and JITs are the machinery that makes it real (part 4). Languages are specs, implementations have strategies, and "compiled versus interpreted" was a question aimed at the wrong layer (part 5). And machine code is mail with a very specific address on it, which is why the whole portability circus exists at all (this one).

The old two-bucket taxonomy wasn't evil. It was a beginner abstraction, and beginner abstractions are fine. The failure is that nobody ever comes back to tell you where the abstraction stops being true, so it quietly hardens into a fact, and then every real thing you meet later, __pycache__, JIT warmup, "download for Apple Silicon," WebAssembly, feels like an exception to a rule instead of what it actually is: the actual system, showing through.

I started this series embarrassed to ask why a type-safe language needs a linter. Every question after that one sounded dumber and turned out to be more fundamental. I'm pretty sure that's not a coincidence. The questions that feel too basic to ask out loud are usually load-bearing, and the tutorials skipped them precisely because they're hard to answer in a paragraph. So here's six essays instead. If someone you know is still filing languages into two buckets, send them to the start.

← back to the series