Kurs STM32L4 – #12 – zewnętrzna pamięć EEPROM (I2C)

Kurs STM32L4 – #12 – zewnętrzna pamięć EEPROM (I2C)

Do tej pory omówiliśmy dwa interfejsy szeregowe na STM32L4, czyli UART i SPI. Pierwszy wymagał dwóch linii, ale był dość powolny. Drugi pracował szybciej, ale wykorzystywał więcej wyprowadzeń.

Teraz zajmiemy się I2C, czyli kolejnym interfejsem komunikacyjnym, dzięki któremu do tych samych linii możemy łatwo podłączyć wiele urządzeń.

Czego dowiesz się z tej części kursu STM32L4?

W tej części kursu STM32L4 omówimy podstawy pracy z popularnym interfejsem I2C. Podczas ćwiczeń wykorzystamy go do komunikacji z zewnętrzną pamięcią EEPROM – będziemy mogli zapisywać do niej informacje, które nie będą tracone po odłączeniu zasilania układu. W trakcie ćwiczeń udowodnimy również, że debugger może czasami przypadkiem „zafałszować” działanie naszego urządzenia.

Czym jest I2C?

I2C (czytaj: i-kwadrat-ce lub w wersji angielskiej: i-squared-c), zapisywany często jako I2C lub IIC, to standard komunikacji synchronicznej. Oznacza to, że sygnał zegarowy jest przesyłany między układami, co odróżnia go np. od asynchronicznego UART-a, który musiał polegać na zegarach wbudowanych w komunikujące się ze sobą urządzenia.

Projektanci interfejsu I2C postanowili ograniczyć liczbę niezbędnych linii, ale jednocześnie chcieli umożliwić podłączanie wielu układów do tych samych linii sygnałowych. Wymagania na pierwszy rzut oka mogą się wydawać sprzeczne, jednak ostatecznie założony cel został osiągnięty – oczywiście odbyło się to kosztem komplikacji protokołu.

Schemat podłączenia trzech urządzeń podrzędnych do wspólnej magistrali I2C

Schemat podłączenia trzech urządzeń podrzędnych do wspólnej magistrali I2C

Tak samo jak w przypadku SPI, układy komunikujące się przez I2C dzielą się na dwie kategorie – mamy tu więc układy nadrzędne (ang. master) i podrzędne (ang. slave). Co ciekawe, do jednego interfejsu I2C może być jednocześnie podłączonych kilka układów nadrzędnych, ale my skupimy się na prostej wersji z jednym masterem, którym będzie mikrokontroler – taka wersja jest też najczęściej wykorzystywana w większości projektów z I2C.

Dlaczego potrzebne są dodatkowe rezystory?

Przy SPI do przesyłania danych wykorzystywane były dwie linie danych (MOSI i MISO). Dzięki temu jednocześnie można było wysyłać i odbierać informacje – taki tryb działania nazywany jest full-duplex. Z kolei I2C posiada tylko jedną linię danych, dlatego w danej chwili możemy tylko wysyłać lub odbierać dane (jest to tryb half-duplex). W efekcie do komunikacji (oprócz zasilania) potrzebne są tylko dwie linie:

  • SDA – dwukierunkowa linia danych,
  • SCL – linia zegarowa sterowana przez układ nadrzędny.

Najczęściej wyjścia mikrokontrolera pracują w tzw. trybie przeciwsobnym (ang. push-pull). Oznacza to, że jeśli na wyjściu jest ustawione logiczne zero, to odpowiednie wyprowadzenie jest zwierane do masy. Natomiast logiczna jedynka to połączenie tego wyprowadzania z dodatnią szyną zasilania (tutaj 3,3 V).

Przy takiej konfiguracji nieodpowiednie sterowanie wyprowadzeniami podłączonymi do tej samej linii danych może łatwo prowadzić do zwarcia. Wystarczy, że mikrokontroler będzie ustawiał logiczne zero, a układ podrzędny spróbuje w tym samym czasie ustawić logiczną jedynkę – nastąpi zwarcie, które może uszkodzić oba układy. Aby tego uniknąć, konieczna jest synchronizacja dostępu do interfejsu – a tym samym zapobiegnięcie jednoczesnym zapisom do wspólnej linii.

W związku z tym, że interfejs I2C nie wykorzystuje żadnych sygnałów podobnych do CS, konieczne było znalezienie innej metody współdzielenia linii. Użyto w tym celu wyjść typu otwarty dren (ang. open drain). Oznacza to, że gdy mikrokontroler wystawia na pinie logiczne zero, linia jest zwierana do masy. Natomiast logiczna jedynka, zamiast zwierać wyjście do dodatniej szyny zasilania, pozostawia linię niepodłączoną – dokładnie tak samo jak podczas pracy danego wyprowadzenia jako wejście.

Gdybyśmy od wyjścia w trybie otwartego drenu odłączyli wszystkie elementy, a podłączyli multimetr, okazałoby się, że niezależnie od wysterowania zawsze zmierzone napięcie wynosi 0 V. Konieczne jest więc podłączenie rezystora podciągającego (np. 4,7 k).

Teraz napięcia na wyjściu będą zachowywały się zgodnie z oczekiwaniami – będziemy mieć na danej linii 0 V, gdy ustawimy logiczne zero, oraz 3,3 V, gdy ustawimy logiczną jedynkę. Co więcej, jeśli układy podłączone do tej samej linii danych ustawią przeciwne sygnały, to nic złego się nie wydarzy.

Taki układ ma jednak kilka wad. Po pierwsze, jeśli ustawimy logiczne zero, to przez rezystor płynie prąd, co powoduje straty energii (im mniejsza jest rezystancja rezystora podciągającego, tym komunikacja może przebiegać szybciej, ale tracimy wtedy więcej energii). Dodatkowo przełączanie z 0 na 1 działa dość wolno (rezystor podciągający z pojemnościami naszego obwodu tworzy układ RC). 

Ogromną zaletą takiego podłączenia jest brak zwarć, w przypadku gdy wyjścia kilku układów są ze sobą połączone oraz wysterowane różnym poziomem. Jeśli którykolwiek z podłączonych układów wystawi logiczne zero, to cała linia jest zwierana do masy. Natomiast kiedy wszystkie układy wystawiają logiczną jedynkę, linia za pomocą rezystora jest łączona z zasilaniem i pojawia się na niej napięcie 3,3 V.

Jak przebiega komunikacja przez I2C?

W uproszczeniu komunikacja przez I2C wygląda następująco:

  1. Układ nadrzędny rozpoczyna komunikację poprzez wystawienie sygnału START (jest to specjalna kombinacja przebiegów na liniach SDA i SCL informująca o początku transmisji).
  2. Układ nadrzędny wysyła adres układu, z którym chce się komunikować, oraz bit informujący o kierunku transmisji. Układ podrzędny odpowiada bitem potwierdzenia, tzw. ACK.
  3. Po takim wybraniu układu następuje właściwa transmisja:
    • jeśli zostało wybrane nadawanie, to układ nadrzędny wysyła kolejne bajty, a układ podrzędny potwierdza odebranie każdego, odsyłając bit ACK,
    • jeśli zostało wybrane odbieranie, to układ podrzędny wysyła kolejne bajty, a układ nadrzędny odbiera je i odsyła bity ACK.
  4. Na koniec układ nadrzędny wysyła sygnał STOP, informujący o zakończeniu transmisji (jest to specjalna kombinacja przebiegów na liniach SDA i SCL informująca o końcu transmisji).

Są jednak dwie rzeczy, które zostały pominięte w powyższym opisie. Po pierwsze, jeśli układ nadrzędny chce zacząć kolejną transmisję natychmiast po poprzedniej, to może pominąć końcowy sygnał STOP i od razu wysłać kolejny START. Taka sytuacja występuje najczęściej w przypadku przełączania się z trybu przesyłania komend na tryb przesyłania danych (w ramach jednej transmisji) – taki przykład został też opisany w dalszej części tego poradnika.

Po drugie, oprócz potwierdzenia (ACK) istnieje jeszcze negatywne potwierdzenie (NACK) – najczęściej informuje ono o końcu danych, np. master, odczytując dane, powinien potwierdzać odbiór za pomocą ACK, poza ostatnim bajtem, kiedy powinien odesłać NACK, tak aby układ podrzędny wiedział, że nie musi już wysyłać więcej danych.

Gotowe zestawy do kursów Forbota

 Komplet elementów  Gwarancja pomocy  Wysyłka w 24h

Zamów zestaw elementów i wykonaj ćwiczenia z tego kursu! W komplecie płytka NUCLEO-L476RG oraz m.in. wyświetlacz graficzny, joystick, enkoder, czujniki (światła, temperatury, wysokości, odległości), pilot IR i wiele innych.

Zamów w Botland.com.pl »

Zewnętrzna pamięć EEPROM

Podczas pierwszych ćwiczeń z I2C opiszemy komunikację z pamięcią EEPROM, a konkretnie z układem 24AA01. Jest to relatywnie mała pamięć nieulotna o pojemności 128 B – może się to wydawać niewiele w dobie dysków o pojemności terabajtów, jednak taka pamięć jest zaskakująco użyteczna. Nawet tak mała pojemność wystarczy do przechowywania np. numeru seryjnego urządzenia, daty produkcji, ustawień czy zaszyfrowanego hasła dostępu.

Oczywiście w naszym mikrokontrolerze znajdziemy dużo więcej pamięci nieulotnej typu flash, którą moglibyśmy wykorzystać do przechowywania danych. Jednak pamięć flash ma ograniczoną liczbę cykli zapisu. W przypadku STM32L476 producent deklaruje 10 000 cykli zapisu, więc jeśli chcielibyśmy w tej pamięci przechowywać dane, które często zmieniamy, moglibyśmy szybko przekroczyć ten limit. Dla porównania – nasza pamięć EEPROM wytrzyma aż 1 000 000 cykli zapisu, czyli 100 razy więcej. 

Pinout pamięci EEPROM 24AA01

Pinout pamięci EEPROM 24AA01

Linie Vss i Vcc to odpowiednio masa i zasilanie. SCL i SDA to linie interfejsu I2C. Wyprowadzenie WP to zanegowane wejście Write Protect – musi ono być połączone z masą, jeśli chcemy zapisywać coś do pamięci EEPROM. W obudowie są jeszcze trzy wyprowadzenia adresowe: A0, A1, A2, ale nie są one używane w układzie 24AA01, więc mogą pozostać niepodłączone.

Podłączenie pamięci EEPROM do STM32

Podłączenie pamięci do mikrokontrolera jest stosunkowo proste. Podczas ćwiczeń wykorzystamy I2C1, którego linie dostępne są na pinach PB6 (SCL) i PB7 (SDA). Oprócz tego kluczowe jest dodanie dwóch rezystorów podciągających 4,7 k – bez nich nie uda nam się nawiązać komunikacji przez I2C.

Schemat ideowy i montażowy do przykładu z pamięcią EEPROM

Schemat ideowy i montażowy do przykładu z pamięcią EEPROM

Zapis do pamięci EEPROM – teoria

Schemat komunikacji z układem 24AA01 znajdziemy oczywiście w jego dokumentacji technicznej. Dla nas najważniejszy jest teraz poniższy diagram, który pokazuje, w jaki sposób należy zapisywać dane do tej zewnętrznej pamięci.

Diagram informujący o sposobie zapisu do pamięci 24AA01

Diagram informujący o sposobie zapisu do pamięci 24AA01

Jest to typowa komunikacja za pomocą I2C. Najpierw wysyłamy sygnał START, po nim adres urządzenia wraz z bitem kierunku transmisji (zero dla nadawania, jedynka dla odbioru).

Układ pamięci odpowiada wtedy, przesyłając potwierdzenie ACK. Następnie wysyłamy adres komórki pamięci, do której chcemy zapisywać dane. Pamiętamy przy tym, że dostępnych jest (aż) 128 adresów. Pamięć ponownie potwierdza odbiór danych za pomocą ACK.

Następnie wysyłamy jedną lub więcej wartości – każda jest potwierdzana za pomocą ACK. Na koniec wysyłamy STOP, aby zakończyć transmisję i umożliwić pamięci rzeczywisty zapis danych. Warto jednak już teraz wiedzieć, że zapis do pamięci jest stosunkowo powolny. Na dodatek, przesyłając więcej niż jeden bajt, należy brać pod uwagę wielkość licznika oraz adresowanie stron. Szczegóły na ten temat znaleźć można oczywiście w dokumentacji tego układu.

Zapis do pamięci EEPROM – praktyka

Zaczynamy od wstępnego projektu z mikrokontrolerem STM32L476RG, który pracuje z częstotliwością 80 MHz. Uruchamiamy debugger i USART2 w trybie asynchronicznym. Od razu zaznaczamy również w opcjach projektu, że CubeMX ma wygenerować osobne pliki dla wszystkich modułów.

Po wygenerowaniu wstępnej wersji kodu dodajemy przekierowanie komunikatów wysyłanych przez printf na UART – tak samo, jak robiliśmy to w części o komunikacji STM32L4 przez UART. Wystarczy dodanie pliku nagłówkowego:

oraz kodu zbliżonego do poniższego:

Teraz możemy rozpocząć konfigurację modułu I2C1, który znajduje się w zakładce Connectivity. Nie mamy tu dużo do zrobienia, bo wystarczy, że w sekcji Mode rozwiniemy jedyną dostępną opcję, o nazwie I2C, i wybierzemy tam wartość I2C. CubeMX automatycznie oznaczy piny PB6 i PB7 jako te, które będą wykorzystywane podczas transmisji.

Nie musimy zmieniać żadnych parametrów komunikacji (wystarczą wartości domyślne). Warto jednak zwrócić uwagę na parametr I2C Speed Mode, który został automatycznie ustawiony na Standard, co sprawiło, że prędkość transmisji została ustawiona na 100 kb/s (widać to w parametrze I2C Speed Frequency). Jest to domyślna prędkość dla I2C – niezbyt wysoka, jeśli porówna się ją z innymi standardami, ale taka komunikacja będzie wystarczająca w wielu przypadkach.

Jednak zarówno nasza pamięć EEPROM, jak i mikrokontroler STM32L476RG obsługują również tzw. tryb Fast Mode, który pozwala na komunikację z prędkością 400 kb/s (cztery razy szybciej). Nasz STM32L4 potrafi jeszcze komunikować się w tzw. trybie Fast Mode Plus (do 1000 kb/s), ale tego trybu nie obsługuje już nasza pamięć EEPROM.

Konfiguracja pierwszego projektu z I2C

Konfiguracja pierwszego projektu z I2C

Teraz możemy zapisać zmiany w CubeMX, wygenerować kod i przejść do pisania programu. Zaczniemy od zapisu danych do pamięci, później zajmiemy się odczytem i obsługą błędów.

Podczas komunikacji przez I2C moglibyśmy korzystać głównie z dwóch poniższych funkcji:

Można ich używać praktycznie tak samo jak funkcji, które były wykorzystywane w trakcie komunikacji przez SPI. Jedyna różnica to dodatkowe pole DevAddress. W przypadku SPI korzystaliśmy z dodatkowej linii CS, która była używana do wyboru adresata naszej komunikacji. W przypadku I2C każde urządzenie ma swój „cyfrowy” adres, więc podajemy go jako parametr dla funkcji.

Jednak to nie koniec przydatnych funkcji. Jak widzieliśmy w dokumentacji EEPROM-u, zaraz po adresie urządzenia przesyłany jest bajt z adresem pamięci, który chcemy zapisać lub odczytać. Taki sposób komunikacji, czyli przesyłanie najpierw adresu urządzenia, potem adresu w pamięci, a na koniec danych, jest tak popularny, że biblioteka HAL posiada funkcje ułatwiające tego typu komunikację:

Parametry ich wywołania to kolejno:

  • hi2c – struktura opisująca interfejs I2C
  • DevAddress – adres urządzenia
  • MemAdres – adres w pamięci lub numer rejestru
  • MemAddSize – długość adresu (liczba bajtów)
  • pData – bufor na dane, które chcemy wysłać lub odebrać
  • Size – wielkość bufora pData (liczba bajtów)
  • Timeout – maksymalny czas transmisji

Może się wydawać, że to sporo pracy, ale za chwilę udowodnimy, że ich użycie jest wręcz banalne. W związku z tym, że nasza pamięć EEPROM korzysta dokładnie z takiego schematu transmisji, w celu zapisu danych do pamięci wystarczy jedno wywołanie funkcji. Gdybyśmy chcieli zapisać jeden bajt do pamięci EEPROM, wystarczyłoby zatem napisać:

Jako pierwszy parametr podajemy wskaźnik do używanego modułu I2C, następnie adres urządzenia, czyli 0xA0. Kolejny parametr to adres w pamięci – jako przykład wybraliśmy adres 16, czyli 0x10. Inne układy mogą używać dłuższych adresów, ale naszej pamięci wystarczy jeden bajt, więc jako kolejny parametr podajemy wartość 1. Następne dwa parametry to bufor z danymi oraz jego wielkość, a na końcu jest czas, jaki możemy maksymalnie czekać na zapis (tzw. timeout).

Jeśli uruchomimy ten program, to zapisze on wartość 0x5A do komórki pamięci o adresie 16. Wartość ta nie zniknie z pamięci EEPROM nawet po odłączeniu zasilania. Jednak w celu sprawdzenia działania tego mechanizmu musimy jeszcze nauczyć się, jak odczytywać dane z pamięci.

Odczyt z pamięci EEPROM

Odczytywanie danych z pamięci warto rozpocząć od analizy schematu transmisji – tak jak poprzednio w dokumentacji układu znajdziemy odpowiedni diagram.

Diagram informujący o sposobie odczytu danych z pamięci 24AA01

Diagram informujący o sposobie odczytu danych z pamięci 24AA01

Odczytywanie danych jest nieco zawiłe, ale tutaj ponownie ratuje nas „gotowiec”, który jest częścią biblioteki HAL. Wystarczy wywołać z odpowiednimi parametrami funkcję HAL_I2C_Mem_Read:

Parametry przekazywane do tej funkcji są identyczne z tymi, których używaliśmy podczas zapisu. Nawet adres urządzenia możemy podać jako 0xA0 (adres dla zapisu), a nie jako 0xA1 (adres dla odczytu).

Teraz możemy napisać krótki program testowy, który zapisze jakąś wartość ze zmiennej test do naszej zewnętrznej pamięci EEPROM, a następnie ją odczyta i zapisze do zmiennej result:

Jak sprawdzić, czy układ i program działają poprawnie? Najlepiej uruchomić całość pod kontrolą debuggera, a następnie podejrzeć wartość tych zmiennych w okienku Variables.

Podgląd danych w debuggerze

Podgląd danych w debuggerze

Jeśli wszystko przebiegło poprawnie, to obie zmienne powinny mieć taką samą wartość. Możemy wtedy edytować program i zakomentować linijkę zapisującą dane do pamięci. Po kompilacji i wgraniu nowej wersji programu do zmiennej result nadal powinna być przypisana wartość 90. Co więcej, nawet odłączenie układu od zasilania i jego ponowne podłączenie nie powinno usunąć z pamięci tych danych.

Kontrola transmisji za pomocą analizatora

Jak widać, używanie funkcji HAL_I2C_Mem_Write oraz HAL_I2C_Mem_Read jest proste i przyjemne, jednak dla zainteresowanych wyjaśnijmy nieco dokładniej, co robią te funkcje i jakie to ma przełożenie na działanie I2C, o którym pisaliśmy na początku tego poradnika.

Podczas analizy działania protokołów komunikacyjnych bardzo przydatnym narzędziem jest analizator stanów logicznych (nawet najprostszy). Za jego pomocą można zobaczyć (w formie graficznej), jak wygląda komunikacja między urządzeniami. Nie musisz posiadać swojego analizatora, wystarczy, że przeanalizujesz zrzuty z takiego narzędzia – zamieściliśmy je poniżej.

Wróćmy jednak do naszego programu (wersja z zapisem i odczytem danych). Po wpięciu analizatora możemy „podsłuchać”, co dokładnie dzieje się na liniach danych. W tym przypadku mamy najpierw wywołanie funkcji HAL_I2C_Mem_Write, a po 5 ms HAL_I2C_Mem_Read. Analizator potwierdza, że program działa tak, jak tego oczekujemy, bo widzimy tutaj dwa „piki” transmisji:

Podglądanie transmisji I2C przez analizator stanów logicznych

Podglądanie transmisji I2C przez analizator stanów logicznych

Warto jednak przybliżyć sygnał, aby zobaczyć, jak dokładnie wygląda wywołanie HAL_I2C_Mem_Write:

Zbliżenie na linie sygnałowe podczas wywołania funkcji zapisującej dane do pamięci

Zbliżenie na linie sygnałowe podczas wywołania funkcji zapisującej dane do pamięci

Górny wiersz prezentuje linię SDA, czyli linię danych, a dolny to linia SCL, przez którą przesyłany jest sygnał zegarowy. Na linii SDA widać wyraźnie zieloną kropkę, która pokazuje sygnał START, następnie transmisję trzech bajtów oraz czerwoną kropkę symbolizującą znak STOP.

Pierwszy przesyłany bajt to adres naszego urządzenia, a zatem 0xA0. Jak widzimy, urządzenie potwierdziło swoją obecność, odsyłając ACK. Dla przypomnienia – ta sama linia jest wykorzystywana do komunikacji w dwie strony. Dlatego zarówno dane, które wysłaliśmy do pamięci, jak i potwierdzenie ACK są widoczne na tej samej linii (SDA). Następny bajt to adres w pamięci – przesłaliśmy tutaj 0x10, a pamięć potwierdziła odebranie danych za pomocą ACK. Ostatni bajt to zapisywana wartość, czyli 0x5A. Tutaj również widzimy potwierdzenie, czyli ACK.

Zapis danych przebiegł więc dokładnie tak, jak się tego spodziewaliśmy. Teraz pora, aby przybliżyć zapis analizatora stanów logicznych na drugą część naszego przebiegu, czyli na odczyt danych. Tutaj od razu widać, że ta część komunikacji jest trudniejsza i składa się z dwóch „niezależnych” części. Pierwsza to zapis, ale tylko adresu w pamięci. Dopiero druga część to faktyczny odczyt danych.

Zbliżenie na linie sygnałowe podczas wywołania funkcji odczytującej dane z pamięci

Zbliżenie na linie sygnałowe podczas wywołania funkcji odczytującej dane z pamięci

Podobnie jak poprzednio, komunikację zaczyna sygnał START reprezentowany przez zieloną kropkę. Następnie mamy rozpoczęcie zapisu oraz przesłanie adresu w pamięci, czyli 0x10. W związku z tym, że chcemy odczytać dane, to transmisja teraz się kończy, ale nie jest przesyłany znak STOP, tylko znów jest wysyłany START (kolejna zielona kropka).

Dopiero w drugim kroku przesyłane jest żądanie odczytu (widać też, że biblioteka sama zmieniła adres z 0xA0 na 0xA1). Moduł pamięci odsyła potwierdzenie, a w następnym kroku przesyła żądaną wartość, czyli 0x5A. Po tym bajcie pojawia się NACK, czyli negatywne potwierdzenie. Wysyła je master, czyli nasz mikrokontroler, informując pamięć, że nie będzie odczytywał więcej bajtów. Na koniec przesyłany jest sygnał STOP (widoczny jako czerwona kropka).

Jak widać, używanie biblioteki HAL ma pewne zalety – mnóstwo niskopoziomowego kodu zostało już napisane i nie musimy wszystkiego robić zupełnie od początku, wystarczy skorzystać z wygodnych funkcji, jak HAL_I2C_Mem_Read. Używanie takich „gotowców” znacznie przyspiesza pracę, trzeba jednak wiedzieć, co dokładnie dzieje się przy wywołaniu danej funkcji, aby nie było później żadnych niejasności (np. dlaczego podczas odczytu i zapisu można w kodzie podać ten sam adres urządzenia).

Czas zapisu i obsługa błędów

Spostrzegawczy czytelnicy zapewne zauważyli, że w pierwszym programie między zapisem a odczytem danych wywołaliśmy funkcję HAL_Delay – wstrzymaliśmy tym samym działanie układu na 5 ms. Wynika to z tego, że zapis do pamięci EEPROM trwa stosunkowo długo.

Fragment dokumentacji używanej przez nas pamięci EEPROM

Fragment dokumentacji używanej przez nas pamięci EEPROM

Dlatego wstawiliśmy do kodu opóźnienie. Oczywiście nie jest to najlepsze rozwiązanie, ale wystarczyło do uruchomienia przykładowych programów. Teraz, gdy już wiemy, jak działa komunikacja z pamięcią EEPROM, możemy dokładniej przyjrzeć się problemom związanym z czasem, który potrzebny jest na zapis. Zacznijmy od usunięcia tego opóźnienia i uruchomienia programu za pomocą debuggera.

Jeśli ustawimy pułapkę na pętli while, to powinniśmy zobaczyć, że wartość zmiennej result wynosi tym razem 0 zamiast spodziewanych 90. Widzimy więc, że ewidentnie coś jest nie tak, jak oczekiwaliśmy.

Niewłaściwy wynik odczytu przy braku opóźnienia

Niewłaściwy wynik odczytu przy braku opóźnienia

Zanim przejdziemy dalej, warto wykonać jeszcze jeden eksperyment i ustawić pułapkę (ang. breakpoint) na linii HAL_I2C_Mem_Read. Po zatrzymaniu programu wartość zmiennej result będzie niepoprawna, bo nie odczytaliśmy jeszcze danych. Następnie wybieramy Step Over (lub naciskamy F6), aby funkcja została wykonana. Teraz program zadziała zgodnie z oczekiwaniami. 

Poprawny wynik po ręcznym uruchomieniu funkcji

Poprawny wynik po ręcznym uruchomieniu funkcji

Zatrzymanie programu na pułapce wprowadziło opóźnienie – czyli zadziałało jak nasz wcześniejszy HAL_Delay. Dlatego program znowu zadziałał poprawnie. Debugger zatrzymał nasz mikrokontroler, ale nie zatrzymał układów, które są do niego podłączone – w efekcie nasze ręczne zatrzymanie wykonywania kodu dało pamięci EEPROM czas na poprawne dokonanie zapisu.

Wróćmy jednak do naszego programu i problemu z niepoprawnym odczytem. Przyczyna leży w braku kontroli błędów. Wywołujemy HAL_I2C_Mem_Write oraz HAL_I2C_Mem_Read, ale nie sprawdzamy, czy funkcje zadziałały poprawnie.

Jeśli ktoś ma jeszcze wątpliwości, czy sprawdzanie błędów jest ważne, to w ramach eksperymentu warto w tej chwili odłączyć zupełnie linie SDA oraz SCL i uruchomić program pod kontrolą debuggera. Całość zadziała tak samo jak wcześniej.

Efekt działania programu przy odłączonych liniach SDA i SCL

Efekt działania programu przy odłączonych liniach SDA i SCL

Program zadziałał, otrzymaliśmy jakieś dane, ale nie mamy pojęcia, że nic nie odczytaliśmy. Gdyby w naszym EEPROM-ie było zapisane jakieś ważne ustawienie urządzenia, wtedy po odłączeniu linii SDA i SCL przyjmowałoby ono wartość równą zero (lub inną, która zostałaby przypadkiem uzyskana).

W celu uniknięcia tego typu problemów trzeba do programu dodać obsługę błędów:

Teraz po uruchomieniu programu bez podłączonych linii SDA i SCL zostaniemy poinformowani o błędzie, bo program zatrzyma się wewnątrz funkcji Error_Handler. Za pomocą debuggera możemy nawet sprawdzić, gdzie ten błąd się pojawia – wywołanie HAL_I2C_Mem_Write kończy się błędem, czyli zwraca ona wartość HAL_ERROR. Oprócz tego, za pomocą analizatora stanów logicznych, możemy jeszcze podejrzeć, jak wyglądała próba komunikacji z układem.

Próba komunikacji z układem przy odłączonych liniach I2C

Próba komunikacji z układem przy odłączonych liniach I2C

Układ nadrzędny (mikrokontroler) wysłał adres układu, z którym chciał się komunikować, jednak nie pojawiła się odpowiedź, co zostało potraktowane jako negatywne potwierdzenie (NACK), a funkcja HAL_I2C_Mem_Write zwróciła błąd.

W ramach kolejnego eksperymentu warto ponownie połączyć układ i raz jeszcze uruchomić program, bez wcześniejszego opóźnienia. Za pomocą debuggera zobaczymy, że tak jak poprzednio pojawił się błąd, ale tym razem był on spowodowany wywołaniem funkcji HAL_I2C_Mem_Read.

Błąd wynikający z wywołania HAL_I2C_Mem_Read

Błąd wynikający z wywołania HAL_I2C_Mem_Read

Tym razem wywołanie HAL_I2C_Mem_Write się powiodło, ale funkcja HAL_I2C_Mem_Read zwróciła błąd. Oczywiście możemy tę sytuację podejrzeć za pomocą analizatora stanów logicznych.

Podgląd linii danych za pomocą analizatora stanów logicznych

Podgląd linii danych za pomocą analizatora stanów logicznych

Najpierw widoczne są trzy bajty wysłane po wywołaniu HAL_I2C_Mem_Write, a następnie sekwencje STOP oraz START i próba wysłania adresu 0xA0, która kończy się niepowodzeniem. To właśnie dlatego funkcja HAL_I2C_Mem_Read zwraca błąd.

Wynika z tego jednoznacznie, że nasza pamięć EEPROM potrzebuje trochę czasu, aby zapisać odebrane dane. Co ważne, układ ten nie będzie reagował na żadne próby komunikacji do momentu, gdy zapis ten się nie zakończy. Możemy więc zmienić program i w pętli próbować odczytywać dane, aż się uda:

Nie jest to idealny kod, bo inny błąd niż opóźnienie zapisu może wprowadzić program w nieskończone oczekiwanie. Warto byłoby się pokusić o rozbudowanie tego kodu w taki sposób, aby program czekał na poprawny odczyt maksymalnie 10 ms – później powinien być zwracany błąd. Powyższa wersja kodu zadziała jednak poprawnie. Oto jak będzie wyglądała komunikacja na liniach I2C:

Próba nawiązania komunikacji (oczekiwanie na zapis)

Próba nawiązania komunikacji (oczekiwanie na zapis)

Jak widać, program próbował się porozumieć z pamięcią EEPROM, aż uzyskał odpowiedź, co nastąpiło dopiero po nieco ponad 4 ms. A zatem dobitnie widać, że po zapisie danych do EEPROM-a musimy dać mu trochę czasu na dokonanie faktycznego zapisu.

Możemy wykorzystać do tego różne rozwiązania. Proste HAL_Delay nie jest na pewno najlepszą opcją, ale do programów przykładowych w zupełności wystarcza. Z kolei ciągłe odpytywanie o zakończenie transmisji sprawia, że możemy odczytać dane nieco szybciej, tzn. od razu po zakończeniu zapisu, ale niestety nadal blokujemy wykonywanie innych części programu. Najlepiej byłoby rozpocząć zapis, a następnie w programie wykonywać inne czynności i dopiero po upływie wymaganego czasu wrócić do komunikacji z pamięcią EEPROM. Dzięki temu uniknęlibyśmy blokowania programu i błędów I2C.

Własna biblioteka do obsługi pamięci EEPROM

Pamięć EEPROM to element, który może przydać się podczas tworzenia przeróżnych projektów. Warto więc przygotować własną bibliotekę, która ułatwi zapisywanie danych do pamięci oraz ich odczytywanie z niej. Kod takiej biblioteki może wyglądać tak jak poniżej.

W katalogu Core\Inc tworzymy plik eeprom.h i umieszczamy w nim poniższą zawartość:

Na początku oprócz #pragma once dołączamy plik stm32l4xx.h, aby możliwe było korzystanie z typu HAL_StatusTypeDef. Dalej dodajemy informacje o trzech funkcjach:

  • eeprom_read – do odczytu danych,
  • eeprom_write – do zapisu,
  • eeprom_wait – funkcja pomocnicza, która wprowadza opóźnienie po zapisie.

Następnie w katalogu Core\Src tworzymy plik eeprom.c i dodajemy do niego właściwą treść funkcji. Zastosujemy nieco inne podejście do wprowadzania opóźnienia po zapisie. W większości przypadków nie musimy wcale czekać na zapisanie danych – wprowadzenie opóźnienia jest konieczne tylko wówczas, gdy chcemy odczytać lub zapisać kolejne dane. 

Zawartość pliku eeprom.c może zatem wyglądać następująco:

Nasza biblioteka będzie wyposażona w „sprytną” funkcję eeprom_wait, która będzie sama dodawała opóźnienie, ale tylko wtedy, gdy będzie ono faktycznie potrzebne. Dodajemy wywołanie tej funkcji wewnątrz eeprom_read oraz eeprom_write i nie musimy się więcej martwić opóźnieniami. Jak działa nasza „sprytna” wersja funkcji opóźniającej?

Nie używamy tutaj HAL_Delay ani nie korzystamy też z ciągłego odczytu lub zapisu w pętli. Zamiast tego w zmiennej last_write przechowujemy czas ostatniego zapisu. Następnie, jeśli przy kolejnym wywołaniu tej funkcji (czyli tuż po rozpoczęciu zapisu/odczytu) minęło mniej czasu, niż jest to wymagane, to funkcja eeprom_wait faktycznie wprowadza opóźnienie, natomiast jeśli minęło więcej czasu, to funkcja ta nie opóźni działania naszego programu.

Teraz możemy napisać prosty przykład testujący naszą bibliotekę:

Całość powinna działać tak samo jak poprzednio (warto to sprawdzić za pomocą debuggera). Jak widać, tworzenie własnych bibliotek ma mnóstwo zalet. Dzięki nim wiele szczegółów, takich jak chociażby użyty interfejs (I2C), adres urządzenia czy konieczność wprowadzania opóźnień, może być niejako ukrytych. W efekcie w pozostałej części programu możemy skoncentrować się na innych funkcjach, a wszystkie niskopoziomowe szczegóły można ukryć w bibliotece.

Licznik uruchomień układu

Umiemy już zapisywać dane w pamięci EEPROM i je odczytywać – czas teraz stworzyć program, który wykorzysta nasze nowe umiejętności. Napiszmy program, który będzie zliczał, ile razy został uruchomiony. Informacje o liczbie uruchomień będziemy wysyłać do komputera za pomocą UART-a.

Poprzednio zapisywaliśmy i odczytywaliśmy tylko jeden bajt. Jednak taki licznik szybko by się przepełnił i po 256 uruchomieniach mielibyśmy znowu wartość zero. Zamiast tego użyjemy licznika 32-bitowego. Dzięki temu (w teorii) wcześniej zepsuje się nasz EEPROM, niż osiągniemy maksymalną wartość.

Program jest prosty. Najpierw odczytujemy, ile razy uruchomiliśmy urządzenie, następnie zwiększamy wartość licznika, wysyłamy komunikat i zapisujemy nową wartość. Cały program wygląda następująco (wystarczy, że będziemy go uruchamiać tylko raz po włączeniu układu; poprzedni kod można usunąć):

Jeśli uruchomimy program w takiej postaci, to uzyskane liczby mogą być nieco zaskakujące. Będzie to wynikało z tego, że nie zapisaliśmy nigdzie w pamięci EEPROM wartości początkowej. Po włączeniu układu nasz program pobrał poprzednią wartość zapisaną w danej lokalizacji pamięci, zwiększył ją o jeden i zapisał tam nowy wynik.

Losowa wartość odczytana z pamięci EEPROM

Losowa wartość odczytana z pamięci EEPROM

Aby nasz program działał poprawnie, musimy najpierw zapisać w pamięci jakąś wartość początkową. Najprościej można to zrobić w taki sposób, że najpierw wgrywamy program z zakomentowaną linią, która odpowiada za odczytanie danych (wtedy do pamięci zapisana zostanie wartość 1), a potem znów odkomentowujemy tę linię i wgrywamy program od nowa. Teraz przy każdym włączeniu/resecie układu nasz licznik będzie już wskazywał poprawną wartość.

Inkrementacja licznika uruchomień z zapisaną wartością początkową

Inkrementacja licznika uruchomień z zapisaną wartością początkową

Zadanie domowe

  1. Wykorzystaj przycisk USER_BUTTON do tego, aby resetował licznik uruchomień urządzenia.
  2. Edytuj program w taki sposób, aby licznik uruchomień układu był resetowany wyłącznie wówczas, gdy przycisk USER_BUTTON jest wciśnięty podczas włączania układu (jego późniejsze wciskanie nie powinno już resetować tego licznika).
  3. Dodaj do projektu drugi przycisk i rozbuduj program w taki sposób, aby oprócz numeru uruchomienia zapamiętywał też liczbę naciśnięć dodatkowego przycisku.

Podsumowanie – co powinieneś zapamiętać?

Za nami podstawy pracy z I2C. Ważne jest, abyś po tej części kursu STM32L4 wiedział, jak napisać kod, który wykorzystuje ten interfejs do komunikacji z modułem pamięci zewnętrznej. Najważniejsze jest jednak, abyś pamiętał o wszystkich haczykach i ograniczeniach, które zostały omówione w tej części (wpływ debuggera na działanie układu, czekanie na dostępność pamięci oraz oczywiście konieczność używania rezystorów podciągających na liniach danych).

Czy wpis był pomocny? Oceń go:

Średnia ocena 4.9 / 5. Głosów łącznie: 15

Nikt jeszcze nie głosował, bądź pierwszy!

Artykuł nie był pomocny? Jak możemy go poprawić? Wpisz swoje sugestie poniżej. Jeśli masz pytanie to zadaj je w komentarzu - ten formularz jest anonimowy, nie będziemy mogli Ci odpowiedzieć!

W kolejnej części wykorzystamy interfejs I2C do komunikacji z precyzyjnym czujnikiem ciśnienia, dzięki któremu możliwe jest również mierzenie wysokości. Będzie to dobra okazja do przećwiczenia pracy z I2C oraz do małej powtórki z fizyki, bo jednostki, w których mierzy się ciśnienie, nie są takie oczywiste.

Nawigacja kursu

Główny autor kursu: Piotr Bugalski
Współautor: Damian Szymański, ilustracje: Piotr Adamczyk
Oficjalnym partnerem tego kursu jest firma STMicroelectronics
Zakaz kopiowania treści kursów oraz grafik bez zgody FORBOT.pl

eeprom, i2c, kursSTM32L4, pamięć, stm32

Trwa ładowanie komentarzy...