这一章回答:为什么一次 Go Run 不能直接把 Python 源码“调用起来”,而要经过 Wasm 中的 C runtime?输入是 Go 写入 Guest 线性内存的 JSON 或源码片段,调用者是 Runner;最终交付是 Python 返回的响应 bytes,或一个明确的 C/ABI 失败状态。C 层的职责很窄:声明 Wasm import/export,转换带长度的字节与 Python 对象,管理 malloc/free 和 Python 引用,并把结果 packed 成 Go 能拆开的整数。JSON 的业务语义在 bootstrap.py,工具查找和 Future 语义在 Go。
Go Runner
→ Wasm export alloc / execute / prefix_*
→ guest/runtime.c
→ CPython C API
→ spine_bootstrap.execute 或 _spine.call
→ spine import 回到 Go bridge
本章只沿当前普通 execute 链展开,再把 prepare/resolve 和 prefix 的分岔收尾。指针都是 Guest 线性内存中的偏移;C 的 const char * 加显式长度不等于 Go 地址,也不依赖 \0 结尾。
1. 编译期先写下跨边界契约
进入这段时还没有运行时状态。宏、头文件和 import 声明决定了 C 能调用哪些 CPython API、哪些函数进入 Wasm export/import 表,以及每个跨边界参数的宽度。
guest/runtime.c · L1–L16 · 打开完整源码
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#define EXPORT(name) __attribute__((export_name(name)))
#define MAX_MESSAGE (1 << 20)
__attribute__((import_module("spine"), import_name("call")))
int32_t host_call(const char *request, int32_t size, char *out, int32_t capacity);
__attribute__((import_module("spine"), import_name("prepare")))
uint32_t host_prepare(const char *request, int32_t size);
__attribute__((import_module("spine"), import_name("resolve")))
int32_t host_resolve(uint32_t id, const char *request, int32_t size, char *out, int32_t capacity);
PY_SSIZE_T_CLEAN 必须位于 Python.h 之前,因为后面 s# 解析器需要用 Py_ssize_t 表达长度。EXPORT 统一给 C 函数添加 Wasm 导出名;MAX_MESSAGE 是 1 MiB 的项目消息政策,不是 Wasm 内存总上限。
host_call、host_prepare、host_resolve 都是名为 spine 的 Wasm import。普通 call 和 resolve 接收输入偏移/长度以及输出偏移/capacity;prepare 只返回 uint32_t handle,0 保留为“没有提前准备”。C 只声明 ABI,不实现 Host 工具查找;真正的 Go 函数由 Runner 注册。
2. 普通调用:Python bytes 与 Host buffer 的两次交接
进入 python_call 时,Python 已经把工具请求 JSON 放进一个参数 tuple。目标是同步调用 Host,并返回一个新的 Python bytes;失败时返回 NULL,同时保持 CPython 异常状态。
guest/runtime.c · L17–L32 · 打开完整源码
// Python string → Host JSON request → Python bytes. Buffer lives only for this call.
static PyObject *python_call(PyObject *self, PyObject *args) {
const char *request;
Py_ssize_t size;
if (!PyArg_ParseTuple(args, "s#", &request, &size)) return NULL;
if (size > MAX_MESSAGE) { PyErr_SetString(PyExc_ValueError, "tool request exceeds 1 MiB"); return NULL; }
char *buffer = malloc(MAX_MESSAGE);
if (!buffer) return PyErr_NoMemory();
int32_t n = host_call(request, (int32_t)size, buffer, MAX_MESSAGE);
PyObject *result = NULL;
if (n < 0 || n > MAX_MESSAGE) PyErr_SetString(PyExc_RuntimeError, "Host tool response exceeds buffer or ABI bounds");
else result = PyBytes_FromStringAndSize(buffer, n);
free(buffer);
return result;
}
PyArg_ParseTuple(..., "s#", ...) 同时取出请求字节指针和长度。长度超过 MAX_MESSAGE 直接设置 ValueError;通过检查后,C 用 malloc 建立最多 1 MiB 的原始输出 buffer。它不是 Python 对象,所以必须用 free,不能用 Py_DECREF。
host_call 同步返回一个有符号长度:负数表示 Go 的失败哨兵,超过上限表示 ABI 越界。成功时 PyBytes_FromStringAndSize 按长度复制 buffer,随后无论成功失败都 free(buffer)。因此 Host 不会持有 Python 的对象指针,Python 也不会继续借用 C 临时 buffer。
这段的关键状态变化是:Python request bytes → C 借用的输入视图 → C-owned 输出 buffer → Python-owned bytes。C 不解析请求里的 tool 或 args,它只负责把调用送到 Go 并把结果包装回 Python。
3. prepare/resolve:handle 不是结果值
PLM 或 prefix 可以把允许的读取提前提交。python_prepare 的输入仍是 JSON 字符串,输出只是一个 Python 整数 handle;python_resolve 还接收 handle 和原调用位置的当前请求,输出仍是响应 bytes。
guest/runtime.c · L33–L56 · 打开完整源码
static PyObject *python_prepare(PyObject *self, PyObject *args) {
const char *request;
Py_ssize_t size;
if (!PyArg_ParseTuple(args, "s#", &request, &size)) return NULL;
if (size > MAX_MESSAGE) return PyLong_FromUnsignedLong(0);
return PyLong_FromUnsignedLong(host_prepare(request, (int32_t)size));
}
static PyObject *python_resolve(PyObject *self, PyObject *args) {
unsigned int id;
const char *request;
Py_ssize_t size;
if (!PyArg_ParseTuple(args, "Is#", &id, &request, &size)) return NULL;
if (size > MAX_MESSAGE) { PyErr_SetString(PyExc_ValueError, "tool request exceeds 1 MiB"); return NULL; }
char *buffer = malloc(MAX_MESSAGE);
if (!buffer) return PyErr_NoMemory();
int32_t n = host_resolve(id, request, (int32_t)size, buffer, MAX_MESSAGE);
PyObject *result = NULL;
if (n < 0 || n > MAX_MESSAGE) PyErr_SetString(PyExc_RuntimeError, "Host tool response exceeds buffer or ABI bounds");
else result = PyBytes_FromStringAndSize(buffer, n);
free(buffer);
return result;
}
prepare 的参数解析失败直接返回 NULL,但请求超过上限返回整数 0:提前阶段是可放弃的优化机会,不能因为消息太长就伪造一个已完成的 Future。成功时把 host_prepare 的无符号 handle 转成 Python 整数;0 仍表示没有提前结果。
resolve 用格式 Is# 同时取无符号 id、请求字节和长度。它位于真实 Python 调用位置,所以超长请求抛 ValueError,而不是静默退化。C 分配临时输出 buffer 调 Go 的 host_resolve,再以与普通 call 相同的负数/长度检查和 PyBytes_FromStringAndSize 规则交付。
C 不判断 handle 是否属于本轮、是否已经消费、参数是否匹配;这些属于 Go runState.resolve。它只把 handle 和当前 JSON 无损传下去。这样原调用位置仍能得到匹配 Future 的结果、参数不匹配后的普通调用结果,或一个可由 Python decode 处理的错误响应。
4. _spine 模块把三个入口交给 Python
CPython 需要一个模块方法表,才能让 bootstrap 导入 call、prepare、resolve。这一步发生在用户源码执行之前。
guest/runtime.c · L57–L66 · 打开完整源码
static PyMethodDef methods[] = {
{"call", python_call, METH_VARARGS, "Call a registered Host tool."},
{"prepare", python_prepare, METH_VARARGS, "Start an allowed read, returning a run-local handle."},
{"resolve", python_resolve, METH_VARARGS, "Consume a read at its original call position."},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef module = {PyModuleDef_HEAD_INIT, "_spine", NULL, -1, methods};
static PyObject *init_spine(void) { return PyModule_Create(&module); }
static PyObject *execute_fn, *prefix_begin_fn, *prefix_feed_fn;
methods 数组的三个条目把 Python 名称映射到 C 函数,并使用 METH_VARARGS,所以每次调用都会把位置参数交给 C 的 PyArg_ParseTuple。结尾的全空条目是 CPython 识别数组终点的哨兵。PyModuleDef 把模块命名为 _spine,init_spine 创建模块对象。
后面的 execute_fn、prefix_begin_fn、prefix_feed_fn 是缓存的 Python 函数对象指针。它们初始为未绑定状态,必须等 init 成功导入 spine_bootstrap 后才能调用。C 的对象引用计数和原始 malloc 是两套独立的所有权规则。
5. init:先注册 _spine,再建立隔离解释器
进入 init 时 Go 只期待一个状态码:0 表示后续 export 可以使用 Python,-1 表示 Guest 初始化失败,用户源码尚未执行。
guest/runtime.c · L67–L91 · 打开完整源码
EXPORT("init") int32_t init(void) {
if (PyImport_AppendInittab("_spine", init_spine) != 0) return -1;
PyConfig config;
PyConfig_InitIsolatedConfig(&config);
config.site_import = 0;
config.write_bytecode = 0;
config.module_search_paths_set = 1;
PyStatus status = PyWideStringList_Append(&config.module_search_paths, L"/usr/lib/python3.14");
if (!PyStatus_Exception(status)) status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
fprintf(stderr, "CPython init: %s\n", status.err_msg ? status.err_msg : "unknown error");
PyConfig_Clear(&config);
return -1;
}
PyConfig_Clear(&config);
PyObject *bootstrap = PyImport_ImportModule("spine_bootstrap");
if (!bootstrap) { PyErr_Print(); return -1; }
execute_fn = PyObject_GetAttrString(bootstrap, "execute");
prefix_begin_fn = PyObject_GetAttrString(bootstrap, "prefix_begin");
prefix_feed_fn = PyObject_GetAttrString(bootstrap, "prefix_feed");
Py_DECREF(bootstrap);
if (!execute_fn || !prefix_begin_fn || !prefix_feed_fn) { PyErr_Print(); return -1; }
return 0;
}
PyImport_AppendInittab 把 _spine 注册为内置模块,必须在 Py_InitializeFromConfig 前完成。接着用 PyConfig_InitIsolatedConfig 建立配置,关闭 site 自动导入和 bytecode 写回,显式设置模块搜索路径,并加入 /usr/lib/python3.14。这些设置决定 Guest 能在哪里找到标准库和打包进去的 spine_bootstrap。
PyStatus 负责报告 CPython 初始化状态,不是 Python 异常对象。失败分支打印诊断、清理 PyConfig 并返回 -1;成功后也要清掉临时 config,但不能因此关闭刚建立的解释器。随后导入 spine_bootstrap,取得并缓存 execute、prefix_begin、prefix_feed 三个属性,释放局部 module 引用;任一属性缺失都返回 -1。
于是 init 成功交付的状态是:CPython 已初始化,_spine 已能被 bootstrap 导入,三个 Python 入口已缓存。它还没有运行用户的 source。
6. prefix 的 C 包装只转交片段
prefix 在源码接收期需要多次调用 Python helper。C 共享一个 prefix_step,避免 begin 和 feed 各自重复同一套调用与引用处理。
guest/runtime.c · L92–L105 · 打开完整源码
// Prefix calls only parse received source and issue eligible reads, never execute it.
static int32_t prefix_step(PyObject *fn, const char *data, int32_t size) {
PyObject *result = PyObject_CallFunction(fn, "s#", data, (Py_ssize_t)size);
if (!result) { PyErr_Print(); return -1; }
Py_DECREF(result);
return 0;
}
EXPORT("prefix_begin") int32_t prefix_begin(const char *data, int32_t size) {
return prefix_step(prefix_begin_fn, data, size);
}
EXPORT("prefix_feed") int32_t prefix_feed(const char *data, int32_t size) {
return prefix_step(prefix_feed_fn, data, size);
}
prefix_step 用 PyObject_CallFunction(..., "s#", ...) 把带显式长度的片段送给缓存函数。调用失败时打印 Python 异常并返回 -1;成功时减少返回对象引用并返回 0。返回 0 只说明 helper 调用成功,不表示源码已经执行,也不表示工具结果已经交付。
导出的 prefix_begin 和 prefix_feed 只是分别传入对应函数指针、Guest 数据指针和长度。C wrapper 本身没有再次做 MAX_MESSAGE 检查,正常边界由 Go 的 receiveSource/callWithBytes 负责;Python Prefix 再决定片段是否构成可分析的完整前缀。
7. alloc、execute 与 packed 结果
Runner 需要一个明确的 Guest buffer 生命周期:先通过 alloc 得到 Guest 地址,把请求写进去,调用 execute,读取返回 buffer,再调用 release。C 导出函数把这个协议固定下来。
guest/runtime.c · L106–L121 · 打开完整源码
EXPORT("alloc") void *alloc(uint32_t size) { return malloc(size); }
EXPORT("release") void release(void *ptr) { free(ptr); }
// Caller owns returned malloc buffer; upper 32 bits length, lower 32 bits pointer.
EXPORT("execute") uint64_t execute(const char *request, int32_t size) {
PyObject *result = PyObject_CallFunction(execute_fn, "s#", request, (Py_ssize_t)size);
if (!result) { PyErr_Print(); return 0; }
char *data;
Py_ssize_t length;
if (PyBytes_AsStringAndSize(result, &data, &length) < 0) { Py_DECREF(result); PyErr_Print(); return 0; }
char *copy = malloc(length);
if (!copy) { Py_DECREF(result); return 0; }
memcpy(copy, data, length);
Py_DECREF(result);
return ((uint64_t)(uint32_t)length << 32) | (uint32_t)(uintptr_t)copy;
}
alloc 和 release 直接配对 Guest 中的原始 malloc/free。execute 调缓存的 Python execute_fn,传入请求指针和显式长度。Python 返回值必须是 bytes;C 用 PyBytes_AsStringAndSize 取得借用数据,再申请另一块 C buffer 并 memcpy。这次复制保证在 Py_DECREF(result) 后,交付给 Go 的 buffer 仍由调用者拥有。
最后把长度放进高 32 位、指针表示放进低 32 位:
pointer = uint32(packed)
length = uint32(packed >> 32)
Python 调用失败、返回值不是 bytes 或最终 malloc 失败都返回 0;Runner 把低半部为 0 视为 bridge 失败。指针仍只是 Guest 内存中的数值,Go 必须在 module 关闭前读取并通过 release 归还。
一次普通调用的真实边界
Go marshal {source, inputs, plm, manifest}
→ alloc + Memory.Write
→ C execute(request, size)
→ bootstrap.execute
→ 具名 Python proxy
→ _spine.call
→ C python_call
→ Go hostCall / writeResponse
→ Python bytes / decode
→ execute response bytes
→ C memcpy + packed(pointer,length)
→ Go Memory.Read + release
读完本章应能回答:为什么 s# 必须带长度、为什么 C 不能把 Python bytes 内部地址直接交给 Go,以及为什么 packed 的低半部不是 Host 指针。下一步读 guest-python.md 看 Python 怎样由 manifest 建立具名函数,再读 bridge.md 看 spine.call 如何落到 ToolSpec.Call。