Core Programming/C for Systems Engineering

C언어 고급 포인터 완벽 정리: 이중 포인터부터 함수 포인터, 포인터 배열 vs 배열 포인터 차이점

임베디드 친구 2024. 12. 14. 11:18
반응형

C 언어를 처음 배울 때 단일 포인터(int *p)의 벽을 겨우 넘어섰다고 안심하는 순간, 곧바로 더 거대한 장벽을 만나게 됩니다. 별 기호가 두 개 붙은 이중 포인터가 등장하고, 포인터와 배열 기호가 엉키기 시작하며, 심지어 함수 자체를 가리키는 포인터까지 나타납니다. 많은 학습자가 이 단계에서 문법적 혼란을 느끼고 포기하곤 합니다. 하지만 이 고급 포인터 개념들을 이해하지 못하면 오픈소스 코드를 분석하거나 하드웨어를 제어하는 저수준 드라이버 설계, 효율적인 메모리 동적 할당 레이아웃을 짜는 것이 불가능합니다. 난해하게 느껴졌던 포인터의 응용 구조들을 확실하게 정리해 드리겠습니다.

Generated by Gemini AI.

📌 핵심 요약 3줄

  • 이중 포인터의 목적: 주소값 자체를 담고 있는 포인터 변수의 메모리 주소를 다시 가리키는 변수이며, 주로 함수 내부에서 외부의 포인터 값을 근본적으로 변경할 때 활용합니다.
  • 함수 포인터와 콜백: 함수 역시 메모리상의 주소를 가집니다. 이 주소를 포인터에 담아두면 함수를 매개변수로 넘기거나 상황에 따라 실행할 함수를 동적으로 바꿀 수 있습니다.
  • 괄호 하나가 만드는 차이: 포인터 배열(*p[3])은 포인터들을 모아놓은 주소의 묶음이고, 배열 포인터((*p)[3])는 특정 크기를 가진 배열 전체를 통째로 가리키는 단 하나의 포인터입니다.

1. 한눈에 보는 고급 포인터 문법 비교

각 포인터 선언문이 가지는 의미와 메모리적 해석의 차이를 표로 직관적으로 정리했습니다.

포인터 종류 대표 선언 형식 핵심 가리키는 대상 실무 주요 활용처
이중 포인터 int **pp; 일반 포인터 변수의 주소값 함수 내에서 외부 포인터 변경, 2차원 동적 배열 동적 할당
함수 포인터 int (*p)(int, int); 함수의 코드 시작 주소 콜백(Callback) 함수 구현, 이벤트 핸들러 처리, 인터럽트 벡터 테이블
포인터 배열 char *p[4]; 여러 개의 주소값들을 담는 배열 문자열 배열 처리, 톱니형(Ragged) 2차원 배열 제어
배열 포인터 int (*p)[3]; 3개짜리 정수 배열 전체의 주소 2차원 배열을 함수의 매개변수로 안전하게 전달할 때

2. 이중 포인터 (Double Pointer)

이중 포인터는 '포인터 변수의 주소'를 담는 변수입니다. 주소의 주소를 타고 들어가는 구조입니다.

💻 이중 포인터를 활용한 외부 포인터 주소 변경 예제

C
 
#include <stdio.h>

// 함수 내부에서 외부 포인터 변수가 가리키는 대상을 직접 바꾸려면 주소의 주소가 필요합니다.
void changeValue(int **ptr) {
    **ptr = 20; // 역참조를 두 번 수행하여 최종 실체인 value 변수의 값을 바꿉니다.
}

int main() {
    int value = 10;
    int *p = &value;   // 싱글 포인터: 변수 value의 주소를 가짐
    int **pp = &p;     // 이중 포인터: 포인터 변수 p의 주소를 가짐

    printf("변경 전 값: %d\n", value);
    changeValue(pp);
    printf("변경 후 값: %d\n", value);

    return 0;
}

3. 함수 포인터 (Function Pointer)

C 언어에서 함수도 컴파일이 완료되면 코드 영역(Text Segment)이라는 메모리 공간에 주소를 잡고 올라갑니다. 이 함수의 시작 주소를 저장하는 것이 함수 포인터입니다.

💻 함수 포인터를 사용한 동적 연산(콜백 구조) 예제

C
 
#include <stdio.h>

int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }

// 세 번째 인자로 '정수 2개를 받고 정수를 반환하는 함수'의 주소를 입력받습니다.
void operate(int a, int b, int (*operation)(int, int)) {
    // 넘겨받은 함수 주소를 이용해 함수를 대리 호출합니다. (콜백 메커니즘)
    printf("연산 결과: %d\n", operation(a, b));
}

int main() {
    // 함수 포인터 변수 선언
    int (*func_ptr)(int, int); 

    func_ptr = add;
    operate(10, 5, func_ptr); // add 함수가 호출됨

    func_ptr = subtract;
    operate(10, 5, func_ptr); // subtract 함수가 호출됨

    return 0;
}

4. 포인터 배열 vs 배열 포인터

이 두 개념은 이름도 비슷하고 기호도 비슷해서 가장 오답률이 높은 구간입니다. 핵심은 연산자 우선순위입니다. 대괄호([])가 참조 별 기호(*)보다 우선순위가 높다는 것만 기억하면 해석이 아주 쉬워집니다.

💡 4.1 포인터 배열 (Array of Pointers)

char *colors[4]; 문장은 대괄호가 먼저 묶여서 "4개짜리 배열인데, 각각의 칸에 char형 주소(char *)가 들어있다"로 해석됩니다.

C
 
#include <stdio.h>

int main() {
    // 문자열 리터럴의 주소 4개를 담고 있는 포인터 배열입니다.
    const char *colors[] = {"Red", "Green", "Blue", "Yellow"};

    for (int i = 0; i < 4; i++) {
        // colors[i]는 문자열이 시작되는 메모리의 주소값입니다.
        printf("Color[%d]: %s\n", i, colors[i]);
    }

    return 0;
}

💡 4.2 배열 포인터 (Pointer to an Array)

int (*p)[3]; 문장은 괄호 덕분에 별 기호가 먼저 묶여서 "변수 p는 포인터다. 그런데 어떤 대상을 가리키느냐? 3개짜리 정수형 배열 전체를 가리킨다"로 해석됩니다.

C
 
#include <stdio.h>

int main() {
    // 가로 크기가 3인 2차원 배열이 있습니다.
    int matrix[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };

    // 가로 크기가 3인 정수형 배열을 가리키는 배열 포인터를 선언하고 주소를 대입합니다.
    int (*p)[3] = matrix; 

    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 3; j++) {
            // 일반 2차원 배열과 똑같은 인덱싱 방식으로 접근이 가능해집니다.
            printf("%d ", p[i][j]);
        }
        printf("\n");
    }

    return 0;
}

💡 고급 포인터 활용을 위한 개발 팁 (Tip)

  1. typedef로 함수 포인터 선언을 인간답게 만드세요: 함수 포인터는 매개변수가 늘어날수록 선언문 형식이 끔찍하게 복잡해집니다. 이때 typedef를 쓰면 일반 변수처럼 깔끔하게 타입화할 수 있습니다.
  2. C
     
    // 기존: void operate(int a, int b, int (*operation)(int, int));
    typedef int (*CalcFunc)(int, int); // CalcFunc라는 새로운 타입을 정의
    void operate(int a, int b, CalcFunc operation); // 가독성이 대폭 상승합니다.
    
  3. 배열 포인터는 2차원 배열을 함수 인자로 넘길 때 필수입니다: 함수 매개변수에 void 함수명(int arr[][3]) 형태로 고정 크기를 적는 것보다, void 함수명(int (*arr)[3], int rows) 형태로 배열 포인터를 명시해주면 매개변수가 포인터라는 정체성이 명확해지며, 행(Row)의 크기를 동적으로 유연하게 처리할 수 있습니다.
  4. 가속도나 센서 처리 펌웨어 구조 설계 시 함수 포인터 배열을 활용하세요: 상태 머신(State Machine)을 구현할 때 switch-case문이 수십 개씩 늘어나면 성능상 손해를 봅니다. 실행할 함수 주소들을 함수 포인터 배열 void (*state_handlers[5])(void);에 담아두고 인덱스로 직통 접근(state_handlers[current_state]();)하면 조건문 분기 유실 없이 실시간 응답성을 극대화할 수 있습니다.

⚠️ 초보자가 흔히 하는 실수 (Mistakes)

  • 함수 포인터 선언 시 괄호 누락: int (*func)(int);라고 써야 할 것을 괄호를 빼먹고 int *func(int);라고 쓰면, 함수 포인터가 아니라 "정수형 포인터를 반환하는 일반 함수의 원형 선언"으로 완전히 다르게 컴파일러가 인식합니다. 괄호 하나로 프로그램의 논리 구조가 통째로 바뀌니 주의하세요.
  • 배열 포인터 증가 연산시 바이트 이동량 오해: 배열 포인터 변수 int (*p)[3];가 있을 때, p + 1을 수행하면 주소값이 단지 4바이트(int 크기)만큼 늘어나는 것이 아닙니다. p가 가리키는 대상이 '3개짜리 정수 배열(12바이트)'이기 때문에, 주소값이 12바이트 단위로 점프합니다. 단일 포인터의 포인터 연산과 완전히 다른 메커니즘입니다.
  • 이중 포인터 해제(free) 순서 뒤틀림: 2차원 동적 배열을 이중 포인터 int arr로 할당받아 사용한 뒤 메모리를 해제할 때, 마스터 이중 포인터인 free(arr);를 먼저 해버리면 내부 행들이 가리키던 개별 메모리 블록들의 주소 줄줄이 미아가 되어 해제할 방법이 없어집니다(Memory Leak). 반드시 내부 행 인덱스들부터 순차적으로 해제한 뒤 최종적으로 마스터 포인터를 해제해야 합니다.

🔚 맺음말

이번 포스팅에서는 C 언어의 핵심이자 가장 고난도 테마인 이중 포인터, 함수 포인터, 그리고 포인터 배열과 배열 포인터의 정체에 대해 낱낱이 파헤쳐 보았습니다. 기호가 복잡하게 얽혀서 처음에는 눈에 잘 들어오지 않겠지만, 컴퓨터 메모리 셀 관점에서 연산자 우선순위를 하나씩 뜯어보면 결국 주소값을 어떻게 다루느냐는 하나의 원리로 귀결됩니다. 이 구조를 정복하셨다면 이제 오픈소스 라이브러리의 복잡한 헤더 파일이나 아키텍처 설계 도면을 보아도 겁먹지 않을 탄탄한 뼈대가 완성된 셈입니다.

포인터 주소 연산 규칙이 잘 이해되지 않거나 예제 실행 중 경고 메시지가 뜬다면 언제든지 소프트웨어 공장 댓글 창에 질문을 남겨주세요!

반응형