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

C 포인터와 메모리 관리 완벽 정리: 임베디드 시스템 기초

by 버그없는토마토 2026. 1. 22.

C 포인터와 메모리 관리 완벽 정리: 임베디드 시스템 기초

C 포인터와 메모리 관리


포인터를 통한 메모리관리법

들어가며

포인터는 C 프로그래밍에서 가장 중요하면서도, 가장 어려운 개념입니다

포인터에 대해 공부도 했을거고(?) 대충 개념도 알고 있을거예요..(?)

하지만 막상 코드를 짜면서, 아키텍쳐를 설계하면서 포인터를 능수능란하게 다룬 다는 것은 쉽지가 않습니다

머릿속에 포인터와 메모리 개념이 들어있어서 자유자재로 쓰고 빼고 설계가 되어야하죠

요즘엔 AI에게 코드를 부탁하면 촤르르~ 짜주지만

포인터에 대해 무지하다면 맞는 코드인지 틀린 점은 없는지 검증 하지 못하겠죠..

그냥 AI에 넣고 복붙했을 때 문제가 생긴다면 AI 탓을 할건가요?

아닙니다

 

코드를 검증하지 못한 자신의 탓이 되겠죠.....

그 래 서! 포인터에 대해 자세히 알아보자구요!!

 

자동차 ECU에서는 무엇을 하는가?

센서 데이터 읽기:
int temperature = ReadSensor();  ← 센서 주소에서 읽음

CAN 메시지 전송:
SendCAN(&message);  ← 메시지 주소를 전송

메모리 제약:
struct EngineData engine;
struct BatteryData battery;
struct TransmissionData transmission;
...
RAM은 정해져 있음 (128KB?)
→ 메모리 어디에 뭘 놓을지 계획 필수!

모두 "메모리의 주소" 를 다루는 작업입니다.

주소를 다루는 포인터를 모르면 임베디드 개발은 깊이 들어갈 수록 불가능 하다고 볼 수 있어요!

이 글에서는 메모리 구조, 포인터의 원리, 배열과의 관계, 포인터 연산, 그리고 자동차 개발에서의 실무 사용법을 정리해보겠습니다.


포인터를 통한 메모리관리법

1. 메모리 구조

1.1 프로그램의 메모리 레이아웃

어디에 뭐가 저장되는가?

높은 주소 (0xFFFFFFFF)
┌────────────────────────┐
│ Stack                  │  ← 지역 변수, 함수 호출
│                        │  (작음, 빠름, 자동 해제)
│                        │
└────────────────────────┘
            ↕ 늘어남/줄어듦
┌────────────────────────┐
│ Heap                   │  ← 동적 메모리 (malloc)
│                        │  (큼, 느림, 수동 해제)
└────────────────────────┘
┌────────────────────────┐
│ BSS Segment            │  ← 초기화 안 된 전역 변수
│ (Block Started by      │
│  Symbol)               │
└────────────────────────┘
┌────────────────────────┐
│ Data Segment           │  ← 초기화된 전역 변수
│                        │
└────────────────────────┘
┌────────────────────────┐
│ Text (Code)            │  ← 프로그램 명령어
│                        │  (읽기 전용)
└────────────────────────┘
낮은 주소 (0x00000000)

1.2 예시: 어디에 뭐가 들어가는가?

#include <stdio.h>
#include <stdlib.h>

int global_var = 10;        // ← Data Segment (초기화 O)
int uninitialized_var;      // ← BSS Segment (초기화 X)
const int constant = 100;   // ← Text (읽기 전용)

void MyFunction(int param) {
    int local_var = 20;     // ← Stack (함수 시작 시 생성)
    static int static_var = 30;  // ← Data Segment

    // 함수가 끝나면 Stack에서 local_var 자동 삭제
}

int main() {
    int arr[10];            // ← Stack (고정 크기)
    int *ptr = malloc(100); // ← ptr은 Stack, 실제 메모리는 Heap

    // malloc 해제 안 하면 메모리 누수!
    free(ptr);

    return 0;
}

메모리 구조:

┌─────────────────────────────────┐
│ Stack                           │
├─────────────────────────────────┤
│ MyFunction() 호출               │
│ ├─ param = ? (함수 인자)        │
│ ├─ local_var = 20               │
│ └─ (리턴 주소)                  │
├─────────────────────────────────┤
│ main() 호출                     │
│ ├─ arr[10] (40 바이트)         │
│ ├─ ptr (주소 저장, 8 바이트)   │
│ └─ (리턴 주소)                  │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ Heap                            │
├─────────────────────────────────┤
│ malloc(100) ← ptr가 가리킴      │
│ (동적할당, 100 바이트)          │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ Data                            │
├─────────────────────────────────┤
│ global_var = 10                 │
│ uninitialized_var = 0 (초기화) │
│ static_var = 30                 │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ Text (Code)                     │
├─────────────────────────────────┤
│ constant = 100 (읽기 전용)      │
│ main() 코드                     │
│ MyFunction() 코드               │
└─────────────────────────────────┘

2. 포인터란?

2.1 정의

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

// 일반 변수
int x = 10;     // x는 10을 저장

// 포인터 변수
int *ptr;       // ptr은 "정수 변수의 주소"를 저장
ptr = &x;       // x의 주소를 ptr에 저장

// 그림으로:
x의 주소    x의 값
0x1000  →  [10]

ptr의 주소  ptr의 값
0x2000  →  [0x1000]  ← x의 주소를 가리킴!

2.2 & 연산자 (주소 연산자)

int x = 42;

&x              // x가 저장된 메모리 주소 반환
                // 예: 0x7fff5fbff8ac

int *ptr = &x;  // ptr에 x의 주소 저장

2.3 * 연산자 (역참조)

int x = 42;
int *ptr = &x;

*ptr            // ptr이 가리키는 곳의 값
                // 42를 반환

*ptr = 100;     // ptr이 가리키는 곳에 100을 저장
                // x가 100으로 변경됨!

2.4 포인터 그림으로 이해하기

선언: int x = 10;
      int *ptr = &x;

메모리:
주소     내용
───────────────
...
0x1000  [10]      ← x가 저장된 위치
0x1001  [ ?]
0x1002  [ ?]
0x1003  [ ?]
0x1004  [ ?]  ← ptr의 위치
       [0x1000]   ← ptr이 저장한 값 (x의 주소)
...

표현식 의미:
x       → 10 (x의 값)
&x      → 0x1000 (x의 주소)
ptr     → 0x1000 (ptr의 값 = x의 주소)
*ptr    → 10 (ptr이 가리키는 곳의 값 = x의 값)

3. 포인터와 배열

3.1 배열은 포인터와 다른가?

// 배열 선언
int arr[5] = {10, 20, 30, 40, 50};

// arr은 사실 배열의 첫 번째 원소의 주소!
int *ptr = arr;  // 자동으로 &arr[0]와 같음

// 따라서:
arr[0] == *ptr          // 10 == 10 (참)
arr[1] == *(ptr + 1)    // 20 == 20 (참)
arr[2] == *(ptr + 2)    // 30 == 30 (참)

3.2 배열과 포인터의 메모리

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

Stack에 연속으로 배치:
주소     내용
────────────────
0x1000  [10]     ← arr[0], arr의 주소
0x1004  [20]     ← arr[1]
0x1008  [30]     ← arr[2]
0x100c  [40]     ← arr[3]
0x1010  [50]     ← arr[4]

int *ptr = arr;에서:
ptr = 0x1000

arr[i] == *(ptr + i)의 원리:
arr[1] == *(ptr + 1)
        == *( 0x1000 + 1*4 )    ← int는 4바이트
        == *(0x1004)
        == 20

3.3 포인터 연산의 특징

int arr[5];
int *ptr = arr;

ptr + 1     // 주소 + 4 (int 크기, 1바이트 X)
            // 다음 배열 원소의 주소!

ptr + 2     // 주소 + 8
ptr + 3     // 주소 + 12

char *cptr;
cptr + 1    // 주소 + 1 (char는 1바이트)

// 포인터 연산은 타입을 고려함!

4. 함수와 포인터

4.1 값 전달 vs 주소 전달

// [방법 1] 값 전달 (원본 수정 불가)
void Increment(int x) {
    x++;                // 매개변수 x만 증가
}                       // 원본 변수는 그대로

int main() {
    int num = 10;
    Increment(num);
    printf("%d\n", num);  // 여전히 10
}


// [방법 2] 주소 전달 (원본 수정 가능)
void Increment(int *x) {
    (*x)++;             // 포인터가 가리키는 값 증가
}                       // 원본 변수 변경됨!

int main() {
    int num = 10;
    Increment(&num);    // 주소 전달
    printf("%d\n", num);  // 11 출력!
}

4.2 자동차 ECU 예시

// CAN 메시지 데이터 구조
struct CANMessage {
    uint16_t id;
    uint8_t dlc;
    uint8_t data[8];
};

// 포인터로 메시지 전달
void SendCANMessage(struct CANMessage *msg) {
    // msg가 가리키는 메시지의 ID 확인
    if (msg->id == 0x123) {
        // 전송
    }
}

int main() {
    struct CANMessage my_msg;
    my_msg.id = 0x123;
    my_msg.dlc = 8;

    // 메시지 자체를 복사하지 않음 (효율적!)
    // 주소만 전달
    SendCANMessage(&my_msg);
}

이유:
✓ 메모리 절약 (주소만 8바이트 vs 전체 구조체)
✓ 원본 수정 가능
✓ 임베디드 시스템에서 필수!

5. 실무 예제: 자동차 센서 데이터

5.1 센서 데이터 구조

// 엔진 센서 데이터
typedef struct {
    int16_t rpm;            // 엔진 RPM
    int16_t temperature;    // 엔진 온도 (°C)
    uint8_t throttle;       // 가속 페달 (0~100%)
    uint16_t fuel_pressure; // 연료 압력 (kPa)
} EngineSensorData;

// 배터리 센서 데이터
typedef struct {
    uint8_t soc;            // State of Charge (%)
    int16_t current;        // 충방전 전류 (A)
    int16_t voltage;        // 배터리 전압 (V)
    int16_t temperature;    // 배터리 온도 (°C)
} BatterySensorData;

// 전체 차량 상태
typedef struct {
    EngineSensorData engine;
    BatterySensorData battery;
    uint32_t trip_distance; // 주행 거리 (m)
} VehicleData;

5.2 센서 읽기

// ECU에서 센서 값을 읽기
void ReadSensors(VehicleData *vehicle) {
    // 엔진 센서
    vehicle->engine.rpm = ReadEngineRPM();
    vehicle->engine.temperature = ReadEngineTemp();
    vehicle->engine.throttle = ReadThrottlePosition();
    vehicle->engine.fuel_pressure = ReadFuelPressure();

    // 배터리 센서
    vehicle->battery.soc = ReadBatterySOC();
    vehicle->battery.current = ReadBatteryCurrent();
    vehicle->battery.voltage = ReadBatteryVoltage();
    vehicle->battery.temperature = ReadBatteryTemp();
}

int main() {
    VehicleData car_state;

    // 센서 읽기 (주소 전달)
    ReadSensors(&car_state);

    // 데이터 사용
    printf("RPM: %d\n", car_state.engine.rpm);
    printf("Battery SOC: %d%%\n", car_state.battery.soc);
}

5.3 배열로 여러 센서 관리

// 온도 센서 여러 개 (예: 엔진 곳곳)
int16_t engine_temps[10];  // 10개 센서

void ReadTemperatures(int16_t *temps, int count) {
    for (int i = 0; i < count; i++) {
        temps[i] = ReadTemperatureSensor(i);
    }
}

int main() {
    ReadTemperatures(engine_temps, 10);

    // 데이터 접근
    printf("Temp[0]: %d\n", engine_temps[0]);
    printf("Temp[5]: %d\n", engine_temps[5]);
}

포인터 관점:
int16_t *ptr = engine_temps;  // 배열 포인터
ptr[0] == *(ptr + 0) == engine_temps[0]
ptr[1] == *(ptr + 1) == engine_temps[1]
...

6. 자주 하는 실수

6.1 NULL 포인터 역참조

- 잘못된 예:
int *ptr = NULL;
int value = *ptr;  // 💥 CRASH! (Segmentation Fault)

문제:
- NULL은 어떤 메모리도 가리키지 않음
- 역참조하면 프로그램 충돌
- 자동차는 재부팅 불가!

-> 올바른 예:
int *ptr = NULL;
if (ptr != NULL) {
    int value = *ptr;  // 안전
}

또는:
int *ptr = malloc(sizeof(int));
if (ptr == NULL) {
    return ERROR;  // 메모리 할당 실패
}

6.2 댕글링 포인터 (Dangling Pointer)

- 잘못된 예:
int *CreatePointer() {
    int local_var = 10;
    return &local_var;  // 💥 위험!
}

int main() {
    int *ptr = CreatePointer();
    printf("%d\n", *ptr);  // 값이 정의되지 않음!
}

문제:
- 함수가 끝나면 local_var은 Stack에서 삭제
- ptr은 사라진 메모리를 가리킴
- 그 메모리가 다른 용도로 사용될 수 있음
- 예측 불가능한 값 반환

-> 올바른 예:
int *CreatePointer() {
    int *ptr = malloc(sizeof(int));
    *ptr = 10;
    return ptr;  // Heap 메모리는 명시적으로 해제할 때까지 유지
}

int main() {
    int *ptr = CreatePointer();
    printf("%d\n", *ptr);  // 안전
    free(ptr);  // 명시적으로 해제
}

6.3 메모리 누수 (Memory Leak)

- 잘못된 예:
void ProcessData() {
    int *buffer = malloc(1024);
    // ... 데이터 처리
    // free(buffer) 호출 안 함!
}

임베디드 특징:
- 24/7 계속 실행
- ProcessData() 1000번 호출
- 1000 × 1024 = 1MB 메모리 누수!
- RAM이 부족해서 시스템 크래시

-> 올바른 예:
void ProcessData() {
    int *buffer = malloc(1024);
    if (buffer == NULL) {
        return;
    }

    // ... 데이터 처리

    free(buffer);  // 반드시 해제!
    buffer = NULL; // 댕글링 포인터 방지
}

6.4 임베디드에서의 동적 메모리

 임베디드 시스템의 일반적인 관행:

- 피해야 할 것:
malloc/free를 실시간 루프에서 사용
→ 메모리 단편화
→ 예측 불가능한 할당 시간

-> 추천하는 방법:
// 초기화 시 모든 메모리 미리 할당
typedef struct {
    int sensor_data[100];
    char message_buffer[1024];
    uint8_t can_data[8];
} SystemMemory;

SystemMemory system_mem;  // 전역, 고정 할당

void Init() {
    // 메모리 초기화만 함
    memset(&system_mem, 0, sizeof(SystemMemory));
}

void MainLoop() {
    // malloc/free 없이 사용
    system_mem.sensor_data[0] = ReadSensor();
}

7. 핵심 정리

메모리 구조:
- Stack: 지역 변수 (자동 해제)
- Heap: 동적 할당 (수동 해제)
- Data: 초기화된 전역 변수
- BSS: 초기화 안 된 전역 변수
- Text: 프로그램 코드

포인터:
- 주소를 저장하는 변수
- &: 주소 얻기
- *: 값 얻기/설정

배열과 포인터:
- 배열은 첫 원소의 주소
- arr[i] == *(arr + i)
- 포인터 연산은 타입 고려

함수와 포인터:
- 값 전달: 복사 (원본 유지)
- 주소 전달: 효율적 (원본 수정 가능)
- 임베디드에서는 주소 전달 선호

실무:
- 센서 데이터: 구조체 포인터
- 배열 처리: 포인터 연산
- 메모리 절약 필수

주의:
- NULL 체크 필수
- 댕글링 포인터 피하기
- 메모리 누수 방지
- 임베디드는 동적 할당 피하기