1. 从一块黑屏说起:为什么嵌入式Linux图形显示值得单独拎出来讲
做嵌入式开发的人,迟早会撞上"屏幕不亮"这件事。板子上电,串口有打印,系统起来了,ping 得通,ssh 进得去,但屏幕就是黑的——或者花屏、闪屏、只显示半屏、颜色反了。这时候你去查资料,会发现网上讲 Linux 图形显示的文章要么停留在"FrameBuffer 就是 /dev/fb0"这种一句话水平,要么直接跳到 DRM/KMS 内核源码里出不来。中间那一大段"从硬件到像素到底经过了什么"的链路,恰恰是嵌入式工程师真正卡住的地方。
这篇东西就是想把这条链路讲透。它适合谁?适合已经能在嵌入式板子上跑起 Linux、会用串口和 ssh、但一碰到显示问题就抓瞎的人;也适合做嵌入式 Linux 应用开发、需要往屏幕上画 UI 但不想被底层坑住的人;还适合准备嵌入式面试、被问到"FrameBuffer 和 DRM 有什么区别"时只能背八股的人。我不会只给你结论,而是把每一层的职责、数据怎么流动、出问题该去哪一层查,都摊开讲。
先给一个全局的认知框架,后面所有内容都挂在这个框架上。一块嵌入式板子要把图像显示到屏幕上,数据大致走这么一条路:应用程序准备好像素数据,通过某种接口(FrameBuffer 的 mmap、DRM 的 dumb buffer、GPU 的渲染命令)交给内核里的显示子系统,内核驱动再把这些数据配置给显示控制器(Display Controller,很多 SoC 里叫 LCDC、DPU、VOP 之类),显示控制器按照时序把像素一行一行推给物理接口(RGB 并口、MIPI DSI、LVDS、HDMI),最后到达屏幕面板点亮像素。这条链路上任何一环断了,你看到的都是黑屏或者异常画面。
理解这条链路的意义在于:排查显示问题本质上就是沿着这条链路逐段确认。屏幕不亮,先确认背光和时序有没有出来,再确认显示控制器有没有在跑,再确认内核驱动有没有正确初始化,最后才怀疑应用层。反过来,如果画面出来了但内容不对,那问题多半在应用层的数据准备或者格式配置上。有了这个分层思路,你查问题就不会像无头苍蝇一样到处乱撞。
接下来我会按"FrameBuffer 这一层到底做了什么""DRM/KMS 为什么取代了它""设备树里那些显示节点怎么配""从零点亮一块屏的完整实操""常见故障的排查链路"这几个角度展开。每一部分都会结合我在实际项目里踩过的坑来讲,尽量让你看完能直接上手。
2. FrameBuffer:最朴素的"显存映射"思路,以及它的天花板
2.1 /dev/fb0 背后到底是什么
FrameBuffer 是 Linux 图形显示里最古老也最直观的抽象。它的核心思想简单到一句话就能说清:内核把显示控制器里那块用于存放当前帧图像的显存,映射成一个字符设备文件 /dev/fb0,应用程序通过 mmap 把它映射到自己的地址空间,然后往这块内存里写像素,显示控制器就会自动把内容刷到屏幕上。没有复杂的命令队列,没有 GPU,没有图层合成,就是一块内存加一个自动扫描的硬件。
这个模型之所以在嵌入式领域流行了这么多年,是因为它足够简单、足够省资源。一块 480x272 的 RGB 屏,16 位色深,需要的显存是 480 × 272 × 2 = 261120 字节,也就是 255KB 左右。这点内存对现在的 SoC 来说毫无压力,甚至可以直接放在片内 SRAM 里。应用程序要画图,无非就是算出像素在显存里的偏移,然后写进去。画一个点、画一条线、填充一个矩形,全都是纯内存操作,不涉及任何硬件交互,速度取决于内存带宽。
用代码感受一下这个流程。打开设备、获取可变屏幕信息、映射显存,三步走:
#include <linux/fb.h> #include <sys/mman.h> #include <fcntl.h> #include <sys/ioctl.h> int fd = open("/dev/fb0", O_RDWR); struct fb_var_screeninfo vinfo; ioctl(fd, FBIOGET_VSCREENINFO, &vinfo); long screensize = vinfo.yres_virtual * vinfo.xres_virtual * vinfo.bits_per_pixel / 8; char *fbp = (char *)mmap(0, screensize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // 在坐标 (x, y) 画一个红色像素(假设 32bpp,ARGB8888) long location = (x + vinfo.xoffset) * (vinfo.bits_per_pixel / 8) + (y + vinfo.yoffset) * vinfo.xres_virtual * (vinfo.bits_per_pixel / 8); unsigned int red = 0x00FF0000; *((unsigned int *)(fbp + location)) = red;这段代码里有个细节值得单独说:计算像素偏移时用的是 xres_virtual 而不是 xres。很多人第一次写 FrameBuffer 程序,直接用 xres 去算行偏移,结果画面出现斜着的撕裂或者错位。原因是虚拟分辨率(virtual resolution)可能比可见分辨率(visible resolution)大,硬件扫描一行是按虚拟宽度走的。这个坑我在早期项目里踩过,当时调了半天以为是时序问题,最后发现是行偏移算错了。
2.2 双缓冲与页面翻转:FrameBuffer 也能防撕裂
单缓冲的 FrameBuffer 有个天然缺陷:应用程序正在往显存里写下一帧的时候,显示控制器可能正好扫描到这块区域,屏幕上就会出现"上半部分是旧画面、下半部分是新画面"的撕裂现象。解决思路是双缓冲——准备两块显存,一块正在显示,一块用来绘制,画完再切换。
FrameBuffer 子系统通过FBIOPAN_DISPLAY这个 ioctl 支持页面翻转。你把 yoffset 设置成 0 或者 yres,就相当于告诉硬件"从显存的第 0 行开始扫描"或者"从第 yres 行开始扫描"。配合FBIO_WAITFORVSYNC等待垂直同步信号,就能实现无撕裂的动画:
struct fb_var_screeninfo vinfo; ioctl(fd, FBIOGET_VSCREENINFO, &vinfo); // 绘制到第二块缓冲区 vinfo.yoffset = vinfo.yres; ioctl(fd, FBIOPAN_DISPLAY, &vinfo); // 等待垂直同步,避免撕裂 int zero = 0; ioctl(fd, FBIO_WAITFORVSYNC, &zero);这里要注意,不是所有 FrameBuffer 驱动都实现了 FBIOPAN_DISPLAY 和 FBIO_WAITFORVSYNC。有些简单的驱动只支持单缓冲,你调用这些 ioctl 会返回错误。所以在实际项目里,如果要做流畅动画,得先确认驱动支持哪些能力,不能想当然。
2.3 FrameBuffer 的局限:为什么它逐渐被淘汰
FrameBuffer 简单是优点,也是它的天花板。它的问题主要有三个。
第一,它假设只有一块画布。现代显示需求里,经常需要多个图层叠加——比如底层是视频、中间是 UI、顶层是鼠标指针。FrameBuffer 模型里只有一个 /dev/fb0,要做图层叠加只能靠 CPU 在内存里手动合成,效率极低。而硬件其实是有多个图层(Overlay)能力的,FrameBuffer 用不上。
第二,它没有标准的模式设置接口。屏幕的分辨率、时序参数、色深这些信息,FrameBuffer 是通过FBIOGET_VSCREENINFO读出来的,但怎么设置、怎么在多个显示模式之间切换,没有统一的标准。每个驱动各搞各的,应用层很难做通用的模式管理。
第三,它和 GPU 的配合很别扭。当系统里有 GPU 时,GPU 渲染出来的结果通常在自己的缓冲区里,要显示到屏幕上得先拷贝到 FrameBuffer 的显存,多了一次内存拷贝,浪费带宽。而 DRM 可以直接把 GPU 的缓冲区作为扫描输出源,零拷贝。
正是这些局限,推动了 DRM/KMS 成为现代 Linux 图形显示的主流方案。但 FrameBuffer 并没有完全消失,很多简单的嵌入式场景、成本敏感的芯片、以及一些老驱动,仍然在用 FrameBuffer。而且 DRM 子系统里其实保留了一个 fbdev 兼容层,让老的 FrameBuffer 程序能在 DRM 上跑。所以理解 FrameBuffer 仍然是必要的,它是理解整个图形显示体系的起点。
3. DRM/KMS:现代 Linux 显示的中枢,以及它解决的三个核心问题
3.1 KMS 到底"模式设置"了什么
DRM 是 Direct Rendering Manager 的缩写,KMS 是 Kernel Mode Setting 的缩写,合起来 DRM/KMS 就是内核里负责图形显示和 GPU 管理的子系统。要理解它,先要理解"模式设置"这个词。
一块屏幕要正常工作,需要一组时序参数:水平方向有多少个有效像素、多少个消隐像素、同步脉冲多宽;垂直方向有多少行、多少消隐行、同步脉冲多高;像素时钟是多少 MHz。这组参数就是"显示模式"(display mode)。模式设置就是把这组参数配置给显示控制器,让它按照正确的时序输出信号。
在 KMS 出现之前,模式设置是在用户空间做的(比如 X server 自己算时序、自己配寄存器)。这带来一个问题:从系统启动到 X server 起来之前,屏幕是黑的或者显示的是固件留下的画面,启动过程中会闪一下。KMS 把模式设置挪到内核里,内核在驱动初始化时就把屏幕点亮,整个启动过程画面是连续的,这就是所谓的"无闪烁启动"。
KMS 的另一个价值是统一了接口。不管底层是哪个厂商的显示控制器,上层看到的都是一套标准的 DRM 接口:CRTC、Encoder、Connector、Plane 这几个抽象对象。理解了这几个对象,你就能理解 DRM 的整个模型。
3.2 CRTC、Encoder、Connector、Plane:四个必须搞懂的对象
这四个对象是 DRM 的骨架,我用一个类比来讲。把显示系统想象成一条从"图像数据"到"屏幕像素"的流水线:
Plane(图层)是流水线的入口,代表一块图像数据。一个 CRTC 可以挂多个 Plane,硬件会把它们叠加合成。比如底层 Plane 放视频帧,上层 Plane 放半透明的 UI,硬件合成后输出。Plane 有格式(RGB、YUV)、有位置、有大小、有透明度。
CRTC是流水线的核心,代表一个显示控制器。它负责从 Plane 读取像素、按顺序合成、生成时序信号。一个 CRTC 对应一个物理显示通道。它决定了分辨率、刷新率、时序参数。
Encoder是流水线的出口转换器,把 CRTC 输出的并行像素信号转换成某种物理接口需要的信号格式,比如 RGB 并口、MIPI DSI、LVDS、HDMI。一个 CRTC 可以接多个 Encoder,但同一时刻只有一个生效。
Connector是物理接口的抽象,代表板子上那个实际的显示接口。它负责读取屏幕的 EDID 信息(如果是 HDMI 这类支持 EDID 的接口),报告支持哪些显示模式,以及当前连接状态(插没插)。
数据流向是:Plane → CRTC → Encoder → Connector → 屏幕。配置显示的过程,就是把这四个对象用属性(property)连起来,然后提交(atomic commit)。
3.3 Atomic Commit:一次提交,整条链路生效
早期 DRM 的配置接口是分步的、非原子的:你先设置 CRTC 的模式,再设置 Plane 的位置,再设置 Connector 的状态,每一步都是独立的 ioctl。这有个问题:如果中间某一步失败了,系统会处于一个不一致的中间状态,屏幕可能花屏。
Atomic Commit 解决了这个问题。它把所有配置打包成一个请求,一次性提交给内核。内核先做完整的合法性检查(比如这个 Plane 能不能挂到这个 CRTC 上、这个分辨率这个 CRTC 支不支持),全部通过才真正生效,任何一项不通过就整体拒绝,屏幕保持原状。这就像数据库的事务,要么全成功,要么全回滚。
用 libdrm 写一个最简单的 atomic commit 大致是这样:
drmModeAtomicReq *req = drmModeAtomicAlloc(); // 设置 CRTC 的模式 drmModeAtomicAddProperty(req, crtc_id, mode_id_prop, blob_id); drmModeAtomicAddProperty(req, crtc_id, active_prop, 1); // 设置 Plane 的源和目标区域 drmModeAtomicAddProperty(req, plane_id, fb_id_prop, fb_id); drmModeAtomicAddProperty(req, plane_id, crtc_id_prop, crtc_id); drmModeAtomicAddProperty(req, plane_id, src_x_prop, 0); drmModeAtomicAddProperty(req, plane_id, src_y_prop, 0); drmModeAtomicAddProperty(req, plane_id, src_w_prop, width << 16); drmModeAtomicAddProperty(req, plane_id, src_h_prop, height << 16); drmModeAtomicAddProperty(req, plane_id, crtc_x_prop, 0); drmModeAtomicAddProperty(req, plane_id, crtc_y_prop, 0); drmModeAtomicAddProperty(req, plane_id, crtc_w_prop, width); drmModeAtomicAddProperty(req, plane_id, crtc_h_prop, height); // 提交,带上 ATOMIC_ALLOW_MODESET 标志 drmModeAtomicCommit(fd, req, DRM_MODE_ATOMIC_ALLOW_MODESET, NULL); drmModeAtomicFree(req);注意 src_w 和 src_h 用了左移 16 位,这是 DRM 的定点数格式,16.16 定点。这个细节很容易忘,忘了就会导致缩放比例完全不对。我第一次写的时候就没注意,画面被拉伸得面目全非,查了半天才发现是定点数的问题。
3.4 DRM 相比 FrameBuffer 的三个本质提升
回到前面说的 FrameBuffer 三个局限,DRM 正好对应解决了:
多图层硬件合成。DRM 的 Plane 抽象直接对应硬件的 Overlay 图层,多个 Plane 可以挂到同一个 CRTC 上,由硬件合成,不需要 CPU 参与。这对视频播放场景意义重大——视频帧走一个 Plane,UI 走另一个 Plane,硬件叠加,CPU 几乎不干活。
统一的模式管理。DRM 有标准的 mode 对象,Connector 通过 EDID 报告支持的模式列表,应用层可以枚举、选择、设置。HDMI 热插拔也有标准的事件通知机制。
与 GPU 零拷贝协作。DRM 的 GEM(Graphics Execution Manager)负责显存管理,GPU 渲染的结果就是一个 GEM buffer,可以直接作为 Plane 的输入,不需要拷贝。这是现代图形栈高效的基础。
理解了这些,你就明白为什么现在做嵌入式 Linux 显示,主流方案都是 DRM/KMS。FrameBuffer 是历史,DRM 是现在和未来。但 FrameBuffer 的简单模型仍然是理解 DRM 的好跳板——DRM 的 Plane 本质上就是"可以叠加的 FrameBuffer"。
4. 设备树里的显示节点:从硬件描述到驱动匹配
4.1 显示控制器节点长什么样
嵌入式 Linux 里,硬件资源是通过设备树(Device Tree)描述的。显示相关的节点通常挂在 SoC 的总线下,一个典型的显示控制器节点大概长这样(以常见的 SoC 为例,具体寄存器地址和时钟名因芯片而异):
display_controller: display-controller@ff900000 { compatible = "vendor,lcdc-v2"; reg = <0x0 0xff900000 0x0 0x40000>; interrupts = <GIC_SPI 45 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru ACLK_LCDC>, <&cru DCLK_LCDC>, <&cru HCLK_LCDC>; clock-names = "aclk", "dclk", "hclk"; power-domains = <&power PD_LCDC>; resets = <&cru SRST_LCDC>; reset-names = "lcdc"; status = "okay"; };这个节点告诉内核:显示控制器的寄存器在 0xff900000,中断号是 45,需要三个时钟(总线时钟、像素时钟、寄存器时钟),属于某个电源域,有一个复位控制。驱动通过 compatible 字符串匹配到这个节点,然后申请资源、初始化硬件。
这里有个经验:时钟配置是显示驱动最容易出问题的地方。像素时钟(dclk)的频率直接决定了刷新率和分辨率能不能对上。如果 dclk 配错了,屏幕可能完全不亮,或者显示但抖动。排查时可以用示波器量像素时钟引脚,看实际频率和预期是否一致。我遇到过一次屏幕偶尔闪一下的问题,最后发现是 dclk 的父时钟选错了,导致频率有微小偏差,累积起来就出问题。
4.2 屏幕面板节点与时序参数
屏幕面板本身也是一个设备树节点,描述它的时序参数、电源控制、背光控制:
panel: panel { compatible = "vendor,generic-rgb-panel"; backlight = <&backlight>; power-supply = <&vcc_lcd>; reset-gpios = <&gpio1 15 GPIO_ACTIVE_LOW>; port { panel_in: endpoint { remote-endpoint = <&lcdc_out_rgb>; }; }; }; &display_controller { port { lcdc_out_rgb: endpoint { remote-endpoint = <&panel_in>; }; }; };时序参数通常在 panel 驱动里硬编码,或者通过 display-timings 子节点描述:
display-timings { native-mode = <&timing0>; timing0: timing0 { clock-frequency = <33000000>; // 像素时钟 33MHz hactive = <800>; // 水平有效像素 hfront-porch = <210>; // 水平前肩 hback-porch = <46>; // 水平后肩 hsync-len = <20>; // 水平同步脉冲宽度 vactive = <480>; // 垂直有效行 vfront-porch = <22>; // 垂直前肩 vback-porch = <23>; // 垂直后肩 vsync-len = <10>; // 垂直同步脉冲宽度 hsync-active = <0>; // 同步极性 vsync-active = <0>; de-active = <1>; // 数据使能极性 pixelclk-active = <0>; // 像素时钟极性 }; };这些参数必须和屏幕数据手册完全一致,差一个值都可能不亮。最容易搞错的是极性参数(hsync-active、vsync-active、de-active、pixelclk-active)。极性反了,屏幕可能完全不亮,也可能显示但偏移。我建议的做法是:先严格按数据手册填,不亮再逐个翻转极性试。但翻转要有记录,一次只改一个,否则改乱了就不知道是哪个起的作用。
4.3 背光与电源:屏幕不亮的第一嫌疑人
屏幕不亮,很多人第一反应是驱动问题,其实背光和电源才是第一嫌疑人。LCD 面板本身不发光,靠背光点亮。背光没开,屏幕就是黑的,但仔细看可能能看到微弱的图像(在强光下斜看)。所以排查第一步应该是确认背光。
背光在设备树里通常是一个 PWM 控制的节点:
backlight: backlight { compatible = "pwm-backlight"; pwms = <&pwm1 0 25000 0>; // PWM 通道 0,周期 25000ns brightness-levels = <0 4 8 16 32 64 128 255>; default-brightness-level = <6>; enable-gpios = <&gpio2 8 GPIO_ACTIVE_HIGH>; };brightness-levels 是一个映射表,把 0-255 的亮度值映射到实际的 PWM 占空比档位。这个表的设计有讲究:人眼对亮度的感知是非线性的,低亮度区需要更细的档位,高亮度区可以粗一些。上面这个表就是前密后疏的设计。
电源方面,panel 节点里的 power-supply 指向一个 regulator。上电时序很关键:很多屏幕要求电源先稳定,延迟一段时间后再给信号,再延迟一段时间后开背光。顺序错了可能不亮,甚至损伤屏幕。这些延迟通常在 panel 驱动里用msleep控制,如果自己写 panel 驱动,一定要按数据手册的时序来。
5. 从零点亮一块 RGB 屏:完整实操链路
5.1 硬件连接确认:别跳过这一步
拿到一块新屏幕,第一件事不是写代码,是确认硬件连接。RGB 并口屏通常有这些信号线:R0-R7、G0-G7、B0-B7(24 位色就是各 8 根)、HSYNC、VSYNC、DE、PCLK,加上背光电源和使能、面板电源。这么多线,接错一根就可能导致颜色不对或者不亮。
我的习惯是拿万用表逐根确认连通性,特别是颜色线。RGB 各 8 根,如果 R 和 B 接反了,画面会偏色(红蓝互换)。如果某根数据线虚焊,会出现固定的颜色条纹。这些硬件问题在软件层面排查会非常痛苦,所以硬件确认一定要做在前面。
背光部分,确认背光电源电压和屏幕要求一致,背光使能引脚的电平极性正确。有些屏幕背光使能是高有效,有些是低有效,接反了背光就不亮。
5.2 内核配置:打开正确的选项
内核配置是点亮屏幕的关键一步。需要确认这几项:
- DRM 支持:
CONFIG_DRM=y - 对应 SoC 的显示驱动:比如
CONFIG_DRM_VENDOR_LCDC=y - 面板驱动:
CONFIG_DRM_PANEL_GENERIC=y或具体型号 - 背光驱动:
CONFIG_BACKLIGHT_PWM=y - FrameBuffer 兼容层(可选,但建议开):
CONFIG_DRM_FBDEV_EMULATION=y - 调试用:
CONFIG_DRM_DEBUG_MODESET=y、CONFIG_FB_VESA之类
打开 fbdev 兼容层的好处是,即使你还没写 DRM 应用,也能通过 /dev/fb0 快速验证屏幕是否点亮。这是分阶段验证的思路:先用最简单的方式确认硬件通了,再上复杂的方案。
5.3 设备树调试:用 modetest 验证
内核起来后,如果 DRM 驱动加载成功,/dev/dri/ 目录下会出现 card0 和 controlD64 之类的节点。这时候用modetest这个工具可以快速验证:
# 列出所有 DRM 对象 modetest -M vendor-lcdc # 查看连接器状态和支持的模式 modetest -M vendor-lcdc -c # 在指定连接器上显示测试图案 modetest -M vendor-lcdc -s 32@35:800x480modetest会显示一个彩色的测试图案(通常是 SMPTE 彩条),如果屏幕能正常显示这个图案,说明从 CRTC 到屏幕的整条链路都通了。这一步非常关键,它把问题范围缩小到了应用层——如果 modetest 能显示,你的应用显示不出来,那就是应用的问题;如果 modetest 也显示不出来,那就是驱动或硬件的问题。
我强烈建议把 modetest 作为点亮屏幕的标准验证工具。它比写一个 FrameBuffer 测试程序更快,而且能直接操作 DRM 对象,信息更全。
5.4 常见不亮原因速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全黑屏,无背光 | 背光电源/使能问题 | 量背光电压,查使能引脚电平 |
| 有背光但无图像 | 时序参数错误 | 对照数据手册核对时序,量 PCLK |
| 图像偏移 | 前后肩参数错误 | 调整 hback-porch/vback-porch |
| 颜色不对 | 数据线接错或格式错 | 检查 RGB 线序,确认像素格式 |
| 画面抖动 | 像素时钟不稳 | 量 PCLK 频率,检查时钟配置 |
| 花屏 | 显存配置错误 | 检查 stride、分辨率设置 |
| 只显示一部分 | 分辨率或虚拟分辨率错 | 核对 xres/yres 和实际屏幕 |
这张表是我这些年排查显示问题的经验总结,覆盖了大部分常见情况。实际排查时,按"背光→时序→数据→显存"的顺序逐层确认,效率最高。
6. 显示问题的分层排查链路:一个真实案例的完整复盘
6.1 问题现象与初步判断
说一个我印象比较深的案例。一块 1024x600 的 MIPI DSI 屏,板子起来后背光亮,但屏幕是白的,没有任何图像。串口日志里 DRM 驱动加载正常,modetest 也能枚举出连接器和模式,但一提交就报错。
初步判断:背光正常,说明电源和背光链路没问题;驱动加载正常,说明设备树匹配和资源申请没问题;问题出在模式设置或者 DSI 链路初始化上。
6.2 逐层排查:从 DSI 链路到面板初始化
第一步,查 DSI 链路。MIPI DSI 是差分串行接口,有专门的初始化序列。用示波器量 DSI 的时钟 lane 和数据 lane,发现时钟 lane 有信号,但数据 lane 没有。这说明 DSI 控制器初始化了,但没有发送数据。
第二步,查 DSI 初始化序列。DSI 面板需要一系列初始化命令(DCS 命令)来配置,比如设置像素格式、设置时序、退出睡眠模式。这些命令通常在 panel 驱动里。检查驱动代码,发现初始化序列确实存在,但执行时机可能有问题。
第三步,查上电时序。仔细看数据手册,发现面板要求:电源稳定后延迟 10ms,然后发初始化命令,再延迟 120ms,才能开始传输图像数据。而驱动里的延迟是 10ms 和 50ms,第二个延迟不够。把 50ms 改成 120ms 后,屏幕正常点亮。
这个案例的教训是:上电时序的延迟时间必须严格按数据手册来,不能想当然。差几十毫秒,面板内部状态机就没准备好,命令发了也不响应。而且这种问题不会报错,只是静默失败,排查起来很费时间。
6.3 排查工具与手段清单
结合这个案例,我把显示问题排查常用的手段整理一下:
- 串口日志:看驱动加载、DRM 对象枚举、错误码。开
CONFIG_DRM_DEBUG_MODESET能看到更详细的模式设置日志。 - modetest:验证 DRM 链路,显示测试图案。
- 示波器/逻辑分析仪:量 PCLK、HSYNC、VSYNC、DE 信号,确认时序;量 DSI/LVDS 差分信号,确认链路。
- 万用表:量电源、背光电压、GPIO 电平。
- sysfs/debugfs:
/sys/class/drm/下有各个连接器的状态,/sys/kernel/debug/dri/下有更详细的调试信息。 - 数据手册:时序参数、上电时序、初始化序列的最终依据。
这些工具里,示波器是最有说服力的。软件层面看到的都是"驱动认为的",硬件层面量到的才是"实际发生的"。当软件和硬件对不上时,以硬件为准。
6.4 一个容易被忽略的坑:像素格式不匹配
还有一个坑值得单独说:像素格式不匹配。DRM 里像素格式用 fourcc 表示,比如DRM_FORMAT_XRGB8888、DRM_FORMAT_RGB565、DRM_FORMAT_ARGB8888。如果应用层准备的 buffer 格式和 Plane 配置的格式不一致,画面会偏色或者错位。
我遇到过一次,应用层用 RGB565 准备数据,Plane 配的是 XRGB8888,结果画面颜色完全乱掉,红色变蓝色,还带条纹。原因是两种格式每个像素占的字节数不同(2 字节 vs 4 字节),硬件按 4 字节解析 2 字节的数据,自然全乱。
排查这类问题,关键是确认 buffer 的实际格式和 Plane 配置的格式一致。可以用drmModeGetFB查 framebuffer 的格式,和 Plane 的 format 属性对比。另外,stride(每行字节数)也要对,stride 不对会导致画面斜切。
7. 应用层怎么用:从直接操作 DRM 到选择图形框架
7.1 直接操作 DRM:适合简单场景
如果你的需求很简单——全屏显示一张图、或者显示一个简单的界面,直接操作 DRM 是可行的。流程是:打开 /dev/dri/card0,获取资源,创建 dumb buffer,映射内存,写入像素数据,然后 atomic commit 显示。
// 创建 dumb buffer struct drm_mode_create_dumb create = {0}; create.width = width; create.height = height; create.bpp = 32; drmIoctl(fd, DRM_IOCTL_MODE_CREATE_DUMB, &create); // 映射 struct drm_mode_map_dumb map = {0}; map.handle = create.handle; drmIoctl(fd, DRM_IOCTL_MODE_MAP_DUMB, &map); void *ptr = mmap(0, create.size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, map.offset); // 创建 framebuffer struct drm_mode_fb_cmd fb = {0}; fb.width = width; fb.height = height; fb.pitch = create.pitch; fb.bpp = 32; fb.depth = 24; fb.handle = create.handle; drmIoctl(fd, DRM_IOCTL_MODE_ADDFB, &fb); // 然后 atomic commit 显示这套流程比 FrameBuffer 复杂不少,但换来的是对显示链路的完全控制。适合做底层验证、或者资源极度受限、不想引入大框架的场景。
7.2 图形框架选型:Qt、LVGL、DirectFB 怎么选
实际项目里,很少直接操作 DRM,而是用图形框架。嵌入式 Linux 上常见的几个选择:
Qt是最重的,功能最全,适合复杂的 UI 应用。它底层可以用 eglfs(直接跑在 DRM/KMS 上,不需要 X/Wayland)或者 linuxfb(跑在 FrameBuffer 上)。eglfs 配合 GPU 性能最好,但需要 GPU 驱动支持。Qt 的学习曲线陡,资源占用大,适合有 GPU 的中高端 SoC。
LVGL是轻量级的,纯 C,资源占用小,适合 MCU 级别的场景或者低端 Linux。它默认用 FrameBuffer,也可以适配 DRM。LVGL 的 API 简单,上手快,做简单的仪表盘、控制面板很合适。缺点是复杂 UI 能力有限。
DirectFB是介于两者之间的老牌框架,现在维护不太活跃了,新项目不太推荐。
选型的核心考量是:有没有 GPU、UI 复杂度、内存和 CPU 资源、团队熟悉度。有 GPU 且 UI 复杂,选 Qt + eglfs;没 GPU 且 UI 简单,选 LVGL + FrameBuffer/DRM;介于中间,看团队情况。
7.3 性能优化的几个实操点
不管用什么框架,显示性能优化有几个通用点:
用双缓冲或三缓冲。单缓冲必然撕裂,双缓冲是底线。DRM 的 atomic commit 天然支持双缓冲,FrameBuffer 需要手动管理 yoffset。
减少不必要的重绘。只更新变化的区域,而不是整屏重绘。LVGL 有脏矩形机制,Qt 有局部更新,用好了能大幅降低 CPU 占用。
利用硬件图层。如果 SoC 有多个 Plane,把视频、UI 分层,让硬件合成,CPU 只更新变化的那一层。这需要应用层和 DRM 配合,稍微复杂,但收益很大。
注意内存带宽。高分辨率下,每帧的数据量很大。1024x600x32bpp 一帧就是 2.4MB,60fps 就是 144MB/s 的带宽。如果内存带宽不够,会出现卡顿。这时候要考虑降低色深(用 RGB565)、降低刷新率、或者用压缩格式。
8. 写在最后:几个我踩过的坑和给你的建议
聊了这么多,最后分享几个具体的经验,都是我在实际项目里踩过的。
第一个,别一上来就怀疑驱动。屏幕不亮,先量背光、量电源、量时钟,这些硬件层面的确认花不了几分钟,但能排除掉一大半问题。我见过太多人一上来就翻驱动代码,翻了半天发现是背光使能引脚接错了。
第二个,modetest 是你的好朋友。点亮新屏幕,先用 modetest 验证,通了再写应用。这个习惯能帮你把问题隔离在正确的层次。
第三个,时序参数严格照数据手册。不要凭经验猜,不要"差不多就行"。前后肩差几个像素,屏幕可能就偏移或者不亮。极性参数更是如此,反了就是不亮。
第四个,上电时序的延迟要留余量。数据手册说 10ms,你就给 15ms。面板批次不同,实际需要的延迟可能有差异,留点余量更稳。
第五个,像素格式和 stride 一定要对齐。这是应用层最容易出的问题,而且现象很迷惑(偏色、斜切),排查时容易被误导到硬件方向。
嵌入式 Linux 图形显示这个领域,说深很深,从 DRM 内核源码到 GPU 驱动到显示时序,每一块都能挖很久;说浅也浅,理解了"数据从应用到屏幕的链路"这个框架,大部分问题都能定位到具体层次。希望这篇东西能帮你建立这个框架,下次再遇到黑屏,你知道该从哪里下手。