· 7 min read ·

JSLinux at Fifteen: What x86_64 Support Required, and Why RISC-V Was the Detour

Source: hackernews

Fabrice Bellard launched JSLinux on April 11, 2011, with a post that treated the whole thing as a minor curiosity. The demonstration ran Linux 2.6 with busybox in a 32-bit x86 emulator written entirely in JavaScript, in the browser, with no plugins. This was four years before WebAssembly existed. The tech press covered it as a novelty; the Hacker News thread treated it as a landmark in JavaScript engine performance. Both readings were accurate.

Fifteen years later, JSLinux has added x86_64 support. The announcement is characteristically understated, as Bellard’s announcements tend to be. But the gap between what the project supported in 2011 and what it supports now is worth tracing, because the path was not a straight line.

The RISC-V Detour

After the original x86 release, Bellard’s attention in the emulation space shifted toward TinyEMU, a standalone C-based machine emulator with a clean VirtIO device model. TinyEMU’s primary target was RISC-V, specifically the RV32GC and RV64GC profiles of the specification. RISC-V emulation is substantially simpler than x86 emulation for a specific reason: instruction decoding.

RISC-V uses fixed-width 32-bit instructions (with a 16-bit compressed extension as an optional addition). The decode step is cheap and regular. x86, by contrast, descends from the 8086, and its instruction encoding reflects fifty years of backward compatibility requirements. A single x86 instruction can be anywhere from one to fifteen bytes. You must process multiple prefix bytes (segment overrides, operand-size overrides, LOCK and REP prefixes, and in x86_64, the REX prefix family) before you know which opcode you are even dealing with. An emulator’s inner loop spends a significant fraction of its time just figuring out what instruction it has, before it can do anything with it.

JSLinux, built on top of TinyEMU compiled to WebAssembly via Emscripten, therefore ran its RISC-V demos by default. Linux’s mainline kernel has full RISC-V support, so nothing needed to change on the software side. From a user perspective the experience was identical: a terminal window in the browser running a minimal Linux environment. The underlying architecture was an implementation detail. But it mattered for performance and for the complexity of the emulator codebase.

What x86_64 Actually Required

Adding x86_64 to TinyEMU’s x86 emulation layer is not simply a matter of extending registers from 32 to 64 bits. The Intel 64 / AMD64 architecture introduced a substantial number of changes that an emulator must handle correctly.

The REX prefix is the most pervasive. In 64-bit long mode, any instruction prefixed with a byte in the range 0x40 to 0x4F gains access to 64-bit operand widths and to the eight additional general-purpose registers (r8 through r15) that AMD64 added to the original eight. The REX prefix also controls access to new register encodings in ModRM and SIB bytes. An x86_64 decoder must check for REX before resolving operand widths and register indices, and every instruction handler needs to be aware of whether the REX.W bit is set. Compared to the regular 4-bit opcode fields of a RISC-V instruction, this kind of contextual decoding adds significant branching to the inner loop.

The syscall interface changes completely. 32-bit x86 Linux uses int 0x80 for system calls, a software interrupt that traps into the kernel. x86_64 Linux uses the SYSCALL instruction, which reads the target address from the LSTAR MSR (Model Specific Register). An emulator that handled int 0x80 for 32-bit code needs a separate code path for SYSCALL and the associated SYSRET return instruction, along with the MSR read and write infrastructure that supports them.

Memory segmentation, already vestigial in 32-bit protected mode, is reduced further in long mode. FS and GS segments retain their base address fields and are used for thread-local storage. glibc stores the thread control block pointer in FS.base. The emulator must model these register-accessible MSRs (FSBASE, GSBASE, KERNEL_GSBASE with SWAPGS) even though general code segmentation is effectively disabled for user-mode code.

Paging moves to a four-level structure (PML4, PDP, PD, PT) from the two-level structure used in 32-bit protected mode. The emulator’s software memory management unit walk must traverse four levels, each with 512 entries of 8-byte pointers. Deeper table walks mean more memory accesses during page fault handling and TLB-equivalent invalidations.

Finally, there is the matter of 64-bit integer arithmetic in WebAssembly. Wasm’s i64 type is available and maps reasonably well to the 64-bit register operations x86_64 requires, but the Emscripten toolchain and surrounding JavaScript glue must handle 64-bit values consistently. The 64-bit arithmetic paths carry more overhead than their 32-bit equivalents, which is part of why x86_64 emulation in a software interpreter costs more per instruction than RISC-V RV64 emulation, despite both being 64-bit targets.

How JSLinux’s Architecture Compares

It is worth situating TinyEMU’s approach against the other serious browser-based x86 emulators, because they make very different trade-offs.

v86 is a JavaScript-native x86 emulator written by Fabian Hemmer. It emulates a more complete PC: legacy VGA BIOS, IDE/AHCI storage, Ne2000 networking, and a real-mode BIOS capable of booting arbitrary x86 operating systems including Windows 98 and Windows XP. More importantly, v86 includes a JIT compiler: x86 basic blocks are translated to WebAssembly at runtime, so frequently-executed code avoids repeated interpretation overhead. This gives v86 substantially better peak throughput on compute-intensive workloads, at the cost of a more complex implementation and heavier warm-up behavior.

WebVM from Leaning Technologies takes a third approach. Their CheerpX engine is a full JIT compiler that translates x86 machine code to WebAssembly bytecode on the fly, similar in concept to what QEMU’s Tiny Code Generator does but targeting Wasm as the output. CheerpX can run a Debian Linux environment with an Xfce desktop at interactive speeds. It is proprietary; JSLinux and v86 are not.

TinyEMU falls between these extremes: a portable C interpreter with no browser-specific JIT, but compiled to WebAssembly, which gives it predictable performance and avoids V8’s JIT deoptimization paths. It does not try to emulate a full PC with legacy VGA and BIOS; it uses VirtIO for all devices. This means it can only run operating systems that have VirtIO drivers compiled in, but every mainstream Linux kernel built after roughly 2010 qualifies.

The real advantage of Bellard’s approach is that TinyEMU is also a native tool. The same C codebase runs on your local machine, in CI, or on embedded systems. The browser version is Emscripten compilation of the same source, not a separate reimplementation. Improvements to the native emulator propagate to the browser version without additional maintenance cost, and the two implementations cannot diverge silently.

The Lazy Disk Fetch

One contribution from the original 2011 JSLinux that has been widely copied is the fetch-on-demand disk image technique. Rather than downloading an entire disk image before the system boots, JSLinux fetches disk sectors lazily via HTTP Range requests as the kernel accesses them. The block device driver in the emulator translates a kernel read request to the sector offset, checks a local cache, and issues a Range-header fetch for the specific bytes if the sector is not cached.

This means a 512 MB disk image can be served from a static file host, and a minimal Linux boot only needs a few megabytes of data transferred before the shell prompt appears. It also means you can run the system on any CDN or object storage bucket without server-side computation. v86 adopted the same technique; WebVM uses a different mechanism (a Tailscale-based networked filesystem in recent versions). The lazy fetch remains one of the most practically useful architectural decisions in JSLinux’s design, and its elegance is that it requires no server-side logic whatsoever.

Why the x86_64 Milestone Matters

The most direct way to frame the x86_64 addition is that it closes the gap between JSLinux and the software that runs on the machines people use. Modern Linux distributions ship x86_64 binaries by default. Pre-compiled toolchains, language runtimes, and statically-linked binaries are distributed for x86_64. Running these without recompilation requires a 64-bit x86 environment.

With RISC-V, you can run Linux and install software compiled for RISC-V, but that excludes a large body of pre-built software that has no RISC-V binary available. With x86_64, you can take a binary off your x86_64 host and run it in the browser emulator, in the same browser on that same machine. There is something satisfying about that arrangement.

Bellard’s broader project list includes FFmpeg, QEMU, TCC, QuickJS, and a collection of records and one-off tools that span decades. Each project is typically written once, written well, and maintained quietly. JSLinux fits that pattern. It launched as an x86 emulator in 2011, shifted to RISC-V as TinyEMU matured, and has now returned to x86 in its modern 64-bit form. The current version is available at bellard.org/jslinux, requires nothing beyond a browser, and boots to a shell prompt in a few seconds. The gap between 2011 and now is visible mostly in which architectures appear in the dropdown, and in the fact that the web platform it runs on has improved enough that the whole thing feels routine.

Was this interesting?