logo_elektromys.eu

/ Bezpečné uspávání mikrokontrolerů AVR |

Představte si jednoduchou aplikaci, která má za úkol provádět několik činností a má být schopná relativně svižně reagovat na vnější podněty. Může například přijímat zprávy po UARTu, reagovat na externí přerušení, sledovat úroveň napětí, přijímat zprávy pomocí dálkové ovladače atp. Často se taková aplikace řeší tak, že periferie (UART, AD převodník, časovač) volají rutiny přerušení. Aplikace v nich pak provádí dílčí činnost a jakmile vyžaduje nějakou zdlouhavější reakci, přenechá ji hlavní smyčce. Například přijímá-li aplikace zprávy po UARTu tak si v rutině přerušení ukládá znaky do paměti a kontroluje zda je zpráva kompletní. Teprve kompletní zprávu předá k dekódování a dalšímu zpracování hlavní smyčce (neboť tato činnost bývá typicky zdlouhavější). Postup jsem načrtnul v následujícím pseudokódu.

volatile char zprava_uart=0,zprava_ir=0,prekroceni_napeti=0;

void main(void){
 inicializace();
 while(1){  // stále dokola kontroluj zda některá rutina přerušení nesignalizuje událost
  if(zprava_uart){
   zprava_uart=0;
   zpracovani_uart_zpravy();
  }
  if(zprava_ir){
   zprava_ir=0;
   zpracovani_ir_zpravy();
  }
  if(prekroceni_napeti){
   prekroceni_napeti=0
   reakce_na_prekroceni_napeti();
  }
  sleep(); // proč nespat když nás přerušení probudí až se něco stane ? (chyba)
 }
}

ISR(UART){
 if(zprava_kompletni){
  zprava_uart=1;
 }
}

ISR(IR){
 if(zprava_kompletni){
  zprava_ir=1;
 }
}

ISR(ADC){
 if(napeti_vysoke){
  prekroceni_napeti=1;
 }
}

V našem případě zprostředkovávají reakci na všechny vnější události periferie, takže můžeme jádro mikrokontroléru během nečinnosti uspávat. I ten nejmělčí spánek (v našem případě Idle) může snížit spotřebu na polovinu nebo i méně. Zdálo by se tedy že stačí prostě na konec smyčky přidat příkaz sleep() a je hotovo (viz stručná poznámka na konci článku pokud jste se ještě s uspáváním mikrokontrolerů nesetkali). Jenže právě tady začíná drobný problém o němž bude tento příspěvek.

/ Problém |

Nejprve si problém demonstrujeme na konkrétním příkladě. Máme demonstrační aplikaci, jejímž úkolem je reagovat na vzestupnou hranu na pinu PA5 tím že vygeneruje krátký impulz na výstupu PA4. Vzestupnou hranu na vstupu snímáme pomocí externího přerušení a v jeho rutině program nastaví vlajku flag1. Hlavní smyčka programu pak na nastavenou flag1 zareaguje (v našem případě vygenerováním zmíněného pulzíku). Modelově je v aplikaci ještě další dvojice vlajek (flag2 a flag3), které představují další činnosti. Pro jednoduchost jsou vlajky flag2 a flag3 vždy nulové a aplikace na ně nijak reagovat nebude.

Pokud bychom na poslední řádek hlavní smyčky programu prostě přidali příkaz sleep (jak je naznačeno v pseudokódu) byla by to chyba. Představme si totiž následující situaci. Program vyhodnotí podmínku if(flag1) s tím, že flag1 je nulová a začne vyhodnocovat podmínku if(flag2). Zrovna v tom okamžiku přijde vzestupná hrana na vstup. Vykoná se rutina přerušení, která nastaví flag1 na jedničku. Po skončení rutiny přerušení se aplikace vrátí kde skončila a pokračuje vyhodnocením podmínky if(flag3) a pak prostě usne ! Usne i když je flag1 nastavená, tedy i když má program za úkol vygenerovat pulzík. Aplikace bude spát do té doby než ji probudí nějaká další událost. V lepším případě tedy zareaguje vygenerováním pulzíku s těžko předvídatelným zpožděním a v horším případě (pokud ji probudíme další vzestupnou hranou na vstupu) dokonce jeden pulz vynechá. Takové chování je naprosto nežádoucí. Takže se rozhodneme "napravit" situaci podmínkou if(!flag1 && !flag2 && !flag3). Pokusíme se čip uspat jedině když jsou všechny vlajky vynulované (tedy aplikace nemá žádnou nevyřízenou událost). Bude to fungovat ?

// Spánek - "Race condition" - ukázka problému (ATTINY416)
#define F_CPU 3333333
#include <avr/io.h>
#include <avr/sleep.h>
#include <avr/interrupt.h>

inline void generate_pulse(void); // krátký pulzík pro sledování na osciloskopu
volatile uint8_t flag1=0, flag2=0, flag3=0; // vlajky signalizujcí událost 1, událost 2 a událost 3

int main(void){
PORTA.PIN5CTRL = PORT_ISC_RISING_gc; // Externí přerušení vzestupnou hranou na PA5
PORTA.DIRSET = PIN4_bm; // PA4 výstup pro signalizaci "reakce"
set_sleep_mode(SLEEP_MODE_IDLE);  // volím režim spánku "IDLE" (mělký)
sleep_enable(); // povolíme spánek
sei(); // globální povolení přerušení

 while (1) { 
   // reagujeme na události
   if(flag1){  // pokud se stala událost 1, reagujeme na ni
    flag1=0; // vyčistíme vlajku
    generate_pulse(); // užitečná reakce na událost 1
   }
   if(flag2){
    flag2=0;
    asm("nop"); // nějaká užitečná reakce na událost 2
   }
   if(flag3){
    flag3=0;
    asm("nop"); // nějaká užitečná reakce na událost 3
   } 
   // proces usínání
   if(!flag1 && !flag2 && !flag3){ // jdi spát pokud na reakci nečeká žádná událost - bacha tohle není dobře
    sleep_cpu(); // jdeme spát 
   }     
 }
}

ISR(PORTA_PORT_vect){
 PORTA.INTFLAGS = PORT_INT5_bm; // vyčistíme vlajku externího přerušení
 flag1=1; // signalizujeme hlavní smyčce že se odehrála událost 1
}

inline void generate_pulse(void){
 PORTA.OUTSET = PIN4_bm; // vygeneruje pulzík na PA4
 asm("nop");
 PORTA.OUTCLR = PIN4_bm;
}

Vyzkoušíme teď jestli aplikace reaguje správně. Pulzy z generátoru přivedu na vstup našeho mikropočítače a budu sledovat jestli opravdu na každý vstupní impulz zareaguje pulzíkem na výstupu. Na následující animaci (gif) můžete vidět nepříjemný problém. Na první vstupní pulz aplikace zareaguje spolehlivě, ale pokud druhý pulz přijde v nevhodnou chvíli tak se něco stane a aplikace na něj nereaguje. Tohle je chyba která má vysoký "frustrační potenciál". V typických situacích se totiž projeví jen opravdu velmi zřídka (vstupní impulz se musí trefit do velmi úzkého "mrtvého pásma"). Takže se chyba může projevit jednou za den, jednou za týden a nebo jednou za měsíc a vám se ji ani za boha nepodaří vyvolat zrovna když ji chcete pozorovat.


Pokud druhý pulz přijde v nevhodnou chvíli tak na něj aplikace nereaguje.

/ Příčina |

Příčina problému už tu jednou padla. Popisovali jsme co by se stalo kdybychom prostě na konec hlavní smyčky (while(1)) přidali sleep_cpu(). Totéž se děje i v této situaci. Jen je potřeba si uvědomit že se podmínka if(!flag1 && !flag2 && !flag3) přeloží na několik instrukcí a trvá několik strojových cyklů. Během jejich vykonávání opět může přijít přerušení které stav jedné z vlajek změní a aplikace už na to poté nepřijde a usne. Pojďme se podívat na výsledný strojový kód pro mikropočítač, který zmíněnou podmínku realizuje:

if(!flag1 && !flag2 && !flag3){ // jdi spát pokud na reakci nečeká žádná událost - bacha tohle není dobře

00000054  LDS R24,0x3F02   Načti hodnotu flag1 z RAM 
00000056  CPSE R24,R1      Zjisti jestli je vlajka nulová
00000057  RJMP PC-0x001D   Pokud je nenulová, jdi na začátek hlavní smyčky 
00000058  LDS R24,0x3F01   Načti hodnotu flag2 z RAM  
0000005A  CPSE R24,R1      Zjisti jestli je vlajka nulová 
0000005B  RJMP PC-0x0021   Pokud je nenulová, jdi na začátek hlavní smyčky
0000005C  LDS R24,0x3F00   Načti hodnotu flag3 z RAM  
0000005E  CPSE R24,R1      Zjisti jestli je vlajka nulová 
0000005F  RJMP PC-0x0025   Pokud je nenulová, jdi na začátek hlavní smyčky 
00000060  SLEEP            Jdi spát
00000061  RJMP PC-0x0027   Jdi na začátek hlavní smyčky 

Pokud rutina přerušení od sledované události (hrana na PA4) přijde mezi instrukcemi na adresách 0054 až 0060 tak aplikace usne bez ohledu na to, že je flag1 nastavená na jedničku (program totiž operuje s její zastaralou hodnotou). Tento problém je společný pro většinu mikrokontrolérů a jeho řešení se různí podle platformy.

/ Řešení |

Na mikrokontrolérech AVR se nabízí dvě řešení. Jedno z nich stojí na speciální vlastnosti instrukce sei (globální povolení přerušení). Jakákoli instrukce následující za sei se totiž vždy vykoná bez ohledu na to zda je aktivní nějaké přerušení. Případná rutina přerušení je vyvolána vždy až po vykonání následující instrukce. Jak tuto vlastnost využít vysvětlím na následujícím fragmentu zdrojového kódu.

// proces usínání
cli(); // dočasně vypneme přerušení aby se nám "flagy" neměnili pod rukama (chceme provést "atomickou" operaci)
if(!flag1 && !flag2 && !flag3){ // zjisti podle stavu vlajek zda můžeme jít spát
 sei(); // zpět zapneme přerušení - sleep musí následovat hned za touto instrukcí
 sleep_cpu(); // jdeme bezpečně spát 
}else{
 sei(); // jinak zpět zapneme přerušení
}

Program vyhodnocuje stav vlajek s dočasně vypnutým přerušením (pomocí cli). To je vcelku běžný postup v mnoha různých situacích (viz tzv. atomické operace). Pokud je některá z vlajek nastavená, celý proces skončí opětovným povolením přerušení (sei) a čip vůbec nepřejde do režimu spánku. Pokud jsou všechny vlajky vynulované (a čip tedy může jít spát), provede se sekvence dvou instrukcí. Nejprve sei a bezprostředně poté sleep (schovaná v makru sleep_cpu). Pokud by se stalo, že během vyhodnocování podmínky přijde vstupní pulz, aplikace na něj nebude reagovat neboť přerušení jsou dočasně vypnutá. Neobsloužené přerušení bude tedy "čekat" na pozadí. Teprve až poté co program vykoná instrukci sei a následně ještě instrukci sleep a usne. Tak teprve pak se čekající přerušení dostane na řadu a čip okamžitě probudí a umožní mu zareagovat a vygenerovat pulz. Přesně tento postup je zdokumentován i v "oficiální" dokumentaci avr-libc/sleep.h. Výsledné chování demonstruje následující animace. Všimněte si nejprve toho že aplikace už nevynechává pulzy. A poté si můžete všimnout, že pro určitou polohu druhého pulzu aplikace někdy vygeneruje pulzík o pár mikrosekund dřív nebo později. To je dáno právě tím zda stihne rutina přerušení přijít ještě včas před vyhodnocením stavu vlajek. A nebo zda to nestihne a aplikace pak reaguje až se zpožděním odpovídajícím celému vyhodnocení + ještě probuzení ze spánku. Ale to je pouze kosmetická vada, která typicky nečiní problém, neboť k takovému zpoždění dochází běžně vlivem zdržení při obsluze dalších událostí (od ostatních vlajek). Zmiňuji to tedy jen pro zajímavost.


Aplikace už spolehlivě reaguje na obě vstupní události (pulzy)

/ Další (lepší) řešení |

Pokud se vám zdálo předchozí řešení krkolomné, nebo prostě takové křivé. Mohu vám nabídnout přímočařejší. AVRka obsahují bit Sleep Enable. Pokud je bit vynulovaný tak program neusne ani když vykoná instrukci sleep. Někdy se tento bit používá jako bezpečnostní prvek. Těsně před usnutím se nastaví a po probuzení zase vynuluje. Aby se nějakým nedopatřením nemohlo stát, že aplikace omylem vykoná instrukci sleep a usne v okamžiku kdy to program nečeká (ten se pak už ani nemusí probudit, pokud nebude nastavena periferie která by čip probudila). Na druhou stranu pravděpodobnost, že se to stane je asi velmi nízká. Domnívám se tedy, že původní zamýšlený význam tohoto bitu je právě k řešení našeho problému. Necháme naši aplikaci aby atomicky zkontrolovala stav flagů a pokud jsou všechny nulové tak nastaví SE bit a povolí tím spánek. Pak aplikace projde sérií podmínek kterými se testují flagy a případně na ně reaguje. A poté prostě vykoná instrukci sleep, tedy pokusí se usnout (a usne jen pokud byl spánek povolen SE bitem). S každým nastavením jakéhokoli flagu SE bit nulujeme (zakazujeme spánek). Díky tomu je nemožné aby aplikace usnula ve stavu kdy je některý flag s hodnotou jedna a čeká na obsloužení. V takovém případě prostě instrukce sleep neudělá nic a program pokračuje a provede znovu test všech flagů...

int main(void){
 PORTA.PIN5CTRL = PORT_ISC_RISING_gc; // Externí přerušení vzestupnou hranou na PA5
 PORTA.DIRSET = PIN4_bm; // PA4 výstup pro signalizaci "reakce"
 set_sleep_mode(SLEEP_MODE_IDLE);  // volím režim spánku "IDLE" (mělký)
 sei(); // globální povolení přerušení

 while (1) { 
   // zjišťujeme zda smíme či nesmíme usnout (atomicky)
   cli();
   if(!flag1 && !flag2 && !flag3){
    sleep_enable(); // pokud smíme usnout, povolíme spánek nastavením SE bitu
   }
   sei();
   // reagujeme na události
   if(flag1){  // pokud se stala údálost 1, reagujeme na ni
    flag1=0; // vyčistíme vlajku
    generate_pulse(); // užitečná reakce na událost 1
   }
   if(flag2){
    flag2=0;
    asm("nop"); // nějaká užitečná reakce na událost 2
   }
   if(flag3){
    flag3=0;
    asm("nop"); // nějaká užitečná reakce na událost 3
   } 
   // proces usínání - zkusíme usnout (čip neusne pokud není spánek povolen)
   sleep_cpu();
 }
}

ISR(PORTA_PORT_vect){
 PORTA.INTFLAGS = PORT_INT5_bm; // vyčistíme vlajku externího přerušení
 sleep_disable(); // zakáže spánek
 flag1=1; // signalizujeme hlavní smyčce že se odehrála událost 1
}

| Poznámky /

| Závěr /

Pokud někdo používáte ještě jiný způsob jak zajistit bezpečné usnutí / neusnutí, pošlete mi ho i s komentářem na mail a já ho rád zveřejním.

| Odkazy /

Home
| V1.00 29.8.2021 /
| By Michal Dudka (m.dudka@seznam.cz) /