1. 从一块点不亮的LCD说起:为什么我要啃DRM这套框架
第一次接触LCD驱动,很多人是从单片机裸机点屏开始的。初始化时序、写寄存器、刷GRAM,一套流程走下来,屏幕能亮、能显示色块,就觉得"驱动不过如此"。等到把同样的屏接到Linux板子上,用上DRM这套框架,才发现事情完全不是一回事:屏幕是亮了,但分辨率不对、方向反了、刷新率上不去、应用层拿不到buffer,一堆问题冒出来,翻遍文档也找不到头绪。
问题的根源在于,Linux下的显示子系统不是"写寄存器点屏"这么简单,它是一整套分层的、面向多应用共享的图形显示架构。DRM(Direct Rendering Manager)就是这套架构的核心,而KMS(Kernel Mode Setting)和GEM(Graphics Execution Manager)是DRM里最常被提起、也最容易被混淆的两个机制。KMS管的是"显示模式设置"——分辨率、时序、图层合成、CRTC与连接器的绑定;GEM管的是"显存管理"——buffer怎么分配、怎么在CPU和GPU之间共享、怎么跨进程传递。
我写这篇东西,是因为在实际调试一块MIPI DSI接口的IPS屏时,被"竖屏改横屏"这个需求卡了整整两天。表面上看只是旋转90度,实际上牵扯到KMS的plane旋转属性、GEM buffer的stride对齐、以及应用层对DRM API的调用方式。把这些串起来之后,我才算真正理解了DRM框架的设计意图。
这篇内容适合三类人:一是刚接触Linux显示驱动、想搞懂DRM到底在干什么的嵌入式工程师;二是做LCD模组、需要理解上层框架如何影响底层时序的硬件同学;三是做应用层图形开发、想搞清楚自己调用的API背后发生了什么的人。我会从框架分层讲起,把KMS和GEM各自的职责、数据结构、调用链路拆开,再结合竖屏改横屏、亮度调节、buffer共享这些实际场景,把踩过的坑和验证过的方法都摊开讲。
2. DRM框架的分层逻辑:谁在管显示,谁在管内存
2.1 从fbdev到DRM:一次架构上的必然演进
早期的Linux显示驱动用的是fbdev(framebuffer device)框架,思路很直接:内核里维护一块显存,应用层通过/dev/fb0映射这块内存,往里写像素,硬件自动扫描输出。这套模型在单应用、单屏幕的场景下够用,但一旦涉及多图层叠加、多应用共享显示、GPU硬件加速,fbdev就撑不住了——它没有图层概念,没有同步机制,没有显存对象的抽象,多个应用抢一块framebuffer必然打架。
DRM的出现就是为了解决这些问题。它把显示相关的功能拆成两大块:一块是模式设置(Mode Setting),也就是KMS,负责决定"屏幕上显示什么、以什么分辨率显示、哪个图层在哪个位置";另一块是显存管理(Memory Management),也就是GEM,负责决定"这些要显示的图像数据存在哪里、怎么分配、怎么共享"。
这个拆分不是拍脑袋定的,而是对应了两个本质不同的问题域。模式设置是"配置态"的,变化频率低,一次设置可以维持很久;显存管理是"数据态"的,每帧都在变,需要高频分配和回收。把两者分开,内核可以针对性地优化:KMS的状态可以缓存和原子提交,GEM的buffer可以复用和跨进程共享。
2.2 KMS的五大对象:CRTC、Encoder、Connector、Plane、Framebuffer
理解KMS,核心是理解它抽象出来的五个对象,这五个对象构成了从显存到屏幕的完整链路。
CRTC(CRT Controller)是最核心的对象,它代表一个扫描输出引擎,负责从显存里读取像素数据,按照配置好的时序(行同步、场同步、消隐区)输出给下游。一块显示控制器通常有多个CRTC,对应多个独立的显示通道。CRTC决定了分辨率、刷新率、时序参数这些"模式"信息。
Encoder是CRTC和Connector之间的转换器。CRTC输出的是并行RGB信号,而实际屏幕可能是MIPI DSI、LVDS、HDMI等不同接口,Encoder负责把并行信号转换成对应接口的格式。一个CRTC可以接多个Encoder,但同一时刻只能激活一个。
Connector代表物理连接器,比如DSI接口、HDMI口。它负责探测屏幕是否插入、读取EDID(Extended Display Identification Data,扩展显示标识数据)获取屏幕支持的分辨率列表,并把可用的显示模式上报给上层。Connector是KMS里唯一直接和物理硬件打交道的对象。
Plane是图层,代表一个可以独立显示和定位的图像层。每个CRTC至少有一个primary plane(主图层,必须显示),还可以有多个overlay plane(叠加图层)和cursor plane(光标图层)。Plane的存在让硬件合成成为可能——多个图层在硬件层面叠加,不需要GPU参与,省电且高效。
Framebuffer是KMS对一块显存的抽象,它描述了一块内存的格式(像素格式、宽高、stride)以及关联的GEM对象。Framebuffer是KMS和GEM的交汇点:KMS用framebuffer来描述"要显示什么",而framebuffer底层指向的正是GEM分配的buffer。
这五个对象的关系可以用一句话概括:Framebuffer提供像素数据,Plane决定数据在屏幕上的位置和层级,CRTC负责扫描输出,Encoder转换信号格式,Connector对接物理屏幕。任何一次显示模式的设置,本质上就是把这五个对象按正确的顺序绑定起来。
2.3 GEM的核心抽象:handle、object与dma-buf
GEM这一侧相对简单,它的核心抽象是GEM object,代表一块可以被GPU和显示控制器访问的显存。每个GEM object有一个全局唯一的name,以及一个进程内的handle。handle是进程私有的,name是全局的,这个设计是为了支持跨进程共享——A进程通过name把object导出,B进程通过name导入,各自拿到自己进程内的handle。
GEM object最关键的能力是dma-buf导出。dma-buf是内核提供的跨设备、跨进程buffer共享机制,GEM object可以导出一个dma-buf文件描述符,这个fd可以传给其他驱动(比如V4L2摄像头驱动、GPU驱动)或者传给其他进程。这是现代Linux图形栈实现零拷贝的基础。
GEM本身不规定显存怎么分配,它只规定接口。具体的分配策略由各个DRM驱动自己实现,比如有的驱动用CMA(Contiguous Memory Allocator)分配连续内存,有的用shmem,有的用TTM(Translation Table Manager)做显存迁移。这也是为什么不同平台的GEM行为会有差异,调试时需要看具体驱动的实现。
2.4 KMS与GEM如何协作:一次完整的显示提交
把KMS和GEM串起来看,一次完整的显示提交大致是这样的流程:
- 应用层通过GEM接口分配一个buffer,得到handle;
- 应用层把像素数据写入buffer(可能是CPU直接写,也可能是GPU渲染);
- 应用层创建一个framebuffer,关联这个GEM handle,指定像素格式和尺寸;
- 应用层通过KMS的atomic接口,把framebuffer绑定到某个plane,plane绑定到CRTC,CRTC绑定到Connector;
- 提交atomic请求,内核校验参数合法性,驱动配置硬件寄存器;
- 硬件开始扫描输出,屏幕显示内容。
这个流程里,GEM负责"数据从哪来",KMS负责"数据怎么显示"。两者通过framebuffer这个对象衔接。理解了这个衔接点,很多调试问题就有了排查方向:如果屏幕花屏,可能是GEM buffer的stride不对;如果屏幕不亮,可能是KMS的CRTC没绑定对;如果方向反了,可能是plane的旋转属性没设置。
3. KMS模式设置的完整链路:从应用调用到寄存器生效
3.1 legacy接口与atomic接口:为什么新代码都该用atomic
KMS的API经历过一次重大演进。早期是legacy接口,用drmModeSetCrtc、drmModeSetPlane这类函数,每次调用只改一个对象的状态,改完立即生效。这套接口的问题是:多个对象的状态变更无法保证原子性,中间态可能被硬件扫描到,导致屏幕闪烁;而且没有回滚机制,一旦某个对象设置失败,前面的设置已经生效了,状态就乱了。
atomic接口解决了这些问题。它的核心思想是:把所有对象的状态变更打包成一个atomic commit,内核先做一次完整的合法性校验(check阶段),全部通过后才真正写入硬件(commit阶段)。校验不通过就整体拒绝,硬件状态不变。这套机制还支持非阻塞提交和fence同步,是现代显示驱动的标准做法。
实际写代码时,atomic的调用模式是:先用drmModeAtomicAlloc分配一个请求对象,用drmModeAtomicAddProperty逐个添加属性变更,然后调drmModeAtomicCommit提交。属性是KMS里所有可变状态的统一抽象,每个对象都有一组属性,比如CRTC有ACTIVE、MODE_ID,plane有FB_ID、CRTC_ID、SRC_X、SRC_Y、SRC_W、SRC_H、CRTC_X、CRTC_Y、CRTC_W、CRTC_H、rotation等。
提示:调试atomic提交失败时,把
DRM_CLIENT_CAP_ATOMIC打开后,内核日志会打印具体哪个属性校验失败,这是定位问题最快的方式。
3.2 属性系统:KMS里所有可变状态的统一入口
KMS的属性系统是理解atomic接口的关键。每个DRM对象(CRTC、plane、connector、encoder)都有一组属性,属性有类型(整型、枚举、blob、bitmask等),有取值范围,有是否可变的标志。应用层通过属性ID来操作,而不是直接调函数。
以plane的旋转为例,rotation属性是一个bitmask,支持的值有DRM_MODE_ROTATE_0、DRM_MODE_ROTATE_90、DRM_MODE_ROTATE_180、DRM_MODE_ROTATE_270,以及DRM_MODE_REFLECT_X、DRM_MODE_REFLECT_Y。设置旋转时,需要先通过drmModeObjectGetProperties拿到plane的属性列表,找到rotation属性的ID,然后在atomic请求里添加这个属性的变更。
属性系统的好处是统一和可扩展。新增功能只需要加一个属性,不需要改API。但代价是应用层代码变复杂了,需要先查询属性ID,再操作。实际项目中,通常会封装一层属性缓存,把常用属性的ID缓存起来,避免每次都查询。
3.3 CRTC与Connector的绑定:模式设置的核心步骤
模式设置的核心,是把CRTC和Connector绑定起来,并给CRTC设置一个显示模式。在atomic接口下,这个操作对应三个属性变更:
- Connector的
CRTC_ID属性设置为目标CRTC的ID; - CRTC的
MODE_ID属性设置为目标模式的blob ID; - CRTC的
ACTIVE属性设置为1。
模式(mode)是一个blob属性,包含时钟频率、水平时序(hdisplay、hsync_start、hsync_end、htotal)、垂直时序(vdisplay、vsync_start、vsync_end、vtotal)、标志位等信息。这些参数决定了屏幕的刷新率和时序,必须和屏幕规格书严格匹配,否则会花屏或者不亮。
获取模式的方式有两种:一是通过Connector的EDID自动获取,二是手动构造。对于MIPI DSI屏幕,很多模组不带EDID,需要驱动里硬编码模式参数,或者通过设备树配置。手动构造模式时,时序参数的计算要特别注意,尤其是消隐区(blanking)的设置,太小会导致信号不稳定,太大又浪费带宽。
3.4 Plane的配置:图层位置、缩放与旋转
Plane的配置是KMS里最灵活也最容易出错的部分。一个plane的配置涉及四组坐标:
SRC_X、SRC_Y、SRC_W、SRC_H:源区域,指定从framebuffer的哪个位置取多大一块;CRTC_X、CRTC_Y、CRTC_W、CRTC_H:目标区域,指定这块内容显示在屏幕的哪个位置、缩放成多大。
这里有个容易踩的坑:SRC_W和SRC_H是16.16定点数格式,也就是实际像素值左移16位。比如要取100像素宽,要写100 << 16。而CRTC_W和CRTC_H是普通整数。这个格式差异如果不注意,会导致取源区域时取错位置,显示出来就是花屏或者只显示一部分。
旋转是通过rotation属性设置的。但要注意,不是所有plane都支持硬件旋转,需要先查询plane的rotation属性的有效值范围。如果硬件不支持旋转,应用层只能自己旋转像素数据,或者用GPU旋转,这会增加功耗和延迟。
3.5 一次竖屏改横屏的完整调试记录
回到我开头提到的竖屏改横屏问题。屏幕是1.8寸TFT LCD,分辨率128x160,物理上是竖屏。但产品需求是横屏显示,也就是要旋转90度。
我的第一反应是在应用层旋转像素数据,但这样每帧都要CPU搬运,功耗高、帧率低。于是转向硬件旋转方案。排查过程是这样的:
第一步,确认plane是否支持旋转。通过drmModeObjectGetProperties查询plane的rotation属性,发现有效值包含DRM_MODE_ROTATE_90,说明硬件支持。
第二步,在atomic请求里设置rotation为DRM_MODE_ROTATE_90。提交后屏幕确实旋转了,但显示内容被裁剪了——只显示了部分区域。
第三步,分析裁剪原因。旋转90度后,源区域的宽高和目标区域的宽高需要对调。原来竖屏时SRC_W=128、SRC_H=160,旋转后应该变成SRC_W=160、SRC_H=128,同时CRTC_W和CRTC_H也要对调。我一开始没改这些参数,导致取源区域时超出了framebuffer边界。
第四步,修正参数后显示正常,但发现刷新率上不去。查驱动发现,旋转后的stride对齐要求变了。原来128宽按128字节对齐,旋转后160宽需要按256字节对齐,否则硬件读取效率低。调整GEM buffer的stride后,刷新率恢复正常。
这个案例说明,KMS的旋转不是简单的"转一下",它牵扯到源/目标区域的对调、stride的对齐、以及硬件能力的边界。调试时要把这些参数当成一个整体来看,改一个就要检查其他几个。
4. GEM显存管理的实战细节:分配、共享与同步
4.1 GEM object的创建与映射:dumb buffer与普通buffer的区别
GEM创建buffer有两种常见方式:dumb buffer和普通GEM buffer。dumb buffer是通过DRM_IOCTL_MODE_CREATE_DUMB创建的,它只支持线性格式,不支持tiling,主要用于简单的framebuffer场景。普通GEM buffer通过驱动的私有ioctl创建,支持各种格式和tiling,但接口不统一,不同驱动不一样。
dumb buffer的好处是跨驱动通用,坏处是性能一般。对于LCD这种对性能要求不高的场景,dumb buffer够用。但如果要做GPU渲染或者视频播放,就需要用普通GEM buffer,配合dma-buf做零拷贝。
创建dumb buffer时,需要指定宽、高、bpp(每像素位数)。内核会根据这些参数计算pitch(每行字节数),pitch通常会做对齐,比如按64字节或256字节对齐。这个对齐值很关键,应用层写数据时必须按pitch来算行偏移,不能简单用宽乘以bpp除以8,否则会错位。
映射buffer到用户空间用DRM_IOCTL_MODE_MAP_DUMB,得到一个offset,然后用mmap映射。映射后应用层可以直接读写像素。但要注意,如果buffer同时被硬件扫描输出,应用层写入时可能会有撕裂,需要配合fence或者双缓冲。
4.2 dma-buf:跨进程、跨设备共享buffer的标准方案
dma-buf是GEM最强大的能力。它允许一个GEM object导出成一个文件描述符,这个fd可以传给任何支持dma-buf的驱动或进程。接收方通过dma_buf_attach和dma_buf_map_attachment拿到sg_table(scatter-gather table),从而访问同一块物理内存。
实际应用场景很多。比如摄像头采集(V4L2)和显示(DRM)共享buffer,摄像头把数据写入buffer,显示直接扫描这个buffer,全程零拷贝。再比如GPU渲染和显示共享buffer,GPU渲染完直接显示,不需要CPU搬运。
使用dma-buf时要注意同步问题。生产者和消费者之间需要fence来协调,否则消费者可能读到还没写完的数据。DRM的atomic接口支持in-fence和out-fence,in-fence表示"等这个fence signaled后再提交",out-fence表示"提交完成后signal这个fence"。应用层通过fence实现流水线,避免阻塞。
4.3 stride与格式对齐:花屏问题的常见根源
stride(也叫pitch)是每行像素占用的字节数。由于硬件对齐要求,stride通常大于"宽 × 每像素字节数"。比如128像素宽、RGB565格式(每像素2字节),理论stride是256字节,但硬件可能要求256字节对齐,实际stride就是256;如果要求512字节对齐,stride就是512。
应用层写数据时,必须按实际stride来算行偏移。如果按理论值算,第二行就会写错位置,显示出来就是斜的或者花屏。这个问题在分辨率不是对齐值整数倍时特别容易出。
获取stride的方式:创建dumb buffer后,通过DRM_IOCTL_MODE_MAP_DUMB返回的结构里有pitch字段;或者通过framebuffer的DRM_FORMAT_MOD_LINEARmodifier查询。不同驱动的对齐要求不同,调试时最好打印出来确认。
4.4 显存回收与泄漏排查:谁在持有buffer
GEM object是引用计数的,handle关闭、framebuffer销毁、dma-buf释放都会减引用,引用归零才真正释放。如果某个环节忘了释放,buffer就会泄漏,表现为显存越用越少,最终分配失败。
排查泄漏的方法:一是看/sys/kernel/debug/dri/*/gem_names,列出当前所有GEM object;二是用drm_gem_object_lookup的调试信息;三是在驱动里加日志,记录每次分配和释放。实际项目里,最常见的是framebuffer没销毁就退出,导致关联的GEM object一直挂着。
注意:在atomic接口下,framebuffer的销毁要等所有引用它的plane都解除绑定后才能进行,否则会返回
-EBUSY。正确的顺序是先提交一个把plane的FB_ID设为0的atomic请求,再销毁framebuffer。
5. 那些文档里不会写的调试经验
5.1 屏幕不亮时的分层排查法
屏幕不亮是最常见也最让人抓狂的问题。我的排查顺序是自下而上:
先看背光。背光没开,屏幕再正常也是黑的。检查背光GPIO、PWM占空比、背光使能寄存器。很多模组的背光使能和显示使能是分开的,容易漏。
再看时序。用示波器或者逻辑分析仪量MIPI DSI的时钟和数据线,确认有没有信号。如果没有信号,说明CRTC没输出,问题在KMS配置;如果有信号但屏幕不亮,可能是时序参数不对,或者初始化序列没发。
然后看KMS状态。通过/sys/kernel/debug/dri/*/state查看当前CRTC、plane、connector的绑定状态,确认ACTIVE是否为1,MODE_ID是否有效,FB_ID是否非零。
最后看GEM。确认framebuffer关联的GEM buffer是否真的分配成功,像素数据是否写进去了。可以在应用层读回buffer内容,确认数据正确。
这个顺序的逻辑是:从最底层的硬件信号开始,逐层往上确认,每一层都排除掉,问题自然就定位了。反过来从上往下查,容易在应用层绕圈子。
5.2 刷新率上不去的几个隐藏原因
刷新率上不去,表面看是时序问题,实际原因可能有很多:
一是带宽不够。MIPI DSI的lane数和每lane速率决定了总带宽,分辨率乘以刷新率乘以bpp就是需要的带宽,超过就上不去。计算时要算上消隐区的开销,实际带宽需求比有效像素带宽高20%左右。
二是内存带宽不够。显示控制器读framebuffer要占内存带宽,如果同时有GPU、CPU在抢带宽,显示就可能欠载。这种情况下降低其他模块的带宽占用,或者提高显示控制器的优先级。
三是stride对齐不好。前面提到过,stride不对齐会导致硬件读取效率低,等效带宽下降。把stride调到对齐值,刷新率可能就上去了。
四是plane配置过多。多个plane叠加会增加硬件合成负担,如果硬件合成器能力有限,就会限制刷新率。减少plane数量,或者把一些图层交给GPU合成。
5.3 亮度调节与DRM的关系:为什么不能直接写寄存器
LCD亮度调节通常通过PWM控制背光实现。在DRM框架下,背光被抽象成backlight设备,通过/sys/class/backlight/*/brightness调节。但有些场景下,应用层想直接控制亮度,绕过sysfs。
直接写背光寄存器的问题是:绕过了内核的背光管理,可能导致状态不一致。比如内核认为亮度是50%,实际寄存器被改成了80%,下次内核调节时就会跳变。正确做法是通过DRM的connector的backlight属性,或者通过标准的backlight接口。
如果确实需要在驱动里直接控制,建议实现一个backlight设备,把寄存器操作封装进去,应用层通过标准接口调用。这样既保证了状态一致,又提供了灵活性。
5.4 多屏场景下CRTC资源的分配策略
多屏场景下,CRTC是稀缺资源。一块显示控制器可能只有2个CRTC,但要驱动3个屏幕,就需要时分复用或者动态分配。
分配策略上,优先保证主屏独占一个CRTC,副屏共享或者按需分配。如果副屏不需要同时显示,可以在切换时重新绑定CRTC。如果必须同时显示,就要看硬件是否支持一个CRTC驱动多个Connector(通常不支持,除非是镜像模式)。
镜像模式下,多个Connector绑定同一个CRTC,显示相同内容。扩展模式下,每个Connector需要独立的CRTC。资源不够时,只能降低分辨率或者刷新率来节省带宽,或者用GPU合成多个屏幕的内容到一个CRTC输出。
6. 从框架理解到实际项目:我的几点体会
把KMS和GEM这套框架啃下来之后,我最大的体会是:不要把它当成一堆API来记,而要理解它解决的问题。KMS解决的是"多应用如何共享显示硬件",GEM解决的是"多设备如何共享显存"。理解了这两个问题,再看那些对象和属性,就顺了。
实际项目中,我建议先把最小可用的显示链路跑通:一个CRTC、一个Connector、一个Plane、一个Framebuffer,能显示纯色就行。然后再逐步加功能:旋转、缩放、多图层、dma-buf共享。每加一个功能,都确认前面的还正常,这样出问题时容易定位。
调试工具方面,modetest是必备的,它能列出所有KMS对象和属性,还能直接做模式设置测试。/sys/kernel/debug/dri/下的调试文件也很有用,能看到当前状态和GEM对象列表。内核日志打开DRM的debug级别,能看到atomic提交的详细过程。
最后说一个容易被忽略的点:不同SoC的DRM驱动实现差异很大。同样是KMS和GEM,A平台的plane可能支持硬件旋转,B平台就不支持;A平台的GEM用CMA,B平台用TTM。所以看文档时要看具体平台的,通用文档只能告诉你框架,细节还得看驱动源码。我调试时养成的习惯是,遇到问题先翻驱动源码里对应的atomic_check和atomic_update函数,那里有最准确的硬件能力描述。