跳至主要內容

老李教你写操作系统 0x00 - bootloader

未央大约 10 分钟操作系统操作系统QEMUGRUBmultiboot

今天开始,我们来学习操作系统的开发。

前置知识

你需要一点汇编语言的知识,老李为此专门写了一个系列文章,算是要用到的基础知识。按逻辑上来讲,本文是接着汇编语言系列的最后一篇文章来写的,那篇文章已经实现了一个操作系统的bootloader,本文只是对其做了规范化

何为规范化?那篇文章中我们的内核最终是纯二进制的指令,我们并不知道内核的大小,只是假设它小于512字节,所以我们只从硬盘读取了一个扇区,加载到内存并执行。规范的做法是将内核组装成约定的格式,最终的内核映像符合这种格式。它有一个header,用于保存内核的元信息,内核的起始地址、加载到何处、由多少段组成等等。我们选择一种通用的格式ELF,下面我们简要介绍一下这种格式。

ELF

先贴一些资料,这些资料都介绍的非常详细,大家可以仔细研究。

这里只介绍我们要用到的一些基础知识。

ELF格式的文件看上去就像这样。

elf-01
elf-01

左边的Linking View对应目标文件,通常来讲就是编译生成的.o文件;右边的Execution View对应可执行文件,通常来讲就是链接生成的文件格式。文件由ELF Header开始,后跟Program Header TableSectionsSegments,最后是Section Header Table。下面我们看一下ELF HeaderProgram Header的结构。

ELF Header

#define EI_NIDENT 16

typedef struct elf32_hdr
{
  unsigned char e_ident[EI_NIDENT]; /* 魔数和相关信息 */
  Elf32_Half e_type;                /* 目标文件类型 */
  Elf32_Half e_machine;             /* 硬件体系 */
  Elf32_Word e_version;             /* 目标文件版本 */
  Elf32_Addr e_entry;               /* 程序进入点 */
  Elf32_Off e_phoff;                /* 程序头部偏移量 */
  Elf32_Off e_shoff;                /* 节头部偏移量 */
  Elf32_Word e_flags;               /* 处理器特定标志 */
  Elf32_Half e_ehsize;              /* ELF头部长度 */
  Elf32_Half e_phentsize;           /* 程序头部中一个条目的长度 */
  Elf32_Half e_phnum;               /* 程序头部条目个数  */
  Elf32_Half e_shentsize;           /* 节头部中一个条目的长度 */
  Elf32_Half e_shnum;               /* 节头部条目个数 */
  Elf32_Half e_shstrndx;            /* 节头部字符表索引 */
} Elf32_Ehdr;

其中各项数据的类型如下:

NameSizeAlignmentPurpose
Elf32_Addr44Unsigned program address
Elf32_Half22Unsigned medium integer
Elf32_Off44Unsigned file offset
Elf32_Sword44Signed large integer
Elf32_Word44Unsigned large integer
unsigned char11Unsigned small integer

Program Header

typedef struct elf32_phdr
{
  Elf32_Word p_type;   /* 段类型 */
  Elf32_Off p_offset;  /* 段位置相对于文件开始处的偏移量 */
  Elf32_Addr p_vaddr;  /* 段在内存中的地址 */
  Elf32_Addr p_paddr;  /* 段的物理地址 */
  Elf32_Word p_filesz; /* 段在文件中的长度 */
  Elf32_Word p_memsz;  /* 段在内存中的长度 */
  Elf32_Word p_flags;  /* 段的标记 */
  Elf32_Word p_align;  /* 段在内存中对齐标记 */
} Elf32_Phdr;

有了这两个结构我们就可以很方便的操作ELF格式的内核了。

实战

准备内核

我们的内核代码如下:

asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))");

unsigned short *video_buffer = (unsigned short *)0xb8000;

char *message = "Hello, world!";

void kernel_main(void)
{
  for (int i = 0; i < 80 * 25; i++)
  {
    video_buffer[i] = (video_buffer[i] & 0xff00) | ' ';
  }

  for (int i = 0; message[i] != '\0'; i++)
  {
    video_buffer[i] = (video_buffer[i] & 0xff00) | message[i];
  }

  while (1)
    ;
}

内核代码在下一篇文章中有详细解释。

编译链接

$ cc -g -c -m32 -fno-pic -ffreestanding kernel.c -o kernel.o
$ ld -m elf_i386 -e kernel_main -Ttext=0x100000 kernel.o -o kernel

接下来看看内核文件的信息。

查看file header

$ readelf -h kernel
ELF 头:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  类别:                              ELF32
  数据:                              2 补码,小端序 (little endian)
  版本:                              1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              EXEC (可执行文件)
  系统架构:                          Intel 80386
  版本:                              0x1
  入口点地址:               0x10000c
  程序头起点:          52 (bytes into file)
  Start of section headers:          9348 (bytes into file)
  标志:             0x0
  本头的大小:       52 (字节)
  程序头大小:       32 (字节)
  Number of program headers:         3
  节头大小:         40 (字节)
  节头数量:         14
  字符串表索引节头: 13

程序入口点地址为:0x10000c,程序头起点为52

查看program header

$ readelf -l kernel

Elf 文件类型为 EXEC (可执行文件)
Entry point 0x10000c
There are 3 program headers, starting at offset 52

程序头:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x00100000 0x00100000 0x000e8 0x000e8 R E 0x1000
  LOAD           0x002000 0x00102000 0x00102000 0x00008 0x00008 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  段节...
   00     .text .rodata .eh_frame
   01     .data
   02

其中可加载的段有两个,如第9行所示,该段相对于文件起始的偏移量为0x1000,虚拟地址为0x100000,物理地址为0x100000,在文件中的大小为0xe8,在内存中的大小为0xe8。这意味着如果我们知道内核文件在硬盘中的起始位置,那么用它加上offset 0x1000就可以得到该段在硬盘中的起始位置,然后从该位置开始,读取FileSiz 0xe8字节的数据到物理地址PhysAddr 0x100000,最后将FileSizMemSiz相差的地方填充成0即可。

下面给出bootloader的代码。

bootloader

bootloader由准备保护模式环境的汇编语言源文件bootasm.S和读取硬盘加载内核的bootmain.c组成。

bootasm.S

#include "asm.h"

.set PROT_MODE_CSEG, 0x08        # code segment selector
.set PROT_MODE_DSEG, 0x10        # data segment selector

.code16
.globl start
start:
  cli

  # Enable A20
  inb $0x92, %al
  orb $0x2, %al
  outb %al, $0x92

  # Load GDT
  lgdt gdtdesc

  # Switch from real to protected mode
  movl %cr0, %eax
  orl $0x1, %eax
  movl %eax, %cr0

  # Jump into 32-bit protected mode
  ljmp $PROT_MODE_CSEG, $start32

.code32
start32:
  movw $PROT_MODE_DSEG, %ax
  movw %ax, %ds
  movw %ax, %es
  movw %ax, %fs
  movw %ax, %gs
  movw %ax, %ss

  movl $start, %esp
  call bootmain

spin:
  jmp spin

.p2align 2
gdt:
  SEG_NULLASM
  SEG_ASM(STA_X | STA_R, 0x0, 0xffffffff)
  SEG_ASM(STA_W, 0x0, 0xffffffff)

gdtdesc:
  .word gdtdesc - gdt - 1
  .long gdt

bootmain.c

#include "elf.h"
#include "x86.h"

#define SECTSIZE 512

void readseg(uint8_t *pa, uint32_t count, uint32_t offset);

void bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uint8_t *pa;

  elf = (struct elfhdr *)0x10000;

  readseg((uint8_t *)elf, 4096, 0);

  if (elf->magic != ELF_MAGIC)
    return;

  ph = (struct proghdr *)((uint8_t *)elf + elf->phoff);
  eph = ph + elf->phnum;
  for (; ph < eph; ph++)
  {
    pa = (uint8_t *)ph->paddr;
    readseg(pa, ph->filesz, ph->off);
    for (int i = 0; i < ph->memsz - ph->filesz; i++)
    {
      *((char *)ph->paddr + ph->filesz + i) = 0;
    }
  }

  entry = (void (*)(void))(elf->entry);
  entry();
}

void waitdisk(void)
{
  while ((inb(0x1F7) & 0xC0) != 0x40)
    ;
}

void readsect(void *dst, uint32_t offset)
{
  waitdisk();
  outb(0x1F2, 1);
  outb(0x1F3, offset);
  outb(0x1F4, offset >> 8);
  outb(0x1F5, offset >> 16);
  outb(0x1F6, (offset >> 24) | 0xE0);
  outb(0x1F7, 0x20);

  waitdisk();
  insl(0x1F0, dst, SECTSIZE / 4);
}

void readseg(uint8_t *pa, uint32_t count, uint32_t offset)
{
  uint8_t *epa;
  epa = pa + count;

  pa -= offset % SECTSIZE;
  offset = (offset / SECTSIZE) + 1;

  for (; pa < epa; pa += SECTSIZE, offset++)
    readsect(pa, offset);
}

代码与之前的例子基本相同,只是多了对ELF格式文件的处理。

解释

1行包含了elf.h,其中包含ELF HeaderProgram Header的定义。

2行包含了x86.h,其中包含了一些基础数据类型的定义。

4行定义了SECTSIZE,表示一个扇区包含的字节数。

6行声明了方法readseg,用于加载一个到内存中,参数pa给出要加载的物理内存地址,参数count给出加载的字节大小,参数offset给出段相对于内核文件起始位置的偏移量,即从offset处加载count字节到内存pa处。实际读取的字节数可能多于需要读取的字节数,因为硬盘读取的最小单位是扇区,即一次至少读取512字节。

10行声明了struct elfhdr *类型的变量elf,用于指向内核文件的ELF Header

11行声明了struct proghdr *类型的变量pheph,分别用于指向第一个程序头的起始和最后一个程序头的结尾。因为我们要遍历多个Program Header,需要用eph控制结束条件。

12行声明了void (*)(void)类型的函数指针entry,用于指向内核的起始地址。

13行声明了uint8_t *类型的变量pa,用于指向每一个段将要加载到的物理地址。

15行,将elf指向内存0x10000处,我们的内核ELF Header将加载到这里。

17行,调用readseg从内核映像起始处读取4096个字节到内存0x10000处。4096个字节对于我们的内核文件头来说时足够的,所以我们可以确保已经将ELF Header完整的读入了内存。

19行,判断文件头的魔数是否正确,错误的话直接返回到bootasm.S中,陷入死循环。

22行,将ph指向第一个Program Header

23行,将eph指向最后一个Program Header的结尾处。

24行开始遍历所有的Program Header,并将对应的段加载到内存中。

28~31行用于将段在内存中多于在文件中大小的位置填充为0。因为段在内存中实际占用的空间可能大于在文件中占用的空间。

34~35行,将entry指向内核入口点并执行。

38~42行,函数waitdisk用于等待磁盘准备好和处理器交互。

44~56行,函数readsect用于从磁盘读取一个扇区到内存dst处。

58~68行,定义函数readseg。参数依次为物理地址,要读取的字节数,段距离文件起始处的偏移量。

60、61行声明读取的结束位置epa,读取count字节到pa,那么pa + count就应当是结束条件。

63行用于将pa按一个扇区,即512字节向下对齐。可能不是很好理解,老李待会儿做个实验给诸位品品。

64行用于将偏移量从字节转换成扇区。比如一个段相对于内核文件起始处的偏移量是1024,那么在磁盘上就相当于偏移两个扇区(1024 / 512),因为内核文件从偏移量为1的扇区开始,即第二个扇区(我们制作镜像的时候会将内核文件放在从第二个扇区开始的地方),所以该段相对于整个磁盘的起始地址还要+1

66~68行,循环调用readsect,将段所在的扇区一个接一个读入内存。

编译链接

$ cc -m32 -g -c -o bootasm.o bootasm.S
$ cc -m32 -g -fno-builtin -fno-pic -fno-stack-protector -nostdinc -Os -c -o bootmain.o bootmain.c
$ ld -N -e start -Ttext=0x7c00 -m elf_i386 -o bootblock.o bootasm.o bootmain.o
$ objcopy -S -O binary -j .text bootblock.o bootblock
$ ./sign bootblock

制作镜像

$ dd if=/dev/zero of=kernel.img count=10000
$ dd if=bootblock of=kernel.img conv=notrunc
$ dd if=kernel of=kernel.img seek=1 conv=notrunc

运行

$ qemu-system-i386 -drive file=kernel.img,format=raw -monitor stdio
boot
boot

完整的代码戳这里open in new window

最后在解释一个刚才遗留问题,pa -= offset % SECTSIZE的作用是什么?

先来看看将text段起始地址设置成0x100000时的每个段的对应的情况。

$ ld -m elf_i386 -e kernel_main -Ttext=0x100000 kernel.o -o kernel
$ readelf -l kernel

Elf 文件类型为 EXEC (可执行文件)
Entry point 0x10000c
There are 3 program headers, starting at offset 52

程序头:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x00100000 0x00100000 0x000e8 0x000e8 R E 0x1000
  LOAD           0x002000 0x00102000 0x00102000 0x00008 0x00008 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  段节...
   00     .text .rodata .eh_frame
   01     .data
   02

注意第10行,这是我们的text段。pa=0x100000offset=0x1000pa -= offset % SECTSIZE之后pa还是等于0x100000

如果将text段起始地址设置成0x100001,情况如下:

$ ld -m elf_i386 -e kernel_main -Ttext=0x100001 kernel.o -o kernel
$ readelf -l kernel

Elf 文件类型为 EXEC (可执行文件)
Entry point 0x10000d
There are 3 program headers, starting at offset 52

程序头:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001001 0x00100001 0x00100001 0x000eb 0x000eb R E 0x1000
  LOAD           0x002000 0x00102000 0x00102000 0x00008 0x00008 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  段节...
   00     .text .rodata .eh_frame
   01     .data
   02

此时textpa=0x100001offset=0x1001pa -= offset % SECTSIZE之后pa向下取整到0x100000,和一个扇区大小对齐。

参考MIT 6.828: Operating System Engineeringopen in new window