ByteOrder - kolejność bajtów

tech • 1926 słów • 10 minut czytania

Architektury współczesnych mikroprocesorów powszechnie używają dwóch różnych metod i konwencji przechowywania danych w pamięci, zwane “kolejnością bajtów” (byte order). Niektóre komputery umieszczają najbardziej znaczący bajt w słowie jako pierwszy (big-endian), a inne jako ostatni (little-endian).

Przez większość czasu, kolejność bajtów może być ignorowana, programista nie musi się martwić o to, jaki format jest używany, ale w niektórych sytuacjach staje się to ważne.

Kiedy implementujemy binarny format pliku, bibliotekę sieciową lub protokół, lub po prostu przesyłamy dane binarne miedzy innymi komputerami, musimy wziąć pod uwagę kwestie związane z uporządkowaniem bajtów.

Niestety nie istnieją żadne standardowe biblioteki, ani przenośne funkcje pozwalające w prosty i przenośny sposób operować na endianowości i konwersji między typami uporządkowania bajtów. Między innymi dlatego pokusiłem się o stworzenie biblioteki ByteOrder (ale o niej trochę później).

Kolejność bajtów

Nie będę przedstawiał tutaj szczegółów teoretycznych na temat konwencji kolejności bajtów, ani tłumaczył co, jak, i dlaczego - wszystko można znaleźć w sieci. W skrócie, jak napisałem we wstępie, obie konwencje różnią się uporządkowaniem bajtów, a dokładnie kolejnością w jakiej zapisywany jest najbardziej znaczący bajt w słowie. Szczegóły przedstawiają poniższe rysunki (dzięki uprzejmości Wikipedii):

endianness

Jak wszyscy pewnie wiedzą, sieciowa kolejność bajtów (network byte order) to nic innego jak big-endian. Implementując protokół sieciowy, powinniśmy trzymać się właśnie tej konwencji.

Wykrywanie konwencji uporządkowania bajtów

Wykrywanie używanie przez system (host) konwencji zapisu danych można dokonać na dwa sposoby. Jednym z nich jest dedykacja w czasie kompilacji, a drugi w czasie działania.

Compile-time

Determinacja konwencji porządkowania bajtów w czasie kompilacji jest jedną z najpopularniejszych metod. Dokonać jej można na podstawie definiowanych przez systemowe nagłówki odpowiednich makr, lub bazując na pewnych stałych (makrach) określających platformę i system operacyjny.

Większość nowoczesnych systemów udostępnia makra “zawierające” informacje o konwencji uporządkowania bajtów używanych przez system. W systemach *nix, nagłówki systemowe zawierają odpowiednie definicje, na przykład linuksowy endian.h: __BYTE_ORDER, __LITTLE_ENDIAN, __BIG_ENDIAN.

Wykorzystać je można do testowania endianowości:

#if __BYTE_ORDER == __BIG_ENDIAN
	#define I_AM_BIG_ENDIAN
#elif __BYTE_ORDER == __LITTLE_ENDIAN
	#define I_AM_LITTLE_ENDIAN
#else
	#error unknown byte order
#endif

Niestety niektóre systemy, na przykład Windows nie zawierają takich informacji, wtedy na podstawie platformy można również wydedukować, jaka konwencje używa nasz system:

#if defined(__sparc__) || defined(__powerpc__) || \
	defined(__ppc__) || defined(__hpux) || \
	defined(__s390__)

	#define I_AM_BIG_ENDIAN

#elif defined(__i386__) || defined(__alpha__) || \
	defined(__ia64__) || defined(__x86_64__) || \
	defined(__amd64__) || defined(__bfin__)

	#define I_AM_LITTLE_ENDIAN

#else

	#error unknown byte order

#endif

Run-time

Determinacja kolejności bajtów w czasie działania aplikacji jest bardzo prosta. Skupia się na zapisaniu znanej wartości do pamięci i odczytania jej pierwszego bajta. Zależnie od jego wartości można wywnioskować używaną przez system konwencje układania bajtów.

Funkcja wykonywująca to działanie mogłaby wyglądać następująco:

enum ByteOrderType {
	BigEndian,
	LittleEndian,
};

inline ByteOrderType ByteOrderTest() {
	volatile short int x = 0x1234;
	return *reinterpret_cast<volatile char*>(&x) == 0x12 ? BigEndian : LittleEndian;
}

Rzutowania, uznawanego przez niektórych za wstrętne, można pozbyć się stosując unie w roli zmiennej x.

Zamiana kolejności bajtów

Istnieje wiele różnych sposobów i metod implementacji mechanizmów zmiany porządku bajtów w liczbach i wartościach. Jedne wykorzystują możliwości oferowane przez procesory, będące optymalnymi i szybkimi operacjami, inne przesunięcia bitowe, albo rozwlekle i naiwne implementacje oparte na układaniu poszczególnych bajtów w pętli.

Przesunięcia bitowe

Najczęściej stosowaną na świecie metodą radzenia sobie ze zmianą uporządkowania bajtów jest wykorzystanie operacji przesunięć bitowych.

Dla 16/32/64-bitowej liczby n operacje takie można zapisać w poniższej postaci:

uint16_t bswap16(uint16_t x) {
	return (((x & 0xff00) >> 8) |
			((x & 0x00ff) << 8));
}

uint32_t bswap32(uint32_t x) {
	return (((x & 0xff000000) >> 24) |
			((x & 0x00ff0000) >>  8) |
			((x & 0x0000ff00) <<  8) |
			((x & 0x000000ff) << 24));
}

uint64_t bswap64(uint64_t x) {
	return (((x & 0xff00000000000000ull) >> 56) |
			((x & 0x00ff000000000000ull) >> 40) |
			((x & 0x0000ff0000000000ull) >> 24) |
			((x & 0x000000ff00000000ull) >>  8) |
			((x & 0x00000000ff000000ull) <<  8) |
			((x & 0x0000000000ff0000ull) << 24) |
			((x & 0x000000000000ff00ull) << 40) |
			((x & 0x00000000000000ffull) << 56));
}

Przełożenie poszczególnych operacji bitowych na asembler w stosunku 1:1 da w rezultacie całkiem zgrabny kod, ale trochę rozwięzły i złożony. Po zastosowaniu optymalizacji, kod ten trochę się uprości, zmniejszy o średnio około 30% operacji. Postać taka, bazując na kodzie wygenerowanym przez VC2k12, mogłaby wyglądać następująco (pomijając ramki stosu i inne takie):

bswap16 proc
	movzx	edx, cx
	movzx	eax, dl
	shr	edx, 8
	shl	eax, 8
	or	eax, edx
	ret	0
bswap16 endp

bswap32 proc
	mov	eax, ecx
	mov	edx, ecx
	shl	edx, 16
	and	eax, 0000ff00h
	or	eax, edx
	mov	edx, ecx
	shr	ecx, 16
	and	edx, 00ff0000h
	shl	eax, 8
	or	edx, ecx
	shr	edx, 8
	or	eax, edx
	ret	0
bswap32 endp

bswap64 proc
	mov	r8, rcx
	mov	r9, rcx
	mov	rax, 00ff000000000000h
	and	r9, rax
	mov	rax, rcx
	mov	rdx, r8
	shr	rax, 16
	and	edx, 0000ff00h
	or	r9, rax
	mov	rax, rcx
	mov	rcx, 0000ff0000000000h
	and	rax, rcx
	shr	r9, 16
	mov	rcx, 000000ff00000000h
	or	r9, rax
	mov	rax, r8
	and	rax, rcx
	shr	r9, 16
	mov	rcx, r8
	or	r9, rax
	mov	rax, r8
	and	ecx, 00ff0000h
	shl	rax, 16
	shr	r9, 8
	or	rdx, rax
	mov	eax, ff000000h
	and	rax, r8
	shl	rdx, 16
	or	rdx, rcx
	shl	rdx, 16
	or	rax, rdx
	shl	rax, 8
	or	rax, r9
	ret	0
bswap64 endp

Jak widać, złożoność takiej operacji nie jest wcale taka mała. Wraz z podwojeniem rozmiaru zmiennej ilość operacji dramatycznie wzrasta, co można zauważyć na 64-bitowej liczby. Być może lepszym rozwiązaniem byłoby potraktowanie 64-bitowej liczby jako dwie 32-bitowe i ich przekształcenie.

Na szczęście dotyczy to wartości konwertowanych w czasie działania programu, w jeśli wartość jest znana w czasie kompilacji, operacje przesunięć bitowych na znanych wartościach zostaną wyliczone przez kompilator, podstawiając w wyniku odpowiednia wartość.

Iteracyjne przemieszczenia

Czasami można spotkać implementacje funkcji konwertujących kolejność bajtów z wykorzystaniem pętli, gdzie każdy bajt zostaje “przeniesiony” w odpowiednie miejsce, co w skrócie odpowiada prostej operacji reverse.

Implementacja takiej funkcji może być podobna do poniższej:

void bswap(void* dest, const void* src, size_t len) {

	const char* in = reinterpret_cast<const char*>(src);
	char* out = reinterpret_cast<char*>(dest) + len;

	for ( ; len > 0; len--)
		*--out = *in++;

}

Rozwijając ją do asemblera otrzymujemy bardzo prostą formę:

bswap proc
	; dest = eax
	; src  = ebx
	; len  = ecx

	test ecx, ecx
	je	short end@bswap
	lea	eax, dword ptr [eax+ecx]

loop@bswap:
	mov	cl, byte ptr [ebx]
	lea	eax, dword ptr [eax-1]
	lea	ebx, dword ptr [ebx+1]
	mov	byte ptr [eax], cl
	dec	ecx
	jne	short loop@bswap

end@bswap:
	ret	0
bswap endp

Oczywiście, zależnie od kompilatora i jego optymalizacji, a także rozmiaru danych, w wyniku możemy otrzymać całkiem inny, nijak niepodobny kod wynikowy, wraz z rozwinięciem pętli włącznie.

xchg, bswap, sse

Współczesne procesory posiadają pewne ciekawe rozkazy, których wykorzystanie może przyspieszyć i uprościć kod związany z odwracaniem kolejności bajtów.

Instrukcja xchg pozwala na konwersje ułożenia bajtów w 16-bitowych danych (rejestrach i pamięci):

xchg al, ah

Dla 32/64-bitowych liczb znajdujących się w rejestrze (tylko) można skorzystać, dostępnej od 486, instrukcji bswap, która “swapuje” bajty w danych 32/64-bitowych (zależnie od platformy):

bswap eax

Nie istnieją żadne instrukcje operujące na 128-bitowych danych, ale wraz z rozszerzeniami SSE2 i SSE3 doszły różne ciekawe instrukcje jak PSHUFD i PSHUFB, które mogą nam pomóc zaimplementować wydajne odwracanie bajtów w rejestrach xmm (128 bity).

Intrinsics

Najpopularniejsze kompilatory zawierają w sobie ciekawe rozszerzenia i wbudowane polecenia intrinsics, które mogą pomóc nam zamienić bajty, i to w najbardziej optymalny sposób jaki się da na danej platformie.

Visual C++ w nagłówku intrin.h definiuje funkcje:

unsigned short   _byteswap_ushort(unsigned short val);
unsigned long    _byteswap_ulong (unsigned long val);
unsigned __int64 _byteswap_uint64(unsigned __int64 val);

GCC posiada swoje odpowiedniki:

int16_t __builtin_bswap64(int16_t x);
int32_t __builtin_bswap32(int32_t x);
int64_t __builtin_bswap64(int64_t x);

Służą one do zmiany kolejności bajtów w kolejno 16/32/64-bitowych liczbach. Funkcje te są wbudowanymi instrukcje typu intrinsics, bezpośrednio rozwijanymi do kodu asemblera używającego, zależnie od platformy, operacji ror (16) i bswap (32/64), przez co są najszybszymi operacjami zmiany kolejności bajtów.

GNU C Library również zawiera optymalne definicje powyższych funkcji (nagłówek bits/byteswap.h), które dla wartości znanych w czasie kompilacji wyliczane są operacjami bazującym na przesunięciach bitowych, a dla pozostałych, o ile platforma na to pozwala, rozwijane do operacji wykorzystujących mnemonik ror* i bswap.

Biblioteka ByteOrder

Biblioteka ByteOrder, mieszcząca się w jednym nagłówku, dostarcza narzędzia i API, umożliwiające w wydajny, a zarazem prosty i przenośny sposób korzystać z elementów jakie opisano w tej notce.

Obecnie nie istnieje żaden standard określający wspólny interfejs funkcji związanych z obsługą endianowości. POSIX definiuje jedynie funkcje sieciowe do zmiany kolejności bajtów rodziny ntoh* i hton*. Mimo wszystko, większość systemów *nix posiada odpowiednie funkcje, często z przyjętą inną, acz zbliżoną konwencję nazewnictwa (Linux vs. BSD).

To właśnie brak standardowych i przenośnych narzędzi oraz potrzeba posiadania szybkich i wydajnych funkcji potrafiących konwertować dane pomiędzy konwencjami uporządkowania bajtów, stały się prekursorem do stworzenia tej biblioteki. Biblioteka ta może zostać doceniona w czasie cross-platformowych implementacji różnych bibliotek do obsługi formatu danych lub protokołu sieciowego, gdzie zwalnia nas z zajmowania się detalami związanymi z uporządkowaniem bajtów. Wykorzystanie możliwości systemu operacyjnego, platformy i kompilatora (wstawki asemblerowe, wbudowane instrukcje intrinsics i rozszerzenia kompilatora) przez definicje zawarte w nagłówku biblioteki ByteOrder, zapewniają najbardziej optymalne rozwiązania. Co szczególnie może być doceniane w implementacjach krytycznych elementów systemu.

Wykrywanie konwencji uporządkowania bajtów

Biblioteka dostarcza mechanizmy determinacji używanej przez hosta (platformę) konwencji uporządkowania bajtów. Podobnie jak to opisano wyżej w notce, dostępne są narzędzia działające w czasie kompilacji jak i działania programu.

Makra BYTE_ORDER i BYTE_ORDER_NAME dostarczają niezbędne informacje o używanej przez system konwencji w czasie kompilacji, a funkcje ByteOrderTest i ByteOrderName, są run-time-owymi odpowiednikami .

Swapowanie bajtów

Biblioteka definiuje wydajne funkcje służące do zmiany porządku bajtów liczbach 16/32/64-bitowych:

uintNN_t bswapNN(uintNN_t n);

Konwersje uporządkowania bajtów

Funkcje dostarczane przez bibliotekę ułatwiają konwersje z/do konwencji używanej przez system hosta a big/little-endian:

uintNN_t htoleNN(uintNN_t n);
uintNN_t letohNN(uintNN_t n);
uintNN_t htobeNN(uintNN_t n);
uintNN_t betohNN(uintNN_t n);

Podobne funkcje dostępne są w systemach Linux i BSD, ale w ByteOrder podążano za OpenBSD-ową konwencją nazewnictwa tych funkcji.

Kopiowanie z konwersją uporządkowania bajtów

Dodatkowo biblioteka definiuje funkcje, za pomocą których można odczytach z strumienia wejściowego dane 16/32/64-bitowe wraz z konwersja z/do konwencji używanej przez system hosta a big/little-endian:

void htoleNNcpy(void* dest, const void* src);
void letohNNcpy(void* dest, const void* src);
void htobeNNcpy(void* dest, const void* src);
void betohNNcpy(void* dest, const void* src);

C++ template

Nie zabrakło także, definicji szablonowych funkcji C++ do szybkiej i łatwej konwersji dowolnego typu:

template<typename T> inline T htole(T n);
template<typename T> inline T letoh(T n);
template<typename T> inline T htobe(T n);
template<typename T> inline T betoh(T n);

Wybór implementacji odpowiedniej funkcji następuje na podstawie rozmiaru typu wydedukowanego przez kompilator w czasie kompilacji.

Dostępność

ByteOrder działa na popularnych platformach – systemach Unix i BSD, Linuksie, Windowsie i Macu. Powinna także, kompilować się bez żadnych problemów pod nowoczesnymi kompilatorami C i C++ (C99, C11, C++98, C++11).

Biblioteka ByteOrder dostępna jest na stronie projektu na projects.malcom.pl, jej źródła leżą w dedykowanym repo na moim GitHubie. Tam również można znaleźć pełną dokumentację oraz przykładowe użycie/wykorzystanie.

Rozpowszechniana jest na zasadach licencji MIT.

W ramach podsumowania

Przedstawione informacje powinny nieco rzucić światłem na problem kolejności bajtów, oraz na to, że z pozoru wydające się proste operacje konwersji wcale nie musza być takie proste i szybkie. A wykorzystanie specjalnych i dedykowanych narzędzi do tego celu może przyspieszyć takie operacje, które w wielu zastosowaniach mogą znajdować się w krytycznych elementach systemu.

Powstanie biblioteki ByteOrder powinno zwolnić nas z kłopotu zajmowania się problemem uporządkowania bajtów, a skupić się na implementacji rozwiązania podstawowego problemu. Bo przecież główne zastosowanie biblioteki ByteOrder to niezależne o platformy operacje “swapowania” bajtów w liczbach 16/32/64-bitowych, konwersji kolejności bajtów danych między używanym przez host porządkiem a big/little-endian.

W ramach ciekawej zabawy i eksperymentów, postaram się, być może wkrótce, przedstawić implementacje funkcji bswap operującej na rejestrach xmm i korzystające z rozszerzeń SSE.

Komentarze (1)

Bartosz Ptak avatar
Bartosz Ptak
20180417-141402-bartosz-ptak

Dziękuję, super artykuł.

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/