跳转到内容

Lesson 18: 实现 printf CNB

练习任务

本课包含三个递进练习,最终实现一个精简版 printf

练习 1(itoa 通用进制):实现 void itoa(int num, char *buf, int base),将整数转为任意进制(2~16)的字符串。输入 100 10 → 输出 100,输入 255 16 → 输出 FF。回顾 Lesson 09 的 itoa——这次通用化,支持任意进制和映射表。

练习 2(myprintf):实现 int myprintf(const char *format, ...),支持 %d(十进制)、%x(十六进制)、%s(字符串)、%c(字符)四种格式符。使用 <stdarg.h>va_list/va_start/va_arg/va_end 处理可变参数。

练习 3(综合):将 itoa 和 myprintf 合并为一个完整程序,输出 "a = 100, b = 0xC8\nc = A, s = helloworld\n"

提示:可变参数的奥秘在于栈帧的内存布局——函数调用时所有参数按顺序依次压栈,va_startap 指向第一个可变参数,va_arg 每次前进一个类型宽度。va_arg(ap, int) 等价于 *(int*)ap; ap += sizeof(int)/sizeof(int*)。理解了这个,就能用15行代码自己实现 va_list

核心知识点

  • 可变参数 va_list — 访问函数不定数量参数的类型和宏集合
  • va_start(ap, last_named) — 将 ap 指向最后一个命名参数之后的位置(栈帧内)
  • va_arg(ap, type) — 从 ap 当前位置取出 type 类型值,并将 ap 推进 sizeof(type)
  • va_end(ap) — 清理 ap(通常为空操作 (void)0
  • 函数调用约定(cdecl) — 参数从右向左压栈,调用方负责清理栈
  • 自实现 va_list — 用 typedef int *va_list + 指针算术模拟标准库
  • itoa 通用进制 — 回顾 Lesson 09,扩展为 itoa(num, buf, base) 支持 2~16 进制
  • 双层 switch-case — 外层解析普通字符/%,内层根据格式符选择处理方式
  • 十六进制字节打印 — (c & 0xF0) >> 4(c & 0x0F) >> 0 提取高/低半字节
  • putchar 逐字符输出 — printf 的基本构件(屏幕 I/O)
  • 函数指针 vs va_listprintf 内部靠 switch 调度,Linux 内核靠函数指针表调度

代码框架

练习 1:通用进制 itoa

itoa_base.c
c
#include <stdio.h>

void itoa(int num, char *buf, int base)
{
    char *hex = "0123456789ABCDEF";
    int i = 0, j;

    do {
        int rest = num % base;        // 取当前位
        buf[i++] = hex[rest];         // 查表转字符
        num /= base;                  // 去掉当前位
    } while (num != 0);

    buf[i] = '\0';

    // 逆序: 因为上面是从低位开始填的
    for (j = 0; j < i / 2; j++)
    {
        char tmp = buf[j];
        buf[j] = buf[i - 1 - j];
        buf[i - 1 - j] = tmp;
    }
}

int main(void)
{
    int num, base;
    char buf[64];

    scanf("%d %d", &num, &base);
    itoa(num, buf, base);
    printf("%s\n", buf);
    return 0;
}

练习 2:myprintf 可变参数打印

myprintf_framework.c
c
#include <stdio.h>
#include <stdarg.h>

void myputs(char *s) { while (*s) putchar(*s++); }

// itoa 同练习 1...

int myprintf(const char *format, ...)
{
    va_list ap;

    /* va_start 初始化 ap 指向第一个可变参数 */
    va_start(ap, format);

    char c;
    while ((c = *format++) != '\0')
    {
        if (c != '%')
        {
            putchar(c);                // 普通字符直接输出
            continue;
        }

        c = *format++;                 // 取 % 后面的格式符
        switch (c)
        {
            char buf[32];
            case 'd':
                itoa(va_arg(ap, int), buf, 10);
                myputs(buf); break;
            case 'x':
                itoa(va_arg(ap, int), buf, 16);
                myputs(buf); break;
            case 's':
                myputs(va_arg(ap, char *)); break;
            case 'c':
                putchar(va_arg(ap, int)); break;  // char 被提升为 int
            default: break;
        }
    }

    va_end(ap);
    return 0;
}

int main(void)
{
    myprintf("a = %d, b = 0x%x\n", 100, 200);
    myprintf("c = %c, s = %s\n", 'A', "helloworld");
    return 0;
}

填充框架的关键思考:va_start(ap, format) 为什么需要 format 作为第二个参数?va_arg(ap, int) 做了哪两件事?%cva_arg 为什么用 int 而不是 char

TIP

先实现 itoa(回顾 Lesson 09,加上 base 参数),再攻 myprintf。如果不确定 va_arg 的行为,可以先用 printf("%p %p %p\n", ...) 打印各个可变参数的地址,观察它们在栈上的排列。


深度讲解

1. 可变参数:超越固定参数列表

1.1 问题:printf 的参数个数是不确定的

c
printf("Hello\n");                          // 1 个参数
printf("num = %d\n", 42);                   // 2 个参数
printf("%d + %d = %d\n", 3, 5, 8);         // 4 个参数

printf 的函数签名用 ... 声明可变参数(variadic arguments),告诉编译器「这个函数可以接受任意数量的额外参数」:

variadic_signature.c
c
int myprintf(const char *format, ...);
//     固定参数(必须至少一个)    ↑ 可变参数列表

C 语言要求可变参数函数至少有一个固定参数——因为 va_start 需要一个锚点来定位可变参数的起始位置。

1.2 va_list / va_start / va_arg / va_end 四大件

va_list_basics.c
c
#include <stdarg.h>

void demo(int count, ...)
{
    va_list ap;                    // 1. 声明: va_list 类型的变量
    va_start(ap, count);           // 2. 初始化: 让 ap 指向 count 之后的位置

    for (int i = 0; i < count; i++)
    {
        int val = va_arg(ap, int); // 3. 取值: 取当前参数值 + 推进 ap
        printf("[%d] = %d\n", i, val);
    }

    va_end(ap);                    // 4. 清理
}

va_list:典型的实现是 char*void*——指向栈上参数区域的指针。 va_start(ap, last):将 ap 指向 last 这个固定参数之后的位置。 va_arg(ap, type):做两件事——(1)把 ap 当前指向的内存解释为 type 类型并返回其值;(2)将 ap 前进 sizeof(type) 字节。 va_end(ap):置 ap = NULL 或空操作,资源清理。

1.3 va_arg 的推进原理(核心)

调用 myprintf("a=%d", 42) 时的栈帧布局 (x86, cdecl 调用约定):

高地址 ┌──────────────────┐
       42 第二个参数 (int, 4 字节)
       ├──────────────────┤
     "a=%d" 第一个参数 (const char *, 4/8 字节)
       ├──────────────────┤
   返回地址 (EIP)  │
       ├──────────────────┤
   旧的 EBP
低地址 └──────────────────┘

va_start(ap, format): ap = (char*)&format + sizeof(format)
 ap 指向 42 所在的位置

va_arg(ap, int):     val = *(int*)ap;         // 读出 42
                     ap = (int*)ap + 1;       // ap 前进 sizeof(int) 字节
                     return val;

核心公式

va_start: ap = (char*)&last_named_param + sizeof(last_named_param)
va_arg:   val = *(type*)ap; ap += sizeof(type)

2. 自实现 va_list:15 行代码理解本质

2.1 不用标准库的 va_list

README 中的 #if 1 / #else 展示了两种实现——系统提供的标准库和自己手写版本。手写版本极简但触及本质:

custom_va_list.c
c
/* 自实现 va_list —— 仅 15 行 */

// va_list 就是一个指向栈上参数的指针
typedef int * va_list;

// ap 指向最后一个命名参数之后的位置
// &A 取地址,+1 把这个指针前进一个 int* 的长度
#define va_start(ap, A)     (ap = (int *)(&(A)) + 1)

// 从 ap 当前位置取 type 类型值,然后将 ap 前进一位
#define va_arg(ap, T)       (*(T *)ap++)

// 空操作
#define va_end(ap)          ((void)0)

逐行分析

c
ap = (int *)(&(A)) + 1
//   └─────┬─────┘   └ 将 int* 指针前进 sizeof(int) 字节
//   取最后一个固定参数 A 的地址,转为 int*
//   结果:ap 指向 A 后面第一个 int 宽度的位置

(*(T *)ap++)
//    └┬─┘ └┬─┘
//  解引用 | 后置自增: 用完 ap 后再前进
//      将 ap 转为 T* 类型

NOTE

这个自实现的 va_list 依赖于 int*&A 的类型匹配。在真实系统中它不够完善(未考虑对齐、浮点参数、32/64 位差异),但完美地展示了本质:va_arg 就是类型安全的指针算术

2.2 自实现 vs 标准库:对比

方面#include <stdarg.h>typedef int * va_list
可移植性跨平台,编译器保证仅 32-bit x86 cdecl
类型安全内置对齐支持int* 假设,无对齐
浮点支持va_arg(ap, double) 正确double 会因对齐而出错
学习价值黑盒透视栈帧机制

3. 栈帧与函数调用约定

3.1 cdecl 调用约定

x86 (32位) 下 C 语言默认的 cdecl 调用约定:

调用 myprintf("a=%d", 100, 200) 前:

1. 参数从右向左压栈 (先压 200, 再压 100, 最后压 "a=%d")
2. CALL 指令将返回地址压栈
3. 函数内部处理完成后,调用方 (caller) 负责清理参数

栈布局 (从高地址到低地址):
┌──────────────────────┐
        200 第四个参数
├──────────────────────┤
        100 第三个参数
├──────────────────────┤
      "a=%d" 第二个参数 (format)
├──────────────────────┤
      返回地址
├──────────────────────┤
    旧的 EBP myprintf 的栈帧基址
└──────────────────────┘

va_start(ap, format): ap = &format + sizeof(char*)
 ap 指向 100 的第一个字节

为什么参数从右向左压栈:保证最左边的固定参数(如 format)离 EBP 最近——va_start 基于它就能推算所有后续参数的位置。

3.2 64 位系统的差异

x86-64 (64位) 使用寄存器传参:
  - 6 个整型参数: RDI, RSI, RDX, RCX, R8, R9
  - 8 个浮点参数: XMM0-XMM7
  - 超出部分通过栈传递

va_list 64 位下更复杂——需要同时追踪寄存器和栈参数
 va_start / va_arg / va_end 的接口不变 (标准库封装了复杂性)

IMPORTANT

自实现的 15 行 va_list 在 64 位系统上不能工作——64 位用寄存器传参,栈布局完全不同。这说明了标准库抽象的价值:接口不变,底层实现随平台变化。


4. itoa 通用进制转换(回顾 Lesson 09)

4.1 从固定进制到通用进制

回顾 Lesson 09 的 itoa 只支持十进制(base = 10)。本课的 itoa 加了 base 参数——核心循环完全不变,只是把 % 10/ 10 中的 10 换成了 base

itoa_generic.c
c
void itoa(int num, char *buf, int base)
{
    const char *map = "0123456789ABCDEF";
    int i = 0, j;

    do {
        buf[i++] = map[num % base];   // 关键改动: base 替代了硬编码的 10
        num /= base;                  // 同上
    } while (num != 0);

    buf[i] = '\0';

    for (j = 0; j < i / 2; j++)       // 逆序(与之前完全相同)
    {
        char tmp = buf[j];
        buf[j] = buf[i - 1 - j];
        buf[i - 1 - j] = tmp;
    }
}

4.2 设计决策:映射表参数

c
// 版本 A: 内置映射表(本课使用——简洁,自动处理 0-9/A-F)
const char *map = "0123456789ABCDEF";
buf[i] = map[num % base];

// 版本 B: 分支判断(冗长,但可以定制任何映射)
if (digit <= 9) buf[i] = '0' + digit;
else            buf[i] = 'A' + (digit - 10);

// 版本 C: 映射表作为参数(最灵活,调用方自定义)
void itoa_map(int num, char *buf, int base, const char *map);

版本 A 是在简洁性和灵活性之间的最佳折中——支持 2~16 进制且只需一行查表。


5. myprintf 格式字符串解析:双层 switch

5.1 解析流程

遍历 "a = %d, b = 0x%x\n"

  ┌─ 不是 '%'? putchar(c), 继续

  └─ '%'? 取下一个字符

            switch (格式符)
 'd' itoa(va_arg(ap,int), buf, 10)
 'x' itoa(va_arg(ap,int), buf, 16)
 's' myputs(va_arg(ap,char*))
 'c' putchar(va_arg(ap,int))
 其他 忽略

双层 switch:外层判断「是否是 %」,内层根据格式符分派不同处理。这正是 printf 内部实现的核心骨架——虽然标准库的实现要复杂得多(宽度、精度、对齐、填充、浮点),但结构本质与我们的 myprintf 完全相同。

5.2 为什么 %c 的 va_arg 用 int 而不是 char

c
case 'c':
    ch = va_arg(ap, int);    // 用 int,不用 char
    putchar(ch);             // 但 putchar 接受的是 int
    break;

原因:C 语言中当 char 作为可变参数传入时,会被**整型提升(integer promotion)**为 int。因此 va_arg(ap, char) 会导致未定义行为——实际从栈上读取了错误的字节数。

整型提升规则char / shortintfloatdouble。所以 printf("%f", 3.14f)va_arg(ap, double) 是对的,va_arg(ap, float) 是错的。

WARNING

所有通过 va_arg 读取的类型都必须匹配提升后的类型,这是可变参数编程中最容易出错的点。

5.3 myprintf 的返回值约定

c
int myprintf(const char *format, ...)
{
    // ...
    return 0;    // 简化版:不跟踪实际输出字符数
}

标准 printf 返回成功输出的字符总数。我们的简化版固定返回 0——要精确计数需要在每处 putchar 后递增计数器。


6. 十六进制字节打印:nibble 位操作

6.1 问题:如何打印 int 的每个字节?

putint_hex.c
c
void putchar_hex(char c)
{
    const char *hex = "0123456789ABCDEF";

    putchar(hex[(c & 0xF0) >> 4]);   // 高半字节 (high nibble)
    putchar(hex[(c & 0x0F) >> 0]);   // 低半字节 (low nibble)
}

void putint_hex(int a)
{
    // 从高字节到低字节依次打印
    putchar_hex((a >> 24) & 0xFF);   // 最高字节
    putchar_hex((a >> 16) & 0xFF);
    putchar_hex((a >> 8)  & 0xFF);
    putchar_hex((a >> 0)  & 0xFF);   // 最低字节
}

位操作分解

c = 0x3F  (二进制: 0011 1111)

 nibble: (c & 0xF0) >> 4
  c        = 0011 1111
  0xF0     = 1111 0000
  & 结果    = 0011 0000
  >> 4      = 0000 0011 = 3 hex[3] = '3'

 nibble: (c & 0x0F) >> 0
  c        = 0011 1111
  0x0F     = 0000 1111
  & 结果    = 0000 1111 = 15 hex[15] = 'F'

输出: "3F"

putint_hex(0x12345678)小端机器上输出 78563412(字节顺序与内存存储一致),在大端机器上输出 12345678(与人类书写习惯一致)——这是 Lesson 12 知识的直接应用。

6.2 映射表的正确声明方式

hex_table_decl.c
c
// 正确: const char * 指向字面量常量(只读,共享)
const char *hex = "0123456789ABCDEF";

// 错误: char[] 在每次函数调用时在栈上重新创建 17 字节副本
char hex[] = "0123456789ABCDEF";    // 不必要的内存浪费!

// 正确的原因: 字符串字面量存储在 .rodata 段,所有调用共享同一份

READEME 中特别注释了 // good// bad——这是一个重要的性能常识。


7. myputs:从字符串到屏幕的最后一步

7.1 最简实现

myputs_impl.c
c
static int myputs(const char *s)
{
    while (*s)               // *s != '\0'
        putchar(*s++);       // 输出字符 + 指针前移
    return 0;
}

*s++ 的执行顺序:先 *s(取当前字符),然后 s++(指针前移)。putchar 输出字符后不必检查返回值——简化版。

7.2 与 putchar 的关系

应用层:   myprintf("a=%d", 100)
 myputs("a=100")
 putchar('a'); putchar('='); putchar('1'); putchar('0'); putchar('0');

内核层:    putchar write(1, buf, 1)  syscall 屏幕驱动程序

每一层抽象都是对上层的服务——myprintf 负责格式化,myputs 负责字符串输出,putchar 负责单字符输出,最终由操作系统处理显示。


8. 完整程序追踪:myprintf 的一次调用全流程

调用: myprintf("a=%d", 100)

1. format = "a=%d", 可变参数 = 100

2. 遍历 format:
   'a' putchar('a')
   '=' putchar('=')
   '%' c = 'd', switch(c):
           case 'd': itoa(va_arg(ap,int), buf, 10)
 va_arg ap 取出 100, ap 前进 4 字节
 itoa(100, buf, 10):
                 do { buf[0]='0'; num=10 } while(10!=0)
                 do { buf[1]='0'; num=1  } while(1!=0)
                 do { buf[2]='1'; num=0  } while(0!=0) → 退出
                 buf[3] = '\0'
                 buf = "001", 逆序后 "100"
 myputs("100"):
               putchar('1'), putchar('0'), putchar('0')

3. format 遍历完毕,va_end(ap)

4. 返回 0

最终屏幕输出: a=100

关键观察va_arg 被调用了一次(取出 100),itoa 产生了三个字符 100myputs 输出了三个字符——整个栈帧操作干净利落。


参考解答

练习1: itoa 通用进制转换
solution_itoa_base.c
c
#include <stdio.h>

void itoa(int num, char *buf, int base)
{
    char *hex = "0123456789ABCDEF";
    int i = 0, j;

    do {
        int rest = num % base;
        buf[i++] = hex[rest];
        num /= base;
    } while (num != 0);

    buf[i] = '\0';

    for (j = 0; j < i / 2; j++)
    {
        char tmp = buf[j];
        buf[j] = buf[i - 1 - j];
        buf[i - 1 - j] = tmp;
    }
}

int main(void)
{
    int num, base;
    char buf[64];

    scanf("%d %d", &num, &base);
    itoa(num, buf, base);
    printf("%s\n", buf);
    return 0;
}

核心:% base 提取当前位,hex[rest] 查表转字符,/= base 去掉已处理位,最后 for (j=0; j<i/2; j++) 逆序字符串。base 为通用参数——2 到 16 任意切换。

练习2: myprintf 可变参数打印
solution_myprintf.c
c
#include <stdio.h>
#include <stdarg.h>

void itoa(int num, char *buf, int base)
{
    char *hex = "0123456789ABCDEF";
    int i = 0, j;
    do { buf[i++] = hex[num % base]; num /= base; } while (num);
    buf[i] = '\0';
    for (j = 0; j < i / 2; j++) { char t = buf[j]; buf[j] = buf[i-1-j]; buf[i-1-j] = t; }
}

void myputs(char *s) { while (*s) putchar(*s++); }

int myprintf(const char *format, ...)
{
    va_list ap;
    va_start(ap, format);

    char c;
    while ((c = *format++) != '\0')
    {
        if (c != '%') { putchar(c); continue; }

        c = *format++;
        switch (c)
        {
            char buf[32];
            case 'd': itoa(va_arg(ap, int), buf, 10); myputs(buf); break;
            case 'x': itoa(va_arg(ap, int), buf, 16); myputs(buf); break;
            case 's': myputs(va_arg(ap, char *)); break;
            case 'c': putchar(va_arg(ap, int)); break;
            default: break;
        }
    }

    va_end(ap);
    return 0;
}

int main(void)
{
    myprintf("a = %d, b = 0x%x\n", 100, 200);
    myprintf("c = %c, s = %s\n", 'A', "helloworld");
    return 0;
}

va_start(ap, format) 定位可变参数起始位置,va_arg(ap, type) 按类型取参并推进指针,外层 while 遍历格式字符串,内层 switch 分配四种格式符。itoa 负责数字格式化,myputs 负责字符串输出。

练习3: 综合版(itoa + myprintf + putchar_hex)
solution_full_printf.c
c
#include <stdio.h>
#include <stdarg.h>

void itoa(int num, char *buf, int base)
{
    char *hex = "0123456789ABCDEF";
    int i = 0, j;
    do { buf[i++] = hex[num % base]; num /= base; } while (num);
    buf[i] = '\0';
    for (j = 0; j < i / 2; j++) { char t = buf[j]; buf[j] = buf[i-1-j]; buf[i-1-j] = t; }
}

void putchar_hex(char c)
{
    const char *hex = "0123456789ABCDEF";
    putchar(hex[(c & 0xF0) >> 4]);
    putchar(hex[(c & 0x0F) >> 0]);
}

void putint_hex(int a)
{
    putchar_hex((a >> 24) & 0xFF);
    putchar_hex((a >> 16) & 0xFF);
    putchar_hex((a >> 8)  & 0xFF);
    putchar_hex((a >> 0)  & 0xFF);
}

void myputs(const char *s) { while (*s) putchar(*s++); }

int myprintf(const char *format, ...)
{
    va_list ap;
    va_start(ap, format);

    char c;
    while ((c = *format++) != '\0')
    {
        if (c != '%') { putchar(c); continue; }
        c = *format++;
        switch (c)
        {
            char buf[32];
            case 'd': itoa(va_arg(ap, int), buf, 10); myputs(buf); break;
            case 'x': itoa(va_arg(ap, int), buf, 16); myputs(buf); break;
            case 's': myputs(va_arg(ap, char *)); break;
            case 'c': putchar(va_arg(ap, int)); break;
            default: break;
        }
    }

    va_end(ap);
    return 0;
}

int main(void)
{
    myprintf("a = %d, b = 0x%x\n", 100, 200);
    myprintf("c = %c, s = %s\n", 'A', "helloworld");
    return 0;
}

完整包含 itoaputchar_hex/putint_hexmyputsmyprintf 四个组件。putint_hex 按字节逆序打印 int 的十六进制表示(小端顺序)。

对照检查va_start(ap, format) 第二个参数是最后一个命名参数吗?va_arg(ap, int)%c 也用的 int 吗?itoa 中对 base=10base=16 都测试过了吗?每个 switch 分支都有 break 了吗?


课堂讨论

  1. printf 的原型是什么?参数和返回值各有什么含义?
  2. 为何用 %c 打印时,传入 va_arg 的第二个参数是 int 类型而不是 char
  3. 自实现的 va_list 中,va_start 宏如何确定第一个可变参数的位置?它依赖什么?
  4. 如何在本课的 myprintf 基础上支持 %f(浮点数打印)?
  5. const char *hexchar hex[] 的区别是什么?在 putchar_hex 中为什么前者是对的?

讨论答案

Q1: printf 的原型、参数和返回值

函数原型int printf(const char *format, ...);

参数

  • format:格式字符串,包含普通字符和以 % 开头的格式说明符
  • ...:可变参数列表,个数和类型由 format 中的格式符决定

返回值:成功输出的字符总数(不含 \0)。如果发生输出错误,返回负值。

printf_return_value.c
c
#include <stdio.h>

int main(void)
{
    int count = printf("Hello\n");
    printf("Printed %d characters\n", count);  // Hello = 5, \n = 1, 共 6
    return 0;
}

与 puts 的区别puts("Hello") 自动追加 \n 输出 6 字符,printf("Hello") 不追加 \n 输出 5 字符。本课的 myprintf 简化返回了 0,真正的 printf 需要维护一个计数器在每处输出后递增。

Q2: 为什么 %c 的 va_arg 用 int 类型?

因为 C 语言的整型提升规则。 当一个 char(或 short)作为可变参数传入时,编译器自动将其提升为 int

promotion_demo.c
c
#include <stdio.h>
#include <stdarg.h>

void demo(int count, ...)
{
    va_list ap;
    va_start(ap, count);

    for (int i = 0; i < count; i++)
    {
        char ch = va_arg(ap, int);   // ✓ 正确:用 int 读取
        // char ch = va_arg(ap, char); // ✗ 错误:char 会导致未定义行为
        putchar(ch);
    }
    va_end(ap);
}

int main(void)
{
    demo(2, 'A', 'B');    // 'A' 和 'B' 在传入时被提升为 int
    return 0;
}

整型提升规则

  • char / signed char / unsigned char / shortint
  • floatdouble

违反此规则(用 va_arg(ap, char))会导致读取了错误的字节数——栈上的实际存储是 int(4 字节),但 char 只读 1 字节。

Q3: 自实现 va_start 如何定位第一个可变参数?

自实现 va_start(ap, A) 等价于 ap = (int*)(&A) + 1 它依赖两个假设:

  1. 栈帧中参数是按顺序连续排列的——最后一个固定参数的后面就是第一个可变参数
  2. 指针尺寸是 int* 的步长——(int*)ptr + 1 前进 sizeof(int) 字节
假设调用: myprintf("fmt", 100, 200, 300)

栈帧 (32-bit):
┌──────┬──────┬──────┬──────┐
"fmt" 100 200 300
├──────┼──────┼──────┼──────┤
&format                           &format + sizeof(int*) = &format + 4
 (int*)(&format) + 1 指向 100 的首字节

va_start(ap, format): ap 指向 100 ✓
va_arg(ap, int): 取 100, ap 前进 → 指向 200
va_arg(ap, int): 取 200, ap 前进 → 指向 300

局限:这在 64 位用寄存器传参的系统上失效——参数在寄存器中,不在栈上连续排列。标准库的 va_list 由编译器内置支持(__builtin_va_list),才能跨平台正确工作。

Q4: 如何在 myprintf 中支持 %f?

需要两步:

第一步:自己实现浮点数转字符串(不能用标准库的 sprintf):

itoa_float.c
c
void ftoa(double val, char *buf, int precision)
{
    // 处理整数部分 + 小数点 + 小数部分
    int ipart = (int)val;
    double fpart = val - ipart;

    itoa(ipart, buf, 10);           // 整数部分
    int ip_len = 0;
    while (buf[ip_len]) ip_len++;

    buf[ip_len++] = '.';

    // 小数部分:乘以 10^precision 后取整
    for (int i = 0; i < precision; i++)
        fpart *= 10;
    itoa((int)(fpart + 0.5), buf + ip_len, 10);  // 四舍五入
}

第二步:在 myprintf 中加 %f 分支

c
case 'f':
{
    char fbuf[64];
    double d = va_arg(ap, double);    // 注意:float 被提升为 double
    ftoa(d, fbuf, 6);                 // 默认 6 位小数
    myputs(fbuf);
    break;
}

难点:浮点数转字符串涉及精度、舍入、科学计数法——标准库的 printf 实现中,%f 的代码量比 %d+%x+%s+%c 加起来还多好几倍。

Q5: const char *hex 和 char hex[] 的区别?

const char *hex = "0123456789ABCDEF"

  • 指针 hex 指向 .rodata 段(只读数据段)中的字符串字面量
  • 字符串在程序启动时已存在,整个程序中只有一份
  • 每次调用 putchar_hex 时只取 8 字节的指针值,不创建新副本

char hex[] = "0123456789ABCDEF"

  • 上分配 17 字节(16 字符 + \0),每次调用都重新分配和初始化
  • 这 17 字节是从 .rodata 中复制到栈上的——浪费时间和内存
hex_memory_compare.c
c
// 正确: 高效
void putchar_hex(const char c)
{
    const char *hex = "0123456789ABCDEF";  // 只存一个 8 字节指针
    // ...
}

// 错误: 浪费
void putchar_hex_bad(char c)
{
    char hex[] = "0123456789ABCDEF";       // 每次在栈上创建 17 字节!
    // ...
}

在频繁调用高频函数中,这种看似微小的差异有实际的性能影响——这就是 // good// bad 注释的价值。


课后练习

  1. 可变参数 max 函数:实现 int max(int count, ...),返回所有传入参数中的最大值。例如 max(3, 10, 5, 20)20

    知识点提示:第一个参数指明后续参数个数、va_arg 循环取出所有值、最大值更新模式

  2. 实现 mysprintf:基于 myprintf 实现 int mysprintf(char *buf, const char *format, ...),将格式化结果写入字符串缓冲区而非屏幕。

    知识点提示:将 putchar 替换为写入缓冲区递增索引、buf 末尾加 \0、返回写入字符数

  3. printf 格式符统计:实现一个 count_formats(const char *fmt, ...) 函数,接收和 printf 相同的参数但不输出,只统计 %d%x%s%c 各出现了多少次。

    知识点提示:格式字符串遍历+分类、va_list 不消费参数(只遍历格式部分,用另一套 va_copy 跳过参数)

练习1: 可变参数 max
ex1_variadic_max.c
c
#include <stdio.h>
#include <stdarg.h>

int max(int count, ...)
{
    va_list ap;
    va_start(ap, count);

    int max_val = va_arg(ap, int);    // 先取第一个值作为初始最大

    for (int i = 1; i < count; i++)
    {
        int val = va_arg(ap, int);
        if (val > max_val)
            max_val = val;
    }

    va_end(ap);
    return max_val;
}

int main(void)
{
    printf("max(3, 10, 5, 20) = %d\n", max(3, 10, 5, 20));   // 20
    printf("max(4, 1, 2, 3, 4) = %d\n", max(4, 1, 2, 3, 4)); // 4
    return 0;
}

第一个参数 count 是参数个数——因为可变参数函数无法自动知道有多少参数。va_arg 循环取每个参数并与当前最大值比较,最后返回最大值。

练习2: mysprintf 实现
ex2_mysprintf.c
c
#include <stdio.h>
#include <stdarg.h>

/* ... itoa 和 myputs 不变 ... */

static char *sprintf_buf;     // 输出目标缓冲区
static int sprintf_pos;       // 当前写入位置

static void sputchar(char c)
{
    sprintf_buf[sprintf_pos++] = c;
}

int mysprintf(char *buf, const char *format, ...)
{
    sprintf_buf = buf;
    sprintf_pos = 0;

    va_list ap;
    va_start(ap, format);

    char c;
    while ((c = *format++) != '\0')
    {
        if (c != '%') { sputchar(c); continue; }
        c = *format++;
        switch (c)
        {
            char tmp[32];
            case 'd': itoa(va_arg(ap, int), tmp, 10); myputs(tmp); break;
            case 's':
            {
                char *s = va_arg(ap, char *);
                while (*s) sputchar(*s++);
                break;
            }
        }
    }

    buf[sprintf_pos] = '\0';    // 末尾加终止符
    va_end(ap);
    return sprintf_pos;
}

核心改动:用 sputchar 替代 putchar——将字符写入缓冲区而非屏幕。全局变量 sprintf_bufsprintf_pos 跟踪写入位置。

练习3: 格式符统计(不消费参数的 va_copy)
ex3_format_count.c
c
#include <stdio.h>
#include <stdarg.h>

int count_formats(const char *fmt, ...)
{
    int d = 0, x = 0, s = 0, c = 0;
    int i = 0;
    va_list ap, ap_copy;

    va_start(ap, fmt);
    va_copy(ap_copy, ap);   // 复制一份 ap(用于跳过参数)

    while (fmt[i])
    {
        if (fmt[i] == '%' && fmt[i+1])
        {
            switch (fmt[i+1])
            {
                case 'd': d++; va_arg(ap_copy, int);   break;
                case 'x': x++; va_arg(ap_copy, int);   break;
                case 's': s++; va_arg(ap_copy, char*); break;
                case 'c': c++; va_arg(ap_copy, int);   break;
            }
            i++;
        }
        i++;
    }

    va_end(ap_copy);
    va_end(ap);
    printf("%%d=%d %%x=%d %%s=%d %%c=%d\n", d, x, s, c);
    return d + x + s + c;
}

va_copy 是 C99 的宏——复制一份 va_list,让两个独立迭代器互不影响。ap_copy 用于消耗参数(同步前进),原始 ap 不被修改。


参考资料

"The computer was born to solve problems that did not exist before." — Bill Gates

Released under the MIT License.