前两天在写程序的过程中发现一个问题,编译后运行结果总是不对,修改了很多回算法都不对。由于整个项目代码过长,所以抽出出错的模型重新写一个简单的易于表述的程序,如下:
1#include 2 3void myfunc(unsigned long long *data, unsigned int size) 4{ 5 *data = size; 6} 7 8int main() 9{ 10 unsigned char b1 = 0; 11 unsigned short b2 = 0; 12 unsigned int b4 = 0; 13 unsigned long long b8 = 0; 14 15 printf("b1=%u, b2=%u, b4=%u, b8=%lu\n", b1, b2, b4, b8); 16 17 myfunc(&b1, 1); 18 myfunc(&b2, 2); 19 myfunc(&b4, 4); 20 myfunc(&b8, 8); 21 22 printf("b1=%u, b2=%u, b4=%u, b8=%lu\n", b1, b2, b4, b8); 23 24 25 return 0; 26}
太容易了,必然是:
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 400530158 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 400530162 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 400530166 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 400530170 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
138 movb $0x0,-0x1(%rbp) <--- b1
139 movw $0x0,-0x4(%rbp) <--- b2
140 movl $0x0,-0x8(%rbp) <--- b4
141 movq $0x0,-0x10(%rbp) <--- b8
很一目了然,内存中的组织形式大概如下:
31 16 0 bit +--------+--------+ | b2 | b1| 0x04 +--------+--------+ | b4 | 0x08 +--------+--------+ | b | 0x0c | 8 | 0x10 +-----------------+
下面看第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字节的空间,所以它的赋值没有干扰到别人。
我想现在问题就清楚了,这样一个运行时才可能出现的问题,如果你忽略编译警告就危险了。而且问题远远比我简化后举例说明的要复杂。试想一下:
一个几万几十万甚至千万行代码的项目,你调用一个不知道谁写的函数,这个函数就类似myfunc(),然后你传入参数的方式又和上面类似。经过一个多小时的编译,打印了数百行行不同的警告信息,但编译通过。你简单测试之后由于测试没有覆盖到有问题的分支和条件导致这个问题被雪藏了。
之后你发布出去新版本的软件,被用户大量使用后发现软件运行不稳定,在不知道什么情况下就会出现莫名其妙的错误。然后用户大量反馈问题给你,说你负责的功能在使用时有很难预知的问题,但是不知道如何稳定的复现,只有不断的运行没准多少天能碰见一次错误。但是这个错误又不是致命错误,不会引起coredump,你又无法获得到方面定位问题的core文件。
这个时候你一定会感觉世界末日到了,根本无从下手调试。数万行的代码,你写了其中几千行,用户说你这几千行代码负责的功能用着有问题。你无从下手调试,没有人知道哪有问题,也非常难复现。你开始反复的苦苦的阅读着你那几千行可能自己都快记不清意思的代码,费尽的读了很多很多遍都找不到算法逻辑的错误(因为算法上确实没有错误。。。)
怎么办?崩溃了吧?仔细看一下编译警告,问题就容易隐藏在那里。这种不好重现,不能定位,不是你一个人的代码,又与你代码的逻辑算法无关的bug会把一个程序员弄的完全崩溃的。问题绝对不会像本文示例的那样简明清楚,问题往往是比捞MH370还难定位。所以,养成良好的编码习惯,注意编译警告,增加对系统机制的理解都有助于避免和定位错误。