作者:HW-HM
上一篇文章,我们讨论了进攻式编程【码手防御-8】进攻式编程,并提供一个C语言版的内存Debug工具,这一篇讲讨论编程中的风险。
先说说什么是这里所说的风险,简单地说就是看起来正确,搞不好就会出错的代码。这里的搞不好,包括换一个硬件环境、不同寻常的输入、程序又一次执行(不同的线程调度)、不小心的编码等,这是我们容易犯错,但是错误又很难发现(因为不是必然出现)的地方,所以需要特别注意。
第一种风险是依赖于环境(硬件、编译器等),当换一个环境时,程序可能就出问题了。主要包括数据类型、字节序、字节对齐等。
使用语言标准严格定义的数据类型,别对类型的长度做假定,仅使用语言标准定义的取值范围。有时我们的数据结构是与数据库表对应的,数据库表定义了每个字段的长度,这种情况可以使用统一的类型定义头文件来定义这些类型,通过预处理宏控制具体的类型定义。
在32位操作系统下long通常是32位,在64位操作系统下long通常是64位。如下的代码使用long进行字节填充以提高memset的性能,假定了long是4个字节,会导致程序可移植性差。
long* longfill(long* pl, long l, size_t size); /* 原型 */
memset(void* pv, byte b, size_t size)
{
byte* pb = (byte*)pv;
if(size >= sizeThreshold)
{
unsigned long l;
l = (b<<8) | b; /* 用4个字节拼成一个长字 */
l = (l<<16) | l;
pb = (byte*)longfill((long*)pb, l, size/4);
size = size%4;
}
while(size-- > 0)
*pb++ = b;
return(pv);
}
别对字节序做假定。字节序有两种,小字节序和大字节序,不同的CPU体系的字节序可能不一样。小字节序(Little endian):将低序字节存储在起始地址。大字节序(Big endian):将高序字节存储在起始地址。如下的代码将long long转化为int对long long做了大字节序假定,同时还假定了int是4个字节,同样会导致代码难以移植。
long long l = 12345;
int i = (int*)((char*)&l + 4);
在和其它计算机通信时,也要考虑字节序的问题,通常转化为网络字节序(大字节序)传输。需要通过网络传输给别的计算机使用的二进制文件也要考虑这个问题,文件中的所有数据都应该按网络字节序存取。当然,你可以使用文本的方式,因为只有一个字节,就没有了字节序的问题了。
按照字节对齐的通常准则设计数据结构,尤其是与数据库相关的数据结构。有的体系结构字节未对齐只会导致性能降低,而更多的体系结构则直接出错(Bus error)。字节对齐详见【码农漫游计算机-9】字节对齐。
第二种风险是类型转换和溢出,包括有符号无符号转换、计算溢出、自加自减溢出等。如下的代码在pv指向串中查找字符ch,看看能否发现问题。C++的类型转换也是容易出错的地方,可参考http://www.cnblogs.com/goodhacker/archive/2011/07/20/2111996.html。
void * memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = (unsigned char *) pv;
while (--size >= 0)
{
if (*pch == ch)
return (pch);
pch++;
}
return( NULL );
}
循环什么时候终止?只有当size小于0时,循环才会终止。可是size会小于0吗?不会,因为size是无符号值,当它为0时,表达式--size将使其下溢而成为类型size_t定义的最大无符号位。编码时经常反问:“这个变量表达式会上溢或下溢吗?”
避免做有符号无符号类型比较。在混合运算中显式进行向更大类型的转换,避免因为小类型越界产生不正确的结果。有符号无符号转换、计算溢出详见【码农漫游计算机-3】数的转换。
第三种风险调用不安全的函数。这样的函数有几种,一种是对参数合法性校验不够,典型的是需要调用者保证内存长度足够,例如strcpy。另一种是使用了静态变量返回结果,例如ctime,这样的函数在不同的调用序列以及多线程并发时往往会出问题。再一种是有副作用的函数,典型情况是修改了输入参数,例如strtok。这些函数的影响详见【码手防御-7】程序界面。这些函数通常都有对应的安全版本,函数名以“_s”结尾的就是,例如strcpy的安全版本是strcpy_s,建议调用安全的函数版本。当然,你也要避免设计出这样的函数。
第四种风险是多线程。包括对静态变量的并发访问以及死锁。如果一个函数使用了静态或全局变量,那么这个函数通常是不可重入的,当然可以对这样的变量加锁。加锁有三种方式:
1、 在客户端加,客户保证调用第一个方法前锁定服务端,在调用完最后一个方法后释放锁。这种方法锁的粒度会比较大。
2、 在服务端加,在服务端内部创建锁定服务端的方法。
3、 适配服务端,添加一个中间层负责锁定,客户端调用这个中间层。
锁加得不好,可能影响性能,甚至出现死锁。可以注意以下几点:
1、 限制数据作用域,临界区越小越好。
2、 对同样的资源,所有的加锁和解锁顺序保持一致,避免出现死锁。
3、 使用数据副本。我自己就曾经制造过一起死锁,我的策略是在提供给客户端调用的函数(public函数)中加锁,StopTask客户会直接调用,对任务数据进行了加锁(互斥锁)。用户退出客户端时,会调用Stop停止所有的任务,这个函数先加锁,遍历任务,依次对每个正在执行的任务调用StopTask,结果死锁了(StopTask再次加锁而不得)。发现死锁之后改成Stop函数先调用函数(内部解锁)读取所有任务数据的副本,然后遍历副本停止正在运行的任务。
4、 线程尽量独立。这个是终极解决方案,往往需要做出设计上的改变。
第五种风险覆盖基类实现。里氏代换原则:把基类都替换成它的子类,程序的行为没有变化。在覆盖基类得实现时,搞得不好很容易违反里氏替换原则。编码时可以采取如下措施:
1、 限制对基类的访问。所有成员属性私有化,仅供基类实现调用的函数私有化。
2、 区分命令和查询。查询返回一个结果,但不改变对象的属性。命令改变对象的属性,但不返回结果。
3、 子类覆盖实现时,先调用父类的实现,以及父类的查询方法,这样确保父类状态的一致性,只进行了行为的扩展。
第六种风险是有风险编程用语。包括混用操作符、使用移位代替乘除、嵌套的:?操作符、一种情况多次处理等,其它的欢迎大家补充。
程序员为了提高效率,将
a = b + c / 4;
转换为
a = b + c >> 2;
结果因为运算符优先级导致了错误,a = b + c >> 2定价于a = (b + c) >> 2。不能毫无必要地将不用类型地操作符混合使用,如果必须将不同类型地操作符混合使用,就用括号把它们隔离开来。同时这里的a = b + c / 4编译器优化时通常会自动将c / 4替换成移位操作,不用手工做这个事情。采用简单直接的方式编码,别过多追求奇技淫巧。
对一种情况,只处理一次,如果一种情况处理有多处,修改时,很容易遗漏。如下得代码level + 1出现了两次:
if(level + 1 < tags.length)
{
parts = new Parse(body, tags, level + 1, offset + endTag);
body = null;
}
引入变量nextLevel之后程序更加清楚,并易于维护。
int nextLevel = level + 1;
if(nextLevel < tags.length)
{
parts = new Parse(body, tags, nextLevel, offset + endTag);
body = null;
}





































苏公安网备 32011402010933号