- Published on
부트로더에서 커널까지
- Authors
- Name
- JaeHyeok CHOI
- none
위 링크를 참고한 공부 내용이다. 원문의 번역을 다시 간단하게 번역하여 저장한다.
컴퓨터에 전원을 인가하였을 때
컴퓨터 체계에서 전원 버튼을 누르면, 시스템은 바로 작동하기 시작한다. 메인보드는 파워 서플라이에 신호를 보내고, 파워 서플라이는 컴퓨터에 적절한 양의 전력을 제공한다. 메인보드가 파워 양호 신호(Power good signal)을 받고 나면, 메인보드는 CPU 시작을 시도한다. CPU는 모든 레지스터에 남아있던 데이터를 초기화하고, 각각에 미리 정의된 값들을 설정한다.
80386 CPU 이상에서 컴퓨터 리셋 이후 CPU 레지스터에 들어갈 미리 정의된 값을 다음을 따른다.
IP 0xfff0
CS selector 0xf000
CS base 0xffff0000
프로세서는 리얼 모드에서 작동을 시작한다. 리얼 모드는 8086에서부터 최신의 인텔 64bit CPU까지 모든 x86 호환 프로세서에서 지원된다. 8086 프로세서는 20비트 주소 버스를 가지고 있어서, 0-0xFFFF 또는 1 MByte 주소 공간으로 동작할 수 있었으나, 2^16 -1 혹은 0xffff(64KB)의 최대 주소를 갖는 16비트 레지스터 밖에 가지고 있지 않았다.
메모리 세그먼트 방식은 사용 가능한 모든 주소 공간을 이용하는데 쓰였다. 모든 메모리는 65535 바이트 (64KB)의 일정한 크기를 가진 작은 세그먼트로 나누어진다. 16비트 레지스터로는 64KB 이상의 주소를 만들 수 없기 때문에 이러한 대체 방법을 고안하게 되었다.
주소는 두 파트로 구성된다. : 주소를 가지고 있는 세그먼트 셀렉터와 기준 주소로부터의 오프셋.
리얼 모드에서 세그먼트 셀렉터의 관련하는 기준 주소는 세그먼트 셀렉터*16 이다. 따라서 메모리의 물리 주소를 얻기 위해서는 이것에 세그먼트 셀렉터에 16을 곱하고 오프셋을 더해주어야 한다.
물리주소 = 세그먼트 셀렉터 * 16 + 오프셋
예를 들어, 만약 CS:IP 가 0x2000:0x0010이면, 해당하는 물리주소는 아래와 같다.
>> hex((0x2000 << 4) + 0x0010)
'0x20010'
하지만, 만약 우리가 0xffff:0xffff와 같이 가장 큰 세그먼트 셀렉터와 오프셋을 갖는다면, 결과는 이렇게 된다.
>> hex((0xffff << 4) + 0xffff)
'0x10ffef'
65520 바이트가 첫 메가바이트를 넘어가 버린다. 따라서 오직 1MB까지만 접근 가능한 리얼모드에서, A20 라인이 비활성화 되어 있을 때 0x10ffef는 0x00ffef가 되어버린다.
CS 레지스터는 두 파트로 구성된다 :
- visible segment selector
- hidden base address
기준 주소는 일반적으로 세그먼트 셀렉터 값에 16을 곱하여 형성되지만, 하드웨어가 리셋되는 동안에는 CS 레지스터의 세그먼트 셀렉터에 0xf000 이 로드되고, 기준 주소에는 0xffff0000이 로드됩니다. 프로세서는 CS의 값이 바뀌기 전까지는 이 특별한 기준 주소를 사용한다.
시작 주소는 EIP 레지스터의 값에 기준 주소를 더함으로써 형성된다.
>>>0xffff0000 + 0xfff0
'0xfffffff0'
4GB의 16바이트 아래인 0xfffffff0 인 이 지점을 리셋 벡터라고 불린다. CPU가 리셋 후 실행할 첫 번째 명령어가 있을 것이라 예상하는 메모리 위치이다. 이 명령은 보통 BIOS 진입 지점을 가리키고 있는 점프(jmp) 명령을 포함하고 있다. 예로써 coreboot의 소스크드 (src/cpu/x86/16bit/reset16.inc)을 보면
.section ".reset", "ax", %progbits
.code16
.globl _start
_start:
.byte 0xe9 // 0xe9 : jmp 명령어
.int _start16bit - ( . + 2 ) // 목적지 주소
...
reset 섹션의 16바이트를 보면 0xfffffff0 주소에서 시작하기 위해 컴파일 된다.
SECTIONS {
/* Trigger an error if I have an unuseable start address */
_bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report.");
_ROMTOP = 0xfffffff0;
. = _ROMTOP;
.reset . : {
*(.reset);
. = 15;
BYTE(0x00);
}
}
바이오스의 시작
이제 바이오스가 시작되었다; 하드웨어 초기화와 검사가 끝난 후에, 바이오스는 부팅 가능한 디바이스를 찾아야 한다. 부팅 순서는 BIOS 설정에 저장되어 있으먀, BIOS가 부팅을 시도하는 장치를 제어한다. 하드 드라이브로부터 부팅을 시도할 때, 바이오스는 부트 섹터를 찾으려 시도한다. MBR 파티션 레이아웃으로 파티션된 하드 드라이브에서 각 섹터가 512 바이트 일때, 부트 섹터는 첫 섹터의 첫 446 바이트에 저장된다. 첫 섹터의 마지막 두 바이트는 0x55와 0xAA 이다. 이는 BIOS 에게 부팅 가능한 장치라는 것을 알려주기 위해 디자인 되었다.
예시)
;
; Note: this example is written in Intel Assembly syntax
;
[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
이렇게 빌드하고 실행할 경우
nasm -f bin boot.nasm && qemu-system-x86_64 boot
이를 통해 QEMU에게 디스크 이미지로 방금 우리가 만든 부팅 바이너리를 사용하도록 지시할 수 있다. 어셈블리 코드에 의해 생성된 바이너리는 부트 섹터의 요구사항(위치 카운터가 0x7c00 설정 되었으며, 매직 시퀀스로 끝남)을 충족하기 때문에 QEMU는 이 바이너리를 디스크 이미지의 마스터 부트 레코드(MBR)로 다룬다.
Simple Bootloader which prints only ‘!’
이 예제에서 코드가 16-bit 리얼 모드에서 실행되며, 메모리의 0x7c00 에서 시작한다는 것을 알 수 있다. 시작하고 난 후, 코드는 단순히 ‘!’을 출력하는 0x10 인터럽트를 호출한다. 나머지 510바이트를 0으로 채우고, 매직 바이트 0xAA와 0x55로 끝난다.
objdump 유틸리티를 사용하여 바이너리 덤프를 볼 수 있다.
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
실제 부트 섹터는 많은 0과 감탄사 대신에 부팅 프로세스와 파티션 테이블을 계속하기 위한 코드를 가지고 있다. 이 시점부터 BIOS는 부트로더에게 제어를 넘겨준다.
위 설명되어 있듯이 CPU는 리얼 모드에 있다; 리얼 모드에서는 물리 주소를 계산하기 위하여 아래의 공식을 따른다.
PhysicalAddress = Segment Selector * 16 + Offset
16비트 범용 레지스터 밖에 가지고 있지 않기 때문에, 16비트 레지스터의 최대 값은 0xffff이다. 따라서 가장 큰 값을 가진다면
>>> hex((0xffff * 16) + 0xffff)
'0x10ffef'
/*
여기서 0x10ffef 는 "1MB + 64KB - 16b" 와 같다.
8086 프로세서는 그에 반해서, 20비트 주소라인을 가지고 있었다.
2^20 = 104876 이 1MB 이므로, 사실상 사용 가능한 메모리가 1MB 라는 뜻이 된다.
*/
일반적으로 리얼 모드의 메모리 맵은 아래와 같다.
0x00000000 - 0x000003FF - 리얼 모드 인터럽트 벡터 테이블
0x00000400 - 0x000004FF - BIOS 데이터 구역
0x00000500 - 0x00007BFF - 사용되지 않음 (Unused)
0x00007C00 - 0x00007DFF - 우리의 부트로더
0x00007E00 - 0x0009FFFF - 사용되지 않음 (Unused)
0x000A0000 - 0x000BFFFF - 비디오 램 메모리 (VRAM)
0x000B0000 - 0x000B7777 - 흑백 비디오 메모리
0x000B8000 - 0x000BFFFF - 컬러 비디오 메모리
0x000C0000 - 0x000C7FFF - 비디오 롬 BIOS (Video ROM BIOS)
0x000C8000 - 0x000EFFFF - BIOS 그림자 구역 (BIOS Shadow Area)
0x000F0000 - 0x000FFFFF - 시스템 BIOS
0xFFFE_0000 - 0xFFFF_FFFF: 128 킬로바이트 롬이 주소 공간에 맵핑됨 (128 kilobyte ROM mapped into address space)
부트로더
GTUB2 와 syslinux 와 같이 리눅스를 부팅시킬 수 있는 많은 부트로더가 있다. 리눅스 커널은 리눅스 지원 시행을 위해 부트로더의 요구사항을 명시해 놓은 부트 프로토콜을 가지고 있다. 여기서는 GRUB2 을 설명할 것이다.
BIOS가 부팅 장치를 선택하고 부트 섹터 코드로 제어권을 넘겨준 지금, 실행은 boot.img에서 시작한다. 이 코드는 공간의 제한으로 인해 매우 간단하며, GRUB2 코어 이미지의 위치로 점프하는데 사용하기 위한 포인터를 포함하고 있다. 코어 이미지는 diskboot.img로 시작하는데, 이 이미지는 보통 첫 번째 파티션 이전의 사용되지 않는 공간(unused space) 첫 번째 섹터 바로 뒤에 저장된다. 위의 코드는 GRUB2 의 커널과 파일 시스템 처리를 위한 드라이버를 포함하고 있는 나머지 코드 이미지를 메모리에 로드한다. 나머지 코드의 로딩 이후에는 gtub_main 함수를 실행시킨다.
grub_main 함수는 콘솔을 초기화하고, 모듈을 위한 기준 주소를 얻고, 루트 디바이스를 설정하며, grub 설정 파일 로드/분석하고, 모듈 로드 등을 한다. 실행이 끝나면 grub_main 함수가 grub를 normal mode로 이동시킨다. grub_normal_execute 함수 (grub-core/normal/main.c)는 최종 준비를 완료하고 운영체제 선택을 위한 메뉴를 보여준다. 우리가 grub 메뉴 항목들 중 하나를 선택하면 grub_menu_execute_entry 함수가 실행되어 grub boot 명령을 실행하고 선택한 운영체제를 부팅한다.
우리가 커널 부트 프로토콜 에서 읽을 수 있듯이, 부트로더는 반드시 커널 구성 코드(kernel setup code)의 0x01f1 오프셋에서 시작하는 커널 구성 헤더의 일부 필드를 읽고 채워야 한다. 링커 스크립트에서 이 오프셋 값을 확인할 수 있다.
커널 헤더는 다음과 같이 시작한다.
.globl hdr
hdr:
setup_sects: .byte 0
root_flags: .word ROOT_RDONLY
syssize: .long 0
ram_size: .word 0
vid_mode: .word SVGA_MODE
root_dev: .word 0
boot_flag: .word 0xAA55
부트로더는 반드시 이 것과 헤더의 나머지 부분(Linux 부트 프로토콜에서 오직 write 타입으로 표시된 것만)을 커맨드 라인으로부터 받았거나 부팅 중에 계산된 값으로 채워야 한다.
커널 부트 프로토콜에서 볼 수 있듯이, 커널이 로딩된 후 메모리는 아래와 같다.
| 보호모드 커널 |
100000 +------------------------+
| 입출력 메모리 홀 |
0A0000 +------------------------+
| BIOS를 위해 제공됨 | 가능한 한 많이 사용하지 말고 미사용으로 내버려 두세요.
~ ~
| 커맨드 라인 | (X+10000 표시 이하일 수도 있습니다.)
X+10000 +------------------------+
| 스택 / 힙 | 커널 리얼모드를 위해 사용 됩니다.
X+08000 +------------------------+
| 커널 구성 | 커널 리얼모드 코드.
| 커널 부트 섹터 | 기존 부트 섹터 커널.
X +------------------------+
| 부트로더 | <- 부트 섹터 진입 지점 0x7C00
001000 +------------------------+
| MBR/BIOS를 위해 제공됨 |
000800 +------------------------+
| 일반적으로 MBR에게 사용됨|
000600 +------------------------+
| BIOS만 사용 가능 |
000000 +------------------------+
부트로더가 커널에게서 제어권을 넘겨 받았을 때에는 여기서 부터 시작한다.
X + sizeof(KernelBootSector) + 1
X는 로드되고 있는 커널 부트 섹터의 주소이다. 필자의 경우엔 메모리 덤프에서 볼 수 있듯, X는 0x10000 이다.
이제 부트로더는 리눅스 커널을 메모리에 로드했다; 헤더 필드들을 채웠고, 그러고 나서는 해당하는 메모리 주소로 점프했다. 이제 커널 구성 코드로 이동할 수 있다.
커널 구성 단계의 시작
첫 번째로 커널 구성 부분은 반드시 압축해제, 그리고 몇몇 메모리 관리와 관련된 것을들 설정한다.
이 후에 커널 구성 부분은 실제 커널을 압축 해제하고 그 곳으로 점프한다. 구성 부분의 실행은 arch/x86/boot/header.S의 _start 심볼에서 시작한다.
qemu-system-x86_64 vmlinuz-3.18-generic
해당 명령어를 실행하면,
header.S 파일은 매직 넘버 MZ 로 시작한다. 에러 메시지는 그것을 보여준다. PE헤더
#ifdef CONFIG_EFI_STUB
# "MZ", MS-DOS header
.byte 0x4d
.byte 0x5a
#endif
...
...
...
pe_header:
.ascii "PE"
.word 0
운영체제를 UEFI로 로드하려면 필요한 것이다.
실제 커널 구성 진입 지점은 아래와 같다.
// header.S line 292
.globl _start
_start:
header.S 가 오류 메시지를 출력하는 .bstext 섹션에서 부터 시작해도, 부트로더(grub2 등등)는 이 지점(MZ 로 부터 0x200 오프셋)으로 점프한다.
//
// arch/x86/boot/setup.ld
//
. = 0; // current position
.bstext : { *(.bstext) } // put .bstext section to position 0
.bsdata : { *(.bsdata) }
커널 구성 지점 포인터는 :
.globl _start
_start:
.byte 0xeb
.byte start_of_setup-1f
1:
//
// rest of the header
//
이는 실질적으로 실행되는 첫 코드이다. 커널 구성 부분이 부트로더부터 제어권을 넘겨 받으면, 첫 번째 jmp 명령이 커널 리얼 모드 시작 지점(첫 512바이트 이후)부터 0x200 오프셋에 위치한다. Linux 커널 부트 프로토콜과 grub2 소스코드에서 확인 할 수 있다.
gs = fs = es = ds = ss = 0x10000
cs = 0x10200
start_of_setup 로 점프한 이후에, 커널은 다음과 같은 일을 수행한다.
- 모든 세그먼트 레지스터 값들이 확실히 같게 할 것.
- 올바른 스택을 구성할 것.
- bss를 구성할 것.
- arch/x86/boot/main.c 의 C코드로 점프할 것.
세그먼트 레지스터 정렬
커널은 ds 와 es 세그먼트 레지스터가 같은 주소를 가리키게 만들어야 한다. 다음은 cld 명령을 사용하여 방향 플래그를 클리어 한다.
movw %ds, %ax
movw %ax, %es
cld
grub는 그 실행이 파일의 시작부터 이루어지지 않기 때문에, 커널 구성 코드를 0x10000에 로드하고, cs에는 0x10200 를 로드해야 한다. 따라서 4d 5a로 부터 512바이트 떨어진 이 곳으로 점프하여 시작한다.
_start:
.byte 0xeb
.byte start_of_setup-1f
또한, cs와 다른 모든 세그먼트 레지스터들을 0x10200에서 0x100000로 정렬할 필요가 있다. 그 후에 스택을 설정한다.
pushw %ds
pushw $6f
lretw
위 코드는 DS의 값을 스택에 저장한 다음, 6라벨의 주소를 지정하고 lretw 명령을 실행한다. lretw 명령이 호출되면, 6라벨의 주소를 명령 포인터 레지스터에 로드하고, cs에 ds의 값을 로드하게 된다. ds와 cs는 같은 값을 가지게 된다.
스택 구성
대부분의 구성 코드는 리얼모드에서의 C언어 환경을 위해 준비하는 것이다. 다음 단계는 ss 레지스터의 값을 확인하고, 만약 ss가 잘못되어 있다면 올바른 스택을 만들어 주는 것이다.
movw %ss, %dx
cmpw %ax, %dx
movw %sp, %dx
je 2f
3개의 상황이 발생할 수 있다.
ss 가 유효한 값 0x1000을 가지고 있다. (cs와 다른 모든 세그먼트 레지스터들과 마찬가지로.)
ss 가 유효하지 않으며 CAN_USE_HEAP 플래그가 설정되어 있다.
ss 가 유효하지 않으며 CAN_USE_HEAP 플래그가 설정되어 있지 않다.
ss 가 유효한 값 0x1000을 가지고 있을 경우)
이 경우에는 라벨 2로 이동한다.
2: andw $~3, %dx
jnz 3f
movw $0xfffc, %dx
3: movw %ax, %ss
movzwl %dx, %esp
sti
여기서 dx(부트로더에 의해 주어진 sp의 값을 가지고 있음.)를 4바이트로 정렬하고, 0인지 아닌지를 확인한다. 만약 0일 경우, 0xfffc(64KB의 최대 세그먼트 크기 이전의 4바이트로 정렬된 주소)를 dx에 넣는다. 만약 0이 아니라면, 우리는 부트로더에 의해 주어진 sp의 값을 계속 사용한다. 이훙