PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
FOUNDATIONS / 05

内存与跨语言 ABI

从 bit/byte 到指针、buffer 与引用计数。

本章目录

这一章解释 Pysolate Spine 最小的内存协议。读者不需要先学会 C 指针;只要先接受一个模型:Guest 和 Host 各自管理自己的对象,跨边界时把数据放进一段有地址和长度的字节区域,再由另一边复制、检查和解析。

源码入口是 guest/runtime.c、guest/bootstrap.py、runner.go、bridge.go、prefix.go 和 prepared.go。本章只讲当前实现,不把 full-copy 说成共享内存,也不把它包装成通用 Wasm checkpoint。执行总路径见 execution-stack.md。Go 的切片和对象值见 go-values.md;并发、取消和 PLM worker 见 concurrency.md。

ABI 是 Application Binary Interface,即二进制接口约定。它回答“函数怎样接收低层参数、返回值怎样表示、谁负责哪段内存”。API 更偏向功能层的使用规则,ABI 关心底层表示;在这个项目中,两层都刻意保持很小。这里说的“协议”就是双方约好的规则,不是另一台服务器或一份安全证书。

1. bit、byte 和二进制表示

计算机最底层只处理 0 和 1。一个 bit 是一个二进制位,只能取 0 或 1。通常把 8 个 bit 组成一个 byte:

5 = 00000101

最右边一位表示 1,向左依次表示 2、4、8……所以 00000101 等于 4 + 1 = 5。一个 byte 能表示 0 到 255,共 256 种状态。四个 byte 可以放下 32 个 bit;八个 byte 可以放下 64 个 bit。

内存可以先理解成编号连续的 byte 格子:

地址 1000:  {第 1 个 byte}
地址 1001:  {第 2 个 byte}
地址 1002:  {第 3 个 byte}

这里的“地址”只是找到格子的编号。要正确取出一段数据,还需要知道从哪里开始,以及取多少个 byte。于是跨语言 ABI 经常传一对数:pointer(起点)和 length(长度)。只传起点而不传长度,会让接收方不知道数据在哪里结束,也可能读过界。

十六进制是另一种更紧凑的写法:每位有 16 种取值,写成 0–9、a–f,其中 a 到 f 对应十进制 10 到 15。一个十六进制位正好表示四个 bit;0xff 就是 15 × 16 + 15 = 255。0x 只是“后面按十六进制读”的前缀,不属于数值。

存储单位也要区分:本课 KiB 表示 1024 byte,MiB 表示 1024 × 1024 byte;它们不是十进制的 1000 / 1000000 byte。代码中的容量限制按 byte 算,不是按汉字数算。

2. i32、u32、u64:相同的 bit 可以有不同解释

这里用 i32 / u32 简写“按有符号 / 无符号方式解释的 32 位整数”,u64 表示无符号 64 位整数。Wasm 自己的值类型叫 i32/i64,并没有另一套 u32/u64 值类型;是否按有符号数运算由具体指令等约定决定。 C 和 Go 中则有明确区分的类型:

  • C int32_t:有符号 32 位整数;
  • C uint32_t:无符号 32 位整数;
  • C uint64_t:无符号 64 位整数;
  • Go uint32、uint64:无符号整数。

本项目涉及的有符号整数用补码解释。先看 8 位模型:最高位的权重是 -128,其余位的权重是 64、32、16、8、4、2、1;因此全 1 等于 -128 + 127 = -1。32 位遵循同一规则,只是位数更多。最重要的现象是:bit 没变,解释方式改变了。 四个 byte 全是 ff 时,作为 u32 是 4294967295;作为 i32,同一组 32 个 bit 表示 -1。

位运算直接操作这些 bit:

  • &:对应位都为 1 时结果才为 1;
  • |:对应位有一个为 1,结果就是 1;
  • 二元 a ^ b:对应位不同才为 1,叫异或;
  • Go 的一元 ^a:只有一个操作数,把每一位翻转。C 中对应的按位翻转写成 ~a,不是 ^a;
  • <<:向左移动,低位补 0;
  • >>:向右移动;无符号值右移时高位补 0。

runner.go 和 bridge.go 里用全 1 的 uint32 表示底层失败:

// 片段,非完整函数
return ^uint32(0)

^uint32(0) 的意思是把 32 个 0 全部翻转成 1,即 0xffffffff。Go 侧把它当作无符号值返回;C 侧的 host_call 声明返回 int32_t,因此收到相同 bit pattern 后会看到 -1。runtime.c 用 n < 0 检查这个失败状态。这里依赖的是 ABI 对 bit pattern 的约定,不是把一个 Go 指针传给了 C。

3. 本项目的 packed 返回值:一个 u64 装两个 u32

guest/runtime.c 的 execute 需要返回两个东西:结果 buffer 在 Guest 内存中的位置,以及这个 buffer 有多少 byte。为了只使用一个 Wasm 返回值,代码把两个 32 位无符号数装进一个 64 位数:

// 片段,非完整函数;见 guest/runtime.c 的 execute
// upper 32 bits: length; lower 32 bits: pointer
return ((uint64_t)(uint32_t)length << 32) |
       (uint32_t)(uintptr_t)copy;

公式是:

packed = (u64(length) << 32) | u64(pointer)
pointer = packed 的低 32 位
length  = packed 右移 32 位后的低 32 位

假设结果 buffer 的 Guest 偏移是 pointer = 4660,长度是 length = 7:

packed = (7 << 32) | 4660
       = 30,064,775,732

它的十六进制写法是 0x0000000700001234:高半部 0x00000007 是 7,低半部 0x00001234 是 4660。Go 的拆包代码如下:

// 片段,非完整函数;见 runner.go 的 Runner.run
p, n := uint32(packed[0]), uint32(packed[0]>>32)

于是 p == 4660,n == 7。p 是 Guest 线性内存中的偏移,不是 Go 进程可直接解引用的机器地址。

下面的小程序只使用 Python 标准库,演示这个整数约定和一种明确的 8-byte 打包方式。它不是 Pysolate 的运行代码,也不会调用 Guest:

# 完整独立小程序;仅演示 packed 的 pack/unpack,不调用 Guest
import struct

pointer = 4660
length = 7
packed = (length << 32) | pointer
wire = struct.pack(">Q", packed)
round_trip = struct.unpack(">Q", wire)[0]

unpacked_pointer = round_trip & 0xffffffff
unpacked_length = (round_trip >> 32) & 0xffffffff
print(packed)
print(wire.hex())
print(unpacked_length, unpacked_pointer)

预期输出是:

30064775732
0000000700001234
7 4660

把多字节整数写成 byte 序列时,还必须选顺序:大端把高位 byte 放前面,小端把低位 byte 放前面。例如 0x1234 写成两 byte,大端是 12 34,小端是 34 12。这里 struct.pack(">Q", ...) 中,> 指大端,Q 指 8-byte 无符号整数;.hex() 把 bytes 显示成十六进制文本。

Wasm 的普通多字节内存读写采用小端约定。但本项目这个返回值不是手写的 8-byte 网络报文:函数返回一个数,再按位拆开。struct.pack 的大端顺序只是为了让独立练习的输出容易阅读;真实 ABI 传的是 Wasm 函数的 i64 返回值(这里按无符号数解释),Go 代码按位移和转换拆开它。不要把 packed 当作 hash、文件句柄或安全凭证。

4. 指针、长度与线性内存

在 Wasm32 中,Guest 指针本质上是 Guest 线性内存中的 32 位偏移。例如 ptr=1000, size=20 表示从 Guest 内存偏移 1000 开始,读取 20 个 byte。它不是文件路径,也不是可以直接解引用的 Host 地址。

guest/runtime.c 的导出函数给出了本项目的基本约定:

// 片段,非完整文件
EXPORT("alloc") void *alloc(uint32_t size) { return malloc(size); }
EXPORT("release") void release(void *ptr) { free(ptr); }
EXPORT("execute") uint64_t execute(const char *request, int32_t size);

Go 先调用 alloc,得到 Guest 内的偏移;prefix.go 的 callWithBytes 再调用 m.Memory().Write 把请求 bytes 写进去。C 函数收到 const char *request 和 size 后,按长度读取请求。这里不能假定 C 字符串一定以 \0 结束,因为 ABI 已经明确携带了长度。

C API 中 Py_ssize_t 是 CPython 用于长度等值的有符号整数类型。PyArg_ParseTuple 根据格式字符串理解后面的参数:这里 s# 表示字符串及其长度;它不是 JSON 格式。C 的 python_call 使用这个带长度的 API,并拒绝超过 MAX_MESSAGE 的请求。调用完成后,Go 侧的临时请求 buffer 由 release 释放;它只在这一次调用中有效。结果 buffer 的生命周期不同:execute 返回的 copy 归 Go 读取,并在 runner.go 中调用 release。

这说明“指针”本身不拥有数据。所有权要靠额外约定说明:谁申请、谁读取、谁释放、在哪个时间点之后不能再用。当前项目把这些约定写在 alloc/release、execute 和 Host bridge 的调用顺序里,而不是靠类型系统自动证明。

几个 C 类型记号

  • char *:指向字节/字符区域的指针;const char * 表示通过这个指针只读这些内容。
  • void *:没有在类型上指定“指向哪种对象”的通用指针;不代表它指向空气,也不代表能随便读取任意长度。
  • PyObject *:指向 CPython 管理的 Python 对象,不是对象本身被塞进了一个整数参数。
  • uintptr_t:C 中可用于容纳指针表示的无符号整数类型;这里的 Guest 是 wasm32,最终传递的是它的偏移。

不要把 Future handle 和这些指针混淆。前者是 Go 的本轮 map 中的一个键,例如编号 1;后者告诉 runtime 要读 Guest 内存的哪个位置。两个都能写成整数,但查找的东西、寿命和权限边界不同。

5. 线性内存、边界检查和内存页

Wasm 实例的主要数据区域叫 linear memory。它是一段连续的、以 byte 编号的 Guest 内存。Host 通过 wazero 的 Memory.Read 和 Memory.Write 访问它;Guest 的 C 代码通过自己的指针和 malloc 访问同一实例的区域。

读取 ptr..ptr+size 时,必须同时满足:起点有效、长度没有溢出、整段区域仍在内存范围内,并且没有超过本项目的消息上限。bridge.go 的 readRequest 会检查 Memory().Read 的成功状态和 maxMessage;runner.go 的 writeResponse 会检查容量、消息大小和写入结果:

// 片段,非完整函数;见 runner.go 的 writeResponse
if len(data) > maxMessage ||
   uint64(len(data)) > uint64(capacity) ||
   !m.Memory().Write(ptr, data) {
    return ^uint32(0)
}

如果 ptr 指向范围外,Wasm API 返回失败;Host 不应该把这个偏移交给 Go 当作地址使用。

Wasm 以 page 管理线性内存,当前一页是 64 KiB,也就是 65536 个 byte。build-guest.sh 的链接参数设置初始内存 134217728 byte(128 MiB),最大内存 536870912 byte(512 MiB),C stack 大小为 16777216 byte(16 MiB)。这些是本项目当前 artifact 的配置,不是 Wasm 标准要求的统一数值,也不是实测的实际占用量。

Memory.Grow 按页增加容量;prepared.go 在 prepared 模式发现新实例比 image 小时,会先把内存增长到 image 的大小,再写入 image。“线性”表示 Guest 按连续编号寻址,不保证底下的物理 RAM 也连续。操作系统通常用地址映射,把程序看到的地址对应到真实存储,这就是理解虚拟内存的第一步。Wasm page 不是操作系统 page。操作系统可能使用 4 KiB 等其他页大小;本章只需要把 Wasm page 理解为 Wasm linear memory 的扩容单位。

6. stack、heap 和三个不同的生命周期

可以用两个直觉理解 Guest 内的内存:

  • **stack(栈)**保存调用过程中临时的调用帧和局部状态。函数返回后,这些空间通常可以被后续调用复用。
  • **heap(堆)**保存需要跨越某个函数调用继续存在的动态对象。它由分配器申请、释放或回收。

在本项目里,C 函数调用会有 C/Wasm 的调用栈;链接参数还为 C stack 指定了大小。malloc 得到的原始 buffer 属于 Guest 的动态分配区域。CPython 的 Python 对象、模块和解释器状态也由 Guest 内的 CPython 分配器管理。Python 的执行 frame 还有自己的对象和生命周期,不能简单等同于一块永远留在 C stack 上的数据。

需要把三套管理机制分开:

6.1 malloc/free:原始 C 内存

runtime.c 里的 malloc(MAX_MESSAGE) 为 Host 工具响应申请临时 buffer,free(buffer) 在转成 Python bytes 后释放它。execute 也用 malloc(length) 申请返回结果,并把它的所有权交给 Go;Go 读取后调用 Guest 的 release,最终仍走 C 的 free。

malloc 只知道一块原始字节区域有多大,不知道里面是不是 Python dict,也不会理解 JSON。用 Py_DECREF 去释放一块 malloc 得到的裸 buffer,或者用 free 去释放一个仍由 CPython 管理的 PyObject,都是错误的所有权混用。

6.2 CPython 引用计数:Python 对象

CPython 用引用计数追踪很多对象的持有关系。先看只计两个显式持有者的简化模型:a 指向对象,再令 b 也指向它,持有者从一个变两个;去掉 a 后还有 b,不能销毁对象。去掉最后的持有者后,普通可释放对象才有机会销毁。真实解释器还可能有其他引用;循环引用等情况另有回收机制,不能把这个模型的数字当成实测计数。

C API 还区分新引用与借用引用。拿到需要自己负责的新引用,不用时要 Py_DECREF;借用别人的引用则不能不分情况直接减计数。本项目的 Import/GetAttr/Call 等位置需要按各自返回约定管理。runtime.c 取得 bootstrap 的 execute、prefix_begin 和 prefix_feed 后,减少 bootstrap 模块对象的一份引用;缓存的函数引用留到 Guest 生命周期结束。

引用计数管理的是 Python 对象关系,不是任意地址,也不是 Host 的 Go 对象。它和 malloc/free 可能在 CPython 内部协作,但 API 的职责不同。

6.3 Go GC:Go 堆上的 Go 值

Go 的垃圾回收器会识别仍可从正在使用的变量等位置到达的对象,并回收已经不再可达的 Go 堆对象,例如 Runner 的 []byte image、JSON 结果和工具闭包捕获的 Go 数据。它不会替代 Guest 的 release,不会扫描并自动释放 C 的 malloc buffer,也不会自动关闭 api.Module 或取消一个不响应的 Host 工具。

因此,生命周期是分层的:C 分配的 buffer 要 free/release,Python 对象要按 CPython 引用规则管理,Go 的对象由 Go GC 加上显式的 Runner/实例关闭管理。r.Close 清理 runtime,不能理解成“Go GC 会把所有 Guest 状态顺便回收”。

7. 一次 JSON 请求怎样被复制过边界

JSON 是本项目的交付格式;JSON 文本不是可以同时被 Go、C 和 Python 持有的共享 dict。一次 Run 的源码/输入请求,与其中每次工具请求,是两种不同的消息。 不要把它们误画成同一份 JSON 被无意义地反复解析。一次普通工具调用大致经过下面这些步骤:

先进入一次 Run:
Go 的 source/inputs → json.Marshal → alloc/Memory.Write
  → C execute → Python bootstrap.execute → 用户 Python

用户 Python 在 tool(...) 处请求工具:
Python 的工具名/参数 → json.dumps → C python_call
  → Host Memory.Read / json.Unmarshal → Go 工具
  → Go encodeResponse → Memory.Write
  → C 创建 Python bytes → Python decode / json.loads
  → 在原 tool(...) 调用处得到值或错误

具体看 bridge.go:readRequest 用 Memory().Read 取得 Guest 请求,json.Unmarshal 得到工具名和 json.RawMessage 参数;invoke 调用 Go 工具;encodeResponse 再把返回值或错误编码成 JSON;writeResponse 将结果写入 Guest 提供的输出区域。

这条路径有多个明确的转换点:

  1. Go 的 json.Marshal 产生请求文本;不能把 Go map 直接当作 Wasm 参数。
  2. Memory.Write 把 bytes 复制进 Guest 线性内存;Host 工具不能借用这个 Guest 地址。
  3. Host json.Unmarshal 产生 Go 管理的请求结构和参数 bytes。工具应把 json.RawMessage 当只读输入。
  4. writeResponse 只在调用期间把响应写入 Guest buffer;容量不足就返回全 1 的失败码。
  5. C 将响应变成 Python bytes,bootstrap.decode 再用 json.loads 得到 Python 值。

返回结果也有一段独立复制:guest/runtime.c 从 Python bytes 取出数据,memcpy 到新申请的 C buffer;Go 读取这块 Guest 内存,解析到 json.RawMessage,然后释放 C buffer。memcpy 不是多余的装饰:如果只借用 Python bytes 内部地址,却先 Py_DECREF(result),Go 之后读到的就可能是已经失效的内存。

所以跨边界的核心不是“大家共享一个对象”,而是“每一层在自己的内存里拥有一份合适的数据,并在交接点检查范围和生命周期”。

8. syscall、WASI 和内存页不是同一个概念

内核是操作系统中管理进程、内存和设备等基础资源的部分。普通程序不能把所有受保护资源当成自己的变量随意操作。**系统调用(syscall)**通常指用户程序请求内核执行某项工作,例如读文件、创建进程或打开网络连接。调用者传递数字、指针和长度,内核检查后返回结果。它和本项目的 ABI 在形式上有相似处:都需要明确的参数布局、长度和错误表示。

但 spine.call 不是一个自动拥有内核权限的 syscall。它是一个 Wasm import,由 Go Host 注册;Host 是否注册某个工具、工具怎样执行,决定了 Guest 能得到什么。WASI 的接口也只是标准化的 Host/Runtime 接口。只有实际安装并配置的能力才存在;接口名称不等于把宿主文件系统、环境变量、网络和进程控制全部交给 Guest。

内存页是内存管理单位:Wasm page 当前为 64 KiB。它不是 syscall,也不是上面 pointer + length 的另一种写法。一个请求可以只有几十个 byte,却位于一页的某个偏移;一块 buffer 也可能跨越多个 Wasm page。Host 仍必须按请求的实际范围检查读写,不可因为“页存在”就跳过长度检查。

9. prepared full-copy 到底复制了什么

prepared.go 的 NewPrepared 先调用普通的 New,创建一个参考 Guest;然后执行 _initialize 和 CPython init,在任何用户源码和工具调用之前读取整段线性内存:

// 片段,非完整函数;见 prepared.go 的 NewPrepared
memory := m.Memory()
data, ok := memory.Read(0, memory.Size())
...
r.image = append([]byte(nil), data...)

append([]byte(nil), data...) 让 Runner 持有自己的 Go byte 副本。参考 Guest 随后关闭。每轮创建新实例时,newGuest 仍先执行该实例自己的 _initialize;如果 Runner 有 image,就把内存增长到相同形状,再执行:

// 片段,非完整函数;见 prepared.go 的 Runner.newGuest
if !memory.Write(0, r.image) {
    return nil, errors.New("cannot copy prepared memory")
}

这是真正的 full-copy:每个实例都有一份自己的线性内存,初始化 image 也有一份 Go 存储。一个实例修改最后一个 byte,不应改变另一个实例或 r.image;测试用例正是用这种写入检查可写别名。

它不是 COW(copy-on-write)。COW 的短定义是:多个执行体先共享未修改的物理页,某一方写入时才分离被写的页;full-copy 则在创建实例时直接复制整份内存。本节描述 full-copy;另有 Linux COW 使用内核页机制,但没有性能测量结论。不要把 Memory.Read 加 Memory.Write 说成“共享内存页”。

它也不是通用 Wasm checkpoint。Wasm global 是模块单独保存的值,table 是保存函数引用等条目的表;它们并不是 linear memory 中任意一段 bytes 的别名。Host 的文件描述符又是资源表里的编号:复制一个编号不会替新实例打开同一个文件或恢复网络连接。

新实例会建立自己的模块状态;这里没有做一套保存/恢复任意 global、table 和 Host 资源的系统。当前 image 只表示固定 Guest 在干净初始化边界后的线性内存字节,不捕获:

  • 正在执行的调用栈或可继续执行的机器状态;
  • 任意 Wasm globals 和 tables;
  • Host 的文件描述符、网络连接、Go goroutine、工具闭包和 Future;
  • 用户代码、输入、工具副作用或一个已经运行到中途的 Guest。

即使某些栈字节位于 linear memory,复制静态字节也不能恢复一个正在执行的调用栈。NewPrepared 的语义是“从干净初始化基线复制出私有 Guest 内存”,不是“暂停任意程序再从任意时刻恢复”。

最后把范围压缩成三句:

  1. pointer 是 Guest linear memory 的偏移,length 决定可读写范围;两者不是 Host 地址和隐含字符串终止符。
  2. C malloc/free、CPython 引用计数和 Go GC 管理不同对象;跨边界 JSON 通过复制和显式释放交付。
  3. Prepared full-copy 是每实例全量复制,Linux COW 是另一种内存创建方式;两者都不是捕获 Host 资源和任意 Wasm 状态的 checkpoint。