88 기반 — 바이트, 뷰, 그리고 넘치지 않는 산술
먼저 알아야 할 것
돌아보기
37장에서 문자 타입을 가리키는 포인터(char*·signed char*·unsigned char*) 만은 어떤 객체든 바이트 단위로 들여다볼 수 있는 특권을 가진다고 했고, 13장에서는 그 규칙을 어긴 코드가 최적화에서 조용히 무너지는 것을 보았다. 그러면 “안전하게 바이트를 다룬다”는 것은 구체적으로 무엇을 지키는 일인가?
답. 두 가지다. 첫째, 들여다보는 타입을 규칙이 면제한 것으로 고정한다 — unsigned char다. 둘째, 들여다보는 범위를 잃어버리지 않는다 — 포인터 하나만 들고 다니면 어디까지가 내 땅인지 잊게 되고, 그것이 38장의 경계 침범이다. proven의 기본 어휘는 이 두 가지를 각각 타입으로 만든 것이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 자르기 함수가
_checked와_unchecked두 판으로 있던데, 후자는 왜 존재하는가?
88.1 바이트에는 이름이 있다
proven_byte_t는 unsigned char의 별칭이다. 별것 아닌 것 같지만 선언 하나가 계약을 적는다 — “이 포인터는 표현(representation)을 보는 눈이지, 어떤 타입의 값을 가리키는 것이 아니다”.
이 구분이 왜 중요한지는 13장에서 이미 보았다. 같은 기억을 uint32_t*와 uint16_t*로 번갈아 보는 코드는 앨리어싱 규칙 위반이고, 컴파일러는 그 전제를 최적화에 쓴다. 반면 unsigned char로 보는 것은 표준이 명시적으로 허용한다. 라이브러리가 원시 메모리를 다룰 때 언제나 이 타입을 거치는 이유이고, 그래서 라이브러리를 통해서는 그 버그를 쓸 수가 없다.
88.2 뷰 — 포인터와 길이를 하나로
기억을 가리키는 어휘는 셋뿐이고, 전부 구조체 두 칸이다. 셋의 차이는 소유하는가와 고칠 수 있는가 둘뿐이다.
/* ① 소유한 덩어리 — "이 기억은 내 것이고, 언젠가 내가 돌려준다" */
typedef struct {
proven_byte_t *ptr;
proven_size_t size;
} proven_mem_t;
/* ② 빌린 읽기 창 — 남의 기억을 들여다보기만 한다 */
typedef struct {
const proven_byte_t *ptr;
proven_size_t size;
} proven_mem_view_t;
/* ③ 빌린 쓰기 창 — 남의 기억을 고칠 수는 있으나 돌려주지는 않는다 */
typedef struct {
proven_byte_t *ptr;
proven_size_t size;
} proven_mem_mut_t;const 하나가 ②와 ③을 가르고, 이름이 ①과 나머지를 가른다. 이 작은 구조체가 하는 일은 하나다 — 포인터와 길이가 절대로 헤어지지 않게 하는 것. 42장에서 “문자열의 진짜 문제는 길이를 따로 들고 다니는 것”이라 했던 그 문제에 대한 답이다.
| 타입 | 소유 | 쓰기 | 어디서 오는가 |
|---|---|---|---|
proven_mem_t | 예 | 예 | 할당자(89장) |
proven_mem_view_t | 아니오 | 아니오 | _as_view, 자르기, 리터럴 |
proven_mem_mut_t | 아니오 | 예 | 할당 결과, 스택 배열, 자르기 |
표 88.1
소유한 덩어리에서 창을 얻는 함수도 짝으로 있다 — proven_mem_view_from_owned, proven_mem_mut_from_owned. 이름의 from_owned가 “소유는 그대로 두고 창만 낸다”는 뜻이다.
examples/ch88/mem.c
/* 기억을 다루는 네 어휘 — 소유(mem_t), 읽기 뷰, 쓰기 뷰, 그리고 정렬.
구조체가 어떻게 생겼고 무엇을 약속하는지 눈으로 확인한다. */
#include <proven.h>
/* 바이트 뷰를 글자로 보기 위한 도우미 — 둘 다 {포인터, 길이}라 그대로 옮긴다 */
static proven_u8str_view_t as_text(proven_mem_view_t v)
{
return (proven_u8str_view_t){ .ptr = v.ptr, .size = v.size };
}
static void show(const char *label, proven_mem_view_t v)
{
proven_println("{:<14} size={} contents=\"{}\"",
PROVEN_ARG(label), PROVEN_ARG(v.size), PROVEN_ARG(as_text(v)));
}
int main(void)
{
/* ── ① 세 가지 어휘 ──────────────────────────────────────────
proven_mem_t { proven_byte_t *ptr; size }; 소유한 덩어리
proven_mem_view_t { const byte *ptr; size }; 빌린 읽기 창
proven_mem_mut_t { byte *ptr; size }; 빌린 쓰기 창
셋 다 "포인터와 길이"가 헤어지지 않게 묶은 것이다. */
proven_byte_t storage[16] = "abcdefghijklmno";
proven_mem_mut_t mut = { .ptr = storage, .size = 15 };
proven_mem_view_t view = { .ptr = storage, .size = 15 };
/* 쓰기 뷰로는 고칠 수 있고, 읽기 뷰로는 고칠 수 없다(컴파일 오류) */
mut.ptr[0] = 'A';
proven_println("sizeof(mem_view_t)={} (pointer + length)",
PROVEN_ARG(sizeof(proven_mem_view_t)));
show("whole view", view);
/* ── ② 자르기 — 검사판과 무검사판 ─────────────────────────── */
proven_result_mem_view_t ok = proven_mem_view_slice_checked(view, 3, 5);
if (proven_is_ok(ok.err)) show("slice(3,5)", ok.value);
proven_result_mem_view_t bad = proven_mem_view_slice_checked(view, 12, 9);
proven_println("slice(12,9) -> err={} (refused, not clamped)",
PROVEN_ARG((int)bad.err));
/* 경계를 이미 확인한 뜨거운 루프에서는 무검사판을 쓴다.
"위험한 선택은 눈에 보이는 이름으로만" 이라는 규칙이 여기 있다. */
proven_mem_view_t fast = proven_mem_view_slice_unchecked(view, 0, 3);
show("unchecked(0,3)", fast);
/* ── ③ 경계를 지키는 복사와 이동 ─────────────────────────── */
proven_byte_t dst[8] = {0};
proven_err_t e1 = proven_mem_copy(dst, sizeof dst,
proven_mem_view_slice_unchecked(view, 0, 5));
proven_println("copy 5 into 8 -> err={}", PROVEN_ARG((int)e1));
proven_err_t e2 = proven_mem_copy(dst, sizeof dst, view); /* 15 > 8 */
proven_println("copy 15 into 8 -> err={} (on overflow it writes nothing at all)",
PROVEN_ARG((int)e2));
/* 겹치는 영역은 move 로. 65장의 memcpy/memmove 구분이 여기서도 같다 */
proven_err_t e3 = proven_mem_move(storage + 2, 13,
proven_mem_view_slice_unchecked(view, 0, 5));
show("source after move", (proven_mem_view_t){ .ptr = storage, .size = 7 });
proven_println("move overlap -> err={}", PROVEN_ARG((int)e3));
/* ── ④ 포인터가 그 덩어리 안에 있는가 ────────────────────── */
proven_size_t off = 0;
bool inside = proven_range_contains_ptr(storage, sizeof storage,
storage + 4, 2, &off);
proven_println("is storage+4 inside? {} (offset {})",
PROVEN_ARG(inside), PROVEN_ARG(off));
/* ── ⑤ 정렬 ──────────────────────────────────────────────── */
proven_println("align_up(13,8)={} align_up(16,8)={} MAX_ALIGN={}",
PROVEN_ARG(proven_mem_align_up(13, 8)),
PROVEN_ARG(proven_mem_align_up(16, 8)),
PROVEN_ARG((proven_size_t)PROVEN_MAX_ALIGN));
proven_println("is_pow2(8)={} is_pow2(12)={} (an alignment must be a power of two)",
PROVEN_ARG(proven_is_pow2(8)), PROVEN_ARG(proven_is_pow2(12)));
return 0;
}
실행 결과
sizeof(mem_view_t)=16 (pointer + length)
whole view size=15 contents="Abcdefghijklmno"
slice(3,5) size=5 contents="defgh"
slice(12,9) -> err=2 (refused, not clamped)
unchecked(0,3) size=3 contents="Abc"
copy 5 into 8 -> err=0
copy 15 into 8 -> err=2 (on overflow it writes nothing at all)
source after move size=7 contents="AbAbcde"
move overlap -> err=0
is storage+4 inside? true (offset 4)
align_up(13,8)=16 align_up(16,8)=16 MAX_ALIGN=16
is_pow2(8)=true is_pow2(12)=false (an alignment must be a power of two)
예제가 이 절과 다음 절의 어휘를 전부 한 번씩 돌려 본다. 다섯 자리를 짚는다.
① 뷰는 두 워드다. 출력의 sizeof(mem_view_t)=16이 그것이다(64비트에서 포인터 8 + 길이 8). 포인터 하나(8바이트)보다 크다는 사실이 뷰의 유일한 비용이고, 그 대가로 경계 검사가 가능해진다.
② 쓰기 창으로만 고칠 수 있다. 예제가 mut.ptr[0] = 'A'로 첫 글자를 바꾸자 이후 출력이 전부 A로 시작한다. 같은 자리를 읽기 뷰로 고치려 하면 컴파일 오류다 — const가 그 일을 한다.
③ 자르기에는 두 판이 있다. 다음 절에서 자세히 본다.
④ 복사·이동에도 경계가 있다. proven_mem_copy(dst, dst_cap, src)는 목적지 용량을 반드시 받고, 들어가지 않으면 한 바이트도 쓰지 않고 OUT_OF_BOUNDS를 돌려준다(출력의 copy 15 into 8). 겹칠 수 있으면 proven_mem_move다 — 65장의 memcpy/memmove 구분이 여기서도 그대로다.
⑤ 포인터가 어느 덩어리에 속하는지 물어볼 수 있다. proven_range_contains_ptr가 그것인데, 눈여겨볼 것은 구현이 정수로 비교한다는 점이다. 서로 다른 할당에서 온 포인터를 <나 >=로 비교하는 것은 계약 밖이므로(37장), 라이브러리는 uintptr_t로 바꾼 뒤 검사한다. 89장의 아레나가 “이 포인터가 내가 내준 것인가”를 확인할 때 쓰는 함수다.
다만 이 우회로가 표준이 보장하는 이식 가능한 검사인 것은 아니다. uintptr_t는 있어도 되고 없어도 되는(선택적) 타입이고, 포인터를 정수로 바꾼 결과가 주소의 크기 순서를 그대로 보존한다는 보장도 표준에는 없다. 표준이 약속하는 것은 “포인터 → uintptr_t → 같은 포인터”의 왕복뿐이다. 평탄한 주소 공간을 쓰는 오늘의 주류 플랫폼에서는 순서 비교가 뜻대로 동작하지만, 세그먼트 주소나 capability 포인터(5장의 CHERI)처럼 주소가 번호 하나가 아닌 기계에서는 다른 이야기가 된다. 그래서 이 함수는 계약이 아니라 proven 의 지원 플랫폼 전제로 읽어야 한다 — “평탄 주소 공간이고 정수 변환이 순서를 보존하는 구현”이 조건이다(94장의 지원 범위 참조).
88.3 자르기 — 이 부에서 가장 자주 쓰는 연산
examples/ch88/view.c
#include <proven.h>
#include <stdio.h>
static void dump(const char *label, proven_mem_view_t v)
{
printf("%-10s size=%zu bytes:", label, v.size);
for (proven_size_t i = 0; i < v.size; i++) printf(" %02x", v.ptr[i]);
printf("\n");
}
int main(void)
{
/* 원시 바이트는 proven_byte_t 다 — unsigned char 의 별칭이라
어떤 객체의 표현이든 들여다볼 수 있는 유일한 타입이다 */
proven_byte_t buf[8] = {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80};
/* view = 포인터 + 길이. 길이를 따로 들고 다니지 않는다 */
proven_mem_view_t all = { .ptr = buf, .size = sizeof buf };
dump("all", all);
/* 자르기: 경계를 넘으면 실패가 값으로 온다 */
proven_result_mem_view_t mid = proven_mem_view_slice_checked(all, 2, 3);
if (proven_is_ok(mid.err)) dump("slice 2+3", mid.value);
proven_result_mem_view_t over = proven_mem_view_slice_checked(all, 6, 4);
printf("%-10s err=%d (refused, not clamped)\n", "slice 6+4", (int)over.err);
/* 크기 계산도 감기지 않게 한다: 배열 원소 수 x 원소 크기 */
proven_size_t n = (proven_size_t)-1 / 2; /* 터무니없이 큰 개수 */
proven_size_t bytes;
if (PROVEN_CKD_MUL(&bytes, n, (proven_size_t)8))
printf("%-10s %zu * 8 overflows — allocation refused\n", "size calc", n);
else
printf("%-10s %zu bytes\n", "size calc", bytes);
/* 정렬 올림: 다음 경계로 밀어 올린다 */
printf("align_up(13, 8) = %zu\n", proven_mem_align_up(13, 8));
return 0;
}
실행 결과
all size=8 bytes: 10 20 30 40 50 60 70 80
slice 2+3 size=3 bytes: 30 40 50
slice 6+4 err=2 (refused, not clamped)
size calc 9223372036854775807 * 8 overflows — allocation refused
align_up(13, 8) = 16
slice 6+4가 이 절의 핵심이다. 8바이트짜리 뷰에서 6번째부터 4바이트를 달라고 했으니 두 바이트가 모자란다. 라이브러리는 있는 만큼 잘라 주지 않는다 — 에러를 돌려준다(2번은 PROVEN_ERR_OUT_OF_BOUNDS다). 있는 만큼 주는 편이 친절해 보이지만, 그러면 호출자는 자기가 받은 것이 요청한 것인지 알 수 없게 된다. 85장의 잘림 문제가 여기서 되풀이되지 않도록 막은 것이다.
네 함수가 짝을 이룬다 — 읽기/쓰기 × 검사/무검사.
| 함수 | 돌려주는 것 | 언제 |
|---|---|---|
proven_mem_view_slice_checked | {err, view} 꾸러미 | 기본값. 경계를 모를 때 |
proven_mem_view_slice_unchecked | 뷰(검사 없음) | 경계를 이미 확인한 뜨거운 루프 |
proven_mem_mut_slice_checked | {err, mut} 꾸러미 | 기본값(쓰기) |
proven_mem_mut_slice_unchecked | 쓰기 창(검사 없음) | 같음 |
표 88.2
검사판의 계약은 세 줄이다. 길이가 0이 아닌데 포인터가 널이면 INVALID_ARG. offset이 크기를 넘거나 offset + size가 크기를 넘으면 OUT_OF_BOUNDS — 그리고 이 검사가 offset + size > view.size가 아니라 size > view.size - offset으로 적혀 있다는 점이 중요하다. 전자는 덧셈이 감길 수 있지만 후자는 절대 감기지 않는다 — 이 장 뒷부분의 검사 산술과 같은 정신이다. 크기가 0이면 널 포인터에 크기 0인 빈 뷰를 돌려준다 — 빈 것을 가리키는 포인터를 역참조하지 못하게 하는 안전장치다.
문. 자르기 함수가 _checked와 _unchecked 두 판으로 있던데, 후자는 왜 존재하는가?
답. 경계를 이미 확인한 자리를 위해서다. 반복문 안에서 매 회 같은 검사를 다시 하는 것은 낭비이므로, 루프 앞에서 한 번 확인하고 안쪽에서는 검사 없는 판을 쓰는 식이다.
/* 8바이트씩 끊어 읽는다 — 경계는 루프 조건이 이미 보증한다 */
for (proven_size_t off = 0; off + 8 <= buf.size; off += 8) {
proven_mem_view_t chunk = proven_mem_view_slice_unchecked(buf, off, 8);
process(chunk);
}이름에 _unchecked가 붙어 있다는 점이 중요하다 — 위험한 선택은 눈에 보이는 이름으로만 할 수 있고, 기본값은 언제나 안전한 쪽이다. 51장에서 말한 “계약을 코드로 적는다”의 한 형태이고, 코드 검토에서 _unchecked를 찾아보는 것만으로 위험 지점을 훑을 수 있다는 실용적 이점도 있다.
반례. 뷰를 소유자보다 오래 들고 있기
proven_mem_view_t get_view(void) {
proven_byte_t local[16] = {0};
return (proven_mem_view_t){ .ptr = local, .size = sizeof local };
} /* local 은 여기서 죽는다 — 돌려준 뷰는 이미 유효하지 않다 */뷰는 빌린 것이다. 소유자가 사라지면 그 순간 유효하지 않게 되고, 그 뒤의 사용은 44장에서 배운 죽은 자동 변수 접근이다. 뷰가 포인터보다 안전한 것은 경계에 대해서이지 수명에 대해서가 아니다 — 수명은 여전히 사람이 지켜야 한다. 그래서 다음 장의 할당자 이야기가 필요해진다.
88.4 넘치지 않는 크기 산술
27장에서 무부호 정수의 감김을 배웠고, 7장에서 그것이 왜 정의된 동작인지 보았다. 감김이 조용하다는 사실은 한 자리에서 특히 위험해진다 — 할당할 바이트 수를 계산할 때다.
void *p = malloc(count * sizeof(item_t)); /* count 가 크면 감긴다 */count가 충분히 크면 곱이 감겨서 아주 작은 수가 되고, malloc은 그 작은 크기로 성공한다. 그 뒤 프로그램은 원래 의도한 개수만큼 쓰기 시작한다 — 전형적인 힙 넘침이다. 27장에서 “크기 계산”을 오버플로의 실제 사고 사례로 든 것이 이 무늬였다.
라이브러리는 크기 계산에 C23의 검사 산술을 쓴다. 예제의 PROVEN_CKD_MUL이 그것으로, 곱이 넘치면 참을 돌려준다(계산 결과가 아니라 넘침 여부가 반환값이다). 넘치면 할당을 아예 시도하지 않는다.
흔한 오해. “size_t는 64비트나 되니 넘칠 리 없다”
size_t는 여전히 32비트이고, 파일 형식에서 읽어 온 32비트 필드 둘을 곱하는 코드는 그 자리에서 감긴다. 무엇보다 중요한 것은 누가 그 수를 정하는가다. 크기가 프로그램 안의 상수라면 안심해도 좋지만, 파일이나 네트워크에서 온 수라면 그것은 공격자가 고른 피연산자다.실제 사례. 곱셈 하나가 만든 취약점들
이 무늬는 CVE 목록의 단골이다. 이미지 디코더가width * height * bytes_per_pixel을 32비트로 계산하다 감긴 사례, 폰트 파서가 글리프 개수를 곱하다 감긴 사례, 압축 해제 코드가 원본 크기를 곱하다 감긴 사례가 반복해서 보고됐다. 공통점은 모두 입력 파일이 크기를 정한다는 것 — 즉 공격자가 곱셈의 피연산자를 고를 수 있다는 것이다. 그래서 요즘 언어와 라이브러리는 크기 계산을 검사 산술로 하거나 아예 넘칠 수 없는 타입으로 다룬다.88.5 정렬 — 다음 경계로 밀어 올리기
6장에서 배운 정렬(alignment)이 여기서 실용적인 도구가 된다. proven_mem_align_up(13, 8)은 16을 돌려준다 — 13번지에서 시작하는 객체를 8바이트 경계에 맞추려면 16으로 밀어야 하기 때문이다. 다음 장의 아레나가 객체를 줄지어 놓을 때 매번 하는 계산이 바로 이것이다.
| 이름 | 무엇 | 메모 |
|---|---|---|
proven_mem_align_up(a, n) | a를 n의 배수로 올린다 | 넘치거나 n이 2의 거듭제곱이 아니면 0 |
proven_uintptr_align_up(p, n) | 주소를 올린다 | 포인터 산술 대신 정수로 |
proven_is_pow2(n) | 2의 거듭제곱인가 | 정렬 값 검사 |
PROVEN_MAX_ALIGN | alignof(max_align_t) | 보통 16. 어떤 타입이든 담는다 |
PROVEN_DEFAULT_ALIGNMENT | 8 | 문자열·버퍼 같은 바이트 자료의 기본 |
표 88.3
실패를 0으로 알린다는 점이 이 함수들의 특이한 계약이다. 정렬이 2의 거듭제곱이 아니거나 올림이 넘치면 0을 돌려주므로, 결과를 그대로 크기로 쓰기 전에 0인지 보아야 한다. 라이브러리 안에서는 아레나가 그 검사를 대신해 주므로, 이 함수를 직접 부를 일은 자기 자료구조를 만들 때뿐이다.
두 상수의 쓰임이 다르다는 것도 알아 둘 만하다. 바이트 배열이나 문자열은 PROVEN_DEFAULT_ALIGNMENT(8)면 충분하고, 어떤 타입이 올지 모르는 범용 할당에는 PROVEN_MAX_ALIGN을 쓴다. 89장의 힙 할당자가 이 둘을 갈라 쓰는 이유이기도 하다 — 기본 정렬 이하의 요청은 malloc으로(제자리 성장이 가능하다), 그보다 엄격한 요청은 정렬 할당으로 보낸다.
복습 정리
이 장의 어휘.
| 이름 | 무엇 | 계약 |
|---|---|---|
proven_byte_t | unsigned char 별칭 | 표현을 보는 유일한 합법 창 |
proven_mem_view_t | 읽기 전용 뷰(ptr+size) | 빌린 것 — 소유자보다 오래 살 수 없다 |
proven_mem_mut_t | 쓰기 가능 뷰 | 같음 |
..._slice_checked | 부분 뷰 만들기 | 범위를 넘으면 에러, 자르지 않음 |
PROVEN_CKD_MUL/ADD | 검사 산술 | 넘치면 참 — 계산은 버린다 |
proven_mem_align_up | 정렬 올림 | 정렬은 2의 거듭제곱 |
표 88.4
기억을 보는 어휘를 갖췄다. 다음은 기억을 얻는 이야기다 — 그리고 그 자리에서 이 라이브러리의 가장 특징적인 결정을 만나게 된다.