字节码、代码生成、链接与优化
1. 栈式指令集#
opcodes.h 是指令集合与长度等元数据的集中来源;opcode.h 定义枚举。编译器有 OP_FIRST_INVALID == 131 的编译期断言。这里的 131 是当前编号空间边界,不是一次游戏运行实际使用的指令数。[S1]
指令主要包括:
| 组 | 例子 | 工作方式 |
|---|---|---|
| 数值 | IADD/FADD/VADD、比较、I2F/F2I | 按整数、浮点、向量解释栈顶 |
| 常量与栈 | PUSH_CONST、DUP、DROP | 不同宽度立即数压栈 |
| 存储 | LOCAL/STATIC/GLOBAL、LOAD/STORE、LOAD_N/STORE_N | 通过帧/实例/共享区域偏移访问 |
| 聚合寻址 | ARRAY、IOFFSET | 数组下标检查、结构字段地址计算 |
| 流程 | J、JZ、SWITCH | 分支和循环 |
| 脚本调用 | CALL、CALLINDIRECT、ENTER、LEAVE | 保存返回地址、帧指针和返回值 |
| 原生调用 | NATIVE | 按程序 Native 表索引找到包装器 |
| 文本/特殊 | STRING、TEXT_LABEL_*、CATCH、THROW | 字符串池、缓冲区及非局部恢复 |
普通变量没有运行时名字查找;名字主要用于编译期和调试。与寄存器 VM 相比,栈式 VM 的生成器简单,但算术组合可能产生更多栈操作和解释器派发。
2. 从源语句到 opcode#
以下是讲解用的逻辑序列,不是本次执行编译器获得的反汇编:
源:score = score + 1
朴素逻辑:
取得 score 地址
LOAD
PUSH_CONST 1
IADD
取得 score 地址
STORE
可能压缩为:
LOCAL_U8_LOAD <slot>
IADD_U8 1
LOCAL_U8_STORE <slot>具体指令取决于 score 是 local/static/global、偏移范围、邻接指令和分页边界。不能把教学序列当成固定真实输出。
语句 AST 直接调用 Emit。IF/循环先留出跳转位置,发射主体后回填;函数调用先记录尚未发射的目标,再由 EmitAccumulatedCalls 生成函数并修补 24 位地址。[S2]
3. 已确认的优化#
| 优化 | 实现证据 | 适用范围与限制 |
|---|---|---|
| 常量折叠 | scrNodeBinary::IsConstant | 主要是已支持的整数常量操作;不是通用执行器 |
| 常量分支消除 | scrNodeIf::Emit | 常量条件选择分支 |
| 按引用生成函数 | EmitAccumulatedCalls | 从调用/取地址出发补发函数体 |
| 紧凑立即数 | EmitInt、EmitFloat | 小值专用 opcode、U8/S16/U24/U32 等 |
| 地址+读写融合 | EmitOp | LOCAL/STATIC/GLOBAL/ARRAY/IOFFSET 后接 LOAD/STORE |
| 算术立即数融合 | EmitOp | PUSH 常量 + IADD/IMUL/IOFFSET |
| 比较跳转融合 | EmitOp | 整数比较 + JZ |
| 多常量压栈融合 | EmitOp | 连续 U8 常量压栈合并 |
| 字符串池复用 | scrNodeString::Emit 附近 | 查找已有字符串,避免重复存储 |
| 热 Native 内建化 | EmitNativeStub | IS_BIT_SET 特判为 opcode |
| 发布裁剪 | DEBUGONLY、-final | 去除相关调用/名字,不是业务语义无关的任意优化 |
这些是树级简化、按需生成与窥孔优化,未发现当前主链路使用 SSA、通用循环向量化、JIT、动态去虚拟化或成熟的全程序别名分析。不要将 C++ 输出路径中的历史注释当成当前 VM 已取得的性能收益。[S2][S3]
4. 分页同时约束指令布局#
程序代码和字符串页大小为 16 KiB。EmitOp 明确避免普通指令跨越代码页;不够放时填 NOP 或生成跨页跳转。跳转、字符串池偏移和解释器 PC 换页必须协同处理。[S2][S4]
这解释了为何同样指令级优化在页尾可能不能应用:优化不仅改变速度,还改变指令长度和跳转距离。短跳转、24 位 CALL、11 MiB 编译缓冲和页内连续要求是不同维度的约束。
分页有利于资源系统装载和内存组织,但“分页资源”不等于操作系统按需缺页或任意指令执行时自动请求磁盘。当前代码支持的具体流式策略需看资源系统的装载生命周期。
5. 调用格式和 ABI 成本#
ENTER 编码参数槽数、局部帧大小和可选过程名;参数槽数用 u8,局部数用 u16。大结构体按值传参可能在达到 256 槽前触发参数限制,而不是按“有几个形参”计算。[S3]
NATIVE 长度为四字节:opcode、包含返回槽数与参数槽数的打包字节、两字节 Native 表索引。返回槽数用两位,参数槽数用六位,当前编译器 Native 表容量为 4,096。[S3] VECTOR 返回需要三槽;任意大 STRUCT 返回不在这套 Native 返回编码的直接容量内。
这也是改语言时必须升级工具链而非只改 parser 的原因:类型大小、指令编码、包装模板、资源格式和调试器都依赖同一 ABI。
6. 产物不是只有字节码#
scrProgram 包含代码页、静态初值、全局初值/块信息、字符串页、Native 标识数组、启动参数大小和名称/调试相关数据。保存 .sco 时还处理压缩、字节序与 Native 表重排及引用重映射。[S5]
Native 名称并非每次调用重新哈希查找。装载验证时将标识解析成函数指针,运行时 NATIVE 访问程序表中该槽位。这一提前绑定降低了频繁跨边界调用的查找开销,但要求程序与命令注册表匹配。
7. 优化正确性与新语言借鉴#
新增优化要验证“结果、错误、挂起点、调试映射、副作用顺序”都保持一致。特别是常量折叠的除零规则与 VM 路径存在静态可见差异;这种问题应以差分测试解决,不能仅测试正常算术。
适合继承的是:小型 typed IR、可解释的优化、紧凑常量编码、Native 提前绑定、保留源映射。建议把指令定义作为单一 schema,自动生成解码、校验、反汇编和文档;让优化前后能在同一小型参考解释器上比较。
源码证据#
- S1 — opcodes.h;sc.cpp 编号断言。
- S2 — EmitOp 窥孔和分页;调用回填。
- S3 — ENTER 生成;NATIVE 生成;表容量。
- S4 — program.h 分页与程序上限。
- S5 — program.cpp::Save。