ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenHarmony图形栈重构:基于Mesa3D Gallium的渲染后端实践

OpenHarmony图形栈重构:基于Mesa3D Gallium的渲染后端实践

如果你和我一样,在一款自带GPU的国产SoC上折腾OpenHarmony图形栈,大概率见过下面这幅场景:系统窗口能刷出来,光标也在动,可只要一跑带OpenGL ES的测试程序,要么黑屏,要么隔几秒崩出满屏EGL_BAD_ALLOC。去年我做Mesa3D移植第一版时就是这样,老render框架能点亮屏幕,但整条链路里到处都是临时判断,改一处坏一处。所以才有了这篇移植二,也是这次真正想聊的render新框架:一个在Mesa3D Gallium之上重新梳理的渲染后端,目标是把OpenHarmony的NativeWindow、BufferQueue和显示合成链路真正串成一条可以扩展的管线。这篇文章不重复第一版的基础步骤,只讲新框架的模块划分、关键移植点,以及我实测过程中踩到的坑。

1. 重做之前的痛点:旧render框架到底卡在哪

1.1 第一版“能跑”和“好用”之间隔着一整条渲染链路

第一版我为了快速验证,实际是把Mesa的EGL初始化直接绑到了某个远程窗口模块的全局单例上。听起来挺省事,Mesa初始化完就能通过opengles测试,但问题在后期集中爆发:系统有三块显示设备,HDMI、MIPI屏、还有一层虚拟屏,旧框架里EGLDisplay只有一个,于是三个屏幕只能轮流抢同一个渲染target。

另外,OpenHarmony的资源管理器有自己一套生命周期,它认为buffer还挂在队列里就应该继续显示,但Mesa那边已经把资源释放了。结果就是开机一小时后突然花屏,或者应用退到后台再回来时,画面上残留上一帧的内容。这些都是第一版完全没考虑进去的状态同步问题。

更麻烦的是线程。旧框架在设计时没有把GL Context的创建和绑定的线程归属理顺,我不得不在应用层加一个全局互斥量来串行化所有eglMakeCurrent调用。这相当于让GPU渲染退化成单线程。一旦某个app开了子线程做后台加载,主线程的渲染帧率就会剧烈抖动。

1.2 帧缓冲管理没有一个清晰的资源归属

旧框架里的framebuffer管理非常“野”:直接把Mesa内部生成的pipe_resource当作成前端要的buffer来用,再映射给显示控制器扫描。这样做的好处是代码短,坏处是资源所有权不明确。谁能改这个buffer,谁能读这个buffer,什么时候改完,在没有Fence约束的情况下全靠运气。

我实际遇到过的现象是:vblank回调已经触发,显示控制器开始读取这一帧,但GPU这边还在绘制同一个buffer,于是一帧画面里上半部分是新的、下半部分是撕裂的。旧框架我试着加过一版“检测到绘制中就多等一个vblank”的逻辑,结果因为不判断绘制是否真结束,只是盲目多等,导致掉帧。真正的问题是缺乏明确的同步机制,而不是等待时间不够。

1.3 从问题反推设计目标

把上面这些痛点列成表格之后,新框架该做什么其实已经很清楚:

问题根因新框架目标
EGLDisplay只能绑定一个屏幕单例设计,没有抽象显示设备支持多Display,每个Display独立EGLSurface
后台线程导致帧率抖动context绑定线程不清晰由框架统一管理context归属
花屏、撕裂、残留画面资源生命周期不明确、缺少同步引入buffer所有权转移与Fence同步
改一处坏一处平台适配代码侵入Mesa内部把平台适配收敛到固定适配层

所以这版我确定的核心原则只有一条:绝不在Mesa源码里到处打补丁,所有硬件相关逻辑全部收口到一个薄适配层。这个决策,直接决定了后面所有模块的代码结构。

2. render新框架的模块划分:在Gallium之上加一个适配层

2.1 所谓“新框架”,本质是给EGL和Gallium之间加一个中间层

很多人一听到“重写render框架”就觉得要动Mesa的渲染核心,其实恰恰相反。Mesa的Gallium架构本身足够稳定,状态跟踪器、编译器、驱动后端分得很清楚,没必要为了适配一个操作系统去改渲染引擎内部。新框架真正改的是Mesa和OpenHarmony平台之间的衔接方式。

我这边最后落地的模块分成三块:

  • Display管理模块:负责发现显示设备、创建EGLDisplay、管理原生窗口和屏幕的映射关系。旧版是把一个全局对象到处传,新版则是注册一张表,每个物理屏幕对应一个Display实例,前端通过类似ohos_display_get_default的接口拿到句柄。
  • Surface管理模块:负责把OpenHarmony的NativeWindow封装成Mesa可识别的EGLSurface。这里包含buffer的申请、提交、释放,以及和BufferQueue的交互。前端的SurfaceView、XComponent,最终都落到这个模块里的一个window_surface结构体。
  • 资源管理模块:负责buffer的导入导出、DMA-BUF的转换、Fence同步。说白了就是让GPU画的帧能被显示控制器安全地拿走去扫。

这三个模块统一收口在一个叫render_backend的接口里,只有一组固定的创建、配置、提帧、释放操作。内部再根据底层GPU能力选择软渲染还是硬件驱动,前端完全无感。

2.2 一次draw调用从应用走到显示器的完整路径

讲代码之前,先过一次数据流,不然看后面细节容易懵。我在新框架里理顺后的完整链路是这样的:

  1. 应用调用eglGetDisplay,框架根据设备节点返回一个Display实例。
  2. 应用调用eglCreateWindowSurface,框架从NativeWindow里拿到一个空闲buffer,交给Mesa作为drawbuffer。
  3. 应用执行glDrawElements,Mesa的GL状态跟踪器把API调用翻译成Gallium命令,再由后端驱动生成实际的GPU命令。
  4. GPU命令执行完,通过Fence通知框架“这帧可以拿去显示了”。
  5. 框架把buffer提交给OpenHarmony的BufferQueue,显示合成器拿到buffer后和UI层叠加,最终送屏。

这版框架最核心的变化在第三步和第四步之间。旧框架里GPU画完后直接把buffer地址丢给显示控制器,新框架则引入了Fence机制,确保显示控制器读取时,GPU的写入已经完整落定。

2.3 为什么保留Gallium,而不是干脆自研一个小渲染器

有人可能会问,既然OpenHarmony本身有自己的render service,为什么还要费劲移植Mesa?答案是应用生态。OpenHarmony的富媒体应用大量依赖OpenGL ES做特效、游戏、地图渲染,这些API的实现不是三天两头能重写干净的。而Mesa的Gallium架构把着色器编译、状态管理、资源管理都做了很好的抽象,我只需要让平台层符合它的接口预期。

相比第一版那种“能用就行”的思路,新框架等于在平台和Mesa之间划了一条明确的契约线。后续想换硬件驱动、想支持Vulkan、想接更多buffer格式,都只动适配层,不需要重新排查整条渲染链路。这是我做这次重构最看重的东西。

3. 移植过程中真正难啃的几个硬骨头

3.1 给Mesa的EGL增加一个OHOS Platform

Mesa本身有一套EGL平台枚举机制,常见的像EGL_PLATFORM_X11、EGL_PLATFORM_WAYLAND、EGL_PLATFORM_DRM。OpenHarmony不在其中,所以第一步是让Mesa能识别OpenHarmony的native window类型。

我参考了Android platform的做法,在Mesa的egl源码里增加了一个平台入口,让eglGetPlatformDisplayEXT在检测到EGL_PLATFORM_OHOS时走我们自己的初始化分支。这里有个关键坑:OpenHarmony的NativeWindow结构体和传统X11/Wayland的window类型完全不同,不能直接强转。正确的做法是在适配层写一个转换函数,把OHOS的OHNativeWindow指针封装成结构,同时把窗口的宽高、格式、usage flag一起传给Mesa的surface配置。

当时我卡了最久的地方是颜色格式。Mesa默认expect的framebuffer格式经常是XRGB8888,而OpenHarmony的BufferQueue默认给的是RGBA8888。这两个在字节排列上看起来很像,但alpha位的处理逻辑不一样。如果不显式设置EGL_ALPHA_SIZE,就会出现半透明窗口背景下文字发虚的奇怪bug。

3.2 DMA-BUF资源导入与缓存同步

新框架里buffer在GPU和显示控制器之间流转,靠的是DMA-BUF的fd传递。Mesa这边有现成的resource_from_handle接口,可以基于一个已有的fd创建pipe_resource,这样就不需要把GPU显存内容拷贝一份到系统内存再拷回去。

但这块有一个必须注意的点:不是所有fd都能直接拿去mmap。我遇到过MMU配置不对导致map出来的地址访问直接段错误,后来排查发现是设备的dma地址范围和CPU地址空间之间需要做偏移转换。这个属于平台级问题,Mesa本身不会帮你处理,必须在buffer导入前把物理地址信息填对。

缓存同步也是大坑。GPU用Cache写数据,CPU那边直接读的时候可能看到的是旧数据。我在调试一个纹理上传慢的问题时,发现每次用CPU往buffer里写数据后,GPU那边拿到的总是上一次的内容。后来在提交缓冲前增加了cache clean操作,在GPU访问前增加cache invalidate操作,才把问题解决。这些东西在PC上一般由驱动自动处理,但在这种嵌入式移植场景里,往往要显式调平台接口。

3.3 线程模型与Context切换

新框架把GL Context的管理收口后,线程问题就从应用层转移到了框架层,框架必须自己保证并发安全。我最后采用的方案是“Display级别的锁”:同一个Display下,eglMakeCurrent切换context时加锁,但不同Display之间互不影响。这样主线程和渲染线程可以同时跑在不同Display上,不会互相卡死。

另一个细节是eglDestroyContext的时机。某些驱动在context销毁时还会尝试访问它绑定过的surface,如果surface已经提前释放,就会触发use-after-free。我在框架里规定,销毁context前必须先解除它和surface的绑定关系,并等待正在执行的GPU命令完成,再回收内存。这个顺序错一步,就容易在退出应用时偶发崩溃。

4. 显示链路改造:让合成器正常消费GPU的产出

4.1 从BufferQueue到HWC的buffer所有权交接

在OpenHarmony的显示架构里,应用拿到的buffer来自BufferQueue,最终展示则要交给HWC合成。Mesa渲染完的buffer不能直接推给屏幕,必须把所有权沿着“应用 -> BufferQueue -> 合成器”的顺序依次移交。

新框架实现这套交接依赖一个buffer_release回调。当BufferQueue认为buffer已经被合成器读取后,会回调框架层,框架再把这个buffer归还给Mesa的资源池,供下一次渲染复用。如果这个回调时机不对,要么buffer被过早回收,合成器读了一半被覆盖;要么buffer一直不归还,应用申请不到新buffer开始卡顿。

我在调试时观察过一个诡异现象:只有连续滑动桌面时才卡,停下来就正常。后来抓log发现是合成器持有了两个buffer还没释放,应用侧的缓冲池已经见底。调整成三缓冲并增加超时回收后,才算稳定。

4.2 像素格式和plane对齐

显示控制器对buffer有几个硬性要求:内存对齐、像素格式匹配、扫描线地址按行对齐。这些在PC上通常由驱动绕过了,但OpenHarmony的HWC会直接检查。

我移植的显示核心虽然支持常见的RGBA8888和RGB888,但有一个tiled模式的问题。GPU那边默认生成tiled布局提升纹理效率,但显示控制器只认linear布局。旧框架里我直接让GPU用linear模式,性能损失明显。新框架的做法是开启GPU的写压缩/转置引擎,在buffer提交前做一次硬件格式转换,让GPU保持高效率的排列,显示侧也能读到正确的linear数据。

这个代价是增加了大约一毫秒的转换时间,相比让GPU全程跑linear带来的带宽损失,值很多。

4.3 三缓冲下的帧间隔和Fence策略

OpenHarmony默认倾向三缓冲来减少jank,但三缓冲如果配合Fence策略不当,反而会增加延迟。我试过两种方案:第一种是渲染完立即释放,不等合成器读;第二种是等Fence信号后再释放。第一种在负载低时流畅,高负载时花屏;第二种在高负载时稳定,但延迟多一到两帧。

最终我做的决定是动态判断:当BufferQueue里可用buffer少于2个时,强制开启Fence等待;可用buffer充足时,走快速路径。实测下来,在高负载场景下花屏消失,普通场景延迟没有明显恶化。

5. 验证与调优:从能出图到稳定跑分

5.1 dEQP测试结果出来的那一晚

渲染框架重构完,第一件事不是跑性能,而是验证正确性。我用了Khronos的dEQP工具集,分别跑GLES2、GLES3的核心测试组。

第一轮结果非常惨,大量shader compile失败的case,几乎全是精度修饰符和循环展开相关的差异。仔细排查后发现不是Mesa的问题,而是我在EGL配置里把EGL_CONTEXT_MAJOR_VERSION设置错了,导致app请求到的是软件兼容profile而不是完整GLES3上下文。修正之后,GLES2的通过率从40%跳到接近100%,GLES3也有99.4%左右,剩下个别case是纹理压缩格式扩展没接全。

还有一个必须提的验证环节是压力测试。dEQP跑的是单帧正确性,不管长时间运行的稳定性。我额外写了脚本循环跑图形demo两小时,同时反复切换前后台、开关屏幕,确认没有buffer泄漏和Fence死锁。

5.2 软渲染和硬件加速的性能差异

这套平台目前适配的是内置GPU,所以硬件路径为主。但调试初期Mesa驱动还没完全工作,我先用llvmpipe软渲染验证框架逻辑,那时候性能很能说明问题。

llvmpipe在OpenHarmony这种轻量系统上,帧率大致能到30到40帧,但对内存带宽非常敏感。新框架dma-buf共享绕过了很多拷贝,软渲染性能比第一版反而提升了。这也印证了我之前说的:很多时候瓶颈不在GPU计算,而在于buffer搬运。

硬件加速跑起来后,我用一个标准UI交互场景和GPU benchmark分别测试。差异最明显的是1080p全屏天气动效场景,软渲染在复杂模糊shader下掉到个位数,硬件加速能稳定在50帧以上。这说明针对该GPU所做的内存布局转换和命令提交优化是有效果的。

5.3 黑屏、花屏问题的定位套路

新框架上线后,我总结出一套黑屏花屏的排查顺序,基本上是:

  1. 看EGL_BAD_ALLOC是否出现在buffer申请阶段,如果是,优先查usage flag和format组合是否被allocator拒绝。
  2. 看显示控制器是否报“pixel format not supported”,如果是,重点查提交前的格式转换有没有生效。
  3. 看Fence回调是否迟迟不触发,如果是,多半是DMA-BUF sync对象没有绑定正确的fd。
  4. 如果以上都正常,查buffer地址对齐,特别是stride大于width时的行对齐问题。

这套顺序帮我节省了大量时间。大多数黑屏不是渲染完全没输出,而是某一环的格式或尺寸信息传错了,显示控制器根本没把buffer当作有效输入。

6. 回头看:除了代码,几个值得说说的选择

6.1 选稳定的Mesa分支,而不是追最新

Mesa的master分支变化很快,特别是在vulkan和新的gallium驱动上。我在项目中期换过一次新分支,结果EGL平台接口内部结构调整,导致我写的适配代码大面积需要重改。后来固定在某个长期稳定版本上,只做小版本跟进。奉劝做嵌入式移植的朋友,除非需要新特性,否则别追主线,稳定分支能少掉一半头发。

6.2 “新框架”不是造轮子,而是把边界划清楚

一开始我也有点抵触重写,毕竟第一版能跑,谁愿意动它。但实践下来的体会是:所谓render新框架,真正带来的价值不是新的渲染算法,而是把“谁负责分配、谁负责渲染、谁负责显示”这条责任链划清了。Mesa内部的功能依然沿用原样,OpenHarmony侧的资源管理模式也基本没动,我只在中间补了一层防腐逻辑,结果所有以前“改了这里坏了那边”的毛病都消失了。

6.3 后续可以怎么扩展

这套框架跑通OpenGL ES之后,后续最自然的扩展是支持Vulkan。Mesa本身就带Vulkan的驱动支持,如果再配一个Vulkan ICD加载器,很多游戏应用可以直接受益。另一个可做的方向是扩展buffer格式,比如支持10bit HDR的RGB10A2,适配高端视频播放场景。框架层面我已经预留了format清单的注册入口,增加新格式不再需要改代码逻辑。

最后分享一个实战建议:拿到一块新板子,别上来就跑dEQP或者游戏demo,先用一个简单的全屏色块程序验证buffer从GPU到显示器的全链路。只要色块颜色正确、切换流畅,说明最底层的传输没坏,后面的功能再逐个加。渲染栈的问题,绝大多数是资源和时序问题,不是数学问题。把这两样理顺了,Mesa3D移植基本就成功了一大半。

RELATED READING

延伸阅读

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