Shell 重定向实现原理

类 Unix 系统中的重定向实现

在 bash 等典型的 Unix shells 中,program < numbers.txt 命令的行为是从 number.txt 创建文件流,并将 program 进程的标准输入重定向到该文件流中。值得注意的是 numbers.txt 是由 shell 进程打开的。文件打开后,shell 进程暂时将自身的标准输入重定向到 fd,然后调用 fork 创建一个新的子进程。 子进程默认情况下会继承其父进程的文件描述符表,fd 的引用计数增加 1。此时,子进程的标准输入为 fd。创建子进程的同时,父进程可以恢复其标准输入流并关闭 fd,其引用计数减少 1,由于子进程仍然持有 fd,因此 numbers.txt 文件流仍然有效。随后,子进程调用 exec,创建真正的 program 进程。program 进程退出时,所有打开的文件描述符都会关闭。

在下面的示例中,我们首先创建一个包含若干无序数字的 numbers.txt

echo 3 5 2 4 1 > numbers.txt

然后,编写如下 C 代码,将标准输入重定向到 numbers.txt 并调用 sort 程序对数字进行排序:

#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(void) {
    int fd = open("numbers.txt", O_RDONLY); // 假定 numbers.txt 已经存在
    if (fd < 0) {
        fprintf(stderr, "Failed to open numbers.txt, error code: %d.", errno);
        return 0;
    }
    int status = dup2(fd, STDIN_FILENO);
    fprintf(stdout, "Child's stdin redirected.\n");
    if (status < 0) {
        fprintf(stderr, "Failed to redirect stdin, error code: %d.\n", errno);
        return 0;
    }
    pid_t pid = fork();
    if (pid < 0) {
        fprintf(stderr, "Failed to fork child process, error code: %d.\n", errno);
        return 0;
    }
    if (pid > 0) { // parent process
        close(fd);
        int status = dup2(STDIN_FILENO, STDIN_FILENO);
        fprintf(stdout, "Parent's stdin restored.\n");
        if (status < 0) {
            fprintf(stderr, "Failed to restore stdin, error cide: %d\n.", errno);
        }
    } else { // child process
        execlp("/bin/sort", "/bin/sort", (char*)NULL);
        fprintf(stderr, "Failed to execute /bin/sort.\n");
    }
    return 0;
}

Windows 系统中的重定向实现

在 Windows 中,上述过程也是类似的,父进程通过 CreateFile 创建文件句柄后,可以使用 SetStdHandle 函数将标准输入、标准输出或标准错误重定向到该文件句柄上。 与 Unix 所不同的是默认的继承策略,Windows 的句柄继承策略更为严格,需要同时满足两个条件:

  • 在打开/创建文件时,SECURITY_ATTRIBUTES 结构体中的 bInheritHandles 参数设置为 TRUE
  • 在创建子进程时,CreateProcess 函数的 bInheritHandles 参数设置为 TRUE

只有满足上述两个条件,子进程才能继承父进程的文件句柄。

而要实现标准句柄的重定向,还需要在 STARTUPINFO 结构体中的 dwFlags 字段中包含 STARTF_USESTDHANDLES 标志,并设置 hStdInputhStdOutputhStdError 三个字段,明确要使用的标准句柄。否则,子进程仍将使用默认的标准句柄。

在下面的示例中,我们同样创建一个包含若干无序数字的 numbers.txt 文件:

echo 5 4 3 2 1 > numbers.txt
#include <Windows.h>
#include <stdio.h>

int main() {
    SECURITY_ATTRIBUTES sa = {
        .nLength = sizeof(SECURITY_ATTRIBUTES),
        .bInheritHandle = TRUE,
        .lpSecurityDescriptor = NULL
    }; // 文件句柄允许继承
    HANDLE hFile = CreateFileW(L"numbers.txt", GENERIC_READ, FILE_SHARE_READ, &sa, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE) {
        fprintf(stdout, "CreateFileW failed with %lu.\n", GetLastError());
        return 0;
    }
    STARTUPINFOW si = {
        .cb = sizeof(STARTUPINFOW),
        .dwFlags = STARTF_USESTDHANDLES,
        .hStdInput = hFile,
        .hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE),
        .hStdError = GetStdHandle(STD_ERROR_HANDLE)
    }; // 指定标准句柄
    PROCESS_INFORMATION pi;
    wchar_t cmd[] = L"C:/Windows/System32/sort.exe";
    BOOL bSuccess = CreateProcessW(
        NULL,
        cmd,
        NULL,
        NULL,
        TRUE, // 允许子进程继承句柄
        0,
        NULL,
        NULL,
        &si,
        &pi
    );
    if (!bSuccess) {
        fprintf(stdout, "CreateProcessW failed with %lu.\n", GetLastError());
    } else {
        fprintf(stdout, "Child process created successfully.\n");
        CloseHandle(pi.hProcess);
        CloseHandle(pi.hThread);
    }
    CloseHandle(hFile); // 关闭文件句柄
}

需要特别注意的是,CreateProcessW 接收的 lpCommandLine 参数必须是可写的字符串缓冲区,不能传入字符串常量,否则程序会因尝试写入只读内存而在运行时直接崩溃。C 语言编译器不会对此进行检查,而 C++ 编译器在编译时会发出警告。