高階 | 只有高手才知道的C語言高效程式設計與程式碼最佳化方法

高階 | 只有高手才知道的C語言高效程式設計與程式碼最佳化方法

大雄總結了一些讓程式執行更快的方法,可以幫助我們從執行速度和記憶體使用等方面來最佳化C語言程式碼。

儘管在C程式碼最佳化方面有很多的指南,但是關於編譯和使用程式設計機器方面的最佳化知識卻很少。

通常,為了讓程式執行的更快,程式的程式碼量可能需要增加。程式碼量的增加又可能會對程式的複雜度和可讀性帶來不利的影響。

這對於在手機、PDA等對於記憶體使用有很多限制的小型裝置上編寫程式時是不被允許的。

因此,在程式碼最佳化時,我們應該確保記憶體使用和執行速度兩方面都得到最佳化。

哪裡需要使用這些方法?

沒有這一點,所有的討論都無從談起。

程式最佳化最重要的就是找出待最佳化的地方,也就是找出程式的哪些部分或者哪些模組執行緩慢亦或消耗大量的記憶體。

只有程式的各部分經過了最佳化,程式才能執行得更快。

程式中執行最多的部分,特別是那些被程式內部迴圈重複呼叫的方法最該被最佳化。

對於一個有經驗的碼農,發現程式中最需要被最佳化的部分往往很簡單。此外,還有很多工具可以幫助我們找出需要最佳化的部分。

Visual C++內建的效能工具profiler能找出程式中消耗最多記憶體的地方。

另一個工具是英特爾的Vtune,它也能很好的檢測出程式中執行最慢的部分。

一般來說,內部或巢狀迴圈,呼叫第三方庫的方法通常是導致程式執行緩慢的最主要的起因。

整形數

如果我們確定整數非負,就應該使用unsigned int而不是int。

有些處理器處理無符號unsigned 整形數的效率遠遠高於有符號signed整形數(這是一種很好的做法,也有利於程式碼具體型別的自解釋)。

因此,在一個緊密迴圈中,宣告一個int整形變數的最好方法是:

register unsigned int variable_name;

記住,整形int的運算速度高浮點型float,並且可以被處理器直接完成運算,而不需要藉助於FPU(浮點運算單元)或者浮點型運算庫。

儘管這不保證編譯器一定會使用到暫存器儲存變數,也不能保證處理器處理能更高效處理unsigned整型,但這對於所有的編譯器是通用的。

例如在一個計算包中,如果需要結果精確到小數點後兩位,我們可以將其乘以100,然後儘可能晚的把它轉換為浮點型數字。

除法和取餘數

在標準處理器中,對於分子和分母,一個32位的除法需要使用20至140次迴圈操作。

除法函式消耗的時間包括一個常量時間加上每一位除法消耗的時間。

Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)

= C0 + C1 * (log2 (numerator) - log2 (denominator))。

對於ARM處理器,這個版本需要20+4。3N次迴圈。這是一個消耗很大的操作,應該儘可能的避免執行。

有時,可以透過乘法表達式來替代除法。

例如,假如我們知道b是正數並且b*c是個整數,那麼(a/b)>c可以改寫為a>(c*b)。

如果確定運算元是無符號unsigned的,使用無符號unsigned除法更好一些,因為它比有符號signed除法效率高。

合併除法和取餘數

在一些場景中,同時需要除法(x/y)和取餘數(x%y)操作。

這種情況下,編譯器可以透過呼叫一次除法操作返回除法的結果和餘數。

如果既需要除法的結果又需要餘數,我們可以將它們寫在一起,如下所示:

int func_div_and_mod (int a, int b) {

return (a / b) + (a % b);

}

透過2的冪次進行除法和取餘數

如果除法中的除數是2的冪次,我們可以更好的最佳化除法。編譯器使用移位操作來執行除法。

因此,我們需要儘可能的設定除數為2的冪次(例如64而不是66)。

並且依然記住,無符號unsigned整數除法執行效率高於有符號signed整形出發。

typedef unsigned int uint; uint div32u (uint a) { return a / 32;

} int div32s (int a){ return a / 32;

}

上面兩種除法都避免直接呼叫除法函式,並且無符號unsigned的除法使用更少的計算機指令。

由於需要移位到0和負數,有符號signed的除法需要更多的時間執行。

取模的一種替代方法

我們使用取餘數運算子來提供算數取模。但有時可以結合使用if語句進行取模操作。考慮如下兩個例子:

uint modulo_func1 (uint count)

{ return (++count % 60);

}

uint modulo_func2 (uint count)

{ if (++count >= 60) count = 0; return (count);

}

優先使用if語句,而不是取餘數運算子,因為if語句的執行速度更快。

這裡注意新版本函式只有在我們知道輸入的count結餘0至59時才能正確的工作。

使用陣列下標

如果想給一個變數設定一個代表某種意思的字元值,可能會這樣做:

switch ( queue ) {case 0 : letter = ‘W’; break;case 1 : letter = ‘S’; break;case 2 : letter = ‘U’; break;

}

或者這樣做:

if ( queue == 0 )

letter = ‘W’;else if ( queue == 1 )

letter = ‘S’;else

letter = ‘U’;

一種更簡潔、更快的方法是使用陣列下標獲取字元陣列的值。如下:

static char *classes=“WSU”;

letter = classes[queue];

全域性變數

全域性變數絕不會位於暫存器中。使用指標或者函式呼叫,可以直接修改全域性變數的值。

因此,編譯器不能將全域性變數的值快取在暫存器中,但這在使用全域性變數時便需要額外的(常常是不必要的)讀取和儲存。

所以,在重要的迴圈中我們不建議使用全域性變數。

如果函式過多的使用全域性變數,比較好的做法是複製全域性變數的值到區域性變數,這樣它才可以存放在暫存器。

這種方法僅僅適用於全域性變數不會被我們呼叫的任意函式使用。例子如下:

int f(void);int g(void);int errs;void test1(void){

errs += f();

errs += g();

}void test2(void){ int localerrs = errs;

localerrs += f();

localerrs += g();

errs = localerrs;

}

注意,test1必須在每次增加操作時載入並存儲全域性變數errs的值,而test2儲存localerrs於暫存器並且只需要一個計算機指令。

使用別名

考慮如下的例子:

void func1( int *data ){ int i; for(i=0; i<10; i++)

{

anyfunc( *data, i);

}

}

儘管*data的值可能從未被改變,但編譯器並不知道anyfunc函式不會修改它,所以程式必須在每次使用它的時候從記憶體中讀取它。

如果我們知道變數的值不會被改變,那麼就應該使用如下的編碼:

void func1( int *data ){ int i; int localdata;

localdata = *data; for(i=0; i<10; i++)

{

anyfunc ( localdata, i);

}

}

這為編譯器最佳化程式碼提供了條件。

變數的生命週期分割

由於處理器中暫存器是固定長度的,程式中數字型變數在暫存器中的儲存是有一定限制的。

有些編譯器支援“生命週期分割”(live-range splitting),也就是說在程式的不同部分,變數可以被分配到不同的暫存器或者記憶體中。

變數的生命週期開始於對它進行的最後一次賦值,結束於下次賦值前的最後一次使用。

在生命週期內,變數的值是有效的,也就是說變數是活著的。

不同生命週期之間,變數的值是不被需要的,也就是說變數是死掉的。

這樣,暫存器就可以被其餘變數使用,從而允許編譯器分配更多的變數使用暫存器。

需要使用暫存器分配的變數數目需要超過函式中不同變數生命週期的個數。

如果不同變數生命週期的個數超過了暫存器的數目,那麼一些變數必須臨時儲存於記憶體。這個過程就稱之為分割。

編譯器首先分割最近使用的變數,用以降低分割帶來的消耗。禁止變數生命週期分割的方法如下:

限定變數的使用數量:這個可以透過保持函式中的表示式簡單、小巧、不使用太多的變數實現。將較大的函式拆分為小而簡單的函式也會達到很好的效果;

對經常使用到的變數採用暫存器儲存:這樣允許我們告訴編譯器該變數是需要經常使用的,所以需要優先儲存於暫存器中。

然而,在某種情況下,這樣的變數依然可能會被分割出暫存器。

變數型別

C編譯器支援基本型別:char、short、int、long(包括有符號signed和無符號unsigned)、float和double。

使用正確的變數型別至關重要,因為這可以減少程式碼和資料的大小並大幅增加程式的效能。

區域性變數

我們應該儘可能的不使用char和short型別的區域性變數。

對於char和short型別,編譯器需要在每次賦值的時候將區域性變數減少到8或者16位。

這對於有符號變數稱之為有符號擴充套件,對於無符號變數稱之為零擴充套件。

這些擴充套件可以透過暫存器左移24或者16位,然後根據有無符號標誌右移相同的位數實現。

這會消耗兩次計算機指令操作(無符號char型別的零擴充套件僅需要消耗一次計算機指令)。

可以透過使用int和unsigned int型別的區域性變數來避免這樣的移位操作。

這對於先載入資料到區域性變數,然後處理區域性變數資料值這樣的操作非常重要。

無論輸入輸出資料是8位或者16位,將它們考慮為32位是值得的。

考慮下面的三個函式:

int wordinc (int a){ return a + 1;

}short shortinc (short a){ return a + 1;

}char charinc (char a){ return a + 1;

}

儘管結果均相同,但是第一個程式片段執行速度高於後兩者。

指標

我們應該儘可能的使用引用值的方式傳遞結構資料,也就是說使用指標,否則傳遞的資料會被複製到棧中,從而降低程式的效能。

函式透過引數接受結構資料的指標,如果我們確定不改變資料的值,我們需要將指標指向的內容定義為常量。例如:

void print_data_of_a_structure ( const Thestruct *data_pointer){

。。。printf contents of the structure。。。

}

這個示例告訴編譯器函式不會改變外部引數的值(使用const修飾),並且不用在每次訪問時都進行讀取。

同時,確保編譯器限制任何對只讀結構的修改操作從而給予結構資料額外的保護。

指標鏈

指標鏈經常被用於訪問結構資料。例如,常用的程式碼如下:

typedef struct { int x, y, z; } Point3;typedef struct { Point3 *pos, *direction; } Object;void InitPos1(Object *p){

p->pos->x = 0;

p->pos->y = 0;

p->pos->z = 0;

}

然而,這種的程式碼在每次操作時必須重複呼叫p->pos,因為編譯器不知道p->pos->x與p->pos是相同的。

一種更好的方法是快取p->pos到一個區域性變數:

void InitPos2(Object *p)

{

Point3 *pos = p->pos; pos->x = 0; pos->y = 0; pos->z = 0;

}

另一種方法是在Object結構中直接包含Point3型別的資料,這能完全消除對Point3使用指標操作。

條件執行

條件執行語句大多在if語句中使用,也在使用關係運算符(<,==,>等)或者布林值表示式(&&,!等)計算複雜表示式時使用。

對於包含函式呼叫的程式碼片段,由於函式返回值會被銷燬,因此條件執行是無效的。

因此,保持if和else語句儘可能簡單是十分有益處的,因為這樣編譯器可以集中處理它們。關係表示式應該寫在一起。

下面的例子展示編譯器如何使用條件執行:

int g(int a, int b, int c, int d){ if (a > 0 && b > 0 && c < 0 && d < 0) // grouped conditions tied up together//

return a + b + c + d; return -1;

}

由於條件被聚集到一起,編譯器能夠將他們集中處理。

布林表示式和範圍檢查

一個常用的布林表示式是用於判斷變數是否位於某個範圍內,例如,檢查一個圖形座標是否位於一個視窗內:

bool PointInRectangelArea (Point p, Rectangle *r)

{ return (p。x >= r->xmin && p。x < r->xmax &&

p。y >= r->ymin && p。y < r->ymax);

}

這裡有一種更快的方法:x>min && x

最佳化後的程式碼如下:< span=“”>

最佳化後的程式碼如下:<>

bool PointInRectangelArea (Point p, Rectangle *r)

{ return ((unsigned) (p。x - r->xmin) < r->xmax &&

(unsigned) (p。y - r->ymin) < r->ymax);

}

布林表示式和零值比較

處理器的標誌位在比較指令操作後被設定。標誌位同樣可以被諸如MOV、ADD、AND、MUL等基本算術和裸機指令改寫。

如果資料指令設定了標誌位,N和Z標誌位也將與結果與0比較一樣進行設定。

N標誌表示結果是否是負值,Z標誌表示結果是否是0。

C語言中,處理器中的N和Z標誌位與下面的指令聯絡在一起:

有符號關係運算x<0,x>=0,x==0,x!=0;無符號關係運算x==0,x!=0(或者x>0)。

C程式碼中每次關係運算符的呼叫,編譯器都會發出一個比較指令。

如果運算子是上面提到的,編譯器便會最佳化掉比較指令。例如:

int aFunction(int x, int y){ if (x + y < 0) return 1; else

return 0;

}

儘可能的使用上面的判斷方式,這可以在關鍵迴圈中減少比較指令的呼叫,進而減少程式碼體積並提高程式碼效能。

C語言沒有借位和溢位位的概念,因此,如果不借助彙編,不可能直接使用借位標誌C和溢位位標誌V。

但編譯器支援借位(無符號溢位),例如:

int sum(int x, int y){ int res;

res = x + y; if ((unsigned) res < (unsigned) x) // carry set? //

res++; return res;

}

高階 | 只有高手才知道的C語言高效程式設計與程式碼最佳化方法

高階 | 只有高手才知道的C語言高效程式設計與程式碼最佳化方法