Инструкции DMB в безопасном прерывании FIFO - PullRequest
0 голосов
/ 06 января 2019

Относительно этой темы , у меня есть FIFO, который должен работать с различными прерываниями на Cortex M4.

Индекс головы должен быть

  • атомно записано (изменено) несколько прерываний (не потоки)
  • атомно считывается по одному (низшему уровню) прерыванию

Функция перемещения головки FIFO выглядит примерно так (есть также проверки, чтобы проверить, не переполнена ли головка в реальном коде, но это основная идея):

#include <stdatomic.h>
#include <stdint.h>

#define FIFO_LEN 1024
extern _Atomic int32_t _head;

int32_t acquire_head(void)
{
    while (1)
    {
        int32_t old_h = atomic_load(&_head);
        int32_t new_h = (old_h + 1) & (FIFO_LEN - 1);

        if (atomic_compare_exchange_strong(&_head, &old_h, new_h))
        {
            return old_h;
        }
    }
}

GCC скомпилирует в:

acquire_head:
        ldr     r2, .L8
.L2:
        // int32_t old_h = atomic_load(&_head);
        dmb     ish
        ldr     r1, [r2]
        dmb     ish

        // int32_t new_h = (old_h + 1) & (FIFO_LEN - 1);
        adds    r3, r1, #1
        ubfx    r3, r3, #0, #10

        // if (atomic_compare_exchange_strong(&_head, &old_h, new_h))
        dmb     ish
.L5:
        ldrex   r0, [r2]
        cmp     r0, r1
        bne     .L6
        strex   ip, r3, [r2]
        cmp     ip, #0
        bne     .L5
.L6:
        dmb     ish
        bne     .L2
        bx      lr
.L8:
        .word   _head

Это голый металлический проект без ОС / потоков. Этот код предназначен для регистрации FIFO, который не критичен ко времени, но я не хочу, чтобы приобретение головы оказывало влияние на задержку остальной части моей программы, поэтому мой вопрос:

  • мне нужны все эти dmb с?
  • будет ли заметное снижение производительности с этими инструкциями, или я могу просто проигнорировать это?
  • если прерывание происходит во время dmb, сколько дополнительных циклов задержки оно создает?

Ответы [ 3 ]

0 голосов
/ 06 января 2019

dmb требуется в таких ситуациях, как

p1:
    str r5, [r1]
    str r0, [r2]

и

p2:
    wait([r2] == 0)
    ldr r5, [r1]

(из http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf, раздел 6.2.1 «Слабая проблема передачи сообщений»).

Оптимизация в CPUI может изменить порядок команд на p1, поэтому вам нужно вставить dmb между обоими хранилищами.

В вашем примере слишком много dmb, что, вероятно, вызвано расширением atomic_xxx(), которое может иметь dmb как в начале, так и в конце.

In должно быть достаточно, чтобы

acquire_head:
        ldr     r2, .L8
        dmb     ish
.L2:
        // int32_t old_h = atomic_load(&_head);
        ldr     r1, [r2]
...
        bne     .L5
.L6:
        bne     .L2
        dmb     ish
        bx      lr

и никаких других dmb между.

Трудно оценить влияние на производительность (вам придется тестировать код с dmb и без него). dmb не использует циклы процессора; он просто останавливает конвейерную обработку внутри процессора.

0 голосов
/ 06 января 2019

TL: DR да, LL / SC (STREX / LDREX) могут быть хороши для задержки прерывания по сравнению с отключением прерываний, делая атомное RMW прерываемым при повторной попытке.

Это может происходить за счет пропускной способности, потому что, очевидно, отключение / повторное включение прерываний в ARMv7 очень дешево (например, 1 или 2 цикла каждый для cpsid if / cpsie if), особенно если вы можете безоговорочно включать прерывания вместо сохранения старого состояния. ( Временно отключить прерывания на ARM ).

Дополнительные затраты на пропускную способность: если LDREX / STREX медленнее, чем LDR / STR на Cortex-M4, cmp / bne (не принимается в успешном случае), и каждый раз, когда цикл должен повторить весь цикл тело снова бежит. (Повтор должен быть очень редким; только если прерывание действительно происходит, когда он находится в середине LL / SC в другом обработчике прерывания.)


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

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

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


Атомика на одноядерном процессоре

В этом случае вы можете значительно упростить его, маскируя после и atomic_fetch_add вместо имитации атомарного сложения с более ранними ролловерами с использованием CAS. (Тогда читатели тоже должны маскироваться, но это очень дешево.)

И вы можете использовать memory_order_relaxed. Если вы хотите переупорядочить гарантии для обработчика прерываний, используйте atomic_signal_fence для принудительного упорядочивания во время компиляции без asm-барьеров для переупорядочения во время выполнения. Сигналы пользовательского пространства POSIX асинхронны в одном и том же потоке точно так же, как прерывания асинхронны в одном и том же ядре.

// readers must also mask _head & (FIFO_LEN - 1) before use

// Uniprocessor but with an atomic RMW:
int32_t acquire_head_atomicRMW_UP(void)
{
    atomic_signal_fence(memory_order_seq_cst);    // zero asm instructions, just compile-time
    int32_t old_h = atomic_fetch_add_explicit(&_head, 1, memory_order_relaxed);
    atomic_signal_fence(memory_order_seq_cst);

    int32_t new_h = (old_h + 1) & (FIFO_LEN - 1);
    return new_h;
}

В проводнике компилятора Godbolt

@@ gcc8.2 -O3 with your same options.
acquire_head_atomicRMW:
    ldr     r3, .L4           @@ load the static address from a nearby literal pool
.L2:
    ldrex   r0, [r3]
    adds    r2, r0, #1
    strex   r1, r2, [r3]
    cmp     r1, #0
    bne     .L2               @@ LL/SC retry loop, not load + inc + CAS-with-LL/SC
    adds    r0, r0, #1        @@ add again: missed optimization to not reuse r2
    ubfx    r0, r0, #0, #10
    bx      lr
.L4:
    .word   _head

К сожалению, я не знаю, как в C11 или C ++ 11 можно выразить LL / SC атомарный RMW, который содержит произвольный набор операций, таких как add и mask, поэтому мы могли бы получить ubfx внутри цикла и части того, что хранится в _head. Для LDREX / STREX существуют специфичные для компилятора особенности: Критические разделы в ARM .

Это безопасно, потому что _Atomic целочисленные типы гарантированно будут дополнены 2 с четко определенным поведением overflow = wraparound. (int32_t уже гарантированно будет дополнением 2, потому что это один из типов фиксированной ширины, но обход без UB только для _Atomic). Я бы использовал uint32_t, но мы получаем то же самое.


Безопасное использование STREX / LDREX из обработчика прерываний:

Примитивы синхронизации ARM® (с 2009 г.) содержит некоторые сведения о правилах ISA, которые управляют LDREX / STREX. Запуск LDREX инициализирует «эксклюзивный монитор» для обнаружения изменений другими ядрами (или другими процессорами, не относящимися к процессору, в системе? Я не знаю). Cortex-M4 - одноядерная система.

У вас может быть глобальный монитор для памяти, совместно используемой несколькими ЦП, и локальные мониторы для памяти, помеченной как не подлежащая обмену. Эта документация гласит: «Если регион, настроенный как Shareable, не связан с глобальным монитором, операции Store-Exclusive в этом регионе всегда завершаются неудачно, возвращая 0 в регистре назначения». Так что если STREX кажется, что всегда терпит неудачу (так что вы застреваете в цикле повтора), когда вы тестируете свой код, это может быть проблемой.

Прерывание не прерывает транзакцию, запущенную LDREX . Если вы переключали контекст в другой контекст и возобновляли что-то, что могло бы остановиться прямо перед STREX, у вас могла бы быть проблема. ARMv6K ввел clrex для этого, иначе более старая ARM использовала бы фиктивный STREX для фиктивной локации.

См. Когда на самом деле нужен CLREX на ARM Cortex M7? , что говорит о том, что CLREX часто не требуется в ситуации прерывания, когда не контекстно-зависимый переключение между потоками.

Но для этой проблемы переключение на - это всегда запуск обработчика прерываний. Вы не делаете упреждающую многозадачность. Таким образом, вы никогда не сможете переключиться с середины одного цикла повторения LL / SC на середину другого. Пока STREX дает сбой в первый раз в прерывании с более низким приоритетом, когда вы возвращаетесь к нему, это нормально.

Это будет иметь место здесь, потому что прерывание с более высоким приоритетом будет возвращаться только после того, как оно выполнит успешный STREX (или вообще не будет делать никаких атомарных RMW).

Так что я думаю, что вы в порядке, даже не используя clrex из встроенного asm или из обработчика прерываний перед отправкой в ​​функции C. В руководстве говорится, что исключение Data Abort оставляет мониторы архитектурно неопределенными, поэтому убедитесь, что вы как минимум CLREX в этом обработчике.

Если прерывание возникает, когда вы находитесь между LDREX и STREX, LL загрузил старые данные в регистр (и, возможно, вычислил новое значение), но еще ничего не сохранил обратно в память, потому что STREX имел не работает.

Код с более высоким приоритетом будет LDREX, получая то же значение old_h, а затем выполнит успешный STREX old_h + 1. (Если оно также не прервано, но это рассуждение работает рекурсивно). Это может произойти сбой в первый раз через цикл, но я так не думаю. Даже если так, я не думаю, что может быть проблема правильности, основанная на документе ARM, который я связал. В документе упоминается, что локальный монитор может быть таким же простым, как конечный автомат, который просто отслеживает инструкции LDREX и STREX, позволяя STREX успешно выполняться, даже если предыдущая инструкция была LDREX для другого адреса. Предполагая, что реализация Cortex-M4 упрощена, это идеально подходит для этого.

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

Затем (после выполнения некоторых других действий) обработчик прерываний вернется, восстановив регистры и вернувшись к середине цикла LL / SC прерывания с более низким приоритетом.

Возвращаясь к прерыванию с более низким приоритетом, STREX не будет работать, потому что STREX в прерывании с более высоким приоритетом сбрасывает состояние монитора. Это хорошо, нам нужно , чтобы он потерпел неудачу, потому что он сохранил бы то же значение, что и прерывание с более высоким приоритетом, которое заняло свое место в FIFO. cmp / bne обнаруживает сбой и снова запускает весь цикл. На этот раз он успешен (если не прерван снова ), считывает значение, сохраненное прерыванием с более высоким приоритетом, и сохраняет и возвращает это + 1.

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


Версия для одного писателя

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

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

// If we're the only writer, and other threads can only observe:
// again using uniprocessor memory order: relaxed + signal_fence
int32_t acquire_head_separate_RW_UP(void) {
    atomic_signal_fence(memory_order_seq_cst);
    int32_t old_h = atomic_load_explicit(&_head, memory_order_relaxed);

    int32_t new_h = (old_h + 1) & (FIFO_LEN - 1);
    atomic_store_explicit(&_head, new_h, memory_order_relaxed);
    atomic_signal_fence(memory_order_seq_cst);

    return new_h;
}

acquire_head_separate_RW_UP:
    ldr     r3, .L7
    ldr     r0, [r3]          @@ Plain atomic load
    adds    r0, r0, #1
    ubfx    r0, r0, #0, #10   @@ zero-extend low 10 bits
    str     r0, [r3]          @@ Plain atomic store
    bx      lr

Это то же самое, что мы получили бы для неатомных head.

0 голосов
/ 06 января 2019

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

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

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

PS stdatomic в очень редких случаях используется в разработке голого металла.

Самый быстрый способ гарантировать эксклюзивный доступ к M4 uC - отключить и включить прерывания.

__disable_irq();
x++;
__enable_irq();

  71        __ASM volatile ("cpsid i" : : : "memory");
080053e8:   cpsid   i
 79         x++;
080053ea:   ldr     r2, [pc, #160]  ; (0x800548c <main+168>)
080053ec:   ldrb    r3, [r2, #0]
080053ee:   adds    r3, #1
080053f0:   strb    r3, [r2, #0]
  60        __ASM volatile ("cpsie i" : : : "memory");

, что обойдется всего в 2 или 4 дополнительных часа для обеих инструкций.

Гарантирует атомарность и не дает лишних затрат

...