목차16개 항목
출처와 참고
하버드 CS50(부스트코스) 5강(메모리)과 고무오리 CS 스터디의 CS50 정리 노트를 뼈대로, 팔만코딩경(42Seoul 커뮤니티 위키)의 「char *str = "abc"와 char str[] = "abc"의 차이!」, 「포인터와 배열의 차이(?)」, 「const char * vs char * const」, 「함수 내에서 외부 포인터에 동적할당하기」, 「malloc, free 그리고 dangling pointer가 생기는 이유」, 「C/C++의 동적 메모리 할당과 해제」, 「정적 할당에 대한 오해」, 「libft ft_lstdelone 함수에서 next 는 free 하면 안되는 이유?」, 「libft, 깨고 나니 보이는 것들」을 바탕으로 배열과 포인터의 차이부터 new/delete 짝까지의 함정 부분을 보강했다. 포인터 연산, 초기화와 NULL은 고려사이버대학교 「프로그래밍 기초와 실습(C언어)」(2024-2학기) 강의 내용으로 보강했다.
C의 변수는 메모리 어딘가에 놓인 바이트 묶음이고, 포인터는 그 위치(주소)를 담는 변수다. 문자열 비교가 왜 실패하는지, swap 함수가 왜 동작하지 않는지 같은 C의 "이상한" 현상은 대부분 주소와 값을 구분하면 풀린다.
16진수와 메모리 주소
- 메모리는 바이트들이 한 줄로 늘어선 공간이고, 각 바이트에는 번호(주소)가 있다.
- 주소는 보통 16진수로 쓴다. 16진수 1자리가 4비트이므로 2자리가 1바이트다.
0x접두사가 16진수 표시다. 예:0x7ffe00b3adbc - 실행할 때마다 주소가 달라지는 것은 운영체제가 보안을 위해 주소 배치를 무작위화(ASLR)하기 때문이다.
&, *, 포인터
int n = 50;
int *p = &n; // p는 int를 가리키는 포인터
printf("%p\n", p); // n의 주소
printf("%i\n", *p); // 그 주소의 값: 50| 표기 | 뜻 |
|---|---|
&x | x의 주소 |
*p | 주소 p에 있는 값 (역참조) |
int *p | "int를 가리키는" 포인터. 가리키는 타입을 함께 적는다 |
- 64비트 시스템에서 포인터 자체는 8바이트다.
- 주소 값보다 "p가 n을 가리킨다"는 관계로 생각한다. 데이터가 다른 데이터를 가리키게 이으면 연결 리스트·트리가 된다.
- 포인터 타입과 가리키는 변수의 타입은 맞춰야 한다.
double *pd = &i;(i는int)는 호환되지 않는 포인터 대입이라 컴파일러가 경고하고(GCC 14부터는 기본 오류), 형 변환으로 억지로 넣어 역참조하면int4바이트를double8바이트로 읽는다.*는 포인터 타입만큼의 바이트를 그 타입으로 해석해 읽기 때문이다.
포인터 연산
포인터에는 정수 덧셈·뺄셈, 증감, 같은 배열 안의 두 포인터끼리 뺄셈과 비교만 된다. p + 1은 주소에 1을 더하는 것이 아니라 sizeof(*p)만큼 이동한다. int *면 4바이트, double *면 8바이트, char *면 1바이트다. 그래서 a[i]는 *(a + i)와 같다.
| 식 | 뜻 |
|---|---|
*p++ | 후위 ++가 *보다 먼저 묶여 *(p++)다. 현재 값을 읽고 포인터를 다음 칸으로 옮긴다 |
(*p)++ | 가리키는 값을 1 늘린다. 포인터는 그대로 |
*++p | 포인터를 먼저 옮기고 새 위치의 값을 읽는다 |
q - p | 두 포인터 사이의 원소 개수(바이트 수가 아니다). 결과 타입은 ptrdiff_t |
배열 끝을 넘어선 포인터는 「마지막 원소 바로 다음」 한 칸까지만 만들 수 있고 역참조할 수 없다. 그 밖으로 계산한 포인터는 남의 데이터나 운영체제 영역을 가리켜 정의되지 않은 동작이 된다.
초기화와 NULL
- 초기화하지 않은 지역 포인터는 쓰레기 주소를 담고 있다(와일드 포인터). 그대로
*p = 3을 하면 임의의 메모리를 덮어쓴다. 가리킬 대상이 아직 없으면NULL로 초기화하고, 쓰기 전에NULL인지 검사한다. NULL은 「아무것도 가리키지 않음」을 뜻하는 널 포인터 상수다(stddef.h·stdio.h등에 정의, 흔히((void *)0)). 일반 운영체제는 0번지 근처를 매핑하지 않아 널 역참조가 즉시 세그폴트로 드러나지만, 표준상으로는 정의되지 않은 동작이다. C23은 타입이 있는nullptr도 도입했다.- 정수 주소를 직접 대입하는
int *p = (int *)0x1000;은 메모리 맵이 고정된 임베디드 장치의 하드웨어 레지스터를 다룰 때나 쓴다. 가상 메모리를 쓰는 데스크톱 OS에서는 의미 없는 주소다. - 함수가 지역 변수의 주소를 반환하면 함수가 끝나는 순간 그 스택 자리는 무효가 된다. 반환할 수 있는 주소는 전역·
static변수,malloc으로 얻은 메모리, 호출한 쪽이 넘겨준 버퍼뿐이다.
문자열의 정체: char *
char *s = "EMMA"; // 메모리: E M M A \0 (5바이트)s는 문자열 전체가 아니라 첫 글자의 주소다. 끝은\0(널 종단 문자)을 만날 때까지 읽어서 안다.s[1]은*(s + 1)을 편하게 쓰는 문법이다.- C에는 문자열 타입이 따로 없다. CS50의
string은typedef char *string;일 뿐이다.
주소와 값을 혼동하면 문자열과 함수 인자에서 다음 실수가 생긴다.
| 하려는 일 | 틀린 코드 | 왜 틀리나 | 맞는 코드 |
|---|---|---|---|
| 비교 | s == t | 두 문자열의 주소를 비교한다 | strcmp(s, t) == 0 |
| 복사 | t = s | 주소만 복사돼 t를 고치면 s도 바뀐다 | malloc 후 strcpy |
| 두 값 교환 | swap(int a, int b) | C는 인자를 값으로 복사해 넘기므로 복사본만 바뀐다 | swap(int *a, int *b) |
char *t = malloc(strlen(s) + 1); // 문자 수 + '\0' 1바이트
if (t == NULL) return 1;
strcpy(t, s);
free(t);void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
swap(&x, &y); // 원본 x, y가 바뀐다동적 할당과 해제
malloc(n): n바이트를 힙에 할당하고 주소를 돌려준다. 실패하면NULL.free(p): 반납한다. 반납하지 않으면 메모리 누수가 생긴다.int *x = malloc(10 * sizeof(int));뒤의x[10] = 0;은 범위 밖 쓰기(버퍼 오버플로우)다. 이런 오류와 누수는 valgrind나 AddressSanitizer로 찾는다(C 디버깅 도구).
정적 할당이라는 말의 두 뜻
"정적 할당"이라는 말은 두 가지로 쓰여 헷갈린다. 엄밀히 정적 저장 기간(static storage duration)은 전역 변수와 static 지역 변수처럼 프로그램 내내 살아 있는 객체를 말한다. 함수 안의 일반 지역 변수(int arr[10];)는 자동 저장 기간으로, 블록에 들어갈 때 스택에 생기고 나갈 때 사라진다. 크기가 컴파일 시점에 정해진다고 해서 정적 저장 기간인 것은 아니다. malloc으로 얻은 메모리는 할당 저장 기간으로, free할 때까지 산다.
힙과 스택
malloc한 데이터는 힙에, 지역 변수와 매개변수는 스택에 놓인다. 코드·데이터 영역까지 포함한 프로세스의 메모리 영역 표는 프로세스와 스레드에 있다.
- 힙과 스택은 주소 공간의 양쪽에서 서로를 향해 자란다.
malloc을 많이 하면 힙이, 함수 호출이 깊어지면 스택이 커진다. - 할당량이 한계를 넘거나 서로의 영역을 침범하면 힙 오버플로우 / 스택 오버플로우가 생긴다. 끝없는 재귀(재귀)가 대표적인 스택 오버플로우 원인이다.
여기서 보는 주소는 물리 주소가 아니라 프로세스마다 따로 갖는 논리(가상) 주소다. MMU가 이를 물리 메모리에 대응시키는 방식은 메모리 관리와 가상 메모리에 있다.
배열은 포인터가 아니다
배열 이름은 대부분의 식에서 첫 원소의 주소로 바뀐다(array-to-pointer decay). 그래서 a[i]와 *(a + i)가 같고, 함수에 배열을 넘기면 포인터가 넘어간다. 하지만 배열 자체는 "연속된 원소 덩어리"라는 객체이고, 바뀌지 않는 자리가 있다.
| 상황 | 배열 int a[3] | 포인터 int *p = a |
|---|---|---|
sizeof | 배열 전체 크기 12 | 포인터 크기 8 |
& | &a는 int (*)[3] (배열 전체의 주소) | &p는 int ** |
| 대입 | a = p; 컴파일 오류 | p = a; 가능 |
| 증감 | a++ 컴파일 오류 | p++ 가능 |
함수 매개변수로 받은 배열은 이미 포인터이므로, 함수 안에서 sizeof(arr) / sizeof(arr[0])로 길이를 구할 수 없다. 길이는 따로 넘긴다. a[i]와 *p++ 중 어느 쪽이 빠른지는 신경 쓸 필요가 없다. 최적화 컴파일러는 둘을 같은 코드로 만든다.
문자열 리터럴은 읽기 전용이다
char *s = "abc"; /* 리터럴의 주소를 가리킴 */
char t[] = "abc"; /* 리터럴을 복사해 만든 배열 */
s[0] = 'A'; /* 미정의 동작: 대개 Segfault나 Bus error */
t[0] = 'A'; /* 가능 */"abc" 같은 문자열 리터럴은 정적 저장 기간을 갖는 배열이고, 보통 실행 파일의 읽기 전용 데이터 영역(.rodata)에 놓인다. char *s는 그 주소를 가리킬 뿐이라 쓰기를 시도하면 OS가 막는다. char t[]는 지역 변수라면 스택에 4바이트 배열을 새로 만들고 내용을 복사하므로 수정할 수 있다. 실수를 막으려면 리터럴을 가리키는 포인터는 처음부터 const char *로 선언한다.
const의 위치
*를 기준으로 왼쪽의 const는 가리키는 대상을, 오른쪽의 const는 포인터 자신을 고정한다. 선언을 오른쪽에서 왼쪽으로 읽으면 헷갈리지 않는다.
| 선언 | 읽는 법 | *p = x | p = q |
|---|---|---|---|
const char *p (= char const *p) | char 상수를 가리키는 포인터 | 불가 | 가능 |
char *const p | char를 가리키는 상수 포인터 | 가능 | 불가 |
const char *const p | 둘 다 상수 | 불가 | 불가 |
함수 매개변수의 const char *는 "이 함수는 네 문자열을 바꾸지 않는다"는 약속이다. 표준 라이브러리(strlen, strcpy의 원본 인자)가 이렇게 선언되어 있다.
함수 안에서 바깥 포인터를 바꾸려면
C의 인자는 값으로 복사된다. 포인터를 넘기면 포인터 값(주소)의 복사본이 넘어가므로, 함수 안에서 그 매개변수에 새 주소를 대입해도 호출한 쪽 변수는 그대로다.
void alloc_bad(char *p) { p = malloc(10); } /* 바깥 변수는 그대로, 메모리는 누수 */
void alloc_ok(char **p) { *p = malloc(10); } /* 바깥 변수의 주소를 받아 그 안을 바꿈 */
char *buf = NULL;
alloc_ok(&buf);같은 이유로 free(p); p = NULL;을 함수 안에서 해도 호출한 쪽의 포인터는 여전히 옛 주소를 들고 있다. 연결 리스트 함수가 t_list **lst를 받는 것도 머리 노드 포인터 자체를 바꿔야 하기 때문이다. 이중 포인터 대신 새 주소를 반환값으로 돌려주는 설계도 있다.
free 이후: 댕글링 포인터
free는 메모리를 0으로 지우지도, 포인터를 NULL로 바꾸지도 않는다. 할당기는 해제된 블록을 빈 블록 목록(free list)에 올려 다음 malloc에 재사용할 뿐이고, 큰 블록이 아니면 운영체제에 곧바로 돌려주지도 않는다. 그래서 해제 직후 같은 주소를 읽으면 옛 데이터가 그대로 보이기도 한다. 이렇게 해제된 메모리를 가리키는 포인터를 댕글링 포인터(dangling pointer)라 하고, 그 주소를 쓰면 해제 후 사용(use-after-free)이라는 미정의 동작이다. 블록이 재사용된 뒤라면 전혀 다른 데이터를 덮어쓰게 된다.
| 규칙 | 이유 |
|---|---|
해제 후 다시 쓸 수 있는 포인터는 NULL로 바꾼다 | 이후 접근이 확실한 NULL 역참조로 드러나고, free(NULL)은 아무 일도 하지 않아 이중 해제도 막힌다 |
free에는 malloc이 돌려준 주소 그대로를 넘긴다 | 포인터를 옮긴 뒤 해제하면 할당기의 장부와 맞지 않아 미정의 동작 |
연결 리스트 노드를 지울 때 next가 가리키는 노드까지 해제하지 않는다 | next는 다른 노드다. 지울 노드의 내용(content)과 노드 자신만 해제하고, 앞뒤 연결은 호출한 쪽이 다시 잇는다 |
| 지역 배열의 주소를 반환하지 않는다 | 함수가 끝나면 스택 프레임이 사라진다. 반환할 메모리는 malloc으로 잡는다 |
이런 오류는 대부분 증상이 늦게 나타나므로 C 디버깅 도구의 AddressSanitizer로 잡는다.
memcpy와 memmove
mem 계열 함수는 널 문자와 상관없이 바이트 수를 받아 임의의 메모리를 다루고, str 계열은 '\0'까지를 문자열로 본다.
| 함수 | 영역이 겹칠 때 |
|---|---|
memcpy(dst, src, n) | 미정의 동작. 앞에서부터 복사하다 아직 읽지 않은 원본을 덮어쓸 수 있다 |
memmove(dst, src, n) | 안전. 결과가 임시 버퍼를 거친 것과 같도록 보장한다 |
임시 버퍼 없이 memmove를 구현하려면 dst가 src보다 뒤에 있을 때 끝에서부터 거꾸로 복사하면 된다. 겹칠 가능성이 조금이라도 있으면 memmove를 쓴다.
malloc/free와 new/delete는 짝을 맞춘다
C++ 코드와 섞일 때는 할당과 해제의 짝이 반드시 맞아야 한다.
| 할당 | 해제 | 하는 일 |
|---|---|---|
malloc / calloc / realloc | free | 바이트만 확보·반환. 생성자·소멸자 없음 |
new T | delete p | 메모리 확보 후 생성자 호출 / 소멸자 호출 후 반환 |
new T[n] | delete[] p | 원소 수를 함께 기록해 n개의 소멸자를 호출 |
new[]로 만든 배열을 delete로 지우면 소멸자가 한 번만 불리고, 원소 수를 기록한 영역 때문에 할당기에 엉뚱한 주소가 넘어가 미정의 동작이 된다. malloc한 메모리를 delete하거나 그 반대도 마찬가지다.
핵심 정리
&는 주소,*는 그 주소의 값. 포인터는 주소를 담는 변수다.p + 1은 가리키는 타입 크기만큼 이동한다.*p++는 값을 읽고 포인터를 옮기고,(*p)++는 값을 늘린다. 대상이 없는 포인터는NULL로 초기화하고, 지역 변수의 주소는 반환하지 않는다.- 문자열은
char *이므로==는 주소 비교, 내용 비교는strcmp, 복사는malloc+strcpy. malloc한 메모리는 반드시free, 배열 범위 밖 접근은 버퍼 오버플로우.- 함수에서 원본을 바꾸려면 포인터를 넘긴다. 바깥 포인터 자체를 바꾸려면 포인터의 주소(
**)를 넘기거나 새 값을 반환한다. - 배열은 식에서 포인터로 바뀌지만
sizeof·&·대입에서는 배열로 남는다. 리터럴은 읽기 전용이니const char *로 가리킨다. free는 포인터를 건드리지 않는다. 다시 쓸 수 있는 포인터는NULL로 바꾼다.- 겹칠 수 있으면
memmove, 할당·해제는 같은 계열끼리 짝을 맞춘다.
관련 노트: C 디버깅 도구, C 사용자 정의 타입과 함수 포인터, 프로세스와 스레드, 기초 자료구조, C 기초
원본 기록10
- Notion 원문 (새 창) app.notion.com/p/2ec02d23df41457bbf156d5d1c7da595
- Notion 원문 (새 창) 80000coding.notion.site/a7082b282de545e0aab7d71452450a3b
- Notion 원문 (새 창) 80000coding.notion.site/69e744e6ef41453fad79079f2655c00c
- Notion 원문 (새 창) 80000coding.notion.site/4eaf13713faa4c11a983902bd6d451a2
- Notion 원문 (새 창) 80000coding.notion.site/d835a99d4d264eadb46df105ed769d99
- Notion 원문 (새 창) 80000coding.notion.site/694fc8c704aa41ea98614f4e7eac7f6c
- Notion 원문 (새 창) 80000coding.notion.site/c2fc3384860f4f5795cb6b8acc208c12
- Notion 원문 (새 창) 80000coding.notion.site/673a9e7d18c24c8ab0e1fb145a954ca3
- Notion 원문 (새 창) 80000coding.notion.site/b799e831198f42719ce64d194fe3ff32
- Notion 원문 (새 창) 80000coding.notion.site/f217ea68a2da4e9da1f503e66f0b705e