Obsługa pamięci SPI Flash

Projekty tworzone w oparciu o mikrokontrolery czasami wymagają dodatkowej pamięci. I nie koniecznie chodzi o kod programu – tutaj zazwyczaj nie brakuje miejsca. Co jednak, jeśli np. obsługujemy wyświetlacz i chcemy przechowywać grafiki, albo urządzenie będzie odtwarzać dźwięki? Wówczas sięgamy po zewnętrzne pamięci, np. popularne i tanie układy SPI Flash.

Problem pojawia się, kiedy chcemy wgrać dane na taką pamięć. Istnieją uniwersalne programatory, które pozwalają nam zaprogramować pamięć Flash za pomocą komputera PC i interfejsu USB. Są idealnym rozwiązaniem, jeśli mamy już gotowe urządzenie i chcemy raz zaprogramować układ, a następnie wlutować go na płytce. Tworząc prototyp potrzebujemy jednak czasami zmienić zawartość pamięci, co wiązałoby się z wylutowywaniem i wlutowywaniem układu, albo tworzeniem dodatkowych adapterów. Jeżeli w projekcie pamięć podłączona jest do mikrokontrolera STM32, jest jednak możliwość wygodnego jej programowania bez dodatkowych narzędzi. Wykorzystajmy ST-Link-a i specjalny wsad, czyli External Loader.

Ten materiał jest pierwszą częścią artykułu o External Loader. Przedstawię w nim obsługę pamięci Flash za pomocą interfejsu SPI. W następnej części pokażę, jak stworzyć plik .stldr i zaprogramować pamięć za pomocą STM32CubeProgrammera.

Pamięć Flash SPI W25Q64FV

Do obsługi pamięci zewnętrznych Flash często wykorzystywany jest (np. w przypadku zestawów Nucleo czy Discovery) interfejs QSPI. Jest znacznie lepszym rozwiązaniem niż standardowe SPI, ponieważ umożliwia szybsze przesyłanie danych oraz możliwość zmapowania dodatkowej pamięci jako części pamięci STM32. Dzięki temu mamy wygodny dostęp do pamięci jak do zwykłego Flash-a mikrokontrolera. Jednak nie wszystkie STM-y mają interfejs QSPI. Korzystając z mniejszych układów musimy zadowolić się komunikacją po SPI.

W przykładzie przedstawionym w artykule wykorzystam pamięć W25Q64FV firmy Wibond. Przede wszystkim ze względu na to, że znalazłem moduł z wlutowaną pamięcią, więc nie trzeba tworzyć adaptera z SOIC8 na raster 2,54 mm, żeby wygodnie podłączyć pamięć do zestawu Nucleo. Na rynku dostępnych jest jednak wiele układów różnych producentów w takiej samej obudowie i 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. Dlatego z łatwością można przenieść poniższy przykład na układ innego producenta.

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 jednak skupimy się dzisiaj na podstawowych funkcjach (kasowaniu pamięci, odczycie, zapisie danych), czyli elementach niezbędnych do implementacji External Loadera. Jeżeli interesują Cię szczegółowe informacje o innych trybach lub jakieś specyficzne zastosowanie, odsyłam do dokumentacji układu.

Konfiguracja mikrokontrolera

Znając podstawowe informacje na temat pamięci SPI Flash możemy przejść do skonfigurowania układów peryferyjnych mikrokontrolera. W zasadzie potrzebny nam będzie tylko interfejs SPI oraz wyjście GPIO do sterowania linią CS. Tworzymy zatem nowy projekt dla Nucleo-L476RG w konfiguracji domyślnej. Wybieramy magistralę SPI2 i pin PB0 jaki wyjście IO.

SPI2 będziemy używali w trybie Full-Duplex Master. Jako format ramki wybieramy „Motorolaz 8-bitowym rozmiarem danych i opcją MSB First. Zegar możemy ustawić na 10 MBits/s, czyli z preskalerem 8, CPOL jako „Low” oraz fazę zegara (CPHA) „1 Edge”. Ze względu na specyfikę działania wsadu ładującego (będziemy musieli oczekiwać na zapis jednej porcji danych, aby odebrać następną), nie będziemy korzystali z przerwań czy DMA, a z podstawowego trybu Pooling. W końcu nasz program i tak nie będzie robił nic innego, jak zajmował się pamięcią Flash. Pełna konfiguracja SPI przedstawiona został poniżej.

Konfiguracja pinów będzie wyglądała jak na poniższej grafice. Warto zauważyć, że dodałem etykietę dla pinu do sterowania linią CS, jako Chip_Select.

Mając tak skonfigurowany mikrokontroler, możemy wygenerować projekt (skrót Alt+K) i przejść do pisania programu.

Implementacja

Każda pamięć Flash SPI ma zakodowany ID producenta, typ układu oraz pojemność, dzięki czemu możemy tworzyć w miarę uniwersalne biblioteki i wywołać odpowiednie funkcję inicjalizacyjne, jeżeli wykryjemy konkretny układ. Na początku odczytamy dane identyfikacyjne i sprawdzimy, czy rzeczywiście komunikujemy się z pamięcią W25Q64 firmy Wibond. Przy okazji przetestujemy, czy komunikacja z układem jest prawidłowa. W tym celu w pliku „W25Q64_Flash.h” tworzymy strukturę memory_info. Dodatkowo umieszczamy stałe informujące o rozmiarze strony i sektora.

#define PAGE_SIZE				256
#define SECTOR_SIZE				4096

struct memory_info
{
	uint8_t manufacturer_ID;
	uint8_t memory_type;
	uint8_t capacity;
};

W pliku „W25Q64_Flash.c” dodajemy stałe z wartościami bitów z rejestru statusu (BUSY i WEL), instrukcjami do obsługi pamięci oraz stałymi producenta i typu pamięci dla W25Q64FV.

#define W25Q64_WRITE_IN_PROGRESS  	        0x01
#define W25Q64_WRITE_ENABLE_LATCH 	        0x02

#define WRITE_ENABLE				0x06
#define WRITE_DISABLE				0x04
#define READ_STATUS_REG1			0x05
#define READ_STATUS_REG2			0x35
#define WRITE_STATUS_REG			0x01
#define READ_DATA				0x03
#define PAGE_PROGRAM				0x02
#define SECTOR_ERASE				0x20
#define CHIP_ERASE				0xC7
#define ENABLE_RESET				0x66
#define RESET					0x99

#define	WIBOND_ID				0xEF
#define SPI_DEVICE_ID				0x40
#define CAPACITY_64_MBIT			0x17

Na początku dodamy cztery funkcje pomocnicze oddzielające nam warstwę sprzętową SPI od warstwy obsługi pamięci. Pozwoli to na łatwą modyfikację kodu (np. zmianę wykorzystywanego interfejsu SPI) poprzez zmianę dwóch linii kodu, a nie kilkunastu czy kilkudziesięciu.

HAL_StatusTypeDef W25Q64_SPI_Transmit_Data(uint8_t *data, uint16_t size)
{
	HAL_StatusTypeDef status;

	status = HAL_SPI_Transmit(&hspi2, data, size, 1000);

	return status;
}

HAL_StatusTypeDef W25Q64_SPI_Receive_Data(uint8_t *data, uint16_t size)
{
	HAL_StatusTypeDef status;

	status = HAL_SPI_Receive(&hspi2, data, size, 1000);

	return status;
}

void W25Q64_Set_ChipSelect_Low(void)
{
	HAL_GPIO_WritePin(Chip_Select_GPIO_Port, Chip_Select_Pin, GPIO_PIN_RESET);
}

void W25Q64_Set_ChipSelect_High(void)
{
	HAL_GPIO_WritePin(Chip_Select_GPIO_Port, Chip_Select_Pin, GPIO_PIN_SET);
}

Następnie implementujemy funkcję inicjalizacyjną, w której resetujemy układ po inicjalizacji SPI oraz odczytujemy dane identyfikacyjne.

uint8_t W25Q64_Init(void)
{
	struct memory_info w25q64_memory;

	W25Q64_ResetFlash();
	W25Q64_get_JEDEC_ID(&w25q64_memory);

	if(WIBOND_ID == w25q64_memory.manufacturer_ID && SPI_DEVICE_ID == w25q64_memory.memory_type && CAPACITY_64_MBIT == w25q64_memory.capacity)
		return HAL_OK;
	else
		return HAL_ERROR;
}

void W25Q64_ResetFlash(void)
{
	uint8_t data_to_send[] = { ENABLE_RESET, RESET };

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(data_to_send, 1);
	W25Q64_Set_ChipSelect_High();

        W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(&data_to_send[1], 1);
	W25Q64_Set_ChipSelect_High();
}

void W25Q64_get_JEDEC_ID(struct memory_info *info)
{
	uint8_t data_to_send = 0x9F;
	uint8_t receive_data[3] = { 0, 0, 0 };

	W25Q64_Set_ChipSelect_Low();

	W25Q64_SPI_Transmit_Data(&data_to_send, 1);
	W25Q64_SPI_Receive_Data(receive_data, 3);

	W25Q64_Set_ChipSelect_High();

	info->manufacturer_ID = receive_data[0];
	info->memory_type = receive_data[1];
	info->capacity = receive_data[2];
}

Teraz możemy przejść do implementacji kasowania pamięci, zapisu i odczytu danych. Najpierw musimy stworzyć funkcje pomocnicze do odblokowania zapisu, oczekiwania na zakończenie wykonywanej operacji (bit BUSY w rejestrze STATUS) oraz ustawienie bitu WEL.

Jak możemy przeczytać w dokumentacji, wywołanie instrukcji Write Enable jest konieczne przed każdą operacją kasowania, zapisu czy odczytu. Następnie musimy poczekać na odblokowanie, czyli ustawienie bitu WEL (Write Enable Latch). Dodatkowo przed każdą operacją musimy sprawdzić, czy poprzednia została już zakończona i możemy wysłać nową instrukcję.

void W25Q64_WriteEnable(void)
{
	uint8_t data_to_send =  WRITE_ENABLE;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(&data_to_send, 1);
	W25Q64_Set_ChipSelect_High();
}

void W25Q64_WriteEnable_and_WaitForWriteEnableLatch(void)
{
	while(!(W25Q64_ReadStatusRegister1() & W25Q64_WRITE_ENABLE_LATCH))
	{
		W25Q64_WriteEnable();
	}
}

void W25Q64_WaitForWriteEnableLatch(void)
{
	while(!(W25Q64_ReadStatusRegister1() & W25Q64_WRITE_ENABLE_LATCH))
	{
		;
	}
}

void W25Q64_WaitForWriteInProgressClear(void)
{
	while((W25Q64_ReadStatusRegister1() & W25Q64_WRITE_IN_PROGRESS))
	{
		;
	}
}

Do kasowania pamięci zaimplementujemy dwie funkcje: czyszczącą pojedynczy sektor oraz całą pamięć. Warto zauważyć, że procedura kasowania zajmuje sporo czasu – instrukcja Chip Erase może potrwać nawet ponad 20 s. Czas ten może być różny dla układów innych producentów. Poza tym nie możemy wykasować mniejszego obszaru pamięci niż jeden sektor.

HAL_StatusTypeDef W25Q64_SectorErase(uint16_t sector_number)
{
	uint32_t adress;
	adress = sector_number * SECTOR_SIZE;
	uint8_t data_to_send[] = { 0, 0, 0, 0 };
	HAL_StatusTypeDef status;

	W25Q64_WaitForWriteInProgressClear();
	W25Q64_WriteEnable_and_WaitForWriteEnableLatch();

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

	W25Q64_Set_ChipSelect_Low();
	status = W25Q64_SPI_Transmit_Data(data_to_send, 4);
	W25Q64_Set_ChipSelect_High();

	W25Q64_WaitForWriteInProgressClear();

	return status;
}

HAL_StatusTypeDef W25Q64_ChipErase(void)
{
	uint8_t data_to_send =  CHIP_ERASE;
	HAL_StatusTypeDef status;

	W25Q64_WaitForWriteInProgressClear();
	W25Q64_WriteEnable_and_WaitForWriteEnableLatch();

	W25Q64_Set_ChipSelect_Low();
	status = W25Q64_SPI_Transmit_Data(&data_to_send, 1);
	W25Q64_Set_ChipSelect_High();

	W25Q64_WaitForWriteInProgressClear();

	return status;
}

Pamięć Flash jest programowana stronami tzn., że maksymalnie w jednej instrukcji Page Program możemy wysłać do 256 bajtów danych (możemy oczywiście mniej). Wysłanie większej ilości spowoduje ponowne nadpisanie początkowych bajtów wysłanego adresu. Przy okazji zapisu danych warto wspomnieć o ważnej cesze układów Flash. Po wykasowaniu danych w pamięci znajdują się same 1, a zapis danych polega na ustawieniu zer w odpowiednich miejscach. Ponieważ funkcją zapisu możemy tylko wpisywać zera, próba ponownego zapisu danych się nie powiedzie – przed każdym zapisem musimy wykasować dane z danego sektora, a potem wpisać nowe.

HAL_StatusTypeDef W25Q64_PageProgram(uint32_t page_adress, uint8_t *data, uint16_t size)
{
	uint8_t data_to_send[] = { 0, 0, 0, 0 };
	HAL_StatusTypeDef 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(data, size);
	W25Q64_Set_ChipSelect_High();

	return status;
}

Odczyt danych realizujemy w analogiczny sposób jak zapis. Tutaj oczywiście nie mamy żadnych ograniczeń – czytać można do woli i w każdym momencie.

HAL_StatusTypeDef W25Q64_ReadDataBytes(uint32_t adress, uint8_t *data, uint16_t size)
{
	uint8_t data_to_send[] = { 0, 0, 0, 0 };
	HAL_StatusTypeDef 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(data, size);
	W25Q64_Set_ChipSelect_High();

	return status;
}

Na koniec musimy zaimplementować jeszcze obsługę zapisu i odczytu rejestrów statusowych. Odczyt odbywa się pojedynczo (każdy z dwóch rejestrów ma inną instrukcję), natomiast zapis realizujemy od razu do obu rejestrów.

uint8_t W25Q64_ReadStatusRegister1(void)
{
	uint8_t data_to_send = READ_STATUS_REG1;
	uint8_t receive_data = 0;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(&data_to_send, 1);
	W25Q64_SPI_Receive_Data(&receive_data, 1);
	W25Q64_Set_ChipSelect_High();

	return receive_data;
}

uint8_t W25Q64_ReadStatusRegister2(void)
{
	uint8_t data_to_send = READ_STATUS_REG2;
	uint8_t receive_data = 0;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(&data_to_send, 1);
	W25Q64_SPI_Receive_Data(&receive_data, 1);
	W25Q64_Set_ChipSelect_High();

	return receive_data;
}

void W25Q64_WriteStatusRegister(uint8_t reg1, uint8_t reg2)
{
	uint8_t data_to_send[] = { 0, 0, 0 };

	W25Q64_WriteEnable_and_WaitForWriteEnableLatch();

	data_to_send[0] = WRITE_STATUS_REG;
	data_to_send[1] = reg1;
	data_to_send[2] = reg2;

	W25Q64_Set_ChipSelect_Low();
	W25Q64_SPI_Transmit_Data(data_to_send, 2);
	W25Q64_Set_ChipSelect_High();
}

Aby przetestować nasz kod możemy wywołać w funkcji main() następujące linie kodu, które wyczyszczą pierwszy sektor, zapiszą dane na pierwszej stronie pamięci oraz je odczytają. Aby sprawdzić, czy wszystko przebiegło pomyślnie, najłatwiej będzie uruchomić program w trybie debugowania (F11) i po każdej instrukcji sprawdzać stan bufora (po czyszczeniu powinien być wypełniony wartościami 0xFF, a po zapisie liczbami od 0 do 255).

  /* USER CODE BEGIN 2 */
  uint8_t read_buffer[256];
  uint8_t write_buffer[256];

  for(int i=0; i< 256; i++)
  {
	  buf1[i] = i;
  }

  W25Q64_Init();
  W25Q64_SectorErase(0);

  W25Q64_ReadDataBytes(0x0000, read_buffer, 256);
  W25Q64_PageProgram(0x0000, write_buffer, 256);
  W25Q64_ReadDataBytes(0x0000, read_buffer, 256);
  /* USER CODE END 2 */

Przed uruchomieniem programu należy jeszcze poprawnie podłączyć pamięć do Nucleo. Schemat połączeń przedstawia poniższa grafika.

Podsumowanie

Dodanie do projektu pamięci SPI Flash pozwala na przechowywanie danych, które nie zmieściły się w pamięci mikrokontrolera – grafik do obsługi wyświetlacza, czy dźwięków. W artykule przedstawiłem sposób obsługi podstawowych funkcji układów, czyli kasowania pamięci, zapisu oraz odczytu danych za pomocą interfejsu SPI w trybie Pooling. W kolejnej części artykułu stworzymy projekt dedykowany pod wygenerowanie tzw. External Loader-a, czyli wsadu, który pozwoli w łatwy sposób zaprogramować naszą pamięć.

Do pobrania

Repozytorium GitHub

Dodaj komentarz

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