首页 > 技术文章 > Linux内核分析-期中总结

kryst4l 2016-04-19 15:05 原文

 

第一周.通过分析汇编代码理解计算机是如何工作的

1.通过分析这段C语言代码的汇编代码,可以得到计算机程序执行的几个特点:

  • 总是通过EIP取得下一段要执行的代码,然后执行该段代码,即总是取指执行
  • 当进行函数调用时,堆栈会保存调用函数之前的程序状态,同时堆栈指针bp和sp会在一个伪初始位置
  • 每次函数调用结束,堆栈指针bp和sp回复到调用之前的状态

第二周.完成一个简单的时间片轮转多道程序内核代码

1. mypcb.h

       首先来看mypcb.h。其中定义了两个结构和一个函数。

 struct Thread {
     unsigned long        ip;
     unsigned long        sp;
 };

 

     第一个是结构Thread,里面有两个变量,ip和sp用于保存现场。

复制代码
复制代码
typedef struct PCB{
    int pid;
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
    char stack[KERNEL_STACK_SIZE];
    /* CPU-specific state of this task */
    struct Thread thread;
    unsigned long    task_entry;
    struct PCB *next;
}tPCB;
复制代码
复制代码

       第二个是结构PCB,PCB结构定义了进程管理块,包括6各变量:(1)pid进程标识符;(2)state状态,-1表示不可运行,0表示可运行,>0表示停止;(3)定义了一个栈空间;(4)一个Thread变量;(5)任务入口点;(6)下一个PCB的指针。

#define MAX_TASK_NUM        4
#define KERNEL_STACK_SIZE   1024*8

void my_schedule(void);

       还定义了一个my_schedule函数,以及两个宏定义。

2. mymain.c

 

tPCB task[MAX_TASK_NUM];
tPCB * my_current_task = NULL;
volatile int my_need_sched = 0;

 

       首先定义了3个全局变量,两个PCB结构,一个是所有的进程集合,一个是当前的进程。

void my_process(void);


void __init my_start_kernel(void){};

       然后是两个函数,my_process和my_start_kernel。

(1)my_start_kernel函数

       这个函数可以分为三部分来解析。

复制代码
复制代码
    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];
复制代码
复制代码

       第一部分,是初始化进程0。pid代表了进程号,0是第一个。state代表运行状态,初始化为可运行。Thread的ip就是进程入口点,其实就是进程运行的起点。sp实际上是定义了一段进程的栈空间。最后定义了下一个PCB的链接先指向自己。

复制代码
复制代码
    /*fork more process */
    for(i=1;i<MAX_TASK_NUM;i++)
    {
        memcpy(&task[i],&task[0],sizeof(tPCB));
        task[i].pid = i;
        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];
    }
复制代码
复制代码

       第二部分,是根据第一个进程0初始化余下的进程。因为我们设置最大进程数为4,所以这里实际上是设置了进程1-3的数据结构的值。

复制代码
复制代码
    /* 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 */
        "popl %%ebp\n\t"
        : 
        : "c" (task[pid].thread.ip),"d" (task[pid].thread.sp)    /* input c or d mean %ecx/%edx*/
    );
复制代码
复制代码

       最后一个部分,是从进程0号开始运行。这里使用了内联汇编编程,实际上就是将进程0的thread.sp的值赋给esp,将当前运行的地址保存到栈中,这样如果切换的话就可以保证下一个进程结束时回到原来的位置执行。

       总而言之,my_start_kernel函数实现了定义进程数组,并运行第一个进程。

(2)my_process函数

复制代码
复制代码
    int i = 0;
    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);
        }     
    }
复制代码
复制代码

      my_process函数很简单,就是建立一个循环不断运行进程,并输出表明进程正在运行的语句。这里注意有一个my_schedule()函数,实际上这个函数是在myinterrupt.c中实现的,主要作用是切换进程。

3. myinterrupt.c

extern tPCB task[MAX_TASK_NUM];
extern tPCB * my_current_task;
extern volatile int my_need_sched;
volatile int time_count = 0;

       首先定义了一些全局变量。然后主要实现了两个函数:my_time_handler和my_schedule,其中my_time_handler实现了中断,而my_schedule实现了中断之后进程的切换。

(1)my_time_handler函数

复制代码
复制代码
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;      
}
复制代码
复制代码

       这个函数也很简单,就是每1000毫秒的时候产生一个中断,产生中断之后把my_need_sched设置为1,这样mymain.c中的my_process函数就会调用my_schedule函数来进行进程切换。

(2)my_schedule函数

       这个函数才是重点,实现了时间片轮转的中断处理过程。

复制代码
复制代码
    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;
复制代码
复制代码

       首先是初始化next和prev两个PCB结构。

复制代码
复制代码
    if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */
    {
        /* 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)
        ); 
        my_current_task = next; 
        printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);       
    }
复制代码
复制代码

       这一段是循环运行代码,就是当下一个进程的state状态是可运行时,说明这个进程之前已经在运行了,此时可以继续执行,就切换到下一个进程,这中间有一段内联汇编,实现了保存栈地址和栈指针,这样进程切换回来的时候就可以正常运行。然后根据之前保存的栈地址恢复执行。

复制代码
复制代码
    else
    {
        next->state = 0;
        my_current_task = next;
        printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
        /* switch to new process */
        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)
        );          
    }   
复制代码
复制代码

       当下一个进程的state不为0时,那么也就是说下一个进程还从来都没有执行过,所以这一段内联汇编的作用是开始执行一个新进程。

 第三周.跟踪分析linux内核的启动过程

1.打开环境

执行命令:cd LinuxKernel/

执行命令:qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img

MenuOS便可以成功启动。可以测试三个命令“help,version,quit”的工作情况

2、使用gdb跟踪调试内核

执行命令:qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S 

冻结启动窗口,重新打开一个终端使用gdb命令调试

3.系统启动后首先执行一系列的初始化工作,直到start_kernel处,它是代码的入口点,相当于main.c函数。然后启动系统的第一个进程init,init是所有进程的父进程,由init再启动子进程,从而使得系统成功运行起来。

第四周.使用库函数API和C代码中嵌入汇编两种方式使用同一个系统调用

1.getpid的函数很简单,就是获取当前进程的进程号

 

.系统调用号放在eax中。
.系统调用的参数,按照顺序分别放在ebxecxedxesiediebp
.返回值使用eax传递

 

2.fork函数同样不需要参数,只有输出,

3.fork这个函数有个特点,就是调用一次返回两次,原因在于它复制出了一个子进程,执行同样地代码段。
区分子进程和父进程的手段就是检查返回值。

4.read函数需要三个参数。参数保存在ebx、ecx等寄存器中,这里的三个参数就是放在这三个寄存器中。最后一行的

 

:"b"(fd), "c"(buf), "d"(count)就是声明,fd使用的是ebx,buf使用ecx传递,count使用edx传递。

第五周.分析system_call中断处理过程

1.system_call()函数

首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器。

pushl %eax

SAVE_ALL

movl $0xffffe000, %ebx /* or 0xfffff000 for 4-KB stacks */

andl %esp, %ebx

接下来检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT标志之一是否被置为1,即检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。

如果系统调用号无效则把-ENOSYS值存放在栈中曾保存eax寄存器的单元中,当进程恢复在用户态的执行时会在eax中得到一个负的返回码。

cmpl $NR_syscalls, %eax

jb nobadsys

movl $(-ENOSYS), 24(%esp)

jmp resume_userspaces

最后调用与eax中所包含的系统调用号对应的特定服务例程。

call *sys_call_table(0, %eax, 4)


2.
从系统调用退出

当系统调用服务例程结束时,system_call()函数从eax获得返回值,并保存在曾经保存用户态eax寄存器值的栈单元位置上,用户态进程将在eax中找到系统调用的返回码。

movl %eax, 24(%esp)

system_call()函数关闭本地中断并检查当前进程的thread_info结构中的标志,如果所有的标志都没有被设置函数就会跳转到restore_all标记处,恢复保存在内核栈中的寄存器的值,并执行iret汇编语言指令以重新开始执行用户态进程。

第六周.分析一个linux内核创建一个新进程的过程 

1.Linux中一般进程都是由现有的一个进程创建的,也就是我们所说的父进程

2.具体的创建是通过fork()实现的

3.fork()的大体工作过程:   

 

1)在内存中申请一页内存存放进程控制块task_struct,并返回进程号nr,并在task数组的nr处存放task_struct的指针,还要将task的当前指针current指到nr处;  

 

2)将父进程的task_struct的内容复制到新进程的task_struct中作为模版  

 

3)对task_struct中的信息进行修改,主要进行一下工作:设置父进程、清除信号位图、时间片、运行时间、根据当前环境设置tss(内核态指针esp0指向task_struct所在页的顶端)、设置LDT的选择子等(根据nr指向GDT中相应的ldt描述符)。  

 

4)设置新进程的代码段、数据段的基地址和段长:更新task_struct中的代码开始地址:进程号(nr)×64M,更新task_struct中局部描述符表中的代码段和数据段描述符。    5)复制父进程的页表目录项和页表:在页目录表中,复制父进程的页表目录项,目的地址由新进程的线性地址计算出来;对每个对应的页表目录项申请一个空闲页,并用页表地址更新页表目录项,最后将父进程页表中各项复制到新进程对应的页表中,也就是说,这个时候,子进程与父进程共享物理内存。   

 

6)更新task_struct中的文件信息:文件打开次数加1,父进程的当前目录引用数加1。   

7)设置TSS和LDT描述符项:在全局描述符表(GDT)中设置新任务的TSS描述符项和LDT段的描述符项,使TSS描述符项和LDT描述符项分别指向task_struct的TSS结构和LDT结构。 

 8)将任务设置为就绪状态,向当前进程(父进程)返回新进程号。​

4.fork()中,内核并不立刻为新进程分配代码和数据物理内存页,新进程与父进程共同使用父进程已有的代码和数据物理内存页面。只有当以后执行过程中由一个进程一写方式访问内存时候被访问的内存页面才会在写操作之前被复制到新申请的内存页面中。

5.另外在fork的最后是将任务设置成了就绪状态,由于fork()是一个系统调用,在系统调用部分system_call.s,可以看到在系统函数返回后,会调用调度函数schedule(),在schedule()中,就会检测到新进程的就绪状态,并用switch_to()切换到新进程进行执行。

 第七周.linux内核如何装载和启动一个内核程序

1.可执行文件的创建就是三步:预处理、编译和链接。

cd Code                                              
vi hello.c              #写入最简单的helloworld的c程序
gcc -E -o hello.cpp hello.c -m32                    #-E参数就是生成预处理后的文件,看到-o后面的是生成的文件hello.cpp,注意它并不是cplusplus,而是随意起的后缀名
vi hello.cpp           #查看该文件,发现预处理做了把include的文件包含进来以及宏替换等工作。
gcc -x cpp-output -S -o hello.s hello.cpp -m32      #-x language filename作用是设定文件使用的语言,使后缀名无效。此处就是让刚才的cpp不要让编译器误会为cplusplus,而是当做cpp-output这种文件格式。-s是指生成汇编.s文件
vi hello.s                                          
gcc -x assembler -c hello.s -o hello.o -m32         #-c指将.s转为.o文件
vi hello.o                                          
gcc -o hello hello.o -m32           #-o指将.o文件链接为可执行的文件               
vi hello                                           
gcc -o hello.static hello.o -m32 -static   #静态链接       
ls -l                  #注意看结果中的各文件的大小,其中静态链接的很大,因为它把所需要的库一次包到进程(可执行文件)中

2.可执行文件属于目标文件之一。目标文件的格式为ELF。ELF的格式以段来组织的二进制代码

3.以ELF为格式的主要有三种文件:①可重定位文件:保持着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者一个共享文件。例如.o文件。
②可执行文件:可以运行的文件。该文件指出了exec(BA_OS)如何来创建进程映象。再来联想下程序和进程的区别。到底这种可执行文件是进程还是程序?我们发现它的段中只含.text和.data一类的段,而不含有堆栈段。所以可以确定它只是程序。当它被操作系统调入内存开始执行时才会真正的成为进程。例如.out文件。
③共享object文件:保存着代码和数据,被两个链接器链接。一个是连接编辑器,可以和其他可重定位和共享object文件来创建其他的object。第二个是动态链接器,联合一个可执行文件和其他共享object文件来创建一个进程映像。
4.ELF文件的头部:使用命令查看hello文件的头:shiyanlou:Code/ $ readelf -h hello

第八周.理解进程调度时机跟踪分析进程调度与进程切换的关系 

1.调度时机

不同类型的进程有不同的调度需求
第一种分类: 
      I/O-bound 
           频繁的进行I/O
           通常会花费很多时间等待I/O操作的完成
     CPU-bound 
           计算密集型
           需要大量的CPU时间进行运算
第二种分类 
    批处理进程(batch process) 
           不必与用户交互,通常在后台运行
           不必很快响应
           典型的批处理程序:编译程序、科学计算
    实时进程(real-time process) 
           有实时需求,不应被低优先级的进程阻塞
           响应时间要短、要稳定
           典型的实时进程:视频/音频、机械控制等
    交互式进程(interactive process) 
           需要经常与用户交互,因此要花很多时间等待用户输入操作
           响应时间要快,平均延迟要低于50~150ms
          典型的交互式程序:shell、文本编辑程序、图形应用程序等

 2.进程调度的时机

 

中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule(); 

 

用户态进程只能被动调度。

 

内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;

 

内核线程是只有内核态没有用户态的特殊进程。内核线程可以主动调度,也可以被动调度。

 

用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。

 

schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换
next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部
context_switch(rq, prev, next);//进程上下文切换
switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程

3.linux进程调度与进程切换

内容:

(1)从schedule()开始,几种不同类型的进程之间的调度选择;在相同类型的进程之间的调度选择算法
首先禁止抢占,获取当前CPU,该CPU的执行队列,队列上正在执行的进程,以及该进程的交换计数信息并释放该进程占用
的锁。
之后,对禁止中断,更新运行队列时钟,该队列的自旋时钟加锁,后清除当前进程的thread_flag中TIF_NEED_RESCHED。
如果进程不在可运行状态,并且可被抢占,若进程处于非阻塞挂起,则将其改为可运行,否则调用deactivate_task()函数,并修改上下文交换次数。其中在deactive_task()函数中调用了denqueue_task()函数:
(2)从CPU的IP值的变化上,说明在switch_to宏执行后,执行分析
(3)堆栈发生切换位置,在切换堆栈前后,current_thread_info变化
保存当前进程的flags ;将当前堆栈的基址压栈;保存当前的栈顶;内核堆栈的切换;保存当前进程的eip
(4)地址空间发生切换,解释地址空间的切换不会影响后续切换代码的执行
(5)current宏所代表的进程发生变化的源码位置
(6)任务状态段中关于内核堆栈的信息发生变化源码位置

  

 

推荐阅读