Микроконтроллеры STM32: работа с SPI на примере флеш-памяти AT45DB161E

12 марта 2018

Благодаря заметке Микроконтроллеры STM32: работа с внешним EEPROM мы научились работать с внешней EEPROM-памятью с I2C-интерфейсом. Сегодня же мы научимся использовать флеш-память с SPI-интерфейсом на примере популярных чипов AT45DBxx. Существуют разные модификации этих чипов, отличающиеся в основном объемом памяти. При написании этой заметки я использовал AT45DB161E объемом 16 Мбит (2 Мб). Но, по идее, все описанное ниже справедливо и для других чипов этой серии.

Пожалуй, наиболее очевидное преимущество чипов AT45DBxx перед рассмотренными ранее 24Cxx заключается в существенно большем объеме памяти. У первых я видел модификации, хранящие 8 Мб данных (AT45DB641E), и теоретически протокол позволяет работать с объемами данных до 16 Мб. Вторые же предлагают максимум 256 Кб. AT45DB161E поддерживает передачу данных с тактовой частотой до 85 МГц, против 400 КГц у 24LC64. То есть, чипы AT45DBxx позволяют передавать данные намного быстрее. Но при этом следует учитывать время выполнения конкретных операций. Например, операция очистки и записи одной страницы в случае AT45DBxx занимает десятки миллисекунд.

EEPROM хорош тем, что он более долговечен. Даташит 24LC64 [PDF] сообщает, что чип переживает более 1 миллиона циклов очистки-записи, и что данные будут хранится более 200 лет. Для сравнения, согласно даташиту AT45DB161E, гарантируется только 100 000 циклов очистки-записи, и данные хранятся лишь 20 лет. Кроме того, чипы 24Cxx заметно дешевле. Розничные цены в России на них начинаются где-то от 0.1$, против 0.6$ у чипов AT45DBxx. Наконец, как мы скоро убедимся, работать с AT45DBxx несколько сложнее, хотя эта сложность и может быть завернута в библиотеку.

Итак, создадим новый проект в STM32CubeMX. Для своих экспериментов я все также использую отладочную плату Nucleo-F411RE. Если у вас другая отладочная плата, отличия в настройке будут минимальными. Для общения с ПК включаем USART2, как делали это в статье Микроконтроллеры STM32: обмен данными по UART. Также включаем периферию SPI1, в выпадающем списке Mode выбираем «Full-Duplex Master». В этом режиме периферии нужен пин PA5, который по умолчанию управляет светодиодом на отладочной плате. Поэтому STM32CubeMX будет жаловаться на ошибку в конфигурации, пока мы не освободим пин PA5. Также помечаем пин PB6 как GPIO_Output. Он будет использоваться для выбора SPI устройства, с которым мы хотим поговорить (так называемый «chip select» или «slave select»).

Вот что должно получиться:

Настройка SPI в STM32CubeMX

В случае подключения Arduino-шилда к плате Nucleo расположение пинов будет следующее: пины (D13, D12, D11, D10) шилда = пины (PA5, PA6, PA7, PB6) микроконтроллера = пины (SCK, MISO, MOSI, CS) шины SPI. Далее генерируем проект, правим немного Makefile — все как обычно. Можно взять за основу Makefile из предыдущего проекта, убрав из списка C_SOURCES все лишнее (например, про I2C) и добавив в него строчку:

$(FIRMWARE)/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_spi.c \

Взглянем на распиновку AT45DB161E (иллюстрация из даташита [PDF]):

Распиновка SPI-flash памяти AT45DB161E

Питание подается через пины VCC и GND, нужно от 2.5 В до 3.6 В. Общение с чипом по SPI производится через пины SI, SO, SCK и CS. Подключаем их к соответствующим пинам отладочной платы. Пин WP — это write protection. Если WP подключен к плюсу, разрешено и чтение и запись. Если же пин подключен к минусу, разрешено только чтение. По умолчанию WP уже подтянут к плюсу, поэтому можно оставить его висящим. Тем не менее, даташит рекомендуется по возможности дополнительно подключать его к VCC. Наконец, подача низкого напряжения на пин RESET сбрасывает внутреннее состояние чипа. Если эта возможность не используется, даташит рекомендует подключить пин к VCC.

С учетом всего вышесказанного, у меня получился такой сэндвич:

Подключение SPI-flash памяти к отладочной плате Nucleo

На этом работа с железной частью завершена, переходим к софтверной. Даташит содержит подробное описание множества команд, поддерживаемых чипом. Рассмотрим некоторые из них.

Важно! Существует две модификации чипов AT45DBxx. Первая модификация работает со страницами размером 528 байт. Пример полной маркировки такого чипа: AT45DB161E-SHD. Вторая модификация работает со страницами размером 512 байт, пример маркировки: AT45DB161E-SHD2B, где часть «2B» как раз указывает на размер страниц в 512 байт (см разел 27.1 даташита). В зависимости от размера страницы меняется и их адресация. Далее приводится код для -SHD чипов, в котором также присутствует закомментированный код для -SHD2B чипов.

Самая простая команда, это, пожалуй, получение device id. Я бы советовал выполнить ее в первую очередь, чтобы убедиться в правильности подключения и всего такого. Команда имеет код 0x9F и принимает в ответ 5 байт:

HAL_StatusTypeDef res1, res2;
uint8_t devid_cmd[1] = { 0x9F };
uint8_t devid_res[5];

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET);
res1 = HAL_SPI_Transmit(&hspi1, devid_cmd, sizeof(devid_cmd),
                        HAL_MAX_DELAY);
res2 = HAL_SPI_Receive(&hspi1, devid_res, sizeof(devid_res),
                       HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);

if((res1 != HAL_OK) || (res2 != HAL_OK)) {
    char msg[256];
    snprintf(
        msg, sizeof(msg),
        "Error during getting the device id, res1 = %d, res2 = %d\r\n",
        res1, res2);
    HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg),
                      HAL_MAX_DELAY);
    return;
}  

{  
    char msg[256];
    snprintf(msg, sizeof(msg),
        "Manufacturer ID: 0x%02X\r\n"
        "Device ID (byte 1): 0x%02X\r\n"
        "Device ID (byte 2): 0x%02X\r\n"
        "Extended device information (EDI) string length: 0x%02X\r\n"
        "EDI byte 1: 0x%02X\r\n"
        "--------\r\n",
        devid_res[0], devid_res[1], devid_res[2],
        devid_res[3], devid_res[4]);
    HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg),
                      HAL_MAX_DELAY);
}

Ответ должен быть следующим:

Manufacturer ID: 0x1F
Device ID (byte 1): 0x26
Device ID (byte 2): 0x00
Extended device information (EDI) string length: 0x01
EDI byte 1: 0x00

Для чтения и записи существует сильно больше одной команды. Устройство имеет два SRAM-буфера, каждый размером в одну страницу. И предусмотрены команды вроде «записать содержимое буфера 1 в страницу», «прочитать содержимое буфера 2», «очистить страницу», и подобного рода. В определенных задачах эти команды могут быть полезны, однако мы рассмотрим лишь самые простые с точки зрения использования команды.

Чтобы записать данные через буфер 1 с предварительной очисткой страницы, воспользуемся командой 0x82. Следом за кодом команды должны следовать три байта, содержащие адрес, по которому мы хотим осуществить запись, а затем и сами данные. О том, что передача данных завершилась, устройство узнает по исчезновению низкого напряжения с пина CS:

uint16_t pageAddr = 0x123;
const char wmsg[] = "This is a test message";

uint8_t wcmd[4];
// opcode
wcmd[0] = 0x82; // 0x82 for buffer 1, 0x85 for buffer 2
// for 512 bytes/page chip address is transfered in form:
// 000AAAAA AAAAAAAa aaaaaaaa
// wcmd[1] = (pageAddr >> 7) & 0x1F;
// wcmd[2] = (pageAddr << 1) & 0xFE;
// wcmd[3] = 0x00;

// 00PPPPPP PPPPPPBB BBBBBBBB
wcmd[1] = (pageAddr >> 6) & 0x3F;
wcmd[2] = (pageAddr << 2) & 0xFC;
wcmd[3] = 0x00;

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET);
res1 = HAL_SPI_Transmit(&hspi1, wcmd, sizeof(wcmd),
       HAL_MAX_DELAY);
res2 = HAL_SPI_Transmit(&hspi1, (uint8_t*)wmsg, sizeof(wmsg),
       HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);

if((res1 != HAL_OK) || (res2 != HAL_OK)) {
    char msg[256];
    snprintf(
        msg, sizeof(msg),
        "Error during writing the data, res1 = %d, res2 = %d\r\n",
        res1, res2);
    HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg),
                      HAL_MAX_DELAY);
    return;
}

Выполнение команды происходит не моментально. Поэтому если сразу после записи мы попытаемся прочитать данные, то, скорее всего, увидим мусор. Можно просто сделать HAL_Delay, скажем, на 20 мс, но, строго говоря, такой вызов не гарантирует, что при возвращении из него запись данных будет обязательно завершена. Действительно работающее решение заключается в чтении status register устройства и проверки его флага RDY. Соответствующая команда имеет код 0xD7 и получает в ответ два байта с содержимым регистра. Если после выполнения команды не вернуть высокое напряжение на CS, устройство продолжит посылать обновленные значения регистра. Пример кода:

uint32_t delta = HAL_GetTick();
uint32_t cnt = 0;

uint8_t status_cmd[1] = { 0xD7 };
uint8_t status_res[2];
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi1, status_cmd, sizeof(status_cmd),
                 HAL_MAX_DELAY);
do {
    cnt++;
    res1 = HAL_SPI_Receive(&hspi1, status_res, sizeof(status_res),
                           HAL_MAX_DELAY);
    if(res1 != HAL_OK)
        break;
} while (! (status_res[0] & 0x80)); // check RDY flag

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);

delta = HAL_GetTick() - delta;
uint8_t protect = (status_res[0] >> 1) & 0x01;
uint8_t page_size = (status_res[0]) & 0x01;
uint8_t epe = (status_res[1] >> 5) & 0x01;
uint8_t sle = (status_res[1] >> 3) & 0x01;
char msg[256];
snprintf(msg, sizeof(msg),
    "Await loop took %ld ms, %ld iterations\r\n"
    "Sector protection status: %s\r\n"
    "Page size: %d bytes\r\n"
    "Erase/program error: %s\r\n"
    "Sector lockdown command: %s\r\n"
    "--------\r\n",
    delta, cnt,
    protect ? "enabled" : "disabled",
    page_size ? 512 : 528,
    epe ? "ERROR!" : "no error",
    sle ? "enabled" : "disabled");
HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);

Пример вывода:

Await loop took 10 ms, 1750 iterations
Sector protection status: disabled
Page size: 528 bytes
Erase/program error: no error
Sector lockdown command: enabled

Дождавшись завершения записи, можно попытаться и прочитать только что записанные данные. Для этого существуют команды 0x0B и 0x1B. Последняя, насколько я смог выяснить, появилась лишь в более поздних чипах производства Adesto (компания купила эту часть бизнеса у Atmel в 2012 году), и не упоминается в даташитах более старых устройств, например AT45DB161D [PDF]. Поэтому рассмотрим более переносимую команду 0x0B. Как и в случае с записью, за ней должны следовать три байта, содержащие адрес, который мы читаем. Кроме того, должен следовать один байт, значение которого будет проигнорировано. Пример кода:

char rmsg[sizeof(wmsg)] = {0};

uint8_t rcmd[5];
// opcode
rcmd[0] = 0x0B;

// for 512 bytes/page chip address is transfered in form:
// rcmd[1] = (pageAddr >> 7) & 0x1F;
// rcmd[2] = (pageAddr << 1) & 0xFE;
// rcmd[3] = 0x00;

// 00PPPPPP PPPPPPBB BBBBBBBB
rcmd[1] = (pageAddr >> 6) & 0x3F;
rcmd[2] = (pageAddr << 2) & 0xFC;
rcmd[3] = 0x00;

// one dummy byte
rcmd[4] = 0x00;

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET);
res1 = HAL_SPI_Transmit(&hspi1, rcmd, sizeof(rcmd),
                        HAL_MAX_DELAY);
res2 = HAL_SPI_Receive(&hspi1, (uint8_t*)rmsg, sizeof(rmsg),
                       HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);

if((res1 != HAL_OK) || (res2 != HAL_OK)) {
    char msg[256];
    snprintf(
        msg, sizeof(msg),
        "Error during reading the data, res1 = %d, res2 = %d\r\n",
        res1, res2);
    HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg),
                      HAL_MAX_DELAY);
    return;
}

if(memcmp(rmsg, wmsg, sizeof(rmsg)) == 0) {
    const char result[] = "Test passed!\r\n";
    HAL_UART_Transmit(&huart2, (uint8_t*)result, sizeof(result)-1,
                      HAL_MAX_DELAY);
} else {
    char msg[256];
    snprintf(
        msg, sizeof(msg),
        "Test failed: wmsg = '%s', rmsg = '%s'\r\n",
        wmsg, rmsg);
    HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg),
                      HAL_MAX_DELAY);
}

Итак, теперь мы знаем, как осуществлять чтение и запись. Этих знаний должно вполне хватить для решения 99% практических задач.

Важно! Обратите внимание, что процедуры HAL_SPI_* принимают размер данных в виде типа uint16_t. То есть, если вам когда-нибудь понадобится передать или принять по SPI больше 64 Кб, данные придется нарезать на куски.

Полную версию исходного кода, как обычно, вы найдете на GitHub. Напомню, что в настоящем, боевом коде вы, вероятно, захотите более серьезно отнестись к проверьке кодов возврата, чем это делал я. Кроме того, я бы советовал хранить вместе с данными их контрольную сумму. За более подробной информацией по чипу AT45DB161E обращайтесь к даташиту [PDF], он классный. Что же касается работы с SPI, стоит отметить, что за кадром осталось использование его совместно с прерываниями и DMA. Но об этом уж как-нибудь в другой раз.

Дополнение: Вас также может заинтересовать статья Учимся работать с SDHC/SDXC-картами по протоколу SPI. О том, как можно быстро сдампить или перезаписать SPI flash, рассказывает пост Реверс-инжиниринг роутера на примере GL.iNet GL-AR750.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.