1. Wskazówki i porady

Poniżej znajduje się kilka przykładów użycia wbudowanego assemblera oraz informacji o tym, jak obejść jego ograniczenia. W tym dokumencie termin „funkcja assemblerowa” odnosi się do funkcji zadeklarowanej w Pythonie z dekoratorem @micropython.asm_thumb, natomiast „podprogram” odnosi się do kodu assemblerowego wywoływanego z wnętrza funkcji assemblerowej.

1.1. Rozgałęzienia kodu i podprogramy

Ważne jest, aby zrozumieć, że etykiety są lokalne dla funkcji assemblerowej. Obecnie nie ma możliwości wywołania podprogramu zdefiniowanego w jednej funkcji z innej funkcji.

Aby wywołać podprogram, wydawana jest instrukcja bl(LABEL). Przenosi ona sterowanie do instrukcji następującej po dyrektywie label(LABEL) i zapisuje adres powrotu w rejestrze łącza (lr lub r14). Aby powrócić, wydawana jest instrukcja bx(lr), która powoduje kontynuację wykonywania od instrukcji następującej po wywołaniu podprogramu. Mechanizm ten oznacza, że jeśli podprogram ma wywołać inny podprogram, musi zapisać rejestr łącza przed wywołaniem i przywrócić go przed zakończeniem.

Poniższy, nieco wymyślony przykład ilustruje wywołanie funkcji. Należy zauważyć, że na początku konieczne jest rozgałęzienie omijające wszystkie wywołania podprogramów: podprogramy kończą wykonywanie instrukcją bx(lr), podczas gdy funkcja zewnętrzna po prostu „dochodzi do końca” w stylu funkcji Pythona.

@micropython.asm_thumb
def quad(r0):
    b(START)
    label(DOUBLE)
    add(r0, r0, r0)
    bx(lr)
    label(START)
    bl(DOUBLE)
    bl(DOUBLE)

print(quad(10))

Poniższy przykład kodu demonstruje zagnieżdżone (rekurencyjne) wywołanie: klasyczny ciąg Fibonacciego. Tutaj, przed wywołaniem rekurencyjnym, rejestr łącza jest zapisywany wraz z innymi rejestrami, których logika programu wymaga zachowania.

@micropython.asm_thumb
def fib(r0):
    b(START)
    label(DOFIB)
    push({r1, r2, lr})
    cmp(r0, 1)
    ble(FIBDONE)
    sub(r0, 1)
    mov(r2, r0) # r2 = n -1
    bl(DOFIB)
    mov(r1, r0) # r1 = fib(n -1)
    sub(r0, r2, 1)
    bl(DOFIB)   # r0 = fib(n -2)
    add(r0, r0, r1)
    label(FIBDONE)
    pop({r1, r2, lr})
    bx(lr)
    label(START)
    bl(DOFIB)

for n in range(10):
    print(fib(n))

1.2. Przekazywanie argumentów i zwracanie wartości

Funkcje assemblerowe mogą obsługiwać od zera do trzech argumentów, które (jeśli są używane) muszą mieć nazwy r0, r1 i r2. Gdy kod jest wykonywany, rejestry zostaną zainicjowane tymi wartościami.

Typy danych, które można przekazywać w ten sposób, to liczby całkowite i adresy pamięci. W obecnym oprogramowaniu układowym można przekazywać i zwracać wszystkie możliwe wartości 32-bitowe. Jeśli wartość zwracana może mieć ustawiony najbardziej znaczący bit, należy zastosować podpowiedź typu Pythona, aby umożliwić MicroPythonowi określenie, czy wartość powinna być interpretowana jako liczba całkowita ze znakiem czy bez znaku: typy to int lub uint.

@micropython.asm_thumb
def uadd(r0, r1) -> uint:
    add(r0, r0, r1)

hex(uadd(0x40000000,0x40000000)) zwróci 0x80000000, demonstrując przekazywanie i zwracanie liczb całkowitych, gdzie bity 30 i 31 różnią się.

Ograniczenia dotyczące liczby argumentów i wartości zwracanych można obejść za pomocą modułu array, który umożliwia dostęp do dowolnej liczby wartości dowolnego typu.

1.2.1. Wiele argumentów

Jeśli tablica liczb całkowitych Pythona zostanie przekazana jako argument do funkcji assemblerowej, funkcja otrzyma adres ciągłego zbioru liczb całkowitych. W ten sposób wiele argumentów można przekazać jako elementy pojedynczej tablicy. Podobnie funkcja może zwrócić wiele wartości, przypisując je do elementów tablicy. Funkcje assemblerowe nie mają możliwości określenia długości tablicy: trzeba ją przekazać do funkcji.

Takie wykorzystanie tablic można rozszerzyć, aby umożliwić użycie więcej niż trzech tablic. Robi się to za pomocą pośredniego adresowania: moduł uctypes obsługuje addressof(), który zwraca adres tablicy przekazanej jako jego argument. W ten sposób można wypełnić tablicę liczb całkowitych adresami innych tablic:

from uctypes import addressof
@micropython.asm_thumb
def getindirect(r0):
    ldr(r0, [r0, 0]) # Address of array loaded from passed array
    ldr(r0, [r0, 4]) # Return element 1 of indirect array (24)

def testindirect():
    a = array.array('i',[23, 24])
    b = array.array('i',[0,0])
    b[0] = addressof(a)
    print(getindirect(b))

1.2.2. Niecałkowite typy danych

Mogą one być obsługiwane za pomocą tablic odpowiedniego typu danych. Na przykład dane zmiennoprzecinkowe pojedynczej precyzji można przetwarzać w następujący sposób. Ten przykład kodu pobiera tablicę liczb zmiennoprzecinkowych i zastępuje jej zawartość ich kwadratami.

from array import array

@micropython.asm_thumb
def square(r0, r1):
    label(LOOP)
    vldr(s0, [r0, 0])
    vmul(s0, s0, s0)
    vstr(s0, [r0, 0])
    add(r0, 4)
    sub(r1, 1)
    bgt(LOOP)

a = array('f', (x for x in range(10)))
square(a, len(a))
print(a)

Moduł uctypes obsługuje wykorzystanie struktur danych wykraczających poza proste tablice. Umożliwia odwzorowanie struktury danych Pythona na instancję bytearray, którą można następnie przekazać do funkcji assemblerowej.

1.3. Nazwane stałe

Kod assemblerowy można uczynić bardziej czytelnym i łatwiejszym w utrzymaniu, używając nazwanych stałych zamiast zaśmiecać kod liczbami. Można to osiągnąć w następujący sposób:

MYDATA = const(33)

@micropython.asm_thumb
def foo():
    mov(r0, MYDATA)

Konstrukcja const() powoduje, że MicroPython zastępuje nazwę zmiennej jej wartością w czasie kompilacji. Jeśli stałe są zadeklarowane w zewnętrznym zakresie Pythona, mogą być współdzielone między wieloma funkcjami assemblerowymi oraz z kodem Pythona.

1.4. Kod assemblerowy jako metody klasy

MicroPython przekazuje adres instancji obiektu jako pierwszy argument do metod klasy. Jest to zwykle mało przydatne dla funkcji assemblerowej. Można tego uniknąć, deklarując funkcję jako metodę statyczną w następujący sposób:

class foo:
  @staticmethod
  @micropython.asm_thumb
  def bar(r0):
    add(r0, r0, r0)

1.5. Użycie nieobsługiwanych instrukcji

Można je zakodować za pomocą instrukcji data, jak pokazano poniżej. Chociaż push() i pop() są obsługiwane, poniższy przykład ilustruje zasadę. Niezbędny kod maszynowy można znaleźć w ARM v7-M Architecture Reference Manual. Należy zauważyć, że pierwszy argument wywołań data, takich jak

data(2, 0xe92d, 0x0f00) # push r8,r9,r10,r11

wskazuje, że każdy kolejny argument jest wielkością dwubajtową.

1.6. Pokonywanie ograniczenia liczb całkowitych w MicroPythonie

Małe liczby całkowite MicroPythona na portach 32-bitowych nie mogą przechowywać wartości, której bity 30 i 31 różnią się (na przykład 0x80000000), więc podprogram assemblerowy, który produkuje pełny 32-bitowy wynik, nie może go po prostu zwrócić bezpośrednio. To ograniczenie można pokonać za pomocą poniższego kodu, który używa assemblera do umieszczenia wyniku w tablicy oraz kodu Pythona do przekształcenia wyniku w liczbę całkowitą bez znaku o dowolnej precyzji.

from array import array

@micropython.asm_thumb
def getval(r0):
    movwt(r1, 0x80000000)  # a 32-bit value whose bits 30 and 31 differ
    str(r1, [r0, 0])

def get():
    a = array('i', [0])
    getval(a)
    return a[0] & 0xffffffff  # coerce to arbitrary precision

print(hex(get()))  # 0x80000000