4、页面翻转:垂直同步与FBIOPAN_DISPLAY实战

页面翻转,英文叫Page Flipping。说白了,就是让显示画面在多个缓冲区之间来回切换。

你想想看,如果只有一个缓冲区,你往里面写数据的同时,硬件也在读它去刷新屏幕。这就会出问题——你写到一半,硬件把半成品读走了,屏幕上就会出现撕裂感。嗯,就是那种画面上下对不齐的感觉。

双缓冲就是为了解决这个。一个缓冲区给硬件读(前台),一个给你写(后台)。写完了,交换一下。但交换的时机很关键——你不能随便换,得等硬件把当前这一帧完整显示完了再换。这个等待的机制,就是垂直同步。

4.1 垂直同步:为什么要等VSync?

垂直同步,全称Vertical Synchronization,缩写VSync。它利用的是显示器的消隐期。

显示器刷新画面,是从左到右、从上到下逐行扫描的。扫完最后一行最后一列,电子枪要回到左上角重新开始。这个回扫的时间窗口,就是垂直消隐期(Vertical Blanking Interval,VBI)。

在这个窗口里切换缓冲区,最安全。因为硬件暂时不读显存了,你换页面它看不见。

我在项目中遇到过一个问题:有个客户说他们的设备画面偶尔闪一下。查了很久,最后发现是切换缓冲区没等VSync,刚好在屏幕中间区域切换的。上半帧是旧画面,下半帧是新画面,人眼就感觉闪了一下。

核心原则:页面翻转必须在垂直消隐期内完成。否则轻则撕裂,重则闪屏。

4.2 FBIO_WAITFORVSYNC:怎么等?

Linux framebuffer驱动提供了一个IOCTL命令:FBIO_WAITFORVSYNC。调用它,进程就会阻塞,直到下一个VSync信号到来。

用法很简单:

#include <linux/fb.h>
#include <sys/ioctl.h>
#include <unistd.h>

int fb_fd;
int crtc = 0;  // 通常为0,多显示器时指定CRTC编号

fb_fd = open("/dev/fb0", O_RDWR);
if (fb_fd < 0) {
    perror("open fb0 failed");
    return -1;
}

// 等待下一个垂直同步信号
if (ioctl(fb_fd, FBIO_WAITFORVSYNC, &crtc) == -1) {
    perror("FBIO_WAITFORVSYNC failed");
    close(fb_fd);
    return -1;
}

// VSync到了,可以安全切换了
printf("VSync arrived, safe to flip\n");

这里有个坑——FBIO_WAITFORVSYNC不是所有驱动都支持。我早期在某个ARM平台上就踩过这个坑,驱动里根本没实现这个IOCTL,调用直接返回EINVAL。后来我查了内核源码,发现是那个厂商的framebuffer驱动太老了,没加这个功能。

注意:调用FBIO_WAITFORVSYNC前,先检查驱动是否支持。可以用FBIOGET_VSCREENINFO获取可变信息,看activate字段是否支持FB_ACTIVATE_VBL标志。或者直接试调用,捕获EINVAL错误。

4.3 FBIOPAN_DISPLAY:真正的页面翻转

等到了VSync,接下来就该翻页了。翻页用的是另一个IOCTL:FBIOPAN_DISPLAY。

这个命令的作用是告诉驱动:把显示起始地址改成另一个缓冲区的地址。硬件下次从新地址开始读数据,画面就切换了。

它的参数是一个fb_var_screeninfo结构体。关键字段是xoffset和yoffset——这两个偏移量决定了显示从显存的哪个位置开始。

#include <linux/fb.h>
#include <sys/ioctl.h>

struct fb_var_screeninfo var;
int fb_fd;
int buffer_index = 0;  // 0或1,表示当前显示哪个缓冲区

// 先获取当前可变信息
ioctl(fb_fd, FBIOGET_VSCREENINFO, &var);

// 假设每个缓冲区占一帧大小
// 帧大小 = xres * yres * bpp / 8
// 这里用yoffset来切换:buffer 0的yoffset=0,buffer 1的yoffset=yres
buffer_index = 1 - buffer_index;  // 切换到另一个缓冲区
var.yoffset = buffer_index * var.yres;

// 设置激活方式:等待VSync
var.activate = FB_ACTIVATE_VBL;

// 执行页面翻转
if (ioctl(fb_fd, FBIOPAN_DISPLAY, &var) == -1) {
    perror("FBIOPAN_DISPLAY failed");
}

注意var.activate = FB_ACTIVATE_VBL这一行。它告诉驱动:等下一个VSync到了再真正切换。如果你不设这个标志,驱动可能立即切换,那就白等VSync了。

我的习惯:先调FBIO_WAITFORVSYNC阻塞等VSync,再调FBIOPAN_DISPLAY设FB_ACTIVATE_VBL。双重保险,确保切换发生在消隐期。虽然多了一次系统调用,但稳定性更好。

4.4 完整流程:从等待到翻转

把上面几个步骤串起来,就是一个完整的页面翻转流程:

  1. 分配两个缓冲区(可以是同一块显存的不同偏移,也可以是两块独立的物理内存)
  2. 在后台缓冲区绘制新帧
  3. 调用FBIO_WAITFORVSYNC等待垂直同步
  4. 调用FBIOPAN_DISPLAY设置新的显示起始地址
  5. 驱动在下一个VSync到来时真正切换
  6. 新帧显示,旧帧变成后台缓冲区
  7. 重复步骤2-6

我画了一张流程图,帮你理清这个循环:

页面翻转完整流程图 页面翻转完整流程 分配双缓冲区 后台缓冲区绘制新帧 FBIO_WAITFORVSYNC FBIOPAN_DISPLAY 驱动在VSync时切换 循环 关键点: • 步骤3和步骤4可以合并:FBIOPAN_DISPLAY设FB_ACTIVATE_VBL后,驱动内部会等VSync • 但分开调用更可控,尤其在高帧率场景下可以提前准备下一帧

4.5 参数对比:不同翻转模式

实际项目中,页面翻转有几种不同的实现方式。我整理了一个表格,方便你对比:

模式 实现方式 优点 缺点
FBIO_WAITFORVSYNC + FBIOPAN_DISPLAY 用户态等待VSync,再调用翻转 控制灵活,可提前准备下一帧 两次系统调用,延迟略高
FBIOPAN_DISPLAY + FB_ACTIVATE_VBL 驱动内部等待VSync后翻转 一次系统调用,延迟低 无法精确控制等待时机
FBIOPAN_DISPLAY + FB_ACTIVATE_NOW 立即翻转,不等VSync 延迟最低 可能产生画面撕裂

我个人习惯用第一种。虽然多一次系统调用,但控制力更强。尤其是在做60fps动画时,我可以提前把下一帧画好,等VSync信号一到,立刻翻转。这样帧率更稳定。

4.6 避坑指南:我踩过的几个坑

做页面翻转,有几个坑是绕不开的。我一个个说:

  • 缓冲区对齐问题:有些硬件要求缓冲区的起始地址对齐到页边界(4KB)。如果你用mmap分配,默认就是页对齐的。但如果你自己算偏移,记得对齐。我曾经因为偏移没对齐,画面显示出来是歪的,查了半天才发现是地址没对齐。
  • 多显示器场景:如果系统有多个显示器,每个显示器有自己的CRTC。调用FBIO_WAITFORVSYNC时要指定正确的CRTC编号。我见过有人只等主显示器的VSync,结果副显示器的画面撕裂了。
  • 驱动不支持VSync:前面说了,不是所有驱动都实现了FBIO_WAITFORVSYNC。遇到这种情况,要么改驱动,要么退而求其次用定时器模拟。但定时器不准,帧率会抖。
  • 缓冲区大小计算:别忘了考虑行对齐(pitch)。有些硬件要求每行像素数对齐到16或32字节。你算缓冲区大小时要用pitch,而不是xres * bpp / 8。否则两个缓冲区之间会有缝隙,画面会错位。

特别提醒:在嵌入式设备上,FBIOPAN_DISPLAY的yoffset不能超过显存总大小。否则驱动可能直接崩溃,或者显示乱码。我见过一个案例,有人把yoffset设成了yres * 2,结果显存只有两帧大小,直接越界了。

4.7 实战代码片段:一个简单的双缓冲循环

最后,给你一个完整的双缓冲循环代码。这是我项目里常用的模板:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/fb.h>

struct framebuffer {
    int fd;
    struct fb_var_screeninfo var;
    struct fb_fix_screeninfo fix;
    unsigned char *fb_mem;
    int frame_size;
    int current_buffer;
};

int fb_init(struct framebuffer *fb, const char *dev) {
    fb->fd = open(dev, O_RDWR);
    if (fb->fd < 0) return -1;

    ioctl(fb->fd, FBIOGET_VSCREENINFO, &fb->var);
    ioctl(fb->fd, FBIOGET_FSCREENINFO, &fb->fix);

    // 计算一帧大小,注意用fix.line_length(pitch)
    fb->frame_size = fb->fix.line_length * fb->var.yres;

    // mmap两帧大小的显存
    fb->fb_mem = mmap(NULL, fb->frame_size * 2,
                      PROT_READ | PROT_WRITE,
                      MAP_SHARED, fb->fd, 0);
    if (fb->fb_mem == MAP_FAILED) {
        close(fb->fd);
        return -1;
    }

    fb->current_buffer = 0;
    return 0;
}

void fb_flip(struct framebuffer *fb) {
    int crtc = 0;
    struct fb_var_screeninfo var;

    // 1. 等待VSync
    ioctl(fb->fd, FBIO_WAITFORVSYNC, &crtc);

    // 2. 切换到另一个缓冲区
    fb->current_buffer = 1 - fb->current_buffer;

    // 3. 获取当前可变信息,修改yoffset
    ioctl(fb->fd, FBIOGET_VSCREENINFO, &var);
    var.yoffset = fb->current_buffer * fb->var.yres;
    var.activate = FB_ACTIVATE_VBL;

    // 4. 执行翻转
    ioctl(fb->fd, FBIOPAN_DISPLAY, &var);
}

// 获取当前可写的后台缓冲区指针
unsigned char *fb_back_buffer(struct framebuffer *fb) {
    int back = 1 - fb->current_buffer;
    return fb->fb_mem + back * fb->frame_size;
}

这个模板我用了好几年,在多个项目里验证过。你拿过去改改就能用。

嗯,页面翻转的核心就这些。记住一句话:等VSync再翻,翻完再画下一帧。顺序别搞反了。