C · 기본
메모리 그림으로 배우는 C
C 포인터와 배열 - 포인터 연산 끝 포인터 2차원 배열 const (C 기초 8장)
포인터 연산이 자료형 크기 단위로 움직이는 것을 재고, 인덱스와 포인터 식의 관계, 끝 포인터, 2차원 배열 배치, const 포인터의 두 가지 뜻을 익힌다.
개발자 · 원고 갱신
이 장에서 배우는 것
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 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 |
| 포인터 + 포인터 | 금지 | 컴파일 오류 |
| 선언 | 다른 곳 가리키기 | 가리키는 값 바꾸기 |
|---|---|---|
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 *로 선언하는 습관을 들인다.
연습 문제
double temps[5];에서&temps[4] - &temps[1]의 값과, 두 주소의 바이트 차이는 각각 얼마인가?const char *p = "gpio";를 받아p가 가리키는 문자열 안에서 첫'i'의 위치(인덱스)를 포인터 연산으로 구하는 코드를 써라. 없으면 -1을 돌려준다.int m[3][4];에서m[2][1]은m[0][0]에서 몇 바이트 뒤에 있는가? (int는 4바이트)
정답
- 포인터 차는 원소 수로 3이고, 바이트 차이는 3 × 8 = 24다.
for (const char *q = p; *q != '\0'; q++) { if (*q == 'i') return (int)(q - p); } return -1;—"gpio"에서는 2를 돌려준다. 표준 함수strchr(p, 'i')도 같은 일을 하며, 못 찾으면 NULL을 돌려주므로 결과를 쓰기 전에 검사한다.- 행 하나는 4 × 4 = 16바이트다.
m[2][1]은 (2 × 4 + 1) = 9번째 원소이므로 9 × 4 = 36바이트 뒤다.
다음 장에서는 스택도 정적 영역도 아닌 세 번째 저장 공간, 실행 중에 필요한 만큼 빌려 쓰는 힙 메모리를 다룬다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.