【问题标题】:volatile under the hood在引擎盖下挥发
【发布时间】:2017-02-23 15:01:37
【问题描述】:

我需要一些帮助以更好地理解以下段落的一部分:
“ volatile 关键字限定符表示变量可以在程序之外更改。例如,外部设备可能会将数据写入端口。编译器有时会临时使用缓存或寄存器,将值保存在内存位置中优化目的。如果外部写入修改了内存位置,那么这种变化不会反映在缓存或寄存器值中。 (来自书:理解和使用c指针,pg 178-179)

我的歧义在这些短语之间:“将值保存在 a 内存位置”和“如果外部写入修改了内存位置”。

我的问题是:我的印象是,如果外部设备将数据写入端口,该数据将存储到某个位置 (???),然后它们将存储到寄存器/缓存 (??) 和然后在c语言源代码的变量里面。我有什么误解。据我所知,正常的工作流程应该是:外部设备->小型临时缓冲区->RAM 内存中的变量,(当数据从小工具传输到 MCU 的 RAM 时)

#define PORT 0xB0000000
unsigned int volatile * const port = (unsigned int*) PORT;
*port = 0x0BF4; // write to port
value = *port; // read from port

【问题讨论】:

  • 这是关于 memory-mapped 设备。所以从程序的角度来看,一切都是“记忆”。
  • 你用的是什么MCU?作为@EugeneSh。
  • 注意:不要对外围寄存器使用标准整数类型。始终使用固定宽度类型,请参阅stdint.h。写你的文字:不清楚你在问什么。也许您应该首先考虑您的实际问题是什么。也许你问书作者这个问题;我们不擅长解读与堆栈溢出无关的人的意图。
  • 你用的是什么MCU?作为@EugeneSh。说,很多 MCU 都有内存映射的设备,然后在 C/C++ 中,您必须使用适当类型(维度)的指针来写入/读取它。其中一些映射的内存区域可能包含与您在其中写入的值不同的值。这是由于设备的各种行为所致。例如,您可能会看到读取内存映射设备会获得一些状态位,而写入设备则会完成特定的操作。

标签: c embedded volatile


【解决方案1】:

Memory mapped I/O devices 不通过 CPU 内核的寄存器(通常是缓存)。这就是为什么它们是外部的,它们只是挂在内存总线上的某个地方,假装是内存。

因此,来自此类设备的值将直接出现在(对 CPU 而言)看起来像内存的地方。

在你给出的例子中,这个:

*port = 0x0BF4; // write to port

可能会导致 A/D 转换器开始转换,而这

value = *port; // read from port

可以读入结果值。这不是一个非常典型的设计(A/D 转换器往往比这更复杂,等等)但它是可能的。

如果编译器认为“嘿,只是从写入该值的位置读取”,它可能会将这两个语句替换为

value = 0x0BF4; // "optimized", but broken since no more I/O occurs

如果您试图从该 A/D 转换器读取值,这会毁了您的一天。

声明位置volatile 告诉编译器不要对访问该位置的副作用做出任何假设。

如果你看一下基于 STM32F4 ARM 的微控制器,它有大量的内存映射 I/O(串行端口、USB 控制器、以太网、定时器、A/D 和 D/A 转换器,...... '都在那里)加上一堆内部(到核心,但仍然是内存映射)的东西。

【讨论】:

  • 在大多数编译器上,volatile 限定符实际上很少需要用于通过将整数常量转换为指针来访问的内存映射端口。当使用在主线和中断服务例程中都可以访问的变量时,这可能更重要。
  • @supercat 好吧,也许吧,但当然正确的做法是将事物声明为 volatile,您真正想要 volatile 的语义,而不是依赖于“大多数编译器”如何进行的假设行为?工作和正确比工作更好。
  • 了解现代编译器的废话种类当然很好,但我也认为从历史背景中了解何时将关键字添加到仅用于嵌入式和系统编程的语言编译器是有用的需要它来处理中断控制标志等情况。该标准的作者故意避免要求所有实现都适合嵌入式和系统编程。它允许编译器做某事的事实并不意味着用于任何特定目的的编译器应该这样做。
  • 顺便说一句,如果代码观察到 (uintptr_t)&someOrdinaryVariable 等于 0x12345678,左值表达式 *(unsigned char*)0x12345678 是否是访问该变量的合法方式?该标准似乎提供了一点余地,因为它只要求一个指向整数的往返行程产生一个与原始比较相等的指针,而不是一个实际上对除比较以外的任何目的有用的指针,但我倾向于认为任何不能保证如果x==(uintptr_t)P,那么(typeofP)x 将产生一个与p 等价的指针的编译器,不应该...
  • ...define uintptr_t 根本 [这是一个可选类型,如果整数到指针的转换不会产生有用的语义,我真的认为拥有该类型没有多大用处] .
【解决方案2】:

正如其他人所说,这些是 CPU 内核本身外部的项目,它可以是 ram,也可以是内存映射的外围设备(可以说是 uart 状态寄存器或定时器寄存器等)。

#define SOME_STATUS_REGA  (*((volatile unsigned int *)0x10008000))
void fun ( void )
{
    while(SOME_STATUS_REGA==0) continue;
}
#define SOME_STATUS_REGB  (*((unsigned int *)0x10008000))
void more_fun ( void )
{
    while(SOME_STATUS_REGB==0) continue;
}

只有一个目标和工具链产生

00000000 <fun>:
   0:   e59f200c    ldr r2, [pc, #12]   ; 14 <fun+0x14>
   4:   e5923000    ldr r3, [r2]
   8:   e3530000    cmp r3, #0
   c:   0afffffc    beq 4 <fun+0x4>
  10:   e12fff1e    bx  lr
  14:   10008000    andne   r8, r0, r0

00000018 <more_fun>:
  18:   e59f300c    ldr r3, [pc, #12]   ; 2c <more_fun+0x14>
  1c:   e5933000    ldr r3, [r3]
  20:   e3530000    cmp r3, #0
  24:   112fff1e    bxne    lr
  28:   eafffffe    b   28 <more_fun+0x10>
  2c:   10008000    andne   r8, r0, r0

您可以看到 more_fun, not volatile case 它读取位置一次并进行一次比较,但进入无限循环。编译器已经完成了我们告诉它做的事情,因为变量无法改变,没有理由烧掉时钟周期重新读取不会改变的东西,所以如果它不是第一个零并且只读取它永远不会为零所以这陷入了一个无限循环。

如果您将其设置为易失性,您就是在“要求”编译器在每次代码访问它时读取或写入它。您可以在有趣的案例中看到,它每次通过循环都会返回以读取该地址以查看它是否已更改。 volatile 关键字是造成这两种行为之间差异的原因。

更改这些值不一定是硬件,如果您使用全局变量在 isr 和前台代码之间进行通信,那么内存中的变量可以由 isr 和/或前台代码更改,因此两者都需要将其视为 volatile。

您还有多核/多线程处理器的情况,其中每个核心/线程独立地可以访问共享资源。在这种情况下,您不仅需要使用 volatile,而且如果内核不共享相同的缓存,您可能需要不缓存该 ram,并且如果需要原子操作(ldrex/ strex 是 ARM 世界的第一步)。

编辑

另一个演示,问题不仅在于读取,还在于写入。假设您有一个外围设备,您需要编写一个配置寄存器来设置某种模式,然后您再次编写它以使用该模式启用它。或者你有一个硬件接口,每次写入都会增加一些逻辑指针,然后你会执行一系列写入来做某事。

#define SOMETHING1 (*((volatile unsigned char *)0x10002000))
void fun ( void )
{
    SOMETHING1=5;
    SOMETHING1=5;
    SOMETHING1=6;
}
#define SOMETHING2 (*((unsigned char *)0x10002000))
void more_fun ( void )
{
    SOMETHING2=5;
    SOMETHING2=5;
    SOMETHING2=6;
}

没有 volatile,该外围设备将无法正常运行。对同一指针/地址的多次写入被视为死代码并进行优化。

00000000 <fun>:
   0:   e3a02005    mov r2, #5
   4:   e3a01006    mov r1, #6
   8:   e59f300c    ldr r3, [pc, #12]   ; 1c <fun+0x1c>
   c:   e5c32000    strb    r2, [r3]
  10:   e5c32000    strb    r2, [r3]
  14:   e5c31000    strb    r1, [r3]
  18:   e12fff1e    bx  lr
  1c:   10002000    andne   r2, r0, r0

00000020 <more_fun>:
  20:   e3a02006    mov r2, #6
  24:   e59f3004    ldr r3, [pc, #4]    ; 30 <more_fun+0x10>
  28:   e5c32000    strb    r2, [r3]
  2c:   e12fff1e    bx  lr
  30:   10002000    andne   r2, r0, r0

EDIT2

Clang/llvm 也演示了这个问题

#define A (*((volatile unsigned char *)0x10002000))
void afun ( void )
{
    A = 4;
    A = 5;
    A = 6;
    A |= 1;
    while(A==0) continue;
}
#define B (*((unsigned char *)0x10002000))
void bfun ( void )
{
    B = 4;
    B = 5;
    B = 6;
    B |= 1;
    while(B==0) continue;
}

制作

00000000 <afun>:
   0:   e3a00a02    mov r0, #8192   ; 0x2000
   4:   e3a01004    mov r1, #4
   8:   e3800201    orr r0, r0, #268435456  ; 0x10000000
   c:   e5c01000    strb    r1, [r0]
  10:   e3a01005    mov r1, #5
  14:   e5c01000    strb    r1, [r0]
  18:   e3a01006    mov r1, #6
  1c:   e5c01000    strb    r1, [r0]
  20:   e5d01000    ldrb    r1, [r0]
  24:   e3811001    orr r1, r1, #1
  28:   e5c01000    strb    r1, [r0]
  2c:   e5d01000    ldrb    r1, [r0]
  30:   e3510000    cmp r1, #0
  34:   0afffffc    beq 2c <afun+0x2c>
  38:   e12fff1e    bx  lr

0000003c <bfun>:
  3c:   e3a00a02    mov r0, #8192   ; 0x2000
  40:   e3a01007    mov r1, #7
  44:   e3800201    orr r0, r0, #268435456  ; 0x10000000
  48:   e5c01000    strb    r1, [r0]
  4c:   e12fff1e    bx  lr

如果您正在做的事情不在可以优化它们的域中,那么添加 volatile 不会对您造成伤害。 (以某种顺序对每个寄存器进行单次写入,对寄存器进行单次读取,单次也意味着没有循环)。如果您进行多次写入(通常在配置外围设备时发生)进行读取修改写入(x |= 某物,y &= 某物,z ^= 某物等),那肯定会伤害您。

如果您使用的工具链没有优化器,或者您选择不优化,则不会出现此问题,但如果您关闭 volatile,则该代码不可移植,如果您不习惯处理,最终会遇到麻烦具有跨编译或其他类似域的变量/代码(硬件是独立于软件的编译域)。

【讨论】:

  • 有点像你的邮箱。如果你自己没有在邮箱里放任何东西,你有理由走出去检查吗?有没有其他方可以修改邮箱的内容?现在拿你口袋里的东西,如果你没有修改你口袋里的东西,你是否需要检查你的口袋,看看有没有改变,或者你只是知道(假设你的记忆或习惯是这样的)?邮箱是不稳定的,理想情况下你的口袋不是,尽管对我们中的一些人来说......
  • 我不认为volatile 用于内存映射 I/O,而是用于中断驱动标志。 I/O 地址通常是整数到指针转换的直接结果,许多编译器——至少从历史上看——不会努力缓存这些东西。由于缓存 *((unsigned char*)0xD000) 之类的值比缓存具有符号表条目并且可能会破坏代码的东西需要更多的工作,因此无论有没有volatile,这些东西几乎总是免于缓存。
  • 我刚刚演示过,gnu 肯定和其他许多人一样有这个问题。你会看到 volatile 的最常见的地方是微控制器和其他基于裸机的库的无数寄存器定义。有时几千到几十行的代码 volatile 指针等等地址...
  • 可能有数千行不使用volatile 在数字地址处定义寄存器,因为许多编译器不会尝试注册缓存带有或不带有限定符的编号地址。将“volatile”添加到头文件中的每个条目会使其变大,减慢编译速度,并且在许多编译器上毫无用处。此外,编译器可以从缓存硬件寄存器中受益的主要情况最好通过添加限定符来区分写入、使用其值的读取和忽略其值的读取的语义。
  • 欢迎您添加到您的示例演示中,gnu 肯定有问题,演示微不足道,此时我很遗憾没有使用其他的,除了可能我可以创建一个示例的 clang .
【解决方案3】:

在 C 添加“易失性”键盘之前,对没有register 限定符的对象的每次访问都会导致从对象地址加载或存储到该地址。给定 declarations int i,j;,代码:

i+=j;
j+=i;
i+=j;

将从内存中加载ij,添加它们,并将结果存储到i。然后它将再次从内存中加载ij,添加它们,并将结果存储到j。最后,它将第三次从内存中加载ij,添加它们,并将结果存储到i。因此,三个语句将导致六个加载、三个添加和三个存储。

如果ij 没有任何“特殊”之处,则如下所示会更有效:

register int t1,t2;
t1=i; t2=j;
t1+=t2; t2+=t1; t1+=t2;
i=t1; j=t2;

虽然这看起来像更多代码,但对 t1t2 的操作不需要加载和存储。因此,编译器只需要生成两次加载、三次加法和两次存储——与原来相比节省了四次加载和一次存储的成本。

让编译器自动将前一种代码转换为后者将有助于解决一个问题:有时看起来像变量的东西可能会以编译器不知道的方式发生变化。这可能是因为除了内存之外的电路连接到内存总线(许多系统具有连接以在代码尝试读取或写入某些地址时响应的 I/O 设备),或者因为机器可能通过调度响应外部刺激控制到称为中断处理程序的特殊代码部分,然后在中断处理程序返回时恢复它正在执行的任何操作。中断处理程序经常读取和写入变量,这些变量也可能被主线代码访问(确实,这是它们存在的原因之一),但如果代码执行以下操作:

while(!data_received)
  ;

一旦数据可用,则依赖于中断处理程序设置 data_received,如果编译器将其替换为以下代码,则此类代码可能会失败:

t1 = data_received;
while(t1)
  ;

这会“更快”地执行循环,但在数据到达时无法退出循环。

volatile 的目的是告诉编译器某些对象需要“特殊”处理。一些编译器(明智的,恕我直言)会将volatile 解释为表示访问如此标记的对象可能会以编译器不知道的方式任意影响系统中的所有内容,因此允许以下构造:

extern volatile char * volatile dma_mem;
extern volatile unsigned dma_count, dma_command, dma_busy;

void put_data(char *data, unsigned size)
{
  dma_mem = data;
  dma_count = size;
  // Following will trigger hardware to automatically copy "dma_count"
  // bytes from memory starting at "dma_mem"; dma_busy will read as
  // zero once operation is complete.
  dma_command = OUTPUT_MEMORY; // Exact value depends on ardware
  while(dma_busy)
    ;
}

在一个避免在volatile 访问中将任何内容保存在寄存器中的编译器上,如果所有外部访问在函数返回之前完成,则可以使用上述函数从“普通”内存中输出数据。但是,如果编译器以优化的名义在volatile 访问中将内容保存在寄存器中,那么除非放入数据的缓冲区也符合volatile 的条件,否则此类代码可能会失败。

PS——虽然volatile 可以并且经常用于 I/O 访问,但对于那些受中断影响的事物来说,它通常不应该(*)几乎一样需要。在许多情况下,I/O 地址将使用类似的结构来定义

#define PORTA (*(unsigned char*)0xD000)
#define PORTB (*(unsigned char*)0xD002)

虽然标准不要求编译器将此类地址视为易失性地址,但许多编译器无论如何都会这样做,因为程序员使用此类地址意味着他们知道编译器不知道的事情。相比之下,由中断处理程序设置的标志在编译器看来就像普通 RAM,只有 volatile 标志表明它们有什么特别之处。

(*) 我见过很多供应商提供的头文件,它们不使用volatile 作为 I/O 地址。如果编译器将生成带有或不带有该关键字的相同代码,那么为编译器添加更多冗长的语句以在每次构建时仔细阅读将无缘无故地减慢编译速度。 该标准的作者故意避免要求所有编译器都适合嵌入式或系统编程,因此没有努力禁止会使编译器不适用于此类目的的行为。用于特定目的的代码应该只适用于适合这种目的的编译器;如果此类代码在故意使其不太适合该目的的编译器上失败,这并不意味着代码“损坏”——而是意味着编译器不再适合与此类代码一起使用。

PS--对于编译器要基于不是volatile 的常量地址进行任何有用的优化,它必须要么“知道”没有观察到其他对象具有相同的地址,要么允许即使两个整数 x 和 y 相等,*(uint8_t*)x*(uint8_t)y 写入其中一个可能不会被识别为影响另一个。由于标准说将指针往返返回整数并返回会产生与原始指针“比较相等”的东西,但并没有说它实际上可以用于任何目的,这将是符合但出乎意料的。

例如,考虑以下包含两个独立翻译单元的程序[假设包含所需的标题]

// UNIT ONE
extern unsigned char foo;
extern uintptr_t volatile tfoo;

int foo_addr(void)
{
   tfoo = (uintptr_t)&foo;
   return tfoo == 0x12345678;
}

// UNIT TWO
void foo_addr(void);
unsigned char foo;
uintptr_t volatile tfoo;
int main(void)
{
  int ok = foo_addr();
  foo = 2;
  if (ok)
    (unsigned char*)0x12345678 = 4;
  return ok + foo;
}

如果 foo 碰巧没有被赋予地址 0x12345678,则不会对地址 0x12345678 进行写操作,代码将返回 0。但是,如果 foo 的地址是 0x12345678,那么 (unsigned char*)0x12345678 将是指向foo 的合法指针,并且应该要求编译器识别访问,除非它决定不将往返指针到整数的转换视为产生可用指针。

到目前为止,考虑(unsigned char*)0x12345678 as aliasing everything it would need to alias would be to treat it asvolatileand refrain from caching in registers anything whose address has been exposed. Useful optimizations from treating such a variable as not beingvolatile` 的最简单方法很少见,除非编译器愿意弯曲指针语义。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2019-05-12
    • 2011-04-01
    • 2019-04-07
    相关资源
    最近更新 更多