linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

陈三县+SA18225041+原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/ 

Table of Contents

一、汇编语言练习

二、嵌入式汇编练习

三、基于mykernel完成多进程的简单内核

实验总结


一、汇编语言练习

实验要求:使⽤Example的c代码分别⽣成.cpp,.s,.o和 ELF可执⾏⽂件,并加载运⾏,分析.s汇编代码在CPU 上的执⾏过程 。通过实验解释单任务计算机是怎样⼯作的,并 在此基础上讨论分析多任务计算机是怎样⼯作的。 

1、GCC的编译过程

一般情况下,C程序的编译过程分为:预处理阶段;编译成汇编代码阶段;汇编代码汇编成目标代码阶段;将目标代码链接成可执行文件阶段。如下图所示:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

1.1、预处理阶段

example.c源码如下:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

        gcc后使用-E参数,输出的文件的后缀为.cpp。即:gcc -E -o example.cpp example.c

 linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

打开example.cpp,如下(代码太多无法显示完全):

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

通过与源代码进行对比,可以发现,所谓的预处理即是在进行编译的第一遍扫描(词法扫描和语法扫描)时,对以符号“#”开头的预处理命令进行翻译,这里面包括三类主要的处理:

*1、在调用宏的地方进行宏替换,即将宏名替换为所定义的相应的字符串;

*2、用实际值替代“define”的文本;

*3、将include包含的标题文件和头文件的具体的内容拷贝到相应的源代码中;

        经过上面三个任务过程,最终生成了.cpp预处理文件。预处理之后的.cpp文件,较之前的源文件在字节数上有很大的变化,这就是将stdio.h头文件的具体内容拷贝过来的结果。

1.2、编译成汇编代码阶段

1.2.1、可以使用-S参数说明生成汇编代码后停止工作,由.c源文件直接编译生成.s汇编文件;即 gcc -S -o example.s example.c

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

1.2.2、 可以使用-x参数说明根据指定的步骤进行工作,然后由cpp-output指明从预处理得到的文件,同时使用-S参数说明生成汇编代码后停止工作;即 gcc -x cpp-output -S -o example1.s example.cpp

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 wc命令来对比一下两者字节大小上的区别:   其中,第一列表示相应文件中的行数,第二列表示相应文件中的单词数,第三列表示相应文件中的字节数。

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

打开文件发现两者内容一致:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

1.3、编译生成目标代码文件阶段 

1.3.1、可以使用-c参数,直接由.c源文件编译生成目标文件;即 gcc -c example.c -o example.o

1.3.2、 可以使用-x参数说明根据指定的步骤进行工作,然后由assembler指明由汇编文件生成相应的文件,同时使用-c参数说明生成目标文件后停止;即 gcc -x assembler -c example.s -o example.o

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

1.4、 链接生成可执行文件, 链接阶段主要是将各个.o目标文件链接起来,形成具体的可执行文件

1.4.1、使用-o参数,直接由.c源文件编译链接生成可执行文件;

1.4.2  使用-o参数,由.o目标文件链接生成可执行文件

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 

2、分析汇编程序在CPU上的执行过程

执行反汇编命令如下:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 得到如下代码:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

  • %rax(%eax) 用于做累加
  • %rcx(%ecx) 用于计数
  • %rdx(%edx) 用于保存数据
  • %rbx(%ebx) 用于做内存查找的基础地址
  • %rsi(%esi) 用于保存源索引值
  • %rdi(%edi) 用于保存目标索引值
  •  %rsp(%esp) 和 %rbp(%ebp) 作为栈指针和基指针

代码分析:

main函数

因main并不是程序拉起后第一个被执行的函数,它被_start函数调用,因此看到是main函数中的第一条语句push %rbp的执行过程为%rbp中保存前一个进程执行时所在的内存栈空间的基地址,因此为了保证在example.c程序执行完毕之后,CPU能正确恢复到之前所执行的进程中去继续执行,必须对前一个进程的上下文环境进行保存,这里即将前一个进程所占用的栈空间的基地址保存起来,从而实现了对前一个进程上下文环境的保存。这里我们假设前一个进程的栈基地址值为ebp1。

mov %rsp,%rbp这条语句的结果是将栈顶指针寄存器%rsp中存储的值传递给栈基地址指针寄存器%rbp,现在,%rbp和%rsp指向栈的同一个位置,均指向当前的栈顶。

mov $ox8, %edi 对于x86_64将参数传入通用寄存器的方式,而x86将参数压入调用栈中。

           callq 069<f>后栈的状态,执行call指令相当于执行了两条指令:push %rip和movl $0x609,%rip。

          经过调用跳转之后,现在跳转到函数f中执行,从函数f的反汇编代码可以看到,前三条语句与main函数的反汇编代码的语句相同,分别为push %rbp,mov %rsp,%rbp和sub $0x8,%rsp。这里主要是实现了对main函数的栈空间状态进行保存,以确保在从被调用函数中返回时,能正确的继续执行main函数。然后,为被调用执行的新函数f分配新的内存空间,主要是通过修改栈基地址指针寄存器%rbp的值实现的。

         callq 5fa <g>同理。后执行add $ox3, %eax,执行加法。后pop %rbq,一直返回至main函数。

3、讨论单任务计算机是如何工作的。

        由上述的example.c源程序的执行过程,我们可以看到单任务计算机执行的大体过程。首先,对于单任务计算机来说,不存在中断。程序是运行在线性地址空间当中。每个函数的执行都有自己相应的栈空间。%rbp(%ebp)栈基址寄存器中保存的是当前执行函数的栈空间的基地址,%esp栈顶指针寄存器中保存的是当前的栈的栈顶。当程序执行时,基本原则是从上到下顺序的执行当前执行函数中的每一条指令,当存在函数调用时,主要是通过call指令。call指令会首先保存当前执行函数中的call指令下一条指令的地址,即当前%rip寄存器中的值,然后将所要调用的函数的地址保存到%rip当中,作为实际上将要执行的下一条指令,即为所要跳转到的函数的第一条指令的地址。

        通常,跳转到的函数的前两条指令是push %rbp和mov %rsp,%rbp。先将%rbp中的值入栈,即保存调用函数的栈空间的基地址,这样做的目的是保护调用函数的栈空间上下文环境,以保证在调用返回时,能够正确的恢复到调用函数中继续执行。然后,将%rsp所指向的地址赋给%rbp,即让%rbp指针和%rsp指针指向同一处栈地址,也即为此时的栈顶位置。这样做相当于为被调用函数分配了一块新的栈空间。然后被调用函数再接着按照从上到下的顺序,顺序执行每一条语句。

        当被调用函数执行完毕后,会执行leave指令来将栈空间恢复到调用函数的栈空间当中,并且释放被调用函数所占的栈空间。然后调用ret指令来将%rip的值恢复到调用函数中call指令的下一条指令的地址值。这样就可以保证调用返回后,可以正确的从调用函数的call指令的下一条指令继续开始执行。

        上述分析,大体上说明了一个程序在单任务计算机中的执行过程。

4、多任务计算机工作方法的分析

        多任务计算机,顾名思义,就是可以同时有多个进程在运行,并且支持中断等机制。

        由上述的单任务计算机的分析,可以想到,多任务计算机的理念也应该大体相同,即多任务中的每一个任务的执行过程与原理,和单任务计算机中的一个任务的执行过程应该是相同的,但是关键在于同时有多个进程在运行,各个进程之间,如果一个进程在执行,另一个进程忽然之间打断了它的执行应该怎么处理。

        通过上面的分析,我们就可以很容易的想到,只要当由一个进程切换到另外一个进程执行时,我们可以先将第一个进程的硬件上下文,包括各个寄存器的值,进程所占栈空间的状态等信息保存起来,以保证在由第二个进程恢复到第一个进程时,能够正确的恢复第一个进程的各个寄存器的值以及对应的栈空间的状态。然后切换到第二个进程中去执行,当第二个进程执行结束之后,会恢复栈空间和相应寄存器到进程切换之前时,第一个进程执行所处的状态,然后再从第一个进程被打断处继续执行。


二、嵌入式汇编练习

1、堆栈的作⽤是什么?请使⽤⼀个实例说明堆栈 的作⽤。

答:1. 子程序调用和中断服务时CPU自动将当前PC值压栈保存,返回时自动将PC值弹栈。2. 保护现场/恢复现场。3. 数据传输。例如用来保存CALL指令调用子程序时的返回地址,RET指令从堆栈中获取返回地址。中断指令INT调用中断程序时,将标志寄存器值、代码段寄存器CS值、指令指针寄存器IP值保存在堆栈中。

2、 为什么要有内核态与⽤户态的区别?请结合32 位x86说明在Linux中,⽤户态与内核态有哪些 区别?在什么情况下,系统会进⼊内核态执⾏?

答:在CPU的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等。如果所有的程序都能使用这些指令,那么系统会非常危险。所以,CPU将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。

通常32位Linux内核地址空间划分0~3G为用户空间,3~4G为内核空间。Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如C函数库中的内存分配函数malloc(),它具体是使用sbrk()系统调用来分配内存,当malloc调用sbrk()的时候就涉及一次从用户态到内核态的切换,类似的函数还有printf(),调用的是wirte()系统调用来输出字符串,等等。

从用户态到内核态的切换,一般存在以下三种情况:

1)当然就是系统调用。

2)异常事件: 当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常。

3)外围设备的中断:当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。


三、基于mykernel完成多进程的简单内核

3.1 部署kernel内核

sudo apt-get install qemu # install QEMU
sudo ln -s /usr/bin/qemu-system-i386 /usr/bin/qemu
wget https://www.kernel.org/pub/linux/kernel/v3.x/linux-3.9.4.tar.xz # download Linux Kernel 3.9.4 source code
wget https://raw.github.com/mengning/mykernel/master/mykernel_for_linux3.9.4sc.patch # download mykernel_for_linux3.9.4sc.patch
xz -d linux-3.9.4.tar.xz
tar -xvf linux-3.9.4.tar
cd linux-3.9.4
patch -p1 < ../mykernel_for_linux3.9.4sc.patch
make allnoconfig
make

按照老师教程完成上述命令,完成如下:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 期间出现如下问题:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

因为linux-3.9.4是老版本的内核,没有compiler-gcc5.h。解决方案:将complier-gcc4.h拷贝给complier-gcc5.h。然后再回到linux-3.9.4路径下进行编译

最后make成功。

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

测试内核是否运行正常,运行qemu -kernel arch/x86/boot/bzImage,从qemu窗口中您可以看到my_start_kernel在执行,同时my_timer_handler时钟中断处理程序周期性执行。

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

cd mykernel 您可以看到qemu窗口输出的内容的代码mymain.c和myinterrupt.c

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

mymain.c代码如下:

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 放入mykernel的源码,重新编译,执行make allnoconfig,make,运行qemu

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

出现如下错误: 

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

修改后,重新编译

linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

 linux操作系统分析实验一(汇编语言练习&&嵌入式汇编练习&&基于mykernel完成多进程的简单内核)

代码分析

 mypcb.h

#define MAX_TASK_NUM        4
#define KERNEL_STACK_SIZE   1024*2 
/* CPU-specific state of this task */
struct Thread {
    unsigned long       ip;
    unsigned long       sp;
};
typedef struct PCB{
    int pid;
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
    unsigned long stack[KERNEL_STACK_SIZE];
    /* CPU-specific state of this task */
    struct Thread thread;
    unsigned long   task_entry;
    struct PCB *next;
}tPCB;
void my_schedule(void);

在这个文件里,定义了 Thread 结构体,用于存储当前进程中正在执行的线程的ip和sp(其中ip用来保存当前指令执行位置,sp用来保存栈顶位置)。

PCB结构体中的各个字段含义如下(这个结构体作为进程控制块,存储了进程id,进程状态,并且有next指针,可以形成进程链表):

  • pid:进程号
  • state:进程状态,在模拟系统中,所有进程控制块信息都会被创建出来,其初始化值就是-1,如果被调度运行起来,其值就会变成0
  • stack:进程使用的堆栈
  • thread:当前正在执行的线程信息
  • task_entry:进程入口函数
  • next:指向下一个PCB,模拟系统中所有的PCB是以链表的形式组织起来的。
  • my_schedule声明,在myinterrupt.c中实现,在mymain.c中的各个进程函数会根据一个全局变量的状态来决定是否调用它,从而实现主动调度。

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;
volatile int my_need_sched = 0;
void my_process(void);
void __init my_start_kernel(void)
{
    int pid = 0;
    int i;
    /* Initialize process 0*/
    task[pid].pid = pid;
    task[pid].state = 0;/* -1 unrunnable, 0 runnable, >0 stopped */
    task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process;
    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++)
    {
        memcpy(&task[i],&task[0],sizeof(tPCB));
        task[i].pid = i;
    //*(&task[i].stack[KERNEL_STACK_SIZE-1] - 1) = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE-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] */
    pid = 0;
    my_current_task = &task[pid];
    asm volatile(
        "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 */
        : 
        : "c" (task[pid].thread.ip),"d" (task[pid].thread.sp)   /* input c or d mean %ecx/%edx*/
    );
} 
int i = 0;
void my_process(void)
{    
    while(1)
    {
        i++;
        if(i%10000000 == 0)
        {
            printk(KERN_NOTICE "this is process %d -\n",my_current_task->pid);
            if(my_need_sched == 1)
            {
                my_need_sched = 0;
                my_schedule();
            }
            printk(KERN_NOTICE "this is process %d +\n",my_current_task->pid);
        }     
    }
}

这段代码显示了mymain.c程序的总体框架。首先是新建了一个PCB结构的任务数组,初始化一个进程0(PID初始化为0,state初始化为0,紧接着初始化ip,sp等),该进程状态为0,即runnable,然后task_entry指向my_process,即指向my_process()函数的地址,然后thread.sp指向stack[]的最尾地址,最后将next指向自己,因为此时系统中只有自己一个进程。for循环这段代码是继续创建上下的进程,总共创建了4个进程(MAX_TASK_NUM=4),然后把这4个PCB结构的控制块连接成链表结构。下面到了嵌入式汇编结构,这部分代码主要初始化EIP,EBP,ESP寄存器,使得进程0能够直接被执行,这段汇编代码中,先将堆栈指针sp赋给了ESP寄存器,然后将堆栈指针sp内容压栈,之后将指令指针ip的内容也压栈,下一条指令是ret,它是将当前栈中ESP所指的内容出栈到EIP中,当前ESP所指的内容就是前一条指令压栈进去的ip的值,现在使得EIP寄存器的内容就是进程0的入口地址(ip内容),从而使得进程0能够被执行。最后一段代码my_process(void)就是进程执行时候的实际运行内容,修改if语句中的内容,可以调控print语句的输出速度(i循环1000000次,调度一次,即调用my_schedule()函数,前一进程终止运行,保存现场;后一进程的ip内容传送到EIP寄存器中,使得下一进程开始执行)。

myinterrupt.c

/*
 *  linux/mykernel/myinterrupt.c
 *
 *  Kernel internal my_timer_handler
 *
 *  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"
extern tPCB task[MAX_TASK_NUM];
extern tPCB * my_current_task;
extern volatile int my_need_sched;
volatile int time_count = 0;
/*
 * Called by timer interrupt.
 * it runs in the name of current running process,
 * so it use kernel stack of current running process
 */
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;     
}
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 */
    {        
        my_current_task = next; 
        printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);  
        /* switch to next process */
        asm volatile(   
            "pushl %%ebp\n\t"       /* save ebp */
            "movl %%esp,%0\n\t"     /* save esp */
            "movl %2,%%esp\n\t"     /* restore  esp */
            "movl $1f,%1\n\t"       /* save eip */ 
            "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)
        ); 
    }  
    return; 
}

首先my_timer_handler(void)函数是一个时间调度函数。换句话说就是实现了时间片轮转的时间约定,每当他执行的时候就是一个进程调度的过程。代码中#if 1表示需要编译器编译后面的内容,#endif表示结束#if的约束控制。每当这段代码执行时,这段程序都要进行编译。

my_schedule()这段代码是整个程序中的关键代码部分。代码开始部分,先定义了两个PCB结构的指针prec和next。之后判断能否进行调度。下面的if_else语句是调度的过程,这里分为两种情况,一种是调度一个已经运行过的进程,另一种情况是调度一个新的未运行过得进程。mymain的my_start_kernel()完成每个进程初始化,每个进程的任务都是my_process(),由于这个函数中有个无限循环,任务永远不会结束;并且启动了0号进程。任务需要调度时根据任务链表顺序进行调度。

实验总结

通过这次实验,了解了基本X86汇编知识。通过实现这个简单的时间片轮转多道程序内核,能够加深对计算机操作系统工作原理的了解。