Linux 进程

冯诺依曼体系结构(了解)

清楚知道数据在硬件中的流通

周边知识 

  •  计算机里几乎所有的设备都有存储数据的能力
  • CPU的数据处理能力很强;内存(掉电易失性存储单元);外设,硬盘(ssd固态硬盘),磁盘(永久的存储介质,机械结构)
  • 设备交互的本质是数据的拷贝;存储的效率直接决定了拷贝的效率,决定了设备和设备间通信的效率
  • 数据层面上,当代CPU一般不直接和外设交互,优先和内存交互
  • 内存可以理解为一个硬件级别的大的缓存
  • 冯诺依曼体系结构的本质是:用较少的钱,做出效率不错的计算机(高性价比)
  • 程序运行之必须先加载到内存,使CPU读取其可执行程序的内存和数据(计算机层面都为二进制)

操作系统

  • 操作系统是开机第一个加载的软件
  • 是一款软硬件资源管理的软件(对下手段),为了给用户提供高效,稳定,安全的运行环境

  • 底层硬件采取冯诺依曼的体系结构
  • 除了CPU和内存外都需要驱动程序
  • 操作系统内部有大量的数据对象和数据结构
  • 操作系统之上不是用户而是系统调用

如何管理

  • 先描述被管理的对象,比如说可以通过驱动程序获得对应硬件的重要属性集合通过数据结构将它们组织起来

解释打印

  • 往显示器打印的本质是往底层的硬件打印,操作系统不允许用户直接访问操作系统,那么更不会允许用户直接访问驱动程序或者对应的硬件,几乎用户的所有行为都必须贯彻操作系统,所以会通过printf这个库函数与操作系统的输出功能进行交互,然后由操作系统帮助向硬件打印
  • 所有语言中的大部分功能都和系统调用有关,所以printf必定封装了系统调用
  • 系统调用是上层访问下层的唯一通道

周边知识

  • 适用者角度:系统调用使用起来比较麻烦,所以引入外壳程序Shell(Windows为图形化界面)
  • 开发角度:可以直接调用系统接口,将其封装,打包形成库
  • 所以开发者很多功能只需要调用库函数即可

系统接口

  • 系统接口可以是一个泛指的概念,包含 库函数 和 系统调用。它指的是程序与操作系统或库之间交互的所有接口

库函数

  • 通常调用 系统调用 来实现更高级别的功能
  • 库函数是应用程序调用的函数,通常是由编程语言的标准库或第三方库提供的

系统调用

  • 最底层的,是操作系统内核提供的接口,是程序与操作系统内核直接交互的接口
  • 允许用户的程序请求操作系统执行一些特权操作
  • 系统调用通常通过封装在标准库中的函数接口来访问

进程

        可执行程序被加载到了内存,而操作系统为了更高管理进程,还需创建对应的PCB(进程 = 可执行程序 + 内核数据结构);管理的本质是管理PCB,数据(程序加载到内存的代码段和数据段...(二进制代码和数据))

        学习进程主要是学习各种内核数据结构

PCB

  • 进程控制块(Process Control Block);进程的结构体对象
  • 包含多种属性:id,代码地址,数据地址,进程状态,优先级,链接字段...
  • 操作系统通过进程控制块来管理进程
  • task_struct是Linux内核中具体实现的PCB
  • PCB 是操作系统管理进程的核心数据结构,PCB 完全由操作系统内核创建、管理和销毁。因此,PCB 是操作系统内核的一部分,存储在内核的内存空间中
  • 描述进程的PCB在排队,而不是程序在排队

周边知识

用户空间

  • 用户空间是普通应用程序运行的内存区域。代码段、数据段、堆、栈等通常都在用户空间中
  • 用户空间的程序不能直接访问内核空间,以确保操作系统的稳定性和安全性

内核空间

  • 这是操作系统内核所在的内存区域,包含了内核代码、系统进程的内存、内核数据结构等
  • 普通用户程序不能直接访问内核空间中的数据和代码,必须通过系统调用,操作系统内核和内核级别的进程等机制间接访问

进程比程序多了内核级别的数据结构

  • 这些数据结构由操作系统内核管理,主要包括PCB、内存管理数据结构、调度队列、文件描述符表等
  • 这些内核数据结构为操作系统提供了对进程的全面控制和管理能力,使得操作系统可以有效地调度进程、管理资源,并确保进程的独立和安全运行

 接近实际的结构

  • task_struct 是用于描述每个进程的通用数据结构,所有进程(无论类型如何)都使用这个结构体
  • 纠正:如果在task_struct里实现链接,这也是合理的,链接又很多种方式
  • 一个进程的PCB可以在多个链表里,这是操作系统内核管理调度进程重要机制之一
  • 可以通过cur(list)来得到task_struct的地址

部分系统调用

监控脚本

  • while :; do ps axj | head -1 && ps axj | grep mybin | grep -v "grep"; sleep 1; echo ""; done
  • 用于动态观察
  • grep命令启动的时候也包含mybin

gitpid(void)/gitppid(void)

  • 通过系统调用拿到pid或者ppid
  • kill -9 pid :kill的-9选项可以通过pid杀进程
#include <stdio.h>    
#include <unistd.h>    
    
int main()    
{    
    printf("这个进程的pid:%d, ppid:%d\n", getpid(), getppid());        
    while(1)    
    {    
        sleep(1);                                       
    }    
    return 0;    
}

  • 发现32001是一个叫bash的进程
  • bash是shell(命令行解释器)的一种
  • 运行的ps axj命令的ppid是bash
  • 结论:通过命令行解释器(bash)启动的进程的父进程都是bash(bash在登陆后就不变了)
  • 启动进程意味着 进程一般是由其父进程创建

chdir(const char *path)

/proc

  • 是一个虚拟文件系统,用于提供关于系统和进程的信息,每个正在运行的进程在 /proc 目录下都有一个对应的子目录,该子目录的名称就是该进程的进程ID
  • 是一个动态的目录结构,存放的是所有存在的进程,目录名就是进程的pid
  • exe:指向了该进程当前正在执行的可执行文件的路径
  • cwd:current work directory 当前工作目录

 cwd实验(chdir)

chdir("/home/wzf/Linux");//修改cwd路径
FILE* fp = fopen("test.txt", "w");
  • 会在/home/wzf/Linux的工作目录下创建test.txt文件
  • 这里的fopen也可以写成./text.txt,就是相对于cwd去比照
  • 结论:默认情况下,进程启动所处的路径就是当前路径
  • 结论:每一个进程都要有自己的工作目录

运行起来后删除可执行程序

  • 当一个可执行程序被加载到内存中执行时,文件的内容(机器指令,数据段,代码段,堆栈段)已经被操作系统读取并加载到内存中。此时,即使删除了文件系统中的可执行文件,已经在运行的进程不会受到影响
  • 如果这个可执行程序不是一次全加载到内存上的,进程当然受到影响

代码启动进程fork(void)

  • 结论:只有父进程会执行fork之前的代码,后续(fork函数体的中后部分)代码父子进程都会执行

★启动进程行为理解

  • OS要管理一个进程就需要创建对应的PCB,那就要向系统申请内存,保存当前进程的可执行程序+task_struct对象,并将task_struct对象添加到进程列表中

创建子进程的目的

  • 让子进程协助父进程完成一些单进程解决不了的任务
  • 多进程多在服务端(简单来说需要同时处理多个客户端的请求),客户端较少

fork创建的子进程

  • 以父进程为模版,为子进程创建PCB,先全部拷贝到子进程再修改部分信息

程序从上往下运行

  • 进程运行时,CPU里有个(指令指针)eip寄存器(x86架构)/pc指针(arm架构),会保存当前正在执行的指令的地址;当处理器执行一条指令时,eip会指向下一条指令的地址;eip寄存器的值 也会被子进程继承

为什么会fork有两个返回值

  • fork核心工作:创建PCB,指向父进程的代码和数据,放入调度队列
  • 所以说子进程在fork的return之前就已经创建好了(eip也被拷贝了),所以return语句子进程也要执行,那么就会有两个返回值

fork对于父子进程返回值不一样

  • 返回子进程的 PID,这允许父进程知道子进程已经成功创建,并且可以使用这个 PID 来管理子进程,比如等待它终止或者发送信号
  • 在子进程中,fork 返回0 ;这是为了让子进程识别自己,并执行与父进程不同的操作

★进程是独立运行的   1

  • 每个进程有自己独立的上下文,包含寄存器状态、内存映射;当进程在 CPU 上运行时,操作系统会加载该进程的上下文到 CPU 中,父进程和子进程虽然共享相同的 EIP 值,但在实际执行时,它们是独立运行的,因为它们拥有各自的进程上下文

为什么id会有不同的值,确又是同一个地址(下面详解)

  • 如果说在创建子进程的时候数据拷贝一份,那么会导致时间变长,空间资源浪费(对于子进程来说父进程的很多变量是用不到的)
  • 操作体统并不是创建进程的时候就将数据分离了,而是采用一种写时拷贝的技术,return的时候发生了写实拷贝,所以同一个变量会有不同的值
  • 所以说这个地址不是物理地址,而是虚拟地址
  • 虽然它们的虚拟地址相同,但这并不意味着它们共享同一段物理内存,操作系统使用内存管理单元(MMU)和页面表将这些虚拟地址映射到不同的物理内存地址,因此,尽管虚拟地址相同,但物理内存是不同的
#include <stdio.h>    
#include <sys/types.h>    
#include <unistd.h>    
    
int main()    
{    
    pid_t id = fork();    
    if(id < 0)    
        return -1;    
    else if(id == 0)    
    {    
        while(1)    
        {    
            printf("子进程的pid:%d, ppid:%d, &id=%p\n", getpid(), getppid(), &id);    
            sleep(1);    
        }    
    }    
    else    
    {    
        while(1)                                                                                         
        {    
            printf("父进程的pid:%d, ppid:%d, &id=%p\n", getpid(), getppid(), &id);    
            sleep(1);    
        }    
    }    
}

OS下进程的状态

        状态就是PCB里的一个字段,变量(配合宏定义使用 #define NEW 1);根据不同的状态放入不同的队列中,不同的操作系统可能会有稍微不同的状态描述

运行状态

  • 在早期操作系统中,进程的运行状态通常是指进程(PCB)已经获得了 CPU,并且正在被执行;现在只要是在运行队列中的进程的状态都是运行状态,不一定是正在运行,而是已经准备好了
  • 每一个CPU在系统层面都会维护一个运行队列

将进程从当前状态改为“运行队列”的操作

  • 改对应的字段状态status为running,再将进程对应的PCB移出当前队列,链接到运行队列中,剩下的由调度器操作

阻塞状态

  • 代码中多少会访问系统的某些资源(磁盘,键盘,网卡...);需要等待软硬件资源的就绪,不然进程的后续代码无法继续执行,无法在运行队列中被调度
struct dev {
    int type;           // 设备类型,通常通过宏定义,如#define hardcard 1
    int status;         // 硬件设备的状态
    int datastatus;     // 硬件设备上数据的状态
    PCB* wait_queue;    // 等待使用这个硬件资源的进程队列
    // ...
    // 链接关系,各个硬件的链接起来
};

简要做法

  • OS要对硬件做管理,管理的本质是先描述被管理对象,在通过数据结构组织起来;所以OS对各种设备的管理转化为对硬件的增删查改
  • 先将进程的PCB链入非CPU的运行队列,即链接到某个dev的等待队列里,设置PCB的状态为阻塞状态,等待资源的就绪,这个状态就是阻塞状态
  • 白话简单来说:进程在运行时受限于某些条件,不能运行,这就是阻塞;进程不在CPU的运行队列里,在某个硬件的等待队列里,就是阻塞状态

别的说法/补充知识

  • OS中存在非常多的队列,CPU的运行队列,各种硬件的等待队列
  • 进程状态变化的表现就是把常量值设置到PCB表示状态的字段里
  • 进程状态变化的本质:1.更改PCB的status整形变量 2.将PCB链入不同的队列
  • OS一定是最先知道它所管理的设备的状态变化;
  • 进程在不同队列中的管理和调度完全依赖于操作系统,操作系统负责创建、维护和操作这些队列,并根据进程的状态变化将进程的 PCB 在不同的队列之间移

详细过程

  • 白话:进程运行过程中,需要某些设备的信息(资源),这时OS去查对应设备是否就绪,或设备上的改进程所需的资源是否就绪,如果不就绪,就把这个PCB列入所需设备的等待队列中,然后将这个进程的PCB的status字段设置为阻塞状态;之后OS知道数据,设备准备好了,然后将这个PCB移除阻塞队列到运行队列中(进程的唤醒)
  • 当进程请求的资源不可用时,操作系统将进程的状态更新为阻塞,并将其 PCB 移动到设备的等待队列中。当资源就绪时,进程被唤醒,状态更新为就绪,并重新进入调度队列

挂起状态

  • 若OS的内存资源严重不足,PCB所指向的代码和数据放到磁盘上,内存会腾出一部分空间,用于其他更紧急或更高优先级的进程,这个操作为挂起
  • 将内存数据置换到磁的swap分区(不大,不然OS频繁使用,效率会下降),通常不会针对所有阻塞进程,而是根据系统的内存管理策略和当前的内存压力来决定
  • 慢是必然的,目的是为了OS还可以继续执行

目的

  • 目的是在系统资源(特别是内存)不足的情况下,释放内存资源,使其他更紧急或高优先级的进程能够继续运行

扩展

  • 挂起进程通常指的是用户空间的进程(我觉得这个说法有问题)
  • 用户空间中没有进程控制块(PCB),用户启动的进程的PCB(进程控制块)在内核空间,而该进程对应的代码和数据在用户空间
  • 被挂起的进程,置换出去的是它的代码和数据
  • 被置换出去的空间通常是为了腾出内存,供新的进程或其他需要更多内存的进程使用

问:挂起是因为内存不足,可是挂起的是用户空间的代码和数据,那么转移了也没用啊,这时候需要启动新的进程不是也没地方吗?你不是说PCB只能在内核空间吗,腾出用户空间没用啊

  • ★内核空间由于其固定且较小的内存需求,内存使用通常不会成为系统内存管理的主要瓶颈
  • 代码段和数据段可能占用数百MB甚至更多的内存,PCB的大小相对固定且较小,通常只占用几KB到几十KB的内存
  • 其实释放的用户空间,要么是为了给新的PCB提供对应的代码和数据一段空间,要么是为了给别的,就要被调度的PCB,这个PCB需要比较大的代码和数据空间
  • 简单来说:内存不足就是用户空间不足,就是PCB所需的代码和数据没地方放,即系统无法为进程的代码和数据分配足够的内存空间

Linux中的进程状态

S(休眠状态)(阻塞)

#include <stdio.h>    
#include <unistd.h>    
                                                                                    
int main()    
{    
    while(1)    
    {    
        printf("打印中...\n");    
        sleep(1);    
    }    
    return 0;    
}    

  • 若有sleep,在sleep期间会放入一个定时器等待队列
  • 若无sleep,也是S+

浅度睡眠

  • 可以被终止,会对外部信号做出响应,可中断的睡眠状态
  • (OS阻塞)状态表示进程在等待某个事件时进入睡眠状态,等待事件发生时会被唤醒

数据的流动

  • 先写入到用户空间的输出缓冲区,再是white系统调用写到内核中的输出缓冲区,通过内核缓冲区转移数据给对应的驱动程序

为什么是S+

  • 数据从内核缓冲区通过设备驱动程序传递到实际硬件设备的过程中,可能会导致I/O阻塞,并使进程进入等待队列
  • +:前台进程;启动后当前命令行bash无法执行其他命令;前台进程只能有一个,ctrl z可以终止
  • ./mybin &:可执行程序后面加个 & 则表示启动后为后台进程,后台进程ctrl z无法终止,其他指令的执行不受影响
  • ctrl z:其实就是个这个前台进程发送一个20号(信号篇补充20与19的区别)

补充

  • kill 后面要先跟选项,再跟pid

D(深度睡眠)(阻塞)

  • 不可中断的睡眠
  • 专门针对磁盘来设计的,防止OS在向磁盘写入关键数据时,进程被杀掉导致数据的丢失
  • 但过多的D状态进程可能导致系统性能问题
  • 关机也杀不掉,正常关机时OS向D进程发送的SIGTERM,但是D进程不会生效,OS会等待这个进程运行完毕,即延迟关机
  • 强行断电,所有未完成的操作会被立即中断,容易导致数据的丢失

R(运行状态)

上面代码把printf注释掉,进程就变成运行状态

T(暂停状态)(阻塞)

  • 一句话就是T状态不能自己恢复,所以进程是被手动暂停的,必须由外部信号(如 SIGCONT)来恢复其执行
  • 进程的执行已经被完全停止,因此它不再参与调度队列的活动

解释手动暂停

  • 可以通过命令行发送信号(ctrl z, kill),也可以通过程序代码(raise(SIGSTOP),  kill(pid, SIGSTOP))

t(暂停状态)(阻塞)

  • 用来表示某种特殊的停止状态或调试状态,这不是标准的Linux进程状态表示
  • dubug程序的时候,追踪程序遇到断点时会发生
  • ★补充:如果在 pstop 等工具中看到小写的 t,可能是因为某些特殊的系统实现或者配置;但在标准的进程状态表示法中,小写的 t 并不常见

Z(僵尸状态)

  • 于当前进程而言,运行完毕后需要将main函数的退出结果写到PCB里,它的所有资源(代码和数据)都会被释放,但PCB会被保留,会被OS维护,直到PCB里的退出状态和其他统计信息被父进程读取,在还没有被读取的这段时间内,此时进程被标记了一个 僵尸进程,僵尸进程不再占用任何资源
  • 于其父进程而言,通过 wait()waitpid() 获得子进程的退出状态码,得知子进程退出的原因

X(死亡状态)

  • 它更准确地被描述为“进程已退出”或“进程已终止
  • 几乎查看不到

孤儿进程

  • 父进程提前退出,而子进程没有结束,此时子进程被1号进程领养
  • 1号进程新版本systemd,老版本initd

优先级

概念:进程在竞争系统资源时的执行顺序

  • 优先级的范围60-99,默认为80;这里60最大
  • 进程PCB中存在nice值,通过修改nice值来修改优先级
  • PRI = pri + NI;pri为确定的起点即80;加减nice值得到最终值
  • 改nice值:top;r;pid;nice     不能用小键盘,Backspace也不可以;这通常是因为 top 命令的输入模式或终端设置的问题

为什么优先级要限制在一定范围内

  • 较为均衡的让每一个进程都要得到调度,不然容易导致优先级较低的进程无法得到CPU资源,出现进程饥饿
  • 补充:这种限制能够帮助操作系统更好地管理资源分配,使所有进程都有机会得到调度,进而确保系统的整体性能和响应性。

进程切换

        在 Linux 中,进程的调度和切换主要是基于 时间片轮转抢占式调度 的组合方式

EIP寄存器(程序计数器)

  • 保存着当前进程正在执行的指令的地址;当进程切换时,操作系统会保存当前进程的 EIP 值,这样当该进程再次获得 CPU 资源时,可以从正确的位置继续执行

进程在运行时会使用寄存器来保存临时数据

  • CPU 寄存器用于存放临时数据、操作数、地址以及其他关键信息
  • 每个进程都有其自己的寄存器内容,寄存器中存储的数据对不同的进程来说是不同的

进程的上下文是什么?

  • 上下文 是指进程在 CPU 上执行时的所有状态信息,这包括程序计数器(EIP)、通用寄存器、标志寄存器、状态寄存器、栈指针以及其它状态信息

进程的上下文保存到哪?

  • 老系统会将当前进程的上下文保存到PCB
  • 现代操作系统,特别是在 Linux 中,由于内核的演变和复杂性增加,PCB 结构变得越来越大进程的上下文并不总是直接保存在 PCB,而是在保存到其内核栈或专门的上下文结构,并通过 PCB 中的指针引用这些信息

内核栈或专门的上下文结构也在内核空间?那为什么不直接放在task_struct里?

  • 一个较小的 task_struct 更容易管理,尤其是在频繁的进程调度和上下文切换中,减少 task_struct 的大小,使得调度器在进行进程切换时能够更快地访问和操作这些数据
  • 灵活性和扩展性

内核栈拓展

  • 作用:内核栈是每个进程在内核态时使用的一块内存区域(位于内核空间),通常用于存储函数调用的返回地址、局部变量、函数参数以及进程在进入内核态时的上下文信息(如寄存器的内容)
  • 每个进程在进入内核态时都会使用自己独立的内核栈(例如 8 KB 或 16 KB),以确保足够的空间存储局部变量和上下文信息
  • 使用自己独立的内核栈用于与CPU寄存器内容和内核栈内容的“切换”(赋值操作)

内核态

  • 注意:不是进程的状态,而是指进程在执行时所处的 执行模式
  • 关键点:当进程进入内核态时,它获得了更高的权限来执行内核相关的任务。此时,进程的代码由操作系统内核直接管理和调度;进程可以从内核态返回用户态,例如,当系统调用完成或中断处理结束时,进程会从内核态返回到用户态继续执行
  • 进程执行操作系统内核的代码,拥有对系统资源的完全访问权限,可以直接操作硬件设备、管理内存、调度其他进程(说的也是  “与CPU寄存器内容和内核栈内容的“切换”” 这个操作一定是执行系统的代码,自己的代码怎么会有这个操作)
  • 系统调用:当进程需要操作系统提供的服务(如文件操作、内存分配、进程管理等)时,它会通过系统调用进入内核态
  • 中断处理:如果硬件中断发生(例如I/O操作完成),当前进程也会进入内核态,内核负责处理这个中断
  • 异常处理:如果进程在用户态发生异常(如除零错误、非法内存访问等),它会陷入内核态,由内核处理这些异常

拓展知识

  • 用户态 vs 内核态 vs 运行态
  • 进程状态 vs 内核态
  • 运行态 vs 运行状态

Linux2.6.11下进程的调度队列

  

这是一个调度器使用的运行队列的数据结构 

运行队列

  • 是一种特定的调度队列,专门用于管理和调度那些已经准备好运行的进程

补充调度队列:

  • 调度队列是一个泛指的概念,用来描述系统中所有等待被调度的进程的集合,它涵盖了操作系统中用于管理不同状态和不同需求的各种队列
  • 就绪队列
  • 等待队列
  • 阻塞队列

字段

  • active:active 是一个指针,它指向 prio_array 结构;prio_array 结构中包含了实际的队列数据(即不同优先级的进程链表)包含所有当前正在运行或准备运行的进程。这些进程有剩余的时间片,且调度器会优先从这个队列中选择进程进行调度
  • expired:包含那些已经用完时间片的进程。进程在用完分配的时间片后会被移动到 expired 队列中,等待下一轮调度周期

prio_array结构

  • nr_active:当前活跃的进程数量(一共有多少个进程)
  • bitmap[5]:用于标识哪些优先级队列非空的位图(32 * 5 个0,用其中的0-139位来表示queue指针数组中下标对应的元素是否为空)
  • queue:这是一个链表数组,包含了所有不同优先级的进程队列

调度的方法

  • 新的进程链入expired的进程链表中
  • 通过swap(avtive,  expired)即可交换

问为什么要两个prio_array?

  • 防止一直插入优先级高的进程,导致优先级低的进程一直得不到调度出现进程饥饿

命令行参数

#include <stdio.h>
#include <string.h>
#include <stdlib.h>                      
int main(int argc, char* argv[])         
{                                        
    //for(int i = 0 ; i < argc; i++)//这样也行      
    //    printf("argv[i]=%s\n", argv[i]);      
    for(int i = 0; argv[i]; i++)         
        printf("argv[%d]=%s\n", i, argv[i]);      
    if(argc != 4)                        
        printf("Error\n");               
    else                                 
    {                                    
        int num1 = atoi(argv[2]), num2 = atoi(argv[3]);    
        if(!strcmp(argv[1], "add"))      
            printf("%d+%d=%d\n",num1, num2, num1 + num2);    
        if(!strcmp(argv[1], "sub"))      
            printf("%d-%d=%d\n",num1, num2, num1 - num2);                                 
        if(!strcmp(argv[1], "mul"))      
            printf("%d*%d=%d\n",num1, num2, num1 * num2);    
        if(!strcmp(argv[1], "div") && num2)    
            printf("%d/%d=%d\n",num1, num2, num1 / num2);
    }      
    return 0;
}

 概念

  • 用户在命令行中输入的以空格分割的字符串,就是命令行参数

字段

  • argc:表示传递给程序的参数个数
  • argv:字符串会存入这个字符指针数组,这个数组的末尾是NULL

命令行

  • 第一个参数是要执行的程序,所以argv[0]一定不是NULL
  • 可以通过不同的选项,让一个程序执行不同的功能

环境变量

问题:为什么系统命令不用带?

  • 这是一个结果,就是不用带  "./";
  • 其次,执行命令的时候会根据二进制文件,即代码和数据,来创建对应的进程
  • 那么系统一定是有办法通过路径,来找到这个二进制文件的

PATH

  • PATH 是一个环境变量,它定义了操作系统在命令行中搜索可执行文件的目录列表;字符串以冒号隔开
  • 操作系统会按照 PATH 变量中列出的目录顺序依次查找这个命令对应的可执行文件
  • 如果找到了就运行,没有则需输入完整路径
  • echo $PATH 可查看 PATH环境变量
  • 可以通过这样的方式来修改内存中PATH

 相关概念

  • 环境变量从配置文件中读取
  • 这些环境变量仅在当前 shell 实例(当前打开的终端)中存在,因为环境变量是在进程的内存空间中保存的,当进程结束时,这些变量就会消失
  • 子进程的命令行参数和环境变量来自父进程

查看环境变量的方式

1.env命令

2.main函数的第三个参数

#include <stdio.h>    
int main(int argc, char* argv[], char* env[])                                                                  
{    
    for(int i = 0; env[i]; i++)    
        printf("env[%d]:%s\n", i, env[i]);    
    return 0;    
} 
  •  argc和argv写了才能加env这个参数
  • 这个代码会有警告,因为argc和argv没有用到

3.C库函数:getenv()

  • printf("PATH=%s\n", getenv("PATH")); 
  • 函数在stdlib.h中
  • 参数为环境变量名

4.通过environ指针

#include <stdio.h>

int main(int argc, char* argv[], char* env[]) {
    extern char **environ; 
    for (int i = 0; environ[i]; i++) 
        printf("env[%d]:%s\n", i, environ[i]);
    return 0;
}
  • 是一个全局变量,意味着它在整个程序的所有源文件中都可以被访问,但前提是你在当前作用域中声明了它
  • 是一个指向字符串数组的指针,每个字符串表示一个环境变量
  • 不需要显式包含头文件,这是因为 environ 是一个由 C 标准库(如 glibc)提供的全局变量,而不是通过头文件声明的常规符号

全局变量的意思

  • 它在程序的整个运行过程中存在,并且可以在程序的任何部分被访问的变量,但这并不意味着它在所有作用域中自动可见,需要声明
  • 就是说,使用的前提是声明,但是仅仅声明是使用用不了的;编译器遇到对某个符号(变量,函数,,,)的使用时,编译器首先会在当前源文件中查找该符号的定义,若无,编译器会记录下这个未解析的符号,并在链接阶段将其传递给链接器,链接器会在其他目标文件或系统库中查找这个符号的定义

本地变量

  • 这些变量在当前的 shell 会话中全局有效
  • 本地变量不会继承给子进程的环境变量
  • export可以导出本地变量

本地变量与环境变量的区别

  • 本地:只在bash进程内有效,不会被子进程继承
  • 环境:通过让子进程继承的方式实现自身的全局性,系统的环境变量具有全局性

内建命令

是什么

  • 内建命令是在 Shell 本身内部实现的,直接在当前的 Shell 进程中运行,这些命令能够直接访问和操作 Shell 的内部状态
  • 不会创建子进程,执行风险低,右bash自己执行,可以看做bash内部的一个函数
  • 常见的内建命令:echo pwd export set unset

启动时环境变量如何导入

  • 系统启动程序的时候,给进程提供两张表1.命令行参数表2.环境变量表
  • Shell 会按照预定义的顺序加载和执行配置文件中的脚本

用户级配置文件

  • ~/.bashrc
  • ~/.bash_profile
  • ~/.profile
  • ~/.bash_logout

系统级别的配置文件

  • /etc/profile
  • /etc/bash.bashrc
  • /etc/environment
  • /etc/profile.d/*.sh

★程序地址空间

 验证地址分布

    1 #include <stdio.h>
    2 #include <stdlib.h>
    3 
    4 const char* str = "hello";
    5 int init_glo = 0;
    6 int uninit_glo;
    7 
W>  8 int main(int argc, char* argv[], char* env[])
    9 {
   10     static int c;
   11     static int d = 1;
   12     int* ptr = (int*)malloc(100);
   13     printf("code addr:%p\n", main);
   14     printf("only read char addr:%p\n", str);
   15     printf("init gloabl addr:%p\n", &init_glo);                                        
   16     printf("init gloabl addr:%p\n", &d);
   17     printf("uninit gloavl addr:%p\n", &uninit_glo);
   18     printf("uninit gloavl addr:%p\n", &c);
   19     printf("head addr:%p\n", ptr);
   20     printf("stack addr:%p\n", &ptr);
   21     for(int i = 0; argv[i]; i++)
   22         printf("argv[%d]:%p\n", i, argv[i]);
   23     for(int i = 0; env[i]; i++)
   24         printf("env[%d]:%p\n", i, env[i]);
   25 }

struct mm_struct部分成员 

  • 结论堆栈相对而生,堆和栈中间有一大块空间

进程地址空间的相关概念

  1. 是一个内核数据结构,内核结构体,始终驻留在物理内存
  2. tesk_struct通过mm_struct类型的指针来管理进程地址空间
  3. 这个内核数据结构有一个指向页表的指针
  4. 通过区域划分进程地址空间

进程地址空间的目的

  1. 更好的管理每一个进程,为每个进程提供一个独立的虚拟内存视图,以便OS更有效的管理内存资源,同时提供内存保护机制,防止一个进程无意中修改另一个进程的内存数据
  2. 并不存储数据,但是划分区域,是为了让程序以一个统一的视角看待数据的分布,让每个进程都能认为自己拥有独立、连续的内存空间;不然要是真放在物理内存里,放不下!
  3. 每个进程都有自己独立的进程地址空间,使得每个进程可以独立运行,不需要担心内存地址的冲突,不仅提高了系统的安全性和稳定性,还允许多个进程同时使用相同的虚拟地址而不冲突

页表

  • 程序地址空间虚拟地址与实际物理地址映射
  • 访问权限字段存在于每一个映射条目,比如说代码区的映射就是只读权限,如果对这个区域的内存发生一个写的信号,那么就会报段错误,很好的安全控制
  • 内存是可以随意读写的,但是进程要访问的时候加了读写的控制条件
  • 页表在物理地址
  • CPU的CR3寄存器用于页表的地址

进程的独立性

  • 进程的独立性在操作系统中得到了多层次的保障
  • 在系统层面上因为每一个进程都有自己 独立的内核数据结构
  • 因为有了地址空间的存在,让不同的进程经过各自页表映射到物理内存的不同处,来支持进程独立性的特点
  • 在进程运行的时候,被映射访问的物理内存也是独立的

问题

fork的相关问题

1.通过代码fork子进程时候,为什么一个地址会有不同的值

  • 因为这个地址是虚拟地址,并不是物理内存上的地址

2.那为什么会有一样的虚拟地址?

  • 因为子进程拷贝父进程的时候,虚拟地址空间和页表也要拷贝,然后修改 个性的数据

2.那么虚拟地址怎么找到物理地址呢?

  • 每一个进程都有自己的页表,通过页表映射就可以 找到 映射到的物理内存上的内容

3.32位下虚拟地址空间有4G大小?如果每个进程都有,那内存怎么放的下?

  • 如其名:虚拟;所以,它并不是一块真正的空间,而是用一个结构体,来描述这个空间;

4.为什么要有这个空间?

  • 因为物理内存给不了你这么多空间,每个进程都要有,那能跑几个进程?所以通过这个方式,不仅能够更好的管理每一个进程,也能以统一的视角看待内存,且因为映射到的物理内存不会同时重叠(纠正,可以重叠只读的,比如代码区),更好的保证了进程的独立性

5.子进程拷贝了页表,那么物理内存不就重叠了吗?

  • 是的!是重叠了,但是子进程如果要修改,会在物理内存中再开辟一块空间,然后修改这快空间上的内容----这就是写实拷贝,又体现了进程的独立性!

6.所以 在pid_t id = fork();fork函数在return的时候也发生了写实拷贝

为什么要有地址空间+页表

  • 让进程统一的以有序的视角看待有序(虚拟地址空间),实际无序分布的物理内存

为什么页表在物理地址?

  • 我觉得只有程序地址空间里的是虚拟地址;但程序地址空间也是在物理地址里的
  • 我觉得能放物理内存就放物理内存,其实都是放在物理内存里的,可能会被这虚拟这个词带偏,
  • 如果页表是虚拟的,也就是说有另一个结构来描述页表,那么这个结构也要在内存上啊,页表也在内存上啊;页表自己能描述清楚,所以不需要通过别的结构来描述
  • 但其实问题的答案早早给出:程序要运行的时候会将代码和数据从磁盘拷贝到内存
  • 好像不对啊,你那个二进制文件里有页表??有虚拟地址空间??这是OS创建的啊!!没错,这些都是内核数据结构,用来描述这个二进制文件的

CPU的CR3寄存器里有页表的地址,可是我问了GPT虚拟地址空间里也有页表的地址,那么CPU在运行的时候这个CR3里的页表地址是去进程的上下文(内核栈)还是地址空间里拿呢?

  • 从设计的角度思考,程序地址空间这个结构里确实有一个指向页表的指针,那么如果上下文里也有,是不是有点多余了
  • 没错,是多余了,且上下文里并没有页表的指针
  • 那么进一步猜测,上下文更多的是保存进程每一次时间片运行后的动态的信息

如果exe非常大,内存无法一次性加载完,怎么办?(需要补充设计虚拟内存管理和分页技术)

  • OS会多态的在内存中不断地申请内存,加载局部程序,重新再页表里构建映射,就可以让程序边加载,边执行

如果一个虚拟地址要被访问了,但是没有分配物理内存空间,没有代码和数据,这时候怎么办?

  • 话有点挂起的意思,但是Linux里没有这个状态
  1. 当发生页面缺失,CPU 会触发一个缺页中断,暂停这个进程,并将控制权交给操作系统内核
  2. 内存分配:操作系统首先检查这个虚拟地址是否有效,然后为该虚拟地址分配物理内存
  3. 从磁盘加载数据:如果这个页面对应的数据已经存在磁盘上(例如,之前被换出到交换区),操作系统会将相应的数据从磁盘加载到分配的物理内存中
  4. 更新页表:操作系统更新页表,将这个虚拟地址映射到新分配的物理内存地址,并设置相应的标志位为有效
  5. 恢复进程执行:完成页面加载后,操作系统恢复进程的执行,进程可以继续从暂停的位置执行,访问刚刚加载到内存中的数据
  • 这个过程不就是等待资源加载到内存中吗?
  • 一个进程试图访问某个虚拟地址时,如果该地址没有映射到物理内存,就会发生页面缺失(缺页中断)
  • ★模块解耦:进程管理和内存管理通过虚拟内存和页表的存在实现了解耦。进程管理关注的是进程的调度和执行,而内存管理则处理具体的内存分配和映射

页表映射是什么时候构建的?

  • 二进制文件加载到内存后,操作系统为每个进程分配虚拟地址空间,并通过页表将虚拟地址映射到物理内存
  • 初步映射:在进程初始化(加载二进制文件)时,操作系统会为进程的关键段(如代码段、数据段、初始堆和栈)分配虚拟地址,并创建初步的映射。这个映射是在进程启动时建立的,但通常只是部分虚拟地址空间的映射。

  • 按需映射:大部分的映射是在进程运行时按需建立的。当进程试图访问尚未映射的虚拟地址时,操作系统通过缺页中断机制分配物理内存并更新页表来完成映射。

  • 映射的构建既发生在二进制文件加载时,也在进程运行时通过按需映射进行扩展

进程在运行的时候,会将进程地址空间即mm_struct加载到CPU里吗?

  • 我觉得需要考虑的是目的,任何一个操作都是有目的的:这里的目的是为了拿到实际的数据,实际的数据是在映射的物理地址里;
  • 所以我们只需要知道进程地址空间的地址,就可以通过虚拟地址,来找到对应的物理地址,物理地址没有是另一回事,进程地址空间不被这个锅
  • 反思:我一直搞错了,我以为内核空间,用户空间是对CPU的划分,导致会有这个问题;其实CPU要运行一个进程,就是去内存里找他的数据

那么PCB也在内存里?

  • 是的,不仅仅是PCB,内核数据结构 都存储在物理内存中的
  • 其实进程在CPU上运行,并不是把task_struct放到CPU上,而是 task_struct的数据
  • 之前说的,运行状态是PCB加载到内存里,不算是不够严谨,就是没理解到位

程序地址空间,进程地址空间,虚拟地址空间的区别

补充知识 

空间概念

数据的01在计算机中通常用高低电频来表示,CPU的寄存器、内存对应的硬件上有触发器,这样的硬件单元可以实现充放电;所以可以想象成电池,有电代表1,没电代表0,一个字节8个电池,本质就是充放电的过程,电池之间用线连起来;这样就以进行数据和信号的交互

C/C++地址

  • &a[0] < &a[9];1.在栈上向下(低地址)申请连续的空间,但是向上使用;所以数组的地址,也是这块区域中地址最低的地址;指针的++正好就是地址的++
  • struct str{a, b ,c}test;&test.a<&test.b<&test.c;和数组的规则一样
  • int a;  &a;这个a的地址是这四个字节里最低的地址
  • static修饰的局部变量,编译器会将其编译为全局变量,在已初始化的全局数据区
  • char* str = "hello";这个会报警告,就是告诉要加上const,防止后序被更改;str指向的字符串在字符常量区与代码区较近,所以在编译时,字符常量区就被编译到代码区里,而代码区是不能写入的;但我更倾向GPT:字符常量区通常与代码区相邻,都是只读的内存区域;;;如果尝试修改,通常会导致段错误(segmentation fault)或类似的运行时异常。
  • C语言中,栈区,向下开辟空间,向上使用,堆区呢,别的区呢
  • 类型的本质:偏移量+起始地址的形式访问任何对象

C/C++中变量的概念

粗略:在编译后形成的可执行文件在系统中就没有变量名的概念,变量名会转变为地址;变量名是程序员看的

vm_area_struct()

  • 可以划分更多的子区域,每个区域都有虚拟地址可以映射
  • 和mm_struct共同构成地址空间
  • 以一个链表或红黑树的形式组织,每一个节点都是一个vm_area_struct

共享区()

  • 动态库加载,进程之间通信,匿名映射

写时拷贝

过程

  1. 当父进程使用 fork() 系统调用创建子进程时,操作系统会复制父进程的页表,而不是实际复制物理内存中的数据。这样,父进程和子进程共享相同的物理内存页;;操作系统将这些共享的内存页的权限设置为只读(包括本来可写的页)。这是为了确保一旦任意进程尝试写入这些共享页,写时拷贝机制能够生效
  2. 子进程或父进程中的代码可能会尝试向这些共享的内存页进行写操作,由于这些页的权限被设置为只读,写操作会导致一个页错误
  3. 操作系统介入:检查是否真出错,尝试写入不可写的内存区域,则视为真正的访问错误,操作系统将终止进程并发出信号;如果该页是因为写时拷贝而只读的并且写操作是合法的,操作系统会为触发写操作的进程分配一个新的物理内存页,然后将原内存页的内容复制到新页;更新页表,将这个新页的地址映射到进程的虚拟地址空间,并设置为可写

优点

  1. 写时拷贝机制保证了只有在需要时才会进行内存复制,节省了系统资源

问:为什么要拷贝,反正都是要修改,申请后修改就行了啊?

  • 可能只改变其中的一部分数据,大部分不用改

字符常量区

  • 常量字符串(字符串字面量)在编译时被存储在只读数据段.rodata),也就是只读的内存区
  • 常量字符串不一定需要显式地加上 const 关键字,但它们本质上是不可修改的,所以通常应该将指向它们的指针声明为 const。如果不使用 const 修饰符,程序仍然能够编译并运行,但尝试修改常量字符串会导致未定义行为,通常会导致程序崩溃(比如段错误)

静态变量

  • 在一个函数内定义的静态变量是局部于该函数的,其他函数不能直接访问
  • 函数内定义的静态变量在再次调用该函数时不会再被赋初始值。静态变量在程序的生命周期内只会初始化一次

缺页中断

原因

  • 写时拷贝机制中,内存页最初是共享的并被标记为只读。当进程尝试写入这些页时,触发了硬件级的保护机制,导致缺页中断

主要两种情况

  1. (没有页)没有对应的物理内存页:当进程访问一个虚拟地址,该地址在页表中没有对应的物理页映射时,会发生缺页中断,这通常意味着该页尚未被分配物理内存,或者该页可能被换出到磁盘上(即页表中对应的页不在物理内存中,而在交换区中)
  2. (页不够)试图写入只读页(写时拷贝情况)

软硬缺页

  • 目前觉得就是,是否有从磁盘或外部存储中加载数据到内存中

fork(待补充exec)

常规用法

  • 是父子进程调用不同的代码段
  • exec待补充

失败的原因

  • 系统中有太多进程,内存资源不足
  • 用户进程数量超过了限制

进程终止

main函数的返回值

  • mian函数也是一个函数,被一个函数调用,return值返回给这个函数
  • ./mybin:例:在命令行中启动一个程序,会将这个进程的退出码(返回值)给bash
  • main函数的返回值叫进程的退出码;0(success);
  • !0(failed),不同的数字代表不同的原因

错误码

for(int i = 0; i < 134; i++)
    printf("%d: %s\n", i, strerror(i));
  •  errno是一个全局变量(通过errno.h引入),用于存储最近一次系统调用库函数执行失败时的错误代码,错误时会将错误码设置到errno中
  • 可以通过strerror将错误码转换为错误描述

退出码

  • echo $?:Shell自动维护的特殊变量,可以打印出最后一个子进程执行完毕后的退出码(echo是内建命令)

代码异常终止(信号详解)

  • 出异常时不看错误码,因为异常机制本身已经传递了足够的信息来描述错误情况
  • 程序崩溃:进程调度中出现异常,异常信息会被OS检测到,OS通过发送信号的方式杀掉、终止(释放)对应的进程
  • 异常通常是程序员自己写出来的
  • 一个进程是否出现异常,看有无受到信号
  • 父进程通过信号数字退出码来判断子进程任务完成的怎样

exit

  • exit(int status):status 进程的退出码
  • 等价于在main函数中直接return,任意地点调exit,表示进程退出,后序代码不执行,直接终止进程
  • exit时候,会将冲刷缓冲区
  • exit 函数的底层封装了 _exit 系统调用接口

_exit

  • 是一个系统调用接口,直接与操作系统内核交互
  • _exit时候,不会冲刷缓冲区,内核之上是系统调用接口,说明缓冲区不在内核中
  • ★是一个底层的、直接与内核交互的系统调用,它的目标是立即终止进程,而不是进行任何用户空间的清理工作

进程等待

是什么

  • 通过wait/waitpid,让父进程对子进程进行资源回收的等待过程

为什么要等待

  • 解决子进程的僵尸问题带来的内存泄露
  • 子进程要将父进程给的任务的完成结果(即,子进程的退出信息->进程的退出码,信号编号)返回给父进程
  • 简:回收子进程资源;获得进程的退出信息

wait
 

void worker()
{
    int cnt = 3;
    while(cnt--)
    {
        printf("child process, pid:%d, ppid%d\n", getpid(), getppid());
        sleep(1);
    }
}

void creatSubProcess()
{
    pid_t id = fork();
    if(id == 0) 
    {
        worker();
        exit(0);
    }
    else
    {    
        sleep(6);    
        pid_t rid = wait(NULL);
    
        if(rid == id)    
        {                                                                                    
            printf("wait success:pid: %d, rid: %d\n", getpid(), rid);    
        }    
        sleep(3);    
    }   
}

 监控脚本

  • 父进程结束的时候,会自动回收子进程,但是也可以在父进程运行中途回收
  • pid_t wait(int *status); status:指向整数的指针,用于存储子进程的退出状态信息
  • wait是一个系统调用接口;声明在 unistd.h;相关的宏和数据类型在 sys/wait.h
  • wait能够回收处于僵尸状态的子进程;如果执行到父进程执行到wait这行代码时,子进程没退出,父进程会在wait这行代码上阻塞等待,知道子进程运行完毕,wait会对其回收;是一种等待软件资源的阻塞,一个进程 等待另一个进程执行完成
  • 僵尸进程无法被信号杀掉,只能通过父进程回收

waitpid

void creatSubProcess()    
{    
    pid_t id = fork();    
    if(id == 0)    
    {    
        worker();    
        exit(0);    
    }    
    else    
    {    
        sleep(6);    
        int status;    
        pid_t rid = waitpid(id, &status, 0);                                               
    
        if(rid == id)    
        {     
            printf("wait success:pid: %d, rid: %d, exit sig: %d ,exit code: %d\n", getpid(), rid, status&0x7F, status>>8&0xFF);        
        }    
        sleep(3);    
    }  
}
  • pid_t waitpid(pid_t pid, int *status, int options);
  • pid:-1:等待为任意一个子进程;不是则为指的的子进程
  • 如果是多个子进程,那么就要回收多次;若是随机回收,回收的顺序由调度器决定,与创建子进程的顺序无关

status

  • 输出型参数
  • 如果异常了,退出码就没用了;实测:子进程中途异常,exit code 为0

扩展

父进程如何得知子进程的退出信息?

  • 父进程调用系统调用接口来获得子进程的退出信息
  • 1.父进程给waitpid传status变量的地址 2.子进程结束后,代码的退出信息会回写到task_struct中
  • 之后再按照一定的方式回写给status  3.将子进程的exit_state改为X(X肯定是一个宏)
  • 我觉得回收资源是最重要的一点,这样可以创建更多的子进程帮我们完成任务;其次就是任务完成的结果
  • 父进程等待子进程的过程中,被链入子进程的等待队列;task_struct 本身不直接包含等待队列(wait_queue)成员

进程阻塞,大多数情况是在阻塞队列                                          

WIFEXITED(status)

  • #define WIFEXITED(status)   (((status) & 0x7F) == 0)
  • W:wait  IF:是否  EXITED:退出
  • 一个宏,用于检查一个子进程是否正常退出,返回非零值时,表示子进程通过 exitreturn 语句正常退出
  • 大概率是用位运算的方式计算出次8未是否为0

WEXITSTATUS(status)

  • #define WEXITSTATUS(status)   (((status) >> 8) & 0xFF)
  • W:wait  EXIT:退出  STATUS:状态  
  • wait/waitpid的方式来等待的子进程的退出状态    其由退出码呈现
  • 用于获取子进程的退出状态码,只有在 WIFEXITED(status) 返回非零时,才能使用 WEXITSTATUS(status) 宏来获取子进程的退出码

为什么不用全局变量获得子进程的退出信息

  • 写实拷贝...进程间是相互独立的,父进程无法直接拿到子进程的数据,要通过系统调用获得

waitpid第三个参数

参数为0

  • 阻塞等待

参数为WNOHANG

  • W:wait     NO:不   HANG:应用夯注(宕机,OS挂掉)
  • 以非阻塞的形式等待子进程,即等待的时候不夯住
  • 一般配合轮询使用,子进程结束时不会阻塞,而是立即返回,做父进程自己的事情

waitpid的返回值 

  • rid > 0 等待成功,且获得被等待进程的pid
  • rid == 0 有需要被等待的子进程,但是对方还未退出
  • rid < 0 等待失败,比如:指定等待的子进程的pid写错了

模拟非阻塞等待轮询逻辑

#include <stdio.h>    
#include <unistd.h>    
#include <stdlib.h>    
#include <sys/types.h>    
#include <sys/wait.h>    
    
#define MAX_WORKER 5    
typedef void (*work)();    
    
void father_work1()    
{    
    printf("father doing work1\n");    
}    
    
void father_work2()    
{    
    printf("father doing work2\n");    
}    
    
void father_work3()    
{    
    printf("father doing work3\n");                                                                                                                                               
}    
void worker()    
{    
    int cnt = 3;    
    while(cnt--)    
    {    
        printf("child process, pid_t: %d, ppid_t: %d, cnt: %d\n", getpid(), getppid(), cnt);    
        sleep(1);    
    }    
}    
void initArray(work array[])    
{    
    for(size_t i = 0; i < MAX_WORKER; i++)    
        array[i] = NULL; 
}

void addWork(work array[], work w)
{
    for(size_t i = 0; i < MAX_WORKER; i++)
    {                                                                                                                                                                             
        if(array[i] == NULL)
        {
            array[i] = w;
            break;
        }
    }
    return;
}

void doingWork(work array[])
{
    for(size_t i = 0; i < MAX_WORKER; i++)
        if(array[i]) array[i](); //(*array[i])();也可以
    //*array[i]这只是解引用函数,() 这才是调用函数
}
int main()
{
    work array[MAX_WORKER]; 
    initArray(array);
    addWork(array, father_work1);
    addWork(array, father_work2);
    addWork(array, father_work3);
    pid_t id = fork();
    if(id == 0)
    {
        worker();
        exit(1);
    }
    else
    {
        while(1)
        {
            pid_t rid = waitpid(id, NULL, WNOHANG);
            if(rid > 0)
            {
                printf("wait success\n");
                break;
            }
            else if(rid == 0)
            {
                printf("father doing other thing\n");
                doingWork(array);
            }
            else
            {
                printf("wait failed\n");
                break;
            }
            sleep(1);
        }
    }
    return 0;
}

进程的程序替换

        让子进程执行新的程序,执行全新的代码和数据

单进程版本的程序替换

#include <stdio.h>    
#include <unistd.h>    
    
int main()    
{    
    printf("before exec\n");    
    execl("/usr/bin/ls","ls", "-l", "-a", NULL);                            
    printf("after exec\n");    
    return 0;    
}   

理解和掌握程序替换的原理

  • 程序替换:OS为新程序的代码和数据分配一块新的内存空间,旧的内存空间将被释放或标记为无效,从而不再被当前进程使用,调换之后执行新程序的代码和数据,所以原有进程的后序代码不会执行;改变的是内存部分,进程管理不变,所以pid不变

父子关系中

  • 这时父子进程不仅仅是数据结构层面上的解耦在代码和数据上也实现了解耦

接口都需要解决两个问题

  1. 找到这个可执行文件
  2. 告诉这个接口怎么执行

程序加载的步骤

execl

  • int execl(const char *path, const char *arg, ...);
  • l:以列表的形式
  • path:是一个字符串,指向要执行的程序的路径。路径可以是相对路径或绝对路径。
  • arg:这是传递给新程序的第一个参数,通常应该是新程序的名称;也是命令行参数的的argv[0]
  • 后续参数:这些是传递给新程序的命令行参数,每个参数都是一个字符串,参数以NULL结束。这些参数会作为命令行参数传递给新的程序
  • 返回值:如果 execl 调用成功,当前进程的映像将被新程序替换,因此 execl 不会返回。如果调用失败,它会返回 -1,并设置 errno 以说明失败的原因;失败了就会执行后序程序

execlp

  • int execlp(const char *file, const char *arg, ...);
  • p:PATH,会自动去环境变量PATH中根据file名去寻找
  • execlp("ls","ls", "-l", "-a", NULL); 两个并不重复,第一个是根据文件名找到对应的可执行文件,第二个是怎么执行
  • 路径写全也能跑,代码健壮性

execv

  • int execv(const char *path, char *const argv[]);
  • v:vector,以数组形式传参
  • 这里的警告是因为传的是const char,但是实际上要的是char

execvp

  • int execvp(const char *file, char *const argv[]);
  • execvp("ls", argv);

execpve

  • int execvpe(const char *file, char *const argv[],
                       char *const envp[]);

1.传该进程原有的环境变量

2.覆盖式传

★我在测试的时候发现很奇怪,怎么不输出编号?

★argc=1为什么会打印出两个?

  • 因为我环境变量PATH下刚好就有一个printenv,正好我取的名字和他一样
  • 如果函数中有p对应的参数名为file,其实这个file指的就是PATH环境变量下有的程序,自己写的不能直接用名字,必须要带上绝对或者相对路径
  • 所以就奇怪了,出现,能跑,好像又不能跑

execve

  • int execve(const char *filename, char *const argv[],
                      char *const envp[]);
  • 这是个系统调用
  • 另外6个函数底层都是通过封装execve来实现的,是最基础的系统调用,负责执行新程序并替换当前进程,而其他函数只是对其进行了不同的封装以简化使用

3.添加环境变量

这样添加不了,也是,怎么putenv是添加到原有进程的env里,所以是添加到environ里面

程序替换的本质

程序替换可以将命令行参数,环境变量通过传参数给被替换程序的main函数中;即在被替换程序中,main 函数可以接收传递的参数和环境变量

exec 系列系统调用的本质是一个加载器,负责将磁盘上的可执行文件加载到当前进程的内存空间中,并准备该进程以执行新程

execl("/usr/bin/bash", "bash", "test.sh", NULL);

execl("./mybincc", "mybincc", NULL);

发现可以调用的不仅仅是指令,C++或者shell脚本都可以

OS是怎么把可执行程序加载到内存里的?

  • exec本质是一个加载器的功能,帮我们把磁盘中的程序加载到内存里
  • 创建进程时先创建对应的内核数据结构,然后再是数据和代码被类似exec代码加载进内存,

模拟实现shell

#include <stdio.h>    
#include <string.h>    
#include <stdlib.h>    
#include <unistd.h>    
#include <sys/types.h>    
#include <sys/wait.h>    

#define COMMAND_LEN 1024    
#define ARGV_LEN 128     
const char* SEP = " ";
//#define Debug 1 //测试是否正确分割进argv    

char pwd[1024];

char* getUSER()
{
    char* ret = getenv("USER");
    if (ret)
        return ret;
    else return NULL;
}
char* getHOSTNAME()
{
    char* ret = getenv("HOSTNAME");
    if (ret)
        return ret;
    else return NULL; //这样不好,可能会发生空指针的解引用    
}
char* getPWD()
{
    char* ret = getenv("PWD");
    if (ret)
        return ret;
    else return NULL;
}
void printCommand()
{
    printf("[%s@%s %s]# ", getUSER(), getHOSTNAME(), getPWD());
}
int getCommand(char* command, char* argv[])
{
    //scanf("%s", command); 有空格就不行,只能输入一个字符串
    fgets(command, COMMAND_LEN, stdin);
    //分割字符串
    command[strlen(command) - 1] = '\0';
    int argc = 0;
    argv[argc++] = strtok(command, SEP);
    while (argv[argc++] = strtok(NULL, SEP));
#ifdef Debug
    for (size_t i = 0; argv[i]; i++)
        printf("%d: %s\n", i, argv[i]);
#endif
    return argc;
}
char* getHomePath()
{
    char* homepath = getenv("HOME");
    return homepath; //和老师不一样,我觉得直接return
}
void cd(char* path)
{
    chdir(path);//更改当前的进程的工作目录
    char* tmp[1024]; //sizeof是应该运算符
    getcwd(tmp, sizeof tmp);//获取当前进程的工作目录
    sprintf(pwd, "PWD=%s", tmp);//通过覆盖PWD环境变量的方法更改命令提示符
    putenv(pwd);
}
int fatherExcuteCommand(char* argv[])
{
    if (strcmp(argv[0], "cd") == 0)
    {
        char* path = NULL;
        if (argv[1] == NULL) path = getHomePath();
        else path = argv[1]; //这也是要检查的,这里略写
        cd(path);
        return 1;
    }
    else if (strcmp(argv[0], "export") == 0)
    {
        if (argv[1] == NULL) return 1;
        putenv(argv[1]);
        return 1;
    }
    else return 0;
}
void childExcuteCommand(char* argv[])
{
    pid_t id = fork();
    if (id == 0)
    {
        execvp(argv[0], argv);
        //错误执行exit
        exit(1);
    }
    else
    {
        pid_t rid = waitpid(id, NULL, 0);
        //if(id == rid)
    }
}
int main()
{
    while (1)
    {
        char command[COMMAND_LEN];
        char* argv[ARGV_LEN];
        //打印命令行
        printCommand();
        //读取输入,即命令行参数
        int n = getCommand(command, argv);
        if (n == 0) continue;//输入回车,重新跑

        //father执行内建命令
        n = fatherExcuteCommand(argv);
        if (n) continue;//内建命令

        //father fork child 执行命令
        childExcuteCommand(argv);
    }
}

注意:

  1. 内建命令需要父进程自己执行,如果让子进程去做是看不到效果的,且达不到目的

问题

  1. 发现有些时候,导入的环境变量会变成别的指令的一部分?
  2. 这是因为我们导入的环境变量是command数组里面的一部分
  3. 所以用env可以查到,因为env覆盖不到export后面的部分
  4. 但是如果下一次输入的指令太长,使得那块空间被覆盖,导致从相同地址读取会不对
  5. 其实这个地方已经是非法空间,但又是合法空间,这是因为我们开了COMMAN_LEN个空间
  6. ??难道每次的command地址都一样??是的,我使用gbd查看了,可是为什么呢?不应该要被释放吗?难道这块空间没有被释放,不会随着每次while进来开辟?
  7. 答:循环每次迭代确实会销毁局部变量并重新分配,但是每次分配的地址往往是相同的,这现象是由于栈内存的管理机制导致的
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值