Hobby OS
부트섹터부터
VGA 드라이버까지.
x86-64 어셈블리로 만드는 운영체제입니다. 전원이 들어온 뒤 BIOS가 읽어 오는 512바이트에서 시작해, 보호 모드와 페이징, 64비트 롱 모드를 지나 C로 짠 커널에서 VGA 드라이버가 돌아가는 데까지 왔습니다.
요약
- 512바이트
- BIOS가 처음 읽어 오는 부트섹터 크기
- 2MB
- 페이지 하나의 크기. 큰 페이지로 항등 매핑
- 0xB8000
- VGA 텍스트 버퍼 주소. 여기에 쓰면 화면에 글자가 찍힘
- 0x500
- 롱 모드 진입 후 EFER 레지스터 값
시작한 계기
어셈블리를 처음부터 배우면서 레지스터, 산술, 분기, 스택, 호출 규약, 메모리 주소 계산까지 익혔고, 배운 걸 실제로 쓸 프로젝트로 운영체제를 골랐습니다. 어셈블리 경험 없이 시작했고, 환경은 GitHub Codespaces(Ubuntu x86-64)에 NASM과 QEMU를 올려 썼습니다.
리눅스도 부팅 부분만 어셈블리이고 나머지는 C입니다. 그래서 CPU를 64비트 모드까지 끌어올리는 부분은 어셈블리로, 그 뒤는 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 함수를 부르는 경계를 직접 만들면서, 앞서 배운 호출 규약을 실제로 쓰게 됐습니다.
메모리 배치
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으로 옮깁니다.
| 파일 | 역할 |
|---|---|
| boot.asm | BIOS에서 제어를 받아 커널을 디스크에서 읽어 옴 |
| kernel.asm | A20, GDT, 보호 모드, 페이징, 롱 모드, C 호출 |
| kmain.c | VGA 텍스트 드라이버 |
| 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로 남는다는 것도 이 과정에서 알게 됐습니다.
진행
- 부트섹터완료
- 커널 로드완료
- 32비트 보호 모드완료
- 페이징완료
- 64비트 롱 모드완료
- freestanding C 커널완료
- VGA 텍스트 드라이버완료
보통 이 다음 단계는 인터럽트(IDT)와 키보드 입력, 메모리 관리, 멀티태스킹입니다.