3、显示框架原理:Android SurfaceFlinger与HWC详解、QNX Screen Graphics Subsystem、Linux DRM/KMS框架

好,我们进入第三讲。这一讲我打算把三个主流车载系统的显示框架掰开揉碎了讲清楚。你想想看,多屏异显异触的核心,说白了就是「谁在管屏幕,怎么管」。Android、QNX、Linux 这三套方案,我都在项目里摸爬滚打踩过坑,今天把干货倒出来。

3.1 Android SurfaceFlinger 与 HWC

Android 的显示链路,我习惯把它分成三层:应用层画图、SurfaceFlinger 合成、HWC 送显。很多刚入行的朋友以为 SurfaceFlinger 就是最终干活的,其实它只是个「调度员」。

核心认知:SurfaceFlinger 负责决定「怎么合成」,HWC(Hardware Composer)负责「实际合成」。HWC 能走硬件就绝不走软件,这是性能关键。

3.1.1 SurfaceFlinger 的工作流

我简单描述一下流程:每个应用窗口对应一个 Surface,SurfaceFlinger 收到 VSync 信号后,开始收集所有可见 Surface 的 Buffer。然后它调用 HWC 的接口,问:「老兄,这些图层你能硬件合成几个?」

HWC 会回答:「我能合 3 个,剩下 2 个你软件合成。」于是 SurfaceFlinger 用 GPU 把剩下的图层合成一张图,再交给 HWC 去显示。

嗯,这里要注意:车载多屏场景下,每个屏幕都有自己的 DisplayDevice。SurfaceFlinger 会为每个屏幕维护一个独立的合成计划。我曾经在项目中遇到过一个问题:副屏的刷新率是 30fps,主屏是 60fps,结果 SurfaceFlinger 的 VSync 信号乱了套。后来怎么解决的?我强制给每个屏幕分配了独立的 VSync 源。

3.1.2 HWC 的硬件加速

HWC 是硬件厂商实现的 HAL 层模块。说白了,它就是个「硬件加速器」。它能做的事情包括:图层叠加、旋转、缩放、色彩转换。

车载场景下,HWC 最头疼的是什么?是图层数量。仪表盘、中控、HUD,每个屏幕可能都有 5-8 个图层。如果 HWC 硬件只能叠加 4 层,剩下的就得走 GPU 合成,功耗和延迟就上去了。

我的经验:在选 SoC 时,一定要确认 HWC 支持的硬件图层数。我见过一个项目,选了某款芯片,结果硬件只支持 3 层叠加,仪表盘导航一开就卡顿。最后只能改 UI 设计,把图层合并到 3 层以内。

3.1.3 关键代码路径

我们看一段 SurfaceFlinger 的核心逻辑,简化后的伪代码:

// SurfaceFlinger 主循环
void SurfaceFlinger::onMessageReceived() {
    // 1. 等待 VSync
    waitForVSync();
    
    // 2. 收集所有可见 Layer
    Vector<LayerState> layers = collectVisibleLayers();
    
    // 3. 调用 HWC 进行合成预算
    HWC::Display display = getDisplayForScreen(screenId);
    HWC::LayerCompositionResult result = display.prepare(layers);
    
    // 4. 如果 HWC 不能处理所有图层,用 GPU 合成剩余部分
    if (result.remainingLayers > 0) {
        gpuComposeRemainingLayers(result.remainingLayers);
    }
    
    // 5. 提交到 HWC 显示
    display.commit();
}

这段代码我改过无数次。最坑的一次是,某个芯片的 HWC 驱动在 prepare() 阶段会死锁,原因是驱动内部用了同一个 mutex。我当时的解决方案是:在 SurfaceFlinger 侧加一个超时机制,如果 prepare 超过 16ms,就强制走 GPU 合成。

3.2 QNX Screen Graphics Subsystem

QNX 的显示框架,和 Android 完全是两套思路。Android 是「应用驱动」,QNX 是「服务驱动」。QNX 的 Screen Graphics Subsystem,我习惯叫它「Screen 框架」。

3.2.1 Screen 的核心概念

Screen 框架里有几个关键对象:

  • Screen Context:全局上下文,一个进程一个
  • Screen Window:对应一个显示区域,可以绑定到某个 Display
  • Screen Display:物理屏幕或虚拟屏幕
  • Screen Buffer:显存中的图像数据

你想想看,在 QNX 里做多屏异显,其实就是创建多个 Display 对象,然后把 Window 绑定到不同的 Display 上。每个 Window 可以独立设置分辨率、刷新率、旋转角度。

关键区别:QNX 没有 SurfaceFlinger 这种中间层。应用直接通过 Screen API 操作显存。这意味着延迟更低,但开发复杂度更高。

3.2.2 多屏配置实战

我记得在某个项目中,需要把仪表盘和中控分别显示在两个物理屏幕上。QNX 的配置是这样的:

// 创建两个 Display
screen_display_t display_main, display_cluster;
screen_create_display(&display_main, context, "main");
screen_create_display(&display_cluster, context, "cluster");

// 设置 Display 属性
screen_set_display_property_iv(display_main, SCREEN_PROPERTY_SIZE, main_size);
screen_set_display_property_iv(display_cluster, SCREEN_PROPERTY_SIZE, cluster_size);

// 创建 Window 并绑定到 Display
screen_window_t win_main, win_cluster;
screen_create_window(&win_main, context);
screen_create_window(&win_cluster, context);

screen_set_window_property_pv(win_main, SCREEN_PROPERTY_DISPLAY, &display_main);
screen_set_window_property_pv(win_cluster, SCREEN_PROPERTY_DISPLAY, &display_cluster);

这里有个坑:QNX 的 Screen 框架默认是单 Display 模式。如果你不显式创建第二个 Display,系统会把所有 Window 都堆到主屏上。我曾经调试一个 bug,副屏一直黑屏,查了两天才发现是 Display 对象没创建成功。

3.2.3 性能与安全

QNX 的优势在于确定性。它的显示调度是实时的,不会像 Android 那样因为 GC 或者后台进程导致掉帧。在仪表盘场景下,这是刚需。

但 QNX 也有短板:图形栈比较封闭。它的 Screen API 是 QNX 私有的,不像 Android 那样有开源社区支持。如果你需要做复杂的特效,比如模糊、阴影,QNX 的硬件加速支持不如 Android 成熟。

避坑指南:我曾经在 QNX 上做 HUD 投影,需要把图像做畸变校正。QNX 的 Screen 框架不支持直接做几何变换,最后我只能用 GPU 的 compute shader 自己实现。如果你有类似需求,提前评估好 GPU 的算力。

3.3 Linux DRM/KMS 框架

Linux 的显示框架,我把它看作是「最灵活但也最原始」的方案。DRM(Direct Rendering Manager)负责显存管理,KMS(Kernel Mode Setting)负责显示模式设置。

3.3.1 DRM 的核心对象

DRM 框架里有几个关键概念:

对象 作用 类比
CRTC 显示控制器,负责扫描输出 相当于一个「显示引擎」
Encoder 编码器,将 CRTC 的信号转换成物理接口 HDMI、LVDS、MIPI 的驱动
Connector 物理连接器,代表实际的屏幕接口 HDMI 接口、eDP 接口
Plane 硬件图层,可以叠加显示 类似 HWC 的硬件图层

你想想看,在 Linux 上做多屏异显,本质就是:为每个屏幕分配一个 CRTC,然后绑定对应的 Encoder 和 Connector

3.3.2 KMS 的配置示例

我习惯用 modetest 工具来调试 KMS。下面是一个典型的双屏配置流程:

// 打开 DRM 设备
int fd = open("/dev/dri/card0", O_RDWR);

// 获取资源
drmModeRes *resources = drmModeGetResources(fd);

// 遍历 Connector,找到两个屏幕
for (int i = 0; i < resources->count_connectors; i++) {
    drmModeConnector *connector = drmModeGetConnector(fd, resources->connectors[i]);
    if (connector->connection == DRM_MODE_CONNECTED) {
        // 为每个屏幕创建一个 CRTC
        uint32_t crtc_id = findAvailableCRTC(resources);
        
        // 设置显示模式
        drmModeCrtc *crtc = drmModeGetCrtc(fd, crtc_id);
        drmModeSetCrtc(fd, crtc_id, fb_id, 0, 0, &connector->connector_id, 1, &connector->modes[0]);
    }
}

嗯,这里要注意:Linux 的 DRM 驱动质量参差不齐。我遇到过某款 SoC 的 DRM 驱动,在切换显示模式时会闪屏。原因是驱动在 drmModeSetCrtc 时没有做 VBlank 同步。解决方案是:在调用前先等待 VBlank 信号。

3.3.3 多屏异显的挑战

Linux 方案的优势是完全可控。你可以直接操作硬件寄存器,做任何你想做的事情。但代价是:

  • 没有现成的合成框架:你需要自己实现图层合成逻辑
  • 驱动调试困难:一旦出问题,可能得啃芯片手册
  • 生态碎片化:不同厂商的 DRM 驱动行为不一致

我的建议:如果你的项目对延迟要求极高(比如仪表盘),Linux DRM/KMS 是首选。但如果你需要快速开发,还是用 Android 或 QNX 的现成框架更稳妥。

3.4 三种框架的对比总结

最后,我用一张表总结一下三者的差异:

维度 Android SurfaceFlinger+HWC QNX Screen Linux DRM/KMS
抽象层级 高(应用层到 HAL) 中(系统服务层) 低(内核层)
多屏支持 原生支持,但需配置 原生支持,配置灵活 需手动管理 CRTC
硬件加速 通过 HWC 实现 通过 Screen API 实现 直接操作硬件
实时性 一般(受 GC 影响) 高(确定性调度) 高(无中间层)
开发难度 中等 中等偏高
典型场景 中控娱乐、副驾屏 仪表盘、HUD 定制化显示、实验平台

我个人习惯是:中控娱乐用 Android,仪表盘用 QNX,原型验证用 Linux。当然,具体选型还得看项目需求和团队能力。好了,这一讲就到这里,下一讲我们聊聊多屏触控的同步与冲突处理。