Devin.KR

C 포인터와 배열 - 포인터 연산 끝 포인터 2차원 배열 const (C 기초 8장)

개발자 조회 1

이 장에서 배우는 것

7장에서 포인터가 "번지수를 적어 둔 칸"임을 봤다. 번지수에 1을 더하면 어디로 갈까? C에서 포인터에 1을 더하면 1바이트가 아니라 가리키는 자료형 하나 크기만큼 이동한다. 이 규칙 덕분에 배열 인덱스 a[i]는 포인터 연산 *(a + i)의 줄임말이 된다. 문자열 처리, 버퍼 파싱, 장치 레지스터 접근 코드가 모두 이 위에 서 있다.

  • 포인터 + 정수가 자료형 크기만큼 움직이는 것을 측정한다.
  • a[i], *(a + i), p[i]가 같은 칸임을 확인한다.
  • 포인터 차(ptrdiff_t)와 "끝 다음 칸" 포인터로 범위를 표현한다.
  • 포인터로 문자열을 훑고, 2차원 배열의 메모리 배치를 본다.
  • const int *int *const의 차이를 컴파일 오류로 확인한다.

문제 상황

펌웨어에서 받은 패킷 버퍼를 파싱하는 코드를 읽는데, 인덱스 대신 p++, end - p 같은 식이 가득하다. 동료는 "배열이랑 포인터는 같은 거야"라고 하고, 다른 동료는 "다르다"고 한다. 둘 다 반쯤 맞다. 배열은 메모리 칸의 묶음이고, 포인터는 주소를 담는 변수다. 다만 식 안에서 배열 이름은 대부분 첫 원소의 주소로 바뀌기 때문에 같은 문법으로 다룰 수 있다.

메모리 그림: 포인터 연산은 칸 단위

int nums[4] = {10, 20, 30, 40};    int *pi = nums;

 바이트 오프셋 0         4         8         12        16
             ┌─────────┬─────────┬─────────┬─────────┐
 nums        │   10    │   20    │   30    │   40    │
             └─────────┴─────────┴─────────┴─────────┘
             ▲         ▲         ▲         ▲         ▲
             pi        pi+1      pi+2      pi+3      pi+4 = end
             nums[0]   nums[1]   nums[2]   nums[3]   (끝 다음: 가리킬 수는 있지만 읽으면 안 됨)

 pi + 1  → 주소는 4바이트 증가 (int 한 칸)
 pd + 1  → double* 이면 8바이트 증가
 pc + 1  → char*   이면 1바이트 증가

 nums[i]  ≡  *(nums + i)  ≡  pi[i]
 &nums[3] - &nums[0]  = 3   (바이트 수가 아니라 원소 수)

int grid[2][3]  — 행이 연달아 놓인다 (행 우선)
 오프셋 0    4    8    12   16   20
       ┌────┬────┬────┬────┬────┬────┐
       │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │
       └────┴────┴────┴────┴────┴────┘
        grid[0][..]     grid[1][..]

int 포인터에 1 을 더하면 4바이트, double 포인터는 8바이트, char 포인터는 1바이트 이동한다. nums + 4 는 끝 다음 칸으로 비교에만 쓴다.

int 포인터에 1 을 더하면 4바이트, double 포인터는 8바이트, char 포인터는 1바이트 이동한다. nums + 4 는 끝 다음 칸으로 비교에만 쓴다.

int grid[2][3] 은 표처럼 보이지만 메모리에서는 0행 세 칸 뒤에 1행 세 칸이 이어진 24바이트 한 줄이다.

int grid[2][3] 은 표처럼 보이지만 메모리에서는 0행 세 칸 뒤에 1행 세 칸이 이어진 24바이트 한 줄이다.

완성 코드

포인터 연산과 끝 포인터

#include <stdio.h>
#include <stddef.h>

int main(void) {
    int nums[4] = {10, 20, 30, 40};
    double temps[2] = {36.5, 37.1};
    char word[] = "abc";

    int *pi = nums;
    double *pd = temps;
    char *pc = word;

    printf("int*    +1 은 %td바이트 이동\n", (char *)(pi + 1) - (char *)pi);
    printf("double* +1 은 %td바이트 이동\n", (char *)(pd + 1) - (char *)pd);
    printf("char*   +1 은 %td바이트 이동\n", (pc + 1) - pc);

    for (int i = 0; i < 4; i++) {
        printf("nums[%d]=%d *(nums+%d)=%d pi[%d]=%d\n",
               i, nums[i], i, *(nums + i), i, pi[i]);
    }

    int *first = &nums[0];
    int *last = &nums[3];
    ptrdiff_t gap = last - first;
    printf("last - first = %td (원소 개수 차이)\n", gap);

    int *end = nums + 4;
    int sum = 0;
    for (int *q = nums; q != end; q++) {
        sum += *q;
    }
    printf("끝 포인터까지 합 = %d\n", sum);
    return 0;
}

포인터로 문자열 훑기, 2차원 배열 배치

#include <stdio.h>
#include <stddef.h>

static size_t my_strlen(const char *s) {
    const char *p = s;
    while (*p != '\0') {
        p++;
    }
    return (size_t)(p - s);
}

static void to_upper_ascii(char *s) {
    for (; *s != '\0'; s++) {
        if (*s >= 'a' && *s <= 'z') {
            *s = (char)(*s - 'a' + 'A');
        }
    }
}

int main(void) {
    char tag[] = "gpio-led";
    printf("my_strlen = %zu\n", my_strlen(tag));
    to_upper_ascii(tag);
    printf("대문자로: %s\n", tag);

    int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};
    const char *origin = (const char *)grid;
    for (int r = 0; r < 2; r++) {
        for (int c = 0; c < 3; c++) {
            printf("grid[%d][%d]=%d @%td  ", r, c, grid[r][c],
                   (const char *)&grid[r][c] - origin);
        }
        printf("\n");
    }
    printf("sizeof grid = %zu, sizeof grid[0] = %zu\n", sizeof grid, sizeof grid[0]);
    return 0;
}

줄별 해설

  • int *pi = nums; — 배열 이름 nums가 식에서 첫 원소 주소 &nums[0]으로 바뀌었다. 단, sizeof nums&nums에서는 바뀌지 않고 배열 전체를 뜻한다.
  • (char *)(pi + 1) - (char *)pi — 포인터 연산으로 한 칸 이동한 뒤, 바이트 단위로 몇 바이트 움직였는지 잰다.
  • *(nums + i) — 시작 주소에서 i칸 간 곳을 읽는다. 컴파일러는 nums[i]를 정확히 이 식으로 바꿔 처리한다.
  • ptrdiff_t gap = last - first; — 같은 배열 안의 두 포인터를 빼면 원소 개수가 나온다. 다른 배열끼리 빼는 것은 UB다.
  • int *end = nums + 4; — 배열의 "끝 다음 칸"을 가리키는 포인터는 만들고 비교하는 것까지 허용된다. 역참조(*end)만 금지다. [시작, 끝) 반열린 구간 표현은 길이 0인 범위도 자연스럽게 다룰 수 있어 C 라이브러리와 C++ 반복자의 기본 관례가 됐다.
  • for (int *q = nums; q != end; q++) — 인덱스 없이 포인터를 한 칸씩 옮기며 읽는다.
  • my_strlen — 0 바이트를 만날 때까지 p를 옮긴 뒤 p - s로 글자(바이트) 수를 얻는다. 표준 strlen도 원리는 같다. 그래서 널 문자가 없는 배열을 넘기면 배열 밖까지 훑는다.
  • to_upper_ascii(char *s) — 매개변수 s는 복사본이므로 함수 안에서 s++로 옮겨도 호출한 쪽의 포인터는 그대로다. 옮겨 다니며 가리키는 칸의 내용을 바꾼다.
  • (const char *)grid — 2차원 배열 전체의 시작 주소를 바이트 단위로 본다. 각 원소의 오프셋을 재서 행이 이어 붙어 있음을 확인한다.

실제 실행 결과

$ ./arith
int*    +1 은 4바이트 이동
double* +1 은 8바이트 이동
char*   +1 은 1바이트 이동
nums[0]=10 *(nums+0)=10 pi[0]=10
nums[1]=20 *(nums+1)=20 pi[1]=20
nums[2]=30 *(nums+2)=30 pi[2]=30
nums[3]=40 *(nums+3)=40 pi[3]=40
last - first = 3 (원소 개수 차이)
끝 포인터까지 합 = 100
$ ./walk
my_strlen = 8
대문자로: GPIO-LED
grid[0][0]=1 @0  grid[0][1]=2 @4  grid[0][2]=3 @8  
grid[1][0]=4 @12  grid[1][1]=5 @16  grid[1][2]=6 @20  
sizeof grid = 24, sizeof grid[0] = 12

grid[1][0]grid[0][2] 바로 뒤, 오프셋 12에 있다. sizeof grid[0]은 한 행(int 3개)의 크기 12다. 이미지 버퍼나 센서 행렬을 1차원 버퍼로 받아 buf[row * width + col]로 접근하는 코드가 이 배치를 이용한다.

const와 포인터: 무엇이 읽기 전용인가

#include <stdio.h>

int main(void) {
    int a = 1;
    int b = 2;

    const int *read_only = &a;
    read_only = &b;
    *read_only = 3;

    int *const fixed = &a;
    *fixed = 4;
    fixed = &b;

    printf("%d %d\n", a, b);
    return 0;
}
$ clang -std=c17 -Wall -Wextra const_ptr.c -o const_ptr
const_ptr.c:9:16: error: read-only variable is not assignable
    9 |     *read_only = 3;
      |     ~~~~~~~~~~ ^
const_ptr.c:13:11: error: cannot assign to variable 'fixed' with const-qualified type 'int *const'
   13 |     fixed = &b;
      |     ~~~~~ ^
const_ptr.c:11:16: note: variable 'fixed' declared const here
   11 |     int *const fixed = &a;
      |     ~~~~~~~~~~~^~~~~~~~~~
2 errors generated.
const int *read_only    "가리키는 int 를 이 포인터로 바꾸지 않겠다"
                         read_only = &b;   가능 (다른 곳을 가리키기)
                         *read_only = 3;   오류

int *const fixed        "이 포인터는 다른 곳을 가리키지 않겠다"
                         *fixed = 4;       가능 (내용 바꾸기)
                         fixed = &b;       오류

읽는 법: * 를 기준으로 왼쪽의 const 는 "가리키는 값", 오른쪽의 const 는 "포인터 자신"

함수 매개변수에서 const char *s는 "이 함수는 네 문자열을 바꾸지 않는다"는 약속이다. 6장의 sum(const int values[], ...)도 같은 뜻이다. 읽기만 하는 포인터 매개변수에는 const를 붙여 두면 실수로 쓰는 코드를 컴파일러가 막아 준다.

한눈에 보기

포인터로 할 수 있는 연산
연산허용결과
포인터 + 정수배열 안과 끝 다음 칸까지자료형 크기 × 정수만큼 이동
포인터 - 포인터같은 배열 안끼리원소 개수(ptrdiff_t)
포인터 비교 < !=같은 배열 안끼리앞뒤 위치
끝 다음 칸 역참조금지UB
포인터 + 포인터금지컴파일 오류
const 의 위치에 따른 차이
선언다른 곳 가리키기가리키는 값 바꾸기
int *p가능가능
const int *p가능불가
int *const p불가가능
const int *const p불가불가

실무에서 자주 틀리는 것

  • 포인터 연산을 바이트 단위로 착각한다. int *p에 헤더 크기 4바이트를 건너뛰려고 p + 4를 하면 16바이트를 건너뛴다. 바이트 단위로 이동하려면 unsigned char *로 계산한다.
  • 끝 포인터를 역참조한다. while (p <= end)처럼 <=를 쓰면 마지막에 *end를 읽는다. 반열린 구간이면 조건은 p != end 또는 p < end다.
  • 2차원 배열을 int **로 넘긴다. int grid[2][3]은 포인터 배열이 아니라 연속 메모리다. 함수 매개변수는 int (*grid)[3] 또는 int grid[][3]이어야 하고, int **로 넘기면 컴파일러 경고와 함께 잘못된 주소를 따라간다.
  • 문자열 리터럴을 고친다. char *s = "abc"; s[0] = 'A';는 읽기 전용 영역을 쓰는 UB이며 보통 죽는다. 고칠 문자열은 char s[] = "abc";로 배열에 복사해 둔다. 리터럴을 가리킬 때는 const char *로 선언하는 습관을 들인다.

연습 문제

  1. double temps[5];에서 &temps[4] - &temps[1]의 값과, 두 주소의 바이트 차이는 각각 얼마인가?
  2. const char *p = "gpio";를 받아 p가 가리키는 문자열 안에서 첫 'i'의 위치(인덱스)를 포인터 연산으로 구하는 코드를 써라. 없으면 -1을 돌려준다.
  3. int m[3][4];에서 m[2][1]m[0][0]에서 몇 바이트 뒤에 있는가? (int는 4바이트)

정답

  1. 포인터 차는 원소 수로 3이고, 바이트 차이는 3 × 8 = 24다.
  2. for (const char *q = p; *q != '\0'; q++) { if (*q == 'i') return (int)(q - p); } return -1;"gpio"에서는 2를 돌려준다. 표준 함수 strchr(p, 'i')도 같은 일을 하며, 못 찾으면 NULL을 돌려주므로 결과를 쓰기 전에 검사한다.
  3. 행 하나는 4 × 4 = 16바이트다. m[2][1]은 (2 × 4 + 1) = 9번째 원소이므로 9 × 4 = 36바이트 뒤다.

다음 장에서는 스택도 정적 영역도 아닌 세 번째 저장 공간, 실행 중에 필요한 만큼 빌려 쓰는 힙 메모리를 다룬다.

댓글 0

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

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