C 언어 포인터 심화
C 언어 포인터 심화¶
목표¶
- 포인터의 동작 원리를 깊이 이해한다
- 다양한 포인터 활용 패턴을 익힌다
- 포인터 관련 흔한 실수를 피하는 방법을 배운다
난이도: ⭐⭐⭐ (중급)
1. 포인터 기초 복습¶
메모리와 주소¶
컴퓨터 메모리는 바이트 단위로 주소가 부여된 연속적인 공간입니다.
#include <stdio.h>
int main(void) {
int x = 42;
printf("값: %d\n", x); // 42
printf("주소: %p\n", (void*)&x); // 0x7ffd12345678 (예시)
printf("크기: %zu 바이트\n", sizeof(x)); // 4
return 0;
}
포인터 선언과 초기화¶
int x = 10;
int *p; // 포인터 선언
p = &x; // 주소 할당
// 선언과 동시에 초기화 (권장)
int *q = &x;
// 초기화하지 않은 포인터는 위험!
int *danger; // 쓰레기 값 - 사용하면 안 됨
역참조 연산자 (*)¶
int x = 42;
int *p = &x;
printf("p가 가리키는 값: %d\n", *p); // 42
*p = 100; // x의 값이 100으로 변경
printf("x의 새 값: %d\n", x); // 100
NULL 포인터¶
int *p = NULL; // 아무것도 가리키지 않음
// NULL 체크는 필수!
if (p != NULL) {
printf("%d\n", *p);
} else {
printf("포인터가 NULL입니다\n");
}
// C11부터 nullptr도 사용 가능 (일부 컴파일러)
void 포인터¶
어떤 타입이든 가리킬 수 있는 범용 포인터입니다.
void *generic;
int x = 42;
double d = 3.14;
char c = 'A';
generic = &x; // OK
generic = &d; // OK
generic = &c; // OK
// 역참조 시 캐스팅 필요
printf("%d\n", *(int*)generic); // 타입 캐스팅 후 역참조
void 포인터 용도:
- malloc() 반환 타입
- 범용 함수 작성 (예: qsort, memcpy)
2. 포인터 산술¶
포인터 증가/감소¶
포인터에 1을 더하면 가리키는 타입의 크기만큼 주소가 증가합니다.
int arr[] = {10, 20, 30, 40, 50};
int *p = arr;
printf("p: %p, *p: %d\n", (void*)p, *p); // arr[0] = 10
p++;
printf("p: %p, *p: %d\n", (void*)p, *p); // arr[1] = 20
p += 2;
printf("p: %p, *p: %d\n", (void*)p, *p); // arr[3] = 40
포인터로 배열 순회¶
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr) / sizeof(arr[0]);
// 방법 1: 인덱스 사용
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
// 방법 2: 포인터 산술
for (int *p = arr; p < arr + n; p++) {
printf("%d ", *p);
}
// 방법 3: 포인터와 인덱스 혼합
int *p = arr;
for (int i = 0; i < n; i++) {
printf("%d ", *(p + i)); // p[i]와 동일
}
포인터 간 뺄셈¶
두 포인터 사이의 요소 개수를 반환합니다.
int arr[] = {10, 20, 30, 40, 50};
int *start = &arr[0];
int *end = &arr[4];
ptrdiff_t diff = end - start; // 4 (바이트가 아닌 요소 수)
printf("요소 개수: %td\n", diff);
포인터 비교¶
int arr[] = {1, 2, 3, 4, 5};
int *p1 = &arr[1];
int *p2 = &arr[3];
if (p1 < p2) {
printf("p1이 더 앞쪽 주소\n"); // 이 줄이 출력됨
}
// 같은 배열의 포인터만 비교 가능
// 다른 배열 포인터 비교는 정의되지 않은 동작
3. 배열과 포인터¶
배열 이름의 의미¶
배열 이름은 대부분의 상황에서 첫 번째 요소의 주소로 변환됩니다.
int arr[5] = {1, 2, 3, 4, 5};
printf("arr: %p\n", (void*)arr); // 같은 주소
printf("&arr[0]: %p\n", (void*)&arr[0]); // 같은 주소
int *p = arr; // int *p = &arr[0];과 동일
예외 상황:
// sizeof는 전체 배열 크기 반환
printf("sizeof(arr): %zu\n", sizeof(arr)); // 20 (5 * 4바이트)
// &arr은 배열 전체의 주소 (타입이 다름)
printf("arr: %p\n", (void*)arr); // int* 타입
printf("&arr: %p\n", (void*)&arr); // int(*)[5] 타입
// 주소는 같지만 +1의 의미가 다름
printf("arr + 1: %p\n", (void*)(arr + 1)); // 4바이트 증가
printf("&arr + 1: %p\n", (void*)(&arr + 1)); // 20바이트 증가
배열 인덱싱의 진실¶
arr[i]는 *(arr + i)의 문법적 설탕(syntactic sugar)입니다.
int arr[] = {10, 20, 30};
// 모두 동일한 값
printf("%d\n", arr[1]); // 20
printf("%d\n", *(arr + 1)); // 20
printf("%d\n", *(1 + arr)); // 20
printf("%d\n", 1[arr]); // 20 (이상하지만 합법!)
2차원 배열¶
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// 요소 접근
printf("%d\n", matrix[1][2]); // 7
printf("%d\n", *(*(matrix + 1) + 2)); // 7
// matrix는 int[4] 배열을 가리키는 포인터로 변환됨
// matrix[i]는 i번째 행의 첫 번째 요소 주소
포인터 배열 vs 배열 포인터¶
// 포인터 배열: 포인터들의 배열
int *ptr_arr[3]; // int* 3개를 담는 배열
int a = 1, b = 2, c = 3;
ptr_arr[0] = &a;
ptr_arr[1] = &b;
ptr_arr[2] = &c;
// 배열 포인터: 배열을 가리키는 포인터
int (*arr_ptr)[4]; // int[4] 배열을 가리키는 포인터
int arr[4] = {1, 2, 3, 4};
arr_ptr = &arr;
printf("%d\n", (*arr_ptr)[2]); // 3
선언 읽는 법:
int *ptr_arr[3]; // [3]이 먼저 → ptr_arr은 크기 3인 배열
// *이 다음 → 요소가 포인터
// int → int에 대한 포인터
int (*arr_ptr)[4]; // *이 먼저 (괄호) → arr_ptr은 포인터
// [4]가 다음 → 크기 4인 배열을 가리킴
// int → int 배열
4. 다중 포인터¶
이중 포인터 (Pointer to Pointer)¶
int x = 42;
int *p = &x;
int **pp = &p;
printf("x: %d\n", x); // 42
printf("*p: %d\n", *p); // 42
printf("**pp: %d\n", **pp); // 42
// 주소 관계
printf("&x: %p\n", (void*)&x); // x의 주소
printf("p: %p\n", (void*)p); // x의 주소
printf("&p: %p\n", (void*)&p); // p의 주소
printf("pp: %p\n", (void*)pp); // p의 주소
이중 포인터 활용: 함수에서 포인터 수정¶
#include <stdio.h>
#include <stdlib.h>
// 잘못된 방법: 포인터의 복사본이 전달됨
void allocate_wrong(int *p, int size) {
p = malloc(size * sizeof(int)); // 로컬 p만 변경됨
// 호출자의 포인터는 변경되지 않음
}
// 올바른 방법: 이중 포인터 사용
void allocate_correct(int **pp, int size) {
*pp = malloc(size * sizeof(int)); // 호출자의 포인터를 변경
}
int main(void) {
int *arr = NULL;
allocate_wrong(arr, 5);
printf("wrong: %p\n", (void*)arr); // NULL
allocate_correct(&arr, 5);
printf("correct: %p\n", (void*)arr); // 유효한 주소
free(arr);
return 0;
}
동적 2차원 배열¶
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int rows = 3, cols = 4;
// 방법 1: 포인터 배열 (행마다 별도 할당)
int **matrix = malloc(rows * sizeof(int*));
for (int i = 0; i < rows; i++) {
matrix[i] = malloc(cols * sizeof(int));
}
// 사용
matrix[1][2] = 42;
printf("%d\n", matrix[1][2]);
// 해제 (역순으로!)
for (int i = 0; i < rows; i++) {
free(matrix[i]);
}
free(matrix);
// 방법 2: 연속 메모리 할당 (캐시 효율적)
int *flat = malloc(rows * cols * sizeof(int));
// flat[i * cols + j]로 접근
flat[1 * cols + 2] = 42;
free(flat);
return 0;
}
문자열 배열 (명령줄 인자)¶
#include <stdio.h>
int main(int argc, char *argv[]) {
// argv는 char* 배열
// argv[0]: 프로그램 이름
// argv[1] ~ argv[argc-1]: 인자들
printf("인자 개수: %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
// 문자열 배열 직접 만들기
char *fruits[] = {"apple", "banana", "cherry"};
int n = sizeof(fruits) / sizeof(fruits[0]);
for (int i = 0; i < n; i++) {
printf("%s\n", fruits[i]);
}
5. 함수 포인터¶
기본 선언과 사용¶
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int main(void) {
// 함수 포인터 선언
int (*fp)(int, int);
// 함수 주소 할당
fp = add; // 또는 fp = &add;
printf("add: %d\n", fp(3, 4)); // 7
fp = sub;
printf("sub: %d\n", fp(3, 4)); // -1
fp = mul;
printf("mul: %d\n", fp(3, 4)); // 12
return 0;
}
typedef로 가독성 높이기¶
// 함수 포인터 타입 정의
typedef int (*Operation)(int, int);
int add(int a, int b) { return a + b; }
int main(void) {
Operation op = add;
printf("%d\n", op(5, 3)); // 8
// 함수 포인터 배열
Operation ops[] = {add, sub, mul};
for (int i = 0; i < 3; i++) {
printf("%d\n", ops[i](10, 3));
}
return 0;
}
콜백 함수¶
#include <stdio.h>
// 콜백 타입 정의
typedef void (*Callback)(int);
void process_array(int *arr, int size, Callback cb) {
for (int i = 0; i < size; i++) {
cb(arr[i]);
}
}
void print_value(int x) {
printf("%d ", x);
}
void print_double(int x) {
printf("%d ", x * 2);
}
int main(void) {
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr) / sizeof(arr[0]);
printf("원본: ");
process_array(arr, n, print_value);
printf("\n");
printf("두 배: ");
process_array(arr, n, print_double);
printf("\n");
return 0;
}
qsort 활용¶
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 비교 함수: 오름차순
int compare_int_asc(const void *a, const void *b) {
return *(int*)a - *(int*)b;
}
// 비교 함수: 내림차순
int compare_int_desc(const void *a, const void *b) {
return *(int*)b - *(int*)a;
}
// 문자열 비교
int compare_str(const void *a, const void *b) {
return strcmp(*(char**)a, *(char**)b);
}
int main(void) {
// 정수 정렬
int nums[] = {3, 1, 4, 1, 5, 9, 2, 6};
int n = sizeof(nums) / sizeof(nums[0]);
qsort(nums, n, sizeof(int), compare_int_asc);
for (int i = 0; i < n; i++) {
printf("%d ", nums[i]);
}
printf("\n"); // 1 1 2 3 4 5 6 9
// 문자열 정렬
char *words[] = {"banana", "apple", "cherry"};
int wn = sizeof(words) / sizeof(words[0]);
qsort(words, wn, sizeof(char*), compare_str);
for (int i = 0; i < wn; i++) {
printf("%s ", words[i]);
}
printf("\n"); // apple banana cherry
return 0;
}
6. 동적 메모리 관리¶
malloc, calloc, realloc, free¶
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
// malloc: 초기화 없이 할당
int *arr1 = malloc(5 * sizeof(int));
// 값이 쓰레기! 초기화 필요
// calloc: 0으로 초기화하여 할당
int *arr2 = calloc(5, sizeof(int));
// 모든 값이 0
// realloc: 크기 변경
arr1 = realloc(arr1, 10 * sizeof(int));
// 기존 값 유지, 추가 공간은 초기화 안 됨
// NULL 체크 필수!
if (arr1 == NULL || arr2 == NULL) {
fprintf(stderr, "메모리 할당 실패\n");
return 1;
}
// 사용 후 해제
free(arr1);
free(arr2);
// 해제 후 NULL로 설정 (선택적이지만 권장)
arr1 = NULL;
arr2 = NULL;
return 0;
}
메모리 누수 방지¶
// 잘못된 패턴: 메모리 누수
void memory_leak(void) {
int *p = malloc(100);
// free 없이 함수 종료 → 누수!
}
// 올바른 패턴
void no_leak(void) {
int *p = malloc(100);
if (p == NULL) return;
// 작업 수행...
free(p); // 반드시 해제
}
// 에러 처리 시 주의
int process(void) {
int *a = malloc(100);
int *b = malloc(200);
if (a == NULL || b == NULL) {
free(a); // NULL이어도 free 호출 가능
free(b);
return -1;
}
// 작업 수행...
free(a);
free(b);
return 0;
}
realloc 안전하게 사용하기¶
// 위험한 패턴
p = realloc(p, new_size); // 실패 시 원본 주소 유실!
// 안전한 패턴
int *temp = realloc(p, new_size);
if (temp == NULL) {
// p는 여전히 유효
free(p);
return NULL;
}
p = temp;
7. const와 포인터¶
네 가지 조합¶
int x = 10;
int y = 20;
// 1. 일반 포인터
int *p1 = &x;
*p1 = 30; // OK: 값 변경 가능
p1 = &y; // OK: 다른 주소 가리키기 가능
// 2. const int* (pointer to const int)
// = int const *
const int *p2 = &x;
// *p2 = 30; // 에러: 값 변경 불가
p2 = &y; // OK: 다른 주소 가리키기 가능
// 3. int* const (const pointer to int)
int *const p3 = &x;
*p3 = 30; // OK: 값 변경 가능
// p3 = &y; // 에러: 다른 주소 가리키기 불가
// 4. const int* const (const pointer to const int)
const int *const p4 = &x;
// *p4 = 30; // 에러: 값 변경 불가
// p4 = &y; // 에러: 다른 주소 가리키기 불가
읽는 방법¶
오른쪽에서 왼쪽으로 읽으세요:
const int *p; // p는 포인터, int const를 가리킴
int *const p; // p는 const 포인터, int를 가리킴
const int *const p; // p는 const 포인터, int const를 가리킴
함수 매개변수에서의 const¶
// 입력 전용: 값을 변경하지 않음을 명시
void print_array(const int *arr, int size) {
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
// arr[i] = 0; // 컴파일 에러!
}
}
// 문자열은 항상 const char*로 받기
void print_str(const char *str) {
while (*str) {
putchar(*str++);
}
}
8. 문자열과 포인터¶
문자열 리터럴 vs 문자 배열¶
// 문자열 리터럴: 읽기 전용 메모리
char *str1 = "Hello";
// str1[0] = 'h'; // 정의되지 않은 동작! (대부분 크래시)
// 문자 배열: 수정 가능
char str2[] = "Hello";
str2[0] = 'h'; // OK
// const 사용 권장
const char *str3 = "Hello"; // 의도를 명확히
문자열 함수 직접 구현¶
#include <stdio.h>
// strlen 구현
size_t my_strlen(const char *s) {
const char *p = s;
while (*p) p++;
return p - s;
}
// strcpy 구현
char *my_strcpy(char *dest, const char *src) {
char *ret = dest;
while ((*dest++ = *src++));
return ret;
}
// strcmp 구현
int my_strcmp(const char *s1, const char *s2) {
while (*s1 && (*s1 == *s2)) {
s1++;
s2++;
}
return *(unsigned char*)s1 - *(unsigned char*)s2;
}
// strcat 구현
char *my_strcat(char *dest, const char *src) {
char *ret = dest;
while (*dest) dest++; // 끝으로 이동
while ((*dest++ = *src++));
return ret;
}
int main(void) {
char buffer[100] = "Hello";
printf("길이: %zu\n", my_strlen(buffer)); // 5
my_strcat(buffer, " World");
printf("%s\n", buffer); // Hello World
return 0;
}
문자열 배열¶
// 방법 1: 포인터 배열 (다른 길이 가능)
const char *names1[] = {
"Alice",
"Bob",
"Charlie"
};
// 방법 2: 2차원 배열 (고정 길이)
char names2[][10] = {
"Alice",
"Bob",
"Charlie"
};
// 차이점
printf("sizeof(names1[0]): %zu\n", sizeof(names1[0])); // 8 (포인터 크기)
printf("sizeof(names2[0]): %zu\n", sizeof(names2[0])); // 10 (배열 크기)
9. 구조체와 포인터¶
구조체 포인터 기본¶
#include <stdio.h>
#include <string.h>
typedef struct {
char name[50];
int age;
double height;
} Person;
int main(void) {
Person p1 = {"Alice", 25, 165.5};
Person *ptr = &p1;
// 멤버 접근: -> 연산자
printf("이름: %s\n", ptr->name); // (*ptr).name과 동일
printf("나이: %d\n", ptr->age);
// 값 수정
ptr->age = 26;
return 0;
}
동적 구조체¶
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *name; // 동적 할당할 문자열
int age;
} Person;
Person *create_person(const char *name, int age) {
Person *p = malloc(sizeof(Person));
if (p == NULL) return NULL;
p->name = malloc(strlen(name) + 1);
if (p->name == NULL) {
free(p);
return NULL;
}
strcpy(p->name, name);
p->age = age;
return p;
}
void free_person(Person *p) {
if (p) {
free(p->name);
free(p);
}
}
int main(void) {
Person *alice = create_person("Alice", 25);
if (alice) {
printf("%s, %d\n", alice->name, alice->age);
free_person(alice);
}
return 0;
}
자기참조 구조체 (연결 리스트)¶
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next; // 자기 자신을 가리키는 포인터
} Node;
// 노드 생성
Node *create_node(int data) {
Node *node = malloc(sizeof(Node));
if (node) {
node->data = data;
node->next = NULL;
}
return node;
}
// 앞에 추가
void push_front(Node **head, int data) {
Node *new_node = create_node(data);
if (new_node) {
new_node->next = *head;
*head = new_node;
}
}
// 출력
void print_list(Node *head) {
while (head) {
printf("%d -> ", head->data);
head = head->next;
}
printf("NULL\n");
}
// 전체 해제
void free_list(Node *head) {
while (head) {
Node *temp = head;
head = head->next;
free(temp);
}
}
int main(void) {
Node *list = NULL;
push_front(&list, 3);
push_front(&list, 2);
push_front(&list, 1);
print_list(list); // 1 -> 2 -> 3 -> NULL
free_list(list);
return 0;
}
10. 흔한 실수와 디버깅¶
댕글링 포인터 (Dangling Pointer)¶
해제된 메모리를 가리키는 포인터입니다.
// 위험한 코드
int *p = malloc(sizeof(int));
*p = 42;
free(p);
// p는 여전히 같은 주소를 가리킴 (댕글링 포인터)
printf("%d\n", *p); // 정의되지 않은 동작!
// 해결책
free(p);
p = NULL; // 명시적으로 NULL 설정
if (p != NULL) {
printf("%d\n", *p); // NULL 체크로 방어
}
Use After Free¶
// 위험한 패턴
char *str = malloc(100);
strcpy(str, "Hello");
free(str);
// ...
printf("%s\n", str); // 해제된 메모리 접근!
Double Free¶
// 위험한 코드
int *p = malloc(sizeof(int));
free(p);
free(p); // 같은 메모리 두 번 해제 → 크래시 가능
// 해결책
free(p);
p = NULL;
free(p); // NULL free는 안전함
버퍼 오버플로우¶
// 위험한 코드
char buffer[10];
strcpy(buffer, "This is a very long string"); // 오버플로우!
// 안전한 코드
char buffer[10];
strncpy(buffer, "This is a very long string", sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
// 또는 snprintf 사용
snprintf(buffer, sizeof(buffer), "%s", "This is a very long string");
Valgrind로 메모리 오류 찾기¶
# 컴파일 (디버그 정보 포함)
gcc -g -o myprogram myprogram.c
# Valgrind 실행
valgrind --leak-check=full ./myprogram
Valgrind 출력 예시:
==12345== HEAP SUMMARY:
==12345== in use at exit: 100 bytes in 1 blocks
==12345== total heap usage: 5 allocs, 4 frees, 500 bytes allocated
==12345==
==12345== 100 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2BBAF: malloc (vg_replace_malloc.c:299)
==12345== by 0x400547: main (myprogram.c:10)
디버깅 팁¶
- 포인터 출력하기
printf("ptr = %p, *ptr = %d\n", (void*)ptr, ptr ? *ptr : -1);
- assert 사용하기
#include <assert.h>
void process(int *arr, int size) {
assert(arr != NULL);
assert(size > 0);
// ...
}
- AddressSanitizer 사용 (GCC/Clang)
gcc -fsanitize=address -g myprogram.c -o myprogram
./myprogram
연습 문제¶
문제 1: 배열 뒤집기¶
포인터만 사용하여 배열을 제자리에서 뒤집는 함수를 작성하세요.
void reverse_array(int *arr, int size);
// 예시: {1, 2, 3, 4, 5} → {5, 4, 3, 2, 1}
문제 2: 문자열 단어 뒤집기¶
"Hello World"를 "World Hello"로 변환하세요.
문제 3: 연결 리스트 뒤집기¶
단일 연결 리스트를 뒤집는 함수를 작성하세요.
Node *reverse_list(Node *head);
문제 4: 함수 포인터 계산기¶
사칙연산을 함수 포인터 배열로 구현하세요.
// 입력: "3 + 4" → 출력: 7
요약¶
| 개념 | 핵심 포인트 |
|---|---|
| 포인터 기본 | &(주소), *(역참조), NULL 체크 필수 |
| 포인터 산술 | 타입 크기만큼 증가/감소 |
| 배열과 포인터 | arr[i] == *(arr + i) |
| 다중 포인터 | 함수에서 포인터 수정 시 사용 |
| 함수 포인터 | 콜백, qsort 비교 함수 |
| 동적 메모리 | malloc/free, 누수 방지, realloc 안전 패턴 |
| const 포인터 | const int* vs int* const |
| 디버깅 | Valgrind, AddressSanitizer |