可變引數
可變引數是指函式的引數的資料型別和數量都是不固定的。
printf
函式的引數就是可變的。這個函式的原型是:int printf(const char *format, ...)
。
用一段程式碼演示printf
的用法。
// code-A
#include <stdio.h>
int main(int argc, char **argv)
{
printf("a is %d, str is %s, c is %c\n", 23, "Hello, World;", 'A');
printf("T is %d\n", 78);
return 0;
}
在code-A中,第一條printf
語句有4個引數,第二條printf
語句有2個引數。顯然,printf
的引數是可變的。
實現
程式碼
code-A
先看兩段程式碼,分別是code-A和code-B。
// file stack-demo.c
#include <stdio.h>
// int f(char *fmt, int a, char *str);
int f(char *fmt, ...);
int f2(char *fmt, void *next_arg);
int main(int argc, char *argv)
{
char fmt[20] = "hello, world!";
int a = 10;
char str[10] = "hi";
f(fmt, a, str);
return 0;
}
// int f(char *fmt, int a, char *str)
int f(char *fmt, ...)
{
char c = *fmt;
void *next_arg = (void *)((char *)&fmt + 4);
f2(fmt, next_arg);
return 0;
}
int f2(char *fmt, void *next_arg)
{
printf(fmt);
printf("a is %d\n", *((int *)next_arg));
printf("str is %s\n", *((char **)(next_arg + 4)));
return 0;
}
編譯執行,結果如下:
# 編譯
[root@localhost c]# gcc -o stack-demo stack-demo.c -g -m32
# 反彙編並把彙編程式碼寫入dis-stack.asm中
[root@localhost c]# objdump -d stack-demo>dis-stack.asm
[root@localhost c]# ./stack-demo
hello, world!a is 10
str is hi
code-B
// file stack-demo.c
#include <stdio.h>
// int f(char *fmt, int a, char *str);
int f(char *fmt, ...);
int f2(char *fmt, void *next_arg);
int main(int argc, char *argv)
{
char fmt[20] = "hello, world!";
int a = 10;
char str[10] = "hi";
char str2[10] = "hello";
f(fmt, a, str, str2);
return 0;
}
// int f(char *fmt, int a, char *str)
int f(char *fmt, ...)
{
char c = *fmt;
void *next_arg = (void *)((char *)&fmt + 4);
f2(fmt, next_arg);
return 0;
}
int f2(char *fmt, void *next_arg)
{
printf(fmt);
printf("a is %d\n", *((int *)next_arg));
printf("str is %s\n", *((char **)(next_arg + 4)));
printf("str2 is %s\n", *((char **)(next_arg + 8)));
return 0;
}
編譯執行,結果如下:
# 編譯
[root@localhost c]# gcc -o stack-demo stack-demo.c -g -m32
# 反彙編並把彙編程式碼寫入dis-stack.asm中
[root@localhost c]# objdump -d stack-demo>dis-stack.asm
[root@localhost c]# ./stack-demo
hello, world!a is 10
str is hi
str2 is hello
分析
在code-A中,呼叫f的語句是f(fmt, a, str);
;在code-B中,呼叫f的語句是f(fmt, a, str, str2);
。
很容易看出,int f(char *fmt, ...);
就是引數可變的函式。
關鍵語句
實現可變引數的關鍵語句是:
char c = *fmt;
void *next_arg = (void *)((char *)&fmt + 4);
printf("a is %d\n", *((int *)next_arg));
printf("str is %s\n", *((char **)(next_arg + 4)));
printf("str2 is %s\n", *((char **)(next_arg + 8)));
&fmt
是第一個引數的記憶體地址。next_arg
是第二個引數的記憶體地址。next_arg+4
、next_arg+8
分別是第三個、第四個引數的記憶體地址。
為什麼
記憶體地址的計算方法
先看一段虛擬碼。這段虛擬碼是f函式的對應的彙編程式碼。假設f有三個引數。當然f也可以有四個引數或2個引數。我們用三個引數的情況來觀察一下f。
f:
; 入棧ebp
; 把ebp設定為esp
; ebp + 0 儲存的是 eip,由call f入棧
; ebp + 4 儲存的是 舊ebp
; 第一個引數是 ebp + 8
; 第二個引數是 ebp + 12
; 第三個引數是 ebp + 16
; 函式f的邏輯
; 出棧ebp。ebp恢復成了剛進入函式之前的舊ebp
; ret
呼叫f的虛擬碼是:
; 入棧第三個引數
; 入棧第二個引數
; 入棧第一個引數
; 呼叫f,把eip入棧
在彙編程式碼中,第一個引數的記憶體地址很容易確定,第二個、第三個還有第N個引數的記憶體地址也非常容易確定。無法是在ebp的基礎上增加特定長度而已。
可是,我們只能確定,必定存在第一個引數,不能確定是否存在的二個、第三個還有第N個引數。沒有理由使用一個可能不存在的引數作為參照物、並且還要用它卻計算其他引數的地址。
第一個引數必定存在,所以,我們用它作為確定其他引數的記憶體地址的參照物。
記憶體地址
在f函式的C程式碼中,&fmt
是第一個引數佔用的f的棧的元素的記憶體地址,換句話說,是一個區域性變數的記憶體地址。
區域性變數的記憶體地址不能作為函式的返回值,卻能夠在本函式執行結束前使用,包括在本函式呼叫的其他函式中使用。這就是在f2中仍然能夠使用fmt
計算出來的記憶體地址的原因。
難點
當引數是int
型別時,獲取引數的值使用*(int *)(next_arg)
。
當引數是char str[20]
時,獲取引數的值使用*(char **)(next_arg + 4)
。
為什麼不直接使用next_arg
、(next_arg + 4)
呢?
分析*(int *)(next_arg)
。
在32位作業系統中,任何記憶體地址的值看起來都是一個32位的正整數。可是這個正整數的值的型別並不是unsigned int
,而是int *
。
關於這點,我們可以在gdb中使用ptype
確認一下。例如,有一小段程式碼int *a;*a = 5;
,執行ptype a
,結果會是int *
。
next_arg
只是一個正整數,損失了它的資料型別,我們需要把資料型別補充進來。我們能夠把這個操作理解成”強制型別轉換“。
至於*(int *)(next_arg)
前面的*
,很容易理解,獲取一個指標指向的記憶體中的值。
用通用的方式分析*(char **)(next_arg+4)
。
- 因為是第三個引數,因此
next_arg+4
。 - 因為第三個引數的資料型別是
char str[20]
。根據經驗,char str[20]
對應的指標是char *
。 - 因為
next_arg+4
只是函式的棧的元素的記憶體地址,在目標元素中儲存的是一個指標。也就是說,next_arg+4
是一個雙指標型別的指標。它最終又指向字串,根據經驗,next_arg+4
的資料型別是char **
。沒必要太糾結這一點。自己寫一個簡單的指向字串的雙指標,使用gdb的ptype
檢視這種型別的資料型別就能驗證這一點。 - 最前面的
*
,獲取指標指向的資料。
給出一段驗證第3點的程式碼。
char str[20] = "hello";
char *ptr = str;
// 使用gdb的ptype 列印 ptype &ptr
列印結果如下:
Breakpoint 1, main (argc=1, argv=0xffffd3f4) at point.c:13
13 char str7[20] = "hello";
(gdb) s
14 char *ptr = str7;
(gdb) s
19 int b = 7;
(gdb) p &str
$1 = (char **) 0xffffd2fc
優化
在code-A和code-B中,我們人工根據引數的型別來獲取引數,使用*(int *)(next_arg)
或*(char **)(next_arg + 4)
。
庫函式printf
顯然不是人工識別引數的型別。
這個函式的第一個引數中包含%d
、%x
、%s
等佔位符。遍歷第一個引數,識別出%d
,就用*(int *)next_arg
替換%d
。識別出
%s
,就用*(char **)next_arg
。
實現了識別佔位符並且根據佔位符選擇指標型別的功能,就能實現一個完成度很高的可變引數了。