Linux 中的每个进程都动态分配一个 struct task_struct
结构。Linux 上可以创建的最大进程数仅受物理内存大小的限制,并且等于(参见 kernel/fork.c:fork_init()
)
/*
* The default maximum number of threads is set to a safe
* value: the thread structures can take up at most half
* of memory.
*/
max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 2;
在 IA32 架构上,这基本上意味着 num_physpages/4
。例如,在一台 512M 内存的机器上,您可以创建 32k 个线程。与旧版本(2.2 及更早版本)内核的 4k-epsilon 限制相比,这是一个相当大的改进。此外,可以使用 KERN_MAX_THREADS
sysctl(2) 在运行时更改此值,或者直接使用 procfs 接口来调整内核参数
# cat /proc/sys/kernel/threads-max
32764
# echo 100000 > /proc/sys/kernel/threads-max
# cat /proc/sys/kernel/threads-max
100000
# gdb -q vmlinux /proc/kcore
Core was generated by `BOOT_IMAGE=240ac18 ro root=306 video=matrox:vesa:0x118'.
#0 0x0 in ?? ()
(gdb) p max_threads
$1 = 100000
Linux 系统上的进程集合表示为 struct task_struct
结构的集合,这些结构通过两种方式链接在一起
p->next_task
和 p->prev_task
指针。哈希表称为 pidhash[]
,在 include/linux/sched.h
中定义
/* PID hashing. (shouldnt this be dynamic?) */
#define PIDHASH_SZ (4096 >> 2)
extern struct task_struct *pidhash[PIDHASH_SZ];
#define pid_hashfn(x) ((((x) >> 8) ^ (x)) & (PIDHASH_SZ - 1))
任务通过其 pid 值进行哈希,并且上述哈希函数应该将元素均匀地分布在其域中(0
到 PID_MAX-1
)。哈希表用于通过给定的 pid 快速查找任务,使用来自 include/linux/sched.h
的内联函数 find_task_pid()
static inline struct task_struct *find_task_by_pid(int pid)
{
struct task_struct *p, **htable = &pidhash[pid_hashfn(pid)];
for(p = *htable; p && p->pid != pid; p = p->pidhash_next)
;
return p;
}
每个哈希列表(即哈希到相同值的列表)上的任务通过 p->pidhash_next/pidhash_pprev
链接,hash_pid()
和 unhash_pid()
使用它们将给定进程插入和移除哈希表。这些操作在读写自旋锁 tasklist_lock
的写保护下完成。
维护使用 p->next_task/prev_task
的循环双向链表是为了可以轻松遍历系统上的所有任务。这是通过 include/linux/sched.h
中的 for_each_task()
宏实现的
#define for_each_task(p) \
for (p = &init_task ; (p = p->next_task) != &init_task ; )
for_each_task()
的用户应获取 tasklist_lock
的读锁。请注意,for_each_task()
使用 init_task
来标记列表的开始(和结束)——这是安全的,因为空闲任务(pid 0)永远不会退出。
进程哈希表和/或进程表链接的修改器,特别是 fork()
、exit()
和 ptrace()
,必须获取 tasklist_lock
的写锁。更有趣的是,写入者还必须禁用本地 CPU 上的中断。原因并非显而易见:send_sigio()
函数遍历任务列表,因此获取 tasklist_lock
的读锁,并且它是从中断上下文中的 kill_fasync()
调用的。这就是为什么写入者必须禁用中断,而读取者不需要的原因。
现在我们了解了 task_struct
结构是如何链接在一起的,让我们检查 task_struct
的成员。它们大致对应于 UNIX 'struct proc' 和 'struct user' 组合在一起的成员。
UNIX 的其他版本将任务状态信息分为两个部分,一部分应始终驻留在内存中(称为“proc 结构”,包括进程状态、调度信息等),另一部分仅在进程运行时才需要(称为“u 区域”,包括文件描述符表、磁盘配额信息等)。这种丑陋设计的唯一原因是内存是非常稀缺的资源。现代操作系统(好吧,目前只有 Linux,但其他操作系统,例如 FreeBSD,似乎正在朝着 Linux 的方向改进)不需要这种分离,因此始终在内核驻留内存的数据结构中维护进程状态。
task_struct
结构在 include/linux/sched.h
中声明,目前大小为 1680 字节。
状态字段声明为
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 4
#define TASK_STOPPED 8
#define TASK_EXCLUSIVE 32
为什么 TASK_EXCLUSIVE
定义为 32 而不是 16?因为 16 已被 TASK_SWAPPING
使用,当我删除所有对 TASK_SWAPPING
的引用时(大约在 2.3.x 版本中),我忘记将 TASK_EXCLUSIVE
向上移动。
p->state
声明中的 volatile
表示它可以异步修改(来自中断处理程序)
TASK_RUNNING
并将其放置在运行队列中不是原子操作。您需要持有 runqueue_lock
读写自旋锁的读锁才能查看运行队列。如果您这样做,您将看到运行队列上的每个任务都处于 TASK_RUNNING
状态。但是,反之则不然,原因如上所述。类似地,驱动程序可以将自己(或者更确切地说,它们运行的进程上下文)标记为 TASK_INTERRUPTIBLE
(或 TASK_UNINTERRUPTIBLE
),然后调用 schedule()
,这将从运行队列中删除它(除非有挂起的信号,在这种情况下,它将留在运行队列中)。TASK_INTERRUPTIBLE
相同,只是它无法被唤醒。wait()
)。TASK_INTERRUPTIBLE
或 TASK_UNINTERRUPTIBLE
中的任何一个进行或运算。这意味着当此任务在具有许多其他任务的等待队列上休眠时,它将单独唤醒,而不是通过唤醒所有等待者来引起“惊群”问题。任务标志包含有关进程状态的信息,这些状态不是互斥的
unsigned long flags; /* per process flags, defined below */
/*
* Per process flags
*/
#define PF_ALIGNWARN 0x00000001 /* Print alignment warning msgs */
/* Not implemented yet, only for 486*/
#define PF_STARTING 0x00000002 /* being created */
#define PF_EXITING 0x00000004 /* getting shut down */
#define PF_FORKNOEXEC 0x00000040 /* forked but didn't exec */
#define PF_SUPERPRIV 0x00000100 /* used super-user privileges */
#define PF_DUMPCORE 0x00000200 /* dumped core */
#define PF_SIGNALED 0x00000400 /* killed by a signal */
#define PF_MEMALLOC 0x00000800 /* Allocating memory */
#define PF_VFORK 0x00001000 /* Wake up parent in mm_release */
#define PF_USEDFPU 0x00100000 /* task used FPU this quantum (SMP) */
字段 p->has_cpu
、p->processor
、p->counter
、p->priority
、p->policy
和 p->rt_priority
与调度器相关,将在稍后查看。
字段 p->mm
和 p->active_mm
分别指向进程的地址空间(由 mm_struct
结构描述)和活动的地址空间(如果进程没有真实的地址空间,例如内核线程)。这有助于在任务被调度出去时最小化切换地址空间时的 TLB 刷新。因此,如果我们正在调度内核线程(它没有 p->mm
),那么它的 next->active_mm
将设置为已调度出去的任务的 prev->active_mm
,如果 prev->mm != NULL
,这将与 prev->mm
相同。如果将 CLONE_VM
标志传递给 clone(2) 系统调用或通过 vfork(2) 系统调用,则可以在线程之间共享地址空间。
字段 p->exec_domain
和 p->personality
与任务的个性有关,即某些系统调用的行为方式,以便模拟外国 UNIX 风格的“个性”。
字段 p->fs
包含文件系统信息,在 Linux 中,这意味着三条信息
此结构还包括一个引用计数,因为当将 CLONE_FS
标志传递给 clone(2) 系统调用时,它可以在克隆的任务之间共享。
字段 p->files
包含文件描述符表。如果使用 clone(2) 系统调用指定了 CLONE_FILES
,则也可以在任务之间共享此表。
字段 p->sig
包含信号处理程序,可以通过 CLONE_SIGHAND
在克隆的任务之间共享。
关于操作系统的不同书籍以不同的方式定义“进程”,从“程序在执行中的实例”开始,到“由 clone(2) 或 fork(2) 系统调用产生的东西”结束。在 Linux 下,有三种类型的进程
空闲线程在编译时为第一个 CPU 创建;然后通过 arch/i386/kernel/smpboot.c
中的特定于架构的 fork_by_hand()
为每个 CPU“手动”创建,它手动展开 fork(2) 系统调用(在某些架构上)。空闲任务共享一个 init_task 结构,但具有私有的 TSS 结构,位于每个 CPU 数组 init_tss
中。空闲任务都具有 pid = 0,并且没有其他任务可以共享 pid,即使用 CLONE_PID
标志到 clone(2)。
内核线程使用 kernel_thread()
函数创建,该函数在内核模式下调用 clone(2) 系统调用。内核线程通常没有用户地址空间,即 p->mm = NULL
,因为它们显式地执行 exit_mm()
,例如通过 daemonize()
函数。内核线程始终可以直接访问内核地址空间。它们在低范围内分配 pid 号。在处理器的环 0(在 x86 上)运行意味着内核线程享有所有 I/O 特权,并且不能被调度程序抢占。
用户任务通过 clone(2) 或 fork(2) 系统调用创建,这两个系统调用都在内部调用 kernel/fork.c:do_fork()。
让我们了解一下当用户进程发出 fork(2) 系统调用时会发生什么。尽管 fork(2) 由于传递用户堆栈和寄存器的不同方式而依赖于架构,但执行实际工作的底层函数 do_fork()
是可移植的,并且位于 kernel/fork.c
中。
完成以下步骤
retval
设置为 -ENOMEM
,因为如果 fork(2) 无法分配新的任务结构,则应将 errno
设置为此值。clone_flags
中设置了 CLONE_PID
,则返回错误 (-EPERM
),除非调用者是空闲线程(仅在启动期间)。因此,普通用户线程无法将 CLONE_PID
传递给 clone(2) 并期望它成功。对于 fork(2),这无关紧要,因为 clone_flags
设置为 SIFCHLD
- 这仅在从 sys_clone()
调用 do_fork()
时才相关,sys_clone()
传递从用户空间请求的值 clone_flags
。current->vfork_sem
(稍后在子进程中清除)。sys_vfork()
(vfork(2) 系统调用,对应于 clone_flags = CLONE_VFORK|CLONE_VM|SIGCHLD
)使用它使父进程休眠,直到子进程执行 mm_release()
,例如由于 exec()
另一个程序或 exit(2)。alloc_task_struct()
宏分配新的任务结构。在 x86 上,它只是 GFP_KERNEL
优先级的 gfp。这是 fork(2) 系统调用可能休眠的第一个原因。如果此分配失败,我们将返回 -ENOMEM
。*p = *current
将当前进程任务结构中的所有值复制到新的任务结构中。也许应该用 memcpy 替换它?稍后,应由子进程继承的字段将设置为正确的值。RLIMIT_NPROC
软限制 - 如果是,则以 -EAGAIN
失败,否则,按给定的 uid p->user->count
递增进程计数。-EAGAIN
失败。p->did_exec = 0
)p->swappable = 0
)p->state = TASK_UNINTERRUPTIBLE
(TODO:为什么要这样做?我认为不需要 - 删除它,Linus 确认不需要)p->flags
根据 clone_flags 的值设置;对于普通的 fork(2),这将是 p->flags = PF_FORKNOEXEC
。p->pid
使用 kernel/fork.c:get_pid()
中的快速算法设置(TODO:lastpid_lock
自旋锁可以变得冗余,因为 get_pid()
始终在 do_fork()
的大内核锁下调用,还要删除 get_pid()
的 flags 参数,补丁已于 2000 年 6 月 20 日发送给 Alan - 后续跟进)。do_fork()
中的其余代码初始化子进程任务结构的其余部分。最后,子进程的任务结构被哈希到 pidhash
哈希表中,并且子进程被唤醒(TODO:wake_up_process(p)
设置 p->state = TASK_RUNNING
并将进程添加到 runq,因此我们可能不需要在 do_fork()
中更早地设置 p->state
为 TASK_RUNNING
)。有趣的部分是将 p->exit_signal
设置为 clone_flags & CSIGNAL
,对于 fork(2),这意味着仅是 SIGCHLD
,并将 p->pdeath_signal
设置为 0。当进程“忘记”原始父进程(通过死亡)时,将使用 pdeath_signal
,并且可以通过 prctl(2) 系统调用的 PR_GET/SET_PDEATHSIG
命令设置/获取它(您可能会争辩说,通过 prctl(2) 中用户空间指针参数返回 pdeath_signal
值的方式有点傻 - mea culpa,在 Andries Brouwer 更新手册页后,修复它为时已晚;)因此,任务被创建。任务终止的方式有几种
func == 1
调用 bdflush(2)(这是 Linux 特有的,为了与旧发行版兼容,这些发行版在 /etc/inittab
中仍然有 'update' 行 - 如今,update 的工作由内核线程 kupdate
完成)。在 Linux 下实现系统调用的函数以 sys_
为前缀,但它们通常只关注参数检查或特定于架构的传递某些信息的方式,而实际工作由 do_
函数完成。sys_exit()
也是如此,它调用 do_exit()
来完成工作。尽管如此,内核的其他部分有时会调用 sys_exit()
,而它们实际上应该调用 do_exit()
。
函数 do_exit()
在 kernel/exit.c
中找到。关于 do_exit()
的要点
schedule()
,它永远不会返回。TASK_ZOMBIE
。current->pdeath_signal
不为 0,则通知任何子进程。current->exit_signal
,通常等于 SIGCHLD
。
调度程序的工作是在多个进程之间仲裁对当前 CPU 的访问。调度程序在“主内核文件” kernel/sched.c
中实现。几乎每个内核源文件都包含相应的头文件 include/linux/sched.h
(显式或隐式地)。
与调度程序相关的任务结构字段包括
p->need_resched
:如果应在“下一次机会”调用 schedule()
,则设置此字段。p->counter
:在此调度时间片中剩余运行的时钟节拍数,由定时器递减。当此字段变得小于或等于零时,它将重置为 0,并设置 p->need_resched
。这有时也称为进程的“动态优先级”,因为它本身可以更改。p->priority
:进程的静态优先级,仅通过众所周知的系统调用(如 nice(2)、POSIX.1b sched_setparam(2) 或 4.4BSD/SVR4 setpriority(2))更改。p->rt_priority
:实时优先级p->policy
:调度策略,指定任务所属的调度类。任务可以使用 sched_setscheduler(2) 系统调用更改其调度类。有效值是 SCHED_OTHER
(传统的 UNIX 进程)、SCHED_FIFO
(POSIX.1b FIFO 实时进程)和 SCHED_RR
(POSIX 轮询实时进程)。也可以将 SCHED_YIELD
或运算到这些值中的任何一个,以表示进程决定放弃 CPU,例如通过调用 sched_yield(2) 系统调用。FIFO 实时进程将运行,直到 a) 它阻塞 I/O,b) 它显式放弃 CPU,或 c) 它被另一个具有更高 p->rt_priority
值的实时进程抢占。SCHED_RR
与 SCHED_FIFO
相同,只是当其时间片到期时,它会返回到运行队列的末尾。调度程序的算法很简单,尽管 schedule()
函数看起来非常复杂。该函数很复杂,因为它在一个函数中实现了三种调度算法,并且还因为微妙的 SMP 特性。
schedule()
中看似“无用”的 goto 语句是有目的的 - 为了生成最佳优化(对于 i386)的代码。另外,请注意,调度程序(像大多数内核一样)在 2.4 版本中已完全重写,因此以下讨论不适用于 2.2 或更早版本的内核。
让我们详细看一下该函数
current->active_mm == NULL
,则表示出现问题。当前进程,即使是内核线程 (current->mm == NULL
),也必须始终具有有效的 p->active_mm
。tq_scheduler
任务队列上有待处理的任务,请立即处理。任务队列提供了一种内核机制,用于在稍后的时间调度函数的执行。我们将在其他地方详细介绍它。prev
和 this_cpu
初始化为当前任务和当前 CPU。schedule()
是否是从中断处理程序调用的(由于错误),如果是,则 panic。struct schedule_data *sched_data
以指向每个 CPU(缓存行对齐以防止缓存行乒乓)调度数据区域,该区域包含 last_schedule
的 TSC 值和指向上次调度的任务结构的指针(TODO:sched_data
仅在 SMP 上使用,但为什么 init_idle()
也在 UP 上初始化它?)。runqueue_lock
自旋锁。请注意,我们使用 spin_lock_irq()
,因为在 schedule()
中,我们保证中断已启用。因此,当我们解锁 runqueue_lock
时,我们可以直接重新启用它们,而不是保存/恢复 eflags(spin_lock_irqsave/restore
变体)。TASK_RUNNING
状态,则保持不变;如果它处于 TASK_INTERRUPTIBLE
状态并且有挂起的信号,则将其移动到 TASK_RUNNING
状态。在所有其他情况下,它将从运行队列中删除。next
(最佳调度候选者)设置为此 cpu 的空闲任务。但是,此候选者的 goodness 设置为非常低的值 (-1000),希望有比它更好的候选者。prev
(当前)任务处于 TASK_RUNNING
状态,则当前 goodness 设置为其 goodness,并且它被标记为比空闲任务更好的调度候选者。goodness()
的函数计算,该函数通过使其 goodness 非常高 (1000 + p->rt_priority
) 来处理实时进程,这大于 1000,保证没有 SCHED_OTHER
进程可以获胜;因此它们仅与可能具有更大 p->rt_priority
的其他实时进程竞争。如果进程的时间片 (p->counter
) 结束,则 goodness 函数返回 0。对于非实时进程,goodness 的初始值设置为 p->counter
- 这样,如果进程已经运行了一段时间,则不太可能获得 CPU,即交互式进程比 CPU 密集型数值计算程序更受青睐。特定于架构的常量 PROC_CHANGE_PENALTY
尝试实现“cpu 亲和性”(即,优先考虑同一 CPU 上的进程)。它还为 mm 指向当前 active_mm
的进程或没有(用户)地址空间的进程(即内核线程)提供轻微的优势。
recalculate:
{
struct task_struct *p;
spin_unlock_irq(&runqueue_lock);
read_lock(&tasklist_lock);
for_each_task(p)
p->counter = (p->counter >> 1) + p->priority;
read_unlock(&tasklist_lock);
spin_lock_irq(&runqueue_lock);
}
请注意,我们在重新计算之前放弃了 runqueue_lock
。原因是我们要遍历整个进程集;这可能需要很长时间,在此期间,schedule()
可以在另一个 CPU 上调用,并选择一个 goodness 对于该 CPU 来说足够好的进程,而我们在此 CPU 上被迫重新计算。好吧,诚然,这有点不一致,因为当我们在(此 CPU 上)选择具有最佳 goodness 的进程时,在另一个 CPU 上运行的 schedule()
可能会重新计算动态优先级。next
指向要调度的任务,因此我们将 next->has_cpu
初始化为 1,并将 next->processor
初始化为 this_cpu
。现在可以解锁 runqueue_lock
。next == prev
),那么我们可以简单地重新获取全局内核锁并返回,即跳过所有硬件级(寄存器、堆栈等)和 VM 相关(切换页目录、重新计算 active_mm
等)的东西。switch_to()
是特定于架构的。在 i386 上,它与 a) FPU 处理,b) LDT 处理,c) 重新加载段寄存器,d) TSS 处理和 e) 重新加载调试寄存器有关。
在我们继续研究等待队列的实现之前,我们必须熟悉 Linux 标准双向链表实现。等待队列(以及 Linux 中的所有其他内容)大量使用它们,并且在行话中称为“list.h 实现”,因为最相关的文件是 include/linux/list.h
。
这里基本的数据结构是 struct list_head
struct list_head {
struct list_head *next, *prev;
};
#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define LIST_HEAD(name) \
struct list_head name = LIST_HEAD_INIT(name)
#define INIT_LIST_HEAD(ptr) do { \
(ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)
#define list_entry(ptr, type, member) \
((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))
#define list_for_each(pos, head) \
for (pos = (head)->next; pos != (head); pos = pos->next)
前三个宏用于通过将 next
和 prev
指针都指向自身来初始化空列表。从 C 语法限制中可以明显看出哪些宏应该在何处使用 - 例如,LIST_HEAD_INIT()
可以用于声明中结构的元素初始化,第二个可以用于静态变量初始化声明,第三个可以在函数内部使用。
宏 list_entry()
提供对单个列表元素的访问,例如(来自 fs/file_table.c:fs_may_remount_ro()
)
struct super_block {
...
struct list_head s_files;
...
} *sb = &some_super_block;
struct file {
...
struct list_head f_list;
...
} *file;
struct list_head *p;
for (p = sb->s_files.next; p != &sb->s_files; p = p->next) {
struct file *file = list_entry(p, struct file, f_list);
do something to 'file'
}
list_for_each()
宏的一个很好的用例是在调度程序中,我们在运行队列中查找具有最高 goodness 的进程
static LIST_HEAD(runqueue_head);
struct list_head *tmp;
struct task_struct *p;
list_for_each(tmp, &runqueue_head) {
p = list_entry(tmp, struct task_struct, run_list);
if (can_schedule(p)) {
int weight = goodness(p, this_cpu, prev->active_mm);
if (weight > c)
c = weight, next = p;
}
}
在这里,p->run_list
在 task_struct
结构内部声明为 struct list_head run_list
,并充当列表的锚点。从列表中删除元素以及添加元素(到列表的头部或尾部)由 list_del()/list_add()/list_add_tail()
宏完成。以下示例是在运行队列中添加和删除任务
static inline void del_from_runqueue(struct task_struct * p)
{
nr_running--;
list_del(&p->run_list);
p->run_list.next = NULL;
}
static inline void add_to_runqueue(struct task_struct * p)
{
list_add(&p->run_list, &runqueue_head);
nr_running++;
}
static inline void move_last_runqueue(struct task_struct * p)
{
list_del(&p->run_list);
list_add_tail(&p->run_list, &runqueue_head);
}
static inline void move_first_runqueue(struct task_struct * p)
{
list_del(&p->run_list);
list_add(&p->run_list, &runqueue_head);
}
当进程请求内核执行当前不可能但以后可能实现的操作时,该进程将被置于休眠状态,并在请求更有可能得到满足时唤醒。用于此目的的内核机制之一称为“等待队列”。
Linux 实现允许使用 TASK_EXCLUSIVE
标志的唤醒语义。使用等待队列,您可以要么使用众所周知的队列,然后简单地 sleep_on/sleep_on_timeout/interruptible_sleep_on/interruptible_sleep_on_timeout
,要么您可以定义自己的等待队列并使用 add/remove_wait_queue
将自己添加到其中并从中删除,并使用 wake_up/wake_up_interruptible
在需要时唤醒。
等待队列的第一个用例示例是页面分配器(在 mm/page_alloc.c:__alloc_pages()
中)和 kswapd
内核守护进程(在 mm/vmscan.c:kswap()
中)之间的交互,通过等待队列 kswapd_wait
,在 mm/vmscan.c
中声明;kswapd
守护进程在此队列上休眠,并在页面分配器需要释放一些页面时将其唤醒。
自主等待队列用例的一个示例是用户进程通过 read(2) 系统调用请求数据与内核在中断上下文中运行以提供数据之间的交互。中断处理程序可能看起来像(简化的 drivers/char/rtc_interrupt()
)
static DECLARE_WAIT_QUEUE_HEAD(rtc_wait);
void rtc_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
spin_lock(&rtc_lock);
rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
spin_unlock(&rtc_lock);
wake_up_interruptible(&rtc_wait);
}
因此,中断处理程序通过从某些特定于设备的 I/O 端口读取来获取数据(CMOS_READ()
宏变成一对 outb/inb
),然后唤醒在 rtc_wait
等待队列上休眠的任何进程。
现在,read(2) 系统调用可以实现为
ssize_t rtc_read(struct file file, char *buf, size_t count, loff_t *ppos)
{
DECLARE_WAITQUEUE(wait, current);
unsigned long data;
ssize_t retval;
add_wait_queue(&rtc_wait, &wait);
current->state = TASK_INTERRUPTIBLE;
do {
spin_lock_irq(&rtc_lock);
data = rtc_irq_data;
rtc_irq_data = 0;
spin_unlock_irq(&rtc_lock);
if (data != 0)
break;
if (file->f_flags & O_NONBLOCK) {
retval = -EAGAIN;
goto out;
}
if (signal_pending(current)) {
retval = -ERESTARTSYS;
goto out;
}
schedule();
} while(1);
retval = put_user(data, (unsigned long *)buf);
if (!retval)
retval = sizeof(unsigned long);
out:
current->state = TASK_RUNNING;
remove_wait_queue(&rtc_wait, &wait);
return retval;
}
rtc_read()
中发生的情况是这样的
rtc_wait
等待队列。TASK_INTERRUPTIBLE
,这意味着在下次休眠后它将不会被重新调度。TASK_RUNNING
,从等待队列中删除自己并返回EAGAIN
失败(与 EWOULDBLOCK
相同)TASK_INTERRUPTIBLE
,那么调度程序可能会在数据可用之前调度我们,从而导致不必要的处理。还值得指出的是,使用等待队列,很容易实现 poll(2) 系统调用
static unsigned int rtc_poll(struct file *file, poll_table *wait)
{
unsigned long l;
poll_wait(file, &rtc_wait, wait);
spin_lock_irq(&rtc_lock);
l = rtc_irq_data;
spin_unlock_irq(&rtc_lock);
if (l != 0)
return POLLIN | POLLRDNORM;
return 0;
}
所有工作都由设备无关函数 poll_wait()
完成,该函数执行必要的等待队列操作;我们所需要做的就是将其指向由我们特定于设备的 中断处理程序唤醒的等待队列。
现在让我们将注意力转向内核定时器。内核定时器用于在未来的指定时间分派特定函数(称为“定时器处理程序”)的执行。主要数据结构是 include/linux/timer.h
中声明的 struct timer_list
struct timer_list {
struct list_head list;
unsigned long expires;
unsigned long data;
void (*function)(unsigned long);
volatile int running;
};
list
字段用于链接到内部列表,受 timerlist_lock
自旋锁保护。expires
字段是 jiffies
的值,当 function
处理程序应该被调用时,data
将作为参数传递给它。running
字段用于 SMP 系统上测试定时器处理程序当前是否在另一个 CPU 上运行。
函数 add_timer()
和 del_timer()
用于向列表中添加和移除给定的定时器。当定时器到期时,它会被自动移除。在定时器被使用之前,必须使用 init_timer()
函数对其进行初始化。并且在添加之前,必须设置 function
和 expires
字段。
有时,将中断处理程序内部要执行的工作量拆分为立即执行的工作(例如,确认中断、更新统计信息等)和可以推迟到稍后中断启用时执行的工作(例如,对数据进行一些后处理、唤醒等待此数据的进程等)是合理的。
底半部是用于延迟执行内核任务的最古老机制,自 Linux 1.x 以来就已可用。在 Linux 2.0 中,添加了一种称为“任务队列”的新机制,这将是下一节的主题。
底半部由 global_bh_lock
自旋锁串行化,即在任何时候任何 CPU 上只能运行一个底半部。然而,当尝试执行处理程序时,如果 global_bh_lock
不可用,则底半部会被标记(即,调度)为执行 - 因此处理可以继续,而不是在 global_bh_lock
上进行忙循环。
总共只能注册 32 个底半部。操作底半部所需的函数如下(全部导出到模块):
void init_bh(int nr, void (*routine)(void))
:将由 routine
参数指向的底半部处理程序安装到槽位 nr
中。槽位应该在 include/linux/interrupt.h
中以 XXXX_BH
的形式枚举,例如 TIMER_BH
或 TQUEUE_BH
。通常,子系统的初始化例程(模块的 init_module()
)使用此函数安装所需的底半部。void remove_bh(int nr)
:执行与 init_bh()
相反的操作,即卸载在槽位 nr
安装的底半部。此处不执行错误检查,因此,例如 remove_bh(32)
将会导致系统 panic/oops。通常,子系统的清理例程(模块的 cleanup_module()
)使用此函数来释放槽位,以便稍后可以被其他子系统重用。(TODO:如果有一个 /proc/bottom_halves
列出系统上所有已注册的底半部不是很好吗?这意味着 global_bh_lock
显然必须是可读/写的)void mark_bh(int nr)
:标记槽位 nr
中的底半部以供执行。通常,中断处理程序会标记其底半部(因此得名!)以便在“更安全的时间”执行。底半部是全局锁定的 tasklet,因此问题“底半部处理程序何时执行?”实际上是“tasklet 何时执行?”。答案是在两个地方:a) 在每次 schedule()
时,以及 b) 在 entry.S
中的每次中断/系统调用返回路径中(TODO:因此,schedule()
情况真的很无聊 - 它就像添加另一个非常非常慢的中断,为什么不完全删除 schedule()
中的 handle_softirq
标签?)。
任务队列可以被认为是旧底半部的动态扩展。事实上,在源代码中,它们有时被称为“新”底半部。更具体地说,上一节讨论的旧底半部有以下限制:
因此,使用任务队列,可以链式连接任意数量的函数并在稍后的时间依次处理。可以使用 DECLARE_TASK_QUEUE()
宏创建一个新的任务队列,并使用 queue_task()
函数将任务排队到该队列上。然后可以使用 run_task_queue()
处理任务队列。除了创建自己的任务队列(并且必须手动消耗它)之外,您还可以使用 Linux 预定义的任务队列之一,这些队列在众所周知的位置被消耗:
tq_timer
任务也在中断上下文中运行,因此无法阻塞。tq_timer
一样在关闭 tty 设备时消耗)。由于调度器在正在重新调度的进程的上下文中执行,因此 tq_scheduler
任务可以执行任何它们想做的事情,即阻塞、使用进程上下文数据(但它们为什么要这样做),等等。IMMEDIATE_BH
,因此驱动程序可以 queue_task(task, &tq_immediate)
,然后 mark_bh(IMMEDIATE_BH)
以便在中断上下文中消耗。除非驱动程序使用自己的任务队列,否则它不需要调用 run_tasks_queues()
来处理队列,除非在下面解释的情况下。
如果记住驱动程序可以在队列上调度任务,并且这些任务只有在设备的特定实例仍然有效时才有意义 - 这通常意味着直到应用程序关闭它,那么 tq_timer/tq_scheduler
任务队列不仅在通常的位置而且在其他地方(关闭 tty 设备只是一个例子)被消耗的原因就变得清楚了。因此,驱动程序可能需要调用 run_task_queue()
来刷新它(和任何其他人)放在队列上的任务,因为允许它们在稍后的时间运行可能没有意义 - 即相关的数据结构可能已被不同的实例释放/重用。这就是您在定时器中断和 schedule()
之外的其他地方看到 tq_timer
和 tq_scheduler
上 run_task_queue()
的原因。
暂无,将在未来版本中添加。
暂无,将在未来版本中添加。
在 Linux 下,有两种实现系统调用的机制:
原生 Linux 程序使用 int 0x80,而来自 UNIX 外来版本(Solaris、UnixWare 7 等)的二进制文件使用 lcall7 机制。“lcall7”这个名称在历史上具有误导性,因为它也涵盖了 lcall27(例如 Solaris/x86),但处理函数被称为 lcall7_func。
当系统启动时,会调用函数 arch/i386/kernel/traps.c:trap_init()
,它会设置 IDT,以便向量 0x80(类型 15,dpl 3)指向来自 arch/i386/kernel/entry.S
的 system_call 入口地址。
当用户空间应用程序进行系统调用时,参数通过寄存器传递,应用程序执行“int 0x80”指令。这会导致陷入内核模式,处理器跳转到 entry.S
中的 system_call 入口点。它执行的操作是:
NR_syscalls
(目前为 256),则返回 ENOSYS
错误。tsk->ptrace & PF_TRACESYS
),则进行特殊处理。这是为了支持像 strace(SVR4 truss(1) 的类似物)或调试器之类的程序。sys_call_table+4*(syscall_number from %eax)
。此表在同一文件(arch/i386/kernel/entry.S
)中初始化,以指向各个系统调用处理程序,这些处理程序在 Linux 下(通常)以 sys_
为前缀,例如 sys_open
、sys_exit
等。这些 C 系统调用处理程序将在堆栈上找到它们的参数,SAVE_ALL
在那里存储了它们。schedule()
(tsk->need_resched != 0
)、检查是否有待处理的信号以及如果有则处理它们有关。Linux 最多支持 6 个系统调用参数。它们通过 %ebx、%ecx、%edx、%esi、%edi 传递(%ebp 暂时使用,请参阅 asm-i386/unistd.h
中的 _syscall6()
)。系统调用号通过 %eax 传递。
原子操作有两种类型:位图和 atomic_t
。位图非常方便用于维护“已分配”或“空闲”单元的概念,这些单元来自一些大型集合,其中每个单元都由某个数字标识,例如空闲 inode 或空闲块。它们也广泛用于简单的锁定,例如提供对打开设备的独占访问。可以在 arch/i386/kernel/microcode.c
中找到一个示例:
/*
* Bits in microcode_status. (31 bits of room for future expansion)
*/
#define MICROCODE_IS_OPEN 0 /* set if device is in use */
static unsigned long microcode_status;
无需将 microcode_status
初始化为 0,因为 Linux 下的 BSS 会显式清零。
/*
* We enforce only one user at a time here with open/close.
*/
static int microcode_open(struct inode *inode, struct file *file)
{
if (!capable(CAP_SYS_RAWIO))
return -EPERM;
/* one at a time, please */
if (test_and_set_bit(MICROCODE_IS_OPEN, µcode_status))
return -EBUSY;
MOD_INC_USE_COUNT;
return 0;
}
位图上的操作包括:
addr
指向的位图中第 nr
位。addr
指向的位图中第 nr
位。addr
指向的位图中第 nr
位(如果已设置则清除,如果已清除则设置)。nr
位并返回旧的位值。nr
位并返回旧的位值。nr
位并返回旧的位值。这些操作使用 LOCK_PREFIX
宏,在 SMP 内核上,该宏评估为总线锁定指令前缀,而在 UP 上则为空。这保证了 SMP 环境中访问的原子性。
有时,位操作不方便,但我们需要执行算术运算 - 加法、减法、递增、递减。典型的例子是引用计数(例如,对于 inode)。此功能由 atomic_t
数据类型和以下操作提供:
atomic_t
变量 v
的值。atomic_t
变量 v
的值设置为整数 i
。i
加到由 v
指向的原子变量的值。v
指向的原子变量的值中减去整数 i
。v
指向的原子变量的值中减去整数 i
;如果新值为 0,则返回 1,否则返回 0。i
加到 v
并返回 1(如果结果为负数)。如果结果大于或等于 0,则返回 0。此操作用于实现信号量。
自 Linux 支持的早期(90 年代初,本世纪),开发人员就面临着在不同类型的上下文(用户进程 vs 中断)和来自多个 cpu 的相同上下文的不同实例之间访问共享数据的经典问题。
SMP 支持于 1995 年 11 月 15 日添加到 Linux 1.3.42(原始补丁于同年 10 月制作到 1.3.37)。
如果代码的关键区域可能由进程上下文和中断上下文执行,那么在 UP 上使用 cli/sti
指令保护它的方法是:
unsigned long flags;
save_flags(flags);
cli();
/* critical code */
restore_flags(flags);
虽然这在 UP 上是可以的,但在 SMP 上显然没有用,因为相同的代码序列可能在另一个 cpu 上同时执行,并且虽然 cli()
提供了针对每个 CPU 上中断上下文竞争的保护,但它根本没有提供针对在不同 CPU 上运行的上下文之间竞争的保护。这就是自旋锁有用的地方。
自旋锁有三种类型:普通(基本)、读写和大读者自旋锁。当自然倾向于“多读者少写者”时,应使用读写自旋锁。这方面的一个例子是访问已注册文件系统列表(参见 fs/super.c
)。该列表由 file_systems_lock
读写自旋锁保护,因为只有在注册/注销文件系统时才需要独占访问,但任何进程都可以读取文件 /proc/filesystems
或使用 sysfs(2) 系统调用来强制对 file_systems 列表进行只读扫描。这使得使用读写自旋锁变得合理。使用读写自旋锁,可以同时有多个读者,但只能有一个写者,并且在有写者时不能有读者。顺便说一句,如果在有写者尝试获取锁时,新的读者不会获得锁,那就更好了,也就是说,如果 Linux 可以正确处理多个读者可能导致写者饥饿的问题。这将意味着在有写者尝试获取锁时,必须阻止读者。目前情况并非如此,并且不清楚是否应该修复它 - 相反的论点是 - 读者通常只在很短的时间内获取锁,那么当写者在可能更长的时间内获取锁时,他们真的应该被饿死吗?
大读者自旋锁是一种读写自旋锁的形式,它针对非常轻量的读取访问进行了大量优化,但会惩罚写入。大读者自旋锁的数量有限 - 目前只有两个存在,其中一个仅在 sparc64 上使用(全局 irq),另一个用于网络。在所有其他访问模式不符合这两种情况的任何一种情况时,应使用基本自旋锁。持有任何类型的自旋锁时,您都不能阻塞。
自旋锁有三种风格:plain、_irq()
和 _bh()
。
spin_lock()/spin_unlock()
:如果您知道中断始终被禁用,或者您不与中断上下文竞争(例如,在中断处理程序中),那么您可以使用这个。它不会触及当前 CPU 上的中断状态。spin_lock_irq()/spin_unlock_irq()
:如果您知道中断始终启用,那么您可以使用此版本,它只是简单地禁用(在锁定时)和重新启用(在解锁时)当前 CPU 上的中断。例如,rtc_read()
使用 spin_lock_irq(&rtc_lock)
(在 read()
内部始终启用中断),而 rtc_interrupt()
使用 spin_lock(&rtc_lock)
(在中断处理程序内部始终禁用中断)。请注意,rtc_read()
使用 spin_lock_irq()
而不是更通用的 spin_lock_irqsave()
,因为在进入任何系统调用时,中断始终是启用的。spin_lock_irqsave()/spin_unlock_irqrestore()
:最强的形式,当中断状态未知时使用,但仅当中断完全重要时才使用,即,如果我们的中断处理程序不执行任何关键代码,则使用它是没有意义的。如果您与中断处理程序竞争,则不能使用 plain spin_lock()
的原因是,如果您获取了它,然后在同一个 CPU 上发生中断,它将永远忙等待锁:锁持有者已被中断,在中断处理程序返回之前不会继续。
自旋锁最常见的用法是访问用户进程上下文和中断处理程序之间共享的数据结构:
spinlock_t my_lock = SPIN_LOCK_UNLOCKED;
my_ioctl()
{
spin_lock_irq(&my_lock);
/* critical section */
spin_unlock_irq(&my_lock);
}
my_irq_handler()
{
spin_lock(&lock);
/* critical section */
spin_unlock(&lock);
}
关于此示例,有几点需要注意:
ioctl()
(为了清晰起见,省略了参数和返回值),必须使用 spin_lock_irq()
,因为它知道在执行设备 ioctl()
方法时中断始终是启用的。my_irq_handler()
表示(同样,为了清晰起见,省略了参数),可以使用 plain spin_lock()
形式,因为在中断处理程序内部中断被禁用。
有时,在访问共享数据结构时,必须执行可能阻塞的操作,例如将数据复制到用户空间。Linux 下可用于此类场景的锁定原语称为信号量。信号量有两种类型:基本信号量和读写信号量。根据信号量的初始值,它们可以用于互斥(初始值为 1)或提供更复杂的访问类型。
读写信号量与基本信号量的不同之处在于,读写自旋锁与基本自旋锁的不同之处相同:可以同时有多个读者,但只能有一个写者,并且在有写者时不能有读者 - 即,写者阻塞所有读者,并且在写者等待时,新的读者会阻塞。
此外,基本信号量可以是可中断的 - 只需使用操作 down/up_interruptible()
而不是 plain down()/up()
,并检查从 down_interruptible()
返回的值:如果操作被中断,则它将是非零值。
在关键代码段可能按引用调用其他子系统/模块注册的未知函数的情况下,使用信号量进行互斥是理想的,即调用者无法先验地知道函数是否阻塞。
kernel/sys.c
中有一个信号量用法的简单示例,即 gethostname(2)/sethostname(2) 系统调用的实现。
asmlinkage long sys_sethostname(char *name, int len)
{
int errno;
if (!capable(CAP_SYS_ADMIN))
return -EPERM;
if (len < 0 || len > __NEW_UTS_LEN)
return -EINVAL;
down_write(&uts_sem);
errno = -EFAULT;
if (!copy_from_user(system_utsname.nodename, name, len)) {
system_utsname.nodename[len] = 0;
errno = 0;
}
up_write(&uts_sem);
return errno;
}
asmlinkage long sys_gethostname(char *name, int len)
{
int i, errno;
if (len < 0)
return -EINVAL;
down_read(&uts_sem);
i = 1 + strlen(system_utsname.nodename);
if (i > len)
i = len;
errno = 0;
if (copy_to_user(name, system_utsname.nodename, i))
errno = -EFAULT;
up_read(&uts_sem);
return errno;
}
关于此示例,需要注意的点是:
copy_from_user()/copy_to_user()
中从/向用户空间复制数据时阻塞。因此,它们不能在此处使用任何形式的自旋锁。尽管 Linux 信号量和读写信号量的实现非常复杂,但可以想到一些尚未实现的可能场景,例如,没有可中断的读写信号量的概念。显然,这是因为没有实际情况需要这些原始类型的奇异变体。
Linux 是一个单内核操作系统,尽管现在有很多关于基于微内核设计的操作系统提供的一些“优势”的炒作,但真相仍然存在(引用 Linus Torvalds 本人):
... 将消息传递作为操作系统的基本操作只是一种计算机科学的自慰行为。它可能感觉良好,但你实际上并没有完成任何事情。
因此,Linux 现在和将来都将基于单内核设计,这意味着所有子系统都在相同的特权模式下运行并共享相同的地址空间;它们之间的通信是通过通常的 C 函数调用方式实现的。
然而,尽管像微内核中那样将内核功能分离为单独的“进程”绝对是一个坏主意,但在某些情况下(例如,在内存低的机器上或对于安装内核,否则可能包含互斥的 ISA 自动探测设备驱动程序),将其分离为动态可加载的按需内核模块是可取的。是否包含对可加载模块的支持的决定是在编译时做出的,并由 CONFIG_MODULES
选项确定。通过 request_module()
机制对模块自动加载的支持是一个单独的编译选项(CONFIG_KMOD
)。
以下功能可以在 Linux 下作为可加载模块实现:
/proc
和 devfs 中的虚拟(常规)文件(例如,/dev/cpu/microcode
vs /dev/misc/microcode
)。在 Linux 下,有一些东西不能作为模块实现(可能是因为将它们模块化没有意义):
Linux 提供了几个系统调用来辅助加载模块:
caddr_t create_module(const char *name, size_t size)
:使用 vmalloc()
分配 size
字节,并在其开头映射一个模块结构。然后,这个新模块被链接到由 module_list 引导的列表中。只有具有 CAP_SYS_MODULE
权限的进程才能调用此系统调用,其他进程将返回 EPERM
。long init_module(const char *name, struct module *image)
:加载重定位的模块映像,并导致模块的初始化例程被调用。只有具有 CAP_SYS_MODULE
权限的进程才能调用此系统调用,其他进程将返回 EPERM
。long delete_module(const char *name)
:尝试卸载模块。如果 name == NULL
,则尝试卸载所有未使用的模块。long query_module(const char *name, int which, void *buf, size_t bufsize, size_t *ret)
:返回有关模块(或有关所有模块)的信息。用户可用的命令界面包括:
除了能够使用 insmod 或 modprobe 手动加载模块之外,还可以在需要特定功能时让内核自动插入模块。内核为此提供的接口是名为 request_module(name)
的函数,该函数已导出到模块,以便模块也可以加载其他模块。request_module(name)
在内部创建一个内核线程,该线程使用标准 exec_usermodehelper()
内核接口(也已导出到模块)执行用户空间命令 modprobe -s -k module_name。该函数在成功时返回 0,但是通常不值得检查 request_module()
的返回值。相反,编程习惯用法是:
if (check_some_feature() == NULL)
request_module(module);
if (check_some_feature() == NULL)
return -ENODEV;
例如,fs/block_dev.c:get_blkfops()
就是这样做的,当尝试打开主设备号为 N
的块设备时,加载模块 block-major-N
。显然,没有名为 block-major-N
的模块(Linux 开发人员仅为他们的模块选择了合理的名称),但它使用文件 /etc/modules.conf
映射到正确的模块名称。但是,对于大多数众所周知的主设备号(和其他类型的模块),modprobe/insmod 命令知道要加载哪个真实模块,而无需在 /etc/modules.conf
中使用显式别名语句。
在 mount(2) 系统调用内部加载模块的一个很好的例子。mount(2) 系统调用接受文件系统类型作为字符串,然后 fs/super.c:do_mount()
将其传递给 fs/super.c:get_fs_type()
:
static struct file_system_type *get_fs_type(const char *name)
{
struct file_system_type *fs;
read_lock(&file_systems_lock);
fs = *(find_filesystem(name));
if (fs && !try_inc_mod_count(fs->owner))
fs = NULL;
read_unlock(&file_systems_lock);
if (!fs && (request_module(name) == 0)) {
read_lock(&file_systems_lock);
fs = *(find_filesystem(name));
if (fs && !try_inc_mod_count(fs->owner))
fs = NULL;
read_unlock(&file_systems_lock);
}
return fs;
}
此函数中需要注意的几件事:
file_systems_lock
的保护下完成的(因为我们没有修改已注册文件系统的列表)。try_inc_mod_count()
返回 0,那么我们认为这是一个失败 - 即,如果模块在那里但正在被删除,则它与根本不存在一样。file_systems_lock
,因为我们接下来要做的(request_module()
)是一个阻塞操作,因此我们不能在其上持有自旋锁。实际上,在这种特定情况下,即使 request_module()
保证是非阻塞的,并且模块加载在相同的上下文中原子地执行,我们也必须释放 file_systems_lock
。原因是模块的初始化函数将尝试调用 register_filesystem()
,这将为写入获取相同的 file_systems_lock
读写自旋锁。file_systems_lock
自旋锁,并尝试在列表中找到新注册的文件系统。请注意,这有点错误,因为原则上 modprobe 命令中的错误可能会导致它在成功加载请求的模块后崩溃,在这种情况下,即使新文件系统已注册,request_module()
也会失败,但 get_fs_type()
找不到它。当模块加载到内核中时,它可以引用内核使用 EXPORT_SYMBOL()
宏或当前加载的其他模块导出的任何符号。如果模块使用来自另一个模块的符号,则在依赖关系重新计算期间,它将被标记为依赖于该模块,这是通过在启动时运行 depmod -a 命令来实现的(例如,在安装新内核之后)。
通常,必须将模块集与它们使用的内核接口版本相匹配,这在 Linux 下仅表示“内核版本”,因为通常没有特殊的内核接口版本控制机制。但是,有一种称为“模块版本控制”或 CONFIG_MODVERSIONS
的有限功能,它允许在切换到新内核时避免重新编译模块。这里发生的事情是,内核符号表对于内部访问和来自模块的访问的处理方式不同。符号表的公共(即导出的)部分的元素是通过对 C 声明进行 32 位校验和来构建的。因此,为了解决模块在加载期间使用的符号,加载程序必须匹配符号的完整表示形式,其中包括校验和;如果这些符号不同,它将拒绝加载模块。这仅在内核和模块都启用了模块版本控制的情况下才会发生。如果它们中的任何一个使用原始符号名称,则加载程序只需尝试匹配模块声明的内核版本和内核导出的内核版本,如果它们不同,则拒绝加载。