1. 从“画布”到“显示器”:DRM框架的通俗理解
如果你玩过单片机或者简单的嵌入式开发,可能对“直接往显存地址写数据”这种操作不陌生。早期的Linux图形显示,也就是FB(Framebuffer)框架,差不多就是这么干的:给你一块内存当画布,你往里面填颜色,硬件就按顺序把它扫出来显示在屏幕上。这就像你一个人在黑板上画画,简单直接。但随着显示需求变得复杂——比如手机需要同时显示桌面、播放视频、还有浮动的小窗——这种“单打独斗”的模式就力不从心了。它没法高效地管理多层画面,也缺乏现代的同步机制,容易导致画面撕裂。
这时候,DRM(Direct Rendering Manager,直接渲染管理器)就登场了。你可以把它想象成一个专业的“展览馆管理系统”。这个系统不再只有一块黑板,而是有一个完整的策展团队。KMS(内核模式设置)就是这个团队里的“现场调度”和“灯光师”,负责决定最终在屏幕上展示什么画面(合成多个图层)、以什么格式展示(分辨率、刷新率),以及何时切换画面(同步)。而GEM(图形执行管理器)则是团队里的“物料仓库管理员”,专门负责管理所有用来作画的“画布”(显存)——包括它们的申请、存放、在不同“画家”(比如GPU和显示控制器)之间传递,以及确保大家不会同时修改同一块画布而产生混乱。
我刚开始接触DRM时,也被一堆术语吓到过:CRTC、Plane、Encoder、Fence……但当你把它们放到“展览馆”这个场景里,一切就清晰多了。这篇文章,我就结合自己在RK3568等平台上的调试经验,带你深入DRM框架的腹地,把KMS和GEM这两个核心模块掰开揉碎了讲明白。我们会避开枯燥的理论堆砌,用实际的代码片段和踩过的坑,让你不仅能理解它们“是什么”,更能知道在驱动开发中“怎么用”。
2. KMS:内核中的“显示指挥中心”
KMS,全称Kernel Mode-Setting,是DRM框架中负责一切与显示输出相关设置的模块。所谓“Mode-Setting”,直译就是“模式设置”,这包括了从分辨率和刷新率到电源状态的所有事情。在FB时代,很多这类设置需要用户空间的X Server等组件参与,过程复杂且容易出问题。KMS则将这些权力彻底收归内核,实现了原子化的、无闪烁的模式切换,这也是其名字中“内核模式”的由来。
2.1 KMS的核心成员:CRTC、Plane、Encoder与Connector
要指挥一场展览,你需要明确的分工。KMS的核心对象就是这些分工明确的角色。
CRTC(Cathode Ray Tube Controller,显示控制器):这是整个显示管道的“发动机”。尽管名字源于古老的显像管,但现在它抽象的是SoC内部的显示控制器硬件,比如Rockchip平台的VOP(Video Output Processor)模块。它的核心职责是扫描。想象CRTC是一个高速、精准的扫描仪,它按照设定好的时序(比如1920x1080@60Hz),从一块或多块内存(Framebuffer)中读取像素数据,生成对应的行同步、场同步和像素时钟信号。在代码里,一个struct drm_crtc对象就代表一个显示控制器。我调试时最常打交道的crtc_funcs中的几个关键回调:
mode_set: 设置显示模式(分辨率、刷新率)。这是配置时序的核心。page_flip: 在垂直消隐期(VBlank)安全地切换前台显示的Framebuffer,这是实现无撕裂翻页的关键。enable/disable: 开启或关闭显示输出。
Plane(图层):这是连接“画布”(Framebuffer)和“扫描仪”(CRTC)的纽带。一个Plane代表一个独立的硬件图层。现代显示控制器通常支持多个Overlay Plane(叠加层)和一个Primary Plane(主层)。比如,你可以用Primary Plane显示桌面背景,用一个Overlay Plane播放视频,再用另一个Overlay Plane显示鼠标光标。在drm_plane_funcs中,update_plane或atomic_update回调函数负责配置这个图层要显示哪块Framebuffer、在屏幕的什么位置(x, y坐标)、以及如何缩放。这里有个坑我踩过:不是所有硬件都支持任意位置的缩放或颜色格式转换,在驱动里需要检查并设置drm_plane的format_modifiers和supported_rotations等属性,否则用户空间用了不支持的配置,画面就出不来。
Encoder(编码器)与Connector(连接器):这两者经常被放在一起理解。CRTC生成了标准的RGB时序信号,但最终要送到千差万别的显示设备上(HDMI显示器、LVDS屏、MIPI-DSI屏),需要不同的物理信号。Encoder就是“翻译官”,负责将CRTC输出的通用时序“编码”成特定接口的信号,比如转换成HDMI的TMDS信号或者MIPI-DSI的数据包。Connector则代表物理接口本身(如HDMI插座)及其连接的显示设备。它负责探测设备是否接入(热插拔)、读取设备的EDID信息以获取其支持的分辨率列表。在驱动中,我们通常需要实现一个struct drm_encoder_helper_funcs,在其中的mode_set回调里,配置具体的物理编码器芯片,使其工作模式与CRTC的时序匹配。
一个简单的工作流示例:当用户插入一个HDMI显示器时,Connector探测到热插拔事件,读取EDID;用户空间通过libdrm选择一个合适的分辨率(Mode)提交给KMS;KMS内核驱动会依次设置CRTC的时序(crtc->mode_set)、设置各Plane的Framebuffer和位置、最后设置Encoder将此时序转换为HDMI信号(encoder->mode_set)。这一切准备就绪后,使能CRTC,画面就开始稳定输出了。
2.2 Atomic Mode-Setting:为什么它是现代显示的基石
早期的KMS API(被称为“Legacy”或“Non-Atomic”)允许程序分别设置CRTC、Plane等属性。这听起来灵活,却带来了大问题:如果设置CRTC模式和设置Plane的Framebuffer不是原子操作(即不可分割),可能在中间态出现屏幕闪烁、撕裂甚至黑屏。
Atomic Mode-Setting(原子模式设置)彻底解决了这个问题。它的思想是:将所有对显示状态的修改(改分辨率、切换图层、调整色彩属性)打包成一个“原子提交”(Atomic Commit)。内核会先验证这个包里所有更改的合法性和一致性,如果全部通过,则在一次VBlank中断期间,将所有更改原子化地应用到硬件上。对用户来说,显示状态的变化是瞬间完成的,没有中间态。
在驱动层面,这意味着我们需要实现drm_crtc_funcs、drm_plane_funcs等的atomic_系列回调,例如atomic_check和atomic_update。
atomic_check: 这是“预演”阶段。用户空间提交的配置包会先经过这里,驱动需要检查硬件是否支持这些配置的组合(比如某个Plane是否支持想要的缩放倍数和像素格式)。如果检查失败,整个提交都会被拒绝,不会影响当前显示。atomic_update: 这是“正式演出”阶段。当所有检查通过,且到了该应用更改的时机(如下一个VBlank),这个函数被调用,将配置真正写入硬件寄存器。
static const struct drm_plane_funcs my_plane_funcs = { .update_plane = drm_atomic_helper_update_plane, // 传统接口,可选 .disable_plane = drm_atomic_helper_disable_plane, .destroy = drm_plane_cleanup, .reset = drm_atomic_helper_plane_reset, .atomic_duplicate_state = drm_atomic_helper_plane_duplicate_state, .atomic_destroy_state = drm_atomic_helper_plane_destroy_state, .atomic_get_property = my_plane_atomic_get_property, .atomic_set_property = my_plane_atomic_set_property, }; static const struct drm_plane_helper_funcs my_plane_helper_funcs = { .atomic_check = my_plane_atomic_check, // 重点:原子检查 .atomic_update = my_plane_atomic_update, // 重点:原子更新 };从用户空间看,使用libdrm的drmModeAtomicCommit接口进行提交,并可以指定DRM_MODE_ATOMIC_ALLOW_MODESET等标志位。强烈建议所有新的显示驱动和应用程序都基于Atomic API开发,这是确保稳定、可靠显示体验的基石。
3. GEM:显存管理的艺术与科学
如果说KMS负责指挥“显示什么”,那么GEM就负责管理“用什么来显示”——也就是图形缓冲区(Graphic Buffer),我们常说的显存。在复杂的图形系统里,CPU、GPU、显示控制器(CRTC)可能都需要访问同一块图像数据。如何高效、安全地分配、共享和同步这些内存,就是GEM要解决的核心问题。
3.1 从DUMB到PRIME:显存抽象的演进
DUMB Buffer:这是GEM提供的最简单的一种缓冲区。称之为“Dumb”(哑的),是因为它只是一块普通的、连续的内存区域,不支持硬件加速特性(如扫描行偏移tiling、压缩),也没有专门的硬件单元管理它。你通过DRM_IOCTL_MODE_CREATE_DUMB申请它,得到的是一个句柄(handle)和物理地址信息,然后可以映射到用户空间进行CPU读写。它非常适合简单的帧缓冲需求,或者在没有GPU的纯显示系统上使用。但它的局限也很明显:效率不高,且难以在多个设备间高效共享。
PRIME:这是GEM框架下解决缓冲区跨设备共享的现代化方案。它的名字是“PRoposal for Improved Memory sharing for Embedded/Enterprise”的缩写。PRIME的核心思想是基于DMA-BUF。DMA-BUF是Linux内核中一个用于在不同驱动子系统间共享DMA缓冲区的通用框架。
PRUME的工作流程是这样的:
- 导出:比如,GPU驱动创建了一块缓冲区,它可以调用
drm_gem_prime_export将其导出为一个DMA-BUF文件描述符(fd)。 - 导入:显示控制器驱动需要用到这块缓冲区,它拿到这个fd,调用
drm_gem_prime_import,就能根据fd创建出一个本地的GEM对象(drm_gem_object),并获取到这块内存的物理地址(通常是经过IOMMU映射后的DMA地址),从而配置CRTC/Plane去显示它。
这个过程实现了零拷贝共享。GPU渲染好的画面,其缓冲区可以直接交给显示控制器去扫描输出,数据不需要在内存中来回搬运,极大提升了性能。在Android、Wayland等现代图形栈中,PRIME是基石。
// 在驱动中实现PRIME的回调函数集 static const struct drm_driver my_drm_driver = { ... .prime_handle_to_fd = drm_gem_prime_handle_to_fd, .prime_fd_to_handle = drm_gem_prime_fd_to_handle, .gem_prime_export = drm_gem_prime_export, // 通常用helper即可 .gem_prime_import = drm_gem_prime_import, // 通常用helper即可 .gem_prime_get_sg_table = my_gem_prime_get_sg_table, // 可能需要自定义 .gem_prime_import_sg_table = my_gem_prime_import_sg_table, .gem_prime_vmap = my_gem_prime_vmap, .gem_prime_vunmap = my_gem_prime_vunmap, .gem_prime_mmap = drm_gem_prime_mmap, };3.2 Fence:让异步世界井然有序的“围栏”
在现代图形流水线中,操作是高度并行的:GPU可能在渲染下一帧,而显示控制器正在扫描输出当前帧,视频解码器又在解码新的图像。如果GPU还没画完,显示控制器就去扫描这块缓冲区,就会看到半成品画面(撕裂或乱码)。Fence(围栏)就是用来解决这种跨硬件、跨时间同步问题的机制。
Fence是一个简单的同步原语,你可以把它想象成一个“信号旗”。生产者(如GPU)在开始一项任务(渲染)时,创建一个Fence。任务完成后,它“发出信号”(signal)这个Fence。消费者(如显示控制器的Plane)在开始使用缓冲区之前,会“等待”(wait)这个Fence变为已信号状态。只有等到了,才说明缓冲区里的数据是准备就绪的,可以安全使用。
在DRM驱动里,Fence通常与Plane的页面翻转(page flip)或Atomic更新紧密结合。当用户空间提交一个包含新Framebuffer的Atomic Commit时,这个Framebuffer可能关联着一个或多个来自GPU的Fence(in_fence_fd)。驱动在atomic_check阶段需要接收这些Fence,在atomic_update阶段,需要将它们传递给硬件(如果硬件支持等待Fence)或者在内核中通过软件等待。同样,当显示控制器完成一帧的扫描后,驱动也可以创建一个新的Fence(out_fence_ptr)返回给用户空间,告知GPU“这块缓冲区我已经用完了,你可以回收或重新渲染了”。
static int my_plane_atomic_check(struct drm_plane *plane, struct drm_plane_state *new_state) { // 检查新的plane状态 ... // 处理输入的fence if (new_state->fence) { // 可以将fence传递给硬件队列,或存储起来在update时等待 my_store_hw_fence(new_state->fence); } // 为输出fence分配资源 if (new_state->out_fence_ptr) { ret = my_create_out_fence(new_state); if (ret) return ret; } return 0; }忽略Fence同步是驱动开发中一个常见的错误来源,会导致随机性的花屏或系统锁死。理解并正确实现Fence,是构建健壮图形系统的关键一步。
4. 实战:在驱动中整合KMS与GEM
理论说得再多,不如动手写一行代码。我们以一个简化的虚拟显示驱动为例,看看KMS和GEM是如何协同工作的。假设我们有一个虚拟的显示控制器,它支持一个Primary Plane,输出到虚拟的HDMI Connector。
4.1 初始化与对象创建
驱动的初始化流程需要按顺序创建KMS对象,并将它们关联起来。
static int my_drm_bind(struct device *dev) { struct drm_device *drm_dev; struct my_private *priv; struct drm_plane *primary_plane; struct drm_crtc *crtc; struct drm_encoder *encoder; struct drm_connector *connector; int ret; // 1. 分配并初始化DRM设备 drm_dev = drm_dev_alloc(&my_drm_driver, dev); priv = kzalloc(sizeof(*priv), GFP_KERNEL); drm_dev->dev_private = priv; // 2. 初始化GEM相关(如果驱动需要管理自己的显存) drm_dev->driver->gem_prime_res_obj = &my_gem_object_funcs; // 3. 创建CRTC ret = my_crtc_init(drm_dev, &crtc); if (ret) goto err; // 4. 创建Primary Plane,并与CRTC关联 // 注意:在Atomic API下,通常先创建Plane,再创建CRTC,因为CRTC初始化时需要知道可用的Plane。 // 这里为流程清晰做了简化。 ret = drm_plane_init(drm_dev, primary_plane, BIT(DRM_PLANE_TYPE_PRIMARY), &my_plane_funcs, my_supported_formats, // 支持的像素格式数组 my_supported_modifiers, // 支持的修饰符(如tiling) NULL, DRM_PLANE_TYPE_PRIMARY, NULL); if (ret) goto err; // 5. 创建Encoder ret = my_encoder_init(drm_dev, &encoder); if (ret) goto err; // 将Encoder与CRTC可能支持的模式进行关联(encoder->possible_crtcs) encoder->possible_crtcs = drm_crtc_mask(crtc); // 6. 创建Connector ret = my_connector_init(drm_dev, &connector); if (ret) goto err; // 将Connector与Encoder关联 drm_connector_attach_encoder(connector, encoder); // 7. 注册并启动DRM设备 ret = drm_dev_register(drm_dev, 0); if (ret) goto err; return 0; err: // 错误处理,清理资源 ... }4.2 实现一个简单的Atomic Plane更新
让我们深入看一下my_plane_atomic_update函数可能的样子。这个函数负责将软件状态(drm_plane_state)写入硬件。
static void my_plane_atomic_update(struct drm_plane *plane, struct drm_plane_state *old_state) { struct my_private *priv = plane->dev->dev_private; struct drm_plane_state *state = plane->state; struct drm_framebuffer *fb = state->fb; struct my_gem_object *my_gem_obj; dma_addr_t dma_addr; // 如果没有有效的Framebuffer(例如plane被禁用),则关闭硬件图层 if (!fb) { my_hw_disable_plane(priv, plane->index); return; } // 1. 从GEM对象中获取物理地址 // fb->obj[0] 是底层的GEM对象 my_gem_obj = to_my_gem_object(fb->obj[0]); dma_addr = my_gem_obj->dma_addr; // 2. 处理Fence同步(等待生产者完成) if (state->fence) { // 简单示例:阻塞等待。实际硬件可能支持非阻塞等待。 dma_fence_wait(state->fence, false); dma_fence_put(state->fence); state->fence = NULL; } // 3. 配置硬件寄存器 // 设置显存基地址(可能需要加上偏移量 fb->offsets[0]) my_hw_set_plane_base(priv, plane->index, dma_addr + fb->offsets[0]); // 设置显示位置和大小 my_hw_set_plane_position(priv, plane->index, state->crtc_x, state->crtc_y, state->crtc_w, state->crtc_h); // 设置源缓冲区大小和裁剪(如果需要) my_hw_set_plane_src(priv, plane->index, state->src_x >> 16, state->src_y >> 16, // 定点数转换 state->src_w >> 16, state->src_h >> 16); // 设置像素格式 my_hw_set_plane_format(priv, plane->index, fb->format->format); // 4. 使能硬件图层 my_hw_enable_plane(priv, plane->index); }这个例子省略了很多错误处理和硬件细节,但它清晰地展示了流程:从Atomic状态中获取Framebuffer,通过GEM对象找到物理内存,处理同步Fence,最后将参数写入硬件。在实际项目中,你还需要仔细处理颜色空间转换、alpha混合、旋转缩放等更复杂的特性。
调试这样的驱动,我习惯使用drm_debugfs,特别是/sys/kernel/debug/dri/0/state这个文件,它能以文本形式dump出整个DRM设备的当前状态,包括所有CRTC、Plane、Encoder、Connector的属性和状态,是定位问题不可或缺的工具。另一个实用技巧是使用modetest这个libdrm自带的测试工具,它可以绕过复杂的图形合成器,直接测试驱动的KMS和GEM功能是否正常,是驱动开发者的“瑞士军刀”。