请稍侯

内存拷贝的注意事项

05 September 2014

  有道面试题是让写出memcpy的实现,memcpy是c和c++使用的内存拷贝函数,功能是从源地址所指的内存地址的起始位置开始拷贝n个字节到目标地址所指的内存地址的起始位置中。与此类似的,在使用strcpy的时候,也应该需要注意这些问题。我们粗略的考虑下有哪些是值得注意的。

  1 需要对参数进行检查,当传入源地址和目的地址参数有NULL时需要特殊处理;    
  2 源地址在函数中是不应该被修改的,可以加上const进行修饰,防止代码中错误的将源地址进行修改,这样在编译的时候可以检查出来;    
  3 传入的源地址和目的地址的类型应该是任意的指针类型,而不是针对特定的指针类型,可以使用void*类型;    
  4 内存重叠的问题,一般进行拷贝时都是从源地址的第一个字节开始向后拷贝n个到目的地址,考虑这样一种情况:`memcpy(p+1,p,N)`,这种情况下,目的地址会覆盖源地址的内容,此时有两种方法,一种是用O(n)的空间来换取准确性,还有一种就是从后向前拷贝,这样就算覆盖也是覆盖使用过的数据了。

  考虑差不多就可以开始写了:

void *mymemcpy(void *v_dst, const void *v_src, int c)
{
        assert(v_dst);
        assert(v_src);
        const char *src = v_src;
        char *dst = v_dst;
        if (v_dst <= v_src)
        {
            while (c--)
                *dst++ = *src++;
        }
        else
        {
            src = src + c - 1;
            dst = dst + c - 1;
            while (c--)
                *dst-- = *src--;
        }
        return v_dst;
}

  测试用例包括基本功能的测试,参数为空,内存覆盖(目的地址大于或者小于源地址都要考虑)。还有一点需要注意的是当调用这个函数之后,我们只能保证目的地址的数据的准确性,对于源地址,在有内存重叠的情况下时,很有可能已经被改变了。比如:

int main()
{
    char p1[] = "0123456";
    char p2[] = "123";
    mymemcpy(p2,p1,strlen(p1)+1);
    printf("%s\n%s\n",p1,p2);
    return 0; 
}

  执行完mymemcpy(库函数memcpy也是一样)之后,打印p1p2发现p2等于"0123456",但是p1等于"456"。这是因为p1和p2都是局部变量,存储在栈中,因为两个数据是连续申请的,所以地址也是连在一起的,我们传入的第三个参数是p1的长度+1,也就是8,当复制到'4'的时候,此时p2没有足够的空间去存放p1中的元素,于是就会占用p1的空间。如下图所示,于是打印p1发现p1变成了"456"。所以在调用内存赋值的函数时,一定要保证目的地址有足够的空间去容纳你所要拷贝的数据。
1   assert是一个宏,定义在中,作用是先计算括号内的表达式,如果其值为假(即为0),那么它先向stderr打印一条出错信息,错误消息包括源文件,行号,和表达式本身,然后通过调用 abort 来终止程序运行。程序中通过比较目的地址和源地址的大小来决定采用从前向后拷贝还是从后向前拷贝。其实,memcpy函数的使用场合是不需要考虑内存重叠问题的,因为涉及到内存重叠时我们应该调用的是memmove函数,下面是Linux内核源码中memcpy和memmove的代码,可以看出,memcpy在实现的时候就没有考虑到内存重叠的问题。

void *memcpy(void *v_dst, const void *v_src, __kernel_size_t c)
{
        const char *src = v_src;
        char *dst = v_dst;

        /* Simple, byte oriented memcpy. */
        while (c--)
                *dst++ = *src++;

        return v_dst;
}

void *memmove(void *v_dst, const void *v_src, __kernel_size_t c)
{
        const char *src = v_src;
        char *dst = v_dst;

        if (!c)
                return v_dst;

        /* Use memcpy when source is higher than dest */
        if (v_dst <= v_src)
                return memcpy(v_dst, v_src, c);

        /* copy backwards, from end to beginning */
        src += c;
        dst += c;

        /* Simple, byte oriented memmove. */
        while (c--)
                *--dst = *--src;

        return v_dst;
}
 

  _kernel_size_t是一个与机器相关的unsigned类型,功能与size_t类似,只不过是用在kernel中的,根据机器的位数确定,64bits的机器上为typedef long unsigned int __kernel_size_t;,32bits机器上是typedef unsigned int __kernel_size_t;。size_t也是如此,为了增强程序的可移植性(关于这一点理解的还是不够明白),不同的系统上,定义size_t可能不一样,size_t一般用来表示一种计数,比如有多少东西被拷贝等。例如:sizeof操作符的结果类型是size_t,该类型保证能容纳实现所建立的最大对象的字节大小,经常用于循环计数,数组索引,还有地址运算等等。 还有一个原因就是sizet可以安全的容纳指针,如果考虑应用程序的兼容性和可移植性,指针的长度就是一个问题,在大部分现代平台上,数据指针的长度通常是一样的,与指针类型无关,尽管C标准没有规定所有类型指针的长度相同,但是通常实际情况就是这样。但是函数指针长度可能与数据指针的长度不同,比如类的函数指针。指针的长度取决于使用的机器和编译器,一般是32位(32bits机器)或是64位长(64bits机器),因为size_t是被定义为unsigned,所以可以使用size_t来进行存储。