这是一篇「照着做就能通」的完整实现指南:在昉·星光 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/PPS | HEVC 的三级参数集,解码器初始化的必需信息 |
| NAL | HEVC 码流的基本单元(网络抽象层单元),可理解为「一个包」 |
| AnnexB | 码流打包格式:每个 NAL 以起始码 00 00 01 分隔 |
| WebCodecs | 浏览器提供的底层硬件编解码 API |
| MSE | Media 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):
- 设格式:对 sensor 子设备调
VIDIOC_SUBDEV_S_FMT,设 1920×1080SRGGB10_1X10(mbus code0x300f)。手写这个 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探针核对; - 开时钟:写 sysfs
/sys/devices/platform/soc/12060000.i2c/i2c-6/6-0010/power/control为on,使能 MCLK; - 等 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],
}
采集流程:
open("/dev/video1")→S_FMT请求 1920×1080 NV12——高度必须 1080,不要为对齐请求 1088(1088 会让管线协商几何与 sensor 实际输出错位,2026-08-25 实测直接整幅花屏)→G_FMT回读确认 1920×1080(sizeimage=3110400);REQBUFS申请 4 个缓冲 → 逐个QUERYBUF+mmap+QBUF(QUERYBUF 返回的 offset/length 是建 DMA 描述符的依据,不可跳过);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 → 等中断 → 取结果"]
逐步说明:
vdi_init(0)→VPU_InitWithBitcode()(固件读成 Uint16 数组,sizeInWord = 字节数/2;两者都是进程级幂等单例);VPU_EncOpen(STD_HEVC),ringBufferEnable=0(逐帧 buffer-flush);ENC_SET_SLICE_INFO(多 slice,每片 128 CTB)+SET_SEC_AXI(全关);VPU_EncGetInitialInfo(带最多 10 次、间隔 20ms 的重试);- recon 帧缓冲:
COMPRESSED_FRAME_MAP布局 + 一次性连续大块分配(fbSize × 数量),再逐帧切地址,VPU_EncRegisterFrameBuffer注册; ENC_PUT_VIDEO_HEADER取 VPS/SPS/PPS 并缓存,供新客户端接入时前插;VPU_EncAllocateFrameBuffer(FB_TYPE_PPU, updateFbInfo=TRUE)注册 3 个源帧缓冲——之后bufCb/bufCr被 VPU 回填为真实地址;- 每帧:按回填偏移分拷 Y/UV →
vdi_flush_ddr→VPU_EncStartOneFrame→VPU_WaitInterrupt等完成中断 →VPU_EncGetOutputInfo。
关键参数(全部实测验证):
| 参数 | 值 | 说明 |
|---|---|---|
bitRate | 目标 bps(如 6000000) | 单位是 bps,上限 700Mbps;配 rcEnable=1(CBR) |
initialDelay | 500 | 合法范围 10..3000 |
vbvBufferSize | 同 bitRate | 单位 bits |
gopPresetIdx | PRESET_IDX_IPPPP | 纯 P 帧,低延迟 |
decodingRefreshType | 2(IDR) | 随机接入干净 |
gopParam.tidPeriod0 | 60 | 校验要求 |
minQp/maxQp/maxDeltaQp | 8 / 51 / 10 | 必须显式赋值(I/P 帧的 min/maxQp 同样设置) |
initialRcQp | 63 | 自动 |
useRecommendEncParam | 1 | 推荐档,稳定 |
frameRateInfo | fps | 低 16 位分子、高 16 位分母-1 |
运行期技巧:
- 强制 IDR:
forcePicTypeEnable=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 路径分三种模式,二进制消息首字节为类型:
| 路径 | 模式 | 消息格式 | 前端解码 |
|---|---|---|---|
/ | annexb | 0x01|参数集、0x02|flags|ts_µs(8B LE)|[IDR前插参数集]AnnexB AU | WebCodecs hev1.* |
/avcc | avcc | 0x05|flags|ts_µs|长前缀 NAL AU | WebCodecs hvc1.* |
/mse | fmp4 | 0x04|init(ftyp+moov),随后 0x03|flags|moof+mdat | MSE 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:true、hardwareAcceleration:'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 下载。
工程技巧集锦
- 采集线程只做 DQBUF→拷贝→QBUF,绝不被编码阻塞;帧率瓶颈一眼可见(
/status里采集 FPS 与编码 FPS 分开显示)。 - 软件白平衡融合进 UV 拷贝:ISP AWB 不可用时,每 30 帧统计 UV 均值自动收敛到 128,偏移在 C 侧拷帧时顺手施加(零额外遍历);|偏移|<4 走纯 memcpy 分支。
- 摄像头关闭即释放编码器,VPU 内存/固件让出;重开时百毫秒级重建,并排空帧池避免播陈旧帧。
- 启动先健康检查再决定是否复位:
pgrep -x stf_isp_ctrl+ video1 试采一帧(1.5s poll),健康则完全不碰管线。 - systemd 三件套:
camera-pipeline-setup.service(oneshot 恢复管线)→stf-isp-ctrl.service→camera-web.service(ExecStartPre 先 sleep 5 再跑 venc 复位脚本,Restart=always)。 - 交叉编译:WSL2 里
cargo build --release --target riscv64gc-unknown-linux-gnu(glibc 动态链接,板载 Debian glibc 2.36),C 库由 cc crate 编译、链 vendoredlibsfenc.so,部署一个 scp + systemd restart 脚本搞定。
小结与系列链接
最终成果:一块 1.5GHz 四核 RISC-V 开发板,以极低的 CPU 占用把 1080p 摄像头画面硬编成 HEVC,经 WebSocket 推给浏览器,三种解码路径自动降级,任何能播 HEVC 的浏览器打开即看。
每个环节背后的排错过程(media 链路为什么开机全断、硬编为什么曾被误判不可用、MSE 黑块与绿带怎么破案)记录在配套的五篇调试手记里: