这是一篇「照着做就能通」的完整实现指南:在昉·星光 2(VisionFive 2,RISC-V 架构)上,把树莓派 Camera v2(imx219)的画面以 1080p HEVC 推到浏览器实时播放。全程只用 Rust + 一层 C 薄封装,不依赖 ffmpeg、GStreamer 或任何现成视频服务。最终效果:硬件编码约 21fps、码率 6000kbps CBR,浏览器打开网页即看,WebCodecs 路径延迟一秒内。

与之配套的五篇调试手记记录了每个环节背后的排错过程,本篇只给正确做法;想听故事的可以从文末链接过去。

硬件与整体架构

项目详情
开发板StarFive VisionFive 2(JH7110,4×U74 RISC-V @1.5GHz)
摄像头imx219(树莓派 Camera v2),CSI 接口
系统Debian riscv64,内核 6.12.5-starfive
编码器板载 WAVE420L VPU(HEVC 硬编,用户态 libsfenc.so + /dev/venc + 固件 monet.bin

整体架构是一条三线程流水线:

flowchart LR
    subgraph BD["开发板(VisionFive 2)"]
        direction TB
        S["imx219 摄像头"] --> PHY["csiphy0"] --> CSI["csi0"] --> ISP["isp0"] --> VIN["vin0_isp0"] --> V1["/dev/video1<br/>NV12 1920x1080"]
        V1 --> CAP["采集线程<br/>DQBUF → 拷贝 → QBUF"]
        CAP --> POOL["3 槽环形帧池<br/>满时覆盖最旧帧"]
        POOL --> ENC["编码线程<br/>WAVE420L HEVC 硬编"]
        ENC --> HUB["WsHub 广播<br/>每客户端容量 2 队列"]
    end
    HUB -->|"WebSocket :8081"| BR["浏览器<br/>WebCodecs hev1 → hvc1 → MSE fMP4"]

阅读本文前,先认识几个反复出现的名词:

名词含义
NV12一种 YUV420 像素格式:一个 Y 亮度平面 + 一个 UV 交错色度平面
ISP图像信号处理器,把 sensor 的 RAW 数据加工成可用图像
VPU视频处理单元,本文指 WAVE420L 硬件编码器
IDR关键帧(即时解码刷新帧),不依赖任何前帧即可独立解码
GOP关键帧间隔(多少帧出一个 IDR)
CBR固定码率控制
VPS/SPS/PPSHEVC 的三级参数集,解码器初始化的必需信息
NALHEVC 码流的基本单元(网络抽象层单元),可理解为「一个包」
AnnexB码流打包格式:每个 NAL 以起始码 00 00 01 分隔
WebCodecs浏览器提供的底层硬件编解码 API
MSEMedia Source Extensions,用 JS 向 <video> 持续喂媒体数据的机制
fMP4 / hvcC分片 MP4 封装 / 其中存放 HEVC 解码配置的 box

HTTP 端口 8080 提供控制面:/(页面)、/status(JSON 状态)、/control?action=on|off/set?bitrate=/ctrl?contrast=&saturation=&hue=&awb=&exposure=...(ISP 图像参数)。

第一步:恢复采集管线

VisionFive 2 的 VIN media graph 开机后需要显式恢复链路与格式。把下面的脚本保存为 camera-pipeline-setup.sh,并注册为 systemd oneshot 服务开机执行:

#!/bin/sh
# 恢复 imx219 -> csiphy0 -> csi0 -> isp0 -> vin0_isp0 -> video1 管线
set -e
MD=/dev/media0

# 使能两条可配置链路(实体名语法,免疫 /dev/v4l-subdevN 编号变化)
media-ctl -d $MD -l "'stf_csi0':1 -> 'stf_isp0':0 [1]"
media-ctl -d $MD -l "'stf_isp0':1 -> 'stf_vin0_isp0':0 [1]"

# 全路径统一 1920x1080(imx219 驱动只接受 1080,1088 会被 clamp)
media-ctl -d $MD --set-v4l2 '"imx219 6-0010":0[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_csiphy0":0[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_csiphy0":1[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_csi0":0[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_csi0":1[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_isp0":0[fmt:SRGGB10_1X10/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_isp0":1[fmt:Y12_1X12/1920x1080]'
media-ctl -d $MD --set-v4l2 '"stf_vin0_isp0":0[fmt:Y12_1X12/1920x1080]'

采集节点用 /dev/video1(ISP 主输出,NV12)/dev/video0 是 Bayer 直通节点,不走 ISP,本项目不用。

第二步:ISP 守护与 sensor 初始化

ISP 由用户态守护进程配置,启动命令:

sudo pkill -9 -x stf_isp_ctrl   # 先清掉旧实例
sudo setsid /opt/ISP/stf_isp_ctrl -m imx219mipi
sleep 12                        # ISP 完全就绪约需 10s,给足 12s

接着程序里完成 sensor 初始化三步(imx219 在 i2c-6 总线、地址 0x10):

  1. 设格式:对 sensor 子设备调 VIDIOC_SUBDEV_S_FMT,设 1920×1080 SRGGB10_1X10(mbus code 0x300f)。手写这个 ioctl 时注意结构体必须是标准布局的 88 字节——which(u32) + pad(u32) + v4l2_mbus_framefmt(48 字节,width@0/height@4/code@8)+ reserved[8],编号 0xC0585605;which 填 1(ACTIVE,填 0=TRY 只试算不生效)。ioctl 编号内嵌结构体大小,尺寸算错内核直接 ENOTTY,最稳的办法是在板子上用 sizeof 探针核对;
  2. 开时钟:写 sysfs /sys/devices/platform/soc/12060000.i2c/i2c-6/6-0010/power/controlon,使能 MCLK;
  3. 等 200ms 后开流:经 I2C_RDWR 直写寄存器 0x0100=1(Stream On)。I2C_RDWR 成功时返回传输消息数(1),判断用 r >= 0

曝光与图像参数:曝光控件 0x00980911 在 sensor 子设备上(范围 4..3522,启动设最大值提亮);对比度/饱和度/色调/白平衡等控件在 ISP 处理子设备上——枚举 /dev/v4l-subdev*G_CTRL 探测即可定位。

第三步:裸 ioctl V4L2 采集

采集层直接 open/ioctl/mmap,与 v4l2-ctl 走同一内核路径。结构体按内核真实布局定义(64 位 Linux):v4l2_format 共 208 字节,type 在 offset 0,4 字节填充后联合体从 offset 8 开始v4l2_buffer 共 88 字节。ioctl 请求号按 _IOWR('V', nr, size) 生成(G_FMT/S_FMT 等都是读写方向)。

#[repr(C)]
struct V4l2Format {
    type_: u32,
    _pad: u32, // 4 字节填充,把联合体对齐到 offset 8
    pix: V4l2PixFormat,
    _rest: [u8; 200 - 48],
}

采集流程:

  1. open("/dev/video1")S_FMT 请求 1920×1080 NV12——高度必须 1080,不要为对齐请求 1088(1088 会让管线协商几何与 sensor 实际输出错位,2026-08-25 实测直接整幅花屏)→ G_FMT 回读确认 1920×1080(sizeimage=3110400);
  2. REQBUFS 申请 4 个缓冲 → 逐个 QUERYBUF + mmap + QBUF(QUERYBUF 返回的 offset/length 是建 DMA 描述符的依据,不可跳过);
  3. STREAMON,循环:poll(3 秒超时判管线异常)→ DQBUF → 按 bytesused 拷贝真实数据 → QBUF 还回。

1080 与 1088 的分工:采集协商拿到的是精确 1080 行(sizeimage=3110400);而编码器按 1088 打开(CTB 网格对齐),送编码前要把 1080 行帧按 NV12 平面结构重组(NV12 的 UV 平面位置由「宽×高」算出,绝不能线性扩长度):Y 平面真实 1080 行 + 末行复制补 8 行;UV 平面(真实 540 行)搬到 1088 布局的 UV 基址(宽×1088 偏移处)+ 末行复制补 4 行。补齐一律复制最后一真实行,颜色最自然。帧池槽位可以按 1088 容量预分配(兼容两种帧长),但要有一个 used_len 字段记录真实字节数,向下游只暴露实际数据:

// 1080 行真实帧 → 1088 行编码输入:按 NV12 平面重组,末行复制补齐
let y_real = w * 1080;          // 真实 Y 字节数
let y_full = w * 1088;          // 编码器期望的 Y 字节数
let uv_real = y_real / 2;       // 真实 UV 字节数(540 行交错)
let mut buf = vec![0u8; y_full * 3 / 2];
// Y:真实 1080 行 + 末行复制 ×8
buf[..y_real].copy_from_slice(&frame[..y_real]);
let last_y = &frame[y_real - w..y_real];
for r in 0..8 {
    buf[y_real + r * w..y_real + (r + 1) * w].copy_from_slice(last_y);
}
// UV:真实 540 行搬到 1088 布局的 UV 基址 + 末行复制 ×4
buf[y_full..y_full + uv_real].copy_from_slice(&frame[y_real..y_real + uv_real]);
let last_uv = &frame[y_real + uv_real - w..y_real + uv_real];
for r in 0..4 {
    buf[y_full + uv_real + r * w..y_full + uv_real + (r + 1) * w].copy_from_slice(last_uv);
}

帧池设计:3 槽预分配环形缓冲,零逐帧分配;满时覆盖最旧帧——直播要的是最新画面,不是完整队列。

第四步:WAVE420L HEVC 硬编

编码封装在约 550 行的 C 薄封装库里,Rust 经 FFI 调用。正确的 API 序列一图概览:

flowchart TD
    A["vdi_init → VPU_InitWithBitcode<br/>载入固件 monet.bin"] --> B["VPU_EncOpen<br/>STD_HEVC,逐帧 flush 模式"]
    B --> C["ENC_SET_SLICE_INFO + SET_SEC_AXI"]
    C --> D["VPU_EncGetInitialInfo<br/>最多重试 10 次"]
    D --> E["注册 recon 帧缓冲<br/>COMPRESSED_FRAME_MAP 连续大块"]
    E --> F["ENC_PUT_VIDEO_HEADER<br/>取 VPS/SPS/PPS 并缓存"]
    F --> G["VPU_EncAllocateFrameBuffer<br/>注册 3 个源帧缓冲"]
    G --> H["每帧:分拷 Y/UV → flush 缓存<br/>→ StartOneFrame → 等中断 → 取结果"]

逐步说明:

  1. vdi_init(0)VPU_InitWithBitcode()(固件读成 Uint16 数组,sizeInWord = 字节数/2;两者都是进程级幂等单例);
  2. VPU_EncOpen(STD_HEVC)ringBufferEnable=0(逐帧 buffer-flush);
  3. ENC_SET_SLICE_INFO(多 slice,每片 128 CTB)+ SET_SEC_AXI(全关);
  4. VPU_EncGetInitialInfo(带最多 10 次、间隔 20ms 的重试);
  5. recon 帧缓冲:COMPRESSED_FRAME_MAP 布局 + 一次性连续大块分配fbSize × 数量),再逐帧切地址,VPU_EncRegisterFrameBuffer 注册;
  6. ENC_PUT_VIDEO_HEADER 取 VPS/SPS/PPS 并缓存,供新客户端接入时前插;
  7. VPU_EncAllocateFrameBuffer(FB_TYPE_PPU, updateFbInfo=TRUE) 注册 3 个源帧缓冲——之后 bufCb/bufCr 被 VPU 回填为真实地址;
  8. 每帧:按回填偏移分拷 Y/UV → vdi_flush_ddrVPU_EncStartOneFrameVPU_WaitInterrupt 等完成中断VPU_EncGetOutputInfo

关键参数(全部实测验证):

参数说明
bitRate目标 bps(如 6000000)单位是 bps,上限 700Mbps;配 rcEnable=1(CBR)
initialDelay500合法范围 10..3000
vbvBufferSize同 bitRate单位 bits
gopPresetIdxPRESET_IDX_IPPPP纯 P 帧,低延迟
decodingRefreshType2(IDR)随机接入干净
gopParam.tidPeriod060校验要求
minQp/maxQp/maxDeltaQp8 / 51 / 10必须显式赋值(I/P 帧的 min/maxQp 同样设置)
initialRcQp63自动
useRecommendEncParam1推荐档,稳定
frameRateInfofps低 16 位分子、高 16 位分母-1

运行期技巧:

  • 强制 IDRforcePicTypeEnable=1 + forcePicType=3(IDR);新 WS 客户端接入时置标志,下一帧即 IDR,接入 ≤1 帧可起播;
  • IDR 判定:扫输出码流的 AnnexB start code,看 NAL 类型 19/20;
  • IDR 规范化:固件把 IDR 标为 IDR_W_RADL(19),广播前把 AU 内全部 VCL NAL 改写为 IDR_N_LP(20)(多 slice 下每个都要改),硬解器规范检查才能通过;
  • 码率热调整ENC_SET_PARA_CHANGE + ENC_RC_TARGET_RATE_CHANGE,网页滑块实时生效;
  • 诊断寄存器:失败时读 0x110(ret_success)/0x114(fail_reason)/0x70(busy);
  • 自愈:连续 3 帧失败销毁重建编码器;连续 10 次 open 失败退出进程,由 systemd 先 rmmod venc && modprobe venc 并清理 /dev/shm/vencmutex 再拉起。

第五步:WebSocket 广播

WS 服务(tungstenite 同步版,端口 8081)按 URL 路径分三种模式,二进制消息首字节为类型:

路径模式消息格式前端解码
/annexb0x01|参数集0x02|flags|ts_µs(8B LE)|[IDR前插参数集]AnnexB AUWebCodecs hev1.*
/avccavcc0x05|flags|ts_µs|长前缀 NAL AUWebCodecs hvc1.*
/msefmp40x04|init(ftyp+moov),随后 0x03|flags|moof+mdatMSE hvc1

核心设计:编码线程经 broadcast()try_send 投递到每客户端容量 2 的 sync_channel——队列满就丢这一帧(队列里已有更新帧),断开才剔除,编码线程永不阻塞。 每客户端独立收发线程,客户端数量预期个位数:

pub fn broadcast(&self, f: FrameMsg) {
    let f = Arc::new(f);
    let mut clients = self.clients.lock().unwrap();
    clients.retain(|_, tx| match tx.try_send(f.clone()) {
        Ok(()) | Err(TrySendError::Full(_)) => true,  // 满:丢帧保实时
        Err(TrySendError::Disconnected(_)) => false,  // 断开:剔除
    });
}

第六步:浏览器播放

前端按能力三级降级(pickMode()):WebCodecs hev1(AnnexB 直喂,最优)→ WebCodecs hvc1(长前缀)→ MSE hvc1(fMP4,兜底)。探测的 codec 串与码流 SPS 实际参数匹配(wave5 输出 Main@L150、constraint=0):

for(const c of ['hev1.1.6.L150.90','hev1.1.6.L120.90','hev1.1.6.L93.90',
                'hev1.1.6.L150.B0','hev1.1.6.L120.B0']){
  const s = await VideoDecoder.isConfigSupported({codec:c, optimizeForLatency:true});
  if(s.supported) return {mode:'annexb', codec:c};
}

上面 hev1.1.6.L150.90 这串的含义:hev1 表示 AnnexB 打包的 HEVC,1.6 是 Main profile 及兼容位,L150 是 level_idc 150(Level 5.0,覆盖 1080p30)。

降级决策一目了然:

flowchart TD
    P["页面加载,pickMode() 探测"] --> Q1{"WebCodecs<br/>支持 hev1.*?"}
    Q1 -->|是| M1["annexb 模式:AnnexB 直喂(最优)"]
    Q1 -->|否| Q2{"WebCodecs<br/>支持 hvc1.*?"}
    Q2 -->|是| M2["avcc 模式:长前缀 NAL"]
    Q2 -->|否| Q3{"MSE 支持 hvc1?"}
    Q3 -->|是| M3["fmp4 模式:手工 fMP4 封装(兜底)"]
    Q3 -->|否| M4["提示安装 HEVC 视频扩展,5 秒自动重试"]

WebCodecs 路径:optimizeForLatency:truehardwareAcceleration:'prefer-hardware';解码队列积压(decodeQueueSize > 3)时丢 delta 帧追实时,IDR 必喂;解码出错即重连(服务端对新连接强制 IDR,快速恢复)。

MSE 路径的设计要点:

  • fMP4 封装(约 390 行纯字节拼接,无依赖):init segment = ftyp + moov(含由 VPS/SPS/PPS 构建的 hvcC),媒体段 = moof(mfhd + traf[tfhd 0x020038 + tfdt v1 + trun 0x305])+ mdat;样本内 NAL 用 4 字节长前缀(AVCC 风格);时间基 90000;hvcC 的 array 类型字节直接写 NAL 类型值(32/33/34)。
  • 5 帧攒一段(约 167ms),段首帧必为 IDR,接入后先丢非 IDR 帧直到首个 IDR。
  • 时间戳用真实帧间隔:tfdt 记段首真实时间,trun 逐帧 duration 由相邻帧时间差换算,最小钳为 1。
  • ms.duration = 3600(大而有限的值,按 VOD 式缓冲 2-4 秒平滑播放)。
  • 主动裁剪:缓冲总长超 8s 删最旧段,删除边界距播放点 ≥1.5s。
  • 自愈:currentTime 约 3 秒不前进且缓冲堆积超 10s → 重建会话。
  • 三种模式都不可用时提示安装「HEVC 视频扩展」,5 秒自动重试。

画面方向(镜像/翻转/旋转)用纯 CSS transform 实现;拍照把当前画面按相同变换画到离屏 canvas,toBlob 导出 JPEG 下载。

工程技巧集锦

  1. 采集线程只做 DQBUF→拷贝→QBUF,绝不被编码阻塞;帧率瓶颈一眼可见(/status 里采集 FPS 与编码 FPS 分开显示)。
  2. 软件白平衡融合进 UV 拷贝:ISP AWB 不可用时,每 30 帧统计 UV 均值自动收敛到 128,偏移在 C 侧拷帧时顺手施加(零额外遍历);|偏移|<4 走纯 memcpy 分支。
  3. 摄像头关闭即释放编码器,VPU 内存/固件让出;重开时百毫秒级重建,并排空帧池避免播陈旧帧。
  4. 启动先健康检查再决定是否复位pgrep -x stf_isp_ctrl + video1 试采一帧(1.5s poll),健康则完全不碰管线。
  5. systemd 三件套camera-pipeline-setup.service(oneshot 恢复管线)→ stf-isp-ctrl.servicecamera-web.service(ExecStartPre 先 sleep 5 再跑 venc 复位脚本,Restart=always)。
  6. 交叉编译:WSL2 里 cargo build --release --target riscv64gc-unknown-linux-gnu(glibc 动态链接,板载 Debian glibc 2.36),C 库由 cc crate 编译、链 vendored libsfenc.so,部署一个 scp + systemd restart 脚本搞定。

小结与系列链接

最终成果:一块 1.5GHz 四核 RISC-V 开发板,以极低的 CPU 占用把 1080p 摄像头画面硬编成 HEVC,经 WebSocket 推给浏览器,三种解码路径自动降级,任何能播 HEVC 的浏览器打开即看。

每个环节背后的排错过程(media 链路为什么开机全断、硬编为什么曾被误判不可用、MSE 黑块与绿带怎么破案)记录在配套的五篇调试手记里:

  1. 采集管线与传感器救援
  2. HEVC 硬编翻案记:WAVE420L 从「固件不匹配」到 21fps
  3. Web 串流架构与手工封装 fMP4:Chrome 的坑集锦
  4. 右下角黑块悬案:split_annexb 吃掉的 3 个字节
  5. 底部绿带两轮修复:1088 与 1080 的 8 行战争