본문 바로가기
임베디드 기초

포인터와 배열 - 입문자가 반드시 알아야 할 관계

by 버그없는토마토 2025. 12. 29.

포인터와 배열 - 입문자가 반드시 알아야 할 관계

포인터란? 배열이란?


들어가며

오늘은 월요일 -> 기초편입니다

포인터 배열에 대해 알아 볼건데요

그냥 단순한 문법에 대한 고찰이 아닌 직접 실무에서 어떻게 사용되는지와 연관시켜 알아보도록 하겠습니다

 


자동차 ECU에서 센서 데이터를 읽는 상황을 생각해 보겠습니다

온도 센서 배열:
tempData[0] = 85도 (엔진)
tempData[1] = 40도 (냉각수)
tempData[2] = 120도 (배터리)

이 배열의 첫 번째 값을 다른 함수로 전달하고 싶어.

int readTemperature(int *sensor) {
    return *sensor;  // sensor가 가리키는 주소의 값을 읽기
}

result = readTemperature(&tempData[0]);  // 또는 readTemperature(tempData);

같은 코드인가? 다른 코드인가?
답: 이 글을 읽으면 정확히 이해할 수 있습니다.
답하지 못했다면 조금 진심을 다해 알아봅시다

포인터가 없으면 C 코드를 읽을 수 없다. 배열이 없으면 대량의 데이터를 처리할 수 없다.

고도화 된 소프트웨어에서 포인터와 배열이 없다면 감당할 수가 없죠

그런데 포인터와 배열은 겉으로는 비슷해 보이지만 완전히 다르다.

 


이 글에서는:

  • 포인터의 정의와 메모리 주소
  • 배열의 구조와 메모리 레이아웃
  • 배열명은 정말 포인터인가?
  • 포인터와 배열을 함수로 전달할 때 무슨 일이 일어나는가?
  • 실무에서 자주 실수하는 경우 (4가지 정도)

를 명확하게 설명해보겠습니다

 

위에 대해 누군가에게 모두 설명 가능하다면 뒤로가기를 누르셔도 됩니다. 하산하세요.

3분 요약

바쁜 당신을 위한 핵심:

포인터 = 메모리 주소를 저장하는 변수

배열 = 연속된 메모리에 같은 타입의 데이터를 저장한 것

배열명 tempData는 &tempData[0]과 같다 (배열의 첫 주소)

하지만 배열명은 상수 포인터다 (주소를 변경할 수 없음)

포인터 변수는 다른 주소를 가리킬 수 있음

함수로 배열을 전달하면 배열 전체가 아니라 포인터(첫 주소)만 전달됨

따라서 함수 내에서 배열을 수정하면 원본도 수정됨

배열 크기를 알고 싶으면? sizeof()로 계산 불가능 (포인터로 변환되었으므로)

따라서 배열 크기를 별도로 함수에 전달해야 함

포인터란?


포인터(Pointer)란?

정의

포인터는 메모리 주소를 저장하는 변수다.

일반 변수와의 비교:

일반 변수:
int x = 10;
의미: 메모리의 어딘가에 10이라는 값을 저장한다.

포인터 변수:
int *ptr = &x;
의미: x의 메모리 주소를 ptr에 저장한다.

메모리 주소의 개념

메모리의 개념이 사실 입문자에게 있어선 진입장벽이라고 생각합니다
그래서 그만큼 원리는 추상화하여 이해하고, 개념은 정말 자세히 정의하여 정립해야한다고 생각합니다.

컴퓨터 메모리는 0번부터 시작하는 순차적 주소로 구성된다.

메모리 주소 구조:

주소(Address)  |  값(Value)
─────────────────────────
0x1000         |  0x42 (66)
0x1001         |  0x55 (85)
0x1002         |  0x78 (120)
0x1003         |  0x9A
0x1004         |  0xBC
0x1005         |  0xDE

int x = 85;  // 0x1001에 저장됨

int *ptr = &x;  // ptr은 0x1001을 저장

*ptr를 읽으면? 85 (0x1001이 가리키는 값)
ptr를 읽으면? 0x1001 (주소 자체)

포인터 연산자: & 와 *

& (주소 연산자):
int x = 85;
int *ptr = &x;

&x   → 0x1001 (x의 주소)
ptr  → 0x1001 (ptr이 저장한 값)


* (역참조 연산자):
int *ptr = &x;
*ptr → 85 (ptr이 가리키는 주소의 값)

if (*ptr == 85) {
    printf("ptr이 가리키는 값은 85");
}

포인터 선언과 사용

기본 문법:

int *ptr;           // int 타입 포인터 선언
int x = 10;
ptr = &x;           // x의 주소를 ptr에 저장

printf("%d", *ptr);  // 10 출력 (포인터가 가리키는 값)
printf("%p", ptr);   // 0x1001 (주소, %p는 포인터 출력용)

포인터와 NULL

포인터는 항상 무언가를 가리켜야 한다.

int *ptr = NULL;     // 아무것도 가리키지 않음

// NULL 체크 필수!
if (ptr != NULL) {
    printf("%d", *ptr);
} else {
    printf("포인터가 초기화되지 않음");
}

NULL 포인터를 역참조하면? → 크래시 (세그멘테이션 폴트)

배열이란?

배열(Array)이란?

정의

배열은 같은 타입의 여러 데이터를 연속된 메모리에 저장한 것이다.

배열은 다 알고 계시죠? 그럴거라 믿습니다.

배열의 메모리 레이아웃

int tempData[3] = {85, 40, 120};

메모리 구조:

배열 이름: tempData
첫 주소: 0x1000

주소         |  인덱스  |  값
────────────────────────────
0x1000       |  [0]     |  85
0x1004       |  [1]     |  40
0x1008       |  [2]     |  120

(int는 4바이트이므로 주소 차이가 4)

접근 방식:
tempData[0]  → 0x1000의 값 → 85
tempData[1]  → 0x1004의 값 → 40
tempData[2]  → 0x1008의 값 → 120

내부적으로:
tempData[1] = *(tempData + 1)

즉, 배열 접근은 포인터 연산으로 구현된다!

배열 선언과 초기화

선언만:
int tempData[3];     // 크기 3인 배열 선언

초기화:
int tempData[3] = {85, 40, 120};

크기 생략:
int tempData[] = {85, 40, 120};  // 크기는 자동으로 3

2차원 배열:
int matrix[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

메모리 레이아웃 (행 우선):
주소         |  값
────────────────
0x1000       |  1
0x1004       |  2
0x1008       |  3
0x100C       |  4
0x1010       |  5
0x1014       |  6

핵심 질문: 배열명은 포인터인가?

답: 배열명은 포인터처럼 작동하지만, 포인터 변수가 아니다.

배열:
int tempData[3] = {85, 40, 120};

포인터:
int *ptr;

배열명 tempData와 포인터 ptr의 비교:

1. 값 읽기:
   tempData[0]  → 85 (배열 인덱싱)
   *(tempData)  → 85 (포인터 역참조)
   같은 결과!

2. 주소 읽기:
   &tempData[0] → 0x1000
   tempData     → 0x1000
   같은 값!

3. 그런데 주소 재할당:
   tempData = someOtherAddress;  // 에러! 배열명은 상수
   ptr = someOtherAddress;        // 가능

4. sizeof():
   sizeof(tempData)  → 12 (int 3개, 4바이트씩)
   sizeof(ptr)       → 8 (포인터 크기, 64비트 시스템)

결론:
배열명은 "상수 포인터"처럼 작동한다.
하지만 배열명 자체는 수정할 수 없다.

배열과 포인터가 다르게 작동하는 경우

경우 1: & 연산자

배열:
int arr[3];
&arr     → 주소 0x1000 (배열 전체의 주소)
&arr[0]  → 주소 0x1000 (첫 번째 원소의 주소)

겉으로는 같지만, 포인터 타입이 다르다:
&arr     → int (*)[3]  (크기 3인 int 배열 포인터)
&arr[0]  → int *       (int 포인터)

포인터 연산을 하면 차이가 드러난다:
(&arr + 1)     → 0x1000 + 12 = 0x100C (배열 전체 크기만큼)
(&arr[0] + 1)  → 0x1000 + 4 = 0x1004  (int 크기만큼)

경우 2: 함수 전달

배열을 함수로 전달하면:
void printArray(int arr[]) { ... }
printArray(tempData);

내부적으로:
tempData는 &tempData[0]으로 자동 변환됨
즉, 함수는 포인터를 받는 것과 같다!

배열명은 더 이상 배열이 아니라 포인터가 된다.

함수로 배열 전달하기

배열이 포인터로 변환되는 과정

원본 배열:
int sensorData[4] = {85, 40, 120, 60};

함수 선언:
void processSensorData(int *data, int size) {
    for (int i = 0; i < size; i++) {
        printf("%d\n", data[i]);
    }
}

함수 호출:
processSensorData(sensorData, 4);

내부에서:
sensorData → &sensorData[0]로 자동 변환
data = &sensorData[0]

이제 함수 내에서:
data[0] = *(data + 0) = 85
data[1] = *(data + 1) = 40
data[2] = *(data + 2) = 120
data[3] = *(data + 3) = 60

배열 크기 문제

함수에서 배열 크기를 알 수 없다!

void printArray(int arr[]) {
    printf("배열 크기: %d\n", sizeof(arr));
    // 출력: 포인터 크기 (8 바이트)
    // 배열의 실제 크기 (12 바이트)가 아님!
}

따라서 배열 크기를 별도로 전달해야 함:

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d\n", arr[i]);
    }
}

호출:
int tempData[3] = {85, 40, 120};
printArray(tempData, 3);  // 크기도 함께 전달

배열을 수정하면 원본도 수정된다!

배열을 함수로 전달하면 포인터로 변환되므로,
함수 내에서 배열을 수정하면 원본도 수정된다.

원본:
int tempData[3] = {85, 40, 120};

함수:
void addTenDegrees(int *temps, int size) {
    for (int i = 0; i < size; i++) {
        temps[i] += 10;  // 원본 수정!
    }
}

호출:
addTenDegrees(tempData, 3);

결과:
tempData[0] = 95   (85 + 10)
tempData[1] = 50   (40 + 10)
tempData[2] = 130  (120 + 10)

원본이 수정되었다!

포인터를 통해 같은 메모리를 접근하고 있기 때문.

포인터 연산

포인터 + 정수

int arr[5] = {10, 20, 30, 40, 50};

int *ptr = &arr[0];  // arr의 첫 주소

ptr + 1  → 다음 원소의 주소
ptr + 2  → 두 번째 다음 원소의 주소

메모리 계산:
ptr (0x1000) + 1 → 0x1004 (int 크기만큼)
ptr (0x1000) + 2 → 0x1008

즉, 포인터 + n = (포인터 값) + (n * 원소 타입 크기)

배열 순회:
for (int i = 0; i < 5; i++) {
    printf("%d ", *(ptr + i));
    // 또는
    printf("%d ", ptr[i]);
}

결과: 10 20 30 40 50

포인터 - 포인터

int arr[5] = {10, 20, 30, 40, 50};

int *ptr1 = &arr[0];  // 0x1000
int *ptr2 = &arr[3];  // 0x100C

ptr2 - ptr1  → 3 (몇 개 원소 차이인가?)

메모리:
0x100C - 0x1000 = 12 바이트
12 / 4 (int 크기) = 3

따라서:
ptr2 - ptr1 = 3

포인터 비교

int arr[5] = {10, 20, 30, 40, 50};

int *ptr1 = &arr[0];
int *ptr2 = &arr[2];

ptr1 < ptr2   → true  (ptr1이 더 작은 주소)
ptr1 == ptr2  → false
ptr2 > ptr1   → true

배열 순회:
int *ptr = arr;
while (ptr < &arr[5]) {
    printf("%d ", *ptr);
    ptr++;
}

실무 시나리오

시나리오 1: CAN 메시지 수신 및 처리

상황:
CAN 버스에서 8바이트 메시지가 계속 도착한다.
이 메시지를 배열에 저장하고, 여러 함수에서 처리한다.

코드:

uint8_t canMessage[8] = {0x85, 0x28, 0x78, 0x3C, 0x00, 0x00, 0x00, 0x00};

함수 1: 온도값 추출
uint8_t getEngineTemp(uint8_t *msg) {
    return msg[0];  // 첫 번째 바이트 = 엔진 온도
}

함수 2: 기어 상태 확인
uint8_t getGearState(uint8_t *msg) {
    return msg[1];  // 두 번째 바이트 = 기어 상태
}

호출:
uint8_t engineTemp = getEngineTemp(canMessage);  // 0x85 = 133도
uint8_t gearState = getGearState(canMessage);    // 0x28 = 기어 2

포인트:
배열을 포인터로 전달하므로, 메모리 복사 없음.
배열 자체가 고정되어 있으므로 함수들은 같은 데이터를 참조.

시나리오 2: 센서 배열 필터링

상황:
온도 센서 5개의 데이터를 읽어서,
이상값을 제거하는 필터링을 수행한다.

코드:

int rawTemps[5] = {85, 1000, 40, 120, -50};  // 1000, -50은 이상값

void filterOutliers(int *temps, int size) {
    for (int i = 0; i < size; i++) {
        if (temps[i] < 0 || temps[i] > 150) {
            temps[i] = 0;  // 이상값을 0으로 교체
        }
    }
}

호출:
filterOutliers(rawTemps, 5);

결과:
rawTemps[0] = 85    (정상)
rawTemps[1] = 0     (1000 → 0으로 변경)
rawTemps[2] = 40    (정상)
rawTemps[3] = 120   (정상)
rawTemps[4] = 0     (-50 → 0으로 변경)

원본 배열이 수정되었다!

시나리오 3: 포인터로 배열 순회

상황:
CAN 메시지 데이터를 바이트 단위로 순회하면서 체크섬을 계산한다.

코드:

uint8_t canMsg[8] = {0x12, 0x34, 0x56, 0x78, 0xAA, 0xBB, 0xCC, 0xDD};

uint8_t calculateChecksum(uint8_t *data, int size) {
    uint8_t sum = 0;

    // 방법 1: 인덱싱
    for (int i = 0; i < size; i++) {
        sum += data[i];
    }

    // 방법 2: 포인터 연산 (더 효율적)
    uint8_t *ptr = data;
    uint8_t *endPtr = data + size;
    while (ptr < endPtr) {
        sum += *ptr;
        ptr++;
    }

    return sum;
}

호출:
uint8_t checksum = calculateChecksum(canMsg, 8);

계산:
0x12 + 0x34 + 0x56 + 0x78 + 0xAA + 0xBB + 0xCC + 0xDD = ?

포인트:
포인터 연산으로 배열을 순회하는 것이 저수준 언어에서 흔하다.
배열 인덱싱보다 포인터 연산이 더 빠를 수도 있다 (컴파일러 최적화).

시나리오 4: 다차원 배열과 포인터

상황:
2D 배열로 센서 데이터를 저장한다.
행: 센서 종류 (3개)
열: 측정값 (4개)

코드:

int sensorData[3][4] = {
    {85, 86, 84, 85},    // 센서 1 (엔진 온도)
    {40, 41, 39, 40},    // 센서 2 (냉각수)
    {120, 119, 121, 120} // 센서 3 (배터리)
};

메모리 레이아웃 (행 우선):
주소         |  값
────────────────
0x1000       |  85 (행 0, 열 0)
0x1004       |  86 (행 0, 열 1)
0x1008       |  84 (행 0, 열 2)
0x100C       |  85 (행 0, 열 3)
0x1010       |  40 (행 1, 열 0)
...

접근:
sensorData[1][2]  → 39
*(*(sensorData + 1) + 2)  → 39 (포인터 방식)

포인터 타입:
sensorData         → int (*)[4] (행 배열 포인터)
sensorData + 1     → 다음 행의 주소 (16바이트 이동)
*sensorData        → int * (첫 행의 첫 원소 포인터)

함수 전달:
void printSensorData(int (*data)[4], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 4; j++) {
            printf("%d ", data[i][j]);
        }
        printf("\n");
    }
}

호출:
printSensorData(sensorData, 3);

자주 하는 실수

실수 1: 배열 크기를 함수에서 계산하려고 함

잘못된 코드:

void printArray(int arr[]) {
    int size = sizeof(arr) / sizeof(int);  // 틀림!
    for (int i = 0; i < size; i++) {
        printf("%d\n", arr[i]);
    }
}

왜 틀렸나?
arr은 포인터로 변환됨.
sizeof(arr) = 포인터 크기 (8바이트)
sizeof(int) = 4바이트
size = 8 / 4 = 2 (실제 크기는 알 수 없음)

올바른 코드:

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d\n", arr[i]);
    }
}

printArray(tempData, 3);

실수 2: 포인터를 제대로 초기화하지 않음

잘못된 코드:

int *ptr;
*ptr = 85;  // 세그멘테이션 폴트!

ptr은 아무것도 가리키지 않음.
초기화되지 않은 주소에 쓰려고 하니 크래시.

올바른 코드:

int x = 0;
int *ptr = &x;
*ptr = 85;  // 가능

또는:

int *ptr = NULL;
if (ptr != NULL) {
    *ptr = 85;
}

실수 3: 배열명과 포인터 변수를 혼동

배열명은 배열 전체를 나타낸다:
int arr[3] = {1, 2, 3};
arr = someOtherAddress;  // 에러!

포인터 변수는 언제든 다른 주소를 가리킬 수 있다:
int *ptr;
ptr = &arr[0];          // 가능
ptr = &arr[1];          // 가능

실수 4: 포인터 연산의 단위를 무시함

잘못된 코드:

int arr[5] = {10, 20, 30, 40, 50};
int *ptr = &arr[0];

ptr + 1  // 다음 int 원소? 아니면 다음 바이트?

올바른 이해:
ptr이 int 포인터라면:
ptr + 1 = ptr의 주소 + 4 (int 크기)

포인터 타입에 따라 이동 거리가 결정된다!

char *cptr = &arr[0];  // 형식 불일치 (경고)
cptr + 1  → 다음 바이트 (1바이트 이동)

uint8_t *bptr = (uint8_t *)&arr[0];
bptr + 1  → 다음 바이트 (1바이트 이동)

면접 예상 질문

Q1: 배열명 arr과 포인터 ptr의 가장 큰 차이는?

A: 배열명은 상수 포인터라서 주소를 바꿀 수 없지만,
   포인터 변수는 다른 주소를 언제든 가리킬 수 있다.
   arr = someOtherAddress는 에러지만,
   ptr = someOtherAddress는 가능하다.


Q2: 함수로 배열을 전달하면 전체 배열이 복사되나?

A: 아니다. 배열명은 포인터로 변환되므로,
   배열의 첫 주소만 전달된다.
   배열 전체 복사가 아니라 포인터(주소)만 복사.
   따라서 함수에서 배열을 수정하면 원본도 수정된다.


Q3: sizeof(arr)이 배열 크기를 반환하는데,
    함수에서는 포인터 크기를 반환하는 이유?

A: 함수에 도착할 때 배열은 이미 포인터로 변환되기 때문이다.
   컴파일러 관점에서는 포인터 변수를 받은 것이므로,
   sizeof()는 포인터 크기만 반환한다.
   따라서 배열 크기는 별도로 함수에 전달해야 한다.


Q4: 포인터 주소 증가(ptr++)를 하면 메모리에서 몇 바이트가 이동하나?

A: 포인터가 가리키는 타입의 크기만큼 이동한다.
   int *ptr이면 ptr++ → 4바이트 이동
   uint8_t *ptr이면 ptr++ → 1바이트 이동
   포인터의 타입이 이동 거리를 결정한다.


Q5: 배열을 const 포인터로 함수에 전달하려면?

A: void processArray(const int *arr, int size)
   const 키워드로 배열 수정을 방지한다.
   함수 내에서 arr[i] = 100 같은 수정을 할 수 없다.

핵심 정리

포인터:

  • 메모리 주소를 저장하는 변수
  • & 연산자로 주소 추출
    • 연산자로 주소의 값 읽기
  • 포인터 연산(+, -)으로 다른 주소 접근 가능
  • 포인터 변수는 다른 주소를 언제든 가리킬 수 있음

배열:

  • 같은 타입의 연속된 데이터
  • 배열명은 첫 번째 원소의 주소와 같음
  • 하지만 배열명은 상수 포인터 (주소 변경 불가)
  • 함수로 전달하면 포인터로 변환됨
  • 함수에서 배열 크기를 알 수 없음

함수 전달:

  • 배열을 함수로 전달하면 배열의 포인터만 전달됨
  • 함수 내에서 배열 수정하면 원본도 수정됨
  • 배열 크기는 별도로 함수에 전달해야 함
  • 배열 수정을 방지하려면 const 포인터 사용

포인터 연산:

  • ptr + n은 n * (포인터 타입 크기)만큼 주소 이동
  • 배열 순회에 효율적
  • 배열 끝 판정: ptr < &arr[size]