【Linux】深入理解Linux的进程(二)

【Linux】深入理解Linux的进程(二)

【Linux】深入理解Linux的进程(一)-优快云博客讲解了冯诺依曼结构、进程状态、孤儿进程、子进程、tash_struct的部分属性。本文讲解Linux进程调度的真实情况、环境变量、命令行参数、页表、程序地址空间、进程控制、status

进程优先级

进程优先级决定了进程得到CPU资源的先后顺序,可以使用ps -al查看进程优先级,其中-l表示使用长格式显示详细信息,-a表示显示所有用户的进程,上篇文章使用到了-axj-x选项可以显示除当前会话外的其他进程,-j选项表示以作业形式显示详细信息,另外,使用-n可以把usergroup以id形式打印。

  • 进程优先级PRI=PRI默认值+NI(修正值)。PRI默认值等于80,NI的范围在[-20, 19],所以PRI的范围在[60, 99]
  • 操作系统分为分时操作系统实时操作系统,分时操作系统基于时间片公平调度,实时操作系统可以直接进行优先级抢占,让优先级高的进程抢占优先级低的进程工作被广泛应用于工业、制造业等领域。现在的分时操作系统也引入了抢占机制,不对分时操作系统的抢占机制进行讲解。
  • slab:在进行进程调度本质就是对task_struct进行操作,将正在运行的进程的task_struct 放入runqueue就是运行,把阻塞某个设备的进程的task_struct放入struct device{task_struct* sleepqueue; // ..};就是阻塞。运行完的进程的task_struct空间不会被释放,而是会进入slab的``unuselist(数据结构对象的缓存)中,下次使用task_struct直接向slab`申请然后初始化,而不是向内存申请,减少申请时间。
  • 使用指令修改进程优先级
    • top显示不同进资源占用情况,然后在此状态使用r开始指定pid,再输入想要的NI值按回车即可
    • 进程优先级一般由OS决定,自己不进行修改,自己修改可能导致进程饥饿(一个进程长时间占用CPU资源)、也可能因为优先级倒置导致死锁等问题,因此不再讲解相关的系统调用和其他修改NI值的指令。
  • cat /proc/cpuinfo可以查看cpu使用情况

进程切换

进程时间片耗尽等导致进程终止,或者阻塞IO等让进程进入sleep阻塞态,都会切换新进程到CPU运行。从内核态返回用户态时,OS会进行检查,选择新的进程运行。

进程切换分两种操作

  • 记忆旧进程状态——先将寄存器中的内容存放在内存的一个区域中:CPU中集成了寄存器,进程在使用CPU时,将页表地址(存储在CR3 register)、部分数据、当前执行位置等信息存储在寄存器中。切换进程后,CPU资源被新进程使用,为了防止旧进程存储在寄存器的资源丢失、方便保护和恢复旧进程的硬件上下文数据,所以先将寄存器中的内容存放在内存的一个区域中。

    • 之前将这部分数据存储在task_structtss_struct中,不同Linux版本,位置有变化
  • 切换新进程——将task_struct放入CPU,将之前存储在寄存器的信息再存入寄存器中,读取寄存器存储的数据,例如程序计数器(PC)、栈指针(SP)、段寄存器(CS-代码段、SS-栈段)、标志寄存器等等中的数据(当然,OS会设置内核数据结构管理寄存器)。


Linux真实调度算法

本文讲解Linux真实调度算法——O(1)算法,不过要注意的是现在Linux新内核都是用CFS完全公平调度算法。

请添加图片描述
请添加图片描述


环境变量

我们在配置开发环境的时候,经常会在Windows下配置环境变量PATH,因为系统会在启动对应程序时默认从PATH中按顺序遍历路径,可以通过本文深入理解、修改环境变量。

命令行参数

使用Java经常会写到命令行参数,平常写的C/C++不常用,这里解释什么是命令行参数,怎么用、有什么用:

  • int main(int argc, char* argv[]),这个()里的内容就是命令行参数

  • 使用命令行输入./code -a b c,在命令行输入这一串内容后,由bash进行命令解析,./code -a b c拆分成四个char*类型,赋值到argv[]中,并且记录个数argc为4。./code是运行当前路径下code可执行文件,可以在main函数中对argv操作,读取./code.-a.b.c完成个性化操作。

    • 代码示例:

      int main(int argc, char* argv[])
      {
          if(strcmp(argv[1], "-a") == 0) 
          {
              //TODO
          }
          return 0;
      }
      
  • 实际上,main最多有3个参数main(int argc, char* argv[], char* env[])其中env由父进程传递,对于再命令行上写的进程,他们的父进程都是bash,他们如果再去创建进程,就会将env接着传递给孙进程,所以环境变量有全局特性。而main并不是系统最开始运行的函数,Linux入口函数是_start,Windows入口函数是crt_start,先设置ret,用于接受main的返回值,还有int arg_count;记录参数个数等等。最后根据ret处理main的退出信息。

  • 可以通过main的参数传递给子进程自己的条件变量,但是不能修改父进程条件变量

环境变量

指令

  • 查看所有环境变量——env

  • 查看指定环境变量(以PATH为例)内容——echo $PATH

  • 修改特定名称环境变量内容——env 名称=内容

  • 如何向PATH添加新路径——env PATH=$PATH:新路径

  • 添加环境变量——export MTENV=11223344

  • 删除环境变量——unset MTENV

    • 可以通过main的参数传递给子进程自己的条件变量,但是不能修改父进程条件变量。这里为什么可以对全局环境变量添加和修改了?
    • 因为这个exportunset是内建命令,由bash自己调用函数、系统调用亲自执行
  • i = 10,bash会记录两套变量,一套环境变量,一套本地变量,再命令行中写i=10echo $i可以得到10。本地变量不会被子进程继承

  • 使用logout会退出当前用户,set可以查看全部变量

如何从存储角度理解环境变量?环境变量最开始从哪来?

登录时,bash进程从system中/etc/bashrc读取环境变量配置文件,再根据~/.bashrc~/.profile本机环境变量配置文件,创建环境变量表(本质是char* []),将环境变量写入。命令行的命令由bash拿到,构建命令行参数表解析命令,然后拿着命令在环境变量表中找到PATH,由bash寻找命令在system中是否存在,如果存在,创建子进程执行。

认识更多的环境变量

请添加图片描述

系统调用

  • getenv("USER"):获取PATH的环境变量,返回char*,此时可以用char* who接受返回值,与lpx进行strcmp,判断当前用户是谁,可以根据不同用户展现个性化内容。
  • setenv() putenv()
  • 全局变量的使用:只要extern char** environ进行声明就可以使用全局的环境变量了。

可能产生的疑问

使用putenv,setenv会对全局/儿子环境变量有影响吗? execvpe的环境变量缺省值传的是全局environ还是此进程的,如果我函数内部声明extern char* environ,再使用char**手段修改environ,会对全局环境变量有影响吗*

核心理解:环境变量的"全局"是相对于进程而言的,不是系统全局!

所进行的一切修改都是在对此进程而言的,只会影响此进程和后续儿子进程的环境变量,不影响全局!

使用缺省值传参,传的是此进程的环境变量

不会有影响!!环境变量的"全局"是相对于进程而言的,不是系统全局!


程序地址空间

使用代码:

#include <stdio.h>
#include <unistd.h>

int gval = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        while(1)
        {
            printf("子: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
            gval++;
        }
    }
    else
    {
        while(1)
        {
            printf("父: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
        }
    }
}


我们创建子进程,父子进程共享代码和数据,在堆数据进行修改时,会进行写时拷贝,我们使用子进程修改数据gval,然后堆gval数据和地址进行打印,发现打印的地址一样,但是数据不一样。

请添加图片描述

  • 这是因为OS在物理空间上封装了虚拟地址空间,这么做可以保障安全,不让用户访问到内存中的危险数据。这个虚拟空间上的地址被称为虚拟地址,物理内存上的地址被称为物理地址。

  • 由页表完成虚拟地址和物理地址之间的映射,页表具体内容在页表专门讲解。

  • 另一方面可以让进程以为自己得到了十分大的空间,在堆程序进行编码的时候,系统会根据不同类型数据形成不同segment,最后带着可执行文件的不同信息,一起组成ELF文件,形成ELF文件后,程序的逻辑地址就已经确定了、虚拟地址也确定了。举个场景:

  • 你的代码需要4G内存空间才能运行,但是你的物理内存只有2G,在32位系统下,创建4G虚拟地址空间,然后将2G的代码加载到物理内存中,由页表建立映射关系,当访问到其他2G空间的时候,系统发生缺页中断,将访问的新的数据加载到内存中,将之前已经加载到内存的数据覆盖掉,需要时再进行加载即可。而在64位架构下,虚拟地址空间很大很大,最大可以开到(2^64 - 1),完全大于硬件的物理内存,这一操作让进程认为自己独占所有的物理空间。

虚拟地址空间创建的意义

  • 内存保护与安全性

    • 进程隔离:每个进程拥有独立的虚拟地址空间,确保进程之间的内存完全隔离

    • 防止恶意访问:一个进程无法直接访问或修改另一个进程的内存区域

    • 系统稳定性:防止程序错误导致整个系统崩溃,提高系统的健壮性

    • 权限控制:可以为不同的内存区域设置不同的访问权限(读、写、执行)

  • 内存管理的简化与优化

    • 统一地址视图:为每个进程提供连续、统一的地址空间,简化程序设计
    • 物理内存碎片化解决:虚拟地址的连续性掩盖了物理内存的碎片化问题
    • 灵活的内存分配:操作系统可以更灵活地分配和回收内存资源
  • 虚拟内存技术支持

    • 扩展可用内存:程序可以使用比实际物理内存更大的地址空间

    • 分页机制:支持将不常用的内存页面交换到磁盘,释放物理内存

    • 按需加载:只有在实际访问时才加载内存页面,提高内存利用效率

    • 统一编程接口:所有进程都使用相同的地址空间模型进行编程

    • 降低开发复杂度:简化了内存管理相关的编程工作

    • 提高代码可移植性:程序在不同硬件平台上具有更好的兼容性

  • 共享区作用:

    • 内存映射文件:支持将文件映射到虚拟地址空间,实现高效的文件访问
    • 共享内存区域:允许不同进程共享特定的内存区域,实现进程间通信
    • 动态链接库:支持多个进程共享同一份代码库,节省内存资源
    • 多线程程序提供更好的内存访问性能:在共享区创建栈空间给新的轻量化进程
  • 性能优化

    • 局部性原理利用:配合CPU缓存机制,提高内存访问效率
    • 预取机制:支持内存预取策略,减少内存访问延迟
  • 虚拟地址空间共享区可以完成过进程共享物理内存的作用,这也是进程间通信shm创建共享空间的原理

请添加图片描述

怎么管理虚拟地址空间

虚拟地址空间先使用结构体进行描述,对开辟、释放空间的一系列操作都对描述字段维护,以此来方便管理。所以先讲解怎么对虚拟地址空间进行描述。对于管理,使用链表数据结构进行管理vm_area_struct,如果vm_area_struct太多,就使用红黑树进行管理。

虚拟地址空间是进程独占的,每个进程都有自己的虚拟地址空间,OS将虚拟地址空间描述为struct mm_struct,并作为成员放在struct task_struct中,每个mm_struct都有vm_area_struct完成不同区的映射

请添加图片描述

  • vm_area_struct(简称VMA)包含以下关键字段:

    • vm_start:虚拟内存区域的起始地址
    • vm_end:虚拟内存区域的结束地址
    • vm_prot:内存保护标志(读/写/执行权限)
    • vm_flags:各种标志位
    • vm_next:指向下一个 VMA 的指针(形成链表)
  • 我们使用mmap创建空间会创建新的vm_area_struct,注意的是,这里是mmap系统调用,而不是mallocmallc会根据申请空间的大小调用不同系统调用,空间大于一个阈值后调用mmap,小于的时候调用brk,使用 brk申请小空间通常不会创建新的vm_area_struct,而是在原有的vm_area_struct上移动vm_end

    // 每次调用都会创建新的 VMA
    void *ptr1 = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    void *ptr2 = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    // 这会创建两个独立的 VMA
    

部分源码

struct mm_struct {
	struct vm_area_struct * mmap;		/* list of VMAs */
	struct rb_root mm_rb;
	struct vm_area_struct * mmap_cache;	/* last find_vma result */
	unsigned long (*get_unmapped_area) (struct file *filp,
				unsigned long addr, unsigned long len,
				unsigned long pgoff, unsigned long flags);
	void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
	unsigned long mmap_base;		/* base of mmap area */
	unsigned long task_size;		/* size of task vm space */
	unsigned long cached_hole_size; 	/* if non-zero, the largest hole below free_area_cache */
	unsigned long free_area_cache;		/* first hole of size cached_hole_size or larger */
	pgd_t * pgd;
	atomic_t mm_users;			/* How many users with user space? */
	atomic_t mm_count;			/* How many references to "struct mm_struct" (users count as 1) */
	int map_count;				/* number of VMAs */
	struct rw_semaphore mmap_sem;
	spinlock_t page_table_lock;		/* Protects page tables and some counters */

	struct list_head mmlist;		/* List of maybe swapped mm's.	These are globally strung
						 * together off init_mm.mmlist, and are protected
						 * by mmlist_lock
						 */

	/* Special counters, in some configurations protected by the
	 * page_table_lock, in other configurations by being atomic.
	 */
	mm_counter_t _file_rss;
	mm_counter_t _anon_rss;

	unsigned long hiwater_rss;	/* High-watermark of RSS usage */
	unsigned long hiwater_vm;	/* High-water virtual memory usage */

	unsigned long total_vm, locked_vm, shared_vm, exec_vm;
	unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
	unsigned long start_code, end_code, start_data, end_data;
	unsigned long start_brk, brk, start_stack;
	unsigned long arg_start, arg_end, env_start, env_end;
};

以下字段划分形成不同区域,比如start_code brk就是完成堆地址的区域划分。

	unsigned long total_vm, locked_vm, shared_vm, exec_vm;
	unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
	unsigned long start_code, end_code, start_data, end_data;
	unsigned long start_brk, brk, start_stack;
	unsigned long arg_start, arg_end, env_start, env_end;

页表

  • 页表除了完成虚拟地址向物理地址的映射,还设置权限位、是否要进入内核态、是否可命中等信息。
  • 页表将无序的地址变成“有序”的地址,利用局部性原理提高效率,
  • 同时还方便进行内存管理、减少内存碎片。
  • 在进行地址变换中,对地址和操作验证合法性,从而保护物理内存。还可以将进程管理和内存管理解耦,只需要访问虚拟地址空间即可,内存管理由OS完成。
    • 比如,要访问[3,4]G内核空间数据,就必须进入内核态才能访问
    • 比如,你要对特定地址解引用,如果页表没有对应地址映射关系,就会发生段错误。(关于段错误原理,在信号一章节部分讲解)

请添加图片描述

一些问题

  • 页表在哪?

    • 页表分为两种:内核页表和用户页表。分别完成内核空间和用户空间的映射,两者都在内核空间里
  • 可不可以不加载代码和数据,只有task_structmm_struct、页表等?

    • 可以
  • 创建新进程,先加载代码和数据,还是先有task_structmm_struct、页表?

    • 后者
  • 如何理解进程挂起

    • 查页表,把页表映射到的物理地址处的数据放入磁盘swap分区中。
  • free地址后,页表有什么变化

    • 把对应虚拟地址映射关系删除,如果再访问这一虚拟地址,查页表查不到,就会段错误
  • 为什么char* str = "abcd"; *str='H';会崩溃。

    • 这是因为“abcd”被硬编码代码段,页表会对代码段区域设置权限——不可以修改,那么你的修改操作就会崩溃。
  • 缺页中断是什么

    • 在将磁盘中内容向内存加载的过程中,采取分批加载的策略,一开始只加载一部分代码和数据,但是会i根据ELF信息完成页表的建立(页表对未加载代码和数据的部分添加“不可命中”标识)。当进程在运行中访问到特定地址,发现“不可命中”,说明这部分数据不再内存上,将磁盘ELF文件再次向内存加载,这一过程就是缺页中断。
    • 缺页中断证明进程管理和内存IO解耦
  • ps: 在创建子进程的过程中,会申请新的内存块和内核数据结构(页表、mm_struct、vm_area_struct),然后cp task_struct内容到子进程task_struct中,然后进入runqueue开始调度


进程控制

写时拷贝

原理

  • 在创建子进程后,系统会通过页表将数据段的写权限删除;
  • 此时如果子/父进程尝试修改数据,就会触发页面错误(Page Fault)
  • 内核捕获异常 → 检查是写时拷贝(COW)的页面
  • 内核分配新页面 → 复制原页面内容
  • 更新页表映射 → 指向新的物理页面,恢复写权限 → 继续执行写操作

意义:减少创建进程的时间,减少内存浪费

退出码

基础知识

获取退出码:使用echo $?指令获取进程退出码,对于父子进程,父进程可以通过进程等待获取子进程退出码。此外,C标准库系统调用函数执行错误会设置errno,可以使用perror、error打印错误消息,或者return errno;让errno以退出码形式返回,要注意的是:不要混淆errno和退出码,这两个没有直接关系,退出码是程序员设置的,要想让退出码等于errno,要程序员手动return errno

进程退出的情况

  • 自然退出,运行结果正确:exit(0)或者 return 0退出
  • 运行结果错误:exit(!=0)或return 非0
  • 异常错误终止运行:如果你没有对信号进行捕捉处理,导致进程终止,此时退出码无意义。因为你不知道进程运行到哪里出问题了,是否完成了相应的任务。
系统调用
_exit

exit()不是系统调用,他是C封装的函数;_exit()是系统调用,前者封装了后者。
此外,使用前者会刷新内核缓冲区到目标文件,使用C/C++标准完成析构等后续操作,最后调用_exit退出;

使用_exit()会直接退出,有以下不同:

  • 速度会更快

  • 不会刷新内核缓冲区

  • 不关闭文件流:由内核强制回收文件描述符,

  • 不删除临时文件:临时文件可能残留

  • 直接系统调用:立即调用内核终止进程

  • 有关资源回收的文件描述符表、虚拟内存空间、信号处理表、进程控制块(task_struct)都会交给内核回收。

wait waitpid
 #include <sys/types.h>
 #include <sys/wait.h>

pid_t wait(int *status);

pid_t waitpid(pid_t pid, int *status, int options);// options传 WNOHANG 表示不阻塞等待,传入0表示阻塞等待
// waitpid返回值 >0 子进程退出
// <0 wait defalut
// ==0 进程未退出

// pid分为四种情况
// >0 指定pid
// =0等待同GID的所有子进程
// =-1 等待所有子进程,等同于wait函数等待方法
// <-1 等待对应的GID的子进程(相反数)

这个status要注意和退出码区分

请添加图片描述

提供了宏来获取退出码、判断是否正常退出:

  • WEXITSTATUS(int status) 返回退出码,即(status >> 8) & oxFF
  • WIFEXITED(int status) 返回 bool 异常返回false

程序替换

函数:exec*—— (其中execve是系统调用,其他的都是C语言封装的)

  • execl()l表示可变参数list传参,路径+文件名表示执行谁,传参末尾以NULL结尾表示参数传递完毕
  • execlp() 这个p表示只需要告诉文件名,在PATH中寻找文件
  • execv() v表示vector传参
  • execvp()
  • execvpe()这个e表示环境变量,缺省传char** environ
  • execve()

可能产生的疑问

对于环境变量[可能产生的疑问]

内核怎么完成程序替换的?——对原来进程的代码和数据进行覆盖是处理,并没有创建新的task_struct,exec完,原始代码就不存在了,后续代码不运行。

对于exec的函数返回值——只有exec失败的返回值有意义(为-1),如果成功,直接运行新代码了,返回值没意义了。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值