1. 개념 요약

Long mode는 x86-64 환경에서 64비트 코드를 실행하기 위한 CPU 실행 모드다. 이 모드로 전환해야 64비트 레지스터와 x86-64 주소 체계를 사용할 수 있다.

go-os에서는 GRUB이 커널을 32비트 보호 모드 상태로 넘긴다. 따라서 64비트 Go 코드를 실행하려면 커널이 직접 Long mode로 전환해야 한다. Phase 1은 이 전환을 어셈블리로 작성하고 전환이 끝난 뒤 gccgo로 컴파일한 Go 코드를 호출하는 단계다.

Long mode로 전환하려면 페이지 테이블, 컨트롤 레지스터, EFER, GDT 설정이 서로 맞아야 한다. 설정 순서나 값이 어긋나면 paging을 켜는 시점이나 64비트 코드 세그먼트로 점프하는 시점에서 진입에 실패할 수 있다.

2. 왜 필요한가

go-os의 목표는 x86-64 커널이다. 64비트 코드는 Long mode에서만 실행된다. 그래서 64비트 레지스터와 x86-64 주소 체계를 쓰려면 먼저 Long mode로 전환해야 한다.

32비트 보호 모드와 64비트 Long mode의 차이는 다음과 같다.

항목32비트 보호 모드64비트 Long mode
레지스터 크기32비트64비트
범용 레지스터 수8개16개 (R8~R15 추가)
주소 공간4GB32비트 보호 모드보다 넓은 주소 공간
go-os의 64비트 Go 코드 실행이 단계에서는 실행하지 않음전환 뒤 호출 가능

GRUB은 커널을 32비트 보호 모드로 넘기므로 Phase 0의 커널은 32비트 상태에서 시작한다. Go 코드를 실행하려면 이 상태에서 Long mode로 전환해야 한다.

3. 등장 배경

CPU는 전원을 켠 직후 바로 64비트 코드를 실행하지 않는다. 하위호환성 때문에 초기 실행 상태가 과거 x86 환경과 맞춰져 있다. 64비트 실행 모드는 페이지 테이블과 관련 레지스터 설정이 준비된 뒤에야 사용할 수 있다.

go-os의 부팅 흐름에서는 CPU 실행 상태가 다음 순서로 바뀐다. 직접 작성한 커널 코드는 GRUB이 32비트 보호 모드로 제어를 넘긴 뒤부터 실행된다. Phase 1에서는 이 상태에서 64비트 Long mode로 전환하는 부분을 구현한다.

flowchart LR
    R["16비트 리얼 모드
(CPU 리셋 직후)"] P["32비트 보호 모드
(GRUB 핸드오프 지점)"] L["64비트 Long mode
(Phase 1에서 전환)"] R -->|"GRUB이 처리"| P -->|"커널이 직접 전환"| L

Phase 0에서 32비트 상태로 시작할 수 있었던 이유는 GRUB이 그 상태까지 준비한 뒤 커널로 제어를 넘기기 때문이다. GRUB은 32비트 보호 모드로 제어를 넘기지만 그 위의 Long mode 전환은 대신 하지 않는다. 그래서 go-os는 페이지 테이블 준비, 컨트롤 레지스터 설정, EFER 설정, 64비트 GDT 적재를 커널 초기화 단계에서 직접 처리한다.

4. 필요한 전제 조건

Long mode로 진입하려면 다음 설정이 준비돼 있어야 한다. 필요한 설정이 빠지거나 순서가 어긋나면 전환에 실패할 수 있다.

조건설명
32비트 보호 모드 상태GRUB이 이 상태로 커널에 제어를 넘긴다.
페이지 테이블 준비CR3에 넣을 페이지 테이블을 미리 만들어야 한다.
PAE 활성화CR4의 PAE 비트를 켜야 한다.
EFER.LME 설정EFER의 LME 비트를 켜야 Long mode로 전환할 수 있다.
paging 활성화CR0.PG를 켜야 IA-32e 모드가 활성화된다.
64비트 코드 세그먼트64비트 코드 세그먼트로 far jump를 해야 64비트 코드 실행 상태로 들어간다.

이 설정은 순서가 있다. PAE는 paging을 켜기 전에 활성화해야 하고 EFER.LME도 paging을 켜기 전에 설정해야 한다. 순서가 어긋나면 paging을 켜는 시점에 64비트 코드 실행 상태로 들어가지 못한다.

paging은 Phase 1에서 메모리 격리를 위해 쓰는 것이 아니다. Long mode에서는 paging이 켜져 있어야 하므로 페이지 테이블을 최소 구성으로 만든다. 가상 주소를 물리 주소와 똑같이 매핑하는 identity 매핑을 사용한다. 번역해도 주소가 그대로이므로 Long mode 전환 뒤에도 0xB8000으로 VGA 텍스트 메모리에 접근할 수 있다.

Go 코드를 호출하기 위한 조건

위 조건은 Long mode 진입 자체의 조건이다. Long mode 전환이 끝난 뒤 Go 코드를 호출하려면 다른 조건을 따로 맞춰야 한다. gccgo로 만든 Go 코드를 베어메탈 커널에서 호출하려면 운영체제와 표준 라이브러리에 대한 의존성을 줄이는 방식으로 빌드해야 한다. 어셈블리에서 Go 함수를 호출할 때는 x86-64 호출 규약과 망글링된 이름을 맞춰야 한다. 자세한 내용은 §5.5와 §8에서 다룬다.

5. 핵심 구성 요소

Long mode 전환에는 컨트롤 레지스터, EFER, 페이지 테이블, GDT가 관여한다. Go 코드를 호출하기 위한 컴파일 플래그는 §5.5에서 따로 다룬다.

5.1 컨트롤 레지스터 (CR0, CR3, CR4)

컨트롤 레지스터는 CPU의 실행 모드와 paging을 켜고 끄는 설정용 레지스터다.

레지스터역할
CR0paging 활성화(PG 비트)와 보호 모드 같은 제어 비트를 가진다.
CR3현재 페이지 테이블의 최상위 주소를 가진다. CPU가 주소를 번역할 때 참조한다.
CR4PAE 같은 확장 기능을 켜는 제어 비트를 가진다.

컨트롤 레지스터는 일반 레지스터를 거쳐서만 읽고 쓴다. mov eax, cr0으로 값을 읽고 mov cr0, eax로 다시 쓴다. CR0, CR2, CR3, CR4, CR8을 사용하고 CR1은 예약되어 있어 쓰지 않는다.

5.2 EFER

EFER(Extended Feature Enable Register)는 Long mode 활성화에 사용하는 MSR(Model Specific Register)이다. EFER 안의 LME(Long Mode Enable) 비트를 설정해야 Long mode로 전환할 수 있다.

MSR은 일반 레지스터와 따로 있고 rdmsrwrmsr로 읽고 쓴다. EFER를 읽어 LME 비트를 켠 뒤 다시 쓴다.

5.3 페이지 테이블과 identity 매핑

페이지 테이블은 가상 주소를 물리 주소로 번역하는 표다. Long mode에서는 paging이 켜져 있어야 하므로 전환 전에 페이지 테이블을 만들어 CR3에 주소를 넣어야 한다.

paging이 켜지면 CPU는 다음 순서로 주소를 번역한다.

flowchart LR
    VA["가상 주소"]
    PT["페이지 테이블
(CR3가 최상위 주소를 가리킴)"] PA["물리 주소"] VA --> PT --> PA

Phase 1에서는 메모리 격리가 필요 없으므로 identity 매핑을 사용한다. identity 매핑은 가상 주소와 물리 주소를 똑같이 연결하는 방식이다.

가상 주소             물리 주소
0x00000  ── identity ──►  0x00000
0xB8000  ── identity ──►  0xB8000   (VGA 텍스트 메모리)

번역해도 주소가 그대로이므로 Phase 0과 같은 방식으로 0xB8000에 값을 써서 화면에 출력할 수 있다.

5.4 GDT와 64비트 코드 세그먼트

GDT(Global Descriptor Table)는 세그먼트 정보를 담는 표다. 64비트에서는 세그먼트 기반 주소 계산의 역할이 대부분 줄어든다. 그래도 CPU는 코드 세그먼트에서 64비트 코드인지와 권한 수준을 읽으므로 64비트 코드 세그먼트가 있는 GDT가 필요하다.

paging을 켠 직후에는 아직 호환 모드 상태다. 여기서 호환 모드는 IA-32e 모드가 활성화되었지만 현재 실행 중인 코드 세그먼트가 아직 64비트 코드 세그먼트가 아닌 상태를 뜻한다. 즉 Long mode 진입에 필요한 설정은 끝났지만 아직 64비트 코드 세그먼트로 전환하지 않은 상태다. 64비트 코드 세그먼트로 far jump를 해야 64비트 코드 실행 상태로 들어간다.

flowchart LR
    C["호환 모드
(paging ON, 32비트 코드)"] J["far jump
(64비트 코드 세그먼트)"] E["64비트 코드 실행 상태"] C --> J --> E

5.5 gccgo freestanding 플래그

일반적인 Go 실행 파일은 운영체제가 제공하는 런타임 환경을 기대한다. 베어메탈 커널에서는 그런 환경이 없으므로 표준 라이브러리 링크와 스택 확장 같은 의존성을 줄여서 빌드해야 한다. go-os는 다음 플래그를 사용한다.

플래그의미
-ffreestandingC/C++에서 freestanding 환경을 지정할 때 쓰는 옵션이다. 현재 Go 컴파일에서는 실질적인 효과를 기대하지 않는다.
-nostdlibC 표준 라이브러리(libc)를 링크하지 않는다.
-fno-split-stackGo의 자동 스택 확장(__morestack)을 끄고 고정 스택을 사용한다.

따라서 Phase 1에서 실제로 중요한 옵션은 표준 라이브러리 링크를 줄이는 -nostdlib와 split stack을 끄는 -fno-split-stack이다. 이 옵션은 Long mode 진입 자체와는 별개이고 Long mode로 들어간 뒤 Go 코드를 호출하기 위한 설정이다.

6. 동작 흐름

Long mode 전환은 순서가 정해진 절차다. 각 단계는 앞 단계가 끝나야 의미가 있다.

flowchart TD
    A["페이지 테이블 준비 (identity)"]
    B["CR3 ← 페이지 테이블 주소"]
    C["CR4.PAE 활성화"]
    D["EFER.LME 설정"]
    E["CR0.PG 활성화 (paging ON)"]
    F["64비트 GDT 적재 + far jump"]
    G["64비트 코드 실행 상태 (Go 함수 호출 가능)"]

    A --> B --> C --> D --> E --> F --> G

페이지 테이블을 먼저 만들고 CR3에 주소를 넣는다. 그다음 PAE와 LME를 설정하고 paging을 활성화한다. paging을 켠 직후에는 호환 모드 상태다. 64비트 코드 세그먼트로 far jump를 해야 64비트 코드 실행 상태로 들어간다. 이 절차가 끝나야 Go 함수를 호출할 수 있다.

전환이 끝난 뒤 어셈블리에서 Go로 제어를 넘기는 흐름은 다음과 같다.

flowchart LR
    S["_start (32비트)"]
    L["Long mode 전환"]
    M["kernel.Main 호출"]
    G["Go 함수 실행"]

    S --> L
    L -->|"RDI = 멀티부트 정보"| M
    M --> G

x86-64 호출 규약에서 첫 번째 인자는 RDI로 전달한다. 멀티부트 정보 포인터를 RDI에 넣고 kernel.Main을 호출한다.

7. 상태 변화

전환 과정에서 CPU 상태와 paging 상태는 단계적으로 바뀐다.

단계CPU 상태paging
GRUB 핸드오프 직후32비트 보호 모드off
CR3 설정 + PAE 활성화32비트 보호 모드off
EFER.LME 설정32비트 보호 모드off
CR0.PG 활성화호환 모드on
64비트 코드 세그먼트로 far jump64비트 코드 실행 상태on

각 단계는 앞 단계의 설정이 끝나야 의미가 있다. CR3와 PAE가 준비되지 않은 상태에서 paging을 켜면 CPU가 잘못된 페이지 테이블을 참조해 전환에 실패한다.

8. 코드나 설정에서 나타나는 형태

Long mode 개념은 전환 어셈블리, 어셈블리에서 Go로의 호출, Go에서의 VGA 텍스트 메모리 쓰기에서 나타난다.

8.1 PAE 활성화

다음 코드는 CR4의 PAE 비트를 켜는 예시다.

mov eax, cr4
or eax, 1 << 5
mov cr4, eax

이 코드는 Long mode 진입 전에 PAE를 활성화하는 단계다. PAE가 켜져 있어야 Long mode에서 사용하는 페이지 테이블 구조를 쓸 수 있다. 컨트롤 레지스터는 직접 수정할 수 없으므로 eax를 거쳐 읽고 쓴다.

8.2 어셈블리에서 Go 호출

다음 코드는 전환이 끝난 뒤 Go 함수를 호출하는 형태다.

extern go_0kernel.Main
call go_0kernel.Main

gccgo는 kernel.Maingo_0kernel.Main으로 망글링한다. export 지시어로도 이름이 바뀌지 않으므로 어셈블리에서는 망글링된 이름을 그대로 호출한다. 멀티부트 정보 포인터는 호출 전에 RDI에 넣어 둔다.

8.3 Go에서 VGA 텍스트 메모리 쓰기

다음 코드는 Go가 unsafe로 VGA 텍스트 메모리에 값을 쓰는 형태다.

ptr := (*uint16)(unsafe.Pointer(uintptr(0xB8000)))
*ptr = 0x0f4f

Phase 1에서는 표준 라이브러리를 링크하지 않으므로 fmt를 사용하지 않는다. 대신 메모리 맵 I/O로 VGA 텍스트 메모리에 직접 값을 쓴다. unsafe.Pointeruintptr0xB8000 주소를 가리키는 포인터를 만든다. identity 매핑이므로 가상 주소와 물리 주소가 같고 Phase 0의 어셈블리와 같은 위치에 문자가 표시된다.

9. 자주 헷갈리는 지점

9.1 EFER.LME 설정만으로 64비트가 되지 않는다

EFER.LME를 설정했다고 해서 바로 64비트 코드가 실행되는 것은 아니다. LME는 Long mode로 전환할 수 있게 하는 설정이다. 실제 진입은 paging을 활성화하고 64비트 코드 세그먼트로 far jump를 해야 일어난다.

9.2 64비트에서도 GDT는 필요하다

64비트에서는 세그먼트 기반 주소 계산의 역할이 대부분 줄어든다. 그래도 GDT는 필요하다. CPU가 코드 세그먼트에서 64비트 코드인지(L 비트)와 권한 수준(DPL)을 읽기 때문이다. 그래서 64비트 코드 세그먼트가 있는 GDT가 있어야 far jump로 64비트 코드 실행 상태에 들어갈 수 있다.

9.3 freestanding이어도 런타임 심볼이 남는다

표준 라이브러리 링크와 스택 확장 의존성을 줄여 빌드해도 Go가 일부 런타임 심볼을 참조할 수 있다. Phase 1에서는 배열 타입 때문에 gccgo가 자동 생성한 비교 함수가 runtime.memequal을 참조했다. 이번 단계의 실행 경로에서는 이 함수를 사용하지 않으므로 빈 stub을 두어 링크를 통과시켰다. 이후 코드가 늘어나면 이 stub이 실제로 호출되는지 다시 확인해야 한다.

9.4 kernel.Main 이름은 망글링된다

gccgo는 kernel.Maingo_0kernel.Main으로 망글링한다. //go:export//export 모두 이 이름을 바꾸지 못한다. 그래서 어셈블리에서 호출할 때는 망글링된 이름을 그대로 쓴다.

10. 확인 방법

Long mode 전환은 다음 항목으로 확인할 수 있다.

확인 대상보는 값판단 기준
Long mode 진입QEMU 로그나 디버거에서 CS의 L 비트를 본다.L 비트가 켜져 있으면 64비트 코드 세그먼트로 진입한 상태다.
페이지 테이블 적용CR3 값과 매핑된 가상 주소를 본다.CPU가 의도한 페이지 테이블을 참조하는지 알 수 있다.
Go 실행QEMU 화면에 OKGO가 나오는지 본다.OKGO가 보이면 Go 코드가 실행되어 출력한 상태로 볼 수 있다.
런타임 stub 필요링크 오류에 표시된 undefined symbol을 확인한다.추가로 구현하거나 연결해야 할 런타임 심볼을 식별할 수 있다.

QEMU 화면에 OKGO가 나오면 Long mode 전환이 끝났고 그 위에서 Go 코드가 실행되어 출력했다는 것을 확인할 수 있다. 다만 이 결과만으로 고루틴, 채널, GC 같은 런타임 기능이 동작한다고 볼 수는 없다. Phase 1에서 확인한 범위는 Long mode 진입, freestanding 컴파일, 어셈블리와 Go의 호출 연결, VGA 출력까지다.

11. 다음에 볼 개념

Long mode 전환을 이해했다면 다음에는 interrupt를 봐야 한다. IDT 엔트리는 여기서 설정한 64비트 코드 세그먼트 셀렉터를 참조하므로 Long mode의 GDT를 이해한 뒤 interrupt 흐름으로 넘어가는 것이 자연스럽다.

paging을 더 보려면 PML4, PDPT, PD, PT의 관계를 따로 봐야 한다. Phase 1의 identity 매핑은 최소 구성이다. 메모리 격리나 가상 메모리는 이후 단계에서 paging 구조를 확장하면서 다룬다.

부록 A. 참고 레퍼런스

주제출처
Long mode 전환 단계 정리Setting Up Long Mode - OSDev Wiki
gccgo freestanding과 Go 기반 OS 구현dmarro89/go-dav-os

부록 B. 다시 확인할 질문

질문답 요약관련 절
왜 Long mode로 전환해야 하는가?64비트 코드는 Long mode에서만 실행되기 때문이다. GRUB은 32비트 보호 모드로 넘긴다.§2
paging은 왜 먼저 만드는가?Long mode에서는 paging이 켜져 있어야 하기 때문이다. Phase 1에서는 identity 매핑으로 최소 구성한다.§4
EFER.LME만 켜면 64비트가 되는가?되지 않는다. paging을 활성화하고 64비트 코드 세그먼트로 far jump를 해야 진입한다.§9.1
64비트에서 GDT가 왜 필요한가?CPU가 코드 세그먼트에서 64비트 코드인지와 권한 수준을 읽기 때문이다.§9.2
runtime.memequal은 왜 나타났는가?배열 타입 때문에 gccgo가 자동 생성한 비교 함수가 참조한다. Phase 1의 실행 경로에서는 사용하지 않는다고 보고 빈 stub을 두었다.§9.3
어셈블리에서 Go 함수 이름은 무엇인가?kernel.Maingo_0kernel.Main으로 망글링되므로 그 이름으로 호출한다.§9.4