【问题标题】:Load of misaligned address and UBsan finding加载未对齐的地址和 UBsan 发现
【发布时间】:2017-12-03 15:31:51
【问题描述】:

这个问题不是关于未对齐数据访问的定义,而是为什么memcpy 会沉默 UBsan 的发现而类型转换却不会,尽管生成了相同的汇编代码。

我有一些示例代码来解析一个协议,该协议发送一个字节数组,分成六字节组。

void f(u8 *ba) {
    // I know this array's length is a multiple of 6
    u8 *p = ba;
    u32 a = *(u32 *)p;
    printf("a = %d\n", a);
    p += 4;
    u16 b = *(u16 *)p;
    printf("b = %d\n", b);

    p += 2;
    a = *(u32 *)p;
    printf("a = %d\n", a);
    p += 4;
    b = *(u16 *)p;
    printf("b = %d\n", b);
}

在将指针增加 6 并进行另一次 32 位读取后,UBSan 报告有关未对齐负载的错误。我使用memcpy 而不是类型双关语来抑制此错误,但我不太了解原因。需要明确的是,这里是没有 UBSan 错误的相同例程,

void f(u8 *ba) {
    // I know this array's length is a multiple of 6 (
    u8 *p = ba;
    u32 a;
    memcpy(&a, p, 4);
    printf("a = %d\n", a);
    p += 4;
    memcpy(&b, p, 2);
    printf("b = %d\n", b);

    p += 2;
    memcpy(&a, p, 4);
    printf("a = %d\n", a);
    p += 4;
    memcpy(&b, p, 2);
    printf("b = %d\n", b);
}

两个例程都编译为相同的汇编代码(使用movl 进行32 位读取,使用movzwl 进行16 位读取),那么为什么一个未定义行为是另一个未定义行为? memcpy 是否有一些特殊的属性可以保证某些东西?

我不想在这里使用memcpy,因为我不能依赖编译器在优化它方面做得足够好。

【问题讨论】:

标签: c clang undefined-behavior memcpy ubsan


【解决方案1】:

UB sanitizer 用于检测代码不是严格符合的,并且实际上依赖于无法保证的未定义行为。

实际上,C 标准说只要将指针转换为地址未适当对齐的类型,行为就未定义。 C11 (draft, n1570) 6.3.2.3p7:

指向对象类型的指针可以转换为指向不同对象类型的指针。如果结果指针未正确对齐 68) 引用类型,则行为未定义。

u8 *p = ba;
u32 *a = (u32 *)p; // undefined behaviour if misaligned. No dereference required

this 类型转换的存在允许编译器假定 ba 与 4 字节边界对齐(在需要如此对齐 u32 的平台上,许多编译器将在 x86 上执行),之后它可以生成假定对齐的代码。

即使在 x86 平台上,也有一些指令会严重失败: innocent-looking code 可以编译成机器码,在运行时会导致中止。 UBSan 应该在代码中捕获这种情况,否则在您运行它时看起来很正常并且行为“如预期”,但如果使用另一组选项或不同的选项编译则会失败优化级别。

编译器可以为memcpy 生成正确代码 - 而且经常,但这只是因为编译器会知道未对齐的访问会起作用并执行在目标平台上足够好。

最后:

我不想在这里使用memcpy,因为我不能依赖编译器在优化它方面做得足够好。

你在这里说的是:“我希望我的代码在被垃圾或生成慢代码的两个十年前的编译器编译时可靠地工作。绝对不是在编译时与可以优化它以使其快速运行的那些。”

【讨论】:

  • 谢谢,有了这个解释和@Dietrich Epp 的评论,我现在明白我的误解了。
  • “C 标准说行为是未定义的”——仅在不支持未对齐访问的架构上。另一方面,它可能会导致较大的或small (x86) 开销或根本没有开销(例如在 DSP 上)。
  • @yugr 感谢您阅读我的回答。等一下。 在标准方面,无论平台如何,行为都是未定义的。当然,实现可以定义未定义的行为。你什么时候检查编译器的手册?!不,GCC、Clang、MSVC,这些都没有定义它。
  • 如果我正在阅读 6.3.2.2-7 正确的标准,仅当结果类型未正确对齐但不要求 int 的对齐必须严格大于 char 的对齐时才允许 UB。至于手册,至少 ARM 上的 GCC 有官方的-munaligned-access 标志(这很有意义,因为嵌入式算法经常需要对未对齐的数据进行操作)。
  • @yugr 已修复:D 谢谢,引用。即使使用-munaligned-access,结果是访问不是未定义的,但它不会改变对齐要求。它定义了超出标准要求的行为。我不会在这里重复的链接到答案表明,只需在循环中解决未对齐的短路,x86 上就会发生崩溃。我什至没有在我的 GCC 上看到可用于 x86 平台的未对齐访问选项。
【解决方案2】:

您的对象的原始类型最好是u32u32 的数组...否则,您可以使用memcpy 明智地处理这个问题。这不太可能成为现代系统的重大瓶颈。我不会担心这个。

在某些平台上,整数不可能存在于每个可能的地址中。考虑您系统的最大地址,我们可以假设0xFFFFFFFFFFFFFFFF。这里不可能存在四字节整数,对吧?

有时在硬件上执行优化以基于此对齐总线(从 CPU 到各种外围设备、内存等的一系列电线),其中之一是假设仅出现各种类型的地址例如,它们大小的倍数。在此类平台上未对齐的访问可能会导致陷阱(segfault)。

因此,UBSan 正确地警告您这个不可移植且难以调试的问题。

这个问题不仅会导致某些系统完全无法工作,而且您会发现允许您访问不对齐的系统需要通过总线进行第二次提取以检索整数的第二部分。


这段代码还有一些其他问题。

printf("a = %d\n", a);

如果您想打印int,您应该使用%d。但是,您的论点是u32。不要像这样与您的论点不匹配;这也是未定义的行为。我不确定u32 是如何为您定义的,但我想最接近标准的功能可能是uint32_t(来自<stdint.h>)。您应该在要打印uint32_t 的任何地方使用"%"PRIu32 作为格式字符串。 PRIu32(来自<inttypes.h>)符号提供了实现定义的字符序列,实现printf 函数将识别这些字符。

请注意,此问题在其他地方重复出现,您使用的是 u16 类型:

printf("b = %d\n", b);

"%"PRIu16 可能就足够了。

【讨论】:

  • 感谢您的评论。我不明白为什么将它作为一个 u32 数组(而不是 u8)会更好,该函数正在解析一些结构化数据,因此使用指针算法在字节级别跨过不同数量的记录很好,但是也许我正在陷入陷阱。我知道从技术上讲对齐错误是什么,但我不知道为什么 memcpy 版本在生成相同的汇编代码时会避免上述问题,并且打印是草率的,因为它是一次性代码:)
  • @dune.rocks:memcpy 不会导致未定义的行为,因为 C 标准说它与复制一系列 u8 相同。生成的程序集相同或不同的事实与确定行为是否未定义完全无关……为此,您必须查看 C 标准。
  • @DietrichEpp:如果实现指定将未对齐的地址转换为uint32_t* 可能会以任意方式失败,那么实现可能会假设memcpy 类型为uint32_t* 的操作数将包含一个字-对齐的地址。但是,如果一个实现指定任何两种指针类型之间的往返转换将产生一个与原始指针等效的指针,并且将任何指针类型直接转换为 void* 将产生与将其转换为一些相同的结果其他类型,然后到void*,这将反过来定义代码的行为......
  • ...将未对齐的指针投射到uint32_t*,然后将其传递给memcpy。我建议一个假设传递给memcpyuint32_t* 将对齐的实现应该明确记录未针对其各自类型对齐的指针可能表现为陷阱表示。这将证明普遍有用的memcpy 行为是合理的。不幸的是,许多编译器编写者指定了很多东西,但没有指定遵循或不遵循哪些自然推理。
  • @supercat:“如果,......任何两种指针类型之间的往返转换将产生一个与原始指针等效的指针,”这里是错误的部分,参见例如n1548 6.3.2.3 第 7 段指出,如果结果指针未正确对齐,则转换未定义,否则结果应等于原始指针。将uint32_t * 传递给memcpy() 的问题没有实际意义,因为uint32_t * 首先是正确对齐的。
猜你喜欢
  • 1970-01-01
  • 2019-04-28
  • 2017-04-16
  • 2016-05-16
  • 2017-05-17
  • 2017-09-04
  • 1970-01-01
  • 2018-05-02
  • 1970-01-01
相关资源
最近更新 更多