Chapter 1: Operating system interfaces 操作系统接口

Chapter 1: 操作系统接口

操作系统的职责

  • 将一台电脑共享给多个程序
  • 提供比硬件自身更有用的服务集
  • 管理与抽象下层硬件

操作系统通过接口提供服务

  • 设计一个接口需要权衡
    • 简单性与浅显性:能让更加容易写出正确的实现
    • 多样性:想要提供更多的特性
  • 方法是设计接口能够通过少量的机制进行组合,进而提供泛用性

xv6 介绍

  • 采用传统形式的“内核”(kernel),一种特殊的程序,提供运行程序的服务。

  • 运行的程序被称为“进程”(process),具有内存包含了指令,数据,栈。

    • 指令实现了程序的预算。
    • 数据是参与计算的变量。
    • 栈组织了程序的进程调用。
  • 多对一:多个进程,一个内核。

  • 进程调用一个内核服务——唤起“系统调用”(system call),是操作系统进程中的一个调用。

  • 系统调用进入到内核:内核将执行服务并且返回。

  • 因此进程在用户空间和内核空间中交替执行。

img

  • 内核使用硬件提供的保护机制,来保证每个用户空间的进程只会读/写自己的内存。

  • 内核以实现这些保护所exi需的硬件权限执行。用户程序则不带有这些特权。

  • 用户程序唤起一个系统调用的时候,硬件将提升特权级,开始执行已经设计好的内核函数。

  • 系统调用的集合就是用户端能看见的接口。

  • shell是一个读取用户指令并执行他们的程序。shell是用户态进程,不是内核的一部分。

  • shell很容易替换。因此现代操作系统中有大量的不同种类的shell(bash, zsh, fish...)

  • xv6的shell实现在user/sh.c中。

1.1 进程与内存

进程的内存、xv6与进程

  • xv6的进程包括了:用户空间内存(指令,数据,栈)和每个进程对内核私有的进程状态。
  • xv6时间共享程序:显式切换CPU给等待执行的进程。
  • 进程未执行的时候,xv6进程保存进程CPU的寄存器。
  • 下次执行的时候恢复寄存器内的值。
  • 内核通过 PID 来关联程序。

fork:拷贝并生成新的进程

  • 进程通过 fork 可以创建一个新的进程。fork 会直接拷贝调用者进程给新进程的内存:指令、数据与调用者进程的栈。
  • fork 将会在调用者和新进程内都有返回。
    • 在调用者中,返回新进程的 PID
    • 在新进程中,返回0。
    • 调用者被称为“父进程”(parent),新进程则为“子进程”(child).

exit: 停止进程并释放资源

  • exit 调用将会导致调用程序停止执行,并且释放资源(内存,文件)。
  • exit 将会采用一个整数状态参数,一般0为成功,1为失败。

wait: 等待进程退出

  • wait 系统调用将返回一个退出(或被杀死的)当前进程的子进程的 PID

  • 并且将子进程的退出状态传到传入 wait 函数的地址上。

  • 如果没有一个调用者的子程序退出,wait 则等待其中一个退出。

  • 如果调用者没有子程序,立刻返回 -1。

  • 如果父进程不关心子进程的退出状态,可以传递一个 0 地址。

  • 即使子进程是父进程的完全拷贝,两者的执行使用不同的内存/寄存器。修改一个进程变量不会影响到另一个进程。

exec: 替换内存

  • exec 系统调用会用从文件系统中加载的文件的新内存映像替换调用进程的内存。
  • 这样的文件必须要有特定的格式,指明数据、指令的位置,指令开始的地方。例如ELF格式。
  • exec 将不会返回到调用程序,而是从文件中加载指令,并从ELF中指定的入口开始执行。

sh.c: xv6 shell

  • main 循环
    • 通过 getcmd 读取一行输入
    • 调用 fork,创建一个shell进程的拷贝
    • wait 等待子进程执行程序。子进程通过 runcmd 执行真正的指令。
// main loop.
while(getcmd(buf, sizeof(buf)) >= 0){   // getcmd 读取一行输入。
  char *cmd = buf;
  while (*cmd == ' ' || *cmd == '\t')
    cmd++;
  if (*cmd == '\n') // is a blank command
    continue;
  if(cmd[0] == 'c' && cmd[1] == 'd' && cmd[2] == ' '){
    // Chdir must be called by the parent, not the child.
    cmd[strlen(cmd)-1] = 0;  // chop \n
    if(chdir(cmd+3) < 0)
      fprintf(2, "cannot cd %s\n", cmd+3);
  } else {
    if(fork1() == 0)    // fork 创建进程的拷贝。
      runcmd(parsecmd(cmd));    // 子进程通过 runcmd 执行。
    wait(0);
  }
}

隐式内存分配

  • fork 需要分配内存来满足子进程对父进程的拷贝。
  • exec 分配足够的内存来载入可执行文件。
  • sbrk(n) 可以用于进程在运行时分配更多内存(增加 n 个0字节),并返回新内存的地址。

1.2 IO和文件描述符

  • 文件描述符(file descriptor, fd)是一个小的整数,代表了内核管理的对象。进程可以读取或者写入。

  • 一个进程可能会通过:(1) 打开文件/文件夹/设备;(2) 复制一个存在的文件描述符来获取fd。

  • fd 抽象了文件/管道/设备成为字节流。

  • 在内部,xv6内核将 fd 用作每个进程表的下标。

    • 这样每个进程都有一个从0开始的私有空间。相同的fd在不同的进程中指向不同的文件。
    • 默认状态下:fd=0 (标准输入,进程用于读取文件), fd=1 (标准输出,进程用于写入文件), fd=2 (标准错误)。
    • shell保证有三个fd开启。
// Ensure that three file descriptors are open.
while((fd = open("console", O_RDWR)) >= 0){
  if(fd >= 3){
    close(fd);
    break;
  }
}

read/write

  • readwrite 系统调用从已经和打开的文件中读取/写入字节。
    • read(fd, buf, n) 从fd中读取至多n个字节到buf中并且返回读取的字节数。
    • 文件——fd通过偏移(offset)相互关联。
    • read 从当前偏移开始读取数据,并前进偏移。前进的幅度为读取的字节数目。
    • 到达文件末尾时,read 返回0。
    • write(fd, buf, n) 系统调用将向文件写入 n 字节buf的内容。
    • 只有出错时才会少于n字节写入。
    • read 一样,从当前偏移开始写入数据,并前进偏移。前进的幅度为写入的字节数目。

我们可以来看如下的 user/cat.c。需要注意的是 cat 无视读取的来源是文件/终端/管道,也不知道写入的目的是文件/终端/管道。

char buf[512];

void
cat()
{
  int n;
  
  // 从标准输入中读取 buf 大小的数据。大于零则读取成功。否则读取失败。
  while((n = read(0, buf, sizeof(buf))) > 0) {
    // 随后写入标准输出。
    if (write(1, buf, n) != n) {
      fprintf(2, "cat: write error\n");
      exit(1);
    }
  }
  if(n < 0){
    fprintf(2, "cat: read error\n");
    exit(1);
  }
}

close

  • close 系统调用释放一个fd,使其可以被未来的 open/pipe/dup 重用。
  • 一个新分配的文件描述符总是当前进程的可用最小fd。

fd与fork:IO重定向

  • fork 复制父进程的fd与内存,因此子进程将从与父进程相同的文件开始。
  • exec 替换调用进程的内存但是保留了文件表。
  • 这样就可以实现:fork 实现IO重定向,在子进程中开启/关闭文件描述符,exec 执行新的程序。
  • 需要注意:绑定了文件的fd,其下面的 offset 是 共享的
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) { // 子进程执行if语句
  close(0);   // 将fd绑定到新的 `input.txt`
  open("input.txt", O_RDONLY);
  exec("cat", argv);  // 替换内存镜像执行程序
}

user/sh.c 中也是相同的。在如下的代码片段中,注意执行流程:在main中执行 fork 后,子进程执行 runcmd 部分,并通过exec替换了需要执行的程序。

case EXEC:
  ecmd = (struct execcmd*)cmd;
  if(ecmd->argv[0] == 0)
    exit(1);
  exec(ecmd->argv[0], ecmd->argv);
  fprintf(2, "exec %s failed\n", ecmd->argv[0]);
  break;

case REDIR:
  rcmd = (struct redircmd*)cmd;
  close(rcmd->fd);
  if(open(rcmd->file, rcmd->mode) < 0){
    fprintf(2, "open %s failed\n", rcmd->file);
    exit(1);
  }
  runcmd(rcmd->cmd);
  break;

open

  • open 的第二个参数flag,表明了 open 的控制方式。
    • O_RDONLY 只读
    • O_WRONLY 只写
    • O_RDWR 读写
    • O_CREATE 创建新的文件
    • O_TRUNC 截断到0

为什么需要分开 forkexec

  • 控制流混乱,因此需要分离。
  • 正常的 fork/exec 控制流如下:
---- fork ---- fd不变,继续执行
      |---- close ---- open ---- exec
      (此处实现重定向) 
  • 如果使用forkexec,一种可能的做法是,或者带入IO重定向参数,或者让每个指令都自带IO重定向。
                              (撤销操作)
-- close --open -- forkexec -- close -- open
                        |-- exec -- 

dup: 复制文件描述符

  • dup 系统调用复制一个已经存在的文件描述符。这个文件描述符与原先的文件描述符将指代相同的IO对象。可以看下面的例子:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);

这样写入的是 hello world\n。这和下面的例子是相同的。因为 fork 出来的fd将指代相同的IO对象,共享偏移量。

if(fork() == 0) {
  write(1, "hello ", 6);
  exit(0);
} else {
  wait(0);
  write(1, "world\n", 6);
}

1.3 管道

  • pipe 是一个小的kernel 缓冲区,提供给进程一对文件描述符,分别用于读写。一端写入,另一端即可读取。
  • pipe 提供了进程通信的能力。

以下是一个 wc 的例子:

int p[2];   // 文件描述符数组。
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);    // 创建管道的两端:p[0] 读端,p[1] 写端
if(fork() == 0) {
  // 子进程关闭fd = 0,并且通过dup将 p[0] 关联上 stdcin. 也就是标准输入重定向到读端。
  close(0);
  dup(p[0]);
  close(p[0]);
  close(p[1]);    // 关闭写端防止阻塞。
  exec("/bin/wc", argv);    // 执行wc,通过标准输入读取管道。
} else {
  close(p[0]);    // 关闭读端。
  write(p[1], "hello world\n", 12); // 向写端写入数据。这部分数据将会被 wc 读取。
  close(p[1]);
}
  • 如果没有数据,管道的读端将会阻塞等待,要么数据写入写端,或者写端的所有fd关闭。
  • 因此子进程必须关闭写端口,不然无法读到EOF。

xv6 以如下的方式实现 pipe 命令的执行:

// 在main中通过fork进入子程序。
case PIPE:
  pcmd = (struct pipecmd*)cmd;
  if(pipe(p) < 0)   // 子程序打开pipe链接左右两端。
    panic("pipe");
  if(fork1() == 0){ // fork+runcmd 执行管道左边的任务。管道左端需要关联标准输出(stdout,1)与写入端(p[1])。
    close(1);
    dup(p[1]);
    close(p[0]);
    close(p[1]);
    runcmd(pcmd->left);
  }
  if(fork1() == 0){ // fork+runcmd 执行管道右边的任务。管道右端需要关联标准输入(stdin,0)与写入端(p[0])。
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    runcmd(pcmd->right);
  }
  close(p[0]);
  close(p[1]);
  wait(0);
  wait(0);
  break;
  • pipe 可以包含 pipe (如 a | b | c)。这样shell将生成一个进程树。进程树内的叶子,也就是指令将会阻塞等待左右子节点全部完成。

管道的优点

  • pipe 会自动回收资源;文件重定向则需要手动好移除文件。
  • pipe 可以传递任意长度的字节流;文件重定向则需要足够的磁盘空间来存储所有的数据。
  • pipe 允许流水线阶段的并发执行;文件重定向则需要顺序执行。

1.4 文件系统

  • xv6的文件系统提供了数据文件与文件夹。
    • 数据文件:包含了未解释的字节序列。
    • 文件夹:包含了对数据文件与其他文件夹的具名饮用。
  • 文件夹形成树状结构,以root作为起点。没有 / 的路径则为相对于当前路径的相对路径。

一些函数

  • chdir 用于切换当前进程所在的文件夹。
  • mkdir 用于创建新的文件夹。
  • open 通过 O_CREATE 创建新的数据文件。
  • mknod 创建新的设备文件。两个数字参数分别为设备的major与minor号。
    • kernel将会把通过读写系统调用打开的设备文件分离到内核设备的相关实现,不在文件系统的管理下。

组成成分

  • 文件名与文件不同。
    • 在相同的文件下(相同的inode)可以有多个名字(也就是链接link)。
    • 每个链接有一个目录的条目(entry),包含了文件名与对inode 的引用。
    • inode 包含了文件的元数据 (metadata),包括了如下部分:
      • 类型(文件/文件夹/设备)
      • 长度
      • 磁盘上文件内容的位置
      • 到文件的链接数目。

这里我们可以看看 xv6 中的 inode 的结构如下:

// in kernel/fs.h
// On-disk inode structure
struct dinode {
  short type;           // File type
  short major;          // Major device number (T_DEVICE only)
  short minor;          // Minor device number (T_DEVICE only)
  short nlink;          // Number of links to inode in file system
  uint size;            // Size of file (bytes)
  uint addrs[NDIRECT+1];   // Data block addresses
};

// This is the directory structure.
struct dirent {
  ushort inum;          // inodes' count.
  char name[DIRSIZ];    // the name of the directory.
};

系统调用

  • fstat 系统调用将从 fd 指向的 inode 中取回 stat 结构体信息。定义如下所示:
#define T_DIR     1   // Directory
#define T_FILE    2   // File
#define T_DEVICE  3   // Device

struct stat {
  int dev;     // File system's disk device
  uint ino;    // Inode number
  short type;  // Type of file
  short nlink; // Number of links to file
  uint64 size; // Size of file in bytes
};
  • link 系统调用将创建另一个文件系统名,作为存在的文件,指向相同的 inode。创建了link后,对该link的读写等同于对原文件的读写。
  • 每一个inode都通过一个唯一的inode号进行区分。因此,fstat 将会返回 ino (inode number) 来区分文件。
  • unlink 将从文件系统中移除一个文件名。
  • inode和磁盘空间只有在文件的链接数目 nlink=0 的时候才会释放。因此我们具有如下的方式来保证临时文件在程序结束后会被回收:
fd = open("/tmp/xyz", O_CREATE|O_RDWR); unlink("/tmp/xyz");
  • 大部分的文件函数都可以从shell进行调用。而 cd 例外。cd 需要改变进程的文件夹,因此 不能fork后执行,而是直接在shell内执行。

labs

相关的lab已经写完了。近日内将会补充笔记

img

本文没有采用AI辅助创作。

posted @ 2026-09-03 19:01  木木ちゃん  阅读(7)  评论(0)    收藏  举报