38 포인터의 규칙 — 정렬과 프로버넌스
먼저 알아야 할 것
돌아보기
4장에서 “4바이트 짐은 4의 배수 번호에”라는 정렬 규칙을 배웠다. 그런데 포인터는 그냥 수를 담는 변수인데 — 아무 수나 담으면 왜 안 되는가?
답. 담는 것 자체는 막히지 않는 경우가 많다 — 사고는 따라갈 때 난다. int*로 역참조한다는 것은 “그 주소에서 4바이트를 int의 눈으로 읽겠다”는 뜻인데(36장), 그 주소가 int의 정렬(4의 배수)을 어기고 있으면 4장의 그 결과들 — 느려지거나, 기계에 따라 그 자리에서 버스 폴트 — 이 따라온다. 표준의 눈으로는 정렬이 맞지 않는 타입으로의 캐스트부터가 이미 계약 밖이다. 포인터의 타입은 눈이자 계약이다.
이 장의 필요성과 맥락
이 장이 끝나면
char*만의 특권), 15장에서 예고한 프로버넌스(출처 딱지)의 실무 감각까지. 뒤쪽 절은 이 책에서 드물게 아직 표준 본문에 없는 것을 다룬다 — 왜 그런 개념이 필요해졌고, 어디까지 정리됐고, 들어오면 무엇이 달라지며, 그것을 하드웨어로 강제하는 기계가 이미 있다는 것까지. 이 부의 안전 수칙 중 가장 “계약”다운 장이다.이 장에서 답할 질문
- 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?
- 다 정리했으면 그냥 표준에 넣으면 될 텐데, 왜 별책인가?
- 그러면 이 규칙은 결국 CHERI 같은 특수한 기계를 위한 준비인가?
38.1 정렬과 캐스트 — char*의 특권#
포인터 캐스트(타입 바꿔 보기)는 문법상 자유롭지만, 계약상으로는 좁다. 규칙을 실무 문장으로 줄이면 — 더 엄격한 정렬을 요구하는 타입 으로의 캐스트는 위험하다. char*(정렬 1)를 int*(정렬 4)로 바꿔 따라가는 것이 전형적 위반이다. 반대 방향은 안전하다 — 그리고 여기에 C의 의도된 특권이 있다: 문자 타입을 가리키는 포인터 (char*, signed char*, unsigned char*)는 어떤 객체든 바이트 단위로 들여다볼 수 있다. 정렬 1이라 어디든 가리킬 수 있고, 표준이 명시적으로 “모든 객체의 표현은 바이트의 눈으로 읽어도 된다”고 허락해 둔 통로다(14장의 엄격한 앨리어싱에도 이 예외가 뚫려 있다). 3장에서 “칸 안은 그저 비트”라 했던 그 관점의 공식 통로가 unsigned char*인 셈이다.
각 타입의 정렬 요구는 alignof로 물을 수 있다(C23 키워드):
examples/ch37/align.c
#include <stdio.h>
int main(void)
{
printf("alignof(char) = %zu\n", alignof(char));
printf("alignof(int) = %zu\n", alignof(int));
printf("alignof(double) = %zu\n", alignof(double));
return 0;
}
실행 결과
alignof(char) = 1
alignof(int) = 4
alignof(double) = 8
38.1.1 두 특권을 나란히 놓으면#
36장에서 예고한 두 통로를 여기서 정리한다. 둘은 하는 일이 다르다.
void * | char * 계열 | |
|---|---|---|
| 무엇을 잊는가 | 가리키는 타입을 잊는다 | 아무것도 잊지 않는다 — 표현을 그대로 본다 |
| 역참조 | 불가(크기를 모른다) | 가능 — 한 바이트씩 |
| 산술 | 불가(표준상) | 가능 — 바이트 단위 |
| 앨리어싱 | 해당 없음 | 어떤 객체의 표현도 읽을 수 있다(예외 통로) |
| 왕복 보장 | 객체 포인터 ↔ void * 는 원래 값으로 돌아온다 | 바이트로 읽는 것과 포인터를 되찾는 것은 다른 문제다 |
표 38.1 — void * 와 char * 계열의 특권 비교
memcpy의 원형이 void *를 받으면서 내부에서는 바이트로 복사하는 이유가 여기 있다 — 받을 때는 타입을 잊고, 다룰 때는 바이트로 본다. 표준이 void *와 문자 타입 포인터에 같은 표현을 요구한 것도 이 조합을 위해서다 (36장의 워드 주소 기계 이야기).
흔한 오해. “char *로 뜯어볼 수 있으니, 뜯어본 값으로 포인터를 만들어도 된다”
두 가지가 뒤섞인 생각이다. 읽는 것은 허락돼 있다 — 어떤 객체의 표현이든 unsigned char *로 바이트를 읽을 수 있다. 그러나 그렇게 얻은 바이트를 다시 포인터로 조립해 다른 객체를 가리키게 만드는 것은 별개 문제다. 그 포인터에 붙은 출처(프로버넌스)가 원래 객체의 것이기 때문이다 — 다음 절의 주제가 정확히 이것이다.
실무의 결론은 간단하다. 표현을 옮길 때는 memcpy로 값을 옮기고, 포인터가 필요하면 원래 객체에서 다시 얻는다. 바이트를 조립해 만든 주소는 x86-64 에서는 대개 동작하지만, CHERI(capability hardware enhanced RISC instructions) 처럼 포인터에 권한이 실린 기계에서는 그 자리에서 막힌다(36장).
38.2 프로버넌스 — 번호가 같아도 출처가 다르면#
15장의 예고를 실무 감각으로 회수한다. 순진한 그림에서 포인터는 번호일 뿐이니, 번호만 맞으면 어떻게 만들었든 따라가도 될 것 같다 — 그러나 현대 C의 계약은 그렇지 않다. 포인터에는 출처(프로버넌스)가 있다: 어떤 객체에서 유래했는가라는 보이지 않는 딱지다. 실무 규칙 세 줄로 줄인다.
- 포인터 산술은 태어난 객체 안에서만. 어떤 객체의 주소에서 출발한 포인터를 밀고 당겨(39장) 다른 객체 위에 올려놓는 것은 — 설령 번호가
실제 그 객체와 일치해도 — 계약 밖이다.
- 한 칸 지나서까지는 허용. 배열의 끝 “다음” 자리(one-past-the-end)를 가리키는 것은 합법이다(따라가지만 않으면). 루프의 끝 조건에 요긴해서 표준이 특별히 뚫어 둔 자리다(39장에서 실전).
- 수로 다루려면 공식 통로로. 포인터를 정수로 바꿔 저장·연산하는 일(4장의 태그 포인터 같은)은
uintptr_t라는 전용 타입을 경유하는 것이 계약이 마련한 길이다.
문. 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?
답. 많다 — 그리고 그것이 이 규칙들이 위험한 이유다. 위반의 대가를 걷는 것은 기계가 아니라 컴파일러의 최적화다(14장): “포인터는 출처 밖을 가리키지 않는다”는 전제로 재배열·캐싱을 하므로, 위반 코드는 최적화 수준이나 컴파일러 판이 바뀔 때 조용히 다른 동작이 된다. “지금 돌았다”는 계약 준수의 증거가 아니라는 것 — 15장의 “추상 기계 위에서 옳은가”가 기준이라는 것 — 이 이 장의 결론이고, 54장에서 이 주제의 전모를 본다.
38.2.1 표준에 아직 없는 낱말#
여기서 정직하게 밝힐 것이 있다. 프로버넌스는 C23 표준 본문의 용어가 아니다. C23을 처음부터 끝까지 뒤져도 그 낱말로 정의된 조항은 없다. 그런데도 이 책이 한 절을 들이는 이유는 셋이다.
- 컴파일러는 이미 이 개념 위에서 최적화하고 있다. 표준에 없다는 것이 “지켜도 그만 안 지켜도 그만”을 뜻하지 않는다.
- 표준 위원회는 이것을 명문화하는 작업을 별책 기술 명세로 마무리했다 (2025년 ISO/IEC TS 6010). 본문 편입이 남았을 뿐이다.
- 그 명세가 하는 일은 새 규칙을 얹는 것이 아니라, 이미 있던 규칙들의 빈틈을 메우고 서로 아귀가 맞게 다듬는 것이다.
세 번째가 이 절의 핵심이다. 앞으로 이 개념이 표준에 들어와도 새로 배울 문법은 없다. 지금 지켜야 하는 규칙이 그대로 남되, 왜 지켜야 하는지를 표준이 처음으로 한 낱말로 설명하게 된다.
38.2.2 빈틈은 어디에 있었나 — 이웃한 두 변수#
이 개념이 없으면 설명이 막히는 자리를 하나만 보자. 전역에 나란히 놓인 두 변수다.
int y = 2, x = 1;
int *p = &x + 1; /* x 의 "한 칸 지난" 자리 — 만드는 것은 합법 */
int *q = &y;
if (p == q) /* 참일 수 있다 */
*p = 11; /* 그러나 이 줄은? */물음이 둘로 갈린다. p == q가 참일 수 있는가 — 그렇다. 두 변수가 실제로 이웃해 놓이면 주소가 겹치고, 그때 비교 결과가 어느 쪽이 될지는 표준이 정해 두지 않았다(unspecified). 그러면 참인 그 안에서 *p로 y를 고쳐도 되는가 — 안 된다. p는 x에서 태어났고, 끝 다음 자리는 가리키는 것만 합법이지 따라가는 것은 아니다.
순진한 그림이 무너지는 자리가 여기다. “같다고 답한 두 포인터를 서로 바꿔 쓸 수 없다”는 말은 포인터 = 번호 그림에서는 자기모순이다. 그리고 실제 컴파일러가 그 모순 쪽에 서 있다 — 최적화를 켜면 GCC 도 Clang 도 “p와 q는 다른 객체에서 왔으니 겹칠 리 없다”는 전제로 코드를 옮기고 지운다. 그래서 위 코드는 컴파일러와 최적화 수준에 따라 y가 11이 되기도 하고 그대로이기도 하다. 14장의 엄격한 앨리어싱과는 별개라는 점도 중요하다 — -fno-strict-aliasing을 켜도 이 전제는 사라지지 않는다.
표준 본문은 이 상황에 대해 침묵했다. 그 결과 세 당사자의 말이 어긋난 채로 20년이 흘렀다. 표준의 문언은 “포인터는 값”으로 읽히고, 컴파일러는 “출처가 다르면 다른 것”으로 최적화하고, 프로그래머는 “내 기계에서는 돌던데”라고 말한다. 심판이 없으니 컴파일러가 버그를 낸 것인지 프로그램이 계약을 어긴 것인지도 판정할 수 없다.
38.2.3 낱말이 태어난 자리 — DR260#
이 낱말이 공식 문서에 처음 나온 것은 2004년, 결함 보고(Defect Report) 260에 대한 위원회 답변에서다. 요지는 두 문장으로 줄어든다.
“ 구현은 비트 패턴의 유래(origin)를 추적해도 된다. 유래가 다른 포인터는 비트가 같아도 서로 다른 것으로 취급해도 된다. ”
허가는 났다. 그러나 그 허가는 표준 본문에 반영되지 않았다. 정의도 없었고, 어디까지가 허용인지도 없었다. 그 상태로 컴파일러들은 이 답변을 근거 삼아 출처 기반 앨리어스 분석을 밀고 나갔다.
정의 없는 개념이 최적화의 근거가 되면 무슨 일이 생기는가 — 그것이 이 이야기의 교훈이다. 어떤 관용구가 합법인지 아무도 단정할 수 없게 된다. 오래된 저수준 코드의 절반이 회색지대에 잠긴다.
38.2.4 명문화 — 설문 조사에서 기술 명세까지#
명문화 작업이 특이한 방식으로 시작됐다는 점을 짚어 둘 만하다. 먼저 한 일은 조문을 쓰는 것이 아니라 묻는 것이었다. 케임브리지의 연구자들이 2013년에 포인터와 객체 표현에 관한 마흔두 문항짜리 설문을 돌려 실무 C 프로그래머와 구현자가 실제로 무엇을 합법이라 여기는지를 모았고, 그 분석이 위원회 문서로 올라갔다.1 그다음에야 쟁점을 스무 문항(Q1~Q20)으로 추려 위원회에 「이건 합법이어야 하는가」를 물었다.2 표준을 고치기 전에 현실이 무엇을 하고 있는지부터 재는 순서다.
그 위에 모델이 올라갔다. 이름은 PNVI-ae-udi 인데, 이름을 욀 필요는 없고 실무적으로는 세 줄이다.
- 객체(할당)마다 보이지 않는 출처 딱지가 하나 붙는다. 포인터 값은 「출처 + 주소」의 쌍이다.
- 포인터를 정수로 바꾸면 그 객체의 주소가 노출된다(address exposed).
- 정수를 포인터로 되돌릴 때는, 노출된 적 있는 객체 가운데 그 주소를 품은 것을 찾아 출처를 되살린다. 노출된 적이 없으면 되살릴 출처가 없다 — 그렇게 만든 포인터는 따라갈 수 없다.
한 줄로 줄이면 이렇다. 주소를 정수로 내보낸 적 있는 객체만, 그 정수로 되찾을 수 있다. uintptr_t 왕복이 왜 공식 통로이고, 바이트를 주물러 조립한 주소가 왜 통로가 아닌지가 이 한 줄에서 함께 나온다 — 두 규칙이 따로 있는 것이 아니라 하나의 모델에서 파생된다는 것, 이것이 “다듬는다”는 말의 뜻이다.
이 작업의 결과가 ISO/IEC TS 6010(2025) — 「C를 위한 출처 인지 기억 객체 모델」이다.3 표준 본문이 아니라 별책 기술 명세(Technical Specification)로 나왔고, 스스로를 이렇게 규정한다 — 완전한 언어 명세가 아니라, ISO/IEC 9899:2018 에 암묵적으로 들어 있던 기억 객체 모델을 조이고 명확히 하는 문서. 적합한 구현은 「그 차이가 9899 에 이미 통합된 것처럼」 행동해야 한다.
경계도 스스로 밝혀 둔다 — 이 명세는 부분객체(subobject)의 출처는 다루지 않는다. 구조체 멤버를 가리키는 포인터가 어디까지 걸어도 되는지 같은 물음은 아직 열려 있다는 뜻이다.
문. 다 정리했으면 그냥 표준에 넣으면 될 텐데, 왜 별책인가?
답. 구현 경험 없이 본문을 고치는 것은 위험하다는 판단 때문이다. 명세 자신이 그 사정을 적어 두었다 — C 위원회(WG14)와 C++ 쪽의 소위원회가 모두 전체 방향을 승인했으되 「구현 경험을 조건으로」 했다는 것이다. 같은 시기에 Clang/LLVM 과 GCC 쪽 개발자들과도 EuroLLVM·GNU Tools Cauldron 에서 논의가 오갔다.
별책으로 먼저 내면 컴파일러 쪽이 실제로 구현해 보고, 실무 코드가 얼마나 깨지는지 재 볼 시간이 생긴다. 표준화의 속도가 왜 이렇게 느린가에 대한 1장의 답이 여기 그대로 있다 — 한번 못 박으면 30년을 가기 때문이다.
번호와 이름은 외울 것이 아니다. 남길 것은 하나다 — 포인터의 출처 개념은 누군가의 취향이 아니라, 표준화 절차를 밟아 문서로 굳어진 것이며, 다음 판의 본문 후보다.
38.2.5 들어오면 무엇이 달라지는가#
가장 중요한 답부터 말하면 — 대부분의 코드에는 아무 일도 일어나지 않는다. 새 키워드도, 새 문법도, 다시 컴파일해야 할 이유도 없다. 달라지는 것은 “회색”의 개수다. 지금 회색지대에 잠겨 있는 관용구들이 흑과 백으로 갈린다.
| 관용구 | 지금(본문 기준) | 출처 모델이 들어오면 |
|---|---|---|
uintptr_t 왕복 | 보장은 있으나 근거가 흐리다 | 합법 — 정수로 바꾼 그 순간이 곧 “노출” |
포인터 표현을 memcpy 로 복사 | 회색 | 합법 — 출처까지 함께 옮겨진다 |
| 태그 포인터(4장) | 회색 | 왕복 통로를 지키면 합법 |
| XOR 연결 리스트 | 회색 | 불법 — 두 출처를 섞은 값에는 출처가 없다 |
| 객체 밖으로 민 포인터 | 불법(배열 조항으로) | 불법 — 이유가 “출처”로 통일된다 |
| 바이트·입출력으로 조립한 주소 | 회색 | 불법 — 노출된 적 없는 출처는 되살릴 수 없다 |
표 38.2 — 출처 모델이 들어오면 갈리는 관용구
표에서 읽어야 할 것은 개별 판정이 아니라 판정이 가능해졌다는 사실이다. 지금은 저 칸들에 “아마도”라고 쓸 수밖에 없다. 그 밖에 달라지는 것들을 꼽으면 이렇다.
- 컴파일러가 근거를 얻는다. 지금의 출처 기반 최적화는 20년 된 결함 답변 하나에 기대고 있다. 모델이 본문에 들어오면 어떤 재배열이 합법인지 기계적으로 판정할 수 있다 — 컴파일러의 버그와 프로그램의 위반을 가르는 심판이 생긴다는 뜻이다.
- 도구가 정확해진다. 새니타이저(18장)는 지금 “잘못된 주소”를 잡지만 “출처가 다른 주소”는 잘 잡지 못한다. 규칙이 명문화되면 진단 메시지가 “여기서 출처를 잃었다”까지 말할 수 있고, 형식 검증 도구는 C 프로그램의 옳음을 증명할 근거를 얻는다.
- 교육과 문서가 달라진다. “그건 UB(undefined behaviour) 다”로 끝나던 설명이 “그 포인터는 이 객체에서 태어나지 않았다”로 바뀐다. 이 책이 지금 하고 있는 설명이 바로 그 형태다.
- C 혼자의 사정이 아니다. 같은 문제를 Rust 는 라이브러리 수준에서 먼저 정리했다 — 주소만 갈아 끼우는
with_addr, 노출을 명시하는expose_provenance, 그것을 되받는with_exposed_provenance같은 API 가 그것이고, 2025년 1월의 1.84 판에서 안정화됐다.4 LLVM 의 중간 표현에서도 같은 논의가 진행 중이다. 여러 언어가 같은 결론으로 수렴하고 있다는 것 자체가, 이것이 C 의 궤변이 아니라 컴파일러라는 기계 장치의 본성 임을 말해 준다.
흔한 오해. “아직 표준에 없는 개념이니, 당장은 신경 쓰지 않아도 된다”
38.2.6 출처를 하드웨어가 들고 다니는 기계#
이 개념이 종이 위의 이야기만은 아니다. 포인터에 출처를 실어 하드웨어가 강제하는 기계가 이미 있다.
실제 사례. 능력 기반 아키텍처
CHERI. 36장에서 크기 이야기로 만난 그 구조다. 여기서는 출처의 관점에서 다시 본다. CHERI 의 포인터는 「주소 + 범위 + 권한 + 유효 태그 비트 하나」다. 결정적인 것은 마지막 항목이다 — 태그는 데이터와 같은 자리에 있지 않고 대역 밖(out-of-band)에 따로 저장되며, 보통의 명령으로는 세울 수 없다. 정수 연산으로 주소를 주물러 그 자리에 써 넣으면 하드웨어가 태그를 지운다. 태그를 잃은 값은 더 이상 포인터가 아니라 그냥 수이고, 따라가려는 순간 그 자리에서 막힌다.
Arm Morello. 2022년 초에 출하된 실물 시험 보드다.5 태그 비트를 기억 장치까지 나르도록 확장했다 — 즉 이 개념을 지원하려면 CPU 코어만이 아니라 기계 전체를 손봐야 한다.
CHERIoT. 같은 발상을 아주 작은 쪽으로 밀어 붙인 갈래다. 사물인터넷용 32비트 RISC-V 를 겨냥하고, 마이크로소프트가 설계한 명령 집합 확장으로 시작해 실물 보드까지 나와 있다. 서버에서만 되는 이야기가 아니라는 증거다.
새 발상은 아니다. 태그 붙은 기계는 오히려 오래된 계보다. 36장에서 본 IBM System/38·AS/400 의 128비트 태그 포인터, 그리고 그보다 앞선 버로스 B5000 계열의 태그 워드가 같은 생각을 하드웨어로 구현했다. C 가 태어난 기계 계열이 태그 없는 쪽이었을 뿐이다.
플랫폼 노트. 헷갈리기 쉬운 이웃
정리하면 심판이 둘이다. x86-64 에서 출처를 어긴 코드의 대가를 걷는 것은 컴파일러이고(그래서 조용하고 뒤늦다), 능력 기반 기계에서는 하드웨어가 그 자리에서 걷는다(그래서 시끄럽고 즉시다). 어느 쪽이든 걷는다는 것이 요점이다.
문. 그러면 이 규칙은 결국 CHERI 같은 특수한 기계를 위한 준비인가?
답. 아니다. 순서가 반대다. 출처 규칙은 CHERI 때문에 생긴 것이 아니라, 평범한 x86-64 위의 최적화 컴파일러가 이미 그 전제로 돌고 있기 때문에 필요해졌다. CHERI 는 그 규칙을 하드웨어로 옮겨 놓았을 뿐이다. 그래서 이 규칙을 지킨 코드는 덤을 하나 받는다 — 새 기계로 옮길 때 손댈 것이 거의 없다는 덤이다. 반대로 “x86-64 에서 잘 돌더라”에 기대어 쓴 코드는 최적화 수준이 바뀔 때 한 번, 기계가 바뀔 때 또 한 번 무너진다.
포인터의 규칙까지 갖췄다. 이제 이 도구들을 갖고 연속된 기억 — 배열 — 으로 간다. 26장부터 미뤄 온 line[100]의 외상이 다음 장에서 청산된다.
주
- WG14 N2013 「C memory object and value semantics: the space of de facto and ISO standards」와 N2014 「What is C in practice? (Cerberus survey v2): Analysis of Responses」, 2016. ↩
- WG14 N2219·N2263 「Clarifying Pointer Provenance (Q1-Q20)」, Memarian·Gomes·Sewell, 2018. ↩
- ISO/IEC TS 6010:2025, Programming languages — C — A provenance-aware memory object model for C. 2025년 5월 발행. 작업 초안은 WG14 N3231 로 공개돼 있다. ↩
- Rust 1.84.0 release notes, 2025-01-09. 「strict provenance」와 「exposed provenance」 API 묶음. ↩
- Arm Morello — CHERI 를 얹은 ARMv8-A 시험용 SoC 와 개발 보드. 2022년 1월 출하. 케임브리지 대학 CTSRD/CHERI 프로젝트와 Arm 의 공동 작업이다. ↩