进程控制~

一.进程控制

1.进程创建

我们可以通过./cmd来运行我们的程序,而我们运行的程序就是bash进程常见的子进程。当然我们也可以通过fork()系统调用来创建进程。

NAME

fork - create a child process

SYNOPSIS

#include <unistd.h>

pid_t fork(void);

fork会对子进程和父进程返回不同的返回值,对子进程返回0,对父进程返回子进程的pid,如果返回值小于0,则说明子进程创建失败。

fork引申出来的一系列问题:fork函数有两个返回值,fork函数对父子进程返回值不同,fork的返回值为什么大于0又等于0导致if if-else同时成立......这些问题已经在进程部分解释,这里就不再赘述了。【Linux】进程-CSDN博客

2.进程终止

进程终止的本质就是释放资源,就是释放进程创建时申请的内核数据结构和内存中的代码和数据。

而进程退出有三种场景:

  • 代码执行完,结果正确
  • 代码执行完,结果错误
  • 代码异常终止

而对于子进程来说,它是由父进程创建的,为了实现某种功能的,所以它也有上述三种退出场景。那么我们怎么区分进程的执行情况呢?

对于我们所写的C程序来说,为什么我们在main函数的最后要返回一个0呢?通常这表示程序正常结束,如果代码执行的结果不正确,此时就会返回一个非0值。所以对于一个C程序来说,我们可以通过返回值来判断代码的执行情况。而这个返回值就是错误码。

我们可以通过**strerror()**函数将错误码转换成错误信息,而errno则会记录最近的一次错误码。

就比如fopen这个函数,如果打开失败,会返回一个空的文件指针,并且设置错误码。我们可以故意让fopen失败,借助sterror(errno)来观察错误信息。

在C语言中,一共有134个错误码,大家可以用循环打印出每一种来看看。

对一个C语言程序来说,它用错误码来标记执行情况,那么对于一个进程来说也一样!!!。一个进程的退出码,表示了该进程的执行情况。

我们可以使用**echo $?**来查看最近一个进程的退出码

对于上图来说,我们首先调用了ll,该进程正常结束,所以退出码为0,接着我们使用cd命令,跳转到不存在的路径,并且bash表示出现了错误,此时退出码就变成了1,在使用pwd命令,退出码就有变成了1.

在Linux操作系统中,一个进程的退出码如果分为两种,0和非0,0表示成功,非0表示出错

  • 1:一般错误(如参数错误)
  • 2:误用shell命令(如rm删除只读文件)
  • 126:权限不足或命令不可执行
  • 127:命令未找到
  • 130:被CTRL+c终止
  • 139:段错误

而进程的退出码会在该进程推出的时候写入该进程的pcb中

我们可以通过main函数的返回值 /exit /_exit来设置进程的退出码。

return语句在main函数处执行,才表示进程结束,如果在其他函数内执行,只表示该函数结束。

对于exit函数来说,不论在代码的那个地方执行到该语句,进程都将结束,并将exit设置的退出码写入到进程的pcb中。

而除了c标准库的的exit函数外,还有一个系统调用_exit函数。这两个函数有什么关系呢?

exit其实是对_exit函数的封装,调用exit函数最终还是会调用_exit。当然两者也是有区别的:

  • exit在结束进程之前会进行清理操作,刷新文件缓冲区
  • _exit会立刻结束进程,不进行清理操作

有了这个认识,我们就可以猜想,缓冲区的概念是C语言提出的,而不是系统内的缓冲区。

3.进程等待

为什么要进行进程等待呢?

之前说过,当子进程结束之后,父进程没有回收子进程资源时,子进程就会处于僵尸状态,进而导致内存泄漏。

而且一旦进程进入僵尸状态,此时kill -9 也无法无能为力,因为kill -9 不能杀死一个已经死掉的进程。

我们父进程创建子进程是为了帮助我们执行任务的,执行的如何父进程得知道吧!

所以,之所以要进行进程等待,就是为了让父进程回收子进程的资源,避免内存泄漏,并且获取子进程的退出信息。最重要的是回收子进程资源,有时候我们并不关系子进程的结束信息。

说的好,那怎么进行进程等待呢?

3.1wait

cpp 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int* status);

wait就可以使父进程进行等待子进程,而wait会等待父进程的任意一个子进程,一旦等到子进程,等待就结束了。而它还有一个输出型参数status,我们稍后再说

我们接着下面这个例子,来观察子进程由Z->X的过程

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // dhild
        int cnt = 5;
        while (cnt)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }

    // father  
    sleep(10);
    pid_t wid = wait(NULL);
    if (wid > 0)
    {
        printf("success %d\n", wid);
    }
    else
    {
        printf("failed\n");
    }

    sleep(10);
    return 0;
}

说明:创建子进程后,让子进程循环5秒后,然后退出,此时父进程还在休眠中,还没有执行等待代码,此时子进程状态为Z,接着过了又过了5秒之后,父进程休眠结束,执行等待,我们现在不用参数,可以传NULL,此时子进程状态转为X。接着我们让父进程继续睡眠10秒。

监控脚本,每隔一秒观察父子进程的状态

bash 复制代码
while :; do ps ajx | head -1 && ps ajx | grep proc | grep -v grep; echo "-----------------------------------------------------------------";  sleep 1 ; done

接下里我们只需要死盯着STAT即可。

至此,我们成功验证了父进程等待子进程

通过wait()方法,我们可以使进入僵尸状态的子进程,被父进程回收。

3.2waitpid

但是今天的主角并不是wait,因为其功能简单,而是waitpid则是最优先考虑使用的!!!

cpp 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid,  int  *status,  int options);

wait就好像是waitpid的一个子功能,waitpid是一个完全版的wait,可以进行多种等待过程。

返回值pid_t:

  • > 0 :等待成功
  • < 0 :等待失败

参数pid:

  • >0:是多少,就表示等待pid为该数的子进程
  • -1:表示等待任意一个子进程,类似wait

参数status:输出型参数,与wait的功能一致,获取等待的子进程的退出码。

option:可以控制不同方式的等待过程,默认参数为0,表示阻塞等待......

当我们以waitpid(-1, NULL, 0);的方式调用时,此时waitpid和wait是一样的功能。

上面说了,进程等待主要是为了回收子进程的资源,也可以获取子进程的退出信息,回收子进程资源很好理解,将其的状态从Z->X,操作系统就会回收。那么如何获取子进程的退出信息呢?这就是参数status的事了!!!

0x1参数status:

status作为一个输出型参数,子进程执行结束后,会将自己的退出信息和退出码写到自己的pcb中,父进程等待到子进程后,操作系统会在子进程的pcb中拿出退出信息和退出码写入到status中,最后将信息带回给父进程。

但是退出码是代码执行完毕,结果正确或不正确时的标志,而进程终止还有可能是异常终止,此时子进程也会有退出码么?

首先,进程异常终止肯定收到了信号,比如我们使用kill -9 的方式杀死进程就是给进程传递了信号。另外,一旦进程异常终止了,此时的退出码就无意义了,此时更关心的是退出信号。

那么也就是说,子进程pcb中,除了要维护退出码,还要维护退出信号,但是我们只传了一个整型,如果获取两个内容?

答案就是位图。对于status来说,它的32个比特位被分为了三个部分,高16位,中8位和低8位。高16位没有被使用,中8位记录着退出码,低8位中的最高位是一个core dump标志位,是程序崩溃时生成的内存快照文件,用于调试分析,这部分与信号有关,这里不做解释,最后的7位存储的就是退出信号的编号了。

下面,我们就验证一个status的作用:

cpp 复制代码
#include <stdio.h>                                                                                                                                      
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        int cnt = 5;
        while (cnt)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(66); // 子进程退出码设置为66,方便观察
    }

    // father
    int status = 0;
    pid_t wid = waitpid(id, &status, 0);
    if (wid > 0)
    {
        printf("success pid:%d exit_code:%d\n", wid, (status >> 8 & 0xFF));
    }
    else
    {
        printf("failed\n");
    }
    return 0;
}

说明:创建子进程后,我们让子进程执行5秒,子进程退出时,设置其退出码为66,接着父进程等待子进程,等待成功后,打印信息,并打印退出码。因为退出码存储在status的次低8未,所以我们首先右移8位,接着&0xFF,就可以拿出次低8位的内容。

上面是正常退出的结果,我们也可以测试进程异常终止时它的退出信号:

cpp 复制代码
#include <stdio.h>                                                                                                                                      
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // child
        while (1)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
        exit(66); // 子进程退出码设置为66,方便观察
    }

    // father
    int status = 0;
    pid_t wid = waitpid(id, &status, 0);
    if (wid > 0)
    {
        printf("success pid:%d exit_code:%d exit_signal:%d\n", wid, (status >> 8 & 0xFF), status & 0x7F);
    }
    else
    {
        printf("failed\n");
    }
    return 0;

说明:我们让子进程死循环,让父进程进行等待,接着使用kill -9 杀死子进程,观察退出码和退出信号。

自此,我们就验证了wait和waitpid的status参数的用途。当进程被信号杀死时,status的次低8位就被置为了0.

但是我们每一次获取子进程退出码都要进行位运算么? 不,操作系统为我们提供了宏接口,我们可以直接使用宏来获取退出码和退出信号。

获取子进程退出码

bash 复制代码
WEXITSTATUS(status)

如果自己是正常结束的,则返回true,反之返回false

cpp 复制代码
WIFEXITED(status)

获取子进程退出信号

cpp 复制代码
WTERMSIG(status)

通过上述三个宏,我们就可以让我们的等待过程变得更加完整健壮:

cpp 复制代码
// father
pid_t rid = waitpid(id, &status, 0);
if(rid>0) // 等待成功
{
    if(WIFEXITED(status)) // 正常退出
    {
        printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
    }
    else
    {
        printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
    }
}

了解了status之后,我们在了解下一个参数option,它可以设置等待的行为。

0x2参数option:

wait等待和waitpid的第三个参数为0时都表示阻塞等待,什么是阻塞等待呢?

阻塞等待就像c里面的scanf和c++里面的cin。当父进程执行到等待语句时,父进程就卡住了,除非子进程死亡,否则父进程什么也做不了。

当然,除了阻塞等待,还有非阻塞等待。第三个参数我们可以传WNOHANG,来使父进程不阻塞等待,可以执行自己的事。它表示的是return immediately if no child has exited.即父进程等待子进程时,发现没有一个子进程结束,它立刻返回,接着执行自己的代码。

但是归根结底父进程还是得等待子进程结束,如果非阻塞等待询问一次直接结束,执行自己的代码去了,这不还是会造成僵尸状态,内存泄漏。

所以非阻塞等待主要的使用场景是结合循环来实现非阻塞轮询,再结合waitpid的返回值,就可以实现父进程在等待过程中,执行自己的逻辑:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child
        while(1)
        {
            printf("child pid:%d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
        exit(66);
    }

    // father
    while(1)
    {
        int status = 0;
        pid_t rid = waitpid(id, &status, WNOHANG);

        if(rid>0) // 等待成功
        {
            if(WIFEXITED(status)) // 正常退出
            {
                printf("terminated normally exit_code:%d\n", WEXITSTATUS(status));
            }
            else// 异常退出
            {
                printf("terminated by signal exit_signal:%d\n", WTERMSIG(status));
            }
            break;
        }
        else if(rid == 0)
        {
            // 询问结束,子进程未退出,执行自己的逻辑
            // ......
            printf("非阻塞轮询执行逻辑......\n");
        }
        else
        {
            printf("等待失败\n");
            break;
        }
    }
    return 0;
}

说明:这里采用非阻塞等待的方式,一创建子进程后,父进程即可进入非阻塞轮询状态,首先调用一次,判断子进程是否结束,如果返回值>0表示等待成功,此时退出循环,如果返回值 == 0,表示调用结束,子进程未退出,如果返回值小于0,表示等待失败,也退出循环。在非阻塞轮询期间内,每进行一次调用,如果子进程未退出,此时父进程就可以执行自己的代码逻辑。待下一次继续等待。

4.进程程序替换

fork()之后,父子进程各自执行代码的一部分,那如果子进程想要执行一个全新的程序呢?进程程序替换来实现!!!

4.1程序替换的原理

进程 = 内核数据结构pcb+代码和数据,当我们进行程序替换的时候,操作系统会从磁盘中,将要替换的程序的代码和数据覆盖式的放在原代码和数据的位置上。

那么照上面所说,我们程序替换之后,原代码和数据就被覆盖了,是不是替换上来的程序执行完了,那么整个进程就结束了?

没错!程序替换结束后,替换上来的程序结束了,整个进程就结束了。

看个例子:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>


int main()
{
    printf("进程开始执行,pid:%d, ppid:%d\n", getpid(), getppid());

    execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 进行程序替换,执行ls命令

    printf("进程结束,pid:%d, ppid:%d\n", getpid(),getppid());

    return 0;
}

说明:进程一开始便打印信息,接着我们就进行程序替换,执行ls命令,按照上面所说,最后面的打印语句将不再执行。

总结:在进行程序替换的时候,并不会创建新的进程,而是用待替换的程序的代码和数据覆盖原来程序的代码和数据,并且替换之后,原代码的后半部分就不存在了,替换完的程序执行完毕,进程就结束了。

4.2exec系列接口

0x1exec系列函数返回值

exec系列函数的接口在成功调用时没有返回值,只有失败才有返回值-1,并且设置错误码。

The exec() functions return only if an error has occurred. The return value is -1, and errno is set to indicate the error.

当你程序都已经替换成功了,原来的进程上下文都已经不在了,你要返回值干嘛呢?返回值谁接受呢?

失败返回-1,这是非常明确的,所以我们只需要判断返回值是否为-1即可,如果不是-1就说明成功,失败了,我们在根据错误码来判断错误的原因。

0x2execl

exec系列函数都有相同的前缀,那么不同的后缀,就能体现出不同函数的特点。

就比如execl来说,l可以理解为list,即我们传入的参数就好像在一个一个链表的节点中存储的一样,而链表最后一个节点的next指针为NULL,所以对于execl函数来说,它的参数也要以NULL结尾。

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

int execl(const char *path, const char *arg,...);

参数path表示要替换的程序所在的位置即路径+程序名,接下来的参数我们在命令行怎么写,在这就怎么传。

就比如,我们要执行ls命令,写法如下

cpp 复制代码
execl("/usr/bin/ls", "ls", "-l" , "-na", NULL);

我们不仅可以让父进程进行程序替换,也可以创建出来一个子进程,让其执行要替换的程序:

cpp 复制代码
int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        execl("/usr/bin/ls", "ls", "-l", NULL);
        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");

    return 0;
}

首先,子进程进行替换之后,不会执行exit函数,因为后序代码已经被覆盖了,所以我们父进程打印子进程的退出码是不是设置的22,而是0.

其次,为什么子进程进行程序替换没有影响父进程呢?

第一,进程具有独立性;第二,父子进程本来共享代码和数据,子进程进行程序替换,对代码和数据进行了修改,此时会进行写时拷贝。

综上,所以子进程进行程序替换是不会影响父进程的!!!

那么可不可以替换我们自己写的程序呢?

当然也是可以的!!

首先我们写一段C++程序,并编译成可执行文件。

接着便是修改execl的参数

cpp 复制代码
execl("./other", "other", NULL); // 第二个参数可以带./,也可以不带

看结果:

0x2execlp
cpp 复制代码
#include <unistd.h>

int execlp(const char * file, const char *arg ,...);

该函数多了一个后缀p,这个p的意思是环境变量PATH,所以使用这个接口时,第一个参数如果不带路径只是文件名,该函数就会从PATH环境变量里面去搜索该文件。

所以,execlp会自动在PATH环境变量中搜素指定的命令,如果找不到,就会失败返回-1并设置错误码!!!

cpp 复制代码
int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        //execlp("ls", "ls", "-l", NULL);  // 成功替换
        execlp("other", "other", NULL);    // 替换失败 , 并设置错误码为22
        exit(22);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");
    return 0;
}

说明:对于ls命令来说,他所处的路径/usr/bin/ls,在PATH环境变量中,所以不带路径是可以找到的。但是我们的other是在当前目录下,函数找不到,就会失败!!!

0x3execv

不一样的来了

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

 int execv(const char *path, char *const argv[]);

第一个参数依旧是可执行文件的路径+文件名,表示我要执行谁;

第二个参数则不一样了,不在像命令行那样传参数了,而是传了一个命令行参数表!!!所以这里后缀的v其实就是vector的意思。

只需要将命令行参数放入放入一个指针数组里面即可,当然也要以NULL结尾。

cpp 复制代码
int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);

        char* const argv[] = {
            (char* const)"ls",
            (char* const)"-l",
            (char* const)"-a",
            NULL
        };
        execv("/usr/bin/ls", argv);

        exit(22);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("%d\n", WEXITSTATUS(status));
    printf("程序结束\n");
    return 0;
}

0x4execvp
cpp 复制代码
#include <unistd.h>

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

有了上面的基础,这个就很好理解了,第一个参数会默认在PATH中搜索命令,第二个参数表示将命令行参数以指针数组的方式传过去,并且以NULL结尾。

这个就不做示例了,相信大家没问题

0x5execvpe
cpp 复制代码
#include <unistd.h>

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

这个就又不一样了,多了一个后缀e,表示的是环境变量envion。使用该接口除了传递文件名和命令行参数表外,还要传一个环境变量表。传了该环境变量表后就会将原来全局的环境变量表给覆盖掉,子进程只会看到传的环境变量了。

cpp 复制代码
// proc.c
int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        char* const argv[] = {
            (char* const)"other",
            NULL
        };

        char* const envp[] = {
            (char* const)"MYENV = 112233445566778899",
            NULL
        };

        execvpe("./other", argv, envp);

        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);

    printf("程序结束\n");
    return 0;
}

// other.cc

#include <iostream>
#include <cstdio>

int main()
{
    extern char** environ;
    for(int i=0; environ[i]; ++i)
    {
        printf("env[%d] -> %s\n", i, environ[i]);
    }
    return 0;
}

说明:为了测试execvpe接口对环境变量的影响,我们利用proc替换我们自己的.cc程序。该.cc程序主要工作就是打印环境环境变量。我们让该接口替换我们自己的程序,并且在替换前设置了环境变量envp,按照上面所说,会将替换程序的环境变量给替换掉,所有.cc打印出来的环境变量应该只有我们自己设置的。

我们看结果,确实和我们预料的一样。

对于第三个参数来说,如果envp为空的话,即只有一个NULL,新程序就会继承原来的环境变量,不会改变。

但是我们使用该接口时想给子进程新增环境变量,而不是覆盖原来的环境变量,怎么实现呢?

我们可以借助putenv函数来实现新增环境变量

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

int putenv(char *string);

在每一个进程的进程地址空间上,有一段空间专门用来存储当前进程的环境变量,putenv就是将指定环境变量加载到该进程地址空间上的指定位置。这样,我们再使用该接口时,第三个参数传NULL,新程序就会继承原来的环境变量,当然也包含我们新增的环境变量。

cpp 复制代码
// proc.c

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


int main()
{
    printf("程序开始\n");
    if(fork() == 0)
    {
        sleep(1);
        char* const argv[] = {
            (char* const)"other",
            NULL
        };

        // 设置新增环境变量
        char* const addNewEnv[] = {
            (char* const)"MYENV1=11111111",
            (char* const)"MYENV2=22222222",
            (char* const)"MYENV3=33333333",
            NULL
        };

        // 加载新增环境变量
        for(int i=0; addNewEnv[i]; i++)
        {
            putenv(addNewEnv[i]);
        }

        extern char** environ; // 指向环境变量表的全局指针

        execvpe("./other", argv, environ);

        exit(22);
    }

    int status = 0;
    waitpid(-1, &status, 0);

    printf("程序结束\n");
    return 0;
}

// other.cc

#include <iostream>
#include <cstdio>

int main()
{
    extern char** environ;
    for(int i=0; environ[i]; ++i)
    {
        printf("env[%d] -> %s\n", i, environ[i]);
    }
    return 0;
}

说明:我们先定义出想要新增的环境变量,然后通过putenv将其加载到子进程的环境变量上,然后我们将全局的environ传给execvpe函数,让其替换程序,此时替换上来的程序就有了我们新增的环境变量。但是这个新增的环境变量只有子进程可以看到,父进程看不到。

0x6execle

经过上面的分析,相比大家已经知道了该接口的用法了

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

int execle(const char *path, const char *arg,
                  ..., char * const envp[]);

4.3execve

通过对上面exec家族的了解,我们发现,按照规律应该有一个execve的函数啊,为什么找不到呢?

因为,这个函数是系统调用,而上面的exec家族都是对其在语言层上的封装

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

int execve(const char *filename, char *const argv[],
                  char *const envp[]);

但是我们以及可以用上面了解到的知识来解读它,v表示我们要传一个命令行参数表,e表示我们得传一个环境变量表!!!


上述,便是进程控制的全部内容~~~

本章完~

相关推荐
此生只爱蛋15 分钟前
【Linux】正/反向代理
linux·运维·服务器
qq_54702617921 分钟前
Linux 基础
linux·运维·arm开发
zfj32127 分钟前
sshd除了远程shell外还有哪些功能
linux·ssh·sftp·shell
废春啊33 分钟前
前端工程化
运维·服务器·前端
我只会发热37 分钟前
Ubuntu 20.04.6 根目录扩容(图文详解)
linux·运维·ubuntu
爱潜水的小L1 小时前
自学嵌入式day34,ipc进程间通信
linux·运维·服务器
保持低旋律节奏1 小时前
linux——进程状态
android·linux·php
zhuzewennamoamtf1 小时前
Linux I2C设备驱动
linux·运维·服务器
zhixingheyi_tian1 小时前
Linux 之 memory 碎片
linux
邂逅星河浪漫1 小时前
【域名解析+反向代理】配置与实现(步骤)-SwitchHosts-Nginx
linux·nginx·反向代理·域名解析·switchhosts