MIT 6.828 (1)Lab01

357 阅读10分钟

Lab 1: Booting a PC

Introduction

这个实验室分为三个部分。第一部分主要介绍如何熟悉x86汇编语言、QEMU x86模拟器和PC的开机引导过程。第二部分研究6.828内核的引导加载程序,它位于lab树的引导目录中。最后,第三部分将深入研究6.828内核本身的初始模板,名为JOS,它位于内核目录中。

Part 1: PC Bootstrap

(中间省略一大坨配置环境的笔记,说多了全是泪,不止一次怀疑过我要卡死在起点)

MIT6.828Lab(0) 实验环境搭建_6.828 lab_Alagagaga的博客-CSDN博客

The PC's Physical Address Space

PC的物理地址空间是硬连线的,有以下一般布局

+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
|                  |
|      Unused      |
|                  |
+------------------+  <- depends on amount of RAM
|                  |
|                  |
| Extended Memory  |
|                  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000
  • 标记为“低内存”的640KB区域是早期PC可以使用的唯一随机存取内存(RAM);事实上,最早的pc只能配置16KB、32KB或64KB的RAM!

  • 从0x000A0000到0x000FFFFF的384KB区域由硬件保留,用于特殊用途,例如视频显示缓冲区保存在非易失性存储器中的固件。这个保留区域中最重要的部分是基本输入/输出系统(Basic Input/Output System, BIOS),它占据了从0x000F0000到0x000FFFFF的64KB区域。在早期的个人电脑中,BIOS保存在真正的只读存储器(ROM)中,但现在的个人电脑将BIOS存储在可更新的闪存中。

  • BIOS负责执行基本的系统初始化,例如激活显卡和检查已安装的内存量。在执行此初始化之后,BIOS从一些适当的位置(如软盘、硬盘、CD-ROM或网络)加载操作系统,并将机器的控制传递给操作系统。

  • 现代pc在从0x000A0000到0x00100000的物理内存中有一个“hole”,将RAM划分为“低”或“常规内存”(前640KB)和“扩展内存”(其他所有内容)。此外,在PC的32位物理地址空间的最顶端的一些空间,首先是物理RAM,现在通常由BIOS保留,供32位PCI设备使用。

The ROM BIOS

[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b

在make gdb之后会出现以上语句,代表着GDB的要执行的第一条指令

  • IBM PC从物理地址0x000ffff0开始执行,它位于为ROM BIOS保留的64KB区域的最顶端。

  • PC开始执行,CS = 0xf000, IP = 0xfff0。

  • 要执行的第一条指令是jmp指令,它跳转到分段地址CS = 0xf000和IP = 0xe05b。

    •   16 * 0xf000 + 0xfff0 = 0xffff0 
      

      不难看出PC会jump到BIOS ROM的末16字节(在exercise2中会讨论BIOS是如何使用这16字节的)

  • 由于PC中的BIOS“硬连接”到物理地址范围0x000f0000-0x000fffff,因此这种设计确保BIOS总是在上电或任何系统重新启动后首先获得对机器的控制,因为在上电时,机器的RAM中没有其它软件可以被处理器执行。QEMU仿真器自带BIOS,它将BIOS放在处理器模拟物理地址空间中的这个位置。

  • 在处理器复位时,(模拟的)处理器进入实模式,并将CS设置为0xf000,将IP设置为0xfff0,因此执行从该(CS:IP)段地址开始。

    • 实模式:直接利用段寄存器和偏移寄存器对物理地址进行访问,但是不够安全。

      physical address = 16 * segment + offset
      
    • 保护模式:寻址方式不变,但是段基址不直接放在段寄存器了,而是开一个表叫 GDT,即全局描述表,把段的基址、还有这个段的其它一些信息保存到表中。寻址过程中会经过GDT(相当于多加一层抽象)。

  • 当BIOS运行时,它会设置一个中断描述符表并初始化各种设备,例如VGA显示器等。在初始化PCI总线和BIOS所有已知的重要设备之后,它搜索可引导设备,如软盘、硬盘驱动器或CD-ROM。最后,当它找到一个可引导的磁盘时,BIOS从磁盘读取引导加载程序并将控制传递给它。

  • 现在我们来完成exercise2:

    0xffff0:	ljmp   $0xf000,$0xe05b # 第一条指令之前已经分析过了
    (gdb) si # 执行si指令
    0xfe05b:	cmpl   $0x0,%cs:0x6ac8
    0xfe062:	jne    0xfd2e1 # 不等则跳转
    0xfe066:  	xor    %dx, %dx # 寄存器清零    
    0xfe068:  	mov    %dx %ss # %ss 为堆栈寄存器
    0xfe06a:  	mov    $0x7000, %esp # 赋值操作
    0xfe070:  	mov    $0xf34d2, %edx
    0xfe076:  	jmp    0xfd15c
    0xfd15c:  	mov    %eax, %ecx
    0xfd15f:  	cli
    0xfd160:  	cld
    0xfd161:  	mov    $0x8f, %eax
    0xfd167:  	out    %al, $0x70 # 将%al的值写出到$0x70端口
    0xfd169:  	in     $0x71, %al # 将将$0x71端口写入al寄存器
    0xfd16b:  	in     $0x92, %al # 同理
    0xfd16d:  	or     $0x2, %al # 按位或
    0xfd16f:  	out    %al, $0x92
    0xfd171:  	lidtw  %cs:0x6ab8 # 源操作数中的值加载到全局描述符表格寄存器 (GDTR),就是进入保护模式中那个GDT表 
    0xfd177:  	lgdtw  %cs:0x6a74 # 将源操作数中的值加载中断描述符表格寄存器 (IDTR)
    0xfd17d:  	mov    %cr0, %eax
    0xfd180:  	or     $0x1, %eax
    0xfd184:  	mov    %eax, %cr
    
    1. 关于cli和cld:CLI禁止中断发生STI允许中断发生,这两个指令必须成对出现,且只可以在实模式使用,大概的意思是禁止计时器中断,可以使某些必要的代码完成得更加连贯安全(但是在此代码中这俩东西挨着出现我就不太理解了,有啥用嘞?)

    2. in out汇编指令:汇编语言中,CPU对外设的操作通过专门的端口读写指令来完成;读端口用IN指令,写端口用OUT指令。细则请戳IO端口地址对应表

    3. 中断描述符表(Interrupt Descriptor Table,IDT):将每个异常或中断向量分别与它们的处理过程联系起来。

      • 中断:中断是指在计算机执行程序的过程中,当出现异常情况或者特殊请求时,计算机停止现行的程序的运行,转而对这些异常处理或者特殊请求的处理,处理结束后再返回到现行程序的中断处,继续执行原程序。

      • 中断号:外部设备进行I/O操作时,会随机产生中断请求信号。 这个信号中会有特定的标志,使计算机能够判断是哪个设备提出中断请求,这个信号就叫做中断号。

        关于IDT的个人理解:当发生中断时,由硬件(通常为中断控制器)发出中断号,CPU根据中断号并结合IDT表,找到与其对应的中断向量,此中断向量以段基址+段偏移形式列出,指向的是处理此中断的中断处理程序。细则请戳IDT

    and try to guess what it might be doing.:再之后的一些汇编就是一些对寄存器赋值操作,以及一些莫名其妙的操作,能力有限(懒得Google),大概先分析到这里(虽然我很好奇16字节的的地址究竟可以存多少汇编)。

Part 2: The Boot Loader

  • 个人电脑的软盘和硬盘被划分为512字节的区域,称为扇区。扇区是磁盘的最小传输粒度:每次读或写操作必须是一个或多个扇区大小,并在扇区边界上对齐。

  • 如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的地方。

  • 当BIOS找到一个可引导的软盘或硬盘时,它将512字节的引导扇区加载到物理地址0x7c00到0x7dff的内存中,然后使用jmp指令将CS:IP设置为0000:7c00,将控制权传递给引导加载程序。

  • CD-ROM使用的扇区大小为2048字节,而不是512字节,BIOS可以在将控制权转移到内存之前,将磁盘上更大的引导映像加载到内存中(而不仅仅是一个扇区)。

  • 加载引导程序

    • 首先,引导加载程序将处理器从实模式切换到32位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中1MB以上的所有内存。在这一点上,您只需要理解分段地址到物理地址的转换在保护模式下发生的方式不同,并且在转换后偏移量是32位而不是16位。
    • 其次,引导加载程序通过x86的特殊I/O指令直接访问IDE磁盘设备寄存器,从而从硬盘读取内核。
  • exercise3

    1. 虽然不知道题目在胡言乱语些什么,总之先看看两个文件/boot/boot.S和/boot/main.c吧。

      • /boot/boot.S

        #include <inc/mmu.h>
        
        # Start the CPU: switch to 32-bit protected mode, jump into C.
        # The BIOS loads this code from the first sector of the hard disk into
        # memory at physical address 0x7c00 and starts executing in real mode
        # with %cs=0 %ip=7c00.
        
        .set PROT_MODE_CSEG, 0x8         # kernel code segment selector 内核代码段选择器
        .set PROT_MODE_DSEG, 0x10        # kernel data segment selector 内核数据段选择器
        .set CR0_PE_ON,      0x1         # protected mode enable flag 保护模式启用位
        
        .globl start
        start:
          .code16                     # Assemble for 16-bit mode
          cli                         # Disable interrupts 禁止中断
          cld                         # String operations increment ???
        
          # Set up the important data segment registers (DS, ES, SS). 设置重要数据段寄存器
          xorw    %ax,%ax             # Segment number zero 段置零
          movw    %ax,%ds             # -> Data Segment 数据段
          movw    %ax,%es             # -> Extra Segment 附加段
          movw    %ax,%ss             # -> Stack Segment 栈段
        
          # Enable A20:
          #   For backwards compatibility with the earliest PCs, physical
          #   address line 20 is tied low, so that addresses higher than
          #   1MB wrap around to zero by default.  This code undoes this.
        seta20.1:
          inb     $0x64,%al               # Wait for not busy
          testb   $0x2,%al
          jnz     seta20.1
        
          movb    $0xd1,%al               # 0xd1 -> port 0x64
          outb    %al,$0x64
        
        seta20.2:
          inb     $0x64,%al               # Wait for not busy
          testb   $0x2,%al
          jnz     seta20.2
        
          movb    $0xdf,%al               # 0xdf -> port 0x60
          outb    %al,$0x60
        
          # Switch from real to protected mode, using a bootstrap GDT
          # and segment translation that makes virtual addresses 
          # identical to their physical addresses, so that the 
          # effective memory map does not change during the switch.
          lgdt    gdtdesc # 把gdtdesc这个标识符的值送入全局映射描述符表寄存器GDTR中
          movl    %cr0, %eax # 改变cr0寄存器的值
          orl     $CR0_PE_ON, %eax 
          movl    %eax, %cr0 # 根据第十行的宏定义以及对cr0寄存器位的定义,我们知道此段代码是进入到保护模式
        
          # Jump to next instruction, but in 32-bit code segment.
          # Switches processor into 32-bit mode.
          ljmp    $PROT_MODE_CSEG, $protcseg # 转为32地址模式
        
          .code32                     # Assemble for 32-bit mode
        protcseg:
          # Set up the protected-mode data segment registers
          movw    $PROT_MODE_DSEG, %ax    # Our data segment selector
          movw    %ax, %ds                # -> DS: Data Segment
          movw    %ax, %es                # -> ES: Extra Segment
          movw    %ax, %fs                # -> FS
          movw    %ax, %gs                # -> GS
          movw    %ax, %ss                # -> SS: Stack Segment
        
          # Set up the stack pointer and call into C.
          movl    $start, %esp
          call bootmain
        
          # If bootmain returns (it shouldn't), loop.
        spin:
          jmp spin
        
        # Bootstrap GDT
        .p2align 2                                # force 4 byte alignment
        gdt:
          SEG_NULL                              # null seg
          SEG(STA_X|STA_R, 0x0, 0xffffffff)     # code seg
          SEG(STA_W, 0x0, 0xffffffff)           # data seg
        
        gdtdesc:
          .word   0x17                            # sizeof(gdt) - 1
          .long   gdt                             # address gdt                                                      
        
        • 启动CPU:切换到32位保护模式,跳转到C。BIOS将此代码从硬盘的第一个扇区加载到内存在物理地址0x7c00,并开始在实模式下执行,此时的状态为%cs=0 %ip=7c00。

        • Enable A20:要想分析出此代码,就不得不列出各个操作端口代表的行为,请必须戳参考资料

          • 我们不难看出29-31行代码是循环代码,循环结束条件是al寄存器的值等于地址0x2中的数字,之前我们提到in指令将端口数据写入到寄存器中,而inb表示每次只读取一个字节,再结合参考资料中对0x64端口的描述

            0064	r	KB controller read status (ISA, EISA)
            		 bit 7 = 1 parity error on transmission from keyboard
            		 bit 6 = 1 receive timeout
            		 bit 5 = 1 transmit timeout
            		 bit 4 = 0 keyboard inhibit
            		 bit 3 = 1 data in input register is command0 data in input register is data
            		 bit 2	 system flag status: 0=power up or reset  1=selftest OK
            		 bit 1 = 1 input buffer full (input 60/64 has data for 8042)
            		 bit 0 = 1 output buffer full (output 60 has data for system)
            

            此代码目的为将输入端口的缓冲区清空,意味着bit 1置为0。

          • 33-34代码的意思就是将$0xd1输出到0x64端口。

          • seta20.2同理

        • 从实模式切换到保护模式,使用引导GDT和段转换,使虚拟地址与其物理地址相同,以便在切换期间有效内存映射不会改变。

        • 控制处理器

          • CR0是系统内的控制寄存器之一。控制寄存器是一些特殊的寄存器,它们可以控制CPU的一些重要特性。

            • 0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
            • 1 位是监控协处理位MP(Moniter coprocessor),它与第3位一起决定:当TS=1时操作码WAIT是否产生一个“协处理器不能使用”的出错信号。第3位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置1。随着TS=1,就不能使用协处理器。
            • 2位是模拟协处理器位 EM (Emulate coprocessor),如果EM=1,则不能使用协处理器,如果EM=0,则允许使用协处理器。
          • CR1是未定义的控制寄存器,供将来的处理器使用。

          • CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。

          • CR3是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以4K字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。

          • CR4在Pentium系列(包括486的后期版本)处理器中才实现,它处理的事务包括诸如何时启用虚拟8086模式等。

    2. 代码分析结束,我们实际实验一下

      1. Also use the x/i command in GDB to disassemble sequences of instructions in the boot loader, and compare the original boot loader source code with both the disassembly in obj/boot/boot.asm and GDB.

        (gdb) b *0x7c00
        Breakpoint 1 at 0x7c00
        (gdb) c
        Continuing.//我的gdb就一直卡在这了,莫名其妙
        
        (gdb) x/20 0x7c00 //索性直接检查吧
        (gdb) x/20 0x7c00
           0x7c00:	cli    
           0x7c01:	cld    
           0x7c02:	xor    %eax,%eax
           0x7c04:	mov    %eax,%ds
           0x7c06:	mov    %eax,%es
           0x7c08:	mov    %eax,%ss
           0x7c0a:	in     $0x64,%al
           0x7c0c:	test   $0x2,%al
           0x7c0e:	jne    0x7c0a
        

        与前面boot.S代码比较,发现并没有啥区别

      2. 我们再来看一下readseg函数:

        #define SECTSIZE        512
        #define ELFHDR          ((struct Elf *) 0x10000) // scratch space
        
        void readsect(void*, uint32_t);
        void readseg(uint32_t, uint32_t, uint32_t);
        
        void
        bootmain(void)
        {
                struct Proghdr *ph, *eph;
        
                // read 1st page off disk
                readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
        
                // is this a valid ELF?
                if (ELFHDR->e_magic != ELF_MAGIC)
                        goto bad;
        
                // load each program segment (ignores ph flags)
                ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
                eph = ph + ELFHDR->e_phnum;
                for (; ph < eph; ph++)
                        // p_pa is the load address of this segment (as well
                        // as the physical address)
                        readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
        
                // call the entry point from the ELF header
                // note: does not return!
                ((void (*)(void)) (ELFHDR->e_entry))();
        
        bad:
                outw(0x8A00, 0x8A00);
                outw(0x8A00, 0x8E00);
                while (1)
                        /* do nothing */;
        }                                                            
        
        // Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
        // Might copy more than asked
        void
        readseg(uint32_t pa, uint32_t count, uint32_t offset)
        {
                uint32_t end_pa;
        
                end_pa = pa + count;
        
                // round down to sector boundary
                pa &= ~(SECTSIZE - 1);
        
                // translate from bytes to sectors, and kernel starts at sector 1
                offset = (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.
                while (pa < end_pa) {
                        // Since we haven't enabled paging yet and we're using
                        // an identity segment mapping (see boot.S), we can
                        // use physical addresses directly.  This won't be the
                        // case once JOS enables the MMU.
                        readsect((uint8_t*) pa, offset);
                        pa += SECTSIZE;
                        offset++;
                }
        }                                                                                         
        
        • 首先,根据38行注释,此函数的作用是从从kernel开始、以offset为偏移量的cont字节读入到pa为起始的物理内存中,所以结合参数,函数的意义是:将kernel代码从头开始,读取SECTSIZE*8大小(4MB、一页大小)到内存ELFHDR(0x10000)中,可以结合4GBPC物理地址空间布局来理解。

        • 第二行代码:我们需要学习一下ELF格式的内核文件,根据描述:EI_MAG0 to EI_MAG3 A file's first 4 bytes hold a "magic number," identifying the file as an ELF object file;EI_MAG0到EI_MAG3文件的前4个字节包含一个“magic number”,将该文件标识为ELF对象文件。所以这条语句的作用就是检查是否符合ELF文件格式。

          • 关于magic number理解:在有关内存安全方面,我们引入“金丝雀值”(详见CSAPP第三章第七节),其不可改变且随机生成,放在“只读”段上,它的值如果改变,则证明内存泄漏,而此处的magic number类似,起到一个“证明安全”的识别作用。
        • 接下来从ELF文件头读取ELF Header的e_phoff和e_phnum字段,分别表示Segment结构在ELF文件中的偏移和项数。然后将每一个Segment从ph->p_offset对应的扇区读到物理内存ph->p_pa处。

        • 之后跳转到ELFHDR->e_entry指向的指令处。

    3. 接下来不得不来看一下汇编了,和.c代码匹配上。

      00007ce2 <readseg>
      {
          7ce2:       f3 0f 1e fb             endbr32
          7ce6:       55                      push   %ebp 
          7ce7:       89 e5                   mov    %esp,%ebp
          7ce9:       57                      push   %edi
          7cea:       56                      push   %esi
          7ceb:       53                      push   %ebx # 保存调用者的信息(详见CSAPP第三章)
          7cec:       83 ec 0c                sub    $0xc,%esp
              offset = (offset / SECTSIZE) + 1;
          7cef:       8b 7d 10                mov    0x10(%ebp),%edi # edi->SECTSIZE*8 参数2
      {
          7cf2:       8b 5d 08                mov    0x8(%ebp),%ebx # ebx->ELFHDR,参数1 
              end_pa = pa + count;
          7cf5:       8b 75 0c                mov    0xc(%ebp),%esi # esi->0 参数3
              offset = (offset / SECTSIZE) + 1;
          7cf8:       c1 ef 09                shr    $0x9,%edi #逻辑右移
              end_pa = pa + count;
          7cfb:       01 de                   add    %ebx,%esi
              offset = (offset / SECTSIZE) + 1;
          7cfd:       47                      inc    %edi #完成剩余+1的操作
              pa &= ~(SECTSIZE - 1);
          7cfe:       81 e3 00 fe ff ff       and    $0xfffffe00,%ebx # pa &= ~(SECTSIZE-1)重定向
              while (pa < end_pa) {
          7d04:       39 f3                   cmp    %esi,%ebx # 循环条件 
          7d06:       73 15                   jae    7d1d <readseg+0x3b> # 结束时的跳转
                      readsect((uint8_t*) pa, offset);
          7d08:       50                      push   %eax 
          7d09:       50                      push   %eax
          7d0a:       57                      push   %edi 
                      offset++;
          7d0b:       47                      inc    %edi
                      readsect((uint8_t*) pa, offset);
          7d0c:       53                      push   %ebx #压栈
                      pa += SECTSIZE;
          7d0d:       81 c3 00 02 00 00       add    $0x200,%ebx
                      readsect((uint8_t*) pa, offset);
          7d13:       e8 64 ff ff ff          call   7c7c <readsect>
                      offset++;
          7d18:       83 c4 10                add    $0x10,%esp
          7d1b:       eb e7                   jmp    7d04 <readseg+0x22>
      }
          7d1d:       8d 65 f4                lea    -0xc(%ebp),%esp
          7d20:       5b                      pop    %ebx
          7d21:       5e                      pop    %esi
          7d22:       5f                      pop    %edi
          7d23:       5d                      pop    %ebp
          7d24:       c3                      ret
      
      • 关于11-15行代码:先放上大佬的博客从汇编角度理解 ebp&esp 寄存器、函数调用过程、函数参数传递以及堆栈平衡_ebp汇编_江下枫的博客-CSDN博客膜拜膜拜~~

        首先在第四行push了ebp寄存器,将调用者函数的基质放入,第九行为堆栈平衡操作,将sp指向传入的参数,可以理解为跳过栈中ebp和参数之间的地址,11-15行为将ebp进行加减,获取参数,不要忘了函数参数写入的顺序和压入栈中的顺序相反,后写先入的规则。

      • 17行:逻辑右移9位相当于除以512,相当于完成了offset / SECTSIZE。

      • 能力有限,在分析行代码进入waitdisk函数时实在是没看明白。

    4. 再for循环和redseg之间的代码比较容易理解,就不赘述了,我们看一下for循环的汇编代码

              for (; ph < eph; ph++)
          7d66:       39 f3                   cmp    %esi,%ebx # 循环结束条件
          7d68:       73 17                   jae    7d81 <bootmain+0x5c> # 跳出
                      readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
          7d6a:       50                      push   %eax 
              for (; ph < eph; ph++)
          7d6b:       83 c3 20                add    $0x20,%ebx
                      readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
          7d6e:       ff 73 e4                pushl  -0x1c(%ebx)
          7d71:       ff 73 f4                pushl  -0xc(%ebx)
          7d74:       ff 73 ec                pushl  -0x14(%ebx) # 为函数调用做准备
          7d77:       e8 66 ff ff ff          call   7ce2 <readseg> #调用函数
              for (; ph < eph; ph++)
          7d7c:       83 c4 10                add    $0x10,%esp # ph++
          7d7f:       eb e5                   jmp    7d66 # <bootmain+0x41>
              ((void (*)(void)) (ELFHDR->e_entry))();
          7d81:       ff 15 18 00 01 00       call   *0x10018
      
      
      • 根据上下文,我们易知esi存放着e_phnum,ebx存放着e_phoff,所以为循环结束条件。
  • 在结束exercise3之后,我们回答一下四个问题。

    • Q1:At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

    • A1:在实模式下为16bit模式,在保护模式为32bit模式,在做好转换成保护模式的准备之后会切换

        ljmp    $PROT_MODE_CSEG, $protcseg # 转为32地址模式
      
    • Q2:What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

    • A2:

      •   ((void (*)(void)) (ELFHDR->e_entry))(); //最后一个语句
        
      • 为了找到kernel的第一条指令,我们先看一下kernel的反汇编

        lihua@ubuntu:~/6.828/lab/obj/kern$ objdump -x kernel
        
        kernel:     file format elf32-i386
        kernel
        architecture: i386, flags 0x00000112:
        EXEC_P, HAS_SYMS, D_PAGED
        start address 0x0010000c
        

        再用GDB看一下0x0010000c

        (gdb) x/10i 0x0010000c
           0x10000c:	movw   $0x1234,0x472
        

        所以第一条语句为

           0x10000c:	movw   $0x1234,0x472
        
    • Q3:Where is the first instruction of the kernel?(同一个问题为啥问两遍?)

    • A3:

         0x10000c:	movw   $0x1234,0x472
      
    • Q4:How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?

    • A4:

              // load each program segment (ignores ph flags)
              ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
              eph = ph + ELFHDR->e_phnum;
              for (; ph < eph; ph++)
                      // p_pa is the load address of this segment (as well
                      // as the physical address)
                      readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
      
      

      在这个for循环中,循环行为均由ELFHDR中的一些值来决定,所以ELF头文件的规定决定了读取多少扇区。

Loading the Kernel

  1. exercise4:考察对c语言指针的理解与应用(和前几个相比简直小菜一碟)。

    //pointers.c
    #include <stdio.h>
    #include <stdlib.h>
    
    void
    f(void)
    {
        int a[4];
        int *b = malloc(16);
        int *c;
        int i;
    
        printf("1: a = %p, b = %p, c = %p\n", a, b, c);
    
        c = a;
        for (i = 0; i < 4; i++)
    	a[i] = 100 + i;
        c[0] = 200;
        printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
    	   a[0], a[1], a[2], a[3]);
    
        c[1] = 300;
        *(c + 2) = 301;
        3[c] = 302;
        printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
    	   a[0], a[1], a[2], a[3]);
    
        c = c + 1;
        *c = 400;
        printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
    	   a[0], a[1], a[2], a[3]);
    
        c = (int *) ((char *) c + 1);
        *c = 500;
        printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
    	   a[0], a[1], a[2], a[3]);
    
        b = (int *) a + 1;
        c = (int *) ((char *) a + 1);
        printf("6: a = %p, b = %p, c = %p\n", a, b, c);
    }
    
    int
    main(int ac, char **av)
    {
        f();
        return 0;
    }
    //输出
    1: a = 000000000062FDC0, b = 00000000007713F0, c = 0000000000000001
    2: a[0] = 200, a[1] = 101, a[2] = 102, a[3] = 103
    3: a[0] = 200, a[1] = 300, a[2] = 301, a[3] = 302
    4: a[0] = 200, a[1] = 400, a[2] = 301, a[3] = 302
    5: a[0] = 200, a[1] = 128144, a[2] = 256, a[3] = 302
    6: a = 000000000062FDC0, b = 000000000062FDC4, c = 000000000062FDC1
    
    
    • 首先看15行,使得指针a与c指向的是同一块连续地址空间,所以c[0]可以改变原先由a[i]初始化的值。

    • 最有意思的是24行的代码:相当于改变了数组中的值

      3[c] <--> c[3] <--> *(c+3)
      
    • 28行代码,因为c的类型为int *,所以c的步长为4,c+1就会指向数组下一个元素。

    • 33行代码 :首先将c强转成char*,所以步长从4变到了1,此时c指向的是数组第二个元素,而只有一次取出4字节,才可以完整地从数组中取出一个int类型的元素,而此时步长加1,指向的是中间的某一个地址,所以5行输出会有错误。

    • 最后一段代码是为了展示“步长”的存在,首先a为int*类型的指针,加1后地址加4,所以从结果来看b地址比a大4,而强转成char*后,步长为1,加1以后地址也只加1,根据结果c地址比a大1。

    • 当您编译和链接一个C程序(如JOS内核)时,编译器将每个C源文件('. C ')转换为一个对象文件('.o'),其中包含以硬件期望的二进制格式编码的汇编语言指令。然后链接器将所有编译的目标文件合并成一个二进制映像,例如obj/kern/kernel,在这种情况下是ELF格式的二进制文件,它代表**“可执行和可链接格式”**。

    • 在6.828中,可以把ELF可执行文件看作是一个带有加载信息的头文件,后面跟着几个程序段,每个程序段都是一个连续的代码块或数据块,打算在指定地址加载到内存中。引导加载程序不修改代码或数据;它将它加载到内存中并开始执行它。

    • ELF二进制文件以固定长度的ELF头开始,后面是一个可变长度的程序头,列出要加载的每个程序节。这些ELF头文件的C定义在inc/ ELF .h中。我们感兴趣的部分是:

      • .text: The program's executable instructions.
      • .rodata: Read-only data, such as ASCII string constants produced by the C compiler. (We will not bother setting up the hardware to prohibit writing, however.)
      • .data: The data section holds the program's initialized data, such as global variables declared with initializers like int x = 5;.
    • 当链接器计算程序的内存布局时,它为未初始化的全局变量保留空间,例如在内存中紧跟在.data之后的.bss节中保留int x;C要求“未初始化”的全局变量从值0开始。因此,不需要在ELF二进制文件中存储.bss的内容;相反,链接器只记录.bss节的地址和大小。加载程序或程序本身必须安排将.bss节归零。

    • 我们在看kernel的反汇编时,会出现以下内容

      Sections:
      Idx Name          Size      VMA       LMA       File off  Algn
        0 .text         00001acd  f0100000  00100000  00001000  2**4
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .rodata       000006bc  f0101ae0  00101ae0  00002ae0  2**5
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        2 .stab         00004291  f010219c  0010219c  0000319c  2**2
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        3 .stabstr      0000197f  f010642d  0010642d  0000742d  2**0
      

      段的加载地址LMA是该段加载到内存中的内存地址(物理地址)。节的链接地址VMA(虚拟地址)是该节期望执行的逻辑地址。链接器以各种方式在二进制文件中编码链接地址,例如当代码需要全局变量的地址时,结果是如果二进制文件从没有链接到的地址执行,则它通常无法工作。

    • ELF对象中需要加载到内存中的区域是那些标记为“LOAD”的区域。给出了每个程序头文件的其他信息,例如虚拟地址(“vaddr”)物理地址(“paddr”)和加载区域的大小(“memsz”和“filesz”)。回到boot/main.c中,每个程序头的ph->p_pa字段包含段的目标物理地址(在这种情况下,它实际上是一个物理地址,尽管ELF规范对该字段的实际含义不明确)。

      Program Header:
          LOAD off    0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
               filesz 0x00007dac memsz 0x00007dac flags r-x
          LOAD off    0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
               filesz 0x0000b6a8 memsz 0x0000b6a8 flags rw-
         STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
               filesz 0x00000000 memsz 0x00000000 flags rwx
      

      BIOS从地址0x7c00开始将引导扇区加载到内存中,因此这是引导扇区的加载地址。这也是引导扇区执行的地方,所以这也是它的链接地址。我们通过将- text 0x7C00传递给boot/Makefrag中的链接器来设置链接地址,因此链接器将在生成的代码中生成正确的内存地址。

  2. exercise5:

    1. 首先根据提供的规则*我们通过将- text 0x7C00传递给boot/Makefrag中的链接器来设置链接地址*,所以就看一下boot/Makefrag文件

      (OBJDIR)/boot/boot: $(BOOT_OBJS)
              @echo + ld boot/boot
              $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out $^
              $(V)$(OBJDUMP) -S $@.out >$@.asm
              $(V)$(OBJCOPY) -S -O binary -j .text $@.out $@
      

      所以说我们只要改变0x7c00,就可以是程序出现错误。

  3. exercise6:重置机器(退出QEMU/GDB并重新启动它们)。在BIOS进入引导加载程序时检查0x00100000处的8个字的内存,然后在引导加载程序进入内核时再次检查。为什么它们不同?第二个断点处有什么?

    0x100000:   0x00000000  0x00000000  0x00000000  0x00000000 //之前
    0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766 //之后
    

    应该为for循环时,将内核的各个段写入到 ph->p_offset中,写入的应该时各个段的内容。

Part 3: The Kernel

Using virtual memory to work around position dependence

    • 操作系统内核通常喜欢链接并运行在非常高的虚拟地址上,例如0xf0100000,以便将处理器虚拟地址空间的较低部分留给用户程序使用,我们在*·kern/kernel.ld.·*就可以看到。

    • 我们将使用处理器的内存管理硬件将虚拟地址0xf0100000(内核代码预期运行的链接地址)映射到物理地址0x00100000(引导加载程序将内核加载到物理内存中的位置)。这样,尽管内核的虚拟地址足够高,可以为用户进程留下足够的地址空间,但它将被加载到PC机RAM中的1MB点的物理内存中,就在BIOS ROM之上。

    • 事实上,在下一个实验中,我们将映射PC的整个底部256MB的物理地址空间,从物理地址0x00000000到0x0fffffff,分别到虚拟地址0xf0000000到0xffffffff。现在您应该明白了为什么JOS只能使用第一个256MB物理内存。

    • 现在,我们只映射第一个4MB的物理内存,这将足以让我们启动和运行。我们使用kern/entrypgdir.c中手工编写的、静态初始化的页目录和页表来实现这一点。直到kern/entry.S设置CR0_PG标志,内存引用被视为物理地址(严格地说,它们是线性地址,但是boot/boot.S建立了一个从线性地址到物理地址的单位映射)。一旦设置了CR0_PG,内存引用就是虚拟地址,由虚拟内存硬件将其转换为物理地址。都将导致硬件异常,因为我们还没有设置中断处理,将导致QEMU转储机器状态并退出。

      Entry_pgdir进行的虚拟地址映射
      
      0xf0000000~0xf0400000虚拟地址 --> 0x00000000~0x00400000物理地址
      0x00000000~0x00400000虚拟地址 --> 0x00000000~0x00400000物理地址
      

推进到此,出现了一个比较大的问题,上文也提到过,就是在设置断点后,我的继续*c之后,一直停在continuing处卡着,按常理来说应该会运行到断点处然后出现(gdb)* 的标志,但是很遗憾没出现,之前的几个exercise还可以混过去,但是到exercise7就不行了,所以让我先解决问题,再补上exercise7。

Formatted Printing to the Console

  1. exercise8:我们省略了一小段代码——使用“%o”形式的模式打印八进制数所需的代码。查找并填充此代码片段。(我**怎么知道你在哪里少了代码?)

    1. \lib\printfmt.c

      • 会看到注释

                        // (unsigned) octal
                        case 'o':
                                // Replace this with your code.
                                putch('X', putdat);
                                putch('X', putdat);
                                putch('X', putdat);
                                break;
        
      • 所以根据上下文我们将自己的代码写上去。

        			num = getuint(&ap, lflag);
        			base = 8;
        			goto number;
        
    2. 实际上这个练习已经结束了,我们来根据下面问题来分析文件。

      1. 解释printf.c和console.c之间的接口关系

        • 答:调用关系很复杂我只分析了其中一条。具体关系可以看大佬

          vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
          |
          static void
          putch(int ch, int *cnt)
          |
          void
          cputchar(int c)
          |
          static void
          cons_putc(int c)
          
      2. 解释console.c的以下代码

        1 if (crt_pos >= CRT_SIZE) {
        2     int i;
        3     memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
        4     for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
                  crt_buf[i] = 0x0700 | ' ';
        5     crt_pos -= CRT_COLS;
        6 }
        
        • 答:我们先在.h文件中找出宏的意思

          #define CRT_ROWS        25
          #define CRT_COLS        80
          #define CRT_SIZE        (CRT_ROWS * CRT_COLS)
          

          当crt_pos大于等于CRT_SIZE时,说明控制台已写满,再结合memmove函数的意义,因此含义为将内容上移一行,且将多出的一行进行类似初始化的操作。

      3. 逐步跟踪以下代码的执行:

        int x = 1, y = 3, z = 4;
        cprintf ("x %d, y %x, z %d\n"x, y, z);
        
        1. 在调用printf()时,fmt指向什么?ap指向什么?

        2. 列出(按执行顺序)对con_put、va_arg和vcprintf的每个调用。对于con_put,也列出它的参数。对于va_arg,列出ap在调用前后指向的对象。对于vcprintf,列出它的两个参数的值。

        • 答:

                  while (1) {
                          while ((ch = *(unsigned char *) fmt++) != '%') {
                                  if (ch == '\0')
                                          return;
                                  putch(ch, putdat);
                          }
          

          根据此代码可以猜出参数fmt指向的地址直接进行了输出,所以指向了"x %d, y %x, z %d\n"的地址,而我们有注意到

                          case '*':
                                  precision = va_arg(ap, int);
                                  goto process_precision;
          

          后面的代码会常出现va_xxx类似的函数,而ap是作为参数传入到此类参数的具体请戳c - 变量参数如何在 gcc 中实现?- 堆栈溢出 (stackoverflow.com)大概的意思是此函数为我们提供了一种处理函数参数数量不确定的解决方案,所以ap指向参数xyz。后面的调用链详见此博客