标签 C 下的文章

小心'溢出'陷阱

这几天以前曾经做过的一个项目上线测试了,果不其然,没有经过’战争洗礼’的产品就是靠不住,这不出了若干问题。害得我逃了半天课远程支持。

其中的一个问题很值得思考。其所在的模块并非是一个核心功能模块,而是一个提高系统Availability的一个功能模块,主要功能就是监视磁盘占用率。我们通过配置给出允许使用的磁盘空间大小(以M Byte为单位),以及两个阈值,即当占用率达到多少的时候,Do A;达到多少的时候Do B。

我们假设用变量quota代表配置中读取的配额数值,而total代表实际检测到的占用数值,一般关于文件大小的系统调用都是用byte作为单位的,也就是说我们需要做一个转换,假设换算后的变量为quota1。由于最初我们没有考虑周全的原因,我们使用unsigned int作为quota、quota1和total的存储类型。结果在家里没有做过认真的测试,导致一到现场就’露馅’了。这个问题反应到家里后,一个同事发现了这一问题,并作了修改,经过简单的测试,好像表面上问题消失了。再一次提交到现场后,问题依旧。

由于那位同事还有其他工作,我只能逃课改问题,经过一段时间的代码Review终于发现了些许’蛛丝马迹’,简单表述一下,原来这里的代码是这样的:

计算total;
quota1 = quota * 1024 * 1024;
拿total和quota1之比与配额阈值作比较;

注意这里的total和quota1是unsigned long long,也就是64位的,而quota是unsigned int,即32位的。首先quota肯定不会出现溢出的可能,因为检查配置发现这个数不大。那么为什么从日志观察,quota1有问题呢?

比如我们的quota配置为1004800,那么在换算后正确的数值应该是053609164800,而日志中打印出来的结果却是1342177280。基本上可以肯定问题出在quota1 = quota * 1024 * 1024;这个转换式上。

我们大概可以用下面的程序来模拟一下这个问题:
int main() {
        long m = 1004800;
        unsigned long long n;
        n = m * 1024 * 1024;
        printf("%llu\n", n);
}

由于n = m * 1024 * 1024这个计算式的工作流程是这样的,先将m * 1024 * 1024的结果保存在一个临时变量中,然后再将这个临时变量值赋给n,这里是在Solaris9下利用GDB反汇编的结果:

(gdb) disas main
Dump of assembler code for function main:
0x0001066c <main+0>:    save  %sp, -128, %sp
0×00010670 <main+4>:    sethi  %hi(0xf5400), %o0
0×00010674 <main+8>:    or  %o0, 0×100, %o0     ! 0xf5500
0×00010678 <main+12>:   st  %o0, [ %fp + -20 ]
0x0001067c <main+16>:   ld  [ %fp + -20 ], %o0
0×00010680 <main+20>:   sll  %o0, 0×14, %o0
0×00010684 <main+24>:   st  %o0, [ %fp + -28 ]
0×00010688 <main+28>:   sra  %o0, 0x1f, %o0
0x0001068c <main+32>:   st  %o0, [ %fp + -32 ]
0×00010690 <main+36>:   sethi  %hi(0×10400), %o0
0×00010694 <main+40>:   or  %o0, 0×358, %o0     ! 0×10758 <_lib_version+8>
0×00010698 <main+44>:   ld  [ %fp + -32 ], %o1
0x0001069c <main+48>:   ld  [ %fp + -28 ], %o2
0x000106a0 <main+52>:   call  0×20800 <printf>
0x000106a4 <main+56>:   nop
0x000106a8 <main+60>:   mov  %o0, %i0
0x000106ac <main+64>:   nop
0x000106b0 <main+68>:   ret
0x000106b4 <main+72>:   restore

%o0 = 0xf5500 = 1004800
store %o0 -> fp + -20
大概看一下:
0×00010670 <main+4>:    sethi  %hi(0xf5400), %o0
0×00010674 <main+8>:    or  %o0, 0×100, %o0     ! 0xf5500
0×00010678 <main+12>:   st  %o0, [ %fp + -20 ]
这三句实际上是在栈上分配一个变量m,并赋值为1004800,这里编译器利用sethi  %hi(0xf5400), %o0和or  %o0, 0×100, %o0两句在寄存器%o0中构造出1004800(即0xf5500),然后将寄存器的值通过st指令写入到%fp – 20的位置。即m占据着从%fp – 17到%fp – 20这四个字节。

再往下
sll  %o0, 0×14, %o0,
st  %o0, [ %fp + -28 ]
这里是编译器做的优化,它没有乘以两次1024,而是直接乘以1024*1024的结果,也就是2^20,即将%o0逻辑左移20位,即逻辑左移0×14,我们知道逻辑左移即把操作数看成无符号数。对寄存器操作数进行移位,不管左右移,空出的位均补0,我们可以来手工逻辑左移一次,目前%o0中存储的是无符号数0xf5500, 即 0000 0000 0000 1111 0101 0101 0000 0000(B),我们逻辑左移20位后为0101 0000 0000 0000 0000 0000 0000 0000(B), 即0×50000000,即1342177280。之后利用st指令将改寄存器的值存入到%fp – 28开始的8个字节当中(即从%fp – 21到%fp – 28)。这样我们读出来的n值也就是1342177280了。

如何修正呢?看下面的例子:
int main() {
        long m = 1004800;
        unsigned long long n = m;

        n *= 1024 * 1024;
        printf("%llu\n", n);
}

(gdb) disas main
Dump of assembler code for function main:
0x0001066c <main+0>:    save  %sp, -128, %sp
0×00010670 <main+4>:    sethi  %hi(0xf5400), %o0
0×00010674 <main+8>:    or  %o0, 0×100, %o0     ! 0xf5500
0×00010678 <main+12>:   st  %o0, [ %fp + -20 ]
0x0001067c <main+16>:   ld  [ %fp + -20 ], %o0
0×00010680 <main+20>:   st  %o0, [ %fp + -28 ]
0×00010684 <main+24>:   sra  %o0, 0x1f, %o0
0×00010688 <main+28>:   st  %o0, [ %fp + -32 ]
0x0001068c <main+32>:   ldd  [ %fp + -32 ], %o0
0×00010690 <main+36>:   mov  %o0, %o2
0×00010694 <main+40>:   mov  %o1, %o3
0×00010698 <main+44>:   srl  %o3, 0xc, %o5
0x0001069c <main+48>:   sll  %o2, 0×14, %o4
0x000106a0 <main+52>:   or  %o5, %o4, %o0
0x000106a4 <main+56>:   sll  %o3, 0×14, %o1
0x000106a8 <main+60>:   std  %o0, [ %fp + -32 ]
0x000106ac <main+64>:   sethi  %hi(0×10400), %o0
0x000106b0 <main+68>:   or  %o0, 0×378, %o0     ! 0×10778 <_lib_version+8>
0x000106b4 <main+72>:   ld  [ %fp + -32 ], %o1
0x000106b8 <main+76>:   ld  [ %fp + -28 ], %o2
0x000106bc <main+80>:   call  0×20820 <printf>
0x000106c0 <main+84>:   nop
0x000106c4 <main+88>:   mov  %o0, %i0
0x000106c8 <main+92>:   nop
0x000106cc <main+96>:   ret
0x000106d0 <main+100>:  restore

和上面的汇编差不多少,主要的差别就是再st  %o0, [ %fp + -28 ]后,所有的操作均针对8位的m了,而且寄存器也不仅仅一个%o0参与(位数不够了),这句之后都是关于8字节的运算了。也就不存在溢出了。毕竟汇编细节看起来还是很费劲的,大家能明白其中的意思即可。

其实简单来看我们可以这么来理解:
n = m * 1024 * 1024;
n *= 1024 * 1024;

前一个式子可以看成 m’ = m * 1024 * 1024; n = m’;这样我们可以简单的认为m’这个中间变量和m存储空间一致。
而n *= 1024 * 1024 <=> n *= 1048576 <=> n = n * 1048576,都是在n的基础上操作,不会出现溢出问题。

溢出问题一般都很隐蔽,很难轻易发现,大家要格外注意。

P.J.Plauger版本C标准库实现分析之'ctype.h'

如果在你的源代码中经常见到如下代码:
/* To Identify a letter */
if ((i >= 'a' && i = 'A' && i <= 'Z'))

/* To Identify a digit */
if ( i >= '0' && i <= '9')

这说明你对头文件理解的不是很好,而也恰恰是为了减少代码中重复出现的各种'字符分类'代码而设置的。

中的接口常用来进行数据的校验和分类,如在我们的项目中它常被用来校验原始数据的'符合性'。比如说一个11位的手机号码就必须是一个全数字的字符串,我们可以选择'isdigit'来进行测试,如果返回失败,则说明原始数据不符合要求,校验失败。

首先这里有两件事不会提及,首先是对中各个接口的说明,你可以参见'ANSI C标准'文档,也可以参考各种C手册来找到你的答案;另外一点就是暂不考虑locale对中各种接口行为的影响问题,我们仅仅在'C' locale的范围内考虑问题。

Ok,有了上面两个前提,我们就可以考虑如何实现了。传统的方案,同时也是P.J.Plauger实现方案之一,那就是使用'Translation Table'和宏。宏的好处大家都很明了,不外乎可读性好+性能优越,它也是C程序员一直偏爱的工具,尽管现在很多人对之嗤之以鼻,我们依然在很多的源代码中大量的见到它的身影。

P.J.Plauger的实现方案有三个值得注意的地方:
首先我们来看看他声明的(摘录其中一部分)

/* ctype.h */
#ifndef _CTYPE
#define _CTYPE

/* _Ctype code bits */
#define _XA 0×200 /* extra alphabetic */
#define _XS 0×100 /* extra space */
#define _BB 0×80 /* BEL, BS, etc */
#define _CN 0×40 /* CR, FF, HT, NL, VT */
#define _DI 0×20 /* '0' ~ '9' */
#define _LO 0×10 /* 'a' ~ 'z' */
#define _PU 0×08 /* punctuation */
#define _SP 0×04 /* space */
#define _UP 0×02 /* 'A' ~ 'Z' */
#define _XD 0×01 /* '0' ~ '9', 'a' ~ 'z', 'A' ~ 'Z' */

int isdigit(int);
extern const short *_Ctype;
….

#define isdigit(c) (_Ctype[(int)(c)] & _DI)

#endif

/* isdigit.c */
#include

#define XDI (_DI|_XD)
… …

int (isdigit)(int c)
{
 return (_Ctype[c] & _DI);
}

中有一处奇怪的地方,那就是每个接口函数都有一个同名的宏与之对应。再看看isdigit.c中isdigit接口的实现是int (isdigit)(int c),而不是int isdigit(int c),如果是后者,编译都会有问题。不是很了解P.J.Plauger为什么要这么做,Maybe是为了提供多种字符处理的方案,你可以这样来使用宏:
int a = 0;
a = isdigit(5);

同样你也可以这样来选择使用函数接口:
int  b = 0;
b = (isdigit)(5);

第二个值得注意的地方就是'Translation Table'转换的原理了,以检查digit为例,先看看ctype_tab表是什么样子的:
static const short ctype_tab[257] = { 0, /* EOF */
…, …, …,
…, …, …,



…, XDI, …,

};
const short * _Ctype = &ctype_tab[1];

注意这个表支持另外一个额外的值'EOF'宏,这样表的大小就是257,而非256了。

当我们调用isdigit的时候,如:
c = '5';
if (isdigit(c)) {
 printf("c is a digit\n");
} else {
 printf("c is not a digit\n");
}

如上面isdigit实现,它把参数作为index在转换表中找到相应表项,然后与_DI宏做'与'操作。如c = '5',其在ASCII码表中的index为53,我们在转换表中找出index为53的那个表项是XDI,然后XDI & _DI,结果为真。当然转换表中的表项都是事先按照ASCII码标安排好的。

第三个值得注意的地方就是ctype_tab数组类型为short。按照P.J.Plauger的说法他之所以选择short而非unsigned char类型是因为他觉得这样的实现拥有最大的portability,易于以后支持其他各种locale。当然如果你能完全排除支持其他locale的念头,你大可使用unsigned char,而且这样可以更好的节省空间。

中还提供toupper和tolower两个接口,这两个接口的实现也分别各需要一个转换表。这里就不详细叙述了。

附录
P.J.Plauger版本C标准库实现分析之'assert.h'

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系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