记 C++开发中的一个小坑

2022-02-21 16:08:09 +08:00
 dangyuluo

最近写的功能里,发现一个单元测试在 Debug 下可以通过,但是在 RelWithDebInfo 下却报错。错误发生在使用memcmp比较两个内存地址处。抽象出来的代码如下

struct GID{
  MyType m_type; // Member with alignment as 8
  bool used; // bool has alignment of 1
};


GID gid1;
...
GID gid2 = gid1;

assert(memcmp(&gid1, &gid2, sizeof(GID)) == 0); // failed for RelWithDebInfo

后来经过排查,发现 Debug 时gid2.used成员之后内存是干净的,padding 均为 0 ,但是在 RenWithDebInfo 下gid2.used之后内存却有随机值,导致了memcmp失败。经过搜索发现了 auto-generated copy constructor 是不会将 padding 置零。读了 memcmp 的文档后也发现已经有提示过了。真是学无止境。

https://stackoverflow.com/questions/70979077/does-c-standard-guarantee-the-initialization-of-padding-bytes-to-zero-for-non

https://en.cppreference.com/w/cpp/string/byte/memcmp

4333 次点击
所在节点    C++
35 条回复
wctml
2022-02-21 16:31:48 +08:00
楼主 你太年轻了。(❁´◡`❁)
Kasumi20
2022-02-21 16:43:35 +08:00
正确的,没必要折腾几乎不访问的内存。比较结构就得自己实现,很多成员都是指针,指向别的结构
wuruxu
2022-02-21 16:48:06 +08:00
这些变量都应该初始化下 比如 memset, bzero
jones2000
2022-02-21 16:54:55 +08:00
1. 变量或结构体要初始化
2. 结构体字节对齐
LifStge
2022-02-21 17:41:22 +08:00
最关键的问题 不是模式问题 跟 memcmp 也没太大关系 (虽然用来这样比较两个结构存在非常大的问题,正常情况自己做操作符重载)
关键在于 初始化的问题 主要原因 c++ 隐式的行为太多了 这次的问题 属于开发工具 为了 debug 模式下 查找问题容易些 在 c++标准之外做了额外的处理 导致了 本来未知行为的代码(也就是错误) 变成了固定行为(也就是 memcmp 比较的内存相等 )
https://en.cppreference.com/w/cpp/language/default_initialization#:~:text=otherwise%2C%20no%20initialization%20is%20performed%3A%20the%20objects%20with%20automatic%20storage%20duration%20(and%20their%20subobjects)%20contain%20indeterminate%20values.

https://en.cppreference.com/w/cpp/language/value_initialization

https://en.cppreference.com/w/cpp/language/zero_initialization

很多时候都会说 声明变量的时候 就做主动初始化的操作 就比如 T{} T (...)等 这也是个好的习惯 但是很多时候 这种也是属于多余的操作 这就要看自己对代码的把控了
反正就是 切勿对未知的内存做操作就行了 更多的情况 不要关注 debug 模式下可以 release 模式下又不可以 再比如 gcc 下可以 msvc 下又不可以...... 只以标准规定的来做

c++标准这东西 还是多写代码吧 做的多了 就慢慢熟悉了 不过嘛 平常一定按照标准规定的流程做 总没错的
虽然其他的方法也能解决问题 但是也容易带来很多问题
总结就是 该初始化就初始化 把控好 c++的各种隐式的默认行为 能使用明确的 就不要依赖隐式默认的
Mithril
2022-02-21 17:56:12 +08:00
C++主要问题是给码农的权限太高了,随随便便你就可以瞎搞内存。
我见过一个奇葩的就是有个数组每次都访问越界,不过其实越界了多数情况下也没什么问题,两个函数以后就切掉了。
但是就只有越界的时候恰好后面的内存里是某些特殊符号就会出问题。
所以就谁分到那块内存谁倒霉,表现就是随机炸。你最后 Debug 去吧,鬼知道哪天才能复现。
查出这个问题的时候也是运气好,恰好挂着调试器,它恰好炸了。。。
jim9606
2022-02-21 18:03:31 +08:00
struct alignment 和 padding 的问题在 C 一样会有吧。

@jones2000 @LifStge
这事情跟没有初始化也没啥关系吧,这里问题是用了错误的比较方法。GID gid2=gid1 就是 well-defined 的初始化。懒得写比较函数的可以看看显式声明比较函数能不能用而不是用 memcmp 这种依赖 UB 的行为。
lakehylia
2022-02-21 18:05:35 +08:00
真要搞这种字节对比,就应该给 struct 加上 #pragma pack(1)
bitdepth
2022-02-21 18:35:05 +08:00
我反而懷疑是 MyType m_type 這個 MyType 的問題
newmlp
2022-02-21 18:48:09 +08:00
不是应该重载==做判断吗,怎么还能用内存判断相等
LifStge
2022-02-21 20:23:31 +08:00
@jim9606 这种比较方法的使用在大部分情况下是有问题的 这是可以确认的
最终原因就未初始化的结果 是未知的 op 也贴了问题所在 这种隐式的默认行为编译器也有差异问题 之所以 debug 模式下没问题 归根就是编译器的特殊处理的结果 其实也没必要讨论的 明显就是用错了 C4700 的警告 最新的 vc++下是视为错误的 g++ clang++ 倒是没错 但是这本身就是未知的行为 所以实现也有各自的差异 但是把个别编译器对这种未知行为的默认实现 当作标准来用就带来的 现在的问题
```c++
#include <iostream>
#pragma pack(8)
struct AA
{
bool m;
int k;
bool d;
};

int main()
{
AA a;

AA b =a;

for (int i = 0; i < sizeof(a); i++)
{
std::cout << std::hex << (unsigned int)(((unsigned char*)&a)[i]) << ",";
}
std::cout << std::endl;

for (int i = 0; i < sizeof(a); i++)
{
std::cout << std::hex << (unsigned int)(((unsigned char*)&b)[i]) << ",";
}
std::cout << std::endl;

return 0;
}
```
a 未初始化结果
g++ -Os main.cpp
0,0,0,0,0,0,0,0,80,22,2,91,
0,0,0,0,0,0,0,0,80,22,2,91,

clang++ -Os main.cpp
b4,34,e2,4b,ff,7f,0,0,0,0,0,0,
c0,12,40,0,0,0,0,0,d0,10,40,0,

如果把 a 做零初始化后 AA a{}; AA b=a;
g++ -Os main.cpp
0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,

clang++ -Os main.cpp
0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,

初始化后的结果是一样的

也不是说必须要主动的做零初始化 毕竟很多时候 做零初始化是多余的 不过就是很多时候是依赖编译器优化 还是自己主动优化 自己手动优化就要注意更多的细节

总结就是 对结构跟类用 memcmp 做比较是存在问题的
对未初始化的变量的使用上 是要注意的 不要依赖编译器对这种未知行为的实现 因为本身就是未知的行为 不是语言标准的规定 所以编译器实现上也没有必要的统一实现 结果上不同正常的 但是不要把对这种未知行为的实现 当成语言的标准来使用 (除非限定使用前提 ) 比如 此代码 必须在 g++ 甚至指定版本 指定 debug 下运行....
kirory
2022-02-21 21:07:36 +08:00
既然是 C++, 不是 operator < / <=> 就完了吗
jim9606
2022-02-22 01:09:22 +08:00
@LifStge
可能我语序有点问题,我的意思是复制构造(初始化)是没有问题的,比较有问题。一般想到用 memcmp 做比较的起因是懒得手写一个逐成员比较的比较函数,或者想提高性能。如果目的只是前者的话,C++20 可以显式定义默认比较函数。如果成员都是平凡可比较的话用这个默认比较函数的话是没有问题的,不会出现 OP 所提的问题。

https://zh.cppreference.com/w/cpp/language/default_comparisons
dangyuluo
2022-02-22 01:26:53 +08:00
@jim9606 你说得对,这确实是我犯懒了,而且这个测试的本意是某个对象被拷贝后内存一致,我没想到 padding

trivial 翻译成平凡确实是我没想到的😂
littlewing
2022-02-22 01:56:35 +08:00
@Mithril 这其实就是一种性能和安全性的权衡了,std::array 中如果用 [] 访问同样也不做越界检查,如果用 at() 就会做越界检查,其他语言比如 rust 中也有同样的设计
LifStge
2022-02-22 03:36:43 +08:00
@jim9606 op 的举例 不是比较函数能解决的 本身就是未初始化的变量 得到的结果本身就是未知的 就算挨个成员比较还是错误的 虽然-O0 debug 模式下可以得到想要的结果 但是在 -Os 优化模式下 语义上的错误就表现出来了 你看看我前面的例子
例子得到的结果很明显 这里是 -Os 模式下 gcc clang 是有明显差异的 本身就是未初始化的未知数据 看这也没意义的 不过 gcc 跟 clang 的 拷贝的行为是有差异的 但是如果 对 a 做了显式的零初始化 a 跟 b 的值 g++ clang++下得到的结果都一样了
前面 gcc 跟 clang 拷贝结果上的差异 本质上还是 -Os 优化导致的 但是这在语言标准层面 是没有影响的 因为本身就是未初始化的语法 所以结果就是未知的 如果非要使用未知的数据 肯定是错误的...

接下来 切换的-O0 debug 模式下编译 得到的结果是 这次 b 拷贝的 a g++ 跟原来一样 a b 内存是相同的 clang++ 内存也完全一致了

同时 做了另一个测试 就是对 A 主动添加个构造函数 两个测试是 一个添加一个空构造函数 AA(){} 不做任何操作 另一个个 添加个构造函数 AA(): ...(0){} 对所有成员 或个别成员做初始化 结果是 在-O0 debug 模式下的 结果 g++ clang++ 同前面一样 在-Os 模式下 结果就是 空构造函数的结果 g++ 同样 同前面一样 clang++ 照样是 a b 都是随机的 但是对任意成员初始化后的例子 clang++ 下 虽然 a 内存结构 未初始化成员 跟填充区域 都是随机 但是 b 拷贝 a 后的内存数据 同 a 是完全一致的.

造成这种结果 最根本的原因 就是-Os 的优化 在 a 未初始化的情况下 优化器可以认为数据本身就是未知的 无意义的 可以做更大程度上的优化 所以 clang++ 优化后得到的结果就是随机的 跳过了认为无意义的初始化等. 但是 做初始话后的情况 clang++ 保证逻辑上的正确性 具体看反汇编会清楚很多 不过也没啥必要了 本身不做初始化 语言层面结果肯定就是未知的 虽然 debug 模式 或者特定编译器下 得到想要的结果 但是这也就是编译器做的特殊处理 导致的瞎猫碰上了死耗子.. 但是到-Os 优化下 错误就体现出来了

OP 所说的 拷贝构造 函数 对填充区域的的差异问题 我这里倒是没见到 只要对对象做好初始化后 拷贝后的 是相同的 但是如果只声明 不做主动初始化 在-Os 模式下 clang++ 得到的结果就是未知的 不清楚 op 的编译器啥版本 我这里 gcc 8.3.0 clang vcpkg 编译 last 版本


自己说了这么多 其实讨论这些 着实没啥意义 本身就是未初始化的 错误使用 结果就是未知的 先抛开各个编译器的特殊语法 实现等.. 如果正确的按照语言标准层面的 流程做的话 就算是-Os 优化 得到的结果也不应该有差异的 如果真有差异 那就属于编译器的 bug 了
msg7086
2022-02-22 03:57:25 +08:00
简单说就是,用=赋值的,就要用==来判断。
用 memcpy 复制的,采用 memcpy 来判断吧?
GeruzoniAnsasu
2022-02-22 06:01:37 +08:00
@LifStge 你确定你理解了

1. gid2 是有初始化过程的,它用 gid1 来初始化( int a = 1 不是「初始化」么?)
2. 问题出在 GID 这个结构最后 bool 变量 used 的内存对齐上,used 成员本身复制了 gid1.used 的值没有问题,但它后面 padding 的,属于 GID 结构但不属于 used 成员的,那部分内存,没有被清零
3. 当用 copy ctor 初始化对象时(即 gid2 现在的情况),是不会调用默认构造的

这三点吗?
dangyuluo
2022-02-22 06:36:45 +08:00
@GeruzoniAnsasu 你说的应该是对的,我看了汇编代码,确实没有清零的步骤,只有成员拷贝。

https://en.cppreference.com/w/cpp/language/copy_constructor

> Implicitly-defined copy constructor:
> For non-union class types (class and struct), the constructor performs full member-wise copy of the object's bases and non-static members, in their initialization order, using direct initialization.
coreki
2022-02-22 09:11:53 +08:00
根据经验,初始化清零

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/835428

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX