阅读printftm.c
打开printfmt.c
一来是非常蒙的,啥也看不懂,不过可以锁定主要的函数逻辑在vprintfmt
函数里面
可以看到vprintfmt
函数的参数
void vprintfmt(void (*putch)(int, void *), void *putdat, const char *fmt, va_list ap)
对于很久没碰c语言的我来说,这个逻辑着实还是有点绕,不过捋一下还是能懂一点的
void (*putch)(int, void *)
这里应该是putch函数的函数指针
void *putdat
是一个指向任意类型数据的指针
const char *fmt
这里应该对应是就是printf("Hello %s", "world") 中的字符串 "Hello %s"
ap有点没看懂是什么东西
我现在就是大概捋一下这里的逻辑
putch函数
首先就是 putch 这个函数,一般来说就是直接把字符输出,然后指向下一个字符,直到读取到%
while ((ch = *(unsigned char *)fmt++) != '%')
{
if (ch == '\0')
return;
putch(ch, putdat);
}
那么它具体是怎么实现的呢
经过一番寻找之后发现它的代码在printf.c
文件中
c层面的实现
putch
static void
putch(int ch, int *cnt)
{
cputchar(ch);
*cnt++;
}
似乎也没有什么价值,继续往里面看
cputchar
已经到达了console.c
文件了
// `High'-level console I/O. Used by readline and cprintf.
void
cputchar(int c)
{
cons_putc(c);
}
cons_putc
继续
// output a character to the console
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c);
cga_putc(c);
}
这个时候其实已经能看到它这边注释就是说往控制台输出一个字符,不过我很好奇是怎么做到了,再看看
汇编实现
serial_putc
static void
serial_putc(int c)
{
int i;
for (i = 0;
!(inb(COM1 + COM_LSR) & COM_LSR_TXRDY) && i < 12800;
i++)
delay();
outb(COM1 + COM_TX, c);
}
我们首先把下面的几个小东西给仔细看看再分析
inb
static inline uint8_t
inb(int port)
{
uint8_t data;
asm volatile("inb %w1,%0" : "=a" (data) : "d" (port));
return data;
}
-
static inline
:表示这是一个内联函数,编译器会尝试将其内联 到调用点,以减少函数调用的开销。 -
asm volatile
:asm
关键字用于内联汇编,volatile
关键字告诉编译器不要优化这段汇编代码,因为它可能具有副作用。 -
"inb %w1,%0"
:这是汇编指令模板。
inb
:汇编指令,用于从指定的 I/O 端口读取一个字节。- b代表
byte
。 %w1
:表示第一个输入操作数port
,w
表示使用 16 位寄存器。%0
:表示第一个输出操作数data
。
-
: "=a" (data)
:输出操作数部分。
-
=
:表示这是一个写操作。 -
a
:表示使用eax
寄存器。 -
data
:表示将eax
寄存器的值存储到变量data
中。
-
-
: "d" (port)
:输入操作数部分。
d
:表示将输入值存储在edx
寄存器中。port
:表示将port
变量的值存储到edx
寄存器中。
所以大概可以知到,我们首先是从指定的port中读取某个端口的值,然后存到data里面,再return回去。
那么为什么要去读取这个值?有什么意义吗?我们难道不是在做输出吗?
看
/***** Serial I/O code *****/
#define COM1 0x3F8
#define COM_RX 0 // In: Receive buffer (DLAB=0)
#define COM_TX 0 // Out: Transmit buffer (DLAB=0)
#define COM_DLL 0 // Out: Divisor Latch Low (DLAB=1)
#define COM_DLM 1 // Out: Divisor Latch High (DLAB=1)
#define COM_IER 1 // Out: Interrupt Enable Register
#define COM_IER_RDI 0x01 // Enable receiver data interrupt
#define COM_IIR 2 // In: Interrupt ID Register
#define COM_FCR 2 // Out: FIFO Control Register
#define COM_LCR 3 // Out: Line Control Register
#define COM_LCR_DLAB 0x80 // Divisor latch access bit
#define COM_LCR_WLEN8 0x03 // Wordlength: 8 bits
#define COM_MCR 4 // Out: Modem Control Register
#define COM_MCR_RTS 0x02 // RTS complement
#define COM_MCR_DTR 0x01 // DTR complement
#define COM_MCR_OUT2 0x08 // Out2 complement
#define COM_LSR 5 // In: Line Status Register
#define COM_LSR_DATA 0x01 // Data available
#define COM_LSR_TXRDY 0x20 // Transmit buffer avail
#define COM_LSR_TSRE 0x40 // Transmitter off
这个表,可以看出COM1这个串行端口的基地址是0x3F8
,下面的应该是串口的“寄存器”的偏移量(我也不是很懂这个,暂时称之为寄存器吧),而我研究了一会,两个下划线的,也就是0x开头的应该都是一些状态码,是那些“寄存器”里面数据,不同的数据代表着不同的状态。
所以COM1 + COM_LSR
的地址就是就是line Status Register
看字面意思似乎是 线路状态寄存器?搜了一下,发现是行状态寄存器。
那么我们将它的状态码对应的状态翻译一下
#define COM_LSR_DATA 0x01 // 数据可用 处于这个状态时表示接收器有可用数据,即有数据已经从串行线接收并可以读取。
#define COM_LSR_TXRDY 0x20 // 传输缓冲区可用 处于这个状态时表示发送缓冲区可用,即可以开始传输新数据,之前的数据已经发送完毕
#define COM_LSR_TSRE 0x40 // 传输器关闭 处于这个状态时表示发送器空闲,所有数据已传输完毕,且没有待发送的数据。
很直白了,还记得我们的目的吗?就是要发送数据,所以我们要检查缓冲区是否可以写入数据。所以我们要判断是否可以写入才能继续写入,所以这里使用了这段语句
!(inb(COM1 + COM_LSR) & COM_LSR_TXRDY)
,这又是什么意思?
首先,我们先将这几个值的二进制写出来
COM_LSR_DATA 0000 0001
COM_LSR_TXRDY 0010 0000
COM_LSR_TSRE 0100 0000
inb得到的值只能是这三种,所以,任意一个值与其他两个值按位与得到的值都是0。
所以上面的语句逻辑就很清楚了,读取COM1串口的行状态,如果缓冲区可用,那么就是1,如果不可用,就是0。
接下来看到for循环
for (i = 0;
!(inb(COM1 + COM_LSR) & COM_LSR_TXRDY) && i < 12800;
i++)
delay();
可以看到这里对i的最大值也做了限制。
delay
那么接下来就可以看一下delay()
函数
// Stupid I/O delay routine necessitated by historical PC design flaws
static void
delay(void)
{
inb(0x84);
inb(0x84);
inb(0x84);
inb(0x84);
}
可以看到,delay的实现方式非常奇怪,让人看不太懂,为什么加了一堆inb?
看到上面的注释,翻译过来是"由于历史上 PC 设计缺陷而需要的愚蠢的 I/O 延迟例程",有点好笑了哈哈哈哈。
看起来应该是用串口的通信来"拖延"时间。
outb
接下来久看到最后一行,outb吧
outb(COM1 + COM_TX, c);
static inline void
outb(int port, uint8_t data)
{
asm volatile("outb %0,%w1" : : "a" (data), "d" (port));
}
读懂了刚刚汇编代码,现在对于这个代码已经是很容易读懂的程度了,只需要稍微了解一下outb是干什么的就行
-
outb
:表示将一个字节的数据从寄存器传送到 I/O 端口。- 同理
b
代表byte
。
- 同理
-
%0
:第一个输入操作数,它对应于data
,即我们最开始输入的c
,也就是要发送的数据。 -
%w1
:第二个输入操作数(w
代表 word,即 16 位)。它对应于port
,也就是要输出数据的 I/O 端口号。
这时我突然想到一个盲点,我使用printf
函数,我不是只需要在屏幕上显示我要输出的字符就可以了,为什么要向COM1
发送?
我翻了半天互联网,得到的结果也不准确,我个人觉得最可信的结果应该是
为了调试
在qemu中COM1端口常用于输出调试信息,这对于内核开发和调试非常有用。
lpt_putc
好的我们来到下一个环节
/***** Parallel port output code *****/
// For information on PC parallel port programming, see the class References
// page.
static void
lpt_putc(int c)
{
int i;
for (i = 0; !(inb(0x378+1) & 0x80) && i < 12800; i++)
delay();
outb(0x378+0, c);
outb(0x378+2, 0x08|0x04|0x01);
outb(0x378+2, 0x08);
}
结合注释以及之前的经验,可以直到,这个应该是和刚刚的串行输出类似的,只不过这里是并行输出。
有了之前的经验可以看出,这里的for循环应该是同样的检测是否可以发送,然后下面的out我就不是很看得懂了。
不过应该也是对并行端口就行输出。
似乎学习s6.828的重点不在这边,那么我们了解个大概就跳过吧。
cga_putc
static void
cga_putc(int c)
{
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;
switch (c & 0xff) {
case '\b':
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;
case '\n':
crt_pos += CRT_COLS;
/* fallthru */
case '\r':
crt_pos -= (crt_pos % CRT_COLS);
break;
case '\t':
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;
default:
crt_buf[crt_pos++] = c; /* write the character */
break;
}
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
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);
}
这有点抽象了,说实话,很容易看懂是和字符输出相关,但是具体的实现一看又很蒙,我们先从头开始分析吧。
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;if (!(c & ~0xFF))
c |= 0x0700;
首先看到注释if no attribute given, then use black on white
这里的翻译背了大锅,因为一般的翻译软件翻译black on white
,居然是白底黑字的意思,但是终端不都是黑色的吗,黑底白字啊。
最后问了AI,得到了应该是比较正确的一个解答
如果没有指定属性,则将其属性设置为黑底白字,因为在当前代码中,
0x0700
表示黑底白字。
好,继续看这里的代码。
因为这里的cga_putc不止是用来输出普通的白色字符的,实际上它还可以输出==彩色==的字符。当然,我们的printf只支持普通的字符。
那么它是如何做到的呢?
首先我们注意,这里的c并不是字符型,而是int型,也就是说,它实际上是4个字节的长度,而char只占1个字节,那么它的默认就是 0000 00**
。
让我们看到后面四位 00**
其中00
这两位是你的字符颜色属性。其中前一位数字代表背景色,后一位数字代表前景色(即字体颜色)
所以这段代码就能看懂了,首先是判断c的前两位是不是都是0(即未设置颜色)
然后如果未设置颜色,则默认设置为黑底白字。
之后的代码
switch (c & 0xff) {
case '\b':
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;
case '\n':
crt_pos += CRT_COLS;
/* fallthru */
case '\r':
crt_pos -= (crt_pos % CRT_COLS);
break;
case '\t':
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;
default:
crt_buf[crt_pos++] = c; /* write the character */
break;
}
首先是检测c是不是空的,如果不是空的,则继续执行下面的switch,我们直接看到正常字符这里,看懂了这里,其他的也就都不用看了,都懂了。
default:
crt_buf[crt_pos++] = c; /* write the character */
break;
其中crt_pos
是当前crt_position,即当前光标的位置。
crt_buf
则是字符缓冲区。
但是我的第一个疑惑来了,这不对啊,之前的串行输出的时候,都会有一个端口什么的来接受我的信息,但是我翻遍了整个函数,也没看见crt_buf传参的过程。
但是实际上这里暗藏玄机。注意看:
static unsigned addr_6845;
static uint16_t *crt_buf;
static uint16_t crt_pos;
实际上crt_buf是一个指针,那么它到底指哪去了,这个时候就要看到这里的代码了:
static void
cga_init(void)
{
volatile uint16_t *cp;
uint16_t was;
unsigned pos;
cp = (uint16_t*) (KERNBASE + CGA_BUF);
was = *cp;
*cp = (uint16_t) 0xA55A;
if (*cp != 0xA55A) {
cp = (uint16_t*) (KERNBASE + MONO_BUF);
addr_6845 = MONO_BASE;
} else {
*cp = was;
addr_6845 = CGA_BASE;
}
/* Extract cursor location */
outb(addr_6845, 14);
pos = inb(addr_6845 + 1) << 8;
outb(addr_6845, 15);
pos |= inb(addr_6845 + 1);
crt_buf = (uint16_t*) cp;
crt_pos = pos;
}
cga_init
函数,即cga初始化函数,那么我们就看看在初始化的过程种,crt_buf
到底指哪去了吧。
crt_buf = (uint16_t*) cp;
可以看到,这里crt_buf
被赋予了cp
指向的地址。
看到关键代码和宏定义
在看几个关键的宏定义吧
// All physical memory mapped at this address
#define KERNBASE 0xF0000000 //memlayout.h
#define MONO_BASE 0x3B4 //console.h
#define MONO_BUF 0xB0000
#define CGA_BASE 0x3D4
#define CGA_BUF 0xB8000
cp = (uint16_t*) (KERNBASE + CGA_BUF);
was = *cp;
*cp = (uint16_t) 0xA55A;
if (*cp != 0xA55A) {
cp = (uint16_t*) (KERNBASE + MONO_BUF);
addr_6845 = MONO_BASE;
} else {
*cp = was;
addr_6845 = CGA_BASE;
}
这里我有点不懂,前脚刚给*cp
赋值,后脚判断它会不会等于原来的值???我对这里表示疑惑。
在Stack Overflow
中我找到了一个回答:
这段代码测试
cp
是否是一个可写地址,以确定模式应该是CGA还是MONO。如果写入操作失败,那么我们不能使用这部分内存。要写入的内容(垃圾数据)应该是任意的。就我所知,它没有特殊意义。在此之前,程序会将
cp
处的值保存到was
中,这样垃圾数据就不会污染内存。链接:c - What does 0xa55a mean in cga_init() in xv6 source code? - Stack Overflow
看起来它应该就是一个防止错误的代码吧。
那么我们只看else就行了,最后cp
指向了0xA55A
地址。
addr_6845
的值也变成了CGA_BASE
对应的地址0x3D4
。
接下来就可以看到我们下面的代码了:
/* Extract cursor location */
outb(addr_6845, 14);
pos = inb(addr_6845 + 1) << 8;
outb(addr_6845, 15);
pos |= inb(addr_6845 + 1);
crt_buf = (uint16_t*) cp;
crt_pos = pos;
看到注释,Extract cursor location
,即提取光标位置。
首先是向addr_6845
端口,发送了一个14
,选择6845 CRT控制器的光标位置高字节寄存器。
然后从addr_6845 + 1
端口读取一个字节,并将其左移8位,存储在pos
变量中。这样做是为了将读取的高字节放在pos
的高8位。
然后向addr_6845
端口发送15
,选择6845 CRT控制器的光标位置低字节寄存器。
然后从addr_6845 + 1
端口读取一个字节,并将其与pos
变量进行按位或操作。这样做是为了将读取的低字节放在pos
的低8位,从而得到完整的光标位置。
为什么光标的位置需要这么多字节来存储?
这就需要看到这里了
#define CRT_ROWS 25
#define CRT_COLS 80
#define CRT_SIZE (CRT_ROWS * CRT_COLS)
可以看到,CRT_SIZE
即每个屏幕最大显示的字符数量其实是2000,那么一个字节只能存储256种情况,远远不够,所以就需要两个字节来存储。
为什么需要这么大费周章的读两次呢?
因为这个CGA显示器的寄存器只能存储一个字节。(硬件写死的)
那么之后的我们就可以理解了,因为CGA显示器实际上是会主动轮询这个固定的地址,所以我们只需要往这个地址输入东西,显示器自己就会把这个显示出来。
接下来看到
if (crt_pos >= CRT_SIZE)
{
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;
}
很容易看出来,条件是当光标的位置达到屏幕尺寸极限。
让我们看到memmove
。
// Basic string routines. Not hardware optimized, but not shabby.
#include <inc/string.h>
// Using assembly for memset/memmove
// makes some difference on real hardware,
// but it makes an even bigger difference on bochs.
// Primespipe runs 3x faster this way.
#define ASM 1
翻译一下
// 基本的字符串操作函数。虽然没有进行硬件优化,但性能还不错。
#include <inc/string.h>
// 使用汇编实现的memset/memmove
// 在真实硬件上有一定的性能提升,
// 但在Bochs模拟器上提升更为显著。
// 这样Primespipe的运行速度提高了3倍。
#define ASM 1
啊?memmove
在string.c
中被定义了两次?
#if ASM
//...
void *
memmove(void *dst, const void *src, size_t n)
{
//...
}
#else
//...
void *
memmove(void *dst, const void *src, size_t n)
{
//...
}
#endif
看起来是函数级别的if else
,还是第一次见,如果ASM
为true
,则使用上面的代码,否则使用下面的代码。
所以证明他们的功能应该是一样的。
只不过为了在模拟器中运行更快,所以用汇编写了更快的。
那我们就直接分析汇编版本的memmove
吧。
void *
memmove(void *dst, const void *src, size_t n)
{
const char *s;
char *d;
s = src;
d = dst;
if (s < d && s + n > d)
{
s += n;
d += n;
if ((int)s % 4 == 0 && (int)d % 4 == 0 && n % 4 == 0)
asm volatile("std; rep movsl\n" ::"D"(d - 4), "S"(s - 4), "c"(n / 4) : "cc", "memory");
else
asm volatile("std; rep movsb\n" ::"D"(d - 1), "S"(s - 1), "c"(n) : "cc", "memory");
// Some versions of GCC rely on DF being clear
asm volatile("cld" ::: "cc");
}
else
{
if ((int)s % 4 == 0 && (int)d % 4 == 0 && n % 4 == 0)
asm volatile("cld; rep movsl\n" ::"D"(d), "S"(s), "c"(n / 4) : "cc", "memory");
else
asm volatile("cld; rep movsb\n" ::"D"(d), "S"(s), "c"(n) : "cc", "memory");
}
return dst;
}
其实不用太细看,大概也能看出来是将src
的n个内存移动到dst
中。
然后根据数据的大小来使用不同的movs
指令来达到最高效率。
回到我们这里来。
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);
有了刚刚的经验,看这边就能看懂其大概的思路了,就是更新目前光标的位置,达到每输入一个字符,光标都跟着移动。
那么我们算是已经懂了在底层,操作系统是如何输出字符的了。
发表评论