C语言中可变参数函数的实现——printf

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

可以发现输出地址是连续的,并且是递减的,则上述程序和输出可以抽象为⬇️

image-20220907154736443

从图中可以看出,我们的函数变量是存在栈上的。

——因为地址是递减的,再加上我们的知识储备(其实一般来说前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,并且做了一些取余操作,以便以正确的格式输出(正确的进制)。
  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值