图1 Linux系统整体结构
图2 Linux的源代码结构
下面显示一段内核模块代码案例:
#include <linux/moduLe.h>
#include <linux/kernel.h
#include <linux/intt.h>
/*
模块的初始化函数lkp_ init()
_init是用于初始化的修饰符
*/
static int __init lkp_init(void)
{
printk( "<1>Hello ,world!from the kernel space...\n" );
return 0;
}
/*
模块的退出和清理函数1kp_ exit()
*/
static void __exit lkp_exit(void)
{
printk( "<1>Goodbye ,world!leaving kernel space...\n" );
}
module_init(lkp_init);
module_exit(lkp_exit);
/*
模块的许可证声明GPL
*/
MODULE_ LICENSE("GPL");
在此使用了printk0函数,该函数是由内核定义的,功能和C库中的printf()类似,它
把要打印的日志输出到终端或系统日志。字符串中的<1>是输出的级别,表示立即在终端输出。
任何模块都要包含的三个头文件:
#include <linux/module.h>
#include <linux/kernel.h>
#incldue <linux/init.h>
说明: module.h头文件包含了对模块的版本控制; kernel.h包含 了常用的内核函数; init.h包含 了宏__init和__exit,宏__init告诉编译程序相关的函数和变量仅用于初始化,编译程序将标有__init的所有代码存储到特殊的内存段中,初始化结束就释放这段内存。
内核模块的Makefile文件
obj-m:=module_example.o #产生module_example模块的目标文件
CURRENT_PATH :=$(shell pwd) #模块所在的当前路径
LINUX_KERNEL :=$(shell uname -r) #linux内核源代码的当前版本
LINUX_KERNEL_PATH := /usr/src/linux-headers-S(LINUX_KERNEL) #linux内核源代码的绝对路径
all:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modules #编译模快
clean:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean #清理模块
第一行中的obj-m :=这个赋值语句的含义是说明要使用目标文件module_example.o建立一个模块,最后生成的模块名为module_ example.ko。.0文件是经过编译和汇编,而没有经过链接的中间文件。
注: makefile文件中, 若某一 行是命令,则它必须以一个Tab键开头。
模块插入命令:
$insmod module_ example.ko
模块删除命令:
$rmmod module_ example
查看模块信息的命令:
$dmesg
Linux内核模块与C应用的对比
2 操作系统接口
2.1 OS是如何对系统调用进行处理的?
系统调用发生在用户态,当调用了系统调用后就陷入到内核态。如何陷入?比如,在比如DOS的软中断int 21H;Linux下的int 0x80;处理器不同指令不同,我们统一把他叫做陷入指令。OS比较理智,它会在陷入之前先把自己当时执行的CPU现场保存起来,然后进行压栈,给自己留下退路;接下来就是让内核执行一段程序,这段程序叫系统调用服务例程,比如执行sub1在显示器上输出;内核把这种脏活累活于完以后,它会理智的撤出(就是把堆栈中东西弹出来就行)。
2.2 系统调用与一般过程调用有何不同?
系统调用要涉及到CPU状态的转换,首先从用户态陷入到内核态,在内核执行系统调用服务例程,处理结束后,返回用户态;一般的程序它调用的时候在用户态也可能在内核态,只是一个函数调用另外一个函数而已,不存在CPU状态的转换 。
3 Linux系统调用
3.1 Linux各种接口
不管是图形接口还是命令行接口,都统称为用户接回,因为图形界面只是一种与用户更方便打交道的方式,其本质还是一堆实用程序的集合。而库函数,如printf,open,read等等,这些库函数实际上很多也只是穿了件衣服,尤其是与硬件或者系统打交道的话,并不是库函数干的,实际上是操作系统干的,这就是系统调用接口。
3.2 系统调用与API
系统调用是与具体操作系统相关的,而AP1是遵循POSIX标准的,Linux的ibc库的函数malloc和free都叫做API,其实现都调用了brk系统调用;另一方面,一个API实现可能会调用好几个系统调用,而有些AP甚至不需要任何系统调用,比如说strepy函数,因为它们不需要内核提供的服务
系统调用-内核的出口
系统调用,顾名思义说的是操作系统提供给用户程序调用的一组特殊接口,从逻辑上来说,系统调用可被看成是一个内核与用户空间进行交互的接口,它好比一个中间人,把 用户进程请求传达给内核,待内核把请求处理完毕后,再将处理结果送回给用户空间。
如图为Linux系统中,各个子系统相关的工具集,在这里可以通过strace命令查看个应用程序所调用的系统调用,strace被称为神器,它是Linux环境下的一款程序调试工具,它可以统计每一个系统调用所执行的时间、被调用的次数和出错的次数,例如“strace -c 可执行文件名”,它把执行的时间以微妙为单位的每个系统调用平均耗时、调用次数、错误次数以及系统调用名称显示在表格中。
从用户态函数到系统调用
比如在程序中调用fwrite函数,图中①,而fwrite函数在glibc库中调用系统调用write()(图中②),然后从用户态陷入内核态(图中③),查找系统调用表syscall table ( 图中④),在内中中对应的系统调用服务例程为sys_write,然后在内核执行该例程。
3.3 系统调用基本概念
(1)系统调用号
①用来唯一的标识每个系统;
②作为系统调用表的下标,当用户空间的进程执行一个系统调用时,该系统调用号就被用来指明到底要执行哪个进程。
(2)系统调用表
用来把系统调用号和相应的服务例程关联起来。该表存放在sys call table数组中:
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0- old "setup()"system call, used for restarting */
.long sys_exit
.long sys_fork
.long sys_read
.long sys_write
.long sys_open /*5*/
........
所有的系统调用在内核中都存放在一张表中,叫sys_call_table,在内核代码中实际上很简单是汇编语言,通过一个长整型指出每个系统调用的入口地址,如下表列出了部分系统调用:
这张表不是系统调用内核中的表示,而是为方便用户查看而建立的,第一列是系统调用号、第三列是系统调用名、第三列是系统调用在内核实现中所在文件、后面见列是传递给系统调用的参数。这张表主要是让用户理解系统调用号、系统调用表以及参数之间的相互关联。
3.3.1 调用一个系统调用的过程
当用户态的进程调用一个系统调用时,首先调用ibo库中的函数,这个函数必定有一条从用户态转入到内核态的陷入指令,比如 int 0x80,这时就进入Linux内核了,在内核中找到系统调用表的入口地址sys_call_table,然后根据系统调用号在表中进行查找,找到相应的的系统调用服务例程,执行完后,通过返回指令iret,从内核态返回到用户态,这是对系统调用过程的一个简要概述。
3.3.2 系统调用处理过程
如下图所示,就是一个系统调用的处理过程。当用户态的进程调用一个系统调用时,CPU切换到内核态,首先要保护现场,然后根据存放到eax中的系统调用号,并乘以4表示每个表项占4字节,在系统表sys_call_table中找到入口地址,并跳转到相应的服务例程,执行结束后恢复现场,从内核态返回到用户态。
注①:此处语句为: call*SYMBOL_NAME(sys_ call_table)(
3.3.3 从用户态跟踪一个系统调用到内核
下面给出一个具体的fork系统调用的例子:
1.从用户程序中调用fork
2.在libc库中把fork对应的系统调用号2放入寄存器eax
3.通过int 0x80陷入内核
4.在中断描述表IDT中查到系统调用的入口0x80
5.进入Linux内核的entry_32(64).S 文件,从系统调用表sys_call_table 中找到sys_fork 的入口
地址
6.执行fork.c中的do_fork 代码
7通过iret返回
3.4 系统调用机制的优化
在26以前的早期版本中,系统调用的指令都是int 0x80和iret指令,因为系统调用的实现从用户态切换到内核态,执行完系统调用后又从内核态切换回用户态,这样做代价很大,为了加快系统调用的速度,先后引入了两种机制,分别为vSycalls和VDSO,这两种机制都是从机制上对系统调用速度进行的优化,但是使用软中断来进行系统调用,还是需要进行特权级的切换这一根本问题并未得到解决,为了解决这一问题,Intel x86 CPU从Pentfum II之后开始支持快速系统调用指sysenter/sysexit,这两条指令是Intel在32位下提出的,而AMD提syscal/sysret,64位统使用这两条指令了。
3.5 系统调用实例日志收集系统
系统调用是用户程序与系统打交道的入,系统调用的安全直接关系到系统的安全,如果一个用户恶意地不断调用fork将导致系统负载增加,所以如果能收集到是谁调用了一些有危险的系统调用以及系统调用的时间和其他信息,将有助于系统管理员进行事后追踪,从而提高系统的安全性。
上图是系统调用周志收集系统示意图,我们可以看到当用户进程进行系统调用的时候,当执行到do_syscall_64的时候,我们判断是否是我们需要记录的系统调用,如果是则拦截需要记录的系统调用,通过my_audit这一函数将相关信息写入到内核中的buffer里,同时我们编写用户测试程序,在用户测试程序中我们通过我们本次添加的335号系统调用,执行my_sysaudt这一函数,该函数把内核buffer里的信息拿到我们的用户buffer当中,其中my_audti和my_sysaudit,这两个函数是钩子函数,并且在我们的my_audit内核模块当中进行具体的实现-实例操作。
4 进程管理
4.1 Linux中进程状态及转换
操作系统一般具有就绪、运行和阻塞三种状态,这三种状态是任何一个操作系统当中都会存在的,恒到其体的操作系统的时候,就不止这三种了,比如说如图Linux当中睡眠态就有两种,一种是浅度睡眠,就是收到信号就可以唤醒它;另一种是深度睡眠,要调用唤醒函数才能唤醒它。当你调试一个程序的时候,进程就进入暂停状态;当进程退出,资源尚关回收时就进入到僵死状态,那么状态之间的转换所调用的函数是内核函数。
通过top命令我们会看到,在单CPU的机器上,只有一个进程处于R运行状态,一般情况下,进程列表当中的绝大多数进程都处于浅度睡眠TASK_INTERRUPTIBLE状态,也就是S状态;僵死状态Z,就是子进程已经结束,但父进程还没有回收它的资源。
1)Linux内核源代码中进程状态的定义
上图是Linux内核5.3版本中状态的定义,每个状态的值是2的n次方,这种定义方式巧妙之处就在于,通过逻辑与运算,就可以快速的算出一个进程它所处的状态
2)扩展-从进程到云计算中的容器
容器作为目前云技术的核心技术,它与进程到底有多大关系?对于进程来说,它的静态表现就是程序,平时常都安安静静地待在磁盘上,而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现;而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个边界”;对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术,则是用来修改进程视图的主要方法。
4.2 进程控制块及Linux中的task_struct结构
我们说进程是由程序、数据以及进程控制块组成的,进程控制块(Process Control Block ,简称PCB),是管理进程的数据结构,用它来记录进程的外部特征,描述进程的运动行变化全过程。
OS就是靠PCB来管理进程,PCB相当于我们的身份证信息,但比身份证信息更全面,进程在执行过程中的全部信息都记录在其中,进程控制块中的信息一般分为四类:进程标识信息、处理器现场信息、进程调度信息、进程控制信息,其中进程标识信息唯一的标识进程,比如PID叫进程标识符。
处理器现场信息:就相当于我们开连过程出了车祸事故的现场一样,就是它本来好好的执行,被外设打断了,或者要从用户态陷入内核执行系统调用等等,这时必须保存处理器现场信息,这些信息通常包括通用寄存器(8到32个,RISC结构中超过100个)、指令计数器(下一条指令的地址)、状态寄存器(程序状态字PSW,如:EFLAGS寄存器)、用户栈指针(过程和系统调用参数及地址)等等。
进程调度信息:就是就绪队列中的进程被选中到CPU上去运行,由进程的状态(如:运行,,就绪,阻塞...)、优先级(优先级高的进程优先获得处理机)、该进程在等待的事件(阻塞的原因)、调度所需其它信息(如:等待总时间,执行总时间)、等待总时间、执行总时间等等来决定谁运行。
进程控制信息:那一个进程执行起来真不是一件简单的事,需要各种各样的控制信息,比如说从哪里来,要到哪里去(程序和数据的地址---程序和数据所在的内存或外存地址),也就是说要将其程序从外存装入到内存,那么PCB当中就需要有程序和数据的地址信息;还有进程间也有可能需要打电话,这就是同步和通信机制(需要的消息队列指针和信号量等);它们也需要带着干粮上路,也就是说所需要使用的的这些资源,包括文件、IO设备等等;进程间也有七大姑八大姨的关系,这就是亲属关系以及相关的数据结构等等(家族关系----父子进程关系及其它结构)。进程控制块组织方式:索引方式、线性表、链表方式、树形结构。
PCB组织方式-各种队列
PCB的组织方式与我们生活中的组织方式也类似,就是排队!当一个进程创建时,其PCB进入到了就绪队列等待被调度,当CPU空闲时,调度程序从就绪队列当中选择一个进程投入到运行当中,当一个进程时间片到时,它的PCB重新回到就绪队列,当一个进程在执行过程中进行IO操作,就进入到了阻塞队列,当然阻塞队列可以根据等待事件的原因不同而不止一个,那么当等待的事件完成后,该进程的PCB就进入到了就绪队列,在这每种状态下,进程都回归到它自己的队列,老老实实的去进行排队。
Linux中进程的家族关系
$pstree命令查看进程树
可以把进程想象成人类,有生老病有死就很好理解,进程就是一个动态的实体,它具有生命周期,系统中进程的生生死死随时发生,因此,操作系统对进程的描述模仿人类活动,一个进程不会平自无故的诞生,他总会有自己的父母,在Linux系统中,通过调用fork系统调用来创建一个新的进程,新进程创建的子进程同样也能执行fork,所以,可以形成一颗完整的进程树。其中int进程是1号进程,是所有进程的祖先进程,比如说linux系统启动之后我们就会形成这样一颗树,可以通过pstree命令查看进程树。
那么Linux中的PCB结构具体是什么呢?它就叫task struct结构,我们前面介绍过的进程的状态、标识符以及亲属关系就都会存放在这个结构中,我们具体梳理一下
因为一个进程会能生几个儿子,而儿子之间又有兄弟关系,为了描述进程之间的这种父子以及兄弟关系,在进程的PCB中就要引入几个域,假设P表示一个进程,首先要有一个域来去描述它的父进程,就是parent域,其次有一个域描述它P的儿子,儿子又不止一个,因此就要有一个域来去就指向老小这个孩子进程(child),最后P有可能有哥哥弟弟,于是就用一个域猫述来去表示它的old sibling,另外一个域描述来去表示它的younger sibling。
Linux中父子进程关系图
这里要说明的是,一个进程可能又两个父亲,一个为是亲生,一个是为养父,为什么会有养父?般应用当中,都是父亲给儿子派活干,父亲等儿子干活结束后,把儿子的资源就回收收回了,但也可能会出现儿子还在干活,结果父亲已经去世了,儿子就成为孤儿了,这时候于是就需要得给儿子重新找一个养父,但是大多数情况下,生父和养父是相同的。
关于task_struct结构,我们可以进入schedh头文件查看源代码,会有身临其境的感觉,这里列出部分代码片段,其中thread_info结构保存进程PCB当中,频繁访问和需要快速访问的字段,内核依赖于该结构来获得当前进程的PCB,而状态state就是一个无符号长整型,其中的修饰符volatle是gcc编译器的修饰待,有了这个修饰符,就是明确的告诉编译器,对该字段不要优化,从内存读取它的其值而不是要从寄存器取;pid是进程标识符;因为Linux内核支持内核级线程,每个进程中的所有线程就形成了一个线程组,线程组就需要一个领导,这个leader它的ID就是其所在进程的pid
task_ struct源代码片段
这是task struct结构中进程家族关系的字段,可以看到又亲生父亲、养父、孩子、兄弟还有组leader等。
/* Real parent process: */
struct task_struct __rcu *real_ parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
Children/sibling form the list of natural children:
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader ;
进程控制块存放在内存的什么地方?Linux在设计当中发扬艰苦朴素的原则,为了节省空间,Linux把内核栈和PCB的小数据结构thread_info放在了一起,占用8KB的内存区。
内核中使用下列的联合引结构表示这样一个混合结构:
union thread_union
{
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE / sizeof(long)];
/*大小一-般是8KB,但也可以配置为4KB*/
};
那么我们从这个联合体的定义可以看出,内核栈占8KB的内存区,因为thread_info的第一个字段是task_struct结构,这8KB的内存区,有一部分就让给PCB来使用了,Linux当中PCB的组织方式有很多种多种多样,比如说链表、树、哈希表等等。在task_ struct中定义如下:
struct task_struct
{
... struct list head_tasks;
char comm[TASK_COMM_LEN]; /* 可执行程序的名字
};
这里给出链表结构,那么链表的头和尾部都为init_task,inift task是0号进程的PCB,0号进程就是系统当中的ile(闲逛)进程,系统当中的所有进程通过ist head结构组成双向循环链表,list_head是内核中一个独特的双链表结构。
动手实践——打印进程控制块中的字段
打印系统中所有进程的PID和进程名,代码如下:
static int print_pid(void)
{
struct task_struct *task, *p;
struct list_head *pos;
int count = 0;
printk("Hello World enter begin:\n");
task = &init_task;
list_for_each(pos, &task->tasks) /*关键*/
{
p = list_entry(pos, struct task_struct, tasks);
count++;
printk("%---> %s\n", p->pid, p->comm);
}
printk("the number of process is:%d\n", count);
return 0;
}
task_ struct小结:因为Linux进程控制块当中的信息非常多,为了有助于我们认识他对其认识,可以对这些信息进行如下分类:包括了处理器的环境信息、状态信息、链接信息、各种标识符、调度信息、进程间通讯信息、虚拟内存信息、文件系统信息、时间和定时器等等。
进程控制