这个项目的目标,是在昉·星光 2(VisionFive 2)这块 RISC-V 开发板上,把树莓派 Camera v2(imx219)看到的画面通过网页实时展示:V4L2 采集 → HEVC 硬件编码 → WebSocket 推送 → 浏览器 WebCodecs/MSE 解码上屏。听起来每一步都有现成轮子,实际上每一步都有坑在等。采集层的代码最终是手写裸 libc ioctl 实现的——不是不想用库,是用了就出事,本篇会讲清楚。
本系列共五篇:
- 采集管线与传感器救援(本篇)
- HEVC 硬编翻案记:WAVE420L 从「固件不匹配」到 21fps
- Web 串流架构与手工封装 fMP4:Chrome 的坑集锦
- 右下角黑块悬案:split_annexb 吃掉的 3 个字节
- 底部绿带两轮修复:1088 与 1080 的 8 行战争
如果只想看「正确的做法是什么」而不想听排错故事,可以先读配套的《VisionFive 2 摄像头网页串流完整实现》。
认识这套硬件
| 项目 | 详情 |
|---|---|
| 开发板 | StarFive VisionFive 2(昉·星光 2),JH7110 SoC |
| CPU | 4×SiFive U74-MC RISC-V @1.5GHz(无 RVV 向量扩展) |
| 摄像头 | imx219(树莓派 Camera v2),CSI 接口 |
| 系统 | Debian riscv64,内核 6.12.5-starfive |
| VPU | Chips&Media WAVE420L,只支持 HEVC 编码(官方规格「H265 编码 1080p@30fps」) |
| ISP | 依赖用户态守护进程 stf_isp_ctrl,没它相机出不了帧 |
采集链路用内核 media controller 框架的拓扑来描述(media graph,可用 media-ctl -p -d /dev/media0 查看):
flowchart LR
S["imx219<br/>(sensor)"] --> PHY["csiphy0"] --> CSI["csi0"] --> ISP["isp0"] --> VIN["vin0_isp0"] --> V1["/dev/video1<br/>NV12 主输出 ✅"]
S -.->|"经 vin0_wr 的 Bayer 直通(不可用)"| V0["/dev/video0 ❌"]
VIN -.->|"辅助节点(不出帧)"| V2["/dev/video2、video3 ❌"]
先记住两个结论,能省一下午:
/dev/video0是原始 Bayer 直通节点(SRGGB10),不可用;/dev/video2/video3在标准管线下不出帧(dmesg 报Can't find sensor)。真正可用的是 ISP 主输出节点/dev/video1,输出 NV12(YUV420)。sensor 有效输出固定 1920×1080,协商时就该请求 1080——请求 1088 驱动也会答应并多给 8 行无数据填充,那 8 行后来引发了两轮绿带加一次整幅花屏(第五篇)。- 内核 jh7110-vin 驱动自己搞不定 ISP,必须靠 StarFive 的用户态进程
stf_isp_ctrl把 ISP 配置好,之后 video1 才能正常协商格式、出帧。
第一冤案:「摄像头硬件损坏」
项目中期某次重启后,video1 彻底不出帧:S_FMT 返回 ENOTTY(Inappropriate ioctl),内核日志 [st_video] error: Can't find sensor,强行 STREAMON 则报 EPIPE(Broken pipe)。当时的直觉是「完了,CSI 排线/摄像头烧了」——其实不是。
真因写在后来开机自启脚本 camera-pipeline-setup.sh 的头注释里:这块板子每次开机后,VIN media graph 里所有可配置链路都是 DISABLED 状态,sensor pad 格式停留在驱动默认值 3280×2464。链路没接通、格式又不匹配,于是:
stfcamss_find_sensor()找不到 sensor → video 节点S_FMT直接 ENOTTY;- 链路缺失时 STREAMON 能启动但永远等不到数据;格式不匹配时则
link_validate失败报 EPIPE。
修复就是两条 media-ctl 命令使能链路,再把整条路径的 pad 格式统一刷成 1920×1080:
MD=/dev/media0
# 使能两条可配置链路(其余链路由驱动内建使能)
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:用实体名语法,免疫 /dev/v4l-subdevN 编号变化
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]'
两个细节:必须用实体名('stf_csi0')而不是 v4l-subdevN 编号,后者重启后会变;格式必须写 1080 而不是 1088——imx219 驱动会把 1088 clamp 成 1080,全链路只有 1080 是唯一一致的选择(后来我们发现连 video1 节点的 S_FMT 也必须请求 1080,那是第五篇补记的故事)。
为什么手写 ioctl:两个库都靠不住
采集层是 Rust 写的。常规选择有两个,都被否掉了:
- v4l crate:构建期要 libclang 做 bindgen,RISC-V 上缺包,直接出局。
- libv4l2:一开始以为 StarFive 的 ISP 需要它的插件才能协商格式,结果适得其反——libv4l2 的 ISP 插件会在 TRY_FMT/S_FMT 协商时进入格式转换模式、回写垃圾格式:实测拿到
1920x1920 stride=1 sizeimage=1920,同时打印libv4l2: error set_fmt gave us a different result than try_fmt!。而v4l2-ctl走裸 ioctl 能正常抓 1920×1080 NV12,证明内核路径本身没问题,是插件在捣乱。
结论:绝不用 libv4l2,直接 open/ioctl/mmap 裸调,与 v4l2-ctl 走同一套内核路径。(顺带一提,stf_isp_ctrl 进程自己链了 libv4l2,它启动时打印的 libv4l2 警告与你的程序无关,可以无视。)
结构体布局是血泪换来的
手写 ioctl 意味着要手写内核 ABI 结构体。这一步错了不是报错那么简单,而是拿到「看起来能用、实际全错」的数据。
坑一:v4l2_format 的 4 字节填充。 内核真实布局经 C 探针实测:共 208 字节,type 在 offset 0,之后有 4 字节填充(联合体里有 8 字节对齐的字段,把联合体整体顶到 offset 8),联合体 fmt 共 200 字节。如果把 pix 直接贴在 type 后面(offset 4),内核读写字段整体错位 4 字节,S_FMT 协商出来的就是前面那组垃圾格式。Rust 侧正确的写法:
#[repr(C)]
struct V4l2Format {
type_: u32,
_pad: u32, // 4 字节填充,把联合体对齐到 offset 8
pix: V4l2PixFormat,
_rest: [u8; 200 - 48], // 联合体剩余:200 - pix(48) = 152
}
坑二:v4l2_buffer 必须恰好 88 字节(64 位 Linux,逻辑 84 字节 + 尾部 4 字节填充)。尺寸不对,DQBUF/QBUF 全部 ENOTTY。我们在结构体末尾显式补了 _pad_tail: u32,不依赖编译器的隐式尾部填充。
坑三:ioctl 方向常量。 内核里 VIDIOC_G_FMT 和 VIDIOC_TRY_FMT 是同一个 _IOWR('V',4,...),请求号 0xC0D05604——必须用 _IOC_RW(读+写)。写成 _IOC_READ 会得到 0x800D5604,同样 ENOTTY。「读格式」直觉上是只读操作,但内核就是按读写定义的,照抄头文件宏生成逻辑才保险。
坑四:libc 差异。 glibc 的 ioctl request 参数是 c_ulong,musl 是 i32,Rust FFI 要按 target_env 分派,否则编译不过或行为不符:
#[cfg(target_env = "musl")]
{ libc::ioctl(fd, request as i32, arg as *mut T as *mut c_void) }
#[cfg(not(target_env = "musl"))]
{ libc::ioctl(fd, request, arg as *mut T as *mut c_void) }
坑五(2026-08-25 补):ioctl 编号里嵌着结构体大小,算错尺寸就是 ENOTTY。 给 sensor 子设备设格式用的 VIDIOC_SUBDEV_S_FMT,其编号按 _IOWR('V', 5, sizeof(struct v4l2_subdev_format)) 生成。v4l2_subdev_format 在这块板子上是 88 字节(which@0、pad@4、v4l2_mbus_framefmt format@8 共 48 字节、reserved[8]),正确编号 0xC0585605。我们曾一度把 v4l2_mbus_framefmt 按 64 字节算,得出 104 字节的结构体和编号 0xC0685605——编号对不上,内核直接返回 ENOTTY(Inappropriate ioctl for device),请求根本没到达驱动。这个错误还次生了一场误诊:中间某版把 format 写到错误的偏移上,读回一组「5×1080、colorspace=0xb」的垃圾,被我们误当成「厂商内核 UAPI 与主线不同」的证据。最可靠的验证方法是别猜布局,直接在板子上编译一个 sizeof 探针:
#include <stdio.h>
#include <linux/v4l2-subdev.h>
int main(void) {
printf("%zu 0x%lx\n", sizeof(struct v4l2_subdev_format),
(unsigned long)VIDIOC_SUBDEV_S_FMT); // 88 0xc0585605
}
另外这个结构体的 which 字段:TRY=0 只是试算不碰硬件,ACTIVE=1 才真正生效——和主线一致,没有「厂商互换」这回事(那也是那场误诊里的冤假错案)。
stf_isp_ctrl 的三个坑
ISP 守护进程本身也是事故高发区:
- 假启动:官方脚本
isp_ctrl_daemon.sh不带传感器参数时只打印一行 Usage 然后exit 0——退出码是 0,但守护进程根本没启动,于是内核报Can't find sensor、video1 不出帧。盯着「命令执行成功」的判断逻辑会被它骗过去。 - 必须带传感器参数:正确姿势是
stf_isp_ctrl -m imx219mipi,不带-m等于没配 sensor。 - 残留实例互锁:旧的
stf_isp_ctrl没杀干净再启动新实例会互锁挂起。我们的做法是pkill -9 -x stf_isp_ctrl之后用setsid直接启动新实例,绕开那个 daemon 脚本。
启动后还要等足 12 秒:ISP 完全就绪约需 10 秒(I2C 初始化 + 管线建立),抢跑去 S_FMT 会拿到无效尺寸。
sensor 救援:s_stream 传播断了
管线配好、守护进程跑起来,按理说该出帧了。但 2026-08-10 实测:每次重启后必现 STREAMON EPIPE。查下来是两层问题叠加。
第一层:sensor 格式没人配。 这个镜像的 stf_isp_ctrl 会静默跳过 sensor 配置,sensor 停在驱动默认的 3280×2464,和 csiphy 1920×1080 的链路不匹配 → link_validate 失败 → EPIPE。media-ctl -V 在这条链路上会 EINVAL(语法问题),只能程序里直接调 VIDIOC_SUBDEV_S_FMT 把 sensor 子设备设成 1920×1080 SRGGB10。
第二层,更深的坑:s_stream 传播是断的。 2026-08-11 实测:STREAMON 时 VIN/ISP 驱动根本不会调用 sensor 的 s_stream——读 sensor 寄存器 0x0100 恒为 0、runtime PM(运行时电源管理)恒 suspended、MCLK(sensor 的主时钟)始终不使能。也就是说内核驱动从头到尾没「叫醒」摄像头,sensor 自己什么数据都没发。
救援只能在用户态手动做完内核该做的事,固定序列如下(格式 → 时钟 → 等待 → 开流):
flowchart LR
A["① SUBDEV_S_FMT<br/>设 1920x1080 SRGGB10"] --> B["② sysfs 写 on<br/>使能 MCLK"]
B --> C["③ 等 200ms<br/>MCLK 稳定"]
C --> D["④ I2C 写 0x0100=1<br/>Stream On"]
fn ensure_sensor_ready() {
ensure_sensor_format(); // ① VIDIOC_SUBDEV_S_FMT 设 1920x1080 SRGGB10
sensor_mclk_on(); // ② sysfs 写 power/control="on",强制 PM on(使能 MCLK)
thread::sleep(Duration::from_millis(200)); // MCLK 刚使能时不稳定,立即写会失败
imx219_reg_write(0x0100, 0x01); // ③ I2C_RDWR 直写寄存器:Stream On
}
每一步都有讲究:
- ② 是写
/sys/devices/platform/soc/12060000.i2c/i2c-6/6-0010/power/control,把 runtime PM 强制 on,MCLK 才会真正输出; - ② 和 ③ 之间的 200ms 不能省,MCLK 刚使能时 sensor 内部还没稳定,立即写流寄存器会失败;
- ③ 走
I2C_RDWR(0x0707)而不是I2C_SLAVE——内核驱动绑定了从机地址,I2C_SLAVE会 EBUSY。imx219 在 i2c-6 总线、地址 0x10(VF2 固定)。还有一个反直觉点:I2C_RDWR成功时返回的是传输的消息数(1)而不是 0,判断成功要用r >= 0。
作为旁证和备份,我们还写了独立工具 tools/imx219init.c:完整复刻厂商内核驱动 imx219_start_streaming() 的 1080p30 初始化序列(PLL 分频 57/114 → 456Mbps/lane、crop 窗口 (688,700,1920×1080)、2 lanes、RAW10),带逐寄存器回读校验。寄存器表不是手抄的——是用 tools/extract_imx219_table.py 按「已验证寄存器片段」从厂商内核 Image 二进制里逆向搜出来的。
另外这个镜像的 3A 也不驱动曝光(AE 值恒不动,画面偏暗),启动时要手动把 sensor 子设备的曝光控件 0x00980911 写到最大值 3522 提亮。
教训:不要轻易重启 ISP
管线能跑之后还有最后一道坎。某次排查时我们发现:程序每次启动都无条件复位 media 链路 + 重启 ISP 守护,反而会打断 sensor 的 I2C 初始化,导致内核报 Can't find sensor、video1 永久不出帧(2026-08-10 实测),只能做完整 media 复位才能恢复。
所以现在的启动逻辑是先健康检查,健康就完全不碰:
pgrep -x stf_isp_ctrl看守护进程活着没;- 在 video1 上试采一帧:G_FMT 校验
sizeimage >= 1920×1080×1.5,然后 REQBUFS → QUERYBUF → mmap → QBUF → STREAMON →poll1.5 秒等 POLLIN,最后 STREAMOFF 收尾; - 两步都过就跳过全部复位流程;任何一步不过才走
media-ctl -r→ 重建管线 → 重启守护 → 等 12 秒的完整流程。
这里还埋着一个小坑:QBUF 前必须先 QUERYBUF + mmap——VIN 驱动靠 QUERYBUF 返回的 offset/length 建 DMA 描述符,跳过这步直接 QBUF,poll 恒超时、一帧都没有。
小结与预告
到这里,采集链路终于稳了:开机脚本恢复 media 管线,程序启动先健康检查再决定是否复位,sensor 由用户态手动配格式、开时钟、写 Stream On,/dev/video1 开始稳定吐帧。但这里还埋着一颗当时没意识到的雷:采集代码向 video1 请求的高度是 1088 而不是 1080(为了和编码器的 CTB 对齐图省事),驱动照单回 1920×1088、多给 8 行填充。这个「多要的 8 行」先在第五篇炸成两轮底部绿带,又在 2026-08-25 换摄像头模组后炸成整幅花屏——最终的正确做法就是向驱动永远请求 1080,对齐补齐只在送编码器前做。
帧拿到了,下一步是编码。这块板子挂着一颗 WAVE420L 硬编 VPU,但我们一度在 README 里白纸黑字写下「硬编不可用」。下一篇《HEVC 硬编翻案记》讲这个系列最大的反转:所谓固件与驱动不匹配,其实是我们自己 API 调用顺序错了。