Devin.KR

C · 기본

메모리 그림으로 배우는 C

C 포인터 기초 - 주소와 역참조 출력 매개변수 NULL 검사 (C 기초 7장)

주소를 담는 변수인 포인터를 그림으로 이해하고, swap과 출력 매개변수 패턴을 만들며, NULL과 초기화하지 않은 포인터가 일으키는 실패를 직접 본다.

개발자 · 원고 갱신

이 장에서 배우는 것

5장에서는 함수가 인자의 복사본만 받아서 원본을 바꾸지 못했고, 6장에서는 배열이 함수로 넘어가며 "첫 원소의 주소"가 되는 것을 봤다. 이 장의 주인공인 포인터는 그 주소를 담는 변수다. 포인터는 어렵다는 평이 많지만, 그림으로 보면 "다른 칸의 번지수를 적어 둔 칸"이 전부다.

  • &(주소 얻기)와 *(주소를 따라가 읽고 쓰기)의 짝을 그림으로 이해한다.
  • 포인터 자신도 주소와 크기를 가진 변수라는 것을 출력으로 확인한다.
  • 포인터로 호출한 쪽의 변수를 바꾸는 swap과 "출력 매개변수" 패턴을 쓴다.
  • NULL 포인터와 초기화하지 않은 포인터의 위험을 컴파일러 경고·실행 결과·UBSan으로 확인한다.

문제 상황

분 단위 시간을 "시간"과 "분"으로 나누는 함수를 만들어야 한다. return은 값을 하나만 돌려준다. 게다가 입력이 잘못되면 실패도 알려야 한다. 임베디드 드라이버 API나 POSIX 시스템 함수가 쓰는 방식은 이렇다. 반환값으로는 성공·실패를 알리고, 결과는 호출한 쪽이 준 주소에 써 준다. 이 패턴을 쓰려면 포인터가 필요하다. 한편 검색 함수가 "못 찾음"을 NULL로 알렸는데 그것을 검사하지 않고 읽으면 프로그램이 죽는다.

메모리 그림: 번지수를 적어 둔 칸

int stock = 42;
int *p = &stock;          (주소는 예시. 실제 값은 실행마다 다르다)

 주소            이름     내용
 ...638   ┌──────────────────────┐
          │ stock  (int, 4바이트) │  42
          └──────────────────────┘
               ▲
               │ p 가 가리킨다
 ...630   ┌──────────────────────┐
          │ p      (int*, 8바이트)│  ...638   ← stock 의 주소가 값으로 들어 있다
          └──────────────────────┘

  p        → ...638          (p 칸에 적힌 값 = 주소)
  *p       → 42              (그 주소로 가서 int 로 읽기)
  *p = 7   → stock 칸이 7 로 바뀐다
  &p       → ...630          (p 칸 자체의 주소)

swap(&x, &y) 호출
 main 프레임 ┌ x = 1 ┐ ┌ y = 2 ┐
             └───▲───┘ └───▲───┘
 swap 프레임  a ──┘       b ──┘     a, b 에는 주소가 복사된다
 *a = *b  → x 칸을 직접 고친다

포인터도 결국 값(주소)이 복사되어 전달된다. 다만 복사된 주소가 원본 칸을 가리키므로, 그 주소를 따라가서 쓰면 원본이 바뀐다.

p 칸에는 stock 의 주소가 값으로 들어 있다. *p 는 그 주소를 따라가 stock 을 읽고 쓰며, &p 는 p 칸 자신의 주소다.

p 칸에는 stock 의 주소가 값으로 들어 있다. *p 는 그 주소를 따라가 stock 을 읽고 쓰며, &p 는 p 칸 자신의 주소다.

swap(&x, &y) 는 주소를 복사해 넘긴다. a, b 가 main 의 x, y 칸을 가리키므로 *a = *b 는 원본을 직접 바꾼다.

swap(&x, &y) 는 주소를 복사해 넘긴다. a, b 가 main 의 x, y 칸을 가리키므로 *a = *b 는 원본을 직접 바꾼다.

완성 코드

주소와 역참조

#include <stdio.h>

int main(void) {
    int stock = 42;
    int *p = &stock;

    printf("stock 의 주소 &stock = %p\n", (void *)&stock);
    printf("p 에 들어 있는 값 p   = %p\n", (void *)p);
    printf("p 자신의 주소 &p      = %p\n", (void *)&p);
    printf("p == &stock ? %d\n", p == &stock);
    printf("*p = %d\n", *p);

    *p = 7;
    printf("*p = 7 뒤 stock = %d\n", stock);
    printf("sizeof stock = %zu, sizeof p = %zu\n", sizeof stock, sizeof p);
    return 0;
}

swap과 출력 매개변수

#include <stdio.h>

static void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

static int split_minutes(int total, int *hours, int *minutes) {
    if (total < 0 || hours == NULL || minutes == NULL) {
        return -1;
    }
    *hours = total / 60;
    *minutes = total % 60;
    return 0;
}

int main(void) {
    int x = 1;
    int y = 2;
    swap(&x, &y);
    printf("swap 뒤 x=%d y=%d\n", x, y);

    int h = 0;
    int m = 0;
    if (split_minutes(135, &h, &m) == 0) {
        printf("135분 = %d시간 %d분\n", h, m);
    }
    if (split_minutes(-5, &h, &m) != 0) {
        printf("-5분은 거절, h=%d m=%d 는 그대로\n", h, m);
    }
    return 0;
}

NULL로 "없음"을 알리는 검색

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

static int *find(int *values, int count, int target) {
    for (int i = 0; i < count; i++) {
        if (values[i] == target) {
            return &values[i];
        }
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    int ids[] = {3, 8, 5};
    int *hit = find(ids, 3, 8);
    if (hit != NULL) {
        printf("8 찾음: 인덱스 %td\n", hit - ids);
    }

    int *miss = find(ids, 3, 9);
    if (argc > 1 && strcmp(argv[1], "unsafe") == 0) {
        printf("검사 없이 읽기: %d\n", *miss);
    } else if (miss == NULL) {
        printf("9 없음: NULL 이므로 읽지 않는다\n");
    }
    return 0;
}

줄별 해설

  • int *p = &stock; — 선언에서 *는 "p는 int를 가리키는 포인터"라는 뜻이다. &stockstock 칸의 주소다. 선언의 *와 식의 *(역참조)는 같은 기호지만 역할이 다르다.
  • printf("%p", (void *)&stock) — 주소는 %p로 찍고, 인자는 void *로 바꿔 넘기는 것이 표준에 맞는 방법이다.
  • *p = 7; — p에 든 주소로 가서 그 칸에 7을 쓴다. stock이라는 이름을 쓰지 않았는데 stock이 바뀐다.
  • sizeof p — 포인터 자신의 크기다. 가리키는 대상이 int든 큰 구조체든 64비트 환경에서는 보통 8바이트다.
  • static void swap(int *a, int *b) — 주소를 받는다. int tmp = *a; *a = *b; *b = tmp;는 "a가 가리키는 칸"과 "b가 가리키는 칸"의 내용을 바꾼다.
  • swap(&x, &y); — 호출하는 쪽에서 주소를 넘긴다. 이 &가 "이 함수는 내 변수를 바꿀 수 있다"는 표시 역할도 한다.
  • split_minutes(int total, int *hours, int *minutes) — 반환값은 성공 0, 실패 -1이고, 결과는 포인터 두 개로 돌려준다. 실패하면 출력 칸을 건드리지 않는다는 약속도 함께 지킨다.
  • hours == NULL 검사 — 받은 포인터가 NULL이면 따라가면 안 된다. 공개 함수라면 이 검사를 먼저 한다.
  • return NULL;(null.c) — "가리키는 것이 없음"을 나타내는 특별한 포인터 값이다. 역참조하면 UB이며, 대부분의 운영체제에서는 0번지 근처가 접근 금지라 프로그램이 즉시 죽는다.
  • hit - ids — 같은 배열 안을 가리키는 두 포인터의 차는 원소 개수다(8장에서 자세히).
  • argv[1]"unsafe"일 때 — 일부러 NULL 검사를 건너뛰는 경로다. 실험용으로만 넣었다.

실제 실행 결과

$ ./pointer
stock 의 주소 &stock = 0x16fa7b638
p 에 들어 있는 값 p   = 0x16fa7b638
p 자신의 주소 &p      = 0x16fa7b630
p == &stock ? 1
*p = 42
*p = 7 뒤 stock = 7
sizeof stock = 4, sizeof p = 8

주소 값(0x16f...)은 이 실행에서 나온 값이고, macOS와 리눅스는 실행할 때마다 스택 위치를 무작위로 바꾸므로(ASLR) 여러분의 결과와 다르다. 변하지 않는 사실은 세 가지다. p의 값과 &stock이 같다. &p는 다른 칸이다. p는 8바이트다. 이 실행에서는 pstock보다 8바이트 낮은 주소에 놓였지만, 지역 변수를 어떤 순서로 배치할지는 컴파일러 마음이라 믿으면 안 된다.

$ ./swap
swap 뒤 x=2 y=1
135분 = 2시간 15분
-5분은 거절, h=2 m=15 는 그대로
$ ./null
8 찾음: 인덱스 1
9 없음: NULL 이므로 읽지 않는다

NULL을 검사하지 않고 읽으면

$ ./null unsafe; echo "종료 코드: $?"
8 찾음: 인덱스 1
/bin/sh: line 1: <pid> Segmentation fault: 11  ./null unsafe
종료 코드: 139

셸이 "Segmentation fault: 11"을 알리고 종료 코드는 139(128 + 신호 번호 11)다. 리눅스 bash에서는 "Segmentation fault (core dumped)"처럼 문구가 조금 다르다. 운영체제가 막아 줘서 바로 죽었지만, 이것은 UB가 우연히 "빨리 죽는" 쪽으로 나타난 것이다. 임베디드 보드처럼 0번지가 실제 메모리인 환경에서는 죽지 않고 엉뚱한 값을 읽고 계속 달린다. UBSan을 켜면 어느 줄에서 NULL을 읽었는지 알려 준다.

$ clang -std=c17 -Wall -Wextra -g -fsanitize=undefined -fno-sanitize-recover=null null.c -o null_ub && ./null_ub unsafe
8 찾음: 인덱스 1
null.c:22:46: runtime error: load of null pointer of type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior null.c:22:46 
/bin/sh: line 1: <pid> Abort trap: 6           ./null_ub unsafe

초기화하지 않은 포인터

#include <stdio.h>

int main(void) {
    int *p;
    *p = 10;
    printf("%d\n", *p);
    return 0;
}
$ clang -std=c17 -Wall -Wextra uninit.c -o uninit
uninit.c:5:6: warning: variable 'p' is uninitialized when used here [-Wuninitialized]
    5 |     *p = 10;
      |      ^
uninit.c:4:11: note: initialize the variable 'p' to silence this warning
    4 |     int *p;
      |           ^
      |            = NULL
1 warning generated.

int *p;는 쓰레기 주소를 담고 있다. 그 주소에 10을 쓰면 아무 곳의 메모리를 망가뜨린다. 결과가 우연에 달려 있으므로 실행 결과는 싣지 않는다. 포인터는 선언할 때 가리킬 대상이나 NULL로 반드시 초기화한다.

한눈에 보기

& 와 * 한눈에 (int stock = 42; int *p = &stock;)
자료형
stockint칸의 값 42
&stockint *stock 칸의 주소
pint *p 칸에 적힌 주소(= &stock)
*pint주소를 따라간 칸(= stock)
&pint **p 칸 자신의 주소
역참조하면 안 되는 포인터
상태흔한 원인예방
NULL검색 실패, malloc·fopen 실패쓰기 전에 검사
초기화 안 됨int *p; 선언만선언과 동시에 초기화
사라진 지역 변수를 가리킴지역 변수 주소 반환값 반환이나 호출자 버퍼 사용
해제된 블록을 가리킴free 뒤 계속 사용free 직후 NULL 대입(9장)

실무에서 자주 틀리는 것

  • int* a, b;에서 b도 포인터라고 생각한다. *는 변수 하나에 붙는다. b는 그냥 int다. 한 줄에 포인터 하나만 선언한다.
  • 반환 코드와 출력 매개변수 중 하나만 확인한다. 실패했는데 출력 칸을 읽으면 이전 값이나 쓰레기 값을 쓴다. 반환값이 성공일 때만 결과를 읽는다.
  • NULL 검사를 호출하는 쪽과 받는 쪽 모두 생략한다. "못 찾으면 NULL"을 돌려주는 함수(strchr, fopen, malloc, getenv 등)의 결과는 쓰기 전에 항상 검사한다.
  • 주소 값을 로그에 남겨 재현하려 한다. ASLR 때문에 실행마다 바뀐다. 주소보다 "몇 번째 원소인가", "어느 구조체의 어떤 필드인가"를 기록한다.

연습 문제

  1. int a = 3; int *p = &a; int **pp = &p;일 때 **pp = 9;를 실행하면 무엇이 바뀌는가? 메모리 그림으로 설명하라.
  2. 배열의 최솟값과 최댓값을 한 번에 구하는 함수의 선언을 "반환값은 성공 여부, 결과는 출력 매개변수" 패턴으로 써라. 빈 배열이면 어떻게 하는가?
  3. 다음 코드의 문제는 무엇이며, 어떻게 고치는가? char *name = getenv("DEVICE"); printf("%zu\n", strlen(name));

정답

  1. a가 9로 바뀐다. pp 칸에는 p의 주소가, p 칸에는 a의 주소가 들어 있다. *ppp, **pp*p, 즉 a다. 화살표 두 번을 따라가는 것이다. 이 "포인터의 포인터"는 함수가 호출한 쪽의 포인터 변수 자체를 바꿔야 할 때(예: 새로 할당한 버퍼 주소를 돌려줄 때) 쓴다.
  2. int min_max(const int *values, size_t count, int *min_out, int *max_out); — 성공하면 0, count == 0이거나 포인터가 NULL이면 -1을 돌려주고 출력 칸은 건드리지 않는다. 빈 배열에는 최솟값이 없으므로 임의의 값(예: 0)을 채워 주면 호출한 쪽이 진짜 0과 구분할 수 없다.
  3. getenv는 환경 변수가 없으면 NULL을 돌려준다. strlen(NULL)은 UB이며 보통 즉시 죽는다. if (name == NULL) { fprintf(stderr, "DEVICE 가 설정되지 않았다\n"); return 1; }처럼 먼저 검사하고, 필요하면 기본값을 쓴다.

다음 장에서는 포인터에 정수를 더하고 빼는 포인터 연산과, 배열 인덱스가 사실은 포인터 연산이라는 것을 본다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.