一、前言
完成了CPU和内存这两个计算机核心部件的管理以后,最后一个问题就是完成对外设的管理。
外部设备通过总线和CPU、内存相连,每个外设都有自己的控制电路,当我们想要每个外设执行相应的动作时,CPU就会向某个地方写入命令,外设接收到后就会解释命令完成相应的动作。
整个过程可以用两步来总结:
(1)CPU通过端口地址发送对外设的工作要求吗,一般是命令“out ax,端口号”,其中AX寄存器存放的就是外设的具体工作内容;(2)外设开始工作,在工作完成后通过中断告诉CPU,CPU会在中断处理程序中处理外设的工作结果。
操作系统不仅仅是要像上面那样使用外设,而且还引入了文件视图这一概念,将所有对外设的使用变成了对文件视图的操作,给上层用户提供了一个接口,屏蔽细节,达到方便使用的目的。
二、设备驱动的基本原理
1.外设的工作原理
CPU对外设的使用由两条主线构成:第一条主线从CPU开始,CPU向外设发送命令,表现为CPU执行指令“out ax,端口号”;第二条主线从外设开始,当外设执行完任务或状态发生变化,就会通过中断机制通知CPU,CPU会通过中断处理程序完成后续工作。
2.文件视图
如果像上面那样去完成对外设的操作,需要程序员知道外设对应的端口号,了解指令的详细格式,而且不同公司的同一种外设的格式可能完全不一样,这是一项繁杂且无趣的工作。为了提高工作效率,可以将所有的外设都抽象为一个文件视图,调用read、write等统一的接口完成对外设的使用。比如向“显示器文件”写入(write)一个字符串“hello”,那么就会在显示器上显示出“hello”。
main()
{
int fd = open("/dev/xxx");
for(int i = 0; i < 20; i++)
{
write(fd, i, sizeof(int));
}
close(fd);
}
采用了上述的结构,就将操作系统对外设的操作转变成了对文件的写入操作。操作系统会将这些读写操作展开成各种out语句,这个展开工作就是操作系统外设管理的核心任务。
三、显示器驱动
之前我们论述了系统对外设的操作有两条主要路线,这一节就以显示器的驱动为例介绍第一条主要路线,即从文件读写到设备命令。
1.从printf开始
printf是一个库函数,它将%d、%c等内容统一处理为字符串,然后以该字符串所在的内存地址buf和长度count为参数调用系统调用write(1, buf, count)。
write的内核是sys_write,所以printf的下一步是sys_write。sys_write的第一步就是分辨操作的文件是设备文件还是普通文件,若是设备文件,sys_write要根据文件中存放的设备属性信息转到相应的操作命令。
设备信息存放在描述文件本身(非文件内容)的数据结构中,这个数据结构就是文件控制块(file control block),所以sys_write的第一步就是找到FCB。
//sys_write代码片段,用于寻找FCB
int sys_write(unsigned int fd, char *buf, int count)
{
struct file* file;
file = current->filp[fd];
inode = file->f_inode;
......
}
为了找到文件的FCB,第一步就是要从当前进程PCB中找到打开文件的句柄标识,即fd,对于显示器而言,fd=1,根据这个fd可以找到文件的FCB,就是代码中的inode。
current->flip中存放的是当前进程打开的文件,如果一个文件不是当前进程打开的,那么一定是其父进程打开后再由子进程继承来的。这是UNIX类操作系统执行fork的结果,因为在fork的核心实现copy_process()中有如下资源复制:
int copy_process()
{
*p = *current;
for(i=0; i<NR_OPEN; i++)
{
if((f=p->flip[i])) f->f_count++;
......
}
fd=1的文件对应标准输出,因为每个进程都可能用到标准输出,所以每个进程都会打开这个文件。既然所有文件都会打开这个设备文件,操作系统初始化时的1号进程会首次打开这个设备文件,操作系统初始化时的1号进程会首次打开这个设备文件,然后其他文件继承这个文件句柄。在系统启动后创建1号进程的地方,即在init函数中调用open来打开一个名为"/dev/tty0"的文件,由于这是该进程打开的第一个文件,所以对应的文件句柄fd=0,接下来调用两次dup,使得fd=1,fd=2也都指向了"/dev/tty0"的FCB,结果如下图。
系统调用open的核心就是找到文件对应的FCB(存储在磁盘中),将其读入到内存并与PCB关联成如上图所示fd=0的样子。dup的功能就是将上一个fd的内容复制到下一个fd中。
//系统初始化代码片段(打开tty设备)
void main(void){if(!fork()){init();}
void init(void)
{
open("/dev/tty0",O_RDWR,0);
dup(0);
dup(0);
execve("/bin/sh",argv,envp);
......
}
显示器的设备文件已经找到,就是文件"/dev/tty0",其属性信息也存放到了sys_write函数中inode变量中了,现在可以沿着命令路线继续推进。
2.文件视图中的大量分支
沿着sys_write继续向下:
//sys_write代码片段
int sys_write(unsigned int fd, char* buf, int cnt)
{
if(S_ISCHR(inode->i_mode))
return rw_char(WRITE, inode->izone[0], buf, cnt);
......
}
首先根据ionde中的信息判断该文件对应的设备是不是字符设备,显示器就是一个字符设备,用命令ls -l /dev/tty0
可以找到这个文件的属性信息
crw-rw-rw- 1 root root 4,0 Mar 4
第一个字符c就是表示这是一个字符设备,而4,0表示的是这个设备的主设备号和副设备号,因为计算机上有多个字符设备,用4,0来区分到底是哪个字符设备。
显示器是字符设备,现在sys_write就要分支到rw_char(WRITE,inode->i_zone[0],buf,cnt)中去执行,其中inode->i_zone[0]中存放的就是该设备的主设备和次设备号。继续向下执行相应的字符设备处理代码:
//字符设备处理代码
int rw_char(int rw, int dev, char* buf, int cnt)
{
crw_ptr call_addr = crw_table[MAJOR(dev)];
call_addr(rw, dev, buf, cnt);
static crw_ptr crw_table[] = {......rw_ttyx,......};
static int rw_ttyx(int rw, unsigned minor, char* buf, int count)
{
return ((rw = READ)? tty_read(minor, buf): tty_write(minor, buf));
}
......
}
rw_char中以主设备号(MAJOR(dev))为索引从一个函数表crw_table中查找和终端设备对应的函数rw_ttyx,然后执行它。
rw_ttyx根据是设备读操作还是设备写操作继续分支。显示器个键盘合在一起构成了终端设备tty,键盘只读,显示器只写,所以将调用tty_write(minor, buf)。根据文件的属性,即inode中的信息,经过大量分支以后,从写文件write最终达到真正操作显示器的tty_write。
3.最终到达mov ax, [pos]
tty_write首先获得一个结构体tty_struct,主要目的就是在这个结构体中找到队列tty_write_q。实际站在用户的角度,输出到显示器就是输出到这个队列中,最终要等到合适的时候,有操作系统统一将队列中的内容输出到显示器上,这就是著名的缓冲机制。
缓冲就是指两个速度存在差异的设备通过缓冲队列去弥补差异的一种方式。简单来说就是高速设备将数据存放到缓冲队列中,然后去做其他事情,低速设备在合适的时候从缓冲队列中取出内容进行输出即可。在这里高速设备是CPU,低速设备是显示器。
现在printf()要输出的数据已经被放在缓冲队列里了,接下来调用tty-write(tty)从这个队列中取出字符真正写到显示器上。从对tty结构体的初始化可以看出,tty-write调用的函数是con_write。
//con_write核心代码(真正将字符打印到显示器上)
struct tty_struct tty_table[] = {con_write, {0,0,0,0,
" "},{0,0,0,0," "}},{},......};
void con_write(struct tty_struct *tty)
{
GETCH(tty->write_q, c);
if(c>32 && c<127)
{
_asm_("movb attr, %%ah"
......
}
}
这是一段嵌入式汇编代码,具体完成的工作是:
mov c, al
mov attr, ah
mov ax, [pos]
将要输出的内容存放到AX的低8位,将显示属性attr放到AX的高8位,然后将AX输出到地址pos处。显示器输出工作最终到了out指令处,即最后一个环节。