W wielu przypadkach optymalny sposób wykonania jakiegoś zadania może zależeć od kontekstu, w którym zadanie jest wykonywane. Jeśli procedura jest napisana w języku asemblera, generalnie nie będzie możliwe zmienianie sekwencji instrukcji w zależności od kontekstu. Jako prosty przykład rozważ następującą prostą metodę:
inline void set_port_high(void)
{
(*((volatile unsigned char*)0x40001204) = 0xFF);
}
Kompilator dla 32-bitowego kodu ARM, biorąc pod uwagę powyższe, prawdopodobnie renderowałby go jako:
ldr r0,=0x40001204
mov r1,#0
strb r1,[r0]
[a fourth word somewhere holding the constant 0x40001204]
a może
ldr r0,=0x40001000 ; Some assemblers like to round pointer loads to multiples of 4096
mov r1,#0
strb r1,[r0+0x204]
[a fourth word somewhere holding the constant 0x40001000]
Można to nieco zoptymalizować w ręcznie składanym kodzie, ponieważ:
ldr r0,=0x400011FF
strb r0,[r0+5]
[a third word somewhere holding the constant 0x400011FF]
lub
mvn r0,#0xC0 ; Load with 0x3FFFFFFF
add r0,r0,#0x1200 ; Add 0x1200, yielding 0x400011FF
strb r0,[r0+5]
Oba ręcznie zmontowane podejścia wymagałyby 12 bajtów przestrzeni kodu zamiast 16; ten ostatni zastąpiłby „obciążenie” „dodaniem”, co w przypadku ARM7-TDMI wykona dwa cykle szybciej. Gdyby kod miał być wykonywany w kontekście, w którym r0 nie wiedział / nie przejmował się, wersje językowe asemblera byłyby nieco lepsze niż wersja skompilowana. Z drugiej strony załóżmy, że kompilator wiedział, że jakiś rejestr [np. R5] będzie przechowywał wartość mieszczącą się w granicach 2047 bajtów od pożądanego adresu 0x40001204 [np. 0x40001000], a ponadto wiedział, że idzie inny rejestr [np. R7] do przechowywania wartości, której niskie bity to 0xFF. W takim przypadku kompilator może zoptymalizować wersję C kodu, aby po prostu:
strb r7,[r5+0x204]
Znacznie krótszy i szybszy niż nawet ręcznie zoptymalizowany kod zestawu. Ponadto załóżmy, że set_port_high wystąpił w kontekście:
int temp = function1();
set_port_high();
function2(temp); // Assume temp is not used after this
W ogóle nie jest to niemożliwe przy kodowaniu systemu wbudowanego. Jeśli set_port_high
jest zapisany w kodzie asemblera, kompilator musiałby przenieść r0 (który przechowuje wartość zwracaną function1
) gdzie indziej przed wywołaniem kodu asemblera, a następnie przenieść tę wartość z powrotem do r0 (ponieważ function2
spodziewa się swojego pierwszego parametru w r0), więc „zoptymalizowany” kod zestawu wymagałby pięciu instrukcji. Nawet jeśli kompilator nie wiedział o żadnym rejestrze zawierającym adres lub wartość do przechowywania, jego czteroinstrukcyjna wersja (którą mógłby przystosować do korzystania z dowolnych dostępnych rejestrów - niekoniecznie r0 i r1) pobiłaby „zoptymalizowany” zestaw wersja językowa. Gdyby kompilator miał niezbędny adres i dane w r5 i r7, jak opisano wcześniej, function1
nie zmieniłby tych rejestrów, a zatem mógłby zastąpićset_port_high
z pojedynczą strb
instrukcją - cztery instrukcje mniejsze i szybsze niż kod asemblera „zoptymalizowany ręcznie”.
Zauważ, że ręcznie zoptymalizowany kod asemblera często przewyższa kompilator w przypadkach, gdy programiści znają dokładny przebieg programu, ale kompilatory świecą w przypadkach, gdy kawałek kodu jest napisany przed poznaniem jego kontekstu lub gdy jeden fragment kodu źródłowego może być wywoływany z wielu kontekstów [jeśli set_port_high
jest używany w pięćdziesięciu różnych miejscach kodu, kompilator może niezależnie dla każdego z nich zdecydować, jak najlepiej go rozwinąć].
Zasadniczo sugerowałbym, że język asemblera jest w stanie zapewnić największą poprawę wydajności w tych przypadkach, w których do każdego fragmentu kodu można podejść z bardzo ograniczonej liczby kontekstów, i może być szkodliwy dla wydajności w miejscach, w których fragment do kodu można podchodzić z wielu różnych kontekstów. Co ciekawe (i dogodnie) przypadki, w których montaż jest najbardziej korzystny dla wydajności, to często przypadki, w których kod jest najbardziej prosty i łatwy do odczytania. Miejsca, w których kod języka asemblerowego zamieniłby się w lepki bałagan, to często te, w których pisanie w asemblerze zapewniałoby najmniejszą korzyść w zakresie wydajności.
[Drobna uwaga: jest kilka miejsc, w których można użyć kodu asemblera, aby wywołać hiperoptymalizowany lepki bałagan; na przykład jeden kawałek kodu, który zrobiłem dla ARM, potrzebował pobrać słowo z pamięci RAM i wykonać jedną z około dwunastu procedur na podstawie sześciu górnych bitów wartości (wiele wartości odwzorowanych na tę samą procedurę). Myślę, że zoptymalizowałem ten kod do czegoś takiego:
ldrh r0,[r1],#2! ; Fetch with post-increment
ldrb r1,[r8,r0 asr #10]
sub pc,r8,r1,asl #2
Rejestr r8 zawsze zawierał adres głównej tablicy wysyłkowej (w pętli, w której kod spędza 98% swojego czasu, nic nigdy nie wykorzystywało go do żadnych innych celów); wszystkie 64 wpisy odnosiły się do adresów w 256 bajtach poprzedzających. Ponieważ pętla pierwotna miała w większości przypadków sztywny limit czasu wykonania wynoszący około 60 cykli, pobieranie i wysyłanie w dziewięciu cyklach było bardzo istotne dla osiągnięcia tego celu. Użycie tabeli 256 32-bitowych adresów byłoby o jeden cykl szybsze, ale pochłonęłoby 1 KB bardzo cennej pamięci RAM [flash dodałby więcej niż jeden stan oczekiwania]. Użycie 64 32-bitowych adresów wymagałoby dodania instrukcji maskowania niektórych bitów z pobranego słowa i nadal pochłonąłoby 192 bajty więcej niż tabela, której faktycznie użyłem. Korzystanie z tabeli 8-bitowych przesunięć dało bardzo kompaktowy i szybki kod, ale nie jest to coś, czego oczekiwałbym od kompilatora; Nie spodziewałbym się również, że kompilator poświęci rejestrowi „pełny czas” na przechowywanie adresu tabeli.
Powyższy kod został zaprojektowany do działania jako samodzielny system; może okresowo wywoływać kod C, ale tylko w pewnych momentach, gdy sprzęt, z którym się komunikuje, może być bezpiecznie wprowadzony w stan „bezczynności” na dwa mniej więcej co milisekundowe interwały co 16 ms.