Pár poznámek k programování C/C++ přímo na železo.

Motivace.

Na internetu lze najít leccos a je proto dobré dát si dobrý pozor kdo to vystavuje a proč. Programování přímo na železo (bare-metal) má určitá specifika, lidi co to dělají jsou dost konzervativní, u některých je jen přechod z jazyka symbolických adres (assembleru) k čistému C bolestivá záležitost a používat cokoli pokročilejšího je pak nemyslitelné. Na druhé straně stojí nová generace, která se už takovými detaily např. jak funguje procesor nechce vůbec zabývat a věnují se spíš hledání "svatého grálu" IT, tedy programovacího jazyka, který vyřeší všechny jejich problémy, hlavně však nedostatek znalostí a zkušenosti.

Nebude zde nějaký kompletní návod jak naprogramovat mikroprocesor, ale na co nejjednodušším příkladu se budu snažit demonstrovat čeho se vyvarovat a proč. Styl programování jako Arduino je zde použit proto, že vlastně ničemu moc nevadí a lidi to celkem používají. Nicméně z prostředí Arduina není použito vůbec nic, jako cílový procesor je použit STM32F051, má jednoduché jádro Cortex-M0, které se na tento účel docela hodí. Pro úspěšný překlad a vyzkoušení stačí standardní toolchain arm-none-eabi- a z důvodů, uvedených dále je potřeba ještě clang. Nejsou vlastně použity žádné knihovny kromě libc a libm a ty jsou součástí toolchainu.

Pro demonstraci jsem vybral generaci harmonického signálu a jeho výstup na DAC (digitálně-analogový převodník, na STM32F051 výstup na PA4). Aby to bylo co nejjednodušší není ani nějakým specifickým způsobem inicializován celý systém, systémové hodiny tedy běží na defaultní hodnotě (HSI 8MHz). Není ani řešeno přesnější časování výstupu, prostě co se spočítá, okamžitě jde na DAC, což je v praxi samosebou nepoužitelné, ale dobře to demonstruje rychlost výpočtu. Proto chybí i možnost nastavení výstupní frekvence, ve všech příkladech je použito 256 vzorků na periodu.

Úplně stupidní program.

  #include <math.h>
  #include "stm32f05x.h"

  #define STEP (M_PI / 128.0)
  #define AMPL (2047.5)

  double phi = 0.0;

  void dac_init () {
    RCC->AHBENR.B.IOPAEN = 1u;
    RCC->APB1ENR.B.DACEN = 1u;
    DAC->CR.B.EN1 = 1u;
  }
  int dac_step () {
    double v = AMPL * (1.0 + sin (phi));
    phi += STEP;
    return (int) v;
  }
  /*******************************************/
  void setup () {
    dac_init();
  }
  void loop () {
    int a = dac_step();
    DAC->DHR12R1.R = a;
  }
  

Pokud si myslíte, že je to blbá sranda, není tomu tak. Velmi podobnou blbost jsem opravdu našel na webu. A teď proč je to vlastně úplně špatně

Nicméně něco to dělá, máme určitý základ. Sice to zabere 11KiB ve flash, výstupní frekvence je jen cca 3Hz, ale je to jednoduché. Nakonec pro AVR je double de facto jednoduchá přesnost, takže následující příklad není zase tak moc odlišný.

O něco méně pitomý program.

  #include <math.h>
  #include "stm32f05x.h"

  static const float D_PI = 2.0f * 3.141592654f;
  static const float STEP = 3.141592654f / 128.0f;
  static const float AMPL = 2048.0f;

  static float phi = 0.0;

  static void dac_init () {
    RCC->AHBENR.B.IOPAEN = 1u;
    RCC->APB1ENR.B.DACEN = 1u;
    DAC->CR.B.EN1 = 1u;
  }
  static unsigned dac_step () {
    const float v = AMPL * (1.0f + 0.9f * sinf (phi));
    phi += STEP;
    // přetečení argumentu přes periodu -> divné chování
    if (phi > D_PI) phi -= D_PI;
    return (unsigned) v;
  }
  /*******************************************/
  void setup () {
    dac_init();
  }
  void loop () {
    const unsigned a = dac_step();
    DAC->DHR12R1.R = a;
  }
  

Takže to zkusíme v jednoduché přesnosti, trochu to zmodernizujeme. Místo maker, definujících konstanty použijeme statické konstanty. To se dělá i pro složitějsí makra, nahrazující se statickými inline funkcemi kvůli lepší typové kontrole a přehlednosti. Funguje to i v čistém C, novější překladače to umějí přeložit tak, že negenerují žádný kód navíc. Co lze, označíme jako konstantu (usnadní to život překladači) a zamezíme růstu argumentu. Ale i tak to zabere 8KiB a výstupní frekvence se moc nezvětší. Je docela hezké pozorovat na osciloskopu zkreslení signálu, které vzniká tím, že čas výpočtu funkce sin() se mění s argumentem tt. funkce. Ano, tohle je také hodně špatně.

Zkusíme to napravit pomocí tabulky.

  #include <math.h>
  #include "stm32f05x.h"

  static const float STEP = 3.141592654f / 128.0f;
  static const float AMPL = 2048.0f;

  static unsigned arg = 0;
  static uint16_t sin_tab [256];

  static void dac_init () {
    RCC->AHBENR.B.IOPAEN = 1u;
    RCC->APB1ENR.B.DACEN = 1u;
    DAC->CR.B.EN1 = 1u;
  }
  static void tab_init () {
    unsigned n;
    for (n=0; n<256; n++) {
      const float phi = (float) n * STEP;
      const float v = AMPL * (1.0f + 0.90f * sinf (phi));
      sin_tab [n] = (uint16_t) v;
    }
  }
  static unsigned dac_step () {
    const uint16_t v = sin_tab [arg];
    arg += 1u;
    arg &= 0x00FF;
    return v;
  }
  /*******************************************/
  void setup () {
    dac_init();
    tab_init();
  }
  void loop () {
    const unsigned a = dac_step();
    DAC->DHR12R1.R = a;
  }
  

Jediná cesta jak z toho ven, je předpočítat si tabulku hodnot sin() a v reálném čase do ní jen sáhnout si pro tu správnou hodnotu. Zrychlení je velmi podstatné, frekvence je zde 1.3kHz a protože sáhnout do tabulky zabere vždy stejný čas, zmenší se zkreslení.

Přesně takto by to řešil normální programátor, zvyklý na výhody operačního systému. Možná by tabulku umístil na haldu, ale to není podstatné, faktem zůstává, že tabulka, která se pak už nemění zabere 512B v paměti RAM, která je v malém procesoru relativně drahá. A výpočet té tabulky zabere také celkem zbytečně 8KiB flash.

Tabulku musíme umístit do flash.

  #include <math.h>
  #include "stm32f05x.h"

  static unsigned arg = 0;
  extern const uint16_t sin_tab [];

  static void dac_init () {
    RCC->AHBENR.B.IOPAEN = 1u;
    RCC->APB1ENR.B.DACEN = 1u;
    DAC->CR.B.EN1 = 1u;
  }
  static unsigned dac_step () {
    const uint16_t v = sin_tab [arg];
    arg += 1u;
    arg &= 0x00FF;
    return v;
  }
  /*******************************************/
  void setup () {
    dac_init();
  }
  void loop () {
    const unsigned a = dac_step();
    DAC->DHR12R1.R = a;
  }
  

Tabulka je tedy konstantní a můžeme jí dát do flash. Tady to jde prostým označením const. Pro AVR by to bylo složitější, ale to není účelem tohoto pojednání. V čistém C si pro tento účel vygenerujeme pomocný soubor sin.c třeba následujícím skriptem v pythonu

  #!/usr/bin/python
  # -*- coding: utf-8 -*-

  import math

  header = '''/* Generated file */
  #include <stdint.h>
  const uint16_t sin_tab[] = {{{0:s}
  }};
  '''

  def generate ():
    s = ''
    for n in range(0,256):
      if (n % 16) == 0:
        s += '\n  '
      a  = float(n) * math.pi / 128.0
      v  = int (round (2048.0 * (1.0 + 0.90 * math.sin (a))));
      s += '{0:+6d},'.format(v)
    return s

  if __name__ == '__main__':
    s = generate()
    f = open ('sin.c','w')
    f.write(header.format(s))
    f.close()
  

No a teď už máme jen 748B ve flash, celkem nic v RAM a frekvence se drží na 1.3kHz. To už je slušná optimalizace. Ovšem za cenu použití nějakého externího generátoru. Jestli by to šlo udělat v čistém C přímo pomocí nějakých maker nevím, ale pochybuji o tom.

V moderním C++ to jde přímo, ale trochu to dře.

Za povšimnutí stojí, že v inicializaci periferií se v C++ používají odkazy, v čistém C ukazatele. Není to nutné, šlo by to udělat stejně jako v C, ale vypadá to lépe a nezvětšuje to délku kódu.

  #include "init.h"
  #include "mmath.h"
  #include "stm32f05x.h"

  static constexpr unsigned W_TB = 8u;
  static constexpr double   AMPL = 2048.0;
  static constexpr int      ULEN =  1  << W_TB;
  static constexpr unsigned MASK = (1u << W_TB) - 1u;

  static constexpr uint16_t u16_sin (const int x) {
    const double a = (double (x) * D_PI) / double (ULEN);
    const double s = AMPL * (1.0 + 0.90 * sincos (a, true));
    return i_round (s);
  }
  static const TABLE<uint16_t, ULEN> sin_tab (u16_sin);
  /**************************************************/
  class DAC_Class {
    unsigned arg;
    public:
      explicit constexpr DAC_Class () noexcept : arg (0u) {}
      void init () const {
        RCC.AHBENR.B.IOPAEN = 1u;
        RCC.APB1ENR.B.DACEN = 1u;
        DAC.CR.B.EN1 = 1u;
      }
      void step () {
        DAC.DHR12R1.R = sin_tab [arg];
        arg += 1u;
        arg &= MASK;
      }
  };
  static DAC_Class dac;
  /**************************************************/
  extern "C" void setup () {
    dac.init();
  }
  extern "C" void loop () {
    dac.step();
  }
  

Na první pohled to nevypadá zase tak hrozně, no dobře je tam nějaké to constexpr, to funguje od C++11, tak proč ne. Ale není to tak jednoduché. To constexpr říká kompilátoru, že pokud je znám argument funkce v době překladu, není nutné aby pro ní generoval kód, ale může to spočítat už právě při překladu a do výsledného kódu by měl dosadit jen celkový výsledek. Může to ale vůbec nějak udělat ? GCC si poradí jen s jednoduchými konstrukcemi, tady však počítáme docela složitou funkci sinus, s tím nás vyhodí. Clang je o něco lepší - zřejmě tím, že si to uvnitř překládá do nějakého interního bytecode, který pak dokáže interpretovat, tedy nějak spustit, poradí si i s tímto. Cenou za to je, že třeba tu funkci sincos(), která počítá sinus (nebo kosinus) si musíme napsat sami a otestovat. Protože i ta musí být constexpr a vlastně křížový překlad pomocí clang ani neumí použít knihovní funkce z cmath. Takže složitost jsme zametli pod koberec v hlavičce mmath.h, ale ty funkce nemusí být vůbec efektivní, spouští se jen při překladu, musí být jen přesné. A ani ta tabulka sin_tab není jen tak obyčejné C-čkové pole, je to šablona z hlavičky init.h. Pokud si všimneme výsledného kódu, opravdu v něm není ani stopa výpočtů v double i když ve zdrojácích je toho dost. Zapouzdření výkonného kódu do třídy DAC_Class je prostě jen zvyklost z C++, která nic nekazí. Kód má shodou okolností také jen 748B ve flash, důležité je, že tabulka sin_tab, která mimochodem zabírá 512B z těch 748B celku, je úplně stejná jako ta externě generovaná. Výstupní frekvence je opět cca 1.3kHz. Není se čemu divit, kód je vlastně stejný.

Pozn. Novější verze gcc (zhruba od major verze 5.) to sice umí vše přeložit, ale co mu nejde, je inicializovat tabulku při překladu a umístit jí do sekce .rodata. Proč to clang umí, nevím.

A tady se už projeví limitace přístupu Arduino. V C++ se třídy často instancují tak, aby jejich data byla uložena na zásobníku, tedy uvnitř nějaké funkce. Důvodů pro to může být více, ovšem v bare metal je nutné k tomu potřeba přistupovat dost obezřetně - např. pokud se v konstruktoru inicializují periferie, je pak potřebné v destruktoru tyto zase definovaně odstavit. Můžeme to zkusit a napsat to normálně. Musí se samozřejmě upravit startup kód, metodu init() přeneseme do konstruktoru a opět složitost zameteme do hlavičky main.h. Zůstane jen

  #include "main.h"

  int main () {
    DAC_Class dac;
    for (;;) {
      dac.step();
    }
  }
  

To, že se ušetří pár bytů flash není vlastně ani tak podstatné, ale kompaktní kód odstranil volání funkce loop() a smyčka se tím zrychlila na dvojnásobek - výstupní frekvence je pak už celých 2.6kHz. Ale i tak - výkonný kód 144B plus 512B tabulka ve flash a máme plně funkční generátor harmonického průběhu.

Závěr.

Cílem bylo představit konstantní výrazy v C++ a jejich využití v bare-metal. Tento příklad byl poměrně komplikovaný ale constexpr lze použít v jednodušší formě i v GCC. C++ má spoustu různých vychytávek a pochytit jejich výhody (ale i nevýhody) stojí fakticky dost úsilí. Je to složitý jazyk, ale poskytuje programátorovi velkou míru svobody. S ní samozřejmě přichází i velká míra odpovědnosti a tak se nelze divit, že tento jazyk nevyhovuje každému.

Co by mě opravdu zajímalo je, jak by prošel takovýto zdrojový kód jakýmkoli auditem. Není v něm vlastně nic divného a funguje. Použité constexpr funkce jsou otestovány komparací s tím, co vygeneroval úplně jiný jazyk (python). Na druhou stranu gcc to nepřeloží a může se stát, že v příští verzi clang někdo zjistí, že takto široce pojaté konstantní výrazy byly vlastně špatně a rázem to utne. Když si vezmu, že pro prosté C má MISRA desítky pravidel, která je nutné pro úspěšný audit dodržovat, neumím si představit soubor podobných smysluplných pravidel pro C++, které je o řád složitější. Ale existuje to i když o smysluplnosti by se asi dalo úspěšně pochybovat.

Před časem jsem experimentoval s jazykem Rust, který má podobná pravidla implemetována už v samotném jazyce. Je hodně restriktivní, typový systém je přísnější ale v té době se na bare-metal opravdu nehodil. I takové běžné operace jako zápis do registrů procesoru byl v unsafe sekci a najít aplikaci, která by používala přerušení bylo nemožné. Od té doby se hodně změnilo a možná se k tomu ještě vrátím, prevenci hloupých chyb považuji za důležitou, ale nemyslím si, že by se tímto přístupem dalo zabránit úplně všem chybám. Každý člověk se může zmýlit (mejlej se i ministři, jak říká klasik) a je dobré, když ho na to překladač upozorní, ale ani ten nejlepší překladač nemůže nikdy být na takové úrovni aby odhalil každou chybu. Protože jak se v IT říká, to není chyba, ale vlastnost. Hranice mezi tím je velmi tenká. Když vezmeme jako příklad ten zápis do registru periferie procesoru je hodně na pováženou co lze považovat za bezpečné a co už ne. Tato část adresního prostoru je složitě strukturována a vždy bude existovat nějaký kus kódu, který ji popisuje, ale může obsahovat chybu. Říci, že každý zápis je nebezpečný alespoň potenciálně je tady poměrně silné tvrzení. Programátor nutně musí mít nějaký prostor pro práci, nějakou volnost aby vůbec mohl něco dělat. Nepomůže striktně hlídat adresní prostor aby čtení nebo zápis na neexistující adresu končil segfaultem, i když je to užitečné, pokud to nezabírá příliš prostředků. Pokud se k tomu dostane člověk, který jak se říká neví, která bije, stejně může na existující adresu zapsat informaci, která procesor spolehlivě zničí. Například v procesorech řady STM32 je oblast option sloužící v podstatě (mimo jiné) k ochraně autorských práv, kam lze legálně zapsat určitou sekvenci příkazů a to je to poslední, co s tím procesorem uděláte. A není to chyba, je to záměr výrobce. Chci tím říct, že bare-metal nejde dělat bezpečně bez znalostí. Ano, můžete použít nějakou sadu softwarových nástrojů od výrobce čipu a snažit se tak svalit možný problém na něj, ale ani tato cesta není jednoduchá.

Úplně na konec přidám i zabalené zdrojáky pod licencí MIT (jinak řečeno dělejte si s tím co chcete, ale neotravujte mne s tím, že to nefunguje). V adresáři xgen je tam kompletní příklad zdroje harmonického signálu s přesným časováním a s možností nastavení frekvence. Popisovat to nebudu, výše vysvětlené principy jsou použity i zde.

Ve zdrojácích jsou dva patche

Patche lze aplikovat pomocí Makefile (příkazy #make static nebo #make guard).