Kursy • Poradniki • Inspirujące DIY • Forum
Na początku wykorzystamy I2C (czytaj: i kwadrat ce) do komunikacji z pamięcią EEPROM, a później z akcelerometrem. Przykład z pamięcią EEPROM jest bardzo ważny. Osoby przyzwyczajone do programowania układów AVR często zapominają, że STMy nie są wyposażone w pamięć EEPROM, więc taki dodatkowy układ może okazać się pomocny przy wielu projektach.
I2C - Najważniejsze informacje
Interfejs I2C podobnie do SPI działa w sposób synchroniczny. Oznacza to, że sygnał zegarowy jest przesyłany między układami, co odróżnia go od UART-a, który musiał polegać na zegarach wbudowanych w komunikujące się układy.
Projektanci interfejsu postanowili ograniczyć liczbę niezbędnych linii, ale jednocześnie umożliwić podłączanie wielu układów do wspólnego interfejsu. Wymagania na pierwszy rzut oka mogą się wydawać sprzeczne. Jednak ostatecznie założony cel został osiągnięty (kosztem komplikacji protokołu).
W kursie opiszemy I2C najprościej jak się da, bez wnikania w szczegóły. Osoby zainteresowane wykorzystaniem I2C w swoich projektach powinny potraktować ten artykuł raczej jako wstęp, a nie wyczerpujące kompendium wiedzy.
Tak samo jak w przypadku SPI układy komunikujące się przez I2C dzielą się na 2 kategorie: układów nadrzędnych (ang. master) oraz podrzędnych (ang. slave). Do jednego interfejsu może być jednocześnie podłączonych kilka układów nadrzędnych, my skupimy się na prostej wersji z jednym układem master, którym będzie nasz mikrokontroler.
Gotowe zestawy do kursów Forbota
Komplet elementów Gwarancja pomocy Wysyłka w 24h
Zestaw ponad 120 elementów do przeprowadzenia wszystkich ćwiczeń z kursu można nabyć u naszych dystrybutorów! Dostępne są wersje z płytką Nucleo lub bez niej!
Zamów w Botland.com.pl »Podłączenie do magistrali I2C
Schemat podłączenia układów do I2C wygląda następująco:
Jak widać wykorzystywane są dwie linie komunikacyjne (Vcc, to zasilanie):
- SDA - dwukierunkowa linia danych.
- SCL - linia zegarowa sterowana przez układ nadrzędny.
Przy SPI do przesyłania danych wykorzystywane były dwie linie danych MOSI i MISO. Dzięki temu jednocześnie można wysyłać i odbierać informacje. Taki tryb działania nazywany jest full-duplex. I2C posiada tylko jedną linię danych, więc w danej chwili można na niej tylko wysyłać lub odbierać dane.
Jest to tryb half-duplex - więcej o nim można przeczytać na Wikipedii.
Najczęściej wyjścia mikrokontrolera są używane w tzw. trybie przeciwsobnym. Jeśli na wyjściu jest logiczne 0, odpowiednie wyprowadzenie jest zwierane do masy. Natomiast logiczna 1 to połączenie z zasilaniem (3.3V).
W takiej sytuacji połączenie wyjść dwóch układów może łatwo prowadzić do zwarcia. Jeśli jeden układ będzie próbował wystawić logiczne 0, a drugi 1, nastąpi zwarcie między masą i zasilaniem. Aby tego uniknąć konieczna jest synchronizacja dostępu do interfejsu i zapobieganie jednoczesnym zapisom do wspólnej linii.
W przypadku SPI do takiej synchronizacji były wykorzystywane linie CS - tylko jeden układ slave mógł w danym momencie sterować linią MISO.
Ponieważ I2C nie wykorzystuje sygnałów CS, konieczne było znalezienie innej metody współdzielenia linii. Wykorzystano w tym celu wyjście typu otwarty kolektor (open drain). Oznacza to, że gdy mikrokontroler wystawia na pinie logiczne 0, linia jest zwierana do masy. Natomiast logiczna 1 zamiast zwierać wyście do zasilania, pozostawia linię niepodłączoną - dokładnie tak samo, jak podczas pracy linii jako wejścia.
Niepodłączona linia to nic dobrego, konieczne jest więc użycie rezystorów podciągających!
Sposób działania linii magistrali I2C
Jeśli którykolwiek z układów podłączonych do danej linii interfejsu wystawi logiczne 0, cała linia jest zwierana do masy. Natomiast kiedy wszystkie układy wystawiają wartość 1, linia za pomocą rezystora jest łączona z zasilaniem i pojawia się na niej napięcie 3.3 V.
Trzeba pamiętać o rezystorach podciągających. Bez nich układ nie zadziała!
Kolejne etapy komunikacji przez I2C
Komunikacja przez I2C wygląda następująco:
- 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.
- Następnie wysyła adres układu, z którym chce się komunikować oraz bit informujący o kierunku transmisji (nadawanie albo odbiór). Układ podrzędny odpowiada bitem potwierdzenia ACK.
- Po wybraniu układu, następuje właściwa transmisja:
- jeśli zostało wybrane nadawanie, master wysyła kolejne bajty, a slave potwierdza odebranie każdego bitem ACK
- w przypadku odbioru, slave wysyła kolejne bajty, a master odbiera i wysyła ACK.
- Na koniec master wysyła sygnał STOP informujący o zakończeniu transmisji.
Są jeszcze dwie rzeczy, które zostały pominięte w powyższym opisie - jeśli master chce zacząć kolejną transmisję natychmiast po poprzedniej, może pominąć końcowe STOP i od razu wysłać kolejne START.
Druga sprawa to potwierdzenia - pisaliśmy o ACK, ale istnieje jeszcze negatywne potwierdzenie NACK. Najczęściej informuje ono o końcu danych, przykładowo master odczytując dane powinien potwierdzać odbiór za pomocą ACK, poza ostatnim bajtem, kiedy powinien odesłać NACK tak, aby slave wiedział, że nie musi już więcej danych wysyłać.
Pamięć EEPROM
Gdy wiemy już jak działa ten interfejs możemy przejść do praktyki. Teraz zajmiemy się pamięcią eeprom, a w kolejnym odcinku przyjdzie pora na akcelerometr cyfrowy.
Jako pierwszy przykład wykorzystamy komunikację z pamięcią EEPROM, konkretnie z układem typu 24AA01. Jak zawsze należy zacząć od przeczytania dokumentacji. Jest to relatywnie mała pamięć nieulotna o pojemności 128B. Może się to wydawać niewiele w dobie dysków o pojemności TB, jednak taka pamięć jest zaskakująco użyteczna.
Nawet tak mała pojemność wystarczy do przechowywania np. numeru seryjnego urządzenia, daty produkcji, czy hasła dostępu.
Oczywiście w mikrokontrolerze znajdziemy dużo więcej pamięci nieulotnej typu Flash, którą moglibyśmy wykorzystać do przechowywania danych. Jednak pamięć Flash ma dość ograniczoną liczbę cykli zapisu. W przypadku STM32F103 producent deklaruje 10000 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ż 1000000 cykli zapisu. Poza tym łatwiej ją wymienić niż cały mikrokonotroler.
Przyjrzyjmy się wyprowadzeniom układu 24AA01:
Linie Vss i Vcc to odpowiednio masa i zasilanie. SCL i SDA to linie naszego interfejsu I2C. WP oznacza Write Protect i musi być połączona z masą, jeśli chcemy mieć możliwość zapisu do pamięci EEPROM. Są jeszcze trzy wyprowadzenia adresowe A0, A1, A2 - nie są one używane w układzie 24AA01 i mogą pozostać niepodłączone.
Podłączenie pamięci EEPROM do STM32
Układ powinniśmy podłączyć zgodnie z poniższym rysunkiem. Do komunikacji z pamięcią EEPROM wykorzystamy interfejs I2C1. Jego linie SCL i SDA są dostępne na pinach PB6 i PB7.
Należy pamiętać o konieczności podpięcia rezystorów podciągających (3,3kΩ).
Zapis do pamięci EEPROM
Schemat komunikacji z układem 24AA01 znajdziemy we wspomnianej wcześniej dokumentacji. Poniżej umieściłem najważniejszy dla nas diagram:
Jak widzimy, jest to typowa komunikacja za pomocą I2C. Najpierw wysyłamy sygnał START. Po nim adres urządzenia wraz z bitem kierunku transmisji. Przy nadawaniu (zapisie) jest to zero, a przy odbiorze (odczycie) jedynka.
Podczas zapisywania należy odwołać się do adresu 0xA0, a podczas odczytywania z 0xA1.
Układ pamięci odpowiada przesyłając potwierdzenie ACK. Następnie wysyłamy adres komórki pamięci, do której chcemy zapisywać. 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.
Zapis do pamięci jest stosunkowo powolny, dodatkowo przesyłając więcej niż jeden bajt należy brać pod uwagę wielkość licznika oraz adresowanie stron. Szczegóły w dokumentacji.
STM32 i EEPROM - zapis danych w praktyce
Wiemy już jak ma wyglądać transmisja, teraz możemy przejść do jej implementacji na naszym mikrokontrolerze. Jak zwykle zaczynamy od podłączenia odpowiedniego sygnału zegarowego:
1 |
__HAL_RCC_I2C1_CLK_ENABLE(); |
Konfigurujemy piny PB6 i PB7 do pracy z interfejsem I2C. Tryb którego użyjemy to open-drain:
1 2 3 4 5 6 |
GPIO_InitTypeDef gpio; gpio.Mode = GPIO_MODE_AF_OD; gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; // SCL, SDA gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &gpio); |
Kolejny krok to zadeklarowanie zmiennej konfigurującej I2C oraz inicjalizacja modułu. Opcji jest dość sporo, ale taki jest właśnie urok biblioteki HAL:
1 2 3 4 5 6 7 8 9 10 |
i2c.Instance = I2C1; i2c.Init.ClockSpeed = 100000; i2c.Init.DutyCycle = I2C_DUTYCYCLE_2; i2c.Init.OwnAddress1 = 0xff; i2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; i2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; i2c.Init.OwnAddress2 = 0xff; i2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; i2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&i2c); |
Wybrana prędkość, to 100kHz. Maksymalna dla naszego układu wynosi 400kHz, jednak wtedy dość długie przewody łączące mogłyby spowodować problemy z komunikacją. Biblioteka HAL udostępnia parę funkcji: HAL_I2C_Master_Transmit oraz HAL_I2C_Master_Receive, które pozwalają wysyłanie oraz odbieranie danych przez I2C tak jak opisywaliśmy to wcześniej.
Okazuje się, że wiele urządzeń stosuje nieco inny schemat komunikacji. Najpierw wysyłany jest adres urządzenia slave, następnie adres w pamięci lub numer rejestru do którego chcemy się odwoływać. Można taką komunikację uzyskać wielokrotnie wywołując wspomniane funkcje, ale dla wygody i optymalizacji kodu HAL udostępnia również parę funkcji HAL_I2C_Mem_Write i HAL_I2C_Mem_Read.
Funkcje są zadeklarowane następująco:
1 2 |
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout) HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
Parametry ich wywołania to kolejno:
- hi2c - struktura opisująca interfejs i2c
- DevAddress - adres układu
- 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 jak za chwilę zobaczymy ich użycie jest bardzo proste. Wcześniej wspomnieliśmy, że nasza pamięć eeprom stosuje dokładnie taki schemat komunikacji. Oznacza to, że do zapisania danych wystarczy jedno wywołanie funkcji. Gdybyśmy chcieli zapisać jeden bajt wystarczyłoby więc napisać:
1 2 3 |
uint8_t test = 0x5a; HAL_I2C_Mem_Write(&i2c, 0xa0, 0x10, 1, (uint8_t*)&test, sizeof(test), HAL_MAX_DELAY); |
STM32 i EEPROM - odczyt danych w praktyce
Wiemy już jak zapisać dane, teraz spróbujemy je odczytać i sprawdzimy, czy wartości się zgadzają. Ponownie, przebieg odczytu jest opisany w dokumentacji układu pamięci.
Odpowiedni diagram widzimy poniżej:
Jak widać komunikacja jest nieco skomplikowana, ale uratuje nas gotowa funkcja HAL_I2C_Mem_Read. Wystarczy więc, że napiszemy:
1 2 3 |
uint8_t result; HAL_I2C_Mem_Read(&i2c, 0xa0, 0x0, 1, &result, 1, HAL_MAX_DELAY); |
Teraz możemy napisać krótki program, który zapisze wartość zmiennej test do pamięci eeprom, a następnie wczyta zapisaną wartość do zmiennej result.
Całość powinna wyglądać następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#include "stm32f1xx.h" I2C_HandleTypeDef i2c; int main(void) { SystemCoreClock = 8000000; // taktowanie 8Mhz HAL_Init(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); GPIO_InitTypeDef gpio; gpio.Mode = GPIO_MODE_AF_OD; gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; // SCL, SDA gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &gpio); i2c.Instance = I2C1; i2c.Init.ClockSpeed = 100000; i2c.Init.DutyCycle = I2C_DUTYCYCLE_2; i2c.Init.OwnAddress1 = 0xff; i2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; i2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; i2c.Init.OwnAddress2 = 0xff; i2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; i2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&i2c); uint8_t test = 0x5a; HAL_I2C_Mem_Write(&i2c, 0xa0, 0x10, 1, (uint8_t*)&test, sizeof(test), HAL_MAX_DELAY); uint8_t result = 0; HAL_I2C_Mem_Read(&i2c, 0xa0, 0x10, 1, (uint8_t*)&result, sizeof(result), HAL_MAX_DELAY); while (1) { } } |
Jak sprawdzić, czy program działa? Najlepiej uruchomić go pod debuggerem i zobaczyć czy zawartość zmiennych test i result jest zgodna z oczekiwaniami. O ile poprzednio zapisaliśmy dane, a nasz układ jest poprawnie podłączony powinniśmy zobaczyć rezultat jak na obrazku:
Licznik uruchomień układu
Umiemy już zapisywać i odczytywać dane z pamięci EEPROM, czas napisać program, który wykorzysta naszą nową umiejętność. Napiszmy program, który będzie zliczał ile razy został uruchomiony. Informacje o liczbie uruchomień będziemy wysyłać za pomocą UARTa.
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 wcześniej popsujemy nasz eeprom niż osiągniemy maksymalną wartość.
Program jest dość prosty. Najpierw odczytuje ile razy uruchomiliśmy urządzenie, następnie zwiększa stan licznika, wypisuje komunikat oraz zapisuje nową wartość. Cały program wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
#include "stm32f1xx.h" I2C_HandleTypeDef i2c; UART_HandleTypeDef uart; void send_char(char c) { HAL_UART_Transmit(&uart, (uint8_t*)&c, 1, 1000); } int __io_putchar(int ch) { if (ch == '\n') send_char('\r'); send_char(ch); return ch; } int main(void) { uint32_t counter = 0; SystemCoreClock = 8000000; // taktowanie 8Mhz HAL_Init(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); __HAL_RCC_USART2_CLK_ENABLE(); GPIO_InitTypeDef gpio; gpio.Mode = GPIO_MODE_AF_OD; gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; // SCL, SDA gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &gpio); gpio.Mode = GPIO_MODE_AF_PP; gpio.Pin = GPIO_PIN_2; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio); gpio.Mode = GPIO_MODE_AF_INPUT; gpio.Pin = GPIO_PIN_3; HAL_GPIO_Init(GPIOA, &gpio); i2c.Instance = I2C1; i2c.Init.ClockSpeed = 100000; i2c.Init.DutyCycle = I2C_DUTYCYCLE_2; i2c.Init.OwnAddress1 = 0xff; i2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; i2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; i2c.Init.OwnAddress2 = 0xff; i2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; i2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&i2c); uart.Instance = USART2; uart.Init.BaudRate = 115200; uart.Init.WordLength = UART_WORDLENGTH_8B; uart.Init.Parity = UART_PARITY_NONE; uart.Init.StopBits = UART_STOPBITS_1; uart.Init.HwFlowCtl = UART_HWCONTROL_NONE; uart.Init.OverSampling = UART_OVERSAMPLING_16; uart.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&uart); HAL_I2C_Mem_Read(&i2c, 0xa0, 0x10, 1, (uint8_t*)&counter, sizeof(counter), HAL_MAX_DELAY); counter++; printf("Uruchomienie numer %ld\n", counter); HAL_I2C_Mem_Write(&i2c, 0xa0, 0x10, 1, (uint8_t*)&counter, sizeof(counter), HAL_MAX_DELAY); while (1) { } } |
Rezultat działania programu w praktyce widoczny jest poniżej:
Podsumowanie
Komunikacja za pomocą I2C jest nieco bardziej skomplikowana niż za pomocą SPI, czy UART, jednak interfejs ten ma naprawdę wiele zalet. Za pomocą raptem dwóch linii można połączyć wiele urządzeń i komunikować się z nimi z zadowalającą prędkością.
Komunikacja może w pierwszej chwili wydawać się skomplikowana, ale raz napisane procedury mogą być równie łatwo używane w przykładowym programie, jak i w przypadku innych rozwiązań.
Nawigacja kursu
Autor kursu: Piotr Bugalski
Redakcja: Damian Szymański
Testy: Piotr Adamczyk
Powiązane wpisy
eeprom, i2c, interfejs, komunikacja, kursSTM32F1HAL, stm32
Trwa ładowanie komentarzy...