1. 程式人生 > 程式設計 >c語言 malloc函式詳解

c語言 malloc函式詳解

談到malloc函式相信學過c語言的人都很熟悉,但是malloc底層到底做了什麼又有多少人知道。

1、關於malloc相關的幾個函式

關於malloc我們進入Linux man一下就會得到如下結果:

這裡寫圖片描述

也可以這樣認為(window下)原型:

extern void *malloc(unsigned int num_bytes);

標頭檔案:

#include<malloc.h>或者#include<alloc.h>兩者的內容是完全一樣的

如果分配成功:則返回指向被分配記憶體空間的指標

不然返回指標NULL

同時,當記憶體不再使用的時候,應使用free()函式將記憶體塊釋放掉。

關於:void*,表示未確定型別的指標,c,c++規定void*可以強轉為任何其他型別的指標,關於void還有一種說法就是其他任何型別都可以直接賦值給它,無需進行強轉,但是反過來不可以

malloc:

malloc分配的記憶體大小至少為引數所指定的位元組數

malloc的返回值是一個指標,指向一段可用記憶體的起始位置,指向一段可用記憶體的起始地址,多次呼叫malloc所分配的地址不能有重疊部分,除非某次malloc所分配的地址被釋放掉malloc應該儘快完成記憶體分配並返回(不能使用NP-hard的記憶體分配演算法)實現malloc時應同時實現記憶體大小調整和記憶體釋放函式(realloc和free)

malloc和free是配對的,如果申請後不釋放就是記憶體洩露,如果無故釋放那就是什麼也沒做,釋放只能釋放一次,如果一塊空間釋放兩次或者兩次以上會出現錯誤(但是釋放空指標例外,釋放空指標也等於什麼也沒做,所以釋放多少次都是可以的。)

2、malloc和new

new返回指定型別的指標,並且可以自動計算所需要的大小。

int *p;
p = new int;//返回型別為int* ,分配的大小是sizeof(int)
p = new int[100];//返回型別是int*型別,分配的大小為sizeof(int)*100

而malloc需要我們自己計算位元組數,並且返回的時候要強轉成指定型別的指標。

int *p;
p = (int *)malloc(sizeof(int));

(1)malloc的返回是void*,如果我們寫成了:p=malloc(sizeof(int));間接的說明了(將void轉化給了int*,這不合理)
(2)malloc的實參是sizeof(int),用於指明一個整型資料需要的大小,如果我們寫成p=(int*)malloc(1),那麼可以看出:只是申請了一個一個位元組大小的空間。
(3)malloc只管分配記憶體,並不能對其進行初始化,所以得到的一片新記憶體中,其值將是隨機的。一般意義上:我們習慣性的將其初始化為NULL,當然也可以使用memset函式。

簡單的說:

malloc函式其實就是在記憶體中找一片指定大小的空間,然後將這個空間的首地址給一個指標變數,這裡的指標變數可以是一個單獨的指標,也可以是一個數組的首地址,這要看malloc函式中引數size的具體內容。我們這裡malloc分配的記憶體空間在邏輯上是連續的,而在物理上可以不連續。我們作為程式設計師,關注的是邏輯上的連續,其他的作業系統會幫著我們處理。

下面就來看看malloc具體是怎麼實現的。

首先要了解作業系統相關的知識:

虛擬記憶體地址和實體記憶體地址

為了簡單,現代作業系統在處理實體記憶體地址時,普遍採用虛擬記憶體地址技術。即在彙編程式層面,當涉及記憶體地址時,都是使用的虛擬記憶體地址。採用這種技術時,每個程序彷彿自己獨享一片2N位元組的記憶體,其中N是機器位數。例如在64位CPU和64位作業系統下每個程序的虛擬地址空間為264Byte。

這種虛擬地址空間的作用主要是簡化程式的編寫及方便作業系統對程序間記憶體的隔離管理,真實中的程序不太可能如此大的空間,實際能用到的空間大小取決於實體記憶體的大小。

由於在機器語言層面都是採用虛擬地址,當實際的機器碼程式涉及到記憶體操作時,需要根據當前程序執行的實際上下文將虛擬地址轉化為實體記憶體地址,才能實現對記憶體資料的操作。這個轉換一般由一個叫MMU的硬體完成。

頁與地址構成

在現代作業系統中,不論是虛擬記憶體還是實體記憶體,都不是以位元組為單位進行管理的,而是以頁為單位。一個記憶體頁是一段固定大小的連續的連續記憶體地址的總稱,具體到Linux中,典型的記憶體頁大小為4096 Byte

所以記憶體地址可以分為頁號和頁內偏移量。下面以64位機器,4G實體記憶體,4K頁大小為例,虛擬記憶體地址和實體記憶體地址的組成如下:

這裡寫圖片描述

上面是虛擬記憶體地址,下面是實體記憶體地址。由於頁大小都是4k,所以頁內偏移都是用低12位表示,而剩下的高地址表示頁號
MMU對映單位並不是位元組,而是頁,這個對映通過差一個常駐記憶體的資料結構頁表來實現。現在計算機具體的記憶體地址對映比較複雜,為了加快速度會引入一系列快取和優化,例如TLB等機制,下面給出一個經過簡化的記憶體地址翻譯示意圖:

這裡寫圖片描述

記憶體頁與磁碟頁

我們知道一般將記憶體看做磁碟的快取,有時MMU在工作時,會發現頁表表名某個記憶體頁不在實體記憶體頁不在實體記憶體中,此時會觸發一個缺頁異常,此時系統會到磁碟中相應的地方將磁碟頁載入到記憶體中,然後重新執行由於缺頁而失敗的機器指令。關於這部分,因為可以看做對malloc實現是透明的,所以不再詳述
真實地址翻譯流程:

這裡寫圖片描述

Linux程序級記憶體管理

2.2.1記憶體排布

明白了虛擬記憶體和實體記憶體的關係及相關的對映機制,下面看一下具體在一個程序內是如何排布記憶體的。

以Linux 64位系統為例。理論上,64bit記憶體地址空間為0x0000000000000000-0xFFFFFFFFFFFFFFF,這是個相當龐大的空間,Linux實際上只用了其中一小部分

具體分佈如圖所示:

這裡寫圖片描述

對使用者來說主要關心的是User Space。將User Space放大後,可以看到裡面主要分成如下幾段:

  • Code:這是整個使用者空間的最低地址部分,存放的是指令(也就是程式所編譯成的可執行機器碼)
  • Data:這裡存放的是初始化過的全域性變數
  • BSS:這裡存放的是未初始化的全域性變數
  • Heap:堆,這是我們本文主要關注的地方,堆自底向上由低地址向高地址增長

Mapping Area:這裡是與mmap系統呼叫相關的區域。大多數實際的malloc實現會考慮通過mmap分配較大塊的記憶體空間,本文不考慮這種情況,這個區域由高地址像低地址增長

Stack:棧區域,自高地址像低地址增長

Heap記憶體模型:

一般來說,malloc所申請的記憶體主要從Heap區域分配,來看看Heap的結構是怎樣的。

這裡寫圖片描述

Linux維護一個break指標,這個指標執行堆空間的某個地址,從堆開始到break之間的地址空間為對映好的,可以供程序訪問,而從break往上,是未對映的地址空間,如果訪問這段空間則程式會報錯

brk與sbrk

由上文知道,要增加一個程序實際上的可用堆大小,就需要將break指標向高地址移動。Linux通過brk和sbrk系統呼叫操作break指標。兩個系統呼叫的原型如下:

int brk(void *addr);
void *sbrk(inptr_t increment);

brk將break指標直接設定為某個地址,而sbrk將break從當前位置移動increment所指定的增量。brk在執行成功時返回0,否則返回-1並設定為errno為ENOMEM,sbrk成功時返回break移動之前所指向的地址,否則返回(void*)-1;
資源限制和rlimirt

系統為每一個程序所分配的資源不是無限的,包括可對映的空間,因此每個程序有一個rlimit表示當前程序可用的資源上限,這個限制可以通過getrlimit系統呼叫得到,下面程式碼獲取當前程序虛擬記憶體空間的rlimit

其中rlimt是一個結構體

struct rlimit
{
  rlimt_t rlim_cur;
  rlim_t rlim_max;
};

每種資源有硬限制和軟限制,並且可以通過setrlimit對rlimit進行有條件限制作為軟限制的上限,非特權程序只能設定軟限制,且不能超過硬限制

實現malloc

(1)資料結構

首先我們要確定所採用的資料結構。一個簡單可行方案是將堆記憶體空間以塊的形式組織起來,每個塊由meta區和資料區組成,meta區記錄資料塊的元資訊(資料區大小、空閒標誌位、指標等等),資料區是真實分配的記憶體區域,並且資料區的第一個位元組地址即為malloc返回的地址

可以使用如下結構體定義一個block

typedef struct s_block *t_block;
struck s_block{
  size_t size;//資料區大小
  t_block next;//指向下個塊的指標
  int free;//是否是空閒塊
  int padding;//填充4位元組,保證meta塊長度為8的倍數
  char data[1];//這是一個虛擬欄位,表示資料塊的第一個位元組,長度不應計入meta
};

(2)尋找合適的block

現在考慮如何在block鏈中查詢合適的block。一般來說有兩種查詢演算法:
First fit:從頭開始,使用第一個資料區大小大於要求size的塊所謂此次分配的塊
Best fit:從頭開始,遍歷所有塊,使用資料區大小大於size且差值最小的塊作為此次分配的塊
兩種方式各有千秋,best fit有較高的記憶體使用率(payload較高),而first fit具有較高的執行效率。這裡我們採用first fit演算法

t_block find_block(t_block *last,size_t size){
  t_block b = first_block;
  while(b&&b->size>=size)
  {
    *last = b;
    b = b->next;
  }
  return b;
}

find_block從first_block開始,查詢第一個符合要求的block並返回block起始地址,如果找不到這返回NULL,這裡在遍歷時會更新一個叫last的指標,這個指標始終指向當前遍歷的block.這是為了如果找不到合適的block而開闢新block使用的。

(3)開闢新的block
如果現有block都不能滿足size的要求,則需要在連結串列最後開闢一個新的block。這裡關鍵是如何只使用sbrk建立一個struct:

#define BLOCK_SIZE 24

t_block extend_heap{
  t_block b;
  b = sbrk(0);
    if(sbrk(BLOCK_SIZE+s)==(void*)-1)
    return NULL;
    b->size = s;
    b->next - NULL;
    if(last)
    last->next = b;
    b->free = 0;
    return b;
};

(4)分裂block
First fit有一個比較致命的缺點,就是可能會讓更小的size佔據很大的一塊block,此時,為了提高payload,應該在剩餘資料區足夠大的情況下,將其分裂為一個新的block

void split_block(t_block b,size_t s)
{
  t_block new;
  new = b->data;
  new->size = b->size-s-BLOCK_SIZE;
  new->next = b->next;
  new ->free = 1;
  b->size = s;
  b->next = new;
}

(5)malloc的實現
有了上面的程式碼,我們就可以實現一個簡單的malloc.注意首先我們要定義個block連結串列的頭first_block,初始化為NULL;另外,我們需要剩餘空間至少有BLOCK_SIZE+8才執行分裂操作
由於我們需要malloc分配的資料區是按8位元組對齊,所以size不為8的倍數時,我們需要將size調整為大於size的最小的8的倍數

size_t align8(size_t s)
{
  if(s&0x7 == 0)
  return s;
  return ((s>>3)+1)<<3;
}
#define BLOCK_SIZE 24
void *first_block=NULL;
void *mallloc(size_t size)
{
  t_block b,last;
  size_t s;
  //對齊地址
  s = align8(size);
  if(first_block)
  //查詢適合block
  last = first_block;
  b = find_block(&last,s);
  if(b)
  {
  //如果可以則分裂
  if((b->size-s)>=(BLOCK_SIZE + 8))
  split_block(b,s);
  b->free = 0;
  }
  else
  {
    //沒有合適的block,開闢一個新的
    b=extend_heap(last,s);
    if(!b)
    {
      return NULL;
    }
    else
    {
      b=extend_heap(NULL,s);
      if(!b)
      {
        return NULL;
      }
      first_block = b;
    }
  }
  return b->data;
}

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援我們。