上一篇解决了采集:/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 硬件只支持 HEVCEncOpenParam.bitstreamFormatSTD_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;

逐步说明:

  1. vdi_init(0)VPU_InitWithBitcode():进程级单例,幂等。固件要读成 Uint16 数组传入,sizeInWord = 字节数 / 2
  2. VPU_EncOpen(STD_HEVC)ringBufferEnable=0,逐帧 buffer-flush 模式。
  3. ENC_SET_SLICE_INFO + SET_SEC_AXI(次级 AXI 全关,与官方 sample 一致)。
  4. VPU_EncGetInitialInfo必须带重试(我们重试 10 次、每次间隔 20ms)。
  5. 分配并注册 recon 帧缓冲(约束见下节)→ VPU_EncRegisterFrameBuffer
  6. ENC_PUT_VIDEO_HEADER 取 VPS/SPS/PPS 并缓存(顺序错就 0x2,见上)。
  7. VPU_EncAllocateFrameBuffer(FB_TYPE_PPU, updateFbInfo=TRUE) 注册 3 个源帧缓冲,之后 bufCb/bufCr 已被 VPU 回填成真实地址——拷帧时 Y/UV 要按回填偏移分拷,不能假设 UV 紧跟 Y
  8. 每帧:memcpy Y/UV → vdi_flush_ddr 刷缓存 → VPU_EncStartOneFrameVPU_WaitInterruptVPU_EncGetOutputInfo

第 4 步为什么要重试:瞬时失败(minfb=0)偶发,重试一次就成功;而一旦让它真失败,错误路径会遗留 vdi_lock,随后 EncClose 必死锁——所以必须重试到成功为止。

参数坑一览

EncOpenParam 里每个字段都可能是雷,以下全部实测:

参数正确值踩坑记录
bitRate单位 bps,上限 700Mbpsvpuapi.h 里「kbps」注释是 CODA9/WAVE320 的遗留,不适用 420L
initialDelay500填 0 会 INVALID_PARAM(校验要求 10..3000)
gopParam.tidPeriod060gopPresetIdx<16 时校验强制要求 60
initialRcQp63(自动)0 会被拒;<52 时必须落在 [minQp,maxQp] 内
minQp/maxQp/maxDeltaQp8 / 51 / 10memset 全 0 会把 QP 钳在 0 → 码率彻底失控,实测 1.5MB/帧 ≈ 375Mbps
useRecommendEncParam1(推荐,稳定)2(Boost) 有固件未就绪/首帧不完问题,且失败路径遗留 vdi_lock 死锁,勿用
decodingRefreshType2(IDR)早期误用 1(CRA),浏览器随机接入不友好
forcedIdrHeaderEnable1实测不生效:IDR AU 里只有 VCL NAL,参数集只能服务端前插(第三篇细讲)

最离谱的是 QP(量化参数,值越大压缩越狠、画质越低)那一行:官方 cfgParser 的默认值是 8/51/10,但你 memset(&op, 0, ...) 之后这些字段全是 0,编码器就把 QP 钳死在 0,码率失控到 375Mbps——一帧 1.5MB,比无损还夸张。QP 范围必须显式赋初值

recon 帧缓冲的两条硬约束

recon(重建)帧是编码器内部保存的「已编码画面副本」,后续 P 帧以它为参考做运动补偿预测。它的分配方式踩了两颗雷:

  1. 必须 COMPRESSED_FRAME_MAP(FBC 压缩布局)LINEAR 直接返回 NOT_SUPPORTED_FEATURE
  2. 必须一次性分配连续大块,再按 fbSize 切分给各帧。因为 bufCb/bufCr=-1(压缩布局由 VPU 决定)时,vpuapi 假定 bufYsize*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 三连坑。