Kurs STM32 LL cz. 23. Komunikacja SPI w trybie przerwań

Przerwania w SPI, podobnie jak w przypadku innych układów peryferyjnych, pozwalają usprawnić działanie programu i obsłużyć wysyłanie danych oraz ich odbieranie w sposób mniej obciążający jednostkę obliczeniową mikrokontrolera, niż było to w przypadku komunikacji w trybie polling. Jak używać przerwań z SPI? O tym w dzisiejszym materiale.

Przerwania w magistrali SPI

Podczas komunikacji SPI przerwanie może być wygenerowane przez następujące zdarzenia:

  • Pusty bufor nadawczy w kolejce TXFIFO
  • Odebrane dane w kolejce RXFIFO
  • Błąd trybu Master
  • Błąd nadpisania bufora (Overrun)
  • Błąd formatu ramki TI
  • Błąd protokołu CRC

Każde z przerwań jest sygnalizowane przez oddzielną flagę w rejestrze SR (Status Register) i włączane za pomocą bitów w rejestrze konfiguracyjnym CR2. Przedstawia to poniższa tabela.

Pamięć Flash SPI W25Q64

Samo włączenie i wyłączenie przerwań wydaje się proste. Przejdźmy zatem do przykładu. Dotychczas przy okazji obsługi wyświetlacza tylko wysyłaliśmy dane. Żeby obsłużyć również odbiór, tym razem wykorzystamy pamięć Flash z interfejsem SPI. 

Na rynku dostępnych jest wiele układów różnych producentów w takiej samej obudowie i o takich samych pojemnościach, które w obsłudze są w zasadzie identyczne – czasami różni się inicjalizacja, ale w większości instrukcje są takie same. Ja będę korzystał z pamięci W25Q64FV firmy Wibond. Moduł użyty w projekcie ma 6 wyprowadzeń. Przedstawia je poniższa grafika i tabela.

Nazwa pinuFunkcja
VCCZasilanie
CSLinia Chip Select interfejsu SPI
DOLinia Data Output interfejsu SPI
GNDMasa
CLKLinia zegarowa interfejsu SPI
DILinia Data Input interfejsu SPI

Sam układ ma ponadto jeszcze dwa piny: WP (Write Protect) oraz HOLD. Są one domyślnie podłączone do linii VCC, co oznacza, że zabezpieczenie przed zapisem oraz funkcja Hold (wstrzymanie operacji) są nieaktywne – możemy swobodnie korzystać z pamięci.

Flash SPI ma organizację wielowarstwową: podzielona jest na strony (256 B), sektory (4 kB) oraz bloki (64 kB). Układ W25Q64FV ma pojemność 64 Mbit, czyli 8 MB – ma 128 bloków, każdy z bloków ma 16 sektorów, które mają po 16 stron.

Opisywane pamięci mają sporo możliwości – pozwalają na blokowanie poszczególnych sektorów czy bloków oraz różne tryby odczytu danych. My skupimy się dzisiaj na podstawowych funkcjach (kasowaniu pamięci, odczycie, zapisie danych).

Zanim przejdziemy do kodu obsługi pamięci, musimy ją podłączyć do Nucleo. Tabela poniżej przedstawia sposób połączenia wyprowadzeń.

W25Q64FVNucleo-G071RB
VCC3.3V
CSPA4
DOPA6
GNDGND
CLKPA1
DIPA7

Piny przedstawione w tabeli znajdziemy na płytce Nucleo w miejscach oznaczonych na grafice.

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

[PROGRAM] Komunikacja SPI w trybie przerwań

Do obsługi pamięci wykorzystam lekko zmodyfikowaną bibliotekę opisaną w artykule “Obsługa pamięci SPI Flash”. Dzisiaj pokrótce przypomnę najważniejsze funkcje. Jeżeli chciałbyś poznać dokładniejszy opis implementacji, odsyłam do artykułu.

Biblioteka składa się z dwóch plików: “W25Q64_flash.c” oraz “W25Q64_flash.h”. Najważniejsze funkcje dostępne dla użytkownika to:

  • W25Q64_Init – inicjalizacja pamięci oraz odczyt jej podstawowych parametrów
  • W25Q64_ReadDataBytes – odczyt danych z pamięci
  • W25Q64_PageProgram – zapis danych w pamięci
  • W25Q64_SectorErase – czyszczenie sektora pamięci
  • W25Q64_ChipErase – czyszczenie całej pamięci

Obsługa pamięci sprowadza się zatem do wywołania funkcji inicjalizującej oraz odczycie lub zapisaniu danych. Przed zapisem należy pamiętać, że pamięć w danym sektorze musi być wyczyszczona.

W25Q64_Init();
W25Q64_SectorErase(0);
W25Q64_PageProgram(0x0000, write_buffer, SIZE_BUFFER);
W25Q64_ReadDataBytes(0x0000, read_buffer, SIZE_BUFFER);

Teraz przejdźmy do kwestii magistrali SPI. W bibliotece umieszczone zostały funkcje odpowiadające za wysyłanie (W25Q64_SPI_Transmit_Data) i odbieranie danych (W25Q64_SPI_Receive_Data) oraz obsługę wyjścia Chip Select (W25Q64_Set_ChipSelect_Low, W25Q64_Set_ChipSelect_High).

W trakcie obsługi pamięci wysyłane są pojedyncze instrukcje i komendy. Odczytywane są również bity statusowe. Te operacje wymagają często odczekania na właściwy stan, aby można było wykonać zapis lub odczyt danych. Realizacja tych funkcjonalności za pomocą przerwań trochę mija się z celem – trwają one stosunkowo krótko. Można oczywiście je obsłużyć w formie przerwań, ale wymagałoby to stworzenia dość rozbudowanej maszyny stanu, a nie na tym chciałbym się dzisiaj skupić. Dlatego operacje dotyczące konfiguracji pozostawimy jako komunikację w trybie polling.

uint8_t W25Q64_SPI_Transmit_Data(uint8_t *data, uint16_t size)
{
	spi_write_data(data, size);


	return 0;
}

uint8_t W25Q64_SPI_Receive_Data(uint8_t *data, uint16_t size)
{


	spi_read_data(data, size);


	return 0;
}

void W25Q64_Set_ChipSelect_Low(void)
{
	spi_cs_set_low();
}

void W25Q64_Set_ChipSelect_High(void)
{
	spi_cs_set_high();
}

Funkcje spi_write_data() oraz spi_read_data() już znamy z poprzedniego rozdziału. Funkcje spi_cs_set_low() i spi_cs_set_high() to nic innego, jak sterowanie pinem CS.

void spi_cs_set_high(void)
{
	LL_GPIO_SetOutputPin(SPI_CS_GPIO_Port, SPI_CS_Pin);
}

void spi_cs_set_low(void)
{
	LL_GPIO_ResetOutputPin(SPI_CS_GPIO_Port, SPI_CS_Pin);
}

Przerwania zastosujemy do najbardziej obciążających CPU operacji, czyli zapisu i odczytu dużej ilości danych w pamięci. Do tego celu dodamy dwie funkcje:

uint8_t W25Q64_SPI_Transmit_Data_IT(uint8_t *data, uint16_t size)
{
	spi_write_data_it(data, size);


	return 0;
}

uint8_t W25Q64_SPI_Receive_Data_IT(uint8_t *data, uint16_t size)
{
	spi_read_data_it(data, size);


	return 0;
}

Umieścimy je w dwóch miejscach zamiast dotychczasowych funkcji w trybie polling, czyli w funkcji W25Q64_ReadDataBytes oraz W25Q64_PageProgram. Dodatkowo dotychczasowa komunikacja w trybie polling powodowała, że linię CS w stan wysoki mogliśmy ustawić zaraz po wywołaniu funkcji odczytu lub zapisu. Było to tożsame z zakończeniem komunikacji. W trybie przerwań stan wysoki na linii CS ustawimy dopiero po zakończeniu transmisji w obsłudze przerwania. Funkcje do odczytu i zapisu w pamięci będą wyglądały jak poniżej.

uint8_t W25Q64_ReadDataBytes(uint32_t adress, uint8_t *data, uint16_t size)
{
	uint8_t data_to_send[] = { 0, 0, 0, 0 };
	uint8_t status;

	W25Q64_WaitForWriteInProgressClear();

	data_to_send[0] = READ_DATA;
	data_to_send[1] = (adress >> 16) & 0xff;
	data_to_send[2] = (adress >> 8) & 0xff;
	data_to_send[3] = adress & 0xff;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(data_to_send, 4);
	status = W25Q64_SPI_Receive_Data_IT(data, size);
	//W25Q64_Set_ChipSelect_High(); ->przeniesione do obsługi przerwania

	return status;
}

uint8_t W25Q64_PageProgram(uint32_t page_adress, uint8_t *data, uint16_t size)
{
	uint8_t data_to_send[] = { 0, 0, 0, 0 };
	uint8_t status;

	W25Q64_WaitForWriteInProgressClear();
	W25Q64_WriteEnable_and_WaitForWriteEnableLatch();

	data_to_send[0] = PAGE_PROGRAM;
	data_to_send[1] = (page_adress >> 16) & 0xff;
	data_to_send[2] = (page_adress >> 8) & 0xff;
	data_to_send[3] = page_adress & 0xff;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(data_to_send, 4);
	status = W25Q64_SPI_Transmit_Data_IT(data, size);
	//W25Q64_Set_ChipSelect_High(); ->przeniesione do obsługi przerwania

	return status;
}

Jak zatem będzie wyglądała inicjalizacja i obsługa przerwań. Przyjrzyjmy się funkcjom spi_write_data_it() oraz spi_read_data_it().

void spi_write_data_it(uint8_t *data, uint32_t size)
{
	tx_buffer.data_ptr = data;
	tx_buffer.count = size;

	LL_SPI_EnableIT_TXE(spi);
	LL_SPI_Enable(spi);
}

void spi_read_data_it(uint8_t *data, uint32_t size)
{
	tx_buffer.count = size;

	rx_buffer.data_ptr = data;
	rx_buffer.count = size;

	LL_SPI_EnableIT_TXE(spi);
	LL_SPI_EnableIT_RXNE(spi);
	LL_SPI_Enable(spi);
}

Obsługa sprowadza się zatem do przypisania danych do buforów (znanych już z komunikacji I2C) oraz włączeniu przerwania i magistrali SPI. Warto zauważyć, że w przypadku odbioru danych musimy obsługiwać zarówno wysyłanie danych, jak i odbieranie – wiąże się to ze specyfiką działania SPI, w której dane są zawsze jednocześnie wysyłane i odbierane synchronicznie. Jeżeli urządzenie Master chce odebrać dane, jednocześnie musi wysłać cokolwiek w stronę Slave.

Teraz przejdziemy do obsługi przerwania. W funkcji SPI1_IRQHandler() umieszczamy sprawdzenie flagi oraz wywołanie odpowiedniej funkcji.

void SPI1_IRQHandler(void)
{
	if(LL_SPI_IsActiveFlag_TXE(SPI1) && LL_SPI_IsEnabledIT_TXE(SPI1))
	{
		spi_it_transmit_callback();
	}

	if(LL_SPI_IsActiveFlag_RXNE(SPI1) && LL_SPI_IsEnabledIT_RXNE(SPI1))
	{
		spi_it_receive_callback();
	}
}

W funkcji spi_it_transmit_callback() umieściłem obsługę przerwania od bustego bufora nadawczego. Omówimy teraz jak ona wygląda. 

W przypadku, gdy mamy do wysłania dane, sprawdzamy, czy jesteśmy w trybie nadawania czy odbierania. Jeżeli tylko nadajemy dane, wpisujemy kolejny bajt do rejestru SPI i zmniejszamy licznik oraz zwiększamy wskaźnik danych. Jeżeli aktywny jest tryb odbierania, wysyłamy tzw. DUMMY BYTE (czyli cokolwiek) znany z poprzedniego rozdziału.

if(tx_buffer.count > 0)
{
	if(!LL_SPI_IsEnabledIT_RXNE(spi))
	{
		LL_SPI_TransmitData8(spi, *tx_buffer.data_ptr);


		tx_buffer.data_ptr++;
		tx_buffer.count--;
	}
	else
	{
		LL_SPI_TransmitData8(spi, DUMMY_BYTE);
		tx_buffer.count--;
	}
}

Jeżeli licznik wysłanych danych spadł do zera, musimy zakończyć transmisję. Sekwencję wykonujemy tylko w przypadku, gdy jest nieaktywny tryb odbierania. Po odczekaniu na flagę BUSY i wyczyszczeniu kolejek ustawiamy linię CS w stan wysoki sygnalizując zakończenie komunikacji z danych urządzeniem. Na koniec wywołujemy callback informujący aplikację o zakończeniu transferu.

if(tx_buffer.count <= 0 && !LL_SPI_IsEnabledIT_RXNE(spi))
{
	LL_SPI_DisableIT_TXE(spi);

	while (LL_SPI_GetTxFIFOLevel(spi) != LL_SPI_TX_FIFO_EMPTY)
		;

	while (LL_SPI_IsActiveFlag_BSY(spi) != 0)
		;

	LL_SPI_Disable(spi);

	while (LL_SPI_GetRxFIFOLevel(spi) != LL_SPI_RX_FIFO_EMPTY)
	{
		LL_SPI_ReceiveData8(spi);
	}

	LL_SPI_ClearFlag_OVR(spi);

	spi_cs_set_high();
	spi_transfer_cplt_callback(TRANSMIT);
}

Podobnie postępujemy w przypadku funkcji spi_it_receive_callback(). Jeżeli mamy jeszcze dane do odebrania, odczytujemy je z rejestru SPI. Jeżeli licznik danych spadł do zera, kończymy komunikację.

if(rx_buffer.count > 0)
{
	*rx_buffer.data_ptr = LL_SPI_ReceiveData8(spi);

	rx_buffer.data_ptr++;
	rx_buffer.count--;
}

if(rx_buffer.count <= 0)
{
	LL_SPI_DisableIT_RXNE(spi);
	LL_SPI_DisableIT_TXE(spi);

	while (LL_SPI_GetTxFIFOLevel(spi) != LL_SPI_TX_FIFO_EMPTY)
		;

	while (LL_SPI_IsActiveFlag_BSY(spi) != 0)
		;

	LL_SPI_Disable(spi);

	while (LL_SPI_GetRxFIFOLevel(spi) != LL_SPI_RX_FIFO_EMPTY)
	{
		LL_SPI_ReceiveData8(spi);
	}

	LL_SPI_ClearFlag_OVR(spi);

	spi_cs_set_high();
	spi_transfer_cplt_callback(RECEIVE);
}

W funkcjach spi_write_data_it() oraz spi_read_data_it() włączyliśmy przerwania od SPI. W funkcji inicjalizacyjnej poza znaną już konfiguracja musimy pamiętać jeszcze o włączeniu kontrolera NVIC.

NVIC_SetPriority(SPI1_IRQn, 1);
NVIC_EnableIRQ(SPI1_IRQn);

Czas przetestować działanie komunikacji.

Dodamy trzy bufory – dwa do przechowywania odebranych danych oraz jeden do wysyłanych.

#define SIZE_BUFFER	256

uint8_t read_buffer_1[SIZE_BUFFER];
uint8_t read_buffer_2[SIZE_BUFFER];
uint8_t write_buffer[SIZE_BUFFER]

Bufor nadawczy wypełnimy kolejnymi liczbami naturalnymi.

for (uint32_t i = 0; i < SIZE_BUFFER; i++) 
{
	write_buffer[i] = i;
}

Inicjalizujemy interfejs SPI oraz pamięć W25Q64.

spi_init();
W25Q64_Init();

Czyścimy zerowy sektor, do którego zapiszemy dane.

W25Q64_SectorErase(0);

Teraz przy pomocy prostej maszyny stanów odczytamy dane po wyczyszczeniu (powinny być w niej same 0xFF). Następnie zapiszemy dane i odczytamy je ponownie. Porównamy dwa bufory z danymi przez zapisem i po zapisie.

while (1)
{
	if(state == READ_1)
	{
		state = WAIT;
		W25Q64_ReadDataBytes(0x0000, read_buffer_1, SIZE_BUFFER);
	}
	else if(state == WRITE)
	{
		state = WAIT;
		W25Q64_PageProgram(0x0000, write_buffer, SIZE_BUFFER);
	}
	else if(state == READ_2)
	{
		state = STOP;
		W25Q64_ReadDataBytes(0x0000, read_buffer_2, SIZE_BUFFER);
	}
}

Definicje stanów umieściłem w enumie.

typedef enum
{
	READ_1 = 0,
	WRITE,
	READ_2,
	WAIT,
	STOP
}state_t;

Zmiana stanu będzie odbywała się w callbacku od zakończenia transmisji spi.

void spi_transfer_cplt_callback(transfer_type_t type)
{
	if(type == TRANSMIT && state == WAIT)
	{
		state = READ_2;
	}
	else if(type == RECEIVE && state == WAIT)
	{
		state = WRITE;
	}
}

Teraz możemy wgrać i uruchomić program w trybie Debug. Po wykonaniu wszystkich operacji (czas trwania nie powinien być dłuższy niż kilka milisekund), możemy zobaczyć efekt w postaci danych dwóch buforów odbiorczych. Bufor pierwszy będzie zawierał wyczyszczoną pamięć (0xFF), a bufor drugi dane zapisane do pamięci.

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 *