

#### Minix3中的I/O

##### 中断处理例程和I/O访问

用户空间的驱动程序需要不同的I/O访问类型，这些都需要系统任务通过使用内核调用来进行支持。

1. 访问它自己数据空间以外的内存。比如内存驱动程序。
2. 读写I/O端口。比如硬盘驱动程序。
3. 响应可预测的中断。比如硬盘驱动程序向硬盘控制器写入命令。
4. 响应不可预测的中断。比如键盘驱动程序。

对于第一种情况，需要系统任务使用Intel处理器的硬件分段机制。通过系统任务，内存驱动程序就可以访问它所需要的内存区域，控制台驱动程序则可访问视频显示适配器(VDA)的内存。

对于第二种情况，则需要系统任务通过内核调用来使用I/O指令。

第三种情况的过程是。当用户空间的程序通过`sys_irqctl`内核调用初始化一个中断，中断处理例程总是`generic_handler`，这一在系统任务里定义的函数。此例程把中断转化为通知消息。内核调用把命令发给控制器，然后设备驱动程序初始化一个`receive`操作。设备驱动程序收到通知后继续做接下来的事。

第四种情况的问题是。比如tty驱动程序，它支持多种设备，所以有多个进程和它交互。所以不能为个单个源的输入，而阻塞`receive`调用。Minix3使用了多种技术来处理此问题。

##### 设备驱动程序

每种I/O设备都对应一个设备驱动程序，这些驱动程序都是完整的进程。一个简单的设备驱动程序会放在单个的源文件里。

磁盘设备组：RAM盘，硬盘，软盘都对应着单一的源文件。而支持块设备的通用例程则放在`driver.c`和`drvlib.c`。`driver.h`用于支持所有的块设备。

终端设备组：内存映射控制台，键盘，串口，伪终端都对应着单一的源文件。硬件无关的部分则放在`tty.c`。这样，单个的进程就可以支持上述这此设备了。`tty.h`提供了所有终端设备的通用定义。

块设备请求消息里的字段：

```
int m.m_type	// 所请求的操作
int m.DEVICE	// 使用的次设备
int m.PROC_NR	// 哪个进程的请求
int m.COUNT		// 字节数或ioctl码
long m.POSITION	// 设备里的位置
char* m.ADDRESS	// 发出请求的那个进程的缓冲区地址，用于保存要收发的数据
```

块设备回复消息里的字段：

```
int m.m_type		// 总是DRIVER_REPLY
int m.REP_PROC_NR	// 即请求消息里的PROC_NR
int m.REP_STATUS	// 传送的字节数或错误号
```

字符设备用到的字段类似于块设备，但不同字符设备之间有轻微的不同。

块设备驱动程序是严格顺序执行的，当发出硬件请求后，驱动程序就执行`receive`操作，这意思着它只接收中断消息不再接收新的请求。终端驱动程序则不是这样，因为一个终端驱动程序服务着多个设备。

##### 设备无关的I/O软件

所有设备无关的I/O都包含在文件系统进程里。

##### 用户级I/O软件

- 库例程，如`printf`和`scaonf`。
- 一个多任务缓冲守护进程`lpd`，通过`lp`命令缓冲和打印文件。
- 支持各种网络功能的大量守护进程。
- 网络服务进程，内存管理进程，文件系统进程。

##### 死锁的处理

Minix3里直接忽略了死锁的问题。

Minix3里的某些设计可以避免死锁。最主要的是进程间消息传递的设计。

还有`notify`消息原语的设计也可以避免死锁。

锁的机制也可以避免死锁。

#### RAM盘驱动程序

内存驱动程序用来访问内存的任意部分。RAM盘驱动程序属于内存驱动程序，它保留一部分内存当传统硬盘来用。

RAM硬盘的思想就是在内存里保存块，可以快速的访问经常使用的数据。

对于支持挂载的系统，一旦挂载完成，用户就不必考虑文件在哪个设备上。

对于不支持挂载的系统，用户必须显式地指定文件的位置。对于拥有几十个磁盘的大型系统这将是个灾难。

RAM盘也是由块组成的。当向驱动程序发出消息要读或写一个块的时候，它计算出块的位置直接在内存里读或写。最终通过系统任务调用`phys_copy`实现内存间复制。

一个RAM盘驱动程序可以支持多个RAM盘，这些RAM盘之间通过次设备号来区分。RAM盘的空间一般应该是分开的，但也可以重叠。

#### 硬盘驱动程序

Minix把不同的硬盘驱动程序都放进了启动镜像，由用户决定来用哪个。

用户通过向引导监控程序输入参数`albel = AT`强制让Minix3使用IDE磁盘控制器(at_wini)。

硬盘驱动程序的主循环支持9种请求。

- DEV_OPEN
- DEV_CLOSE
- DEV_IOCTL
- DEV_READ, DEV_WRITE, DEV_GATHER, DEV_SCATTER
- DEV_CANCEL. DEV_SELECTE：被忽略。

#### 终端驱动程序

终端驱动程序构成了Minix3中最大的驱动程序。终端驱动程序即要处理键盘和显示器，还要处理两种可选的终端。

终端驱动程序接收的消息类型有：

1. 用户进程通过FS读终端。

2. 用户进程通过FS写终端。

   读写的消息和块设备的消息有相同的格式。

3. 用户进程通过FS设置终端的参数。

   一般应使用POSIX函数间接调用`ioctl`，若POSIX不支持则只能直接调用`ioctl`。

4. 当按下或释放键的时候，发生键盘中断。

5. 当信号产生的时候，FS取消之前的请求。

6. 打开设备。

7. 关闭设备。

8. 其它特殊用途的消息。

Minix3可以支持最高8个虚拟终端，这样用户就可以通过登陆不同的终端，在多个显示输出和键盘输入之间切换。可以按ALT+Fn切换虚拟控制台，也可以用ALT+方向键切换虚拟控制台。此外，串行线可以支持远程连接，伪终端可以支持网络连接。

POSIX要求标准库中的一些函数可以让程序来控制终端参数，基中最重要的两个是`tcgetattr()`和`tcsetattr()`。它们读写`termios`结构体，`termios`结构体包含了关于终端的所有信息。Minix3提供了`ioctl()`系统调用来支持这两个函数。

```c
struct termios {
tcflag_t c_iflag; /* 存放影响输入的位 */
    // ICRNL位可以让输入的CR字符转成NL字符
tcflag_t c_oflag; /* 存放影响输出的位 */
    // OPOST位使能输出
    // ONLCR位可以输出的NL字符转成 CR NL 序列
tcflag_t c_cflag; /* control modes */
    // Minix3默认使能一条线路接收8位字符，用户登出这条线路则modem挂起。
tcflag_t c_lflag; /* local modes */
    // ECHO位使能回显
    // ICANON位使能规范模式。
    // 其它位如果都是默认设置，则进到传统的"cbreak mode"，输入字符不等满行就传给了程序。
speed_t c_ispeed; /* input speed */
speed_t c_ospeed; /* output speed */
cc_t c_cc[NCCS]; /* 存放可以改变的特殊字符 */
};

/*
 * request：功能请求码，对termios结构体是读还是写
 * argp：指向termios结构体的指针
 */
ioctl(file_descriptor, request, argp);
```

##### 从键盘输入到应用程序的流程

当用户在系统控制台上登陆，会创建一个shell，并把`/dev/console`作为标准输入、标准输出和标准错误。shell调用库例程`read`来从标准输入读数据。`read`向文件系统发消息，消息包含了文件描述符，缓冲区地址，字节数。发完消息后，shell阻塞，等待回复。

文件系统接收到`read`发过来的消息，并定位到文件描述符所对应的索引节点(i-node)。这个索引节点对应着`/dev/console`，包含了终端的主、次设备号。终端的主设备号是4,控制台的次设备号是0。

文件系统通过检索设备映射dmap来找到终端驱动程序TTY的编号。然后向TTY发消息。一般来说用户还没有任何输入，所以终端驱动程序不能满足此请求。终端驱动程序立即发送一个回复以解除文件系统的阻塞。文件系统在控制台的结构体`tty_table`里记录下有一个进程在等待终端输入，然后就处理下一个工作请求。用户的shell则会一直阻塞，直到请求的字符抵达。

当键盘输入最终发生时，会产生两个中断，一个是键被按下时产生，一个是键被释放时产生。需要注意的是键盘生成的不是ASCII码，而是**扫描码**(scan code)。同一个键扫描码的低7位是相同的，最高位按下是0，释放是1。

键盘中断是IRQ 1。此中断线无法在系统总线上访问，也不能被其它I/O适配器共享。当`_hwint01`调用`intr_handle`，要通知的TTY会被很快找到。低层的中断处理例程通过直接调用系统任务里的`generic_handler`发出通知消息。`tty_task`接收到`HARD_INT`消息后就分发给`kbd_interrupt`，`kbd_interrupt`再调用`scan_keyboard`。`scan_keyboard`进行了3个内核调用让系统调用读写若干 I/O端口，最终得到扫描码，并把扫描码放到循环缓冲区中。然后设置`tty_events`标志，表示此缓冲区包含字符不再为空。

每当`tty_task`的主循环开始另一轮循环时，它检索每个终端设备的`tty_events`标志，对于设置了此标志的设备则调用`handle_events`。`handle_events`调用设备相关的函数来进行输入输出，因为`tty_events`标志可以标示各种活动。对于键盘输入来说`handle_events`调用的是`kb_read`，`kb_read`把扫描码转换为ASCII码。`kb_read`再调用`in_process`来处理ASCII码，一般来说就是把字符加到控制台的输入队列`tty_table`。`in_process`还需要把ASCII码回显到显示器上。

当接收到足够的字符，终端驱动程序执行另一个内核调用让系统任务把数据复制到shell所要求的地址上。复制数据不是通过消息传递来实现的。为满足用户需求，可能要进行多次这样的操作。当操作最终完成，驱动程序会向文件系统发消息表示自己的工作完成了，然后文件系统会向shell发消息来解除阻塞。

判断是否接收到足够字符的标准取决于终端的模式。规范模式下的标准是，接收到了换行码(linefeed)，行尾码(end-of-line)或文件尾码(end-of-file)，且一个行的大小不能超过输入队列的大小。非规范模式下的标准是，文件系统接收到操作完成的消息，在此之前读操作可以请求大量的字符，`in_process`可能需要传输多次。

注意，系统任务是直接把字符从TTY的地址空间复制到shell的地址空间的。这并不经过文件系统。对于块I/O开说，数据确实是经过文件系统的，文件系统使用缓冲区缓存来保存常用的块。

然而对于键盘I/O，缓存是没用的。文件系统请求磁盘只需要几百毫秒，所以等的起。键盘I/O则可能要花几小时，不可能让文件系统阻塞来等它完成的。

##### 其它输入

串行端口上传输的是字符，而不是扫描码。老的UART没有缓冲，所以每次按键都需要一个中断。新的UART有缓冲(16~128字节)，可以配置为缓冲区有多个字符时产生一个中断。以太网传输速度比串行线更快，但以太网适配器可以缓存整个包，每个包只需要一个中断。

##### 终端输入总结

终端驱动程序被读请求首次激活时发生的事件：

- 消息抵达终端驱动程序以请求键盘字符，主例程`tty_task`调用`do_read`来处理此请求。`do_read`把调用参数保存到`tty_table`的键盘入口里，以防止没有缓存足够的字符来满足此请求。
- `do_read`先调用`in_transfer`以接收已经在等待的输入，再调用`handle_events`。`handle_events`先调用`kb_read`把键盘输入立即复制给用户，再调用`in_transfer`以从输入流中把字符提取出来。如果`in_transfer`或`handle_events`完成了读操作，则向文件系统发消息，这样文件系统就可以解除对调用者的阻塞。如果没有完成读操作（因为字符数不够），则`do_read`会告诉文件系统挂起调用者（阻塞的读操作）或取消读操作（非阻塞的读操作）。

终端驱动程序被键盘输入再次激活时发生的事件：

- 当键入一个字符，中断“处理例程“`kbd_interrupt`调用`scan_keyboard`，`scan_keyboard`通过调用系统任务来完成I/O。（`kbd_interrupt`不是真正的处理例程，是系统任务里的`generic_handler`向`tty_task`发消息才把它激活的。）`kbd_interrupt`把扫描码放到键盘缓冲区`ibuf`，并设置一个标志以表明在控制台设备上有事件发生了。当`kbd_interrupt`把控制转移给`tty_task`，一个`continue`语句让主循环开始下一轮循环。`tty_task`会检查所有终端设备的事件标志(event flags)，对于被标志的设备则调用`handle_events`。如果被标志的是键盘，`handle_events`则调用`kb_read`和`in_transfer`，就像读请求首次激活时那样处理。此事件可能会发生多次，直到接收到足够的字符满足`do_read`的要求。
- `kb_read`调用的函数包括：`map_key`把扫描码转换成ASCII码。`make_break`追踪修饰键（modifier keys）的状态，比如SHIFT键。`in_process`处理复杂情况，比如退格键、其它特殊字符、不同输入模式下的可用选项。`in_process`也会调用`tty_echo`，所以输入的字符也会显示在显示器上。

##### 从应用程序到控制台输出的流程

进程一般是调用`printf`来打印一些东西。`printf`调用`write`向文件系统发消息，这个消息里包含了一个指针指向要打印的字符。文件系统向终端驱动程序发消息，终端驱动程序把那些字符复制到视频RAM里。

当终端驱动程序收到写屏幕的消息，则调用`do_write()`把参数保存进`tty_table`里控制台的结构体`tty`里。`do_write()`接下来再调用`handle_events()`，此函数为参数里指定的设备再调用输入和输出例程。对于控制台输出，这意味着等待着的键盘输入都先被处理过了。如果还有等待着的输入，则会把要回显的字符添加到等待输出的字符中。`handle_events()`接下来再调用`cons_write()`，`cons_write()`就是内存映射显示器的输出例程。`cons_write()`使用`phys_copy`从用户进程复制字符块到当前缓冲区。当前缓冲区满后(64字节)，所有字节被转移到另一个缓冲区`ramqueue`。`ramqueue`是由16位字组成的数组。16位字里的另一半是屏幕属性字节，它控制着前景色、后景色和其它属性。一般字符直接传进`ramqueue`里即可，但特殊字符需要特别处理。当字符位置超过屏幕宽度，或`ramqueue`满时也需要特殊处理。`out_char`就是这个进行特殊处理的函数。例如，在屏幕最后一行时收到换行符`out_char()`会调用`scroll_screen()`，如果是转义序列则调用`parse_escape()`来处理字符。`out_char()`调用`flush()`把`ramqueue`的内容复制到视频显示内存中，这是通过汇编例程`mem_vid_copy`实现的。最后一个字符传进`ramqueue`后也会调用`flush()`，确保所有的输出都会显示出来。`flush()`最后会让6845视频控制器芯片显示正确的光标位置。

##### 视频RAM的管理

| 字段     | 含义                                             |
| -------- | ------------------------------------------------ |
| c_start  | 当前控制台显存的起始位位置                       |
| c_limit  | 当前控制台显存的大小                             |
| c_column | 光标所在列的列号                                 |
| c_row    | 光标所在行的行号                                 |
| c_cur    | 光标在显存中的位置                               |
| c_org    | 6845基址寄存器指向的内存位置，即屏幕显示的起始点 |

`console`结构体的`c_start`和`c_limit`字段代表了控制台的视频RAM。当前的光标位则在`c_column`和`c_row`字段。坐标(0,0)在屏幕的左上角，这个位置就是硬件填充屏幕的起点。视频扫描从`c_org`的地址开始，延续80x25字节(即4000字节)。这就意味着，6845芯片从`c_org`拉取字符，并从屏幕的左上角开始显示它们。先是第一行从(0,0)到(79,0)，然后是第二行从(0,1)到(79,1)，最后一直到第二十五行(0,24)到(79,24)。

当计算机启动，屏幕被清空，输出被写入到视频RAM的`c_start`位，此时`c_org`和`c_start`是相等的。当行满或检测到换行符，输出被写入到`cstart + 80`。当25行都满，则会要求屏幕滚动。一些程序（比如编辑程序）还有向下滚动的要求，如果此时光标在顶行且要求继续向上移动。

滚动屏幕有两种方式。一种是**软件滚动**，总是把要在(0,0)坐标上显示的字符放在显存的起始位置，`c_start`代表第0个字的位置。`c_org`保存和`c_start`相同的值，这样视频控制器芯片就会首先从此位置开始显示了。当屏幕需要滚动时，视频RAM上相对位置80的内容要复制到相对位置0，81则复制到相对位置1，以此类推。扫描队列没有改变，还是把屏幕上(0,0)位置的数据放在显存的第0个位置，视屏图像整体向上移动一行。开销是CPU移动了1920（80*24）个字。一种是**硬件滚动**，不是数据在显存里移动，而是视频控制芯片从不同的位置开始显示。比如，从数据的第80个字符开始显示。把80加进`c_org`里，然后把这个值写入视频控制器芯片对应的寄存器里。这要求要么控制器足够聪明能够利用有限的视频RAM进行正确的显示，要么视频RAM足够大可以保存多屏的内容。

较老的显卡内存小，只能让显示的内容回绕。新显卡内存大，控制器不能回绕。比如保存204行需要32768字节的显存(204x80x2)，则可滚动179次(204-25)。但触底后还是需要把最后24行复制到显存的顶部。

当开启了虚拟控制台，这些控制台就会均分视频适配器的内存，这是通过设置每个控制台的`c_start`和`c_limit`字段实现的。这会影响到滚屏。尽管硬件滚屏是有效的，但此时一般会使用软件滚屏。当控制台的数量达到可能的最大值时，每次滚屏都将是软件滚屏。

虽然光标位可以通过`c_column`和`c_row`计算出来，但用字段`c_cur`记录它的值会更高效。被打印的字符会放在`c_cur`的位置，然后`c_cur`和`c_column`都被更新。

通过调整`c_column`, `c_row`和`c_cur`就可以处理影响光标位置的字符（比如换行，回退）。`flush()`通过在最后调用`set_6845()`实现此功能。

##### 解析转义序列

Minix3实现了一个有限状态自动机来解析转义序列。控制台结构体里的字段`c_esc_state`一般为0。当`out_char()`检测到`ESC`字符，它就把`c_esc_state`置1，后继字符则由`parse_escape()`来处理。`parse_escape()`会依据`c_esc_state`的值来选择相应的操作，如果下一个字符是控制序列引导符`[`则将`c_esc_state`置2，否则就认为此序列已完成并调用`do_escape()`。如果是2，只要下一个字符是数字，则将`c_esc_parmp`转换为`c_esc_parmp*10 `加上该字符对应的数值（因为`c_esc_parmp`的初值为0，这就可以把数字的字符序列转化为对应的数值）。当下一个字符变成分号，则把处理转到`c_esc_parmp`数组的下一个元素（此数组只包含两个元素）。当下一个字符即非数字也非分号，则认为序列已完成，会再次调用`do_escape()`。`do_escape()`依据当前字符来选择采取什么动作，怎么解释参数。

##### 可加载的键盘映射

扫描码是按键的编号，按下时产生的扫描码高位是0，松开时产生的扫描码高位是0。通过记录按下和松开的状态，可产生大量的组合键。

操作系统使用**键盘映射**(keymap)来把键的状态转换为字符码。Minix3的键盘映射从逻辑上看就是一个128行，6列的数组。128行代表了可能的扫描码，之所以设计的这么大是为了兼容日文键盘。6列分别代表了无修饰键(no modifier)，SHIFT键，Control键，左ALT键，右ALT键，ALT+SHIFT组合键。这种方案可产生720种字符码（`(128-6)*6`）。这就要求键盘映射里每个表项都是16位大小。

在`keyboard.c`里通过`#include keymaps/us-std.src`把标准键盘映射编译进了Minix3内核，但通过`ioctl(0, KIOCSMAP, keymap)`也可以把不同的映射载入内核地址`keymap`。 一个完整的映射占据1536字节(`128*6*2`)。额外的键盘映射会以压缩形式存储。程序`genmap`用于生成新的压缩的键盘映射。`genmap()`会为指定的键盘映射包含`<keymap>.src`文件，并把压缩的键盘映射输出为文件。`loadkeys`命令读取压缩的键盘映射，把它展开，然后调用`ioctl()`把键盘映射转移到内核内存。Minix3在启动时会自动执行`loadkeys`，用户也可以调用`loadkeys`。

键盘映射的源码在`src/kernel/keymaps/us-std.src`，里面定义了一个已经初始化的大数组。扫描码0不对应IBM-PC键盘上的任何一个键。扫描码1对应ESC键，当按下SHIFT或CTRL键不影响返回值，但ALT和ESC一同按下会返回不同的编码。在`include/minix/keymap.h`里的宏定义了这些返回值的计算方法：

```c
#define	C(c)	((c) & 0x1F)	/* 映射到控制码		*/
#define A(c)	((c) | 0x80)	/* 最高位置1 (ALT)		*/
#define CA(c)	A(C(c))		/* Control-Alt			*/
#define	L(c)	((c) | HASCAPS)	/* 添加 "大写生效" 属性 */
```

前三个宏通过位运算生成应用程序所需的编码。最后一个宏则是将高字节的第16位置1来使大写生效。观察扫描码2, 13, 16可以分别了解到数字，标点符号，字母是怎么处理的。扫描码28代表了ENTER键，一般来说ENTER键对应ASCII码的CR字符(0x0d)，即`C('M')`的运算结果。然而在UNIX里新行是LF字符(0x0a)，所以当前键盘映射提供了CTRL-ENTER组合键，即`C('J')`来对应`0x0a`。

扫描码29是修饰键CTRL的编码，不管按下什么键都不会对它产生影响。扫描码59对应功能键F1，它在ASCII表里没有对应的值，它所对应的值都定义在`include/minix/keymap.h`里。扫描码127是表里的最后一项，表里的靠后的很多项都和它一样。因为很多键盘类型都无法生成那么多的编码，所以那些项都被填充为0。

##### 可加载的字体

早期的PC要在显示器屏幕上生成字符，只能使用存储在ROM上的生成模型。但现代的显示器在显卡上提供了RAM，可用于加载自定义的字符自成模型。Minix3上`ioctl(0, TIOCSFON, font)`可支持此操作。

Minix3支持80列x25行的视频模式，字体文件包含4096字节。每个字符使用8x16个像素，故描述一个字符需要16个字节。但显卡使用32个字节来映射字符，这样就可以支持更高的分辨率了，但Minix3现在还不支持这种模式。`loadfont`命令把字体文件转化为`font`结构体，并调用`ioctl()`来加载字体。和键盘映射一样，即可以在启动时加载字体，也可以在随后任何时间加载字体。每个显卡都在它的ROM里保存了默认字体随时可用。没有必要把字体编译进Minix3，在内核里只有`TIOCSFON ioctl`支持必要的字体操作。
