C++ 结构内部的这种越界访问怎么会出错?

C++ HOW can this out-of-range access inside struct go wrong?

#include <iostream>
#include <random>
using namespace std;

struct TradeMsg {
  int64_t timestamp; // 0->7
  char exchange; // 8
  char symbol[17]; // 9->25
  char sale_condition[4]; // 26 -> 29
  char source_of_trade; // 30
  uint8_t trade_correction;  // 31
  int64_t trade_volume; // 32->39
  int64_t trade_price; // 40->47
};
static_assert(sizeof(TradeMsg) == 48);

char buffer[1000000];

template<class T, size_t N=1>
int someFunc(char* buffer, T* output, int& cursor) {
    // read + process data from buffer. Return data in output. Set cursor to the last byte read + 1.
    return cursor + (rand() % 20) + 1; // dummy code
}

void parseData(TradeMsg* msg) {
    int cursor = 0;
    cursor = someFunc<int64_t>(buffer, &msg->timestamp, cursor);
    cursor = someFunc<char>(buffer, &msg->exchange, cursor);
    
    cursor++;
    int i = 0;
    // i is GUARANTEED to be <= 17 after this loop,
    // edit: the input data in buffer[] guarantee that fact.
    while (buffer[cursor + i] != ',') {
        msg->symbol[i] = buffer[cursor + i];
        i++;
    }
    msg->symbol[i] = '\n'; // might access symbol[17].
    cursor = cursor + i + 1;
            
    for (i=0; i<4; i++) msg->sale_condition[i] = buffer[cursor + i];
    cursor += 5;
    
    //cursor = someFunc...
}

int main()
{
    TradeMsg a;
    a.symbol[17] = '[=10=]';

    return 0;
}

我有这个保证具有可预测大小的结构。在代码中,有一种情况程序会尝试将值分配给超过其大小的数组元素 msg->symbol[17] = ... .

但是,在那种情况下,只要满足以下条件,分配就不会造成任何伤害:

  1. 在分配下一个结构成员 (sale_condition) 之前完成(没有意外的代码重新排序)。

  2. 它不修改任何以前的成员(时间戳,交换)。

  3. 它不访问结构之外的任何内存。

我了解到这是未定义的行为。但是什么样的optimization/code代编译器可以让这个出错呢? symbol[17] 位于结构中间的深处,所以我看不出编译器如何在其外部生成访问权限。假设平台仅为 x86-64

少数情况下:

  1. 设置变量保护时:https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html

  2. 在 C++ 解释器中(是的,它们存在):https://root.cern/cling/

您的符号大小为 17 然而,您正在尝试为第 18 个索引赋值 a.symbol[17] = '\0';

请记住您的索引值从 0 开始,而不是 1。

所以你有两个地方可能会出错。我可以等于 17,这将导致错误,而我在上面显示的最后一行将导致错误。

许多人指出调试模式检查会在访问结构的数组成员边界之外时触发,选项如 gcc -fsanitize=undefined。除此之外,编译器使用成员访问之间不重叠的假设来重新排序实际上 do alias:

的两个赋值也是合法的

允许编译器假定对 msg->symbol[i] 的访问不会影响其他结构成员,并可能延迟 msg->symbol[i] = '\n'; 直到写入 [=16 的循环之后=]. (即将存储到函数底部的下沉)。

您没有充分的理由 期望 编译器想要单独在这个函数中执行此操作,但也许在内联到一些也存储了一些东西的调用程序之后,它可能是相关的。或者仅仅因为它是这个思想实验中存在的 DeathStation 9000 来破解你的代码。


你可以安全地写这个,尽管 GCC 编译得更糟

因为 char* 可以作为任何其他对象的别名,您可以相对于整个结构的开头而不是成员数组的开头来偏移 char*。使用 offsetof 找到正确的起点,如下所示:

#include <cstddef>

    ...
    ((char*)msg + offsetof(TradeMsg, symbol))[i] = '\n'; // might access symbol[17].

根据 C++ 的 [] 运算符的定义,这完全等同于 *((char*)msg + offsetof(...) + i) = '\n';,即使它允许您使用 [i] 相对于同一位置进行索引。

然而,使用 GCC11.2 -O2 编译为效率较低的 asm。 (Godbolt),主要是因为 int i, cursor 比指针窄-宽度。从结构开始重新索引的“安全”版本在 asm 中做更多的索引工作,而不是使用它已经用作循环中基址寄存器的 msg+offsetof(symbol) 指针。

# original version, with UB if `i` goes past the buffer.
# gcc11.2 -O2 -march=haswell.  -O3 fully unrolls into a chain of copy/branch

      ... partially peeled first iteration
.L3:                                      # do{
        mov     BYTE PTR [rbx+8+rax], dl   # store into msg->symbol[i]
        movsx   rdi, eax                   # not read inside the loop
        lea     ecx, [r8+rax]
        inc     rax
        movzx   edx, BYTE PTR buffer[rsi+1+rax]  # load from buffer
        cmp     dl, 44
        jne     .L3                       # }while(buffer[cursor+i] != ',')
## End of copy-and-search loop.  
# Loops are identical up to this point except for MOVSX here vs. MOV in the no-UB version.
        movsx   rcx, ecx       # just redo sign extension of this calculation that was done repeatedly inside the loop just for this, apparently.
.L2:
        mov     BYTE PTR [rbx+9+rdi], 10   # store a newline
        mov     eax, 1                     # set up for next loop
# offsetof version, without UB

  # same loop, but with RDI and RSI usage switched.
  # And with    mov  esi, eax  zero extension instead of  movsx rdi, eax  sign extension
        cmp     dl, 44
        jne     .L3                       # }while(buffer[cursor+i] != ',')

        add     esi, 9              # offsetof(TradeMsg, symbol)
        movsx   rcx, ecx            # more stuff getting sign extended.
        movsx   rsi, esi            # including something used in the newline store
.L2:
        mov     BYTE PTR [rbx+rsi], 10
        mov     eax, 1                  # set up for next loop

RCX计算好像只是为了下一个循环使用,设置sale_conditions.

顺便说一句,复制和搜索循环类似于 strcpy,但有一个 ',' 终止符。不幸的是 gcc/clang 不知道如何优化它;他们编译成一个慢速的一次一个字节的循环,而不是例如使用 vec == set1_epi8(',') 比较中的 mask-1 的 AVX512BW 掩码存储,以获得选择字节前 ',' 而不是逗号元素的掩码。 (不过,可能需要一个 bithack 来将最低设置位隔离为唯一的设置位,除非始终复制 16 或 17 个字节与查找 ',' 位置分开是安全的,这可以在没有屏蔽存储的情况下有效地完成或分支。)


另一个选项可能是 char[21]struct{ char sym[17], sale[4];} 之间的联合,如果您使用允许 C99 风格联合类型双关的 C++ 实现。 (它是一个 GNU 扩展,也被 MSVC 支持,但不一定是每个 x86 编译器。)


此外,在风格方面,用 for( int i=0 ; i<4 ; i++ ) 遮盖 int i = 0; 是糟糕的风格。为该循环选择一个不同的 var 名称,例如 j。 (或者,如果有什么有意义的话,i 的更好名称必须在多个循环中存活。)