【Linux】Linux基础知识(Linux系统、Linux中的链表)

Linux系统简介

Linux系统的结构及特点

Linux系统的结构图如下图所示:


从上图可以看出,Linux是一个典型的宏内核(一体化内核)结构。硬件系统上面时硬件抽象层,在硬件抽象层上面时内核服务功能模块,这些模块通过系统调用接口向用户进程提供服务。

  • Linux进程管理的系统调用包括:进程的创建、调度、中止、等待等。
  • Linux支持内存管理控制器MMU,使用虚拟内存管理机制。虚拟内存管理系统调用包括:内存分配、内存回收、请求分页和交换页等。
  • 由于Linux使用了虚拟文件管理系统VFS,从而使它能够支持不同的文件系统。文件管理系统允许用户进程通过一组通用的系统调用(例如:open、close、read、write、chmod等)对不同文件系统中的文件进行访问。

所谓嵌入式Linux,是指对标准Linux经过小型化剪裁处理之后,能够固化在容量只有几KB或者几MB的存储器芯片或者单片机中,适合于特定嵌入式应用场合的专用Linux操作系统。

与其他的操作系统相比,Linux具有以下一系列显著的特点:

  • 模块化程度高:Linux的内核设计非常精巧,分成进程调度、内存管理、进程间通信、虚拟文件系统和网络接口五大部分;其独特的模块机制可根据用户的需要进行裁剪,很适合嵌入式系统的需求;
  • 源码公开;
  • 广泛地硬件支持:Linux能支持x86、ARM、MIPS、ALPHA和PowerPC等多种体系结构的微处理器;
  • 安全性和可靠性好;
  • 具有优秀的开发工具:嵌入式Linux为开发者提供了一套很完整的工具链,能够很方便地实现从操作系统到应用软件的各个级别的调试;
  • 有很好的网络支持和文件系统支持。

但由于Linux不是为实时而设计的,因此这就成了Linux在实时系统中应用的最大遗憾。

Linux的内核版本

Linux的内核版本号的编排规则为“x.yy.zz”,其中:

  • x的取值范围为0-9;
  • yy的取值范围为0-99;
  • zz的取值范围为0-99。


Linux系统的嵌入式应用

实现实时Linux的思路

Linux应用于实时应用中时存在如下的一些问题:

  • Linux系统中的调度单位为10ms,所以它不能够提供精确的定时;
  • 当一个进程调用系统调用进入内核态运行时,它是不可被抢占的;
  • Linux内核实现中使用了大量的封中断操作会造成中断的丢失;
  • 由于使用虚拟内存技术,当发生页出错时,需要从硬盘中读取交换数据,但硬盘读写由于存储位置的随机性会导致随机的读写时间,这在某些情况下会影响一些实时任务的截止期限;
  • 虽然Linux进程调度也支持实时优先级,但缺乏有效的实时任务的调度机制和调度算法;
  • 它的网络子系统的协议处理和其它设备的中断处理都没有与它对应的进程的调度关联起来,并且它们自身也没有明确的调度机制。

在解决Linux系统实时性问题的思想方法方面,有如下的四个思路:

  • 提高时钟精度,解决中断和调度延时问题;
  • 解决在Linux内核中不允许调度的问题;
  • 提供对于实时多媒体应用的支持,包括引入新颖的调度算法(网络包调度、进度调度和磁盘调度)
  • 引入新颖的调度框架以及资源管理思想,以更好地支持网络系统中的QoS要求。

UCLinux

UCLinux是一种由Linux内核发展而来的嵌入式Linux版本,是专门没有MMU的微处理器设计的嵌入式Linux操作系统。

UCLinux系统采用romfs文件系统,它是一种相对简单、占用空间较少的文件系统。空间的节约来自两方面:

  • 首先内核支持romfs文件系统比支持Ext2文件系统需要更少的代码;
  • 其次romfs文件系统相对简单,在建立文件系统超级块时需要更少的存储空间。

romfs是只读的文件系统,禁止写操作,因此系统同时需要虚拟盘支持临时文件和数据文件的存储。但是UCLinux对实时性的支持方面并不好。

RT-Linux

为了提高Linux的实时性能,比较引人注意的就是RT-Linux。

RT-Linux的设计思想极为简单有效:单独设计一个剥夺性实时微内核,并由这个内核来管理处理器;所有的实时进程,包括普通Linux都运行在这个微内核之上。也就是说,在RT-Linux中把Linux也看成是与其他实时进程一样的一个进程,但它是一个优先级最低的进程。这样,就可以把实时进程交给微内核管理,而非实时进程交给普通Linux内核处理。

RT-Linux的设计思想及系统结构图,如下图所示:


从图中可以看到,实时进程是由实时微内核RT-Linux来管理的,普通进程是由Linux来管理的,而Linux又是由微内核RT-Linux管理的一个优先级最低的进程。也就是说,Linux也被看成了一个实时进程,只不过其优先级最低,因此其处理器使用权可被所有的实时进程剥夺。

在实现上,RT-Linux的关键技术是通过软件来模拟硬件的中断控制器。这个中断控制器主要有两个作用:

  • 当Linux系统要封锁CPU的中断时时,RT-Linux中的实时子系统会截取到这个请求,把它记录下来,而实际上并不真正封锁硬件中断,这样就避免了由于封中断所造成的系统在一段时间没有响应的情况,此时处理器却可以响应实时中断的,从而提高了实时性;
  • 当有硬件中断到来时,RT-Linux截取该中断,并判断是否有实时子系统中的中断例程来处理、还是传递给普通的Linux内核进行处理。

另外,普通Linux系统中的最小定时精度由系统中的实时时钟的频率决定,一般Linux系统将该时钟设置为每秒来100个时钟中断,所以Linux系统中一般的定时精度为 10ms,即时钟周期是10ms,而RT-Linux采用的是终端计时中断方式。可以根据最近的进程的时间需要,不断地调整定时器的定时间隔,可以提供十几个微秒级的调度粒度。

Kurt-Linux

不同于RT-Linux单独实现一个实时内核的做法,Kurt -Linux是在通用Linux系统的基础上实现的,它也是第一个可以使用普通Linux系统调用的基于Linux的实时系统。

Kurt-Linux将系统分为三种状态:正常态、实时态和混合态,在正常态时它采用普通的Linux的调度策略,在实时态只运行实时任务,在混合态实时和非实时任务都可以执行;实时态可以用于对于实时性要求比较严格的情况。

为了提高Linux系统的实时特性,必须提高系统所支持的时钟精度。但如果仅仅简单地提高时钟频率,会引起调度负载的增加,从而严重降低系统的性能。为了解决这个矛盾,Kurt-Linux采用的提高Linux系统中的时钟精度的方法:它将时钟芯片设置为单次触发状态(One shot mode),即每次给时钟芯片设置一个超时时间,然后到该超时事件发生时在时钟中断处理程序中再次根据需要给时钟芯片设置一个超时时间。它的基本思想是一个精确的定时意味着我们需要时钟中断在我们需要的一个比较精确的时间发生,但并非一定需要系统时钟频率达到此精度。

缺点就是:Kurt-Linux所采用的这种方法需要频繁地对时钟芯片进行编程设置。

RED-Linux

RED-Linux将对实时调度的支持和Linux很好地实现在同一个操作系统内核中。它同时支持三种类型的调度算法,即:Time-Driven、Priority-Dirven、Share-Driven。

为了提高系统的调度粒度,RED-Linux从RT-Linux那儿借鉴了软件模拟中断管理器的机制,并且提高了时钟中断频率。当有硬件中断到来时,RED-Linux的中断模拟程序仅仅是简单地将到来的中断放到一个队列中进行排队,并不执行真正的中断处理程序。

另外为了解决Linux进程在内核态不能被抢占的问题, RED-Linux在Linux内核的很多函数中插入了抢占点原语,使得进程在内核态时,也可以在一定程度上被抢占。通过这种方法提高了内核的实时特性。

RED-Linux的设计目标就是提供一个可以支持各种调度算法的通用的调度框架,该系统给每个任务增加了如下几项属性,并将它们作为进程调度的依据:

  • Priority:作业的优先级;
  • Start-Time:作业的开始时间;
  • Finish-Time:作业的结束时间;
  • Budget:作业在运行期间所要使用的资源的多少;

通过调整这些属性的取值及调度程序按照什么样的优先顺序来使用这些属性值,几乎可以实现所有的调度算法。这样的话,可以将三种不同的调度算法无缝、统一地结合到了一起。


Linux中的C语言和汇编语言

Linux中的C语言

在Linux内核中使用的C语言与通常的有所不同,它的编译器为gcc。例如:再定义一个结构体类型的对象时,不像普通C语言那样只使用结构名,而是在结构名前面还要有关键字struct。例如下面的定义:

struct student {
    ...
}

在普通C语言中定义变量的形式为:

student S;

而在gcc的C语言中,上述的定义则为:

struct student S;

Linux中的汇编语言

通常见到或使用的是Intel格式的汇编语言,而gcc采用的是AT&T的汇编格式语言。

基本语法

这两种汇编语言在基本语法的主要有以下几个不同:

  • 寄存器命名原则。在AT&T汇编指令中,在寄存器的名称前面要带有前缀%,例如“%eax”;
  • 源/目的操作数顺序。在AT&T汇编语言数据传输指令中,数据的传递方向与Intel指令的方向相反。例如:Intel指令“mov ebx,eax”在ST&T中为“movl %eax,%ebx”;
  • 常数的格式。在AT&T指令中的常数要带前缀$,例如“movl $_value,%ebx”,在Intel中为“mov eax,_value”;
  • 寄存器间接寻址。在使用寄存器间接寻址方式时,在AT&T指令中使用“()”,而不像Intel汇编那样使用“[]”,例如“(%eax)”。
嵌入C代码中的行内汇编

在行内汇编方面比较简单,一般的格式为asm("statements")。asm与__asm__是完全一样的。如果有多行汇编,则每一行都要加上“\n\t”。例如:

asm("pushl %eax\n\t"
    "movl $0,%eax\n\t"
    "popl %eax");


Linux中的链表

Linux链表的设计思想

由于双链表有一个共同的特点,即它们都有两个指针域,分别指向链表的前一个节点和后一个节点。Linux设计者就将这两个指针定义成一个标准的结构,于是,在linux/list.h中定义了一个具有两个指针并叫做list_head的结构:

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

这样,我们就有了一个空链表,因为Linux用头指针的next是否指向自己来判断链表是否为空:

static inline int list_empty(const struct list_head *head)
{
        return head->next == head;
}

链表头的创建及链表节点的插入

为了使用户可在系统初始化时创建一个链表头,Linux在文件linux/list.h中提供了宏LIST_HEAD()。其定义如下:

#define LIST_HEAD_INIT(name) {&(name),&(name)}
#define LIST_HEAD(name)\
    struct list_head name=LIST_HEAD_INIT(name);

这里理解一下结构体的初始化,例如:

struct student stu = {"张三","男",18} ;

当用LIST_HEAD(student_list)声明一个名为student_list的链表头时,其next和prev指针都将被初始化为指向自身。创建的链表头如下图所示:


除可用LIST_HEAD()宏在初始化时创建一个链表头以外,Linux还提供了另一个可在运行时创建链表头的宏INIT_LIST_HEAD()。在文件linux/list.h中这个宏的定义如下:

#define INIT_LIST_HEAD(ptr) do{\
    (ptr)->next=(ptr); (ptr)->prev=(ptr); \
}while(0)

插入节点

创建了链表头之后,就可在需要时向链表中插入节点了。在链表的头部插入一个节点的函数如下:

static inline void list_add(
    struct list_head *new,            //待插入结点
    struct list_head *head            //链表头
    )
{
    __list_add(new,head,head->next);
}

其中:__list_add()的定义如下:

static inline void __list_add(
    struct list_head *new,                //待插入节点
    struct list_head *prev,                //链表头
    struct list_head *next                //链表头的next
    )
{
    next->prev=new;
    new->next=next;
    new->prev=prev;
    prev->next=new;
}

这个函数的作用是将一个新节点new插入链表的头部。

需要注意的是:

  • 我们看到整个list_add()函数的所以参数都是list_head的类型,不是用户结构(比如:student)类型!我们以前都习惯使用用户结构类型变量作为函数的参数,通过该变量来引用其成员!
  • 在定义的这个链表结构中,head指针实际上是链表尾,head->next才是链表头!或者理解成添加一个节点,是往head的后面添加。

也就是说,双向链表的结构如下:


也可在链表的尾部插入一个节点,该函数的原型如下:

static inline void list_add_tail(struct list_head *new, struct list_head *head);

链表节点宿主结构的访问

之前说到,链表中的节点操作都是按照list_head类型算的,但list_head又是用户结构的成员,所以根据list_head在结构体中的位置可以经过适当的运算,可通过list_head来访问以list_head为成员的用户结构,即其宿主结构。Linux为此提供了一个list_entry()宏:

#define list_entry(ptr,type,member) \
    container_of(ptr,type,member)

在文件linux/kernel.h中定义的container_of()如下:

#define container_of(ptr,type,member) ({\
    const typeof(((type *)0)->member) *_mptr=(ptr);\
    (type *)((char *)_mptr-offsetof(type,member));})

其中,ptr是指向用户结构中list_head成员的指针,也就是它在链表中的地址值;type为用户结构;member为用户结构中list_head成员的变量名。

例如:访问student_list链表中首个student_struct变量,则如此调用:

list_entry(student_list->next,struct student_struct,list);

这里的list正是struct student_struct结构中定义的list_head类型的成员变量名。

这里list_entry的结构:使用了编译器的一个小技巧,即先求得结构体成员在结构体中的偏移量,然后根据成员变量的地址反过来得出宿主结构变量的地址。

container_of()和offsetof()并不仅用于链表操作,这里最有趣的地方是:

((type *)0)->member

将0地址强制转换为type结构的指针,再访问type结构中的member成员。在container_of宏中,它用来给typeof()提供参数,已获得member成员的数据类型;在offsetof()中,这个member成员的地址实际上就是type结构中member成员相对于结构变量的偏移量。其示意图如下所示:


链表的遍历

可使用宏list_of_each()来遍历一个链表。该宏有两个参数:第一个参数用来指向当前项,第二个参数为需要遍历的链表指针。在每次遍历时,第一个参数随着遍历在链表中不断移动,直到每个节点都被访问。

在文件include/linux/list.h中,宏list_for_each()的定义为:

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

实际上它是一个for循环,利用传入的pos作为循环变量,从表头head开始,逐渐往后移动pos,直到又回到head。(prefetch()可不考虑,用于预取,以提供遍历速度)

可以看到list_foe_each()函数的参数有pos变量,是一个list_head类型的变量。所以在使用该宏进行遍历时,首先需要定义一个(struct list_head*)指针变量,然后才能遍历。

绝大多数情况下,遍历链表时都需要获得链表节点数据项,也就是说:list_for_each()和list_entry()总是同时使用。为此,Linux给出了一个list_for_each_entry()宏:

#include list_for_each_entry(pos,head,member)

与list_for_each()不同的是,这里的pos是数据项结构指针类型,不是(struct list_head*)指针变量。

哈希链表

Linux链表设计者认为双头(next、prev)的双链表对于HASH表来说“过于浪费”,因此设计了一套用于HASH表应用的hlist数据结构,它属于单指针表头双循环链表。它的结构如下图所示:



可以看出,哈希链表的表头仅有之个指向首节点的指针,而没有指向尾节点的指针,这样在可能是海量的HASH表中存储的表头就能减少一半的空间消耗。

在文件include/linux/list.h中定义了hlist链表结构如下:

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

因为表头和节点的数据结构不同,所以插入操作如果发生在表头和首节点之间,以往的方式就行不通了:表头的first指针必须修改指向新插入的节点,切不能使用类似于list_add()这样统一的描述。

为此,hlist节点的prev不再是指向前一个节点的指针,而是指向前一个节点(可能是表头)中的next(对于表头,则是first)指针(struct list_head **pprev),从而在表头插入的操作可以通过一致的“*(node->pprev)”访问和修改前驱节点的next(或first)指针。

这里回答关于hlist的两个问题:

1、Linux 中的hlist和list是不相同的,在list中每个结点都是一样的,不管头结点还是其它结点,使用同一个结构体表示,但是在hlist中,头结点使用的是struct hlist_head来表示的,而对于其它结点使用的是strcuct hlist_node这个数据结果来表示的。还有list是双向循环链表,而hlist不是双向循环链表。因为hlist头结点中没有prev变量。为什么要这样设计呢?

解答:散列表的目的是为了方便快速的查找,所以散列表通常是一个比较大的数组,否则“冲突”的概率会非常大,这样就失去了散列表的意义。如何来做到既能维护一张大表,又能不占用过多的内存呢?此时只能对于哈希表的每个entry(表头结点)它的结构体中只能存放一个指针。这样做的话可以节省一半的指针空间,尤其是在hash bucket很大的情况下。(如果有两个指针域将占用8个字节空间)

2、hlist的结点有两个指针,但是pprev是指针的指针,它指向的是前一个结点的next指针,为什么要采用pprev,二不采用一级指针?

由于hlist不是一个完整的循环链表,在list中,表头和结点是同一个数据结构,直接用prev是ok的。在hlist中,表头中没有prev,只有一个first。

为了能统一地修改表头的first指针,即表头的first指针必须修改指向新插入的结点,hlist就设计了pprev。list结点的pprev不再是指向前一个结点的指针,而是指向前一个节点(可能是表头)中的next(对于表头则是first)指针(这是因为next是一个指针,指向指针的指针,二级指针),从而在表头插入的操作中可以通过一致的node->pprev访问和修改前结点的next(或first)指针;

还解决了数据结构不一致,hlist_node巧妙的将pprev指向上一个节点的next指针的地址,由于hlist_head和hlist_node指向的下一个节点的指针类型相同,就解决了通用性。

关于hlist如果有不太了解的,可以参考文章:Linux内核哈希表分析与应用

猜你喜欢

转载自blog.csdn.net/qq_38410730/article/details/80990756
今日推荐