禁用中断会保护非易失性变量还是会发生重新排序?

Will disabling interrupts protect a non-volatile variable or can reordering happen?

假设 INTENABLE 是一个微控制器的寄存器,enables/disables 中断,我在我的库中的某个地方将其声明为位于适当地址的易失性变量。 my_var 是在一个或多个中断内以及 my_func.

内修改的某个变量

my_func中我想在my_var中做一些读取然后写入的操作(例如+=原子地(从某种意义上说,它必须完全在中断之后或之前发生——在中断进行时不能发生中断。

那时我通常会有这样的东西:

int my_var = 0;

void my_interrupt_handler(void)
{
    // ...

    my_var += 3;

    // ... 
}

int my_func(void)
{
    // ...

    INTENABLE = 0;
    my_var += 5;
    INTENABLE = 1;

    // ...
}

如果我理解正确,如果 my_var 被声明为 volatile,那么 my_var 将保证 "cleanly" 更新(也就是说中断不会在 my_func 的读写之间更新 my_var)因为 C 标准保证易失性内存访问按顺序发生。

我想确认的部分是未声明的部分 volatile。然后,编译器将不保证在禁用中断的情况下进行更新,对吗?

我想知道,因为我已经编写了类似的代码(使用非易失性变量),不同之处在于我通过另一个编译单元(某些库的文件)的函数禁用了中断。如果我理解正确,那么起作用的可能实际原因是编译器不能假定编译单元外部的调用未读取或修改该变量。因此,比方说,如果我使用 GCC 的 -flto 进行编译,则可能会在临界区之外重新排序(坏事)。我有这个权利吗?


编辑:

感谢 Lundin 的评论,我在脑海中意识到我将禁用外围设备中断寄存器的情况与使用特定汇编指令禁用处理器上所有中断的情况混合在一起。

我会想象 enables/disables 处理器中断的指令会阻止其他指令从之前到之后或从之后到之前重新排序,但我仍然这样做不确定这是否属实。

编辑 2:

关于易失性访问:因为我不清楚围绕易失性访问重新排序是标准不允许的事情,是允许但实际上没有发生的事情,还是允许并且确实发生的事情实践中,我想出了一个小测试程序:

volatile int my_volatile_var;

int my_non_volatile_var;

void my_func(void)
{
    my_volatile_var = 1;
    my_non_volatile_var += 2;
    my_volatile_var = 0;
    my_non_volatile_var += 2;
}

使用 arm-none-eabi-gcc 版本 7.3.1 使用 -O2 编译 Cortex-M0 (arm-none-eabi-gcc -O2 -mcpu=cortex-m0 -c example.c) 我得到以下程序集:

movs    r2, #1
movs    r1, #0
ldr     r3, [pc, #12]   ; (14 <my_func+0x14>)
str     r2, [r3, #0]
ldr     r2, [pc, #12]   ; (18 <my_func+0x18>)
str     r1, [r3, #0]
ldr     r3, [r2, #0]
adds    r3, #4
str     r3, [r2, #0]
bx      lr

您可以清楚地看到两个 my_non_volatile_var += 2 合并为一条指令,发生在两次易失性访问之后。这意味着 GCC 在优化时确实会重新排序(我将继续并假设这意味着标准允许这样做)。

如果没有中断,我认为您可以安全地避免调度程序切换以及某些东西在您背后更改您的变量。但归根结底,这可能取决于计算机体系结构。对于典型的 x86 来说是这样。

关于非易失性变量的另一个陷阱是,如果编译器认为它无法更改,那么编译器将优化变量读取,无论该部分是否有中断都会发生这种情况。但是除非变量本质上是易变的,比如输入引脚,否则 "shouldn't" 会破坏临界区。

简短回答:处于关键部分不会从优化器中保存您的非易失性变量。

这里有几个问题。

指令重新排序

关于指令重新排序作为优化的一部分,不允许编译器在易失性变量访问中执行此操作。计算易失变量 "strictly according to the rules of the abstract machine",这意味着在实践中,在易失访问表达式末尾的序列点,必须计算该表达式之前的所有内容。

在这方面,内联汇编器也很可能被认为是安全的,不会重新排序。任何对手动编写的汇编程序进行重新排序或优化的编译器都已损坏,不适合嵌入式系统编程。

这意味着如果您示例中的中断 enable/disable 归结为 setting/clearing 全局中断掩码,作为某种形式的内联汇编程序宏,那么编译器不能很好地重新订购它。如果它是对硬件寄存器的访问,那么它将(希望)是易失性限定的并且也不能重新排序。

这意味着内联汇编程序 instructions/volatile 访问之间的内容相对于内联 assembler/volatile 访问是安全的,不会重新排序,但与其他任何内容无关。

优化与 ISRs/with 共享的变量,没有明显的副作用

这主要是回答 here。在您的具体示例中, my_var 没有明显的副作用,可能会被优化掉。如果它是从中断修改的,也是如此。这是更大的危险,因为围绕非易失性变量访问的内联 asm/volatile 访问无关紧要。

使用 "spaghetti globals"/外部 linkage 设计,编译器在优化时可能确实无法做出各种假设。我不完全确定 link-time 优化 gcc 在这里意味着什么,但是如果你告诉 linker 不要担心其他翻译单元意大利面条式访问,那么我确实认为坏事可能发生。不是因为重新排序,而是因为一般 "no side-effect" 优化。尽管可以说,如果您在整个程序中吐出 extern,这是您最不担心的事情。


如果您没有启用优化,那么您是相当安全的。如果你有,那么通常嵌入式系统编译器是相当宽容的,不会做过于激进的优化。 gcc 是另一回事,它热衷于在 -O2 或 -O3 的嵌入式软件中造成严重破坏,尤其是当您的代码包含某种方式的未明确指定的行为时。

C/C++ volatile 有一个非常狭窄的保证用途范围:直接与外界交互(用 C/C++ 编写的信号处理程序是 "outside" 当它们是异步调用);这就是为什么 volatile 对象访问被定义为 observables,就像控制台 I/O 和程序的退出值(return main 的值)。

一种看待它的方法是想象任何不稳定的访问实际上是由 I/O 在一个特殊的控制台、终端或一对名为 Accesses 的 FIFO 设备上转换的和 Values 其中:

  • 对类型 T 的对象 x 的易失性写入 x = v; 转换为写入 FIFO 访问 指定为 4-uplet 的写入顺序 ("write", T, &x, v)
  • x 的易失性读取(左值到右值转换)被转换为写入 Accesses 一个 3-uplet ("read", T, &x) 并等待值在 .

这样,volatile 就像一个交互式控制台。

一个很好的 volatile 规范是 ptrace 语义(除了我没有人使用,但它仍然是有史以来最好的 volatile 规范):

  • 在程序在明确定义的点停止后,debugger/ptrace 可以检查 volatile 变量;
  • 任何易失性对象访问都是一组定义明确的 PC(程序计数器)点,因此可以在那里设置断点(**):执行易失性访问的表达式转换为代码中的一组地址,其中breaking 在定义的 C/C++ 表达式处导致中断;
  • 任何volatile对象的状态都可以在程序停止时用ptrace以任意方式修改(*),仅限于C/C++中对象的合法值;使用 ptrace 更改 volatile 对象的位模式等同于在 C/C++ 中的 C/C++ 明确定义的断点处添加赋值表达式,因此它等同于更改 C/C+ + 运行 时间的源代码。

这意味着您在这些时间点具有明确定义的易失性对象的 ptrace 可观察状态。

(*) 但是您不能使用 ptrace 将 volatile 对象设置为无效的位模式:编译器可以假定任何对象都具有 ABI 定义的合法位模式.所有使用 ptrace 访问易失性状态的行为都必须遵循与单独编译的代码共享的对象的 ABI 规范。例如,如果 ABI 不允许,编译器可以假定易失性数字对象没有负零值。 (对于 IEEE 浮点数,负零显然是一种有效状态,在语义上不同于正零。)

(**) 内联和循环展开可以在 assembly/binary 代码中生成许多点,对应于唯一的 C/C++ 点;调试器通过为一个源代码级断点设置许多 PC 级断点来处理这一问题。

ptrace 语义甚至不暗示 volatile 局部变量存储在堆栈上而不是寄存器中;这意味着变量的位置,如调试数据中所述,可以通过其在堆栈中的稳定地址在可寻址内存中修改(显然在函数调用期间稳定)或在保存的寄存器的表示中暂停的程序,当执行线程暂停时,它是调度程序保存的寄存器的临时完整副本。

[在实践中,所有编译器都提供了比 ptrace 语义更强的保证:所有 volatile 对象都有一个稳定的地址,即使它们的地址从未在 C/C++ 代码中被采用;这种保证有时是没有用的,而且是非常悲观的。更轻量级的 ptrace 语义保证本身对于 "high level assembly".]

中寄存器中的自动变量非常有用

您无法在不停止的情况下检查 运行ning 程序(或线程);如果没有同步,你无法从任何 CPU 观察到(ptrace 提供了这种同步)。

这些保证适用于任何优化级别。在最小优化下,所有变量实际上都是可变的,程序可以在任何表达式处停止。

在更高的优化级别,计算量减少,甚至可以优化变量,如果它们不包含任何合法的有用信息运行;最明显的例子是 "quasi const" 变量,它没有声明为 const,但使用了 a-if const: set once and never changed。如果用于设置它的表达式稍后可以重新计算,则此类变量在 运行 时不包含任何信息。

许多携带有用信息的变量仍然有一个有限的范围:如果程序中没有表达式可以将有符号整数类型设置为数学负数结果(真正为负数的结果,而不是因为溢出而负数在 2 补码系统中),编译器可以假定它们没有负值。任何在调试器中或通过 ptrace 将这些设置为负值的尝试都将不受支持,因为编译器可以生成集成假设的代码;使对象成为 volatile 将强制编译器允许对象的任何可能的合法值,即使完整代码中只存在正值赋值(每个 TU(翻译单元)中可以访问该对象的所有路径中的代码)可以访问该对象)。

请注意,对于超出 集体翻译代码集 共享的任何对象(所有一起编译和优化的 TU),关于对象的可能值的任何信息都不能在适用的 ABI 旁边假设。

陷阱(不是计算中的陷阱)是在至少单个 CPU、线性、有序的语义编程中期望 Java 易失性语义(根据定义没有命令执行,因为状态上只有 POV,唯一的 CPU):

int *volatile p = 0;
p = new int(1);

没有 volatile 保证 p 只能为 null 或指向值为 1 的对象:在 int 的初始化和volatile 对象,因此异步信号处理程序或 volatile 分配上的断点可能看不到 int 已初始化。

但是 volatile 指针可能不会被推测修改:直到编译器获得 rhs(右侧)表达式不会抛出异常的保证(因此保持 p 不变),它不能修改volatile 对象(因为 volatile 访问根据定义是可观察的)。

回到你的代码:

INTENABLE = 0; // volatile write (A)
my_var += 5;  // normal write
INTENABLE = 1; // volatile write (B)

此处 INTENABLE 是易变的,因此所有访问都是可见的;编译器必须准确地产生那些副作用;正常的写入是抽象机内部的,编译器只需要保留这些副作用 WRT 以产生正确的结果,而不考虑 C/C++ 抽象语义之外的任何信号。

在ptrace语义方面,你可以在点(A)和(B)设置一个断点并观察或改变INTENABLE的值,仅此而已。尽管 my_var 可能无法完全优化,因为它可以通过外部代码(信号处理代码)访问,但该函数中没有其他任何东西可以访问它, 所以 [=23] 的具体表示=] 不必根据当时的抽象机匹配其值.

如果你调用了一个真正外部(编译器无法分析,在"collectively translated code"之外)之间的无所事事的功能,那就不一样了:

INTENABLE = 0; // volatile write (A)
external_func_1(); // actual NOP be can access my_var 
my_var += 5;  // normal write
external_func_2(); // actual NOP be can access my_var 
INTENABLE = 1; // volatile write (B)

请注意,这两个对可能什么都不做的外部函数的调用都是必需的:

  • external_func_1() 可能观察到 my_var
  • 的先前值
  • external_func_2() 可能观察到 my_var
  • 的新值

这些调用是针对必须根据 ABI 进行的外部单独编译的 NOP 函数;因此所有可全局访问的对象都必须携带其抽象机器值的 ABI 表示:对象必须达到其规范状态,这与优化器知道某些对象的某些具体内存表示的优化状态不同还没有达到抽象机的价值

在 GCC 中,这种无所事事的外部函数可以拼写为 asm("" : : : "memory");asm("");"memory" 被含糊地指定,但 明确地 表示 "accesses anything in memory whose address has been leaked globally"。

[看到这里我依赖于规范的透明意图而不是它的文字,因为这些文字经常被错误地选择(#)而且无论如何都没有被任何人用来构建一个实现,而且只是意见人算数,言行不一。

(#) 至少在通用编程语言的世界里,人们没有资格编写正式甚至正确的规范。 ]