Linux内核的task_struct结构体是内核中表示进程或线程的数据结构,它在进程管理、调度和同步等方面起着至关重要的作用。每个进程或线程在内核中都有一个对应的task_struct实例,通过这个结构体,内核可以保存关于进程或线程的各种信息,并对其进行管理。

对task_struct的简单说明

  • state: 进程状态,如运行、等待或睡眠。
  • pid: 进程ID,用于唯一标识一个进程。
  • parent: 父进程的指针。
  • children: 子进程链表的头指针。
  • sibling: 兄弟进程链表的指针。
  • mm: 内存描述符,包含了进程所拥有的地址空间信息。
  • files: 文件描述符表,保存了进程打开文件的相关信息。
  • sched_entity: 调度实体,记录了与调度相关的信息,如优先级、时间片等。
  • cred: 进程凭证,包含了与权限相关的信息,如用户ID、组ID等。

概述

任务ID

任务ID是任务的唯一标识,在tast_struct中,主要涉及以下几个ID:

// include\linux\sched.h
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
  • pid(process ID)是任务的唯一标识符,每一个任务的 pid 都是不一样的。也就是说,如果一个进程有多个线程,那么这些多个线程所使用的 pid 也都是不一样的。

  • tgid(thread group ID),是线程组的 ID,一个进程中的所有线程的 tgid 都是一样的。如果一个进程,只有主线程,那么 pid 是自己,tgid 也是自己。如果一个进程,创建了其他线程,那么其他线程都有自己的 pid,但是其他线程的 tgid 是进程主线程的 pid。

  • group_leader 则指向进程主线程的 task_struct。同上,如果一个进程,只有主线程,那么 pid 是自己,group_leader 指向的也是自己。如果一个进程,创建了其他线程,那么这些线程的 group_leader 指向的都是进程主线程的 task_struct。(信息共享机制的原理)

有了 tgid 之后,我们就知道 task_struct 代表的是一个进程还是一个线程了。

补充(孤儿进程跟僵死进程):

​ 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

​ 僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

子进程销毁过程:

  1. 子进程
  2. 执行结束(等待销毁)
  3. 僵尸进程(留下基础状态信息)
  4. 等待父进程调用wait()删除
  5. 若长时间不调用wait() , 则变成孤儿线程
  6. init进程将孤儿进程变成它的子进程
  7. 由init进程调用wait()来删除孤儿进程

 unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

  孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

  **任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。**这是每个 子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

进程亲缘关系

除了0号进程以外,其他进程都是有父进程的。全部进程其实就是一颗进程树

相关成员变量如下所示:

// include\linux\sched.h

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 */
  • parent 指向其父进程。当它终止时,必须向它的父进程发送信号。
  • children 指向子进程链表的头部。链表中的所有元素都是它的子进程。
  • sibling 用于把当前进程插入到兄弟链表中。

通常情况下,real_parent 和 parent 是一样的,但是也会有另外的情况存在。

例如,bash 创建一个进程,那进程的 parent 和 real_parent 就都是 bash。如果在 bash 上使用 GDB 来 debug 一个进程,这个时候 GDB 是 parent,bash 是这个进程的 real_parent。(即嵌套线程)

补充:0号进程 , 1号进程 , 2号进程:

  • 0号进程(idle)

    idle进程,或者也称为swapper进程。:

    • 该进程是Linux中的第一个进程(线程),PID为0;
    • idle进程是init进程和kthreadd进程(内核线程)的父进程;

kthreadd进程是在内核初始化start_kernel()的最后rest_init()函数中,由0号进程(swapper进程)创建了两个进程:

init进程(PID = 1, PPID = 0)

kthreadd进程(PID = 2, PPID = 0)

  • 1号进程(init)

前文提到了负责摧毁孤儿进程的主进程 --> 所有进程追溯其祖先最终都会落到进程号为1的进程身上,这个进程叫init进程

init进程是linux内核启动后第一个执行的进程。

init引导系统,启动守护进程并且运行必要的程序。

  • 2号进程(kthreadd)

kthreadd就是Linux的2号进程

他是其他内核线程的父进程或者祖先进程

其他内核进程都是通过2号进程创建的,2号进程轮询创建进程的任务队列

rest_init(void)的源码:

 

noinline void __ref rest_init(void)
{
	struct task_struct *tsk;
	int pid;

	rcu_scheduler_starting();
	/*
	 * We need to spawn init first so that it obtains pid 1, however
	 * the init task will end up wanting to create kthreads, which, if
	 * we schedule it before we create kthreadd, will OOPS.
	 */
	pid = kernel_thread(kernel_init, NULL, CLONE_FS);                 //创建init进程,入参kernel_init是进程的执行体,类似java线程的run函数
	/*
	 * Pin init on the boot CPU. Task migration is not properly working
	 * until sched_init_smp() has been run. It will set the allowed
	 * CPUs for init to the non isolated CPUs.
	 */
	rcu_read_lock();
	tsk = find_task_by_pid_ns(pid, &init_pid_ns);
	set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()));
	rcu_read_unlock();

	numa_default_policy();
	pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);       //创建kthreadd进程,入参kthreadd是进程的执行体,类似java线程的run函数
	rcu_read_lock();
	kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
	rcu_read_unlock();

	/*
	 * Enable might_sleep() and smp_processor_id() checks.
	 * They cannot be enabled earlier because with CONFIG_PREEMPTION=y
	 * kernel_thread() would trigger might_sleep() splats. With
	 * CONFIG_PREEMPT_VOLUNTARY=y the init task might have scheduled
	 * already, but it's stuck on the kthreadd_done completion.
	 */
	system_state = SYSTEM_SCHEDULING;

	complete(&kthreadd_done);                   // 等待kthreadd创建完毕

	/*
	 * The boot idle thread must execute schedule()
	 * at least once to get things moving:
	 */
	schedule_preempt_disabled();
	/* Call into cpu_idle with preempt disabled */
	cpu_startup_entry(CPUHP_ONLINE);
}
 

任务状态

主要涉及:

volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
int exit_state;
unsigned int flags;

其中状态state通过设置比特位的方式来赋值

// from 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)
state:

TASK_RUNNING并不是说进程正在运行,而是表示进程在时刻准备运行的状态。

当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。(此时线程的状态不会被改变)

在运行中的进程,一旦要进行一些 I/O 操作,需要等待 I/O 完毕,这个时候会释放 CPU,进入睡眠状态。

根据具体的等待情况,进程可能会进入以下两种睡眠状态之一:

  1. TASK_INTERRUPTIBLE: 如果进程在等待 I/O 操作完成时希望能够被信号中断,那么它会进入 TASK_INTERRUPTIBLE 状态。在这个状态下,进程是可休眠的,并且如果接收到一个信号(如中断信号),它可以被唤醒并从睡眠状态中恢复。

  2. TASK_UNINTERRUPTIBLE: 如果进程希望在等待期间不被任何信号打扰(例如,确保某些关键操作的完整性),它会进入 TASK_UNINTERRUPTIBLE 状态。在这个状态下,进程是不可被信号中断的,必须等到 I/O 操作完成后才会被唤醒。

    kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。(所以尽量不要设置成这个状态)

当 I/O 操作完成后,进程会从上述睡眠状态中唤醒,并重新进入就绪状态以等待 CPU 时间片。此时,进程的状态将再次变为 TASK_RUNNING,等待调度器将其安排到 CPU 上运行。

  • 于是,我们就有了一种新的进程睡眠状态,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_ZOMBIEEXIT_DEAD 也可以用于 exit_state

exit_state

​ 可用于标记EXIT_ZOMBIEEXIT_DEAD

flags

上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,我们称为标志。放在 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 去掉。

补充fork 与 exec:

fork() 是一个在 Unix 和类 Unix 操作系统(如 Linux)中使用的系统调用,用于创建一个新的进程。调用 fork() 后,进程会被复制,产生一个新的进程,称为子进程。以下是 fork() 的工作机制和一些关键点:

fork() 工作机制

  1. 复制进程: 调用 fork() 会创建一个新的子进程,子进程是父进程的几乎精确的副本。子进程将继承父进程的内存空间、文件描述符、信号处理和环境变量等。

  2. 返回值:

    • 在父进程中,fork() 返回子进程的进程 ID (PID)。
    • 在子进程中,fork() 返回 0
    • 如果 fork() 失败,则返回 -1,并设置 errno 来指示错误原因。
  3. 执行路径: 在调用 fork() 之后,父进程和子进程将继续执行,从 fork() 调用返回的位置开始。这通常需要使用返回值来区分父进程和子进程,并执行不同的逻辑。

  4. 重要注意事项

    • 写时复制 (Copy-On-Write): Linux 使用写时复制(COW)来优化 fork() 的性能。子进程在 fork() 时不会立即复制父进程的整个地址空间,而是只有在子进程或父进程尝试修改共享内存时才进行实际的复制。
    • 文件描述符共享: 在 fork() 后,父进程和子进程共享相同的文件描述符表,这意味着对文件描述符的操作会影响另一个进程(如关闭或重新定位文件偏移)。
    • 进程 ID 和父进程 ID: 子进程拥有一个新的进程 ID,但其父进程 ID 为创建它的父进程的进程 ID。
    • exec() 系列函数: 通常,fork() 后会调用 exec() 系列函数(如 execve())来用一个新程序替换子进程的地址空间,这在创建多进程服务器或运行外部程序时非常有用。
    • 同步与等待: 父进程通常需要等待子进程完成,可以使用 wait()waitpid() 函数来获取子进程的终止状态。

    fork() 是创建新进程的基础机制,在编写多进程程序时非常重要。通过结合 exec() 系列函数,程序可以执行不同的任务和命令。

    exec :

    在 Linux 中,exec 系列函数用于将当前进程的地址空间替换为一个新的程序。这意味着,在执行 exec 后,调用进程的代码、数据段、堆栈等将被新程序的内容替换,进程继续执行新程序。

    (exec是一个 系列函数)

(另外也可以参考进程进行过程中的七个状态)

任务权限

// include\linux\sched.h

/* 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 : 表示我这个进程可以操作谁,实质上就是我操作别人时具有的权限是什么;

real_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。这个是对文件操作会审核的权限。

suid 与 spid

除了以用户和用户组控制权限,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

对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;

没有的时候,就不能做,这样粒度要小很多。

运行时统计信息

主要有以下字段:

// include\linux\sched.h

u64        utime;			// 用户态消耗的CPU时间
u64        stime;			// 内核态消耗的CPU时间

unsigned long      nvcsw;	 // 自愿(voluntary)上下文切换计数
unsigned long      nivcsw;	 // 非自愿(involuntary)上下文切换计数

u64        start_time;		 // 进程启动时间,不包含睡眠时间
u64        real_start_time;	 // 进程启动时间,包含睡眠时间

进程调度

进程调度的代码要见另一篇linux/进度调度的笔记

以下简单罗列一下成员变量

//是否在运行队列上
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;

信号处理

/* 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;

进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所以进程共享内核虚拟地址空间,但每个进程有独立的用户虚拟地址空间。这块的内容可具体查看 Linux 内存管理。

内核线程没有用户地址空间,那么 mm 将为空,active_mm 则指向此时用户态的地址空间。对于用户进程来说,mm 和 active_mm 是一样的。

在 Linux 内核中,struct mm_struct 是一个用于表示进程内存管理信息的数据结构。它包含有关进程虚拟内存空间的详细信息,如内存区域、页表等。指针 *mm*active_mm 都指向这个结构体的实例,但它们在不同的上下文中使用。

struct mm_struct

这个结构体包含管理进程地址空间所需的各种信息。主要组成部分包括:

  • 页表:用于管理虚拟地址和物理地址之间转换的信息。

  • 内存区域:有关不同内存区域的信息(如文本段、数据段、栈段)。

  • 文件映射:有关内存映射文件的信息。

  • 虚拟内存区域 (VMAs)vm_area_struct 结构体的链表或树结构,用于表示连续的内存区域。

mm

  • 定义mm 是指向拥有自己内存空间的进程的 mm_struct 的指针。它指向的是一个进程特有的内存管理结构。

  • 上下文:用于具有独立内存管理的进程,即用户空间进程。当一个用户空间进程在 CPU 上运行时,它的 mm 指向描述其虚拟地址空间的实际内存管理结构。

  • 例子:对于典型的用户空间进程,current->mm 会指向它的内存管理结构。这对内核处理页面故障、分配内存等操作非常重要。

active_mm

  • 定义active_mm 是一个用于没有自己内存空间的内核线程的指针。它指向的是最后在 CPU 上运行的用户空间进程的 mm_struct

  • 上下文:内核线程(运行在内核空间并不需要用户空间内存上下文)没有自己的 mm_struct,因为它们共享内核的地址空间。然而,当一个内核线程运行时,active_mm 用于跟踪在该内核线程接管 CPU 之前活动的内存上下文。

  • 例子:如果一个内核线程(如使用 kthread_run() 创建的线程)正在执行,它的 current->mm 会设置为 NULL,但 current->active_mm 会指向最后被切换出以运行内核线程的用户空间进程的 mm_struct

进程切换

当调度器从一个进程切换到另一个进程时,会发生以下情况:

  1. 从用户进程切换到内核线程

    • 内核线程的 mmNULL
    • active_mm 设置为被切换出的用户进程的 mm
  2. 从内核线程切换到用户进程

    • 用户进程的 mm 成为 active_mm
    • active_mm 更新为新用户进程的 mm

这个机制允许高效的上下文切换,确保当进程在 CPU 上被调度进出时,使用正确的内存管理信息。

总结

  • mm:指向当前用户空间进程的内存管理信息。

  • active_mm:在内核线程运行时,指向最后一个用户空间进程的 mm_struct

这种区分有助于 Linux 内核有效地管理进程内存上下文,特别是在处理内核线程的轻量级执行模型时。

文件与文件系统

每个进程都有一个文件系统的数据结构,还有一个打开文件的数据结构。

// include\linux\sched.h

/* Filesystem information: */
struct fs_struct                *fs;
/* Open file information: */
struct files_struct             *files;

见另外的/linux/文件管理

内核态栈

在程序的执行过程中,一旦调用了系统调用,那么就需要进入内核继续执行。跟在用户态下函数执行的过程类似,进程陷入到内核态执行时也有一个栈,我们称其为内核栈。

**Linux 给每个 task 都分配了内核栈。**在 x86 32 系统上,内核栈的大小是 8K。在 x86 64 系统上,内核栈的大小一般是 16K,并且要求起始地址必须是 8192 的整数倍。

// include\linux\sched.h

struct thread_info    thread_info;
void  *stack;

// arch\x86\include\asm\page_32_types.h
#define THREAD_SIZE_ORDER	1
#define THREAD_SIZE		(PAGE_SIZE << THREAD_SIZE_ORDER)

// arch\x86\include\asm\page_64_types.h
#ifdef CONFIG_KASAN
#ifdef CONFIG_KASAN_EXTRA
#define KASAN_STACK_ORDER 2
#else
#define KASAN_STACK_ORDER 1
#endif
#else
#define KASAN_STACK_ORDER 0
#endif

#define THREAD_SIZE_ORDER	(2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

内核栈是一个非常特殊的数据结构,它还包含了 thread_info 和 pt_regs 等数据结构,也就是说 THREAD_SIZE 的长度是指包含了 thread_info 和 pt_regs 长度之后的,如下图所示:

thread_info:提供线程的基本信息和状态,紧密结合内核栈,适用于快速访问。(由不同的架构体系自己定义)

task_struct:提供更全面的进程和线程信息,包括进程内存管理、调度信息、文件描述符表等。

pt_regs : Linux 内核中用于保存 CPU 寄存器状态的数据结构,通常在上下文切换、异常处理、中断处理和系统调用时使用。该结构体是平台和架构特定的,因此其具体实现可能会因处理器架构而异。

这段空间的最低位置,是一个 thread_info 数据结构,这个数据结构是对 task_struct 的补充。需要这个数据结构主要是因为 task_struct 通用,而 linux 需要考虑到不同体系结构,而不同体系结构会有一套自己需要保存的东西。所以往往与体系结构相关的内容都会被保存到 thread_info,也就说 thread_info 这个数据结构由不同的体系结构自己定义,可以查看 arch 目录下各体系结构对 thread_info 这个结构体的定义。


在系统调用的时候,从用户态切换到内核态的时候,首先要做的第一件事情就是将用户态运行过程中的 CPU 上下文保存起来,其实主要就是保存在pt_regs的寄存器变量里。这样当从内核系统调用返回的时候,就可以从进程在刚才的地方继续运行下去。而在系统调用的过程中,压栈的顺序和 struct pt_regs 中寄存器定义的顺序是一样的。

pt_regs


#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 的 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 位上,修改了这个问题,变成了定长的。

通常,CPU 使用四个环级别来表示不同的权限等级:

  • Ring 0: 内核模式(Kernel Mode)
  • Ring 1: 低权限模式(通常不使用)
  • Ring 2: 低权限模式(通常不使用)
  • Ring 3: 用户模式(User Mode)

这些级别的主要作用是限制不同级别代码对系统资源的访问,从而提供不同程度的保护。

CPU 使用 ring 级别来实现不同的权限等级,从而提供系统保护和资源隔离。Ring 0 是最高权限级别,通常用于内核模式,而 Ring 3 是最低权限级别,通常用于用户模式。Ring 1 和 Ring 2 主要是历史遗留,现代操作系统大多数只使用 Ring 0 和 Ring 3。通过这些权限级别,操作系统可以有效地管理对硬件和内存的访问,保证系统的安全性和稳定性。

通过内核栈找task_struct

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 <asm/current.h>
#define current_thread_info() ((struct thread_info *)current)
#endif

current_thread_info中current的定义:


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(struct task_struct *, current_task);

在 Linux 内核中,“per CPU” 是指每个处理器(CPU)都有自己独立的、专用的资源或数据结构。这种设计可以提高并发性能,减少不同 CPU 之间的竞争,并优化性能。以下是一些关于 per CPU 的关键点:

使用场景

  • 中断处理:每个 CPU 处理自己的中断,减少中断处理的锁竞争。
  • 调度:调度器可以维护每个 CPU 的运行队列,以提高调度效率。
  • 内存分配:在多处理器系统中,每个 CPU 可以有自己的内存分配信息和缓存,以优化内存分配性能。

优势

  • 减少竞争:每个 CPU 处理自己独立的数据,减少了对共享数据的竞争。
  • 提高效率:减少了由于锁和缓存一致性协议引起的开销,提高了系统效率。
  • 增强可伸缩性:系统能更好地扩展到多个 CPU,避免了全局数据结构的性能瓶颈。

定义这个变量,在 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。

当要获取当前的运行中的 task_struct 的时候,就需要调用 this_cpu_read_stable 进行读取。


#define this_cpu_read_stable(var)       
percpu_stable_op("mov", var)

通过这种方式,即可轻松的获得task_struct的地址。

文章作者: cosh
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 cosh'blog
Linux
喜欢就支持一下吧