공부

malloc의 정렬(alignment)과 힙 오버플로우

CodingNabi 2026. 7. 11. 13:27

들어가기에 앞서 malloc에 대해 짧게 설명하자면, malloc은 힙(heap)이라는 메모리 영역에서 동적으로 메모리를 할당받는 C 표준 라이브러리 함수다.

스택 변수와 다르게, 힙 메모리는:

  • 프로그램이 런타임에 크기를 결정할 수 있음 (컴파일 타임에 크기가 고정되는 배열과 다름)
  • 명시적으로 free()를 호출하기 전까지 계속 살아있음 (함수가 끝나도 사라지지 않음)
  • 힙 관리자(heap manager)라는 별도의 소프트웨어가 이 영역을 추적하고 관리함 (OS가 직접 관리하는 게 아니라, CRT/힙 라이브러리가 OS로부터 큰 덩어리를 받아와서 잘게 쪼개 나눠주는 방식)

라느 특징을 가진다.

malloc 내부 구조

malloc(12)를 호출하면 힙 관리자는 단순히 "12바이트를 뚝 떼어서 준다"가 아니라, 그 블록을 관리하기 위한 메타데이터를 함께 만든다. 대략적인 구조는 이렇다 (구현마다 세부는 다름)

[헤더: 블록 크기, 사용중/free 여부, 이전·다음 블록 링크 등 관리정보]
[정렬용 패딩 (필요 시)]
[실제 데이터 영역: 내가 요청한 12바이트]  ← p가 가리키는 지점
[정렬용 패딩 (필요 시, 다음 블록 시작 위치 맞추기 위함)]

 

  • 헤더: 힙 관리자가 이 블록의 크기, 상태(사용중/free), free list 연결 정보 등을 기록해두는 영역. free(p)를 호출하면 힙 관리자는 p를 기준으로 앞쪽에 있는 이 헤더를 읽어서 "아, 이 블록은 몇 바이트짜리구나" 판단하고 free list에 돌려놓는다.
  • 데이터 영역: 실제로 내가 쓸 수 있는 12바이트. p는 정확히 이 영역의 시작 주소를 가리킨다.
  • 패딩: 정렬 요구사항을 맞추기 위해 붙는 여유 공간 (아래에서 자세히).

 

정렬은 항상 보장될까?

C 표준은 malloc이 반환하는 주소가 어떤 타입으로 캐스팅해도 안전한 정렬을 만족하도록 요구한다. x64에서는 보통 16바이트 정렬이다. 즉, malloc이 반환하는 주소값 자체가 항상 16의 배수가 되도록 힙 관리자가 보장해준다는 뜻이다. 이건 "요청 크기"까지 항상 정렬 단위로 올리는 건 아니다. 힙 관리자 구현 방식은 크게 두 가지로 나뉜다.

 

방식 A: Size class(bin) 기반 — glibc malloc, Windows 힙 관리자 등 대부분

작은 요청은 미리 정해진 "크기 등급"에 배정됨: 16, 24, 32, 48, 64, 96, 128, ...

malloc(12) → 가장 작은 등급(예: 16 또는 24)에 배정
malloc(20) → 다음 등급(예: 32)에 배정

정렬을 위해 올린다기보다, 메모리 단편화 방지와 관리 효율을 위해 미리 정해둔 "그릇 크기"에 담는 것. malloc(12)와 malloc(15)가 같은 등급에 들어가 _msize 결과가 동일할 수 있음.

 

방식 B: 헤더 오버헤드가 정렬 패딩 역할

[헤더 8~16바이트][요청한 데이터 영역][정렬 맞추기용 패딩]

블록마다 개별적으로 전체 크기가 정렬 단위(8/16)의 배수가 되도록 패딩을 끼워넣는 방식.

 

_msize가 알려주는 건 "물리적 크기"가 아니다

_msize는 Windows CRT가 제공하는 비표준 함수로, "이 포인터가 가리키는 힙 블록이 얼마나 크냐"를 알려주는 용도로 흔히 소개된다.

char* p = (char*)malloc(12);
printf("%d", _msize(p));

이걸 실제로 Release 모드에서 돌려보면 12가 그대로 나온다. (Debug 모드에서도 12가 나온다.)

 

왜 물리적으로는 더 크게 잡았을 수 있는데 _msize는 12를 돌려주는가?

 

Windows 힙 관리자는 블록마다 다음 두 가지를 별도로 추적한다:

  1. 실제 물리적으로 확보한 블록 크기 (정렬/size class 때문에 12보다 클 수 있음, 예: 16)
  2. UnusedBytes — "이 블록에서 실제로 안 쓰이는 뒷부분이 몇 바이트인지"를 정확히 기록

그리고 _msize(내부적으로 HeapSize API 호출)는 다음과 같이 계산해서 반환한다:

 
usable_size = 물리적_확보_크기 - UnusedBytes
            = 16 - 4
            = 12   ← 요청한 크기 그대로 나옴

결론

_msize는 물리적 블록 크기가 아니라, 힙 관리자가 별도로 계산해서 알려주는 "사용 가능한(usable) 크기"를 반환한다. 이 값은 대체로 요청한 크기와 동일하게 나오며, 내부 패딩량을 반영하지 않는다. 즉, _msize로는 "실제 물리적으로 몇 바이트가 확보되어 있는지" 알아낼 방법이 없다. 이건 힙 구현이 완전히 캡슐화해서 감춰버린 정보다.

 

만약 12바이트를 할당받고 13바이트를 쓴다면?

직관적으론  "12바이트는 정리되고 1바이트만 쓰레기값으로 남는다"라고 생각할 수 있겠지만, 앞서 말했듯  malloc(12)를 호출하면 힙 관리자는 단순히 "12바이트를 뚝 떼어서 준다"가 아니라, 그 블록을 관리하기 위한 메타데이터를 함께 만든다.따라서 넘어간 바이트가 어디를 침범했냐에 따라 결과가 달라진다.

넘어간 위치 free(p) 시점 나중 결과
정렬/size-class 여유 공간 내부 (내 블록 소유) 문제없음 우연히 안전 — 재현 안 되는 시한폭탄
옆 블록 헤더 (메타데이터) 문제없이 통과 가능 옆 블록 malloc/free 시 크래시·힙 손상 에러
옆 블록 데이터 (사용 중) 문제없음 그 데이터를 쓰는 코드에서 값 오염 발견 — 조용한 논리 버그

어느 경우든 오버플로우가 발생한 순간에는 아무 에러도 나지 않는다. 버그를 심는 시점과 증상이 터지는 시점이 완전히 분리되어 디버깅이 매우 어렵다. 앞서 본 것처럼 _msize로도 이 여유 공간의 존재 여부를 미리 알 방법이 없으니, "여유 공간이 있으니 조금 넘쳐도 괜찮겠지"라는 가정 자체가 애초에 성립하지 않는다.