笔记03.4 - Lab 1:控制台输出函数

以下是 JOS 内核控制台输出代码关于 kern/printf.c、 lib/printfmt.c、 kern/console.c 的解析。摘抄自【系统的启动和初始化】一文。

C 语言中解决变参问题的一组宏

1) 用 va_list 可以定义一个 va_list 型的变量,这个变量是指向参数的指针。
2) 用 va_start 宏可以初始化一个 va_list 变量,这个宏有两个参数,第一个是 va_list 变量本身,第二个是可变的参数的前一个参数,是一个固定的参数。
3) 用 va_arg 宏可以返回可变的参数,这个宏也有两个参数,第一个是 va_list 变量,即指向参数的指针,第二个是返回的参数类型。
4) 用 va_end 宏结束可变参数的获取。

cprintf()函数原型

kern/printf.c 之 cprintf()函数的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
第一个参数 fmt 代表的是显示字符串的指针,诸如 “%d %c”。之后的参数值用来在显示的时候替代 %d、 %c,可以是常数、整形变量、浮点变量、字符、字符串指针。
*/

int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;

/*
定义了 va_list 变量 ap 后马上就用 va_start 宏对其进行了初始化,用 va_start 宏进行初始化的时候第二个参数是 fmt,也就是 cprintf 函数的第一个参数,即可变参数之前的固定参数,于是这时 ap 便指向了后面的可变参数。函数的参数实际上都是存放在内存的堆栈中的,而且参数会按照先后顺序依次存放,靠前的参数会存放在较低的地址处,其中每个参数会根据其类型被分配相应大小的空间。于是 ap 在这个时候便指向了可变参数 1 的存放地址,这样我们就可以用 va_arg 宏依次读取之后的可变参数。
*/

va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);

return cnt;
}

vcprintf函数原型

kern/printf.c 之 cprintf 函数所调用的 vcprintf 函数的原型

1
2
3
4
5
6
7
8
9
10
11
12
int
vcprintf(const char *fmt, va_list ap)
{
int cnt = 0;

/*
第一个参数实际上是一个函数指针, putch 函数被当做成了一个参数, putch 函数的功能是输出一个字符在屏幕上
*/

vprintfmt((void*)putch, &cnt, fmt, ap);
return cnt;
}

putch函数原型

kern/printf.c 之 putch 函数的原型

1
2
3
4
5
6
7
8
9
10
/*
整形变量 ch 代表的是要输出的字符,因为 int 的变量是 32 位的,而一个字符的 ASCII 码只需要有 8 位,所以实际上 32 位整形变量的低八位代表的是字符的 ASCII 码,而第 8 位到 15 位代表的是输出字符的格式,因此 int 变量的高 16 位实际上没有用的;而 cnt 指针指向一个整形变量,这个整形变量每当用 putch 函数输出一个字符后就加 1。
*/

static void
putch(int ch, int *cnt)
{
cputchar(ch); /*见 kern/console.c */
*cnt++;
}

cputchar函数原型

kern/console.c 之 cputchar 函数的原型

1
2
3
4
5
void
cputchar(int c)
{
cons_putc(c);
}

cons_putc函数原型

kern/console.c 之 cons_putc 函数的原型

1
2
3
4
5
6
7
8
/*将一个字符输出到控制台*/
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c); /*进行一些硬件初始化的工作*/
cga_putc(c);
}

下图表示的是整形参数的 8 到 15 位是如何确定字符输出的格式的:

可以看到高 4 位决定了字符的背景色,可以有 16 种颜色,同样低四位则决定了字符本
身的颜色,在这里, R 代表红色的色素, G 代表绿色的色素, B 代表蓝色的色素,这三个色素的组合就可以组成 8 种不同的颜色,而 I 则表示颜色是否是高亮的,于是这样便可以有 16 种颜色。
下图表示的是显存与显示屏的对应关系:

cga_putc函数原型

kern/console.c 之 cga_putc 函数的原型

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
static void
cga_putc(int c)
{

/*
整形参数 c 的低 8 位是字符的 ASCII 码,而 8 到 15 位则是字符输出的格式,函数首先判断字符的格式有没有事先设定,如果没有,即 8 到 15 位皆为 0,则系统将会将这个字符的设定为默认格式。
*/
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;

/*
crt_buf 是一个指向 16 位无符号整形数的静态指针,它实际上指向的是内存中
物理地址为 0xb8000 的位置,(物理内存的 0xa0000 到 0xc0000 这 128KB 的空间是留给 VGA 显示缓存的,实际上在我们的试验中从 0xb8000 这个位置开始的一部分内存空间便是可以直接与显示屏相关联的显存)
在本实验中,显示屏规定为 CRT_ROWS = 25 行,每行可以输出 CRT_COLS = 80 个字符,由于每个字符实际上占据显存中的两个字节,于是物理内存中从 0xb8000 到 0xb8fa0 之间的内容都会以字符的形式在屏幕上显示出来。 crt_pos 是一个静态的 16 位无符号整形变量,如果把 crt_buf 指向的内存空间看做 16 位整形数的数组,则 crt_pos 则是数组的下标,在这里它实际上表示的是光标的位置,而 CRT_COLS 则是一个常量,表示一行可以输出的字符数,即为 25。
*/

switch (c & 0xff) {

/*
把 crt_pos 减 1 表示光标向后退一格,并且将光标
当前指向位置对应的显存中的两个字节的值置为(c & ~0xff) | ' ', 即把原来的字符替换为了一个空格,这样便完成了退格的操作。
*/

case '\b': // 表示退格
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;

/*把 crt_pos 加上 25,即将光标的位置换到下一行相同的位置。*/

case '\n': // 表示换行
crt_pos += CRT_COLS;
/* fallthru */

/*将 crt_pos 减去 (crt_pos % CRT_COLS)。*/

case '\r': // 表示光标退到这一行的开头处
crt_pos -= (crt_pos % CRT_COLS);
break;

/*递归的调用 cons_putc 函数连续打印 5 个空格。*/

case '\t': // 光标向前移动 5 格
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;

/*不是以上的这些特殊字符时,程序便将其直接写入显存中,并且将光标的位置加 1,值得注意的是在这里 crt_buf[crt_pos++]表示的是内存中 16 位的空间,然而整形变量 c 是 32 位的,于是在写入内存的时候只取 c 的低 16 位。*/

default: // 往屏幕上打印一个字符
crt_buf[crt_pos++] = c; /* write the character */
break;
}

// What is the purpose of this?

/*
在物理地址超过 0xb8fa0 的内存部分中存储字符数据,此时实际上显示屏就无法显示超过的部分,这个时候通常下显示屏都会滚屏好让最新输出的字符能够显示出来。在每输出一个字符后都先判断 crt_pos 是否大于或等于 CRT_SIZE,而 CRT_SIZE 实际上就是一个屏幕可以输出的字符数,即 80*25。当等于 CRT_SIZE 时,说明此时已经满屏,当大于 CRT_SIZE 时,说明有字符没有显示出来。当满足这两种情况的中的一种时,程序所做的处理是将屏幕上第二行到最后一行的的字符数据复制到第一行到倒数第二行去,然后将屏幕的最后一行输出为空格。
*/
if (crt_pos >= CRT_SIZE) { // CRT_SIZE = (CRT_ROWS * CRT_COLS)
int i;

memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t)); // 执行滚屏操作
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}

/* move that little blinky thing */
outb(addr_6845, 14);
outb(addr_6845 + 1, crt_pos >> 8);
outb(addr_6845, 15);
outb(addr_6845 + 1, crt_pos);
}

vprintfmt函数程序流程图

vcprintf (见 kern/printf.c) 函数所调用的 vprintfmt (见 lib/printfmt.c) 函数的程序流程图

上图 5 个格式变量: padc、 width、 precision、 lflag、 altflag。 padc 代表的是填充字符,在初始化的时候 padc 变量会被初始化为空格符,而当程序在显示字符串的 ’%’ 字符后读到 ’-’ 或者 ’0’ 的字符时便会将 ’-’ 或者 ’0’ 赋值给 padc。 width 代表的是打印的一个字符串或者一个数字在屏幕上所占的宽度,而 precision 则特指一个字符串在屏幕上应显示的长度。当打印字符串的时候, padc = ’-’ 代表着字符串需要左对齐,右边补空格, padc =’ ’ 代表字符串右对齐, 而左边由空格补齐, padc = ’0’ 代表字符串右对齐, 左边由 0 补齐。在我们这个实验中当输出数字时会一律的右对齐,左边补 padc,数字显示长度为数字本身的长度。 lfag 变量则是专门在输出数字的时候起作用,在我们这个实验中为了简单起见实际上是不支持输出浮点数的,于是 vprintfmt 函数只能够支持输出整形数,,输出整形数时,当 lflag = 0 时,表示将参数当做 int 型的来输出,当 lflag = 1 时,表示当做 long 型的来输出,而当 lflag = 2 时表示当做 long long 型的来输出。最后, altflag 变量表示当 altflag = 1 时函数若输出乱码则用 ’?’ 代替。

vprintfmt函数打印字符串

lib/printfmt.c 之 vprintfmt 函数打印一个字符串具体是如何实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case 's':
if ((p = va_arg(ap, char *)) == NULL)
p = "(null)"; /* 当字符串指针为空时,将它指向"(null)"字符串*/
if (width > 0 && padc != '-')
for (width -= strnlen(p, precision); width > 0; width--)
putch(padc, putdat); /*字符串右对齐,左边补相应数量的空格或者 0*/
for (; (ch = *p++) != '\0' && (precision < 0 || --precision >= 0); width--)
if (altflag && (ch < ' ' || ch > '~'))
putch('?', putdat);
else
putch(ch, putdat);/*打印相应长度的字符串*/
for (; width > 0; width--)
putch(' ', putdat); /*当字符串是左对齐的时候打印相应数量的空格*/
break;

当程序识别了显示字符串中 ’%’ 后的 ’s’ 字符后便从可变参数中读入字符串指针,若指针为空,则让它指向一个 “(null)” 字符串。然后再判断输出是左对齐还是右对齐,若 padc = ’-’ , 表示是左对齐, 否则是右对齐。确认是右对齐的话,按照我们之前所讲的, 用 width 减去字符串实际显示长度便得到需要在左边补空格或 0 的个数,注意到 int strnlen(char *str, int maxlen) 函数原型是计算字符串 str 的 (unsigned int 型)长度,不包括结束符NULL,该长度最大为 maxlen , maxlen 传入 -1 的话会返回字符串的长度。在这之后程序便开始打印字符串本身,可以看到若 precision 大于 0 则显示长度等于 precision 与字符串长度之间的最小值, precision 小于 0 则显示长度等于字符串本身的长度(无论 width 多大)。最后程序判断如果字符串是左对齐的话则在右侧剩余空间补充空格。

printnum函数原型

在打印数字的时候则会用到 printnum 这个函数,该函数的主体如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*参数 num 代表需要打印出来的整形数, base 代表整形数的进制,其它的参数和 vprintfmt 函数中代表同样的意思。当 num 超过 1 位时函数递归的调用自己本身,这样便可以先打印高位的数字,当 num 只有 1 位时,程序便首先按照右对齐的格式在左侧打印填充字符,然后打印这个数字。*/

static void
printnum(void (*putch)(int, void*), void *putdat,
unsigned long long num, unsigned base, int width, int padc)
{
// first recursively print all preceding (more significant) digits
if (num >= base) {
printnum(putch, putdat, num / base, base, width - 1, padc);
} else {
// print any needed pad characters before first digit
while (--width > 0)
putch(padc, putdat);
}

// then print this (the least significant) digit
putch("0123456789abcdef"[num % base], putdat);
}

可以看到无论是打印字符串还是数字,都是将它们分解成一个个单个的字符然后用
putch 函数一个一个的打印出来。所以 putch 函数可谓是显示输出的基本函数。

显示 Gitment 评论