3.3 加载存储与内存访问 本节摘要:加载与存储指令是模块触碰外部世界的另一只手。本节拆解指令的三段式地址逻辑(基地址加静态偏移)、对齐提示的真实含义、按字节宽度的加载家族,并手工管理一段结构体内存布局——编译器平时替你遮住的那层,这里全部摊开。 凌晨的压测现场,某个 Wasm 化的解析服务吞吐忽然腰斩,日志里却没有陷阱记录——最后发现是新加的字段把结构体从八字节对齐撞成了错位访问,热路径上全是跨缓存行的搬运。这类事故的病根都在本节的知识点上:内存指令的写法、布局的决定、对齐的语义。把这一节读透,事故现场就能倒推出元凶。 加载存储指令的三段式地址 Wasm 的内存指令统一长成"操作 加 偏移"的样子。
本节摘要:加载与存储指令是模块触碰外部世界的另一只手。本节拆解指令的三段式地址逻辑(基地址加静态偏移)、对齐提示的真实含义、按字节宽度的加载家族,并手工管理一段结构体内存布局——编译器平时替你遮住的那层,这里全部摊开。
凌晨的压测现场,某个 Wasm 化的解析服务吞吐忽然腰斩,日志里却没有陷阱记录——最后发现是新加的字段把结构体从八字节对齐撞成了错位访问,热路径上全是跨缓存行的搬运。这类事故的病根都在本节的知识点上:内存指令的写法、布局的决定、对齐的语义。把这一节读透,事故现场就能倒推出元凶。
Wasm 的内存指令统一长成"操作 加 偏移"的样子。一条完整指令由三部分拼出实际地址:指令自带的对齐与宽度参数、写在指令里的静态偏移量、运行时从栈顶弹出的动态基地址。有效地址就是两者之和:
(module (memory 1) ;; 从地址 base+offset 处读一个 i32 (func $read_i32 (param $base i32) (result i32) (i32.load offset=8 (local.get $base))) ;; 只读低字节并按无符号扩展 (func $read_byte (param $addr i32) (result i32) (i32.load8_u (local.get $addr))) ;; 向 base+offset 写入一个 i32 (func $write_i32 (param $base i32) (param $val i32) (i32.store offset=8 (local.get $base) (local.get $val))))
静态偏移的价值在结构化访问:编译器把"结构体第 N 个字段"翻译成固定 offset,基地址由对象指针承担。指令家族按宽度铺开——i32.load8_u、load8_s、load16_u、load16_s 读窄宽度并扩展符号或补零,store8、store16 只写低位;i64 家族同理。读写宽度不对称是常见笔误来源:八位写入配三十二位读出,得到的值会混入邻近字节的"旧住户",且验证器毫无意见——类型对它来说合法,语义错在你。
字节序是另一条铁律:线性内存一律小端序。x86 程序员通常无感,从大端平台迁移代码时要格外小心手工拼包的字节顺序——网络协议里的大端字段必须显式做字节翻转,而不能指望内存指令替你纠正。
每条加载存储指令都带一个对齐参数,形如 align=4。它的语义出人意料地温和:纯提示,不含检查——对齐写错,指令照常执行,结果照常正确。规范的用意是给优化器递纸条:"这个访问按 N 字节对齐,你可以放心选最快的那条原生指令"。
提示与现实的落差才是性能所在。引擎会按对齐提示选择原生加载路径;跨缓存行的错位访问则要拆成多次搬运再拼接,热路径上一次错位就足以拖垮整段循环——开头压测腰斩的元凶正是它。工程习惯由此而来:结构体按最大成员对齐、数组步长取对齐倍数、热数据的布局让同批访问的字段挤在同一缓存行。编译器对高级语言代码默认做得很好,手写 WAT 或手工布局内存时,这份责任就回到你手里。
把"编译器替你做的事"亲手做一遍。目标是管理一个三字段记录(id 为 i32,score 为 f32,flags 为 i32),总宽十二字节:
(module (memory (export "mem") 1) ;; 布局常量:id 在偏移零,score 在四,flags 在八 (func $rec_write (param $base i32) (param $id i32) (param $score f32) (param $flags i32) (i32.store offset=0 (local.get $base) (local.get $id)) (f32.store offset=4 (local.get $base) (local.get $score)) (i32.store offset=8 (local.get $base) (local.get $flags))) (func $rec_score (param $base i32) (result f32) (f32.load offset=4 (local.get $base))) ;; 记录数组:第 i 条记录的基地址等于 i 乘以十二 (func $rec_at (param $i i32) (result i32) (i32.mul (local.get $i) (i32.const 12))))
从 JavaScript 侧按同一份布局镜像读写,两端就共享了同一块内存里的数据结构:
const { instance } = await WebAssembly.instantiateStreaming(fetch("recs.wasm")); const mem = new DataView(instance.exports.mem.buffer); const REC = 12; // 单条记录十二字节 instance.exports.rec_write(REC * 0, 1001, 97.5, 3); instance.exports.rec_write(REC * 1, 1002, 88.0, 1); console.log(mem.getInt32(REC * 0 + 0, true)); // 1001 小端序 console.log(mem.getFloat32(REC * 1 + 4, true)); // 88.0
这个例子浓缩了跨边界数据共享的全部要点:两侧对同一份布局达成书面约定、步长一致、偏移一致、字节序一致。第五章的数据传递机制会把这套约定升级成系统化的工程方法;批量操作场景下还有两条捷径——memory.copy 与 memory.fill 指令(批量内存提案的标准成员),分别承担整块搬移与整块填充,替代手写循环。
内存的手艺到手,下一节回到运行时的门槛:实例化与链接,看静态字节点亮成运行态的完整仪式。