中断安全FIFO中的DMB指令

时间:2019-01-06 11:38:03

标签: c gcc assembly c11 atomicity

this thread相关,我有一个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

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

  • 我需要所有这些dmb吗?
  • 这些说明是否会对性能造成明显的影响,还是我可以忽略掉它?
  • 如果在dmb期间发生了中断,那么还会产生几个额外的延迟周期?

3 个答案:

答案 0 :(得分:3)

TL:DR是的,LL/SC(STREX / LDREX)与禁用中断相比,通过使原子RMW可重试而可中断,与中断中断相比,它可以更好地解决中断延迟。

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

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


不幸的是,像gcc这样的C11编译器没有针对单处理器系统或单线程代码的特殊情况模式。因此,他们不知道如何利用以下事实进行代码生成:即使在没有任何障碍的情况下,在同一内核上运行的任何东西都将在一定程度上看到我们所有的程序顺序。

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

即使在用于多线程代码的多核系统上,仅由几个ALU指令分隔的背对背dmb指令也是多余的。这是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;
}

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,它包含一组任意操作,如add和mask,因此我们可以在循环中获取ubfx并存储到_head的内容的一部分。但是,对于LDREX / STREX,有特定于编译器的内在函数:Critical sections in 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之间共享内存的全局监视器,以及一个用于标记为不可共享的本地监视器。该文档说:“如果配置为Shareable的区域未与全局监视器关联,则对该区域的Store-Exclusive操作将始终失败,并在目标寄存器中返回0。”因此,如果在测试代码时STREX似乎总是 失败(因此您陷入了重试循环),则可能是问题所在。

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

请参见When is CLREX actually needed on ARM Cortex M7?,这与我将要指出的观点相同,即在中断情况下,当不需要在线程之间进行上下文切换时,通常不需要

但是对于这个问题,您将切换为的事情始终是中断处理程序的开始。您没有执行抢先式多任务处理。 因此,您永远无法从一个LL / SC重试循环的中间切换到另一个LL / SC重试循环。只要STREX返回时,它在低优先级中断中第一次失败,就可以了。

之所以会是这种情况,是因为更高优先级的中断只会在成功执行STREX(或完全不执行任何原子RMW)之后返回。

因此,我认为即使没有在分配给C函数之前从内联asm或中断处理程序使用clrex的情况下,您也可以接受。该手册说,数据中止异常会导致监视器丢失在架构上未定义,因此请确保至少在该处理程序中使用CLREX。

如果在LDREX和STREX之间插入中断,则LL已将旧数据加载到寄存器中(并可能计算了新值),但是由于STREX尚未将任何内容存储回内存没跑。

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

在CPU已从以前的LDREX监视时,为相同的地址运行另一个LDREX似乎无效。对不同地址执行排它加载会将监视器重置为打开状态,但是为此,它将始终是同一地址(除非您在其他代码中包含其他原子?)

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

在低优先级中断中,STREX将失败,因为高优先级中断中的STREX会重置监视器状态。很好,我们需要失败,因为它可以存储与优先级较高的中断相同的值,而该中断在FIFO中占有优势。 cmp / bne检测到故障并再次运行整个循环。这次它成功(除非再次中断 ),读取优先级较高的中断存储的值,并存储并返回+ 1。

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


单写版本

或者,如果没有其他事情可以修改该变量,则根本不需要原子RMW,只需一个纯原子负载,然后一个新值的纯原子存储即可。 ({@ {1}}或任何读者都受益。)

或者,如果没有其他线程或中断完全触及该变量,则不必是_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 所获得的asm相同。

答案 1 :(得分:2)

您的代码不是以“裸机”的方式编写的。这些“通用”原子函数不知道读取或存储的值是位于内部存储器中,还是可能是位于远离内核的某个地方并且通过总线(有时还包括写/读缓冲区)连接的​​硬件寄存器。

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

IMO只要您想以原子方式访问内存位置,就足以禁用中断。

在裸金属uC开发中,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个额外的时钟。

它保证原子性并且不提供不必要的开销

答案 2 :(得分:0)

在类似情况下

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的开头和结尾都可能有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都必须对代码进行基准测试)。 {{1}}不占用CPU周期;它只是停止在CPU中进行流水线操作。

相关问题