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));
}
上述函数的主要功能就是,将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 自己动手实现一个传入参数个数和类型的函数??
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 宏来实现的。
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函数实现??。
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:
consputc('%');
consputc(c);
break;
}
}
if(locking)
release(&pr.lock);
}
可以看到,这里和我们实现的区别主要有:
- 加锁;——这是显然的,防止其他进程 / 线程打断,导致printf输出一半;
- 使用constputc来输出内容;——原因同样显而易见,我们在定义printf,肯定只能使用更加底层的函数调用。——其实再往里是调用了uartputc,即往uart设备中输出一个字符串,uart就是我们的console设备的名称。
- 不同的类型,有不同的print函数,比如
printint , printptr 等,其实里面还是使用uartputc,并且做了一些取余操作,以便以正确的格式输出(正确的进制)。
|