ULK 进程

 Linux  Kernel  进程 󰈭 32394字

进程、轻量级进程和线程

进程是资源分配的实体,而线程是作业调度的基本单位。Linux使用轻量级进程来支持多线程应用程序,轻量级进程之间可以共享一部分资源,诸如地址空间、打开的文件等,通过将轻量级进程和线程关联起来就可以实现多线程应用程序。

进程描述符

进程描述符包含了与一个进程有关的所有信息,内容相当复杂。

进程拥有多种特殊资源,本章主要说明两种字段:进程的状态和进程间的父子关系。

进程的状态

可运行状态 TASK_RUNNING

此时,程序已被挂入运行队列,处于准备运行状态。一旦获得处理器使用权,即可进入运行状态。当进程获得处理器而运行时 ,state的值仍然为TASK_RUNNING,并不发生改变;但Linux会把一个专门用来指向当前运行任务的指针current指向它,以表示它是一个正在运行的进程。

可中断的等待状态 TASK_INTERRUPTIBLE

进程等待某些资源,被挂起。产生硬件中断、获得系统资源或传递一个信号都可能可以唤醒进程。

不可中断等待状态 TASK_UNINTERRUPTIBLE

这个状态被应用在内核中某些场景中,比如当进程需要对磁盘进行读写,而此刻正在DMA中进行着数据到内存的拷贝,如果这时进程休眠被打断(比如强制退出信号)那么很可能会出现问题,所以这时进程就会处于不可被打断的状态下。

暂停状态 TASK_STOPPED

收到SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU信号后,进入暂停状态。

跟踪状态 TASK_TRACED

程序由debugger程序暂停。

僵死状态 EXIT_ZOMBIE

子进程结束后父进程未调用wait类系统调用,则子进程成为僵尸进程。

僵尸撤销状态 EXIT_DEAD

进程即将释放它的所有资源,为了防止其余线程执行wait指令,将zombie状态转为dead状态。

最后两个字段既可以存放在进程描述符的state字段中,也可以存放在exit_state中。

内核链表

双向链表

对于每个链表都实现一套源于操作会造成较大的浪费,因而Linux内核定义了list_head的结构。

c
1struct list_head {
2    struct list_head *next, *prev;
3};

针对这个双向链表,有一些宏、函数操作。

初始化

LIST_HEAD_INIT和LIST_HEAD都可以进行初始化(实质做的事情其实就是令前驱和后继都指向自身),不同的是前者需要提供一个实体的指针,而后者直接通过一个名称即可创建。

c
1#define LIST_HEAD_INIT(name) { &(name), &(name) }
2
3#define LIST_HEAD(name) \
4    struct list_head name = LIST_HEAD_INIT(name)
5
6#define INIT_LIST_HEAD(ptr) do { \
7    (ptr)->next = (ptr); (ptr)->prev = (ptr); \
8} while (0)

增删改查类

事实上,这里实现的双向链表是有头节点的,因而list_add函数是头插入;此外,有头节点这一特点也方便了之后的遍历等操作。

c
1static inline void list_add(struct list_head *new, struct list_head *head);
2static inline void list_add_tail(struct list_head *new, struct list_head *head);
3static inline int list_empty(const struct list_head *head);
4static inline void list_splice(struct list_head *list, struct list_head *head); //从head位置将list拼入当前链表

删除操作比较有趣,他会调用内部函数删除当前链表头,同时将当前元素的双向指针设置为一些非零的地址。这样当非法访问到这些未被初始化的指针时,会产生缺页错误,从而方便我们进一步诊断问题。

c
 1static inline void list_del(struct list_head *entry)
 2{
 3    __list_del(entry->prev, entry->next);
 4    entry->next = LIST_POISON1;
 5    entry->prev = LIST_POISON2;
 6}
 7
 8
 9/*
10 * These are non-NULL pointers that will result in page faults
11 * under normal circumstances, used to verify that nobody uses
12 * non-initialized list entries.
13 */
14#define LIST_POISON1  ((void *) 0x00100100)
15#define LIST_POISON2  ((void *) 0x00200200)

接下来是一些宏操作。

获取入口

list_entry返回一个含有名为member且地址为ptr的成员的type类型结构体的地址,实现方法又进一步调用了include/linux/kernel.h中的container_of宏和include/linux/stddef.h的offsetof宏。

c
1/**
2 * list_entry - get the struct for this entry
3 * @ptr:	the &struct list_head pointer.
4 * @type:	the type of the struct this is embedded in.
5 * @member:	the name of the list_struct within the struct.
6 */
7#define list_entry(ptr, type, member) \
8    container_of(ptr, type, member)
c
 1/**
 2 * container_of - cast a member of a structure out to the containing structure
 3 *
 4 * @ptr:	the pointer to the member.
 5 * @type:	the type of the container struct this is embedded in.
 6 * @member:	the name of the member within the struct.
 7 *
 8 */
 9#define container_of(ptr, type, member) ({			\
10        const typeof( ((type *)0)->member ) *__mptr = (ptr);	\
11        (type *)( (char *)__mptr - offsetof(type,member) );})
c
1#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

对于offsetof宏而言,基地址选为0或其余任何值都不影响,选为0方便计算而已

对于container_of宏而言,第一步利用typeof获取member应有的类型,并将ptr赋给中间变量mptr。事实上完全可以省去这一步直接用ptr减去偏移量,不省略的原因在于,利用编译器在编译阶段对类型进行检查,保证ptr确实是member对应类型的指针。第二步是将该指针转换为char类型并与偏移量相减,这也是一个值的注意的地方,指针相减并不是一般的数值相减,如果两个int指针相减,结果会是实际代数相减的1/4。所以为了获得实际的地址,我们应当把mptr指针强制转换为char 或void*,这样就能获得包含该地址的结构体的首地址辣!

遍历

有两种方式进行遍历,prefetch是gcc用来提高效率的函数,用来预取数据,而__list_for_each则是最简单的遍历方式,linux代码开发者提示我们仅当链表较短时才使用他。

NOTE:仔细考察循环的方式,会发现并不会遍历到包含head的结构体,这就是存在头节点在链表遍历中的一个优势。

c
 1/**
 2 * list_for_each	-	iterate over a list
 3 * @pos:	the &struct list_head to use as a loop counter.
 4 * @head:	the head for your list.
 5 */
 6#define list_for_each(pos, head) \
 7    for (pos = (head)->next; prefetch(pos->next), pos != (head); \
 8            pos = pos->next)
 9
10/**
11 * __list_for_each	-	iterate over a list
12 * @pos:	the &struct list_head to use as a loop counter.
13 * @head:	the head for your list.
14 *
15 * This variant differs from list_for_each() in that it's the
16 * simplest possible list iteration code, no prefetching is done.
17 * Use this for code that knows the list to be very short (empty
18 * or 1 entry) most of the time.
19 */
20#define __list_for_each(pos, head) \
21    for (pos = (head)->next; pos != (head); pos = pos->next)

接下来说明一下prefetch函数。 预取

通过给 CPU 发送一个特殊的 hint 来表明之后某段内存区域马上会被访问,是可能的。在 Intel 64 架构上使用 prefetch 指令来达到这个目的。该指令接收一个内存地址;CPU 会在最近尽量将这些内容载入到缓存中。这样做可以防止 cache miss。

prefetch 使用得好可以很高效,不过一定要进行测试。prefetch 操作应该在数据被访问前进行,但不能和执行执行的时间间隔太接近。缓存的预载是异步进行的,也就是说数据载入和紧跟着的指令执行几乎是同时发生的。如果预取和数据访问时间太接近的话,CPU 可能来不及把数据载入到 cache,数据访问就发生了。这时候就会有 cache-miss。

另外还需要理解一点,与数据访问操作距离的 “近” 和 “远” 指的是指令执行的序列中的指令位置。考虑到程序的结构,我们不应该把 prefetch 放在同一函数内,而应放在数据放问之前的其它位置。可以放在完全不同的另外一个模块,例如,在日志模块中,一般在数据访问之前很可能会被调用到。不过真的这样做将会对代码的可读性造成极大的负面影响,并引入不明显的模块间依赖,这里说的是一种破罐破摔的技巧了。

在 C 语言中使用 prefetch,只需要使用 GCC 内置的:

Text
1Void __builtin_prefetch (const void *addr, ...)

编译器会自动把这个 prefetch 替换为对应架构下的 prefetch 汇编指令。

除了地址之外,函数还接收两个参数,两个数值常量。

  1. 该地址是读(传 0,默认值)还是写(传1)?
  2. 局部性有多强?3 表示最大,一直到 0 表示最小。0 的话表示这个值在使用过之后可以立刻从 cache 中清除,3 表示所以级别的 cache 都应该继续保持该值。

只要 CPU 能够预测下一次内存访问发生在什么位置,那么就会自己进行预取操作。预取在连续的内存访问情况下可以工作得很好,例如进行数组遍历的时候。如果进行随机内存访问的时候,预取就不是那么得高效了。

对应的,有另一种直接返回包含list_head的结构体的指针的遍历方式list_for_each_entry。

c
 1/**
 2 * list_for_each_entry	-	iterate over list of given type
 3 * @pos:	the type * to use as a loop counter.
 4 * @head:	the head for your list.
 5 * @member:	the name of the list_struct within the struct.
 6 */
 7#define list_for_each_entry(pos, head, member)				\
 8    for (pos = list_entry((head)->next, typeof(*pos), member);	\
 9         prefetch(pos->member.next), &pos->member != (head); 	\
10         pos = list_entry(pos->member.next, typeof(*pos), member))

相应的,也有倒序遍历list_for_each_prev(pos, head)和list_for_each_entry_reverse(pos, head, member) ,这里不予赘述。

此外,观察上述遍历代码可以发现,并不支持在遍历时删除一些链表元素,因而链表开发者提供一种安全的遍历方式,使用一个中间变量获得当前链表头的下一个位置,从而允许我们在遍历时删除当前元素。

c
 1/**
 2 * list_for_each_safe	-	iterate over a list safe against removal of list entry
 3 * @pos:	the &struct list_head to use as a loop counter.
 4 * @n:		another &struct list_head to use as temporary storage
 5 * @head:	the head for your list.
 6 */
 7#define list_for_each_safe(pos, n, head) \
 8    for (pos = (head)->next, n = pos->next; pos != (head); \
 9        pos = n, n = pos->next)
10
11
12/**
13 * list_for_each_entry_safe - iterate over list of given type safe against removal of list entry
14 * @pos:	the type * to use as a loop counter.
15 * @n:		another type * to use as temporary storage
16 * @head:	the head for your list.
17 * @member:	the name of the list_struct within the struct.
18 */
19#define list_for_each_entry_safe(pos, n, head, member)			\
20    for (pos = list_entry((head)->next, typeof(*pos), member),	\
21        n = list_entry(pos->member.next, typeof(*pos), member);	\
22         &pos->member != (head); 					\
23         pos = n, n = list_entry(n->member.next, typeof(*n), member))

除了上述说明的一些操作,还有rcu、conitnue等一些其余的功能,暂不进行深入学习。

哈希链表

Linux 内核 hlist 详解

首先给出hlist的数据结构。在哈希表中hlist_head被组织起来,每个hlist_head则指向一个hlist_node结构。在hlist_node中,next指向下一个node,而prev则指向上一个node的next字段。

c
 1/*
 2 * Double linked lists with a single pointer list head.
 3 * Mostly useful for hash tables where the two pointer list head is
 4 * too wasteful.
 5 * You lose the ability to access the tail in O(1).
 6 */
 7
 8struct hlist_head {
 9    struct hlist_node *first;
10};
11
12struct hlist_node {
13    struct hlist_node *next, **pprev;
14};

使用两个数据结构进行设计是有深意的。首先对于一个哈希表而言,其必定存在大量的表项以尽可能避免冲突,因而对于每一项而言我们仅给他一个哈希链表的指针(即first)来节省空间,接下来我们使用pprev双指针来维护一致性,具体来说,我们可以考虑删除的情形:对于一个有前驱和后继的传统节点而言,删除一个元素n可以使用如下的方式:

c
1n->prev->next = n->next;
2n->next->prev = n->prev;

但是在这里这个方法并不奏效。对于除了首节点以外的所有节点,上述删除方式都是正确的,但是对于首节点而言,n->prev并不含有next指针,此方法不可行。如果单独考虑首节点的情况,不仅不优美更会影响效率。因而内核链表设计者给出了这样的方案,并不使用prev指向前一个节点,而是使用pprev指向前一个节点的next域,这其实是一个不影响功能实现的进一步细化,使得首节点也能很好地保持一致性。

参考下述的删除代码会有一个更好的理解。无论将要删除的节点位于链表何处,下述代码都能正确的运行。

c
1static inline void __hlist_del(struct hlist_node *n)
2{
3    struct hlist_node *next = n->next;
4    struct hlist_node **pprev = n->pprev;
5    *pprev = next;
6    if (next)
7        next->pprev = pprev;
8}

对于插入操作而言hlist和list大同小异,值的注意的是hlist并不是循环链表,因而在插入到链表结尾的时候需要注意以下空指针的情况。

对于遍历操作而言,涉及到一个GNU C的一个特性,该特性往往广泛应用于宏中。

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

GNU C允许用小括号括起来的复合语句出现在一个表达式中。

例如:

c
1int a = ({ int b = 8;
2          int c = 99;
3          b + c;
4          b + c - 10;
5         });

结果a = b + c - 10 = 97

因而在上述遍历中,该宏利用该特性在自增前偷偷利用prefetch预取数据,同时利用1来保证不影响结果,十分精妙。

对于“宿主”的遍历则如下:

c
 1/**
 2 * hlist_for_each_entry	- iterate over list of given type
 3 * @tpos:	the type * to use as a loop counter.
 4 * @pos:	the &struct hlist_node to use as a loop counter.
 5 * @head:	the head for your list.
 6 * @member:	the name of the hlist_node within the struct.
 7 */
 8#define hlist_for_each_entry(tpos, pos, head, member)			 \
 9    for (pos = (head)->first;					 \
10         pos && ({ prefetch(pos->next); 1;}) &&			 \
11        ({ tpos = hlist_entry(pos, typeof(*tpos), member); 1;}); \
12         pos = pos->next)

这里pos是中间变量,tpos才是暴露出的接口指针。由于hlist没有头节点,而是以NULL作为遍历的结束,所以有必要引入pos来辅助遍历和判断遍历的结束。

此外hlist也实现了safe版本以供任意的删除。

c
 1#define hlist_for_each_safe(pos, n, head) \
 2    for (pos = (head)->first; pos && ({ n = pos->next; 1; }); \
 3         pos = n)
 4
 5/**
 6 * hlist_for_each_entry_safe - iterate over list of given type safe against removal of list entry
 7 * @tpos:	the type * to use as a loop counter.
 8 * @pos:	the &struct hlist_node to use as a loop counter.
 9 * @n:		another &struct hlist_node to use as temporary storage
10 * @head:	the head for your list.
11 * @member:	the name of the hlist_node within the struct.
12 */
13#define hlist_for_each_entry_safe(tpos, pos, n, head, member) 		 \
14    for (pos = (head)->first;					 \
15         pos && ({ n = pos->next; 1; }) && 				 \
16        ({ tpos = hlist_entry(pos, typeof(*tpos), member); 1;}); \
17         pos = n)

进程链表

进程链表将所有进程的描述符连接起来,每个tast_struct结构都包含一个list_head类型的tasks字段。

SET_LINKS和REMOVE_LINK宏可以从进程链表中插入、删除一个进程描述符,这些宏考虑了进程间地父子关系。具体代码如下:

c
 1#define remove_parent(p)	list_del_init(&(p)->sibling)
 2#define add_parent(p, parent)	list_add_tail(&(p)->sibling,&(parent)->children)
 3
 4#define REMOVE_LINKS(p) do {					\
 5    if (thread_group_leader(p))				\
 6        list_del_init(&(p)->tasks);			\
 7    remove_parent(p);					\
 8    } while (0)
 9
10#define SET_LINKS(p) do {					\
11    if (thread_group_leader(p))				\
12        list_add_tail(&(p)->tasks,&init_task.tasks);	\
13    add_parent(p, (p)->parent);				\
14    } while (0)REMOVE_LINKS

在执行插入、删除操作前会检查当前进程是否是线程组组长,如果是的话则会额外地在进程链表头(init_task描述符,0进程或swapper进程的描述符)中进行插入删除,不然则只在线程父亲的chilidren链表中进行修改。

另一个常用的宏是for_each_process,该宏会遍历整个进程链表。每一个任务描述符都有一个tasks字段,该字段的类型是list head; 因而在循环中调用的list_entry宏的第一个参数和第三个参数也是相匹配的。

c
1#define next_task(p)	list_entry((p)->tasks.next, struct task_struct, tasks)
2#define for_each_process(p) \
3    for (p = &init_task ; (p = next_task(p)) != &init_task ; )

TASK_RUNNING状态的进程链表

Linux早先的内核将所有可运行的进程放在一个链表中,每次调度不得不遍历整个链表以寻找最优进程。Linux2.6有所不同,它会在固定的时间内选出一个“最佳”的进程,花费的时间与可运行的进程数无关。

提高调度程序速度的诀窍是建立多优先级的可运行进程的链表,每个进程被放入到对应其优先级的链表中,通过这种结构可以实现固定时间内选出“最佳”进程。在第7章节会进行详细的说明。

内核为每个运行队列保存大量的数据,通过prio_array_t的结构来实现:

c
1struct prio_array {
2    unsigned int nr_active;
3    unsigned long bitmap[BITMAP_SIZE];
4    struct list_head queue[MAX_PRIO];
5};

enqueue_task(p, array)函数将进程描述符插入到某个运行队列的链表,其代码本质上为:

c
1list_add_tail(&p->run_list, &array->queue[p->prio]);
2__set_bit(p->prio, array->bitmap);
3array->nr_active++;
4p->array = array;

进程描述符的prio字段存放进程的动态优先级,而array字段是一个指针,指向当前运行队列的prio_array_t结构。

标识一个进程

由于进程描述符与进程严格的一一对应,所以大部分对进程的引用是通过描述符指针进行的。

另一方面,也允许使用pid标识一个进程。一个物理页为32768个位,因而缺省情况下,PID的范围为0-32767。另一方面,对于一个多线程的进程而言,为了统一管理所有线程,每个线程(即轻量级进程)都使用领头线程的pid,该值被存入进程描述符的tgid字段;当调用getpid系统调用时,返回的是tgid的值而不是pid的值(其实是gettgid!),从这方面而言,可以说一个多线程应用的所有线程有相同的pid。

后续将说明如何高效地从PID导出其进程描述符指针

通过%esp寄存器

对于每个进程来说,Linux将两个相关的数据结构紧凑的存放在内存中,一个是与进程描述符有关的小数据结构thread_info, 另一个是内核态的进程堆栈。这块存储区域通常为2个页框,当几乎找不到连续的2个页框时,可以在编译时设置允许他们跨越一个单独的页框。thread_info占用最低的52个字节,栈从高地址往下增长,因而最多可以占用8140个字节。

C语言使用联合结构可以很方便地表示这样的分布结构:

c
1union thread_union {
2    struct thread_info thread_info;
3    unsigned long stack[THREAD_SIZE/sizeof(long)];
4};

因此,内核为了获得当前进程thread_info,直接利用esp即可:

asm
1movl $0xffffe000, %ecx
2andl %esp, %ecx
3movl %ecx, p

事实上,进程经常使用的是进程描述符的地址,而不是thread_info结构的地址。内核会调用current宏,该宏本质上等于current_thread_info->tast,产生汇编代码:

asm
1movl $0xffffe000, %ecx
2andl %esp, %ecx
3movl (%ecx), p

因为task字段在thread_info中的偏移量为0,因而上述代码就会将描述符的地址放置p中。

c
 1struct thread_info {
 2    struct task_struct	*task;		/* main task structure */
 3    struct exec_domain	*exec_domain;	/* execution domain */
 4    unsigned long		flags;		/* low level flags */
 5    unsigned long		status;		/* thread-synchronous flags */
 6    __u32			cpu;		/* current CPU */
 7    __s32			preempt_count; /* 0 => preemptable, <0 => BUG */
 8
 9
10    mm_segment_t		addr_limit;	/* thread address space:
11                            0-0xBFFFFFFF for user-thead
12                           0-0xFFFFFFFF for kernel-thread
13                        */
14    struct restart_block    restart_block;
15
16    unsigned long           previous_esp;   /* ESP of the previous stack in case
17                           of nested (IRQ) stacks
18                        */
19    __u8			supervisor_stack[0];
20};

在多处理器系统上,对于每个处理器仅仅只需要通过栈指针就可以获得正确的进程。而早先的Linux系统中,必须把current定义为一个数组来对应每个CPU。

pidhash表

内核经常需要通过pid值查找到对应的进程描述符指针,因而内核使用4个散列表加快查询速度。

散列表结构即为之前提到的哈希链表结构,每个表项内容存储一个指针。一个散列表的长度依赖于可用RAM的容量,一个系统拥有512MB的RAM,那么每个散列表就被存放在4个页框中,可以拥有2048个表项。 这句话是书上给出的,并不清楚如何从512MB的RAM推出具体的哈希表长度。。。下面通过源码尝试分析其原因。

首先给出pidhash表的初始化代码:

c
 1/*
 2 * The pid hash table is scaled according to the amount of memory in the
 3 * machine.  From a minimum of 16 slots up to 4096 slots at one gigabyte or
 4 * more.
 5 */
 6void __init pidhash_init(void)
 7{
 8    int i, j, pidhash_size;
 9    unsigned long megabytes = nr_kernel_pages >> (20 - PAGE_SHIFT);
10
11    pidhash_shift = max(4, fls(megabytes * 4));
12    pidhash_shift = min(12, pidhash_shift);
13    pidhash_size = 1 << pidhash_shift;
14
15    printk("PID hash table entries: %d (order: %d, %Zd bytes)\n",
16        pidhash_size, pidhash_shift,
17        PIDTYPE_MAX * pidhash_size * sizeof(struct hlist_head));
18
19    for (i = 0; i < PIDTYPE_MAX; i++) {
20        pid_hash[i] = alloc_bootmem(pidhash_size *
21                    sizeof(*(pid_hash[i])));
22        if (!pid_hash[i])
23            panic("Could not alloc pidhash!\n");
24        for (j = 0; j < pidhash_size; j++)
25            INIT_HLIST_HEAD(&pid_hash[i][j]);
26    }
27}

在具体深入探究原理之前,先说明几个有趣的小知识点:

  • L15: %zd 用于输出size_t类型

  • L19: 由于enum本质是用整数来一一对应枚举类型的,所以pidhash使用了如下的一种枚举类型enum pid_type

c
1enum pid_type
2{
3    PIDTYPE_PID,
4    PIDTYPE_TGID,
5    PIDTYPE_PGID,
6    PIDTYPE_SID,
7    PIDTYPE_MAX
8};

此后即可使用PIDTYPE_MAX来初始化数组大小,用PIDTYPE_xxx来选择一维数组。其本质是用enum来代替人为define,十分值得学习。

  • L20: bootmem分配器。 Bootmem 内存分配器 这里我们暂时不过多地关注bottmem分配器的原理,留个坑有缘再见吧。。大概说来这个分配器是内核在引导时的一个物理内存分配器,它的实现简单,使用bitmap对内存进行管理,缺点在于bitmap不适合分配大物理内存,会消耗较多的时间空间。

  • L11:fls函数,即find_the_last_bit_set,实现方式十分简洁优美。返回输入参数的最高有效bit位(从低位往高数最后的有效bit位)的序号,该序号与常规0起始序号不同,它是1起始的(当没有有效位时返回0)。

c
 1/*
 2 * fls: find last bit set.
 3 */
 4
 5static __inline__ int generic_fls(int x)
 6{
 7    int r = 32;
 8
 9    if (!x)
10        return 0;
11    if (!(x & 0xffff0000u)) {
12        x <<= 16;
13        r -= 16;
14    }
15    if (!(x & 0xff000000u)) {
16        x <<= 8;
17        r -= 8;
18    }
19    if (!(x & 0xf0000000u)) {
20        x <<= 4;
21        r -= 4;
22    }
23    if (!(x & 0xc0000000u)) {
24        x <<= 2;
25        r -= 2;
26    }
27    if (!(x & 0x80000000u)) {
28        x <<= 1;
29        r -= 1;
30    }
31    return r;
32}

接下来考察一下pid哈希表的长度到底是如何确定的。。以512MB的RAM为例,内核将计算得知pidhash_size的结果为2048,从而就确定了每个表项是2048项,每项内容是一个32位地址,确实需要4个页框。。emmm至于为什么是MB的数量*4,可能关于内核开发者考虑的效率、冲突等问题,不予过多关注了。

内核通过hash_long宏来将pid转换为表索引:

c
 1#if BITS_PER_LONG == 32
 2/* 2^31 + 2^29 - 2^25 + 2^22 - 2^19 - 2^16 + 1 */
 3#define GOLDEN_RATIO_PRIME 0x9e370001UL
 4#elif BITS_PER_LONG == 64
 5/*  2^63 + 2^61 - 2^57 + 2^54 - 2^51 - 2^18 + 1 */
 6#define GOLDEN_RATIO_PRIME 0x9e37fffffffc0001UL
 7#else
 8
 9static inline unsigned long hash_long(unsigned long val, unsigned int bits)
10{
11    unsigned long hash = val;
12
13#if BITS_PER_LONG == 64
14    /*  Sigh, gcc can't optimise this alone like it does for 32 bits. */
15    unsigned long n = hash;
16    n <<= 18;
17    hash -= n;
18    n <<= 33;
19    hash -= n;
20    n <<= 3;
21    hash += n;
22    n <<= 3;
23    hash -= n;
24    n <<= 4;
25    hash += n;
26    n <<= 2;
27    hash += n;
28#else
29    /* On some cpus multiply is faster, on others gcc will do shifts */
30    hash *= GOLDEN_RATIO_PRIME;
31#endif
32
33    /* High bits are more random, so use them. */
34    return hash >> (BITS_PER_LONG - bits);
35}

0x9e370001UL是一个神秘的数字,在源码中他被命名为GOLDEN_RATIO_PRIME, 其实这个数是对$2^{32}$做黄金分割后选取的最靠近分割点的素数,这样结果会比较满意,Knuth这么建议道。。

Linux利用链表来处理pid冲突的情况。当pid映射到了一个表项时,就用hlist将他们串起来。由于系统的进程数往往小于32768,因而使用链表的散列法性能更加优越。

不过由于要追踪进程间的关系,pid散列表需要的数据结构较为复杂。比如,为了回收一个线程组的所有进程,那么通过tgid查只会返回一个进程描述符,为了更快的回收所有的资源,内核必须为每个线程组保留一个进程链表;同样的对于进程组、会话组都有类似的问题。事实上,pid使用如下的结构来方便地解决上述问题:

c
1struct pid
2{
3    /* Try to keep pid_chain in the same cacheline as nr for find_pid */
4    int nr;
5    struct hlist_node pid_chain;
6    /* list of pids with the same nr, only one of them is in the hash */
7    struct list_head pid_list;
8};

其中,nr为pid号,pid_chain将哈希表中所有冲突的描述符串起来,而pid_list则将该组下所有的进程串起来。

最后介绍一些处理pid表的宏:

pid_task通过pid结构体中的链表指针返回整个进程描述符的地址,本质是对list_entry的一个应用。

c
1#define pid_task(elem, type) \
2    list_entry(elem, struct task_struct, pids[type].pid_list)

do_while控制语句。遍历type类型(4个中1个)的链表中所有nr为who地内容,暴露task作为进程描述符接口。

c
 1#define do_each_task_pid(who, type, task)				\
 2    if ((task = find_task_by_pid_type(type, who))) {		\
 3        prefetch((task)->pids[type].pid_list.next);		\
 4        do {
 5
 6#define while_each_task_pid(who, type, task)				\
 7        } while (task = pid_task((task)->pids[type].pid_list.next,\
 8                        type),			\
 9            prefetch((task)->pids[type].pid_list.next),	\
10            hlist_unhashed(&(task)->pids[type].pid_chain));	\
11    }								\

返回type类型的pid链表中指定nr号的一个pid结构体。

c
 1struct pid * fastcall find_pid(enum pid_type type, int nr)
 2{
 3    struct hlist_node *elem;
 4    struct pid *pid;
 5
 6    hlist_for_each_entry(pid, elem,
 7            &pid_hash[type][pid_hashfn(nr)], pid_chain) {
 8        if (pid->nr == nr)
 9            return pid;
10    }
11    return NULL;
12}

attach_pid: 如果插入哈希表时没有冲突,那么直接加入并初始化task的pid_list链;如果产生了冲突,那么初始化task的pid_chain(但不加入!)并将pid_list链在之前冲突的进程的pid_list下面。

c
 1int fastcall attach_pid(task_t *task, enum pid_type type, int nr)
 2{
 3    struct pid *pid, *task_pid;
 4
 5    task_pid = &task->pids[type];
 6    pid = find_pid(type, nr);
 7    if (pid == NULL) {
 8        hlist_add_head(&task_pid->pid_chain,
 9                &pid_hash[type][pid_hashfn(nr)]);
10        INIT_LIST_HEAD(&task_pid->pid_list);
11    } else {
12        INIT_HLIST_NODE(&task_pid->pid_chain);
13        list_add_tail(&task_pid->pid_list, &pid->pid_list);
14    }
15    task_pid->nr = nr;
16
17    return 0;
18}

插入哈希表时如果不存在同一个nr的进程,那么应该新建一个哈希表项并加入pid_chain链中;如果存在同一个nr的进程,此时不是说明哈希表产生了冲突,而是说明有一个组长进程或类似的进程已经加入了,那么已经不再需要向pid_chain链中加入新节点,直接将当前节点缀到之前加入过的pid的pid_list后面即可。

通过阅读这段代码,能让人对于linux内核设计者对链表的设计思路有更加深刻的理解。。。按照个人以往的编程方式,大概率会用链表将一整个结构串起来,通过一个指针访问需要的结构,而不是将链表指针内嵌到结构中,学习到了。

上述代码的fastcall是一种调用方式,速度是更快,它通过寄存器ECX和EDX传递2个字,后面更多的仍然压栈。 函数的调用规则(__cdecl,__stdcall,__fastcall,__pascal)

2021年10月30日勘误:fastcall的实现并未标准化!不同编译器的实现不同,上述贴出的博客实际是microsoft/gcc的标准,而另有一种Borland fastcall,其顺序为从左至右,三个参数分别为EAX, EDX和ECX, 在i386上的某些版本Linux也使用了此约定 X86调用约定

detach_pid:删除type链表中task对应的进程描述符。

hlist_unhashed检测某个哈希表节点是否没有被加入到散列表中,有可能某节点仅仅被初始化但尚未被加入到链表中。所以第一层if判断如果失败则无需修改pid_chain相关的表项内容,但如果加入了哈希表中,则应该对应在哈希表中执行一些操作。如果当前进程的pid_list下还有一些其余的进程,那么应该将pid_list下一个进程的chain再链回去保证哈希表的正确;如果只有当前一个进程占用了这个pid号,则应该考虑释放这个pid号供后续的进程使用,通过一个循环检查其余类型的pidhash表中是否有人仍然在使用该号码,如果无人使用,则可以在pidmap中对该位进行清除供后人使用。

不过不太明白为什么有可能会出现没有加入到散列表的pid项??

c
 1static fastcall int __detach_pid(task_t *task, enum pid_type type)
 2{
 3    struct pid *pid, *pid_next;
 4    int nr = 0;
 5
 6    pid = &task->pids[type];
 7    if (!hlist_unhashed(&pid->pid_chain)) {
 8        hlist_del(&pid->pid_chain);
 9
10        if (list_empty(&pid->pid_list))
11            nr = pid->nr;
12        else {
13            pid_next = list_entry(pid->pid_list.next,
14                        struct pid, pid_list);
15            /* insert next pid from pid_list to hash */
16            hlist_add_head(&pid_next->pid_chain,
17                &pid_hash[tynext_threadpe][pid_hashfn(pid_next->nr)]);
18        }
19    }
20
21    list_del(&pid->pid_list);
22    pid->nr = 0;
23
24    return nr;
25}
26
27void fastcall detach_pid(task_t *task, enum pid_type type)
28{
29    int tmp, nr;
30
31    nr = __detach_pid(task, type);
32    if (!nr)
33        return;
34
35    for (tmp = PIDTYPE_MAX; --tmp >= 0; )
36        if (tmp != type && find_pid(tmp, nr))
37            return;
38
39    free_pidmap(nr);
40}

next_thread(task)返回TGID链表中task的下一个线程的描述符地址。由于pid_list是的双向循环链表,所以对传统的进程应用该函数会返回本身的描述符地址。

c
1task_t fastcall *next_thread(const task_t *p)
2{
3    return pid_task(p->pids[PIDTYPE_TGID].pid_list.next, PIDTYPE_TGID);
4}

进程间的关系

进程0和进程1是内核创建的,进程1(init)是所有进程的祖先。

在进程描述符中,有一些字段表示进程的亲属关系。

除了亲属关系,进程之间还有其他的关系:一个进程可能是一个进程组的领头进程,也可能是一个线程组的领头线程,还可能追踪其他进程的执行(二十章“执行追踪)。下表给出一些非亲属关系的字段。

进程的组织

内核将所有处于TASK_RUNNING的进程组织在运行队列链表中,对于INTERRUPTTIBLE和UNINTERRUPTTIBLE状态进程则使用等待队列进行维护,对处于STOPPED、EXIT_ZOMBIE、EXIT_DEAD等简单的状态的进程不单独设计链表。

等待队列

等待队列由双向链表实现,每个元素中有一个睡眠进程,通过task_list字段串在同一个链表中;每个等待队列都有一个等待队列头,并通过自旋锁保证对双向链表的互斥访问。

c
 1// 等待队列元素
 2typedef struct __wait_queue wait_queue_t;
 3struct __wait_queue {
 4    unsigned int flags;
 5#define WQ_FLAG_EXCLUSIVE	0x01
 6    struct task_struct * task;
 7    wait_queue_func_t func;
 8    struct list_head task_list;
 9};
10
11// 等待队列头元素
12struct __wait_queue_head {
13    spinlock_t lock;
14    struct list_head task_list;
15};
16typedef struct __wait_queue_head wait_queue_head_t;

并不懂自旋锁 学习一下 噢原来自旋锁其实就是忙等待的方式,如果需要的锁已被占用,则间隔一段时间再次不断尝试。当锁被持有时间较短时,自旋锁可以避免过多的进程调度和线程切换,因而经常被用于内核中。

当某个事件发生后,并不应该总是唤醒所有睡眠的进程。如多个进程正在等待某一个互斥资源时,而该资源仅能被一个进程占用,此时唤醒所有进程并无意义,仅需唤醒其中一个进程即可。因而在链表节点中存在flags字段用于区分互斥进程和非互斥进程,当flags字段为1时内核有选择的进行唤醒,而当flags字段为0时总是被唤醒。

书上说这是“雷鸣般兽群”问题。。。神秘。。看到一篇博客说是“惊群”问题,这个可能还跟准确些。。 Linux中的惊群问题/

等待队列元素的func字符按用于表示睡眠的进程应该用什么方式唤醒。

对等待队列的操作

初始化

与之前见过的许多内核代码一样,对等待队列的初始化也有宏和函数两种方式,其中default_wake_function是在后续章节讲要讨论的try_to_wake_up函数的一个简单封装。

c
 1// 等待队列元素的声明&初始化
 2#define __WAITQUEUE_INITIALIZER(name, tsk) {				\
 3    .task		= tsk,						\
 4    .func		= default_wake_function,			\
 5    .task_list	= { NULL, NULL } }
 6
 7#define DECLARE_WAITQUEUE(name, tsk)					\
 8    wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk)
 9
10// 等待队列头元素的声明&初始化
11#define __WAIT_QUEUE_HEAD_INITIALIZER(name) {				\
12    .lock		= SPIN_LOCK_UNLOCKED,				\
13    .task_list	= { &(name).task_list, &(name).task_list } }
14
15#define DECLARE_WAIT_QUEUE_HEAD(name) \
16    wait_queue_head_t name = __WAIT_QUEUE_HEAD_INITIALIZER(name)
17
18// 函数型初始化
19static inline void init_waitqueue_head(wait_queue_head_t *q)
20{
21    q->lock = SPIN_LOCK_UNLOCKED;
22    INIT_LIST_HEAD(&q->task_list);
23}
24
25static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)
26{
27    q->flags = 0;
28    q->task = p;
29    q->func = default_wake_function;
30}
31
32static inline void init_waitqueue_func_entry(wait_queue_t *q,
33                    wait_queue_func_t func)
34{
35    q->flags = 0;
36    q->task = NULL;
37    q->func = func;
38}

DEFINE_WAIT初始化也可以用于声明一个链表节点变量,自动使用当前CPU上运行的进程描述符和autoremove_wake_function作为唤醒函数,这个唤醒函数如果成功唤醒进程,则调用list_del_init将该进程从WL中删除,否则不进行其他操作,函数执行结束,返回结果。

c
1#define DEFINE_WAIT(name)						\
2    wait_queue_t name = {						\
3        .task		= current,				\
4        .func		= autoremove_wake_function,		\
5        .task_list	= {	.next = &(name).task_list,	\
6                    .prev = &(name).task_list,	\
7                },					\
8    }
增删改查

add_wait_queue将非互斥进程插入链表头,add_wait_queue_tail将非互斥进程插入链表尾,add_wait_queue_exclusive则将一个互斥进程插入到链表尾,remove_wait_queue从WL中删去一个进程,waitqueue_active检查一个给定的WL是否非空。

设置唤醒条件

使用sleep_on函数对当前进程操作,设置为UNINTERRUPTIBLE状态,并让其在指定等待队列中睡眠,接下来调用调度程序选取另一个程序进行运行。

c
 1#define	SLEEP_ON_VAR					\
 2    unsigned long flags;				\
 3    wait_queue_t wait;				\
 4    init_waitqueue_entry(&wait, current);
 5
 6#define SLEEP_ON_HEAD					\
 7    spin_lock_irqsave(&q->lock,flags);		\
 8    __add_wait_queue(q, &wait);			\
 9    spin_unlock(&q->lock);
10    
11#define	SLEEP_ON_TAIL					\
12    spin_lock_irq(&q->lock);			\
13    __remove_wait_queue(q, &wait);			\
14    spin_unlock_irqrestore(&q->lock, flags);
15
16
17void fastcall __sched sleep_on(wait_queue_head_t *q)
18{
19    SLEEP_ON_VAR
20
21    current->state = TASK_UNINTERRUPTIBLE;
22
23    SLEEP_ON_HEAD
24    schedule();
25    SLEEP_ON_TAIL
26}

其中SLEEP_ON_VAR定义了等待队列的元素并使用当前的进程对其进行初始化,SLEEP_ON_HEAD使用自旋锁向等待队列中加入一个元素,SLEEP_ON_TAIL使用自旋锁从等待队列中删去任务。

另一个值得关注的是在宏中对于自旋锁的使用,首先说明一下关于自旋锁的两个函数spin_lock_irqspin_lock_irqsave之间的区别:

c
 1#define _spin_lock_irqsave(lock, flags) \
 2do {	\
 3    local_irq_save(flags); \
 4    preempt_disable(); \
 5    _raw_spin_lock(lock); \
 6    __acquire(lock); \
 7} while (0)
 8
 9#define _spin_lock_irq(lock) \
10do { \
11    local_irq_disable(); \
12    preempt_disable(); \
13    _raw_spin_lock(lock); \
14    __acquire(lock); \
15} while (0)

在源码中,可以发现irqsave函数和irq的区别在于一个是存储目前的状态,另一个是直接操作中断开关状态。比如,在当前中断处理程序中,中断已经被别的程序关闭,此时该中断仍然使用lock_irq以及unlock_irq操作,那么在unlock_irq解锁时会将原本关中断的状态破坏掉,造成错误。因而只有当绝对确定当前中断的状态为开中断时,才可以调用irq的一对加解锁函数。参考: spin_lock_irqsave vs spin_lock_irq

回到源码中,使用了一对irqsave函数和一对基本的spinlock函数共同完成进程的睡眠和新进程的调度,同时能保证唤醒后原本的中断状态不变,十分的精妙。

interruptible_sleep_on几乎与sleep_on类似,不同之处只在于设置的状态为interruptible,因而进程接收一个信号即被唤醒。

sleep_on_timeout 和interruptible_sleep_on_timeout也与上述一对函数类似,但它允许调用者定义一个时间间隔,经过该时间后内核将唤醒该进程。

c
 1long fastcall __sched sleep_on_timeout(wait_queue_head_t *q, long timeout)
 2{
 3    SLEEP_ON_VAR
 4
 5    current->state = TASK_UNINTERRUPTIBLE;
 6
 7    SLEEP_ON_HEAD
 8    timeout = schedule_timeout(timeout);
 9    SLEEP_ON_TAIL
10
11    return timeout;
12}

prepare_to_wait、prepare_to_wait_exclusive和finish_wait函数往往成套使用,用于提供更加丰富的循环控制方式。

c
 1void fastcall
 2prepare_to_wait(wait_queue_head_t *q, wait_queue_t *wait, int state)
 3{
 4    unsigned long flags;
 5
 6    wait->flags &= ~WQ_FLAG_EXCLUSIVE;
 7    spin_lock_irqsave(&q->lock, flags);
 8    if (list_empty(&wait->task_list))
 9        __add_wait_queue(q, wait);
10    /*
11     * don't alter the task state if this is just going to
12     * queue an async wait queue callback
13     */
14    if (is_sync_wait(wait))
15        set_current_state(state);
16    spin_unlock_irqrestore(&q->lock, flags);
17}
18
19void fastcall finish_wait(wait_queue_head_t *q, wait_queue_t *wait)
20{
21    unsigned long flags;
22
23    __set_current_state(TASK_RUNNING);
24    /*
25     * We can check for list emptiness outside the lock
26     * IFF:
27     *  - we use the "careful" check that verifies both
28     *    the next and prev pointers, so that there cannot
29     *    be any half-pending updates in progress on other
30     *    CPU's that we haven't seen yet (and that might
31     *    still change the stack area.
32     * and
33     *  - all other users take the lock (ie we can only
34     *    have _one_ other CPU that looks at or modifies
35     *    the list).
36     */
37    if (!list_empty_careful(&wait->task_list)) {
38        spin_lock_irqsave(&q->lock, flags);
39        list_del_init(&wait->task_list);
40        spin_unlock_irqrestore(&q->lock, flags);
41    }
42}

在某些情况下,在进入睡眠前可能还要执行一些操作,此时就适合使用这一对函数。

例子参考: what is the difference between simple sleeping (using wait_event_*() functions) and advanced sleeping (using prepare_to_wait() function)?

wait_event和wait_event_interruptible使调用进程在等待队列上睡眠,一直到条件满足为止。

c
 1#define __wait_event(wq, condition) 					\
 2do {									\
 3    DEFINE_WAIT(__wait);						\
 4                                    \
 5    for (;;) {							\
 6        prepare_to_wait(&wq, &__wait, TASK_UNINTERRUPTIBLE);	\
 7        if (condition)						\
 8            break;						\
 9        schedule();						\
10    }								\
11    finish_wait(&wq, &__wait);					\
12} while (0)
13
14#define wait_event(wq, condition) 					\
15do {									\
16    if (condition)	 						\
17        break;							\
18    __wait_event(wq, condition);					\
19} while (0)

为了插入一个互斥进程,必须使用prepare_to_wait_exclusive或者add_wait_queue_exclusive进行插入,其余的所有函数都将以非互斥方式插入。

此外,除非使用DEFINE_WAIT或finish_wait,否则内核必须在唤醒等待进程后从等待队列中删除对应的元素。

内核3.15移除了sleep_on函数,因而不再过多关注其与wait_event的区别了。

进程的唤醒

在kernel/sched.c中定义了一些基本的唤醒函数。__wake_up_common是所有wake家族的核心函数,传入参数q为等待队列头,mode决定可否中断,nr_exclusive决定唤醒的互斥进程的数量,sync决定是同步唤醒sync,还是异步唤醒 async,key一般是NULL;通过函数注释可知,采用互斥唤醒时,不仅仅会唤醒一定数量的互斥进程,还会唤醒所有的非互斥进程

当该函数遍历等待队列,通过curr获取当前选取的进程描述符,通过判断该进程的信息和传入参数的关系进行一些操作。在说明这个看上去比较复杂的if语句前,我们先阐述一个容易让人忽视但十分重要的小细节:在一个等待队列中,一定是所有的非互斥进程在队列的开始位置,所有的互斥进程在队列的结束位置;这一点可以通过之前讨论过的如何添加一个互斥进程的方式得到印证。

得知这一点后,wake函数中的if判断则容易理解了。。首先检查当前进程的唤醒函数是否已经有效地唤醒了进程(返回1),接下来检查互斥情况,当进程的flag标志为0时说明此时仍然是非互斥进程,应当继续进行循环;而当flag标志为1时,根据上述说明的布局结构可以保证此时已经尝试唤醒了所有非互斥进程,接下来只需要唤醒指定数量的互斥进程即可,因而if逻辑的后面一部分便是执行这样的业务逻辑。

c
 1/*
 2 * The core wakeup function.  Non-exclusive wakeups (nr_exclusive == 0) just
 3 * wake everything up.  If it's an exclusive wakeup (nr_exclusive == small +ve
 4 * number) then we wake all the non-exclusive tasks and one exclusive task.
 5 *
 6 * There are circumstances in which we can try to wake a task which has already
 7 * started to run but is not in state TASK_RUNNING.  try_to_wake_up() returns
 8 * zero in this (rare) case, and we handle it by continuing to scan the queue.
 9 */
10static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
11                 int nr_exclusive, int sync, void *key)
12{
13    struct list_head *tmp, *next;
14
15    list_for_each_safe(tmp, next, &q->task_list) {
16        wait_queue_t *curr;
17        unsigned flags;
18        curr = list_entry(tmp, wait_queue_t, task_list);
19        flags = curr->flags;
20        if (curr->func(curr, mode, sync, key) &&
21            (flags & WQ_FLAG_EXCLUSIVE) &&
22            !--nr_exclusive)
23            break;
24    }
25}
26
27/**
28 * __wake_up - wake up threads blocked on a waitqueue.
29 * @q: the waitqueue
30 * @mode: which threads
31 * @nr_exclusive: how many wake-one or wake-many threads to wake up
32 */
33void fastcall __wake_up(wait_queue_head_t *q, unsigned int mode,
34                int nr_exclusive, void *key)
35{
36    unsigned long flags;
37
38    spin_lock_irqsave(&q->lock, flags);
39    __wake_up_common(q, mode, nr_exclusive, 0, key);
40    spin_unlock_irqrestore(&q->lock, flags);
41}

以__wake_up函数为核心,有如下的若干变体,这些宏均被定义在include/linux/wait.h中。

c
1#define wake_up(x)			__wake_up(x, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE, 1, NULL)
2#define wake_up_nr(x, nr)		__wake_up(x, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE, nr, NULL)
3#define wake_up_all(x)			__wake_up(x, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE, 0, NULL)
4#define wake_up_interruptible(x)	__wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
5#define wake_up_interruptible_nr(x, nr)	__wake_up(x, TASK_INTERRUPTIBLE, nr, NULL)
6#define wake_up_interruptible_all(x)	__wake_up(x, TASK_INTERRUPTIBLE, 0, NULL)
7#define	wake_up_locked(x)		__wake_up_locked((x), TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE)
8#define wake_up_interruptible_sync(x)   __wake_up_sync((x),TASK_INTERRUPTIBLE, 1)

所有这些宏都考虑了处于可中断睡眠状态的进程,当一个宏不包含字符串interruptible则它会尝试唤醒所有的进程;名字中还有nr或all的宏会唤醒对应数量的互斥进程。

特别说明一下最后两个宏使用的函数:

  • __wake_up_locked:实现的功能同wake_up;区别在于此函数用于在调用此函数时,用户已经手动 spin_lock了此等待队列头的lock,这个函数存在的原因是同一个spin_lock在同一个线程中不能递归加锁。
  • __wake_up_sync:此函数唤醒一个进程后不会令其抢占CPU,不会改变之前使用CPU的进程情况;而对于其余的宏而言,调用它们时往往还检查唤醒进程的优先级,如果优先级高于系统中正在运行的进程,会在必要时调用schedule函数。。

进程资源限制

每个进程都有一组相关的资源限制,避免了用户过分使用系统资源。

当前进程的资源限制存放在current->signal->rlim字段下,该字段是rlimit结构的数组,每个资源限制对应一个数组元素。

c
 1struct rlimit {
 2    unsigned long	rlim_cur;
 3    unsigned long	rlim_max;
 4};
 5
 6
 7struct rlimit rlim[RLIM_NLIMITS];
 8
 9
10/*
11 * Resource limits
12 */
13#define RLIMIT_CPU 0			/* CPU time in ms */
14#define RLIMIT_FSIZE 1			/* Maximum filesize */
15#define RLIMIT_DATA 2			/* max data size */
16#define RLIMIT_STACK 3			/* max stack size */
17#define RLIMIT_CORE 4			/* max core file size */
18#define RLIMIT_NOFILE 5			/* max number of open files */
19#define RLIMIT_AS 6			/* mapped memory */
20#define RLIMIT_RSS 7			/* max resident set size */
21#define RLIMIT_NPROC 8			/* max number of processes */
22#define RLIMIT_MEMLOCK 9		/* max locked-in-memory address space */
23#define RLIMIT_LOCKS 10			/* maximum file locks held */
24#define RLIMIT_SIGPENDING 11		/* max number of pending signals */
25#define RLIMIT_MSGQUEUE 12		/* maximum bytes in POSIX mqueues */
26
27#define RLIM_NLIMITS 13			/* Number of limit flavors.  */

rlim_cur是进程当前的资源限制,rlim_max是所允许的最大值。利用getrlimit和setrlimit系统调用,用户可以把一些资源限制增加到rlim_max,但是只有超级用户才能改变rlim_max的值,或者把rlim_cur的值设置为大于rlim_max的一个值。

进程切换

为了控制进程的执行,内核必须有能力挂起进程和恢复进程的运行,这种行为被称为进程切换、任务切换或上下文切换。

硬件上下文

进程恢复执行前必须装入寄存器的一组数据被称为硬件上下文。Linux中硬件上下文一部分放在TSS段,剩余部分存放在内核堆栈中。

早期Linux版本利用x86体系的硬件支持,通过far jmp跳到新进程的TSS的段选择符来进行进程切换,CPU自动保存原本的硬件上下文,并装入新的硬件上下文。但出于下述原因,Linux2.6使用软件来进行进程切换:

  • 通过一组mov指令切换时,可以较好地检查装入数据的合法性,防止寄存器被恶意用户篡改。
  • 两种方法所需要的时间相同。

进程切换只发生在内核态。在进程切换前,用户态进程的寄存器已经保存在内核堆栈上。

任务状态段

x86体系提供了一个特殊的段结构TSS来存放硬件上下文。尽管Linux 不使用硬件上下文切换,但是要求它为每个CPU创建一个TSS。有如下的两个主要原因:

  • 当某个CPU从用户态切换到内核态时,它从TSS中获取内核态堆栈的地址,后续章节( __switch_to函数)会进一步说明
  • 当用户态进程试图通过in、out指令访问io端口时,CPU访问TSS段中的IO许可证位图检查该进程是否拥有相应的权限。

每当进程切换时,内核都将更新TSS中的某些字段;因而TSS段只反映CPU上当前运行的进程的特权级,而不为每个进程都保留TSS段。

TSS的段描述符S标志被清零,以说明TSS是系统段的情况;type为11、9表示这是一个TSS段;在intel的原始设计中,每个进程都有一个TSS段,因而type字段的第二个有效位被称为busy位,如果进程正在被执行则置为1,不过在Linux设计中每个CPU都只有一个TSS段,对应的是正在执行的进程,因而busy字段总是为1。

由linux创建的tssd描述符存放在gdt中,每个cpu的tr寄存器包含对应的tssd选择符,并且含有两个隐藏的非编程字段:TSSD的base和limit字段。这样,处理器就能直接对tss寻址,而不需要从gdt中查找其地址。

thread字段

当进程切换时,硬件上下文必须保存在别处,因为Linux为CPU保留TSS而不是对每个进程保留TSS。

在进程描述符中有一个类型为thread_struct的字段thread,只要进程被切换出去,内核就把硬件上下文保存在这个结构中。

下面是i386架构下的硬件上下文内容。这个数据结构段不包括诸如eax、ebx的通用寄存器,他们的值保存在内核堆栈中。

c
 1struct thread_struct {
 2/* cached TLS descriptors. */
 3    struct desc_struct tls_array[GDT_ENTRY_TLS_ENTRIES];
 4    unsigned long	esp0;
 5
 6    unsigned long	sysenter_cs;
 7    unsigned long	eip;
 8    unsigned long	esp;
 9    unsigned long	fs;
10    unsigned long	gs;
11/* Hardware debugging registers */
12    unsigned long	debugreg[8];  /* %%db0-7 debug registers */
13/* fault info */
14    unsigned long	cr2, trap_no, error_code;
15/* floating point info */
16    union i387_union	i387;
17/* virtual 86 mode info */
18    struct vm86_struct __user * vm86_info;
19    unsigned long		screen_bitmap;
20    unsigned long		v86flags, v86mask, saved_esp0;
21    unsigned int		saved_fs, saved_gs;
22/* IO permissions */
23    unsigned long	*io_bitmap_ptr;
24/* max allowed port in the bitmap, in bytes: */
25    unsigned long	io_bitmap_max;
26};

执行进程切换

进程只可能在schedule函数执行时切换,后续章节会仔细叙述,这里我们只关注内核如何执行一个进程切换。

本质上说,进程切换由两步组成:

  • 切换页全局目录以安装一个新的地址空间,后续章节再具体说明
  • 切换内核堆栈和硬件上下文

switch_to宏

switch_to存在于include/asm-i386/system.h头中。

c
 1#define switch_to(prev,next,last) do {					\
 2    unsigned long esi,edi;						\
 3    asm volatile("pushfl\n\t"					\
 4             "pushl %%ebp\n\t"					\
 5             "movl %%esp,%0\n\t"	/* save ESP */		\
 6             "movl %5,%%esp\n\t"	/* restore ESP */	\
 7             "movl $1f,%1\n\t"		/* save EIP */		\
 8             "pushl %6\n\t"		/* restore EIP */	\
 9             "jmp __switch_to\n"				\
10             "1:\t"						\
11             "popl %%ebp\n\t"					\
12             "popfl"						\
13             :"=m" (prev->thread.esp),"=m" (prev->thread.eip),	\
14              "=a" (last),"=S" (esi),"=D" (edi)			\
15             :"m" (next->thread.esp),"m" (next->thread.eip),	\
16              "2" (prev), "d" (next));				\
17} while (0)

首先说明宏中的L7$1f涉及的一些语法。

Need to figure out the meaning of following inline assembly code 博客告诉了我们movl $1f, %1是什么意思:这里$1f并不是一个通常的立即数,而是标号为1的代码的地址f表示从当前位置向后搜索到的第一个标号为1的代码,另有参数b表示向前搜索的标号。将1f 保存在到prev->eip中,这样当控制权从某个地方再次回到了prev进程后,代码就会从标号1指向的指令开始执行。

继续讨论switch_to宏的流程:

  • 将%eflags, %ebp压入prev的内核栈

  • 将%esp存储到prev.thread的字段esp中

  • 将next对应的esp存入到%esp寄存器中,此时由于esp的载入,内核栈已经切换到了next对应的内核栈。根据之前在标识一个进程中所描述的结构我们直到,内核栈和进程描述符的地址是紧挨着的,所以切换内核栈也就意味着改变了当前的进程。

  • 将标号1对应的地址存放在prev.thread的eip字段中,这样当prev进程重新获得CPU时会从标号1处的代码开始执行。

  • 将next->thread.eip压入next的内核栈后,调用__switch_to宏。

    • Q:为什么要压入eip? A:压入eip本质上是手动设置返回地址。首先要注意到jmp和call调用过程的不同之处,jmp是直接跳转,而call会将ip或ip+cs压入栈中再跳转;当跳转对象是一个过程时,过程结束后会调用ret指令,而ret本质就是pop ip, 因而当调用的过程__switch_to完成后,就会执行之前我们人工压栈的指令,即next->thread.eip,十分精妙。
  • 当执行标号1处的代码时,进程首先将之前保存在prev栈中%ebp, %eflags都按顺序弹栈。由于当本进程再次获得CPU时,一定是某些其他的进程调用switch_to函数,并且参数next是本进程,所以那个进程已经执行过了esp的替换操作,esp已经替换成了当前进程的esp,从而标号1处直接弹栈是正确无误的,确实是对当前进程栈进行操作的。

除了汇编代码中的操作,在操作数列表中也隐含了一些操作:

注意到操作数中%0, %1, %5, %6都是内存寻址方式,而剩余的一些操作数则都利用到了寄存器。在内联代码执行前,首先将prev存入到%eax寄存器、next存入到%edx寄存器,在代码执行完后,将%esi、%edi的内容输出到c语言变量esi与edi中,并将%eax的内容输出到last参数中。

这里着重说明一下last参数的意义: 该参数在switch_to宏中是一个输出变量,用于保存上一次是谁切换到了prev进程。由于进程总在不停地切换,仅仅知道prev即将切换到next是不够的,进一步知道之前是谁切换到了prev往往会很有用。

考察整个代码可以知道,在prev开始切换时,会将自身的进程描述符地址保存到%eax寄存器中,在经过漫长的等待后prev又获得了CPU,此时上一位转手CPU的进程也一定冻结于未完成的switch_to宏中(因为进程切换只会发生在精心设计的函数中),自然也就已经将自身的地址覆盖在%eax中了,那么prev继续执行未完成的switch_to,执行标号1的代码,全部代码完成后将%eax寄存器的内容存入到last变量中,就能实现了通过last变量记录上一位转让CPU给prev的进程描述符地址。

最后,为啥要保存%esi, %edi呢?idk…但是这篇博客可能有一些参考价值,也可能毫无价值 switch_to函数为什么要保存esi/edi/ebx/ebp? 我没有去深入探究GCC会如何处理内联汇编,也可能是因为调用switch_to宏的上下文可能有需求?暂时留坑。。

之前为了对$1f语法做测试,我写了非常挫的一些代码。。不加任何选项的编译下述会产生错误,加上-no-pie后才可以成功编译,这个错误的产生大概是因为$1f的原因。。 Options for Code Generation Conventions 查阅GCC官方文档学习一下-fpic, -fPIC, -fpie, -fPIE这四个选项的意思。-fpic会产生position-independent code(PIC),通常用于共享库中。在该选项下,常量通过一个全局偏移表(global offset table, GOT)来访问,受到机器的限制,当该偏移表过大时,可以使用-fPIC选项,该选项允许更大的偏移表;-fpie, -fPIE是类似的功能,不过该选项只会产生用于链接为可知性文件的代码。

g++默认会加入pie选项,这产生了错误,因而加上-no-pie通知g++才能成功编译下述代码。。。具体原因不深入考究(),只能猜测是因为$1f指向了一段代码的地址,那么用pie的相对地址可能就会有一点问题?emm也留坑

c
 1#include <stdio.h>
 2#include <stdlib.h>
 3          "=a" (last),"=S" (esi),"=D" (edi)			\
 4             :"m" (next->thread.esp),"m" (next->thread.eip),	\
 5              "2" (prev), "d" (next));	
 6int main(int argc,char **argv){
 7    int x = 1145;
 8    void *y, *z;
 9    __asm__ volatile(
10        "lea %2, %%eax\n\t"
11        "movl %%eax, %0 \n\t"
12        "movl $1f, %1\n\t"
13        "1:"
14        "xorl %%eax, %%eax"
15        : "=m"(y), "=m"(z)
16        : "m"(x)
17        : "eax"
18    );
19    printf("%p %p %p\n", y, &x, z);
20    return 0;
21}

__switch_to函数

选取了i386的__switch_to函数,位于arch/i386/kernel/process.h文件中。

c
 1/*
 2 *	switch_to(x,yn) should switch tasks from x to y.
 3 *
 4 * We fsave/fwait so that an exception goes off at the right time
 5 * (as a call from the fsave or fwait in effect) rather than to
 6 * the wrong process. Lazy FP saving no longer makes any sense
 7 * with modern CPU's, and this simplifies a lot of things (SMP
 8 * and UP become the same).
 9 *
10 * NOTE! We used to use the x86 hardware context switching. The
11 * reason for not using it any more becomes apparent when you
12 * try to recover gracefully from saved state that is no longer
13 * valid (stale segment register values in particular). With the
14 * hardware task-switch, there is no way to fix up bad state in
15 * a reasonable manner.
16 *
17 * The fact that Intel documents the hardware task-switching to
18 * be slow is a fairly red herring - this code is not noticeably
19 * faster. However, there _is_ some room for improvement here,
20 * so the performance issues may eventually be a valid point.
21 * More important, however, is the fact that this allows us much
22 * more flexibility.
23 *
24 * The return value (in %eax) will be the "prev" task after
25 * the task-switch, and shows up in ret_from_fork in entry.S,
26 * for example.
27 */
28struct task_struct fastcall * __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
29{
30    struct thread_struct *prev = &prev_p->thread,
31                 *next = &next_p->thread;
32    int cpu = smp_processor_id();
33    struct tss_struct *tss = &per_cpu(init_tss, cpu);
34
35    /* never put a printk in __switch_to... printk() calls wake_up*() indirectly */
36
37    __unlazy_fpu(prev_p);
38
39    /*
40     * Reload esp0, LDT and the page table pointer:
41     */
42    load_esp0(tss, next);
43
44    /*
45     * Load the per-thread Thread-Local Storage descriptor.
46     */
47    load_TLS(next, cpu);
48
49    /*
50     * Save away %fs and %gs. No need to save %es and %ds, as
51     * those are always kernel segments while inside the kernel.
52     */
53    asm volatile("movl %%fs,%0":"=m" (*(int *)&prev->fs));
54    asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->gs));
55
56    /*
57     * Restore %fs and %gs if needed.
58     */
59    if (unlikely(prev->fs | prev->gs | next->fs | next->gs)) {
60        loadsegment(fs, next->fs);
61        loadsegment(gs, next->gs);
62    }
63
64    /*
65     * Now maybe reload the debug registers
66     */
67    if (unlikely(next->debugreg[7])) {
68        loaddebug(next, 0);
69        loaddebug(next, 1);
70        loaddebug(next, 2);
71        loaddebug(next, 3);
72        /* no 4 and 5 */
73        loaddebug(next, 6);
74        loaddebug(next, 7);
75    }
76
77    if (unlikely(prev->io_bitmap_ptr || next->io_bitmap_ptr))
78        handle_io_bitmap(next, tss);
79
80    return prev_p;
81}

关于fastcall,由于switch_to宏将prev和next分别存入到了%eax和%edx中,因而我认为此处的fastcall采用了Borland fastcall的实现(上文 pidhash表中的某处对此有更详细的说明),从左到右按照EAX, EDX和ECX的顺序读取参数。

另一种猜测才是真实情况,那就是使用__attribute__regparm关键字指定函数取参数的方式。由于在代码中尚未发现这两个关键字,所以我最开始就排除了这种可能性,但其实可以在编译内核时通过添加-mregparm=2选项实现寄存器传参,而且事实上fastcall是一个已经被定义的宏(include/asm-386/linkage.h):

c
1#define FASTCALL(x)	x __attribute__((regparm(3)))
2#define fastcall	__attribute__((regparm(3)))

__attribute__((regparm(n)))中的两个关键字不是c语言标准的关键字,而是gcc实现的拓展功能,按照eax、edx、ecx、栈的顺序进行传递参数,这与borland的fastcall实现不约而同。(或者其实根本来说这就是一个东西?)

接下面开始对__switch_to函数进行说明:

  • 首先看到的是smp_processor_id,该宏实际读取的是cpu_number的percpu变量,会从当前进程的thread_info结构中的cpu字段获得下标并将其保存在cpu局部变量中,关于这个变量在第五章会详细说明,这里只需要知道该函数会读取当前cpu的id.

  • 接下来通过per_cpu函数获得cpu的tss段; ##会实现参数的拼接,per_cpu的第一个参数(即per_cpu__init_tss)没有找到定义,在后续章节再仔细考察吧。。

    对于RELOC_HIDE宏,其目的是向gcc隐藏地址信息。在一个maillist中有一些提问和回答: 使用RELOC_HIDE的原因; 更广泛的推广. 因为当对一个符号的添加偏移地址会让gcc认为结果仍然在符号的范围内,通过该宏隐藏地址来阻止gcc做出假设。

c
1/* This macro obfuscates arithmetic on a variable address so that gcc
2   shouldn't recognize the original var, and make assumptions about it */
3#define RELOC_HIDE(ptr, off)					\
4  ({ unsigned long __ptr;					\
5    __asm__ ("" : "=g"(__ptr) : "0"(ptr));		\
6    (typeof(ptr)) (__ptr + (off)); })
7
8#define per_cpu(var, cpu) (*RELOC_HIDE(&per_cpu__##var, __per_cpu_offset[cpu]))
  • unlazy_fpu函数会有选择保存prev进程中FPU、MMX、XMM寄存器中的内容,在本章博客后续的内容 保存FPU寄存器中会进一步说明它。

  • load_esp0将新进程的esp0装入到tss段中。在第十章将看到,任何通过sysenter汇编指令产生的从用户态到内核态的转换都将会把esp0拷贝到esp寄存器中。

c
1static inline void load_esp0(struct tss_struct *tss, struct thread_struct *thread)
2{
3    tss->esp0 = thread->esp0;
4    /* This can only happen when SEP is enabled, no need to test "SEP"arately */
5    if (unlikely(tss->ss1 != thread->sysenter_cs)) {
6        tss->ss1 = thread->sysenter_cs;
7        wrmsr(MSR_IA32_SYSENTER_CS, thread->sysenter_cs, 0);
8    }
9}
  • load_TLS将next使用的线程局部存储(Thread-Local Data, 即TLS)段装入本地CPU的GDT中,这段代码藏在include/asm-i386/desc.h中,这里GDT_ENTRY_TLS_MIN定义为6,原因参考GDT内的分布图。
c
1static inline void load_TLS(struct thread_struct *t, unsigned int cpu)
2{
3#define C(i) per_cpu(cpu_gdt_table, cpu)[GDT_ENTRY_TLS_MIN + i] = t->tls_array[i]
4    C(0); C(1); C(2);
5#undef C
6}
  • 接下来通过一组内联汇编指令,将fs和gs段寄存器的内容保存在prev对应字段中。

  • 检查fs或gs段寄存器是否已经被prev或next中的任意一个使用(即非0),如果被使用则将next对应的段寄存器内容写回到fs和gs中。fs寄存器用于处理tls段,gs寄存器则没有特定的用处而是可以由应用自由使用。参考: 28.8. Using FS and GS segments in user space applications

    通过if判断对应四个值是否全0来决定是否要写回next的段寄存器结果。其中unlikelylikely宏完全不影响if判断的结果,他们只是用于提示编译器更多关于“分支跳转”的情况,使得编译器更好的优化代码。参考: linux中likely()和unlikely() loadsegment(seg, value)宏实现的功能如其名所述,但是内部实现还稍微更加复杂一些,因为当它检测到一个无效的段寄存器值时会采用一种fix-up的修正方法,在第十章会继续讨论这个宏,该宏存在于/include/asm-i386/system.h中。

  • next_p->thread.debugreg数组的内容装在db寄存器中,只有当next在使用调试寄存器时被挂起才需要进行这种操作(即net->debugreg[7]非零)。调试寄存器的内容不需要写回prev进程对应字段,是因为只有当某个调试器想要监控prev时才会修改prev的debugreg字段。#会将变量两侧加上双引号,变成字符串。

c
1#define loaddebug(thread,register) \
2        __asm__("movl %0,%%db" #register  \
3            : /* no output */ \
4            :"r" (thread->debugreg[register]))

至此我们已经知道了C语言宏中###不同的用处了:# 可以将宏参数进行字符串化操作,而##则实现参数的拼接。 C语言宏定义##连接符和#符的使用(转载)

  • 当prev或next进程有自己IO位图时,应当更新TSS中的IO位图。由于进程很少修改IO权限位图,所以仅当进程即将实际访问IO端口时,真实位图才被拷贝进CPU的TSS中。结合代码可知,有三种可能方式:
    • 当next不具有自己的位图时,IOMAP的偏移量为一个指定的宏常量,值为0x8000

    • 当next拥有自己的位图,且与tss中的位图是同一个,那么直接修改base地址为结构体内iomap的偏移。

    • 最后一种情况就是,next拥有自己位图,且不是tss中的同一个,需要更新位图到tss中,那么就将采用lazy模式。将bitmap_base设置为一个指定的宏常量,真实值为0x9000。

    • 当next进程尝试访问一个IO端口时,0x8000和0x9000都是tss段外地址,会产生异常,如果产生0x8000的异常,则函数发送一个SIGSEGV给用户进程;如果是0x9000的异常,则处理函数把真实进程位图拷贝到TSS中,把IO位图的偏移设置为实际偏移。

    • 存储的居然不是iomap的地址,而是结构体内的io_bitmap偏移(104),因而难怪才有通过0x8000和0x9000来判断是否为tss段外地址一说=_=

c
 1#define IO_BITMAP_OFFSET offsetof(struct tss_struct,io_bitmap)
 2#define INVALID_IO_BITMAP_OFFSET 0x8000
 3#define INVALID_IO_BITMAP_OFFSET_LAZY 0x9000
 4
 5
 6static inline void
 7handle_io_bitmap(struct thread_struct *next, struct tss_struct *tss)
 8{
 9    if (!next->io_bitmap_ptr) {
10        tss->io_bitmap_base = INVALID_IO_BITMAP_OFFSET;
11        return;
12    }
13    if (likely(next == tss->io_bitmap_owner)) {
14        tss->io_bitmap_base = IO_BITMAP_OFFSET;
15        return;
16    }
17    tss->io_bitmap_base = INVALID_IO_BITMAP_OFFSET_LAZY;
18}
  • 最后一步,return prev。虽然看上去很简单,但还是蛮精妙的。由于我们希望保证%eax始终存储switch_to函数中被替换的进程描述符的地址,因而在经过__switch_to时我们应该采取一定的措施保护%eax的结果不被改变。这里用到了一个缺省的特点:C函数中的return默认会将值传递给%eax,因而实际上return prev实现的汇编操作为movl %edi, %eax; ret;(此时prev_p在%edi里),这样就保护了eax寄存器的内容。

  • ret会将栈顶保存的地址装入eip程序计数器,因而next进程即将从之前压入进程内核栈next->thread.eip的位置继续执行(由switch宏压入的%6);如果next此前从未被挂起,则会从ret_from_fock()函数的起始位置开始(参考本博客后续fork、clone相关内容)。

    • ULK上关于跳转位置的表述大概率是错误的,它说ret会找到之前前压入栈中的1号地址。根据我对这两个函数如此大篇幅的思考、理解,我认为这明显是错误的。

保存与加载FPU、MMX和XMM寄存器

从80486DX开始,算术浮点单元(floating-poting unit, FPU)开始被集成到CPU中。为了兼容旧模式,浮点算术函数使用ESCAPE指令来执行,这些指令作用于包含在CPU中的浮点寄存器集。当一个进程使用ESCAPE指令,那么浮点寄存器的内容也属于硬件上下文,应当被保存。

在之后的Pentium模型中,Intel引入了一个新的汇编指令集,叫做MMX指令,用于加速多媒体应用程序的执行。MMX指令也作用于FPU的浮点寄存器,这样做的话缺点在于编程者无法将浮点指令和MMX指令混用,优点在于OS设计者能够忽略新的指令集,使得浮点单元的状态切换代码可以不加修改地应用到MMX指令集上。

MMX加速了多媒体应用的执行,因为它们在内部引入了单指令多数据流水线(single-instruction multiple-data, SIMD)PentiumIII模型拓展了这种SIMD地能力,引入了SSE拓展(Streaming SIMD Exetensions),该拓展为处理8个128位的寄存器(叫做XMM寄存器)的浮点值增加了功能;该寄存器不与FPU/MMX的寄存器重叠,因而可以与FPU/MMX指令混用。PentiumIV还引入了SSE2拓展,支持高精度的浮点值,与SSE使用同一XMM寄存器集。

80x86微处理器并不在TSS中自动保存FPU、MMX和XMM寄存器,但它提供了某种硬件支持,能在需要时保存这些寄存器的值,流程如下:

  • 当硬件上下文切换时,设置TS标志

  • 当TS标志被设置时,执行ESCAPE、MMX、SSE等指令,控制单元就产生一个Device not available异常,进而由异常处理程序处理。

TS标志的存在使得lazy模式成为可能,只有当真正需要协处理器时才恢复浮点寄存器的内容。

在进程描述符中有一个专门为了上述几个寄存器的选择性装入而引入的数据结构。

c
1union i387_union {
2    struct i387_fsave_struct	fsave;
3    struct i387_fxsave_struct	fxsave;
4    struct i387_soft_struct soft;
5};

对于老旧的无协处理器地CPU使用软件模拟协处理器,使用soft结构;对于有协处理器、MMX单元的CPU模型使用fsave结构;对于有SSE、SSE2拓展功能的CPU模型则使用fxsave结构。

在进程描述符中含有两个相应的标志:

  • 包含在thread_info描述符下status字段中的TS_USEDFPU标志。表示进程在当前执行过程中是否使用过FPU、MMX、XMM寄存器。
  • 包含在task_struct描述符下flags字段中的PF_USED_MATH标志,表示thread.i387子字段的内容是否有意义。在下述两种情况下该位会被清零(表示字段无意义):
    • 进程调用execve系统调用,由于程序不再回去原代码,因而i387中的数据自然也无用。
    • 用户态下的一个进程开始执行一个信号处理程序(参见十一章)由于信号处理程序和原始进程的执行流是异步的,因而浮点寄存器的内容对信号处理程序来说可能是无意义的。不过由于内核在处理信号前会在i387结构体中保存浮点寄存器、处理完信号后恢复寄存器,所以信号处理程序可以使用数学协处理器。

保存FPU寄存器

fpu相关处理代码存在于/include/asm-i386/i387.h

__switch_to中我们调用过__unlazy_fpu函数,该函数检查TS标志位是否被置位,置位意味着prev使用过了相应的指令,内核必须保存相关的硬件上下文。

c
1#define __unlazy_fpu( tsk ) do { \
2    if ((tsk)->thread_info->status & TS_USEDFPU) \
3        save_init_fpu( tsk ); \
4} while (0)

save_init_fpu函数主要完成下述操作:

  • 把FPU内容转储到prev的进程描述符中,然后初始化FPU。如果使用到了SSE扩展,也应该实现类似的操作。这两个操作由一对功能强大的汇编指令完成。
  • 复位prev的TS标志。
  • 调用stts宏设置cr0的TS标志。
c
 1#define stts() write_cr0(8 | read_cr0())
 2
 3/*
 4 * These must be called with preempt disabled
 5 */
 6static inline void __save_init_fpu( struct task_struct *tsk )
 7{
 8    if ( cpu_has_fxsr ) {
 9        asm volatile( "fxsave %0 ; fnclex"
10                  : "=m" (tsk->thread.i387.fxsave) );
11    } else {
12        asm volatile( "fnsave %0 ; fwait"
13                  : "=m" (tsk->thread.i387.fsave) );
14    }
15    tsk->thread_info->status &= ~TS_USEDFPU;
16}
17
18
19/*
20 * These disable preemption on their own and are safe
21 */
22static inline void save_init_fpu( struct task_struct *tsk )
23{
24    preempt_disable();
25    __save_init_fpu(tsk);
26    stts();
27    preempt_enable();
28}

装载FPU寄存器

当next进程恢复执行时,由于lazy模式浮点寄存器的内容尚未恢复;不过cr0的TS标志已经被__unlazy_fpu设置,因而当next第一次尝试执行相关指令时会产生一个异常;异常处理程序运行math_state_restore函数进行处理。

c
 1/*
 2 *  'math_state_restore()' saves the current math information in the
 3 * old math state array, and gets the new ones from the current task
 4 *
 5 * Careful.. There are problems with IBM-designed IRQ13 behaviour.
 6 * Don't touch unless you *really* know how it works.
 7 *
 8 * Must be called with kernel preemption disabled (in this case,
 9 * local interrupts are disabled at the call-site in entry.S).
10 */
11asmlinkage void math_state_restore(struct pt_regs regs)
12{
13    struct thread_info *thread = current_thread_info();
14    struct task_struct *tsk = thread->task;
15
16    clts();		/* Allow maths ops (or we recurse) */
17    if (!tsk_used_math(tsk))
18        init_fpu(tsk);
19    restore_fpu(tsk);
20    thread->status |= TS_USEDFPU;	/* So we fnsave on switch_to() */
21}

该函数首先清cr0的TS标志,接下来检查i387字段是否有效,如果字段无效则调用init_fpu,该函数初始化i387字段,并将PF_USED_MATH标志置位,然后在restore_fpu函数中同样通过一对强力的汇编指令将保存在i387中的寄存器内容载入FPU寄存器,最后将thread中的TS使用标志置位。

内核态使用FPU、MMX和SSE/SSE2单元

内核在使用这些单元时应当避免干扰用户态进程的计算,因而:

  • 在使用协处理器前,如果用户设置了TS_USEDFPU标志,内核必须调用kernel_fpu_begin(),其本质是save_init_fpu()来保护寄存器的内容,然后重新设置cr0的TS标志。
  • 在使用完协处理器后,内核必须调用kernel_fpu_end()来设置cr0的TS标志。

稍后在用户执行协处理器指令时,math_state_restore将恢复寄存器内容。

事实上,当用户态在使用协处理器时,内核执行kernel_fpu_begin 的时间会相当长,以至于无法达到加速的目的;因而内核只有在有限的场合能够使用这些单元,典型情况如:移动、清除大内存区字段,计算校验和等。

创建进程

Unix系统紧紧依赖进程创建来满足用户的需求,但是传统的Unix以统一的方式对待所有的进程,子进程复制父进程的所有资源。这种方法效率很低且速度很慢,因为子进程往往不必读写父亲的所有资源。

现代的Unix采用三种不同的机制解决这个问题:

  • 写时复制技术(Copy-On-Write):父子进程可以读相同的物理页,但是一旦一方将要写时,内核就会拷贝出一个全新的相同内容的物理页分配给希望写物理页的进程。

  • 轻量级进程允许父子进程共享其在内核中的很多数据结构,如:页表、打开文件表以及信号处理

  • vfork()系统调用创建的进程能够共享父进程的内存地址空间。为了防止父进程修改子进程需要的数据,该调用会阻塞父进程直到子进程退出或执行一个新的程序为止。

clone、fork与vfork系统调用

c
 1asmlinkage int sys_fork(struct pt_regs regs)
 2{
 3    return do_fork(SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
 4}
 5
 6asmlinkage int sys_clone(struct pt_regs regs)
 7{
 8    unsigned long clone_flags;
 9    unsigned long newsp;
10    int __user *parent_tidptr, *child_tidptr;
11
12    clone_flags = regs.ebx;
13    newsp = regs.ecx;
14    parent_tidptr = (int __user *)regs.edx;
15    child_tidptr = (int __user *)regs.edi;
16    if (!newsp)
17        newsp = regs.esp;
18    return do_fork(clone_flags, newsp, &regs, 0, parent_tidptr, child_tidptr);
19}
20
21/*
22 * This is trivial, and on the face of it looks like it
23 * could equally well be done in user mode.
24 *
25 * Not so, for quite unobvious reasons - register pressure.
26 * In user mode vfork() cannot have a stack frame, and if
27 * done by calling the "clone()" system call directly, you
28 * do not have enough call-clobbered registers to hold all
29 * the information you need.
30 */
31asmlinkage int sys_vfork(struct pt_regs regs)
32{
33    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
34}

do_fork函数

系统调用clone(), fork(), vfork()全部通过do_fork()函数来实现,那么首先对do_fork函数进行说明。

c
 1/*
 2 *  Ok, this is the main fork-routine.
 3 *
 4 * It copies the process, and if successful kick-starts
 5 * it and waits for it to finish using the VM if required.
 6 */
 7long do_fork(unsigned long clone_flags,
 8          unsigned long stack_start,
 9          struct pt_regs *regs,
10          unsigned long stack_size,
11          int __user *parent_tidptr,
12          int __user *child_tidptr)
13{
14    struct task_struct *p;
15    int trace = 0;
16    long pid = alloc_pidmap();
17
18    if (pid < 0)
19        return -EAGAIN;
20    if (unlikely(current->ptrace)) {
21        trace = fork_traceflag (clone_flags);
22        if (trace)
23            clone_flags |= CLONE_PTRACE;
24    }
25
26    p = copy_process(clone_flags, stack_start, regs, stack_size, parent_tidptr, child_tidptr, pid);
27    /*
28     * Do this prior waking up the new thread - the thread pointer
29     * might get invalid after that point, if the thread exits quickly.
30     */
31    if (!IS_ERR(p)) {
32        struct completion vfork;
33
34        if (clone_flags & CLONE_VFORK) {
35            p->vfork_done = &vfork;
36            init_completion(&vfork);
37        }
38
39        if ((p->ptrace & PT_PTRACED) || (clone_flags & CLONE_STOPPED)) {
40            /*
41             * We'll start up with an immediate SIGSTOP.
42             */
43            sigaddset(&p->pending.signal, SIGSTOP);
44            set_tsk_thread_flag(p, TIF_SIGPENDING);
45        }
46
47        if (!(clone_flags & CLONE_STOPPED))
48            wake_up_new_task(p, clone_flags);
49        else
50            p->state = TASK_STOPPED;
51
52        if (unlikely (trace)) {
53            current->ptrace_message = pid;
54            ptrace_notify ((trace << 8) | SIGTRAP);
55        }
56
57        if (clone_flags & CLONE_VFORK) {
58            wait_for_completion(&vfork);
59            if (unlikely (current->ptrace & PT_TRACE_VFORK_DONE))
60                ptrace_notify ((PTRACE_EVENT_VFORK_DONE << 8) | SIGTRAP);
61        }
62    } else {
63        free_pidmap(pid);
64        pid = PTR_ERR(p);
65    }
66    return pid;
67}

do_fork参数列表:

  • clone_flags: 包含各种各样的信息。flags共有8个字节,低5字节用于指定子进程结束时发向父进程的信号代码(usr/include/asm/signal.h),高3字节则用于设置clone标志组。
  • stack_start: 表示把用户态堆栈指针分配给子进程的esp寄存器

  • regs: 指向通用寄存器值的指针,通用寄存器的值是在用户态切换到内核态时保存到内核堆栈中的。

  • stack_size: 未使用,总为0

  • parent_tidptr/child_tidptr: 表示父/子进程的用户态变量地址,只有在CLONE_PARENT_SETTID/CLONE_CHILD_SETTID被设置时才有意义。

函数主要流程:

  • 首先通过alloc_pidmap()分配pid。

    • RESERVED_PIDS(300)是0号pid哈希表重分配的起始位置,因而pid<300的号码仅允许分配一次,不参与后续的动态重分配了。

    • max_scan的值根据offset是否为零而有变化,需要注意的是for循环的取值范围是[0,max_scan]没看清这个右闭括号害我半天理解不能。当offset为0时,max_scan需要减1,扫描的次数即为pidhash表的页框数,这样的扫描将遍历整个哈希表的每一个角落;而当offset为1时,由于起始扫描的位置为当前页框偏移量offset的地方,所以当前页框的前半部分可能没有被扫描到,因而max_scan的值为总页框数+1

    • 调用了find_next_offset宏,实则调用了find_next_zero_bit函数,该函数位于include/asm-i386/bitops.h头中,不予深究函数细节。

c
 1#define PIDMAP_ENTRIES		((PID_MAX_LIMIT + 8*PAGE_SIZE - 1)/PAGE_SIZE/8)
 2#define BITS_PER_PAGE		(PAGE_SIZE*8)
 3#define BITS_PER_PAGE_MASK	(BITS_PER_PAGE-1)
 4#define mk_pid(map, off)	(((map) - pidmap_array)*BITS_PER_PAGE + (off))
 5#define find_next_offset(map, off)					\
 6        find_next_zero_bit((map)->page, BITS_PER_PAGE, off)
 7
 8/*
 9 * PID-map pages start out as NULL, they get allocated upon
10 * first use and are never deallocated. This way a low pid_max
11 * value does not cause lots of bitmaps to be allocated, but
12 * the scheme scales to up to 4 million PIDs, runtime.
13 */
14typedef struct pidmap {
15    atomic_t nr_free;
16    void *page;
17} pidmap_t;
18
19static pidmap_t pidmap_array[PIDMAP_ENTRIES] =
20     { [ 0 ... PIDMAP_ENTRIES-1 ] = { ATOMIC_INIT(BITS_PER_PAGE), NULL } };
21
22     
23int alloc_pidmap(void)
24{
25    int i, offset, max_scan, pid, last = last_pid;
26    pidmap_t *map;
27
28    pid = last + 1;
29    if (pid >= pid_max)
30        pid = RESERVED_PIDS;
31    offset = pid & BITS_PER_PAGE_MASK;
32    map = &pidmap_array[pid/BITS_PER_PAGE];
33    max_scan = (pid_max + BITS_PER_PAGE - 1)/BITS_PER_PAGE - !offset;
34    for (i = 0; i <= max_scan; ++i) {
35        if (unlikely(!map->page)) {
36            unsigned long page = get_zeroed_page(GFP_KERNEL);
37            /*
38             * Free the page if someone raced with us
39             * installing it:
40             */
41            spin_lock(&pidmap_lock);
42            if (map->page)
43                free_page(page);
44            else
45                map->page = (void *)page;
46            spin_unlock(&pidmap_lock);
47            if (unlikely(!map->page))
48                break;
49        }
50        if (likely(atomic_read(&map->nr_free))) {
51            do {
52                if (!test_and_set_bit(offset, map->page)) {
53                    atomic_dec(&map->nr_free);
54                    last_pid = pid;
55                    return pid;
56                }
57                offset = find_next_offset(map, offset);
58                pid = mk_pid(map, offset);
59            /*
60             * find_next_offset() found a bit, the pid from it
61             * is in-bounds, and if we fell back to the last
62             * bitmap block and the final block was the same
63             * as the starting point, pid is before last_pid.
64             */
65            } while (offset < BITS_PER_PAGE && pid < pid_max &&
66                    (i != max_scan || pid < last ||
67                        !((last+1) & BITS_PER_PAGE_MASK)));
68        }
69        if (map < &pidmap_array[(pid_max-1)/BITS_PER_PAGE]) {
70            ++map;
71            offset = 0;
72        } else {
73            map = &pidmap_array[0];
74            offset = RESERVED_PIDS;
75            if (unlikely(last == offset))
76                break;
77        }
78        pid = mk_pid(map, offset);
79    }
80    return -1;
81}
  • 接下来处理父进程的ptrace字段。do_fork需要检查debugger程序是否自己想追踪子进程,通过clone_flags字段中的CLONE_PTRACE标志来判断。

  • 调用copy_process复制进程描述符,这是创建进程的关键步骤,我们将在后续 copy_process函数讨论

  • 如果设置了CLONE_STOPPED标志,或者p->ptrace中设置了PT_PTRACED标志,那么子进程的状态就被设置成TASK_STOPPED,并为子进程增加挂起的SIGSTOP信号(参见第十一章 信号的作用)

  • 如果没有没有设置CLONE_STOPPED标志,则将执行wake_up_new_task函数

    • 调整父子进程的调度参数,不予具体分析,留待第七章调度算法

    • 如果父子进程运行在同一个CPU上,且父子进程不能共享页表(没有设置CLONE_VM标志),那么内核就将子进程插入到父进程的运行队列,令其在父进程之前运行,迫使子进程提前运行。如果子进程刷新其地址空间,那么可以节省一系列不必要的COW操作。

    • 否则,就将子进程插入到父进程运行队列的结尾。

  • 如果父进程被debugger追踪,则将子进程的pid存入父进程的ptrace_message字段中,并通过ptrace_notify函数通知debugger当前进程产生了一个子进程,且可以通过ptrace_message字段获取其pid。

  • 如果设置了CLONE_VFORK,则将父进程插入到等待队列,直到子进程结束。

  • 返回子进程pid

copy_process函数

copy_process函数用于创建进程描述符以及子进程运行所需要的其他所有数据结构。

其参数与do_fork相同,外加子进程的pid.

c
1static task_t *copy_process(unsigned long clone_flags,
2                 unsigned long stack_start,
3                 struct pt_regs *regs,
4                 unsigned long stack_size,
5                 int __user *parent_tidptr,
6                 int __user *child_tidptr,
7                 int pid);

其最重要的步骤如下:

  • 检查传递标志的一致性,在一些矛盾的情况下返回错误代号。

  • 通过security_task_create以及稍后调用的security_task_alloc函数进行附加的安全检查,详情参考第二十章。

  • 通过dup_task_struct函数为子进程获取进程描述符:

    • 如果需要,调用__unlazy_fpu,将相关寄存器的内容保存到父进程的thread_info结构中,稍后这些内容将被复制到子进程的thread_info中。

    • 执行alloc_task_struct宏,为新进程分配进程描述符。

    • 执行alloc_thread_info宏以获取一块空闲内存区,用来存放新进程的thread_info结构和内核栈。如前所述,这块内存区字段的大小为8KB/4KB。

    • 将父进程的thread_info和tast_struct信息赋给刚分配的子进程的对应数据结构。

    • 将子进程描述符的使用计数器置为2

    • 返回描述符指针

c
 1/*
 2 * This gets called before we allocate a new thread and copy
 3 * the current task into it.
 4 */
 5void prepare_to_copy(struct task_struct *tsk)
 6{
 7    unlazy_fpu(tsk);
 8}
 9
10static struct task_struct *dup_task_struct(struct task_struct *orig)
11{
12    struct task_struct *tsk;
13    struct thread_info *ti;
14
15    prepare_to_copy(orig);
16
17    tsk = alloc_task_struct();
18    if (!tsk)
19        return NULL;
20
21    ti = alloc_thread_info(tsk);
22    if (!ti) {
23        free_task_struct(tsk);
24        return NULL;
25    }
26
27    *ti = *orig->thread_info;
28    *tsk = *orig;
29    tsk->thread_info = ti;
30    ti->task = tsk;
31
32    /* One for us, one for whoever does the "release_task()" (usually parent) */
33    atomic_set(&tsk->usage,2);
34    return tsk;
35}
  • 检查父进程描述中signal->rlim[RLIMIT_NPROC].rlim_cur中的值是否小于等于其所拥有的进程数。如果是,除非进程拥有root权限,不然返回错误码。

  • 递增user_struct结构的使用计数器(__count)和进程数量计数器(processes)

  • 检查系统中进程数量(全局变量nr_threads)是否超过最大数量(全局变量max_threads)。这个变量的缺省值取决于内存的大小,总原则是所有的thread_info描述符和内核栈所占用的空间不能超过物理内存的1/8,不过系统管理员可以手动修改这个值。

  • 如果新进程的执行与和可执行格式的内核函数(第二十章)都被包含在内核模块中,则递增其计数器

  • 设置与进程状态有关的几个关键字段:

    • did_exec字段设置为0,记录子进程使用execve()系统调用的次数。

    • 将父进程的标志复制给子进程,但是清除PF_SUPERPRIV标志(表示使用了某种超级用户权限),并设置PF_FORKNOEXEC标志(表示子进程未使用过execve系统调用)。

    • 把大内核锁计数器lock_depth初始化为-1(参考第五章大内核锁一节)

  • 将子进程pid存储到对应子进程描述符对应字段。如果clone_flags中有CLONE_PARENT_SETTID标志,则将该pid也复制到parent_tidptr指向的用户态变量中。

  • 初始化子进程描述符的list_head结构和自旋锁,并为与挂起信号、定时器以及时间统计表相关的字段赋初始值。

  • 调用函数copy_{semundo, files, fs, sighand, signal, mm, namespace}来创建新的数据结构,并将父进程的信息拷贝进来。

  • 调用copy_thread函数,用发生clone()系统调用时CPU寄存器的值来初始化子进程的内核栈。

    • 将eax寄存器强制设为0; 这正是fork()和clone()系统调用在子进程中的返回值。

    • thread.esp字段初始化为子进程内核栈的基地址。

    • thread.eip存放着ret_from_fork汇编指令的地址

    • 如果父进程使用io_bitmap,则子进程也获得一份拷贝。

    • 如果设置了CLONE_SETTLS标志,则子进程获取clone()系统调用的参数tls指向的用户态数据结构TLS段。由于通常是通过拷贝系统调用的参数到某个寄存器后传递给内核,因而尽管在给copy_thread函数传参时不包含clone()的tls参数,其仍然可以通过esi的值来获取对应的参数内容。

    c
    1    if (copy_from_user(&info, (void __user *)childregs->esi, sizeof(info)))
    2    ...
    3    desc = p->thread.tls_array + idx - GDT_ENTRY_TLS_MIN;
    4    desc->a = LDT_entry_a(&info);
    5    desc->b = LDT_entry_b(&info);
  • 如果设置了CLONE_CHILD_SETTID或者CLONE_CHILD_CLEARTID则把child_tidptr的值分别赋到set_child_tidclear_child_tid字段中,表示必须改变子进程用户态地址空间child_tidptr指向的变量值,不过实际的写操作要稍后执行。

  • 清除子进程的TIF_SYSCALL_TRACE标志,以使ret_from_fork不会把系统调用结束的消息通知给调试进程(参见第十章)

  • clone_flags参数的低位信号编码初始化exit_signal字段,如果CLONE_THREAD被设置,就把exit_signal字段初始化为-1

  • 调用sched_fork函数完成对新进程调度相关数据结构的初始化,该函数将新进程设置为RUNNIGN状态,但不实际运行它,并把preempt_count字段设置为1从而禁止内核抢占(参考第五章内核抢占一节)。此外,为了保证公平的进程调度,该函数使得父子进程共享爱那个父进程的时间片(参考第七章scheduler_tick函数)

  • 将新进程thread_info结构中的cpu字段设置为本地cpu号

  • 初始化表示亲子关系的字段。

  • 如果不需要跟踪子进程,则将子进程描述符ptrace字段的值设置为0。这样,即使父进程被追踪,子进程也不会被追踪。

  • 执行SET_LINKS宏,将子进程描述符插入到进程链表

c
1#define SET_LINKS(p) do {					\
2    if (thread_group_leader(p))				\
3        list_add_tail(&(p)->tasks,&init_task.tasks);	\
4    add_parent(p, (p)->parent);				\
5    } while (0)
  • 如果子进程需要被追踪,则将父进程的parent字段复制给子进程parent字段,并将子进程插入到调试程序的追踪链表

  • 根据子进程是否是进程组的领头进程(CLONE_THREAD是否设置),来决定tgid, group_leader字段的取值、以及三次attach_pid函数插入散列表的类型。

  • 至此,新进程已经被加入到进程合集,递增nr_thread的值,递增total_forks变量以记录被创建的进程数量

  • 结束并返回子进程描述符指针


终于,我们有了一个处于RUNNING状态的子进程,但是该子进程还没有实际运行,后续调度程序会决定何时将CPU分配给它,并且继续完善这个子进程: 将描述符thread字段的值装入几个寄存器中(尤其是esp和eip寄存器)。这会用到schedule_tail函数,用栈中的值装载所有寄存器,并强迫CPU回到用户态。此后,当fork、clone或vfork调用结束后,新进程将开始执行。

内核线程

传统Unix系统中诸如刷新磁盘高速缓存、交换出无用的页框、维护网络连接等任务比较适合放在后台调度,因而现代的操作系统将他们委托给内核线程,不受到不必要的的用户上下文的拖累。

在Linux中,内核线程有几个特点:

  • 只运行在内核态,而普通进程既可以运行在用户态也可以运行在内核态

  • 进而内核线程只能使用大于PAGE_OFFSET的线性地址空间,普通进程可以任意使用4GB的地址空间。

通常使用thread_kernel函数来创建一个内核线程,接受参数有内核函数的地址、传递给函数的参数、一组clone标志。其本质上调用do_fork函数:

c
 1/*
 2 * This gets run with %ebx containing the
 3 * function to call, and %edx containing
 4 * the "args".
 5 */
 6extern void kernel_thread_helper(void);
 7__asm__(".section .text\n"
 8    ".align 4\n"
 9    "kernel_thread_helper:\n\t"
10    "movl %edx,%eax\n\t"
11    "pushl %edx\n\t"
12    "call *%ebx\n\t"
13    "pushl %eax\n\t"
14    "call do_exit\n"
15    ".previous");
16
17/*
18 * Create a kernel thread
19 */
20int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
21{
22    struct pt_regs regs;
23
24    memset(&regs, 0, sizeof(regs));
25
26    regs.ebx = (unsigned long) fn;
27    regs.edx = (unsigned long) arg;
28
29    regs.xds = __USER_DS;
30    regs.xes = __USER_DS;
31    regs.orig_eax = -1;
32    regs.eip = (unsigned long) kernel_thread_helper;
33    regs.xcs = __KERNEL_CS;
34    regs.eflags = X86_EFLAGS_IF | X86_EFLAGS_SF | X86_EFLAGS_PF | 0x2;
35
36    /* Ok, create the new process.. */
37    return do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, &regs, 0, NULL, NULL);
38}

VM标志避免复制页表,由于内核线程不会访问用户态空间,所以这种复制无疑是一种浪费。UNTRACE标志则避免内核线程被追踪。

函数将寄存器%ebx和%edx的内容设置为内核函数fn和参数arg,将%eip设置为汇编代码以执行fn内核函数。内核函数结束后,调用exit系统调用,并将内核函数的返回值传递给它。

kernel_thread_helper这一块的参数传递其实没有太看明白。。一般的函数直接从栈中取参数,因而向栈中前后压入了%edx和%eax(也就是fn的返回值所在的寄存器)作为两个函数的参数。那么为什么最开始要向eax复制一份edx的内容呢? 一种猜测是为了兼容可能的__attribute__关键字。。。因为其顺序是%eax、%edx、%ecx和栈,所以哪怕使用了这种快捷调用的方式,内核线程仍然可以正确运行。。但是这样有可能会导致压入栈中的%edx永远得不到弹出而一直存在于栈中。

提问SO: How kernel_thread_helper pass parameters to kernel thread function?

事实:

  • Linux 使用 asmlinkage 约束来告诉编译器该函数必须从栈中取元素,所有的系统调用都有这个标签,因而他们智能从栈中取参数。 参考: What is the ‘asmlinkage’ modifier meant for?

  • 在早期的Linux内核版本中(v.2.5.0)中,存在注释解释移动edx到eax的原因,因而为了兼容性确实是真实的情况。

So yes, as you suspect, the movl into eax is exactly to provide compatibility for both functions compiled with -mregparam or an equivalent __attribute__ and without (i386 System V ABI calling convention, i.e. all parameters on the stack).

  • 另外,由于在内核函数调用结束后,立刻执行了exit系统调用,那么即使在栈中有着未被使用的信息,栈中空间也随后会被释放,不会产生影响。

进程0

所有进程的祖先是进程0,称为idle进程或是swapper进程`,它是在Linux初始化阶段从无到有创建的一个内核线程。

其使用静态分配的数据结构(所有其他进程的数据结构都是动态分配的):

  • init_tast中的进程描述符,以及描述符指向的各种表: init_mm, init_fs, init_files, init_signals, init_sighand,这些结构都由对应大写字母的宏进行初始化。

  • init_thread_union中的thread_info描述符和内核堆栈,由INIT_THREAD_INFO宏完成初始化。

主内核页全局目录存放在swapper_pg_dir中。

start_kernel函数将初始化内核需要的所有数据结构,打开中断,创建进程1(init进程)。而进程0创建完进程1后,将执行cpu_idle函数,本质上是在开中断的情况下重复执行hlt汇编指令(参见第四章)。只有在没有其他进程处于可运行状态下才会选择到进程0。

多处理器系统中,每个CPU都有一个进程0。当打开电源后,BIOS将启动某一个CPU,并禁用其他CPU。该运行中的swapper将初始化内核数据结构并激活其他的CPU,通过copy_process函数创建另外的swapper进程。

进程1

进程1共享进程0的所有内核数据结构,当调度程序选择到它时,init将执行init()函数,依次完成内核初始化。init()调用execve系统调用装入可执行程序init,结果init内核进程变为一个普通进程,且拥有自己的每进程内核数据结构(参考第二十章程序的运行)。

在系统关闭前,进程1一直存在,因为其创建和监控外层执行的所有进程的活动。

其他内核线程

  • keventd: 执行工作队列中的函数(参考第四章中断与异常)

  • kswapd: 执行内存回收,参考第十七章周期回收一节

  • pdflush: 刷新脏缓冲区内容到磁盘以回收内存,参考第十五章“pdflush与内核线程”一节

  • kblockd: 执行kblockd_workqueue工作队列中的函数,参考第十四章“激活块设备驱动程序”

  • ksoftirqd: 运行tasklet, 参考第四章“软中断以及tasklet”一节

撤销进程

很多进程终止了它们本该执行的代码,这种情况下必须通知内核以释放进程所拥有的资源,包括内存、打开文件、信号量以及其他零碎的东西。

进程终止的一般方式是调用exit()库函数,该函数释放C函数库分配的资源,执行编程者注册的每个函数,并结束从系统回收进程的系统调用。exit函数可能由编程者显示地插入,而C编译程序总是把该函数插入到main()的最后一条语句之后。

另外,当内核接收到不能处理或忽视的信号(参考第十一章信号)或内核在运行内核态进程时产生了不可恢复的CPU异常(参考第四章异常与中断)后,内核可以有选择地强迫整个线程组死亡。

进程终止

Linux2.6中有两个终止用户态应用的系统调用:

  • exit_group系统调用,用于终止整个线程组,其主要内核函数为do_group_exit()。这是c库函数exit()应该调用的系统调用。
c
1/*
2 * this kills every thread in the thread group. Note that any externally
3 * wait4()-ing process will get the correct exit code - even if this
4 * thread is not the thread group leader.
5 */
6asmlinkage void sys_exit_group(int error_code)
7{
8    do_group_exit((error_code & 0xff) << 8);
9}
  • exit系统调用,用于终止某一个进程,而不管线程组中其他的进程,其主要内核函数为do_exit()。这是被诸如pthread_exit等Linux线程库函数调用的系统调用。
c
1asmlinkage long sys_exit(int error_code)
2{
3    do_exit((error_code&0xff)<<8);
4}

do_group_exit

该函数杀死当前线程组下的所有进程。它接收进程终止代号作为参数,该值可以是exit_group正常结束后指定的一个值,也可能是内核提供的一个错误代码。

该函数将执行:

  • 检查该进程的SIGNAL_GROUP_EXIT是否为0.如果不为0,说明内核已经开始为这个线程组执行退出的过程。在这种情况下,就将signal->group_exit_code中的值作为返回码。

  • 不然,则设置SIGNAL_GROUP_EXIT标志,并将终止代号存放在signal->group_exit_code字段中。随后调用zap_other_threads函数杀死线程组中的其他进程。该函数在PIDTYPE_TGID类型的散列表中寻找与当前进程tgid相同的所有进程,向其发送SIGKILL信号,从而所有这样的进程都将执行do_exit函数。

  • 调用do_exit函数,传递终止代号作为参数。

do_exit

所有进程的终止都是由do_exit函数处理的,其从内核数据结构中删除对终止进程的大部分引用,其执行下述操作:

  • 设置进程描述符的flag字段为PF_EXITING标志,表示进程正在被删除。

  • 调用del_timer_sync函数从动态定时器队列中删除进程描述符

  • 调用exit_mm、exit_sem、__exit_files、__exit_fs、exit_namespace和exit_thread函数从进程描述符中分离出这些相关的数据结构。如果没有其他进程共享他们,那么这些函数还将删除这些数据结构。

  • 如果执行域可可执行格式的内核函数包含在内核模块中,则函数递减他们的计数器

  • 将终止代号填入到进程描述符的exit_code字段。

  • 调用exit_notify函数:

    • 更新父子进程的亲属关系

    • 根据exit_signal 字段的内容、进程组中是否有其他成员以及子进程是否被追踪,来决定是否要向父进程发送信号以及进程自身描述符的exit_state字段:

    c
     1/* Let father know we died 
     2 *
     3 * Thread signals are configurable, but you aren't going to use
     4 * that to send signals to arbitary processes. 
     5 * That stops right now.
     6 *
     7 * If the parent exec id doesn't match the exec id we saved
     8 * when we started then we know the parent has changed security
     9 * domain.
    10 *
    11 * If our self_exec id doesn't match our parent_exec_id then
    12 * we have changed execution domain as these two values started
    13 * the same after a fork.
    14 *	
    15 */
    16
    17if (tsk->exit_signal != SIGCHLD && tsk->exit_signal != -1 &&
    18    ( tsk->parent_exec_id != t->self_exec_id  ||
    19      tsk->self_exec_id != tsk->parent_exec_id)
    20    && !capable(CAP_KILL))
    21    tsk->exit_signal = SIGCHLD;
    22
    23/* If something other than our normal parent is ptracing us, then
    24 * send it a SIGCHLD instead of honoring exit_signal.  exit_signal
    25 * only has special meaning to our real parent.
    26 */
    27if (tsk->exit_signal != -1 && thread_group_empty(tsk)) {
    28    int signal = tsk->parent == tsk->real_parent ? tsk->exit_signal : SIGCHLD;
    29    do_notify_parent(tsk, signal);
    30} else if (tsk->ptrace) {
    31    do_notify_parent(tsk, SIGCHLD);
    32}
    33
    34state = EXIT_ZOMBIE;
    35if (tsk->exit_signal == -1 &&
    36    (likely(tsk->ptrace == 0) ||
    37     unlikely(tsk->parent->signal->flags & SIGNAL_GROUP_EXIT)))
    38    state = EXIT_DEAD;
    39tsk->exit_state = state;
    40
    41...
    42
    43/* If the process is dead, release it - nobody will wait for it */
    44if (state == EXIT_DEAD)
    45    release_task(tsk);
    46
    47tsk->flags |= PF_DEAD;
  • 调用schedule函数进行进程调度。调度程序将忽略zombie状态的进程,所以这种进程在schedule中的switch_to宏被调用后停止执行。(?)

进程删除

在上一节中,release_task函数将从进程描述符中分离出最后的数据结构,对僵尸程序的处理有两种可能的方式:

  • 父进程不再需要接受子进程的信号,就调用do_exit()

  • 如果以及发送给父进程一个信号,就调用wait4()或waitpid()系统调用

前一种方式下内存的回收将由调度程序完成(参考第七章),后一种方式下父进程将会回收进程描述符占用的空间。

release_task执行下述过程:

c
 1void release_task(struct task_struct * p)
 2{
 3    int zap_leader;
 4    task_t *leader;
 5    struct dentry *proc_dentry;
 6
 7repeat: 
 8    atomic_dec(&p->user->processes);
 9    spin_lock(&p->proc_lock);
10    proc_dentry = proc_pid_unhash(p);
11    write_lock_irq(&tasklist_lock);
12    if (unlikely(p->ptrace))
13        __ptrace_unlink(p);
14    BUG_ON(!list_empty(&p->ptrace_list) || !list_empty(&p->ptrace_children));
15    __exit_signal(p);
16    __exit_sighand(p);
17    __unhash_process(p);
18
19    /*
20     * If we are the last non-leader member of the thread
21     * group, and the leader is zombie, then notify the
22     * group leader's parent process. (if it wants notification.)
23     */
24    zap_leader = 0;
25    leader = p->group_leader;
26    if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) {
27        BUG_ON(leader->exit_signal == -1);
28        do_notify_parent(leader, leader->exit_signal);
29        /*
30         * If we were the last child thread and the leader has
31         * exited already, and the leader's parent ignores SIGCHLD,
32         * then we are the one who should release the leader.
33         *
34         * do_notify_parent() will have marked it self-reaping in
35         * that case.
36         */
37        zap_leader = (leader->exit_signal == -1);
38    }
39
40    sched_exit(p);  //调整父进程的时间片
41    write_unlock_irq(&tasklist_lock);
42    spin_unlock(&p->proc_lock);
43    proc_pid_flush(proc_dentry);
44    release_thread(p);
45    put_task_struct(p);
46
47    p = leader;
48    if (unlikely(zap_leader))
49        goto repeat;
50}
嗨! 这里是 rqdmap 的个人博客, 我正关注 GNU/Linux 桌面系统, Linux 内核 以及一切有趣的计算机技术! 希望我的内容能对你有所帮助~
如果你遇到了任何问题, 包括但不限于: 博客内容说明不清楚或错误; 样式版面混乱; 加密博客访问请求等问题, 请通过邮箱 rqdmap@gmail.com 联系我!
修改日志
  • 2023-05-29 23:05:14 博客结构与操作脚本重构
  • 2023-05-08 21:44:36 博客架构修改升级
  • 2022-12-12 23:29:24 仅更新了Markdown头, 删除了残留的VSC插件选项
  • 2022-11-16 01:27:34 迁移老博客文章内容