Прочитал на Хабре кучу простых, и даже очень, статеек на тему программирования микроконтроллеров, тоже решил добавить что-то простое, понятное, но чуть более полезное.

Да простят меня бывалые разработчики за сей труд, да и хейтеры пусть прощают тоже????

О чём пойдет речь

Будем размышлять и пытаться писать быстрый драйвер флешки и при этом попробуем сэкономить её ресурс при перезаписях.

Предыстория и проблемы возможных реализаций

Понадобилась SPI флэшка для хранения данных, которыми оперирует встраиваемое ПО микроконтроллера. Типовая задача, если не хватает места внутри основного чипа.

Объем памяти нужен был небольшой, но приличный, что-то в районе 8 мегабайт.

Как и все нормальные программисты, во избежание изобретения велосипеда, полез искать в сети (github, gitlab) в итоге ничего не приглянулось.

Основные проблемы найденных вариантов:

  1. Все реализации предоставляют только базовые функции записи и чтения по страницам и секторам.

  2. Нет реализаций, когда нужно считать или записать страницы, диапазон которых разбросан на два или более секторов, а некоторые не позволяют даже с несколькими страницами в одном секторе провзаимодействовать.

  3. Кривая по тем или иным причинам реализация.

  4. Реализация в лоб. Необходимо ещё свою обертку писать с нормальным взаимодействием.

  5. Плохой стиль/архитектура:

    1. extern везде, где нужно и не нужно

    2. Куча макросов, где можно обойтись без них, ещё и получить бóльший профит.

    3. Вытаскивание наружу того, что должно оставаться внутри модуля, — торчащие глобальные переменные и функции, которые используются только внутри и никак не описаны в файле заголовка.

Приведу несколько примеров того, что не понравилось.

Пример 1 - с описанием и критикой

https://github.com/nimaltd/w25qxx

//...
extern w25qxx_t w25qxx;

bool W25qxx_Init(void);

void W25qxx_EraseChip(void);
void W25qxx_EraseSector(uint32_t SectorAddr);
void W25qxx_EraseBlock(uint32_t BlockAddr);

uint32_t W25qxx_PageToSector(uint32_t PageAddress);
uint32_t W25qxx_PageToBlock(uint32_t PageAddress);
uint32_t W25qxx_SectorToBlock(uint32_t SectorAddress);
uint32_t W25qxx_SectorToPage(uint32_t SectorAddress);
uint32_t W25qxx_BlockToPage(uint32_t BlockAddress);

bool W25qxx_IsEmptyPage(uint32_t Page_Address, uint32_t OffsetInByte, uint32_t NumByteToCheck_up_to_PageSize);
bool W25qxx_IsEmptySector(uint32_t Sector_Address, uint32_t OffsetInByte, uint32_t NumByteToCheck_up_to_SectorSize);
bool W25qxx_IsEmptyBlock(uint32_t Block_Address, uint32_t OffsetInByte, uint32_t NumByteToCheck_up_to_BlockSize);

void W25qxx_WriteByte(uint8_t pBuffer, uint32_t Bytes_Address);
void W25qxx_WritePage(uint8_t *pBuffer, uint32_t Page_Address, uint32_t OffsetInByte, uint32_t NumByteToWrite_up_to_PageSize);
void W25qxx_WriteSector(uint8_t *pBuffer, uint32_t Sector_Address, uint32_t OffsetInByte, uint32_t NumByteToWrite_up_to_SectorSize);
void W25qxx_WriteBlock(uint8_t *pBuffer, uint32_t Block_Address, uint32_t OffsetInByte, uint32_t NumByteToWrite_up_to_BlockSize);

void W25qxx_ReadByte(uint8_t *pBuffer, uint32_t Bytes_Address);
void W25qxx_ReadBytes(uint8_t *pBuffer, uint32_t ReadAddr, uint32_t NumByteToRead);
void W25qxx_ReadPage(uint8_t *pBuffer, uint32_t Page_Address, uint32_t OffsetInByte, uint32_t NumByteToRead_up_to_PageSize);
void W25qxx_ReadSector(uint8_t *pBuffer, uint32_t Sector_Address, uint32_t OffsetInByte, uint32_t NumByteToRead_up_to_SectorSize);
void W25qxx_ReadBlock(uint8_t *pBuffer, uint32_t Block_Address, uint32_t OffsetInByte, uint32_t NumByteToRead_up_to_BlockSize);

Куча ненужных функций, в тупую реализован datasheet. Из всего множества функций можно выделить лишь несколько необходимых. Но тех, которые реально нужны нет.

А теперь очень длинный кусок кода, который я уже сократил, но он всё ещё слишком большой.

switch (id & 0x000000FF)
{
case 0x20: // 	w25q512
    w25qxx.ID = W25Q512;
    w25qxx.BlockCount = 1024;
    break;
case 0x19: // 	w25q256
    w25qxx.ID = W25Q256;
    w25qxx.BlockCount = 512;
    break;
case 0x18: // 	w25q128
    w25qxx.ID = W25Q128;
    w25qxx.BlockCount = 256;
    break;
case 0x17: //	w25q64
    w25qxx.ID = W25Q64;
    w25qxx.BlockCount = 128;
    break;
case 0x16: //	w25q32
    w25qxx.ID = W25Q32;
    w25qxx.BlockCount = 64;
    break;
case 0x15: //	w25q16
    w25qxx.ID = W25Q16;
    w25qxx.BlockCount = 32;
    break;
case 0x14: //	w25q80
    w25qxx.ID = W25Q80;
    w25qxx.BlockCount = 16;
    break;
case 0x13: //	w25q40
    w25qxx.ID = W25Q40;
    w25qxx.BlockCount = 8;
    break;
case 0x12: //	w25q20
    w25qxx.ID = W25Q20;
    w25qxx.BlockCount = 4;
    break;
case 0x11: //	w25q10
    w25qxx.ID = W25Q10;
    w25qxx.BlockCount = 2;
    break;
default:
    w25qxx.Lock = 0;
    return false;
}

w25qxx.PageSize = 256;
w25qxx.SectorSize = 0x1000;
w25qxx.SectorCount = w25qxx.BlockCount * 16;
w25qxx.PageCount = (w25qxx.SectorCount * w25qxx.SectorSize) / w25qxx.PageSize;
w25qxx.BlockSize = w25qxx.SectorSize * 16;
w25qxx.CapacityInKiloByte = (w25qxx.SectorCount * w25qxx.SectorSize) / 1024;

Чтобы добавить или убрать какие-то микросхемы, которые никогда в жизни не попадут в устройство, нужно пролистать до 170 строки, вставить или удалить ветви switch, а потом не забыть проверить дальнейший код.

Пример 2 - чуть более лояльная критика, но это не точно

https://github.com/iammingge/Driver_W25Qxx

Те же самые проблемы с API. Но уже есть возможность вернуть ошибку при выполнении операций. Да и то не самым элегантным способом.

Список доступных функций
//.. это 250 строка до этого куча макросов бесполезных макросов.
uint16_t W25Qxx_ID_Manufacturer(void);
uint32_t W25Qxx_ID_JEDEC(void);
uint64_t W25Qxx_ID_Unique(void);

void W25Qxx_Reset(void);
void W25Qxx_PowerEnable(void);
void W25Qxx_PowerDisable(void);
void W25Qxx_VolatileSR_WriteEnable(void);
void W25Qxx_WriteEnable(void);
void W25Qxx_WriteDisable(void);
void W25Qxx_4ByteMode(void);
void W25Qxx_3ByteMode(void);
void W25Qxx_Suspend(void);
void W25Qxx_Resume(void);

void W25Qxx_ReadExtendedRegister(void);
void W25Qxx_WriteExtendedRegister(uint8_t ExtendedAddr);

void W25Qxx_ReadStatusRegister(uint8_t Select_SR_1_2_3);
void W25Qxx_WriteStatusRegister(uint8_t Select_SR_1_2_3, uint8_t Data);
void W25Qxx_VolatileSR_WriteStatusRegister(uint8_t Select_SR_1_2_3, uint8_t Data);
uint8_t W25Qxx_RBit_WEL(void);
uint8_t W25Qxx_RBit_BUSY(void);
uint8_t W25Qxx_RBit_SUS(void);
uint8_t W25Qxx_RBit_ADS(void);
void W25Qxx_WBit_SRP(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_TB(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_CMP(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_QE(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_SRL(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_WPS(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_DRV(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_BP(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_LB(W25Qxx_SRM srm, uint8_t bit);
void W25Qxx_WBit_ADP(uint8_t bit);
uint8_t W25Qxx_ReadStatus(void);

void W25Qxx_isStatus(uint8_t Select_Status, uint32_t timeout, W25Qxx_ERR *err);

void W25Qxx_Global_UnLock(W25Qxx_ERR *err);
void W25Qxx_Global_Locked(W25Qxx_ERR *err);
void W25Qxx_Individual_UnLock(uint32_t ByteAddr, W25Qxx_ERR *err);
void W25Qxx_Individual_Locked(uint32_t ByteAddr, W25Qxx_ERR *err);

void W25Qxx_Erase_Chip(W25Qxx_ERR *err);
void W25Qxx_Erase_Block64(uint32_t Block64Addr, W25Qxx_ERR *err);
void W25Qxx_Erase_Block32(uint32_t Block32Addr, W25Qxx_ERR *err);
void W25Qxx_Erase_Sector(uint32_t SectorAddr, W25Qxx_ERR *err);
void W25Qxx_Erase_Security(uint32_t SectorAddr, W25Qxx_ERR *err);
void W25Qxx_Read(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToRead, W25Qxx_ERR *err);
void W25Qxx_Read_Security(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToRead, W25Qxx_ERR *err);
void W25Qxx_Read_SFDP(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToRead, W25Qxx_ERR *err);
void W25Qxx_DIR_Program_Page(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToWrite, W25Qxx_ERR *err);
void W25Qxx_DIR_Program(uint8_t *pBuffer, uint32_t ByteAddr, uint32_t NumByteToWrite, W25Qxx_ERR *err);
void W25Qxx_DIR_Program_Security(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToWrite, W25Qxx_ERR *err);
void W25Qxx_Program(uint8_t *pBuffer, uint32_t ByteAddr, uint32_t NumByteToWrite, W25Qxx_ERR *err);
void W25Qxx_Program_Security(uint8_t *pBuffer, uint32_t ByteAddr, uint16_t NumByteToWrite, W25Qxx_ERR *err);

W25QXX_EXT W25Qxx_t W25Qxx;
void W25Qxx_QueryChip(W25Qxx_ERR *err);
void W25Qxx_config(W25Qxx_ERR *err);
void W25Qxx_SusResum_Erase_Sector(uint32_t SectorAddr, W25Qxx_ERR *err);

А теперь по стилистике.

Приведу небольшой кусочек в заголовочном файле.

Описаны команды, которые может принимать микросхема. Объясните, для чего их нужно вытаскивать из модуля, для чего они именно макросами, а не в виде перечисления.

Зачем здесь данные о самой микросхеме, если они нигде в этом API не используются, а нужны только для внутренних вычислений?

Так много вопросов и так мало ответов...

#define W25Q_CMD_WEN		             			 0x06
#define W25Q_CMD_VOLATILESREN        				 0x50
#define W25Q_CMD_WDEN   		         			 0x04				 
#define W25Q_CMD_POWEREN    	       				 0xAB
#define W25Q_CMD_MANUFACTURER						 0x90
#define W25Q_CMD_JEDECID		         			 0x9F 
#define W25Q_CMD_UNIQUEID            				 0x4B				 
#define W25Q_CMD_READ			           			 0x03
#define W25Q_CMD_4BREAD              				 0x13
#define W25Q_CMD_FASTREAD            				 0x0B
#define W25Q_CMD_4BFASTREAD          				 0x0C	

/**
 * @brief W25Qxx SIZE
 */
#define W25Qxx_PAGESIZE							 	 0x00100
#define W25Qxx_PAGEPOWER							 0x08
#define W25Qxx_SECTORSIZE						 	 0x01000
#define W25Qxx_SECTORPOWER							 0x0C
#define W25Qxx_BLOCKSIZE						 	 0x10000
#define W25Qxx_BLOCKPOWER							 0x10
#define W25Qxx_SECTURITYSIZE						 0x00300

От себя маленькое замечание: все структуры и перечисления, на основе которых делаются типы, нужно именовать, а не просто так. Вот пример, как плохо.

typedef enum 
{
    UNKNOWN = 0x000000,
    W25X05 = 0xEFFF10,
    W25X10 = 0xEFFF11,
    ...
} W25Qxx_CHIP;

Если отредактировать первую строку и вписать туда имя перечисления, пусть даже W25Qxx_CHIP, появится возможность делать неполное описание этого типа там, где он вдруг понадобится, а не подключать весь заголовочный файл. Даже тупо подсветка в редакторе кода симпатичнее.

typedef enum W25Qxx_CHIP W25Qxx_CHIP;

Правда, это отдельная тема...

У этого кода есть и хорошие черты, например, запись данных реализована интереснее, чем в остальных: можно писать массивом страниц, автоматизировано стирание сектора.

Пример 3 - ну совсем коротко

https://github.com/zoosmand/Winbond-W25Qxx-EEPROM-SPI-with-DMA-on-STM32

Просто ужас:

  1. Инициализация портов микроконтроллера внутри библиотеки.

  2. Макросы-команды памяти в заголовочном файле.

  3. Невнятное описание функций.

  4. Торчащие наружу переменные и константы.

Примерные чувства
Примерные чувства

А теперь чего хотелось:

  1. Простой и понятный API. По сути нужен не драйвер микросхемы, а менеджер памяти с драйвером (и рыбку съесть, и … костью не подавиться).

  2. Возможность легко использовать с другими библиотеками типа FatFs и usb msc. Следовательно нужны функции:

    1. Чтения и записи страниц, и не обязательно внутри одного сектора

    2. Получение статуса памяти (драйвера памяти) - понадобится для выше стоящих библиотек

    3. Получение информации о размере, также будет нужно.

  3. Возможность легко расширить для использования других похожих микросхем, что есть почти во всех реализациях, но без лишнего кода и действий.

  4. Какие-нибудь элементы синхронизации при использовании RTOS: семафоры или мьютексы.

  5. Без насильственных действий над памятью.

  6. Быстрым чтением и записью. Быстрее и ещё быстрее.

Приступаем к велосипедостроению

Приведу сразу весь заголовочный файл. Он не содержит ничего лишнего вроде списка команд, регистров или ещё какого-то мусора, только то, что нужно непосредственно пользователю (имеется ввиду программист).

mem.h
/**
 ************************************************************************************************
 * @file    mem.h
 * @copyright Copyright (C) 2023
 ************************************************************************************************
 */
#pragma once

#include <stddef.h>
#include <stdint.h>

/* ---------------------------------------------------------------------------------------------------------*/
typedef enum mem_status_t
{
    MEM_OK = 0, // 0: Successful
    MEM_ERROR,  // 1: R/W Error
    MEM_WRPRT,  // 2: Write Protected
    MEM_NOTRDY, // 3: Not Ready / Busy
    MEM_PARERR  // 4: Invalid Parameter
} mem_status_t;

typedef enum mem_ioctl_cmd_t
{
    MEM_IOCTL_CTRL_SYNC = 0, // Complete pending write process
    MEM_IOCTL_PAGES_COUNT,   // Get page size
    MEM_IOCTL_PAGE_SIZE,     // Get page size
    MEM_IOCTL_SECTOR_SIZE,   // Get erase block size
    MEM_IOCTL_BURN,          // Write chache to memory
} mem_ioctl_cmd_t;

/* ---------------------------------------------------------------------------------------------------------*/

mem_status_t mem_init();
mem_status_t mem_status();
mem_status_t mem_read_page(uint8_t* buff, uint32_t page, size_t count);
mem_status_t mem_write_page(uint8_t* buff, uint32_t page, size_t count);
mem_status_t mem_ioctl(mem_ioctl_cmd_t cmd, void* data);
mem_status_t mem_deinit();

/* ---------------------------------------------------------------------------------------------------------*/

Интерфейс есть, пора перейти к его реализации. Но для начала стоит разобраться, как всё это должно работать.

Важное замечание. Данная память устроена так, что, если хочешь изменить один байт, нужно переписывать весь сектор. Неожиданно, да? На самом деле нет. Однако...

Стирать можно минимум сектор (4 килобайта), писать - максимум страницу (256 байт). Читать можно сколько угодно, но нам это и не важно.

Следовательно нужен буфер размером с сектор.

Типовой алгоритм должен выглядеть следующим образом, это есть в одном из примеров:

  1. Читаем весь сектор

  2. Модифицируем буфер

  3. Стираем сектор

  4. Записываем буфер в сектор.

Что здесь плохо?

Пример: хотим мы записать 10 страниц с помощью этого алгоритма и будем записывать их не сразу, а по одной, не важно подряд или в хаотичном порядке. В итоге мы 10 раз сотрём и перезапишем сектор. Неслабо, да?

Как сделать лучше?

Для начала можно начать писать сразу массивом страниц: 1 операция стирания и несколько операций записи, что уже ускорит работу и облегчит жизнь микросхеме.

Также можно попробовать кэшировать изменения. Переписывать сектор только если он меняется: работали с одним сектором, перешли к работе с другим сектором, при переходе провели запись данных.

В итоге линейный алгоритм приобретает чуть большую сложность за счет ветвления.

Блок схема алгоритма управления кэшем
Блок схема алгоритма управления кэшем

Для уменьшения количества перезаписей необходимо ввести флаг был ли модифицирован кэш. В итоге в ветке "ДА" блок с записью пойдет через ещё одно условие.

Изменения в алгоритме записи. Теперь только по необходимости.
Изменения в алгоритме записи. Теперь только по необходимости.

Но и здесь есть свои минусы: если всё время работать с одним и тем же сектором и никогда его не сохранять, то при обрыве питания устройства можно потерять все данные, что намодифицировали. Чтобы этого избежать, необходимо периодически выполнять запись кэша в память. Как часто - ХЗ, это компромисс между насилием и страхом потери.

Разберём небольшой пример.

Расположение данных в памяти
Расположение данных в памяти

На изображении показано, что записываемые данные должны располагаться в 4..7 и 0,1 страницах первого и второго секторов соответственно. Необходимо сначала обработать страницы первого сектора, затем второго, а по пути сделать одну перезапись и ещё одну уже потом.

Блок схема работы с использованием кэша
Блок схема работы с использованием кэша

Функции чтения и записи почти идентичны. Взглянем на их код.

mem_status_t mem_read_page(uint8_t* buff, uint32_t page, size_t count)
{
    mem_mux_take();
    mem_status_t status = mem_operation(buff, page, count, false);
    mem_mux_give();
    return status;
}

mem_status_t mem_write_page(uint8_t* buff, uint32_t page, size_t count)
{
    mem_mux_take();
    mem_status_t status = mem_operation(buff, page, count, true);
    mem_mux_give();
    return status;
}

Функции mem_mux_take() и mem_mux_give() захватывают и освобождают объект синхронизации. Так как это не везде нужно объявил их как weak, в реализации по умолчанию они пустые, а при сборке кода будут выпилены.

void __attribute__((weak)) mem_mux_create() {}
void __attribute__((weak)) mem_mux_take() {}
void __attribute__((weak)) mem_mux_give() {}

Основная функция, которая оперирует с данными, представлена ниже:

static mem_status_t mem_operation(uint8_t* buf, uint32_t page, size_t count, bool write)
{
    if (mem_is_init == 0) return MEM_ERROR;
    if ((page + count) > (dev->flash_size / dev->page_size) || (buf == NULL)) return MEM_PARERR;
  
    mem_status_t status = MEM_OK;

    int32_t sectorn = page / dev->pages_in_sector; // number operating sector

    for (size_t npage = 0; npage < count && status == MEM_OK;)
    {
        //  if sector has changed
        if (current_sector != sectorn) status = mem_update_cache(sectorn);

        // page number in sector
        uint32_t pn_in_sector = page & (dev->pages_in_sector - 1);

        // pages count for operation
        uint32_t pagec_op = (count - npage);

        pagec_op = (pn_in_sector + pagec_op) > dev->pages_in_sector //
                       ? (dev->pages_in_sector - pn_in_sector)      //
                       : pagec_op;

        size_t cahce_n   = dev->page_size * pn_in_sector; // byte number of cahce
        size_t buff_n    = dev->page_size * npage;        // byte number of buffer
        size_t data_size = dev->page_size * pagec_op;     // bytes quantity for operation

        if (write)
        {
            sector_modify = true;
            memcpy(&mem_cache[ cahce_n ], &buf[ buff_n ], data_size);
        }
        else
        {
            memcpy(&buf[ buff_n ], &mem_cache[ cahce_n ], data_size); //
        }

        // pages of next sector if nessary
        page += pagec_op;
        npage += pagec_op;
        sectorn++;
    }

    return status;
}

Заодно посмотрим и на функцию работы с кэшем. Это единственный буфер который используется в коде.

static volatile int32_t current_sector = -1;
static volatile bool sector_modify     = false;
static uint8_t mem_cache[ 1024 * 4 ]   = {0};
static mem_status_t mem_update_cache(int32_t sector)
{
    mem_status_t status = MEM_OK;

    if (sector_modify && (current_sector != -1))
    {
        status = mem_erase_sector(current_sector * dev->sector_size);

        for (size_t i = 0; i < dev->pages_in_sector && status == MEM_OK; i++)
        {
            uint32_t page_offset = i * dev->page_size;
            if (mem_write_data((current_sector * dev->sector_size) + page_offset, // address
                               mem_cache + page_offset,                           // data
                               dev->page_size) != MEM_OK)
            {
                status = MEM_ERROR;
                break;
            }
        }
        sector_modify = false;
    }

    if (status == MEM_OK && (current_sector != sector))
    {
        current_sector = sector;

        status = mem_read_data(sector * dev->sector_size, mem_cache, dev->sector_size);
    }
    return status;
}

Функции для непосредственного взаимодействия с микросхемой памяти (чтение и запись) также довольно тривиальны и почти как у всех.

Адрес разворачивается типичной для ARM инструкцией REV, которую можно заменить на 4 строки с раскладыванием переменной адреса в массив по байтам в нужном порядке или цепочкой сдвигов и дизъюнкций.

static mem_status_t mem_read_data(uint32_t addr, uint8_t* data, size_t len)
{
    if (addr > dev->flash_size || data == NULL) { return MEM_PARERR; }

    mem_status_t status = MEM_OK;

    spi_cs_activate();
    {
        uint32_t cmd = __REV(addr) | MEM_CMD_READ_DATA;
        if (spi_tx(&cmd, sizeof(cmd)) != SPI_OK || // send address
            spi_rx(data, len) != SPI_OK)           // read data
        {
            status = MEM_ERROR;
        }
    }
    spi_cs_deactivate();

    return status;
}

static mem_status_t mem_write_data(uint32_t addr, uint8_t* data, size_t len)
{
    if (addr > dev->flash_size || data == NULL || len > 256) { return MEM_PARERR; }
    // needed before write
    mem_status_t status = mem_send_cmd(MEM_CMD_WRITE_EN);

    spi_cs_activate();
    {
        uint32_t cmd = __REV(addr) | MEM_CMD_WRITE_PAGE;
        if (status != MEM_OK ||                    // write enable
            spi_tx(&cmd, sizeof(cmd)) != SPI_OK || // send address
            spi_tx(data, len) != SPI_OK)           // write data
        {
            status = MEM_ERROR;
        }
    }
    spi_cs_deactivate();

    status = mem_wait_rdy(dev->timeout);

    return status;
}

Добавление альтернативных микросхем.

Нужно лишь вписать инициализированную структуру в массив, остальное выполниться автоматически:

static const mem_dev_t dev_table[] = {
    // ZD25Q64B
    {
        .manufact_id = 0xBA,
        .device_id   = 0x16,
        .capacity_id = 0x17,
        .memory_id   = 0x32,
        //
        .flash_size      = 0x800000,
        .page_size       = 0x100,
        .sector_size     = 0x1000,
        .pages_in_sector = 16,
        .timeout         = 100,
    },
};

Тип этой структуры определен в том же файле "mem.c", что исключает возможность просачивания типа во внешнюю среду.

typedef struct mem_dev_t
{
    uint8_t manufact_id; // Manufacturer ID
    uint8_t device_id;   // Device ID
    uint8_t capacity_id; // Capacity ID
    uint8_t memory_id;   // Memory ID
    //
    uint32_t flash_size;      // Total size, bytes
    uint16_t page_size;       // Min program size, bytes
    uint16_t sector_size;     // Min erase size, bytes
    uint16_t pages_in_sector; // Pages quantity in sector, pieces
    uint16_t timeout;         // Operation timeout, ms
} mem_dev_t;

А что на счет скорости и всего такого?

Для начала предлагаю рассмотреть пример с рисунка выше, где записывался массив данных:

  • 2 сектора

  • В каждом секторе по 8 страниц

  • Данные начинается с 4 страницы первого сектора, а заканчиваются в 1 странице второго сектора.

В итоге полное количество страниц, с которыми оперируем, будет равно 6 (4 в первом + 2 во втором).

Вариант 1. По одной странице за раз.

На запись одной страницы выходит 8 циклов записи (w) и 1 цикл стирания (e), циклы чтения(r) почти бесплатны, но посчитаем тоже - 8. В итоге имеем следующее выражение.

x = 8r + 8w + 1e

Итого без использования кэша получаем время равное 6x (зависит от количества страниц), с кэшем - 2x (от количества секторов).

Неплохо, да?

Может показаться, что, улучшив алгоритм без кэша, чтобы писать сразу блок страниц внутри сектора, мы приблизимся к тем же затратам, что и с кэшем - 2x. Можно, однако это лишь один из вариантов использования.

Вариант 2. Внутри одного сектора.

В таком случае временны́е затраты без кэша будут равны Nx, c кэшем - 1x.

Ожидаю полного тест памяти без кэша.
Ожидаю полного тест памяти без кэша.

В худшем случае, когда пишем каждый раз в новый сектор, алгоритм с кэшированием по затрачиваемому времени будет равен алгоритму без кэша. Так почему бы его не использовать раз уже выделили временный буфер под программу перезаписи данных в микросхеме?

Недоработки.

Внимательный читатель наверняка заметил, что упор сделан на запись данных, а чтение стало ещё медленнее. Даже если нам нужна одна страница из сектора, приходится читать весь сектор целиком, что не очень хорошо.

Это действительно так. Признаюсь, осознание того, как это сделать красиво, пришло только во время набора этого текста.

Есть куда расти.

Вместо выводов

  1. Реализованный API прост и понятен даже без комментариев (моё личное мнение)

  2. Из модуля наружу ничего не торчит (макросы, типы, переменные и функции)

  3. Сохранен ресурс микросхемы по максимуму (с некоторыми оговорками)

  4. Скорость взаимодействия увеличена до приемлемого максимума.

    Но есть нюанс ????: при отправке команд разрешения записи, стираний и самой записи необходимо после поднятия чип селекта (CS) подождать некоторое время перед следующей командой, такой как запрос статуса занятости. Пока это реализовано с помощью HAL_Delay. Знаю, что это тупо, учитывая скорость общения равной 18 мегабитам, но пока не удалось придумать нормальную реализацию.

    Хотя была мысль без прижатого CS вхолостую прогнать несколько байтов по шине SPI, тем самым получив небольшую задержку, но это работало не стабильно, возможно стоит увеличить посылку.

    Возможно кто-то подскажет как обойти эту проблему.

  5. Код не зависит от других библиотек типа HAL и тому подобных. За одним исключением: задержка, которую можно сделать иначе, хочу в это верить.

В принципе, на этом всё, приготовился к критике и вопросам.

Весь код можно посмотреть здесь https://gitlab.com/devprodest/m25q64_mem_mng

Описание стилистики оставим на следующий раз, если будет интересно узнать очередное мнение очередного чувака из этих ваших интернетов.

Комментарии (30)


  1. INSTE
    23.04.2023 11:39
    -2

    Вместо bool write enum будет выглядеть лучше.

    Для mutex стандартной терминологией является down/up, выбор take/give неоправдан в случае если код публикуется для масс.
    На 144 строке было бы аккуратнее с early-exit и continue.

    Где происходит оборачивание навроде spi_cs_activate()/spi_cs_deactivate() более аккуратно выглядит static inline функция, внутри которой всего 3 действия: захват-действие-отпуск, где действие - это вызов еще одной вложенной static inline.


    1. devprodest Автор
      23.04.2023 11:39
      +1

      Вместо bool write enum будет выглядеть лучше.

      Согласен, будет выглядеть аккуратнее.

      Для mutex стандартной терминологией является down/up, выбор take/give неоправдан в случае если код публикуется для масс.

      Не готов согласиться, down/up не совсем отражает суть действия и сходу понятны куда меньше.

      take/give тоже не идеал, но имхо ближе к сути, и для знакомых с freeRTOS намного яснее отражает суть. lock/unlock - тоже было бы норм, но...

      Где происходит оборачивание навроде spi_cs_activate()/spi_cs_deactivate() более аккуратно выглядит static inline функция, внутри которой всего 3 действия: захват-действие-отпуск, где действие - это вызов еще одной вложенной static inline.

      Напишите понятнее, что имеется ввиду? Все функции для работы с spi: прижатие чип селекта, приём и передача определены в другом модуле, здесь им делать нечего.

      На 144 строке было бы аккуратнее с early-exit и continue.

      Подробнее, про какой кусок кода идёт речь?


      1. INSTE
        23.04.2023 11:39

        В случае с mutex это не "понятнее", это глоссарий. Эти термины, которые несмотря на их возможную противоречивость при переводе, наиболее ожидаемы теми кто читает код.

        Примерно вот так с inline
        static inline mem_status_t mem_init_locked_spi_()
        {
        	uint8_t jdec[ 4 ] = {MEM_CMD_READ_JEDEC, 0, 0, 0};
        
        	if (spi_tx_rx(jdec, jdec, 4) != SPI_OK)
        	{
        		return MEM_ERROR;
        	}
        
        	for (uint32_t i = 0; i < sizeof(dev_table) / sizeof(mem_dev_t); i++) //-V1008
        	{
        		if (jdec[ 1 ] == dev_table[ i ].manufact_id &&                   //
        			jdec[ 2 ] == dev_table[ i ].memory_id &&                     //
        			jdec[ 3 ] == dev_table[ i ].capacity_id)
        		{
        			dev    = &dev_table[ i ];
        
        			return MEM_OK;
        		}
        	}
        
        	return MEM_ERROR;
        }
        
        static inline mem_status_t mem_init_locked_()
        {
        	spi_cs_activate();
        
        	mem_status_t status = mem_init_locked_spi_();
        
        	spi_cs_deactivate();
        
        	return status;
        }
        
        mem_status_t mem_init()
        {
            if (mem_is_init > 0)
            {
                mem_is_init++;
                return MEM_OK;
            }
        
            spi_cs_deactivate();
            spi_init();
            mem_mux_create();
        
            // Read JEDEC ID
        
        	mem_mux_take();
        
        	mem_status_t status = mem_init_locked_();
        
        	mem_mux_give();
        
            if (status == MEM_OK) mem_is_init++;
        
            return status;
        }
        


        1. devprodest Автор
          23.04.2023 11:39
          +1

          Реализация с кучей функций имеет право на существование, но в чем смысл плодить сущности, если и так понятно что происходит?

          take/give по нескольким причинам:

          • Нет мнимого чувство что можно использовать счетный, как это может показаться с down/up

          • Так исторически сложилось. Самый тупой аргумент, но имеет место быть

          • Понятность куда важнее, а если имя функции может спровоцировать ошибку, то нафиг оно нужно?

          • Всеобщая практика показывает: кто как хочет, так и ... Делает.

          Спор про мьютекс считаю завершенным.


          1. INSTE
            23.04.2023 11:39

            >> Реализация с кучей функций имеет право на существование, но в чем смысл плодить сущности, если и так понятно что происходит?

            Завтра вам нужно будет где-то сделать return, и случайно вылетите без анлока. Плюс более строго управляется scope переменных - если нужно что-то использовать/менять в заблокированном контексте, то нужно будет передавать через аргументы и "случайно" оно не вылезет.

            >> Спор про мьютекс считаю завершенным.

            Хозяин барин конечно, но вот работать на проекте, который как-бы на C или C++, но все ключевые слова там на русском (который потом препроцессится) да переменные все с emoji - не захотел бы, хотя формально все по стандарту да и аргументы можно любые привести под них.


            1. devprodest Автор
              23.04.2023 11:39

              По первой части. Где и зачем может потребоваться сделать дополнительный return в функции инициализации? Странно и не нужно.

              В других случаях - согласен. Подобный вариант может быть уместен.

              По второй. Что-то вы выдумываете и приписываете мне чужие "заслуги" ????

              Приведите примеры и аргументы правильности использования варианта, предложенного вами.


  1. nikolz
    23.04.2023 11:39

    "Пример: хотим мы записать 10 страниц с помощью этого алгоритма и будем записывать их не сразу, а по одной, не важно подряд или в хаотичном порядке. В итоге мы 10 раз сотрём и перезапишем сектор. Неслабо, да? "

    где Вы такой пример взяли? Никто никогда так не делал. Вы же сами написали типовой алгоритм. Читаем сектор в буфер, изменяем в буфере 10 страниц, стираем сектор 1, а не 10 раз, и пишем сектор. Это и есть типовой алгоритм.

    Аналогично Ваш пример быстродейcтвия. Именно 2x - это и будет классика и без кеша, так как Вы изначально выделяете буфер размером с сектор.

    Впечатление такое, что Вы игнорируете даташит и рекомендации разработчиков микросхем, а сразу строите свой велосипед и радуетесь, что "гениально" придумали два не квадратных колеса.


    1. devprodest Автор
      23.04.2023 11:39

      Посмотрите как реализованы fatfs и stm USB msc, вызывается и пишется блоками по 512 байт. Вот и пример, а выделять отдельно по 4кб для USB и fat глупо, особенно если в чипе всего 20 килобайт оперативы.

      Я привел хоть и синтетический пример, но не такой уж и далекий от реального положения вещей


      1. nikolz
        23.04.2023 11:39

        В этом случае глупо выделять кеш.

        А еще, если у Вас UART, то в этом случае буфер 256 байт . В этом случае пишем по 256 байт.

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

        ----------------

        Если Вы по USB будете писать во флеш, то у вас не предусмотрена синхронизация. В итоге будете писать по 256 байт и медленно.


        1. devprodest Автор
          23.04.2023 11:39

          При чем здесь uart?

          Медленно будет если не использовать кэширование, так как будет по две страницы писаться.

          Но если записывать только по смене сектора и по таймауту, то будет быстрее.

          Вижу, что вы не особо внимательно прочли. 4 килобайта в любом случае выделять, так как стереть можно минимум сектор, а он как раз 4 килобайта. Либо их использовать как временный буфер, либо ещё и в качестве кэша.


          1. nikolz
            23.04.2023 11:39

            Вы же сами написали, " а выделять отдельно по 4кб для USB и fat глупо, особенно если в чипе всего 20 килобайт оперативы". А теперь рассказываете про выделение кеш. Что не так?

            Кроме того, Вы не учитываете, что для записи надо кроме данных передать команду и адрес. Это 32 такта. Если пишите 1 байт, то время записи составит 40 тактов SPI и только в том случае если используете DMA. Иначе будет еще больше.

            Т е в Вашем расчете быстродействия надо считать время записи 1-го байта не 8w, а 40t, а скорость записи страницы как 2080t, где t- такт SPI.

            Если Вы из внешнего источника программируете флеш через USB, то скорость USB-2.0 это примерно 7 Мбайт/сек.

            Если у Вас SPI работает на 10 МГц и используется DMA, то скорость записи по 1 байту составит 0.25 Mбайт/сек, а скорость записи по страницам составит не более 1.2 Мбайт/сек.

            Т е скорость записи во флеш в 6-30 раз меньше, чем скорость поступления данных. В этом случае можно работать налету , что не требует ни кеш, ни большого буфера.

            В любом случае, ограничение скорости записи при программировании флеш связано с низкой скорость физической записи. Ваш драйвер не решает эту проблему.


            1. devprodest Автор
              23.04.2023 11:39

               А теперь рассказываете про выделение кеш. Что не так?

              Кэш не выделяется, а используется уже готовый буфер, который и так использовался бы при записи данных. И в статье описано, и в комментах вам отвечал.

              Время записи, физической записи, в тактах SPI считать крайне неправильная идея. Вы учитываете лишь малую часть этого процесса.

              Временные характеристики операций
              Временные характеристики операций

              Время на пересылку 260 байт (1 команда + 3 адрес + 256 данные) при ваших 10 МГц будет 26 мкс, что 4,5% (в лучшем случае) от всех временных затрат.

              Взгляните на рисунок и будет понятно, что ни о каких 1.2 Мбайт/сек речи и быть не может.

              От чего и выбраны магические константы w, e и r, которые от чипа к чипу меняются.

              Если пишите один байт, то у вас будет только время записи байта, а вот если переписываете - время стирания и переписывания всего сектора: tse + tpp * n, где n - количество страниц в секторе (в примере было 8, в жизни 16).

              Использование DMA немного помогло бы, тут я согласен, но не кардинально. В довесок мы бы получили отложенный возврат статуса операции и обработки ошибок. Можно использовать неблокирующий способ передачи, но нужно будет дополнительно усложнить логику уровнем выше.

              Ваш драйвер не решает эту проблему.

              Как раз за счет уменьшения этих операций и решает.


              1. nikolz
                23.04.2023 11:39

                "Время на пересылку 260 байт (1 команда + 3 адрес + 256 данные) при ваших 10 МГц будет 26 мкс, что 4,5% (в лучшем случае) от всех временных затрат. "

                Вы ошибаетесь. 10 MГц - это тактовая частота SPI.

                SPI - это последовательный интерфейс т. е. это сдвиговый регистр.

                260 байт - это 2080 бит.

                Для передачи 2080 бит по SPI надо 2080 тактов.

                В итоге не 26 мкс, а 208 мкс. т е в 8 раз больше.


                1. devprodest Автор
                  23.04.2023 11:39

                  Точно-точно, прошу прощения, потерял восьмёрку в расчетах. Однако это не сильно меняет ситуацию. А наоборот показывает, что операция длится ещё дольше и хорошо бы свести лишние записи к минимуму.


                  1. nikolz
                    23.04.2023 11:39
                    -1

                    Тогда мой довод ранее о том, что писать можно налету работает. Я сам так пишу при программировании SoC.

                    О каких лишних записях Вы говорите?


                    1. devprodest Автор
                      23.04.2023 11:39
                      -1

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

                      При использовании fatfs и USB MSC у вас будет буфер размером в 512 байт, что всего 2 страницы. При типовой реализации будут лишние записи и стирания, если переписывать данные. Однако если чуть поднакопить изменения в буфере и записать все разом операций будет меньше.

                      Что именно вы пишете, мне кажется мы говорим о разных применениях?


                      1. nikolz
                        23.04.2023 11:39

                        Непонятны Ваши рассуждения.

                        То Вы говорите про запись по USB, то про fatfs.

                        если программируем с компа SoC то никакой fatfs не используется.

                        Но даже в этом случае скорость записи существенно меньше, чем скорость передачи данных.

                        Если Вы используете fatfs, то у Вас скорее всего управление файловой системой будет существенно медленнее, чем передача данных.

                        Чтобы обсуждать предметно, напишите для какого конкретно решения в железе Вы написали свой драйвер. Иначе это беседа о гипотетическом решении.


                      1. devprodest Автор
                        23.04.2023 11:39
                        -1

                        Естественно если вы заливаете прошивку с компа, то запись уже идет большими блоками и уже довольно оптимально. И это совершенно другое. Про программирование с компа речи и не шло, было про оперирование данными в микроконтроллере.

                        Про USB и fatfs говорю потому, что обе эти возможности должны или могут быть реализованы сразу. И у каждой есть ограничения и особенности.

                        Можно было бы выделить под USB 4 килобайта, что бы сразу писать, но такой роскоши нет или может не быть.


                      1. nikolz
                        23.04.2023 11:39
                        -1

                        Повторю, то что сказал ранее. Все зависит от конкретного железа.

                        Допускаю, что Вы сделали решение для Вашего варианта железа, но это не кросс платформенное решение и не универсальное.

                        Но в статье Вы нет конкретики, а есть претензия на универсальность.


                      1. devprodest Автор
                        23.04.2023 11:39
                        -1

                        От железа зависит только скорость пересылки данных, даже не от самого железа, а от его возможностей.

                        Реализация алгоритма не связана с конкретным ядром. Возьмите хоть arm, хоть avr, хоть risc-v, все едино. И ничего не поменяется.

                        Основной посыл не писать сразу данные, а поднакопить их.


                      1. nikolz
                        23.04.2023 11:39
                        -1

                        Посыл спорный. Дайте хотя бы один реальный пример.


                      1. devprodest Автор
                        23.04.2023 11:39

                        Ткните пальцем в код, где есть куски, которые завися от железа.

                        Реальный пример уже приводил, FatFs и ST USB MSC. Возьмите другую связку, например FatFs + AVR USB Software Library.

                        При любом раскладе взаимодействие будет сводится к блочному чтению, по умолчанию это будут 2 страницы, если конечно у вас нет большего буфера.


                      1. nikolz
                        23.04.2023 11:39
                        -1

                        любой код - это команды для управления железом. Т е без железа - это бла бла бла.

                        Код на СИ будет преобразован в код на ASM и его эффективность зависит от компилятора.

                        Ваш пример не корректный.

                        Например , FatFS+AVR (какой чип конкретно? какая максимальная у него скорость SPI конкретно?) какая скорость FatFS для этого чипа поиска свободного сектора конкретно

                        Какой алгоритм загрузки данных в буфер конкретно?

                        Когда Вы все это учтете, то это и будет конкретный реальный пример.

                        Но Вы можете этого не делать, а полагать, что все зависит лишь от того, что Вы убрали макросы и комментарии из приведенных Вами аналогов.

                        ---------------

                        Например, посмотрите это про железо SPI AVR :

                        https://www.microchip.com/content/dam/mchp/documents/MCU08/ApplicationNotes/ApplicationNotes/TB3215-Getting-Started-with-SPI-DS90003215.pdf


                      1. devprodest Автор
                        23.04.2023 11:39
                        -1

                        Код, в частности на си, это не команды для железа, а всего лишь человекочитаемое представление оных.

                        Эффективность решения задачи зависит не только от того какие вы флаги оптимизации включите, а то, как реализуете алгоритм.

                        Но Вы можете этого не делать, а полагать, что все зависит лишь от того, что Вы убрали макросы и комментарии из приведенных Вами аналогов.

                        Всё это называется: "не читал, но осуждаю". Дело не в макросах и комментариях.

                        Не вижу смысла больше общаться с вами, никаких аргументов вы не приводите.


  1. VT100
    23.04.2023 11:39

    … необходимо после поднятия чип селекта (CS) подождать некоторое время перед следующей командой, такой как запрос статуса занятости. Пока это реализовано с помощью HAL_Delay.

    Что-то не припоминаю такой подлости с чтением статуса. Речь именно о 4-битном Винбонде из заголовков?


    1. devprodest Автор
      23.04.2023 11:39

      Там подлость не в чтении статуса, а выдержке с отпущенным чипселектом после операций записи.

      В итоге либо делать магическую задержку, либо проверять статус.

      Но проверять статус сразу после записи не удается из-за слишком малого времени выдержки.

      Микросхем не винбонд, а ZD25Q64B. Она в принципе аналогична. Хотя не исключаю, что это именно её особенность с длинной выдержкой.


  1. nixtonixto
    23.04.2023 11:39
    +1

    Пример: хотим мы записать 10 страниц с помощью этого алгоритма и будем записывать их не сразу, а по одной, не важно подряд или в хаотичном порядке. В итоге мы 10 раз сотрём и перезапишем сектор.

    А кто вас заставляет стирать уже стёртые страницы? Прочитать страницу, и если она вся 0хFF — то можно сразу вписать новую страницу без очистки всего сектора. Накладные расходы на чтение на порядки меньше, чем на стирание, и ресурс экономится. Тем более что при очистке сектора надо будет читать не одну страницу, а сразу весь сектор.


    1. devprodest Автор
      23.04.2023 11:39

      Никто не заставляет, можно и даже нужно. Равно как и запись данных которые уже там есть, расширь проверку не просто на пустоту, но и на равенство.

      Это очень хорошая идея


  1. Dark_Purple
    23.04.2023 11:39

    Страници, сектора… Хочу простые и понятные fread и fwrite. ))


    1. devprodest Автор
      23.04.2023 11:39

      Так это доступно уровнем выше))

      Пару ключиков для линкера, реализуем функции _write, _read, _lseek и остальные, и дело в шляпе - пользуемся fprintf и всеми остальными благами)

      Или можно не сильно заморачиваясь использовать функции fatfs: f_open, f_write, f_read.