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.