Следует ли по-прежнему использовать volatile для обмена данными с ISR в современном C++?

Я видел некоторые варианты этих вопросов и видел смешанные ответы, но все еще не уверен, что они актуальны и полностью применимы к моему варианту использования, поэтому я спрошу здесь. Дайте мне знать, если это дубликат!

Учитывая, что я разрабатываю микроконтроллеры STM32 (голое железо) с использованием С++ 17 и набора инструментов gcc-arm-none-eabi-9:

Нужно ли мне по-прежнему использовать volatile для обмена данными между ISR и main()?

volatile std::int32_t flag = 0;

extern "C" void ISR()
{
    flag = 1;
}

int main()
{
    while (!flag) { ... }
}

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

Однако для случая использования ISR я не знаю, можно ли считать это случаем многопоточности или нет. В этом случае люди рекомендуют использовать новые функции многопоточности С++ 11 (например, std::atomic). Я знаю о разнице между volatile (не оптимизировать) и atomic (безопасный доступ), поэтому ответы, предлагающие std::atomic, меня здесь смущают.

В случае реальной многопоточности в системах x86 я не видел необходимости использовать volatile.

Другими словами: может ли компилятор знать, что flag может измениться внутри ISR? Если нет, то как он может узнать об этом в обычных многопоточных приложениях?

Спасибо!


person user1011113    schedule 18.08.2020    source источник
comment
Вы должны использовать volatile, чтобы сообщить компилятору, что внутри основного flag компилятор может изменить его без уведомления. std::atomic тоже подойдет, но в данном случае это не особо нужно.   -  person HS2    schedule 18.08.2020
comment
@HS2: при использовании clang/gcc, если не используется ни atomic, ни clang/gcc __asm, операции с флагом готовности данных volatile могут быть переупорядочены относительно операций с буфером, для защиты которого использовался этот флаг. .   -  person supercat    schedule 18.08.2020
comment
@supercat Верно, и изменение порядка не регулируется стандартом, только последовательная согласованность. Но если я не ошибаюсь, это был не первоначальный вопрос.   -  person HS2    schedule 19.08.2020
comment
@supercat И да, когда дело доходит до, скажем, семантики семафора / мьютекса, необходимо учитывать потенциальное переупорядочение, спекулятивное выполнение и предварительную выборку.   -  person HS2    schedule 19.08.2020
comment
@HS2: В одноядерной системе при использовании компилятора, который рассматривает volatile как глобальный барьер для переупорядочения компилятора, volatile будет надежно работать для координации действий с ISR. При использовании clang и gcc семантика volatile слишком слаба, чтобы ее можно было использовать для этой цели без использования встроенных функций затирания памяти.   -  person supercat    schedule 19.08.2020
comment
Существует также стандартный sig_atomic_t, который равен the (possibly volatile-qualified) integer type of an object that can be accessed as an atomic entity, even in the presence of asynchronous interrupts.   -  person KamilCuk    schedule 20.08.2020
comment
@KamilCuk: Это, как правило, имеет несколько ограниченную полезность, поскольку большинство реализаций могут предлагать семантические гарантии, которые сильнее, чем требует Стандарт, и многие задачи были бы непрактичными, если не полностью невозможными, без таких гарантий.   -  person supercat    schedule 20.08.2020


Ответы (4)


Я думаю, что в этом случае и volatile, и atomic, скорее всего, будут работать на практике на 32-битной ARM. По крайней мере, в более старой версии инструментов STM32 я видел, что на самом деле атомарность C была реализована с использованием volatile для небольших типов.

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

Однако сгенерированный код должен отличаться для типов, которые нельзя загрузить в одной инструкции. Если вы используете volatile int64_t, компилятор с радостью загрузит его двумя отдельными инструкциями. Если ISR выполняется между загрузкой двух половин переменной, вы загрузите половину старого значения и половину нового значения.

К сожалению, использование atomic<int64_t> также может привести к сбою с подпрограммами обслуживания прерываний, если реализация не является свободной от блокировок. Для Cortex-M 64-битные доступы не обязательно не блокируются, поэтому на атомарность не следует полагаться без проверки реализации. В зависимости от реализации система может заблокироваться, если механизм блокировки не является реентерабельным и прерывание происходит, пока блокировка удерживается. Начиная с C++17, это можно запросить, проверив atomic<T>::is_always_lock_free. Конкретный ответ для конкретной атомарной переменной (это может зависеть от выравнивания) можно получить, проверив flagA.is_lock_free() начиная с C++11.

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

Таким образом, правильный способ - использовать std::atomic, пока доступ не блокируется. Если вас беспокоит производительность, может оказаться целесообразным выбрать соответствующий порядок памяти и придерживаться значений, которые можно загрузить в одной инструкции.

Не использовать ни один из них было бы неправильно, компилятор проверит флаг только один раз.

Все эти функции ждут флага, но они переводятся по-разному:

#include <atomic>
#include <cstdint>

using FlagT = std::int32_t;

volatile FlagT flag = 0;
void waitV()
{
    while (!flag) {}
}

std::atomic<FlagT> flagA;
void waitA()
{
    while(!flagA) {}    
}

void waitRelaxed()
{
    while(!flagA.load(std::memory_order_relaxed)) {}    
}

FlagT wrongFlag;
void waitWrong()
{
    while(!wrongFlag) {}
}

Используя volatile, вы получаете цикл, который пересматривает флаг так, как вы хотели:

waitV():
        ldr     r2, .L5
.L2:
        ldr     r3, [r2]
        cmp     r3, #0
        beq     .L2
        bx      lr
.L5:
        .word   .LANCHOR0

Atomic с последовательным последовательным доступом по умолчанию создает синхронизированный доступ:

waitA():
        push    {r4, lr}
.L8:
        bl      __sync_synchronize
        ldr     r3, .L11
        ldr     r4, [r3, #4]
        bl      __sync_synchronize
        cmp     r4, #0
        beq     .L8
        pop     {r4}
        pop     {r0}
        bx      r0
.L11:
        .word   .LANCHOR0

Если вас не волнует порядок памяти, вы получаете рабочий цикл, как и в случае с volatile:

waitRelaxed():
        ldr     r2, .L17
.L14:
        ldr     r3, [r2, #4]
        cmp     r3, #0
        beq     .L14
        bx      lr
.L17:
        .word   .LANCHOR0

Использование ни volatile, ни atomic не укусит вас с включенной оптимизацией, так как флаг проверяется только один раз:

waitWrong():
        ldr     r3, .L24
        ldr     r3, [r3, #8]
        cmp     r3, #0
        bne     .L23
.L22:                        // infinite loop!
        b       .L22
.L23:
        bx      lr
.L24:
        .word   .LANCHOR0
flag:
flagA:
wrongFlag:
person PaulR    schedule 18.08.2020
comment
Интересный ответ, но можно ли доверять компилятору Godbolt gcc ARM для генерации того же кода, что и gcc ARM none EABI, используемому цепочками инструментов STM32 на голом металле? - person Lundin; 19.08.2020
comment
Вы можете и должны всегда запускать your-gcc -S, чтобы увидеть фактический вывод сборки, или дизассемблировать с помощью objdump. Обратите также внимание, что ваша компиляция для STM32, вероятно, содержит значительное количество дополнительных флагов, я просто добавил то, что смог вспомнить на месте. Дело в том, что с atomic компилятор должен убедиться, что параллельный доступ работает, с volatile гарантия другая - person PaulR; 19.08.2020
comment
Если платформа не имеет естественного способа атомарной обработки 64-битных операций, атомарные функции реализации вряд ли будут надежно работать в сочетании с прерываниями, если только они не могут сохранять состояние прерывания, отключать прерывания, выполнять операцию и восстанавливать состояние прерывания. Если бы временное отключение прерываний было бы приемлемо, пользовательский код должен был бы иметь возможность делать это без необходимости использования атомарных функций реализации и использовать полученную семантику для выполнения различных действий проще, чем это было бы возможно с атомарностью. - person supercat; 19.08.2020
comment
Я согласен с тем, что atomic - это правильный путь, но считаю ваш аргумент о volatile int64_t ложным - вы также не можете использовать atomic<int64_t> (если его is_lock_free() ложно). Это будет либо использование мьютекса (блокировка IRQ/ISR на неопределенный срок), либо LL-SC (что не рекомендуется делать в IRQ, потому что LL-SC обычно не может быть вложенным, нарушите логику, если вы это сделаете). - person firda; 20.08.2020
comment
@firda: я не думаю, что Стандарт ясно дает понять, должны ли атомарные элементы, использующие ll/sc целевого типа, указывать is_lock_free(). Как правило, компилятор не может гарантировать, что такие операции будут технически свободны от блокировок, но на практике часто можно гарантировать, что они будут выполняться, если системе когда-либо удастся выполнить более нескольких инструкций между прерываниями. Для многих целей важнее, чтобы операции были свободны от препятствий и чтобы они использовали тот же механизм блокировки, что и все остальное в системе, которое должно быть атомарным. - person supercat; 20.08.2020
comment
@supercat: LL-SC (LDREX/STREX) — это спин-блокировка, которая не является свободной от блокировки. Вы либо используете atomic_flag, который является единственной гарантированной вещью для работы в ISR, либо вам нужно сделать его специфичным для платформы. Там я ставлю на atomit_int, когда это необходимо, потому что volatile может быть недостаточно, затирания памяти может быть недостаточно (может потребоваться DMB или DSB инструкции). так что atomit либо делает правильно, либо просто не возможно вообще. (и вы можете добавить немного static_assert или использовать ATOMIC_INT_LOCKFREE. - person firda; 22.08.2020
comment
@firda: Во многих системах обстоятельства, необходимые для блокировки LL / SC, никогда не могут возникнуть, хотя реализация может не знать об этом. Что необходимо, так это способ для того, кто знает семантику базовой платформы, чтобы иметь последовательный независимый от компилятора способ указания тех, что есть в языке - что-то, для чего C раньше был хорош, но становится все хуже по мере того, как разработчики компиляторов теряют зрение. того факта, что полезность C сделала не анемичная модель абстракции стандарта, а то, что язык может адаптироваться ко многим моделям абстракции. - person supercat; 22.08.2020
comment
@firda: Если нельзя использовать is_lock_free, чтобы определить, утверждает ли реализация использование собственной семантики платформы для атомарных операций, какие средства следует использовать? Нужен ли вам DMB или DSB, зависит от ядра и от того, взаимодействует ли он с прерываниями или с такими вещами, как DMA, которые могут изменять память без участия ядра. Программисты часто знают такие вещи, когда компиляторы не могут. - person supercat; 22.08.2020
comment
@supercat: прочитайте это en.cppreference.com/w/cpp/atomic/atomic /is_always_lock_free и этот en.cppreference.com/w/c/atomic/ ATOMIC_LOCK_FREE_consts Практически вы либо найдете решение без блокировок, либо вам придется отключить прерывания. (И насчет вашего во многих системах... это неверно для рассматриваемого STM32, вы должны использовать STREX с тем же адресом, что и последний LDREX, иначе вы нарушите контракт = UB = никогда не делайте этого в ISR). - person firda; 22.08.2020
comment
@firda: на Cortex-M3, если происходит переключение контекста прерывания между LDREX и STREX, это гарантированно сделает ожидающий LDREX недействительным, поэтому последующий STREX сообщит об ошибке. Если время между LDREX и STREX достаточно велико, чтобы между ними всегда возникало прерывание, STREX никогда не будет успешным, но если когда-либо будет достаточно долгое время без прерываний, цикл LDREX/STREX будет работать до тех пор. - person supercat; 22.08.2020
comment
@supercat: static.docs.arm.com/dui0553/a/DUI0553A_cortex_m4_dgug.pdf - стр. 83: Результат выполнения инструкции Store-Exclusive по адресу, отличному от адреса, использованного в предыдущей инструкции Load-Exclusive, непредсказуем. - person firda; 22.08.2020
comment
@supercat: PS: я не вижу реальной причины, по которой HW не запоминал бы последний использованный адрес и приводил к сбою STREX при использовании с другим, но в этом документе указано иное. Я не вижу способа даже правильно реализовать переключение потоков, если STREX был настолько сломан. Я бы хотел, чтобы он работал правильно, но... слишком часто видел, как HW не делает то, что вы ожидаете. В любом случае, если у вас есть лучший документ, пожалуйста, сюда. В противном случае нам следует либо перейти в чат, либо оставить эту тему открытой. - person firda; 22.08.2020
comment
См. разработчик .arm.com/documentation/dui0552/a/ для получения информации о ldrex/strex. Обратите внимание, в частности, что обработка исключения (прерывания) очищает флаг монопольного доступа, поэтому на Cortex-M3 основным эффектом strex является выполнение сохранения, если после ldrex не произошло прерывание. Кстати, мне любопытно, почему strex не устанавливает флаги, поскольку код почти наверняка будет заинтересован в ветвлении в зависимости от того, удалось оно или нет. - person supercat; 22.08.2020
comment
Очень интересная дискуссия, я понимаю, что мне еще многое предстоит узнать по этой теме! Большое спасибо :) Я очень ценю пример и методологию - проверьте сборку, чтобы быть уверенным на 100%. В основном я хотел знать, поняли бы это современные компиляторы C++ в 2020 году, но оказалось, что нет (возможно, никогда не поймут?). Для обеспечения безопасности потоков я, вероятно, сейчас отключу/включу прерывания, так как на самом деле я хочу читать массивы вместо 32-битных флагов. @Lundin Godbolt поддерживает arm-none-eabi :) godbolt.org/z/hdxz4b - person user1011113; 23.08.2020
comment
@firda: есть пара подходов, которые система может использовать для реализации чего-то вроде LDREX/STREX: наблюдайте за адресом и вызывайте сбой STREX, если с ним что-то случится, или следите за чем-либо подозрительным и вызывайте сбой STREX, если это происходит. Последний подход проще и легче реализовать, но он будет работать крайне плохо, если вообще непригодно, в многоядерной системе. Разница, которую я не помню, упоминалась ли документация Cortex, заключается в том, что при использовании прежнего подхода что-то вроде... - person supercat; 24.08.2020
comment
ldrex r1,[r0] / str r1,[r2] / strex r2,r1,[r0] приведет к ошибке сообщения strex, если r0 и r2 равны (из-за сохранения по адресу r0/r2 между ldrex и strex), но при использовании последнего подхода strex, вероятно, перезапишет значение, записанное str (если только произошло прерывание между ldrex/strex). - person supercat; 24.08.2020
comment
@firda: Конечно, это оставляет открытым вопрос о том, будут ли какие-либо/все версии clang/gcc воздерживаться от других операций с памятью в ldrex и strex. Если, например. code 'ldrex' создает указатель на начало списка, сохраняет его в следующем указателе нового элемента списка, а затем 'strex' направляет указатель заголовка списка на новый элемент, при этом компилятор откладывает обновление следующего указателя элемента списка после strex может привести к неправильному чтению следующего указателя из нового элемента. - person supercat; 24.08.2020
comment
@supercat: Искал немного больше и 1. Я могу подтвердить, что clrex выполняется автоматически при прерывании (что приводит к сбою следующего strex, делая возможным переключение задач), но 2. любой доступ к памяти между ними может привести к проблемам и неожиданным /undefined поведение (Exclusives Reservation Granule), оставляя для них только одно надежное применение — спин-блокировки (CAS/RMW). И это возвращает нас к тупику ISR (блокировка мьютекса в ISR). Итак, еще раз: либо безблокировочная атомарность (особенно atomic_flag), либо отключение прерываний. Ничто другое не является надежным (в общем, поставщики могут дать лучшие гарантии). - person firda; 26.08.2020

Из протестированных мной коммерческих компиляторов, не основанных на gcc или clang, все они обрабатывают чтение или запись через указатель volatile или lvalue как способные получить доступ к любому другому объекту, независимо от того, кажется ли это возможным для указатель или lvalue для попадания в рассматриваемый объект. Некоторые, такие как MSVC, официально задокументировали тот факт, что изменчивые записи имеют семантику освобождения, а изменчивые чтения имеют семантику приобретения, в то время как другим потребуется пара чтения/записи для достижения семантики получения.

Такая семантика позволяет использовать объекты volatile для создания мьютекса, который может защищать обычные объекты в системах со строгой моделью памяти (включая одноядерные системы с прерываниями) или в компиляторах, применяющих барьеры захвата/освобождения на аппаратном уровне упорядочения памяти. а не просто уровень упорядочения компилятора.

Однако ни clang, ни gcc не предлагают никакой другой опции, кроме -O0, которая предлагала бы такую ​​семантику, поскольку они препятствовали бы оптимизации, которая в противном случае могла бы преобразовать код, выполняющий кажущиеся избыточными загрузки и сохранения [которые на самом деле необходимы для правильной работы] в более эффективный код [который не работает]. Чтобы сделать код пригодным для использования с ними, я бы рекомендовал определить макрос «засорения памяти» (который для clang или gcc будет asm volatile ("" ::: "memory");) и вызывать его между действием, которое должно предшествовать изменчивой записи, и самой записью, или между изменчивой записью. read и первое действие, которое должно следовать за ним. Если кто-то сделает это, это позволит легко адаптировать код к реализациям, которые не поддерживают и не требуют таких барьеров, просто определяя макрос как пустое расширение.

Обратите внимание, что в то время как некоторые компиляторы интерпретируют все директивы asm как засорение памяти, и нет никакой другой цели для пустой директивы asm, gcc просто игнорирует пустые директивы asm, а не интерпретирует их таким образом.

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

short buffer[10];
volatile short volatile *tx_ptr;
volatile int tx_count;
void test(void)
{
    buffer[0] = 1;
    tx_ptr = buffer;
    tx_count = 1;
    while(tx_count)
        ;
    buffer[0] = 2;
    tx_ptr = buffer;
    tx_count = 1;
    while(tx_count)
        ;
}

GCC решит оптимизировать назначение buffer[0]=1;, потому что Стандарт не требует, чтобы он признавал, что сохранение адреса буфера в volatile может иметь побочные эффекты, которые будут взаимодействовать с сохраненным там значением.

[править: дальнейшие эксперименты показывают, что icc переупорядочивает доступ к volatile объектам, но поскольку он переупорядочивает их даже относительно друг друга, я не уверен, что с этим делать, так как это может показаться неправильным любой воображаемой интерпретацией Стандарта].

person supercat    schedule 18.08.2020

Чтобы понять проблему, вы должны сначала понять, зачем вообще нужен volatile.

Здесь есть три совершенно разных вопроса:

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

    Решение: volatile или осведомленность компилятора.

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

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

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

    Решение: барьеры памяти или выделение/выполнение в областях памяти, которые не кэшируются. volatile доступ может или не может действовать как барьер памяти в некоторых системах.

Как только кто-то поднимает такой вопрос о SO, вы всегда получаете множество программистов для ПК, болтающих о 2 и 3, ничего не зная или не понимая о 1. Это потому, что они никогда в своей жизни не писали ISR и компиляторы для ПК с несколькими -threading, как правило, знает, что будут выполняться обратные вызовы потоков, поэтому в программах для ПК это обычно не проблема.

Что вам нужно сделать, чтобы решить 1) в вашем случае, это посмотреть, действительно ли компилятор генерирует код для чтения while (!flag) с включенной оптимизацией или без нее. Разобрать и проверить.

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

К сожалению, большинство компиляторов используют только ключевые слова interrupt etc для создания правильных соглашений о вызовах и инструкций возврата. Недавно я столкнулся с недостающей ошибкой volatile всего несколько недель назад, помогая кому-то на сайте SE, и они использовали современную цепочку инструментов ARM. Поэтому я не верю, что компиляторы справятся с этим в 2020 году, если только они явно не задокументируют это. Если вы сомневаетесь, используйте volatile.

Что касается 2) и повторного входа, современные компиляторы в настоящее время поддерживают _Atomic, что очень упрощает задачу. Используйте его, если он доступен и надежен в вашем компиляторе. В противном случае для большинства систем с «голым железом» вы можете использовать тот факт, что прерывания не прерываются, и использовать обычное логическое значение в качестве облегченного мьютекса (example), если нет переупорядочения инструкций (маловероятный случай для большинства микроконтроллеров).

Но обратите внимание, что 2) – это отдельная проблема, не связанная с volatile. volatile не решает потокобезопасный доступ. Поточно-ориентированный доступ не устраняет неправильные оптимизации. Так что не смешивайте эти две несвязанные концепции в одном беспорядке, как это часто бывает на SO.

person Lundin    schedule 19.08.2020
comment
Комментарии не для расширенного обсуждения; этот разговор был перешел в чат. - person Jean-François Fabre; 22.08.2020

Краткий ответ: всегда используйте std::atomic<T>, is_lock_free() которого возвращает true.

Рассуждение:

  1. volatile может надежно работать на простых архитектурах (одноядерных, без кэша, ARM/Cortex-M), таких как STM32F2 или ATSAMG55, например, с Компилятор IAR. Но...
  2. Он может не работать должным образом на более сложных архитектурах (многоядерных с кешем) и когда компилятор пытается выполнить определенные оптимизации (многие примеры в других ответах не повторяются).
  3. atomic_flag и atomic_int (если is_lock_free() они должны) безопасно использовать где угодно, потому что они работают как volatile с добавленными барьерами памяти/синхронизацией при необходимости (избегая проблем в предыдущем пункте).
  4. Причина, по которой я специально сказал, что вы должны использовать только те, у которых is_lock_free() равно true, заключается в том, что вы не можете остановить IRQ, как вы могли бы остановить поток. Нет, IRQ прерывает основной цикл и выполняет свою работу, он не может ждать блокировку мьютекса, потому что он блокирует основной цикл, которого должен ожидать.

Практическое примечание: я лично либо использую atomic_flag (единственный гарантированно работающий) для реализации своего рода спин-блокировки, где ISR отключается при обнаружении заблокированной блокировки, в то время как основной цикл всегда снова включает ISR после разблокировки. Или я использую очередь без блокировки с двойным счетчиком (SPSC - один производитель, один потребитель), используя этот atomit_int. (Имейте один счетчик чтения и один счетчик записи, вычтите, чтобы найти реальный счет. Подходит для UART и т. Д.)

person firda    schedule 20.08.2020