Linux操作系统及源码分析(一):操作系统模块结构简述及部分数据结构说明

1. Linux内核的技术特点

1.1 单内核结构

Linux内核被设计成单内核(Monolithic)结构,效率高,紧凑性强,下面简述单内核结构和微内核结构的特性

1.1.1 单内核特性

单内核就是从整体上把内核作为一个大过程来实现,而进程管理、内存管理等是其中一个个模块,模块之间可直接调用相关函数

① 优点

模块之间直接调用函数,除了函数调用的开销,没有额外开销

② 缺点

庞大的操作系统有数以千计的函数,复杂的调用关系势必会导致操作系统维护困难

③ 要点

单内核操作系统追求效率

1.1.2 微内核特性

       微内核是一种更贴近硬件的核心软件,一般只包含基本的内存管理、同步原语、进程间通信机制、IO操作和中断管理

内核与各个服务器之间通过通信机制进行交互,内核发出请求,服务器做出应答(C/S架构)

① 优点

a. 各个服务器模块相对独立,维护相对容易

b. 有利于提高可扩展性和可移植性

② 缺点

a. 微内核与文件管理、设备驱动、虚拟内存管理、进程管理等其他上层模块之间有较高的通信开销

③ 要点

微内核操作系统追求简洁

1.2 抢占式内核

1.2.1 非抢占式内核特性

2.6版本之前的Linux内核是单线程结构,即同一时间只允许有一个执行线程在内核中运行,不会被调度程序打断而运行其他任务

① 优点

a. 就单处理器而言,内核中没有并发任务,因此避免了复杂的同步问题

② 缺点

a. 非抢占式内核延迟了系统响应速度,新任务必须等待当前任务在内核执行完毕并自动退出后才能获得运行机会

1.2.2 抢占式内核特性

2.6版本将抢占式技术引入Linux内核,当进程位于内核空间时,如果有一个更高优先级的任务,可以将当前任务挂起,执行高优先级任务

① 优点

提高了系统的响应速度

② 缺点

同步变得更为复杂

1.3 支持动态加载内核模块

① 为了保住能方便地支持新设备、新功能,又不会无限地扩大内核规模,Linux对设备驱动和新文件系统等采用了模块化的方式

② 用户在需要时可以动态加载,使用完毕可以动态卸载

1.4 被动提供服务

Linux为用户服务的唯一方式是通过系统调用来请求在内核空间执行某种任务

② 内核本身是一种函数和数据结构的集合,不存在运行着的内核进程为用户提供服务

1.5 虚拟内存技术

① Linux采用了虚拟内存技术,使得内存空间达到4GB,其中一般情况下(可自定义),

0 ~ 3GB属于用户空间,称为用户段

3 ~ 4GB属于内核空间,称为内核段

1.6 实现抽象文件模型VFS

① Linux文件系统实现了一种抽象文件模型,即虚拟文件系统(Virtual Filesystem Switch,VFS),该文件系统属于UNIX风格

② VFS屏蔽了各种不同文件系统的内在差别,使得用户可以通过统一的界面(e.g. open / close / read / write / ioctl等系统调用)访问不同格式的文件系统

1.7 提供有效的延迟执行机制

① Linux提供了一套很有效的延迟执行机制,比如底半部、软中断、Tasklet和工作队列等

② 延迟执行机制保证了系统可以针对任务的轻重缓急,更细粒度地选择执行时机

2. Linux内核的位置

① 应用程序进程与Linux内核的关系属于C/S模式,应用程序向内核发出请求

② 系统调用是用户程序与Linux内核的桥梁,Linux内核是Server,提供服务;用户程序是Client,请求服务

3. Linux内核体系结构

下面简述Linux内核各个子系统之间的关系,

3.1 进程调度(Process Scheduler,SCHED)

① 进程调度控制着进程对CPU的访问

② 当需要选择一个进程运行时,由调度程序选择最值得运行的进程

③ 进程调度处于中心位置,其他所有子系统都依赖他,因为每个子系统都需要挂起或恢复进程

3.2 内存管理(Memory Manager,MM)

① 内存管理允许多个进程安全地共享内存区域

② Linux的内存管理支持虚拟内存,即在计算机中运行的程序,其代码、数据和堆栈的总量可以超过实际内存的大小。操作系统只将当前使用的程序块保留在内存中,其余的程序块则保留在磁盘上

必要时,操作系统负责在磁盘和内存之间交换程序块

说明:嵌入式设备中一般不设置swap分区 (https://blog.csdn.net/asukaztc/article/details/79459125)

③ 内存管理从逻辑上可以分为硬件无关的部分和硬件相关的部分

3.3 虚拟文件系统(VFS)

① 虚拟文件系统隐藏各种不同硬件的具体细节,为所有设备提供统一的接口

② 虚拟文件系统可分为逻辑文件系统设备驱动程序,其中,

a. 逻辑文件系统是指Linux所支持的文件系统,如Ext2、Ext3、NTFS等

b. 设备驱动是指为每一种硬件控制器所编写的设备驱动程序模块

③ 设备管理(字符 / 块设备)被纳入虚拟文件子系统(万物皆文件)

3.4 网络接口(Network Interface,NI)

① 网络接口提供对各种网络标准协议的存取和各种网络硬件的支持

② 网络子系统可分为网络协议网络驱动程序,其中,

a. 网络协议部分负责实现每一种可能的网络传输协议

b. 网络设备驱动程序负责与硬件设备进行通信

3.5 进程间通信(Inter-Process Communication,IPC)

① 进程间通信支持各种通信机制,包括共享内存、消息队列和管道等

说明1:Linux系统整体结构图示

说明2:Unix / Linux的设计理念是机制与策略分离

机制:提供什么样的功能

策略:如何使用这些功能

示例1:创建进程是机制;何时创建机制是策略

示例2:系统调用机制起到隔离变化的作用,使用相同的系统调用(e.g. open / close / read / write),可以读写不同的设备(e.g. 磁盘、网络)

说明3:每个处理器在任何指定时间点上的活动必然为下列三种情况之一,

① 运行于用户空间,执行用户进程

② 运行于内核空间,处于进程上下文,代表某个特定的进程执行

③ 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断

之所以存在一个专门的中断上下文执行环境,是为了保证中断服务程序能够在第一时间响应和处理中断请求,然后快速地退出

4. Linux内核源代码结构

在安装好的Linux操作系统中,内核源代码位于/urs/src/linux目录

说明:Ubuntu系统需要下载源码,否则只有头文件

 

目录

主要内容

include

建立内核代码时所需的大部分头文件,与系统相关的在include/linux目录

init

内核的初始化代码,这是内核开始工作的起点

arch

Linux支持的所有硬件体系结构的内核代码,如X86、ARM等

drivers

内核中所有的设备驱动程序,如字符设备、块设备等

fs

所有文件系统的代码,如Ext3、Ext4、NTFS等

net

内核中关于网络的代码,实现了常见的网络协议

mm

所有的内存管理代码,和体系结构相关的部分代码在arch/xxx/mm目录下

ipc

进程间通信的代码

kernel

主内核代码,包括进程调度、定时器等。和体系结构相关的部分代码在arch/xxx/kernel目录下

block

块设备通用函数(e.g. IO调度)

crypto

常用加密和散列算法(e.g. AES、SHA等)以及一些压缩和CRC校验算法

Documentation

内核各部分的通用解释和注释

lib

库文件代码

scripts

用于配置内核的脚本文件

security

主要是SELinux模块

sound

ALSA、OSS音频设备的驱动核心代码和常用设备驱动

usr

实现了用于打包和压缩的cpio等

5. Linux内核模块编程简介

5.1 模块定义

① Linux内核模块全称为动态可加载内核模块(Loadable Kernel Module,LKM),是Linux内核向外部提供的一个插口

② Linux内核提供内核模块是为了弥补单内核结构扩展性和可维护性较差的缺点

③ 内核模块是具有独立功能的程序,他可以被单独编译,但不能独立运行,他在运行时被链接到内核并作为内核的一部分在内核空间运行

④ 内核模块本质上是一段内核代码,他运行在CPU的特权层(Ring 0 on X86),与内核代码具有相同的功能

5.2 程序示例

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
	printk("<1> Hello world, from the kernel space\n");
	return 0;
}

static void __exit hello_exit(void)
{
	printk("<1> Goodbye world, from the kernel space\n");
}

module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");

5.2.1 头文件说明

头文件

主要内容

linux/module.h

包含了对模块的结构定义以及模块的版本控制任何模块程序都要包含

linux/kernel.h

包含了常用的内核函数,比如示例中使用的printk

linux/init.h

包含了__init宏__exit宏

补充:printk函数的使用和printf函数类似,把打印信息输出到终端或系统日志(根据输出级别)。注意,printk不能打印浮点数

5.2.2 __init & __exit宏分析

#define __init      __section(.init.text) __cold notrace 
#define __exit		__section(.exit.text) __exitused __cold

/*Simple shorthand for section definition*/
#ifdef __section
#define __section(S) __attribute__ ((__section__(#S)))
#endif

根据上述宏定义,__init 就是将模块初始化函数链接到.ini.text段__exit 就是将模块退出清理函数链接到.init.exit段。将这类函数链接到特定位置,可以便于控制这部分内存的释放,具体来说,

① .init.text 段所在内存在模块被加载后将被释放

② .exit.init 段在模块编译进内核或内核不允许卸载模块时将被释放。若是可动态加载模块,模块卸载后,这部分内存将被释放。

说明:__init 宏和__exit 宏属于利用GNU C 的attribute 关键字进行手动优化

hello_init和hello_exit分别是该模块初始化函数退出清理函数,分别被module_init和module_exit宏修饰,下面分析下这2个宏:

5.2.3 module_init宏分析

分析module_init宏的注释可知,

① 所修饰函数的性质

模块初始化入口点

模块初始化函数调用时机

a. 如果该模块被编译进内核(builtin),在调用do_initcalls函数时被调用

b. 如果该模块可动态加载,在模块加载时被调用

说明:模块初始化函数只被调用一次

下面接着分析__initcall宏的逐层实现:

回顾一下调用过程,

module_init(hello_init) ---> __initcall(hello_init) ---> device_initcall(hello_init) ---> __define_initcall("6", hello_init, 6)

而__define_initcall展开后如下,

static initcall_t  __initcall_hello_init_6 __used    __attribute__((__section__(".initcall""6"".init"))) = hello_init

也就是定义了一个函数指针__initcall_hello_init_6,将其指向hello_init 函数,并被链接到.inicall6.init 段

小结一下__int 宏和module_init 宏的作用和关系:

① __init 宏将模块初始化函数链接到.init.text 段,目的是及时释放该部分内存

② module_init 宏定义了一个函数指针,指向该模块的初始化函数,这个指针本身也被链接到指定区域(.initcall6.init),目的是在内核启动或模块加载时调用模块初始化函数

5.2.4 module_exit宏分析

① 所修饰函数的性质

模块退出入口点

模块退出函数调用时机

a. 如果该模块可动态加载,在卸载模块时调用(此时还会调用cleanup_module函数)

b. 如果编译进内核,该函数没有作用

下面接着分析__exitcall宏的逐层实现,

可见对于模块退出清理函数也定义了一个函数指针,该指针被链接到.exitcall.exit段

5.3 Makefile说明

MOD_NAME = hello_module
obj - m = $(MOD_NAME).o

CURRENT_PATH = $(shell pwd)
LINUX_KERNEL_PATH = / lib / modules / $(shell uname - r) / build

all :
make - C $(LINUX_KERNEL_PATH) M = $(CURRENT_PATH) modules

clean :
make - C $(LINUX_KERNEL_PATH) M = $(CURRENT_PATH) clean

说明1:模块的目标必须是obj-m,这是在内核的Makefile中定义的

说明2:编译模块的源文件名必须和模块目标名相同。比如此处目标名为hello_module.o,那么对应的源文件名必须是hello_module.c,否则编译不会成功

obj-m = hello.o 的意思就是有一个模块要从目标文件hello.o 构造,而从该目标文件中构造的模块名称为hello.ko

说明3:如果一个模块由多个源文件构成,编译时可以使用module-objs来扩展

obj-m = module.o module-objs = file1.o file2.o

说明4:编译模块时要指定该模块依附的Linux源码目录,而该源码目录编译出的内核就是该模块即将在其上运行的内核

5.4 模块加载和运行

只有root用户才有权限加载 / 卸载内核模块

② 可使用dmesg命令查看模块加载 / 卸载时的打印信息

说明:关于dmesg命令

 

 

补充:也可以查看/var/log/syslog日志,获取模块加载 / 卸载信息

5.5 应用程序与内核模块比较

说明1:应用程序大多执行任务,而内核模块只是预先注册自己,以便服务于将来的某个请求

说明2:应用程序退出时,可以不进行资源的释放和清理工作(虽然这不是个好习惯)。但是模块的退出函数必须撤销初始化函数所做的一切,否则在系统重启之前,未释放的资源会残留在系统中

说明3:应用程序的链接过程能够解析外部引用从而使用适当的函数库;但模块仅仅被链接到内核,因此只能调用内核导出的函数

说明:应用程序中的段错误是无害的,并且可以用调试器追踪源代码。但内核错误即使不影响整个系统,也会至少kill掉当前进程

说明5:应用程序在虚拟内存中布局,有很大的栈空间。内核栈则很小,且要和整个内核空间的调用链共享。因此如果需要大的结构,应该动态分配

6. Linux内核链表实现分析

6.1 内核链表类型定义

struct list_head
{
	struct list_head *next, *prev;
};

说明1:内核链表为带头节点的双向循环链表

说明2:内核链表最大的特点是只包含指针域,在使用时可嵌入任何结构

说明3:list_head与被嵌入的数据结构的关系类似基类和派生类的关系,list_head就是链表这一抽象概念的基类

6.2 list_entry宏分析

由于list_head被嵌入到具体的结构中,所以要解决的问题就是如何通过链表节点的地址计算出嵌入对象节点的地址,实现该目标的就是list_entry宏:

说明1:list_entry宏参数说明

ptr:结构体成员的指针,指向list_head节点

type:整个结构体的类型

member:第1个参数在整个结构体中的成员名称

说明2:list_entry宏计算原理

struct list_head {
struct list_head *next, *prev;
};

struct list_head *tmp;
struct usb_hub *hub;

hub = list_entry(tmp, struct usb_hub, event_list);

#define list_entry(ptr, type, member) \
((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))

list_entry宏的计算过程,就是先找出list_head(指针域)在结构体中的偏移量,然后ptr再减去这个偏移量就得到了结构体的地址

备注:

核心思想:通过成员地址计算整体地址

说明3:在container_of 宏中定义__mptr 变量是为了避免宏调用中的副作用:例如++操作等

6.3 链表的定义和初始化

6.3.1 定义 + 初始化

#define LIST_HEAD_INIT(name) {&(name), &(name)}

#define LIST_HEAD(name) \
    struct list_head name = LIST_HEAD_INIT(name) // 结构体初始化 

示例:
LIST_HEAD(mylist); // 定义链表mylist,并将其初始化为空表,mylist为头节点

6.3.2 初始化函数

static inline void INIT_LIST_HEAD(struct list_head *list)
{
	list->next = list;
	list->prev = list;
}

示例:
struct list_head mylist;
INIT_LIST_HEAD(&mylist);

6.4 在链表中插入节点

//插入节点的算法依赖于__list_add函数
static inline void __list_add(struct list_head *new,
	struct list_head *prev,
	struct list_head *next)
{
	next->prev = new;
	new->next = next;
	new->prev = prev;
	prev->next = new;
}

说明:__list_add函数仅插入一个节点,并不考虑插入的位置

static inline void list_add(struct list_head *new,
	struct list_head *head)
{
	__list_add(new, head, head->next);
}

static inline void list_add_tail(struct list_head *new,
	struct list_head *head)
{
	__list_add(new, head->prev, head);
}

说明1:list_add在头节点和第1个节点之间插入,实现在链表表头插入节点

说明2:list_add_tail在最后1个节点和头节点之间插入,实现在链表表尾插入节点

6.5 在链表中删除节点

static inline void __list_del(struct list_head *prev,
	struct list_head *next)
{
	next->prev = prev;
	prev->next = next;
}

static inline void list_del(struct list_head *entry)
{
	__list_del(entry->prev, entry->next);
	entry->next = LIST_POISON1;
	entry->prev = LIST_POISON2;
}

说明1:LIST_POISON1和LIST_POISON2宏定义了两个不可访问的地址,如果访问他们,会导致页错误

说明2:list_del函数只将节点从链表中断开,并没有释放节点内存(e.g. 调用kfree释放内存),这需要编程者根据自身需要处理

6.6 遍历链表

6.6.1 普通遍历(一般仅用于访问)

#define list_for_each(pos, head) \
    for (pos = (head)->next; prefetch(pos->next), pos != (head); \
    pos = pos->next)

说明1:从list_for_each宏的实现分析,Linux内核链表均默认包含头节点

说明2:prefetch宏分析

在遍历链表时,在判断遍历是否到达终点之前,对链表下一节点的地址调用了prefetch宏,该宏定义如下(include/linux/prefetch.h)

① prefetch宏的目的是预先将内存中指定地址处的数据读取到CPU的L1 cache中

② 如果内核中没有定义ARCH_HAS_PREFETCH宏,也就是没有基于体系结构实现的prefetch操作,将会使用GCC编译器内置的__builtin_prefetch函数

③ ARM架构的prefetch操作定义在arch/arm/include/asm/process.h文件中,具体如下,

可见ARM架构中预取函数的核心就是PLD指令,该指令含义如下,

6.6.2 安全遍历(允许在遍历过程中删除节点)

#define list_for_each_safe(pos, n, head) \
    for (pos = (head)->next, n = pos->next; pos != (head); \
    pos = n, n = pos->next)

说明:此处增加了临时变量n用于保存当前节点的后继节点地址,所以可以在遍历过程中安全地删除pos指向的节点

7. Linux内核哈希表实现分析

7.1 哈希表概述

① 哈希表(Hash Table,也称作散列表)是根据关键字(Key value)直接进行访问的数据结构,可以把Key value映射到表中的一个位置来访问记录,从而加快查找的速度

② 如上图中所示,不同的Key value可能被映射到相同的位置,这种情况称作哈希值冲突。Linux内核中使用链地址法解决哈希值冲突,即将哈希值相同的成员以链表的方式存储

7.2 内核哈希表类型定义

struct hlist_node {
	struct hlist_node *next, **pprev;
};
struct hlist_head {
	struct hlist_node *first;
};

说明1:二级指针的操作

type C; type *B = &C; type **A = &B; // 因此,*A就是B,**A就是C

说明2:struct hlist_head是哈希表头结点;struct hlist_node是哈希表数据结点,可见与双向链表不同,头节点与数据结点类型不同,后文会分析为何会不同

其中struct hlist_head中的first域指向哈希表链表的首个成员;struct hlist_node的next域指向哈希表链表的后继结点,pprev域指向前驱结点的next域

最终形成下图的结构:

说明3:内核哈希表类型定义详解

内核哈希表头结点与数据结点类型的主要差异在于2点,

① 指针域个数不同,头节点只有一个指针域

② 数据结点的pprev不是指向完整的前驱结点,而只是指向前驱结点的next字段,而这点又是因为头结点只有一个指针域

先说答案:内核如此定义的目的是为了在满足功能 & 性能的同时减少内存使用。然后我们来讨论下其他方案的缺点,

① 使用单链表

使用单链表可以统一头结点与数据结点的类型,但是删除指定成员时需要遍历链表,时间复杂度为O(n)

② 均使用数据结点类型

会导致表头结点的内存用量翻倍,而表头结点的个数是和哈希表entry个数一一对应,所以会浪费空间

说明4:内核中典型的哈希表pid_hash,pid_hash定义在kernel/pid.c文件中

其实pid_hash是PIDTYPE_MAX个哈希表,在pidhash_init函数中为其分配空间

当创建进程时,会以pid为索引,将task_struct加入pid_hash哈希表,后续使用find_task_by_pid函数根据pid找到对应task_struct

7.3 哈希表的定义和初始化

// 定义一个空的哈希表头结点
#define HLIST_HEAD(name) struct hlist_head name = {  .first = NULL }
// 初始化一个哈希表为空表
// pidhash_init函数中有调用
#define INIT_HLIST_HEAD(ptr) ((ptr)->first = NULL)
// 初始化一个空的哈希表数据结点
#define INIT_HLIST_NODE(ptr) ((ptr)->next = NULL, (ptr)->pprev = NULL)

说明1:对于哈希表头结点,可以使用hlist_empty函数判断是否是空表

static inline int hlist_empty(const struct hlist_head *h)
{
	return !h->first;
}

说明2:对于哈希表数据结点,可以使用hlist_unhashed函数判断是否已经加入哈希表

static inline int hlist_unhashed(const struct hlist_node *h)
{
	return !h->pprev;
}

7.4 在哈希表中删除结点

static inline void __hlist_del(struct hlist_node *n)
{
	struct hlist_node *next = n->next;
	struct hlist_node **pprev = n->pprev;
	*pprev = next; // 让前驱结点的next域指向后继结点
	if (next) // 如果后继结点非空,让后继结点的pprev域指向前驱结点的next域
		next->pprev = pprev;
}
static inline void hlist_del(struct hlist_node *n)
{
	// 删除结点
	__hlist_del(n);
	// 将被删除结点的指针域指向无效位置
	n->next = LIST_POISON1;
	n->pprev = LIST_POISON2;
}

7.5 在哈希表中插入结点

// 将数据结点n插入哈希表链表头部位置
static inline void hlist_add_head(struct hlist_node *n,
	struct hlist_head *h)
{
	struct hlist_node *first = h->first;
	n->next = first;
	if (first) // 哈希表非空,调整原首个数据结点的pprev域
		first->pprev = &n->next;
	h->first = n;
	n->pprev = &h->first; // 可以看出,头结点的first域相当于数据结点的next域
}

// 将数据结点n插入数据结点next之前
// 调用时需要确保next不为空
static inline void hlist_add_before(struct hlist_node *n,
	struct hlist_node *next)
{
	// 仍然需要调整4个指针域
	n->pprev = next->pprev;
	n->next = next;
	next->pprev = &n->next;
	*(n->pprev) = n;
}
// 将数据结点next插入数据结点n之后
// 调用时需要确保next不为空
static inline void hlist_add_after(struct hlist_node *n,
	struct hlist_node *next)
{
	next->next = n->next;
	n->next = next;
	next->pprev = &n->next;

	if (next->next) // 处理n为尾结点,next在尾结点之后插入
		next->next->pprev = &next->next;
}

7.6 遍历哈希表

#define hlist_for_each(pos, head) \
	for (pos = (head)->first; pos && ({ prefetch(pos->next); 1; }); \
	     pos = pos->next)

#define hlist_for_each_safe(pos, n, head) \
	for (pos = (head)->first; pos && ({ n = pos->next; 1; }); \
	     pos = n)

说明:此处的prefetch仍然用于实现预取

备注:关于hasp_map可参考:

https://www.cnblogs.com/leebxo/p/10834933.html

https://www.cnblogs.com/williamjie/p/9099141.html

https://blog.csdn.net/yyyljw/article/details/80903391

猜你喜欢

转载自blog.csdn.net/chenjinnanone/article/details/109074597