符合ISO标准的内存映射I/O寄存器访问
对寄存器的访问是否存在非未定义行为?
将整数强制转换为指针的行为是实现定义的。但对未创建对象的访问是未定义的行为。volatile不能解决这个问题。它唯一的保证是每次访问都会直接进行。
使用 placement new 和
std::start_lifetime_as 并不好,因为它们对指针有一些条件:指针必须是存储指针(来自operator new或分配函数)。将整数到指针的强制转换得到的指针不符合该要求。
我知道编译器的扩展规则,对于实现定义的行为来说是没有问题的,但对未定义行为呢?
我看到的唯一解决办法是使用汇编,或尝试玩转new运算符。new运算符将每个指针都定义为存储指针。
解决方案
很多方面的C++都继承自C 标准,后者把“未定义行为”作为一个兜底概念,包含诸如内存映射I/O等行为,其行为可能被定义,也可能不被定义,取决于超出语言范围的因素。根据C 标准发布的设计理由(Rationale):
未定义行为赋予实现者不去捕捉某些难以诊断的程序错误的许可。它也标识了可能的符合性语言扩展的领域:实现者可以通过对官方未定义行为给出定义来扩展语言。
当对行为的规范被视为优先于将边缘情况通常归类为未定义行为的情形时,这种做法运作良好。不幸的是,C与 C++标准也把“未定义行为”的概念应用于 原本已定义的边缘情况,这些情况有时在正确处理上会显得尴尬。如果标准把这些边缘情况表述为会触发“未定义行为”,那么编译器就不需要再担心如何正确处理它们了。
涉及内存映射I/O的操作通常涉及一种有用的“未定义行为”,它允许实现与执行环境通过定义超出标准范围的更多行为来扩展语言语义。不幸的是,没有官方的区分把那类“未定义行为”与被邀请去胡闹地行事的行为区分开来。
备选方案
C与 C++标准指出,如果存在一个 uintptr_t 类型,将该类型转换为 void* 指向任意对象指针是被允许的。此外,标准对 volatile 说明符的作用也较模糊,但几乎所有实现都表示通过 volatile 指针或引用进行内存访问不会被优化掉。内存映射I/O正是它的设计用途,因此你通常会把地址强制转换为指向 volatile 的指针,指向一个 struct 或数组。如果你使用的不是与兼容对象指针进行往返转换到一个 void* 再转回一个足够宽的整数的方案,标准说你可能会创建一个陷阱表示,这是未定义行为,但C 标准也表示,
将指针转换为整数或整数转换为指针的映射函数应当与执行环境的寻址结构保持一致。
在现实世界中,这种工作方式的反直觉实例包括16位 DOS(以及16位 Windows和 OS/2)。在DOS的 Borland Turbo C中,你需要一个 MK_FP 宏,把像 B800:0000 这样的8086地址转换成所谓的带有段寄存器正确设置以进行内存映射I/O的远指针。在为80286和 80386的某些16位操作系统上,你通常需要先获得一个有效的16位段选择子,以对绝对地址进行别名,通常通过DOS Protected-Mode Interface。在80286的保护模式下,即使生成一个垃圾指针,也可能把一个无效的选择子加载到段寄存器中,使CPU无法在描述符表中找到该选择子并导致硬件故障崩溃。这也是为什么把任意整数常量转换为指针是未定义行为的原因。
在大多数现代操作系统上,程序看到的是一个单一的扁平化的32位或64位地址空间,因此从 uintptr_t 转换为ISA地址是透明的。这些地址位于进程的虚拟地址空间中,而不是系统总线上可以进行MMIO的物理地址,这也是一种有意的安全特性。操作系统随后可能提供将设备内存映射到程序的地址空间的方式,或者可能没有,或者可能暴露给设备驱动程序的接口。
在POSIX上,标准的用户态接口是打开一个对设备的文件描述符,用 fstat() 获取其缓冲区大小,然后用 mmap() 将该文件描述符映射到进程的地址空间。设备本身对用户态进程来说是一个不透明的黑盒,但例如Linux内核提供了一个 register_framebuffer() 接口,供内核态的图形驱动实现它。