之前调一块 i.MX8M Mini 主板的显示驱动时,U-Boot 阶段 logo 死活不出来,kernel 起来之后 framebuffer 又正常,折腾了两天最后发现是把 U-Boot 阶段的显示初始化和 kernel 阶段的 DRM 流程搅在了一起。从那以后,我在看任何显示驱动问题之前,都会先问一句:这个问题发生在哪一层?是 U-Boot 的简单 framebuffer 初始化,还是 kernel 里完整的 DRM/KMS 驱动,还是用户态的 weston/wayland 合成?三层代码各有各的任务,也各有各的坑,混在一起查基本是在浪费时间。
这篇先聊清楚偏底层的东西:DRM 框架到底解决了什么问题、它在内核里的对象之间是什么关系、U-Boot 阶段虽然没有完整的 DRM,但设备树解析和驱动 probe 又是怎么配合的。适合刚接触显示驱动、或者做过 framebuffer 开发但没系统捋过 DRM 的人。内容偏基础,但也夹了一些我实际踩过的坑,尤其是 MIPI DSI 竖屏改横屏这类时序问题,在 U-Boot 阶段的表现和 kernel 阶段完全不一样,值得单独拿出来说。
1. 为什么内核显示驱动框架最后收敛到 DRM:从 FBDEV 的局限性说起
1.1 FBDEV 时代:一个 fb_info 扛下所有,浆糊一样的架构
早年间写 Linux 显示驱动,绕不开 framebuffer。核心数据结构就是struct fb_info,里面塞了var、fix、fbops、screen_base,一整套都在描述一块线性的显存和一个显示屏的时序。早期单屏、单图层、单分辨率场景下,这套东西确实够用,代码路径短,应用层直接 mmap 后写像素就行。
但显示系统一复杂就崩了。多屏输出的时候,你要给每个屏幕注册一个 fb_info,应用层还得自己搞清楚/dev/fb0、/dev/fb1分别对应哪个物理显示接口。更麻烦的是,fbdev 没有统一的机制去管理显示控制器的硬件流水线,像 layer blending、alpha、缩放、旋转这些能力,在 fbdev 框架里基本靠私有 ioctl 去扩展。每个驱动各自为政,接口风格五花八门,上层框架根本没法做统一封装。我记得当时做多屏拼接,光是对齐不同驱动的私有 ioctl 就花了两周。
另一个核心痛点是 fbdev 不支持原子更新。修改分辨率或者切换显示模式时,要先把fb_info->var里的参数改掉,再通过fb_set_par触发底层重新计算寄存器。这个过程不可回滚,一旦时序参数填得有问题,屏幕直接花掉或者黑掉,没有事务的概念。对于现代显示场景,这个缺陷几乎是致命的。
1.2 DRM 的组件化思路:把显示流水线拆成可以拼接的积木
DRM(Direct Rendering Manager)最早的起源是为了 GPU 渲染服务,后来 KMS(Kernel Mode Setting)被并入,慢慢就变成了 Linux 上显示控制的通用框架。它最大的特点是把显示链路抽象成几个明确角色:PLANE(图层)、CRTC(显示控制器)、ENCODER(信号编码器)、CONNECTOR(物理接口)。
我常用生活里的例子解释这套模型:PLANE 相当于一张张透明胶片,CRTC 相当于投影仪,ENCODER 相当于把投影仪信号转换成 HDMI/VGA 这类具体格式的转换头,CONNECTOR 就是投影仪屁股上那个物理接口。你要让屏幕亮起来,必须把这几个角色从软件层面依次串联,最终形成一个完整的显示链路。这种组件化设计让驱动开发者的工作变得清晰:每个硬件模块只关心自己在链路上的那一段。
组件化的价值在 SoC 平台体现得最明显。同一个显示控制器可以接 HDMI、MIPI DSI、LVDS 多种接口,如果用 fbdev 的做法,每种组合都要写一套独立的驱动逻辑。而在 DRM 下,CRTC 驱动只需要实现atomic_check和atomic_flush;ENCODER 驱动只需要关心时序信号转换;CONNECTOR 驱动只需要管热插拔检测和 EDID 读取。各管一段,然后通过drm_connector_attach_encoder这类接口把链路搭起来。
1.3 DRM 与 FBDEV 最关键的差异:原子提交与显存共享
DRM 还有一个 FBDEV 完全不具备的机制:Atomic Mode Setting。这是我在实际项目中感受最深的一点。atomic 的含义是把所有显示状态修改打包成一个事务对象,通过drm_atomic_commit一次性提交,硬件在 VBLANK 中断点切换,要么全部生效,要么全部不动,不存在中间状态。
这个机制解决了两类实际问题。第一是残影和撕裂,用户态在 vsync 切配置,不会出现上下两半屏幕分辨率不一致的情况;第二是状态一致性,多屏配置切换时,如果只需要改某一个屏幕的参数,其他屏幕的状态不会被波及。我在客户现场遇到过一个问题:在 1080p 和 4K 两个显示器同时输出的环境下,单独切 4K 分辨率会导致 1080p 那边也闪一下,用 fbdev 查了三天无果,迁移到 DRM atomic 后这个现象天然消失。
显存管理方面,DRM 通过 GEM(Graphics Execution Manager)提供缓冲区管理能力,配合 DMA-BUF 可以实现跨设备共享内存。视频解码器输出的画面可以直接导入给 DRM 做显示合成,不需要 CPU 拷贝一份。这在 fbdev 时代几乎不敢想——那时候要么走copy_to_user再write回去,要么用VIDIOC_QBUF和FBIOPUT_VSCREENINFO各种土办法绕。DRM 加 DMA-BUF 之后,零拷贝显示成了标准能力。
2. DRM 的框架骨骼:设备模型、对象拓扑与用户态接口
2.1 drm_device 与 drm_driver 的注册流程
打开任意一个 DRM 驱动的源码(比如imx-drm、rockchip、msm),初始化流程其实大同小异。驱动 probe 之后,核心动作是分配struct drm_device,填充struct drm_driver,然后调drm_dev_register。从 4.7 内核开始推荐用drm_dev_alloc+drm_dev_register两步走,而不是老的drm_get_pci_dev那套。
一个标准驱动的 probe 大致长这样:
static int my_drm_platform_probe(struct platform_device *pdev) { struct drm_device *drm; struct my_drm_private *priv; priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); drm = drm_dev_alloc(&my_drm_driver, &pdev->dev); if (IS_ERR(drm)) return PTR_ERR(drm); platform_set_drvdata(pdev, drm); priv->drm = drm; // 在这里做硬件资源映射、中断请求、对象注册 my_drm_setup_pipeline(drm, priv); my_drm_register_dsi(drm, priv); ret = drm_dev_register(drm, 0); if (ret) goto err_unload; }这里面有个值得注意的演化方向:新内核(5.5+)越来越推荐用 devm_drm_dev_alloc 配合 DRM managed API(drmm_*)来管理生命周期,作用是把 devm 的设备资源管理和 DRM 对象的释放统一到一起,驱动卸载路径简单很多。老驱动里的drm_unload、drm_lastclose那些回调基本都废弃了,新写的驱动没必要再学那套,直接看drm/i915或者drm/vc4这类作为模板比看老书靠谱。
2.2 KMS 对象拓扑:CRTC、ENCODER、CONNECTOR 之间到底怎么连线
对象注册本身不难,难的是理解它们之间的拓扑。一个完整的 KMS 链路是:用户态拿到一个 DRM framebuffer → 绑定到一个 PLANE → PLANE 绑定到一个 CRTC → CRTC 通过下游的 ENCODER → ENCODER 输出到 CONNECTOR。
CRTC 对应的硬件是显示控制器(Display Controller),它负责从内存中取像素、做图层合成、产生时序信号。一个 SoC 通常有多个 CRTC,比如 i.MX8M Mini 有两个显示控制器,rockchip RK3568 有三个。ENCODER 负责把 CRTC 送出来的并行 RGB 信号转换成特定标准信号,比如 HDMI 的 TMDS、MIPI DSI 的差分串行信号、LVDS 的信号。CONNECTOR 代表物理插口,管理热插拔、EDID 和面板电源控制。
我把这几者的关系整理成一张表,便于对照:
| 对象 | 硬件对应 | 主要职责 | 核心 API |
|---|---|---|---|
| PLANE | 图层/硬件 overlay | 关联 Framebuffer、做缩放旋转 | drm_plane_init |
| CRTC | 显示控制器 | 时钟、时序生成、图层合成 | drm_crtc_init_with_planes |
| ENCODER | 信号转换芯片 | 格式转换、链路训练 | drm_encoder_init |
| CONNECTOR | 物理接口 | 热插拔、EDID、面板电源 | drm_connector_init |
调试的时候有一个很实用的思路:用户态通过drmModeGetResources拿到的所有 ID(crtc_id、plane_id、connector_id、encoder_id)本质上就是这棵树的可视化映射,你可以把它理解成内核树形对象模型的一个用户态快照。很多驱动 bug 最终会发现是mode_valid回调里没有拒绝某个 encoder/connector 组合,导致链路拓扑非法,用户态一配置就报错。
2.3 GEM 与 libdrm 的角色划分:用户态为什么不能直接写 ioctl
DRM 内核侧再完整,用户态调用还是得有个库来兜底,这个库就是 libdrm。它的主要作用是封装 ioctl 细节,把内核态的struct drm_mode_get_connector这类结构体转换成应用友好的drmModeConnectorPtr,让上层不用关心字节序对齐之类的问题。
我第一次看 libdrm 代码的时候比较困惑:既然最终都是 ioctl,为啥不直接 open/dev/dri/card0然后自己ioctl(fd, DRM_IOCTL_MODE_GETCONNECTOR, &arg)?后来发现 libdrm 的价值不止是封装,它把内核对象模型映射成带引用的 C 对象,处理了资源类型枚举、连接器信息缓存刷新、CRTC 属性快照等公共逻辑。更别说drmModePageFlip这类需要关注 DRM event 的接口,底层还有对 DRM fd 事件循环的处理,自己做这些纯属重复造轮子。
对于写内核驱动的开发者来说,对 libdrm 的掌握程度不用太深,但建议把drmModeGetResources的调用流程走一遍,因为它对应的正是内核端drm_mode_getresources的 ioctl 实现。你在内核里修一个链路拓扑 bug,用户态怎么查就靠这套接口,理解对齐很重要。另外,drmDevice和drmVersion相关的接口在调试多 GPU 设备时也很有用,可以区分节点编号,确认走的是哪个 DRM 设备。
3. U-Boot 阶段的显示驱动解析:流程、数据结构与设备树约定
3.1 U-Boot 没有 DRM?那显示初始化到底是怎么发生的
U-Boot 本身并没有完整的 DRM 框架,没有drm_device,也没有 KMS 对象。但现代 U-Boot 的显示驱动设计是跟着设备树走的,而设备树中 display 相关节点的 bindings 恰恰就是 kernel DRM 的 bindings 格式。所以 U-Boot 里的显示初始化本质上是一个简化版的 DRM 驱动解析过程:通过UCLASS_VIDEO这个 uclass 来管理所有视频输出设备。
U-Boot 的 video 框架核心是struct video_priv和struct video_ops,对应的 uclass driver 注册在drivers/video/video-uclass.c。它做的事情很直接:为显存分配一片内存,从设备树里读出面板的时序参数,配置显示控制器寄存器,把 logo 或 bmp 写到显存,然后就没有然后了——没有合成器、没有图层混合、没有原子提交,就是个能点亮屏幕的 framebuffer。
这里有一个容易误解的点:U-Boot 的 video 驱动其实也会去看设备树里的compatible,比如simple-panel、panel-dsi这类节点,也会执行 panel 的 probe,初始化 backlight 和 reset gpio。所以它虽然在概念上没有 DRM/KMS,但从设备树解析的角度看,它跟 kernel 侧是「同一套语言」。你在 kernel 里通过修改设备树来适配面板,U-Boot 阶段大概率也要同步改,否则会出现「U-Boot logo 不亮,kernel 正常」或者反过来「U-Boot 正常,kernel 黑屏」的割裂问题。
3.2 设备树解析链路:从 display 节点到 panel probe 的具体流程
U-Boot 显示设备树解析从video_bind开始,遍历compatible匹配到的 video 设备,然后调video_probe。这个 probe 里会做几件事:第一,通过dev_read_prop读取display-timings节点,如果读到native-mode对应的 timing 子节点,就解析出clock-frequency、hactive、vactive、hfront-porch、hback-porch等参数;第二,找panel或panel-simple这类节点,触发 panel 驱动的 probe;第三,初始化背光。
一段典型的 U-Boot 设备树显示节点如下:
&lcdif { display-timings { native-mode = <&timing0>; timing0: timing0 { clock-frequency = <67500000>; hactive = <1280>; vactive = <800>; hfront-porch = <48>; hback-porch = <80>; hsync-len = <32>; vfront-porch = <3>; vback-porch = <14>; vsync-len = <4>; hsync-active = <0>; vsync-active = <0>; de-active = <0>; pixelclk-active = <0>; }; }; port { display_out: endpoint { remote-endpoint = <&panel_ep>; }; }; }; &panel { compatible = "panel-simple"; backlight = <&backlight>; enable-gpios = <&gpio1 13 GPIO_ACTIVE_HIGH>; port { panel_ep: endpoint { remote-endpoint = <&display_out>; }; }; };U-Boot 里驱动解析这些属性用的接口跟 kernel 高度相似,比如dev_read_u32(&pdev->dev, "clock-frequency", &freq),或者读取 gpio 描述符用gpio_request_by_name。我在排查问题时常用的一招是:在 U-Boot 命令行里用dm tree看 video 节点和 panel 节点是否成功 probe,再用dm uclass看UCLASS_VIDEO设备列表。如果 panel 节点没被 probe,第一反应不是去查时序代码,而是查该节点的compatible是否在 U-Boot 的drivers/video/panel-simple.c里有匹配项。
3.3 MIPI DSI 竖屏改横屏的时序问题:解析思路与调整点
这个点我专门拿出来讲,是因为它在实际项目中极其常见,也是「DRM 显示」和「U-Boot 显示」行为差异最明显的地方。搜索热词里就有mipi dsi drm竖屏改横屏显示,说明很多人卡在这。
竖屏改横屏本质上是把分辨率从竖的(比如 720x1280)变成横的(1280x720)。在内核 DRM 侧,可以通过配置 CONNECTOR 的rotation属性,由硬件层的drm_plane_state做旋转,或者修改 panel 的timing参数并通知 DSI 控制器重新计算行场参数。但在 U-Boot 阶段没有rotation属性这套机制,U-Boot 就是傻子一样按 timing 里面的 hactive 和 vactive 去配置寄存器。
所以如果你只是改了 U-Boot 设备树里 timing 的hactive和vactive,很多 MIPI DSI 屏幕并不会正确旋转显示。因为 DSI 面板本身有自己的行列扫描方向,控制器输出的像素流是按行列逐点发送的,简单地交换 h/v 可能导致图像发生镜像、错位甚至完全不显示。我处理的 RK 平台方案里,竖屏改横屏需要在 DSI 控制器的寄存器层面对水平扫描顺序和垂直扫描顺序做额外处理,通常是修改 HSA、HBP、VSA、VBP 的计算方式,让 DSI 打包的 packet 符合面板横屏时序要求。
一个值得推荐的排查链条是:先在 U-Boot shell 下用printenv检查 video 相关的环境变量,然后加载一个测试用的 bmp 图片,观察显示出来的图案是镜像、错位、还是撕裂。如果是镜像,说明 DSI 的hori_orientation或line_stride方向反了;如果是错位,多半是因为时序参数与实际 DSI 面板要求的hactive不一致;如果是撕裂,可能只是 pclk 频率没配好,导致像素按错误的节奏被推出。搞清楚这三种表现对应哪种原因,能省下一大半 debug 时间。
4. 一次 U-Boot 阶段黑屏问题的排查复盘:定位与修复
4.1 现象描述与初步判断
还是回到开头那个 i.MX8M Mini 的项目。客户反馈的现象是:量产机器上偶尔有 5% 左右的板子在 U-Boot 阶段 logo 不显示,但 kernel 起来之后显示完全正常。这批板子拿到手上,第一反应是怀疑面板个体差异,于是换了新的屏模组,结果故障依旧。然后把 U-Boot 版本回退到老版本,问题消失——这就说明问题不在硬件,而在 U-Boot 的显示初始化逻辑或设备树解析。
我的排查习惯是先收集信息,看 U-Boot 启动日志里有没有报错。在那块板子上,串口输出里根本没有 video 相关的 probe 失败信息,只有一段video: failed to get timing node之类的提示被淹没在启动 log 里,不仔细看真发现不了。这个提示说明 U-Boot 在解析设备树时,没能正确找到display-timings节点。
4.2 逐层确认:设备树、时序参数、背光控制与上下电时序
第一件事是确认 U-Boot 用的设备树和 kernel 用的设备树是不是同一份。很多 BSP 会把 U-Boot 的设备树单独存放,经过fdtgrep裁剪后只保留启动阶段需要的节点。结果发现客户在裁剪时,display-timings节点因为引用的 phandle 关系不完整被裁剪掉了,U-Boot 拿到的是一个残缺的显示节点,解析不到 timing 就直接跳过了 video probe。
这就是我在 3.2 节反复强调的点:U-Boot 对设备树节点的解析是按名匹配和按 phandle 索引的,任何一个中间节点的 phandle 丢失,都可能导致整条解析链路断裂。kernel 侧因为使用完整的设备树,所以没受影响。这个问题的修复手段也很直接,把display-timings和panel相关节点手动加进 U-Boot 设备树的裁剪白名单,重新编译后故障消失。
第二件事是检查背光控制。即便 timing 解析正常,如果enable-gpios配置成GPIO_ACTIVE_LOW但 U-Boot 驱动里按GPIO_ACTIVE_HIGH去拉高,背光永远不会亮。这块板子之前遇到过因为 gpio descriptor flag 解析不对,导致背光时序和 panel 使能时序冲突,现象就是「有图像但看不见」。排查时用了逻辑分析仪抓 panel 的电源和复位信号,发现复位信号拉低时间比规格书要求的 10ms 短,导致面板内部电路没完成初始化,背光虽亮但无图像。这种情况在 kernel 阶段可以通过panel->prepare和panel->enable两步逻辑拉开时序,U-Boot 里很多驱动则把这两步合并了,踩坑概率就更高。
4.3 隐患:U-Boot 通了,kernel 还是会踩坑的常见情况
U-Boot 阶段把显示调亮之后,不等于 kernel 侧就万事大吉,有几个隐藏的交接问题会在 kernel 启动时爆发。
第一个常见情况是:U-Boot 把显示控制器的时钟频率配置成跟面板不一致,kernel 在 probe 时读取硬件寄存器状态做判断,如果 kernel 驱动的初始化依赖 U-Boot 留下的状态,就可能出现时钟没有按新参数重置的问题。这类问题表现往往是 kernel 起来后花屏,重启几次又正常,排查起来特别恼火。解决思路是在 kernel 的 DC 驱动resume或atomic_flush里做寄存器级的状态重建,而不是依赖 U-Boot 留下的状态。
第二个常见情况是:U-Boot 阶段通过fdtdec_setup_memory或panel_simple修改了设备树中的某些属性,比如填充了display-timings里的native-mode的clock-frequency,但 kernel 的设备树还是原始值。两边信息不同步,表现为同一块屏在 U-Boot 下用某个频率能亮,进 kernel 后按另一个频率刷新导致闪烁或花屏。这种问题一定要把 U-Boot 的 fixup 逻辑查一遍,重点看有没有往设备树里写入运行时参数,如果有,kernel 侧解析时要特别注意。
5. 从 U-Boot 到 Kernel 的显示交接:哪些状态要留、哪些必须重来
5.1 内核会重新初始化显示,U-Boot 留下的是「硬件现场」不是「配置现场」
很多刚接触显示驱动的同事会有个错觉:U-Boot 把屏幕点亮了,kernel 起来就会接着用。实际上,kernel 显示驱动的 probe 几乎总会把显示控制器和 DSI 链路重新初始化一遍。U-Boot 阶段设置的寄存器、配置的时序、打开的时钟,到了 kernel 手里基本都会先关掉再重新配。
U-Boot 阶段真正对 kernel 产生持久影响的只有少数几个东西:内存中残存的显存内容、某些 SoC 特有的boot_fb机制保留的 framebuffer、以及可能通过 bootargs 传递的video=参数。比如video=DSI-1:1280x720M@60这种启动参数,kernel 的 DRM 子系统会解析并尝试按这个模式配置。如果你在 U-Boot 改了 bootargs 里的显示参数,而 kernel 驱动没有对应的 mode 校验逻辑,就会直接报unknown mode错误。
最稳妥的做法是:U-Boot 阶段只保证最基本的光标和 logo 显示能力,不做特殊策略;kernel 侧的 DRM/KMS 驱动完全按设备树和 EDID 重新干活。这样两者解耦,每个阶段的可维护性都高。
5.2 经验建议:U-Boot 阶段不要做过多的显示初始化特殊逻辑
我见过最折腾的做法,是在 U-Boot 里为了显示一个开机动画,写了很长的自定义 panel 和 DSI 初始化序列,比如读 EEPROM、做色彩校准、来回切换分辨率。结果 kernel 起来后 DSI 链路两边状态冲突,屏幕要么不稳定闪烁,要么直接黑屏。调试两周后发现,几乎所有问题都出在 U-Boot 那套特殊初始化上。
根据我做过的平台经验,建议把 U-Boot 阶段的显示初始化限制在这么几件事上:解析设备树时序参数,配置显示控制器基本时钟,初始化 DSI/LVDS/HDMI 物理链路的 PHY,拉起背光,然后显示 logo。所有跟面板色彩、Gamma、DSC 压缩、局部调光相关的功能,全部留到 kernel 的 DRM 驱动里做。这不仅是架构上的洁癖,更是维护性的需要——U-Boot 的显示代码本来就缺乏复杂的错误处理机制,越简单越不容易在生产环境中翻车。
另外有一个小技巧:在调试 U-Boot 显示问题的时候,尽量让 U-Boot 和 kernel 共用一份基准设备树,只是在编译时用不同的裁剪配置。这样你在 U-Boot 阶段改动一个时序参数,kernel 侧也能通过同一份源文件同步更新,避免两边设备树最终内容分叉。我自己现在每改一个 panel 配置,都是先跑 U-Boot 确认能亮,再进 kernel 验证效果,两边各花十分钟,比在 kernel 侧反复 reboot 快得多。