实验入口
主要参考文章
lseek()函数:用于移动打开文件的指针
linux系统调用之write源码解析(基于linux0.11)
get_fs_bytes解析
VIM与系统剪贴板的复制粘贴
操作系统实验六 信号量的实现和应用(哈工大李治军)
哈工大操作系统实验6 信号量的实现 pc.c 编译时报错 对‘sem_open‘未定义的引用
Linux 文件编程 open函数
哈工大-操作系统-HitOSlab-李治军-实验5-信号量的实现和应用
地址映射与共享
参考文章
必备知识
要点1 共享内存
顾名思义,共享内存就是允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常安排为同一段物理内存。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
注:共享内存并未提供同步机制,所以我们需要用信号量来实现同步。
Linux提供了一组接口用于使用共享内存,它们声明在头文件 sys/shm.h 中。
1.shmget
程序先通过调用shmget()函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget()函数的返回值)。
1 | int shmget(key_t key, size_t size, int shmflg); |
key为共享内存段名字,size为大小,shmflg是权限标志
注:
① key:非0整数,共享内存段的命名
② shmflag:作用与open函数的mode参数一样,比如IPC_CREAT,或连接
共享内存的权限标志与文件的读写权限一样,举例来说,0644表示允许一个进程创建的共享内存被内存创建者所拥有的进程向共享内存读取和写入数据,同时其他用户创建的进程只能读取共享内存
③ return:成功时返回一个与key相关的共享内存标识符(非负整数)。调用失败返回-1
不相关的进程可以返回值(共享内存标识符)访问同一共享内存。
2.shmat
第一次创建完共享内存时,它还不能被任何进程访问,需要shmat启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。
1 | void *shmat(int shm_id, const void *shm_addr, int shmflg); |
① shm_id:共享内存标识符
② shm_addr:指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址
③ shm_flg:一组标志位,通常为0
④ return:成功时返回一个指向共享内存第一个字节的指针,失败返回-1
3.shmdt
用于将共享内存从当前进程中分离,使该共享内存对当前进程不再可用。
1 | int shmdt(const void *shmaddr); |
① shmaddr:shmat返回的共享内存指针
② return:成功0,失败1
4.shmctl
用来控制共享内存
1 | int shmctl(int shm_id, int command, struct shmid_ds *buf); |
① shm_id:共享内存标识符
② command:要采取的操作,它可以取下面的三个值 :
- IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
- IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
- IPC_RMID:删除共享内存段
③ buf:结构指针
shmid_ds结构 至少包括以下成员:
1 | struct shmid_ds |
实验1 在Ubuntu下编写程序“基于共享内存的生产者消费者模型”
本项实验在 Ubuntu 下完成,与信号量实验中的
pc.c
的功能要求基本一致,仅有两点不同:
- 不用文件做缓冲区,而是使用共享内存;
- 生产者和消费者分别是不同的程序。生产者是 producer.c,消费者是 consumer.c。两个程序都是单进程的,通过信号量和缓冲区进行通信。
Linux 下,可以通过
shmget()
和shmat()
两个系统调用使用共享内存。
直接上代码。感觉比文件操作简单多了2333
consumer.c
1 |
|
producer.c
1 |
|
编译运行指令
1 | gcc -o producer producer.c -pthread |
运行结果c.txt(仅展示部分)
1 | 27696 : 000 |
实验2 在Linux0.11实现共享内存
进程之间可以通过页共享进行通信,被共享的页叫做共享内存,结构如下图所示:
本部分实验内容是在 Linux 0.11 上实现上述页面共享,并将上一部分实现的 producer.c 和 consumer.c 移植过来,验证页面共享的有效性。
具体要求在
mm/shm.c
中实现shmget()
和shmat()
两个系统调用。它们能支持producer.c
和consumer.c
的运行即可,不需要完整地实现 POSIX 所规定的功能。
- shmget()
1 int shmget(key_t key, size_t size, int shmflg);
shmget()
会新建/打开一页内存,并返回该页共享内存的 shmid(该块共享内存在操作系统内部的 id)。所有使用同一块共享内存的进程都要使用相同的 key 参数。
如果 key 所对应的共享内存已经建立,则直接返回
shmid
。如果 size 超过一页内存的大小,返回-1
,并置errno
为EINVAL
。如果系统无空闲内存,返回 -1,并置errno
为ENOMEM
。
shmflg
参数可忽略。
- shmat()
1 void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat()
会将shmid
指定的共享页面映射到当前进程的虚拟地址空间中,并将其首地址返回。如果
shmid
非法,返回-1
,并置errno
为EINVAL
。
shmaddr
和shmflg
参数可忽略。
思路:
1.shmget:由其论述,我们可以知道,我们需要建立一个映射表,其中成员为结构体({key_t key,size_t size,unsigned long page}),每次只需查找映射表,如果有对应key则返回下标,如果没有则新建页表,填入映射体,再返回对应下标。
以下为了图省事,对映射表的实现进行了简化,把key直接当做int类型,作为映射表下标,映射表成员为page,unsigned long。
2.shmat:
首先由指导书的提示:
1 | // 建立线性地址和物理地址的映射 |
我们知道在shmat中,要建立shmget得到的共享物理页面与其虚拟地址的映射,就需要使用这个put_page函数。
但是put_page函数的参数为页和address。页就是我们的shm_map[key],address=虚拟地址+段基址。那么如何得到虚拟地址呢?
通过指导书的提示:
1 | code_base = get_base(current->ldt[1]); |
再结合所学知识,我们可以知道几点:
① 数据段的基址可由current->ldt[2]给出 ② address=虚拟地址+段基址 ③ 我们需要分配给当前共享内存一段空闲的虚拟地址段
则该小段空闲数据段的虚拟地址就是我们的return值,address=return+data_base。
问题就转化成了如何获取一段空闲数据段。
我们由下图:
可知,brk指针指向堆区顶部,即空闲堆的起始位置。因而我们可以用这段空间作为我们要的空闲数据段,当前brk即为虚拟地址。
我们的页有PAGE_SIZE那么大,因而自然也就要用PAGE_SIZE那么大的空闲数据段了。
解说完毕,以下上代码~
1 |
|
信号量
任务一 实现pc.c
1 | 在 Ubuntu 上编写应用程序“pc.c”,解决经典的生产者—消费者问题,完成下面的功能: |
先附上我的代码吧【注:我没做到从缓冲区删除,但其他都完成了】
1 |
|
运行效果:
要点1 系统调用的IO读写
这部分耗费了我海量时间,主要原因还是因为我没有好好学就直接上手写导致很多地方都因为不清楚而寄了。。。
先大致讲讲文件读写的原理吧。打开一个文件作为数据流,有一个文件指针,该指针指向的地方就是之后读写开始的地方,读写还有lseek都可以让指针移动。
再放个各个系统调用的签名。
1 | @param 文件名 模式 |
其中flag的可能取值:
如果想要多个方式并行,则可以用|连接。【联系一下原理,这大概是用了标志位吧,每个标志只有一位是1】
这部分踩过的坑:
① 选择O_CREAT,如果文件已经存在,居然是会报错?【表现为errno=13,还会输出一堆奇怪的东西】
1 | @param 文件描述符 写入字符串 写入长度 |
read会读出size个字节然后存进string里面,同时也会移动文件指针向前size个字节。
1 | @param 文件描述符 写入字符串 写入长度 |
基本同write。
这部分踩过的坑:
write(fd,NULL,0) ——合法
write(fd,NULL,a),a>0 ——寄!
这还是因为write的具体实现了。
write里面有个判断
1 | int sys_write(unsigned int fd,char *buf,int count){ |
而get_fs_byte:
确实感觉空的话挺危险的【】
1 | @param 文件描述符 |
这个没啥好说的,记得关就是了
要点2 信号量的调用
这方面看linux自带的man文档就行,写得很清楚。
输入指令:
1 | man sem_overview |
这部分踩过的坑:
千万注意最后不使用信号量时要释放,使用sem_unlink。不然最后的输出结果会非常诡异。
要点3 编写程序
以上差不多就是涉及到的需要自己了解的课外知识点了,接下来就需要自己编写程序。
总体框架就按它给的差不多:
1 | Producer() |
有个点挺有趣的,就是它实际上把文件指针也看成一种资源了,因此也需要在同步段对其进行更新。
printf的stdout也是资源。
故以上两者都只能在锁内同步段进行更新。
main函数就照本宣科地用fork建立子进程就行。
任务二 自己实现信号量
1 | Linux 在 0.11 版还没有实现信号量,Linus 把这件富有挑战的工作留给了你。如果能实现一套山寨版的完全符合 POSIX 规范的信号量,无疑是很有成就感的。但时间暂时不允许我们这么做,所以先弄一套缩水版的类 POSIX 信号量,它的函数原型和标准并不完全相同,而且只包含如下系统调用: |
由于不小心写完的实验代码被销毁了,因此差不多参考的是这篇文章【戳这里】,修改了一些地方,构成了我的回忆版代码。
要点1 系统调用修改
详见文章,写得很清楚。
要点2 sem.c文件的编写
sem_t定义
1 | /* 定义的信号量数据结构: */ |
1 |
|
sem_open
1 | /* |
sem_wait
1 | /* |
sem_post
1 | /* |
sem_unlink
1 | /* |
pc.c
1 |
|
这部分踩过的坑:
在用户态和核心态之间传递参数【这个我没考虑到】
1
2
3指针参数传递的是应用程序所在地址空间的逻辑地址,
在内核中如果直接访问这个地址,访问到的是内核空间中的数据,不会是用户空间的。
所以这里还需要一点儿特殊工作,才能在内核中从用户空间得到数据。这段代码就是在做这个。
1
2
3
4
5
6
7/* 首先将信号量的名称赋值到新建的缓冲区中 */
char nbuf[20];
int i = 0;
for(; i< 20; i++)
{
nbuf[i] = get_fs_byte(name+i);
}这一段代码值得学习
1
2一个第一眼看傻掉了的问题
1
2
3
4
5
6//sleep函数的签名
void sleep_on(struct task_struct **p);
//一开始初始化队列为空
sem_list[i].queue = NULL;
//使用sleep
sleep_on(&(sem->queue));如果队列为空的时候,传入sleep_on的是不是NULL呢?
其实这个本质上是type* p=NULL,&p是不是NULL的问题。虽然知道不是,但还是写个程序测试一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct {
int value;
}haha;
void isNULL(haha** a){
printf("%d",a==NULL);
}
int main(){
haha *h=NULL;
isNULL(&h);
return 0;
}
//result:0sem_post签名与实现矛盾
1
2wake_up() 的功能是唤醒链表上睡眠的所有进程。
sem_post() 就是信号量的 V 原子操作。如果有等待 sem 的进程,它会唤醒其中的一个。以上都是指导书的内容。这个“所有”和“一个”的用意我不大明白。也许唤醒所有进程,其中一个抢到了锁,其他的全睡了,这个也被认为是唤醒其中一个吧()
聪明的越界处理【未考虑到】
1
2
3
4
5/* 判断:如果传入的信号量是无效信号量,V操作失败,返回-1 */
if(sem == NULL || sem < sem_list || sem > sem_list + SEM_LIST_LENGTH)
{
return -1;
}毕竟有效的信号量都是引用的信号量表的信号量。所以地址越界的自然无效。
最坑的一点
其实指导书提醒了
下面描述的问题未必具有普遍意义,仅做为提醒,请实验者注意。
include/string.h 实现了全套的 C 语言字符串操作,而且都是采用汇编 + inline 方式优化。
但在使用中,某些情况下可能会遇到一些奇怪的问题。比如某人就遇到
strcmp()
会破坏参数内容的问题。如果调试中遇到有些 “诡异” 的情况,可以试试不包含头文件,一般都能解决。不包含string.h
,就不会用 inline 方式调用这些函数,它们工作起来就趋于正常了。但是具体表现跟它说的差距有点大()
我是全部检查没问题了,然后上linux0.11真机运行。PID:number这样的信息全部打印出来了,没啥问题,但是打印完操作系统就会寄,大多数极端情况就直接重启了,小部分还会温和地提醒以下报错信息然后死循环
1
2kernel panic: trying to free up swapper memory space
in swapper task - not syncing最后尝试着修改去掉string.h,才得到了正确的结果,泪目。
proc文件系统
参考文章:
操作系统实验08-proc文件系统的实现
在 Linux 0.11 上实现 procfs(proc 文件系统)内的 psinfo 结点。当读取此结点的内容时,可得到系统当前所有进程的状态信息。例如,用 cat 命令显示
/proc/psinfo
和/proc/hdinfo
的内容,可得到:
1
2
3
4
5
6
7
8
9
10
11
12 $ cat /proc/psinfo
pid state father counter start_time
0 1 -1 0 0
1 1 0 28 1
4 1 1 1 73
3 1 1 27 63
6 0 4 12 817
$ cat /proc/hdinfo
total_blocks: 62000;
free_blocks: 39037;
used_blocks: 22963;
...
procfs
及其结点要在内核启动时自动创建。相关功能实现在
fs/proc.c
文件内。
必备知识
要点1 procfs简介
正式的 Linux 内核实现了
procfs
,它是一个**虚拟文件系统**,通常被 mount(挂载) 到/proc
目录上,通过虚拟文件和虚拟目录的方式提供访问系统参数的机会,所以有人称它为 “了解系统信息的一个窗口”。这些虚拟的文件和目录**并没有真实地存在在磁盘**上,而是内核中各种数据的一种直观表示。虽然是虚拟的,但它们都可以通过标准的系统调用(
open()
、read()
等)访问。其实,Linux 的很多系统命令就是通过读取
/proc
实现的。例如uname -a
的部分信息就来自/proc/version
,而uptime
的部分信息来自/proc/uptime
和/proc/loadavg
。
要点2 基本思路
Linux 是通过文件系统接口实现
procfs
,并在启动时自动将其 mount 到/proc
目录上。此目录下的所有内容都是随着系统的运行自动建立、删除和更新的,而且它们完全存在于内存中,不占用任何外存空间。
Linux 0.11 还没有实现虚拟文件系统,也就是,还没有提供增加新文件系统支持的接口。所以本实验只能在现有文件系统的基础上,通过打补丁的方式模拟一个
procfs
。Linux 0.11 使用的是 Minix 的文件系统,这是一个典型的基于
inode
的文件系统,《注释》一书对它有详细描述。它的每个文件都要对应至少一个 inode,而 inode 中记录着文件的各种属性,包括文件类型。文件类型有普通文件、目录、字符设备文件和块设备文件等。在内核中,每种类型的文件都有不同的处理函数与之对应。我们可以增加一种新的文件类型——proc 文件,并在相应的处理函数内实现 procfs 要实现的功能。
步骤
要点1 新增proc文件类型
include/sys/stat.h 新增:
1 |
要点2 修改mknod()函数和init()函数
psinfo 结点要通过
mknod()
系统调用建立,所以要让它支持新的文件类型。
直接修改 fs/namei.c
文件中的 sys_mknod()
函数中的一行代码,如下:
1 | if (S_ISBLK(mode) || S_ISCHR(mode) || S_ISPROC(mode)) |
内核初始化的全部工作是在
main()
中完成,而main()
在最后从内核态切换到用户态,并调用init()
。
init()
做的第一件事情就是挂载根文件系统:
1
2
3
4
5 void init(void) {
// ……
setup((void *) &drive_info);
// ……
}
procfs
的初始化工作**应该在根文件系统挂载之后开始**。它包括两个步骤:
(1)建立
/proc
目录;建立/proc
目录下的各个结点。本实验只建立/proc/psinfo
。(2)建立目录和结点分别需要调用
mkdir()
和mknod()
系统调用。因为初始化时已经在用户态,所以不能直接调用sys_mkdir()
和sys_mknod()
。必须在初始化代码所在文件中实现这两个系统调用的用户态接口。
1
2
3
4
5
6
_syscall2(int,mkdir,const char*,name,mode_t,mode);
_syscall3(int,mknod,const char*,filename,mode_t,mode,dev_t,dev);
mkdir()
时 mode 参数的值可以是 “0755”(对应rwxr-xr-x
),表示只允许 root 用户改写此目录,其它人只能进入和读取此目录。procfs 是一个只读文件系统,所以用
mknod()
建立 psinfo 结点时,必须通过 mode 参数将其设为只读。建议使用S_IFPROC|0444
做为 mode 值,表示这是一个 proc 文件,权限为 0444(r–r–r–),对所有用户只读。
mknod()
的第三个参数 dev 用来说明结点所代表的设备编号。对于 procfs 来说,此编号可以完全自定义。proc 文件的处理函数将通过这个编号决定对应文件包含的信息是什么。例如,可以把 0 对应 psinfo,1 对应 meminfo,2 对应 cpuinfo。
也就是说,打开linux-0.11/init/main.c
加入:
1 |
|
在init函数中,添加:
1 | mkdir("/proc",0755); |
编译运行即可看到:
这些信息至少说明,psinfo 被正确
open()
了。所以我们不需要对sys_open()
动任何手脚,唯一要打补丁的,是sys_read()
。
要点3 修改read(),让proc可读
首先分析
sys_read
(在文件fs/read_write.c
中)要在这里一群 if 的排比中,加上
S_IFPROC()
的分支,进入对 proc 文件的处理函数。需要传给处理函数的参数包括:
inode->i_zone[0]
,这就是mknod()
时指定的dev
——设备编号buf
,指向用户空间,就是read()
的第二个参数,用来接收数据count
,就是read()
的第三个参数,说明buf
指向的缓冲区大小&file->f_pos
,f_pos
是上一次读文件结束时“文件位置指针”的指向。这里必须传指针,因为处理函数需要根据传给buf
的数据量修改f_pos
的值。
依照指导书,在read_write.c添加如下语句:
1 | extern int proc_handler(unsigned short dev,char* buf,int count,off_t* f_pos); |
要点4 编写pro文件的处理函数
proc 文件的处理函数的功能是根据设备编号,把不同的内容写入到用户空间的 buf。写入的数据要从
f_pos
指向的位置开始,每次最多写 count 个字节,并根据实际写入的字节数调整f_pos
的值,最后返回实际写入的字节数。当设备编号表明要读的是 psinfo 的内容时,就要按照 psinfo 的形式组织数据。实现此函数可能要用到如下几个函数:
- malloc() 函数
- free() 函数
包含
linux/kernel.h
头文件后,就可以使用malloc()
和free()
函数。它们是可以被核心态代码调用的,唯一的限制是一次申请的内存大小不能超过一个页面。
进程的信息就来源于内核全局结构数组
struct task_struct * task[NR_TASKS]
中,具体读取细节可参照sched.c
中的函数schedule()
。可以借鉴一下代码:
1
2
3
4 for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1)+...;
cat 是 Linux 下的一个常用命令,功能是将文件的内容打印到标准输出。
它核心实现大体如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char* argv[])
{
char buf[513] = {'\0'};
int nread;
int fd = open(argv[1], O_RDONLY, 0);
while(nread = read(fd, buf, 512))
{
buf[nread] = '\0';
puts(buf);
}
return 0;
}
在cat的代码中,open函数返回了psinfo的文件描述符,read函数读到该文件描述符,就会识别出我们要读写的文件是PROC类型的,因此就会跳转到我们的proc_handler去执行,再进一步跳转到psinfo_handler执行。根据cat的代码和指导书的提示,不难得出,我们的目标就是把进程的信息按照格式给弄进buf里面,就可以了。
而这也正体现了proc作为“**虚拟文件**”的特点。对它进行读写,它的信息并非存放在磁盘中,而是全部由放在内存中的逻辑和数据【由task_struct提供】来完成。
在fs文件夹下创建文件proc_dev.c,编写proc文件的处理函数。代码如下:
1 |
|
运行结果:
这部分踩过的坑:
1.LAST_TASK 的定义
对于LAST_TASK,我本来的理解是,当前所有进程的最后一个。
本来我设的是跟schedule一样,另p=LAST_TASK,从末尾开始打印。我那时其余代码跟上面一样,就只是把上面的FIRST改成LAST,结果输出为空,调试发现LAST_TASK==NULL。
然后打开sched.h,看到LAST_TASK的定义:
1 |
原来它就是单纯简单粗暴地指“最后一个”进程23333
我们目前当前的进程数量远远小于进程的最大数量,因此最大数量编号的那个进程自然也就是空的了。
2.char s[100]={0};
用这个的时候编译报错:undefined reference to ’memset‘
说明这个简略写法其实本质是用的memset,而要用memset的话需要包含头文件string.h。经测试得包含了string.h后确实就好使了。
1 | //s_imap_blocks、ns_zmap_blocks、 |
3.我发现一件事
我第一次把init/main.c写错了,写成:
1 | mkdir("/proc",0755); |
设别号忘了改了。然后进行了一次编译,运行。
之后我发现错了,就改成了
1 | mkdir("/proc",0755); |
再次编译运行,结果上面的那个错还是没改回来
直到我手动把proc文件夹删了,再重新读一次磁盘加载proc文件夹,才回归正常。
感想
本次实验耗时:下午一点到晚上九点半()
本实验通过对proc虚拟文件的编写流程,实际上让我们体会到了“一切皆文件”的思想。
什么东西都可以是文件,只不过它们有不同的文件类型和不同的read/write处理函数。
对于终端设备和磁盘,其read/write函数本质上是在用out指令跟它的缓冲区交互,只不过磁盘比终端设备抽象层次更深,包含了文件系统的层层封装。
对于虚拟文件,其read/write函数本质上就是与内存交互,通过一段逻辑【处理函数】将内存存储的当前操作系统信息实时显示出来,而不需要存储。
还有,参考文章那篇的代码写的很好,快去看!