多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MIPI RAW10打包与解包:从V4L2格式到内存布局的完整解析

MIPI RAW10打包与解包:从V4L2格式到内存布局的完整解析 有个同事调了一个下午的摄像头驱动传感器明明输出 RAW10采集到的 buffer 里也确实是有效数据但交给后处理脚本一解析画面老是呈一条条对角斜纹。折腾到最后才发现问题根源是他的脚本里用了不带 P 的V4L2_PIX_FMT_SBGGR10去解析而驱动实际上报的是带 P 的V4L2_PIX_FMT_SBGGR10P。这两个名字只差一个字母内存布局却差了整整一代。MIPI raw 和 unpacked raw 的存储差异就是这类问题的核心。MIPI CSI-2 接口为了省引脚、降功耗在传输线上基本都用打包packed方式传输高位深 raw 数据但数据到达 SoC 之后硬件模块出于处理效率考虑往往又会在写入内存前把它解包unpacked成更宽松的像素排布。于是同一份 raw 数据在传输线和 DDR 里完全是两套模样。这篇文章就围绕这两套存储方式展开先说概念和原理再给可复现的打包/解包代码然后讲 stride、对齐、字节序这些内存布局要点最后结合 V4L2 配置和实际调试踩过的坑把存储格式的选择逻辑讲清楚。适合画质调试、ISP 驱动、嵌入式视觉开发以及自己写 raw 解析工具的读者。1. 为什么同样一份 raw 数据内存里能有两副面孔1.1 从 sensor 的 ADC 位数说起图像传感器上每一个像素点本质是一个光电二极管加上一个模数转换器ADC。ADC 的输出位数决定了这个像素的原始亮度精度常见的有 8bit、10bit、12bit、14bit甚至 16bit。这里先明确一个容易混淆的点协议里说的 RAW10 是每个像素有效数据 10bit并不等于内存里每个像素就占 10bit 空间。内存是按字节寻址的10bit 没法直接对齐到某个字节边界所以必须决定怎么把这 10bit 放进一个或两个字节里。这个怎么放就是协议格式和内存格式的差别来源。8bit 的 raw 最省事一个像素正好一个字节传输和存储完全一致。但一旦进入 RAW10、RAW12 这类非整字节位深就必须在节省空间和便于访问之间做取舍。MIPI 的打包格式偏向省空间unpacked 格式偏向便于访问两边自然就出现了分歧。1.2 MIPI 线上为什么要打包MIPI CSI-2 是串行接口用差分信号对传输数据数据通道数量有限带宽是宝贵的。如果 RAW10 的每个像素在链路上都占 16bit两个字节那 10bit 的有效信息里就有 6bit 是纯浪费链路带宽利用率只有 62.5%。所以 MIPI 协议定了打包规则把多个像素的有效位拼接起来以接近无损的密度在链路上传输。RAW10 是把 4 个像素的 40bit 数据装进 5 个字节RAW12 是把 2 个像素的 24bit 数据装进 3 个字节RAW14 则是 4 个像素 56bit 装 7 个字节。这样链路上几乎没有浪费带宽利用率能超过 97%。这就像搬家装箱散装的小物件直接搬会浪费车厢空间先把它们整齐码进统一尺寸的纸箱运输效率才高。MIPI 线上的 raw 数据就是码好箱的状态内存里 unpacked 的数据则是拆箱散装的状态。1.3 带 P 不带 PV4L2 里两个名字相差一个字母Linux V4L2 子系统把这两种状态都用格式标识符固定下来了命名规则很直接带P后缀的是 packed不带的是 unpacked。同样一组 Bayer 排布RAW10 在 V4L2 里就有两组格式格式名Packed每个像素的存储空间典型场景V4L2_PIX_FMT_SBGGR10否unpacked2 字节16bit 容器ISP 输入、算法直接访问V4L2_PIX_FMT_SBGGR10P是packed1.25 字节4 像素占 5 字节MIPI 传输、raw dump、省存储V4L2_PIX_FMT_SBGGR12否unpacked2 字节16bit 容器算法处理V4L2_PIX_FMT_SBGGR12P是packed1.5 字节2 像素占 3 字节传输、省存储注意 Bayer 排列RGGB、BGGR、GRBG、GBRG也会改变四字符码但存储逻辑完全一样。很多从入门到放弃的 raw 调试不是死在 Bayer 排列上而是死在带不带 P 上——带 P 的数据按不带 P 的宽度去读像素全部错位画面自然花掉。2. 从协议字节流到 DDRRAW10/RAW12 打包与解包细节2.1 RAW10 的5 字节装 4 个像素RAW10 打包的基本单位是 4 个像素、5 个字节。假设一行像素按从左到右的顺序把每 4 个像素记为 a、b、c、d每个像素 10bit打包后的 5 个字节记为 b0 到 b4规则如下b0 存 a 的高 8 位a[9:2]b1 存 b 的高 8 位b[9:2]b2 存 c 的高 8 位c[9:2]b3 存 d 的高 8 位d[9:2]b4 是低位收容站按从低到高的顺序依次塞入 a、b、c、d 各自的低 2 位写成位表达式就是b0 a[9:2] b1 b[9:2] b2 c[9:2] b3 d[9:2] b4 (d[1:0] 6) | (c[1:0] 4) | (b[1:0] 2) | a[1:0]这个布局非常紧凑。整行数据的字节数等于width * 10 / 8前提是行像素数能被 4 整除。实际 sensor 的时序参数通常会保证这一点但做裁剪crop或者 binning 之后就得重新核对。2.2 可以抄的 pack/unpack 参考代码调试 raw 时手里有份能直接编译运行的打包/解包函数能省很多事。下面这段按上面公式实现输入 5 字节、输出 4 个 16bit 像素像素值放在低 10 位#include stdint.h // 将 5 个打包字节解开为 4 个 16bit 像素低 10 位有效 static void unpack_raw10_5b4p(const uint8_t b[5], uint16_t px[4]) { px[0] ((uint16_t)b[0] 2) | ((b[4] 0) 0x03u); px[1] ((uint16_t)b[1] 2) | ((b[4] 2) 0x03u); px[2] ((uint16_t)b[2] 2) | ((b[4] 4) 0x03u); px[3] ((uint16_t)b[3] 2) | ((b[4] 6) 0x03u); } // 反向将 4 个 16bit 像素打包为 5 个字节 static void pack_raw10_4p5b(const uint16_t px[4], uint8_t b[5]) { b[0] (uint8_t)(px[0] 2); b[1] (uint8_t)(px[1] 2); b[2] (uint8_t)(px[2] 2); b[3] (uint8_t)(px[3] 2); b[4] (uint8_t)(((px[3] 0x03u) 6) | ((px[2] 0x03u) 4) | ((px[1] 0x03u) 2) | ((px[0] 0x03u) 0)); }拿到一整帧 packed 数据时按每行width / 4组去循环调用unpack_raw10_5b4p即可。想转成 8bit 预览图直接把px[] 2丢掉低 2 位就能得到可显示的灰度值。解码时我习惯先打印前 20 个字节的十六进制再对照公式手算一遍前 4 个像素。这个动作虽然原始但能立刻确认字节序和位对齐是否符合预期。2.3 RAW12 的 3 字节装 2 个像素RAW12 的打包逻辑和 RAW10 思路一样只不过打包单位变成 2 个像素、3 个字节。同样记两个像素为 a、bb0 a[11:4] b1 (a[3:0] 4) | b[11:8] b2 b[7:0]解包公式a (b0 4) | (b1 4) b ((b1 0x0F) 8) | b2RAW14 又回到 4 像素 7 字节的打包尺度低位收集逻辑与 RAW10 类似只是把低 2 位扩展成了低 4 位。掌握了 RAW10 的位拼接思想RAW12/RAW14 都是同一套思路换参数不需要死记。2.4 为什么不建议在 CPU 里逐帧软解包理论上可以在 CPU 里用上面的代码逐字节解包但实测下来这属于能跑、但别用于生产的方案。解包本来就是位运算密集操作每个像素要拆字节、移位、拼位还有循环和函数调用开销。跑 1080p RAW10一帧约 207 万个像素纯 CPU 解包一帧通常需要几十毫秒30fps 实时流水线根本扛不住。现代 SoC 的 MIPI RX 模块一般自带解包逻辑DMA 从 MIPI 拿到打包字节流后在写入 DDR 之前做一次展开输出 unpacked 数据。有的 ISP 前端则直接把打包流吃进去在内部完成解包根本不会以 unpacked 形式落到 DDR。所以要搞清楚你的系统里谁负责解包这决定了用户态看到的最终内存布局。3. 落到内存后的三件大事stride、对齐和字节序3.1 bytesperline 错一个字节整幅图就斜掉内存里一帧图像不是简单地把所有像素排成一条直线。每一行数据后面通常会有一段看不见的填充区让每行起始地址对齐到某个边界。一行数据从起始地址到下一行起始地址的字节数叫 stride也叫 bytesperline。V4L2 里struct v4l2_pix_format的bytesperline字段就是它。很多人在用户态只设了 width/height/pixelformat然后自己按width 像素数乘每像素字节数去算每行大小完全忽略了驱动回填的 bytesperline这是 raw 解析错位的最大来源。举个例子1920 宽的 RAW10 packed 数据理论每行1920 * 10 / 8 2400字节。如果驱动要求 stride 对齐到 256 字节2400 对齐后变成 2560。这时你按 2400 去逐行解析从第二行开始就会偏 160 字节越往后偏移越大最后呈现的效果就是整幅画面像被人水平推了一把形成阶梯状斜纹。3.2 DMA 突发长度与 64 字节 cache lineDMA 搬运和 CPU 读写在内存布局上有个共同的物理约束一次突发传输和一个 cache line 的大小。多数 ARM 平台的 cache line 是 64 字节DMA controller 的 burst 长度常见为 8、16、32 个 32bit 字也就是 32、64、128 字节。如果每行起始地址不落在 64 字节边界上CPU 读取一行数据时就会反复跨越 cache line一次读一行可能要触发两倍数量的 cache miss性能肉眼可见地变差。所以驱动在计算 stride 时通常会做一次向上取整对齐new_stride (old_stride alignment - 1) ~(alignment - 1)我在实际项目中见过三种典型对齐要求比较宽松的平台只要求 4 字节对齐主流移动平台要求 64 字节对齐少数高带宽平台会要求 128 字节甚至更多。你用 4 字节对齐的需求去读一个 64 字节对齐平台 dump 出来的 raw解析结果必然错位。最稳妥的做法是永远用bytesperline字段的实际值而不是自己猜。3.3 小端平台上的位序与字节序内存布局里还有一个容易翻车的是字节序。绝大多数 ARM 平台运行在 little-endian 模式一个 16bit 像素值0x028A十进制 650在内存里是8A 02高字节在后面。如果解包脚本把每两个字节按大端方式拼回去得到的就是0x8A02数值完全不对。这类问题不像 stride 错位那样产生斜纹而是表现为画面整体发花、灰度完全不符合曝光逻辑。另外还要小心 unaligned 的位域结构体。拿 C 语言结构体去映射 raw 字节流时不同编译器对位域的内存分配方向有差异尤其是在处理uint16_t和uint8_t混用的场景。我的建议很简单别用位域结构体直接映射 raw buffer老老实实用移位和掩码。虽然代码啰嗦一点但结果在所有编译器下都一致。4. V4L2 与驱动配置带 P 不带 P 的真实代码差别4.1 用户态 S_FMT 该怎么选在 Linux 用户态设置摄像头采集格式时一个常见错误是上来就写死V4L2_PIX_FMT_SBGGR10然后发现采集出来的帧大小和自己算的不一样。正确的流程是先用VIDIOC_ENUM_FMT枚举驱动支持的格式再明确区分带 P 和不带 P。struct v4l2_format fmt {0}; fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width 1920; fmt.fmt.pix.height 1080; // 关键这里的 pixelformat 决定内存布局 // V4L2_PIX_FMT_SBGGR10P - packed每帧约 width*height*10/8 // V4L2_PIX_FMT_SBGGR10 - unpacked每帧约 width*height*2 fmt.fmt.pix.pixelformat V4L2_PIX_FMT_SBGGR10P; ret ioctl(fd, VIDIOC_S_FMT, fmt); if (ret 0) { perror(VIDIOC_S_FMT); return -1; } // 以驱动回填值为准 unsigned int stride fmt.fmt.pix.bytesperline; unsigned int frame_size fmt.fmt.pix.sizeimage;VIDIOC_S_FMT执行成功后驱动可能根据自己的约束修改了 width、height、bytesperline 和 sizeimage。所以必须在 ioctl 返回之后再读取这些字段而不是沿用自己设置的旧值。如果驱动不支持你选的带 P 格式S_FMT 会返回错误这时再退回去枚举一遍可用格式。4.2 驱动回填的 bytesperline 与 sizeimage从驱动角度bytesperline的计算逻辑很直白先按格式算出裸行字节数再对平台要求的对齐值取整。对于 unpacked 的 RAW1016bit 容器裸行字节数是width * 2对于 packed 的 RAW10P裸行字节数是ceil(width * 10 / 8)但通常还会保证width是 4 的倍数所以可以直接用width * 10 / 8。我调试时拿到一份 dump 文件后会先做一道简单的验证题用文件大小除以行数得到实际行字节数再对比驱动回填的bytesperline。如果两者不一致说明 dump 工具或者读取脚本没有正确处理 padding。这一步能在写像素解析逻辑之前就把大多数存储布局问题挡在门外。4.3 平台相关对齐要求的应对不同平台的相机驱动对 stride 对齐的要求差异很大有的在VIDIOC_S_FMT里回填的值本身就对齐好了有的则要求应用层配合设置。调试时建议在用户态打印一份关键信息printf(format : 0x%08x\n, fmt.fmt.pix.pixelformat); printf(resolution : %ux%u\n, fmt.fmt.pix.width, fmt.fmt.pix.height); printf(bytesperline: %u\n, fmt.fmt.pix.bytesperline); printf(sizeimage : %u\n, fmt.fmt.pix.sizeimage); printf(calculate(no align): %u\n, fmt.fmt.pix.width * 2); // 仅对 unpacked 16bit 容器有效如果bytesperline大于裸行字节数说明有 padding如果等于说明这行天然对齐。遇到后者也别高兴太早有可能只是当前分辨率凑巧对齐换一个分辨率后 padding 就出现了。采集代码里永远把bytesperline当作每行的起始偏移一行数据读完之后要跳到下一行的起始位置而不是顺着上一行末尾直接往后读。5. packed 还是 unpacked带宽、内存与场景权衡5.1 一行算一账带宽差距直观看用 1080p1920x1080RAW10 30fps 来算一笔账数据差别很直观存储形式每像素占用单帧大小30fps 下 DDR 带宽MIPI 线上 packed RAW101.25 字节约 2.47 MB约 74 MB/s内存中 unpacked RAW1016bit 容器2 字节约 3.95 MB约 119 MB/s降级为 8bit 灰度1 字节约 1.98 MB约 59 MB/s同样是 10bit 精度的 sensorunpacked 到 16bit 容器后DDR 写入带宽比 packed 高了 60%。这个差距在单路 1080p30 下不算致命但到 4K60 或者三摄并发时DDR 带宽分分钟成为系统瓶颈。当然MIPI 链路上的带宽不受内存格式影响。线上流量只由 sensor 输出的位深和帧率决定无论内存里是 packed 还是 unpackedMIPI 上的打包规则都保持不变。换句话说内存格式的选择影响的是 DDR 侧不改变传感器到 SoC 的入口流量。5.2 决策矩阵什么场景用哪种我总结了一个很实用的选择矩阵场景推荐格式原因画质调试、raw 离线分析unpacked随机访问任意像素方便工具链友好长时间 raw 录像packed省 DDR 带宽和存储空间算法需要滑动窗口访问unpacked避免逐像素位拆解直接用 ISP 的 MIPI 输入不经过 DDR无所谓数据被 ISP 硬件内部消化CPU 做 3x3 滤波等高斯类算子unpackedSIMD 可整字节加载低功耗待机时的 raw 抓拍packed减少 DDR 活跃时间特别的提醒RAW8 和 RAW16 不存在 packed/unpacked 的差别。RAW8 每像素本来就是一字节RAW16 每像素本来就是 16bit 容器这两种格式在 MIPI 线上和内存里的布局基本一致格式名也没有带 P 的变体。5.3 从 MIPI 到 ISP 的特殊路径不一定过 DDR很多 SoC 里sensor 数据通过 MIPI CSI-2 进来之后可以直接流入 ISP 的前端处理单元全程不经过 DDR。这种情况下内存里根本没有 raw 数据自然谈不上 packed 还是 unpacked 的存储问题。这个不过 DDR的设计对带宽很有帮助raw 数据不进内存也就不会产生 DDR 读写流量。但当你想做 raw dump 调试时就需要额外配置一条旁路把 CSI RX 收到的数据同时复制一份进 DDR或者让 ISP 前端把某些帧单独转存出来。此时 dump 到内存的数据是打包流还是解包后的数据取决于 dump 硬件通路挂在 CSI 解包逻辑的哪一侧。挂在解包之前就是 packed挂在解包之后就是 unpacked。我遇到过不少为什么 dump 出来的 sizeimage 和理论值对不上的疑问本质都是没搞清楚这条通路的位置。6. 我在实际项目里踩过的几个格式坑6.1 现象整幅花屏第一行就解不出某次调试一个传感器驱动frame 显示出来是完全无法辨认的花屏但传感器寄存器配置、MIPI 时序都正常。排查思路是先把 MIPI 接收侧的 unpack 选项关掉让 dump 工具按SFGGR10Ppacked raw10的方式解析结果画面立刻正常。根因是驱动里 MIPI RX 被配置成了不展开但应用层傻乎乎地按 unpacked 16bit 容器去读导致每 5 个字节被读成 2 个像素数据流完全错位。这条链路值得记下来MIPI RX 收到的是打包流内存格式由 RX 模块的 unpack 配置决定两者可以不同步出现。配置驱动的顺序应该先确定内存侧最终想要什么再决定 RX 模块要不要做展开最后把正确的格式标识通过 V4L2 上报给用户态。6.2 现象每行错位形成斜纹最经典的水平向右逐步偏移现象几乎都是 bytesperline 问题。我之前拿到一份 4000x3000 的 raw dump文件大小看起来正常但每行都往右偏了一段距离越到下面越明显形成巨大的阶梯斜纹。检查发现 dump 工具用了裸行大小width * 10 / 8 5000字节去切行而实际驱动设置的对齐后 stride 是5056字节差了整整 56 字节。解决方案就一句话所有解析脚本里行起始地址必须从元数据里读不能从 width 和 bit depth 反推。我在自己的 dump 工具里加了参数校验如果file_size / height不等于元数据里的 stride直接拒绝解析并报错。6.3 现象解析出来噪声大对比度很碎还有一种情况图像没有明显错位但画面颗粒感极重暗部尤其明显看起来像把低 2 位当成了噪声。这通常是解析时把 10bit 数据直接当 8bit 用丢掉了低位信息比如有人图省事用(buf[i] 2)把 10bit 压成 8bit 那是正确的但有人直接取buf[i]高 8 位等于硬生生丢了 2bit 有效数据信息缺失导致暗部出现明显阶梯伪轮廓。排查方法很简单用 sensor 对着均匀灰色卡拍一帧解析后看相邻像素数值。如果中等亮度附近出现大量只有 0、64、128 之类跳跃值的区间说明 bit 位对齐出错了。特别地unpacked 数据里如果有效位放在高 10 位而脚本按低 10 位读取也会出现全图数值被压缩的怪异表现。6.4 快速验证格式的四条核对链路现在拿到任何一份 raw dump我都会按下面的顺序做快速验证不再凭感觉猜格式文件总大小核对文件大小 / 高度必须等于某个合理的每行字节数如果得到小数要么高度错了要么数据不是裸行连续。前几行像素连续性取第一行前若干个像素检查像素值是否在合理范围有无周期性突变。切换 packed/unpacked 解析同一份 dump 分别用两种方式解一次看哪一种能解出平滑的渐变灰阶另一种必然出现明显断裂或杂点。Bayer pattern 验证灰度正确后如果图像整体发绿或者有彩色假影才是 Bayer 排列RGGB/BGGR 等问题和 packed/unpacked 无关。这套链路帮我解决过至少五次疑似驱动问题的排查。很多时候问题根本不在驱动而在解析端没有对齐存储格式。MIPI raw 与 unpacked raw 的存储之争本质上就是传输效率和访问效率的权衡。理解了两边的字节布局再遇到带 P 不带 P 的格式选择心里就有底了。最后分享一个小技巧拿到新 sensor 的时候先拍一张黑白渐变测试图分别以 packed 和 unpacked 两种方式解析同一份数据对比哪一份灰度平滑。这个双解对比法能让你在一个小时内摸清整个通路上的存储格式比抠协议文档快得多。
返回列表