Linux之进程
- 一.进程
- 进程之形
- ps命令
- 进程状态
- 特殊进程
- 孤儿进程
- 守护进程
- 进程创建之创建子进程
- 进程特性
- 优先级
- 进程切换(分时操作系统)
- 二.环境变量
- 三.进程地址空间
- 四.进程终止&进程等待
- 五.进程替换
- 六.自定义shell
本篇博客希望简略的介绍进程,重点放在进程控制。默认本文定位于Linux操作系统。
在此之前,你应该理解到管理的重要性,并认识到管理的本质是 先描述再组织,先用某种形式,比如类或结构体去描述一个对象,再用某种方式,比如特定的数据结构去组织它。
一.进程
进程之形
进程是指程序运行起来后,它的内核数据结构+程序的代码和数据。程序文件通常位于磁盘,需要先把代码和数据加载进内存才能运行起来,同时os为了方便管理大量进程,也会在内存的内核区创建一种结构体(统称PCB,process control block,即进程控制块,具体在Linux下是task_struct结构体),并用某种数据结构(Linux下是特殊的双向链表)组织起来。
PCB其实就是保存了进程的各种信息属性,通常有如下成员
- 标识符(进程id),对应pid,进程的名字,标识进程唯一性
- 状态,各种进程状态,见下文
- 优先级
- 程序计数器,将要执行的下一条指令的地址
- 内存指针,指向程序代码、共享内存块等
- 上下文数据,通常☞寄存器里的数据
- 。。。
下面搂一眼Linux内核代码中task_struct 的实现之冰山一角
PCB中没列完的成员可以参考sched.h中的task_struct的成员实现。
不得不提一个设计技巧:task_struct(简称ts)所谓的特殊的双向链表,是指ts内部定义了一个
struct list_head tasks;
而list_head的定义内部struct list_head { struct list_head *next, *prev; };
包含两个指针,分别指向前后的ts内部的list_head部分,然后就可以通过计算偏移量(或者c语言的offset宏)找到ts内其他所有成员。图解如下:
这样设计的好处是,list_head既属于ts,又属于双向链表,未来还可以属于其他结构体,而且ts内部还有诸多list_head成员,完美实现复用。如果是传统双链表,把ts内部的list_head换成两个ts*的指针,就没有这样的效果了。
又不得不提一个设计技巧了:进程结束,ts要释放时,选择不释放,而是把这些ts组织起来,有新进程创建就拿一个给它用,免去释放和新建ts的额外资源开销。称为slab。
ps命令
进程信息查看命令ps/top
使用ps -axj | grep hello
查看程序hello的进程数据,并使用while :; do ps -axj|head -1&&ps -axj|grep hello|grep -v grep; sleep 1; echo '####################'; done
持续监控hello进程情况。也可top。
/proc目录是记录进程信息的,其下根据pid即可搜到指定进程hello,ll一下即可发现有cwd,即current work directory,是个指向程序工作时路径的链接。而exe就是指向进程对应程序的链接。
cwd是程序运行时所在路径,也就是fopen时相对路径的省略部分。可以通过chdir更改cwd。
进程状态
- 标准的进程状态,也就是教科书上的,基本采用五态模型,新建、就绪、运行、阻塞、终止(、挂起等)。划分过于细致,不予赘述。
- 这里我采取简单基本的三态模型,运行、阻塞、挂起。
- 运行,排在调度队列里的进程状态。1个CPU内会维护一个调度队列,物理结构是双链表,逻辑结构是队列,优先级高的自然也就排在前面,调度进程时从链表头/队头开始挨个运行。
- 阻塞,等待某种设备或资源就绪而进入等待队列的进程状态。Linux下一切皆文件,它把各种IO设备诸如磁盘、键盘、显示器、网卡等都用struct去描述,并保存成文件。那么从磁盘/键盘读取输入比如scanf和向显示器输出数据都需要等待设备文件加载就绪以及scanf引起的等待用户输入,这时,os就会把该进程的PCB移出调度队列,移进该设备的等待队列(由设备的struct内list_head组成的双链表维护)。等硬件中断通知os再调回来运行。
- 挂起,内存严重不足时os为了释放内存把等待队列里的进程甚至调度队列靠后的进程的代码和数据,从内存换出暂存到磁盘上的swap分区,等内存不足缓解时再将其代码数据换入内存,换出后的这种内存里只有PCB,却不见其代码与数据,因为它们到了swap分区的状态便是挂起。阻塞队列里的进程挂起称为阻塞挂起,而调度队列的进程挂起称为运行挂起。
- Linux下进程状态的具体实现:
先上源码,
static const char *task_state_array[] = {"R (running)", /* 0 */"S (sleeping)", /* 1 */"D (disk sleep)", /* 2 */"T (stopped)", /* 4 */"T (tracing stop)", /* 8 */"Z (zombie)", /* 16 */"X (dead)" /* 32 */
};
-
R (running),运行状态,很好实验。但注意频繁IO会更多地显示成S,因为等设备就绪时间占比更大。
-
S (sleeping),阻塞状态,全称应该叫可中断睡眠,即浅睡眠。阻塞期间会响应信号,可被杀死。
-
D (disk sleep),也是阻塞,全称不可中断睡眠,即深度睡眠。阻塞期间不会响应信号,不可被杀死,也就是说只有等该进程完成或计算机断电才能杀死。这是为了保护对关键数据设备如磁盘的访问,防止重要数据丢失。
好玩的命令:
dd if=/dev/zero of=~/test.txt bs=4096 count=10000
从zero读取10000次4096大小的数据到test文件里。/dev/zero是个伪设备,提供无限量的零字节!危险,但可能出现D状态。
-
T (stopped),暂停,可以ctrl+z手动暂停进程,也可以发19号信号(再用18信号恢复)搞出T效果。通常是非法操作等导致os暂停了进程,这是os怀疑进程出问题但是交给用户决定是否让其继续运行。一个场景是守护进程向显示器输出。
-
t (tracing stop),特殊用途,基本上用于debug模式下断点引起的暂停进程。
-
Z (zombie),进程退出后未被父进程回收获取时的状态。进程退出时,代码和数据会立即被释放,但是子进程的信息都存放在task_struct里不能被释放直到被父进程获取到,还有退出码等信息等待父进程获取,毕竟子进程是父进程创建出来办事的,父进程要知道它办咋样。僵尸进程的PCB占用内存资源不释放,造成内存泄漏问题。注意,程序内malloc出来的随进程结束而被os释放。
defunct表示死亡的 -
X (dead),终止状态,难模拟。
进程状态后跟个+,表示前台进程。./hello &运行则使之成为后台进程,不会占用命令行。
特殊进程
孤儿进程
父进程退出,还在运行的子进程称为孤儿进程。实验代码是创建子进程部分代码让父进程直接退出即可。
1号进程是谁?(图中是,init进程新版本内核1号进程叫systemd)
可以认为是os,(0号进程在启动时被替换为1号)也就是说,父进程死后,孤儿进程会被系统领养,并且成为后台进程。
守护进程
后面补充
进程创建之创建子进程
通过系统调用(system call)fork函数创建子进程。
fork函数内部os已经创建出一个子进程,函数结束给父进程返回子进程的pid,给子进程返回0。
实验代码:
#include <stdio.h>
#include <stdlib.h>//用于exit
#include <unistd.h>
int main()
{pid_t id=fork();if(id<0)//失败返回-1,设置错误码{printf("fork failed\n");exit(1);}else if(id==0)//child{while(1){printf("我是子进程,%d\n",getpid());sleep(1);}}else if(id>0)//parent,also current{while(1){printf("我是父进程,%d\n",getpid());sleep(1);}}
}
需要理解的一些问题:为什么fork能返回两个值?为什么还是两个不同的值?为什么父进程拿到的是子进程pid而子进程拿到的是0?为什么看起来明明是同一个id却同时走了两个if判断语句?难道1个id变量有两个值?
解析全程:
- 首先fork是个系统调用,fork其实是操作系统内核在操盘,内核切走(专业点叫上下文切换,参见下文进程切换)父进程,内核创建出子进程,内核为新创建的子进程申请一个新的PCB,并复制父进程的大部分状态信息(如寄存器、文件描述符等,当然不包括pid)给子的PCB。
- 内核再把子进程的return值设为0,父进程的return值设为子的pid。
- 内核再恢复父、子进程的执行,当然是从fork结束、return值赋给id开始,然后二者并发执行。
- 从创建子进程开始,子一直都和父共享相同的物理内存页,但这些页面被标记为只读。内核会监控子对这些只读内存的尝试修改。
- 执行到对id赋值,子给id的值不等于父给的值,它(子进程)想修改这个id,但这是只读共享区,内核不让改!而是(内核)再开一块空间存子的id给子用!这叫写时拷贝,平常咱俩共用一块空间,都只读不修改它,那没事。一旦有人想写,再开一块给你用。这样大大的节省了宝贵的内存空间资源。所以两个进程拿到的id就不一样,走不同的if语句,打印不同的语句。
看出来没,一切的一切背后,全是内核的影子。
写时拷贝:它的核心思想是延迟实际的复制操作直到数据被修改,从而节省资源并提高效率。具体来说,当需要复制一份数据时,系统并不会立即创建该数据的独立副本,而是让多个实体共享同一份数据;只有在某个实体尝试修改这份数据时,系统才会真正复制一份专属于该实体的副本。
有趣的点:内核是通过有进程试图修改只读页面从而触发“页面故障”来判定写时拷贝事件,从而为“试图写入者”开新独立内存页,拷贝旧页数据过来,并更新它的内存映射到新页,且新页被设为可写,尔后该进程才可写入新页。那么父进程对只读页面写入也会触发写时拷贝吗?yes,因为内核是根据有进程尝试写入一个被多个进程共享的页面来判定写时拷贝的。那么父就有了新空间了,此时子再写入这个只读页,会怎样?yes,依然写时拷贝,既是因为页面属性,也为了数据隔离,保证进程独立性,而且万一有多个子进程呢,这种方式是最轻松的。
话说回来,创建子进程后,父进程的数据段得从可写改成只读,才能触发页面故障。
进程特性
- 独立性。这主要通过以下几个方面保证:
- 独立的后面补充
- 并行:多进程分别在多个CPU下同时运行
- 并发:多进程在1个CPU下采用进程切换,使其在一段时间内都得以推进。
- 竞争性。CPU资源有限,就有了优先级。
优先级
优先级本质是进程享用CPU资源的先后顺序,而排队是因为资源不足。Linux里,PRI值越低,优先级越高。
可以使用ps -al命令查看进程优先级。
上图中UID是用户标识,os使用UID区分用户。优先级PRI默认值就是80,NI全称nice值,是对PRI的修正。也就是说Linux下进程真正优先级=80+NI。注意每次修改NI值都是对80做调整。NI取值[-20,19],即真正优先级取值[60,99]。有上下限是为了避免进程饥饿,即优先级低的进程长期得不到系统资源。
可以通过 sudo top命令,输r进行renice,再输你想修改的进程PID,再输你想要的nice值。(还有命令行的nice、renice命令,系统调用getpriority、setpriority接口都可更改nice值,具体自己搜)
进程切换(分时操作系统)
当一个进程的时间片用完、等待 I/O 操作完成、被更高优先级的进程抢占等情况发生时,os就会通过调度器切换新进程来运行。进程切换前半部分主要是保存当前进程上下文,后半部分是选择新进程和新进程的加载。
上下文主要包括CPU里各种寄存器的的状态、下文中的页表、文件描述符表还有进程本身各种信息比如状态、调度、信号等。
老进程走时,文件描述符表、页表和本身信息会拷贝进task_struct。寄存器里的数据也拷贝到自己的task_struct里还有内核栈里。(老的x86架构采用TSS,一种独立于PCB的数据结构来保存部分寄存器信息,但现代os基本采用软件调度算法)
关于进程优先级怎么体现在进程调度上见下:
LinuxO(1)调度算法:调度队列struct runqueue里有个成员queue[140],优先级为x的进程的task_struct会被挂到queue[x-60+(140-40)]处,相同优先级就挂成链表,所以这个queue本质是个哈希表。但是为了防止大量空穴导致遍历效率低,还配套了一个用5个unsigned int组成的位图来快速定位不为空的位置。
但还有一个问题,如果高优先级进程死循环阻塞等待比如高IO,回来继续排在低优先级前面,那低优先级不就永远执行不了了吗?
于是runqueue又加了一个queue[140],称为过期进程expired queue队列,上面的称为active queue活跃进程队列。调度完的进程被放入过期进程队列中,只有当活跃进程队列空了才调度过期进程队列。这就保证了相对公平。
来了新进程,放在a队列,还是e队列?理论上在e。但现代os支持内核优先级抢占,插在a队列。
runqueue中还有cpu_load用于记录该cpu负载,以及nr_switches切换次数,用于多CPU并行时保证负载均衡,等成员。runqueue中的prio_array_t结构体包含了上面的queue[140]、bitmap[5]和nr_active三个成员。queue的前100个空间为实时os保留。所以优先级为60的基础应该在下标100处,即60-60+100。
二.环境变量
关于环境变量,我理解不深,这里只能提几个概念和举几个用法,如下
- 首先,main函数是可以有参数的,全参的main函数长这样
int main(int argc,char*argv[],char*env[])
,argc是命令行参数个数,argv是命令行参数表,env就是父进程传过来的环境变量表。所以,我们输入的命令是可以带参数的,这些参数就传给了main函数的argv里,main函数里对参数进行if判断即可实现多功能。所以,命令就是c语言写的可执行程序。
main怎么做到“可变”参数的?其实,Linux下main 函数通常是程序员编写的入口点,但实际的程序入口函数是 _start。多提一嘴,_start 负责初始化运行时环境,并最终调用 main 函数。Bash会将用户输入的命令行参数填进表中,然后启动新进程,并通过CRT和操作系统加载器把两表交给_start。_start会解析命令行参数和环境变量。比如,传给_start的argc是2,那就给main传argv的前2个即可。
- 然后,最常见的环境变量就是PATH了,它是给bash用来搜索命令的默认搜索路径。如果一个程序想运行,不指明路径,只给名字,bash会先去PATH里一个个找叫这个名字的程序,都没找到就报错command not find。命令行使用
echo $PATH
查看环境变量PATH的内容如下,其中:是分隔符
也可以使用命令env,列出所有环境变量名字和内容
- 说结论:用户登录时,每个用户的每个终端会话都有自己独立的 Bash 进程,bash存有两个表,一个是命令行参数表,一个是环境变量表。bash在启动时,先从环境变量配置文件中读取环境变量拷贝给环境变量表,而命令行参数表是执行一个命令前更新一次即可。细说:bash会先去系统级别的、适于所有用户的配置文件/etc/profile里读取,再去读取登录的用户独有的配置文件 ~ /.bash_profile、~ /.bash_login 或 ~ /.profile还可能有~ /.bashrc,具体读哪个自行了解吧。
- 再认识一些其他环境变量,上图中HOSTNAME主机名,SHELL就是外壳程序路径,HISTSIZE是history命令最多存储数,SSH_CLIENT分别是发起 SSH 连接的客户端机器IP、端口号和服务器端口号,SSH_TTY指向当前ssh对应的伪终端设备(很有意思,可以试试往里面输入东西,比如echo hello > $SSH_TTY,自行了解吧),USER和LOGNAME可以粗略理解为当前的用户名,PWD是当前工作目录和pwd命令没直接关系,OLDPWD和PWD相互配合即可实现cd -,HOME是当前用户的家目录,这便是cd/cd ~时实际上切到了HOME路径…
su是允许你以root权限执行命令,但echo $ USER 和$LOGNAME 依然是su前的用户,而su - 则是以root身份重新登录,这两变量会改为root。su root和前者一样不变。su 普通用户会变。
- 修改环境变量,命令行可以直接
PATH=$PATH:/home/lky
,就在原有PATH后加了新路径,注意=是覆盖。而程序里可以使用getenv库接口,传环境变量名,返回其内容。
通过该接口可以实现一些特殊功能,比如只允许某个用户执行的程序:
#include<stdio.h>
#include<stdlib.h>//getenv所在库
#include<string.h>
int main()
{char*user=getenv("USER");if(strcmp(user,"lky")==0){printf("execute success\n");exit(0);}else {printf("not allowed to execute!\n");exit(1);}
}
程序里还可以用全局变量environ来获取
#include<stdio.h>
#include<unistd.h>
extern char**environ;
int main()
{for(int i=0;environ[i];i++){printf("env[%d]->%s\n",i,environ[i]);}
}
当然,通过main参数获取env也可,与上同。
- 新建、删除环境变量,命令行可以
export MYENV=100
导入,unset MYENV
去掉。但是只在当前会话有效,想永久有效,需要修改配置文件。 - 本地变量,bash支持命令行定义本地变量,x=1即可,set查看所有shell变量,包括本地和环境。unset x取消。
- 环境变量有继承性和全局性。这是因为父进程可以通过传参把环境变量表传给子进程,而所有命令行运行的命令(除了内建命令)都是bash的子进程,所以如果不修改环境变量,它们用的都是bash的那一份。而内建命令,则是不创建子进程,由bash亲自执行的动作,比如export,pwd,whoami等。g++命令也是通过继承bash的环境变量表找到库路径。
三.进程地址空间
进程地址空间,程序地址空间,虚拟地址空间,虚拟内存,是同一个东西。物理内存即真实内存。
进程地址空间分布如下
#include<stdio.h>
#include<stdlib.h>
int init_global=1;
int unin_global;
int main(int argc,char*argv[],char*env[])
{int stack1=0;int stack2=0;int*heap1=(int*)malloc(4);int*heap2=(int*)malloc(4);static int sglobal=1;const char* con="abc";printf("env[1]\t\t->%p\n",&env[1]);printf("env[0]\t\t->%p\n",env);printf("argv[1]\t\t->%p\n",&argv[1]);printf("argv[0]\t\t->%p\n",argv);printf("stackaddr1\t->%p\n",&stack1);printf("stackaddr2\t->%p\n",&stack2);printf("heapaddr2\t->%p\n",heap2);printf("heapaddr1\t->%p\n",heap1);printf("uningaddr\t->%p\n",&unin_global);printf("staticaddr\t->%p\n",&sglobal);printf("initgaddr\t->%p\n",&init_global);printf("constaddr\t->%p\n",con);printf("codeaddr\t->%p\n",main);
}
注意,左边是64位的地址,右边4GB是32位机器的虚拟内存最大值。默认使用32位来解释比较方便,但无奈64位的电脑。
其实程序里使用的地址空间,即进程地址空间并不是真实内存地址空间,而是通过页表映射找到真实内存。
首先,进程地址空间怎么管理的?六字真言:先描述,后组织。task_struct里有struct mm_struct*mm成员,它负责描述进程地址空间,下面是mm_struct的定义中的几个成员,(数据段就是已和未初始化数据区)
unsigned long start_code, end_code, start_data, end_data;//分别记录代码段和数据段的首尾
unsigned long start_brk, brk, start_stack;//分别记录整体堆区的首尾
unsigned long stack_vm;//记录栈区的一端
unsigned long arg_start, arg_end, env_start, env_end;//分别记录命令行参数和环境变量的首尾
可见,只要这些指向一个区首尾的变量存好虚拟地址,就可以划分好各个区域的范围。
当然,栈区的一端和堆区的两端是不断变化的,所以需要额外的结构体来动态描述,于是有了struct vm_area_struct * mmap成员,下面是vm_area_struct 的定义中的几个成员,
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address within vm_mm. */
可见vm_area_struct就是指向栈和堆中各块空间的结构体。至此,整个进程地址空间就被描述好了,至于组织依然是链表形式,不予赘述。(mm_struct的初始化自然是在代码数据加载时)
下说页表,虚拟内存映射到真实内存的关键工具。其实基本原理很简单,映射思想。但是页表除了两个地址也有其他成员,比如地址对应的权限,这也是上面提到的“页面故障”和下面要说的“缺页中断”的原理。
缺页中断,在进程尝试访问一个不在物理内存中的虚拟页面时,硬件会触发一个异常,操作系统内核需要处理这个异常,将所需的页面加载到物理内存中。这就允许os加载部分甚至不加载代码数据到物理内存,而进程地址空间和页表都准备好,当加载代码时,就找不到物理内存(具体是页表中有效位为0,表示该页面不在物理内存中),就由硬件触发缺页中断,os内核判断该页面确实应该存在于内存中,此时才加载上来代码数据。这样只有进程运行时才加载代码数据,称为按需加载,节省内存。
再看挂起:os只需把内存中代码数据拷贝到磁盘,页表有效位置0,运行时触发缺页中断再拷回来即可。
页表里还有许多控制位等属性信息,此处不研究,故图中页表只含三部分。
综上,os选择让程序使用进程地址空间,自己管理物理内存和页表。好处如下
- 给上层,即用户提供了统一有序的地址空间,掩盖了物理内存的细节,使用成本降低。
- 由于页表的存在,内存受到有效保护。用户通过虚拟地址试图访问物理内存,这之间页表可以通过页面访问权限和有效位等拦截非法操作。
再看野指针,不就是访问时页表判定访问权限为只读,试图修改,os就崩溃给你看嘛(其实是杀死进程)。
- 节省内存。依然是页表的存在,比如“缺页中断”和写时拷贝引起的“页面故障”都要经过页表完成,它们都让os便于高效管理使用内存。
- 使进程管理和内存管理解耦。这也是进程独立性的一个保证。
后面补充页面概念
四.进程终止&进程等待
进程控制包括进程创建、进程终止、进程调度、进程替换、进程间通信、进程状态管理、信号处理等。到这就1万字了,进程间通信和信号大概是要放到下一篇博客里的。
前面进程创建之创建子进程部分,实际上就是进程的创建。进程状态也说过了,关于进程调度也略提了一嘴。接下来介绍进程终止、进程等待和进程程序替换。
进程终止分3种,1代码运行完毕,结果正确;2代码运行完毕,结果不正确;3代码异常中止。1和2都是正常退出。所谓结果就是task_struct里的int exit_code成员记录进程退出码,通常是被传递过来的main函数返回值,或者stdlib库里的exit接口的参数,或者系统调用_exit的参数。_exit和exit都是传入进程退出码,帮你终止进程的函数。void _exit(int status);
系统调用_exit VS 库函数exit:其实exit封装了_exit,毕竟进程终止权力还是_exit提供的。_exit是单纯的终止进程,exit里先进行清理,包括冲刷flush打开的文件的IO缓冲区,关闭文件描述符,再调用_exit终止进程。
从此处,可以窥见文件缓冲区其实是fopen时,c语言标准库会为每个打开的文件维护一个内部缓冲区,而不是os为你开辟的缓冲区。
可以使用echo $?
查看最近一个进程退出码。
对于3代码异常中止,是指进程被信号杀死,对应task_struct里的int exit_signal成员记录死前接收到的信号类型。
进程等待就是父进程通过wait()、waitpid() 等系统调用,获取子进程退出码、回收子进程资源以避免僵尸进程的存在,也可以通过这些函数的阻塞来控制进程运行顺序。注意,kill -9 杀不死僵尸进程,因为它已经死/退出了。其实,僵尸进程的用户内存的代码数据以及文件描述符等释放了,也不占CPU,但是存储在内核空间的内存中的PCB还在,而内核内存绝对稀缺啊!
父进程通过wait系列系统调用让os来通知自己是否有子进程退出,借os之手拿到子进程task_struct 里的退出码等信息。wait调用结束,os自动释放子进程task_struct 。
而再看僵尸状态,就是操作系统中用于保存子进程退出状态信息的一种机制,确保父进程可以获取并处理这些信息。
下面介绍wait和waitpid
函数 | 参数 | 返回值 | ||
wait | int*status,wait和waitpid的status一样,同下 | pid_t,同下 | ||
waitpid | pid_t pid | int*status | int options | pid_t |
<-1,等待pid绝对值子进程 | 一样,后面单独讲 | NULL,配合-1pid等于wait | 成功,返回退出子进程pid | |
’-1,等待任一个子进程 | WNOHANG,下面讲 | 失败,返回-1 | ||
0,暂且不管也用不到 | 选项很多,后面补充 | 0,先别管,我也不懂 | ||
>0,等待pid子进程 |
再说WNOHANG选项,全称wait no hang,非阻塞轮询式等待。使用后,waitpid的返回值含义就变了,如果没等到任何一个子进程退出,返回0;如果等到任一子进程退出,返回其pid;出错返回负值。通常while套waitpid+WNOHANG,如果返回0,就可以干自己的事情了。轮询就是边等边询问,没准备好就干会别的,过会再询问。
五.进程替换
这里的进程替换是,调用exec系列函数,用新程序替换掉当前程序,包括当前进程的进程地址空间上的堆栈、代码段和数据段。注意,加载新程序,是指将控制权传递给新程序的入口点(通常是 _start 函数),开始执行新程序的代码。
下面是6个库函数,exec就是executable可执行文件,l是list列表,v是env环境变量,p是path文件路径。第一个参数是为了找到可执行文件,arg是命令行参数表。
int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
(可变参数传参,这里要求必须以(char *)NULL结尾)
要求传参尽量传完整,第一个arg是命令名,后面跟一个个选项,否则可能出错。因为这些是作为命令行参数传给新程序的。
示例代码:
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{if(fork()==0){printf("%d\n",getpid());execl("/usr/bin/ls","ls","-l","-a",(char*)NULL);printf("am i existed?\n");_exit(1);}printf("parent %d\n",getpid());waitpid(-1,NULL,0);_exit(0);
}
首先,am i existed部分是没运行的,因为原程序从执行exec系列函数后就被替换掉了,而去执行新程序的代码。
其次,可以证明exec并没有创建新进程,而是将原进程的代码和数据替换为新程序的。可以通过exec执行myexe(自己写个可执行,打印自己pid),可以发现原程序和和新程序的进程pid相同,说明就是程序替换,而非进程新建。
2. int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
这个参数第一个是file,可以直接传程序名,它会去环境变量PATH里找。但也可以传路径,就等于execl。
3. int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
最后可传环境变量表,但是是覆盖子进程的环境变量表的。注意,传的环境变量表envp要以NULL结尾。为了防止覆盖,可以使用putenv函数为当前进程导入新环境变量。这样,execle为子进程传environ或者干脆execl,都可以让子进程拿到父进程环境变量表+新导入的环境变量。(复习一下,environ指向当前进程的环境变量列表)
其实,putenv传入格式是name=value,如果名存在,就是修改,不存在就是新增。跟operator[ ]异曲同工之妙。
4. int execv(const char *pathname, char *const argv[]);
把命令行参数都放在argv指针数组里。注意,依然要以NULL结尾。
5. int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]);
这俩不多说了吧。
此系列还有execve,它是系统调用,也就是说前面6个都是封装了int execve(const char *pathname, char *const _Nullable argv[], char *const _Nullable envp[]);
无需多言了吧。
六.自定义shell
至此,bash的原理,也浮出水面了。创建子进程,让它进程替换,执行用户输入的命令。据此,可做模拟bash。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
char *lwd; // last work dir
int lec; // last exit code
int main()
{lwd = getcwd(NULL, 0);while (1){char cmdline[64] = {0};printf("[%s@%s %s]$ ", getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));// 获取输入if (NULL == fgets(cmdline, sizeof(cmdline), stdin)){perror("fgets error");continue;}// 分解命令cmdline[strlen(cmdline) - 1] = 0;char *argv[16] = {NULL};int i = 0;char *tmp = strtok(cmdline, " ");while (tmp != NULL){argv[i++] = tmp;tmp = strtok(NULL, " ");}// 处理别名if (strcmp(argv[0], "ll") == 0){argv[0] = "ls", argv[i] = "-l";}// 处理内建命令if (strcmp(argv[0], "cd") == 0){char tmp[32] = {0};if (i == 1 || strcmp(argv[1], "~") == 0)argv[1] = getenv("HOME");else if (strcmp(argv[1], "-") == 0)argv[1] = lwd;lwd = getcwd(NULL, 0);chdir(argv[1]);snprintf(tmp, sizeof(tmp), "PWD=%s", getcwd(NULL, 0));putenv(tmp);continue;}if (strcmp(argv[0], "echo") == 0){int forkflag = 0;for (int i = 0; argv[i]; i++){if (strcmp(argv[i], "<") == 0 || strcmp(argv[i], ">") == 0 || strcmp(argv[i], ">>") == 0){forkflag = 1;break;}}if (!forkflag){if (strcmp(argv[1], "$?"))printf("%d\n", lec);else if (argv[1][0] == '$')printf("%s\n", getenv(argv[1] + 1));elseprintf("%s\n", argv[1]);lec = 0;continue;}}if (strcmp(argv[0], "export") == 0 && argv[1]){char env[64] = {0};strcpy(env, argv[1]);putenv(env);continue;}// 执行命令pid_t id = forK();if (id < 0){perror("fork fail");continue;}else if (id == 0){for (int i = 0; argv[i]; i++){if (strcmp(argv[i], "<") == 0){int fd = open(argv[i + 1], O_RDONLY, 0666);dup2(fd, 0);argv[i] = 0;execvp(argv[0], argv);}if (strcmp(argv[i], ">") == 0){int fd = open(argv[i + 1], O_WRONLY | O_CREAT | O_TRUNC, 0666);dup2(fd, 1);argv[i] = 0;execvp(argv[0], argv);}if (strcmp(argv[i], ">>") == 0){int fd = open(argv[i + 1], O_WRONLY | O_CREAT | O_APPEND, 0666);dup2(fd, 1);argv[i] = 0;execvp(argv[0], argv);}}execvp(argv[0], argv);exit(-1);}// 获取执行结果int status = 0;pid_t cid = waitpid(-1, &status, 0);lec = WEXITSTATUS(status);if (cid > 0)printf("child exit code:%d\n", WEXITSTATUS(status));}
}
可见,内建命令就是bash自己去执行的命令。
当然了,这是最简易的自制bash,还有很多功能可以完善,我就到此为止了,你们加哟。
到此,此博客也就结束了。这是一篇写作持续了一个多月的文章,从24年12月到今天25年1月12,中间夹了一个期末月😂。ok啊,进程部分基本结束。接下来是文件、基础IO和信号了,刚把得呐!