【linux】系统编程-1-进程、管道和信号


1. 进程

1.1 概念

  • 程序
    • 程序是存放在存储介质上的一个可执行文件
  • 进程
    • 进程是程序执行的过程,是程序在执行过程中分配和管理资源的基本单位
    • 程序是静态的,进程是动态的。进程的状态是变化的,其包括进程的创建、调度和消亡
  • 线程
    • 线程是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源
    • 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程
  • 进程ID
    • 进程ID 是一个16位的正整数,默认取值范围是从 232768可以修改
    • PID数字为1的值一般是为特殊进程 init 保留
  • 父进程
    • 任何进程(除init进程)都是由另一个进程启动,该进程称为被启动进程的父进程(ID号称为:PID),被启动的进程称为子进程(ID号称为:PPID),
    • 父进程号无法在用户层修改

1.2 查看进程

  • 查看进程命令
    • ps -aux
      • 查看系统进程
    • pstree
      • 将进程以树状关系列出来

1.3 启动新进程

  • 介绍三种方法启动新进程
    1. system() 函数
    2. fork() 函数
    3. exec() 函数

1.3.1 system() 函数

  • 可以理解为 启动新进程
  • system()启动了一个运行着/bin/sh的子进程
    • 说明 system() 函数依赖与 shell
  • int system (const char *string )
    • 效果就相当于执行 sh –c string
  • system() 函数的特点
    • 建立独立进程,拥有独立的代码空间,内存空间
    • 等待新的进程执行完毕,system才返回。(阻塞)
  • 例程
    • system 运行完才会返回,才会在当前终端打印出数据
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    pid_t result;
    printf("This is a system demo!\n\n");
    /*调用 system()函数*/
    result = system("ls -l");
    printf("Done!\n\n");
    return result;
}

1.3.2 fork() 函数

  • 可以理解为 复制进程
  • 头文件
    1. #include<unistd.h>
    2. #include<sys/types.h>
  • pid_t fork( void);
    • 若成功调用一次则
      • 子进程返回 0
      • 父进程返回子进程 ID
    • 出错返回 -1
  • 例程
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    pid_t result;
    printf("This is a fork demo!\n\n");
    /*调用 fork()函数*/
    result = fork();
    /*通过 result 的值来判断 fork()函数的返回情况,首先进行出错处理*/
    if(result == -1) {
        printf("Fork error\n");
    }
    /*返回值为 0 代表子进程*/
    else if (result == 0) {
        printf("The returned value is %d, In child process!! My PID is %d\n\n", result, getpid());
    }
    /*返回值大于 0 代表父进程*/
    else {
        printf("The returned value is %d, In father process!! My PID is %d\n\n", result, getpid());
    }
    return result;}

1.3.2 exce 系列函数

  • 可以理解为 替换进程
  • 调用 exec 并不创建新进程,所以前后的进程 ID 并未改变
  • exec 只是用另一个新程序替换了当前进程的正文、数据、堆和栈段
  • 在原进程中已经打开的文件描述符,在新进程中仍将保持打开,除非它们的“执行时关闭标志”(close on exec flag)被置位
  • 任何在原进程中已打开的目录流都将在新进程中被关闭
  • 举个例子,A进程调用 exce 系列函数启动一个进程B,此时进程B会替换进程A,进程A的内存空间、数据段、代码段等内容都将被进程B占用,进程A将不复存在
1.3.2.1 exce 系列函数说明
  • exec 系列函数有 6 个不同的 exec 函数
    1. int execl(const char *path, const char *arg, ...)
    2. int execlp(const char *file, const char *arg, ...)
    3. int execle(const char *path, const char *arg, ..., char *const envp[])
    4. int execv(const char *path, char *const argv[])
    5. int execvp(const char *file, char *const argv[])
    6. int execve(const char *path, char *const argv[], char *const envp[])
  • 函数说明
    • 名称包含 l 字母的函数(execl、 execlp 和execle)接收参数列表”list”作为调用程序的参数
    • 名称包含 p 字母的函数(execvp 和execlp)接受一个程序名作为参数,然后在当前的执行路径中搜索并执行这个程序
    • 名字不包含 p 字母的函数在调用时必须指定程序的完整路径,其实就是在系统环境变量”PATH”搜索可执行文件
    • 名称包含 v 字母的函数(execv、execvp 和 execve)的命令参数通过一个数组”vector”传入
    • 名称包含 e 字母的函数(execve 和 execle)比其它函数多接收一个指明环境变量列表的参数,并且可以通过参数envp传递字符串数组作为新程序的环境变量,这个envp参数的格式应为一个以 NULL 指针作为结束标记的字符串数组,每个字符串应该表示为”environment =virables”的形式

1.3 终止进程

  • 可以分为 5 种进程终止
    • 正常终止
      • main 函数返回
      • 调用 exit() 终止
      • 调用 _exit() 函数终止
    • 异常终止
      • 调用 abort() 函数终止
      • 由系统信号终止

1.4 等待进程

  • 父进程中调用wait()或者waitpid()函数让父进程等待子进程的结束

1.4.1 wait() 函数

  • wait()函数只是 waitpid() 函数的一个特例,在 Linux内部实现 wait 函数时直接调用的就是 waitpid 函数
  • pid_t wait(int *wstatus);
    • wait() 函数在被调用的时候,系统将暂停父进程的执行,直到有信号来到或子进程结束
    • 如果在调用 wait() 函数时子进程已经结束,则会立即返回子进程结束状态值
    • 子进程的结束状态信息会由参数wstatus返回
    • 该函数的返回值为子进程的PID
  • 注意
    • wait()要与fork()配套出现,且 fork() 调用先
    • 参数wstatus用来保存被收集进程退出时的一些状态
  • 可以使用以下宏来判断退出状态
    • WIFEXITED(status) :如果子进程正常结束,返回一个非零值
    • WEXITSTATUS(status): 如果WIFEXITED非零,返回子进程退出码
    • WIFSIGNALED(status) :子进程因为捕获信号而终止,返回非零值
    • WTERMSIG(status) :如果WIFSIGNALED非零,返回信号代码
    • WIFSTOPPED(status): 如果子进程被暂停,返回一个非零值
    • WSTOPSIG(status): 如果WIFSTOPPED非零,返回一个信号代码

1.4.2 waitpid() 函数

  • wait()函数只是 waitpid() 函数的一个特例,在 Linux内部实现 wait 函数时直接调用的就是 waitpid 函数
  • pid_t waitpid(pid_t pid, int *wstatus, int options);
    • pid:参数pid为要等待的子进程ID
      • pid < -1:等待进程组号为pid绝对值的任何子进程
      • pid = -1:等待任何子进程,此时的waitpid()函数就等同于wait()函数
      • pid = 0:等待进程组号与目前进程相同的任何子进程,即等待任何与调用waitpid()函数的进程在同一个进程组的进程
      • pid > 0:等待指定进程号为pid的子进程
    • wstatus:与wait()函数一样
    • options:参数 options 提供了一些另外的选项来控制waitpid()函数的行为。如果不想使用这些选项,则可以把这个参数设为0

2. 管道

2.1 概念

  • 管道
    • 管道是 Linux 由 Unix 那里继承过来的进程间的通信机制,它是Unix早期的一个重要通信机制。
    • 其思想是,在内存中创建一个共享文件,从而使通信双方利用这个共享文件来传递信息。由于这种方式具有单向传递数据的特点,所以这个作为传递消息的共享文件就叫做“管道”
  • 管道分类
    • 匿名管道(无名管道)(PIPE
    • 命名管道(有名管道)(FIFO

2.2 匿名管道

2.2.1 匿名管道特征

  1. 没有名字,因此不能使用 open() 函数打开,但可以使用 close() 函数关闭
  2. 只提供单向通信
  3. 只能用于具有血缘关系的进程间通信,通常用于父子进程建通信
  4. 管道是基于字节流来通信的
  5. 依赖于文件系统,它的生命周期随进程的结束而结束
  6. 写入操作不具有原子性,因此只能用于一对一的简单通信情形
  7. 管道也可以看成是一种特殊的文件,对于它的读写也可以使用普通的read()和write()等函数。但是它又不是普通的文件,并不属于其他任何文件系统,并且只存在于内核的内存空间中,因此不能使用lseek()来定位

2.2.2 pipe() 函数

  • pipe() 函数用于创建一个匿名管道,一个可用于进程间通信的单向数据通道。
  • 头文件
    • #include <unistd.h>
  • 函数原型
    • int pipe(int pipefd[2]);
      • pipefd[0] 指向管道的 读取
      • pipefd[1] 指向管道的
      • 返回 0:匿名管道创建成功
      • 返回 -1:创建失败
  • 使用步骤
    1. 父进程调用 pipe() 函数创建匿名管道
    2. 父进程调用 fork() 函数启动(创建)一个子进程
    3. 若想从父进程将数据传递给子进程
      1. 父进程:关闭读取端
      2. 子进程:关闭写端
    4. 若想从子进程将数据传递给父进程
      1. 父进程:关闭写端
      2. 子进程:关闭读取端
    5. 当不需要使用管道时,关闭所有端口即可
  • 例程
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_DATA_LEN 256
#define DELAY_TIME 1

int main()
{
    pid_t pid;
    int pipe_fd[2];                             //(1)
    char buf[MAX_DATA_LEN];
    const char data[] = "Pipe Test Program";
    int real_read, real_write;

    memset((void*)buf, 0, sizeof(buf));

    /* 创建管道 */
    if (pipe(pipe_fd) < 0)                  //(2)
    {
        printf("pipe create error\n");
        exit(1);
    }

    /* 创建一子进程 */
    if ((pid = fork()) == 0)
    {
        /* 子进程关闭写描述符,并通过使子进程暂停 3s 等待父进程已关闭相应的读描述符 */
        close(pipe_fd[1]);
        sleep(DELAY_TIME * 3);
        /* 子进程读取管道内容 */ 
        if ((real_read = read(pipe_fd[0], buf, MAX_DATA_LEN)) > 0)
        {
            printf("%d bytes read from the pipe is ‘%s‘\n", real_read, buf);
        }
        /* 关闭子进程读描述符 */
        close(pipe_fd[0]);
        exit(0);
    }
    else if (pid > 0)
    {
        /* 父进程关闭读描述符,并通过使父进程暂停 1s 等待子进程已关闭相应的写描述符 */
        close(pipe_fd[0]);
        sleep(DELAY_TIME);
        if((real_write = write(pipe_fd[1], data, strlen(data))) != -1)
        {
            printf("Parent write %d bytes : ‘%s‘\n", real_write, data);
        }
        /*关闭父进程写描述符*/
        close(pipe_fd[1]);
        /*收集子进程退出信息*/
        waitpid(pid, NULL, 0);
        exit(0);
    }
}

2.3 命名管道

2.3.1 命名管道特征

  1. 有名字,存储于普通文件系统之中
  2. 任何具有相应权限的进程都可以使用 open() 来获取命名管道的文件描述符
  3. 跟普通文件一样:使用统一的 read()/write() 来读写
  4. 跟普通文件不同:不能使用 lseek() 来定位,原因是数据存储于内存中
  5. 具有写入原子性,支持多写者同时进行写操作而数据不会互相践踏
  6. 遵循先进先出(First In First Out)原则,最先被写入 FIFO 的数据,最先被读出来

2.3.2 创建命名管道命令

  • mkfifo
    • mkfifo test
      • test 文件为命名管道文件

2.3.3 fifo() 函数

  • fifo() 函数
  • 头文件
    • #include <unistd.h>
  • 函数原型
    • int mkfifo(const char * pathname,mode_t mode);
      • pathname:命名管道文件
      • mode:
        • O_RDONLY:读管道
        • O_WRONLY:写管道
        • O_RDWR:读写管道
        • O_NONBLOCK:非阻塞
        • O_CREAT:如果该文件不存在,那么就创建一个新的文件,并用第三个参数为其设置权限
        • O_EXCL:如果使用 O_CREAT 时文件存在,那么可返回错误消息
      • 返回值:
        • 0:成功
        • EACCESS:参数 filename 所指定的目录路径无可执行的权限
        • EEXIST:参数 filename 所指定的文件已存在
        • ENAMETOOLONG:参数 filename 的路径名称太长
        • ENOENT:参数 filename 包含的目录不存在
        • ENOSPC:文件系统的剩余空间不足
        • ENOTDIR:参数 filename 路径中的目录存在但却非真正的目录
        • EROFS:参数 filename 指定的文件存在于只读文件系统内
  • 例程
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <string.h>

#define MYFIFO "myfifo"    /* 命名管道文件名*/

#define MAX_BUFFER_SIZE PIPE_BUF /* 4096 定义在于 limits.h 中*/

void fifo_read(void)
{
    char buff[MAX_BUFFER_SIZE];
    int fd;
    int nread;

    printf("***************** read fifo ************************\n");
    /* 判断命名管道是否已存在,若尚未创建,则以相应的权限创建*/
    if (access(MYFIFO, F_OK) == -1)
    {
        if ((mkfifo(MYFIFO, 0666) < 0) && (errno != EEXIST))
        {
            printf("Cannot create fifo file\n");
            exit(1);
        }
    }
    /* 以只读阻塞方式打开命名管道 */
    fd = open(MYFIFO, O_RDONLY);
    if (fd == -1)
    {
        printf("Open fifo file error\n");
        exit(1);
    }
    memset(buff, 0, sizeof(buff));
    if ((nread = read(fd, buff, MAX_BUFFER_SIZE)) > 0)
    {
        printf("Read ‘%s‘ from FIFO\n", buff);
    }
    printf("***************** close fifo ************************\n");
    close(fd);
    exit(0);
}

void fifo_write(void)
{
    int fd;
    char buff[] = "this is a fifo test demo";
    int nwrite;
    sleep(2);   //等待子进程先运行
    /* 以只写阻塞方式打开 FIFO 管道 */
    fd = open(MYFIFO, O_WRONLY | O_CREAT, 0644);
    if (fd == -1)
    {
        printf("Open fifo file error\n");
        exit(1);
    }
    printf("Write ‘%s‘ to FIFO\n", buff);
    /*向管道中写入字符串*/
    nwrite = write(fd, buff, MAX_BUFFER_SIZE);
    if(wait(NULL))  //等待子进程退出
    {
        close(fd);
        exit(0);
    }
}

int main()
{
    pid_t result;
    /*调用 fork()函数*/
    result = fork();
    /*通过 result 的值来判断 fork() 函数的返回情况,首先进行出错处理*/
    if(result == -1)
    {
        printf("Fork error\n");
    }
    else if (result == 0) /*返回值为 0 代表子进程*/
    {
        fifo_read();
    }
    else /*返回值大于 0 代表父进程*/
    {
        fifo_write();
    }
    return result;
}

3. 信号

3.1 概念及特征

  • 信号(signal)
    • 又称为软中断信号,用于通知进程发生了异步事件
    • 它是Linux系统响应某些条件而产生的一个事件
    • 它是在软件层次上对中断机制的一种模拟
    • 是一种异步通信方式
    • 在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的
  • 信号是进程间通信机制中唯一的异步通信机制
  • 信号产生
    • 信号可能是由于系统中某些错误而产生
    • 也可以是某个进程主动生成的一个信号

3.2 系统支持的信号

  • 查询系统支持的信号种类命令:kill -l
  • linux支持62种信号(没有 32 号和 33 号信号
    • 非实时信号(不可靠):1-32
      • 没有排队功能,信号可能被丢弃
      • 不会立即执行
      • 先放入该进程控制块(PCB),待合适的时候处理
    • 实时信号(可靠信号):34-64
      • 有排队功能

3.3 信号处理

  • 信号类似可分为三大类型:程序错误、外部事件以及显式请求
  • 当信号发生时,信号可以采取如下三种操作:
    • 忽略信号(SIGTOP 和 SIGKILL 是绝不能被忽略的)
    • 捕获信号
    • 让默认信号起作用
      • 终止进程并且生成内存转储文件
      • 终止终止进程但不生成core文件
      • 忽略信号
      • 暂停进程
      • 若进程是暂时暂停,恢复进程,否则将忽略信号

3.4 发送信号函数

  • kill()
  • raise()
  • alarm()

3.4.1 kill()

  • 命令:kill [信号或选项] PID(s)
  • 函数
    • 头文件:
      #include <sys/types.h>
      #include <signal.h>
      
    • 函数原型:int kill(pid_t pid, int sig);
      • pid 取值如下
        • pid > 1:将信号sig发送到进程ID值为pid指定的进程
        • pid = 0:信号被发送到所有和当前进程在同一个进程组的进程
        • pid = -1:将sig发送到系统中所有的进程,但进程1(init)除外
        • pid < -1:将信号sig发送给进程组号为-pid (pid绝对值)的每一个进程
      • sig 为 信号值
      • 返回值
        • 0:发送成功
        • -1:发送失败

3.4.2 raise()

  • raise() 函数为进程向自身发送信号
  • 函数
    • 头文件
      #include <signal.h>
      
    • 函数原型:int raise(int sig);
      • sig 为 信号值
      • 返回值
        • 0:发送成功
        • -1:发送失败

3.4.3 alarm()

  • alarm() 称为闹钟函数,设置时间为 seconds 秒,时间到后,它就向进程发送SIGALARM信号。在时间未到时便重新调用 alarm() 函数,会更新到时值。
  • 函数
    • 头文件
      #include <unistd.h>
      
    • 函数原型:unsigned int alarm(unsigned int seconds);

3.5 捕获信号函数

  • signal()、sigaction()等函数

3.5.1 signal()

  • signal()主要是用于捕获信号,可以改变进程中对信号的默认行为
  • 函数
    • 头文件
      #include <signal.h>
      
    • 函数原型
      typedef void (*sighandler_t)(int);
      sighandler_t signal(int signum, sighandler_t handler);
      
    • signum 是指定捕获的信号,如果指定的是一个无效的信号,或者尝试处理的信号是不可捕获或不可忽略的信号(如SIGKILL),errno将被设置为EINVAL
    • handler 是一个函数指针,它的类型是 void(*sighandler_t)(int) 类型
    • handler 也可以是一个宏定义
      • SIG_IGN:忽略该信号
      • SIG_DFL:采用系统默认方式处理信号

3.5.2 sigaction() *

  • 不推荐读者使用signal(),而推荐使用 sigaction();
  • 函数
    • 头文件
      #include <signal.h>
      
    • 函数原型:int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
      • signum:指定捕获的信号值

      • act:是一个结构体

        • sa_handler 是一个函数指针,是捕获信号后的处理函数
        • sa_sigaction 是扩展信号处理函数,它也是一个函数指针,不仅可以接收到int 型的信号值,还会接收到一个 siginfo_t 类 型的结构体指针,还有一个void类型的指针,还有需要注意的就是,不要同时使用 sa_handlersa_sigaction,因为这两个处理函数是有联合的部分(联合体)
        • sa_mask 是信号掩码,它指定了在执行信号处理函数期间阻塞的信号的掩码,被设置在该掩码中的信号,在进程响应信号期间被临时阻塞。除非使用 SA_NODEFER 标志,否则即使是当前正在处理的响应的信号再次到来的时候也会被阻塞
        • re_restorer 则是一个已经废弃的成员变量,不要使用
        • oldact 返回原有的信号处理参数,一般设置为NULL即可
        • sa_flags 是指定一系列用于修改信号处理过程行为的标志
          • SA_NOCLDSTOP 使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。即当它们接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU(停止)中的一种时或接收到SIGCONT(恢复)时,父进程不会收到通知
          • SA_NOCLDWAIT 从Linux 2.6开始就存在这个标志了,它表示父进程在它的子进程终止时不会收到 SIGCHLD 信号,这时子进程终止则不会成为僵尸进程。
          • SA_NODEFER 一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
          • SA_RESETHAND 信号处理之后重新设置为默认的处理方式。
          • SA_SIGINFO 从Linux 2.2开始就存在这个标志了,使用 sa_sigaction成员而不是使用sa_handler 成员作为信号处理函数。
        struct sigaction {
                                 void     (*sa_handler)(int);
                                 void     (*sa_sigaction)(int, siginfo_t *, void *);
                                 sigset_t   sa_mask;
                                 int        sa_flags;
                                 void     (*sa_restorer)(void);
             };
        
        • siginfo_t
        siginfo_t {
           int      si_signo;     /* 信号数值 */
           int      si_errno;     /* 错误值 */
           int      si_code;      /* 信号代码 */
           int      si_trapno;   /*导致硬件生成信号的陷阱号,在大多数体系结构中未使用*/
           pid_t    si_pid;       /* 发送信号的进程ID */
           uid_t    si_uid;       /*发送信号的真实用户ID */
           int      si_status;    /* 退出值或信号状态*/
           clock_t  si_utime;     /*消耗的用户时间*/
           clock_t  si_stime;     /*消耗的系统时间*/
           sigval_t si_value;     /*信号值*/
           int      si_int;       /* POSIX.1b 信号*/
           void    *si_ptr;
           int      si_overrun;   /*计时器溢出计数*/
           int      si_timerid;   /* 计时器ID */
           void    *si_addr;      /*导致故障的内存位置 */
           long     si_band;
           int      si_fd;        /* 文件描述符*/
           short    si_addr_lsb;  /*地址的最低有效位 (从Linux 2.6.32开始存在) */
           void    *si_lower;     /*地址冲突时的下限*/
           void    *si_upper;     /*地址冲突时的上限 (从Linux 3.19开始存在) */
           int      si_pkey;      /*导致的PTE上的保护密钥*/
           void    *si_call_addr; /*系统调用指令的地址*/
           int      si_syscall;   /*尝试的系统调用次数*/
           unsigned int si_arch;  /* 尝试的系统调用的体系结构*/
        }
        

3.6 信号集

  • 数据类型 sigset_t 是信号集,信号掩码就是这种类型
  • 头文件:#include <signal.h>
  • 函数
    1. int sigemptyset(sigset_t *set);
      • 将信号集初始化为空,使进程不会屏蔽任何信号
    2. int sigfillset(sigset_t *set);
      • 将信号集初始化为包含所有已定义的信号
    3. int sigaddset(sigset_t *set, int signum);
      • 添加一个信号到信号集中
    4. int sigdelset(sigset_t *set, int signum);
      • 从信号集中删除一个信号
    5. int sigismember(const sigset_t *set, int signum);
      • 判断一个信号是否在信号集中
  • 注意:
    • 一个应用程序,在使用信号集前,必须对其进行初始化,即是调用 sigemptyset()sigfillset()

3.7 例子

  • 例程来自野火
  • 实验现象
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

/** 信号处理函数 **/void signal_handler(int sig)                    //(1){
    printf("\nthis signal numble is %d \n",sig);
    if (sig == SIGINT) {
        printf("I have get SIGINT!\n\n");
        printf("The signal is automatically restored to the default handler!\n\n");
        /** 信号自动恢复为默认处理函数 **/
    }

}
int main(void){
    struct sigaction act;
    printf("this is sigaction function test demo!\n\n");
    /** 设置信号处理的回调函数 */
    act.sa_handler = signal_handler;
    /* 清空屏蔽信号集 */
    sigemptyset(&act.sa_mask);
    /** 在处理完信号后恢复默认信号处理 */
    act.sa_flags = SA_RESETHAND;
    sigaction(SIGINT, &act, NULL);
    while (1)
    {
        printf("waiting for the SIGINT signal , please enter \"ctrl + c\"...\n\n");
        sleep(1);
    }
    exit(0);
}

参考:

  * 野火

【linux】系统编程-1-进程、管道和信号

上一篇:MacOS环境变量加载顺序


下一篇:vmware安装centos虚拟机之后没有图形界面