Как стать автором
Обновить

51 Атрибут Хорошего С-кода (Хартия Си программистов)

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров26K

Этот текст адресован когорте программистов на С(ях). Это не академические атрибуты из пыльных учебников, это скорее правила буравчика оформления сорцов, которые кристаллизовались на основе многолетней коммерческой разработки firmware. Некоторые приёмы случайно совпали с MISRA, некоторые с CERT-C. А кое-что является результатом множества итераций инспекций программ и перестроек после реальных инцидентов. Что-то покажется очевидным, но, тем не менее, именно это очевидное все и нарушают. В общем тут представлен обогащенный концентрат полезных практик программирования на С(ях).

*1–Все функции должны быть менее 45 строк. Так каждая функция сможет уместиться на одном экране. Это позволит легко анализировать алгоритм и управлять модульностью кода. Также множество мелких функций удобнее покрывать модульными тестами.

*2–Не допускать всяческих магических чисел в коде. Это уничтожает читаемость кода. Все константы надо определять в перечисления заглавными буквами в отдельном файле для каждого программного компонента.

*3–На все сборки должна быть одна общая кодовая база (общак, репа, копилка). Модификация в одном компоненте должна отражаться на всех сборках организации, использующих компонент (например алгоритмы CRC). Это позволит сэкономить время на создание новых проектов для новых программ.

*4–Все .с файлы должны быть оснащены одноименным .h файлом. Так эффективнее переносить, анализировать и мигрировать проекты на очередные аппаратные платформы. И сразу понятно, где следует искать прототипы функций из *.c файлов.

*5–Аппаратно-зависимый код должен быть отделен от аппаратно независимого кода по разным файлам и разным папкам. Так можно тестировать на другой архитектуре платформо-независимые функции и алгоритмы. Всякую математику, калькуляторы всяческих CRC(шек) и работу со строчками.

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

7–Не вставлять функции внутрь if() . Коды возврата приходится анализировать пошаговым отладчиком до проверки условия.

вот это очень плохо:

if (MmGet(ID_IPv4_ROLE, tmp, 1, &tmp_len) != MM_RET_CODE_OK) {
    return ERROR_CODE_HARDWARE_FAULT;
}

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

int ret = MmGet(ID_IPv4_ROLE, tmp, 1, &tmp_len);
if (ret != MM_RET_CODE_OK) {
    return ERROR_CODE_HARDWARE_FAULT;
}

8–Использовать static функции везде, где только можно. Это повысит модульность.

*9–Используй препроцессорный #error для предупреждения о нарушении зависимостей между компонентами.

#ifndef ADC_DRV_H
#define ADC_DRV_H

#ifdef __cplusplus
extern "C" {
#endif

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

#include "adc_bsp.h"
#include "adc_types.h"

#ifndef HAS_MCU
#error "+ HAS_MCU"
#endif

#ifndef HAS_ADC
#error "+ HAS_ADC"
#endif

bool adc_init_channel(uint8_t adc_num, AdcChannel_t adc_channel);
bool adc_init(void);
bool adc_proc(void);
bool adc_channel_read(uint8_t adc_num, uint16_t adc_channel, uint32_t* code);

#ifdef __cplusplus
}
#endif

#endif /* ADC_DRV_H  */

*10--Если что-то можно проверить на этапе make файлов, то это надо проверить на этапе make файлов. Каждый компонент должен проверять, что подключены нужные зависимости. Это можно сделать через условные операторы make файлов.

$(info I2S_MK_INC=$(I2S_MK_INC))
ifneq ($(I2S_MK_INC),Y)
    I2S_MK_INC=Y

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

    I2S_DIR = $(WORKSPACE_LOC)bsp/bsp_stm32f4/i2s
    #@echo $(error I2S_DIR=$(I2S_DIR))

    INCDIR += -I$(I2S_DIR)
    OPT += -DHAS_I2S

    SOURCES_C += $(I2S_DIR)/i2s_drv.c

    ifeq ($(DIAG),Y)
        SOURCES_C += $(I2S_DIR)/i2s_diag.c
    endif

    ifeq ($(CLI),Y)
        ifeq ($(I2S_COMMANDS),Y)
            OPT += -DHAS_I2S_COMMANDS
            SOURCES_C += $(I2S_DIR)/i2s_commands.c
        endif
    endif
endif

*11--Если что-то можно проверить на этапе препроцессора, то это надо проверить на этапе препроцессора. Каждый компонент должен проверять, что подключены нужные зависимости. Это можно сделать через макросы компонентов.

*12–Если что-то можно проверить на этапе компиляции, то это надо проверить на этапе компиляции (static_assert(ы)). Например можно проверить, что в конфигурациях скорость UART не равна нулю. В RunTime не должно быть проверок, которые можно произвести на этапе компиляции, препроцессора или make файлов.

*48--Не дублировать конфиги. Каждый параметр должен конфигурироваться в одном месте программы. Если 2 структуры нуждаются в одинаковых конфигах, то это должно рассчитываться в run-time(е) исходя из главного корневого конфига.

*13–Каждой set функции должна быть поставлена в соответствие get функция. И наоборот. Это позволит написать модульный тест для данного параметра.

*14–Если переменная это физическая величина, то в суффиксе указывать размерность (timeout_ms). Это увеличивает понятность кода.

*15–Все Си-функции должны всегда возвращать код ошибки. Минимум тип bool или числовой код ошибки. Так можно понять, где именно что-то пошло не так. Проще говоря, не должно быть функций, которые возвращают void. Функции void это, по факту, бомбы с часовым механизмом. В один день они отработают ошибочно, а вы об этом ничего даже не узнаете.

*16–Для каждого программного компонента создавать несколько *.с *.h файлов: 

Файл компонента или драйвера

h

c

файл констант

*

файл типов данных

*

файл команд CLI

*

*

файл энергонезависимых параметров

*

файлы конфигурации по умолчанию

*

*

файлы диагностики

*

*

файлы с модульными тестами

*

*

файлы самого драйвера. Функционал и бизнес логика.

*

*

Это позволит ориентироваться в коде и управлять модульностью.

17–Если функция получает указатель, то пусть сразу проверяет на нуль значение указателя. Так прошивки не будут падать при получении нулевых указателей. Это повысит надежность кода. Вы же не знаете как и кто этот код будет испытывать. Хорошая функция всегда проверяет то, что ей дают.

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

19–В идеале все переменные должны иметь разные имена. Так было бы очень удобно делать поиск по grep. Но тут надо искать компромисс с наглядностью.

20–У каждой функции должен быть только 1 return. Это позволит дописать какой-то функционал в конце, зная, что он точно вызовется.

21–Не использовать операторы >, >= Вместо них использовать <, <= просто поменяв местами аргументы там, где это нужно. Это позволит интуитивно проще анализировать логику по коду. Человеку еще со времен школьной математики понятнее, когда то, что слева - то меньше, а то, что справа - то больше. Так как ось X стрелкой показывала вправо. Особенно удобно при проверке переменной на принадлежность интервалу. Получается, что  > и >= это вообще два бессмысленных оператора в языке С.

*22–В проекте обязательно должны быть модульные тесты. Тесты это просто функции, которые вызывают другие функции в run-time. Это позволит сделать безболезненную перестройку кода, когда архитектура начнет скрипеть. Тесты можно вызывать как до запуска приложения, так и по команде из UART- CLI.

*23–Избегайте бесконечных циклов while (1) при блокирующем ожидании чего-либо. Например ожидание прерывания по окончании отправки в UART. Прерывания могут и не произойти из-за сбоя.  while (1) это просто капкан в программировании.  Всегда должен быть предусмотрен аварийный механизм выхода по TimeOut(у) как тут.

bool UartSendWaitLl(uint8_t uart_num, uint8_t* tx_buffer, uint16_t length) {
    bool res = false;
    // We send mainly from Stack. We need wait the end of transfer.
    UartHandle_t* UartNode = UartGetNode(uart_num);
    if(UartNode && tx_buffer && length) {
         UartNode->uart_h->EVENTS_TXDRDY = 0;
         UartNode->uart_h->EVENTS_ENDTX = 0;

         UartNode->uart_h->TXD.PTR = (uint32_t)tx_buffer;
         UartNode->uart_h->TXD.MAXCNT = length;
         UartNode->uart_h->TASKS_STARTTX = 1;
         uint32_t start_ms =  time_get_ms32();
         uint32_t cur_ms = time_get_ms32();
         uint32_t diff_ms = 0;
         while(!UartNode->uart_h->EVENTS_ENDTX) {
            cur_ms = time_get_ms32();
            diff_ms = cur_ms - start_ms;
            if(UART_SEND_TIME_OUT_MS < diff_ms) {
                res = false;
                break;
            }
         }
         res = true;
    }
    return res;
}

24–Использовать макрофункции препроцессора для кодогенерации одинаковых функций или пишите кодогенераторы, если препроцессор запрещен MISRA(ой). Копипаста - причина программных ошибок №1.

*25--Все высокоуровневые функции в конец .с файла. Это избавит от нужды указывать отдельно прототипы static функций. Например поэтому функции xxx_init() xxx_proc() должны быть вообще в самом конце файла. Если проводить аналогию из медицины, то это как центрифугирование крови. Си код тоже надо разделять на фракции!

26–Скрывать область видимости локальных переменных по максимуму.

27–Если код не используется, то этот код не должен собираться. Это уменьшит размер артефактов. Уменьшит вероятность ошибок.

28–(optional) Если Вы в Си передаете что-то через указатель или возвращаете через указатель, то указываете направление движения данных приставками in, io или out и показываете это ключевым словом const.

Например:

bool ProcSomeData(const uint8_t * const in_buffer,
                  uint8_t* const out_buffer, 
                  int len,
                  int * const out_len);

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

29–Давайте переменным осмысленные имена, чтобы было удобно grep(ать) по кодовой базе.

*30--Если в коде есть список чего-либо (прототипы функций, макросы, перечисления), то эти строки должны быть отсортированы по алфавиту. Если сложно сортировать вручную, то можно прибегнуть к помощи консольной утилиты sort. Это позволит сделать визуальный бинарный поиск и найти нужную строчку. Также при сравнении 2-x отсортированных файлов отличия будут минимальные.

32–Функции CamelCase переменные snake_case. Чисто ради наглядности.

*33–Все *.h файлы снабжать защитой препроцессора от повторного включения. Это же касается *.mk файлов. 

*34–Сборка из Makefile(ов) является предпочтительнее чем сборка из GUI-IDE. Makе позволяет по-полной управлять модульностью кодовой базы. Если вы собираете сорцы из Makefile, то вы можете инициировать сборки прямо из командной строки. А это значит, что процесс сборки можно автоматизировать. Прикреплять в Jenkins. А утром контролировать результаты сотен сборок, что отработали ночью. Производительность работы с Makefile выше.

В случае с IDE вам придется вручную водить мышку, чтобы стартонуть сборку. А если что-то случилось с версией IDE, то вы вообще не сможете запустить сборку.
IDE это форма технологического диктата со стороны вендора IDE (IAR, KEIL). Они там будут решать кому можно, а кому нельзя собирать исходники. Вам оно надо?

*35--Для синтаксического разбора регистров использовать объединения вкупе с битовыми полями.

/*Table 15. IB2-ADDR: I0000010*/
typedef union {
    uint8_t reg_val;
    struct{
    	uint8_t clipping_information:1;
	   	uint8_t output_offset_information:1;
	   	uint8_t input_offset_information:1;
	    uint8_t fault_information:1;
	  	uint8_t temperature_warning_information: 3;
	  	uint8_t res:1;
    };
}Fda801RegIb2Addr_t;

Это позволит делать парсинг полей одной строчкой.

    Fda801RegIb2Addr_t  Reg;
    Reg.reg_val = reg_val;

*36–Соблюдать программную иерархичность. Низкоуровневый модуль не должен управлять (вызывать функции) более высокоуровневого модуля. UART не должен вызывать функции LOG. И компонент LOG не должен вызывать функции CLI. Управление должно быть направлено в сторону от более высокоуровневого компонента к более низкоуровневому компоненту. Например CLI->LOG->UART. Не наоборот.

*37–Делать автоматическое форматирование отступов исходного кода. Подойдет например бесплатная утилита clang-format или GNUIndent. Это позволит делать простые выражения при поиске по коду утилитой grep. И будет минимальный diff при сравнении истории файлов. Придерживаться какого-нибудь одного стиля форматирования. Пусть будет "единообразно безобразно".

38--При сравнении переменных с константой константу ставьте слева от оператора ==.

неправильно: if (val == 10 ) doSmth=1;

правильно: if (10 == val) doSmth=1;

Когда константа на первом месте, то компилятор выдаст ошибки присвоение к константе в случае опечатки

if (10=val  ) doSmth=1;

Такая конструкция 

 if (val = 10 ) doSmth=1;

незаметно собирается и вызовет трагедию во время исполнения.

39–В каждом if всегда обрабатывать else вариант даже если else тривиальный. Это позволит предупредить многие осечки в программе.

40–Всегда инициализировать локальные переменные в стеке. Иначе там просто будут случайные значения, которые могут что-нибудь повредить.

*41–Тесты и код разделять на разные компоненты. То есть код и тесты должны быть в разных папках. Включаться и отключаться одной строчкой в make-файле. 

*42–В хорошем С-коде в принципе не должно быть комментариев. Лучший комментарий к коду - это адекватные имена функций и переменных. 

*43–Собирать артефакты как минимум двумя компиляторами (CCS + IAR) или (GCC+GHS) или (Clang+GCC) и тп. Если первый компилятор пропустил ошибку, то второй компилятор может и найти ошибку.

44–Прогонять кодовую базу через статический анализатор. Хотя бы бесплатный CppCheck. Может, найдется очередная загвоздка.

45--За if, for ... всегда должны быть { }. Весьма вероятно, что условие будет пополнено операторами.

46-- Include(ы) всегда должны только содержать только название конечного файла. Include(ы) не должны содержать часть пути к файлу.

#include "C:/Docs/code_base/trunk/utils/data_types/cyclical_buff/cyclical_buff.h"

Вот так гораздо лучше

#include "cyclical_buff.h"

Таким образом вы сможете спокойно перетасовывать файлы в папках проекта и проект по- прежнему будет собираться. И визуально это намного легче читать, поддерживать. А сами пути к заголовочным файлам надо передавать через опцию -I компилятора через make файлы. В коде же #include(ы) должны быть максимально короткими

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

неправильно

const LedConfig_t LedConfig[LED_CNT] = {
       {LED_GREEN_ID,   1000, 0, 60, PORT_C, 13,"Green", LED_MODE_PWM, true,},
};

правильно

const LedConfig_t LedConfig[LED_CNT] = {
       {.num=LED_GREEN_ID,   
        .period_ms=1000, 
        .phase_ms=0, 
        .duty=60,
        .pad.port=PORT_C,
        .pad.pin=13, 
        .name="Green",
        .mode=LED_MODE_PWM, 
        .valid=true,},
  
};

*49--Когда Switch разрастается больше 45 строк, то надо делать статические LookUpTable(лы) (LUTы). Элементом LUT(а) может являться указатель на функцию до 45 строк.

static const AdcChannelInfo_t AdcChannelInfoLut[] = {
    {.code = ADC_CHANNEL_0, .adc_channel = ADC_CHAN_0},  
    {.code = ADC_CHANNEL_1, .adc_channel = ADC_CHAN_1},
       ....
    {.code = ADC_CHANNEL_999, .adc_channel = ADC_CHAN_999},
};

uint32_t AdcChannel2HalChan(AdcChannel_t adc_channel) {
    uint32_t code = 0;
    uint32_t i = 0;
    for(i = 0; i < ARRAY_SIZE(AdcChannelInfoLut); i++) {
        if(AdcChannelInfoLut[i].adc_channel == adc_channel) {
            code = AdcChannelInfoLut[i].code;
            break;
        }
    }
    return code;
}

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

*51 - В проекте не должно быть бесхозных функций, которые никто так или иначе не вызывает из main() во всей кодовой безе. Надо писать код по мере надобности. Код как кристалл должен произрастать из одной точки кристаллизации. Надо собирать Си-код вот с этими опциями компилятора.

OPT += -Werror=unused-but-set-variable
OPT += -Werror=unused-variable
OPT += -Werror=unused-function

Аномалии оформления сорцов из реальной жизни (War Stories)

1–Магические циферки на каждой строчке

2–Переиспользование глобальных переменных

3--Доступ к регистрам микроконтроллера в каждом файле проекта

4–Повторяемость кода

5--Очевидные комментарии

6–"заборы" из комментариев (например ///////////////////////)

7--*.с файлы оснащены разноименными *.h файлами.

16--Статические прототипы функций в *.h файлах.

8--Макросы маленькими буквами

9--Код без модульных тестов

10–Код как в миксере перемешанный с модульними тестами

11--Функции от 1000 до 5000 строк и даже более

12--Вставка препроцессором #include *.c файлов. (Это просто полнейшая школота).

13--Вся прошивка в одном main.c файлике 75000 строк аж подвисает текстовый редактор.

14--С-функции с именами литературных персонажей.

15--Длинные пути к файлам в #includ(ах) (начиная с корня диска С:)

16--Бесхозные функции, которые никто не вызывает во всём проекте.

Вывод
Общая канва такова, что надо писать С-код по таким понятиям как простота, тестопригодность, поддерживаемость, ремонтопригодность, модульность, согласованность (принцип наименьшего удивления), масштабируемость, иерархичность, конфигурируемость, изоляция компонентов и переносимость.

Если вы программируете на С(ях) микроконтроллеры, то можно еще добавить внимание на то, что надо делать UART-CLI для отладки и верификации прошивки в run-time(е), добавлять встроенные модульные тесты, собирать из самописных Makefile(ов) и всё у вас будет очень даже модульно, масштабируемо и гибко.

Если вы практикуете еще какие-то вспомогательные эффективные приемы написания программ на С(ях), то упомяните про них, пожалуйста, в комментариях.

Links

The Power of Ten–Rules for Developing Safety Critical Code http://pixelscommander.com/wp-content/uploads/2014/12/P10.pdf

Сайт с пояснением назначения функций в Си http://all-ht.ru/inf/prog/c/func/_exit.html

Пиши на C как джентльмен https://habr.com/ru/articles/325678/

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы в общем согласны с представленными атрибутами?
86.27% да220
13.73% нет35
Проголосовали 255 пользователей. Воздержался 51 пользователь.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы программируете на C?
78.19% да233
21.81% нет65
Проголосовали 298 пользователей. Воздержались 19 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы программируете на С микроконтроллеры?
54.27% да159
45.73% нет134
Проголосовали 293 пользователя. Воздержались 22 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы программируете на С в Linux ядре?
18.86% да53
81.14% нет228
Проголосовал 281 пользователь. Воздержались 27 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы программируете на С не микроконтроллеры и не ядра ОС?
50.24% да106
49.76% нет105
Проголосовали 211 пользователей. Воздержались 22 пользователя.
Теги:
Хабы:
Всего голосов 50: ↑45 и ↓5+52
Комментарии164

Публикации

Истории

Работа

Программист С
29 вакансий

Ближайшие события

12 – 13 июля
Геймтон DatsDefense
Онлайн
19 сентября
CDI Conf 2024
Москва