C语言中可变参数函数的实现(printf()
的实现)
文章目录
1. 可变参数的实现
1.1 参数的地址如何获取
1.1.1 参数地址观察👀
首先看一下程序里面的参数是怎样存储的,看下面这段程序⬇️
我们在函数print_v_address
中使用三个参数v0, v1, v2
,并分别输出三个参数的地址。
void print_v_address(int v0, int v1, int v2) {
printf("v0_addr = %p\n", &v0);
printf("v1_addr = %p\n", &v1);
printf("v2_addr = %p\n", &v2);
}
print_v_address(0, 1000, 1);
当我们得到的输出为⬇️
v0_addr = 0x16b34b32c
v1_addr = 0x16b34b328
v2_addr = 0x16b34b324
可以发现输出地址是连续的,并且是递减的,则上述程序和输出可以抽象为⬇️
从图中可以看出,我们的函数变量是存在栈上的。
——因为地址是递减的,再加上我们的知识储备(其实一般来说前8个变量是存在栈上的),可以确定这一点⬆️。
1.1.2 参数地址获取
根据1.1.1中的观察,我们可以通过第一个参数的地址来获取后续参数的地址⬇️
void get_values(int v0, int v1, int v2) {
uint64_t v0_p, v1_p, v2_p;
v0_p = (uint64_t)(&v0);
v1_p = v0_p - sizeof(v0);
v2_p = v1_p - sizeof(v1);
printf("v0 = %d\n", *((int*)v0_p));
printf("v1 = %d\n", *((int*)v1_p));
printf("v2 = %d\n", *((int*)v2_p));
}
// get_values(0, 100, 1);
上述函数的主要功能就是,将v0地址取出来,并且根据v0类型,获取到v1地址(使用v0地址减去v0的大小),v2地址同理也可以计算得到。
调用上述函数的输出如下⬇️,可以看到我们已经根据v0地址,还有参数个数和类型,正确获取到了所有变量v0,v1,v2的内容。
v0 = 0
v1 = 100
v2 = 1
1.2 参数的个数和类型如何确定?——尝试自己写一个printf?
1.2.1 printf的传入参数怎么写?(参数个数和类型如何确定?)
xv6系统(一个类Unix系统)中的printf定义如下⬇️
void
printf(char *fmt, ...)
{
...
}
这个fmt是什么意思呢?
——平时我们使用的printf如下⬇️,从这里大致就能看出来,printf的第一个传入参数应该是一个字符串,里面会包含一些%d,%p,%s…等信息。因此上面的printf的实现中char *fmt
是什么就显而易见了(就是形如"a = %d, str = %s, ptr = %p"
的字符串)。
printf("a = %d, str = %s, ptr = %p", a, str, p);
重新解释一下printf中的可变参数实现:
其实就是通过char *fmt中包含的%d,%p,%s … 来识别传入参数的个数和数量的。
1.2.3 自己动手实现一个传入参数个数和类型的函数⬇️
// 仅支持%d和%s输出
void my_first_printf (char *fmt, ...) {
char *cur_p = fmt;
char* cur_v_p = (char*)&fmt;
printf("%s\n", *((char* *)cur_v_p));
char *p = (char *)&fmt;
p -= 16;
printf("%s\n", *((char* *)p));
char c = 0;
for (int i = 0; (c = fmt[i]) != '\0'; ++i) {
if (c != '%') {
printf("%c", c);
continue;
}
if (c == '%') {
char c_next = fmt[++i];
switch(c_next) {
case 'd':
cur_v_p -= sizeof(int);
printf("%d", *((int *)cur_v_p));
fflush(stdout);
break;
case 's':
cur_v_p -= sizeof(char*);
printf("%s", *((char* *)cur_v_p));
fflush(stdout);
break;
}
}
}
}
int main() {
int a = 100000;
printf("helo. a = %d, str = %s\n", a, "aloha");
exit(0);
}
这里仅作演示,其中fflush是为了flush输出缓冲区,防止内容不能正确输出。
输出结果为⬇️
helo. a = 100000, str = aloha
可以发现上面的内容可以做进一步优化,即,地址计算和变量输出每次都用指针操作有点麻烦了。其实真正的printf中是使用va_list
宏来实现的。
PS: 为什么只写了%d和%s的识别?
——不是因为我懒,多种类型组合起来,我暂时识别不了——因为具体怎么存的,要问编译器,当参数类型比较多的时候,在不清楚编译器对变量的存储规则之前,我们是很难准确找到每个参数的地址的。
——所以更要使用va_list
宏了,因为它是知道编译器的对参数的存储规则的。
2. va_list
宏
主要包含以下几个宏⬇️(这不是我的机器里面的,是从别处找到的,我的机器上只能使用宏,但是看不到它的具体定义。)
#define va_list char* /* 可变参数地址 */
#define va_start(ap, x) ap=(char*)&x+sizeof(x) /* 初始化指针指向第一个可变参数 */
#define va_arg(ap, t) (ap-=sizeof(t),*((t*)(ap-sizeof(t)))) /* 取得参数值,同时移动指针指向后续参数 */
#define va_end(ap) ap=0 /* 结束参数处理 */
其实内容也很简单,就是实现我们的地址计算的功能。
但是这里需要注意的是:我们的地址计算并不是通用的,这里的地址是由编译器的特点决定的,因此需要首先确定编译器的类型和CPU的型号,才能确定地址是如何计算的(比如,博文 中,地址就是递增的,而不是像我们这样递减操作,此外我在实验过程中,还发现了一些奇怪的现象:比如地址并不是 -= sizeof(char *),而是减了12,我猜测可能和对齐有关,总之,定义va_list
的时候一定要确定编译器是如何分配地址的才行)。
3.实际上的 printf()
实现⬇️
下面是摘自 xv6内核中的printf函数实现⬇️。
// Print to the console. only understands %d, %x, %p, %s.
void
printf(char *fmt, ...)
{
va_list ap;
int i, c, locking;
char *s;
locking = pr.locking;
if(locking)
acquire(&pr.lock);
if (fmt == 0)
panic("null fmt");
va_start(ap, fmt);
for(i = 0; (c = fmt[i] & 0xff) != 0; i++){
if(c != '%'){
consputc(c);
continue;
}
c = fmt[++i] & 0xff;
if(c == 0)
break;
switch(c){
case 'd':
printint(va_arg(ap, int), 10, 1);
break;
case 'x':
printint(va_arg(ap, int), 16, 1);
break;
case 'p':
printptr(va_arg(ap, uint64));
break;
case 's':
if((s = va_arg(ap, char*)) == 0)
s = "(null)";
for(; *s; s++)
consputc(*s);
break;
case '%':
consputc('%');
break;
default:
// Print unknown % sequence to draw attention.
consputc('%');
consputc(c);
break;
}
}
if(locking)
release(&pr.lock);
}
可以看到,这里和我们实现的区别主要有:
- 加锁;——这是显然的,防止其他进程 / 线程打断,导致printf输出一半;
- 使用constputc来输出内容;——原因同样显而易见,我们在定义printf,肯定只能使用更加底层的函数调用。——其实再往里是调用了uartputc,即往uart设备中输出一个字符串,uart就是我们的console设备的名称。
- 不同的类型,有不同的print函数,比如
printint
,printptr
等,其实里面还是使用uartputc,并且做了一些取余操作,以便以正确的格式输出(正确的进制)。