Warning
This page is located in archive. Go to the latest version of this course pages. Go the latest version of this page.

Hints - Semestrální práce

Doporučený postup

Obě aplikace (ovládací i Nucleo) lze implementovat rozličným způsobem a lze také začít úplně od začátku, což může být výhodné zejména pokud je cílem realizovat aplikaci s bonusovými funkcionalitami. Doporučený postup je však využít řešení předešlých domácích úkolů, zejména HW 10, které obsahují dílčí části a způsoby řešení nejdůležitější funkcionalit - vícebajtová komunikace, ošetření zdrojů více asynchroních událostí, přerušení na Nucleo desce, vícevláknovou aplikaci a vyčítání klávesnice. S ohledem na dostupnost binárního obrazu s testovacím řešením části pro Nucleo desku je vhodné začít s implementací ovládací aplikace, následně využít dostupnou aplikaci pro Nucleo desku a otestovaci komunikaci a implementovat vlastní aplikaci pro Nucleo desku, např. s využitím předchozí aplikace v rámci řešení HW 10.

Jeden z doporučených postupů může být následující:

  1. Navrhnout strukturu více-vláknové ovládací aplikace. Dedikovat jedno vlákno na každý zdroj asynchronních událostí, tj. vstup od uživatele (klávesnice), komunikace ze seriového portu, a jedno hlavní vlákno s frontou zpráv, ve které bude řešena aplikační logika. Jedná se o architekturu boss/worker, ve které hlavní vlákno přistupuje ke zdrojům (např. posílání zprávy) a ostatní vlákna komunikují s hlavním vláknem zejména prostřednictvím zasílání zpráv. Např. při stisku klávesy 'a' vlákno ošetřující vstup z klávesnice zasílá zprávu EV_ABORT hlavnímu vláknu, které následně odesílá zprávu MSG_ABORT Nucleo desce.
  2. Navrhnout vhodné datové struktury pro reprezentaci výpočtu, rozsahu části komplexní roviny, velikosti obrázku, výpočetního gridu a vlastního obrázku.
  3. Navrhnout vhodný způsob a konvenci reportování stavů, např. zavedením logovacích tříd: INFO, DEBUG, WARN a ERROR.
  4. Využit funkce pro vytvoření grafického okna xwin_init() a uvolnění zdrojů xwin_close() spolu s otestováním generování obrázku, např. barevné pruhy, červený obrázek, zelený obrázek atd., s využitím funkce xwin_redraw().
  5. Následně implementujte vlastní výpočet fraktálu v rámci ovládací aplikace a ověřte zobrazení výpočetního gridu (tj. pole hodnot $k$ pro jednotlivé pixely odpovídající bodům komplexní roviny) do barevného obrázku. Např. funkcí compute(), kterou identicky můžete použít pro implementaci aplikace na Nucleo desce:
    uint8_t compute(double cx, double cy, double px, double py, uint8_t max_iteration);
  6. Aplikaci otestujte s využitím dostupného obrazu aplikace pro Nucleo
  7. Implementujte vlastní aplikaci pro Nucleo. Využijte vyčítání dat ze seriového portu v přerušení a ukládání dat do bufferu s následným unmarshalingem. Podobně realizujte přenos zpráv v přerušení s uložením zprávy do samostatného pole pro marshalling datové struktury zprávy s následným překopírováním zprávy jako pole bajtů do odesílacího bufferu, který je postupně odesílán v obsluze přerušení seriového portu po odeslání jednoho dílčího bajtu
  8. Aplikaci otestujte na “memory-leaks”, korektní ukončení po stisku, např. klávesy 'q', možnost přerušení výpočtu, atd.
  9. Aplikaci doplňte o zpracování volitelných argumentů příkazové řádky modifikující výchozí hodnoty výpočtu (rozsah, rozlišení, hodnota $c$, atd.)
  10. Implementaci upravte, odstraňte nepotřebné části nebo reimplementujte méně čitelné části. Zkontrolujte text vypisovaných zpráv. Zkontrolujte dodržování zvolené kódovací konvence a případně kód upravte.
  11. Aplikaci otestujte na dostatečném množství rozličných scénařů a nečekaných vstupů.
  12. Rozhodněte zdali aplikaci budete dále rozšiřovat či odevzdáte základní řešení
  13. V případě dalšího rozšiřování zvažte uložení zdrojových kódu v repositáři, např. https://gitlab.fel.cvut.cz/

Pokud to bude možné konzultujte svůj postup s řešením semestrální práce se svým cvičícím na cvičení. Zdrojové kódy včas odevzdejte do odevzdávacího systému a domluvte si s cvičícím termín konzultace pro odevzdání a ohodnocení semestrální práce.

Vlákna

Použití vláken není vyloženě nutné, nicméně je velmi doporučené, protože umožňuje organizovat a zpřehlednit kód. V každém vláknu je možné se soustředit na konkrétní části odpovídající obsluze zdroje asynchroních zpráv, případně vizualizaci. Lze využít realizovaných programů HW 09 (Nucleo) - Jednobajtová komunikace nebo HW 10 (Nucleo) - Interaktivní aplikace s komunikací s nadřazeným počítačem, které v zásadě odpovídají ukázce na 8. přednášce.

Například lze realizovat vlákna pro každý zdroj asynchronních události, které jsou v aplikaci v zásadě dva a to

  1. interaktivní vstup uživatele z klávesnice
  2. příjem zpráv ze sériového portu.

Přirozeně se nabízí vytvořit jedno hlavní vlákno, ve kterém můžeme implementovat aplikační logiku a po jednom vláknu pro každý zdroj asynchronních události (vstup z klávesnice a vyčítání dat ze sériového portu).

Jelikož chceme umožnit okamžité (nebo velmi rychlé) ukončení aplikace při stisku klávesy q, použijeme neblokovaný režim pro načítání dat ze sériového portu, například s využitím pollingu implementovaného v dostupném modulu prg_serial_nonblock.c.

Komunikace po sériové lince je v našem případě plně duplexní, proto můžeme bezpečně v jednom vláknu pouze číst ze sériového portu a v jiném vláknu zapisovat. Určitě je dobré číst vstup pouze v jediném vláknu (platí jak pro sériový port, tak pro klávesnici). Z hlediska zápisu na sériový port je obecně vhodné též přístupovat k zápisu z jediného vlákna a to zejména z důvodu zapsání kompletní zprávy na sériový portu. Proto je v případě přístupu k sériovému portu z více vláken využít kritické sekce (tj. exkluzivní přístup řízený mutexem). V našem případě je plně postačující zapisovat na sériový portu například pouze z hlavního vlákna, které řeší kompletní aplikační logiku programu.

Komunikaci mezi vlákny lze realizovat opět různými způsoby. Nicméně v případě hlavního vlákna a dvou vláken pro vyčítání interaktivního uživatelského vstupu z klávesnice a čtení dat ze sériového portu probíhá komunikace pouze směrem od čtecích vláken do hlavního vlákna. Proto hlavní vlákno realizujeme jako smyčku, ve které vyčítáme zprávy z fronty, do které zapisují zprávy vyčítací vlákna. Hlavní vlákno reaguje na uživatelský vstup vytvořením příslušené zprávy pro Nucleo nebo výpisem či aktualizací grafického výstupu po zpracování zpráv od Nuclea.

Přestože se může na první pohled zdát použití fronty a dalších zpráv složité, např. z hlediska definovaných struktur a délky kódu, umožňuje tento přístup organizovat kód do dílčích nezávislých částí. Také je velmi přimočaré přidávání dalších funkcionalit, neboť komunikační rozhraní mezi vlákny tvoří fronta zpráv a není nutné individuálně řešit zasílání signálů pro různé conditional variables. Definujeme pouze novou zprávu, její zaslání a zpracování. Vše jednotně a podobně jako ostatních zprávy. Přirozeně závisí na vkusu a zkušenosti, nicméně přehlednost a udržitelnost kódu je u takto explicitně definovaného rozhraní mnohem vyšší, neboť pracuje s datovou abstrakcí a vlastně i menším počtem sdílených proměnných. Zcela jistě svou roli také hraje zvládnutí editace textu zdrojových kódů. Při zvládnutí svého oblíbenoho prostředí je režie pro napsání kódu zanedbatelné v porovnání s případnou nutností ladění a řešení návazností synchronizačních funkcí.

Komunikace mezi vlákny frontou zpráv

Motivační fronta zpráv je dostupná v modulu event_queue.c a odpovídá kruhové frontě realizované v HW 06 - Kruhová fronta v poli. Hlavní rozdíl je v přístupu k datové struktuře z více vláken. Frontu používáme ve stylu producent/konzument, kdy hlavní vlákno je konzumentem a vyčítací vlákna produkují zprávy. Proto je nutné zajistit exkluzivní přístup do fronty prostřednictvím kritické sekce (mutexu).

V případě, že je fronta prázdná, očekáváme, že konzument pozastaví svou činnost do doby, než se objeví nová zpráv, tak abychom zbytečně nevyužívali výpočetní zdroje. Vynucené ukončení od uživatele v tomto případě pošle zprávu, proto nepředstavuje žádný problém. V opačném případě, kdy konzument nestíhá zpracovat zprávy a fronta je plná, je nutné pozastavit činnost konzumentů a v případě zpracování zprávy (uvolnění místa ve frontě) je vhodné takového konzumenta znovou aktivovat. Proto použijeme podmíněné proměnné a mutex. Obecně očekáváme, že hlavní vlákno je dostatečně rychlé a k přeplnění by nemělo docházat často, případně jen nárazově. Podle toho volíme velikost fronty.

Vlastní zprávy ve frontě lze realizovat různě, od jednoduchého ukazatele na typ void až například po samostatné typy zpráv ve výčtovém typu a rozlišení zdroje zprávy jak je použito v dostupném souboru event_queue.h.

Opět může tento přístup vypadat složitý a zbytečně dlouhý. Výhoda je v relativní explicitnosti a tím čitelnosti. Pedevším však udržitelnosti a také snadnějšímu ladění, neboť každá zpráva je jednoznačně identifikovatelná a dokumentovaná v definicich typu v event_queue.h.

Konkrétně v event_queue.h definujeme dva zdroje:

typedef enum {
   EV_NUCLEO,
   EV_KEYBOARD,
   EV_NUM
} event_source;

a pro každý zdroj zprávy definujeme samostatný obsah zprávy strukturami

typedef struct {
   int param;
} event_keyboard;
 
typedef struct {
   message *msg;
} event_serial;

Tedy pro vstup z klávesnice máme pouze datovou část v podobně int hodnoty. V případě zprávy ze sériového portu používáme přímo datovou strukturu message, kterou předáváme jako ukazatel, abychom se vyhnuli zbytečnému kopírování kompletní zprávy, která může být několik (desítek) bajtů dlouhá.

Samotnou zprávu pak definujeme jako zdroje zprávy (event_source) a typ zpráv (event_type), které jsou následně doplněny o vlastní zprávu a to buď struct event_keyboard nebo struct event_serial podle toho jestli je typ zprávy EV_KEYBOARD nebo EV_NUCLEO. Proto zde s výhodou použijeme typ union.

typedef struct {
   event_source source;
   event_type type;
   union {
      int param;
      message *msg;
   } data;
} event;

Položka data struktury event tak obsahuje param i msg. To zdali je v proměnné typu event zpráva od klávesnice (param) nebo ze sériového portu (msg) rozlišíme při vytváření zprávy nastavením příslušné datové části a typu zprávy. Příklad použití může vypadat například

void process_event(event *ev)
{
   if (ev->type == EV_KEYBOARD) {
      printf("Param value %\d\n", ev->data.param);
   } else if (ev->type == EV_NUCLEO) {
      printf("Msg ptr %p\n", ev->data.msg);
   }
}

Komunikace po sériovém portu a využití union

Komunikace po sériovém portu je zápis a čtení posloupností bajtů, které jsou organizovány do zpráv. Ve zvoleném případě zpráv se jedná o zprávy definovaných (fixních) délek. Zcela jistě lze přístupovat ke zprávám jako posloupnosti bajtů a k jednolivým částech přistupovat prostřednictvím indexů prvků. Takový přístup však není příliš čitelný, neboť vyžaduje detailní znalost organizace jednotlivých zpráv. Proto může být výhodnější definovat si pro každý typ zprávy samostatnou strukturu tak, jak je to provedeno v přiloženém souboru messages.h. Tím získamé relativně čitelný přístup na dílčí položky jednotlivých zpráv. Komunikační zpráva pro Nucleo pak odpovídá struktuře message, která obsahuje jednobajtový indikátor typu zprávy type a union možných dílčích zpráv

typedef struct {
   uint8_t type;   // message type
   union {
      msg_version version;
      msg_startup startup;
      msg_set_compute set_compute;
      msg_compute compute;
      msg_compute_data compute_data;
   } data;
   uint8_t cksum; // message command
} message;

Kromě typu a dílčího obsahu zprávy v položce data dále zpráva obsahuje položku cksum, která představuje primitivní způsob jak kontrolovat konzistenci a správnost došlých bajtů. Hodnota cksum je spočítána tak, aby součet jednotlivých bajtů (uint8_t) zprávy včetně cksum byl 0xFF. Tedy pokud se stane nějaká chyba, je možné ji detekovat. Jedná se o základní detekčních mechanismus a v praktických úloha se zpravidla navrhují cycklické redundatní kódy podle charakteru možných chyb. V našem případě nám postačuje tento jednoduchý kontrolní součet cksum, který je součástí transformace struktury zprávy do pole bajtů, která se provádí ve funkci fill_message_buf() v modulu messages.c ve druhé části funkce.

   // 2nd - send the message buffer
   if (ret) { // message recognized
      buf[0] = msg->type;
      buf[*len] = 0; // cksum
      for (int i = 0; i < *len; ++i) {
         buf[*len] += buf[i];
      }
      buf[*len] = 255 - buf[*len]; // compute cksum
      *len += 1; // add cksum to buffer
   }
neboť kontrolní součet je poslední bajt zpráv.

Při posílání zpráv jako posloupnosti bajtů po sériové lince vás může napadnou co se stane, když se třeba jeden bajt ze zprávy nepřečte, případně se přenese chybně. V takovém případě může zafungovat právě cksum. Obecně však předpokládáme nějaký výchozí definovaný stav, např. běží ovládací aplikace a resetujeme Nucleo. V takovém případě čteme ze sériového portu první znak, na základě kterého identifikuje typ zprávy, který definuje délku zprávy. Proto dále čteme očekávaný počet bajtů. Následně kontrolujeme cksum. Pokud vše sedí, pokračujeme dále. Pokud ne, hlasíme chybu. Může se však stát taková chyba, kterou nedokážeme jednoduchým cksum detekovat, případně dojde k výpadku např. jednoho bajtu. Pak interpretujeme přijatá chybná data jako nějaký konkrétní typ zprávy, což může stále fungovat, jen to nebude podle našich původních představ. Obecně se tomu úplně nevyhneme, ale můžeme riziko snížit kontrolou během interpretace dat, použitím lepší detekce CRC nebo samoopravných kódů. V neposlední řadě také uvedením do nějakého výchozího stavu, například resetem Nuclea. Nicméně pro naši aplikaci je sériové rozhraní dostatečně spolehlivé a zpravidla jediný problém, se kterým se můžeme setkat, je při zapnutí nebo přeprogramování Nuclea, při kterém mohou být přečteny ze sérivého portu nějaké náhodné bajty. V takovém případě postačí restart Nuclea a/nebo ovládací aplikace.

Zápis zprávy jako pole bajtů

Se zprávou jako strukturou se z hlediska přístupu k jednotlivým položkám zprávy pracuje mnohem intuitivněji než indexování do pole bajtů. Nicméně přenos zprávy po sériové lince je přenos definovaného počtu bajtů odpovídající jednotlivým položkám. Převodu zprávy (objektu / datové struktury) na posloupnost bajtů se říká marshalling a v dostupném modulu messages.c je implementován ve funkci fill_message_buf(), která je jednou ze tří funkcí vyžadující detailní znalost organizace jendotlivých zpráv jako posloupnosti bajtů. Princip můžeme demonstrovat na zprávě MSG_VERSION, která je 5 bajtů dlouhá (typ zprávy, tři datové položky - major, minor, patch a cksum). Vyplnění prvního batju a posledního bajtu je pro všechny zprávy identické, a proto je součástí druhé části funkce uvedené výše. Vlastní datová část pak odpovída případu kdy msg→type je MSG_VERSION, např.

   switch(msg->type) {
   ...
      case MSG_VERSION:
         buf[1] = msg->data.version.major;
         buf[2] = msg->data.version.minor;
         buf[3] = msg->data.version.patch;
         *len = 4;
         break;
   ...
   buf[0] = msg->type; 
   ...
   buf[*len] = 255 - buf[*len]; 
kde je demonstrován přístup k datové položce data typu union, která je v tomto případě validní jako data.version, neboť type je MSG_VERSION, který používáme právě pro indikaci typu zprávy.

Vytvoření zprávy z pole bajtů

Při čtení zprávy ze sériového portu očekáváme, že ja vstupní buffer prázdný a první znak, který přečteme, odpovídá typu zprávy. Na základě typu zprávy můžeme určit počet bajtů zprávy, které je nutné přečíst, abychom mohli vytvořit zprávu konkrétního typu jako proměnnou typu struct. V modulu messages.c je definice funkce get_message_size(), která vrací očekávanou délku zprávy podle typu prostřednictvím předáváného ukazatele len. Například pro zprávu typu MSG_VERSION může funkce vypadat

bool get_message_size(uint8_t msg_type, int *len)
{
   bool ret = true;
   switch(msg_type) {
      ...
      case MSG_VERSION:
         *len = 2 + 3 * sizeof(uint8_t); // 2 + major, minor, patch
         break;
      ...
      default:
         ret = false;
         break;
   }
   return ret;
}
což je explicitní zápis pro 2 bajty společné pro všechny zprávy a 3 bajty pro major, minor a patch. Nic nám nebrání definovat například
 *len = 5;
Případně s využitím číslování typu zpráv jako pole. Konkrétní způsob je tak spíše věcí vkusu a představy o čitelnosti.

V okamžiku, že načteme očekávaný počet bajtů, můžeme pole bajtů interpretovat jako konkrétní zprávu. Příklad implementce je opět dostupný v modulu messages.c ve funkci parse_message_buf(), která může například pro MSG_VERSION vypadat následovně.

bool parse_message_buf(const uint8_t *buf, int size, message *msg)
{
   uint8_t cksum = 0;
   for (int i = 0; i < size; ++i) {
      cksum += buf[i];
   }
   bool ret = false;
   int message_size;
   if (
         size > 0 && cksum == 0xff && // sum of all bytes must be 255
         ((msg->type = buf[0]) >= 0) && msg->type < MSG_NBR &&
         get_message_size(msg->type, &message_size) && size == message_size) {
      ret = true;
      switch(msg->type) {
          ...
          case MSG_VERSION:
            msg->data.version.major = buf[1];
            msg->data.version.minor = buf[2];
            msg->data.version.patch = buf[3];
            break;
          ...            
          default: // unknown message type
            ret = false;
            break;
      } // end switch
   }
   return ret;
}
Opět vycházíme ze znalosti definice komunikačních zpráv jako posloupnosti bajtů. Testujeme cksum a případně reportujeme chybu návratovou hodnout false.

Jedna z hlavních výhod použití modulu messages.c je, že pouze v tomto modulu je nutné použít znalost definice komunikačních zpráv jako posloupnosti bajtů. Máme tak pouze jedno místo, které zvyšuje přehlednost a případné ladění chyb.
courses/b3b36prg/semestral-project/hints.txt · Last modified: 2019/05/01 01:10 by faiglj