linux内核分析课程笔记(二)

时间:2022-11-18 16:49:44

运行一个精简的操作系统内核

存储程序计算机是几乎所有计算机的基础逻辑框架。
堆栈是计算机中非常基础的东西,在最早计算机没有高级语言时,在高级语言出现之前,我们没有函数的概念。但高级语言出现后有了函数调用后,堆栈就显得非常重要了。

堆栈

堆栈式C语言运行时必须记录的一个记录调用路径和参数的空间:

  • 函数调用框架
  • 传递参数
  • 保存返回地址
  • 提供局部变量空间

32位x86是使用堆栈传递参数,64位的稍有不同。

C语言编译器对堆栈的使用有一套的规则,不同的指令序列也可能实现相同的功能,所以在Linux上反汇编一个C程序,和在windows上反汇编得到的AT&T汇编一般有差异。

寄存器

堆栈相关寄存器:

  • esp,堆栈指针(stack pointer)
  • ebp,基址指针(base pointer)

其他关键寄存器:

  • cs:eip : 总是指向下一条的指令地址(注意这里下一条指令的地址不一定地址连续,要看控制流而定!)
    • 顺序执行:指向地址连续的下一条指令
    • 跳转/分支,cs:eip 根据程序需要被修改
    • call: 将cs:eip 压入栈顶,cs:eip 指向被调用函数的入口地址
    • ret: 将栈顶弹出原来保存在这里的cs:eip 的值,放入cs:eip中。
pushl %ebp
movl %esp,%ebp
do sth.(Assembly)

首先使用 gcc -g可以得到test.c的可执行文件test,然后使用objdump -S可以获得 test的反汇编文件。

有压栈必有出栈,有生必有死。

#include <stdio.h>
int  main() {
/* val1+val2=val3 */ 
unsigned int val1 = 1; 
unsigned int val2 = 2; 
unsigned int val3 = 0; printf("val1:%d,val2:%d,val3:%d\n",val1,val2,val3); 
asm volatile( 
"movl $0,%%eax\n\t" /* clear %eax to 0*/ 
"addl %1,%%eax\n\t" /* %eax += val1 */ 
"addl %2,%%eax\n\t" /* %eax += val2 */ 
"movl %%eax,%0\n\t" /* val2 = %eax*/ 
: "=m" (val3) /* output =m mean only write output memory variable*/ 
: "c" (val1),"d" (val2) /* input c or d mean %ecx/%edx*/ 
); 
printf("val1:%d+val2:%d=val3:%d\n",val1,val2,val3);
return 0;
}

Asm内联汇编的一个重要的技巧在于在后面涉及到
:有关的,按照顺序从前到后可以使用编号替换变量。编号的顺序按照:后出现的前后关系而定,从0开始编号。比如在这个例子里,%0即是变量val3%1即是变量val1%2即变量val2
因为%n代表的是变量,所以我们在表示寄存器时需要转义,即使用双百分号%%eax其实等价于%eax,第一个百分号表示转义。

利用mykernel模拟多进程切换

早期的计算机执行完一个程序之后,执行另外一个程序。
由CPU和内核代码共同实现了保存现场和恢复现场。

疑问:

在内联汇编

asm volatile(
    "movl %1,%%esp\n\t"
    "pushl %1\n\t"
    "pushl %0\n\t"
    "ret\n\t"
    "popl %%ebp\n\t"
    :
    :"c"(task[pid].thread.ip),"d"(task[pid]).thread.sp)
);

疑问:movl %1,%%esp 第一句已经将栈顶设置为 task[pid].thread.sp了。
但是为什么后面要push %1\n\t 在 popl %%ebp呢?
上面这段代码与下面这段代码难道是不等价的吗?
movl %1,%%esp\n\t
pushl %0\n\t
ret\n\t
movl %%esp,%%ebp\n\t

自问自答:实际上是不一样的,因为这段汇编到ret的时候,会跳走执行task[pid].thread.ip地方的汇编,不是一段直接顺序执行到底的汇编代码!
而实际上为了保持对称性,所以在一开始的时候将整个系统的ebp压入栈中,不过事实上如果内核运行正常,是不会执行到最后的Popl的,因为在调度中应当是个死循环,while(1),保持一直有进程在占用运行,否则整个系统就"停下来了"。

实验截图

内核的重新make编译生成第一步:

linux内核分析课程笔记(二)

内核编译生成后,第二步:
linux内核分析课程笔记(二)

最终跑起来内核调度程序时的现象:
linux内核分析课程笔记(二)

进程切换分析

首先我们来分析主程序代码:

mykernel/mymain.c

/*
 *  linux/mykernel/mymain.c
 *
 *  Kernel internal my_start_kernel
 *
 *  Copyright (C) 2013  Mengning
 *
 */
#include <linux/types.h>
#include <linux/string.h>
#include <linux/ctype.h>
#include <linux/tty.h>
#include <linux/vmalloc.h>


#include "mypcb.h"

tPCB task[MAX_TASK_NUM];
tPCB * my_current_task = NULL;
/*指向当前PCB块的指针*/
volatile int my_need_sched = 0;
/*是否要调度的标志,当 my_need_sched 为1时表明要被调度*/

void my_process(void);


void __init my_start_kernel(void)
{
    int pid = 0;
    int i;
    /* 初始化0号进程 */
    task[pid].pid = pid;
    task[pid].state = 0;/* -1 unrunnable, 0 runnable, >0 stopped */
    /* 初始化进程的代码段入口,并且注意要设置进程上下文中的ip(ip = %eip?) */
    task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process;
    /* 这里使用了数组来模拟一个进程的栈,实际上 &task[pid].stack[KERNEL_STACK_SIZE-1] = &*(add of task[pid].stack + KERNEL_STACK_SIZE - 1),
    最终得到的是 stack 所模拟的栈顶的位置,这个栈是从数组标号高的地方向低的地方增长。 */
    task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
    task[pid].next = &task[pid];
    /*fork more process */
    for(i=1;i<MAX_TASK_NUM;i++)
    {
        /* 将task[0]的有关tPCB的所有信息拷贝到 task[i]处,进程的初始化信息大部分是一致的,注意tPCB 包含了 Thread*/
        memcpy(&task[i],&task[0],sizeof(tPCB));
        task[i].pid = i;
        /* 目前只有0号进程应该被调度执行,其他暂时还不行 */
        task[i].state = -1;
        /* 重设栈指针 */
        task[i].thread.sp = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
        task[i].next = task[i-1].next;
        task[i-1].next = &task[i];
    }
    /* start process 0 by task[0] */
    /* 首先启动0号进程开始运行 */
    pid = 0;
    my_current_task = &task[pid];
    asm volatile(
        /* 根据进程控制块中的sp值,设置当前task的栈顶指针 */
        "movl %1,%%esp\n\t"     /* set task[pid].thread.sp to esp */
        "pushl %1\n\t"          /* push ebp */
        "pushl %0\n\t"          /* push task[pid].thread.ip */
        "ret\n\t"               /* pop task[pid].thread.ip to eip */
        "popl %%ebp\n\t"
        : 
        : "c" (task[pid].thread.ip),"d" (task[pid].thread.sp)   /* input c or d mean %ecx/%edx*/
    );
}   
void my_process(void)
{
    int i = 0;
    while(1)
    {
        i++;
        /* 之前我们设置了0号进程的task_entry(代码段入口)是 my_process,所以当0号进程启动并开始执行代码段时,my_process开始执行。
                
        if(i%10000000 == 0)
        {
        /* 一个进程每循环1000万次后即打印一条信息 */
            printk(KERN_NOTICE "this is process %d -\n",my_current_task->pid);
            /* 如果 my_need_sched 为1时,则启动调度器去调度其他进程 */
            if(my_need_sched == 1)
            {
                my_need_sched = 0;
                my_schedule();
            }
            printk(KERN_NOTICE "this is process %d +\n",my_current_task->pid);
        }     
    }
}

有一个有意思的地方在于这段代码使用简单的两行实现了一个循环链表的结构。在初始化0号进程之后,我们使用了task[pid].next = &task[pid];,此时当前PCB的next指向自身,形成一个循环的结构。之后在向循环链表中插入新的元素时使用了task[i].next = task[i-1].next; task[i-1].next = &task[i];,非常精妙。

在 my_process 里是一个不断运行的死循环,那么我们什么时候才会使 my_need_sched 为1呢?答案在 my_timer_handle 函数中,当时钟中断产生后,会默认调用该函数:

void my_timer_handler(void)
{
#if 1
    if(time_count%1000 == 0 && my_need_sched != 1)
    {
        printk(KERN_NOTICE ">>>my_timer_handler here<<<\n");
        my_need_sched = 1;
    } 
    time_count ++ ;  
#endif
    return;     
}

我们可以看到,当 time_count 为 1000的倍数 且 my_need_sched 仍为0 时,我们设置了 my_need_sched 为1,从而"阻塞"当前进程,使得它主动放弃处理器,执行my_schedule函数调度其他进程。

void my_schedule(void)
{
    tPCB * next;
    tPCB * prev;
    
    if(my_current_task == NULL 
        || my_current_task->next == NULL)
    {
        return;
    }
    printk(KERN_NOTICE ">>>my_schedule<<<\n");
    /* schedule */
    next = my_current_task->next;
    prev = my_current_task;
    if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */
    {
        /* switch to next process */
        /* 这里是进程切换中最重要的一步,进程上下文的切换 */
        asm volatile(
                /* 一个是保存ebp 栈顶,一个是更新 thread中的栈顶指针 */    
            "pushl %%ebp\n\t"       /* save ebp */
            "movl %%esp,%0\n\t"     /* save esp */
                /* %esp = next-> thread.sp,切换栈顶指针*/
            "movl %2,%%esp\n\t"     /* restore  esp */
                /* 这句的语法很独特,按照意思来理解是 将 prev->thread.ip 设置为 label(标签)1的位置的地址*/
            "movl $1f,%1\n\t"       /* save eip */
                /* pushl 和 ret 使得切换到另一个进程本身的代码段开始运行 */
            "pushl %3\n\t" 
            "ret\n\t"               /* restore  eip */
            "1:\t"                  /* next process start here */
            "popl %%ebp\n\t"
            : "=m" (prev->thread.sp),"=m" (prev->thread.ip)
            : "m" (next->thread.sp),"m" (next->thread.ip)
        ); 
        my_current_task = next; 
        printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);      
    }
    else
    {
        next->state = 0;
        my_current_task = next;
        printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
        /* switch to new process */
        /* 要选择切换到的是一个新的进程,是第一次切换到该进程运行,但注意,这里可以发现没有label 1,但是依旧使用了 movl $1f,%1\n\t */
        asm volatile(   
            "pushl %%ebp\n\t"       /* save ebp */
            "movl %%esp,%0\n\t"     /* save esp */
            "movl %2,%%esp\n\t"     /* restore  esp */
            "movl %2,%%ebp\n\t"     /* restore  ebp */
            "movl $1f,%1\n\t"       /* save eip */  
            "pushl %3\n\t" 
            "ret\n\t"               /* restore  eip */
            : "=m" (prev->thread.sp), \"=m" (prev->thread.ip)
            : "m" (next->thread.sp), "m" (next->thread.ip)
        );          
    }   
    return; 
}

最困惑的地方:
1、为什么在进程上下文切换的时候,使用了这样的语句搭配label 1: 设置下个进程该开始的地方呢?进程代码段之间是独立的,为什么要强行设置另一个进程的ip呢?
"movl $1f,%1\n\t" /* save eip */
2、这里的pushl %ebp和 popl %ebp 本身是否是有意义的?

总结

第一节中我们了解到冯诺依曼体系结构实际上是存储计算机模型,即CPU运行已经装在内存中的预定代码段以达到预期的效果。
在这节中我们了解到这种特性,其中一点映证就在于在操作系统中是不断有进程在运行的,不会出现某个时刻系统正常但没有任何一个进程在运行的情况。进程的上下文也可以看作是进程的环境,每个进程在不同的时刻环境会不同,所以为了保证进程之间不会互相影响,实现进程之间的互相隔离(也叫透明性),所以在进程切换时也要进行进程上下文的切换。
在本次实验中的精简内核第一个运行的进程是0号进程,但本次实验中的0号进程好像没有什么特殊之处,与其余进程执行的是相同的代码~