메모리 구조와 주소 체계
중앙처리장치(CPU)는 데이터를 처리할 때 메모리에서 정보를 읽어오며, 처리된 결과 역시 메모리에 다시 저장됩니다. 일반적으로 8GB, 16GB 등의 메모리를 갖춘 컴퓨터는 이 공간을 효율적으로 관리하기 위해 각각 1바이트 크기의 단위로 나누어 구성합니다.
컴퓨터 시스템의 기본 용어:
- 비트(bit): 2진수 0 또는 1 하나를 저장하는 최소 단위
- 바이트(Byte): 8비트로 구성
- KB, MB, GB, TB 등: 각각 1024 단위씩 증가 (예: 1KB = 1024B)
메모리는 마치 기숙사처럼 여러 방(메모리 단위)으로 나뉘며, 각 방에는 고유한 번호(주소)가 있습니다. 이 주소를 통해 CPU는 특정 위치의 데이터에 빠르게 접근할 수 있습니다. C 언어에서는 이러한 '주소' 개념을 포인터(pointer)라고 부릅니다. 즉, 다음과 같은 동등 관계가 성립합니다:
메모리 번호 == 주소 == 포인터
주소 생성 방식 이해하기
CPU가 특정 바이트에 접근하려면 그 위치를 정확히 알아야 하며, 이를 위해 하드웨어 수준에서 주소 체계를 설계합니다. 예를 들어, 32비트 아키텍처는 32개의 주소선(address bus)을 사용하며, 각 선은 전기 신호의 유무(0 또는 1)를 나타냅니다. 따라서 총 2^32개의 고유한 주소를 표현할 수 있으며, 각 주소는 4바이트(32비트)로 저장됩니다. 마찬가지로 64비트 시스템은 8바이트 크기의 주소를 사용합니다.
주소 연산자와 포인터 변수
C 언어에서 변수를 선언하면 메모리에 공간이 할당됩니다. 예를 들어:
#include <stdio.h>
int main() {
int value = 15;
printf("주소: %p\n", &value);
return 0;
}
여기서 &는 주소 연산자로, 변수의 첫 번째 바이트 주소를 반환합니다. 이 주소는 별도의 변수에 저장할 수 있는데, 바로 포인터 변수입니다:
int value = 15;
int* ptr = &value; // ptr은 value의 주소를 저장
이 경우 ptr의 타입인 int*는 "정수형 변수를 가리키는 포인터"임을 의미합니다.
역참조 연산자로 값 조작하기
저장된 주소를 통해 실제 데이터에 접근하려면 *(역참조 연산자)를 사용합니다:
int value = 15;
int* ptr = &value;
*ptr = 25; // value의 값이 25로 변경됨
이 코드에서 *ptr은 마치 value 자체처럼 동작하여 값을 수정할 수 있습니다.
포인터의 크기
포인터 변수의 크기는 플랫폼에 따라 달라지며, 데이터 타입과는 무관합니다:
- 32비트 시스템: 4바이트
- 64비트 시스템: 8바이트
다음 코드로 확인 가능:
printf("포인터 크기: %zu 바이트\n", sizeof(int*));
포인터 타입의 의미
포인터의 타입은 단순히 가리키는 데이터의 형식을 나타낼 뿐 아니라, 연산 시에도 영향을 미칩니다.
타입에 따른 역참조 범위
int num = 0x11223344;
char* c_ptr = (char*)#
int* i_ptr = #
*c_ptr = 0x00; // 첫 번째 바이트만 수정
*i_ptr = 0x00; // 전체 4바이트 수정
즉, char*는 1바이트, int*는 4바이트 접근 권한을 갖습니다.
포인터와 정수의 산술 연산
포인터에 정수를 더하거나 뺄 수 있으며, 이때 이동 거리는 포인터 타입에 따라 다릅니다:
int arr[3] = {10, 20, 30};
int* p = arr;
p++; // 다음 int 요소로 이동 (4바이트 전진)
이러한 특성 덕분에 배열 순회가 가능해집니다.
void* 포인터: 제네릭 포인터
void*는 어떤 타입의 주소라도 저장할 수 있는 범용 포인터입니다:
double d = 3.14;
void* v_ptr = &d; // 문제 없음
// 하지만 직접 역참조 불가
// *v_ptr = 5; // 컴파일 오류
이 포인터는 주로 함수 매개변수에서 다양한 데이터 타입을 처리할 때 사용됩니다.
const 키워드와 포인터 보호
const는 포인터를 통해 데이터가 의도치 않게 수정되는 것을 방지합니다.
const int fixed = 100;
int* bad_ptr = &fixed; // 경고 발생
const int* safe_ptr = &fixed; // 올바른 방법
//*safe_ptr = 200; // 컴파일 오류: 수정 금지
또한 포인터 자체를 상수로 만들 수도 있습니다:
int val = 50;
int* const locked_ptr = &val; // 주소 변경 불가
//locked_ptr = &another; // 오류
*locked_ptr = 60; // 값은 변경 가능
양쪽 모두에 const를 붙이면 완전히 불변의 포인터가 됩니다:
const int* const full_lock = &fixed; // 주소도, 값도 변경 불가
포인터 연산의 종류
포인터 ± 정수
배열 내 이동에 자주 사용됩니다:
int data[5] = {1,2,3,4,5};
int* start = data;
for(int i = 0; i < 5; i++) {
printf("%d ", *(start + i));
}
포인터 간의 뺄셈
두 포인터 사이에 존재하는 요소의 개수를 계산합니다:
char str[] = "hello";
char* begin = str;
char* end = str;
while(*end) end++;
printf("길이: %td\n", end - begin); // 5 출력
관계 연산
주로 반복문 조건에서 사용됩니다:
int* p = data;
while(p < data + 5) {
printf("%d ", *p++);
}
댕글링 포인터(Dangling Pointer) 문제와 해결법
유효하지 않은 메모리 주소를 가리키는 포인터를 말합니다. 주요 원인은 다음과 같습니다:
- 초기화되지 않은 로컬 포인터 변수
- 배열 범위를 초과한 접근
- 함수 종료 후 스택 메모리 해제된 지역 변수의 주소 반환
예시:
int* get_bad_pointer() {
int local = 42;
return &local; // 위험! 함수 종료 후 메모리 소멸
}
방지 방법:
- 포인터는
NULL로 초기화 - 사용 후 즉시
NULL대입 - 사용 전
if(ptr != NULL)검사 - 지역 변수의 주소 반환 금지
assert 매크로를 통한 안전성 강화
<assert.h>에 정의된 assert()는 디버깅 중 조건을 검증하는 데 유용합니다:
#include <assert.h>
void process(int* input) {
assert(input != NULL); // NULL 체크
*input *= 2;
}
릴리스 빌드에서는 #define NDEBUG를 정의하여 모든 assert를 비활성화할 수 있어 성능 저하를 방지합니다.
값 전달 vs 주소 전달
함수 호출 시 인수 전달 방식에는 두 가지가 있습니다.
값 전달(Pass by Value)
void swap_fail(int a, int b) {
int temp = a;
a = b;
b = temp; // 실제 변수에 영향 없음
}
형식 매개변수는 실인수의 사본이므로 수정이 반영되지 않습니다.
주소 전달(Pass by Address)
void swap_success(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp; // 실제 값 교환
}
// 호출 시:
swap_success(&x, &y);
이 방식은 함수 내부에서 호출자의 변수를 직접 수정할 수 있게 해줍니다.
문자열 길이 계산 함수 구현 예
#include <assert.h>
size_t my_strlen(const char* str) {
size_t len = 0;
assert(str != NULL);
while(str[len] != '\0') {
len++;
}
return len;
}
이처럼 포인터는 배열 순회, 동적 메모리 관리, 함수 간 상태 공유 등 다양한 저수준 작업에서 필수적인 도구입니다.