《Linux内核修炼之道》 笔记

分析Linux内核最好的入手方式就是 各个目录下的 KconfigMakefile去寻找目标代码,就像地图去寻找目的地一样

 

例如我们打算研究U盘驱动,因为U盘是一种storage设备,首先进入 /drivers/usb/storage

Kconfig CONFIG_USB_STORAGE宏 ,然后看Makefile关联哪些文件。

 

USB 子系统位于drivers/usb目录下, core目录放一些核心代码比如初始化整个USB系统,初始化Root Hub host目录是 初始化主机控制器的代码,

 

主机系统的USB驱动程序控制插入其中的USB设备,而USB gadget的驱动程序控制外围设备如何作为一个USB设备和主机通信,如SD卡。

 

HCD:主机控制器

 

/drivers/usb/core/usb.c USB子系统的的实现入口。

 

Usbcore 这个模块它代表的不是某一个设备,而是所有USB设备赖以生存的模块,Linux中,像这样一个类别的设备驱动被归结为一个子系统。比如PCI子系统,SCSI子系统,基本上drivers

目录下的第一层都算一个子系统。

Subsys_initcall(usb_init) 子系统初始化函数

 

__init标记 ,表明这个函数只在初始化期间使用,在模块加载后它占用的资源就会被释放掉。

__attribute是什么,是GUN C的扩展,对代码的优化和目标代码布局,安全检查方面提供支持,GUN C有十几个这个样的属性,

Subsys_initcall 等宏的定义在include/linux/init.h

各个目标文件.o 被链接成一个可执行文件是有arch// 下的vmlinux.lds 这个链接脚本去完成,它负责链接内核的各个节并将它们装入内存中的特定的偏移地址。

 

.initcall1.init .initcall2.init .initcall3.init .....  这些初始化都是有顺序的,

 

 

内核的初始化代码是 init/main.c 中。 start_kernel

 

关于usb_init 函数,nousb这个标志 可以通过内核的配置去改变启动的时候是否去掉USB子系统

 

 

内核启动的时候传入参数,可以是内核参数也可以加入模块参数,如果是内核参数 参数=

如果是模块参数 模块名.参数=

 

例如命令行中 modprobe usbcore autosuspend =2  ,对应到kernel行就是 usbcore.autosuspend=2

命令 modinfo  -p ${modulename} 可以得知一个模块有哪些参数可以使用。

对于已经加入到内核里的模块,它们的模块参数会列举在 /sys/module/${modulename}/parameters/目录下面。可以通过echo -n ${value} > /sys/module/${modulename}/parameters/${parm}去修改

 

 

设备模型:

 

总线,设备,驱动。Busdevicedriver。 内核里面都有自己的结构,include/linux/device.h

 

structdevice中有两个成员struct bus_type * bus和struct device_driver *driver 

 

structdevice_driver中有两个成员structbus_type*bus

 

structbus_type 中的 drivers_ksetdevices_kset

 

Struct device 中的bus表示这个设备连接到哪个总线上,driver表示这个设备的驱动是什么,

 

Struct device_driver 中的bus表示这个驱动属于哪个总线

Struct bus_type 中的 devicedriver表示这个总线拥有哪些设备和哪些驱动

 

Kobjectklist存在的意义就是把总线,设备,驱动这样的对象链接到设备模型上,

整个linux的设备模型就是一个OO的体系结构,总线,设备,驱动都是鲜活的对象,kobject是他们的基类,所实现的只是一些公共的接口,kset是同种类型kobject对象的集合, 是链表实现,struct bus_type 结构中的devicedriver表示一个总线拥有两条链表,我们就可以知道了这条总线关联了多少设备,又有哪些驱动来支持这些设备。

 

内核要求每次出现一个设备就要向总线汇报,或者说注册,,每次出现一个驱动,也要向总线汇报,或者说注册,比如系统初始化的时候,会扫描连接了哪些设备,并为每一个设备建立起一个 structdevice 的变量,每一次有一个驱动程序,就要准备一个 structdevice_driver 结构的变量。把这些变量统统加入相应的链表,device 插入 devices 链表,driver 插入 drivers 链表。 这样通过总线就能找到每一个设备

每当一个 structdevice 诞生,它就会去 bus 的 drivers链表中寻找自己的另一半,反之,每当一个一个 struct device_driver 诞生,它就去 bus的 devices 链表中寻找它的那些设备。如果找到了合适的,那么 OK

 

那么根据上面的来看,总线,设备,驱动就已经关联起来了。

 

 

在系统中 lsmod 看一下 有一个 usbcore ,基本上电脑上USB 设备两个模块是必须的,usbcore还有就是主机控制驱动程序,比如usbcore1492884usbhid,ehci_hcd,uhci_hcd

你的USB要工作,合适的USB主机控制器模块也是不可少的

 

 

usbcore 负责实现一些核心的功能,为别的设备驱动程序提供服务,提供一个用于访问
和控制 USB 硬件的接口,而不用去考虑系统当前存在哪种主机控制器。至于 core、主机控制器和 USB 驱动三者之间的关系

 

 

 

   USB 设备驱动

   Usb core

   USB主机控制器

   USB 设备

 

USB 驱动和主机控制器就像 core 的两个保镖,协议里也说了,主机控制器的驱动
HCD)必须位于 USB 软件的最下一层。HCD 提供主机控制器硬件的抽象,隐藏硬件的
细节,在主机控制器之下是物理的 USB 及所有与之连接的 USB 设备。而 HCD 只有一个客户,对一个人负责,就是 usbcore。usbcore 将用户的请求映射到相关的 HCD,用户不能直接访问 HCD。core 为咱们完成了大部分的工作,因此咱们写 USB 驱动的时候,只能调用 core 的接口,core 会将咱们的请求发送给相应的 HCD。

 

USB子系统与设备模型 

 

关于设备模型,最主要的问题就是,bus、device、driver 是如何建立联系的?换言之,
这三个数据结构中的指针是如何被赋值的?绝对不可能发生的事情是,一旦为一条总线申请了一个 structbus_type 的数据结构之后,它就知道它的 devices 链表和 drivers 链表会包含哪些东西,这些东西一定不会是先天就有的,只能是后天填进来的。
具体到 USB 子系统,完成这个工作的就是 USB core。USB core 的代码会进行整个
USB 系统的初始化,比如申请 structbus_typeusb_bus_type,然后会扫描 USB 总线,看线上连接了哪些 USB 设备,或者说 RootHub 上连了哪些 USB 设备,比如说连了一个 USB键盘,那么就为它准备一个 structdevice,根据它的实际情况,为这个 structdevice 赋值,并插入 devices 链表中来。
又比如 Root Hub 上连了一个普通的 Hub,那么除了要为这个 Hub 本身准备一个
structdevice 以外,还得继续扫描看这个 Hub 上是否又连了别的设备,有的话继续重复之前的事情,这样一直进行下去,直到完成整个扫描,最终就把 usb_bus_type 中的 devices链表给建立了起来。
那么 drivers 链表呢?这个就不用 bus 方面主动了,而该由每一个 driver 本身去 bus上面登记,或者说挂牌。具体到 USB 子系统,每一个 USB 设备的驱动程序都会对应一个structusb_driver 结构,其中有一个 structdevice_driverdriver 成员,USBcore 为每一个设备驱动准备了一个函数,让它把自己的这个 struct device_driver driver 插入到usb_bus_type 中的 drivers 链表中去。而这个函数正是我们此前看到的 usb_register。而与之对应的 usb_deregister 所从事的正是与之相反的工作,把这个结构体从 drivers 链表中删除

 

 

 

 

 

Linux 内核设计与实现》 简称 LKD

深入理解 Linux 内核》简称 ULK,相比于 LKD 的内容不够深入、覆盖面不广,ULK 要深入全面得多。

Linux 设备驱动程序》简称 LDD,驱动开发者都要人手一本了。

《深入理解 LINUX 网络内幕》

《深入理解 Linux 虚拟内存管理》推荐看英文版

usb 的可以看我们的《Linux那些事儿》

 

 

 

structlist_head{
22 structlist_head*next,*prev;
23};

 

include/linux/list.h 文件

由此可见,内 核中的链表实际上都是双链表(通常都是双循环链 表)

 

 

内核选项 Linux 允许用户传递内核配置选项给内核, 内核在初始化过程中调用 parse_args 函数对这些选项进行解析,并调用相应的处理函数。 parse_args 函数能够解析形如“变量名=值”的字符串,在模块加载时,它也会 被调用来解析模块参数

 

 

内核选项的使用格式同样为“变量名=值”,打开系统的 grub 文件,然后找到 kernel 行,比如:
83
 kernel/boot/vmlinuz-2.6.18root=/dev/sda1rosplash=silent
vga=0x314pci=noacpi 其中的“pci=noacpi”等都表示内核选项。 内核选项不同于模块参数,模块参数通常在模块加载时通过“变量名=值”的形 式指定,而不是内核启动时。如果希望在内核启动时使用模块参数,则必须添 加 模块名做为前缀,使用“模块名.参数=值”的形式,比如,使用下面的命令在加 载 usbcore 时指定模块参数 autosuspend 的值为 2。  $modprobeusbcoreautosuspend=2 若是在内核启动时指定,则必须使用下面的形式: 

 

注册内核选项 我们也不必理解 parse_args 函数的实现细节。但我们必须知道如何注册内核选项:模块参数使 module_param 系列的宏注册内核选项则使用__setup 宏来注册 __setup 宏在 include/linux/init.h 文件中定义。
171#define__setup(str,fn)\
172 __setup_param(str,fn,fn,0) __setup 需要两个参数,其中 str 是内核选项的名字,fn 是该内核选项关联的处 理函数。__setup 宏告诉内核,在启动时如果检测到内核选 项 str,则执行函数

不同的内核选项可以关联相同的处理函数,比如内核选项 netdev 和 ether 都关 联了 netdev_boot_setup 函数。 除了__setup 宏之外,还可以使用 early_param 宏注册内核选项。它们的使用方式相同,不同的是,early_param 宏注册的内核 选项必须要在其他内核选项之前被处理。

 

 

两次解析 相应于__setup 宏和 early_param 宏两种注册形式,内核在初始化时,调用了 两次 parse_args 函数进行解析。
parse_early_param();
parse_args("Bootingkernel",static_command_line,__start___param,
__stop___param-__start___param,
 &unknown_bootoption); parse_args 的第一次调用就在 parse_early_param 函数里面,为什么会出现两 次调用 parse_args 的情况?这是因为内核选项又分成了两种,就像现实世界中 的我们,一种是普普通通的,一种是有特权的,有特权的需要在普通选项之前进行处理。

 

 

 

.initcall2.init 子节中的两个函数 pcibus_class_init 和 pci_driver_init。现在问题出现了

对于处于同一子节中的那些函数,比如 pcibus_class_init 和 pci_driver_init 这两个函数来说又是哪个会最先被调用?

 

对于 pcibus_class_init 函数和 pci_driver_init 函数这样位于同一目录位置的可 以通过该目录 Makefile 文件指定 的链接顺序来判断

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Linux内核设计与实现》

 

http://www.kernel.org

 

 

编译内核:

 

编译linux 内核 make config 这个命令行字符工具会逐一遍历所有的配置选项要求用户选择yes,no,或者是module

 

Make menuconfig 是基于ncurse库编制的图形界面工具

或者是 make gconfig 基于gik+的图形工具

 

配置文件是根目录下.config

 

 

CONFIG_IKCONFIG_PROC 配置选项把完整的压缩过程的内核配置文件放到/proc/config.gz下,这样当你编译一个新的内核的时候就可以很方便的克隆当前的配置,如果你目前的内核已经启动了此选项,就可以从/proc下复制出配置文件并且使用它来编译一个新内核。

$ zcat /proc/config.gz > .config
$ make oldconfig

 

 

Make 程序能够把编译过程拆分成多个作业,make -jn 能够有效的运用多处理器

N 代表作业的个数,一般一个处理器衍生一个或者两个作业。

 

 

安装:

 

要和体系结构以及bootloader启动引导工具

 

 

 

 

 

 

 

 

 

 

 

 

 

 

内核编程不依赖任何C标准库

内核编程必须使用GUN C

内核编程难以执行浮点操作

内核编程没有像用户空间那样的内存保护机制

内核给每个进程只有一个很小的定长堆栈

内核支持异步中断,抢占和SMP,必须注意同步和并发

 

 

内核源码树中的内核基本头文件include/linux/xxxx.h 已经实现了大部分常用的C库函数

体系结构相关的头文件 arch/xxx/include/xxx

 

Printk()函数负责把格式化好的字符串拷贝到内核日志缓冲区上,这样syslog程序就可以通过读取该缓冲区来获取内核信息,printk()允许你通过指定一个标志来设定优先级,syslogd会根据这个优先级标志来决定在什么地方显示这条系统消息。

 

GCC 是GUN编译器的集合,包含的C编译器编译内核,也可以编译linux系统上用的C语言写的其他代码。

 

C99 和GUN C 均支持内联函数,定义一个内联函数需要使用static作为关键字,一般在头文件定义内联函数,内核中为了安全和易读性,优先使用内联函数而不是宏。

 

GCC 支持在C函数中嵌入汇编指令,只有知道体系结构才能用对应的

 

如果一个应用程序试图非法访问内存,那么内核发现后会结束整个进程,但是如果内核发生了内存错误导致oops,可能会死掉,此外内核中用的内存是每用掉一个字节物理内存就少一个字节。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

进程:

 

一个进程至少有一个线程,

现代操作系统中,进程提供了两种虚拟机制:虚拟处理器和虚拟内存。

 

Linux系统中进程一般由fork()系统调用去创建,该系统调用通过复制一个现有的进程来创建一个新的进程。调用fork的进程为父进程,产生的新进程为子进程,在调用结束时,在返回点这个相同的位置上,父进程恢复执行,子进程开始执行,fork()从内核返回两次:一次是回到父进程,一次是回到产生的新进程。

通常情况下,创建的新的进程都是为了立即执行新的程序,接着就调用exec()这组函数就可以创建新地址空间,并把程序载入其中,现代操作系统中,fork实际上是由clone()系统调用实现的。

 

最终程序会调用exit()系统调用退出执行,这个函数会终结进程并将其占用的资源释放掉,

 

 

进程的另一个名字是 task (任务),内核中这么叫。

 

进程内核中描述符和结构:

 

头文件 linux/sched.h中定义 task_struct (进程描述符)

内核把进程的列表存放在叫做任务队列 task list 的双向链表

进程描述符能够完整的描述一个正在执行的程序,它打开的文件,进程的地址空间,挂起的信号,进程的状态等等。

 

Linux 通过slab分配器分配task_struct 结构,由于slab动态的分配,所以只需要在栈底或者栈顶创建一个新的结构体 struct thread_info

 

内核中通过PID 进程标示值来标识每一个进程。

可以通过 /proc/sys/kernel/pid_max 来修改最大PID 的值

 

 

进程的状态

运行:

可中断:

不可中断:

被其他进程跟踪的进程:

停止:

 

 

Set_task_state(task,state) 设置进程的状态

Set_current_state(state) =  set_task_state(current,state)

每一个进程都有一个父进程,init 为1 的进程除外,所以的进程都是init 这个进程的后代。拥有同一个父进程的叫做兄弟,每个task_struct 都包含一个指向父进程的task_struct 叫做parent。还有包含children子进程的链表

 

 

Linux 系统中的fork()调用使用写时复用技术(copy-on-write)内核此时并不复制整个进程地址的空间,而是让父进程和子进程共享同一个拷贝,fork实际开销就是复制父进程的页表和给予子进程唯一的标识符。

 

Linux 是通过clone() 系统调用去实现fork() ,fork(),vfork() __clone() 库函数都是根据各自的需要的参数标志去调用clone(),然后由clone去调用do_fork()   // kernel/fork.c 中

 

Fork():

 

do_fork()中调用 copy_process()函数:

1. 调用dup_task_struct()为新的进程创建一个内核栈和thread_info ,task_struct ,这些值与当前进程的值相同,此时,子进程和父进程的描述符是完全相同的。

2. 检查确保新创建的子进程后,当前用户所拥有的进程数目没有超过给它分配的资源限制。

3. 子进程着手使自己和父进程区分开来,进程描述符内的许多成员都要被清0,或设为初始值,

4. 子进程的状态被设置为 TASK_UNINTERRUPTIBLE,保证它不会运行

5. 调用copy_flags()以更新task_struct的flags成员,

new_flags &= ~PF_SUPERPRIV;

new_flags |= PF_FORKNOEXEC;

new_flags |= PF_STARTING;

 

表明进程是否拥有超级用户的权限的PF_SUPERPRIV 被清0,表明进程还没有调用exec()函数的 PF_FORKNOEXEC 被设置。

 

6. 调用alloc_pid 为新进程分配一个有效的PID.

7. 根据传入的clone_flags标志拷贝或者共享打开的文件,文件系统信息,信号处理函数,进程地址空间和命令空间等。

8. 最后返回一个指向子进程的指针。

9. 返回 do_fork 如果成功新创建的子进程就会被唤醒运行,内核有意选择子进程首先执行,因为一般子进程都会马上调用exec函数,这样可以避免写时copy的开销。父进程先执行的话有可能会开始向地址写入。

 

 

 

 

Vfork():

 

除了不拷贝父进程的页表项外,vfork和fork功能相同,子进程作为父进程的一个线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec().子进程不能向地址空间写入。

 

 

 

线程:

 

从内核的角度上讲没有线程这个概念。线程的机制是一种抽象的机制,该机制提供了在同一个程序内共享内存地址空间运行的一组线程。内核并没有相关的数据结构去标识线程,而是被视为一个与其他进程共享某些资源的进程,每个线程都有拥有唯一的隶属于自己的task_struct,所以在内核中,它看起来像一个进程。也叫做轻量级进程。比如,有一个进程包含四个线程,通常会有一个包含指向四个不同线程的指针进程描述符,该描述符负责描述像地址空间,打开的文件这样的共享资源,线程本身再去描述它独占的资源←其他系统linux系统仅仅创建四个进程并分配四个普通的task_struct结构,建立四个进程时指定他们共享某些资源。

 

 

线程的创建:

和普通进程创建类似,只不过调用clone()的时候需呀传入一些参数标志来指明需要共享的资源:

 

// 线程

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)

 

//fork

clone(SIGCHLD, 0)

 

// vfork

clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

 

 

 

内核线程

 

内核需要后台执行一些操作,可以通过内核线程去完成,独立运行在内核空间的标准进程,和普通的进程相比内核进程没有独立的地址空间(实际指向的地址空间mm 被设置为 NULL)它们只在内核空间运行,从来不切换到用户空间,可以被调度,可以被抢占。

 

内核线程只能由内核线程去创建,内核是通过从kthreadd内核进程中衍生出,所以新的内核线程来自动处理这一点。linux/kthread.h

进程的终结:

do_exit() // kernel/exit.c

1. task_struct中的成员设置为PF_EXITING

2. 调用del_timer_sync()删除任一内核定时器

3. 调用exit_mm()释放进程占用的mm_struct,如果没有别的进程使用它们,也就是说,这个地址空间没有被共享,就彻底的释放。

4. 调用 exit_sem(),如果进程排队等候IPC信号,它则离开队列

5. 调用exit_files 和 exit_fs,分别递减文件描述符,文件系统数据的引用计数,如果某个计数为0,那么它就可以释放资源了。

6. task_struct 的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者内核规定的其他退出代码。

7. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者init进程,并把进程状态设置为EXIT_ZOMBIE

8. 最后调用 schedule()切换到新的进程,进程处于EXIT_ZOMBIE状态,它占用的所有内存就是内核栈,thread_info,和 task_struct,此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到后或者通知内核那是无关后的信息,由进程所持有的剩余内存被释放,归还给系统调用。

 

父进程调用wait() 由wait4()系统调用来实现,挂起调用的进程,等待子进程退出,最终需要释放进程描述符调用 release_task()

1.它调用__exit_signal() ... 等删除该进程也要从任务列表中删除该进程

2.__exit_signal()释放僵死的进程所以的剩余资源,并进行最终的统计和记录

3.如果这个进程是线程组中最后一个进程,并且领头的死掉了。那么release_task就要通知领头进程的父进程。

4.Release_task 调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct 所占用的slab高速缓存

 

 

就第7点来说给子进程找一个养父:exit_notify()会调用 forget_original_parent(),然后调用find_new_reaper()来执行寻父的过程。

 

 

 

 

 

 

 

 

 

 

 

 

进程调度:

 

进程调度程序,它是确保进程能有效工作的一个内核子系统。

只有合理的调度才能使系统资源最大限度的发挥作用,多进程才会有并发的效果。

 

多任务操作系统就是能够同时并发的交互执行多个进程的操作系统。

同一时间只能有一个进程在运行。

 

抢占式模式:

由调度程序决定什么时候停止一个进程的运行。以便其他的进程能够得到执行的机会,这个强制的挂起动作就叫做抢占,进程在被抢占之前能够运行的时间是预先设定好的,叫做进程的时间片

 

非抢占式模式:

进程如果不主动停止,那么一直运行。

 

CFS:完全公平调度算法。

 

 

策略:

1.I/O消耗型和处理器消耗型

2.进程优先级

   2.1  nice 值 ,-20 - +90 值越低优先级越高可以获得更多的处理器时间

         Ps 命令可以查看

   2.2  实时优先级,ps - eo state,uid,pid,ppid,rtprio,time,comm

         命令,如果有 - ,不是实时进程

 

3.时间片 一般是一个默认的值

 

 

 

调度算法:

调度器程序:

 

Linux调度器是以模块方式提供的,这样做是允许多种不同类型的进程可以有针对性的选择调度算法。

每一个调度器都有一个优先级,//kernel/sched.c ,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出,CFS 是针对普通进程的调度类。// kernel/sched_fair.c

 

CFS 引入了最小粒度的时间片底线的概念,默认为1ms, 这种模式下,nice值是作用是 本身的nice值和其他进程的nice值进行相对差值的比较,

 

Linux 调度的实现:

四个组成部分

1. 时间记账

2. 进程选择

3. 调度器入口

4. 睡眠和唤醒

 

 

 

1. 时间记账

①. 调度器实体结构

所有的调度器都必须对进程的运行时间做记账。 CFS 不再有时间片的概念,但也必须要维护每个进程的时间记账。// linux/sched.h  struct sched_entity

调度器实体结构来追踪进程运行记账 每个 task_struct 中有一个 se 的成员变量

 

②.虚拟实时

Vruntime 变量存放进程的虚拟运行时间,(花在运行上的时间和)以 ns为单位。

用它来记录一个程序到底运行了多久和还应该运行多久。(主要是处理器无法实现完美的多任务,就算相同的级别的所有进程也都不可能都是相同的vruntime)

 

Updata_curr()实现了记账功能,由系统定时器周期性调用

 

 

 

2. 进程选择

①.

CFS需要选择下一个进程运行时,它会挑一个vruntime最小的值进程,这是是核心算法,CFS使用红黑树来组织可运行的进程队列,迅速的找到最小值的vruntime进程,rbtree ,节点的键值就是vruntime,在树中最左侧的叶子节点。 这一个过程在 __pick_next_entity(),其实这个值已经缓存在rb_leftmost 字段中。如果返回为NULL,那么就是没有任何节点了,就会idle任务运行。

 

②.

向树中添加进程

 

这一切发生在进程变为可运行状态(被唤醒)或者是fork 调用第一次创建进程。

Enqueue_entity()实现

 

 

 

 

③.从树中删除进程

这发生在进程堵塞变为不可运行状态或者终止运行

Dequeue_entity()实现

 

 

3. 调度器入口

入口函数是 schedule() // kernel/sched.c ,内核其他部分用于调度器的入口,选择哪个进程可以运行,可是进行投入运行,

 

 

4. 唤醒和睡眠

 

休眠(被阻塞)是一种特殊的不可执行的状态。进程的休眠有很多原因,但肯定是在等待一件事情,最常见是就是I/O 如 read(),无论哪种情况,内核的操作都是相同的,进程把自己标记为休眠状态,从可执行的红黑树中移出,放入等待队列中,然后调用schedule() 去选择和执行下一个进程。 唤醒的过程刚好相反,进程被设置为可运行状态,然后再从等待队列中移到可执行的红黑树中去。

 

休眠有两种状态,可中断和不可中断,如果不可中断进程会忽略信号,可中断会被提前唤醒。

 

 

等待队列:

休眠通过等待队列处理,等待队列是由等待某些事件发生的进程组成的简单链表。

内核用 wake_queue_head_t来代表等待队列, 可以通过静态DECLARE_WAITQUEUE() 或者动态创建init_waitqueue_head()

进程把自己放入等待队列中并设置不可执行的状态

Wake_up 唤醒

 

 

抢占和上下文切换

 

从一个可执行进程切换到另外一个可执行进程, // kernel/sched.c中的context_switch()处理,

1. 调用switch_mm()负责把虚拟内存从上一个进程映射切换到新进程中

2. 调用 switch_to()负责从上一个进程的处理器状态切换到新进程的处理器状态,包括保存状态,恢复栈信息和寄存器信息,还有其他的任何和体系结构相关的状态信息,都必须已每个进程为对象进行管理和保存

 

 

综上所述就是进程从运行状态到休眠状态 调用schedule改变状态标志,从运行队列删除,加入等待队列,相反从休眠到运行,调用schedule改变状态标志,从等待队列删除,加入到运行队列。

内核提供一个need_resched标志来表明是否需要重新执行一次调度,当某个进程应该要被抢占时,scheduler_tick()就会设置这个标志,当一个优先级高的进程进入,try_to_wake_up()也会设置,内核检查该标志位,确认被设置,调用schedule()去切换到一个新的进程

 

每个进程的thread_info引入preempt_count计数器,每当使用锁的时候加1,释放的时候减1,当数值为0的时候内核就可以抢占,从中断返回内核空间的时候,去检查preempt_countneed_resched 2个标志位,如果need_resched

被设置,并且preempt_count0的话,此时调度程序会被调度,如果preempt_count不为0的话,说明当前任务持有锁,抢占不成功,内核就会像平常一样从中断返回当前执行的进程,当当前执行的进程所以的锁被释放了,preempt_count会被重新置为0,此时,释放锁的代码就会检查need_resched

是否被设置,如果是的话,就调度程序

 

 

实时调度策略:

 

1,普通如CFS非实时的调度 是 SCHED_NORMAL

2. 实时调度在 // kernel/sched_rt.c两种模式 SCHED_FIFOSCHED_RR

 

SCHED_FIFO 这个算法不使用时间片,SCHED_FIFO肯定要比SCHED_NORMAL级别的进程都先得到调度,一旦一个SCHED_FIFO进程处于可执行状态,就会一直执行,直到它自己受阻塞或者释放处理器,不基于时间片,只有更高级的SCHED_FIFO或者SCHED_RR才能抢占,如果有2个或者多个同优先级的SCHED_FIFO,它们会轮流,但是依然要等一个愿意让出处理器。其他较低级的要等它变为不可运行才能有机会执行。

 

SCHED_RR:是带有时间片的 SCHED_FIFO ,时间一到,同一优先级的实时进程被调度。高优先级可以抢占,

 

 

绑定处理器相关的系统调用:

 

强制指定某个进程在哪个处理器上运行。Task_struct中的 cpus_allowed位掩码标志,该掩码标志的每一位对应一个系统可以用的处理器。

 

 

 

 

 

 

 

系统调用:

 

C库实现了标准的C库函数和系统调用接口,

Linux中每个系统调用都会有一个系统调用号。通过这个独一无二的号就可以关联系统调用。内核记录了系统调用表中的所以已注册过的系统调用列表,存储在sys_call_table.syscall.h中 这个表为每一个有效的系统调用提供一个唯一的系统调用号。 所有的系统调用都要用asmlinkage 限定词,

 

Unistd.h 中  //系统调用号

#define __NR_restart_syscall      0

...

...

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

内核数据结构:

 

链表,队列,映射,二叉树

 

链表 // linux/list.h

 

List_add()

List_add_tail()

List_del()

List_del_init()

List_move()

List_move_tail()

List_empty()

List_splice()

List_splice_init()

 

List_for_each() //遍历宏  一般和list_entry()配合使用

List_for_each_entry()  //

List_for_each_entry_reverse() // 反向遍历

List_for_each_entry_safe() // 遍历的同时删除

List_for_each_entry_safe_reverse() // 反向遍历同时删除

 

 

队列:FIFO   // linux/kfifo.h

生产者和消费者

 

映射:(关联数组) // idr.h  就想C++中的STL map

 

 

二叉树:

二叉树搜索是一个节点有序的二叉树

1.根的左分支点都小于根节点

2.右分支点都大于根节点

3.所有的子树都是二叉搜索树

 

关于数的概念后面补充

红黑二叉树  // lib/rbtree.c   linux/rbtree.h

 

 

 

 

 

 

 

 

数据结构的选择:

1. 如果是对数据集合遍历 用链表

2. 如果符合生产者-消费者模式用队列

3. 如果需要映射一个UID到一个对象用 映射

4. 如果需要存储大量数据,并且检索迅速,用红黑树,可以确保搜索时间复杂度为对数关系,遍历时间复杂度为线性关系。

 

 

算法复杂度

 

时间复杂度大O表示

举例:如果房间有7个人,每秒钟数一个人,要7秒,有N个人,要N秒,所以时间复杂度为O(N),如果我要再房间跳舞,不管放假有几个人,时间复杂度为O(1)

 

O(1)// 恒量

O(logn)//对数    如 log 100 = 2其实就是10的平方

O(n)// 线性

O(n^2)// 平方

O(n^3)// 立方

O(2^n)// 指数

O(n!)// 阶乘

 

 

 

 

O 是评价算法和内核组件在多用户,处理器,进程,网络连接等 伸缩的指标。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

中断和中断处理:

 

由于处理器和硬件设备响应的速度相差很大,不可能让处理器内核专门等待,轮询polling可能是一种很好的办法,但是最无用功太多,这时候希望内核处理其他任务的时候,等硬件真正的完成了请求通知内核来处理它,中断。

 

中断使得硬件得以发送通知给处理器。本质上是一种电信号,处理器接收到中断后,会马上向操作系统反映此信号,然后由操作系统来处理到来的数据,硬件设备生成中断送入中断控制器的输入引脚,然后中断控制器会给处理器一个电信号,处理器检查到信号,中断自己当前的工作转而去做处理中断,处理器然后通知操作系统处理。

 

不同设备对应的中断不同,而每个中断都通过一个唯一的数字标志, IRQ

 

中断不会考虑处理器的时钟同步,想发就发, (硬件产生的中断)

异常会考虑处理器的时钟同步。也成为同步中断。(处理器产生的中断)

 

 

中断处理程序:

中断处理程序是驱动的一部分,驱动是用于对设备进行管理的内核代码。

中断处理程序是被内核调用来响应中断的,它们运行在我们成为中断上下文的特殊上下文中。

 

中断处理程序分为2个部分:

中断处理程序的上半部分:接受一个中断开始执行,对中断进行应答或复位硬件。

中断处理程序的下半部分:允许稍后完成的工作会推迟到下半部分。

 

注册中断处理程序:

驱动程序通过request_irq()注册一个中断处理程序。// linux/interrupt.h

 

request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,

    const char *name, void *dev);

 

Irq:中断号

Handler : 指向中断处理程序

Flags

IRQF_DISABLED 处理中断处理程序时候,禁止所有的其他中断。

IRQF_SHARED  多个中断处理程序之间可以共享中断线。

Name :是与中断设备相关的ASCII文本。如pc机上的按键中断对应keyboard

这些名字会被,/proc/irq /proc/interrupts文件使用

Dev : 共享中断线

Free_irq()

 

 

中断上下文和进程上下文没任何关系

中断上下文不能够被睡眠,因为不和进程有任何关系,没有后备进程,也不能调度,所以不能从中断上下文中调用某些函数,如睡眠函数。

 

Procfs 是一个虚拟的文件系统,它只存在于内核内存中。安装与/proc目录下。

procfs中读写文件都要调用内核函数。

/proc/interrupts 文件存放的是系统中与中断相关的统计信息。

代码位于fs/proc,与体系结构有关 show_interrupts()

 

 

/proc # cat interrupts

           CPU0       

  7:     750615      MIPS   7  timer

 14:          0  M36_sys_irq  14  ali_tsg_0

 15:          0  M36_sys_irq  15  16:          0  M36_sys_irq  16  21:          8  M36_sys_irq  21  hdmi

 24:       1338  M36_sys_irq  24  serial

 27:          1  M36_sys_irq  27  ali_m36_ir

 30:          0  M36_sys_irq  30  ali_pmu

 44:        659  M36_sys_irq  44  ehci hcd:usb1

 46:       2803  M36_sys_irq  46  eth0

 56:      23710  M36_sys_irq  56  ali_nand

 58:          0  M36_sys_irq  58  ohci_hcd:usb2

 68:          0  M36_sys_irq  68  mcomm

 69:          0  M36_sys_irq  69  mcomm

 70:        810  M36_sys_irq  70  mcomm

 71:        525  M36_sys_irq  71  mcomm

ERR:          0

 

 

第一列为中断线号

第二列为接受中断数目的计数器

 

中断控制:

Local_irq_disable()

Local_irq_enbable()

 

 

 

 

 

 

 

中断处理程序以异步方式执行。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

内核同步

在使用共享内存的应用程序中,

加锁:

自死锁: 一个线程获取了一个锁,又获取这个锁,就会死等

 

如果多个线程用了多个锁,必须都按同顺序去调用获取锁,不然可能死锁

 

 

 

同步方法:

原子操作:可以保证指令以原子的方式执行,执行过程不被打断。

内核提供了2组原子操作的接口:一组针对整数进行操作,一组对单独的位进行操作。

原子整数操作:atomic_t 类型,原子操作函数只能与这类型一起使用。也避免了与整数int类型的冲突,确保编译器不会对其值进行优化。linux/types.h

Ams/atomic.h

Asm :汇编

 

Atomic_t v ;

atomic_t v= ATOMIC_INIT(0);

atomic_set(&v, 4); /* v = 4 (atomically) */

atomic_add(2, &v); /* v = v + 2 = 6 (atomically) */
atomic_inc(&v); /* v = v + 1 = 7 (atomically) */

printk(“%d\n”, atomic_read(&v)); /* will print “7” */

 

 

Ams/bitops.h 对位操作

 

 

unsigned long word = 0;
set_bit(0, &word); /* bit zero is now set (atomically) */
set_bit(1, &word); /* bit one is now set (atomically) */
printk(“%ul\n”, word); /* will print “3” */
clear_bit(1, &word); /* bit one is now unset (atomically) */
change_bit(0, &word); /* bit zero is flipped; now it is unset (atomically) */

 

 

 

 

 

 

 

 

 

自旋锁:

复杂的多语句,简单的原子操作无能为力,需要使用更为复杂的同步方法。锁保护机制。

 

 

Linux 中最常见的锁是自旋锁。 Spin lock ,自旋锁最多被一个可执行线程执行。如果一个线程试图获得一个已经被使用的自旋锁,那么线程会一直等到锁的可用,其实会特别浪费处理器时间。初衷是对短期间内的轻量级加锁。当然可以让请求的线程睡眠来处理,直到锁的可用来唤醒。

// asm/spinlock.h   linux/spinlock.h

 

 

DEFINE_SPINLOCK(mr_lock);
unsigned long flags;
spin_lock_irqsave(&mr_lock, flags);
/* 临界区 ... */
spin_unlock_irqrestore(&mr_lock, flags);

 

自旋锁不可递归,所以如果自己拥有了锁,然后又去试图获得这个锁,线程一直自旋没有机会去释放这个锁,所以会被自己锁死。

 

 

原则: 对数据加锁而不是对代码加锁

 

自旋锁配置 CONFIG_DEBUG_SPINLOCK  CONFIG_DEBUG_LOCK_ALLOC.

 

 

还有另外一种方式:

读写锁    // asm/rwlock.h   linux/rwlock.h

一个或者多个读任务可以并发的持有读者锁,写者锁只能有一个任务持有。

 

read_lock(&mr_rwlock);
/* 临界区 (read only) ... */
read_unlock(&mr_rwlock);
Finally, in the writer code path:
write_lock(&mr_rwlock);
/* 临界区 (read and write) ... */
write_unlock(&mr_lock);

 

如果加锁时间很长或者代码在持有锁时有可能睡眠,那么最好使用信号量来完成加锁功能

 

 

 

信号量:

 

Linux 中信号量是一种睡眠锁,当一个任务试图去获得已被占用的锁时候,信号量会将其推送到一个等待队列中去,让其睡眠,处理器可以有时间去处理其他的任务操作,当持有信号量的被释放时候,处于等待队列中的那个任务被唤醒获得信号量。

 

 

1. 占用信号量的时候不能占用自旋锁,因为你等待信号量的时候可能会睡眠,而持有自旋锁的时候不能睡眠。

2. 如果是短时间等待,那么用自旋锁,因为信号量进程上下文的切换可能比自旋锁开销更大。

3. 由于信号量可以被睡眠等待,可以用于锁被长时间持有的情况。

4. 中断上下文不能调用信号量,因为不能被睡眠。

 

 

信号量独有的特性:

信号量允许同时有多个持有者,这个值被称为使用者数量,信号量和自旋锁一样同一时刻只允许有一个持有者,这时候计数为1,叫做二值信号量,一般来说基本上用到的都是二值信号量也叫做互斥信号量,两个原子操作,P(),V(),也叫down(),up(), down 会对信号量计数器减1来获得一个信号量,如果是大于0

获得信号量。否则放入等待队列。Up用来释放信号量加1

 

Asm/semaphore.h  linux/semaphore.h

 

struct semaphore name;
sema_init(&name, count);

 

Down_interruptible()试图获取信号量,不可用就把调用的进程置成TASK_INTERRUPTIBLE状态。进入睡眠。

 

 

读写信号量,// linux/rwsem.h   asm/rwsem.h

和读写自旋锁差不多,读者锁可以无限,但是写者锁只能有一个。

 

 

 

 

 

 

 

 

 

 

 

 

互斥体:

一个更为简单的锁,一个简化版的信号量。不需要管理任何计数器。

1. 任何时刻只能有一个持有者,也就是说计数器永远是1

2. mutex上锁者必须负责释放锁,所以不适合复杂的像内核用户空间这样的

   在一个进程上下文上锁,另一个上下文解锁是不行的。

3. 不能递归的上锁

4. 当持有一个mutex,进程不可以退出。

5. Mutex不能再中断或者下半部使用,即使使用mutex_trylock()

 

 

 

mutex_init(&mutex);

mutex_lock(&mutex);
/* critical region ... */
mutex_unlock(&mutex);

 

通过配置 CONFIG_DEBUG_MUTEXES

 

 

信号量和互斥体相比,优先使用互斥体

自旋锁和互斥体,中断上下文只能使用自旋锁。

 

 

 

完成变量:

 

如果在内核中一个任务需要发出信号通知另外一个任务发生了某个特定的事情,利用完成变量。如果一个任务执行一些操作处理,另外一个任务就等待完成变量,当任务处理完成后会使用完成变量唤醒等待的任务,听起来像一个信号量,

Linux/completion.h

列子可以参考kernel/sched.c ,kernel/fork.c

 

 

 

 

 

 

 

 

 

 

 

 

 

BLK:大内核锁,一个全局的自旋锁。 不常用,了解就行,

1. 只能在进程上下文中用,不能用于中断上下文,因为是可睡眠的,当任务无法被调度的时候,会自动释放锁。

2. 是一个递归的锁,可以多次请求一个锁

Linux/smp_lock.h

 

 

 

顺序锁:  // linux /seqlock.h  

和自旋锁差不多但也有很大的区别。

Jiffies 就是使用这种锁来实现读取。

1. 读者很多写者很少

2. 写优先于读,不允许读让写饥饿

3. 数据很简单,有些场合是不能使用原子量的

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

定时器和时间管理器:

系统定时器是一种可编程的硬件芯片,它能以固定频率产生中断。就是所谓的定时器中断。负责更新系统时间,

 

动态定时器:一种用来推迟执行程序的工具。内核可以动态创建和销毁动态定时器。

 

硬件为内核提供一个系统定时器,以某种频率自动触发。内核知道连续两次时钟中断的间隔时间,这个间隔时间就是节拍tick

 

 

节拍率:HZ

Asm/param.h

一个周期为 1/HZ秒 ,

HZ 并不是一个不变的值,大多数体系结构的节拍率是可调的。如mips

#define HZ 100

 

HZ的的优势是更高的准确度。 缺点是系统负担越重。处理器必须花更多的时间来处理时钟中断。

 

Linux 内核支持CONFIG_HZ的选项,无节拍操作。实质性的受益者是省电。

 

全局变量 Jiffies用于记录自系统开机起来产生的节拍的总数。内核启动的时候将该变量初始化为0. Jiffies是指的两次连续的时钟节拍之间的时间。

Linux/jiffies.h

 

extern unsigned long volatile __jiffy_data jiffies;

无符号长整形,100HZ频率 497天会溢出。

如果是64位,那么Jiffies会访问jiffies_64的低32位。为了兼容

如果溢出会又回到0开始

 

unsigned long timeout = jiffies + HZ/2; /* timeout in 0.5s */

 

if (timeout > jiffies) {
/* we did not time out, good ... */
} else {
/* we timed out, error ... */
}

 

 

上面的代码 可能在进行判断的时候jiffies 可能会回到0,这就存在问题,用下面的宏来判断

 

 

 

if (time_before(jiffies, timeout)) {
/* we did not time out, good ... */
} else {
/* we timed out, error ... */
}

 

 

#define time_after(unknown, known) ((long)(known) - (long)(unknown) < 0)
#define time_before(unknown, known) ((long)(unknown) - (long)(known) < 0)
#define time_after_eq(unknown, known) ((long)(unknown) - (long)(known) >= 0)
#define time_before_eq(unknown, known) ((long)(known) - (long)(unknown) >= 0)

 

 

用户空间也依赖于HZ 的定义值,如果改变了HZ, 内核必须更改所以导出的jiffies值,因而内核定义了USER_HZ来代替用户空间看到的HZ值。

内核可以使用 jiffies_to_clock_t() //kernel/time.c将一个HZ的节拍计数转换成一个USER_HZ表示的节拍计数。

 

 

实时时钟:

RTC 用来持久保存系统时间的设备,即便系统关闭了,它也可以靠主板上的微型电池提供的电力保持系统的计时,

当系统启动的时候,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中,

 

时钟中断处理函数:

tick_periodic

 

 

 

实际时间:(也叫墙上时间)

Timespec // linux/time.h

 

struct timespec {
__kernel_time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};

 

 

Tv.sec 存放自197011(UTC)以来经过的时间,

Tv_nsec存放记录自上一秒开始经过的ns

 

读写timespec 变量要用到xtime_lock 锁,这个锁是seqlock顺序锁

 

write_seqlock(&xtime_lock);
/* update xtime ... */
write_sequnlock(&xtime_lock);

 

从用户空间获取墙上时间是 gettimeofday(),内核对应sys_gettimeofday()

 

 

定时器:动态的创建和撤销。

Timer_List // linux/timer.h

 

struct timer_list my_timer;

init_timer(&my_timer);

 

 

Jiffies 前面的关键字 volatile 指示编译器在每次访问变量时都从主内存中获得,而不是通过寄存器中的变量别名来访问,

 

短延迟:

内核提供了三个可以处理us,ns,ms级别的函数

Linux/delay.h asm/delay.h

 

void udelay(unsigned long usecs)
void ndelay(unsigned long nsecs)
void mdelay(unsigned long msecs)

 

 

Schedule_timeout()需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

内存管理

页:

内核把页作为内存管理的基本单位。处理器最小寻址单位为字节,

但是,内存管理单元(MMU,管理内存并把虚拟地址转换为物理地址的硬件)通常以页为单位进行。从虚拟内存角度看,最小的管理单位是页表。

 

// linux /mm_types.h

 

struct page {
    unsigned long flags;
atomic_t _count;
atomic_t _mapcount;
unsigned long private;
struct address_space *mapping;
pgoff_t index;
struct list_head lru;
void *virtual;
};

 

Flags: 每一位表示一种状态,可以有32种不同状态,定义在//linux/page-flags.h

 

_count:表示这一页被引用了多少次。-1表示没有引用,新的分配中就可以使用它了,page_count()来进行检查,

 

Virtual:是页在虚拟内存中的地址。

 

注意:page是与物理内存页有关,而非虚拟页有关。 系统中的每个物理页必须要分配这样一个结构体。4G内存,结构体占用的也不过是20M,占用的比列很小。

 

区:

 

Linux中包含了四种区: // linux/mmzone.h

ZONE_DMA     :这个区包含的页可以用来DMA操作(直接访问内存)

ZONE_DMA32   : DMA差不多,但是只能是32位设备访问

ZONE_NORMAL  :这个区包含的都是正常映射的页

ZONE_HIGHMEM :这个区包含高端内存页,其中的页并不能永久的映射到内核地址空间,

 

区的实际使用和体系结构相关。

 

 

 

 

x86-32上,前2个区各取所需后,ZONE_NORMAL就是从16M-896M所有的物理内存,896以后都是ZONE_HIGHMEM

 

Linux 把系统的页划分了区,形成不同的内存池。这样根据用途进行分配,区的划分没有什么任何物理意义,只是逻辑上的分组。

一般来说,不同的用途在不同的区中分配,但是如果内存资源不够了,内核会去占用其他可用区的内存。

不是所有的体系结构都定义了这几种区,有些就只有ZONE_DMAZONE_NORMAL

 

 

struct zone {
unsigned long watermark[NR_WMARK];
unsigned long lowmem_reserve[MAX_NR_ZONES];
struct per_cpu_pageset pageset[NR_CPUS];
spinlock_t lock;
struct free_area free_area[MAX_ORDER]
spinlock_t lru_lock;
struct zone_lru {
struct list_head list;
unsigned long nr_saved_scan;
} lru[NR_LRU_LISTS];
struct zone_reclaim_stat reclaim_stat;
unsigned long pages_scanned;
unsigned long flags;
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
int prev_priority;
unsigned int inactive_ratio;
wait_queue_head_t *wait_table;
unsigned long wait_table_hash_nr_entries;
unsigned long wait_table_bits;
struct pglist_data *zone_pgdat;
unsigned long zone_start_pfn;
unsigned long spanned_pages;
unsigned long present_pages;
const char *name;
}

 

Watermark:内核使用水位为每个内存区设置合适的内存消耗基准,该水位随空闲内存的多少而变化。

Name : 内核启动期间初始化这个值,代码位于mm/page_alloc.c中。

三个区的名字为DMA Normal,HighMem

 

 

 

 

获得页:

struct page * alloc_pages(gfp_t gfp_mask, unsigned int order)

该函数分配2^order个连续的物理页

void * page_address(struct page *page)

把页转换为它的逻辑地址

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)

这个函数不用page,直接返回的是第一页的逻辑地址。

 

Kmalloc 分配的是物理上连续的N个字节地址,虚拟地址也是连续的

Kfree  // linux/slab.h

 

Vmalloc 分配的是连续的内存虚拟地址。物理上不需要连续。这也是用户空间分配的方式。由malloc返回的页在进程的虚拟地址空间是连续的但不能保证它们在物理RAM上是连续的。  // linux/vmalloc.h  mm/vmalloc.c

 

Vmalloc为了映射物理不连续的内存地址,需要建立专门的表项

vfree

 

大多数情况下硬件设备上要用到物理地址是连续的内存,

 

 

Slab层:slab分配器

 

分配和释放数据结构是所有内核最普遍的操作之一,为了便于数据的频繁分配和回收,编程人员经常会用到空闲链表,空闲链表包含可供使用的已经分配好的数据结构块,当代码需要一个新的数据结构实例时,就从空闲链表中抓取一个,而不需要分配内存,再把数据放进去,以后不需要这个数据结构的实例时,就把它放回空闲链表中去。而不是去释放它,从这个意义上看相当于高速缓存

Slab层充当了高速缓存的角色

 

Slab层把不同的对象划分为所谓的高速缓存组,每个高速缓存组存放不同类型的对象,例如,一个高速缓存用于存放进程的描述符,另一个高速缓存存放索引节点对象,kmalloc接口是建立在slab层上的,使用一组通用的高速缓存。

 

Slab是一个或者是多个物理上连续的页组成,每个slab处于三种状态。满,部分满,空。 内核需要一个新的对象,先从部分满的slab中进行分配,如果没有部分满的slab,就从空的分配,如果没有空的,就创建一个slab

 

Inode结构是磁盘索引节点的内存中的体现.这些数据会频繁的创建和释放,因此很有必要使用slab分配器.

 

 

每个高速缓存struct kmem_cache ,包含3个链表slabs_partial,slabs_full,slabs_free 存放在 struct kmem_list3结构体中.

 

// 创建高速缓存

struct kmem_cache * kmem_cache_create(const char *name,
size_t size,
size_t align,
unsigned long flags,
void (*ctor)(void *));

 

// 撤销高速缓存  通常在模块的注销代码中 ,调用这个条件是

1.高速缓存的slab都为空

int kmem_cache_destroy(struct kmem_cache *cachep)

 

 

// 从缓存中获取对象

void * kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags)

// 释放一个对象

void kmem_cache_free(struct kmem_cache *cachep, void *objp)

 

 

 

// kernel/fork.c 中 内核使用一个全局的task_struct 高速缓存的指针。

static struct kmem_cache *task_struct_cachep;

内核初始化期间 fork_init()会去创建高速缓存。

每当进行fork操作时候,do_fork会创建一个新的进程描述符 dup_task_struct()做的 alloc_task_struct(),如果没有子进程等待,它的进程描述符被释放,并返回给task_struct_cachep ,调用free_task_struct(),由于进程描述符是内核的核心部分,时刻都要用到,所以绝对不会被撤销掉。

 

如果要频繁的创建很多相同类型的对象,应该考虑slab高速缓存

 

栈上静态分配:

用户空间的栈可以很大,并且是动态的增长,相反,内核就不能这么奢侈,内核栈小而且是固定的。每个进程的内核栈大小依赖与体系结构,一般是2个页大小8K

这样可以让进程减少内存的消耗。并且随着运行时间的增长,之后找2个未分配的连续的页是比较困难的。(也就是说内核栈是连续的2个页 ?)

 

刚开始的时候,中断处理程序也要放到内核栈中,之后,出现了中断栈。中断栈为每个进程提供一个用于中断处理程序的栈。一般是一页,所以对进程来说消耗不大。

内核栈如果溢出没什么好的办法,要么宕机要么数据被破坏,尤其是thread_info这个数据结构是贴着每个进程的内核堆栈的末端。

 

 

内核地址空间也就是虚拟内存地址-逻辑地址。

 

 

高端内存映射:

 

根据定义,高端内存中的页不能永久的映射到内核地址空间中去,因此通过alloc_pages()函数以__GFP_HIGHMEM标志获得的页不可能有逻辑地址。

 

永久映射

要映射一个给定的page就要用到 linux/highmem.h中的

Kmap函数 void *kmap(struct page *page) ,这个函数可以用在高端内存和低端内存,如果是低端内存,返回的是该页的虚拟地址,如果页位于高端内存,则会建立一个永久映射然后返回地址。函数可以睡眠,只能用于进程上下文中。

 

永久映射的数量是有限的。当不需要的时候应该解除映射。

void kunmap(struct page *page)

 

临时映射:

当创建一个映射而当前的上下文又不能睡眠时,内核提供了临时映射,也就是所谓的原子映射,有一组保留的映射,它们可以存放新创建的临时映射,内核可以原子的把高端内存中的一个页映射到某个保留的映射中去。可以用在中断处理程序中。

 

 //  Asm/kmap_types.h

 

void *kmap_atomic(struct page *page, enum km_type type)

void kunmap_atomic(void *kvaddr, enum km_type type)

 

 

 

每个CPU的数据会存放在一个数组中,数组中的每一项对应着系统上存在的一个处理器

 

unsigned long my_percpu[NR_CPUS];
Then you access it as
int cpu;
cpu = get_cpu(); /* get current processor and disable kernel preemption */
my_percpu[cpu]++; /* ... or whatever */
printk(“my_percpu on cpu=%d is %lu\n”, cpu, my_percpu[cpu]);
put_cpu(); /* enable kernel preemption */

 

get_cpu //已经是禁止了内核抢占,

 

新的CPU接口:

// linux/percpu.h asm/percpu.h

 

使用CPU数据的原因是:省去许多的数据上锁,因为已经在接口中处理了禁止内核抢占,并且上锁的代价比内核抢占要大的多。所以可以用在进程上下文和中断上下文中。注意不能访问的时候睡眠,有肯能起来后是其他的处理器。

 

 

 

分配函数的选择

 

1. 如果需要分配连续的物理内存,可以用kmalloc函数

2. 如果想从高端内存中分配,使用alloc_pages(),这个函数返回的是一个指向struct page结构的指针,而不是一个指向逻辑地址的指针,高端内存可能没有映射,访问它的唯一方式就是要通过相应的struct page 去映射。Kmap把高端内存映射到内核的逻辑地址上去。

3. 如果你不需要连续的页,仅仅是需要虚拟内存中的连续,vmalloc

4. 如果你要创建和撤销很多大的数据结构,可以考虑建立slab高速缓存,

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

虚拟文件系统:

你可能感兴趣的:(Linux,Command)