1. 整数编码的世界观:为什么WebAssembly只认整数
第一次接触WebAssembly的人,十有八九会先愣一下:i32.add、i64.mul、i32.load,整个指令集里翻来覆去就那么几个整数类型,连个字符串都没有,布尔值也得用i32假装。这不是设计者的疏忽,恰恰相反,这是WebAssembly最核心的设计哲学之一——让一切变得可预测、可验证、可极致优化。
想想看,你写JavaScript的时候,1 + "1"是"11",0.1 + 0.2是0.30000000000000004,类型转换规则能把人绕晕。而WebAssembly把类型系统砍到只剩整数和浮点,每个操作都明确标注了是i32还是i64,是带符号还是无符号,是回卷(wrap)还是陷阱(trap)。这种“笨办法”换来的是执行引擎可以放心大胆地做JIT优化,因为每一个整数运算的语义都被规范钉死了,没有任何“隐式转换”的暧昧空间。
从实际应用场景来看,这套整数体系承担了WebAssembly里几乎所有“重体力活”:
- 数组和对象的内存地址计算,全靠整数算术来定位偏移
- 状态机、协议解析、编解码器,本质就是整数位运算的大杂烩
- 甚至字符串操作,最终也要落到UTF-8字节的整数处理上
- 与JavaScript互操作时,
BigInt和i64的边界转换全靠整数规则
我见过不少从C/C++转过来的开发者,在理解WASM整数语义时很容易踩坑。最典型的就是:C语言里int的溢出是“未定义行为”,编译器想怎么优化就怎么优化;而WebAssembly的i32.add溢出是完全被定义的——直接做二的补码回卷(two's complement wrap),结果等于数学上的模 2^32 运算。这不是WASM在偷懒或者图省事,而是一个深思熟虑的设计决策,后面我展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LEB128变长编码:WASM二进制格式的数字命脉
2.1 为什么要用变长编码
如果让你设计一套二进制指令格式,整数常量怎么存?最直接的想法是固定宽度,比如i32就固定占4字节。但这会带来一个很实际的问题:绝大多数整数常量其实都很小。get_local 0这种指令里,局部变量索引需要编码;i32.const 5这个常量也需要编码。如果所有整数都用4字节表示,一个只有几十条指令的小模块,光索引和常量就得白白浪费几百字节。
LEB128(Little Endian Base 128)变长编码就是来解决这个问题的。核心思想很简单:每个字节只用7位存数据,最高位作为“续组标志”,如果最高位是1,说明后面还有字节,需要继续读;如果是0,说明这是最后一个字节。数字越小,占用的字节越少,0到127只需要1个字节,128到16383需要2个字节,以此类推。
这个方案非常聪明,因为它完美匹配了整数取值分布的幂律特征:绝大多数索引和常量都集中在较小的区间内,用变长编码能获得接近“实时压缩”的效果,而且解压不需要任何查表或预处理,纯粹顺藤摸瓜一路读下来就行。V8、Wasmer、Wasmtime这些引擎加载二进制模块时,最热路径上的第一步就是成千上万次跑LEB128解码,其重要性就是这么撑起来的。
2.2 无符号LEB128:u32的编解码细节
无符号LEB128的算法非常直观。编码过程就是把数字按7位一组切分,每次取低7位,然后右移7位,直到数字变成0:
code复制假设要编码数字 624485
二进制:10011000011101100101 (这是一个20位的数)
按7位分组,从低位开始:100 1100001 1101100101
第一组:1101100101 的低7位是 0100101,加续组标志变成 10100101
右移7位后剩下:1001100001
第二组低7位:0100001,加续组标志变成 10100001
右移7位后剩下:100
第三组低7位:0000100,这是最后一组,变成 00000100
最终编码结果就是0xA5 0xA1 0x04,三个字节搞定。如果用固定4字节,就是四个字节,看起来差别不大,但考虑到一个模块里可能有成千上万个这样的整数,总量差距就很可观了。而且LEB128的解码逻辑极其简单——循环读字节、检查最高位、累加位移,几十行代码就能写完一个高效的解码器。
2.3 有符号LEB128:s32/s64的符号扩展艺术
有符号整数就没那么直白了。难点在于:负数在补码表示下,高位全是1,如果不做任何处理,一个-1按无符号规则编码会变成5个字节的0xFF 0xFF 0xFF 0xFF 0x0F(i32),白白浪费空间。
WebAssembly的解决方案是符号扩展(sign extension):先对原值做符号位扩展,补到一个足够容纳所有有效位的宽度,然后再按无符号规则编码。具体规则是这样的:
- 输入是负数时,用符号位填满高位,直到高位变成全1为止
- 输入是正数时,高位置0,缩短到最高有效位为止
看两个实际例子。编码i32.const -1(32位全1),规则要求它编码成1字节的0x7F,因为对-1做符号扩展后,低7位正好是1111111,再往后都是1的延续,已经不需要再编码了,直接把首字节高位清0即可。编码i32.const -128呢?-128的二进制是10000000,低7位是0000000,但这里的最高有效位是符号位1,光看低7位无法区分“这到底是不是最后一个字节”,所以继续向高位看,发现第8位也是1,说明还有信息要编码,于是低7位加续组标志得到10000000,继续右移7位,剩下1,再加一次得到0x01,最终结果就是0x80 0x01。
这个逻辑乍一听很绕,但核心规律就一句话:负数编码,什么时候“符号扩展后的剩余值”变成全1,什么时候就停。
2.4 分节编码与陷阱:WASM的严格校验
真正把
