Linux--进程(进程虚拟地址空间、页表、进程控制、实现简易shell)

一、进程虚拟地址空间

这里以kernel 2.6.32,32位平台为例。

1.空间布局

在 32 位系统中,虚拟地址空间大小为 4GB。其中:

  • 内核空间:占据高地址的 1GB ,用于操作系统内核运行,包含内核代码、内核数据等,用户进程一般不能直接访问。
  • 用户空间 :占据低地址的 3GB ,从低地址到高地址依次为:
    • 正文代码:存放程序的可执行代码。
    • 初始化数据(数据段)已初始化全局变量和静态变量
    • 未初始化数据(BSS段)未初始化全局变量和静态变量,在程序开始执行前会被自动初始化为 0。
    • :用于动态内存分配,由使用如 malloc 等函数进行内存的申请和释放,内存分配从低地址向高地址增长
    • 共享区:存放共享内存等。
    • :用于函数调用时存储局部变量、函数参数、返回地址 等,内存分配从高地址向低地址增长
    • 命令行参数和环境变量:存储程序启动时传入的命令行参数以及环境变量信息。
    • 只读数据段存放的是程序中不会被修改的数据,像字符串常量(如 "hello world")以及一些被声明为const的全局变量等。这些数据在程序运行期间内容固定不变,被放在只读数据段可以防止程序意外修改它们,保障数据的安全性和一致性。在 32 位系统的虚拟地址空间里,它一般处于正文代码区和已初始化数据区附近,具体位置由链接器和操作系统在程序加载时确定。

通过代码验证:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_var1 = 100;
int g_var2;

int main(int argc, char *argv[], char *envp[])
{
  static int sta_v1 = 100;
  static int sta_v2;
  printf("正文代码区:%p\n",main);
  printf("已初始化全局变量:%p\n",&g_var1);
  printf("未初始化全局变量:%p\n",&g_var2);
  printf("已初始化静态变量:%p\n",&sta_v1);
  printf("未初始化静态变量:%p\n",&sta_v2);

  int* heap_v1 = (int*)malloc(sizeof(int));
  int* heap_v2 = (int*)malloc(sizeof(int));
  int* heap_v3 = (int*)malloc(sizeof(int));
  printf("堆区heap_v1:%p\n",heap_v1);
  printf("堆区heap_v2:%p\n",heap_v2);
  printf("堆区heap_v3:%p\n",heap_v3);

  int v1 = 1;
  int v2;
  int v3;
  printf("栈区v1:%p\n",&v1);
  printf("栈区v2:%p\n",&v2);
  printf("栈区v3:%p\n",&v3);

  const char* const_str1 = "abcdefg";
  const char* const_str2 = "6666";
  printf("字符串常量const_str1:%p\n",const_str1);
  printf("字符串常量const_str2:%p\n",const_str2);

  for(int i = 0; envp[i]; i++)
  {
    printf("环境变量envp[%d]:%p\n",i,&envp[i]);
  }
  for(int i = 0; argv[i]; i++)\
  {
    printf("命令行参数argv[%d]:%p\n",i,&argv[i]);
  }

  return 0;
}
bash 复制代码
正文代码区:0x564735d15189
已初始化全局变量:0x564735d18010
未初始化全局变量:0x564735d1801c
已初始化静态变量:0x564735d18014
未初始化静态变量:0x564735d18020
堆区heap_v1:0x56474643e6b0
堆区heap_v2:0x56474643e6d0
堆区heap_v3:0x56474643e6f0
栈区v1:0x7ffdd24dfe8c
栈区v2:0x7ffdd24dfe90
栈区v3:0x7ffdd24dfe94
字符串常量const_str1:0x564735d16108
字符串常量const_str2:0x564735d16110
环境变量envp[0]:0x7ffdd24e0020
环境变量envp[1]:0x7ffdd24e0028
环境变量envp[2]:0x7ffdd24e0030
环境变量envp[3]:0x7ffdd24e0038
环境变量envp[4]:0x7ffdd24e0040
环境变量envp[5]:0x7ffdd24e0048
环境变量envp[6]:0x7ffdd24e0050
环境变量envp[7]:0x7ffdd24e0058
环境变量envp[8]:0x7ffdd24e0060
环境变量envp[9]:0x7ffdd24e0068
环境变量envp[10]:0x7ffdd24e0070
环境变量envp[11]:0x7ffdd24e0078
环境变量envp[12]:0x7ffdd24e0080
环境变量envp[13]:0x7ffdd24e0088
环境变量envp[14]:0x7ffdd24e0090
环境变量envp[15]:0x7ffdd24e0098
环境变量envp[16]:0x7ffdd24e00a0
环境变量envp[17]:0x7ffdd24e00a8
环境变量envp[18]:0x7ffdd24e00b0
环境变量envp[19]:0x7ffdd24e00b8
环境变量envp[20]:0x7ffdd24e00c0
环境变量envp[21]:0x7ffdd24e00c8
环境变量envp[22]:0x7ffdd24e00d0
环境变量envp[23]:0x7ffdd24e00d8
环境变量envp[24]:0x7ffdd24e00e0
命令行参数argv[0]:0x7ffdd24dfff8
命令行参数argv[1]:0x7ffdd24e0000
命令行参数argv[2]:0x7ffdd24e0008
命令行参数argv[3]:0x7ffdd24e0010

再看两段代码:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int g_var = 1;

int main()
{
  int var = 1000;
  pid_t pid = fork();

  if(pid == 0)
  {
    //子进程
    printf("子进程g_var:%d,%p\n",g_var,&g_var);
    printf("子进程var:%d,%p\n",var,&var);
  }
  else if(pid > 0)
  {
    //父进程
    printf("父进程g_var:%d,%p\n",g_var,&g_var);
    printf("父进程var:%d,%p\n",var,&var);
  }
  else
  {
    perror("fork");
    exit(1);
  }

  return 0;
}

可以发现父子进程的变量值和地址是一样的,这是因为子进程按照父进程为模板进行拷贝,且父子并进程没有修改变量(写实拷贝)。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int g_var = 1;

int main()
{
  int var = 1000;
  pid_t pid = fork();

  if(pid == 0)
  {
    //子进程
    g_var = 666;
    var = 666;
    printf("子进程g_var:%d,%p\n",g_var,&g_var);
    printf("子进程var:%d,%p\n",var,&var);
  }
  else if(pid > 0)
  {
    //父进程
    sleep(2);//子进程先跑完,再执行父进程
    printf("父进程g_var:%d,%p\n",g_var,&g_var);
    printf("父进程var:%d,%p\n",var,&var);
  }
  else
  {
    perror("fork");
    exit(1);
  }

  return 0;
}

子进程修改了g_var和var,子进程的变量数值变化,父进程依然不变,这符合逻辑;但为什么地址还是一样的,这是同一个变量出现不同的值吗?-- 其实不是,这里父子进程的变量绝对不是同一个变量,但地址相同,就说明当前看到的地址不是物理地址, 在Linux下,这种地址叫做虚拟地址 。(在用c/c++语言所看到的地址,全部都是虚拟地址,用于看不见物理地址,物理地址由OS统一管理,这就需要OS负责将虚拟地址与物理地址进行转换

2.虚拟地址空间

  • 虚拟地址空间 :在 Linux 中,每个进程都有自己独立的虚拟地址空间。这是一个从 0 开始到某个最大值(如 32 位系统通常为2^32,即 4GB)的线性地址空间。虚拟地址空间使得进程可以认为自己独占了系统的所有内存资源,每个进程看到的内存布局都是一致的,它可以在自己的虚拟地址空间内自由地访问和操作内存,而不用担心会与其他进程的内存相互干扰。
  • 地址映射:虚拟地址空间中的地址并不直接对应物理内存中的实际地址,而是通过地址映射机制与物理内存进行关联。操作系统通过页表等数据结构来维护虚拟地址到物理地址的映射关系,将进程访问的虚拟地址转换为实际的物理地址,从而实现对物理内存的访问。

描述Linux的进程虚拟地址空间的所有信息的结构体是mm_struct(内存描述符),每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程的结构。

cpp 复制代码
struct task_struct
{
    /*...*/
    struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的
    虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。
    struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该
    进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所
    有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
    /*...*/
}

mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。

cpp 复制代码
struct mm_struct
{
    /*...*/
    struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
    struct rb_root mm_rb; /* red_black树 */
    unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/
    /*...*/
    // 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
    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;
    /*...*/
}

虚拟区域 :虚拟区域(Virtual Memory Area,简称 VMA)指的是进程虚拟地址空间中具有特定属性和用途的一段连续地址范围

  • 地址连续性:在虚拟地址空间内,一个 VMA 是一段连续的地址范围。比如代码段、数据段等在虚拟地址空间中各自占据一段连续区域。

  • 属性特定:每个 VMA 都有自己的属性,包括访问权限(可读、可写、可执行等)、是否与文件关联(如代码段关联可执行文件,数据段可能关联初始化数据文件等)、共享属性(私有或共享)等。
    虚拟空间的组织方式

  • 单链表方式 :当进程的虚拟区间较少 时,操作系统会采用单链表 来组织这些虚拟区间。在mm_struct结构体中有一个**mmap指针**,它指向这个单链表。单链表的每个节点对应一个虚拟内存区域(VMA),这种组织方式简单直接,对于虚拟区间较少的情况,遍历和操作的开销较小。比如,一个简单的小程序,它所占用的虚拟内存区域不多,用单链表就可以高效地管理这些区域。

  • 红黑树方式 :当进程的虚拟区间较多 时,单链表的查找效率会变低,因为需要顺序遍历链表。此时,操作系统会采用红黑树 来管理虚拟区间。mm_struct结构体中的**mm_rb指针**指向这棵红黑树。红黑树是一种自平衡的二叉查找树,它能保证在最坏情况下,查找、插入和删除操作的时间复杂度都是对数级别的。所以对于虚拟区间较多的进程,使用红黑树可以快速地查找、插入和删除虚拟内存区域,提高管理效率。

vm_area_struct 结构体

Linux 内核使用**vm_area_struct结构体** 来表示一个独立的虚拟内存区域(VMA) 。不同类型的虚拟内存区域,比如代码段、数据段、堆、栈等,它们的功能和内部机制都不一样,所以一个进程会用多个vm_area_struct结构体 来分别表示不同类型的虚拟内存区域。无论是单链表还是红黑树的组织方式,都是通过vm_area_struct结构体来连接各个 VMA 的。这些结构体包含了对应虚拟内存区域的相关信息,如起始地址、结束地址、访问权限等,从而方便进程快速访问和操作系统进行管理。

cpp 复制代码
struct vm_area_struct {
    unsigned long vm_start; //虚存区起始
    unsigned long vm_end; //虚存区结束
    struct vm_area_struct *vm_next, *vm_prev; //前后指针
    struct rb_node vm_rb; //红⿊树中的位置
    unsigned long rb_subtree_gap;
    struct mm_struct *vm_mm; //所属的 mm_struct
    pgprot_t vm_page_prot;
    unsigned long vm_flags; //标志位
    struct {
        struct rb_node rb;
        unsigned long rb_subtree_last;
    } shared;
    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;
    const struct vm_operations_struct *vm_ops; //vma对应的实际操作
    unsigned long vm_pgoff; //⽂件映射偏移量
    struct file * vm_file; //映射的⽂件
    void * vm_private_data; //私有数据
    atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
    struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
    struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
    struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

通过对虚拟内存空间、虚拟区域的组织、vm_area_struct 结构体的理解,可以得到更细致描述:

3.页表

每个进程都有自己独立的4G内存空间,虚拟内存空间通过MMU(内存管理单元,即计算机系统中用于管理内存访问和虚拟内存的硬件组件)与真实的物理内存产生联系。MMU的工作原理就是使用页表来实现虚拟地址到物理地址的转换。

Linux把虚存空间分成若干个大小相等的存储分区 ,Linux把这样的分区叫做 。为了换入、换出的方便,物理内存也按大小分成若干个块 。由于物理内存中的块空间是用来容纳虚存页的容器,所以物理内存中的块叫做页框页与页框是Linux实现虚拟内存技术的基础。

  • 页(Page) :在 Linux 及许多现代操作系统中,为了便于管理虚拟内存,会将虚拟地址空间划分成大小相等的存储分区,这些分区就被称为页。页的大小通常是固定的 ,常见的页大小有 4KB、8KB 等。这种划分方式使得操作系统可以以页为单位来管理虚拟内存 ,简化了内存管理的复杂性。
    (为什么页的大小是4KB/8KB?
    我们知道一个地址最少需要4字节,如果将每个虚拟内存的Byte都对应到物理内存的地址,那在4G内存的情况下,就需要16G的空间来存放映射表,那么这张表占16G连真正的物理内存都放不下了,所以有了页的概念,以页位单位来管理;如果以4KB作为页的大小,划分为各个页,之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减少了空间,4G的内存,只需要((4 * 1024 * 1024) / 4) * 4KB= 4MB 的映射表即可;同时Linux还为大内存设计了多级页表,可以进一步减少页的内存消耗)

  • 页框(Page Frame)物理内存同样也会被划分成大小与页相同的块 ,这些块被称为页框。页框是物理内存中实际存储数据的单元,它们为虚拟页提供了存放的物理空间。

  • 页表:虚拟内存到物理内存的映射表(页与页框的映射),就被称为页表。
    物理内存和虚拟内存被分成了页框与页之后,其存储单元原来的地址都被自然地分成了两段 ,并且这两段各自代表着不同的意义:高位段分别叫做页框码和页码 ,它们是识别页框和页的编码 ;低位段分别叫做页框偏移量和页内偏移量 ,它们是存储单元在页框和页内的地址编码。(可以理解为看书时,页框码/页码是书的页数,页匡偏移量/页内偏移量是书的某一页中的某一段)

  • 虚拟地址:虚拟地址通常由页号和页内偏移两部分组成。页号用于在页表中查找对应的物理页框号,页内偏移则用于在物理页框内定位具体的存储单元。例如,在一个页大小为 4KB 的系统中,虚拟地址的低 12 位通常表示页内偏移,高 20 位表示页号(假设是 32 位地址空间)。

  • 物理地址:物理地址也由页框号和页内偏移两部分组成。页框号对应于物理内存中的一个页框,页内偏移与虚拟地址中的页内偏移相同,用于在页框内定位具体的存储单元。

为了使系统可以正确的访问虚存页在对应页框中的映射,在把一个页映射到某个页框上的同时,就必须把页码和存放该页映射的页框码填入一个叫做页表的表项中 。这个页表就是之前提到的映射记录表。

页模式下,虚拟地址和物理地址转换关系:处理器遇到的地址都是虚拟地址。虚拟地址和物理地址都分成页码/页框码和偏移值两部分。在由虚拟地址转化成物理地址的过程中,偏移值不变。而页码和页框码之间的映射就在一个映射记录表一一页表。

页表共享:

在多程序系统中,常常有多个程序需要共享同一段代码或数据的情况。在分页管理的存储器中,这个事情很好办:让多个程序共享同一个页框即可。

具体的方法是:使这些相关程序的虚拟空间的页在页表中指向内存中的同一个页框。这样,当程序运行并访问这些相关页面时,就都是对同一个页框中的页面进行访问,而该页框中的页就被这些程序所共享。

页表项(Page Table Entry,PTE)是页表中的一个条目,用于记录虚拟页与物理页框之间的映射信息以及相关的控制信息

页表项的结构:

页表项是构成页表的基本单元,一个页表由多个页表项组成。每个页表项通常占用固定的字节数,其具体大小取决于系统的设计和地址空间的位数。例如,在 32 位系统中,一个页表项可能占用 4 字节;在 64 位系统中,页表项可能占用 8 字节。

页表项的内容:

  • 物理页框码(Physical Page Frame Number):这是页表项中最重要的信息,用于记录虚拟页所映射到的物理页框的编号。通过将虚拟地址中的页码与页表项中的物理页框码进行结合,再加上页内偏移量,就可以得到对应的物理地址。
  • 访问权限位(Access Permission Bits):用于控制对该页的访问权限,常见的权限包括读(R)、写(W)、执行(X)等。例如,设置为只读权限的页,程序只能读取该页中的数据,而不能进行写操作。通过设置不同的访问权限,可以增强系统的安全性,防止程序对内存进行非法访问。
  • 存在位(Present Bit) :用于指示该虚拟页是否已经映射到物理内存中的页框。如果存在位为 1,表示该虚拟页已经在物理内存中;如果为 0,表示该页目前不在物理内存中,当程序访问该页时会触发页故障(Page Fault),操作系统需要将该页从磁盘交换到物理内存中(一个进程启动时并不是把程序全部加载到内存,而是分批加载到内存。 )。
  • 修改位(Dirty Bit) :也称为脏位 ,用于标记该页在物理内存中是否被修改过。如果修改位为 1,表示该页在物理内存中已经被修改,当该页需要被换出到磁盘时,操作系统需要将修改后的数据写回到磁盘上 ;如果为 0,表示该页在物理内存中没有被修改,换出时可以直接丢弃,无需写回磁盘,这样可以提高系统的性能。
  • 引用位(Reference Bit):用于记录该页最近是否被访问过。操作系统可以通过定期检查引用位来实现页面置换算法,例如,当物理内存不足时,优先选择最近未被访问过的页进行换出。

页表项的作用:

  • 地址转换:页表项是实现虚拟地址到物理地址转换的关键。MMU(内存管理单元)根据虚拟地址中的页号查找对应的页表项,从中获取物理页框号,从而完成地址转换过程。
  • 内存保护:通过访问权限位,页表项可以对内存访问进行控制,防止程序越权访问内存,保证系统的安全性和稳定性。
  • 页面置换:存在位、修改位和引用位等信息为操作系统实现页面置换算法提供了依据,帮助操作系统在物理内存不足时,合理地选择要换出的页面,提高内存的使用效率。
    例子:

代码1:char *str = "hello"; *str = 'H';

代码2:const char *str = "hello"; *str = 'H';

代码1和2都是错的,因为常量不可以修改,但报错的机制不同;

代码1报错是因为"hello"是常量,页表设置的访问权限是只读,当尝试去修改时,会根据页表的只读权限将进程终止掉。

代码2报错是因为用const提前声明了"hello"是常量,编译器在编译时发现const修饰的常量尝试修改,编译器就将错误提出

代码 错误类型 错误原因 错误提示
char *str = "hello"; *str = 'H'; 运行时错误 字符串常量存储在只读内存中,页表权限禁止修改,触发段错误。 Segmentation fault (core dumped)
const char *str = "hello"; *str = 'H'; 编译时错误 const 关键字明确禁止修改指向的内容,编译器在编译阶段检查到错误并报错。 error: assignment of read-only location '*str'

关于地址空间mm_struct:

mm_struct本身是一个结构体变量,是结构体变量就得初始化,用来初始化的数据从可执行程序中来。

源代码通过编译器编译,获取可执行程序,同时会进程分段和保留属性(分区大小,权限等),所以通过可执行程序可以获取内存分区大小、权限等属性来初始化mm_struct和页表。

  • 代码区,已初始化数据区,未初始化数据区通过可执行程序的属性获取初始化信息;堆区和栈区是被操作系统动态创建的。
  • 堆区:当malloc申请内存空间时,扩大堆区,只是在进程虚拟地址空间改变对堆区划分的变量,实际上并没有在实际物理内存上申请,只有在访问时,才会真正在物理内存上申请(延迟分配物理内存,因为malloc的空间不一定立即使用,提高内存利用率)

4.进程管理和内存管理解耦合实现延迟分配物理内存

进程管理与内存管理的常规关联:

在传统的内存分配模式下,当进程提出内存分配请求时,操作系统会立即 为该进程分配所需的物理内存。这意味着进程管理(如进程创建、内存请求等操作)和内存管理(实际的物理内存分配)是紧密耦合的。进程管理一旦有需求,内存管理就必须马上响应并进行资源分配。
延迟分配物理内存的实现机制:

  • 解耦合进程管理与内存管理

    • 延迟分配物理内存打破了这种紧密耦合的关系。当进程请求内存时,操作系统并不会立即分配物理内存,而是在进程的虚拟地址空间中为其预留相应的地址范围,仅在进程真正访问这些虚拟地址时,才进行实际的物理内存分配。
    • 例如,在 Linux 操作系统中,当进程调用 malloc() 函数请求内存时,内核只是在进程的虚拟地址空间中标记一块可用的内存区域,并没有分配实际的物理页框。只有当进程试图访问这块虚拟内存时,会触发页错误(Page Fault)异常,此时内核才会分配物理内存并建立虚拟页到物理页框的映射。
  • 基于虚拟内存机制

    • 这种策略的实现依赖于虚拟内存技术。虚拟内存为每个进程提供了一个独立的、连续的地址空间,进程可以在这个虚拟空间中自由地进行内存操作。操作系统通过页表来管理虚拟地址到物理地址的映射关系。
    • 在延迟分配的过程中,页表项会被标记为 "不存在",当进程访问这些标记为 "不存在" 的虚拟地址时,MMU(内存管理单元)会触发页错误,内核会捕获这个异常并进行相应的处理,即分配物理内存并更新页表。
      延迟分配物理内存的优点:
  • 提高内存利用率

    • 延迟分配避免了预先分配大量物理内存而导致的内存浪费。因为有些进程请求的内存可能在实际运行过程中并不会全部使用,延迟分配可以确保只有真正被使用的内存才会被分配物理资源。
  • 加快进程创建速度

    • 由于在进程创建或内存请求时不需要立即分配物理内存,减少了分配物理内存的时间开销,从而加快了进程的创建速度。进程可以更快地启动并进入运行状态,提高了系统的整体性能。
  • 支持更大的虚拟地址空间

    • 即使物理内存有限,系统也可以为进程提供更大的虚拟地址空间。因为只有在需要时才分配物理内存,所以可以同时支持更多的进程运行,提高了系统的并发处理能力。

5. 为什么要有虚拟地址空间

如果程序直接可以操作物理内存会造成什么问题?

  • 在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
  • 那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种内存分配策略问题很多:

  • 安全风险:
    每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域,如果是一个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
  • 地址不确定:
    众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了。(在虚拟地址层面,每个进程的虚拟地址空间通常都是从 0 开始的)
  • 效率低下:
    如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

使用虚拟地址空间和分页机制解决上述问题:

  • 地址空间和页表是OS创建并维护的,凡是想使用地址空间和页表进行映射也一定要在OS的监管之下来进行访问,也顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据。
  • 因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载,物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。
  • 因为有地址空间的存在,所以我们在C、C++语言上new,malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知。
  • 因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。

二、进程控制

1.进程创建

fork()

  • 系统调用:fork();用于从已存在的进程中创建一个新进程,新进程为子进程,原进程为父进程。

  • 头文件:<unistd.h>

  • 原型:pid_t fork(void);

  • 返回值:子进程中返回0,父进程返回子进程PID,出错返回-1。

  • 进程调用fork,当控制转移到内核中的fork代码后,内核做:
    1、分配新的内存块和内核数据给子进程
    2、将父进程部分数据结构内容拷贝至子进程
    3、添加子进程到系统进程列表中
    4、fork返回,调度器开始调度

  • fork之前父进程独立执行;fork之后,父子两个进程分别执行,谁先执行完全由调度器决定。

  • 写时拷贝:通常父子代码共享,父子进程在不写入时,数据也是共享的 ,当任意一方试图写入,便以写时拷贝的方式各自一份副本()。 (在Linux系统中,子进程无法直接获取父进程的执行位置。fork()创建的子进程会复制父进程的地址空间,包括程序计数器(PC),因此子进程会从fork()调用后的下一条指令开始执行,与父进程共享相同的代码位置。 )

    写时拷贝的详细过程:

    1. fork() 调用时的初始状态

    当调用 fork() 系统调用时,操作系统会创建一个新的子进程,该子进程几乎是父进程的一个副本。此时,父进程会将部分数据结构内容(如进程控制块 PCB)拷贝至子进程。同时,操作系统会更新父进程和子进程对应的页表中的页表项权限为只读 。这意味着父进程和子进程在此时实际上共享同一块物理内存 ,它们都只能对这块内存进行读操作。

    2. 触发缺页中断

    当父进程或子进程尝试对共享的内存区域进行写入操作时,由于页表项的权限被设置为只读,CPU 会检测到这个非法写操作,从而触发缺页中断 。缺页中断是一种硬件异常,它会将控制权转移给操作系统内核

    3. 系统检测与写时拷贝判定

    操作系统内核在接收到缺页中断后,会对中断原因进行检测和分析。如果判定是由于写操作违反了只读权限而触发的中断,并且该内存区域是采用写时拷贝机制 的,那么系统就会决定进行写时拷贝操作。

    4. 申请新的内存

    操作系统会为发起写操作的进程(父进程或子进程)申请一块新的物理内存。这块新内存的大小与要写入的共享内存区域相同。

    5. 数据拷贝

    将共享内存区域中的数据从原来的物理内存拷贝到新申请的物理内存中。这样,发起写操作的进程就拥有了一份独立的数据副本 ,而另一个进程仍然使用原来的共享内存

    6. 修改页表

    操作系统会更新发起写操作的进程的页表,将其指向新申请的物理内存。同时,将新页表项的权限设置为可读写 ,以允许该进程对新的内存区域进行写入操作。而另一个进程的页表保持不变,仍然指向原来的共享内存,且权限仍为只读。

    7. 恢复执行

    完成上述操作后,操作系统会恢复发起写操作的进程的执行,该进程可以继续进行写入操作,而不会再触发缺页中断。

    写时拷贝技术的存在,使父子进程得以彻底分离,完成进程独立性。同时写时拷贝是一种延时申请技术,可以提高整机内存的使用率。

2.进程终止

进程终止的本质是释放系统资源 ,就是释放进程申请的相关内核数据结构 和对应的数据和代码。(进程 = 内核数据结构 + 代码和数据)

进程退出场景:

  1. 代码运行完毕,结果正确
  2. 代码运行完毕,结果不正确
  3. 代码异常终止

进程退出方法:

  1. 正常终止:从main返回;调用exit;_exit。
  2. 异常退出:ctrl + c,信号终止

退出码:
退出码(退出状态) 可以告诉我们最后一次执行的命令的状态。在命令结束以后,我们可以知道命令是成功完成的还是以错误结束的。其基本思想是**,程序返回退出代码 0时表示执行成功,没有问题。代码 1或0 以外的任何代码都被视为不成功。
Linux shell中的主要退出码:**

退出码 含义 示例
0 命令成功执行 ls 命令正常列出目录内容后返回退出码 0。
1 通用未知错误 例如在执行某个脚本时,脚本内部发生了未明确处理的错误。
2 误用 shell 命令 比如使用了不存在的选项或参数 ,像 ls -unknownoption 会返回退出码 2。
126 命令可找到但无法执行 当你尝试执行一个没有可执行权限的文件 时,会返回该退出码,如 ./non_executable_file(假设 non_executable_file 没有执行权限)。
127 命令未找到 输入一个系统中不存在的命令 ,例如 nonexistent_command 会返回 127。
128 无效的退出参数 在**exit 命令后使用了非法的参数** ,如 exit abc 会导致此退出码。
128 + N 因信号 N 导致进程终止 例如,当进程收到 SIGTERM(信号 15)终止信号时,退出码为 128 + 15 = 143
130 脚本通过 Ctrl + C 终止 当你在脚本运行时按下 Ctrl + C 组合键,脚本会以退出码 130 终止。
255 退出状态码越界 使用 exit 命令时,参数超出了 0 - 255 的范围,如 exit 300 会返回 255。

_exit函数(系统调用):

  • 头文件: <unistd.h>

  • 原型:void _exit(int status);

  • 参数:status定义了进程的终止状态,父进程通过wait来获取该值(后面会讲)。

  • 注意:status虽然是int类型,但是仅有低8位可以被父进程所用,所以_exit(-1)时,在终端执行命令$?发现返回值是255。
    exit函数(库函数):

  • 头文件:<unistd.h>

  • 原型:void exit(int status);

exit最后会调用_exit,但在调用_exit之前,还做了其他工作(exit和_exit的区别):

  1. 执行用户通过atexit或on_exit定义的清理函数

  2. 关闭所有打开的流,所有的缓存数据均被写入(刷新缓冲区,语言级缓冲区不在操作系统内部,而是在用户空间,exit能刷新缓冲区是因为封装了fflush(),exit和fflush都是语言级别的函数,而_exit是系统调用,没有调用上层的fflush,所以无法刷新缓冲区 )

  3. 调用_exit

    bash 复制代码
    int main()
    {
    printf("hello");
    exit(0);
    } 
    运⾏结果:
    [root@localhost linux]# ./a.out
    hello[root@localhost linux]#
    
    int main()
    {
    printf("hello");
    _exit(0);
    }
    运⾏结果:
    [root@localhost linux]# ./a.out
    [root@localhost linux]#

return退出:

return是一种更常见的退出进程方法,执行return n相当于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数。

3.进程等待

进程等待的必要性

  1. 之前讲过,子进程退出,父进程如果不管不顾,就可能造成'僵尸进程'的问题,进而造成内存泄漏。

  2. 另外,进程一旦变成僵尸状态,kill-9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。

  3. 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。

  4. 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
    进程等待的方法

  5. wait方法:

    cpp 复制代码
    #include<sys/types.h>
    #include<sys/wait.h>
    
    pid_t wait(int* status);
    
    返回值:成功返回被等待进程pid,失败返回-1。
    
    参数:输出型参数,获取⼦进程退出状态,不关⼼则可以设置成为NULL
  6. waitpid方法:

    cpp 复制代码
    pid_ t waitpid(pid_t pid, int *status, int options);
    
    返回值:
        当正常返回的时候waitpid返回收集到的⼦进程的进程ID;
        如果设置了选项WNOHANG,⽽调⽤中waitpid发现没有已退出的⼦进程可收集,则返回0;
        如果调⽤中出错,则返回-1,这时errno会被设置成相应的值以指⽰错误所在;
    
    参数:
    pid:
        Pid=-1,等待任意⼀个⼦进程。与wait等效。
        Pid>0,等待其进程ID与pid相等的⼦进程。
    
    status: 输出型参数
        WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真。(查看进程是否是正常退出)
        WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程的退出码)
    
    options:
            默认为0,表⽰阻塞等待
            WNOHANG: 若pid指定的⼦进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回            
            该⼦进程的ID。

    关于输出型参数status:
    1、status 是一个输出型参数,它是一个整型指针,waitpid 会将子进程的终止状态信息存储在 status 所指向的内存位置,常用低 16 位,低 8 位存正常退出码,部分位存终止或暂停信号编号,第 7 位表是否核心转储,以位图形式 高效存子进程多种状态信息。

    为了方便解析 status 中的状态信息,系统提供了一系列的宏,这些宏定义在 <sys/wait.h> 头文件中。
    2、WIFEXITED(status):如果子进程是正常终止(通过 exit_exit 系统调用),则该宏返回非零值;否则返回 0。
    3、WEXITSTATUS(status):当 WIFEXITED(status) 返回非零值时,使用这个宏可以获取子进程的退出状态码。退出状态码是子进程调用 exit_exit 时传入的参数,范围是 0 - 255。
    4、WIFSIGNALED(status):如果子进程是因为接收到某个信号而终止,该宏返回非零值;否则返回 0。
    5、WTERMSIG(status):当 WIFSIGNALED(status) 返回非零值时,使用这个宏可以获取导致子进程终止的信号编号。
    6、WCOREDUMP(status):当 WIFSIGNALED(status) 返回非零值时,如果子进程在终止时产生了核心转储文件(core dump),该宏返回非零值;否则返回 0。
    7、WIFSTOPPED(status):如果子进程是因为接收到某个信号而暂停执行,该宏返回非零值;否则返回 0。
    8、WSTOPSIG(status):当 WIFSTOPPED(status) 返回非零值时,使用这个宏可以获取导致子进程暂停的信号编号。
    9、WIFCONTINUED(status):如果子进程是因为接收到 SIGCONT 信号而恢复执行,该宏返回非零值;否则返回 0。

PCB中维护了变量int exit_code,exit_signal;wait()就是通过PCB获取退出码和退出信号的,再通过位图的形式返回给用户。

阻塞等待和非阻塞等待:

阻塞等待

  • 概念:当一个进程进行阻塞等待时,它会暂停执行并进入等待状态,直到等待的事件发生或条件满足。在等待期间,该进程会释放 CPU 资源,不参与 CPU 调度,直到等待的条件达成,才会被唤醒并继续执行后续操作。

  • 应用场景

    • I/O 操作:在进行磁盘读写、网络通信等 I/O 操作时,进程可能需要等待数据传输完成。例如,当进程从磁盘读取文件时,会发起 I/O 请求并进入阻塞状态,直到磁盘将数据读取到内存后,进程才会被唤醒继续处理数据。
    • 进程同步:在多进程编程中,一个进程可能需要等待另一个进程完成某个任务后才能继续执行。比如,父进程等待子进程结束,就可以使用阻塞等待的方式,确保子进程的任务完成后再进行后续操作。
  • 实现方式

  • 在 C 语言中,使用 wait()waitpid() 函数进行进程等待时,如果不设置特定的非阻塞标志,默认就是阻塞等待。例如:

    cpp 复制代码
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <unistd.h>
    #include <stdio.h>
    
    int main() {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            sleep(2);
            _exit(0);
        } else if (pid > 0) {
            // 父进程
            int status;
            wait(&status); // 阻塞等待子进程结束
            printf("子进程已结束\n");
        }
        return 0;
    }

    非阻塞等待

  • 概念:非阻塞等待允许进程在等待事件或条件的同时继续执行其他任务,而不会被挂起。进程会立即返回等待操作的结果,如果等待的事件尚未发生,进程可以继续执行其他代码,之后可以再次尝试检查事件是否发生。
  • 应用场景
    • 实时系统:在实时系统中,需要保证系统能够及时响应多个事件。例如,一个监控系统需要同时监控多个传感器的数据,使用非阻塞等待可以让系统在等待某个传感器数据的同时,继续处理其他传感器的数据或执行其他任务。
    • 用户交互:在图形界面程序或命令行交互程序中,为了保证界面的流畅性和响应性,通常会使用非阻塞等待。例如,程序在等待用户输入的同时,可以继续更新界面显示或执行其他后台任务。
  • 实现方式
    • waitpid() 函数中,可以通过设置 WNOHANG 标志来实现非阻塞等待。例如:

      cpp 复制代码
      #include <sys/types.h>
      #include <sys/wait.h>
      #include <unistd.h>
      #include <stdio.h>
      
      int main() {
          pid_t pid = fork();
          if (pid == 0) {
              // 子进程
              sleep(2);
              _exit(0);
          } else if (pid > 0) {
              // 父进程
              int status;
              pid_t result;
              do {
                  result = waitpid(pid, &status, WNOHANG); // 非阻塞等待
                  if (result == 0) {
                      // 子进程还未结束,继续做其他事情
                      printf("子进程还在运行,父进程继续执行其他任务\n");
                      sleep(1);
                  }
              } while (result == 0);
              if (result > 0) {
                  printf("子进程已结束\n");
              }
          }
          return 0;
      }

4.进程程序替换

fork()之后,父子各自执行父进程代码的一部分,如果子进程就想执行一个全新的程序呢?进程的程序替换来完成这个功能。程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。

替换原理:

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。


替换的函数:

函数原型 功能描述 参数说明 是否搜索 PATH 是否可指定环境变量
int execl(const char *path, const char *arg, ...); 执行指定路径的程序 path:程序完整路径名;后续参数为传递给新程序的命令行参数,以 NULL 结尾
int execv(const char *path, char *const argv[]); 执行指定路径的程序 path:程序完整路径名;argv:以 NULL 结尾的字符串数组,包含命令行参数
int execle(const char *path, const char *arg, ..., char * const envp[]); 执行指定路径的程序并可指定环境变量 path:程序完整路径名;后续参数为命令行参数,以 NULL 结尾;envp:以 NULL 结尾的字符串数组,包含环境变量
int execve(const char *filename, char *const argv[], char *const envp[]); 执行指定文件的程序并可指定环境变量 filename:程序完整路径名;argv:命令行参数数组;envp:环境变量数组
int execlp(const char *file, const char *arg, ...); 执行指定文件名的程序,在 PATH 中查找 file:程序文件名;后续参数为命令行参数,以 NULL 结尾
int execvp(const char *file, char *const argv[]); 执行指定文件名的程序,在 PATH 中查找 file:程序文件名;argv:命令行参数数组
cpp 复制代码
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execl 示例
        execl("/bin/ls", "ls", "-l", NULL);
        perror("execl");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execv 示例
        char *args[] = {"ls", "-l", NULL};
        execv("/bin/ls", args);
        perror("execv");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execle 示例
        char *env[] = {"MYVAR=value", NULL};
        execle("/bin/ls", "ls", "-l", NULL, env);
        perror("execle");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execve 示例
        char *args[] = {"ls", "-l", NULL};
        char *env[] = {"MYVAR=value", NULL};
        execve("/bin/ls", args, env);
        perror("execve");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execlp 示例
        execlp("ls", "ls", "-l", NULL);
        perror("execlp");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // execvp 示例
        char *args[] = {"ls", "-l", NULL};
        execvp("ls", args);
        perror("execvp");
        exit(EXIT_FAILURE);
    }

    return 0;
}



execl 函数是 C 标准库中的函数,主要用于执行可执行文件(如编译后的二进制文件或脚本解释器)。如果想通过 execl 执行其他编程语言的代码(如 Python、Shell 脚本等),你需要调用该语言的解释器,并将脚本文件作为参数传递。

总结:

程序替换:fork -> 调用exec*接口 -> 从新程序的main函数开始运行

那么命令行参数是怎么传递给你的程序的?由谁传递的?

在程序替换过程中,命令行参数的传递机制:

  1. 命令行解释器解析 :当你在命令行输入命令时,命令行解释器(如bashzsh等)会解析输入的命令和参数。例如,输入./myprogram arg1 arg2,解释器会将./myprogramarg1arg2解析为参数。

  2. 构建参数数组 :命令行解释器将这些参数构建成一个字符串数组(argv),其中第一个元素通常是程序名(./myprogram),后续元素是命令行参数(arg1arg2)。数组的最后一个元素是NULL,表示数组结束。

  3. 调用exec*接口 :在fork之后,子进程调用exec*系列函数(如execlexecv等)来加载新程序。exec*函数会将构建好的参数数组传递给新程序的main函数。

  4. main函数接收参数 :新程序的main函数通过argcargv接收这些参数。argc表示参数的数量,argv是指向参数数组的指针。

三、 实现简易的shell

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

const int basesize = 1024;
const int argvnum = 64;
const int envnum = 64;
// 全局的命令行参数表
char *gargv[argvnum];
int gargc = 0;

// 全局的变量
int lastcode = 0;

// 我的系统的环境变量
char *genv[envnum];

// 全局的当前shell工作路径 
char pwd[basesize];
char pwdenv[basesize];

string GetUserName()
{
    string name = getenv("USER");
    return name.empty() ? "None" : name;
}

string GetHostName()
{
    string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
}

string GetPwd()
{
    if(nullptr == getcwd(pwd, sizeof(pwd))) return "None";
    snprintf(pwdenv, sizeof(pwdenv),"PWD=%s", pwd);
    putenv(pwdenv); // PWD=XXX
    return pwd;

    //string pwd = getenv("PWD");
    //return pwd.empty() ? "None" : pwd;
}

string LastDir()
{
    string curr = GetPwd();
    if(curr == "/" || curr == "None") return curr;
    // /home/whb/XXX
    size_t pos = curr.rfind("/");
    if(pos == std::string::npos) return curr;
    return curr.substr(pos+1);
}

string MakeCommandLine()
{
    // [whb@bite-alicloud myshell]$ 
    char command_line[basesize];
    snprintf(command_line, basesize, "[%s@%s %s]# ",\
            GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str());
    return command_line;
}

void PrintCommandLine() // 1. 命令行提示符
{
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);
}

bool GetCommandLine(char command_buffer[], int size)   // 2. 获取用户命令
{
    // 我们认为:我们要将用户输入的命令行,当做一个完整的字符串
    // "ls -a -l -n"
    char *result = fgets(command_buffer, size, stdin);
    if(!result)
    {
        return false;
    }
    command_buffer[strlen(command_buffer)-1] = 0;
    if(strlen(command_buffer) == 0) return false;
    return true;
}

void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
{
    (void)len;
    memset(gargv, 0, sizeof(gargv));
    gargc = 0;
    // "ls -a -l -n"
    const char *sep = " ";
    gargv[gargc++] = strtok(command_buffer, sep);
    // =是刻意写的
    while((bool)(gargv[gargc++] = strtok(nullptr, sep)));
    gargc--;
}

void debug()
{
    printf("argc: %d\n", gargc);
    for(int i = 0; gargv[i]; i++)
    {
        printf("argv[%d]: %s\n", i, gargv[i]);
    }
}
// 在shell中
// 有些命令,必须由子进程来执行
// 有些命令,不能由子进程执行,要由shell自己执行 --- 内建命令 built command
bool ExecuteCommand()   // 4. 执行命令
{
    // 让子进程进行执行
    pid_t id = fork();
    if(id < 0) return false;
    if(id == 0)
    {
        //子进程
        // 1. 执行命令
        execvpe(gargv[0], gargv, genv);
        // 2. 退出
        exit(1);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        if(WIFEXITED(status))
        {
            lastcode = WEXITSTATUS(status);
        }
        else
        {
            lastcode = 100;
        }
        return true;
    }
    return false;
}

void AddEnv(const char *item)
{
    int index = 0;
    while(genv[index])
    {
        index++;
    }

    genv[index] = (char*)malloc(strlen(item)+1);
    strncpy(genv[index], item, strlen(item)+1);
    genv[++index] = nullptr;
}
// shell自己执行命令,本质是shell调用自己的函数
bool CheckAndExecBuiltCommand()
{
    if(strcmp(gargv[0], "cd") == 0)
    {
        // 内建命令
        if(gargc == 2)
        {
            chdir(gargv[1]);
            lastcode = 0;
        }
        else
        {
            lastcode = 1;
        }
        return true;
    }
    else if(strcmp(gargv[0], "export") == 0)
    {
        // export也是内建命令
        if(gargc == 2)
        {
            AddEnv(gargv[1]);
            lastcode = 0;
        }
        else
        {
            lastcode = 2;
        }
        return true;
    }
    else if(strcmp(gargv[0], "env") == 0)
    {
        for(int i = 0; genv[i]; i++)
        {
            printf("%s\n", genv[i]);
        }
        lastcode = 0;
        return true;
    }
    else if(strcmp(gargv[0], "echo") == 0)
    {
        if(gargc == 2)
        {
            // echo $?
            // echo $PATH
            // echo hello
            if(gargv[1][0] == '$')
            {
                if(gargv[1][1] == '?')
                {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                }
            }
            else
            {
                printf("%s\n", gargv[1]);
                lastcode = 0;
            }
        }
        else
        {
            lastcode = 3;
        }
        return true;
    }
    return false;
}

// 作为一个shell,获取环境变量应该从系统的配置来
// 我们今天就直接从父shell中获取环境变量
void InitEnv()
{
    extern char **environ;
    int index = 0;
    while(environ[index])
    {
        genv[index] = (char*)malloc(strlen(environ[index])+1);
        strncpy(genv[index], environ[index], strlen(environ[index])+1);
        index++;
    }
    genv[index] = nullptr;
}

int main()
{
    InitEnv();
    char command_buffer[basesize];
    while(true)
    {
        PrintCommandLine(); // 1. 命令行提示符
        // command_buffer -> output
        if( !GetCommandLine(command_buffer, basesize) )   // 2. 获取用户命令
        {
            continue;
        }
        //printf("%s\n", command_buffer);
        //ls
        //"ls -a -b -c -d"->"ls" "-a" "-b" "-c" "-d"
        ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令

        if ( CheckAndExecBuiltCommand() )
        {
            continue;
        }

        ExecuteCommand();   // 4. 执行命令
    }
    return 0;
}
相关推荐
技术小齐1 分钟前
网络运维学习笔记 016网工初级(HCIA-Datacom与CCNA-EI)PPP点对点协议和PPPoE以太网上的点对点协议(此处只讲华为)
运维·网络·学习
ITPUB-微风8 分钟前
Service Mesh在爱奇艺的落地实践:架构、运维与扩展
运维·架构·service_mesh
打不了嗝 ᥬ᭄16 分钟前
Linux的权限
linux
落幕21 分钟前
C语言-进程
linux·运维·服务器
一只码代码的章鱼21 分钟前
数据结构与算法-搜索-剪枝
算法·深度优先·剪枝
深度Linux30 分钟前
C++程序员内功修炼——Linux C/C++编程技术汇总
linux·项目实战·c/c++
chenbin5201 小时前
Jenkins 自动构建Job
运维·jenkins
java 凯1 小时前
Jenkins插件管理切换国内源地址
运维·jenkins
AI服务老曹1 小时前
运用先进的智能算法和优化模型,进行科学合理调度的智慧园区开源了
运维·人工智能·安全·开源·音视频
roman_日积跬步-终至千里1 小时前
【后端基础】布隆过滤器原理
算法·哈希算法