1. 程式人生 > >Linux 原始碼系列之可變引數列表實現

Linux 原始碼系列之可變引數列表實現

背景

從事 android 開發四年有餘,應用開發做得越久,就越有“知其然不知其所以然”的感覺,於是乎,過去的大半年,我幾乎一有時間就去啃《Linux核心完全剖析-基於0.12核心》,接近1000頁的 linux 系統原始碼,讀的過程可謂是五味雜陳,終於在2016春節跟它作了一個了斷。雖不能說對 linux 的底層實現已瞭然於心,但閱讀這本書確實起到了醍醐灌頂的功效,在很大程度上打通了我在技術上的任督二脈。

有人會問,為什麼是0.12版本?

答案很簡單:

麻雀雖小,五臟俱全。

恕我才疏學淺,就像我更喜歡看 2.3版本的 android framework 原始碼一樣,就是因為簡單。linux 發展至今20餘年,歷經茫茫多的版本迭代,但其中的設計思想卻是歷久彌新,跟動輒幾百萬行的最新版本原始碼相比,先選一個軟柿子捏捏,何樂而不為呢。

接下來我會把書上看到的知識點結合我自己的理解,寫成一個系列釋出出來,也算是對自己過去一段時間學習成果的整理和總結。

可變引數列表的使用

可變引數列表在很多語言中都有對應的語法支援,比如 Javapython。C 語言雖然是一門低階語言, 但是也支援可變引數列表, 而且它的可變引數列表實現得簡單又不失巧妙,其中最著名的就是幾個用來格式化字串輸出的 C 標準函式:

  • printf, 直接把輸出送到標準輸出控制代碼 stdout
  • cprintf, 把輸出送到控制檯
  • fprintf, 把輸出送到檔案控制代碼
  • sprintf, 把輸出送到以 null 結尾的字串中

可變引數列表的實現

下面就以 sprintf 函式說明在 C 標準庫函式中是如何實現可變引數列表的。在說明 sprintf的實現之前,非常有必要溫習一下 C 函式呼叫在作業系統中是如何實現的,後面你們就知道為什麼這裡說這個很重要了。

C 函式呼叫機制

學過計算機的人都有一個模糊的印象(如果能把這裡的事情說明白,那你基礎一定很紮實),函式呼叫是通過棧來實現的,基本上就是一個入棧出棧的過程。大家可能又知道,C 的函式呼叫是傳值呼叫,意思就是說 C 函式中用到的引數只是函式呼叫時傳入引數的一個副本,所以要修改某個變數,就必須傳入對應變數的地址指標。

那麼為什麼是這樣的?且看下面這幅圖

這裡寫圖片描述

上面這幅圖描繪了一個典型的函式呼叫棧記憶體結構,其中棧幀

是指單個函式呼叫所使用的記憶體部分。每個棧幀的起始位置儲存在暫存器 ebp 中。當在 C 程式中做函式呼叫的時候,比如函式 A 呼叫函式 B,在 A 的程式碼邏輯中會把 B 函式用到的實參壓入棧中,實參所在的記憶體部分實則屬於 A 函式的棧幀部分(引數1到引數n),所以圖中引數1到引數n部分其實是在函式 B 被呼叫之前被複制到記憶體中的,即所謂的副本。

問題來了,我們知道C 程式裡僅僅是一個簡單的函式呼叫,那麼這部分工作是由誰來完成的呢?

答案是編譯器,編譯器會把要用的引數以準確的順序和準確的大小壓入記憶體中。被呼叫函式在使用引數時,編譯器會把指定的引數引用轉化成相對於 ebp暫存器的偏移值,就是圖中形如 ebp + 4 的部分。 編譯器會在引數設定完成後呼叫特定 CPU 的函式呼叫(CALL)指令,而 CPU 會在處理函式呼叫指令時把函式返回地址呼叫者的棧幀地址壓入記憶體,同時調整 ebp暫存器指向新的棧幀起始位置。

OK,說了那麼多,可能有些抽象,讓我們看一個簡單的函式呼叫的例子。

C 函式呼叫舉例

讓我們來看一個簡單的例子,示例程式碼如下:

void swap(int *a, int *b) {
    int c;
    c = *a;
    *a = *b;
    *b = *c;
}

int main() {
    int a, b;
    a = 16;
    b = 32;
    swap(&a, &b);
    return (a - b);
}

程式碼邏輯很簡單,就是在 main 函式中呼叫 swap 函式,完成兩個整型變數的數值交換。程式碼邏輯不是我們關注的重點,我們要關注的是在作業系統底層是如何完成這次函式呼叫的。來看一下函式呼叫的棧幀結構。

這裡寫圖片描述

圖中左邊部分和右邊的上半部分是 main 函式的棧幀結構,可以看到在呼叫 swap 函式之前,main 函式會先取變數 ab 的地址,並壓入棧中(屬於自己的棧幀部分) ,然後再跳轉到 swap 函式中執行,執行過程中用到的本地變數(這裡是 c)也會壓入棧裡。這裡函式 swap 要用到引數 *a*b 時,就通過暫存器 ebp所指向的地址分別向上偏移8和12個位元組地址來實現。

這裡有一個關鍵的問題需要明確一下,編譯器如何知道被呼叫函式的引數在記憶體的什麼地方?約定,約定,約定。

編譯器會把被呼叫函式用到的引數按照引數順序逆序壓入棧中,然後再壓入函式返回地址,所以對於上面這個例子而言,ebp + 8 一定是第一個引數 *a 的記憶體地址處,依此類推。明確了這一點,我們就可以來看看 sprintf 是如何實現可變引數列表了。

sprintf 可變引數列表的實現

還是要先貼一下關鍵程式碼

static int sprintf(char * str, const char *fmt, ...)
{
    va_list args;
    int i;

    va_start(args, fmt);
    i = vsprintf(str, fmt, args);
    va_end(args);
    return i;
}

上面這段程式碼相當簡單,直覺告訴我們,這裡面比較關鍵是這幾個點:

  • va_list 是什麼
  • va_start 是什麼
  • vsprintf 實現了什麼

我們來一一解答。

  • va_list 是什麼?
typedef char *va_list;

從宣告來看, va_list 就是一個位元組指標,更確切地說它表示一個記憶體地址

  • va_start 是什麼?
/* Amount of space required in an argument list for an arg of type TYPE.
   TYPE may alternatively be an expression whose type is used.  */

#define __va_rounded_size(TYPE)  \
  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

#ifndef __sparc__
#define va_start(AP, LASTARG)                       \
 (AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))
#else
#define va_start(AP, LASTARG)                       \
 (__builtin_saveregs (),                        \
  AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))
#endif

#define va_arg(AP, TYPE)                        \
 (AP += __va_rounded_size (TYPE),                   \
  *((TYPE *) (AP - __va_rounded_size (TYPE))))

va_start 是一個巨集定義,套入 va_start(args, fmt) 後得到

args = (char *) &(fmt) + __va_rounded_size(fmt)

根據註釋, __va_rounded_size(fmt) 是求得 fmt 所需要的記憶體空間,並且補齊到4位元組邊界。 綜上所述, va_start 的作用就是調整 argsfmt 後面的記憶體地址處。至於為什麼這麼處理,我們還得看 vsprintf 的實現。

  • vsprintf 實現了什麼?

vsprintf 的實現程式碼很長,大部分是處理格式化字串的邏輯,我們只摘取其中涉及可變引數列表的部分。

...
switch (*fmt) {
    case 'c':
        ...
        *str++ = (unsigned char) va_arg(args, int);
        ...
        break;
    case 's':
        s = va_arg(args, char *);
        ...
        break;
    case 'o':
        str = number(str, va_arg(args, unsigned long), 8,
            field_width, precision, flags);
        break;
    case 'p':
        ...
        str = number(str,
            (unsigned long) va_arg(args, void *), 16,
            field_width, precision, flags);
        break;
    ...
}
...

這段邏輯是該函式的重點,簡單的說,在掃描 fmt 的過程中,如果遇到特殊字元,就從引數中獲取對應型別的值。如何獲取?可以看出,在每個分支中都會有 va_arg 的呼叫,就是通過這個巨集取得對應型別的值。我們找一個例子來說明一下。

va_arg(args, unsigned long) 通過巨集轉換之後,得到如下語句:

  (args += __va_rounded_size(unsigned long), *((unsigned long *)(args - __va_rounded_size(unsigned long)))); 

乍一看是個挺複雜的語句, 其實它做了兩件事,一是從 args 當前所表示的記憶體處取出對應型別的值,二就是讓 args 指向所取值後面的記憶體地址處。

弄清楚了幾個關鍵點之後,我們再回過頭來看看。sprintf 的典型呼叫如下所示:

sprintf(buf, "This is only a test %d, %s", 1, "hello"); 

結合 C 函式呼叫的機制,所有的引數都會在函式呼叫前按照一定的順序被壓入棧中, 只要通過 va_start 設定好引數的記憶體起始位置,就可以通過 va_arg 取得所有對應型別的引數,而具體要取得什麼型別的引數,則是由格式字串中的特殊字元決定的。

或許有人會問,如果我傳入的實參和格式化字串裡指定的型別不一樣,程式會掛嗎?

我可以很負責任地告訴你,程式會照常執行,但是可能得不到你想要的結果,具體為什麼,你自己去想吧,這就是 C 的精妙之處。