要命的编译警告——指针参数类型混乱

要命的编译警告——指针参数类型混乱

前言

我记得有一个笑话(有很多变种),其中一个是这样说的:

大妈看见一小伙子抽烟,遂好心劝到:“你知不知抽烟有害健康吗?你没看到烟盒上还写着那个警告吗?(warning)”
小伙子:“没事,我是一个程序员。”
大妈:“哪又怎么样呢?”
小伙子:“我们只关心Error,从来不关心什么叫做Warning。”

这个笑话围绕程序员更多关心编译Error,不太关心编译Warning这个梗展开,有很多变种的版本。但是我们究其根本,程序员真的不需要关心编译的Warning吗?我想答案应该是否定的,Warning可以忽略,但是不代表我们不需要检查。或者说我们应该经过检查和确认后忽略掉确认没问题的Warning,而不是一股脑的直接忽略。

正文

我记得曾经在写程序的过程中发现一个问题,编译后运行结果总是不对,修改了很多回算法都不对。由于整个项目代码过长,所以抽出出错的模型重新写一个简单的易于表述的程序,如下:

​#include <stdio.h>

void myfunc(unsigned long long *data, unsigned int size)
{
        *data = size;
}

int main()
{
        unsigned char b1 = 0;
        unsigned short b2 = 0;
        unsigned int b4 = 0;
        unsigned long long b8 = 0;

        printf("b1=%u, b2=%u, b4=%u, b8=%lu\n", b1, b2, b4, b8);
        myfunc(&b1, 1);
        myfunc(&b2, 2);
        myfunc(&b4, 4);
        myfunc(&b8, 8);

        printf("b1=%u, b2=%u, b4=%u, b8=%lu\n", b1, b2, b4, b8);

        return 0;
}

猜测一下这段代码在x86_64体系结构上gcc编译后的运行结果是什么?

太容易了,必然是:

b1=0, b2=0, b4=0, b8=0
b1=1, b2=2, b4=4, b8=8

好吧,错!

这是一个有问题的程序,编译器(太次的编译器不算)会打出类似这样的警告信息:

warning: passing argument 1 of 'myfunc' from incompatible pointer type

但是多数情况下会编译通过,并生成可执行文件,对于习惯性忽略编译警告的人,特别是当编译一个很大的项目出现很多编译警告的时候,这个问题往往就被忽略掉了,而直接使用编译出来的可执行文件。但是执行后发现执行结果却可能是(在不同的体系结构或编译器下可能还会有不同):

b1=0, b2=0, b4=0, b8=0
b1=0, b2=0, b4=4, b8=8

这是为什么?我明明传递了b1的指针,然后把b1指针的内容写成了1,b2也类似如此。为什么这两个会是0呢?

可能经验值高一点的C程序员已经拍脑门猜到问题的可能所在了。由于原始问题代码过于复杂,我首先怀疑的是算法代码写的问题。后来确认算法代码的正确性后我开始使用gdb调试,我发现当myfunc(&b1, 1);执行之后b1的值是对的,是1没错。但是当myfunc(&b2, 2);执行之后b1就变成0了,但是b2是对的,是2。

就示例中的简单代码来看,这个时候已经很容易猜到应该是b2的赋值覆盖了b1。我当时的推测是后续大字节数在栈中和前面小字节数使用同一块四字节空间,为了验证我大想法,我使用了最直接了当大方式——反汇编:

objdump -d mytest

看到反汇编代码后答案一目了然,看一下主要大两个函数:

​
1230000000000400530:
124  400530:       55                      push   %rbp
125  400531:       48 89 e5                mov    %rsp,%rbp
126  400534:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
127  400538:       89 75 f4                mov    %esi,-0xc(%rbp)
128  40053b:       8b 55 f4                mov    -0xc(%rbp),%edx
129  40053e:       48 8b 45 f8             mov    -0x8(%rbp),%rax
130  400542:       48 89 10                mov    %rdx,(%rax)
131  400545:       5d                      pop    %rbp
132  400546:       c3                      retq
133
1340000000000400547:
135  400547:       55                      push   %rbp
136  400548:       48 89 e5                mov    %rsp,%rbp
137  40054b:       48 83 ec 10             sub    $0x10,%rsp
138  40054f:       c6 45 ff 00             movb   $0x0,-0x1(%rbp)
139  400553:       66 c7 45 fc 00 00       movw   $0x0,-0x4(%rbp)
140  400559:       c7 45 f8 00 00 00 00    movl   $0x0,-0x8(%rbp)
141  400560:       48 c7 45 f0 00 00 00    movq   $0x0,-0x10(%rbp)
142  400567:       00
143  400568:       48 8b 75 f0             mov    -0x10(%rbp),%rsi
144  40056c:       8b 4d f8                mov    -0x8(%rbp),%ecx
145  40056f:       0f b7 45 fc             movzwl -0x4(%rbp),%eax
146  400573:       0f b7 d0                movzwl %ax,%edx
147  400576:       0f b6 45 ff             movzbl -0x1(%rbp),%eax
148  40057a:       0f b6 c0                movzbl %al,%eax
149  40057d:       49 89 f0                mov    %rsi,%r8
150  400580:       89 c6                   mov    %eax,%esi
151  400582:       bf a0 06 40 00          mov    $0x4006a0,%edi
152  400587:       b8 00 00 00 00          mov    $0x0,%eax
153  40058c:       e8 7f fe ff ff          callq  400410
154  400591:       48 8d 45 ff             lea    -0x1(%rbp),%rax
155  400595:       be 01 00 00 00          mov    $0x1,%esi
156  40059a:       48 89 c7                mov    %rax,%rdi
157  40059d:       e8 8e ff ff ff          callq  400530
158  4005a2:       48 8d 45 fc             lea    -0x4(%rbp),%rax
159  4005a6:       be 02 00 00 00          mov    $0x2,%esi
160  4005ab:       48 89 c7                mov    %rax,%rdi
161  4005ae:       e8 7d ff ff ff          callq  400530
162  4005b3:       48 8d 45 f8             lea    -0x8(%rbp),%rax
163  4005b7:       be 04 00 00 00          mov    $0x4,%esi
164  4005bc:       48 89 c7                mov    %rax,%rdi
165  4005bf:       e8 6c ff ff ff          callq  400530
166  4005c4:       48 8d 45 f0             lea    -0x10(%rbp),%rax
167  4005c8:       be 08 00 00 00          mov    $0x8,%esi
168  4005cd:       48 89 c7                mov    %rax,%rdi
169  4005d0:       e8 5b ff ff ff          callq  400530
170  4005d5:       48 8b 75 f0             mov    -0x10(%rbp),%rsi
171  4005d9:       8b 4d f8                mov    -0x8(%rbp),%ecx
172  4005dc:       0f b7 45 fc             movzwl -0x4(%rbp),%eax
173  4005e0:       0f b7 d0                movzwl %ax,%edx
174  4005e3:       0f b6 45 ff             movzbl -0x1(%rbp),%eax
175  4005e7:       0f b6 c0                movzbl %al,%eax
176  4005ea:       49 89 f0                mov    %rsi,%r8
177  4005ed:       89 c6                   mov    %eax,%esi
178  4005ef:       bf a0 06 40 00          mov    $0x4006a0,%edi
179  4005f4:       b8 00 00 00 00          mov    $0x0,%eax
180  4005f9:       e8 12 fe ff ff          callq  400410
181  4005fe:       b8 00 00 00 00          mov    $0x0,%eax
182  400603:       c9                      leaveq
183  400604:       c3                      retq
184  400605:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
185  40060c:       00 00 00
186  40060f:       90                      nop

第134行main函数开始,135~137行分配了main存储临时变量的栈空间。138~141行定义并初始化临时变量b1, b2, b4, b8:

​
31       16       0  bit
+--------+--------+
|   b2   |      b1|  0x04
+--------+--------+
|       b4        |  0x08
+--------+--------+ 
|        b        |  0x0c
|        8        |  0x10
+-----------------+

我们看,b1和b2在同一个四字节空间里,b1占在了第一个自己,b2占在了第三和第四字节。b4占了b2下面四个字节,b8占了b4下面八个字节。

下面看第154~157行,也就是调用myfunc(&b1, 1)的时候。

  lea    -0x1(%rbp),%rax   把b1的地址存在rax寄存器里。

  mov    $0x1,%esi         把参数size,也就是数值1存在esi寄存器里。

  mov    %rax,%rdi         又把上面rax存放的内容,也就是b1的地址,存到rdi寄存器里。

  callq  400530            调用myfunc函数。

再看myfunc函数,由于我没有使用优化编译的选项,所以这个编译出来的代码过于冗余。myfunc函数那么多行,先是存放参数的寄存器内容入栈,然后又把参数出栈存到另外的寄存器里,然后最后赋值。其实最主要的就是这行:

mov    %rdx,(%rax)

把1写入b1所在的地址空间。注意这是一个8字节的操作,实际是写入从&b1所在地址开始的8个字节。但是我想是因为字节对齐机制的保护作用,所以&b1之上的内容,也就是其它整数段的内容没有被覆盖。

但是当第二次调用myfunc(&b2, 2)的时候,b1的空间无法幸免于难,被b2无情的刷掉了。

第三次调用myfunc(&b4, 4)的时候,b1和b2都无法幸免,因为是当作8字节操作的,所以b1,b2连上b4一起被b4刷掉了。

第四次调用myfunc(&b8, 8)的时候,由于b8拥有8字节的空间,所以它的赋值没有干扰到别人。

结束语

我想经过上面的分析,现在这个问题就很清楚了。可能有人会说了,作者你太小题大作了,这么明显的warning一眼就看见了,怎么可能忽略掉?呵呵,确实对于只有几十几百甚至几千行的程序来说,看到编译警告后也不是很难定位问题,但是现实往往是这样的:

你面对一个几万几十万甚至千万行代码的项目,你需要和很多人合作开发,你调用一个不知道谁写的函数,这个函数还被封装在一个库里,然后你在各种可能的条件下调用它,有的时候编译器能帮你发现,有的时候编译器不一定能探测出来。退一万步,假设编译器探测出疑似问题并给予警告,你面对一个硕大的项目,本来编译后就有数不清的警告信息会被打印出来,忽略或没看到新增的其中一两行小小的Warning简直再普遍不过。往往很多人都是在写完代码后,经历了漫长的编译等待时间,最后一次性看到Success,error=0,那简直都能让其美上天了。

然后再经过一些覆盖面不是很全的测试,由于测试没有覆盖到有问题的分支和条件导致这个问题被雪藏了。之后你发布出去新版本的软件,被用户大量使用后发现软件运行不稳定,在不知道什么情况下就会出现莫名其妙的错误。然后用户大量反馈问题给你,说你负责的功能在使用时有很难预知的问题,但是不知道如何稳定的复现,只有不断的运行随机测试没准多少天能碰见一次错误。但是这个错误又不是致命错误,不会引起coredump,你又无法获得到方便定位问题的core文件。

这个时候你一定会感觉世界末日到了,根本无从下手调试。数万数十万行的代码,和出问题的功能部分相关的代码假设有几千行,假设是你写了这几千行,但是却是调用了老的很多函数。用户说你这几千行代码负责的功能用着有问题。你无从下手调试,没有人知道哪有问题,也非常难复现。你开始反复的苦苦的阅读着你那几千行可能自己都快记不清意思的代码,努力的读了很多很多遍都找不到算法逻辑的错误(因为算法上确实没有错误。。。)

怎么办?老板才不管那新,新功能你写的,给你三天查出并解决。崩溃了吧?仔细看一下编译警告,问题就容易隐藏在那里。这种不好重现,不能定位,不是你一个人的代码,又与你代码的逻辑算法无关的bug会把一个程序员弄的完全崩溃的。问题绝对不会像本文示例的那样简明清楚,问题往往是比从大海里的找一架遇难的飞机还要难。所以,养成良好的编码习惯,注意编译警告,增加对系统机制的理解都有助于避免和定位错误。


更多内容请参阅:

醉卧沙场:README - 专栏文章总索引

编辑于 2019-07-12 11:29