命令行参数与标准 I/O 重定向#

本节导读#

虽然我们已经支持从文件系统中加载应用,还实现了文件的创建和读写,但是目前我们在应用中只能硬编码要操作的文件,这就使得应用的功能大大受限,shell程序对于文件的交互访问能力也很弱。为了解决这些问题,我们需要在shell程序和内核中支持命令行参数的解析和传递。而且我们可以把应用的命令行参数的扩展,管道以及标准 I/O 重定向功能综合在一起,来让两个甚至多个互不相干的应用也能合作。

命令行参数#

在使用 C/C++ 语言开发 Linux 应用的时候,我们可以使用标准库提供的 argc/argv 来获取命令行参数,它们是直接被作为参数传给 main 函数的。下面来看一个打印命令行参数的例子:

 1// a.c
 2
 3#include <stdio.h>
 4
 5int main(int argc, char* argv[]) {
 6    printf("argc = %d\n", argc);
 7    for (int i = 0; i < argc; i++) {
 8        printf("argv[%d] = %s\n", i, argv[i]);
 9    }
10    return 0;
11}

其中 argc 表示命令行参数的个数,而 argv 是一个长度为 argc 的字符串数组,数组中的每个字符串都是一个命令行参数。我们可以在 Linux 系统上运行这个程序:

$ gcc a.c -oa -g -Wall
$ ./a aa bb 11 22 cc
argc = 6
argv[0] = ./a
argv[1] = aa
argv[2] = bb
argv[3] = 11
argv[4] = 22
argv[5] = cc

为了支持后续的一些功能,我们希望在内核和shell程序上支持这个功能。为了对实现正确性进行测试,在本章中我们编写了一个名为 cmdline_args 的应用,它是用 Rust 编写的,并只能在我们的内核上执行,但是它的功能是和 a.c 保持一致的。我们可以在我们的内核上运行该应用来看看效果:

Rust user shell
>> cmdline_args aa bb 11 22 cc
argc = 6
argv[0] = cmdline_args
argv[1] = aa
argv[2] = bb
argv[3] = 11
argv[4] = 22
argv[5] = cc
Shell: Process 2 exited with code 0
>>

可以看到二者的输出是基本相同的。

但是,要实现这个看似简单的功能,需要内核和用户态应用的共同努力。为了支持命令行参数, sys_exec 的系统调用接口需要发生变化:

1// user/src/syscall.rs
2
3pub fn sys_exec(path: &str, args: &[*const u8]) -> isize;

可以看到,它的参数多出了一个 args 数组,数组中的每个元素都是一个命令行参数字符串的起始地址。由于我们是以引用的形式传递这个数组,实际传递给内核的是这个数组的起始地址:

1// user/src/syscall.rs
2
3pub fn sys_exec(path: &str, args: &[*const u8]) -> isize {
4    syscall(SYSCALL_EXEC, [path.as_ptr() as usize, args.as_ptr() as usize, 0])
5}
6
7// user/src/lib.rs
8
9pub fn exec(path: &str, args: &[*const u8]) -> isize { sys_exec(path, args) }

接下来我们分析一下,一行带有命令行参数的命令从输入到它的命令行参数被打印出来中间经历了哪些过程。

shell程序的命令行参数分割#

回忆一下,之前在shell程序 user_shell 中,一旦接收到一个回车,我们就会将当前行的内容 line 作为一个名字并试图去执行同名的应用。但是现在 line 还可能包含一些命令行参数,只有最开头的一个才是要执行的应用名。因此我们要做的第一件事情就是将 line 用空格进行分割:

 1// user/src/bin/user_shell.rs
 2
 3let args: Vec<_> = line.as_str().split(' ').collect();
 4let mut args_copy: Vec<String> = args
 5.iter()
 6.map(|&arg| {
 7    let mut string = String::new();
 8    string.push_str(arg);
 9    string
10})
11.collect();
12
13args_copy
14.iter_mut()
15.for_each(|string| {
16    string.push('\0');
17});

经过分割, args 中的 &str 都是 line 中的一段子区间,它们的结尾并没有包含 \0 ,因为 line 是我们输入得到的,中间本来就没有 \0 。由于在向内核传入字符串的时候,我们只能传入字符串的起始地址,因此我们必须保证其结尾为 \0 。从而我们用 args_copyargs 中的字符串拷贝一份到堆上并在末尾手动加入 \0 。这样就可以安心的将 args_copy 中的字符串传入内核了。我们用 args_addr 来收集这些字符串的起始地址:

1// user/src/bin/user_shell.rs
2
3let mut args_addr: Vec<*const u8> = args_copy
4.iter()
5.map(|arg| arg.as_ptr())
6.collect();
7args_addr.push(0 as *const u8);

向量 args_addr 中的每个元素都代表一个命令行参数字符串的起始地址。由于我们要传递给内核的是这个向量的起始地址,为了让内核能够获取到命令行参数的个数,我们需要在 args_addr 的末尾放入一个 0 ,这样内核看到它的时候就能知道命令行参数已经获取完毕了。

fork 出来的子进程里面我们需要这样执行应用:

1// user/src/bin/user_shell.rs
2
3// child process
4if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
5    println!("Error when executing!");
6    return -4;
7}

sys_exec 将命令行参数压入用户栈#

sys_exec 中,首先需要将应用传进来的命令行参数取出来:

 1// os/src/syscall/process.rs
 2
 3pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
 4    let token = current_user_token();
 5    let path = translated_str(token, path);
 6    let mut args_vec: Vec<String> = Vec::new();
 7    loop {
 8        let arg_str_ptr = *translated_ref(token, args);
 9        if arg_str_ptr == 0 {
10            break;
11        }
12        args_vec.push(translated_str(token, arg_str_ptr as *const u8));
13        unsafe { args = args.add(1); }
14    }
15    if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
16        let all_data = app_inode.read_all();
17        let task = current_task().unwrap();
18        let argc = args_vec.len();
19        task.exec(all_data.as_slice(), args_vec);
20        // return argc because cx.x[10] will be covered with it later
21        argc as isize
22    } else {
23        -1
24    }
25}

这里的 args 指向命令行参数字符串起始地址数组中的一个位置,每次我们都可以从一个起始地址通过 translated_str 拿到一个字符串,直到 args 为 0 就说明没有更多命令行参数了。在第 19 行调用 TaskControlBlock::exec 的时候,我们需要将获取到的 args_vec 传入进去并将里面的字符串压入到用户栈上。

 1// os/src/task/task.rs
 2
 3impl TaskControlBlock {
 4    pub fn exec(&self, elf_data: &[u8], args: Vec<String>) {
 5        // memory_set with elf program headers/trampoline/trap context/user stack
 6        let (memory_set, mut user_sp, entry_point) = MemorySet::from_elf(elf_data);
 7        let trap_cx_ppn = memory_set
 8            .translate(VirtAddr::from(TRAP_CONTEXT).into())
 9            .unwrap()
10            .ppn();
11        // push arguments on user stack
12        user_sp -= (args.len() + 1) * core::mem::size_of::<usize>();
13        let argv_base = user_sp;
14        let mut argv: Vec<_> = (0..=args.len())
15            .map(|arg| {
16                translated_refmut(
17                    memory_set.token(),
18                    (argv_base + arg * core::mem::size_of::<usize>()) as *mut usize
19                )
20            })
21            .collect();
22        *argv[args.len()] = 0;
23        for i in 0..args.len() {
24            user_sp -= args[i].len() + 1;
25            *argv[i] = user_sp;
26            let mut p = user_sp;
27            for c in args[i].as_bytes() {
28                *translated_refmut(memory_set.token(), p as *mut u8) = *c;
29                p += 1;
30            }
31            *translated_refmut(memory_set.token(), p as *mut u8) = 0;
32        }
33        // make the user_sp aligned to 8B for k210 platform
34        user_sp -= user_sp % core::mem::size_of::<usize>();
35
36        // **** hold current PCB lock
37        let mut inner = self.acquire_inner_lock();
38        // substitute memory_set
39        inner.memory_set = memory_set;
40        // update trap_cx ppn
41        inner.trap_cx_ppn = trap_cx_ppn;
42        // initialize trap_cx
43        let mut trap_cx = TrapContext::app_init_context(
44            entry_point,
45            user_sp,
46            KERNEL_SPACE.lock().token(),
47            self.kernel_stack.get_top(),
48            trap_handler as usize,
49        );
50        trap_cx.x[10] = args.len();
51        trap_cx.x[11] = argv_base;
52        *inner.get_trap_cx() = trap_cx;
53        // **** release current PCB lock
54    }
55}

第 11-34 行所做的主要工作是将命令行参数以某种格式压入用户栈。具体的格式可以参考下图(比如应用传入了两个命令行参数 aabb ):

../_images/user-stack-cmdargs.png
  • 首先需要在用户栈上分配一个字符串指针数组,也就是蓝色区域。数组中的每个元素都指向一个用户栈更低处的命令行参数字符串的起始地址。在第 12~24 行可以看到,最开始我们只是分配空间,具体的值要等到字符串被放到用户栈上之后才能确定更新。

  • 第 23~32 行,我们逐个将传入的 args 中的字符串压入到用户栈中,对应于图中的橙色区域。为了实现方便,我们在用户栈上预留空间之后逐字节进行复制。注意 args 中的字符串是通过 translated_str 从应用地址空间取出的,它的末尾不包含 \0 。为了应用能知道每个字符串的长度,我们需要手动在末尾加入 \0

  • 第 34 行将 user_sp 以 8 字节对齐,即图中的绿色区域。这是因为命令行参数的长度不一,很有可能压入之后 user_sp 没有对齐到 8 字节,那么在 K210 平台上在访问用户栈的时候就会触发访存不对齐的异常。在 Qemu 平台上则并不存在这个问题。

我们还需要对应修改 Trap 上下文。首先是第 45 行,我们的 user_sp 相比之前已经发生了变化,它上面已经压入了命令行参数。同时,我们还需要修改 Trap 上下文中的 a0/a1 寄存器,让 a0 表示命令行参数的个数,而 a1 则表示图中 argv_base 即蓝色区域的起始地址。这两个参数在第一次进入对应应用的用户态的时候会被接收并用于还原命令行参数。

用户库从用户栈上还原命令行参数#

在应用第一次进入用户态的时候,我们放在 Trap 上下文 a0/a1 两个寄存器中的内容可以被用户库中的入口函数以参数的形式接收:

 1// user/src/lib.rs
 2
 3#[no_mangle]
 4#[link_section = ".text.entry"]
 5pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
 6    unsafe {
 7        HEAP.lock()
 8            .init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
 9    }
10    let mut v: Vec<&'static str> = Vec::new();
11    for i in 0..argc {
12        let str_start = unsafe {
13            ((argv + i * core::mem::size_of::<usize>()) as *const usize).read_volatile()
14        };
15        let len = (0usize..).find(|i| unsafe {
16            ((str_start + *i) as *const u8).read_volatile() == 0
17        }).unwrap();
18        v.push(
19            core::str::from_utf8(unsafe {
20                core::slice::from_raw_parts(str_start as *const u8, len)
21            }).unwrap()
22        );
23    }
24    exit(main(argc, v.as_slice()));
25}

可以看到,在入口 _start 中我们就接收到了命令行参数个数 argc 和字符串数组的起始地址 argv 。但是这个起始地址不太好用,我们希望能够将其转化为编写应用的时候看到的 &[&str] 的形式。转化的主体在第 10~23 行,就是分别取出 argc 个字符串的起始地址(基于字符串数组的 base 地址 argv ),从它向后找到第一个 \0 就可以得到一个完整的 &str 格式的命令行参数字符串并加入到向量 v 中。最后通过 v.as_slice 就得到了我们在 main 主函数中看到的 &[&str]

通过命令行工具 cat 输出文件内容#

有了之前的命令行参数支持,我们就可以编写命令行工具 cat 来输出指定文件的内容了。它的使用方法如下:

>> filetest_simple
file_test passed!
Shell: Process 2 exited with code 0
>> cat filea
Hello, world!
Shell: Process 2 exited with code 0
>>

filetest_simple 会将 Hello, world! 输出到文件 filea 中。之后我们就可以通过 cat filea 来打印文件 filea 中的内容。

cat 本身也是一个应用,且很容易实现:

 1// user/src/bin/cat.rs
 2
 3#![no_std]
 4#![no_main]
 5
 6#[macro_use]
 7extern crate user_lib;
 8extern crate alloc;
 9
10use user_lib::{
11    open,
12    OpenFlags,
13    close,
14    read,
15};
16use alloc::string::String;
17
18#[no_mangle]
19pub fn main(argc: usize, argv: &[&str]) -> i32 {
20    assert!(argc == 2);
21    let fd = open(argv[1], OpenFlags::RDONLY);
22    if fd == -1 {
23        panic!("Error occurred when opening file");
24    }
25    let fd = fd as usize;
26    let mut buf = [0u8; 16];
27    let mut s = String::new();
28    loop {
29        let size = read(fd, &mut buf) as usize;
30        if size == 0 { break; }
31        s.push_str(core::str::from_utf8(&buf[..size]).unwrap());
32    }
33    println!("{}", s);
34    close(fd);
35    0
36}

标准输入输出重定向#

为了进一步增强shell程序使用文件系统时的灵活性,我们需要新增标准输入输出重定向功能。这个功能在我们使用 Linux 内核的时候很常用,我们在自己的内核中举个例子:

>> yield > fileb
Shell: Process 2 exited with code 0
>> cat fileb
Hello, I am process 2.
Back in process 2, iteration 0.
Back in process 2, iteration 1.
Back in process 2, iteration 2.
Back in process 2, iteration 3.
Back in process 2, iteration 4.
yield pass.

Shell: Process 2 exited with code 0
>>

通过 > 我们可以将应用 yield 的输出重定向到文件 fileb 中。我们也可以注意到在屏幕上暂时看不到 yield 的输出了。在应用 yield 退出之后,我们可以使用 cat 工具来查看文件 fileb 的内容,可以看到里面的确是 yield 的输出。同理,通过 < 则可以将一个应用的输入重定向到某个指定文件而不是从键盘输入。

注意重定向功能对于应用来说是透明的。在应用中除非明确指出了数据要从指定的文件输入或者输出到指定的文件,否则数据默认都是输入自进程文件描述表位置 0 (即 fd=0 )处的标准输入,并输出到进程文件描述符表位置 1 (即 fd=1 )处的标准输出。这是由于内核在执行 sys_exec 系统调用创建基于新应用的进程时,会直接把文件描述符表位置 0 放置标准输入文件,位置 1 放置标准输出文件,位置 2 放置标准错误输出文件。标准输入/输出文件其实是把设备当成文件,标准输入文件就是串口的输入或键盘,而标准输出文件就是串口的输出或显示器。

因此,在应用执行之前,我们就要对应用进程的文件描述符表进行某种替换。以输出为例,我们需要提前打开文件并用这个文件来替换掉应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的重定向。在重定向之后,应用认为自己输出到 fd=1 的标准输出文件,但实际上是输出到我们指定的文件中。我们能够做到这一点还是得益于文件的抽象,因为在进程看来无论是标准输出还是常规文件都是一种文件,可以通过同样的接口来读写。

为了实现重定向功能,我们需要引入一个新的系统调用 sys_dup

1// user/src/syscall.rs
2
3/// 功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
4/// 参数:fd 表示进程中一个已经打开的文件的文件描述符。
5/// 返回值:如果出现了错误则返回 -1,否则能够访问已打开文件的新文件描述符。
6/// 可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
7/// syscall ID:24
8pub fn sys_dup(fd: usize) -> isize;

这个系统调用的实现非常简单:

 1// os/src/syscall/fs.rs
 2
 3pub fn sys_dup(fd: usize) -> isize {
 4    let task = current_task().unwrap();
 5    let mut inner = task.acquire_inner_lock();
 6    if fd >= inner.fd_table.len() {
 7        return -1;
 8    }
 9    if inner.fd_table[fd].is_none() {
10        return -1;
11    }
12    let new_fd = inner.alloc_fd();
13    inner.fd_table[new_fd] = Some(Arc::clone(inner.fd_table[fd].as_ref().unwrap()));
14    new_fd as isize
15}

sys_dup 函数中,首先检查传入 fd 的合法性。然后在文件描述符表中分配一个新的文件描述符,并保存 fd 指向的已打开文件的一份拷贝即可。

那么我们应该在什么时候进行替换,又应该如何利用 sys_dup 进行替换呢?

答案是在shell程序 user_shell 中进行处理。在分割命令行参数的时候,我们要检查是否存在通过 <> 进行输入输出重定向的情况,如果存在的话则需要将它们从命令行参数中移除,并记录匹配到的输入文件名或输出文件名到字符串 inputoutput 中。注意,为了实现方便,我们这里假设输入shell程序的命令一定合法:即 <> 最多只会出现一次,且后面总是会有一个参数作为重定向到的文件。

 1// user/src/bin/user_shell.rs
 2
 3// redirect input
 4let mut input = String::new();
 5if let Some((idx, _)) = args_copy
 6.iter()
 7.enumerate()
 8.find(|(_, arg)| arg.as_str() == "<\0") {
 9    input = args_copy[idx + 1].clone();
10    args_copy.drain(idx..=idx + 1);
11}
12
13// redirect output
14let mut output = String::new();
15if let Some((idx, _)) = args_copy
16.iter()
17.enumerate()
18.find(|(_, arg)| arg.as_str() == ">\0") {
19    output = args_copy[idx + 1].clone();
20    args_copy.drain(idx..=idx + 1);
21}

打开文件和替换的过程则发生在 fork 之后的子进程分支中:

 1// user/src/bin/user_shell.rs
 2
 3let pid = fork();
 4if pid == 0 {
 5    // input redirection
 6    if !input.is_empty() {
 7        let input_fd = open(input.as_str(), OpenFlags::RDONLY);
 8        if input_fd == -1 {
 9            println!("Error when opening file {}", input);
10            return -4;
11        }
12        let input_fd = input_fd as usize;
13        close(0);
14        assert_eq!(dup(input_fd), 0);
15        close(input_fd);
16    }
17    // output redirection
18    if !output.is_empty() {
19        let output_fd = open(
20            output.as_str(),
21            OpenFlags::CREATE | OpenFlags::WRONLY
22        );
23        if output_fd == -1 {
24            println!("Error when opening file {}", output);
25            return -4;
26        }
27        let output_fd = output_fd as usize;
28        close(1);
29        assert_eq!(dup(output_fd), 1);
30        close(output_fd);
31    }
32    // child process
33    if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
34        println!("Error when executing!");
35        return -4;
36    }
37    unreachable!();
38} else {
39    let mut exit_code: i32 = 0;
40    let exit_pid = waitpid(pid as usize, &mut exit_code);
41    assert_eq!(pid, exit_pid);
42    println!("Shell: Process {} exited with code {}", pid, exit_code);
43}
  • 输入重定向发生在第 6~16 行。我们尝试打开输入文件 inputinput_fd 中。之后,首先通过 close 关闭标准输入所在的文件描述符 0 。之后通过 dup 来分配一个新的文件描述符来访问 input_fd 对应的输入文件。这里用到了文件描述符分配的重要性质:即必定分配可用描述符中编号最小的一个。由于我们刚刚关闭了描述符 0 ,那么在 dup 的时候一定会将它分配出去,于是现在应用进程的文件描述符 0 就对应到输入文件了。最后,因为应用进程的后续执行不会用到输入文件原来的描述符 input_fd ,所以就将其关掉。

  • 输出重定向则发生在 18~31 行。它的原理和输入重定向几乎完全一致,只是通过 open 打开文件的标志不太相同。

实现到这里,就可以通过 exec 来执行应用了。

小结#

虽然 fork/exec/waitpid 三个经典的系统调用自它们于古老的 UNIX 时代诞生以来已经过去了太长时间,从某种程度上来讲已经不太适合新的内核环境了。人们也已经提出了若干种替代品并已经在进行实践,比如POSIX标准中的 posix_spawn 或者 Linux 上的 clone 系统调用。但是它们迄今为止仍然存在就证明在它们的设计中还能够找到可取之处。从本节介绍的重定向就可以看出它们的灵活性以及强大的功能性:我们能够进行重定向恰恰是因为创建新应用进程分为 forkexec 两个系统调用,那么在这两个系统调用之间我们就能够进行一些类似重定向的处理。在实现的过程中,我们还用到了 fork 出来的子进程会和父进程共享文件描述符表的性质。