昨晚我在CSDN的C语言版上看到一个有关两道C语言面试题的帖子,其中一个提到了strlen的实现。从答案里发现了蚂蚁终结者的strlen源码剖析,异常精彩。由于新用户的外链贴限制,就不发原文了,大家可以自行搜索。
本文是针对其中的strlen_c作一个更细致的分析,因为好的代码总是让人回味无穷,里面所包含的东西不仅仅是编程语言以及算法。与一般的程序不同的、又恰恰是其本身最有价值的部分,是代码里面蕴含着的对于计算机程序运行乃至计算机本身的深刻理解。
先贴出蚂蚁终结者的代码(按个人习惯重排了部分格式)
typedef unsigned long ulong;
size_t strlen_c(const char *str) {
const char *char_ptr;
const ulong *longword_ptr;
register ulong longword, magic_bits;
for(char_ptr=str; ((ulong)char_ptr & (sizeof(ulong) - 1)) != 0; ++char_ptr) {
if (*char_ptr == '\0')
return char_ptr - str;
}
longword_ptr = (ulong *)char_ptr;
magic_bits = 0x7efefeffL ;
while(1){
longword = *longword_ptr++ ;
if((((longword + magic_bits) ^ ~longword) & ~magic_bits) != 0) {
const char *cp = (const char *)(longword_ptr - 1);
if(cp[0] == 0 )
return cp - str;
if(cp[1] == 0 )
return cp - str + 1 ;
if(cp[2] == 0 )
return cp - str + 2 ;
if(cp[3] == 0 )
return cp - str + 3 ;
}
}
}
02行 typedef unsigned long ulong;
unsigned long 为 unsigned long int的去int写法,在32位的机器上,类型长度为4字节。而在64位机器上,类型长度为8字节。
08行 register ulong longword, magic_bits;
register修饰符暗示编译程序相应的变量将被频繁使用,如果可能的话,将其保存在CPU的寄存器中,以加快存储速度。
这个修饰符有几点限制:
1. register变量必须是能被CPU所接受的类型。这通常意味着register变量必须是一个单个的值,并且长度应该小于或者等于整型长度。
2.register变量可能不存放在内存中,所以不能用“&”来获取register变量的地址。
早期的C编译程序不会把变量保存在寄存器中,除非你命令它这样作,这时register修饰符是C语言一种很有价值的补充。然而,随着编译程序技术的进步,在决定哪些变量应该被存到寄存器中时,现在的C编译环境能比程序员做出更好的决定。实际上,许多编译程序都会忽略register修饰符,因为尽管它合法,但它仅仅是暗示而不是命令。
以上解释摘自百度百科。
10-13行 数据对齐。
要完全理解数据对齐以及数据对齐为什么能够极大地缩小程序运行时间,追根溯源还得从硬件开始讲起。
地址总线与数据总线
先来说说地址总线。32位的地址总线使得CPU的寻址范围为4GB(2^32个地址,每个地址对应一个1字节的内存单位),由于地址总线是并口总线,是一次性发送/接受32位二进制数据。
数据总线里的位数,说的是一次能传的数据位数。这也是决定了数据对齐的位数。32位的数据总线一次能传输4个字节(注意:此时不是跟地址总线的位数一样,一个bit对应的就是一个bit)也就决定了4字节对齐的CPU效率最高。(如果不是4字节对齐,数据刚好在4*X地址左右分成两部分,那么需要读取两次数据、拼接、去掉多余字节,效率低下)
了解以上两者概念之后着重回答以下问题
Q1:为什么不直接寻址到某一内存,而要以4字节间隔?
效率问题。存储器其实是多个存储单元组成的阵列,需要通过地址寻址到相应的存储单元。一般存储器都将地址分成了行地址和列地址。每当输入一个地址的时候,通过译码器,每个行地址对应一行,每个列地址选中一类,行列的交叉就是我们要寻址的存储单元。如果要直接寻址到某一内存地址,可能导致存储单元分散到不同的行与列,通过传统的译码方式很难寻址成功。这必然会导致译码电路复杂从而降低效率。(行列即是位扩展与字扩展)
Q2:为什么数据对齐能极大地提高效率?
一个程序的瓶颈往往不是CPU的速度,而是取决于内存的带宽,因为CPU的处理速度要远大于从内存中读取数据的速度,因此减少对内存空间的访问是提高程序性能的关键,数据对齐就是因此目的而生。
Q3:如果内存只有1G,为什么32位的地址总线仍能寻址4G?(虚拟内存地址/线性地址空间、物理地址空间、虚拟内存、分页机制)
当线性空间/虚拟地址空间由32位地址总线所决定为4G,而物理地址空间却只有1G,大小不一致时,这样就需要借用硬盘空间。虚拟内存,就是操作系统借给物理地址空间的硬盘空间。在虚拟内存之上按固定大小将其分成页。启动了分页机制后,使用中的页回放在内存,不使用的放在硬盘,所以才有调入调出的动作。当同时使用内存需求大于1G的时候机器会卡,是因为内存页不断地被换入换出。
如何实现数据对齐?
我只想展示一个例子,例子来自百度知道(关于数据对齐,里面也讲述了一些)
/*
* sizeof(struct A) = 8
*/
struct A
{
int a;
char b;
short c;
};
/*
* sizeof(struct B) = 12
*/
struct B
{
char b;
int a;
short c;
};
/*
* sizeof(struct C) = 8
*/
#progma pack (2)
/* 指定按2字节对齐 */
struct C
{
char b;
int a;
short c;
};
#progma pack ()
/*取消指定对齐,恢复缺省对齐*/
/*
* sizeof(struct D) = 7
*/
#progma pack (1)
/*指定按1字节对齐*/
struct D
{
char b;
int a;
short c;
};
#progma pack ()
/*取消指定对齐,恢复缺省对齐*/
10-13行 for(char_ptr=str; ((ulong)char_ptr & (sizeof(ulong) - 1)) != 0; ++char_ptr)
{
if (*char_ptr == '\0')
return char_ptr - str;
}
这一步骤实现了原文中所说:(1) 一次判断一个字符直到内存对齐,如果在内存对齐之前就遇到'\0'则直接return,否则到(2);
我们先处理下以上数据:char_ptr指向字符串,是字符串的起始地址,sizeof(ulong) - 1为3。那么那个"&"操作到底是什么意思?
将3化为2进制数是11,如果要使得循环停止,char_ptr所存放的字符串起始地址的末尾两位2进制数必须为00。好了,可以设想倘若char_ptr所存放的地址为XXXXXXXX XXXXXXXX XXXXXXXX XXXXX001是没有跳出循环的(没有数据对齐),+1、+2后末尾分别变成010、011也没有跳出循环(没有数据对齐),直到再+1末尾三位变成100才跳出循环,这下数据对齐了(地址可以被4整除,对应的内存寻址也以4字节为间隔)。
倘若是8字节对齐,要把"&"后的数值设成7(111)。
PS. 这里有一点极易弄错,就是强制转换不改变原来的数,原本是指向char的char_ptr,在这个循环体用时仍是指向char,在指针自加时每次递增1,而不是8。
17行 magic_bits = 0x7efefeffL;
23行 if((((longword + magic_bits) ^ ~longword) & ~magic_bits) != 0);
这两点实现了(2) 一次读入并判断一个WORD,如果此WORD中没有为0的字节,则继续下一个WORD,否则到(3);(3) 到这里则说明WORD中至少有一个字节为0,剩下的就是找出第一个为0的字节的位置然后return。
老样子先展开数据看看:magic_bits = 01111110 11111110 11111110 11111111b。
假设 longword含0字节为11111111 11111111 00000000 11111111b,~longword为 00000000 00000000 11111111 00000000b,((longword + magic_bits) ^ ~longword) 有
01111110 11111110 11111110 11111111
+ 11111111 11111111 00000000 11111111
________________________________________________________
01111110 11111111 11111111 11111110
^ 00000000 00000000 11111111 00000000
_________________________________________________________
01111110 11111111 00000000 11111110
& 10000001 00000001 00000001 00000000
_________________________________________________________
00000000 00000001 00000000 00000000
当我们设longword里面不含0字节为11111111 11111111 11111111 11111111时有
01111110 11111110 11111110 11111111
+ 11111111 11111111 11111111 11111111
________________________________________________________
01111110 11111110 11111110 11111110
^ 00000000 00000000 00000000 00000000
_________________________________________________________
00000000 00000000 00000000 00000000
& 10000001 00000001 00000001 00000000
_________________________________________________________
00000000 00000000 00000000 00000000
可以猜想下,当longword不含0字节时得到的结果为0,含0字节时结果非0。
让我们看看"^ ~longword"跟"& ~magic_bits"到底产生了什么效果,换一个更普遍的数,设longword为01010101 01010101 00000000 01010101
01111110 11111110 11111110 11111111
+ 01010101 01010101 00000000 01010101
_________________________________________________________
11010100 01010011 11111111 01010100
^ 10101010 10101010 11111111 10101010
_________________________________________________________
01111110 11111001 00000000 11111110
先将longword的每一个二进制位的0、1提取出来单独观察,发现1^0(~1)=1、1^1(~0)=0、 0^0(~1)=0、 0^1(~0)=1。即是如果longword经过加法后得到的和的某一位并没有改变它原本(存在于longword相应位)的值,则作^运算后相应位会得1。 --1
01111110 11111001 00000000 11111110
& 10000001 00000001 00000001 00000000
__________________________________________________________
00000000 00000001 00000000 00000000
会发现,这个过程是magic_bits的反检验原本自己存0的位置(作与运算)了,同时将其他各位置为0。 --2
好了。那么为什么要用longword+magic_bits?因为那就是检测是否存在0的过程,观察magic_bits,发现01111110 11111110 11111110 11111111的设置非常有趣,当一个不存在0字节的longword加上去时,会产生连锁进位情况。当一个存在0字节的longword加上去时,在0字节所在的字节位置是不会对向前一字节进位的。根据1、2条件,可知只有加法后没有改变数值的hole位置(magic_bits的原本存0的位置)才能使得最终结果非0,进一步说,只有字节与字节之间有"无进位"的状况才能使得结果非0。所以能产生连锁进位的不含0字节的longword得到的最终结果是0。