【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>
当你看到这个标题,心里一定在想肯定是因为内存重叠导致的bug,我承认你是对的,但也不全对,如果你对此感兴趣可以继续往下看,你一定会有所收获。
首先我简单介绍一下背景,最近公司的项目遇到个诡异的问题,当客户端同时发送多个包时,服务端有一定概率解析不了,我们的协议格式是4字节头+消息体,每解析一条完整的包,就使用memcpy将剩余的数据copy到buffer头,类似这样的代码:memcpy(buffer, buffer+offset, len)。就是这样一行简单的代码,却有一定机率出现数据copy错误。【细心的读者可能注意到,其实不需要memcpy,每次都memcpy太浪费了,直接使用指针偏移就可以,没错,正确的姿势确实应该是这样,但是我很庆幸出现了这样的代码,不然可能要错过这么有意思的事,并且如果后续的数据不足一个完整的包,那么还是需要memcpy,所以这个问题最终还是会遇到】。
在详细说明之前,我先简单介绍一下memcpy,搞c/c++的对他肯定很熟悉,其主要用途就是用来copy内存,但需要注意的是在出现内存重叠时,可能会出现bug。在copy内存时源地址和目标地址可能有三种情况:1、源地址与目标地址完全不重叠;2、源地址与目标地址重叠且目标地址小于源地址;3、源地址与目标地址重叠且目标地址大于源地址。对于第一种情况,memcpy完全不会出现问题;对于第三种情况,memcpy一定会出现问题,需要使用memmove替代;但对于第二种情况,大部分人(包括我)和网上的介绍都认为这种情况memcpy没有问题,但现在我可以很负责任的说这是错的,因为这次的bug就因他而起。
服务器是从云上买的,环境是CentOS Linux release 7.4.1708 (Core) ,gcc 4.8.5,glibc 2.17,为了方便测试,我将出现bug的代码剥离出来,同样的逻辑,同样的数据,正常的结果应该是能正确的解析完2个包,但多运行几次会出现body not enough错误,有兴趣的同学可以自行测试,不同的环境可能结果不太一样,代码如下:
#include <stdio.h>
#include <string.h>
int main()
{
int count = 1;
char ptr[] = {0,0,0,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,0,0,0,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60};
int len = sizeof(ptr);
int chunkSize = 0;
char *ptr2 = malloc(len);
memcpy(ptr2, ptr, len); //这段代码主要是为了动态连接memcpy,后续使用gdb再调试memcpy时可以直接进到函数实现
printf("total %d\n", len);
do{
if(len < 4){
printf("head not enough\n");
break;
}
chunkSize = (ptr[0] << 24) & 0x7fffffff;
chunkSize|= ((ptr[1] << 16) & 0xff0000);
chunkSize|= ((ptr[2] << 8) & 0xff00);
chunkSize|= (ptr[3] & 0xff);
if(len - 4 < chunkSize){
printf("body not enough, chunkSize=%d\n", chunkSize);
break;
}
len-= (4 + chunkSize);
if(len > 0){
memcpy(ptr, ptr + 4 + chunkSize, len);
}
printf("parse chunk:%d, len=%d\n", chunkSize, len);
}while(len > 0);
printf("done\n");
}
上面出现问题的代码是:memcpy(ptr, ptr + 4 + chunkSize, len),为了找到原因,我先找到了glibc2.17的源代码,但是从源代码里没找到答案,可能是源代码没找到对地方吧。于是只能通过汇编来定位问题,上面这段调用memcpy的汇编如下:
0x0000000000401185 <main+987>: mov -0x4(%rbp),%eax
0x0000000000401188 <main+990>: movslq %eax,%rdx
0x000000000040118b <main+993>: mov -0xc(%rbp),%eax
0x000000000040118e <main+996>: cltq
0x0000000000401190 <main+998>: lea 0x4(%rax),%rcx
0x0000000000401194 <main+1002>: mov -0x18(%rbp),%rax
0x0000000000401198 <main+1006>: add %rax,%rcx
0x000000000040119b <main+1009>: mov -0x18(%rbp),%rax
0x000000000040119f <main+1013>: mov %rcx,%rsi
0x00000000004011a2 <main+1016>: mov %rax,%rdi
0x00000000004011a5 <main+1019>: callq 0x400520 <memcpy@plt>
其中ptr, ptr+4+chunkSize, len 分别放在rdi,rsi,rdx三个寄存器里,我们先查看一下内容
(gdb) x/8xb $rdi
0x7fffffffe3b0: 0x00 0x00 0x00 0x14 0x0b 0x0c 0x0d 0x0e
(gdb) x/8xb $rsi
0x7fffffffe3c8: 0x00 0x00 0x00 0x1e 0x1f 0x20 0x21 0x22
(gdb) p $rdx
$1 = 34
通过单步执行汇编,获得了完整的执行流程
=> 0x0000000000400520 <memcpy@plt+0>: jmpq *0x201b12(%rip) # 0x602038 <memcpy@got.plt>
=> 0x00007ffff7b619f0 <__memcpy_ssse3_back+0>: mov %rdi,%rax
=> 0x00007ffff7b619f3 <__memcpy_ssse3_back+3>: cmp $0x90,%rdx
=> 0x00007ffff7b619fa <__memcpy_ssse3_back+10>: jae 0x7ffff7b61a30 <__memcpy_ssse3_back+64>
=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>: cmp %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>: jbe 0x7ffff7b61a1a <__memcpy_ssse3_back+42>
=> 0x00007ffff7b61a01 <__memcpy_ssse3_back+17>: add %rdx,%rsi
=> 0x00007ffff7b61a04 <__memcpy_ssse3_back+20>: add %rdx,%rdi
=> 0x00007ffff7b61a07 <__memcpy_ssse3_back+23>: lea 0x39202(%rip),%r11 # 0x7ffff7b9ac10
=> 0x00007ffff7b61a0e <__memcpy_ssse3_back+30>: movslq (%r11,%rdx,4),%rdx
=> 0x00007ffff7b61a12 <__memcpy_ssse3_back+34>: lea (%r11,%rdx,1),%rdx
=> 0x00007ffff7b61a16 <__memcpy_ssse3_back+38>: jmpq *%rdx
=> 0x00007ffff7b63d12 <__memcpy_ssse3_back+8994>: lddqu -0x22(%rsi),%xmm0
=> 0x00007ffff7b63d17 <__memcpy_ssse3_back+8999>: movdqu %xmm0,-0x22(%rdi)
=> 0x00007ffff7b63d1c <__memcpy_ssse3_back+9004>: lddqu -0x12(%rsi),%xmm0
=> 0x00007ffff7b63d21 <__memcpy_ssse3_back+9009>: lddqu -0x10(%rsi),%xmm1
=> 0x00007ffff7b63d26 <__memcpy_ssse3_back+9014>: movdqu %xmm0,-0x12(%rdi)
=> 0x00007ffff7b63d2b <__memcpy_ssse3_back+9019>: movdqu %xmm1,-0x10(%rdi)
=> 0x00007ffff7b63d30 <__memcpy_ssse3_back+9024>: retq
上面这段代码关键部分转换成C大概是这样
dst+= 34
src+= 34
memcpy(dst-34, src-34, 16)
memcpy(dst-18, src-18, 16)
memcpy(dst-16, src-16, 16)
通过上面的代码明确了一件事,那就是这种方法是从src的起始位置开始copy,执行后dst的内容如下
(gdb) x/8xb 0x7fffffffe3b0
0x7fffffffe3b0: 0x00 0x00 0x00 0x1e 0x1f 0x20 0x21 0x22
可以看到,memcpy执行完后,完成了从src到dst的copy,没有异常。到这里,一切顺利,但接下来发生的事,就是本文最重要的部分,睁大眼睛继续看。通过在char ptr[]前面增加一行char tmp[],内容和ptr一模一样(其实这一步可以不做的,但是加了这一行,复现机率比较高),即:
char tmp[] = {0,0,0,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,0,0,0,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60};
再次通过gdb执行,从查看内存、汇编跟踪到查看结果一气呵成,得到如下结果
(gdb) x/8xb $rdi
0x7fffffffe3f0: 0x00 0x00 0x00 0x14 0x0b 0x0c 0x0d 0x0e
(gdb) x/8xb $rsi
0x7fffffffe408: 0x00 0x00 0x00 0x1e 0x1f 0x20 0x21 0x22
(gdb) p $rdx
$1 = 34
=> 0x0000000000400520 <memcpy@plt+0>: jmpq *0x201b12(%rip) # 0x602038 <memcpy@got.plt>
=> 0x00007ffff7b619f0 <__memcpy_ssse3_back+0>: mov %rdi,%rax
=> 0x00007ffff7b619f3 <__memcpy_ssse3_back+3>: cmp $0x90,%rdx
=> 0x00007ffff7b619fa <__memcpy_ssse3_back+10>: jae 0x7ffff7b61a30 <__memcpy_ssse3_back+64>
=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>: cmp %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>: jbe 0x7ffff7b61a1a <__memcpy_ssse3_back+42>
=> 0x00007ffff7b61a1a <__memcpy_ssse3_back+42>: lea 0x38faf(%rip),%r11 # 0x7ffff7b9a9d0
=> 0x00007ffff7b61a21 <__memcpy_ssse3_back+49>: movslq (%r11,%rdx,4),%rdx
=> 0x00007ffff7b61a25 <__memcpy_ssse3_back+53>: lea (%r11,%rdx,1),%rdx
=> 0x00007ffff7b61a29 <__memcpy_ssse3_back+57>: jmpq *%rdx
=> 0x00007ffff7b6440c <__memcpy_ssse3_back+10780>: lddqu 0x12(%rsi),%xmm0
=> 0x00007ffff7b64411 <__memcpy_ssse3_back+10785>: movdqu %xmm0,0x12(%rdi)
=> 0x00007ffff7b64416 <__memcpy_ssse3_back+10790>: lddqu 0x2(%rsi),%xmm0
=> 0x00007ffff7b6441b <__memcpy_ssse3_back+10795>: lddqu (%rsi),%xmm1
=> 0x00007ffff7b6441f <__memcpy_ssse3_back+10799>: movdqu %xmm0,0x2(%rdi)
=> 0x00007ffff7b64424 <__memcpy_ssse3_back+10804>: movdqu %xmm1,(%rdi)
=> 0x00007ffff7b64428 <__memcpy_ssse3_back+10808>: retq
(gdb) x/8xb 0x7fffffffe3f0
0x7fffffffe3f0: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x3a
可以看出,memcpy执行之后,内存copy失败了,dst的内容和src内容完全不一样了。那么是什么导致的呢?通过上面的汇编代码,我们转换成C代码,如下:
memcpy(dst+18, src+18, 16)
memcpy(dst+2, src+2, 16)
memcpy(dst, src, 16)
问题就出在这代码上,从上面的代码我们可以看到,他的实现是从src的尾部开始copy,那从尾部开始copy怎么就导致数据错乱呢?我们来看一下流程
//初始状态
//注意:src的起始地址紧跟在dst之后
dst = 00,00,00,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24, 25,26,27,28,29,30
src = 00,00,00,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44, 45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60
//执行memcpy(dst+18, src+18, 16)
//src+18的位置(空格之后的部分)copy到src+18的位置,一共16字节,由于dst+24就等于src,因此src+24的数据其实copy到了src,注意观察src已经被修改了
dst = 00,00,00,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24, 45,46,47,48,49,50
src = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44, 45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60
//执行memcpy(dst+2, src+2, 16)
//这两段内存没有重叠,正常copy,
dst = 00,00, 53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44, 45,46,47,48,49,50
src = 51,52, 53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44, 45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60
//执行memcpy(dst, src, 16)
//两段内存没有重叠,正常copy
dst = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42, 43,44,45,46,47,48,49,50
src = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42, 43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60
这段分析之后,我们知道问题的根源在于memcpy的copy方式上,如果从前往后,是没有问题的,但如果从后往前,一定会出问题,那么同样的平台,同样的代码,同样的数据,为什么执行结果完全不同呢?细心的你会发现,两次memcpy所执行的代码在第6行之后完全不一样了,问题关键就在第5和6行:
=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>: cmp %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>: jbe 0x7ffff7b61a1a <__memcpy_ssse3_back+42>
上面这两行代码是比较dst的src地址的最低1字节,即:如果dst的最低1字节小于等于src的最低1字节,就使用从前往后的方式,反之使用从后往前的方式。我们再回头比较两次执行时dst和src的地址
//正常的地址
dst = 0x7fffffffe3b0
dil = 0xb0
src = 0x7fffffffe3c8
sil = 0xc8
//异常的地址
dst = 0x7fffffffe3f0
dil = 0xf0
src = 0x7fffffffe408
sil = 0x08
果然,因为内存地址不一样,得到的结果也不一样了。到此,一切都真相大白了,就是因为memcpy的实现通过比较内存地址最低1字节来决定copy方式。不禁仰天长啸,这操作真是骚。目前我还理解不了为何这么干,可能又是因为性能吧。其实官方手册明确的说了,当memcpy出现内存重叠的时应该用memmove代替,因为可能在某些实现可能是从后往前copy,应该使用memmove代替。其实在问题开始大家都应该猜到,应该使用memmove来替换就可以解决问题,但是如果不追查到问题的根源,怎么能发现这么有意思事情呢?
最后提醒大家,如果你不了解具体的实现,当内存重叠时(无论第2或第3种情况),最好都使用memmove。
来源:oschina
链接:https://my.oschina.net/scgywx/blog/3143100