strcpy, strncpy

char *strcpy(char *dest, const char *src);

  • strcpy 在拷贝字符串时会把结尾的 '\0' 也拷到 dest 中,以保证 dest 字符串以 '\0' 结尾。
    • strcpy 只知道 src 字符串的首地址,不知道长度,它会一直拷贝到 '\0' 为止,所以 dest 所指向的内存空间要足够大,否则有可能写越界;如果没有保证 src 所指向的内存空间以 '\0' 结尾,也有可能读越界。
    • strcpy 函数的实现者通过函数接口无法得知 src 字符串的长度和 dest 内存空间的大小,所以“确保不会写越界”应该是调用者的责任,调用者提供的 dest 参数应该指向足够大的内存空间,“确保不会读越界”也是调用者的责任,调用者提供的 src 参数指向的内存应该确保以 '\0' 结尾。
  • srcdest 所指向的内存空间不能有重叠。
char buf[10] = "hello";
// 不允许
strcpy(buf, buf+1);
  • dest 指针本身就是调用者传过去,再返回一遍 dest 指针并没有提供任何有用信息。
  • 这么规定是为了把函数调用当作一个指针类型的表达式使用,比如 printf("%s\n", strcpy(buf, "hello"));如果 strcpy 的返回值是 void 就没有这么方便。
  • 凡是有指针参数的 C 标准库函数基本都要求每个指针参数所指向的内存空间互不重叠。

char *strncpy(char *dest, const char *src, size_t n);

  • strcpystrncpy 更加不安全,调用前不仔细检查 src 字符串的长度就有可能写越界。
void foo(char *str)
{
	char buf[10];
	strcpy(buf, str);
	// ...
}
  • str 所指向的字符串有可能超过 10 个字符而导致写越界。
  • 这种写越界可能当时不出错,而在函数返回时出现段错误,原因是写越界覆盖了保存在栈帧上的返回地址(返回到该地址继续执行代码),函数返回时跳转到非法地址,因而出错。

buf 这种由调用者分配并传给函数读或写的一段内存通常称为缓冲区(Buffer),缓冲区写越界的错误称为缓冲区溢出(Buffer Overflow)。

  • 只是出现段错误还不算严重,更严重的是缓冲区溢出 Bug 经常被恶意用户利用,使函数返回时跳转到一个事先设好的地址,执行事先设好的指令。
    • 如果设计得巧妙甚至可以启动一个 Shell,然后随心所欲执行任何命令;如果一个用 root 权限执行的程序存在这样的 Bug,被攻陷了,后果很严重。
    • Smashing The Stack For Fun And Profit

malloc, free

动态分配一块内存可以定义一个缓冲区数组,但这种方法不够灵活:C89 要求定义的数组是固定长度的,而程序往往在运行时才知道要动态分配多大的内存。

C99 引入 VLA 特性,可以定义 char buf[n+1] = {};,这样可确保 buf'\0' 结尾。

  • VLA 仍然不够灵活,VLA 在栈上动态分配,函数返回时就要释放,如果希望动态分配一块全局的内存空间,而全局数组无法定义成 VLA,所以仍然不能满足要求。

进程有一个堆空间,C 标准库函数 malloc 可以在堆空间动态分配内存。

  • malloc 底层通过 brk 系统调用向操作系统申请内存。
  • 动态分配的内存用完之后可以用 free 释放,归还malloc,下次调用 malloc 时这块内存可以再次被分配。
    • free 的参数是先前 malloc 返回的内存块首地址。
#include <stdlib.h>
void *malloc(size_t size);
// 返回值:成功返回所分配内存空间的首地址,出错返回 NULL
void free(void *ptr);
  • malloc 的参数 size 表示要分配的字节数,如果分配失败(比如系统内存耗尽)则返回 NULL。
  • malloc 不知道用户要存放的数据类型,所以返回通用指针 void *,用户程序可以转换成其它类型的指针再访问这块内存。
  • malloc 函数保证它返回的指针所指向的地址满足系统的对齐要求
    • 例如在 32 位平台上返回的指针一定对齐到 4 字节边界,以保证用户程序把它转换成任何类型的指针都能用

使用示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
	int number;
	char *msg;
} unit_t;

int main(void)
{
	unit_t *p = malloc(sizeof(unit_t));
	if (p == NULL) {
		printf("out of memory\n");
		exit(1);
	}

	p->number = 3;
	p->msg = malloc(20);
	strcpy(p->msg, "Hello world!");
	printf("number: %d\nmsg: %s\n", p->number, p->msg);

	free(p->msg);
	free(p);
	p = NULL;

	return 0;
}
  • unit_t *p = malloc(sizeof(unit_t)); 等号右边是 void * 类型,等号左边是 unit_t * 类型,编译器会做隐式类型转换。
    • void * 类型和任何指针类型之间都可以相互隐式转换。
  • malloc 之后应该判断是否申请成功。
    • 大部分系统函数都有成功的返回值和失败的返回值,每次调用系统函数都应该判断是否成功。
  • 要先 free(p->msg)free(p)。如果先 free(p)p 就成了野指针,不能再通过 p->msg 访问到。
  • free(p); 之后,归还了 p 所指向的内存空间,但 p 的值并没有变,free 函数不能改变 p 的值,p 现在指向的内存空间已经不属于用户,也就是说 p 成了野指针;所以为避免出现野指针,应该在 free(p); 之后手动置 p = NULL;

简单的程序即使不用 free 释放内存也可以,因为程序退出时整个进程地址空间都会释放,包括堆空间,该进程占用的所有内存都会归还给操作系统。但如果一个程序长期运行(如网络服务器程序),并且在循环或递归中调用 malloc 分配内存,则必须有 free 与之配对,分配一次就要释放一次,否则就会慢慢耗尽系统内存,称为内存泄漏(Memory Leak)。

  • 大量的内存泄漏会使系统内存紧缺,导致频繁换页,不仅影响当前进程,而且会把整个系统都拖得很慢。
  • malloc 返回的指针一定要保存好,只有把它传给 free 才能释放这块内存,如果这个指针丢失了,就没有办法 free 这块内存,造成内存泄漏。

mallocfree 基于环形链表的简单实现:

)

  • 白色背景的框表示 malloc 管理的空闲内存块。
  • 深色背景的框不归 malloc 管,可能是已经分配给用户的内存块,也可能不属于当前进程。
  • Break 之上的地址不属于当前进程,需要通过 brk 系统调用向内核申请。
  • 每个内存块开头都有一个头节点,里面有一个指针字段,指向下一个头结点和一个长度字段(自己管理的内存的大小)。
    • 指针字段把所有不连续的空闲块的头节点串在一起,组成一个环形链表。
    • 长度字段记录着头节点和后面的内存块加起来一共有多长
      • 内存块大小是$$长度字段 * 8 字节$$;8 字节是选取头节点的长度作为单位。
  • 操作分析:
    1. 一开始堆空间由一个空闲块组成,长度为 $$7×8=56$$ 字节;
      • 头节点本身占 8 字节。
    2. 调用 malloc 分配 8 字节后,在空闲块的末尾截出 16 个字节的内存块;
      • 新的头节点占 8 个字节;另外 8 个字节返回给用户使用,返回的指针 p1 指向头节点后面的内存块
    3. 再次调用 malloc 分配 16 字节后,又在空闲块的末尾截出 24 个字节的内存块,步骤和上一步类似;
    4. 调用 free 释放 p1 所指向的内存块,内存块(包括头节点在内)归还给了 malloc,现在 malloc 管理着两块不连续的内存,用环形链表串起来
      • 此时 p1 成了野指针,指向不属于用户的内存,p1 所指向的内存地址在 Break 之下,属于当前进程,所以访问 p1 时不会出现段错误,但在访问 p1 时这段内存可能已经被 malloc 再次分配出去了,可能会读到意外改写数据。
      • 此时如果通过 p2 向右写越界,有可能覆盖右边的头节点,从而破坏 malloc 管理的环形链表,malloc 就无法从一个空闲块的指针字段找到下一个空闲块了。
    5. 调用 malloc 分配 16 个字节,虽然有两个空闲块,各有 8 个字节可分配,但是这两块不连续,malloc 只好通过 brk 系统调用抬高 Break,获得新的内存空间;
    6. 新申请的空闲块和前一个空闲块连续,因此可以合并成一个;在能合并时要尽量合并,以免空闲块越割越小,无法满足大的分配请求;
    7. 在合并后的这个空闲块末尾截出 24 个字节,新的头节点占 8 个字节,另外 16 个字节返回给用户;
    8. 调用 free(p3) 释放这个内存块,由于它和前一个空闲块连续,又重新合并成一个空闲块;
      • Break 只能抬高而不能降低,从内核申请到的内存以后都归 malloc 管,即使调用 free 也不会还给内核。

References