V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
dangyuluo
V2EX  ›  C++

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

  •  4
     
  •   dangyuluo · 2022-02-21 16:08:09 +08:00 · 4287 次点击
    这是一个创建于 766 天前的主题,其中的信息可能已经有所发展或是发生改变。

    最近写的功能里,发现一个单元测试在 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

    35 条回复    2022-03-14 15:30:45 +08:00
    wctml
        1
    wctml  
       2022-02-21 16:31:48 +08:00
    楼主 你太年轻了。(❁´◡`❁)
    Kasumi20
        2
    Kasumi20  
       2022-02-21 16:43:35 +08:00
    正确的,没必要折腾几乎不访问的内存。比较结构就得自己实现,很多成员都是指针,指向别的结构
    wuruxu
        3
    wuruxu  
       2022-02-21 16:48:06 +08:00
    这些变量都应该初始化下 比如 memset, bzero
    jones2000
        4
    jones2000  
       2022-02-21 16:54:55 +08:00
    1. 变量或结构体要初始化
    2. 结构体字节对齐
    LifStge
        5
    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
        6
    Mithril  
       2022-02-21 17:56:12 +08:00
    C++主要问题是给码农的权限太高了,随随便便你就可以瞎搞内存。
    我见过一个奇葩的就是有个数组每次都访问越界,不过其实越界了多数情况下也没什么问题,两个函数以后就切掉了。
    但是就只有越界的时候恰好后面的内存里是某些特殊符号就会出问题。
    所以就谁分到那块内存谁倒霉,表现就是随机炸。你最后 Debug 去吧,鬼知道哪天才能复现。
    查出这个问题的时候也是运气好,恰好挂着调试器,它恰好炸了。。。
    jim9606
        7
    jim9606  
       2022-02-21 18:03:31 +08:00   ❤️ 1
    struct alignment 和 padding 的问题在 C 一样会有吧。

    @jones2000 @LifStge
    这事情跟没有初始化也没啥关系吧,这里问题是用了错误的比较方法。GID gid2=gid1 就是 well-defined 的初始化。懒得写比较函数的可以看看显式声明比较函数能不能用而不是用 memcmp 这种依赖 UB 的行为。
    lakehylia
        8
    lakehylia  
       2022-02-21 18:05:35 +08:00   ❤️ 1
    真要搞这种字节对比,就应该给 struct 加上 #pragma pack(1)
    bitdepth
        9
    bitdepth  
       2022-02-21 18:35:05 +08:00
    我反而懷疑是 MyType m_type 這個 MyType 的問題
    newmlp
        10
    newmlp  
       2022-02-21 18:48:09 +08:00
    不是应该重载==做判断吗,怎么还能用内存判断相等
    LifStge
        11
    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
        12
    kirory  
       2022-02-21 21:07:36 +08:00
    既然是 C++, 不是 operator < / <=> 就完了吗
    jim9606
        13
    jim9606  
       2022-02-22 01:09:22 +08:00
    @LifStge
    可能我语序有点问题,我的意思是复制构造(初始化)是没有问题的,比较有问题。一般想到用 memcmp 做比较的起因是懒得手写一个逐成员比较的比较函数,或者想提高性能。如果目的只是前者的话,C++20 可以显式定义默认比较函数。如果成员都是平凡可比较的话用这个默认比较函数的话是没有问题的,不会出现 OP 所提的问题。

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

    trivial 翻译成平凡确实是我没想到的😂
    littlewing
        15
    littlewing  
       2022-02-22 01:56:35 +08:00
    @Mithril 这其实就是一种性能和安全性的权衡了,std::array 中如果用 [] 访问同样也不做越界检查,如果用 at() 就会做越界检查,其他语言比如 rust 中也有同样的设计
    LifStge
        16
    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
        17
    msg7086  
       2022-02-22 03:57:25 +08:00 via Android
    简单说就是,用=赋值的,就要用==来判断。
    用 memcpy 复制的,采用 memcpy 来判断吧?
    GeruzoniAnsasu
        18
    GeruzoniAnsasu  
       2022-02-22 06:01:37 +08:00   ❤️ 1
    @LifStge 你确定你理解了

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

    这三点吗?
    dangyuluo
        19
    dangyuluo  
    OP
       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
        20
    coreki  
       2022-02-22 09:11:53 +08:00
    根据经验,初始化清零
    jackchenly
        21
    jackchenly  
       2022-02-22 09:25:59 +08:00
    @wctml 同,我的编程习惯就是会初始化所有变量
    3dwelcome
        22
    3dwelcome  
       2022-02-22 10:03:41 +08:00
    这算什么,隔壁邻居 memcpy 才是巨坑好吧。

    https://en.cppreference.com/w/cpp/string/byte/memcpy 里有一行超小字,写着如果输入内存部分有重叠,那么 the behavior of memcpy is not specified and may be undefined.

    而且这 undefined 行为,还是和 C++运行时有关系的,不同版本不一样。你本地运行没问题,换台机子就有问题了!被坑了好久。
    3dwelcome
        23
    3dwelcome  
       2022-02-22 10:07:41 +08:00
    大家体会一下官方那句“may be undefined”,用了 may, 没用肯定句。

    也就是结果可能正确,可能不正确。

    你在 99 台机器上运行正确,换最后 1 台机器就不正确了。
    LifStge
        24
    LifStge  
       2022-02-22 10:31:21 +08:00
    @GeruzoniAnsasu 谢谢 确实我比较偏激 结论上也有问题 你说的 3 点 2,3 我没反对的 看到未初始化的地方 然后我表达的就跑题了 对于非 POD 类型 拷贝构造函数 是不会额外的对整体做零初始化的 默认生成的 还是自定义的 结果一样的 所以就是你说的 第二点这个问题 我前面表达的 主要还是针对 gid1 未初始化的情况 不太清楚 OP 的 MyType 是否是 POD 类型 如果是 POD 类型 那么 GID 也就是 POD 类型了 这种情况 就是我前面的例子出现的结果了 在 clang -Os 优化下 问题就表现出来了 这时候 gid1 跟 gid2 都是未知数据 同时 gid2 跟 gid1 也不同 应该就是优化情况下 啥都没干. 此时如果显式的对 gid1 做零初始化 就比如 GID gid1{}; 这个情况下 gcc 跟 clang 的表现就都相同了 clang 下的反汇编 如果 GID 位数不多 gid1 跟 gid2 同时直接就是几条指令 清零了 如果是结构体较大 就转变为 memset 清零 .
    ColorfulBoar
        25
    ColorfulBoar  
       2022-02-22 13:26:12 +08:00   ❤️ 1
    @3dwelcome 恭喜你被 cppreference 整蛊了~
    我懒得查哪个词是敏感词了所以点击这里查看剩余内容 https://telegra.ph/%E4%BF%84%E7%BD%97%E6%96%AF%E6%96%B9%E5%9D%97-02-22
    MatDK
        26
    MatDK  
       2022-02-22 18:07:48 +08:00
    可以 学习了
    secondwtq
        27
    secondwtq  
       2022-02-22 19:56:13 +08:00   ❤️ 1
    secondwtq
        28
    secondwtq  
       2022-02-22 20:21:01 +08:00
    @GeruzoniAnsasu 因为 gid1 是 indeterminate ,gid2 初始化了也没用,这玩意会传染的。语言律师地说,楼主这一步已经死了。

    https://godbolt.org/z/hzenM768K
    mingl0280
        29
    mingl0280  
       2022-02-22 20:32:10 +08:00 via Android
    首先,在 GID2 中楼主不应当期待一个非 POD 的对象 /结构,在未初始化为 0 的情况下内存对齐以后,在对齐的 padding 部分中存在为 0 的数据(实际上这部分数据几乎肯定是随机的)。你要么 pack(1)干掉 padding 要么别搞 memcmp ,去自定义 operator=
    GeruzoniAnsasu
        30
    GeruzoniAnsasu  
       2022-02-22 20:46:54 +08:00
    @secondwtq 厄。。 那好像大家理解有点歧义, gid1 下面有省略号,我默认它是合法在用的对象,或者说 gid1 从定义到使用不存在问题
    secondwtq
        31
    secondwtq  
       2022-02-22 21:06:02 +08:00
    @GeruzoniAnsasu gid1 后面还有个分号,也就是说很有可能是没初始化的 ... 虽然他要是省略号里面给他赋了个值应该就没事了,要是没赋值就用还是炸
    当然这个帖子主要问题还是在 memcmp 上面 ...
    nicebird
        32
    nicebird  
       2022-02-22 22:29:48 +08:00
    确实是这样的,就是中间的空洞部分不会处理呗。
    dangyuluo
        33
    dangyuluo  
    OP
       2022-02-23 01:09:35 +08:00
    @secondwtq 这只是简易版的代码,实际上 gid1 是被正常初始化并且填 0 的。gid1 不是问题所在
    dangyuluo
        34
    dangyuluo  
    OP
       2022-02-23 01:13:05 +08:00
    @mingl0280 实际上这个类是 POD 的类,只是设计不当导致有 padding 而已
    TencentCEO
        35
    TencentCEO  
       2022-03-14 15:30:45 +08:00
    同意 @wctml 说的 "楼主 你太年轻了" 😃
    我现在都习惯类或结构体成员给默认值,struct GID{ bool used=false; };
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   998 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 20:01 · PVG 04:01 · LAX 13:01 · JFK 16:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.