显示驱动核心:显示驱动栈、帧缓冲管理、模式设置与 CRTC/Encoder 模型
显示驱动,说白了就是操作系统跟显示器之间的翻译官。你写个 GUI 程序,画了个按钮,数据怎么跑到屏幕上?中间经过了多少层?我刚开始接触这块时,觉得不就是往显存里写数据嘛,后来被一个撕裂问题折腾了三天,才老老实实把整个驱动栈啃了一遍。
今天咱们就把这个栈从上到下捋清楚。你理解了这个结构,后面做动画优化、做多屏适配,心里才有底。
2.1 显示驱动栈:从用户态到像素点亮
一个像素从应用程序到屏幕,大概要经过这么几层:
- 用户态图形库(比如 Wayland/Weston、X11、Android 的 SurfaceFlinger)—— 负责合成图层、处理窗口遮挡。
- 内核态 DRM 子系统(Direct Rendering Manager)—— 核心调度,管理显存、模式设置、同步。
- KMS 驱动(Kernel Mode-Setting)—— 负责配置显示控制器、时钟、接口。
- 硬件(GPU + Display Controller + PHY)—— 真正把数据变成电信号。
我个人习惯把 DRM 看作一个「显示路由器」。它不关心你画的是什么,只关心数据怎么搬、什么时候搬、搬到哪里去。
核心认知:显示驱动栈的本质是「解耦」。图形库只管画,DRM 只管调度,KMS 只管配硬件。每一层只做一件事,才能让 Linux 支持从树莓派到 8K 工作站的各种硬件。
下面这张图是我自己画的一个简化流程,你可以对照着看:
2.2 帧缓冲管理:显存怎么管?
帧缓冲(Framebuffer)就是显存里的一块区域,存着当前屏幕要显示的画面。DRM 用 drm_framebuffer 结构体来管理它。你想想看,如果只有一个缓冲,你一边写数据一边被显示器读走,画面就会撕裂——上半截是新帧,下半截还是旧帧。
解决办法?双缓冲甚至三缓冲。
| 缓冲模式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 单缓冲 | 只有一个 Framebuffer,CPU/GPU 直接写 | 延迟最低 | 撕裂严重,几乎不用 |
| 双缓冲 | 两个 FB:一个显示(Front),一个绘制(Back),通过 Page Flip 交换 | 消除撕裂,实现简单 | 可能掉帧(丢一帧) |
| 三缓冲 | 三个 FB:显示、绘制、等待 | 平滑,减少掉帧 | 多占一份显存,延迟略增 |
我在项目中遇到过一个问题:某个嵌入式平台只有 16MB 显存,开三缓冲后 UI 界面直接 OOM。后来我改成双缓冲 + 脏矩形更新,才把内存压下来。所以别盲目上三缓冲,得看硬件家底。
避坑指南:我曾经在调试一个 4K 60fps 的播放器时,发现 Page Flip 总是超时。查了半天,原来是 Framebuffer 的 stride(行跨度)没对齐到 256 字节,DMA 搬运时卡住了。记住:显存分配时,stride 和 offset 必须按硬件要求对齐。
2.3 模式设置(Mode-Setting):告诉显示器怎么干活
模式设置,就是配置显示器的分辨率、刷新率、像素时钟、行场同步信号。KMS 驱动负责这件事。你插上一个新显示器,系统怎么知道它支持 1920x1080@60 还是 3840x2160@30?靠 EDID(Extended Display Identification Data)。
EDID 是一段 128 字节的数据,存在显示器的 EEPROM 里。里面写了厂商、分辨率列表、支持的颜色格式。KMS 驱动读 EDID,然后匹配一个最合适的模式。
我建议你记住这个流程:
- 用户态调用
drmModeGetResources()获取连接器(Connector)列表。 - 对每个 Connector,调用
drmModeGetConnector()拿到 EDID。 - 解析 EDID,得到
drmModeModeInfo列表。 - 选一个模式,调用
drmModeSetCrtc()应用。
嗯,这里要注意:不是所有显示器都老实。有些廉价显示器 EDID 写错了,标称支持 60Hz 实际只能跑 50Hz。我遇到过一台显示器,EDID 里写 1920x1080@60,但实际像素时钟差了 0.5MHz,画面一直闪。最后我手动指定了一个 CEA 标准模式才搞定。
警告:不要在生产环境里硬编码模式参数。EDID 可能不准,但硬编码更危险——换一台显示器就黑屏。正确的做法是:优先用 EDID 推荐模式,如果不行再 fallback 到用户指定的模式。
2.4 CRTC 与 Encoder 模型:显示管线的核心
CRTC(Cathode Ray Tube Controller)这个名字很古老,但概念一直沿用。你可以把它理解成「显示扫描引擎」。它负责:
- 从 Framebuffer 里读像素数据
- 按设定的时序(行频、场频、消隐区间)生成信号
- 把数据送给 Encoder
Encoder 呢?它负责把 CRTC 输出的并行数据转换成物理接口需要的信号。比如:
- HDMI Encoder:把 RGB 数据 + 音频打包成 TMDS 信号
- DP Encoder:把数据打包成 SST/MST 流
- LVDS Encoder:用于老式液晶屏
它们的关系是这样的:
你写驱动时,核心就是配好这个管线。DRM 提供了 drm_crtc、drm_encoder、drm_connector 三个结构体,你需要在 probe 阶段把它们注册进去,然后在 mode_set 阶段把 CRTC 和 Encoder 连接起来。
我举个例子,一个典型的 mode_set 回调大概长这样:
static void my_encoder_mode_set(struct drm_encoder *encoder,
struct drm_display_mode *mode,
struct drm_display_mode *adjusted_mode)
{
struct my_device *dev = encoder->dev->dev_private;
// 1. 设置像素时钟
my_set_pixel_clock(dev, mode->clock * 1000);
// 2. 设置行场同步参数
my_set_timing(dev, mode->htotal, mode->vtotal,
mode->hsync_start, mode->hsync_end,
mode->vsync_start, mode->vsync_end);
// 3. 配置 Encoder 输出格式(比如 RGB 还是 YUV)
my_set_output_format(dev, MY_FORMAT_RGB888);
// 4. 启动输出
my_encoder_enable(encoder);
}
你看,代码本身不复杂。但坑往往藏在细节里——比如 adjusted_mode 和 mode 有什么区别?adjusted_mode 是经过 CRTC 硬件限制调整后的模式。如果你的 CRTC 不支持某个时钟,DRM 会帮你微调,然后传给你 adjusted_mode。你写驱动时,一定要用 adjusted_mode 去配硬件,别用原始的 mode。
经验之谈:我早期写驱动时,直接拿 mode 去配时钟,结果 1920x1080@60 在某个 CRTC 上死活出不来画面。后来发现那个 CRTC 的 PLL 分频器有约束,DRM 已经帮我算好了 adjusted_mode,但我没用。从那以后,我每次写 mode_set 回调,第一行就是 drm_mode_debug_printmodeline(adjusted_mode),先看一眼调整后的参数对不对。
2.5 总结:记住这三件事
显示驱动核心,说白了就是三件事:
- 管好显存——Framebuffer 分配、对齐、双缓冲/三缓冲策略。
- 配好模式——读 EDID、选分辨率、设时钟、同步参数。
- 连好管线——CRTC 负责扫,Encoder 负责编,Connector 负责出。