Linux Inside #1 BIOS
커널이 메모리로 이동하여 첫 번째 사용자 프로세스가 시작될 때까지 커널이 수행하는 작업을 알아보자.
로우레벨이 내부적으로 어떻게 동작하는지 프로그램이 컴퓨터에서 어떻게 실행되는지 어떻게 그것들이 메모리에 적재되는지 커널이 프로세스와 메모리 관리를 어떻게 하는지, 네트워크 스택이 로우레벨에서 어떻게 동작하는지 등...
부팅 프로세스를 간단히 살펴보면 다음과 같다.
- 장비의 BIOS 또는 부팅 펌웨어가 로드되어 부트 로더를 실행.
- 부트로더는 디스크에서 커널 이미지를 찾아 메모리에 로드하고 시작.
- 커널은 장치와 드라이버를 초기화.
- 커널은 루트 파일 시스템을 마운트.
- 커널은 프로세스 ID 1로 init 이라는 프로그램을 시작. 이 지점은 사용자 공간 시작.
- init은 나머지 시스템 프로세스를 동작시킴.
- 어떤 시점에서 init는 로그인을 허용하는 프로세스를 시작하는데, 이는 보통 부팅 시퀀스의 마지막이나 거의 끝날 무렵에 시작됨.
컴퓨터의 시작: 전원 버튼에서 리얼모드까지
컴퓨터 전원을 켤 때 메인보드가 전력을 공급받아 CPU를 작동시킨다. 이 상태는 거의 쓸모없는 상태이다. 왜냐하면 RAM에는 아무 의미 없는 데이터들이 들어있고, 운영체제(OS)도 아직 실행되고 있지 않기 때문이다.
부팅을 시작하려면, 특별한 하드웨어 회로가 CPU의 RESET 핀을 "1"로 설정한다. RESET이 활성화되면, CPU 내부의 몇몇 레지스터들(예: cs, eip)이 고정된 초기값으로 설정되고, 물리 주소 0xfffffff0에 있는 코드를 실행하기 시작한다.
- 전원을 켜면 CPU는 자동으로 정해진 위치(0xfffffff0)의 코드부터 실행
- 이 주소는 하드웨어에 의해 ROM(Read-Only Memory)이라고 불리는 읽기 전용의 비휘발성 메모리로 연결되어있음
- 이 ROM 안에 BIOS 코드가 들어 있음
- ROM 안에 들어 있는 프로그램들의 집합을 BIOS라고 부름
- BIOS는 부팅 중에 사용되는 하드웨어 초기화용 저수준 함수들을 제공 (키보드, 화면, 디스크 등을 다루는 인터럽트 기반 루틴들)
- 리눅스가 protected mode로 들어가면 더 이상 BIOS를 사용하지 않음
- 대신 리눅스는 모든 하드웨어 장치에 대해 자체 디바이스 드라이버를 사용
- 왜냐하면, BIOS는 리얼 모드에서만 실행 가능하기 때문
- 리얼 모드에서는 메모리 보호나 멀티태스킹 같은 고급 기능을 쓸 수 없음
Real Mode란
리얼 모드는 8086이라는 매우 오래된 CPU 부터 모든 현대적인 x86 프로세서까지 지원되는 모드이다.
BIOS는 리얼 모드 주소체계를 사용하는데, 컴퓨터가 막 켜졌을 때 사용할 수 있는 유일한 주소 방식이기 때문이다.
리얼 모드 주소는 세그먼트:오프셋 형식이다.
(예: seg = 0x1234, off = 0x5678이라면 실제 물리 주소는 seg * 16 + off = 0x12340 + 0x5678 = 0x179B8)
GDT(Global Descriptor Table), LDT(Local Descriptor Table), 페이징 테이블을 초기화하는 코드는 반드시 리얼 모드에서 실행되어야 한다. (왜냐하면 이 구조체들이 있어야 프로텍티드 모드 진입이 가능하니까.)
세그먼트 방식은 segment selector 와 offset 이라는 두 부분으로 메모리 주소를 구성한다.
물리 주소는 세그먼트 셀렉터에 16을 곱하고 offset을 더하여 계산된다.
리얼모드에서는 CPU가 최대 1MB 의 메모리만 접근할 수 있다. 8086 CPU의 레지스터는 16 비트였기 때문에 최대 64KB 크기의 데이터를 처리할 수 있다. 그래서 이 한계를 극복하기 위해 세그먼트 방식을 사용하여 1MB 메모리 공간을 활용했다.
물리주소 = 세그먼트 셀렉터 * 16 + 오프셋
예를 들어 CS:IP 값이 0x2000:0x0010 이라면 이 값을 물리 주소로 변환하면 0x20010 이 된다.
>>> hex((0x2000 << 4) + 0x0010)
'0x20010'
Reset 이후의 CPU 상태
리셋 이후 CPU는 리얼 모드에서 시작하며 이 때 중요한 레지스터 값들이 다음과 같이 설정된다.
- IP: 0xfff0
- CS selector(visible segement selector) : 0xf000
- CS base( hidden base address) : 0xffff0000
CPU는 CS:IP 조합을 사용하여 실행할 첫 번째 명령어의 위치를 찾는다.
이 위치는 4GB 메모리 공간의 끝에서 16 바이트 아래인 0xfffffff0 이다. EIP 레지스터의 값에 기준 주소를 더함으로써 형성된다.
>>> 0xffff0000 + 0xfff0
'0xfffffff0'
이 위치는 reset vector 라고 불리며 CPU가 리셋되었을 때 실행해야할 첫번째 명령어가 저장된 위치이다.
BIOS로의 진입
CPU는 리셋 벡터에 저장된 명령어를 실행하여 BIOS에 진입한다.
리눅스는 부트스트래핑(bootstrapping) 단계에서 BIOS를 사용할 수밖에 없다. 이 단계에서는 디스크나 기타 외부 장치에서 커널 이미지를 불러와야 하기 때문이다.
BIOS의 부트스트랩 절차는 본질적으로 다음의 네 가지 작업을 수행한다.
- POST (Power-On Self-Test)
- 하드웨어 점검. CPU, 메모리, 키보드, 기타 장치가 정상인지 테스트
- 컴퓨터에 어떤 하드웨어 장치들이 연결되어 있는지, 그리고 그 장치들이 제대로 작동하는지를 확인하는 일련의 테스트를 실행
- 이 과정에서 BIOS 버전 배너 등 여러 메시지들이 화면에 표시됨.
- ACPI 표준에 따른 하드웨어 정보 테이블 구성
- 시스템에 어떤 하드웨어가 있는지 정보를 테이블에 정리
- 최신의 80×86, AMD64, Itanium 기반 컴퓨터는 ACPI(Advanced Configuration and Power Interface) 표준을 사용.
- ACPI를 지원하는 BIOS의 부트스트랩 코드는 시스템에 존재하는 하드웨어 장치들을 설명하는 여러 테이블들을 생성.
- 이 테이블들은 제조사와 관계없이 사용할 수 있는 표준 포맷을 따르며, 운영체제 커널이 이를 읽어서 하드웨어를 어떻게 제어할지 판단할 수 있음.
- 하드웨어 장치 초기화
- 특히 PCI 기반 아키텍처에서 이 단계는 매우 중요. 모든 장치들이 IRQ 라인이나 I/O 포트에서 충돌 없이 동작할 수 있도록 설정을 마치기 때문.
- 이 단계가 끝나면 시스템에 설치된 PCI 장치들의 목록이 출력됨.
- 운영체제 탐색 및 부팅 시도
- BIOS 설정에 따라, 시스템에 있는 플로피 디스크, 하드 디스크, CD-ROM 등의 장치들을 미리 정의된 순서에 따라 접근하여 부팅 가능한 운영체제를 찾음.
- 하드 드라이버의 경우 BIOS 는 부트 섹터(첫 512 바이트)를 읽고 이 곳에서 부팅에 필요한 명령어를 찾는다.
- MBR 파티션 레이아웃 으로 파티션된 하드 드라이브에서 각 섹터가 512 바이트일때, 부트 섹터는 첫 섹터의 첫 446 바이트에 저장됨
- 마지막 2바이트가 0x55, 0xAA일 경우, 이것이 부팅 가능한 장치임을 인식
- 512바이트를 RAM의 0x7c00에 로드하고, 그 위치로 점프(jump) 하여 실행
어셈블리 코드를 작성하여 부트 섹터를 만들고 나면 이 코드를 QEMU 와 같은 가상 머신에서 실행해볼 수 있다.
컴퓨터와 노트북은 바로 작동하기 시작한다. 메인보드는 파워 서플라이에 신호를 보낸다. 신호를 받고 나면 파워 서플라이는 컴퓨터에 적잘한 양의 전력을 제공하기 시작한다. 메인보드가 Power Good Signal을 받고 나면 메인보드는 CPU 시작을 시도한다. CPU는 모든 레지스터에 남아있는 데이터를 초기화하고 각각에 미리 정의된 값들을 설정한다.
다음은 OS 없이도 BIOS만으로 수행할 수 있는 가장 원시적인 부트 코드이다.
아래 어셈블리 코드를 바이너리로 컴파일 후 QEMU에서 부팅해보자.
[BITS 16]
boot:
mov al, '!'
mov ah, 0x0e
mov bh, 0x00
mov bl, 0x07
int 0x10
jmp $
times 510-($-$$) db 0
db 0x55
db 0xaa
- [BITS 16]:
- NASM 어셈블러에게 16비트 코드를 작성하고 있음을 알려줌.
- 이는 부트로더가 16비트 모드에서 실행되기 때문입니다.
- boot 레이블:
- 부트 코드의 시작 지점
mov al, '!': ASCII 값이 !인 값을 AL 레지스터에 저장
mov ah, 0x0e: AH 레지스터에 BIOS 인터럽트 0x10의 0x0E 기능(문자 출력 기능)을 설정
mov bh, 0x00: BH 레지스터에 화면 페이지 번호를 설정합니다. 여기서는 0페이지를 의미
mov bl, 0x07: BL 레지스터에 텍스트 색상을 설정. 0x07은 흰색 텍스트에 검은색 배경을 의미.
int 0x10: BIOS 인터럽트 0x10을 호출하여 '!' 문자를 출력.
jmp $: 무한 루프. 현재 위치로 계속 점프하라는 의미.
- 부트 코드의 시작 지점
- times 510-($-$$) db 0:
- 512바이트 부트 섹터를 맞추기 위해 남은 바이트를 0으로 채움.
- 부트 섹터는 반드시 512바이트여야 함.
- db 0x55와 db 0xaa:
- 부트 섹터의 마지막 2바이트는 0x55와 0xAA로 설정됨
- 이는 부팅 가능한 디스크임을 BIOS에 알리기 위한 매직 넘버
NASM 어셈블러를 사용하여 바이너리 파일로 컴파일한다.
nasm -f bin boot.nasm -o boot.bin
QEMU를 이용하여 이 바이너리 파일을 부팅한다.
qemu-system-x86_64 -drive format=raw,file=boot.bin
QEMU 창에서 !가 출력되는 것을 확인할 수 있다.
