Asembler procesorów ARM

Jak już wspominałem, programy w asemblerze są nieprzenośne. Programu napisanego na procesor z jednej rodziny nie da się nawet zasemblować asemblerem dla procesora z innej rodziny. Pomimo różnic w językach asemblera różnych procesorów, większość z nich opiera się jednak na takich samych podstawach. Zazwyczaj jest pewna liczba rejestrów określonego rozmiaru i rozkazy wykonujące proste operacje na rejestrach i komórkach pamięci. Sprawia to, że po zapoznaniu się z asemblerem dla jednej rodziny procesorów można dość szybko zacząć programować w asemblerze innej rodziny. Jest to podobna sytuacja jak, przykładowo, z językami programowania obiektowego.

Celem tych zajęć jest zapoznanie się z asemblerem procesorów ARM.

Procesory ARM

Ogólnie o procesorach ARM poczytać m.in. w Wikipedii, w wersji angielskej i trochę uboższej polskiej. Procesory te są dość popularne w rozmaitych urządzeniach elektronicznych takich jak telefony komórkowe, przenośne konsole do gier (Gameboy Advance), odtwarzacze muzyczne (Ipod, Iriver iFP) i innych, niekiedy bardziej poważnych.

Podstawowym dokumentem opisującym tę rodzinę procesorów jest ARM Architecture Reference Manual, w skrócie ARM ARM. Nie jest on chyba dostępny na stronie producenta, można go jednak znaleźć na przykład na stronie firmy Altera. Tutaj opiszę tylko kilka cech, na które warto zwrócić uwagę.

Rejestry

Procesory ARM mają 15 32-bitowych rejestrów ogólnego przeznaczenia o nazwach od R0 do R14. Rejestr R15, zwany też PC, jest licznikiem programu. Jest jednak również zaliczany do rejestrów ogólnego przeznaczenia i może być używany prawie wszędzie tam, gdzie pozostałe. Zapis do R15 powoduje skok pod wpisywany adres, odczyt zwraca adres bieżącej instrukcji powiększony o 8.

Architektura trójadresowa

Rozkazy realizujące operacje dwuargumentowe mają zazwyczaj trzy operandy: dwa źródłowe i jeden docelowy. Instrukcja:

  ADD R0, R1, R2
wykonuje operację R0 := R1 + R2. Oczywiście nie wszystkie operandy muszą być różne:
  ADD R0, R0, R0
podwaja zawartość R0.

Architektura wczytaj/zapisz

W odróżnieniu od procesorów z rodziny IA-32, rozkazy wykonujące obliczenia nie mogą używać komórek pamięci jako operandów. Komunikacja z pamięcią odbywa się tylko za pomocą rozkazów wczytujących i zapisujących dane.

Układ przesuwający

Wiele rozkazów oprócz właściwej operacji pozwala też na wykonanie przesunięcia bitowego jednego z argumentów. Rozkaz

  ADD R0, R1, R2, LSL #3
wykonuje operację R0 := R1 + R2 * 8.

Rozkazy warunkowe

Wszystkie rozkazy, poza pewnymi wyjątkami w nowszych procesorach, mogą być warunkowe. Rozkaz

  ADDEQ R0, R1, R2, LSL #3
wykona się tylko jeśli znacznik Z jest równy 1. W przeciwnym przypadku procesor po prostu przejdzie tylko do kolejnej instrukcji. Oczywiście rozkaz skoku (np. rozkaz B) również może być warunkowy.

Stos

Procesory ARM nie mają rozkazów przeznaczonych specjalnie do obsługi stosu takich jak PUSH i POP. Operacje na stosie można jednak wykonywać za pomocą innych rozkazów. Przyjęta konwencja mówi, że rejestr R13, zwany też SP, wskazuje na element na wierzchołku stosu. Stos rośnie w kierunku malejących adresów. Do operacji na stosie szczególnie dobre są rozkazy LDMFD i STMFD (są to inne nazwy rozkazów LDMIA i STMDB -- patrz punkt 5.4.6 ARM ARM). Zapisują one zbiór rejestrów do pamięci pod adres wskazany pierwszym operandem i ewentualnie (jeśli po pierwszym operandzie jest wykrzyknik) zmieniają odpowiednio ten adres. Przy operacjach stosowych wykrzyknik występuje w zasadzie zawsze. Przykład:
  STMFD SP!, {R4, R5, R6}   ; odłóż na stos rejestry R4-R6
  ...
  LDMFD SP!, {R4, R5, R6}   ; zdejmij je ze stosu

Procedury

Rozkaz służący do wykonywania skoków do procedur nazywa się BL. Skacze on pod podany adres, a do rejestru R14, zwanego również LR, wpisuje adres powrotu, czyli adres następnej instrukcji po BL. Nie ma specjalnej instrukcji powrotu z procedury. Zamiast niej można wykonać na przykład MOV PC, LR. Jeśli, będąc w pewnej procedurze, chcemy wykonać skok do procedury, to musimy zachować bieżącą zawartość LR. Można ją po prostu odłożyć na stos. Przy pomocy pewnej sztuczki można połączyć odtwarzanie zachowanych rejestrów z powrotem z procedury:

  STMFD SP!, {R4, R5, R6, LR}   ; odłóż na stos rejestry R4-R6 oraz
                                ; adres powrotu
  ...
  LDMFD SP!, {R4, R5, R6, PC}   ; zdejmij je ze stosu wpisując
                                ; adres powrotu do PC

Konwencje dotyczące procedur można znaleźć w dokumencie Procedure Call Standard. Ogólnie, cztery pierwsze argumenty, o ile nie są większe niż 32-bitowe, umieszcza się w rejestrach R0-R3, a wynik zwraca się w R0 (jeśli nie jest za duży). Procedury muszą zachowywać rejestry R4-R11 i R13 (przypominam, że R13 to SP).

Stałe liczbowe

Wiele rozkazów pozwala na stałe liczbowe jako operandy. Ponieważ kody wszystkich rozkazów zajmują 32-bity to oczywiście nie da się w nim umieścić dowolnej 32-bitowej stałej tak, by było miejsce na jeszcze inne informacje. Można więc umieścić tylko niektóre stałe (patrz punkt 5.1.2 ARM ARM). Inne trzeba wczytywać z pamięci jako dane. Asembler GNU udostępnia specjalną składnię:

  LDR R1, =123456
umieści w pamięci stałą 123456 i wygeneruje rozkaz ładujący ją do R1.

Środowisko programistyczne

Można popracować z asemblerem procesorów ARM nawet nie mając maszyny opartej o taki procesor. Narzędzia programistyczne GNU można skompilować tak, by działały na komputerze, który mamy (np. z procesorem z rodziny IA-32) i generowały kod dla procesorów ARM. Kompilator generujący kod na inną platformę niż ta, na której sam działa nazywa się czasem cross-kompilatorem. Debuger GDB zaś pozwala uruchamiać programy na procesory ARM w symulowanym środowisku.

Potrzebne będą pakiety binutils, gcc, gdb oraz newlib. Newlib to taka uproszczona biblioteka C. Kompilacja wymaga kilkuset MB przestrzeni dyskowej, więc trudno ją przeprowadzić na kontach studenckich. Dla chętnych przedstawiam krótką instrukcję kompilacji. Najpierw należy ściągnąć pewne pliki:

oraz umieścić je w jednym katalogu. Następnie należy je rozpakować:
tar xjf binutils-2.16.1.tar.bz2 
tar xjf gcc-4.0.3.tar.bz2
tar xjf gdb-6.4.tar.bz2
tar xzf newlib-1.14.0.tar.gz
Zaleca się kompilację poza katalogami ze źródłami, więc trzeba stworzyć katalogi do kompilacji:
mkdir build
cd build
mkdir binutils
mkdir gcc
mkdir gdb
mkdir newlib
Kompilujemy binutils. Opcja --target określa architekturę, na którą będzie generowany kod. Opcja --prefix określa katalog, w którym zostaną zainstalowane skompilowane programy. Pracując na własnym komputerze można tę opcję pominąć, wtedy instalacja zostanie dokonana w katalogu /usr/local/. W tym wypadku do instalacji będą potrzebne prawa roota.
cd binutils
../../binutils-2.16.1/configure --target=arm-elf --prefix=$HOME/arm
make
make install
Przed dalszą kompilacją należy zapewnić by nowe binutils były w ścieżce poszukiwania.
cd ../gcc
export PATH=$HOME/arm/bin:$PATH
../../gcc-4.0.3/configure --target=arm-elf --prefix=$HOME/arm --enable-languages=c
make
make install
cd ../gdb
 ../../gdb-6.4/configure --target=arm-elf --prefix=$HOME/arm
make
make install
cd ../newlib
../../newlib-1.14.0/configure --target=arm-elf --prefix=$HOME/arm
make
make install

Przygotowane środowisko programistyczne jest też na moim koncie na serwerze students w katalogu /home/inf/t/tm201069/arm. Nie miałem niestety okazji, by się przekonać, czy jest dostępne z innych kont niż moje, ale mam nadzieję, że uprawnienia są dobrze ustawione. Aby z niego korzystać wystarczy wykonać polecenie:

export PATH=/home/inf/t/tm201069/arm/bin:$PATH

Przykład programu

Napiszemy program w C korzystający z procedury asemblerowej. Będziemy używali asemblera GNU. W przypadku procesorów ARM używa on takiej samej składni jak proponowana przez producenta. O szczegółach składniowych niezależnych od procesora (dyrektywy, komentarze, itp.) można poczytać np. w info (info gas).

Plik a.c:

#include <stdio.h>

int pomnoz(int a);

int main(void) {
  printf("%d", pomnoz(5));
  return 0;
}
Plik b.s:
        .text
        .global pomnoz

pomnoz: ldr     r1, =1234567
        mul     r0, r1, r0
        mov     pc, lr
Program można skompilować jednym poleceniem:
arm-elf-gcc -o program a.c b.s

Aby go uruchomić należy skorzystać z GDB:

arm-elf-gdb program
i dalej w GDB:
target sim
load
run

Przypominam, że GCC ma opcję -S pozwalającą obejrzeć wygenerowany kod.