Skip to content

Repository files navigation

Assembly in the browser

Assemble and run real x86-64 machine code in a browser tab, with no server.

You type Intel-syntax assembly; the page turns it into machine code and executes it on an emulated CPU. No source leaves the tab, no build step, no native binary, no WebAssembly Text (WAT) and no AssemblyScript — this is genuine x86-64.

It is a proof of concept for adding an assembly language to LiveCodes, in the same shape as browser-cobol and browser-haskell were for their languages.

assembly source  →  Keystone (WASM)  →  x86-64 machine code  →  Unicorn (WASM)  →  stdout
                                         865 bytes here           emulated CPU        via syscalls

Both libraries are real, well-known projects compiled to WebAssembly:

  • Keystone — the multi-architecture assembler framework (LLVM MC under the hood for x86).
  • Unicorn — the CPU emulator framework (several generations of QEMU's TCG under the hood).

Demo

npm start          # → http://localhost:8123/

Pick an example (or type your own) and press Run — or Ctrl/Cmd + Enter in the editor. A static HTTP server is required only because file:// cannot compile WebAssembly; there is no build step and nothing is fetched at run time.

npm start is a plain file server over the repository directory. The page runs on the package in packages/assembly-wasm, pointed at the vendored runtimes under vendor/ — so it works offline and pins the exact versions, instead of reaching for the CDN the published package defaults to.

What you get

  • Client-side assembly and execution. Keystone assembles, Unicorn runs; the program's syscalls are served by the page (see below).
  • Real CPU semantics. Registers, the stack, rip-relative addressing, div, flags, loops and memory all behave as on the real thing.
  • No network at run time. ~5 MB of vendored WebAssembly, loaded lazily on the first Run.
  • Final register dump for every run, plus the exit code, syscall count, code size and wall-clock time.

The syscall ABI

The guest is a bare program with no kernel, so syscall is trapped by a Unicorn hook and handled in JavaScript. Only a small Linux-like subset is implemented:

rax call behaviour
0x00 read(fd, buf, len) reads from the stdin box, returns the byte count
0x01 write(fd, buf, len) fd 1 → output pane, fd 2 → output pane (marked), returns len
0x3c exit(status) stops the emulator
0xe7 exit_group(status) same as exit
other — returns -ENOSYS (rax = -38)

Anything else — open, mmap, brk, fork — is not available. The memory map is fixed and documented below, and a program is a single flat blob: no linker, no relocations, no libc.

Memory map

region address size notes
program + data 0x1000000 64 KiB read/write/execute; entry point is the first byte
stack 0x2000000 64 KiB rsp starts near the top

If a program runs off the end of its code it lands on an appended exit(0) epilogue, so forgetting to exit is not an error.

Verified

Every row was run end to end — under Node against the same vendored builds, and again through the page in headless Chrome (pick the example, press Run, read the output pane). Each error row was followed by a good program, to confirm that a failed run leaves the runtime usable.

program result syscalls
write + exit Hello, world! 2
read then write (stdin Assembly, running in a tab.) Assembly, running in a tab. 3
fill a buffer with A..Z, then write ABCDEFGHIJKLMNOPQRSTUVWXYZ 2
sum 1..100 with add/dec/jnz, print it with div 5050 2
read unmapped memory The program read from an address that is not mapped. —
write unmapped memory The program wrote to an address that is not mapped. —
jmp to an unmapped address Execution jumped to an address that is not part of the program. —
spin: jmp spin, never exits The program did not exit (stopped after 10,000,000 instructions)… —
NASM-style ; comments, and a ; inside a string a;b;c 2
unprefixed integers are decimal: mov rax, 10 / .byte 10 48 c7 c0 0a 00 00 00 / 0a —
frobnicate rax (not an instruction) Assembly failed: Invalid mnemonic (KS_ERR_ASM_MNEMONICFAIL) —

All four examples exit 0. Through the page, the first Run — toolchain load included — is ~60 ms and later runs are ~5 ms.

Things worth knowing

Six behaviours of this stack cost real debugging time and shape the code. Each was established by running it, not by reading docs.

1. ; is not a comment — so we make it one. Keystone's Intel mode follows the GNU assembler, where # and // comment and ; does not, so the NASM-style ; header almost every x86 tutorial opens with fails on its first line. The page and the package therefore strip ; comments before assembling, with a string-aware scan so a ; inside .ascii "a;b" stays data.

The obvious alternative does not work. Keystone's own NASM dialect accepts ;, but rejects db, equ $, times, resb and section — so it cannot define data — and it still reads an unprefixed integer as hexadecimal; MASM is not compiled into this build at all. The dialect in use is the only one with working directives, so the comment style is the part that gives.

2. Integer literals are hexadecimal — so we rewrite them. Keystone's x86 backend parses an unprefixed number as hex: mov rax, 10 loads 16, and .byte 10 emits 0x10 rather than a newline. Every mode it offers behaves this way — its NASM dialect included, and .intel_syntax inside the AT&T parser — so there is no dialect to switch to, and no decimal spelling to fall back on. The page and the package therefore re-base bare integers to decimal before assembling: 0x is hex, 0b is binary, and everything else is decimal, including a leading zero (which Keystone would read as octal). The examples are now written the way a tutorial would write them.

3. Unicorn's wall-clock timeout cannot be used. uc_emu_start implements its timeout with a QEMU timer thread, and this single-threaded WASM build has no pthreads:

qemu: qemu_thread_create: Not supported
Aborted()

Passing a non-zero timeout aborts the run immediately. The only brake is the instruction-count limit, which works because it is checked inline. The interpreter (TCI, no JIT) runs at roughly 2–4M instructions/second, so the page's 10M budget caps a runaway loop at a few seconds.

4. A bad memory access can poison the emulator instance. Jumping to an unmapped address is a clean, catchable error (UC_ERR_FETCH_UNMAPPED). Under Node, a data access to an unmapped address — or simply running off the end of mapped memory — instead trapped inside Unicorn's error path and threw a JavaScript RuntimeError: memory access out of bounds, leaving that WASM instance unusable so the next call into it crashed. In the browser, the same accesses surfaced as ordinary UC_ERR_READ_UNMAPPED / UC_ERR_WRITE_UNMAPPED errors instead.

The page handles both. A clean error becomes one of the friendly messages below; if a JavaScript trap escapes instead, the page drops its reference to the module and the next Run calls MUnicorn() again, which returns a fresh, working instance. The rebuild path was verified under Node (trap, rebuild, run cleanly), and the clean-error path in the browser — each followed by a successful run.

5. Running off the end is not an error. The raw emulator would fall into unmapped memory. Instead the page appends a 12-byte exit(0) epilogue after the assembled program, so a program that simply ends — the most common beginner mistake — exits cleanly.

6. close() is fine on success, not after a trap. uc_close() releases the engine handle; calling it on a healthy engine is safe and repeated runs work. Calling it after a trapped run trips the same corrupt state, so the page guards it and rebuilds if it throws.

Limitations

  • x86-64, Intel syntax only. Keystone and Unicorn both support ARM, MIPS, RISC-V and more; only the x86 build is vendored here.
  • A syscall shim, not Linux. The handful of calls in the table above, and nothing else. There is no libc, no dynamic linking and no filesystem.
  • ~5 MB of WebAssembly, loaded on the first Run rather than at page load (Unicorn's all-architecture bundle is 9 MB; the x86-only build is 915 KB).
  • No disassembly or single-stepping yet. Keystone assembles but does not disassemble; a trace view would need Capstone.
  • ~2–4M instructions/second. Fine for the kind of programs a playground runs; not a way to run a compiler.

The npm package

packages/assembly-wasm is the same idea as a publishable package, @live-codes/assembly-wasm, for a host that wants a language rather than a page. It exposes createCompiler(options).run(code, input, options) and resolves with { stdout, stderr, output, errors, exitCode, registers, … } — the shape @live-codes/clang-wasm uses, so errors.length is the failure test and exitCode is null when the program never ran.

It deliberately does not vendor either runtime. Both are already on npm, so a browser names the URLs to load them from (DEFAULT_ASSETS pins the same versions this demo ships) and Node imports them from node_modules. See its README for the API, and THIRD-PARTY-NOTICES.md for what that does and does not require of the GPL-2.0 runtimes.

The demo on this page is built on the package. main.js is nothing but the DOM wiring, and it hands the package the vendor/ URLs — which is the whole point of the URL option: a page keeps its own pinned copies, and the package can still be published without carrying them.

Layout

index.html                 the page: examples, editor, stdin, output, register dump
main.js                    the UI; hands the vendored asset URLs to the package
vendor/                    keystone.js + keystone.wasm, unicorn_x86.js  (pinned, offline)
packages/assembly-wasm/    the published package — the engine, runtimes from URLs
serve.js                   static server; `node serve.js [port] [root]`

There is no bundler and no build step. main.js is an ES module that imports the package's source directly, and the package loads the two vendored runtimes as classic scripts on the first Run.

Verifying

what command
serve the page npm start → http://localhost:8123/
syntax-check npm run check
package tests (Node) npm test
package browser probe node serve.js 8124 → http://localhost:8124/packages/assembly-wasm/test/browser.html

The page exposes its element ids as globals (editor, stdinInput, runButton, output, statusEl, registers, examples) and reports state on document.documentElement.dataset (status, stage, runs, exitCode, syscalls), so a scripted check never needs to match UI text.

License

MIT (the page and the package). The vendored vendor/ runtime is GPL-2.0 (Keystone and Unicorn). Both execute as separate programs inside the page rather than being linked with it — the same arrangement LiveCodes already uses for its GPL-licensed language runtimes (R, Gnuplot) — but redistributing the vendored binaries means shipping their source offer and notices alongside them.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages