Передача нескольких флагов и небольших перечислений в качестве аргумента функции: самодельные битовые маски целых чисел или небольшая структура?

Я работаю со встроенным материалом (PIC), и во всех кодах C, которые я видел, если функция принимает несколько флагов или небольших перечисляемых значений, это делается в виде битовых масок int, сделанных вручную, например:

/* first flag */
#define  MY_FIRST_FLAG              (1 << 0)

/* second flag */
#define  MY_SECOND_FLAG             (1 << 1)

/*
 * some param that could have three variants
 */
#define  __MY_TRISTATE_OFFSET       2
#define  __MY_TRISTATE_MASK         (0x03 << __MY_TRISTATE_OFFSET)
#define  MY_TRISTATE_ONE            (0 << __MY_TRISTATE_OFFSET)
#define  MY_TRISTATE_TWO            (1 << __MY_TRISTATE_OFFSET)
#define  MY_TRISTATE_THREE          (2 << __MY_TRISTATE_OFFSET)

/* third flag */
#define  MY_THIRD_FLAG              (1 << 4)



void my_func(int opts)
{
   if (opts & MY_FIRST_FLAG){
      /* ... */
   }

   switch (opts & __MY_TRISTATE_MASK){
      case MY_TRISTATE_ONE:
         /* ... */
         break;
      case MY_TRISTATE_TWO:
         /* ... */
         break;
      case MY_TRISTATE_THREE:
         /* ... */
         break;
      default:
         /* error! */
         break;
   }

   /* ... */
}

Но мне не очень нравится этот "компьютерный" подход:

  • Типобезопасности во время компиляции вообще нет, можно по ошибке сделать следующее: my_func(MY_TRISTATE_ONE | MY_TRISTATE_TWO); и он скомпилируется.
  • Опять же, типобезопасность: если у нас есть другая функция с другими флагами, мы можем свободно передавать флаги для другой функции, и компилятор тут не поможет;
  • Легко ошибиться при определении всех этих флагов; если нам нужно добавить больше флагов или параметров, мы должны быть очень осторожны, чтобы не перепутать смещение.
  • Что, если однажды наше тройное состояние станет некоторой переменной с 5 состояниями? Затем мы должны изменить его маску, а также изменить смещения всех следующих за ним параметров. Если мы ошибемся здесь, никто нам не поможет. Поэтому, если что-то пойдет не так, мы должны пересматривать эту часть кода снова и снова.

Единственным преимуществом такого подхода является бинарная совместимость: данные организованы одинаково на всех платформах, поэтому разные устройства/программы могут взаимодействовать с этим интерфейсом. Но меня это почти всегда не волнует: большая часть кода работает только на одном конкретном устройстве, нет необходимости сохранять бинарную совместимость с другим миром.

Итак, для меня гораздо лучше определить небольшую структуру:

enum my_tristate_e {
   MY_TRISTATE_ONE,
   MY_TRISTATE_TWO,
   MY_TRISTATE_THREE,
};

struct my_func_opts_s {
   unsigned             my_first_flag  : 1;
   unsigned             my_second_flag : 1;
   enum my_tristate_e   my_tristate    : 2;
   unsigned             my_third_flag  : 1;
};


void my_func(struct my_func_opts_s opts)
{
   if (opts.my_first_flag){
      /* ... */
   }

   switch (opts.my_tristate){
      case MY_TRISTATE_ONE:
         /* ... */
         break;
      case MY_TRISTATE_TWO:
         /* ... */
         break;
      case MY_TRISTATE_THREE:
         /* ... */
         break;
      default:
         /* error! */
         break;
   }

   /* ... */
}

Преимущества такого подхода:

  • Мы не можем по ошибке дать другую структуру my_func (скажем, у нас есть другая структура с параметрами для другой функции), компилятор выдаст ошибку, если мы попытаемся;
  • Нам не нужно копаться во всех офсетах; компилятор делает это за нас: так что здесь мы не можем ошибиться;
  • Если однажды наше tristate станет переменной с 5 состояниями и мы забудем изменить ширину поля с 2 на 3, компилятор предупредит нас, что поле слишком узкое;
  • Поскольку компилятор может реализовать эти битовые поля по своему усмотрению, вполне вероятно, что он будет генерировать более оптимизированный код на любой платформе.

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

Кто-то может возразить, что вызов этой функции выглядит слишком громоздко: в случае с флагами int мы имеем:

my_func(MY_TRISTATE_TWO | MY_FIRST_FLAG);

Но в случае структуры:

my_func2((struct my_func_opts_s){
      .my_first_flag = true,
      .my_tristate   = MY_TRISTATE_TWO,
      });

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

Я проверил сгенерированный дизассемблированный MIPS в обоих случаях (ну, мне пришлось немного изменить функции, чтобы заставить их что-то делать, иначе они оптимизируются компилятором), и сгенерированный код практически идентичен (оптимизация -Os, что очень часто бывает во встраиваемом мире):

Но этот подход никогда (или почти никогда) не используется. Почему? Я пропустил что-то важное?


person Dmitry Frank    schedule 14.09.2014    source источник
comment
Знаете ли вы, что можете использовать enum вместо #define для обеспечения безопасности типов?   -  person André Sassi    schedule 15.09.2014
comment
@AndréSassi Я знаю, что могу использовать enum, и иногда я делаю это для некоторой удобочитаемости кода, но, насколько я знаю, нет никакой безопасности типов, которую я мог бы извлечь из этого: я могу легко выполнять побитовое OR для элементов из разных перечислений или смешайте значения перечисления и макросы вместе, и компилятор молча разрешит это. О каком типе безопасности вы говорите?   -  person Dmitry Frank    schedule 15.09.2014


Ответы (2)


Одним из недостатков использования битовых полей в struct является то, что вы не можете записывать флаги непосредственно при вызове функции, если у вас нет доступа к «составным литералам» из C99.

Допустим, у вас есть битовое поле, которое выглядит так:

struct bit_field {
    unsigned int a:1;
    unsigned int b:1;
    unsigned int c:1;
    unsigned int d:1;
};

И функция, которая использует битовое поле, объявленное следующим образом:

void use_bit_field(struct bit_field a);

Если вы пишете на C99, вы можете написать вызов функции так, как вы это сделали:

use_bit_field((struct bit_field){
    .a = 1,
    .b = 0,
    .c = 1,
    .d = 0
});

Что выглядит очень хорошо! Но чтобы это работало, нам нужно иметь возможность использовать «составные литералы», что является функцией C99. Вы, наверное, уже знаете, что C99, к сожалению, не так широко доступен, как C89.

Люди, у которых нет доступа к C99, должны написать вызов функции следующим образом:

struct bit_field bits = {
    1, 1, 0, 1 // EDIT: Initializing the struct step by step is alot less error prone (mentioned in the comments).
};

/* ... */

use_bit_field(bits);

Что, по мнению некоторых людей, намного менее ясно, чем:

use_bit_field(BITFIELD_A | BITFIELD_B | BITFIELD_D);

Но, конечно, это всего лишь вопрос предпочтений.

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

Вы не можете написать:

if (bit_field1 & bit_field2)

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

if (*(int8_t *)&bit_field1 & *(int8_t *)&bit_field2)

Это недостатки, о которых я мог подумать. Лично я по-прежнему считаю, что определение битовых полей в struct является лучшим подходом.

person wefwefa3    schedule 14.09.2014
comment
struct bit_field bits = { 1, 1, 0, 1 }; совершенно неприемлем: он гораздо более подвержен ошибкам, чем простые битовые маски. Что если порядок полей когда-нибудь изменится? Это могло привести к действительно трудноуловимым багам, однажды мне уже приходилось иметь дело именно с таким багом. Если у нас нет C99, гораздо лучше инициировать шаг за шагом, например: struct bit_field bits = {}; bits.a = 1; bits.b = 1; /*etc*/. Выглядит не идеально, но гораздо стабильнее. Но, к счастью, в наши дни C99 присутствует в большинстве компиляторов, даже в современных встраиваемых компиляторах Microchip, которые, как правило, дрянные. Так что мне было бы все равно, если бы не C99. - person Dmitry Frank; 14.09.2014
comment
В случае, если вам интересно: посмотрите мой собственный ответ на этот вопрос, к сожалению, я обнаружил серьезный недостаток, связанный с gcc, в использовании структуры вместо битовой маски int. - person Dmitry Frank; 01.10.2014

К сожалению, я обнаружил серьезный недостаток, связанный с gcc, в использовании структуры вместо битовой маски int: когда оптимизация равна 0, каждый вызов такой функции занимает намного больше стека.

Оптимизация 0 иногда полезна, если нам нужно что-то отладить.

Я работаю со встроенными программами (сейчас PIC32), компилятор для PIC32 основан на gcc. Когда я отключил оптимизацию, у меня появились странные ошибки, после нескольких часов отладки я выяснил, что это из-за переполнения стека: одна функция, которая много раз передает в качестве параметра небольшую структуру, занимает около 1400 байт стека. Когда оптимизация s, она занимает 300 байт стека.

Итак, когда я переписал материал так, что вместо struct передается int, код, сгенерированный с оптимизацией 0, также занимает 300 байтов стека.

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

person Dmitry Frank    schedule 30.09.2014
comment
Для меня это новость, интересная находка. Я не знаю, поддерживает ли этот компилятор расширения gcc, но если поддерживает: пробовали ли вы использовать __attribute__((packed)) в структуре? - person wefwefa3; 01.10.2014
comment
Он поддерживает это расширение, но я не думаю, что это поможет. Хотя, возможно, я попробую, спасибо. См. мой старый вопрос о структурах в стеке, это, вероятно, связано: stackoverflow.com/questions/14807016/ - person Dmitry Frank; 02.10.2014