Najwięcej materiałów do artykułu dostarczyło pisanie programu funkcjonującego z wykorzystaniem przerwań, emulującego układy 1-Wire pracujące w trybie overdrive. W tym trybie trzeba w ciągu maksymalnie 2 ms od opadającego zbocza sygnału odczytu wystawić transmitowany bit. Czas wykonania jednego rozkazu przy zegarze 16 MHz to około 0,0625 ms. Biorąc pod uwagę fakt, że gdy pisze się aplikację w GCC, przy wejściu w przerwanie operacje na stosie zajmują około 2 ms (na stos jest odkładana zawartość 20 rejestrów), wydaje się niemożliwe obsłużenie tego trybu. A jednak się udało, o czym dalej.
Zakres zmiennych
Wydawałoby się, że wynikiem mnożenia dwóch liczb unsigned int będzie unsigned long. Czy na pewno? Spróbujmy:
unsigned int a=10000, b=10;
unsigned long w;
Program działa poprawnie, jeśli wynikiem mnożenia jest liczba typu unsigned int. Gdy jest większy, nieoczekiwanie otrzymujemy dziwne rezultaty. Bliższe przyjrzenie się wynikom operacji w debuggerze ujawnia, że starsze bajty zmiennej są zerem. Jak rozwiązać problem? Trzeba wykonać jawną konwersję typu i operację zapisać tak:
w = (unsigned long)a * b;
Dlaczego tak niewielka zmiana spowodowała poprawne działanie programu? Otóż kompilator wykonuje następujące działania:
a * b -> w
A więc mnoży zmienną „a” przez zmienną „b”, zapisując wynik w zmiennej „a”, po przepisuje go do zmiennej „w”. Gdy zmienna „a” była zadeklarowany jako unsigned int, kod wynikowy programu w asemblerze wyglądał następująco:
„w = a * b;”
1c74: 20 91 02 01 lds r18, 0x0102
1c78: 30 91 03 01 lds r19, 0x0103
1c7c: 80 91 00 01 lds r24, 0x0100
1c80: 90 91 01 01 lds r25, 0x0101
1c84: ac 01 movw r20, r24
1c86: 24 9f mul r18, r20
1c88: c0 01 movw r24, r0
1c8a: 25 9f mul r18, r21
1c8c: 90 0d add r25, r0
1c8e: 34 9f mul r19, r20
1c90: 90 0d add r25, r0
1c92: 11 24 eor r1, r1
1c94: a0 e0 ldi r26, 0x00 ; 0
1c96: b0 e0 ldi r27, 0x00 ; 0
1c98: 80 93 05 02 sts 0x0205, r24
1c9c: 90 93 06 02 sts 0x0206, r25
1ca0: a0 93 07 02 sts 0x0207, r26
1ca4: b0 93 08 02 sts 0x0208, r27
Wyraźnie widać, że najstarsze bajty są wyzerowane. Gdy zmienną rzutujemy na unsigned int, wynik pracy kompilatora wygląda następująco:
„w = (long)a * b;”
1c74: 60 91 02 01 lds r22, 0x0102
1c78: 70 91 03 01 lds r23, 0x0103
1c7c: 80 e0 ldi r24, 0x00 ; 0
1c7e: 90 e0 ldi r25, 0x00 ; 0
1c80: 20 91 00 01 lds r18, 0x0100
1c84: 30 91 01 01 lds r19, 0x0101
1c88: 40 e0 ldi r20, 0x00 ; 0
1c8a: 50 e0 ldi r21, 0x00 ; 0
1c8c: 0e 94 4e 20 call 0x409c ; 0x409c <__mulsi3>
1c90: 60 93 05 02 sts 0x0205, r22
1c94: 70 93 06 02 sts 0x0206, r23
1c98: 80 93 07 02 sts 0x0207, r24
1c9c: 90 93 08 02 sts 0x0208, r25
Użyta procedury mnożenia __mulsi3 jest dosyć długa, dlatego zainteresowanych zachęcam do obejrzenia wyniku kompilacji na własnym komputerze. Ważne, że procedura ta operuje na 32 bitach.
Przerwanie programowe
Mikrokontrolery AVR nie mają możliwości wywoływania przerwań z aplikacji użytkownika. Pomijam tu asemblerową instrukcję BREAK, której działanie nie jest szeroko opisane. Jeśli istnieje potrzeba wygenerowania takiego przerwania i mamy wolne wejście przerwań zewnętrznych, można poradzić sobie w następujący sposób:
Skonfigurować wejście INTx jako wywołujące przerwanie opadającym zboczem sygnału.
Ustawić pin jako wyjście.
Aby wywołać przerwanie, wykonać rozkaz PORTx &= ~_BV(y);. W programie obsługi przerwania wykonać PORTx |= _BV(y); i obsłużyć przerwanie. Rozwiązania tego używałem w celu emulowania impulsatora za pomocą UART obsługiwanego z terminalu na komputerze PC.
Przerwanie od WDG
W niektórych mikrokontrolerach AVR układ czasowy watchdog (WDG) może generować przerwanie. Jeśli w obsłudze tego przerwania nie ustawimy flagi WDIE (kasowana automatycznie przez przerwanie od WDG), to kolejne zadziałanie WDG spowoduje restart CPU. Aby funkcjonalność przerwań od WDG zadziałała, nie może być ustawiony bit WDTON w bitach konfiguracyjnych. Funkcjonalność IRQ od WDG włączamy, ustawiając WDIE poleceniem WDTCSR |= (1<<WDIE);. Oczywiście, należy ustawić globalne zezwolenie na przerwania instrukcją sei(), w przeciwnym wypadku po dwukrotnym przepełnieniu timera nastąpi reset mikrokontrolera.
W przerwaniu od WDG możemy poznać adres, z którego program skoczył do obsługi IRQ. Każde CPU, skacząc do procedury obsługi IRQ, odkłada na stos adres powrotu, a niektóre także rejestr stanu. Dodatkowo jest zapamiętywany stan flagi „I”. Jeśli przerwanie jest zadeklarowane jako INTERRUPT lub ISR z atrybutem NOBLOCK, pierwszym rozkazem w obsłudze przerwań jest sei(). Umożliwia to obsługę kolejnego przerwania podczas obsługi aktualnego. Używając INTERRUPT, trzeba pamiętać, że nie wszystkie przerwania po wejściu do procedury obsługi automatycznie zerują bit powodujący ich zgłoszenie, dlatego użycie INTERRUPT dla przerwań od UART czy wejścia INT wyzwalanego poziomem spowoduje przepełnienie stosu. Jeśli zależy nam na obsłudze innych przerwań podczas obsługi przerwania od np. UART, to można to zrobić na przykład tak:
SIGNAL ( INT_UART_vect )
{
unsigned char a, b, d;
a = UCSRA;
b = UCSRB;
d = UDR;
sei();
//tu obsługa IRQ
}
Instrukcja sei() pojawia się po odczycie rejestru UDR. Odczyt UDR kasuje flagę RXC (UDRE), dlatego kolejne przerwanie od UART nie będzie wywołane (chyba że pojawi się kolejny znak). Rejestry UDRa, UDRb, UDRc muszą być zapamiętane przed odczytem UDR, ponieważ tak jak rejestr UDR, flagi RXB8 (błędów) są zaopatrzone w FIFO (w AVR – 2 bajty).
Trochę odbiegliśmy od głównego tematu. Jak więc poznać adres, z którego nastąpił skok do obsługi przerwania? Należy odjąć od wskaźnika stosu (SP) 2 lub 3 bajty. Tu należy wiedzieć, że adres powrotu dla mikrokontrolerów AVR z pamięcią Flash większą niż 128 kB jest 3-bajtowy. Niestety, zanim zostanie wykonany kod obsługi przerwania, kompilator odłoży na stos rejestry używane w przerwaniu (chyba że użyjemy flago NAKED). To, ile rozkazów push zostanie użytych, zależy od kodu procedury przerwania. Nie ma tu uniwersalnej metody – należy obejrzeć wynik kompilacji w asemblerze i wpisać odpowiednią wartość. Na szczęście, jeśli nie będziemy modyfikować naszej procedury, liczba rozkazów push nie zmieni się. Jako pierwszej instrukcji obsługi przerwania można by oczywiście użyć rozkazu nop(), w obsłudze przerwania odliczyć liczbę rozkazów push i zmodyfikować wskaźnik stosu. Można też użyć atrybutu ISR_NAKED. Wtedy rejestry nie będą odkładane na stos. Z procedury przerwania nie można wyjść, ponieważ program główny „pójdzie w maliny” z powodu zmiany stanu rejestrów. Ponadto, samemu trzeba by procedurę zakończyć rozkazem reti(). Przykładową procedurę obsługi przerwania od czasomierza WDG pokazano na listingu 1.
Uruchomienie WDG przebiega w następujący sposób:
InitWdgI(WDTO_500MS, &IrqWdg); // Inicjowanie przerwania od WDG
sei(); // sei() konieczne, aby działały przerwania; w przeciwnym
//wypadku, po 2-krotnym przepełnieniu timera WDG, nastąpi
//restart mikrokontrolera
Deklaracja #define WDGI_NAKED zmniejszy rozmiar kodu wynikowego procedury i wywoła reset po obsłudze przerwania od WDG.
W funkcji InitWdgI() adres funkcji użytkownika nie jest konieczny – można wpisać zero. Nie ma to jednak większego sensu, bo przeważnie chcemy poznać adres, na którym zadziałało przerwanie. Funkcja użytkownika może wyglądać następująco:
void IrqWdg()
{
PrintString_P( (char*)PSTR(CRLF”********”CRLF) );
if ( IdSoftReset == DEF_RST_SOFT )
PrintString_P( (char*)PSTR(„Soft Reset”) );
else PrintString_P( (char*)PSTR(„Wdg Error”) );
PrintLongHex( adrCallWdg );
PrintString_P( (char*)PSTR(CRLF”********”CRLF) );
}
Funkcja odróżnia przerwanie wywołane przez WDG od resetu programowego, w tem celu należy zadeklarować IdSoftReset (najlepiej jako long). W procedurze main() nadać wartość zmiennej IdSoftReset:
IdSoftReset = DEF_RST_SOFT ^ 0xFFFFFF;
Aby wywołać reset programowy, należy wykonać:
IdSoftReset = DEF_RST_SOFT; while( true ) ;
Jeśli nie korzystamy z funkcji IrqWdg(), adres, z którego nastąpiło zadziałanie WDG, będzie znajdował się w zmiennej adrCallWdg. Adres będzie ważny, jeśli flaga WDFR w MCUSR będzie ustawiana.
Adres powrotu z funkcji
Podczas debugowania przydatna jest znajomość adresu powrotu z funkcji. W asemblerze jest to proste – wystarczy sprawdzić adres PC na stosie. W języku C, zanim zostanie wykonany pierwszy rozkaz funkcji na stosie mogą być odkładane rejestry. Aby nie sprawdzać po każdej kompilacji liczby odłożonych danych, można posłużyć się fragmentem kodu umieszczonym pomiędzy „gwiazdkami” na list. 1. Ten fragment można zawrzeć w funkcji, ale należy pamiętać, że adres powrotu zwiększy się o adres powrotu i ewentualnie odkładane rejestry. Odkładania adresu powrotu można uniknąć, deklarując funkcję jako inline.
Wywołanie funkcji nie musi powodować odłożenia adresu powrotu na stosie. Nie zostanie on zapamiętany, jeśli:
- Funkcję zadeklarowano jako inline.
- Funkcji użyto tylko raz (!).
- Jest to ostatnie (niekoniecznie) użycie funkcji w kodzie programu.
Skupmy się na drugim przypadku, dlaczego tak może się stać? Przy włączonej optymalizacji, jeśli funkcja jest użyta raz, kompilator zamiast skompilować kod C:
main()
{
// polecenia main
funkcja()
// rozkazy main
}
void funkcja()
{
//rozkazy funkcji
}
do postaci symbolicznej:
main:
…rozkazy main…
call funkcja
…rozkazy main…
funkcja:
…rozkazy funkcji…
ret
wygeneruje kod asemblerowy w postaci:
main:
...rozkazy main
...rozkazy funkcji
...rozkazy main
Natomiast w trzecim wypadku, program w języku C w postaci:
main()
{
...rozkazy main
funkcja()
funkcja()
funkcja()
}
void funkcja()
{
...rozkazy funkcji
}
skompiluje do:
main:
...rozkazy main
funkcja()
funkcja()
funkcja:
...rozkazy funkcji
ret
Sprintf i scanf
Funkcja sprintf operuje na argumentach typu int. Jak wyświetlić zmienną typu long? Rozwiązaniem jest własna procedura konwersji zmiennej long na łańcuch znaków. Pokazano ją na listingu 2. Przytoczone tu funkcje działają dość szybko i zajmują mało miejsca w pamięci Flash, ponieważ nie obsługują formatowania stringów. Funkcje scannf_P i sprintf_P w drugim parametrze (string w pamięci Flash umieszczony za PSTR) adresują tylko 64 kB Flash (nie ustawiają RAMPZ). Na szczęście kompilator wszystkie stałe umieszcza na początku pamięci Flash, więc nie stanowi to problemu. Kłopoty zaczynają się, gdy przesuniemy sekcję „.text” na adres ponad 64 kB. Rozwiązaniem jest zastąpienie scannf_P i sprintf_P przez scannf i sprintf. Zajmą one więcej pamięci RAM, ale procesory z Flash mieszczącą więcej niż 64 kB danych mają jej dużo, a bootloader potrzebuje niewiele pamięci RAM.
Porównanie bitów
Porównanie bitów przez
if( (bajt1 & _BV(bit1)) == (bajt2 & _BV(bit2)) )
{
….
}
najczęściej nie zadziała, bo wynikiem fałszu jest zawsze zero, ale prawdą wynik różny od zera, który to – zwłaszcza przy sprawdzaniu stanu bitu w peryferiach – będzie zależał od numeru sprawdzanego bitu/bitów. Aby porównanie zadziałało prawidłowo, należy je wykonać w następujący sposób:
if ( ((bajt1 & _BV(bit1) == 0) && ((bajt2 & _BV(bit2)) != 0) ||
((bajt1 & _BV(bit1) != 0) && ((bajt2 &_BV(bit2)) == 0) )
{
….
}
Zużycie pamięci RAM
Gdy stos mikrokontrolera zajmie obszar przeznaczony na dane, praca aplikacji zakończy się w trudno przewidywalny sposób. Warto więc wiedzieć, jakie jest bieżące i maksymalne zużycie pamięci. Przykładowy sposób określenia zużycia pamięci pokazano na listingu 3. Deklarację zmiennej DnoStosu należy umieścić bezpośrednio przed main(). Zagwarantuje to, że zmienna będzie zadeklarowana jako ostatnia i będzie zajmowała najwyższy adres w RAM. Nieprzypadkowo też jest to zmienna z atrybutem NOINIT, „zwykła” zmienna zadeklarowana przed main() wcale nie musi być umieszczona w pamięci jako ostatnia. Czy faktycznie jest ona ostatnia można upewnić się sprawdzając wyniki kompilacji.
Oszacowanie częstotliwości
taktującej mikrokontroler
Do czego może przydać się ta funkcja? Zdarza się pomylić kwarc. W dobie wszechstronnej miniaturyzacji napisy są coraz trudniejsze do odczytania. Zdarza się też trafić na kwarc overtonowy, przeznaczony do pracy na częstotliwości harmonicznej. W takim przypadku częstotliwość podstawowa jest kilkukrotnie niższa. W takiej sytuacji program może poinformować o złej częstotliwości taktującej.
Jeśli w mikrokontrolerze mamy dostępny sygnał wzorcowy, to nie ma problemu z policzeniem, ile rozkazów wykona mikrokontroler w zadanym czasie. Co jednak, gdy takiego sygnału nie ma? Jeśli mamy wolne wyprowadzenie mikrokontrolera, można do niego dołączyć obwód RC i zmierzyć czas ładowania się kondensatora. Zależność ta jest nieliniowa, ale nie zależy nam na dokładnym pomiarze, lecz oszacowaniu częstotliwości, a właściwie stwierdzeniu zbyt dużej jej odchyłki. Bez obwodu RC można pokusić się o pomiar pojemności własnej niepodłączonego wyprowadzenia. Co jeśli, wszystkie piny są wykorzystane? Manipulując bitami konfiguracyjnymi, można by przełączyć taktowanie na wewnętrzny generator RC, dokonać pomiaru i operację powtórzyć z zegarem zewnętrznym. Niestety, operacji takiej nie należy przeprowadzać zbyt często, bo przekroczymy liczbę dopuszczalnych zapisów do pamięci Flash.
Na listingu 4 pokazano procedurę, która nie wymaga zewnętrznych sygnałów wzorcowych i można ją zaimplementować w każdym współczesnym mikrokontrolerze. W tym celu wykorzystamy wewnętrzny generator RC, który służy do taktowania operacji na EEPROM, ale nie będziemy mierzyć czasu zapisu, a właściwie kasowania komórki tej pamięci, tylko skorzystamy z usług watchdog, taktowanego tym samym sygnałem. Dokładność tego sygnału nie jest rewelacyjna, zależy od wielu czynników, między innymi od napięcia zasilania, ale do naszych celów jest wystarczająca. W mikrokontrolerach z rozbudowanym układem WDG, który może generować przerwanie po przepełnieniu timera, pomiar jest stosunkowo prosty, ale stosując się do kilku reguł, można skorzystać z WDG każdego mikrokontrolera.
Funkcja fcpuCalculate zwraca częstotliwość taktowania mikrokontrolera podzieloną przez 1000. W zmiennej fcpuDeviation zwraca odchyłkę od częstotliwości nominalnej, dla której skompilowano kod – stała F_CPU. Dokładność obliczeń można zwiększyć, wydłużając czas pomiaru. Przy zmianach trzeba zadbać o to, aby nie przekroczyć zakresu zmiennych. Funkcja wykonuje się 30 ms, czas ten jest tak długi tylko przy pierwszym uruchomieniu mikrokontrolera po włączeniu zasilania.
Sławomir Skrzyński, EP