크래프톤 정글/정글에서 문제풀기

Malloc Lab을 활용한 Implicit Free List 구현기

cedis 2026. 4. 13. 15:54

 

COMPUTER SYSTEMS DEEP GUIDE
malloc lab의 mm.c를 교재처럼 읽기
이번 글은 malloc lab 구현 회고가 아니라,
현재 allocator 코드가 힙을 어떤 규칙으로 읽고 갱신하는지를 함수 단위로 해설하는 공부 글이다.
Implicit Free List Boundary Tag First Fit Split Coalesce

왜 allocator는 함수 셋보다 메모리 규칙 묶음처럼 읽어야 하는가

지금 구현한 allocator는 implicit free list 기반의 비교적 단순한 구조다. 하지만 이 코드만 제대로 읽어도 힙 경계, 블록 레이아웃, 포인터 산술, free 이후 병합, realloc의 비용 같은 시스템 개념이 한 번에 연결된다. 그래서 이 글은 내 코드가 무엇을 하고 있는지를 따라가면서, allocator를 시스템 구조로 이해하는 데 초점을 둔다.

먼저, malloc lab은 무슨 과제인가

malloc lab은 “메모리를 달라 하면 주고, 돌려주면 다시 합치고, 재할당도 처리하는 작은 동적 메모리 할당기”를 직접 만드는 과제다. 드라이버는 우리가 작성한 mm.c를 호출하면서 mm_init, mm_malloc, mm_free, mm_realloc가 제대로 동작하는지 검사한다. 즉 이 글에서 보는 코드는 독립적인 유틸 함수 모음이 아니라, malloc 패키지의 핵심 함수 자리에 들어가는 제출 코드다.

드라이버
mdriver
제출 파일
mm.c
heap 모델
memlib
handout 기준으로 보면
원전 handout도 같은 구조를 전제로 한다. 학생이 실제로 수정하는 파일은 mm.c 하나이고, mdriver가 trace file을 흘려 보내면서 동작을 검증한다. 보통은 short1-bal.rep, short2-bal.rep 같은 작은 trace부터 시작해서 블록 배치와 병합이 맞는지 확인한 뒤, 기본 trace 묶음으로 넘어간다.
지금 읽는 코드는 파일 안에서 어디를 맡고 있나
함수 과제에서 맡는 역할 이 글에서 보는 포인트
mm_init 힙의 시작 상태를 만든다 프롤로그, 에필로그, 첫 free block이 왜 필요한지
mm_malloc 요청 크기를 받아 블록을 할당한다 사용자 요청이 내부 블록 크기로 어떻게 번역되는지
mm_free 할당 블록을 다시 free block으로 돌린다 header/footer를 free로 바꾼 뒤 왜 곧바로 병합하는지
mm_realloc 블록 크기를 다시 조정한다 현재 구현이 어디까지 단순화돼 있고 어디가 검토 포인트인지

이 글은 파일 전체를 한꺼번에 해설하는 대신, allocator를 이해하는 데 가장 중요한 함수 경로를 따라간다. 그래서 매크로 -> 초기화 -> malloc -> free/coalesce -> realloc 순서로 읽는다.

이번 글에서 다루는 것
1. 이 allocator가 선택한 기본 모델과 그 의미
2. 매크로 계층이 어떻게 allocator의 공용 문법이 되는지
3. mm_initextend_heap가 힙 경계를 어떻게 세우는지
4. mm_malloc, find_fit, place가 실제 할당을 어떻게 구성하는지
5. mm_free, coalesce, mm_realloc가 왜 allocator의 핵심 학습 포인트인지

먼저 짚고 갈 용어

묵시적 가용 리스트(implicit free list) free block을 별도 연결 리스트로 관리하지 않고, 힙에 있는 블록 전체를 순회하며 가용 블록을 찾는 구조다.
경계 태그(boundary tag) 헤더와 풋터에 크기/할당 비트를 같이 둬서 현재 블록뿐 아니라 이전 블록의 크기도 역방향으로 복원할 수 있게 만드는 구조다.
프롤로그 / 에필로그 힙의 시작과 끝을 안전하게 다루기 위한 경계 블록이다. allocator 구현에서 예외 처리를 줄이는 핵심 장치다.

이 코드가 택한 allocator 모델

지금 구현은 explicit free list나 segregated free list가 아니라, implicit free list + boundary tag + first-fit + split + coalesce 조합으로 되어 있다. 이 선택은 성능 최적화보다는 구조 이해를 우선한 셈이다. free block을 따로 체인으로 연결하지 않으므로, allocator가 힙을 어떻게 훑고 블록을 어떻게 다시 해석하는지가 더 직접적으로 보인다.

일반 원리와 현재 구현의 선택은 분리해서 봐야 한다
allocator 일반 원리는 블록 메타데이터를 읽고, free 공간을 재사용하고, 필요하면 병합한다는 쪽이다. 반면 implicit free list, first-fit, 즉시 coalesce, naive realloc은 지금 구현이 택한 정책이다. 이 글은 둘을 함께 설명하지만, 현재 코드의 선택을 allocator의 유일한 정답처럼 밀어붙이진 않으려 한다.
공부 관점의 장점
블록 구조, 헤더/풋터, 다음 블록과 이전 블록 계산이 allocator의 중심에 놓인다. 그래서 포인터와 메모리 레이아웃을 같이 보기 좋다.
실전 관점의 한계
free block 탐색 시 힙을 처음부터 훑어야 하므로, 큰 trace에서는 탐색 비용이 빠르게 커질 수 있다.

매크로가 사실상 allocator의 언어다

이 파일에서 가장 중요한 코드는 함수보다 먼저 매크로다. PACK, GET, PUT, GET_SIZE, GET_ALLOC, HDRP, FTRP, NEXT_BLKP, PREV_BLKP는 allocator 전체가 공통으로 쓰는 문법이다. 나중에 함수가 아무리 길어져도 결국은 “이 주소의 헤더를 읽고, 그 크기만큼 이동하고, 할당 비트를 바꾸는 일”을 반복한다.

블록을 그림으로 보면
Header
Payload
Footer
헤더와 풋터에는 size | alloc이 들어가고, payload 포인터인 bp를 기준으로 앞뒤 메타데이터 위치를 다시 계산한다.
#define PACK(size, alloc)  ((size) | (alloc))
#define GET(p)       (*(unsigned int *)(p))
#define PUT(p, val)  (*(unsigned int *)(p) = (val))

#define GET_SIZE(p)  (GET(p) & ~0x7)
#define GET_ALLOC(p) (GET(p) & 0x1)

#define HDRP(bp)  ((char *)(bp) - WSIZE)
#define FTRP(bp)  ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE)
#define NEXT_BLKP(bp)  ((char *)(bp) + GET_SIZE(((char *)(bp) - WSIZE)))
#define PREV_BLKP(bp)  ((char *)(bp) - GET_SIZE(((char *)(bp) - DSIZE)))
코드 바로 옆에서 보면
payload 포인터
bp
- WSIZE
header 위치
HDRP(bp)
payload 포인터
bp
+ size - DSIZE
footer 위치
FTRP(bp)
이 매크로들은 새 계산을 만드는 게 아니라, 이미 메모리에 적어둔 size|alloc 정보를 읽어서 현재 블록의 앞뒤 경계를 다시 찾아간다.

예를 들어 HDRP(bp)는 payload 시작 위치에서 4바이트 뒤로 가 헤더를 읽는다. NEXT_BLKP(bp)는 현재 블록 헤더의 size를 읽어서 그만큼 앞으로 이동한다. 즉 allocator는 포인터를 무작정 더하고 빼는 코드가 아니라, 메모리 안에 저장해둔 size와 alloc 비트를 해석하면서 움직이는 코드다.

mm_init과 extend_heap은 힙의 경계를 세우는 함수다

초기화는 “메모리를 조금 확보한다”는 수준에서 끝나지 않는다. 현재 구현은 먼저 정렬 패딩, 프롤로그 헤더/풋터, 에필로그 헤더를 만들고, 그 다음 extend_heap으로 첫 free block을 추가한다. 이 구조가 있어야 힙의 시작과 끝을 예외 없이 다룰 수 있다.

if ((heap_listp = mem_sbrk(4 * WSIZE)) == (void *)-1)
    return -1;

PUT(heap_listp, 0);
PUT(heap_listp + (1 * WSIZE), PACK(DSIZE, 1));
PUT(heap_listp + (2 * WSIZE), PACK(DSIZE, 1));
PUT(heap_listp + (3 * WSIZE), PACK(0, 1));
heap_listp += (2 * WSIZE);

if (extend_heap(CHUNKSIZE / WSIZE) == NULL)
    return -1;
초기 힙 배치를 그림으로 보면
padding
prologue
header/footer
첫 free block
extend_heap 후
epilogue
핵심은 힙을 그냥 늘리는 게 아니라, 시작과 끝을 항상 allocator가 읽을 수 있는 블록 문법 안에 두는 것이다.
정렬 패딩
힙 시작점의 alignment를 맞춘다.
프롤로그 블록
항상 할당된 작은 블록처럼 둬서 앞쪽 경계를 안전하게 만든다.
에필로그 헤더
크기 0의 가짜 블록으로 두어 순회의 종료점을 만든다.

extend_heap도 같은 이유로 중요하다. 새 free block의 헤더/풋터를 쓰고, 바로 뒤에 새 에필로그를 다시 세운다. 즉 힙 확장은 메모리를 길게 만드는 작업이 아니라, 확장된 영역을 allocator의 블록 문법 안으로 편입시키는 작업이다.

malloc 경로를 코드 그대로 따라가 보기

현재 mm_malloc의 흐름은 네 단계다. 요청 크기를 조정하고, 적당한 free block을 찾고, 배치하고, 없으면 힙을 늘린다. 여기서 중요한 건 “요청 크기”와 “실제 블록 크기”를 구분해 보는 것이다.

if (size == 0)
    return NULL;

if (size <= DSIZE)
    asize = 2 * DSIZE;
else
    asize = DSIZE * ((size + (DSIZE) + (DSIZE - 1)) / DSIZE);

if ((bp = find_fit(asize)) != NULL) {
    place(bp, asize);
    return bp;
}

extendsize = MAX(asize, CHUNKSIZE);
if ((bp = extend_heap(extendsize / WSIZE)) == NULL)
    return NULL;

place(bp, asize);
malloc 경로를 단계 그림으로 붙이면
요청 size
asize 계산
find_fit
place / 없으면 extend
여기서 가장 중요한 번역은 `사용자 요청 크기`가 곧바로 블록 크기가 되지 않는다는 점이다. allocator는 중간에 반드시 `asize`라는 내부 블록 단위로 바꿔 읽는다.
예시: mm_malloc(24)를 요청하면
1단계
사용자가 원하는 payload는 24바이트다.
2단계
헤더/풋터와 alignment를 감안하면 asize = 32가 된다.
3단계
first-fit으로 32바이트 이상인 free block을 찾는다.
4단계
예를 들어 4096바이트 free block을 찾았다면, 앞 32바이트는 할당 블록으로 쓰고 나머지 4064바이트는 다시 free block으로 남긴다.
시점 블록 상태 여기서 읽어야 하는 것
할당 전 [4096/0] 힙 안의 큰 free block 하나가 allocator의 출발점이다.
앞부분 배치 후 [32/1] 24바이트 요청이 내부적으로는 32바이트 블록 하나로 번역된다.
남은 조각 [4064/0] 남은 공간도 그냥 찌꺼기가 아니라, 다음 find_fit이 다시 읽을 free block으로 남는다.

여기서 allocator 공부의 핵심이 드러난다. 사용자는 “24바이트”를 원했지만, allocator는 그 요청을 32바이트짜리 블록 하나로 변환해 받아들인다. 즉 allocator는 raw bytes를 직접 다루는 함수가 아니라, 사용자 요청을 내부 블록 단위로 번역하는 함수에 가깝다.

find_fit과 place는 왜 항상 같이 봐야 하는가

find_fit은 free block을 찾고, place는 찾은 block을 실제로 나누어 쓰는 함수다. 이 둘은 따로 보면 단순하지만, allocator 관점에서는 사실 한 세트다. “어디를 선택할 것인가”와 “선택한 공간을 어떤 모양으로 다시 쓸 것인가”가 같이 정해져야 하기 때문이다.

for (bp = heap_listp; GET_SIZE(HDRP(bp)) > 0; bp = NEXT_BLKP(bp)) {
    if (!GET_ALLOC(HDRP(bp)) && (asize <= GET_SIZE(HDRP(bp)))) {
        return bp;
    }
}
if ((csize - asize) >= (2 * DSIZE)) {
    PUT(HDRP(bp), PACK(asize, 1));
    PUT(FTRP(bp), PACK(asize, 1));

    bp = NEXT_BLKP(bp);
    PUT(HDRP(bp), PACK(csize - asize, 0));
    PUT(FTRP(bp), PACK(csize - asize, 0));
} else {
    PUT(HDRP(bp), PACK(csize, 1));
    PUT(FTRP(bp), PACK(csize, 1));
}
split 장면을 바로 옆에서 보면
선택된 free block
csize
할당
asize
남은 free
csize-asize
`place`는 단순히 alloc 비트만 1로 바꾸는 함수가 아니다. 큰 free block 하나를 `할당 블록 + 계속 관리할 free block` 두 개의 문법으로 다시 써 넣는 함수다.
함수 하는 일 이 코드가 보여주는 감각
find_fit free block을 찾는다 implicit free list에서는 탐색이 힙 순회와 같다
place 선택한 block을 할당하고 필요하면 split한다 남는 조각도 allocator가 계속 관리해야 할 블록이다

여기서 자주 헷갈리는 지점은 split 조건이다. 남는 공간이 너무 작으면 새 free block으로 남겨도 의미가 없다. 그래서 현재 구현은 (csize - asize) >= 2 * DSIZE일 때만 분할한다. 즉 allocator는 모든 여유 공간을 잘게 쪼개는 게 아니라, 다시 유효한 블록이 될 수 있는 조각만 남긴다.

free와 coalesce는 메모리를 다시 시스템 안으로 돌려놓는 과정이다

mm_free는 짧지만 allocator 공부에서는 가장 중요한 함수 중 하나다. 메모리를 “없앰”이 아니라, 다음 할당에서 다시 해석 가능한 free block으로 복원하는 역할을 하기 때문이다. 헤더/풋터를 free로 바꾸고, 바로 coalesce로 넘긴다.

size_t size = GET_SIZE(HDRP(bp));

PUT(HDRP(bp), PACK(size, 0));
PUT(FTRP(bp), PACK(size, 0));
coalesce(bp);
free 직후 일어나는 변화를 그림으로 붙이면
allocated block
header/footer만 free로 변경
coalesce로 인접 free와 병합
즉 free는 “끝”이 아니라 “이 블록을 힙 안에서 다시 쓸 수 있는 상태로 복구하고, 필요하면 주변 free block과 합쳐 단편화를 줄이는 시작점”이다.

coalesce는 앞뒤 블록 상태를 읽고 네 케이스로 나눈다. 이 부분이 boundary tag의 진짜 용도를 보여 준다. 이전 블록의 상태를 알아야 하는데, 그걸 위해 현재 블록 바로 앞의 메모리에서 이전 블록의 풋터를 읽는다.

Case 1
앞뒤 모두 할당 상태. 병합할 필요가 없다.
Case 2
다음 블록만 free. 현재 블록을 뒤로 확장한다.
Case 3
이전 블록만 free. 앞 블록으로 병합한 뒤 포인터를 옮긴다.
Case 4
앞뒤 모두 free. 세 블록을 하나로 합친다.
Case 3을 실제 장면으로 그리면
prev free
[64/0]
|
current free
[32/0]
|
next alloc
[48/1]
↓ 병합 후
merged free
[96/0]
|
next alloc
[48/1]
이 케이스에서 중요한 건 size를 더하는 것만이 아니다. 병합 결과의 시작점이 앞 블록으로 바뀌므로, bp = PREV_BLKP(bp)처럼 포인터 기준도 같이 옮겨야 한다.
왜 이전 블록의 풋터를 읽는가
현재 블록 포인터 bp만 있을 때, 이전 블록 헤더가 정확히 어디 있는지 바로 알 수는 없다.
하지만 이전 블록의 풋터는 현재 블록 바로 앞쪽에 있기 때문에, 거기서 이전 블록 크기를 읽을 수 있다.
그래서 boundary tag는 단순 중복 저장이 아니라, 역방향 병합을 가능하게 만드는 구조다.

현재 realloc 구현은 단순하지만, 그대로 미화하면 안 된다

mm_realloc은 새 블록을 할당하고, 기존 데이터를 복사한 뒤, old block을 free하는 구조다. 큰 흐름만 보면 allocator 규칙을 재사용하는 단순한 구현이다. 다만 이 섹션은 “정리 잘 된 realloc”을 칭찬하는 부분이 아니라, 현재 구현이 어디까지는 분명하고 어디부터는 다시 검토해야 하는지를 읽는 부분이어야 한다.

newptr = mm_malloc(size);
if (newptr == NULL)
    return NULL;

copySize = GET_SIZE(HDRP(oldptr)) - WSIZE;
if (size < copySize)
    copySize = size;

memcpy(newptr, oldptr, copySize);
mm_free(oldptr);
naive realloc의 실제 움직임
old block
새 block 할당
payload 복사
old block free
성능은 단순할 수 있어도 구조는 명확하다. realloc도 독립 기능이 아니라 `malloc + metadata 해석 + free` 조합으로 이해할 수 있다.
여기서 바로 의심해야 하는 줄
copySize = GET_SIZE(HDRP(oldptr)) - WSIZE;는 “현재 구현이 이렇게 쓰고 있다”는 사실이지, 바로 옳은 payload 계산식이라고 미화할 부분은 아니다. 블록 크기에는 헤더와 풋터가 함께 들어가므로, 이 줄은 footer 영역까지 걸칠 수 있는지 다시 점검해야 한다. 즉 이 코드는 realloc의 큰 경로는 보여주지만, 복사 길이 계산은 재검토 대상으로 읽는 편이 정확하다.

그래서 이 섹션에서 가져가야 할 교훈은 “realloc도 메타데이터를 읽는다” 정도이지, 현재 구현의 세부 식이 이미 잘 정리됐다고 받아들이는 것은 아니다. 교재형으로 읽는다면 오히려 이런 줄에서 멈춰서 어디가 일반 원리이고 어디가 현재 코드의 검토 포인트인지를 구분해야 한다.

이 구현으로부터 바로 읽히는 한계

현재 선택 지금 단계의 장점 다음 단계에서 개선할 지점
Implicit free list 구조가 직관적이고 설명하기 쉽다 탐색 비용을 줄이려면 explicit free list나 segregated list가 필요하다
First fit 구현이 단순하고 trace 추적이 쉽다 fragmentation과 배치 품질을 더 고민해야 한다
즉시 coalesce free 직후 구조가 정리돼 이해하기 쉽다 trace에 따라 지연 병합 전략과 비교해볼 수 있다
Naive realloc allocator 전체 규칙을 재사용한다 인접 free block을 이용한 제자리 확장 최적화가 없다

즉 지금 코드는 “좋은 allocator의 최종형”보다는, 현재 구현의 정책과 allocator 일반 원리를 분리해서 읽는 기준점에 더 가깝다. 성능 개선은 그 다음 문제고, 지금 단계의 핵심은 힙과 블록을 손으로 설명할 수 있게 되는 것이다.

직접 해볼 실습

실습 1. malloc(24)를 손으로 추적해 보기
종이에 다음 네 단계만 적어 본다.
  • 요청 크기 24바이트를 내부 블록 크기 asize로 바꾼다.
  • 현재 free block 하나를 가정하고 first-fit이 어디를 잡을지 표시한다.
  • place 이후 헤더/풋터가 어떻게 바뀌는지 적는다.
  • 남은 블록이 split될 조건을 같이 써 본다.
관찰 포인트
사용자 요청 24바이트가 왜 내부에서는 32바이트 블록이 되는지 설명할 수 있어야 한다.
실습 2. coalesce 네 케이스를 직접 그려 보기
세 개의 연속 블록을 그리고, 가운데 블록을 free한다고 가정한 뒤 앞뒤 상태를 각각 바꿔 본다.
  • 앞 allocated / 뒤 allocated
  • 앞 allocated / 뒤 free
  • 앞 free / 뒤 allocated
  • 앞 free / 뒤 free
관찰 포인트
왜 이전 블록 병합에는 풋터가 필요하고, 다음 블록 병합에는 헤더가 바로 쓰이는지 말로 설명해 본다.
이번 글에서 기억할 것
  • allocator를 읽을 때는 함수 이름보다 블록 규칙부터 봐야 한다.
  • 헤더/풋터와 boundary tag는 free block 병합을 가능하게 만드는 핵심 장치다.
  • mm_malloc은 사용자 요청을 내부 블록 크기로 번역하는 함수다.
  • find_fitplace는 탐색과 배치를 같이 설명해야 의미가 선명해진다.
  • mm_realloc도 결국은 블록 메타데이터를 다시 읽는 함수다.
스스로 점검
1. 왜 사용자 요청 24바이트가 내부에서 24바이트 블록으로 끝나지 않는가?
2. 이전 블록 병합에서는 왜 풋터가 중요하고, 다음 블록 병합에서는 왜 헤더가 충분한가?
3. explicit free list로 바꾸면 어떤 비용은 줄고 어떤 관리 책임은 늘어나는가?
4. 지금 realloc이 단순하다는 건 정확히 어떤 최적화를 아직 하지 않았다는 뜻인가?
원전 실습을 같이 보고 싶다면

이 글은 현재 구현을 교재처럼 푼 버전이지만, malloc lab 자체를 같이 보고 읽으면 맥락이 더 잘 잡힌다. 특히 과제 구조, trace 기반 평가, 더 나아간 explicit list나 성능 해석은 원전 자료를 같이 보는 편이 좋다.

한 줄 정리

지금 mm.c는 빠른 allocator라기보다, 힙과 블록을 어떤 규칙으로 읽어야 하는지 몸으로 익히게 해 주는 작은 시스템 교재에 가깝다.