Powrót

Kurs Arduino – wykorzystanie łącza I2C

W szóstym odcinku kursu Arduino nadal będziemy zajmować się pomiarami temperatury i wilgotności. Ale najważniejsze będzie zagłębianie się w różne aspekty wykorzystania Arduino, dostępnych bibliotek, a w szczególności łącza I2C.

Do tej pory wykorzystywaliśmy „potrójny” czujnik BME280. Trzeba jednak wiedzieć, że dostępnych jest szereg innych „podwójnych“ czujników temperatury i wilgotności.

Tabela 1

W tabeli 1 wyszczególnione są te, które można tanio kupić w postaci małych modułów (fotografia 1).

Fotografia 1

W tabeli nie wymieniono czujników wilgotności HIH (Honeywell), które nie są dostępne jako tanie chińskie moduły.

Czujniki wilgotności jako element czynny wykorzystują polimer, czyli tworzywo sztuczne, a konkretnie zmiany jego pojemności. Tajemnicą producenta jest skład tego tworzywa sztucznego i proces technologiczny, który daje bardziej lub mniej dokładne i powtarzalne wyniki pomiarów. Oprócz pojemnościowego czujnika wilgotności zawierają zawsze także czujnik temperatury oraz skomplikowany układ scalony, który przetwarza uzyskane dane i podaje wartość wilgotności względnej. Czujniki wilgotności nie mają dużej precyzji, a jednym z poważnych ograniczeń jest występująca histereza.

Przeglądając Internet można odnieść wrażenie, że najpopularniejsze są czujniki DHT11 i nowsze DHT22 (AM2302) chińskiej produkcji. Ich parametry wyglądają dobrze, ale większość hobbystów nie zwraca uwagi na informacje o ich powolnej pracy, niestabilności i awariach. Dziś koniecznie trzeba wiedzieć o czujnikach bardziej renomowanych firm, dostępnych w takich samych cenach. Przede wszystkim szwajcarski Sensirion produkuje wysokiej jakości czujniki SHT. Obecnie najekonomiczniejsza dobra wersja to SHT31. Porównywalne parametry mają czujniki HDC1080 Texas Instruments, Si7021 z Silicon Labs oraz HTU21D, pochodzące z oddziału TE Connectivity, znanej bardziej z dobrej jakości elementów stykowych.

Jak pokazuje tabela 1 czujniki DHT mają specyficzny interfejs 1-przewodowy, a pozostałe interfejs I2C. Co istotne, tylko niektóre mogą wprost współpracować z 5-woltowym Arduino. Do czujników Si7021 i HTU21D należy zastosować translator poziomów I2C 5V/3,3V.

Większość czujników ma dodatkowo wbudowany grzejnik – rezystor, a niektóre oferują dodatkowe funkcje. Podane wartości prądów zasilania można zdecydowanie zmniejszyć wykonując pomiary co jakiś czas.  Ogólnie biorąc, katalogowe parametry takich niedrogich czujników dotyczące pomiaru wilgotności są takie, jak jeszcze używanej do niedawna kosztownej aparatury profesjonalnej. W ramach kursu Arduino przede wszystkim chcemy bliżej zapoznać się z bardzo popularnym łączem I2C (TWI), ale przy okazji spróbujemy potwierdzić parametry takich czujników.

Łącze I2C (TWI)

Opracowane na początku lat 80. przez Philipsa (NXP) „krótkodystansowe“ łącze I2C i jego bezlicencyjna odmiana TWI (Two Wire Interface) do dwukierunkowej komunikacji między układami scalonymi sprytnie wykorzystują dwie linie sygnałowe: zegarową (SCK) i danych (SDA). W podstawowej wersji łacze I2C pracuje przy częstotliwościach od 0 do 100kHz, ale wiele kostek może pracować z zegarem (SCK) o częstotliwościach do 400kHz, niektóre do 1MHz, a nawet ponad 3MHz.

W łączu I2C zawsze jest (zwykle jeden) tzw. Master – pan, nadzorca zarządzający łączem, które jest dwukierunkowe i zgodnie z rysunkiem 2 może zawierać wiele urządzeń mu podległych (Slave), identyfikowanych w wersji podstawowej za pomocą 7-bitowych adresów.

Rysunek 2

Master wytwarza sygnał zegarowy, podawany jednocześnie do wszystkich układów. Dwukierunkowa komunikacja jest realizowana w takt sygnałów zegara przez sprytne zwieranie linii SDA do masy na przemian przez urządzenia przekazujące informacje. Wykorzystując Arduino oraz gotowe moduły i biblioteki nie trzeba się zagłębiać w bardzo interesujące szczegóły. Tak było przy wykorzystaniu modułu BME280. Ale nie zawsze to wystarczy.

Na pewno trzeba wiedzieć, że ATmega328P ma wbudowane sprzętowe obwody obsługi łącza I2C, dostępne w Arduino zarówno na pinach A4, A5, jak i na pinach z drugiej strony płytki.

Łącze I2C można też zrealizować programowo na dowolnych pinach.

Dwie żyły jednego łącza zapewnią komunikację z niemal dowolną liczbą układów z interfejsem I2C (teoretycznie ponad 100), ale pod warunkiem, że te układy będą mieć niepowtarzalne adresy. My teraz chcemy dołączyć do Arduino Uno wiele czujników wilgotności i sporym problemem jest to, że kostki Si7021 i HTU21D mają ten sam, niezmienny adres 0x40. Jest też kłopot z różnymi napięciami zasilania. Problemy te można rozwiązań na różne sposoby; my chcemy wykorzystać być może najprostszy. Mianowicie można zastosować kostki TCA/PCA9548A, które są „inteligentnymi multiplekserami łącza I2C“. Podstawowa zasada działania jest prosta: najpierw Master (Arduino) wysyła do kostki ‘9548A „polecenie konfiguracyjne“, które magistralę (żyły SDA i SCL) dołącza do jednego o ośmiu „wyjść“ gdzie jest dołączona (co najmniej jedna) kostka I2C. „Inteligentny multiplekser“ ‘9548A przy okazji bezboleśnie rozwiązuje problem translacji napięć szyny I2C, ponieważ poszczególne kostki dołączone do jego „wyjść“ mogą być zasilane dowolnymi napięciami (1,65…5V).

Ja do Arduino chcę dołączyć wszystkie posiadane czujniki wilgotności, by porównać ich wskazania. Na pewno czujniki HDC1080, Si7021 i HTU21D muszą być dołączone przez „multiplekser“ z uwagi na ich stały adres 0x40, natomiast BME280 i SHT21 – niekoniecznie, ponieważ mają do wyboru dwa adresy I2C. Spośród moich modułów, tylko HTU21D muszą być zasilane napięciem 3,3V (moduły Si7021 mają wbudowany translator, podobnie jak BME280).

Podane warunki można spełnić na wiele sposobów. Mój układ jest dość mocno rozbudowany jak pokazuje rysunek 3.

Rysunek 3

Czujniki DHT22 dołączyłem do oddzielnych pinów Arduino.

Schemat ten został zrealizowany w nieco dziwnej postaci, pokazanej na fotografii 4.

Fotografia 4

Zdziwienie na pewno budzi pokrywka od słoika, która jednak jest niezbędna, ponieważ umożliwi testowanie, a nawet kalibrację przy różnych wartościach wilgotności. Na życzenie Czytelników mogę to opisać szerzej.

Teraz jednak najbardziej interesuje nas łącze I2C. Jak pokazuje rysunek 2 wymagane są tam dwa rezystory podciągające do dodatniej szyny zasilania. Są one konieczne także „na wyjściach„ multipleksera TCA9548A. Na rysunku 3 takich elementów nie widzimy. Jednak te niezbędne rezystory są wbudowane w każdy z wykorzystanych modułów. Także w moduł multipleksera TCA9548A, co pokazuje rysunek 5. Jeden problem jest rozwiązany.

Rysunek 5

Drugi problem to programowe sterowanie łącza I2C. Zasadniczo realizują to biblioteki, które wcześniej napisał ktoś mający odpowiednią wiedzę. Bez trudu znajdziemy biblioteki do wszystkich zastosowanych czujników wilgotności.

Jeżeli chodzi o BME280, nie chciałem eksperymentować z wcześniej używaną biblioteką, ponieważ były poszlaki, że mogą być problemy z obsługą dwóch czujników dołączonych do jednej szyny I2C. Zainstalowałem więc bibliotekę BME280 od BlueDot, w której opisie jest wzmianka o obsłudze dwóch czujników (o adresach 0x76, 0x77). Niestety, program testowy nie widział drugiego czujnika, który powinien mieć adres 0x77. Okazało się, że to był mój błąd, bo niezbyt starannie wykonałem zmianę adresu (przecięcie ścieżki i zwarcie dwóch pól kroplą cyny). Po pomiarach i wykorzystaniu silnej lupy poprawiłem to i problem zniknął.

Do obsługi czujników wilgotności zainstalowałem wiele bibliotek, w większości z Adafruit, przez co wyniknął kolejny kłopot.

Mianowicie przy próbie skompilowania programu dla czujników DHT22 kompilator zgłaszał błąd i się zatrzymywał. Okazało się, że trzeba doinstalować wspólną bibliotekę dla czujników Adafruit Unified Sensor (rysunek 6), co nie było nigdzie wyraźnie podane.

Rysunek 6

Pewien kłopot jest z multiplekserem TCA9548A. Wprawdzie dostępna jest do niego biblioteka:

https://github.com/jeremycole/TI_TCA9548A

ale brak opisu, jak z niej korzystać. Należałoby przebijać się zarówno przez szczegóły z karty katalogowej, jak też analizować kod źródłowy tej biblioteki. Na szczęście jest łatwiejsze rozwiązanie.

Na stronach Adafruit podany jest prościutki sposób bez żadnej biblioteki. Mianowicie w programie – szkicu definiuje się prościutką funkcję o nazwie tcaselect, która jako argument przyjmuje liczbę – numer wyjścia multipleksera, które ma zostać dołączone do magistrali I2C. Oto proponowany tam kod:

#define TCAADDR 0x70
void tcaselect(uint8_t i) {
if (i > 7) return;
Wire.beginTransmission(TCAADDR);
Wire.write(1 << i);
Wire.endTransmission();
}

Najpierw zgodnie z dobrymi praktykami definiujemy stałą TCAADDR. Liczba szesnastkowa 0x70 to domyślny adres kostki TCA9548A, ale gdyby w systemie były dwie kostki TCA albo inny układ o adresie 0x70, za pomocą zworek można zmienić adres w zakresie 0x71…0x77.

Po włączeniu zasilania, multiplekser TCA9548A jest „wyłączony“. Wywołując funkcję tcaselect (numer), jako argument podajemy numer wyjścia multipleksera (0…7), które chcemy dołączyć do magistrali I2C. Gdy podamy liczbą większą niż 7, funkcja zakończy działanie. Ale gdy podamy liczbą w zakresu 0…7, do kostki TCA (o adresie I2C równym 0x70). zostanie wysłane jednobajtowe polecenie. Instrukcja Wire.write(1<<i) powoduje, że najpierw w dwójkowej liczbie 00000001 jedynka zostaje przesunięta bitowo w lewo o i (0…7) pozycji, a z prawej strony zostaną dopisane zera. Uzyskany bajt z jedną jedynką i siedmioma zerami zostanie przesłany magistralą I2C i „włączy“ wyjście multipleksera o podanym numerze 0…7.

Ten prościutki program obsłuży multiplekser, wiec nie potrzebujemy biblioteki. Do wstępnych testów wystarczy jednorazowo w arduinowej funkcji setup() ustawić numer czynnego wyjścia multipleksera, a potem „normalnie“, czyli za pomocą stosownej biblioteki obsługiwać dołączony tam moduł.

Warto wiedzieć, że dostępny jest program: skaner, sprawdzający, jakie urządzenia dołączone są do magistrali I2C. Na stronie Adafruit (i w licznych innych miejscach) można też znaleźć program – skaner odpowiednio zmodyfikowany specjalnie dla TCA9548A:

#include „Wire.h”
extern „C” {
#include „utility/twi.h”
} //to umożliwi skanowanie magistrali I2C
// i krótka definicja funkcji  tcsselect():
#define TCAADDR 0x70
void tcaselect(uint8_t i) {
  if (i > 7) return;
  Wire.beginTransmission(TCAADDR);
  Wire.write(1 << i);
  Wire.endTransmission();  }
//a dalej dwie standardowe funkcje:
void setup()  {
 while (!Serial);  delay(1000);
 Wire.begin();  Serial.begin(9600);
 Serial.println(„\nskanujemy TCA9548A:”);
 for (uint8_t t=0; t<8; t++) {
  tcaselect(t);
  Serial.print(„TCA \”wyjście\” numer: „);
  Serial.println(t);
  for(uint8_t addr=0; addr<=127; addr++) {
   if (addr == TCAADDR) continue;
   uint8_t data;
   if(! twi_writeTo(addr, &data, 0, 1, 1)) {
    Serial.print(„znalazłem I2C 0x”);
    Serial.println(addr,HEX); } } }
  Serial.println(„\nkoniec!”); }
void loop()  { }

Pętla loop() jest pusta, więc program jednorazowo sprawdzi jakie numery mają urządzenia, dołączone do poszczególnych „wyjść“ multipleksera. W przypadku mojego systemu z rysunku 3 i fotografii 4, skanowanie (szkic A0601_skaner.) dało wynik pokazany na rysunku 7.

Rysunek 7

Skaner ten sprawdza kolejne „wyjścia“ i za każdym razem wykrywa dwa czujniki BME280 stale dołączone do magistrali (0x76, 0x77), a do tego pojedyncze czujniki dołączone do kolejnych „wyjść“.

Wprawdzie co najmniej dwa szczegóły są tu niejasne, ale mamy dowód, że procesor Arduino potrafi porozumieć się z poszczególnymi czujnikami. Czyli moduł TCA9548A działa.

Moim następnym krokiem było sprawdzenie poszczególnych czujników pojedynczo i „ręcznie“.

W tym celu wykorzystywałem przykładowe programy – szkice, które są dołączone do bibliotek poszczególnych czujników. Każda taka biblioteka, oprócz „biblioteki właściwej“ z plikami o rozszerzeniach .h i .cpp, z reguły zawiera też jeden lub więcej przykładowych programów w katalogu \examples. Chciałem w najprostszy sposób wykorzystać te gotowe programy tylko do sprawdzenia, czy dany czujnik działa i ewentualnie wstępnie porównać wyniki.

Niestety, pojawiły się problemy charakterystyczne dla Arduino, gdzie generalnie wszystkie gotowe programy działają, ale często nawet na pozór nieznacząca modyfikacja programu wywraca wszystko do góry nogami.

Tak było i tu. Ujawnił się znakomity, klasyczny przykład potwierdzający, że Arduino to tylko zasłona, przykrywająca całą złożoność, zarówno języka C, jak i kwestie sprzętowych.

Otóż Arduino przyzwyczaja do korzystania z gotowców i nie uczy dbałości o szczegóły. Język C (i C++) zmusza do znacznie większej dbałości, a już asembler wymusza troskę o każdy, nawet najmniejszy szczegół.

I tak we wspomnianych gotowych szkicach, dołączonych do bibliotek, już na początku mamy instrukcje dla preprocesora, by podczas kompilacji dołączył (wykorzystał) te biblioteki. A nie ulega wątpliwości, że biblioteki te wykorzystują łącze I2C, więc na pewno zawierają wszystkie polecenia, które łącze I2C konfigurują i „włączają“. Dowodem jest rysunek 8, fragment głównego pliku bibliotecznego ClosedCube_HDC1080.cpp.

Rysunek 8

A jeżeli te polecenia konfigurująco-włączające są w bibliotece, to po co je dublować w programie – szkicu?

Logiczne i oczywiste wydaje się, że dla komunikacji z poszczególnymi czujnikami wystarczy w przykładowym programie – szkicu (hdc1080measurement.ino) po pierwsze na początku dodać wspomnianą wcześniej definicję funkcji tcaselect(numer), a w funkcji setup() wywołać ją, podając numer wyjścia multipleksera (czujnika). Przykładowo dla czujnika HDC1080 mogłoby to wyglądać jak na rysunku 9 (całość w szkicu A0602 w Elportalu), gdzie uprościłem funkcję tcaselect(), zawierając adres kostki TCA (0x70) w ciele-definicji funkcji.

Rysunek 9

Zarówno w przypadku tego czujnika, jak i pozostałych, taki program – szkic nie zadziała prawidłowo. Jak pokazuje rysunek 10, ewidentnie brak kontaktu z czujnikiem HDC1080.

Rysunek 10

A teraz zadanie dla Ciebie:

spróbuj samodzielnie poprawić szkic A0602.ino, żeby usunąć błąd.

W elementarnym kursie Arduino nie sposób zagłębić się we wszystkie szczegóły związane z budową i funkcjonowaniem wbudowanych w ATmega328P obwodów sprzętowego interfejsu I2C (40 stron w katalogu), ani w szczegóły programowej obsługi łącza. Trzeba jednak przynajmniej z grubsza zapoznać się z wbudowaną w Arduino biblioteką o nieszczęśliwej nazwie Wire. Ta nazwa bardziej kojarzy się z jednoprzewodowym interfejsem 1-Wire (One Wire), a tymczasem jest to biblioteka do obsługi I2C, czyli TWI (Two Wire Interface).

Chcąc wykorzystać bibliotekę Wire, na początku programu oczywiście trzeba dodać dyrektywę #include <Wire.h>, a w samym programie trzeba umieścić wywołanie metody Wire.begin() co spowoduje inicjalizację łącza

Użytkownik Arduino powinien wiedzieć, że w systemie I2C/TWI każde urządzenie podrzędne (Slave – sługa), ma swój adres, zasadniczo siedmiobitowy (0…127). Natomiast urządzenie zarządzające Master (pan) adresu nie ma. Płytka Arduino zazwyczaj jest zarządcą (Master), ale może też być sługą (Slave), na przykład przy komunikacji dwóch płytek Arduino przez łącze I2C.

Tylko przy pracy płytki Arduino jako sługa (Slave) potrzebne są zawarte w bibliotece Wire  funkcje – metody onReceive() oraz onRequest(), które posłużą wtedy do określenia co trzeba zrobić, odpowiednio gdy Master przyśle dane oraz gdy zażąda danych. Większość funkcji – metod biblioteki Wire potrzebna jest przy pracy w roli Mastera – nadzorcy.

Gdy master ma wysłać do któregoś urządzenia Slave jeden lub więcej bajtów informacji, najpierw musi poinformować, dla kogo przeznaczone są te dane. Realizujemy to za pomocą metody

Wire.beginTransmission(adresI2C);

zawierającej adres odbiorcy, podany najczęściej w postaci szesnastkowej.

Następnie Master przesyła jeden lub więcej bajtów, w najprostszym przypadku:

Wire.write(bajt_danych);

a potem Master kończy połączenie:

Wire.endTransmission();

Gdy Master chce otrzymać dane od urządzenia Slave, w swoim żądaniu podaje adres tego urządzenia oraz liczbę oczekiwanych bajtów:

Wire.requestFrom (adres, liczba_bajtów);

Zasada jest więc prosta: wysyłamy żądanie i odbieramy tyle bajtów, co trzeba…

Ale powyższa instrukcja jest tylko żądaniem wysłanym do urządzenia Slave. Bity i bajt(y) od urządzenia Slave trzeba jeszcze odebrać i „wczytać do programu“, co wcale nie dzieje się automatycznie.

Skąd odebrać? Otóż nie odbieramy kolejnych bitów wprost z szyny SDA. Przy korzystaniu z biblioteki Wire bez naszej wiedzy określana jest wielkość bufora danych, który jest w sumie niezależny od bieżącego programu (standardowo ma on 3 x 32 bajty).

Odebrane bajty trafiają więc do 32-bajtowego bufora i dopiero stamtąd można je odebrać. Analogicznie jak w przypadku łącza Serial, także i w bibliotece Wire, mamy prościutką funkcję – metodę read(), która odczytuje, niejako zabiera z bufora jeden, pierwszy, najwcześniejszy bajt:

Wire.read();

Problem w tym, że procesor (i przebieg programu) jest bardzo szybki, taktowany zegarem 16MHz (0,0625us), natomiast standardowa częstotliwość łącza I2C to 100kHz (10us), wiec nie ma rady: na odebranie z urządzenia Slave jednego bajtu danych trzeba czekać, i to nie tylko 80us, tylko zdecydowanie dłużej. W tym czasie procesor mógłby wykonać tysiące instrukcji programu. I zawodowy, a przynajmniej doświadczony programista napisałby tego rodzaju program z wykorzystaniem przerwań. W Arduino też oczywiście musimy czekać na odebranie danych. Z tym mogą być problemy, ale na razie tematu nie drążymy.

Ściślej biorąc, przy wysyłaniu danych też korzystamy z oddzielnego, też 32-bajtowego bufora. Wspomniane wcześniej polecenie Wire.write(bajt_danych) i pokrewne, nie wysyłają danych wprost na magistralę, tylko do bufora, a dopiero funkcja Wire.endTransmission(); wysyła całą paczkę (adres + wszystkie dane) na magistralę.

Wracamy teraz do szkicu z rysunku 9 (A0602) i problemu z rysunku 10.

Na pewno w programie mamy dyrektywę #include <Wire.h>. Jak pokazuje rysunek 8, w dołączanej do szkicu bibliotece czujnika jest i dyrektywa #include <Wire.h>, i polecenie inicjalizacji Wire.begin();. Czyli wszystkie potrzebne polecenia są.

Tak, ale problemem jest kolejność. W naszym szkicu A0602 (rysunek 9) najpierw chcemy włączyć wyjście 3 multipleksera, a dopiero potem następuje inicjalizacja czujnika HDC1080, gdzie zgodnie z rysunkiem 8 pojawia się inicjalizacja magistrali I2C:

tcaselect(3);  // włączamy wyjście #3
hdc1080.begin(0x40); //inicjalizacja HDC1080

Błędem w naszym szkicu jest próba komunikacji z multiplekserem TCA zanim w programie pojawi się polecenie inicjalizacji magistrali I2C. Albo dodamy do szkicu A0602 polecenie:

Wire.begin(); //inicjaliacja magistrali I2C
tcaselect(3);  // włączamy wyjście #3
hdc1080.begin(0x40); //inicjalizacja HDC1080
albo zamienimy kolejność instrukcji:
hdc1080.begin(0x40); //inicjalizacja HDC1080
tcaselect(3);  // włączamy wyjście #3

Gdy dokonamy takiej drobnej zmiany według rysunku 11 (szkic A0603),

Rysunek 11

po uruchomieniu monitora portu szeregowego uzyskamy informacje jak na rysunku 12, pokazujące wartości zmierzone przy ustawionej różnej rozdzielczości.

Rysunek 12

Cieszymy się, że wstępnie udało nam się opanować obsługę łącza I2C. Mnóstwo układów scalonych i gotowych modułów ma właśnie interfejs I2C (TWI), więc poznanie jej magistrali i jej obsługi jest ogromnie ważne dla każdego elektronika.

Do łącza I2C i jego obsługi będziemy wracać, ale nie ulega wątpliwości, że to Ty Czytelniku masz nabrać umiejętności jego obsługi. Dlatego proponuję, żebyś we własnym zakresie zrealizował ćwiczenia z wykorzystaniem posiadanych modułów z I2C, Na razie tylko bardzo powierzchownie zapoznaliśmy się z łączem I2C i biblioteką Wire. Nie załamuj się więc, jeśli natkniesz się na „niewytłumaczalne” problemy. Oczywiście w ramach elementarnego kursu Arduino nie możemy poświęcić zbyt wiele uwagi wprawdzie ważnemu, ale jednemu tylko zagadnieniu. Tym bardziej, że na omówienie czekają inne, również niezmiernie pożyteczne interfejsy. A następnym odcinku kursu (UR7) zajmiemy się głównie łączem SPI.

Piotr Górecki