엔디안 방식(endianness)이라는 개념(엔디안 방식 참조)을 이해하려면 추상적인 관점에서 메모리에 익숙해야 한다. 메모리가 커다란 배열이라는 사실만 이해하면 충분하다. 배열은 바이트로 이루어져 있으며, 컴퓨터 세상에서 사람들은 주소(address)를 사용하여 배열에서 특정 위치를 찾는다.
메모리 배열에서 각 주소는 요소 하나를 저장한다. 보통 요소 하나는 한 바이트를 차지한다. 어떤 메모리 구성에서는 한 주소가 한 바이트를 넘어서기도 한다. 하지만 이런 메모리 구성은 극히 드물므로 여기서는 모든 메모리 주소가 바이트 단위라고 가정한다.
실제로 정수나 단일 정밀도 부동소수점(single-precision floating point)은 모두 32비트, 즉 4바이트다. 하지만 메모리 주소 하나는 한 바이트만 저장한다. 따라서 32비트를 네 개로 나눠 저장해야 한다. 예를 들어, 32비트짜리 16진수 12345678을 살펴보자. 16진수에서 각 숫자는 4비트를 차지하므로 32비트짜리 16진수는 자리수가 8개다. 즉 16진수 12345678은 네 바이트 12, 34, 56, 78로 나뉜다. 이 값을 메모리에 저장하는 방법은 두 가지다.
-
빅 엔디안(Big-endian): 다음 표와 같이 최상위 바이트를 가장 작은 주소에 저장한다.
표 1. 빅 엔디안 방식으로 저장하기주소 값 1000 12 1001 34 1002 56 1003 78
-
리틀 엔디안(Little-endian): 다음 표와 같이 최하위 바이트를 가장 작은 주소에 저장한다.
표 2. 리틀-엔디안 방식으로 저장하기주소 값 1000 78 1001 56 1002 34 1003 12
두 방식이 서로 역순이라는 사실에 주목한다. 최하위 바이트를 먼저 저장하는 쪽이 리틀 엔디안이고 최상위 바이트를 먼저 저장하는 쪽이 빅 엔디안이라고 기억하면 쉽다.
엔디안 방식은 여러 바이트에 걸치는 값을 쪼개 연속적인 메모리 배열에 저장하려는 경우에만 중요하다. 32비트 값을 저장하는 32비트 레지스터가 있다면 엔디안 방식을 신경쓸 필요가 없다. 레지스터는 빅 엔디안도 아니고 리틀 엔디안도 아니다. 그저 32비트 값을 저장하는 레지스터일 뿐이다. 가장 오른쪽 비트는 최하위 비트고, 가장 왼쪽 비트는 최상위 비트다.
어떤 사람들은 레지스터(register)를 빅-엔디안으로 분류하기도 한다. 최상위 바이트를 가장 낮은 메모리 주소에 저장하기 때문이다.
엔디안 방식은 시스템이 정수를 저장하는 방식(왼쪽에서 오른쪽 혹은 오른쪽에서 왼쪽)을 가리킨다. 가상 머신과 기가 헤르츠 프로세서를 사용하는 오늘날에 프로그래머가 이런 소소한 사안까지 신경써야 할까? 불행하게도, 하드웨어나 소프트웨어 아키텍처를 설계하려면 엔디안 방식을 선택해야 한다. 그런데 딱히 자연스런 법칙이 없는 탓에 아키텍처마다 선택하는 방식이 다르다.
모든 프로세서는 빅-엔디안 방식을 따를지 리틀-엔디안 방식을 따를지 결정해야 한다. 예를 들어, 인텔(Intel®) 80x86 프로세서와 호환 프로세서는 리틀-엔디안 방식을 따른다. 반면, 썬 스팍이나 모토롤라 68K, PowerPC® 계열은 모두 빅-엔디안 방식이다.
엔디안 방식이 왜 중요할까? 한 시스템에서 파일에 정수를 저장했다고 치자. 엔디안 방식이 반대인 시스템에서 이 파일을 읽으면 문제가 생긴다. 엔디안 방식이 다르므로 즉 값을 거꾸로 읽어들이기 때문이다.
네트워크로 숫자를 전송할 때도 엔디안 방식이 매우 중요하다. 앞서 예제와 비슷하게, 엔디안 방식이 다른 두 시스템 사이에 값을 전송해도 문제가 생긴다. 네트워크 상에서는 문제가 더욱 심각한데, 자료를 전송 받는 대상 시스템이 어떤 엔디안 방식을 사용하는지 판단이 불가능한 경우도 있기 때문이다.
Listing 1은 엔디안 방식을 모르고 프로그램을 짰을 때 생기는 위험을 예시한다.
Listing 1. 예제
#include <stdio.h>
#include <string.h>
int main (int argc, char* argv[]) {
FILE* fp;
/* 예제 자료 구조 */
struct {
char one[4];
int two;
char three[4];
} data;
/* 예제 자료 구조를 채운다. */
strcpy (data.one, "foo");
data.two = 0x01234567;
strcpy (data.three, "bar");
/* 자료 구조를 파일에 쓴다. */
fp = fopen ("output", "wb");
if (fp) {
fwrite (&data, sizeof (data), 1, fp);
fclose (fp);
}
}
|
위 코드는 모든 시스템에서 문제 없이 컴파일된다. 그러나 시스템이 따르는 엔디안 방식에 따라 결과는 크게 다르다. Listing 2와 Listing 3에서 프로그램 결과를 hexdump 유틸리티로 살펴본다.
Listing 2. 빅 엔디안 시스템에서 hexdump -C를 수행한 결과
00000000 66 6f 6f 00 12 34 56 78 62 61 72 00 |foo..4Vxbar.|
0000000c
|
Listing 3. 리틀 엔디안 시스템에서 hexdump -C를 수행한 결과
00000000 66 6f 6f 00 78 56 34 12 62 61 72 00 |foo.xV4.bar.|
0000000c
|
엔디안 방식을 고려할 필요가 없는 상황도 있다. 정수에 비트 연산을 수행할 때는 엔디안 방식에 영향을 받지 않는다. 시스템이 다중 바이트를 알아서 배열하므로 연산을 수행한 후에도 최상위 바이트와 최하위 바이트가 바뀌지 않는다.
이 즈음에서 문자열을 저장하는 방식도 시스템에 따라 달라지는지 의문이 들지도 모르겠다. 문자열 저장 방식을 이해하려면 기본적인 배열 개념으로 돌아가야 한다. C 문자열은 어디까지나 문자로 이루어진 배열이기 때문이다. 각 문자는 ASCII 코드로 표현하므로 1바이트를 차지한다. 배열에서 한 요소는 다음 요소보다 주소가 낮다. 즉 &arr[i]는 &arr[i+1]보다 주소가 낮다. 흔히 메모리에 있는 값이 여러 바이트에 걸치는 경우 파일에는 낮은 주소에서 높은 주소 순서로 저장된다. 그래서 파일에 값을 쓸 때는 시작 메모리 주소와 (시작 주소부터 세어 나갈) 바이트 수를 지정한다.
예를 들어, 메모리에 man이라는 C 문자열이 들어있다고 가정하자. m은 주소 1000, a는 주소 1001, n은 주소 1002에 저장되어 있다. null 문자인 \0은 주소 1003에 있다. C 문자열은 문자 배열이므로 문자 규칙을 따른다. 위치 지정을 위해 포인터를 사용해 기교를 부려야 하는 int나 long과는 달리, C 문자열은 배열 색인을 사용하여 한 번에 한 바이트씩 확인할 수 있다. int 자료의 개별 바이트는 프로그래머에게 감춰져 있다.
이제 이 문자열을 write() 함수로 파일에 쓴다고 가정하자. m을 가리키는 포인터와 인쇄할 바이트 수를 지정한다. 여기서는 4바이트다. 그러면 write() 함수는 m에서 시작하여 null 문자까지 4바이트를 바이트 단위로 파일에 쓴다.
이렇듯 C 문자열에서는 엔디안 방식이 문제가 되지 않는다.
그러나 C 문자열이라도 엔디안과 관련이 있는 형 변환(type cast)을 수행하는 경우는 엔디안 방식이 중요하다. Listing 4가 한 예다. 단순히 이 예제뿐만이 아니라 문제를 일으킬 소지가 있는 형 변환 유형이 많다는 사실을 명심한다.
Listing 4. 바이트 순서 강제로 유지하기
unsigned char endian[2] = {1, 0};
short x;
x = *(short *) endian;
|
x 값은 무엇일까? 위 코드에서는 2바이트짜리 배열을 생성한 후 short 값으로 형 변환을 수행한다. 문자열 배열을 사용함으로써 특정한 바이트 순서를 강제했다. 그렇다면 시스템은 이 두 바이트를 어떻게 처리할까?
리틀-엔디안 시스템은 0과 1은 거꾸로 해석하므로 0, 1처럼 위치가 바뀌어버린다. 상위 바이트가 0이고 하위 바이트가 1이므로 x 값은 1이다.
반면, 빅-엔디안 시스템은 상위 바이트가 1이므로 x 값은 256이 된다.
실행 중에 엔디안 방식을 판별하는 방법 중 하나로, 미리 정의한 상수 값이 메모리에 배열되는 방식을 살핀다. 예를 들어, 32비트 정수 값 1은 빅-엔디안 시스템에서 00 00 00 01, 리틀-엔디안 시스템에서 01 00 00 00으로 배열된다. 즉 메모리에서 상수 첫 번째 바이트를 읽어보면 시스템이 사용하는 엔디안 방식을 판별해 적절한 행동을 취할 수 있다.
Listing 5는 다중바이트 정수 i에서 첫 번째 바이트를 읽어 1인지 0인지 확인한다. 1이면 현재 시스템이 리틀-엔디안 방식이라고 가정한다. 0이면 빅-엔디안 방식이라고 가정한다.
Listing 5. 엔디안 방식 판별하기
const int i = 1;
#define is_bigendian() ( (*(char*)&i) == 0 )
int main(void) {
int val;
char *ptr;
ptr = (char*) &val;
val = 0x12345678;
if (is_bigendian()) {
printf(??X.%X.%X.%X\n", ptr[0], ptr[1], ptr[2], ptr[3]);
} else {
printf(??X.%X.%X.%X\n", ptr[3], ptr[2], ptr[1], ptr[0]);
}
exit(0);
}
|
엔디안 방식을 판별하는 또 다른 방법으로, 정수 바이트 배열에 문자 포인터를 사용하여 첫 번째 바이트가 0인지 1인지 확인해도 된다. 구체적인 방법은 Listing 6과 같다.
Listing 6. 문자 포인터
#define LITTLE_ENDIAN 0
#define BIG_ENDIAN 1
int endian() {
int i = 1;
char *p = (char *)&i;
if (p[0] == 1)
return LITTLE_ENDIAN;
else
return BIG_ENDIAN;
}
|
네트워크 스택과 통신 프로토콜 역시 엔디안 방식을 정의해야 한다. 두 노드가 엔디안 방식이 다르면 서로 통신하지 못하기 때문이다. 그래서 임베디드 프로그래머는 특히 엔디안 방식에 신경 써야 한다. TCP/IP(Transmission Control Protocol and the Internet Protocol) 내 모든 프로토콜 계층은 빅 엔디안 방식을 따른다. 즉 (IP 주소, 패킷 길이, 체크섬 등) 계층 헤더에 들어 있는 16비트 값이나 32비트 값은 항상 최상위 바이트부터 전송하고 받는다.
TCP/IP 프로토콜이 따르는 다중바이트 정수 표현 방식을 네트워크 바이트 순서(network byte order)라고도 한다. 양단에 있는 컴퓨터가 리틀-엔디안 방식을 따르더라도, 전송하는 쪽 컴퓨터는 다중바이트 정수를 네트워크 바이트 순서로 변환한 후 전송한다. 그러면 수신하는 쪽 컴퓨터는 네트워크 바이트 순서인 정수를 받아서 리틀-엔디안으로 변환한다.
예를 들어, IP 주소가 192.0.1.2인 컴퓨터와 TCP 소켓으로 연결하려고 한다. IPv4(Internet Protocol version 4)는 32비트 정수를 사용하여 네트워크 호스트를 식별하므로 IP 주소가 192.0.1.2인 컴퓨터를 찾으려면 이 IP 주소를 32비트 정수로 변환해야 한다.
여기서 80x86 기반 PC가 스팍 기반 서버와 인터넷으로 통신한다고 가정하자. 엔디안 방식을 고려하지 않는다면, 80x86 프로세서는 192.0.1.2를 리틀 엔디안 정수 0x020100C0로 변환하여 02 01 00 C0 순서로 전송한다. 그러면 스팍 CPU는 02 01 00 C0 순서로 들어오는 바이트를 빅 엔디안 정수 0x020100c0로 인식하여 2.1.0.192라는 엉뚱한 주소를 얻는다.
TCP/IP 스택이 리틀 엔디안 프로세서에서 돌아간다면 계층 헤더 내 모든 다중바이트 값을 실행 중에 재정렬해야 한다. TCP/IP 스택이 빅 엔디안 프로세서에서 돌아간다면 걱정할 필요가 없다. 엔디안 방식에 상관 없이 스택을 돌리려면 재정렬 여부를 (보통 컴파일 시에) 결정해야 한다.
이러한 변환을 돕고자 소켓은 자료를 호스트 순서에서 네트워크 바이트 순서로 혹은 네트워크 바이트 순서에서 호스트 순서로 변환하는 매크로를 제공한다.
-
htons() - 16비트 unsigned 값을 프로세서 순서에서 네트워크 순서로 변환한다. 매크로 이름은 "host to network short"를 줄인 말이다.
-
htonl() - 32비트 unsigned 값을 프로세서 순서에서 네트워크 순서로 변환한다. 매크로 이름은 "host to network long"을 줄인 말이다.
-
ntohs() - 16비트 unsigned 값을 네트워크 순서에서 프로세서 순서로 변환한다. 매크로 이름은 "network to host short"를 줄인 말이다.
-
ntohl() - 32비트 unsigned 값을 네트워크 순서에서 프로세서 순서로 변환한다. 매크로 이름은 "network to host long"을 줄인 말이다.
Listing 7에 있는 C 프로그램을 살펴보자.
Listing 7. 예제 C 프로그램
#include <stdio.h>
main() {
int i;
long x = 0x112A380; /* 테스트 대상 값 */
unsigned char *ptr = (char *) &x; /* 바이트 포인터 */
/* 호스트 바이트 순서로 값을 출력 */
printf("x in hex: %x\n", x);
printf("x by bytes: ");
for (i=0; i < sizeof(long); i++)
printf("%x\t", ptr[i]);
printf("\n");
/* 네트워크 바이트 순서로 값을 출력 */
x = htonl(x);
printf("\nAfter htonl()\n");
printf("x in hex: %x\n", x);
printf("x by bytes: ");
for (i=0; i < sizeof(long); i++)
printf("%x\t", ptr[i]);
printf("\n");
}
|
이 프로그램은 값이 16진수 112A380인 long 변수 x가 저장된 방식을 살펴본다.
위 프로그램을 리틀 엔디안 프로세서에서 실행하면 Listing 8과 같은 결과를 얻는다.
Listing 8. 리틀 엔디안 프로세서에서 실행한 결과
x in hex: 112a380
x by bytes: 80 a3 12 1
After htonl()
x in hex: 80a31201
x by bytes: 1 12 a3 80
|
변수 x에서 개별 바이트를 살펴보면 최하위 바이트 0x80이 최하위 주소에 놓인다. 그러나 htonl( )을 호출하여 네트워크 바이트 순서로 변환하면 최상위 바이트 0x1이 최하위 주소에 놓인다. 물론 바이트 순서를 변환한 후 x 값을 출력하면 전혀 의미 없는 숫자를 얻는다.
Listing 9는 같은 프로그램을 빅 엔디안 프로세서에서 실행한 결과다.
Listing 9. 빅 엔디안 프로세서에서 실행한 결과
x in hex: 112a380
x by bytes: 1 12 a3 80
After htonl()
x in hex: 112a380
x by bytes: 1 12 a3 80
|
여기서는 최상위 바이트 0x1이 최하위 주소에 놓인다. htonl( )을 호출하여 네트워크 바이트 순서로 변환해도 x 값은 변하지 않는다. 네트워크 바이트 순서가 이미 빅 엔디안 방식이기 때문이다.
이제 특정한 엔디안 방식에 구애 받지 않는 코드를 짜보자. 방법은 다양하다. 목적은 플랫폼이 어떤 엔디안 방식을 사용하든 실패하지 않는 코드를 구현하는 데 있다. 이렇게 하려면 파일에 값을 쓰거나 읽을 때 엔디안이 올바른지 확신할 필요가 있다. 또한 컴파일 시 조건 플래그를 지정할 필요 없이 코드 내에서 자동으로 엔디안 방식을 판별할 수 있으면 더욱 좋겠다.
시스템의 엔디안 방식에 따라 주어진 매개변수 값의 바이트 순서를 자동으로 변환하는 함수 집합을 작성해보자.
먼저, 2 바이트로 이루어진 short 매개변수 s를 처리하는 함수부터 살펴보자. 함수에서는 간단한 비트 연산으로 두 바이트를 쪼갠 후 역순으로 붙인다. Listing 10에서 보듯이, 프로세서가 리틀 엔디안 방식인 경우 함수는 순서가 뒤바뀐 short 값을 반환한다. 그렇지 않으면 s를 그대로 반환한다.
Listing 10. 첫 번째 방법: 비트 시프트와 비트 AND 사용
short reverseShort (short s) {
unsigned char c1, c2;
if (is_bigendian()) {
return s;
} else {
c1 = s & 255;
c2 = (s >> 8) & 255;
return (c1 << 8) + c2;
}
}
|
아래 함수에서는 short를 문자 배열로 변환한 후 각 바이트를 새 배열에 거꾸로 저장한다. 프로세서가 빅 엔디안 방식인 경우는 s를 그대로 반환한다.
Listing 11. 두 번째 방법: 문자 배열 포인터 사용
short reverseShort (char *c) {
short s;
char *p = (char *)&s;
if (is_bigendian()) {
p[0] = c[0];
p[1] = c[1];
} else {
p[0] = c[1];
p[1] = c[0];
}
return s;
}
|
이제 int를 처리하자.
Listing 12. 첫 번째 방법: int에 비트 시프트와 비트 AND 사용
int reverseInt (int i) {
unsigned char c1, c2, c3, c4;
if (is_bigendian()) {
return i;
} else {
c1 = i & 255;
c2 = (i >> 8) & 255;
c3 = (i >> 16) & 255;
c4 = (i >> 24) & 255;
return ((int)c1 << 24) + ((int)c2 << 16) + ((int)c3 << 8) + c4;
}
|
이번에는 2바이트가 아니라 4바이트를 뒤집을 뿐 short와 거의 똑같은 방법이다.
Listing 13. 두 번째 방법: int에 문자 배열 포인터 사용
short reverseInt (char *c) {
int i;
char *p = (char *)&i;
if (is_bigendian()) {
p[0] = c[0];
p[1] = c[1];
p[2] = c[2];
p[3] = c[3];
} else {
p[0] = c[3];
p[1] = c[2];
p[2] = c[1];
p[3] = c[0];
}
return i;
}
|
마찬가지로 2바이트가 아니라 4바이트를 처리한다는 사실만 제외하면 short와 동일하다.
비슷한 방식으로 float, long, double 등을 뒤집는 코드도 작성할 수 있으나, 이 기사 범위를 벗어나므로 여기서는 생략하겠다.
한 엔디안 방식이 다른 엔디안 방식보다 낫다고 주장하기는 어렵다. 둘 다 많이 쓰이며 아키텍처마다 나름의 방식을 채택한다. 대다수 개인용 컴퓨터와 랩톱이 리틀 엔디안 프로세서를 (그리고 호환 프로세서를) 사용하므로 오늘날 대다수 데스크톱 컴퓨터는 리틀 엔디안 방식이다.
자료를 저장하는 측면에서 가장 작은 단위는 “바이트”다. 그래서 크기가 한 바이트인 자료나 이러한 자료의 배열은 엔디안 방식에 영향을 받지 않는다. 반면, 여러 바이트에 걸친 자료는 엔디안 방식에 따라 값이 달라지므로 코드를 구현할 때는 주의를 기울여야 한다.
교육
-
AIX®와
UNIX®
:
한국 developerWorks의 AIX와 UNIX 영역은 AIX 시스템을 관리하고 UNIX 지식을 익히는 데 필요한 다양하고 풍부한 정보를 제공한다.
-
AIX and UNIX 입문 (한글):
AIX와 UNIX에 대한 정보 제공
-
인기 있는 자료:
독자들이 뽑은 인기 있는 AIX와 UNIX 자료.
-
AIX 5L™ 위키:
AIX와 관련한 기술 정보를 공동으로 수집하고 관리하는 공간.
- AIX와 UNIX 라이브러리에서 다음 주제를 찾아본다.
-
사파리 온라인 서점:
특정 기술 자료를 찾기 위한 전자 참조 도서관을 방문하자.
-
developerWorks 기술 행사와 웹 캐스트: developerWorks 기술 행사와 웹 캐스트 최신 소식
-
포드캐스트: IBM 기술 전문가의 이야기를 듣자.
제품 및 기술 얻기
-
IBM 평가판 소프트웨어:
developerWorks에서 직접 내려 받아 다음번 프로젝트에 활용하자.
토론
-
developerWorks 블로그와 developerWorks 공동체에 참여한다.
- AIX와 유닉스 포럼에 참여한다.
Harsha Adiga는 인도 방갈로르 시에 있는 IBM 소프트웨어 그룹에서 근무하며 다양한 리눅스, 오픈 소스, 작업 그룹에 적극적으로 참여하고 있다. 주된 관심사는 리눅스와 유닉스 내부 구조, 이식, 컴파일러, 코드 최적화 등이다. 리눅스와 유닉스 플랫폼에서 소프트웨어 개발과 테스트를 6년 넘게 해왔다. 전자편지 주소는 haradiga@in.ibm.com이다.