我无法解释该程序的执行行为:
#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 %lu\n", f(x, len));
}
因此,当使用gcc编译-O3并使用参数25运行时,它会引发段错误。没有优化它工作正常。我已经对它进行了反汇编:它正在进行矢量化,并且编译器假定key2
数组以16字节对齐,因此它使用movdqa
。显然它是UB,虽然我无法解释它。我知道严格的别名规则,并不是这种情况(我希望),因为据我所知,严格的别名规则并不适用于char
。为什么gcc认为这个指针是对齐的?即使经过优化,Clang也能正常工作。
修改
我将unsigned char
更改为char
,并删除了const
,它仍然是段错误。
EDIT2
我知道这段代码不好,但据我所知,严格的别名规则应该可行。违规究竟在哪里?
答案 0 :(得分:32)
代码确实打破了严格的别名规则。但是,不仅存在别名冲突,而且由于别名冲突而导致崩溃。这是因为unsigned short
指针错误对齐;如果结果没有适当对齐,甚至指针转换本身也是未定义的。
C11 (draft n1570) Appendix J.2:
1在以下情况下,行为未定义:
...
- 两种指针类型之间的转换会产生错误对齐的结果(6.3.2.3)。
[...]如果结果指针未正确对齐[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 instructions更有效地执行。使用MOVDQA
将值加载到SSE寄存器中,这要求参数与 16个字节对齐:
当源或目标操作数是内存操作数时,操作数必须在16字节边界上对齐,否则将生成一般保护异常(#GP)。
对于指针在开始时没有适当对齐的情况,编译器将生成代码,逐个求和前7-7个无符号短路,直到指针对齐到16个字节。
当然,如果你从一个指向奇数地址的指针开始,即使添加7次2也不会将一个地址与一个与16个字节对齐的地址。当然,编译器甚至不会生成将检测这种情况的代码,因为“行为未定义,如果两个指针类型之间的转换产生错误对齐的结果” - 并忽略the situation completely with unpredictable results,这意味着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 %zu\n", 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
^
答案 1 :(得分:6)
将指向对象的指针别名为指向char的指针,然后迭代原始对象中的所有字节是合法的。
当指向char的指针实际指向一个对象(通过上一个操作获得)时,转换为指向原始类型的指针是合法的,标准要求您返回原始值。 / p>
但是将指向char的任意指针转换为指向对象的指针并取消引用获取的指针会违反严格别名规则并调用未定义的行为。
因此,在您的代码中,以下行是UB:
const u16 *key2 = (const u16 *) (keyc + 1);
// keyc + 1 did not originally pointed to a u16: UB
答案 2 :(得分:2)
为@Antti Haapala的出色回答提供更多信息和常见陷阱:
TLDR:在C / C ++中,访问未对齐的数据是未定义的行为(UB)。未对齐数据是指地址(aka指针值)处的数据不能被对齐方式(通常是其大小)均匀地整除。在(伪)代码中: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
设置为0 % n == 0
的所有n
对齐为任意大小,则data
无法可能对齐(假设对齐==类型大小):len
位于地址0,因此data
位于地址2和2 % 4 != 0
。但是强制类型转换告诉编译器“此数据已正确对齐”(“ ...,因为否则为UB,而我们永远不会遇到UB”)。因此,在优化过程中,编译器将使用SIMD / SSE指令来更快地求和,并且在给定未对齐数据时,它们会崩溃。
旁注:存在未对齐的SSE指令,但它们的速度较慢,并且由于编译器假定您已承诺对齐,因此此处不使用它们。
您可以在@Antti Haapala的示例中看到这一点,我将其缩短并放在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代码可以像this一样容易地编写:
#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
,则类型uint16_t
基本上只是一些字符,具有从byteswap
到BIG_ENDIAN
的隐式转换。在引擎盖下,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指令仅在以相同的操作处理大量数据时使用,例如添加一个值数组,这就是我在此示例中所做的。通过使用memcopy
在Boost 1.69中对此进行了修复,该{{1}}可以转换为ASM中的“标准”加载指令,该指令支持x86上对齐和不对齐的数据,因此与强制转换版本相比,运行速度没有降低。但是,未经进一步检查,就无法将其翻译成对齐的SSE指令。
外带:请勿在演员表中使用快捷方式。怀疑每次转换,尤其是在使用较小类型的转换时,请检查对齐方式是否正确或使用安全的内存。
答案 3 :(得分:0)
除非代码可以做一些事情来确保字符类型数组是对齐的,否则不要特别期望它将如此。
如果要进行对齐,则代码将获取一次地址,将其转换为另一种类型的指针,并且从不通过未从后者的指针派生的任何方式访问存储,则为低级编程设计的实现应将存储作为抽象缓冲区没有特别困难。由于这样的处理不会很困难,并且对于某些低级编程是必要的(例如,在malloc()可能不可用的上下文中实现内存池),因此不支持这种构造的实现不应认为是合适的用于底层编程。
因此,在专为底层编程设计的实现上,您所描述的构造将允许适当对齐的数组被视为无类型存储。不幸的是,没有一种容易的方法来识别这种实现,因为主要是为低级编程设计的实现常常无法列出所有作者认为很明显这种实现都表现为环境特征的情况(以及因此而精确地做到了这一点),而那些将设计重点放在其他目的上的人可能声称适合低级编程,即使他们出于该目的不当行为。
该标准的作者认识到C是非便携式程序的有用语言,并特别声明他们不希望排除C作为“高级汇编程序”的用途。但是,他们希望,旨在用于各种目的的实现将支持流行的扩展,以方便实现这些目的,而无需考虑标准是否要求他们这样做,因此,不需要标准来解决这些问题。但是,由于这种意图被归因于基本原理而非标准,因此,一些编译器作者将标准视为程序员应从实现中期望的所有内容的完整描述,因此可能不支持低级概念,例如使用静态方法。 -或自动持续时间对象作为有效无类型的缓冲区。