中断安全 FIFO 中的 DMB 指令

DMB instructions in an interrupt-safe FIFO

相关,我有一个 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 将 compile this 到:

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

这是一个没有 OS/threads 的裸机项目。这段代码用于时间要求不严格的日志记录 FIFO,但我不希望获取磁头对我程序其余部分的延迟产生影响,所以我的问题是:

您的代码编写方式非常不 "bare metal"。那些 "general" 原子函数不知道读取或存储的值是否位于内部存储器中,或者它可能是一个硬件寄存器,位于远离核心的某个地方并通过总线连接,有时 write/read 缓冲区。

这就是通用原子函数必须放置这么多 DMB 指令的原因。因为你读取或写入内部内存位置,所以根本不需要它们(M4 没有任何内部缓存,因此也不需要这种强大的预防措施)

IMO 当您想以原子方式访问内存位置时,禁用中断就足够了。

PS stdatomic 在裸机 uC 开发中很少使用。

保证对 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 个额外的时钟。

保证原子性,不提供不必要的开销

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 节 "Weakly-Ordered Message Passing problem")。

CPUI 内优化可以重新排序 p1 上的指令,因此您必须在两个存储之间插入一个 dmb

在您的示例中,dmb 过多,这可能是由于扩展 atomic_xxx() 造成的,它可能在开始和结束时都有 dmb

里面应该够了

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 不消耗 cpu 个周期;它只是在 cpu.

中停止流水线操作

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

这可能会以吞吐量为代价,因为显然在 ARMv7 上禁用/重新启用中断非常便宜(例如 cpsid if / cpsie if 每个可能需要 1 或 2 个周期),尤其是如果您可以无条件地启用中断而不是保存旧状态。 ().

额外的吞吐量成本是:如果 LDREX/STREX 比 Cortex-M4 上的 LDR / STR 慢,cmp/bne(在成功的情况下不采用),并且任何时候循环必须再次重试整个循环体 运行s。 (重试应该 非常 罕见;只有在另一个中断处理程序中的 LL/SC 中间确实出现中断时才会重试。)


不幸的是,

像 gcc 这样的 C11 编译器没有针对单处理器系统或单线程代码的特殊情况模式。所以他们不知道如何利用以下事实进行代码生成:在同一核心上的任何 运行 将看到我们在程序顺序中的所有操作直到某个点,即使没有任何障碍。

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

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


单核原子CPU

在这种情况下,您可以通过在 和 atomic_fetch_add 之后屏蔽 来极大地简化它,而不是使用 CAS 模拟具有较早翻转的原子添加。 (那么读者也必须戴口罩,不过那很便宜。)

并且可以使用memory_order_relaxed。如果您想要针对中断处理程序的重新排序保证,请使用 atomic_signal_fence 强制执行编译时排序,而没有针对 运行 时间重新排序的 asm 障碍。 User-space 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;
}

On the Godbolt compiler explorer

@@ 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,因此我们可以在循环和部分存储到 _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 之间共享内存的全局监视器,以及标记为不可共享的内存的本地监视器。该文档说“如果配置为可共享的区域未与全局监视器关联,则对该区域的存储独占操作总是失败,returning 0 in the destination register.”因此,如果当您测试代码时 STREX 似乎 总是 失败(因此您陷入重试循环),那可能就是问题所在。

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

参见 ,这与我要表达的观点相同,即在中断情况下通常不需要 CLREX,当线程之间不进行上下文切换时。

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

但是对于这个问题,您将 切换到 的事情始终是中断处理程序的开始。你不是在进行先发制人的多任务处理。 所以你永远不能从一个 LL/SC 重试循环的中间切换到另一个重试循环的中间。 只要当你 return给它,没关系。

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

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

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

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

运行 同一地址的另一个 LDREX,而 CPU 已经从之前的 LDREX 进行监控,看起来应该没有效果。对 不同的 地址执行独占加载会将监视器重置为打开状态,但为此它始终是相同的地址(除非你在其他代码中有其他原子?)

然后(在做了一些其他事情之后),中断处理程序将 return,恢复寄存器并跳回到较低优先级中断的 LL/SC 循环的中间。

回到低优先级中断,STREX会失败,因为高优先级中断中的STREX重置了监视器状态。很好,我们需要它失败,因为它会存储与在 FIFO 中占据一席之地的更高优先级中断相同的值。 cmp / bne 检测到故障并再次 运行 整个循环。这次它成功了(除非再次中断 ),读取更高优先级中断存储的值并存储 & returning 那 + 1.

所以我认为我们可以在任何地方没有 CLREX 的情况下逃脱,因为中断处理程序总是 运行 在 return 到他们中断的东西的中间之前完成。他们总是从头开始。


单机版

或者,如果没有其他东西可以修改该变量,则根本不需要原子 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 得到的一样。