37 포인터의 규칙 — 정렬과 프로버넌스
먼저 알아야 할 것
돌아보기
6장에서 “4바이트 짐은 4의 배수 번호에”라는 정렬 규칙을 배웠다. 그런데 포인터는 그냥 수를 담는 변수인데 — 아무 수나 담으면 왜 안 되는가?
답. 담는 것 자체는 막히지 않는 경우가 많다 — 사고는 따라갈 때 난다. int*로 역참조한다는 것은 “그 주소에서 4바이트를 int의 눈으로 읽겠다”는 뜻인데(35장), 그 주소가 int의 정렬(4의 배수)을 어기고 있으면 6장의 그 결과들 — 느려지거나, 기계에 따라 그 자리에서 버스 폴트 — 이 따라온다. 표준의 눈으로는 정렬이 맞지 않는 타입으로의 캐스트부터가 이미 계약 밖이다. 포인터의 타입은 눈이자 계약이다.
이 장의 필요성과 맥락
이 장이 끝나면
char*만의 특권), 14장에서 예고한 프로버넌스(출처 딱지)의 실무 감각까지. 짧지만, 이 부의 안전 수칙 중 가장 “계약”다운 장이다.이 장에서 답할 질문
- 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?
37.1 정렬과 캐스트 — char*의 특권
포인터 캐스트(타입 바꿔 보기)는 문법상 자유롭지만, 계약상으로는 좁다. 규칙을 실무 문장으로 줄이면 — 더 엄격한 정렬을 요구하는 타입 으로의 캐스트는 위험하다. char*(정렬 1)를 int*(정렬 4)로 바꿔 따라가는 것이 전형적 위반이다. 반대 방향은 안전하다 — 그리고 여기에 C의 의도된 특권이 있다: 문자 타입을 가리키는 포인터 (char*, signed char*, unsigned char*)는 어떤 객체든 바이트 단위로 들여다볼 수 있다. 정렬 1이라 어디든 가리킬 수 있고, 표준이 명시적으로 “모든 객체의 표현은 바이트의 눈으로 읽어도 된다”고 허락해 둔 통로다(13장의 엄격한 앨리어싱에도 이 예외가 뚫려 있다). 5장에서 “칸 안은 그저 비트”라 했던 그 관점의 공식 통로가 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
37.1.1 두 특권을 나란히 놓으면
35장에서 예고한 두 통로를 여기서 정리한다. 둘은 하는 일이 다르다.
void * | char * 계열 | |
|---|---|---|
| 무엇을 잊는가 | 가리키는 타입을 잊는다 | 아무것도 잊지 않는다 — 표현을 그대로 본다 |
| 역참조 | 불가(크기를 모른다) | 가능 — 한 바이트씩 |
| 산술 | 불가(표준상) | 가능 — 바이트 단위 |
| 앨리어싱 | 해당 없음 | 어떤 객체의 표현도 읽을 수 있다(예외 통로) |
| 왕복 보장 | 객체 포인터 ↔ void * 는 원래 값으로 돌아온다 | 바이트로 읽는 것과 포인터를 되찾는 것은 다른 문제다 |
표 37.1
memcpy의 원형이 void *를 받으면서 내부에서는 바이트로 복사하는 이유가 여기 있다 — 받을 때는 타입을 잊고, 다룰 때는 바이트로 본다. 표준이 void *와 문자 타입 포인터에 같은 표현을 요구한 것도 이 조합을 위해서다 (35장의 워드 주소 기계 이야기).
흔한 오해. “char *로 뜯어볼 수 있으니, 뜯어본 값으로 포인터를 만들어도 된다”
두 가지가 뒤섞인 생각이다. 읽는 것은 허락돼 있다 — 어떤 객체의 표현이든 unsigned char *로 바이트를 읽을 수 있다. 그러나 그렇게 얻은 바이트를 다시 포인터로 조립해 다른 객체를 가리키게 만드는 것은 별개 문제다. 그 포인터에 붙은 출처(프로버넌스)가 원래 객체의 것이기 때문이다 — 다음 절의 주제가 정확히 이것이다.
실무의 결론은 간단하다. 표현을 옮길 때는 memcpy로 값을 옮기고, 포인터가 필요하면 원래 객체에서 다시 얻는다. 바이트를 조립해 만든 주소는 x86-64 에서는 대개 동작하지만, CHERI 처럼 포인터에 권한이 실린 기계에서는 그 자리에서 막힌다(35장).
37.2 프로버넌스 — 번호가 같아도 출처가 다르면
14장의 예고를 실무 감각으로 회수한다. 순진한 그림에서 포인터는 번호일 뿐이니, 번호만 맞으면 어떻게 만들었든 따라가도 될 것 같다 — 그러나 현대 C의 계약은 그렇지 않다. 포인터에는 출처(프로버넌스)가 있다: 어떤 객체에서 유래했는가라는 보이지 않는 딱지다. 실무 규칙 세 줄로 줄인다.
- 포인터 산술은 태어난 객체 안에서만. 어떤 객체의 주소에서 출발한 포인터를 밀고 당겨(38장) 다른 객체 위에 올려놓는 것은 — 설령 번호가
실제 그 객체와 일치해도 — 계약 밖이다.
- 한 칸 지나서까지는 허용. 배열의 끝 “다음” 자리(one-past-the-end)를 가리키는 것은 합법이다(따라가지만 않으면). 루프의 끝 조건에 요긴해서 표준이 특별히 뚫어 둔 자리다(38장에서 실전).
- 수로 다루려면 공식 통로로. 포인터를 정수로 바꿔 저장·연산하는 일(6장의 태그 포인터 같은)은
uintptr_t라는 전용 타입을 경유하는 것이 계약이 마련한 길이다.
문. 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?
답. 많다 — 그리고 그것이 이 규칙들이 위험한 이유다. 위반의 대가를 걷는 것은 기계가 아니라 컴파일러의 최적화다(13장): “포인터는 출처 밖을 가리키지 않는다”는 전제로 재배열·캐싱을 하므로, 위반 코드는 최적화 수준이나 컴파일러 판이 바뀔 때 조용히 다른 동작이 된다. “지금 돌았다”는 계약 준수의 증거가 아니라는 것 — 14장의 “추상 기계 위에서 옳은가”가 기준이라는 것 — 이 이 장의 결론이고, 52장에서 이 주제의 전모를 본다.
포인터의 규칙까지 갖췄다. 이제 이 도구들을 갖고 연속된 기억 — 배열 — 으로 간다. 25장부터 미뤄 온 line[100]의 외상이 다음 장에서 청산된다.