操作系统构建Part4:实现初版printf模块

4.1 前言

        上一章阐述了必须在机器模式下执行的初始化操作,并且介绍了dummy-xv6的内核模块初始化是在main函数里执行的。main函数是在CPU的内核模式下运行的,而本章开始,会陆续往main函数里写入内核模块初始化相关的逻辑。
        所有的一切,都要从输入输出开始,而输出则需要更早实现,因为没有输出,我们将无法高效率得获得反馈信息,这将极大降低读者们的调试效率。本章要实现的正是初版的printf模块,这个printf模块没有线程安全,因此本章对应的用例也只是在一个核下运行,笔者将在下一章实现自旋锁,然后再完善printf模块的线程安全机制。彼时,读者就能看到完整的内核级别的printf模块了。
        与IO设备进行通信,需要用到协议,比如UART协议、USB协议等。本章要介绍的是最简单的UART协议,虽然当今市场USB大行其道,但是UART协议还是广泛被使用的,本章首先会着重介绍UART协议的原理,像这种在内核实现的与输入输出设备交互的模块,也被称为驱动。

4.2 UART协议解析

4.2.1 UART协议简介

        UART是Universal Asynchronous Receiver/Transmitter的缩写,意为通用异步收发器[1]。它是一种串行通信协议,与并行通信协议不同,使用串行通信协议的设备往往只需要两根物理线来实现全双工通信,一根用于发送数据,另一根用于接收数据。不过这也没有严格限制,计算机与设备之间的通信也可以完全只用一根物理线来实现半双工通信,在这种情况下,这根物理线只能在一定时间内进行数据写入或者接收操作。本章采用的则是模拟半双工的UART通信,因此很多寄存器其实是共用的,只是根据当前处于读或者写模式来决定它们的用途。图1展示了串行通信(Serial Communication)和并行通信的(Parallel Communication)区别。
image图1

4.2.2 UART的传输原理

        在UART协议中,发送端要向接收端发送数据,首先要向THR寄存器(Transmit Holding Register,如图1中TX模块,就是THR寄存器)写入一字节的数据,然后硬件设备将THR寄存器的数据,组装成数据帧,然后一个比特一个比特传输给接收端,接收端收齐一字节的数据之后,会将结果缓存到RHR寄存器中(Read Holding Register)中。
        已知UART是串行协议,那么THR寄存器中的数据又是如何一个比特一个比特传输给接收端呢?由于使用的是二进制信息来进行通信,因此传输信号只要两个值用来区分即可,一个是0,一个是1。在UART协议中,在传输电路中施加相对的高电压(比如5V)表示信号1,施加相对的低电压(比如1V)表示0,接收端根据高低电平来判断当前接收的信号是0还是1。读者们应该都知道,二进制数据可以组成任何信息,它们表示什么,取决于解读它们的方式。那么现在问题来了,如果接收端只知道当前接收的是0和1的数据,那么它如何能知道接收数据具体内容是什么?如果发送端连续发送了若干个字节信号过来,接收端如何精确识别出这些字节信息?
        为了解决这个问题,在使用UART协议通信的设备中,一般来说在没有传输数据时,发送端会一直向接收端发送高电平信号,也就是说接收端会持续收到信号1。接收端要感知到有信号到来,首先要收到一个低电平信号–start信号,这个低电平信号会持续非常短的时间,这样信号能够完成传输,并且被接收端感应到。接收端发现电平信号改变,就知道有数据要到来,并做好接收后续比特信号的准备。紧接着,跟随start信号的数据信号就会一个比特,一个比特发送过来。接收端收到一连串的数据信号后,会将其整理记录在RHR寄存器中。现在还有一个问题,接收端怎么知道数据信号一共有多少?什么时候结束?这里折射出一个问题,信号传输协议,需要对传输数据单位进行定义,否则连续的信号传输,接收端很容易变得非常混乱,无法识别接收到的信息。为了解决这个问题,数据帧的概念被提了出来。
        所谓协议,就是收发双端都遵循相同的规则,否则信息交换就无法实现。数据帧规范就是UART协议的重要组成部分。如图1所示的Serial Communication传输示意图中,它就展示了完整的UART数据帧格式。它这里传输了一个字节的数据,一个UART数据帧包含了4个部分,首先是表示数据开始的start信号,它占用1比特。尔后是一连串的数据信号,数据部分的比特长度的方位是5到8位,通过设置LCR(Line Control Register)寄存器来实现(后文会详细介绍)。接下来是1比特的奇偶校验位,用来初步判断传输的信号是否出现比特翻转,如果出现校验结果和校验位不一致,那么接收端则直接放弃这个数据帧。最后就是1到1.5位的数据帧停止比特信号。
        接收端在收到stop比特之后,代表一个数据帧的结束,stop比特是高电平信号,与默认的高电平一致,这样下一个数据帧到达时,重新收到start比特的低电平信号时,接收端就知道又有新的数据传输过来了。

4.2.3 UART协议的硬件支持

        UART协议可以在全双工的设备上运行,也可以在半双工的设备上运行。dummyxv6是运行在半双工设备上,因此发送端和接收端的寄存器共用同一个,图2展示了dummyxv6所使用的UART寄存器信息[2]。除了MSR(Modem Status Register)、SRR(Scratched Register Read)、SRW(Scratched Register Write)和MCR(Modem Control Register)不使用以外,其他寄存器dummyxv6均有使用。图2中展示的寄存器,均是8位寄存器。这些寄存器都是内存映射的,A0、A1和A2是它们的二进制编码,寻找这些寄存器的内存映射地址很简单,只要找到UART基地址,然后用这个基地址加上寄存器编码的十进制值就是其地址,在dummyxv6项目中,运行在qemu上的UART基地址是0x10000000。由于dummyxv6跑在半双工的虚拟设备上,因此很多寄存器是共用的,比如THR和RHR就是通过读写模式来区分,实际上它们的映射地址是一样的,其他寄存器以此类推,全在图2中有所展示。
image图2
        上一节已经基本把UART通信的基本原理讲清楚了,但是这里面还有很多细节还没讨论过,比方说什么时候可以读RHR寄存器,什么时候可以写THR寄存器?写的频率是多少?一个数据帧的长度怎么设置?奇偶位怎么设置?停止比特位长度怎么设置?等等。这些细节都需要通过图2中展示的寄存器来协调处理。
        首先要解答的是第一个问题,内核什么时候写入THR?什么时候从RHR读取?图2中有一个被称为LSR的寄存器,也就是Line Status Register,观察一下图3,LSR和其他寄存器一样都是8个比特位。这个寄存器的作用是呈现收发数据的状态用的。
image图3
通过图3可知,LSR寄存器的BIT0为1时,表示RHR寄存器的数据可以被读取,而当LSR的BIT0为0时,表示当前还没有数据可以被读取。此外LSR寄存器的BIT5为1时,表示THR寄存器可以被写入,当其为BIT0时,表示当前写入繁忙,THR不可被写入。其他BIT位,有的是判断奇偶校验是否失败的,有的用来判断数据帧是否出现收发错误的,有的是判断是否出现过载的。不过这些比特位在dummyxv6中暂不使用。
        接下来要回答的第二个关键问题是,读写的频率怎么设置?衡量UART设备信息吞吐量的是波特率,它表示一秒钟能够通过UART传输的码元数,当一个码元代表一个比特时,它表示每秒能够传输的比特数位。设置这些需要用到LCR寄存器,观察一下图3关于LCR寄存器的比特位设置,dummyxv6设置比特率的操作如下所示:

  • 设置LCR寄存器的BIT7为1,进入设置波特率的模式。
  • 波特率的具体值,是通过两个8位寄存器来进行,它们分别是LSB of Divisor Latch(又称DLL)和MSB of Divisor Latch(又称DLH)。图4展示了,通过往DLL和DLH写入不同的值,可以得到的不同的波特率。通过将图4中所列的对应值,分别设置DLL(Divisor Latch Low)为0x03,设置DLH(Divisor Latch High)为0x00。此时波特率为38.4K。
  • 设置LCR寄存器的BIT7位0,退出波特率设置模式。

image图4
        接下来要解答的第三个问题就是,数据帧格式是怎么确定的,这里需要确定几个具体内容,第一个是数据帧中,数据部分长度,是否设置奇偶位,以及stop比特的长度设置。这些部分仍是在LCR寄存器里设置。LCR寄存器的BIT0和BIT0是用来设置数据部分长度的,它们的值和数据长度的关系如下所示:

BIT-1   BIT-0  WORD LENGTH
  0       0         5
  0       1         6
  1       0         7
  1       1         8

数据帧中stop比特的长度由LCR寄存器的BIT2来决定,其值和对应的stop比特关系如下所示:

BIT-2  WORD LENGTH STOP BITS(s)
  0       5,6,7,8      1
  1       5            1.5
  1       6,7,8        2

LCR寄存器中的BIT3、BIT4和BIT5是和奇偶校验位相关的。当BIT3设置位0时,数据帧不使用奇偶校验位。当BIT3为1时,数据帧使用奇偶校验位。当奇偶校验位要被使用时,BIT4为0时,奇偶校验位为1表示,数据帧中的数据部分有奇数个1。当BIT4为1时,奇偶校验位为1表示,数据帧中的数据部分有偶数个1。
        到这里为止,UART数据帧的格式定义,传输效率怎么设置的问题就基本讨论完了,不过为了数据帧能够按照先到先处理的原则进行,那么就要通过FCR(FIFO control Register)寄存器来进行FIFO模式设置了。回顾一下图3,FCR寄存器的BIT0位就是用来设置是否允许使用FIFO模式的,然后BIT1用来设置是否允许FIFO读取数据重置的。设置FIFO模式是非常关键的,它很大程度上避免了,缓存中的数据迅速被新到的数据覆写。

4.3 UART模块设计与实现

        在完成了UART协议基本原理的论述之后,现在来看一下dummyxv6中的UART模块。在kernel目录下新增了uart.c文件,UART相关的逻辑基本都在这个文件里。UART模块目前就只实现了初始化逻辑,以及同步写入逻辑,其代码如下所示:

1  // kernel/uart.c
2  #include "common.h"
3 
4  #define Reg(x) ((volatile unsigned char*)(UART0 + (x)))
5 
6  // see http://byterunner.com/16550.html
7  // For read mode
8  #define RHR 0 // Receiver Holding Register
9  #define ISR 2 // Interrupt Status Register
10 #define LSR 5 // Line Status Register
11 
12 // For write mode
13 #define THR 0 // Transmitter Holding Register
14 #define DLL 0 // Divisor Latch (Low)
15 #define IER 1 // Interrupt Enable Register
16 #define DLM 1 // Divisor Latch (High)
17 #define FCR 2 // FIFO Control Register
18 #define LCR 3 // Line Control Register
19 
20 #define LCR_ENABLE_DLAB 0x80
21 #define LCR_8BIT 0x03
22 #define FCR_ENABLE_FIFO 0x01
23 #define FCR_ENABLE_CLEAR 0x06
24 #define IER_ENABLE_RX 0x01
25 #define IER_ENABLE_TX 0x02
26 #define LSR_TX_IDLE 0x20
27 #define LSR_RX_READY 0x01
28 
29 #define ReadReg(x) (*Reg(x))
30 #define WriteReg(r, x) (*(Reg(r)) = (x))
31 
32 void uartinit(void) {
33     // disable interrupts
34     WriteReg(IER, 0x00);
35 
36     // set DLAB to access DLL and DLM
37     WriteReg(LCR, LCR_ENABLE_DLAB);
38     // set divisor to 3 (38.4K baud)
39     WriteReg(DLL, 0x03);
40     WriteReg(DLM, 0x00);
41     // 8 data bits, 1 stop bit, no parity
42     WriteReg(LCR, LCR_8BIT);
43 
44     // enable FIFO
45     WriteReg(FCR, FCR_ENABLE_FIFO | FCR_ENABLE_CLEAR);
46 
47     // enable receive and transmit interrupts
48     WriteReg(IER, IER_ENABLE_RX | IER_ENABLE_TX);
49 }
50 
51 void uartputc_sync(char c) {
52     while ((ReadReg(LSR) & LSR_TX_IDLE) == 0);
53     WriteReg(THR, c);
54 }

        代码中第32行到第49行是UART模块的初始化逻辑,在uartinit函数被调用之后,首先执行的是上面代码的第34行,首先禁止掉所有的中断响应,避免在UART模块初始化时被打断。
        接着执行第37行代码,这行代码就是设置LCR寄存器的BIT7,回顾一下图3,BIT7是设置是否开启波特率设置的比特位。接下来执行设置波特率的逻辑是第39行和第40行,根据图4,将DLL设置为0x03,且将DLH设置为0x00,则是将波特率设置为38.4K每秒。完成波特率设置之后,第42行代码将LCR寄存器的最后两位设置为1,同时清理掉其他比特位,也就是同时也退出了波特率设置模式(LCR_8BIT的二进制值是00000011,所以是将LCR寄存器最低两位设置为1,其他位清空)。
        第45行代码,设置了FCR寄存器的最后3个比特位为1,也就是允许FIFO模式,并且允许清空收发缓存。
        第48行代码,则恢复了读和写的中断响应使能位。
        第51行到第54行实现的是同步写入一个字节的信息到THR寄存器,它的逻辑很简单,就是判断LSR寄存器是否可写入,如果不能则一直处于死循环,否则直接写入到THR寄存器。

4.4 console模块设计与实现

        目前console模块实现的内容很少,主要还是转调UART模块的api,console模块初始化阶段会调用UART模块的初始化函数,console模块的输出函数,会调用UART模块的输出函数。

// kernel/console.c
#include "common.h"
#include "console.h"

void consoleinit(void) {
    uartinit();
}

void consoleputc(char c) {
    uartputc_sync(c);
}

4.5 printf模块设计与实现

        consoleputc函数,最终是由printf函数来转调,printf函数输出的字符,最终是经过console模块,最后转调UART模块的接口,最后将字符打印到目标设备上的。printf模块位于kernel/printf.h|c中。由于代码比较多,且逻辑比较简单,读者可以找到第四章随文工程的代码,并查阅对应的模块。

4.6 初始化console模块

        本章在kernel/main.c的main函数中,添加了初始化console模块的逻辑,并添加了测试用例,代码如下所示:

// kernel/main.c
1  #include "common.h"
2  #include "printf.h"
3  #include "param.h"
4  #include "riscv.h"
5 
6  void main() {
7     if (cpuid() == 0) {
8         uartinit();
9         printf("dummy-xv6:booting!\n");
10         printf("cpu0:hello world!\n");
11 
12         printf("----test printf begin----\n");
13         printf("char: %c\n", 'a');
14         printf("string: %s\n", "hello world");
15 
16         printf("decimal: %d\n", 123);
17         printf("long: %ld\n", 1234567890);
18         printf("long long: %lld\n", 1234567890123456789);
19 
20         printf("hex: %x\n", 0x123);
21         printf("long hex: %lx\n", 0x1234567890);
22         printf("long long hex: %llx\n", 0x1234567890123456);
23 
24         printf("unsigned: %u\n", 123);
25         printf("long unsigned: %lu\n", 1234567890);
26         printf("long long unsigned: %llu\n", 1234567890123456789);
27 
28         printf("pointer: %p\n", (void*)0x12345678);
29 
30         printf("%%\n");
31         printf("\taaa\n");
32         printf("\abbb\n");
33         printf("----test printf end----\n");
34 
35         printf("test lld: %lld\n test x:%x\n", 1234, 0x1234);
36     } 
37     else {
38 
39     }
40 }

由于UART模块是全局实用的,所有的CPU核心都可能调用到它,所以它只能初始化一次,dummyxv6将它的初始化逻辑,放在hart0上运行。上述代码中的printf函数里的内容,最终会打印到屏幕上,图5展示了这样一个流程。图中展示了本章kernel经过编译后的可执行文件布局,其中printf函数内的字符串会被保存在.rodata区,kernel内的函数逻辑指令会被保存在.text区,被调用的printf函数也是放在这里,运行printf函数过程中,当LSR表示THR寄存器可写时,printf函数会不断从.rodata区取出字符,写入到THR寄存器中。对端判定可读取RHR寄存器时,会从RHR寄存器中取出信息,并显示在设备上。
image图5

4.6 结束语

        本章主要是介绍最初版的printf模块是如何设计和实现的,最开始介绍了UART相关的内容,包括UART协议的基本概念,UART协议的基本运作原理,UART协议的设置与初始化。而后介绍了console模块和printf模块的具体实现,通过代码展示了console模块是如何与UART模块关联起来的,最后通过一个简单的例子展示了本章kernel的布局与运行printf函数的流程。

Reference

[1] 最详细的UART通讯协议分析在这里
[2] TECHNICAL DATA ON 16550