在嵌入式显示领域,DRM驱动一直是个“平时没人夸、出问题全是锅”的模块。最近我在一块基于某RISC架构处理器的板卡上重新梳理Loongson LSDC DRM驱动,从设备树绑定、probe顺序到atomic modeset实现全部过了一遍,踩了不少坑,也把很多“以前照抄能用就行”的细节彻底弄明白了。这篇文章按实际移植的顺序来写,原理讲清楚,代码骨架给出来,最后把最容易翻车的调试场景一并说透。
如果你还没写过完整的DRM驱动,这是一条从零理解SoC显示控制器如何变成/dev/dri/card0的完整路径;如果你已经写过dumb或fbdev相关代码,那这篇文章的重点会放在atomic提交、buffer生命周期和vblank同步这些真正容易写错的地方。
1. LSDC在SoC中的角色,以及为什么非要用DRM不可
1.1 一个显示控制器到底负责干什么
先明确一件事:LSDC不是GPU,它没有3D引擎,也没有复杂的着色器管线。它就是SoC内部集成的一个显示控制器,简单说是个“从内存往屏幕搬运像素的DMA机器”。它的核心工作可以拆成两条链:
- 读取链:从系统内存的framebuffer中,按照设定的行偏移和像素格式,把数据搬进内部的FIFO。
- 输出链:把FIFO里的数据按照像素时钟逐行逐像素发送给外部接口,比如RGB并口、LVDS、HDMI或MIPI DSI。
所以你在驱动里要做的事,本质上就两件:告诉它“去哪块内存搬数据”,以及“按什么时序把数据送出去”。前一件事对应DRM里的framebuffer/GEM对象,后一件事对应DRM里的CRTC、encoder和connector。
1.2 为什么放弃“自己写个fbdev驱动”的念头
很多做嵌入式的人第一反应是“我直接注册一个struct fb_info,把fb_ops写一写不就行了吗?” 早期的确可以,但现在的内核里,fbdev已经被定位成兼容层,真正的显示管理必须走DRM。原因很实在:
- 多进程访问:fbdev传统模型是一块全局framebuffer,谁写谁冲突,没有统一的buffer所有权管理。DRM从设计上就把
dumb buffer分配、dma buffer共享、fence同步做了完整抽象。 - 热插拔和DPMS:HDMI或DP接口的插拔事件、电源管理,DRM框架给了标准模型,fbdev全靠自己补。
- atomic上下文中写documentation:内核社区对新驱动几乎要求
DRIVER_ATOMIC,你不走这套,连基本review都过不去。
所以哪怕LSDC硬件再简单,正确姿势也是把它接入DRM,而不是再造一个私有的fbdev轮子。
2. 从设备树到drm_device:LSDC驱动的初始化骨架
2.1 probe入口的选择:platform_driver而非pci_driver
LSDC在基于SoC的板卡上基本是板级设备,不是PCIe设备,所以驱动入口首选platform_driver。设备树里要描述的资源很清晰:寄存器地址、中断号、时钟。我习惯这样写DT节点:
display_controller@1e000000 { compatible = "vendor,lsdc"; reg = <0x0 0x1e000000 0x0 0x10000>; interrupts = <29 IRQ_TYPE_LEVEL_HIGH>; clocks = <&dc_clk 0>, <&dc_clk 1>; clock-names = "core", "pixel"; };注意,如果你把“core”和“pixel”两个时钟分开描述,驱动里就能独立控制模块时钟和像素时钟。这是后面调试“屏幕有时起不来”的关键:两者不能共用一个clk,否则你改变像素时钟时,模块时钟也被连带改了,控制器状态会莫名丢失。
2.2 devm_drm_dev_alloc与drm_device的最小初始化
现代驱动推荐用devm_drm_dev_alloc分配struct drm_device,理由是资源释放统一交给device-managed机制,probe中途出错也不会漏释放。
static const struct drm_driver lsdc_drm_driver = { .driver_features = DRIVER_GEM | DRIVER_MODESET | DRIVER_ATOMIC, .name = "lsdc", .desc = "Loongson SoC Display Controller", .date = "20241012", .major = 1, .minor = 0, .fops = &lsdc_fops, }; static int lsdc_probe(struct platform_device *pdev) { struct drm_device *ddev; struct lsdc_device *ldev; int ret; ldev = devm_kzalloc(&pdev->dev, sizeof(*ldev), GFP_KERNEL); if (!ldev) return -ENOMEM; ldev->mmio = devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(ldev->mmio)) return PTR_ERR(ldev->mmio); ddev = devm_drm_dev_alloc(&pdev->dev, &lsdc_drm_driver, struct lsdc_device, drm); if (IS_ERR(ddev)) return PTR_ERR(ddev); platform_set_drvdata(pdev, ddev); drm_mode_config_init(ddev); lsdc_modeset_init(ldev); ret = drm_dev_register(ddev, 0); if (ret) return ret; drm_fbdev_generic_setup(ddev, 32); return 0; }sdev等统一定义完成后,drm_mode_config_init负责初始化设备级模式配置。注意最小实现里你至少还要填两个回调:lsdc_modeset里设置min/max分辨率,以及drm_mode_config_funcs.fb_create。这两个不填,应用层创建framebuffer时会直接报找不到fb_create。
2.3 component框架:解决面板和桥芯片的probe顺序问题
这是我被坑最惨的一次。你以为platform驱动probe时,配套的panel或bridge一定已经probe完了?完全不一定。不同的DT节点probe顺序谁都保证不了。如果lsdc_probe里直接去取panel或bridge,拿到的可能是空指针。
正确做法是引入component框架。LSDC作为aggregate driver注册component_match_add,panel/bridge各自实现component_ops。等所有子设备都注册完毕,内核才调用master_bind。代码结构上是这样:
static int lsdc_component_bind(struct device *dev, struct device *master, void *data) { struct drm_device *ddev = dev_get_drvdata(master); struct lsdc_device *ldev = dev_get_drvdata(dev); return lsdc_modeset_init(ldev); } static const struct component_master_ops lsdc_master_ops = { .bind = lsdc_component_bind, }; static int lsdc_probe(struct platform_device *pdev) { ... ret = component_master_add_with_match(&pdev->dev, &lsdc_master_ops, match); ... }bind在此时才去创建CRTC、encoder、connector,彻底避开“资源没准备好”的时序炸弹。这个设计不是LSDC特有,但几乎所有带panel的显示驱动都应该这么做。
3. 为扫描输出构建内存:dumb、GEM与framebuffer对象
3.1 dumb buffer是给“不知道drm为何物”的APP用的
普通嵌入式GUI不会直接创建GEM对象,它通常调用DRM_IOCTL_MODE_CREATE_DUMB,拿到handle和pitch,再由handle映射到用户态地址上去画图。驱动要实现.gem_create和对应的dumb函数。关键点是pitch对齐。
假设分辨率是1280x720,xRGB8888时一行字节数是1280*4=5120字节。这个数本身已经对齐到64字节,但如果像素格式改成RGB565,一行是2560字节,2560刚好也是64的倍数就不成问题。遇到某些ARM系平台,DC要求行起始地址按256甚至1024对齐,这种情况下pitch必须做更大的对齐,否则画出来的画面每行会倾斜移动。我一般取ALIGN(size, 64),同时允许一个module参数覆盖,方便现场调。
static int lsdc_dumb_create(struct drm_file *file_priv, struct drm_device *ddev, struct drm_mode_create_dumb *args) { unsigned int pitch = ALIGN(args->width * args->bpp / 8, 64); unsigned int size = pitch * args->height; ... args->pitch = pitch; args->size = size; ... }3.2 GEM对象的物理连续性问题
LSDC这类显示控制器的DMA没有复杂页表,所以framebuffer内存最好是物理连续的。你可以在gem_create里使用DMA-backed helper,让分配落在CMA区域,一劳永逸。如果你的平台支持IOMMU,那可以走scatter-gather,但LSDC多数场景没有IOMMU,老老实实用dma_alloc_wc或drm_gem_dma_helper。
这里要特别提醒:DC访问framebuffer是通过总线直接读内存的,内存属性必须是non-cached或write-combine。驱动力图一致性问题:你用CPU往buffer里写了一个像素,DC立刻去读,如果CPU cache还留着脏数据,DC看到的可能是旧内容。于是你会看到“画面更新一半、看起来花屏”这种诡异现象。dma_alloc_wc映射到用户态后能做到不经过CPU cache,但代价是写入稍慢。小型GUI完全可以接受。
3.3 从GEM到framebuffer:一份带refcount的视图
GEM对象只是内存,framebuffer才是“这块内存以什么格式、什么尺寸作为一帧画面”的视图。驱动必须实现drm_mode_config_funcs.fb_create。核心逻辑是根据传进来的四个plane的handle拿到对应的GEM对象,填充framebuffer内部结构。
最容易被偷懒的是:很多驱动为了简化,只用第一个plane,后面的plane填0。如果你的LSDC支持单层,这样确实没问题;但只要硬件支持双图层,你又不实现plane引用,最终结果就是复合图层全灭。标准做法是用drm_fb_create的helper中间层,再按format_info里的num_planes逐个取GEM。
drm_framebuffer内部有完整refcount,而plane在某个atomic commit中引用它时,还会通过drm_framebuffer_get增加引用。这一点务必遵循,否则用户态在page flip还没完成时close handle,内核直接use-after-free。
4. 模式设置的实现路径:CRTC、connector与panel时钟
4.1 CRTC的核心不是“画”,而是“出时序”
CRTC负责像素流的时序生成。LSDC的CRTC实现重点在crtc_mode_set和atomic_flush阶段。你可以把mode里的参数拆成几个时序抬升参数:
htotal=hdisplay + hsync_start...实际上用drm_display_mode里的结构成员。vtotal类似。- pixel clock就是要设置的频率。
计算像素时钟的经典例子:1080p60需要约148.5MHz,而这个值来自htotal * vtotal * refresh。比如htotal为2200,vtotal为1125,则2200112560 ≈ 148.5MHz。驱动里不能直接把mode.clock(单位kHz)当作寄存器写入值,必须先查PLL是否支持。LSDC内部一般有个固定的时钟树,能生成的频率是离散的。如果你发现某个分辨率闪屏或没画面,先检查算出来的pixel clock是不是和PLL实际输出偏差超过5%。时钟偏了,行场同步位置全偏,红屏/花屏都从这里来。
CRTC_flush里还要做一件事:切换新mode前先disable planes,切换完再enable回来。曾经图省事直接在平面上改寄存器,结果面板在mode切换瞬间抓到半帧数据,显示一整屏的杂色竖条。后来我严格按“关plane → 设时序 → 开后stage的plane”流程来,就稳定了。
4.2 connector:EDID、fixed mode和panel bridge
LSDC的connector可以分成两类:
- 外接HDMI/DVI时,走
drm_connector_helper_funcs的get_modes,用drm_get_edid读EDID,再drm_add_edid_modes转成可用mode列表。 - 直接接LCD panel时,没有EDID,通常是读取DT里
panel-timing或display-timing子节点,要么提前准备一个固定mode数组。
我的手写习惯是:在get_modes里先尝试EDID,如果返回0,再退回fixed mode。这样可以保证内部屏和外部HDMI共用一个connector驱动还能无缝切换。
static int lsdc_connector_get_modes(struct drm_connector *connector) { struct edid *edid = drm_get_edid(connector, connector->ddc); int count = 0; if (edid) { drm_connector_update_edid_property(connector, edid); count = drm_add_edid_modes(connector, edid); drm_edid_free(edid); } if (!count) count = drm_add_modes_noedid(connector); drm_set_preferred_mode(connector, 1024, 600); return count; }connector接到LVDS或RGB并口panel时,后面往往还有一块bridge。bridge芯片的初始化顺序很讲究:bridge_attach必须在connector注册前完成,因为connector的driver_private会指向bridge;而bridge的pre_enable/enable生命周期又依赖CRTC的flush。如果顺序错了,kernel report consistent但面板就是不亮。
4.3 panel节点:谁负责上下电
panel的电源和背光控制,一般不由LSDC驱动直接操作,而是由drm_panel框架管理。你的驱动在connector init时做panel_attach,然后在encoder_enable之前调用drm_panel_prepare,在encoder_disable之后调用drm_panel_unprepare。
这里有个常见坑:pre_enable里加延时。很多panel要求display信号稳定后延迟10ms到50ms才拉背光。你如果只做panel prepare,不做unprepare,屏幕关闭时会残影,就是背光没按规格关掉的典型毛病。我吃过亏后把所有panel时序整理成一张表,每个panel型号的power-on-sequence都单独写成driver data,而不是都写成同一个默认值。
5. vblank、页面翻转与buffer引用的“不占便宜”对接
5.1 vblank中断和页面翻转的关系
LSDC硬件会在每帧结束(VBI)时产生中断,驱动把它映射为drm_crtc_handle_vblank(crtc)。这个中断直接影响两个东西:应用层drmWaitVBlank,以及atomic page flip的完成事件。
你要在atomic_flush里注册一个drm_crtc_vblank_get,把commit->event保存下来,等人为触发vblank中断时,用drm_crtc_send_vblank_event发给等待的用户态进程。如果漏了这一步,典型表现是:应用层调用page flip后,画面确实变了,但程序一直卡在等待flip完成事件,超时掉帧,最后整个GUI线程僵死。
5.2 原子提交路径下的buffer销毁保护
DRM对象三件套——dumb buffer、GEM object、framebuffer——引用关系比看起来复杂。我举一个真实的崩溃过程:
- 用户态创建了一个dumb buffer,然后创建framebuffer A,提交给plane。
- 用户态立刻close handle并munmap,看似安全。
- 但DC还在扫描A的地址;如果A的GEM对象在plane还在用的时候就被释放,DC下一帧就会读到已释放的内存。
保护机制:drm_plane里维护fb指针引用,原子提交时drm_framebuffer_get,提交完成后drm_framebuffer_put。只要你的atomic_update/atomic_disable代码严格按照“先取引用,后更新寄存器”的顺序,就不会把释放后的内存交给DC。
我一度为了省一个分支判断,在atomic_update里直接拿新的fb,不判断旧的fb是否还在plane上。测试长时间随机开关页面翻转时,偶尔会屏幕花一下,内核日志里还有“kernel: page fault”字样。查了半天才发现是旧fb在cleanup_fb阶段被释放,新fb又还没挂上去时,DC仍在读旧地址。
5.3 不推荐手工实现“最后一分钟写寄存器”
DRM提供了helper框架,比如drm_atomic_helper_commit。很多小驱动喜欢绕过helper,自己直接实现commit_tail。对于LSDC这种简单控制器,自己实现commit不是不行,但你会丢掉框架自带的两样东西:非阻塞提交、以及fence同步。
简单说,非阻塞提交是把“等待vblank”和“更新硬件”分离,让应用层不必等硬件真正切换完才能准备下一帧。fence同步则是让GPU/NPU等其他引擎在某个buffer被扫描输出前不得改写它。LSDC没有GPU,但你可能有一个视频解码器也需要写同一个buffer,没有fence就会出现“解码器改一半、DC读一半”的问题。
因此我的建议是:即使想优化,也要基于drm_atomic_helper_commit,只覆写atomic_commit_tail里和LSDC硬件强相关的部分(比如准备寄存器值、清中断标志),其他交给框架。
6. 调试LSDC最常踩的坑与我的排查方法
6.1 先分清“时序问题”还是“内存问题”
接到一个“黑屏/花屏”报障,我绝不急着改代码。第一件事是分辨现象属于哪一类:
- 如果黑屏但背光亮,可以怀疑CRTC没输出、panel时序不对、pixel clock没起来。
- 如果显示全屏雪花或强烈花屏,多半是像素时钟和panel时序严重不匹配。
- 如果显示正常但有规则的横纹每隔几行错位,大概率是pitch不对或者DMA起始地址没对齐。
我用一个很简单的方法判断:在fb上画纯色条纹,然后故意把pitch调成错误值,看条纹是不是每行都斜。这比看寄存器快得多。
6.2 用modetest和kmscube验证
遇到LSDC驱动起不来的情况,不建议一上来直接跑GUI。先用modetest -M lsdc,它会列出所有connector、encoder、CRTC、plane。如果modetest看到的资源比预期少,说明你的get_resources回调有遗漏。再跑modetest -M lsdc -s直接设定一个mode,模式切换正常了,再上GUI。
kmscube可以同时验证页面翻转和业务格式。如果kmscube能跑但跑一会儿就丢帧,看dmesg里有没有page flip timed out。有的话,十有八九是vblank中断没有正常处理,重点检查中断号注册和pm_runtime是否在commit过程中把显示控制器的时钟关了。
6.3 一个几乎害我重写驱动的“时钟门控”问题
有一次我下载新内核后LSDC驱动随机黑屏,代码改动明明没涉及电源管理。后来在家用逻辑分析仪抓panel的VSYNC信号,发现它时有时无。一路追进去,发现新内核的runtime PM默认把display controller当作“不活跃设备”在某个时刻关掉了时钟。而我的驱动根本没实现runtime_suspend/resume,时钟一关就再也拉不回来了。
解决的方式很简单:在probe里设置pm_runtime_use_autosuspend,并实现resume回调里做完整的寄存器恢复。不要以为显示控制器是“常开设备”,现代内核里它一样会被runtime PM管理。从此我再也不敢省resume函数。
6.4 内核报“atomic check failed”时怎么定位
atomic check失败常常发生在你改了分辨率,但connector的mode_valid没有允许该mode。比如internal panel只支持800x480,你却把HDMI的1080p mode硬设给它。请先在模式列表里打印一遍drm_mode_create后的mode,再逐项填mode_valid检查。多数情况下都是clock > max_tmds_clock或hdisplay > max_width这类硬限制没写对。
7. 我最终保留的几点LSDC实现原则
写到这里,我的体会是:LSDC这号简单显示控制器恰恰最适合用来理解DRM。它没有GPU那么多状态和调度问题,但该有的模版一样不少。因为简单,你能清晰看到每个环节为什么要这样设计。
实际维护中我给自己定了几条铁律:
- 没想清楚buffer引用生命周期之前,不碰页面翻转代码。
- 所有硬件寄存器变更前,先确认当前CRTC/plane处于disable还是enable状态,别稀里糊涂地写。
- 不要关掉
drm_atomic_helper_commit去手写提交流程,LSDC的硬件性能撑不起复杂的“手写优化”。 - 每次换内核版本,第一件事检查DT里的时钟名是否变了,很多“驱动没动却黑屏”都是DT描述跟新内核不一致导致的。
最后再说一个实用的小技巧:调试阶段可以在LSDC驱动里专门加一个debugfs文件,把当前CRTC的htotal/vtotal/pixel clock/plane地址全部导出来。这个文件比任何寄存器dump都直观——因为它打印的是经过DRM框架换算后的逻辑值,和硬件寄存器的原始值对比,你能立即看出是框架算错还是硬件请假。好多疑难杂症就是这么一句一句比对出来的。