Chapter 9

Linear Memory

A linear memory is an array of bytes, indexed from zero, that a module can read and write with load and store instructions. It is where a compiled program keeps everything that has an address: its static data, its heap, and the parts of its stack that locals cannot hold. This chapter covers the memory itself and every instruction that uses it.

#Declaring a memory9.1

A memory's size is counted in pages of 64 KiB (65,536 bytes). A declaration gives the initial size, and optionally a maximum:

(module
  (memory $m 1 16))     ;; starts at 64 KiB, may grow to 1 MiB

Without a maximum, a memory can grow until the engine refuses. For a 32-bit memory, the hard limit is 65,536 pages, which is 4 GiB, since addresses are i32s. Browsers impose their own limits below that, and a tab may fail to grow far sooner if the device is short of memory.

Most modules have one memory, and the instructions in this chapter use memory 0 unless told otherwise. WebAssembly 3.0 allows several (section 9.9).

#Loads and stores9.2

i32.load pops an address and pushes the 4 bytes there, as an i32. i32.store pops an address and a value, and writes the value:

(module
  (memory 1)
  (func (export "main") (result i32)
    (i32.store (i32.const 100) (i32.const 0x12345678))
    (i32.load (i32.const 100))))
305419896

Memory is little-endian, always, whatever the hardware:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (memory 1)
  (func (export "main")
    (i32.store (i32.const 0) (i32.const 0x12345678))
    (call $print (i32.load8_u (i32.const 0)))     ;; 0x78
    (call $print (i32.load8_u (i32.const 3)))))   ;; 0x12
120
18

Every number type has a load and a store. The integer types also have narrow versions that read or write fewer bytes. A narrow load extends the value to the full width, signed or unsigned. A narrow store keeps the low bytes:

Load Reads Store Writes
i32.load 4 bytes i32.store 4 bytes
i32.load8_s, i32.load8_u 1 byte, extended i32.store8 the low byte
i32.load16_s, i32.load16_u 2 bytes, extended i32.store16 the low 2 bytes
i64.load 8 bytes i64.store 8 bytes
i64.load8_s … i64.load32_u 1, 2 or 4 bytes, extended i64.store8 … i64.store32 the low 1, 2 or 4 bytes
f32.load 4 bytes f32.store 4 bytes
f64.load 8 bytes f64.store 8 bytes
(module
  (import "env" "print_i32" (func $print (param i32)))
  (memory 1)
  (func (export "main")
    (i32.store8 (i32.const 0) (i32.const 0xfff0))   ;; stores 0xf0
    (call $print (i32.load8_u (i32.const 0)))
    (call $print (i32.load8_s (i32.const 0)))))
240
-16

These are what a compiler uses for the narrow types of its source language. A u8 field is loaded with load8_u and stored with store8, and arrives in an i32 already normalised.

#Offsets and alignment9.3

Every load and store has two immediates, written after the instruction: an offset and an align:

(i32.load offset=8 align=4 (local.get $p))

The effective address is the address popped from the stack plus the offset. The offset is how struct field access is usually compiled: the struct's address is on the stack, and the field's position within it is the offset, at no cost:

(module
  (memory 1)
  ;; struct Point { i32 x; i32 y; i32 z; }
  (func $set_point (param $p i32) (param $x i32) (param $y i32) (param $z i32)
    (i32.store offset=0 (local.get $p) (local.get $x))
    (i32.store offset=4 (local.get $p) (local.get $y))
    (i32.store offset=8 (local.get $p) (local.get $z)))
  (func (export "main") (result i32)
    (call $set_point (i32.const 64) (i32.const 1) (i32.const 2) (i32.const 3))
    (i32.load offset=8 (i32.const 64))))     ;; p->z
3

The addition does not wrap. The address is treated as an unsigned 32-bit number, the offset is added with full precision, and if the result is past the end of memory, the access traps. So (i32.load offset=8 (i32.const -4)) is an access at address 4,294,967,300, not at address 4.

align is a hint about the address's alignment, written in bytes in the text format and as a power of two in the binary. It is never checked: unaligned accesses are allowed, and work. The hint lets an engine choose faster instructions on hardware where unaligned access is slow, but on x86 and modern ARM it makes no difference, and engines largely ignore it. It may not be larger than the access itself (align=8 on an i32.load is a validation error). When you leave it out, it defaults to the access size, which is right for naturally aligned data.

#Out of bounds9.4

An access past the end of memory traps:

(module
  (memory 1)
  (func (export "main") (result i32)
    (i32.load (i32.const 65535))))     ;; needs bytes 65535..65538
RuntimeError: memory access out of bounds

The check covers the whole access, so a 4-byte load at the last byte fails even though its first byte is in bounds. Nothing is read or written when an access traps.

Address 0 is valid, and holds whatever was stored there. There is no unmapped page to catch null pointer dereferences:

(module
  (memory 1)
  (func (export "main") (result i32)
    (i32.store (i32.const 0) (i32.const 99))
    (i32.load (i32.const 0))))
99

If your language has null pointers, a null dereference will quietly read or write the bytes at address 0 unless you check for it. Many toolchains leave the first few bytes or kilobytes of memory unused, so that at least writes through null do not damage anything, and so that a debugger shows obviously wrong values. Chapter 10 comes back to this.

#Growing memory9.5

memory.size pushes the current size in pages. memory.grow pops a number of pages to add, and pushes the old size, or −1 if the memory cannot grow:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (memory 1 3)
  (func (export "main")
    (call $print (memory.size))
    (call $print (memory.grow (i32.const 1)))
    (call $print (memory.size))
    (call $print (memory.grow (i32.const 5)))      ;; past the maximum
    (call $print (memory.size))))
1
1
2
-1
2

New pages are zero-filled. Memory never shrinks: there is no instruction to give pages back. A program that allocates a large buffer once keeps that much memory for as long as the instance lives, which matters in a browser tab. Chapter 10 discusses allocators.

#Data segments9.6

A data segment is a run of bytes in the module that is copied into memory. The ones you have seen are active: copied at instantiation, to an address given by a constant expression:

(module
  (import "env" "print" (func $print (param i32 i32)))
  (memory (export "memory") 1)
  (data (i32.const 0) "abc")
  (data (i32.const 3) "\64\65\66")      ;; hex escapes: "def"
  (func (export "main")
    (call $print (i32.const 0) (i32.const 6))))
abcdef

Strings in the text format can contain any bytes, written as \xx hex escapes, so data segments can hold binary data, such as a precomputed table, as well as text.

A passive segment, declared without an address, is not copied automatically. Instead, memory.init copies some or all of it to an address chosen at runtime, and data.drop discards it once it is no longer needed, so the engine can free it:

(module
  (import "env" "print" (func $print (param i32 i32)))
  (memory (export "memory") 1)
  (data $greeting "Hello, again")       ;; passive: no address
  (func (export "main")
    ;; copy 5 bytes, from offset 0 of the segment, to address 1000
    (memory.init $greeting (i32.const 1000) (i32.const 0) (i32.const 5))
    (data.drop $greeting)
    (call $print (i32.const 1000) (i32.const 5))))
Hello

Passive segments are for data whose address is not known until runtime: data copied into a freshly allocated block, or, with threads, data that should be initialised by one thread rather than by every instance that shares the memory.

#Filling and copying9.7

memory.fill sets a range of bytes to a value, and memory.copy copies a range, correctly even when the source and destination overlap, like C's memmove:

(module
  (import "env" "print" (func $print (param i32 i32)))
  (memory (export "memory") 1)
  (data (i32.const 0) "abcdefgh")
  (func (export "main")
    (memory.copy (i32.const 2) (i32.const 0) (i32.const 4))   ;; dest, src, length
    (call $print (i32.const 0) (i32.const 8))
    (memory.fill (i32.const 0) (i32.const 0x2d) (i32.const 3)) ;; dest, byte, length
    (call $print (i32.const 0) (i32.const 8))))
ababcdgh
---bcdgh

The bulk memory instructions arrived in WebAssembly 2.0. Before them, compilers emitted byte-copying loops, and old code you read may still have them. They are worth using: engines turn them into calls to the platform's optimised memmove and memset, and they are much smaller than loops.

#Memory from JavaScript9.8

On the JavaScript side, a memory is a WebAssembly.Memory object whose buffer property is an ArrayBuffer holding its bytes. Typed arrays over that buffer read and write the same bytes the module does, which is how large amounts of data get in and out:

(module
  (memory (export "memory") 1)
  (func (export "sum") (param $p i32) (param $n i32) (result i32)
    (local $total i32)
    (block $done
      (loop $next
        (br_if $done (i32.eqz (local.get $n)))
        (local.set $total (i32.add (local.get $total) (i32.load8_u (local.get $p))))
        (local.set $p (i32.add (local.get $p) (i32.const 1)))
        (local.set $n (i32.sub (local.get $n) (i32.const 1)))
        (br $next)))
    (local.get $total)))
import fs from 'node:fs';

const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { memory, sum } = instance.exports;
const bytes = new Uint8Array(memory.buffer);
bytes.set([10, 20, 30, 40], 256);          // write four bytes at address 256
console.log(sum(256, 4));

100

There is one trap for the unwary here. When a memory grows, its old ArrayBuffer is detached: it becomes zero bytes long, and any typed array over it becomes empty. Code that keeps a typed array across a call that might grow memory will find it unexpectedly useless:

(module
  (memory (export "memory") 1)
  (func (export "grow") (drop (memory.grow (i32.const 1)))))
import fs from 'node:fs';

const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { memory, grow } = instance.exports;
let view = new Uint8Array(memory.buffer);
console.log('before:', view.length);
grow();
console.log('after: ', view.length);
view = new Uint8Array(memory.buffer);       // take a fresh view
console.log('fresh: ', view.length);

before: 65536
after:  0
fresh:  131072

The safe habit is to create typed arrays over memory.buffer just before using them, rather than caching them, or to check view.length === 0 and recreate them. Chapter 16 has more on moving data across the boundary.

#Multiple memories and 64-bit memories9.9

WebAssembly 3.0 added two extensions to memory.

Multiple memories: a module can define or import several memories, and every memory instruction takes an optional memory index:

(module
  (memory $main 1)
  (memory $scratch 1)
  (func (export "main") (result i32)
    (i32.store $scratch (i32.const 0) (i32.const 7))
    (i32.store $main (i32.const 0) (i32.const 9))
    (i32.load $scratch (i32.const 0))))
7

This is useful for keeping data the program must never touch through its ordinary pointers apart from the rest, such as a garbage collector's metadata, or a sanitiser's shadow memory, or a buffer shared with the host that should not be mixed into the heap.

64-bit memories, from the memory64 proposal, take i64 addresses and can exceed 4 GiB:

(module
  (memory i64 1)
  (func (export "main") (result i32)
    (i32.store (i64.const 8) (i32.const 5))
    (i32.load (i64.const 8))))
5

They cost a bounds check on every access, since the guard-page trick in section 9.4 does not extend to 64-bit addresses, and few browser programs need more than 4 GiB. For a game engine, 32-bit memory is the right default. Chapter 18 covers how to detect support for each feature.

#What you have seen9.10

A linear memory is a growable, bounds-checked array of bytes in 64 KiB pages, little-endian, with address 0 as valid as any other. Loads and stores come in every width, with an offset immediate for free address arithmetic and an alignment hint that is never checked. Data segments initialise memory, actively at instantiation or passively on demand, and memory.copy and memory.fill move bytes in bulk. JavaScript sees memory as an ArrayBuffer that is replaced whenever memory grows.

The next chapter is about what a compiler puts in this memory, and where.