How x86 emulation works in the browser
From a JavaScript sandbox to a booting PC: the chips v86 pretends to be, how it turns hot guest code into WebAssembly at runtime, and where the whole approach stops working.
A browser tab is a deliberately powerless place. It cannot touch your files, your devices or your memory. So the idea that an operating system could boot inside one sounds like a category error — until you notice that an operating system does not need any of those things. It needs a processor that executes its instructions and hardware that answers when it asks. Both can be written in software.
The machine v86 hands the guest
TempMV runs on v86, an emulator of a 32-bit PC. It does not emulate a generic computer, and the lack of generality is the point: a system boots only if the chips it probes for answer as their datasheets said they would. Linux expects an interrupt controller at ports 0x20 and 0xA0 and programs a timer through port 0x40; FreeDOS expects a floppy controller at 0x3F0 and a text framebuffer at 0xB8000. If any of it is subtly wrong, the boot stops with no explanation.
| Emulated device | The real thing | Guest use |
|---|---|---|
| x86 CPU, 32-bit | Instruction set around Pentium 4, full SSE3 | Everything. No long mode, one core. |
| MMU and paging unit | The on-die MMU of any 386 | Virtual memory, CR3 page tables, page faults |
| 8042 keyboard controller | Intel 8042, IBM PC/AT, 1984 | PS/2 keyboard and mouse, the A20 gate |
| 8259 PIC | Two cascaded Intel 8259A chips | IRQ 0 to 15, and their priority |
| 8253/8254 PIT | Intel timer clocked at 1.193182 MHz | The system tick on IRQ 0; the PC speaker |
| CMOS RTC | Motorola MC146818 and its NVRAM | Date and time, memory size, boot order |
| IDE controller | ATA/ATAPI at ports 0x1F0 to 0x1F7 | Hard disks and CD-ROM, 512-byte sectors |
| Floppy controller | 8272A, the NEC uPD765 of every PC | 1.44 MB images, and boot sectors |
| VGA card with VBE | IBM VGA of 1987 plus Bochs VBE extensions | Text mode at 0xB8000; linear framebuffer for graphics |
| Serial UART | 16550-compatible, base port 0x3F8 | Where headless Linux images print |
| NE2000 card (optional) | Novell NE2000 / Realtek RTL8390, on PCI | Ethernet frames, if anything carries them |
Why interpreting is not enough
The obvious way to emulate a processor is a loop: read the next instruction, work out what it means, do it, repeat. Decoding one x86 instruction means prefixes, a ModR/M byte, an optional SIB byte, a displacement and an immediate — dozens of host instructions spent deciding what to do before one is spent doing it, and the answer was the same the previous million times. So v86 keeps the interpreter only as a starting point. When a stretch of code proves itself, v86 translates it into a WebAssembly module and hands that to the browser, whose optimising compiler turns it into native code — exactly the kind of code the browser was built to run fast.
How a hot page becomes WebAssembly
The unit of compilation is not the basic block, as most accounts of JITs would lead you to expect. It is the 4 KB physical page. While interpreting, v86 records two things per page: the entry points it has seen — targets of calls and indirect jumps — and a hotness counter, raised by the instructions interpreted from that page. Once hotness crosses the threshold, currently 200,000, the whole page is compiled, plus up to three pages reachable from it.
Basic blocks still exist inside that: codegen runs in two passes, the first finding block boundaries, the second emitting code for each. But they all land in a single WebAssembly function, because WebAssembly has no arbitrary jumps — its control flow is structured, so nothing can goto the middle of a translated routine. v86 wraps the lot in a large br_table dispatch to keep every entry point reachable, and rebuilds the rest with the stackifier algorithm.
; the hot loop, as the guest wrote it (physical page 0x00101000)
0x00101240 mov eax, [ebx+4]
0x00101243 add eax, 1
0x00101246 cmp eax, 52
0x00101249 jb 0x00101240
;; what v86 emits for that page -- in spirit, not literally.
;; One wasm function per page group, one br_table, one case per block.
(func $f (param $entry i32)
(block $exit (loop $dispatch
(br_table $bb0 $bb1 $exit (local.get $entry))
;; $bb0: mov eax, [ebx+4]
;; guest registers live at fixed offsets in linear memory:
;; eax at 64, ebx at 76
(local.set $addr (i32.add (i32.load offset=76 (i32.const 0))
(i32.const 4)))
(local.set $e (i32.load (i32.shl (i32.shr_u (local.get $addr)
(i32.const 12))
(i32.const 2)))) ;; software TLB
(if (tlb_entry_is_usable (local.get $e))
(then ;; fast path:
(i32.store offset=64 (i32.const 0) ;; one load out
(i32.load (i32.xor (local.get $e) (local.get $addr)))))
(else ;; slow path:
(call $safe_read32_slow (local.get $addr)))) ;; walk tables,
;; #PF, or MMIO
;; $bb1: add / cmp / jb, with the compare folded into the branch --
;; no EFLAGS is ever computed here
(i32.store offset=64 (i32.const 0)
(i32.add (i32.load offset=64 (i32.const 0)) (i32.const 1)))
(br_if $dispatch
(i32.lt_u (i32.load offset=64 (i32.const 0)) (i32.const 52)))
;; fall through: write eip back, leave the module,
;; let the main loop decide what runs next
))
)
Two details there matter. Every guest memory access goes through a software TLB: a 4 MB cache holding, per page, the physical address plus flags for read-only, contains-code and memory-mapped I/O. The fast path is a couple of instructions; anything unusual drops to the slow path, which walks the guest page tables and may raise a page fault. The other is the missing flags: EFLAGS is computed lazily, and a conditional jump straight after a compare skips it altogether, so cmp then jb becomes one unsigned less-than.
The generated bytes go to WebAssembly.instantiate, asynchronously, so the emulator keeps interpreting while the browser compiles. The module exports one function, which v86 stores in a WebAssembly.Table and calls indirectly. That table has 900 slots — a real ceiling, given each module's memory overhead — so old ones are evicted. Any write to a page holding generated code discards every module built from it: self-modifying code, common in DOS-era software, and the ordinary case of a kernel reusing a freed page.
Where the machine's state lives
In one place, which is the neat part. The v86 core is a .wasm module, and the whole emulated machine sits inside that module's linear memory — one flat ArrayBuffer the browser owns. Guest RAM is a slice of it, 64 MB by default, seen from JavaScript as a Uint8Array. The registers are in the same buffer at fixed byte offsets: the eight general-purpose ones at 64, the instruction pointer at 556, the control registers at 580, the segment offsets at 736.
Two views of the same bytes, then. JavaScript reads a guest register by indexing a typed array; the compiled WebAssembly reads it with a load from a constant address, which is why the block above stores to offset=64 rather than calling anything. Nothing is serialised, because there is no boundary. What WebAssembly actually is covers linear memory.
It is also why snapshots are possible. The whole machine is data in one buffer — registers, RAM, device state, disk contents — so it can be written to a file and restored. The fastest entries in the catalog do not boot at all; they restore a snapshot taken after the boot finished. See snapshots and machine state.
Why nothing 64-bit boots
The emulated processor is more modern than people assume: the instruction set sits around Pentium 4 level, with full SSE3. What is missing is long mode — the 64-bit extensions AMD shipped in 2003. There are no 64-bit registers, no EFER.LME bit to set, no four-level page tables.
A 64-bit kernel's first real act after the bootloader hands over is to build a page table, set that bit and enable paging, and here that sequence has nowhere to go. An x86_64 image will not boot — you want the i386 or i686 build. Other gaps: task gates, far calls in protected mode, the trap flag and debug registers, and any second CPU.
Where the disk comes from
The guest believes it has an IDE disk. Reads come from a buffer in the page. For a 1.44 MB floppy that buffer is the whole image; for a 1 GB hard disk that would be absurd, so those are streamed with HTTP range requests. A single sector read costs this:
- The guest writes an LBA and a sector count to ports 0x1F0 to 0x1F7 and issues a read.
- v86's IDE device turns that into a byte offset — LBA times 512 — and a length.
- The buffer checks its cache, keyed in 256-byte blocks. On a hit, nothing leaves the tab.
- On a miss it sends a
Range: bytes=start-endheader, rounded out to the image's chunk size — often 256 KB — so one request covers a neighbourhood, not a sector. - The server answers
206 Partial Content. The bytes are cached, the device raises IRQ 14, and the guest's driver reads its sector from the data port.
A system that touches four hundred megabytes downloads four hundred megabytes; one that boots to a prompt and stops might pull twenty. Where a host cannot serve ranges, v86 can fetch an image pre-split into numbered part files.
Whether a remote image loads at all is decided by CORS, and by the browser rather than the emulator. A cross-origin image needs Access-Control-Allow-Origin, and because these are range requests it also needs the Range header allowed and Content-Length exposed. Miss any of it and the fetch is blocked before the emulator sees a byte — the symptom is a machine that boots into nothing, with a CORS error in the console. Disk images explained covers the formats.
Writes go to that same in-memory cache and are never sent anywhere. That is the whole reason a machine here is temporary: the disk you are modifying is a copy that lives in the tab.
Networking has to be tunnelled
The NE2000 emulation is faithful enough that FreeDOS packet drivers and Linux's ne2k-pci bind to it without complaint. The problem is downstream: the card produces raw Ethernet frames, and a tab has no sockets below HTTP and WebSocket. Something must stand in for the network.
- In-browser. Frames are broadcast between v86 instances in the same browser over
BroadcastChannel. No server and no outside world: two tabs can ping each other, and nothing else. - WebSocket relay. Frames are tunnelled unmodified to a proxy owning a TAP device, which bridges them onto a real network. The only backend that gives the guest a genuine stack, and it needs a server you trust.
- WISP. The backend terminates the guest's TCP connections and forwards only payloads, answering ARP, DHCP and DNS locally. Outbound TCP works; listening sockets do not.
- Fetch. The backend spots HTTP requests in the guest's traffic and reissues them with
fetch(). No relay needed, but every request is then subject to CORS.
The limits, plainly
- 32-bit only. No long mode, so no 64-bit operating system. Not a matter of effort; it is not implemented.
- No hardware virtualisation. Every instruction is emulated: a fraction of native speed, and a far smaller fraction while the JIT is cold.
- One core, no SMP. BIOS, not UEFI, and no TPM: systems that require modern firmware have nothing to boot from.
- The FPU is precise but slow. x87 arithmetic runs through Berkeley SoftFloat, because some guest code depends on 80-bit floats. Floating-point-heavy work crawls.
- No 3D acceleration. The VGA card is a framebuffer with VBE modes on top.
- Networking needs a relay, and self-modifying code never gets fast: structural, not bugs.
Those constraints are why the catalog looks the way it does. It is not a collection of curiosities by choice: a single-core 32-bit BIOS PC is the machine that exists here, and these are the systems that run on it. If a term above was new, the glossary has it.