Page Flip 机制:从用户态到硬件的最后一跃

双缓冲的核心,说白了就是「前台画布给你看,后台画布给我画」。但真正让这个机制跑起来的,是 Page Flip——也就是页面翻转。我最早接触这个概念时,以为就是改个指针地址那么简单。后来踩了坑才发现,这里面的门道,比想象中深得多。

drmModePageFlip:用户态的唯一入口

在 DRM 框架里,drmModePageFlip 是用户态程序用来触发翻转的标准接口。它的原型长这样:

int drmModePageFlip(int fd, uint32_t crtc_id, uint32_t fb_id,
                    uint32_t flags, void *user_data);

参数不多,但每个都很关键:

  • fd:DRM 设备文件描述符,你打开 /dev/dri/card0 拿到的那个。
  • crtc_id:你要翻转哪个 CRTC(显示控制器)的扫描缓冲区。
  • fb_id:新 framebuffer 的 ID,也就是后台画好了的那一帧。
  • flags:控制翻转行为的标志位,后面细说。
  • user_data:用户自定义数据,通常用来传一个事件回调的上下文。

调用这个函数后,内核会做两件事:

  1. 把 CRTC 的扫描指针指向新的 framebuffer。
  2. 在下次垂直消隐期(VBlank)到来时,硬件真正切换显示源。

嗯,这里要注意——翻转不是立即生效的。你调用完 drmModePageFlip,返回 0 只代表请求提交成功,不代表画面已经切过去了。真正的切换,要等下一个 VBlank。

我的习惯:每次调用 drmModePageFlip 后,我都会检查返回值。如果返回 -EACCES,说明你还没设置好 CRTC 的 mode;如果返回 -EBUSY,说明上一次翻转还没完成。这两个错误我在项目里都遇到过,尤其是 -EBUSY,调试起来特别容易忽略。

异步 vs 同步翻转:两种模式,两种代价

flags 参数里有两个最重要的标志:DRM_MODE_PAGE_FLIP_EVENT 和 DRM_MODE_ATOMIC_NONBLOCK。但真正区分翻转行为的,是 同步 和 异步 这两个概念。

特性 同步翻转 异步翻转
触发时机 等待下一个 VBlank 立即切换,不等待 VBlank
画面撕裂 无撕裂 可能出现撕裂
延迟 固定延迟(1帧以内) 低延迟(立即生效)
适用场景 视频播放、UI 渲染 游戏、VR、低延迟交互
内核实现 在 VBlank 中断中切换 在调用进程上下文中切换

同步翻转是默认行为。你调用 drmModePageFlip 时不加 DRM_MODE_PAGE_FLIP_ASYNC 标志,就是同步模式。内核会在下一个 VBlank 到来时,在中断处理函数里完成指针切换。这样做的好处是画面完整,不会出现上半帧是旧的、下半帧是新的那种撕裂现象。

异步翻转则激进得多。它不等待 VBlank,直接改硬件寄存器。你想想看,如果此时显示器正在扫描中间某一行,你突然把 framebuffer 地址换了——那这一帧的画面就会从中间劈开,上半部分来自旧帧,下半部分来自新帧。这就是撕裂。

我曾经踩过的坑:有一次做低延迟交互项目,为了追求极致响应,我全程用了异步翻转。结果用户反馈画面「闪了一下」。排查了半天才发现,是异步翻转导致撕裂被摄像头拍到了。后来我改成:关键操作用异步,普通渲染用同步。效果好了很多。

Flip 完成事件:你怎么知道翻转成功了?

调用 drmModePageFlip 返回 0,只代表请求被内核接受了。那你怎么知道硬件真的切过去了?答案是:Flip 完成事件。

当你设置了 DRM_MODE_PAGE_FLIP_EVENT 标志,内核会在翻转真正完成后,通过 DRM 事件队列通知你。这个事件通过 read() 或 drmHandleEvent() 读取,结构体定义如下:

struct drm_event_vblank {
    struct drm_event base;
    uint32_t user_data;  // 你传进去的 user_data
    uint32_t tv_sec;
    uint32_t tv_usec;
    uint32_t sequence;   // VBlank 序列号
    uint32_t reserved;
};

事件处理流程大概是这样的:

  1. 你调用 drmModePageFlip(fd, crtc_id, fb_id, DRM_MODE_PAGE_FLIP_EVENT, &my_data)。
  2. 内核把请求加入队列,返回 0。
  3. 下一个 VBlank 到来,硬件切换 framebuffer。
  4. 内核向 DRM 事件队列写入一个 drm_event_vblank 结构体。
  5. 你的程序通过 drmHandleEvent(fd, &event_context) 读取并分发这个事件。
  6. 在 event_context.page_flip_handler 回调里,你拿到 user_data,知道这一帧已经显示出去了。

这个机制保证了 帧率同步。你可以在回调里释放旧 framebuffer、提交新渲染任务,形成一个完整的流水线。

关键点:不要在主线程里阻塞等待 Flip 完成事件。正确的做法是用 epoll 或 poll 监听 DRM 设备文件描述符的可读事件,在事件循环里调用 drmHandleEvent。这样你的渲染线程可以继续准备下一帧,不会因为等待而空转。

知识体系:Page Flip 的核心逻辑

下面这张图,把整个 Page Flip 的流程和关键节点串起来了。我建议你把它存下来,调试时对照着看。

Page Flip 核心流程 用户态应用 DRM 核心 硬件 CRTC drmModePageFlip() 等待 VBlank 切换 framebuffer Flip 完成事件 关键说明: • 同步翻转:等待 VBlank → 无撕裂 → 固定延迟 • 异步翻转:立即切换 → 可能撕裂 → 低延迟 • Flip 完成事件通过 drmHandleEvent() 在事件循环中读取

实际项目中的选择策略

我做过几个不同场景的项目,总结了一套选择策略,分享给你:

  • 视频播放器:用同步翻转 + Flip 完成事件。每一帧都等 VBlank,画面稳定,不会撕裂。帧率由视频源决定,不需要额外同步。
  • 游戏引擎:用同步翻转为主,但在某些低延迟操作(如鼠标点击反馈)时临时切到异步。注意切回同步时要等一帧,否则会撕裂。
  • VR/AR 头显:必须用异步翻转。延迟每多 1ms,用户就多一分眩晕感。但代价是你要自己处理撕裂——通常通过双缓冲加垂直同步信号来缓解。
  • 桌面合成器(Compositor):用同步翻转 + 原子操作(Atomic API)。因为桌面环境对画面完整性要求极高,撕裂会严重影响体验。
一个小技巧:如果你不确定当前硬件是否支持异步翻转,可以调用 drmSetClientCap(fd, DRM_CLIENT_CAP_ASYNC_PAGE_FLIP, 1) 来尝试启用。如果返回 0,说明支持;如果返回 -EINVAL,说明你的显卡驱动没实现这个功能。我遇到过一些嵌入式 GPU,异步翻转就是个空壳,调了等于没调。