c/c++ 內存使用指南 和實踐指導

如果你完全理解如下內容, 請聯繫我:[email protected],  討論更深層次合作 。


1.        大內高手內存模型

單線程模型

多線程模型

 

2.        大內高手/

       backtrace的實現

       alloca的實現

      可變參數的實現。

       malloc/free系列函數簡介

       new/delete系列操作符簡介

      

3.        大內高手全局內存

       .bss說明

       .data說明

       .rodata說明

       violatile關鍵字說明

       static關鍵字說明

       const關鍵字說明

 

4.        大內高手內存分配算法

      標準C(glibc)分配算法

       STL(STLPort)分配算法

       OS內部分配算法(夥伴/SLAB

 

5.        大內高手慣用手法

      引用計數

      預先分配

      內存池

      會話池

      

6.        大內高手共享內存與線程局部存儲

7.        大內高手自動內存回收機制

8.        大內高手常見內存錯誤

9.        大內高手常用調試工具

 

++++++++++++++++++++++++++++++++++++++++++++++++++++++++

大內高手內存模型

 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

瞭解linux的內存模型,或許不能讓你大幅度提高編程能力,但是作爲一個基本知識點應該熟悉。坐火車外出旅行時,即時你對沿途的地方一無所知,仍然可以到達目標地。但是你對整個路途都很比較清楚的話,每到一個站都知道自己在哪裏,知道當地的風土人情,對比一下所見所想,旅程可能更有趣一些。

 

類似的,瞭解linux的內存模型,你知道每塊內存,每個變量,在系統中處於什麼樣的位置。這同樣會讓你心情愉快,知道這些,有時還會讓你的生活輕更鬆些。看看變量的地址,你可以大致斷定這是否是一個有效的地址。一個變量被破壞了,你可以大致推斷誰是犯罪嫌疑人。

 

Linux的內存模型,一般爲:

 

地址

作用

說明

>=0xc000 0000

內核虛擬存儲器

用戶代碼不可見區域

<0xc000 0000

Stack(用戶棧)

ESP指向棧頂

 

 

 

空閒內存

>=0x4000 0000

文件映射區

 

<0x4000 0000

 

 

 

空閒內存

 

 

Heap(運行時堆)

通過brk/sbrk系統調用擴大堆,向上增長。

 

.data.bss(讀寫段)

從可執行文件中加載

>=0x0804 8000

.init.text.rodata(只讀段)

從可執行文件中加載

<0x0804 8000

保留區域

 

 

很多書上都有類似的描述,本圖取自於《深入理解計算機系統》p603,略做修改。本圖比較清析,很容易理解,但仍然有兩點不足。下面補充說明一下:

 

1.        第一點是關於運行時堆的

爲說明這個問題,我們先運行一個測試程序,並觀察其結果:

 

#include<stdio.h>

 

intmain(intargc,char*argv[])

{

   int first= 0;

   int*p0= malloc(1024);

   int*p1= malloc(1024 * 1024);

   int*p2= malloc(512 * 1024 * 1024 );

   int*p3= malloc(1024 * 1024 * 1024 );

   printf("main=%p print=%p\n",main,printf);

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

   printf("p0=%p p1=%p p2=%p p3=%p\n",p0,p1,p2,p3);

 

   getchar();

 

   return0;

}

 

運行後,輸出結果爲:

main=0x8048404 print=0x8048324

first=0xbfcd1264

p0=0x9253008 p1=0xb7ec0008 p2=0x97ebf008 p3=0x57ebe008

 

l        mainprint兩個函數是代碼段(.text)的,其地址符合表一的描述。

l        first是第一個臨時變量,由於在first之前還有一些環境變量,它的值並非0xbfffffff,而是0xbfcd1264,這是正常的。

l        p0是在堆中分配的,其地址小於0x4000 0000,這也是正常的。

l        p1p2也是在堆中分配的,而其地址竟大於0x4000 0000,與表一描述不符。

 

原因在於:運行時堆的位置與內存管理算法相關,也就是與malloc的實現相關。關於內存管理算法的問題,我們在後繼文章中有詳細描述,這裏只作簡要說明。在glibc實現的內存管理算法中,Malloc小塊內存是在小於0x4000 0000的內存中分配的,通過brk/sbrk不斷向上擴展,而分配大塊內存,malloc直接通過系統調用mmap實現,分配得到的地址在文件映射區,所以其地址大於0x4000 0000

 

maps文件中可以清楚的看到一點:

 

00514000-00515000 r-xp 00514000 00:00 0

00624000-0063e000 r-xp 00000000 03:01 718192     /lib/ld-2.3.5.so

0063e000-0063f000 r-xp 00019000 03:01 718192     /lib/ld-2.3.5.so

0063f000-00640000 rwxp 0001a000 03:01 718192     /lib/ld-2.3.5.so

00642000-00766000 r-xp 00000000 03:01 718193     /lib/libc-2.3.5.so

00766000-00768000 r-xp 00124000 03:01 718193     /lib/libc-2.3.5.so

00768000-0076a000 rwxp 00126000 03:01 718193     /lib/libc-2.3.5.so

0076a000-0076c000 rwxp 0076a000 00:00 0

08048000-08049000 r-xp 00000000 03:01 1307138    /root/test/mem/t.exe

08049000-0804a000 rw-p 00000000 03:01 1307138    /root/test/mem/t.exe

09f5d000-09f7e000 rw-p 09f5d000 00:00 0          [heap]

57e2f000-b7f35000 rw-p 57e2f000 00:00 0

b7f44000-b7f45000 rw-p b7f44000 00:00 0

bfb2f000-bfb45000 rw-p bfb2f000 00:00 0          [stack]

 

2.        第二是關於多線程的。

現在的應用程序,多線程的居多。表一所描述的模型無法適用於多線程環境。按表一所述,程序最多擁有上G的棧空間,事實上,在多線程情況下,能用的棧空間是非常有限的。爲了說明這個問題,我們再看另外一個測試:

 

#include<stdio.h>

#include<pthread.h>

 

 

void*thread_proc(void*param)

{

   int first= 0;

   int*p0= malloc(1024);

   int*p1= malloc(1024 * 1024);

 

   printf("(0x%x): first=%p\n",    pthread_self(), &first);

   printf("(0x%x): p0=%p p1=%p \n", pthread_self(),p0,p1);

 

   return0;

}

 

#defineN5

intmain(intargc,char*argv[])

{

   intfirst = 0;

   inti= 0;

   void*ret= NULL;

    pthread_ttid[N] = {0};

 

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

   for(i= 0; i < N;i++)

    {

        pthread_create(tid+i,NULL,thread_proc,NULL);

    }

 

   for(i= 0; i < N;i++)

    {

        pthread_join(tid[i], &ret);

    }

 

   return0;

}

 

運行後,輸出結果爲:

first=0xbfd3d35c

(0xb7f2cbb0): first=0xb7f2c454

(0xb7f2cbb0): p0=0x84d52d8 p1=0xb4c27008

(0xb752bbb0): first=0xb752b454

(0xb752bbb0): p0=0x84d56e0 p1=0xb4b26008

(0xb6b2abb0): first=0xb6b2a454

(0xb6b2abb0): p0=0x84d5ae8 p1=0xb4a25008

(0xb6129bb0): first=0xb6129454

(0xb6129bb0): p0=0x84d5ef0 p1=0xb4924008

(0xb5728bb0): first=0xb5728454

(0xb5728bb0): p0=0x84d62f8 p1=0xb7e2c008

 

我們看一下:

主線程與第一個線程的棧之間的距離:0xbfd3d35c - 0xb7f2c454=0x7e10f08=126M

第一個線程與第二個線程的棧之間的距離:0xb7f2c454 - 0xb752b454=0xa01000=10M

其它幾個線程的棧之間距離均爲10M

也就是說,主線程的棧空間最大爲126M,而普通線程的棧空間僅爲10M,超這個範圍就會造成棧溢出。

 

棧溢出的後果是比較嚴重的,或者出現Segmentation fault錯誤,或者出現莫名其妙的錯誤。



進階2:

l        

棧作爲一種基本數據結構,我並不感到驚訝,用來實現函數調用,這也司空見慣的作法。直到我試圖找到另外一種方式實現遞歸操作時,我才感嘆於它的巧妙。要實現遞歸操作,不用棧不是不可能,而是找不出比它更優雅的方式。

 

儘管大多數編譯器在優化時,會把常用的參數或者局部變量放入寄存器中。但用棧來管理函數調用時的臨時變量(局部變量和參數)是通用做法,前者只是輔助手段,且只在當前函數中使用,一旦調用下一層函數,這些值仍然要存入棧中才行。

 

通常情況下,棧向下(低地址)增長,每向棧中PUSH一個元素,棧頂就向低地址擴展,每從棧中POP一個元素,棧頂就向高地址回退。一個有興趣的問題:在x86平臺上,棧頂寄存器爲ESP,那麼ESP的值在是PUSH操作之前修改呢,還是在PUSH操作之後修改呢?PUSH ESP這條指令會向棧中存入什麼數據呢?據說x86系列CPU中,除了286外,都是先修改ESP,再壓棧的。由於286沒有CPUID指令,有的OS用這種方法檢查286的型號。

 

一個函數內的局部變量以及其調用下一級函數的參數,所佔用的內存空間作爲一個基本的單元,稱爲一個幀(frame)。在gdb裏,f命令就是用來查看指定幀的信息的。在兩個frame之間通過還存有其它信息,比如上一層frame的分界地址(EBP)等。

 

關於棧的基本知識,就先介紹這麼多,我們下面來看看一些關於棧的技巧及應用:

1.        backtrace的實現

callstack調試器的基本功能之一,利用此功能,你可以看到各級函數的調用關係。在gdb中,這一功能被稱爲backtrace,輸入bt命令就可以看到當前函數的callstack。它的實現多少有些有趣,我們在這裏研究一下。

 

我們先看看棧的基本模型

 

參數N

↓高地址

參數

函數參數入棧的順序與具體的調用方式有關

參數3

參數2

參數1

EIP

返回本次調用後,下一條指令的地址

EBP

保存調用者的EBP,然後EBP指向此時的棧頂。

臨時變量1

 

臨時變量2

 

臨時變量3

 

臨時變量

 

臨時變量5

↓低地址

 

要實現callstack我們需要知道以下信息:

l        調用函數時的指令地址(即當時的EIP)。

l        指令地址對應的源代碼代碼位置。

關於第一點,從上表中,我們可以看出,棧中存有各級EIP的值,我們取出來就行了。用下面的代碼可以輕易實現:

 

#include<stdio.h>

 

intbacktrace(void**BUFFER,int SIZE)

{

   int n = 0;

   int*p = &n;

   int i = 0;

 

   int ebp = p[1];

   int eip = p[2];

 

   for(i= 0; i < SIZE; i++)

    {

       BUFFER[i] = (void*)eip;

       p = (int*)ebp;

       ebp = p[0];

       eip = p[1];

    }

 

   return SIZE;

}

 

#defineN 4

staticvoid test2()

{

   int i = 0;

   void*BUFFER[N] = {0};

 

   backtrace(BUFFER,N);

 

   for(i= 0; i < N; i++)

    {

       printf("%p\n", BUFFER[i]);

    }

 

        return;

}

 

staticvoid test1()

{

   test2();

}

 

staticvoid test()

{

   test1();

}

 

intmain(intargc,char*argv[])

{

   test();

 

   return 0;

}

程序輸出:

0x8048460

0x804849c

0x80484a9

0x80484cc

 

關於第二點,如何把指令地址與行號對應起來,這也很簡單。可以從MAP文件或者ELF中查詢。Binutil帶有一個addr2line的小工具,可以幫助實現這一點。

[root@linux bt]# addr2line  0x804849c -e bt.exe

/root/test/bt/bt.c:42

 

2.        alloca的實現

大家都知道動態分配的內存,一定要釋放掉,否則就會有內存泄露。可能鮮有人知,動態分配的內存,可以不用釋放。Alloca就是這樣一個函數,最後一個a代表auto,即自動釋放的意思。

 

Alloca是在棧中分配內存的。即然是在棧中分配,就像其它在棧中分配的臨時變量一樣,在當前函數調用完成時,這塊內存自動釋放。

 

正如我們前面講過,棧的大小是有限制的,普通線程的棧只有10M大小,所以在分配時,要量力而行,且不要分配過大內存。

 

Alloca可能會漸漸的退出歷史舞臺,原因是新的C/C++標準都支持變長數組。比如int array[n],老版本的編譯器要求n是常量,而新編譯器允許n是變量。編譯器支持的這一功能完全可以取代alloca

 

這不是一個標準函數,但像linuxwin32等大多數平臺都支持。即使少數平臺不支持,要自己實現也不難。這裏我們簡單介紹一下alloca的實現方法。

 

我們先看看一個小程序,再看看它對應的彙編代碼,一切都清楚了。

 

#include<stdio.h>

 

intmain(intargc,char*argv[])

{

   int n = 0;

   int*p = alloca(1024);

 

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

   return 0;

}

彙編代碼爲:

 

intmain(intargc,char*argv[])

{

 8048394:       55                      push   �p

 8048395:       89 e5                  mov    %esp,�p

 8048397:       83 ec 18                sub    $0x18,%esp

 804839a:       83 e4 f0                and    $0xfffffff0,%esp

 804839d:       b8 00 00 00 00         mov    $0x0,�x

 80483a2:       83 c0 0f               add    $0xf,�x

 80483a5:       83 c0 0f               add    $0xf,�x

 80483a8:       c1 e8 04                shr    $0x4,�x

 80483ab:       c1 e0 04                shl    $0x4,�x

 80483ae:       29 c4                   sub    �x,%esp

       int n = 0;

 80483b0:       c7 45 fc 00 00 00 00    movl   $0x0,0xfffffffc(�p)

       int*p = alloca(1024);

 80483b7:       81 ec 10 04 00 00       sub    $0x410,%esp

 80483bd:       8d 44 24 0c             lea    0xc(%esp),�x

 80483c1:       83 c0 0f               add    $0xf,�x

 80483c4:       c1 e8 04                shr    $0x4,�x

 80483c7:       c1 e0 04                shl    $0x4,�x

 80483ca:       89 45f8               mov    �x,0xfffffff8(�p)

 

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

 80483cd:       8b 45f8               mov    0xfffffff8(�p),�x

 80483d0:       89 44 24 08            mov    �x,0x8(%esp)

 80483d4:       8d 45 fc                lea    0xfffffffc(�p),�x

 80483d7:       89 44 24 04            mov    �x,0x4(%esp)

 80483db:      c7 04 24 98 84 04 08    movl   $0x8048498,(%esp)

 80483e2:       e8d1 fe ff ff          call   80482b8 <printf@plt>

       return 0;

 80483e7:       b8 00 00 00 00         mov    $0x0,�x

}

 

其中關鍵的一條指令爲:sub    $0x410,%esp

由此可以看出實現alloca,僅僅是把ESP減去指定大小,擴大棧空間(記記住棧是向下增長),這塊空間就是分配的內存。

 

3.        可變參數的實現。

對新手來說,可變參數的函數也是比較神奇。還是以一個小程序來說明它的實現。

 

#include<stdio.h>

#include<stdarg.h>

 

intprint(constchar*fmt, ...)

{

   int n1 = 0;

   int n2 = 0;

   int n3 = 0;

   va_list ap;

   va_start(ap,fmt);

 

    n1 =va_arg(ap,int);

   n2 = va_arg(ap,int);

    n3 =va_arg(ap,int);

 

   va_end(ap);

 

   printf("n1=%d n2=%d n3=%d\n", n1, n2, n3);

 

   return 0;

}

 

intmain(intarg, char argv[])

{

   print("%d\n", 1, 2, 3);

 

   return 0;

}

 

我們看看對應的彙編代碼:

 

intprint(constchar*fmt, ...)

{

 8048394:       55                      push   �p

 8048395:       89 e5                  mov    %esp,�p

 8048397:       83 ec 28                sub    $0x28,%esp

       int n1 = 0;

 804839a:       c7 45 fc 00 00 00 00    movl   $0x0,0xfffffffc(�p)

       int n2 = 0;

 80483a1:       c7 45f8 00 00 00 00    movl   $0x0,0xfffffff8(�p)

       int n3 = 0;

 80483a8:       c7 45f4 00 00 00 00    movl   $0x0,0xfffffff4(�p)

       va_list ap;

       va_start(ap,fmt);

 80483af:       8d 45 0c                lea    0xc(�p),%eax

 80483b2:       89 45 f0               mov    %eax,0xfffffff0(�p)

 

        n1 =va_arg(ap,int);

 80483b5:       8b 55 f0               mov    0xfffffff0(�p),�x

 80483b8:       8d 45 f0                lea    0xfffffff0(�p),%eax

 80483bb:       83 00 04                addl   $0x4,(%eax)

 80483be:       8b 02                  mov    (�x),%eax

 80483c0:       89 45 fc               mov    %eax,0xfffffffc(�p)

       n2 = va_arg(ap,int);

 80483c3:       8b 55 f0               mov    0xfffffff0(�p),�x

 80483c6:       8d 45 f0                lea    0xfffffff0(�p),%eax

 80483c9:       83 00 04                addl   $0x4,(%eax)

 80483cc:       8b 02                  mov    (�x),%eax

 80483ce:       89 45f8               mov    %eax,0xfffffff8(�p)

        n3 =va_arg(ap,int);

 80483d1:       8b 55 f0               mov    0xfffffff0(�p),�x

 80483d4:       8d 45 f0                lea    0xfffffff0(�p),%eax

 80483d7:       83 00 04                addl   $0x4,(%eax)

 80483da:       8b 02                  mov    (�x),%eax

 80483dc:       89 45f4               mov    %eax,0xfffffff4(�p)

 

       va_end(ap);

      printf("n1=%d n2=%d n3=%d\n", n1, n2, n3);

 80483df:       8b 45f4               mov    0xfffffff4(�p),%eax

 80483e2:       89 44 24 0c            mov    %eax,0xc(%esp)

 80483e6:       8b 45f8               mov    0xfffffff8(�p),%eax

 80483e9:       89 44 24 08            mov    %eax,0x8(%esp)

 80483ed:       8b 45 fc               mov    0xfffffffc(�p),%eax

 80483f0:       89 44 24 04            mov    %eax,0x4(%esp)

 80483f4:       c7 04 24f8 84 04 08    movl   $0x80484f8,(%esp)

 80483fb:       e8 b8 fe ff ff          call   80482b8 <printf@plt>

 

       return 0;

 8048400:       b8 00 00 00 00         mov    $0x0,%eax

}

intmain(intarg,char argv[])

{

 8048407:       55                      push   �p

 8048408:       89 e5                  mov    %esp,�p

 804840a:       83 ec 18                sub    $0x18,%esp

 804840d:       83 e4 f0                and    $0xfffffff0,%esp

 8048410:       b8 00 00 00 00         mov    $0x0,%eax

 8048415:       83 c0 0f               add    $0xf,%eax

 8048418:       83 c0 0f               add    $0xf,%eax

 804841b:       c1 e8 04                shr    $0x4,%eax

 804841e:       c1 e0 04                shl    $0x4,%eax

 8048421:       29 c4                   sub    %eax,%esp

       int n = print("%d\n", 1, 2, 3);

 8048423:       c7 44 24 0c 03 00 00    movl   $0x3,0xc(%esp)

 804842a:       00

 804842b:       c7 44 24 08 02 00 00    movl   $0x2,0x8(%esp)

 8048432:       00

 8048433:       c7 44 24 04 01 00 00    movl   $0x1,0x4(%esp)

 804843a:       00

 804843b:       c7 04 24 0b 85 04 08    movl   $0x804850b,(%esp)

 8048442:       e8 4d ff ff ff          call   8048394 <print>

 8048447:       89 45 fc               mov    %eax,0xfffffffc(�p)

 

       return 0;

 804844a:       b8 00 00 00 00         mov    $0x0,%eax

}

 

從彙編代碼中,我們可以看出,參數是逆序入棧的。在取參數時,先讓ap指向第一個參數,又因爲棧是向下增長的,不斷把指針向上移動就可以取出所有參數了。



進階3:

全局內存

 

 

有人可能會說,全局內存就是全局變量嘛,有必要專門一章來介紹嗎?這麼簡單的東西,還能玩出花來?我從來沒有深究它,不一樣寫程序嗎?關於全局內存這個主題雖然玩不出花來,但確實有些重要,瞭解這些知識,對於優化程序的時間和空間很有幫助。因爲有好幾次這樣經歷,我才決定花一章篇幅來介紹它。

 

正如大家所知道的,全局變量是放在全局內存中的,但反過來卻未必成立。用static修飾的局部變量就是放在放全局內存的,它的作用域是局部的,但生命期是全局的。在有的嵌入式平臺中,堆實際上就是一個全局變量,它佔用相當大的一塊內存,在運行時,把這塊內存進行二次分配。

 

這裏我們並不強調全局變量和全局內存的差別。在本文中,全局強調的是它的生命期,而不是它的作用域,所以有時可能把兩者的概念互換。

 

一般來說,在一起定義的兩個全局變量,在內存的中位置是相鄰的。這是一個簡單的常識,但有時挺有用,如果一個全局變量被破壞了,不防先查查其前後相關變量的訪問代碼,看看是否存在越界訪問的可能。

 

ELF格式的可執行文件中,全局內存包括三種:bssdatarodata。其它可執行文件格式與之類似。瞭解了這三種數據的特點,我們才能充分發揮它們的長處,達到速度與空間的最優化。

 

1.        bss

已經記不清bss代表Block Storage Start還是Block Started by Symbol。像這我這種沒有用過那些史前計算機的人,終究無法明白這樣怪異的名字,也就記不住了。不過沒有關係,重要的是,我們要清楚bss全局變量有什麼樣特點,以及如何利用它。

 

通俗的說,bss是指那些沒有初始化的和初始化爲0的全局變量。它有什麼特點呢,讓我們來看看一個小程序的表現。

 

intbss_array[1024 * 1024] = {0};

 

intmain(intargc,char*argv[])

{

   return 0;

}

[root@localhost bss]# gcc -g bss.c -o bss.exe

[root@localhost bss]# ll

total 12

-rw-r--r-- 1root root   84Jun 22 14:32 bss.c

-rwxr-xr-x1 root root 5683 Jun 22 14:32 bss.exe

 

變量bss_array的大小爲4M,而可執行文件的大小隻有5K由此可見,bss類型的全局變量只佔運行時的內存空間,而不佔文件空間。

 

另外,大多數操作系統,在加載程序時,會把所有的bss全局變量全部清零,無需要你手工去清零。但爲保證程序的可移植性,手工把這些變量初始化爲0也是一個好習慣。

 

2.        data

bss相比,data就容易明白多了,它的名字就暗示着裏面存放着數據。當然,如果數據全是零,爲了優化考慮,編譯器把它當作bss處理。通俗的說,data指那些初始化過(非零)的非const的全局變量。它有什麼特點呢,我們還是來看看一個小程序的表現。

 

intdata_array[1024 * 1024] = {1};

 

intmain(intargc,char*argv[])

{

   return 0;

}

 

[root@localhostdata]# gcc-gdata.c -odata.exe

[root@localhostdata]# ll

total 4112

-rw-r--r-- 1root root      85Jun 22 14:35 data.c

-rwxr-xr-x1 root root 4200025 Jun 22 14:35 data.exe

 

僅僅是把初始化的值改爲非零了,文件就變爲4M多。由此可見,data類型的全局變量是即佔文件空間,又佔用運行時內存空間的。

 

3.        rodata

rodata的意義同樣明顯,ro代表read only,即只讀數據(const)。關於rodata類型的數據,要注意以下幾點:

l        常量不一定就放在rodata裏,有的立即數直接編碼在指令裏,存放在代碼段(.text)中。

l        對於字符串常量,編譯器會自動去掉重複的字符串,保證一個字符串在一個可執行文件(EXE/SO)中只存在一份拷貝。

l        rodata是在多個進程間是共享的,這可以提高空間利用率。

l        在有的嵌入式系統中,rodata放在ROM(norflash)裏,運行時直接讀取ROM內存,無需要加載到RAM內存中。

l        在嵌入式linux系統中,通過一種叫作XIP(就地執行)的技術,也可以直接讀取,而無需要加載到RAM內存中。

 

由此可見,把在運行過程中不會改變的數據設爲rodata類型的,是有很多好處的:在多個進程間共享,可以大大提高空間利用率,甚至不佔用RAM空間。同時由於rodata在只讀的內存頁面(page)中,是受保護的,任何試圖對它的修改都會被及時發現,這可以幫助提高程序的穩定性。

 

4.        變量與關鍵字

static關鍵字用途太多,以致於讓新手模糊。不過,總結起來就有兩種作用,改變生命期限制作用域。如:

l        修飾inline函數:限制作用域

l        修飾普通函數:限制作用域

l        修飾局部變量:改變生命期

l        修飾全局變量:限制作用域

 

const關鍵字倒是比較明瞭,用const修飾的變量放在rodata裏,字符串默認就是常量。對const,注意以下幾點就行了。

l        指針常量:指向的數據是常量。如 const char* p = “abc”; p指向的內容是常量,但p本身不是常量,你可以讓p再指向”123”

l        常量指針:指針本身是常量。如:char* const p = “abc”; p本身就是常量,你不能讓p再指向”123”

l        指針常量 +常量指針:指針和指針指向的數據都是常量。const char* const p =”abc”;兩者都是常量,不能再修改。

 

violatile關鍵字通常用來修飾多線程共享的全局變量和IO內存。告訴編譯器,不要把此類變量優化到寄存器中,每次都要老老實實的從內存中讀取,因爲它們隨時都可能變化。這個關鍵字可能比較生僻,但千萬不要忘了它,否則一個錯誤讓你調試好幾天也得不到一點線索。



進階4:

內存管理()

 

 

作爲一個C程序員,每天都在和malloc/free/calloc/realloc系列函數打交道。也許和它們混得太熟了,反而忽略了它們的存在,甚至有了三五年的交情,仍然對它們的實現一無所知。相反,一些好奇心未泯的新手,對它們的實現有着濃厚的興趣。當初正是一個新同事的問題,促使我去研究內存管理算法的實現。

 

內存管理算法多少有些神祕,我們很少想着去實現自己的內存管理算法,這也難怪:有這樣需求的情況並不多。其實,至於內存分配算法的實現,說簡單也簡單,說複雜也複雜。要寫一個簡單的,或許半天時間就可以搞掂,而要寫一個真正實用的,可能要花上你幾周甚至幾個月的時間。

 

mallocfree是兩個核心函數,而callocrealloc之所以存在,完全是爲了提高效率的緣故。否則完全可以用mallocfree的組合來模擬它們。

 

calloc函數的實現來說,在32位機上,內存管理器保證內存至少是4字節對齊的,其長度也會擴展到能被4字節整除,那麼其清零算法就可以優化。可以一次清零4個字節,這大大提高清零速度。

 

realloc函數的實現來說,如果realloc的指針後面有足夠的空間,內存管理器可以直接擴展其大小,而無須拷貝原有內容。當然,新大小比原來還小時,更不拷貝了。相反,通過mallocfree來實現realloc時,兩種情況下都要拷貝,效率自然會低不少。

 

另外還有兩個非機標準的,但很常用的函數,也涉及到內存分配:strdupstrndup。這兩個函數在linuxwin32下都支持,非常方便。這完全可以用malloc來模擬,而且沒有性能上的損失。

 

這裏我們主要關注mallocfree兩個函數的實現,並以glibc 2.3.5(32linux)爲例分析。

 

內存管理器的目標

內存管理器爲什麼難寫?在設計內存管理算法時,要考慮什麼因素?管理內存這是內存管理器的功能需求。正如設計其它軟件一樣,質量需求一樣佔有重要的地位。分析內存管理算法之前,我們先看看對內存管理算法的質量需求有哪些:

 

l        最大化兼容性

要實現內存管理器時,先要定義出分配器的接口函數。接口函數沒有必要標新立異,而是要遵循現有標準(POSIX或者Win32),讓使用者可以平滑的過度到新的內存管理器上。

 

l        最大化可移植性

通常情況下,內存管理器要向OS申請內存,然後進行二次分配。所以,在適當的時候要擴展內存或釋放多餘的內存,這要調用OS提供的函數才行。OS提供的函數則是因平臺而異,儘量抽象出平臺相關的代碼,保證內存管理器的可移植性。

 

l        浪費最小的空間

內存管理器要管理內存,必然要使用自己一些數據結構,這些數據結構本身也要佔內存空間。在用戶眼中,這些內存空間毫無疑問是浪費掉了,如果浪費在內存管理器身的內存太多,顯然是不可以接受的。

 

內存碎片也是浪費空間的罪魁禍首,若內存管理器中有大量的內存碎片,它們是一些不連續的小塊內存,它們總量可能很大,但無法使用,這也是不可以接受的。

 

l        最快的速度

內存分配/釋放是常用的操作。按着2/8原則,常用的操作就是性能熱點,熱點函數的性能對系統的整體性能尤爲重要。

 

l        最大化可調性(以適應於不同的情況)

內存管理算法設計的難點就在於要適應用不同的情況。事實上,如果缺乏應用的上下文,是無法評估內存管理算法的好壞的。可以說在任何情況下,專用算法都比通用算法在時/空性能上的表現更優。

 

爲每種情況都寫一套內存管理算法,顯然是不太合適的。我們不需要追求最優算法,那樣代價太高,能達到次優就行了。設計一套通用內存管理算法,通過一些參數對它進行配置,可以讓它在特定情況也有相當出色的表現,這就是可調性。

 

l        最大化局部性(Locality)

大家都知道,使用cache可以提高程度的速度,但很多人未必知道cache使程序速度提高的真正原因。拿CPU內部的cacheRAM的訪問速度相比,速度可能相差一個數量級。兩者的速度上的差異固然重要,但這並不是提高速度的充分條件,只是必要條件。

 

另外一個條件是程序訪問內存的局部性(Locality)。大多數情況下,程序總訪問一塊內存附近的內存,把附近的內存先加入到cache中,下次訪問cache中的數據,速度就會提高。否則,如果程序一會兒訪問這裏,一會兒訪問另外一塊相隔十萬八千里的內存,這隻會使數據在內存與cache之間來回搬運,不但於提高速度無益,反而會大大降低程序的速度。

 

因此,內存管理算法要考慮這一因素,減少cache misspage fault

 

l        最大化調試功能

作爲一個C/C++程序員,內存錯誤可以說是我們的噩夢,上一次的內存錯誤一定還讓你記憶猶新。內存管理器提供的調試功能,強大易用,特別對於嵌入式環境來說,內存錯誤檢測工具缺乏,內存管理器提供的調試功能就更是不可或缺了。

 

l        最大化適應性

前面說了最大化可調性,以便讓內存管理器適用於不同的情況。但是,對於不同情況都要去調設置,無疑太麻煩,是非用戶友好的。要儘量讓內存管理器適用於很廣的情況,只有極少情況下才去調設置。

 

設計是一個多目標優化的過程,有些目標之間存在着競爭。如何平衡這些競爭力是設計的難點之一。在不同的情況下,這些目標的重要性又不一樣,所以根本不存在一個最好的內存分配算法。

 

關於glibc的內存分配器,我們並打算做代碼級分析,只談談幾點有趣的東西:

1.        Glibc分配算法概述:

l        小於等於64字節:用pool算法分配。

l        64512字節之間:在最佳憑配算法分配和pool算法分配中取一種合適的。

l        大於等於512字節:用最佳憑配算法分配。

l        大於等於128K:直接調用OS提供的函數(mmap)分配。

 

2.        Glibc擴展內存的方式:

 

l        int brk(void *end_data_segment);

本函數用於擴展堆空間(堆空間的定義可參考內存模型一章),用end_data_segment指明堆的結束地址。

l        void *sbrk(ptrdiff_t increment);

本函數用於擴展堆空間(堆空間的定義可參考內存模型一章),用increment指定要增加的大小。

l        void*  mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);

本函數用於分配大塊內存了,如前面所述大於128K的內存。

 

3.        空指針和零長度內存

l        free(NULL)會讓程序crash嗎?答案是不會,標準C要求free接受空指針,然後什麼也不做。

l        malloc(0)會分配成功嗎?答案是會的,它會返回一塊最小內存給你。

 

4.        對齊與取整

l        內存管理器會保證分配出來的內存地址是對齊的,通常是48字節對齊。

l        內存管理器會對要求內存長度取整,讓內存長度能被48的整除。

5.        已經分配內存的結構

 

allocate

如果前面有一塊有效內存塊的,則第一個size_t指明前一塊內存的大小。

第二個size_t指明自己的大小,同時還指明:自己是不是用mmap分配的(M),前面是否有一個效內存塊(P)。你可能覺得奇怪,在32位機上,sizeof(size_t)就是32位,怎麼還能留下兩個位來保存標誌呢?前面我們說了,會對內存長度取整,保證最低23bits0,即是空閒的。

 

6.        空閒內存的管理

 

free

由此可以看出,最小內存塊的長度爲16字節:

sizeof(size_t) +

sizeof(size_t) +

sizeof(void*) +

sizeof(void*) +

0

這一招非常管用,第一次看到時,感覺簡直太巧妙了。這使得無需要額外的內存來管理空閒塊,利用空閒塊自己,把空閒塊強制轉換成一個雙向鏈表就行了。

 

 

大內高手共享內存與線程局部存儲

  

城裏的人想出去,城外的人想進來。這是《圍城》裏的一句話,它可能比《圍城》本身更加有名。我想這句話的前提是,要麼住在城裏,要麼住在城外,二者只能居其一。否則想住在城裏就可以住在城裏,想住在城外就可以住在城外,你大可以選擇單日住在城裏,雙日住在城外,也就沒有心思去想出去還是進來了。

 

理想情況是即可以住在城裏又可以住在城外,而不是走向極端。儘管像青蛙一樣的兩棲動物絕不會比人類更高級,但能適應於更多環境的能力畢竟有它的優勢。技術也是如此,共享內存和線程局部存儲就是實例,它們是爲了防止走向內存完全隔離和完全共享兩個極端的產物。

 

當我們發明了MMU時,大家認爲天下太平了,各個進程空間獨立,互不影響,程序的穩定性將大提高。但馬上又認識到,進程完全隔離也不行,因爲各個進程之間需要信息共享。於是就搞出一種稱爲共享內存的東西。

 

當我們發明了線程的時,大家認爲這下可爽了,線程可以併發執行,創建和切換的開銷相對進程來說小多了。線程之間的內存是共享的,線程間通信快捷又方便。但馬上又認識到,有些信息還是不共享爲好,應該讓各個線程保留一點隱私。於是就搞出一個線程局部存儲的玩意兒。

 

共享內存和線程局部存儲是兩個重要又不常用的東西,平時很少用,但有時候又離不了它們。本文介紹將兩者的概念、原理和使用方法,把它們放在自己的工具箱裏,以供不時之需。

 

1.        共享內存

大家都知道進程空間是獨立的,它們之間互不影響。比如同是0xabcd1234地址的內存,在不同的進程中,它們的數據是不同的,沒有關係的。這樣做的好處很多:每個進程的地址空間變大了,它們獨佔4G(32)的地址空間,讓編程實現更容易。各個進程空間獨立,一個進程死掉了,不會影響其它進程,提高了系統的穩定性。

 

要做到進程空間獨立,光靠軟件是難以實現的,通常要依賴於硬件的幫助。這種硬件通常稱爲MMU(Memory Manage Unit),即所謂的內存管理單元。在這種體系結構下,內存分爲物理內存和虛擬內存兩種。物理內存就是實際的內存,你機器上裝了多大內存就有多大內存。而應用程序中使用的是虛擬內存,訪問內存數據時,由MMU根據頁表把虛擬內存地址轉換對應的物理內存地址。

 

MMU把各個進程的虛擬內存映射到不同的物理內存上,這樣就保證了進程的虛擬內存是獨立的。然而,物理內存往往遠遠少於各個進程的虛擬內存的總和。怎麼辦呢,通常的辦法是把暫時不用的內存寫到磁盤上去,要用的時候再加載回內存中來。一般會搞一個專門的分區保存內存數據,這就是所謂的交換分區。

 

這些工作由內核配合MMU硬件完成,內存管理是操作系統內核的重要功能。其中爲了優化性能,使用了不少高級技術,所以內存管理通常比較複雜。比如:在決定把什麼數據換出到磁盤上時,採用最近最少使用的策略,把常用的內存數據放在物理內存中,把不常用的寫到磁盤上,這種策略的假設是最近最少使用的內存在將來也很少使用。在創建進程時使用COW(Copy on Write)的技術,大大減少了內存數據的複製。爲了提高從虛擬地址到物理地址的轉換速度,硬件通常採用TLB技術,把剛轉換的地址存在cache裏,下次可以直接使用。

 

從虛擬內存到物理內存的映射並不是一個字節一個字節映射的,而是以一個稱爲頁(page)最小單位的爲基礎的,頁的大小視硬件平臺而定,通常是4K。當應用程序訪問的內存所在頁面不在物理內存中時,MMU產生一個缺頁中斷,並掛起當前進程,缺頁中斷負責把相應的數據從磁盤讀入內存中,再喚醒掛起的進程。

 

進程的虛擬內存與物理內存映射關係如下圖所示(灰色頁爲被不在物理內存中的頁):

 

mmu

 

 

也許我們很少直接使用共享內存,實際上除非性能上有特殊要求,我更願意採用socket或者管道作爲進程間通信的方式。但我們常常間接的使用共享內存,大家都知道共享庫(或稱爲動態庫)的優點是,多個應用程序可以公用。如果每個應用程序都加載一份共享庫到內存中,顯然太浪費了。所以操作系統把共享庫放在共享內存中,讓多個應用程序共享。另外,同一個應用程序運行多個實例時,也採用同樣的方式,保證內存中只有一份可執行代碼。這樣的共享內存是設爲只讀屬性的,防止應用程序無意中破壞它們。當調試器要設置斷點時,相應的頁面被拷貝一分,設置爲可寫的,再向其中寫入斷點指令。這些事情完全由操作系統等底層軟件處理了,應用程序本身無需關心。

 

共享內存是怎麼實現的呢?我們來看看下圖(黃色頁爲共享內存)

 

shmem

 

 

由上圖可見,實現共享內存非常容易,只是把兩個進程的虛擬內存映射同一塊物理內存就行了。不過要注意,物理內存相同而虛擬地址卻不一定相同,如圖中所示進程1page5和進程2page2都映射到物理內存的page1上。

 

如何在程序中使用共享內存呢?通常很簡單,操作系統或者函數庫提供了一些API給我們使用。如:

 

Linux:

 

void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);

int munmap(void *start, size_t length);

 

Win32:

 

HANDLE CreateFileMapping(
  HANDLE hFile,                       // handle to file
  LPSECURITY_ATTRIBUTES lpAttributes, // security
  DWORD flProtect,                    // protection
  DWORD dwMaximumSizeHigh,            // high-order DWORD of size
  DWORD dwMaximumSizeLow,             // low-order DWORD of size
  LPCTSTR lpName                      // object name
);
BOOL UnmapViewOfFile(
  LPCVOID lpBaseAddress   // starting address
);

 

2.        線程局部存儲(TLS)

同一個進程中的多個線程,它們的內存空間是共享的(棧除外),在一個線程修改的內存內容,對所有線程都生效。這是一個優點也是一個缺點。說它是優點,線程的數據交換變得非常快捷。說它是缺點,一個線程死掉了,其它線程也性命不保;多個線程訪問共享數據,需要昂貴的同步開銷,也容易造成同步相關的BUG;

 

unix下,大家一直都對線程不是很感興趣,直到很晚以後才引入線程這東西。像X Sever要同時處理N個客戶端的連接,每秒鐘要響應上百萬個請求,開發人員寧願自己實現調度機制也不用線程。讓人很難想象X Server是單進程單線程模型的。再如Apache(1.3x),在unix下的實現也是採用多進程模型的,把像記分板等公共信息放入共享內存中,也不願意採用多線程模型。

 

正如《unix編程藝術》中所說,線程局部存儲的出現,使得這種情況出現了轉機。採用線程局部存儲,每個線程有一定的私有空間。這可以避免部分無意的破壞,不過仍然無法避免有意的破壞行爲。

 

個人認爲,這完全是因爲unix程序不喜歡面向對象方法引起的,數據沒有很好的封裝起來,全局變量滿天飛,在多線程情況下自然容易出問題。如果採用面向對象的方法,可以讓這種情況大爲改觀,而無需要線程局部存儲來幫忙。

 

當然,多一種技術就多一種選擇,知道線程局部存儲還是有用的。儘管只用過幾次線程局部存儲的方法,在那種情況下,沒有線程局部存儲,確實很難用其它辦法實現。

 

線程局部存儲在不同的平臺有不同的實現,可移植性不太好。幸好要實現線程局部存儲並不難,最簡單的辦法就是建立一個全局表,通過當前線程ID去查詢相應的數據,因爲各個線程的ID不同,查到的數據自然也不同了。

 

大多數平臺都提供了線程局部存儲的方法,無需要我們自己去實現:

 

linux:

 

方法一:

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

int pthread_key_delete(pthread_key_t key);

void *pthread_getspecific(pthread_key_t key);

int pthread_setspecific(pthread_key_t key, const void *value);

方法二:

__thread int i;

Win32

 

方法一:

DWORD TlsAlloc(VOID);

BOOL TlsFree(

  DWORD dwTlsIndex   // TLS index

);

BOOL TlsSetValue(

  DWORD dwTlsIndex,  // TLS index

  LPVOID lpTlsValue  // value to store

);

LPVOID TlsGetValue(

  DWORD dwTlsIndex   // TLS index

);

方法二:

__declspec( thread ) int tls_i = 1;

 

~~end~~



進階5:

常見內存錯誤

 

 

 

隨着諸如代碼重構和單元測試等方法引入實踐,調試技能漸漸弱化了,甚至有人主張廢除調試器。這是有道理的,原因在於調試的代價往往太大了,特別是調試系統集成之後的BUG,一個BUG花了幾天甚至數週時間並非罕見。

 

而這些難以定位的BUG基本上可以歸爲兩類:內存錯誤和併發問題。而又以內存錯誤最爲普遍,即使是久經沙場的老手,也有時也難免落入陷阱。前事不忘,後世之師,瞭解這些常見的錯誤,在編程時就加以注意,把出錯的概率降到最低,可以節省不少時間。

 

這些列舉一些常見的內存錯誤,供新手參考。

 

1.        內存泄露。

大家都知道,在堆上分配的內存,如果不再使用了,應該把它釋放掉,以便後面其它地方可以重用。在C/C++中,內存管理器不會幫你自動回收不再使用的內存。如果你忘了釋放不再使用的內存,這些內存就不能被重用,就造成了所謂的內存泄露。

 

把內存泄露列爲首位,倒並不是因爲它有多麼嚴重的後果,而因爲它是最爲常見的一類錯誤。一兩處內存泄露通常不至於讓程序崩潰,也不會出現邏輯上的錯誤,加上進程退出時,系統會自動釋放該進程所有相關的內存,所以內存泄露的後果相對來說還是比較溫和的。當然了,量變會產生質變,一旦內存泄露過多以致於耗盡內存,後續內存分配將會失敗,程序可能因此而崩潰。

 

現在的PC機內存夠大了,加上進程有獨立的內存空間,對於一些小程序來說,內存泄露已經不是太大的威脅。但對於大型軟件,特別是長時間運行的軟件,或者嵌入式系統來說,內存泄露仍然是致命的因素之一。

 

不管在什麼情況下,採取比較謹慎的態度,杜絕內存泄露的出現,都是可取的。相反,認爲內存有的是,對內存泄露放任自流都不是負責的。儘管一些工具可以幫助我們檢查內存泄露問題,我認爲還是應該在編程時就仔細一點,及早排除這類錯誤,工具只是用作驗證的手段。

 

2.        內存越界訪問。

內存越界訪問有兩種:一種是讀越界,即讀了不屬於自己的數據,如果所讀的內存地址是無效的,程度立刻就崩潰了。如果所讀內存地址是有效的,在讀的時候不會出問題,但由於讀到的數據是隨機的,它會產生不可預料的後果。另外一種是寫越界,又叫緩衝區溢出。所寫入的數據對別人來說是隨機的,它也會產生不可預料的後果。

 

內存越界訪問造成的後果非常嚴重,是程序穩定性的致命威脅之一。更麻煩的是,它造成的後果是隨機的,表現出來的症狀和時機也是隨機的,讓BUG的現象和本質看似沒有什麼聯繫,這給BUG的定位帶來極大的困難。

 

一些工具可以夠幫助檢查內存越界訪問的問題,但也不能太依賴於工具。內存越界訪問通常是動態出現的,即依賴於測試數據,在極端的情況下才會出現,除非精心設計測試數據,工具也無能爲力。工具本身也有一些限制,甚至在一些大型項目中,工具變得完全不可用。比較保險的方法還是在編程是就小心,特別是對於外部傳入的參數要仔細檢查。

 

3.        野指針。

野指針是指那些你已經釋放掉的內存指針。當你調用free(p)時,你真正清楚這個動作背後的內容嗎?你會說p指向的內存被釋放了。沒錯,p本身有變化嗎?答案是p本身沒有變化。它指向的內存仍然是有效的,你繼續讀寫p指向的內存,沒有人能攔得住你。

 

釋放掉的內存會被內存管理器重新分配,此時,野指針指向的內存已經被賦予新的意義。對野指針指向內存的訪問,無論是有意還是無意的,都爲此會付出巨大代價,因爲它造成的後果,如同越界訪問一樣是不可預料的。

 

釋放內存後立即把對應指針置爲空值,這是避免野指針常用的方法。這個方法簡單有效,只是要注意,當然指針是從函數外層傳入的時,在函數內把指針置爲空值,對外層的指針沒有影響。比如,你在析構函數裏把this指針置爲空值,沒有任何效果,這時應該在函數外層把指針置爲空值。

 

4.        訪問空指針。

空指針在C/C++中佔有特殊的地址,通常用來判斷一個指針的有效性。空指針一般定義爲0。現代操作系統都會保留從0開始的一塊內存,至於這塊內存有多大,視不同的操作系統而定。一旦程序試圖訪問這塊內存,系統就會觸發一個異常。

 

操作系統爲什麼要保留一塊內存,而不是僅僅保留一個字節的內存呢?原因是:一般內存管理都是按頁進行管理的,無法單純保留一個字節,至少要保留一個頁面。保留一塊內存也有額外的好處,可以檢查諸如p=NULL; p[1]之類的內存錯誤。

 

在一些嵌入式系統(arm7)中,從0開始的一塊內存是用來安裝中斷向量的,沒有MMU的保護,直接訪問這塊內存好像不會引發異常。不過這塊內存是代碼段的,不是程序中有效的變量地址,所以用空指針來判斷指針的有效性仍然可行。

 

在訪問指針指向的內存時,在確保指針不是空指針。訪問空指針指向的內存,通常會導致程度崩潰,或者不可預料的錯誤。

 

5.        引用未初始化的變量。

未初始化變量的內容是隨機的(VC一類的編譯器會把它們初始化爲固定值,如0xcc),使用這些數據會造成不可預料的後果,調試這樣的BUG也是非常困難的。

 

對於態度嚴謹的程度員來說,防止這類BUG非常容易。在聲明變量時就對它進行初始化,是一個編程的好習慣。另外也要重視編譯器的警告信息,發現有引用未初始化的變量,立即修改過來。

 

6.        不清楚指針運算。

對於一些新手來說,指針常常讓他們犯糊塗。

 

比如int *p = …; p+1等於(size_t)p + 1

老手自然清楚,新手可能就搞不清了。事實上, p+n等於 (size_t)p + n * sizeof(*p)

 

指針是C/C++中最有力的武器,功能非常強大,無論是變量指針還是函數指針,都應該掌握都非常熟練。只要有不確定的地方,馬上寫個小程序驗證一下。對每一個細節都瞭然於胸,在編程時會省下不少時間。

 

7.        結構的成員順序變化引發的錯誤。

在初始化一個結構時,老手可能很少像新手那樣老老實實的,一個成員一個成員的爲結構初始化,而是採用快捷方式,如:

 

Structs

{

   int  l;

   char*p;

};

 

intmain(intargc,char*argv[])

{

   struct s s1 = {4, "abcd"};

   return 0;

}

 

以上這種方式是非常危險的,原因在於你對結構的內存佈局作了假設。如果這個結構是第三方提供的,他很可能調整結構中成員的相對位置。而這樣的調整往往不會在文檔中說明,你自然很少去關注。如果調整的兩個成員具有相同數據類型,編譯時不會有任何警告,而程序的邏輯上可能相距十萬八千里了。

 

正確的初始化方法應該是(當然,一個成員一個成員的初始化也行):

 

structs

{

   int  l;

   char*p;

};

 

intmain(intargc,char*argv[])

{

   struct s s1 = {.l=4, .p= "abcd"};

   struct s s2 = {l:4,p:"abcd"};

 

   return 0;

}

 

8.        結構的大小變化引發的錯誤。

我們看看下面這個例子:

 

structbase

{

   int n;

};

 

structs

{

   struct base b;

   int m;

};

 

OOP中,我們可以認爲第二個結構繼承了第一結構,這有什麼問題嗎?當然沒有,這是C語言中實現繼承的基本手法。

 

現在假設第一個結構是第三方提供的,第二個結構是你自己的。第三方提供的庫是以DLL方式分發的,DLL最大好處在於可以獨立替換。但隨着軟件的進化,問題可能就來了。

 

當第三方在第一個結構中增加了一個新的成員int k;,編譯好後把DLL給你,你直接給了客戶了。程序加載時不會有任何問題,在運行邏輯可能完全改變!原因是兩個結構的內存佈局重疊了。解決這類錯誤的唯一辦法就是全部重新相關的代碼。

 

解決這類錯誤的唯一辦法就是重新編譯全部代碼。由此看來,DLL並不見得可以動態替換,如果你想了解更多相關內容,建議閱讀《COM本質論》。

      

9.        分配/釋放不配對。

大家都知道malloc要和free配對使用,new要和delete/delete[]配對使用,重載了類new操作,應該同時重載類的delete/delete[]操作。這些都是書上反覆強調過的,除非當時暈了頭,一般不會犯這樣的低級錯誤。

 

而有時候我們卻被矇在鼓裏,兩個代碼看起來都是調用的free函數,實際上卻調用了不同的實現。比如在Win32下,調試版與發佈版,單線程與多線程是不同的運行時庫,不同的運行時庫使用的是不同的內存管理器。一不小心鏈接錯了庫,那你就麻煩了。程序可能動則崩潰,原因在於在一個內存管理器中分配的內存,在另外一個內存管理器中釋放時出現了問題。

 

10.    返回指向臨時變量的指針

大家都知道,棧裏面的變量都是臨時的。當前函數執行完成時,相關的臨時變量和參數都被清除了。不能把指向這些臨時變量的指針返回給調用者,這樣的指針指向的數據是隨機的,會給程序造成不可預料的後果。

 

下面是個錯誤的例子:

 

char* get_str(void)

{

   char str[] = {"abcd"};

 

   return str;

}

 

intmain(intargc, char* argv[])

{

   char*p = get_str();

 

   printf("%s\n",p);

 

   return 0;

}

 

 

下面這個例子沒有問題,大家知道爲什麼嗎?

 

char*get_str(void)

{

   char*str = {"abcd"};

 

   return str;

}

 

intmain(intargc,char*argv[])

{

   char*p = get_str();

 

   printf("%s\n",p);

 

   return 0;

}

 

11.    試圖修改常量

在函數參數前加上const修飾符,只是給編譯器做類型檢查用的,編譯器禁止修改這樣的變量。但這並不是強制的,你完全可以用強制類型轉換繞過去,一般也不會出什麼錯。

 

而全局常量和字符串,用強制類型轉換繞過去,運行時仍然會出錯。原因在於它們是是放在.rodata裏面的,而.rodata內存頁面是不能修改的。試圖對它們修改,會引發內存錯誤。

 

下面這個程序在運行時會出錯:

 

intmain(intargc,char*argv[])

{

   char*p = "abcd";

 

    *p= '1';

 

   return 0;

}

 

 

12.    誤解傳值與傳引用

C/C++中,參數默認傳遞方式是傳值的,即在參數入棧時被拷貝一份。在函數裏修改這些參數,不會影響外面的調用者。如:

 

 

#include <stdlib.h>

#include <stdio.h>

 

void get_str(char* p)

{

    p = malloc(sizeof("abcd"));

    strcpy(p, "abcd");

 

    return;

}

 

int main(int argc, char* argv[])

{

    char* p = NULL;

 

    get_str(p);

 

    printf("p=%p\n", p);

 

    return 0;

}

 

main函數裏,p的值仍然是空值。

 

13.    重名符號。

無論是函數名還是變量名,如果在不同的作用範圍內重名,自然沒有問題。但如果兩個符號的作用域有交集,如全局變量和局部變量,全局變量與全局變量之間,重名的現象一定要堅決避免。gcc有一些隱式規則來決定處理同名變量的方式,編譯時可能沒有任何警告和錯誤,但結果通常並非你所期望的。

 

下面例子編譯時就沒有警告:

t.c

 

#include<stdlib.h>

#include<stdio.h>

 

intcount = 0;

 

intget_count(void)

{

   return count;

}

 

 

main.c

 

#include <stdio.h>

 

extern int get_count(void);

 

int count;

 

int main(int argc, char* argv[])

{

    count = 10;

 

    printf("get_count=%d\n", get_count());

 

    return 0;

}

 

如果把main.c中的int count;修改爲int count = 0;gcc就會編輯出錯,說multiple definition of `count'。它的隱式規則比較奇妙吧,所以還是不要依賴它爲好。

 

14.    棧溢出。

我們在前面關於堆棧的一節講過,在PC上,普通線程的棧空間也有十幾M,通常夠用了,定義大一點的臨時變量不會有什麼問題。

 

而在一些嵌入式中,線程的棧空間可能只5K大小,甚至小到只有256個字節。在這樣的平臺中,棧溢出是最常用的錯誤之一。在編程時應該清楚自己平臺的限制,避免棧溢出的可能。

 

15.    誤用sizeof

儘管C/C++通常是按值傳遞參數,而數組則是例外,在傳遞數組參數時,數組退化爲指針(即按引用傳遞),用sizeof是無法取得數組的大小的。

 

從下面這個例子可以看出:

 

voidtest(charstr[20])

{

   printf("%s:size=%d\n", __func__, sizeof(str));

}  

 

intmain(intargc,char*argv[])

{

   char str[20]  = {0};

 

   test(str);

 

   printf("%s:size=%d\n", __func__, sizeof(str));

   

   return 0;

}

[root@localhost mm]# ./t.exe

test:size=4

main:size=20

 

16.    字節對齊。

字節對齊主要目的是提高內存訪問的效率。但在有的平臺(arm7)上,就不光是效率問題了,如果不對齊,得到的數據是錯誤的。

 

所幸的是,大多數情況下,編譯會保證全局變量和臨時變量按正確的方式對齊。內存管理器會保證動態內存按正確的方式對齊。要注意的是,在不同類型的變量之間轉換時要小心,如把char*強制轉換爲int*時,要格外小心。

 

另外,字節對齊也會造成結構大小的變化,在程序內部用sizeof來取得結構的大小,這就足夠了。若數據要在不同的機器間傳遞時,在通信協議中要規定對齊的方式,避免對齊方式不一致引發的問題。

 

17.    字節順序。

字節順序歷來是設計跨平臺軟件時頭疼的問題。字節順序是關於數據在物理內存中的佈局的問題,最常見的字節順序有兩種:大端模式與小端模式。

 

大端模式是高位字節數據存放在低地址處,低位字節數據存放在高地址處。

小端模式指低位字節數據存放在內存低地址處,高位字節數據存放在內存高地址處;

 

      比如long n = 0x11223344

      

 

模式

1個字節

2個字節

3個字節

4個字節

大端模式

0x11

0x22

0x33

0x44

小端模式

0x44

0x33

0x22

0x11

 

在普通軟件中,字節順序問題並不引人注目。而在開發與網絡通信和數據交換有關的軟件時,字節順序問題就要特殊注意了。

 

18.    多線程共享變量沒有用valotile修飾。

在關於全局內存的一節中,我們講了valotile的作用,它告訴編譯器,不要把變量優化到寄存器中。在開發多線程併發的軟件時,如果這些線程共享一些全局變量,這些全局變量最好用valotile修飾。這樣可以避免因爲編譯器優化而引起的錯誤,這樣的錯誤非常難查。

 

可能還有其它一些內存相關錯誤,一時想不全面,這裏算是拋磚引玉吧,希望各位高手補充。





進階6:

POSA》中根據模式粒度把模式分爲三類:架構模式、設計模式和慣用手法。其中把分層模式、管道過濾器和微內核模式等歸爲架構模式,把代理模式、命令模式和出版-訂閱模式等歸爲設計模式,而把引用計數等歸爲慣用手法。這三類模式間的界限比較模糊,在特定的情況,有的設計模式可以作爲架構模式來用,有的把架構模式也作爲設計模式來用。

 

在通常情況下,我們可以說架構模式、設計模式和慣用手法,三者的重要性依次遞減,畢竟整體決策比局部決策的影響面更大。但是任何整體都是局部組成的,局部的決策也會影響全局。慣用手法的影響雖然是局部的,其作用仍然很重要。它不但在提高軟件的質量方面,而且在加快軟件開發進度方面都有很大貢獻。本文介紹幾種關於內存的慣用手法,這些手法對於老手來說已經習以爲常,對於新手來說則是必修祕技。

 

1.        預分配

假想我們實現了一個動態數組(vector)時,當向其中增加元素時,它會自動擴展(縮減)緩衝區的大小,無需要調用者關心。擴展緩衝區的大小的原理都是一樣的:

 

l        先分配一塊更大的緩衝區。

l        把數據從老的緩衝區拷貝到新的緩衝區。

l        釋放老的緩衝區。

 

如果你使用realloc來實現,內存管理器可能會做些優化:如果老的緩衝區後面有連續的空閒空間,它只需要簡單的擴展老的緩衝區,而跳過後面兩個步驟。但在大多數情況下,它都要通過上述三個步驟來完成擴展。

 

以此可見,擴展緩衝區對調用者來說雖然是透明的,但決不是免費的。它得付出相當大的時間代價,以及由此產生的產生內存碎片問題。如果每次向vector中增加一個元素,都要擴展緩衝區,顯然是不太合適的。

 

此時我們可以採用預分配機制,每次擴展時,不是需要多大就擴展多大,而是預先分配一大塊內存。這一大塊可以供後面較長一段時間使用,直到把這塊內存全用完了,再繼續用同樣的方式擴展。

 

預分配機制比較常見,多見於一些帶buffer的容器實現中,比如像vectorstring等。

 

2.        對象引用計數

在面向對象的系統中,對象之間的協作關係非常複雜。所謂協作其實就調用對象的函數或者向對象發送消息,但不管調用函數還是發送消息,總是要通過某種方式知道目標對象才行。而最常見的做法就是保存目標對象的引用(指針),直接引用對象而不是拷貝對象,提高了時間和空間上的效率,也避免了拷貝對象的麻煩,而且有的地方就是要對象共享才行。

 

對象被別人引用了,但自己可能並不知道。此時麻煩就來了,如果對象被釋放了,對該對象的引用就變成了野針,系統隨時可能因此而崩潰。不釋放也不行,因爲那樣會出現內存泄露。怎麼辦呢?

 

此時我們可以採用對象引用計數,對象有一個引用計數器,不管誰要引用這個對象,就要把對象的引用計數器加1,如果不再該引用了,就把對象的引用計數器減1。當對象的引用計數器被減爲0時,說明沒有其它對象引用它,該對象就可以安全的釋放了。這樣,對象的生命週期就得到了有效的管理。

 

對象引用計數運用相當廣泛。像在COMglib裏,都是作爲對象系統的基本設施之一。即使在像JAVAC#等現代語言中,對象引用計數也是非常重要的,它是實現垃圾回收(GC)的基本手段之一。

 

代碼示例: (atlcom.h: CcomObject)

 

        STDMETHOD_(ULONG,AddRef)() {returnInternalAddRef();}

        STDMETHOD_(ULONG,Release)()

         {

                  ULONG l = InternalRelease();

                  if (l == 0)

                           delete this;

                  return l;

         }

 

3.        寫時拷貝(COW)

OS內核創建子進程的過程是最常見而且最有效的COW例子:創建子進程時,子進程要繼承父進程內存空間中的數據。但繼承之後,兩者各自有獨立的內存空間,修改各自的數據不會互相影響。

 

要做到這一點,最簡單的辦法就是直接把父進程的內存空間拷貝一份。這樣做可行,但問題在於拷貝內容太多,無論是時間還是空間上的開銷都讓人無法接受。況且,在大多數情況下,子進程只會使用少數繼承過來的數據,而且多數是讀取,只有少量是修改,也就說大部分拷貝的動作白做了。怎麼辦呢?

 

此時可以採用寫時拷貝(COW)COW代表Copy on Write。最初的拷貝只是個假象,並不是真正的拷貝,只是把引用計數加1,並設置適當的標誌。如果雙方都只是讀取這些數據,那好辦,直接讀就行了。而任何一方要修改時,爲了不影響另外一方,它要把數據拷貝一份,然後修改拷貝的這一份。也就是說在修改數據時,拷貝動作才真正發生。

 

當然,在真正拷貝的時候,你可以選擇只拷貝修改的那一部分,或者拷貝全部數據。在上面的例子中,由於內存通常是按頁面來管理的,拷貝時只拷貝相關的頁面,而不是拷貝整個內存空間。

 

寫時拷貝(COW)對性能上的貢獻很大,差不多任何帶MMUOS都會採用。當然它不限於內核空間,在用戶空間也可以使用,比如像一些String類的實現也採用了這種方法。

 

代碼示例(MFC:strcore.cpp)

拷貝時只是增加引用計數:

 

CString::CString(constCString&stringSrc)

{

        ASSERT(stringSrc.GetData()->nRefs!= 0);

        if (stringSrc.GetData()->nRefs>= 0)

         {

                  ASSERT(stringSrc.GetData() != _afxDataNil);

                  m_pchData = stringSrc.m_pchData;

                  InterlockedIncrement(&GetData()->nRefs);

         }

        else

         {

                  Init();

                   *this = stringSrc.m_pchData;

         }

}

 

修改前才拷貝:

 

voidCString::MakeUpper()

{

        CopyBeforeWrite();

        _tcsupr(m_pchData);

}

 

voidCString::MakeLower()

{

        CopyBeforeWrite();

        _tcslwr(m_pchData);

}

 

 

拷貝動作:

 

voidCString::CopyBeforeWrite()

{

        if (GetData()->nRefs> 1)

         {

                  CStringData*pData = GetData();

                  Release();

                  AllocBuffer(pData->nDataLength);

                  memcpy(m_pchData,pData->data(), (pData->nDataLength+1)*sizeof(TCHAR));

         }

        ASSERT(GetData()->nRefs<= 1);

}

 

 

4.        固定大小分配

頻繁的分配大量小塊內存是內存管理器的挑戰之一。

 

首先是空間利用率上的問題:由於內存管理本身的需要一些輔助內存,假設每塊內存需要8字節用作輔助內存,那麼即使只要分配4個字節這樣的小塊內存,仍然要浪費8字節內存。一塊小內存不要緊,若存在大量小塊內存,所浪費的空間就可觀了。

 

其次是內存碎片問題:頻繁分配大量小塊內存,很容易造成內存碎片問題。這不但降低內存管理器的效率,同時由於這些內存不連續,雖然空閒卻無法使用。

 

此時可以採用固定大小分配,這種方式通常也叫做緩衝池(pool)分配。緩衝池(pool)先分配一塊或者多塊連續的大塊內存,把它們分成N塊大小相等的小塊內存,然後進行二次分配。由於這些小塊內存大小是固定的,管理大開銷非常小,往往只要一個標識位用於標識該單元是否空閒,或者甚至不需要任何標識位。另外,緩衝池(pool)中所有這些小塊內存分佈在一塊或者幾塊連接內存上,所以不會有內存碎片問題。

 

固定大小分配運用比較廣泛,差不多所有的內存管理器都用這種方法來對付小塊內存,比如glibcSTLPortlinuxslab等。

 

5.        會話緩衝池分配(Session Pool)

服務器要長時間運行,內存泄露是它的威脅之一,任何小概率的內存泄露,都可能會累積到具有破壞性的程度。從它們的運行模式來看,它們總是不斷的重複某個過程,而在這個過程中,又要分配大量(次數)內存。

 

比如像WEB服務器,它不斷的處理HTTP請求,我們把一次HTTP請求,稱爲一次會話。一次會話要經過很多階段,在這個過程要做各種處理,要多次分配內存。由於處理比較複雜,分配內存的地方又比較多,內存泄露可以說防不甚防。

 

針對這種情況,我們可以採用會話緩衝池分配。它基於多次分配一次釋放的策略,在過程開始時創建會話緩衝池(Session Pool),這個過程中所有內存分配都通過會話緩衝池(Session Pool)來分配,當這個過程完成時,銷燬掉會話緩衝池(Session Pool),即釋放這個過程中所分配的全部內存。

 

因爲只需要釋放一次,內存泄露的可能大大降低。會話緩衝池分配並不是太常見,apache採用的這種用法。後來自己用過兩次,感覺效果不錯。

      

當然還有其一些內存慣用手法,如cache等,這裏不再多說。上述部分手法在《實時設計模式》裏有詳細的描述,大家可以參考一下。



進階7:

調試手段及原理

 

 

 

知其然也知其所以然,是我們《大內高手》系列一貫做法,本文亦是如此。這裏我不打算講解如何使用boundscheckerpurifyvalgrind或者gdb,使用這些工具非常簡單,講解它們只是多此一舉。相反,我們要研究一下這些工具的實現原理。

 

本文將從應用程序、編譯器和調試器三個層次來講解,在不同的層次,有不同的方法,這些方法有各自己的長處和侷限。瞭解這些知識,一方面滿足一下新手的好奇心,另一方面也可能有用得着的時候。

 

從應用程序的角度

 

最好的情況是從設計到編碼都紮紮實實的,避免把錯誤引入到程序中來,這纔是解決問題的根本之道。問題在於,理想情況並不存在,現實中存在着大量有內存錯誤的程序,如果內存錯誤很容易避免,JAVA/C#的優勢將不會那麼突出了。

 

對於內存錯誤,應用程序自己能做的非常有限。但由於這類內存錯誤非常典型,所佔比例非常大,所付出的努力與所得的回報相比是非常划算的,仍然值得研究。

 

前面我們講了,堆裏面的內存是由內存管理器管理的。從應用程序的角度來看,我們能做到的就是打內存管理器的主意。其實原理很簡單:

 

對付內存泄露。重載內存管理函數,在分配時,把這塊內存的記錄到一個鏈表中,在釋放時,從鏈表中刪除吧,在程序退出時,檢查鏈表是否爲空,如果不爲空,則說明有內存泄露,否則說明沒有泄露。當然,爲了查出是哪裏的泄露,在鏈表還要記錄是誰分配的,通常記錄文件名和行號就行了。

 

對付內存越界/野指針。對這兩者,我們只能檢查一些典型的情況,對其它一些情況無能爲力,但效果仍然不錯。其方法如下(源於《Comparing and contrasting the runtime error detection technologies)

 

l        首尾在加保護邊界值

 

Header

Leading guard(0xFC)

User data(0xEB)

Tailing guard(0xFC)

 

在內存分配時,內存管理器按如上結構填充分配出來的內存。其中Header是管理器自己用的,前後各有幾個字節的guard數據,它們的值是固定的。當內存釋放時,內存管理器檢查這些guard數據是否被修改,如果被修改,說明有寫越界。

 

它的工作機制註定了有它的侷限性:只能檢查寫越界,不能檢查讀越界,而且只能檢查連續性的寫越界,對於跳躍性的寫越界無能爲力。

 

l        填充空閒內存

 

空閒內存(0xDD)

 

內存被釋放之後,它的內容填充成固定的值。這樣,從指針指向的內存的數據,可以大致判斷這個指針是否是野指針。

 

它同樣有它的侷限:程序要主動判斷才行。如果野指針指向的內存立即被重新分配了,它又被填充成前面那個結構,這時也無法檢查出來。

 

從編譯器的角度

 

boundscheckerpurify的實現都可以歸於編譯器一級。前者採用一種稱爲CTI(compile-time instrumentation)的技術。VC的編譯不是要分幾個階段嗎?boundschecker在預處理和編譯兩個階段之間,對源文件進行修改。它對所有內存分配釋放、內存讀寫、指針賦值和指針計算等所有內存相關的操作進行分析,並插入自己的代碼。比如:

 

Before

    if (m_hsession) gblHandles->ReleaseUserHandle( m_hsession );

    if (m_dberr) delete m_dberr;

 

After

    if (m_hsession) {

        _Insight_stack_call(0);

        gblHandles->ReleaseUserHandle(m_hsession);

        _Insight_after_call();

    }

 

    _Insight_ptra_check(1994, (void **) &m_dberr, (void *) m_dberr);

    if (m_dberr) {

        _Insight_deletea(1994, (void **) &m_dberr, (void *) m_dberr, 0);

        delete m_dberr;

    }

 

Purify則採用一種稱爲OCI(object code insertion)的技術。不同的是,它對可執行文件的每條指令進行分析,找出所有內存分配釋放、內存讀寫、指針賦值和指針計算等所有內存相關的操作,用自己的指令代替原始的指令。

 

boundscheckerpurify是商業軟件,它們的實現是保密的,甚至擁有專利的,無法對其研究,只能找一些皮毛性的介紹。無論是CTI還是OCI這樣的名稱,多少有些神祕感。其實它們的實現原理並不複雜,通過對valgrindgccbounds checker擴展進行一些粗淺的研究,我們可以知道它們的大致原理。

 

gccbounds checker基本上可以與boundschecker對應起來,都是對源代碼進行修改,以達到控制內存操作功能,如malloc/free等內存管理函數、memcpy/strcpy/memset等內存讀取函數和指針運算等。Valgrind則與Purify類似,都是通過對目標代碼進行修改,來達到同樣的目的。

 

Valgrind對可執行文件進行修改,所以不需要重新編譯程序。但它並不是在執行前對可執行文件和所有相關的共享庫進行一次性修改,而是和應用程序在同一個進程中運行,動態的修改即將執行的下一段代碼。

 

Valgrind是插件式設計的。Core部分負責對應用程序的整體控制,並把即將修改的代碼,轉換成一種中間格式,這種格式類似於RISC指令,然後把中間代碼傳給插件。插件根據要求對中間代碼修改,然後把修改後的結果交給corecore接下來把修改後的中間代碼轉換成原始的x86指令,並執行它。

 

由此可見,無論是boundscheckerpurifygccbounds checker,還是Valgrind,修改源代碼也罷,修改二進制也罷,都是代碼進行修改。究竟要修改什麼,修改成什麼樣子呢?別急,下面我們就要來介紹:

 

管理所有內存塊。無論是堆、棧還是全局變量,只要有指針引用它,它就被記錄到一個全局表中。記錄的信息包括內存塊的起始地址和大小等。要做到這一點並不難:對於在堆裏分配的動態內存,可以通過重載內存管理函數來實現。對於全局變量等靜態內存,可以從符號表中得到這些信息。

 

攔截所有的指針計算。對於指針進行乘除等運算通常意義不大,最常見運算是對指針加減一個偏移量,如++pp=p+np=a[n]等。所有這些有意義的指針操作,都要受到檢查。不再是由一條簡單的彙編指令來完成,而是由一個函數來完成。

 

有了以上兩點保證,要檢查內存錯誤就非常容易了:比如要檢查++p是否有效,首先在全局表中查找p指向的內存塊,如果沒有找到,說明p是野指針。如果找到了,再檢查p+1是否在這塊內存範圍內,如果不是,那就是越界訪問,否則是正常的了。怎麼樣,簡單吧,無論是全局內存、堆還是棧,無論是讀還是寫,無一能夠逃過出工具的法眼。

 

代碼賞析(源於tcc)

對指針運算進行檢查:

 

void*__bound_ptr_add(void*p,int offset)

{

   unsigned long addr = (unsigned long)p;

   BoundEntry *e;

#ifdefined(BOUND_DEBUG)

   printf("add: 0x%x %d\n", (int)p,offset);

#endif

 

   e = __bound_t1[addr>> (BOUND_T2_BITS+ BOUND_T3_BITS)];

   e = (BoundEntry*)((char*)e+

                       ((addr >> (BOUND_T3_BITS- BOUND_E_BITS)) &

                        ((BOUND_T2_SIZE- 1) << BOUND_E_BITS)));

   addr -= e->start;

   if (addr > e->size) {

       e = __bound_find_region(e,p);

       addr = (unsigned long)p- e->start;

    }

   addr += offset;

   if (addr > e->size)

       return INVALID_POINTER;

   return p + offset;

}

staticvoid __bound_check(constvoid *p,size_t size)

{

   if (size == 0)

       return;

   p = __bound_ptr_add((void*)p,size);

   if (p == INVALID_POINTER)

       bound_error("invalid pointer");

}

 

 

重載內存管理函數:

 

void*__bound_malloc(size_tsize,const void *caller)

{

   void *ptr;

   

   

   ptr= libc_malloc(size+ 1);

   

   if (!ptr)

       return NULL;

   __bound_new_region(ptr,size);

   return ptr;

}

void__bound_free(void*ptr,const void *caller)

{

   if (ptr == NULL)

       return;

   if (__bound_delete_region(ptr) != 0)

       bound_error("freeing invalid region");

 

   libc_free(ptr);

}

 

 

重載內存操作函數:

 

void*__bound_memcpy(void*dst,const void *src,size_t size)

{

   __bound_check(dst,size);

   __bound_check(src,size);

   

   if (src >= dst && src < dst + size)

       bound_error("overlapping regions in memcpy()");

   return memcpy(dst,src,size);

}

 

從調試器的角度

 

現在有OS的支持,實現一個調試器變得非常簡單,至少原理不再神祕。這裏我們簡要介紹一下win32linux中的調試器實現原理。

 

Win32下,實現調試器主要通過兩個函數:WaitForDebugEventContinueDebugEvent。下面是一個調試器的基本模型(源於:Debugging Applications for Microsoft .NET and Microsoft Windows)

 

 

voidmain ( void )

{

   CreateProcess ( ..., DEBUG_ONLY_THIS_PROCESS,... ) ;

                                                                           

    while ( 1 == WaitForDebugEvent( ... ) )

    {

       if ( EXIT_PROCESS )

        {

           break ;

        }

       ContinueDebugEvent ( ... ) ;

   }

}

 

由調試器起動被調試的進程,並指定DEBUG_ONLY_THIS_PROCESS標誌。按Win32下事件驅動的一貫原則,由被調試的進程主動上報調試事件,調試器然後做相應的處理。

 

linux下,實現調試器只要一個函數就行了:ptrace。下面是個簡單示例:(源於《Playing with ptrace)

 

#include<sys/ptrace.h>

#include<sys/types.h>

#include<sys/wait.h>

#include<unistd.h>

#include<linux/user.h>  

intmain(intargc,char *argv[])

{   pid_ttraced_process;

   struct user_regs_struct regs;

   long ins;

   if(argc!= 2) {

       printf("Usage: %s <pid to be traced>\n",

              argv[0],argv[1]);

       exit(1);

    }

   traced_process = atoi(argv[1]);

    ptrace(PTRACE_ATTACH,traced_process,

          NULL,NULL);

    wait(NULL);

    ptrace(PTRACE_GETREGS,traced_process,

          NULL, &regs);

   ins = ptrace(PTRACE_PEEKTEXT, traced_process,

                regs.eip,NULL);

   printf("EIP: %lx Instruction executed: %lx\n",

          regs.eip,ins);

    ptrace(PTRACE_DETACH,traced_process,

          NULL,NULL);

   return 0;

}

 

由於篇幅有限,這裏對於調試器的實現不作深入討論,主要是給新手指一個方向。以後若有時間,再寫個專題來介紹linux下的調試器和ptrace本身的實現方法。





 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章