Hobby OS

부트섹터부터
VGA 드라이버까지.

x86-64 어셈블리로 만드는 운영체제입니다. 전원이 들어온 뒤 BIOS가 읽어 오는 512바이트에서 시작해, 보호 모드와 페이징, 64비트 롱 모드를 지나 C로 짠 커널에서 VGA 드라이버가 돌아가는 데까지 왔습니다.

NASM, C, QEMU

요약

512바이트
BIOS가 처음 읽어 오는 부트섹터 크기
2MB
페이지 하나의 크기. 큰 페이지로 항등 매핑
0xB8000
VGA 텍스트 버퍼 주소. 여기에 쓰면 화면에 글자가 찍힘
0x500
롱 모드 진입 후 EFER 레지스터 값

시작한 계기

어셈블리를 처음부터 배우면서 레지스터, 산술, 분기, 스택, 호출 규약, 메모리 주소 계산까지 익혔고, 배운 걸 실제로 쓸 프로젝트로 운영체제를 골랐습니다. 어셈블리 경험 없이 시작했고, 환경은 GitHub Codespaces(Ubuntu x86-64)에 NASM과 QEMU를 올려 썼습니다.

리눅스도 부팅 부분만 어셈블리이고 나머지는 C입니다. 그래서 CPU를 64비트 모드까지 끌어올리는 부분은 어셈블리로, 그 뒤는 C로 넘어가는 구조로 잡았습니다.

부팅 순서

16비트 리얼 모드 BIOS전원 켜짐0x7C00에 적재 부트섹터512바이트int 0x13 디스크 읽기 커널 로드2단계 코드를0x7E00에 적재 A20 · GDTA20 활성화flat GDT 구성 32비트 보호 모드 64비트 롱 모드 보호 모드CR0.PE = 1far jump 페이징PML4·PDPT·PD2MB 항등 매핑 롱 모드EFER.LME = 1CR0.PG = 1 kmain()freestanding CVGA 드라이버
전원이 들어온 뒤 C 커널이 실행되기까지의 순서

리얼 모드

BIOS는 디스크의 첫 섹터 512바이트를 메모리 0x7C00에 올리고 실행합니다. 이 공간에 다 들어가지 않는 나머지 코드는 부트섹터가 BIOS 인터럽트 int 0x13으로 디스크에서 읽어 와 0x7E00에 올립니다. 그다음 A20 라인을 켜고, null, 32비트 코드, 데이터, 64비트 코드 디스크립터로 이루어진 flat 모델 GDT를 만듭니다.

보호 모드와 페이징

CR0의 PE 비트를 켜고 far jump를 하면 32비트 보호 모드로 넘어갑니다. 64비트로 가려면 페이징이 먼저 필요해서 0x1000, 0x2000, 0x3000에 PML4, PDPT, PD 테이블을 두고, 2MB 큰 페이지로 가상 주소와 물리 주소가 같게(항등 매핑) 설정했습니다.

롱 모드와 C

rdmsr/wrmsr로 EFER의 LME 비트를 켜고, CR0의 PG 비트로 페이징을 켜면 CPU가 롱 모드로 들어갑니다. 여기서 C로 작성한 kmain()을 호출합니다. 어셈블리에서 C 함수를 부르는 경계를 직접 만들면서, 앞서 배운 호출 규약을 실제로 쓰게 됐습니다.

메모리 배치

0x0000_1000 PML4최상위 페이지 테이블, CR3가 가리킴 0x0000_2000 PDPT페이지 디렉터리 포인터 테이블 0x0000_3000 PD2MB 큰 페이지로 항등 매핑 0x0000_7C00 부트섹터BIOS가 올려 주는 512바이트 0x0000_7E00 커널linker.ld에서 .text 시작 위치 0x000B_8000 VGA 텍스트 버퍼80×25칸, 칸마다 글자 + 색 2바이트
실제 크기 비율이 아니라 주소 순서대로 나열한 그림

C 커널

C 커널은 운영체제 위에서 도는 프로그램이 아니라서 표준 라이브러리도, 레드 존도, SSE도 쓸 수 없습니다. 그래서 아래 옵션으로 컴파일하고, 링커 스크립트 linker.ld로 .text가 0x7E00에서 시작하도록 배치했습니다.

gcc -ffreestanding -nostdlib -mno-red-zone -mno-sse ...

VGA 드라이버는 0xB8000부터 한 칸에 글자 1바이트와 색 1바이트를 쓰는 방식입니다. clear_screen(), 줄바꿈을 처리하는 putchar(), 색을 지정해 문자열을 찍는 print()를 만들었습니다. 줄바꿈은 커서를 (cursor / 80 + 1) * 80으로 옮깁니다.

K e r n e l   r u n n i n g   i n   C ! V G A   d r i v e r   w o r k i n g . 색 속성 0x0A (밝은 초록) · 0x0F (흰색) · 한 줄 = 80칸 = 160바이트
VGA 메모리 내용을 화면처럼 다시 그린 것
파일 구성
파일역할
boot.asmBIOS에서 제어를 받아 커널을 디스크에서 읽어 옴
kernel.asmA20, GDT, 보호 모드, 페이징, 롱 모드, C 호출
kmain.cVGA 텍스트 드라이버
linker.ld메모리 배치

검증 방법

QEMU를 curses 화면으로 띄우면 처음부터 끝까지 'VGA Blank mode'만 보였습니다. 그래서 화면 대신 QEMU 모니터로 CPU와 메모리 상태를 직접 읽어서 확인했습니다. info registers로 레지스터를, xp로 VGA 메모리를 봤습니다.

(qemu) info registers
CS =0018 ... CS64      64비트 코드 세그먼트에서 실행 중
EFER=0000000000000500  LME(8번 비트) + LMA(10번 비트)
CR0=80000011           최상위 비트 PG = 페이징 켜짐
CR3=0000000000001000   PML4 주소
CR4=00000020           5번 비트 PAE

VGA 메모리에는 첫 줄이 색 속성 \n(0x0A, 밝은 초록)으로, 둘째 줄이 \x0f(흰색)로 들어가 있습니다. 둘째 줄이 0xB80A0, 즉 160바이트(80칸 × 2바이트) 뒤에서 시작하는 것으로 줄바꿈 계산이 맞다는 것도 확인했습니다.

(qemu) xp/60c 0xb8000
00000000000b8000: 'K' '\n' 'e' '\n' 'r' '\n' 'n' '\n' 'e' '\n' 'l' '\n' ' ' '\n' 'r' '\n'
00000000000b8010: 'u' '\n' 'n' '\n' 'n' '\n' 'i' '\n' 'n' '\n' 'g' '\n' ' ' '\n' 'i' '\n'
00000000000b8020: 'n' '\n' ' ' '\n' 'C' '\n' '!' '\n' ' ' '\x07' ' ' '\x07' ' ' '\x07' ' ' '\x07'
...
00000000000b80a0: 'V' '\x0f' 'G' '\x0f' 'A' '\x0f' ' ' '\x0f' 'd' '\x0f' 'r' '\x0f' 'i' '\x0f' 'v' '\x0f'
00000000000b80b0: 'e' '\x0f' 'r' '\x0f' ' ' '\x0f' 'w' '\x0f' 'o' '\x0f' 'r' '\x0f' 'k' '\x0f' 'i' '\x0f'
00000000000b80c0: 'n' '\x0f' 'g' '\x0f' '.' '\x0f' ' ' '\x07' ' ' '\x07' ' ' '\x07' ' ' '\x07' ' ' '\x07'

디스크 읽기가 실패하면 EIP가 0x7C3x 근처에 멈추고 AH가 0x0E로 남는다는 것도 이 과정에서 알게 됐습니다.

진행

  1. 부트섹터완료
  2. 커널 로드완료
  3. 32비트 보호 모드완료
  4. 페이징완료
  5. 64비트 롱 모드완료
  6. freestanding C 커널완료
  7. VGA 텍스트 드라이버완료

보통 이 다음 단계는 인터럽트(IDT)와 키보드 입력, 메모리 관리, 멀티태스킹입니다.