Framebuffer与图像显示
在上一部分中,我们介绍了一帧图像数据是如何从摄像头出发,经过驱动程序进入用户空间,并最终被保存成图片文件的。接下来,我们沿着另一条方向继续介绍:一帧位于用户空间中的图像数据,是如何经过 Framebuffer 向下传递,并最终显示在 LCD 屏幕上的。
目录
- 图像显示原理
- Framebuffer 介绍
- Framebuffer 编程
- 一、打开 Framebuffer,并获取屏幕信息
- 1. 打开 Framebuffer 设备节点
- 2. 获取 Framebuffer 的固定信息
- 3. 获取 Framebuffer 的可变信息
- 二、使用 mmap() 将 Framebuffer 映射到用户空间
- 三、计算一个屏幕像素对应的 Framebuffer 地址
- 四、根据 Framebuffer 的像素格式转换 RGB24 数据
- 五、将像素数据写入 Framebuffer
- 六、解除映射并释放资源
- 一、打开 Framebuffer,并获取屏幕信息
- 总结
图像显示原理
之前我们说过,图像的本质就是一段按照特定规则组织起来的数据。
更具体地说,对于一幅 RGB 图像来说,它可以看成一个二维的像素阵列,而每个像素又拥有自己的 R、G、B 三个颜色分量。因此,只要确定了每个像素的位置以及对应的 R、G、B 值,就能够确定整幅图像的内容。
例如,一幅宽为 width、高为 height 的 RGB24 图像,在内存中可以简单理解为:
第0行:Pixel(0,0) Pixel(1,0) Pixel(2,0) …
第1行:Pixel(0,1) Pixel(1,1) Pixel(2,1) …
第2行:Pixel(0,2) Pixel(1,2) Pixel(2,2) …
…
每个 Pixel:
R(8bit) + G(8bit) + B(8bit)
屏幕显示图像的原理与此类似。LCD 屏幕同样由大量按照二维空间排列的像素组成,每个像素最终也需要表现出对应的颜色。因此,从数据的角度来看,显示一幅图像,本质上就是让图像中的像素数据按照正确的位置和格式对应到屏幕上的像素。
不过,在 Linux 系统中,应用程序通常并不会直接去控制 LCD 屏幕上的每一个像素。在我们的项目中,应用程序首先将像素数据写入 Framebuffer 对应的显存区域,随后 LCD 控制器会按照指定的像素格式不断读取这块显存中的数据,并最终将其显示在屏幕上。
- LCD 控制器可以看作连接 Framebuffer 和 LCD 屏幕之间的桥梁。它会按照显示时序持续读取 Framebuffer 中的像素数据,并将这些数据配合相应的控制与时序信号发送给 LCD 屏幕,最终完成图像显示。
这也意味着,如果我们想把摄像头获取到的一帧图像显示出来,真正需要完成的核心工作就是把图像中的每一个像素,转换成 Framebuffer 所需要的格式,并写入 Framebuffer 中对应的位置。

不过,这里还有一个问题:图像当前使用的像素格式,与 Framebuffer 所要求的像素格式不一定相同。
例如,我们的摄像头可能输出 YUYV 或 MJPEG 数据,而 Framebuffer 又可能使用 RGB565、RGB888 或其他像素格式。如果直接把两种组织方式不同的数据复制过去,Framebuffer 就无法按照我们预期的方式解析这些像素,最终显示出来的颜色自然也会出现错误。
因此,在这个项目中,我们先将摄像头输出的各种格式统一转换成 RGB24:
- R:8 bit G:8 bit B:8 bit
- 一个像素 = 24 bit = 3 Byte
- 之所以选择 RGB24 作为项目中的统一中间格式,是因为它是一种非常直观的非压缩 RGB 格式。每个像素都直接保存完整的 R、G、B 三个颜色分量,一个颜色分量占一个字节,因此无论进行缩放、裁剪、图片保存,还是后续转换为 Framebuffer 所需要的格式,处理起来都比较方便。
其中,对于 RGB24 转 RGB565 这类不同位宽的 RGB 格式转换,本质上是在降低每个颜色分量的表示精度,同时尽可能保持颜色分量在原有取值范围中的相对比例。例如 RGB24 中的红色分量使用 8 bit 表示,取值范围为 0~255;而 RGB565 中的红色分量只有 5 bit,取值范围为 0-31。因此在转换时,需要将:0-255映射到0-31。
到这里,我们已经知道了图像最终需要经过怎样的数据流才能显示到屏幕上。接下来,就可以正式认识连接应用程序与显示设备之间的关键接口——Framebuffer。
Framebuffer介绍
在这一部分,我们主要介绍 Framebuffer 是什么,以及它是如何建立起图像数据与 LCD 屏幕之间联系的。
Framebuffer,简称为 fb,是 Linux 内核向用户空间提供的一套显示接口。它通常以设备文件的形式存在,例如 /dev/fb0。应用程序通过访问这个设备,可以获得与屏幕显示相关的信息,并访问一块用于保存屏幕像素数据的缓冲区。
从整个显示链路来看,这块缓冲区一端面向应用程序,应用程序可以向其中写入像素数据;另一端则会被 LCD 控制器读取,用于产生最终的屏幕显示。因此,我们可以把 Framebuffer 看成连接用户空间图像数据和 LCD 显示硬件之间的一座桥梁。
这块 Framebuffer 内存由内核和显示驱动管理,用户程序不能直接使用其中的物理地址。为了在应用程序中访问它,我们通常需要使用 mmap,将这块内存映射到当前进程的虚拟地址空间。完成映射之后,应用程序得到一个普通的内存地址,此后就可以像访问普通内存一样读取和修改其中的数据。
需要注意的是,mmap 得到的本质上仍然是一段线性的内存空间。只不过屏幕本身是二维的,因此我们可以根据像素的横坐标、纵坐标、每行占用的字节数以及每个像素占用的字节数,计算出某个屏幕像素在 Framebuffer 中对应的内存位置。
- 在实际编程中,我们通常使用 uint8_t 指针来保存映射后的地址,这样就可以直接以字节为单位进行地址计算和数据写入。
因此,向屏幕显示一幅图像的本质,其实就是根据图像中每个像素的位置,找到它在 Framebuffer 中对应的内存地址,再按照 Framebuffer 要求的像素格式,把这个像素的数据写入对应位置。像素被写入 Framebuffer 的什么位置,也就决定了它最终出现在屏幕上的什么位置。
因此,从应用程序的角度来看,我们可以把 Framebuffer 中的这块缓冲区理解为一块用于保存 LCD 像素点阵信息的内存。要正确地向其中写入图像,后续主要需要关注以下几个信息:
- 屏幕的分辨率,也就是一行和一列分别有多少个像素;
- 每个像素占用多少位、多少字节;
- 每个颜色分量在一个像素中的位置和位宽;
- Framebuffer 每一行实际占用多少字节。
知道这些信息之后,我们才能根据屏幕坐标计算出正确的内存地址,并按照正确的格式将 RGB 图像数据写入 Framebuffer。
Framebuffer编程
在这一部分,我们介绍如何通过具体的API来实现图片显示。对于我们现在使用的 Framebuffer 显示方式来说,应用程序的核心工作就是修改对应的显示缓冲区内容。LCD 控制器会按照自己的显示时序持续读取 Framebuffer,只要应用程序正确地修改了对应位置的像素数据,新的内容就能够反映到屏幕上。
基本步骤为:

一、打开 Framebuffer,并获取屏幕信息
1. 打开 Framebuffer 设备节点
- Framebuffer 同样通过设备文件向用户空间提供接口,在我们的系统中对应的设备节点通常为 /dev/fb0。因此,首先需要使用 open() 打开设备。
- 打开成功以后获得的 fb_fd,就是后续调用 ioctl() 和 mmap() 时使用的文件描述符。
int fb_fd = open("/dev/fb0", O_RDWR);
if (fb_fd < 0) {
perror("open framebuffer");
return –1;
}
2. 获取 Framebuffer 的固定信息
Framebuffer 的固定信息保存在 struct fb_fix_screeninfo 中,通过 FBIOGET_FSCREENINFO 获取。
其主要结构可以简化理解为:
struct fb_fix_screeninfo {
char id[16];
unsigned long smem_start;
__u32 smem_len;
__u32 type;
__u32 visual;
__u32 line_length;
...
};
使用方式如下:
struct fb_fix_screeninfo finfo;
if (ioctl(fb_fd, FBIOGET_FSCREENINFO, &finfo) < 0) {
perror("FBIOGET_FSCREENINFO");
return –1;
}
其中我们主要关注:
- smem_len:Framebuffer 显存区域的总大小
- line_length:Framebuffer 中一行像素数据实际占用的字节数
- type:Framebuffer 使用的数据组织方式
- visual:颜色的表示方式
其中需要注意的是line_length这个变量,直觉上,一行像素占用的空间似乎应该等于屏幕宽度 × 每个像素占用的字节数。但实际情况不一定如此。驱动可能会因为内存对齐等原因,使每一行占用的实际空间更大。因此,后续计算某个像素在 Framebuffer 中的位置时,不能直接用屏幕宽度计算每一行的跨度,而应该使用驱动返回的 line_length。
3. 获取 Framebuffer 的可变信息
屏幕的分辨率、每个像素占用的位数以及 RGB 分量在一个像素中的排列方式,则保存在 struct fb_var_screeninfo 中,通过 FBIOGET_VSCREENINFO 获取。
主要结构可以简化为:
struct fb_bitfield {
__u32 offset;
__u32 length;
__u32 msb_right;
};
struct fb_var_screeninfo {
__u32 xres;
__u32 yres;
__u32 xres_virtual;
__u32 yres_virtual;
__u32 xoffset;
__u32 yoffset;
__u32 bits_per_pixel;
struct fb_bitfield red;
struct fb_bitfield green;
struct fb_bitfield blue;
struct fb_bitfield transp;
...
};
获取方式如下:
struct fb_var_screeninfo vinfo;
if (ioctl(fb_fd, FBIOGET_VSCREENINFO, &vinfo) < 0) {
perror("FBIOGET_VSCREENINFO");
return –1;
}
其中主要关注:
- xres:当前屏幕可见区域的宽度
- yres:当前屏幕可见区域的高度
- xres_virtual:虚拟 Framebuffer 的宽度
- yres_virtual:虚拟 Framebuffer 的高度
- xoffset:当前可见区域在虚拟 Framebuffer 中的横向偏移
- yoffset:当前可见区域在虚拟 Framebuffer 中的纵向偏移
- bits_per_pixel:每个像素占用多少 bit
- red、green、blue:R、G、B 三个颜色分量分别占据哪些 bit
- transp:透明度分量对应的位置和位宽
对于每一个颜色分量,我们又主要关注两个参数:
- length:这个颜色分量使用多少 bit
- offset:这个颜色分量从一个像素数据的第几位开始存放
二、使用 mmap() 将 Framebuffer 映射到用户空间
虽然我们已经获取了 Framebuffer 的各种信息,但此时还不能直接像普通数组一样访问对应的显存。
因此,接下来需要使用 mmap(),把 Framebuffer 显存映射到当前进程的虚拟地址空间。
uint8_t *fb_mem;
fb_mem = mmap(NULL,
finfo.smem_len,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fb_fd,
0);
if (fb_mem == MAP_FAILED) {
perror("mmap framebuffer");
return –1;
}
这里几个参数分别表示:
- finfo.smem_len:需要映射的 Framebuffer 空间大小
- PROT_READ | PROT_WRITE:允许程序读取和写入这块空间
- MAP_SHARED:对这块映射区域的修改会反映到底层 Framebuffer 中
- fb_fd:前面打开的 Framebuffer 文件描述符
- 最后的 0:从 Framebuffer 映射区域的起始位置开始映射
mmap() 成功以后,fb_mem 就指向了当前进程虚拟地址空间中的一块内存,而这块虚拟内存实际上对应着 Framebuffer 的显示缓冲区。此后,我们只需要修改 fb_mem 指向的内存内容,实际上就是在修改 Framebuffer 中保存的像素数据。
三、计算一个屏幕像素对应的 Framebuffer 地址
完成映射以后,我们就要解决:屏幕上的某一个像素,到底对应 fb_mem 中的哪个位置?
假设我们现在希望修改屏幕坐标 (x, y) 对应的像素。
首先需要知道一个像素占用多少字节。
如果当前 Framebuffer 的每个像素按照整字节存储,那么:
int bytes_per_pixel = vinfo.bits_per_pixel / 8;
接下来,根据:
- 当前像素位于第几行
- 每一行实际占多少字节
- 当前像素位于这一行中的第几个位置
就可以计算其对应的内存地址:
uint8_t *dst;
dst = fb_mem
+ (y + vinfo.yoffset) * finfo.line_length
+ (x + vinfo.xoffset) * bytes_per_pixel;
这个公式可以拆成三个部分理解。
首先:
(y + yoffset) × line_length
表示先跳过前面的若干行,找到当前像素所在行的起始位置。
- 注意一行的字节数是line_length
然后:
(x + xoffset) × bytes_per_pixel
表示在当前这一行中继续向后移动,找到第 x 个像素的位置。
Framebuffer 在内存中本质上仍然是一段线性空间,所谓二维屏幕,只是我们根据每一行的长度和像素坐标,对这段线性空间进行二维解释。
四、根据 Framebuffer 的像素格式转换 RGB24 数据
找到目标像素的地址之后,还不能直接把 RGB24 中的三个字节复制过去,这是因为 RGB24 中每个颜色分量都是 8 bit:
而 Framebuffer 中每个颜色分量占用的位数可能不同。例如 RGB565 中:
- Red 5bit
- Green 6bit
- Blue 5it
所以,在真正写入之前,需要根据 red、green、blue 中记录的 length 和 offset,把 RGB24 中的三个颜色分量重新转换并组合成 Framebuffer 所需要的一个像素值。
例如,可以定义一个函数,将一个 8 bit 颜色分量转换到 Framebuffer 对应的 bitfield 中:
static uint32_t pack_channel(uint8_t value, struct fb_bitfield field)
{
if (field.length == 0)
return 0;
uint32_t max_value = (1U << field.length) – 1;
return ((uint32_t)value * max_value / 255U) << field.offset;
}
然后分别处理 RGB:
uint32_t pixel;
pixel = pack_channel(r, vinfo.red)
| pack_channel(g, vinfo.green)
| pack_channel(b, vinfo.blue);
这样得到的 pixel,就已经按照当前 Framebuffer 要求的方式组织好了 R、G、B 三个颜色分量。因此,这一步实际上完成的是将RGB24 图像中的颜色表示 转换为当前 Framebuffer 所要求的像素表示。
五、将像素数据写入 Framebuffer
现在,我们已经同时知道了:
- 这个像素应该写到哪里
- 这个像素应该按照什么格式保存
接下来只需要将转换完成的像素值写入前面计算得到的地址即可。
对于我们的 IMX6ULL 平台来说,CPU 使用小端存储,因此一个多字节像素在内存中会按照低地址保存低字节的方式排列。
- 实际实现中,可以根据 bytes_per_pixel 将转换后的像素写入对应地址。
对整张图像中的所有像素重复这个过程,就可以完成一帧图像的显示。也就是说,把一张 RGB24 图像显示到 Framebuffer 的本质,就是遍历图像中的每个像素,然后完成“像素格式转换 + 显示地址计算 + 内存写入”三个操作。
六、解除映射并释放资源
当程序准备退出时,需要解除之前建立的 Framebuffer 映射:
munmap(fb_mem, finfo.smem_len);
最后关闭设备文件:
close(fb_fd);
总结
通过这一部分的介绍,我们已经理清了图像数据从用户空间到 LCD 屏幕的显示流程:应用程序先获取 Framebuffer 的相关信息,再通过 mmap 将显存映射到用户空间,随后根据屏幕坐标计算目标地址,并按照 Framebuffer 要求的像素格式将图像数据写入对应位置,最终由 LCD 控制器读取显存并完成屏幕显示。
也就是说,使用 Framebuffer 显示图像的核心,本质上就是完成 像素格式转换、地址计算和显存写入 这三个步骤。
在完成了底层显示链路的打通之后,下一部分我们就将继续进入 LVGL,看看在已经具备图像显示能力的基础上,LVGL 是如何进一步帮助我们构建图形界面,并将摄像头画面接入到界面显示流程中的。
网硕互联帮助中心


评论前必须登录!
注册