ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

全志T113-i G2D硬件加速YUV转RGB实战指南

全志T113-i G2D硬件加速YUV转RGB实战指南 如果你最近正在折腾全志T113-i这块板子或者准备拿它做视频类产品——HMI人机界面、车载后装显示、内窥设备、视频采集盒——大概率会撞到同一个问题解码器或者摄像头送出来的帧是YUV格式的但你想在Qt、LVGL、OpenCV或者framebuffer上显示的是RGB。YUV转RGB这件事在PC上根本不叫事但在T113-i这种双核A7的小核平台上软件转换的代价高到会让你怀疑人生。我第一次在T113-i上做1080p视频预览时解码器输出NV21我直接在应用里写了个循环去做转换结果帧率从预期的30fps直接掉到不到10fpsCPU占用拉满UI卡到完全没法看。后来把转换切到G2D硬件加速单帧转换耗时从几十毫秒降到几毫秒CPU几乎不参与。这篇就把我在T113-i上做G2D YUV转RGB的完整过程写出来包括驱动环境确认、代码实现、性能对比和那些文档里根本不写的坑给准备在这个平台上做视频方案的朋友一个参考。1. 视频链路里的那个隐形瓶颈1.1 一个再常见不过的场景T113-i是全志面向工业控制、车载、HMI等场景推出的一颗双核Cortex-A7处理器主频1.2GHz集成DDR3典型功耗很低。它的视频通路一般是这样的VPUCedarX解码器解出H.264/H.265码流输出YUV420格式的帧然后送到显示引擎DE2.0叠加显示。或者摄像头传感器通过CSI接口进来连到ISP处理同样输出YUV帧。问题出在你用这些帧做什么。如果你只是走标准显示链路YUV帧直接喂给DE2.0就能出画面不需要RGB。但很多场景需要你在应用层半路拦截用Qt做带OSD的视频界面QImage/RGB帧最方便做屏幕截图或录像RGB裸数据才好编码和处理跑OpenCV做检测识别mat默认是BGR/RGB通道顺序通过LVGL显示视频流LVGL的image格式也需要RGB一旦涉及这些YUV转RGB就成了必经之路。而这条路在T113-i上软件走不通。1.2 软转为什么扛不住算一笔粗账先算笔账1080p是1920×1080约207万个像素。NV21转RGB888每个像素要解出Y、U、V三个分量做BT.601矩阵变换加上饱和裁剪纯C标量实现的话一个像素大概需要25~30个CPU周期。这么算下来一帧总共消耗约5000万到6000万个周期。T113-i的Cortex-A7是双核1.2GHz单核每秒可执行12亿个周期。理论上单核全速跑转换一帧也要50毫秒左右。而30fps视频里每一帧的显示预算只有33.3毫秒。也就是说CPU单核100%全速跑都追不上30fps的实时转换需求这还是在没有任何其他业务的理想状况下。就算你手工加NEON优化把每像素成本压到5~8个周期一帧也需要10~16毫秒。30fps下光颜色转换这一件事就要吃掉CPU单核30%~50%的处理能力。别忘了T113-i总共只有两个核。你的UI刷新、协议解析、传感器采集、业务逻辑全部在跟它抢CPU。所以软件转换可用在低分辨率低帧率下勉强成立1080p30fps就是死路。另外不要忽略DDR带宽。NV21一帧约3MB转成RGB888后约6MB每秒30帧意味着每秒270MB的搬运量。T113-i的DDR带宽大约1~2GB/s带宽本身不紧张但CPU跑转换循环时会产生大量随机访问和cache miss实际性能会比理论估算还要差。1.3 G2D就是为这件事准备的全志在T113-i/S3系列平台里集成了一颗2D图形加速引擎叫G2DGraphic 2D Engine。它挂在系统总线上专门处理位块传输、颜色空间转换、缩放、旋转、混合这类固定管线的2D操作。视频解码器输出YUV帧后标准显示链路里的DE2.0可以直接消费YUV但如果你需要RGB最合理的做法就是让G2D插在中间VPU解码器输出YUV帧 -- G2D做格式转换 -- RGB帧 -- 应用层/显示层G2D做颜色转换的耗时和CPU占比都低到可以忽略这让T113-i上跑实时视频处理变得现实。我在这篇文章里就基于这条思路完整走一遍从环境确认到性能实测的过程。2. G2D的定位与能力边界不是GPU但正好够用2.1 T113-i的图形/显示架构里G2D处于什么位置T113-i的Linux SDKTina或Buildroot里显示子系统通常涉及两个硬件模块DE2.0Display Engine 2.0负责最终合成输出把多个图层叠加、缩放后送到LVDS、RGB、MIPI DSI等屏幕接口。G2DGraphic 2D Engine负责在主存当中做图形搬运和变换相当于一块独立的2D协处理单元不直接驱动屏幕。两者分工不同。DE2.0是显示链路末端G2D是通用2D加速器。G2D的运算结果可以给DE2.0当图层也可以给应用层直接使用。从驱动角度G2D在Linux用户态通常暴露为/dev/g2d设备节点通过ioctl命令操作部分新SDK也会封装成libg2d或libuapi_g2d这样的用户态库。2.2 G2D支持哪些操作根据全志相关资料和实际使用经验G2D支持的主要操作包括位块传输BitBLT把一块图像从源区域拷贝到目标区域颜色空间转换YUV420/NV21/NV12/YUYV/UYVY等格式转RGB565/RGB888/ARGB8888等缩放把源图缩放到目标尺寸支持双线性等插值旋转/镜像支持0/90/180/270度旋转和水平垂直镜像混合支持Alpha混合、全局Alpha、颜色填充和ROP操作其中格式转换和缩放是使用最频繁的两个能力。对于我们的目标场景——NV21转ARGB8888——就是一条ioctl的事。2.3 G2D不是GPU别指望跑shader这里必须说清楚一个容易误解的地方G2D不是GPU。它没有可编程shader不能跑OpenGL/Vulkan不支持自定义滤镜也不是通用并行计算设备。它只是把内置的那些固定管线操作用硬件做掉了。所以方案选型时要清醒如果你的需求是格式转换、缩放、旋转、叠加G2D非常合适如果你想跑图像识别、AI推理、复杂特征提取G2D帮不了你那得外挂NPU或者换带GPU的平台的平台。把G2D定位成视频显示链路里的一块专用搬运工使用场景就非常清晰了。3. 运行环境准备让 /dev/g2d 老老实实工作3.1 内核配置和设备节点确认开发板拿到手第一步不是写代码而是确认G2D驱动在内核里有没有被编译、设备节点是否注册成功。# 查看设备节点是否存在 ls -l /dev/g2d如果存在说明内核已经注册了G2D驱动。如果不存在需要手动检查内核配置。在Tina Linux内核的menuconfig里G2D相关配置一般在Device Drivers - Graphics support - G2D engine下选项名通常是SUNXI_G2D或者SUNXI_IOMMUg2d。确认内核源码里有drivers/video/sunxi/g2d这个目录配置打开后重新编译烧录。# 也可以在内核启动日志里确认 dmesg | grep -i g2d正常能看到类似g2d init成功的信息。如果dmesg没有输出可以查一下设备树确认g2d节点status是否为okay。3.2 头文件和用户态库从哪来G2D的ioctl参数结构体定义在SDK内核源码里常见位置是include/linux/g2d_driver.hdrivers/video/sunxi/g2d/g2d_driver.h不同SDK版本结构体字段名会有细微差异但核心概念一致。编译用户态程序时需要把这个头文件复制到你的工程里。如果SDK提供了用户态封装的库用起来会更方便# 一般库名是 libg2d.so 或 libuapi_g2d.so gcc -O2 test_g2d.c -lg2d -o test_g2d没有用户态库也没关系裸open(/dev/g2d) ioctl本身就是全部依赖不需要任何第三方库。这也是我推荐的做法因为封装库在不同SDK版本里接口变化很大而ioctl接口相对稳定只要头文件对上了就能用。3.3 第一个最小验证程序强烈建议先写一个最小可运行的验证程序用G2D做一次纯填充操作FillRect把一块区域填成红色再通过截图或者framebuffer确认画面变化。这样做的好处是先排除驱动和内存问题再进入复杂的YUV转RGB逻辑。#include stdio.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include g2d_driver.h int main(void) { int fd open(/dev/g2d, O_RDWR); if (fd 0) { perror(open /dev/g2d); return -1; } g2d_fill_rect_t fill; memset(fill, 0, sizeof(fill)); fill.rect.x 0; fill.rect.y 0; fill.rect.w 320; fill.rect.h 240; fill.color 0xFFFF0000; /* ARGB8888 红色 */ fill.flag 0; int ret ioctl(fd, G2D_CMD_FILL_RECT, fill); if (ret 0) { perror(G2D_CMD_FILL_RECT); } close(fd); return ret; }这段程序如果能跑通就证明G2D通路没问题可以放心往下走。不过这里有个很关键的前提填充目标必须是一块物理连续的buffer否则内核驱动很可能直接oom或者返回错误。这就引出了G2D开发最核心的一个问题——内存从哪来。3.4 物理连续内存G2D绕不开的第一道坎G2D是DMA设备它访问的是物理地址空间不经过CPU的MMU。我们平时在应用层用malloc申请的内存都是虚拟地址连续的普通内存物理页很可能是离散的G2D无法直接访问。所以在T113-i上用G2Dbuffer必须来自物理连续的内存区。常见来源有三个解码器输出的bufferCedarX VPU解码后的帧本身就是物理连续的可以直接喂给G2D。这是最省事的方式。通过ION/CMA分配T113-i的SDK里通常提供/dev/ion节点可以分配物理连续buffer。全志的libion或者封装库里的g2d_alloc_buffer底层都是走ION。通过DMA堆分配部分SDK的DMA堆也提供物理连续内存分配接口。我强烈建议直接复用解码器的buffer或者在初始化时用ION一次性分配好转换所需的内存运行时不再动态分配。动态分配物理连续内存是个慢操作而且频繁申请释放容易产生内存碎片。4. YUV转RGB核心实现从ioctl参数到完整代码4.1 数据从哪来、到哪去先说清这部分的输入输出约定。假设解码器输出一帧NV21格式的1080p图像NV21是YUV420 SPSemi-Planar的一种Y平面占width*height字节UV平面占width*height/2字节UV交错存储但顺序是V在前u在后这也是NV21和NV12最大的区别NV12是U在前。我们要做的转换是NV21 - ARGB8888也就是把YUV420 SP转成每像素32位带alpha通道的RGB格式。选择ARGB8888而不是RGB888的原因有两个ARGB8888在内存里是4字节对齐访存效率高很多GUI库和framebuffer原生支持ARGB8888后续使用更方便RGB888反而每像素3字节在很多对齐场景下会有性能问题。4.2 完整的转换流程代码核心思路是准备一块物理连续的NV21源buffer和一块物理连续的ARGB目标buffer填入g2d_bitblt_t结构体然后调用G2D_CMD_BITBLT。#include stdio.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include g2d_driver.h /* 假设以下分配函数是SDK封装好的返回物理地址和虚拟地址 */ extern int g2d_alloc_buffer(unsigned int size, unsigned long *phys, void **virt); static int g2d_nv21_to_argb(int fd, unsigned long y_phys, unsigned long uv_phys, unsigned long dst_phys, int width, int height) { g2d_bitblt_t blt; memset(blt, 0, sizeof(blt)); /* 源图像NV21 */ blt.src_image.format G2D_FORMAT_YUV420_NV21; blt.src_image.size.width width; blt.src_image.size.height height; blt.src_image.align[0] width; /* Y平面stride单位字节 */ blt.src_image.align[1] width; /* UV平面stride */ blt.src_image.base[0] y_phys; /* Y平面物理地址 */ blt.src_image.base[1] uv_phys; /* UV平面物理地址 */ blt.src_rect.x 0; blt.src_rect.y 0; blt.src_rect.w width; blt.src_rect.h height; /* 目标图像ARGB8888 */ blt.dst_image.format G2D_FORMAT_ARGB8888; blt.dst_image.size.width width; blt.dst_image.size.height height; blt.dst_image.align[0] width * 4; /* 目标stride详见对齐说明 */ blt.dst_image.base[0] dst_phys; blt.dst_rect.x 0; blt.dst_rect.y 0; blt.dst_rect.w width; blt.dst_rect.h height; blt.flag G2D_BLEND_NONE; /* 不混合直接覆盖 */ blt.rotate 0; return ioctl(fd, G2D_CMD_BITBLT, blt); } int main(int argc, char *argv[]) { int width 1920, height 1080; int y_size width * height; int uv_size width * height / 2; int dst_size width * height * 4; unsigned long y_phys 0, uv_phys 0, dst_phys 0; void *y_virt NULL, *uv_virt NULL, *dst_virt NULL; if (g2d_alloc_buffer(y_size, y_phys, y_virt) 0) { printf(alloc y buffer failed\n); return -1; } if (g2d_alloc_buffer(uv_size, uv_phys, uv_virt) 0) { printf(alloc uv buffer failed\n); return -1; } if (g2d_alloc_buffer(dst_size, dst_phys, dst_virt) 0) { printf(alloc dst buffer failed\n); return -1; } /* 往源buffer里填入一帧NV21数据比如从解码器拷贝 */ /* ... */ int fd open(/dev/g2d, O_RDWR); if (fd 0) { perror(open /dev/g2d); return -1; } int ret g2d_nv21_to_argb(fd, y_phys, uv_phys, dst_phys, width, height); if (ret 0) { perror(g2d bitblt); } close(fd); return ret; }这段代码看起来不复杂但里面有几个参数如果不理解报错时很难排查。关键点一个一个说。4.3 src_rect和dst_rect几何裁剪的核心src_rect和dst_rect分别表示源图和目标图上的矩形区域。G2D会读取源矩形里的像素转换后放到目标矩形里。两个矩形可以大小不同比如源矩形是1920×1080目标矩形是1280×720G2D会顺便做缩放。这里有个容易被忽视的点对于NV21这类YUV420格式src_rect的x、y、w、h都必须是偶数。因为UV平面是水平垂直各1/2采样奇数坐标会导致UV混合不对输出颜色错乱。如果源图像分辨率本身是奇数比如某些特殊摄像头输出1280×721请先做一次裁剪或者填充成偶数尺寸否则G2D的转换结果会出现边缘色偏。4.4 stride对齐不理解会查出半天的幽灵问题stride就是图像每一行在内存中占用的字节数它不一定是width像素数乘以每像素字节数。G2D通常要求stride按32字节对齐部分SDK要求64字节不满足对齐条件的stride会导致转换结果行错位或者ioctl直接返回EINVAL。NV21源Y平面一行是width字节1920正好是32的倍数1920/3260所以没有问题。但如果width是19221922就不满足32对齐需要向上取整成1924或者遇到更大对齐变成1952。目标ARGB一行的真实字节数是width*4192047680正好32对齐没问题。但如果width是12801280451205120/32160也正好对齐。一般情况下常见分辨率都是对齐的但如果你做了奇怪的裁剪就很容易踩到。建议封装一个对齐宏#define ALIGN_UP(x, a) (((x) (a) - 1) / (a) * (a))然后用ALIGN_UP(width, 32)来设置stride避免手算出错。4.5 flag参数的语义blt.flag G2D_BLEND_NONE表示不做混合直接覆盖目标区域。如果是做OSD叠加可以设置成G2D_BLEND_ALPHA让源图像的alpha通道生效。我们做视频帧转换时通常不需要混合用G2D_BLEND_NONE就好。这个参数设置在错误时通常会表现为目标区域颜色异常或者alpha通道全透明导致画面不可见。4.6 代码里故意留的下一步上面的示例里我用了g2d_alloc_buffer这个外部函数实际SDK里它的实现通常封装了ION分配逻辑。如果你的SDK没有这个函数需要自己通过/dev/ion的ioctl来分配核心步骤是打开/dev/ion调用ION_IOC_ALLOC申请物理连续buffer拿到fd后通过mmap映射得到虚拟地址如果驱动需要物理地址通过ION_PHYS或其他接口获取不同内核版本的ION接口变化很大我建议优先搜索你SDK里有没有封装好的分配接口不要自己从头写ION调用。5. 实测对比G2D、libyuv、纯软转的真实差距5.1 测试方法为了让你直观感受G2D到底值不值得用我在同一块T113-i开发板上测了三套方案方案A纯C标量软转BT.601定点化实现方案Blibyuv的NV21ToARGBlibyuv在ARMv7上会使用NEON优化方案CG2D ioctl同步转换统一转换1920×1080 NV21到ARGB8888测100帧取平均。软转方案用clock_gettime(CLOCK_MONOTONIC)测G2D方案计时范围包括ioctl整个调用时间同步模式返回即表示完成。为了公平G2D方案把cache flush也计入总耗时因为在真实应用里这是必须做的。测试帧内容彩色渐变文字运动区域保证色彩分布足够丰富避免碰巧全部是纯色这种极端情况。5.2 实测数据方案单帧平均耗时CPU占用情况主观感受纯C标量软转82.4毫秒单核打满极卡肉眼可见掉帧手动查表优化51.6毫秒单核接近打满依然无法实时libyuvNEON11.8毫秒单核约35%30fps帧预算33ms勉强够G2D ioctl2.7毫秒类似memcpy的开销CPU几乎无感注以上数据是我在一块T113-i 1.2GHz、DDR3平台上的实测约值不同内存频率、不同SDK驱动版本会有浮动但相对趋势是稳定的。数字背后的含义非常直观纯C软转在1080p30fps场景下完全不可用即使双核全开转换一帧也要80ms左右直推到不了30fps。libyuv的NEON优化已经把CPU压榨到极致了11.8ms一帧在30fps情况下CPU占用单核约35%整个系统依然感到紧张。G2D方案的2.7ms相比libyuv快了4倍以上但更重要的是CPU几乎被完全释放出来可以去跑UI、跑网络协议、跑业务逻辑。5.3 数字之外的几个判断只看耗时还不够我再说几个实际使用的体会。第一G2D在低分辨率场景优势没这么大。如果是480×272这种小分辨率软转也就2~3毫秒G2D反而要付出ioctl调用和cache flush的固定开销。所以如果是做小尺寸GUI产品用软转可能更简单。第二G2D方案的cache flush开销不可忽视。在2.7毫秒的总耗时里cache flush占了约0.5毫秒。如果驱动实现得好使用uncached buffer可以省掉这部分但代价是CPU去写源buffer时变慢。对于解码器DMA直接输出的buffer本来CPU也不怎么访问源数据用uncached是完全划算的。第三G2D转换的色彩空间默认是BT.601。如果你的视频源是BT.709色域的高清素材G2D转出来的颜色会和用BT.709矩阵软转的版本有肉眼可见的差别主要体现在饱和度上。这一点在后面的坑里细说。第四软转方案在工程上还有一个隐藏成本CPU发热和功耗。T113-i散热条件通常不好CPU持续满载会更快升温影响整机稳定性。G2D把负载从CPU卸掉对系统稳定也是加分项。6. 实测中的坑缓存一致性、对齐限制与并发调度到这里G2D转换本身已经跑通了但真正从能跑到稳定跑之间还有好几个大坑。这些坑我在文档里基本没看到系统性的总结都是踩过才明白。6.1 缓存一致性不处理的话你看到的是花屏和旧数据这是G2D开发里最关键的坑。CPU往源buffer里写入YUV数据数据暂时存在CPU的cache里不会立刻写回物理内存。而G2D是DMA设备直接访问物理内存不经过CPU cache。于是两种情况都可能出现转换前CPU已经往源buffer写了最新数据但没写回物理内存G2D读到的还是旧数据于是转出来是上一帧的画面或者花屏。转换后G2D把结果写到了物理内存里但CPU的cache里还保留着目标地址的旧数据CPU去读取目标buffer时拿到的还是cache里的旧脏数据看起来就像G2D根本没执行。解决办法就是做cache clean/invalidate。具体有三种常见做法在调用ioctl前对源buffer做cache clean写回转换完成后对目标buffer做cache invalidate让CPU cache失效重新从内存读取。直接把buffer映射成uncached/non-cacheable。这样CPU访问和DMA访问永远一致但CPU读写速度会下降。用SDK封装好的驱动函数部分全志libg2d会在ioctl内部自动处理cache操作。我的经验是视频转换场景里源数据通常来自解码器或摄像头DMA输出CPU不常写源buffer用uncached映射是最省心的彻底消除cache问题。如果你的CPU需要频繁填充源数据那就老老实实做cache flush别偷懒。6.2 对齐限制有的错误不会报只会默默出脏数据G2D的驱动对地址和stride的对齐要求不同SDK版本略有区别但通常要求32字节或者64字节对齐。地址对齐如果被破坏ioctl大概率会返回错误但stride对齐被破坏时驱动可能不报错而是在内存布局上出现每行偏移最终画面看起来像被切成一条一条再错位拼起来。1920×1080这个分辨率本身是幸运的Y平面stride 1920是32的倍数ARGB8的stride 7680也是32的倍数。但如果你用的是1280×720Y stride 1280也是32的倍数如果是1080×720之类的非常规分辨率就要格外小心。写代码时统一用ALIGN_UP宏处理stride是最稳妥的。另外源buffer的物理起始地址本身也要求对齐通常至少32字节建议128字节对齐。ION分配器一般都会满足但如果手动从解码器buffer池里取地址要仔细确认。6.3 NV21的UV平面地址和stride不要想当然NV21的UV平面大小是width*height/2但UV平面的stride在部分G2D驱动里要求等于Y平面stride而不是width/2*2即width。如果你在设置blt.src_image.align[1]时填了width/2有些驱动会直接转换失败或者输出颜色错乱。正确做法是先查你那个SDK的g2d_driver.h看注释怎么写的。以我接触过的几个版本为例align[1]填width是通用做法。如果拿不准两个值都试一下通过输出画面比对就能确认。6.4 同步还是异步区别在于CPU要不要等G2D_CMD_BITBLT默认是同步阻塞的ioctl返回时转换已经完成。这种方式实现简单时序确定适合单路视频处理。如果你的系统有多路视频同时转换或者每一帧CPU有其他高优先级任务要做可以考虑异步模式。部分全志驱动支持设置G2D_BLEND_NONE之外的flag来启用异步配合poll等待完成事件。异步的好处是CPU提交任务后可以立刻去跑别的DMA在后台转换完成后再通过事件唤醒。我实际测下来单路转换用同步就够了异步堆高吞吐需要在业务侧做队列管理复杂度提升不少。只有当你确认同步等待导致的CPU空闲是瓶颈时才值得上异步。6.5 多线程并发调用G2D并不加速G2D设备节点驱动内部有锁多线程同时调ioctl是安全的但会被驱动锁串行化。也就是说开四个线程各转一路视频并不会让四路同时并行而是每个时刻只有一路在转其他三路等着。所以多路视频转换的正确姿势是单线程循环依次提交转换任务或者一个线程对应一个独立的一路视频因为带宽资源有限总体吞吐受限。想靠多线程压榨G2D性能是走不通的。另外/dev/g2d的fd最好在程序初始化时打开一次全局复用不要每转换一帧都open/close。open/close虽然是轻量操作但频繁做还是会产生不必要的开销和资源碎片。6.6 颜色矩阵的坑G2D默认BT.601G2D做YUV转RGB用的颜色转换矩阵默认是BT.601。如果源视频是高清广色域素材按BT.709矩阵编码的G2D转出来的颜色饱和度会偏低肤色会偏灰。如果你的产品对颜色还原要求高可以做两个补救在应用层用GPU或libyuv再校正一次但这等于放弃G2D性能优势不推荐。在源数据进入G2D前先跟解码器确认输出色彩空间让解码器输出和G2D色彩空间一致。全志CedarX解码器的色彩空间配置经常被忽略我遇到过一次解码器输出BT.709视频源G2D按BT.601转换整个UI画质发灰的问题。最后分享一个小技巧做性能测试时不要只测ioctl的耗时要把整个链路都测进去——从源buffer准备、cache flush、ioctl执行到目标buffer读取。很多优化方案表面上看单点很快整条链路加进去就露馅了。我在T113-i上做视频方案时最终把G2D的fd常开、buffer池预分配、cache处理放到驱动封装里单帧从解码器输出到RGB可用的总耗时稳定控制在8毫秒以内1080p30fps跑得很稳CPU余量也足够跑UI和业务逻辑。如果你也在T113-i或者同系列平台上做类似的视频处理照着这个思路走应该能少走不少弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进