程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> 關於C語言 >> 程序設計基石與實踐系列之類型提升、內存分配,數組轉指針、打樁和矢量變換

程序設計基石與實踐系列之類型提升、內存分配,數組轉指針、打樁和矢量變換

編輯:關於C語言

程序設計基石與實踐系列之類型提升、內存分配,數組轉指針、打樁和矢量變換


C語言可用於系統編程、嵌入式系統中,同時也是其他應用程序可能的實現工具之一。當你對計算機編程懷有強烈興趣的時候,卻對C語言不感冒,這種可能性不大。想全方位地理解C語言是一件極具挑戰性的事。

Peter Fa?ka 在2014年1月份寫下了這篇長文,內容包括:類型提升、內存分配,數組轉指針、顯式內聯、打樁(interpositioning)和矢量變換。

整型溢出和類型提升

多數C程序員以為,整型間的基本操作都是安全的。事實上,整型間基本操作也容易出現問題,例如下面的代碼:

 

int main(int argc, char** argv) {
    long i = -1;

    if (i < sizeof(i)) {
         printf("OK\n");
    }
    else {
         printf("error\n");
    }

    return 0;
}
上述代碼中,變量i被轉換為無符號整型。這樣一來,它的值不再是-1,而是size_t的最大值。變量i的類型之所以被轉換,是因為sizeof操作符的返回類型是無符號的。具體參見C99/C11標准之常用算術轉換一章:

 

“If the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type.”

若無符號整型類型的操作數的轉換優先級不低於另一操作數,則有符號數轉為無符號數的類型。

C標准中,size_t被定義為不低於16位的無符號整型。通常size_t完全對應於long。這樣一來,intsize_t的大小至少相等,可基於上述准則,強轉為無符號整型。(譯者注:本人印象深刻的相關問題是“if(-1U > 0L)”在32、64位機器上的判斷結果分別是什麼,為什麼;除long long外,long 類型在涉及兼容性的產品代碼中應被禁用)

這個故事給了我們一個關於整型大小可移植性的觀念。C標准並未定義shortintlonglong long 的確切大小及其無符號形式。標准僅限定了它們的最小長度。以x86_64架構為例,long在Linux環境中是64比特,但在64位Windows系統中是32比特。為了使代碼更具移植性,常見的方法是使用C99的stdint.h文件中定義的、指定長度的特殊類型,包括uint16_tint32_t等。此文件定義了三種整型類型:

有確切長度的:uint8_t uint16_t,int32_t等有長度最小值的最短類型:uint_least8_t,uint_least16_t,int_least32_t等執行效率最高的有長度最小值的類型:uint_fast8_t,uint_fast16_t,int_fast32_t等

但不幸的是,僅依靠stdint.h並不能根除類型轉換的困擾。C標准中“整型提升規則”中寫道:

若int的表達范圍足以覆蓋所有的基礎類型,此值將被轉換為int;否則將轉為unsignedint。這就叫做整型提升。整型提升過程中,所有其他的類型保持不變。

下述代碼在32位平台中將返回65536,在16位平台上返回0:

 

uint32_t sum()
{
    uint16_t a = 65535;
    uint16_t b = 1;
    return a+b;
}
無論C語言實現中,是否把未修飾的char看做有符號的,整型提升都連同符號一起把值保留下來。

 

如何實現char類型通常取決於硬件體系或操作系統,常由其平台的ABI(應用程序二進制接口)指定。如果你願意自己嘗試的話,char會被轉為signed char,下述代碼將打印出-128和-127,而不是128和129。x86架構中可用GCC的-funsigned-char參數切換到強制無符號提升。

 

char c = 128;
char d = 129;
printf("%d,%d\n",c,d);

 

內存申請和管理

malloc, calloc, realloc, free

 

使用malloc分配指定字節大小的、未初始化的內存對象。若入參值為0,其行為取決於操作系統實現,或者說,這是C和POSIX標准均未定義的行為。

若請求的空間大小為0,則結果視具體實現而定:返回值可以是空指針或特殊指針。

malloc(0)通常返回有效的特殊指針。或者返回的值可成為free函數的參數,且函數不會錯誤退出。例如free函數對NULL指針不做任何操作。

因此,若空間大小參數是某個表達式的結果的話,要確保測試過整型溢出的情況。

 

size_t computed_size;

if (elem_size && num > SIZE_MAX / elem_size) {
    errno = ENOMEM;
    err(1, "overflow");
}

computed_size = elem_size*num;
一般說來,要分配一個元素大小相同的序列,可考慮使用calloc而非用表達式計算大小。同時calloc將把分配的內存初始化為0。像往常一樣使用free釋放分配的內存。

 

realloc將改變已分配內存對象的大小。此函數返回一個指針,指針可能指向新的內存起始位置,內存大小取決於入參中請求的空間大小,內容不變。若新的空間更大,額外的空間未被初始化。若realloc入參中,指向舊對象的指針為NULL,並且大小非0,此行為等價於malloc。若新的大小為0,且提供的指針非空,此時realloc的行為依賴於操作系統。

多數實現將嘗試釋放對象內存,返回NULL或與malloc(0)相同的返回值。例如在Windows中,此操作會釋放內存並返回NULL。OpenBSD也會釋放內存,但返回的指針指向的空間大小為0。

realloc失敗時會返回NULL,也因此斷開與舊的內存對象的關聯。所以不但要檢查空間大小參數是否存在整型溢出,還要正確處理realloc失敗時的對象大小。

 

#include 
#include 
#include 
#include 

#define VECTOR_OK            0
#define VECTOR_NULL_ERROR    1
#define VECTOR_SIZE_ERROR    2
#define VECTOR_ALLOC_ERROR   3

struct vector {
    int *data;
    size_t size;
};

int create_vector(struct vector *vc, size_t num) {

    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }

    vc->data = 0;
    vc->size = 0;

    /* check for integer and SIZE_MAX overflow */
    if (num == 0 || SIZE_MAX / num < sizeof(int)) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }

    vc->data = calloc(num, sizeof(int));

    /* calloc faild */
    if (vc->data == NULL) {
        return VECTOR_ALLOC_ERROR;
    }

    vc->size = num * sizeof(int);
    return VECTOR_OK;
}

int grow_vector(struct vector *vc) {

    void *newptr = 0;
    size_t newsize;

    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }


    /* check for integer and SIZE_MAX overflow */
    if (vc->size == 0 || SIZE_MAX / 2 < vc->size) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }

    newsize = vc->size * 2;

    newptr = realloc(vc->data, newsize);

    /* realloc faild; vector stays intact size was not changed */
    if (newptr == NULL) {
        return VECTOR_ALLOC_ERROR;
    }

    /* upon success; update new address and size */
    vc->data = newptr;
    vc->size = newsize;
    return VECTOR_OK;
}

 

避免致命錯誤

 

一般避免動態內存分配問題的方法無非是盡可能把代碼寫得謹慎、有防御性。本文列舉了一些常見問題和少量避免這些問題的方法。

1) 重復釋放內存

調用free可能導致此問題,此時入參指針可能為NULL(依照《C++ Primer Plus》,free(0)不會出現問題。譯者注)、未使用malloc類函數分配的指針,或已經調用過free/realloc(realloc參數中大小填0,可釋放內存。譯者注)的指針。考慮下列幾點可讓代碼更健壯:

指針初始化為NULL,以防不能立即傳給它有效值的情況GCC和Clang都有-Wuninitialized參數來警告未初始化的變量靜態和動態分配的內存不要用同一個變量調用free後要把指針設回為NULL,這樣一來即便無意中重復釋放也不會導致錯誤測試或調試時使用assert之類的斷言(如C11中靜態斷言,譯者注)
char *ptr = NULL;

/* ... */

void nullfree(void **pptr) {
    void *ptr = *pptr;
    assert(ptr != NULL)
    free(ptr);
    *pptr = NULL;
}

2) 訪問未初始化的內存或空指針 代碼中的檢查規則應只用於NULL或有效的指針。對於去除指針和分配的動態內存間聯系的函數或代碼塊,可在開頭檢查空指針。

3) 越界訪問內存

(PS:你能說出strcpy/strncpy/strlcpy的區別麼,能的話這節就不必看)

訪問內存對象邊界之外的地方並不一定導致程序崩潰。程序可能使用損壞了的數據繼續運行,其行為可能很危險,也可能是故意而為之,利用此越界操作來改變程序的行為,以此獲取其他受限的數據,甚至注入可執行代碼。 老套地人工檢查數組和動態分配內存的邊界是避免此類問題的主要方法。內存對象邊界的相關信息必須人工跟蹤。數組的大小可由sizeof操作符指出,但數組被轉換為指針後,函數調用sizeof僅返回指針大小(視機器位數而定,譯者注),而非原來的數組大小。

C11標准中邊界檢查接口Annex K定義了一些新的庫函數集合,這些函數可用於替換標准庫(如字符串和I/O操作)常見部分,它們更安全、更易於使用。例如[the slibc library][slibc]都是上述函數的開源實現,但接口不被廣泛采用。基於BSD(或基於Mac OS X)的系統提供了strlcpystrlcat函數來完成更好的字符串操作。其他系統可通過libbsd庫調用它們。

許多操作系統提供了通過內存區域間接控制受保護內存的接口,以防止意外讀/寫操作,入Posxi mprotect。類似的間接訪問的保護機制常用於所有的內存頁。

避免內存洩露

內存洩露,常由於程序中未釋放不再使用的動態分配的內存導致。因此,真正理解所需要的分配的內存對象的范圍大小是很有必要的。更重要的是,要明白何時調用free。但當程序復雜度增加時,要確定free的調用時機將變得更加困難。早期設計決策時,規劃內存很重要。

以下是處理內存洩露的技能表:

1) 啟動時分配 想讓內存管理保持簡單,一個方法是在啟動時在堆中分配所有所需的內存。程序結束時,釋放內存的重任就交給了操作系統。這種方法在許多場景中的效果令人滿意,特別是當程序在一個批量操作中完成對輸入的處理的情況。

2) 變長數組 如果你需要有著變長大小的臨時存儲,並且其生命周期在變量內部時,可考慮VLA(Variable Length Array,變長數組)。但這有個限制:每個函數的空間不能超過數百字節。因為C99指出邊長數組能自動存儲,它們像其他自動變量一樣受限於同一作用域。即便標准未明確規定,VLA的實現都是把內存數據放到棧中。VLA的最大長度為SIZE_MAX字節。考慮到目標平台的棧大小,我們必須更加謹慎小心,以保證程序不會面臨棧溢出、下個內存段的數據損壞的尴尬局面。

3) 自己編寫引用計數 這個技術的想法是對某個內存對象的每次引用、去引用計數。賦值時,計數器會增加;去引用時,計數器減少。當引用計數變為0時,這意味著此內存對象不再被使用,可以釋放。因為C不提供自動析構(事實上,GCC和Clang都支持cleanup語言擴展),也不是重寫賦值運算符,引用計數由調用retain/release的函數手動完成。更好的方式,是把它作為程序的可變部分,能通過這部分獲取和釋放一個內存對象的擁有權。但是,使用這種方法需要很多(編程)規范來防止忘記調用release(停止內存洩露)或不必要地調用釋放函數(這將導致內存釋放地過早)。若內存對象的生命期需要外部事件指出,或應用程序的數據結構隱含了某個內存對象的持有權的處理,無論何種情況,都容易導致問題。下述代碼塊含有簡化了的內存管理引用計數。

 

#include 
#include 

#define MAX_REF_OBJ 100
#define RC_ERROR -1

struct mem_obj_t{
    void *ptr;
    uint16_t count;
};

static struct mem_obj_t references[MAX_REF_OBJ];
static uint16_t reference_count = 0;

/* create memory object and return handle */
uint16_t create(size_t size){

    if (reference_count >= MAX_REF_OBJ)
        return RC_ERROR;

    if (size){
        void *ptr = calloc(1, size);

        if (ptr != NULL){
            references[reference_count].ptr = ptr;
            references[reference_count].count = 0;
            return reference_count++;
        }
    }

    return RC_ERROR;
}

/* get memory object and increment reference counter */
void* retain(uint16_t handle){

    if(handle < reference_count && handle >= 0){
        references[handle].count++;
        return references[handle].ptr;
    } else {
        return NULL;
    }
}

/* decrement reference counter */
void release(uint16_t handle){
    printf("release\n");

    if(handle < reference_count && handle >= 0){
        struct mem_obj_t *object = &references[handle];

        if (object->count <= 1){
            printf("released\n");
            free(object->ptr);
            reference_count--;
        } else {
            printf("decremented\n");
            object->count--;
        }
    }
}
如果你關心編譯器的兼容性,可用cleanup屬性在C中模擬自動析構。

 

 

void cleanup_release(void** pmem) {
    int i;
    for(i = 0; i < reference_count; i++) {
        if(references[i].ptr == *pmem)
           release(i);
    }
}

void usage() {
    int16_t ref = create(64);

    void *mem = retain(ref);
    __attribute__((cleanup(cleanup_release), mem));

    /* ... */
}
上述方案的另一缺陷是提供對象地址讓cleanup_release釋放,而非引用計數值。這樣一來,cleanup_release必須在references數組中做開銷大的查找操作。一種解決辦法是,改變填充的接口為返回一個指向struct mem_obj_t的指針。另一種辦法是使用下面的宏集合,這些宏能夠創建保存引用計數值的變量並追加clean屬性。

 

 

/* helper macros */
#define __COMB(X,Y) X##Y
#define COMB(X,Y) __COMB(X,Y)
#define __CLEANUP_RELEASE __attribute__((cleanup(cleanup_release)))

#define retain_auto(REF) retain(REF); int16_t __CLEANUP_RELEASE COMB(__ref,__LINE__) = REF

void cleanup_release(int16_t* phd) {
    release(*phd);
}

void usage() {
    int16_t ref = create(64);

    void *mem = retain_auto(ref);
    /* ... */
}
(譯者注:##符號源自C99,用於連接兩個變量的名稱,一般用在宏裡。如int a##b就會定義一個叫做ab的變量;__LINE__指代碼行號,類似的還有__FUNCTION__或__func__和__FILE__,可用於打印調試信息;__attribute__符號來自gcc,主要用於指導編譯器優化,也提供了一些如構造、析構、字節對齊等功能)

 

4) 內存池 若一個程序經過數階段才能徹底執行,每階段的開頭都分配有內存池,需要分配內存時,就使用內存池的一部分。內存池的選擇,要考慮分配的內存對象的生命周期,以及對象在程序中所屬的階段。每個階段一旦結束,整個內存池就要立即釋放。這種方法在記錄型運行程序中特別有用,例如守護進程,它可能隨著時間減少內存分段。下述代碼是個內存池內存管理的仿真:

 

#include 
#include 

struct pool_t{
    void *ptr;
    size_t size;
    size_t used;
};

/* create memory pool*/
struct pool_t* create_pool(size_t size) {
    struct pool_t* pool = calloc(1, sizeof(struct pool_t));

    if(pool == NULL)
        return NULL;

    if (size) {
        void *mem = calloc(1, size);

        if (mem != NULL) {
            pool->ptr = mem;
            pool->size = size;
            pool->used = 0;
            return pool;
        }
    }
    return NULL;
}

/* allocate memory from memory pool */
void* pool_alloc(struct pool_t* pool, size_t size) {

    if(pool == NULL)
        return NULL;

    size_t avail_size = pool->size - pool->used;

    if (size && size <= avail_size){
        void *mem = pool->ptr + pool->used;
        pool->used += size;
        return mem;
    }

    return NULL;
}

/* release memory for whole pool */
void delete_pool(struct pool_t* pool) {
    if (pool != NULL) {
        free(pool->ptr);
        free(pool);
    }
}

內存池的實現涉及非常艱難的任務。可能一些現有的庫能很好地滿足你的需求:

 

GNU libc obstackSamba tallocRavenbrook Memory Pool System

5) 數據結構

把數據存到正確的數據結構裡,能解決很多內存管理問題。而數據結構的選擇,大多取決於算法,這些算法訪問數據、把數據保存到例如鏈表、哈希表或樹中。按算法選擇數據結構有額外的好處,例如能夠遍歷數據結構一次就能釋放數據。因為標准庫並未提供對數據結構的支持,這裡列出幾個支持數據結構的庫:

For traditional Unix implementation of linked lists and trees see BSD’s queue.h and tree.h macros both are part of libbsd.GNUlibavlGlib Data TypesFor additional list seehttp://adtinfo.org/index.html

6) 標記並清除垃圾收集器

處理內存問題的另一種方式,就是利用自動垃圾收集器的優勢,自此從自己清除內存中解放出來。於引用計數中內存不再需要時清除機制相反,垃圾收集器在發生指定事件是被調用,如內存分配錯誤,或分配後超過了確切的閥值。標記清除算法是實現垃圾收集器的一種方式。此算法先為每個引用到分配內存的對象遍歷堆,標記這些仍然可用的內存對象,然後清除未標記的內存對象。

可能C中最有名的類似垃圾收集器的實現是Boehm-Demers-Weiser conservative garbage collector。使用垃圾收集器的瑕疵可能是性能問題,或向程序引入非確定性的延緩。另一問題是,這可能導致庫函數使用malloc,這些庫函數申請的內存不受垃圾處理器監管,必須手動釋放。

雖然實時環境無法接受不可預料的卡頓,仍有許多環境從中獲取的好處遠超過不足。從性能的角度看,甚至有性能提升。一些項目使用含有Mono項目GNU Objective C運行環境或Irssi IRC客戶端的Boehm垃圾收集器。

指針和數組

盡管在某些上下文中數組和指針可相互替換,但在編譯器看來二者完全不同,並且在運行時所表達的含義也不同。

當我們說對象或表達式有類型的時候,我們通常想的是定位器值的類型,也叫做左值。當左值有完全non-const類型時,此類型不是數組類型(因為數組本質是內存的一部分,是個只讀常量,譯者注),我們稱此左值為可修改左值,並且此變量是個值,當表達式放到賦值運算符左邊的時候,它被賦值。若表達式在賦值運算符的右邊,此變量不必被修改,變量成為了修改左值的的內容。若表達式有數組類型,則此表達式的值是個指向數組第一個元素的指針。

上文描述了大多數場景下數組如何轉為指針。在兩種情形下,數組的值類型不被轉換:當用在一元運算符&(取地址)或sizeof時。參見C99/C11標准 6.3.2.1小節:

(Except when it is the operand of the sizeof operator or the unary & operator, or is a string literal used to initialize an array, an expression that has type “array of type” is converted to an expression with type “pointer to type” that points to the initial element of the array object and is not an lvalue.)

除非它是sizeof或一元運算符&的操作數,再或者它是用於初始化數組的字符文本,否則有著“類型數組”類型的表達式被轉換為“指向類型”類型的指針,此指針指向數組對象的首個元素且指針不是左值。

由於數組沒有可修改的左值,並且在絕大多數情況下,數組類型的表達式的值被轉為指針,因此不可能用賦值運算符給數組變量賦值(即int a[10]; a = 1;是錯的,譯者注)。下面是一個小示例:

 

short a[] = {1,2,3};
short *pa;
short (*px)[];

void init(){
    pa = a;
    px = &a;

    printf("a:%p; pa:%p; px:%p\n", a, pa, px);

    printf("a[1]:%i; pa[1]:%i (*px)[1]:%i\n", a[1], pa[1], (*px)[1]);
}
(ps:%i能識別輸入的八進制和十六進制)

 

aint 型數組,pa 是指向 int 的指針,px 是個未完成的、指向數組的指針。a 賦值給 pa 前,它的值被轉為一個指向數組開頭的指針。右值表達式 &a並非意味著指向 int,而是一個指針,指向 int 型數組因為當使用一元符號&時右值不被轉換為指針。

表達式 a[1] 中下標的使用等價於 *(a+1),且服從如同 pa[1] 的指針算術規則。但二者有一個重要區別。對於 a 是數組的情況,a 變量的實際內存地址用於獲取指向第一個元素的指針。當對於 pa 是指針的情況,pa 的實際值並不用於定位。編譯器必須注意到 apa見的類型區別,因此聲明外部變量時,指明正確的類型很重要。

 

int a[];
int *pa;
但在另外的編譯單元使用下述聲明是不正確的,將毀壞代碼:

 

 

extern int *a;
extern int pa[];

 

數組作為函數形數

某些類型數組變為指針的另一個場合在函數聲明中。下述三個函數聲明是等價的:

 

void sum(int data[10]) {}

void sum(int data[]) {}

void sum(int *data) {}
編譯器應報告函數 sum 重定義相關錯誤,因為在編譯器看來上述三個例子中的參數都是 int 型的。.

 

多維數組是有點棘手的話題。首先,雖然用了“多維”這個詞,C並不完全支持多維數組。數組的數組可能是更准確的描述。

 

typedef int[4] vector;
vector m[2] = {{1,2,3,4}, {4,5,6,7}};
int n[2][4] = {{1,2,3,4}, {4,5,6,7}};
變量 m 是長度為2的 vector 類型,vector 是長為4的 int 型數組。除了存儲的內存位置不同外,數組 nm 是相同的。從內存的角度講,兩個數組都如同括號內展示的內容那樣,排布在連續的內存區域。訪問到的和聲明的完全一致。

 

 

int *p = n[1];
int y = p[2];
通過使用下標符號 n[1],我們獲取到了每個元素大小為4字節的整型數組。因為我們要定位數組的第二個元素, 其位置在多維數組中是數組開始偏移四倍的整型大小。我們知道,在這個表達式中整型數組被轉為指向 int 的指針,然後存為 p。然後 p[2] 將訪問之前表達式產生的數組中的第三個元素。上面代碼中的 y 等價於下面代碼中的 z:
int z = *(*(n+1)+2);
也等價於我們初學C時寫的表達式:
int x = n[1][2];
當把上文中的二維數組作為參數傳輸時,第一“維”數組會轉為指針,指向再次陣列的數組的第一個元素。因此不需要指明第一維。剩余的維度需要明確指出其長度。否則下標將不能正確工作。當我們能夠隨心所欲地使用下述表格中的任一形式來定義函數接受數組時,我們總是被強制顯式地定義最裡面的(即維度最低的)數組的維度。
void sum(int data[2][4]) {}
void sum(int data[][4]) {}
void sum(int (*data)[4]) {}
為繞過這一限制,可以轉換數組為指針,然後計算所需元素的偏移。
void list(int *arr, int max_i, int max_j){
    int i,j;

    for(i=0; i另一種方法是main函數用以傳輸參數列表的方式。main函數接收二級指針而非二維數組。這種方法的缺陷是,必須建立不同的數據,或者轉換為二級指針的形式。不過,好在它運行我們像以前一樣使用下標符號,因為我們現在有了每個子數組的首地址。
int main(int argc, char **argv){
    int arr1[4] = {1,2,3,4};
    int arr2[4] = {5,6,7,8};

    int *arr[] = {arr1, arr2};

    list(arr, 2, 4);
}

void list(int **arr, int max_i, int max_j){
    int i,j;

    for(i=0; i用字符串類型的話,初始化部分變得相當簡單,因為它允許直接初始化指向字符串的指針。
const char *strings[] = {
    "one",
    "two",
    "three"
};
但這有個陷阱,字符串實例被轉換成指針,用 sizeof 操作符時會返回指針大小,而不是整個字符串文本所占空間。另一個重要區別是,若直接用指針修改字符串內容,則此行為是未定義的。

 

假設你能使用變長數組,那就有了第三種傳多維數組給函數的方法。使用前面定義的變量來指定最裡面數組的維度,變量 arr 變為一個指針,指向未完成的int數組。

 

void list(int max_i, int max_j, int arr[][max_j]){
    /* ... */
    int x = arr[1][3];
}
此方法對更高維度的數組仍然有效,因為第一維總是被轉換為指向數組的指針。類似的規則同樣作用於函數指示器。若函數指示器不是 sizeof 或一元操作符 & 的參數,它的值是一個指向函數的指針。這就是我們傳回調函數時不需要 & 操作符的原因。
static void catch_int(int no) {
    /* ... */
};

int main(){
    signal(SIGINT, catch_int);

    /* ... */
}

 

打樁(Interpositioning)

打樁是一種用定制的函數替換鏈接庫函數且不需重新編譯的技術。甚至可用此技術替換系統調用(更確切地說,庫函數包裝系統調用)。可能的應用是沙盒、調試或性能優化庫。為演示過程,此處給出一個簡單庫,以記錄GNU/Linux中 malloc 調用次數。

 

/* _GNU_SOURCE is needed for RTLD_NEXT, GCC will not define it by default */
#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 

static uint32_t malloc_count = 0;
static uint64_t total = 0;

void summary(){
    fprintf(stderr, "malloc called: %u times\n", count);
    fprintf(stderr, "total allocated memory: %" PRIu64 " bytes\n", total);
}

void *malloc(size_t size){
    static void* (*real_malloc)(size_t) = NULL;
    void *ptr = 0;

    if(real_malloc == NULL){
        real_malloc = dlsym(RTLD_NEXT, "malloc");
        atexit(summary);
    }

    count++;
    total += size;
    return real_malloc(size);
}
打樁要在鏈接libc.so之前加載此庫,這樣我們的 malloc 實現就會在二進制文件執行時被鏈接。可通過設置 LD_PRELOAD 環境變量為我們想讓鏈接器優先鏈接的全路徑。這也能確保其他動態鏈接庫的調用最終使用我們的 malloc 實現。因為我們的目標只是記錄調用次數,不是真正地實現內存分配,所以我們仍需要調用“真正”的 malloc 。通過傳遞 RTLD_NEXT 偽處理程序到 dlsym,我們獲得了指向下一個已加載的鏈接庫中 malloc 事件的指針。第一次 malloc 調用 libc 的 malloc,當程序終止時,會調用由 atexit 注冊的獲取和 summary 函數。看GNU/Linxu中打樁行為(真的184次調用!):
$ gcc -shared -ldl -fPIC malloc_counter.c -o /tmp/libmcnt.so
$ export LD_PRELOAD="/tmp/libstr.so"
$ ps
  PID TTY          TIME CMD
 2758 pts/2    00:00:00 bash
 4371 pts/2    00:00:00 ps
malloc called: 184 times
total allocated memory: 302599 bytes

 

4.1 符號可見性

默認情況下,所有的非靜態函數可被導出,所有可能僅定義有著與其他動態鏈接庫函數甚至模板文件相同特征標的函數,就可能在無意中插入其它名稱空間。為防止意外打樁、污染導出的函數名稱空間,有效的做法是把每個函數聲明為靜態的,此函數在目標文件之外不能被使用。

在共享庫中,另一種控制導出的共享目標的方式是用編譯器擴展。GCC 4.x和Clang都支持 visibility 屬性和 -fvisibility編譯命令來對每個目標文件設置全局規則。其中 default 意味著不修改可見性,hidden 對可見性的影響與 static 限定符相同。此符號不會被放入動態符號表,其他共享目標或可執行文件看不到此符號。

 

#if __GNUC__ >= 4 || __clang__
  #define EXPORT_SYMBOL __attribute__ ((visibility ("default")))
  #define LOCAL_SYMBOL  __attribute__ ((visibility ("hidden")))
#else
  #define EXPORT_SYMBOL
  #define LOCAL_SYMBOL
#endif
全局可見性由編譯器參數指定,可通過設置 visibility 屬性被本地覆蓋。實際上,全局策略設置為 hidden,則所有符號會被默認為本地的,只有修飾__attribute__((visibility (“default”)))才將被導出。

 

五、顯式內聯

(想讓)函數代碼被直接集成到調用函數中,而非產生獨立的函數目標和單個調用,可顯式地使用 inline 限定符來指示編譯器這麼做。根據section 6.7.4 of C standardinline 限定符僅建議編譯器使得”調用要盡可能快”,並且“此建議是否有效由具體實現定義”

要用內聯函數優點的最簡單方法是把函數定義為 static ,然後將定義放入頭文件。

 

/* middle.h */
static inline int middle(int a, int b){
    return (b-a)/2;
}
獨立的函數對象仍然可能被導出,但在翻譯單元的外部它是不可見的。這種頭文件被包含在多個翻譯單元中,編譯器可能為每個單元發射函數的多份拷貝。因此,有可能兩個變量指向相同的函數名,指針的值可能不相等。

 

另一種方法是,既提供外部可連接的版本,也提供內聯版本,兩個版本功能相同,讓編譯器決定使用哪個。這實際上是內嵌限定符的定義:

If all of the file scope declarations for a function in a translation unit include the inline function specifier without extern, then the definition in that translation unit is an inline definition. An inline definition does not provide an external definition for the function, and does not forbid an external definition in another translation unit. An inline definition provides an alternative to an external definition, which a translator may use to implement any call to the function in the same translation unit. It is unspecified whether a call to the function uses the inline definition or the external definition.

在一個翻譯單元中,若某個函數在所有的文件范圍內都包含不帶extern的內聯函數限定符,則此翻譯單元中此函數定義是內聯定義。內聯定義不為函數提供外部的定義,也不禁止其他翻譯單元的外部定義。內聯定義為外部定義提供一個可選項,在同一翻譯單元內翻譯器可用它實現對函數的任意調用。調用函數時,使用內聯定義或外聯定義是不確定的。

(譯者注:即gcc中的 extern inline,優先使用內聯版本,允許外部版本的存在)

對於函數的兩個版本,我們可以把下面的定義放在頭文件中:

 

/* middle.h */
inline int middle(int a, int b){
    return (b-a)/2;
}
然後在具體的源文件中,用extern限定符發射翻譯單元中外部可鏈接的版本:
#include "middle.h"
extern int middle(int a, int b);
GCC編譯器的實現不同於上述譯碼方式。若函數由 inline 聲明,GCC總是發射外部可鏈接的目標代碼,並且程序中只存在一個這樣的定義。若函數被聲明為export inline的,GCC將永不為此函數發射外部可鏈接的目標代碼。自GCC 4.3版本起,可使用-STD= c99的選項使能為內聯定義使能C99規則。若C99的規則被啟用,則定義GNUC_STDC_INLINE。之前描述的 static使用方法不受GCC對內聯函數解釋的影響。如果你需要同時使用內聯和外部可鏈接功能的函數,可考慮以下解決方案:
/* global.h */
#ifndef INLINE
# if __GNUC__ && !__GNUC_STDC_INLINE__
#  define INLINE extern inline
# else
#  define INLINE inline
# endif
#endif
頭文件中有函數定義:
/* middle.h  */
#include "global.h"
INLINE int middle(int a, int b) {
  return (b-a)/2;
}
在某個具體實現的源文件中:
#define INLINE
#include "middle.h
若要對函數強制執行內聯,GCC和Clang編譯器都可用always_inline 屬性達成此目的。下面的例子中,獨立的函數對象從未被發射。
/* cdefs.h */
# define __always_inline   inline __attribute__((always_inline))

/* middle.h */
#include 
static __always_inline int middle(int a, int b) {
  return (b-a)/2;
}
一旦編譯器內聯失敗,編譯將因錯誤而終止。例如 Linux kernel就使用這種方法。可在 cdefs.h 中上述代碼中使用的__always_inline

六、矢量擴展

許多微處理器(特別是x86架構的)提供單指令多數據(SIMD)指令集來使能矢量操作。例如下面的代碼:

#include 
#include 
#define SIZE 8
int16_t a[SIZE], b[SIZE];

void addtwo(){
    int16_t i = 0;

    while (i < SIZE) {
        a[i] = b[i] + 2;
        i++;
    }
}

int main(){
    addtwo();
    return a[0];
}
addtwo 中的循環迭代 8 次,每次往數組 b 上加 2,數組 b 每個元素是 16 位的有符號整型。函數 addtwo 將被編譯成下面的匯編代碼:
$ gcc -O2 auto.c -S -o auto_no.asm

addtwo:
.LFB22:
        .cfi_startproc
        movl    $0, %eax
.L2:
        movzwl  b(%rax), %edx
        addl    $2, %edx
        movw    %dx, a(%rax)
        addq    $2, %rax
        cmpq    $16, %rax
        jne     .L2
        rep
        ret
        .cfi_endproc
起初,0 寫入到 eax 寄存器。標簽 L2 標著循環的開始。b 的首個元素由movzwl指令被裝入的32位寄存器 edx 前16位。 edx寄存器的其余部分填 0。然後 addl 指令往 edx 寄存器中 a 的第一個元素的值加 2並將結果存在 dx 寄存器中。累加結果從 dx(edx 寄存器的低16位)復制到 a 的第一個元素。最後,顯然存放了步長為 2 (占2個字節 – 16位)的數組的rax 寄存器與數組的總大小(以字節為單位)進行比較。如果 rax 不等於16,執行跳到 L2 ,否則會繼續執行,函數返回。

SSE2 指令集提供了能夠一次性給 8 個 16 位整型做加法的指令 paddw。實際上,最現代化的編譯器都能夠自動使用如 paddw 之類的矢量指令優化代碼。Clang 默認啟用自動向量化。 GCC的編譯器中可用-ftree-vectorize或 -O3 開關啟用它。這樣一來,向量指令優化後的 addtwo 函數匯編代碼將會大有不同:

$ gcc -O2 -msse -msse2 -ftree-vectorize -ftree-vectorizer-verbose=5 auto.c -S -o auto.asm

addtwo:
.LFB22:
        .cfi_startproc
        movdqa  .LC0(%rip), %xmm0
        paddw   b(%rip), %xmm0
        movdqa  %xmm0, a(%rip)
        ret
        .cfi_endproc

;...

.LC0:
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
        .value  2
最顯著的區別在於循環處理消失了。首先,8 個 16 位值為 2 整數被標記為 LC0,由 movdqa 加載到 xmm0 寄存器。然後paddw 把b 的每個 16 位的元素分別加到xmm0 中的多個數值 2上。結果寫回到 a,函數可以返回。指令 movqda 只能用在由16個字節對齊的內存對象上。這表明編譯器能夠對齊兩個數組的內存地址以提高效率。

 

數組的大小不必一定只是 8 個元素,但它必須以 16 字節對齊(需要的話,填充),因此也可以用 128 位向量。用內聯函數也可能是一個好主意,特別是當數組作為參數傳遞的時候。因為數組被轉換為指針,指針地址需要16字節對齊。如果函數是內聯的,編譯器也許能減少額外的對齊開銷。

 

#include 

void __always_inline addtwo(int16_t* a, int16_t *b, int16_t size){
    int16_t i;

    for (i = 0; i < size; i++) {
        a[i] = b[i] + 2;
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];

    addtwo(a, b, size);
    return a[0];
}
循環迭代 1024 次,每次把兩個長度為 16 比特的有符號整型相加。使用矢量操作的話,上例中的循環總數可減少到 128。但這也可能自動完成,在GCC環境中,可用 vector_size 定義矢量數據類型,用這些數據和屬性顯式指導編譯器使用矢量擴展操作。此處列舉出 emmintrin.h 定義的采用 SSE 指令集的多種矢量數據類型。

 

 

* SSE2 */
typedef double __v2df __attribute__ ((__vector_size__ (16)));
typedef long long __v2di __attribute__ ((__vector_size__ (16)));
typedef int __v4si __attribute__ ((__vector_size__ (16)));
typedef short __v8hi __attribute__ ((__vector_size__ (16)));
typedef char __v16qi __attribute__ ((__vector_size__ (16)));
這是用 __v8hi 類型優化之前的示例代碼後的樣子:

 

 

#include 
#include 
#include 

static void __always_inline _addtwo(__v8hi *a, __v8hi *b, const int16_t sz){
    __v8hi c = {2,2,2,2,2,2,2,2};

    int16_t i;
    for (i = 0; i < sz; i++) {
        a[i] = b[i] + c;
    }
}

static void __always_inline addtwo(int16_t *a, int16_t *b, const int16_t sz){
    _addtwo((__v8hi *) a, (__v8hi *) b, sz/8);
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
關鍵是把數據轉到合適的類型(此例中為 __v8hi),然後由此調整其他的代碼。優化的效果主要看操作類型和處理數據量的大小,可能不同情況的結果差異很大。下表是上例中 addtwo 函數被循環調用 1 億次的執行時間:

 

 

Compiler 	                 Time
gcc 4.5.4 O2 	                1m 5.3s
gcc 4.5.4 O2 auto vectorized 	12.7s
gcc 4.5.4 O2 manual 	        8.9s
gcc 4.7.3 O2 auto vectorized 	25.s
gcc 4.7.3 O2 manual 	        8.9s
clang 3.3 O3 auto vectorized 	8.1s
clang 3.3 O3 manual 	       9.5s

 

Clang 編譯器自動矢量化得更快,可能是因為用以測試的外部循環被優化的更好。慢一點的 GCC 4.7.3在內存對齊(見下文)方面效率稍低。

 

int32_t i;
for(i=0; i < 100000000; i++){
    addtwo(a, b, size);
}

 

6.1 使用內建函數(Intrinsic Function)

GCC 和 Clang 編譯器也提供了內建函數,用來顯式地調用匯編指令。

確切的內建函數跟編譯器聯系很大。x86 平台下,GCC 和 Clang 編譯器都提供了帶有定義的頭文件,通過 x86intrin.h 匹配 Intel 編譯器的內建函數(即 GCC 和 Clang 用 Intel 提供的頭文件,調用 Intel 的內建函數。譯者注)。下表是含特殊指令集的頭文件:

MMX:mmintrin.hSSE:xmmintrin.hSSE2:emmintrin.hSSE3:mm3dnow.h3dnow:tmmintrin.hAVX:immintrin.h

使用內建函數後,前面的例子可以改為:

 

#include 
#include 
#include 

static void __always_inline addtwo(int16_t *a, int16_t *b, int16_t size){

    int16_t i;
    __m128i c = _mm_set1_epi16(2);

    for (i = 0; i < size; i+=8) {
        __m128i bb = _mm_loadu_si128(b+i);  // movqdu b+i -> xmm0
        __m128i r = _mm_add_epi16(bb, c);   // paddw c + xmm0 -> xmm0
        _mm_storeu_si128(a+i, r);           // movqdu xmm0 -> a+i
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size];
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
當編譯器產生次優的代碼,或因代碼中的 if 條件矢量類型不可能表達需要的操作時時,可能需要這種編寫代碼的方法。

 

6.2 內存對齊

注意到上個例子用了與 movqdu 而非 movqda(上面的例子裡僅用 SIMD 產生的匯編指令使用的是 movqda。譯者注)同義的 _mm_loadu_si128。這因為不確定 ab 是否已按 16 字節對齊。使用的指令是期望內存對象對齊的,但使用的內存對象是未對齊的,這樣肯定會導致運行錯誤或數據毀壞。為了讓內存對象對齊,可在定義時用 aligned 屬性指導編譯器對齊內存對象。某些情況下,可考慮把關鍵數據按 64 字節對齊,因為 x86 L1 緩存也是這個大小,這樣能提高緩存使用率。

 

#include 
#include 
#include 

static void __always_inline addtwo(int16_t *a, int16_t *b, int16_t size){

    int16_t i;
    __m128i c = _mm_set1_epi16(2) __attribute__((aligned(16)));

    for (i = 0; i < size; i+=8) {
        __m128i bb = _mm_load_si128(b+i);  // movqda b+i -> xmm0
        __m128i r = _mm_add_epi16(bb, c);   // paddw c + xmm0 -> xmm0
        _mm_store_si128(a+i, r);           // movqda xmm0 -> a+i
    }
}

int main(){
    const int16_t size = 1024;
    int16_t a[size], b[size] __attribute__((aligned(16)));
    /* ... */

    addtwo(a, b, size);
    return a[0];
}
考慮到程序運行速度,使用自動變量好過靜態或全局變量,情況允許的話還應避免動態內存分配。當動態內存分配無法避免時,Posix 標准 和 Windows 分別提供了 posix_memalign_aligned_malloc 函數返回對齊的內存。

 

高效使用矢量擴展喊代碼優化需要深入理解目標架構工作原理和能加速代碼運行的匯編指令。這兩個主題相關的信息源有Agner`s CPU blog和它的裝訂版Optimization manuals。

 

七、逸聞轶事

本文最後一節討論 C 編程語言裡一些有趣的地方:

 

array[i] == i[array];
因為下標操作符等價於*(array + i),因此 array 和 i 是可交換的,二者等價。

 

 

$ gcc -dM -E - < /dev/null | grep -e linux -e unix
#define unix 1
#define linux 1
默認情況下,GCC 把 linuxunix 都定義為 1,所以一旦把其中一個用作函數名,代碼就會編不過。

 

 

int x = 'FOO!';
short y = 'BO';
沒錯,字符表達式可擴展到任意整型大小。

 

 

x = i+++k;
x = i++ +k;
後綴自增符在加號之前被詞法分析掃描到。

 

(即示例中兩句等價,不同於 x = i + (++k) 。譯者注)

 

x = i+++++k; //error
x = i++ ++ +k; //error

y = i++ + ++k; //ok
詞法分析查找可被處理的最長的非空格字符序列(C標准6.4節)。第一行將被解析成第二行的樣子,它們倆都會產生關於缺少左值的錯誤,缺失的左值本應該被第二個自增符處理。

 

致謝

若有需要增改之處,歡迎留言到 原文鏈接。

參考文獻

Expert C Programming, Book by Peter van LindenC99 StandardC11 StandardPOSIX.1-2008 StandardInside memory managementC Reference Counting and YouStack Overflow ProblemsGNU ObstackTalloc DocumantationRavenbrook Memory Pool SystemlibbsdBoehm-Demers-Weiser conservative garbage collectorHow To Write Shared Libraries by Ulrich DrepperInline Functions In CBlog of Agner FogOptimization Manuals by Agner FogAuto-vectorization in GCCAuto-Vectorization in LLVM

關於程序設計基石與實踐更多討論與交流,敬請關注本博客和新浪微博songzi_tea.

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved