汇编实现的memcpy和memset

作者: 扫帚的影子 | 来源:发表于2019-12-16 19:04 被阅读0次

    天天山珍海味的吃,也会烦。偶尔来点花生,毛豆小酌一点,也别有一番风味。

    天天java, golang, c++, 咱们今天来点汇编调剂一下,如何?

    通过这篇文章,您可以了解过:

    • CPU寄存器的一些知识;
    • 函数调用的过程;
    • 汇编的一些知识;
    • glibc 中 memcpy和memset的使用;
    • 汇编中memcpy和memset是如何实现的;

    闲话不多说,今天来看看汇编中如何实现memcpymemset(脑子里快回忆下你最后一次接触汇编是什么时候......)

    函数是如何被调用的
    栈的简单介绍
    • 栈对函数调用来说特别重要,它其实就是进程虚拟地址空间中的一部分,当然每个线程可以设置单独的调用栈(可以用户指定,也可以系统自动分配); 栈由栈基址(%ebp)和栈顶指针(%esp)组成,这两个元素组成一个栈帧,栈一般由高地址向低地址增长,将数据压栈时%esp减小,反之增大;
    • 调用一个新函数时,会产生一个新的栈帧,即将老的%ebp压栈,然后将%ebp设置成跟当前的%esp一样的值即可。函数返回后,之前压栈的数据依然出栈,这样最终之前进栈的%ebp也会出栈,即调用函数之前的栈帧被恢复了,也正是这种机制支撑了函数的多层嵌套调用;

    不管是写Windows程序还是Linux程序,也不管是用什么语言来写程序,我们经常会把某个独立的功能抽出来封装成一个函数,然后在需要的地方调用即可。看似简单的用法,那它背后是如何实现的呢?一般分为四步:

    • 传递参数,通常我们使用来传递参数,先将所有参数都压栈处理;
    • 保存所调用的函数的下面一条指令的地址,就是我们执行完要调用的函数,拿到结果后程序接着从哪里继续运行的位置,通常我们也将其压入栈里保存;
    • 跳转到被调用的函数,进行前面所说的栈帧的切换,然后执行函数主体;
    • 函数返回,清理当前栈,之前压栈的元素都退栈,最终恢复到老的栈帧,返回在第二步保存的指令地址,继续程序的运行。
    函数调用规则

    函数一般都会有多个参数,我们根据函数调用时,

    • 参数压栈的方向(参数从左到右入栈,还是从右到左入栈);
    • 函数调用完是函数调用者负责将之前入栈的参数退栈,还是被调用函数本身来作等

    这两点(其实还有一点,就是代码被编译后,生成新函数名的规则,跟我们这里介绍的关系不大)来分类函数的调用方式:

    • stdcall: 函数参数由右向左入栈, 函数调用结束后由被调用函数清除栈内数据;
    • cdecl: 函数参数由右向左入栈, 函数调用结束后由函数调用者清除栈内数据;
    • fastcall: 从左开始不大于4字节的参数放入CPU的EAX,ECX,EDX寄存器,其余参数从右向左入栈, 函数调用结束后由被调用函数清除栈内数据; 这种方式最大的不同是用寄存器来存参数,所有它fast。
    glibc中的memcpy

    我们先来看下glibc中的memcpy , 原型如下:

    void *memcpy(void *dest, const void *src, size_t n);
    

    从src拷贝连续的n个字节数据到dest中, 不会有任何的内存越界检查。

    char dest[5] = {0};                                                                                                    
    char test[5] = {0,'b'};                                                                                                
    char src[10] = {'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a'};           
    
     ::memcpy(dest, src, 6);     
                                                       
    std::cout << src << std::endl;  
    std::cout << dest << std::endl;                                                                                        
    std::cout << test << std::endl;
    

    大家有兴趣的话可以考虑下上面的代码输出是什么?

    汇编实现的memcpy

    说来惭愧,汇编代码作者本人也不会写。不过我们可以参考linux源码里面的实现,这相对还是比较权威的吧。

    它的实现位于 arch/x86/boot/copy.S, 文件开头有这么一行注释Copyright (C) 1991, 1992 Linus Torvalds, 看起来应该是大神亲手写下的。我们来看一看

    GLOBAL(memcpy)
        pushw   %si
        pushw   %di
        movw    %ax, %di
        movw    %dx, %si
        pushw   %cx
        shrw    $2, %cx
        rep; movsl
        popw    %cx
        andw    $3, %cx
        rep; movsb
        popw    %di
        popw    %si
        retl
    ENDPROC(memcpy)
    
    • CPU的众多通用寄存器有%esi和%edi, 它们一个是源址寄存器,一个是目的寄存器,常被用来作串操作,我们的这个memcpy最终就是将%esi指向的内容拷贝到%edi中,因为这种代码在linux源码中是被标识成了.code16, 所有这里都只用到这两个寄存器的低16位:%si和%di;

    • 代码的第一,二句保存当前的%si和%di到栈中;

    • 这段代码实际上是fastcall调用方式,void *memcpy(void *dest, const void *src, size_t n);

      其中 dest 被放在了%ax寄存器,src被放在了%dx, n被放在了%cx;

    • movw %ax, %di, 将dest放入%di中,movw %dx, %s,将stc放入%si中;

    • 一个字节一个字节的拷贝太慢了,我们四个字节四个字节的来,shrw $2, %cx,看看参数n里面有几个4, 我们就需要循环拷贝几次,循环的次数存在%cx中,因为后面还要用到这个%cx, 所以计算之前先将其压栈保存 pushw %cx

    • rep; movslrep重复执行movsl这个操作,每执行一次%cx的内容就减一,直到为0。movsl每次从%si中拷贝4个字节到%di中。这其实就相当于一个for循环copy;

    • 参数n不一定能被4整除,剩下的余数,我们只能一个字节一个字节的copy了。

      andw $3, %cx 就是对%cx取余,看还剩下多少字节没copy;

    • rep; movsb一个字节一个字节的copy剩下的内容;

    glibc中的memset

    我们先来看下glibc中的memset, 原型如下:

    void *memset(void *s, int c, size_t n);
    

    这个函数的作用是用第二个参数的最低位一个字节来填充s地址开始的n个字节,尽管第二个参数是个int, 但是填充时只会用到它最低位的一个字节。

    你可以试一下下面代码的输出:

    int c = 0x44332211;                                                                                                    
    int s = 0;                                                                                                             
    ::memset((void*)&s, c, sizeof(s));                                                                                       
    std::cout << std::setbase(16) << s << std::endl; // 11111111
    
    汇编实现的memset

    我们还是来看一下arch/x86/boot/copy.S中的实现:

    GLOBAL(memset)
        pushw   %di
        movw    %ax, %di
        movzbl  %dl, %eax
        imull   $0x01010101,%eax
        pushw   %cx
        shrw    $2, %cx
        rep; stosl
        popw    %cx
        andw    $3, %cx
        rep; stosb
        popw    %di
        retl
    ENDPROC(memset)
    
    • 不同于memcpy,这里不需要%si源址寄存器,只需要目的寄存器,所以我们先将其压栈保存 pushw %di;

    • 参考void *memset(void *s, int c, size_t n)可知,参数s被放在了%ax寄存器;参数n被放在了%cx寄存器;

      参数c被放在了%dl寄存器,这里只用到了%edx寄存器的最低一个字节,所以对于c这个参数不管你是几个字节,其实多只有最低一个字节被用到;

    • memcpy一样,一次一个字节的操作太慢了,一次四个字节吧,假设参数c的最低一个字节是0x11, 那么一次set四个字节的话,就是0x11111111:

      movzbl    %dl, %eax
      imull $0x01010101,%eax
      

    imull $0x01010101,%eax这句话就是把0x11变成0x11111111

    • rep; stosl,rep重复执行stosl 这个操作,每执行一次%cx的内容就减一,直到为0。stosl每次从%eax中拷贝4个字节到%di中。这其实就相当于一个for循环copy;

    • 参数n不一定能被4整除,剩下的余数,我们只能一个字节一个字节的copy了。

      andw $3, %cx 就是对%cx取余,看还剩下多少字节没copy;

    • rep; stosl 一个字节一个字节的copy剩下的内容;

    好了,到这里这次的内容就结束了,有疏漏之处,欢迎指正。

    相关文章

      网友评论

        本文标题:汇编实现的memcpy和memset

        本文链接:https://www.haomeiwen.com/subject/dywdnctx.html