上一篇解决了采集:/dev/video1 稳定输出 1920×1080 的 NV12 帧。接下来是编码。JH7110 上挂着一颗 Chips&Media WAVE420L VPU,配套用户态库 libsfenc.so、内核模块 /dev/venc、固件 /lib/firmware/monet.bin——但这颗 VPU 我们一度判了死刑。
项目 README 里有这么一行(2026-08-08 写下):
H264/HEVC 硬编(libsfenc+/dev/venc)不可用:JH7110 VENC 仅 HEVC;monet.bin 固件(2023)与 6.12.5 内核驱动(2025)不匹配,固件命令全部失败(0x2)。
这个结论错得离谱。8 月 11 日复盘时发现,所谓「固件命令全部失败」其实是我们自己造成的两个低级错误。本篇先把翻案过程讲清楚,再给出这份验证过的 WAVE420L 编码调用清单。
先说死的部分:H264 硬编真的不存在
有一句结论不用翻:JH7110 的 VENC 硬件只支持 HEVC。EncOpenParam.bitstreamFormat 传 STD_AVC 会返回 RETCODE_NOT_SUPPORTED_FEATURE(实测确认),官方规格本来也只写了「H265 编码 1080p@30fps」。
项目里最早的验证工具叫 h264_enc_stream.c——文件名是个历史误导,它从第一天起编码的就是 HEVC(连早期计划文档都是按「H264 硬编」制定的,后来整份作废)。如果你也在 JH7110 上找 H264 硬件编码,别找了。
误诊始末:fail_reason=0x2 的两种打开方式
8 月 7 日的现象是:按官方 sample 的流程走,固件命令频繁失败,诊断寄存器 0x114(fail_reason)读出 0x2。查不到文档,看起来就是「2023 年的固件配上 2025 年的驱动,协议对不上」,于是写入 README,转去折腾 MJPEG/JPU(那条路也死了,按下不表)。
一周后重看代码,发现两个致命伤:
错误一:ENC_PUT_VIDEO_HEADER 调用时机太早。 输出 VPS/SPS/PPS 头的命令必须在 VPU_EncGetInitialInfo + VPU_EncRegisterFrameBuffer 之后调用,否则固件返回的正是 fail_reason=0x2。我们当时参考 sample 但调换了顺序,于是「固件命令全部失败」。修正后的代码注释里留着这句忏悔:
/* 输出 VPS/SPS/PPS 头。注意:必须在 GetInitialInfo + RegisterFrameBuffer 之后调用,
否则固件返回 fail_reason=0x2(2026-08-07 曾因此误判为固件/驱动不匹配) */
错误二:没等中断就取结果。 VPU 编码是异步的,VPU_EncStartOneFrame 只是下发任务,必须 VPU_WaitInterrupt 等完成中断后再调 VPU_EncGetOutputInfo。不等中断直接取,会因为 VPU 忙(busy=1)失败——又一笔算在了「固件坏」头上:
/* 编码是异步的:必须等完成中断后再取结果(与官方 sample 一致)。
不等中断直接 GetOutputInfo 会因 VPU 忙(busy=1)失败——2026-08-07 曾因此误判固件坏 */
{
int timeouts = 0;
Int32 int_reason;
while ((int_reason = VPU_WaitInterrupt(0, 2000)) == -1) {
if (++timeouts >= 3) { /* 三次 2s 超时才算真失败 */ }
}
}
两个错误改掉,同一套固件、同一个驱动,编码器一次点亮。教训:硬件 SDK 的「失败」先怀疑自己的调用顺序和时序,别急着判固件死刑。
验证过的调用序列
最终固化在 C 薄封装库(约 550 行,Rust 经 FFI 调用)里的完整序列如下,标红的两步正是当年冤案的元凶:
flowchart TD
A["vdi_init → VPU_InitWithBitcode<br/>载入固件 monet.bin(Uint16 数组)"] --> B["VPU_EncOpen<br/>STD_HEVC,逐帧 flush 模式"]
B --> C["ENC_SET_SLICE_INFO / SET_SEC_AXI"]
C --> D["VPU_EncGetInitialInfo<br/>带重试(10 次 × 20ms)"]
D --> E["注册 recon 帧缓冲<br/>COMPRESSED + 连续大块"]
E --> F["ENC_PUT_VIDEO_HEADER<br/>取 VPS/SPS/PPS 并缓存"]
F --> G["VPU_EncAllocateFrameBuffer<br/>3 个源帧缓冲"]
G --> H["每帧:分拷 Y/UV → flush<br/>→ EncStartOneFrame"]
H --> I["VPU_WaitInterrupt<br/>必须等完成中断"]
I --> J["VPU_EncGetOutputInfo<br/>取码流"]
classDef hot fill:#fdecea,stroke:#c0392b,color:#000;
class F,I hot;
逐步说明:
vdi_init(0)与VPU_InitWithBitcode():进程级单例,幂等。固件要读成 Uint16 数组传入,sizeInWord = 字节数 / 2。VPU_EncOpen(STD_HEVC):ringBufferEnable=0,逐帧 buffer-flush 模式。ENC_SET_SLICE_INFO+SET_SEC_AXI(次级 AXI 全关,与官方 sample 一致)。VPU_EncGetInitialInfo:必须带重试(我们重试 10 次、每次间隔 20ms)。- 分配并注册 recon 帧缓冲(约束见下节)→
VPU_EncRegisterFrameBuffer。 ENC_PUT_VIDEO_HEADER取 VPS/SPS/PPS 并缓存(顺序错就 0x2,见上)。VPU_EncAllocateFrameBuffer(FB_TYPE_PPU, updateFbInfo=TRUE)注册 3 个源帧缓冲,之后bufCb/bufCr已被 VPU 回填成真实地址——拷帧时 Y/UV 要按回填偏移分拷,不能假设 UV 紧跟 Y。- 每帧:
memcpyY/UV →vdi_flush_ddr刷缓存 →VPU_EncStartOneFrame→VPU_WaitInterrupt→VPU_EncGetOutputInfo。
第 4 步为什么要重试:瞬时失败(minfb=0)偶发,重试一次就成功;而一旦让它真失败,错误路径会遗留 vdi_lock,随后 EncClose 必死锁——所以必须重试到成功为止。
参数坑一览
EncOpenParam 里每个字段都可能是雷,以下全部实测:
| 参数 | 正确值 | 踩坑记录 |
|---|---|---|
bitRate | 单位 bps,上限 700Mbps | vpuapi.h 里「kbps」注释是 CODA9/WAVE320 的遗留,不适用 420L |
initialDelay | 500 | 填 0 会 INVALID_PARAM(校验要求 10..3000) |
gopParam.tidPeriod0 | 60 | gopPresetIdx<16 时校验强制要求 60 |
initialRcQp | 63(自动) | 0 会被拒;<52 时必须落在 [minQp,maxQp] 内 |
minQp/maxQp/maxDeltaQp | 8 / 51 / 10 | memset 全 0 会把 QP 钳在 0 → 码率彻底失控,实测 1.5MB/帧 ≈ 375Mbps |
useRecommendEncParam | 1(推荐,稳定) | 2(Boost) 有固件未就绪/首帧不完问题,且失败路径遗留 vdi_lock 死锁,勿用 |
decodingRefreshType | 2(IDR) | 早期误用 1(CRA),浏览器随机接入不友好 |
forcedIdrHeaderEnable | 1 | 实测不生效:IDR AU 里只有 VCL NAL,参数集只能服务端前插(第三篇细讲) |
最离谱的是 QP(量化参数,值越大压缩越狠、画质越低)那一行:官方 cfgParser 的默认值是 8/51/10,但你 memset(&op, 0, ...) 之后这些字段全是 0,编码器就把 QP 钳死在 0,码率失控到 375Mbps——一帧 1.5MB,比无损还夸张。QP 范围必须显式赋初值。
recon 帧缓冲的两条硬约束
recon(重建)帧是编码器内部保存的「已编码画面副本」,后续 P 帧以它为参考做运动补偿预测。它的分配方式踩了两颗雷:
- 必须
COMPRESSED_FRAME_MAP(FBC 压缩布局),LINEAR直接返回NOT_SUPPORTED_FEATURE; - 必须一次性分配连续大块,再按
fbSize切分给各帧。因为bufCb/bufCr=-1(压缩布局由 VPU 决定)时,vpuapi 假定bufY起size*num字节是连续内存——独立分配多块会导致固件访问越界,报WAVE5_SYSERR_ACCESS_VIOLATION_HW(实测 enc_err=0x1)。
错误处理哲学:vdi_lock 死锁与 broken 标志
这个 SDK 最阴间的特性:VPU API 的错误路径会遗留 vdi_lock(用户态库内部的互斥锁)未释放。一旦某次调用失败,之后再调任何 API——包括 VPU_EncClose——都会死锁。所以封装层的态度是「错了就摆烂」:
struct CamHevcCtx {
/* ... */
int broken; /* 编码致命错误后置位:close 跳过全部 VPU 调用
(VPU API 错误路径遗留 vdi_lock,再调任何 API 必死锁;
此时进程应尽快退出,靠内核回收 DMA/实例) */
};
void cam_hevc_close(CamHevcCtx *ctx) {
if (ctx->broken) {
free(ctx->header);
free(ctx);
return; /* 只释放自有内存,DMA/实例靠进程退出时内核回收 */
}
/* ...正常清理... */
}
配合上层策略:连续 3 帧编码失败 → 销毁重建编码器;连续 10 次 open 失败 → 直接退出进程,让 systemd 先复位 VPU 模块再拉起来。
还有两个驱动层的「坑点勘察」成果:
- 强制 IDR(关键帧,不依赖前帧即可独立解码)的正确姿势:
forceIPicture是 coda9 遗留字段,WAVE5 驱动根本不编程这个寄存器,设了无效。必须用forcePicTypeEnable=1 + forcePicType=3(vpuapi.h 的顺序是 I,P,B,IDR,CRA,3=IDR)。 - IDR 判定不能信寄存器:
encVclNal在此固件返回垃圾值(实测0x70400000这类物理地址)。只能在输出码流里扫 AnnexB(以起始码00 00 01分隔各 NAL 单元的打包格式),看 NAL 类型 19/20 判 IDR。 - 诊断寄存器:失败时读
0x110(ret_success) /0x114(fail_reason) /0x70(busy),比返回码信息量大得多。 - TLS 别用:线程局部存储会给 glibc 交叉链接引入
__tls_get_addr的麻烦,错误字符串用静态缓冲。
VPU 自愈:venc-reset.sh
编码进程被 kill 后,VPU 固件可能 wedge(下个进程首帧又是 fail_reason=0x2),而且 /dev/shm/vencmutex 的过期互斥锁会让 vdi_init 直接卡死。所以 systemd 的 ExecStartPre 挂了一个 9 行的复位脚本:
#!/bin/bash
# 复位 WAVE420L VPU 内核模块(camera-web.service ExecStartPre 调用,root 执行)
set -e
/sbin/rmmod venc 2>/dev/null || true
/sbin/modprobe venc
chmod 666 /dev/venc 2>/dev/null || true
rm -f /dev/shm/vencmutex
重启模块 + 清锁,是目前最可靠的自愈组合。另外注意驱动重载后 /dev/venc 权限恢复 root 属主,脚本里顺手 chmod 666。
小结与预告
翻案后的最终形态:1080p 输入、CBR(固定码率)默认 6000kbps、GOP(关键帧间隔)30、IPPPP 纯 P 帧低延迟,实测稳定编码约 21fps——比最初的软件 JPEG 方案(4.1fps、CPU 100%)高了一个量级,CPU 还几乎空着。
码流有了,怎么送到浏览器并让它解码,是下一个战场。下一篇《Web 串流架构与手工封装 fMP4》:WebSocket 广播、WebCodecs 三级降级链,以及一个 392 行零依赖的 fMP4 封装器和它遇到的 Chrome 三连坑。