像文本模式一样显示字符串
在拼操作系统的征程中,仅仅是画上一些简单的图形,显然是不够的。原因就在于,如果开发的过程中,出现了“臭虫”,而系统并不能显示任何有价值的信息,那我们岂不是两眼一抹黑,要抓瞎了。在这一章中,我们就要不遗余力地使操作系统在图形模式下,能够像文本模式一样显示字符串,从而使操作系统的开发变地简单易行。
首先看我们新添加的工具软件,这两个软件分别是makefont.exe和bin2obj.exe,它们分别是将现成的可见字库转变成二进制文件和进一步将二进制文件转变成可链接的目标文件的小程序。
在图形模式下,怎么描画和显示我们通常的字符呢?其实这是个很简单的事情了,它的本质和画矩形简直是一样一样的呢。
我们来对kernel.c所作的修改。
【kernel.c 节选】
(上面省略)
char pic[16][16] =
{
{'*','*','*','*','*','*','*','*','*','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','#','#','#','#','#','#','#','#','#','#','#','#','#','*'},
{'*','*','#','*','*','*','*','*','#','*','*','*','*','*','#','*'},
{'*','*','#','*','*','*','*','*','#','*','*','*','*','*','#','*'},
{'*','*','#','*','*','*','*','*','#','*','*','*','*','*','#','*'},
{'*','*','#','*','*','*','*','*','#','*','*','*','*','*','#','*'},
{'*','*','#','#','#','#','#','#','#','#','#','#','#','#','#','*'},
{'*','*','#','*','*','*','*','*','#','*','*','*','*','*','#','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','#','*','*','*','*','*','*','*'},
{'*','*','*','*','*','*','*','*','*','*','*','*','*','*','*','*'},
};
unsigned int pic_data[16][16];
for(j = 0; j < 16; j++) {
for(i = 0; i < 16; i++) {
if(pic[j][i] == '#') {
pic_data[j][i] = 0x00888888;
}
if(pic[j][i] == '*') {
pic_data[j][i] = 0x00000000;
}
}
}
for(j = 0; j < 16; j++) {
for(i = 0; i < 16; i++) {
draw_point(video + 1024 * (128 + 16 + 16),
1024, i + 512, j, pic_data[j][i]);
}
}
(下面省略)
通过看上面的程序,相信大家已经清楚的看到了吧。对了,图中用字符拼出来的正是“中国”的“中”字,我们定义了两个二位数组,第一个是存放字符的,第二个则是存放颜色数值的。第一次的循环语句借助条件判断,将字符转化为颜色值,第二次是用画点函数将这个中文字符写在屏幕上。见下图。
图中的“中”字是小了点,但是一点也不影响我们的判断。同时我们看到,我们的蜗牛系统不但在信息区显示了英文字符,而且在图形区写出了“我爱你蜗牛操作系统!”的汉字,这些都是我们系统的字符处理模块的功劳。下面让我们来逐个看一下字符处理函数。我们当然是在工作目录下新建了叫做string的目录,并创建了string.h和string.c文件。
【string.h】
// string.h 创建者:至强 创建时间:2022年8月
#ifndef __STRING_H
#define __STRING_H
int pos;
void make_ascii(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* font);
void putfont8(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* font);
void put_str_buf(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* str);
void put_str(char* s, unsigned int colour);
void put_str_red(char* s);
unsigned int strlen_(const char* s);
unsigned int div_(unsigned int* i, unsigned int n);
void i2a(char* buf, unsigned int a, unsigned int b);
int vsprintf_(char* buf, const char* format_s, char* temp);
int printf_(const char* format_s, ...);
void memcpy_(void* d_, void* s_, unsigned int c);
void memset_(void* d_, unsigned char v, unsigned int l);
char* strcpy_(char* a_, char* b_);
void put_gb2312_buf(int* buf, int xsize, int x, int y, int colour,
unsigned char* s);
void putfont16(int* buf, int xsize, int x, int y, int colour, short* font);
#endif
【string.c】
// string.c 创建者:至强 创建时间:2022年8月
#include "global.h"
#include "string.h"
#include "x.h"
/*
描画一个ASCII字符的函数。一个ASCII字符被定义一个字节
的长度,他被称为"美国信息交换标准码",什么又是老美。
哎,没办法呀,谁让人家计算机发展得早。为了能够画出一
个好看而且完整的字符,我们选定了8 * 16的矩形区域(点
阵)。而为了节省字库的空间在描述每个点时用计算机中的
一位来表示。即使这样,一个字符也需要16字节的内存空间。
而ASCII可表示的字符极限时256(2^8)个,因此字库的大小
推测应该最小是4096(256 * 16)字节。而实际上还要比这个
稍大一些。
*/
/*
这样大家就应该明白或者糊涂了吧,是的我在最开始的时候
也是这样。其实ASCII字符在计算机中只是一些数值,从0开
始到255也就是这个范围。为了和描绘图形的点阵字库相对
应上,我们的字库也是按这个顺序来安排的。比如,字符'a'
的数值是97,则在点阵字库中的位置是97 * 16字节处。也
就是通过ASCII的值能表明描述字符图形的16字节的初始位
置。而我们要做的是用这16字节来描画一个字符的图形。
*/
void make_ascii(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* font)
{
int i;
int* p;
char d;
// 下面的循环是对每一个字节进行解析,共16个字节
for(i = 0; i < 16; i++) {
/*
大家对于p的计算方法可能会有疑问。p代表的是像素
在内存中地址。x、y代表的是像素的在窗口图形中的
横纵坐标,每一行的像素是窗口的横向尺寸。因此,
每换一行画点纵坐标的值要乘以该尺寸。x方向上的
8个点是用p[0]——p[7]逐个画出来的,y方向上的16行
是用循环完成的。大家可能会想,这个不就是画矩形吗?
是的,只不过通过每个条件语句决定了这个点是描绘
还是不描绘。
*/
p = buf + (y + i) * x_size + x;
d = font[i];
/*
每一个字节都有8位,这里对每一位的操作没有
使用循环,而是逐位解析逐个像素填充颜色,主要
是基于效率的考量。而且,大家直观的也能看出一个
规律,那就是每一位就是8421的循环。当然这里是
16进制的表示方法。
*/
if(d & 0x80) {p[0] = colour;}
if(d & 0x40) {p[1] = colour;}
if(d & 0x20) {p[2] = colour;}
if(d & 0x10) {p[3] = colour;}
if(d & 0x08) {p[4] = colour;}
if(d & 0x04) {p[5] = colour;}
if(d & 0x02) {p[6] = colour;}
if(d & 0x01) {p[7] = colour;}
}
}
/* 附赠的和make_ascii()功能相同的函数。虽然效率与make_ascii()
要差很多,但是更好理解了。
*/
void putfont8(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* font)
{
int i, j;
for(j = 0; j < 16; j++) {
for(i = 0; i < 8; i++) {
if(font[j] & (0x80 >> i)) {
draw_point(buf, x_size, x + i, y + j, colour);
}
}
}
}
// 在窗口的任意位置描画字符串的的函数。
void put_str_buf(int* buf, unsigned int x_size, int x, int y,
unsigned int colour, char* s) {
// ASCII码的8 * 16点阵字符。
extern char ascii[4402];
while(*s) {
make_ascii(buf, x_size, x, y, colour, ascii + *s++ * 16);
// 字符的横向始终是占据8个像素。
x += 8;
}
}
// 我们用于在信息区持续描画字符串的函数。具有自动换行和清屏功能。
void put_str(char* s, unsigned int colour) {
extern char ascii[4402];
unsigned int* video_base = get_video_addr(multiboot2_magic,
multiboot2_addr);
// x、y分别代表横纵坐标上的像素数。
int x, y;
// 获取像素位置。
x = pos % 1024;
y = pos >> 10;
while(*s) {
/* 信息区只有1024 * 128个双字的大小,如果
纵坐标的像素数大于或等于128则清除信息区,
并且置信息去字符的起始位置位0。
*/
if(y >= 128) {
y = 0;
info_area_cls();
}
// 对于换行的处理。
if(*s == '\n') {
s++;
x = 0;
y += 16;
continue;
}
// 显示字符。
putfont8(video_base, 1024, x, y, colour, ascii + *s++ * 16);
x += 8;
/*
因为x只是坐标,所以当x大于他的极限时,
要对x进行充值,同时y坐标增加16。
*/
if(x >= 1024) {
x = 0;
y += 16;
}
}
// 每显示一次对全局像素变量进行修正。
pos = (y << 10) + x;
}
// 仅仅是在信息去显示红色的字符。
void put_str_red(char* s) {
put_str(s, 0x00ff0000);
}
// 附赠的内存复制的函数。
void memcpy_(void* d_, void* s_, unsigned int c) {
char* d = d_, * s = s_;
char* p = d_ + c;
for(;d < p;) {
*d++ = *s++;
}
}
// 附赠的内存写入特定值的函数。
void memset_(void* d_, unsigned char v, unsigned int l) {
unsigned char* d = d_;
unsigned char* s = d_ + l;
for(; d < s;) {
*d++ = v;
}
}
// 字符串拷贝函数。
char* strcpy_(char* a_, char* b_) {
char* a = a_;
while(*b_) {
*a_++= *b_++;
}
return a;
}
// 在窗口的任意位置描画中文(gb2312标准)的的函数。
void put_gb2312_buf(int* buf, int xsize, int x, int y, int colour,
unsigned char* s)
{
// 符合国标gb2312的汉字点阵字库。
extern short gb2312[267922 / 2];
unsigned char a, b;
unsigned int offset;
short* word_addr;
while(*s) {
a = s[0];
b = s[1];
// gb2312编码中汉字字符的偏移值计算方法。
offset = ((a - 0xa0 - 1) * 94 + (b - 0xa0 - 1)) * 16;
word_addr = gb2312 + offset;
putfont16(buf, xsize, x, y, colour, word_addr);
s += 2;
x += 16;
}
}
// 描画gb2312标准的汉字的函数,这个我们要在正文中详细啰嗦一下。
void putfont16(int* buf, int xsize, int x, int y, int colour, short* font) {
int i;
int* p;
short d;
for(i = 0; i < 16; i++) {
p = buf + (y + i) * xsize + x;
d = font[i];
if(d & 0x80) {p[0] = colour;}
if(d & 0x40) {p[1] = colour;}
if(d & 0x20) {p[2] = colour;}
if(d & 0x10) {p[3] = colour;}
if(d & 0x08) {p[4] = colour;}
if(d & 0x04) {p[5] = colour;}
if(d & 0x02) {p[6] = colour;}
if(d & 0x01) {p[7] = colour;}
if(d & 0x8000) {p[8] = colour;}
if(d & 0x4000) {p[9] = colour;}
if(d & 0x2000) {p[10] = colour;}
if(d & 0x1000) {p[11] = colour;}
if(d & 0x0800) {p[12] = colour;}
if(d & 0x0400) {p[13] = colour;}
if(d & 0x0200) {p[14] = colour;}
if(d & 0x0100) {p[15] = colour;}
}
}
// 求取字符串长度的函数。
unsigned int strlen_(const char* s) {
const char* t = s;
while(*t) {
t++;
}
return t - s;
}
/*
一个特殊的除法函数,商存放于放置被除数的地址中,
而返回值为本次除法的余数。
*/
unsigned int div_(unsigned int* i, unsigned int n) {
unsigned int res = *i % n;
*i /= n;
return res;
}
// 将数值转化为特定字符串,并存储于缓冲区buf中。
void i2a(char* buf, unsigned int a, unsigned int b) {
char* res = buf, * p;
if(!a) {
*res++ = '0';
*res = '\0';
return;
}
const char num[32] = "0123456789abcdef";
char t[0x100];
p = t;
// 相当于用查表的方法获取对应字符的ASCII值。
while(a) {
*p++ = num[div_(&a, b)];
}
*p = '\0';
/*
因为获得的字符串是倒置的,所以这里把字符串摆正,
怎么样教科书上做没做过类似的习题。看来书上的乏味
不是没有道理,而是我们不懂。
*/
while(p != t - 1) {
*res++ = *--p;
}
*res = '\0';
}
// 下面是一个简单的格式化字符串的函数。
int vsprintf_(char* buf, const char* format_s, char* temp) {
const char* s = format_s;
char t[0x100], * p, * res = buf;
unsigned int i;
while(*s) {
/*
当出现%时,将是变量或特殊字符的解析,
其它的按正常字符显示。
*/
if(*s == '%') {
s++;
switch(*s) {
// %%将被解释为一个%字符并显示。
case '%':
*buf++ = *s;
break;
// 预示着在可变参数中将出现一个字符。
case 'c':
i = *((char*)(temp+=4));
*buf++ = (char)i;
break;
// 在可变参数中出现一个字符指针,即字符串。
case 's':
p = *((char**)(temp+=4));
while(*p) {
*buf++ = *p++;
}
break;
// 可变参数中的数值将被转化成十进制字符串。
case 'd':
i = *((int*)(temp+=4));
i2a(t, i, 10);
p = t;
while(*p) {
*buf++ = *p++;
}
break;
// 可变参数中的数值将被转化成十六进制字符串。
case 'x':
i = *((int*)(temp+=4));
i2a(t, i, 16);
*buf++ = '0';
*buf++ = 'x';
p = t;
while(*p) {
*buf++ = *p++;
}
break;
// 可变参数中的数值将被转化成二进制字符串。
case 'b':
i = *((int*)(temp+=4));
i2a(t, i, 2);
p = t;
while(*p) {
*buf++ = *p++;
}
*buf++ = 'b';
break;
}
s++;
} else {
*buf++ = *s++;
}
}
*buf = '\0';
return strlen_(res);
}
// 实现的一个简单的printf函数,这个函数够经典吧。
int printf_(const char* format_s, ...) {
char t[0x400];
// 获得可变参数中第一个参数的地址。
char* temp = (char*)&format_s;
vsprintf_(t, format_s, temp);
temp = NULL;
put_str_red(t);
return strlen_(t);
}
上面的代码是很繁复的,幸好笔者在代码中间写了不少的注释,大家应该不会有什么疑问了。而且我们附赠了一些将来有用的函数,等以后慢慢享用。
下面是我们在kernel.c和boot.asm中添加的代码,它更加深刻地阐释了显示字符函数的原理。
【kernel.c 节选】
(上面省略)
static char font_A[16] = {
0x00, 0x18, 0x18, 0x18, 0x18, 0x24, 0x24, 0x24,
0x24, 0x7e, 0x42, 0x42, 0x42, 0xe7, 0x00, 0x00
};
extern char font_A_;
make_ascii(video + 1024 * 128, 1024, 0, 0, 0x000000ff, (char*)&font_A_);
make_ascii(video + 1024 * (128 + 16), 1024, 0, 0, 0x0000ff00, font_A);
printf_("%s %d %x %b", "I love you SnailOS...!", 0x8, 8, 8);
put_gb2312_buf(video, 1024, 500, 400, 0x00aa0000, "我爱你蜗牛操作系统!");
(下面省略)
【boot.asm 节选】
(生面省略)
; 共16个字节,正好是字库中一个字符的点阵表示。
global _font_A_
_font_A_:
db 0x00, 0x18, 0x18, 0x18, 0x18, 0x24, 0x24, 0x24
db 0x24, 0x7e, 0x42, 0x42, 0x42, 0xe7, 0x00, 0x00
在上面首先我们定义了一个静态字符数组font_A,然后我们引用了一个外部的变量font_A_。这不是很奇怪吗?你猜对了吗?我们要说明的是它们在功能上是等效的,即是定义字符静态数组等价于汇编语言中db伪指令。另外要说明的是字库中图形的表示方法整整比我们最开始用的方法节省8倍的空间。原因是一个字节的每个位能够描述一个像素。
在揭开字库生成原理的大幕前,我们首先用手头的工具生成字库吧。下面是操作方法。
显示用户页
C:\Users\free2\.VirtualBox\temp\font>makefont.exe hankaku.txt
usage>makefont source.txt font.bin
转化为二进制文件
C:\Users\free2\.VirtualBox\temp\font>makefont.exe hankaku.txt ascii_font.bin
显示用户页
C:\Users\free2\.VirtualBox\temp\font>bin2obj
usage>bin2obj binfile objfile [-]label
转化为可连接的目标文件,并生成可用的全局符号。
C:\Users\free2\.VirtualBox\temp\font>bin2obj ascii_font.bin ascii_font.obj _ascii
Kankaku.txt就是一个纯文本文件,大家有兴趣可以用wps等软件打开参观一下。里面的内容就类似于我们自己定义的pic[16][16]这个数组,人可以看懂,而且能够编辑。通过它能够比较形象的看到每个字符的点阵表示形式。这当然是我们能够手工编辑的了。然后我们通过makefont.exe工具就生成了二进制的字符文件,这也就相当于我们上面的第一个循环,只不过我们用直接用像素表示比较浪费空间,毕竟每像素四字节。这里就是把字符转化为位图的表示方法,从而节省空间。接下来用bin2obj.exe工具,把纯二进制文件转化成我们能够链接的目标文件,就能够被我们的编程语言操作了。作为类比我们在boot.asm中也global调出了全局标签_font_A_为C语言程序所使用。bin2obj.exe的作用和目标也就仅此而已。并且我们知道最终生成的ascii_font.obj文件需要链接到内核文件中。
综合上面的叙述,我们不难总结一点,其实是可以纯手工的打造字库的,而且仅仅是有语言本身的就够了。不过那样不形象,以至于我们要在纸上画很多的图形,来确定字符每个字节的二进制值。所以前辈想到了,先用特殊字符编辑形象的矩形图形,在将字符图形转化成点阵字库的好方法。不过点阵字库也不过是为了节省空间,在我们使用字库的时候,还要编程把它转化的为真正像素的点才能正常的显示,这不是“卖孩子买猴”的玩法吗?也亏了这些大侠们在时空之间穿梭的能力了。
有了ASCII字符的字库,下面们该说说“简体汉字”的字库了。笔者用的是“无脑仔的小明”提供的hzk16的符合gb2312标准的字库(更详细的内容请查看“无脑仔的小明”《30天自制操作系统》实现中文显示一文)。我这只是把那些重要的东西给大家交代一下就好了。
通过我们的putfont16函数的我们可以知道,在编码中低字节存放的是汉字的左边,高字节存放的是汉字的右边,因此,如果使用两层循环(类似于画矩形)来显示汉字的话,应该第一层循环分成两部分,分别显示,不能直接从大到小地完成。而第二层循环依然是从上倒下的,所以可以一次完成。
第二个要注意的问题就是gb2312编码是怎样的得到,汉字字符的偏移的。这个标准的常用汉字是6千多不到7千个,因此,用一个字节来编码板顶是不够了。因此,制定标准的人们采用了连个字节的方式。不过不是直接用两个字节指定汉字位置的偏移,而是绕了个弯子,当然是又给我们埋了个坑。首先是把汉字分成若干个区,每个区内只放94个汉字,因此,要找汉字首先要确定区,然后是区内的位号。在两个字节的编码中低字节就是区号,高字节就是位号。同时,区号和位号也都不是随意使用,它们都从0xa1开始的,也就是在此套编码中,任何汉字的编码都应该是比0xa1a1大。编码就是这样了,偏移怎么求取呢?下面的公式告诉我们。
在点阵字符中的偏移= (94*(区码-1)+(位码-1))*32
而我们的实际与语句是offset = ((a - 0xa0 - 1) * 94 + (b - 0xa0 - 1)) * 16;
原因是我们的区位号是直接从汉字编码中得来的,所以要在此基础上减去区位的起始号0xa0,同时因为数组开始于0,所以还要在减去1。至于我们为什么乘以16,而公式乘以32,那是因为我们的数组是字类型的,而公式是字节类型的。
最后有个关键的环节还要告诉大家了,即使是你前面的事情都做得完全正确,也可能不能正常显示汉字。这又是为什么呢。实话实说,我在首次实验到这个地方的时候都快要崩溃了,最后不知从哪里才想到这个连做梦都不会想到的地方。
看到这个地方你就豁然开朗了吧。原来我们的文本编辑器默认的编码规则是ANSI。在这里也让我感叹一下吧,在如释重负之前,难道一定要经历炼丹炉、走火焰山等等等等九九八十一难吗?我不禁的这样问!
经历了前面的苦痛之后,我甚至都把毛爷爷的诗词七律《人民解放军占领南京》想起来了,就让我们在屏幕上显示出来吧!我们改写kernel.c的部分语句,同时还删除了那些实验的中间产物。这些语句还是请大家自己发挥吧,这里就不再贴出来了。
最后的最后,也就是剩下printf相关函数还没有讲解了。printf_()函数使用了c语言的一个不常用的功能——可变参数。也就是参数表中的“...”,这样一来我们就能够在一个固定的格式化字符串后使用不定的若干个参数来输出数目不等的变量信息了。
关于这个技巧的实质是一个很有价值的话题——栈与函数调用。这个问题我们已经接触过了,还记得boot.asm文件中的这几个语句吗。
push ebx
push eax
call _kernel_main
上面的三条汇编语句,就相当于c语言中的“kernel_main(eax, ebx);”,即是上面的先把ebx入栈,再把eax入栈,在调用函数(汇编中一般称为过程),这个顺序就是所谓的从右往左的顺序。至于说为什么这样,这个就叫做“王八的屁股”了。可变参数也不例外,比如咱们的printf_("%s %d %x %b", "I love you SnailOS...!", 0x8, 8, 8);语句,就是先后把8、8、0x8、"I love you SnailOS...!"和"%s %d %x %b"入栈,这其中整数的入栈当然是整数本身,字符串入栈就是字符串的首地址了。最后一个入栈的是不变参数,也就是咱们的格式化字符串。由于机器自带的硬件栈的单元长度是固定的,所以只要知道格式化字符串地址的地址,就可以推算出其后的各个可变参数的地址,有了地址就能够访问到地址中的内容。又由于栈是向下生长的,所以最后入栈的格式化字符串地址的地址就是最低地址,如果定义为字符指针的情况下,对该指针做增4的运算,就得到了下一个参数的地址,依次类推可以得到所有参数的地址。然而,大家如果明白了我上述的内容。你就会提出一个疑问,我们是怎么确定参数的个数的,如果参数的个数与格式化字符串中的不相符怎么办?遗憾的是,这类函数并无对参数相符的判断能力,全要靠我们对格式化字符串及之后参数的人工核对,也就是说必须由程序员来负责调用这类函数的正确性,即使你搞错了,因为是可变参数,c编译系统和你运行的程序都不知道参数与格式化字符串中的参数是否对应。因此,要是程序员一不留神写错了,那大概率是程序运行到此,就导致问题的发生。
还有一点是不得不说明的,刚才我们提到的kernel_main函数,因为是系统中第一个c函数,同时也是内核函数,因此他是一个有去无回的函数,从此也不会返回到调用的它的汇编代码中了。它不能称之为一次完整的函数调用。一般的,一次完整的函数调用,被调函数在执行完成后一定要返回到主调函数,call指令隐含的是操作机器栈的,通常情况下,call指令在转移到被调函数之前,会把call指令下面语句的地址压入栈,这样一来,当被调函数想要返回主调函数中继续执行时,就会从栈中得到返回地址,从而顺利返回到主调函数。ret指令正是这样的一条指令,它执行与call指令相反的操作,从栈中取回返回地址,并回到主调函数。由于call和ret分别在主调函数和被调函数中配合默契到天衣无缝的感觉,因此,我们不会特别关注它们。而由于主调函数在函数调用之前,通过操作栈类指令改变了栈指针。通常情况下,在call指令下,我们会加入恢复栈指针的指令,拿kernel_main为例,函数调用前有两个参数入栈,因此在返回后,会执行add esp, 2 * 4指令使栈指针恢复原位。这样做的目的是显而易见的,因为栈既是传送数据的缓冲区,同时又是返回地址的根据地。在多重的函数调用过程中,如果不对栈指针进行恢复,程序必定会因为返回地址的错乱而跑飞的。