标签 GNU 下的文章

跨过BUG查找的”最后一公里”

如果你看到一个C程序员在通宵熬夜神情紧张地对着电脑敲代码或阅读代码,多数只有两种可能:一是为了赶进度;二就是查找内存Bug。
                                                                                                                              — 个人感悟
 
昨晚搞到凌晨一点多,终于算是把一个棘手的Bug的来龙去脉搞清楚了。截至到今天,这个Bug已经困扰了项目组两个核心开发同事达三周之久了。

这个Bug的确很难查找:

   – 首先模拟环境下无法复现该Bug;
   – 生产环境下该Bug是随机出现的,发生频率十分低;
   – Bug出现时并未有dump core等明显异常现象出现,系统依旧运行良好。

得到Bug报告后,我的两位同事就开始对bug引发的问题现象进行了分析,得出了内存被污染的初步结论。之后又在生产环境做了GDB attach到进程的调试,甚至替换了生产环境的版本,利用传统的print语句在关键路径上输出提示信息,试图找到引发Bug的真正原因。但做过这些 后,所能得到的结论依旧停留在内存被污染,至于怎么被污染的、在哪个业务流程上被污染的却无从得知。无奈之下,两位同事开始根据 subversion的commit history进行代码比对和分析,试图查找到哪些新增或修改的代码引发了Bug。代码修改量小还好,如果修改数量巨大,这种代码比对就好比大海捞针,我 们无法保证注意力自始自终是集中的,结果两位同事也的确没有从代码变更中发现什么蛛丝马迹。这类Bug会让你有一种有力无处施展的感觉,面对这样 的Bug,我的两位开发人员似乎也失去了信心和思路。

下面简要描述一下这个Bug:

有这样一个字段数目众多的结构体foo_t,这里仅列出bug相关的几个字段e、c、flag、pdata:

struct foo_t {
    … …
    char e[XX_SIZE];
    char c[XX_SIZE];
    char flag;
    data_t *pdata;
    … …
};

业务逻辑是:

if (flag) {
    处理e、c两个字段;
}

   
bug现象:值本是1的flag字段被污染,值变成了0,导致e、c两个字段没有被做处理,从而引发业务异常,导致客户投诉。我的同事曾经做过如 下尝试,以确定内存污染的行为特点,她在flag之前又加了一个字段flag1:

struct foo_t {
    … …
    char e[XX_SIZE];
    char c[XX_SIZE];
    unsigned int flag1;
    char flag;
    data_t *pdata;
    … …
};

在生产环境下运行得到的结果是flag1和flag值正常,但字段c的尾部字节遭到了污染。现象已经十分明确,离真相就差那最后一公里了。

对于上面的内存污染问题,我首先会怀疑在处理flag或c之前的字段时出现了缓冲区溢出,导致后面字段的内容被整体或局部覆盖。不过从bug现象 来看,这个思路也有说不通的地方,那就是为何是c的尾部字段被污染,而不是从头部开始呢?不过我们依旧沿着这个思路追查了e以及e的诸多前驱字 段,细致的分析了代码,但没有发现溢出点。

c或flag的后继字段比如pdata要想污染c或flag则必须具备更多条件,至少要有操作&pdata的代码,之前基本认为这不太可 能。但现在仅有这一条路可以继续走下去了,也只能沿着这条路走下去。事实证明我们走的没错。在后续的处理流程中有这样的一个函数:

int func(void *p, int size)

这个函数本来是用于处理data_t*变量的,但由于编码者的疏忽,将&pdata传给了p,另外size这个参数也传了一个错误的值, 估计是滥用了copy&paste。而func函数体中对p指向的内存地址做了修改,这个修改直接污染了 ((char*)&pdata + size)起始的那片内存块儿,这就是问题的真正原因所在。这样看来pdata并未污染其所在的foo_t实例中的flag或c字段,而是污染了其他foo_t实例中的flag或c字段,因为这些实例都放在一个mem block pool中的,所以这还是一个随机的远距离内存污染^_^。

我走完了BUG查找的最后那一公里,到达了终点。这个BUG的查找确实不易,但并非遥不可及,为何我的两位同事就停在离真相只有一公里的地方而踌 躇不前了呢?对此我也做了一些考量,希望能在日后的BUG查找方面给予帮助。

要跨过BUG查找的那最后一公里,可从如下几个方面着手努力:

* 收罗证据,不放过一处可疑之处

这是准备工作,就好比警察查看罪案现场,哪怕是一根毛发,一处异物也不能放过。一般来说我们至少要收集到Bug发生时的各方面信息,包括:

 - 系统日志
        这个时间点上各个模块的日志都要搜罗到;

 - core文件
        如果bug引发core dump,那core文件是bug查找的最佳入口;

 - 通信数据包内容
        对于很多后端服务程序而言,不合法的通信数据包常常会引发Bug,我经手的类似Bug就不止一起了。必要时通过抓包工具将通信包抓到文件中以备后用。

 - CPU/内存/磁盘实时状况
       千万不要小视这些信息。如果发现CPU过高,则很可能代码存在死循环的可能(后pstack进程号,则可直接找到问题所在);如果磁盘满,则可以很好解释 数据不完整的异常;如果mem占用过高,则可以解释分配内存异常或性能下降等问题。

 - 系统操作日志
       如果有管理员的操作行为的话,我们也不要放过,将操作日志(一般系统都有保存,并需要对这些日志进行定期审核)截取并保留,以备后用。

 - 操作系统/硬件相关异常信息等。
       如果是因为OS或硬件异常导致的Bug,那搜集到这些信息就太重要了,否则你将付出惨重的Bug查找代价。

Bug查多了你就会有这种感悟:证据用时方恨少啊!

* 沉下心,保持清晰思路

BUG有难有易,简单的Bug大家都能应付,而困难的Bug,就要比拼能力和经验了。要想解决掉Bug,务必要沉下心,不急不躁,这是保持大脑始 终有清晰思路的前提。

能用工具(比如GDB)调试出来的Bug,都不是最难的问题,因为现场就摆在你的面前,你可以看到一切蛛丝马迹。最难的问题最终都是要通过脑力分 析出来的。

解决问题前,要根据之前搜罗的证据,形成自己的查找思路。没有思路是可怕的。没有思路的时候,也不要急于开始查,那样只会乱套。应根据已有的蛛丝 马迹,行成一些思路,哪怕这个思路你自己都不是很肯定,先按这个思路做做看,也许走出一步后,你又能收获新的信息,形成新的思路。就这样敏捷地向 前进,边向前探索边定期回顾。

* 知晓原理,缩小查找范围,形成正确思路

要保持清晰正确的思路,开发人员对系统的运行原理要做到十分清楚,这样可以缩小查找范围,重点突破。就好比上面的那个bug例子,我们要知道 c/flag被污染有几种潜在的可能,并形成多种思路,然后沿着这几种可能的思路继续走下去。在这次查找过程中,想必两位同事恰恰是在原理这方面 没有理解透彻吧。

* 质疑,从自己的代码开始

查Bug就要抛弃“不可能”,拥抱“质疑一切”。而质疑要从自己的代码开始。程序员或多或少都有一种“自负”的心态,骨子里会认为自己的代码肯定 是正确的。如果出现问题,一定是其他人代码的问题,哪怕是OS这样总体来说十分稳定的平台也会成为被首先质疑的对象。不过事实证明,错误多出在我 们自己的代码中,毫无理由的去怀疑操作系统、怀疑你使用的第三方库,多半会南辕北辙,浪费你宝贵的查找时间。

* 拥抱调试技巧和工具

必要的调试技巧是Bug查找的基本功底,这些技巧在涉及内存问题查找过程中相当有用。

  — print语句
        不用多说,print语句是最简单、最常用的调试手段,在代码任意位置,根据你的需要,输出信息,帮助你分析bug原因。其唯一的缺点就是可能需要你重新 build代码和部署你的应用。

  — gdb切入进程地址空间查看堆栈
         利用gdb一类的专用调试工具可在代码运行时切入进程地址空间,实时查看数据变化。你也可以在gdb下执行应用,获得同样的效果(适合单进程应用)。
 
  — 调试版中采用magic number + assert
         C程序的bug多为内存问题。常见的内存越界访问或污染的调试手段是在代码中为内存块添加magic number,并在特定环节用assert保证该magic number的值是没有被修改的。一旦值改变了,则说明问题发生在执行流的两次assert之间的某个地方,后续可进一步缩小assert间隙,直到定位 到问题。

  — 让bug尽可能的容易复现
         一个可以在模拟环境下复现的Bug总是比较好查的。出于这个考虑,我们可通过放大问题区域来尽可能更容易的复现bug,比如将一个字节的字段改为4个字 节,这样可能占据更多被污染的区域,比较利于Bug的复现(但这不总是ok的)。

* 把握节奏,避免陷入惯性思维

一些比较难fix的Bug,其查找过程可能会十分漫长,就像这次我们遇到的这个问题。这就需要我们的开发人员把握好Bug查找的节奏,因为长时间 调试和查问题容易让人陷入惯性思维,反倒不利于Bug的查找。一旦意识到自己进入惯性思维后,可考虑换种活动做做,比如出去散散步、洗个热水澡 等。或者给其他人员讲解你的查找思路,这个过程中自己可能会发现思路上的缺陷,或者由他人指出你思路方面的问题。

感觉Bug查找是一门手艺活,要学会慢工出细活,这总比不出活儿的要好,尤其是在面对那些十分诡异的内存Bug时。

再谈C语言位域

我在日常工作中使用C语言中的位域(bit field)的场景甚少,原因大致有二:

* 一直从事于服务器后端应用的开发,现在的服务器的内存容量已经达到了数十G的水平,我们一般不需要为节省几个字节而使用内存布局更加紧凑的位域。
* 结构体中位域的实现是平台相关或Compiler相关的,移植性较差,我们不会贸然地给自己造“坑”的。

不过近期Linux技术内核社区(www.linux-kernel.cn) mail list中的一个问题让我觉得自己对bit field的理解还欠火候,于是乎我又花了些时间就着那个问题重新温习一遍bit field。

零、对bit field的通常认知

在C语言中,我们可以得到某个字节的内存地址,我们具备了操作任意内存字节的能力;在那个内存空间稀缺的年代,仅仅控制到字节级别还不足以满足C 程序员的胃口,为此C语言中又出现了bit级别内存的“有限操作能力” – 位域。这里所谓的“有限”指的是机器的最小粒度寻址单位是字节,我们无法像获得某个字节地址那样得到某个bit的地址,因此我们仅能通过字节的运算来设置 和获取某些bit的值。在C语言中,尝试获得一个bit field的地址是非法操作

struct flag_t {
    int a : 1;
};

struct flag_t flg;
printf("%p\n", &flg.a);

error: cannot take address of bit-field ‘a’

以下是C语言中bit field的一般形式:

struct foo_t {
    unsigned int b1 : n1,
                 b2 : n2,
                 … …
                 bn : nk;
};

其中n1,n2,nk为对应位域所占据的bit数。

位域(bit field)的出现让我们可以用变量名代表某些bit,并通过变量名直接获得和设置一些内存中bit的值,而不是通 过晦涩难以理解的位操作来进行,例如:

struct foo_t {
    unsigned int a : 3,
                 b : 2,
                 c : 4;
};

struct foo_t f;
f.a = 3;
f.b = 1;
f.c = 12;

另外使用位域我们可以在展现和存储相同信息的同时,自定义更加紧凑的内存布局,节约内存的使用量。这使得bit field在嵌入式领域,在驱动程序领域得到广泛的应用,比如可以仅用两个字节就可以将tcpheader从dataoffset到fin的信息全部表示 和存储起来:

struct tcphdr {
    … …
    __u16   doff:4,
            res1:4,
            cwr:1,
            ece:1,
            urg:1,
            ack:1,
            psh:1,
            rst:1,
            syn:1,
            fin:1;
    … …
};

一、存储单元(storage unit)

C标准允许unsigned int/signed int/int类型的位域声明,C99中加入了_Bool类型的位域。但像Gcc这样的编译器自行加入了一些扩展,比如支持short、char等整型类 型的位域字段,使用其他类型声明位域将得到错误的结果,比如:

struct flag_t {
    char* a : 1;
};
 error: bit-field ‘a’ has invalid type

C编译器究竟是如何为bit field分配存储空间的呢?我们以Gcc编译器(Ubuntu 12.04.2 x86_64 Gcc 4.7.2 )为例一起来探究一下。

我们先来看几个基本的bit field类型的例子:

struct bool_flag_t {
    _Bool a : 1,
          b : 1;
};

struct char_flag_t {
    unsigned char a : 2,
                  b : 3;
};

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

struct int_flag_t {
    int a : 2,
        b : 3;
};

int
main()
{
    printf("%ld\n", sizeof(struct bool_flag_t));
    printf("%ld\n", sizeof(struct char_flag_t));
    printf("%ld\n", sizeof(struct short_flag_t));
    printf("%ld\n", sizeof(struct int_flag_t));

    return 0;
}

编译执行后的输出结果为:
1
1
2
4

可以看出Gcc为不同类型的bit field分配了不同大小的基本内存空间。_Bool和char类型的基本存储空间为1个字节;short类型的基本存储空间为2个字节,int型的为4 个字节。这些空间的分配是基于结构体内部的bit field的size没有超出基本空间的界限为前提的。以short_flag_t为例:

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

a、b两个bit field总共才使用了5个bit的空间,所以Compiler只为short_flag_t分配一个基本存储空间就可以存储下这两个bit field。如果bit field的size变大,size总和超出基本存储空间的size时,编译器会如何做呢?我们还是看例子:

struct short_flag_t {
    unsigned short a : 7,
                   b : 10;
};

将short_flag_t中的两个bit字段的size增大后,我们得到的sizeof(struct short_flag_t)变成了4,显然Compiler发现一个基础存储空间已经无法存储下这两个bit field了,就又为short_flag_t多分配了一个基本存储空间。这里我们所说的基本存储空间就称为“存储单元(storage unit)”它是Compiler在给bit field分配内存空间时的基本单位,并且这些分配给bit field的内存是以存储单元大小的整数倍递增的。但从上面来看,不同类型bit field的存储单元大小是不同的

sizeof(struct short_flag_t)变成了4,那a和b有便会有至少两种内存布局方式:
* a、b紧邻
* b在下一个可存储下它的存储单元中分配内存

具体采用哪种方式,是Compiler相关的,这会影响到bit field的可移植性。我们来测试一下Gcc到底采用哪种方式:

void
dump_native_bits_storage_layout(unsigned char *p, int bytes_num)
{

    union flag_t {
        unsigned char c;
        struct base_flag_t {
            unsigned int p7:1,
                         p6:1,
                         p5:1,
                         p4:1,
                         p3:1,
                         p2:1,
                         p1:1,
                         p0:1;
        } base;
    } f;

    for (int i = 0; i < bytes_num; i++) {
        f.c = *(p + i);
        printf("%d%d%d%d %d%d%d%d ",
                         f.base.p7,
                         f.base.p6, 
                         f.base.p5, 
                         f.base.p4, 
                         f.base.p3,
                         f.base.p2, 
                         f.base.p1, 
                         f.base.p0);
    }
    printf("\n");
}

struct short_flag_t {
    unsigned short a : 7,
                   b : 10;
};

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 113; /* 0111 0001 */
 s.b = 997; /* 0011 1110 0101 */

 dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));
 
编译执行后的输出结果为: 1000 1110 0000 0000 1010 0111 1100 0000。可以看出Gcc采用了第二种方式,即在为a分配内存后,发现该存储单元剩余的空间(9 bits)已经无法存储下字段b了,于是乎Gcc又分配了一个存储单元(2个字节)用来为b分配空间,而a与b之间也因此存在了空隙。

我们还可以通过匿名0长度位域字段的语法强制位域在下一个存储单元开始分配,例如:

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

这个结构体本来是完全可以在一个存储单元(2字节)内为a、b两个位域分配空间的。如果我们非要让b放在与a不同的存储单元中,我们可以通过加入 匿名0长度位域的方法来实现:

struct short_flag_t {
    unsigned short a : 2;
    unsigned short   : 0;
    unsigned short b : 3;
};

这样声明后,sizeof(struct short_flag_t)变成了4。

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 2; /* 10 */
 s.b = 4; /* 100 */

 dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));

执行后,输出的结果为:

0100 0000 0000 0000 0010 0000 0000 0000

可以看到位域b被强制放到了第二个存储单元中。如果没有那个匿名0长度的位域,那结果应该是这样的:

0100 1000 0000 0000

最后位域的长度是不允许超出其类型的最大长度的,比如:

struct short_flag_t {
    short a : 17;
};

error: width of ‘a’ exceeds its type

二、位域的位序

再回顾一下上一节的最后那个例子(不使用匿名0长度位域时):

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 2; /* 10 */
 s.b = 4; /* 100 */

dump bits的结果为0100 1000 0000 0000

怎么感觉输出的结果与s.a和s.b的值对不上啊!根据a和b的值,dump bits的输出似乎应该为1010 0000 0000 0000。对比这两个dump结果不同的部分:1010 0000 vs. 0100 1000,a和b的bit顺序恰好相反。之前一直与字节序做斗争,难不成bit也有序之分?事实就是这样的。bit也有order的概念,称为位序。位域字 段的内存位排序就称为该位域的位序。

我们来回顾一下字节序的概念,字节序分大端(big-endian,典型体系Sun Sparc)和小端(little-endian,典型体系Intel x86):
大端指的是数值(比如0×12345678)的逻辑最高位(0×12)放在起始地址(低地址)上,简称高位低址,就是高位放在起始地址
小端指的是数值(比如0×12345678)的逻辑最低位(0×78)放在起始地址(低地址)上,简称低位低址,就是低位放在起始地址

看下面例子:

int
main()
{
    char c[4];
    unsigned int i = 0×12345678;
    memcpy(c, &i, sizeof(i));

    printf("%p – 0x%x\n", &c[0], c[0]);
    printf("%p – 0x%x\n", &c[1], c[1]);
    printf("%p – 0x%x\n", &c[2], c[2]);
    printf("%p – 0x%x\n", &c[3], c[3]);
}

在x86 (小端机器)上输出结果如下:

0x7fff1a6747c0 – 0×78
0x7fff1a6747c1 – 0×56
0x7fff1a6747c2 – 0×34
0x7fff1a6747c3 – 0×12

在sparc(大端机器)上输出结果如下:

ffbffbd0 – 0×12
ffbffbd1 – 0×34
ffbffbd2 – 0×56
ffbffbd3 – 0×78

通过以上输出结果可以看出,小端机器的数值低位0×78放在了低地址0x7fff1a6747c0上;而大端机器则是将数值高位0×12放在了低 地址0xffbffbd0上。

机器的最小寻址单位是字节,bit无法寻址,也就没有高低地址和起始地址的概念,我们需要定义一下bit的“地址”。以一个字节为例,我们把从左到右的8个bit的位置(position)命名按顺序命名如下:

p7 p6 p5 p4 p3 p2 p1 p0

其中最左端的p7为起始地址。这样以一字节大小的数值10110101(b)为例,其在不同平台下的内存位序如下:

大端的含义是数值的最高位1(最左边的1)放在了起始位置p7上,即数值10110101的大端内存布局为10110101。
小端的含义是数值的最低位1(最右边的1)放在了起始位置p7上,即数值10110101的小端内存布局为10101101。

前面的函数dump_native_bits_storage_layout也是符合这一定义的,即最左为起始位置。

同理,对于一个bit个数为3且存储的数值为110(b)的位域而言,将其3个bit的位置按顺序命名如下:

p2 p1 p0

其在大端机器上的bit内存布局,即位域位序为: 110;
其在小端机器上的bit内存布局,即位域位序为: 011

在此基础上,理解上面例子中的疑惑就很简单了。

 s.a = 2; /* 10(b) ,大端机器上位域位序为 10,小端为01 */
 s.b = 4; /* 100(b),大端机器上位域位序为100,小端为001 */

于是在x86(小端)上的dump bits结果为:0100 1000 0000 0000
而在sparc(大端)上的dump bits结果为:1010 0000 0000 0000

同时我们可以看出这里是根据位域进行单独赋值的,这样位域的位序是也是以位域为单位排列的,即每个位域内部独立排序, 而不是按照存储单元(这里的存储单元是16bit)或按字节内bit序排列的。

三、tcphdr定义分析

前面提到过在linux-kernel.cn mail list中的那个问题大致如下:

tcphdr定义中的大端代码:

__u16   doff:4,
        res1:4,
        cwr:1,
        ece:1,
        urg:1,
        ack:1,
        psh:1,
        rst:1,
        syn:1,
        fin:1;

问题是其对应的小端代码该如何做字段排序?似乎有两种方案摆在面前:

方案1:
__u16    res1:4,
         doff:4,
         fin:1,
         syn:1,
         rst:1,
         psh:1,
         ack:1,
         urg:1,
         ece:1,
         cwr:1;

or

方案2:
__u16   cwr:1,
        ece:1,
        urg:1,
        ack:1,
        psh:1,
        rst:1,
        syn:1,
        fin:1,
        res1:4
        doff:4;

个人觉得这两种方案从理论上都是没错的,关键还是看tcphdr是如何进行pack的,是按__u16整体打包,还是按byte打包。原代码中使用的是方 案1,推测出tcphdr采用的是按byte打包的方式,这样我们只需调换byte内的bit顺序即可。res1和doff是一个字节内的两个位域,如果 按自己打包,他们两个的顺序对调即可在不同端的平台上得到相同的结果。用下面实例解释一下:

假设在大端系统上,doff和res1的值如下:

doff res1
1100 1010 大端

在大端系统上pack后,转化为网络序:

doff res1
1100 1010 网络序

小端系统接收后,转化为本地序:

0101 0011

很显然,我们应该按如下方法对应:

res1 doff
0101 0011

也就相当于将doff和res1的顺序对调,这样在小端上依旧可以得到相同的值。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats