포인터는 화살표가 아니다 — C와 C++가 하드웨어와 대화하는 방식
포인터를 화살표로 배운 탓에 저수준 프로그래밍이 어려워진다는 주장을 담은 글이다. 메모리를 번호 붙은 사물함 거리로 보면 포인터 연산, 배열 붕괴, 세그멘테이션 폴트가 모두 단순한 계산으로 풀린다.

Pointers Aren't Arrows. How C and C++ Actually Talk to Hardware.
A visual, beginner-friendly guide to memory addresses, pointer arithmetic, and what happens when your code touches physical silicon. Discover why pointers are just numbers, why arrays decay, and what a Segmentation Fault really means.
개요 #
화이트보드에 포인터를 그려보라고 하면 대부분 상자 두 개와 그 사이를 잇는 화살표를 그린다. 40년 넘게 강의실에서 쓰인 그림이다. Dev.to에 올라온 이 글은 바로 그 그림이 C와 C++를 배우는 사람들을 벽에 부딪히게 만든다고 말한다.
이유는 간단하다. 컴퓨터 안에 화살표는 없다. CPU 어디에도 "화살표"라고 쓰인 배선은 없고, "방향"을 저장하는 레지스터도 없다. 저자가 내세우는 대안은 한 문장이다. 포인터는 주소를 담은 평범한 정수일 뿐이다.
화살표를 버리고 실리콘의 눈으로 보면 포인터 연산은 마법이 아니라 곱셈이 되고, 배열 붕괴(array decay)는 당연한 결과가 되며, 악명 높은 세그멘테이션 폴트는 무작위 재앙이 아니라 운영체제의 보호 장치로 바뀐다.
램은 번호 붙은 사물함이 늘어선 거리다 #
파일도 변수도 객체도 잠시 잊자. CPU에게 시스템 메모리는 번호가 매겨진 바이트 사물함이 끝없이 늘어선 거리에 불과하다. 사물함 하나에 정확히 1바이트가 들어가고, 모든 사물함에는 순차적인 번지수가 붙어 있다. 그 번지수가 주소(address) 다.
사물함 주소: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005
저장된 값: [ 0x2A ] [ 0x00 ] [ 0x00 ] [ 0x00 ] [ 0x48 ] [ 0x69 ]C에서 평범한 변수를 하나 선언해보자.
int score = 42;컴파일러는 "score"라는 이름표가 붙은 상자를 만들지 않는다. 32비트 int가 4바이트를 요구하니 연속된 사물함 4칸을 예약하고, 거기에 42를 이진수로 적어 넣은 뒤, 내부 심볼 테이블에 이렇게 기록한다.
프로그래머가
score라고 쓰면0x1000번 사물함을 찾아가라.
score라는 이름은 사람이 읽기 위해서만 존재한다. 코드가 기계어로 컴파일되는 순간 이름은 영영 사라지고, CPU는 오직 숫자 주소만 본다.
화살표라는 거짓말 깨기 #
이제 포인터를 넣어보자.
int score = 42;
int* ptr = &score;앰퍼샌드 &는 주소 연산자다. 묻는 건 하나다. "score가 사는 집 번호가 뭐지?" score가 0x1000에 산다면 &score는 숫자 0x1000으로 평가된다.
ptr도 다른 변수와 똑같은 변수라 살 공간이 필요하다. 컴파일러가 0x2000에 자리를 잡아준다. 그리고 그 안에 넣는 값은 숫자 0x1000. 그게 전부다.

요즘 64비트 운영체제에서 주소는 64비트, 즉 8바이트 폭이다. 무엇을 가리키든 모든 포인터 변수는 정확히 8바이트를 차지한다는 뜻이다. 터미널에서 직접 확인할 수 있다.
#include <stdio.h>
struct HugePayload {
char data[10000];
};
int main(void) {
char* c_ptr;
int* i_ptr;
struct HugePayload* struct_ptr;
printf("char* size: %zu bytes\n", sizeof(c_ptr));
printf("int* size: %zu bytes\n", sizeof(i_ptr));
printf("struct* size: %zu bytes\n", sizeof(struct_ptr));
return 0;
}출력은 이렇다.
char* size: 8 bytes
int* size: 8 bytes
struct* size: 8 bytes포인터가 정말 화살표라면 1바이트짜리 char를 가리킬 때보다 10,000바이트 구조체를 가리킬 때 더 굵고 긴 화살표가 필요했을 것이다. 현실에서 번지수는 대상이 원룸이든 대형 창고든 길이가 같다. 그냥 주소니까.
역참조(*ptr)가 CPU에 시키는 일도 단순하다. ptr 안에 든 번지수 0x1000을 읽고, 0x1000번 사물함으로 가서, 거기 있는 바이트를 읽거나 쓴다.
ptr + 1이 1을 더하지 않는 이유 #
초심자가 처음 놀라는 지점이다.
int numbers[3] = {10, 20, 30};
int* p = &numbers[0];
printf("Before: %p\n", (void*)p);
p = p + 1;
printf("After: %p\n", (void*)p);주소가 1 증가하리라 기대하고 실행하면 결과는 다르다.
Before: 0x1000
After: 0x10044바이트를 건너뛰었다. 컴파일러가 p의 타입이 int*라는 걸 알고, int가 4바이트를 차지한다는 것도 알기 때문이다. 만약 p + 1이 1바이트만 전진했다면 p는 정수의 이진 표현 한가운데를 가리키게 되고, 읽히는 건 깨진 데이터다.

포인터 연산에서 컴파일러는 모든 걸음을 대상 타입 크기만큼 곱해서 늘린다.
목적지 주소 = 현재 주소 + (걸음 수 * sizeof(*p))char*(1바이트):p + 1은 1바이트 전진int*(4바이트):p + 1은 4바이트 전진double*(8바이트):p + 1은 8바이트 전진
표준 ISO C가 void* 연산을 금지하는 이유도 여기 있다. void에는 정의된 크기가 없으니 컴파일러가 거리에서 몇 바이트를 나아가야 할지 계산할 방법이 없다.
배열 붕괴의 충격 — arr[i]와 i[arr]가 같은 이유 #
자바스크립트나 자바에서 배열은 메서드와 길이 속성을 가진 객체다. C와 C++에서는 다르다. 배열에는 런타임 메타데이터가 하나도 없다. 나란히 붙어 있는 바이트 사물함 묶음일 뿐이다.
int arr[4] = {10, 20, 30, 40};컴파일러는 4바이트 정수 네 개를 0x1000, 0x1004, 0x1008, 0x100C에 연달아 배치한다. 그리고 C 명세에서 대괄호 문법 arr[i]의 정의는 이것이다.
arr[i]는 *(arr + i)와 동일하다대괄호는 포인터 연산과 역참조를 감싼 문법 설탕에 불과하다.
![The Array Decay Illusion: Why arr[i] Equals i[arr]](https://i.imgur.com/mjoRYX9.png)
덧셈은 교환법칙이 성립하므로(a + b == b + a) *(arr + 2)와 *(2 + arr)는 같다. *(arr + 2)를 arr[2]로 쓸 수 있다면, *(2 + arr)도 2[arr]로 쓸 수 있다는 말이 된다.
#include <stdio.h>
int main(void) {
int arr[4] = {10, 20, 30, 40};
printf("arr[2] = %d\n", arr[2]);
printf("*(arr+2) = %d\n", *(arr + 2));
printf("2[arr] = %d\n", 2[arr]);
return 0;
}arr[2] = 30
*(arr+2) = 30
2[arr] = 30물론 실무 코드에 2[arr]를 쓰라는 얘기는 아니다. 다만 왜 되는지 알면 구조가 선명해진다. C의 배열에는 마법 같은 조회 엔진이 없다. 기준 주소와 오프셋, 그리고 바이트 인출만 있다.
스택과 힙, 램 안의 물리적 현실 #
이 번지수들은 어디서 오는가. 프로그램이 실행되면 운영체제는 가상 메모리 영역을 할당하고, 그 안을 두 개의 주요 작업 구역으로 나눈다. 스택과 힙이다.

스택 — CPU 레지스터 하나로 끝난다 #
스택이 빠른 건 장부 관리가 거의 필요 없어서다. CPU에는 전용 하드웨어 레지스터 RSP(x86_64의 스택 포인터)가 있다.
지역 변수가 있는 함수를 호출하면,
void calculate(void) {
int a = 10;
int b = 20;
}CPU는 어셈블리 명령 한 줄을 실행한다.
sub rsp, 16
1나노초도 안 되는 사이에 스택 포인터가 16바이트 아래로 내려가고, 새로 열린 그 공간이 a와 b의 몫이 된다. 함수가 끝나면,
add rsp, 16
ret
스택 포인터가 다시 위로 미끄러진다. 해제는 즉시 끝난다.
여기서 눈여겨볼 대목이 있다. CPU는 그 메모리 바이트를 지운 적이 없다. 값은 사물함에 그대로 남아 있고, 스택 포인터만 지나쳐 갔을 뿐이다. 다음에 호출되는 함수가 그 위를 덮어쓴다. C에서 초기화하지 않은 지역 변수에 쓰레기 값이 들어 있는 이유가 이것이다.
힙 — 동적 창고 #
데이터가 자신을 만든 함수보다 오래 살아야 하거나, 크기를 런타임에야 알 수 있다면 힙에서 할당한다.
int* buffer = (int*)malloc(1024 * sizeof(int));malloc은 레지스터를 빼는 것만으로 끝나지 않는다. 힙 할당자(glibc의 ptmalloc 같은)는 자유 메모리 빈(bin)들의 연결 리스트를 뒤져 적당한 블록을 찾는다. 미리 확보한 공간이 부족하면 커널 시스템 콜(brk 또는 mmap)을 날려 경계를 넓힌다.
다 쓰면 블록을 반납한다.
free(buffer);댕글링 포인터 함정 #
free(buffer)를 호출하면 실제로 벌어지는 일은 세 가지다.
- 할당자가 그 메모리 블록을 "앞으로 할당 가능"으로 표시한다.
buffer변수는 건드리지 않는다.- 메모리 안의 바이트도 지우지 않는다.
buffer는 여전히 옛 주소를 들고 있다. 나중에 *buffer에 접근하면 Use-After-Free 버그가 터진다. 그 사이 다른 스레드나 함수가 같은 주소를 할당받았을 수 있고, 그렇다면 남의 살아 있는 데이터를 읽거나 덮어쓰는 셈이다.
그래서 반납한 직후에 곧바로 이렇게 해둔다.
free(buffer);
buffer = NULL;세그멘테이션 폴트가 일어날 때 실제로 벌어지는 일 #
전형적인 크래시를 보자.
int* ptr = NULL;
*ptr = 100;프로그램은 즉시 멈춘다.
Segmentation fault (core dumped)그 몇 밀리초 동안 기계 안에서 무슨 일이 있었을까. 코드가 물리 램을 망가뜨린 게 아니다. 애초에 이 포인터는 물리 램을 건드린 적조차 없다.

하드웨어 파수꾼, MMU #
현대 운영체제는 응용 프로그램에 생 물리 메모리를 직접 노출하지 않는다. 대신 가상 메모리를 쓴다. CPU 코어와 물리 메모리 사이에는 MMU(메모리 관리 장치)가 앉아 있다.
메모리는 4KB 페이지 단위로 조직되고, 운영체제는 가상 페이지를 물리 램 프레임에 대응시키는 페이지 테이블을 관리한다.
- 가상 페이지
0x1000→ 물리 램0x8A40(읽기/쓰기) - 가상 페이지
0x0000(NULL) → 매핑 없음 (접근 불가)
ptr = NULL 상태에서 *ptr = 100을 실행하면 이런 순서로 진행된다.
- CPU가 주소
0x0을 MMU로 보낸다. - MMU가 페이지 테이블에서
0x0을 찾지만 유효한 매핑이 없다. - MMU가 쓰기를 차단하고 하드웨어 페이지 폴트 예외(CPU 인터럽트 14)를 발생시킨다.
- CPU가 프로그램을 멈추고 제어권을 OS 커널로 넘긴다.
- 커널이 위반을 확인하고 프로세스에 시그널 11(
SIGSEGV)을 보낸다. - 등록된 커스텀 시그널 핸들러가 없으므로 OS가 시스템 안정성을 위해 프로세스를 종료한다.
세그멘테이션 폴트는 컴퓨터 고장이 아니다. 불법적인 메모리 훼손을 운영체제가 적극적으로 막아선 보안 장벽이다.
이중 포인터 — **ptr 풀어보기 #
char**, int** 같은 이중 포인터가 헷갈리는 건 화살표를 겹쳐 그리려 들기 때문이다. 번지수 모델로 바꾸면 이렇게 정리된다.
- 일반 변수는 데이터를 담는다 (
val = 42). - 단일 포인터(
ptr)는 데이터의 집 번호를 담는다 (0x1000). - 이중 포인터(
pptr)는 다른 포인터 변수의 집 번호를 담는다 (0x2000).
int val = 99; // 주소 0x1000: 99를 담음
int* ptr = &val; // 주소 0x2000: 0x1000을 담음
int** pptr = &ptr; // 주소 0x3000: 0x2000을 담음왜 이게 필요한가. C는 모든 인자를 값으로 복사해 전달하기 때문이다. 함수가 호출자의 정수를 고치게 하려면 그 주소(int*)를 넘긴다. 마찬가지로 함수가 메모리를 할당해서 호출자의 포인터 자체를 바꾸게 하려면 포인터의 주소(int**)를 넘겨야 한다.
void allocate_memory(int** p) {
*p = (int*)malloc(sizeof(int)); // 호출자의 포인터 변수를 수정
}
int main(void) {
int* data = NULL;
allocate_memory(&data); // data의 주소를 전달
*data = 42;
free(data);
return 0;
}**p를 보면 화살표 대신 이렇게 생각하라는 게 저자의 조언이다. "포인터가 어디를 가리킬지 다시 쓰기 위해 그 포인터의 주소를 받고 있다."
생존용 치트 시트 #
| 문법 / 개념 | 화살표라는 거짓말 | 하드웨어의 현실 |
|---|---|---|
int* ptr | 정수를 가리키는 선 | 메모리 주소를 담은 8바이트 변수 |
&x | 화살표 그리기 | x의 사물함 번호를 숫자로 꺼내기 |
*ptr | 화살표 따라가기 | 그 주소의 바이트를 읽거나 쓰라고 CPU에 지시 |
ptr + 1 | 상자 하나 전진 | 램에서 주소 + (1 * sizeof(*ptr)) 바이트 전진 |
arr[i] | 배열 조회 | *(arr + i)의 축약. 직접 주소 오프셋 |
free(ptr) | 데이터 삭제 | 주소 예약을 힙 관리자에 반납 |
NULL (nullptr) | 빈 공간 | 주소 0x0. MMU 트랩을 유발하도록 매핑이 보장되지 않음 |
| 세그폴트 | 프로그램 오작동 | 불법 접근에 대해 OS가 받아낸 하드웨어 MMU 예외 |
관점 하나가 바꾸는 것 #
파이썬이나 자바스크립트 같은 고수준 언어는 가비지 컬렉션과 추상화로 개발자를 생 메모리에서 떼어놓는다. 편리함은 분명한 가치지만, 대가로 컴퓨터가 실리콘 수준에서 어떻게 동작하는지를 가린다. C와 C++는 그 절연층을 걷어내고 메모리 버스의 통제권을 그대로 넘긴다.
저자의 결론은 처음 주장으로 돌아온다. 포인터는 신비한 존재도, 허공에 뜬 화살표도 아니다. 컴퓨터가 매 마이크로초마다 소프트웨어를 실행하며 쓰는 숫자 좌표일 뿐이다. 메모리를 번호 붙은 사물함 거리로 그리는 순간 포인터 연산도, 배열 붕괴도, 메모리 할당도 제자리를 찾는다.
글 말미에서 저자는 독자에게 질문을 던진다. C나 C++에서 도무지 알 수 없는 세그폴트나 포인터 버그와 씨름한 적이 있다면, 그건 어떤 버그였고 포인터가 마침내 이해된 순간은 언제였느냐고.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.
