0) 왜 포인터인가?
C는 “메모리에 가까운 언어”예요. 포인터는 메모리의 주소를 값으로 갖는 변수입니다. 이 주소값을 통해 우리는 메모리 블록의 시작점을 가리키고, 타입 정보를 바탕으로 그 블록을 해석합니다. 성능·표현력·시스템 프로그래밍의 자유를 주지만, 잘못 쓰면 치명적 버그(크래시, UAF, OOB)가 됩니다.
1) 메모리 모델 한 장 요약
일반적인 64-bit Linux 프로세스의 가상 메모리 개략(ASLR 생략):
높은 주소
┌───────────────────────┐
│ 스택(Stack) │ ↓ 성장 (함수 프레임, 지역변수)
├───────────────────────┤
│ 힙(Heap) │ ↑ 성장 (malloc/free)
├───────────────────────┤
│ 데이터/ BSS 영역 │ 전역/정적 변수 (초깃값/무초깃값)
├───────────────────────┤
│ 텍스트(Text) │ 코드/상수
└───────────────────────┘
낮은 주소
포인터는 이 주소 공간 어딘가를 가리키는 정수 같은 값(단, 산술이 타입 크기 단위로 진행).
64-bit에선 포인터 크기가 8바이트(일반적). sizeof(void*) == 8.
2) 포인터의 기초: 선언, 주소 연산, 역참조
int a = 10;
int p = &a; // p에는 'a의 주소'가 들어감
p = 20; // p가 가리키는 메모리에 20을 씀 → a가 20이 됨
& : 주소 연산자 (변수 → 그 변수의 시작 주소)
p의 타입(int*)은 “이 주소를 int로 해석하라”는 약속입니다.
포인터 크기와 정렬
printf("%zu %zu\n", sizeof(int), sizeof(double)); // 보통 둘 다 8
타입과 무관하게 “포인터 자체의 크기”는 플랫폼 고정(대개 8). 하지만 포인터 연산(p+1)은 sizeof(*p)만큼 이동한다는 점이 핵심.
3) 데이터 레벨: 바이트로 보는 메모리
리틀엔디언에서 “낮은 주소에 하위 바이트”가 옵니다.
#include <stdio.h>
#include <stdint.h>
int main() {
uint32_t x = 0x11223344; // 16진 상수
uint8_t px = (uint8_t)&x; // 바이트 단위 접근
printf("%02X %02X %02X %02X\n", px[0], px[1], px[2], px[3]);
return 0;
}
메모리 바이트 배치(리틀엔디언):
주소: 0x1000 0x1001 0x1002 0x1003
값: 44 33 22 11
px[0] == 0x44, px[1] == 0x33 …
같은 주소를 uint32_t로 보면 한 덩어리 0x11223344, uint8_t로 보면 바이트 배열.
char*로 보면 뭐든 바이트 스트림
표준이 보장하는 유일한 “바이트 단위 포인터”는 unsigned char/char예요. 임의의 객체를 안전하게 바이트복사하려면 memcpy를 쓰고, 바이트 단위 검사도 char* 캐스팅으로 합니다.
4) 배열과 포인터: 같지만 다르다
int arr[4] = {10, 20, 30, 40};
int *p = arr; // == &arr[0]
주소/크기 차이
printf("%zu\n", sizeof(arr)); // 4 * sizeof(int) == 16 (배열 전체 크기)
printf("%zu\n", sizeof(p)); // 포인터 크기 == 8
식에서 배열 이름은 대부분 첫 요소의 주소로 decay합니다. 그러나 sizeof(arr)처럼 “배열 자체”가 필요한 문맥에선 decay 안 되고 전체 크기.
포인터 연산의 단위
가령 p가 0x1000을 가리키면(64-bit, int=4B 가정):
p = 0x1000 → p == arr[0]
p + 1 = 0x1004 → (p+1) == arr[1]
p + 2 = 0x1008 → *(p+2) == arr[2]
arr vs &arr
arr의 타입: int*로 decay
&arr의 타입: int (*)[4] (배열 전체의 포인터)
산술도 다름: &arr + 1은 16바이트를 건너뜀(배열 전체 크기만큼)
5) 동적 메모리와 포인터: Heap의 삶과 죽음
#include <stdlib.h>
int p = malloc(sizeof(int) 3); // 힙에 12바이트
p[0] = 7; p[1] = 8; p[2] = 9;
free(p); // 메모리 반납
p = NULL; // 댕글링 방지
데이터 레벨 관찰 포인트
malloc은 커널로부터 큰 페이지를 받아 사용자 공간 할당자(glibc ptmalloc 등)가 쪼개서 줍니다.
우리가 받는 포인터 바로 앞에는 할당자용 메타데이터가 있을 수 있어요(크기·상태). 이 영역은 사용 금지.
UAF(use-after-free): free(p) 후 *p를 접근하면 정의되지 않은 동작.
Double free: 같은 포인터 두 번 free → 힙 손상/크래시.
calloc/realloc
calloc(n, sz): n*sz 바이트 할당 후 0으로 초기화.
realloc(p, newsz): 크기 변경. 새 블록이 필요하면 복사 후 옛 블록 free. 실패 시 원래 포인터 유지.
6) 이중 포인터, 포인터 배열, 그리고 문자열
포인터의 포인터
int val = 42;
int p = &val; // int에 대한 포인터
int **pp = &p; // "int에 대한 포인터"
**pp = 100; // val = 100
포인터 배열 vs 이차원 배열
// 포인터 배열: 각 행이 제각각 위치
char *rows[3] = { "hi", "pointer", "world" };
// 진짜 2D 배열(연속 메모리)
int grid[2][3] = { {1,2,3}, {4,5,6} };
메모리 그림(포인터 배열):
rows ──┬─> "hi\0"
├─> "pointer\0"
└─> "world\0"
rows 자체는 3개의 포인터 값(각 8B)로 이루어진 연속 메모리. 각 포인터는 어디든 가리킬 수 있음(힙/텍스트/스택 상수 등).
7) 구조체와 포인터: 정렬(Alignment)과 패딩(Padding)
#include <stdio.h>
#include <stddef.h>
struct S {
char a; // 1B
int b; // 4B
short c; // 2B
};
int main() {
printf("sizeof(S) = %zu\n", sizeof(struct S));
printf("offsets: a=%zu b=%zu c=%zu\n",
offsetof(struct S, a),
offsetof(struct S, b),
offsetof(struct S, c));
}
가능한 배치(일반 x86_64, int 4B 정렬, short 2B 정렬):
offset 0: a (1B)
offset 1-3: padding (3B) // b를 4바이트 경계에 맞추기 위함
offset 4-7: b (4B)
offset 8-9: c (2B)
offset 10-11: padding (2B) // 전체 크기를 가장 큰 정렬(4B)의 배수로
sizeof(S) == 12
포인터로 구조체 접근
struct S s = { 'Z', 0x11223344, 0x5566 };
struct S *ps = &s;
printf("%X\n", ps->b); // 0x11223344
주의: 패킹(packed)
네트워크/파일 포맷에 맞추려 패딩을 없애고 싶을 때:
struct attribute((packed)) P {
char a;
int b;
short c;
}; // 패딩 제거 (미세 성능/정렬 페널티, 미이식성 주의)
별도: 엄격 별칭(aliasing) 규칙
서로 다른 타입의 포인터로 동일 메모리를 접근하면 UB가 될 수 있어요. 바이트 접근(unsigned char*)만이 예외적으로 허용.
8) const와 포인터: 어디가 상수일까?
const int p; // p는 가변, p가 가리키는 "값"은 상수(읽기전용)
int const p2; // p2 자체가 상수(재할당 불가), 값은 수정 가능
const int * const p3; // 포인터도 상수, 값도 상수
문법 팁: const는 별표 왼쪽을 수식한다고 기억하면 이해가 빨라요.
9) 함수 포인터: 콜백의 핵심
#include <stdio.h>
int add(int a, int b) { return a+b; }
int mul(int a, int b) { return a*b; }
int calc(int (*op)(int,int), int x, int y) {
return op(x,y);
}
int main() {
printf("%d\n", calc(add, 2, 3)); // 5
printf("%d\n", calc(mul, 2, 3)); // 6
}
시그니처 주의
함수 포인터 타입이 정확히 일치해야 안전합니다. 표준 라이브러리의 qsort, pthread_create 등은 콜백 함수 포인터를 인자로 받습니다.
10) 흔한 실수 & 베스트 프랙티스
(1) sizeof(p) vs sizeof(p)
int p = malloc(10 sizeof(p)); // int 10개 (OK)
int q = malloc(10 sizeof(q)); // sizeof(q)==8 (버그!)
(2) 배열 길이 vs 포인터 길이
int a[5];
int *p = a;
sizeof(a) == 20 // 배열 전체 바이트
sizeof(p) == 8 // 포인터 크기
(3) 경계 밖 접근(Out-Of-Bounds)
정의되지 않은 동작(UB). 테스트에선 “우연히” 통과해도 배포 후 크래시.
(4) 댕글링/Double-Free
free(p);
*p = 10; // UAF (금지)
free(p); // Double-free (금지)
p = NULL; // 관례적으로 바로 NULL 대입
(5) 정렬/패딩 무시
네트워크/디스크 포맷 직렬화는 memcpy/명시적 바이트 변환으로 처리하세요. 구조체를 그대로 송수신하는 건 이식성 지뢰.
(6) void*의 함정
void*는 타입 정보가 없어 산술 불가. 반드시 적절한 타입으로 캐스팅 후 연산.
11) 메모리 레벨 예제 모음
예제 A: 배열 메모리 지도
int arr[4] = {10,20,30,40};
int p = arr;
printf("%p %p %p %p\n", (void)&arr[0], (void)&arr[1], (void)&arr[2], (void*)&arr[3]);
가상 배치(가정, int=4B):
&arr[0] = 0x1000: 0A 00 00 00
&arr[1] = 0x1004: 14 00 00 00
&arr[2] = 0x1008: 1E 00 00 00
&arr[3] = 0x100C: 28 00 00 00
p+1 == &arr[1], *(p+2) == 30.
예제 B: short 엔디언 차이
short s = 0x1234; // 16-bit
unsigned char c = (unsigned char)&s;
printf("%02X %02X\n", c[0], c[1]); // 리틀엔디언: 34 12
예제 C: 구조체 바이트 보기
struct S { char a; int b; short c; } s = { 'A', 0x01020304, 0x0506 };
unsigned char m = (unsigned char)&s;
/ 바이트 덤프 /
for (size_t i=0; i<sizeof s; ++i) printf("%02X ", m[i]); puts("");
가능 출력(12B, 리틀엔디언 가정):
41 00 00 00 04 03 02 01 06 05 00 00
a pad pad b(LSB...MSB) c(LSB MSB) pad
예제 D: argv 메모리 레벨
int main(int argc, char *argv) {
// argv는 char의 배열(포인터 배열)로 볼 수 있음
// argv[i]는 각 문자열의 시작 주소
}
메모리적으로:
argv ──┬─> "prog\0"
├─> "arg1\0"
└─> "arg2\0"
argv 자체는 [포인터, 포인터, 포인터, NULL]의 연속 배열.
12) 함수 인자 전달: 배열은 왜 포인터로 느껴질까
void foo(int a[10]) { // 사실상 int *a 와 동일
printf("%zu\n", sizeof(a)); // 8 (포인터 크기)
}
함수 인자로 배열을 넘기면 포인터로 decay합니다. 길이가 필요한 함수라면 길이를 별도 인자로 받거나, struct로 묶는 패턴이 안전합니다.
13) 고급 주제 한 숟갈
restrict: 같은 객체를 서로 다른 포인터가 가리키지 않는다는 약속으로 최적화 유도 (void foo(int restrict a, int restrict b, size_t n)).
원자적 접근: 멀티스레드에서 포인터/데이터 접근은 <stdatomic.h>의 원자 타입을 고려.
안전한 캐스팅: 네트워크/파일 바이너리 파싱 시는 무조건 바이트 단위 파서(엔디언 변환 함수)로 접근. 구조체 재해석은 금물.
14) 체크리스트 (실전용)
malloc(N sizeof(p)) — 타입 변경에도 안전
free 후 p=NULL
경계 검사(OOB 금지)
sizeof(arr) vs sizeof(ptr) 혼동 금지
구조체 직렬화 금지(명시적 변환 사용)
함수 포인터 시그니처 일치 확인
const의 위치로 무엇이 불변인지 분명히
포인터 연산은 요소 크기 단위임을 기억
15) 마무리
포인터는 “주소”라는 단순한 출발점에서 타입, 정렬, 엔디언, 배열 디케이, 구조체 패딩, 동적 메모리, 함수 포인터까지 연결됩니다. 핵심은 두 가지예요.
타입이 해석을 결정한다 — 같은 바이트라도 int/char로 전혀 다르게 보인다.
경계/수명 관리 — OOB, UAF, Double-free를 철저히 차단하라.