Pure Common Lisp WebAssembly runtime. Zero external dependencies.
Passes 100% of the WebAssembly MVP spec tests (24,877/24,877 non-skipped).
Requires SBCL (tested with 2.6.1). Symlink or copy to your local-projects:
ln -s /path/to/cl-wasm ~/.roswell/lisp/quicklisp/local-projects/cl-wasm(asdf:load-system :cl-wasm)(asdf:load-system :cl-wasm)
;; Load and instantiate a .wasm file
(let* ((module (cl-wasm:load-module #P"add.wasm"))
(instance (cl-wasm:instantiate module)))
;; Call an exported function
(cl-wasm:call-export instance "add" 1 2))
;; => 3From a file:
(defvar *module* (cl-wasm:load-module #P"path/to/module.wasm"))From an in-memory byte vector:
(defvar *module* (cl-wasm:load-module-from-octets bytes))Both return a wasm-module struct. You can inspect it before instantiation:
(cl-wasm:wasm-module-exports *module*)
(cl-wasm:wasm-module-imports *module*)(defvar *instance* (cl-wasm:instantiate *module*))
;; Call an exported function by name
(cl-wasm:call-export *instance* "factorial" 10)
;; => 3628800
;; Arguments are auto-tagged based on the function signature.
;; Return values are auto-untagged.
;; Multiple return values come back as a list.Get any export (function, memory, table, global):
(cl-wasm:get-export *instance* "memory")
;; => (:memory . #<WASM-MEMORY-INSTANCE ...>)Read/write linear memory from the CL side:
(let ((mem (cl-wasm:get-instance-memory *instance*)))
;; Write bytes
(cl-wasm:wasm-memory-write-bytes mem #(72 101 108 108 111) 0)
;; Read bytes
(cl-wasm:wasm-memory-read-bytes mem 0 5)
;; => #(72 101 108 108 111)
;; Write/read strings (UTF-8)
(cl-wasm:wasm-memory-write-string mem "Hello, Wasm!" 100)
(cl-wasm:wasm-memory-read-string mem 100 12)
;; => "Hello, Wasm!"
)Run WASI modules (compiled with wasi_snapshot_preview1):
(multiple-value-bind (instance ctx)
(cl-wasm:instantiate-with-wasi
(cl-wasm:load-module #P"hello.wasm")
:args '("hello" "world")
:env '(("HOME" . "/home/user")))
;; Call _start (WASI entry point)
(handler-case
(cl-wasm:call-export instance "_start")
(cl-wasm:wasi-exit (e)
(format t "Exit code: ~D~%" (cl-wasm:wasi-exit-code e)))))Implemented WASI functions: proc_exit, fd_write, fd_read, fd_close, fd_seek, environ_sizes_get, environ_get, args_sizes_get, args_get, fd_prestat_get, fd_prestat_dir_name, clock_time_get, random_get.
Use .wasm files directly as ASDF components:
(defsystem :my-app
:defsystem-depends-on (:cl-wasm)
:depends-on (:cl-wasm)
:components
((:wasm-module-file "crypto"
:package :my-crypto
:wasi t)
(:file "main")))This loads crypto.wasm, instantiates it with WASI support, and creates the :my-crypto package with all exports on asdf:load-system.
Generate a CL package from a Wasm module's exports:
(cl-wasm:define-wasm-module :my-math #P"math.wasm")
;; Exported Wasm functions become CL functions in the package.
;; "add_two" -> MY-MATH:ADD-TWO
(my-math:add-two 10 20) ;; => 30All conditions inherit from cl-wasm:wasm-error:
| Condition | When |
|---|---|
wasm-decode-error |
Binary format parsing failure |
wasm-validation-error |
Module validation failure |
wasm-link-error |
Import resolution / instantiation failure |
wasm-trap |
Runtime trap (unreachable, div-by-zero, OOB, etc.) |
wasi-exit |
proc_exit called (not an error, a condition) |
(handler-case
(cl-wasm:call-export instance "risky_func")
(cl-wasm:wasm-trap (e)
(format t "Trap: ~A~%" e)))From C:
clang --target=wasm32-unknown-unknown -nostdlib -Wl,--no-entry \
-Wl,--export-all -o add.wasm add.cFrom C with WASI:
clang --target=wasm32-wasi --sysroot=/usr -o hello.wasm hello.cFrom WAT (text format):
wat2wasm add.wat -o add.wasmWebAssembly MVP: 24,877 pass, 0 fail (15 skipped assert_exhaustion tests).
Tested against the official WebAssembly spec test suite using wast2json + JSON runner.
The 15 skipped tests are all assert_exhaustion (stack overflow detection). The interpreter uses CL's native call stack, so infinite Wasm recursion produces a CL-level stack overflow rather than a clean wasm-trap.
CoreMark 1.0 benchmark (aarch64 Linux, SBCL 2.6.1):
| Runtime | CoreMark | vs Native |
|---|---|---|
| Native C (GCC -O2) | 35,350 | 1.00x |
| wasmtime (JIT) | 29,447 | 0.83x |
| cl-wasm (compiler) | 1,147 | 0.032x |
| cl-wasm (interpreter) | 72 | 0.0020x |
The compiler is enabled by default. Set cl-wasm:*enable-compiler* to nil before instantiation to use the interpreter. Functions that fail to compile fall back to the interpreter automatically.
Note: Each compiled function is a separate CL closure produced by (compile nil ...). Instantiating many large modules in a single image can consume significant heap. If memory is a concern, disable the compiler for bulk loading.
- Wasm-to-CL compiler -- compiles Wasm functions to native CL closures via
(compile nil ...)at instantiation time. Uses a simple-vector stack with fixnum stack pointer, SBCL type declarations for unboxed i32 arithmetic, and CLblock/tagbodyfor Wasm control flow. - Tree-walking interpreter -- fallback for functions the compiler cannot handle. Instructions are decoded into struct trees at load time.
- Full validation -- binary reader performs complete type validation (stack typing, control flow, index bounds) per the spec.
- No external dependencies -- pure ANSI CL with SBCL-specific workarounds for IEEE 754 edge cases only.
- No call stack depth limit -- deep Wasm recursion exhausts the CL stack instead of trapping cleanly.
- No streaming/incremental loading -- the entire
.wasmfile must be in memory before decoding begins. - No WAT text format reader --
load-moduleonly accepts binary.wasm. Usewat2wasm(wabt) to convert.
None of these are currently implemented: fixed-width SIMD, memory64, threads/atomics, exception handling, tail calls, multi-memory, GC, component model.
- Register allocation -- reduce stack manipulation overhead with virtual registers
- Inlining of small Wasm functions -- eliminate call overhead for leaf functions
- Direct memory access -- SAP-based memory operations to replace byte-at-a-time access
MIT