Back to course

Globals and Initialisers

19 minintermediateModule 2: DeclarationsContinues from wasm-tables-memories
19 min · Module 2: Declarations · Algorithms

Why This Matters

A global is a named value that lives for the module's whole life. It is the closest thing WebAssembly has to a static variable, and the reason the format has one at all is a specific and rather elegant constraint: a module may not modify anything it did not create. No global, no write to a read-only import; a global it declared, and then only if it declared that global mutable.

That constraint is what makes a WebAssembly module safe to run next to code it does not trust. There is no writable memory that two modules share unless both agreed to share it, and a module cannot scribble on an imported memory or a stack pointer belonging to its host. The global section is where that rule is expressed, and the mutability bit is the whole of it.

The second thing this section teaches is a shape you have not met anywhere else in a binary format: an initialiser that is an expression, not a value. Every other section stores data. This one stores a tiny program — an opcode stream, terminated by its own end marker — because a global's initial value may depend on another global. And that is the reason a value-looking field has to be walked rather than read, which is a trap worth studying.

A Simple Model

Two bytes of type information, then an expression that produces the value:

global section: count how many globals follow global, repeated `count` times: valtype i32 / i64 / f32 / f64 / v128 mut ONE byte: 0x00 = immutable, 0x01 = mutable init a CONSTANT EXPRESSION

The mut byte is a boolean written as a whole byte, not a bit inside a flags word. It has exactly two legal values, and they are 0 and 1 — this is the one place in the core format where a flag really is an enumeration, which is why the previous concept's warning about limits flags does not apply here. The distinction is worth holding precisely: limits flags have independent bits that happen to combine into small numbers, while this is a single yes/no and a reader that accepted 0x02 would be accepting an illegal module.

What a constant expression is

An expression in the WebAssembly sense is a sequence of instructions operating on a stack. A constant expression is the tiny subset that can be evaluated at load time rather than at run time, and it is what a global's initialiser must be:

41 xx 0b              i32.const xx ; end
42 xx 0b              i64.const xx ; end
43 xxxx 0b            f32.const (4 bytes, little-endian)
44 xxxxxxxx 0b        f64.const (8 bytes, little-endian)
23 xx 0b              global.get xx ; end
d0 xx 0b              ref.null <heaptype> ; end
d2 xx 0b              ref.func <funcidx> ; end

Every one ends with 0x0b, the same byte that ends a function body. That is not a coincidence and not a shared implementation convenience: the specification defines one end opcode used by both, and the body framing in the code section is what distinguishes the two uses. A global's initialiser is a function body with no locals, no parameters, and a restricted opcode set.

And that restricted set is why the initialiser can reference another global: global.get is in the constant subset, so

(global $a i32 (i32.const 10)) (global $b i32 (global.get 0)) // = $a = 10

works. What is not in the subset is arithmetic. There is no i32.add in a constant expression, so a global cannot be defined as "twice another global" — the compiler has to emit a global.get 0 plus a global.get 0 and add them in the code that uses it. The subset is chosen so that a reader can evaluate it with a stack of a known small size and no possibility of looping, branching, or trapping.

The Actual Detail

A module with eight globals covering the full signed range of both integer types. Eleven bytes of section, and every byte is visible:

0045: 08                    EIGHT globals
      |  |
      |  +-- the sixth:  7e 01 42 80 80 80 80 7f 0b
      |          |     |  |
      |          |     |  +-- i64.const, the value, then end
      |          |     +---- 0x01: MUTABLE
      |          +---------- 0x7e: i64
      +--------------------- 0x45: payload starts here

Read the last entry in full, because it is the one that shows why the initialiser has to be walked:

  7e                 valtype: i64
  01                 mutable
  42                 opcode: i64.const
  80 80 80 80 7f     the operand: a 5-byte SIGNED LEB128
  0b                 end

That operand is -1. And a decoder that assumes a constant expression is opcode, one byte, end reads 0x80 as the value, treats 0x80 as an opcode, and is already lost. The rule is simple and the discipline is what matters: an opcode stream is read until its terminator, never to a guessed length. This is the same trap as the data section's offset expressions, the same trap as a DIE chain that must be walked rather than sized, and the reason this course keeps finding it in a new place is that a length-prefixed container and a self-delimiting one look identical until one of them is variable-width.

What the two reference tools print, and why they differ

Here is the same eight globals read by wabt's detail view, with the values my decoder recovers from the same bytes:

  wabt -x                        my decoder
  global[0] init i32=0              i32.const 0 end
  global[1] init i32=42             i32.const 42 end
  global[2] init i32=-1             i32.const -1 end
  global[3] init i32=-1000000       i32.const -1000000 end
  global[4] init i32=2147483647     i32.const 2147483647 end
  global[5] init i32=-2147483648    i32.const -2147483648 end
  global[6] init i64=-1             i64.const -1 end
  global[7] init i64=9223372036854775807   i64.const 9223372036854775807 end

All eight agree. And the interesting part is that wabt gets this right in one subcommand and wrong-looking in another. The same three bytes 41 7f 0b mean i32.const -1. Ask for details:

$ wasm-objdump -x globs.wasm | grep 'global\[2\]'
   - global[2] i32 mutable=0 - init i32=-1        <- signed, correct

$ wasm-objdump -d neg.wasm | grep '41 7f'
   000049: 41 7f    | i32.const 4294967295      <- UNSIGNED

One tool, two subcommands, two presentations of the same two bytes. The detail view is interpreting the operand as the value of an initialiser and printing it signed; the disassembler is printing an instruction operand and treating it as the 32-bit bit pattern. Both are internally consistent, and one of them will mislead anyone who copies a number out of it.

Why the unsigned display is the dangerous one. A disassembler prints a bit pattern because that is what a disassembler is for — you are looking at bytes, and a byte has no sign. A detail view prints a value because you are looking at a program, and a program's i32 initialiser is a signed integer. The wrong one to trust is the disassembly, because it is the one people read when they want to know what a number is. If you copy 4294967295 out of a disassembly into a program and compare it to a source constant -1, the comparison fails and the number of plausible explanations is large. When a tool prints a signed value in two different ways, the difference is not a bug in either tool; it is two different questions being answered, and the reader has to know which question was asked.

Mutability, and what it actually protects

The mutability bit is the module's own declaration about whether it will write to a global, and it is enforced by the validator rather than by convention. Three consequences follow, and the third is the one that makes the rule worth having:

That third point is the security argument for the whole feature, and it is worth seeing it against a native platform. In a PE image a global variable is an address, and the linker will happily point one at a function, another at a data object, and a third at an import thunk — three "globals" with three unrelated meanings distinguished only by convention. WebAssembly makes all three of those explicit and checked, and pays for it with a global that can only be a number.

A Real Example

Evaluating an initialiser, which is the operation a reader does once per global at load time, and the reason it cannot simply be "read the bytes and interpret them as a value":

eval_const_expr(module, offset) -> (value, type, bytes_consumed): stack = [] # a value stack, as an interpreter loop: opcode = read_u8() 0x41: push i32 (read_sleb32) # signed: 0x7f is -1, not 127 0x42: push i64 (read_sleb64) 0x43: push f32 (read 4 bytes, little-endian) 0x44: push f64 (read 8 bytes, little-endian) 0x23: push (module.globals[read_uleb].current_value) 0xd0: push null (read heaptype) 0xd2: push (ref to module.funcs[read_uleb]) 0x0b: RETURN (pop one value, and bytes_consumed) anything else: REJECT not a constant expression. The validator's error, and it is the one place a reader that skipped validation will silently accept something it must not.

Two details in that are the whole lesson. read_sleb32, not read_uleb32 — the operand is a signed LEB128, so 0x7f is -1, and a reader that reads it unsigned gets 127 for every -1 in the file. And global.get reads the value of an earlier global, which is legal only because the constant subset contains no arithmetic and no control flow, so the dependency graph over globals is acyclic by construction: a global can only read globals numbered below it, and reading a later one is a validation error.

That acyclicity is a design choice, and it is load-bearing. A constant expression with i32.add in it could still be acyclic by the same argument, so the restriction is not strictly necessary — but restricting to leaf values makes the evaluation obviously terminating and the dependency obviously a DAG, and it is why a reader can evaluate globals in declaration order without a fixpoint loop. Choose the subset that makes the naive algorithm correct.

Now the failure that the naive algorithm invites, which is the most valuable thing in this concept because it is entirely silent. Suppose the reader treats an initialiser as a fixed four bytes — a plausible guess for "a value" before you think about the encoding:

  correct:   7f 01 42 80 80 80 80 7f 0b     9 bytes, 5-byte operand
  naive:     7f 01 42 80 80 80 80            6 bytes read
            -> reports i64 = 0x80808080 = 2155905152
            -> the next global starts at 0x0b, which is 0x7f
            -> reads 0x7f as a valtype (i32), 0x0b as mutability (11!),
               then runs off into the instruction bytes

  and the worst part: the wrong value 2155905152 is a perfectly
  plausible i64. Nothing complains. The global is just wrong.

So the desynchronisation is caught eventually, but the first thing the reader gets wrong is a number that looks entirely reasonable, and if the module had no second global the desync would never happen at all — the reader would report one wrong value and stop. A decoder that cannot detect its own desync will report a confident wrong answer rather than an error, and the only defence is to decode each structure by the rules that structure has.

Try It Yourself

$ wasm-objdump -x globs.wasm | sed -n '/^Global/,$p'
$ python3 courses/wasm/assets/samples/wasm_decode.py globs.wasm

Check Your Understanding

Without looking back: a global's bytes are 7e 01 42 80 80 80 80 7f 0b. What is its type, its mutability, and its value? And what would a reader that read the initialiser as 0x42 plus a single unsigned byte report?

Apply It

You are writing a loader. A module's globals come out as: the first correct, the second correct, the third reporting i64 = 2155905152 when the source says -1, and the fourth reporting a mutability of 11. Everything after the third is garbage, and the module validated before you ran it. The third global's bytes are 7f 01 42 80 80 80 80 7f 0b. What is the defect, why did validation not catch it, and what is the general rule for reading any field in this format that could be variable-width?

The general habit: after decoding each structure, assert that your position matches what the enclosing section's framing says it should. That turns a silent desync into a clean error at the section boundary, and a desync at the section boundary is a bug you can find in one print statement.

Connect

The mutability bit is the WebAssembly answer to a question every executable format has to answer differently, and the three answers are worth comparing. An ELF section's SHF_WRITE flag is a property of a whole region, fixed at link time. A COFF section's characteristics work the same way. A PE section's VirtualProtect is mutable by default and only becomes read-only if something asks. WebAssembly's answer is the most restrictive of the four: the unit of mutability is a single value, it is declared, and the default is read-only.

That default matters more than it looks. A format where immutable is the default and mutable is something you have to ask for means a mistake is always in the safe direction, which is a property worth designing for. It is the same instinct behind the ELF header's read-only program headers preceding the writable ones, and behind a linker putting .text before .data.

The constant-expression subset connects to PE base relocations, which is the nearest thing in this collection to a limited computation at load time. A base relocation record says "add the image base to this address", and the set of them is fixed by the linker. WebAssembly's initialisers are the same idea generalised: a tiny declarative language whose only job is to produce a value before execution starts. The difference is that WebAssembly's is genuinely an expression with a stack, so it can reference other globals, where a relocation can only add a constant.

And the walked-to-terminator discipline has now appeared in the element section, the data section, the global section, and every function body. That is four places in one small format where the same rule applies, which is the strongest argument in this course for treating "how do I know where this ends" as the first question to ask about any field. DWARF's DIE chains and the PE resource directory tree are the same shape in completely different formats, and a reader who has internalised it there will not have to relearn it here.

Next: the element and data sections, where the eight element encodings come from, and then the object format the final module covers.