学习操作系统的时候,买了一本人民邮电出版社的-Operating Systems: Three Easy Pieces 中译版(操作系统导论)。
从书中学到了很多关于操作系统的思想,出现一个问题,思考策略,并比较策略之间的利弊。整本书读起来十分有趣,当然我不是在这里打广告。
当时只是学习了理论知识,书中所给的练习以及后面的实验都没有做过,当时在附录后面看到:xv6 实验,一直没来做。
这些天,从网上找到了 xv6 实验对应的课程 6.S081,课程的 lab 是重点。
这个 Lab 主要是 Shell 的一些命令实现,做完之后会体会到:原来这些命令是这样实现的!
希望我的文章能给大家带来些帮助!
- 一些代码的分析会需要一些 Linux 知识,有使用过
shell
的经验会更好。 - 做实验的同时,也需要研读下课程提供的参考书,这里我选择了原文阅读。
- 感兴趣的小伙伴,可以一起来做做!环境配置放在这里了 xv6内核实验环境搭建
Xv6-Lab-utilities
sleep (easy)
Some hints:
- Before you start coding, read Chapter 1 of the xv6 book.
- Look at some of the other programs in
user/
(e.g.,user/echo.c
,user/grep.c
, anduser/rm.c
) to see how you can obtain the command-line arguments passed to a program.- If the user forgets to pass an argument, sleep should print an error message.
- The command-line argument is passed as a string; you can convert it to an integer using
atoi
(see user/ulib.c).- Use the system call
sleep
.- See
kernel/sysproc.c
for the xv6 kernel code that implements thesleep
system call (look forsys_sleep
),user/user.h
for the C definition ofsleep
callable from a user program, anduser/usys.S
for the assembler code that jumps from user code into the kernel forsleep
.- Make sure
main
callsexit()
in order to exit your program.- Add your
sleep
program toUPROGS
in Makefile; once you’ve done that,make qemu
will compile your program and you’ll be able to run it from the xv6 shell.- Look at Kernighan and Ritchie’s book The C programming language (second edition) (K&R) to learn about C.
user 目录下的其他程序
按照实验指导给的步骤,先看看可执行命令对应的代码是怎样的,再添加自己编写的程序。
echo.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(int argc, char *argv[])
{
int i;
for(i = 1; i < argc; i++){
write(1, argv[i], strlen(argv[i]));
if(i + 1 < argc){
write(1, " ", 1);
} else {
write(1, "\n", 1);
}
}
exit(0);
}
rm.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc, char *argv[])
{
int i;
if(argc < 2){
fprintf(2, "Usage: rm files...\n");
exit(1);
}
for(i = 1; i < argc; i++){
if(unlink(argv[i]) < 0){
fprintf(2, "rm: %s failed to delete\n", argv[i]);
break;
}
}
exit(0);
}
这些程序都是这么做的:
- 执行的操作是从 int main 函数开始的
- 通过 argc(参数数量)和 argv(参数值数组)获取调用的参数
- 程序正常结束时,调用 exit(0) 退出表示调用成功,调用 exit(1) 退出表示调用失败,这个很重要!!!
我们可以猜测这么一个过程:在命令行输入某个命令时,会查找这个命令对应的可执行文件,并尝试去运行;程序通过 argc、argv 来获取命令参数。
更进一步,通过查看 xv6 book
我们知道 argv 的第一个参数(即argv[0])存放着程序名称。
Most programs ignore the first element of the argument array, which is conventionally the name of the program
- book-riscv-rev2.pdf#P12
我们还知道 shell 是通过 fork + exec 两个系统调用的配合来实现命令的执行。
The main loop reads a line of input from the user with getcmd. Then it calls fork, which creates a copy of the shell process…If exec succeeds then the child will execute instructions from echo instead of runcmd.
这个时候就大胆的看 xv6
中的 shell
源码,不过在这里就不多说了,后续有机会再好好分析。
user/sh.c
int
main(void)
{
static char buf[100];
int fd;
// Ensure that three file descriptors are open.
while((fd = open("console", O_RDWR)) >= 0){
if(fd >= 3){
close(fd);
break;
}
}
// Read and run input commands.
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
// Chdir must be called by the parent, not the child.
buf[strlen(buf)-1] = 0; // chop \n
if(chdir(buf+3) < 0)
fprintf(2, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
runcmd(parsecmd(buf));
wait(0);
}
exit(0);
}
实验指导让我们往 Makefile
的 UPROGS
段添加自己的程序名,看看这个地方放着什么:
UPROGS=\
$U/_cat\
$U/_echo\
$U/_forktest\
$U/_grep\
$U/_init\
$U/_kill\
$U/_ln\
$U/_ls\
$U/_mkdir\
$U/_rm\
$U/_sh\
$U/_stressfs\
$U/_usertests\
$U/_grind\
$U/_wc\
$U/_zombie\
cat、echo、grep…都是可执行命令,可以想象的到,编译内核时会根据这个内容来添加对应的命令。
系统调用 sleep 学习
sleep 系统调用接受一个 int 类型的参数,即要睡眠的 ticks。
int sleep(int);
用户程序调用内核 sleep 的汇编代码
可见,调用 sleep 相当于调用 SYS_sleep
.global sleep
sleep:
li a7, SYS_sleep
ecall
ret
sleep 的内核实现
uint64
sys_sleep(void)
{
int n;
uint ticks0;
if(argint(0, &n) < 0)
return -1;
acquire(&tickslock);
ticks0 = ticks;
while(ticks - ticks0 < n){
if(myproc()->killed){
release(&tickslock);
return -1;
}
sleep(&ticks, &tickslock);
}
release(&tickslock);
return 0;
}
- 获取 trapframe 中调用进程传入的第一个参数,即 ticks
- 使用自旋锁 tickslock 确保只有一个进程在while 循环代码中执行
- 记录起始 ticks,当前 ticks 减去起始 ticks 小于给定的 n时,会先判断进程的状态;如果进程在睡眠中途被 kill 则会释放锁并退出,否则调用 sleep 睡眠
实现 sleep
我们要实现的命令类似于这样
$ sleep 10
(nothing happens for a little while)
$
由于命令行的参数都是字符串,而我们需要 int 参数来调用 sleep,因此需要转换函数 atoi
。
atoi 函数原型
int atoi(const char*);
user/sleep.c
//
// Created by 悠一木碧 on 2022/6/15.
//
#include "../kernel/types.h"
#include "../kernel/stat.h"
#include "../user/user.h"
int
main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(2, "Usage: sleep ticks\n");
exit(1);
}
int n = atoi(argv[1]);
sleep(n);
exit(0);
}
Makefile
...
...
UPROGS=\
$U/_cat\
$U/_echo\
$U/_forktest\
$U/_grep\
$U/_init\
$U/_kill\
$U/_ln\
$U/_ls\
$U/_mkdir\
$U/_rm\
$U/_sh\
$U/_stressfs\
$U/_usertests\
$U/_grind\
$U/_wc\
$U/_zombie\
$U/_sleep\
...
...
测试结果
pingpong (easy)
Write a program that uses UNIX system calls to ‘‘ping-pong’’ a byte between two processes over a pair of pipes, one for each direction. The parent should send a byte to the child; the child should print “: received ping”, where is its process ID, write the byte on the pipe to the parent, and exit; the parent should read the byte from the child, print “: received pong”, and exit. Your solution should be in the file
user/pingpong.c
.Some hints:
- Use
pipe
to create a pipe.- Use
fork
to create a child.- Use
read
to read from the pipe, andwrite
to write to the pipe.- Use
getpid
to find the process ID of the calling process.- Add the program to
UPROGS
in Makefile.- User programs on xv6 have a limited set of library functions available to them. You can see the list in
user/user.h
; the source (other than for system calls) is inuser/ulib.c
,user/printf.c
, anduser/umalloc.c
.Run the program from the xv6 shell and it should produce the following output:
$ make qemu ... init: starting sh $ pingpong 4: received ping 3: received pong $
Your solution is correct if your program exchanges a byte between two processes and produces output as shown above.
系统调用 pipe 学习
A pipe is a small kernel buffer exposed to processes as a pair of file descriptors, one for reading and one for writing. Writing data to one end of the pipe makes that data available for reading from the other end of the pipe.
- book-riscv-rev2.pdf#P15
pipe 系统用创建两个文件描述符,并将其放置在给定的数组 arr
中;其中arr[0]
用于read
,arr[1]
用于write
。当向arr[1]
写入数据时,可以从arr[0]
读取。
The program calls pipe… After fork, both parent and child have file descriptors referring to the pipe.
- book-riscv-rev2.pdf#P16
Two file descriptors share an offset if they were derived from the same original file descriptor by a sequence of fork and dup calls.
- book-riscv-rev2.pdf#P15
需要注意的是,当进程在调用 pipe
之后再调用 fork
创建子进程,父子进程都会有这两个文件描述符(file desciptor)的引用,且共享读写的 offset。
这里提到的东西会很多,比如文件描述符的引用计数,涉及到了文件系统组织,读写的 offset
听上去很绕口;学习过操作系统和 IO 的同学可能会知道这里指什么。不过这里,我们也不细研究,只探讨书中所说的是否正确。
测试
为了测试书中所说的两个功能,我们使用 pipe
系统调用创建管道,并使用 fork
创建子进程,父子进程都通过 p[1]
写入,测试它们的写入是否共享 offset
,并测试是否能从 p[0]
读取。
user/test.c
//
// Created by 悠一木碧 on 2022/6/15.
//
#include "../kernel/types.h"
#include "../kernel/stat.h"
#include "../user/user.h"
int
main(int argc, char *argv[])
{
int p[2];
pipe(p);
int ret;
if ((ret = fork()) == 0) {
close(p[0]);
const char *str = "hello ";
int len = strlen(str);
int n = write(p[1], str, len);
int pid = getpid();
if (n != len) {
fprintf(2, "process %d: write %d bytes, but expect to write %d bytes!\n", pid, n, len);
exit(1);
}
fprintf(2, "process %d: write %d bytes\n", pid, n);
close(p[1]);
} else if (ret > 0) {
wait(0);
const char *str = "world!";
int len = strlen(str);
int n = write(p[1], str, len);
int pid = getpid();
if (len != n) {
fprintf(2, "process %d: write %d bytes, but expect to write %d bytes!\n", pid, n, len);
exit(1);
}
fprintf(2, "process %d: write %d bytes\n", pid, n);
close(p[1]);
char buf[1024];
while ((n = read(p[0], buf, 1023)) > 0) {
buf[n] = '\0';
fprintf(1, "process %d: read %d bytes, str is %s\n", pid, n, buf);
}
if (n < 0) {
fprintf(2, "read error!\n");
exit(1);
}
close(p[0])