Hardverközeli Arduino programozás

Kategória: Arduino.

Áttekintés

A hardverközeli programozás során nem (ill. nem csak) a magasabb szintű függvényeket használjuk, hanem a chip regisztereit közvetlenül állítjuk, ill. hardverközeli függvényeket hívunk. Ennek számos előnye és számos hátránya van. Előnyei:

  • Bizonyos funkciók csak ilyen módon érhetőek el. Pl. csak így tudjuk elérni az EEPROM memóriát, vagy azt, hogy adat kerüljön a programmemóriába.
  • A magasabb szintű függvények használatának szinte mindig van valamekkora "ára": többnyire lehet jobban optimalizálni, mint ahogyan azt egy általános fordító teszi. Erre ritkán, de szükség lehet, pl. fotó készítésekor a beépített függvények sebessége nem elegendő, de közvetlen hardver szintű programozással el tudjuk érni a kívánt sebességet.
  • Segítségével jobban meg tudjuk érteni azt, hogy mi történik legalul. Pl. megtudhatjuk, hogy a várakozás során sohasem pontosan annyi ideig függeszti fel a futást a processzor, amennyit megadunk neki, mivel az csak az órajel többszöröse lehet.

Ugyanakkor ne feledkezzünk meg a hátrányokról sem:

  • A kód hordozhatósága lényegében megszűnik, vagy legalábbis rendkívüli módon leszűkül. Ha nagyon picit eltérő lapkára szeretnénk feltölteni a kódot, lehetséges, hogy módosítanunk kell rajta.
  • A kód olvashatósága is jelentősen romlik.

Ezeket figyelembe véve igyekezzünk csak akkor használni a lent felsorolt technikákat, ha feltétlenül muszáj.

Konstansok tárolása a programmemóriában

Amint arról már volt szó, az Arduino külön memóriahelyen tárolja a programot és az adatot. A program memória Arduino UNO esetén 32 kB, az adta memória viszont csak 2 kB. A konstans jellegű adatokat érdemes tehát a program memóriában tárolni. Ezt a PROGMEM és F() makrók segítségével tudjuk megtenni:

const int dataInProgmem[] PROGMEM = {1, 2, 3, 4, 5};
 
void setup() {
  Serial.begin(9600);
  Serial.println(F("Reading data from PROGMEM."));
  for (int i = 0; i < 5; i++) {
    int dataFromProgmem = pgm_read_word(dataInProgmem + i);
    Serial.println(dataFromProgmem);
  }
}
 
void loop() {}

Hasonlítsuk össze a következővel, ahol külön nem jelezzük, és az adat automatikusan az SRAM-ba kerül, nem a program memóriába:

int dataInSRAM[] = {1, 2, 3, 4, 5};
 
void setup() {
  Serial.begin(9600);
  Serial.println("Reading data from SRAM.");
  for (int i = 0; i < 5; i++) {
    int dataFromSRAM = dataInSRAM[i];
    Serial.println(dataFromSRAM);
  }
}
 
void loop() {}

Ez utóbbi fordítási eredménye az alábbi:

Sketch uses 1790 bytes (5%) of program storage space. Maximum is 32256 bytes.
Global variables use 222 bytes (10%) of dynamic memory, leaving 1826 bytes for local variables. Maximum is 2048 bytes.

Az előbbi, PROGMEM-es változat viszont ez:

Sketch uses 1840 bytes (5%) of program storage space. Maximum is 32256 bytes.
Global variables use 188 bytes (9%) of dynamic memory, leaving 1860 bytes for local variables. Maximum is 2048 bytes.

Látható, hogy a PROGMEM-es esetben több adat került a program memóriába, melynek mérete 32256 bájt, a másikban viszont a dinamikus memóriába, melynek a mérete mindössze 2048 bájt.

A PSTR("…") szintén olyan makró, melynek segítségével szöveget tudunk tárolni a program memóriában.

A program memóriáról részletesen olvashatunk itt: https://www.arduino.cc/reference/en/language/variables/utilities/progmem/. A makrók és függvények a avr/pgmspace.h fejlécben vannak deklarálva, viszont az újabb fejlesztőeszközök esetén már nem kell explicit importálni. A specifikációja egyébként itt található: https://www.nongnu.org/avr-libc/user-manual/group__avr__pgmspace.html.

Adat tárolása EEPROM-ban

Az EEPROM-ban tárolt adat kikapcsoláskor is megmarad. Az EEPROM könyvtár (https://www.arduino.cc/en/Reference/EEPROM) függvényeit tudjuk felhasználni adatok olvasására ill. írására.

A következő példában a lapkára szerelt 13-as LED villan először egyet, majd kettőt, hármat, és így tovább, és 10 után ismét egyet. Érdemes lekapcsolni mondjuk ötnél, és újraindítást követően ellenőrizni, hogy hatot villan-e.

#include <EEPROM.h>
 
const int address = 0;
int data;
 
void setup() {
  pinMode(13, OUTPUT);
  digitalWrite(13, LOW);
}
 
void loop() {
  EEPROM.get(address, data);
  if (data > 10 || data < 1) {
    data = 1;
    EEPROM.put(address, 1);
  }
  for (int i = 0; i < data; i++) {
    digitalWrite(13, HIGH);
    delay(100);
    digitalWrite(13, LOW);
    delay(100);
  }
  data++;
  EEPROM.put(address, data);
  delay(1000);
}

A példában a 0 című memóriaterületről olvasunk ill. írunk. A kód kicsit defenzív, mivel a feltöltés törli az EEPROM memóriát, ott elvileg nem lehet egy korábbi program által hátrahagyott "szemét", mindenesetre egy negatív vagy túl nagy kezdeti értékre is fel van vértezve a program.

Az EEPROM-ban tárolt adat ki- és beolvasása lassú, ráadásul korlátos, hogy összesen hányszor lehet ezt megtenni (kb. százezer ciklus), így indokolatlanul nem érdemes használni.

Hardverközeli portelérés

Az Arduino által nyújtott felület elfedi a hardverbeli eltéréseket a különböző lapkák között. Annak érdekében, hogy ugyanaz a kód többféle lapkával is használható legyen, továbbá amiatt, hogy a kód olvasható maradjon, célszerű a magasabb szintű függvényeket használni, pl. pinMode(), digitalWrite() stb. Azonban jó, ha ismerjük a saját eszközünk processzorát is, ugyanis előfordulhat, hogy bizonyos eszközöknél már nem megengedett a közvetítésből adódó lassulás, közvetlenül, hardver szinten kell programoznunk. Ill. ami valószínűbb, belefutunk ilyen kódba, pl. bizonyos könyvtárakba, és hogy meg tudjuk azt is érteni.

A port kezelés dokumentációja itt található: https://www.arduino.cc/en/Reference/PortManipulation. Az Arduino UNO ATmega328P chip-et tartalmaz, mely hardver szinten 3*3 regisztert definiál port elérésre:

  • B: a 8-13 lábak elérésére
  • C: az A0-A5 lábak elérésére
  • D: a 0-7 lábak elérésére

Mindhárom port esetén 3 műveletet tudunk végrehajtani (x jelzi a regisztert, tehát B, C vagy D):

  • DDR[x]: adat irány regiszter. Ezzel tudjuk beállítani, hogy az adott láb bemenet vagy kimenet legyen, tehát a pinMode() regiszter szintű párja. 1 az output, 0 az input. Írni és olvasni is tudjuk.
  • PORT[x]: adat regiszter. Ezzel tudunk írni az adott lábra.
  • PIN[x]: bemeneti regiszter. Ezzel tudunk beolvasni.

Példák:

  • DDRB |= 32;: a B regiszterről van szó, tehát a 8-13 közötti portokról. DDR, tehát az adat irányáról beszélünk. A 32 binárisan kódolva 00100000, a | művelet pedig a bitenkénti logikai vagy. Ennek eredményeként a DDRB regiszter 6. bitje 1 lesz, a többi változatlan. A 6. bit pedig a 13-as lábat jelenti. Így ez az utasítás egyenértékű ezzel: pinMode(13, OUTPUT);.
  • PORTB ^= 32;: a fentihez hasonló logika mentén végiggondolva, a 13-as lábon levő értéket változtatja meg (a ^ a bitenkénti logikai kizáró vagy, így a 6. bit fog változni, a többi marad). Ez tehát nagyjából megegyezik a state = HIGH - state; digitalWrite(13, state); utasításokkal.

Még egy gyakori utasítás, egészen pontosan makró: _BV(x). Ez egyenértékű egy bit balra léptetésével x lépést. Ezzel lehet adott bitet kiválasztani. Pl. a _BV(0) eredménye bináris 00000001 lesz, a _BV(5) eredménye pedig bináris 00100000, azaz decimális 32. Jelenleg pont erre van szükségünk.

A delay() hardverszintű megfelelője _delay_ms(), így a kezdeti LED villogtató program regiszterekkel val megvalósítása az alábbi:

void setup() {
  DDRB |= _BV(5);
}
 
void loop() {
  PORTB ^= _BV(5);
  _delay_ms(1000);
}

Hardverközeli soros kommunikáció

A TX-RX kommunikációt a Serial osztály függvényeivel célszerű megvalósítani. Ezt közvetlenül a státusz regiszterek, azon belül a státusz bitek beállításával is meg tudjuk tenni. Az érintett regiszterek: UCSR0A , UCSR0B és UCSR0C. Ezzel kapcsolatban viszonylag kevés dokumentációt találunk; egy blogbejegyzést olvashatunk a témáról itt: https://appelsiini.net/2011/simple-usart-with-avr-libc/.

Hardverközeli PWM kezelés

A PWM hardver szintű programozásához a TCCRnA és TCCRnB regisztereket kell megfelelően beállítani, melynek részletesebb leírása megtalálható a következő oldalakon:

Hardverközeli TWI

A TWI (más néven I2C) interfészt tipikusan közvetetten érjük el, az adott hardver könyvtárán keresztül. Mivel a tipikus felhasználása az, hogy hardver szinten küldünk adatokat a célhardvernek, tehát nem is assembly, hanem közvetlen gépi kódú programozásról van itt szó, ez a téma kívül esik e tanuló jellegű leírás keretein. A téma mélyét nem említve, az érintett regiszterek az alábbiak:

  • TWBR (Two Wire Interface Bit Rate Register) - ezzel tudjuk a bitrátát beállítani
  • TWCR (Two Wire Interface Control Register) - a regiszter bitjeit beállítva (a 8 bitből 7-et) tudjuk vezérelni az adatforgalmat
  • TWSR (Two Wire Interface Status Register) - segítségével a státuszt tudjuk kiolvasni; a kódok jelentését a processzor adatlapja tartalmazza
  • TWDR (Two Wire Interface Data Register) - a küldendő adatot tartalmazza

A következő függvény adott I2C címre adott regiszternek adott értéket ad:

void twiWrite(uint8_t addr, uint8_t reg, uint8_t dat){
  TWCR = _BV(TWINT) | _BV(TWEN) | _BV(TWSTA);
  while (!(TWCR & _BV(TWINT))) {}
 
  TWDR = addr;
  TWCR = _BV(TWINT) | _BV(TWEN);
  while (!(TWCR & _BV((TWINT))) {}
 
  TWDR = reg;
  TWCR = _BV(TWINT) | _BV(TWEN);
  while (!(TWCR & _BV(TWINT))) {}
 
  TWDR = dat;
  TWCR = _BV(TWINT) | _BV(TWEN);
  while (!(TWCR & _BV(TWINT))) {}
 
  TWCR = _BV(TWINT) | _BV(TWEN) | _BV(TWSTO);
}

További adatforrások:

Hardverközeli időzítés

A regiszterek beállításával elért időzítésről egy kiváló összefoglalót találhatunk itt: https://www.instructables.com/id/Arduino-Timer-Interrupts/. Lássuk, hogyan is néz ki a már bemutatott fél másodperces megszakításokkal LED-et villogtató példa:

void setup(){
  pinMode(13, OUTPUT);
 
  cli();
  TCCR1A = 0;
  TCCR1B = 0;
  TCNT1  = 0;
  OCR1A = 7882; // = 16,000,000 / (2 * 1024) - 1 (must be < 65536)
  TCCR1B |= (1 << WGM12);
  TCCR1B |= (1 << CS12) | (1 << CS10);  
  TIMSK1 |= (1 << OCIE1A);
  sei();
}
 
ISR(TIMER1_COMPA_vect) {
  digitalWrite(13, 1 - digitalRead(13));
}
 
void loop() {}
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License