《ORANGE’S:一个操作系统的实现》读书笔记(十七)进程(五)

如果你对Linux不太熟悉或只在Windows下写过程序,那么你可能不熟悉“系统调用”这个概念,不过你一定听说过API。在Windows中,应用程序通过调用API与操作系统建立联系,比如,弹出一个对话框可以使用MessageBoxA。

系统调用与此类似。在我们的操作系统中,已经存在的3个进程是运行在ring1上的,它们已经不能任意地使用某些指令,不能访问某些权限更高的内存区域,但如果一项任务需要这些指令或者内存区域时,该怎么办呢?这是只能通过系统调用来实现,它是应用程序和操作系统之间的桥梁。这篇文章就记录书中关于系统调用部分的内容。

系统调用

应用程序能力是有限的,很多事情做不了,只能交给操作系统来做。系统调用就是告诉操作系统:“我有一件事情,请你来帮我完成”。所以,一件事情就可能是应用程序做一部分,操作系统做一部分。这样,问题就又涉及特权级变换。不过,这对于现在的我们应该不是问题了,因为进程切换就是不停地在重复这么一个特权级变换的过程。在那里,触发变换的是外部中断,这里我们把它变成“int nnn”就可以了。

实现一个简单的系统调用

书上用实现一个叫做 int get_ticks() 的函数作为例子来进行说明。我们使用这个函数来得到当前总共发生了多少次时钟中断。设置一个全局变量ticks,每发生一次时钟中断,它就加1。进程可以通过get_ticks()这个系统调用来得到这个值。

现在,假设进程P想要得到当前的ticks,操作的流程应该是这样的:进程P请求操作系统ticks –> 操作系统查找ticks –> 把ticks给进程P。下面就按照这个顺序来实现系统调用:get_ticks()。

我们之前说过,用中断可以方便地实现系统调用。但是,当发生中断后,处理程序从哪里获得关于系统调用本身及其参数信息呢?使用堆栈在这里不再是个好主意,虽然ring0仍然可以读取ring1堆栈中的数据,但还是不如读取寄存器来的方便。get_ticks()的代码如下所示。

代码 kernel/syscal.asm,get_ticks。

%include "sconst.inc"

_NR_get_ticks       equ 0   ; 要跟 global.c 中 sys_call_table 的定义相对应
INT_VECTOR_SYS_CALL equ 0x90

global get_ticks    ; 导出符号

bits 32
[section .text]

get_ticks:
    mov eax, _NR_get_ticks
    int INT_VECTOR_SYS_CALL
    ret

首先将eax赋值为_NR_get_ticks,这样,在中断处理程序中,系统看到eax的值是_NR_get_ticks,就知道是要获取ticks的值。这里将中断号设置为0x90,它只要不和原来的中断号重复即可。

接下来就来定义INT_VECTOR_SYS_CALL对应的中断门,在init_prot()中,在初始化其它中断门后面定义。

代码 kernel/protect.c,初始化系统调用的中断门。

PUBLIC void init_prot()
{
...
    /* 初始化系统调用的中断门 */
    init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
...
}

这样我们就将INT_VECTOR_SYS_CALL号中断和sys_call对应起来了。那么sys_call应该如何实现呢?我们可以模仿hwint_master宏来做:先保存寄存器的值,然后调用相应的函数,最后返回。我们先来看一下函数save,发现里面有一条语句是 mov eax, esp 改变了eax的值。这显然是现在不允许的,因为eax中存放着进程P系统调用的参数。不过这个问题好解决,我们把eax换成其它的寄存器就可以了,这里我们把eax统统换成esi。

代码 kernel/kernel.asm,修改后的save。

save:
    pushad      ; `.
    push    ds  ;  |
    push    es  ;  | 保存原寄存器值
    push    fs  ;  |
    push    gs  ; /
    mov     dx, ss
    mov     ds, dx
    mov     es, dx

    mov     esi, esp                        ; esi = 进程表起始位置

    inc     dword [k_reenter]               ; k_reenter++;
    cmp     dword [k_reenter], 0            ; if(k_reenter==0)
    jne     .1                              ; {
    mov     esp, StackTop                   ;   mov esp, StackTop <--切换到内核栈
    push    restart                         ;   push restart
    jmp     [esi + RETADR - P_STACKBASE]    ;   return;
.1:                                         ; } else { 已经在内核,不需要再切换
    push    restart_reenter                 ;   push restart_reenter
    jmp     [esi + RETADR - P_STACKBASE]    ;   return;
                                            ; }

现在可以来编写sys_call了。

代码 kernel/kernel.asm,sys_call。

extern sys_call_table
...
global sys_call
...
sys_call:
    call    save

    sti

    call    [sys_call_table + eax * 4]
    mov     [esi + EAXREG - P_STACKBASE], eax

    cli

    ret

函数sys_call基本上就是hwint_master的简化,设置连对相应处理程序的调用都类似,在hwint_master中是call [irq_table+4*1%](即调用了irq_table[%1]),这里变成了call [sys_call_table+eax*4](调用的是sys_call_table[eax])。与irq_table类似,sys_call_table是一个函数指针数组,每一个成员都指向一个函数,用以处理相应的系统调用。

sys_call_table的定义在global.c中,目前它只是一个成员:

PUBLIC system_call sys_call_table[NR_SYS_CALL] = {sys_get_ticks};

其中,system_call是在type.h中定义的:

typedef void* system_call;

这样,无论系统调用何种函数,都不会有编译时错误。

前面eax已经被赋值为_NR_get_ticks(即0),而sys_call_table[0]已经初始化为sys_get_ticks,所以call [sys_call_table+eax*4]这一句调用的就是sys_get_ticks。由于ticks是与进程相关的东西,我们就单独建立一个文件proc.c,把sys_get_ticks放在里面。为简单起见,先让这个函数打印一个字符“+”就返回,不做其它任何操作。

代码 kernel/proc.c,sys_get_ticks。

PUBLIC int sys_get_ticks()
{
    disp_str("+");
    return 0;
}

下面在proto.h中添加函数声明。

代码 include/proto.h,函数声明。

/* proc.c */
PUBLIC int sys_get_ticks(); /* sys_call */

/* syscall.asm */
PUBLIC void sys_call(); /* int_handler */
PUBLIC int get_ticks();

现在可以在进程中添加调用get_ticks()的代码了,来到TestA中添加如下语句。

代码 kernel/main.c,TestA。

void TestA()
{
    int i = 0;
    while(1) {
        get_ticks();
        disp_str("A");
        disp_int(i++);
        disp_str(".");
        delay(1);
    }
}

再次提醒一句,别忘了在kernel.asm和syscall.asm中导入和导出相应的符号,并且修改Makefile(增加了syscall.asm和proc.c)。然后就可以make并运行了,效果如下所示。

从上面的动图中可以看到,“+”号出现在字符A的前面,说明我们的第一个系统调用成功了。

下面我们来改进一下函数sys_get_ticks(),让它发挥应用的功能。它要返回的是当前ticks,但是我们还没有声明这样的全局变量,现在我们来到global.h中声明该变量:

EXTERN int          ticks;

在main.c中进行初始化。

代码 kernel/main.c,初始化ticks。

PUBLIC int kernel_main()
{
...
    ticks = 0;
...
}

在clock_handler(int irq)中添加一句。

代码 kernel/clock.c,时钟中断处理程序。

PUBLIC void clock_handler(int irq)
{
    disp_str("#");
    ticks++;

    if (k_reenter != 0) {
        disp_str("!");
        return;
    }

    p_proc_ready++;
    if (p_proc_ready >= proc_table + NR_TASKS) {
        p_proc_ready = proc_table;
    }
}

然后修改sys_get_ticks(),代码 kernel/proc.c。

PUBLIC int sys_get_ticks()
{
    return ticks;
}

最后修改TestA()。我们不再打印递增的i了,改成打印当前的ticks。

代码 kernel/main.c,TestA。

void TestA()
{
    while(1) {
        disp_str("A");
        disp_int(get_ticks());
        disp_str(".");
        delay(1);
    }
}

好了,下面我们可以make,运行,看一下效果了。

第一次打印出的是A0x0,第二次打印出的是A0x3,如果你数一下,会发现两次打印之间的“#”恰好为3个,证明get_ticks一切正常。

get_ticks的应用

我们当初写get_ticks的时候并没有考虑它有什么实际用处,只是觉得它足够简单。可是你有没有考虑过这样一个问题,时钟中断发生的时间间隔是一定的,如果我们知道了这个时间间隔,就可以用get_ticks函数来写一个判断时间的函数,进而替代我们曾经使用的delay()。那么时钟中断间隔多长时间发生一次呢?我们下面就来看一下。

8253/8254 PIT

我们一直在讲时钟中断,但好像到目前为止,我们还没有考虑过它为什么发生,以及由谁来产生。中断不是凭空产生的,实际上它是由一个被称作PIT(Programmable Interval Timer)的芯片来触发的。在IBM XT中,这个芯片用的是Intel 8253,在AT以及以后换成了Intel 8254。8254功能更强一些,但是增强的功能,我们不一定涉及,所以书上这里讲的是8253。8253有3个计数器(Counter),它们都是16位的,各有不同的作用,如下表所示。

计数器作用
Counter0输出到IRQ0,以便每隔一段时间让系统产生一次时钟中断
Counter1通常被设为18,以便大约每15μs做一次RAM刷新
Counter2连接PC喇叭

从上表中看到,时钟中断是由8253的Counter0产生的。

计数器的工作原理是这样的:它有一个输入频率,在PC上是1193180Hz。在每一个时钟周期(CLK cycle),计数器值会减1,当减到0时,就会触发一个输出。由于计数器是16位的,所以最大值是65535,因此,默认的时钟中断的发生频率就是 1193180/65536≈18.2Hz。

我们可以通过编程来控制8253。因为如果改变计数器的计数值,那么中断产生的时间间隔也就相应的改变了。比如,如果想让系统每隔10ms产生一次中断,也就是说让输出频率为100Hz,那么需要位计数器赋值为 1193180/100≈11931。

已经知道了原理,接下来就是改变计数器的计数值。改变计数器的计数值是通过对相应端口的写操作来实现的。8253的端口如下表所示。

端口描述
40h8253 Counter0
41h8253 Counter1
42h8253 Counter2
43h8253模式控制寄存器(Mode Control Register)

从上表中可以知道,改变Counter0计数值需要操作端口40h。但是这个操作有一点复杂,因为我们需要先通过端口43h写8253模式控制寄存器。先来看一下它的数据格式,如下图所示。

计数器模式位如下表所示。

模式位置模式名称
321
000模式0interrupt on terminal count
001模式1programmable one-shot
010模式2rate generator←我们的时钟中断采用此模式
011模式3square wave rate generator
100模式4software triggered strobe
101模式5hardware triggered strobe

读 / 写 / 锁(Read/Write/Latch)位如下表所示。

描述
54
00锁住当前计数值(以便于读取)
01只读写高字节
10只读写低字节
11先读写低字节,再读写高字节

注意:锁住(Latch)当前计数器值并不是让计数停止,而仅仅是为了便于读取。相反,如果不锁住直接读取会影响计数。

计数器选择位如下表所示。

描述
76
00选择Counter0
01选择Counter1
10选择Counter2
11对8253而言非法,对8254是 Read Back 命令

了解了各部分的含义之后,如何写模式控制寄存器就很明确了。我们要操作Count0,所以第7、6位应该是“00”;计数值是16位的,所以低字节和高字节都要写入,于是第5、4位应该是“11”;使用模式2,所以第3、2、1位应该是“010”;第0位设为“0”。这样,整个字节就变成“00110100”,也就是十六进制的0x34。

下面来设置计数值。

代码 kernel/main.c,设置8253。

PUBLIC int kernel_main()
{
...
    /* 初始化 8253 PIT */
    out_byte(TIMER_MODE, RATE_GENERATOR);
    out_byte(TIMER0, (u8) (TIMER_FREQ / HZ));
    out_byte(TIMER0, (u8) ((TIMER_FREQ / HZ) >> 8));
...
}

其中各个宏的定义如下代码所示。

代码 include/const.h,有关8253的宏定义。

/* 8253/8254 PIT (Programmable Interval Timer) */
#define TIMER0          0x40 /* I/O port for timer channel 0 */
#define TIMER_MODE      0x43 /* I/O port for timer mode control */
#define RATE_GENERATOR  0x34 /* 00-11-010-0:
                              * Counter0 - LSB then MSB - rate generator - binary
                              */
#define TIMER_FREQ      1193182L /* clock frequency for timer in PC and AT */
#define HZ              100      /* clock freq (software settable on IBM-PC) */

不太精确的延迟函数

通过上面的代码,我们已经把两次时钟中断的间隔改成了10ms,如果现在运行程序,你会看到下图所示情形:在很短的时间内打印出很多“#”,这说明中断发生快了很多。毕竟原来一秒钟18.2次中断,大约55ms发生一次,现在一秒钟100次,10ms发生一次,所以区别才会这么明显。

现在我们可以来编写新的延迟函数了,因为中断10ms发生一次,所以ticks也是10ms增加一次,延迟函数代码如下所示。

代码 kernel/clock.c,精确到 10ms 的延迟函数。

PUBLIC void milli_delay(int milli_sec)
{
    int t = get_ticks();

    while (((get_ticks() - t) * 1000 / HZ) < milli_sec) {}
}

函数一开始得到当前的ticks值,然后开始循环,每次循环的时候看已经过去了多少ticks(假设是∆t个)。因为ticks之间的间隔是(1000/Hz)ms,所以∆t个ticks相当于(∆t*1000/Hz)ms,循环会在这个毫秒数大于要求的毫秒数时退出。

接下来修改进程A的进程体,将原先使用的延迟函数更改为我们刚刚编写的延迟函数。

代码 kernel/main.c,修改进程A。

void TestA()
{
    while(1) {
        disp_str("A");
        disp_int(get_ticks());
        disp_str(".");
        milli_delay(1000);
    }
}

同时也修改进程B和进程C的延迟函数使用,换成新的延迟函数,然后make,运行,效果如下图所示。

从运行结果的截图中可以看到,发生了很多次重入。大家可以根据打印出的ticks值来计算两次打印“A”之间发生了多少次中断,在上图中,第2次和第1次打印A之间发生了0x64,也就是100次中断,第3次和第2从打印A之间发生了0x64(0xC8-0x64),也是100次中断,这很完美。但是如果实验多次的话,可能会发现不是每次都运行这么完美的,误差有时是的确存在的,我在实验的时候,间隔多为0x65、0x67,截图这次正好是0x64,算是完美一次。也就是说,虽然中断时10ms发生一次,但通过这种方式写出来的milli_delay误差却不只10ms,而是“10ms级”的。

书上告知的原因是:一个很重要的方面在于,现在不只有一个进程在运行,当时间满足条件之后,CPU控制权可能恰好交给了其它进程,这时其它进程可能耗费掉若干的ticks。另外,打印字符和数字也会用掉一些ticks。

为了排除其它因素的影响,我们把进程数减为1(可以通过修改NR_TASKS和task_table[NR_TASKS]来实现),然后把中断例程打印“#”和“!”的代码也去掉,再运行一次,会发现每一次的间隔都是0x64,也就是100个ticks。我运行了好几次,都是这样的结果,大家也可以试一下,看看是不是这样的结果。

虽然存在误差的可能,虽然精度也不够高,但是比起原来那个循环,却已经好很多了。而且我们知道了如何进行系统调用,同时还掌握了如何操作8253。这些收获无疑让这个不完美的函数价值大增。

公众号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值