c-禁用中断是否会保护非易失性变量或可能发生重新排序



假设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将被保证"干净"地更新(也就是说,在my_func对它的读写之间,中断不会更新my_var),因为C标准保证易失性内存访问按顺序发生。

我想确认的部分是当它没有声明为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为Cortex-M0(arm-none-eabi-gcc -O2 -mcpu=cortex-m0 -c example.c)使用-O2进行编译,我得到以下程序集:

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在优化时确实会重新排序(我将继续假设这意味着它是标准允许的)。

C/C++volatile有一个非常狭窄的保证用途:直接与外部世界交互(用C/C++编写的信号处理程序在异步调用时是"外部"的);这就是为什么易失性对象访问被定义为可观察性,就像控制台I/O和程序的退出值(main的返回值)一样。

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

  • 对类型为T的对象x的易失性写入x = v;被转换为对FIFO的写入访问指定为4元组("write", T, &x, v)的写入顺序
  • CCD_ 19的易失性读取(左值到右值的转换)被转换为写入访问三元组CCD_ 20并等待上的值

通过这种方式,volatile就像一个交互式控制台。

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

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

这意味着在这些点,周期,您有一个定义良好的易失性对象的ptrace可观察状态。

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

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

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

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

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

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

在更高的优化级别上,如果变量不包含任何合法运行的有用信息,则可以减少计算,甚至可以优化变量;最明显的情况是"拟const"变量,它没有声明为const,而是使用了a-ifconst:set一次,从未更改。如果用于设置该变量的表达式稍后可以重新计算,则该变量在运行时不携带任何信息。

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

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

陷阱(而不是计算中的陷阱)是期望在至少单个CPU、线性、有序的语义编程中使用类似Java volatile的语义(根据定义,不存在无序执行,因为状态上只有一个POV,即唯一的CPU):

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

没有volatile保证p只能为null或指向值为1的对象:在int的初始化和volatile对象的设置之间没有隐含的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可能没有被完全优化,因为它可以被外部代码(信号处理代码)访问,但该函数中没有其他东西可以访问它,所以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)

请注意,这两个不做任何事情的调用都可能需要做任何外部函数:

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

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

在GCC中,这种无所事事的外部函数既可以拼写为asm("" : : : "memory");,也可以拼写为仅asm("");"memory"被模糊地指定,但清楚地意味着"访问内存中地址被全局泄露的任何内容"。

[看这里,我依赖于规范的透明意图,而不是它的措辞,因为这些措辞经常被错误地选择(#),而且无论如何都不会被任何人用来构建实现,而且只有人们的意见才重要,这些措辞永远不会起作用。

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

在没有中断的情况下,我认为您可以安全地避免调度器切换,也不会有什么东西在背后更改您的变量。但归根结底,这可能取决于计算机体系结构。这对于典型的x86来说是正确的。

非易失性变量的另一个问题是,如果编译器认为无法更改变量,则会优化变量读取,无论该部分是否中断,都会发生这种情况。但是,除非变量本质上是不稳定的,比如输入引脚,否则"不应该"破坏关键部分。

简言之:处于关键部分不会从优化器中保存非易失性变量。

这里有几点值得关注。

指令重新排序

将指令重新排序作为优化的一部分,编译器不允许在易失性变量访问中这样做。volatile变量的求值"严格按照抽象机器的规则",这意味着在实践中,在volatile访问表达式末尾的序列点,必须对该表达式之前的所有内容进行求值。

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

这意味着,如果您的例子中的中断启用/禁用归结为设置/清除全局中断掩码,作为某种形式的内联汇编程序宏,那么编译器就不能很好地对其进行重新排序。如果这是对硬件寄存器的访问,那么(希望)它将是volatile限定的,也不能重新排序。

这意味着内联汇编程序指令/易失性访问之间的内容是安全的,不会相对于内联汇编程序/易失器访问进行重新排序,但不会相对于其他任何内容进行重新排序。

优化与ISR共享的变量/无明显副作用

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

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


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

相关内容

  • 没有找到相关文章

最新更新