跳转至

ndk RV32I 沙盒运行时

一、概述

ndk 是 LuatOS 里的一个 RISC-V 沙盒虚拟机:它把 mini-rv32ima 解释器编进固件,在模组上直接运行一个 RV32IMA 裸机(baremetal)镜像,并让这个镜像通过一块「交换区」(exchange buffer)与 Lua 侧交换数据。

它的价值在于三点:

1、把性能敏感的算法挪出 Lua。Lua 是解释执行的,逐点 FFT、大整数运算、加密循环这类代码用 C 编译成 RISC-V 机器码后跑在沙盒里,速度比等价 Lua 代码高一个量级,Lua 侧只负责调度与 IO;

2、镜像与脚本解耦。镜像是一个独立的二进制文件(放在文件系统里,如 /luadb/baremetal.bin),升级镜像不必改脚本,也不占脚本区空间;

3、沙盒隔离。guest 拿不到 Lua 的栈、拿不到 luat 库、也拿不到裸机寄存器,它能做的事只有:读写自己的 RAM、读写交换区、通过 Host ABI 的 CSR 请求宿主代办(GPIO / UART / 时间 / 事件 / MD5 / CRC32)。guest 死循环或 trap 也只会被 ndk.stop 打断,不会把固件拖死。

几个必须先记住的边界:

项目 约束
编译开关 LUAT_USE_NDK。公开固件里只有 ec718hm 平台(Air780EHM / EHV / EGH / EGG / EHU / EHN、Air700ECH、Air8000 全系列)的 13 号与 24 号固件打开了它;ec718pm 平台的 Air780EPM / EGP、Air700ECP、Air780EX2 没有编入
指令集 RV32IMA(isa 目前只接受 "rv32ima" 这一种写法)
guest RAM 默认 8 KiB,硬上限 512 KiB(LUAT_NDK_MAX_RAM_SIZE)
交换区 默认 4 KiB(LUAT_NDK_DEFAULT_EXCHANGE_SIZE),必须小于 guest RAM
镜像来源 文件系统里的一个二进制文件,走 io/fs 能读到的路径都可以(/luadb/、/sd/ 等)
Host ABI v1.0.0,magic 为 NDK1(0x4E444B31)

⚠️ 用之前先自检,ndk 不是所有固件都有:

if ndk then
    log.info("ndk", "当前固件包含 ndk")
else
    log.info("ndk", "当前固件不含 ndk,需要选带 LUAT_USE_NDK 的固件号")
end

二、快速上手

一个完整的生命周期只有六步:创建 → 查状态 → 写交换区 → 执行 → 读交换区 → 停止并释放。

-- 1. 创建:镜像路径、guest RAM 32 KiB、交换区 1 KiB
local ctx, err = ndk.rv32i("/luadb/baremetal.bin", 32 * 1024, 1024)
if not ctx then
    log.error("ndk", "创建失败", err)
    return
end

-- 2. 查状态,确认 ABI 与内存布局
local info = ndk.info(ctx)
log.info("ndk", "mem", info.mem, "exchange", info.exchange,
         "abi", info.abi_version, "features", info.features,
         "image", info.image, "isa", info.isa)

-- 3. 往交换区写输入
local n, werr = ndk.setData(ctx, "ping")
if not n then
    log.error("ndk", "写交换区失败", werr)
end

-- 4. 同步执行:steps = 0 表示不限步数
local ok, ret, mcause, mtval = ndk.exec(ctx, {steps = 0, elapsed = 100})
if not ok then
    log.error("ndk", "执行失败", ret, mcause, mtval)
    ndk.stop(ctx, 1000)
    return
end

-- 5. 读回结果
local data, derr = ndk.getData(ctx, 16, 0)
log.info("ndk", "retval", ret, "data", data or derr)

-- 6. 停止并复位;ctx 置 nil 后由 GC 回收
ndk.stop(ctx, 1000)
ndk.reset(ctx)
ctx = nil
collectgarbage("collect")

异步跑长任务的写法:

local ctx = ndk.rv32i("/luadb/filter.bin", 64 * 1024, 2048)
ndk.setData(ctx, "1024")                      -- 参数:要算 1024 点
local tid = ndk.thread(ctx, {steps = 0})      -- 起后台线程,立刻返回
log.info("ndk", "thread id", tid)

-- 主线程继续做别的事;需要时再停
sys.wait(2000)
local ok, serr = ndk.stop(ctx, 1000)          -- 最多等 1 秒
if not ok then
    log.warn("ndk", "停止超时", serr)
end
log.info("ndk", "结果", ndk.getData(ctx, 32, 0))

三、内存布局与生命周期

3.1 交换区落在 RAM 尾部

guest 的地址空间从 0x80000000 起算,整块 RAM 分三段:

0x80000000                                            0x80000000 + mem_size
|------------------------- guest RAM ------------------------------|
|  镜像加载区 |        自由空间(栈/堆)        |   交换区(尾部)   |
                                              ^
                                 exchange_offset = mem_size - exchange_size

也就是说:镜像放在 RAM 头部,交换区钉在 RAM 尾部,中间留给 guest 的栈和堆。ndk.info(ctx).exchange_addr 返回的就是交换区在 guest 视角的起始地址(0x80000000 + mem_size - exchange_size)。

这带来两条实践约束:

1、exchange_size 不能省。不传时为 4 KiB,小 RAM 配置(如 8 KiB)下交换区就占掉一半,guest 实际可用空间很少,建议显式传值;

2、镜像不能超过 mem_size - exchange_size。超了会返回 image too large,此时要么调大 mem_size(最大 512 KiB),要么精简镜像。

3.2 状态约束

ndk 内部有四个状态(空闲 / 运行中 / 停止中 / 复位中),对外表现为「能不能现在调这个接口」:

接口 空闲态 运行中(thread 起的线程还在跑) 说明
ndk.exec ✅ ❌ 返回 false, "busy" 同步执行,会阻塞到 guest 退出或 trap
ndk.thread ✅ ❌ 返回 nil, "busy" 异步执行,立刻返回线程 ID
ndk.stop ✅ 幂等成功 ✅ 用来打断 wait_ms 是等待上限
ndk.reset ✅ ❌ 返回 false, "busy" 重新读镜像、清零 RAM 与交换区、PC 复位到 0x80000000
ndk.getData / ndk.setData ✅ ⚠️ 可调但不安全 交换区正在被 guest 读写,可能读到半成品
ndk.info ✅ ✅ 只读,任何时候都能调

要点:exec 与 thread 互斥。想「跑一会儿再看一眼」的正确做法是 thread + stop(wait_ms=0) 非阻塞轮询,而不是在 exec 外面套定时器。

四、函数详解

4.1 ndk.rv32i(path, mem_size, exchange_size, opts)

功能

创建并加载一个 RV32 镜像,返回执行上下文。这是所有其它接口的入口。

参数

参数 类型 必填 说明
path string 是 镜像文件路径,走文件系统(/luadb/baremetal.bin、/sd/xx.bin)
mem_size int 否 guest RAM 字节数,默认 8 KiB,上限 512 KiB
exchange_size int 否 交换区字节数,默认 4 KiB,必须小于 mem_size
opts table 否 目前只支持 {isa = "rv32ima"}

返回值

情况 返回
成功 ctx(userdata,GC 托管,__gc 时会自动停线程并释放)
失败 nil, err,err 是错误串(见 4.9 错误串对照表)

示例

-- 最小配置
local ctx = ndk.rv32i("/luadb/baremetal.bin")

-- 最大 RAM(512 KiB),交换区 8 KiB
local big, err = ndk.rv32i("/luadb/big.bin", 512 * 1024, 8 * 1024)
if not big then
    log.error("ndk", err)   -- 例如 "no memory" / "image too large"
end

注意

mem_size 是从 Lua 堆上一次性申请的,512 KiB 在部分固件上会直接失败返回 no memory——不是因为上限不允许,而是当下堆不够。申请大 RAM 前先把脚本自身的临时对象释放掉,或者把 mem_size 降到 128 KiB / 64 KiB。

4.2 ndk.setData(ctx, data, offset)

功能

把数据写进交换区,供 guest 读取。

参数

参数 类型 必填 说明
ctx userdata 是 ndk.rv32i 返回的上下文
data string / zbuff 是 字符串,或一个 zbuff 实例
offset int 否 交换区内偏移,默认 0

返回值

情况 返回
成功 写入的字节数
失败 false, err

示例

ndk.setData(ctx, "ping")              -- 写到偏移 0
ndk.setData(ctx, "meta", 64)          -- 写到偏移 64

-- 用 zbuff 传二进制(图片、定长结构体)
local buf = zbuff.create(128)
buf[0] = 0x01
buf[1] = 0x02
ndk.setData(ctx, buf)                 -- zbuff 整块写入

注意

写入长度以数据自身长度为准,不会自动补齐到交换区大小,也不会自动清零旧内容。guest 侧若按定长结构体解析,先自己把尾部填 0,避免读到上一轮的残留。

4.3 ndk.getData(ctx, buff, len, offset)

功能

从交换区读数据。参数位置是「可选的 buff 在前」,因此有三种调用形态。

参数

参数 类型 必填 说明
ctx userdata 是 执行上下文
buff zbuff 否 输出缓冲;传了就写进 buff 并返回字节数
len int 否 读取长度,默认交换区大小(传 buff 时还会被裁到 buff 长度)
offset int 否 交换区内偏移,默认 0

返回值

形态 返回
不传 buff 字符串(实际读到的内容)
传 buff 整数(读取字节数),内容在 buff 里
失败 false, err

示例

-- 形态一:直接拿字符串
local s = ndk.getData(ctx)             -- 整个交换区
local head = ndk.getData(ctx, 16, 0)   -- 前 16 字节

-- 形态二:写进 zbuff,避免临时字符串占用堆
local buf = zbuff.create(512)
local n = ndk.getData(ctx, buf, 512, 0)
log.info("ndk", "读到", n, "字节,首字节", buf[0])

注意

ndk.getData(ctx) 不传 len 时按整个交换区大小分配字符串,交换区设成 4 KiB 就是每次读 4 KiB;只想取一小段时一定把 len 传上。

4.4 ndk.exec(ctx, opts, elapsed_us)

功能

在当前线程同步执行 guest 代码,直到它主动退出、trap,或被步数预算耗尽。

参数

参数 类型 必填 说明
ctx userdata 是 执行上下文
opts int / table 否 传数字时等于步数;传表时支持 {steps = 步数, elapsed = 每步时间us}
elapsed_us int 否 opts 为数字时,用它指定每步时间(us)

steps 与 elapsed 都不传时按 0 处理:steps = 0 表示不限步数,elapsed = 0 表示不模拟每步耗时(跑得最快)。想让 guest 的「软件延时」接近真实速度时,把 elapsed 设成 100 左右。

返回值

情况 返回 含义
正常退出(写 SYSCON 0x5555) true, 0 guest 调了 ndk_exit_ok() 或同类退出路径
正常退出(ecall,mcause = 11) true, retval retval 是 guest 的 a0 寄存器
步数耗尽 / 被 ndk.stop 打断 false, "timeout", mcause, mtval 预算用完了,不是 guest 写错
异常(非法指令、访问违例…) false, "trap", mcause, mtval 看 mcause / mtval 定位
已在运行 false, "busy", mcause, mtval 先 ndk.stop 再 exec

示例

-- 旧式:限 10 万步,每步按 500us 计
local ok, ret, mcause, mtval = ndk.exec(ctx, {steps = 100000, elapsed = 500})

-- 新式(推荐):不限步数,由 guest 自己退出
local ok2, ret2 = ndk.exec(ctx, {steps = 0, elapsed = 100})

-- 也支持位置参数写法:10 万步、每步 500us
local ok3, ret3 = ndk.exec(ctx, 100000, 500)

注意

steps = 0 是「不限步数」,不是「跑 0 步」。guest 死循环时不会把固件卡死——它仍然响应 ndk.stop,但同步 exec 期间 Lua 主线程是被占住的,长任务请改用 ndk.thread。调试期建议给一个较大的 steps 上限(例如 1000000),异常时能自己退出而不是一直等。

4.5 ndk.thread(ctx, opts, elapsed_us)

功能

在独立线程里异步执行 guest,立刻返回。参数与 ndk.exec 完全一致。

参数

同 4.4:opts 可传数字(步数)或表 {steps=, elapsed=},elapsed_us 是 opts 为数字时的每步时间。

返回值

情况 返回
成功启动 整数线程 ID(单调递增)
失败 / 繁忙 nil, err

示例

local ctx = ndk.rv32i("/luadb/fft.bin", 128 * 1024, 4096)
ndk.setData(ctx, input_bytes)

local tid = ndk.thread(ctx, {steps = 0})
if not tid then
    log.error("ndk", "起线程失败")
    return
end

-- 轮询:要「跑着也能看状态」就用 wait_ms = 0
while ndk.info(ctx).running do
    sys.wait(50)
end

local ok = ndk.stop(ctx, 0)   -- 已结束,幂等成功
log.info("ndk", "输出", ndk.getData(ctx, 512, 0))

注意

线程起来之后 exec 与 reset 都会被拒(返回 busy),必须先 stop。线程 ID 只是递增序号,不是句柄;同一个 ctx 只能有一个线程。

4.6 ndk.stop(ctx, wait_ms)

功能

请求停止后台线程并等待它退出。

参数

参数 类型 必填 说明
ctx userdata 是 执行上下文
wait_ms int 否 等待上限,默认 1000 毫秒;传 0 表示不等待、只置停止标志

返回值

情况 返回
成功(含空闲态调用) true
超时 false, "timeout"

示例

-- 异步流程里做非阻塞轮询
local stopped = false
for i = 1, 20 do
    if ndk.stop(ctx, 0) then
        stopped = true
        break
    end
    sys.wait(50)
end
log.info("ndk", "线程是否已停", stopped)

注意

stop 是幂等的:没在跑、已经停了、刚创建还没跑,调它都返回 true。因此可以把 stop 直接写在错误分支和回收分支里,不必先判状态。wait_ms = 0 时返回值表示「这一次查询时是否已经停下」,适合放在循环里轮询。

4.7 ndk.reset(ctx)

功能

把沙盒恢复到「刚创建」的状态:重新从文件读镜像、清空 RAM 与交换区、复位全部寄存器(PC 回到 0x80000000)。

参数

参数 类型 必填 说明
ctx userdata 是 执行上下文

返回值

情况 返回
成功 true
失败 / 运行中 false, err

示例

-- 同一份镜像重复跑不同输入,reset 比重新 rv32i 更省(不重新申请 RAM)
for i = 1, 3 do
    ndk.reset(ctx)
    ndk.setData(ctx, "run-" .. i)
    local ok, ret, mcause, mtval = ndk.exec(ctx, {steps = 0})
    log.info("ndk", i, ok, ret, ndk.getData(ctx, 32, 0))
end

注意

reset 会重新读一次镜像文件。镜像被替换、被删、或文件系统未挂载时会失败;量产场景里若镜像在 SD 卡上,务必先确认卡已挂好再 reset。

4.8 ndk.info(ctx)

功能

读取上下文状态,用于生命周期判断与 ABI 能力探测。只读,任何状态下都能调。

参数

参数 类型 必填 说明
ctx userdata 是 执行上下文

返回值

成功返回一个表,字段见第五节;ctx 不是合法上下文时会抛参数类型错误。

示例

local info = ndk.info(ctx)
log.info("ndk", "RAM", info.mem, "交换区", info.exchange,
         "正在跑", info.running, "ABI", info.abi_version,
         "能力位", string.format("0x%02X", info.features))

if info.features & 0x08 ~= 0 then
    log.info("ndk", "支持 GPIO 代理")
end

4.9 错误串对照表

ndk.* 返回的 err 都是下面这几个固定串,直接按它分支即可:

错误串 触发场景 处理
invalid param 参数类型不对、opts 不是表、isa 不是字符串 改调用方式
no memory 申请 RAM / 缓冲失败 降 mem_size、先释放临时对象
io error 镜像读不到、文件系统没挂 检查路径与挂载
image too large 镜像大于 mem_size - exchange_size 调大 mem_size 或精简镜像
busy 运行中调了 exec / thread / reset 先 ndk.stop
trap guest 执行异常 看 mcause / mtval
timeout 步数耗尽或 stop 等待超时 加大 steps 或 wait_ms
unknown 未归类的内部错误 抓完整日志给 FAE

五、ndk.info 返回字段

字段 类型 含义
mem int guest RAM 总大小(字节)
exchange int 交换区大小(字节)
exchange_addr int 交换区在 guest 视角的起始地址(0x80000000 + mem - exchange)
image int 镜像字节数(源码字段 image_size,不是路径字符串)
running boolean 当前是否在运行(等价于内部是否 busy)
mcause int 最后一次 trap 的原因码
mtval int 最后一次 trap 的附加值
abi_magic int Host ABI magic,固定 0x4E444B31(ASCII NDK1)
abi_version int Host ABI 版本,当前 0x00010000(即 1.0.0)
features int 宿主能力位图,见第六节
last_error int 最近一次 Host ABI 调用写入的错误码
event_slots int 事件队列槽位数量
isa string 当前 ISA,默认 rv32ima

mcause 常见取值(guest 报 trap 时按这张表对照):

mcause 含义 典型原因
1 取指访问异常 跳到了非法地址;镜像 _start 的返回地址寄存器没写对
2 非法指令 用了 RV32IMA 之外的指令(如压缩指令或浮点)
5 读访问异常 读了不在 RAM 范围内的地址
7 写访问异常 栈指针初值为 0,prologue 一减就写到 0xFFFFFFFC
11 环境调用(ecall) 不是错误,是 guest 主动退出,retval 就是 a0

六、features 能力位与 Host ABI

6.1 features 位定义

位 名称 含义
bit0 META 可查询宿主元信息(型号、版本等)
bit1 TIME 可读宿主微秒时钟、可请求延时
bit2 EVENT 有事件队列,guest 可投递/收取事件
bit3 GPIO 可经宿主代理配置与读写 GPIO
bit4 UART 可经宿主代理收发串口数据
bit5 CRYPTO 可请求宿主计算 MD5 / CRC32

判断写法:if info.features & 0x04 ~= 0 then ... end(0x04 即 EVENT)。guest 侧一定要先读 features 再决定要不要用某个 CSR,能力位没开时写对应 CSR 不会生效。

6.2 Host ABI CSR 一览

guest 通过 csrrw / csrrs 访问下面这些编号的 CSR 来请求宿主服务;这些编号不是 RISC-V 标准 CSR,只在本沙盒内有效:

CSR 用途
0x13C ~ 0x13F 宿主 magic、ABI 版本、能力位、最近错误码
0x140 事件槽位数量
0x141 / 0x142 宿主微秒时钟 低 32 位 / 高 32 位
0x143 请求延时(单位 us)
0x144 / 0x145 事件使能 / 事件挂起状态
0x210 ~ 0x214 GPIO 配置、写、读、中断状态、中断清除
0x220 ~ 0x224 UART 配置、发送、接收状态、接收读取、接收清空
0x230 / 0x231 CRYPTO 的 MD5 / CRC32

另外还有一个 SYSCON 约定:guest 向 SYSCON 写 0x5555 表示「正常结束」,宿主侧对应 ndk.exec 返回 true, 0。

七、guest 镜像怎么来

ndk 只负责「跑」,不负责「编」。镜像要自己用 RISC-V 工具链编出来:

1、工具链:LLVM ≥ 16(自带 RISC-V 后端与 ld.lld),需要 clang、ld.lld、llvm-objcopy 三个命令在 PATH 里;目标三元组用 riscv32-unknown-elf,-march=rv32ima -mabi=ilp32;

2、入口约定:入口可以是裸汇编,也可以直接用官方示例里的 NDK_GUEST_START(main) 宏。手写 C 的 _start 必须标 __attribute__((naked, noreturn)),否则编译器会给它生成 prologue,一进来就 addi sp, sp, -0x10 再 sw ra, 0xc(sp),而宿主把全部通用寄存器清成了 0,必然出现「写入 0xFFFFFFFC」的 trap;

3、内存布局:链接脚本里 RAM 起始 0x80000000,长度就是 mem_size(要扣掉交换区);栈顶由 guest 自己按 CSRR 从宿主取,或者固定在镜像尾部之前;

4、结束方式:main 正常 return 之前调一次 ndk_exit_ok()(等价于写 SYSCON 0x5555),或者用 ecall 把返回值放进 a0;

5、放哪:编出来的 .bin 用 Luatools 的「脚本和资源文件」一起下载到 /luadb/,或放进 SD 卡。

官方仓库里有五个可直接参考的示例:hello_world(最小骨架)、exchange_buffer_demo(交换区收发)、gpio_hostabi_demo(经宿主点灯)、crypto_hash_demo(宿主算 MD5/CRC32)、perf_guest_v1(手写 naked 入口的性能测试)。源码位置:components/ndk/guest/examples。

八、常见问题

Q1:ndk 是 nil,代码直接报错怎么办?

当前固件没编入 ndk。公开固件里只有 ec718hm 平台的 13 号与 24 号固件打开了 LUAT_USE_NDK,换这两个固件号,或者脚本里先 if ndk then 兜底,走 Lua 实现的分支。

Q2:ndk.exec 返回 false, "timeout" 是 guest 崩了吗?

不是。timeout 表示步数预算用完了,或者被 ndk.stop 打断,guest 本身没有异常。要么把 steps 调大(或直接传 0 表示不限步数),要么检查是不是客代码里真的有死循环。

Q3:ndk.exec 返回 false, "trap", 7, 0xFFFFFFFC,日志里 guest 一行输出都没有。

这是「入口函数没标 naked」的经典指纹:宿主编译器为 C 写的 _start 生成 prologue,第一步就用到栈指针,而入口时所有寄存器都是 0,于是写到 0xFFFFFFFC。给 _start 加 __attribute__((naked, noreturn)),或改用 NDK_GUEST_START(main) 宏。

Q4:ndk.exec 返回 false, "trap", 1, 0。

mcause=1 是取指异常、mtval=0 说明跳到了地址 0。通常是 main 自然 return 时返回地址寄存器是 0(入口没把返回地址写好)。解决办法同样是在 main 末尾调 ndk_exit_ok() 走正常退出路径,而不是靠 ret。

Q5:想给 guest 配 1 MB RAM,为什么不行?

guest RAM 硬上限是 512 KiB。ndk.rv32i(path, 512 * 1024, 1024) 就是能拿到的最大值;再大只能改固件的 LUAT_NDK_MAX_RAM_SIZE,属于定制固件范畴。

Q6:exec 里传 steps = 0 会不会把模组跑死?

不会。steps = 0 的语义是「不限步数」——guest 会一直跑到自己退出、trap,或者被 ndk.stop 打断;stop 是异步生效的,1 秒内必定返回。但同步 exec 期间 Lua 主线程确实被占住,业务脚本会停在那儿,长任务请用 ndk.thread。

Q7:guest 想点灯 / 读串口,为什么不直接操作寄存器?

沙盒里没有裸机外设,guest 只能通过 Host ABI 的 CSR 请求宿主代办。用之前先读 ndk.info(ctx).features,确认 bit3(GPIO)或 bit4(UART)已经置位;没置位说明当前固件没有把这部分能力接进来。

搜索