Kurs STM32 LL cz. 20. Komunikacja I2C w trybie DMA

Komunikacja z pamięcią EEPROM w przypadku dużych ilości danych może być obciążająca dla jednostki CPU. Warto wtedy skorzystać z kontrolera DMA i bezpośredniego transferu danych.

Kontroler DMA

Direct Memory Access (DMA), czyli mechanizm bezpośredniego dostępu do pamięci to technika przesyłania danych z pominięciem jednostki CPU.

DMA pozwala na przesyłanie danych:

  • z pamięci do pamięci
  • z pamięci do układu peryferyjnego
  • z układu peryferyjnego do pamięci
  • z układu peryferyjnego do układu peryferyjnego

W mikrokontrolerach STM32 możemy mieć do dyspozycji jeden lub dwa kontrolery DMA. Każdy kontroler ma kilka kanałów, a każdy kanał może obsługiwać różne rodzaje transferów. W STM32G071 dostępny jest jeden kontroler DMA1 z 7 kanałami.

To, jak zbudowany jest przepływ danych w STM32, przedstawia poniższa grafika.

Kontroler DMA podłączony jest do szyny AHB (Advanced High-Performance Bus), która komunikuje go z pamięcią SRAM  i Flash oraz za pośrednictwem szyny APB (Advanced Peripheral Bus) z układami peryferyjnymi. Układem nadzorującym współpracę między szynami jest Bus Matrix.

W przypadku, gdy dwa kanały DMA chcą wykonywać transfer jednocześnie, Arbiter rozdziela czas dostępu do Bus Matrix dla każdego kanału.

Gdy dostępne są dwa oddzielne kontrolery DMA, mogą one korzystać z Bux Matrix jednocześnie, dopóki nie korzystają z tych samych układów peryferyjnych.

Szyna AHB implementuje algorytm karuzelowy (round robin), według którego przydziela dla każdego z układów przedział czasowy na wykonanie operacji, nie uwzględniając żadnych priorytetów. Kontroler DMA może więc spowolnić pracę CPU, jeżeli oba elementy potrzebują dostępu do tego samego obszaru pamięci lub układu peryferyjnego (zdarza się to bardzo rzadko). Nie może jednak zablokować CPU, ponieważ arbiter rozdziela po równo cykle pracy pomiędzy poszczególne elementy mikrokontrolera.

W starszych mikrokontrolerach (seria F i L) kontroler DMA podzielony jest na kanały. Do każdego z kanałów dołączonych jest kilka układów peryferyjnych i tylko jeden z nich może być jednocześnie obsługiwany jest dany kanał DMA.

W nowszych układach (seria G) do każdego z kanałów można podpiąć dowolny układ peryferyjny z dostępnych. Za zarządzanie odpowiada DMAMUX. DMAMUX pozwala także na użycie generatora DMA do układów peryferyjnych, które nie mają funkcji DMA.

DMA generuje trzy rodzaje przerwań:

  • po wykonaniu połowy transferu
  • po wykonaniu całego transferu
  • w przypadku błędu

DMA może przesyłać dane po 8, 16 lub 32 bity (odpowiednio Byte, Half Word i Word). Szerokość danych zależna jest od rodzaju wysyłanych przez nas danych (po stronie pamięci) oraz od rozmiaru rejestru. Dodatkowo przed rozpoczęciem transferu DMA ustalany jest rozmiar, czyli ilość danych do przesłania.

Każdy transfer DMA może mieć przypisany jeden z 4 priorytetów, według których kontroler wykonuje transfery w przypadku, gdy jednocześnie wystąpi potrzeba obsługi dwóch lub więcej kanałów.

  • very high
  • high
  • medium
  • low

Poza tym kontroler DMA ma możliwość automatycznej inkrementacji adresów po stronie układu peryferyjnego i pamięci oraz możliwość cyklicznego transferu (po zakończeniu jednego rozpoczyna się kolejny od początku skonfigurowanego adresu – na zasadzie bufora kołowego). Pozwala to dobrać sposób przesyłania danych według potrzeb.

Rejestry DMA

Rejestr DMA_ISR – rejestr z flagami przerwań dla każdego z 7 kanałów

Rejestr DMA_IFCR – czyszczenie flag przerwań

DMA_CCRx – rejestr konfiguracyjny dla kanału x

Bity MEM2MEM – włączenie transferu pamięć-pamięć

Bit PL[1:0] – wybór priorytetu

Bity MSIZE[1:0] – rozmiar danych po stronie pamięci

Bity PSIZE[1:0] – rozmiar danych po stronie układu peryferyjnego

Bit MINC – włączenie zwiększania adresu po stronie pamięci

Bit PINC – włączenie zwiększania adresu po stronie układu peryferyjnego

Bit CIRC – tryb kołowy

Bit DIR – kierunek transferu

Bit TEIE – włączenie przerwania od błędu

Bit HTIE – włączenie przerwania od połowy transferu

Bit TCIE – włączenie przerwania od pełnego transferu

Bit EN – włączenie kanału

Rejestr DMA_CNDTRx – rozmiar transferu dla kanału x

DMA_CPARx – adres układu peryferyjnego dla kanału x

DMA_CMARx – adres układu pamięci dla kanału x

Multiplekser DMA

W układach z serii G do każdego z kanałów można podpiąć dowolny układ peryferyjny z puli dostępnych. Za zarządzanie przepływem danych odpowiada multiplekser, czyli DMAMUX.

DMAMUX ma za zadanie przekierować na wybrany kanał sygnał z układu peryferyjnego, którego chcemy użyć. Dzięki temu mamy dużą elastyczność pod względem używania DMA. Nie pojawi się problem często występujący w poprzednich seriach STM32 (np. F1), gdzie nie mogliśmy użyć dwóch peryferiów w trybie DMA, bo były podłączone do tego samego kanału.

W STM32G071 mamy do dyspozycji 77 wejść mogących korzystać z kontrolera DMA. Pełną listę przedstawia tabela.

Rodzaj wejścia dla kanału x konfigurujemy przy pomocy rejestru DMAMUX_CxCR i bitów DMAREQ_ID[6:0].

DMA w magistrali I2C

DMA (Direct Memory Access) może być włączone do transmisji poprzez ustawienie bitu TXDMAEN w rejestrze I2C_CR1. Dane są ładowane z obszaru SRAM skonfigurowanego za pomocą peryferii DMA do rejestru I2C_TXDR, gdy ustawiony jest bit TXIS.

Analogicznie jest w przypadku odbierania. DMA można włączyć do odbioru przez ustawienie bitu RXDMAEN w rejestrze I2C_CR1. Dane są ładowane z rejestru I2C_RXDR do obszaru pamięci SRAM skonfigurowanego za pomocą peryferii DMA, gdy ustawiony jest bit RXNE.

Tak jak w przypadku przerwań, inicjalizacja, adres slave, kierunek, ilość bajtów i bit START są programowane ręcznie (adres slave nie może być przesyłany za pomocą DMA). Tylko dane są przesyłane za pomocą DMA.

Wszystkie projekty z kursu dostępne są w moim repozytorium GitHub.

[PROGRAM] Zapis danych do pamięci EEPROM w trybie DMA

Korzystając z DMA możemy jeszcze bardziej zautomatyzować przesyłanie danych do pamięci EEPROM. Dzisiaj pokaże Ci, jak to skonfigurować.

Inicjalizacja I2C będzie wyglądała bardzo podobnie jak dotychczas. Konfigurujemy piny SDA i SCL w trybie funkcji alternatywnej jako wyjścia Open-Drain.

LL_IOP_GRP1_EnableClock(LL_IOP_GRP1_PERIPH_GPIOB);

LL_GPIO_SetPinSpeed(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_SPEED_FREQ_LOW);
LL_GPIO_SetPinOutputType(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinPull(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_PULL_NO);
LL_GPIO_SetAFPin_8_15(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_AF_6);
LL_GPIO_SetPinMode(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_MODE_ALTERNATE);

LL_GPIO_SetPinOutputType(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinPull(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_PULL_NO);
LL_GPIO_SetPinSpeed(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_SPEED_FREQ_VERY_HIGH);
LL_GPIO_SetAFPin_8_15(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_AF_6);
LL_GPIO_SetPinMode(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_MODE_ALTERNATE);

Następnie włączamy taktowanie I2C.

LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_I2C1);

Analogicznie jak w poprzedniej części, konfigurujemy filtry, rejestr TIMINGR, oraz tryb pracy I2C przy wyłączonym peryferium. Następnie włączamy I2C.

LL_I2C_Disable(i2c);
LL_I2C_ConfigFilters(i2c, LL_I2C_ANALOGFILTER_ENABLE, 0x00);
LL_I2C_SetTiming(i2c, 0x10707DBC);
LL_I2C_EnableClockStretching(i2c);
LL_I2C_SetMode(i2c, LL_I2C_MODE_I2C);
LL_I2C_Enable(i2c);

Teraz przechodzimy do konfiguracji DMA. Włączamy taktowanie dla kontrolera.

LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1);

Następnie konfigurujemy parametry transferu czyli peryferium I2C1_TX. Wybieramy kierunek transmisji z pamięci do układu peryferyjnego oraz priorytet. Ustawiamy również tryb jako normalny (nie cykliczny) oraz konfigurujemy automatyczną inkrementację adresów dla pamięci. Korzystamy z wartości o szerokości bajta, dlatego wybieramy wyrównanie DATAALIGN_BYTE zarówno po stronie pamięci, jak i rejestru I2C.

LL_DMA_SetPeriphRequest(DMA1, LL_DMA_CHANNEL_1, LL_DMAMUX_REQ_I2C1_TX);
LL_DMA_SetDataTransferDirection(DMA1, LL_DMA_CHANNEL_1, LL_DMA_DIRECTION_MEMORY_TO_PERIPH);
LL_DMA_SetChannelPriorityLevel(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PRIORITY_HIGH);
LL_DMA_SetMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MODE_NORMAL);
LL_DMA_SetPeriphIncMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PERIPH_NOINCREMENT);
LL_DMA_SetMemoryIncMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MEMORY_INCREMENT);
LL_DMA_SetPeriphSize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PDATAALIGN_BYTE);
LL_DMA_SetMemorySize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MDATAALIGN_BYTE);

Teraz włączamy przerwania od zakończenia transmisji DMA.

NVIC_SetPriority(DMA1_Channel1_IRQn, 0);
NVIC_EnableIRQ(DMA1_Channel1_IRQn);
LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_1);

Wysyłanie rozpoczynamy od “ręcznego” ustawienia adresu i wygenerowania START. 

LL_I2C_HandleTransfer(i2c, slave_addr, LL_I2C_ADDRSLAVE_7BIT, (uint32_t)(size+1), LL_I2C_MODE_AUTOEND, LL_I2C_GENERATE_START_WRITE);

Następnie wysyłamy jeden bajt z adresem komórki pamięci EEPROM.

while(LL_I2C_IsActiveFlag_TXIS(i2c) == 0)
	;
LL_I2C_TransmitData8(i2c, (uint8_t)reg_addr);

Teraz konfigurujemy resztę wysyłania przy pomocy DMA. Ustawimy adres danych do wysłania oraz adres rejestru I2C1_TX. Wybieramy również kierunek transmisji.

LL_DMA_ConfigAddresses(DMA1, LL_DMA_CHANNEL_1, (uint32_t)tx_buffer.data_ptr, LL_I2C_DMA_GetRegAddr(I2C1, LL_I2C_DMA_REG_DATA_TRANSMIT), LL_DMA_GetDataTransferDirection(DMA1, LL_DMA_CHANNEL_1));

Następnie podajemy ilość danych do wysłania przez DMA.

LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, tx_buffer.count);

Teraz włączamy DMA w rejestrze I2C oraz włączamy kanał 1 w kontrolerze DMA.

LL_I2C_EnableDMAReq_TX(I2C1);
LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1);

Po zakończeniu transferu otrzymamy przerwanie od Transfer Complete DMA1, w którym możemy wyczyścić flagę STOP.

void DMA_Channel1_IRQHandler(void)
{
	if (LL_DMA_IsActiveFlag_TC1(DMA1))
	{
		LL_DMA_ClearFlag_TC1(DMA1);
		LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_1);
		LL_I2C_ClearFlag_STOP(i2c);
	}
}

Aby zapisać dane do pamięci EEPROM, wystarczy wywołać funkcję.

i2c_reg_write_dma(EEPROM_ADDR, 0x00, data_w, sizeof(data_w));

[PROGRAM] Odczyt danych z pamięci EEPROM w trybie DMA

Inicjalizacja I2C w przypadku odbioru będzie wyglądała bardzo podobnie jak dla wysyłania danych. Konfigurujemy piny SDA i SCL w trybie funkcji alternatywnej jako wyjścia Open-Drain.

LL_IOP_GRP1_EnableClock(LL_IOP_GRP1_PERIPH_GPIOB);

LL_GPIO_SetPinSpeed(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_SPEED_FREQ_LOW);
LL_GPIO_SetPinOutputType(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinPull(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_PULL_NO);
LL_GPIO_SetAFPin_8_15(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_AF_6);
LL_GPIO_SetPinMode(I2C1_SCL_GPIO_Port, I2C1_SCL_Pin, LL_GPIO_MODE_ALTERNATE);

LL_GPIO_SetPinOutputType(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinPull(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_PULL_NO);
LL_GPIO_SetPinSpeed(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_SPEED_FREQ_VERY_HIGH);
LL_GPIO_SetAFPin_8_15(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_AF_6);
LL_GPIO_SetPinMode(I2C1_SDA_GPIO_Port, I2C1_SDA_Pin, LL_GPIO_MODE_ALTERNATE);

Następnie włączamy taktowanie I2C.

LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_I2C1);

Analogicznie jak w poprzedniej części, konfigurujemy filtry, rejestr TIMINGR, oraz tryb pracy I2C przy wyłączonym peryferium. Następnie włączamy I2C.

LL_I2C_Disable(i2c);
LL_I2C_ConfigFilters(i2c, LL_I2C_ANALOGFILTER_ENABLE, 0x00);
LL_I2C_SetTiming(i2c, 0x10707DBC);
LL_I2C_EnableClockStretching(i2c);
LL_I2C_SetMode(i2c, LL_I2C_MODE_I2C);
LL_I2C_Enable(i2c);

Teraz przechodzimy do konfiguracji DMA. Włączamy taktowanie dla kontrolera.

LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1);

Następnie konfigurujemy parametry transferu czyli peryferium I2C1_RX. Wybieramy kierunek transmisji z układu peryferyjnego do pamięci oraz priorytet. Ustawiamy również tryb jako normalny (nie cykliczny) oraz konfigurujemy automatyczną inkrementację adresów dla pamięci. Korzystamy z wartości o szerokości bajta, dlatego wybieramy wyrównanie DATAALIGN_BYTE zarówno po stronie pamięci, jak i rejestru I2C.

LL_DMA_SetPeriphRequest(DMA1, LL_DMA_CHANNEL_1, LL_DMAMUX_REQ_I2C1_RX);
LL_DMA_SetDataTransferDirection(DMA1, LL_DMA_CHANNEL_1, LL_DMA_DIRECTION_PERIPH_TO_MEMORY);
LL_DMA_SetChannelPriorityLevel(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PRIORITY_HIGH);
LL_DMA_SetMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MODE_NORMAL);
LL_DMA_SetPeriphIncMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PERIPH_NOINCREMENT);
LL_DMA_SetMemoryIncMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MEMORY_INCREMENT);
LL_DMA_SetPeriphSize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PDATAALIGN_BYTE);
LL_DMA_SetMemorySize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MDATAALIGN_BYTE);

Teraz włączamy przerwania od zakończenia transferu DMA.

NVIC_SetPriority(DMA1_Channel1_IRQn, 0);
NVIC_EnableIRQ(DMA1_Channel1_IRQn);
LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_1);

Wysyłanie rozpoczynamy od “ręcznego” ustawienia adresu i wygenerowania START. 

LL_I2C_HandleTransfer(i2c, slave_addr, LL_I2C_ADDRSLAVE_7BIT, 1, LL_I2C_MODE_SOFTEND, LL_I2C_GENERATE_START_WRITE);

Następnie wysyłamy jeden bajt z adresem komórki pamięci EEPROM i czekamy na koniec transmisji.

while(LL_I2C_IsActiveFlag_TXIS(i2c) == 0)
	;
LL_I2C_TransmitData8(i2c, (uint8_t)reg_addr);
while(LL_I2C_IsActiveFlag_TC(i2c) == 0)
	;

Teraz konfigurujemy odbieranie danych.

LL_I2C_HandleTransfer(i2c, slave_addr, LL_I2C_ADDRSLAVE_7BIT, (uint32_t)size, LL_I2C_MODE_AUTOEND, LL_I2C_GENERATE_START_READ);

Dane przeniesiemy z rejestru I2C do pamięci przy pomocy DMA. Ustawimy adres danych odebranych oraz adres rejestru I2C1_RX. Wybieramy również kierunek transmisji.

LL_DMA_ConfigAddresses(DMA1, LL_DMA_CHANNEL_1, LL_I2C_DMA_GetRegAddr(I2C1, LL_I2C_DMA_REG_DATA_RECEIVE), (uint32_t)rx_buffer.data_ptr, LL_DMA_GetDataTransferDirection(DMA1, LL_DMA_CHANNEL_1));

Następnie podajemy ilość danych do odebrania przez DMA.

LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, rx_buffer.count);

Teraz włączamy DMA w rejestrze I2C oraz włączamy kanał 1 w kontrolerze DMA.

LL_I2C_EnableDMAReq_RX(I2C1);
LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1);

Po zakończeniu transferu otrzymamy przerwanie od Transfer Complete DMA1, w którym możemy wyczyścić flagę STOP.

void DMA_Channel1_IRQHandler(void)
{
	if (LL_DMA_IsActiveFlag_TC1(DMA1))
	{
		LL_DMA_ClearFlag_TC1(DMA1);
		LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_1);
		LL_I2C_ClearFlag_STOP(i2c);
	}
}

Teraz wystarczy wywołać funkcję odczytu danych z pamięci.

i2c_reg_read_dma(EEPROM_ADDR, 0x00, data_r, sizeof(data_r));

Chciałbyś otrzymywać na bieżąco informacje o nowych artykułach z kursu? Zapisz się do newslettera!

TO NIE TYLKO MAIL Z INFORMACJĄ O NOWEJ LEKCJI, ALE TAKŻE DODATKOWE MATERIAŁY. NIE PRZEGAP NOWEJ TREŚCI I DODATKOWYCH BONUSÓW. PRZEJDŹ DO STRONY KURSU I PODAJ SWÓJ ADRES E-MAIL. NIE ZAPOMNIJ POTWIERDZIĆ CHĘCI DOŁĄCZENIA W PIERWSZEJ WIADOMOŚCI!
Repozytorium GitHub

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *