首页 > 解决方案 > 禁用中断会保护非易失性变量还是会发生重新排序?

问题描述

假设INTENABLE是一个微控制器的寄存器,它启用/禁用中断,我在我的库中将它声明为位于适当地址的易失性变量。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将保证“干净”更新(也就是说,中断不会在它的读取和写入之间更新),因为 C 标准保证 volatile内存访问按顺序发生。my_varmy_func

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

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


编辑:

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

我想启用/禁用处理器中断的指令会阻止其他指令从之前到之后或从之后到之前重新排序,但我仍然不确定这是否正确。

编辑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 编译-O2Cortex-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被合并为一条指令,该指令发生在两次 volatile 访问之后。这意味着 GCC 在优化时确实会重新排序(我将继续并假设这意味着它是标准允许的)。

标签: cembeddedvolatileinterrupt-handlingsequence-points

解决方案


C/C++ volatile 的保证用途范围很窄:直接与外界交互(用 C/C++ 编写的信号处理程序在被异步调用时是“外部”);这就是为什么volatile 对象访问被定义为 observables,就像控制台 I/O 和程序的退出值(main 的返回值)一样。

一种看待它的方法是想象任何易失性访问实际上是由特殊控制台上的 I/O 转换的,或者是终端或一对名为AccessesValues的 FIFO 设备,其中:

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

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

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

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

这意味着您在这些点(周期)处具有明确定义的易失性对象的 ptrace 可观察状态。

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

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

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

[在实践中,所有编译器都提供了比 ptrace 语义更强的保证:所有 volatile 对象都有一个稳定的地址,即使它们的地址从未在 C/C++ 代码中使用;这种保证有时是没有用的,而且是非常悲观的。较轻的 ptrace 语义保证本身对于“高级汇编”中寄存器中的自动变量非常有用。]

不停止就无法检查正在运行的程序(或线程);如果没有同步,您将无法从任何 CPU 进行观察(ptrace 提供了这种同步)。

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

在更高的优化级别,计算减少,如果变量不包含任何合法运行的有用信息,甚至可以优化它们;最明显的情况是“准 const”变量,它没有被声明为 const,而是使用了 a-if const: 设置一次并且从不改变。如果用于设置它的表达式可以在以后重新计算,则此类变量在运行时不携带任何信息。

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

请注意,对于在集体翻译的代码集(一起编译和优化的所有 TU)之外共享的任何对象,除了适用的 ABI 之外,不能假设对象的可能值。

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

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

没有 volatile 保证p只能为 null 或指向值为 1 的对象:在 volatile 对象的初始化int和 volatile 对象的设置之间没有隐含的 volatile 排序,因此在 volatile 赋值上使用异步信号处理程序或断点可能看不到int初始化。

但是 volatile 指针可能不会被推测性地修改:在编译器获得 rhs(右手边)表达式不会抛出异常(因此p保持不变)的保证之前,它不能修改 volatile 对象(因为 volatile 访问是一个 observable根据定义)。

回到你的代码:

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

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

在 ptrace 语义方面,您可以在点 (A) 和 (B) 处设置断点并观察或更改的值,INTENABLE但仅此而已。虽然my_var可能没有完全优化,因为它可以被外部代码(信号处理代码)访问,但是该函数中没有其他东西可以访问它,所以根据抽象的具体表示my_var不必匹配它的值机器在那个时候

如果您调用了一个真正的外部(编译器无法分析,在“集体翻译的代码”之外)之间的无操作函数,那就不同了:

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)

请注意,这两个对 do-nothing-possibly-do-anything 外部函数的调用都是必需的:

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

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

在 GCC 中,这种无所事事的外部函数可以拼写为asm("" : : : "memory");or 或 just asm("");"memory"含糊地指定但清楚地表示“访问内存中地址已在全球范围内泄漏的任何内容” 。

[See here I'm relying on the transparent intent of the specification and not on its words as the words are very often badly chosen(#) and not used by anyone to build an implementation anyway, and only the opinion of people count, the words never do.

(#) at least in the world of common programming languages where people don't have the qualification to write formal or even correct specifications. ]


推荐阅读