【MIT6.828学习笔记】Lab1 - 2 逐行分析 + 翻译

*前排提醒*: 排版确实有点奇怪,因为我是用typora写的,写完直接复制粘贴发了,要想良好体验 最好粘贴到typora)

Lab1

知识前提

实模式

1024B = 1KB , 1024KB = 1MB

2^10 = 1024B = 1KB

1024KB = 1MB

2^20 = 2^10 * 2^10 = 1KB * 2^10 = 1024KB = 1MB

实模式出现在早期的8088CPU的时期,由于当时Cpu性能有限,一共有20位地址线(2^20 = 1MB,所以地址空间只有1MB),以及8个16位的通用寄存器,4个16位的段寄存器。

而16位和20位肯定是无法相互转换的,所以为了能通过16位寄存器来计算出20位主存地址,访问存储器的地址码(物理地址)由段地址段内偏移地址两部分组成。

注意:实模式都是直接对物理地址进行操作。

(段基址:段偏移量)

其中 段基址 是由段寄存器提供的,而段寄存器有四种。

  • 段寄存器是因为对内存的分段管理而设置的。计算机需要对内存分段,以分配给不同的程序使用

  • 四个段寄存器

    • CS 是代码 段寄存器

      • 存放当前正在运行的程序代码所在段的段基址,也就是可以从此段寄存器的指定存储器中获取当前指令代码(程序代码)

    • DS 是数据 段寄存器

      • 存放当前程序所使用的数据所存放段的最低地址,也就是存放数据段的段基址

      • IP指令指针寄存器 存放与段基地址的偏移量

      • CS:IP 指向当前要执行的指令的地址

    • SS 是堆栈 段寄存器

      • 存放当前堆栈的底部地址,也就是存放堆栈段的段基址

      • SP栈顶指针寄存器 存放与栈顶的偏移量

      • SS:SP 指向栈顶元素

    • ES 是附加 段寄存器

      • 存放当前程序所使用的附件数据段的段基址

每一个段寄存器都负责对应的指令类型

eg:

  • 取得程序指令应该采用CS寄存器

  • 要读写数据应该采用DS寄存器

  • 需要对堆栈操作应该采用SS寄存器

  • ....

不管什么指令,都会有一个段寄存器提供一个16位的段基址

CS:IP CPU将内存中 CS:IP 指向的内容当作指令执行

段内偏移量 = 目标内存地址 - 段基址,这个值是由通用寄存器提供的,所以也是16位。

  • 问题就来了!两个16位值如何组合成为20位地址呢?

    • 物理地址 = 段基址 * 16 (二进制表示是 << 4,十六进制表示是<< 1)

    • 物理地址 += 段内偏移

总的来说就是:

  • 物理地址 = 段基址 * 16 + 段内偏移。(注意!这个计算过程是在位址加法器上执行的!所以不要担心左移就溢出了)

eg:%ds = 2000H,%ax = 0000H(%代表是个寄存器,H代表是16进制位)

则(%ds : %ax)= 2000 * 16+ 0000 = 20000H

计算完成后通过地址总线(bus 20Bit) 传入内存 完成寻址 !

到内存完成寻址 找到对应指令并读取之后,由数据总线(data Bus 16位) 传输回到 输入输出电路 - 指令缓冲区 - 执行控制器 从而由CPU去执行指令 进而

IP = IP + 所读指令的长度,指向下一条指令。重复执行以上过程

以上就是实模式下访问内存地址的原理,由此可发现 实模式中每次都是直接操作物理地址,如果操作失误、不当 可能会造成系统崩塌 等极为严重的事件,

并且

但是随着CPU的发展,CPU的地址线的个数也从原来的20根变为现在的32根,所以可以访问的内存空间也从1MB变为现在4GB(),寄存器的位数也变为32位。所以实模式下的内存地址计算方式就已经不再适合了。所以就引入了现在的保护模式,实现更大空间的,更灵活的内存访问。

由此引出我们下一个讨论的保护模式

附:

保护模式

地址转化方向:

逻辑地址 -> [段式内存管理单元] -> 线性地址(虚拟地址) -> [页式内存管理单元]-> 物理地址

 

程序员编写、看得到的都是虚拟地址,但此指令并不是真正写入指令当中,真正写入指令当中的地址是需要进行推导的,推导出来的是逻辑地址

逻辑地址

  • 是包含在机器语言指令中用来指定一个操作数或一条指令的地址。

  • 每一个逻辑地址都由一个段和偏移量组成, 偏移量指明了从段开始的地方到实际地址之间的距离。

    1. 段选择子 (segment selector)

    2. 段内偏移量 (offset)

通常写作成:segment : offset 在程序中 采用哪个段选择子在指令中是隐含的,程序员只需要指明 段内偏移量 然后就会通过分段管理机构(段式内存管理) 将逻辑地址转化成线性地址,接着继续通过分页管理机构逻辑地址转化成物理地址,倘若不存在分页机构,则转化出的线性地址就是物理地址

注意:在boot loader中 并没有开启分页管理机构,所以计算出的线性地址就是真实需要访问的物理地址

那么在保护模式下,我们是如何通过 segment : offset 最终得到物理地址的呢?

首先我们应该了解:

计算机中存在两张同类型的表

  • GDT 全局段描述符表(全局可见)

  • LDT 局部(本地)段描述符表

两张表的作用都是相同的,即都是用来存放运行在内存中的程序分段信息

比如 此程序的 代码段 数据段 从哪开始(段基址),有多大(总量);

GDT是全局可见,即在内存上运行的所有程序都能看见这张表,所以操作系统的内核信息也都存在里面。

  • 在整个系统中GDT只有一张,GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。

LDT是本地可见,即是每个在内存中运行的程序都有这一张表,里面指明了该程序的段信息。

GDT/LDT中的段信息

  • GDT / LDT 段信息

    • Base :32位,代表此程序的这个段的基地址

    • Limit:20位,代表此程序的这个段的大小

    • Flags:12位,代表此程序的这个段的访问权限

在此我们就可以分析该如何得到物理地址了

保护模式并不像实模式那样,segment : offset,直接将 segment 作为基地址,与offset偏移量直接相加。 而是,把segment的值作为一个selector(选择器),代表这个段的段表项GDT/LDT表的索引。

系统会给程序自动分配程序段,代码段等,这些段以及偏移组成了逻辑地址,而逻辑地址通过GDTR/LDTR , 首先根据Flags字段判断能否访问此段内容(这样子是为了对进程间的地址进行保护),如果能访问,则把Base字段(段基址)的内容取出 直接与offset相加得到 线性(虚拟)地址.

总结:

由此可见,保护模式比实模式作用更大更好

  1. 实模式下段基地址必须是16的整数倍,保护模式下的段基地址可以是4GB空间内的任意地址。

  2. 实模式下 段的长度是 65536 B,但保护模式下 段的长度是可以达到4GB的

  3. 保护模式下可以对内存访问多一层保护,也就是Flags判断访问权限,但是实模式没有,直接操作真实内存地址。

x86系列CPU的主要寄存器

寄存器名称名称主要功能
eax累加寄存器运算
ebx基址寄存器存储地址地址
ecx计数寄存器计算循环次数
edx数据寄存器存储数据
esi源基址寄存器存储数据发送源的内存地址
edi目的基址寄存器存储数据发送目标的内存地址
ebp扩展基址指针寄存器存储数据存储领域基点的内存地址
esp扩展栈指针寄存器存储栈中最高位数据的内存地址

x86系列32位CPU的寄存器名称中,开头都带了字母e。这是因为16位CPU寄存器名称是ax、bx、cx、dx...而32位CPU寄存器名称中的e,有扩展(extended)的意思。我们也可以仅利用32位寄存器的低16位,此时只需有把要指定的寄存器的低16位,此时只需要把指定的寄存器名开头的 “e” 去掉即可。而保护模式就是32位的,所以上述寄存器可在保护模式下使用。


第二部分:引导加载器

不过对于 6.828,我们将使用传统的硬盘引导机制,意味着我们的引导加载器必须小于 512 字节。引导加载器是由一个汇编源文件boot/boot.S和一个 C 源文件boot/main.c构成,仔细研究这些源文件可以让你彻底理解引导加载器都做了些什么。

在下贴出boot.S文件分析详情

注意:GNU汇编器使用 AT&T 样式的语法,所以其中的源和目的操作数和 Intel 文档中给出的顺序是相反的。

#include <inc/mmu.h>
​
# Start the CPU: switch to 32-bit protected mode, jump into C.(BOIS目的是在启动CPU后切换到32位保护模式之后 跳转到C语言函数中。)
​
# The BIOS loads this code from the first sector of the hard disk into(BIOS将这行代码加载到硬盘第一个扇区中,这片扇区称之为启动区)
# memory at physical address 0x7c00 and starts executing in real mode(在实模式下,执行内存中0x7c00的物理地址)
# with %cs=0 %ip=7c00. 这里的地址就是 0x7c00
​
.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 配置为 16位模式
  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. 
  #   为了兼容最原始的电脑,物理地址总线20被置于低位,所以大于 1MB 的内存地址初始值默认为0 
  #   并且无法访问到大于1MB的内存空间, 为了解决这个窄小的内存空间, 下面将要执行的代码 取消了这些限制
  #   简单理解就是: 摆脱实模式, 进入保护模式, 下面是解决代码
  
 ============================看到这里,强烈建议你先看下面端口的介绍============================
 
  # inb outb 属于IO端口指令
  @inb:往端口读入一个字节,(inb = in + b 向某个端口读取一个字节,一个字节八个bit, 而0x64端口正好是8bit 每bit对应一种状态, 你细品!)
seta20.1:
  inb     $0x64,%al               # Wait for not busy 等待空闲时. 意思如下:
  testb   $0x2,%al               # 与(0x2 ->二进制)0000 0010 和 (0x64的二进制)进行'与运算' 
                                # 判断 第二位是否为1, 为1时 输入缓冲区已满(满了就是繁忙状态)
                                # 为 1 的时候就会执行jnz指令,为 0 的时候就执行下面的指令
                                
  jnz     seta20.1  # 又跳到seta20.1, 可以发现是死循环,也就可以解释'等待'这个的意思了(testb 和 jnz 是联合的 不是说一定会执行jnz)
  
  @outb:输出指令,往端口写入一个字节
  movb    $0xd1,%al               # 0xd1 -> port 0x64 
                                # al寄存器接受'端口0xd1'的一个字节
  outb    %al,$0x64               # 将'端口0xd1'的数据写入'端口0x64'当中
                                  # D1指令代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口。可以理解为下一个写入0x60端口的数据是一个控制指令(控制指令也就是:启动A20)。
​
seta20.2:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al                # 分析跟上面一样,就不再赘述了          
  jnz     seta20.2
​
  movb    $0xdf,%al               # 0xdf -> port 0x60
  outb    %al,$0x60               # 由上面可知, 此时往0x60端口写的数据是往804x控制器输出端口写入
                                  # 这里就是启动了A20地址线, 代表可以访问1MB以上的内存空间。从这里开始表示 实模式的1MB限制地址的时代已经结束了,但别急!依然要为保护模式进行一些配置。
​
 

0x64端口

  • 0x64端口 是键盘控制器的IO端口

  • 键盘控制器有两个端口0x64和0x60 .

    端口0x64(命令端口)用于向键盘控制器(PS / 2)发送命令 .

    端口0x60(数据端口)用于向/从PS / 2(键盘)控制器或PS / 2设备本身发送数据 .

  • 各位的含义:

  • 0064    r   KB controller read status (MCA) # 控制器读取状态
             bit 7 = 1 parity error on transmission from keyboard # 为1时, 键盘传输时奇偶校验错误
             bit 6 = 1 general timeout # 为1时, 超时
             bit 5 = 1 mouse output buffer full # 为1时,鼠标输出缓存区已经满了
             bit 4 = 0 keyboard inhibit # 为0时,禁止键盘
             bit 3 = 1 data in input register is command # 为1时,输入寄存器中的数据为命令
                 0 data in input register is data # 为0时,输入寄存器中的数据为数据
             bit 2   system flag status: 0=power up or reset  1=selftest OK # 系统标志状态:0=开机或复位 1=自检正常
             bit 1 = 1 input buffer full (input 60/64 has data for 804x) # 为1时 输入缓冲区已满
             bit 0 = 1 output buffer full (output 60 has data for system)# 为1时 输出缓冲区已满

0xd1 0xdf端口

  • ->   D1 dbl   write output port. next byte written  to 0060
                  will be written to the 804x output port; # D1指令代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口。可以理解为下一个写入0x60端口的数据是一个控制指令。
                  the original IBM AT and many compatibles use bit 1 of
                  the output port to control the A20 gate.
                  
              Compaq  The system speed bits are not set by this command
                  use commands A1-A6 (!) for speed functions.
         D2 MCA   Enhanced Command: write keyboard output buffer
         D3 MCA   Enhanced Command: write pointing device out.buf.
         D4 MCA   write to mouse
         D4 AWARD Enhanced Command: write to auxiliary device
         DD sngl  disable address line A20 (HP Vectra only???)
                  default in Real Mode
    ->    DF    sngl  enable address line A20 (HP Vectra only???)# 启动使用A20 地址线 代表可以进入保护模式了(这里有同学会产生疑问, 没关系 下面为你揭晓)
  • 为什么要开启A20地址线呢?

    • 在最早的cpu 8086/8088,当时只有实模式,而实模式下地址线是20位(A0 ~ A19), 所以最大只能范围2^20 即1MB,但即使超出1MB也是允许的。为什么允许呢?因为实模式使用了地址回绕(wrap-around) ,将超过1MB的部分自动回绕到0地址,并继续从0地址开始映射。也就是把 地址对1MB求模

    • 但是随着技术的发展,区区1MB的内存空间不够用了,cpu发展到80286时期,地址总线从20位发展到24位 (A0 - A23) 很明显已经超出了1MB的寻址范围,达到了2^23 即16MB。如果还继续使用实模式下那套规矩,那么就没办法寻址1MB外的内存空间,为了解决这个问题,就yin'chu

    • 自古以来,Intel都会把 兼容 放在第一位,所以为了开创保护模式,80286拥有两种模式实模式 保护模式 ( 80286就是第一款具有保护模式的CPU),

      • 在 A20 Gate 被禁止时,表现依然会与8086/8088一样,依然拥有地址环绕,也就是,只有A0 - A19 能运作。默认是被禁止,因为BIOS是最先执行的程序

      • 在 A20 Gate 被开启时,就可以访问1MB以上的内存空间。

继续接上面的代码

  # 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.
  # 通过使用了 bootstrap GDT(在代码最下方) 和 分段管理机构(段式内存管理) 配合形成 虚拟地址,来完成实模式转换至保护模式这一过程。
  # 因为(线性地址)虚拟地址与物理地址完全一模一样(注意!没有引进分页机制) 因此即使在转换过程中所要执行的是真正的内存地址依然不会改变。
  
  @gdtdesc:是一个标识符, 标识着一个内存地址
  @gdtdesc:是一个函数入口, 具体函数实现在所有代码的最下方 
  lgdt    gdtdesc        # 把 gdtdesc 这个标识符的值送入全局映射描述符表寄存器GDTR中
                         # 其中包括GDT表的内存起始地址,以及GDT表的长度。
                         # 这个寄存器由48位组成,其中低16位表示该表长度,高32位表该表在内存中的起始地址。
  movl    %cr0, %eax        # CR0是系统内的控制寄存器之一。
  orl     $CR0_PE_ON, %eax  # 或运算, 目前不知道为什么要取或, 直接赋值1不就好了, 迷惑ing 原因:很简单,保证其他bit位的数据 ps:我也不知道我这个愚蠢的问题哪来的
  movl    %eax, %cr0        # 开启保护模式!(终于!泪目)
  
  • CR0

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

    • 0位(CR0)是保护允许位PE(Protedted Enable),用于启动保护模式。

    • 如果PE=1,则启动保护模式。

    • 如果PE=0,则禁用保护模式(只能以实模式运行,拥有地址环绕)。

# Jump to next instruction, but in 32-bit code segment.
  # Switches processor into 32-bit mode.
  #接下来,指定跳转到下一个指令,但跳转后的所要执行的指令是在32位数据段中执行的(当前是16位),所以我们要执行.code32 将CPU转换至32位模式下
  ljmp    $PROT_MODE_CSEG, $protcseg # 跳转至 protcseg
​
  .code32                     # Assemble for 32-bit mode 组建 32位模式
protcseg:
  # Set up the protected-mode data segment registers
  # 配置好32位保护模式下的数据段寄存器
  # PROT_MODE_DSEG 是在代码顶部配置好的 内湖数据段选择器
  movw    $PROT_MODE_DSEG, %ax    # Our data segment selector , 将内核数据段选择器中的数据
  movw    %ax, %ds                # -> DS: Data Segment , 配置到数据段
  movw    %ax, %es                # -> ES: Extra Segment ,配置到FS
  movw    %ax, %fs                # -> FS ,配置到FS
  movw    %ax, %gs                # -> GS ,配置GS
  movw    %ax, %ss                # -> SS: Stack Segment , 配置到栈段
  
  # 关于x86 寄存器更多了解我放在下面
  

x86 寄存器

# Set up the stack pointer and call into C.
  # 设置好 esp寄存器的值(栈指针寄存器) 然后就可以进入C语言部分啦!
  movl    $start, %esp
  call bootmain # 哦吼!进入伟大熟悉的bootmain()函数
​
  # If bootmain returns (it shouldn't), loop.
  # 如果 bootmain 返回了,在这里就死循环(我也不知道为什么)
spin:
  jmp spin
#-------------------------------------------------------------------  
# Bootstrap GDT
# 上面提到的 Bootstrap GDT 在这里出现了
.p2align 2                                # force 4 byte alignment
​
gdt: # 从这里开始就是 GDT表了, 没错 这就是真面目!
  SEG_NULL                        # null seg 空段
  SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg 代码段
  SEG(STA_W, 0x0, 0xffffffff)           # data seg 数据段
  
  # 由于xv6其实并没有使用分段机制, 也就是说数据和代码都是写在一起的, 所以数据段和代码段的起始地址都是0x0,大小都是 0xffffffff = 4GB
  # gdt 中的三行代码是调用seg()子函数来构造的, 此函数定义在 mmu.h 中, 我在下面已经罗列出来了
​
 #gdtdesc就是要存放上面GDT表的信息了
gdtdesc:
  .word   0x17                            # sizeof(gdt) - 1 , (0x17是这个表的大小 - 1) = 0x17 = 23, 因为24就是gdt表(下面)的起始地址了
  .long   gdt                             # address gdt 保存get地址信息

mmu.h中 seg() 子函数

 #define SEG(type,base,lim)                    \
                    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);    \
                    .byte (((base) >> 16) & 0xff), (0x90 | (type)),        \
                    (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

这里来小结一下:

  • 当CPU启动时 率先加载的是 0xffff0 此处的内存地址 ( 相对于执行了BIOS ,因为这段内存地址是在BIOS范围中 ),这里有个指令,也是第一个要执行的指令 :ljmp $0xf000,$0xe05b 这是跳转到0xfe05b 之后,执行一系列的初始化,检测设备等重要工作,其中最重要的就是

    1. 寻找可引导盘(操作系统存在的磁盘) ,然后将可引导盘的第一个扇区 通常命名为:启动区 加载到内存中

      • 硬盘布局

        • bootloader (boot.S and main.c) 存放在启动盘的第一个 sector

        • kernel (必须为 elf 文件) 存放在第二个 sector

    2. 在启动区(boot sector)中,有个十分重要的程序boot loader ,通过它负责整个操作系统从磁盘加载进内存的工作 以及一些极其重要的配置工作(例如我们分析到的 配置32位寄存器环境,开启A20、CR0的 0 bit 赋值为1...)。

    3. 至此,操作系统就运行起来

  • 可见,当PC启动后,大致流程是 BIOS -> bootloader -> 操作系统内核

接着继续分析main.c 文件,让我们瞧瞧他都在干些什么?

#include <inc/x86.h> 
#include <inc/elf.h> 
//上面引用了这两个文件,当你遇到不熟悉的结构和属性时,可以在这里面找到
/**********************************************************************
​
* This a dirt simple boot loader, whose sole job is to boot
 * an ELF kernel image from the first IDE hard disk.
 * 这是一个简陋的引导加载器,他的唯一任务就是从第一个IDE硬盘中加载一个ELF内核映射
 *
 * DISK LAYOUT 硬盘布局
 *  * This program(boot.S and main.c) is the bootloader.  It should
 *    be stored in the first sector of the disk.
 *    由于这个程序是引导加载器, 所以它会被加载进磁盘的第一个扇区
 *
 *  * The 2nd sector onward holds the kernel image.
 *    而第二个扇区用于保存内核镜像。
 *  * The kernel image must be in ELF format.
 *    内核镜像必须是ELF格式
 

在这里就要科普一下ELF

  • "Executable and Linkable Format" 的简称。当编译和链接一个 C 程序的时候,编译器将每个 C 源码文件 (.c) 转为一个对象文件 (.o) ,对象文件中存放的是机器能理解的二进制格式的汇编语言指令。然后,链接器 (linker) 将所有对象文件结合为一个二进制映像 (image) 文件

  • EFL头以一个16字节的序列开始,ELF头含有12个节

    • 简单介绍 .text .data .bss 这三个节

    • 在采用段式内存管理的架构中(比如intel的80x86系统),bss段(ted by Symbol segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域,一般在初始化时 bss 段 Block Star部分将会清零。bss段属于静态内存分配,即程序一开始就将其清零了。在C语言之类的程序编译完成之后,已初始化的全局变量保存在 .data 段中,未初始化的全局变量保存在 .bss 段中 。

    • 介绍
      .text已编译代码所在的位置
      .data全局表、变量等所在的位置
      .bss不要在你的文件中寻找.bss的相关存储信息,因为那是不存在的。为什么不存在呢?因为那是你代码中未初始化数组和变量所在的地方,加载器 "知道"它们应该被赋值为零......但是在你的磁盘上存储这些未初始化的零是没有任何意义的( 浪费空间 ),不是吗?
    • ELF详细的介绍地址:ELF - OSDev Wiki 或者 CSAPP 上的 第 7 章 以及 ELF文件详解 - 简书 (jianshu.com)

    • 当然,在下面我依然对ELF对进一步的讲解,但只是对于表面了解,我们的目的只用于去理解,而不是挖深( 当然,感兴趣就另说啦!)

​
 
 /* * BOOT UP STEPS 
 *    以下是CPU供电后的启动步骤
 *  * when the CPU boots it loads the BIOS into memory and executes it
 *    当CPU将启动时,它将BIOS加载进内存中并启动BIOS程序
 *  * the BIOS intializes devices, sets of the interrupt routines, and
 *    reads the first sector of the boot device(eg., hard-drive)
 *    into memory and jumps to it.
 *    BIOS初始化设备, 设置中断例程, 并将引导设备(如硬盘驱动器 即操作系统在的磁盘)的第一个扇区(启动区)读入内存并跳转到它。
 *
 *  * Assuming this boot loader is stored in the first sector of the
 *    hard-drive, this code takes over...
 *    如果引导加载器存储在硬盘的启动区, 则这些代码将
 *
 *  * control starts in boot.S -- which sets up protected mode,
 *    and a stack so C code then run, then calls bootmain()
 *    BIOS执行完后将CPU控制权交给给boot.S, 在拥有控制权的期间, 它将系统设置成保护模式 并且 调整了栈顶指针(与我们上面所分析的一致).
 *    之后, 就调用了C语言代码中的bootmain()函数
 *
 *  * bootmain() in this file takes over, reads in the kernel and jumps to it.
 *    由文件中的bootmain()接管, 读入内核并进入到它。
 **********************************************************************/
​
#define SECTSIZE    512 /* 一个扇区大小为512字节                          */
#define ELFHDR      ((struct Elf *) 0x10000) // scratch space Elf表起始地址(表头)
​
@readsect()
@readseg() // 这两个函数的具体实现在代码的最下方
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. 从磁盘上读取第一页 
    // 因为 512*8 = 4096 = 4MB ,所以磁盘第一个扇区上的第一页的大小为4MB
    readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);/* readseg 的形参:
                                                "pa": ELFHDR, --Elf表头
                                                "count": SECTSIZE*8, --页长度
                                                "offset": 0 --偏移量
                                           */
​
    // is this a valid ELF? 判断ELFHDR是否为 ELF
    // elf头部信息的 magic字段是整个头部信息的开端。
    // 并且如果这个文件是格式是ELF格式的话,文件的elf->magic域应该是== ELF_MAGIC的
    // 所以这条语句就是判断这个输入文件是否是 真正的elf可执行文件。
    if (ELFHDR->e_magic != ELF_MAGIC)
        /*在头文件中的inc/elf.h中 ELF_MAGIC 定义语句为: # define ELF_MAGIC 0x464C457FU */
        goto bad; //不是的话就跳转bad函数, 观察底部的bad函数可以发现, 是死循环

  • 头引用文件路径 inc/elf.h 下的 Elf 以及 Proghdr 结构

    • /* 
         uint32_t:无符号4个字节的整型 
         uint16_t:无符号2个字节的整型 
         uint8_t: 无符号1个字节的整型 
      */
      struct Elf {
          uint32_t e_magic;   // must equal ELF_MAGIC 必须与ELF_MAGIC 一样
          uint8_t e_elf[12];
          uint16_t e_type;
          uint16_t e_machine;
          uint32_t e_version;
          uint32_t e_entry;
          uint32_t e_phoff;
          uint32_t e_shoff;
          uint32_t e_flags;
          uint16_t e_ehsize;
          uint16_t e_phentsize;
          uint16_t e_phnum;
          uint16_t e_shentsize;
          uint16_t e_shnum;
          uint16_t e_shstrndx;
      };
      struct Proghdr {
          uint32_t p_type;
          uint32_t p_offset;
          uint32_t p_va;
          uint32_t p_pa;
          uint32_t p_filesz;
          uint32_t p_memsz;
          uint32_t p_flags;
          uint32_t p_align;
      };

// load each program segment (ignores ph flags), 加载每个程序段(忽略ph标志)。
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
​
  • 解析一下,这行代码什么意思

    • 因为ELFHDR表示的是 efl 表头地址,e_phoff 表示的就是 表头 距离 Program Header Table的偏移量。

    • 所以ELFHDR转换为 uint8_t 类型指针后,两者相加的地址就是Program Header Table表头。

  • 引出新的问题了,Program Header Table是啥?

    • 先看一下 ELF文件结构图

    • ELF header: 描述整个文件的组织。

    • Program Header Table: 描述文件中的各种segments(段),通过这个表我们才能找到要执行的代码段,数据段等等。也是 用来告诉系统如何创建进程映像的。

    • sections、 segments的区别和联系:segments是从运行的角度来描述elf文件,sections是从链接的角度来描述elf文件,也就是说,在链接阶段,我们可以忽略program header table来处理此文件,在运行阶段可以忽略section header table来处理此程序(所以很多加固手段删除了section header table)。从图中我们也可以看出,segments与sections是包含的关系,一个segment包含若干个section。

    • Section Header Table: 包含了文件各个section的属性信息(描述文件节区的信息,比如大小、偏移等)。

    
​
    eph = ph + (ELFHDR->e_phnum);
// 由于phnum中存放的是Program Header Table表中表项的个数, 即段的个数。
// 所以这步操作是把eph指向该表末尾
​
    for (; ph < eph; ph++){
        
        // p_pa is the load address of this segment (as well
        // as the physical address)
         // p_pa 是这个段的加载地址(即 物理地址)
         // p_offset 字段指的是这一段的开头相对于这个elf文件的开头的偏移量
         // ph->p_memsz 则指的是这个段被实际装入内存后的大小
        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))();
    /* e_entry字段指向的是这个文件的执行入口地址。所以这里相当于开始运行这个文件。也就是内核文件。 自此就把控制权从boot loader转交给了操作系统的内核。*/
​
/*
                        bootmain 完结!撒花!!!
                        2022/5/4/ 13:30
​
-----------------------------------------------------------------------------------------
    下面是对函数的解析
*/
bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);
    while (1)
        /* do nothing */;
}
​
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'. 从内核的 "偏移量 "处读取 "计数 "字节到物理地址 "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); //重新定向到offset存储单元所在的扇区的起始地址
​
    // translate from bytes to sectors, and kernel starts at sector 字节到扇区编号的转换,内核从扇区开始
    offset = (offset / SECTSIZE) + 1; //从这里开始, offset表示当前在哪一个扇区
​
    // 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 wont be the
        // case once JOS enables the MMU.
         // 因为我们到此还没有使用分页机制,目前在保护模式下使用的是分段机制(boot.S有说到),
         // 所以我们可以直接使用物理地址, 但是一旦JOS启动了MMU 就不是这个样子了(埋下伏笔了 哈哈哈)
        
        readsect((uint8_t*) pa, offset); // 读入扇区
        pa += SECTSIZE; // 指向下一个扇区的头部
        offset++; // 扇区号加一
    }
}
​
void
waitdisk(void)
{
    // wait for disk reaady 等待磁盘重新准备好
    while ((inb(0x1F7) & 0xC0) != 0x40)
        //从端口0x1F7取出一个字节, 由下面具体信息可知 要判断的就是 bit6 == 1 即驱动器是否准备好
        //0xC0:11000000 选取0xC0的原因显而易见,就是要测试bit 7和6 的状态,需要满足位7 = 0, 位6 = 1
        //0x40:01000000 可以观察出 当计算出为0x40时 也就是控制器不执行命令且驱动器准备好 这时候就是磁盘准备好的状态
        
        /* do nothing 啥也不做 也就是等待的意思 */;
}
​

端口0x1F7

  • 01F7    r   status register
             bit 7 = 1  controller is executing a command # 位 7 = 1 控制器正在执行一个命令
             bit 6 = 1  drive is ready # 位 6 = 1 驱动器已准备好
             bit 5 = 1  write fault    # 位 5 = 1 写故障
             bit 4 = 1  seek complete  # 位 4 = 1 寻道完成
             bit 3 = 1  sector buffer requires servicing  # 位 3 = 1个扇区缓冲区需要服务
             bit 2 = 1  disk data read successfully corrected # 位2 = 1个磁盘数据读取成功修正
             bit 1 = 1  index - set to 1 each disk revolution # 位1=1索引--每转一圈设置为1
             bit 0 = 1  previous command ended in an error # 位 0 = 1 前一个命令以错误结束

void
readsect(void *dst, uint32_t offset)
{
    /*
    "dst":当前的表起始地址 --表头
    "offset":当前的扇区号
    */
   
    waitdisk(); // wait for disk to be ready. 等待磁盘准备好
    
    //outb(io,data) 向io端口写入数据data
    outb(0x1F2, 1);     // count = 1 扇区计数为1,代表取出扇区的个数
    outb(0x1F3, offset); // 写入当前扇区编号
    outb(0x1F4, offset >> 8); // 不太懂..为什么要>>8
    outb(0x1F5, offset >> 16); // 不太懂..为什么要>>16 坐等大佬解答呜呜呜
    outb(0x1F6, (offset >> 24) | 0xE0);// 不太懂.................
    /* 但可以知道:
        outb(0x1F2 ~ 0x1F6) 要将需要读取扇区的信息写入对应的端口中
    */
    outb(0x1F7, 0x20);  // cmd 0x20 - read sector 读入扇区,  向0x1F7端口输入指令:0x20, 具体端口含义在下方
​
    // wait for disk to be ready 等待磁盘准备好
    waitdisk();
​
    // read a sector 读取一个扇区
    // insl 指令只能一次读4个字节,所以是 512/4 = 128次。
    insl(0x1F0, dst, SECTSIZE / 4 );
    /*
     insl这个函数包含3个输入参数.
        1.port 代表端口号.
        2.addr 将指定端口中的数据存放到此内存空间的地址.
        3.cnt  则代表读取的次数.
    */
}
  • 01F0、0x1F2、0x1F3、0x1F4、0x1F5、0x1F6、0x1F7 端口信息

    • 01F0    r/w data register # 数据寄存器
      01F2    r/w sector count  # 计数扇区
      01F3    r/w sector number # 扇区编号
      01F4    r/w cylinder low  # 低位磁道
      01F5    r/w cylinder high # 高位磁道
      ​
      01F6    r/w drive/head
               bit 7   = 1
               bit 6   = 0
               bit 5   = 1
               bit 4   = 0  drive 0 select
                    = 1  drive 1 select
               bit 3-0      head select bits
      ​
      01F7    w   command register
              commands:
               98 E5   check power mode   (IDE)
               90  execute drive diagnostics
               50  format track
               EC  identify drive     (IDE)
               97 E3   idle           (IDE)
               95 E1   idle immediate     (IDE)
               91  initialize drive parameters
               1x  recalibrate
               E4  read buffer        (IDE)
               C8  read DMA with retry    (IDE)
               C9  read DMA without retry (IDE)
               C4  read multiplec     (IDE)
               20  read sectors with retry # 0x20指令为 表示要读取这个扇区
               21  read sectors without retry
               22  read long with retry
               23  read long without retry
               40  read verify sectors with retry
               41  read verify sectors without retry
               7x  seek
               EF  set features       (IDE)
               C6  set multiple mode  (IDE)
               99 E6   set sleep mode     (IDE)
               96 E2   standby        (IDE)
               94 E0   standby immediate  (IDE)
               E8  write buffer       (IDE)
               CA  write DMA with retry   (IDE)
               CB  write DMA with retry   (IDE)
               C5  write multiple     (IDE)
               E9  write same     (IDE)
               30  write sectors with retry
               31  write sectors without retry
               32  write long with retry
               33  write long without retry
               3C  write verify       (IDE)
               9A  vendor unique      (IDE)
               C0-C3   vendor unique      (IDE)
               8x  vendor unique      (IDE)
               F0-F4   EATA standard      (IDE)
               F5-FF   vendor unique      (IDE)

  • bootmain( )一句话总结:

    • 因为内核文件一定要放入EIF下执行,所以bootmain就是将内核文件装入EIF下,装完后就执行内核。

  • 10
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
实验目标: 本实验的目标是完成一个可以在QEMU仿真器上运行的x86操作系统。具体地说,我们将编写引导扇区代码和内核代码,并将它们组合成一个可引导的磁盘映像。最后,我们将使用QEMU仿真器启动我们的操作系统。 实验步骤: 1. 准备工作 准备工作包括安装必要的软件和工具、下载实验代码和文档等。 2. 编写引导扇区代码 引导扇区是操作系统的第一个扇区,它需要被放置在磁盘的第一个扇区。引导扇区必须包含一个512字节的主引导记录(MBR),其中包括一个引导程序和分区表。我们需要编写一个能够在引导扇区中运行的汇编代码,它将加载内核并将控制权转交给内核。 3. 编写内核代码 内核是操作系统的核心部分,它负责管理计算机的硬件资源、提供系统调用接口等。我们需要编写一个简单的内核,该内核将输出“Hello, world!”并进入无限循环。我们可以使用C语言编写内核代码,并使用GCC编译器将其编译成汇编代码。 4. 构建磁盘映像 我们需要将引导扇区和内核代码组合成一个可引导的磁盘映像。为此,我们可以使用dd命令将引导扇区和内核代码写入一个空白磁盘映像中。 5. 启动操作系统 最后,我们需要使用QEMU仿真器启动我们的操作系统。我们可以使用以下命令启动QEMU并加载磁盘映像: ``` qemu-system-i386 -hda os.img ``` 实验结果: 经过以上步骤,我们成功地编写了一个简单的操作系统,并使用QEMU仿真器进行了测试。当我们启动操作系统时,它将输出“Hello, world!”并进入无限循环。 实验总结: 本实验让我了解了操作系统的基本概念和架构,并学会了如何编写一个简单的操作系统。通过实验,我更深入地理解了计算机系统的底层原理,对操作系统的工作原理有了更深入的了解。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值