컴퓨터공학 300 주제 시리즈의 081번째 글이다. 전체 지도는 여기.

한 줄 요약

컴퓨터 안의 모든 값은 0 과 1 의 묶음이고, 같은 비트 묶음을 “몇 진법으로, 몇 바이트로, 어떤 순서로” 읽느냐가 정수 표현이다. 16진수는 그 비트를 사람이 읽기 좋게 4개씩 묶어 적는 표기일 뿐이다.

왜 필요한가

5부의 주제문은 “코드가 실제로 도는 기계를 알면 성능 문제의 절반이 보인다” 이다. 그 기계가 다루는 단위가 비트와 바이트다. 이걸 읽지 못하면 다음 장면에서 막힌다.

  • 패킷 덤프나 hexdump 에 찍힌 ea b0 80 이 무엇인지 모른다.
  • 파일 권한 0755, 색상 #FF8800, 메모리 주소 0x7ffd... 가 왜 그런 모양인지 모른다.
  • 네트워크로 보낸 정수 1000 이 상대편에서 엉뚱한 값으로 읽히는 이유(바이트 순서)를 설명하지 못한다.

진법 변환 자체는 초등 산수다. 중요한 것은 “같은 비트를 다르게 읽을 수 있다” 는 감각이다.

핵심 개념

자리값 표기

b 진법에서 숫자열 d(k-1) … d1 d0 의 값은 각 자리 숫자에 b 의 거듭제곱을 곱해 더한 것이다.

값 = d(k-1)·b^(k-1) + ... + d1·b^1 + d0·b^0

2진수 11001010 을 계산하면 다음과 같다.

비트:   1   1   0   0   1   0   1   0
자리: 128  64  32  16   8   4   2   1
합:   128 + 64 + 8 + 2 = 202

왜 16진수인가

16 = 2^4 이다. 그래서 2진수 4자리가 16진수 1자리에 정확히 대응한다. 변환에 곱셈이 필요 없고 표를 보며 끊어 읽으면 된다.

2진 16진 2진 16진
0000 0 1000 8
0001 1 1001 9
0010 2 1010 A
0011 3 1011 B
0100 4 1100 C
0101 5 1101 D
0110 6 1110 E
0111 7 1111 F

1100 1010 은 C A, 곧 0xCA 다. 한 바이트(8비트)는 항상 16진수 두 자리로 적힌다. hexdump 가 바이트마다 두 글자씩 찍는 이유다. 8진수는 3비트씩 묶는 표기이고, 유닉스 파일 권한(rwx 3비트 × 3)과 잘 맞아서 아직 쓰인다.

10진수를 b 진수로 바꾸기

b 로 나눈 나머지를 모으고 마지막에 뒤집는다.

2026 ÷ 16 = 126 나머지 10 (a)
 126 ÷ 16 =   7 나머지 14 (e)
   7 ÷ 16 =   0 나머지  7
→ 0x7ea

비트·바이트·워드

단위 크기 부호 없는 범위
비트 1 0~1
니블 4비트 0~15
바이트 8비트 0~255
16비트 2바이트 0~65,535
32비트 4바이트 0~4,294,967,295
64비트 8바이트 0~2^64−1

n 비트로는 2^n 개의 서로 다른 패턴을 만든다. 부호 없는 정수로 읽으면 0 부터 2^n − 1 까지다. 음수를 어떻게 넣는지는 다음 글(2의 보수)에서 다룬다. “워드” 는 CPU 가 한 번에 다루는 자연스러운 크기를 가리키는 말이라 아키텍처마다 뜻이 다르다는 점에 주의한다.

바이트 순서 (엔디언)

정수 1000 은 32비트로 0x000003E8 이다. 이 네 바이트를 메모리에 어떤 순서로 놓을지는 정해져 있지 않다.

주소        +0  +1  +2  +3
빅 엔디언    00  00  03  E8   (큰 자리부터)
리틀 엔디언  E8  03  00  00   (작은 자리부터)

x86-64 와 대부분의 ARM 리눅스 환경은 리틀 엔디언으로 동작한다. 반면 TCP/IP 헤더의 정수는 빅 엔디언으로 적는 것이 관례이고, 그래서 빅 엔디언을 “네트워크 바이트 순서” 라고 부른다. 같은 바이트를 서로 다른 순서로 해석하면 1000 이 3,892,510,720 (0xE8030000) 으로 읽힌다.

숫자가 아닌 것도 비트다

문자열도 결국 바이트열이다. UTF-8 에서 A 는 1바이트 0x41, 가 는 3바이트 EA B0 80 이다. 이 바이트를 텍스트로 옮겨 적을 때 쓰는 대표 방식이 16진수(Base16)와 Base64 이며, RFC 4648 에 정의돼 있다.

직접 해 보기

python3 로 실행해 확인한 코드다.

n = 202
print(bin(n), oct(n), hex(n))
print(int("11001010", 2), int("ca", 16), 0xCA, 0b1100_1010)
print(f"{n:08b} {n:02X} {n:#x}")

def to_base(n, b):
    digits = "0123456789abcdef"
    if n == 0:
        return "0"
    out = []
    while n > 0:
        n, r = divmod(n, b)
        out.append(digits[r])
    return "".join(reversed(out))

for b in (2, 8, 16):
    print(b, to_base(2026, b))

data = "가A".encode("utf-8")
print(data, data.hex(" "), len(data))
print((1000).to_bytes(4, "big").hex(" "), (1000).to_bytes(4, "little").hex(" "))
print(int.from_bytes(b"\x00\x00\x03\xe8", "big"))
print((255).bit_length(), (256).bit_length())

출력:

0b11001010 0o312 0xca
202 202 202 202
11001010 CA 0xca
2 11111101010
8 3752
16 7ea
b'\xea\xb0\x80A' ea b0 80 41 4
00 00 03 e8 e8 03 00 00
1000
8 9

to_bytes 의 두 번째 인자만 바꿔도 같은 1000 이 다른 바이트열이 된다. bit_length() 는 그 수를 표현하는 데 필요한 최소 비트 수다. 255 는 8비트, 256 은 9비트가 필요하다.

현업에서는

  • 덤프 읽기: xxd, hexdump -C, tcpdump -X 출력은 전부 바이트당 16진수 두 자리다. UTF-8 한글이 EA~ED 로 시작하는 3바이트 묶음으로 보이면 인코딩 문제를 바로 의심할 수 있다.
  • 비트 플래그: 리눅스 파일 권한 0644, 네트워크 서브넷 마스크 /24 = 0xFFFFFF00, 기능 플래그를 정수 하나에 담는 설계 모두 비트 단위 읽기를 전제로 한다.
  • 직렬화: 바이너리 프로토콜을 직접 짤 때 엔디언을 명시하지 않으면 서로 다른 언어·기계 사이에서 값이 깨진다. 파이썬 struct, 자바 ByteBuffer.order() 처럼 순서를 지정하는 API 를 쓴다.
  • 쿠버네티스 매니페스트: 볼륨의 defaultMode 같은 권한 값은 같은 0644 를 YAML 에선 8진수로 적을 수 있지만 JSON 에는 8진수 리터럴이 없어 10진수 420 으로 적어야 한다. 홈랩에서 시크릿 마운트 권한이 기대와 다르게 나오면 이 진법 차이부터 의심하는 것이 빠르다.

확인 문제

  1. 2진수 1011 0110 을 10진수와 16진수로 바꿔라.
  2. 16비트 부호 없는 정수의 최댓값을 10진수와 16진수로 적어라.
  3. 32비트 정수 0x12345678 을 리틀 엔디언으로 메모리에 놓으면 주소 +0 에 무엇이 오는가.
  4. 왜 8진수는 3비트, 16진수는 4비트 단위로 끊어 읽을 수 있는가.

풀이

  1. 128+32+16+4+2 = 182, 0xB6.
  2. 2^16 − 1 = 65,535 = 0xFFFF.
  3. 가장 낮은 자리 바이트 0x78. 순서는 78 56 34 12.
  4. 8 = 2^3, 16 = 2^4 이라 자릿수 하나가 정확히 3비트·4비트에 대응하기 때문이다.

더 읽을거리 (References)