Back to course

The Element Section

24 minadvancedModule 4: Data PlacementContinues from wasm-instructions
24 min · Module 4: Data Placement · Algorithms

Why This Matters

The element section fills tables. That is its entire job, and it is a small enough job that you would expect a small encoding. Instead it has eight segment forms, and understanding why is the most useful thing this concept can teach — because the answer is that each form was added to solve a problem the previous forms could not, and none of them were removed.

A table holds function references, and the table section told you how big it is. The element section says what goes in it, and there are three genuinely different questions to answer about that:

Two binary choices would give four forms. There are eight, because the original design committed to the shortest possible encoding for the most common case and then added forms for the other combinations — including a declarative mode that has no analogue in the other two dimensions at all.

A Simple Model

The whole section is a vector of segments, and each segment starts with a flags byte that says which of the eight shapes follows:

element section: count how many segments follow segment: flags ONE byte, 0 to 7 ...then the fields that flags selects

The eight forms, laid out by the two binary dimensions plus the third:

FlagsModeTableElementsFields after the flag
0activeimplicit 0funcidx vectoroffset expr, then a count and that many indices
1passive—funcidx vectorelemkind byte, count, indices
2activeexplicitfuncidx vectortable index, offset expr, elemkind byte, count, indices
3declarative—funcidx vectorelemkind byte, count, indices
4activeimplicit 0expr vectoroffset expr, count, that many expressions
5passive—expr vectorreftype byte, count, expressions
6activeexplicitexpr vectortable index, offset expr, reftype byte, count, expressions
7declarative—expr vectorreftype byte, count, expressions

Read the table by rows and the pattern is obvious: forms 0–3 are the function-index family, 4–7 are the expression family, and within each family the four modes are in the same order. Active-implicit, passive, active-explicit, declarative. The numbering is structured, not arbitrary, which is worth knowing because it means the flag value alone tells you the encoding's family and its mode.

The 0x00 elemkind byte is the trap in this section, and it is the most dangerous single byte in the format. In forms 1, 2 and 3, an elemkind byte appears before the element count, and its only legal value is 0x00, meaning "the table element type is funcref". It is not a valtype — the valtype funcref is 0x70, as in forms 5–7. So the same concept has two different byte encodings in the same section, thirty bytes apart. A reader that expects 0x70 and gets 0x00 will not necessarily notice, and a reader that reads the 0x00 as a count of zero will report an empty segment for a segment that has three elements in it — and then desynchronise, because the three indices it did not read are still in the stream. Why does the byte exist at all if it is always zero? Because a later proposal wanted externref there, and the field was reserved rather than omitted. The flag space was extended (forms 4–7) before the elemkind byte was ever given a second value. A vestigial field kept for symmetry with a future, and the symmetry cost a byte and a reader's sanity.

The Actual Detail

One section, five segments, five different flag values, 32 bytes. Here are all of them and all eight forms are verified below:

0028: 05 00 41 00 0b 02 00 01 02 01 41 01 0b 00 01 02
0038: 03 00 01 00 01 00 02 00 01 02 04 41 02 0b 00 00

And wabt's own reading of the same bytes, which prints the flag value directly — so this is a check by a second reader on the exact field the concept is about:

  segment[0] flags=0 table=0 count=2 - init i32=0
    elem[0] = ref.func:0        elem[1] = ref.func:1
  segment[1] flags=2 table=1 count=1 - init i32=1
    elem[1] = ref.func:2
  segment[2] flags=3 table=0 count=1
    elem[1] = ref.func:0
  segment[3] flags=1 table=0 count=2
    elem[1] = ref.func:0        elem[2] = ref.func:1
  segment[4] flags=2 table=4 count=0 - init i32=2

Segment 4 is worth a second look: a table index of 4 and a count of zero. It is a legal active segment that writes nothing to table 4 at offset 2. It was generated because a (table $e) was declared and an element segment named it, but nothing was put in it. A reader that assumed a non-zero count would be wrong here, and a reader that assumed the table index was small would be suspicious of 4. Empty is not the same as absent, and this section contains both.

Decoding the first segment by hand

Four segments in this section are the function-index family, so let me walk all of them from the raw bytes:

  05                count: FIVE segments

  segment 0 -- flags = 0x00, active, table 0, funcidx vector
    00                flags = 0
    41 00 0b          offset: i32.const 0 ; end
    02                TWO elements
    00 01             funcidx 0, funcidx 1

  segment 1 -- flags = 0x02, active, EXPLICIT table, funcidx vector
    02                flags = 2
    01                table index = 1
    41 01 0b          offset: i32.const 1 ; end
    00                ELEMKIND: 0x00 = funcref   <- the trap byte
    01                ONE element
    02                funcidx 2

  segment 2 -- flags = 0x03, declarative, funcidx vector
    03                flags = 3
    00                ELEMKIND: 0x00
    01                ONE element
    00                funcidx 0

  segment 3 -- flags = 0x01, passive, funcidx vector
    01                flags = 1
    00                ELEMKIND: 0x00
    02                TWO elements
    00 01             funcidx 0, funcidx 1

Compare segment 1 with segment 3, which are the two that most often trip people up. Segment 3 is passive and has no offset expression and no table index; segment 1 is active and has both. A reader that decides "active versus passive" by looking for an offset will be wrong about form 1, where the byte sequence begins with the elemkind and there is no 0x41 to be found. And the elemkind byte looks identical in forms 1, 2 and 3, so a reader that special-cases one of them by position will be wrong about the other two.

The four forms wabt will not emit

Here is a finding worth having, because it would be easy to write this concept from the specification alone and get the claim backwards. wabt's writer lowers the expression forms to the function forms. Writing (elem (i32.const 0) (ref.func $f0) (ref.func $f1)) produces flags=0, not flags=4:

  (elem (i32.const 0) (ref.func $f0) (ref.func $f1))
      -> segment[0] flags=0 table=0 count=2 - init i32=0
         (the function-index form, NOT the expression form)

  (elem funcref (item (ref.func $f0)))
      -> segment[0] flags=1 table=0 count=1
         (passive function-index form, NOT flags=5)

So forms 4 through 7 cannot be produced with wat2wasm, and a course that claimed to have observed them from a .wat file would be wrong. They were verified here by hand-assembling the bytes and confirming that a second, independent implementation accepts them:

  form 4   04 41 00 0b 02 d2 00 0b d2 01 0b
          |   |  |     |  |  |     |
          |   |  |     |  |  +----- ref.func 1 ; end
          |   |  |     |  +-------- ref.func 0 ; end
          |   |  |     +----------- count = 2 expressions
          |   |  +----------------- offset: i32.const 0 ; end
          |   +-------------------- flags = 4
          +------------------------ ACTIVE, table 0, expression vector

  form 5   05 70 01 d2 00 0b        PASSIVE, reftype 0x70, 1 expression
  form 6   06 00 41 00 0b 70 02 d2 00 0b d2 01 0b
                                    ACTIVE, table 0, reftype, 2 expressions
  form 7   07 70 01 d2 00 0b        DECLARATIVE, reftype 0x70, 1 expression

Note the contrast that makes the trap concrete: form 4 has no type byte at all, while form 5 has 0x70, and both describe a funcref table. The difference is that form 4 is active into table 0, whose element type the module already declared in the table section, so there is nothing to check — while a passive or declarative segment has no table to inherit from and must state its own element type. The type byte appears exactly where the table section cannot supply the information. That is a design principle, and it predicts which of the eight forms have the byte without anyone having to enumerate them.

A Real Example

Deciding which form to emit, which is the operation every producer actually performs, and the arithmetic behind it:

choose_form(table_index, is_active, is_declarative, elements, type_section):

    needs_table_index = (table_index != 0)         # 0 is the common case
    needs_type_byte   = NOT is_active              # an active segment
                                                  # inherits its table's type
    elements_are_plain_funcidx = every element is a
                                  ref.func to a function in this module

    if elements_are_plain_funcidx:
        base = 0                       # forms 0-3
    else:
        base = 4                       # forms 4-7, expression vector

    if is_active:
        form = base + (2 if needs_table_index else 0)
    elif is_declarative:
        form = base + 3
    else:                              # passive
        form = base + 1

    # and the minimum size:
    #   form 0: 1 + offset_expr + 1 + n        (no type byte, no table index)
    #   form 4: 1 + offset_expr + 1 + n*3      (d2 xx 0b per element)
    # so an expression form costs 2 extra bytes PER ELEMENT

That last line is the whole economic argument for forms 0–3, and it is why wabt always uses them. A ref.func expression encodes as 0xd2, a LEB index, and 0x0b — three bytes for a function index that a funcidx vector stores in one. A table of 100 entries is 33 bytes larger in the expression form. Since the function-index forms can express every function reference that a module actually needs, and the expression forms exist only for values that have no function index, a producer should use forms 0–3 whenever it can and the expression forms only when it must. wabt's normalisation is not a limitation; it is the correct choice.

Now the three modes, and what each one actually buys, because "passive" and "declarative" are the two that puzzle people:

So the eight forms are not eight encodings of one idea. They are three capabilities, in two element representations, plus a table-index distinction that is an optimisation. A reader that treats the flags byte as a single enumeration of eight cases is correct, and a reader that understands the decomposition can extend the format correctly when a ninth mode is proposed — because it will already know which axis the new mode belongs to.

Try It Yourself

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

Check Your Understanding

Without looking back: a segment's bytes are 02 01 41 01 0b 00 01 02. What are the flag value, the table, the offset, the element type and the contents? And what would a reader that expected 0x70 for the element type report?

Apply It

You are writing a table-initialising runtime. On a module with one passive element segment holding three functions, your runtime copies nothing: the table comes up all null, and the module's first call_indirect traps. The same runtime handles every active segment correctly, and wasm-validate accepts the module. The segment's bytes are 01 00 03 00 01 02. What is the defect, and why did it not show up on any of your active-segment tests?

The general habit: when a field's position or presence depends on a preceding tag, branch on the tag for every value of it. And test the branch, not the happy path — a reader that handles the most common shape correctly has told you nothing about the other seven.

Connect

The element section's growth pattern is a case study in format evolution, and this collection has two other instances to set it against. The PE import directory grew by adding directory entries rather than variants, and DWARF's version number grew by reserving whole tag ranges per version. WebAssembly's approach — add a flag value, never remove one, and keep the old encodings valid forever — is the one that requires an eight-way switch in every reader, and it is the one that makes a modern reader strictly larger than a 2017 one. The alternative was to break the old encodings, and the format's designers chose file size over reader simplicity. That is a defensible trade for a format whose files are downloaded once and executed many times, and it is exactly the opposite of what COFF does with its auxiliary records, where the variety is handled by a count rather than by a tag.

The declarative mode has a direct counterpart in security mechanisms this course has already met. The PE load config and control flow guard exist to stop a module calling through a function pointer it did not author, and the declarative element segment is the same guarantee expressed in four bytes: only these functions may enter a table. A native platform needs a load-time table because addresses are unforgeable in practice; WebAssembly needs a section because indices are trivially forgeable, and says so explicitly.

The passive mode and the data section's passive form are the same idea for the two kinds of segment, and both need a datacount section before the instructions that consume them can be validated. That is the next concept, and it is the last piece of the core format: a section that exists only to let an earlier section be checked against a later one.