여러 바이트로 이루어진 값을 메모리에 어떤 순서로 저장하는지에 대한 규칙.
두 방식
0x12345678 (4바이트)을 주소 0x1000부터 저장하면
0x1000 0x1001 0x1002 0x1003
리틀 엔디안 78 56 34 12 ← 낮은 자리가 낮은 주소
빅 엔디안 12 34 56 78 ← 높은 자리가 낮은 주소
빅 엔디안이 사람이 읽는 순서와 같다. 리틀 엔디안은 거꾸로 보인다.
이름의 유래
걸리버 여행기에서 달걀을 큰 쪽(big end) 부터 깨느냐 작은 쪽(little end) 부터 깨느냐로 다투는 이야기에서 왔다. "어느 쪽이든 상관없는데 다툰다"는 풍자가 담겨 있다 — 실제로 기술적 우열은 거의 없다.
어디가 어느 쪽인가
| 리틀 엔디안 | 빅 엔디안 |
|---|---|
| x86 / x64 | 네트워크 바이트 순서 |
| ARM (기본) | 옛 PowerPC, SPARC, m68k |
| RISC-V | TCP/IP 헤더, 대부분의 프로토콜 |
ARM은 바이엔디안(bi-endian) 이라 설정으로 바꿀 수 있지만 현실적으로 거의 모두 리틀 엔디안으로 동작한다.
리틀 엔디안의 실질적 장점
uint32_t x = 0x12345678;
uint8_t b = *(uint8_t *)&x; // 리틀: 0x78 (하위 바이트)
타입을 좁혀 캐스팅할 때 주소가 그대로다. 32비트 값의 하위 8비트를 읽으려면 같은 주소에서 1바이트만 읽으면 된다. 빅 엔디안은 주소를 조정해야 한다.
빅 엔디안은 덤프를 눈으로 읽기 쉽다는 장점이 있다.
확인 방법
int is_little(void) {
uint16_t x = 1;
return *(uint8_t *)&x == 1; // 리틀이면 하위 바이트(1)가 먼저
}
// 컴파일 시 판별 (GCC)
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
문제가 되는 곳
① 네트워크 통신
// 네트워크 바이트 순서 = 빅 엔디안 (RFC 1700)
uint16_t port = htons(8080); // host to network short
uint16_t p = ntohs(recv_port); // network to host short
리틀 엔디안 기기에서 htons는 바이트를 뒤집고, 빅 엔디안 기기에서는
아무것도 하지 않는다. 양쪽 모두 같은 코드를 쓸 수 있다는 것이 요점이다.
② 서로 다른 MCU 간 통신
struct Packet { uint32_t id; uint16_t value; };
send(&pkt, sizeof(pkt)); // ✗ 상대가 다른 엔디안이면 값이 뒤집힌다
③ 파일·플래시 저장 저장한 기기와 읽는 기기가 다르면 깨진다. 부트로더가 펌웨어 헤더를 읽을 때 엔디안이 어긋나 크기를 잘못 읽는 사고가 있다.
④ 하드웨어 레지스터 일부 주변장치(이더넷 MAC, SD 카드 등)는 내부적으로 빅 엔디안 데이터를 다룬다. 데이터시트를 확인해야 한다.
안전한 해결 — 바이트 단위 직렬화
// 빅 엔디안으로 명시적 인코딩
void put_u32_be(uint8_t *b, uint32_t v) {
b[0] = (uint8_t)(v >> 24);
b[1] = (uint8_t)(v >> 16);
b[2] = (uint8_t)(v >> 8);
b[3] = (uint8_t)(v );
}
uint32_t get_u32_be(const uint8_t *b) {
return ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16)
| ((uint32_t)b[2] << 8) | (uint32_t)b[3];
}
시프트 연산은 값에 대한 것이지 메모리 배치에 대한 것이 아니다.
v >> 24는 어느 엔디안에서든 최상위 바이트를 준다.
그래서 이 코드는 어떤 플랫폼에서 컴파일해도 같은 바이트열을 만든다.
이것이 엔디안 문제의 정석 해법이다 — 구조체를 그대로 보내지 말고 바이트열로 조립한다.
비트 엔디안은 또 다른 문제
바이트 안에서 비트 번호를 어느 쪽부터 세는지도 규약마다 다르다. CAN·이더넷 프레임 명세를 읽을 때 헷갈리는 지점이다. 비트 필드 구조체를 쓰면 안 되는 이유이기도 하다.