Proven C BookEnglish GitHub

6 주소의 특수 지식 — 0번지, 정렬, 하위 비트

먼저 알아야 할 것

5장 워드와 주소 · 주소는 기억의 칸에 붙은 번호다

돌아보기

5장에서 “주소도 수라서 다른 사물함에 적어 둘 수 있다”고 했다. 그런데 적어 둔 주소가 아직 없음을 나타내고 싶으면 — “아무 데도 가리키지 않는다”는 표시는 어떻게 하는가?

답. 특별한 값 하나를 “없음”의 표시로 약속하면 된다. 그리고 거의 모든 시스템이 그 표시로 0을 골랐다 — 0번 사물함을 아무도 안 쓰는 칸으로 비워 두고, “0이 적혀 있으면 아무 데도 안 가리키는 것”으로 읽는 약속이다. 이 약속된 값이 널 포인터(null pointer)다. 그런데 왜 하필 0번 칸을 비워 둘 수 있었는가 — 그 이야기가 이 장의 첫 절이다.

이 장의 필요성과 맥락

5장이 그린 복도에는 구석이 있다. 0번지·정렬·하위 비트 — 셋 다 나중에 버그의 모습으로 처음 만나기 쉬운 것들이다. 널 포인터(36장), 정렬(47장), 태그 포인터(51장)가 각각 제 장을 갖지만, 그때는 이미 C 문법 안이라 「기계가 원래 그런 것」과 「C 가 그렇게 정한 것」을 가르기 어렵다. 그래서 기계 쪽 사정만 먼저 떼어 여기 둔다.

이 장이 끝나면

사물함 복도의 구석 지식 세 가지를 챙긴다. 0번 사물함은 왜 특별한지, 왜 큰 짐은 아무 번호에나 못 넣는지(정렬), 그리고 번호의 끝자리에 정보를 숨기는 재주(태그 포인터)까지. “널”이라는 이름의 세 가지 다른 물건도 여기서 처음 구별해 둔다.

이 장에서 답할 질문

  1. 0번지가 멀쩡한 메모리인 기계에서는, “없음” 표시와 “진짜 0번지”를 어떻게 구별하는가?
  2. 왜 기계는 아무 데서나 못 집는가? 사물함 비유로 무엇이 걸리는가?
  3. 프로그래머가 직접 태그 포인터 재주를 부려도 되는가?

6.1 0번 사물함에는 무엇이 있는가

답부터 말하면, 기계마다 다르다. 그리고 그 다름이 재미있다.

데스크톱과 서버, 스마트폰처럼 운영체제가 보호 모드로 도는 환경에서는, 운영체제가 0번지 근처를 일부러 접근 금지 구역으로 만들어 둔다. 어떤 프로그램이 0번지를 읽거나 쓰려 하면 그 즉시 붙잡혀 종료된다. 이것은 친절이다 — “없음” 표시(널)를 실수로 진짜 주소처럼 쓰는 사고는 아주 흔한데, 0번지가 금지 구역이라서 그런 실수가 조용히 지나가지 않고 그 자리에서 요란하게 잡힌다.

임베디드의 세계는 다르다. 운영체제 없이 도는 작은 칩에서는 0번지가 멀쩡한, 심지어 중요한 메모리인 경우가 많다 — 여러 마이크로컨트롤러가 0번지 부근에 인터럽트 벡터 테이블(비상 연락망 같은 표)을 둔다. 그런 기계에서 0번지 읽기는 합법이고 의미가 있다.

문. 0번지가 멀쩡한 메모리인 기계에서는, “없음” 표시와 “진짜 0번지”를 어떻게 구별하는가?

답. 구별이 흐려지는 것이 사실이고, 그래서 임베디드 프로그래머는 이 경계를 의식하며 일한다. 더 흥미로운 것은 역사의 답이다 — 아예 0이 아닌 값을 “없음” 표시로 쓴 기계들이 실제로 있었다. 다음 사례가 그 이야기다.

실제 사례. 널 포인터가 0이 아니었던 기계들

C 표준은 처음부터 신중했다. 소스 코드에 적은 상수 0이 널 포인터를 뜻한다는 것은 표준의 약속이지만, 그 널 포인터가 기계 안에서 실제로 어떤 비트 패턴으로 표현되는지는 기계의 자유로 남겨 두었다. 그리고 그 자유를 실제로 쓴 기계들이 있었다. Prime 50 시리즈는 세그먼트 07777, 오프셋 0이라는 값을 널로 썼고, CDC Cyber 180은 0xB00000000000이라는 특별한 패턴을, Lisp에 최적화된 Symbolics 머신은 “NIL 객체의 주소”라는 0 아닌 값을 널로 썼다. 이런 기계에서 널 포인터의 비트를 들여다보면 0이 하나도 아니다 — 그래도 소스 코드에서 포인터를 0과 비교하면 표준의 약속대로 참이 나온다. 소스의 0은 기호이고, 기계 속 표현은 구현이다 — 4장부터 이어 온 “기호와 표현의 분리”가 여기서도 반복된다.

오늘날의 주류 기계(인텔·AMD·ARM 등)는 전부 “모든 비트 0”을 널로 쓰므로 이 구별을 체감할 일은 드물다. 그러나 뒤에 볼 태그 포인터의 세계에서는 “없음을 나타내는 값이 전부 0은 아닌” 상황이 지금도 멀쩡히 살아 있다.

흔한 오해. “널 포인터는 0이다”

반은 맞고 반은 틀리다. 이 문장을 두 층으로 갈라 읽어야 한다.

소스 코드의 층(표기)기억 속의 층(표현)
p = 0; 이라고 적을 수 있다그렇게 담긴 뒤의 비트가 전부 0이라는 보장은 없다
p == 0 이 참인지 물을 수 있다비교는 컴파일러가 그 기계의 널 표현과 견주어 준다
표준이 약속한다기계가 정한다

표 6.1

소스에 적는 0은 “없음”이라는 뜻의 기호이고, 컴파일러가 그것을 그 기계의 진짜 널 표현으로 번역한다. 그래서 대입도 비교도 어디서나 옳다.

반대로 비트를 직접 0으로 채우는 일은 다른 층의 작업이다. memset으로 포인터를 0으로 밀거나 calloc이 준 기억을 그대로 널로 믿는 것은, “모든 비트 0”과 “널”이 같은 기계에서만 맞는 이야기다. 오늘날의 주류 기계에서는 실제로 같지만 계약은 아니다 — 36장에서 이 차이를 실물로 확인하고, 구조체를 비우는 두 방법이 왜 뜻이 다른지는 46장에서 본다.

6.2 널 삼형제 — 이름만 닮은 세 가지

“널”이라 불리는 것이 C 주변에 셋 있다. 지금 한 번 구별해 두면 나중에 큰 혼란을 던다. 정식 취급은 제7부(36장)에서 하고, 여기서는 얼굴만 익힌다.

포인터 값이다. 포인터의 세계에 산다.

셋 다 이름에 “널”이 들어가고 셋 다 어딘가에 0이 얽혀 있어서 뒤섞기 쉽지만, 포인터의 널과 문자의 NUL은 사는 세계가 다르다. “값이 우연히 다 0이라서 이름이 닮았을 뿐인 남남”이라 기억해 두면 된다.

6.3 정렬 — 두 칸짜리 짐은 아무 데나 못 넣는다

사물함 복도의 두 번째 구석 지식이다. 5장에서 기계는 여러 칸을 한 움큼에 집는다고 했다(워드). 그런데 기계의 손은 아무 위치에서나 움큼을 집도록 만들어져 있지 않다 — 대개 자기 크기의 배수 번호에서만 편하게 집는다. 4바이트짜리 수는 4의 배수 주소(100, 104, 108, …)에, 8바이트짜리는 8의 배수 주소에 놓여 있을 때 한 번에 집힌다. 이 규칙이 정렬(alignment)이다.

78563412
100101102103104105106107

그림 6.1

위처럼 100번(4의 배수)에서 시작하는 4바이트는 정렬이 맞다. 만약 같은 수를 102번에서 시작해 넣으면 — 정렬이 어긋난다. 그때 무슨 일이 일어나는지도 기계마다 다르다. 관대한 기계(인텔 계열)는 두 움큼으로 나눠 집느라 느려질 뿐 일은 해 준다. 엄격한 기계(옛 SPARC, 일부 ARM 시절)는 그 자리에서 오류(버스 폴트)를 내며 프로그램을 세웠다. “되는 기계에서만 되는 코드”라는 이식성 문제의 고전적 원천 중 하나다.

문. 왜 기계는 아무 데서나 못 집는가? 사물함 비유로 무엇이 걸리는가?

답. 기계의 손(버스)이 복도를 4칸짜리 격자로 보고 움직이기 때문이다. 100–103이 한 격자, 104–107이 다음 격자다. 102에서 시작하는 4바이트는 두 격자에 걸쳐 있어서, 한 번에 집으려면 격자 두 개를 모두 열고 필요한 조각을 이어 붙여야 한다 — 두 번 일하거나, 아예 거부하거나다. 두 칸짜리 짐은 짝수 번호부터 넣으라는 사물함 관리 규칙과 같다.

6.4 하위 비트의 재주 — 태그 포인터

정렬 규칙에는 뜻밖의 부산물이 있다. 4의 배수인 수를 2진법으로 적으면 끝의 두 비트가 항상 0이고, 8의 배수라면 끝의 세 비트가 항상 0이다. 즉, 정렬된 주소의 하위 비트들은 언제나 0이라서 — 정보를 담는 공짜 공간으로 쓸 수 있다.

이 재주를 태그 포인터(tagged pointer)라 부른다. 주소를 적어 두는 김에, 어차피 0인 끝자리에 작은 딱지(태그)를 끼워 넣는 것이다. 실제로 널리 쓰인다 — Lisp 계열 언어와 OCaml의 런타임은 “이 값은 정수인가, 객체 주소인가”를 하위 비트 태그로 구별하고, 자바스크립트 엔진과 가비지 컬렉터들도 같은 재주로 상태 표시를 포인터에 얹는다. 물론 공짜는 아니다 — 그 주소를 진짜로 쓰기 전에는 딱지를 떼야(하위 비트를 도로 0으로) 한다.

여기서 앞의 널 이야기와 다시 만난다. 태그가 얹힌 세계에서는 “없음”을 나타내는 특별한 값조차 태그를 품고 있어서 모든 비트가 0이 아닐 수 있다. 예컨대 Lisp의 “없음”(NIL)은 실재하는 특별 객체의 주소다. C의 널 포인터가 주류 기계에서 전부 0으로 통일된 오늘날에도, 한 층 위의 런타임 세계에서는 “0 아닌 없음”이 여전히 현역이라는 이야기다.

문. 프로그래머가 직접 태그 포인터 재주를 부려도 되는가?

답. C에서 기술적으로 가능하고 실제로 쓰이는 기법이지만, 함정이 많은 고급 기술이다 — 정렬 보장이 있어야 하고, 쓰기 전에 반드시 태그를 떼야 하고, 뒤에 배울 포인터 규칙(14장 프로버넌스, 37장)과도 얽힌다. 지금 단계의 결론은 하나면 된다: 주소의 하위 비트는 “그냥 수”가 아니라 정렬이 만들어 준 특별한 자리라는 것.

이 장의 세 가지 구석 지식 — 0번지의 특별함, 정렬, 하위 비트 — 은 모두 하나의 사실에서 나왔다. 주소는 수이지만, 아무 수나 똑같이 대접받는 것은 아니라는 것. 복도에도 지리(地理)가 있다.

다음 장부터는 사물함에 담기는 내용물 쪽으로 눈을 돌린다. 표현의 사다리 첫 단은 정수다 — 음수를 비트로 담는 세 가지 방법이 경쟁했던 사연과 C23의 결단, 넘치는 수(오버플로), 그리고 비트를 통째로 밀고 당기는 시프트까지.