Strona główna Podstawy teoretyczne Poradniki Przykłady

Miksowanie kodu C i asemblera przy użyciu AVR-GCC

Podstawowe wiadomości Zasadność mieszania kodu ASM i C Preprocesor Deklaracje kontra etykiety - używanie słowa kluczowego .extern Kwestia optymalizacji kompilatora C Różnice w składni asemblera Dołączanie plików z definicjami mikrokontrolera Nadawanie rejestrom nazw symbolicznych Definiowanie stałych Wydobywanie poszczególnych bajtów ze stałych wielobajtowych Sekcje / segmenty Deklarowanie stałych w pamięci FLASH lub EEPROM Deklarowanie zmiennych w pamięci RAM Operacje na rejestrach wejścia/wyjścia Reguły stosowane przez kompilator AVR‑GCC Przekazywanie argumentów do funkcji Wartości zwracane przez funkcje Rejestry stałe Rejestry niszczone przez funkcje Rejestry zachowywane przez funkcje Miksowanie Współdzielenie zmiennych Zmienna zadeklarowana w pliku C dostępna dla kodu ASM Zmienna zadeklarowana w pliku ASM dostępna dla kodu C Wywoływanie funkcji napisanej w ASM z kodu napisanego w C Funkcja bez parametrów nie zwracająca wartości Funkcja z parametrami nie zwracająca wartości Funkcja bez parametrów zwracająca wartość Funkcja z parametrami zwracająca wartość Procedura obsługi przerwania Wywoływanie funkcji napisanej w C z kodu napisanego w ASM Funkcja bez parametrów nie zwracająca wartości Funkcja z parametrami nie zwracająca wartości Funkcja bez parametrów zwracająca wartość Funkcja z parametrami zwracająca wartość Funkcja z biblioteki standardowej C Korzystanie ze wspólnego pliku nagłówkowego Podsumowanie

 

Podstawowe wiadomości

Zakładam, że każdy z czytających ten artykuł posiada podstawową wiedzę z dziedziny programowania zarówno w C jak i w asemblerze, dlatego nie będę tu zbyt szczegółowo wszystkiego opisywał. Przypomnę jednak kilka pojęć istotnych dla omawianego tematu i przedstawię kilka ważnych uwag.

Dla uproszczenia ograniczę się do opisania programów, których objętość danych w pamięci FLASH nie przekracza 64KB, a łącznie z kodem wykonywalnym 128KB. Powyżej tej granicy mieszanie kodu nieco się komplikuje ze względu na konieczność użycia wskaźników o rozmiarze przekraczającym 16 bitów, ale to raczej temat na inny artykuł. Myślę jednak, że to ograniczenie nie będzie większym problemem, gdyż linker umieszcza dane w pamięci FLASH na samym początku, zaraz po wektorach przerwań, dopiero później funkcje. Sytuacje, kiedy zapisujemy do FLASH dane o rozmiarze większym od 64KB nie zdarzają się chyba często, więc w większości przypadków 16-bitowe adresowanie będzie wystarczające.

Opisane tutaj zasady działania kompilatora i sposoby mieszania kodu dotyczą głównie aktualnego toolchain'a Atmela w wersji 3.5.3.1700. Jak powszechnie wiadomo oprogramowanie w dzisiejszych czasach dosyć szybko ewoluuje, więc nie mogę zagwarantować, że dokładnie te same zasady we wszystkich szczegółach dotyczą starszych wersji i będą dotyczyły nowszych wersji toolchain'a.

Dla ścisłości dodam jeszcze, że z lenistwa zamiast długich sformułowań w stylu 'język asemblera' czy też 'kod w języku asemblera' często będę używał skrótu ASM.

• Zasadność mieszania kodu ASM i C

Właściwie to potrzeba wstawiania kodu asemblera do kodu w języku C nie zdarza się zbyt często. Obecnie kompilatory C potrafią naprawdę bardzo dobrze optymalizować kod. Zwykle robią to równie dobrze lub nawet lepiej niż programista, szczególnie w przypadku tych bardziej skomplikowanych funkcji.

Zdarzają się jednak sytuacje, kiedy (moim zdaniem) takie wstawki mają sens. Przypuśćmy przykładowo, że potrzebujemy wydajnej (szybkiej) funkcji lub procedury obsługi przerwania, w której optymalnym typem danych będzie liczba całkowita np. 40-bitowa (5 bajtów). Język C nie wspiera wprost operacji na takich liczbach.

Można wprawdzie zastosować typ nadmiarowy 64-bitowy (8 bajtów zamiast 5), ale to spowoduje (mowa oczywiście o mikrokontrolerze 8-bitowym), że każde ładowanie zmiennej z pamięci do rejestrów i odwrotnie, wszelkie operacje dodawania, odejmowania, porównania i logiczne (nie wspominając o ewentualnej konieczności maskowania nieużywanych bajtów) będą zajmowały kilka (3, 6 lub nawet 9) taktów więcej, niż byłoby to konieczne dla operacji na pięciu bajtach, zamiast na ośmiu. W przypadku operacji mnożenia lub dzielenia te różnice mogą sięgać nawet kilkudziesięciu taktów, szczególnie w mikrokontrolerach nie obsługujących instrukcji mul, muls itp.

Można też ewentualnie próbować obejść problem stosując jakieś triki ze strukturami i/lub uniami i/lub tablicami, ale to z kolei gmatwa kod i w dodatku wcale nie daje gwarancji, że będzie on optymalny. Dlatego (przynajmniej dla mnie) łatwiej rozwiązać zadanie za pomocą funkcji napisanej w asemblerze.

Pewnie znalazłyby się jeszcze inne argumenty do stosowania wstawek, ale pozostawmy może te rozważania. Pokażę po prostu jak to technicznie wykonać, a każdy sam zdecyduje, czy i kiedy wstawki stosować. Oczywiście nie polecam nadużywania, ponieważ to na prawdę w niektórych przypadkach może nawet pogorszyć sprawę. Można najpierw poeksperymentować, pisząc takie same funkcje w języku C i w ASM sprawdzając, kto wygeneruje wydajniejszy kod wynikowy - programista używający języka asemblera czy kompilator C.

• Preprocesor

W AVR GCC pliki zawierające kod asemblera mają rozszerzenia *.s (małe s) lub *.S (duże S). Aplikacja tłumacząca kod asemblera na wynikowy kod maszynowy (avr-as.exe) podczas procesu budowania nie jest wywoływana bezpośrednio, lecz poprzez aplikację avr-gcc.exe, która przed uruchomieniem avr-as.exe decyduje o użyciu lub nie użyciu preprocesora. Kiedyś decyzja ta podejmowana była właśnie na podstawie rozszerzenia pliku. W przypadku dużego S preprocesor był używany, a w przypadku małego s – nie.

Podobno w systemach operacyjnych, które nie biorą pod uwagę wielkości liter w nazwach plików (np. Windows) były z tym jakieś problemy, więc z tego i/lub z innych powodów zasada ta została zmieniona. Obecnie opcja programu avr-gcc.exe

-x assembler

oznacza przekazanie pliku bezpośrednio do asemblacji, natomiast

-x assembler-with-cpp

wymusza wcześniejsze użycie preprocesora.

Dlaczego właściwie preprocesor C ma przetwarzać plik z kodem ASM?
Otóż skoro już miksujemy kod C i ASM, to przecież fajnie byłoby mieć pewne stałe zdefiniowane w programie dostępne jednocześnie w obu kodach. Trochę niewygodnie byłoby definiować stałą w dwóch miejscach (ryzyko pomyłki) i później jeszcze pamiętać, żeby ją w razie potrzeby zmienić w dwóch miejscach. Program asemblujący nie rozumie jednak tych wszystkich komentarzy, dyrektyw typowych dla języka C i będzie generował błędy składni. Jednak dzięki preprocesorowi możemy dołączyć ten sam plik nagłówkowy do obu kodów, a preprocesor przed asemblacją pliku z kodem ASM zamieni wszystkie symbole na ich zdefiniowane wartości, po czym pousuwa dyrektywy preprocesora i komentarze C-style, dzięki czemu asembler będzie mógł prawidłowo wygenerować kod wynikowy.

Preprocesor będzie więc nam potrzebny. W Atmel Studio 7 opcja użycia preprocesora jest domyślnie włączona zarówno dla plików typu „Assembler File (.s)” jak i dla „Preprocessing Assembler File (.S)”. Zgodnie z moją wiedzą nie można tej opcji wyłączyć inaczej, jak tylko poprzez ręczne edytowanie pliku Makefile. Dopóki więc będziemy używać automatycznie generowanego Makefile, nie powinno być problemów.

W Eclipse preprocesor dla plików z ASM również powinien być domyślnie włączony, ale tutaj można go wyłączyć (choć my nie powinniśmy tego robić, chyba że mamy uzasadniony powód) wyłączając opcję „Use preprocessor” we właściwościach projektu:
Project->Properties->C/C++ Build->Settings->zakładka Tool Settings->AVR Assembler->General
(jeśli dołączymy kilka plików z kodem ASM, można tę opcję włączyć lub wyłączyć dla każdego pliku osobno we właściwościach pliku). Tutaj też nie powinno być problemu, dopóki ktoś tej opcji nie wyłączy.

Mimo tego, że w obu środowiskach opcja preprocesora jest domyślnie włączona, postanowiłem o tym napisać, bo moim zdaniem dobrze jest wiedzieć o co chodzi z tym preprocesorem, szczególnie gdyby ktoś korzystał ze starszej wersji avr-gcc (w dodatku w systemie unixowym), ewentualnie uruchamiał kompilację z wiersza poleceń lub zechciał ręcznie edytować plik Makefile.

• Deklaracje kontra etykiety - używanie słowa kluczowego .extern

Kompilator języka C wymaga od programisty precyzyjnego zadeklarowania zmiennych i funkcji. Potrzebuje tych informacji głównie w celu wygenerowania poprawnego kodu maszynowego (np. użycie właściwych instrukcji ASM w zależności od tego, czy zmienna całkowita jest ze znakiem, czy bez) oraz analizy semantycznej (np. kontrola typów podczas przypisania wartości jednej zmiennej do innej lub argumentów przekazywanych do funkcji).

W przypadku asemblera to na programiście spoczywa obowiązek prawidłowej obsługi poszczególnych zmiennych, przekazywania prawidłowych argumentów do funkcji i wykonywania wszystkich innych zadań, które wykonuje kompilator C. Tutaj deklaracja zmiennej czy funkcji odbywa się poprzez nadanie im etykiet, które po zakończeniu budowania aplikacji stają się liczbami reprezentującymi adres komórki pamięci RAM lub FLASH. Etykiety nie mówią nic o typie zmiennej, o ilości i typach argumentów przekazywanych do funkcji, o wartości zwracanej przez funkcję. Ba, na podstawie samej etykiety nie można nawet stwierdzić, czy dotyczy ona zmiennej, stałej w pamięci programu czy może funkcji.

Piszę o tym, ponieważ czasami spotykam próby deklarowania w pliku ASM konstrukcje tego rodzaju:

.extern uint8_t nazwa

co oczywiście nie ma większego sensu z dwóch powodów. Po pierwsze dla asemblera typ uint8_t i tak jest niezrozumiały - liczy się tylko słowo 'nazwa' będące dla asemblera etykietą (symbolem zmiennej). Po drugie samo użycie .extern jest przez GCC assembler akceptowane ze względu na kompatybilność z innymi asemblerami, nie jest jednak wymagane, gdyż program as (w naszym przypadku avr-as) traktuje wszystkie niezdefiniowane (w danym pliku) symbole jako .extern. Jeśli tylko w innym pliku naszego projektu lub w bibliotekach standardowych istnieje taki symbol (nazwa zmiennej lub funkcji), asemblacja przebiegnie bez błędów. Czy kod będzie działał prawidłowo, to już inna sprawa. Zależy to tylko od tego, czy programista nie pomyli np. typów zmiennych lub nazwy funkcji z nazwą zmiennej itp.

• Kwestia optymalizacji kompilatora C

W czasie wykonywania każdej funkcji używane są (przynajmniej niektóre) rejestry - nie da się tego uniknąć. Należy więc liczyć się z tym, że zawartość niektórych rejestrów po wykonaniu funkcji może być inna, niż przed jej wykonaniem. Jeśli nie chcemy utracić zawartości jakichś rejestrów, należy je przed wywołaniem funkcji zapamiętać (np. na stosie) lub przechować te wartości w rejestrach, o których wiemy, że nie zostaną zmienione. Zapamiętywanie rejestrów na stosie wiąże się z zaangażowaniem pewnych zasobów (np. czasu procesora, zajętości RAM), lepiej więc tego unikać.

Jedną ze strategii optymalizacyjnych kompilatora C jest właśnie takie dobieranie rejestrów w trakcie wykonywania programu, aby zminimalizować potrzebę zapamiętywania ich wartości. Niestety dołączając do projektu pliki ASM, w których "na sztywno" definiujemy rejestry użyte wewnątrz funkcji, zawężamy mu nieco pole manewru, przez co optymalizacja może być mniej skuteczna. Lepsze efekty pod tym względem daje inline assembler, gdyż pozostawia kompilatorowi dobór rejestrów wykorzystywanych we funkcji, jednak jak dla mnie jego składnia jest stosunkowo trudna, a kod mało czytelny i osobiście nie lubię go stosować, chyba że do definiowana naprawdę prostych funkcji.

Istnieje jeszcze jeden problem podobnej natury. Zdarza się, że chcąc przyspieszyć dostęp do jakichś newralgicznych danych, zechcemy zarezerwować dla jakiejś zmiennej (lub kilku) rejestr (rejestry) mikrokontrolera. Można to oczywiście zrobić używając słowa kluczowego 'register' deklarując zmienną globalną (stosunkowo bezpiecznie jest używać do tego celu rejestry z zakresu r2-r7):

Język: C Zwiń
  1. register uint8_t my_var asm("r2");

jednak trzeba mieć świadomość, że to zablokuje dostęp do tego rejestru kompilatorowi, również utrudniając mu zadanie optymalizacji.

Nie należy się tym oczywiście zniechęcać. Dobrze jest jednak o tym wiedzieć i pamiętać, aby nie przesadzać z długością i ilością funkcji pisanych w ASM oraz z ilością używanych w nich rejestrów.

Na koniec mam też dobrą wiadomość. Przerwania w mikrokontrolerze występują w zupełnie przypadkowych momentach. Skoro nie da się przewidzieć tych momentów, kompilator i tak nie będzie mógł zoptymalizować kodu poprzez taki dobór rejestrów, by nie trzeba było ich zapamiętywać, gdyż (ze względu na tę przypadkowość) procedura obsługi przerwania musi zawsze zapamiętać wszystkie rejestry oraz rejestr statusowy SREG na początku procedury i przywrócić ich stan przed powrotem do programu głównego. Dlatego też napisanie procedury obsługi przerwania w ASM nie wpłynie na możliwości optymalizacyjne kompilatora C. Ewentualne użycie nadmiernej liczby rejestrów wpłynie jedynie na czas wykonania samej procedury, ale nie wpłynie na optymalizację kompilatora C. W związku z tym właśnie w procedurach obsługi przerwań będziemy mieli prawdziwe pole do popisu.

Różnice w składni asemblera

Asembler AVR GCC i ten Atmela różnią się nieco składnią. Oczywiście nie dotyczy to samych instrukcji ASM (czyli tzw. mnemoników), a raczej sposobu deklarowania stałych, danych w pamięci RAM lub FLASH, dołączania plików itp. Odnoszę wrażenie, że trochę trudno dotrzeć do szczegółowej dokumentacji, więc przedstawię najważniejsze różnice, które udało mi się znaleźć.

• Dołączanie plików z definicjami mikrokontrolera:

Atmel assembler (avrasm2) GCC assembler (avr-as)
.include "m644pdef.inc"
#include "m644pdef.inc"
#include <avr/io.h>

• Nadawanie rejestrom nazw symbolicznych:

Atmel assembler (avrasm2) GCC assembler (avr-as)
.def my_reg=r16 #define my_reg r16
my_reg = 16

• Definiowanie stałych:

Atmel assembler (avrasm2) GCC assembler (avr-as) Uwagi
.equ mask=0x21 #define mask 0x21
.equ mask, 0x21
nie można zmienić
.set mask=0x21 .set mask, 0x21 można zmienić w dalszej
części programu

• Wydobywanie poszczególnych bajtów ze stałych wielobajtowych.

Kiedy zdefiniujemy stałą, której wartość wykracza poza 8 bitów, i będziemy chcieli ją załadować do kilku rejestrów, musimy wydobyć jakoś poszczególne bajty z takiej stałej. Służą do tego specjalne instrukcje dla preprocesora umieszczane w kodzie:

bity Atmel assembler (avrasm2) GCC assembler (avr-as)
 0 -  7 low(expression) lo8(expression)
 8 - 15 high(expression)
byte2(expression)
hi8(expression)
16 - 23 byte3(expression) hh8(expression)
hlo8(expression)
24 - 31 byte4(expression) hhi8(expression)

Przykład dla GCC assembler:

Język: AVR assembler Zwiń
  1. #define moja_stala_32bit 1258453 ; hex 00 13 33 D5
  2.  
  3. .section .text
  4.  
  5. load_const_to_registers:
  6. ldi r16, lo8(moja_stala_32bit) ; r16 = 0xD5
  7. ldi r17, hi8(moja_stala_32bit) ; r17 = 0x33
  8. ldi r18, hlo8(moja_stala_32bit) ; r18 = 0x13
  9. ldi r19, hhi8(moja_stala_32bit) ; r19 = 0x00
  10. ret

AVR GCC assembler oferuje dodatkowo instrukcje, które są przydatne przy pobieraniu adresu, gdyż przetwarzają adres bajtu we FLASH na adres słowa. Jest to przydatne na przykład, gdy chcemy wywołać funkcję poprzez wskaźnik za pomocą instrukcji ASM icall (indirect call) lub wykonać skok do określonej przez rejestr wskaźnikowy lokalizacji w programie za pomocą instrukcji ijmp (indirect jump).

bity GCC assembler (avr-as) Uwagi
 0 -  7 pm_lo8(expression)
 8 - 15 pm_hi8(expression)
16 - 23 pm_hh8(expression) tylko w przypadku pamięci flash powyżej 64K słów (128KiB)

Przykład:

Język: AVR assembler Zwiń
  1. .section .text
  2.  
  3. funkcja_1:
  4. ldi zl, pm_lo8(funkcja_2)
  5. ldi zh, pm_hi8(funkcja_2)
  6. icall
  7. ret
  8.  
  9. funkcja_2:
  10. ret

• Sekcje / segmenty.

Atmel assembler (avrasm2) GCC assembler (avr-as) Opis
.dseg .section .data data segment
(segment danych)
pamięć RAM
.cseg .section .text code segment
(segment kodu)
pamięć FLASH
.eseg .section .eeprom eeprom segment
(segment kodu)
pamięć EEPROM

• Deklarowanie stałych w pamięci FLASH lub EEPROM:

Oczywiście deklaracje należy umieszczać w odpowiedniej sekcji (.section .text lub .section .eeprom) oraz przed każdą deklaracją musi znajdować się etykieta, ponieważ tylko w ten sposób będzie można określić adres, pod którym dane się znajdują.

Typ danych Atmel assembler (avrasm2) GCC assembler (avr-as)
ciąg znaków
(zakończony znakiem '\0')
.db „example text”, 0 .asciz „example text”
(znak '\0' jest dodawany automatycznie)
stałe 1-bajtowe .db 0, 0x26, 0b01101001 .byte 0, 0x26, 0b01101001
stałe 2-bajtowe .dw 0, 5846, 0x15AF .word 0, 5846, 0x15AF
stałe 4-bajtowe .dd 15, 158933, 0x056A0D2E .long 15, 158933, 0x056A0D2E
stałe 8-bajtowe .dq 689958741, 0x54A6d32F5011AE1C .quad 689958741, 0x54A6d32F5011AE1C

• Deklarowanie zmiennych w pamięci RAM:

Tak jak poprzednio należy również pamiętać o umieszczeniu deklaracji w odpowiedniej sekcji (tym razem w .section .data) i opatrzeniu ich etykietami.

Typ danych Atmel assembler (avrasm2) GCC assembler (avr-as)
ciąg znaków
(zakończony
znakiem '\0')
.byte 13
; liczba 13 oznacza ilość znaków w ciągu 
; +1 na znak '\0'
.asciz „example text”
; znak '\0' jest dodawany
; automatycznie
zmienne 1‑bajtowe
(8‑bit)
.byte 3  ; rezerwuje 3 bajty .byte 0, 0x26, 0b01101001 ; 3 bajty
zmienne 2‑bajtowe
(16‑bit)
.byte 6  ; rezerwuje 3*2 bajty .word  0, 5846, 0x15AF ; 3*2 bajty
zmienne 4‑bajtowe
(32‑bit)
.byte 12 ; rezerwuje 3*4 bajty .long 15, 158933, 0x056A0D2E ; 3*4 bajty
zmienne 8‑bajtowe
(64‑bit)
.byte 16 ; rezerwuje 2*8 bajtów .quad 689958741, 0x54A6d32F5011AE1C ; 2*8 bajtów

Tutaj jednak różnice nie dotyczą tylko składni. W przypadku projektu napisanego tylko w języku asemblera (Atmel assembler), z przyczyn oczywistych na programiście spoczywa obowiązek zainicjowania zmiennych odpowiednimi wartościami przed ich użyciem.

Jeśli tworzymy projekt w języku C, to kompilator powinien zainicjować za nas zmienne zadeklarowane w kodzie ASM wartościami, które podamy przy deklaracji, np.:

Język: AVR assembler Zwiń
  1. .section .data
  2. my_asm_var: .byte 7 ; to polecenie rezerwuje miejsce dla zmiennej jednobajtowej
  3. ; i przypisuje jej wartość 7
  4. my_asm_var: .byte 7, 19, 121 ; to polecenie rezerwuje miejsce dla tablicy trzech
  5. ; elementów jednobajtowych i przypisuje im wartości 7, 19 i 121

Z moich doświadczeń wynika jednak, że nie zawsze to robi (nie udało mi się znaleźć odpowiedzi na pytanie, czy jest to działanie zamierzone).

Jeśli w kodzie C będziemy mieli zadeklarowaną co najmniej jedną zmienną statyczną z przypisaniem wartości niezerowej, np.:

Język: C Zwiń
  1. // globalnie, czyli poza funkcjami
  2. uint8_t my_c_var = 5;
  3. // lub wewnątrz jakiejś funkcji
  4. static uint8_t my_c_var = 5;

to kompilator C wygeneruje kod, który podczas startu mikrokontrolera zainicjuje zarówno zmienne zadeklarowane w kodzie C, jak i te zadeklarowane w kodzie ASM.

Jeśli w kodzie C nie będziemy mieli zadeklarowanej takiej zmiennej, o której mowa powyżej, to kompilator zwyczajnie podczas startu mikrokontrolera wyzeruje tylko wszystkie komórki pamięci przeznaczone na zmienne (zadeklarowane w kodzie C), uwzględni wprawdzie miejsce na zmienne zadeklarowane w ASM, ale pominie tworzenie kodu inicjującego wartości.

Jeśli wystąpi taka sytuacja, a zależy nam na zainicjowaniu zmiennych zadeklarowanych w kodzie ASM konkretnymi wartościami podczas startu, mamy dwa sposoby rozwiązania problemu:

  1. Przeniesienie definicji zmiennej do pliku C i tam przypisanie jej wartości (później opcjonalnie zadeklarowanie w pliku ASM jako .extern, choć nie jest to wymagane). Jeśli jednak nie będzie to zmienna współdzielona przez oba kody, to definiowanie zmiennej z kodu ASM w pliku z kodem w C nie jest (moim zdaniem) dobrym rozwiązaniem, dlatego że zmienna używana tylko w pliku ASM powinna być tylko dla tego pliku widoczna (osiągalna).
  2. Dopisanie w kodzie ASM procedury wpisującej wartości do odpowiednich komórek pamięci, i umieszczenie jej np. w sekcji .init1, której kod jest wykonywany podczas startu mikrokontrolera, jeszcze przed wejściem do funkcji main().

Przykład:

Język: AVR assembler Zwiń
  1. #include <avr/io.h>
  2. ; ewentualne definicje
  3.  
  4. .section .data
  5. ; zmienna, która ma być widoczna tylko w pliku asm
  6. moja_zmienna_asm: .byte 0x21
  7.  
  8. ; zainicjowanie wartości zmiennej
  9. .section .init1,"ax",@progbits
  10.  
  11. ldi r16, 0x21
  12. sts moja_zmienna_asm, r16
  13.  
  14. .section .text
  15.  
  16. .global moja_funkcja_asm
  17.  
  18. moja_funkcja_asm:
  19. ; tutaj kod funkcji
  20. ret

• Operacje na rejestrach wejścia/wyjścia

Mikrokontrolery posiadają szereg rejestrów specjalnych (tzw. rejestry wejścia/wyjścia, tutaj przyjmijmy skrótową nazwę: rejestry i/o), które służą do sterowania pracą wbudowanych w niego układów peryferyjnych. W mikrokontrolerach AVR 8-bitowych rejestry te są podzielone na dwie grupy.

Pierwsze 64 rejestry mogą być odczytywane i modyfikowane zarówno przez instrukcje asemblera in oraz out operujące na przestrzeni adresowej i/0 jak i poprzez instrukcje load oraz store operujące na przestrzeni adresowej pamięci danych SRAM. Oczywiście nie ma takich instrukcji asemblera load oraz store. Pod tym pojęciem rozumiem tutaj wszystkie instrukcje czytające z pamięci (np. ld, lds) jak i zapisujące do pamięci (np. st, sts).

Jeśli mikrokontroler jest rozbudowany na tyle, że musi mieć więcej niż 64 rejestry (maksymalnie może mieć 160 dodatkowych rejestrów), dodatkowe rejestry są osiągalne tylko poprzez instrukcje load oraz store.

W tej chwili interesuje nas ta pierwsza grupa rejestrów. Ich adresy w przestrzeni i/o mieszczą się w zakresie 0-63 (0x00-0x3F heksadecymalnie), natomiast w przestrzeni adresowej pamięci mają adresy 32-95 (0x20-0x5F heksadecymalnie). Relacja między adresami jest taka, że te w przestrzeni pamięci są większe o 32 (0x20 heksadecymalnie) od tych w przestrzeni i/o.

Z punktu widzenia szybkości wykonania programu korzystniejsze są instrukcje in oraz out, dlatego dobrze jest korzystać z nich (zamiast load oraz store) kiedy to tylko możliwe. Korzystając z Atmel assembler robimy to zwyczajnie pisząc np.:

Język: AVR assembler Zwiń
  1. in r16, PINA

GCC assembler korzysta jednak z tego samego pliku nagłówkowego (<avr/io.h>), co kompilator C. Wszystkie adresy rejestrów i/o są tam zdefiniowane jako adresy w przestrzeni adresowej pamięci. Kiedy kompilator uzna, że należy użyć instrukcji operujących na przestrzeni adresowej i/o, automatycznie odejmuje 0x20 od adresu podczas generowania takiej instrukcji. Niestety, jeśli piszemy wstawki asemblerowe musimy sami zadbać o użycie właściwego adresu. Jeśli więc chcemy użyć instrukcji in lub out (ewentualnie którejś z instrukcji operowania na bitach rejestru i/o, np. sbi), powinniśmy użyć do tego makra _SFR_IO_ADDR(), przykładowo:

Język: AVR assembler Zwiń
  1. in r16, _SFR_IO_ADDR(PINA)

Może to być nieco kłopotliwe, istnieje jednak sposób, aby pozbyć się tej niedogodności. Polega on na dodaniu na samym początku pliku ASM (jeszcze przed dyrektywą #include <avr/io.h>) definicji:

Język: AVR assembler Zwiń
  1. #define __SFR_OFFSET 0
  2. #include <avr/io.h>
  3. ; pozostałe definicje dyrektywy
  4. ; jakiś kod
  5. in r16, PINA ; bez konieczności użycia _SFR_IO_ADDR()
  6. ; reszta kodu

We wszystkich przedstawionych tu przykładowych fragmentach kodu zostało przyjęte, że powyższa definicja została umieszczona na początku pliku.

Reguły stosowane przez kompilator AVR‑GCC

Mieszając kod ASM z kodem C musimy stosować takie same reguły co kompilator C. Przykładowo, chcąc wywołać funkcję napisaną w języku C z kodu w ASM, musimy wiedzieć, w jakich rejestrach umieścić argumenty przekazywane do funkcji i w jakich rejestrach oczekiwać wartości zwracanych przez funkcję. W odwrotnym przypadku, gdy piszemy w kodzie ASM funkcję, która będzie wywoływana z kodu C, też musimy wiedzieć, w jakich rejestrach kompilator umieści argumenty i w jakich będzie oczekiwał wartości zwracanej przez naszą funkcję. Poza tym kompilator C traktuje niektóre rejestry lub grupy rejestrów w ściśle określony sposób i my też musimy te same zasady stosować.

• Przekazywanie argumentów do funkcji

W języku C funkcje mogą (choć nie muszą) przyjmować argumenty, czyli innymi słowami jakieś dane, którymi będą operować (wykonywać jakieś obliczenia, porównania itp.). Przekazanie argumentu do funkcji polega na podaniu ich w nawiasie po nazwie funkcji.

W języku ASM samo wywołanie jest stosunkowo proste. Służy do tego celu np. instrukcja call, która ma jeden operand. Tym operandem jest etykieta, która wyznacza adres początku funkcji (dokładniej adres słowa w pamięci FLASH, w którym znajduje się pierwsza instrukcja funkcji).

Skoro instrukcja call ma tylko jeden operand, to jak przekazać argumenty do funkcji?

Można to zrobić na przykład w taki sposób:

Jeśli wywoływana funkcja jest również napisana przez nas w ASM, to możemy do tego celu wyznaczyć dowolne rejestry wedle uznania. Wystarczy, że funkcję napiszemy tak, aby czytała dane z właściwych rejestrów. Jeśli jednak w pliku ASM wywołujemy funkcję napisaną w języku C, musimy wiedzieć, w których rejestrach funkcja ta oczekuje argumentów, ponieważ nie my o tym decydujemy, tylko kompilator.

Na szczęście kompilator C stosuje pewne stałe reguły określające, w których rejestrach mają być przekazywane argumenty do funkcji. Zostały do tego celu wyznaczone rejestry r25 - r8. Rejestry są pogrupowane w pary. Najmniej znaczący bajt argumentu jest zawsze umieszczany w rejestrze o parzystym numerze. Kolejne bajty argumentu są umieszczane w kolejnych rejestrach o wyższych numerach. Kolejne argumenty są umieszczane w rejestrach poczynając od wyższych numerów, kończąc na niższych. Precyzyjnie sposób wyznaczania rejestrów do przekazania argumentów wygląda następująco:

Za rejestr (nazwijmy go) "bazowy" przyjmujemy r26 i wykonujemy następujące kroki:

  1. Jeśli rozmiar argumentu jest liczbą nieparzystą, dodajemy do rozmiaru 1.
  2. Tak obliczony rozmiar odejmujemy od numeru rejestru bazowego, uzyskując nowy numer rejestru bazowego.
  3. Jeśli nowy numer rejestru bazowego jest większy od r8, będzie do niego wpisany najmniej znaczący bajt naszego argumentu. Kolejne bajty będą wpisane do kolejnych rejestrów (w kierunku wyższych numerów).
  4. Jeśli numer rejestru będzie mniejszy od r8, argument zostanie umieszczony w pamięci RAM. Tutaj nie będziemy rozpatrywać takiego przypadku, ponieważ zakładamy pisanie stosunkowo prostych funkcji jak wstawek ASM. Za pomocą wyznaczonych do tego celu rejestrów możemy do funkcji przekazać maksymalnie 18 bajtów, więc w zdecydowanej większości przypadków będzie to ilość wystarczająca.
  5. Jeśli aktualny argument powinien być umieszczony w pamięci RAM, poprzestajemy na tym, ponieważ to oznacza, że wszystkie następne argumenty również muszą być umieszczone w RAM.
  6. Jeśli mamy do przekazania następny argument, wracamy do punktu 1. (oczywiście przyjmując do obliczeń nowo wyznaczony numer rejestru bazowego).

Wygląda to może nieco skomplikowanie, więc podam kilka przykładów:


Przykład 1:

Język: C Zwiń
  1. void funkcja(uint32_t arg1, int8_t arg2, int16_t arg3);

arg1: 4 bajty

  1. 4 jest liczbą parzystą, więc pozostaje bez zmian.
  2. 26 - 4 = 22 rejestr bazowy dla pierwszego argumentu to r22.
  3. Numer rejestru r22 jest większy od numeru rejestru r8 więc przypisujemy:
  4.     r22 = arg1[byte0]
        r23 = arg1[byte1]
        r24 = arg1[byte2]
        r25 = arg1[byte3]
  5. Nie dotyczy naszego przypadku.
  6. Nie dotyczy naszego przypadku.
  7. Kontynuujemy z następnym argumentem.

arg2: 1 bajt

  1. 1 jest liczbą nieparzystą, więc dodajemy 1 i otrzymujemy rozmiar równy 2
  2. 22 - 2 = 20 rejestr bazowy dla drugiego argumentu to r20.
  3. Numer rejestru r20 jest większy od numeru rejestru r8 więc przypisujemy:
  4.     r20 = arg2[byte0]
  5. Nie dotyczy naszego przypadku.
  6. Nie dotyczy naszego przypadku.
  7. Kontynuujemy z następnym argumentem.

arg3: 2 bajty

  1. 2 jest liczbą parzystą, więc pozostaje bez zmian.
  2. 20 - 2 = 18 rejestr bazowy dla pierwszego argumentu to r18.
  3. Numer rejestru r18 jest większy od numeru rejestru r8 więc przypisujemy:
  4.     r18 = arg3[byte0]
        r19 = arg3[byte1]
  5. Nie dotyczy naszego przypadku.
  6. Nie dotyczy naszego przypadku.
  7. Kończymy - to był ostatni argument.


Przykład 2:

Język: C Zwiń
  1. void funkcja(uint32_t *arg1, int8_t *arg2, uint8_t arg3);

arg1: 2 bajty ( wskaźnik = 16 bit )

        r24 = arg1[byte0]
        r25 = arg1[byte1]

arg2: 2 bajty

        r22 = arg2[byte0]
        r23 = arg2[byte1]

arg3: 1 bajt

        r20 = arg3[byte0]

Przykład 3:

Język: C Zwiń
  1. void funkcja(uint8_t arg1, uint8_t *arg2, int64_t arg3);
        r14 = arg3[byte0]
        r15 = arg3[byte1]
        r16 = arg3[byte2]
        r17 = arg3[byte3]
        r18 = arg3[byte4]
        r19 = arg3[byte5]
        r20 = arg3[byte6]
        r21 = arg3[byte7]
        
        r22 = arg2[byte0]
        r23 = arg2[byte1]
        
        r24 = arg1[byte0]
    

• Wartości zwracane przez funkcje

Jeżeli pisząc w ASM będziemy wywoływali funkcję napisaną w C lub będziemy pisali funkcję w ASM wywoływaną w pliku C, musimy znać też rejestry, w których - po wykonaniu funkcji - znajdzie się wartość zwracana przez tę funkcję (oczywiście tylko wtedy, gdy jakaś wartość ma być zwracana, bo przecież nie zawsze musi być).

Tutaj sprawa wygląda prościej, gdyż zwracana wartość jest jedna, a nie kilka. Wartość zwracana przez funkcję jest umieszczana w rejestrach, jeśli jej rozmiar nie przekroczy 8 bajtów, czyli zdecydowanie mniej niż w przypadku argumentów przekazywanych do funkcji, ale i tak w większości przypadków rozmiar 64-bitowy jest wystarczający. Właściwie w avr-libc nie ma np. typu całkowitego o większym rozmiarze, a typy zmiennoprzecinkowe float oraz double mają po 32-bity. Jedynym przypadkiem przekroczenia limitu będzie więc zwrócenie przez funkcję struktury (poprzez wartość) o rozmiarze większym od ośmiu bajtów. Moim zdaniem jednak zarówno przekazywanie do funkcji, jak i zwracanie przez funkcję dużych struktur poprzez wartość w ośmiobitowych mikrokontrolerach, mających stosunkowo małe pojemności pamięci RAM, nie jest dobrą praktyką.

Wyznaczanie rejestrów wygląda podobnie, jak w przypadku argumentów (rejestr bazowy to także r26), a więc przyporządkowanie rejestrów będzie wyglądać tak:

1 bajt

    [byte0]
      r24

2 bajty

    [byte1]   [byte0]
      r25       r24

4 bajty

    [byte3]   [byte2]   [byte1]   [byte0]
      r25       r24       r23       r22

8 bajtów

    [byte7]   [byte6]   [byte5]   [byte4]   [byte3]   [byte2]   [byte1]   [byte0]
      r25       r24       r23       r22       r21       r20       r19       r18

• Rejestry stałe

Rejestry stałe to rejestry o specjalnym przeznaczeniu, które nie są alokowane przez kompilator wprost do operacji na danych:

• Rejestry niszczone przez funkcje

Każda funkcja musi używać rejestrów, co oznacza, że zawartość rejestrów podczas wykonywania funkcji zostaje zmieniona. Kompilator C przyjmuje, że zawartość rejestrów r18-r27, r30-r31, r0, flaga T w SREG może ulec zniszczeniu podczas działania funkcji.

Jeśli pisząc wstawkę w ASM zależy nam na zachowaniu wartości któregoś z wyżej wymienionych rejestrów po zakończeniu wykonania funkcji napisanej w C, musimy przed jej wywołaniem zapamiętać tę wartość (np. na stosie).

Z kolei pisząc funkcję w ASM, która będzie wywoływana w kodzie C, możemy dowolnie korzystać z tych rejestrów, nie martwiąc się o to, że zniszczymy ich zawartość. Nie trzeba ich zapamiętywać na początku i przywracać na końcu funkcji.

Wyjątkiem są tutaj oczywiście (o czym pisałem już wcześniej) procedury obsługi przerwań, które zawsze muszą zapamiętywać wszystkie używane przez siebie rejestry, jak i rejestr statusowy SREG.

• Rejestry zachowywane przez funkcje

Odwrotna zasada dotyczy pozostałych rejestrów, czyli r2-r17, r28-r29. Wywołując w pliku ASM funkcję C możemy założyć, że zawartość tych rejestrów nie zostanie zmieniona.

Pisząc w ASM funkcję wywoływaną później w pliku C, jeśli chcemy skorzystać z tych rejestrów, musimy zadbać o zapamiętanie zawartości tych rejestrów na początku funkcji i przywrócenie na końcu.

Do tej grupy rejestrów można zaliczyć również rejestr r1, jednak należy pamiętać, że rejestr ten jest rejestrem specjalnym ("zerowym") i obowiązują go trochę inne reguły opisane wcześniej.

Miksowanie

Zaznaczam, że nie jest tutaj moim zamiarem udowadnianie, że kod napisany przez programistę jest wydajniejszy od tego wygenerowanego przez kompilator. Chodzi mi tylko o pokazanie, jak technicznie wykonać miksowanie, więc przedstawione tu przykładowe fragmenty kodu będą stosunkowo banalne i zapewne niepraktyczne. Na koniec podam nieco obszerniejszy przykład przy okazji omawiania wykorzystania wspólnego pliku nagłówkowego.

Jak już wspomniałem wcześniej (podrozdział "Operacje na rejestrach wejścia/wyjścia"), możliwość użycia instrukcji inout zamiast loadstore jest uzależnione od adresu rejestru. W poniższych przykładach przyjąłem adresy rejestrów mikrokontrolera ATmega644P

• Współdzielenie zmiennych

Wprawdzie podałem tu przykłady współdzielenia zmiennych przez zwykłe funkcje, jednak bardziej uzasadnione jest współdzielenie zmiennych np. poprzez funkcje C i procedurę obsługi przerwania napisaną w ASM. Moim zdaniem, w przypadku zwykłych funkcji, lepiej przekazywać dane poprzez argumenty i zwracanie wartości.

Oczywiście w przypadku współdzielenia zmiennej pomiędzy procedurą obsługi przerwania w ASM a funkcjami C, należy pamiętać o dodaniu słowa kluczowego volatile.

◦ Zmienna zadeklarowana w pliku C dostępna dla kodu ASM

W pliku C deklarujemy zmienną globalną:

Język: C Zwiń
  1. uint16_t counter;

W pliku ASM można zadeklarować zmienną .extern, choć nie jest to konieczne:

Język: AVR assembler Zwiń
  1. .extern counter

Przypominam, że w plikach ASM nie deklarujemy typu zmiennej. Pomimo tego, że deklaracja .extern nie jest konieczna, uważam że warto to zrobić np. w taki sposób (typ można podać w komentarzu):

Język: AVR assembler Zwiń
  1. ; zmienna globalna w pliku *.c
  2. ; wynik obliczeń funkcji 'moja_funkcja'
  3. .extern counter ; uint16_t

Dzięki temu, bez konieczności przełączania się do pliku *.c możemy sobie przypomnieć nazwę, typ i przeznaczenie zmiennej.

Przykład użycia:

Język: AVR assembler Zwiń
  1. ; zmienna globalna w pliku *.c
  2. ; wynik obliczeń funkcji 'moja_funkcja'
  3. .extern counter ; uint16_t
  4.  
  5. .global moja_funkcja
  6.  
  7. moja_funkcja:
  8. ; tutaj ewentualny prolog, czyli zapamiętanie wartości
  9. ; używanych rejestrów na stosie
  10. lds r24, counter ; wczytujemy młodszy bajt
  11. lds r25, counter+1 ; wczytujemy starszy bajt
  12. ; tutaj obliczenia
  13. sts counter, r24 ; zapisujemy młodszy bajt
  14. sts counter+1, r25 ; zapisujemy starszy bajt
  15. ; tutaj ewentualny epilog, czyli przywrócenie wartości
  16. ; używanych rejestrów ze stosu
  17. ret

◦ Zmienna zadeklarowana w pliku ASM dostępna dla kodu C

W pliku ASM deklarujemy zmienną w sekcji .data:

Język: AVR assembler Zwiń
  1. .section .data
  2.  
  3. ; ewentualny opis zmiennej
  4. counter: .word 0 ; uint16_t

Tutaj należy pamiętać o możliwej konieczności zainicjowania wartości zmiennej, co opisałem w podrozdziale: "Deklarowanie zmiennych w pamięci RAM", gdyż kompilator może za nas tego nie zrobić.

W pliku C deklarujemy zmienną extern:

Język: C Zwiń
  1. extern uint16_t counter;

Pomimo tego, że w pliku ASM zmienna counter nie ma typu (tzn. ma tylko domyślnie przyjęty przez programistę), w pliku C musimy bezwzględnie podać typ zmiennej.

• Wywoływanie funkcji napisanej w ASM z kodu napisanego w C

W kodzie ASM etykiet używamy nie tylko do oznaczenia zmiennych czy też początku funkcji. Część etykiet, zwykle większa część, jest przeznaczona do oznaczenia miejsc, do których będą wykonywane skoki warunkowe (np. instrukcje brne, breq itp.), skoki bezwarunkowe (np. instrukcje rjmp, jmp itp.) lub do oznaczenia początków funkcji lokalnych, pomocniczych, które używane będą tylko w kodzie ASM. Nie jest wskazane, aby niepotrzebne etykiety były widoczne dla kodu C. Dlatego należy specjalnie oznaczyć tylko etykiety oznaczające wejścia do funkcji, które będą wywoływane z kodu C. Służy do tego słowo kluczowe .global

◦ Funkcja bez parametrów nie zwracająca wartości

Deklaracja i wywołanie funkcji w pliku C:

Język: C Zwiń
  1. // deklaracja funkcji
  2. void clear_timer1(void);
  3.  
  4. // wywołanie funkcji
  5. clear_timer1();

Definicja funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. .global clear_timer1
  2.  
  3. clear_timer1:
  4. ; jeśli korzystamy z przerwań należy zapewnić
  5. ; atomowy dostęp do 16-bitowych rejestrów
  6. cli
  7. ; nie jest to procedura obsługi przerwania
  8. ; tylko zwykła funkcja, możemy więc użyć
  9. ; rejestru r1 jako rejestru z wartością zerową
  10. ; rejestry TCNT1 w ATmega644P są poza zakresem i/o
  11. ; musimy użyć instrukcji sts
  12. sts TCNT1L, r1
  13. sts TCNT1H, r1
  14. ; jeśli korzystamy z przerwań, po wykonaniu
  15. ; atomowej operacji na rejestrze 16-bitowym,
  16. ; musimy ponownie włączyć globalne zezwolenie
  17. ; na przerwania
  18. sei
  19. ret

◦ Funkcja z parametrami nie zwracająca wartości

Deklaracja i wywołanie funkcji w pliku C:

Język: C Zwiń
  1. // deklaracja funkcji
  2. void set_timer1_pwm(uint16_t period, uint16_t pulse_width);
  3.  
  4. // wywołanie funkcji
  5. uint16_t per = 12500, width = 2000;
  6. set_timer1_pwm(per, width);

Definicja funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. .global set_timer1_pwm
  2.  
  3. set_timer1_pwm:
  4. cli
  5. ; argument 'period' znajduje się w rejestrach r25:r24
  6. ; wpisujemy go do rejestrów OCR1AH:OCR1AL
  7. ; podczas zapisu do rejestrów 16-bit najpierw bajt starszy
  8. sts OCR1AH, r25
  9. sts OCR1AL, r24
  10. ; argument 'pulse_width' znajduje się w rejestrach r23:r22
  11. ; wpisujemy go do rejestrów OCR1BH:OCR1BL
  12. sts OCR1BH, r23
  13. sts OCR1BL, r22
  14. sei
  15. ret

◦ Funkcja bez parametrów zwracająca wartość

Deklaracja i wywołanie funkcji w pliku C:

Język: C Zwiń
  1. // deklaracja funkcji
  2. uint16_t get_t1_input_capture(void);
  3.  
  4. // wywołanie funkcji
  5. uint16_t icp = get_t1_input_capture();

Definicja funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. .global get_t1_input_capture
  2.  
  3. get_t1_input_capture:
  4. cli
  5. ; wartość zwracana musi znaleźć się w rejestrach r25:r24
  6. ; podczas odczytu z rejestrów 16-bit najpierw bajt młodszy
  7. lds r24, ICR1L
  8. lds r25, ICR1H
  9. sei
  10. ret

◦ Funkcja z parametrami zwracająca wartość

Deklaracja i wywołanie funkcji w pliku C:

Język: C Zwiń
  1. // deklaracja funkcji
  2. uint8_t get_array_element(uint8_t *array_ptr, uint8_t element_id);
  3.  
  4. // wywołanie funkcji
  5. uint8_t my_array[] = {15,21,233,187};
  6. uint8_t element = get_array_element(my_array, 2);

Definicja funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. .global get_array_element
  2.  
  3. get_array_element:
  4. ; dla treningu użyjemy rejestrów, których wartość
  5. ; musi być zachowana
  6. push yl ; r28
  7. push yh ; r29
  8. ; pierwszy argument: 16-bitowy wskaźnik do tablicy
  9. ; kompilator C umieści w rejestrach r25:r24
  10. ; drugi argument: 8-bitowy indeks tablicy
  11. ; kompilator C umieści w rejestrze r22
  12. ; by uzyskać wskaźnik do odczytywanego elementu
  13. ; tablicy musimy te dwa argumenty dodać
  14. add r24, r22
  15. adc r25, r1 ; wartość r1 = 0
  16. ; wartość rejestrów r25:r24 jest wskaźnikiem
  17. ; na odczytywany element tablicy
  18. ; przenosimy ten wskaźnik do rejestru
  19. ; wskaźnikowego 'y'
  20. movw y, r24
  21. ; 8-bitowa wartość odczytana z tablicy musi
  22. ; zostać zwrócona przez funkcję w rejestrze r24
  23. ld r24, y
  24. ; przywracamy wartości użytych rejestrów,
  25. ; które zapamiętaliśmy na początku
  26. pop yh ; r29
  27. pop yl ; r28
  28. ret

◦ Procedura obsługi przerwania

Procedura obsługi przerwania to specjalny rodzaj funkcji. Jest ona wywoływana automatycznie przez mikrokontroler w momencie wystąpienia zdarzenia. W przeciwieństwie do zwykłej funkcji nie da się przewidzieć, w którym momencie wykonywania programu głównego procedura zostanie wywołana, dlatego obowiązują tu inne reguły:

  1. O ile funkcję możemy nazwać dowolnie, o tyle nazwa procedury obsługi przerwania (w przypadku asemblera - etykieta globalna) musi być zgodna z nazwą zdefiniowaną w plikach nagłówkowych mikrokontrolera dla danego przerwania. Prościej mówiąc, musi mieś nazwę, którą wpisalibyśmy w nawiasie używając makra ISR() pisząc procedurę w języku C (oczywiście przy użyciu AVR‑GCC).
  2. Wszystkie rejestry używane wewnątrz procedury oraz rejestr statusowy SREG muszą zostać zapamiętane na stosie w prologu i odtworzone w epilogu procedury. Dotyczy to również rejestrów, których zniszczenie zawartości jest dopuszczalne w normalnej funkcji jak i rejestrów stałych: tymczasowego r0 (który w normalnej funkcji nie musi być zapamiętywany i odtwarzany) oraz zerowego r1 (który w normalnej funkcji musi zostać na końcu wyzerowany, jeśli jego zawartość uległa zmianie).
    W wyjątkowych przypadkach, jeśli żadna z instrukcji wewnątrz procedury nie zmienia zawartości SREG, możemy pominąć jego zapamiętywanie i przywracanie.
    Z drugiej strony należy pamiętać, że w przypadku wywołania jakiejś funkcji wewnątrz procedury, musimy uwzględnić w prologu i epilogu rejestry przez tę funkcję niszczone.
  3. W odróżnieniu od normalnej funkcji kończonej instrukcją powrotu ret, procedura obsługi przerwania musi być zakończona instrukcją powrotu reti.

Typowa konstrukcja procedury obsługi przerwania (dla przykładu przyjmijmy przepełnienie timer'a 0) powinna wyglądać tak:

Język: AVR assembler Zwiń
  1. .global TIMER0_OVF_vect
  2.  
  3. TIMER0_OVF_vect:
  4. ; ------ prolog - początek ----------
  5. push r16
  6. in r16, SREG
  7. push r16
  8. ; inne rejestry używane w procedurze
  9. ; przykładowo:
  10. push r26
  11. push r27
  12. ; ----- prolog - koniec -------------
  13. ;
  14. ; tutaj właściwy kod procedury
  15. ;
  16. ; ----- epilog - początek -----------
  17. pop r27
  18. pop r26
  19. pop r16
  20. out SREG, r16
  21. pop r16
  22. ; ----- epilog - koniec -------------
  23. reti

W przypadku, kiedy np. chcemy w procedurze tylko ustawić flagę odpowiednio interpretowaną później w programie głównym, (w niektórych mikrokontrolerach) możemy użyć do tego rejestru i/o - GPIOR. W procedurze obsługi przerwania zmieniamy tylko jeden rejestr, jednak żadna z instrukcji nie zmienia żadnego bitu w rejestrze SREG. Cała procedura mogłaby wtedy wyglądać np. tak:

Język: AVR assembler Zwiń
  1. .global TIMER0_OVF_vect
  2.  
  3. TIMER0_OVF_vect:
  4. push r16
  5. ldi r16, 1
  6. out GPIOR1, r16
  7. pop r16
  8. reti

Tak naprawdę to tutaj będziemy mieli główne pole do popisu. Napisanie w języku asemblera procedury obsługi przerwania nie wpływa na jakość optymalizacji kompilatora, o czym pisałem wcześniej. Zauważyłem za to tendencje kompilatora do odkładania (w procedurach obsługi przerwań) na stos niepotrzebnych rejestrów, szczególnie w przypadku bardziej rozbudowanych procedur wywołujących dodatkowo jakieś funkcje. Dzięki temu, pisząc w ASM, można oszczędzić kilka cennych taktów. Dodatkowo mamy szansę np. na skrócenie operacji arytmetycznych poprzez ograniczenie rozmiaru zmiennej do niezbędnego minimum.

• Wywoływanie funkcji napisanej w C z kodu napisanego w ASM

◦ Funkcja bez parametrów nie zwracająca wartości

Definicja funkcji w pliku C:

Język: C Zwiń
  1. void clear_timer1(void)
  2. {
  3. ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
  4. {
  5. TCNT1 = 0;
  6. }
  7. }

Wywołanie funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. ; nie wymaga żadnych dodatkowych zabiegów
  2. ; wystarczy w odpowiednim miejscu
  3. ; umieścić instrukcję:
  4. call clear_timer1

◦ Funkcja z parametrami nie zwracająca wartości

Definicja funkcji w pliku C:

Język: C Zwiń
  1. void set_timer1_pwm(uint16_t period, uint16_t pulse_width)
  2. {
  3. ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
  4. {
  5. OCR1A = period;
  6. OCR1B = pulse_width;
  7. }
  8. }

Wywołanie funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. ; wymagane umieszczenie argumentów
  2. ; w odpowiednich rejestrach
  3. ; 'period' w rejestrach r25:r24
  4. ; przykładowo (wartość 12500 = 0x30D4):
  5. ldi r25, 0x30
  6. ldi r24, 0xD4
  7. ; 'pulse_width' w rejestrach r23:r22
  8. ; przykładowo (wartość 2000 = 0x07D0):
  9. ldi r23, 0x07
  10. ldi r22, 0xD0
  11. ; dopiero teraz wywołujemy funkcję
  12. call set_timer1_pwm

◦ Funkcja bez parametrów zwracająca wartość

Definicja funkcji w pliku C:

Język: C Zwiń
  1. uint16_t get_t1_input_capture(void)
  2. {
  3. uint16_t icr;
  4. ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
  5. {
  6. icr = ICR1;
  7. }
  8. return icr;
  9. }

Wywołanie funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. ; Funkcja nie posiada parametrów, więc w odpowiednim
  2. ; miejscu kodu po prostu ją wywołujemy. Jedyne
  3. ; co musimy ewentualnie zrobić, to zapamiętać wartości
  4. ; rejestrów, które chcemy zachować, a wywołanie
  5. ; funkcji może zniszczyć. Przyjmijmy, że chcemy zachować
  6. ; rejestry r19:r18, gdyż zawierają wartość z którą
  7. ; będzie porównywana wartość odczytana z ICR1
  8. push r18
  9. push r19
  10. ; wywołujemy funkcję
  11. call get_t1_input_capture
  12. ; przywracamy wartość rejestrów
  13. pop r19
  14. pop r18
  15. ; wartość zwrócona będzie w rejestrach r25:r24
  16. ; wykonujemy na niej żądane operacje, przykładowo:
  17. cp r24, r18
  18. cpc r25, r19
  19. brlo lable_if_lower
  20. ; dalsza część kodu

◦ Funkcja z parametrami zwracająca wartość

Definicja funkcji w pliku C:

Język: C Zwiń
  1. uint8_t get_array_element(uint8_t *array_ptr, uint8_t element_id)
  2. {
  3. return array_ptr[element_id];
  4. }

Wywołanie funkcji w pliku ASM:

Język: AVR assembler Zwiń
  1. ; zakładamy, że mamy w pliku *.c
  2. ; zadeklarowaną tablicę:
  3. ; uint8_t my_array[] = {0,1,2,3};
  4. ; argument pierwszy 'array_ptr'
  5. ; rejestry r25:r24
  6. ldi r24, lo8(my_array)
  7. ldi r25, hi8(my_array)
  8. ; argument drugi 'element_id'
  9. ; rejestr r22
  10. ldi r22, 2 ; pobieramy drugi element
  11. ; wywołanie funkcji
  12. call get_array_element
  13. ; wartość zwrócona przez funkcję (1 bajt)
  14. ; znajduje się w rejestrze r24
  15. ; wykonujemy na niej żądane operacje
  16. ; przykładowo
  17. cpi r24, 125
  18. brne not_equal
  19. ; dalsza część kodu

◦ Funkcja z biblioteki standardowej C

Pisząc w języku asembler możemy również używać funkcji z bibliotek standardowych C. Nie trzeba w tym celu dołączać plików nagłówkowych dyrektywą #include. Wystarczy tak jak w przypadku naszych funkcji umieścić ewentualne argumenty w odpowiednich rejestrach, wywołać funkcję i odczytać wynik z odpowiednich rejestrów.

Przykładowe wyliczenie wartości bezwzględnej za pomocą funkcji abs() z biblioteki <stdlib.h>:

Język: AVR assembler Zwiń
  1. ; zakładamy zdefiniowanie w sekcji .data
  2. my_val: .word -5684
  3. ; później w kodzie w sekcji .text:
  4. ;
  5. ; umieszczamy argument typu int
  6. ; (będzie to nasza zmienna 'my_val')
  7. ; w rejestrach r25:r24
  8. ldi xl, lo8(my_val)
  9. ldi xh, hi8(my_val)
  10. ld r24, x+
  11. ld r25, x
  12. ; wywołujemy funkcję
  13. call abs
  14. ; teraz w rejestrach r25:r24 znajduje się
  15. ; wartość zwrócona przez funkcję,
  16. ; czyli wartość bezwzględna zmiennej 'my_val'

• Korzystanie ze wspólnego pliku nagłówkowego

Na koniec mały projekt przykładowy, pokazujący w jaki sposób korzystać ze wspólnego pliku nagłówkowego zarówno dla C jak i asemblera, dzięki czemu można uniknąć konieczności podwójnego definiowania stałych używanych w programie.

Projekt napisany został dla mikrokontrolera ATmega644P. Przedstawia wprawdzie prostą obsługę klawiatury z debouncing'iem opartym o timer, ale nie to jest jego głównym celem. Koncentrowałem się przede wszystkim na pokazaniu ogólnej struktury projektu wykorzystującego mieszany kod ze współdzielonym plikiem nagłówkowym, więc choć program powinien działać, nie mogę zagwarantować pełnej niezawodności oraz optymalności kodu.

Projekt składa się z trzech plików, które przedstawiłem poniżej (dokładniejszy opis w komentarzach):

plik "main.c"

Język: C Zwiń
  1. /*
  2.  * main.c
  3.  *
  4.  * Created: 2016-09-12 19:35:09
  5.  * Author : Andrews
  6.  */
  7.  
  8. #include <avr/io.h>
  9. #include <stdbool.h>
  10. #include <avr/interrupt.h>
  11. #include "keyboard.h"
  12.  
  13. // Zmienna współdzielona z procedurą obsługi przerwania
  14. // napisaną w ASM. Wartość zmiennej jest aktualizowana
  15. // przez procedurę obsługi przerwania, kiedy procedura
  16. // odczyta dwukrotnie ten sam kod klawiatury
  17. volatile enum Key pressed_key = NO_KEY;
  18.  
  19. // Funkcja konfigurująca port klawiatury
  20. // oraz timer
  21. void kbrd_init(void);
  22.  
  23. int main(void)
  24. {
  25. // Ustawienie pinów portu A jako wyjść
  26. // by można było obserwować efekty wciskania
  27. // klawiszy
  28. DDRA = 0x0F;
  29.  
  30. // Zmienna blokująca wielokrotne wykonanie
  31. // kodu obsługi klawisza przed jego puszczeniem
  32. bool key_processed = false;
  33.  
  34. // Zmienna 'pressed_key' może zostać zmieniona
  35. // przez procedurę obsługi przerwania w dowolnym
  36. // momencie. Zmienna pomocnicza zagwarantuje,
  37. // że wartość zmiennej się nie zmieni
  38. // do momentu zakończenia analizy
  39. enum Key pk;
  40.  
  41. kbrd_init();
  42. sei();
  43.  
  44. while (1)
  45. {
  46. pk = pressed_key;
  47. if (key_processed)
  48. {
  49. // Oczekiwanie na zwolnienie przycisku
  50. if (pk==NO_KEY)
  51. {
  52. key_processed = false;
  53. // Tutaj ewentualny kod wykonywany
  54. // po zwolnieniu klawisza, przykładowo:
  55. PORTA = 0x00;
  56. }
  57. }
  58. else
  59. {
  60. // Poniższy warunek zagwarantuje,
  61. // że kod obsługi klawisza będzie
  62. // wykonany tylko raz na jedno
  63. // przyciśnięcie
  64. if (pk!=NO_KEY) key_processed = true;
  65. switch (pk)
  66. {
  67. // Tutaj kody obsługi poszczególnych klawiszy
  68. case UP:
  69. // przykładowo:
  70. PORTA = 0x01;
  71. break;
  72. case DOWN:
  73. PORTA = 0x02;
  74. break;
  75. case ESC:
  76. PORTA = 0x04;
  77. break;
  78. case ENTER:
  79. PORTA = 0x08;
  80. break;
  81. case NO_KEY:
  82. default:
  83. break;
  84. }
  85. }
  86. }
  87. }
  88.  
  89. // Funkcja inicjująca obsługę klawiatury
  90. void kbrd_init(void)
  91. {
  92. // Włączenie pull-up dla pinów,
  93. // do których są podłączone klawisze
  94. KBRD_PORT = KBRD_bm;
  95.  
  96. // Konfiguracja timera
  97. // Założenie: F_CPU=16MHz
  98. TCCR2A = (1<<WGM21); // tryb CTC
  99. TCCR2B = (7<<CS20); // prescaler 1024
  100. OCR2A = 155; // przerwanie co ok. 10ms
  101. TIMSK2 = (1<<OCIE2A); // włączenie przerwania od porównania
  102. }

plik "keyboard.h"

Język: C Zwiń
  1. /*
  2.  * keyboard.h
  3.  *
  4.  * Created: 2016-09-12 19:47:56
  5.  * Author: Andrews
  6.  */
  7.  
  8. #ifndef KEYBOARD_H_
  9. #define KEYBOARD_H_
  10.  
  11. // *************************************************************
  12. // Fragment przeznaczony do edycji.
  13. // Można ustawić własne porty i numery pinów
  14. // należy tylko zwrócić uwagę, by nie kolidowały
  15. // z pinami portem i pinami, wykorzystanymi w pętli głównej
  16. // programu do sygnalizacji.
  17. // (przyciski podłączone pomiędzy pin mikrokontrolera a masę)
  18. #define KBRD_PORT PORTB
  19. #define KBRD_PIN PINB
  20. #define KEY_UP_bp 0
  21. #define KEY_DOWN_bp 1
  22. #define KEY_ESC_bp 2
  23. #define KEY_ENTER_bp 3
  24. //**************************************************************
  25.  
  26. #define KBRD_bm ( ( 1<<(KEY_UP_bp) ) |\
  27.   ( 1<<(KEY_DOWN_bp) ) |\
  28.   ( 1<<(KEY_ESC_bp) ) |\
  29.   ( 1<<(KEY_ENTER_bp) ) )
  30.  
  31. #define KEY_UP_bm ( KBRD_bm & ~( 1<<(KEY_UP_bp) ) )
  32. #define KEY_DOWN_bm ( KBRD_bm & ~( 1<<(KEY_DOWN_bp) ) )
  33. #define KEY_ESC_bm ( KBRD_bm & ~( 1<<(KEY_ESC_bp) ) )
  34. #define KEY_ENTER_bm ( KBRD_bm & ~( 1<<(KEY_ENTER_bp) ) )
  35.  
  36.  
  37. // Definicja typu enum Key przeznaczona tylko dla pliku main.c
  38. #ifndef __ASSEMBLER__
  39.  
  40. enum Key {
  41. NO_KEY = KBRD_bm,
  42. UP = KEY_UP_bm,
  43. DOWN = KEY_DOWN_bm,
  44. ESC = KEY_ESC_bm,
  45. ENTER = KEY_ENTER_bm
  46. };
  47.  
  48. #endif
  49.  
  50. #endif /* KEYBOARD_H_ */

plik "timer_isr.S"

Język: AVR assembler Zwiń
  1. /*
  2.   * timer_isr.S
  3.   *
  4.   * Created: 2016-09-12 19:39:35
  5.   * Author: Andrews
  6.   */
  7. #define __SFR_OFFSET 0
  8.  
  9. #include <avr/io.h>
  10. #include "keyboard.h"
  11.  
  12. ; zmienna zadeklarowana w pliku main.c
  13. ; będziemy do niej wpisywać informację
  14. ; o wciśniętym klawiszu
  15. .extern pressed_key ; uint8_t
  16.  
  17. ; ===============================================
  18. .section .data
  19.  
  20. ; zmienna lokalna, widoczna tylko dla asm
  21. ; poprzednio odczytany stan klawiatury
  22. prev_code: .byte KBRD_bm ; enum Key
  23.  
  24. ; ===============================================
  25. ; W pliku 'main.c' jest zadeklarowana zmienna
  26. ; statyczna 'pressed_key' z przypisaną wartością,
  27. ; więc nasza zmienna lokalna 'prev_code' powinna
  28. ; zostać zainicjowana prawidłowo
  29. ; przez kompilator C (pisałem o tym wcześniej).
  30. ; W innym przypadku w tym miejscu powinniśmy
  31. ; zainicjować naszą zmienną sami.
  32. ;
  33. ; .section .init1,"ax",@progbits
  34. ;
  35. ; ldi r16, KBRD_bm
  36. ; sts prev_code, r16
  37. ;
  38. ; ===============================================
  39.  
  40. .section .text
  41.  
  42.  
  43. .global TIMER2_COMPA_vect
  44.  
  45. ; Zdecydowanie łatwiej operować nazwami symbolicznymi
  46. ; niż numerami rejestrów. Możemy je zdefiniować tak,
  47. ; jak jest pokazane poniżej.
  48. ; Dyrektywa asemblera .set (w przeciwieństwie do .equ)
  49. ; pozwala na wielokrotne definiowanie tego samego
  50. ; symbolu, dzięki czemu w innej funkcji możemy
  51. ; przypisać inny numer rejestru do tego samego symbolu,
  52. ; przykładowo:
  53. ; .set temp, 22
  54.  
  55. .set temp, 16
  56. .set prev, 17
  57.  
  58. TIMER2_COMPA_vect:
  59. ; ------------------ prolog --------------------
  60. push temp
  61. in temp, SREG
  62. push temp
  63. push prev
  64. ; ---------------- koniec prologu ---------------
  65. lds prev, prev_code
  66. in temp, KBRD_PIN
  67. andi temp, KBRD_bm
  68. cp temp, prev
  69. brne not_equ
  70. sts pressed_key, prev
  71. not_equ:
  72. sts prev_code, temp
  73. ; ------------------ epilog --------------------
  74. pop prev
  75. pop temp
  76. out SREG, temp
  77. pop temp
  78. ; --------------- koniec epilogu ----------------
  79. reti

Podsumowanie

Obawiam się trochę, że bardziej się skoncentrowałem na tym, żeby było rzeczowo, niż żeby było ciekawie. Mimo tego liczę na to, że znajdzie się ktoś, kto przeczyta ten poradnik w całości unikając zaśnięcia. Mam również nadzieję, że udało mi się zebrać w jednym miejscu wszystkie informacje niezbędne do stworzenia projektu w języku C zawierającego wstawki w kodzie ASM (który się skompiluje i będzie działał prawidłowo), że było wyczerpująco i zrozumiale, no i że komuś się to kiedyś do czegoś przyda.