1. 개념 요약
메모리 할당기는 실행 중인 코드의 요청에 따라 사용할 메모리 영역을 배정한다. Go에서 new로 객체를 생성하거나 슬라이스의 용량을 늘릴 때도 메모리 할당이 발생한다.
일반적인 Go 프로그램에서는 런타임이 할당기를 제공하지만 freestanding 커널에는 완성된 Go 런타임이 없다. 따라서 gccgo가 참조하는 할당 함수를 커널에서 구현해야 한다. go-os의 첫 구현은 메모리를 순차적으로 배정하고 해제하지 않는 bump allocator다.
2. 왜 필요한가
정적 메모리만으로는 크기가 미리 정해진 데이터밖에 다루지 못한다. 입력을 모으는 버퍼나 길이가 변하는 목록처럼 실행 중에 크기가 정해지는 데이터를 쓰려면 동적 할당이 필요하다.
new(T), make([]T, n)과 append는 컴파일 과정에서 런타임 할당 함수 호출로 변환될 수 있다. freestanding 커널에 해당 함수가 없으면 링크 과정에서 undefined symbol 오류가 발생한다.
3. 등장 배경
이전 단계까지 커널은 전역 배열 같은 정적 메모리 위에서만 동작했다. VGA 버퍼나 IDT, 스택은 모두 크기가 고정돼 있어 할당기가 없어도 됐다.
동적 할당 코드를 추가하면 gccgo가 runtime.newobject 등의 런타임 심볼을 참조한다. 커널에 해당 심볼의 구현이 없으면 링크가 실패하므로 이 단계에서 할당기를 추가했다.
4. 필요한 조건
bump 할당기 자체는 단순하지만 gccgo의 동적 할당이 실제로 동작하려면 세 가지를 더 맞춰야 한다.
| 조건 | 설명 |
|---|---|
| 할당 심볼 제공 | runtime.newobject, runtime.makeslice, runtime.growslice를 구현한다. |
| SSE 활성화 | gccgo가 SSE 명령을 쓰므로 CR4에서 SSE를 켜지 않으면 #UD가 난다. |
| GC 쓰기 배리어 무력화 | 포인터를 heap에 저장하면 배리어 코드가 끼므로 writeBarrier.enabled를 0으로 둬야 한다. |
5. 핵심 구성 요소
| 구성 요소 | 역할 |
|---|---|
| heap 영역 | .bss에 확보한 고정 크기 배열로, 동적 할당에 사용할 메모리다. |
| bump 포인터 | 다음 할당이 시작될 위치를 가리키며 할당 크기만큼 전진한다. |
newobject | new(T)가 호출하며 타입 크기에 맞는 메모리를 할당한다. |
makeslice | make([]T, n)이 호출하며 원소 크기와 용량에 맞춰 메모리를 할당한다. |
growslice | append가 용량을 넘길 때 부른다. 더 큰 배열로 옮긴다. |
| 서술자 stub | 타입·함수 서술자(..d, ..f)와 GC 배리어 심볼. 링크를 통과시킨다. |
6. 동작 흐름
flowchart TD
A["new(T) / make / append"]
B["gccgo가 runtime 할당 함수 호출"]
C["커널이 채운 newobject/makeslice/growslice"]
D["bumpAlloc(size)"]
E["heapNext를 size만큼 전진"]
F["할당한 메모리 주소 반환"]
A --> B --> C --> D --> E --> F
gccgo는 new와 슬라이스 연산을 런타임 할당 함수 호출로 변환한다. 커널에서 구현한 각 함수는 bumpAlloc을 호출한다. bumpAlloc은 현재 주소를 정렬하고 요청 크기만큼 bump 포인터를 전진시킨 뒤 할당한 영역의 시작 주소를 반환한다.
7. 상태 변화
| 단계 | heap 상태 |
|---|---|
| 부팅 직후 | heapNext가 0이다. heap 전체가 비어 있다. |
| 할당 1회 후 | heapNext가 할당한 크기만큼 앞으로 이동한다. |
| 반복 할당 | heapNext가 계속 전진한다. free가 없어 되돌아오지 않는다. |
| heap 소진 | 요청한 크기가 남은 공간보다 크면 할당에 실패한다. |
8. 코드나 설정에서 나타나는 형태
//go:linkname으로 커널의 구현을 runtime.newobject 심볼에 연결한다. 타입 서술자에서 객체 크기를 읽어 bumpAlloc에 전달한다.
//go:linkname newobject runtime.newobject
func newobject(typ *_type) unsafe.Pointer {
return bumpAlloc(typ.size)
}
SSE는 Long mode 진입 직후 CR0와 CR4에서 켠다. 이 설정이 없으면 SSE 명령을 쓰는 할당 코드가 #UD로 멈춘다.
mov rax, cr0
and ax, 0xFFFB ; CR0.EM 끔
or ax, 0x2 ; CR0.MP 켬
mov cr0, rax
mov rax, cr4
or ax, 1 << 9 ; CR4.OSFXSR
or ax, 1 << 10 ; CR4.OSXMMEXCPT
mov cr4, rax
9. 자주 헷갈리는 지점
할당 함수를 구현해도 테스트 코드가 항상 heap을 사용하는 것은 아니다. gccgo의 escape 분석 결과에 따라 지역 변수가 스택에 배치될 수 있기 때문이다. 실제 할당 경로를 검증할 때는 결과를 전역 변수에 저장하는 등 객체가 함수 범위 밖에서도 사용되도록 구성했다.
runtime.memequal64..f 같은 함수 서술자는 함수 심볼과 다르다. 서술자는 첫 워드가 함수 진입점인 데이터라서 //go:linkname으로는 나오지 않고 따로 만들어야 한다.
growslice의 시그니처는 gccgo 버전마다 다르다. 추측하지 말고 호출부가 레지스터를 어떻게 채우는지 확인해 맞춰야 한다. gccgo 12는 (et, oldData, oldLen, oldCap, newLen)을 받아 슬라이스를 반환한다.
10. 확인 방법
| 확인 대상 | 확인 방법 |
|---|---|
| 할당기 호출 여부 | 전역 변수에 new·make 결과를 담아 heap 경로를 강제하고 동작을 본다. |
| heap 동작 | 부팅 시 new·make·append를 거치는 자체 검증을 돌리고 결과를 시리얼로 받는다. |
| SSE 활성 여부 | SSE 명령을 쓰는 할당(append 확장)이 #UD 없이 동작하는지 본다. |
go-os는 부팅 시 heap 자체 검증을 돌리고 결과를 시리얼로 보낸다. -display none -serial로 QEMU를 띄우면 화면 없이 heap self-test: OK를 확인할 수 있다.
11. 다음에 볼 개념
현재 할당기는 메모리 해제를 지원하지 않아 할당이 반복되면 heap이 고갈된다. 다음 단계에서는 해제를 지원하는 free-list allocator나 GC를 검토해야 한다. 맵과 고루틴에는 각각 맵 런타임과 스케줄러가 추가로 필요하므로 별도 단계에서 다룬다.