C 标准库中对内存拷贝的操作位于头文件 <string.h>,其中涉及到内存拷贝的函数有:

  1. memcpy:内存拷贝,存在内存重叠的风险,源地址空间与目的地址空间的数据安全皆无法保障
  2. memmove:内存拷贝,可以保证目的地址空间数据的安全(不越界的情况下),但仍可能破坏源地址空间的数据
  3. mempcpy:内存拷贝,返回目的地址空间拷贝末尾后一个地址
  4. memccpy:内存拷贝,遇到指定字节时停止,返回该字节后一个地址;若未遇到指定字节,则全部拷贝并返回 NULL
  5. strcpy:字符串拷贝,返回目的地址空间的首地址,与 memcpy 一样存在内存重叠风险
  6. stpcpy:字符串拷贝,返回目的地址空间字符串末尾 '\0' 后的一个地址
  7. strncpy:字符串拷贝,拷贝不超过 n 个字节的字符串,返回首地址
  8. stpncpy:字符串拷贝,拷贝不超过 n 个字节的字符串,返回目的地址空间字符串末尾 '\0' 后的一个地址(或目的地址空间的尾地址)

1. memcpy

内存拷贝,从 srcpp 指向的地址开始,拷贝 len 长度的数据到 dstpp 起始的地址,源码如下。

void *memcpy (void *dstpp, const void *srcpp, size_t len)
{
  unsigned long int dstp = (long int) dstpp;
  unsigned long int srcp = (long int) srcpp;

  if (len >= OP_T_THRES)
    {
      len -= (-dstp) % OPSIZ;
      BYTE_COPY_FWD (dstp, srcp, (-dstp) % OPSIZ);
      PAGE_COPY_FWD_MAYBE (dstp, srcp, len, len);
      WORD_COPY_FWD (dstp, srcp, len, len);
    }

  BYTE_COPY_FWD (dstp, srcp, len);
  return dstpp;
}

其中有宏声明为:

#define OP_T_THRES      8
#define OP_T_THRES      16
#define OPSIZ   (sizeof(op_t))
#define op_t    unsigned long long int
#define op_t    unsigned long int

OP_T_THRES 是用于判断拷贝方式的阈值,通常是 8 字节或 16 字节,与架构相关。如果待拷贝的数据不小于 OP_T_THRES,则要经过更复杂的操作。op_t 表示单次操作指令加载和存储支持的最大类型,对应的长度 OPSIZ 用于内存对齐,通常为 8 字节。len -= (-dstp) % OPSIZ; 是针对目的地址的内存对齐计算。在 memcpy 函数中涉及到三个宏定义,依次拆解。

1.1 BYTE_COPY_FWD 与 BYTE_COPY_BWD

BYTE_COPY_FWD 是一个架构相关的宏定义,有几个不同架构下的实现,其中 generic 版本的代码如下,byte 的定义为 unsigned char

#define BYTE_COPY_FWD(dst_bp, src_bp, nbytes)        \
  do                                                 \
    {                                                \
      size_t __nbytes = (nbytes);                    \
      while (__nbytes > 0)                           \
      {                                              \
        byte __x = ((byte *) src_bp)[0];             \
        src_bp += 1;                                 \
        __nbytes -= 1;                               \
        ((byte *) dst_bp)[0] = __x;                  \
        dst_bp += 1;                                 \
      }                                              \
    } while (0)

从源码中可以发现,这是一个简单的以字节为单位,从源地址的起始地址开始,拷贝到目的地址开始的地址空间。另一个与之相似的宏为 BYTE_COPY_BWD,区别在于前者是 foreward 方向的拷贝,即拷贝的源地址和目的地址都是起始地址;而后者是 backward 的拷贝,源地址和目的地址指向的是一段地址空间末尾的地址。BYTE_COPY_BWD 的源码如下。

#define BYTE_COPY_BWD(dst_ep, src_ep, nbytes)       \
  do                                                \
    {                                               \
      size_t __nbytes = (nbytes);                   \
      while (__nbytes > 0)                          \
    {                                               \
      byte __x;                                     \
      src_ep -= 1;                                  \
      __x = ((byte *) src_ep)[0];                   \
      dst_ep -= 1;                                  \
      __nbytes -= 1;                                \
      ((byte *) dst_ep)[0] = __x;                   \
    }                                               \
    } while (0)

1.2 WORD_COPY_FWD

WORD_COPY_FWD 有数个架构相关的实现,通过内联汇编的方式优化。内联汇编的语法规则如下通过 asm 关键字启动内联汇编,后面接一行汇编指令,或一对大括号作用域下的汇编代码片段。

  • “asm-qualifiers” 有三个选项,分别为:

    • volatile:关闭优化,取值自内存地址而非寄存器
    • inline:内联限定符
    • goto:此限定符将通知编译器 asm 中的语句可以跳转到 GotoLabels 中列出的某个标签
  • “AssemblerTemplate”:由文本字符串构成,即具体的汇编指令代码
  • “OutputOperands”:由 AssemblerTemplate 中的指令修改的 C 语言变量的逗号分隔列表,允许使用空列表
  • “InputOperands” 是由 AssemblerTemplate 中的指令读取的以逗号分隔的 C 语言表达式列表,允许使用空列表
  • “Clobbers”:由 AssemblerTemplate 更改的寄存器或其他值的逗号分隔列表,超出列为输出的值,允许使用空列表
  • “GotoLabels”:当使用 asm goto 形式时,此部分包含 AssemblerTemplate 中的代码可能跳转到的所有 C 语言标签的列表
asm [ asm-qualifiers ] ( 
    AssemblerTemplate
    : OutputOperands
    [ : InputOperands
    [ : Clobbers ] ])

asm asm-qualifiers ( 
    AssemblerTemplate
    :
    : InputOperands
    : Clobbers
    : GotoLabels)

下面的代码段是 x86 上的 WORD_COPY_FWD 实现。dst_bpsrc_bp 分别是目的地址和源地址,nbytes 是数据拷贝的字节数。nbytes_left 则表示剩余未拷贝的字节数,该变量将作用于宏外部的操作。

#define WORD_COPY_FWD(dst_bp, src_bp, nbytes_left, nbytes)      \
  do                                                            \
    {                                                           \
      int __d0;                                                 \
      asm volatile(                                             \
           "cld\n"                                              \
           "rep\n"                                              \
           "movsl" :                                            \
            "=D" (dst_bp), "=S" (src_bp), "=c" (__d0) :         \
           "0" (dst_bp), "1" (src_bp), "2" ((nbytes) / 4) :     \
           "memory");                                           \
      (nbytes_left) = (nbytes) % 4;                             \
    } while (0)
  • cld:复位方向标记位 DF=0,此时寄存器 ESI/EDI 递增,在后向拷贝 WORD_COPY_BWD 中,std 指令设置方向标记位 DF=1,ESI/EDI 递减
  • rep:当寄存器 ECX>0 时重复执行后面的指令
  • movsl:该指令一次拷贝 4 个字节,当不足 4 字节时,nbytes_left 不为 0,剩余的数据在宏外部以字节为单位拷贝
  • =D:设置 EDI 目标索引寄存器为 dst_bp
  • =S:设置 ESI 源索引寄存器为 src_bp
  • =c:设置 ECX 计数器为 __d0,对应到 (nbytes)/4,即 rep 的重复次数
  • memory:汇编代码对输入和输出操作数中列出的项以外的项执行内存读写

综上,首先设置 ESI 和 EDI 寄存器为 源地址和目的地址,并计算出拷贝次数,重复以 4 字节为单位执行拷贝操作,每轮结束后递增寄存器的值,并递减计数器。最后剩余数据不足 4 字节时,在宏外部以字节为单位执行拷贝。

1.3 PAGE_COPY_FWD_MAYBE

虚拟页拷贝,根据 PAGE_OFFSET 的定义,当源地址与目的地址在页上能够对齐时(即页内的偏移量相同),将使用三步操作进行拷贝。

memcpy页拷贝.png

  • 通过 WORD_COPY_FWD 的方式以 4 字节为单位执行拷贝
  • 完成页面 1 上的数据拷贝后,通过 PAGE_COPY_FWD 的方式拷贝整个页面
  • 剩余一个不完整的页通过 WORD_COPY_FWD 的方式以 4 字节为单位执行拷贝
  • 最后剩余不足 4 字节的数据按照字节为单位拷贝
#define PAGE_COPY_THRESHOLD 0
#define PAGE_OFFSET(n)    ((n) & (PAGE_SIZE - 1))

#define PAGE_COPY_FWD_MAYBE(dstp, srcp, nbytes_left, nbytes)            \
  do                                                                    \
    {                                                                   \
      if ((nbytes) >= PAGE_COPY_THRESHOLD &&                            \
        PAGE_OFFSET ((dstp) - (srcp)) == 0)                             \
        {                                                               \
          size_t nbytes_before = PAGE_OFFSET (-(dstp));                 \
          if (nbytes_before != 0)                                       \
            {                                                           \
              WORD_COPY_FWD (dstp, srcp, nbytes_left, nbytes_before);   \
              assert (nbytes_left == 0);                                \
              nbytes -= nbytes_before;                                  \
            }                                                           \
          PAGE_COPY_FWD (dstp, srcp, nbytes_left, nbytes);              \
        }                                                               \
    } while (0)

PAGE_COPY_FWD 的源码如下,这里涉及的 __vm_copy 并不是 glibc 的函数,而是 mach kernel 中的原语。

#define PAGE_COPY_FWD(dstp, srcp, nbytes_left, nbytes)      \
  ((nbytes_left) = ((nbytes) -                              \
            (__vm_copy (__mach_task_self (),                \
                (vm_address_t) srcp, trunc_page (nbytes),   \
                (vm_address_t) dstp) == KERN_SUCCESS        \
             ? trunc_page (nbytes)                          \
             : 0)))

1.4 memcpy 一图流总结

memcpy算法流程图.png

memcpy 函数存在安全问题,① 访问越界;② 内存重叠。内存重叠是指目的地址对应的内存空间与源地址的内存空间有重叠,这将导致拷贝结果出错。另外以上与架构相关的分析(例如 WORD_COPY 以 4 字节为单位的拷贝)都是基于 x86 的,在其他平台上可能有不同的取值。


2. memmove

源地址空间与目的地址空间的分布存在四种可能性,如下图所示。①②两种情况不存在重叠问题,对于第③种源地址空间在目的地址空间之后,memcpy 执行完后,会导致源地址空间的数据被覆盖;对于第④中情况,memcpy 在拷贝一开始,就将源地址空间尾部重叠部分的数据覆盖,这将使源地址与目的地址指向的数据都出错。

memmove地址空间重叠示意图.png

memmove 可以部分地解决 memcpy 中存在的内存重叠问题(只针对目的地址空间而言),如果严格要求内存拷贝,而不是内存区域数据的转移,则需要人为地保证源地址与目的地址的空间不重叠。memmove 函数的定义为:

rettype inhibit_loop_to_libcall
MEMMOVE (a1const void *dest, a2const void *src, size_t len)
{
    unsigned long int dstp = (long int) dest;
    unsigned long int srcp = (long int) src;

    if (dstp - srcp >= len)
    {
#if MEMCPY_OK_FOR_FWD_MEMMOVE
        dest = memcpy (dest, src, len);
#else
        if (len >= OP_T_THRES)
        {
            len -= (-dstp) % OPSIZ;
            BYTE_COPY_FWD (dstp, srcp, (-dstp) % OPSIZ);
            PAGE_COPY_FWD_MAYBE (dstp, srcp, len, len);
            WORD_COPY_FWD (dstp, srcp, len, len);
        }
        BYTE_COPY_FWD (dstp, srcp, len);
#endif
    }
    else
    {
        srcp += len;
        dstp += len;
        if (len >= OP_T_THRES)
        {
            len -= dstp % OPSIZ;
            BYTE_COPY_BWD (dstp, srcp, dstp % OPSIZ);
            WORD_COPY_BWD (dstp, srcp, len, len);
        }
        BYTE_COPY_BWD (dstp, srcp, len);
    }

    RETURN (dest);
}

memmove 根据源地址与目的地址两种类型的空间关系,采取不同的拷贝方式,以保证数据的安全。源码中通过无符号数 dstp - srcp >= len 的判断,实现了区间的划分:

  • ① 目的地址在源地址左侧,或目的地址在源地址右侧但没有与源地址空间发生重叠
  • ② 目的地址在源地址的右侧,且两者之差小于源地址的空间偏移量,这种情况下目的地址空间左侧重叠了源地址空间的右侧

对于第一种情况,memmove 采取 memcpy 的前向拷贝方式进行拷贝;对于第二种情况,采取从后向前的拷贝,但是与 memcpy 略微不同的处理方式在于:后向拷贝没有涉及虚拟页表方式的拷贝,从而避免覆盖的风险。


3. mempcpy

memcpymemmove 在执行完成后,都会返回目的地址空间的起始地址,然而在某些情况下,我们希望执行完拷贝操作之后,返回的指针指向拷贝结束位置向后的一个地址,以便于在接下来从结束位置再继续向后写数据,这时就会用到 mempcpy,其定义为:

#define mempcpy(D, S, N) ((void *) ((char *) memcpy (D, S, N) + (N)))

可以看到该函数仍然是通过 memcpy 来执行拷贝的,仅仅只是在返回的目的地址基础上简单地加上了拷贝内容的长度。


4. memccpy

memccpy 将不超过 n 个字节的源地址空间数据拷贝到目的地址空间,遇到字节 c 时停止。返回目的地址空间中字节 c 对应地址的后一个地址,如果未找到字节 c,则返回 NULL。源码定义如下:

void *memccpy (void *dest, const void *src, int c, size_t n)
{
  void *p = memchr (src, c, n);

  if (p != NULL)
    return __mempcpy (dest, src, p - src + 1);

  memcpy (dest, src, n);
  return NULL;
}

该函数非常好理解,首先在 n 长度的源地址空间中查找字节 c,memchr 返回查找到的 c 的地址。

  • 如果 c 找到,则从源地址开始,通过 mempcpy 一直拷贝到字节 c,并返回字节 c 后面的一个地址
  • 如果 c 未找到,则通过 memcpy 完成全部拷贝,并返回 NULL

5. strcpy

字符串拷贝要通过两个步骤实现:① 计算字符串长度;② 通过 memcpy 执行拷贝。源码如下:

char *STRCPY (char *dest, const char *src)
{
  return memcpy (dest, src, strlen (src) + 1);
}

需要注意的是 strlen 返回的长度不包括字符串末尾的 '\0',所以调用 memcpy 时拷贝的长度需要 +1。


6. stpcpy

拷贝过程与 strcpy 相似,不同点在于 stpcpy 执行完字符串拷贝后,返回的地址指向字符串末尾的 '\0'

char *STPCPY (char *dest, const char *src)
{
  size_t len = strlen (src);
  return memcpy (dest, src, len + 1) + len;
}

7. strncpy

如果我们在执行拷贝时,不知道源字符串有多大,而目的地址空间长度有限,为了防止越界,必须要设置最大拷贝长度,stpncpy 可以很好的完成这一任务。

char *STRNCPY (char *s1, const char *s2, size_t n)
{
  size_t size = __strnlen (s2, n);
  if (size != n)
    memset (s1 + size, '\0', n - size);
  return memcpy (s1, s2, size);
}
  • 函数 strnlenstrlen 稍许不同,它的返回值是字符串 s 的实际长度与 n 的最小值
  • 第二步,只要源字符串的长度小于 n,就首先将目的地址空间剩余的部分填充为 '\0'
  • 最后执行拷贝,返回目的地址空间的首地址

8. stpncpy

strncpy 类似,stpncpy 执行不超过 n 的拷贝后,如果字符串在目的地址空间中未越界,则返回字符串末尾 '\0' 之后的一个地址;如果越界,则返回目的地址空间最末尾的地址。

char *STPNCPY (char *dest, const char *src, size_t n)
{
  size_t size = __strnlen (src, n);
  memcpy (dest, src, size);
  dest += size;
  if (size == n)
    return dest;
  return memset (dest, '\0', n - size);
}

(完)