在 Linux 这个庞大而精妙的操作系统世界里,隐藏着无数复杂而神奇的底层原理。今天,我们将一同深入探索其中至关重要的一环 ——Task 的内核态表示。这就像是打开了一扇通往系统核心深处的大门,在那里,我们会看到 Linux 是如何有条不紊地管理进程和线程,如同一位技艺高超的指挥家掌控着一场宏大的交响乐演奏。无论是对 Linux 系统充满好奇的新手,还是希望深入理解其内部机制的专业人士,都将在这次探索中领略到 Linux 底层的独特魅力。
想象一下,每一个在 Linux 系统中运行的进程和线程,它们就像是一个个活跃的小世界,而 Task 就是将这些小世界有序组织起来的框架。内核态则是这个框架得以稳固构建和高效运行的神秘空间,在这里,Task 展现出了它最为关键的一面,它所蕴含的丰富信息和复杂机制,构成了 Linux 系统稳定运行的基石。让我们开始这一奇妙的探秘之旅吧!
1.1Task 的定义与作用
在 Linux 操作系统中,任务(Task)是内核管理进程和线程的统一结构。它可以是一个进程,也可以是一个线程。一个任务可以包含多个线程,而一个进程可以包含多个任务。通常,一个任务由一个进程来管理。Task 负责维护进程相关信息,如进程状态、标识符、内核栈、标记以及表示进程亲属关系的成员等。这些信息对于内核有效地管理进程的执行、资源分配以及进程间的通信和协作至关重要。例如,通过进程标识符(PID),内核可以唯一地识别每个任务,便于进行调度和管理。
内核态在 Linux 系统中处于核心地位。当 CPU 处于内核态时,可以使用任何 CPU 指令集、访问一切硬件资源。这使得内核能够进行关键的系统管理任务,如进程切换、内存管理、文件系统管理等。在进程切换方面,只有内核能挂起正在 CPU 上运行的进程,或者恢复之前被挂起的进程。内核通过一系列复杂的操作,如保存处理机上下文、更新 PCB 信息、选择另一个进程并更新其 PCB 以及恢复处理机上下文等,实现高效的进程切换。内存管理方面,内核为所有进程建立虚拟地址空间,确保不同进程之间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程的地址空间中的数据。文件系统管理中,内核支持多种文件系统类型,负责管理和操作文件和目录,提供强大的文件权限、安全性以及数据完整性保护机制。总之,内核态是 Linux 系统稳定运行和高效管理资源的关键。
内核源码中task_struct结构光sched.h中就有七八百行,里面的结构开枝散叶可以说是个相当庞大的结构这里是按功能将部分重要成员介绍:
task_struct
├── Process Identification
│ ├── pid: process id,进程的唯一标识符 (PID)
│ ├── tgid: thread group ID,线程组 ID
│ └── comm: 进程的可读名称
├── Process State Management
│ ├── state: 当前进程的状态 (TASK_RUNNING, TASK_INTERRUPTIBLE 等)
│ ├── exit_state: 进程退出状态
│ ├── exit_code: 进程退出时的返回码
│ ├── jobctl: 记录任务控制的状态(如作业控制信号)
│ └── atomic_flags: 进程的原子操作标志,用于同步
├── Scheduling
│ ├── prio: 进程的动态优先级
│ ├── static_prio: 进程的静态优先级
│ ├── rt_priority: 实时任务的优先级
│ ├── sched_class: 进程的调度类(CFS, RT 等)
│ ├── se: 普通调度实体 (sched_entity)
│ ├── rt: 实时调度实体 (sched_rt_entity)
│ ├── sched_info: 调度器的统计信息
│ └── on_rq: 进程是否在运行队列上的标志
├── Memory Management
│ ├── mm: 指向进程的内存描述符 (mm_struct)
│ ├── active_mm: 内核线程的内存描述符,当 mm == NULL 时使用
│ ├── min_flt: 次缺页错误次数
│ ├── maj_flt: 主缺页错误次数
│ ├── reclaim_state: 内存回收状态,用于进程内存管理
│ └── numa_group: NUMA 相关信息,用于处理多处理器系统上的内存分配
├── File System and I/O Management
│ ├── fs: 文件系统相关信息,指向进程的 fs_struct
│ ├── files: 打开文件的表 (files_struct)
│ ├── io_context: I/O 上下文,用于跟踪进程的 I/O 操作
│ ├── splice_pipe: 缓存最近的管道 (pipe_inode_info)
│ ├── bio_list: 块设备 I/O 相关信息
│ └── plug: 用于块设备 I/O 的插入操作状态
├── Signal Handling
│ ├── signal: 信号处理结构 (signal_struct),进程的信号控制块
│ ├── sighand: 信号处理程序表 (sighand_struct),正在通过信号处理函数进行处理的信号
│ ├── pending: 挂起的信号队列
│ ├── blocked: 阻塞的信号集
│ └── pdeath_signal: 父进程终止时要发送的信号
├── Security and Credentials
│ ├── cred: 进程的有效凭证 (cred),包括 UID、GID 等
│ ├── real_cred: 进程的实际凭证
│ ├── seccomp: Seccomp 安全配置,限制系统调用的安全机制
│ ├── audit_context: 审计相关的上下文信息
│ └── security: 用于 LSM 模块的安全字段
├── Inter-Process Relationships
│ ├── real_parent: 实际的父进程
│ ├── parent: 接收 SIGCHLD 的父进程
│ ├── children: 子进程链表
│ ├── sibling: 兄弟进程链表
│ ├── group_leader: 线程组的组长指针
│ └── ptraced: 被 ptrace() 跟踪的进程链表
├── Namespace Management
│ ├── nsproxy: 命名空间代理结构,存储进程的命名空间信息
│ ├── cgroups: 进程所属的控制组 (cgroup)
│ ├── cg_list: cgroup 任务链表
│ └── namespaces: 进程的各种命名空间 (pid, net, uts, ipc 等)
├── Time and Statistics
│ ├── start_time: 进程的启动时间(单调时间)
│ ├── utime: 用户态运行时间
│ ├── stime: 内核态运行时间
│ ├── nvcsw: 进程的自愿上下文切换次数
│ ├── nivcsw: 进程的非自愿上下文切换次数
│ └── sched_statistics: 调度器的统计信息
├── Thread and CPU State
│ ├── thread: 每个 CPU 特定的线程状态结构 (thread_struct)
│ ├── recent_used_cpu: 最近使用的 CPU
│ ├── on_cpu: 进程是否当前正在运行的标志
│ └── wake_cpu: 唤醒进程时使用的目标 CPU
├── Debugging and Tracing
│ ├── ptrace: 记录 ptrace 系统调用的调试状态
│ ├── last_siginfo: 上次发送给该进程的信号信息
│ ├── task_state_change: 记录进程状态变更的时间戳
│ └── trace_recursion: 跟踪递归的掩码和计数器
├── Resource Limits and Accounting
│ ├── rlimit: 资源限制 (RLIMIT_CPU, RLIMIT_NOFILE 等)
│ ├── ioac: I/O 统计信息,用于 I/O 资源消耗的统计
│ └── acct_rss_mem1: 进程的 RSS 内存使用量
├── Error Handling and Recovery
│ ├── task_works: 用于延迟处理的任务列表(如信号处理)
│ ├── restart_block: 系统调用重启时使用的上下文
│ └── oom_reaper_list: 用于记录 OOM 情况下需要处理的进程
在 Linux 内核中,不同的进程状态有着特定的含义和特点:
TASK_RUNNING:表示进程要么正在执行,要么正要准备执行(已经就绪),正在等待 cpu 时间片的调度。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。
TASK_INTERRUPTIBLE:表示进程因为等待一些条件而被挂起(阻塞)而所处的状态。这些条件主要包括:硬中断、资源、一些信号……,一旦等待的条件成立,进程就会从该状态(阻塞)迅速转化成为就绪状态 TASK_RUNNING(执行或准备执行态)。这是一种浅睡眠的状态,虽然在睡眠,等待 I/O 完成,但是这个时候一个信号来的时候,进程还是要被唤醒。唤醒后,是进行信号处理,不是继续刚才的操作。程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个 I/O 操作完成,直接退出;或者收到某些信息,继续等待。
TASK_UNINTERRUPTIBLE:意义与上一个类似对于处于此状态的进程,即使传递一个信号或者有一个外部中断都不能唤醒他们。只有它所等待的资源可用的时候,它才会被唤醒。这是一种深度睡眠状态,不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。
sys_fork
├── _do_fork
├── wake_up_new_task
├── p->state = TASK_RUNNING
├── 进程分配到 CPU,进入运行状态
│ ├── 时间片用完或被抢占,重新调度
│ └── 执行任务
├── 进程等待某事件或资源:
│ ├── TASK_INTERRUPTIBLE (可被信号唤醒的睡眠)
│ ├── TASK_UNINTERRUPTIBLE (不可被信号唤醒的睡眠)
│ └── TASK_KILLABLE (只能被 SIGKILL 终止的睡眠)
├── 进程被 SIGSTOP 信号停止:
│ └── TASK_STOPPED (进程暂停)
└── 进程退出,进入僵尸状态:
└── EXIT_ZOMBIE (进程结束但父进程未回收)
任务ID是任务的唯一标识,在tast_struct中,主要涉及以下几个ID:
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
之所以有pid(process id),tgid(thread group ID)以及group_leader,是因为线程和进程在内核中是统一管理,视为相同的任务(task)。
任何一个进程,如果只有主线程,那 pid 和tgid 相同,group_leader 指向自己。但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的 pid,tgid 就是进程的主线程的 pid,group_leader 指向的进程的主线程。因此根据pid和tgid是否相等我们可以判断该任务是进程还是线程。
除了0号进程以外,其他进程都是有父进程的。全部进程其实就是一颗进程树,相关成员变量如下所示:
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
real_parent:是进程的 “亲生父亲”。如果创建进程的父进程不再存在,则指向 PID 为 1 的 init 进程。
parent:是进程的父进程。进程终止时,必须向它的父进程发送信号。它的值通常与 real_parent 相同。
children:表示链表的头部,链表中的所有元素都是它的子进程。
sibling:用于把当前进程插入到兄弟链表中。
通常情况下,real_parent 和 parent 是一样的,但是也会有另外的情况存在。例如,bash 创建一个进程,那进程的 parent 和 real_parent 就都是 bash。如果在 bash 上使用 GDB 来 debug 一个进程,这个时候 GDB 是 parent,bash 是这个进程的 real_parent。
任务状态部分主要涉及以下变量:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
int exit_state;
unsigned int flags;
state:通过设置比特位的方式来反映任务状态。如 TASK_RUNNING、TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE 等。
exit_state:当一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait () 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。EXIT_DEAD 是进程的最终状态。EXIT_ZOMBIE 和 EXIT_DEAD 也可以用于 exit_state。
其中状态state
通过设置比特位的方式来赋值,具体值在include/linux/sched.h
中定义
/* Used in tsk->state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk->exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
#define TASK_PARKED 512
#define TASK_NOLOAD 1024
#define TASK_NEW 2048
#define TASK_STATE_MAX 4096
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
TASK_RUNNING 并不是说进程正在运行,而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。在运行中的进程,一旦要进行一些 I/O 操作,需要等待 I/O 完毕,这个时候会释放 CPU,进入睡眠状态。
在 Linux 中,有两种睡眠状态:
一种是 TASK_INTERRUPTIBLE,可中断的睡眠状态。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等待 I/O 完成,但是这个时候一个信号来的时候,进程还是要被唤醒。只不过唤醒后,不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个 I/O 操作完成,直接退出;或者收到某些信息,继续等待。
另一种睡眠是 TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。你可能会说,我 kill 它呢?别忘了,kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成 TASK_UNINTERRUPTIBLE。
于是,我们就有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,它的运行原理类似 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。由于TASK_WAKEKILL 用于在接收到致命信号时唤醒进程,因此TASK_KILLABLE即在TASK_UNINTERUPTIBLE的基础上增加一个TASK_WAKEKILL标记位即可。
TASK_STOPPED 是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。
TASK_TRACED 表示进程被 debugger 等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。
一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。EXIT_DEAD 是进程的最终状态。EXIT_ZOMBIE 和 EXIT_DEAD 也可以用于 exit_state。
上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,我们称为标志。放在 flags 字段中,这些字段都被定义成为宏,以 PF 开头。
#define PF_EXITING 0x00000004
#define PF_VCPU 0x00000010
#define PF_FORKNOEXEC 0x00000040
PF_EXITING 表示正在退出。当有这个 flag 的时候,在函数 find_alive_thread() 中,找活着的线程,遇到有这个 flag 的,就直接跳过。
PF_VCPU 表示进程运行在虚拟 CPU 上。在函数 account_system_time 中,统计进程的系统运行时间,如果有这个 flag,就调用 account_guest_time,按照客户机的时间进行统计。
PF_FORKNOEXEC 表示 fork 完了,还没有 exec。在 _do_fork ()函数里面调用 copy_process(),这个时候把 flag 设置为 PF_FORKNOEXEC()。当 exec 中调用了 load_elf_binary() 的时候,又把这个 flag 去掉。
任务权限主要包括以下两个变量,real_cred是指可以操作本任务的对象,而red是指本任务可以操作的对象。
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
cred定义如下所示:
struct cred {
......
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
......
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
......
} __randomize_layout;
从这里的定义可以看出,大部分是关于用户和用户所属的用户组信息。
uid 和 gid,注释是 real user/group id。一般情况下,谁启动的进程,就是谁的 ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
euid 和 egid,注释是 effective user/group id。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
fsuid 和 fsgid,也就是 filesystem user/group id。这个是对文件操作会审核的权限。
在Linux中,我们可以通过chmod u+s program命令更改更改euid和fsuid来获取权限。
除了以用户和用户组控制权限,Linux 还有另一个机制就是 capabilities。
原来控制进程的权限,要么是高权限的 root 用户,要么是一般权限的普通用户,这时候的问题是,root 用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个 root 的权限。这个太不安全了。于是,我们引入新的机制 capabilities,用位图表示权限,在capability.h可以找到定义的权限。我这里列举几个。
#define CAP_CHOWN 0
#define CAP_KILL 5
#define CAP_NET_BIND_SERVICE 10
#define CAP_NET_RAW 13
#define CAP_SYS_MODULE 16
#define CAP_SYS_RAWIO 17
#define CAP_SYS_BOOT 22
#define CAP_SYS_TIME 25
#define CAP_AUDIT_READ 37
#define CAP_LAST_CAP CAP_AUDIT_READ
对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;没有的时候,就不能做,这样粒度要小很多。
运行统计从宏观来说也是一种状态变量,但是和任务状态不同,其存储的主要是运行时间相关的成员变量,具体如下所示:
u64 utime;//用户态消耗的CPU时间
u64 stime;//内核态消耗的CPU时间
unsigned long nvcsw;//自愿(voluntary)上下文切换计数
unsigned long nivcsw;//非自愿(involuntary)上下文切换计数
u64 start_time;//进程启动时间,不包含睡眠时间
u64 real_start_time;//进程启动时间,包含睡眠时间
进程调度部分较为复杂,会单独拆分讲解,这里先简单罗列成员变量。
//是否在运行队列上
int on_rq;
//优先级
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
//调度器类
const struct sched_class *sched_class;
//调度实体
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
//调度策略
unsigned int policy;
//可以使用哪些CPU
int nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;
policy 等调度策略及相关信息在进程调度中非常重要。不同的调度策略决定了进程在何时以及如何被分配 CPU 时间片。例如,实时进程和普通进程可能采用不同的调度策略,以满足不同的响应时间要求。
task_struct 里面关于信号处理的字段定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。信号处理函数默认使用用户态的函数栈,也可以开辟新的栈专门用于信号处理。
信号处理相关的数据结构如下所示:
/* Signal handlers: */
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
unsigned int sas_ss_flags;
这里将信号分为三类:
阻塞暂不处理的信号(blocked)
等待处理的信号(pending)
正在通过信号处理函数处理的信号(sighand)
信号处理函数默认使用用户态的函数栈,当然也可以开辟新的栈专门用于信号处理,这就是 sas_ss_xxx
这三个变量的作用。
内存管理部分成员变量如下所示:
struct mm_struct *mm;
struct mm_struct *active_mm;
mm_struct 等成员变量对内存进行描述和管理。mm 成员变量表示进程所拥有的用户空间内存描述符,内核线程无的 mm 为 NULL。active_mm 指向进程运行时所使用的内存描述符,对于普通进程而言,这两个指针变量的值相同。但是内核线程 kernel thread 是没有进程地址空间的,所以内核线程的 tsk->mm 域是空(NULL)。但是内核必须知道用户空间包含了什么,因此它的 active_mm 成员被初始化为前一个运行进程的 active_mm 值。
文件系统部分也会在后面详细说明,这里先简单列举成员变量:
/* Filesystem information: */
struct fs_struct *fs;
/* Open file information: */
struct files_struct *files;
任务与文件系统有着紧密的关联。进程在执行过程中会打开、读取、写入文件,这些操作都需要通过文件系统来实现。任务的 I/O 状态信息包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
内核栈相关的成员变量如下所示。为了介绍清楚其作用,我们需要从为什么需要内核栈开始逐步讨论。
struct thread_info thread_info;
void *stack;
当进程产生系统调用时,会利用中断陷入内核态。而内核态中也存在着各种函数的调用,因此我们需要有内核态函数栈。Linux 给每个 task 都分配了内核栈。在 32 位系统上 arch/x86/include/asm/page_32_types.h,是这样定义的:一个 PAGE_SIZE 是 4K,左移一位就是乘以 2,也就是 8K。
#define THREAD_SIZE_ORDER 1
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
内核栈在 64 位系统上arch/x86/include/asm/page_64_types.h
,是这样定义的:在 PAGE_SIZE 的基础上左移两位,也即 16K,并且要求起始地址必须是 8192 的整数倍。
#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
内核栈的结构如下所示,首先是预留的8个字节,然后是存储寄存器,最后存储thread_info
结构体。
任务内核栈的特点和作用显著。与用户态栈不同,内核栈用于内核模式下的函数调用和数据存储。内核通过 thread_union 联合体来表示进程的内核栈,其中 THREAD_SIZE 宏的大小为 8192。通过 alloc_thread_info 函数分配它的内核栈,通过 free_thread_info 函数释放所分配的内核栈。
这个结构是对 task_struct 结构的补充。因为 task_struct 结构庞大但是通用,不同的体系结构就需要保存不同的东西,所以往往与体系结构有关的,都放在 thread_info 里面。在内核代码里面采用一个 union将thread_info和 stack 放在一起,在 include/linux/sched.h 中定义用以表示内核栈。由代码可见,这里根据架构不同可能采用旧版的task_struct直接放在内核栈,而新版的均采用thread_info,以节约空间。
union thread_union {
#ifndef CONFIG_ARCH_TASK_STRUCT_ON_STACK
struct task_struct task;
#endif
#ifndef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
另一个结构pt_regs
,定义如下。其中,32 位和 64 位的定义不一样。
#ifdef __i386__
struct pt_regs {
unsigned long bx;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
unsigned long bp;
unsigned long ax;
unsigned long ds;
unsigned long es;
unsigned long fs;
unsigned long gs;
unsigned long orig_ax;
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
};
#else
struct pt_regs {
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long bp;
unsigned long bx;
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long ax;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
unsigned long orig_ax;
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
/* top of stack page */
};
#endif
内核栈和task_struct是可以互相查找的,而这里就需要用到task_struct中的两个内核栈相关成员变量了。
⑴通过task_struct查找内核栈
如果有一个 task_struct
的 stack
指针在手,即可通过下面的函数找到这个线程内核栈:
static inline void *task_stack_page(const struct task_struct *task)
{
return task->stack;
}
从 task_struct 如何得到相应的 pt_regs 呢?我们可以通过下面的函数,先从 task_struct 找到内核栈的开始位置。然后这个位置加上 THREAD_SIZE 就到了最后的位置,然后转换为 struct pt_regs,再减一,就相当于减少了一个 pt_regs 的位置,就到了这个结构的首地址。
/*
* TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
* This is necessary to guarantee that the entire "struct pt_regs"
* is accessible even if the CPU haven't stored the SS/ESP registers
* on the stack (interrupt gate does not save these registers
* when switching to the same priv ring).
* Therefore beware: accessing the ss/esp fields of the
* "struct pt_regs" is possible, but they may contain the
* completely wrong values.
*/
#define task_pt_regs(task) \
({ \
unsigned long __ptr = (unsigned long)task_stack_page(task); \
__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \
((struct pt_regs *)__ptr) - 1; \
})
这里面有一个TOP_OF_KERNEL_STACK_PADDING,这个的定义如下:
#ifdef CONFIG_X86_32
# ifdef CONFIG_VM86
# define TOP_OF_KERNEL_STACK_PADDING 16
# else
# define TOP_OF_KERNEL_STACK_PADDING 8
# endif
#else
# define TOP_OF_KERNEL_STACK_PADDING 0
#endif
也就是说,32 位机器上是 8,其他是 0。这是为什么呢?因为压栈 pt_regs 有两种情况。我们知道,CPU 用 ring 来区分权限,从而 Linux 可以区分内核态和用户态。因此,第一种情况,我们拿涉及从用户态到内核态的变化的系统调用来说。因为涉及权限的改变,会压栈保存 SS、ESP 寄存器的,这两个寄存器共占用 8 个 byte。另一种情况是,不涉及权限的变化,就不会压栈这 8 个 byte。这样就会使得两种情况不兼容。如果没有压栈还访问,就会报错,所以还不如预留在这里,保证安全。在 64 位上,修改了这个问题,变成了定长的。
⑵通过内核栈找task_struct
首先来看看thread_info的定义吧。下面所示为早期版本的thread_info和新版本thread_info的源码
struct thread_info {
struct task_struct *task; /* main task structure */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
mm_segment_t addr_limit;
unsigned int sig_on_uaccess_error:1;
unsigned int uaccess_err:1; /* uaccess failed */
};
struct thread_info {
unsigned long flags; /* low level flags */
unsigned long status; /* thread synchronous flags */
};
老版中采取current_thread_info()->task
来获取task_struct
。thread_info
的位置就是内核栈的最高位置,减去 THREAD_SIZE,就到了thread_info
的起始地址。
static inline struct thread_info *current_thread_info(void)
{
return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
}
而新版本则采用了另一种current_thread_info
#include
#define current_thread_info() ((struct thread_info *)current)
#endif
那 current
又是什么呢?在 arch/x86/include/asm/current.h
中定义了。
struct task_struct;
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
return this_cpu_read_stable(current_task);
}
#define current get_current
新的机制里面,每个 CPU 运行的 task_struct 不通过 thread_info 获取了,而是直接放在 Per CPU 变量里面了。多核情况下,CPU 是同时运行的,但是它们共同使用其他的硬件资源的时候,我们需要解决多个 CPU 之间的同步问题。Per CPU 变量是内核中一种重要的同步机制。顾名思义,Per CPU 变量就是为每个 CPU 构造一个变量的副本,这样多个 CPU 各自操作自己的副本,互不干涉。比如,当前进程的变量 current_task 就被声明为 Per CPU 变量。要使用 Per CPU 变量,首先要声明这个变量,在 arch/x86/include/asm/current.h 中有:
DECLARE_PER_CPU(structtask_struct*, current_task);
然后是定义这个变量,在arch/x86/kernel/cpu/common.c
中有:
DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;
也就是说,系统刚刚初始化的时候,current_task都指向init_task。当某个 CPU 上的进程进行切换的时候,current_task被修改为将要切换到的目标进程。例如,进程切换函数__switch_to就会改变current_task。
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
......
this_cpu_write(current_task, next_p);
......
return prev_p;
}
当要获取当前的运行中的task_struct
的时候,就需要调用this_cpu_read_stable
进行读取。
#definethis_cpu_read_stable(var)percpu_stable_op("mov", var)
通过这种方式,即可轻松的获得task_struct
的地址。
3.1Hung Task 机制
Hung task 机制是内核为了检测长期处于 D 状态(TASK_UNINTERRUPTIBLE)的进程而设计的。通常情况下,处于 D 状态的进程是为了等待 IO 完成,正常情况下 IO 应该会瞬息完成,然后唤醒响应 D 状态的进程。但如果一个进程长期处于 D 状态,就可能是出现了问题,比如 IO 设备损坏,或者是内核中存在 bug 或机制不合理。
Hung task 机制通过内核线程 khungtaskd 来实现。khungtaskd 会定期 120s 唤醒一次,然后遍历内核所有进程,检查进程是否处于 TASK_UNINTERRUPTIBLE 状态,并且满足 nvcsw+nivcsw==last_switch_count 这两个条件。如果满足,就会打印进程信息和堆栈。
例如,在进行 hung task 分析之前,需要了解 struct task_strcut 中的 state、nvcsw、nivcsw、last_switch_count 几个成员含义。其中,state 表示当前进程状态,TASK_UNINTERRUPTIBLE 表示进程不会被打断;nvcsw 表示进程主动切换次数,nivcsw 表示进程被动切换次数,两者之和就是进程总的切换次数;last_switch_count 这个变量只有两个地方修改,一是在新建进程的时候设置初始值 last_switch_count=nvcsw+nivcsw,另一个是在 khungtaskd 中进行更新。
⑴暂停状态处理
当进程处于暂停状态(TASK_STOPPED or TASK_TRACED)时,向进程发送一个 SIGSTOP 信号,它就会因响应该信号而进入 TASK_STOPPED 状态(除非该进程本身处于 TASK_UNINTERRUPTIBLE 状态而不响应信号)。向进程发送一个 SIGCONT 信号,可以让其从 TASK_STOPPED 状态恢复到 TASK_RUNNING 状态。当进程正在被跟踪时,它处于 TASK_TRACED 这个特殊的状态。处于 TASK_TRACED 状态的进程不能响应 SIGCONT 信号而被唤醒,只能等到调试进程通过 ptrace 系统调用执行特定操作,或调试进程退出,被调试的进程才能恢复 TASK_RUNNING 状态。
⑵僵死状态处理
当进程处于退出状态(TASK_DEAD - EXIT_ZOMBIE),进程成为僵尸进程。在这个退出过程中,进程占有的所有资源将被回收,除了 task_struct 结构(以及少数资源)以外。此时,进程就只剩下 task_struct 这么个空壳,故称为僵尸。父进程有义务去处理子进程,避免其变成僵死进程。可以通过调用 wait () 获取子进程退出码,或者等父进程结束,Init 进程收养孤儿进程,调用 wait () 获取子进程退出码。Init 进程会自动调用 wait () 获取子进程退出码,确保子进程不会成为僵死进程。
4.1执行环境差异
在 Linux 系统中,内核态和用户态有着显著的执行环境差异。
在指令集使用方面,内核态可以使用任何 CPU 指令集,CPU 处于内核态时没有任何限制,能够执行所有的指令。而用户态的程序在执行时,其指令集受到严格限制,不能像内核态那样随意使用所有的指令。
在资源访问权限上,内核态可以访问内存所有数据,包括内存和硬盘等所有硬件资源。相比之下,用户态只有受限的访问内存,且不允许访问外围设备如硬盘、网卡等。
用户态切换到内核态主要有以下几种方式:
系统调用:用户态进程通过系统调用申请使用操作系统提供的服务完成工作,这是用户态进程主动要求切换到内核态的一种方式。例如 fork () 实际上就是执行了一个创建新进程的系统调用,而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如 Linux 的 int 80h 中断。
异常:当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
外围设备中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
用户态和内核态在堆和栈概念上存在明显差异。
在用户态中,堆和栈对应于用户进程虚拟地址空间中的某个区域。栈向下增长,堆通过 malloc 分配,向上增长。用户空间堆栈在 task_struct->mm->vm_area 中进行描述,它们都属于进程虚拟地址空间的一个区域。
而在内核态,内核态的栈描述在 task_struct->stack 中,栈的底部是 thread_info 对象。整个栈区域通常只有一页内存(可配置),在 32 位系统中为 4KB。并且内核态没有进程堆的概念,内存分配使用 kmalloc () 函数,实际上是由 Linux 内核统一管理的。一般情况下,使用 slab 分配器,它是一个内存缓存池,管理所有可通过 kmalloc () 分配的内存。从原理上看,在 Linux 内核态,kmalloc 分配的内存可以被所有运行在 Linux 内核态的任务访问到。
Linux 系统中的 Task 内核态在整个操作系统中扮演着至关重要的角色。它是系统稳定运行和高效管理资源的核心。Task 的内核态结构复杂而精细,涵盖了进程状态、任务 ID、亲缘关系、任务状态、运行统计、进程调度信息、信号处理、内存管理、文件与文件系统以及内核栈等多个方面。这些结构和信息使得内核能够有效地管理进程的执行、资源分配、进程间的通信和协作。
Hung task 机制以及特殊状态处理机制确保了系统能够及时检测和处理出现问题的进程,避免系统因长时间等待 IO 或其他异常情况而陷入停滞。同时,对暂停状态和僵死状态的处理,保证了系统的稳定性和资源的合理回收。
Task 内核态与用户态的差异和联系也十分重要。执行环境的差异使得内核能够在更高的权限下进行系统管理任务,而用户态程序则在受限的环境中运行,保证了系统的安全性。切换方式的多样性使得用户态程序能够在需要时请求内核的服务,实现了系统的功能扩展。内存概念的区别则体现了内核态和用户态在资源管理方式上的不同。