Linux如何在屏幕上显示ASCII/中文字符

能调API完成的事情非要自己折腾,这会严重影响效率,但这只是玩玩。

下一篇文章我会介绍 setfont 命令的玩法。

问题

如何在屏幕上显示一个字符?

很简单,调用 printf , 执行 echo

然而,我们知道 任何显示的操作,最终都是在显示器上描像素 来完成的。换句话说,任何图案,包括GUI,文字字符等,全部是 画出来的!

把图案画出来的方法有两种,一种是静态的点阵法,一种是动态的矢量法,本文基本都是按照点阵法来的,因为矢量法太复杂,这是另一个话题,准备后面再写一篇专门描述矢量图的。

现在可以开始了。


Linux ASCII字符的显示

当我们调用 printf("%s", ‘A’) 的时候,最终是谁来指导计算机 如何画这个’A’字符 的呢?

以Linux内核为例,我们知道在Linux内核启动尚未启动到用户态init/systemd进程的时候,内核便开始打印启动信息:

Initializing cgroup subsys cpuset
Initializing cgroup subsys cpu
Initializing cgroup subsys cpuacct
Linux version 3.10.0-862.11.6.el7.x86_64 ([email protected]) (gcc version 4.8.5 20150623 (Red Hat 4.8.5-28) (GCC) ) #1 SMP Tue Aug 14 21:49:04 UTC 2018
Command line: BOOT_IMAGE=/vmlinuz-3.10.0-862.11.6.el7.x86_64 root=/dev/mapper/centos-root ro crashkernel=auto rd.lvm.lv=centos/root rd.lvm.lv=centos/swap rhgb quiet LANG=en_US.UTF-8 vga=793
e820: BIOS-provided physical RAM map:
...

此时不可能有任何用户态的库和配置文件介入,到底是谁在指导内核如何在屏幕上画出这些字符的呢?

内核中一定有一部字典!保存着所有这些字符的“外观信息”,然后Linux用某种方式按照这些外观信息的指示,将其画在了屏幕上。

还真有!请看下面的文件:

drivers/video/console/font_10x18.c
drivers/video/console/font_6x11.c
drivers/video/console/font_7x14.c
drivers/video/console/font_8x16.c
drivers/video/console/font_8x8.c
drivers/video/console/font_acorn_8x8.c
drivers/video/console/font_mini_4x6.c
drivers/video/console/font_pearl_8x8.c
drivers/video/console/font_sun12x22.c
drivers/video/console/font_sun8x16.c

我们以 font_10x18.c 为例,看看它的内容:

/********************************
 * adapted from font_sun12x22.c *
 * by Jurriaan Kalkman 06-2005  *
 ********************************/

#include 

#define FONTDATAMAX 9216

static const unsigned char fontdata_10x18[FONTDATAMAX] = {

    /* 0 0x00 '^@' */
    0x00, 0x00, /* 0000000000 */
    0x00, 0x00, /* 0000000000 */
    0x00, 0x00, /* 0000000000 */
    0x00, 0x00, /* 0000000000 */
    ...

嗯,每18行的数组元素表示一个 可以显示的字符 ,由于ASCII码从0x20开始索引可显示字符,所以ASCII码的索引和上述数组元素的/18索引在 0x20~0x7e 范围处是重合的。数组的 0x00~0x1f 以及 0x7f~0xff 的\18索引就可以保存一些 其它字符的外观 ,所谓的 其它 指的就是类似?,♠️,♣️,♦️这些 经典,常用,接地气,但不权威 的字符了…

该文件一共描述了 FONTDATAMAX/2 个可显字符的外观,事实上只要不涉及中文和其它非字母文字,这个字符的总量总是不会太多,整部英文小说也就是ASCII码(英文字母,标点符号等)组成的,其实127个就够了!然而由于加入了一些其它奇奇怪怪非权威但接地气的字符,所以, FONTDATAMAX 也就很大了

好的,让我们找到ASCII索引0x41的字符 ‘A’ 在数组中的位置:

    /* 65 0x41 'A' */
    0x00, 0x00, /* 0000000000 */
    0x04, 0x00, /* 0000010000 */
    0x04, 0x00, /* 0000010000 */
    0x0e, 0x00, /* 0000111000 */
    0x0e, 0x00, /* 0000111000 */
    0x1b, 0x00, /* 0001101100 */
    0x1b, 0x00, /* 0001101100 */
    0x19, 0x80, /* 0001100110 */
    0x31, 0x80, /* 0011000110 */
    0x3f, 0x80, /* 0011111110 */
    0x31, 0x80, /* 0011000110 */
    0x61, 0x80, /* 0110000110 */
    0x60, 0xc0, /* 0110000011 */
    0x60, 0xc0, /* 0110000011 */
    0xf1, 0xc0, /* 1111000111 */
    0x00, 0x00, /* 0000000000 */
    0x00, 0x00, /* 0000000000 */
    0x00, 0x00, /* 0000000000 */

后面注释的二进制序列由改行前面两个字节 p[0]p[1] 拼接而成:

bin = p[0]<<2|p[1]>>6

现在来看看 它为什么就可以定义’A’的外观

为此,将这个数组的16行元素拼接而成的二进制1用红色加粗表示出来,就看出来了。用看马赛克的标准方法,眯着眼睛看。

Linux如何在屏幕上显示ASCII/中文字符_第1张图片

所要做的就是, 按照这个二进制矩阵去描绘一个 10 × 18 10\times 18 10×18像素的矩形区域,二进制1用白色描绘,二进制0用黑色描绘,这就是 ‘A’ 了!

这种方法就是点阵法,上述的二进制矩阵就是字符的点阵图。依照点阵图,我们就可以把一个字符按照 10 × 18 10\times 18 10×18 点阵图的指引在同样的 10 × 18 10\times 18 10×18像素矩阵 中来描述了。

除了 10 × 18 10\times 18 10×18 矩阵的点阵图,还有别的,比如 8 × 8 8\times 8 8×8 点阵, 16 × 16 16\times 16 16×16 点阵等等。

接下来,我用 8 × 8 8\times 8 8×8 点阵举例,写一个程序,来将命令行输入的ASCII字符显示在Linux的Framebuffer上,也就是开机后的显示器终端的 P ( 100 , 100 ) P(100,100) P(100,100) 坐标处。

需要做的就是将上述的内核代码的 drivers/video/console/font_8x8.c 借用过来,然后include到我的程序中,把内核相关的下面的内容去掉,仅仅借用它这个点阵数组:

#include 

//...

const struct font_desc font_vga_8x8 = {
    .idx    = VGA8x8_IDX,
    .name   = "VGA8x8",
    .width  = 8,
    .height = 8,
    .data   = fontdata_8x8,
    .pref   = 0,
};

drivers/video/console/font_8x8.c 复制到当前目录,然后我来写自己的程序,如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include "font_8x8.c"

// 显示缓存的内存抽象,mmap而来
unsigned int *mem = NULL;
// 屏幕信息
static struct fb_var_screeninfo info;
// framebuffer文件抽象
int fb_fd;

// 在p(pos_x,pos_y)坐标的8x8矩形像素内显示字符c
static int show_char(unsigned char c, int pos_x, int pos_y)
{
	unsigned short base = c * 8;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned char rank;

	for (h = pos_y; h < pos_y + 8; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + 8; w++) {
			// 显存抽象的像素点索引位置
			idx = h*info.xres + w;
			// 该点在点阵中的位置
			rank = 1 <<(8-(w - pos_x));
			// 如果该点在点阵中是1,就用白色描绘该像素
			if (fontdata_8x8[row] & rank) {
				// 适配屏幕分辨率!
				if (info.bits_per_pixel == 32)
					// 如果是32位色,则一个像素用32位描绘
					mem[idx] = 0xffffffff;
				else if(info.bits_per_pixel == 16) {
					// 如果是16位色,则一个像素用16位描绘
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = 0xffff;
				} else if(info.bits_per_pixel == 8) {
					// 如果是8位色,则一个像素用8位描绘
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = 0xff;
				}
			}
		}
	}
	// 返回字符的宽度。
	return 8;
}

int main(int argc, char **argv)
{
	unsigned char *str = argv[1];
	int len = strlen(str);
	int i = 0;
	int baseX = 100, baseY = 100;

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	// 将命令行的参数按照点阵图显示在屏幕的p(100,100)坐标开始的一行里
	for (i = 0; i < len; i++) {
		baseX += show_char(str[i], baseX, baseY);
	}
}

我们执行它:

[root@localhost 8x8]# ./a.out 'qwera 123 9 i U PK'
[root@localhost 8x8]#

效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第2张图片

这个 ’qwera 123 9 i U PK’ 可不是任何API打印的。这是按照点阵的指引在屏幕上写像素写出来的。

下面这个程序是依照最开始我引入的 10 × 18 10\times 18 10×18 点阵来的,它可以在屏幕上以渐变色来显示命令行字符:

#include 
#include 
#include 
#include 
#include 
#include 
#include "font_10x18.c"

unsigned short *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;

int start = 0xff00;

static int show_char(unsigned char c, int pos_x, int pos_y)
{
	unsigned short base = c * 18;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned short rank;

	for (h = pos_y; h < pos_y + 18; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + 10; w++) {
			unsigned short line = 0;
			idx = h*info.xres + w;
			// 注意和8x8点阵的不同,拼接方式不同
			line = fontdata_10x18[row*2]<<2|(fontdata_10x18[row*2+1]>>6);
			rank = 1 <<(10-(w - pos_x));
			if (line & rank) {
				if (info.bits_per_pixel == 32)
					mem[idx] = 0xff000000 + start;
				else if(info.bits_per_pixel == 16) {
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = start;
				}
				else if(info.bits_per_pixel == 8) {
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = (unsigned char)start & 0xff;
				}
				// 颜色渐变
				start += 5;
			}
		}
	}

	return 10;
}

int main(int argc, char **argv)
{
	unsigned char *str = argv[1];
	size_t len = strlen(str);
	int i = 0;
	int baseX = 100, baseY = 150;

	printf("size:%d\n", sizeof(unsigned short));
	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	for (i = 0; i < len; i++) {
		baseX += show_char(str[i], baseX, baseY);
	}
}

我们不擦除刚才那个 8 × 8 8\times 8 8×8点阵的输出,直接执行这个程序,注意,这个程序打印的字符要稍微大一些,并且在更下面一点,以示区别:

[root@localhost 10x18]# ./a.out 'qwera 123 9 i U PK'

效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第3张图片


Linux中文字符的显示

是时候显示中文了。

一般提到中文,肯定会提起一堆编码的概念,比如GBK,GB2312,UTF-8等等,这无助于我们理解中文字符的显示!所以我不用这种方式来解释。

无论什么字符,中文也好,英文也好,日文也罢,?也好,♠️也罢,都是 一幅画! 必须 画出来

中文依然可以用点阵图的方式来指引像素描述最终显示出来。

我从网上扒啦出来一个 “我” 16 × 16 16\times 16 16×16点阵描述数组:

0000010010000000
0000111010100000
0111100010010000
0000100010010000
0000100010000100
1111111111111110
0000100010000000
0000100010010000
0000101010010000
0000110001100000
0001100001000000
0110100010100000
0000100100100000
0000101000010100
0010100000010100
0001000000001100

至于在哪里扒啦下载下来的不重要,重要的是我能看出来这确实是一个 “我” 字!注意,眯着眼睛看,离远了看,关注二进制1,看看是不是 “我” 的轮廓。

如果你眼神不好,那么我就将上面这个 16 × 16 16\times 16 16×16点阵的每一个像素用 8 × 8 8\times 8 8×8的矩形画出来,二进制1填前景色,二进制0填黑色,代码如下:

#include 
#include 
#include 
#include 
#include 

unsigned int *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;

int drawrect(int x, int y, int color)
{
	int i, j;
	int idx;
	for (j = y; j < y + 8; j++) {
		for (i = x; i < x + 8; i++) {
			idx = j*info.xres + i;
			if (info.bits_per_pixel == 32)
				mem[idx] = color;
			else if(info.bits_per_pixel == 16) {
				unsigned short *vbuf = (unsigned short *)mem;
				vbuf[idx] = (unsigned short)color & 0xffff;
			} else if(info.bits_per_pixel == 8) {
				unsigned char *vbuf = (unsigned char *)mem;
				vbuf[idx] = (unsigned char)color & 0xff;
			}
		}
	}
}

int main(int argc, char **argv)
{
	int baseX = 100, baseY = 100;
	int i, j, row;
	int color;

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	for (j = 0; j < 16; j++) {
		row = j;
		for (i = 0; i < 16; i++) {
			unsigned short line = 0, rank;
			line = me[row*2]<<8|(me[row*2+1]);
			rank = 1 <<(16-i);
			if (line & rank) {
				color = 0xffffffff;
			} else {
				color = 0x55555555;
			}
			drawrect(baseX + i*8, baseY + j*8, color);
		}
	}
}

展示效果前,先将背景清空:

[root@localhost 16x16zh]# dd if=/dev/zero of=/dev/fb0

效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第4张图片
确实就是 “我” 字。

现在,我们需要用正规的方法,将这个 “我” 字在console上 打印 出来!

依照上述的 16 × 16 16\times 16 16×16 点阵,和刚才显示ASCII完全一致的方法,根据点阵的指引,把它显示出来:

static const unsigned char me[32] = {
    0x04, 0x80, // 0000010010000000
    0x0e, 0xa0, // 0000111010100000
    0x78, 0x90, // 0111100010010000
    0x08, 0x90, // 0000100010010000
    0x08, 0x84, // 0000100010000100
    0xff, 0xfe, // 1111111111111110
    0x08, 0x80, // 0000100010000000
    0x08, 0x90, // 0000100010010000
    0x0a, 0x90, // 0000101010010000
    0x0c, 0x60, // 0000110001100000
    0x18, 0x40, // 0001100001000000
    0x68, 0xa0, // 0110100010100000
    0x09, 0x20, // 0000100100100000
    0x0a, 0x14, // 0000101000010100
    0x28, 0x14, // 0010100000010100
    0x10, 0x0C  // 0001000000001100
};

#include 
#include 
#include 
#include 
#include 

unsigned int *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;

static int show_char(int pos_x, int pos_y)
{
	unsigned short base = 0;;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned short rank;
	unsigned int *pos = mem;

	for (h = pos_y; h < pos_y + 16; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + 16; w++) {
			unsigned short line = 0;
			idx = h*info.xres + w;
			line = me[row*2]<<8|(me[row*2+1]);
			rank = 1 <<(16-(w - pos_x));
			if (line & rank) {
				if (info.bits_per_pixel == 32)
					mem[idx] = 0xffffffff;
				else if(info.bits_per_pixel == 16) {
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = 0xffff;
				}
				else if(info.bits_per_pixel == 8) {
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = 0xff;
				}
			}
		}
	}

	return 16;
}

int main(int argc, char **argv)
{
	int i = 0;
	int baseX = 250, baseY = 200;

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	show_char(baseX, baseY);
}

执行效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第5张图片
嗯,打印出来了中文!

我们仔细看代码,不管是中文还是ASCII码,其显示的方法完全一致,关键是 只要有点阵!

只要有点阵,任何“形状”能在显示器上将其表达出来! 不管这是ASCII码的点阵,中文的点阵,还是说一个?的点阵,或者说是一双皮鞋?。

Linux内核支持的10x18点阵的所有字符

在继续深入之前,我把Linux内核自带的 10 × 18 10\times 18 10×18点阵的字符全部描绘出来:

// show_char 函数复用前文的代码
int main(int argc, char **argv)
{
	int i, j, seq = 0;
	int baseX = 40, baseY = 40;

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	for (j = 10; j < info.yres - 80; j += 40, baseY += 40) {
		baseX = 10;
		for (i = 10; i < info.xres - 80; i += 40) {
			baseX += show_char(seq++, baseX, baseY);
			if (seq == 254)
				return 0;
		}
	}
}

这次用红色显示,效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第6张图片


以上我们显示了出来了ASCII字符,中文字符,然而没有涉及任何关于编码的概念,我是觉得,先说编码,不利于理解 计算机如何显示 这件事的本质,它会平添复杂度,无益于眼下的事情,显然, 显示字符的形状 这件事,与编码无关。

字符集/编码/字体

在我们成功显示了字符之后,我们发现,这些被显示的字符都是 我们给定的点阵 ,下一个话题就是 如何找到需要显示的字符对应的点阵,以及在哪里可以找到它们。

现在,该聊聊编码了。


先来解释一下 字符集编码字体 的概念。

如果世界上只有英美等使用英文的国家的话,那么26个字母外加少数有限个标点符号,将其组合,就能表达几乎所有的信息了,这里的 元字符 算上ASCII码和几个非权威,接地气的字符,只有不超过200个。毕竟计算机是从欧美开始发端的,它们设计的ASCII码,对于它们而言,已经够用了!

如果按照 16 × 16 16\times 16 16×16点阵来描述,即便使用文本文件保存字符点阵信息,一个字符也仅仅占据 32个0xXX ,我们去掉固定的十六进制前缀0x,每一个XX需要2个字符描述,每一个字符需要1个字节,按照200个字符算,一共也就是需要200个点阵,那么总的存储空间占用就是 2 × 32 × 200 = 12800 2\times 32\times 200=12800 2×32×200=12800字节,这根本不算什么,这完全可以用上述数组的方式将其编译到Linux内核!

事实上,Linux内核也是这么做的,让我们再次回顾那就个定义字符点阵的font文件:

drivers/video/console/font_10x18.c
drivers/video/console/font_6x11.c
drivers/video/console/font_7x14.c
drivers/video/console/font_8x16.c
drivers/video/console/font_8x8.c
drivers/video/console/font_acorn_8x8.c
drivers/video/console/font_mini_4x6.c
drivers/video/console/font_pearl_8x8.c
drivers/video/console/font_sun12x22.c
drivers/video/console/font_sun8x16.c

以下便是它们的大小:
Linux如何在屏幕上显示ASCII/中文字符_第7张图片

将这种 文本格式定义的点阵矩阵 直接塞进一个Linux内核绝对不成问题。

然而,实际上不可能都是英文,信息互联网节点之间需要信息交换,交换双方也不限制于在英语文化圈。

因此,基于英语文化圈的ASCII码根本就不够用,仅仅汉语的形旁,声旁加在一起就千余个,就更别说由这些形旁,声旁排列组合成的所有汉字符号了。

主导权虽依然掌握在ASCII文化圈的美国人那里,但是几乎每一个国家都有自己的计算机系统, 显示本国母语字符是刚需! 因此除了通用的ASCII码之外,每个国家均有 按照自己国家地区通用的字符定义的字符符号! 用于信息交换。

换句话说,每个国家或者地区都有自己的母语为主导的字符构成的字符集!以中文为例,像类似GB2312,就是这种字符集,用于标记本地字符的数字。

GB2312表示的字符要比ASCII多得多。因为作为二维空间伸展的中文,要比26个字母一维线形排列组合而成的英文的 元字符 多得多。因此,和一个字节标记ASCII码不同,中文的GB2312要使用两个字节!

以上的 “我” 字,用ASCII码是没法标记的,使用GB2312,那么它的码字就是0xD2CE,这个码字又叫做 区位码想找到一个符号的定义,需要先找到它所在的区,然后再在该区中定位它的位。

为什么不像ASCII码那样直接索引而要采用两层区位来索引呢?

和ASCII码一维伸展不同,汉字的二维伸展( 上下结构,左右结构,半包围结构,全包围结构等 )多了一个维度,单个元字符可以组合的信息量指数级增加。由于GB2312等中文字符集空间实在太大,为了高效索引,必须采用二维索引,而不能像ASCII码那样,仅仅通过一个字节的索引就能表示。

在ASCII码的表示中,0x41既可以表示字符 “A” 在ASCII码表里的索引,又可以表示字符 “A” 本身。然而在GB2312中,直接用0xD2CE表示一个索引的话,那么就注定这个索引集合是稀疏的,载入计算机内存的话,这将浪费大量的内存空间。

所以就采用了二维分级的索引方案,即 区位码 ,既然汉字是二维结构,其字符集也是二维结构,保证 描述信息信息本身 的信息量等量。

这就和现代操作系统的MMU实现的页表索引那般,都是一个意思。


字符集只是一个字符集合的索引,也就是说,比如字符集ASCII里,0x41指的就是 抽象字符 “A” ,至于这个字符采用什么字体,怎么写,形状的异化,这就不是字符集的范畴了。

需要注意的是,字符集里的字符,那是完全抽象的字符定义,并非显示在你眼前的那个字符。

然而即便是抽象的字符,也必须要存储和传输,这个时候的首要考虑就是性价比的问题了,即如何存储或者传输效率最高。这就是这里第二个话题,即 字符集的编码!

这个不多说,否则话题会跑偏。只要理解, 字符集编码也是抽象的,依然不管这个具体字符长什么样子! 编码就是 解决如何存储和传输字符集里的字符 ,性价比是第一位。

所以说,类似哈夫曼编码的原则在这里就会变得有用武之地了!即 使用频度最高的字符编码长度越短,反之就越长。

有了 字符集 来定义字符,有了 字符集编码 来定义字符集里的字符如何存储和传输字符的格式,如果仅仅于此,那不过是空中楼阁,最终落地的,那还是 如何描绘,实现字符的显示 ,这才是我们看得见摸得着的东西,这就是点阵图或者TrueType矢量图等字体定义的任务了。


就着上面ASCII码显示命令行输入的例子,这里需要来一个显示命令行输入中文的例子。

为此,我们需要先的到一个 中文的点阵图 ,这个可不是Linux内核里能找得到的了,Linux内核里天然不支持中文显示。这个点阵需要从网上下载。

我在github上找到一个HZK16的GB2312点阵图:
https://github.com/aguegu/BitmapFont/blob/master/font/HZK16
将它下载下来,保存为本地的文件 HZK16 文件。

如何具体地在这个二进制点阵文件中索引到某个具体中文字符的位置,这就要靠 区位码 来规范化操作了。

摘自Wiki的关于 区位码 计算区位的介绍;

区位码是1980年中国制定的一个字符编码标准。每一个字符都有对应一个4位十进制数字码位表示,其中前两位为“区”,后两位为“位”。中文汉字的编号区号是从16开始的,位号从1开始。

GB2312编码就是基于区位码的,用双字节编码表示中文和中文符号。GB2312编码方式是: 0xA0+区号,0xA0+位号 。如“安”,区位号是1618(十进制),那么“安”字的GB2312编码就是 0xA0+16 0xA0+18 也就是 0xB0 0xB2 。根据区位码表,GB2312的汉字编码范围是0xB0A1~0xF7FE。

直接向计算机输入区位码而得到汉字的方法叫做区位输入法。相应地,输入国标码而得到汉字的方法叫做GB内码输入法。在DOS时代,许多中文系统都实现了国标码及区位码输入法。普通用户一般不使用这些输入法,在DOS被Windows系统取代后,国标码和区位码输入法已少有人使用。

Windows 95至2000、Me中,有“区位输入法”和“内码输入法”;Windows XP中,有“中文(简体) - 内码”;Windows Vista起,该输入法被移除,须从XP系统中移植WinGB.IME方可使用[1]。

Let’ go!

所以,按照 0xA0+区号,0xA0+位号 的规则,代码就很好写了,在屏幕framebuffer显示命令行输入的中文字符,代码如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 

unsigned int *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;
int fd;

static int show_char(unsigned short c, int pos_x, int pos_y)
{
	unsigned short base = 0;;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned short rank;
	unsigned int offset;
	unsigned char buff[32];

	// 按照区位号计算规则,计算区位,然后二维索引到具体位置
	offset = (94*(unsigned int)((unsigned char)(c>>8) - 0xa0 - 1)+((unsigned char)(c&0xff) - 0xa0 -1))*32;

	int r = pread(fd, buff, 32, offset);

	for (h = pos_y; h < pos_y + 16; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + 16; w++) {
			unsigned short line = 0;
			idx = h*info.xres + w;
			// 按照16x16的拼装规则。
			line = buff[row*2]<<8|(buff[row*2+1]);
			rank = 1 <<(16-(w - pos_x));
			if (line & rank) {
				// 适配屏幕分辨率。
				if (info.bits_per_pixel == 32)
					mem[idx] = 0xffffffff;
				else if(info.bits_per_pixel == 16) {
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = 0xffff;
				}
				else if(info.bits_per_pixel == 8) {
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = 0xff;
				}
			}
		}
	}

	return 20;
}

int main(int argc, char **argv)
{
	unsigned char *str = argv[1];
	size_t inlen = strlen(str), outlen = 32;
	int i;

	iconv_t cd;
	char *from = "UTF-8";
	char *to = "GB2312";
	unsigned char inbuf[128] = {0};
	unsigned char outbuf[128] = {0};
	char *outb, *inb;
	int baseX = 200, baseY = 100;

	memset(outbuf, 0, 128);
	memset(inbuf, 0, 128);
	memcpy(inbuf, str, inlen);

	{
	// 由于Linux默认采用UTF-8编码中文字符,所以需要把UTF-8的编码具体对应到两个字节的GB2312。
		cd = iconv_open (to, from);

		outb = &outbuf[0];
		inb = &inbuf[0];

		iconv (cd, &inb, &inlen, &outb, &outlen);
	}
	fd = open("HZK16", O_RDWR);

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	for (i = 0; i < outlen && outbuf[i] != 0; i +=2) {
		unsigned short c = outbuf[i]<<8|outbuf[i+1];
		baseX += show_char(c, baseX, baseY);
	}
}

当我在命令后输入 “./a.out 浙江温州皮鞋湿” 之后,看看效果:
Linux如何在屏幕上显示ASCII/中文字符_第8张图片
OK!

再接下来,既然我们已经知道 “我” 字在GB2312里是0xD2CE,那么按照区位码的定位原则,在 HZK16 文件里一定可以通过区位计算 定位任意字符 ,我们来打印 “我” 字之后的50个字符怎么样?

看代码:

#include 
#include 
#include 
#include 
#include 
#include 

unsigned int *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;
int fd;

static int show_char(unsigned short c, int pos_x, int pos_y)
{
	unsigned short base = 0;;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned short rank;
	unsigned int offset;
	unsigned char buff[32];

	offset = (94*(unsigned int)((unsigned char)(c>>8) - 0xa0 - 1)+((unsigned char)(c&0xff) - 0xa0 -1))*32;

	int r = pread(fd, buff, 32, offset);

	for (h = pos_y; h < pos_y + 16; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + 16; w++) {
			unsigned short line = 0;
			idx = h*info.xres + w;
			line = buff[row*2]<<8|(buff[row*2+1]);
			rank = 1 <<(16-(w - pos_x));
			if (line & rank) {
				if (info.bits_per_pixel == 32)
					mem[idx] = 0xffffffff;
				else if(info.bits_per_pixel == 16) {
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = 0xffff;
				}
				else if(info.bits_per_pixel == 8) {
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = 0xff;
				}
			}
		}
	}

	return 20;
}

int main(int argc, char **argv)
{
	int i = 0;
	int baseX = 100, baseY = 100;
	unsigned char hi, lo;
	unsigned short c;

	fd = open("HZK16", O_RDWR);

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres*info.yres*info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	hi = 0xce;
	lo = 0xd2;
	for (i = 0; i < 100; i +=2) {
		c = hi << 8|lo;
		baseX += show_char(c, baseX, baseY);
		hi++;
		lo++;
	}
}

看效果:
Linux如何在屏幕上显示ASCII/中文字符_第9张图片

回顾一下ASCII码,如何往字符集,编码,字体这个分类中去套。很简单,ASCII码本身既是字符集,又是编码,因为它只有一个字节表示一个字符,压缩成本/收益比比较大,并且根本就没有大端小端的传输和存储歧义,最直接最简单最具性价比的方案就是, ASCII码本身就作为编码来使用!

本文写到这里,貌似要结束了,但是有意义的东西才刚刚开始。

UNICODE的本地映射显示

我们已经知道, GB2312 是一个 字符集 。然而除了GB2312,还有很多中文字符集,连同各种全世界其他国家的字符集,最后加上ASCII码,如果全世界连接成了一张唯一的互联网,那么消除语言隔阂那是首要的目标。

所有的字符集都要被消灭, 大家既然要互联互通互相能懂,那就必须采用同一个字符集 。这个已经做到了,那就是 UNICODE!

unicode采用2个字节一共16位来定义 一个世界上任何地方的字符 ,全世界的字符都够了!然而,如前文所述,字符集是抽象的,它仅仅定义一个数字编码代表的是哪一个字符,这个只为通信的对方能解析到相同的抽象字符即可,仅此而已!

unicode不管字符具体长什么样子, unicode不管一个“我”是楷体,还是宋体,是否加粗,它只管这是一个抽象的“我”字!(否则,16位远远不够,一个非黑即白的属性就需要一个bit!)

所以,一开始unicode自己定义字体的动机就不强,所以,很少见什么unicode的点阵图。

unicode只是为了信息交换,存储。

字符若要 落实到屏幕上给人看 ,那就必须关联一个字体,具体对于计算机显示器来讲就是要关联一个点阵矩阵(或者一个矢量)。一个“我”字,无论是传输还是存储,都可以用0xD2CE来替代,但是一旦要显示在屏幕上或者打印在纸上给人看,那就必须 按照人眼能识别的方式把它画出来 ,这就必然需要一个点阵的指引来填充像素。

计算机完全可以一直用0xD2CE来代替“我”字,但是对于人类而言,我就是“我”!

说说五线谱和简谱。

五线谱和简谱,哪个才是人更容易识别的,我觉得是五线谱,而不是简谱,如果人觉得简朴容易,那可能是被苏联式的音乐教育洗脑了,人怎么就不觉得0xD2CE比“我”字更简单呢?

五线谱可以很直观地看出来音的高低,节拍,简谱的优势在于其容易记忆和传播,写数字就好了,不用画道子了。这个意义上,简谱就好像是编码,而五线谱则是字形点阵之类的。


既然unicode没有动机做字形,人们依然有这个需求。这不,现在有GNU unifont可用了。在这之前,人们不得不把unicode映射到本地字符集才能完成其显示。

unicode不能直接被显示,因为没有直接基于unicode的字形可用。所以必须把unicode先映射到本地字符集,然后再用本地字符集对应的字形去显示。

比如,unicode映射到GB2312。步骤如下:

  1. unicode码 C 1 C_1 C1映射到GB2312码 C 2 C_2 C2
  2. C 2 C_2 C2解析成区位码的“区P”和“位L”;
  3. 用P和L索引字形文件,获取点阵或者矢量;
  4. 根据点阵或者矢量的指引点缀屏幕像素。

最关键的步骤就是第1步。我们通过什么途径去获取这个映射?

两个字符集之间转换表很多,当然,调用iconv就没有意思了,如果你是 一心一意完成完成需求满足客户的业务逻辑 ,那就直接调API吧。

我这里同样在Linux内核中找到了unicode和GB2312的转换表。来自下面的文件:
fs/nls/nls_cp936.c

所谓的cp936指的是code page的第936页,该页就是GB2312,这最初是IBM的定义。

还是以 “我” 字为例,看看在这个文件里如何查表。

这个文件中有两张重要的表:

  • page_uni2charset:将unicode转换为GB2312
  • page_charset2uni:将GB2312转换为unicode

在下面的网站上,你可以查到一个字符的unicode码:
https://graphemica.com/
比如输入“我”字,搜索:
Linux如何在屏幕上显示ASCII/中文字符_第10张图片

现在我们知道 “我” 的unicode码是 0x6211 ,步骤如下:

  1. 用0x6211的高字节0x62索引page_uni2charset,获取一个数组u2c_62;
  2. 用0x6211的低字节0x11索引u2c_62数组,获取连续的两个元素0xCE, 0xD2;
  3. 0xCE,0xD2小端转换,0xD2CE就是“我”字的GB2312码。

是不是很简单呢?现在让我们反过来找一下,用“我”字的GB2312码0xD2CE索引一下它的unicode,看看是多少。按照上面的步骤,比着葫芦画个瓢:

  1. 大端转换0xD2CE,分解为0xCE,0xD2;
  2. 用0xCE索引page_charset2uni表,获得元素c2u_CE,它是个数组;
  3. 用0xD2索引数组c2u_CE,获得一个元素0x6211;
  4. 0x6211就是“我”的unicode码。

如此优美简洁的数据结构,还是看一眼长什么样子吧:

static const unsigned char u2c_5B[512] = {
    0x8B, 0xBE, 0x8B, 0xBF, 0x8B, 0xC0, 0x8B, 0xC1, /* 0x00-0x03 */
	...
    0x8B, 0xD3, 0x8B, 0xD4, 0x8B, 0xD5, 0x8B, 0xD6, /* 0x18-0x1B */
    ...
}
static const unsigned char *const page_uni2charset[256] = {
    u2c_00, u2c_01, u2c_02, u2c_03, u2c_04, NULL,   NULL,   NULL,
   	...
    u2c_50, u2c_51, u2c_52, u2c_53, u2c_54, u2c_55, u2c_56, u2c_57,
    u2c_58, u2c_59, u2c_5A, u2c_5B, u2c_5C, u2c_5D, u2c_5E, u2c_5F,
    u2c_60, u2c_61, u2c_62, u2c_63, u2c_64, u2c_65, u2c_66, u2c_67,

以往,我们说,使用unicode需要关联一个code page,就是这个意思,做兼容转换用的,毕竟unicode可用且权威的字形几乎是缺失的。

十年前左右,我写过一篇关于编码,code page的文章,可以回顾一下:
unicode浅谈–信息论系列: https://blog.csdn.net/dog250/article/details/5303476


UNICODE的直接显示

GNU unifont出来后,可以不必再做code page转换了,因为GNU unifont就是unicode的直接字形!

它的Wiki页面:https://zh.wikipedia.org/wiki/GNU_Unifont
它的主页:http://www.unifoundry.com/
它的最新版本下载页面:http://www.unifoundry.com/unifont/index.html
它的历史:

Roman Czyborra在1998年创造了Unifont格式[4],但更早期的努力可以追溯至1994年。

2008年,Luis Alejandro González Miranda写了把这个字体转换成TrueType字体的程序。Paul Hardy在稍后修改它以支持在新版TrueType中的组合字母。

最后,理查德·斯托曼在2013年10月接受Unifont成为一个GNU软件包,而Paul Hardy是它的维护者。

我当然要去玩玩了。我玩的是hex版本,因为它是文本的格式,符合UNIX哲学。

我下载的是这个文件:unifont_sample-12.1.01.hex ,它目前的链接是:
http://unifoundry.com/pub/unifont/unifont-12.1.01/font-builds/unifont_sample-12.1.01.hex.gz

然后我们解压它,形成一个文本文件 unifont_sample-12.1.01.hex , 看看它的内容:

非常简单清晰的一维索引结构,不过我倒是觉得可以采用类似GB2312的 二级区位索引结构 ,这样更加紧凑,节省内存。

不过,好在unicode只有16比特地址空间,64K的大小。

如果我要获取 “我” 字的点阵,直接找到这个文件的0x6211行即可:

[root@localhost test]# cat unifont_sample-12.1.01.hex | grep 6211:
6211:04400E50784808480840FFFE084008440A440C48183068220852088A2B061002

它的点阵就是 "04400E50784808480840FFFE084008440A440C48183068220852088A2B061002" 按照十六进制翻译后, 16 × 16 16\times 16 16×16拆解后的字形矩阵了。

现在,我来按照这个hex文件定义的字形,把 “我” 之后的20个汉字显示出来,看看它们的样子。

  1. 对这个hex文件进行预处理,截取0x6211行之后20行的内容;
  2. 将20行的内容文本整理成一个unsigned char型的点阵数组;
  3. 将该点阵数组include到C文件,编译执行。

这就是我喜欢文本文件的原因,连文件IO都不需要,直接在编译预处理时解决所有问题。

下面就是脚本了:

#!/bin/bash

## process 脚本

# Step 1: 截取一部分供显示测试的字体点阵描述到一个临时文件
sed -n '25106,25125p' unifont_sample-12.1.01.hex >./partial

# Step 2: 去掉最前面的unicode编码,只保留后面的点阵描述信息
sed -i 's/ //g;s/^....\://g' partial

# Step 3: 每两个字符加一个 0x,用来生成数组
sed -i 's/ //g;s/../\, 0x&/g' partial

# Step 4: 去掉第一行最开始的 , 号
sed -i '1s/^,//' partial

# Step 5: 增加数组定义头 & 增加数量宏定义
sed -i '1 iunsigned char code[(25126 - 25105 + 1) * 32] = {' partial
sed -i '1 i#define NUM	(25126 - 25105 + 1)' partial

# Step 6: 增加数组定义尾
sed -i '$ a};' partial

# Step 7: 编译主程序
gcc show_unicode_char.c -o show_unicode_char

现在给出主程序 show_unicode_char.c 的代码:

#include 
#include 
#include 
#include 
#include 
#include 
#include "partial"

#define UNICODE_WIDTH	16
#define GAP_WIDTH		4

unsigned int *mem = NULL;
static struct fb_var_screeninfo info;
int fb_fd;

static int show_char(unsigned short char_idx, int pos_x, int pos_y)
{
	unsigned short base = char_idx * UNICODE_WIDTH;
	unsigned int idx;
	unsigned int w, h;
	unsigned int row;
	unsigned short rank;

	for (h = pos_y; h < pos_y + UNICODE_WIDTH; h++) {
		row = base + h - pos_y;
		for (w = pos_x; w < pos_x + UNICODE_WIDTH; w++) {
			unsigned short line = 0;
			idx = h*info.xres + w;
			line = code[row*2]<<8|(code[row*2+1]);
			rank = 1 <<(16-(w - pos_x));
			if (line & rank) {
				if (info.bits_per_pixel == 32)
					mem[idx] = 0xffffffff;
				else if(info.bits_per_pixel == 16) {
					unsigned short *vbuf = (unsigned short *)mem;
					vbuf[idx] = 0xffff;
				}
				else if(info.bits_per_pixel == 8) {
					unsigned char *vbuf = (unsigned char *)mem;
					vbuf[idx] = 0xff;
				}
			}
		}
	}

	return UNICODE_WIDTH + GAP_WIDTH;
}

int main(int argc, char **argv)
{
	int i;
	int baseX = 110, baseY = 180;

	fb_fd = open("/dev/fb0", O_RDWR);
	ioctl(fb_fd, FBIOGET_VSCREENINFO, &info);
	mem = mmap(NULL, info.xres * info.yres * info.bits_per_pixel/8, PROT_READ|PROT_WRITE, MAP_SHARED, fb_fd, 0);

	for (i = 0; i < NUM; i ++) {
		baseX += show_char(i, baseX, baseY);
	}
}

执行之:

[root@localhost test]# dd if=/dev/zero of=/dev/fb0
dd: 正在写入"/dev/fb0": 设备上没有空间
记录了10241+0 的读入
记录了10240+0 的写出
5242880字节(5.2 MB)已复制,0.0443195 秒,118 MB/秒
[root@localhost test]#
[root@localhost test]# ./process
[root@localhost test]#
[root@localhost test]# ./show_unicode_char
[root@localhost test]#

效果如下:
Linux如何在屏幕上显示ASCII/中文字符_第11张图片

关于矢量字体

GNU unifont除了点阵字体,同时也提供了矢量字体:
The Standard Unifont TTF: http://unifoundry.com/pub/unifont/unifont-12.1.01/font-builds/unifont-12.1.01.ttf

我们知道点阵字体没有办法放大,它是静态的,放大之后在同样的距离同样的光线看,它就成了锯齿+马赛克,只有你离的足够远到一定程度,让该字体单位面积光的摄入量小到一定程度的时候,它才能恢复原貌,比如离远了看,眯着眼睛看…

点阵字体的点阵一旦形成,就决定了它的分辨率就是 16 × 16 16\times 16 16×16 8 × 8 8\times 8 8×8这种,万年不变。

而矢量字体是动态的,矢量字体保存的不是 字体的像素描述 这种二手资料,而是 字体的描述 本身。

以下以 “十” 字为例,简单的区别了点阵字体和矢量字体:

  • 点阵字体: 16 × 16 16\times 16 16×16矩形像素框里p(0,0)=0,p(1,0)=0,…
  • 矢量字体:横竖两条线相等,垂直交叉于各自的中点,边缘呈直角。

看出区别了吧,矢量字体其实是一种 指导性的描述 ,照着做就能还原字体本身。

矢量字体太复杂,本文不做描述,涉及到大量的数学,贝塞尔曲线什么的,已经过凌晨了,心血任消磨,睡觉了!


皮鞋进水的重要性

2019年5月14日晚10时50分,位于青田县温溪镇意尔康股份有限公司皮鞋仓库起火,8万双皮鞋付之一炬…

意尔康确切的说不是温州皮鞋,然而离温州超级近,方言也包括温州话…

如果皮鞋进水湿,那先不管它会不会变胖,至少不会烧着。如何进水湿,必然要下雨,下雨必然皮鞋湿。所以说,为了让自己的皮鞋不会被烧毁,一定要下雨穿。

下雨,皮鞋进水后,火烧,水被烘干,就不会变胖了。

so,浙江温州皮鞋湿,下雨进水不会胖!

你可能感兴趣的:(Linux如何在屏幕上显示ASCII/中文字符)