0%

HIT-OS 设备驱动管理与文件系统

设备驱动-从文件视图到out指令

设备驱动的基本原理

外设的工作工作原理

CPU对外设的使用主要由如下两条主线构成:

  • 第一条主线是从CPU开始,CPU发送命令给外设,最终表现为CPU执行指令, out ax, 端口号
  • 第二条主线是从外设开始,外设在完成工作后或出现状态变化时通过中断通知CPU,CPU通过中断处理程序完成后续工作

文件视图

一段操纵外设的程序

1
2
3
4
5
int fd = open("/dev/xxx");
for (int i = 0; i < 10; i ++) {
write(fd, i, sizeof(int));
}
close(fd);

不管是什么样子的外设,操作系统都将其统一抽象成一个文件,程序员通过文件接口 open、read、write来使用这些外设。

  • 无论什么设备都是open, read, wirte, close

    操作系统为用户提供统一的接口

  • 不同设备对应不同的设备文件(/dev/xxx)

    根据设备文件找到控制器的地址、内容格式等等

显示器的驱动

从printf开始

printf是一个库函数,该库函数会将 %d、%c等内容统一处理为字符串,然后以该字符串所在的内存地址 buf 和字符串长度 count 为参数进行系统调用write(1, buf, count)

write系统调用通过 int 0x80 中断进入内核执行 sys_write()函数

1
2
3
4
5
6
7
// 在linux/fs/read_write.c中
int sys_write(unsigned int fd, char * buf, int count) {
struct file * file;
file = current->filp[fd];
inode = file->f_inode;
......
}

fd是找到file的索引, current表示当前进程,file的目的就是得到inode,显示器的信息就在这里

sys_write首先要做的就是找到所写文件的属性,即到底是普通文件还是设备文件,如果是设备文件,sys_write要根据设备文件中存放的设备属性信息转到相应的操作命令。

设备信息存放在描述文件本身(非文件内容)的数据结构中,这个数据结构就是著名的文件控制块(file control block, FCB)。所以 sys_write的第一步工作就应该是找到要写的“文件”的FCB。

fd=1的flip从哪里来

为了找到文件FCB,首先要做的工作就是从当前进程的PCB中找到打开文件的句柄标识,即fd, 对于显示器而言,这个fd = 1,然后根据这个fd可以找到文件FCB,即代码中的inode。这个inode到底对应了什么设备?里面存放了什么内容?需要从为inode赋值说起。

current->filp中存放了当前进程打开的文件如果一个文件不是当前进程打开的,那么就一定是其父进程打开后再由子进程继承而来的。这个是UNIX类操作系统执行fork的结果,因为在fork的核心实现copy_process()中就有这样的资源复制。

1
2
3
4
5
6
7
8
9
// 进程创建的代码片段(复制父进程文件句柄)
int copy_process() {
*p = *current;
for (int i = 0; i < NR_OPEN; i ++) {
if ((f = p->filp[i])) f->f_count ++;
......
}
}

谁是一开始打开的,shell进程启动了whoami命令,shell是其父进程

1
2
3
4
5
6
7
8
9
10
11
// 系统初始化代码片段
void main(void) {
if (!fork()) init();
}
void init(void) {
open("/dev/tty0", O_RDWR, 0);
dup(0);
dup(0);
execve("/bin/sh", argv, envp);
.....
}

fd = 1 的文件对应标准输出,因为每个进程都可能用到标准输出,所以每个进程都会打开这个文件。既然所有进程都要打开这个设备文件,操作系统初始化时的1号进程会首次打开这个设备文件,然后其它进程继承这个文件句柄。在系统启动后创建1号进程的地方,即在init函数中调用名为 /dev/tty0的文件,由于这是该进程打开的第一个文件,所以对应的文件句柄fd = 0,接下来调用两次dup,使得 fd = 1,fd = 2也都指向了 /dev/tty0 的FCB,如下图所示

系统调用 open 的核心工作就是找到文件对应的 FCB (存储在磁盘上),将其读入到内存里并和进程PCB关联如下图中 fd = 0的样子。dup的功能就是将上一个fd的内容复制到下一个fd中, 所以两次调用以后就变成下图所示的效果

现在显示器对应的设备文件已经找到了,就是文件/dev/tty0,其属性信息也已经存放在sys_write函数中的inode变量中了。

open系统调用完成了什么

主要为了在设备文件中读入inode, 根据inode的信息,就可以确认设备的信息,比如后续磁盘的话就走磁盘路,显示器就走显示器的路…

准备好了,真正向屏幕输出

现在沿着sys_write继续向下

1
2
3
4
5
6
7
8
// sys_write 代码片段(根据文件属性进行分支)
int sys_write(unsigned int fd, char * buf, int cnt) {
...
inode = file->f_inode;
if(S_ISCHR(inode->i_mode)) { // /dev/tty0的inode信息是字符设备
return rw_char(WRITE, inode->i_zone[0], buf, cnt); // inode->i_zone[0]表示对应的设备号
}
}

首先根据inode中的信息判断该文件对应的设备是否是一个字符设备,显示器就是一个字符设备,用命令ls -l /dev/tty0可以列出这个文件的设备信息

其中第一个字符 c 就表示这是一个字符设备,而后面给出的数字4, 0是这个而设备id主设备号和次设备号。因为计算机上有多个字符设备,用4, 0区分到底是哪一个字符设备。

显示器是字符设备,现在sys_write 就要分支到函数 rw_char(WRITE, inode->i_zone[0], buf, cnt) 中去执行, 其中 inode->izone_[0]中存放的就是该设备id主设备号和次设备号。继续向下执行相应的字符设备处理代码。

1
2
3
4
5
6
7
8
9
10
// 字符设备处理代码
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 。

继续tty_write这一核心函数

经过漫长的分支(在实际使用的操作系统中,如Linux 2.6,这个分支要比这里论述的还要长)以后,tty_write(代码如下)开始真正向显示器输出了。

tty_write首先获得一个结构体 tty_struct , 主要的目的是在这个结构体中找到队列 tty->write_q。实际上,站在用户的角度上,输出到显示器就是输出到这个队列中。最终等到合适的时候,由操作系统将这一队列中的内容输出到显示器上,这就是著名的缓冲机制

缓冲是指两个速度存在差异的设备之间通过缓存队列来弥补这种差异的一种方式,具体而言,就是高速设备将数据存到缓存队列中,然后高速设备去做其他事,低速设备在合适的时候从缓冲队列中取走内容进行输出,这样高速设备不必一直同步等待低速设备,提高了系统的整体效率。在这里,高速设备就是CPU,低速设备就是显示器。缓冲机制是操作系统在外设管理时经常使用的基本机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// tty_write 核心代码(将显示字符放到缓冲队列中)
int tty_write(unsigned channel, char * buf, int nr) {
struct tty_struct *tty;
tty = channel + tty_table;
sleep_if_full(&tty->write_q);
char c, *b = buf;
while(nr > 0 && !FULL(tty->write_q)) {
c = get_fs_byte(b);
if (c == 'r') {
PUTCH(13, tty->write_q); continue;
}
if (O_LCUC(tty)) c = toupper(c);
PUTCH(c, tty->write_q);
b++; nr--;
} // 输出完成或队列满
tty->write(tty);
}

tty->write()应该就是真的开始输出屏幕了

tty->write()

从缓冲队列中取出字符真正写到显示器上。从对 tty 结构体的初始化可以看出,tty->write调用的函数时con_write()

1
2
3
4
5
6
7
8
9
10
11
// 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 > 31 && c < 127) {
_asm("movb attr, %%ah"
"movw %%ax, %1" :: " a"(c)," m"(*(short*)pos): "ax");
pos += 2;
}
}

上述的核心代码是一段嵌入式汇编代码,具体完成的工作是:

1
2
3
mov c, al
mov attr, ah
mov ax, [pos]

即将要输出的字符放在寄存器 AX 的低8为位,将显示属性 attr 放到 AX的高8位,然后将 AX 输出到地址 pos 处。显示器输出工作真正做到了 out 指令,即整个文件视图的最后一个环节。

为了故事的完整性,我们需要知道 pos 到底是什么。 con_write 中每输出一个 AX 都让 pos + 2 ,这是必然的,因为 AX 就是两个字节。所以理解pos 的关键在于 pos 的初值,在初始化 con_init 中,调用函数 gotoxy 将 pos 的值初始化为 origin + [0x90001]*video_size_row + ([0x9000]<<1)。90000这个数字并不陌生吧,在系统启动的 setup.s时利用BIOS中断将当前光标的行、列位置取出来放到了 0x90000和 0x90001处。而 origin 时显存在内存中的初始位置。因此初始化以后 pos 就是开机以后当前光标所在的显存位置。

1
2
3
4
5
6
7
8
9
// pos(光标位置)的初始化代码
void con_init(void) {
gotoxy(ORIG_X, ORIG_Y);
}
static inline void gotoxy(x, y) {
pos = origin + y * video_size_row + (x << 1);
}
#define ORIG_X (*(unsigned char *)0x90000)
#define ORIG_Y (*(unsigned char *)0x90001)

现在应该明白了,mov ax, [pos] 就是将 prinf要显示的字符放在显存当前光标位置处。这里的 mov 和 out 没有本质区别,计算机硬件原理告诉我们,外设可以独立编址,也可以和内存统一编制,如果是独立编址就用 out 指令, 如果统一编制就用 mov 指令

到了这里, printf -> write -> sys_write -> rw_char -> rw_ttyx -> tty_write -> write_q –> con_write -> mov ax, [pos] ,这条从 printfmov ax, [pos] 的文件视图路线就完整了

printf的整个过程

键盘

从键盘的中断开始

对于用户:敲键盘、看结果

对于操作系统:等着你敲键盘,敲了就中断

所以故事就应该从键盘中断开始,从中断初始化开始

1
2
3
void con_init(void) {
set_trap_gate(0x21, &keyboard_interrupt);
}
1
2
3
4
5
6
7
8
.global _keyboard_interrupt

_keyboard_interrupt:
inb $0x60, %al // 从端口0x60读扫描码
call key_table(,%eax, 4) // 调用key_table+eax*4
....
push $0
call do_tty_interrupt

从键盘的 0x60端口上获得按键扫描码,然后根据这个扫描码调用不同的处理函数来处理各个按键,即 cal key_table(, %eax, 4)

处理扫描吗key_table+eax*4

从key_map中取出ASCII码

put_queue 将 ASCII码放到? con.read_q

键盘处理

生磁盘

磁盘结构

磁盘的IO过程

最直接的使用磁盘

通过盘块号读写磁盘(一层抽象)

多个进程通过队列使用磁盘(第二层抽象)

FCFS磁盘调度算法

SSTF磁盘调度

SCAN磁盘调度

C-SCAN磁盘调度(电梯算法)

多进程共同使用磁盘

文件系统

从磁盘到文件

引入文件,对磁盘使用的第三层抽象

链式结构也可以实现文件

文件实现的第三种方式,索引结构

实际系统是多级索引

文件使用磁盘的实现

文件抽象的基本过程:给定一个文件inode,给出一个字符流读写的位置,操作系统通过该inode中存放的索引信息找到文件读写位置所在的物理盘块号,然后利用这个盘块号调用bread

再次使用磁盘,通过文件使用

file_write的工作过程应该就是

file_write的实现

现在有了字符流的读写位置 pos, 接下来根据 pos 和索引节点中的索引信息来计算物理盘块号。在函数 create_block(inode, pos/BLOCK_SIZE)中会调用 bmap来完成这项工作,其中 post / BLOCK_SIZE就是算出来的逻辑块号,用这个数值去查找 inode中的直接数据块,一阶索引疑惑二阶索引就是这项工作的核心内容。

create_block算盘块,文件抽象的核心

bmap函数中,if (block < 7) 表示什么?逻辑盘块号小于等于6,说明inode中的直接数据块就能映射出盘块号,所以直接返回 return inode->i_zone[block] 就是物理盘块号了。当然如果这个逻辑盘块没有映射到物理盘块,即发现 !inode->i_zone[block]时就调用 new_block(inocd->i_dev)从磁盘上申请一个空闲物理盘块。

block -= 7后判断 if (block < 256) 。逻辑盘块号对应的物理盘块号用来存放在一阶间接索引中,接下来需要读入一节索引块,bread(inode->i_dev, inode->i_zone[7]) 用来读入这个索引 , 接下来需要在这个索引块中寻找和逻辑块对应的物理盘块号即可。

再回到 file_write函数。一旦找到物理盘块号,就可以利用物理盘块号去真正写磁盘了。经过前面给出的三层抽象,写磁盘实际上就是写高速缓存。bh = bread(inode->i_dev, block) 用来获得一个缓存块,不管要从物理磁盘读入(改写文件内容),还是获得一个空闲缓存块(追加文件内容),总之执行完 bread(inode->i_dev, block) 后得到一个高速缓存块bh, 并且从用户角度而言,操作 bh 等同于操作物理磁盘。

接下来将用户缓存中的字符逐个写到磁盘高速缓存中,在适当时候,操作系统会将磁盘高速缓存中的这个磁盘写请求放到电梯队列中,等磁盘中断时候才真正写磁盘,这就是著名的磁盘延迟写。至于什么时候适当,需要设计相应的算法,可以通过 sync系统调用直接写高速缓存。

此次文件读写完成以后需要修改f_pos,因为读写指针已经移动了,下一次访问文件时应该从新的字符流位置开始。

m_node,设备文件的inode

伟大的文件视图

目录和文件系统

文件,抽象成一个磁盘块集合

第五层抽象:将整个磁盘抽象成一个文件系统

引入目录树

树状目录的完整实现

目录中不存放文件的FCB,但可以存放一个FCB地址,需要的时候通过这个地址到磁盘文件上读入文件的FCB数据结构。

什么是FCB ? 一种常见的处理方法是将磁盘上所有文件的FCB数据结构组织成一个数组连续存放在一个磁盘块序列上,此时一个文件的“FCB地址“就是这个文件的FCB数据结构在FCB数组里的索引。

文件名字符串和FCB索引这两个信息形成的结构体常被称为一个目录项,因此可以得出这样的结论:目录的内容就是一个目录项数组。

要使整个系统能自举,还需存一些信息

“完成全部映射下”磁盘使用

目录解析代码实现

将 open 弄明白

get_dir 完成真正的目录解析

目录解析首先找到一个解析的起点,如果路径名从 “/“ 开始,就从根目录的 inode 开始,否则从当前目录的 inode开始,接下来的工作就是读出目录文件内容,然后用文件路径上的下一段文件名和目录中的目录项逐个对比,在匹配的目录项中存有下一层文件的 inode编号。接下来的工作是类似的,就是不断递归向下继续做目录解析,知道路径名被完全处理完成。最终找到目标文件的indoe,返回。

目录解析-从根目录开始

在 1 号进程 init函数中要执行mount_root(),该函数用来将根目录的inode读入到内存中,并且关联到1号进程的PCB中。当然,完成根目录 inode 的读取是很简单的,就是要找到 inode 数组在磁盘的起始位置。

读取inode - iget

开始目录解析 - find_entry(&inode, name, … &de)

函数find_entry的工作原理很简单,就是根据目录文件的 inode读取目录项数组,然后逐个目录项进行匹配,即 “while(i < entries) if (match(namelen, name, de))….”。iget用来读取索引节点,根据 inode编号和 inode数组起始位置计算出该 inode 所在的磁盘块号,再用 bread 发出磁盘读将 inode 读入即可。

while(i < entries)

总结五层抽象

至此完成了对磁盘使用的全部五层抽象,将这五层抽象倒过来就是从用户出发的、操作系统封装起来的磁盘使用全过程

  • 安装操作系统时,会将磁盘格式化如下图所示

  • 系统启动的时候,会将磁盘的根目录文件找到,将根目录文件的 inode读入到内存中,作为1号进程的一个资源

  • 用户创建的任何一个进程都会继承逐个根目录的 FCB

  • 用户在程序中(一旦执行就变成进程)中打开(open)一个文件,如 open(/my/data/test)时,会启动目录解析,最终得到目标文件(即test)的inode并将其读入到内存中,返回一个文件句柄 fd

  • 用户利用这个文件句柄 fd 操作文件,如 read(fd, buf, count) 时,操作系统会根据 fd 找到当前文件字符流位置 pos, 根据 pos和文件 inode中存储的索引信息找到pos对应的物理盘块的盘块号 block

  • 调用 bread(block) 读磁盘高速缓存,如果内容已经在缓存中,则直接复制它并送回用户态缓存 buf中,如果没有在高速缓存中,则获取一个空闲的高速缓存bh,并将block等信息填写到bh

  • 发起磁盘读写请求 req , 根据磁盘块和扇区之间的关系算出 block对应的扇区号 sector,利用sector信息将req加入到电梯队列中,发起读进程进行睡眠等待。

  • 当磁盘控制器处理完上一个磁盘读写请求以后产生磁盘中断,在磁盘中断处理程序中,操作系统会从电梯队列中取出这个请求req,根据其中的读写扇区号sector换算出要读的柱面号C、磁头号H和扇区号S, 利用out指令将C、H、S发出到磁盘控制器上,现在磁盘控制器开始真正读磁盘了。

  • 磁盘控制器完成对该磁盘的请求后会再次产生磁盘中断,中断处理程序会唤醒那个睡眠的进程,当被唤醒的进程再次执行时,磁盘高速缓存bh已经存放了/my/data/test的字符流位置信息了,将这个内容复制到用户缓存buf中,真个磁盘使用的工作到此全部完成。

求大佬赏个饭