Linux Kernel Development 2nd
<<Linux Kernel Development 2nd>>的中文版<<Linux内核设计与实现>>读书笔记。
01 Linux内核简介
我们可以将处理器在任何指定时间点上的活动范围概况为下列三者之一:
- 运行于内核空间,处于进程上下文,代表某个特定的进程执行。
- 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断。
- 运行于用户空间,执行用户进程。
当CPU空闲时,内核就运行一个空进程,处于进程上下文,运行于内核空间。
Linux支持动态加载内核模块,支持对称多处理SMP机制,内核可以抢占(preemptive),内核不区分线程和一般的进程;Linux提供具有设备类的面向对象的设备模型,热插拔事件,以及用户空间的设备文件系统;
02 从内核出发
屏蔽掉无用的内核编译输出信息
$ make > /dev/null减少内核编译时间
在双处理器的机器上,使用如下命令 make -jN N = cpu数 * 2
$ make -j4
内核开发的特点:
内核编程时不能访问C库
内核编程时必须使用 GNU C
内核开发使用的C语言涵盖了ISO C99标准和GNU C扩展特性内核编程时缺乏像用户空间那样的内存保护机制
- 内核编程时浮点数很难使用(不要用)
- 内核只有一个很小的定长堆栈
- 由于内核支持异步中断,抢占和SMP,因此必须时刻注意同步和并发
- 要考虑可移植性的重要性
03 进程管理
fork, exit
内核把进程存放在叫做任务队列的双向循环链表中。链表的每一项都是类型为task_struct称为进程描述符的结构,该结构定义在
Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。
内核通过一个唯一的进程标识值或PID来标识每个进程。PID类型为pid_t,实际short int,最大值32768
进程描述符中的state域描述了进程的当前状态。系统中的每个进程都必然处于五种状态之一:
- TASK_RUNNING 进程是可执行的,它或者正在执行,或者在运行队列中等待执行
- TASK_INTERRUPTIBLE(可中断) 进程正在睡眠,等待某些条件的达成。一旦条件达成,内核就会把进程状态设置为可运行,处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
- TASK_UNINTERRUPTIBLE(不可中断) — 除了不会因为接收到信号而被唤醒从而投入运行外,这个状态与可中断状态相同。
- TASK_ZOMBIE(僵死) —- 该进程已经结束,但是其父进程还没有调用wait4()系统调用
- TASK_STOPPED(停止) — 进程停止执行,进程没有投入运行也不能投入运行。
内核经常需要调整某个进程的状态:使用set_task_state(task, state)
可执行程序代码是进程的重要组成部分。这些代码从可执行文件载入到进程的地址空间执行,一般程序在用户空间执行,当一个程序执行了系统调用或触发了某个异常,它就陷入了内核空间。
所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。init进程的进程描述符是作为init_task静态分配的
PID为0的进程为idle
struct task_struct task;
struct list_head list;
list_for_each(list, ¤t->children) {
task = list_entry(list, struct task_struct, sibling);
}
许多其他的操作系统都提供了产生进程的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。Unix将上述步骤分解到两个单独的函数中执行: fork() 和 exec()
Linux的fork使用写时拷贝(copy-on-write)页实现.Linux通过clone系统调用实现fork()。fork(), vfork()和__clone()库函数都根据各自需要的参数标志去调用clone(),然后clone()调用do_fork().do_fork完成了进程创建的大部分工作,定义在kernel/fork.c中。该函数调用copy_process()函数,然后让进程开始运行。
Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux吧所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程,相反,线程仅仅被视为一个和其它进程共享某些资源的进程。每个线程都拥有唯一属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程。
线程的创建和普通进程的创建类似,只是在调用clone()时传递一席参数标志来指明需要共享的资源: clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)
内核经常需要在后台执行一些操作。这种任务可以通过内核线程kernel thread完成—独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去,内核线程与普通进程一样,可以被调度,也可以被抢占。
kernel_thread
3.4 进程终结
进程的终止发生在它调用exit()后,主要由do_exit()完成,do_exit()调用schedlule()切换到其他进程,其实现在kernel/exit.c中。
在调用do_exit()之后,尽管线程已经僵死不能再运行,但是系统还保留它的进程描述符。这种可以在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。
wait()这一族函数都是通过唯一的系统调用wait4()实现。当最终需要释放进程描述符时,release_task()被调用。
04 进程调度
Linux提供了抢占式的多任务模式,在此模式下,由调度程序决定什么时候停止一个进程的运行以便其他进程能够得到执行机会。这个强制的挂起动作就叫做抢占(preemption),进程在被抢占之前能够运行的时间是预先设置好的,叫进程的时间片(timeslice)。
4.1 策略
策略决定调度程序在何时让什么进程运行。
O(1)
CFS
选定下一个进程并切换到它去执行是通过schedule()函数实现的。上下文切换,也就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.c中的context_switch函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule()就调用该函数。它完成两项基本工作:
- 调用定义在
中的switch_mm,该函数负责把虚拟内存从上一个进程映射切换到新的进程中 - 调用定义在
中的switch_to,该函数负责从上一个进程的处理器状态切换到新进程的处理器状态,包括保存,恢复栈信息和寄存器信息。
05 系统调用
系统调用在用户空间进程和硬件设备之间添加了一个中间层。主要作用有三个:
- 它为用户空间提供了一种硬件的抽象接口
- 系统调用保证了系统的稳定和安全
在Linux中,系统调用是用户空间访问内核的惟一手段,除异常和陷入外,它们是内核惟一的合法人口。
调用printf(应用程序)->printf(c库)->write(c库)->write系统调用(内核)
在linux中,每个系统调用被赋予一个系统调用号。这样,通过这个对一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用,进程不会提及系统调用的名称。
系统调用号相当关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中,它与体系结构有关,一般在entry.s中定义。这个表中为每一个有效的系统调用指定了惟一的系统调用号。
通知内核的机制是靠软中断实现的,通过引发一个异常来促使系统切换到内核态执行异常处理程序,此时的异常处理程序实际上就是系统调用处理程序。它与硬件体系结构紧密相关,通常在entry.S文件中用汇编语言编写。
因为所有的系统调用陷入内核的方式都一样,因此必须把系统调用号一并传给内核。在X86上,系统调用号是通过eax寄存器传递给内核的。
系统调用必须仔细检查它们所有的参数是否合法有效。最重要的一种检查是检查用户提供的指针是否有效。内核提供两个放来完成必须的检查和内核空间与用户空间之间数据的来回拷贝。内核无论何时都不能轻率接受来自用户空间的指针。这两个方法必须有一个被调用。为了向用户空间写入数据,内核提供copy_to_user(进程空间中的目的内存地址,内核空间内的源地址,需要拷贝的数据长度)及copy_from_user函数。
06 中断和中断处理程序
在响应一个特定中断的时候,内核会执行一个函数,叫做中断处理程序(interrupt handler) 或 中断服务例程(interrupt service routine, ISR)
中断处理程序与其他内核函数的真正区别在于: 中断处理程序是被内核调用来响应中断的,而它们运行于我们称之为中断上下文的特殊上下文中。
中断处理程序是上半部(top half)–接收到一个中断,立即开始执行,但只做有严格时限的工作;能够被允许稍后完成的工作会推迟到下半部(bottom half)去,在合适的时机,下半部会被执行。
int reuest_irq(unsigned int irq, irqreturn_t (handler)(int, void , struct pt_regs ), unsigned long irqflags, const char devname, void dev_id);
Linux中的中断处理程序是无需重入的。当一个给定的中断处理程序正在执行时,相应的中断线在所有处理器上都会被屏蔽掉,以防止在同一中断线上接收另一个新的中断。
CPU收到中断信号,立即停止当前的工作,关闭中断系统,然后跳到内存中预定义的位置开始执行代码。这个预定义的位置是由内核设置的,是中断处理程序的入口点。
在内核中,中断的旅程开始于预定义入口点,这类似于系统调用通过预定义的异常句柄进入内核。对于每条中断线,处理器都会跳到对应的一个惟一的位置,这样内核就可知道所接收中断的IRQ号了。初始入口点只是在栈中保存这个号,并存放当前寄存器的值,然后内核调用do_IRQ()
07 下半部和推后执行的工作
内核提供了三种不同形式的下半部实现机制: 软中断,tasklet和工作队列
另一个可以用于将工作推后执行的机制是内核定时器。不像其他3种机制,内核定时器把操作推迟到某个确定的时间段之后执行。
tasklet是通过软中断实现的,软中断代码位于kernel/softirq.c
软中断是在编译器间静态分配的,它不像tasklet那样能被动态地注册或去除。软中断由softirq_action结构表示,定义在
软中断处理程序的原型如下 void softirq_handler(struct softirq_action *)
一个注册的软中断必须在被标记后才会执行。通常,中断处理程序会在返回前标记它的软中断,使其在稍后被执行。于是,在合适的时刻,该软中断就会运行,下下列地方,待处理的软中断会被检查和执行:
- 从一个硬件中断代码处返回时
- 在ksoftirqd内核线程中
- 在那里显示检查和执行待处理的软中断的代码中
不管用什么办法,软中断都在do_softirq()中执行。
软中断保留给系统中对时间要求最严格以及最重要的下半部使用。目前,只有两个子系统-网络和SCSI-直接使用软中断。内核定时器和tasklet都是建立在软中断上的。
tasklet由tasklet_struct结构表示,在
使用 tasklet
- 声明tasklet
DECLARE_TASKLET(name, func, data)
DECLARE_TASKLET_DISABLED(name, func, data) - 编写tasklet处理函数
void tasklet_handler(unsigned long data); - 调度tasklet
tasklet_schedule(&my_tasklet);
ksoftirqd
每个处理器都有一组辅助处理软中断(和tasklet)的内核线程,当内核中出现大量软中断时,这些内核线程会辅助处理它们。每个处理器都有一个这样的线程,线程名字叫做ksoftirqd/n,n是处理器编号。
7.4 工作队列
工作队列(work queue)将工作推后,交由一个内核线程执行,它总是会在进程上下文执行,因此最重要的是工作队列允许重新调度甚至睡眠。
通常,在工作队列和软中断/tasklet中做出选择非常容易。如果推后执行的任务需要睡眠,则选择工作队列;否则选择软中断/tasklet。
默认的工作队列创建的内核线程叫做events/n,n是处理器编号,每个处理器对应一个线程。
使用work queue
- 创建推后的工作
DECLARE_WORK(name, void (func)(void), void *data); - 工作队列处理函数
void work_handler(void *data); - 对工作进行调度
schedule_work(&work);
如果进程上下文和一个下半部共享数据,在访问这些数据之前,需要禁止下半部的处理并得到锁的使用权。所做的这些都是为了本地和SMP的保护并且防止死锁的出现。
如果中断上下文和一个下半部共享数据,在访问这些数据之前,需要禁止中断并得到锁的使用权。所做的这些也是为了本地和SMP的保护并且防止死锁的出现。
08 内核同步介绍
critical section
race condition
synchronizatioin
用户程序之所以需要同步,是因为用户程序会被调度程序抢占和重新调度。内核中有类似可能造成并发执行的原因:
- 中断 —- 中断几乎可以在任何时刻异步发生,也就可能随时打断当前正在执行的代码。
- 软中断和tasklet —- 内核能在任何时候唤醒或调度软中断和tasklet,打断当前正在执行的代码
- 内核抢占 —- 因为内核具有抢占性,所以内核中的任务可能会被另一个任务抢占
- 睡眠及用户空间的同步 —- 在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程执行。
- 对称多处理器 —- 两个或多个处理器可以同时执行代码
辨认出真正需要共享的数据和相应的临界区,才是真正有挑战性的地方。记住,最开始设计代码的时候就要考虑加入锁,二是事后才想到。
CONFIG_SMP CONFIG_PREEMPT
预防死锁的简单原则:
- 加锁的顺序是关键。使用嵌套的锁时 必须 保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其它人也能照此顺序使用。
- 防止发生饥饿
- 不要重复请求同一个锁
- 越复杂的加锁方案越有可能造成死锁 — 设计应力求简单
09 内核同步方法
原子操作
原子整数 atomic_t 类型,原子操作 声明在
原子整数操作的最常见用途就是实现计数器: atomic_inc, atomic_dec自旋锁 spin lock
spin_lock(), spin_unlock()
自旋锁可以使用在中断处理程序中(此处不能使用信号量,因为它们会导致睡眠),在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断。
spin_lock_irqsave(), spin_unlock_irqrestore()读写自旋锁
rwlock_t
read_lock(), read_unlock()
write_lock(), write_unlock()
- 信号量
Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器能重获自由,从而去执行其他代码。当持有信号量的进程将信号量释放后,处于等待队列中的那个任务将被唤醒,并获得该信号量。
信号量的实现是与体系结构相关的,具体实现定义在文件
static DECLARE_SEMAPHORE_GENERIC(name, count)
sema_init(sem, count)
down_interruptible(struct semaphore *)
up(struct semaphore *)
- 读写信号量
rw_semaphore 定义在
static DECLARE_RWSEM(mr_rewsem);
down_read(&mr_rwsem);
up_read(&mr_rwsem);
在中断上下文中只能使用自旋锁,而在任务睡眠时只能使用信号量。
- 完成变量
如果在内核中一个任务需要发出信号通知另一个任务发生了特定事件,利用完成变量(completion variable)是使两个任务得以同步的简单方法。
完成变量由结构completion表示,定义在
DECLARE_COMPLETION(mr_comp);
init_completion()
wait_for_completion()
complete()
9.10 顺序和屏障
当处理多处理器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。编译器和处理器为了提高效率,可能对读和写重新排序,所有可能重新排序和写的处理器提供了机器指令来确保顺序要求。同样也可以指示编译器不要对定点周围的指令序列进行重新排序。这些确保顺序的指令称作屏障(barrier)
rmb(), wmb(), mb()
10 定时器和时间管理
周期性产生的事件,都是由系统定时器驱动的。系统定时器是一种可编程硬件芯片,它能以固定频率产生中断。该中断就是所谓的定时器中断,它所对应的中断处理程序负责更新系统时间,还负责执行需要周期性执行的任务。
系统定时器以某种频率自行触发时钟中断,该频率可以通过编程设定,称作节拍率tick rate.
系统定时器频率(节拍率)是通过静态预处理定义的,也就是HZ(赫兹),在系统启动时按照HZ值对硬件进行设置。体系结构不同,HZ的值也不同。内核在文件
内核中的全部时间概念都来源于周期运行的系统时钟。所以选择一个合适的频率,就如同在人际交往中建立和谐关系一样,必须取得各方面的折中。
提高节拍率提升系统精度同时,也带来负作用,节拍率越高,意味着时钟中断频率越高,也就意味着系统负担越重。因为处理器必须花时间来执行时钟中断处理程序,所以节拍率越高,中断处理程序占用的处理器的时间越多。这样不但减少了处理器处理其他工作的时间,而且还会更频繁地打乱处理器高速缓存。
全局变量 jiffies 用来记录自系统启动以来产生的节拍的总数。启动时,内核将该变量初始化为0,此后,每次时钟中断处理程序都会增加该变量的值。因为一秒内时钟中断的次数等于HZ,所以jiffies一秒内增加的值也就是HZ。系统运行时间以秒为单位计算,就等于jiffies/HZ。
jiffies定义于文件
extern unsigned long volatile jiffies;
硬时钟和定时器
体系结构提供了两种设备进行计时 —- 系统定时器和实时时钟
实时时钟(RTC)是用来持久存放系统时间的设备。
中断服务程序主要通过调用与体系结构无关的历程do_timer()执行下面的工作:
- 给jiffies_64变量增加1
- 更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间
- 执行已经到期的动态定时器
- 执行scheduler_tick()函数
- 更新wall time
- 计算平均负载值
定时器,动态定时器或内核定时器
定时器的使用,需要执行一些初始化工作,设置一个超时时间,指定超时发生后执行的函数,然后激活定时器就可以了。指定的函数将在定时器到期时自动执行。注意定时器并不周期运行,它在超时后就自行销毁。
定时器由结构time_list表示,定义在文件
struct timer_list my_timer;
init_timer(&my_timer);
my_tiemr.expires = jiffies + delay;
my_time.data = 0;
my_timer.function = my_function
add_timer(&my_timer);
使用mod_timer(&my_timer, jiffies + new_delay)更改已经激活的定时器超时时间
10.8 延迟执行
内核代码(尤其是驱动程序)除了使用定时器或下半部机制(软中断,tasklet, work queue)以外还需要其它方法来推迟执行任务。这种推迟通常发生在等待硬件完成某些工作时,而且等待的时间往往非常短。
忙等待
忙循环实现起来很简单,在循环中不断旋转直到希望的时钟节拍数耗尽。该方法仅仅在想要延迟的时间是节拍的整数倍或精确率要求不高时才可以使用。
unsigned long delay = jiffies + HZ 2; / 2秒 */
while(time_before(jiffies, delay))
;短延迟
内核提供两个可以处理微妙和毫秒级别的延迟的函数,它们定义在
void udelay(unsigned long usecs); // 微妙
void mdelay(unsigned long msecs); // 毫秒 millisecond
l 秒 = 1000 毫秒
1 毫秒 = 1000 微秒
udelay()函数依靠执行数次循环达到延迟效果,而mdelay()函数又是通过udelay()函数实现的。因为内核知道处理器在一秒内能执行多少次循环(BogoMips),所以udelay()函数仅仅需要根据指定的延迟时间在1秒钟占的比例,就能决定需要进行多少次循环就能达到要求的推迟时间。
BogoMIPS并不是为了表示为了表现你的机器性能,它主要被udelay()函数和mdelay()函数使用。BogoMIPS值记录处理器在给定时间内忙循环执行的次数。该值存放在变量loops_per_jiffy中,可以从文件/proc/cpuinfo中读到,延迟循环函数使用loops_per_jiffy值来计算为提供精确延迟而需要进行多少次循环。
内核在启动时利用calibrate_delay()计算loops_per_jiffy值,该函数在文件init/main.c中.
- schedule_timeout()
11 内存管理
page
内核把物理页作为内存管理的基本单元。page结构与物理页相关,与虚拟页无关。该数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据。zone
由于硬件的限制,内核并不能对所有的页一视同仁。有些页位于内存中特定的物理地址上,所以不能将其用于一些特定的任务。由于存在这种限制,所以内核将页划分为不同的区(zone)。Linux 必须处理如下两种由于硬件存在缺陷而引起的内存寻址问题:
- 一些硬件只能用某些特定的内存地址来执行DMA
- 一些体系结构其内存的物理寻址范围比虚拟寻址范围大得多,这样,就有一些内存不能永久地映射到内核空间。
Linux使用三种区来解决此问题:
- ZONE_DMA —- 这个区包含的页能用来执行DMA操作
- ZONE_NORMAL —- 这个区包含能正常映射的页
- ZONE_HIGHMEM —- 该区的页不能永久地映射到内核地址空间
alloc_page, free_page
kmalloc 用它获得以字节为单位的一块内核内存,在
void *kmalloc(size_t size, int flags);
该函数返回一个指向内存块的指针,其内存块至少要有size大小,所分配的内存区在物理上是连续的。
GFP_ATOMIC 分配是高优先级的,且不会睡眠,用在中断处理程序,下半部等不能睡眠的地方
GFP_KERNEL 常规分配方式,可能会阻塞
void kfree(const void ptr);
vmalloc函数的工作方式类似于kmalloc,只是前者分配的内存虚拟地址是连续的,而物理地址则无需连续。
大多数情况下,只有硬件设备需要得到物理地址连续的内存。在很多体系结构上,硬件设备存在于内存管理单元之外,它根本不理解什么是虚拟地址,因此硬件设备用到的任何内存区都必须是物理上连续的块,而不仅仅是虚地址连续的块。
vmalloc仅在为了获得大块内存时使用。
11.6 slab层
如果需要创建和销毁很多较大的数据结构,应考虑建立slab高速缓存。slab层会给每个处理器维持一个对象高速缓存(空闲链表),这种高速缓存会极大地提高对象分配和回收的性能。slab层布什频繁地分配和释放内存,而是把事先分配好的对象存放到高速缓存中,当需要一块新的内存来存放数据结构时,slab层一般无需另外去分配内存,而只需要从高速缓存中得到一个对象即可。
在任意一个函数中,必须尽量节省栈资源。在栈上进行大量静态分配,比如分配大型数组和大型结构体,是很危险的。
12 虚拟文件系统
Unix使用四种和文件系统相关的传统抽象概念:文件,目录项,索引节点和安装点(mount point)
Unix系统将文件的相关信息和文件本身这两个概念加以区分,例如访问权限,大小,拥有者,创建时间等信息。文件相关信息,有时也被称为文件的元数据,被存储在一个单独的数据结构中,该结构被称为索引节点(inode)。
所有这些信息都和文件系统的控制信息密切交融,文件系统的控制信息存储在超级块中,超级块是一种包含文件和系统信息的数据结构。
VFS其实采用的是面向对象的设计思路,使用一族数据结构来代表通用文件对象。
VFS中有四个主要的对象类型,同时每个对象都包含一个操作对象
超级块对象,代表一个已安装文件系统
各种文件系统都必须实现超级块,该对应用于存储特定文件系统的信息
super_block 结构体描述
操作对象:super_operations对象,其中包括内核针对特定文件系统所能调用的方法,比如read_inode()和sync_fs()等方法索引节点对象,代表一个文件
索引节点对象包含内核在操作文件或目录时需要的全部信息
inode结构体描述。一个索引节点代表文件系统中(虽然索引节点仅当文件被访问时才在内存中创建)的一个文件,它也可以是设备或管道这样的特殊文件。
操作对象: inode_operations对象,包括内核针对特定文件所能调用的放, 比如create()和link()等方法目录项对象,代表一个目录项,是路径的一个组成部分
操作对象: dentry_operations对象,包括内核针对特定目录所能调用的方法,比如d_compare()和d_delete()方法
dentry 结构描述,VFS把目录当作文件看待。虽然它们可以统一由inode表示,但是VFS经常需要执行目录相关的操作,为了方便查找操作,VFS引入了目录项的概念。每个dentry代表路径中的一个特定部分。不想super_block和inode,dentry没有对应的磁盘数据结构,VFS根据字符串形式的路径名即时创建它。而且由于dentry对象并非真正保存在磁盘上,所以目录项结构体没有是否被修改的标志。
- 文件对象,代表由进程打开的文件
操作对象: file_operations,包括进程针对已打开文件所能调用的方法,比如read()和write()方法
file对象是已打开的文件在内存中的表示。该对象由相应的open()系统调用创建,由close()系统调用销毁。
file -> dentry -> inode
13 block i/o 层
如果一个硬件设备是以字符流的方式被访问的话,那就应该将它归于字符设备,反过来,如果一个设备是随机访问的,那么它就属于块设备。
考虑到块设备的复杂性及性能要求,内核提供了专门的子系统,称为block i/o层
块设备中最小的可寻址单元是扇区,扇区的大小是设备的物理属性,一般是512字节。
block是文件系统的一种抽象,只能基于block来访问文件系统。
每一个块I/O请求都通过一个 bio 结构体描述。每个请求包含一个或多个块,这些块存储在bio_vec结构体数组中,该结构体描述了每个片段在物理页中的实际位置,并且像向量一样被组织在一起。
如果简单地以内核产生请求的次序直接将请求发向块设备的话,性能肯定让人难以接受。磁盘寻址是整个计算机最慢的操作之一,每一次寻址需要花费不少时间,所以尽量缩短寻址时间无疑是提高系统性能的关键。
为了优化寻址操作,内核既不会简单地按请求接收次序,也不会立即将其提交给磁盘。相反,它会在提交前,先执行 合并和排序 的预操作,这种预操作可以极大地提高系统的整体性能。在内核中负责提交I/O请求的子系统称为I/O调度程序。
I/O调度程序的工作是管理块设备的请求队列,它决定队列中的请求排列顺序以及在什么时刻派发请求到块设备。
I/O调度程序算法
- Linus 电梯
- deadline i/o 调度程序
- 预测I/O调度程序
- 完全公平的排队I/O调度程序(Complete Fair Queuing)
- Noop
Noop不执行排序,只执行合并操作.
Noop I/O调度程序位于drivers/block/noop-iosched.c,专门为随机访问设备而设计
在启动时,可以通过命令行选项elevator=来启动相应的I/O调度程序
预测 as
完全公平 cfq
最终期限 deadline
空操作 noop
14 进程地址空间
内核使用内存描述符结构体mm_struct来表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息,定义在
struct mm_struct {
struct vm_area_struct mmap;
pgd_t pgd;
unsigned long start_code; / 代码段开始地址 /
unsigned long end_code; / 代码段结束地址 /
unsigned long start_data; / 数据段开始地址 /
unsigned long end_data; / 数据段结束地址 /
unsigned long start_brk; / 堆开始地址 /
unsigned long brk; / 堆结束地址 /
unsigned long start_stack;/* 进程栈首地址 */
};
可以使用/proc文件系统和pmap工具查看给定进程的内存空间和其中所含的内存区域。
页表
Linux使用三级页表完成地址转换,利用多级页表能够节约地址转换需占用的存放空间。
顶级页表是页全局目录(pgd),二级页表是中间页目录(pmd),最后一级pte,指向物理页面。
多数体系结构中,搜索页表的工作是由硬件完成的。
15 页高速缓存和页回写
页高速缓存是Linux内核实现的一种主要磁盘缓存,主要用来减少对磁盘的I/O操作。具体的说,通过把磁盘中的数据缓存到物理内存总,把对磁盘的访问变为对物理内存的访问。
pdflush Daemon
由于页高速缓存的缓存作用,写操作实际上会被延迟。当页高速缓存中的数据比后台存储的数据更新时,那么该数据就被称为脏数据,在内存中累计起来的脏页最终必须被写回磁盘。在以下两种情况发生时,脏页被写回磁盘:
- 当空闲内存低于一个特定的阀值时,内核必须将脏页写回磁盘,以便释放内存。
- 当脏页在内存中驻留时间超过一个阀值时,内核必须将超时的脏页写回磁盘,以确保脏页不会无限期驻留在内存中。
可以在/proc/sys/vm中设置相关回写参数
pdflush线程的实现代码在文件mm/pdflush.c中,回写机制的实现代码在文件mm/page-writeback.c和fs/fs-writeback.c中
16 模块
make -C /kernel/source/location SUBDIRS=$PWD modules
定义模块参数
module_param(name, type, perm);
17 kobject与sysfs
Linux设备模型的核心是kobject结构体,定义在文件
struct kobject {
const char name;
struct kref ref;
struct list_head entry;
struct kobject parent;
struct kset kset;
struct kobj_type ktype;
};
编写可移植的代码需要考虑许多问题:字长,数据类型,对齐,字节序,也大小,时间(HZ)
跟选择一个唯一确定的风格相比,选择什么样的风格反而显得不是那么重要。
Linux的编码规范记录在Documentation/CodingStyle中
内核使用八个字符长度的制表符(TAB)。
命名规范
名称中不允许使用混合的大小字符。匈牙利命名法(在变量名称中加入变量的类别)危害极大,绝对不允许使用。
/**
- @
- @
/
不应该使用
#ifdef CONFIG_FOO
foo();
#endif
而是采用在CONFIG_FOO没有定义的时候让foo()函数为空
#ifdef CONFIG_FOO
static int foo(void)
{
/ .. /
}
#else
static int foo(void) { }
#endif
这样的好处是免得#ifdef … #else … #endif到处泛滥。
创建补丁:
diff -urN linux-x.y.z/ linux/ > my-patch
应用补丁
patch -p1 < ../my-patch
单向链表,双向链表,双向环形链表
Linux内核的标准链表就是采用环形双向链表形式实现的。链表结构定义在文件
struct list_head {
struct list_head next;
struct list_head prev;
};