VC CRT库中的strlen算法

VC中CRT库的strlen算法浅析

最近在看《老码识途》一书时,看到其对于C运行时库中的strlen函数的讲解,详细解释了汇编实现的加速算法。为了加深理解又从网上参考了一些博文【揭秘VC CRT库Intel模块】-- strlen,在仔细研究,并融入自己的理解后,将其做一个记录。

1.strlen的c语言实现

  首先,我们应该知道在C/C++中,0(并非字符“0”)标识着一个字符串的结束,因此计算字符串的长度 length = 第一个0的位置 - 首字符的位置。如果让我们自己写一个strlen的算法,我们很容易就能写出类似下面这个形式的函数。

size_t __cdecl strlen ( const char * str)
{
        const char *eos = str;
        while( *eos++ ) ;          //不为0就加1,查看下个字符是否为0
        return( eos - str - 1 );    //因为*eos = 0后,++操作使其移到下个字符,所以这里需要减1.
}

  在VS2017中,将上述代码反汇编后,其汇编如下所示。针对每条语句我已经做了注释,可以看到刚好与c语言的逻辑吻合。

    const char *eos = str;
00BF42A8 8B 45 08             mov         eax,dword ptr [str] ;str指向的是字符串的首地址,将其指向的首地址赋值给eax
00BF42AB 89 45 F8             mov         dword ptr [eos],eax ;最终将字符串首地址保存局部变量eos指向的栈空间
    while (*eos++);          
00BF42AE 8B 45 F8             mov         eax,dword ptr [eos]  ;将eos指向的地址取出,赋值给eax
00BF42B1 8A 08                mov         cl,byte ptr [eax]  ;从eax指向的地址处,取一个字符赋值给cl
00BF42B3 88 8D 33 FF FF FF    mov         byte ptr [ebp-0CDh],cl  ;将该字符保存在ebp-oxcdh的栈空间
00BF42B9 8B 55 F8             mov         edx,dword ptr [eos]  ;将eos指向的地址取出,赋值给ebx
00BF42BC 83 C2 01             add         edx,1  ;加1,指向下一个字符
00BF42BF 89 55 F8             mov         dword ptr [eos],edx  ;保存回eos指向的栈空间
00BF42C2 0F BE 85 33 FF FF FF movsx       eax,byte ptr [ebp-0CDh]  ;取回之前保存的字符
00BF42C9 85 C0                test        eax,eax  ;字符与自身做与操作
00BF42CB 74 02                je          strlen+4Fh (0BF42CFh)  ;如果该字符为0,说明到了字符结尾,跳转到return
00BF42CD EB DF                jmp         strlen+2Eh (0BF42AEh)  ;如果不等0,循环,继续判断下一个字符
    return(eos - str - 1);    
00BF42CF 8B 45 F8             mov         eax,dword ptr [eos]  
00BF42D2 2B 45 08             sub         eax,dword ptr [str]  ;eos处指向的地址-str首地址
00BF42D5 83 E8 01             sub         eax,1 ;再减1,返回结果保存在eax中
2.CRT库中strlen的汇编高效实现

  然而,因为strlen作为一个相当常用的函数,如果其实现的算法速度更快,那么其对整个系统的性能提升是比较可观的。因此在CRT库中,针对strlen函数,实现了一个更为高效的汇编实现的算法,我本机是win10 + vs2017环境,在路径 C:\Program Files (x86)\Windows Kits\10\Source\10.0.17763.0\ucrt\string\i386 中找到了strlen.asm的实现,其主要代码如下:

string  equ     [esp + 4]
        mov     ecx,string              ; ecx -> string
        test    ecx,3                   ; test if string is aligned on 32 bits
        je      short main_loop

str_misaligned:
        ; simple byte loop until string is aligned
        mov     al,byte ptr [ecx]
        add     ecx,1
        test    al,al
        je      short byte_3
        test    ecx,3
        jne     short str_misaligned
        add     eax,dword ptr 0         ; 5 byte nop to align label below
        align   16                      ; should be redundant

main_loop:
        mov     eax,dword ptr [ecx]     ; read 4 bytes
        mov     edx,7efefeffh
        add     edx,eax
        xor     eax,-1
        xor     eax,edx
        add     ecx,4
        test    eax,81010100h
        je      short main_loop
        ; found zero byte in the loop
        mov     eax,[ecx - 4]
        test    al,al                   ; is it byte 0
        je      short byte_0
        test    ah,ah                   ; is it byte 1
        je      short byte_1
        test    eax,00ff0000h           ; is it byte 2
        je      short byte_2
        test    eax,0ff000000h          ; is it byte 3
        je      short byte_3
        jmp     short main_loop         ; taken if bits 24-30 are clear and bit 31 is set

byte_3:
        lea     eax,[ecx - 1]
        mov     ecx,string
        sub     eax,ecx
        ret
byte_2:
        lea     eax,[ecx - 2]
        mov     ecx,string
        sub     eax,ecx
        ret
byte_1:
        lea     eax,[ecx - 3]
        mov     ecx,string
        sub     eax,ecx
        ret
byte_0:
        lea     eax,[ecx - 4]
        mov     ecx,string
        sub     eax,ecx
        ret

  上述源码中,根据开发人员已经有的注释,我们可以了解其整体框架,下面我们对其进行详细的解释。

string  equ     [esp + 4]               ; 字符串地址
        mov     ecx,string              ; ecx -> string
        test    ecx,3                   ; test if string is aligned on 32 bits,判断地址是否4字节对齐
        je      short main_loop

  上述代码主要是判断字符串地址是否4字节(32bits)对齐,如果对齐直接跳到main_loop主循环,否则先进行地址对齐。这里是通过test ecx,3进行判断,3的二进制表达为 011B,如果 ecx & 011B > 0, 说明ecx低两位不为0,也就说明ecx所保存的地址没有4字节对齐,即不能整除4。

str_misaligned:
        ; simple byte loop until string is aligned
        mov     al,byte ptr [ecx]    ; 从ecx指向的内存取一个字符存放在al中
        add     ecx,1                ; ecx指向下一个字符
        test    al,al                ; 测试所取的字符是否为0
        je      short byte_3         ; 如果为0,跳转到byte_3代码块
        test    ecx,3                ; 再次测试地址是否4字节对齐
        jne     short str_misaligned ; 不对齐则继续循环
        add     eax,dword ptr 0      ; 5 byte nop to align label below
        align   16                   ; should be redundant

  先从ecx指向的内存空间读取一个字符,存入al寄存器。然后将ecx指向下一个字符,判断al是否等于0,如果为0说明到达字符结尾,跳转到byte_3(后面介绍)运行,否则继续。然后再测试更新后的ecx是否4字节对齐,如果没有则继续循环(最多循环3次)。后面两条指令,根据其注释,看起来像填充符号。【揭秘VC CRT库Intel模块】-- strlen一文的博主解释最后这两条指令是为了16字节对齐,即使得main_loop的地址16字节对齐。
  下面开始进入main_loop的介绍,由于此段代码相比之前更长一点,我们先将其分解详细解释,对于代码前半部分。

mov     eax,dword ptr [ecx]     ; read 4 bytes,一次读四个字节,比我们前一半版每次读一个字节快!
        mov     edx,7efefeffh ; 将7efefeffh赋值给edx, 0x7efefeffh = 01111110 11111110 11111110 11111111B
        add     edx,eax ; 将读取的四个字符加到edx
        xor     eax,-1 ; 将eax取反,eax 异或 -1 = 将eax取反,因为-1的补码表示为0xffffffffh
        xor     eax,edx ; eax 异或 edx
        add     ecx,4 ; ecx加4,指向下4个字符
        test    eax,81010100h ; 测试四个hole是否存在1
        je      short main_loop ; 如果不存在1,则四个字符不存在0,没到字符串结尾,继续循环

  首先第一给指令,给edx赋值0x7efefeffh, 其二进制表示为:

01111110\ 11111110\ 11111110\ 11111111
  将这个数的二进制分为4组,对应四个字符。这个数有一个特点,对于其中每个为0的位置,如果再加上某个数后,该位置没有进位,那么该位置之后的低bits中(8个一组),一定存在为0的字符。后面量xor指令则是为了获取该位置是否有进位,这里我简单说一下我的理解。

对于二进制中0的位置是否存在进位,有以下4种情况,我们首先假定array为进位值(0 或者 1), x 为 eax寄存器中该位置的值(0 or 1,因为指令是add edx,eax),res为该位置的结果。

  1. array=0,x=0;
add指令  ->  res = array + x = 0
xor指令   ->   x = x xor -1 = !x = 1
xor指令   ->  res = res xor x = 1
最后eax保存在该位置的值为1,那么就说明该位置之前的字符存在0,所以相加后没有进位。
  1. array=1, x=0;
add指令  ->  res = array + x = 1
xor指令   ->   x = x xor -1 = !x = 1
xor指令   ->  res = res xor x = 0
最后eax保存在该位置的值为0,那么就说明该位置之前的字符不存在0。
  1. array=0, x=1;
add指令  ->  res = array + x = 1
xor指令   ->   x = x xor -1 = !x = 0
xor指令   ->  res = res xor x = 1
最后eax保存在该位置的值为1,那么就说明该位置之前的字符存在0,所以相加后没有进位。
  1. array=1, x=1;
add指令  ->  res = array + x = 0 (只保留1bit)
xor指令   ->   x = x xor -1 = !x = 0
xor指令   ->  res = res xor x = 0
最后eax保存在该位置的值为0,那么就说明该位置之前的字符不存在0。

  从上面的4种情况分析,我们可以发现一旦在 0 的位置不存在进位,经过上述add和两个xor操作后,eax在该位置保存的值就为1,最后用test eax,81010100h,来取这些0位置的值。其中0x81010100h的二进制为:

10000001\ 00000001\ 00000001\ 00000000
最后通过je指令来判断,是否0位置是否存在没有进位的点,如果不存在,说明没到字符串末尾,继续循环,否则进入后面的字符末尾判断代码段。

  发现取得的四个字符中存在编码为0的字符时,进入下面的逻辑段判断具体哪个字符编码为0,即为字符串的结尾。这段代码主要判断哪个字符编码为0,以确定字符串的结尾,需要格外注意的是大小端存储的问题,列如字符四个字符存储在内存中时为 [0x65h 0x66h 0x67h 0x68h]。但是,当我们以dword的形式读入到寄存器比如eax中时,由于是小端存储,那么从左到右它将被转换为 [0x68h 0x67h 0x66h 0x65h],因此 al 保存的应该是0x65h,所以其内存地址应该是ecx-4,即跳转到byte_0运行。下面的代码我注释了前几行,后面几行十分类似,这里就不赘述了。

; found zero byte in the loop
        mov     eax,[ecx - 4]  ;由于前面ecx加了4,这里减去4再取回之前的4个字符(注意大小端存放)
        test    al,al          ; 由于是小端存放,则al处存放的字符应该是[ecx-4]处的一个字节
        je      short byte_0 ; 当al为字符结尾时,跳转到byte_0
        test    ah,ah                   ; is it byte 1
        je      short byte_1
        test    eax,00ff0000h           ; is it byte 2
        je      short byte_2
        test    eax,0ff000000h          ; is it byte 3
        je      short byte_3
        jmp     short main_loop         ; taken if bits 24-30 are clear and bit 31 is set
; 这个jmp指令有点不理解,如果按之前的分析,是一定存在编码为0的字符的,所以不可能运行到这条指令
; 这里猜测其可能是为了避免四个字符中高位字符编码 = 128这种情况,
; 这种情况下, 31bit处的0也没有进位,但是其不存在编码为0的字符。

  现在,代码运行到最后的收尾阶段了,即用编码为0的地址减去字符串首地址计算字符串长度,代码相对比较简单,就不多加描述了。

; 这里ecx减一个数再取值比如 lea eax,[ecx-1] 的原因是因为在循环中ecx已经指向下一个四字节
byte_3:
        lea     eax,[ecx - 1] ; = eax, [ecx- 4 + 3], 将字符串结尾地址保存在eax
        mov     ecx,string ; 将字符串首地址保存在ecx
        sub     eax,ecx  ; eax = eax-ecx 即为字符串长度,返回结果保存在eax中
        ret
byte_2:
        lea     eax,[ecx - 2] ; = eax, [ecx- 4 + 2]
        mov     ecx,string
        sub     eax,ecx
        ret
byte_1:
        lea     eax,[ecx - 3] ; = eax, [ecx- 4 + 1]
        mov     ecx,string
        sub     eax,ecx
        ret
byte_0:
        lea     eax,[ecx - 4] ; = eax, [ecx- 4 + 0]
        mov     ecx,string
        sub     eax,ecx
        ret
3.测试c语言版本和汇编版本的性能差

  为了测试一下c语言版本和CRT库中的运行性能,我先构造了一个 长度为187的字符串,分别用上述c语言版本和CRT库中的汇编版本进行长度统计,循环1000000次,计时后结果如下:

图1. 计时结果
我们可以看到两者有十几倍的性能差,这有点太夸张。后面想到因为我们自己实现的c语言版本因为调用函数进栈和出栈也十分耗费时间,而根据【揭秘VC CRT库Intel模块】-- strlen博主介绍:

crt的strlen函数属于intrinsic函数,所谓intrinsic函数,可以称作为内部函数,这与inline函数有点类似,但是不是inline之意。

  因此,可以猜想CRT的strlen函数没有太多的调用函数进栈和出栈时间耗费,因此我们再一次实现了一个内联汇编版本的strlen,源代码如下所示:

#include <stdio.h>
#include <string.h>
#include "mytime.h"
using namespace std;

int strlen2(const char* str) {
    const char *eos = str;
    while (*eos++);          //不为0就加1,查看下个字符是否为0
    return(eos - str - 1);    //因为*eos = 0后,++操作使其移到下个字符,所以这里需要减1.
}

int main() {
    const char* test = "hello world, errorhkajhsdlkablsalbxgdulgauwlddgaklusgddajkkaljbjakb\
skjbalksdaksddagkavkj.ajbbhquigfpeuyweyfwcwvcbvj.cb;uh)((**&^^%$%$$^%#%UR&*&*\
GYGYKFFJTDRHCFTIFYVJVKIDUDW#WSxiyufyfvkuyu!";
    TimerClock *tc = new TimerClock();
    int COUNT = 1000000;
    int b = 0;
    tc->update();
    for (int i = 0; i < COUNT; ++i) {
        b= strlen(test);
    }
    cout << "crt库:strlen  -> cost time:" << tc->getTimerMicroSec() << "us" << endl;
    cout << "length = " << b << endl;
    b = 0;
    tc->update();
    for (int i = 0; i < COUNT; ++i) {
        //b = strlen2(test);
        __asm {
            push eax
            push ecx
            xor ecx,ecx
            mov eax, dword ptr[test]
        _main_loop:
            mov cl,byte ptr [eax]
            add eax,1
            test ecx,ecx
            je _return
            jmp _main_loop
        _return:
            mov ecx, dword ptr[test]
            sub eax,ecx
            sub eax,1
            mov b,eax
            pop ecx
            pop eax
        }
    }
    cout << "c语言:strlen  -> cost time:" << tc->getTimerMicroSec() << "us" << endl;
    cout << "length = " << b << endl;
    getchar();
    return 1;
}

  其运行时间结果如下,可以看到还是CRT库版本的strlen函数运行时间更短,而且接近4倍,正好和其一次性取值的的byte数接近(CRT版本一次取4个字节,我们实现的版本一次取1个字节),这也正好论证了我们的猜想。
图2. 计时结果2
4.总结

  从上面的介绍了我们已经大致了解了CRT库中的strlen算法的具体实现,同时也实现了我们的版本并与其进行对比,论证了其高效的性能。当然,我们这里只是窥得了CRT库的冰山一角,相信其中还有更多优秀高效的算法值得我们去学习。利用《老码识途》所推崇的 “猜测 -> 实证 -> 构建” 思维实践方法,拨开底层的神秘面纱!

参考
  1. 韩宏,李林,《老“码”识途:从机器码到框架的系统观逆向修炼之路》
  2. masefee,【揭秘VC CRT库Intel模块】-- strlen
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。