中断安全 FIFO 中的 DMB 指令

Lou*_*Lou 6 c assembly gcc atomicity c11

此线程相关,我有一个 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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

这是一个没有操作系统/线程的裸机项目。此代码用于记录 FIFO,它不是时间关键,但我不希望头部的获取对程序其余部分的延迟产生影响,所以我的问题是:

  • 我需要所有这些dmb吗?
  • 这些说明会不会有明显的性能损失,或者我可以忽略这一点吗?
  • 如果在 a 期间发生中断dmb,它会产生多少个额外的延迟周期?

Pet*_*des 5

TL:DR 是的,与禁用中断相比,LL/SC (STREX/LDREX) 可以通过重试使原子 RMW 可中断来减少中断延迟。

这可能会牺牲产量为代价,因为很明显上禁用的ARMv7 /重新启用中断是很便宜(如可能为1或2个周期,每个周期为cpsid if/ cpsie if),特别是如果你可以无条件允许中断而不是保存旧的状态。(暂时禁用 ARM 上的中断)。

额外的吞吐量成本是:如果 LDREX/STREX 在 Cortex-M4 上比 LDR/STR 慢,cmp/bne(在成功案例中不采用),以及任何时候循环必须重试整个循环体再次运行. (重试应该是非常罕见的;只有在另一个中断处理程序中的 LL/SC 中间实际发生了中断时。)


不幸的是,像 gcc 这样的 C11 编译器没有用于单处理器系统或单线程代码的特殊模式。因此,他们不知道如何利用这样一个事实来进行代码生成,即在同一核心上运行的任何东西都会在某个点之前按程序顺序查看我们的所有操作,即使没有任何障碍。

(乱序执行和内存重新排序的主要规则是它保留了单线程或单核按程序顺序运行指令的错觉。)

dmb即使在多线程代码的多核系统上,仅由一对 ALU 指令分隔的背靠背指令也是多余的。这是一个 gcc 遗漏优化,因为当前的编译器基本上没有对原子进行优化。(最好是安全和缓慢而不是冒险变得太弱。推理、测试和调试无锁代码而不必担心可能的编译器错误已经足够困难了。)


单核 CPU 上的原子

在这种情况下,您可以通过在 之后进行屏蔽来大大简化它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;
}
Run Code Online (Sandbox Code Playgroud)

在 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
Run Code Online (Sandbox Code Playgroud)

不幸的是,我知道在 C11 或 C++11 中无法表达包含任意操作集(例如添加和掩码)的LL/SC原子 RMW,因此我们可以在循环中获取 ubfx 以及存储的部分内容到_head。不过,对于 LDREX/STREX 有特定于编译器的内在函数:ARM 中的关键部分

这是安全的,因为_Atomic整数类型保证是具有明确定义的溢出 = 环绕行为的 2 的补码。(int32_t已经保证是 2 的补码,因为它是固定宽度类型之一,但 no-UB-wraparound 仅适用于_Atomic)。我会使用uint32_t,但我们得到相同的 asm。


从中断处理程序内部安全地使用 STREX/LDREX:

ARM® Synchronization Primitives(从 2009 年开始)有一些关于管理 LDREX/STREX 的 ISA 规则的详细信息。运行 LDREX 会初始化“独占监视器”以检测其他内核(或系统中其他非 CPU 事物?我不知道)的修改。Cortex-M4 是一个单核系统。

您可以为多个 CPU 之间共享的内存设置一个全局监视器,并为标记为不可共享的内存设置一个本地监视器。该文档说“如果配置为可共享的区域未与全局监视器关联,则对该区域的 Store-Exclusive 操作始终失败,并在目标寄存器中返回 0。” 因此,如果在测试代​​码时STREX 似乎总是失败(因此您陷入重试循环),那可能就是问题所在。

中断确实没有中止由LDREX启动的事务。如果您正在上下文切换到另一个上下文并恢复可能在 STREX 之前停止的某些内容,那么您可能会遇到问题。clrex为此引入了ARMv6K ,否则较旧的 ARM 将使用虚拟 STREX 到虚拟位置。

请参阅何时在 ARM Cortex M7 上实际需要 CLREX?,这与我即将谈到的观点相同,即在中断情况下,当不在线程之间进行上下文切换时,通常不需要 CLREX。

(有趣的事实:关于该链接问题的最新答案指出,Cortex M7(或一般的 Cortex M?)会在中断时自动清除监视器,这意味着中断处理程序中永远不需要 clrex。下面的推理仍然适用于较旧的单个-core ARM CPU,带有不跟踪地址的监视器,与多核 CPU 不同。)

但是对于这个问题,您要切换的始终是中断处理程序的开始。你不是在做先发制人的多任务处理。 因此,您永远无法从一个 LL/SC 重试循环的中间切换到另一个循环的中间。 只要 STREX 在您返回时在较低优先级中断中第一次失败,就可以了。

这里就是这种情况,因为更高优先级的中断只会在成功执行 STREX(或根本没有执行任何原子 RMW)后返回。

所以我认为即使clrex在分派到 C 函数之前不使用from inline asm 或从中断处理程序也可以。 该手册说数据中止异常会使监视器在体系结构上未定义,因此请确保至少在该处理程序中使用 CLREX。

如果在 LDREX 和 STREX 之间出现中断,则 LL 已将旧数据加载到寄存器中(并且可能计算了一个新值),但尚未将任何内容存储回内存,因为 STREX 尚未运行.

更高优先级的代码将 LDREX,获得相同的old_h值,然后执行成功的 STREX old_h + 1。(除非也被打断,但这种推理是递归的)。这可能会在第一次循环中失败,但我不这么认为。即使是这样,根据我链接的 ARM 文档,我认为不会存在正确性问题。该文档提到本地监视器可以像状态机一样简单,它只跟踪 LDREX 和 STREX 指令,即使前一条指令是针对不同地址的 LDREX,也能让 STREX 成功。假设 Cortex-M4 的实现是简单的,这就是完美的。

在 CPU 已经从之前的 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;
}
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)

这与我们为 non-atomic 得到的 asm 相同head

  • @P__J__:在这种情况下,低优先级中断处理程序中的 STREX 将失败,因为它会在不处于匹配 LDREX 创建的状态时运行。更高优先级中断处理程序中的 STREX 会实现这一点。请参阅 http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf#page=11。或者这里是否存在破坏正常 LL/SC 操作的极端情况?我只是浏览了有关 CLREX / 打开监视器状态内容的文档。 (3认同)
  • @P__J__:我想它是用编译器生成的重试循环来处理的,以实现多线程`fetch_add`。你甚至读过asm吗?我什至在编译器输出中评论了 LL/SC 重试循环。这非常类似于 OP 在其 C 源代码中编写的 CAS 重试循环。如果争用较少,则几乎总是第一次失败的比较和分支很可能比禁用和重新启用中断更便宜。在 x86 上,在有序 Pentium 上需要大约 10 个周期(或在 OoO exec CPU 上更多);我猜想一个有序的 Cortex-M4 可能是相似的。 (2认同)
  • @P__J__:您有`LDREX`,后跟`ADD` 指令,后跟`STREX`。当 `STREX` 失败时,你**不要**从中断返回,只要你需要,你就可以重试操作。 (2认同)
  • @P__J__:当然可以,这是唯一需要考虑的有意义的情况。上级中断接着做`LDREX`/`ADD`/`STREX`,成功,返回下级中断,下级中断失败,需要重试。**任何在 `LDREX` 和 `STREX` 之间没有被中断的中断都会成功。** (2认同)
  • @P__J__:如果没有再次中断,为什么 STREX 会失败?就像我多次解释过的那样,在运行这个 C 函数之前的 CLREX 肯定会确保没有任何东西阻止 LDREX/STREX 在它没有被中断的情况下成功,或者在它被中断时能够重试成功。您还没有解释这如何陷入循环。您一直声称这一点,但您没有链接到任何架构文档,说明为什么 LDREX/STREX 循环在不中断的情况下会反复失败。 (2认同)