南京航空航天大学《操作系统实践》课程报告
前言:本篇课程报告仅供nh学生参考,希望各位还是能够自己根据所学情况,亲自完成个人实践报告,祝各位学习顺利,天天开心。
文章目录
实验一:job6/sh4
1.题目要求
-
完成 sh4
- 重新编写 Makefile
- 实现连接多条命令
-
编写测试用例
-
参考 job6/sh4/parse.c
- 实现 test_parse_cmd_2
- 实现 test_parse_pipe_cmd_1
- 实现 test_parse_pipe_cmd_2
-
参考 job6/sh4/cmd.c
- 实现 test_exec_pipe_cmd
-
2.解决思路
先明确目标任务是在sh3的基础上实现管道线/多条命令的执行、重新编写Makefile以及为sh4编写测试用例。
对于第一个任务,需要先读取标准输入至数组中,然后将命令进行分割,由于数组中包含多条命令,在分割时需要递归实现,分割后的字符串保存在cmd结构体中,接下来判断结构体中的命令是否是内置指令以及创建子进程来执行多条命令。
对于第二个任务,重新编写makefile文件,按照老师给的格式重新构建目标,并将它们链接成一个名为 sh4 的可执行文件,注意定义清理操作以及生成依赖关系文件的规则,相比于使用命令来编译链接文件,这么做对于大型项目中代码的维护和修改更加灵活。
对于第三个任务,需要测试多条管道命令,比如:echo abc >log、cat <input | sort | cat >output等,看看通过分割的管道命令与预先设定的命令是否匹配,在test_exec_pipe_cmd函数中,执行命令并调用read_and_cmp函数验证执行结果是否与预期一致。
3.关键代码
命令结构体:
#define MAX_ARGC 10
struct cmd {
int argc;
char *argv[MAX_ARGC];
char *input;
char *output;
};
将读入的命令分割后保存在该结构体中,比如echo abc > log命令分割后,argc = 2,argv = {“echo”, “abc”},input = NULL,output = “log”
管道命令分割:
int parse_pipe_cmd(char *line, struct cmd *cmdv)
{
int cmdc = 0;
char *save = NULL;
char *cmd_line = strtok_r(line, "|", &save);
while (cmd_line != NULL) {
parse_cmd(cmd_line, cmdv+cmdc);
cmd_line = strtok_r(NULL, "|", &save);
cmdc++;
}
return cmdc;
}
将输入的命令行字符串按照管道符’|'进行分割,并将每个子命令解析为一个 struct cmd 结构体,存储在 cmdv 数组中,在实现循环分割的时候,发现使用strtok函数分割结果总是出错,调了很久bug,最后改用strtok_r函数中的一个参数来保存当前状态,才能顺利解析。
内置指令的执行:
int builtin_cmd(struct cmd *cmd)
{
char **argv = cmd->argv;
if(strcmp(argv[0], "cd") == 0)
{
chdir(argv[1]);
return 1;
}
if(strcmp(argv[0], "pwd") == 0)
{
char cwd[100];
getcwd(cwd, sizeof(cwd));
printf("%s\n", cwd);
return 1;
}
if(strcmp(argv[0], "exit") == 0)
{
exit(0);
return 1;
}
return 0;
}
对于保存在结构体中的命令,首先需要检查是否是内置指令,将第一个参数(argv[0])与内置命令的字符串进行比较,判断命令是否为内置命令。如果匹配到了某个内置命令,就执行相应的操作,并返回1表示该命令是内置命令。如果没有匹配到内置命令,就返回0表示该命令不是内置命令。
管道命令执行:
void exec_pipe_cmd(int cmdc, struct cmd *cmdv)
{
int i;
int fds[2];
int in = 0;
for (i = 0; i < cmdc; i++) {
struct cmd *cmd = cmdv + i;
pid_t pid = fork();
if (pid == 0) {
if (i > 0) {
dup2(in, 0);
close(in);
}
if (i < cmdc - 1) {
dup2(fds[1], 1);
close(fds[1]);
} else {
if (cmd->output) {
int fd = creat(cmd->output, 0666);
dup2(fd, 1);
close(fd);
}
}
if (cmd->input) {
int fd = open(cmd->input, O_RDONLY);
dup2(fd, 0);
close(fd);
}
if (execvp(cmd->argv[0], cmd->argv) < 0) {
perror("execvp");
exit(1);
}
}
if (in > 0) {
close(in);
}
close(fds[1]);
in = fds[0];
}
for (i = 0; i < cmdc; i++) {
wait(NULL);
}
}
循环遍历每个管道命令,每个循环中,先创建一个管道,在创建一个子进程,子进程中需要根据管道命令进行重定向操作,如果是第一个管道命令之后的命令,则将前一个命令的输出作为当前命令的输入,通过 dup2(in, 0) 实现。如果是最后一个管道命令,并且有输出重定向,则将输出重定向到指定文件,通过 dup2(fd, 1) 实现。如果有输入重定向,则将输入重定向到指定文件,通过 dup2(fd, 0) 实现。最后在执行子进程中的命令,可以直接使用sh3中的execvp函数实现。
Makefile自动化使用:
CC=gcc
OBJS=main.o cmd.o parse.o utest.o //目标文件列表
all: depend sh4
sh4: $(OBJS)
$(CC) -o sh4 $(OBJS) //将目标文件链接为可执行文件'sh4'
depend:
./gen-dep.sh > Makefile.dep
%.o: %.c //定义模式规则
$(CC) -c -o $@ $<
clean:
rm -f sh4 *.o
include Makefile.dep //定义依赖关系规则
此处makefile文件用来构建目标文件,并将它们链接成一个名为 sh4 的可执行文件,它还定义了清理操作以及生成依赖关系文件的规则,放到了Makefile.dep中。
管道命令测试:(以cat <input | sort | cat >output为例)
void test_parse_pipe_cmd_2()
{
struct cmd cmdv[3];
int cmdc;
char line[] = "cat <input | sort | cat >output";
cmdc = parse_pipe_cmd(line, cmdv);
assert(cmdc == 3);
assert(strcmp(cmdv[0].argv[0], "cat") == 0);
assert(cmdv[0].argv[1] == NULL);
assert(strcmp(cmdv[0].input, "input") == 0);
assert(strcmp(cmdv[1].argv[0], "sort") == 0);
assert(cmdv[1].argv[1] == NULL);
assert(strcmp(cmdv[2].argv[0], "cat") == 0);
assert(cmdv[2].argv[1] == NULL);
assert(strcmp(cmdv[2].output, "output") == 0);
}
void test_exec_pipe_cmd()
{
unlink("test.out");
pid_t pid = fork();
if (pid == 0) {
struct cmd cmdv[] = {
{2, {"cat", NULL}, "test.in", NULL},
{2, {"sort", NULL}, NULL, NULL},
{2, {"uniq", NULL}, NULL, NULL},
{2, {"cat", NULL}, NULL, "test.out"},
};
exec_pipe_cmd(4, cmdv);
}
wait(NULL);
read_and_cmp("test.out", "1\n2\n3\n");
}
第一个函数test_parse_pipe_cmd_2通过执行assert断言,验证parse_pipe_cmd分割后的命令是否正确,第二个函数另外构造需要测试的结构体,子进程执行exec_pipe_cmd函数,主进程调用 read_and_cmp 函数,比较文件中的内容与预期结果是否相同。
4.运行结果
& make //任务二
> cat <test.in | sort | uniq | cat >test.out //任务一
> cat test.out
1
2
3
& ./sh4 -utest //任务三
000:test_parse_cmd_1 √
001:test_parse_cmd_2 √
002:test_parse_pipe_cmd_1 √
003:test_parse_pipe_cmd_2 √
004:test_exec_cmd √
005:test_exec_pipe_cmd √
以上分别展示sh4的三个任务结果。
实验二:job8/pc1.c
1.题目要求
使用条件变量解决生产者、计算者、消费者问题:
-
系统中有3个线程:生产者、计算者、消费者
-
系统中有2个容量为4的缓冲区:buffer1、buffer2
-
生产者生产’a’、‘b’、‘c’、‘d’、‘e’、‘f’、‘g’、'h’八个字符,放入到buffer1
-
计算者从buffer1取出字符,将小写字符转换为大写字符,放入到buffer2
-
消费者从buffer2取出字符,将其打印到屏幕上
2.解决思路
这道题算是典型的多线程模型了,基本思路是需要通过互斥锁(mutex)和条件变量来实现不同线程之间的同步和协作,确保数据在不同缓冲区之间的正确传递和处理。在访问缓冲区时需要对缓冲空间当前状态(空或满)进行判断,然后配合锁机制以及信号量进行访问即可。
我的解决思路是:
在生产者-计算者模型中,生产者判断缓冲区1状态,然后获取锁1,生产一个字符,放入缓冲区,释放锁1;计算者判断当前缓冲区1的状态,获取锁1,取出一个字符,释放锁1,变换为大写字符;判断缓冲区2的状态,获取锁2,放入缓冲区2,释放锁2;
在计算者-消费者模型中,消费者判断缓冲区2状态,然后获取锁2,消费一个字符,释放锁2。
3.关键代码
这道题代码实现有两种方式,一种是我自己写的,另一种是老师给的标准答案,两者解决思路大致一样,只不过在数据结构和缓冲区操作上有些区别,下面我将以对比的方式给出两种代码实现的关键点:
在数据结构上我写的代码:
int buffer1[CAPACITY];
int buffer2[CAPACITY];
int in1;
int in2;
int out1;
int out2;
在数据结构上老师给出的代码:
struct buff {
int data[CAPACITY];
int in;
int out;
pthread_mutex_t mutex;
pthread_cond_t cond;
};
struct buff b1, b2;
不难看出,老师给出的代码使用了自定义的结构体buff来表示缓冲区,其中包含了数据数组和读写指针等信息。而我自己写的代码直接使用了全局变量包括两个数组和四个指针来分别表示两个缓冲区的数据和读写位置。
老师使用自定义结构体 buff来表示缓冲区具有更好的封装性和可扩展性,适用于需要更复杂缓冲区逻辑和多个实例的情况,更加安全但相应开销也会大一些;而我写的代码可以提高访问速度和性能效率,但是扩展缓冲区相关的属性和状态就会变得困难,需要手动处理多个数组和指针。
三个线程的入口函数,我写的代码是:(以计算者线程为例)
void *calculate(void *arg)
{
int i;
int item;
for (i = 0; i < ITEM_COUNT; i++) {
pthread_mutex_lock(&mutex);
while (buffer1_is_empty())
pthread_cond_wait(&wait_full_buffer1, &mutex);
item = get_item1() - 32;
//printf(" calculate item: %c\n", item);
while (buffer2_is_full())
pthread_cond_wait(&wait_empty_buffer2, &mutex);
put_item2(item);
pthread_cond_signal(&wait_empty_buffer1);
pthread_cond_signal(&wait_full_buffer2);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
三个线程的入口函数,老师给的代码是:(以计算者线程为例)
void *compute(void *arg)
{
int i;
int item;
for (i = 0; i < ITEM_COUNT; i++) {
item = buff_get_item(&b1);
item = toupper(item);
// printf(" compute item: %c\n", item);
buff_put_item(&b2, item);
}
return NULL;
}
对于三个线程函数的内部实现,两种实现方式的简要流程都是:加锁->判断缓冲区状态->处理->更改缓冲区状态->释放锁。相比于我在每个函数中多次调用锁机制和信号量,老师的实现方式是将锁和信号量封装到函数中,需要时直接调用,结构更加清晰简洁,方便调试。
4.运行结果
A
B
C
D
E
F
G
H
消费者每次从缓冲区2中取出一个字符,就会将其打印在显示屏幕上,直至输出完毕。
实验三:job8/pc2.c
1.题目要求
使用信号量解决生产者、计算者、消费者问题:
- 系统中有3个线程:生产者、计算者、消费者
- 系统中有2个容量为4的缓冲区:buffer1、buffer2
- 生产者生产’a’、‘b’、‘c’、‘d’、‘e’、‘f’、‘g’、'h’八个字符,放入到buffer1
- 计算者从buffer1取出字符,将小写字符转换为大写字符,放入到buffer2
- 消费者从buffer2取出字符,将其打印到屏幕上
2.解决思路
这道题的解决思路和上面的实验二:job8/pc1.c相似,只不过此处需要使用信号量来实现多线程模型,需要对缓冲区1和缓冲区2各申请两个信号量来表示空或者满。
我的具体思路是:
在生产者-计算者模型中,生产者需要先等待一个空的缓冲区1信号量和锁,然后生产一个字符,释放一个满的缓冲区1信号量和锁,计算者需要等待一个满的缓冲区1信号量和锁,然后取出一个字符并转换成大写,释放一个空的缓冲区1信号量和锁,再等待一个空的缓冲区2信号量和锁,放入字符,释放一个满的缓冲区2信号量和锁;
在计算者-消费者模型中,消费者需要等待一个满的缓冲区2信号量和锁,取出字符并打印,最后释放一个空的缓冲区2信号量和锁。
3.关键代码
和pc1.c一样,这道题代码实现有两种方式,一种是我自己写的,另一种是老师给的标准答案,两者解决思路大致一样,只不过在数据结构和缓冲区操作上有些区别,下面我将以对比的方式给出两种代码实现的关键点:
在数据结构上我写的代码:
int buffer1[CAPACITY]; //写者-计算者缓冲空间 1
int buffer2[CAPACITY]; //计算者-读者缓冲空间 2
int in1;
int in2;
int out1;
int out2;
在数据结构上老师给出的代码:
struct buff {
int data[CAPACITY];
int in;
int out;
sema_t empty_buff_sema;
sema_t full_buff_sema;
};
struct buff b1, b2;
这里我选择将缓冲区数组(buffer1 和 buffer2)以及输入/输出指针(in1、in2、out1、out2)定义为全局变量,而在老师给的代码中,这些元素被封装在 struct buff 结构体内部,并且引入了两个 struct buff 结构体的实例来分别表示两个缓冲区,这种封装有助于在结构体内部管理与缓冲区相关的数据和同步,使得代码更加有组织性和模块化。
以生产者为例,我写的代码为:
void put_item1(int item)
{
buffer1[in1] = item;
in1 = (in1 + 1) % CAPACITY;
}
void *produce()
{
int i;
int item;
for (i = 0; i < ITEM_COUNT; i++) {
sema_wait(&empty_buffer_sema1);
sema_wait(&mutex_sema1);
item = i + 'a';
put_item1(item);
// printf("produce item: %c\n", item);
sema_signal(&mutex_sema1);
sema_signal(&full_buffer_sema1);
}
return NULL;
}
以生产者为例,老师给出的代码为:
void buff_put_item(struct buff *buff, int item)
{
sema_wait(&buff->empty_buff_sema);
buff->data[buff->in] = item;
buff->in = (buff->in + 1) % CAPACITY;
sema_signal(&buff->full_buff_sema);
}
void *produce(void *arg)
{
int i;
int item;
for (i = 0; i < ITEM_COUNT; i++) {
item = 'a' + i;
buff_put_item(&b1, item);
printf("produce item: %c\n", item);
}
return NULL;
}
在实现逻辑上,两类代码逻辑shan都是先申请一个空的信号量,然后处理字符,最后释放一个满的信号量。我将信号量的调用放在每个线程函数中,老师将信号量的调用放在put_item和get_item中,这使得每次处理缓冲区时,直接调用相应函数即可,省去其它全局变量的使用,结构更清晰。
4.运行结果
A
B
C
D
E
F
G
H
消费者每次从缓冲区2中取出一个字符,就会将其打印在显示屏幕上,直至输出完毕。
实验四:job9/pfind.c
1.题目要求
- 功能要求与sfind相同,实现在文件或者目录中查找指定的字符串,并打印包含该字符串的行
- 要求使用多线程完成
- 主线程创建若干各子线程
- 主线程负责遍历目录中的文件
- 遍历到目录中的叶子节点时,将叶子节点发送给子线程进行处
- 两者之间使用生产者-消费者模型通信
- 主线程生成数据
- 子线程读取数据
- 主线程创建若干各子线程
2.解决思路
按照老师给的讲义框架,先创建一个初始状态为空任务队列,用于存放主线程遍历目标目录下的分任务,创建几个子线程,用于接收任务队列中的任务,主线程遍历目录并将叶子节点的路径加入任务队列,再创建几个特殊任务,当特殊任务的标识is_end为真时,表示主线已完成遍历,不需要再向任务队列中放置任务,子线程可以退出,最后等待所有的子线程结束为止。
3.关键代码
子线程入口函数:
void *worker_entry(void *arg)
{
task *t;
while(1)
{
pthread_mutex_lock(&mutex);
while(buff_is_empty())
pthread_cond_wait(&full, &mutex);
t = &buff[out];
out = (out + 1) % MAX_SIZE;
pthread_mutex_unlock(&mutex);
if(t->is_end == 1)
break;
find_file(t->path, t->string);
}
}
子线程入口函数需要在一个循环中,在任务队列中获取一个任务,然后去执行,当读取到一个特殊的任务(is_end为true),循环结束。
遍历文件并创建子任务:
void CreatTask(char *path, char *target, int start)
{
DIR *dir = opendir(path);
struct dirent *entry;
while ((entry = readdir(dir))) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
char entry_path[256];
snprintf(entry_path, sizeof(entry_path), "%s/%s", path, entry->d_name);
if (entry->d_type == DT_DIR) {
CreatTask(entry_path, target, start + 1);
} else if (entry->d_type == DT_REG) {
pthread_mutex_lock(&mutex);
while (buff_is_full())
pthread_cond_wait(&empty, &mutex);
buff[in].is_end = start;
strcpy(buff[in].path, entry_path);
strcpy(buff[in].string, target);
in = (in + 1) % MAX_SIZE;
pthread_cond_signal(&full);
pthread_mutex_unlock(&mutex);
}
}
if (start == 0) {
for (int i = 0; i < WORKER_NUMBER; i++) {
buff[in].is_end = 1;
in = (in + 1) % MAX_SIZE;
}
}
closedir(dir);
}
我的思路时递归地遍历目录树,对于每个目录项,首先判断是否为当前目录或父目录。然后构建完整的搜索路径,判断目录项的类型,如果是子目录,需要递归调用自身,如果是普通文件,就需要将其加入任务队列中。当循环完当前目录中的所有子目录或普通文件后,判断当前的搜索深度(is_ture)是不是0,是的话退出。
4.运行结果
$ pfind test main
test/hello/hello.c: int main()
test/world/world.c: int main()
执行pfind test main后,查找出test目录中所有含main字符串的文件目录和相应代码行。
实验五:job11/http服务器
1.题目要求
- 获取代码 wget https://www.nuaalab.cn/os/jobs/job10.tgz
- 完成多进程版本的 httpd,能够实现动态页面(时间展示)、页面参数传递等功能。注意:缺省情况下,本地主机无法访问虚拟机中的网络服务,需要在虚拟机中设置端口映射。
2.解决思路
要实现的第一个功能是点击网页中/App/now后出现动态时间,即http服务器调用job10 /http/serial/www/app 程序。该页面的url是http://localhost:8080/app/now ,传给服务器请求为GET /app/now HTTP/1.1\r\n,需要先提取出请求路径为/app/now,然后通过fw写回页面。
第二个要实现的功能是带参数的动态页面,比如/app/show env?name=tom&age=10,动态页面对应的应用程序需要接收用户的参数(以该命令为例,环境变量为env? name =tom & age =10)。类似的,要实现Add Student功能,http服务器需要分割接收到的url,分出来调用的程序代码路径和envv参数,再传递给add_student.html,而remove_student功能实现和上述同理。
3.关键代码
http请求解析函数:
char *http_parse_req(FILE *fr, char *req, int req_size) {
fgets(req, req_size, fr);
//puts(req);
char *http_path = strtok(req, " ");
http_path = strtok(NULL, " ");
return http_path;
}
以GET /index.html HTTP/1.1\r\n为例吧,req字符串为GET /index.html HTTP/1.1\r\n,第一次http_path为GET,第二次分割后http_path为/index.html。
http请求处理函数:
void http_handler(int fd) {
FILE *fr = fdopen(fd, "r");
FILE *fw = fdopen(fd, "w");
char req[1024];
char *http_path;
http_path = http_parse_req(fr, req, sizeof(req));
if (http_path != NULL) {
if (strcmp(http_path, "/") == 0) {
http_path = "/index.html";
}
char file_path[256];
sprintf(file_path, "%s%s", web_root, http_path);
if (strstr(http_path, "/app") != NULL) {
handle_app_request(fw, http_path);
} else if (is_directory(file_path)) {
handle_directory_request(fw, file_path);
} else {
handle_file_request(fw, file_path);
}
}
}
在执行完http请求解析函数(http_parse_req())后,需要进行判断,如果请求路径为根目录,需要将其设置为默认的文件路径(/index.html)。然后合并完整的文件访问路径,保存到数组中,if语句中的三个函数封装如下:
解析路径,执行应用程序函数、处理目录或文件请求函数:
void handle_app_request(FILE *fw, const char *http_path) {
char *query_string = NULL;
char *path = strtok(http_path, "?");
char *query = strtok(NULL, "?");
char *app_path = strdup(path + 1);
setenv("QUERY_STRING", query_string, 1);
execl(app_path, app_path, NULL);
free(app_path);
free(query_string);
}
void handle_directory_request(FILE *fw, const char *file_path) {
DIR *dir = opendir(file_path);
http_send_headers(fw, "text/html");
http_printf(fw, "<h1>Index of %s</h1>\n", file_path);
http_printf(fw, "<hr>\n");
http_printf(fw, "<ol>\n");
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
char entry_path[256];
snprintf(entry_path, sizeof(entry_path), "%s/%s", file_path, entry->d_name);
struct stat file_stat;
if (stat(entry_path, &file_stat) == 0) {
if (S_ISDIR(file_stat.st_mode)) {
http_printf(fw, "<a href=\"%s/%s/\">%s/</a><br>\n", file_path, entry->d_name, entry->d_name);
} else {
http_printf(fw, "<a href=\"%s/%s\">%s</a><br>\n", file_path, entry->d_name, entry->d_name);
}
}
}
http_printf(fw, "</ol>\n");
http_printf(fw, "<hr>\n");
http_end(fw);
closedir(dir);
}
void handle_file_request(FILE *fw, const char *file_path) {
FILE *file = fopen(file_path, "r");
char *content_type = strstr(file_path, ".html") != NULL ? "text/html" : "text/plain";
http_send_headers(fw, content_type);
fseek(file, 0, SEEK_END);
long file_size = ftell(file);
rewind(file);
char *file_content = malloc(file_size);
fread(file_content, file_size, 1, file);
http_write_chunk(fw, file_content, file_size);
free(file_content);
http_end(fw);
fclose(file);
}
int is_directory(const char *path) {
struct stat file_stat;
if (stat(path, &file_stat) == 0) {
return S_ISDIR(file_stat.st_mode);
}
return 0;
}
以上三个函数在http请求处理函数中被调用,这样调试起来比较方便一些。首先去判断数组中是否包含/app路径,是则通过管道和创建子进程去执行命令,然后将结果通过fw返回到页面;如果访问的是普通文件,将文件内容返回到页面;如果是文件夹,就生成文件列表的html响应返回给页面。
其它需要补充的地方,比如主函数需要创建监听、接收客户端连接请求以及关闭连接等,由于不是关键代码,所以此处就不再赘述了。
4.运行结果
./httpd.ok -p 8080
运行上述命令后,打开网页,输入http://localhost:8080,进入后测试页面功能(显示时间、添加学生、展示学生列表等)运行正常。
实验总结与感悟
全程跟下来感觉内容还是挺多的,涉及到的面很广,包括进程管理、文件描述符、IO重定向、使用管道进行进程之间的通信、makefile的使用、命令行的执行、程序执行的测试、线程的使用以及socket编程等。几乎涵盖了OS的各个方面,也对我学习OS理论课程起到指导和帮助作用。可惜的是,由于比赛日和实验课程考试冲突,无奈只能选择写报告的方式完成此次考试测验。不过收获还是有很多的,无论是上机课程的理论学习,还是代码的编写技巧,这些都能够为我未来的就业奠定基础。