操作系统系列:关于终端Shell

发布时间:2023年12月20日


Shell

Unix命令处理器或者Shell都是进程,它获取用户键入的命令,fork出一个进程,子进程调用exec来执行用户的命令,父进程等待子进程执行结束。
这是一段简单的shell伪代码:

   pid_t pid;
    while (1) {
       GetNextCommand();
       pid = fork();
       if (pid == 0) {
          ExecCommand();
          PrintErrorMsg()
          exit(0);
       }
       else if (pid > 0) 
          wait();
       else 
          HandleForkFailure();
    }

Unix 允许用户通过在命令行中添加与号 (&) 来实现在后台运行命令,当进程在后台运行时,他不能从终端接收用户输入,并立即显示 shell 提示符,允许用户在后台进程完成之前输入新命令。对于上面的伪代码,如果想要在后台运行,开发者要做些什么呢?

在Win32上创建一个新进程

这是在Win32上创建新进程的API:

BOOL CreateProcess( 
    LPCTSTR lpApplicationName, /* executable program */
    LPTSTR lpCommandLine,      /* command line args  */
    LPSECURITY_ATTRIBUTES lpProcessAttributes,  /* use NULL */
    LPSECURITY_ATTRIBUTES lpThreadAttributes,   /* use NULL */
    BOOL bInheritHandles,  /* does proc inherit parents open handles */
    DWORD dwCreationFlags, 
    LPVOID lpEnvironment, /* if NULL, use parent environment */
    LPCTSTR lpCurrentDirectory, /* if NULL, use parent curr dir */
    LPSTARTUPINFO lpStartupInfo, 
    LPPROCESS_INFORMATION lpProcessInformation 
); 

typedef struct PROCESS_INFORMATION {
    HANDLE hProcess;
    HANDLE hThread;
    DWORD  dwProcessId;
    DWORD  dwThreadId;
} PROCESS_INFORMATION;

CreateProcess相当于Unix上的fork()和exec()。相比于Unix的系统调用,Win32 APIs几乎都会有很多的参数。

  • LPCTSTR lpApplicationName 这是要执行的进程的路径名,相当于 Unix execl 命令的第一个参数,它可以是相对路径名或绝对路径名。
  • LPTSTR lpCommandLine 这个参数通常是 NULL
  • LPSECURITY_ATTRIBUTES lpProcessAttributes 进程属性,通常是NULL
  • LPSECURITY_ATTRIBUTES lpThreadAttributes 线程属性,通常是NULL
  • BOOL bInheritHandles 如果该值为 TRUE,则每表示个打开的文件句柄(或其他句柄)也可以在子进程中打开
  • DWORD dwCreationFlags 通过该值设置一些标志,默认值是0
  • LPVOID lpEnvironment 允许开发者改变环境,如果该值取NULL表示子进程继承父进程的环境
  • LPCTSTR lpCurrentDirectory 允许开发者改变目录,如果为 NULL,则子进程与父进程有相同的当前工作目录
  • LPSTARTUPINFO lpStartupInfo 这是一个指向有关如何呈现新进程的信息的指针。 在Windows环境中,一个新进程通常是一个新窗口,它会告诉操作系统该窗口放置在哪里,窗口的高度和宽度以及其他信息。
  • LPPROCESS_INFORMATION lpProcessInformation 这是一个向父级返回有关子级的信息的结构,例如进程 ID 和句柄。
    与大多数 Win32 API 一样,此调用在成功时返回 TRUE,在失败时返回 FALSE。

下面是一段简短的示例代码,用于启动记事本。

#include <windows.h>
#include <stdio.h>
#include <string.h>

char *GetErrorMessage() 
{
    char *ErrMsg;
        FormatMessage( 
             FORMAT_MESSAGE_ALLOCATE_BUFFER | 
             FORMAT_MESSAGE_FROM_SYSTEM |              
                         FORMAT_MESSAGE_IGNORE_INSERTS,
             NULL,
             GetLastError(),
             MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), // Default language
             (LPTSTR) &ErrMsg,
             0,
             NULL 
        );
        return ErrMsg;
}

int main()
{
    char commandline[255];
    PROCESS_INFORMATION ProcessInfo;
    STARTUPINFO StartupInfo;
    strcpy(commandline,"notepad");
    GetStartupInfo(&StartupInfo);
    if (CreateProcess (
        "c:\\winnt\\notepad.exe",
        commandline, NULL, NULL,
        FALSE, 0, NULL, NULL, 
        &StartupInfo,
        &ProcessInfo) == TRUE) 
    {
        printf("Create was successful\n");
        printf("Proc id is %d\n",ProcessInfo.dwProcessId);
    }
    else 
    {
		printf("error, CreateProcess failed, error number %d\n",GetLastError());
        printf("%s\n",GetErrorMessage());
    }
    return 0;
}

请特别注意 CreateProcess 的第一个参数,由于反斜杠是字符串中的转义字符,因此字符串中的反斜杠字符必须用双反斜杠表示。
这在 Unix 中不会出现太多,但在 Windows 中却是个一直存在的问题,因为反斜杠是目录分隔符。

重定向输入和输出

不管在 Unix上,还是在Windows 上,当创建一个新进程时,都会自动创建三个 I/O 流,它们称之为标准输入、标准输出和标准错误。

  • 标准输入默认为控制终端的键盘;
  • 标准输出和标准错误默认为控制终端的监视器。

监视器默认有两个单独的输出流的原因是,有时用户希望将正常输出重定向到文件,但希望将错误消息显示在终端上(或发送到 不同的文件)。

  1. 用户可以在 shell 提示符下使用 > 运算符将进程的输出从终端重定向到文件。 例如:

    ls -l > outfile
    

    这个操作会将 ls -l 命令的输出发送到名为 outfile 的文件,而不是终端。

    shell 还可以将标准输入从键盘重定向到带有 < 字符的文件。

    每个 Unix 进程都有一个与其关联的文件描述符数组。系统调用 open 就返回一个文件描述符,文件描述符是一个低位正整数。

    按照约定,标准输入是文件描述符 0,标准输出是文件描述符 1,标准错误是文件描述符 2。

    当程序打开文件时,通常会为其分配最低的未使用文件描述符。写入到终端的函数(例如 printf)或者从键盘读取的函数(例如 scanf、getchar)都会调用写入和读取系统调用,并将文件描述符设置为标准输出和标准输入。

    如果开发者想要将标准输入或标准输出从程序内重定向到文件,请使用 dup 或 dup2 系统调用。 这两个调用会复制一个文件描述符,对 dup2 的调用需要两个参数:fd1 和 fd2,它们都是文件描述符。 第一个参数 fd1 必须是已打开的文件描述符, 该调用使 fd2 引用与 fd1 相同的文件。 如果fd2打开,则首先要把它关闭。

    请看下面的例子:

    /* dup2.c - a demo of the dup2 call */
    #include <stdio.h>
    #include <fcntl.h>
    #include <errno.h>
    #include <unistd.h>
    int main()
    {
       int fd, retval;
       fd = open("temp",O_WRONLY | O_CREAT | O_TRUNC, 0200 | 0400); 
       /* fd is probably 3, the lowest unused file descriptor */
       if (fd < 0) {
              perror("Error on opening temp");
              exit(0);
       }
       printf("This line written on the terminal\n");
       retval = dup2(fd,1); 
       if (retval != 1) {
           perror("Error on dup2");
           exit(0);
       }   
       printf("This line is written to the file temp\n");
       return 0;
    }
    

    该程序打开一个名为 temp 的文件进行写入,然后它调用 printf,将一行写入终端,下一行是程序的关键语句:

    retval = dup2(fd, 1);
    

    该行使文件描述符 1 引用 fd 引用的文件,由于文件描述符 1 引用标准输出,因此首先要将其关闭。

    在 printf 函数中,有一行代码如下所示:

    n = write(1,.....
    
  2. 通常,文件描述符1指的是标准输出,但我们的程序重新定义了它,使其指的是文件temp,因此,对 write 的调用将写入文件而不是终端。如果成功,dup2 返回其第二个参数的值,否则返回负值。

    dup 是 dup2 的老版本,它只使用一个参数,即要复制的文件描述符,并将最低的未使用文件描述符设置为等效。 要使用此功能,请在调用 dup 之前根据需要关闭文件描述符 0 或 1。

    请参见dup2的帮助文档。

    • NAME
      dup2 - 复制打开的文件描述符
    • SYNOPSIS
      #include <unistd.h>
      int dup2(int fildes, int fildes2);
    • DESCRIPTION
      dup2() 函数使文件描述符 fildes2 引用与 fildes 相同的文件。 fildes 参数是引用打开文件的文件描述符,fildes2 是一个非负整数,小于调用进程允许的打开文件描述符最大数量的当前值。 请参阅 getrlimit(2)。 如果 fildes2 已经引用了一个打开的文件,而不是 fildes,则首先将其关闭。 如果fildes2引用fildes,或者fildes不是有效的打开文件描述符,则fildes2不会首先被关闭。dup2() 函数相当于 fcntl(fildes, F_DUP2FD, fildes2).
    • RETURN VALUES
      成功完成后,将返回表示文件描述符的非负整数。 否则,返回-1并设置errno以指示错误。
    • ERRORS
    • EBADF - fildes参数不是有效的打开文件描述符。.
    • EBADF - files2参数为负数或不小于 getrlimit(RLIMIT_NOFILE, …) 返回的当前资源限制。
    • EINTR - 在调用dup2时返回了一个信号。
    • EMFILE - 进程打开了过多的文件。
  3. 调用 fork 后会创建一个与旧进程相同的新进程,父进程的文件描述符表也会复制到子进程,因此如果父进程有一个打开的文件,子进程将继承这个打开的文件。 此外,如果父进程复制了文件描述符,子进程也将继承它,文件描述符表也会通过对系统调用 exec 系列成员的调用进行复制。

    这对于 shell 的实现很重要,shell 分叉出一个新进程来执行命令,如果用户指定输出到文件而不是标准输出,那么shell 可以使用 dup2 来将标准输出重定向到文件,然后再调用 exec 来执行命令。

祭出一个编程小作业

请编写一个简单的 shell,它在一个无限循环中重复执行以下操作:

  • 显示提示 (>)
  • 从用户处获取命令行
  • 解析命令行
  • 执行命令
  1. 用户输入的命令行应包含一个命令,该命令可以是相对路径名或绝对路径名,后跟零个或多个参数,然后是输入和输出重定向。 stdin 或 stdout 都可以重定向,或者两者都不能重定向。
    如果用户附加一个与号 (&),则该进程应在后台运行。

    以下都是有效的命令行:

    a.out
    a.out arg1 arg2
    a.out arg1 arg2 > outfile < infile
    a.out arg1 arg2>outfile &

    这个作业最难的部分是解析命令行,您可以下载一个文件 ParseCommandLine.h,它定义了一个 CommandData 结构体,包含执行命令所需的命令行中的所有数据。

    struct CommandData {
      char *command;  /* the pathname of the command */
      char *args[11]; /* arguments to the command */
      int numargs;    /* the number of arguments */
      char *infile;   /* the file for input redirection, NULL if none */
      char *outfile;  /* the file for output redirection, NULL if none */
      int  background;  /* 0 if process is to run in foreground, 1 if in background */
    };
    
  2. 它还定义了一个函数:

    int ParseCommandLine(char *line, struct CommandData *data)
    

    它接受两个参数,即用户输入的命令行,以及指向 CommandData 结构的指针(其内存必须由调用函数分配)。 该函数解析命令行并填充 CommandData 结构:

    • 如果没有错误,则返回 1;
    • 如果用户输入无效命令,则返回 0。

    在后一种情况下,数据的内容是不确定的。

  3. 某些对于真实 shell 具有特殊含义的字符不能与此 shell 一起使用。

  • 命令名和路径名只能使用以下字符:大写或小写字母、数字、下划线、斜杠、连字符或点。
  • <、> 和 & 字符具有特殊含义,不能在文件名中使用。
  • 任何其他字符(例如美元符号、星号、问号、引号、逗号、管道、双引号或波形符)都将在 ParseCommandLine 函数中生成错误条件。
  • 文件名和路径名的名称中不能有空格。
  1. 如果命令是 quit 你的程序应该终止。

  2. 您的 shell 应该执行该命令,根据需要重定向 stdin 和 stdout,并将参数传递给程序。 请注意,按照约定(因此,对于此分配),argv[0] 是实际命令,您必须填写此命令。
    如果命令行是:

    a.out arg1 arg2 <infile> outfile
    

    当a.out启动时,argv[0]的值应该是a.out,argv[1]的值应该是arg1,argv[2]的值应该是arg2。

  3. 您的 shell 不需要搜索路径,所有相对路径名都应从当前工作目录开始。 请注意,这意味着如果命令行是 ls,您的程序将回复 command not find(除非当前目录中碰巧存在名为 ls 的可执行文件),要运行 ls,用户必须输入 /bin/ls (并注意您的 shell 必须将 argv[0] 设置为 ls)。

文章来源:https://blog.csdn.net/wss_xt/article/details/135115394
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。