Introduction

课程介绍

6.S081 2020 Lecture 1: OS概述

概述

  • 6.S081目标

    • 了解操作系统(OS)的设计和实现
  • 动手扩展小型操作系统的实践经验

  • 有编写系统软件的实际操作经验

  • 操作系统的目的是什么?

    • 为方便和可移植性而对硬件进行抽象
  • 在多种应用中实现硬件的多路复用

  • 隔离应用程序,多个程序互不干扰

  • 允许在合作的应用程序之间共享

  • 控制共享安全

  • 不要妨碍高效

  • 支持广泛的应用

  • 组织方式:分层结构

    • 用户应用层:vi、gcc、DB和c
  • 内核服务层

  • 硬件层: CPU、RAM、磁盘、网络等

我们非常关心接口和内部内核结构

  • 操作系统内核通常提供什么服务?

    • 进程(一个正在运行的程序)
  • 内存分配

  • 文件内容

  • 文件名,目录

  • 访问控制(安全性)

  • 其他:用户、IPC(进程间通信)、网络、时间、终端

  • 什么是应用程序/内核接口?

    • “系统调用”
  • 例子,在UNIX(如Linux, macOS, FreeBSD)中的C语言中:

  1. fd = open("out", 1);
  2. write(fd, "hello\n", 6);
  3. pid = fork();
  • 这些看起来像函数调用,但实际上不是

  • 为什么操作系统的设计和实现是困难和有趣的?

    • 恶劣的环境:古怪的硬件,很难调试
  • 许多设计张力:

    • 高效vs抽象/便携/通用
  • 强大的vs简单的接口

  • 灵活vs安全

  • 功能交互:’fd = open(); fork()`

  • 用途多种多样:笔记本电脑、智能手机、云计算、虚拟机、嵌入式

  • 不断发展的硬件:NVRAM、多核、高速网络

  • 你会很高兴你选了这门课,如果你…

    • 关心计算机运行的背后发生了什么
  • 喜欢基础架构

  • 需要追踪漏洞或安全问题

  • 注重高性能

课程结构

  • 网上课程信息:

6.S081官网,网站中包含了课程表,作业,实验

Piazza:公告,讨论,实验帮助

  • 视频课程

    • 操作系统的想法
  • 通过代码和xv6的书,xv6(一个小的操作系统)的案例研究

  • 实验背景

  • 操作系统相关论文

  • 上课前提交一个关于阅读材料的问题。

  • 实验:

    • 重点是:实践经验(基本上每个星期一个实验)
  • 实验的三种类型:

    • 系统编程(下周截止…)
  • OS原语,例如线程切换。

  • xv6的OS内核扩展,例如网络。

  • 使用piazza提问/回答实验室的问题。

  • 讨论很好,但请不要看别人的解决方案!

  • 评分:

    • 70%的实验室,基于测试(与运行的测试相同)。
  • 20%的实验室检查会议:我们会问你关于随机选择的实验室的问题。

  • 10%的家庭作业和课堂/广场讨论。

  • 没有考试,没有小测验。

  • 请注意,大部分成绩来自实验室,请尽早开始!

UNIX系统调用简介

  • 应用程序通过系统调用查看操作系统;这种接口将是我们关注的重点。

    • 让我们从查看程序如何使用系统调用开始。
  • 您将在第一个实验室中使用这些系统调用。

  • 并在随后的实验室中进行扩展和改进。

  • 我将展示一些示例,并在xv6上运行它们。

xv6的结构与UNIX系统(如Linux)类似。但是要简单得多——您将能够理解xv6的全部内容附带的书解释了xv6的工作原理和原因

  • 为什么选择UNIX ?

    • 开源代码,有良好的文档,干净的设计,广泛使用
  • 如果您需要了解Linux内部,学习xv6将有所帮助

  • xv6在6.S081中有两个角色:

    • 核心函数的例子:虚拟内存,多核,中断,等等
  • 大多数实验的起点

  • xv6运行在RISC-V上,就像当前的6.004一样

  • 您将在qemu机器仿真器下运行xv6

例子:copy.c:复制输入到输出

  1. char buf[64];
  2. while(1){
  3. int n = read(0, buf, sizeof(buf));
  4. if(n <= 0)
  5. break;
  6. write(1, buf, n);
  7. }
  8. exit(0);

从输入中读取字节,并将其写入输出

copy.c是用C语言写的,克尼根和里奇(K&R)的《C程序设计语言》可以帮助你学习C语言,另外你可以通过官网上时间表中的example指向的链接找到这些示例程序

其中read()write()是系统调用

  • read()/write()的第一个参数是一个“文件描述符”(fd)

它传递给内核,告诉系统调用要读/写哪个“打开的文件”,fd必须是已经打开过的,可以指向文件/设备/套接字等等。一个进程可以打开很多文件,有很多fd,UNIX约定:fd 0是“标准输入”,1是“标准输出”

  • 第二个read()参数是一个指向要读取的内存的指针

  • 第三个参数是要读取的最大字节数

read()可以读得更少,但不能读得更多

  • 返回值:实际读取的字节数,或-1表示错误

注意:copy.c不关心数据的格式,UNIX I/O是8位字节,解释是特定于应用程序的,例如数据库记录,C源,等等

文件描述符来自哪里?

例子:open.c,创建一个文件

  1. // open.c: create a file, write to it.
  2. #include "kernel/types.h"
  3. #include "user/user.h"
  4. #include "kernel/fcntl.h"
  5. int
  6. main()
  7. {
  8. int fd = open("output.txt", O_WRONLY | O_CREATE);
  9. write(fd, "ooo\n", 4);
  10. exit(0);
  11. }

open()创建文件,返回文件描述符fd(或-1表示错误),fd是一个短整数,fd索引到内核维护的每个进程表中

不同的进程具有不同的fd命名空间,也就是说,文件描述符1对于不同的进程通常意味着不同的东西,然而这些例子忽略了可能的错误——但你不要这么草率!

《xv6》中的图1.2列出了系统调用的参数、返回值,或者你查看UNIX手册页,例如。man 2 open

  • 当程序调用像open()这样的系统调用时会发生什么?

open看起来像一个函数调用,但实际上是一个特殊的指令

  1. 硬件保存一些用户寄存器
  2. 硬件增加特权级别
  3. 硬件会跳转到内核中一个已知的“入口点”
  4. 现在在内核中运行C代码
  5. 内核调用系统调用执行
    • open()在文件系统中查找文件名
    • 它可能会等待磁盘
    • 它更新内核数据结构(缓存,FD表)
  6. 恢复用户寄存器
  7. 减少特权级别
  8. 回到程序中的调用点,它将继续运行

我们将在后面的课程中看到更多细节

  • Shell是UNIX系统上的命令行界面。

    shell会打印“$”提示符提示输入命令,它允许您运行UNIX命令行实用程序,这对系统管理、文件处理、开发、脚本编写非常有用

  1. $ ls
  2. $ ls > out
  3. $ grep x < out

UNIX也支持其他类型的交互,例如窗口系统,图形用户界面,服务器,路由器,等等。但是,通过shell实现分时是UNIX最初的重点。我们可以通过shell执行许多系统调用。

例子:fork.c:创建一个新的过程

  1. // fork.c: create a new process
  2. #include "kernel/types.h"
  3. #include "user/user.h"
  4. int
  5. main()
  6. {
  7. int pid;
  8. pid = fork();
  9. printf("fork() returned %d\n", pid);
  10. if(pid == 0){
  11. printf("child\n");
  12. } else {
  13. printf("parent\n");
  14. }
  15. exit(0);
  16. }

shell会为您键入的每个命令创建一个新进程,例如,对于

  1. $ echo hello

来说fork()系统调用创建一个新进程,内核复制调用进程的指令、数据、寄存器、文件描述符、当前目录

“父进程”和“子进程”的唯一的区别是:fork()在父进程中返回pid,在子进程中返回0,pid(进程ID)是一个整数,内核给每个进程一个不同的pid

因此:fork.cprintf("fork() returned %d\n", pid);会在父子两个进程中执行

if(pid == 0)”允许代码进行区分父子进程

fork让我们创建一个新进程,那么我们如何在这个进程中运行一个程序呢?

例子:exec.c:用可执行文件替换调用进程

  1. // exec.c: replace a process with an executable file
  2. #include "kernel/types.h"
  3. #include "user/user.h"
  4. int
  5. main()
  6. {
  7. char *argv[] = { "echo", "this", "is", "echo", 0 };
  8. exec("echo", argv);
  9. printf("exec failed!\n");
  10. exit(0);
  11. }

shell是如何运行程序的?例如

  1. $ echo a b c

程序存储在一个文件中,指令和初始内存由编译器和链接器创建

有一个叫echo的文件,包含指令

  1. $ echo

echo.c文件内容如下

  1. #include "kernel/types.h"
  2. #include "user/user.h"
  3. int
  4. main(int argc, char *argv[])
  5. {
  6. int i;
  7. for(i = 1; i &lt; argc; i++){
  8. write(1, argv[i], strlen(argv[i]));
  9. if(i + 1 &lt; argc){
  10. write(1, " ", 1);
  11. } else {
  12. write(1, "\n", 1);
  13. }
  14. }
  15. exit(0);
  16. }
  • exec()系统调用

    • 用可执行文件替换当前进程
    • 丢弃指令和数据存储器
    • 从文件中加载指令和内存
    • 保存文件描述符
  • exec(filename, argument-array)

    • argument-array保存命令行参数;exec将参数传递给main()
    • 执行cat user/echo.c
    • echo.c程序演示了如何查看命令行参数

例子:forkexec.cfork()一个新进程,exec()一个程序

  1. #include "kernel/types.h"
  2. #include "user/user.h"
  3. // forkexec.c: fork then exec
  4. int
  5. main()
  6. {
  7. int pid, status;
  8. pid = fork();
  9. if(pid == 0){
  10. char *argv[] = { "echo", "THIS", "IS", "ECHO", 0 };
  11. exec("echo", argv);
  12. printf("exec failed!\n");
  13. exit(1);
  14. } else {
  15. printf("parent waiting\n");
  16. wait(&amp;status);
  17. printf("the child exited with status %d\n", status);
  18. }
  19. exit(0);
  20. }
  • forkexec.c包含一个常见的UNIX习惯用法:

    • fork()一个子进程
    • exec()子进程中的命令
    • 父进程调用wait()等待子进程结束
  • 对于您键入的每个命令,shell都会fork/exec/wait

    • wait()完成之后,shell打印下一个提示符
    • 若想让程序在后台运行,可在命令的最后加上符号&,这样shell会跳过wait()
  • exec(status)-> wait(&status)

    • 状态约定:0表示成功,1表示命令遇到错误
  • 注意:fork()会复制,但是exec()会丢弃复制的内存

这似乎很浪费,在“copy-on-write”实验室中,你将透明的删除复制

例子:redirect.c,重定向命令的输出

  1. #include "kernel/types.h"
  2. #include "user/user.h"
  3. #include "kernel/fcntl.h"
  4. // redirect.c: run a command with output redirected
  5. int
  6. main()
  7. {
  8. int pid;
  9. pid = fork();
  10. if(pid == 0){
  11. close(1);
  12. open("output.txt", O_WRONLY|O_CREATE);
  13. char *argv[] = { "echo", "this", "is", "redirected", "echo", 0 };
  14. exec("echo", argv);
  15. printf("exec failed!\n");
  16. exit(1);
  17. } else {
  18. wait((int *) 0);
  19. }
  20. exit(0);
  21. }

shell如何完成重定向呢?

  1. $ echo hello > out

答案是:通过fork产生子进程,然后在子进程中改变文件描述符1,再调用exec执行echo

  • 注意:open()总是选择最小的未使用文件描述符;在重定向中,由于close(1)使得1成为了最小的文件描述符

  • fork 、FDs(文件描述符)和exec可以很好地交互以实现I/O重定向

    • forkexec分离给了子进程一个在exec之前更改文件描述符的机会

文件描述符提供了一种间接性:命令只使用描述符0和1,而不需要知道文件描述符到底指向去哪里

exec会保存shell所设置的文件描述符

  • 因此:只有shell需要知道I/O重定向,而不是每个程序

  • 关于设计决策,有必要问一下“为什么”:

    • 为什么要采用这些I/O和流程抽象?为什么不是别的?
    • 为什么要提供文件系统?为什么不让程序以自己的方式使用磁盘呢?
    • 为什么使用文件描述符?为什么不将文件名传递给write()?
    • 为什么文件是字节流,而不是磁盘块或格式化的记录?
    • 为什么不合并fork()和exec()呢?
    • UNIX设计工作得很好,但我们将看到其他设计!

例子:pipe1.c,通过管道交流

  1. // pipe1.c: communication over a pipe
  2. #include "kernel/types.h"
  3. #include "user/user.h"
  4. int
  5. main()
  6. {
  7. int fds[2];
  8. char buf[100];
  9. int n;
  10. // create a pipe, with two FDs in fds[0], fds[1].
  11. pipe(fds);
  12. write(fds[1], "this is pipe1\n", 14);
  13. n = read(fds[0], buf, sizeof(buf));
  14. write(1, buf, n);
  15. exit(0);
  16. }

shell是如何实现管道的呢

  1. $ ls | grep x
  • 文件描述符可以指向“管道”,也可以指向文件

  • pipe()系统调用创建两个文件描述符

    • 第一个用于读取
    • 第二个用于写入
  • 内核为每个管道维护一个缓冲区

    • write()追加到缓冲区
    • read()等待数据

例子:pipe2.c,进程之间的通信

  1. #include "kernel/types.h"
  2. #include "user/user.h"
  3. // pipe2.c: communication between two processes
  4. int
  5. main()
  6. {
  7. int n, pid;
  8. int fds[2];
  9. char buf[100];
  10. // create a pipe, with two FDs in fds[0], fds[1].
  11. pipe(fds);
  12. pid = fork();
  13. if (pid == 0) {
  14. write(fds[1], "this is pipe2\n", 14);
  15. } else {
  16. n = read(fds[0], buf, sizeof(buf));
  17. write(1, buf, n);
  18. }
  19. exit(0);
  20. }
  • 管道和fork()很好地结合在一起来实现ls | grep x
    • shell创建一个管道,
    • 然后执行两次fork
    • 然后将ls的文件描述符1连接到管道的写文件描述符
    • grep的文件描述符0连接到管道的读文件描述符

管道是一个单独的抽象,但与fork()结合得很好。

例子:list.c,列出目录中的文件

  1. #include "kernel/types.h"
  2. #include "user/user.h"
  3. // list.c: list file names in the current directory
  4. struct dirent {
  5. ushort inum;
  6. char name[14];
  7. };
  8. int
  9. main()
  10. {
  11. int fd;
  12. struct dirent e;
  13. fd = open(".", 0);
  14. while(read(fd, &amp;e, sizeof(e)) == sizeof(e)){
  15. if(e.name[0] != '\0'){
  16. printf("%s\n", e.name);
  17. }
  18. }
  19. exit(0);
  20. }

ls如何得到一个目录中的文件列表呢?

你可以打开一个目录并读取它->文件名

.“是进程当前目录的伪名称

请参阅ls.c了解更多细节

总结

  • 我们已经了解了UNIX的I/O、文件系统和进程抽象。

  • 接口很简单——只有整数和I/O缓冲区。

  • 抽象组合得很好,例如I/O重定向。

你们将在下周的第一个实验中使用这些系统调用。