使用mmap写framebuffer实现写屏, bmp图片格式概述, freetype2渲染文字

unix中读写文件的基本函数

在谈论mmap前, 先来回顾下linux如何使用系统调用读写文件。在c语言中如果要读写文件, 我们第一时间想到的就是open打开某个文件, 然后通过read/write对文件描述符读写。看下面的代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define _GNU_SOURCE

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
int fd = open("./new_file", O_RDWR|O_CREAT|O_EXCL, 0600);
if (fd == -1) {
perror("failed to create new_file");
return 1;
}

const char *msg = "Hello World";
int n = write(fd, msg, strlen(msg));
if (n == -1) {
perror("failed to write to new_file");
return 1;
}

return 0;
}

这里解释下open的三个参数:

  • 参数1: 文件的路径
  • 参数2: O_RDWR, 代表可读写, 打开一个文件必须包含O_RDONLY/O_WRONLY/O_RDWR三个之一。O_CREATE代表了如果文件不存在则创建文件。而O_EXCL必须与O_CREATE联合使用, 代表文件必须不存在, 再执行创建, 但如果文件存在则保存, 返回EEXIST错误。

如果仅仅使用O_CREATE的话, 文件存在时不会报错, 而是简单地打开。而O_CREATE|O_EXCL时文件存在则报错, 用这种组合的好处是能原子地判定文件不存在并创建新的文件, 这在某种场景下是很必要的。比如一个进程服务要求是“单例”的(也就是不允许同时运行两个相同的进程), 那么就能用O_CREATE|O_EXCL来创建一个标志文件。如果进程能成功创建这个文件, 那么它就有权运行。相反, 如果此文件已经存在, 那么open就会返回错误, 这个进程也就退出并打印: “进程已经在运行了, 请勿重复运行”的错误信息。

  • 参数3: 代表如果创建了文件, 那么文件的权限是如何的。0600代表仅仅自己能读写, 而其他用户无权。0666则代表自己和其它用户都能读写。权限有三种: 读/写/执行, 不过多展开。这个很简单可以自行上网查阅。

值得注意的是, 如果这个文件已经存在, 而不是新创建出来的, 那么权限参数没有意义。参数3只在真正创建了文件的时候才赋予文件相应的权限设置。

为什么我们不close fd?因为进程结束时, 内核会负责关闭所有打开的fd, 因此这里关不关都无所谓。

如果用mmap读写文件?

mmap说白了就是能把文件描述符对应的文件内容映射到内存, 能像操作内存一样读写文件。下面进行一个演示(注意new_file里面有“hello world”文本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#define _GNU_SOURCE

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>

int main() {
int fd = open("./new_file", O_RDWR, 0660);
if (fd == -1) {
perror("failed to create new_file");
return 1;
}

char* mem = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
if (mem == NULL) {
perror("failed to map memory");
return 1;
}
mem[0] = 'H';
mem[1] = 'E';
mem[2] = 'L';
mem[3] = 'L';
mem[4] = 'O';
}

运行这个程序后, 文件的内容变成了HELLO world。我们来说明一下参数的意义:

  • 参数1: 指定被映射到内存的哪个位置。这个参数一般为NULL代表让操作系统进行自动选择, 这是因为作为应用程序我们一般不知道哪块内存是空闲的, 因此要求内核帮我们将文件映射到合适的虚拟内存上去。
  • 参数2: 映射的内存大小。
  • 参数3: 可选的是PROT_READ(可读), PROT_WRITE(可写), PROT_EXEC(可动态加载代码, 然后跳过去执行, 动态库就用到了这个), PROT_NONE(不允许访问, 若访问, 那么触发缺页异常, 此时程序被SIGSEGV信号终止)。
  • 参数4: 特殊的flag。有下面的flag:
  1. MAP_SHARED: 对映射内存的写将会和文件内容同步, 会同步到底层的对象
  2. MAP_PRIVATE: 相当于映射时只是进行一个复制, 并不会与底层对象产生同步。说实话, 这个我觉得意义不大。
  3. MAP_ANONYMOUS: 不映射任何fd, 这是一种申请内存的特别方法, 特别适用于申请大内存。实际上malloc在申请大内存的时候就用了这个标志
  • 参数5: 要映射的fd
  • 参数6: 开始映射的位置。这里我们从文件开头就开始映射。

对于参数3值得注意的是, 如果我们指定了PROT_WRITE, 那么fd本身需要是可写的。如果fd本身不能读写, 那么PROT_READ和PROT_WRITE是无意义的。

此时文件的大小仅仅只有“HELLO world”11个字节, 那么如果我们访问超过这个量怎么办呢? 我们可以看到, 虽然文件大小仅有11个字节, 我们却映射了4096个字节啊?答案很简单。此时, 内核会向程序发出SIGBUS信号终止这个程序。

那么有人就会疑问了, 这个mmap这么鸡肋, 用它读写文件不是脱裤子放屁吗? 答案是: 是的。如果你仅仅用它读写文件, 那么它就是不好用。然而mmap在跨进程的内存共享上有特别的用处。下面来列举一下它的应用场景:

  • 父进程的mmap将被子进程共享, 对于多进程的服务, 我们可以通过fork共享内存。做同步时, 以来mlock/munlock系统调用, 可实现跨进程的进程锁。
  • 我们之前提到的都是用mmap映射文件, 其实很多内核设备都能被映射。关于一个设备是否能被mmap映射, 这取决于这个设备有没有实现mmap的接口。很明显文件就实现了这个接口。此外我们之后讨论的framebuffer也实现了这个接口, 那么它也能被mmap映射。如果我们是驱动开发者, 我们做字符驱动设备时也可以考虑实现mmap的接口, 使得软件开发者用我们的设备文件更舒适。
  • mmap也有申请内存的特别功效, 我们之前提到的MAP_ANONYMOUS(匿名映射就是这样的), 之后来演示如何通过mmap申请内存。

mmap原理和懒加载

mmap并不是把物理内存真的映射到虚拟内存上, 相反, 在mmap后它不作任何映射, 而是将这块内存区域记录在内核里面。事后此时如果进程要访问这块内存, 由于没有任何映射, 产生却页异常进入内核代码。然后内核负责分配物理内存做相关映射。

比如我们映射了一个文件的100k内容, 访问这块内存的第一个字节时, 将产生缺页错误。此时内核查表发现这块虚拟内存是有mmap映射的, 但是没有映射到任何物理内存, 此时内核就会分配4k的物理内存并把文件的前4k内容拷贝进去, 并将这块物理内存映射到相应的虚拟内存上。然后进程再次尝试访问这块内存, 就能访问到真正的物理内存了😃。

匿名映射的懒加载, 简而言之就是mmap如果匿名极大的内存, 这些内存不会被立刻分配物理内存, 而是在访问后, 触发缺页异常后内核再一点点分配。使用这个特性, 能很好地实现动态栈, 参见我以前的博客。这里有一个演示程序, 可以证明这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
// 分配了2g, 然而我们读写1g内存
size_t k = (size_t)1024*1024*1024*2;
volatile char *mem = mmap(NULL, k, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
if (mem == NULL) {
perror("failed to mmap");
return 1;
}

for (size_t i = 0; i < k / 2; i++) {
// 换成char c = mem[i]; 来验证读也会触发内存分配
mem[i] = 1;
}

printf("完毕\n");


sleep(300);
}

如果我们的机器仅有8G内存却mmap匿名映射了80G, 此时mmap不会报错。当我们访问(不论是读还是写)这些内存的某个4k单元时, 由于页错误进入内核, 内核负责分配这个4k单元。电脑不会宕机。这就是懒加载: 在用的时候再加载。

那么有人就好奇了, 如果已经映射好物理内存, 为啥我手动改变文件的内容, 程序读到的那块内存还会改变呢? 答案很简单, 在进程运行时其实不能察觉到文件内容的改变, 真正的改变其实是线程进入内核调度后, 内核负责相关同步和改变的。因此这中间其实有延时性, 直到进入内核调度之前, 此进程都不会直到文件是否已经发生了改变。

ok。现在我来臆想下MAP_PRIVATE究竟有啥用(但我的想法可能不对):

我们可以了解到MAP_PRIVATE不会把写入内存同步到底层对象中, 写入后只有自己知道, 别的进程一概不知。那么它就是在自娱自乐。当写入后, 在下一次进入内核调度后, 内核不会将对内存的写入同步到文件中, 只是把文件的变化同步给内存(但是如果我们写入了这块内存之后文件发生了改变, 文件的改变会覆盖我们的全部写入😡)。

既然少了把内存同步给文件的这个步骤, 那么就在在特定的场景下更快, 我能下面这个场景:

  • 文件永远不变, 我们的写入不需要反映给文件, 那么此时mmap就相当于把文件拷贝到内存, 并享有了我们上述提到了懒加载优势(访问的时候再加载一个4k的页)。

虽然我臆想了一下这个用途, 但是其实我还是不太自信, 到现在我还是觉得这个MAP_PRIVATE没太大的意义。

mmap匿名映射申请内存

基本的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define _GNU_SOURCE

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>

int main() {
char* mem = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0);
if (mem == NULL) {
perror("failed to map memory");
return 1;
}

for (size_t i = 0; i < 4096; i++) {
mem[i] = i%127;
}

for (size_t i = 0; i < 4096; i++) {
printf("%d\n", mem[i]);
}
}

这里fd填的-1, 这是因为对匿名映射, fd没有任何意义, 它就不是用来映射fd的, 它不需要fd(因此我特地把它设置成没有意义的值)。它只是用来申请内存的。这里的MAP_PRITVATE属于没什么意义但必须填, 实际上填SHARD和PRIVATE都能工作(但我认为都没什么特别的意义)。

mmap映射framebuffer来写屏

芜湖, 来到正题。/dev/fb0是linux抽象出来的屏幕设备, 我们可以打开它然后mmap映射到内存上进行写屏。直接运行下面的代码就能玩。注意这个程序请打开tty2(因为默认的tty1是桌面, 我们需要切换到虚拟终端来运行这个程序)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#define _GNU_SOURCE
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <linux/fb.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <time.h>
#include <ncurses.h>
#include <linux/tty.h>
#include <linux/kd.h>
#include <linux/vt.h>
#include <ncurses.h>

/*
屏幕的坐标规定:

(0,0)------------------x轴
|
|
|
|
|
|
y轴
*/

int main()
{
// 打开fb设备文件
int framebuffer_fd = open("/dev/fb0", O_RDWR);
if (framebuffer_fd == -1) {
perror("failed to open framebuffer device");
exit(1);
}
printf("Framebuffer device opened successfully.\n");

// 获取屏幕信息, var -> variable, 是可修改的, 可变的信息
struct fb_var_screeninfo vinfo;
if (ioctl(framebuffer_fd, FBIOGET_VSCREENINFO, &vinfo)) {
perror("failed to read variable information");
exit(1);
}
printf("%dx%d, %dbpp\n", vinfo.xres, vinfo.yres, vinfo.bits_per_pixel);

// 每个像素都会是4个字节表示它的颜色
if (vinfo.bits_per_pixel != 32) {
printf("not supported bits_per_pixel, it only supports 32 bit color");
exit(1);
}

// 屏幕的大小=x像素数目*y像素数目
int screen_size = vinfo.xres * vinfo.yres;

// framebuffer的设备实现了映射到内存的接口, 因此这里需要用mmap
// 参数1: NULL, 起始位置由系统指定, 一般mmap都将它设置为NULL
// 参数2: 映射的字节数目, 一个像素4字节(32bit)
// 参数3: 指定读/写/执行权限, 这里只需要读写
// 参数4: 这块内存是否独享, 如果是private, 那么它的写不会同步到文件, 然而文件改变会刷新这块内存
// 而shared则同步到文件
volatile char* framebuffer_mem = (char *)mmap(NULL, screen_size*vinfo.bits_per_pixel/8, PROT_READ | PROT_WRITE, MAP_SHARED, framebuffer_fd, 0);
if (framebuffer_mem == NULL) {
perror("failed to map framebuffer device to memory");
exit(1);
}
printf("The framebuffer device was mapped to memory successfully.\n");
printf("Press Enter to show new screen:\n");

if (ioctl(STDIN_FILENO, KDSETMODE, KD_GRAPHICS) < 0) {
perror("failed to set tty to graphics mode");
exit(1);
}

initscr();
raw();
noecho();
refresh();
keypad(stdscr, TRUE);

vinfo.activate |= FB_ACTIVATE_NOW | FB_ACTIVATE_FORCE;
if(ioctl(framebuffer_fd, FBIOPUT_VSCREENINFO, &vinfo) < 0) {
perror("failed to set framebuffer mode");
exit(1);
}

int c = getch();
if (c != '\n') {
goto end;
}

for (int j = 0; j < 5; j ++) {
for (size_t i = 0; i < screen_size; i++) {
if(j % 2 == 0) {
*(framebuffer_mem + i*4 + 0) = 0xff;
*(framebuffer_mem + i*4 + 1) = 0x00;
*(framebuffer_mem + i*4 + 2) = 0x00;
*(framebuffer_mem + i*4 + 3) = 0x00;
} else {
*(framebuffer_mem + i*4 + 0) = 0x00;
*(framebuffer_mem + i*4 + 1) = 0xff;
*(framebuffer_mem + i*4 + 2) = 0x00;
*(framebuffer_mem + i*4 + 3) = 0x00;
}
}
usleep(1000000);
}

end:

munmap((void*)framebuffer_mem, screen_size*4);
close(framebuffer_fd);

ioctl(STDIN_FILENO, KDSETMODE, KD_TEXT);

endwin();

return 0;
}

运行样例子(没有安装ncurses库自行安装):

1
2
3
4
5
6
7
> gcc main.c -o main -lncurses
> ./main
Framebuffer device opened successfully.
2560x1080, 32bpp
The framebuffer device was mapped to memory successfully.
Press Enter to show new screen:
(此时按Enter键绘制, 按其他键退出, 5s后程序结束)

fb是framebuffer的缩写, 它是一个字符设备, 用来抽象屏幕的每个像素点来实现写屏。打开这个设备文件后, 我们需要第一时间用ioctl得到屏幕信息, 分辨率和支持的色彩bit数目。然后我们才能确定mmap多少字节才好。

每个1px单元都有4个字节(32比特)来描述它的色彩, 第一个字节是red, 第二个是grenn, 第三个是blue, 第四个是alpha表示透明度。

这里值得一提的设置是vinfo.activate |= FB_ACTIVATE_NOW | FB_ACTIVATE_FORCE;。默认下fb0的刷新很不积极, 通过设置这些位, 使得我们写入内存后内核及时读取内存更新屏幕。

此外用到了ncurses这个库。我用它的原因是我想简单地调用别人的轮子设置键盘映射, 我不希望用户在运行这个程序的时候按到ctrl+c导致无法正确还原到tty的KD_TEXT模式。ncurses的raw mode很好, 它会设置一些键盘映射, 我们可以通过ncurses的接口拿到用户的键盘输入。

这里用到了ncurses库帮我们做键盘映射, 实际上可以自己做的, 然而我懒了, 也没研究过这些内容, 日后再研究。

现在有了ncurses + framebuffer, 就可以自己控制屏幕和键盘了, 可以尝试做些小游戏玩玩。如果想要控制鼠标, 可以打开/dev/input里面的设备文件evnetn, 读取每个输入设备的相关数据。触摸板, 鼠标, 键盘, 甚至是笔记本的电源按键都创建了对应的event文件。

VT(虚拟终端)?图形模式?字符模式?

我们暂不讨论安装桌面的linux环境, 假设现在电脑上装的只是无桌面的linux版本。登陆到终端后我们能用ALT + F1~F6切换到6个不同的虚拟终端进行工作。

现在我们将键盘+屏幕称为控制台(控制台只有一个), 那么就可以说控制台能够在多个虚拟终端间进行切换。

控制台的设备文件是console, 虚拟终端的设备文件就是tty, 目前拥有控制台的终端称为活动终端。ALT+F2其实就是切换了活动终端。从/dev目录下我们只能找到一个console文件, 而却有63个tty文件。

linux最多支持63个tty, 我们可以自由切换到任何tty, 默认下linux只开启了6个, 我们可以更改这一配置, 修改/etc/systemd/logind.conf, 将#NAutoVTs=6变为#NAutoVTs=63就能拥有63个VT了。然而键盘只有F1-F12, 要切换到tty13-tty63, 我们需要用chvt这个命令切换。

我们可以理解为console利用framebuffer控制了屏幕, 而tty在某个时刻占有了console, 我们只能通过tty访问console。所以我们的键盘输入能够通过console传递给tty, tty也能够将相应的信息发给console供它显示给屏幕。它的架构就像下面:

架构

需要知道的是一般情况tty处于TEXT模式(文字模式)下, 如果我们想要接管整个屏幕, 自然不能再让tty绘制这个framebuffer。ioctl(STDIN_FILENO, KDSETMODE, KD_TEXT);可以进入图形模式, 这告知tty不要再绘制framebuffer, 让应用程序独占framebuffer, 这样tty和我们的应用程序就不会竞争写入framebuffer了, 这可以抑制光标闪烁。

然而在图形模式下无法切换活动终端, 假设我们在tty2下跑应用程序切换到图形模式, 只有在文字模式下ALT+Fx的组合键才起作用, 我想这应该是tty的特判。对于console发来的ALT+Fx键, 不会发给应用程序使用, 而是拦截住它然后切换活动终端。因此要在图形模式下切换活动终端, 相关逻辑需要我们自己写。

将fb0导成图片

假设已经安装好ffmpeg并设置相关环境变量, 执行指令:

1
> ffmpeg -f fbdev -i /dev/fb0 out.jpg

展示图片

这里读者也许会好奇, 为何在桌面模式下(我的机子是kde桌面)显示的不是桌面的截图而是一个命令行界面。这与linux的显示架构有很大的关系, 我尚未研究😃。

然后你就能看到fb0当前的显示状况了。用ffmpeg转图片很low, 下面我们提供一个程序生成bmp图片文件, 将fb0屏幕输出到文件里, 然后我们可以用图片预览软件观看。首先介绍下bmp文件格式。下面是它的字节结构:

数据段名称 是否可选 大小(Byte)
bmp文件头 必选 14字节
位图信息头 必选 40字节
调色板信息 可选 由位图信息头里面的信息决定
位图数据 必选 由图像的大小决定

对于文件头, 我们用下面的结构体:

1
2
3
4
5
6
7
typedef struct bitmapfileheader {
unsigned short bfType; // 0x4d42固定值, 标识文件类型
unsigned int bfSize; // 整个文件的大小
unsigned short bfReserved1; // 保留字段, 必须为0
unsigned short bfReserved2; // 保留字段, 必须为0
unsigned int bfOffBits; // 从文件头到位图数据的偏移, 单位字节, 这里的偏移其实也可以说是位图数据相对整个文件起始位置的偏移
} __attribute__((packed)) bitmapfileheader_t; // 要求编译器不对此结构体进行内存对齐

对于位图信息头, 用下面的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct bitmapinfoheader {
unsigned int biSize; // 此结构体的大小 (14-17字节)
int biWidth; // 图像的宽 (18-21字节)
int biHeight; // 图像的高 (22-25字节)
unsigned short biPlanes; // 表示bmp图片的平面属,显然显示器只有一个平面,所以恒等于1 (26-27字节)
unsigned short biBitCount; // 一像素所占的位数 (28-29字节)
unsigned int biCompression; // 说明图象数据压缩的类型,0为不压缩。 (30-33字节)
unsigned int biSizeImage; // 像素数据所占大小, 这个值应该等于上面文件头结构中bfSize-bfOffBits (34-37字节)
int biXPelsPerMeter; // 说明水平分辨率,用像素/米表示。一般为0 (38-41字节)
int biYPelsPerMeter; // 说明垂直分辨率,用像素/米表示。一般为0 (42-45字节)
unsigned int biClrUsed; // 说明位图实际使用的调色板中的颜色索引数(设为0的话,则说明使用所有调色板项)。 (46-49字节)
unsigned int biClrImportant; // 说明对图象显示有重要影响的颜色索引的数目,如果是0,表示都重要。(50-53字节)
} __attribute__((packed)) bitmapinfoheader_t;

讨论下各个字段的意义:

biBitCount常见的有1, 4, 8, 16, 24, 32。代表的意思是用多少个bit代表一个像素的颜色。其中24位是真色彩, 可以用红绿蓝三原色各8位表示现实中的所有颜色(RGB模式)。32位图像使用4字节保存颜色值,每一个字节代表一种颜色,除了原来的红、绿、蓝,还有Alpha通道,即透明色(RGBA模式)。这两种色彩是真色彩, 不需要用到调色板, 如果用这两种模式, 那就只包含bmp文件头, 位图信息头和位图数据了。

比如biBitCount为1, 那么此时只能用到两种颜色。那么到底是哪两种颜色呢? 是黑白还是红绿? 这就取决于调色板了。调色板信息紧接着图像信息头, 它就像一张映射表。四个字节R, G, B, A代表一个色彩。A为0则不设置透明通道。笔者的屏幕是32位真色彩的, 因此不需要调色板, 这个字段就是0了。

那么biXPelsPerMeterbiYPelsPerMeter是什么意思呢? 只是给显示器打印机的一个参考值, 我的理解是看软件如何理解它, 我认为设置为0即可。一般的软件显示图片是根据用户的屏幕大小和图片本身的分辨率放缩显示, 而不看这两个值。也就是说多数情况下它们没有意义。

biCompression代表压缩格式, .bmp是不经压缩的位图, 此外通过一些算法, 可以得到jpg和jpeg等格式的图片。

笔者不是很理解rgba中a(Alpha)的含义, 我从网上找到资料提到: Alpha指示每个像素的不透明程度,并允许使用Alpha合成将图像组合到其他像素上。这里我猜想对单一图层它没有什么意义, 但是与其它图层一起显示时, 就能发挥它的“透明度”的作用了。

上面talk了许多, 下面直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#define _GNU_SOURCE

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

typedef struct bitmapfileheader {
unsigned short bfType; // 0x4d42固定值, 标识文件类型
unsigned int bfSize; // 整个文件的大小
unsigned short bfReserved1; // 保留字段, 必须为0
unsigned short bfReserved2; // 保留字段, 必须为0
unsigned int bfOffBits; // 从文件头到位图数据的偏移, 单位字节, 这里的偏移其实也可以说是位图数据相对整个文件起始位置的偏移
} __attribute__((packed)) bitmapfileheader_t; // 要求编译器不对此结构体进行内存对齐

typedef struct bitmapinfoheader {
unsigned int biSize; // 此结构体的大小 (14-17字节)
int biWidth; // 图像的宽 (18-21字节)
int biHeight; // 图像的高 (22-25字节)
unsigned short biPlanes; // 表示bmp图片的平面属,显然显示器只有一个平面,所以恒等于1 (26-27字节)
unsigned short biBitCount; // 一像素所占的位数 (28-29字节)
unsigned int biCompression; // 说明图象数据压缩的类型,0为不压缩。 (30-33字节)
unsigned int biSizeImage; // 像素数据所占大小, 这个值应该等于上面文件头结构中bfSize-bfOffBits (34-37字节)
int biXPelsPerMeter; // 说明水平分辨率,用像素/米表示。一般为0 (38-41字节)
int biYPelsPerMeter; // 说明垂直分辨率,用像素/米表示。一般为0 (42-45字节)
unsigned int biClrUsed; // 说明位图实际使用的调色板中的颜色索引数(设为0的话,则说明使用所有调色板项)。 (46-49字节)
unsigned int biClrImportant; // 说明对图象显示有重要影响的颜色索引的数目,如果是0,表示都重要。(50-53字节)
} __attribute__((packed)) bitmapinfoheader_t;

int main(int argc, char **argv) {
if(argc != 2) {
printf("usage: %s <file_name>\n", argv[0]);
return 1;
}

int fbdev = open("/dev/fb0", O_RDONLY, 0);
if (fbdev == -1) {
perror("failed to open fb0 device");
return 1;
}

struct fb_var_screeninfo vinfo;
if (ioctl(fbdev, FBIOGET_VSCREENINFO, &vinfo)) {
perror("failed to read variable information");
return 1;
}

if (vinfo.bits_per_pixel != 32) {
printf("not supported bits_per_pixel, it only supports 32 bit color");
return 1;
}


char* framebuffer_mem = malloc(vinfo.xres*vinfo.yres*vinfo.bits_per_pixel/8);
if (framebuffer_mem == NULL) {
perror("failed to malloc mem");
return 1;
}

if (read(fbdev, framebuffer_mem, vinfo.xres*vinfo.yres*vinfo.bits_per_pixel/8) < 0) {
perror("failed to read framebuffer device to memory");
return 1;
}

printf("%d %d %d\n", vinfo.xres, vinfo.yres, vinfo.bits_per_pixel);

// 输出文件由三部分组成
int outputfileSize = sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + vinfo.xres * vinfo.yres * vinfo.bits_per_pixel / 8;
char *outputBytes = malloc(outputfileSize);
if (outputBytes == NULL) {
perror("failed to malloc mem");
return 1;
}
printf("大小: %d\n", outputfileSize);

// 写文件头
bitmapfileheader_t fheader = {};
fheader.bfType = 0x4d42;
fheader.bfSize = sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + vinfo.xres * vinfo.yres * vinfo.bits_per_pixel / 8;
fheader.bfOffBits = sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t);
memcpy(outputBytes, &fheader, sizeof(bitmapfileheader_t));

// 写图像信息头
bitmapinfoheader_t iheader = {};
iheader.biBitCount = vinfo.bits_per_pixel;
iheader.biHeight = -vinfo.yres;
iheader.biWidth = vinfo.xres;
iheader.biPlanes = 1;
iheader.biSize = sizeof(bitmapinfoheader_t);
iheader.biSizeImage = vinfo.xres * vinfo.yres * vinfo.bits_per_pixel / 8;
memcpy(outputBytes+sizeof(bitmapfileheader_t), &iheader, sizeof(bitmapinfoheader_t));

// 拷贝raw数据
memcpy(outputBytes+sizeof(bitmapfileheader_t)+sizeof(bitmapinfoheader_t), framebuffer_mem, vinfo.xres*vinfo.yres*vinfo.bits_per_pixel/8);

int outputFd = open(argv[1], O_WRONLY|O_CREAT|O_TRUNC, 0666);
if (outputFd < 0) {
perror("failed to create file");
return 1;
}

if(write(outputFd, outputBytes, outputfileSize) == -1) {
perror("failed to write to file");
return 1;
}

return 0;
}

值得注意的是, 代码中biHeight是负数, 这代表它是正向的位图。

tty支持中文显示?

相比爱折腾的人肯定想过让tty支持中文显示, 然后了解到了fbterm这个程序。它不但可以显示中文甚至还能在里面用fcitx输入法。即使在没有安装桌面的系统上, fbterm也能正常使用。

正如它的名字, fbterm正是用framebuffer来绘制的界面, 文字渲染则是使用的freetype进行渲染。

此外有大神写了内核补丁, 能够在console中显示cjk(中日韩)文字, 这很cool, 点击此处查看。

调用freetype渲染文字

很多时候我们低估了文字渲染的复杂性, 人类理所当然地再电脑上用输入法打字, 却不了解其代码层面的难度。在计算机上, 负责渲染文字的代码至少数以十万计。linux使用freetype2渲染文字, 有了别人的轮子, 我们能很简单地渲染出文字。在linux桌面环境下, gtk和qt都是调用freetype2的轮子进行文字渲染的。

绘图的基本步骤是通过freetype2库打开.ttf或.ttc后缀的字体文件, 根据编码找到对应的字形, 然后根据字形数据绘制文字。这里的字形指的是文字的图像。

ttf文件保存了一个face, 可以认为是一个字体。ttc文件则表示它拥有多个face, 即它有包含了多个字体。比如/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc拥有五个face。下面的程序打印了中日韩字体文件里面的所有face信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 编译:
// gcc xxx.c -lfreetype -I /usr/include/freetype2
#include <ft2build.h>

#include FT_FREETYPE_H


FT_Library library;
FT_Face face;

int main() {
FT_Error err = FT_Init_FreeType(&library);
if (err != FT_Err_Ok) {
printf("failed to init freetype2 library\n");
return 0;
}

// 指定打开的face索引为-1, 这样可以拿到num_faces, 这是官方提供的获取num_faces的方法。
err = FT_New_Face(library, "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc", -1, &face);
if (err != FT_Err_Ok) {
printf("failed to new freetype2 face\n");
return 0;
}


for (int i = 0; i < face->num_faces; i ++) {
err = FT_New_Face(library, "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc", i, &face);
printf("%s\n", face->family_name);
}
}

/* output:
Noto Serif CJK JP 65535
Noto Serif CJK KR 65535
Noto Serif CJK SC 65535
Noto Serif CJK TC 65535
Noto Serif CJK HK 65535
*/

可以看到cjk中日韩的ttc字体文件里面有五组字符, jp日本, kr韩国, sc简体中文, tc繁体中文, hk香港。

一个字体文件不一定包含所有unicode字符的字形, 完整的字形渲染往往需要查找很多ttf/ttc文件。比如有专门的emoji字体文件, 有的字体文件只有中文, 有的只有字母数字和ascii符号。在linux中使用fontconfig工具可以找到支持某个码点的字体, qt的字体渲染就是使用的freetype2+fontconfig。

接下来我们来谈一谈矢量字体, 我们知道矢量字体的特点就是它在高分辨率下依旧丝滑。而点阵字体的缺点就是如果放大缩小, 就会出现模糊锯齿的现象。矢量字体的关键点就在它用存储了字体关键点, 利用数学曲线连接关键点, 然后填充闭合曲线的方法动态绘制。

然而需要知道的是, 虽然动态绘制的方式很好, 但是在小尺寸比如12px * 12px上的效果很不好(因为点太少了, 没法拟合出好的效果), 所以为了这种变态的需求, 对于字体的某些特定尺寸保存了自行的点阵图。我们可以说ttf文件不仅有矢量图, 为了极致的美观还会在使用特定尺寸时切换到点阵图。这些点阵图都是字体工作人员一点一点地做出来的,太幸苦了😢。

从/usr/share/fonts/noto的文件名来看, 可以看到对于某个字体它拥有多种形式, 比如NotoSerifYezidi-Bold.ttf, NotoSerifYezidi-Regular.ttf。如果我们想让字体的样式是粗体的, 那么可以用到Bold后缀的字体文件。很多字体没有设计斜体字体文件, 比如浏览器里面显示斜体的emoji, 但是软件依然可以显示出斜体的emoji字体, 这是因为矢量图是用线绘制的, 可以通过transform旋转来影响线的绘制, 这样就能绘制出假斜体。

ttf文件存储了很多字的字形, 需要通过编码找到对应的字形索引, 每个face都可以支持多个编码, 打开face之后默认就是unicode编码, 一般unicode就是最优的了, 2023年就不要再用其他编码了。下面我们使用代码拿到一个字符的bmp数据并写入文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
typedef struct bitmapfileheader
{
unsigned short bfType; // 0x4d42固定值, 标识文件类型
unsigned int bfSize; // 整个文件的大小
unsigned short bfReserved1; // 保留字段, 必须为0
unsigned short bfReserved2; // 保留字段, 必须为0
unsigned int bfOffBits; // 从文件头到位图数据的偏移, 单位字节, 这里的偏移其实也可以说是位图数据相对整个文件起始位置的偏移
} __attribute__((packed)) bitmapfileheader_t; // 要求编译器不对此结构体进行内存对齐

typedef struct bitmapinfoheader
{
unsigned int biSize; // 此结构体的大小 (14-17字节)
int biWidth; // 图像的宽 (18-21字节)
int biHeight; // 图像的高 (22-25字节)
unsigned short biPlanes; // 表示bmp图片的平面属,显然显示器只有一个平面,所以恒等于1 (26-27字节)
unsigned short biBitCount; // 一像素所占的位数 (28-29字节)
unsigned int biCompression; // 说明图象数据压缩的类型,0为不压缩。 (30-33字节)
unsigned int biSizeImage; // 像素数据所占大小, 这个值应该等于上面文件头结构中bfSize-bfOffBits (34-37字节)
int biXPelsPerMeter; // 说明水平分辨率,用像素/米表示。一般为0 (38-41字节)
int biYPelsPerMeter; // 说明垂直分辨率,用像素/米表示。一般为0 (42-45字节)
unsigned int biClrUsed; // 说明位图实际使用的调色板中的颜色索引数(设为0的话,则说明使用所有调色板项)。 (46-49字节)
unsigned int biClrImportant; // 说明对图象显示有重要影响的颜色索引的数目,如果是0,表示都重要。(50-53字节)
} __attribute__((packed)) bitmapinfoheader_t;

#include <ft2build.h>
#include <fcntl.h>
#include <memory.h>
#include <unistd.h>
#include <freetype2/freetype/ftcolor.h>
#include FT_FREETYPE_H
#include FT_GLYPH_H
#include FT_OUTLINE_H

FT_Library library;
FT_Face face;

int main() {
FT_Error error = FT_Init_FreeType(&library);
if (error) {
printf("failed to FT_Init_FreeType\n");
return 1;
}


int major, minor;
FT_Library_Version(library, &major, &minor, NULL);
printf("version: %d.%d\n", major, minor);

error = FT_New_Face(library, "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc", 0, &face);
if (error) {
printf("failed to FT_New_Face\n");
return 1;
}

int size = 64;
error = FT_Set_Pixel_Sizes(face, 0, size);
if (error) {
printf("font size %d not supported\n", size);
return 1;
}

FT_UInt glyphIdx = FT_Get_Char_Index(face, L'我');
error = FT_Load_Glyph(face, glyphIdx, 0);
if (glyphIdx == 0) {
printf("glyph not found\n");
return 1;
}

printf("glyph index: %d\n", glyphIdx);

error = FT_Load_Glyph(face, glyphIdx, FT_LOAD_COLOR|FT_LOAD_DEFAULT);
if (error) {
printf("failed to load glyph\n");
return 1;
}

FT_Glyph glyph;
FT_Get_Glyph(face->glyph, &glyph);
FT_Glyph_To_Bitmap(&glyph, FT_RENDER_MODE_NORMAL, NULL, 1);

FT_BitmapGlyph glyphBitmap = (FT_BitmapGlyph)glyph;

FT_Bitmap bitmap = glyphBitmap->bitmap;

printf("height(px): %d width(px): %d pitch: %d\n", bitmap.rows, bitmap.width, bitmap.pitch);

// ---

int bufferSize = 4 * bitmap.rows * bitmap.width;
int outputfileSize = sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + bufferSize;
unsigned char *outputBytes = malloc(outputfileSize);
if (outputBytes == NULL) {
perror("failed to malloc mem");
return 1;
}
printf("file size: %d\n", outputfileSize);
printf("image buffer size: %d\n", bufferSize);

// 写文件头
bitmapfileheader_t fheader = {};
fheader.bfType = 0x4d42;
fheader.bfSize = outputfileSize;
fheader.bfOffBits = sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t);
memcpy(outputBytes, &fheader, sizeof(bitmapfileheader_t));

// 写图像信息头
bitmapinfoheader_t iheader = {};
iheader.biBitCount = 32;
iheader.biHeight = -bitmap.rows;
iheader.biWidth = bitmap.width;
iheader.biPlanes = 1;
iheader.biSize = sizeof(bitmapinfoheader_t);
iheader.biSizeImage = bufferSize;
memcpy(outputBytes + sizeof(bitmapfileheader_t), &iheader, sizeof(bitmapinfoheader_t));

// 将8bit灰度图片转换为rgb格式
for (int y = 0; y < bitmap.rows; y++) {
for (int x = 0; x < bitmap.width; x++) {
unsigned grayScale = bitmap.buffer[y * bitmap.pitch + x];
if (grayScale) {
*((outputBytes + sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + y * bitmap.width*4 + x*4 + 0)) = grayScale;
*((outputBytes + sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + y * bitmap.width*4 + x*4 + 1)) = grayScale;
*((outputBytes + sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + y * bitmap.width*4 + x*4 + 2)) = grayScale;
*((outputBytes + sizeof(bitmapfileheader_t) + sizeof(bitmapinfoheader_t) + y * bitmap.width*4 + x*4 + 3)) = 0;
}
}
}

int outputFd = open("ft2_output.bmp", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (outputFd < 0) {
perror("failed to create file");
return 1;
}

if (write(outputFd, outputBytes, outputfileSize) == -1) {
perror("failed to write to file");
return 1;
}

return 0;
}

/* output:
version: 2.13
glyph index: 18528
height(px): 61 width(px): 60 pitch: 60
file size: 14694
image buffer size: 14640
*/

渲染出的图片:

渲染结果

值得注意的是, 不同于24字节代表一个像素的rgb格式, 我们程序中拿到的glyph->bitmap每一个像素只有一个字节代表色彩。它是一张灰度图, 要还原到RGB, 直接按程序中写法处理即可。关于freetype2的诸多机制, 我无法过多赘述, 请参考这篇博文

参考