嵌入式DRM驱动从零移植:LSDC显示控制器atomic modeset实战与调试

发布时间:2026/10/18 23:49:14
嵌入式DRM驱动从零移植:LSDC显示控制器atomic modeset实战与调试
在嵌入式显示领域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同步做了完整抽象。热插拔和DPMSHDMI或DP接口的插拔事件、电源管理DRM框架给了标准模型fbdev全靠自己补。atomic上下文中写documentation内核社区对新驱动几乎要求DRIVER_ATOMIC你不走这套连基本review都过不去。所以哪怕LSDC硬件再简单正确姿势也是把它接入DRM而不是再造一个私有的fbdev轮子。2. 从设备树到drm_deviceLSDC驱动的初始化骨架2.1 probe入口的选择platform_driver而非pci_driverLSDC在基于SoC的板卡上基本是板级设备不是PCIe设备所以驱动入口首选platform_driver。设备树里要描述的资源很清晰寄存器地址、中断号、时钟。我习惯这样写DT节点display_controller1e000000 { 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_addpanel/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对齐。假设分辨率是1280x720xRGB8888时一行字节数是1280*45120字节。这个数本身已经对齐到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里的参数拆成几个时序抬升参数htotalhdisplay hsync_start...实际上用drm_display_mode里的结构成员。vtotal类似。pixel clock就是要设置的频率。计算像素时钟的经典例子1080p60需要约148.5MHz而这个值来自htotal * vtotal * refresh。比如htotal为2200vtotal为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 connectorEDID、fixed mode和panel bridgeLSDC的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框架换算后的逻辑值和硬件寄存器的原始值对比你能立即看出是框架算错还是硬件请假。好多疑难杂症就是这么一句一句比对出来的。