Unix C:内存管理

内存管理

动态内存分配

  • 可以在程序运行的时候临时决定需要分配的存储区个数,这种分配方式叫动态内存分配
  • 为了管理动态分配内存需要使用一组标准函数,这些标准函数需要包含stdlib.h头文件
  • malloc函数可以动态分配一组连续的存储区
    • 这个函数需要一个整数类型的参数表示希望分配到字节个数
    • 它的返回值表示分配好的第一个字节的地址
    • 如果内存分配失败就返回NULL
    • 返回值是无类型指针(void *),需要先强制类型转换成有类型指针,然后才能使用

malloc

  • malloc函数即:memory allocation,返回值类型为void *
  • malloc为指针赋值时需要做类型转换,例如:(int *)malloc(20);
    • 如果不做类型转换,编译器会隐式转换为接收指针的指针类型
int *p = (int *)malloc(20);
int* p1 = (int *)malloc(sizeof(int) * 5);

/*分配失败判断*/
if(p == NULL){
    printf("memory allocation failed\n");
    return -1;
}

free

  • 计算机不会主动回收动态分配的内存,当程序不再需要动态分配的内存时,就应该主动把释放内存,否则会造成内存泄漏
  • free函数用来释放动态分配内存
    • 函数需要第一个字节的地址作为参数
    • 如果使用指针作为参数调用free函数,则函数结束后必须把指针设置为空指针
    int *p = (int *)malloc(20);
    free(p);
    p = NULL;
    

calloc

callocclear allocation

  • 功能:
    • 在内存动态存储区中分配nmemb块长度为size字节的连续区域。calloc自动将分配的内存置0。
  • 参数:
    • nmemb:分配存储区的个数
    • size:每个存储单元的大小(单位:字节)
  • 返回值:
    • 成功:分配空间的起始地址
    • 失败:NULL
void *calloc(size_t nmemb, size_t size);

int *p = calloc(5, size(of(int)));

callocmallocde的区别

  • calloc函数分配的存储区全部初始化为0
  • malloc函数分配的存储区为随机数(不初始化)

realloc

reallocreset allocation,重新分配用malloc或者calloc函数在堆中分配内存空间的大小。

  • 功能:
    • realloc不会自动清理增加的内存,需要手动清理,如果指定的地址后面有连续的空间,那么就会在已有地址基础上增加内存,如果指定的地址后面没有空间,那么realloc会重新分配新的连续内存,把旧内存的值拷贝到新内存,同时释放旧内存。
  • 参数:
    • ptr:为之前用malloc或者calloc分配的内存地址,如果此参数等于NULL,那么和reallocmalloc功能一致
    • size:首地址上调整后的字节数
  • 返回值:
    • 成功:新分配的堆内存地址
    • 失败:NULL
void *realloc(void *ptr, size_t size);

int* p = malloc(8);
p = realloc(p,16);	//将p的内存大小调整为16字节

内存分区模型

用户地址空间中布局如下(从上往下为从高地址到低地址):

区域 功能
参数和环境区 命令行参数和环境变量
栈区(stack) 非静态局部变量(向低地址增长)
堆栈增长的预留空间、共享库、共享内存等
堆区(heap) 动态内存分配(向高地址增长)
BSS区 未初始化的全局变量和静态局部变量
数据区(data) 不具有常属性且被初始化的全局变量和静态局部变量
代码区(text) 可执行指令、字面值常量、具有常属性且被初始化的全局和静态局部变量
//虚拟地址空间布局
#include<stdio.h>
#include<stdlib.h>
const int const_global = 10;//常全局变量
int init_global = 20;//初始化全局变量
int uninit_global;//未初始化全局变量
int main(int argc,char* argv[],char* envp[]){
    const static int const_static = 30;//常静态变量
    static int init_static = 40;//初始化静态变量
    static int uninit_static;//未初始化静态变量
    const int const_local = 50;//常局部变量
    int local;//局部变量
    int* heap = malloc(sizeof(int));//堆变量
    char* string = "hello";//字面值常量
    printf("---------参数和环境区--------\n");
    printf("        命令行参数:%p\n",argv);
    printf("          环境变量:%p\n",envp);
    printf("-------------栈区------------\n");
    printf("        常局部变量:%p\n",&const_local);
    printf("          局部变量:%p\n",&local);
    printf("-------------堆区------------\n");
    printf("            堆变量:%p\n",heap);
    printf("------------BSS区------------\n");
    printf("  未初始化全局变量:%p\n",&uninit_global);
    printf("  未初始化静态变量:%p\n",&uninit_static);
    printf("------------数据区-----------\n");
    printf("    初始化全局变量:%p\n",&init_global);
    printf("    初始化静态变量:%p\n",&init_static);
    printf("------------代码区-----------\n");
    printf("              函数:%p\n",main);
    printf("        字面值常量:%p\n",string);
    printf("        常全局变量:%p\n",&const_global);
    printf("        常静态变量:%p\n",&const_static);
    printf("-----------------------------\n");
    return 0;
}
cwork$ ./a.out 
---------参数和环境区--------
        命令行参数:0x7fffb07d4788
          环境变量:0x7fffb07d4798
-------------栈区------------
        常局部变量:0x7fffb07d4680
          局部变量:0x7fffb07d4684
-------------堆区------------
            堆变量:0x16aa010
------------BSS区------------
  未初始化全局变量:0x601060
  未初始化静态变量:0x60105c
------------数据区-----------
    初始化全局变量:0x601050
    初始化静态变量:0x601054
------------代码区-----------
              函数:0x400626
        字面值常量:0x40086c
        常全局变量:0x400868
        常静态变量:0x400adc
-----------------------------

栈区(stack)

  • 栈是一种先进后出的内存结构,由编译器自动分配释放,存放程序临时创建的局部变量,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且等调用结束后,函数的返回值也会被存放在回栈中。
    • 栈的大小:最大大小由编译时确定,不会太大。
    • 释放和分配:由编译器自动分配释放,由操作系统自动管理,无须手动管理。
    • 栈区地址:由高地址向低地址生长。
    • 若越界访问则会出现段错误(Segmentation Fault)
    • 若多次递归调用增加栈帧导致越界则会出现栈溢出(Stack Overflow)

栈的大小可以通过ulimit命令查看:

ulimit -s # 只查看stack的大小
ulimit -a # 查看当前所有的资源限制,stack 字段,单位Kbytes

栈的地址测试

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

int main() {
    
    int x = 10; // 栈分配
    int y = 10; // 栈分配

    int *p = &x; // 栈分配

    printf("&x = %p\n", &x);
    printf("&y = %p\n", &y);
    printf("&p = %p\n", &p);

    exit(0);
}

打印结果:

&x = 0x7ffd1ce3e33c
&y = 0x7ffd1ce3e338
&p = 0x7ffd1ce3e330

可以看到x,y,p的地址从高向低依次排列。

堆区(heap)

  • 堆是一个大容器,它的容量要远远大于栈,但没有栈那样先进后出的顺序
    • 用于动态内存分配。堆在内存中位于BSS区和栈区之间
    • 一般由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。
    • 生命周期是整个程序运行期间。使用malloc或者new进行堆的申请。
    • 堆分配内存API:callocrealloc
  • 堆区大小:视内存大小而定,由程序员进行分配。
  • 堆区地址由低向高生长

代码示例

int main() {
    int x =10;  // 栈分配
    int y = 20; // 栈分配
    char *p = (char*)malloc(256); //堆分配
    
    return 0;
}

堆的地址测试

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

int main() {
    int x = 10;
    int y = 10;

    int *p = &x;
    char *q = (char *)malloc(256 * sizeof(char)); // 堆上分配

    printf("&x = %p\n", &x);
    printf("&y = %p\n", &y);
    printf("p = %p\n", p);
    printf("q = %p\n", q);
	
    free(q);
    exit(0);
}

打印结果:

&x = 0x7fff986a56b0
&y = 0x7fff986a56b4
p = 0x7fff986a56b0
q = 0x559d15a642a0

可以看到分配在堆区的q的地址在其他三个的低处,且距离较远。

已初始化数据区

全局初始化数据区/静态数据区(data segment),它主要存储已初始化全局变量和静态变量,属于静态内存分配,其内容由程序初始化。

float PI= 3.14f; // 此变量以初值存放在初始化数据段中

int main(void) {
    // ...
    return 0;
}

未初始化数据区(BSS)

  • 通常将此段称为BSS段,意思是block started by symbol(由符号开始的块)
  • 位置可以分开亦可以紧靠数据段,存储未初始化全局变量和静态变量
  • 生存周期为整个程序运行过程。
long sum[1000]; // 此变量存放在非初始化数据段中

int main(void) {
    // ...
    return 0;
}

代码区(text segment)

加载的是可执行文件代码段,所有的可执行代码都加载到代码区,这块内存在运行期间是不可以修改的。

字符串常量

  • 在编译时,字符串编译后所处的位置与具体的编译器(目标文件格式)相关。
    • a.out格式目标文件中,字符串常量位于代码区(.text)。
  • 在运行时,与具体的操作系统(可执行文件格式)和加载器的实现相关。

静态区演示

#include <stdio.h>
// 数据区:data段
char m = 'a';
char n = 'a';

// 数据区:bss段
char arr1[10];

// 数据区:data段
char static MAX = 'a';
// 数据区:bss段
char static MIN;

void test() {
    // 栈区
    int x, y;
    
    // 常量区
    const int z = 10;
    
    // 数据区:data段
    static char a = 'a';
    // 数据区:bss段
    static char b;

    // "Hello World"在常量区
    // p在栈区
    const char *p = "Hello World";

    // "123456"和arr均在栈区,且地址相同
    char arr2[] = "123456";
}

int main(void) {
    test();
    return 0;
}

内存壁垒

  • 每个进程的用户空间都是0~3G-1,但它们所对应的物理内存却是各自独立的。
  • 系统为每个进程的用户空间维护一张专属于该进程的内存映射表,记录虚拟内存到物理内存的对应关系,因此在不同进程之间交换虚拟内存地址是毫无意义的。
  • 所有进程的内核空间都是3G~4G-,他们所对应的物理内存只有一份,系统为所有进程的内核空间维护一张内存映射表init_mm.pgd,记录虚拟内存到物理内存的对应关系,因此不同进程通过系统调用所访问的内核代码和数据是同一份
  • 用户空间的内存映射表会随着进程的切换而切换,内核空间的内存映射表则无需随着进程的切换而切换

段错误

段错误(Segmentation fault)是计算机程序运行时的一种常见错误,通常是由于程序试图访问的内存区域没有权限访问或者不存在导致的。这种错误会导致程序崩溃或者异常退出。

段错误通常发生在以下情况:

  • 访问空指针:当程序试图通过空指针访问内存时,会触发段错误。
  • 越界访问:当程序试图访问数组、字符串等数据结构的边界之外的内存时,会触发段错误。
  • 解引用悬挂指针:如果一个指针被释放了,但仍然被解引用,那么也会触发段错误。

为了避免段错误,程序员需要确保在访问内存之前检查指针是否为空,并确保不越界访问数据结构。同时,需要谨慎地管理指针和内存,避免出现悬挂指针的情况。在某些情况下,可以使用诸如valgrind之类的内存检测工具来帮助发现潜在的段错误。

//段错误演示
#include<stdio.h>

int main(void){
    /*int* p = (int*)0x12345678;
    *p = 123;
    printf("*p = %d\n",*p);*/

    static const int i = 1;
    //i = 2;
    // i ==> const int 
    // &i ==> const int*
    *(int*)&i = 2;
    printf("i = %d\n",i);
    return 0;
}
  • 一切对虚拟内存的越权访问,都会导致段错误
  • 试图访问没有映射到物理内存的虚拟内存
  • 试图以非法方式访问虚拟内存,如对制度内存做写操作等

内存映射的建立与解除

建立内存映射

#include<sys/mman.h>
void* mmap(void* start,size_t length,int prot,int flags,
           int fd,off_t offset);
  • 功能:建立虚拟内存到物理内存或磁盘文件的映射;
  • 参数:
    • start:映射区虚拟内存的起始地址,NULL系统自动选定后返回
    • length:映射区字节数,自动按页圆整
    • prot:映射区操作权限,可以取以下值:
      • PROT_READ - 映射区可读
      • PROT_WRITE - 映射区可写
      • PROT_EXEC - 映射区可执行
      • PROT_NON - 映射区不可访问
    • flag:映射标志,可以取以下值:
      • MAP_ANONYMOUS - 匿名映射,将虚拟内存映射到物理内存而非文件,忽略fd和offset参数
      • MAP_PRIVATE - 对映射区的写操作只反映到缓冲区中并不会真正写入文件
      • MAP_SHARED - 对映射区的写操作直接反映到文件
      • MAP_DENYWRITE - 拒绝其他对文件的写操作
      • MAP_FIXED - 若在start上无法创建映射,则失败(无此标志系统会自动调整)
    • fd:文件描述符
    • offset:文件偏移量,自动按页(4K)对齐
  • 返回值:成功返回映射区虚拟内存的起始地址,失败返回MAP_FAILED(-1)

解除内存映射

#include<sys/mman.h>
int munmap(void* start,size_t length);
  • 功能:解除虚拟内存到物理内存或磁盘文件的映射
  • 参数:
    • start:映射区虚拟内存到物理内存或磁盘文件的映射
    • length:映射区字节数,自动按页圆整
  • 返回值:成功返回0,失败返回-1
  • munmap允许对映射区的一部分映射,但必须按页处理
//内存映射文件
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>

int main(void){
    //打开文件
    int fd=open("./fmap.txt",O_RDWR|O_CREAT|O_TRUNC,0664);
    if(fd == -1){
        perror("open");
        return -1;
    }
    //修改文件大小
    if(ftruncate(fd,4096) == -1){
        perror("ftruncate");
        return -1;
    }    
    //内存映射文件
    char* start = mmap(NULL,4096,PROT_READ|PROT_WRITE,
                    MAP_SHARED,fd,0);
    if(start == MAP_FAILED){
        perror("mmap");
        return -1;
    }
    //操作文件
    strcpy(start,"今天有点冷");//write
    printf("%s\n",start);//read
    //解除映射
    if(munmap(start,4096) == -1){
        perror("munmap");
        return -1;
    }
    //关闭文件
    close(fd);
    return 0;
}
#include <stdio.h>  
#include <stdlib.h>  
#include <fcntl.h>  
#include <sys/mman.h>  
#include <unistd.h>  
  
int main() {  
    int fd = open("file.txt", O_RDONLY); // 打开文件  
    if (fd == -1) {  
        perror("open");  
        exit(EXIT_FAILURE);  
    }  
    off_t length = lseek(fd, 0, SEEK_END); // 获取文件大小  
    lseek(fd, 0, SEEK_SET); // 返回文件开头  
    void* addr = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0); // 建立内存映射  
    if (addr == MAP_FAILED) {  
        perror("mmap");  
        exit(EXIT_FAILURE);  
    }  
    // 通过指针访问内存映射,就像访问普通内存一样  
    char* data = (char*) addr;  
    for (off_t i = 0; i < length; i++) {  
        printf("%c", data[i]);  
    }  
    printf("\n");  
    // 解除映射  
    if (munmap(addr, length) == -1) {  
        perror("munmap");  
        exit(EXIT_FAILURE);  
    }  
    // 关闭文件  
    if (close(fd) == -1) {  
        perror("close");  
        exit(EXIT_FAILURE);  
    }  
    return 0;  
}

虚拟内存的分配和释放

sbrk 是一个在 Unix-like 系统(如 Linux)中提供的系统调用,用于改变当前进程的堆栈大小。这个函数主要用于在运行时动态地增加或减少堆栈的大小。

函数的原型是:

#include <unistd.h>
void *sbrk(intptr_t increment);
  • 功能:改变当前进程的堆栈的大小
  • 参数: increment 表示增加(或减少)堆栈大小的大小。
  • 返回值:成功指向新堆栈开始位置的指针,失败返回 (void *) -1

以下是一个简单的例子,演示如何使用 sbrk 来增加堆栈大小:

#include <stdio.h>
#include <unistd.h>

int main() {
    char *stack_end = (char *) sbrk(0); // 获取当前堆栈的结束位置
    char *new_stack_end = (char *) sbrk(1024 * 1024); // 增加1MB的堆栈大小
    if (new_stack_end == (char *) -1) {
        perror("sbrk");
        return 1;
    }
    printf("Old stack end: %p\n", stack_end);
    printf("New stack end: %p\n", new_stack_end);
    return 0;
}
#include <unistd.h>
int brk(void *endds);
  • 功能:用于更改进程的数据段结束位置,从而改变进程的可用内存空间。brk函数通常用于在运行时动态地增加或减少进程的堆内存大小。
  • 参数: endds 表示为重新设置的数据段结束地址。
  • 返回值:成功返回值为0;行失则返回-1。

brk函数在实现上通常是通过改变进程的堆栈指针或数据段指针来改变进程的内存空间大小。在Linux系统中,brk函数是系统调用接口之一,内核的syscall_table.s中定义了brk函数。在malloc函数的实现中,当需要动态分配内存时,如果分配的内存大小超过了MMAP_THRESHOLD(通常为128KB),则会使用mmap函数来分配内存;否则,会使用brk函数来扩展进程的堆内存空间。

下面是一个使用brk函数的示例:

#include <stdio.h>  
#include <unistd.h>  
  
int main() {  
    void *old_brk = sbrk(0); // 获取当前数据段结束位置  
    printf("当前堆尾: %p\n", old_brk);  
  
    if (brk(old_brk + 1024) == -1) { // 尝试增加1024字节的内存空间  
        perror("brk");  
        return 1;  
    }  
  
    void *new_brk = sbrk(0); // 获取新的数据段结束位置  
    printf("新堆尾: %p\n", new_brk);  
  
    return 0;  
}

这个示例中,我们首先使用sbrk(0)获取当前数据段的结束位置,然后尝试使用brk()函数将数据段的结束位置向后移动1024字节,即增加1024字节的内存空间。如果brk()函数执行成功,我们再次使用sbrk(0)获取新的数据段结束位置,并将其打印出来。

需要注意的是,brk()函数并不直接返回分配或释放的内存的大小,而是通过改变数据段的结束位置来间接地改变进程的内存空间大小。因此,在使用brk()函数时,我们需要谨慎处理指针和数据访问,以确保程序的正确性和健壮性。同时,我们还需要注意brk()函数的安全性和边界问题,以避免潜在的安全漏洞和内存错误。

需要注意的是,sbrkbrk 是相对老旧且不安全的系统调用,因为它没有进行任何错误检查或边界检查。在现代应用程序中,更推荐更现代、更安全的系统调用,使用 如mallocfree等函数以及mmap等系统调用等。

posted @ 2024-12-06 14:30  -O-n-e-  阅读(52)  评论(0)    收藏  举报