Journey to Security/C언어

C언어 구조체 포인터와 malloc 함수

Cordilog 2026. 2. 25. 20:23

아래 코드를 바탕으로 구조체 포인터를 선언할 때 왜 malloc 함수를 사용해야 하는지, 그리고 메모리에는 어떤 변화가 일어나는지 디버깅을 통해 알아본다.

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

struct member
{
    char name[20];
    int age;
};

struct member *create_member(const char *name, int age);

int main()
{
    struct member *m; 
    m = create_member("Hgd", 20);
    printf("name = %s, age = %d\n", m->name, m->age );
    free(m);
    return 0;
}

struct member *create_member(const char *name, int age)
{
    struct member *p = (struct member*) malloc(sizeof(struct member));  
    if(p == NULL)
        return NULL; 

    strncpy(p->name, name, sizeof(p->name)-1);
    p->name[sizeof(p->name)-1] = '\0';
    p->age = age;

    return p; 
}

 

1. 왜 malloc을 사용해야 하는가?

위 코드에서 create_member 함수는 구조체 인스턴스를 생성하고 그 주소를 반환한다.

만약 malloc을 사용하지 않고 지역 변수로 구조체를 선언했다면 다음과 같은 문제가 발생한다.

  • 스택(Stack)의 한계: 지역 변수로 선언된 구조체는 함수의 스택 프레임에 할당된다. 함수가 실행을 마치고 리턴되는 순간, 해당 스택 프레임은 해제(pop)되며 내부의 모든 데이터는 사라진다.
  • 댕글링 포인터(Dangling Pointer): 함수가 종료된 후 main 함수로 돌아가서 리턴받은 주소에 접근하려고 하면, 이미 사라진 메모리 영역을 가리키게 되어 예기치 못한 오류(Segmentation Fault 등)가 발생한다.
  • 데이터의 생명 주기 제어: malloc을 통해 힙(Heap) 영역에 메모리를 할당하면, free를 호출하기 전까지 데이터가 메모리에 유지된다. 즉, 함수의 범위를 벗어나서도 데이터를 안전하게 공유하기 위해 malloc을 사용한다.

 

2. 동적 할당 과정에 따른 메모리 구조 변화

create_member 함수가 호출되고 malloc이 실행되는 과정을 메모리 관점에서 단계별로 살펴보면 다음과 같다.

1단계: 함수 호출 직후 (Stack 생성)

main 함수에서 create_member를 호출하면 스택nameage 인자, 그리고 포인터 변수 p를 위한 공간이 생성된다.

이때 p는 아직 아무것도 가리키지 않는 쓰레기 값을 가지고 있다.

2단계: malloc 실행 (Heap 할당)

malloc(sizeof(struct member))가 실행되면 시스템은 힙 영역에서 24바이트(char 20 + int 4)의 빈 공간을 찾아 할당하고 그 시작 주소를 반환한다.

이제 스택에 있는 포인터 p는 힙에 생성된 이 메모리 블록의 주소값을 저장한다.

3단계: 값 복사 및 리턴

strncpy와 대입 연산을 통해 힙 영역의 메모리에 실제 데이터("Hgd", 20)가 작성된다.

함수가 return p;를 실행하면, 힙의 주소값이 main 함수의 m에게 전달된다. (**변수 p는 사라짐)

4단계: 함수 종료 후

create_member의 스택 프레임은 사라지지만, 힙에 할당된 메모리는 그대로 남는다.

main의 포인터 m이 이 힙 영역을 계속 참조하고 있기 때문에 안전하게 데이터를 사용할 수 있다.

 

 

 

4. GDB를 이용한 메모리 디버깅

실제 메모리에 데이터가 어떻게 들어있는지 확인하기 위해 다음과 같이 GDB로 디버깅을 해볼 수 있다.

 

1️⃣디버깅 준비

gcc -g -o member_test main.c
gdb ./member_test

 

2️⃣주요 중단점 및 확인 명령어

중단점 설정: malloc 직후의 상태를 보기 위해 create_member 내부에 중단점을 건다.

  • (gdb) b create_member
  • (gdb) r
  • (gdb) n (malloc 실행 이후까지 진행)

3️⃣메모리 할당 직후

malloc 호출 직후 포인터 p의 상태를 확인한다.

  • p p: $1 = (struct member *) 0x5555555592a0 → 힙(Heap) 영역의 주소가 할당되었다.
  • p *p: $2 = {name = '\000'..., age = 0} → 메모리 공간만 확보되었을 뿐, 아직 실제 데이터는 들어있지 않다.

4️⃣데이터 채우기 완료

strncpy와 p->age 대입이 끝난 후를 확인한다.

  • p *p: $4 = {name = "Hgd", ..., age = 20} → 힙 메모리에 "Hgd"와 20이라는 값이 정상적으로 저장되었다.

 

5️⃣함수 리턴과 대입 전

finish 명령어로 main 함수로 돌아온 직후

  • Value returned is $5 = 0x5555555592a0: 함수는 성공적으로 힙 주소를 들고 왔다.
  • p m: $6 = (struct member *) 0x7fffffffde68 → 아직 대입 연산(m = ...)이 실행되기 전이라 m은 스택 영역의 쓰레기 주소값을 가지고 있다.

 

6️⃣main에서 주소 수신 완료

n을 눌러 대입을 완료한 상태

  • p m: $7 = (struct member *) 0x5555555592a0 → 이제 m이 create_member에서 만든 힙 주소를 정확히 가리킨다.
  • p *m: $8 = {name = "Hgd", ..., age = 20} → 함수가 끝났음에도 힙 데이터는 여전히 존재한다.

 

7️⃣free 호출 이후 

메모리 해제 후의 상태

  • p m: $10 = (struct member *) 0x5555555592a0 → free를 해도 포인터 m에 저장된 주소값은 변하지 않는다.
  • p *m: $9 = {name = "YUUU...", age = 20} → 하지만 실제 내용은 쓰레기 값으로 변했다. 시스템이 해당 메모리를 반납 처리하며 데이터가 사라졌기 때문이다. 이를 댕글링 포인터(Dangling Pointer)라고 한다.

 

 

5. 메모리 구조와 포인터 참조 관계

 

💡free(m) 이후에도 m은 여전히 이전의 주소(0x5555555592a0)를 들고 있지만, 그곳에 저장된 데이터(*m)는 더 이상 신뢰할 수 없는 상태가 된다.

따라서 free 직후에는 m = NULL;을 실행하여 포인터를 초기화하는 습관이 보안상 매우 중요하다.