通过预定义的静态地址访问寄存器是 C++ 中未定义的行为吗?

Is accessing registers through predefined static addresses undefined behaviour in C++?

我正在将 C++ 程序编译为 运行 在独立环境中 CPU 我正在 运行ning 定义了一个可用的 32 位外设寄存器( 编辑:PERIPH_ADDRESS 处的内存映射)(正确对齐,不与任何其他 C++ 对象、堆栈等重叠)。

我使用 PERIPH_ADDRESS 预定义编译以下代码,稍后 link 使用完整程序和 运行 它。

#include <cstdint>

struct Peripheral {
    const volatile uint32_t REG;
};

static Peripheral* const p = reinterpret_cast<Peripheral*>(PERIPH_ADDRESS);

uint32_t get_value_1() {
    return p->REG;
}

static Peripheral& q = *reinterpret_cast<Peripheral*>(PERIPH_ADDRESS);

uint32_t get_value_2() {
    return q.REG;
}

extern Peripheral r;
// the address of r is set in the linking step to PERIPH_ADDRESS

uint32_t get_value_3() {
    return r.REG;
}

是否有任何 get_value 函数(直接或通过 p/q)具有未定义的行为?如果是,我可以修复它吗?

我认为一个等价的问题是:任何符合标准的编译器都可以拒绝为我编译预期的程序吗?例如,打开 UB 消毒器的一个。

我看过 [basic.stc.dynamic.safety] and [basic.compound#def:object_pointer_type] 但这似乎只限制了指向动态对象的指针的有效性。我认为它不适用于这段代码,因为 PERIPH_ADDRESS 处的 "object" 从未被假定为动态的。我想我可以肯定地说 p 表示的存储永远不会达到其存储持续时间的结束,它可以被认为是 static.

我还查看了 以及该问题的答案。它们也只引用动态对象的地址和它们的有效性,所以它们没有回答我的问题。

我考虑过但无法回答自己但可能有助于解决主要问题的其他问题:

显然,我更喜欢引用任何最新 C++ 标准的答案。

指针转换的含义由实现定义[expr.reinterpret.cast]

A value of integral type or enumeration type can be explicitly converted to a pointer. A pointer converted to an integer of sufficient size (if any such exists on the implementation) and back to the same pointer type will have its original value; mappings between pointers and integers are otherwise implementation-defined.

因此这是明确的。如果您的实现向您保证转换的结果有效,那么您没问题。

链接的问题是关于指针运算的,与手头的问题无关。

† 根据定义,valid pointer points to an object, implying subsequent indirections are also well-defined. Care should be exercised in making sure the object is within its lifetime

我不知道您是在这里寻找语言律师的答案,还是实用的答案。我给你一个实际的答案。

语言定义没有告诉您该代码的作用。您得到的答案表明该行为是实现定义的。我不相信任何一种方式,但这并不重要。假设行为未定义。这并不意味着会发生坏事。这意味着 C++ 语言定义没有告诉您该代码的作用。如果您使用的编译器记录了它的作用,那很好。如果编译器没有记录它,但每个人都知道它的作用,那也没关系。您展示的代码是在嵌入式系统中访问内存映射寄存器的一种合理方式;如果它不起作用,很多人会不高兴。

Does any of the get_value functions (either directly or through p/q) have undefined behavior?

是的。他们都。他们都在访问一个对象(类型 Peripheral)的值,就 C++ 对象模型而言,该对象不存在。这在 [basic.lval/11] 中定义,又名:严格的别名规则:

If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:

问题不在于 "cast";这是使用该演员表的 results。如果那里有指定类型的对象,则行为是明确定义的。如果没有,那么它是未定义的。

因为那里没有 Peripheral,所以它是 UB。

现在,如果您的执行环境承诺在该地址 类型 Peripheral 的对象,那么这是明确定义的行为。否则,没有。

If yes, can I fix it?

没有。就靠UB了。

您在受限环境中工作,使用的是独立实施,可能适用于特定架构。我不会出汗。

像上面这样的代码有效地试图将 C 用作 "high-level assembler" 的一种形式。虽然有些人坚持认为 C 不是高级汇编程序,但 C 标准的作者在他们发布的基本原理文档中是这样说的:

Although it strove to give programmers the opportunity to write truly portable programs, the C89 Committee did not want to force programmers into writing portably, to preclude the use of C as a “high-level assembler”: the ability to write machine-specific code is one of the strengths of C. It is this principle which largely motivates drawing the distinction between strictly conforming program and conforming program (§4).

C 和 C++ 标准有意避免要求所有实现都可用作高级汇编程序,并且不尝试定义使它们适合此类目的所需的所有行为。因此,标准没有定义像您这样有效地将编译器视为高级汇编器的构造的行为。然而,该标准的作者明确认识到某些程序将该语言用作高级汇编程序的能力的价值,因此明确打算像您这样的代码可用于旨在支持此类构造的实现——失败定义行为绝不意味着这样的代码是 "broken".

甚至在编写标准之前,用于在平台上进行低级编程的实现在处理指针和类似大小的整数之间的转换是有意义的,就像简单地重新解释其中的位一样,基本上一致地处理这样的转换方法。这种处理极大地促进了此类平台上的低级编程,但标准的作者认为没有理由强制执行它。在这样的行为没有意义的平台上,这样的强制将是有害的,而在那些有意义的平台上,编译器编写者在有或没有它的情况下都会表现得适当,因此没有必要。

不幸的是,标准的作者有点太冒昧了。已发布的基本原理陈述了维护 C 精神的愿望,其原则包括 "Don't prevent the programmer from doing what needs to be done"。这表明,如果在具有自然强内存排序的平台上,可能需要在不同时间由不同的执行上下文 "owned" 存储区域,这是一种用于此类低级编程的高质量实现平台,给出如下内容:

extern volatile uint8_t buffer_owner;
extern volatile uint8_t * volatile buffer_address;

buffer_address = buffer;
buffer_owner = BUFF_OWNER_INTERRUPT;
... buffer might be asynchronously written at any time here
while(buffer_owner != BUFF_OWNER_MAINLINE)
{  // Wait until interrupt handler is done with the buffer and...
}  // won't be accessing it anymore.
result = buffer[0];
在 代码读取 object_owner 并收到值 BUFF_OWNER_MAINLINE 之后,

应该从 buffer[0] 读取一个值。不幸的是,一些实现认为尝试使用一些早期观察到的 buffer[0] 值比将易失性访问视为可能释放和重新获取相关存储的所有权更好。

一般来说,编译器将在禁用优化的情况下可靠地处理此类构造(实际上无论有无 volatile 都会这样做),但如果不使用特定于编译器的指令(这也会使 volatile 不必要)。我认为 C 精神应该明确指出,用于低级编程的高质量编译器应该避免会削弱 volatile 语义的优化,这种优化会阻止低级程序员做可能需要的事情目标平台,但显然还不够明确

C 和 C++ 标准都没有正式涵盖链接由不同编译器编译的目标文件的行为。C++ 标准不提供任何保证,您可以与使用任何 C 编译器编译的模块,甚至与此类模块接口的意义; C++ 编程语言甚至不遵从 C 标准的任何核心语言特性;没有正式保证 C++ class 与 C 结构兼容。 (C++ 编程语言甚至没有正式承认存在一种 C 编程语言,其某些基本类型与 C++ 中的拼写相同。)

编译器之间的所有接口根据定义由 ABI 完成:应用程序二进制接口。

使用在实现之外创建的对象必须遵循 ABI;其中包括在内存中创建对象表示的系统调用(如 mmap)和 volatile 对象。

这是对@curiousguy @Passer By、@Pete Backer 和其他人最初发布的非常有用的答案的总结。这主要基于标准文本(因此是 language-lawyer 标签)以及其他答案提供的参考。我将其设为社区 Wiki,因为 none 的答案完全令人满意,但许多答案都很好。欢迎编辑。

代码在最好的情况下是实现定义的,但它可能有未定义的行为。

实现定义的部分:

  1. reinterpret_cast 从整数类型到指针类型是实现定义的。 [expr.reinterpret.cast/5]

    A value of integral type or enumeration type can be explicitly converted to a pointer. A pointer converted to an integer of sufficient size (if any such exists on the implementation) and back to the same pointer type will have its original value; mappings between pointers and integers are otherwise implementation-defined. [ Note: Except as described in [basic.stc.dynamic.safety], the result of such a conversion will not be a safely-derived pointer value. — end note ]

  2. 对易失性对象的访问是实现定义的。 [dcl.type.cv/5]

    The semantics of an access through a volatile glvalue are implementation-defined. If an attempt is made to access an object defined with a volatile-qualified type through the use of a non-volatile glvalue, the behavior is undefined.

需要避免的UB部分:

  1. 指针必须指向C++中有效的对象abstract machine,否则程序有UB。

    据我所知,如果抽象机的实现是由健全、一致的编译器和链接器 运行 在具有如上所述的寄存器内存映射的环境中生成的程序,那么实现可以说在那个位置有一个C++ uint32_t对象,并且没有任何函数的UB。这似乎是 [intro.compliance/8]:

    允许的

    A conforming implementation may have extensions (including additional library functions), provided they do not alter the behavior of any well-formed program. [...]

    这仍然需要对 [intro.object/1] 进行自由解释,因为对象不是以任何列出的方式创建的:

    An object is created by a definition ([basic.def]), by a new-expression, when implicitly changing the active member of a union ([class.union]), or when a temporary object is created ([conv.rval], [class.temporary]).

    如果抽象机的实现有一个带有消毒剂的编译器(-fsanitize=undefined-fsanitize=address),那么可能需要向编译器添加额外的信息以说服它 该位置的有效对象。

    当然 ABI 必须是正确的,但这在问题中已经暗示(正确的对齐和内存映射)。

  2. 实现是 strict 还是 relaxed 指针安全 [basic.stc.dynamic.safety/4]. With strict pointer safety, objects with dynamic storage duration can only be accessed through a safely-derived pointer [basic.stc.dynamic.safety] . p&q 值不是那个,但是他们引用的对象没有动态存储持续时间,所以这个条款不适用。

    An implementation may have relaxed pointer safety, in which case the validity of a pointer value does not depend on whether it is a safely-derived pointer value. Alternatively, an implementation may have strict pointer safety, in which case a pointer value referring to an object with dynamic storage duration that is not a safely-derived pointer value is an invalid pointer value [...]. [ Note: The effect of using an invalid pointer value (including passing it to a deallocation function) is undefined, see [basic.stc].

实际的结论似乎是需要实现定义的支持来避免 UB。对于理智的编译器,生成的程序是无 UB 的,或者它可能具有非常可靠的 UB(取决于您如何看待它)。然而,消毒剂可以合理地抱怨代码,除非他们被明确告知正确的对象存在于预期的位置。指针的推导应该不是实际问题

实际上,在您建议的结构中,这个

struct Peripheral {
    volatile uint32_t REG;  // NB: "const volatile" should be avoided
};

extern Peripheral r;
// the address of r is set in the linking step to PERIPH_ADDRESS

uint32_t get_value_3() {
    return r.REG;
}

最有可能不会 运行 违反 "surprising" 优化器行为,我认为它的行为在最坏的情况下是实现定义的。

因为 rget_value_3 的上下文中是一个具有外部 linkage 的对象,该对象未在此翻译单元中定义,编译器必须假定该对象确实存在,并且在为 get_value_3 生成代码时已经正确构建。 Peripheral 是一个 POD 对象,因此无需担心静态构造函数的顺序。将对象定义为在 link 时间位于特定地址的功能是实现定义行为的缩影:它是您正在使用的硬件的 C++ 实现的官方文档化功能,但它不包含在C++ 标准。

警告 1:绝对不要尝试使用非 POD 对象;特别是,如果 Peripheral 有一个非平凡的构造函数或析构函数,这可能会导致在启动时对该地址进行不适当的写入。

注意事项 2:同时正确声明为 constvolatile 的对象极为罕见,因此编译器在处理此类对象时往往会出现错误。我建议只对这个硬件寄存器使用 volatile

注意事项 3:正如 supercat 在评论中指出的那样,任何时候在特定内存区域中只能有一个 C++ 对象。例如,如果有多组寄存器复用到一个地址块上,您需要以某种方式用单个 C++ 对象(也许联合会起作用)来表达它,而不是用分配相同基地址的多个对象。