19、图形系统与GPU:OpenGL ES、Vulkan、Gralloc内存管理
说到嵌入式Android的图形系统,我脑子里第一个蹦出来的词就是「复杂」。真的,这玩意儿比你想的要深得多。你想想看,从App里画一个按钮,到最终像素点亮屏幕,中间经过了多少层?我当年第一次调GPU驱动的时候,差点被这堆概念绕晕。今天咱们就把这块硬骨头啃下来。
19.1 图形系统的整体架构
嵌入式Android的图形栈,说白了就是三层:应用层、框架层、硬件层。应用层用OpenGL ES或Vulkan画图,框架层用SurfaceFlinger做合成,硬件层用Gralloc管内存。这三层缺一不可。
核心要点:图形系统的性能瓶颈往往不在GPU算力,而在内存带宽和缓冲区管理。我踩过这个坑,后面细说。
咱们先看一张整体架构图,把各模块的关系理清楚。
这张图我建议你多看两眼。每次遇到图形性能问题,先定位是哪个层出的问题,能省不少时间。
19.2 OpenGL ES:移动端的图形基石
OpenGL ES,全称是OpenGL for Embedded Systems。说白了,就是OpenGL的阉割版——但别误会,这个「阉割」是好事。去掉了很多桌面端用不到、移动端跑不动的功能,换来的是更轻量、更省电。
我个人习惯把OpenGL ES的渲染流程理解成一条流水线:
- 顶点着色器:处理每个顶点的位置、颜色
- 图元装配:把顶点连成三角形
- 光栅化:把三角形转成像素片段
- 片段着色器:计算每个像素的最终颜色
- 测试与混合:深度测试、透明度混合
我在项目中遇到过一个问题:某个游戏在低端机上掉帧严重。查了半天,发现是片段着色器里写了一个循环,每个像素都要算几十次。你想想看,1080p屏幕有200多万像素,每个像素多算一次,那就是200多万次额外计算。后来把循环展开、用查表法替代,帧率直接翻倍。
实战技巧:写OpenGL ES着色器时,尽量少用if-else和循环。GPU是并行计算单元,分支预测能力很弱。能用纹理查表解决的问题,就别用数学计算。
来看一个最简单的OpenGL ES 3.0顶点着色器示例:
#version 300 es
layout(location = 0) in vec4 aPosition;
layout(location = 1) in vec4 aColor;
uniform mat4 uMVPMatrix;
out vec4 vColor;
void main() {
gl_Position = uMVPMatrix * aPosition;
vColor = aColor;
}
嗯,这里要注意:layout(location = x) 是OpenGL ES 3.0才支持的。如果你还在用2.0,得用 glBindAttribLocation 手动绑定。我建议新项目直接上3.0,省心。
19.3 Vulkan:更底层的控制权
Vulkan是Khronos在2016年推出的新一代图形API。跟OpenGL ES比,它最大的特点就是——「你说了算」。OpenGL ES帮你做了很多事,比如内存管理、同步、命令缓冲。Vulkan把这些全丢给你了。
为什么会这样?因为移动端GPU的架构差异太大了。Mali是Tile-based渲染,Adreno是统一架构,PowerVR是TBDR。OpenGL ES为了兼容,只能取最大公约数,性能上难免有折损。Vulkan让你自己管,你就能针对特定GPU做优化。
我记得第一次写Vulkan程序时,光是初始化就要写200多行代码。创建实例、选择物理设备、创建逻辑设备、创建交换链、创建渲染通道、创建帧缓冲...每一步都要自己配置。但好处是,一旦你理解了这套流程,你对GPU的理解会上一个台阶。
避坑指南:我曾经在Vulkan的同步机制上栽过跟头。如果你用信号量(semaphore)和栅栏(fence)的顺序搞反了,会出现诡异的画面撕裂。我的建议是:画个时序图,把每个提交、每个等待的依赖关系标清楚,再动手写代码。
Vulkan的核心概念对比:
| 概念 | OpenGL ES | Vulkan |
|---|---|---|
| 上下文 | 全局状态机 | 显式状态对象 |
| 内存管理 | 驱动自动管理 | 开发者手动分配 |
| 命令提交 | 隐式提交 | 显式命令缓冲 |
| 多线程 | 单线程为主 | 原生多线程支持 |
| 调试 | glGetError | Validation Layers |
说白了,Vulkan给了你手术刀,但用不好会割到自己。如果你的项目对性能要求没那么极致,OpenGL ES完全够用。但如果你要做高端游戏、AR/VR,或者要榨干GPU的每一分性能,Vulkan是必经之路。
19.4 Gralloc内存管理:图形缓冲区的幕后英雄
Gralloc,全称是Graphics Memory Allocator。它的工作很简单:为图形缓冲区分配内存。但简单的事情往往最难做好。
你想想看,一个图形缓冲区可能被谁访问?CPU要写数据进去,GPU要读出来渲染,显示控制器要读出来显示。这三者可能不在同一个内存域。CPU用系统内存,GPU用显存,显示控制器用专用的帧缓冲内存。Gralloc要做的就是协调这些访问。
在Android里,Gralloc基于ION或DMA-BUF实现。ION是早期的方案,DMA-BUF是内核主线推荐的方案。我建议新平台直接用DMA-BUF,ION已经被标记为废弃了。
Gralloc分配缓冲区时,有几个关键参数:
- 宽度/高度:像素尺寸
- 格式:RGBA_8888、NV12、YUV420等
- 使用标志:CPU读、CPU写、GPU纹理、显示合成等
- 层数:单层还是多层(如立体视频)
我在项目中遇到过一个问题:某个设备播放4K视频时画面卡顿。查了Gralloc的分配日志,发现缓冲区格式是NV12,但显示控制器只支持YUV420。每次显示前都要做格式转换,消耗了大量带宽。后来在Gralloc层直接分配YUV420格式的缓冲区,问题解决。
关键经验:Gralloc的使用标志一定要设置正确。如果你告诉Gralloc这个缓冲区只给GPU用,它就会分配在GPU能最快访问的内存区域。如果你说CPU也要读写,它就会分配在共享内存区域。选错了,性能会差一个数量级。
来看一个Gralloc分配的简化流程:
// 1. 创建分配器
gralloc_module_t *module;
hw_get_module(GRALLOC_HARDWARE_MODULE_ID, (const hw_module_t**)&module);
// 2. 分配缓冲区
buffer_handle_t handle;
int stride;
module->alloc(module, 1920, 1080, HAL_PIXEL_FORMAT_RGBA_8888,
GRALLOC_USAGE_HW_TEXTURE | GRALLOC_USAGE_HW_COMPOSER,
&handle, &stride);
// 3. 锁定缓冲区(CPU访问)
void *vaddr;
module->lock(module, handle, GRALLOC_USAGE_SW_WRITE_OFTEN,
0, 0, 1920, 1080, &vaddr);
// 4. 写入像素数据
memcpy(vaddr, pixel_data, 1920 * 1080 * 4);
// 5. 解锁
module->unlock(module, handle);
嗯,这里要注意:lock和unlock要配对使用。我曾经见过一个同事,lock了之后忘了unlock,结果GPU一直拿不到缓冲区,画面卡死。这种问题很难排查,因为日志里不会报错,只会显示「等待缓冲区超时」。
19.5 三者的协作关系
OpenGL ES/Vulkan负责「画」,Gralloc负责「存」,SurfaceFlinger负责「合成显示」。这三者是怎么配合的?
我画个流程图你就明白了:
整个流程是这样的:App调用OpenGL ES/Vulkan绘制,绘制结果写入Gralloc分配的缓冲区。SurfaceFlinger拿到这些缓冲区,合成一帧完整的画面,然后交给显示控制器输出。每一步都有缓冲区在流转,这就是所谓的「缓冲区队列」机制。
我最后说一句:图形系统是嵌入式Android里最考验综合能力的模块。你得懂GPU架构、懂内存管理、懂显示协议、懂操作系统调度。但反过来,如果你把这套东西吃透了,其他模块对你来说就是降维打击。
公众号:蓝海资料掘金营,微信deep3321