본문으로 건너뛰기
Previous
Next
C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA | 실전 가이드

C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA | 실전 가이드

C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA | 실전 가이드

이 글의 핵심

배열 이름이 언제 “첫 요소의 포인터”로 떨어지는지, 왜 char s[] = "hi"와 char *p = "hi"의 수정 가능성이 다른지, VLA가 스택에 어떤 비용을 남기는지 정리합니다.

#06 | 전체 목차 | 이전: #05 포인터 · 다음: #07 구조체·비트필드


해킹당한 적 있나요? 배열 경계 검사 안 해서 인덱스 한 칸 넘겼다가 옆집 쓰기 때려박고, 그게 CVE로 찍힌 적. …아, 네 PC 뚫린 느낌 말고 코드 얘기야. 아래는 반말로 갈게. C는 배열에 길이 필드를 싣지 않고, a[i] 직전에 “끝이 어딘지” 네가 증명 못 하면 UB 쪽으로 미끄러질 수도 있지 — 컴파일러는 “그래, 믿을게” 하고 넘기거든.

그래서 실제 세계에선 버퍼 오버런이란 이름으로 CVE가 수도 없이 찍혀 왔지. 80년대 Morris 웜은 finger 데몬 쪽에서 경계를 안 본 입출력이 터지면서 퍼졌다고 알려져 있고(역사 뒤엔 gets 류의 문화도 한몫했다는 이야기도 많고), 2000년대 초 Code Red 같은 건 웹서버·스택 한 바퀴에서 쓰기 한도를 넘는 입력이 그대로 RCE 쪽으로 이어진 대표적인 악몽이고. 2014년 Heartbleed는 더 교과서에 가깝다 — OpenSSL의 TLS “heartbeat” 구현이 상대가 말한 payload 길이를 믿고, 실제로 보낸 바이트 수랑 안 맞는데도 그만큼 메모리를 읽어버린 케이스야. “배열”이라고 쓰지 않아도, 결국 얼마만 읽을지/쓸지를 런타임에 검증하지 않은 게 똑같이 칼날이었던 거지.

여기서 할 일은 교과서 표로 뼈말뚝 세우는 게 아니라, C에서 배열이 어떻게 “길이를 잃는지”를 몸에 붙이는 거야. 아래는 그냥 내가 터놓는 이야기 + 예제뿐이야.

int a[5]; 하면 다섯 칸짜리 한 덩어리 객체가 잡혀. a[0]부터 a[4]까지만 합법이고, a[5]는 표준에선 UB. 디버그 빌드에서 잡힐 수도, 릴리스에선 “아무 일 없이” 옆을 건드릴 수도 있어 — 그게 더 무섭다.

배열을 함수에 넘기면, 대부분의 문맥에서 이름이 첫 요소의 포인터로 “쭈그러듯” 떨어지는 걸 array-to-pointer decay라고 불러(디케이). 그러면 “원래 10칸”이란 정보는 타입에서 증발에 가깝다고 보면 돼. 그래서 void f(int a[]) 안에서 sizeof a 찍으면, 배열 전체가 아니라 포인터 크기가 나올 수 있어 — 이 함정, 한 번쯤은 다 밟지.

그래서 실무 API는 ptr+len 쌍이나, 아예 struct { size_t len; int *data; } 같이 길이를 같이 넘기는 패턴이 딱 늘어서 나와. sizeof로 요소 개수 세는 매크로 COUNTOF 같은 거 쓸 때는, 파일/블록 스코프의 진짜 배열에만 써. 함수 인자로 받은 int a[]에 쓰면 전부 틀린 숫자가 나온다는 거, 그냥 외우면 돼.

sizeof는 decay 예외로 잘 쓰인다. int x[10]; 이면 sizeof x는 (대개) 40 — 전체. 반대로 x를 값으로 쓰는 식이면 int *로 가버리는 경우가 대부분이고, x가 decay한 뒤 x+1은 “다음 int 한 칸”이야. &x는 전체 배열에 대한 포인터라 &x+1은 “10칸 통째”만큼 점프하는 그늘진 타입이고 — 2D나 디버깅할 때 헷갈리면, 일단 1D로 쪼개서 stride 생각해봐. row-majorm[r][c]는 잡아두면 r * cols + c 한 줄짜리 인덱스로 돌릴 수 있지.

초기화는 여전히 C의 친구야. {0}으로 쓰면 나머지 0 채우고, C99 int d[10] = { [0] = 1, [9] = 2 }; 이런 designated init이 가끔은 구원이야. 복합 리터럴 sum((int[]){1,2,3}, 3) 같은 것도 쓰지만, 반환 뒤에 포인터를 들고 사는 설계는 수명 딱 맞을 때만. 아니면 그냥 스택/힙에서 만들고 규칙을 문서에 박아.

VLA, int a[n]; (런타임 n) 쓰면 “필요한 만큼만” 잡힌다는 면은 있지만, 스택 밑바닥을 긁을 수 있고, 재귀랑 겹치면 O(깊이×크기)로 터질 수도 있지. C11에선 optional이라 구현이 없는 환경도 있고, 임베디드는 꺼놓는 팀이 많다. 껌뻑하고 malloc/calloc 쪽 + 상한, 아니면 고정 MAX 버퍼 + 실제 len 필드, 이런 쪽이 “제품”에서 더 흔해.

문자열은 꼬이기 좋다. char s[] = "hello";쓰기 가능한 복사본이고, const char *p = "hello";리터럴(대개 읽기 전용에 가까움)을 가리키는 거. 옛날 스타일 char *p = "hello"; p[0]='H';는 환경에 따라 바로 죽거나, 아주 나쁜 꿈(UB)이고. strcpy는 목적지가 충분한지 증명 못 하면 위험 — snprintf(dst, sizeof dst, "%s", src)로 상한+널, 바이너리는 memcpy/memmove 쪽. UTF-8은 바이트 인덱스라 “문자 단위”랑은 또 다른 머리 써야 하고.

동적 1D는 malloc(n * sizeof *a); 느낌이고, 곱셈 오버플로는 진짜로 뉴스 나와 — 작은 덩어리 잡혀놓고 엄청 쓰다가 힙 망가지는 그림. n != 0 && sizeof(*a) > SIZE_MAX / n 같은 가드, 또는 calloc, 팀에서 쓰는 안전한 곱 헬퍼 쓰는 게 맞다. 2D는 행마다 malloc해서 int ** 가끔 짜는데, 캐시는 아픈 맛이 있을 수 있고, 한 덩이리 + r*cols+c 는 실수는 줄이려면 작은 at(r,c) 인라인 함수로 묶는 편이 편하다. realloc옛 포인터 무효일 수 있으니, 다른 스레드가 동시에 잡고 있지 않은지(레이스)도 같이.

정렬/탐색은 qsort/bsearch가 있고, bsearch이미 정렬됐다는 전제 잊지 마. ASan(주소 산자), -Wall -Wextra 같은 건 해독제지 면죄부는 아냐. 공개 헤더엔 “이 포인터는 n개야, 0이면 어떻게”까지 최소한 영어/한글로 써 두고, 파서/디코더는 fuzz 한 번쯤 돌려봐 — Heartbleed가 말해준 건, 런과 입력을 믿지 말고 길이를 다시 측정하라는 것 쪽이 더 크다.

restrict 쓰면 컴파일러가 안 겹친다는 약속이거든 — 겹치면 UB. aligned_alloc은 SIMD 버퍼 같은 데 쓰지만, free 짝이랑 표준/플랫폼 문서는 읽고 써. FAM(유연한 배열 멤버)이나 구조체랑 겹치는 건 #07 쪽 흐름이고, 힙/세니타이저 심화는 #09 보면 이어진다.


다음: #07 구조체·정렬·비트필드


내부 링크


끄트머리에 할 만한 미친(교육용) 실험 세 가지: (1) COUNTOFvoid f(int a[10]) 안에 박아서 sizeof랑 찍어보기. (2) char *p = "x"; p[0]='X'; 를 ASan 켜고. (3) 2D를 1D로 펴서 꼭짓점 인덱스만 따로 검증하는 테스트 하나.

원격에 올릴 땐 팀 루북 먼저 — 보통 git addcommitpushnpm run deploy 흐름 쓰는 곳이 많다.

/* 고정 vs 전달: sizeof 함정 눈으로 보기 */
#include <stdio.h>
#define COUNTOF(a) (sizeof(a) / sizeof((a)[0]))

void f(int a[10]) {
    (void)a;
    printf("inside f, sizeof a == %zu (pointer-sized, usually)\n", sizeof a);
}

int main(void) {
    int xs[10] = {0};
    printf("in main, COUNTOF(xs) == %zu\n", COUNTOF(xs));
    f(xs);
    return 0;
}
/* 리터럴 vs 복사본: 하나는 건드리면 지옥, 하나는 괜찮음 */
int main(void) {
    char s[] = "hi";   /* 스택(또는 지역)에 복사 */
    s[0] = 'H';        /* OK */

    const char *p = "hi";
    (void)p; /* p[0] = 'H';  <- don't: 상수 쪽 */

    return 0;
}
/* row-major 인덱스: 열/행 뒤집으면 나중에만 터짐 */
#include <stddef.h>
static size_t idx_rm(size_t r, size_t c, size_t cols) { return r * cols + c; }
/* snprintf로 문자열 이음 — 상한 */
#include <stdio.h>
void copy_name(char *dst, size_t dstsz, const char *src) {
    if (snprintf(dst, dstsz, "%s", src) >= (int)dstsz) { /* 팀 룰대로 잘림 처리 */ }
}
/* malloc: 곱 overflow 한 번 점검 */
#include <stdint.h>
#include <stdlib.h>
int *alloc_ints(size_t n) {
    if (n != 0 && sizeof(int) > SIZE_MAX / n) return NULL;
    return (int *)malloc(n * sizeof(int));
}

자주 묻는 질문 (FAQ)

Q. sizeof(a)는 언제 전체, 언제 포인터?

A. a진짜 배열 객체로 남는 문맥(예: 같은 함수의 int a[10];)이면 전체 바이트. 함수 매개변수 int a[] 는 조정돼서 포인터로 취급되니까, 그때 sizeof포인터 크기로 나갈 수 있어. “길이”를 넘기지 못하니, API에선 n이나 struct로 같이.

Q. 리터럴을 수정하면?

A. char *p = "x"; 로 잡고 p[0] = 같은 거 하면 UB 쪽. 리터럴은 보통 읽기 전용에 가깝게 올라가. 고치고 싶으면 char s[] = "x"; 식으로 배열에 복사해.

Q. Heartbleed랑 C 배열 수업이 무슨 상관?

A. 상관은 “실제 램프에서 몇 바이트”인지 신뢰하기 전에 한 번 더 재검증하라는 점. 언어가 배열 길이를 안 들고 가니까, 프로토콜/입력에서 온 “길이”도 똑같이 검증 대상이야.


키워드

C, 배열, decay, sizeof, 문자열 리터럴, VLA, row-major, memcpy, malloc, qsort, AddressSanitizer, 버퍼 오버런, Heartbleed, snprintf, restrict 등.