Pull to refresh

Архитектура Хорошо Поддерживаемого драйвера для I2C/SPI/MDIO Чипа (или как писать код по понятиям)

Level of difficultyEasy
Reading time11 min
Views8.6K

Итак, вам дали плату, а в ней 4 навороченных умных периферийных чипа с собственными внутренними конфигурационными по SPI/I2C регистрами. Это могут быть такие чипы, как at24cxx, si4703, ksz8081, sx1262, wm8731, tcan4550, fda801, tic12400, ssd1306, dw1000, или drv8711. Не важно, какой конкретно чип. Все они работают по одному принципу. Прописываешь по проводному интерфейсу чиселки в их внутренние регистры и там внутри чипа заводится какая-то электрическая цепочка. Easy.

Допустим, что на GitHub драйверов для вашего I2C/SPI чипа нет или качество этих open sourсe драйверов оставляет желать лучшего. Как же оформить и собрать качественный драйвер для I2C/SPI/MDIO чипа? И почему это вообще важно?

Смоки, тут не Вьетнам, это — боулинг, здесь есть правила.

Понятное дело, что нужно, чтобы драйвер был модульным, поддерживаемым, тесто-пригодным, диагностируемым, понятным. Прежде всего надо понять, как организовать структуру файлов с драйвером. Это можно сделать так:

*.с/*xxx_drv.h файлы с самим функционалом.*

Должна быть функция инициализации, обработчика в цикле, проверка Link(а), функции чтения и записи регистра по адресу. Плюс набор высокоуровневых функций для установки и чтения конкретных параметров. Это тот минимум минимумов, на котором большинство разработчиков складывает руки. Далее следует материал уровня аdvanced.

*xxxxx_isr.с/*xxxxx_isr.h Отдельные файлы с кодом драйвера, который должен отрабатывать в обработчике прерываний

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

Отдельный *xxxxx_types.h файл с перечислением типов*

В этом файле следует определить основные типы данных для данного программного компонента. Также определить объединения и битовые поля для каждого регистра.

/* page 105
 * 7.2.27 Register file: 0x19 – DW1000 State Information*/
typedef union {
    uint8_t byte[4];
    uint32_t dword;
    struct {
        uint32_t tx_state : 4;     /*bit 0-3: TX_STATE*/
        uint32_t res1 : 4;      /*bit 4-7: */
        uint32_t rx_state: 5; /*bit 8-12: RX_STATE*/
        uint32_t res2 : 3;      /*bit 13-15:*/
        uint32_t pmsc_state : 4;     /*bit 16-19: PMSC_STATE*/
        uint32_t res3 : 12;     /*bit 20-31:*/
    };
} Dwm1000SysState_t;

Делать отдельный *.h файл для перечисления типов данных это не блажь. Хранить код и данные в разных местах - это основной принцип гарвардской архитектуры компьютеров. Этого же принципа логично придерживаться и в разработке кода.

Harvard architecture
Harvard architecture

Отдельный *xxxx_const.h файл с перечислением констант*

Тут надо определить адреса регистров, перечисления. Это очень важно быстро найти файл с константами и отредактировать их, поэтому для констант делаем отдельный *.h файл.

typedef enum {
    BIT_RATE_110_KBPS = 0,  /*110 kbps*/
    BIT_RATE_850_KBPS = 1,  /*850 kbps*/
    BIT_RATE_6800_KBPS = 2, /*6.8 Mbps*/
    BIT_RATE_RESERVED = 3,  /*reserved*/

    BIT_RATE_UNFED = 4,
} Dwm1000BitRate_t;

*xxxx_param.h файл с параметрами драйвера*

Каждый драйвер нуждается в энергонезависимых параметрах с настройками (битовая скорость, CAN-трансивера или несущая частота радиопередатчика). Именно эти настройки будут применятся при инициализации при старте питания. Параметры позволят существенно изменять поведение всего устройства без нужды пересборки всех сорцов. Просто прописали через CLI параметры и перезагрузились. И у вас новый функционал. Успех! Поэтому надо где-то указать как минимум тип данных и имя параметров драйвера.

#ifndef SX1262_PARAMS_H
#define SX1262_PARAMS_H

#include "param_drv.h"
#include "param_types.h"

#ifdef HAS_GFSK
#include "sx1262_gfsk_params.h"
#else
#define PARAMS_SX1262_GFSK
#endif

#ifdef HAS_LORA
#include "sx1262_lora_params.h"
#else
#define PARAMS_SX1262_LORA
#endif

#define PARAMS_SX1262       \
    PARAMS_SX1262_LORA      \
    PARAMS_SX1262_GFSK      \
    {SX1262, PAR_ID_FREQ, 4, TYPE_UINT32, "Freq"},   /*Hz*/                              \
    {SX1262, PAR_ID_WIRELESS_INTERFACE, 1, TYPE_UINT8, "Interface"},    /*LoRa or GFSK*/          \
    {SX1262, PAR_ID_TX_MUTE, 1, TYPE_UINT8, "TxMute"},                                        \
    {SX1262, PAR_ID_RX_GAIN, 1, TYPE_UINT8, "RxGain"},         \
    {SX1262, PAR_ID_RETX, 1, TYPE_UINT8, "ReTx"},              \
    {SX1262, PAR_ID_IQ_SETUP, 1, TYPE_UINT8, "IQSetUp"},                                         \
    {SX1262, PAR_ID_OUT_POWER, 1, TYPE_INT8, "OutPower"}, /*loRa output power*/          \
    {SX1262, PAR_ID_MAX_LINK_DIST, 8, TYPE_DOUBLE, "MaxLinkDist"}, /*Max Link Distance*/ \
    {SX1262, PAR_ID_MAX_BIT_RATE, 8, TYPE_DOUBLE, "MaxBitRate"}, /*Max bit/rate*/   \
    {SX1262, PAR_ID_RETX_CNT, 1, TYPE_UINT8, "ReTxCnt"},


#endif /* SX1262_PARAMS_H  */

*config_xxxx.с/*config_xxxx.h файл с конфигурацией по умолчанию*

После старта питания надо как-то проинициализировать драйвер. Особенно при первом запуске, когда FlashFs/NVRAM ещё пустая. Для этого создаем отдельные файлы для конфигов по умолчанию. Это способствует методологии "код отдельно, конфиги отдельно".

#include "ds3231_config.h"

#ifdef HAS_I2C
#include "i2c_drv.h"
#endif

#include "data_utils.h"
#include "ds3231_types.h"

const Ds3231Config_t Ds3231Config[]={
    {.num=1, .i2c_num=1, .valid=true, .hour_mode=HOUR_MODE_24H,},
};

Ds3231Handle_t Ds3231Item[]={
    {.num=1, .valid=true, .init=false,}
};

const Ds3231RegConfig_t Ds3231RegConfig[]={
};

uint32_t ds3231_get_reg_config_cnt(void){
    uint8_t cnt=0;
    cnt = ARRAY_SIZE(Ds3231RegConfig);
    return cnt;
}


uint32_t ds3231_get_cnt(void){
    uint8_t cnt=0;
    cnt = ARRAY_SIZE(Ds3231Config);
    return cnt;
}

--Драйвер должен легко масштабироваться

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

*xxxx_commands.с/*xxxx_commands.h файл с командами CLI*

У каждого взрослого компонента должна быть ручка для управления. В мире компьютеров исторически еще со времен UNIX (в 197x) такой "ручкой" является интерфейс командной строки (CLI) поверх UART. Поэтому создаем отдельные файлы для интерпретатора команд для каждого конкретного драйвера. Буквально 3-4 команды: инициализация, диагностика, get /set регистра. Так можно будет изменить логику работы драйвера в Run-Time. Вычитать сырые значения регистров, прописать конкретный регистр. Показать диагностику, серийный номер, ревизию , пулять пакеты в I2C, SPI, UART, MDIO и т. п.

#ifndef DWM1000_COMMANDS_H
#define DWM1000_COMMANDS_H

#include <stdbool.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

#ifndef HAS_DIAG
#error "+ HAS_DIAG"
#endif

#ifndef HAS_DWM1000
#error "+ HAS_DWM1000"
#endif

#ifndef HAS_DWM1000_COMMANDS
#error "+HAS_DWM1000_COMMANDS"
#endif

bool dwm1000_read_register_command(int32_t argc, char* argv[]);
bool dwm1000_tx_buff_command(int32_t argc, char* argv[]);
bool dwm1000_read_reg_file_command(int32_t argc, char* argv[]);
bool dwm1000_read_offset_command(int32_t argc, char* argv[]);
bool dwm1000_read_tx_buff_command(int32_t argc, char* argv[]);
bool dwm1000_read_rx_buff_command(int32_t argc, char* argv[]);
bool dwm1000_write_tx_buff_command(int32_t argc, char* argv[]);
bool dwm1000_init_command(int32_t argc, char* argv[]);
bool dwm1000_diag_command(int32_t argc, char* argv[]);
bool dwm1000_reset_command(int32_t argc, char* argv[]);

#define DWM1000_COMMANDS                                                   \
    CLI_CMD( "drtb", dwm1000_read_tx_buff_command, "Dwm1000ReadTxBuff"),   \
    CLI_CMD( "drrb", dwm1000_read_rx_buff_command, "Dwm1000ReadTxBuff"),   \
    CLI_CMD( "dtb", dwm1000_tx_buff_command, "Dwm1000TxBuff"),             \
    CLI_CMD( "dwt", dwm1000_write_tx_buff_command, "Dwm1000WriteTxBuff"),  \
    CLI_CMD( "drr", dwm1000_read_register_command, "Dwm1000ReadReg"),      \
    CLI_CMD( "dro", dwm1000_read_offset_command, "Dwm1000ReadOffSet"),     \
    CLI_CMD( "dwd", dwm1000_diag_command, "Dwm1000Diag"),                  \
    CLI_CMD( "dwi", dwm1000_init_command, "Dwm1000Init"),                  \
    CLI_CMD( "dwr", dwm1000_reset_command, "Dwm1000Reset"),

#ifdef __cplusplus
}
#endif

#endif /* DWM1000_COMMANDS_H */

*xxxx_diag.с/*xxxx_diag.h файлы с диагностикой*

У каждого драйвера есть куча всяческих констант. Значения подобраны вендором обычно рандомно. Эти константы надо интерпретировать в строки для человека-понимания. Поэтому создается файл с Hash функциями. Суть проста: даешь бинарное значение константы и тут же получаешь её значение в виде текстовой строчки. Эти Hash функции как раз вызывает CLI(шка) и компонент логирования при логе инициализации board(ы).

const char* DacLevel2Str(uint8_t code){
    const char *name="?";
    switch(code){
    case DAC_LEV_CTRL_INTERNALY: name="internally"; break;
    case DAC_LEV_CTRL_LOW:       name="low"; break;
    case DAC_LEV_CTRL_MEDIUM:    name="medium"; break;
    case DAC_LEV_CTRL_HIGH:      name="high"; break;
    }
    return name;
}

*test_xxxxx.с/*test_xxxxx.h Файлы с модульными тестами диагностикой*

Драйвер должен быть покрыт модульными тестами (скрепы). Это позволит делать безопасное перестроение кода с целью его упрощения. Тесты нужны для отладки большого куска кода, который трудно проходить пошаговым отладчиком. Тесты позволят быстрее делать интеграцию. Помогут понять, что сломалось в случае ошибок. Т.е. тесты позволяют сэкономить время на отладке. Тесты будут поощрять вас писать более качественный и структурированный код.

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

Make файл *xxxxx.mk для правил сборки драйвера из Make*

Сборка из Make это самый мощный способ управлять модульностью и масштабируемостью любого кода. С make можно производить выборочную сборку драйвера в зависимости от располагаемых ресурсов на печатной плате. Код станет универсальным и переносимым. При сборке из Makefile(ов) надо для каждого логического компонента или драйвера вручную определять make файл. Make - это целый отдельный язык программирования со своими операторами и функциями. Спека GNU Make всего навсего это 224 страницы.

ifneq ($(SI4703_MK_INC),Y)
    SI4703_MK_INC=Y

    mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
    $(info Build  $(mkfile_path) )

    SI4703_DIR = $(WORKSPACE_LOC)Drivers/si4703
    #@echo $(error SI4703_DIR=$(SI4703_DIR))

    INCDIR += -I$(SI4703_DIR)

    OPT += -DHAS_SI4703
    OPT += -DHAS_MULTIMEDIA
    RDS=Y

    FM_TUNER=Y
    ifeq ($(FM_TUNER),Y)
        OPT += -DHAS_FM_TUNER
    endif
    
    SOURCES_C += $(SI4703_DIR)/si4703_drv.c
    SOURCES_C += $(SI4703_DIR)/si4703_config.c

    ifeq ($(RDS),Y)
        OPT += -DHAS_RDS
        SOURCES_C += $(SI4703_DIR)/si4703_rds_drv.c
    endif

    ifeq ($(DIAG),Y)
        ifeq ($(SI4703_DIAG),Y)
            SOURCES_C += $(SI4703_DIR)/si4703_diag.c
        endif
    endif

    ifeq ($(CLI),Y)
        ifeq ($(SI4703_COMMANDS),Y)
            OPT += -DHAS_SI4703_COMMANDS
            SOURCES_C += $(SI4703_DIR)/si4703_commands.c
        endif
    endif

endif

Вот так должен примерно выглядеть код драйвера в папке с проектом:

Со структурой драйвера определились. Теперь буквально несколько слов о функционале обобщенного драйвера.

*9--Добавить xxxxx_dep.h файл с проверками зависимостей на фазе препроцессора. Это позволит отловить на стадии компиляции ошибки отсутствия драйверов, которые нужны для этого драйвера.

#ifndef DWM3000_DEPENDENCIES_H
#define DWM3000_DEPENDENCIES_H

#ifndef HAS_DWM3000
#error "+HAS_DWM3000"
#endif

#ifndef HAS_SPI
#error "+HAS_SPI"
#endif

#ifndef HAS_GPIO
#error "+HAS_GPIO"
#endif

#ifndef HAS_MCU
#error "+HAS_MCU"
#endif

#ifndef HAS_LIMITER
#error "+HAS_LIMITER"
#endif

#endif /* DWM3000_DEPENDENCIES_H  */

10--xxx.gvi файл на языке Graphviz явно указывающий зависимости внутри программного компонента. Это надо для авто генерации документации

subgraph cluster_Keepass {
    style=filled;
    color=khaki1;
    label = "Keepass";

    Base64->KEEPASS
    Salsa20->KEEPASS
    LIFO->XML
    GZip->KEEPASS
    AES256->KEEPASS
    XML->KEEPASS
    SHA256->KEEPASS
    COMPRESSION->KEEPASS
}

Подробнее про это можно почитать тут https://habr.com/ru/articles/765424/

1--Должна быть инициализация чипа, функция bool xxxx_init(void). Причем повторная инициализация драйвера или любого другого программного компонента не должна приводить к зависанию прошивки или иным ошибкам. Первоначальная проверка link(а), запись строчки в логе загрузки, прописывание либо конфигов по умолчанию, либо конфигов из on-chip Nor FlashFs, определение уровня логирования для данного компонента.

2--У каждого драйвера должны быть счетчики разнородных событий: количество отправок, приёмов, счетчики ошибок, прерываний. Это нужно для процедуры health monitor. Чтобы драйвер сам себя периодически проверял на предмет накопления ошибок и в случае обнаружения мог отобразить в лог (UART или SD карта) красный текст.

8--Если ваш чип с I2C, то вам очень-очень повезло, так как в интерфейсе I2C есть бит подтверждения адреса и можно разом просканировать всю I2C шину. Драйвер I2C должен обязательно поддерживать процедуру сканирования шины и печатать таблицу доступных адресов. Вот как тут.

3--У каждого драйвера должна быть функция вычитывания всех сырых регистров разом. Это называется memory blob. Так как поведение чипа целиком и полностью определяется значениями его внутренних регистров. Вычитывание memory blob(а) позволит визуально сравнить конфигурацию с тем, что прописано в datasheet(е) и понять в каком режиме чип работает прямо сейчас.

4--Должен быть механизм непрерывной проверки SPI/I2C/MDIO link(а). Это позволит сразу определить проблему с проводами, если произойдет потеря link(а). Обычно в нормальных чипах есть регистр ChipID (например DW1000). Прочитали регистр ID, проверили с тем, что должно быть в спеке(datasheet), значение совпало - значит есть link. Успех! С точки зрения надежности не стоит вообще закладывать в проект чипы без ChipID именно по этой причине, что их иначе невозможно протестировать.

5--Должен быть предусмотрен механизм записи и чтения отдельных регистров из командной строки поверх UART. Это поможет воспроизводить и находить ошибки далеко в run-time(е).

*6--В суперцикле должна быть функция xxxxx_proc() для опроса (poll(инга)) регистров чипа, его переменных и событий. Эта функция будет синхронизировать удаленные регистры чипа и их отражение в RAM памяти микроконтроллера. Эта функция proc, в сущности, и будет делать всю основную работу по функционалу и бизнес логике драйвера. Она может работать как в суперцикле, так и в отдельном потоке.

7(Advanced)--Должна быть диагностика чипа. В идеале даже встроенный интерпретатор регистров каждого битика, который хоть что-то значит в карте регистров микросхемы. Либо, если нет достаточно On-Chip NorFlash(а), должна быть отдельная DeskTop утилита для полного и педантичного синтаксического разбора memory blob(а), вычитанного из UART. Типа такой: https://github.com/aabzel/tja1101-register-value-blob-parser Так как визуально анализировать переменные, глядя на поток нулей и единиц, если вы не выучили в школе шестнадцатиричную таблицу умножения, весьма трудно и можно легко ошибиться. Поэтому интерпретатор регистров понадобится при сопровождении и отладке гаджета.

*8--У каждого компонента должна быть версия. Должна быть поддержка чтения версии компонента в run-time

#define KEEPASS_COMPONENT_VERSION "1.2"

При каждом изменении в коде драйвера версию надо увеличивать.

9--Если у чипа есть внутренние состояния (Idle, Rx, Tx и проч), то об изменении состояния надо сигнализировать в UART Log. Это нужно для отладки драйвера чипа.

*10--Если ваш чип является трансивером в какой-то физический интерфейс, будь-то проводной (10BASE5, CAN, LIN, 1-Wire, RS485, MIL-STD-1553, ARINC) или беспроводной (LoRa, UWB, GFSK), то чип должен периодически посылать Hello пакеты в эфир. Их еще называют Blink пакет или Heartbeat сообщение. Это позволит другим устройствам в сети понять, кто вообще живет на шине, а кто вовсе завис.

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

12--Должен быть файл xxxx_preconfig.mk. Дело в том что перед запуском сборки хорошо бы проинициализировать переменные окружения, которые нужны для данной конкретной сборки. Часть этих переменных можно прописать в корневом config.mk. Однако можно случайно упустить какие-то конкретные зависимости. По этой причине каждый драйвер должен содержать файл xxxx_preconfig.mk для явного определения зависимостей.

$(info AT24CXX_PRECONFIG_MK_INC=$(AT24CXX_PRECONFIG_MK_INC) )

ifneq ($(AT24CXX_PRECONFIG_MK_INC),Y)
    AT24CXX_PRECONFIG_MK_INC=Y

    TIME=Y
    AT24CXX=Y
    I2C=Y
    GPIO=Y
endif

*13--Если есть функция, которая что-то устанавливает, то должна быть и функция, которая это что-то прочитывает. Проще говоря, у каждого setter(а) должен быть getter, подобно тому к в математике у каждой функции есть обратная функция.

Суммируя вышесказанное получается вот такой список необходимых файлов

Вывод

Это базис любого драйвера. Своего рода ортодоксально-каноническая форма. Или просто строительные леса. Остальной код зависит уже от конкретного ASIC чипа, будь это чип управления двигателем, беспроводной трансивер, RTC или простой датчик давления. Как видите, чтобы написать адекватный драйвер чипа надо учитывать достаточно много нюансов и проделать некоторую инфраструктурную работу.

Не стесняйтесь разбивать драйвер на множество файлов. Это потом сильно поможет при custom(мизации), переносе и упаковке драйвера в разные проекты с разными ресурсами.

Если есть замечания на тему того, какими еще атрибутами должен обладать обобщенный драйвер периферийного I2C/SPI/MDIO чипа, то пишите в комментариях.

Links

https://docs.google.com/spreadsheets/d/15NytV9HHFhiZxEC8OUhsROoK6hX6QQpPPZqc2VgSTUY/edit#gid=0

Only registered users can participate in poll. Log in, please.
Вам случалось писать драйвер для какого-нибудь периферийного I2C/SPI/MDIO чипа?
71.17% да79
28.83% нет32
111 users voted. 4 users abstained.
Only registered users can participate in poll. Log in, please.
Вы в общем согласны с отмеченными в тексте атрибутами?
70.11% да61
29.89% нет26
87 users voted. 16 users abstained.
Only registered users can participate in poll. Log in, please.
Вы работали с интерфейсом I2C?
91.21% да83
8.79% нет8
91 users voted. 1 user abstained.
Only registered users can participate in poll. Log in, please.
Вы работали с интерфейсом SPI?
90.11% да82
9.89% нет9
91 users voted. 1 user abstained.
Only registered users can participate in poll. Log in, please.
Вы работали с интерфейсом MDIO?
28.57% да24
71.43% нет60
84 users voted. 6 users abstained.
Only registered users can participate in poll. Log in, please.
Вы писали на DeskTop(е) утилиты-интерпретаторы регистров какого-нибудь I2C/SPI чипа?
24.24% да8
75.76% нет25
33 users voted. 1 user abstained.
Tags:
Hubs:
Total votes 25: ↑18 and ↓7+11
Comments26

Articles