Прочитал на Хабре кучу простых, и даже очень, статеек на тему программирования микроконтроллеров, тоже решил добавить что-то простое, понятное, но чуть более полезное.
Да простят меня бывалые разработчики за сей труд, да и хейтеры пусть прощают тоже????
О чём пойдет речь
Будем размышлять и пытаться писать быстрый драйвер флешки и при этом попробуем сэкономить её ресурс при перезаписях.
Предыстория и проблемы возможных реализаций
Понадобилась SPI флэшка для хранения данных, которыми оперирует встраиваемое ПО микроконтроллера. Типовая задача, если не хватает места внутри основного чипа.
Объем памяти нужен был небольшой, но приличный, что-то в районе 8 мегабайт.
Как и все нормальные программисты, во избежание изобретения велосипеда, полез искать в сети (github, gitlab) в итоге ничего не приглянулось.
Основные проблемы найденных вариантов:
Все реализации предоставляют только базовые функции записи и чтения по страницам и секторам.
Нет реализаций, когда нужно считать или записать страницы, диапазон которых разбросан на два или более секторов, а некоторые не позволяют даже с несколькими страницами в одном секторе провзаимодействовать.
Кривая по тем или иным причинам реализация.
Реализация в лоб. Необходимо ещё свою обертку писать с нормальным взаимодействием.
-
Плохой стиль/архитектура:
extern везде, где нужно и не нужно
Куча макросов, где можно обойтись без них, ещё и получить бóльший профит.
Вытаскивание наружу того, что должно оставаться внутри модуля, — торчащие глобальные переменные и функции, которые используются только внутри и никак не описаны в файле заголовка.
Приведу несколько примеров того, что не понравилось.
Пример 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
Просто ужас:
Инициализация портов микроконтроллера внутри библиотеки.
Макросы-команды памяти в заголовочном файле.
Невнятное описание функций.
Торчащие наружу переменные и константы.
А теперь чего хотелось:
Простой и понятный API. По сути нужен не драйвер микросхемы, а менеджер памяти с драйвером (и рыбку съесть, и … костью не подавиться).
-
Возможность легко использовать с другими библиотеками типа FatFs и usb msc. Следовательно нужны функции:
Чтения и записи страниц, и не обязательно внутри одного сектора
Получение статуса памяти (драйвера памяти) - понадобится для выше стоящих библиотек
Получение информации о размере, также будет нужно.
Возможность легко расширить для использования других похожих микросхем, что есть почти во всех реализациях, но без лишнего кода и действий.
Какие-нибудь элементы синхронизации при использовании RTOS: семафоры или мьютексы.
Без насильственных действий над памятью.
Быстрым чтением и записью. Быстрее и ещё быстрее.
Приступаем к велосипедостроению
Приведу сразу весь заголовочный файл. Он не содержит ничего лишнего вроде списка команд, регистров или ещё какого-то мусора, только то, что нужно непосредственно пользователю (имеется ввиду программист).
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 байт). Читать можно сколько угодно, но нам это и не важно.
Следовательно нужен буфер размером с сектор.
Типовой алгоритм должен выглядеть следующим образом, это есть в одном из примеров:
Читаем весь сектор
Модифицируем буфер
Стираем сектор
Записываем буфер в сектор.
Что здесь плохо?
Пример: хотим мы записать 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. В итоге имеем следующее выражение.
Итого без использования кэша получаем время равное 6x (зависит от количества страниц), с кэшем - 2x (от количества секторов).
Неплохо, да?
Может показаться, что, улучшив алгоритм без кэша, чтобы писать сразу блок страниц внутри сектора, мы приблизимся к тем же затратам, что и с кэшем - 2x. Можно, однако это лишь один из вариантов использования.
Вариант 2. Внутри одного сектора.
В таком случае временны́е затраты без кэша будут равны Nx, c кэшем - 1x.
В худшем случае, когда пишем каждый раз в новый сектор, алгоритм с кэшированием по затрачиваемому времени будет равен алгоритму без кэша. Так почему бы его не использовать раз уже выделили временный буфер под программу перезаписи данных в микросхеме?
Недоработки.
Внимательный читатель наверняка заметил, что упор сделан на запись данных, а чтение стало ещё медленнее. Даже если нам нужна одна страница из сектора, приходится читать весь сектор целиком, что не очень хорошо.
Это действительно так. Признаюсь, осознание того, как это сделать красиво, пришло только во время набора этого текста.
Есть куда расти.
Вместо выводов
Реализованный API прост и понятен даже без комментариев (моё личное мнение)
Из модуля наружу ничего не торчит (макросы, типы, переменные и функции)
Сохранен ресурс микросхемы по максимуму (с некоторыми оговорками)
-
Скорость взаимодействия увеличена до приемлемого максимума.
Но есть нюанс ????: при отправке команд разрешения записи, стираний и самой записи необходимо после поднятия чип селекта (CS) подождать некоторое время перед следующей командой, такой как запрос статуса занятости. Пока это реализовано с помощью
HAL_Delay
. Знаю, что это тупо, учитывая скорость общения равной 18 мегабитам, но пока не удалось придумать нормальную реализацию.Хотя была мысль без прижатого CS вхолостую прогнать несколько байтов по шине SPI, тем самым получив небольшую задержку, но это работало не стабильно, возможно стоит увеличить посылку.
Возможно кто-то подскажет как обойти эту проблему.
Код не зависит от других библиотек типа HAL и тому подобных. За одним исключением: задержка, которую можно сделать иначе, хочу в это верить.
В принципе, на этом всё, приготовился к критике и вопросам.
Весь код можно посмотреть здесь https://gitlab.com/devprodest/m25q64_mem_mng
Описание стилистики оставим на следующий раз, если будет интересно узнать очередное мнение очередного чувака из этих ваших интернетов.
Комментарии (30)
nikolz
23.04.2023 11:39"Пример: хотим мы записать 10 страниц с помощью этого алгоритма и будем записывать их не сразу, а по одной, не важно подряд или в хаотичном порядке. В итоге мы 10 раз сотрём и перезапишем сектор. Неслабо, да? "
где Вы такой пример взяли? Никто никогда так не делал. Вы же сами написали типовой алгоритм. Читаем сектор в буфер, изменяем в буфере 10 страниц, стираем сектор 1, а не 10 раз, и пишем сектор. Это и есть типовой алгоритм.
Аналогично Ваш пример быстродейcтвия. Именно 2x - это и будет классика и без кеша, так как Вы изначально выделяете буфер размером с сектор.
Впечатление такое, что Вы игнорируете даташит и рекомендации разработчиков микросхем, а сразу строите свой велосипед и радуетесь, что "гениально" придумали два не квадратных колеса.
devprodest Автор
23.04.2023 11:39Посмотрите как реализованы fatfs и stm USB msc, вызывается и пишется блоками по 512 байт. Вот и пример, а выделять отдельно по 4кб для USB и fat глупо, особенно если в чипе всего 20 килобайт оперативы.
Я привел хоть и синтетический пример, но не такой уж и далекий от реального положения вещей
nikolz
23.04.2023 11:39В этом случае глупо выделять кеш.
А еще, если у Вас UART, то в этом случае буфер 256 байт . В этом случае пишем по 256 байт.
В итоге, Ваш универсальный драйвер для гипотетического универсального микроконтроллера. В реальности все делается для конкретного железа.
----------------
Если Вы по USB будете писать во флеш, то у вас не предусмотрена синхронизация. В итоге будете писать по 256 байт и медленно.
devprodest Автор
23.04.2023 11:39При чем здесь uart?
Медленно будет если не использовать кэширование, так как будет по две страницы писаться.
Но если записывать только по смене сектора и по таймауту, то будет быстрее.
Вижу, что вы не особо внимательно прочли. 4 килобайта в любом случае выделять, так как стереть можно минимум сектор, а он как раз 4 килобайта. Либо их использовать как временный буфер, либо ещё и в качестве кэша.
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 раз меньше, чем скорость поступления данных. В этом случае можно работать налету , что не требует ни кеш, ни большого буфера.
В любом случае, ограничение скорости записи при программировании флеш связано с низкой скорость физической записи. Ваш драйвер не решает эту проблему.
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 немного помогло бы, тут я согласен, но не кардинально. В довесок мы бы получили отложенный возврат статуса операции и обработки ошибок. Можно использовать неблокирующий способ передачи, но нужно будет дополнительно усложнить логику уровнем выше.
Ваш драйвер не решает эту проблему.
Как раз за счет уменьшения этих операций и решает.
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 раз больше.
devprodest Автор
23.04.2023 11:39Точно-точно, прошу прощения, потерял восьмёрку в расчетах. Однако это не сильно меняет ситуацию. А наоборот показывает, что операция длится ещё дольше и хорошо бы свести лишние записи к минимуму.
nikolz
23.04.2023 11:39-1Тогда мой довод ранее о том, что писать можно налету работает. Я сам так пишу при программировании SoC.
О каких лишних записях Вы говорите?
devprodest Автор
23.04.2023 11:39-1На лету как раз не выйдет, потому что скорость записи существенно меньше скорости поступления данных.
При использовании fatfs и USB MSC у вас будет буфер размером в 512 байт, что всего 2 страницы. При типовой реализации будут лишние записи и стирания, если переписывать данные. Однако если чуть поднакопить изменения в буфере и записать все разом операций будет меньше.
Что именно вы пишете, мне кажется мы говорим о разных применениях?
nikolz
23.04.2023 11:39Непонятны Ваши рассуждения.
То Вы говорите про запись по USB, то про fatfs.
если программируем с компа SoC то никакой fatfs не используется.
Но даже в этом случае скорость записи существенно меньше, чем скорость передачи данных.
Если Вы используете fatfs, то у Вас скорее всего управление файловой системой будет существенно медленнее, чем передача данных.
Чтобы обсуждать предметно, напишите для какого конкретно решения в железе Вы написали свой драйвер. Иначе это беседа о гипотетическом решении.
devprodest Автор
23.04.2023 11:39-1Естественно если вы заливаете прошивку с компа, то запись уже идет большими блоками и уже довольно оптимально. И это совершенно другое. Про программирование с компа речи и не шло, было про оперирование данными в микроконтроллере.
Про USB и fatfs говорю потому, что обе эти возможности должны или могут быть реализованы сразу. И у каждой есть ограничения и особенности.
Можно было бы выделить под USB 4 килобайта, что бы сразу писать, но такой роскоши нет или может не быть.
nikolz
23.04.2023 11:39-1Повторю, то что сказал ранее. Все зависит от конкретного железа.
Допускаю, что Вы сделали решение для Вашего варианта железа, но это не кросс платформенное решение и не универсальное.
Но в статье Вы нет конкретики, а есть претензия на универсальность.
devprodest Автор
23.04.2023 11:39-1От железа зависит только скорость пересылки данных, даже не от самого железа, а от его возможностей.
Реализация алгоритма не связана с конкретным ядром. Возьмите хоть arm, хоть avr, хоть risc-v, все едино. И ничего не поменяется.
Основной посыл не писать сразу данные, а поднакопить их.
devprodest Автор
23.04.2023 11:39Ткните пальцем в код, где есть куски, которые завися от железа.
Реальный пример уже приводил, FatFs и ST USB MSC. Возьмите другую связку, например FatFs + AVR USB Software Library.
При любом раскладе взаимодействие будет сводится к блочному чтению, по умолчанию это будут 2 страницы, если конечно у вас нет большего буфера.
nikolz
23.04.2023 11:39-1любой код - это команды для управления железом. Т е без железа - это бла бла бла.
Код на СИ будет преобразован в код на ASM и его эффективность зависит от компилятора.
Ваш пример не корректный.
Например , FatFS+AVR (какой чип конкретно? какая максимальная у него скорость SPI конкретно?) какая скорость FatFS для этого чипа поиска свободного сектора конкретно
Какой алгоритм загрузки данных в буфер конкретно?
Когда Вы все это учтете, то это и будет конкретный реальный пример.
Но Вы можете этого не делать, а полагать, что все зависит лишь от того, что Вы убрали макросы и комментарии из приведенных Вами аналогов.
---------------
Например, посмотрите это про железо SPI AVR :
devprodest Автор
23.04.2023 11:39-1Код, в частности на си, это не команды для железа, а всего лишь человекочитаемое представление оных.
Эффективность решения задачи зависит не только от того какие вы флаги оптимизации включите, а то, как реализуете алгоритм.
Но Вы можете этого не делать, а полагать, что все зависит лишь от того, что Вы убрали макросы и комментарии из приведенных Вами аналогов.
Всё это называется: "не читал, но осуждаю". Дело не в макросах и комментариях.
Не вижу смысла больше общаться с вами, никаких аргументов вы не приводите.
VT100
23.04.2023 11:39… необходимо после поднятия чип селекта (CS) подождать некоторое время перед следующей командой, такой как запрос статуса занятости. Пока это реализовано с помощью HAL_Delay.
Что-то не припоминаю такой подлости с чтением статуса. Речь именно о 4-битном Винбонде из заголовков?
devprodest Автор
23.04.2023 11:39Там подлость не в чтении статуса, а выдержке с отпущенным чипселектом после операций записи.
В итоге либо делать магическую задержку, либо проверять статус.
Но проверять статус сразу после записи не удается из-за слишком малого времени выдержки.
Микросхем не винбонд, а ZD25Q64B. Она в принципе аналогична. Хотя не исключаю, что это именно её особенность с длинной выдержкой.
nixtonixto
23.04.2023 11:39+1Пример: хотим мы записать 10 страниц с помощью этого алгоритма и будем записывать их не сразу, а по одной, не важно подряд или в хаотичном порядке. В итоге мы 10 раз сотрём и перезапишем сектор.
А кто вас заставляет стирать уже стёртые страницы? Прочитать страницу, и если она вся 0хFF — то можно сразу вписать новую страницу без очистки всего сектора. Накладные расходы на чтение на порядки меньше, чем на стирание, и ресурс экономится. Тем более что при очистке сектора надо будет читать не одну страницу, а сразу весь сектор.
devprodest Автор
23.04.2023 11:39Никто не заставляет, можно и даже нужно. Равно как и запись данных которые уже там есть, расширь проверку не просто на пустоту, но и на равенство.
Это очень хорошая идея
Dark_Purple
23.04.2023 11:39Страници, сектора… Хочу простые и понятные fread и fwrite. ))
devprodest Автор
23.04.2023 11:39Так это доступно уровнем выше))
Пару ключиков для линкера, реализуем функции _write, _read, _lseek и остальные, и дело в шляпе - пользуемся fprintf и всеми остальными благами)
Или можно не сильно заморачиваясь использовать функции fatfs: f_open, f_write, f_read.
INSTE
Вместо
bool write
enum будет выглядеть лучше.Для mutex стандартной терминологией является down/up, выбор take/give неоправдан в случае если код публикуется для масс.
На 144 строке было бы аккуратнее с early-exit и continue.
Где происходит оборачивание навроде
spi_cs_activate()/spi_cs_deactivate()
более аккуратно выглядит static inline функция, внутри которой всего 3 действия: захват-действие-отпуск, где действие - это вызов еще одной вложенной static inline.devprodest Автор
Согласен, будет выглядеть аккуратнее.
Не готов согласиться, down/up не совсем отражает суть действия и сходу понятны куда меньше.
take/give тоже не идеал, но имхо ближе к сути, и для знакомых с freeRTOS намного яснее отражает суть. lock/unlock - тоже было бы норм, но...
Напишите понятнее, что имеется ввиду? Все функции для работы с spi: прижатие чип селекта, приём и передача определены в другом модуле, здесь им делать нечего.
Подробнее, про какой кусок кода идёт речь?
INSTE
В случае с mutex это не "понятнее", это глоссарий. Эти термины, которые несмотря на их возможную противоречивость при переводе, наиболее ожидаемы теми кто читает код.
Примерно вот так с inline
devprodest Автор
Реализация с кучей функций имеет право на существование, но в чем смысл плодить сущности, если и так понятно что происходит?
take/give по нескольким причинам:
Нет мнимого чувство что можно использовать счетный, как это может показаться с down/up
Так исторически сложилось. Самый тупой аргумент, но имеет место быть
Понятность куда важнее, а если имя функции может спровоцировать ошибку, то нафиг оно нужно?
Всеобщая практика показывает: кто как хочет, так и ... Делает.
Спор про мьютекс считаю завершенным.
INSTE
>> Реализация с кучей функций имеет право на существование, но в чем смысл плодить сущности, если и так понятно что происходит?
Завтра вам нужно будет где-то сделать return, и случайно вылетите без анлока. Плюс более строго управляется scope переменных - если нужно что-то использовать/менять в заблокированном контексте, то нужно будет передавать через аргументы и "случайно" оно не вылезет.
>> Спор про мьютекс считаю завершенным.
Хозяин барин конечно, но вот работать на проекте, который как-бы на C или C++, но все ключевые слова там на русском (который потом препроцессится) да переменные все с emoji - не захотел бы, хотя формально все по стандарту да и аргументы можно любые привести под них.
devprodest Автор
По первой части. Где и зачем может потребоваться сделать дополнительный return в функции инициализации? Странно и не нужно.
В других случаях - согласен. Подобный вариант может быть уместен.
По второй. Что-то вы выдумываете и приписываете мне чужие "заслуги" ????
Приведите примеры и аргументы правильности использования варианта, предложенного вами.