2016年3月22日 星期二

[轉] va_list與void *

在C/C++中,有所謂的variant argument(變動引數)這東西。講白一點,就是可以讓函數
使用數量不固定的引數。這東西也許不是每個人都知道,但我想每個人都用過。因為
printf()家族就是使用這個東西的典型函數。

定義一個函數的prototype(原型)時,若是將參數列以"..."代入,就指述了這個函數即將
使用variant argument。如:

void func(...);

這樣子便可讓編繹器不檢驗傳入這種函數裡的引數型態和數量,編出來的程式碼在呼叫端
就能夠盡可能地把各式各樣引數傳入。如:

void func(0, 1, 2, 3, 0.4, 0.5 "6789");

那麼,在如此的程式裡,要怎麼存取variant argument呢?因為缺少引數變數,所以我們
不可能像一般程式一樣直接存取它們,而是要改用stdarg.h裡面所提供的三個巨集與一個
型別,分別是:

va_list,宣告一個指標,讓它指向引數串列。
va_start,初始化這個指標,讓它真正指向正確的引數串列開頭。
va_arg,來取得va_list中的資料。
va_end,清除這個指標,把它設為NULL。範例如下:

void func(int n, ...)
{
 va_list args;

 va_start(args, n);
 while(n>0)
 {
  printf("%d\n", va_arg(args, int));
  n--;
 }

 va_end(args);
}

以上程式是假設所有參數都會是int,並且有n個,把每個參數都印出來。
我們可以看看這三個傢伙都是在做些什麼事,所以把它們展開來:

In vadefs.h...
typedef char *  va_list;

#define _ADDRESSOF(v)   ( &reinterpret_cast<const char &>(v) )
#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define _crt_va_end(ap)      ( ap = (va_list)0 )

In stdarg.h...
#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end

從這裡可以看出va_list其實只是一個char*,它將整串引數當成一個位元陣列;而
va_start與va_arg可能比較神奇一點,但是仔細觀查的話,可以發現va_start是參考實際
引數(Actual argument)t,把它的記憶體位址(也許你對_ADDRESSOF巨集不解,也從沒看
過reinterpret_cast<>,其實這就是C++的static casting,當做(const char *)&v便是。
)加上另一個巨集_INTSIZEOF(n)做為偏移量,以真正地指向串列的開頭。

那麼,為什麼要大費周張地用神祕巨集_INTSIZEOF(n)呢。就合理的推斷來說,應該只要加
上sizeof(t)不就好了嗎?這是因為,以C/C++編繹出來的各個參數是會對齊sizeof(int)的
。也就是說,今天若你傳入了char做為variant argument的前一個參數,那麼想像中,整
個參數的配置應該是1+n個byte(char, ...)。但實際上,對於那個char,編繹出來的碼
會是4+n byte才是。(要注意的是,這麼做很可能只有在X86/VC上是如此,若是換成其它
CPU或編繹器,可能不會這麼做。)也就是因為這種要五毛給一塊的行為,讓我們不能簡
單地直接加上sizeof(t)。

再看看va_arg,你應該也發現它只是把ap往下繼續延伸,很簡單。

所以再看看我們的func函數,因為它的引數是(int n, ...),所以做過擴展後得到的就是
char *args; //arg_list

args=((const char *)&n)+4; //va_start
*(int *)((args+=4)-4); //va_arg

又va_start總是需要variant argument的前一個變數當做參考,所以我們一開始寫的
func(...)便是永遠不可能實用化的,除了一個例外。

考慮一個我們想實作的printf,它有Escape char,當%1時輸出字串;%2時輸出數字:

int my_printf(const char *fmt, ...);

可以寫成這樣子:

void my_printf_helper(const char *fmt, va_list args)
{
 char *ptr=(char *)fmt;

 while(*ptr)
 {
  switch(*ptr)
  {
   case '\\':
    if(*++ptr)
     ptr++;
    continue;

   case '%':
    switch(*++ptr)
    {
     case NULL:
      continue;

     case '1':
      printf("%s", va_arg(args, char *));
      break;

     case '2':
      printf("%d", va_arg(args, int));
      break;
    }
    ptr++;

   default:
    putchar(*ptr++);
  }
 }
}

void my_printf(const char *fmt, ...)
{
 va_list args;

 va_start(args, fmt);
 my_printf_helper(fmt, args);
 va_end(args);
}

好了,我們現在做出一個自己的printf了,就是這麼簡單。接著,我們談談另一個應用。
應該有不少人會遇到一些函數其argument是void *吧。這是不定型指標,也就是說當
compiler遇到它時,不會檢查它的型別。換句話說,若是需要傳入一個複雜資料結構時,
便需要很噁心的一長串casting。又或是想傳入多種資料時,不僅要casting,還得要配置
記憶體。這實在很令人難過,如_beginthreadex或CreateThread就是需要這樣的一種函數
做為引數,好做為thread的主體:

unsigned int __stdcall my_func(void *args)
{...}

...
_beginthreadex(NULL, 0, my_func, ptr, 0, NULL);
...

但若考慮va_list的特性,我們可以做出比較乾淨的程式碼:

unsigned int __stdcall my_func(void *_args)
{
 va_list args;

 while(1)
 {
  args=(va_list)_args;
  printf("%d\n", va_arg(args, int));
  printf("%s\n", va_arg(args, char *));
  Sleep(500);
 }

 return 0;
}

void start_thread(...)
{
 va_list args;

 __asm lea eax, [ebp+8] //because we don't have previous argument of the
 __asm mov args, eax    //variant argument... So we can't use va_start...

 WaitForSingleObject((HANDLE)_beginthreadex(NULL, 0, my_func, args, 0, NULL),
  INFINITE);
}

...
start_thread(100, "abcdef");

這樣看起來就完美許多,這是因為variant argument事實上就是將stack視為byte array
,所以可以利用一點小技巧騙過compiler,並且讓OS幫我們把資料捆成一包,送給thread

沒有留言:

張貼留言