如果你对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的端口如下表所示。
端口 | 描述 |
40h | 8253 Counter0 |
41h | 8253 Counter1 |
42h | 8253 Counter2 |
43h | 8253模式控制寄存器(Mode Control Register) |
从上表中可以知道,改变Counter0计数值需要操作端口40h。但是这个操作有一点复杂,因为我们需要先通过端口43h写8253模式控制寄存器。先来看一下它的数据格式,如下图所示。
计数器模式位如下表所示。
模式位置 | 模式 | 名称 | ||
3 | 2 | 1 | ||
0 | 0 | 0 | 模式0 | interrupt on terminal count |
0 | 0 | 1 | 模式1 | programmable one-shot |
0 | 1 | 0 | 模式2 | rate generator←我们的时钟中断采用此模式 |
0 | 1 | 1 | 模式3 | square wave rate generator |
1 | 0 | 0 | 模式4 | software triggered strobe |
1 | 0 | 1 | 模式5 | hardware triggered strobe |
读 / 写 / 锁(Read/Write/Latch)位如下表所示。
位 | 描述 | |
5 | 4 | |
0 | 0 | 锁住当前计数值(以便于读取) |
0 | 1 | 只读写高字节 |
1 | 0 | 只读写低字节 |
1 | 1 | 先读写低字节,再读写高字节 |
注意:锁住(Latch)当前计数器值并不是让计数停止,而仅仅是为了便于读取。相反,如果不锁住直接读取会影响计数。
计数器选择位如下表所示。
位 | 描述 | |
7 | 6 | |
0 | 0 | 选择Counter0 |
0 | 1 | 选择Counter1 |
1 | 0 | 选择Counter2 |
1 | 1 | 对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。这些收获无疑让这个不完美的函数价值大增。
公众号