"포인터의 덧셈은 수학의 덧셈이 아닙니다. '보폭(Stride)'의 이동입니다."
수학에서 1000 + 1은 1001입니다.
하지만 C언어의 포인터 세계에서 0x1000 + 1은 0x1001일 수도, 0x1004일 수도, 심지어 0x2000일 수도 있습니다.
포인터 연산의 결과는 "그 포인터가 가리키는 자료형(Type)의 크기"에 따라 결정됩니다. 이것을 Scaling Factor(배율 계수)라고 합니다.
CPU가 포인터 덧셈 명령(ptr + 1)을 만나면 내부적으로 어떤 계산을 수행하는지 뜯어보겠습니다.
공식은 다음과 같습니다:
시각적으로 확인해 봅시다. 주소 0x1000에서 시작한다고 가정합니다.
char *ptr (1 Byte stride)char는 1바이트입니다. 보폭이 작습니다.
ptr (0x1000) -> [ char ]
ptr + 1 (0x1001) -> [ char ]
ptr + 2 (0x1002) -> [ char ]
실제 CPU 계산:
0x1000 + (1 * 1) = 0x1001
int *ptr (4 Byte stride)int는 4바이트입니다. 보폭이 큽니다.
ptr (0x1000) -> [ int (4 bytes) ]
ptr + 1 (0x1004) -> [ int (4 bytes) ] <-- 1을 더했는데 주소는 4가 증가!
ptr + 2 (0x1008) -> [ int (4 bytes) ]
실제 CPU 계산:
0x1000 + (1 * 4) = 0x1004
struct Big *ptr (1024 Byte stride)만약 1KB짜리 거대한 구조체라면?
ptr (0x1000) -> [ Struct Big ... ]
ptr + 1 (0x1400) -> [ Struct Big ... ] <-- 1을 더했는데 주소가 1024(0x400) 점프!
실제 CPU 계산:
0x1000 + (1 * 1024) = 0x1400
Q: 왜 그냥 +1 하면 1바이트만 이동하게 안 만들었나요? 헷갈리게...
A: "배열(Array)을 쉽게 다루기 위해서입니다."
여러분이 아파트 엘리베이터를 타고 있습니다.
+1을 누릅니다.만약 엘리베이터가 "3미터 올라가주세요"라고 입력해야 한다면 얼마나 불편할까요?
C언어 설계자는 프로그래머가 "몇 바이트 뒤로 갈지" 계산하느라 머리 쓰는 것을 원치 않았습니다. 대신 "몇 번째 요소(Element)로 갈지"만 생각하게 만든 것입니다.
이것이 바로 arr[i]가 존재하는 이유입니다.
사실 컴파일러에게 arr[i]는 *(arr + i)를 예쁘게 쓴 것(Syntactic Sugar)에 불과합니다. CPU는 i번째 방을 찾기 위해 자동으로 i * 크기를 곱해서 주소를 찾아갑니다.
void* 포인터의 함정 (주의!)void는 "크기가 없는(알 수 없는) 타입"입니다. 표준 C언어(Standard C)에서 void*에 덧셈을 하는 것은 불법(Undefined Behavior)입니다.
void *v_ptr = malloc(100);
v_ptr++; // Error! 크기를 모르는데 몇 칸을 갈지 어떻게 알아?
Tip: GCC 컴파일러는 편의상
void*연산을char*처럼 1바이트 이동으로 처리해주지만, 이식성을 위해 절대 의존하지 마십시오. 반드시 캐스팅 후 연산하세요.
하드웨어 제어, 통신 패킷 처리 등을 할 때는 자료형과 상관없이 무조건 1바이트 단위로 주소를 움직여야 할 때가 있습니다. 이때는 uint8_t* (unsigned char) 캐스팅을 사용하십시오.
#include <stdio.h>
#include <stdint.h> // uint8_t
struct Packet {
int id;
int data;
};
int main() {
struct Packet pkt[2]; // 구조체 배열
// 1. 일반적인 포인터 연산 (구조체 크기만큼 점프)
struct Packet *p_struct = pkt;
p_struct++; // +8 bytes 이동 (sizeof(struct Packet))
// 2. 바이트 단위 정밀 제어 (하드웨어 해킹 스타일)
// "구조체 시작점에서 정확히 5바이트 뒤의 값을 보고 싶다!"
uint8_t *p_byte = (uint8_t*)pkt;
uint8_t val = *(p_byte + 5);
// 이 방식은 패딩 바이트나 특정 레지스터에 접근할 때 필수적입니다.
return 0;
}
ptr2 - ptr1의 결과는 무엇일까요? 바이트 차이일까요?
아닙니다. "두 포인터 사이에 몇 개의 요소(Element)가 들어가는가"입니다.
즉, (Address2 - Address1) / sizeof(Type) 값이 반환됩니다.
생각해볼만한 점:
"포인터를 이해했다는 건, 이제 여러분이 메모리라는 건물의 '마스터키'를 쥐었다는 뜻입니다. 자료형이라는 껍데기를 벗겨내고(casting),char*를 이용해 1바이트 단위로 메모리를 수술할 수 있는 능력이 생겼으니까요."