Programátorské poznámky k jednomu Arduino projektu

Do fóra Robodoupěte poslal Fulda odkaz na projekt, který ukazuje připojení maticové klávesnice 4×4 klávesy k Arduinu pomocí pouhého jediného analogového vstupu (+2 piny na napájení). To je oproti běžnému zapojení se 4 digitálními vstupy na řádky + 4 digitálními vstupy na sloupce podstatná úspora. Co se mi ovšem dosti nelíbilo, byl program uvedený v projektu.

Projekt je k nalezení na Instructables, schema zapojení je na obrázku níže. Využívá toho, že vhodnou volbou odporů v zapojení dosáhneme pro každou klávesu při zmáčknutí jinou celkovou hodnotu jednoho odporu v odporovém děliči a tedy jiné napětí, které pomocí A/D převodníku přečteme a podle toho zpět zjistíme, kterou klávesu uživatel zmáčkl. To je hezký nápad (i když samozřejmě vůbec ne nový). K tomu já se vyjadřovat nebudu, chci ale vysvětlit několik věcí ohledně programové obsluhy. Ve článku proto zkusím napsat několik námětů, jak program vylepšit, a berte to ne jako kritiku tohoto jednoho projektu, ale jako náměty k zamyšlení o programování obecně.

Klávesnice 4×4 připojená jediným pinem
(detaily na Instructables)


Program vypadá přibližně takhle1:

void loop()
{ 
  readvalue = analogRead(Button);
  if (readvalue==852){lcd.clear();lcd.print("A");}else{ 
    if (readvalue==763){lcd.clear();lcd.print("B");}else{ 
      if (readvalue==685){lcd.clear();lcd.print("C");}else{ 
  ...
                  if (readvalue==279){lcd.clear();lcd.print("0");}else{

atd., a pak to končí hromadou pravých závorek2
Co se mi na tom hned na první pohled nelíbilo (a na co v debatě na Instructables taky brzy jeden čtenář upozornil), je, že hodnota analogového vstupu se porovnává s konkrétním číslem na rovnost. Stačí drobný šum, změna teploty, zoxidovaný kontakt apod., které způsobí, že napětí na vstupním pinu bude malinko jiné a následně ho pak A/D převodník převede na jiné číslo, třeba 853 místo 852 a klávesa ‚A‘ už se nerozpozná. Takže první praktická rada: nikdy netestujte, zda je hodnota z analogového vstupu rovna něčemu, vždy testujte nerovnosti nebo interval.

Vylepšená verze proto kontroluje, jestli přečtená hodnota je nějak kolem očekávané. Z pokusu jim vyplynulo, že hodnoty kolísají o ±3, takže kód si změnili takto:

if(abs(readvalue-852)<=8){lcd.clear();lcd.print("A");}

atd.

Pak jsem dočetl až na konec projektu, a tam se píše: „As you can see the program sometimes confuses buttons but it still work , in theory ther is no thing wrong with the circuit but the code needes more calibration.“
Tak mi to připomnělo pěkný bonmot: In theory there is no difference between theory and practice, but in practice…

Ten kód s kontrolováním hodnoty v nějakém rozsahu je lepší. Myslím přesto, že v tomhle případě není potřeba zjišťovat, jestli to je přesně kolem té hodnoty, ale stačí zjistit, k čemu je zjištěná hodnota nejblíž. To se udělá snadno porovnáváním přečtené hodnoty proti půlce mezi dvěma sousedními hodnotami. Nebude pak potřeba při hledání dělat výpočet, jen se porovná. V programu je jen potřeba hodnoty srovnat podle velikosti, aby se to dělalo snadno, a pak to bude vypadat nějak takhle3:

void loop()
{ 
  uint16_t readvalue = analogRead(A1);
  if (readvalue<482){lcd.clear();lcd.print("0");}else{
    if (readvalue<724){lcd.clear();lcd.print("C");}else{ 
      if (readvalue<807){lcd.clear();lcd.print("B");}else{ 
        if (readvalue<908){lcd.clear();lcd.print("A");}else{ 
          if (readvalue<994){lcd.clear();lcd.print("D");} 
        }
      }
    }
  }
}

a to proto, protože 482 je napůl mezi 279 a 685, 724 napůl mezi 685 a 763, 807 mezi 463 a 852 atd. Když to je víc než 994, tak se neujme ani poslední test, ale to jen znamená, že uživatel nic nezmáčkl a tedy nic nechceme vypsat4.

Stále se mi však program moc nelíbí, a důvodem je to, že je všechno namlácené do jedné fukce loop, která se tak stala dost nepřehlednou. Nedejbože kdybych to měl jen jako čtení vstupu pro něco většího. Mnohem lepší je rozdělit si to, a dekódování schovat do nějaké funkce:

char urciPismeno(uint16_t cislo)
{
  if (cislo<482){return '0';}
  else{ if (cislo<724){return 'C';}
    else{ if (cislo<807){return 'B';}
      else{ if (cislo<908){return 'A';}
        else{ if (cislo<994){return 'D';}
        }
      }
    }
  }
  return 0; // nic nezmáčknuto
}

void loop() {
  uint16_t readvalue = analogRead(A1);
  char pismeno = urciPismeno(readvalue);
  if(pismeno>0) {
    lcd.clear();
    lcd.print(pismeno);
  }
  delay(1000);
}

program je nyní mnohem čitelnější. Když dostane hodnotu odpovídající nějaké zmáčklé klávese, vrátí její hodnotu a když není nic zmáčknutého, vrátí 05. A tohle nám také umožní oddělit zpracování dat od jejich prezentace, protože co kdybych se rozhodl nevypisovat to na LCD displej ale třeba poslat po seriáku? V původním kódu bych musel 16x přepsat lcd.clear();lcd.print("D"); na Serial.print("D"); (atd. pro další klávesy) a pak bych si uvědomil, že jsem třeba chtěl napsat i2c.send('D'); a zas bych to celé musel procházet a 16x měnit. Takhle si to převedu, a s výsledkem provedu co je potřeba a bude se to psát jen jednou. A změna taky jen jednou.

Z hlediska očekávaného průběhu programu, kdy pouze jednou za čas uživatel něco zmáčkne a jinak procesor nic nedělá, by bylo lepší celou testovací sekvenci otočit, aby se jako první provedla nejvíc pravděpodobná varianta a nemuselo se už nic dál zkoumat:

char urciPismeno(uint16_t cislo)
{
  if (cislo>994) {return 0;}
  else{ if (cislo>908) {return 'D';}
        else{ if (cislo>807) {return 'A';}
              else{ if (cislo>724) {return 'B';}
                    else{ if (cislo>482) {return 'C';}
                          else {return '0';}
                        }
                  }
            }
      }
}

Zde jsem si také dovolil při otáčení nerovnosti nenapsat správný opak, tj. >=, ale ono to je prakticky jedno, ten případný posun hranice o jedničku nehraje roli. Taky jsem přeformátoval text, takže if a else jsou pod sebou a otevírací a zavírací závorka taky, což může být čitelnější6.

Já osobně bych ale byl líný psát tu hromadu vnořených ifů, takže bych to třeba napsal takhle:

char urciPismeno(uint16_t cislo)
{
  if (cislo>994) return 0;
  if (cislo>908) return 'D';
  if (cislo>807) return 'A';
  if (cislo>724) return 'B';
  if (cislo>482) return 'C';
  return '0';
}

a to proto, že když je podmínka splněna, tak příkaz return vrátí danou hodnotu, funkce skončí, už se dál nepokračuje, takže je zbytečné ten další kód balit do else větve jakože když podmínka splněná není, tak… A taky když za ifem (a i jinými konstrukcemi v C/C++) je jediný příkaz, není potřeba psát kolem něj složené závorky7.

No a když tohle vidím, tak už bych to možná napsal s pomocí tabulky, kde bych měl spárované číslo a k tomu příslušné písmeno:

typedef struct{
  uint16_t cislo;
  char pismeno;
} tabulka_t;

tabulka_t tabulka[] = {
  {994, 0},
  {908, 'D'},
  {807, 'A'},
  {724, 'B'},
  {482, 'C'},
  {0, '0'}
};

char urciPismeno(uint16_t cislo)
{
  uint8_t index=0;
  while( cislo < tabulka[index].cislo ) index++; 
  return tabulka[index].pismeno;
}

V cyklu tady jedu tak dlouho, dokud nenajdu příslušné písmeno8. Určitě to skončí, protože poslední záznam v tabulce je s číslem 0 a tak tam nikdy nebude nerovnost splněná a tak se index nezvýší 9. A přitom venku v loop se nic nezměnilo.

Ten cyklus se dá napsat i takhle:

char urciPismeno(uint16_t cislo)
{
  uint8_t index;
  for(index=0; cislo < tabulka[index].cislo; index++); 
  return tabulka[index].pismeno;
}

a z hlediska jazyka C to bude jedno.

A nakonec jsem si ještě pohrál s funkcí na určováním písmene, a napsal ji s přístupem k tabulce poněkud nižším způsobem, přes pointry:

char urciPismeno(uint16_t cislo)
{
  uint8_t *pointr=(uint8_t*)tabulka;
  while( cislo < *(uint16_t*)pointr )
    pointr+=3;
  return *(pointr+2);
}

ale to už není moc k ukazování, protože to zneužívá toho, že tuším, jak to bude uložené v paměti, což ale nemusí být pravda (i když pro to Uno to tak je).

Pokud bychom se podívali na to, jak bude kód efektivní na Arudinu, tak to možná bude překvapivé. Skoro bych to shrnul do motta „Když budete programovat hezky, kód může být kratší a rychlejší“.
Pro porovnání jsem udělal několik prográmků v Arduinu tak, aby se lišily jen v těch diskutovaných aspektech, a nechal přeložit pro Uno. Výsledek je na obrázcích, všimněte si, kolik který prográmek zabral programové a datové paměti10:

Na závěr znovu uvádím, že cílem nebylo přepracování na ideální kód, ale naznačení několika možných variant na vylepšení a ukázat některé běžné programátorské postupy.

Edit: Článek má pokračování, které komentuje hardware (zapojení).


Poznámky:

  1. Toto je jen úryvek, program na Instructables je trochu nečitelný, protože ho autor okopíroval do běžného textu, kde se ale špatně přeformátoval, další vylepšení pak dal jen jako obrázek
  2. Tak vypadají běžné programy v LISPu a je to předmětem mnoha vtipů, třeba zde, ale v C/C++ to není úplně běžné :-)
  3. Tady uvedu jen pár hodnot, ať to není dlouhé, zbylé by se tam samozřejmě přidaly obdobně.
  4. Pro ostatní klávesy by tam byly přidané patřičné další hodnoty
  5. Všimněte si rozdílu mezi return '0' a return 0 – první vrací znak 0 (reprezentovaný svým ASCII kódem, číslem 48 = 0x30) a druhý vrací číslo 0. A když bych napsal "0", tak to je řetězec znaků, který je shodou okolností jednopísmenný a je to číslice 0.
  6. Takovýchhle formátovacích konvencí je víc různých. Na vykonávání kódu to samozřejmě vliv nemá.
  7. Akorát pak trochu hrozí chyby, když bych tam nechtěl mít jeden příkaz ale dva a zapomněl tam ty {} doplnit.
  8. Proto je tam to „menší než“ místo „větší než“, které co je v předchozím kusu kódu
  9. A tady se mi vyloženě hodí, že netestuju <= ale <, protože to by při hledání čísla 0 i na tom posledním řádku bylo splněné a jel bych pořád dál…
  10. Na nějakém Robodoupěti se o tom kdyžtak můžeme pobavit, ono to nakonec je docela logické, ale na vysvětlení do článku to není, bylo by to příliš obsáhlé