现在我们有了4个进程,分别是TTY、A、B、C。其中A、B、C是可有可无的,它们其实不是操作系统的一部分,更像是用户在执行的程序。而TTY则不同,它肩负着重大的职责,没有它我们连键盘都无法使用。那么这篇文章记录如何将进程区分开来。
区分任务和用户进程
我们有必要将进程区分开来,分为两类。我们称TTY为“任务”,而称A、B、C为“用户进程”。
在具体的实现上,也来做一些相应的改变,让用户进程运行在ring3,任务继续留在ring1。这样就形成了如下图所示的情形。
现在我们就来修改代码,首先增加对NR_PROCS的定义。
代码 include/proc.h,增加NR_PROC。
/* 最大允许任务数 */
#define NR_TASKS 1
/* 最大允许用户进程数 */
#define NR_PROCS 3
增加NR_PROCS的同时,将NR_TASKS修改为1。
然后在所有用到NR_TASKS的地方都要做相应的修改,首先是proc_table和task_table。
代码 kernel/global.c,区分task和proc。
PUBLIC PROCESS proc_table[NR_TASKS + NR_PROCS];
PUBLIC TASK task_table[NR_TASKS] = {{task_tty, STACK_SIZE_TTY, "task_tty"}};
PUBLIC TASK user_proc_table[NR_PROCS] = {{TestA, STACK_SIZE_TESTA, "TestA"},
{TestB, STACK_SIZE_TESTB, "TestB"},
{TestC, STACK_SIZE_TESTC, "TestC"}};
我们声明了一个数组user_proc_table[],实际上是权宜之计,因为完善的操作系统应该有专门的方法来新建一个用户进程,不过目前使用与任务相同的方法来做无疑是简单的。
初始化进程表的地方当然也需要进行修改。
代码 kernel/main.c,初始化进程表时区分task和proc。
PUBLIC int kernel_main()
{
...
u8 privilege;
u8 rpl;
int eflags;
for(i = 0; i < NR_TASKS + NR_PROCS; i++){
if (i < NR_TASKS) { /* 任务 */
p_task = task_table + i;
privilege = PRIVILEGE_TASK;
rpl = RPL_TASK;
eflags = 0x1202; /* IF=1, IOPL=1, bit 2 is always 1 */
} else { /* 用户进程 */
p_task = user_proc_table + (i - NR_TASKS);
privilege = PRIVILEGE_USER;
rpl = RPL_USER;
eflags = 0x202; /* IF=1, bit 2 is always 1 */
}
strcpy(p_proc->p_name, p_task->name); /* name of the process */
p_proc->pid = i; /* pid */
p_proc->ldt_sel = selector_ldt;
memcpy(&p_proc->ldts[0], &gdt[SELECTOR_KERNEL_CS >> 3], sizeof(DESCRIPTOR));
p_proc->ldts[0].attr1 = DA_C | privilege << 5;
memcpy(&p_proc->ldts[1], &gdt[SELECTOR_KERNEL_DS >> 3], sizeof(DESCRIPTOR));
p_proc->ldts[1].attr1 = DA_DRW | privilege << 5;
p_proc->regs.cs = ((8 * 0) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | rpl;
p_proc->regs.ds = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | rpl;
p_proc->regs.es = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | rpl;
p_proc->regs.fs = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | rpl;
p_proc->regs.ss = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | rpl;
p_proc->regs.gs = (SELECTOR_KERNEL_GS & SA_RPL_MASK) | rpl;
p_proc->regs.eip = (u32)p_task->initial_eip;
p_proc->regs.esp = (u32)p_task_stack;
p_proc->regs.eflags = eflags;
p_task_stack -= p_task->stacksize;
p_proc++;
p_task++;
selector_ldt += 1 << 3;
}
k_reenter = 0;
ticks = 0;
proc_table[0].ticks = proc_table[0].priority = 15;
proc_table[1].ticks = proc_table[1].priority = 5;
proc_table[2].ticks = proc_table[2].priority = 3;
proc_table[3].ticks = proc_table[3].priority = 1;
...
}
这里不但改变了用户进程的特权级,而且通过改变eflags,还剥夺了用户进程所有的I/O权限。
另外,还有protect.c中初始化GDT中LDT描述符的代码和proc.c中进程调度的相关代码也进行了修改。
代码 kernel/protect.c,区分task和proc后初始化GDT中的LDT描述符。
PUBLIC void init_prot()
{
...
for (i = 0; i < NR_TASKS + NR_PROCS; i++) {
init_descriptor(&gdt[selector_ldt >> 3],
vir2phys(seg2phys(SELECTOR_KERNEL_DS), proc_table[i].ldts),
LDT_SIZE * sizeof(DESCRIPTOR) - 1,
DA_LDT);
p_proc++;
selector_ldt += 1 << 3;
}
}
代码 kernel/proc.c,区分task和proc后的进程调度。
PUBLIC void scheduld()
{
PROCESS* p;
int greatest_ticks = 0;
while (!greatest_ticks) {
for (p = proc_table; p < proc_table + NR_TASKS + NR_PROCS; p++) {
if (p->ticks > greatest_ticks) {
greatest_ticks = p->ticks;
p_proc_ready = p;
}
}
if (!greatest_ticks) {
for (p = proc_table; p < proc_table + NR_TASKS + NR_PROCS; p++) {
p->ticks = p->priority;
}
}
}
}
上述修改完成后,就可以make并运行了。虽然它的运行结果与之前是一样的,但是,这次的改动将又是一次标志性事件。它标志着Orange’S现在已经运行在了3个特权级之上,普通的用户进程从此和系统任务区分开来了。
printf
如今我们已经有了一个任务和三个用户进程,但已经好久没有看到过A、B、C三个进程的运行情况了。你一定也很想看到进程在特定终端运行的情景,而且,由于我们的TTY已经初具雏形,也是时候编写一个供输出使用的printf()了。
由于printf()要完成屏幕输出的功能,需要调用控制台模块中的相应代码,所以,它必须通过系统调用才能完成。
为进程指定 TTY
可以想象,当某个进程调用printf()时,操作系统必须知道往哪个控制台输出才行。而当系统调用发生,ring3跳入ring0时,系统只能知道当前系统调用是由哪个进程触发的。所以,我们必须为每个进程指定一个与之相对应的TTY,这可以通过在进程表中添加一个成员来实现。
代码 include/proc.h,进程表添加一个成员。
/* 进程结构体 */
typedef struct s_proc {
STACK_FRAME regs; /* process registers saved in stack frame */
u16 ldt_sel; /* gdt selector giving ldt base and limit */
DESCRIPTOR ldts[LDT_SIZE]; /* local descriptors for code and data */
int ticks; /* remained ticks */
int priority;
u32 pid; /* process id passed in from MM */
char p_name[16]; /* name of the process */
int nr_tty;
}PROCESS;
我们还是用与初始化PROCESS的ticks和priority成员时相同的方法来为nr_tty设置初值。
代码 kernel/main.c,为nr_tty这是初值。
PUBLIC int kernel_main()
{
...
for(i = 0; i < NR_TASKS + NR_PROCS; i++){
...
p_proc->nr_tty = 0;
...
}
...
proc_table[1].nr_tty = 0;
proc_table[2].nr_tty = 1;
proc_table[3].nr_tty = 1;
...
}
可以看到,在for循环中,所有进程的nr_tty都被初始化为0,这样,所有进程默认与第0个TTY绑定。不过在后面,B和C两个进程与第1个TTY绑定。这意味着,将来B和C的输出将同时出现在控制台1,而A的输出出现在控制台0。
printf()的实现
函数printf()对于我们来说肯定是非常熟悉的了,从学习HelloWorld的时候就开始用它了。但它的实现却并不简单,首先是它的参数个数和类型都可变,而且其表示格式的参数(比如:%d、%x等)形式多样,在printf()中都要加以识别。不过,我们并不做这么复杂的,只实现一个简单的。接下来我们实现的printf()只支持“%x”这一种格式。
代码 kernel/printf.c,printf。
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4); /* 4是参数fmt所占堆栈中的大小 */
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其中,vsprintf()的实现方法如下代码所示。
代码 kernelvsprintf.c,vsprintf。
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
函数vsprintf虽然只识别“%x”这一种格式,但其它格式的原理也是一样的,即根据%后的格式字符就能判断下一个参数的类型,从而知道从堆栈中取出什么。
系统调用 write()
下面我们就来编写系统调用write(),它把vsprintf输出的字符打印到屏幕上。由于我们已经实现过一个系统调用get_ticks(),所以再增加一个不再是什么难事。增加一个系统调用(假设为foo)的过程如下表所示。
步骤 | 内容 | 文件 |
1 | NR_SYS_CALL加一 | const.h |
2 | 为sys_call_table[]增加一个成员,假设是sys_foo | global.c |
3 | sys_foo的函数体 | 因具体情况而异 |
4 | sys_foo的函数声明 | proto.h |
5 | foo的函数声明 | proto.h |
6 | _NR_foo的定义 | syscall.asm |
7 | foo的函数体 | syscall.asm |
8 | 添加global foo | syscall.asm |
9 | 如果参数个数与以前的系统调用比有所增加,则需要修改sys_call | kernel.asm |
我们把这个新增的系统调用取名为write(),把它对应的内核部分取名为sys_write(),它们声明在proto.h中。
代码 include/proto.h,关于write()的函数声明。
PUBLIC int sys_write(char* buf, int len, PROCESS* p_proc);
...
PUBLIC void write(char* buf, int len);
这样第4、5步已经做好,而步骤1、2、6、8都是很容易的,剩下的工作就是添加write()和sys_write()这两个函数体了。先来看write()。
代码 kernel/syscall.asm,代码write()。
_NR_write equ 1
...
global write
...
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
ret
这里使用了ebx和ecx来传递两个参数。由于我们已有的系统调用是没有参数的,所以一会儿我们还需要修改sys_call。
再来看一下sys_write(),代码kernel/tty.c。
PUBLIC void tty_write(TTY* p_tty, char* buf, int len)
{
char* p = buf;
int i = len;
while (i) {
out_char(p_tty->p_console, *p++);
i--;
}
}
PUBLIC int sys_write(char* buf, int len, PROCESS* p_proc)
{
tty_write(&tty_table[p_proc->nr_tty], buf, len);
return 0;
}
sys_write()通过调用新增加的简单函数tty_write()来实现字符的输出。注意,sys_write()比write()多了一个参数,这个参数也是在我们即将要修改的sys_call中压栈的。
代码 kernel/kernel.asm,修改sys_call()。
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
由于当前运行的进程就是通过设置p_proc_ready来恢复执行的,所以当进程切换到未发生之前,p_proc_ready的值就是指向当前进程的指针。把它压栈就将当前进程,即write()的调用者指针传递给了sys_write()。
接下来我们再来修改一下console.c中的flush()函数,让其只有在进程是使用当前控制台的时候才进行光标设置和屏幕的滚动,如果不加判断的话,不同进程执行时由于使用的控制台是不一样的,会导致屏幕来回进行控制台切换。
代码 kernel/console.c,修改flush()。
PRIVATE void flush(CONSOLE* p_con)
{
if (is_current_console(p_con)) {
set_cursor(p_con->cursor);
set_video_start_addr(p_con->current_start_addr);
}
}
使用 printf()
这样,我们的第二个系统调用printf()就完成了。下面在3个用户进程中调用它。
代码 kernel/main.c,使用printf。
void TestA()
{
while(1) {
printf("<Tricks:%x>", get_ticks());
milli_delay(2000);
}
}
void TestB()
{
while(1) {
printf("B");
milli_delay(2000);
}
}
void TestC()
{
while(1) {
printf("C");
milli_delay(2000);
}
}
好了,我们现在make并运行,效果如下图所示。
可以看到运行成功了,真是太棒了,我们终于有了自己的printf,从此不但可以告别disp_str,而且,它是一个用户程序,可以被普通用户的用户进程调用。
现在让我们回顾一下printf的调用过程,如下图所示。
一个系统调用涉及特权级的切换,所以从实现到运行还是有一点复杂的。不过上图清晰地表示了这一过程,其中箭头表示函数之间的调用关系。
一切都明白之后,我们就可以好好享受一下我们的作品了。你可以来到控制台2的空白屏幕上敲击键盘,也可以来到控制台0看当前打印的数字,还可以来到控制台1看两个进程的字母交替出现。一切都向我们展示着一个多任务多控制台操作系统的特性。
公众号