C 未定义的行为.严格的混叠规则或不正确的对齐方式



我无法解释这个程序的执行行为:

#include <string> 
#include <cstdlib> 
#include <stdio.h>
typedef char u8;
typedef unsigned short u16;
size_t f(u8 *keyc, size_t len)
{
u16 *key2 = (u16 *) (keyc + 1);
size_t hash = len;
len = len / 2;
for (size_t i = 0; i < len; ++i)
hash += key2[i];
return hash;
}
int main()
{
srand(time(NULL));
size_t len;
scanf("%lu", &len);
u8 x[len];
for (size_t i = 0; i < len; i++)
x[i] = rand();
printf("out %lun", f(x, len));
}

因此,当它使用 gcc 使用 -O3 编译并使用参数 25 运行时,它会引发段错误。没有优化,它可以正常工作。我已经反汇编了它:它正在矢量化,编译器假设key2数组以 16 个字节对齐,因此它使用movdqa.显然是UB,虽然我无法解释。我知道严格的混叠规则,但不是这种情况(我希望),因为据我所知,严格的混叠规则不适用于char。为什么 gcc 假定此指针已对齐?Clang 也运行良好,即使进行了优化。

编辑

unsigned char改为char,并删除了const,它仍然有段错误。

编辑2

我知道这段代码不好,但据我所知,它应该可以正常工作,严格的混叠规则。违规行为究竟在哪里?

代码确实打破了严格的混叠规则。但是,不仅存在别名冲突,而且不会因为别名冲突而发生崩溃。发生这种情况是因为unsigned short指针未正确对齐;如果结果未正确对齐,则指针转换本身也是未定义的。

C11(草案n1570)附录J.2:

1 在以下情况下未定义行为:

....

  • 两种指针类型之间的转换会产生错误对齐的结果 (6.3.2.3)。

用 6.3.2.3p7 说

[...]如果生成的指针未正确对齐引用的类型 [68],则行为未定义。[...]

unsigned short对实现(x86-32 和 x86-64)的对齐要求为2,您可以使用该对齐要求进行测试

_Static_assert(_Alignof(unsigned short) == 2, "alignof(unsigned short) == 2");

但是,您强制u16 *key2指向未对齐的地址:

u16 *key2 = (u16 *) (keyc + 1);  // we've already got undefined behaviour *here*!

有无数的程序员坚持认为,不对齐的访问保证在任何地方的x86-32和x86-64上都可以工作,并且在实践中不会有任何问题 - 好吧,他们都错了。

基本上发生的情况是编译器注意到

for (size_t i = 0; i < len; ++i)
hash += key2[i];

如果适当对齐,可以使用 SIMD 指令更有效地执行。这些值使用MOVDQA加载到 SSE 寄存器中,这要求参数与16 个字节对齐:

当源或目标操作数是内存操作数时,操作数必须在 16 字节边界上对齐,否则将生成常规保护异常 (#GP)。

对于指针在开始时未正确对齐的情况,编译器将生成代码,将前 1-7 个无符号短裤逐个求和,直到指针对齐到 16 个字节。

当然,如果你从一个指向数地址的指针开始,即使加上 7 乘以 2 也不会将 1 到达一个与 16 字节对齐的地址。当然,编译器甚至不会生成可以检测这种情况的代码,因为"如果两种指针类型之间的转换产生错误对齐的结果,则行为是未定义的" - 并且完全忽略结果不可预测的情况,这意味着要MOVDQA的操作数将无法正确对齐,然后会导致程序崩溃。


可以很容易地证明,即使不违反任何严格的混叠规则,也会发生这种情况。考虑以下由2个翻译单元组成的程序(如果f及其调用者都放在一个翻译单元中,我的 GCC 足够聪明,可以注意到我们在这里使用的是打包结构,并且不会生成带有MOVDQA的代码):

翻译单元 1

#include <stdlib.h>
#include <stdint.h>
size_t f(uint16_t *keyc, size_t len)
{
size_t hash = len;
len = len / 2;
for (size_t i = 0; i < len; ++i)
hash += keyc[i];
return hash;
}

翻译单元 2

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <inttypes.h>
size_t f(uint16_t *keyc, size_t len);
struct mystruct {
uint8_t padding;
uint16_t contents[100];
} __attribute__ ((packed));
int main(void)
{
struct mystruct s;
size_t len;
srand(time(NULL));
scanf("%zu", &len);
char *initializer = (char *)s.contents;
for (size_t i = 0; i < len; i++)
initializer[i] = rand();
printf("out %zun", f(s.contents, len));
}

现在编译并将它们链接在一起:

% gcc -O3 unit1.c unit2.c
% ./a.out
25
zsh: segmentation fault (core dumped)  ./a.out

请注意,那里没有锯齿冲突。唯一的问题是未对齐的uint16_t *keyc.

-fsanitize=undefined将产生以下错误:

unit1.c:10:21: runtime error: load of misaligned address 0x7ffefc2d54f1 for type 'uint16_t', which requires 2 byte alignment
0x7ffefc2d54f1: note: pointer points here
00 00 00  01 4e 02 c4 e9 dd b9 00  83 d9 1f 35 0e 46 0f 59  85 9b a4 d7 26 95 94 06  15 bb ca b3 c7
^ 

将指向对象的指针指定为指向 char 的指针的别名,然后迭代原始对象中的所有字节是合法的。

当指向 char 的指针实际上指向一个对象(已通过前面的操作获得)时,将 is 转换回指向原始类型的指针是合法的,并且标准要求您返回原始值。

但是,将任意指向 char 的指针转换为指向对象的指针并取消引用获得的指针会违反严格的锯齿规则并调用未定义的行为。

因此,在您的代码中,以下行是 UB:

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB

为@Antti Haapala的出色答案提供更多信息和常见陷阱:

TLDR:在 C/C++ 中,访问未对齐的数据是未定义行为 (UB)。未对齐的数据是地址(也称为指针值)处的数据,该地址不能被其对齐方式(通常是其大小)整除。在(伪)代码中:bool isAligned(T* ptr){ return (ptr % alignof(T)) == 0; }

解析通过网络发送的文件格式或数据时经常会出现此问题:您有一个不同数据类型的密集结构。示例是这样的协议:struct Packet{ uint16_t len; int32_t data[]; };(读作:16 位长度后跟 len 乘以 32 位 int 作为值)。您现在可以执行以下操作:

char* raw = receiveData();
int32_t sum = 0;
uint16_t len = *((uint16_t*)raw);
int32_t* data = (int32_t*)(raw2 + 2);
for(size_t i=0; i<len; ++i) sum += data[i];

行不通!如果您假设raw对齐(在您脑海中,您可以将对齐到任何大小的raw = 0设置为所有n0 % n == 0),那么data不可能对齐(假设对齐==类型大小):len位于地址0,因此data位于地址2和2 % 4 != 0。但是演员表告诉编译器"此数据已正确对齐"("...因为否则它是UB,我们永远不会遇到UB")。因此,在优化期间,编译器将使用 SIMD/SSE 指令来更快地计算总和,当给定未对齐的数据时,这些指令会崩溃。
旁注:有未对齐的 SSE 指令,但它们速度较慢,并且由于编译器假定您承诺的对齐方式,此处不使用它们。

你可以在哈帕拉@Antti的例子中看到这一点,我缩短了这个例子,放在 godbolt 上供你玩:https://godbolt.org/z/KOfi6V。观看"返回的程序:255"又名"崩溃"。

此问题在反序列化例程中也很常见,如下所示:

char* raw = receiveData();
int32_t foo = readInt(raw); raw+=4;
bool foo = readBool(raw); raw+=1;
int16_t foo = readShort(raw); raw+=2;
...

read*负责恩迪亚,通常按如下方式实现:

int32_t readInt(char* ptr){
int32_t result = *((int32_t*) ptr);
#if BIG_ENDIAN
result = byteswap(result);
#endif
}

请注意此代码如何取消引用指向可能具有不同对齐方式的较小类型的指针,并且您遇到了确切的问题。

这个问题非常普遍,甚至 Boost 在许多版本中都遭受了这个问题。有Boost.Endian,它提供了简单的字节序类型。来自 godbolt 的 C 代码可以很容易地写成这样:

#include <cstdint>
#include <boost/endian/arithmetic.hpp>

__attribute__ ((noinline)) size_t f(boost::endian::little_uint16_t *keyc, size_t len)
{
size_t hash = 0;
for (size_t i = 0; i < len; ++i)
hash += keyc[i];
return hash;
}
struct mystruct {
uint8_t padding;
boost::endian::little_uint16_t contents[100];
};
int main(int argc, char** argv)
{
mystruct s;
size_t len = argc*25;
for (size_t i = 0; i < len; i++)
s.contents[i] = i * argc;
return f(s.contents, len) != 300;
}

类型little_uint16_t基本上只是一些字符,如果当前的机器 endianessBIG_ENDIAN,则隐式转换与byteswapuint16_t。在引擎盖下,Boost:endian使用的代码类似于以下内容:

class little_uint16_t{
char buffer[2];
uint16_t value(){
#if IS_x86
uint16_t value = *reinterpret_cast<uint16_t*>(buffer);
#else
...
#endif
#if BIG_ENDIAN
swapbytes(value);
#endif
return value;
};

它使用的知识是在x86架构上可以进行未对齐的访问。来自未对齐地址的加载速度稍慢,但即使在汇编程序级别,也与来自对齐地址的加载相同。

然而,"可能"并不意味着有效。如果编译器用 SSE 指令替换了"标准"负载,那么这将失败,如在 godbolt 上看到的那样。这在很长一段时间内都没有被注意到,因为这些 SSE 指令只是在使用相同的操作处理大块数据时使用,例如添加值数组,这就是我在本例中所做的。这在 Boost 1.69 中通过使用可以转换为 ASM 中的"标准"加载指令的memcopy修复,该指令支持 x86 上的对齐和未对齐数据,因此与投射版本相比没有减速。但是,如果不进行进一步检查,就无法将其转换为对齐的SSE指令。

要点:不要在演员表上使用快捷方式。对每个铸件持怀疑态度,尤其是从较小类型投射时,并检查对齐是否不会错误或使用安全内存。

除非代码执行某些操作来确保字符类型的数组对齐,否则它不应特别期望它会对齐。

如果处理了对齐,代码获取其地址一次,将其转换为另一种类型的指针,并且从不通过任何不是从后一个指针派生的方式访问存储,那么为低级编程设计的实现应该不会特别困难将存储视为抽象缓冲区。 由于这种处理并不困难,并且对于某些类型的低级编程(例如,在 malloc() 可能不可用的上下文中实现内存池)是必需的,因此不支持此类构造的实现不应声称适合低级编程。

因此,在为低级编程设计的实现中,诸如您描述的构造将允许将适当对齐的数组视为非类型化存储。 不幸的是,没有简单的方法来识别这样的实现,因为主要为低级编程设计的实现通常无法列出作者认为这些实现明显以环境特征的方式运行的所有情况(因此它们恰恰这样做),而那些设计专注于其他目的的实现可能声称适合低级编程,即使它们的行为不恰当。为此。

该标准的作者认识到C语言对于非可移植程序是一种有用的语言,并特别指出他们不希望排除将其用作"高级汇编程序"。 然而,他们希望,旨在用于各种目的的实现将支持流行的扩展以促进这些目的,而不考虑标准是否要求他们这样做,因此没有必要让标准处理这些问题。 然而,由于这种意图被降级为基本原理而不是标准,一些编译器编写者将标准视为程序员应该从实现中期望的所有内容的完整描述,因此可能不支持低级概念,例如使用静态或自动持续时间对象作为有效的非类型化缓冲区。

最新更新