从零实现最简单的DRM应用程序:plane-test 中 drmModeSetPlane 与 Plane/CRTC 的配合实践
1. 从全屏刷图到局部合成plane-test 到底解决了什么问题如果你已经跑通过drmModeSetCrtc()或者drmModePageFlip()大概率会有一种“显示也就这样了”的错觉打开/dev/dri/card0拿一个 framebuffer往 CRTC 上一挂屏幕就亮了。但真到要做多图层叠加、视频小窗、OSD 菜单、局部刷新的时候你会发现这两个接口根本不够用——它们只能把一整块 framebuffer 铺满整个屏幕没法只显示其中一块区域更没法把多个 buffer 叠在一起。plane-test就是用来补上这块拼图的。它是 libdrm 测试集里一个非常小的示例核心只做一件事调用drmModeSetPlane()把 framebuffer 里指定的一块矩形区域搬到屏幕上的指定位置。听起来简单但它背后牵扯出 DRM 显示管线里最关键的一对关系——Plane 和 CRTC 的绑定。先把概念理清楚不然后面看代码会晕。DRM 里的 Plane 不是 YUV 里的那个 plane。YUV 的 plane 是内存排布概念Y 一段、UV 一段DRM 的 Plane 是 Display Controller 里的硬件图层单元是实打实的硬件模块。你可以把它想成一块“贴纸”framebuffer 是整张画布Plane 负责从画布上剪下一块贴到 CRTC 这块“展示板”的某个坐标上。CRTC 负责的是时序——它决定屏幕什么时候扫到哪一行、像素时钟多少、分辨率多大。Plane 负责的是内容——它决定这一层显示什么、显示在哪、多大、什么格式。所以一个完整的显示链路是这样的framebuffer 提供像素数据Plane 从 framebuffer 取数据并做裁剪/缩放/格式转换CRTC 把 Plane 合成后的结果按时序输出到 encoderencoder 再送到 connector 对应的物理接口。drmModeSetCrtc()做的是“把某个 framebuffer 作为主图层挂到 CRTC 上”而drmModeSetPlane()做的是“把某个 framebuffer 通过某个 Plane 挂到某个 CRTC 上”。前者是后者的特例后者更底层、更灵活。这篇面向的是刚接触 Linux DRM 显示管线的开发者尤其是那些已经能跑通 modeset 但想进一步理解 Plane 机制的人。我会给出可复制的编译命令、运行参数、dmesg 验证步骤让你在本地真正跑起来并看到画面变化。热词里的 DRM、plane-test、drmModeSetPlane、Plane、CRTC 会贯穿全文但不会为了堆词而堆词。需要提前说明一点Plane 不是所有硬件都支持。像 s3c2440 那种老 ARM9 的 LCDC 就没有真实 Plane 硬件但 DRM 框架规定每个 CRTC 必须有一个 Primary Plane哪怕这个 Primary Plane 就是 LCDC 本身。所以你在不同板子上跑 plane-testdrmModeGetPlaneResources()返回的 plane 数量可能差别很大。这不是 bug是硬件差异。2. 跑 plane-test 之前TaoToken 与 DRM 环境的前置准备在真正敲代码之前先把环境理顺。plane-test 本身不依赖任何云服务它是纯本地 DRM 操作但如果你后续想把这类底层调试经验整理成文档、或者用 AI 辅助分析 dmesg 日志和 libdrm 源码一个稳定的模型调用入口会省很多事。我平时会把 TaoToken 作为统一的 API 入口来用官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 它兼容常见的 OpenAI 风格调用拿来问 libdrm 结构体含义、让模型解释drmModeSetPlane参数、或者帮你把 dmesg 里的报错翻译成人话都很顺手。不过这一节的重点还是本地 DRM 环境。你需要一台带 DRM 驱动的 Linux 机器物理机最好虚拟机里如果显卡直通也能跑但很多虚拟显卡的 Plane 支持不完整drmModeGetPlaneResources()可能只返回一个 Primary Plane甚至返回空。我试过在 QEMU 的 virtio-gpu 上跑plane 数量是 1能验证基本流程但看不到多图层叠加效果。先确认内核 DRM 驱动正常加载ls /dev/dri/ # 正常应该看到 card0 和 renderD128如果只有card0没有renderD128说明没有独立的 render node不影响 plane-test因为 plane-test 走的是 card0 的 modeset 路径。接着看当前有哪些 CRTC 和 connectorcat /sys/class/drm/card0-*/status # 找到 status 为 connected 的那个比如 card0-HDMI-A-1然后确认没有显示服务占用 card0。如果你跑着 Xorg、Wayland 或者 kmsconopen(/dev/dri/card0, O_RDWR)可能直接返回Permission denied或者虽然打开成功但drmModeSetCrtc报EBUSY。最稳妥的做法是切到纯文本 ttyCtrlAltF3停掉显示管理器sudo systemctl stop gdm3 # 或 lightdm / sddm或者用sudo chvt 3切到没有 X 的虚拟终端。这一步不做后面大概率卡在Permission Denied这是新手最常见的坑。依赖库方面需要 libdrm 开发包和内核头文件# Debian/Ubuntu sudo apt install libdrm-dev build-essential linux-headers-$(uname -r) # Fedora/RHEL sudo dnf install libdrm-devel gcc make kernel-devel验证 libdrm 版本plane-test 用到的drmModeSetPlane在 libdrm 2.4.x 之后都稳定pkg-config --modversion libdrm # 输出类似 2.4.120如果你打算用 TaoToken 辅助排查可以先把 API Key 配好后面遇到drmModeGetPlaneResources返回 NULL 或者drmModeSetPlane返回EINVAL时把结构体定义和报错一起丢给模型比翻 man page 快。API Key 在 https://taotoken.net/api-keys 生成接入文档在 https://taotoken.net/doc 。模型对话入口是 https://taotoken.net/models 长期做底层编码调试的话 Coding Plan 在 https://taotoken.net/coding-plan 更划算。这些链接都带utm_sourcetaotoken_aicg_blog_endutm_campaignrewrite方便归因但对你使用没影响。环境准备好之后先别急着写完整代码用一个小命令确认 DRM 设备可访问sudo apt install drm-info # 或者用 modetest modetest -c -p -M card0modetest会打印出所有 connector、CRTC、plane 的信息。重点看 plane 列表里有没有typePrimary和typeOverlay以及每个 plane 支持的 formats。如果modetest都跑不起来plane-test 也不用试了先解决驱动问题。3. 可复制的 plane-test 配置与编译运行这一节给出完整的可复制配置。先建目录、写源码、写 Makefile然后编译运行。源码基于 libdrm 的tests/planetest/planetest.c精简只保留最小可跑逻辑但补上了错误检查和资源释放避免你跑完一次后设备节点被占住。先建工作目录mkdir -p ~/drm-lab/plane-test cd ~/drm-lab/plane-test写modeset-plane-test.c完整内容如下。注意drmModeSetPlane的 src 坐标要左移 16 位这是 DRM 的定点数约定16.16 格式#define _GNU_SOURCE #include errno.h #include fcntl.h #include stdbool.h #include stdint.h #include stdio.h #include stdlib.h #include string.h #include sys/mman.h #include unistd.h #include xf86drm.h #include xf86drmMode.h struct buffer_object { uint32_t width; uint32_t height; uint32_t pitch; uint32_t handle; uint32_t size; uint8_t *vaddr; uint32_t fb_id; }; static int modeset_create_fb(int fd, struct buffer_object *bo) { struct drm_mode_create_dumb create {0}; struct drm_mode_map_dumb map {0}; create.width bo-width; create.height bo-height; create.bpp 32; if (drmIoctl(fd, DRM_IOCTL_MODE_CREATE_DUMB, create) 0) { perror(CREATE_DUMB); return -1; } bo-pitch create.pitch; bo-size create.size; bo-handle create.handle; if (drmModeAddFB(fd, bo-width, bo-height, 24, 32, bo-pitch, bo-handle, bo-fb_id) ! 0) { perror(AddFB); return -1; } map.handle create.handle; if (drmIoctl(fd, DRM_IOCTL_MODE_MAP_DUMB, map) 0) { perror(MAP_DUMB); return -1; } bo-vaddr mmap(0, create.size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, map.offset); if (bo-vaddr MAP_FAILED) { perror(mmap); return -1; } memset(bo-vaddr, 0xff, bo-size); /* 全白 */ return 0; } static void modeset_destroy_fb(int fd, struct buffer_object *bo) { struct drm_mode_destroy_dumb destroy {0}; drmModeRmFB(fd, bo-fb_id); munmap(bo-vaddr, bo-size); destroy.handle bo-handle; drmIoctl(fd, DRM_IOCTL_MODE_DESTROY_DUMB, destroy); } int main(int argc, char **argv) { int fd; drmModeConnector *conn; drmModeRes *res; drmModePlaneRes *plane_res; uint32_t conn_id, crtc_id, plane_id; struct buffer_object buf {0}; fd open(/dev/dri/card0, O_RDWR | O_CLOEXEC); if (fd 0) { perror(open card0); return 1; } res drmModeGetResources(fd); if (!res) { perror(GetResources); return 1; } crtc_id res-crtcs[0]; conn_id res-connectors[0]; /* 关键不设置这个 capGetPlaneResources 只返回 Overlay */ drmSetClientCap(fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1); plane_res drmModeGetPlaneResources(fd); if (!plane_res || plane_res-count_planes 0) { fprintf(stderr, no plane available\n); return 1; } plane_id plane_res-planes[0]; printf(crtc_id%u conn_id%u plane_id%u\n, crtc_id, conn_id, plane_id); conn drmModeGetConnector(fd, conn_id); buf.width conn-modes[0].hdisplay; buf.height conn-modes[0].vdisplay; modeset_create_fb(fd, buf); /* 先用 SetCrtc 初始化整条链路否则 SetPlane 无效 */ if (drmModeSetCrtc(fd, crtc_id, buf.fb_id, 0, 0, conn_id, 1, conn-modes[0]) ! 0) { perror(SetCrtc); return 1; } printf(full screen white, press Enter to crop...\n); getchar(); /* 从 fb(100,150) 取 320x320放到 crtc(50,50) */ if (drmModeSetPlane(fd, plane_id, crtc_id, buf.fb_id, 0, 50, 50, 320, 320, 100 16, 150 16, 320 16, 320 16) ! 0) { perror(SetPlane); return 1; } printf(cropped, press Enter to exit...\n); getchar(); modeset_destroy_fb(fd, buf); drmModeFreeConnector(conn); drmModeFreePlaneResources(plane_res); drmModeFreeResources(res); close(fd); return 0; }写 Makefile注意链接libdrmCC gcc CFLAGS -Wall -O2 $(shell pkg-config --cflags libdrm) LDFLAGS $(shell pkg-config --libs libdrm) modeset-plane-test: modeset-plane-test.c $(CC) $(CFLAGS) -o $ $ $(LDFLAGS) clean: rm -f modeset-plane-test编译make如果报undefined reference to drmModeSetPlane说明 libdrm 没链上检查pkg-config --libs libdrm输出。如果报xf86drmMode.h: No such file装libdrm-dev。运行前再确认 card0 没被占用sudo fuser -v /dev/dri/card0 # 没有输出说明空闲然后运行sudo ./modeset-plane-test程序会打印crtc_id、conn_id、plane_id屏幕变全白。按一次回车屏幕左上角出现一块 320x320 的白色区域因为 framebuffer 本身就是全白裁剪后还是白但位置和大小变了。再按回车退出。这里有个细节因为 framebuffer 全白裁剪效果肉眼不明显。想看得更清楚把memset(bo-vaddr, 0xff, bo-size)改成画一个彩色渐变或者棋盘格。比如按行填充不同颜色for (uint32_t y 0; y bo-height; y) { for (uint32_t x 0; x bo-width; x) { uint32_t *p (uint32_t *)(bo-vaddr y * bo-pitch x * 4); *p ((x / 64) % 2 (y / 64) % 2) ? 0xFFFF0000 : 0xFF00FF00; } }这样裁剪出来的区域会显示红绿棋盘格位置和大小一目了然。4. 验证请求与成功结果dmesg 与画面双重确认跑起来只是第一步关键是要确认 Plane 真的生效了而不是碰巧屏幕亮了。验证分两条线内核日志和画面行为。先看 dmesg。在运行 plane-test 前后分别抓一次sudo dmesg -C # 清空 sudo ./modeset-plane-test # 按回车触发 SetPlane 后另开一个终端 sudo dmesg | tail -50你会看到类似这样的输出不同驱动措辞不同[drm] crtc 0: plane 1 set to fb 42, src 100x150 320x320, dst 50x50 320x320 [drm:intel_plane_atomic_update] plane 1 update如果是 amdgpu可能看到amdgpu_dm_plane_set之类的。关键是确认有 plane 相关的更新日志而不是只有 crtc 的。如果 dmesg 里完全没有 plane 记录但drmModeSetPlane返回 0说明驱动可能把请求吞了或者你用的 plane 是 Primary 且被 CRTC 独占实际没生效。再看画面。全白 framebuffer 下SetPlane 前后屏幕都是白的看不出区别。所以强烈建议用棋盘格 framebuffer。改完之后重新编译运行你应该看到第一次回车前整屏红绿棋盘格。 第一次回车后屏幕大部分区域还是原来的棋盘格因为 SetCrtc 挂的 Primary Plane 还在但在 (50,50) 位置出现一块 320x320 的区域内容是 framebuffer 里 (100,150) 开始的 320x320 棋盘格。如果这块区域和周围图案对不上说明裁剪坐标生效了。这里有个容易误解的点drmModeSetPlane并不会自动清掉原来的 Primary Plane 内容。你看到的“叠加”效果其实是 Primary Plane 和 Overlay Plane 同时存在硬件按 Z-order 合成。drmModeSetPlane的最后一个参数是 flags传 0 表示不特殊处理。如果你想让这块 plane 透明或者带 alpha需要 framebuffer 格式支持 ARGB 并且设置 blend。验证缩放把drmModeSetPlane的 dst 宽高改成 640x640src 还是 320x320你会看到那块棋盘格被放大一倍。验证平移改 dst 坐标图案跟着移动。验证裁剪改 src 坐标和宽高取 framebuffer 不同区域。用modetest交叉验证 plane 状态modetest -M card0 -p它会列出所有 plane 的当前状态包括crtc_id、fb_id、src、dst。运行 plane-test 后按回车再跑一次modetest -p应该能看到你用的那个 plane 的crtc_id变成了你传入的 crtcfb_id也变了。这是最硬的证据。如果modetest -p里 plane 的crtc_id还是 0说明 SetPlane 没真正绑定。常见原因是drmModeSetCrtc没先执行或者你选的 plane 不支持当前 CRTC。DRM 里 plane 和 CRTC 不是随便配的每个 plane 有possible_crtcs位掩码表示它能挂到哪些 CRTC 上。drmModeGetPlaneResources返回的 plane 结构里有possible_crtcs你可以打印出来printf(plane %u possible_crtcs0x%x\n, plane_id, plane_res-planes[0]);不过drmModeGetPlaneResources只返回 plane id 列表要拿possible_crtcs得用drmModeGetPlane(fd, plane_id)。如果possible_crtcs不包含你选的 crtc 索引SetPlane 会返回EINVAL。成功跑通的标志就三个drmModeSetPlane返回 0、dmesg 有 plane 更新日志、modetest -p显示 plane 绑定了正确的 crtc 和 fb。三个都满足说明 Plane 和 CRTC 的配合没问题。5. 本篇常见报错排查401、local proxy failed、reading choices、OAuth虽然 plane-test 是本地程序不涉及网络请求但很多人在这个阶段会同时折腾 AI 辅助工具或者远程开发环境报错容易混在一起。这一节把几类高频错误分开说清楚避免你排查错方向。第一类open /dev/dri/card0: Permission denied。这是最常见的。原因就两个没用 root 跑或者 card0 被显示服务占用。解决sudo运行或者把当前用户加入video组sudo usermod -aG video $USER后重新登录。如果是被占用sudo fuser -v /dev/dri/card0看谁占着停掉对应服务。注意即使你用 sudo如果 Xorg 已经打开了 card0 并且做了 master你的drmModeSetCrtc会返回EBUSY报错信息是SetCrtc: Device or resource busy。这时候必须切到纯 tty。第二类drmModeGetPlaneResources返回 NULL 或count_planes 0。先确认有没有调drmSetClientCap(fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1)。不调这个很多驱动只返回 Overlay Plane如果硬件没有 Overlay就返回空。调了之后还是空说明驱动没实现 plane 或者虚拟显卡不支持。换物理机或者换驱动。第三类drmModeSetPlane: Invalid argument。EINVAL的原因比较多plane_id 和 crtc_id 不匹配possible_crtcs不包含该 crtc、src/dst 尺寸超出 plane 能力、格式不支持、fb_id 无效。排查顺序先打印drmModeGetPlane返回的possible_crtcs、possible_formats、crtc_id确认 plane 支持你要挂的 crtc。再确认 src 宽高不超过 fb 尺寸dst 宽高不超过 crtc 分辨率。drmModeSetPlane的 src 坐标是 16.16 定点别忘了左移 16 位漏了移位会得到离谱的坐标导致 EINVAL。第四类如果你在用 AI 工具辅助分析可能遇到401 Unauthorized。这是 API Key 没配或者过期。检查你的环境变量或者配置文件里的 key重新在 https://taotoken.net/api-keys 生成一个。local proxy failed通常是本地代理端口没起来或者被防火墙拦了检查代理进程和端口监听。reading choices报错一般是响应体解析失败可能是模型返回了非预期格式重试或者换模型。OAuth相关报错多见于 Claude Code 这类工具需要重新走授权流程检查 token 是否过期。第五类程序跑完一次后第二次跑报Permission denied。这是因为上一次没正常释放资源card0 还被你的进程占着。检查代码里有没有close(fd)有没有在异常分支提前 return 导致 fd 泄漏。用sudo fuser -k /dev/dri/card0强制释放但更好的做法是修代码。第六类画面花了或者颜色不对。检查drmModeAddFB的 depth 和 bpp 参数。上面代码用的是 depth24、bpp32对应 XRGB8888。如果你用 ARGB8888depth 要传 32。传错会导致颜色通道错位。另外 pitch 要用create.pitch不要自己算 width*4因为驱动可能做对齐。如果你在排查过程中需要对照 libdrm 源码或者内核驱动代码可以把相关结构体和报错贴给模型让它帮你定位。模型对话在 https://taotoken.net/models 接入文档在 https://taotoken.net/doc Coding Plan 在 https://taotoken.net/coding-plan 。这些入口都带utm_sourcetaotoken_aicg_blog_endutm_campaignrewrite不影响使用。6. 把 plane-test 用起来从最小示例到实际显示管线调试跑通 plane-test 之后你对 Plane 和 CRTC 的关系应该有了体感。但最小示例和真实项目之间还有距离这一节说几个实际会用到的延伸点。第一Plane 的类型和选择。drmModeGetPlaneResources返回的 plane 列表里顺序不保证 Primary 在前。你要用drmModeGetPlane拿每个 plane 的type字段DRM_PLANE_TYPE_PRIMARY、DRM_PLANE_TYPE_OVERLAY、DRM_PLANE_TYPE_CURSOR。做视频叠加要用 Overlay做鼠标光标要用 Cursor做主显示用 Primary。选错类型可能功能受限比如 Cursor plane 通常尺寸很小只能放 64x64。第二drmModeSetPlane和 atomic API 的关系。现在新驱动都推荐 atomic modesetdrmModeSetPlane是 legacy 接口内部会被转换成 atomic commit。如果你在做新项目建议直接学drmModeAtomicCommit它能一次性提交多个 plane 的状态避免闪烁。但理解 legacy 接口对读懂老代码和调试很有帮助plane-test 就是最好的起点。第三多 plane 叠加的 Z-order。硬件合成时plane 有 Z 轴顺序。Primary 通常在最底层Overlay 在上面Cursor 在最顶。你可以通过drmModeSetPlane的 flags 或者 atomic 的DRM_PLANE_ZPOS属性调整。实际做 OSD 时要让 OSD plane 在视频 plane 之上就得设对 Z-order。第四性能考量。Plane 合成是硬件做的比 GPU 合成省电但 plane 数量有限。一个 CRTC 通常只有 1 个 Primary、1-2 个 Overlay、1 个 Cursor。超过数量就得用 GPU 合成或者软件合成。做多路视频显示时先查硬件支持几个 plane再决定架构。第五调试技巧。除了modetest和 dmesg还可以用drm_debug内核参数打开 DRM 详细日志echo 0x1f /sys/module/drm/parameters/debug然后跑 plane-testdmesg 会打印每一步的 atomic 状态转换能看到 plane 的 src/dst 怎么被驱动接受或拒绝。调试完记得关掉不然日志刷屏。最后说一个实际踩过的坑有些驱动对drmModeSetPlane的 src 宽高有对齐要求比如必须是 2 的倍数。你传 321 可能返回 EINVAL传 320 就正常。遇到 EINVAL 时先把所有尺寸改成 64 的倍数试再逐步缩小范围定位。plane-test 的价值不在于它本身能做什么而在于它把 DRM 显示管线里最核心的 Plane-CRTC 绑定关系用最小代码暴露出来。你把这 100 多行代码吃透后面看 compositor 源码、调试显示异常、写 KMS 应用都会顺很多。编译命令、运行参数、dmesg 验证步骤都在上面直接复制就能跑。跑通之后试着改改 src/dst 参数观察画面变化比看十篇文章都管用。