VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > Java教程 >
  • ucore操作系统学习(一) ucore lab1系统启动流程分析

一、ucore操作系统介绍

  操作系统作为一个基础系统软件,对下控制硬件(cpu、内存、磁盘网卡等外设),屏蔽了底层复杂多样的硬件差异;对上则提供封装良好的应用程序接口,简化应用程序开发者的使用难度。站在应用程序开发人员的角度来看,日常开发中常见的各种关于并发、I/O、程序通信的问题等都和操作系统相关,因此一定程度上了解底层的操作系统工作原理是有必要的。

  另一方面,由于操作系统自身功能的复杂性,整体设计一般会有一个好的模块化架构;操作系统作为基础服务,对性能效率的要求也很高,底层会用到许多关于数据结构和算法相关的知识。如果仔细的研究一个操作系统的源码,既可以学习设计一个复杂软件的架构知识,又可以看到偏理论的数据结构和算法知识是如何被运用在实际场景中的,更深刻的体会不同数据结构、算法在特定场景下的性能差异。

  然而对于初学者而言,学习操作系统并不是一件轻松的事情。操作系统理论的学习过于抽象,往往看了就忘。而主流商业操作系统动辄十万、百万级的内核源码也令想要一窥究竟的普通人望而却步。对于一个已经迭代发展相当一段时间的系统,个人认为好的学习方法不是从最新的,相对复杂的版本开始了解,而是从最初始的,较为简单的版本起步,研究其是如何一步步优化、迭代至如今的这个版本。经过无数人迭代、优化的最新版本linux内核固然无比复杂,但90年代早期发布的版本却简单太多,更容易理解和学习,在掌握了相对简单的早期版本后,能降低后续学习更复杂的版本的难度。

  对于操作系统的学习而言,有不少大牛都出版了关于如何实现一个简易版操作系统的书籍,例如《Orange'S:一个操作系统的实现》《30天自制操作系统》等等。不少大学也开始对操作系统的课程进行改革,不再是枯燥的灌输理论知识点,而是尝试着让学生亲自动手实现一个demo操作系统,加深对知识内容的理解。其中麻省理工大学的公开课MIT 6.828是出品较早,久负盛名的。

  本系列博客的主角,是由清华大学出品的操作系统网上公开课,其中的实验课程就需要学生通过一个个的迭代实验,逐步实现一个名为ucore的操作系统。其实验指导书上对ucore os的评价是"麻雀虽小,五脏俱全",非常适合操作系统初学者进行学习。

ucore项目github仓库链接:https://github.com/chyyuu/os_kernel_lab (master分支)

ucore实验指导书链接:https://chyyuu.gitbooks.io/ucore_os_docs/content/

ucore公开课视频链接(学堂在线):https://www.xuetangx.com/course/THU08091000267

二、学习ucore所需要的准备知识

  工欲善其事,必先利其器。操作系统作为一门综合性的课程,需要掌握一定的前置基础知识才能顺利的完成ucore操作系统的学习。

1.C语言  

  ucore内核中绝大多数的功能都是使用C语言实现的,掌握C语言是学习ucore的基础。除了熟练掌握C语言的基础语法知识之外,最好能对宏、指针有一定了解。

  推荐学习书籍:《C primer》

2.x86汇编语言(32位)

  ucore内核是运行在80386这一32位x86架构的cpu之上的。虽然ucore的内核主要是由C语言实现的,但由于操作系统是贴近底层,与硬件有频繁交互的系统程序。在ucore中,CPU加电后的内核引导程序以及中断、与特定硬件交互时的部分都需要通过x86汇编来实现。

  如果是汇编语言的初学者,强烈建议先学习8086汇编语言,建立一个对底层CPU工作原理的基本知识结构后,再学习更为复杂的32位汇编。  

  需要注意的一点是,ucore中的x86汇编代码是以AT&T格式编写的,和Intel格式的x86汇编虽然逻辑等价,但写法上有较大差异。对于通过Intel格式入门汇编的初学者来说,需要稍微适应一下。

  推荐学习书籍:《汇编语言》王爽著(8086汇编)、《X86汇编从实模式到保护模式》(80386汇编)

3.80386CPU工作原理

  汇编语言对应的是机器码,其中的很多功能都与CPU硬件紧密关联。80386的分页、中断,特权级等功能在ucore操作系统的实现中扮演了重要的角色。如果对80386的工作原理了解不够,在阅读ucore与之相关的源码时会有困难。

  推荐学习书籍:《X86汇编从实模式到保护模式》、《intel 80386参考手册》

4.C语言机器实现底层(与x86汇编的关联)

  在ucore中经常会出现c和汇编代码互相调用的地方。要想理解其工作原理,需要去理解C语言编译后生成的底层机器指令(汇编),统一的站在汇编语言的角度来思考。你需要了解C中的结构体、数组等数据结构在内存中的是如何排布的,C中的指针操作是如何被转换成各种内存寻址指令的,C中的函数调用与返回过程中,参数压栈出栈等操作时栈上数据是如何变化的等等。

  其实C中的指针等比较难理解的概念,在有了一定的汇编语言基础后会理解的更加透彻。C中的指针和结构体使得程序员不必再去思考汇编层面中繁琐的内存访问偏移量计算,统统的交由编译器处理,C程序员的脑力得到解放,能够站在更高的抽象层面去思考更复杂的业务问题。

  有了C语言和汇编的基础后,可以通过编写简单的C程序,查看其反汇编代码来进行相关的学习(通过32位的编译器)。

  推荐学习书籍:《深入理解计算机系统》(Computer Systems A Programmer's perspective  CSAPP)

5.基本的数据结构知识

  ucore中所涉及到的通用数据结构并不多,只需要对双向链表和哈希表有一定了解即可。

  虽然在后续的实验中参考linux的实现引入了红黑树等复杂数据结构优化一些算法的实现,但并不涉及核心流程,如果不是学有余力,在ucore的学习过程中当做一个黑盒子去看待就行。

  推荐学习公开课视频: 清华大学出品的数据结构公开课(邓俊辉)

  初学者在学习ucore的过程中碰到的一个很大的困难就是lab1作为最初始的一个实验,为了搭建起一个能实际运行的系统,一下子引入了很多内容。这里面既有生成img镜像的功能,也有bootloader加载内核的功能,还有许多与硬件交互的代码逻辑,这些信息铺天盖地的涌来,容易劝退初学者。当时的我就差点被劝退了,但由于自己强烈的好奇心以及实验指导书首页的提醒:“lab1和lab2比较困难,有些同学由于畏难而止步与此,很可惜。通过lab1和lab2后,对计算机原理中的中断、段页表机制、特权级等的理解会更深入,等会有等同于打通了任督二脉,后面的实验将一片坦途。”,最终还是坚持了下来。实际的感觉也确实如此,如果能理解lab1、lab2中诸多硬件相关的知识和C内核实现中很多巧妙但晦涩的指针、宏的用法,后续的实验将简单很多。

  在整个ucore的学习过程中,除了公开课的视频和资料外,网上很多关于ucore学习的博客也给了我很大帮助,所以我也希望能通过博客分享自己的学习心得,帮助到更多对操作系统、ucore感兴趣的人。如果实验中碰到不懂的地方,多通过关键字去搜索相关资料以及网上关于ucore学习的博客能够起到事半功倍的作用。

三、ucore操作系统lab1 系统加载启动过程分析

  下面进入正题,开始分析ucore在实验课程lab1中的内容:ucore系统加载启动过程的分析。

  ucore的lab1项目结构从整体来看,按照执行的流程顺序分为三部分:img磁盘映像的生成引导内核的bootloader程序ucore内核的初始化

ucore的img磁盘映像生成

  ucore整体是一个makefile项目。通过make指令,解析项目中的makefile文件后会生成一个ucore.img磁盘映像。(lab1的实验课视频演示中可以详细的看到构建的全过程)

  这个磁盘映像主要由两大部分组成:位于第一个扇区即引导扇区的ucore bootloader程序,以及第二个扇区开始往后的ucore kernel内核程序。

  80386CPU在加电启动之初,会执行固化在BIOS中的程序。BIOS由于容量有限,自身不提供加载完整操作系统内核的功能,而是约定好会读取磁盘中第一个扇区(引导扇区)中的内容,将其加载至内存地址空间0x7c00处,在加载完毕后,令CS:IP指向0x7c00,跳转执行引导扇区中的引导程序的第一条指令。为了避免所加载的磁盘引导扇区是一个无效扇区(可能引导扇区中的内容就是空的或是乱码),要求512字节大小的扇区在其最后两字节必须是0x55AA(其余的空余空间可以用0填充),否则无法通过BIOS的校验,引导失败。

  ucore的makefile文件中,将项目中位于boot文件夹下的程序放入了ucore的第一个扇区,在makefile的"#create bootblock"注释开头的段中有所体现。其中调用了/tool/sign.c来生成写入一个合法的引导扇区。

  由于项目中的makefile文件中有不少复杂脚本,如果对makefile工作原理不熟悉,在ucore的学习中可以降低要求,大致了解一下每一部分的代码大概在干什么即可,不必强求理解每一行,避免在学习之初就产生太强的挫败感。

如果想对通过makefile是如何一步步完整的生成磁盘映像感兴趣,可以参考以下内容:

  1. lab1项目目录下的report.md实验报告示例

  2. https://www.jianshu.com/p/2f95d38afa1d  其中对lab1中makefile的分析非常详细

ucore的bootloader引导程序

  当BIOS加载完引导扇区的内容至内存后,CPU便会跳转到0x7c00执行命令,此时CPU的控制权便交给了ucore的引导程序bootloader。引导程序主体由boot文件夹下的bootasm.S和bootasm.c共同组成,其中bootasm.S由于构建时靠前,是先执行的。

令CPU进入保护模式

  bootasm.S的主要工作就是令80386从加电时默认的实模式切换到32位保护模式,通过代码的注释可以看到,由于一些历史原因要令80386正确的进入保护模式还是有点小麻烦的(并不是简单的调整一个开关位就行)。在《X86汇编语言 从实模式到保护模式》一书中对此有更加详细的介绍。

  在通过汇编指令完成80386从实模式至保护模式的切换后,通过call bootmain指令,跳转至bootmain.c中的bootmain函数完成引导内核的工作。

bootasm.S:

复制代码
  1 #include <asm.h>
  2 
  3 # Start the CPU: switch to 32-bit protected mode, jump into C.
  4 # The BIOS loads this code from the first sector of the hard disk into
  5 # memory at physical address 0x7c00 and starts executing in real mode
  6 # with %cs=0 %ip=7c00.
  7 
  8 # 80386CPU为了兼容8086程序,最开始启动时是以16位的实模式进行工作的
  9 # 生成img磁盘映像时,bootasm.S中的引导代码将会被放在引导扇区

 10 # 80386CPU加电启动后,会执行BIOS中的默认引导程序,BIOS引导程序会将引导扇区中(第一个磁盘块)的内容读入内存,并放置在0x7C00(16位)/0x00007c00(32位)处

 11 # 随后CPU会跳转到0x7c00处开始第一条指令的执行,即bootasm.S的第一条指令(start:)
 12 
 13 .set PROT_MODE_CSEG,        0x8                     # kernel code segment selector
 14 .set PROT_MODE_DSEG,        0x10                    # kernel data segment selector
 15 .set CR0_PE_ON,             0x1                     # protected mode enable flag
 16 
 17 # start address should be 0:7c00, in real mode, the beginning address of the running bootloader
 18 .globl start
 19 start:
 20 .code16                                             # Assemble for 16-bit mode
 21     cli                                             # Disable interrupts
 22     cld                                             # String operations increment
 23 
 24     # Set up the important data segment registers (DS, ES, SS).
 25     xorw %ax, %ax                                   # Segment number zero
 26     movw %ax, %ds                                   # -> Data Segment
 27     movw %ax, %es                                   # -> Extra Segment
 28     movw %ax, %ss                                   # -> Stack Segment
 29 
 30     # Enable A20:
 31     #  For backwards compatibility with the earliest PCs, physical
 32     #  address line 20 is tied low, so that addresses higher than
 33     #  1MB wrap around to zero by default. This code undoes this.
 34 
 35     # 为了进入32位保护模式,必须先开启A20(第21位内存访问线),否则在32位寻址模式下给出的内存地址第21位始终为0,造成错误
 36     # 为什么需要特意开启A20总线?
 37     # 在早期的8086CPU中,内存总线是20位的,由高16位的段基址和低16位的段内偏移共同构成一个20位的内存地址
 38     # 但事实上在段基址和段内偏移比较大的情况下,其实际得出的结果是超过了20位的(例如0xFFFF段基址 <<< 4 + 0xFFFF段内偏移 > 0xFFFFF),出现了溢出
 39     # 而8086中对这种溢出是兼容的,这种溢出在8086上会体现为绕回0x00000低端
 40     # “程序员,你是知道的,他们喜欢钻研,更喜欢利用硬件的某些特性来展示自己的技术,很难说在当年有多少程序在依赖这个回绕特性工作着”
 41     # 摘自《X86汇编语言 从实模式到保护模式》 11.5 关于第21条地址线A20的问题

 42     # 到了更新版的80286时代,24位的内存总线,如果不默认关闭A20总线,那么就无法兼容使用回绕特性的8086程序了

 43     # 而80386作为80286的后一代,也继承了80286这一特性

 44 seta20.1:
 45     inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
 46     testb $0x2, %al
 47     jnz seta20.1
 48 
 49     movb $0xd1, %al                                 # 0xd1 -> port 0x64
 50     outb %al, $0x64                                 # 0xd1 means: write data to 8042’s P2 port
 51 
 52 seta20.2:
 53     inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
 54     testb $0x2, %al
 55     jnz seta20.2
 56 
 57     movb $0xdf, %al                                 # 0xdf -> port 0x60
 58     outb %al, $0x60                                 # 0xdf = 11011111, means set P2’s A20 bit(the 1 bit) to 1
 59 
 60     # Switch from real to protected mode, using a bootstrap GDT
 61     # and segment translation that makes virtual addresses
 62     # identical to physical addresses, so that the
 63     # effective memory map does not change during the switch.
 64     # 设置GDT,修改CRO寄存器中的保护模式允许位,进入保护模式

 65     lgdt gdtdesc
 66     movl %cr0, %eax
 67     orl $CR0_PE_ON, %eax
 68     movl %eax, %cr0
 69 
 70     # Jump to next instruction, but in 32-bit code segment.
 71     # Switches processor into 32-bit mode.
 72     # 通过一个远跳转指令指向protcseg处的指令,令CPU清空之前在实模式下保存在流水线中的指令(当前处于保护模式下执行实模式的指令会出现各种问题)
 73     ljmp $PROT_MODE_CSEG, $protcseg
 74 
 75 # 下面的都是X86-32的汇编程序

 76 .code32                                             # Assemble for 32-bit mode
 77 protcseg:
 78     # Set up the protected-mode data segment registers
 79     # 跳转至保护模式后,需要刷新数据段寄存器(因为引入了特权级保护,避免数据段寄存器之前的值不对而出现漏洞)
 80     movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
 81     movw %ax, %ds                                   # -> DS: Data Segment
 82     movw %ax, %es                                   # -> ES: Extra Segment
 83     movw %ax, %fs                                   # -> FS
 84     movw %ax, %gs                                   # -> GS
 85     movw %ax, %ss                                   # -> SS: Stack Segment
 86 
 87     # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
 88     # 设置栈段寄存器 栈基址0x0,栈顶指针指向start段所在位置(0x7c00)
 89     movl $0x0, %ebp
 90     movl $start, %esp
 91     # 调用跳转至bootmain.c中的bootmain函数,完成内核的引导
 92     call bootmain
 93 
 94     # If bootmain returns (it shouldn’t), loop.
 95     # 自旋死循环(但如果引导程序和内核实现正确,bootmain函数将永远不会返回并执行至此。因为操作系统内核本身就是通过自旋循环常驻内存的)
 96 spin:
 97     jmp spin
 98 
 99 # Bootstrap GDT
100 .p2align 2                                          # force 4 byte alignment
101 # SEG_ASM是位于asm.h中的宏,用于构造GDT中的段描述符
102 # 按照GDT的约定,第一个为NULL段。ucore采用的是平坦内存模型,所以代码段和数据段在内核中均只存在一个。

103 gdt:
104     SEG_NULLASM                                     # null seg
105     SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
106     SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel
107 
108 gdtdesc:
109     .word 0x17                                      # sizeof(gdt) - 1
110     .long gdt                                       # address gdt
复制代码

bootloader引导加载内核

  bootloader引导程序是位于设备的第一个扇区,即引导扇区的,而ucore的内核程序则是从第二个磁盘扇区开始往后存放的。bootmain.c的任务就是将kernel内核部分从磁盘中读出并载入内存,并将程序的控制流转移至指定的内核入口处。

  ucore的内核文件在生成磁盘映像时是以ELF(Executable and linking format)格式保存的。ELF文件是Unix/Linux下通用的一种可执行文件,对于ELF的详细介绍在《深入理解计算机系统》的"链接"一章中有较为详细的介绍。

  要想彻底的理解ELF格式的文件是如何被编译器、链接器等工具生成的,需要对编译原理相关的知识进行系统的学习,难度很大。因此在ucore的学习过程中,如果不是很了解ELF,可以简单的理解为ELF的文件头中标识了一个可执行程序中包含了哪些部分,比如代码段、数据段(只读数据段、可读写数据段)、栈段等等,分别存储在哪里;并指明了需要为这些段分配多少内存空间、需要被加载到内存的什么地址(虚拟地址)等。

  ucore内核生成ELF文件的关键配置在/tools/kernel.ld中,可以清楚的看到内核加载的.text代码段基址为0x100000,后面紧跟着各种类型的数据段等。

kernel.ld:

复制代码
/* Simple linker script for the JOS kernel.
   See the GNU ld 'info' manual ("info ld") to learn the syntax. */

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(kern_init)

SECTIONS {
    /* Load the kernel at this address: "." means the current address */
    . = 0x100000;

    .text : {
        *(.text .stub .text.* .gnu.linkonce.t.*)
    }

    PROVIDE(etext = .);    /* Define the 'etext' symbol to this value */

    .rodata : {
        *(.rodata .rodata.* .gnu.linkonce.r.*)
    }

    /* Include debugging information in kernel memory */
    .stab : {
        PROVIDE(__STAB_BEGIN__ = .);
        *(.stab);
        PROVIDE(__STAB_END__ = .);
        BYTE(0)        /* Force the linker to allocate space
                   for this section */
    }

    .stabstr : {
        PROVIDE(__STABSTR_BEGIN__ = .);
        *(.stabstr);
        PROVIDE(__STABSTR_END__ = .);
        BYTE(0)        /* Force the linker to allocate space
                   for this section */
    }

    /* Adjust the address for the data segment to the next page */
    . = ALIGN(0x1000);

    /* The data segment */
    .data : {
        *(.data)
    }

    PROVIDE(edata = .);

    .bss : {
        *(.bss)
    }

    PROVIDE(end = .);

    /DISCARD/ : {
        *(.eh_frame .note.GNU-stack)
    }
}
复制代码

  在libs/elf.h中定义了两个ELF相关的结构体,elfhdrproghdr,用于映射读取出来的内核ELF头信息。

elf.h:

复制代码
#ifndef __LIBS_ELF_H__
#define __LIBS_ELF_H__

#include <defs.h>

#define ELF_MAGIC    0x464C457FU            // "\x7FELF" in little endian

/* file header */
struct elfhdr {
    uint32_t e_magic;     // must equal ELF_MAGIC
    uint8_t e_elf[12];
    uint16_t e_type;      // 1=relocatable, 2=executable, 3=shared object, 4=core image
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // file version, always 1
    uint32_t e_entry;     // entry point if executable
    uint32_t e_phoff;     // file position of program header or 0
    uint32_t e_shoff;     // file position of section header or 0
    uint32_t e_flags;     // architecture-specific flags, usually 0
    uint16_t e_ehsize;    // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum;     // number of entries in program header or 0
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum;     // number of entries in section header or 0
    uint16_t e_shstrndx;  // section number that contains section name strings
};

/* program section header */
struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};

#endif /* !__LIBS_ELF_H__ */
复制代码

bootmain.c:

复制代码
#include <defs.h>
#include <x86.h>
#include <elf.h>

/* *********************************************************************
 * This a dirt simple boot loader, whose sole job is to boot
 * an ELF kernel image from the first IDE hard disk.
 *
 * DISK LAYOUT
 *  * This program(bootasm.S and bootmain.c) is the bootloader.
 *    It should be stored in the first sector of the disk.
 *         这个程序(bootasm.S and bootmain.c)是引导加载器程序,应该被保存在磁盘的第一个扇区

 *
 *  * The 2nd sector onward holds the kernel image.
 *         第二个扇区往后保存着内核映像
 *
 *  * The kernel image must be in ELF format.
 *         内核映像必须必须是ELF格式的

 *
 * BOOT UP STEPS
 *  * when the CPU boots it loads the BIOS into memory and executes it
 *
 *  * the BIOS intializes devices, sets of the interrupt routines, and
 *    reads the first sector of the boot device(e.g., hard-drive)
 *    into memory and jumps to it.
 *
 *  * Assuming this boot loader is stored in the first sector of the
 *    hard-drive, this code takes over...
 *
 *  * control starts in bootasm.S -- which sets up protected mode,
 *    and a stack so C code then run, then calls bootmain()
 *
 *  * bootmain() in this file takes over, reads in the kernel and jumps to it.
 * */
unsigned int    SECTSIZE  =      512 ;  // 一个磁盘扇区的大小为512字节
struct elfhdr * ELFHDR    =      ((struct elfhdr *)0x10000) ;     // scratch space

/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
    // 读数据,当0x1f7不为忙状态时,可以读
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}

/* readsect - read a single sector at @secno into @dst */
// 读取一个单独的扇区(由@secno指定)到@dst指针指向的内存中
static void
readsect(void *dst, uint32_t secno) {
    // https://chyyuu.gitbooks.io/ucore_os_docs/content/lab1/lab1_3_2_3_dist_accessing.html
    // 实验指导书lab1中的对ide硬盘的访问中有详细介绍


    // wait for disk to be ready
    waitdisk();

    // 磁盘读取参数设置
    outb(0x1F2, 1);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors

    // wait for disk to be ready
    waitdisk();

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);
}

/* *
 * readseg - read @count bytes at @offset from kernel into virtual address @va,
 * might copy more than asked.
 * */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // round down to sector boundary
    va -= offset % SECTSIZE;

    // translate from bytes to sectors; kernel starts at sector 1
    // 计算出需要读取的磁盘扇区号,由于第1个扇区被bootloader占据,kernel内核从第二个扇区开始(下标为1),所以扇区号需要增加1
    uint32_t secno = (offset / SECTSIZE) + 1;

    // If this is too slow, we could read lots of sectors at a time.
    // We'd write more to memory than asked, but it doesn't matter --
    // we load in increasing order.
    // 循环往复,通过va指针的自增,一个一个扇区的循环读取数据写入va指向的内存区域
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // read the 1st page off disk
    // 从硬盘中读取出内核文件ELF文件头数据,存入ELFHDR指针指向的内存区域 (大小为8个扇区)
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF? 校验读取出来的ELF文件头的魔数值是否正确
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff); // 根据elf文件头,获得程序段的起始
    eph = ph + ELFHDR->e_phnum; // 程序段起始指针(*ph)指针偏移程序段数目(ELFHDR->e_phnum) = 最后一段程序的头部
    for (; ph < eph; ph ++) {
        // 循环往复,将各个程序段的内容读取至指定的内存位置(ph->p_va)
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    // 通过函数指针的方式,跳转至ELFHDR->e_entry指定的程序初始执行入口(即内核入口)
    // 在makefile的配置中,ELFHDR->e_entry指向的是kern/init/init.c中的kern_init函数 (kernel.ld中的ENTRY(kern_init))
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad:
    // 跳转至内核之后,不应该返回
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}
复制代码

ucore内核结构分析

  在bootloader将ucore的kernel内核完整的加载至内存,并通过ELF文件头中指定的entry入口跳转至内核入口,即/kern/init.c中的kern_init函数。

  kern_init函数是内核的总控函数,内核中的各个组成部分都在kern_init函数中完成初始化。

内核总控函数kern_init 

  总控函数一方面负责初始化与各种硬件的交互(例如与显卡、中断控制器、定时器等),另一方面初始化各种内核功能(比如初始化物理内存管理器、中断描述符表IDT等),之后便通过一个自旋死循环令操作系统常驻内存,通过监听各种中断提供操作系统服务。

init.c(主体部分):

复制代码
#include <defs.h>
#include <stdio.h>
#include <string.h>
#include <console.h>
#include <kdebug.h>
#include <picirq.h>
#include <trap.h>
#include <clock.h>
#include <intr.h>
#include <pmm.h>
#include <kmonitor.h>
void kern_init(void) __attribute__((noreturn));
void grade_backtrace(void);
static void lab1_switch_test(void);

/**
 * 内核入口 总控函数
 * */
void
kern_init(void){
    extern char edata[], end[];
    memset(edata, 0, end - edata);

    // 初始化控制台(控制显卡交互),只有设置好了对显卡的控制后,std_out输出的信息(例如cprintf)才能显示在控制台中
    cons_init();                // init the console

    const char *message = "(THU.CST) os is loading ...";
    cprintf("%s\n\n", message);

    print_kerninfo();

    grade_backtrace();

    // 初始化物理内存管理器
    pmm_init();                 // init physical memory management

    // 初始化中断控制器
    pic_init();                 // init interrupt controller
    // 初始化中断描述符表
    idt_init();                 // init interrupt descriptor table

    // 初始化定时芯片
    clock_init();               // init clock interrupt
    // 开中断
    intr_enable();              // enable irq interrupt

    //LAB1: CAHLLENGE 1 If you try to do it, uncomment lab1_switch_test()
    // user/kernel mode switch test
    lab1_switch_test();

    /* do nothing */
    // 陷入死循环,避免内核程序退出。通过监听中断事件进行服务
    while (1);
}
复制代码

  从kern_init函数的代码中可以看出,其依次完成了如下的几个主要工作:

  1. cons_init  初始化控制台(控制显卡交互)

  2. pmm_init  初始化物理内存管理器(lab1中里面暂时只是完成了GDT的重新设置,比较简单。而在lab2的物理内存管理的实现中,pmm_init才成为主角)

  3. pic_init 初始化中断控制器(内部通过与8259A中断控制器芯片进行交互,令ucore能够接收到来自硬件的各种中断请求)

  4. idt_init 初始化中断描述符表(在下面的中断机制一节中详细介绍)

  5. clock_init 初始化定时器(进行8253定时器的相关设置,将其设置为10ms发起一次时钟中断)

  6. intr_enable 完成了内核结构的初始化后,开启中断,至此ucore内核正式开始运行

  在kern_init的内核初始化过程中,涉及到的与显卡、定时器等硬件交互的地方,要想深入理解其工作原理,除了仔细阅读ucore的代码外,还需通过硬件手册等资料熟悉不同硬件提供的交互接口,限于篇幅就不再展开了。个人认为这一部分内容并不属于ucore的核心,如果不是特别感兴趣,可以将其暂时视为一个黑盒子,理解大致工作原理即可。

ucore的中断工作机制

  ucore在lab1中实现的一个非常重要的功能,就是建立了一个可以工作的中断服务框架。可以说操作系统的工作是离不开硬件提供的中断机制的。

80386中断工作机制介绍

  前面提到过学习ucore的一个前提是对80386CPU的硬件工作原理有一定了解,这里先回顾一下80386的中断工作机制。

  1. 在80386中,为了更好的支持对中断服务例程的特权级保护,使用中断描述符表代替了8086中的中断向量表。和8086中断向量表被固定在低位内存不一样,80386CPU通过中断描述符表寄存器IDTR来定位中断描述符表IDT的位置,这给了操作系统的设计者一定的自主权。

  2. 80386在执行完每条指令后,都会检查当前是否存在中断请求。如果没有发现中断请求,则接着执行后续指令;如果发现存在中断请求,则会根据中断信号中给出的中断类型码,从中断描述符表中查找到对应的中断描述符,在中断描述符中记录了对应的中断服务例程的入口。

  3. 随后,CPU硬件会打断当前控制流,在栈上压入CS、EIP、EFLAGS等寄存器的内容(用于中断服务例程的返回),跳转到对应的中断服务例程入口,进行中断请求的处理。当中断服务返回时,通过之前压入栈中的CS,EIP等返回到之前被中断请求打断的控制流中,恢复现场,继续运行。

  由于80386中断工作机制相对比较复杂,限于篇幅这里的流程介绍省略了不少细节。如果对这一块内容不熟悉的话需要通过实验指导书或是有关资料进行学习,或者参考我之前写的博客 80386学习(四) 80386中断,里面对此有较为详细的介绍。

ucore中断功能的组成部分

  ucore的中断工作机制大致可以分为以下几个部分:

  1. IDT中断描述符表的建立

  2. 中断栈帧的生成

  3. 接收到中断栈帧,通过对应的中断服务例程进行处理

  4. 中断服务例程处理完毕,中断返回

IDT中断描述符表的建立

  在ucore中,对于中断描述符表IDT的初始化,是在kern_init总控函数中通过idt_init函数进行的。

 idt_init函数:

复制代码
/* *
 * Interrupt descriptor table:
 *
 * Must be built at run time because shifted function addresses can't
 * be represented in relocation records.
 * */
static struct gatedesc idt[256] = {{0}};

static struct pseudodesc idt_pd = {
    sizeof(idt) - 1, (uintptr_t)idt
};

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
     /* LAB1 YOUR CODE : STEP 2 */
     /* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
      *     All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
      *     __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
      *     (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
      *     You can use  "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
      * (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
      *     Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
      * (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
      *     You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
      *     Notice: the argument of lidt is idt_pd. try to find it!
      */
    extern uintptr_t __vectors[];
    int i;
    // 首先通过tools/vector.c通过程序生成/kern/trap/verctor.S,并在加载内核时对之前已经声明的全局变量__vectors进行整体的赋值

    // __vectors数组中的每一项对应于中断描述符的中断服务例程的入口地址,在SETGATE宏的使用中可以体现出来

    // 将__vectors数组中每一项关于中断描述符的描述设置到下标相同的idt中,通过宏SETGATE构造出最终的中断描述符结构
    for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
        // 遍历idt数组,将其中的内容(中断描述符)设置进IDT中断描述符表中(默认的DPL特权级都是内核态DPL_KERNEL=0)
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
    }
    // set for switch from user to kernel
    // 用户态与内核态的互相转化是通过中断实现的,单独为其一个中断描述符
    // 由于需要允许用户态的程序访问使用该中断,DPL特权级为用户态DPL_USER=3
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
    // load the IDT 令IDTR中断描述符表寄存器指向idt_pd,加载IDT
    // idt_pd结构体中的前16位为描述符表的界限,pd_base指向之前完成了赋值操作的idt数组的起始位置
    lidt(&idt_pd);
}
复制代码

  通过上述代码的注释可以发现,在idt_init函数中,通过构建项目时自动生成的中断描述符元信息数组__vectors,在一个循环中,通过SETGATE宏,将idt[i]中的每一项都赋值了一个中断描述符。 可以看到中断描述符和gatedesc门描述符结构体的对应关系。(C中结构体的字段在内存中排布的顺序是按照定义的顺序,从低位到高位的)

中断门示意图:

  

gatedesc结构体:

复制代码
/* Gate descriptors for interrupts and traps */
struct gatedesc {
    unsigned gd_off_15_0 : 16;        // low 16 bits of offset in segment
    unsigned gd_ss : 16;            // segment selector
    unsigned gd_args : 5;            // # args, 0 for interrupt/trap gates
    unsigned gd_rsv1 : 3;            // reserved(should be zero I guess)
    unsigned gd_type : 4;            // type(STS_{TG,IG32,TG32})
    unsigned gd_s : 1;                // must be 0 (system)
    unsigned gd_dpl : 2;            // descriptor(meaning new) privilege level
    unsigned gd_p : 1;                // Present
    unsigned gd_off_31_16 : 16;        // high bits of offset in segment
};
复制代码

SETGATE宏:

复制代码
/* *
 * Set up a normal interrupt/trap gate descriptor
 *   - istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate
 *   - sel: Code segment selector for interrupt/trap handler
 *   - off: Offset in code segment for interrupt/trap handler
 *   - dpl: Descriptor Privilege Level - the privilege level required
 *          for software to invoke this interrupt/trap gate explicitly
 *          using an int instruction.
 * */
#define SETGATE(gate, istrap, sel, off, dpl) {            \
    (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        \
    (gate).gd_ss = (sel);                                \
    (gate).gd_args = 0;                                    \
    (gate).gd_rsv1 = 0;                                    \
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    \
    (gate).gd_s = 0;                                    \
    (gate).gd_dpl = (dpl);                                \
    (gate).gd_p = 1;                                    \
    (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        \
}
复制代码

  最终构建出了一个48位的结构体pseudodesc,前16位标识着中断描述符表的大小(pd_lim),后32位标识着中断描述符表IDT的基址(pd_base)。

  如果熟悉80386中断机制的话,就会发现这一结构与IDTR寄存器所需要的结构一致。在idt_init函数的最后,通过lidt函数执行汇编指令lidt,完成了对IDTR寄存器的赋值。至此,ucore的中断描述符表设置完成。

/* Pseudo-descriptors used for LGDT, LLDT(not used) and LIDT instructions. */
struct pseudodesc {
    uint16_t pd_lim;        // Limit
    uint32_t pd_base;        // Base address
} __attribute__ ((packed));

中断栈帧的生成

  下面接着分析,中断描述符表里到底存放了什么数据结构,在ucore的中断服务功能的建立中是如何发挥作用的?

  打开之前用于构造中断描述符数组,为vertors赋值的/kern/trap/vector.S,可以看到其中的每一项的中断服务例程的代码都一样。有的项首先push了一个0,有的没有(下面会介绍为什么会有这种差异)。接下来将下标push压入栈中,便统一jmp跳转到了__alltraps处了。

vector.S:(很长,几乎都是脚本生成的模板代码)

 View Code

   __alltraps是定义在同一目录下即/kern/trap/trapentry.S中的。

  在__alltraps中,按照顺序将当前的各个常用的寄存器的值压入了栈中,随后将ds、es等数据段寄存器载入了内核的数据段选择子(这是因为中断可能来自用户态,而中断服务例程必须在内核态运行以拥有所有资源的访问权限,避免内核中的中断服务例程由于特权级不够,访问数据时出现问题)。

  随后栈中压入esp的值,便通过call trap跳转到了内核的中断服务分发函数trap中。trap函数位于/kern/trap/trap.c中。

trapentry.S:

复制代码
#include <memlayout.h>

# vectors.S sends all traps here.
.text
.globl __alltraps
__alltraps:
    # push registers to build a trap frame
    # therefore make the stack look like a struct trapframe
    pushl %ds
    pushl %es
    pushl %fs
    pushl %gs
    pushal

    # load GD_KDATA into %ds and %es to set up data segments for kernel
    movl $GD_KDATA, %eax
    movw %ax, %ds
    movw %ax, %es

    # push %esp to pass a pointer to the trapframe as an argument to trap()
    pushl %esp

    # call trap(tf), where tf=%esp
    call trap

    # pop the pushed stack pointer
    popl %esp

    # return falls through to trapret...
.globl __trapret
__trapret:
    # restore registers from stack
    popal

    # restore %ds, %es, %fs and %gs
    popl %gs
    popl %fs
    popl %es
    popl %ds

    # get rid of the trap number and error code
    addl $0x8, %esp
    iret
复制代码

trap函数入口:

复制代码
/* *
 * trap - handles or dispatches an exception/interrupt. if and when trap() returns,
 * the code in kern/trap/trapentry.S restores the old CPU state saved in the
 * trapframe and then uses the iret instruction to return from the exception.
 * */
void
trap(struct trapframe *tf) {
    // dispatch based on what type of trap occurred
    trap_dispatch(tf);
}
复制代码

  trap函数的参数是trapframe结构体。仔细观察可以看到,trapframe中字段属性的定义和trapentry.S中入栈的顺序是相反的。

  1. 第一个字段pushregs保存了pushal压入栈中的数据,后续的tf_gs、tf_padding0两个字段是因为pushl会压入一个32位的数据,而gs数据段寄存器本身保存的段选择子是16位的,需要一个16位的padding空闲字段合并起来与之对应。后面的fs、es、ds原理一样。

  2. 之后的tf_trapno属性对应着跳转至__alltraps之前所压入栈中的中断向量号,tf_err对应的是在上面的pushl $0,即错误号。根据注释可以看到,包括tf_err在内的中断错误号都是x86CPU硬件在中断发生时自动压入栈中的。但并不是所有的硬件中断都会被自动压入错误号(需要去阅读硬件手册才能知道具体细节),为了能够以一个统一的接口去处理所有的中断请求,在vertor.S中对于没有错误号的中断请求默认加上了pushl $0,压入一个默认的错误号0;对于CPU硬件会压入错误号的中断向量则没有进行默认处理,例如vertor8、vertor9等等。

  3. 发生中断时,x86CPU会默认按照顺序依次压入eflags、cs和eip用于中断后的现场恢复。对应的是tf_eip、tf_cs + tf_padding4以及tf_eflags。而当发生了CPL特权级的变化时,x86CPU硬件会发生不同特权级栈的切换,因此还会先依次压入切换特权级前的ss栈段寄存器和esp栈顶指针的值入栈,便于中断返回后回到对应的特权级中。一个很典型的例子就是,当用户程序执行系统调用时(系统调用是通过中断机制实现的,在ucore的lab5中实现了这一功能),会从用户的CPL特权级ring3切换到内核的特权级ring0,系统调用的服务例程是在分配好的内核栈中执行的。

复制代码
/* registers as pushed by pushal */
struct pushregs {
    uint32_t reg_edi;
    uint32_t reg_esi;
    uint32_t reg_ebp;
    uint32_t reg_oesp;            /* Useless */
    uint32_t reg_ebx;
    uint32_t reg_edx;
    uint32_t reg_ecx;
    uint32_t reg_eax;
};

struct trapframe {
    struct pushregs tf_regs;
    uint16_t tf_gs;
    uint16_t tf_padding0;
    uint16_t tf_fs;
    uint16_t tf_padding1;
    uint16_t tf_es;
    uint16_t tf_padding2;
    uint16_t tf_ds;
    uint16_t tf_padding3;
    uint32_t tf_trapno;
    /* below here defined by x86 hardware */
    uint32_t tf_err;
    uintptr_t tf_eip;
    uint16_t tf_cs;
    uint16_t tf_padding4;
    uint32_t tf_eflags;
    /* below here only when crossing rings, such as from user to kernel */
    uintptr_t tf_esp;
    uint16_t tf_ss;
    uint16_t tf_padding5;
} __attribute__((packed));
复制代码

为什么tramframe中断栈帧的结构属性的定义顺序会和入栈时的顺序相反?

  要理解这个需要对C语言编译后的底层机器代码模型有一定了解。

  1.C语言结构体定义的内存排布是按照字段定义顺序,从内存的低位到高位延伸的。

  2.栈在压入数据时,栈顶指针是递减的,由高位往低位延伸的。

  3.trap函数的参数trapframe指针指向的是当前栈顶,相对处于低位,而对所包含的字段则是在这个指针的基础上向高位偏移对应的N个字节来访问的。要想在C中通过一个结构体映射出栈上的内存数据便于后续的访问,那么必须以和入栈顺序相反的顺序来定义结构体。栈帧结构体trapframe定义最后的__attribute__((packed))指的是强制令C编译器使用紧凑模式处理该结构体,避免编译器在字段的处理上进行额外的内存对齐操作,导致访问时最后生成的内存地址访问偏移量计算错误。

  这里需要注意的一点是,当处理没有发生特权级切换的中断时,trapframe对应的最后三个字段是不存在于栈上的,此时如果通过tf_ss等属性访问时,会越界访问到原本在栈上不相关的数据。所以在访问这几个字段时,必须先判断是否发生了特权级的变化,避免损坏栈上数据,令程序出错甚至崩溃。

中断服务例程进行处理

  现在分析当中断发生时,trap函数接收到trapframe中断帧参数后是如何进行中断服务处理的。

  在lab1中,trap函数只是简单的调用了同一文件中的trap_dispatch函数,在trap_dispatch中通过对tf->tf_trapno,即中断向量号进行判断,将控制流转移至中断向量号对应的中断服务例程中。(这里trap函数只是简单的调用trap_dispatch,是因为后续的lab中,会在trap函数中在中断处理服务开始前后加入许多逻辑,预先将对中断请求的分发逻辑抽取了出来)

  比如第一个case块便是用于处理时钟中断的。在lab1中,通过一个被volatile关键字修饰的ticks全局变量,在每次时钟中断时累加1,当次数每达到TICK_NUM时(默认100,对应的是10ms一次的时钟中断),便打印一段话。体现在lab1实验中便是,ucore内核启动完成后,控制台每秒钟周期性的打印出"100ticks"。

  可以看到,虽然80386CPU的硬件设计者希望操作系统的设计者直接在中断描述符中设置对应的中断服务例程的入口地址,但ucore却没有充分利用这一特性,而是选择了在中断服务例程的入口处简单压入几个数据后将中断服务的控制流程统一的指向了__alltraps,最后通过trap_dispatch函数进行分发。这样的设计虽然在性能上可能有微小的损失,但是却使得ucore的中断服务实现更加灵活、可控。(作为一个java后端程序员,这一设计令我想到了springmvc框架基于下层servlet的封装机制:通过一个/*的servlet获得所有请求的控制权,再由框架灵活封装各种参数,最后将参数和控制权交给对应的controller方法进行处理)

trap_dispatch函数:

复制代码
/* trap_dispatch - dispatch based on what type of trap occurred */
static void
trap_dispatch(struct trapframe *tf) {
    char c;

    switch (tf->tf_trapno) {
    case IRQ_OFFSET + IRQ_TIMER:
        /* LAB1 YOUR CODE : STEP 3 */
        /* handle the timer interrupt */
        /* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
         * (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
         * (3) Too Simple? Yes, I think so!
         */
        ticks ++;
        if (ticks % TICK_NUM == 0) {
            print_ticks();
        }
        break;
    case IRQ_OFFSET + IRQ_COM1:
        c = cons_getc();
        cprintf("serial [%03d] %c\n", c, c);
        break;
    case IRQ_OFFSET + IRQ_KBD:
        c = cons_getc();
        cprintf("kbd [%03d] %c\n", c, c);
        break;
    //LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
    case T_SWITCH_TOU:
        if (tf->tf_cs != USER_CS) {
            switchk2u = *tf;
            switchk2u.tf_cs = USER_CS;
            switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
            switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
        
            // set eflags, make sure ucore can use io under user mode.
            // if CPL > IOPL, then cpu will generate a general protection.
            switchk2u.tf_eflags |= FL_IOPL_MASK;
        
            // set temporary stack
            // then iret will jump to the right stack
            *((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
        }
        break;
    case T_SWITCH_TOK:
        if (tf->tf_cs != KERNEL_CS) {
            tf->tf_cs = KERNEL_CS;
            tf->tf_ds = tf->tf_es = KERNEL_DS;
            tf->tf_eflags &= ~FL_IOPL_MASK;
            switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
            memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
            *((uint32_t *)tf - 1) = (uint32_t)switchu2k;
        }
        break;
    case IRQ_OFFSET + IRQ_IDE1:
    case IRQ_OFFSET + IRQ_IDE2:
        /* do nothing */
        break;
    default:
        // in kernel, it must be a mistake
        if ((tf->tf_cs & 3) == 0) {
            print_trapframe(tf);
            panic("unexpected trap in kernel.\n");
        }
    }
}  
复制代码

中断返回

  中断服务例程用于处理突发性的中断事件,一般来说都是短小精悍的服务代码,会很快的执行完毕并返回。下面接着分析ucore在中断返回时的处理机制。

  在trap函数返回后,代码的控制流回到了trapentry.S中,即CPU指令指向call trap的下一条指令。为了保证中断服务返回后之前被中断程序上下文的正确性,需要将执行call trap之前的压入的数据一一弹出还原。

  1. 按照相反的顺序弹出、还原各个常用寄存器的值(popl esp、popal、popl gs/fs/es/ds)。

  2. 通过addl $0x8, %esp,以直接上移栈顶指针的方式,略过之前压入的中断号tf_trapno和错误码tf_err。

  3. 执行iret指令,iret指令会将之前硬件自动压入的eip、cs、eflags按照顺序弹出。当CPU发现弹出时的cs值和当前cs值不一致,则认定此次中断发生了特权级的变化。此时CPU会接着弹出之前压入了的esp、ss寄存器的值,令其返回到中断发生前对应的特权级栈中继续执行。

  CPU认为只有之前发生特权级变化时才会额外压入ss、esp,所以中断返回时如果发现弹出的cs与当前cs不一致时,除了恢复之前栈上的cs(也恢复了CPL),同时会额外的弹出esp、ss。

  这一特权级机制在lab1的挑战练习lab1 challenge1中被利用了起来,挑战练习1需要模拟出内核态转化至用户态,再从用户态再转换回内核态的过程。

lab1挑战练习1实现原理分析

  在kern_init总控函数中,最后通过lab1_switch_test来实现这一过程。

  lab1_switch_to_user函数中,通过内联汇编执行了int命令,触发了一个软中断,中断号为T_SWITCH_TOU。控制流最终会指向trap_dispatch函数中对应的case块中,在其中通过修改当前中断栈帧中的cs代码段寄存器、ds、es、ss等数据段寄存器的值,使得中断栈帧上的CS的段选择子的值为用户态。这样在中断返回时,便"欺骗"了CPU,使得CPU在中断返回后将当前的特权级由内核态切换到了用户态。在后续的实验中,例如通过系统调用加载并运行一个用户态应用程序,就是通过这一"欺骗"机制巧妙地实现特权级的切换。

  lab1_switch_to_kernel函数中,同样通过内联汇编执行int命令触发软中断,中断号为T_SWITCH_TOK。控制流最终指向trap_dispatch函数中对应的case块中,通过设置cs代码段寄存器的值为内核代码段、ds、es设置为内核数据段来实现中断返回后,令CPU再从用户态回到内核态。

复制代码
static void
lab1_switch_test(void) {
    lab1_print_cur_status();
    cprintf("+++ switch to  user  mode +++\n");
    lab1_switch_to_user();
    lab1_print_cur_status();
    cprintf("+++ switch to kernel mode +++\n");
    lab1_switch_to_kernel();
    lab1_print_cur_status();
}

static void
lab1_switch_to_user(void) {
    //LAB1 CHALLENGE 1 : TODO
    asm volatile (
        "sub $0x8, %%esp \n"
        "int %0 \n"
        "movl %%ebp, %%esp"
        : 
        : "i"(T_SWITCH_TOU)
    );
}

static void
lab1_switch_to_kernel(void) {
    //LAB1 CHALLENGE 1 :  TODO
    asm volatile (
        "int %0 \n"
        "movl %%ebp, %%esp \n"
        : 
        : "i"(T_SWITCH_TOK)
    );
}
复制代码

总结

  从年初接触ucore到现在完成学习,通过博客总结心得已经过去了大半年。学习ucore就像攀爬一座高山一样,最初的我由于汇编、C等基础的前置知识掌握的不牢靠,导致每每想研究ucore源码时都因为看不懂代码而宣告失败,因此我下定决心将汇编和C重新学习了一遍。虽然最初的动机是为了更好的学习ucore,但在学习过程中我却收获颇丰,领略了登山途中的好风景。一方面使我对计算机底层的运行机制建立起了一个大致的知识框架,理解了CPU的运行机制、中断等硬件的工作原理(主要还是单核CPU的工作原理)。另一方面,随着对汇编、C语言的进一步学习,也慢慢的理解了《黑客与画家》中对于编程语言抽象能力的看法,为什么计算机硬件不断发展,抽象程度更高但性能较低的编程语言会变得越来越流行。

  正如C语言最初作为一种"高级汇编语言"而出现,其提供的数组、结构体、指针等机制简化了汇编中令人头疼的访问数据时的地址偏移问题。同时C还提供了标准库,在绝大多数场景下能够屏蔽掉不同硬件、操作系统平台的差异,使其做到一次编写,到处编译。而C++作为C的高级版本,提供了面向对象的编程机制,由编译器提供多态等诸多语法糖,由编译器来自动完成之前需要C程序员通过函数指针集合等方式手动实现的面向对象逻辑。java作为C++的后继者,认为C++为了兼容C依然保留了太多应用程序开发时不需要的底层功能,便将包括指针、goto在内的许多机制都隐藏起来了,不让程序员直接接触,通过jvm在绝大多数场景下屏蔽了不同操作系统平台的差异。而Lisp语言的抽象程度则更高,正同《程序员的呐喊》中所说的:“Lisp假装操作系统不存在”。如果不考虑在当前冯.诺依曼架构机器上的运行效率,LISP倡导的就是肆无忌惮的进行函数递归而不必担心栈溢出,为了使函数调用无副作用可以任意的copy数据,而不必担心内存不足和垃圾回收的负担,最重要的是程序的可读性、可维护性,怎么方便人思考怎么来,不太关心空间、时间性能。随着机器性能的不断提升,未来的编程语言实现中也许真的可以用kv Map完全的替代数组,甚至用丘奇数来替代整数以追求数学上极致简约的美?Orz

  任意编程语言的内容主要分为两部分,一是基础语法,另一部分则是在基于的特定平台上功能的封装。例如javascript由ECMA语法和对其工作平台浏览器相关功能的封装组成,而java、nodejs等通用编程语言则是由语法和对操作系统功能的封装。作为一个以java作为日常开发语言的我来说,学习ucore让我对java中诸如并发同步、BIO/NIO等机制有了进一步的理解,解开了不少对于jvm底层与操作系统交互机制的困惑。总而言之,学习操作系统还是能学到很多知识的,而ucore os网上公开课就是一个很好的学习方式。

  这是我ucore学习系列博客的第一篇博客,未来会不断的更新后续实验的学习心得,博客中会尝试着尽量将初学者可能碰到的各种疑惑一一解答。希望能帮助到对操作系统、ucore os感兴趣的人。

  这篇博客的完整代码在我的github上:https://github.com/1399852153/ucore_os_lab (fork自官方仓库)中的lab1_answer,存在许多不足之处,还请多多指教。

出处:https://www.cnblogs.com/xiaoxiongcanguan/p/13714587.html


相关教程