目录
- 习题解答
- 内核相关数据结构
- 中断处理过程的细节
- 内核态栈和用户态栈的变化
- 块设备驱动程序
- 字符设备驱动程序
- 内存映射
- 心得感悟和相关知识纠正
习题解答
以下都是个人关于习题的个人解答(主要是没找到正确答案),如有错误,还望指出。
第一章
分别说明虚拟地址、线性地址和实际物理地址的定义,并说明他们之间的主要区别和相互关系。
虚拟地址:假设了每个段的大小范围是0~4G,那么一个段的虚拟地址就是该段数据相对于本段的偏移值,是一个相对地址。
线性地址:虚拟地址+偏移地址。即虚拟内存空间(VMA,virtual memory address),是一个进程的所有段相对于他的前一段的偏移地址,一般这个地址的最大限制受制于CPU的位数
物理地址:线性地址通过段部件(虚拟内存映射)和页部件(页表和页目录表)生成的地址。即真实物理内存地址。
本书所讨论的Linux内核定义了系统同时运行的最大任务数是64个 。请问是如何定义出来的,根据内核对地址的使用方式,是否还可以增大同时运行的任务数
线性地址的限制:一个进程64M,而线性地址最大值是4G,4G/64M=64
页目录限制:一个进程64M,一个页目录项能管理4M,64M/4M=16,而页目录项最大是1024项,1024/16=64。
什么是引导启动映像(bootimage)盘?什么是根文件系统(rootimage)盘?他们的主要作用分别是什么?
引导启动映像盘:包括内核的主要执行代码
根文件系统:仅在内存中加载代码并不能让Linux系统运行起来,作为完整可运行的Linux系统还需要一个基本的文件系统支持,即根文件系统
第二章
请说明Linux内核引导启动的完整过程,系统是在什么时候开始进入386保护运行模式的?
当PC的电源开启后,进入“加电自启“阶段,80x86结构中的CPU将自动进入实模式,并从地址0xFFFF0开始自动执行程序代码(这个地址一般是ROM-BIOS的地址),BIOS进行检测,并在物理地址0处开始初始化中断向量,此后,他将可启动设置的第一个扇区(磁盘引导扇区,512B)读入内存绝对地址0x7C00,并跳转过去。
setup.s中设置了CR0寄存器,系统开始进入保护模式运行,并跳转位于system模块最前面的head.s程序继续运行。
在本章所描述的内核启动程序中,为什么不直接将system模块搬到0x00000处二十先搬到0x10000处,再搬到0x00000
由前面可道在BIOS初始化的时候会在物理地址开始位置的地址初始化了中断向量,而在setup.s程序中要使用中断调用功能来获取有关机器配置的一些参数(例如显卡模式、硬盘参数表模式等等),因此不可在一开始便将系统模块移动到0x00000处
setup.s和head.s中都分别设置了一次全局描述符表GDT和中断描述符表IDT,这是为什么?能否只在head.s中设置一次?
在setup.s程序中将system模块移动到0x00000后,加载了中断描述符表(idtr)和全局描述符表寄存器(gdtr),最后设置了控制寄存器CR0,进入32位保护模式运行。为了能让head.s在保护模式下运行,临时设置了IDT和GDT,并在GDT中设置了当前内核代码段和数据段的描述符。
head.s重新设置GDT除了增大GDT的限长之外,还把GDT移动到了内存高速缓冲区
若不使用as86汇编器,而用gas来编译bootsect可以么?为什么Linus当时要使用as86汇编器?
as86主要编译Intel8086、80386汇编编译程序和链接程序,而bootsect.s是8086代码,所以才使用as86(head.s是AT&T汇编,与此不同)
as86编译文件更加小巧,并具有一些gas所没有的特性,比如宏以及更多的错误检测手段
第三章
在setup.s代码执行完之后,head.s及system被移到了0x00000~0x80000处,那么PC开机时0x0000-0x0400处及之后的一些参数不也是被覆盖了吗?内核以后是怎么设置的?
Linux 在这之后就完全不用PC自己的中断程序,而纯粹自己作中断程序了。在head.s中的78行(setup_idt)开始,首先在232行的_idt处设置了256个亚中断向量,指向一个只显示"Unknown interrupt"的中断处理程序。然后会在init的main()中各个硬件的初始化函数中一个一个地分别设置所用到的实际中断向量。
简述Linux内核的整个初始化过程
main.c程序首先利用前面的setup.s程序取得的系统参数设置系统的根文件设备号以及一些内存全局变量,然后内核进行所有的硬件硬件初始化操作,包括陷阱门、块设备、字符设备和tty、还包括第一个人工设置第一个任务(task0),然后开中断(sti),并切换到任务0运行
详细说明_syscall0(int, fork)嵌入函数的使用方法,在程序中调用该函数的实际语句是怎样的?
// 调用内联函数
static inline _syscall0(int, fork);
// 宏定义
#define _syscall0(type, name) { \
long __res; \
__asm__ volidate ("int $0x80" \
: "=a" (__res); \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1;\
}
// 展开如下
static inline int fork(void) {
long __res;
__asm__ volidate ("int $0x80" // 系统调用
: "=a" (__res); // 输出
: "0" (__NR_fork)); // 输入
if (__res >= 0)
return (type) __res;
errno = -__res;
return -1;
}
第四章
硬盘中断是怎么产生的?系统调用read、write等都是产生请求,并将请求插入到请求队列,在中断时由中断处理函数遍历请求队列完成读写,那么最初的硬盘中断是由谁、如何激发的呢?
asm.s用于实现大部分硬件异常所引起的中断的汇编语言处理过程,主要涉及对Intel保留中断int0~int16的处理,其余保留的中断int17-int31由Intel公司留作今后扩展使用。
copy_process的参数由17个,其中的none对应的是对应的是堆栈中的什么内容
int copy_process(int nr, long ebp, long edi, long esi, long gs, long none, long ebx, ……)
复制进程,其中参数none是system_call.s中调用sys_call_table时压入堆栈的返回地址。
在do_signal()函数中的104行语句是:*(&eip)=sa_handler;这条语句不就是等价于eip=sa_handler吗?Linus为什么这样表达?
将内核态栈上用户调用系统调用吓一跳代码指令只在eip指向该信号处理句柄,由于C函数是传值函数而不是传指针,因此给eip赋值时需要使用*(&eip)的形式
在head.s中执行lss_stack_start,%esp,此时ss是何内容?
// sche.c
/** 定义用户堆栈,共1K,容量4K字节。在内核初始化操作过程中被用作内核栈,初始化完成之后将被用作任务0的用户态堆栈。
* 在运行完任务0之前他是内核栈,以后用作任务0和1的用户态栈
*/
long user_stack[PAGE_SIZE >> 2];
/** 该结构用于设置堆栈ss:esp(数据段描述符,指针)
* ss被设置位内核数据段选择符(0x10),指针esp指在user_stack数组的最后一项后面,这是因为Intel CPU执行堆栈操作时是先递减堆栈指针sp值,然后在sp处保存入栈内容
*/
struct {
long *a;
short b;
} stack_start = {&user_stack[PAGE_SIZE >> 2], 0x10};
用于设置堆栈开始的位置,ss指的是内核数据段描述符
提示:此后该堆栈再无传递,不作为其他任务的堆栈,其他任务的用户态堆栈是在内存映像中,内核态堆栈是在进程描述符后面位置
在中断程序中,段描述符寄存器的值丢了,请问是在什么时候改的?
在创建新进程时,fork(0函数在父进程中会返回新进程的pid,而在子进程中则返回0,这是为什么?
int copy_process(……) {
//
p->tss.eax = 0;
//
}
返回的是eax寄存器的内容
waitpid()函数中,若进程被阻塞,请问是如何被唤醒的?
第五章
块设备于字符设备的主要区别是什么?访问块设备一定要通过高速缓冲吗?
块设备是一种可以以固定大小的数据块为单位进行寻址的访问的设备,例如硬盘和软盘
字符设备是一种以字节流作为操作对象的设备,不能进行寻址操作,例如打印机设备、网络接口设备和终端设备
块设备的主设备号是什么?硬盘hd1设备的次设备号是什么?
主设备号 | 类型 | 说明 | 相关操作函数 |
0 | 无 | 无 | NULL |
1 | 块/字符 | ram,内存设备(虚拟盘等) | do_rd_request() |
2 | 块 | fd,软驱设备 | do_fd_request() |
3 | 块 | hd,硬盘设备 | do_hd_request() |
4 | 字符 | ttyx设备(虚拟或串行终端) | NULL |
5 | 字符 | tty设备 | NULL |
6 | 字符 | lp打印机设备 | NULL |
块设备的主设备号1、2、3,硬盘hd1设备的次设备号是1
在内核中调用ll_rw_block()时会触发对块设备的读写操作,在一次读盘操作中,在哪个程序的哪个函数中进行了首次块读写操作?
第六章
数学协处理器处理函数在什么条件下被调用?
当CPU执行到一条协处理器指令时就会引发“设备不存在”异常中断7,该异常过程的处理代码在sys_call.s中。如果操作系统在初始化时已经设置了CPU控制寄存器CR0的EM位,那么此时就会调用math_emulate.c程序中的math_emulate()函数来用软件“解释”执行每一条协处理器指令。
对于不含数学协处理器硬件的计算机,通过用什么方法才能使用协处理器的应用程序运行起来?
在系统内核级使用仿真程序来模拟协处理器的运算功能,在对程序不加任何改动的情况下把所编程序运行在具有协处理器的机器上
第七章
当安装一个文件系统时,内核调用驱动程序的打开过程,但在系统调用结束时释放设备特殊文件索引节点。在卸载一个文件系统时,内核读取设备特殊文件的索引节点,并调用驱动程序的关闭过程,然后释放索引节点,试对索引节点操作及驱动程序打开和关闭的顺序与打开和关闭一个块设备的顺序进行比较和说明
在buffer.c程序getblk()函数中,既然已经是对空闲缓冲队列操作,为什么还要判断缓冲区是否被引用?
有以下几种情况
如果在hash表队列中搜索指定设备号和逻辑块号的缓冲块是否已经存在,存在则返回头指针,否则从空闲链接进行扫描,寻找一个空闲缓冲块。在寻找的过程中还要对找到的空闲块作比对,根据赋予修改标志和锁定标志组合而成的权值,比较哪个空闲块合适
若没有找到空闲块,则让当前进程进入到睡眠状态,待继续执行时再次寻找;若该空闲块被锁定,则进程也进入睡眠,等待其他进程解锁
若在睡眠等待的过程中,该缓冲块又被其他进程占用,那么只从头开始搜索缓冲块;否则判断该缓冲块是否已被修改过,若是,则将该块写盘,并等待该块解锁
此时如果该缓冲块又被别的进程占用,那么又一次前功尽弃,只好从头执行getblk()
kernel中许多lock函数都调用了cli和sti,不知是为什么?是担心中断程序会捣乱吗?假有一个进程在内核空间获得了super锁,然后在bread()中等待,这时另一个进程也试图获得super锁,于是关了中断,这是否会导致死锁?
cli:关中断;sti:开中断
main.c中init函数,execve是如何处理当前进程映像的?返回值为2是出于什么考虑?
/** pathname: 文件路径
* argv:传入参数
* envp:环境变量
*/
int execve(const char *pathname, char *const argv[],
char *const envp[]);
/** eip:指向堆栈中调用系统中断的程序代码指针
* tmp:系统中断在调用sys_execve时的返回地址
* filename:文件路径
* argv:传入参数
* envp:环境变量
*/
int do_execve(unsigned long * eip,long tmp,char * filename,
char ** argv, char ** envp)
提示:所有的进程都是task0进程的子进程
execve()函数的主要功能为:
- 执行对命令行参数空间页面的初始化操作-设置初始空间指针;初始化空间页面指针数组为NULL,根据执行文件名取执行对象的i节点‘;计算参数个数和环境变量个数;检查文件类型、执行权限
- 根据执行文件开始部分的头数据结构,对其中信息进行处理
- 对当前调用进程进行运行新文件前初始化操作
- 替换堆栈上原execve()程序的返回地址为新执行程序运行地址,运行新加载的程序
在execve()执行过程中,系统会清掉fork()复制的原程序的页目录和页表项,并释放对应页面。系统仅为新加载的程序代码重新设置进程数据结构的信息,申请和映射了命令行参数和环境参数所占的内存页面,以及设置了执行代码和执行点。此时内核并不从执行文件所在块设备上加载程序和代码,然后开始需求加载(load on demand)
另外,由于新程序是在子进程中执行,所以该子进程就是新程序的进程。新进程的进程ID就是该子进程的进程ID
在程序开始执行前,命令行参数和环境字符串被放置在用户堆栈顶端的地方
函数do_execve()最后返回的时候会把原调用系统中断程序在堆栈上的代码指针eip替换为指向新执行程序的入口点,并将堆栈指针替换为新执行文件的栈指针esp
在高速缓冲管理程序buffer.c的getblk函数中,在检查一个块是否正处于忙状态之前,内核必须提高CPU的优先级以封锁中断,为什么?
MINIX文件系统以两个版本,1.0和2.0版,在Linux0.11内核中使用了他的1.0版,请指出这两个版本之间的主要区别,改进的主要作用是什么?
第八章
简要说明Intel处理器的内存分段机制和分页管理机制
不做概述
在memory.c程序中,size为什么要加上0x3fffff?
页目录位于物理地址0开始处,共1024项,占4KB,每个目录项指定一个页表,内核页表从物理地址0x1000处开始(紧接着目录空间),每个页表有1024项,也占4KB,每个页表项对应一个页物理内存(4KB)。目录项和页表项的大小均为4B。
而size指的是页目录项数(4MB的进位整数倍),即所占页表数,0x3fffff为4194303,为2的22次方减1
写时复制(copy on write)机制的工作原理是什么?为什么要这样做?
当两个进程对一个共享资源进行读的时候,不对共享资源进行修改,则两个进程共享一个资源
若当其中一个进程要对共享资源进行修改的时候,则该进程就应该对该共享资源进行一个复制再进行修改,此时这个资源独属于该进程。
在缺页异常处理时调用了do_no_page(),但在该函数调入页面并且修改了对应页表项后,并没有使用invalidate()函数来刷新CPU的页变换缓冲,这样做可以吗?为什么?
内核相关数据结构
高速缓冲区(池)
高速缓冲区位于内核代码和主内存区之间,整个高速缓冲区被划分为1024B大小的缓冲块,正好和块设备上的磁盘逻辑块大小一样。
- 高速缓冲区采用hash表和空闲缓冲队列进行操作管理
- 缓冲区的高端被划分为一个个1024B的缓冲块,地段则分别建立起对应缓冲块的缓冲头结构buffer_head,用于描述对应缓冲块的属性和所有缓冲头连接成链表
- 各个buffer_head被链接成一个空闲缓冲块双向链表结构
为了能快速在缓冲区中寻找请求的数据块是否已经被读入到缓冲区中,buffer.c程序使用了具有307个buffer_head指针项的hash结构。哈希函数为(设备号^逻辑块号)mod 307
其中free_list指向空闲链表
任务等待队列
主要发生在调度程序schedule中。这里主要讨论schedule()和sleep_on()函数。
sleep_on()函数主要功能是当一个进程(或任务)所请求的资源正忙或者不在内存中时暂时切换出去,放在等待队列中等待一段时间。当切换后再次运行。(放入等待队列的方式是利用了函数中的tmp指针作为各个正在等待任务的联系,主要涉及到三个指针*p-等待队列头指针(如文件系统内存i节点的i_wait指针、内存缓存操作中的buffer_wait指针等),tmp-临时指针和current-当前任务指针)
*p指针以i_wait为例,每个i节点都有一个i_wait字段,为task_struct *结构,表示等待该节点的进程指针
每个进程都会在栈上构建一个tmp指针
当刚进入该函数时,队列头指针* p指向已经在等待队列中等待的任务结构(进程描述符)(在系统刚开始执行的时,等待队列上午等待任务,此时*p指向NULL),通过指针操作,在调用调度程序之前,队列头指针指向了当前任务结构,而函数中的临时指针tmp指向了原等待任务。从而通过该临时指针的作用,在几个进程为等待同一资源而多次调用该函数时,程序就隐式构筑出一个等待队列。
slub链表
主要是针对于kmalloc()这个函数,kmalloc主要是用在设备驱动程序中动态开辟内存,释放内存使用的是kfree()(当然也可以直接使用get_free_page()函数),该函数申请的物理内存空间在3G-vmalloc_start之间。
kmalloc分配的内存在虚拟上是连续的,在物理上也是连续的,因为该“虚拟内存+offset”=物理内存,所以不用页表和页目录表来转换,也就是无序MMU
kmalloc()函数使用了存储桶的原理对分配的内存进行管理。使用存储桶目录分别进行管理,类比slub链表,将2B、4B……1024B等大小分配在不同的桶目录上,相同的则构成链表。
当从链表中取出或放入一个描述符都是从链表头开始操作;当取出一个描述符时,就将链表头指针所指向的头一个描述符取出;当释放一个空闲描述符时也是将其放在链表头处。
kmalloc()函数指向的基本步骤:
- 首先搜索目录,寻找合适请求内存块大小的目录项对应的描述符链表。当目录项的对象字节长度大于请求的字节长度,就算找到了相应的目录项,如果搜索完整个目录都没有找到合适的目录项,则说明用户请求的内存块太大(可以考虑vmalloc)
- 在目录项对应的描述符链表中查找具有空闲空间的描述符
- 将该描述符插入到对应目录项的描述符链表中。
此处略提,详情可见slub链表的实现原理。
自行可查kmalloc、vmalloc、remalloc、malloc、calloc的区别。
中断处理过程的细节
中断这部分是很神秘的,不是一句“用户态陷入内核态调用系统中断”便可以说明的,我下面来具体说一下他的全过程。
对于中断号int0-int31,每个中断的功能是由Intel固定设定或保留用的,属于软件中断,但Intel成为异常。他一般在初始化的时候执行trap_init()函数进行设置中断调用门
void trap_init(void)
{
int i;
// 设置除操作出错的中断向量值。
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
// 下面把int17-47的陷阱门先均设置为reserved,以后各硬件初始化时会重新设置自己的陷阱门。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
// 设置协处理器中断0x2d(45)陷阱门描述符,并允许其产生中断请求。设置并行口中断描述符。
set_trap_gate(45,&irq13);
set_trap_gate(39,¶llel_interrupt); // 设置并行口1的中断0x27陷阱门的描述符。
}
在Linux系统中,则将int32–int74对应于8259A中断控制器芯片发出的硬件中断请求信号IRQ0-IRQ15,并把程序编程发出的系统调用中断设置为int128(0x80)
outb_p(inb_p(0x21)&0xfb,0x21); // 允许8259A主芯片的IRQ2中断请求。
outb(inb_p(0xA1)&0xdf,0xA1); // 允许8259A从芯片的IRQ3中断请求。
在进程将控制权交给中断处理程序之前,CPU会首先将至少12B的信息压入中断处理程序的堆栈中,CPU还会吧代码段选择符和返回地址的偏移压入堆栈,另外,CPU还总是将标志寄存器EFLAGS的内容压入堆栈。
# | ------原SS |
# | --原ESP--- |
# | --EFLAGS- |
# | -------CS |
# | --EIP---- |
# |---出错号----|
asm.s代码文件主要涉及对Intel保留中断int0-int16的处理,其他的保留的中断int17-int31由Intel公司留作今后扩展使用。
对应于中断控制器芯片各IRQ发出的int32-int47的16个处理程序将分别在各个硬件初始化程序中处理。
在system_call.s中有一段程序
call _sys_call_table(, %eax, 4)
其中eax指的是在_sys_call_table中的系统调用号,也是索引号。
一般情况下,系统中断的触发方式有两种,一种是使用嵌入式宏汇编函数syscall0-6(type,name)(一般在unistd.h中),一种是用嵌入式宏汇编写的函数(如open.c中的open()函数)。
#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
一般会带着一个eax调用号和一个int0x80从而在跳转到system_call.s所指向的sys_call_table[i]位置(sys.h)根据该索引号即可找到相关的函数,而在sys.h中该函数是extern的,从而去找相关的文件即可。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid };
随着Linux内核版本的不断优化,系统调用的方式也加了一些中间层,但是总体思路没变。
你看,其实吧,中断调用也没什么。
内核态栈和用户态栈的变化
初始化阶段
(1)开机初始化时(bootsect.S,setup.s)
当bootsect代码被ROM BIOS引导加载到物理内存0x7c00处时,并没有设置堆栈段,当然程序也没有使用堆栈。直到bootsect被移动到0x9000:0处时,才把堆栈段寄存器SS设置为0x9000,堆栈指针esp寄存器设置为0xff00,即堆栈顶端在0x9000:0xff00处,参见boot/bootsect.s第61、62行。setup.s程序中也沿用了bootsect中设置的堆栈段。这就是系统初始化时临时使用的堆栈。
(2)进入保护模式时(head.s)
从esp设置成指向user_stack数组的顶端(参见user_stack数组定义在sched.c的67~23。此时该堆栈是内核程序自己使用的堆栈。其中给出的地址是大约值,它们与编译时的实际设置参数有关。这些地址位置是从编译内核时生成的system.map文件中查到的。
(3)初始化时(main.c)
在init/main.c程序中,在执行move_to_user_mode()代码把控制权移交给任务0之前,系统一直使用上述堆栈。而在执行过move_to_user_mode()之后,main.c的代码被“切换”成任务0中执行。通过执行fork()系统调用,main.c中的init()将在任务1中执行,并使用任务1的堆栈。而main()本身则在被“切换”成为任务0后,仍然继续使用上述内核程序自己的堆栈作为任务0的用户态堆栈
任务0的内核态堆栈是在其人工设置的初始化任务数据结构中指定的,而它的用户态堆栈是在执行move_to_user_mode()时,在模拟iret返回之前的堆栈中设置的。
在这个人工设置内容的堆栈中,原esp值被设置成仍然是user_stack中原来的位置值,而原ss段选择符被设置成0x17,即设置成用户态局部表LDT中的数据段选择符。然后把任务0代码段选择符0x0f压入堆栈作为栈中原CS段的选择符,把下一条指令的指针作为原EIP压入堆栈。这样,通过执行IRET指令即可“返回”到任务0的代码中继续执行了。(进程0有他自己的用户栈了)
块设备驱动程序
记住此时是存在VMA的,因此对于一个进程要把他看做他有着所有的内存空间来看!进程0和进程1一样。
写了一大篇软件部分,来了解一下硬件部分吧,块设备部分较为简单,我简要做一下介绍。
块设备一般指的是硬盘,软盘和内存。
当程序读取硬盘上的一个逻辑块的时,就会向缓存管理程序提出申请,而程序进程啧进入睡眠等待状态。
ll_rw_block()函数是通过请求项来与各种块设备建立联系并发出读写请求的。
struct blk_dev_struct {
void (*request_fn)(void); // 请求项操作的函数指针
struct request * current_request; // 当前请求项指针
};
当内核发出一个块设备读写或其他操作请求时,ll_rw_block()函数即会利用敌营的请求项操作函数do_XX_request()建立一个块设备请求项,并利用电梯算法插入到请求队列中。
每个块设备的当前请求指针与请求项数组中该设备的请求链表共同构成了该设备的请求队列
// 每个请求项的结构
struct request {
int dev; /* -1 if no request */
int cmd; /* READ or WRITE */
int errors;
unsigned long sector;
unsigned long nr_sectors;
char * buffer;
struct task_struct * waiting;
struct buffer_head * bh;
struct request * next;
};
来看看hd.c文件,主要有以下几个函数
- 初始化硬盘和设置硬盘所用数据结构的信息函数
- 向硬盘控制器发送命令的函数
- 处理硬盘当前请求项的函数
- 硬盘中断处理过程中调用的C函数
- 硬盘控制器操作辅助函数
// 硬盘初始化函数
void hd_init(void)
/**
* 在main.c文件的init()函数中有setup((void) &driver_info);
* driver_info = DRIVER_INFO
* #define DRIVER_INFO(* (struct driver_info *) 0x90080) 硬盘参数表基址
*/
int sys_setup(void * BIOS)
// 检测硬盘执行命令后的状态,返回0代表正常,1出错。如果执行命令错,啧再读错误寄存器HD_ERROR
static int win_result(void)
// 向硬盘控制器发送命令
static void hd_out(unsigned int drive,unsigned int nsect,unsigned int sect,
unsigned int head,unsigned int cyl,unsigned int cmd,
void (*intr_addr)(void))
// 判断并循环等待驱动器就绪
// 读硬盘控制器状态寄存器端口HD_STATUS(0x1f7),并循环检测驱动器就绪比特位和控制器忙位
static int controller_ready(void)
// 写三区中断调用函数,在硬盘中断处理程序中被调用
static void write_intr(void)
还有一些文件ll_rw_block.c,该函数主要的功能是为块设备创建块设备读写请求项,并插入到指定块设备请求队列中。实际的读写操作则是由设备的请求项处理函数request_fn()玩,对于硬盘操作,该函数是do_hd_request(),对于软盘操作,该函数是do_fd_request(),对于虚拟盘操作,该函数是do_rd_request()
// 用于请求数组没有空闲项时的临时等待处
struct task_struct * wait_for_request = NULL;
// 锁定指定的缓冲区bh,如果指定的花出去已经被其他任务锁定,则使自己睡眠(不可中断的等待),知道被执行解锁缓冲区的任务明确的唤醒
static inline void lock_buffer(struct buffer_head * bh)
// 释放(解锁)锁定的缓冲区
static inline void unlock_buffer(struct buffer_head * bh)
// 向链表中加入请求项
static void add_request(struct blk_dev_struct * dev, struct request * req)
// 创建请求项并插入请求队列
static void make_request(int major,int rw, struct buffer_head * bh)
// 底层读写数据块函数
void ll_rw_block(int rw, struct buffer_head * bh)
// 块设备初始化函数,在main.c中执行
void blk_dev_init(void)
字符设备驱动程序
如果说在Linux源码中最难以让人理解的是哪个,那便属文件系统了,次之呢?字符设备驱动程序当之无愧,其复杂性,着实让人看的眼花缭乱。此处只做一些简单概述,少涉及源码。
字符设备驱动程序的源码看起来很无聊,每个函数的大致功能差不多,且一般都是一些关于坐标偏移的函数
字符设备驱动程序主要分为三部分:串行线路驱动程序、控制台驱动程序和终端驱动程序。
终端驱动程序用于控制终端设备,在终端设备和进程之间传输数据,并对所传输的数据进行一定的处理。
每个终端设备都对应一个tty_struct数据结构,主要用来保存终端设备当前参数设置、所属的前台进程组ID和字符IO缓冲队列等信息。
struct {
struct termios termios; // 终端io属性和控制字符数据结构
int pgrp; // 所属进程组
int stopped; // 停止标志
void (*write)(struct tty_struct * tty); // tty写函数指针
struct tty_queue read_q; // tty读队列
struct tty_queue write_q; // tty写队列
struct tty_queue secondary; // tty辅助队列(存放规范模式字符序列)
};
extern struct tty_struct tty_table[]; // tty结构数组
Linux共支持三个终端设备,一个是控制台设备,另外两个是使用系统上两个串行端口的串行终端设备。
struct tty_queue {
unsigned long data; // 等待队列缓冲区中当前数据统计值
unsigned long head; // 缓冲区中数据头指针
unsigned long tail; // 缓冲区中数据尾指针
struct task_struct * proc_list; // 等待本缓冲区的进程列表
char buf[1024]; // 队列的缓冲区
};
每个字符缓冲队列的长度是1K字节。其中读缓冲队列read_q用于临时存放从键盘或串行终端的原始字符序列;写缓冲队列write_q用于存放写在控制台显示屏或串行终端去的数据;根据ICANON标志,辅助队列secondary用于存放从read_q中取出的经过行规则程序处理过的数据。
在读入用户键入的数据时,中断处理汇编程序只负责把原始字符数放入输入缓冲队列中,而由中断处理过程中调用的C函数copy_to_cooked()来处理字符的变换工作。
struct termios {
unsigned long c_iflag; // 输入模式标志
unsigned long c_iflag; // 输出模式标志
unsigned long c_iflag; // 控制模式标志
unsigned long c_iflag; // 本地模式标志
unsigned char c_line; // 线路规程(速率)
unsigned char c_cc[NCCS]; // 控制字符数组
};
// tty数据结构的tty_table数组,其中包含三个初始化项数据,分别对应控制台,串口终端1和串口终端2的初始化数据
struct tty_struct tty_table[] = {
//
}
// tty:指定的tty终端号
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table + tty)
}
// 一些宏函数在tty.h文件中实现,此处不作详细讲解
#define GETCH(queue, c) \
(void)({c=(queue).buf[(queue).tail];INC((queue).tail);})
void copy_to_cooked(struct tty_struct *tty) {
// 如果tty的读队列缓冲区不空并且辅助队列缓冲区为空,则循环执行以下代码
while (!EMPTY(tty_read_q) && !FULL(tty->secondary)) {
GETCH(tty->read_q,c); // 从队尾处取出一字符到c,并迁移尾指针
// 以下是对输入字符,利用输入模式标志集进行处理
//
if (L_ECHO(tty)) {
// 如果本地模式标志集中回显标志ECHO置位,那么一般情况下也把该字符放入tty写缓冲队列中
// 最后调用该tty的写操作函数
}
}
}
// tty读函数
// channel:子设备号
// buf:缓冲区指针
// nr:欲读字节数。此处间接的说明了在tty_struct结构中的缓冲区头指针和尾指针的作用,一个read不可能一下子把数据全部读走
int tty_read(unsigned channel, char *buf, int nr) {
// 读函数的作用主要是把数据从键盘或其他串行终端中读取字符
}
// tty写函数
int tty_write(unsigned channel, char *buf, int nr) {
// 写函数的作用主要是把数据写到控制台
}
内存映射
在使用命令strace
查看一个可执行文件执行的过程中,如下
execve("./a.out", ["./a.out"], 0x7ffec1035820 /* 29 vars */) = 0
...
mmap(NULL, 29607, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fe65a1b3000
mmap(NULL, 107592, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fe659fb4000
mmap(0x7fe659fb7000, 73728, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x3000) = 0x7fe659fb7000
mmap(0x7fe659fc9000, 16384, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x15000) = 0x7fe659fc9000
mmap(0x7fe659fcd000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18000) = 0x7fe659fcd000
close(3) = 0
mmap(NULL, 2037344, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fe659dc2000
mmap(0x7fe659de4000, 1540096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x22000) = 0x7fe659de4000
mmap(0x7fe659f5c000, 319488, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x19a000) = 0x7fe659f5c000
mmap(0x7fe659faa000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7fe659faa000
mmap(0x7fe659fb0000, 13920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fe659fb0000
可见在很多很多的地方都用到了一个内核函数mmap
,可见这个函数举足轻重,这个就是内存映射,下面来解析一下。
在《深入理解LINUX内核(第三版)》中有对内存映射的概念描述:
一个线性区可以和磁盘文件系统的普通文件的某一部分或者块设备文件相关联,这就意味着内核把对线性区中页内某个字节的访问转换为对文件中相应字节的操作。
在传统的对文件进行操作是通过write
和read
两个函数来操作的,先将数据放入高速缓冲区,然后对数据进行操作,再写回。但是这样只适合小的数据和文件,一旦有大文件,则需要足够强大的CPU和内存条。
mmap()会返回一个指针ptr,它指向进程逻辑地址空间中的一个地址,从而进程可以直接访问自身地址空间的虚拟地址来访问高速缓冲区的页。
read
和write
进行数据拷贝是两次(从硬盘到内核缓冲区,从内核缓冲区到用户缓冲区),而mmap()中没有进行数据拷贝,这是因为此时把对数据的操作看成是对进程地址空间数据的操作,此时如果发现没有该文件,则进行的是缺页中断,此时会将数据直接从硬盘映射到进程地址空间中,这相当于是一次内存映射,因此,内存映射的效率要比read/write效率高。
心得感悟和相关知识纠正
其实之前一直弄混了一个概念,误以为虚拟地址空间(即线性地址)是分段内存管理模型下的产物,因为他有段……其实不然,分段管理模型下假设了所有的段最大空间都是4G,而在保护模式下是段和段之间的访问受GDT和LDT以及IDT的中间层,加强了对段的保护(保护模式仅仅是对段的保护,而不是虚拟内存空间)。
之前一直弄不清GDT和LDT以及IDT在分页模型下存在的意义,既然不用为何还要加载?
其实线性地址,即虚拟地址空间是分页模型的产物。每个段是相对于可执行文件头部的偏移,然后经过页部件进行映射到物理内存。
现在用gcc编译一个源代码,得到的是一个线性地址,是分页模型的产物,一般情况下是先逻辑地址再线性地址的,而gnu使用了可重定位技术来获得线性地址,跳过了分段模型,相当于分段模型不用了。
那么为何在Linux内核中还是要加载GDT和IDT这些呢?因为在没开始分页模型的时候,即在setup.S中会开启CR0寄存器,这意味着开启了保护模式,加载GDT和LDT,并在GDT中设置了内核代码段描述符和内核数据段描述符,这样意义何在?为后来的head.S做准备。因为head.S是运行在保护模式下的,而保护模式下必须有GDT和LDT才能进行访问,head.S的作用是加载各个数据段寄存器,并重新设置IDT和GDT,然后开启分页机制。
可能有人觉得这里开了分页机制,那GDT和LDT又怎么在分页模式下使用呢?又进入了死循环问题?其实在构造内核映像文件中由三部分组成:boostsect、setup和system。其中system是通过gcc编译生成的,因此是线性地址,是可以直接通过线性地址来实现分页机制的,其后的可执行文件也可以通过可重定位技术来实现,而“绕过”屁事多的分段机制,换句话来说,这里的GDT和LDT没用了,纯属是为了保护模式下的摆设(可能在重定位技术出现之前还有用),IDT有没有用呢?其实对于中断,用户态(特权级为3)通过系统调用来到内核态(特权级为0),内核调用中断,所以这个特权级好像也没有太大意义了仿佛是,可以直接看成是原始的中断向量表吧。
一句话,保护模式为分段而生,但是在分页模式下把权限交给了MMU(页表中有访问位),保护模式这个嘘头仿佛也没有太大作用了,有点类似于长模式了,长模式也是有段描述符,但是段基址和偏移都是0,完全把自己交给分页机制(长模式必须打开分页模式)。