엔디안(바이트 순서)과 인터넷 체크섬 — LSB·MSB와 htons를 직접 만들어보기

NOTE

정수를 메모리에 저장하는 바이트 순서(엔디안)가 왜 CPU마다 다른지, 이를 통일하는 htons/htonl 계열 함수의 원리, 그리고 IP/ICMP/TCP/UDP가 공통으로 쓰는 16비트 인터넷 체크섬 알고리즘을 정리한다.

0. 왜 필요한가

여러 기종의 컴퓨터가 네트워크로 정수를 주고받으려면, 그 정수를 이루는 바이트들을 어떤 순서로 읽고 쓸지를 미리 합의해야 한다. CPU 아키텍처마다 이 순서(엔디안)가 다르기 때문에, 합의 없이 그냥 메모리를 그대로 복사해서 보내면 받는 쪽이 완전히 다른 값으로 해석하게 된다.

1. LSB/MSB, 리틀엔디안/빅엔디안

unsigned int value = 0x12345678;
unsigned char *bytes = (unsigned char *)&value;
방식메모리에 저장되는 순서(낮은 주소 → 높은 주소)대표 사례
리틀엔디안 (little-endian)bytes[0]=0x78(LSB, 최하위 바이트가 먼저) bytes[1]=0x56 bytes[2]=0x34 bytes[3]=0x12(MSB)대부분의 x86/x64 CPU
빅엔디안 (big-endian)bytes[0]=0x12(MSB가 먼저) bytes[1]=0x34 bytes[2]=0x56 bytes[3]=0x78(LSB)네트워크 바이트 오더, 일부 임베디드/네트워크 장비

LSB(Least Significant Byte, 값의 크기에 가장 적게 기여하는 바이트)가 낮은 메모리 주소에 먼저 오면 리틀엔디안, MSB(Most Significant Byte, 값에 가장 크게 기여하는 바이트)가 먼저 오면 빅엔디안이다. 네트워크 프로토콜(IP, TCP, UDP 헤더 등)은 관례적으로 빅엔디안을 표준으로 정해뒀고, 이걸 “네트워크 바이트 오더”라고 부른다.

2. 표준 변환 함수 — htons/htonl/ntohs/ntohl

#include <arpa/inet.h>
 
uint16_t net_val16  = htons(host_val16);   /* host -> network(빅엔디안), 16비트 */
uint32_t net_val32  = htonl(host_val32);   /* host -> network, 32비트 */
uint16_t host_val16 = ntohs(net_val16);    /* network -> host, 16비트 */
uint32_t host_val32 = ntohl(net_val32);    /* network -> host, 32비트 */

이름의 규칙은 h(host) to(변환) n(network)/n to h이고, 뒤의 s/l은 각각 short(16비트)/long(32비트)을 뜻한다. 이 함수들은 실행 중인 CPU가 이미 빅엔디안이면 아무 것도 하지 않고 값을 그대로 반환하도록 내부적으로 구현돼 있어서, 어떤 아키텍처에서 컴파일하든 항상 옳은 결과를 보장한다.

3. 직접 구현해보기 — 시프트로 만드는 나만의 htons/htonl

uint16_t my_htons(uint16_t host16)
{
    return (uint16_t)(((host16 & 0xFF) << 8) | ((host16 >> 8) & 0xFF));
    /* 하위 바이트를 상위 자리로, 상위 바이트를 하위 자리로 — 두 바이트의 위치를 맞바꾼다 */
}
 
uint32_t my_htonl(uint32_t host32)
{
    return ((host32 & 0x000000FFUL) << 24)
         | ((host32 & 0x0000FF00UL) << 8)
         | ((host32 & 0x00FF0000UL) >> 8)
         | ((host32 & 0xFF000000UL) >> 24);
}

각 바이트를 마스크로 뽑아낸 뒤, 원래 있던 자리와 반대쪽 자리로 시프트해서 옮기는 방식이다. 다만 이 구현은 항상 무조건 바이트 순서를 뒤집는다 — 이미 빅엔디안인 시스템에서 이 함수를 쓰면 오히려 순서가 다시 뒤집혀 틀린 값이 나온다. 그래서 실무 코드에서는 이런 자체 구현 대신 표준 라이브러리의 htons/htonl을 쓰는 게 맞고, 위 코드는 어디까지나 “그 함수들이 내부적으로 뭘 하는지” 원리를 이해하기 위한 것이다.

4. 실전 예 — 16비트 인터넷 체크섬(1의 보수 합)

unsigned short internet_checksum(const void *data, int len)
{
    const unsigned short *p = data;
    unsigned long sum = 0;
 
    while (len > 1) {
        sum += *p++;
        len -= 2;
    }
    if (len == 1) {
        sum += *(const unsigned char *)p;   /* 홀수 바이트가 남으면 마지막 한 바이트만 더한다 */
    }
 
    sum = (sum >> 16) + (sum & 0xFFFF);   /* 32비트로 넘친 윗부분을 아랫부분에 다시 더한다(fold) */
    sum += (sum >> 16);                    /* 한 번 더 접으면서 생긴 캐리까지 반영 */
 
    return (unsigned short)(~sum);          /* 1의 보수를 취해서 최종 체크섬 완성 */
}

IP/ICMP/TCP/UDP 헤더가 공통으로 쓰는 표준 체크섬 알고리즘(RFC 1071)이다. 데이터를 16비트씩 계속 더하다가, 자리올림(carry)으로 32비트를 넘어선 부분이 생기면 상위 16비트와 하위 16비트를 다시 더해서(fold) 16비트 안에 욱여넣고, 마지막으로 결과를 통째로 뒤집는(1의 보수, ~) 방식이다. 이 체크섬을 계산하기 전에는 반드시 대상 데이터의 각 필드가 네트워크 바이트 오더로 정렬돼 있어야 한다 — 그렇지 않으면 송신측과 수신측이 같은 데이터를 두고도 서로 다른 체크섬을 계산하게 된다.

5. 자주 하는 실수

  • 시스템 엔디안을 확인 안 하고 직접 구현한 뒤집기 함수를 무조건 적용하기 — 이미 빅엔디안인 시스템에서 3번의 my_htons를 그대로 쓰면 오히려 순서가 다시 뒤집혀 틀린다. 실무 코드에서는 이식성을 위해 항상 자체 구현 대신 표준 함수(htons 등)를 쓴다.
  • 체크섬 계산 시 데이터 길이가 홀수일 때 마지막 바이트 처리를 빼먹기 — 4번 예제처럼 남은 1바이트를 따로 처리하지 않으면 그 바이트가 계산에서 통째로 빠지거나, 짝을 맞추려고 무리하게 2바이트씩 읽다가 범위 밖 메모리를 읽게 된다.
  • 구조체를 통째로 캐스팅/memcpy해서 보내면서 필드별 엔디안 변환을 빼먹기 — 구조체 전체를 그대로 복사해서 네트워크로 보내면, 멀티바이트 필드들은 호스트의 엔디안 그대로 실려 나간다. 2바이트 이상인 필드는 각각 htons/htonl로 변환한 뒤에 실어 보내야 한다(관련: 실무 C 코드 관례와 UB 함정 정리의 B12).
  • 체크섬 필드 자기 자신을 계산 범위에 넣었다 뺐다 헷갈리기 — 체크섬을 계산할 때는 보통 체크섬 필드 자리를 0으로 채워둔 채로 전체 데이터를 계산하고, 그 결과를 나중에 그 자리에 채워 넣는다. 이미 값이 들어있는 체크섬 필드까지 포함해서 계산해버리면, 검증하는 쪽이 다시 계산했을 때 값이 어긋난다.

관련 문서