스택 세그먼트는 함수 호출 메커니즘과 강하게 결합된다. 스택 세그먼트 공간의 할당과 해제는 힙 세그먼트에 비해 빠르게 일어나며 메모리 관리가 자동적으로 수행된다. 하지만 스택에는 큰 객체를 저장할 수 없고, 내용을 잘못 사용 시 충돌이 발생할 수 있기에 조심히 사용해야 한다.
코드 박스 5-1 버퍼 오버플로 상황: 스택의 내용을 덮어 쓰는 strcpy 함수
#include <string.h>
int main(int argc, char** argv) {
char str[10];
strcpy(str, "asjksgjdgkdsfhgsdgsigergubfdgsbdfgkrdsgy");
return 0;
}
위 코드에서 strcpy 함수는 스택의 내용을 덮어 쓴다. 이를 스택 스매싱(stack smashing)이라고도 한다. str 배열은 10바이트의 공간이 할당되었지만 strcpy는 이 경계를 넘어 스택 프레임을 변조시킨다.
스택 세그먼트는 소유자 프로세스만의 전용 메모리(private memory)이다. 그러므로 읽거나 쓰기 위해 스택을 소유하는 프로세스에 속해야 한다. 이러한 동작을 위해 디버거를 사용한다.
코드 박스 5-2 [예제 5-1] 스택의 가장 위에 할당된 배열을 선언하기
#include <stdio.h>
int main(int argc, char** argv) {
char arr[4];
arr[0] = 'A';
arr[1] = 'B';
arr[2] = 'C';
arr[3] = 'D';
return 0;
}
셀 박스 5-1 디버그 옵션 -g로 [예제 5-1] 컴파일하기
$ gcc -g 5_1.c -o 5_1_dbg.out
셀 박스 5-2 -g 옵션이 있거나 없는, 출력된 실행 가능한 목적 파일의 크기
$ gcc 5_1.c -o 5_1.out
$ ls -al 5_1.out 5_1_dbg.out
-rwxr-xr-x 1 yush1nk1m yush1nk1m 15960 Aug 31 15:22 5_1.out
-rwxr-xr-x 1 yush1nk1m yush1nk1m 17224 Aug 31 15:21 5_1_dbg.out
컴파일러에 -g 옵션 전달 시 실행 가능한 목적 파일에 디버깅 정보가 삽입된다.
셀 박스 5-3 [예제 5-1]의 디버거를 시작하기
$ gdb 5_1_dbg.out
셀 박스 5-4 실행한 뒤 디버거의 결과
$ gdb 5_1_dbg.out
GNU gdb (Ubuntu 17.1-2ubuntu1) 17.1
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from 5_1_dbg.out...
(gdb)
디버거에 입력 값으로 지정된 실행 가능한 목적 파일을 실행하기 위해 명령어 r 또는 run을 사용한다.
셀 박스 5-5 run 명령어를 전달한 뒤 디버거의 출력
(gdb) run
Starting program: /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_1_dbg.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
[Inferior 1 (process 36370) exited normally]
중단점(breakpoint)은 gdb가 프로그램의 실행을 멈추고 나중의 명령어를 기다리도록 하는 표시(indicator)이다. b 또는 break 명령어를 사용해 중단점을 설정할 수 있다.
셀 박스 5-6 gdb에서 main 함수의 중단점 설정하기
(gdb) break main
Breakpoint 1 at 0x55555555515c: file 5_1.c, line 3.
셀 박스 5-7 중단점을 설정한 후 프로그램 다시 실행하기
(gdb) r
Starting program: /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_1_dbg.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main (argc=1, argv=0x7fffffffd8c8) at 5_1.c:3
3 int main(int argc, char** argv) {
코드의 다음 행을 실행한 후 멈추기 위해 n 또는 next 명령어를 사용한다.
셀 박스 5-8 코드의 다음 행을 실행하는 n(또는 next) 명령어 사용하기
(gdb) n
5 arr[0] = 'A';
(gdb) n
6 arr[1] = 'B';
(gdb) next
7 arr[2] = 'C';
(gdb) next
8 arr[3] = 'D';
(gdb) next
9 return 0;
디버거에 print arr 명령어를 입력하면 배열 내용이 문자열로 출력된다.
셀 박스 5-9 gdb로 arr 배열의 내용 프린트하기
(gdb) print arr
$1 = "ABCD"
배열 arr에 할당된 메모리의 내용을 프린트하며 스택 세그먼트의 내용을 확인할 수 있다.
셀 박스 5-10 배열 arr로 시작하는 메모리의 바이트 프린트하기
(gdb) x/4b arr
0x7fffffffd784: 65 66 67 68
(gdb) x/8b arr
0x7fffffffd784: 65 66 67 68 0 16 68 -112
명령어 x/4b arr는 arr이 가리키는 지점으로부터 4바이트를 나타내며, x/8b는 8바이트를 나타낸다. 배열의 크기가 4바이트이기 때문에 두 번째 출력의 경우 배열에 할당된 스택 세그먼트 경계 너머에 있는 main 함수에 관한 스택 프레임의 데이터를 나타낸다. 스택 세그먼트는 다른 세그먼트와 달리 큰 주소부터 데이터가 채워지기 때문에 더 큰 주소 값에 대응되는 영역에는 main 함수의 스택 프레임이 저장되어 있다.
셀 박스 5-11 set 명령어로 배열의 개별 바이트 변경하기
(gdb) x/4b arr
0x7fffffffd784: 65 66 67 68
(gdb) set arr[1] = 'F'
(gdb) x/4b arr
0x7fffffffd784: 65 70 67 68
(gdb) print arr
$2 = "AFCD"
셀 박스 5-12 배열의 경계 바깥에 있는 개별 바이트 변경하기
(gdb) x/20x arr
0x7fffffffd784: 0x41 0x46 0x43 0x44 0x00 0x10 0x44 0x90
0x7fffffffd78c: 0xf8 0x7c 0x65 0x6b 0x40 0xd8 0xff 0xff
0x7fffffffd794: 0xff 0x7f 0x00 0x00
(gdb) set *(0x7fffffffd790) = 0xff
(gdb) x/20x arr
0x7fffffffd784: 0x41 0x46 0x43 0x44 0x00 0x10 0x44 0x90
0x7fffffffd78c: 0xf8 0x7c 0x65 0x6b 0xff 0x00 0x00 0x00
0x7fffffffd794: 0xff 0x7f 0x00 0x00
c 또는 continue 명령어를 사용해 gdb에서 프로세스를 계속해서 실행하면 스택 방어 메커니즘에 의해 프로그램 실행이 중단된다.
셀 박스 5-13 스택의 중요 바이트를 변경하면 프로세스가 종료됨
(gdb) c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7000000 in ?? ()
q 또는 quit 명령어를 사용해 gdb를 종료한다.
스택 최상단에 할당된 버퍼(buffer)에 확인되지 않은 값(unchecked value)을 작성하는 것은 취약점(vulnerability)으로 간주된다. 이는 버퍼 오버플로(buffer overflow) 공격에 의한 익스플로잇(exploit)이라고 한다.
코드 박스 5-3 버퍼 오버플로 취약성을 나타내는 프로그램
#include <string.h>
int main(int argc, char** argv) {
char str[10];
strcpy(str, argv[1]);
printf("Hello %s!\n", str);
}
str의 크기를 초과하는 실행 인자는 충돌을 유발할 수 있다.
스택 세그먼트의 각 스택 변수는 고유한 스코프(scope)에 의해 수명이 결정된다. 스택 변수 선언 시마다 스택 세그먼트의 최상단에 변수가 할당되며, 이 변수는 언젠가 함수 반환에 의해 팝아웃되며 메모리 해제와 같은 효과가 발생한다.
코드 박스 5-4 스택의 맨 위에 변수 하나를 선언하기
int main(int argc, char** argv) {
int a;
...
return 0;
}
지역 변수 a는 main 함수의 스코프에 속한다.
코드 박스 5-5 [예제 5-2] 스택의 가장 윗부분에서 변수를 선언하기
int* get_integer() {
int var = 10;
return &var;
}
int main(int argc, char** argv) {
int* ptr = get_integer();
*ptr = 5;
return 0;
}
셀 박스 5-14 리눅스에서 [예제 5-2] 컴파일하기
$ gcc 5_2.c -o 5_2.out
5_2.c: In function ‘get_integer’:
5_2.c:3:12: warning: function returns address of local variable [-Wreturn-local-addr]
3 | return &var;
| ^~~~
셀 박스 5-15 리눅스에서 [예제 5-2] 실행하기
$ ./5_2.out
Segmentation fault ./5_2.out
지역 변수의 주소를 반환하는 문제는 컴파일 시점에 식별되며, 실행 시점에는 충돌을 발생시킨다.
셀 박스 5-16 디버거에서 [예제 5-2] 실행하기
$ gcc -g 5_2.c -o 5_2_dbg.out
5_2.c: In function ‘get_integer’:
5_2.c:3:12: warning: function returns address of local variable [-Wreturn-local-addr]
3 | return &var;
| ^~~~
$ gdb 5_2_dbg.out
GNU gdb (Ubuntu 17.1-2ubuntu1) 17.1
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from 5_2_dbg.out...
(gdb) run
Starting program: /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_2_dbg.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
Program received signal SIGSEGV, Segmentation fault.
0x00005555555551a6 in main (argc=1, argv=0x7fffffffd8c8) at 5_2.c:8
8 *ptr = 5;
(gdb) quit
get_integer 함수에 의해 반환된 포인터는 허상 포인터이며, 이에 대한 역참조 연산이 충돌을 유발한다.
스택 세그먼트의 특성은 다음과 같다.
힙 세그먼트의 특성은 다음과 같다.
malloc 계열의 함수 호출을 통해 힙 메모리 블록을 하나씩 획득해야 한다.힙 메모리 관리를 위해 일련의 함수나 stdlib.h에 정의된 C 표준 라이브러리 API를 사용해야 한다. 할당 함수는 malloc, calloc, realloc이 있고, 해제 함수는 free만이 유일하다.
코드 박스 5-6 [예제 5-3] 힙 메모리 블록 2개를 할당한 이후의 메모리 매핑
#include <stdio.h> // header for printf function
#include <stdlib.h> // header for heap memory functions of C library
void print_mem_maps() {
#ifdef __linux__
FILE* fd = fopen("/proc/self/maps", "r");
if (!fd) {
printf("Could not open maps file.\n");
exit(1);
}
char line[1024];
while (!feof(fd)) {
fgets(line, 1024, fd);
printf("> %s", line);
}
fclose(fd);
#endif
}
int main(int argc, char** argv) {
// allocate 10 bytes without initialization
char* ptr1 = (char*) malloc(10 * sizeof(char));
printf("Address of ptr1: %p\n", (void*) &ptr1);
printf("Memory allocated by malloc at %p: ", (void*) ptr1);
for (int i = 0; i < 10; ++i) {
printf("0x%02x ", (unsigned char) ptr1[i]);
}
printf("\n");
// allocate 10 bytes initialized by 0
char* ptr2 = (char*) calloc(10, sizeof(char));
printf("Address of ptr2: %p\n", (void*) &ptr2);
printf("Memory allocated by calloc at %p: ", (void*) ptr2);
for (int i = 0; i < 10; ++i) {
printf("0x%02x ", (unsigned char) ptr2[i]);
}
printf("\n");
print_mem_maps();
free(ptr1);
free(ptr2);
return 0;
}
__linux__ 매크로는 대부분의 유닉스 계열 운영체제에 정의되어 있어 전처리 시 print_mem_maps 함수 내부의 코드가 추가된다.
셀 박스 5-17 리눅스에서 [예제 5-3]을 실행한 결과
$ gcc 5_3.c -o 5_3.out
$ ./5_3.out
Address of ptr1: 0x7ffc0a994f58
Memory allocated by malloc at 0x6320a0180010: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Address of ptr2: 0x7ffc0a994f60
Memory allocated by calloc at 0x6320a0180440: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
> 632099fc6000-632099fc7000 r--p 00000000 08:30 59915 /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_3.out
> 632099fc7000-632099fc8000 r-xp 00001000 08:30 59915 /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_3.out
> 632099fc8000-632099fc9000 r--p 00002000 08:30 59915 /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_3.out
> 632099fc9000-632099fca000 r--p 00002000 08:30 59915 /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_3.out
> 632099fca000-632099fcb000 rw-p 00003000 08:30 59915 /home/yush1nk1m/Study/Study_C/ExtremeC/chapter05/5_3.out
> 6320a0180000-6320a01a1000 rw-p 00000000 00:00 0 [heap]
> 79ec4ac00000-79ec4ac28000 r--p 00000000 08:30 14143 /usr/lib/x86_64-linux-gnu/libc.so.6
> 79ec4ac28000-79ec4adc0000 r-xp 00028000 08:30 14143 /usr/lib/x86_64-linux-gnu/libc.so.6
> 79ec4adc0000-79ec4ae0e000 r--p 001c0000 08:30 14143 /usr/lib/x86_64-linux-gnu/libc.so.6
> 79ec4ae0e000-79ec4ae12000 r--p 0020d000 08:30 14143 /usr/lib/x86_64-linux-gnu/libc.so.6
> 79ec4ae12000-79ec4ae14000 rw-p 00211000 08:30 14143 /usr/lib/x86_64-linux-gnu/libc.so.6
> 79ec4ae14000-79ec4ae21000 rw-p 00000000 00:00 0
> 79ec4aff9000-79ec4affc000 rw-p 00000000 00:00 0
> 79ec4b001000-79ec4b003000 rw-p 00000000 00:00 0
> 79ec4b003000-79ec4b007000 r--p 00000000 00:00 0 [vvar]
> 79ec4b007000-79ec4b009000 r--p 00000000 00:00 0 [vvar_vclock]
> 79ec4b009000-79ec4b00b000 r-xp 00000000 00:00 0 [vdso]
> 79ec4b00b000-79ec4b00c000 r--p 00000000 08:30 13898 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
> 79ec4b00c000-79ec4b03b000 r-xp 00001000 08:30 13898 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
> 79ec4b03b000-79ec4b046000 r--p 00030000 08:30 13898 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
> 79ec4b046000-79ec4b048000 r--p 0003b000 08:30 13898 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
> 79ec4b048000-79ec4b049000 rw-p 0003d000 08:30 13898 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
> 79ec4b049000-79ec4b04a000 rw-p 00000000 00:00 0
> 7ffc0a976000-7ffc0a997000 rw-p 00000000 00:00 0 [stack]
> 7ffc0a976000-7ffc0a997000 rw-p 00000000 00:00 0 [stack]
포인터 변수 ptr1, ptr2 자체는 스택 세그먼트에 저장되어 있지만, 변수가 가리키고 있는 주소는 힙 세그먼트임을 확인할 수 있다.
calloc은 청소 및 할당(clear and allocate), malloc은 메모리 할당(memory allocate)을 의미한다. C++에서는 new, delete 키워드가 각각 malloc, free와 같은 역할을 한다.
C 언어 명세에 따르면 malloc은 할당된 메모리를 초기화하지 않으며 메모리 블록에 접근하기 전까지 할당을 지연하는 것이 기본 동작이기 때문에 calloc보다 빠르다. 이후 메모리를 초기화하기 위해 memset 함수를 사용할 수 있다.
코드 박스 5-7 memset 함수로 메모리 블록 초기화하기
#include <stdlib.h> // header for malloc
#include <string.h> // header for memset
int main(int argc, char** argv) {
char* ptr = (char*) malloc(16 * sizeof(char));
memset(ptr, 0, 16 * sizeof(char)); // fill with 0
memset(ptr, 0xff, 16 * sizeof(char)); // fill with 0xff
...
free(ptr);
return 0;
}
코드 박스 5-8 realloc 함수로 이미 할당된 블록의 크기 변경하기
int main(int argc, char** argv) {
char* ptr = (char*) malloc(16, sizeof(char));
...
ptr = (char*) realloc(32 * sizeof(char));
...
free(ptr);
return 0;
}
realloc 함수는 이전 블록에 있는 데이터를 변경하지 않고 기 할당된 블록을 새로운 블록으로 확장한다. 단편화(fragmentation) 때문에 블록 확장이 불가능할 경우 충분히 큰 블록으로 데이터를 복제한다.
코드 박스 5-9 [예제 5-4] main 함수에서 반환될 때 할당된 블록을 해제하지 않고 메모리 누수 만들기
#include <stdlib.h> // header for heap memory function
int main(int argc, char** argv) {
char* ptr = (char*) malloc(16 * sizeof(char));
return 0;
}
이 프로그램은 고의적으로 16바이트의 메모리 누수를 발생시킨다. valgrind 도구를 활용하면 이러한 메모리 누수를 디버깅할 수 있다. valgrind는 실행 가능한 목적 파일을 실행하는 동안 발생하는 모든 메모리 할당과 해제를 기록하여 이에 대한 요약과 해제되지 않은 메모리 양을 출력한다.
셀 박스 5-19 [예제 5-4]의 실행에서 16바이트의 메모리 누수가 나타남을 보여주는 valgrind의 출력
$ gcc -g 5_4.c -o 5_4.out
$ valgrind ./5_4.out
==15977== Memcheck, a memory error detector
==15977== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==15977== Using Valgrind-3.26.0 and LibVEX; rerun with -h for copyright info
==15977== Command: ./5_4.out
==15977==
==15977==
==15977== HEAP SUMMARY:
==15977== in use at exit: 16 bytes in 1 blocks
==15977== total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==15977==
==15977== LEAK SUMMARY:
==15977== definitely lost: 16 bytes in 1 blocks
==15977== indirectly lost: 0 bytes in 0 blocks
==15977== possibly lost: 0 bytes in 0 blocks
==15977== still reachable: 0 bytes in 0 blocks
==15977== suppressed: 0 bytes in 0 blocks
==15977== Rerun with --leak-check=full to see details of leaked memory
==15977==
==15977== For lists of detected and suppressed errors, rerun with: -s
==15977== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
셀 박스 5-20 할당에 실제로 관여하는 행을 나타내는 valgrind의 출력
$ gcc -g 5_4.c -o 5_4.out
$ valgrind --leak-check=full ./5_4.out
==16357== Memcheck, a memory error detector
==16357== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==16357== Using Valgrind-3.26.0 and LibVEX; rerun with -h for copyright info
==16357== Command: ./5_4.out
==16357==
==16357==
==16357== HEAP SUMMARY:
==16357== in use at exit: 16 bytes in 1 blocks
==16357== total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==16357==
==16357== 16 bytes in 1 blocks are definitely lost in loss record 1 of 1
==16357== at 0x4850858: malloc (vg_replace_malloc.c:447)
==16357== by 0x4001165: main (5_4.c:4)
==16357==
==16357== LEAK SUMMARY:
==16357== definitely lost: 16 bytes in 1 blocks
==16357== indirectly lost: 0 bytes in 0 blocks
==16357== possibly lost: 0 bytes in 0 blocks
==16357== still reachable: 0 bytes in 0 blocks
==16357== suppressed: 0 bytes in 0 blocks
==16357==
==16357== For lists of detected and suppressed errors, rerun with: -s
==16357== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
--leak-check=full 옵션까지 전달하면 어떤 행에서 할당한 메모리가 누수된 것인지까지 파악할 수 있다.
코드 박스 5-10 [예제 5-4]에서 할당된 메모리 블록을 해제하기
#include <stdlib.h> // header for heap memory function
int main(int argc, char** argv) {
char* ptr = (char*) malloc(16 * sizeof(char));
free(ptr);
return 0;
}
셀 박스 5-21 할당된 메모리 블록을 해제한 이후 valgrind의 출력 내용
$ gcc -g 5_4.c -o 5_4.out
$ valgrind --leak-check=full ./5_4.out
==17188== Memcheck, a memory error detector
==17188== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==17188== Using Valgrind-3.26.0 and LibVEX; rerun with -h for copyright info
==17188== Command: ./5_4.out
==17188==
==17188==
==17188== HEAP SUMMARY:
==17188== in use at exit: 0 bytes in 0 blocks
==17188== total heap usage: 1 allocs, 1 frees, 16 bytes allocated
==17188==
==17188== All heap blocks were freed -- no leaks are possible
==17188==
==17188== For lists of detected and suppressed errors, rerun with: -s
==17188== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
valgrind로 실행하는 프로그램은 10~50배 느려지지만 메모리 누수 문제를 쉽게 프로파일링할 수 있도록 한다. 이 외에도 LLVM 주소 새니타이저(LLVM Address Sanitizer)와 MemProf 프로파일러도 사용할 수 있다.
프로파일러의 특성은 다음과 같다.
valgrind가 대표적인 예이며, 이 방식은 코드를 재컴파일할 필요가 없다.valgrind, ASAN은 메모리 프로파일러 라이브러리로 실행 가능한 목적 파일에 링크될 수 있다. 이 방식은 재컴파일을 요구한다.MemProf가 대표적인 예이며, 이 방식은 대상 소스 코드를 컴파일할 필요가 없다. LD_PRELOAD 환경 변수를 조작하여 기본 libc 라이브러리 대신 프로파일러가 미리 적재할 라이브러리를 명시한다.힙 메모리 블록은 스코프를 갖지 않는다. 때문에 메모리를 수동으로 해제하거나, 현대 언어는 세대별 가비지 컬렉션(generational garbage collection)을 사용한다.
힙 수명 복잡성 극복을 위한 전략 중 가장 선호되는 것은 메모리 블록을 아우르는 스코프를 갖는 대신 메모리 블록의 소유자(owner)를 정의하는 방법이다. 소유자가 힙 메모리 블록의 수명을 관리하는 유일한 개체(entity)로서 할당과 해제를 책임진다.
다음은 소유권(onwership) 전략에 관한 예제이다.
코드 박스 5-11 [예제 5-5] 힙 수명 관리를 위한 소유권 전략을 설명
#include <stdio.h> // header for printf function
#include <stdlib.h> // header for heap memory function
#define QUEUE_MAX_SIZE 100
typedef struct {
int front;
int rear;
double* arr;
} queue_t;
void init(queue_t* q) {
q->front = q->rear = 0;
// queue object owns the allocated heap memory block
q->arr = (double*) malloc(QUEUE_MAX_SIZE * sizeof(double));
}
void destroy(queue_t* q) {
free(q->arr);
}
int size(queue_t* q) {
return q->rear - q->front;
}
void enqueue(queue_t* q, double item) {
q->arr[q->rear++] = item;
}
double dequeue(queue_t* q) {
return q->arr[q->front++];
}
int main(int argc, char** argv) {
// main function owns the allocated heap memory block
queue_t* q = (queue_t*) malloc(sizeof(queue_t));
// allocate necessary memory for the queue object
init(q);
enqueue(q, 6.5);
enqueue(q, 1.3);
enqueue(q, 2.4);
printf("%f\n", dequeue(q));
printf("%f\n", dequeue(q));
printf("%f\n", dequeue(q));
// queue object destroy(q) frees the allocated resource
destroy(q);
// main function frees the allocated memory for the queue object
free(q);
return 0;
}
main 함수는 queue_t 타입 변수에 할당된 메모리를 소유한다. queue_t 타입 변수는 arr 구조체 변수에 할당된 메모리를 소유한다. 힙 메모리 블록의 소유자는 주석으로 표기해야 하며, 소유자가 아닌데 메모리 해제에 개입하여 이중 해제(double free) 시 메모리 손상 문제가 발생한다.
가비지 컬렉터를 활용하는 전략도 있으며, C 언어용 가비지 컬렉터는 Boehm-Demers-Weiser Conservative Garbage Collector이다. 이는 표준 C 메모리 할당 함수 대신 호출될 수 있는 일련의 메모리 할당 함수를 제공한다.
RAII(Resource Acquisition Is Initialization) 객체를 사용한 힙 블록 수명 관리 기술도 있다. 객체 생성 시 리소스 초기화, 파괴 시 리소스를 해제하지만 C에서는 객체 파괴에 관한 정보를 수신할 수 없기 때문에 사용이 불가능하다. C++에서는 소멸자(destructor)를 활용해 이 기술을 사용할 수 있다. RAII 객체에서 리소스는 생성자(constructor)에서 초기화되고 소멸자에서 해제된다.
힙 메모리 사용에 관한 가이드라인은 다음과 같다.
malloc은 그 비용이 가장 낮은 함수이다.제한된 환경에서는 일반적으로 메모리 사용을 제한하는 몇 가지 제약 조건(constraint)이 존재한다. 이러한 제약 조건에는 하드 리밋(hard limit), 저용량 하드웨어, 큰 용량을 지원하지 않는 운영체제 등이 있다.
가용한 메모리 자체가 제한된 환경의 예로, 일반적으로 10~100MB 크기의 메모리를 갖는 임베디드 시스템이 있다. 이 경우에는 일반적으로 시간 복잡도(time complexity)를 희생하여 공간 복잡도가 낮은 알고리즘을 사용한다.
패킹된 구조체(packed structure)는 메모리 정렬을 포기하는 대신 더 작은 공간에 연속적으로 필드를 저장한다.
압축(compression)은 텍스트 데이터가 많은 프로그램에 효과적이다. 텍스트 데이터는 이진 데이터에 비해 압축률(compression ratio)이 더 높다. 하지만 압축 알고리즘은 CPU 바운드(CPU-bound) 및 계산 집약적(computation-intensive)이다.
네트워크 서비스, 클라우드 인프라, 하드디스크 드라이브 형태로 외부 데이터 저장소(external data storage)를 사용할 수 있다. 모든 관계형 데이터베이스 서비스는 이 기술을 사용한다.
이 방법은 메모리가 주 저장소가 아닌 캐시(cache) 메모리의 역할을 한다고 가정하며, 메모리에 데이터 전체가 아닌 페이지(page) 단위로 적재할 수 있다고 가정한다.
일반적으로 시간 복잡도를 개선하면 메모리를 더 사용하게 된다.
상이한 읽기·쓰기 성능을 가진 두 데이터 저장소는 일반적으로 메모리 계층도에 따라 성능이 우수할수록 적은 저장 공간을 갖는다. 캐싱(caching)은 이때 성능이 더 좋은 저장소에 자주 사용되는 데이터를 적재해 두는 기법이다.
CPU는 명령어 실행 시 메모리로부터 레지스터로 필요한 데이터를 캐싱한다. 이때 이전 주소에 대해 근접성(proximity)을 만족하는 연산 수행 시 캐시 적중(cache hit)이 발생하여 빠른 실행이 가능하다. 반대의 경우에는 캐시 실패(cache miss) 발생으로 실행이 느려진다.
지역성의 원리(the principle of locality) 때문에 CPU는 지역 참조(local reference)로부터 데이터를 더 많이 가져온다. 따라서 캐시 친화적(cache-friendly)인 알고리즘은 이러한 지역성을 적극 활용하는 것이다.
코드 박스 5-12 [예제 5-6] 캐시 친화적 코드와 비 캐시 친화적 코드의 성능 나타내기
#include <stdio.h> // header for printf function
#include <stdlib.h> // header for heap memory function
#include <string.h> // header for strcmp function
void fill(int* matrix, int rows, int columns) {
int counter = 1;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
*(matrix + i * columns + j) = counter;
}
++counter;
}
}
void print_matrix(int* matrix, int rows, int columns) {
printf("Matrix:\n");
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
printf("%d ", *(matrix + i * columns + j));
}
printf("\n");
}
}
void print_flat(int* matrix, int rows, int columns) {
printf("Flat matrix: ");
for (int i = 0; i < (rows * columns); ++i) {
printf("%d ", *(matrix + i));
}
printf("\n");
}
int cache_friendly_sum(int* matrix, int rows, int columns) {
int sum = 0;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
sum += *(matrix + i * columns + j);
}
}
return sum;
}
int non_cache_friendly_sum(int* matrix, int rows, int columns) {
int sum = 0;
for (int j = 0; j < columns; ++j) {
for (int i = 0; i < rows; ++i) {
sum += *(matrix + i * columns + j);
}
}
return sum;
}
int main(int argc, char** argv) {
if (argc < 4) {
printf("Usage: %s [print|cache-friendly-sum|non-cache-friendly-sum] ", argv[0]);
printf("[number-of-rows] [number-of-columns]\n");
exit(1);
}
char* operation = argv[1];
int rows = atol(argv[2]);
int columns = atol(argv[3]);
int* matrix = (int*) malloc(rows * columns * sizeof(int));
fill(matrix, rows, columns);
if (strcmp(operation, "print") == 0) {
print_matrix(matrix, rows, columns);
print_flat(matrix, rows, columns);
} else if (strcmp(operation, "cache-friendly-sum") == 0) {
int sum = cache_friendly_sum(matrix, rows, columns);
printf("Cache friendly sum: %d\n", sum);
} else if (strcmp(operation, "non-cache-friendly-sum") == 0) {
int sum = non_cache_friendly_sum(matrix, rows, columns);
printf("Non cache friendly sum: %d\n", sum);
} else {
printf("FATAL: Not supported operation!\n");
exit(1);
}
free(matrix);
return 0;
}
셀 박스 5-22 2행 3열의 행렬을 나타내는 [에제 5-6]의 출력 결과
$ gcc 5_6.c -o 5_6.out
$ ./5_6.out print 2 3
Matrix:
1 1 1
2 2 2
Flat matrix: 1 1 1 2 2 2
행렬의 평탄화된 출력을 통해 행렬이 메모리상에 행-우선 순위(row-major order)로 저장되어 있음을 확인할 수 있다. cache_friendly_sum 함수는 행-우선 순위로 작성되었고, non_cache_friendly_sum 함수는 열-우선 순위(column-major order)로 작성되었다.
셀 박스 5-23 열-우선과 행-우선의 행렬 합계 연산 알고리즘 간 시간 차이의 예시
$ time ./5_6.out cache-friendly-sum 20000 20000
Cache friendly sum: 1585447424
real 0m9.995s
user 0m2.998s
sys 0m6.988s
$ time ./5_6.out non-cache-friendly-sum 20000 20000
Non cache friendly sum: 1585447424
real 0m13.697s
user 0m6.627s
sys 0m7.050s
행-우선으로 작성된 캐시 친화적 함수가 더 시간 효율적임을 확인할 수 있다.
스택 메모리의 크기는 실행 시점에 결정적이기 때문에 할당에 많은 시간이 소요되지 않는다. 하지만 힙 메모리의 크기는 비결정적이기 때문에 할당 가능한 공간 탐색을 위한 비용이 발생한다.
C 언어에서는 malloc, ptmalloc, tcmalloc, Hoard, dlmalloc와 같이 다양한 메모리 할당 함수를 활용할 수 있다. 하지만 최선의 방법은 할당과 해제 연산을 최소화하는 것이다.
메모리 풀은 힙 메모리를 스택 메모리처럼 결정론적으로 운용하는 방법이다. 풀의 각 블록이 일반적으로 식별자(identifier)를 가지며 별도의 API를 통해 공간을 관리할 수 있다. 공간의 크기가 결정론적이기 때문에 메모리 관리의 예측 가능성 측면에서도 이점이 있다.