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]
= ... .
但是,在那种情况下,只要满足以下条件,分配就不会造成任何伤害:
在分配下一个结构成员 (sale_condition) 之前完成(没有意外的代码重新排序)。
它不修改任何以前的成员(时间戳,交换)。
它不访问结构之外的任何内存。
我了解到这是未定义的行为。但是什么样的optimization/code代编译器可以让这个出错呢? symbol[17]
位于结构中间的深处,所以我看不出编译器如何在其外部生成访问权限。假设平台仅为 x86-64
少数情况下:
设置变量保护时:https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
在 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
的更好名称必须在多个循环中存活。)
#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]
= ... .
但是,在那种情况下,只要满足以下条件,分配就不会造成任何伤害:
在分配下一个结构成员 (sale_condition) 之前完成(没有意外的代码重新排序)。
它不修改任何以前的成员(时间戳,交换)。
它不访问结构之外的任何内存。
我了解到这是未定义的行为。但是什么样的optimization/code代编译器可以让这个出错呢? symbol[17]
位于结构中间的深处,所以我看不出编译器如何在其外部生成访问权限。假设平台仅为 x86-64
少数情况下:
设置变量保护时:https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
在 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
的更好名称必须在多个循环中存活。)