2.3 内存与表模型 本节摘要:模块的全部状态家当只有三样:线性内存、表、全局变量。线性内存是一块按页计量、越界即陷阱的字节平原;表是间接调用的登记簿;全局变量是类型化的最小状态单元。本节把三者的声明、扩容与协同讲透,并给出一段 WAT 与 JavaScript 配合的完整实例。 为什么内存偏偏按"页"来计量,而不是按字节随意声明?带着这个问题进入本节最合适——答案藏在沙箱与效率的平衡术里,读完你会对整个内存模型的理解上一个台阶。 线性内存:一块有封印的字节平原 线性内存是模块可自由读写的唯一区域。它的形状是连续的、从地址零开始的字节数组——没有分段、没有偏移基址、没有内存映射文件的复杂花样。
本节摘要:模块的全部状态家当只有三样:线性内存、表、全局变量。线性内存是一块按页计量、越界即陷阱的字节平原;表是间接调用的登记簿;全局变量是类型化的最小状态单元。本节把三者的声明、扩容与协同讲透,并给出一段 WAT 与 JavaScript 配合的完整实例。
为什么内存偏偏按"页"来计量,而不是按字节随意声明?带着这个问题进入本节最合适——答案藏在沙箱与效率的平衡术里,读完你会对整个内存模型的理解上一个台阶。
线性内存是模块可自由读写的唯一区域。它的形状是连续的、从地址零开始的字节数组——没有分段、没有偏移基址、没有内存映射文件的复杂花样。所有加载与存储指令都以"基地址加偏移"的方式访问它,越界访问不产生未定义行为,而是立刻触发陷阱,实例当场停摆。这种"宁可崩溃也不越读一字节"的语义,是沙箱安全的地板。
内存的容量以页为单位,每页 64 KiB。模块在内存节里申报初始页数与最大页数,实例化时按初始值分配,之后可以动态扩容:
(module ;; 申报:初始 1 页,最大 4 页 (memory (export "mem") 1 4) ;; 数据段:把字符串装进内存偏移 0 处 (data (i32.const 0) "cargo") ;; 查询当前页数 (func $pages (result i32) memory.size) ;; 尝试增长 2 页,返回旧页数;失败返回 -1 (func $expand (result i32) (memory.grow (i32.const 2))))
两条纪律决定了扩容的工程写法。其一,增长可能失败:宿主环境有内存上限,memory.grow 失败时返回 -1 而不是抛异常,调用方必须检查返回值再继续。其二,增长可能搬址:引擎为了给线性内存保留连续地址空间,扩容时会重新分配整块内存——之前由宿主持有的视图(JavaScript 里的 ArrayBuffer)会与新地址脱钩。所以成熟的做法是:先把数据写满当前容量、再扩容、再重建视图,任何"扩容后继续用旧视图"的代码都在埋雷。
从 JavaScript 侧看,线性内存是一个可导出的 WebAssembly.Memory 对象,其 buffer 属性就是宿主可读写的视图:
const res = await WebAssembly.instantiateStreaming(fetch("mem_demo.wasm")); const { mem, pages, expand } = res.instance.exports; console.log(pages()); // 1 —— 初始一页 const view = new Uint8Array(mem.buffer); console.log(Buffer.from(view.slice(0, 5)).toString()); // cargo console.log(expand()); // 1 —— 返回扩容前的页数 console.log(pages()); // 3 —— 现在三页 // 注意:mem.buffer 在扩容后已是新对象,旧 view 已失效
表回答的问题是:模块如何在运行时决定调用哪个函数?直接调用(call 指令)在编译期就锁定了目标;间接调用(call_indirect)则在运行时查一张表——表里登记着一串带类型的函数引用,调用方给出表索引,引擎核对签名后放行。C/C++ 的函数指针、面向对象语言的虚表、解释器的分派表,编译到 Wasm 后统统落在这张表上。
表与函数的关系是"登记"而非"包含":表存的是引用,被引用的函数本体仍在代码节里。元素节负责初始化表的初始内容,把函数按索引装订成册:
(module (type $binop (func (param i32 i32) (result i32))) (func $add (type $binop) (i32.add (local.get 0) (local.get 1))) (func $sub (type $binop) (i32.sub (local.get 0) (local.get 1))) ;; 声明一张最小两格、最多八格的函数表 (table $ops 2 8 funcref) ;; 元素段:零号格登记 add,一号格登记 sub (elem (i32.const 0) $add $sub) ;; 间接调用:按索引取函数并核对签名 (func $apply (param $op i32) (param $a i32) (param $b i32) (result i32) (call_indirect (type $binop) (local.get $a) (local.get $b) (local.get $op))))
call_indirect 的签名核对是表模型的安全设计:如果表里那个格子里登记的函数签名与调用点声明的类型不符,调用当场陷阱——绝不做"硬着头皮调下去"的事。这保证了即使模块自身的逻辑把索引算错(比如把数据当索引用),后果也只是本实例崩溃,不会劫持到任意地址。表还可以从宿主侧操作(WebAssembly.Table 的 get/set/grow),JavaScript 可以把宿主函数装进表里供模块间接回调,第五章的回调机制正是这样搭起来的。
全局变量是类型化的单值存储,声明时给定类型、可变性(常量或可变)与初值。它的定位是"不变式"多于"状态":经典用法是存放内存基址、栈顶指针、特性开关这类实例生命周期内少变的量。可变全局可以导出,宿主与模块就能共享少量轻量状态;但把全局当数据库用是明确的反模式——线性内存才是批量数据的家,全局塞满了既难调试又拖慢访问。

把三者装进一个真实场景——简易事件计数器:内存存计数值,全局存配置的告警阈值,表分派不同事件的累加策略:
(module (memory (export "mem") 1) (global $threshold (export "threshold") (mut i32) (i32.const 100)) ;; 计数区:每类事件在内存里占四个字节 (func $bump (param $slot i32) (i32.store (i32.mul (local.get $slot) (i32.const 4)) (i32.add (i32.load (i32.mul (local.get $slot) (i32.const 4))) (i32.const 1)))) (func $bump_click) ;; 细节略:调用 bump 零号槽 (func $bump_scroll) ;; 细节略:调用 bump 一号槽 (type $handler (func)) (table $routes 2 funcref) (elem (i32.const 0) $bump_click $bump_scroll) ;; 事件分派入口:宿主传事件类型编号 (func $dispatch (param $kind i32) (call_indirect (type $handler) (local.get $kind))) (func $over_threshold (param $kind i32) (result i32) (i32.gt_u (i32.load (i32.mul (local.get $kind) (i32.const 4))) (global.get $threshold))))
这段骨架浓缩了全节要点:数据在内存里按固定步长排布;阈值这种配置量住全局;事件路由走表分派;宿主每来一个事件调一次 dispatch,批量查询阈值时读内存即可——高频路径零互调。这个模式直接可以迁移到第七章的真实案例上。
箱子拆完、仓库看清,第三章下到分子层:指令如何推着操作数栈走,控制流为何必须是结构化的。