Optymalizacja kodu

Jak zwiększyć ilość miejsca na Attiny2313? Oczywiście najprostszy sposób, to kupić większe Attiny, ale modele typu 4313 nie są powszechnie dostępne. Drugim rozwiązaniem jest optymalizacja kodu. Będzie to miało dwojaki skutek. Nie dość, że damy radę upchnąć więcej kodu, to jeszcze będzie się wykonywał szybciej!

O tym jak szybko i łatwo podłączyć Attiny do waszego komputera za pomocą Arduino możecie przeczytać tutaj

Po pierwsze typy danych! Najbardziej rozpowszechnionym i zwykle stosowanym bez większego zastanowienia jest integer. Fakt, że jego zakres jest bardzo duży, ale czy naprawdę potrzebujemy aż tyle? Bardzo często ten typ danych można zastąpić zmienną typu byte. Zajmuje tylko jeden bajt, może przyjmować wartości od 0 do 255 i potrzebuje mniej czasu procesora na wykonanie każdej operacji. Na przykład w pętlach typu for często możemy zredukować licznik pętli z typu int, na typ byte.

Po drugie: komendy z Arduino IDE. Są praktyczne, ładne i szybkie w użyciu, ale nie ma nic za darmo. Podczas kompilacji programu dodawane są dodatkowe linijki kodu, który nie dość, że szybko zapełnia pamięć, to potrzebuje więcej czasu na wykonanie. Dziś postaram się pokazać, że nieco inne podejście do pinów Attiny2313 może być bardzo praktyczne.

Powyższy rysunek pokazuje jak pogrupowane są piny Attiny 2313. Patrząc od lewej strony od góry są to kolejno: PA2, PD0, PD1, PA1 itd. Jak to rozgryźć? Literka P oznacza, że mówimy o pinie, którym da się w jakiś sposób sterować (dlatego VCC i GND nie mają takiego oznaczenia) Kolejne literki to:

A – Mogą być użyte jako zwykłe porty cyfrowego wejścia-wyjścia. Ich specjalne zadania to obsługa przycisku reset oraz zewnętrznego rezonatora kwarcowego.

B – Mogą być użyte jako zwykłe porty cyfrowego wejścia-wyjścia, Ich specjalne zadania są różne (związane z obsługą przerwań, komparatory analogowe) my, póki co, nauczyliśmy się używać ich interfejsów komunikacyjnych (piny PB5-PB7) do programowania przez SPI, oraz PB5 i PB7 do komunikacji przez I2C.

D – Piny z tej grupy jako dodatkowe funkcjonalności mają na przykład obsługę przerwań, zliczanie sygnałów dla liczników/zegarów, komunikację szeregową (TX/RX), oraz oczywiście cyfrowe wejście/wyjście.

Podział może wydawać się nieco dziwny, bo częściowo jest on powiązany z funkcjonalnościami, ale nie do końca. Jednym z głównych wyznaczników jest to, że w każdej grupie może być maksymalnie 8 portów. Każda z tych grup ma swoje własne ustawienia. Ustawienia te, zwane rejestrami zawierają w sobie informacje, czy dany pin, należący do grupy ma służyć jako wejście, wyjście, w jakim stanie (wysokim, czy niskim) aktualnie się znajduje. Pisząc program w Arduino IDE i używając komend pinMode, digitalWrite i digitalRead właśnie do tych rejestrów się odwołujemy, jednak nie bezpośrednio. W tle musi być wykonanych kilkanaście operacji, zanim funkcja digitalWrite zmieni stan pinu, a to pochłania czas i pamięć. Znając nazwy tych rejestrów możemy obejść cały ten proces i zyskać na szybkości.

Każdą grupę w Attiny 2313 opisują trzy bajty rejestru (n w nazwie, to litera grupy tu mogą być to A, B, D):

DDRn – (Data Direction Register) ten bajt odpowiada za ustalenie czy poszczególne piny mają być w trybie wejścia, czy wyjścia

PORTn – Rejestr ma dwojakie zadanie. Jeśli dany pin jest ustawiony w tryb wyjścia (za pomocą rejestru DDR) i ustawimy PORTn tego pinu na 1, to podajemy napięcie na ten Pin (analogicznie do komendy digitalWrite() ). Powoduje to np. zaświecenie się diody. Jeśli natomiast pin jest w trybie wejścia, to ustawienie 1 spowoduje podłączenie do tego pinu wewnętrznego rezystora typu Pull-Up.

PINn – Rejestr służy do sprawdzania stanu pinu (jeśli DDRn dla tego pinu jest ustawiony na wejście)

Na obrazku widać, że ustawienia pinu PB0 są zapisane w pierwszym bicie (pierwszy bit ma numer 0!) poszczególnych rejestrów, a ósmego w ósmym (ósmy bit ma numer 7!)

Tabelka pokazuje podstawowe własności np rejestru DDRB

Bit

7

6

5

4

3

2

1

0

R/W

R/W

R/W

R/W

R/W

R/W

R/W

R/W

R/W

Wart. pocz.

0

0

0

0

0

0

0

0

R/W to Read/Write – czyli odczyt zapis.

Rejestr DDRx odpowiada za ustalenie, czy dany Pin z grupy x ma być w trybie wejścia, czy wyjścia. „0” oznacza tryb wejścia. 1 tryb wyjścia.

Jak tego użyć? Najlepiej wykorzystując bitowy sposób podawania wartości w Arduino IDE. Czyli B00000001 dla przykładu to to samo co 0x1 lub 1. Kolejny przykład: B10000000 to tyle samo co 0x80 lub 128. Łatwo zauważyć, że dla liczby szesnastkowej wykorzystywany jest przedrostek „0x” a dla bitowej „B” Gdzie tu ułatwienie?

Powiedzmy, że potrzebujemy do naszego projektu 5 pinów jako output, żeby mrygały diodami. Mamy kilka możliwości. Pierwsza:

pinMode(9, OUTPUT);
pinMode(10, OUTPUT);
pinMode(11, OUTPUT);
pinMode(12, OUTPUT);
pinMode(13, OUTPUT);

Druga:

for (byte i = 9 ; i < 14 ; i++){
PinMode(i , OUTPUT);
}

Niby lepiej, ale to wymaga pętli I dodatkowej zmiennej w pamięci.

Równoważnym zapisem jest:

DDRB = B00011111;

I jeszcze mała ilustracja dla tego zapisu:

Co tak właściwie zrobiliśmy? Użyliśmy rejestru portów B odpowiadającego za ustawienie kierunku poszczególnych pinów. Liczbę w postaci bitowej trzeba po prostu rozciągnąć i wpasować w tabelkę.

Dalej, Jeśli chcemy, żeby piny nr. 9, 11, 13 były w stanie HIGH, a reszta LOW, to znowu możemy pisać:

digitalWrite(9, HIGH);
digitalWrite(11, HIGH);
digitalWrite(13, HIGH);

Ale możemy też zrobić to tak:

PORTB = B00010101;

Ile w ten sposób zyskaliśmy? Trzeba by było trzy razy wywołać funkcję digitalWrite(). Każdorazowe jej użycie jest ok. 20x dłuższe niż jedna zmiana rejestru PORTB więc oszczędność czasu jest ogromna.

Kolejny ważny krok, to operacje na wybranych bitach. Do tej pory zapisywaliśmy cały bajt, czyli ustalaliśmy stany dla wszystkich bitów (i tym samym PINów). Zakładając sytuację, że wcześniej PIN 12 był w stanie HIGH, to po zapisaniu PORTB z powyższego przykładu przeszedł w stan LOW (bo bit nr 3 miał wartość 0). Zwykle jest tak, że chcemy zmieniać stan tylko jednego wyjścia i przy okazji pozostawić resztę w stanie niezmienionym. Język C oferuje do tego celu pakiet operacji bitowych, o których można poczytać sobie na przykład TU lub TU choć nie jest to konieczne i podaję to tylko jako ciekawostkę.

To co ważne, to operatory bitowe i ich interpretacja:

Operator & (AND)  i jego właściwości:

    B00001110
  & B00001000
  = B00001000
   B00001110
 & B00010000
 = B00000000

Załóżmy, że pierwsza linijka od góry, to nasze PORTB. Jeśli wykonamy na nim operację bitową AND ze specjalnie spreparowanym łańcuchem, który ma tylko jeden bit ustawiony na 1, to otrzymamy wynik różny od zera wtedy, gdy ten sam bit ustawiony jest na jeden w PORTB (przykład po lewej). Jeśli natomiast bit, który sprawdzamy będzie miał w PORTB wartość 0, to zwróconą wartością będzie 0.

Innymi słowy: PORTB & B00000010 Testuje pin PB1 (0 – jeśli wyłączony, różne od 0 – jeśli włączony)

Operator | (OR) i jego własności:

    B00001110
  | B00001000
  = B00001110
   B00001110
 | B00010000
 = B00011110

Tym razem wykonaliśmy operację OR. PORTB, jak i nasz spreparowany bajt ma te same wartości co poprzednio, jednak wynik jest zupełnie inny. W przykładzie po lewej stronie widać, że wynik jest taki sam, co PORTB. Przykład po prawej stronie jest o wiele ciekawszy, i to właśnie na nim się skupimy. Wynik w tym wypadku jest równy PORTB plus zmieniony bit4! Używając takich spreparowanych bitów możemy za pomocą operacji OR „włączać” poszczególne bity rejestru! Warto też zauważyć, że użucie OR na bicie, który już jest włączony, nie powoduje jego wyłączenia. Do tego posłużą nam inne operacje.

W skrócie: PORTB | B00000001 włącza PB0

Wracając do przykładu z włączeniem trzech diod na pinach: 9(PB0), 11(PB2) i 13 (PB4) jeśli nie chcemy modyfikować stanów pozostałych pinów (warto sobie przypomnieć, że przecież ostatnie piny tego rejestru odpowiadają za komunikację z Arduino i ręczne ich przestawianie może tę komunikację zaburzyć) możemy zapisać:

PORTD |= (_BV(0) | _BV(2) | _BV(4));

Operator ^ (XOR) i jego własności:

    B00001110
  ^ B00001000
  = B00000110
   B00001110
 ^ B00010000
 = B00011110

Kolejne ciekawe rozwiązanie, to operacja XOR. Jak widać na obu przykładach jej użycie zmienia stan bitu, który wskazujemy za pomocą naszej bitmaski. Inaczej PORTB ^B00001000 zmienia stan bitu wskazanego w bitmasce na przeciwny. W kontekście zadania z trzema włączonymi pinami (9, 11, 13)możemy zastosować XOR w następujący sposób:

PORTD ^= (_BV(0) | _BV(2) | _BV(4));

Bitshift <<

Warto zadać pytanie: skąd wziąć taki bajt-bitmaskę? Z pomocą przychodzi tu przesunięcie bitowe (bitshift) cała formuła ma postać 1 << x. Gdzie x mówi nam jak daleko w lewo mamy tę jedynkę w bajcie przesunąć. Żeby jeszcze trochę uprościć sprawę ludzie wymyślili makro _BV (x) które ma dokładnie takie samo działanie jak 1 <<x

Operacja Opis Makro Wynik
 1 << 0  Przesuwamy 1 w lewo o 0 miejsc  _BV(0)  B00000001
 1 << 3  Przesuwamy 1 w lewo o 3 miejsca  _BV(3)  B00001000
 1 << 7  Przesuwamy 1 w lewo o 7 miejsc  _BV(7)  B10000000

Innymi słowy do _BV() trzeba podać, który numer bitu chcemy ustawić na „1” (należy przy tym pamiętać, że bity numerujemy od 0!)

Wyłączanie bitów:

Bit możemy wyłączyć nie tylko przez XOR. Jeśli chcemy mieć pewność, że na danym bicie zostanie ustalona wartość 0, możemy skorzystać z konstrukcji PORTB &= ~(_BV(n)) w takim wypadku, najpierw tworzymy za pomocą makra _BV zmienną z jednym włączonym bitem w n-tym miejscu. Następnie zamieniamy za pomocą negacji wszystkie bity (czyli wszystkie 0 stają się 1 i na odwrót) a następnie składamy to za pomocą AND z rejestrem PORTB otrzymując w ten sposób 0 na n-tym bicie rejestru. Jest to o tyle bezpieczniejsza od XOR metoda, że niezależnie od stanu początkowego n-tego bitu, na koniec będzie miał 0. (Jeśli z jakiegoś powodu było by to 0 przed wydaniem komendy, to XOR zmieniło by ten stan na 1)

Praktyczny przykład z krótkim opisem:
void setup() {
  DDRD = B01111111;  
  PORTD = B00000001;
}

void loop() {
  if (PORTD & _BV(6))
    PORTD = PORTD << 1 | 1;
  else
    PORTD <<= 1;
  delay (300);
}

Jest to prosty program, którego zadaniem jest zapalanie kolejnych diod podłączonych do pinów grupy D. Wykorzystuje makro BV, operację OR oraz bitshift. Dodatkowo prosty warunek w sekcji loop pokazuje jak z przesunięcia bitowego zrobić rotację( o tym później). Na uwagę zasługuje fakt, że nie musi to być „wędrówka” jednej świecącej diody. Schemat świecących diod ustawiamy w rejestrze PORTD na początku.

Dla odmiany będziemy działali na siedmiobitowych rejestrach portów D. Różnica jest taka, że najwyższy bit tych portów jest nieosiągalny. Plusem sekcji pinów D jest to, że nie trzeba się martwić o to, że „wejdziemy” na piny komunikacyjne w rejestrach B, co mogło by przynieść nieoczekiwane skutki.

Opis programu:

W sekcji setup ustawiamy wszystkie piny na „wyjście”  ( za pomocą rejestru DDRD ) . Oraz ustawiamy schemat świecenia diod. Na początek będzie to jedna wędrująca dioda. (Jedna dioda jest włączona, bo jest tylko jedna jedynka wpisana w bajcie rejestru PORTD)

Sekcję loop można opisać następująco:

  1. Użyj przesunięcia bitowego, aby zmienić pozycję jedynki w rejestrze PORTD
  2. Jeśli najstarszym bitem w bajcie jest „1”, to po przesunięciu zapisz 1 w najmłodszym bicie

Ad2. Warunek jest potrzebny, gdyż jeśli dojdziemy do momentu B01000000 << 1 to po wykonaniu operacji otrzymamy wynik B00000000. A nam chodzi o to, żeby ta jedynka wróciła na początek. i wynik był B00000001. To jest właśnie rotacja (wtedy bajt rozpatrujemy tak, jakby jego końce były ze sobą sklejone)

  if (PORTD & _BV(6))

sprawdzamy, czy najwyższy bit w PORTD jest różny od 0. Jeśli tak, to po operacji << zostaje on stracony i musimy zapisać go z powrotem na początku bajtu. Realizuje to linijka:

PORTD = PORTD << 1 | 1;

Bardziej wyraźnie widać co się dzieje, jeśli zapiszemy to osobno: PORTD = PORTD << 1 – czyli najpierw przesuwamy wszystkie bity o jeden w lewo, a następnie PORTD = PORTD  | 1 –  Czyli stosujemy OR na bicie 0 włączając go tym samym. Polecam teraz zmienić PORTD w setup-ie na na przykład B01010000 lub B00000011 i zobaczyć co się stanie.

Dodając do tego programu obsługę I2C możemy w łatwy sposób wprowadzać nowe wzorce świecenia.

Dodatkową korzyścią jest przerobienie naszego Attiny2313 na prosty generator PWM. Co prawda ma on wbudowane cztery wyjścia PWM, ale czasem potrzeba więcej. Wadą tego rozwiązania jest to, że wszystkie piny będą miały tę samą jasność (można generować dla różnych pinów różne jasności, ale kosztem rozdzielczości). Bardziej polecanym rozwiązaniem jest tu układ TLC5940 o którym można poczytać sobie tutaj.

Modyfikując zmienną toggle (min. 1 max 10000) można regulować jasność świecenia. Dobrym ćwiczeniem może być próba sterowania zmienną toggle przez I2C

int toggle = 100;
void setup() {
  DDRD = B01111111;  
  PORTD = B00000000;
}

void loop() {
  PORTD = B01111111;
  delayMicroseconds(toggle);
  PORTD = B00000000;
  delayMicroseconds(10001 - toggle);
}
Co powinniśmy zapamiętać po tej lekcji?
  • X << n –  przesuwa w zmiennej X wszystkie bity w lewo o n miejsc.
  • _BV(n) – tworzy zmienną pomocniczą z jednym włączonym bitem na n-tym miejscu
  • X = X |  _BV(n) – (OR) włącza w zmiennej X bit na jej n-tym miejscu
  • X & _BV(n) – zwraca 0 jeśli bit na n-tym miejscu w zmiennej X był ustawiony na 0, lub wartość różną od 0, jeśli bit był ustawiony na 1
  • X  = X ^ _BV(n) – zmienia stan n-tego bitu w zmiennej X na przeciwny
  • X &= ~_BV(n) – zapisanie w zmiennej X 0 na n-tym miejscu bitowym.