Osobliwości kompilatora AVR-GCC i mikrokontrolerów AVR (1)

Osobliwości kompilatora AVR-GCC i mikrokontrolerów AVR (1)
Pobierz PDF Download icon
Kompilator AVR GCC jest chętnie stosowany do kompilowania programów dla mikrokontrolerów AVR. Jak każdy kompilator ma swoje wady i zalety. Specyfika kompilatorów może być istotna, gdy program ma działać szybko lub zajmować mało miejsca w pamięci. Bałagan w definicjach rejestrów czy ich funkcjonalność zmieniana przez producenta w niektórych typach procesorów nie ułatwia pisania programów.

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

Artykuł ukazał się w
Elektronika Praktyczna
marzec 2017
DO POBRANIA
Pobierz PDF Download icon
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik kwiecień 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio marzec - kwiecień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje kwiecień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna kwiecień 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich maj 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów