精华内容
下载资源
问答
  • 哈工大操作系统实验
    2021-10-17 20:01:53

    前言

    这便是最后一个实验了,到目前为止,这最后一个实验也就这样结束,但纵观这8个实验,其实真正自己独立完成的少之又少,看着别的博主能够有自己的思想,能够写出自己的代码,然后自己去调试,我相信那种过程无疑更加有成就感的,而像我却大多数都是自己瞎琢磨一段时间后不得不去看别人的操作系统实验,其实就本身而言我觉得我这8个实验还是做的比较失败的,当然自己思考了一番,我自己存在比较大的问题,我其实是有自己的灵光的,但是自己缺乏行动性,以后自己得改掉这个习惯,一定不要胆怯,自己想到啥就自己去实践,而不是自己一遇到困惑就去查看别人的博客。但即使如此,我也只有努力去学,坚持去学,因为操作系统是真的有趣啊。
    这个实验我就直接转载别人了,因为我自己的思想性基本没有。

    实验

    博客链接:哈工大操作系统之外设与文件系统(Lab 8 proc文件系统的实现 详细流程分析+代码实现)
    博客链接:MOOC哈工大操作系统实验8:proc文件系统的实现

    更多相关内容
  • 本人清理旧系统时整理出来的,包含了哈尔滨工业大学操作系统课程8次实验的源代码及实验报告的整合版(除了实验七没有找到,但可以在本人的资源http://download.csdn.net/detail/ertwer12/1935717中下到实验七的单独...
  • 哈工大操作系统实验

    2015-06-26 12:41:47
    哈工大 操作系统 实验 完整 打包 1.系统初始化 2.系统接口 3.进程管理 4.进程同步 5.I/O设备管理 6.内存管理
  • 哈工大操作系统 Linux 0.11 实验报告。 结构说明 基本上按照实验楼顺序完成,具体每个地方都有文档,文档中有实验相对的名字,文档提供三种格式,一是写作用的markdown源格式,第二个是导出的PDF,第三个是导出的有...
  • 哈工大 操作系统 实验 李治军 八个实验 还有从网上其他地方下载的
  • 哈工大操作系统实验六,今年老师要求制作PPT,于是良心制作,详细记录了系统调用的相关操作及实验结论,希望对有心人有用,大家共同进步。PS:这部分是队友做的
  • 哈工大操作系统实验代码(全)
  • 实验地址,https://www.lanqiao.cn/courses/115/learning/?id=374, 在现做实验, 好处是环境提前都配好了, 不足之处是敲代码有网络延迟, 环境无法保存....这个实验主要是介绍实验环境的基本情况, 宿主机使用ubunt..

    实验地址, https://www.lanqiao.cn/courses/115/learning/?id=374, 在现做实验, 好处是环境提前都配好了, 不足之处是敲代码有网络延迟, 环境无法保存.总的来说比较方便.

    教科书, <<Linux内核完全注释 修正版 V5.0 >> (赵炯), 后面简称<<注释>>, <<汇编语言>>(王爽)

     

    实验1 熟悉实验环境

    这个实验主要是介绍实验环境的基本情况, 宿主机使用ubuntu, 通过boch模拟器来模拟Linux0.11的运行.

    主要用到了下面的命令:

    cd /home/shiyanlou/oslab/  #cd 是change directory 表示更换文件夹, 后面跟的是更改的文件地址, 如果不是以斜杠 '/'开头的话默认是当前文件夹, 和'./'表示相同的意思, '/'开头表示根目录
    
    tar -zxvf hit-oslab.tar.gz -C /home/shiyanlou  #tar是打包命令, 在"鸟哥的linux私房菜"里有详细的介绍, 这里的-z表示用zip格式压缩或则解压, -x表示extract就是解压, -v表示显示压缩或者解压的文件细节, -f后面跟处理的tar文件(无论是压缩还是解压), -C后面跟的是指定的解压地址, C为大写
    
    make all  #这里的make是一个编译命令, 当前所在文件夹必须有Makefile文件, 关于Makefile文件在<<linux内核完全注释(第五版)>>里3.6节有详细的介绍, 这个知识个人认为非常有必要掌握
    
    sudo ./mount-hdc  # 挂载hdc硬盘命令, 必须使用sudo (super user do)来完成, 否则权限不够; 并且当前目录下的hdc文件夹的主要文件映射是在 /hdc/usr/root/ -> /root; ./run进入boch模拟之后的默认其实目录是/home/usr/root, 所以为了交换文件方便, 尽量放在/hdc/usr/root里
    
    sudo umount hdc 跟上一个命令对应, 在./run运行boch前要umount
    
     

    实验2 操作系统的引导

    这个实验主要是介绍操作系统从电源上电到启动的整个过程, 里面涉及到了 .s 文件(也就是汇编文件)的修改, 所以要对汇编有一定的了解, 了解这个可以使用王爽的<<汇编语言>>(主要介绍实模式下的一些汇编操作), 和<<注释>>里的第三章, 讲了AT&T语法和8086实模式汇编语法的区别, 以及C语言内嵌汇编的格式, 不弄懂这些的话后面很多汇编语言都会看不懂

    下面是一些实验过程自己的一些问题和想法

    #6.2完成bootsect.s的屏幕输出功能
    #.org 510    这一句的意思是下面的代码从第510个字节开始算, 后面跟了一个.word 0xAA55, 正好印证了引导扇区是512个字节
    #.word 0xAA55    这一句是约定的引导扇区的地址, 没有别的意思
    
    dd bs=1 if=bootsect of=Image skip=32     #这里的dd是linux的备份命令, 查了一下是disk destroyer的缩写, 在<<鸟哥>>里第九章有介绍, bs表示block size 一个块的大小, if 是 input file, of是output file, skip 32 表示跳过前32个字节, 这句命令在linux0.11目录下的Makefile里也有, 所以手工编译链接的时候需要你进行这一步处理
    
    jnc     # 汇编命令, jump no carry, 没有进位时跳转
    jmpi    # 段间跳转命令, 所以后面必须有两个操作数, 一个表示段, 一个表示段内偏移
    lds si, memory    # load ds, 从memory中获取32位的信息到 ds:si中
    
    
     

    这个实验重点需要弄明白操作系统的引导整个过程, 在<<注释>>一书中第六章有详细介绍, 个人感觉核心就是这张图

    实验3 系统调用

    这个实验介绍了如何从用户态进入到内核态并且调用内核代码的过程, 以及如何修改和添加内核代码

    操作系统实现系统调用的五段论, 结合一个例子表示如下:

    1.应用程序调用库函数(API);
    2.API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
    3.内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
    4.系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
    5.中断处理函数返回到 API 中;
    API 将 EAX 返回给应用程序
    
    # 以系统调用 close.c 为例
    第一步, 应用程序调用 int close(int fd)     # 这里是用户态, 用户调用close()函数是系统调用的API
    
    第二步, API将系统调用号NR_close存到eax里作为系统中断int 0x80的第一个参数, 根据参数调用_NR_table里对应的系统调用        # 这里是通过_syscall1(int, close, int , fd)这个宏定义将close变成__NR_close, __NR_close在文件sys.h中定义了其序号以及相应的处理函数, 这个时候刚进入内核状态, 后面进行内核栈的切换操作, 切换操作就是让 ds, es 指向核心地址空间 0x10, 用户地址空间是0x17
    
    第三步, 查sys_call_table, 调用对应的内核函数sys_close        #  这时候已经进入内核态, 并进行内核栈的切换
    第四步, sys_close在内核态处理, 返回的结果在eax中
    第五步, 内核态切换为用户态, 此时返回值是用户态的返回值

    这个实验在网上看到很多都有点小bug, 在iam.c里赋值之前要把buffer的值全部设置为0, 否则会留下上一次设置的痕迹

     

    实验4 进程运行轨迹的跟踪与统计

    这个实验开始进入到操作系统的核心部分, 多进程编程,

    实验涉及的文件和个人理解如下:

    process.c     # 这是个样本程序, 实现了一个cpuio_bound函数, 用来模拟一个进程是cpu耗时为主还是IO耗时为主, 从而来观察得出一个进程的不同状态的持续时间
    
    /var/process.log      # 通过在main.c的init()之前, 将文件描述符为3的输出关联到process.log, 因此后面用到fprintk就可以将输出指定在process.log里
    
    (void) open("/var/process.log", O_CREAT | O_TRUNC | O_WRONLY, 0666)      # 这里的 O_CREAT是打开文件的flag设置, 定义在/usr/include/asm-generic/fcntl.h  O_CREAT 表示文件不存在就创建, O_TRUNC 表示文件存在就截为0(即清空), O_WRONLY 表示只写write only
    
    /kernel/printk.c         # 这里面的门道有点多, 一个一个来
    
    va_list args         # 这个是用来专门对应 const char* fmt 里的输出格式 , 如 "%d %s", ... 
    struct file* file    # 这个struct file* 是在后面的文件系统里会学习到, 是一个文件类型的指针
    struct m_inode* inode    # 这个inode是磁盘里的一个struct m_inode数据结构指针, 是一个文件节点, 里面存有当前文件对应的数据块block索引以及下一级目录的索引
    
    count = vsprintf(logbuf, fmt, args)        # 这里是将fmt, args的结果输入到定义的内核缓存logbuf中, vsprintf返回输入的字节数, 这里的count表示输入了多少个字节到logbuf里
    
    addl $8, %%esp\n\t        # 这里是栈的回滚, 前面pushl %logbuf 和 pushl %1正好占据了8个字节, 将esp加8就相当于抹去这两个数据, 后面的addl $12 也是相同的道理
    
    /6.4jiffies 滴答
    set_intr_gate(0x20, &timer_interrupt)        # 这里就是吧timer_interrupt这个时钟中断设置到IDT的0x20位置
    
    /6.5寻找状态切换点
    struct task_struct *p        # 这个struct task_struct 就是老师课件里提到的PCB(进程控制块 process control block), 里面有一个进程的各种信息, 栈, 寄存器, 上下文等, 定义在/include/linux/sched.h中
    
    *p = current        # current是一个全局变量, 表示当前的进程控制块PCB  
    

    实验中容易出错的地方有

    1. 进程0会不断调用sys_pause, 如果不对这个做处理的话python文件会报错, 重复行, 也就是某一个进程的状态跟上一次一样

    2. switch_to(next) 也要对next任务和当前任务current的pid进行比较, 如果是同一个任务就不需要做更改

     

    实验5 基于内核栈切换的进程切换

    这个实验难度比较大, 主要在于代码量较大, 而且得对内核栈有一定的了解, 稍有不慎就会出错, 

    这个实验做完要记住GDT表的整体结构

    一些代码的注释如下:

    /6.3 schedule 与 switch_to
    if ((*p)->state == TASK_RUNNING && (*p)->counter > c)    #    (*p)->counter > c表示p的时间片比当前最大的时间片大, 所以当前p的优先级最高, 下一个调度的任务就是*p, 
        c = (*p)->counter, next = i, pnext = *p;        # next 是下一个任务的编号, 所有的任务是定义的一个NR_TASKS
    
    //.......
    
    switch_to(pnext, LDT(next));
    /6.4 实现switch_to
    pushl %ebp
    movl %esp, %ebp        # 这两句是栈帧操作, %ebp保存当前栈的栈帧位置, 因为栈是往下扩展的, 一个栈的栈帧是地址的最高值
    
    movl 8(%ebp), %ebx        # %ebp是当前栈帧的地址, %ebp+4是保存的上一个栈帧的地址, %ebp+8就是switch_to的第一个参数, 回想一下上一节switch_to(pnext, LDT(next)), 因此这里是把pnext的值赋给了%ebx, 也就是要切换的下一个任务的指针
    
    je 1f        # 这里的1是一个标号, 对应的下面的1:    , f表示forward,向前跳转到1的位置
    
    movl tss, %ecx
    addl $4096, %ebx    # %ebx表示下一个任务的指针, 增加4096相当于开辟了4096个字节的空间, 也就是一页的大小, 可以作为内核PCB的空间
    movl %ebx, ESP0(%ecx)    # 这里就把%ebx也就是任务的指针存到了任务0的内核栈指针那里, 所有的内核任务都共用这一个地址
    
    movl %esp,KERNEL_STACK(%eax)    #    这个%eax其实也是pnext指针, KERNEL_STACK(%eax)就是下一个任务的内核栈地址

    还需要了解的是任务状态段TSS(task state segment)的结构

    以及PCB(task_struct)的结构, 在源代码里可以看到, 或者看另一个博主的博文, 讲的也挺详细

    https://blog.csdn.net/qq_29503203/article/details/54618275

     

    实验6 信号量的实现和应用

    这个实验大概是对考研和找工作中帮助比较大的一个实验, 主要是通过信号量来讲述进程间通信和锁的原理

    首先需要我们设计一个sem_t的数据结构, 根据视频里讲的, 一个信号量结构要有两个基本成员, 一个是信号量的名称(char* name), 一个是信号量里的值(int value), 进程间的通信就通过这个信号量来进行阻塞和唤醒;

    还有一个是任务的队列, 这里可以在信号量sem_t里加入一个 sem_t_queue的指针, 也可以直接利用实验指导里的隐式队列;

    其次要理解生产者消费者这个程序的PV顺序,  在互斥信号量MUTEX里(临界区), 要尽量使用少的别的信号量资源, 使用的越多越容易造成死锁,  实验的思考题里就是这个问题的一种情况 

    /6.1 信号量
    Producer()
    {
        // 生产一个产品 item;
    
        // 空闲缓存资源
        P(Empty);
    
        // 互斥信号量
        P(Mutex);
    
        // 将item放到空闲缓存中;
        V(Mutex);
    
        // 产品资源
        V(Full);
    }
    
    Consumer()
    {
        P(Full);
        P(Mutex);
    
        //从缓存区取出一个赋值给item;
        V(Mutex);
    
        // 消费产品item;
        V(Empty);
    }
    /6.2 多进程共享文件
    
    使用标准 C 的文件操作函数要注意,它们使用的是进程空间内的文件缓冲区,父进程和子进程之间不共享这个缓冲区        # 这句话其实隐含的说明了进程的内存空间是独立的, 父进程和子进程之间不会共享, 所以需要写完之后马上fflush一下, 手动把要写入的东西立即写入磁盘
    
    建议直接使用系统调用进行文件操作    # 这句话说明系统调用可以使用统一的内核空间, 这样相当于直接使用了共享内存
    
    
    /6.4 原子操作, 睡眠和唤醒
    
    这里用到的原子操作是开关中断(sti(), cli()), 缺点是只能在单核上有效, 因为开关中断是CPU单核上的一个引脚的置1或者0
    课堂上还讲了利用硬件的原子操作test&set(sem), 也就是P,V操作

    string.h里要用到strcmp来进行信号量名字(sem_t ->name)的比较

     

    实验7 地址映射与共享

    这个实验的第一部分是直观地展示了一个逻辑地址如何通过层层映射到物理内存上的

    逻辑地址上的ds是一个段选择子(segment selector), 是在LDT上面的偏移, 要找LDT就得找GDT, 所以过程是  查GDTR(寄存器)-->找到GDT的地址->查LDTR(寄存器)对应的位数-->找到LDT在GDT地址的偏移-->找到GDT偏移处的64位数据, 取出包含LDT物理地址的32位信息也就是真正的段基址-->根据段基址+段偏移3004就能得到逻辑地址ds:3004的物理地址;

    段选择子ds的值其实也是有讲究的, 都说ds=0x17表示用户态, 主要就是0x17的二进制是00010111, 后两位表示特权级, 这里是11表示3, 正好是用户态特权级3, 第2位表示TI, TI为1表示去LDT查, 那不就正好是用户态去LDT查数据, 而内核态去GDT查数据了嘛; 因此0x10表示内核态就说得通了, 0x10的二进制是00010000,后两位表示特权级为0, 内核态的特权级, TI位为0, 表示在GDT上查数据, 印证了内核态的数据在GDT上查

    通过线性地址查物理地址那就简单了, 利用页表知识, 线性地址的32位分为页目录+页表项+页内偏移, 页目录项和页表项的大小都是一个32位的数, 所以要根据页目录基址+页目录号*4来找到对应的页表, 页表基址+页表号*4得到页号, 最后+页内偏移就得到了物理地址

    第二个实验的核心部分其实是找到空闲的物理页面, 把物理页面存放在内核的一个共享空间table里, 让两个进程根据shmid来获取同一块共享内核内存, 从而达到共享内存的目的, 其余部分跟实验6的是相似的, 这里面学习到了&这个字符的使用方法, 以及进程空间64M的内存是如何分布的

     

    实验8 终端设备的控制

    这个实验主要是学习如何处理键盘输入和显示输出的过程, 为了让按下F12就能实现所有字符为'*', 就得找到这个输出'*'的出口在哪里, 根据视频里老师的介绍, 要在console.c这个文件里修改

    还要找到F12对应的输入码是多少, 在输入缓冲区中判断一下出现了F12的输入码(ESC, [, [, L好像是),  一旦出现, 就令console_write函数里写到console的字符为"*"即可, 这种方法确保了只有按下F12才能改变成'*'

    参考了这位博主的做法https://blog.csdn.net/weixin_45666853/article/details/105278345

     

    实验9 PROC文件系统的实现

    这个实验主要是学习文件系统中block, inode的概念, 通过挂载相应的inode实现PROC这个虚拟文件, 

    主要要知道mkdir() 和mknod()这两个函数的用法来创造节点, 然后要知道task_struct的结构从而获取每个pid对应的state, start_time, count等信息

    hdinfo的获取方法目前还是有点不大明白, 整体上看是通过遍历统计所有使用过的block和inode, 然后用总的block和inode去减

    其余步骤没有特别难理解的地方, 按照实验指导一步一步来就能得到结果

     

    总结

    9个实验花了差不多1个月的时间, 主要前半个月花在了看王爽的<<汇编语言>>上了, 对实模式的汇编有了一定的了解, 但其实这个系列的实验对汇编的要求在<<注释>>一书中都能找到比较全面的解析, 所以其实可以更快完成, 完成过程中也参考了不少别人的做法, 刚接触这个玩意儿的时候还是一头雾水, 不知道从哪里开始下码, 做到后面几个实验的时候就逐渐有了点感觉, 可以独立写一大段代码, 唯一头疼的地方还是debug, 用GDB来找BUG实在是很困难.

     

    展开全文
  • 11级本人自写的作业, 于实验八截至后第一天上传; 没采用轻量级风格的线程, 而是仿造windows solaris的风格. 应该好使, 欢迎讨论, 联系方式在下载包内.
  • 哈工大操作系统实验1-操作系统引导

    千次阅读 2021-07-17 20:36:36
    哈工大操作系统实验1-操作系统引导 实验内容: 1. 改写 bootsect.s 主要完成如下功能: bootsect.s 能在屏幕上打印一段提示信息“XXX is booting...”, 其中 XXX 是你给自己的操作系统起的名字,例如 LZJos、Sunix ...

    哈工大操作系统实验1-操作系统引导

    实验内容:

    1. 改写 bootsect.s 主要完成如下功能:
    bootsect.s 能在屏幕上打印一段提示信息“XXX is booting...”,
    其中 XXX 是你给自己的操作系统起的名字,例如 LZJos、Sunix 等
    2. 改写 setup.s 主要完成如下功能:
    2.1 bootsect.s 能完成 setup.s 的载入,并跳转到 setup.s 开始地址执行。
    而 setup.s 向屏幕输出一行"Now we are in SETUP"2.2 setup.s 能获取至少一个基本的硬件参数,如内存参数、显卡参数、
    硬盘参数等.将其存放在内存的特定地址,并输出到屏幕上。
    2.3 setup.s 不再加载 Linux 内核,保持上述信息显示在屏幕上即可。
    

    本实验主要是为了说明开机上电之后,计算机的如何启动的过程:(全部是汇编代码)

    1. 从磁盘第一扇区读取bootsect.s,通过bios中断,读取setup.s和system模块。
    2. setup.s从bios读取基本系统参数(光标位置、内存大小等)进入保护模式。
    3. 设置IDT表和GDT表,调用main函数,进而进入C语言相关系统代码。
      实验

    注意:BIOS 0X10中断:显示字符串;BIOS 0X13中断:读取磁盘内容。
    实验之前请参考我的博客,实验0,进行linux内核文件的解压缩。
    实验(1):bootsect.s修改,即显示自己定义的字符串:
    1.a首先一种简单的修改方法:
    直接修改bootsect.s中98行和246行的内容:
    246行修改为自己想改的字符串之后,修改98行cx寄存器的数值即可
    1.b按照实验说明重写bootsect.s和setup.s 文件:

    cd ~/oslab/linux-0.11/boot/
    rm -f bootsect.s     #进入文件夹删除bootsect.s,重写bootsect.s
    vim bootsect.s
    

    新的bootsect.s内容:

    entry _start
    _start:
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#20        //显示的字符长度
        mov bx,#0x0007
        mov bp,#msg1
        mov ax,#0x07c0
        mov es,ax
        mov ax,#0x1301
        int 0x10
    z_loop:
        jmp z_loop
    msg1:
        .byte   13,10
        .ascii  "zzy say hello!"    //显示的字符
        .byte   13,10,13,10
    .org 510
    boot_flag:
        .word   0xAA55
    

    在这里插入图片描述
    修改之后进行编译:

    $ as86 -0 -a -o bootsect.o bootsect.s
    $ ld86 -0 -s -o bootsect bootsect.o
    $ dd bs=1 if=bootsect of=Image skip=32 //删除刚开始的32个byte,使其正好为520 byte,即一个扇区大小
    

    运行:

    # 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/boot/
    
    # 将刚刚生成的 Image 复制到 linux-0.11 目录下
    $ cp ./Image ../Image
    
    # 执行 oslab 目录中的 run 脚本
    $ ../../run
    

    运行示意图:
    在这里插入图片描述
    实验(2)修改setup.s
    修改setup.s内容为:

    entry _start
    _start:
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#29        //显示的字符长度
        mov bx,#0x0007
        mov bp,#msg2
        mov ax,cs
        mov es,ax
        mov ax,#0x1301
        int 0x10
    z_loop:
        jmp z_loop
    msg1:
        .byte   13,10
        .ascii  "now we are in zzy SETUP"    //显示的字符
        .byte   13,10,13,10
    .org 510
    boot_flag:
        .word   0xAA55
    

    bootsect.s改为:

    SETUPLEN=2
    SETUPSEG=0x07e0
    entry _start
    _start:
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#20
        mov bx,#0x0007
        mov bp,#msg1
        mov ax,#0x07c0
        mov es,ax
        mov ax,#0x1301
        int 0x10
    load_setup:
        mov dx,#0x0000
        mov cx,#0x0002
        mov bx,#0x0200
        mov ax,#0x0200+SETUPLEN
        int 0x13
        jnc ok_load_setup
        mov dx,#0x0000
        mov ax,#0x0000
        int 0x13
        jmp load_setup
    ok_load_setup:
        jmpi    0,SETUPSEG
    msg1:
        .byte   13,10
        .ascii  "zzy say hello!"
        .byte   13,10,13,10
    .org 510
    boot_flag:
        .word   0xAA55
    

    修改tools/build.c: 根据实验指导注释掉最后的内容。编译运行:
    在这里插入图片描述

    展开全文
  • 哈尔滨工业大学操作系统实验,实验一,操作系统的引导,仅供参考,不要抄袭哦~
  • 这个是07级哈尔滨工业大学操作系统实验的辛勤劳动,下面的压缩包中包含源代码,及实验报告,最好自己搭建平台,本人在电脑中又装了 linux,希望给感兴趣的人看看,呵呵,如果是工大的学弟学妹的话,千万不要顺手牵羊...
  • HIT-OS-Lab 哈工大操作系统实验 含报告,PPT,以及做实验过程中的一些小记录README.md 说明:*不保证所有的代码都正确,如果有问题,欢迎发送拉请求。融合大量学长学姐的结晶完成!
  • 哈工大操作系统实验8——内核级线程。本次的实验仅完成了用户态的实现。内核级要实现实在困难,耗费巨大精力也不见得能有好的成效,而且重要的是内核级仅占一个。
  • 本仓库涵盖了哈尔滨工业大学操作系统》课程实验指导手册,实验环境(64位支持)及子系统。针对针对32位和64位的Ubuntu系统,一键式建造好环境,为同学们节省环境配置时间。 在线学习与实验 由于历史原因,推荐大家...
  • 实验是纸老虎,要敢于挑战……而且,提醒各位,借鉴可以,一定要完全理解了哦~~
  • 操作系统实验一到实验九合集(哈工大李治军)

    千次阅读 多人点赞 2022-01-01 16:06:50
    操作系统实验一到实验九合集(哈工大李治军) 有详细代码及注释,程序已在本地测试,均能跑通

    操作系统实验

    作者寄语

    操作系统实验的学习是一个循序渐进的过程,初次看linux-0.11中的代码,看着满屏的汇编语言,确实头疼。但通过学习赵炯博士的Linux内核0.11完全注释,结合着王爽老师的汇编语言一书,我逐渐理解每段汇编语言的含义和作用。本文主要是通过对哈工大李治军配套实验的实现,着重解释每一段的汇编代码,使读者对实验的整体脉络有一个初步的认识,不再因为畏惧汇编而不放弃实验。本文只是抛砖引玉,希望读者可以深入研究我下文提供的参考资料,做到理论与实践兼具。

    参考资料

    目录

    文章目录

    实验一 熟悉实验环境

    只是熟悉实验环境,我没有使用蓝桥云课的实验环境,而是通过阿里云服务器搭建了linux环境,有需要的可以看以下2篇文章

    实验二 操作系统的引导

    Linux 0.11 文件夹中的 boot/bootsect.sboot/setup.stools/build.c 是本实验会涉及到的源文件。它们的功能详见《Linux内核0.11完全注释》的 6.2、6.3 节和 16 章。

    汇编知识

    简要整理了一下这次实验所需的基础汇编知识,可以在下文阅读代码是碰到再回过头来看!

    int 0x10

    image-20210808151038949

    注意,这里ah要先有值,代表内部子程序的编号

    功能号 a h = 0 x 03 ah=0x03 ah=0x03​​​,作用是读取光标的位置

    • 输入:bh = 页号
    • 返回:ch = 扫描开始线;cl = 扫描结束线;dh = 行号;dl = 列号

    功能号 a h = 0 x 13 ah=0x13 ah=0x13,作用是显示字符串

    • 输入:al = 放置光标的方式及规定属性,下文 al=1,表示目标字符串仅仅包含字符,属性在BL中包含,光标停在字符串结尾处;es:bp = 字符串起始位置;cx = 显示的字符串字符数;bh = 页号;bl = 字符属性,下文 bl = 07H,表示正常的黑底白字;dh = 行号;dl = 列号

    功能号 a h = 0 x 0 e ah=0x0e ah=0x0e​,作用是显示字符

    • 输入:al = 字符

    int 0x13

    在DOS等实模式操作系统下,调用INT 13h会跳转到计算机的ROM-BIOS代码中进行低级磁盘服务,对程序进行基于物理扇区的磁盘读写操作。

    功能号 a h = 0 x 02 ah=0x02 ah=0x02,作用是读磁盘扇区到内存

    • 输入:

      寄存器含义
      ah读磁盘扇区到内存
      al需要读出的扇区数量
      ch磁道
      cl扇区
      dh磁头
      dl驱动器
      es:bx数据缓冲区的地址
    • 返回:ah = 出错码(00H表示无错,01H表示非法命令,02H表示地址目标未发现…);CF为进位标志位,如果没有出错 C F = 0 CF=0 CF=0

    功能号 a h = 0 x 00 ah=0x00 ah=0x00​,作用是磁盘系统复位

    • 输入:dl = 驱动器
    • 返回:如果操作成功———— C F = 0 CF=0 CF=0​​, a h = 00 H ah=00H ah=00H​​

    这里我只挑了下文需要的介绍,更多内容可以参考这篇博客BIOS系统服务 —— 直接磁盘服务(int 0x13)

    int 0x15

    功能号 a h = 0 x 88 ah=0x88 ah=0x88,作用是获取系统所含扩展内存大小

    • 输入:ah = 0x88
    • 返回:ax = 从0x100000(1M)处开始的拓展内存大小(KB)。若出错则CF置位,ax = 出错码。

    int 0x41

    在PC机中BIOS设定的中断向量表中int 0x41的中断向量位置 ( 4 ∗ 0 x 41 = 0 x 0000 : 0 x 0104 4*0x41 = 0x0000:0x0104 40x41=0x0000:0x0104​​​)存放的并不是中断程序的地址,而是第一个硬盘的基本参数表。对于100%兼容的BIOS来说,这里存放着硬盘参数表阵列的首地址0xF000:0E401,第二个硬盘的基本参数表入口地址存于int 0x46中断向量位置处.每个硬盘参数表有16个字节大小.

    位移大小说明
    0x00柱面数
    0x02字节磁头数
    0x0E字节每磁道扇区数
    0x0F字节保留

    CF

    要了解CF,首先要知道寄存器中有一种特殊的寄存器————标志寄存器,其中存储的信息通常被称为程序状态字。以下简称为flag寄存器。

    flag和其他寄存器不一样,其他寄存器是用来存放数据的,都是整个寄存器具有一个含义。而flag寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。

    image-20210810220308173

    flag的1、3、5、12、13、14、15位在8086CPU中没有使用,不具有任何含义。而0、2、4、6、7、8、9、10、11位都具有特殊的含义。

    CF就是flag的第0位————进位标志位。在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。

    jnc

    C F = 0 CF=0 CF=0​​​ 的时候,进行跳转,即不进位则跳转,下文就是在读入没有出错时,跳转到ok_load_setup

    jl

    小于则跳转

    lds

    格式: LDS reg16,mem32

    其意义是同时给一个段寄存器和一个16位通用寄存器同时赋值

    举例:

    地址100H101H102H103H
    内容00H41H02H03H
    LDS AX,[100H]
    ! 结果:AX=4100H  DS=0302H
    

    可以把上述代码理解为这样一个过程,但实际上不能这么写

    mov AX,[100H]
    mov DS,[100H+2]
    

    即把低字(2B)置为偏移地址,高字(2B)置为段地址

    DF标志和串传送指令

    flag的第10位是DF,方向标志位。在串处理指令中,控制每次操作后si、di的增减。

    • df=0:每次操作后si、di递增
    • df=1:每次操作后si、di递减

    来看一个串传送指令

    • 格式:movsb

    • 功能:相当于执行了如下2步操作

      1. ( ( e s ) ∗ 16 + ( d i ) ) = ( ( d s ) ∗ 16 + s i ) ((es)*16+(di))=((ds)*16+si) ((es)16+(di))=((ds)16+si)

      2. 如果df=0:(si)=(si)+1,(di)=(di)+1

        如果df=1:(si)=(si)-1,(di)=(di)-1

    可以看出,movsb的功能是将 d s : s i ds:si ds:si 指向的内存单元中的字节送入 e s : d i es:di es:di中,然后根据标志寄存器df位的值,将si和di递增或递减。

    也可以传送一个字

    • 格式:movsw

    • 功能:相当于执行了如下2步操作

      1. ( ( e s ) ∗ 16 + ( d i ) ) = ( ( d s ) ∗ 16 + s i ) ((es)*16+(di))=((ds)*16+si) ((es)16+(di))=((ds)16+si)

      2. 如果df=0:(si)=(si)+2,(di)=(di)+2

        如果df=1:(si)=(si)-2,(di)=(di)-2

    可以看出,movsw的功能是将 d s : s i ds:si ds:si 指向的内存单元中的字节送入 e s : d i es:di es:di​中,然后根据标志寄存器df位的值, 将si和di递增2或递减2。

    movsb和movsw进行的是串传送操作的一个步骤,一般配合rep使用

    格式如下:rep movsb

    用汇编语法描述:

    s:movsb
     loop s
    

    可见rep的作用是根据cx的值,重复执行串传送指令。由于每执行一次movsb指令si和di都会递增或递减指向后面一个单元或前面一个单元,则 rep movsb就可以循环实现(cx)个字符的传送。

    call

    (1) 将当前IP或CS和IP压入栈中

    (2) 转移

    CPU执行“call 标号”时,相当于进行:

    push IP
    jmp near ptr 标号
    

    ret

    ret指令用栈中的数据,修改IP的内容,从而实现近转移

    (1) ( I P ) = ( ( s s ) ∗ 16 + ( s p ) ) (IP)=((ss)*16+(sp)) (IP)=((ss)16+(sp))

    (2) ( s p ) = ( s p ) + 2 (sp)=(sp)+2 (sp)=(sp)+2

    CPU执行ret指令时,相当于进行:

    pop IP
    

    改写bootsect.s

    打开 bootsect.s

    image-20210711135216130

    image-20210814222810462

    Loading system ...就是开机时显示在屏幕上的字,共16字符,加上3个换行+回车,一共是24字符。我将要修改他为Hello OS world, my name is WCF,30字符,加上3个换行+回车,共36字符。所以图一代码修改为mov cx.#36

    .org 508 修改为 .org 510,是因为这里不需要 root_dev: .word ROOT_DEV,为了保证 boot_flag 一定在引导扇区最后两个字节,所以要修改 .org.org 510 表示下面语句从地址510(0x1FE)开始,用来强制要求boot_flag一定在引导扇区的最后2个字节中(第511和512字节)。

    完整的代码如下:

    entry _start
    _start:
        mov ah,#0x03        ! 设置功能号
        xor bh,bh           ! 将bh置0
        int 0x10            ! 返回行号和列号,供显示串用
        mov cx,#52          !要显示的字符串长度
        mov bx,#0x0007      ! bh=0,bl=07(正常的黑底白字)
        mov bp,#msg1        ! es:bp 要显示的字符串物理地址
        mov ax,#0x07c0      ! 将es段寄存器置为#0x07c0
        mov es,ax           
        mov ax,#0x1301      ! ah=13(设置功能号),al=01(目标字符串仅仅包含字符,属性在BL中包含,光标停在字符串结尾处)
        int 0x10            ! 显示字符串
    
    ! 设置一个无限循环(纯粹为了能一直看到字符串显示)
    inf_loop:
        jmp inf_loop
    
    ! 字符串信息
    msg1:
        .byte   13,10           ! 换行+回车
        .ascii  "Welcome to the world without assembly language"
        .byte   13,10,13,10     ! 换行+回车
    
    ! 将
    .org 510
    
    ! 启动盘具有有效引导扇区的标志。仅供BIOS中的程序加载引导扇区时识别使用。它必须位于引导扇区的最后两个字节中
    boot_flag:
        .word   0xAA55
    

    Ubuntu 上先从终端进入 ~/oslab/linux-0.11/boot/目录

    执行下面两个命令编译和链接 bootsect.s

    $ as86 -0 -a -o bootsect.o bootsect.s
    $ ld86 -0 -s -o bootsect bootsect.o
    

    image-20210711112503976
    其中 bootsect.o 是中间文件。bootsect 是编译、链接后的目标文件。

    需要留意的文件是 bootsect 的文件大小是 544 字节,而引导程序必须要正好占用一个磁盘扇区,即 512 个字节。造成多了 32 个字节的原因是 ld86 产生的是 Minix 可执行文件格式,这样的可执行文件处理文本段、数据段等部分以外,还包括一个 Minix 可执行文件头部,它的结构如下:

    struct exec {
        unsigned char a_magic[2];  //执行文件魔数
        unsigned char a_flags;
        unsigned char a_cpu;       //CPU标识号
        unsigned char a_hdrlen;    //头部长度,32字节或48字节
        unsigned char a_unused;
        unsigned short a_version;
        long a_text; long a_data; long a_bss; //代码段长度、数据段长度、堆长度
        long a_entry;    //执行入口地址
        long a_total;    //分配的内存总量
        long a_syms;     //符号表大小
    };
    

    6 char(6 字节)+ 1 short(2 字节) + 6 long(24 字节)= 32,正好是 32 个字节,去掉这 32 个字节后就可以放入引导扇区了。

    对于上面的 Minix 可执行文件,其 a_magic[0]=0x01,a_magic[1]=0x03,a_flags=0x10(可执行文件),a_cpu=0x04(表示 Intel i8086/8088,如果是 0x17 则表示 Sun 公司的 SPARC),所以 bootsect 文件的头几个字节应该是 01 03 10 04。为了验证一下,Ubuntu 下用命令hexdump -C bootsect可以看到:

    image-20210814222824190

    去掉这 32 个字节的文件头部

    $ dd bs=1 if=bootsect of=Image skip=32
    

    生成的 Image 就是去掉文件头的 bootsect

    去掉这 32 个字节后,将生成的文件拷贝到 linux-0.11 目录下,并一定要命名为“Image”(注意大小写)。然后就“run”吧!

    # 当前的工作路径为 /oslab/linux-0.11/boot/
    # 将刚刚生成的 Image 复制到 linux-0.11 目录下
    $ cp ./Image ../Image
    # 执行 oslab 目录中的 run 脚本
    $ ../../run
    

    image-20210814222834302

    bootsect.s读入setup.s

    首先编写一个 setup.s,该 setup.s 可以就直接拷贝前面的 bootsect.s(还需要简单的调整),然后将其中的显示的信息改为:“Now we are in SETUP”。

    和前面基本一样,就不注释了。

    entry _start
    _start:
    	mov ah,#0x03
    	xor bh,bh
    	int 0x10
    	mov cx,#25
    	mov bx,#0x0007
    	mov bp,#msg2
    	mov ax,cs				! 这里的cs其实就是这段代码的段地址
    	mov es,ax
    	mov ax,#0x1301
    	int 0x10
    inf_loop:
    	jmp inf_loop
    msg2:
    	.byte	13,10
    	.ascii	"Now we are in SETUP"
    	.byte	13,10,13,10
    .org 510
    boot_flag:
    	.word	0xAA55
    

    接下来需要编写 bootsect.s 中载入 setup.s 的关键代码

    所有需要的功能在原版 bootsect.s 中都是存在的,我们要做的仅仅是将这些代码添加到新的 bootsect.s 中去。

    除了新增代码,我们还需要去掉在 bootsect.s 添加的无限循环。

    SETUOLEN=2              ! 读入的扇区数
    SETUPSEG=0x07e0         ! setup代码的段地址
    entry _start
    _start:
        mov ah,#0x03        ! 设置功能号
        xor bh,bh           ! 将bh置0
        int 0x10            ! 返回行号和列号,供显示串用
        mov cx,#52          !要显示的字符串长度
        mov bx,#0x0007      ! bh=0,bl=07(正常的黑底白字)
        mov bp,#msg1        ! es:bp 要显示的字符串物理地址
        mov ax,#0x07c0      ! 将es段寄存器置为#0x07c0
        mov es,ax           
        mov ax,#0x1301      ! ah=13(设置功能号),al=01(目标字符串仅仅包含字符,属性在BL中包含,光标停在字符串结尾处)
        int 0x10            ! 显示字符串
    
    ! 将setup模块从磁盘的第二个扇区开始读到0x7e00
    load_setup:
        mov dx,#0x0000                  ! 磁头=0;驱动器号=0
        mov cx,#0x0002                  ! 磁道=0;扇区=2
        mov bx,#0x0200                  ! 偏移地址
        mov ax,#0x0200+SETUPLEN         ! 设置功能号;需要读出的扇区数量
        int 0x13                        ! 读磁盘扇区到内存
        jnc ok_load_setup               ! CF=0(读入成功)跳转到ok_load_setup  
        mov dx,#0x0000                  ! 如果读入失败,使用功能号ah=0x00————磁盘系统复位
        mov ax,#0x0000
        int 0x13
        jmp load_setup                  ! 尝试重新读入
    
    ok_load_setup:
        jmpi    0,SETUPSEG              ! 段间跳转指令,跳转到setup模块处(0x07e0:0000)
    
    ! 字符串信息
    msg1:
        .byte   13,10           ! 换行+回车
        .ascii  "Welcome to the world without assembly language"
        .byte   13,10,13,10     ! 换行+回车
    
    ! 将
    .org 510
    
    ! 启动盘具有有效引导扇区的标志。仅供BIOS中的程序加载引导扇区时识别使用。它必须位于引导扇区的最后两个字节中
    boot_flag:
        .word   0xAA55
    

    再次编译

    $ make BootImage
    

    有 Error!这是因为 make 根据 Makefile 的指引执行了 tools/build.c,它是为生成整个内核的镜像文件而设计的,没考虑我们只需要 bootsect.ssetup.s 的情况。它在向我们要 “系统” 的核心代码。为完成实验,接下来给它打个小补丁。c

    build.c 从命令行参数得到 bootsect、setup 和 system 内核的文件名,将三者做简单的整理后一起写入 Image。其中 system 是第三个参数(argv[3])。当 “make all” 或者 “makeall” 的时候,这个参数传过来的是正确的文件名,build.c 会打开它,将内容写入 Image。而 “make BootImage” 时,传过来的是字符串 “none”。所以,改造 build.c 的思路就是当 argv[3] 是"none"的时候,只写 bootsect 和 setup,忽略所有与 system 有关的工作,或者在该写 system 的位置都写上 “0”。

    修改工作主要集中在 build.c 的尾部,可长度以参考下面的方式,将圈起来的部分注释掉。

    image-20210814222844903

    重新编译

    $ cd ~/oslab/linux-0.11
    $ make BootImage
    $ ../run
    

    image-20210814222851074

    setup.s获取基本硬件参数

    这里把一些难以理解的代码单独列出来

    1. 获得磁盘参数

    这里花了我很长时间,原因是概念没有搞清楚,我觉得老师在实验指导书上写的也不是很清楚,CSDN上都只是草草复制的代码,感觉他们可以压根没有理解这一段。

    先来回顾一下上文的一个概念:int 0x41

    在PC机中BIOS设定的中断向量表中int 0x41的中断向量位置 ( 4 ∗ 0 x 41 = 0 x 0000 : 0 x 0104 4*0x41 = 0x0000:0x0104 40x41=0x0000:0x0104​)存放的并不是中断程序的地址,而是第一个硬盘的基本参数表。对于100%兼容的BIOS来说,这里存放着硬盘参数表阵列的首地址0xF000:0E401,第二个硬盘的基本参数表入口地址存于int 0x46中断向量位置处.每个硬盘参数表有16个字节大小.

    这段话是重点,我之前误理解为磁盘参数就存放在以0x0000:0x0104为首地址的单元中,总共占16个字节,但实际上,只存了4个字节,里面存放的是磁盘参数表的偏移地址和段地址,也就是上文所说这里存放着硬盘参数表阵列的首地址0xF000:0E401

    lds    si,[4*0x41]
    

    再看这行代码就可以理解了,这里是把0x0000:0x0104单元存放的值(表示硬盘参数表阵列的首地址的偏移地址)赋给si寄存器,把0x0000:0x0106单元存放的值(表示硬盘参数表阵列的首地址的段地址)赋给ds寄存器。

    1. 参数以十六进制方式显示

    先说说浪费我很长时间的我的错误:我想的是一个ASCII码8位,为什么答案里是4位4位输出,这里是搞清楚显示的目的。显示的是存在内存单元里的16进制数,例如某个字(2个字节)中的数值为 019 A 019A 019A​,我所要显示的不是01和9A表示的ASCII码,而是显示019A本身,所以要4位4位显示。

    以十六进制方式显示比较简单。这是因为十六进制与二进制有很好的对应关系(每 4 位二进制数和 1 位十六进制数存在一一对应关系),显示时只需将原二进制数每 4 位划成一组,按组求对应的 ASCII 码送显示器即可。ASCII 码与十六进制数字的对应关系为:0x30 ~ 0x39 对应数字 0 ~ 9,0x41 ~ 0x46 对应数字 a ~ f。从数字 9 到 a,其 ASCII 码间隔了 7h,这一点在转换时要特别注意。为使一个十六进制数能按高位到低位依次显示,实际编程中,需对 bx 中的数每次循环左移一组(4 位二进制),然后屏蔽掉当前高 12 位,对当前余下的 4 位(即 1 位十六进制数)求其 ASCII 码,要判断它是 0 ~ 9 还是 a ~ f,是前者则加 0x30 得对应的 ASCII 码,后者则要加 0x37 才行,最后送显示器输出。以上步骤重复 4 次,就可以完成 bx 中数以 4 位十六进制的形式显示出来。

    下面是提供的参考代码

    INITSEG  = 0x9000                   ! 参数存放位置的段地址
    entry _start
    _start:
    ! 打印 "NOW we are in SETUP"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#25
        mov bx,#0x0007
        mov bp,#msg2
        mov ax,cs
        mov es,ax
        mov ax,#0x1301
        int 0x10
    
    ! 获取光标位置
        mov ax,#INITSEG
        mov ds,ax
        mov ah,#0x03
        xor bh,bh
        int 0x10                        ! 返回:dh = 行号;dl = 列号
        mov [0],dx                      ! 存储到内存0x9000:0处
    
    ! 获取内存大小
        mov ah,#0x88
        int 0x15                        ! 返回:ax = 从0x100000(1M)处开始的扩展内存大小(KB)
        mov [2],ax                      ! 将扩展内存数值存放在0x90002处(1个字)
    
    ! 读第一个磁盘参数表复制到0x90004处
        mov ax,#0x0000
        mov ds,ax
        lds si,[4*0x41]                 ! 把低字(2B)置为偏移地址,高字(2B)置为段地址
        mov ax,#INITSEG
        mov es,ax
        mov di,#0x0004
        mov cx,#0x10                    ! 重复16次,即传送16B
        rep
        movsb                           ! 按字节传送
    
    ! 打印前的准备
        mov ax,cs
        mov es,ax
        mov ax,#INITSEG
        mov ds,ax
    
    ! 打印"Cursor position:"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#18
        mov bx,#0x0007
        mov bp,#msg_cursor
        mov ax,#0x1301
        int 0x10
    
    ! 打印光标位置
        mov dx,[0]
        call    print_hex
    
    ! 打印"Memory Size:"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#14
        mov bx,#0x0007
        mov bp,#msg_memory
        mov ax,#0x1301
        int 0x10
    
    ! 打印内存大小
        mov dx,[2]
        call    print_hex
    
    ! 打印"KB"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#2
        mov bx,#0x0007
        mov bp,#msg_kb
        mov ax,#0x1301
        int 0x10
    
    ! 打印"Cyls:" 
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#7
        mov bx,#0x0007
        mov bp,#msg_cyles
        mov ax,#0x1301
        int 0x10
    
    ! 打印柱面数   
        mov dx,[4]
        call    print_hex
    
    ! 打印"Heads:"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#8
        mov bx,#0x0007
        mov bp,#msg_heads
        mov ax,#0x1301
        int 0x10
    
    ! 打印磁头数
        mov dx,[6]
        call    print_hex
    
    ! 打印"Sectors:"
        mov ah,#0x03
        xor bh,bh
        int 0x10
        mov cx,#10
        mov bx,#0x0007
        mov bp,#msg_sectors
        mov ax,#0x1301
        int 0x10
        mov dx,[18]
        call    print_hex
    
    inf_loop:
        jmp inf_loop
    
    ! 上面的call都转到这里
    print_hex:
        mov    cx,#4                    ! dx(16位)可以显示4个十六进制数字
    print_digit:
        rol    dx,#4                    ! 取 dx 的高4比特移到低4比特处
        mov    ax,#0xe0f                ! ah = 请求的功能值(显示单个字符),al = 半字节(4个比特)掩码
        and    al,dl                    ! 前4位会被置为0
        add    al,#0x30                 ! 给 al 数字加上十六进制 0x30
        cmp    al,#0x3a                 ! 比较看是否大于数字十
        jl     outp                     ! 是一个不大于十的数字则跳转
        add    al,#0x07                 ! 否则就是a~f,要多加7
    outp:
        int    0x10                     ! 显示单个字符
        loop   print_digit              ! 重复4次
        ret                             
    
    ! 打印换行回车
    print_nl:
        mov    ax,#0xe0d     ! CR
        int    0x10
        mov    al,#0xa     ! LF
        int    0x10
        ret
    
    msg2:
        .byte 13,10
        .ascii "NOW we are in SETUP"
        .byte 13,10,13,10
    msg_cursor:
        .byte 13,10
        .ascii "Cursor position:"
    msg_memory:
        .byte 13,10
        .ascii "Memory Size:"
    msg_cyles:
        .byte 13,10
        .ascii "Cyls:"
    msg_heads:
        .byte 13,10
        .ascii "Heads:"
    msg_sectors:
        .byte 13,10
        .ascii "Sectors:"
    msg_kb:
        .ascii "KB"
    
    .org 510
    boot_flag:
        .word 0xAA55
    

    经过漫长的调试,得到如下结果

    在这里插入图片描述

    Memory Size 是 0x3C00KB,算一算刚好是 15MB(扩展内存),加上 1MB 正好是 16MB,看看 Bochs 配置文件 bochs/bochsrc.bxrc:

    image-20210814222912950

    这些都和上面打出的参数吻合,表示此次实验是成功的。

    天道酬勤

    实验二总共花费25小时,因为没有汇编基础,花费了大量时间在理解代码上,希望下面的实验可以越做越快吧。

    实验三 系统调用

    提醒

    这次实验涉及的宏过于复杂,加上本人能力有限,我也没有花大量时间去研究每一段代码,只是理解到每一段代码做了什么这一程度。

    实验目的

    此次实验的基本内容是:在 Linux 0.11 上添加两个系统调用,并编写两个简单的应用程序测试它们。

    1. iam()

      第一个系统调用是 iam(),其原型为:

      int iam(const char * name);
      

      完成的功能是将字符串参数 name 的内容拷贝到内核中保存下来。要求 name 的长度不能超过 23 个字符。返回值是拷贝的字符数。如果 name 的字符个数超过了 23,则返回 “-1”,并置 errno 为 EINVAL。

    2. whoami()

      第二个系统调用是 whoami(),其原型为:

      int whoami(char* name, unsigned int size);
      

      它将内核中由 iam() 保存的名字拷贝到 name 指向的用户地址空间中,同时确保不会对 name 越界访存(name 的大小由 size 说明)。返回值是拷贝的字符数。如果 size 小于需要的空间,则返回“-1”,并置 errno 为 EINVAL。

    应用程序如何调用系统调用

    在通常情况下,调用系统调用和调用一个普通的自定义函数在代码上并没有什么区别,但调用后发生的事情有很大不同。

    调用自定义函数是通过 call 指令直接跳转到该函数的地址,继续运行。

    而调用系统调用,是调用系统库中为该系统调用编写的一个接口函数,叫 API(Application Programming Interface)。API 并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:

    • 把系统调用的编号存入 EAX;
    • 把函数参数存入其它通用寄存器;
    • 触发 0x80 号中断(int 0x80)。

    linux-0.11 的 lib 目录下有一些已经实现的 API。Linus 编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动 shell。而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的 API。

    我们不妨看看 lib/close.c,研究一下 close() 的 API:

    #define __LIBRARY__
    #include <unistd.h>
    
    _syscall1(int, close, int, fd)
    

    其中 _syscall1 是一个宏,在 include/unistd.h 中定义。

    #define _syscall1(type,name,atype,a) \
    type name(atype a) \
    { \
    long __res; \
    __asm__ volatile ("int $0x80" \
        : "=a" (__res) \
        : "0" (__NR_##name),"b" ((long)(a))); \
    if (__res >= 0) \
        return (type) __res; \
    errno = -__res; \
    return -1; \
    }
    

    _syscall1(int,close,int,fd) 进行宏展开,可以得到:

    int close(int fd)
    {
        long __res;
        __asm__ volatile ("int $0x80"
            : "=a" (__res)
            : "0" (__NR_close),"b" ((long)(fd)));
        if (__res >= 0)
            return (int) __res;
        errno = -__res;
        return -1;
    }
    

    这就是 API 的定义。它先将宏 __NR_close 存入 EAX,将参数 fd 存入 EBX,然后进行 0x80 中断调用。调用返回后,从 EAX 取出返回值,存入 __res,再通过对 __res 的判断决定传给 API 的调用者什么样的返回值。

    其中 __NR_close 就是系统调用的编号,在 include/unistd.h 中定义:

    #define __NR_close    6
    /*
    所以添加系统调用时需要修改include/unistd.h文件,
    使其包含__NR_whoami和__NR_iam。
    */
    
    /*
    而在应用程序中,要有:
    */
    
    /* 有它,_syscall1 等才有效。详见unistd.h */
    #define __LIBRARY__
    
    /* 有它,编译器才能获知自定义的系统调用的编号 */
    #include "unistd.h"
    
    /* iam()在用户空间的接口函数 */
    _syscall1(int, iam, const char*, name);
    
    /* whoami()在用户空间的接口函数 */
    _syscall2(int, whoami,char*,name,unsigned int,size);
    

    在 0.11 环境下编译 C 程序,包含的头文件都在 /usr/include 目录下。

    该目录下的 unistd.h 是标准头文件(它和 0.11 源码树中的 unistd.h 并不是同一个文件,虽然内容可能相同),没有 __NR_whoami__NR_iam 两个宏,需要手工加上它们,也可以直接从修改过的 0.11 源码树中拷贝新的 unistd.h 过来。

    从“int 0x80”进入内核函数

    int 0x80 触发后,接下来就是内核的中断处理了。先了解一下 0.11 处理 0x80 号中断的过程。

    在内核初始化时,主函数在 init/main.c 中,调用了 sched_init() 初始化函数:

    void main(void)
    {
    //    ……
        time_init();
        sched_init();
        buffer_init(buffer_memory_end);
    //    ……
    }
    

    sched_init()kernel/sched.c 中定义为:

    void sched_init(void)
    {
    //    ……
        set_system_gate(0x80,&system_call);
    }
    

    set_system_gate 是个宏,在 include/asm/system.h 中定义为:

    #define set_system_gate(n,addr) \
        _set_gate(&idt[n],15,3,addr)
    

    _set_gate 的定义是:

    #define _set_gate(gate_addr,type,dpl,addr) \
    __asm__ ("movw %%dx,%%ax\n\t" \
        "movw %0,%%dx\n\t" \
        "movl %%eax,%1\n\t" \
        "movl %%edx,%2" \
        : \
        : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
        "o" (*((char *) (gate_addr))), \
        "o" (*(4+(char *) (gate_addr))), \
        "d" ((char *) (addr)),"a" (0x00080000))
    

    虽然看起来挺麻烦,但实际上很简单,就是填写 IDT(中断描述符表),将 system_call 函数地址写到 0x80 对应的中断描述符中,也就是在中断 0x80 发生后,自动调用函数 system_call

    接下来看 system_call。该函数纯汇编打造,定义在 kernel/system_call.s 中:

    !……
    ! # 这是系统调用总数。如果增删了系统调用,必须做相应修改
    nr_system_calls = 72
    !……
    
    .globl system_call
    .align 2
    system_call:
    
    ! # 检查系统调用编号是否在合法范围内
        cmpl \$nr_system_calls-1,%eax
        ja bad_sys_call
        push %ds
        push %es
        push %fs
        pushl %edx
        pushl %ecx
    
    ! # push %ebx,%ecx,%edx,是传递给系统调用的参数
        pushl %ebx
    
    ! # 让ds, es指向GDT,内核地址空间
        movl $0x10,%edx
        mov %dx,%ds
        mov %dx,%es
        movl $0x17,%edx
    ! # 让fs指向LDT,用户地址空间
        mov %dx,%fs
        call sys_call_table(,%eax,4)
        pushl %eax
        movl current,%eax
        cmpl $0,state(%eax)
        jne reschedule
        cmpl $0,counter(%eax)
        je reschedule
    

    system_call.globl 修饰为其他函数可见。

    call sys_call_table(,%eax,4) 之前是一些压栈保护,修改段选择子为内核段,call sys_call_table(,%eax,4) 之后是看看是否需要重新调度,这些都与本实验没有直接关系,此处只关心 call sys_call_table(,%eax,4) 这一句。

    根据汇编寻址方法它实际上是:call sys_call_table + 4 * %eax,其中 eax 中放的是系统调用号,即 __NR_xxxxxx

    显然,sys_call_table 一定是一个函数指针数组的起始地址,它定义在 include/linux/sys.h 中:

    fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,...
    

    增加实验要求的系统调用,需要在这个函数表中增加两个函数引用 ——sys_iamsys_whoami。当然该函数在 sys_call_table 数组中的位置必须和 __NR_xxxxxx 的值对应上。

    同时还要仿照此文件中前面各个系统调用的写法,加上:

    extern int sys_whoami();
    extern int sys_iam();
    

    不然,编译会出错的。

    实现 sys_iam() 和 sys_whoami()

    添加系统调用的最后一步,是在内核中实现函数 sys_iam()sys_whoami()

    每个系统调用都有一个 sys_xxxxxx() 与之对应,它们都是我们学习和模仿的好对象。

    比如在 fs/open.c 中的 sys_close(int fd)

    int sys_close(unsigned int fd)
    {
    //    ……
        return (0);
    }
    

    它没有什么特别的,都是实实在在地做 close() 该做的事情。

    所以只要自己创建一个文件:kernel/who.c,然后实现两个函数就万事大吉了。

    按照上述逻辑修改相应文件

    通过上文描述,我们已经理清楚了要修改的地方在哪里

    1. 添加iam和whoami系统调用编号的宏定义(_NR_xxxxxx),文件:include/unistd.h

      image-20210829143749907

    2. 修改系统调用总数, 文件:kernel/system_call.s

      image-20210829144114522

    3. 为新增的系统调用添加系统调用名并维护系统调用表,文件:include/linux/sys.h

      image-20210829144844784

    4. 为新增的系统调用编写代码实现,在linux-0.11/kernel目录下,创建一个文件 who.c

      #include <asm/segment.h>
      #include <errno.h>
      #include <string.h>
      
      char _myname[24];
      
      int sys_iam(const char *name)
      {
          char str[25];
          int i = 0;
      
          do
          {
              // get char from user input
              str[i] = get_fs_byte(name + i);
          } while (i <= 25 && str[i++] != '\0');
      
          if (i > 24)
          {
              errno = EINVAL;
              i = -1;
          }
          else
          {
              // copy from user mode to kernel mode
              strcpy(_myname, str);
          }
      
          return i;
      }
      
      int sys_whoami(char *name, unsigned int size)
      {
          int length = strlen(_myname);
          printk("%s\n", _myname);
      
          if (size < length)
          {
              errno = EINVAL;
              length = -1;
          }
          else
          {
              int i = 0;
              for (i = 0; i < length; i++)
              {
                  // copy from kernel mode to user mode
                  put_fs_byte(_myname[i], name + i);
              }
          }
          return length;
      }
      

    修改 Makefile

    要想让我们添加的 kernel/who.c 可以和其它 Linux 代码编译链接到一起,必须要修改 Makefile 文件。

    Makefile 里记录的是所有源程序文件的编译、链接规则,《注释》3.6 节有简略介绍。我们之所以简单地运行 make 就可以编译整个代码树,是因为 make 完全按照 Makefile 里的指示工作。

    Makefile 在代码树中有很多,分别负责不同模块的编译工作。我们要修改的是 kernel/Makefile。需要修改两处。

    (1)第一处

    OBJS  = sched.o system_call.o traps.o asm.o fork.o \
            panic.o printk.o vsprintf.o sys.o exit.o \
            signal.o mktime.o
    

    改为:

    OBJS  = sched.o system_call.o traps.o asm.o fork.o \
            panic.o printk.o vsprintf.o sys.o exit.o \
            signal.o mktime.o who.o
    

    添加了 who.o

    image-20210829161702940

    (2)第二处

    ### Dependencies:
    exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
      ../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
      ../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
      ../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
      ../include/asm/segment.h
    

    改为:

    ### Dependencies:
    who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h
    exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
      ../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
      ../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
      ../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
      ../include/asm/segment.h
    

    添加了 who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h

    image-20210829162022371

    Makefile 修改后,和往常一样 make all 就能自动把 who.c 加入到内核中了。

    编写测试程序

    到此为止,内核中需要修改的部分已经完成,接下来需要编写测试程序来验证新增的系统调用是否已经被编译到linux-0.11内核可供调用。首先在oslab目录下编写iam.c,whoami.c

    /* iam.c */
    #define __LIBRARY__
    #include <unistd.h> 
    #include <errno.h>
    #include <asm/segment.h> 
    #include <linux/kernel.h>
    _syscall1(int, iam, const char*, name);
       
    int main(int argc, char *argv[])
    {
        /*调用系统调用iam()*/
        iam(argv[1]);
        return 0;
    }
    
    /* whoami.c */
    #define __LIBRARY__
    #include <unistd.h> 
    #include <errno.h>
    #include <asm/segment.h> 
    #include <linux/kernel.h>
    #include <stdio.h>
       
    _syscall2(int, whoami,char *,name,unsigned int,size);
       
    int main(int argc, char *argv[])
    {
        char username[64] = {0};
        /*调用系统调用whoami()*/
        whoami(username, 24);
        printf("%s\n", username);
        return 0;
    }
    

    以上两个文件需要放到启动后的linux-0.11操作系统上运行,验证新增的系统调用是否有效,那如何才能将这两个文件从宿主机转到稍后虚拟机中启动的linux-0.11操作系统上呢?这里我们采用挂载方式实现宿主机与虚拟机操作系统的文件共享,在 oslab 目录下执行以下命令挂载hdc目录到虚拟机操作系统上。

    sudo ./mount-hdc 
    

    再通过以下命令将上述两个文件拷贝到虚拟机linux-0.11操作系统/usr/root/目录下,命令在oslab/目录下执行:

    cp iam.c whoami.c hdc/usr/root
    

    如果目标目录下存在对应的两个文件则可启动虚拟机进行测试了。

    • 编译

      [/usr/root]# gcc -o iam iam.c
      [/usr/root]# gcc -o whoami whoami.c
      
    • 运行测试

      [/usr/root]# ./iam wcf
      [/usr/root]# ./whoami
      

    命令执行后,很可能会报以下错误:

    image-20210829172115712

    这代表虚拟机操作系统中/usr/include/unistd.h文件中没有新增的系统调用调用号

    为新增系统调用设置调用号

    #define __NR_whoami	   	72
    #define __NR_iam	   	73     
    

    再次执行:

    image-20210829215630491

    实验成功


    • 为什么这里会打印2次?

    • 因为在系统内核中执行了 printk() 函数,在用户模式下又执行了一次 printf() 函数。

    要知道到,printf() 是一个只能在用户模式下执行的函数,而系统调用是在内核模式中运行,所以 printf() 不可用,要用 printk()。

    printk()printf() 的接口和功能基本相同,只是代码上有一点点不同。printk() 需要特别处理一下 fs 寄存器,它是专用于用户模式的段寄存器。

    image-20210829220203825

    image-20210829220217721

    天道酬勤

    实验三总共花费7小时,看的不是特别仔细,没有特别深入的学习宏展开和内联汇编。但基本理解了系统调用的目的和方式,Linus永远的神!

    实验四 进程运行轨迹的跟踪与统计

    实验目的

    • 掌握 Linux 下的多进程编程技术;
    • 通过对进程运行轨迹的跟踪来形象化进程的概念;
    • 在进程运行轨迹跟踪的基础上进行相应的数据统计,从而能对进程调度算法进行实际的量化评价,更进一步加深对调度和调度算法的理解,获得能在实际操作系统上对调度算法进行实验数据对比的直接经验。

    实验内容

    进程从创建(Linux 下调用 fork())到结束的整个过程就是进程的生命期,进程在其生命期中的运行轨迹实际上就表现为进程状态的多次切换,如进程创建以后会成为就绪态;当该进程被调度以后会切换到运行态;在运行的过程中如果启动了一个文件读写操作,操作系统会将该进程切换到阻塞态(等待态)从而让出 CPU;当文件读写完毕以后,操作系统会在将其切换成就绪态,等待进程调度算法来调度该进程执行……

    本次实验包括如下内容:

    • 基于模板 process.c 编写多进程的样本程序,实现如下功能: + 所有子进程都并行运行,每个子进程的实际运行时间一般不超过 30 秒; + 父进程向标准输出打印所有子进程的 id,并在所有子进程都退出后才退出;
    • Linux0.11 上实现进程运行轨迹的跟踪。 + 基本任务是在内核中维护一个日志文件 /var/process.log,把从操作系统启动到系统关机过程中所有进程的运行轨迹都记录在这一 log 文件中。
    • 在修改过的 0.11 上运行样本程序,通过分析 log 文件,统计该程序建立的所有进程的等待时间、完成时间(周转时间)和运行时间,然后计算平均等待时间,平均完成时间和吞吐量。可以自己编写统计程序进行统计。
    • 修改 0.11 进程调度的时间片,然后再运行同样的样本程序,统计同样的时间数据,和原有的情况对比,体会不同时间片带来的差异。

    /var/process.log 文件的格式必须为:

    pid    X    time
    

    其中:

    • pid 是进程的 ID;
    • X 可以是 N、J、R、W 和 E 中的任意一个,分别表示进程新建(N)、进入就绪态(J)、进入运行态®、进入阻塞态(W) 和退出(E);
    • time 表示 X 发生的时间。这个时间不是物理时间,而是系统的滴答时间(tick);

    三个字段之间用制表符分隔。例如:

    12    N    1056
    12    J    1057
    4    W    1057
    12    R    1057
    13    N    1058
    13    J    1059
    14    N    1059
    14    J    1060
    15    N    1060
    15    J    1061
    12    W    1061
    15    R    1061
    15    J    1076
    14    R    1076
    14    E    1076
    ......
    

    编写process.c文件

    提示

    在 Ubuntu 下,top 命令可以监视即时的进程状态。在 top 中,按 u,再输入你的用户名,可以限定只显示以你的身份运行的进程,更方便观察。按 h 可得到帮助。

    image-20210903111253440

    在 Ubuntu 下,ps 命令可以显示当时各个进程的状态。ps aux 会显示所有进程;ps aux | grep xxxx 将只显示名为 xxxx 的进程。更详细的用法请问 man。

    image-20210903111426774

    在 Linux 0.11 下,按 F1 可以即时显示当前所有进程的状态。

    image-20210903111506016

    文件主要作用

    process.c文件主要实现了一个函数:

    /*
     * 此函数按照参数占用CPU和I/O时间
     * last: 函数实际占用CPU和I/O的总时间,不含在就绪队列中的时间,>=0是必须的
     * cpu_time: 一次连续占用CPU的时间,>=0是必须的
     * io_time: 一次I/O消耗的时间,>=0是必须的
     * 如果last > cpu_time + io_time,则往复多次占用CPU和I/O,直到总运行时间超过last为止
     * 所有时间的单位为秒
     */
    cpuio_bound(int last, int cpu_time, int io_time);
    

    下面是 4 个使用的例子:

    // 比如一个进程如果要占用10秒的CPU时间,它可以调用:
    cpuio_bound(10, 1, 0);
    // 只要cpu_time>0,io_time=0,效果相同
    // 以I/O为主要任务:
    cpuio_bound(10, 0, 1);
    // 只要cpu_time=0,io_time>0,效果相同
    // CPU和I/O各1秒钟轮回:
    cpuio_bound(10, 1, 1);
    // 较多的I/O,较少的CPU:
    // I/O时间是CPU时间的9倍
    cpuio_bound(10, 1, 9);
    

    修改此模板,用 fork() 建立若干个同时运行的子进程,父进程等待所有子进程退出后才退出,每个子进程按照你的意愿做不同或相同的 cpuio_bound(),从而完成一个个性化的样本程序。

    它可以用来检验有关 log 文件的修改是否正确,同时还是数据统计工作的基础。

    wait() 系统调用可以让父进程等待子进程的退出。

    关键函数解释

    fork()

    这里摘录某位博主的详解操作系统之 fork() 函数详解 - 简书 (jianshu.com)

    fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

    一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

    #include <unistd.h>  
    #include <stdio.h>   
    int main ()   
    {   
        pid_t fpid; //fpid表示fork函数返回的值  
        int count=0;  
        fpid=fork();   
        if (fpid < 0)   
            printf("error in fork!");   
        else if (fpid == 0) {  
            printf("i am the child process, my process id is %d/n",getpid());   
            printf("我是爹的儿子/n");//对某些人来说中文看着更直白。  
            count++;  
        }  
        else {  
            printf("i am the parent process, my process id is %d/n",getpid());   
            printf("我是孩子他爹/n");  
            count++;  
        }  
        printf("统计结果是: %d/n",count);  
        return 0;  
    }  
    

    运行结果:

    i am the child process, my process id is 5574
    我是爹的儿子
    统计结果是: 1
    i am the parent process, my process id is 5573
    我是孩子他爹
    统计结果是: 1
    

    在语句fpid=fork()之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的几乎完全相同,将要执行的下一条语句都是if(fpid<0)

    为什么两个进程的fpid不同呢,这与fork函数的特性有关。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
    1)在父进程中,fork返回新创建子进程的进程ID;
    2)在子进程中,fork返回0;
    3)如果出现错误,fork返回一个负值;

    在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。

    引用一位网友的话来解释fpid的值为什么在父子进程中不同。“其实就相当于链表,进程形成了链表,父进程的fpid(p 意味point)指向子进程的进程id, 因为子进程没有子进程,所以其fpid为0.
      fork出错可能有两种原因:
     1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
     2)系统内存不足,这时errno的值被设置为ENOMEM。
     创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。
     每个进程都有一个独特(互不相同)的进程标识符(process ID),可以通过getpid()函数获得,还有一个记录父进程pid的变量,可以通过getppid()函数获得变量的值。
      fork执行完毕后,出现两个进程

    img

    有人说两个进程的内容完全一样啊,怎么打印的结果不一样啊,那是因为判断条件的原因,上面列举的只是进程的代码和指令,还有变量啊。
     执行完fork后,进程1的变量为count=0,fpid!=0(父进程)。进程2的变量为count=0,fpid=0(子进程),这两个进程的变量都是独立的,存在不同的地址中,不是共用的,这点要注意。可以说,我们就是通过fpid来识别和操作父子进程的。
     还有人可能疑惑为什么不是从#include处开始复制代码的,这是因为fork是把进程当前的情况拷贝一份,执行fork时,进程已经执行完了int count=0;fork只拷贝下一个要执行的代码到新的进程。

    struct tms 结构体

    struct tms 结构体定义在 <sys/times.h> 头文件里,具体定义如下:

    引用
    /* Structure describing CPU time used by a process and its children.  */ 
    struct tms 
      { 
        clock_t tms_utime ;          /* User CPU time.  用户程序 CPU 时间*/ 
        clock_t tms_stime ;          /* System CPU time. 系统调用所耗费的 CPU 时间 */ 
    
        clock_t tms_cutime ;         /* User CPU time of dead children. 已死掉子进程的 CPU 时间*/ 
        clock_t tms_cstime ;         /* System CPU time of dead children.  已死掉子进程所耗费的系统调用 CPU 时间*/ 
      };
    

    用户CPU时间和系统CPU时间之和为CPU时间,即命令占用CPU执行的时间总和。实际时间要大于CPU时间,因为Linux是多任务操作系统,往往在执行一条命令时,系统还要处理其他任务。另一个需要注意的问题是即使每次执行相同的命令,所花费的时间也不一定相同,因为其花费的时间与系统运行相关。

    数据类型 clock_t

    关于该数据类型的定义如下:

    #ifndef _CLOCK_T_DEFINED
    typedef long clock_t;
    #define _CLOCK_T_DEFINED
    #endif
    

    clock_t 是一个长整型数。

    在 time.h 文件中,还定义了一个常量 CLOCKS_PER_SEC ,它用来表示一秒钟会有多少个时钟计时单元,其定义如下:

    #define CLOCKS_PER_SEC ((clock_t)1000)
    

    下文就模拟cpu操作,定义 H Z = 100 HZ=100 HZ=100​​,内核的标准时间是jiffy,一个jiffy就是一个内部时钟周期,而内部时钟周期是由100HZ的频率所产生中的,也就是一个时钟滴答,间隔时间是10毫秒(ms).计算出来的时间也并非真实时间,而是时钟滴答次数,乘以10ms可以得到真正的时间。

    代码实现

    下面给出代码:

    #include <stdio.h>
    #include <unistd.h>
    #include <time.h>
    #include <sys/times.h>
    
    #define HZ	100
    
    void cpuio_bound(int last, int cpu_time, int io_time);
    
    int main(int argc, char * argv[])
    {
    	pid_t n_proc[10]; /*10个子进程 PID*/
    	int i;
    	for(i=0;i<10;i++)
    	{
    		n_proc[i] = fork();
    		/*子进程*/
    		if(n_proc[i] == 0)
    		{
    			cpuio_bound(20,2*i,20-2*i); /*每个子进程都占用20s*/
    			return 0; /*执行完cpuio_bound 以后,结束该子进程*/
    		}
    		/*fork 失败*/
    		else if(n_proc[i] < 0 )
    		{
    			printf("Failed to fork child process %d!\n",i+1);
    			return -1;
    		}
    		/*父进程继续fork*/
    	}
    	/*打印所有子进程PID*/
    	for(i=0;i<10;i++)
    		printf("Child PID: %d\n",n_proc[i]);
    	/*等待所有子进程完成*/
    	wait(&i);  /*Linux 0.11 上 gcc要求必须有一个参数, gcc3.4+则不需要*/ 
    	return 0;
    }
    
    /*
     * 此函数按照参数占用CPU和I/O时间
     * last: 函数实际占用CPU和I/O的总时间,不含在就绪队列中的时间,>=0是必须的
     * cpu_time: 一次连续占用CPU的时间,>=0是必须的
     * io_time: 一次I/O消耗的时间,>=0是必须的
     * 如果last > cpu_time + io_time,则往复多次占用CPU和I/O
     * 所有时间的单位为秒
     */
    void cpuio_bound(int last, int cpu_time, int io_time)
    {
    	struct tms start_time, current_time;
    	clock_t utime, stime;
    	int sleep_time;
    
    	while (last > 0)
    	{
    		/* CPU Burst */
    		times(&start_time);
    		/* 其实只有t.tms_utime才是真正的CPU时间。但我们是在模拟一个
    		 * 只在用户状态运行的CPU大户,就像“for(;;);”。所以把t.tms_stime
    		 * 加上很合理。*/
    		do
    		{
    			times(&current_time);
    			utime = current_time.tms_utime - start_time.tms_utime;
    			stime = current_time.tms_stime - start_time.tms_stime;
    		} while ( ( (utime + stime) / HZ )  < cpu_time );
    		last -= cpu_time;
    
    		if (last <= 0 )
    			break;
    
    		/* IO Burst */
    		/* 用sleep(1)模拟1秒钟的I/O操作 */
    		sleep_time=0;
    		while (sleep_time < io_time)
    		{
    			sleep(1);
    			sleep_time++;
    		}
    		last -= sleep_time;
    	}
    }
    

    image-20210904084620417

    答疑

    ❓ 为什么说它可以用来检验有关 log 文件的修改是否正确,同时还是数据统计工作的基础?

    • 每个子进程都通过 cpuio_bound 函数实现了占用CPU和I/O时间的操作,并且可以精确的知道每个操作的时间。所以下面的 log 文件(日志文件)正确与否可以借此推算。

    尽早打开log文件

    操作系统启动后先要打开 /var/process.log,然后在每个进程发生状态切换的时候向 log 文件内写入一条记录,其过程和用户态的应用程序没什么两样。然而,因为内核状态的存在,使过程中的很多细节变得完全不一样。

    为了能尽早开始记录,应当在内核启动时就打开 log 文件。内核的入口是 init/main.c 中的 main(),其中一段代码是:

    //……
    move_to_user_mode();
    if (!fork()) {        /* we count on this going ok */
        init();
    }
    //……
    

    这段代码在进程 0 中运行,先切换到用户模式,然后全系统第一次调用 fork() 建立进程 1。进程 1 调用 init()

    在 init()中:

    // ……
    //加载文件系统
    setup((void *) &drive_info);
    
    // 打开/dev/tty0,建立文件描述符0和/dev/tty0的关联
    (void) open("/dev/tty0",O_RDWR,0);
    
    // 让文件描述符1也和/dev/tty0关联
    (void) dup(0);
    
    // 让文件描述符2也和/dev/tty0关联
    (void) dup(0);
    
    // ……
    

    这段代码建立了文件描述符 0、1 和 2,它们分别就是 stdin、stdout 和 stderr。这三者的值是系统标准(Windows 也是如此),不可改变。

    可以把 log 文件的描述符关联到 3。文件系统初始化,描述符 0、1 和 2 关联之后,才能打开 log 文件,开始记录进程的运行轨迹。

    为了能尽早访问 log 文件,我们要让上述工作在进程 0 中就完成。所以把这一段代码从 init() 移动到 main() 中,放在 move_to_user_mode() 之后(不能再靠前了),同时加上打开 log 文件的代码。

    修改后的 main() 如下:

    //……
    move_to_user_mode();
    
    /***************添加开始***************/
    setup((void *) &drive_info);
    
    // 建立文件描述符0和/dev/tty0的关联
    (void) open("/dev/tty0",O_RDWR,0);
    
    //文件描述符1也和/dev/tty0关联
    (void) dup(0);
    
    // 文件描述符2也和/dev/tty0关联
    (void) dup(0);
    
    (void) open("/var/process.log",O_CREAT|O_TRUNC|O_WRONLY,0666);
    
    /***************添加结束***************/
    
    if (!fork()) {        /* we count on this going ok */
        init();
    }
    //……
    

    打开 log 文件的参数的含义是建立只写文件,如果文件已存在则清空已有内容。文件的权限是所有人可读可写。

    这样,文件描述符 0、1、2 和 3 就在进程 0 中建立了。根据 fork() 的原理,进程 1 会继承这些文件描述符,所以 init() 中就不必再 open() 它们。此后所有新建的进程都是进程 1 的子孙,也会继承它们。但实际上,init() 的后续代码和 /bin/sh 都会重新初始化它们。所以只有进程 0 和进程 1 的文件描述符肯定关联着 log 文件,这一点在接下来的写 log 中很重要。

    小结

    其实就是为了尽早打开log日志文件开始记录,那必须满足在用户模式且可以进行文件读写,因此最前的位置只能在 move_to_user_mode() 之后(不能再靠前了),并且建立文件描述符 0、1 和 2,它们分别就是 stdinstdoutstderr

    编写fprintk()函数

    log 文件将被用来记录进程的状态转移轨迹。所有的状态转移都是在内核进行的。

    在内核状态下,write() 功能失效,其原理等同于《系统调用》实验中不能在内核状态调用 printf(),只能调用 printk()。编写可在内核调用的 write() 的难度较大,所以这里直接给出源码。它主要参考了 printk()sys_write() 而写成的:

    #include "linux/sched.h"
    #include "sys/stat.h"
    
    static char logbuf[1024];
    int fprintk(int fd, const char *fmt, ...)
    {
        va_list args;
        int count;
        struct file * file;
        struct m_inode * inode;
    
        va_start(args, fmt);
        count=vsprintf(logbuf, fmt, args);
        va_end(args);
    /* 如果输出到stdout或stderr,直接调用sys_write即可 */
        if (fd < 3)
        {
            __asm__("push %%fs\n\t"
                "push %%ds\n\t"
                "pop %%fs\n\t"
                "pushl %0\n\t"
            /* 注意对于Windows环境来说,是_logbuf,下同 */
                "pushl $logbuf\n\t"
                "pushl %1\n\t"
            /* 注意对于Windows环境来说,是_sys_write,下同 */
                "call sys_write\n\t"
                "addl $8,%%esp\n\t"
                "popl %0\n\t"
                "pop %%fs"
                ::"r" (count),"r" (fd):"ax","cx","dx");
        }
        else
    /* 假定>=3的描述符都与文件关联。事实上,还存在很多其它情况,这里并没有考虑。*/
        {
        /* 从进程0的文件描述符表中得到文件句柄 */
            if (!(file=task[0]->filp[fd]))
                return 0;
            inode=file->f_inode;
    
            __asm__("push %%fs\n\t"
                "push %%ds\n\t"
                "pop %%fs\n\t"
                "pushl %0\n\t"
                "pushl $logbuf\n\t"
                "pushl %1\n\t"
                "pushl %2\n\t"
                "call file_write\n\t"
                "addl $12,%%esp\n\t"
                "popl %0\n\t"
                "pop %%fs"
                ::"r" (count),"r" (file),"r" (inode):"ax","cx","dx");
        }
        return count;
    }
    

    因为和 printk 的功能近似,建议将此函数放入到 kernel/printk.c 中。fprintk() 的使用方式类同与 C 标准库函数 fprintf(),唯一的区别是第一个参数是文件描述符,而不是文件指针。

    例如:

    // 向stdout打印正在运行的进程的ID
    fprintk(1, "The ID of running process is %ld", current->pid);
    
    // 向log文件输出跟踪进程运行轨迹
    fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'R', jiffies);
    

    jiffies,滴答

    jiffieskernel/sched.c 文件中定义为一个全局变量:

    long volatile jiffies=0;
    

    它记录了从开机到当前时间的时钟中断发生次数。在 kernel/sched.c 文件中的 sched_init() 函数中,时钟中断处理函数被设置为:

    set_intr_gate(0x20,&timer_interrupt);
    

    而在 kernel/system_call.s 文件中将 timer_interrupt 定义为:

    timer_interrupt:
    !    ……
    ! 增加jiffies计数值
        incl jiffies
    !    ……
    

    这说明 jiffies 表示从开机时到现在发生的时钟中断次数,这个数也被称为 “滴答数”。

    另外,在 kernel/sched.c 中的 sched_init() 中有下面的代码:

    // 设置8253模式
    outb_p(0x36, 0x43);
    outb_p(LATCH&0xff, 0x40);
    outb_p(LATCH>>8, 0x40);
    

    这三条语句用来设置每次时钟中断的间隔,即为 LATCH,而 LATCH 是定义在文件 kernel/sched.c 中的一个宏:

    // 在 kernel/sched.c 中
    #define LATCH  (1193180/HZ)
    
    // 在 include/linux/sched.h 中
    #define HZ 100
    

    再加上 PC 机 8253 定时芯片的输入时钟频率为 1.193180MHz,即 1193180/每秒,LATCH=1193180/100,时钟每跳 11931.8 下产生一次时钟中断,即每 1/100 秒(10ms)产生一次时钟中断,所以 jiffies 实际上记录了从开机以来共经过了多少个 10ms。

    注意这里是 H Z = 100 HZ=100 HZ=100 的情况,前文也介绍过。所以时间其实就是近似等于中断次数乘以 1 / H Z 1/HZ 1/HZ

    寻找状态切换点

    必须找到所有发生进程状态切换的代码点,并在这些点添加适当的代码,来输出进程状态变化的情况到 log 文件中。

    此处要面对的情况比较复杂,需要对 kernel 下的 fork.csched.c 有通盘的了解,而 exit.c 也会涉及到。

    例子 1:记录一个进程生命期的开始

    第一个例子是看看如何记录一个进程生命期的开始,当然这个事件就是进程的创建函数 fork(),由《系统调用》实验可知,fork() 功能在内核中实现为 sys_fork(),该“函数”在文件 kernel/system_call.s 中实现为:

    sys_fork:
        call find_empty_process
    !    ……
    ! 传递一些参数
        push %gs
        pushl %esi
        pushl %edi
        pushl %ebp
        pushl %eax
    ! 调用 copy_process 实现进程创建
        call copy_process
        addl $20,%esp
    

    所以真正实现进程创建的函数是 copy_process(),它在 kernel/fork.c 中定义为:

    int copy_process(int nr,……)
    {
        struct task_struct *p;
    //    ……
    // 获得一个 task_struct 结构体空间
        p = (struct task_struct *) get_free_page();
    //    ……
        p->pid = last_pid;
    //    ……
    // 设置 start_time 为 jiffies
        p->start_time = jiffies;
    //       ……
    /* 设置进程状态为就绪。所有就绪进程的状态都是
       TASK_RUNNING(0),被全局变量 current 指向的
       是正在运行的进程。*/
        p->state = TASK_RUNNING;
    
        return last_pid;
    }
    

    因此要完成进程运行轨迹的记录就要在 copy_process() 中添加输出语句。

    这里要输出两种状态,分别是“N(新建)”和“J(就绪)”。

    例子 2:记录进入睡眠态的时间

    第二个例子是记录进入睡眠态的时间。sleep_on() 和 interruptible_sleep_on() 让当前进程进入睡眠状态,这两个函数在 kernel/sched.c 文件中定义如下:

    void sleep_on(struct task_struct **p)
    {
        struct task_struct *tmp;
    //    ……
        tmp = *p;
    // 仔细阅读,实际上是将 current 插入“等待队列”头部,tmp 是原来的头部
        *p = current;
    // 切换到睡眠态
        current->state = TASK_UNINTERRUPTIBLE;
    // 让出 CPU
        schedule();
    // 唤醒队列中的上一个(tmp)睡眠进程。0 换作 TASK_RUNNING 更好
    // 在记录进程被唤醒时一定要考虑到这种情况,实验者一定要注意!!!
        if (tmp)
            tmp->state=0;
    }
    /* TASK_UNINTERRUPTIBLE和TASK_INTERRUPTIBLE的区别在于不可中断的睡眠
     * 只能由wake_up()显式唤醒,再由上面的 schedule()语句后的
     *
     *   if (tmp) tmp->state=0;
     *
     * 依次唤醒,所以不可中断的睡眠进程一定是按严格从“队列”(一个依靠
     * 放在进程内核栈中的指针变量tmp维护的队列)的首部进行唤醒。而对于可
     * 中断的进程,除了用wake_up唤醒以外,也可以用信号(给进程发送一个信
     * 号,实际上就是将进程PCB中维护的一个向量的某一位置位,进程需要在合
     * 适的时候处理这一位。感兴趣的实验者可以阅读有关代码)来唤醒,如在
     * schedule()中:
     *
     *  for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
     *      if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
     *         (*p)->state==TASK_INTERRUPTIBLE)
     *         (*p)->state=TASK_RUNNING;//唤醒
     *
     * 就是当进程是可中断睡眠时,如果遇到一些信号就将其唤醒。这样的唤醒会
     * 出现一个问题,那就是可能会唤醒等待队列中间的某个进程,此时这个链就
     * 需要进行适当调整。interruptible_sleep_on和sleep_on函数的主要区别就
     * 在这里。
     */
    void interruptible_sleep_on(struct task_struct **p)
    {
        struct task_struct *tmp;
           …
        tmp=*p;
        *p=current;
    repeat:    current->state = TASK_INTERRUPTIBLE;
        schedule();
    // 如果队列头进程和刚唤醒的进程 current 不是一个,
    // 说明从队列中间唤醒了一个进程,需要处理
        if (*p && *p != current) {
     // 将队列头唤醒,并通过 goto repeat 让自己再去睡眠
            (**p).state=0;
            goto repeat;
        }
        *p=NULL;
    //作用和 sleep_on 函数中的一样
        if (tmp)
            tmp->state=0;
    }
    

    总的来说,Linux 0.11 支持四种进程状态的转移:就绪到运行、运行到就绪、运行到睡眠和睡眠到就绪,此外还有新建和退出两种情况。其中就绪与运行间的状态转移是通过 schedule()(它亦是调度算法所在)完成的;运行到睡眠依靠的是 sleep_on()interruptible_sleep_on(),还有进程主动睡觉的系统调用 sys_pause()sys_waitpid();睡眠到就绪的转移依靠的是 wake_up()。所以只要在这些函数的适当位置插入适当的处理语句就能完成进程运行轨迹的全面跟踪了。

    修改fork.c文件

    fork.c文件在kernel目录下,这里要输出两种状态,分别是“N(新建)”和“J(就绪)”,下面做出两处修改:

    int copy_process(int nr,……)
    {
        struct task_struct *p;
    //    ……
    // 获得一个 task_struct 结构体空间
        p = (struct task_struct *) get_free_page();  
    //    ……
        p->pid = last_pid;
    //    ……
    // 设置 start_time 为 jiffies
        p->start_time = jiffies; 
          //新增修改,新建进程
    	fprintk(3,"%d\tN\t%d\n",p->pid,jiffies);   
    //       ……
    /* 设置进程状态为就绪。所有就绪进程的状态都是
       TASK_RUNNING(0),被全局变量 current 指向的
       是正在运行的进程。*/
        p->state = TASK_RUNNING;    
    	//新增修改,进程就绪
    	fprintk(3,"%d\tJ\t%d\n",p->pid,jiffies);
        return last_pid;
    }
    

    修改sched.c文件

    文件位置:kernel/sched.c

    修改schedule函数
    //这里仅仅说一下改动了什么
    /* check alarm, wake up any interruptible tasks that have got a signal */
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
    		if (*p) {
        
    			if ((*p)->alarm && (*p)->alarm < jiffies) {
        
    					(*p)->signal |= (1<<(SIGALRM-1));
    					(*p)->alarm = 0;
                }
    			if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
    			(*p)->state==TASK_INTERRUPTIBLE){
        
    				(*p)->state=TASK_RUNNING;
                	fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
                }	
    		}
    
    while (1) {
        
        c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS];
    
    // 找到 counter 值最大的就绪态进程
        while (--i) {
        
            if (!*--p)    continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
                c = (*p)->counter, next = i;
        }       
    
    // 如果有 counter 值大于 0 的就绪态进程,则退出
        if (c) break;  
    
    // 如果没有:
    // 所有进程的 counter 值除以 2 衰减后再和 priority 值相加,
    // 产生新的时间片
        for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
              if (*p) 
                  (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;  
    }
    //切换到相同的进程不输出
    if(current->pid != task[next] ->pid)
    	{
    		/*新建修改--时间片到时程序 => 就绪*/
    		if(current->state == TASK_RUNNING)
    			fprintk(3,"%d\tJ\t%d\n",current->pid,jiffies);
    		fprintk(3,"%d\tR\t%d\n",task[next]->pid,jiffies);
    	}
    // 切换到 next 进程
    switch_to(next);  
    
    修改sys_pause函数
    int sys_pause(void)
    {
    	current->state = TASK_INTERRUPTIBLE;
    	/*
    	*修改--当前进程  运行 => 可中断睡眠
    	*/
    	if(current->pid != 0)
    		fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
    	schedule();
    	return 0;
    }
    
    修改sleep_on函数
    void sleep_on(struct task_struct **p)
    {
    	struct task_struct *tmp;
    
    	if (!p)
    		return;
    	if (current == &(init_task.task))
    		panic("task[0] trying to sleep");
    	tmp = *p;
    	*p = current;
    	current->state = TASK_UNINTERRUPTIBLE;
    	/*
    	*修改--当前进程进程 => 不可中断睡眠
    	*/
    	fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
    	schedule();
    	if (tmp)
    	{
    		tmp->state=0;
    		/*
    		*修改--原等待队列 第一个进程 => 唤醒(就绪)
    		*/
    		fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies);
    	}
    }
    
    修改interruptible_sleep_on函数
    void interruptible_sleep_on(struct task_struct **p)
    {
    	struct task_struct *tmp;
    
    	if (!p)
    		return;
    	if (current == &(init_task.task))
    		panic("task[0] trying to sleep");
    	tmp=*p;
    	*p=current;
    repeat:	current->state = TASK_INTERRUPTIBLE;
    	/*
    	*修改--唤醒队列中间进程,过程中使用Wait
    	*/
    	fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
    	schedule();
    	if (*p && *p != current) {
    		(**p).state=0;
    		/*
    		*修改--当前进程 => 可中断睡眠
    		*/
    		fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
    		goto repeat;
    	}
    	*p=NULL;
    	if (tmp)
    	{
    		tmp->state=0;
    		/*
    		*修改--原等待队列 第一个进程 => 唤醒(就绪)
    		*/
    		fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies);
    	}
    }
    
    修改wake_up函数
    void wake_up(struct task_struct **p)
    {
    	if (p && *p) {
    		(**p).state=0;
    		/*
    		*修改--唤醒 最后进入等待序列的 进程
    		*/
    		fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
    		*p=NULL;
    	}
    }
    

    修改exit.c文件

    当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。

    当一个用户程序调用exit()系统调用时,就会执行内核函数do_exit()。该函数会首先释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的i节点进行同步操作。如果进程有子进程,则让init进程作为其所有子进程的父进程。如果进程是一个会话头进程并且有控制终端,则释放控制终端(如果按照实验的数据,此时就应该打印了),并向属于该会话的所有进程发送挂断信号 SIGHUP,这通常会终止该会话中的所有进程。然后把进程状态置为僵死状态 TASK_ZOMBIE。并向其原父进程发送 SIGCHLD 信号,通知其某个子进程已经终止。最后 do_exit()调用调度函数去执行其他进程。由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。

    在子进程在执行期间,父进程通常使用wait()waitpid()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。

    int do_exit(long code)
    {
    	int i;
    	free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
    	free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
    	//    ……
    	
    	current->state = TASK_ZOMBIE;
    	/*
    	*修改--退出一个进程
    	*/
    	fprintk(3,"%d\tE\t%d\n",current->pid,jiffies);
    	current->exit_code = code;
    	tell_father(current->father);
    	schedule();
    	return (-1);	/* just to suppress warnings */
    }
    //    ……
    int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options)
    {
    	int flag, code;
    	struct task_struct ** p;
    //    ……
    //    ……
    	if (flag) {
    		if (options & WNOHANG)
    			return 0;
    		current->state=TASK_INTERRUPTIBLE;
    		/*
    		*修改--当前进程 => 等待
    		*/
    		fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
    		schedule();
    		if (!(current->signal &= ~(1<<(SIGCHLD-1))))
    			goto repeat;
    		else
    			return -EINTR;
    	}
    	return -ECHILD;
    }
    

    小结

    总的来说,Linux 0.11 支持四种进程状态的转移:就绪到运行、运行到就绪、运行到睡眠和睡眠到就绪,此外还有新建和退出两种情况。其中就绪与运行间的状态转移是通过 schedule()(它亦是调度算法所在)完成的;运行到睡眠依靠的是 sleep_on()interruptible_sleep_on(),还有进程主动睡觉的系统调用 sys_pause()sys_waitpid();睡眠到就绪的转移依靠的是 wake_up()。所以只要在这些函数的适当位置插入适当的处理语句就能完成进程运行轨迹的全面跟踪了。

    为了让生成的 log 文件更精准,以下几点请注意:

    • 进程退出的最后一步是通知父进程自己的退出,目的是唤醒正在等待此事件的父进程。从时序上来说,应该是子进程先退出,父进程才醒来。
    • 系统无事可做的时候,进程 0 会不停地调用 sys_pause(),以激活调度算法。此时它的状态可以是等待态,等待有其它可运行的进程;也可以叫运行态,因为它是唯一一个在 CPU 上运行的进程,只不过运行的效果是等待。

    编译

    重新编译

    make all
    

    image-20210904210843212

    编译运行process.c

    将process.c拷贝到linux0.11系统中,这个过程需要挂载一下系统硬盘,挂载拷贝成功之后再卸载硬盘,然后启动模拟器进入系统内编译一下process.c文件,过程命令及截图如下:

    // oslab目录下运行
    sudo ./mount-hdc 
    cp ./test3/process.c ./hdc/usr/root/
    sudo umonut hdc
    

    进入linux-0.11

    gcc -o process process.c
    ./process
    sync
    

    使用./process即可运行目标文件,运行后会生成log文件,生成log文件后一定要记得刷新,然后将其拷贝到oslab/test3目录,命令如下:

    sudo ./mount-hdc 
    cp ./hdc/var/process.log ./test3/
    sudo umonut hdc
    

    image-20210904220922036

    process.log自动化分析

    实验楼stat_log.py下载地址

    只要给 stat_log.py 加上执行权限(使用的命令为 chmod +x stat_log.py)就可以直接运行它。

    在结果中我们可以看到各个进程的周转时间(Turnaround,指作业从提交到完成所用的总时间)、等待时间等,以及平均周转时间和等待时间。

    修改时间片

    MOOC哈工大操作系统实验3:进程运行轨迹的跟踪与统计_ZhaoTianhao的博客-CSDN博客

    这段没有耐心实现了,摘录了一位博主的解释

    linux0.11采用的调度算法是一种综合考虑进程优先级并能动态反馈调整时间片的轮转调度算法。 那么什么是轮转调度算法呢?它为每个进程分配一个时间段,称作它的时间片,即该进程允许运行的时间。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程;如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,它被移到队列的末尾。那什么是综合考虑进程优先级呢?就是说一个进程在阻塞队列中停留的时间越长,它的优先级就越大,下次就会被分配更大的时间片。
    进程之间的切换是需要时间的,如果时间片设定得太小的话,就会发生频繁的进程切换,因此会浪费大量时间在进程切换上,影响效率;如果时间片设定得足够大的话,就不会浪费时间在进程切换上,利用率会更高,但是用户交互性会受到影响,举一个很直观的例子:我在银行排队办业务,假设我要办的业务很简单只需要占用1分钟,如果每个人的时间片是30分钟,而我前面的每个人都要用满这30分钟,那我就要等上好几个小时!如果每个人的时间片是2分钟的话,我只需要等十几分钟就可以办理我的业务了,前面没办完的会在我之后轮流地继续去办。所以时间片不能过大或过小,要兼顾CPU利用率和用户交互性。
    时间片的初始值是进程0的priority,是在linux-0.11/include/linux/sched.h的宏 INIT_TASK 中定义的,如下:我们只需要修改宏中的第三个值即可,该值即时间片的初始值。

    #define INIT_TASK \
        { 0,15,15, 
    // 上述三个值分别对应 state、counter 和 priority;
    

    修改完后再次编译make all,进入模拟器后编译运行测试文件process.c,然后运行统计脚本stat_log.py查看结果,与之前的结果进行对比。

    问题回答

    问题1:单进程编程和多进程编程的区别?

    1.执行方式:单进程编程是一个进程从上到下顺序进行;多进程编程可以通过并发执行,即多个进程之间交替执行,如某一个进程正在I/O输入输出而不占用CPU时,可以让CPU去执行另外一个进程,这需要采取某种调度算法。

    2.数据是否同步:单进程的数据是同步的,因为单进程只有一个进程,在进程中改变数据的话,是会影响这个进程的;多进程的数据是异步的,因为子进程数据是父进程数据在内存另一个位置的拷贝,因此改变其中一个进程的数据,是不会影响到另一个进程的。

    3.CPU利用率:单进程编程的CPU利用率低,因为单进程在等待I/O时,CPU是空闲的;多进程编程的CPU利用率高,因为当某一进程等待I/O时,CPU会去执行另一个进程,因此CPU的利用率高。

    4.多进程用途更广泛。

    问题2:仅针对样本程序建立的进程,在修改时间片前后,log 文件的统计结果都是什么样?结合你的修改分析一下为什么会这样变化,或者为什么没变化?

    依次将时间偏设为1,5,10,15,20,25,50,100,150后,经统计分析log文件可以发现:
    1)在一定的范围内,平均等待时间,平均完成时间的变化随着时间片的增大而减小。这是因为在时间片小的情况下,cpu将时间耗费在调度切换上,所以平均等待时间增加。
    2)超过一定的范围之后,这些参数将不再有明显的变化,这是因为在这种情况下,RR轮转调度就变成了FCFS先来先服务了。随着时间片的修改,吞吐量始终没有明显的变化,这是因为在单位时间内,系统所能完成的进程数量是不会变的。

    警示

    编译好后进入linux-0.11

    image-20210904213110368

    直接报了内核错误,这肯定是之前的代码打错了。那么多代码,我怎么知道错误在哪。但是注意以前正常情况下会打印剩余空间。

    image-20210904213651430

    而先前改代码的时候发现这段打印的代码就在进程1 init() 函数内,所以推断是修改进程0时出现了错误

    image-20210904213915766

    好家伙,顺序反了。之前说了,文件系统初始化,描述符 0、1 和 2 关联之后,才能打开 log 文件。这里却直接先打开 log 文件了。😢

    天道酬勤

    粗略的了解了进程运行的方式,多进程的好处显而易见,大大节省了效率,但其调度算法如何实现可以尽可能缩短浪费的时间还是需要好好思考的。

    实验五 基于内核栈切换的进程切换

    实验目的

    • 深入理解进程和进程切换的概念;
    • 综合应用进程、CPU 管理、PCB、LDT、内核栈、内核态等知识解决实际问题;
    • 开始建立系统认识。

    实验内容

    现在的 Linux 0.11 采用 TSS 和一条指令就能完成任务切换,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要 200 多个时钟周期。

    而通过堆栈实现任务切换可能要更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得 CPU 的设计变得简单。所以无论是 Linux 还是 Windows,进程/线程的切换都没有使用 Intel 提供的这种 TSS 切换手段,而都是通过堆栈实现的。

    本次实践项目就是将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是基于堆栈的切换程序。具体的说,就是将 Linux 0.11 中的 switch_to 实现去掉,写成一段基于堆栈切换的代码。

    本次实验包括如下内容:

    • 编写汇编程序 switch_to
    • 完成主体框架;
    • 在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等;
    • 修改 fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
    • 修改 PCB,即 task_struct 结构,增加相应的内容域,同时处理由于修改了 task_struct 所造成的影响。
    • 用修改后的 Linux 0.11 仍然可以启动、可以正常使用。

    TSS 切换

    在现在的 Linux 0.11 中,真正完成进程切换是依靠任务状态段(Task State Segment,简称 TSS)的切换来完成的。

    具体的说,在设计“Intel 架构”(即 x86 系统结构)时,每个任务(进程或线程)都对应一个独立的 TSS,TSS 就是内存中的一个结构体,里面包含了几乎所有的 CPU 寄存器的映像。有一个任务寄存器(Task Register,简称 TR)指向当前进程对应的 TSS 结构体,所谓的 TSS 切换就将 CPU 中几乎所有的寄存器都复制到 TR 指向的那个 TSS 结构体中保存起来,同时找到一个目标 TSS,即要切换到的下一个进程对应的 TSS,将其中存放的寄存器映像“扣在” CPU 上,就完成了执行现场的切换,如下图所示。

    图片描述信息

    Intel 架构不仅提供了 TSS 来实现任务切换,而且只要一条指令就能完成这样的切换,即图中的 ljmp 指令。

    具体的工作过程是:

    • (1)首先用 TR 中存取的段选择符在 GDT 表中找到当前 TSS 的内存位置,由于 TSS 是一个段,所以需要用段表中的一个描述符来表示这个段,和在系统启动时论述的内核代码段是一样的,那个段用 GDT 中的某个表项来描述,还记得是哪项吗?是 8 对应的第 1 项。此处的 TSS 也是用 GDT 中的某个表项描述,而 TR 寄存器是用来表示这个段用 GDT 表中的哪一项来描述,所以 TR 和 CS、DS 等寄存器的功能是完全类似的。
    • (2)找到了当前的 TSS 段(就是一段内存区域)以后,将 CPU 中的寄存器映像存放到这段内存区域中,即拍了一个快照。
    • (3)存放了当前进程的执行现场以后,接下来要找到目标进程的现场,并将其扣在 CPU 上,找目标 TSS 段的方法也是一样的,因为找段都要从一个描述符表中找,描述 TSS 的描述符放在 GDT 表中,所以找目标 TSS 段也要靠 GDT 表,当然只要给出目标 TSS 段对应的描述符在 GDT 表中存放的位置——段选择子就可以了,仔细想想系统启动时那条著名的 jmpi 0, 8 指令,这个段选择子就放在 ljmp 的参数中,实际上就 jmpi 0, 8 中的 8。
    • (4)一旦将目标 TSS 中的全部寄存器映像扣在 CPU 上,就相当于切换到了目标进程的执行现场了,因为那里有目标进程停下时的 CS:EIP,所以此时就开始从目标进程停下时的那个 CS:EIP 处开始执行,现在目标进程就变成了当前进程,所以 TR 需要修改为目标 TSS 段在 GDT 表中的段描述符所在的位置,因为 TR 总是指向当前 TSS 段的段描述符所在的位置。

    上面给出的这些工作都是一句长跳转指令 ljmp 段选择子:段内偏移,在段选择子指向的段描述符是 TSS 段时 CPU 解释执行的结果,所以基于 TSS 进行进程/线程切换的 switch_to 实际上就是一句 ljmp 指令:

    #define switch_to(n) {
        struct{long a,b;} tmp;
        __asm__(
            "movw %%dx,%1"
            "ljmp %0" ::"m"(*&tmp.a), "m"(*&tmp.b), "d"(TSS(n)
        )
     }
    
    #define FIRST_TSS_ENTRY 4
    
    #define TSS(n) (((unsigned long) n) << 4) + (FIRST_TSS_ENTRY << 3))
    

    GDT 表的结构如下图所示,所以第一个 TSS 表项,即 0 号进程的 TSS 表项在第 4 个位置上,4<<3,即 4 * 8,相当于 TSS 在 GDT 表中开始的位置,TSS(n)找到的是进程 n 的 TSS 位置,所以还要再加上 n<<4,即 n * 16,因为每个进程对应有 1 个 TSS 和 1 个 LDT,每个描述符的长度都是 8 个字节,所以是乘以 16,其中 LDT 的作用就是上面论述的那个映射表,关于这个表的详细论述要等到内存管理一章。TSS(n) = n * 16 + 4 * 8,得到就是进程 n(切换到的目标进程)的 TSS 选择子,将这个值放到 dx 寄存器中,并且又放置到结构体 tmp 中 32 位长整数 b 的前 16 位,现在 64 位 tmp 中的内容是前 32 位为空,这个 32 位数字是段内偏移,就是 jmpi 0, 8 中的 0;接下来的 16 位是 n * 16 + 4 * 8,这个数字是段选择子,就是 jmpi 0, 8 中的 8,再接下来的 16 位也为空。所以 swith_to 的核心实际上就是 ljmp 空, n*16+4*8,现在和前面给出的基于 TSS 的进程切换联系在一起了。

    图片描述信息

    本次实验的内容

    虽然用一条指令就能完成任务切换,但这指令的执行时间却很长,这条 ljmp 指令在实现任务切换时大概需要 200 多个时钟周期。而通过堆栈实现任务切换可能要更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得 CPU 的设计变得简单。所以无论是 Linux 还是 Windows,进程/线程的切换都没有使用 Intel 提供的这种 TSS 切换手段,而都是通过堆栈实现的。

    本次实践项目就是将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是基于堆栈的切换程序。具体的说,就是将 Linux 0.11 中的 switch_to 实现去掉,写成一段基于堆栈切换的代码。

    在现在的 Linux 0.11 中,真正完成进程切换是依靠任务状态段(Task State Segment,简称 TSS)的切换来完成的。具体的说,在设计“Intel 架构”(即 x86 系统结构)时,每个任务(进程或线程)都对应一个独立的 TSS,TSS 就是内存中的一个结构体,里面包含了几乎所有的 CPU 寄存器的映像。有一个任务寄存器(Task Register,简称 TR)指向当前进程对应的 TSS 结构体,所谓的 TSS 切换就将 CPU 中几乎所有的寄存器都复制到 TR 指向的那个 TSS 结构体中保存起来,同时找到一个目标 TSS,即要切换到的下一个进程对应的 TSS,将其中存放的寄存器映像“扣在”CPU 上,就完成了执行现场的切换。

    要实现基于内核栈的任务切换,主要完成如下三件工作:

    • (1)重写 switch_to
    • (2)将重写的 switch_toschedule() 函数接在一起;
    • (3)修改现在的 fork()

    正式修改代码前小结

    下面开始正式修改代码。其实主要就修改3个文件,但我不会按照每个文件一次性修改的顺序,而是按照实验逻辑,跳转修改,请保持清晰的逻辑。在截图的右下角有当前位置所在行数,注意观察不要修改错位置。

    schedule 与 switch_to

    目前 Linux 0.11 中工作的 schedule() 函数是首先找到下一个进程的数组位置 next,而这个 next 就是 GDT 中的 n,所以这个 next 是用来找到切换后目标 TSS 段的段描述符的,一旦获得了这个 next 值,直接调用上面剖析的那个宏展开 switch_to(next);就能完成如图 TSS 切换所示的切换了。

    现在,我们不用 TSS 进行切换,而是采用切换内核栈的方式来完成进程切换,所以在新的 switch_to 中将用到当前进程的 PCB、目标进程的 PCB、当前进程的内核栈、目标进程的内核栈等信息。由于 Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址;另外,由于当前进程的 PCB 是用一个全局变量 current 指向的,所以只要告诉新 switch_to()函数一个指向目标进程 PCB 的指针就可以了。同时还要将 next 也传递进去,虽然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的,也就是说,现在每个进程不用有自己的 TSS 了,因为已经不采用 TSS 进程切换了,但是每个进程需要有自己的 LDT,地址分离地址还是必须要有的,而进程切换必然要涉及到 LDT 的切换。

    综上所述,需要将目前的 schedule() 函数(在 kernel/sched.c 中)做稍许修改,即将下面的代码:

    if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
        c = (*p)->counter, next = i;
    
    //......
    
    switch_to(next);
    

    修改为:

    if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
        c = (*p)->counter, next = i, pnext = *p;
    
    //.......
    
    switch_to(pnext,_LDT(next));
    

    image-20210912125612965

    注意 pnext 是指向 pcb 的指针

    struct tast_struct *pnext = &(init_task.task);
    

    image-20210912133411152

    使用 switch_to 需要添加函数声明

    extern long switch_to(struct task_struct *p, unsigned long address);
    

    image-20210912124516055

    实现 switch_to

    实现 switch_to 是本次实践项目中最重要的一部分。

    由于要对内核栈进行精细的操作,所以需要用汇编代码来完成函数 switch_to 的编写。

    这个函数依次主要完成如下功能:由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;接下来要取出表示下一个进程 PCB 的参数,并和 current 做一个比较,如果等于 current,则什么也不用做;如果不等于 current,就开始进程切换,依次完成 PCB 的切换、TSS 中的内核栈指针的重写、内核栈的切换、LDT 的切换以及 PC 指针(即 CS:EIP)的切换。

    可以很明显的看出,该函数是基于TSS进行进程切换的(ljmp指令),
    现在要改写成基于堆栈(内核栈)切换的函数,就需要删除掉该语句,在include/linux/sched.h 文件,我们将它注释掉。

    image-20210912120209360

    然后新的switch_to()函数将它作为一个系统调用函数,所以要将函数重写在汇编文件kernel/system_call.s:

    .align 2
    switch_to:
        //因为该汇编函数要在c语言中调用,所以要先在汇编中处理栈帧
    	pushl %ebp
    	movl %esp,%ebp
    	pushl %ecx
    	pushl %ebx
    	pushl %eax
    
        //先得到目标进程的pcb,然后进行判断
        //如果目标进程的pcb(存放在ebp寄存器中) 等于   当前进程的pcb => 不需要进行切换,直接退出函数调用
        //如果目标进程的pcb(存放在ebp寄存器中) 不等于 当前进程的pcb => 需要进行切换,直接跳到下面去执行
    	movl 8(%ebp),%ebx
    	cmpl %ebx,current
    	je 1f
    
        /** 执行到此处,就要进行真正的基于堆栈的进程切换了 */
    	
            // PCB的切换
    	movl %ebx,%eax
    	xchgl %eax,current
    	
    	// TSS中内核栈指针的重写
    	movl tss,%ecx
    	addl $4096,%ebx
    	movl %ebx,ESP0(%ecx)
    
    	//切换内核栈
    	movl %esp,KERNEL_STACK(%eax)
    	movl 8(%ebp),%ebx
    	movl KERNEL_STACK(%ebx),%esp
    
    	//LDT的切换
    	movl 12(%ebp),%ecx
    	lldt %cx
    	movl $0x17,%ecx
    	mov %cx,%fs
    	
    	cmpl %eax,last_task_used_math
    	jne 1f
    	clts
    	
    	//在到子进程的内核栈开始工作了,接下来做的四次弹栈以及ret处理使用的都是子进程内核栈中的东西
    
    1:	popl %eax
    	popl %ebx
    	popl %ecx
    	popl %ebp
    	ret
    

    image-20210911212856156

    逐条解释基于堆栈切换的switch_to()函数四段核心代码:

    // PCB的切换
    movl %ebx,%eax
    xchgl %eax,current
    
    起始时eax寄存器保存了指向目标进程的指针,current指向了当前进程,
    第一条指令执行完毕,使得ebx也指向了目标进程,
    然后第二条指令开始执行,也就是将eax的值和current的值进行了交换,最终使得eax指向了当前进程,current就指向了目标进程(当前状态就发生了转移)
    
    // TSS中内核栈指针的重写
    movl tss,%ecx
    addl $4096,%ebx
    movl %ebx,ESP0(%ecx)
    
    中断处理时需要寻找当前进程的内核栈,否则就不能从用户栈切到内核栈(中断处理没法完成),
    内核栈的寻找是借助当前进程TSS中存放的信息来完成的,(当然,当前进程的TSS还是通过TR寄存器在GDT全局描述符表中找到的)。
    
    虽然此时不使用TSS进行进程切换了,但是Intel的中断处理机制还是要保持。
    所以每个进程仍然需要一个TSS,操作系统需要有一个当前TSS。
    这里采用的方案是让所有进程共用一个TSS(这里使用0号进程的TSS),
    因此需要定义一个全局指针变量tss(放在system_call.s中)来执行0号进程的TSS:
    struct tss_struct * tss = &(init_task.task.tss);
    
    此时唯一的tss的目的就是:在中断处理时,能够找到当前进程的内核栈的位置。
    
    在内核栈指针重写指令中有宏定义ESP0,所以在上面需要提前定义好 ESP0 = 4,
    (定义为4是因为TSS中内核栈指针ESP0就放在偏移为4的地方)
    并且需要将: blocked=(33*16) => blocked=(33*16+4) 
    
    
    • kernel/system_call.s 文件

      重写TSS中的内核栈指针

      ESP0 = 4
      KERNEL_STACK = 12
      
      state   = 0     # these are offsets into the task-struct.
      counter = 4
      priority = 8
      kernelstack = 12
      signal  = 16
      sigaction = 20      # MUST be 16 (=len of sigaction)
      blocked = (37*16)
      

      image-20210912130927944

    • kernel/sched.c 文件

      struct tss_struct * tss = &(init_task.task.tss);
      

      image-20210912132329744

    //切换内核栈
    movl %esp,KERNEL_STACK(%eax)
    movl 8(%ebp),%ebx
    movl KERNEL_STACK(%ebx),%esp
    
    第一行:将cpu寄存器esp的值,保存到当前进程pcb的eax寄存器中(保存当前进程执行信息)
    第二行:获取目标进程的pcb放入ebx寄存器中
    第三行:将ebx寄存器中的信息,也就是目标进程的信息,放入cpu寄存器esp中
    
    但是之前的进程控制块(pcb)中是没有保存内核栈信息的寄存器的,所以需要在sched.h中的task_struct(也就是pcb)中添加kernelstack,
    但是添加的位置就有讲究了,因为在某些汇编文件(主要是systen_call.s中),有操作这个结构的硬编码,
    一旦结构体信息改变,那这些硬编码也要跟着改变,
    比如添加kernelstack在第一行,就需要改很多信息了,
    但是添加到第四行就不需要改很多信息,所以这里将kernelstack放到第四行的位置:
    struct task_struct {
    /* these are hardcoded - don't touch */
    	long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
    	long counter;
    	long priority;
    	/** add  kernelstack */
    	long kernelstack;
        ...
    }
    
    改动位置及信息:
    将
    #define INIT_TASK \
    /* state etc */	{ 0,15,15,\
    /* signals */	0,{{},},0, \
    ...
    改为:
    #define INIT_TASK \
    /* state etc */	{ 0,15,15, PAGE_SIZE+(long)&init_task,\
    /* signals */	0,{{},},0, \
    ...
    
    在执行上述切换内核栈的代码之前(也就是switch_to()函数前),要设置栈的大小:KERNEL_STACK = 12
    然后就执行上面的三行代码,就可以完成对内核栈的切换了。
    
    • include/linux/sched.h 文件

      long kernelstack;
      

      image-20210911220001621

      由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化, 需要修改 #define INIT_TASK,即在 PCB 的第四项中增加关于内核栈栈指针的初始化。代码如下:

      #define INIT_TASK \
      /* state etc */	{ 0,15,15,\
      /* signals */	0,{{},},0, \
      ...
      改为:
      #define INIT_TASK \
      /* state etc */	{ 0,15,15, PAGE_SIZE+(long)&init_task,\
      /* signals */	0,{{},},0, \
      ...
      

      image-20210911220418785

    • kernel/system_call.s

      KERNEL_STACK = 12
      

      image-20210911220802195

    //LDT的切换
    movl 12(%ebp),%ecx
    lldt %cx
    movl $0x17,%ecx
    mov %cx,%fs
    
    前两条语句的作用(切换LDT):
    第一条:取出参数LDT(next)
    第二条:完成对LDTR寄存器的修改
    
    然后就是对PC指针(即CS:EIP)的切换:
    后两条语句的含有就是重写设置段寄存器FS的值为0x17
    
    补:FS的作用:通过FS操作系统才能访问进程的用户态内存。
    这里LDT切换完成意味着切换到了新的用户态地址空间,所以需要重置FS。
    

    修改fork()系统调用

    现在需要将新建进程的用户栈、用户程序地址和其内核栈关联在一起,因为TSS没有做这样的关联fork()要求让父子进程共享用户代码、用户数据和用户堆栈虽然现在是使用内核栈完成任务的切换(基于堆栈的进程切换),但是fork()的基本含义不应该发生变化。

    综合分析:

    修改以后的fork()要使得父子进程共享同一块内存空间、堆栈和数据代码块。

    fork() 系统调用的代码放在 system_call.s 汇编文件中,先来看看已经写好的代码:

    .align 2
    sys_fork:
    	call find_empty_process
    	testl %eax,%eax
    	js 1f
    	push %gs
    	pushl %esi
    	pushl %edi
    	pushl %ebp
    	pushl %eax
    	call copy_process//跳转到copy_process()函数
    	addl $20,%esp
    1:	ret
    

    可以看到fork()函数的核心就是调用了 copy_process(),接下来去看copy_process()

    copy_process()函数定义在kernel/fork.c中,代码和分析见注释:

    int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
    		long ebx,long ecx,long edx,
    		long fs,long es,long ds,
    		long eip,long cs,long eflags,long esp,long ss)
    {
    	struct task_struct *p;
    	int i;
    	struct file *f;
    
    	p = (struct task_struct *) get_free_page();//用来完成申请一页内存空间作为子进程的PCB
    	...
    
    	/** 很容易看出来下面的部分就是基于tss进程切换机制时的代码,所以将此片段要注释掉
    
    	p->tss.back_link = 0;
    	p->tss.esp0 = PAGE_SIZE + (long) p;
    	p->tss.ss0 = 0x10;
    	...
    	*/
    	
    	/** 然后这里要加上基于堆栈切换的代码(对frok的修改其实就是对子进程内核栈的初始化 */
    	long *krnstack;
         krnstack = (long)(PAGE_SIZE +(long)p);//p指针加上页面大小就是子进程的内核栈位置,所以这句话就是krnstack指针指向子进程的内核栈
    
    	//初始化内核栈(krnstack)中的内容:
    	//下面的五句话可以完成对书上那个图(4.22)所示的关联效果(父子进程共有同一内存、堆栈和数据代码块)
    	/*
    	而且很容易可以看到,ss,esp,elags,cs,eip这些参数来自调用该函数的进程的内核栈中,
    	也就是父进程的内核栈,所以下面的指令就是将父进程内核栈的前五个内容拷贝到了子进程的内核栈中
    	*/
    	*(--krnstack) = ss & 0xffff;
    	*(--krnstack) = esp;
    	*(--krnstack) = eflags;
    	*(--krnstack) = cs & 0xffff;
    	*(--krnstack) = eip;
    
        *(--krnstack) = ds & 0xffff;
        *(--krnstack) = es & 0xffff;
        *(--krnstack) = fs & 0xffff;
        *(--krnstack) = gs & 0xffff;
        *(--krnstack) = esi;
        *(--krnstack) = edi;
        *(--krnstack) = edx;
        
    	*(--krnstack) = (long) first_return_kernel;//处理switch_to返回的位置
    
    	*(--krnstack) = ebp;
    	*(--krnstack) = ecx;
    	*(--krnstack) = ebx;
    	*(--krnstack) = 0;
    
    	//把switch_to中要的东西存进去
    	p->kernelstack = krnstack;
    	...
    
    • kernel/fork.c 文件

      注释tss进程切换片段

      image-20210912113220486

      添加代码

      	long *krnstack;
          krnstack = (long)(PAGE_SIZE +(long)p);
      
      	*(--krnstack) = ss & 0xffff;
      	*(--krnstack) = esp;
      	*(--krnstack) = eflags;
      	*(--krnstack) = cs & 0xffff;
      	*(--krnstack) = eip;
      
      	*(--krnstack) = ds & 0xffff;
          *(--krnstack) = es & 0xffff;
          *(--krnstack) = fs & 0xffff;
          *(--krnstack) = gs & 0xffff;
          *(--krnstack) = esi;
          *(--krnstack) = edi;
          *(--krnstack) = edx;
      
      	*(--krnstack) = (long) first_return_kernel;
      
      	*(--krnstack) = ebp;
      	*(--krnstack) = ecx;
      	*(--krnstack) = ebx;
      	*(--krnstack) = 0;
      
      	p->kernelstack = krnstack;
      

    上面的first_return_kernel(系统调用)的工作:
    "内核级线程切换五段论"中的最后一段切换,即完成用户栈和用户代码的切换,依靠的核心指令就是iret,当然在切换之前应该恢复一下执行现场,主要就是eax,ebx,ecx,edx,esi,gs,fs,es,ds这些寄存器的恢复,

    要将first_return_kernel(属于系统调用,而且是一段汇编代码)写在kernel/system_call.s头文件里面:

    首先需要将first_return_kernel设置在全局可见:

    .globl switch_to,first_return_kernel
    

    image-20210912115449194

    将具体的函数实现放在system_call.s头文件里面:

    .align 2
    first_return_kernel:
     popl %edx
     popl %edi
     popl %esi
     pop %gs
     pop %fs
     pop %es
     pop %ds
     iret
    

    image-20210912134215661

    最后要记得是在 kernel/fork.c 文件里使用了 first_return_kernel 函数,所以要在该文件里添加外部函数声明

    extern void first_return_kernel(void);
    

    image-20210912115701559

    编译运行

    image-20210912140916907

    天道酬勤

    有些人不会写文章就不要发,参考的几篇全有错误,最后居然还能有运行成功的截图,你们编译怎么通过的?真牛!虽然我文章都是看的别人的,但我至少保证自己独立运行一遍,能保证运行通过。幸好最后碰到一位博主,看了他的文章自惭形秽。写的特别简洁但重点突出,有自己的理解。反观自己废话连篇,文章东拼西凑。

    强烈推荐该博主哈工大-操作系统-HitOSlab-李治军-实验4-基于内核栈切换的进程切换_garbage_man的博客-CSDN博客

    也可以看看这位博主的学习笔记,加深对线程了解操作系统学习笔记——用户级线程和核心级线程_garbage_man的博客-CSDN博客

    实验六 信号量的实现和应用

    实验目的

    • 加深对进程同步与互斥概念的认识;
    • 掌握信号量的使用,并应用它解决生产者——消费者问题;
    • 掌握信号量的实现原理。

    实验内容

    本次实验的基本内容是:

    • 在 Ubuntu 下编写程序,用信号量解决生产者——消费者问题;
    • 在 0.11 中实现信号量,用生产者—消费者程序检验之。

    用信号量解决生产者—消费者问题

    在 Ubuntu 上编写应用程序“pc.c”,解决经典的生产者—消费者问题,完成下面的功能:

    • 建立一个生产者进程,N 个消费者进程(N>1);
    • 用文件建立一个共享缓冲区;
    • 生产者进程依次向缓冲区写入整数 0,1,2,…,M,M>=500;
    • 消费者进程从缓冲区读数,每次读一个,并将读出的数字从缓冲区删除,然后将本进程 ID 和 + 数字输出到标准输出;
    • 缓冲区同时最多只能保存 10 个数。

    一种可能的输出效果是:

    10: 0
    10: 1
    10: 2
    10: 3
    10: 4
    11: 5
    11: 6
    12: 7
    10: 8
    12: 9
    12: 10
    12: 11
    12: 12
    ……
    11: 498
    11: 499
    

    其中 ID 的顺序会有较大变化,但冒号后的数字一定是从 0 开始递增加一的。

    pc.c 中将会用到 sem_open()sem_close()sem_wait()sem_post() 等信号量相关的系统调用,请查阅相关文档。

    实现信号量

    Linux 在 0.11 版还没有实现信号量,Linus 把这件富有挑战的工作留给了你。如果能实现一套山寨版的完全符合 POSIX 规范的信号量,无疑是很有成就感的。但时间暂时不允许我们这么做,所以先弄一套缩水版的类 POSIX 信号量,它的函数原型和标准并不完全相同,而且只包含如下系统调用:

    sem_t *sem_open(const char *name, unsigned int value);
    int sem_wait(sem_t *sem);
    int sem_post(sem_t *sem);
    int sem_unlink(const char *name);
    
    • sem_open()​ 的功能是创建一个信号量,或打开一个已经存在的信号量。

      • sem_t 是信号量类型,根据实现的需要自定义。
      • name 是信号量的名字。不同的进程可以通过提供同样的 name 而共享同一个信号量。如果该信号量不存在,就创建新的名为 name 的信号量;如果存在,就打开已经存在的名为 name 的信号量。
      • value 是信号量的初值,仅当新建信号量时,此参数才有效,其余情况下它被忽略。当成功时,返回值是该信号量的唯一标识(比如,在内核的地址、ID 等),由另两个系统调用使用。如失败,返回值是 NULL。
    • sem_wait() 就是信号量的 P 原子操作。如果继续运行的条件不满足,则令调用进程等待在信号量 sem 上。返回 0 表示成功,返回 -1 表示失败。

    • sem_post() 就是信号量的 V 原子操作。如果有等待 sem 的进程,它会唤醒其中的一个。返回 0 表示成功,返回 -1 表示失败。

    • sem_unlink() 的功能是删除名为 name 的信号量。返回 0 表示成功,返回 -1 表示失败。

    kernel 目录下新建 sem.c 文件实现如上功能。然后将 pc.c 从 Ubuntu 移植到 0.11 下,测试自己实现的信号量。

    什么是信号量?

    信号量,英文为 semaphore,最早由荷兰科学家、图灵奖获得者 E. W. Dijkstra 设计,任何操作系统教科书的“进程同步”部分都会有详细叙述。

    Linux 的信号量秉承 POSIX 规范,用man sem_overview可以查看相关信息。

    本次实验涉及到的信号量系统调用包括:sem_open()sem_wait()sem_post()sem_unlink()

    生产者—消费者问题

    生产者—消费者问题的解法几乎在所有操作系统教科书上都有,其基本结构为:

    Producer()
    {
        // 生产一个产品 item;
    
        // 空闲缓存资源
        P(Empty);
    
        // 互斥信号量
        P(Mutex);
    
        // 将item放到空闲缓存中;
        V(Mutex);
    
        // 产品资源
        V(Full);
    }
    
    Consumer()
    {
        P(Full);
        P(Mutex);
    
        //从缓存区取出一个赋值给item;
        V(Mutex);
    
        // 消费产品item;
        V(Empty);
    }
    

    显然在演示这一过程时需要创建两类进程,一类执行函数 Producer(),另一类执行函数 Consumer()

    思路

    大家可以参考这个演示视频,先总体梳理一遍。如果实验三系统调用学的不错的话,这里的系统调用编写和编译应该是很清晰的。

    通俗的讲,在用户程序中想要使用内核态中的系统调用命令,需要在用户态执行对系统调用命令的调用

    image-20211020150559446

    通过宏展开

    image-20211020150719338

    image-20211020150742304

    由此通过著名的 int 0x80 中断进入内核态

    用户程序 pc.c

    知识点

    文件操作

    image-20211021215904645

    image-20211021215715628

    信号量作用

    • mutex 是保证互斥访问缓存池
    • empty 是缓冲池里空位的剩余个数,即空缓冲区数,初始值为n
    • full 是用来记录当前缓冲池中已经占用的缓冲区个数,初始值为0

    代码展示

    
    #define __LIBRARY__
    #include <unistd.h>
    #include <linux/sem.h>
    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <linux/sched.h>
    
    _syscall2(sem_t *,sem_open,const char *,name,unsigned int,value)
    _syscall1(int,sem_wait,sem_t *,sem)
    _syscall1(int,sem_post,sem_t *,sem)
    _syscall1(int,sem_unlink,const char *,name)
    
    const char *FILENAME = "/usr/root/buffer_file";    /* 消费生产的产品存放的缓冲文件的路径 */
    const int NR_CONSUMERS = 5;                        /* 消费者的数量 */
    const int NR_ITEMS = 50;                        /* 产品的最大量 */
    const int BUFFER_SIZE = 10;                        /* 缓冲区大小,表示可同时存在的产品数量 */
    sem_t *metux, *full, *empty;                    /* 3个信号量 */
    unsigned int item_pro, item_used;                /* 刚生产的产品号;刚消费的产品号 */
    int fi, fo;                                        /* 供生产者写入或消费者读取的缓冲文件的句柄 */
    
    
    int main(int argc, char *argv[])
    {
        char *filename;
        int pid;
        int i;
    
        filename = argc > 1 ? argv[1] : FILENAME;
        /* O_TRUNC 表示:当文件以只读或只写打开时,若文件存在,则将其长度截为0(即清空文件)
         * 0222 和 0444 分别表示文件只写和只读(前面的0是八进制标识)
         */
        fi = open(filename, O_CREAT| O_TRUNC| O_WRONLY, 0222);    /* 以只写方式打开文件给生产者写入产品编号 */
        fo = open(filename, O_TRUNC| O_RDONLY, 0444);            /* 以只读方式打开文件给消费者读出产品编号 */
    
        metux = sem_open("METUX", 1);    /* 互斥信号量,防止生产消费同时进行 */
        full = sem_open("FULL", 0);        /* 产品剩余信号量,大于0则可消费 */
        empty = sem_open("EMPTY", BUFFER_SIZE);    /* 空信号量,它与产品剩余信号量此消彼长,大于0时生产者才能继续生产 */
    
        item_pro = 0;
    
        if ((pid = fork()))    /* 父进程用来执行消费者动作 */
        {
            printf("pid %d:\tproducer created....\n", pid);
            /* printf()输出的信息会先保存到输出缓冲区,并没有马上输出到标准输出(通常为终端控制台)。
             * 为避免偶然因素的影响,我们每次printf()都调用一下stdio.h中的fflush(stdout)
             * 来确保将输出立刻输出到标准输出。
             */
            fflush(stdout);
    
            while (item_pro <= NR_ITEMS)    /* 生产完所需产品 */
            {
                sem_wait(empty);
                sem_wait(metux);
    
                /* 生产完一轮产品(文件缓冲区只能容纳BUFFER_SIZE个产品编号)后
                 * 将缓冲文件的位置指针重新定位到文件首部。
                 */
                if(!(item_pro % BUFFER_SIZE))
                    lseek(fi, 0, 0);
    
                write(fi, (char *) &item_pro, sizeof(item_pro));        /* 写入产品编号 */
                printf("pid %d:\tproduces item %d\n", pid, item_pro);
                fflush(stdout);
                item_pro++;
    
                sem_post(full);        /* 唤醒消费者进程 */
                sem_post(metux);
            }
        }
        else    /* 子进程来创建消费者 */
        {
            i = NR_CONSUMERS;
            while(i--)
            {
                if(!(pid=fork()))    /* 创建i个消费者进程 */
                {
                    pid = getpid();
                    printf("pid %d:\tconsumer %d created....\n", pid, NR_CONSUMERS-i);
                    fflush(stdout);
    
                    while(1)
                    {
                        sem_wait(full);
                        sem_wait(metux);
    
                        /* read()读到文件末尾时返回0,将文件的位置指针重新定位到文件首部 */
                        if(!read(fo, (char *)&item_used, sizeof(item_used)))
                        {
                            lseek(fo, 0, 0);
                            read(fo, (char *)&item_used, sizeof(item_used));
                        }
    
                        printf("pid %d:\tconsumer %d consumes item %d\n", pid, NR_CONSUMERS-i+1, item_used);
                        fflush(stdout);
    
                        sem_post(empty);    /* 唤醒生产者进程 */
                        sem_post(metux);
    
                        if(item_used == NR_ITEMS)    /* 如果已经消费完最后一个商品,则结束 */
                            goto OK;
                    }
                }
            }
        }
    OK:
        close(fi);
        close(fo);
        return 0;
    }
    

    修改内核

    编写 sem.h

    文件位置:oslab/linux-0.11/include/linux

    #ifndef _SEM_H
    #define _SEM_H
    
    #include <linux/sched.h>
    
    #define SEMTABLE_LEN    20
    #define SEM_NAME_LEN    20
    
    
    typedef struct semaphore{
        char name[SEM_NAME_LEN];
        int value;
        struct task_struct *queue;
    } sem_t;
    extern sem_t semtable[SEMTABLE_LEN];
    
    #endif
    

    image-20211022150430997

    image-20211101211843916

    编写 sem.c

    #include <linux/sem.h>
    #include <linux/sched.h>
    #include <unistd.h>
    #include <asm/segment.h>
    #include <linux/tty.h>
    #include <linux/kernel.h>
    #include <linux/fdreg.h>
    #include <asm/system.h>
    #include <asm/io.h>
    //#include <string.h>
    
    sem_t semtable[SEMTABLE_LEN];
    int cnt = 0;
    
    sem_t *sys_sem_open(const char *name,unsigned int value)
    {
        char kernelname[100];   
        int isExist = 0;
        int i=0;
        int name_cnt=0;
        while( get_fs_byte(name+name_cnt) != '\0')
        name_cnt++;
        if(name_cnt>SEM_NAME_LEN)
        return NULL;
        for(i=0;i<name_cnt;i++)
        kernelname[i]=get_fs_byte(name+i);
        int name_len = strlen(kernelname);
        int sem_name_len =0;
        sem_t *p=NULL;
        for(i=0;i<cnt;i++)
        {
            sem_name_len = strlen(semtable[i].name);
            if(sem_name_len == name_len)
            {
                    if( !strcmp(kernelname,semtable[i].name) )
                    {
                        isExist = 1;
                        break;
                    }
            }
        }
        if(isExist == 1)
        {
            p=(sem_t*)(&semtable[i]);
            //printk("find previous name!\n");
        }
        else
        {
            i=0;
            for(i=0;i<name_len;i++)
            {
                semtable[cnt].name[i]=kernelname[i];
            }
            semtable[cnt].value = value;
            p=(sem_t*)(&semtable[cnt]);
             //printk("creat name!\n");
            cnt++;
         }
        return p;
    }
    
    
    int sys_sem_wait(sem_t *sem)
    {
        cli();
        while( sem->value <= 0 )       
            sleep_on(&(sem->queue));   
        sem->value--;               
        sti();
        return 0;
    }
    int sys_sem_post(sem_t *sem)
    {
        cli();
        sem->value++;
        if( (sem->value) <= 1)
            wake_up(&(sem->queue));
        sti();
        return 0;
    }
    
    int sys_sem_unlink(const char *name)
    {
        char kernelname[100];  
        int isExist = 0;
        int i=0;
        int name_cnt=0;
        while( get_fs_byte(name+name_cnt) != '\0')
                name_cnt++;
        if(name_cnt>SEM_NAME_LEN)
                return NULL;
        for(i=0;i<name_cnt;i++)
                kernelname[i]=get_fs_byte(name+i);
        int name_len = strlen(name);
        int sem_name_len =0;
        for(i=0;i<cnt;i++)
        {
            sem_name_len = strlen(semtable[i].name);
            if(sem_name_len == name_len)
            {
                    if( !strcmp(kernelname,semtable[i].name))
                    {
                            isExist = 1;
                            break;
                    }
            }
        }
        if(isExist == 1)
        {
            int tmp=0;
            for(tmp=i;tmp<=cnt;tmp++)
            {
                semtable[tmp]=semtable[tmp+1];
            }
            cnt = cnt-1;
            return 0;
        }
        else
            return -1;
    }
    

    文件位置:oslab/linux-0.11/kernel

    image-20211022205328870

    添加系统调用号

    /* 添加的系统调用号 */
    #define __NR_sem_open 72
    #define __NR_sem_wait 73
    #define __NR_sem_post 74
    #define __NR_sem_unlink 75
    

    文件位置:oslab/linux-0.11/include/unistd.h

    image-20211022210605898

    image-20211022211245623

    改写系统调用数

    nr_system_calls = 76
    

    文件位置:oslab/linux-0.11/kernel/system_call.s

    image-20211022211536792

    image-20211022212943278

    添加系统调用的定义

    /* ... */
    extern int sys_setregid();
    /* 添加的系统调用定义 */
    #include <linux/sem.h>
    extern int sys_sem_open();
    extern int sys_sem_wait();
    extern int sys_sem_post();
    extern int sys_sem_unlink();
    
    /* 在sys_call_table数组中添加系统调用的引用: */
    fn_ptr sys_call_table[] = 
    { sys_setup, sys_exit, sys_fork, sys_read,……, sys_sem_open, sys_sem_wait, sys_sem_post, sys_sem_unlink}

    文件位置:oslab/linux-0.11/include/linux/sys.h

    image-20211022213505003

    image-20211022215552936

    image-20211022214317003

    修改工程文件的编译规则

    /* 第一处 */
    OBJS  = sched.o system_call.o traps.o asm.o fork.o \
            panic.o printk.o vsprintf.o sys.o exit.o \
            signal.o mktime.o sem.o
           
    /* 第二处 */
    ### Dependencies:
    sem.s sem.o: sem.c ../include/linux/kernel.h ../include/unistd.h \
      ../include/linux/sem.h ../include/linux/sched.h
    exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
      ../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
      ../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
      ../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
      ../include/asm/segment.h
    
    

    文件位置:oslab/linux-0.11/kernel/Makefile

    image-20211022214634260

    image-20211022214742145

    image-20211022215123328

    Debug

    image-20211023155037517

    反复查看 sem.h 中的代码,找不到错误。后来看了一下 sem.h 文件,发现拙劣的拼写错误

    image-20211023154327001

    修改后,编译成功!

    挂载文件

    将已经修改的 unistd.hsem.h 文件以及用户文件 pc.c 拷贝到linux-0.11系统中,用于测试实现的信号量

    sudo ./mount-hdc 
    cp ./linux-0.11/include/unistd.h ./hdc/usr/include/
    cp ./linux-0.11/include/linux/sem.h ./hdc/usr/include/linux/
    cp ./pc.c ./hdc/usr/root/
    sudo umount hdc/
    

    测试

    启动新编译的linux-0.11内核,用pc.c测试实现的信号量

    gcc -o pc pc.c
    ./pc > wcf.txt
    sync
    

    结果展示:

    image-20211101214610618

    天道酬勤

    Debug好几天,也看不出来哪里有问题,索性完全复制了一份别人的,这是做的最失败的一次实验,只能保证运行结果成功。

    复制的博主源码

    实验七 地址映射与共享

    实验目的

    • 深入理解操作系统的段、页式内存管理,深入理解段表、页表、逻辑地址、线性地址、物理地址等概念;
    • 实践段、页式内存管理的地址映射过程;
    • 编程实现段、页式内存管理上的内存共享,从而深入理解操作系统的内存管理。

    实验内容

    • 用 Bochs 调试工具跟踪 Linux 0.11 的地址翻译(地址映射)过程,了解 IA-32 和 Linux 0.11 的内存管理机制;
    • 在 Ubuntu 上编写多进程的生产者—消费者程序,用共享内存做缓冲区;
    • 在信号量实验的基础上,为 Linux 0.11 增加共享内存功能,并将生产者—消费者程序移植到 Linux 0.11。

    1 跟踪地址翻译过程

    首先以汇编级调试的方式启动 Bochs,引导 Linux 0.11,在 0.11 下编译和运行 test.c。它是一个无限循环的程序,永远不会主动退出。然后在调试器中通过查看各项系统参数,从逻辑地址、LDT 表、GDT 表、线性地址到页表,计算出变量 i 的物理地址。最后通过直接修改物理内存的方式让 test.c 退出运行。

    test.c 的代码如下:

    #include <stdio.h>
    
    int i = 0x12345678;
    int main(void)
    {
        printf("The logical/virtual address of i is 0x%08x", &i);
        fflush(stdout);
        while (i)
            ;
        return 0;
    }
    

    2 基于共享内存的生产者—消费者程序

    本项实验在 Ubuntu 下完成,与信号量实验中的 pc.c 的功能要求基本一致,仅有两点不同:

    • 不用文件做缓冲区,而是使用共享内存;
    • 生产者和消费者分别是不同的程序。生产者是 producer.c,消费者是 consumer.c。两个程序都是单进程的,通过信号量和缓冲区进行通信。

    Linux 下,可以通过 shmget()shmat() 两个系统调用使用共享内存。

    3 共享内存的实现

    进程之间可以通过页共享进行通信,被共享的页叫做共享内存,结构如下图所示:

    图片描述信息

    图 1 进程间共享内存的结构

    本部分实验内容是在 Linux 0.11 上实现上述页面共享,并将上一部分实现的 producer.c 和 consumer.c 移植过来,验证页面共享的有效性。

    具体要求在 mm/shm.c 中实现 shmget()shmat() 两个系统调用。它们能支持 producer.cconsumer.c 的运行即可,不需要完整地实现 POSIX 所规定的功能。

    • shmget()
    int shmget(key_t key, size_t size, int shmflg);
    

    shmget() 会新建/打开一页内存,并返回该页共享内存的 shmid(该块共享内存在操作系统内部的 id)。

    所有使用同一块共享内存的进程都要使用相同的 key 参数。

    如果 key 所对应的共享内存已经建立,则直接返回 shmid。如果 size 超过一页内存的大小,返回 -1,并置 errnoEINVAL。如果系统无空闲内存,返回 -1,并置 errnoENOMEM

    shmflg 参数可忽略。

    • shmat()
    void *shmat(int shmid, const void *shmaddr, int shmflg);
    

    shmat() 会将 shmid 指定的共享页面映射到当前进程的虚拟地址空间中,并将其首地址返回。

    如果 shmid 非法,返回 -1,并置 errnoEINVAL

    shmaddrshmflg 参数可忽略。

    创建test.c

    挂载hdc文件系统

    image-20211117132150617

    切换到用户文件夹

    image-20211117132343691

    编写 test.c

    image-20211117133323502

    退出挂载

    image-20211117133419466

    跟踪地址翻译过程

    1.准备

    ./dbg-asm
    c
    

    image-20211117135600685

    在Bochs中编译运行 test.c

    image-20211117135808884

    只要test.c不变,0x00003004这个值在任何人的机器上都是一样的。即使在同一个机器上多次运行test.c,也是一样的。

    test.c是一个死循环,只会不停占用CPU,不会退出。

    2.暂停

    在命令行窗口按 Ctrl+c,Bochs 会暂停运行,进入调试状态

    其中的 000f 如果是 0008,则说明中断在了内核里。那么就要 c,然后再 ctrl+c,直到变为 000f 为止。

    如果显示的下一条指令不是 cmp ...(这里指语句以 cmp 开头),就用 n 命令单步运行几步,直到停在 cmp ...

    使用命令 u /8,显示从当前位置开始 8 条指令的反汇编代码,结构如下:

    image-20211117141607134

    这就是 test.c 中从 while 开始一直到 return 的汇编代码。变量 i 保存在 ds:0x3004 这个地址,并不停地和 0 进行比较,直到它为 0,才会跳出循环。

    现在,开始寻找 ds:0x3004 对应的物理地址。

    3.段表

    ds:0x3004 是虚拟地址,ds 表明这个地址属于 ds 段。首先要找到段表,然后通过 ds 的值在段表中找到 ds 段的具体信息,才能继续进行地址翻译。

    每个在 IA-32 上运行的应用程序都有一个段表,叫 LDT,段的信息叫段描述符。

    LDT 在哪里呢?ldtr 寄存器是线索的起点,通过它可以在 GDT(全局描述符表)中找到 LDT 的物理地址。

    sreg 命令(是在调试窗口输入):

    image-20211117142301307

    可以看到 ldtr 的值是 0x0068=0000000001101000(二进制),表示 LDT 表存放在 GDT 表的 1101(二进制)=13(十进制)号位置(每位数据的意义参考后文叙述的段选择子)。

    而 GDT 的位置已经由 gdtr 明确给出,在物理地址的 0x00005cb8

    xp /32w 0x00005cb8 查看从该地址开始,32 个字的内容,及 GDT 表的前 16 项,如下:

    image-20211117143546468

    GDT 表中的每一项占 64 位(8 个字节),所以我们要查找的项的地址是 0x00005cb8+13*8

    输入 xp /2w 0x00005cb8+13*8,得到:

    image-20211117144017369

    “0x52d00068 0x000082fd” 将其中的加粗数字组合为“0x00faa2d0”,这就是 LDT 表的物理地址(为什么这么组合,参考后文介绍的段描述符)。

    xp /8w 0x00fd52d0,得到:

    image-20211117162058532

    4.段描述符

    在保护模式下,段寄存器有另一个名字,叫段选择子,因为它保存的信息主要是该段在段表里索引值,用这个索引值可以从段表中“选择”出相应的段描述符。

    先看看 ds 选择子的内容,还是用 sreg 命令:

    image-20211117155209387

    可以看到,ds 的值是 0x0017。段选择子是一个 16 位寄存器,它各位的含义如下图:

    图片描述信息

    其中 RPL 是请求特权级,当访问一个段时,处理器要检查 RPL 和 CPL(放在 cs 的位 0 和位 1 中,用来表示当前代码的特权级),即使程序有足够的特权级(CPL)来访问一个段,但如果 RPL(如放在 ds 中,表示请求数据段)的特权级不足,则仍然不能访问,即如果 RPL 的数值大于 CPL(数值越大,权限越小),则用 RPL 的值覆盖 CPL 的值。

    而段选择子中的 TI 是表指示标记,如果 TI=0,则表示段描述符(段的详细信息)在 GDT(全局描述符表)中,即去 GDT 中去查;而 TI=1,则去 LDT(局部描述符表)中去查。

    看看上面的 ds,0x0017=0000000000010111(二进制),所以 RPL=11,可见是在最低的特权级(因为在应用程序中执行),TI=1,表示查找 LDT 表,索引值为 10(二进制)= 2(十进制),表示找 LDT 表中的第 3 个段描述符(从 0 开始编号)。

    LDT 和 GDT 的结构一样,每项占 8 个字节。所以第 3 项 0x00003fff 0x10c0f300(上一步骤的最后一个输出结果中) 就是搜寻好久的 ds 的段描述符了。

    sreg 输出中 ds 所在行的 dl 和 dh 值可以验证找到的描述符是否正确。

    接下来看看段描述符里面放置的是什么内容:

    图片描述信息

    可以看到,段描述符是一个 64 位二进制的数,存放了段基址和段限长等重要的数据。其中位 P(Present)是段是否存在的标记;位 S 用来表示是系统段描述符(S=0)还是代码或数据段描述符(S=1);四位 TYPE 用来表示段的类型,如数据段、代码段、可读、可写等;DPL 是段的权限,和 CPL、RPL 对应使用;位 G 是粒度,G=0 表示段限长以位为单位,G=1 表示段限长以 4KB 为单位;其他内容就不详细解释了。

    5.段基址和线性地址

    组合规则见段描述符结构:其实就是将基地址进行组合

    在这里插入图片描述

    费了很大的劲,实际上我们需要的只有段基址一项数据,即段描述符 “0x00003fff 0x10c0f300” 中加粗部分组合成的 “0x10000000”。这就是 ds 段在线性地址空间中的起始地址。用同样的方法也可以算算其它段的基址,都是这个数。

    段基址+段内偏移,就是线性地址了。所以 ds:0x3004 的线性地址就是:

    0x10000000 + 0x3004 = 0x10003004
    

    calc ds:0x3004 命令可以验证这个结果。

    image-20211117162846275

    6.页表

    从线性地址计算物理地址,需要查找页表。线性地址变成物理地址的过程如下:

    图片描述信息

    首先需要算出线性地址中的页目录号、页表号和页内偏移,它们分别对应了 32 位线性地址的 10 位 + 10 位 + 12 位,所以 0x10003004 的页目录号是 64,页号 3,页内偏移是 4。

    IA-32 下,页目录表的位置由 CR3 寄存器指引。“creg”命令可以看到:

    说明页目录表的基址为 0。看看其内容,“xp /68w 0”:

    image-20211117183126186

    页目录表和页表中的内容很简单,是 1024 个 32 位(正好是 4K)数。这 32 位中前 20 位是物理页框号,后面是一些属性信息(其中最重要的是最后一位 P)。其中第 65 个页目录项就是我们要找的内容,用“xp /w 0+64*4”查看:

    image-20211118204534024

    这就是我们要找的页目录表

    里面的内容是 0x00fa6027

    具体分析一下里面的结构

    页表项结构

    页表所在的物理页框号为0x00fa6,即页表在物理内存为0x00fa6000处,从该位置开始查找3号页表项(每个页表项4个字节),用命令:xp /w 0x00fa6000 + 3*4

    image-20211118210006902

    7.物理地址

    页表里所显示的即线性地址 0x10003004 对应的物理页框号为 0x00fa5,和页内偏移 0x004 连接在一起,得到 0x00fa5004,这就是变量 i 的物理地址。

    可以用两种方法验证:

    • 第一种方法是用命令 page 0x10003004

      image-20211118210740276

    • 第二种方法是用命令 xp /w 0x00fa5004

      image-20211118210900432

    现在,通过直接修改内存来改变 i 的值为 0,命令是: setpmem 0x00fa5004 4 0,表示从 0x00fa5004 地址开始的 4 个字节都设为 0。然后再用“c”命令继续 Bochs 的运行,可以看到 test 退出了,说明 i 的修改成功了,此项实验结束。

    image-20211118211228774

    添加系统调用号

    目录 oslab/linux-0.11/include

    image-20211118213732934

    unistd.h 中添加下面代码

    image-20211118214348757

    然后增加两个系统调用号

    image-20211118214528041

    添加系统调用的定义

    文件位置oslab/linux0.11/include/linux/sys.h

    image-20211118215058695

    增加函数声明

    image-20211118215217827

    image-20211118215319064

    改写系统调用数

    文件位置:oslab/linux0.11/kernel/system_call.s

    image-20211118215455257

    image-20211118215539283

    编写 shm.c

    文件位置:oslab/linux0.11/kernel/shm.s

    image-20211121211007959

    #define __LIBRARY__
    #include <unistd.h>
    #include <linux/kernel.h>
    #include <linux/sched.h>
    #include <linux/mm.h>
    #include <errno.h>
    
    static shm_ds shm_list[SHM_SIZE] = {{0, 0, 0}};
    
    int sys_shmget(unsigned int key, size_t size)
    {
        int i;
        unsigned long page;
        /* If the size exceeds the size of one page of memory */
        if (size > PAGE_SIZE)
        {
            printk("shmget: size %u cannot be greater than the page size %ud. \n", size, PAGE_SIZE);
            return -ENOMEM;
        }
        if (key == 0)
        {
            printk("shmget: key cannot be 0.\n");
            return -EINVAL;
        }
        for (i = 0; i < SHM_SIZE; i++)
        {
            if (shm_list[i].key == key)
            {
                return i;
            }
        }
        page = get_free_page();
        if (!page)
        {
            return -ENOMEM;
        }
        printk("shmget get memory's address is 0x%08x\n", page);
        for (i = 0; i < SHM_SIZE; i++)
        {
            if (shm_list[i].key == 0)
            {
                shm_list[i].key = 0;
                shm_list[i].size = size;
                shm_list[i].page = page;
                return i;
            }
        }
        return -1;
    }
    
    void *sys_shmat(int shmid)
    {
        unsigned long data_base, brk;
        if (shmid < 0 || SHM_SIZE <= shmid || shm_list[shmid].page == 0 || shm_list[shmid].key <= 0)
        {
            return (void *)-EINVAL;
        }
        data_base = get_base(current->ldt[2]);
        printk("current's data_base = 0x%08x,new page = 0x%08x\n", data_base, shm_list[shmid].page);
        brk = current->brk + data_base;
        current->brk += PAGE_SIZE;
        if (put_page(shm_list[shmid].page, brk) == 0)
        {
            return (void *)-ENOMEM;
        }
        return (void *)(current->brk - PAGE_SIZE);
    }
    

    brk指针在哪里?可以参考下图。我们可以发现 brk 指针正好指向堆区顶部,我们可以利用这个指针帮我们定位需要申请的空间首地址。

    image-20211121205228826

    在进程PCB当中记录了brk指针的 逻辑地址,然后加上进程开始处的虚拟地址就可以得到brk指针的虚拟地址或者叫线性地址(虚拟地址=段基址+逻辑地址)

    修改工程文件的编译规则

    文件位置:oslab/linux0.11/kernel/Makefile

    image-20211121211511070

    image-20211121212826216

    进入主文件开始编译,编译完成。

    image-20211121213709502

    编写消费者和生产者程序

    sudo ./mount-hdc
    cd /oslab/hdc/usr/root
    vi producer.c
    vi consumer.c
    sudo umount hdc/
    

    image-20211121214710670

    编写producer.c

    /*producer.c*/
    #define   __LIBRARY__
    #include <unistd.h>
    #include <sys/types.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <linux/sem.h>
    
    _syscall2(sem_t*,sem_open,const char *,name,unsigned int,value);
    _syscall1(int,sem_wait,sem_t*,sem);
    _syscall1(int,sem_post,sem_t*,sem);
    _syscall1(int,sem_unlink,const char *,name);
    _syscall1(void*,shmat,int,shmid);
    _syscall2(int,shmget,int,key,int,size);
    /*_syscall1(int,shmget,char*,name);*/
    
    #define NUMBER 520 /*打出数字总数*/
    #define BUFSIZE 10 /*缓冲区大小*/
    
    sem_t   *empty, *full, *mutex;
    int main()
    {
        int  i,shmid;
        int *p;
        int  buf_in = 0; /*写入缓冲区位置*/
        /*打开信号量*/
        if((mutex = sem_open("mutex",1)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
        if((empty = sem_open("empty",10)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
        if((full = sem_open("full",0)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
        /*shmid = shmget("buffer");*/
    	shmid = shmget(1234, BUFSIZE);
    	printf("shmid:%d\n",shmid);
        if(shmid == -1)
        {
            return -1;
        }
        p = (int*) shmat(shmid);
        /*生产者进程*/
    	printf("producer start.\n");
    	fflush(stdout);
        for( i = 0 ; i < NUMBER; i++)
        {
            sem_wait(empty);
            sem_wait(mutex);
            p[buf_in] = i;
            buf_in = ( buf_in + 1)% BUFSIZE;
            sem_post(mutex);
            sem_post(full);
        }
    	printf("producer end.\n");
    	fflush(stdout);
        /*释放信号量*/
        sem_unlink("full");
        sem_unlink("empty");
        sem_unlink("mutex");
        return 0;
    }
    

    编写consumer.c

    /*consumer.c*/
    #define   __LIBRARY__
    #include <unistd.h>
    #include <sys/types.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <linux/sem.h>
    
    _syscall2(sem_t*,sem_open,const char *,name,unsigned int,value);
    _syscall1(int,sem_wait,sem_t*,sem);
    _syscall1(int,sem_post,sem_t*,sem);
    _syscall1(int,sem_unlink,const char *,name);
    _syscall1(void*,shmat,int,shmid);
    _syscall2(int,shmget,int,key,int,size);
    
    
    #define NUMBER 520 /*打出数字总数*/
    #define BUFSIZE 10 /*缓冲区大小*/
    
    sem_t  *empty, *full, *mutex;
    int main()
    {
        int  i,shmid,data;
        int *p;
        int  buf_out = 0; /*从缓冲区读取位置*/
        /*打开信号量*/
        if((mutex = sem_open("mutex",1)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
        if((empty = sem_open("empty",10)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
        if((full = sem_open("full",0)) == NULL)
        {
            perror("sem_open() error!\n");
            return -1;
        }
    	
    	printf("consumer start.\n");
        fflush(stdout);
        /*shmid = shmget("buffer");*/
    	shmid = shmget(1234, BUFSIZE);
    	printf("shmid:%d\n",shmid);
    	if(shmid == -1)
        {
            return -1;
        }
        p = (int *)shmat(shmid);
    
        for( i = 0; i < NUMBER; i++ )
        {
            sem_wait(full);
            sem_wait(mutex);
    
            data = p[buf_out];
            buf_out = (buf_out + 1) % BUFSIZE;
    
            sem_post(mutex);
            sem_post(empty);
            /*消费资源*/
            printf("%d:  %d\n",getpid(),data);
            fflush(stdout);
        }
        printf("consumer end.\n");
        fflush(stdout);
        /*释放信号量*/
        sem_unlink("full");
        sem_unlink("empty");
        sem_unlink("mutex");
    
        return 0;
    }
    

    运行验证

    image-20211127172238111

    报错原因找了好久,感觉该干的事情都干了。最后看到这篇文章添加了__NR_whoami编译还是不行,才恍然大悟。

    将已经修改的 unistd.h 拷贝到linux-0.11系统中

    sudo ./mount-hdc 
    cp ./linux-0.11/include/unistd.h ./hdc/usr/include/
    sudo umount hdc/
    

    image-20211127172507767

    开始正式运行程序:

    gcc -o producer producer.c
    gcc -o consumer consumer.c
    ./producer > p.txt &
    ./consumer > c.txt
    

    image-20211127181735439

    这个不影响。直接关闭虚拟机,挂载后查看 p.txtc.txt

    image-20211127182449068

    image-20211127182334217

    😉 实验成功!

    天道酬勤

    看到一条弹幕:“能看到这里的人,真的很不容易。”,我想,能写到这里的人,也很不容易。支持我到现在的动力,也许是因为我真的想学一些所谓无用的东西,无用之用,方为大用。就我个人而言,学到现在也没有学到什么特别具体的东西,手写OS只是妄想,甚至改改代码也要依靠别人的代码。但对我而言,我十分感谢这段痛苦的漫长的经历,它告诉我,写一个花哨的网页,跑一段机器学习,深度学习的代码,可能很酷,但其实只是虚假繁荣。我缺乏沉下心来做一件无趣的事情的能力。也就是所谓的坐得住的能力。通过这几个月的实验,我感觉自己越来越坚定了,知道自己想要做什么,我走了一条自己认为对的路,我不后悔。

    实验八 终端设备的控制

    实验目的

    • 加深对操作系统设备管理基本原理的认识,实践键盘中断、扫描码等概念;
    • 通过实践掌握 Linux 0.11 对键盘终端和显示器终端的处理过程。

    实验内容

    本实验的基本内容是修改 Linux 0.11 的终端设备处理代码,对键盘输入和字符显示进行非常规的控制。

    在初始状态,一切如常。用户按一次 F12 后,把应用程序向终端输出所有字母都替换为“*”。用户再按一次 F12,又恢复正常。第三次按 F12,再进行输出替换。依此类推。

    以 ls 命令为例:

    正常情况:

    # ls
    hello.c hello.o hello
    

    第一次按 F12,然后输入 ls:

    # **
    *****.* *****.* *****
    

    第二次按 F12,然后输入 ls:

    # ls
    hello.c hello.o hello
    

    第三次按 F12,然后输入 ls:

    # **
    *****.* *****.* *****
    

    实验思路

    这里列出其他博主的博客————参考博客

    键盘中断初始化

    键盘中断的初始化在boot/main.c文件的init()函数中,在这个函数中会调用tty_init()函数对显卡的变量等进行设置(如:video_size_row,vide_mem_start,video_port_reg,video_port_val,video_mem_end等等),并对键盘中断0x21设置,将中断过程指向keyboard_interrupt函数(这是通过set_trap_gate(0x21, &keyboard_interrupt)语句来实现的。

    键盘中断发生

    当键盘中断发生时,intb $0x60,%al 指令会取出键盘的扫描码,放在al寄存器中,然后查key_table表进行扫描码处理,key_table表中定义了相关所有扫描码对应键的处理函数(如do_self,func,alt,ctrl,none等),比如f1-f12键的处理则要先运行一段处理函数func,调用sched.c中定义的show_stat函数显示当前进程情况,默认情况linux-0.11中所有的功能键都进行这样的动作,然后下一步将控制键以及功能键进行转义,比如ctrl_a将会被转义为^af1会被转义为esc[[Af2被转义为esc[[B,而其他键也经过类似处理,处理完毕后将跳到put_queue,将扫描码放在键盘输入队列中,当然如果没有对应的扫描码被找到,则会通过none标签直接退回,不会被放入队列。然后再调用do_tty_interrupt函数进行最后的处理。do_tty_interrupt直接调用copy_to_cooked函数对队列中的字符进行判断处理,最后调用con_write函数将其输出到显卡内存。在此同时,也会将字符放入辅助队列中,以备缓冲区读写程序使用。

    字符输出流程

    无论是输出字符到屏幕上还是文件中,系统最终都会调用write函数来进行,而write函数则会调用sys_write系统调用来实现字符输出,如果是输出到屏幕上,则会调用tty_write函数,而tty_write最终也会调用con_write函数将字符输出;如果是输出到文件中,则会调用file_write函数直接将字符输出到指定的文件缓冲区中。所以无论是键盘输入的回显,还是程序的字符输出,那只需要修改con_write最终写到显存中的字符就可以了。但如果要控制字符输出到文件中,则需要修改file_write函数中输出到文件缓冲区中的字符即可。

    在这里插入图片描述

    读队列read_q 用于存放从键盘或串行终端输入的原始(raw)字符序列;写队列 write_q 用于存放写到控制台显示屏或串行终端去的数据;辅助队列 secondary 用于存放经过行规则程序处理(过滤)过的数据,或称为熟(cooked)模式数据。上层终端读函数tty_read用于读取secondary队列中的字符。

    在这里插入图片描述

    对于一个控制台,当用户在键盘上键入了一个字符时,会引起键盘中断响应(中断请求信号 IRQ1, 对应中断号 INT 33 ),此时键盘中断处理程序就会从键盘控制器读入对应的键盘扫描码,然后根据使用的键盘扫描码映射表译成相应字符,放入 tty 读队列 read_q中。然后调用中断处理程序的 C函数 do_tty_interrupt(),它又直接调用行规则函数 copy_to_cooked()对该字符进行过滤处理,并放入 tty 辅助队列 secondary 中,同时把该字符放入tty 写队列 write_q 中,并调用写控制台函数 con_write() 。此时如果该终端的回显( echo )属性是设置的,则该字符会显示到屏幕上。 do_tty_interrupt()copy_to_cooked()函数在tty_io.c中实现。

    有关控制台终端操作的驱动程序,主要涉及两个程序。一个是键盘中断处理程序 keyboard.S ,主要用于读入用户键入的字符并放入 read_q缓冲队列中;另一个是屏幕显示处理程序 console.c,用于从 write_q队列中取出字符并显示在屏幕上。

    添加F12功能键盘处理

    • 文件位置 kernel/chr_drv/tty_io.c

    image-20211209122643915

    文件末尾添加

    image-20211209123524738

    • 文件位置 include/linux/tty.h

    image-20211209123659655

    文件末尾添加

    image-20211209123854247

    • 文件位置 kernel/chr_drv/keyboard.S

    image-20211209124540798

    注意位置 525行注释掉,改成调用 press_f12_handle 函数

    image-20211209124309669

    image-20211209124534637

    添加字符*显示处理

    • 文件位置 linux-0.11/kernel/chr_drv/console.c

    image-20211209125447123

    其实很简单,就是循环队列一个个取字符进行处理后放入显存

    我们就在放入显存之前进行一次过滤,即 F12_flag == 1 时将字符转变为 *

    image-20211209125439613

    编译运行

    make all

    image-20211209125833172

    run

    image-20211209130000477

    😉 实验完成

    实验问题

    1. 在原始代码中,按下F12,中断响应后,中断服务程序会调用func?它实现的是什么功能?

    将F12转义成转义字符序列 [ [ L , 对F1-F12处理类似 [ [ A -> [ [ L

    1. 在你的实现中,是否把向文件输出的字符也过滤了?如果是,那么怎么能只过滤向终端输出的字符?如果不是,那么怎么能把向文件输出的字符也一并进行过滤?

    没有把向文件输出的字符过滤,只过滤向终端输出的字符是通过con_write函数的修改来实现的。过滤向文件输出的字符则通过修改file_write函数来实现。

    天道酬勤

    这次实验十分简单,一个多小时就完成了。主要就是要捋顺文件之间的逻辑。

    实验九 proc文件系统的实现

    实验目的

    • 掌握虚拟文件系统的实现原理;
    • 实践文件、目录、文件系统等概念。

    实验内容

    在 Linux 0.11 上实现 procfs(proc 文件系统)内的 psinfo 结点。当读取此结点的内容时,可得到系统当前所有进程的状态信息。例如,用 cat 命令显示 /proc/psinfo 的内容,可得到:

    $ 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 文件内。

    procfs介绍

    正式的 Linux 内核实现了 procfs,它是一个虚拟文件系统,通常被 mount(挂载) 到 /proc 目录上,通过虚拟文件和虚拟目录的方式提供访问系统参数的机会,所以有人称它为 “了解系统信息的一个窗口”。

    这些虚拟的文件和目录并没有真实地存在在磁盘上,而是内核中各种数据的一种直观表示。虽然是虚拟的,但它们都可以通过标准的系统调用(open()read() 等)访问。

    例如,/proc/meminfo 中包含内存使用的信息,可以用 cat 命令显示其内容:

    image-20220101134655104

    Linux 是通过文件系统接口实现 procfs,并在启动时自动将其 mount 到 /proc 目录上。

    此目录下的所有内容都是随着系统的运行自动建立、删除和更新的,而且它们完全存在于内存中,不占用任何外存空间。

    Linux 0.11 还没有实现虚拟文件系统,也就是,还没有提供增加新文件系统支持的接口。所以本实验只能在现有文件系统的基础上,通过打补丁的方式模拟一个 procfs

    Linux 0.11 使用的是 Minix 的文件系统,这是一个典型的基于 inode 的文件系统,《注释》一书对它有详细描述。它的每个文件都要对应至少一个 inode,而 inode 中记录着文件的各种属性,包括文件类型。文件类型有普通文件、目录、字符设备文件和块设备文件等。在内核中,每种类型的文件都有不同的处理函数与之对应。我们可以增加一种新的文件类型——proc 文件,并在相应的处理函数内实现 procfs 要实现的功能。

    增加新文件类型

    文件位置:include/sys/stat.h

    #define S_IFPROC 0030000
    #define S_ISPROC(m) (((m) & S_IFMT) ==  S_IFPROC)
    

    image-20220101135740255

    mknod() 支持新的文件类型

    psinfo 结点要通过 mknod() 系统调用建立,所以要让它支持新的文件类型。

    if (S_ISBLK(mode) || S_ISCHR(mode) || S_ISPROC(mode))
         inode->i_zone[0] = dev;
    

    image-20220101140917424

    procfs 的初始化工作

    内核初始化的全部工作是在 main() 中完成,而 main() 在最后从内核态切换到用户态,并调用 init()

    init() 做的第一件事情就是挂载根文件系统:

    void init(void)
    {
    //    ……
        setup((void *) &drive_info);
    //    ……
    }
    

    procfs 的初始化工作应该在根文件系统挂载之后开始。它包括两个步骤:

    • (1)建立 /proc 目录;建立 /proc 目录下的各个结点。本实验只建立 /proc/psinfo

    • (2)建立目录和结点分别需要调用 mkdir()mknod() 系统调用。因为初始化时已经在用户态,所以不能直接调用 sys_mkdir()sys_mknod()。必须在初始化代码所在文件中实现这两个系统调用的用户态接口,即 API:

      
      static inline _syscall0(int,fork)
      static inline _syscall0(int,pause)
      static inline _syscall1(int,setup,void *,BIOS)
      static inline _syscall0(int,sync)
      /*新增mkdir和mknode系统调用*/
      _syscall2(int,mkdir,const char*,name,mode_t,mode)
      _syscall3(int,mknod,const char *,filename,mode_t,mode,dev_t,dev)
          
      //.......   
          
      	setup((void *) &drive_info);
      	(void) open("/dev/tty0",O_RDWR,0);
      	(void) dup(0);
      	(void) dup(0);
      	mkdir("/proc",0755);
      	mknod("/proc/psinfo",S_IFPROC|0444,0);
      	mknod("/proc/hdinfo",S_IFPROC|0444,1);
      	mknod("/proc/inodeinfo",S_IFPROC|0444,2);
      

    文件目录:init/main.c

    image-20220101142324146

    image-20220101143650297

    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。

    如此项工作完成得没有问题,那么编译、运行 0.11 内核后,用 ll /proc 可以看到:

    image-20220101144222230

    inode->i_mode 就是通过 mknod() 设置的 mode。信息中的 XXX 和你设置的 S_IFPROC 有关。通过此值可以了解 mknod() 工作是否正常。这些信息说明内核在对 hdinfo 进行读操作时不能正确处理,向 cat 返回了 EINVAL 错误。因为还没有实现处理函数,所以这是很正常的。

    这些信息至少说明,hdinfo 被正确 open() 了。所以我们不需要对 sys_open() 动任何手脚,唯一要打补丁的,是 sys_read()

    proc 文件可读

    文件位置:fs/read_write.c

    添加extern,表示proc_read函数是从外部调用的

    /*新增proc_read函数外部调用*/
    extern int proc_read(int dev,char* buf,int count,unsigned long *pos);
    

    image-20220101145341739

    然后在sys_read函数中仿照其他if语句,加上 S_IFPROC() 的分支,添加proc文件的proc_read()调用:

    	if (inode->i_pipe)
    		return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
    	/*新增proc_read调用*/
    	if (S_ISPROC(inode->i_mode))
    		return proc_read(inode->i_zone[0],&file->f_pos,buf,count);
    	if (S_ISCHR(inode->i_mode))
    		return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
    

    image-20220101154426345

    需要传给处理函数的参数包括:

    • inode->i_zone[0],这就是 mknod() 时指定的 dev ——设备编号
    • buf,指向用户空间,就是 read() 的第二个参数,用来接收数据
    • count,就是 read() 的第三个参数,说明 buf 指向的缓冲区大小
    • &file->f_posf_pos 是上一次读文件结束时“文件位置指针”的指向。这里必须传指针,因为处理函数需要根据传给 buf 的数据量修改 f_pos 的值。

    实现 proc_read 函数

    proc 文件的处理函数的功能是根据设备编号,把不同的内容写入到用户空间的 buf。写入的数据要从 f_pos 指向的位置开始,每次最多写 count 个字节,并根据实际写入的字节数调整 f_pos 的值,最后返回实际写入的字节数。当设备编号表明要读的是 psinfo 的内容时,就要按照 psinfo 的形式组织数据。

    实现此函数可能要用到如下几个函数:

    • malloc() 函数
    • free() 函数

    包含 linux/kernel.h 头文件后,就可以使用 malloc()free() 函数。它们是可以被核心态代码调用的,唯一的限制是一次申请的内存大小不能超过一个页面。

    文件位置:fs/proc.c

    代码来自一位大佬的博客————参考代码

    #include <linux/kernel.h>
    #include <linux/sched.h>
    #include <asm/segment.h>
    #include <linux/fs.h>
    #include <stdarg.h>
    #include <unistd.h>
    
    #define set_bit(bitnr,addr) ({ \
    register int __res ; \
    __asm__("bt %2,%3;setb %%al":"=a" (__res):"a" (0),"r" (bitnr),"m" (*(addr))); \
    __res; })
    
    char proc_buf[4096] ={'\0'};
    
    extern int vsprintf(char * buf, const char * fmt, va_list args);
    
    //Linux0.11没有sprintf(),该函数是用于输出结果到字符串中的,所以就实现一个,这里是通过vsprintf()实现的。
    int sprintf(char *buf, const char *fmt, ...)
    {
    	va_list args; int i;
    	va_start(args, fmt);
    	i=vsprintf(buf, fmt, args);
    	va_end(args);
    	return i;
    }
    
    int get_psinfo()
    {
    	int read = 0;
    	read += sprintf(proc_buf+read,"%s","pid\tstate\tfather\tcounter\tstart_time\n");
    	struct task_struct **p;
    	for(p = &FIRST_TASK ; p <= &LAST_TASK ; ++p)
     	if (*p != NULL)
     	{
     		read += sprintf(proc_buf+read,"%d\t",(*p)->pid);
     		read += sprintf(proc_buf+read,"%d\t",(*p)->state);
     		read += sprintf(proc_buf+read,"%d\t",(*p)->father);
     		read += sprintf(proc_buf+read,"%d\t",(*p)->counter);
     		read += sprintf(proc_buf+read,"%d\n",(*p)->start_time);
     	}
     	return read;
    }
    
    /*
    *  参考fs/super.c mount_root()函数
    */
    int get_hdinfo()
    {
    	int read = 0;
    	int i,used;
    	struct super_block * sb;
    	sb=get_super(0x301);  /*磁盘设备号 3*256+1*/
    	/*Blocks信息*/
    	read += sprintf(proc_buf+read,"Total blocks:%d\n",sb->s_nzones);
    	used = 0;
    	i=sb->s_nzones;
    	while(--i >= 0)
    	{
    		if(set_bit(i&8191,sb->s_zmap[i>>13]->b_data))
    			used++;
    	}
    	read += sprintf(proc_buf+read,"Used blocks:%d\n",used);
    	read += sprintf(proc_buf+read,"Free blocks:%d\n",sb->s_nzones-used);
    	/*Inodes 信息*/
    	read += sprintf(proc_buf+read,"Total inodes:%d\n",sb->s_ninodes);
    	used = 0;
    	i=sb->s_ninodes+1;
    	while(--i >= 0)
    	{
    		if(set_bit(i&8191,sb->s_imap[i>>13]->b_data))
    			used++;
    	}
    	read += sprintf(proc_buf+read,"Used inodes:%d\n",used);
    	read += sprintf(proc_buf+read,"Free inodes:%d\n",sb->s_ninodes-used);
     	return read;
    }
    
    int get_inodeinfo()
    {
    	int read = 0;
    	int i;
    	struct super_block * sb;
    	struct m_inode *mi;
    	sb=get_super(0x301);  /*磁盘设备号 3*256+1*/
    	i=sb->s_ninodes+1;
    	i=0;
    	while(++i < sb->s_ninodes+1)
    	{
    		if(set_bit(i&8191,sb->s_imap[i>>13]->b_data))
    		{
    			mi = iget(0x301,i);
    			read += sprintf(proc_buf+read,"inr:%d;zone[0]:%d\n",mi->i_num,mi->i_zone[0]);
    			iput(mi);
    		}
    		if(read >= 4000) 
    		{
    			break;
    		}
    	}
     	return read;
    }
    
    int proc_read(int dev, unsigned long * pos, char * buf, int count)
    {
    	
     	int i;
    	if(*pos % 1024 == 0)
    	{
    		if(dev == 0)
    			get_psinfo();
    		if(dev == 1)
    			get_hdinfo();
    		if(dev == 2)
    			get_inodeinfo();
    	}
     	for(i=0;i<count;i++)
     	{
     		if(proc_buf[i+ *pos ] == '\0')  
              break; 
     		put_fs_byte(proc_buf[i+ *pos],buf + i+ *pos);
     	}
     	*pos += i;
     	return i;
    }
    

    同时修改fs/Makefile文件:

    OBJS=	open.o read_write.o inode.o file_table.o buffer.o super.o \
    	block_dev.o char_dev.o file_dev.o stat.o exec.o pipe.o namei.o \
    	bitmap.o fcntl.o ioctl.o truncate.o proc.o
    //......
    ### Dependencies:
    proc.o : proc.c ../include/linux/kernel.h ../include/linux/sched.h \
      ../include/linux/head.h ../include/linux/fs.h ../include/sys/types.h \
      ../include/linux/mm.h ../include/signal.h ../include/asm/segment.h
    

    image-20220101153300252

    image-20220101153343127

    编译运行

    再次重新编译内核make all,然后运行内核,查看psinfo(当前系统进程状态信息)和hdinfo(硬盘信息)的信息:

    image-20220101154642892

    天道酬勤

    byez

    终章

    能看到这里的人,真的很不容易。只是起点,差的太多了,只能尽力而为。但行好事莫问前程!

    展开全文
  • 做这个实验大致上消耗了我三天时间,本来我是想在10月10号之前搞完的,但最终还是消耗到了现在,搞计算机行业或许真的要有一颗强大的心理。不然你真的回被各种各样的bug给搞麻来。从前天开始我就不停的在调试修改的...
  • 这个是07级哈尔滨工业大学操作系统实验的辛勤劳动,下面的压缩包中包含源代码,及实验报告,最好自己搭建平台,本人在电脑中又装了 linux,希望给感兴趣的人看看,呵呵,如果是工大的学弟学妹的话,千万不要顺手牵羊...
  • 能对操作系统代码进行简单的控制,揭开操作系统的神秘面纱。 二、实验内容 此次实验的基本内容是: 阅读《Linux 内核完全注释》的第 6 章,对计算机和 Linux 0.11 的引导过程进行初步的了解; 按照下面的要求改写 ...
  • 哈工大操作系统实验3-进程运行轨迹的追踪和统计 实验任务: 1.基于模板 process.c 编写多进程的样本程序,实现如下功能: + 所有子进程都并行运行,每个子进程的实际运行时间一般不超过 30 秒; + 父进程向标准输出...
  • 这个是07级哈尔滨工业大学操作系统实验的辛勤劳动,下面的压缩包中包含源代码,及实验报告,最好自己搭建平台,本人在电脑中又装了 linux,希望给感兴趣的人看看,呵呵,如果是工大的学弟学妹的话,千万不要顺手牵羊...
  • 本文采用自顶向下的方式完成实验,也即从自己编写的用户应用程序开始一步一步完成最终的功能,这样可以更好地把握主线。 零、总览框图 一、编写应用程序 该应用程序iam.c和whoami.c将会调用iam()和whoami()这两个系
  • 哈工大操作系统实验3 计算机学院 请自行学习研究,直接使用,小心雷同
  • 仅供参考,被查雷同概不负责 sys.h unistd.h system_call.s sem.c shm.c memory.c producer.c consumer.c report.txt
  • 哈工大操作系统实验坏境搭建

    千次阅读 2021-10-09 16:21:17
    链接:https://pan.baidu.com/s/1UwLn0b1Y8H5AeWwpivwFXw 提取码:f6ex
  • 哈工大操作系统实验报告及PPT(全六个实验)
  • 哈工大操作系统实验二:实现系统调用

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 4,671
精华内容 1,868
关键字:

哈工大操作系统实验