C · 기본
메모리 그림으로 배우는 C
C 배열과 문자열 - 연속 메모리 널 종료와 안전한 복사 (C 기초 6장)
배열의 연속 배치와 함수로 넘길 때 사라지는 크기, 널 종료 문자열과 UTF-8 바이트, snprintf 잘림 검사와 범위 밖 접근의 UBSan 보고를 확인한다.
개발자 · 원고 갱신
이 장에서 배우는 것
5장에서 지역 변수가 스택 프레임의 칸이라는 것을 봤다. 배열은 그 칸을 같은 크기로 여러 개 붙여 놓은 것이고, C의 문자열은 그 배열 끝에 0 바이트 하나를 붙인 약속이다. 배열에는 길이 정보가 따로 저장되지 않는다. 이 한 가지 사실이 C에서 가장 흔한 보안 사고인 버퍼 오버플로의 뿌리다.
- 배열 원소가 메모리에 빈틈없이 연속으로 놓이는 것을 오프셋으로 확인한다.
- 배열을 함수에 넘기면 크기 정보가 사라지는 현상(포인터로의 변환)을 경고와 함께 본다.
- 문자열의 널 종료,
sizeof와strlen의 차이, UTF-8 한글의 바이트 수를 안다. snprintf로 잘림을 감지하며 안전하게 복사하고, 배열 범위 밖 접근을 UBSan으로 잡는다.
문제 상황
사용자 ID를 8바이트 버퍼에 복사하는 코드가 있다. 테스트 ID는 모두 짧아서 문제가 없었는데, 긴 ID가 들어오자 옆 변수 값이 바뀌고 가끔 프로그램이 죽는다. 또 재고 합계를 구하는 반복문이 가끔 이상한 합계를 낸다. 배열은 자기 크기를 모르고, C는 범위를 검사하지 않는다. 그 대가를 이 장에서 눈으로 확인한다.
메모리 그림: 배열과 문자열
int stock[5] = {12, 7, 3}; (int 4바이트, 나머지는 0 으로 채워짐)
오프셋 0 4 8 12 16 20
┌─────┬─────┬─────┬─────┬─────┐
stock │ 12 │ 7 │ 3 │ 0 │ 0 │ ?? ← stock[5] 는 배열 밖
└─────┴─────┴─────┴─────┴─────┘
[0] [1] [2] [3] [4]
sizeof stock = 20, 원소 수 = 20 / 4 = 5
char animal[] = "cat"; 문자열 = 문자 + 끝 표시 0
┌────┬────┬────┬────┐
animal │ 63 │ 61 │ 74 │ 00 │ 'c' 'a' 't' '\0'
└────┴────┴────┴────┘
sizeof = 4 (배열 크기), strlen = 3 (0 앞까지 센 글자 수)
char hangul[] = "한글"; UTF-8: 한 글자 3바이트
┌────┬────┬────┬────┬────┬────┬────┐
│ ed │ 95 │ 9c │ ea │ b8 │ 80 │ 00 │
└────┴────┴────┴────┴────┴────┴────┘
──── 한 ──── ──── 글 ────
int stock[5] 는 4바이트 칸 다섯 개가 붙은 20바이트다. stock[5] 는 배열 바로 뒤의 남의 메모리이며 C 는 범위를 검사하지 않는다.
"cat" 은 글자 3개와 0 바이트로 4바이트다. "한글" 은 UTF-8 로 글자마다 3바이트라 strlen 이 6 이다.
완성 코드
배열의 배치와 합계
#include <stdio.h>
#include <stddef.h>
static int sum(const int values[], size_t count) {
int total = 0;
for (size_t i = 0; i < count; i++) {
total += values[i];
}
return total;
}
int main(void) {
int stock[5] = {12, 7, 3};
size_t count = sizeof stock / sizeof stock[0];
printf("sizeof stock = %zu, 원소 수 = %zu\n", sizeof stock, count);
for (size_t i = 0; i < count; i++) {
ptrdiff_t offset = (char *)&stock[i] - (char *)&stock[0];
printf("stock[%zu] = %2d (시작에서 %td바이트)\n", i, stock[i], offset);
}
printf("합계 = %d\n", sum(stock, count));
return 0;
}
함수로 넘긴 배열의 sizeof
#include <stdio.h>
static void show(int values[5]) {
printf("함수 안 sizeof values = %zu\n", sizeof values);
}
int main(void) {
int stock[5] = {12, 7, 3, 0, 9};
printf("main 안 sizeof stock = %zu\n", sizeof stock);
show(stock);
return 0;
}
문자열의 바이트
#include <stdio.h>
#include <string.h>
static void dump(const char *label, const char *s, size_t size) {
printf("%-8s sizeof=%zu strlen=%zu 바이트:", label, size, strlen(s));
for (size_t i = 0; i < size; i++) {
printf(" %02x", (unsigned char)s[i]);
}
printf("\n");
}
int main(void) {
char animal[] = "cat";
char name[8] = "kim";
char hangul[] = "한글";
dump("animal", animal, sizeof animal);
dump("name", name, sizeof name);
dump("hangul", hangul, sizeof hangul);
name[1] = '\0';
printf("name[1] 에 0 을 넣은 뒤: \"%s\" (strlen=%zu)\n", name, strlen(name));
return 0;
}
잘림을 감지하는 복사
#include <stdio.h>
#include <string.h>
int main(void) {
const char *input = "embedded-engineer";
char id[8];
int needed = snprintf(id, sizeof id, "%s", input);
printf("snprintf: \"%s\" (필요한 길이 %d, 버퍼 %zu)\n", id, needed, sizeof id);
if (needed >= (int)sizeof id) {
printf("잘렸다 -> 거절하거나 더 큰 버퍼를 쓴다\n");
}
char code[4];
strncpy(code, "ABCD", sizeof code);
printf("strncpy 뒤 code[3] = '%c', 널 종료 여부 = %s\n",
code[3], memchr(code, '\0', sizeof code) ? "있음" : "없음");
return 0;
}
한 칸 넘치는 반복문
#include <stdio.h>
int main(void) {
int stock[5] = {12, 7, 3, 0, 9};
int total = 0;
for (int i = 0; i <= 5; i++) {
total += stock[i];
}
printf("합계 = %d\n", total);
return 0;
}
줄별 해설
int stock[5] = {12, 7, 3};— 초기값을 일부만 주면 나머지 원소는 0이 된다. 초기값을 아예 주지 않은 지역 배열은 쓰레기 값을 가진다.(char *)&stock[i] - (char *)&stock[0]— 두 원소 주소를 바이트 단위 포인터로 바꿔 빼면 오프셋이 나온다. 같은 배열 안의 주소끼리 빼는 것은 표준이 허용한다. 결과형은ptrdiff_t이고%td로 찍는다.static int sum(const int values[], size_t count)— 매개변수int values[]는 사실const int *values와 같다. 배열은 함수로 넘어가는 순간 첫 원소의 주소로 바뀌므로, 원소 수를 따로 넘겨야 한다. C 표준 라이브러리 함수들이 거의 다 "포인터 + 길이" 쌍을 받는 이유다.sizeof values(decay.c) — 매개변수에[5]라고 적어도 포인터의 크기(이 기계에서 8)가 나온다. clang이-Wsizeof-array-argument로 경고한다.char name[8] = "kim";— 8칸 중 앞 3칸에 글자, 넷째 칸에 0, 나머지도 0으로 채워진다.strlen은 첫 0 바이트까지만 세므로 3이다.name[1] = '\0';— 중간에 0을 넣으면 문자열은 거기서 끝난다. 뒤의 바이트는 그대로 남아 있지만 문자열 함수는 보지 않는다.snprintf(id, sizeof id, "%s", input)— 버퍼 크기를 넘지 않게 쓰고 항상 0으로 끝낸다. 반환값은 "공간이 충분했다면 썼을 글자 수"이므로, 버퍼 크기 이상이면 잘렸다는 뜻이다. 잘린 ID를 그대로 쓰면 다른 사용자와 겹칠 수 있으니 잘림은 오류로 처리한다.strncpy(code, "ABCD", sizeof code)— 원본이 버퍼보다 길거나 같으면 0을 붙이지 않는다. 이름과 달리 안전한 문자열 복사 함수가 아니다.memchr로 0이 없음을 확인했다.for (int i = 0; i <= 5; i++)(bounds.c) —<=때문에stock[5], 즉 배열 바로 뒤 4바이트를 읽는다. 컴파일러는 이 반복문을 경고하지 않는다(출력 없음).
실제 실행 결과
$ ./arrays
sizeof stock = 20, 원소 수 = 5
stock[0] = 12 (시작에서 0바이트)
stock[1] = 7 (시작에서 4바이트)
stock[2] = 3 (시작에서 8바이트)
stock[3] = 0 (시작에서 12바이트)
stock[4] = 0 (시작에서 16바이트)
합계 = 22
원소가 정확히 4바이트 간격으로 붙어 있다. 초기값을 주지 않은 stock[3], stock[4]는 0이다.
$ clang -std=c17 -Wall -Wextra decay.c -o decay
decay.c:4:55: warning: sizeof on array function parameter will return size of 'int *' instead of 'int[5]' [-Wsizeof-array-argument]
4 | printf("함수 안 sizeof values = %zu\n", sizeof values);
| ^
decay.c:3:22: note: declared here
3 | static void show(int values[5]) {
| ^
1 warning generated.
$ ./decay
main 안 sizeof stock = 20
함수 안 sizeof values = 8
$ ./strings
animal sizeof=4 strlen=3 바이트: 63 61 74 00
name sizeof=8 strlen=3 바이트: 6b 69 6d 00 00 00 00 00
hangul sizeof=7 strlen=6 바이트: ed 95 9c ea b8 80 00
name[1] 에 0 을 넣은 뒤: "k" (strlen=1)
| 배열 | sizeof | strlen | 바이트 |
|---|---|---|---|
| animal | 4 | 3 | 63 61 74 00 |
| name | 8 | 3 | 6b 69 6d 00 00 00 00 00 |
| hangul | 7 | 6 | ed 95 9c ea b8 80 00 |
"cat"은 3글자인데 4바이트를 차지한다. "한글"은 2글자지만 strlen은 6이다. C의 strlen은 글자가 아니라 바이트를 센다. 화면 글자 수를 제한하는 기능이라면 UTF-8 디코딩이 필요하다.
$ ./copy
snprintf: "embedde" (필요한 길이 17, 버퍼 8)
잘렸다 -> 거절하거나 더 큰 버퍼를 쓴다
strncpy 뒤 code[3] = 'D', 널 종료 여부 = 없음
범위 밖 접근을 UBSan으로 잡기
$ clang -std=c17 -Wall -Wextra bounds.c -o bounds
$ clang -std=c17 -Wall -Wextra -g -fsanitize=undefined -fno-sanitize-recover=bounds bounds.c -o bounds_ub && ./bounds_ub
bounds.c:7:18: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior bounds.c:7:18
/bin/sh: line 1: <pid> Abort trap: 6 ./bounds_ub
-fsanitize=undefined에 포함된 bounds 검사가 크기를 아는 배열(int[5])의 인덱스 5를 잡았다. 여기서는 -fno-sanitize-recover=bounds를 붙여 첫 오류에서 바로 멈추게 했다(셸이 "Abort trap"을 알리고, 번호 자리의 <pid>는 실행마다 바뀌는 프로세스 번호다). 멈추지 않으면 배열 뒤 메모리에 우연히 있던 값이 합계에 더해져 실행마다 다른 결과가 나올 수 있다. 이 검사는 컴파일 시점에 배열 크기를 아는 경우에만 동작한다. 함수로 넘어가 포인터가 된 배열이나 malloc한 메모리의 범위 초과는 AddressSanitizer가 필요하다(9장·12장).
한눈에 보기
| 함수 | 버퍼 크기를 받나 | 널 종료 보장 | 권장 |
|---|---|---|---|
strcpy | 아니오 | 예(넘쳐도 씀) | 쓰지 않는다 |
strncpy | 예 | 아니오 | 문자열 복사용으로 쓰지 않는다 |
snprintf | 예 | 예 | 기본 선택, 반환값으로 잘림 검사 |
memcpy | 길이를 직접 | 해당 없음 | 길이를 이미 알 때 |
실무에서 자주 틀리는 것
strcpy,strcat,sprintf,gets를 쓴다. 모두 대상 버퍼 크기를 모른다.gets는 C11에서 표준에서 아예 빠졌다. 크기를 받는snprintf를 기본으로 쓰고 반환값으로 잘림을 확인한다.- 문자열 버퍼 크기를 글자 수만큼만 잡는다. "최대 8자"인 ID를 담으려면 널 문자를 위해 9바이트가 필요하다. UTF-8 한글이면 글자당 3바이트를 더 고려한다.
char t[3] = "cat";— C에서는 오류도 경고도 없이 컴파일된다(널 문자를 넣을 자리가 없어 조용히 빠진다). 이 배열을printf("%s")나strlen에 넘기면 배열 밖을 읽는다. 크기를 생략하고char t[] = "cat";으로 쓴다.- 함수 안에서
sizeof arr / sizeof arr[0]를 쓴다. 매개변수는 포인터이므로 엉뚱한 값이 나온다. 원소 수는 배열이 선언된 곳에서 계산해 넘긴다. - 문자열을
==로 비교한다. 주소 비교가 된다. 내용 비교는strcmp(a, b) == 0이다.
연습 문제
char msg[] = "hi\n";의sizeof msg와strlen(msg)는 각각 얼마인가?- 최대 10바이트짜리 장치 이름을 입력받아 저장하려 한다. 버퍼 크기는 최소 얼마여야 하고,
snprintf로 복사한 뒤 잘림을 어떻게 검사하는가? arrays.c의sum함수 안에서sizeof values를 찍으면 얼마가 나오는가? 왜sum은count를 따로 받아야 하는가?
정답
sizeof msg는 4(h,i, 줄바꿈, 널),strlen(msg)는 3이다.\n은 두 글자처럼 보이지만 바이트 하나(0x0a)다.- 최소 11바이트(10 + 널 문자).
char name[11]; int n = snprintf(name, sizeof name, "%s", input); if (n < 0 || n >= (int)sizeof name) { /* 잘림 또는 오류: 거절 */ }.snprintf는 인코딩 오류 등에서 음수를 돌려줄 수 있으므로 음수도 함께 검사한다. - 포인터의 크기인 8(이 기계 기준)이 나온다.
int values[]매개변수는int *values와 같아서 배열의 길이 정보를 전달받지 못하기 때문에, 원소 수를 호출하는 쪽이 계산해 넘겨야 한다. 크기를 적지 않은values[]형태에서도 clang은 같은-Wsizeof-array-argument경고를 낸다(검증 스크립트에서 확인). 하지만 매개변수를 처음부터const int *values로 선언했다면 경고 없이 8이 나오므로, 함수 안에서는 원소 수를sizeof로 구하려는 시도 자체를 하지 않는다.
다음 장에서는 이 장에서 계속 등장한 "주소"를 직접 변수에 담아 다루는 포인터를 배운다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.