MCU 编程 - C++ O2 优化在循环中中断

电器工程 微控制器 编程 C++
2022-01-16 16:57:11

我知道人们说代码优化应该只带出程序中隐藏的错误,但请听我说。我一直呆在屏幕上,直到遇到通过中断的某些输入。

这是我在调试器中看到的。注意检查的行和截取的表达式值。 在此处输入图像描述

图片中的代码:

//...
ui::Context::addEventListener(ui::EventType::JOYSTICK_DOWN, &constant_dynamic_handler);

while (true) {
    if (choice != 0) //debugger pause
        break;
}

ui::Context::removeEventListener(ui::EventType::JOYSTICK_DOWN, &constant_dynamic_handler);

if (choice == 1) goto constant;
else if (choice == 2) goto dynamic;
else if (choice == 3) goto reset;
else if (choice == 4) goto exit;
//...

//debugger view: 
//expression: choice
//value: 1

是前面声明的constant_dynamic_handlerlambda 函数,它只是更改choice为除0. 我可以在循环中暂停的事实意味着循环没有退出,但值实际上已更改。我无法在调试器中跨过一步,因为它将无法读取 CPU 上的内存,需要重新启动才能再次调试。

choice被简单地声明在与 if 语句块相同的范围内,如int choice = 0;. 它仅在由硬件输入触发的中断侦听器内更改。

该程序使用O0flag 而不是O1or O2

如果需要,我正在使用 NXP K60 和 c++11。是我的问题吗?有什么我不知道的吗?我是 MCU 编程的初学者,并且此代码适用于桌面(刚试过,不行)。

2个回答

代码优化器已经分析了代码,从它可以看到的值choice永远不会改变。而且由于它永远不会改变,因此首先检查它是没有意义的。

解决方法是声明变量volatile,以便编译器被迫发出检查其值的代码,而不管使用的优化级别如何。

(关于线程案例的跨站点重复,而不是中断/信号处理程序案例)。还相关:何时将 volatile 与多线程一起使用?


atomic变量1上的数据竞争是 C++11 2中的未定义行为即潜在的并发读+写或写+写,没有任何同步以提供先发生关系,例如互斥锁或释放/获取同步。


允许编译器假设choice在两次读取之间没有其他线程修改(因为那将是数据争用 UB(未定义行为)),因此它可以CSE并将检查提升出循环。

这实际上是 gcc 所做的(以及大多数其他编译器):

while(!choice){}

优化为如下所示的 asm:

if(!choice)     // conditional branch outside the loop to skip it
    while(1){}  // infinite loop, like ARM  .L2: b .L2

这发生在 gcc 的目标无关部分,因此它适用于所有架构。

希望编译器能够进行这种优化,因为真正的代码包含for (int i=0 ; i < global_size ; i++ ) { ... }. 您希望编译器能够在循环外加载全局,而不是在每次循环迭代或函数中的每次访问时都重新加载它。数据必须在寄存器中,CPU 才能使用它,而不是内存。


编译器甚至可以假设代码永远不会到达choice == 0,因为没有副作用的无限循环是未定义的行为。(非volatile变量的读/写不算作副作用)。类似的东西printf是一种副作用,但调用非内联函数也会阻止编译器优化对 . 的重新读取choice,除非它是static int choice. (然后编译器会知道printf无法修改它,除非此编译单元中的某些内容传递&choice给非内联函数。即转义分析可能允许编译器证明无法static int choice通过调用“未知”来修改非内联函数。)

在实践中,真正的编译器不会优化简单的无限循环,它们会假设(作为实现质量问题或其他问题)您打算编写while(42){}. 但是https://en.cppreference.com/w/cpp/language/ub中的一个示例表明,如果其中有没有副作用的代码被优化掉,clang 将优化掉一个无限循环。


官方支持 100% 可移植/合法的 C++11 方法:

实际上并没有多个线程,而是有一个中断处理程序。在 C++11 术语中,这就像一个信号处理程序:它可以与您的主程序异步运行,但在同一个内核上。

C 和 C++ 长期以来一直有解决方案:volatile sig_atomic_t保证可以在信号处理程序中写入并在主程序中读取

一种整数类型,即使存在由信号产生的异步中断,也可以作为原子实体访问。

void reader() {

    volatile sig_atomic_t shared_choice;
    auto handler = a lambda that sets shared_choice;

    ... register lambda as interrupt handler

    sig_atomic_t choice;        // non-volatile local to read it into
    while((choice=shared_choice) == 0){
        // if your CPU has any kind of power-saving instruction like x86 pause, do it here.
        // or a sleep-until-next-interrupt like x86 hlt
    }

    ... unregister it.

    switch(choice) {
        case 1: goto constant;
        ...
        case 0: // you could build the loop around this switch instead of a separate spinloop
                // but it doesn't matter much
    }
}

标准不保证其他类型是原子的(尽管实际上它们在x86volatile和 ARM等普通架构上至少达到指针宽度,因为本地将自然对齐。 是单字节,现代 ISA 可以原子地存储byte 没有读取/修改/写入周围的单词,尽管您可能听说过有关面向单词的 CPU 的任何错误信息)。uint8_t

您真正想要的是一种使特定访问可变的方法,而不是需要单独的变量。您可能可以使用 来做到这一点*(volatile sig_atomic_t*)&choice,例如 Linux 内核的ACCESS_ONCE宏,但 Linux 编译时禁用严格别名以使这种事情安全。我认为实际上这适用于 gcc/clang,但我认为它不是严格合法的 C++。


std::atomic<T>免锁T

(与std::memory_order_relaxed获得有效的 asm 没有障碍指令,就像你可以从volatile

C++11 引入了一种标准机制来处理一个线程读取变量而另一个线程(或信号处理程序)写入它的情况。

它提供了对内存排序的控制,默认情况下具有顺序一致性,这很昂贵并且您的情况不需要。 std::memory_order_relaxed原子加载/存储将编译为与 相同的 asm(对于您的 K60 ARM Cortex-M4 CPU)volatile uint8_t,其优点是让您可以使用 auint8_t而不是任何宽度sig_atomic_t,同时仍然避免 C++11 数据竞争 UB .

当然,它只能移植到atomic<T>对您的 T 无锁的平台;否则来自主程序和中断处理程序的异步访问可能会死锁。C++ 实现不允许发明对周围对象的写入,所以如果他们有的uint8_t话,它应该是无锁原子的。或者只是使用unsigned char。但是对于太宽而不能自然原子的类型,atomic<T>将使用隐藏锁。当唯一的 CPU 内核卡在中断处理程序,如果在持有该锁的同时信号/中断到达,您将被搞砸。)

#include <atomic>
#include <stdint.h>

volatile uint8_t v;
std::atomic<uint8_t> a;

void a_reader() {
    while (a.load(std::memory_order_relaxed) == 0) {}
    // std::atomic_signal_fence(std::memory_order_acquire); // optional
}
void v_reader() {
    while (v == 0) {}
}

两者都在 Godbolt 编译器资源管理器上编译为相同的 asm,使用 gcc7.2 -O3 for ARM

a_reader():
    ldr     r2, .L7      @ load the address of the global
.L2:                     @ do {
    ldrb    r3, [r2]        @ zero_extendqisi2
    cmp     r3, #0
    beq     .L2          @ }while(choice eq 0)
    bx      lr
.L7:
    .word   .LANCHOR0


void v_writer() {
    v = 1;
}

void a_writer() {
    // a = 1;  // seq_cst needs a DMB, or x86 xchg or mfence
    a.store(1, std::memory_order_relaxed);
}

两者的ARM asm:

    ldr     r3, .L15
    movs    r2, #1
    strb    r2, [r3, #1]
    bx      lr

所以在这种情况下,对于这个实现,volatile可以做同样的事情std::atomic在某些平台上,volatile可能意味着使用访问内存映射 I/O 寄存器所需的特殊指令。 (我不知道有任何这样的平台,在 ARM 上也不是这样。但这是volatile你绝对不想要的一个特性)。


使用atomic,您甚至可以阻止针对非原子变量的编译时重新排序,如果您小心的话,不会产生额外的运行时成本。

不要使用.load(mo_acquire),这将使 asm 对于同时在其他内核上运行的其他线程是安全的。相反,使用宽松的加载/存储并atomic_signal_fence在宽松的加载之后或在宽松的存储之前使用(不是 thread_fence)来获取获取或释放顺序。

一个可能的用例是一个中断处理程序,它写入一个小缓冲区,然后设置一个原子标志以指示它已准备好。或者一个原子索引来指定一组缓冲区中的哪一个。

请注意,如果中断处理程序可以在主代码仍在读取缓冲区时再次运行,则您有数据竞争 UB(以及真实硬件上的实际错误)在没有时序限制或保证的纯 C++ 中,您可能具有理论上的潜力UB(编译器应该假设永远不会发生)。

但如果它真的在运行时发生,它只是 UB;如果您的嵌入式系统具有实时保证,那么您可以保证读取器始终可以在中断再次触发之前完成检查标志并读取非原子数据,即使在最坏的情况下也会出现其他中断并且耽误事情。您可能需要某种内存屏障来确保编译器不会通过继续引用缓冲区而不是您将缓冲区读入的任何其他对象来进行优化。编译器不理解避免 UB 需要立即读取缓冲区一次,除非您以某种方式告诉它。(像 GNU Casm("":::"memory")这样的东西应该可以解决问题,甚至asm(""::"m"(shared_buffer[0]):"memory"))。


当然,像这样的读/修改/写操作a++v++将使用 LL/SC 重试循环或 x86以不同的方式从,编译为线程安全的原子 RMWlock add [mem], 1volatile版本将编译为加载,然后是单独的存储。您可以使用以下原子来表达这一点:

uint8_t non_atomic_inc() {
    auto tmp = a.load(std::memory_order_relaxed);
    uint8_t old_val = tmp;
    tmp++;
    a.store(tmp, std::memory_order_relaxed);
    return old_val;
}

如果你真的想增加choice内存,你可能会考虑volatile避免语法痛苦,如果这是你想要的,而不是实际的原子增量。 但请记住,每次访问 a volatileoratomic都是额外的加载或存储,因此您应该选择何时将其读入非原子/非易失性本地。

编译器目前不优化 atomics,但标准允许在安全的情况下使用它,除非您使用volatile atomic<uint8_t> choice.

同样,我们真正喜欢的是atomic在注册中断处理程序时访问,然后是正常访问。

C++20 提供了这个std::atomic_ref<>

但是 gcc 和 clang 在他们的标准库(libstdc++ 或 libc++)中实际上都不支持这个。 no member named 'atomic_ref' in namespace 'std', 与 gcc 和 clang-std=gnu++2a不过,实际实施它应该没有问题;GNU C 内置函数喜欢__atomic_load处理常规对象,因此原子性是基于每个访问而不是基于每个对象的。

void reader(){ 
    uint8_t choice;
    {  // limited scope for the atomic reference
       std::atomic_ref<uint8_t> atomic_choice(choice);
       auto choice_setter = [&atomic_choice] (int x) { atomic_choice = x; };

       ui::Context::addEventListener(ui::EventType::JOYSTICK_DOWN, &choice_setter);
       while(!atomic_choice) {}

       ui::Context::removeEventListener(ui::EventType::JOYSTICK_DOWN, &choice_setter);

    }

    switch(choice) { // then it's a normal non-atomic / non-volatile variable
    }
}

您可能最终会额外加载一个变量 vs. while(!(choice = shared_choice)) ;,但是如果您在自旋循环之间调用一个函数,并且在使用它时,不强制编译器将最后一次读取结果记录在另一个本地(其中它可能不得不溢出)。或者我想在取消注册之后你可以做一个 finalchoice = shared_choice;使编译器可以choice只保存在一个寄存器中,然后重新读取原子或易失性。


脚注1:volatile

即使数据竞争在技术上也是 UB,但在这种情况下,如果您避免原子读取-修改-写入操作,您volatile在实际实现中获得的行为是有用的,并且通常与atomicwith相同。memory_order_relaxed

何时在多线程中使用 volatile?更详细地解释了多核情况:基本上从不,std::atomic改用(放宽 memory_order)。

加载或存储的编译器生成的代码uint8_t在您的 ARM CPU 上是原子的。Read/modify/write likechoice++不会是一个原子 RMW on ,只是volatile uint8_t choice一个原子负载,然后是一个以后的原子存储,它可以踩到其他原子存储。

脚注 2:C++03

在 C++11 之前,ISO C++ 标准没有提及线程,但旧的编译器以相同的方式工作;C++11 基本上只是正式宣布编译器已经工作的方式是正确的,除非您使用特殊的语言功能,否则应用 as-if 规则来保留单个线程的行为。