操作系统实验一到实验九合集(哈工大李治军)

操作系统实验一到实验九合集(哈工大李治军)操作系统实验一到实验九合集(哈工大李治军)有详细代码及注释,程序已在本地测试,均能跑通

大家好,欢迎来到IT知识分享网。

操作系统实验

作者寄语

操作系统实验的学习是一个循序渐进的过程,初次看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位通用寄存器同时赋值

举例:

地址 100H 101H 102H 103H
内容 00H 41H 02H 03H
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

终章

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

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/22405.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信