ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [xv6] Boot-loader
    프로젝트/운영체제 만들기 2023. 7. 29. 05:39

    글의 참고

    - https://github.com/mit-pdos/xv6-public/tree/master

    xv6 - DRAFT as of September 4, 2018


    글의 전제

    - 밑줄로 작성된 글은 강조 표시를 의미한다.

    - 그림 출처는 항시 그림 아래에 표시했다.


    글의 내용

    - `xv6`의 부트 로더는 `bootasm.s`와 `bootmain.c` 파일로 구성되어 있다. 이 내용은 `xv6` 문서에서 확인이 가능하다.

    ....
    The xv6 boot loader comprises two source files, one written in a combination of 16-bit and 32-bit x86 assembly (`bootasm.S`; (9100)) and one written in C (`bootmain.c`; (9200))
    ....

    - 참고 : xv6 - DRAFT as of September 4, 2018 [ The boot loader ]

    : `xv6`도 32비트 x86 기반의 운영 체제다. 그래서 BIOS 시스템 펌웨어를 이용한다. BIOS 시스템 펌웨어는 기본적으로 시스템에 존재하는 모든 하드 디스크의 맨 처음 512B를 검사해서 해당 디스크가 부팅 디스크인지를 확인한다(MBR). 예를 들어, USB, HDD, SDD가 있을 때, 어떤 디스크가 `부팅 디스크` 인지를 알 수 있을까? BIOS가 부팅 디스크를 판단하는 기준은 510B(0x55) 와 511B(0xAA) 위치에 특정 매직 넘버를 검사하게 된다. 이 매직 넘버를 `MBR 부트 시그니처`라고 한다. BIOS는 MBR 파티션 기반으로 만들어진 시스템 펌웨어이기 때문에, 시스템 펌웨어로 UEFI가 아닌 BIOS를 사용할 경우, MBR 부트 시그니처를 0x510, 0x511에 반드시 작성해야 한다.

     

    : `xv6`의 `Makefile`에서 부트 로더를 만드는 과정을 확인할 수 있다. 아래에서 `bootblock` 타겟을 통해서 `xv6` 부트 로더를 생성하게 된다. 링킹 시 별도의 링커 스크립트를 사용하지는 않는다. `-Ttext 0x7C00` 을 통해서 링키 시, `bootblock.o` 파일이 0x7C00을 기준으로 주소를 재배치 하도록 한다. 왜냐면, BIOS가 자신의 작업을 모두 마치면, `xv6` 부트 로더를 0x7C00으로 로드하기 때문이다.

    ...
    xv6.img: bootblock kernel
    	dd if=/dev/zero of=xv6.img count=10000
    	dd if=bootblock of=xv6.img conv=notrunc
    	dd if=kernel of=xv6.img seek=1 conv=notrunc
    ...
    
    bootblock: bootasm.S bootmain.c
    	$(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c
    	$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S
    	$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
    	$(OBJDUMP) -S bootblock.o > bootblock.asm
    	$(OBJCOPY) -S -O binary -j .text bootblock.o bootblock
    	./sign.pl bootblock
    ...

    : 그런데, 마지막에 `./sign.pl bootblock`은 뭘까? 위에서 말했다시피, BIOS는 여러 개의 하드 디스크중에서 어떤 디스크를 부팅 디스크인지를 판단할 수 있어야 한다. 그래서 `부트 시그니처`를 510B, 511B에 삽입한다고 했다. `./sign.pl bootblock` 코드가 510B, 511B 쪽에 `부트 시그니처`를 삽입해준다. 

     

    : `sign.pl` 파일은 펄 스크립트 언어로 작성된 파일이다. `$n`은 전달된 `bootblock` 바이너리를 의미한다. 이 파일 사이즈가 510B 이상이면, 부트 시그니처를 넣을 곳이 없으므로, 에러를 발생시킨다. 만약, 파일 사이즈가 510B 이하라면 `510B = 0x55 , 511B = 0xAA` 삽입한다. 이렇게, MBR 부트 로더가 만들어진다.

    #!/usr/bin/perl
    
    open(SIG, $ARGV[0]) || die "open $ARGV[0]: $!";
    
    $n = sysread(SIG, $buf, 1000);
    
    if($n > 510){
      print STDERR "boot block too large: $n bytes (max 510)\n";
      exit 1;
    }
    
    print STDERR "boot block is $n bytes (max 510)\n";
    
    $buf .= "\0" x (510-$n);
    $buf .= "\x55\xAA";
    
    open(SIG, ">$ARGV[0]") || die "open >$ARGV[0]: $!";
    print SIG $buf;
    close SIG;

     

    : 사실 아래의 내용은 `x86` 아키텍처를 모른다면, 이해할 수 없는 문장들이다. 그러므로, 먼저 `x86` 아키텍처를 공부하고 올 것을 권한다. `xv6`의 부트 로더는 16비트에서 빠르게 보호 모드로 전환하기 위한 내용밖에 없다. 다른 내용은 일절 찾아볼 수 가 없다. 예를 들어, 16비트에서 흔히 사용하는 비디오 메모리에도 접근하지 않고, 디스크에도 접근하지 않는다. 보호 모드로 진입하기 위해서는 `A20`, `GDT`가 준비되어 있어야 한다. 딱, 그 2개만 설정하고, `bootmain` 함수를 호출한다. `bootmain` 에서 32비트 커널을 실제 커널을 메모리에 로드하는 코드가 나온다. 참고로, `bootmain` 함수부터는 C 언어다. 그래서, 스택을 반드시 설정하고 들어가야 한다. 16비트 부트 로더의 시작점인 0x7C00(`start`)을 보호 모드의 스택 TOP으로 설정한다.

    #include "asm.h"
    #include "memlayout.h"
    #include "mmu.h"
    
    # Start the first 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.
    
    .code16                       # Assemble for 16-bit mode
    .globl start
    start:
      cli                         # BIOS enabled interrupts; disable
    
      # Zero data segment registers DS, ES, and SS.
      xorw    %ax,%ax             # Set %ax to zero
      movw    %ax,%ds             # -> Data Segment
      movw    %ax,%es             # -> Extra Segment
      movw    %ax,%ss             # -> Stack Segment
    
      # Physical address line A20 is tied to zero so that the first PCs 
      # with 2 MB would run software that assumed 1 MB.  Undo that.
    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.  Use a bootstrap GDT that makes
      # virtual addresses map directly to physical addresses so that the
      # effective memory map doesn't change during the transition.
      lgdt    gdtdesc
      movl    %cr0, %eax
      orl     $CR0_PE, %eax
      movl    %eax, %cr0
    
    //PAGEBREAK!
      # Complete the transition to 32-bit protected mode by using a long jmp
      # to reload %cs and %eip.  The segment descriptors are set up with no
      # translation, so that the mapping is still the identity mapping.
      ljmp    $(SEG_KCODE<<3), $start32
    
    .code32  # Tell assembler to generate 32-bit code now.
    start32:
      # Set up the protected-mode data segment registers
      movw    $(SEG_KDATA<<3), %ax    # Our data segment selector
      movw    %ax, %ds                # -> DS: Data Segment
      movw    %ax, %es                # -> ES: Extra Segment
      movw    %ax, %ss                # -> SS: Stack Segment
      movw    $0, %ax                 # Zero segments not ready for use
      movw    %ax, %fs                # -> FS
      movw    %ax, %gs                # -> GS
    
      # Set up the stack pointer and call into C.
      movl    $start, %esp
      call    bootmain
    
      # If bootmain returns (it shouldn't), trigger a Bochs
      # breakpoint if running under Bochs, then loop.
      movw    $0x8a00, %ax            # 0x8a00 -> port 0x8a00
      movw    %ax, %dx
      outw    %ax, %dx
      movw    $0x8ae0, %ax            # 0x8ae0 -> port 0x8a00
      outw    %ax, %dx
    spin:
      jmp     spin
    
    # Bootstrap GDT
    .p2align 2                                # force 4 byte alignment
    gdt:
      SEG_NULLASM                             # null seg
      SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)   # code seg
      SEG_ASM(STA_W, 0x0, 0xffffffff)         # data seg
    
    gdtdesc:
      .word   (gdtdesc - gdt - 1)             # sizeof(gdt) - 1
      .long   gdt                             # address gdt

     

    : 부트 로더에 가장 중요한 기능은 역시나 커널을 메모리의 특정 위치에 로드하는 것이다. `bootmain` 함수가 그 역할을 맞고 있다. 디스크에서 `xv6.img`의 부트 섹터를 제외하고 4KB를 읽어어서 메모리 0x10000에 로드한다. 왜 0x10000 일까? 그 이유는 `xv6` 문서에 나와있지 않다. 잠깐 임시 방편으로 로드한 영역이라는 것만 알면된다.

     

    : 32비트 커널을 로드하는 부분은 ELF 헤더를 읽어야 한다. 그런데, 4KB나 읽을 필요가 있을까? 512B만 읽으면 안될까? 

     

    : `xv6.img` 이미지 4KB에는 어떤 내용이 있는걸까? 부트 섹터를 제외하고 4KB를 읽었기 때문에 `섹터 1번 ~ 섹터 9번`까지 읽은셈이다. 여기에는 `xv6` 32비트 커널의 시작 주소와 사이즈가 들어가 있다. 그런데, ELF 포맷을 만들어져 있기 때문에 먼저 ELF 포맷이 유효한지 부터 검사해야 한다. 그리고 나서, ELF 포맷을 해석해서 `xv6` 커널의 시작 주소와 사이즈를 파싱한다. ELF 포맷에 대한 내용은 이 글을 참고하자.

    ....
    Xv6 applications are described in the widely-used ELF format, defined in `elf.h`. An ELF binary consists of an ELF header, `struct elfhdr` (0905), followed by a sequence of program section headers, `struct proghdr` (0924). Each proghdr describes a section of the application that must be loaded into memory; xv6 programs have only one program section header, but other systems might have separate sections for instructions and data.
    ....

    - 참고 : xv6 - DRAFT as of September 4, 2018 [ Code: exec ]

    : 커널을 시작 주소와 사이즈를 알기 위해서는 `프로그램 섹션 헤더`를 읽어야 한다. ELF 포맷을 가지는 프로그램들은 여러 개의 프로그램 섹션 헤더를 가질 수 있지만, `xv6`는 딱 하나의 `프로그램 섹션 헤더`를 가지고 있다(그래서, 아래의 루프문이 의미가 없을 수 있지만, 여러 개가 생길 수 있다는 전제로 루프문을 사용한 것으로 보인다). 바로 이 `프로그램 섹션 헤더`가 32비트 `xv6` 커널에 대한 정보를 가지고 있다. `ph->paddr`은 프로그램이 메모리에 로드될 시작 주소를 의미한다. 즉, 32비트 `xv6` 커널이 디스크에서 읽여진 뒤 RAM에 어디에 위치될 지를 의미한다. `ph->paddr`의 값은 어디가 될까? 이걸 알기 위해서는 `Makefile`과 `kernel.ld`를 살펴봐야 한다.

    // Makefile
    ...
    kernel: $(OBJS) entry.o entryother initcode kernel.ld
    	$(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother
    	$(OBJDUMP) -S kernel > kernel.asm
    	$(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym
    ...

    : `kernel` 파일에는 유저 프로그램을 제외한 `xv6`의 거의 대부분의 파일들이 포함된다. 그런데, 여기서 핵심은 `-T kernel.ld` 다. `kernel` 파일을 만들 때, 사용된 링커 스크립트를 알아야 `kernel` 실행 파일이 RAM에 어디에 로드되는지를 알 수 있다.

     

    : `kernel.ld` 파일의 앞 부분 일부만을 가져왔다. `. = 0x80100000`를 통해서 VMA와 LMA를 `0x80100000`으로 설정한다. 그런데, 뒷쪽에서 바로 텍스트 영역의 LMA를 `0x100000`로 변경한다(`.text : AT(0x100000))`. 이 시점부터 가상 주소와 물리 주소가 서로 달라진다. 위 내용을 잘 모르겠다면, 이 글을 참고하자. 이 링커 스크립트를 통해서 링커는 32비트 `xv6` 커널 이미지를 만들 때, `kernel` 실행 파일의 프로그램 섹션 헤더의 `paddr = 0x100000, vaddr = 0x80100000` 을 설정한다.

    // kenrel.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(_start)
    
    SECTIONS
    {
    	/* Link the kernel at this address: "." means the current address */
            /* Must be equal to KERNLINK */
    	. = 0x80100000;
    
    	.text : AT(0x100000) {
    		*(.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__ = .);
    	}
    
    	.stabstr : {
    		PROVIDE(__STABSTR_BEGIN__ = .);
    		*(.stabstr);
    		PROVIDE(__STABSTR_END__ = .);
    	}
    
    	/* Adjust the address for the data segment to the next page */
    	. = ALIGN(0x1000);
    
    	/* Conventionally, Unix linkers provide pseudo-symbols
    	 * etext, edata, and end, at the end of the text, data, and bss.
    	 * For the kernel mapping, we need the address at the beginning
    	 * of the data section, but that's not one of the conventional
    	 * symbols, because the convention started before there was a
    	 * read-only rodata section between text and data. */
    	PROVIDE(data = .);
    
    	/* The data segment */
    	.data : {
    		*(.data)
    	}
    
    	PROVIDE(edata = .);
    
    	.bss : {
    		*(.bss)
    	}
    
    	PROVIDE(end = .);
    
    	/DISCARD/ : {
    		*(.eh_frame .note.GNU-stack)
    	}
    }

     

    : `kernel` 파일 사이즈는 `ph->filesz`가 나타낸다. `ph->filesz`는 `end - 0x80100000`가 될 것이다. `ph->off`는 ELF 포맷을 따르는 실행 파일에서 각 프로그램들의 시작 주소를 의미한다. 예를 들어, ELF 포맷을 따르는 A라는 실행 파일이 있다. 이 파일에는 3개의 프로그램 헤더가 존재한다. 이 말은, A라는 실행 파일에는 3개의 프로그램이 존재한다는 소리다. 즉, A라는 실행 파일에는 3개의 프로그램이 존재하며, 각 프로그램의 시작 주소는 모두 다를 것이다. 이 프로그램들의 시작 주소가 `ph->off`를 의미한다. 그런데, A 실행 파일은 디스크 파일에 들어있다. 결국, 디스크에서 읽어서 메모리에 로드해야 한다. 그럴려면, `ph->off`(바이트)를 디스크 섹터 번호로 컨버팅해야 한다. 예를 들어, A 실행 파일의 크기가 782932 일 때, 이걸 디스크 섹터 번호로 컨버팅하면 다음과 같다.

    0" A-0 프로그램 오프셋 : 82732 - 161 섹터
    1" A-1 프로그램 오프셋 : 273823 - 534 섹터
    2" A-2 프로그램 오프셋 : 528342 - 1031 섹터

    : 위의 내용을 토대로 `readseg(pa, ph->filesz, ph->off)`를 이해하면 된다. `if(ph->memsz > ph->filesz)` 조건문은 `kernel` 실행 파일에 BSS 영역을 할당 했는지를 판단한다. 링커를 통해 실행 파일을 만들 때, BSS 영역은 실행 파일에 포함되지 않는다. 단지, BSS에 대한 메타 데이터만 포함된다. 실제 BSS 영역에 대한 할당은 메모리에 로딩될 때, 할당된다. 그래서 BSS 영역을 사용하기 전에 0으로 초기화하기 위와 같은 조건문이 있는 것이다. `stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz)` 은 BSS 영역을 0으로 초기화하는 코드다.

     

    // Boot loader.
    //
    // Part of the boot block, along with bootasm.S, which calls bootmain().
    // bootasm.S has put the processor into protected 32-bit mode.
    // bootmain() loads an ELF kernel image from the disk starting at
    // sector 1 and then jumps to the kernel entry routine.
    
    #include "types.h"
    #include "elf.h"
    #include "x86.h"
    #include "memlayout.h"
    
    #define SECTSIZE  512
    
    void readseg(uchar*, uint, uint);
    
    void
    bootmain(void)
    {
      struct elfhdr *elf;
      struct proghdr *ph, *eph;
      void (*entry)(void);
      uchar* pa;
    
      elf = (struct elfhdr*)0x10000;  // scratch space
    
      // Read 1st page off disk
      readseg((uchar*)elf, 4096, 0);
    
      // Is this an ELF executable?
      if(elf->magic != ELF_MAGIC)
        return;  // let bootasm.S handle error
    
      // Load each program segment (ignores ph flags).
      ph = (struct proghdr*)((uchar*)elf + elf->phoff);
      eph = ph + elf->phnum;
      for(; ph < eph; ph++){
        pa = (uchar*)ph->paddr;
        readseg(pa, ph->filesz, ph->off);
        if(ph->memsz > ph->filesz)
          stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
      }
    
      // Call the entry point from the ELF header.
      // Does not return!
      entry = (void(*)(void))(elf->entry);
      entry();
    }
    
    void
    waitdisk(void)
    {
      // Wait for disk ready.
      while((inb(0x1F7) & 0xC0) != 0x40)
        ;
    }
    
    // Read a single sector at offset into dst.
    void
    readsect(void *dst, uint offset)
    {
      // Issue command.
      waitdisk();
      outb(0x1F2, 1);   // count = 1
      outb(0x1F3, offset);
      outb(0x1F4, offset >> 8);
      outb(0x1F5, offset >> 16);
      outb(0x1F6, (offset >> 24) | 0xE0);
      outb(0x1F7, 0x20);  // cmd 0x20 - read sectors
    
      // Read data.
      waitdisk();
      insl(0x1F0, dst, SECTSIZE/4);
    }
    
    // Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
    // Might copy more than asked.
    void
    readseg(uchar* pa, uint count, uint offset)
    {
      uchar* epa;
    
      epa = pa + count;
    
      // Round down to sector boundary.
      pa -= offset % SECTSIZE;
    
      // Translate from bytes to sectors; 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.
      for(; pa < epa; pa += SECTSIZE, offset++)
        readsect(pa, offset);
    }

    : 그런데, `readseg` 함수에서 `offset = (offset / SECTSIZE) + 1`에서 `+1`을 하는 이유는 뭘까? 2가지 이유가 있다.

    0" 0번 섹터는 `MBR 부트 섹터`다.
    1" `xv6`의 커널 이미지는 `xv6.img`에 들어있다. 그런데, `0B ~ 512B`는 `MBR 부트 섹터(부트 로더)`가 들어가 있다. `512B` 이상 부터 실제 `xv6` 커널이 들어간다. 그런데, `xv6.img`에 들어간 커널 이미지가 RAW 포맷이 아닌, ELF 포맷의 커널이다. 그런데, ELF 포맷에서 프로그램 헤더에 보면, 프로그램 시작 오프셋(p_offset)은 0B를 기준으로 설정이 된다. 그런데, `kernel` 이미지는 `bootblock`을 포함하고 있지 않아서 디스크 섹터를 읽을 때, 앞에 부트 섹터를 생략하고 읽어야 한다. 즉, `xv6.img`는 `bootblock`과 `kernel`로 구성되는데, `kernel`은 bootblock`이 포함되어 있지 않기 때문에 디스크 드라이브에서 `kernel` 이미지의 맨 앞 부분을 읽기 위해서는 1번 섹터를 읽어야 한다는 것이다. 0번 섹터는 `bootblock`이 된다. 

    : 참고로, `readseg` 함수는 `fs.img(디스크 드라이브 1)`가 아닌, `xv6.img(디스크 드라이브 0)`를 읽어들이는 함수다. 해당 내용은 `0x1F6`의 4번째 비트가 0인 것으로 판단이 가능하다.

     

    : 32비트 `xv6` 커널을 0x100000에 모두 로드하면, `elf->entry` 주소를 마치 함수처럼 호출한다. 저 주소값은 몇일까? `xv6` 문서에서는 `0x10000c`라고 언급하고 있다. 이 주소를 직접 명시하고 있지는 않다. 그러나, ELF 포맷으로 만들어진 실행 파일은 명시적으로 엔트리 포인트를 언급하지 않으면, `_start` 심볼의 주소를 엔트리 포인트로 지정한다. `_start` 심볼은 `entry.S` 파일에 작성되어 있다. `entry.S` 파일은 32비트 `xv6`의 최초 시작점이다.

    The boot loader’s final step is to call the kernel’s entry point, which is the instruction at which the kernel expects to start executing. For xv6 the entry address is `0x10000c`

    # objdump -f kernel
    kernel: file format elf32-i386
    architecture: i386, flags 0x00000112:
    EXEC_P, HAS_SYMS, D_PAGED
    start address `0x0010000c`

    By convention, the `_start` symbol specifies the ELF entry point, which is defined in the file `entry.S` (1040). Since xv6 hasn’t set up virtual memory yet, xv6’s entry point is the physical address of entry (1044).

     

    : `entry.S` 파일에 내용은 더 많지만, 여기서는 `_start` 심볼만 확인하고 넘어가도록 한다.

    // entry.S
    ...
    # By convention, the _start symbol specifies the ELF entry point.
    # Since we haven't set up virtual memory yet, our entry point is
    # the physical address of 'entry'.
    .globl _start
    _start = V2P_WO(entry)
    ...

     

    : 이렇게 `xv6` 부트 로더는 마무리가 되고 커널이 시작되게 된다.

    '프로젝트 > 운영체제 만들기' 카테고리의 다른 글

    [xv6] entry  (0) 2023.08.01
    [xv6] mkfs  (0) 2023.07.30
    [xv6] - fork  (0) 2023.07.27
    [xv6] - sbrk  (0) 2023.07.26
    [xv6] Log  (0) 2023.07.25
Designed by Tistory.