逸珺 · 2022年04月12日

抽象思想解读Linux进程描述符

[导读] 内核是怎么工作的,首先要理解进程管理,进程调度,本文开始阅读进程管理部分,首先从进程的抽象描述开始。抽象是软件工程的灵魂,而对于Linux操作系统而言,更是将抽象思想体现的淋漓尽致。本文从抽象建模的角度来对Linux进程描述符进行个人解读,同时也参考了内核文档,一些网络信息。

注:代码基于linux-5.4.31,是一个最新的长期支持稳定版本。

整理匆忙,限于水平,文章中错误一定很多,真诚恳请有这方面擅长的朋友帮忙指出,不甚感激!

进程的基本概念

进程 or 线程 or 任务?

进程:进程是一个正在运行的程序实例,由可执行的目标代码组成,通常从某些硬媒介(如磁盘,闪存等)读取并加载到内存中。但是,从内核的角度来看,涉及很多相关的工作内容。操作系统存储和管理有关任何当前正在运行的程序的其他信息:地址空间,内存映射,用于读/写操作的打开文件,进程状态,线程等。

进程是正在执行的计算机程序的实例。它包含程序代码及其当前活动。取决于操作系统(OS),进程可能由同时执行指令的多个执行线程组成。基于进程的多任务处理使您可以在使用文本编辑器的同时运行Java编译器。在单个CPU中采用多个进程时,使用了各种内存上下文之间的上下文切换。每个过程都有其自己的变量的完整集合。

但是,在Linux中,如果不讨论线程(有时称为轻量级进程),进程的抽象是不完整的。根据定义,线程是流程中的执行上下文或执行流;因此,每个进程至少包含一个线程。包含多个执行线程的进程被称为多线程进程。一个进程中有多个线程可以进行当前编程,并且在多处理器系统上可以实现真正的并行性。

线程:则是某一进程中一路单独运行的程序,也就是说,线程存在于进程之中。一个进程由一个或多个线程构成,各线程共享相同的代码和全局数据,但各有其自己的堆栈。由于堆栈是每个线程一个,所以局部变量对每一线程来说是私有的。由于所有线程共享同样的代码和全局数据,它们比进程更紧密,比单独的进程间更趋向于相互作用,线程间的相互作用更容易些,因为它们本身就有某些供通信用的共享内存:进程的全局数据。

线程是CPU利用率的基本单位,由程序计数器,堆栈和一组寄存器组成。执行线程是由计算机程序的分支分解为两个或多个同时运行的任务而产生的。线程和进程的实现因一个操作系统而异,但在大多数情况下,线程包含在进程内部。多个线程可以存在于同一进程中并共享资源(例如内存),而不同进程则不共享这些资源。同一进程中的线程示例是自动拼写检查和写入时自动保存文件。线程基本上是在相同内存上下文中运行的进程。线程在执行时可能共享相同的数据。线程图,即单线程与多线程

任务:是最抽象的,是一个一般性的术语,指由软件完成的一个活动。一个任务既可以是一个进程,也可以是一个线程。简而言之,它指的是一系列共同达到某一目的的操作。与线程非常相似,不同之处在于它们通常不直接与OS交互。像线程池一样,任务不会创建自己的OS线程。一个任务内部可能有一个线程,也可能没有。例如,读取数据并将数据放入内存中。这个任务可以作为一个进程来实现,也可以作为一个线程(或作为一个中断任务)来实现。在RTOS中,一般会将调度的基本单元称为任务,比如freeRTOS,ucos,embOS等,在RTOS中没有进程的概念

进程线程
进程是重量级的操作线程是轻量级操作
每个进程都有自己的内存空间线程共享它们所属的进程的内存空间
进程间的通信速度很慢,因为进程具有不同的内存地址线程间通信可能比进程间通信快,因为同一进程的线程与其所属的进程共享内存
进程之间的上下文切换开销大在同一进程的线程之间进行上下文切换的开销较低
进程不与其他进程共享内存线程与同一进程的其他线程共享内存
进程间通讯机制
  • 管道(Pipe)及有名管道(named pipe):管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
  • 信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数 sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal 函数);
  • 报文(Message)队列(消息队列):消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
  • 信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
  • 套接字(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。
线程间的同步机制:为啥线程间没有讨论通讯机制?因为同一进程内的线程共享进程的资源。那么资源共享,则需要处理资源共享时的同步问题。
  • 互斥锁(mutex):通过锁机制实现线程间的同步。同一时刻只允许一个线程执行一个关键部分的代码。这部分代码常称为临界区。哪些可能是临界区呢?简言之,多个线程可能竞争访问的资源。以下一些函数是互斥锁的API函数。
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutex_attr_t *mutexattr);
int pthread_mutex_lock(pthread_mutex *mutex);
int pthread_mutex_destroy(pthread_mutex *mutex);
int pthread_mutex_unlock(pthread_mutex *
  • 全局条件变量(condition variable): 创建一些全局条件变量进行互斥访问控制。以下是其操作的基本接口函数:
int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *cond_attr);  
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex,const timespec *abstime);
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
  • 信号量(semaphore):如同进程一样,线程也可以通过信号量来实现通信,其基本操作接口API:
int sem_init (sem_t *sem , int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_destroy(sem_t *sem);

进程在内核中如何描述?

Linux中进程描述在./include/linux/sched.h中定义:

struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
 /* 必须是首个元素  */
 struct thread_info  thread_info;
#endif
 /* -1 unrunnable, 0 runnable, >0 stopped: */
 volatile long   state;

 /* 前面是与调度密切相关的信息添加在这之前 */
 randomized_struct_fields_start

 void    *stack;
 refcount_t   usage;
 /* Per task flags (PF_*), defined further below: */
 unsigned int   flags;
 unsigned int   ptrace;
 .........
};

该结构非常大,集总抽象了进程的所有信息,包括进程ID,状态,父进程,子进程,同级,处理器寄存器,打开的文件,地址空间等。系统使用循环双向链接列表进行存储 所有过程描述符。

像这样的大型结构肯定会占用大量内存空间。为每个进程提供较小的内核堆栈大小(可以使用编译时选项进行配置,但默认情况下限制为一页,即对于32位体系结构严格为4KB(一个页),对于64位体系结构严格为8KB(两个页) –内核堆栈不具备增长或收缩),以这种浪费的方式使用资源并不是很方便。因此,决定在堆栈中放置一个更简单的结构,并带有指向实际task_struct的指针,从而引申出thread_info。

抽象建模思想看进程描述符

进程首先是操作系统对底层进行抽象而提供面向应用接口的一种抽象,而进程描述符则将底层资源、进程本身的调度从以下几个大的方面进行高级别的抽象封装:

  • 应用程序信息抽象
  • 操作系统资源抽象
  • 调度接口抽象
  • 内存管理抽象
  • 账户信息抽象
  • ......

通过预读进程描述符,个人将进程描述相关信息大致分为以下几个大类抽象:

  • 调度相关抽象

85be04c657317787385c52ef6e496b38.png

涉及thread_info、优先级、栈、上下文切换、调度相关链表等关键数据。

  • CPU相关抽象

7eacdcad6cc5c89b7fd988e211f20e70.png

涉及SMP多核处理抽象、CPUSET子系统相关、当前CPU等相关数据抽象。

  • 保护机制抽象

ce1016b2918c025277cc22b65486ea55.png

  • 内存管理抽象

dc405875e963cb72db6edffcf47d5477.png

  • 缓存相关抽象

d6f7c61e36de04ea13044b384827a239.png

参考阅读:https://www.cnblogs.com/20135...

https://blog.csdn.net/wang_xy...

  • 信号通信抽象

943ebb282252bded8ffd2ca5cc9e901f.png

  • 接口相关抽象

9edb8eeb107ea327a1e961b85b9fe586.png

  • 调试跟踪抽象

183955e38c558a5d5bca41e4e9cf9543.png

  • 安全机制抽象

0819bb248ef2859159f5da7dd8ea05fa.png

  • 资源管理抽象

b02cf888abe451ccb50cf8313a3d2fd0.png

  • 杂项信息抽象

8a6d291d2e3545ef6298df1bdc9915ef.png

最后附上些整理搜集到数据域的一些较详细的介绍。

thread_info

该字段保存特定于处理器的状态信息,并且是进程描述符的关键元素。具体定义在./arch/xxx/include/asm/thread_info.h中。

  • entry.S需要立即访问此结构的低级任务数据应完全适合一个缓存行,此结构共享主管堆栈页面
  • 如果更改此结构的内容,则还必须更改汇编代码。
  • 因为thread_info包含了当前进程的指针,存储在栈底或栈顶,取决于不同体系架构栈的增长方向,利用thread_info可以快速的访问当前进程的信息,而不必依次遍历。

ARM32的定义:

struct thread_info {
 unsigned long flags;  /* low level flags */
 int       preempt_count; /* 0 => preemptable, <0 => bug */
 mm_segment_t addr_limit; /* address limit */
 struct task_struct *task;  /* main task structure */
 __u32   cpu;  /* cpu */
 __u32   cpu_domain; /* cpu domain */
#ifdef CONFIG_STACKPROTECTOR_PER_TASK
 unsigned long  stack_canary;
#endif
 struct cpu_context_save cpu_context; /* cpu context */
 __u32   syscall; /* syscall number */
 __u8   used_cp[16]; /* thread used copro */
 unsigned long  tp_value[2]; /* TLS registers */
#ifdef CONFIG_CRUNCH
 struct crunch_state crunchstate;
#endif
 union fp_state  fpstate __attribute__((aligned(8)));
 union vfp_state  vfpstate;
#ifdef CONFIG_ARM_THUMBEE
 unsigned long  thumbee_state; /* ThumbEE Handler Base register */
#endif
};

从书上和网上看到都是前面这样描述的,但是对于ARM64的却没有当前进程指针,这是为何呢?没弄明白,有谁知道告诉下我呗。

struct thread_info {
 unsigned long flags;  /* low level flags */
 mm_segment_t    addr_limit; /* address limit */
#ifdef CONFIG_ARM64_SW_TTBR0_PAN
 u64   ttbr0;     /* saved TTBR0_EL1 */
#endif
 union {
  u64  preempt_count;    /* 0 => preemptible, <0 => bug */
  struct {
#ifdef CONFIG_CPU_BIG_ENDIAN
   u32 need_resched;
   u32 count;
#else
   u32 count;
   u32 need_resched;
#endif
  } preempt;
 };
};

利用如下的几种方式,可以获取thread_info信息:

1649744250(1).png

SLUB 分配器

thread_info实现了进程存储对描述符的引用以及如何访问它们。但是,如果task_struct不是在内核堆栈内部,则task_struct到底位于内存中的什么位置?为此,Linux提供了一种特殊的内存管理机制,称为SLUB层。SLUB动态生成task_struct,并把thread_info存在栈底或栈顶。

volatile long state

进程状态,可取的进程状态:

  • TASK_RUNNING:  可执行态
  • TASK_INTERRUPTIBLE:可中断
  • TASK_UNINTERRUPTIBLE:不可中断
  • __TASK_STOPPED:停止态
  • __TASK_TRACED:被其他进程跟踪的进程

为何用volatile修饰。由于内核经常需要从不同位置更改进程的状态,例如,如果在单个CPU硬件上同时将两个进程设置为RUNNABLE。熟悉单片机编程的朋友一定知道,当在中断函数中需要修改以及在中断外部也会被修改的变量,就会使用到volatile修饰变量。

randomized_struct_fields_start

这是gcc的一个插件(插件来自于Grsecurity),其作用就是这之后的变量不会按照声明顺序存储在内存中,而会按照一定的随机顺序存放,这样做是基于安全考虑,比如应用程序的进程描述符被劫持,如果按顺序存放,则容易篡改其内容。

—_END_—

首发:嵌入式客栈
作者:逸珺

推荐阅读

更多硬核嵌入式技术干货请关注嵌入式客栈专栏。
推荐阅读
关注数
2882
内容数
266
分享一些在嵌入式应用开发方面的浅见,广交朋友
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息