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_start让ap指向第一个可变参数,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_list—printf内部靠 switch 调度,Linux 内核靠函数指针表调度
代码框架
练习 1:通用进制 itoa
#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 可变参数打印
#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) 做了哪两件事?%c 的 va_arg 为什么用 int 而不是 char?
TIP
先实现 itoa(回顾 Lesson 09,加上 base 参数),再攻 myprintf。如果不确定 va_arg 的行为,可以先用 printf("%p %p %p\n", ...) 打印各个可变参数的地址,观察它们在栈上的排列。
深度讲解
1. 可变参数:超越固定参数列表
1.1 问题:printf 的参数个数是不确定的
printf("Hello\n"); // 1 个参数
printf("num = %d\n", 42); // 2 个参数
printf("%d + %d = %d\n", 3, 5, 8); // 4 个参数printf 的函数签名用 ... 声明可变参数(variadic arguments),告诉编译器「这个函数可以接受任意数量的额外参数」:
int myprintf(const char *format, ...);
// 固定参数(必须至少一个) ↑ 可变参数列表C 语言要求可变参数函数至少有一个固定参数——因为 va_start 需要一个锚点来定位可变参数的起始位置。
1.2 va_list / va_start / va_arg / va_end 四大件
#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 展示了两种实现——系统提供的标准库和自己手写版本。手写版本极简但触及本质:
/* 自实现 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)逐行分析:
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:
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 设计决策:映射表参数
// 版本 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
case 'c':
ch = va_arg(ap, int); // 用 int,不用 char
putchar(ch); // 但 putchar 接受的是 int
break;原因:C 语言中当 char 作为可变参数传入时,会被**整型提升(integer promotion)**为 int。因此 va_arg(ap, char) 会导致未定义行为——实际从栈上读取了错误的字节数。
整型提升规则:char / short → int,float → double。所以 printf("%f", 3.14f) 中 va_arg(ap, double) 是对的,va_arg(ap, float) 是错的。
WARNING
所有通过 va_arg 读取的类型都必须匹配提升后的类型,这是可变参数编程中最容易出错的点。
5.3 myprintf 的返回值约定
int myprintf(const char *format, ...)
{
// ...
return 0; // 简化版:不跟踪实际输出字符数
}标准 printf 返回成功输出的字符总数。我们的简化版固定返回 0——要精确计数需要在每处 putchar 后递增计数器。
6. 十六进制字节打印:nibble 位操作
6.1 问题:如何打印 int 的每个字节?
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 映射表的正确声明方式
// 正确: const char * 指向字面量常量(只读,共享)
const char *hex = "0123456789ABCDEF";
// 错误: char[] 在每次函数调用时在栈上重新创建 17 字节副本
char hex[] = "0123456789ABCDEF"; // 不必要的内存浪费!
// 正确的原因: 字符串字面量存储在 .rodata 段,所有调用共享同一份READEME 中特别注释了 // good 和 // bad——这是一个重要的性能常识。
7. myputs:从字符串到屏幕的最后一步
7.1 最简实现
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 产生了三个字符 100,myputs 输出了三个字符——整个栈帧操作干净利落。
参考解答
练习1: itoa 通用进制转换
#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 可变参数打印
#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)
#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;
}完整包含 itoa、putchar_hex/putint_hex、myputs、myprintf 四个组件。putint_hex 按字节逆序打印 int 的十六进制表示(小端顺序)。
对照检查:
va_start(ap, format)第二个参数是最后一个命名参数吗?va_arg(ap, int)对%c也用的int吗?itoa中对base=10和base=16都测试过了吗?每个switch分支都有break了吗?
课堂讨论
- printf 的原型是什么?参数和返回值各有什么含义?
- 为何用
%c打印时,传入va_arg的第二个参数是int类型而不是char? - 自实现的
va_list中,va_start宏如何确定第一个可变参数的位置?它依赖什么? - 如何在本课的 myprintf 基础上支持
%f(浮点数打印)? const char *hex和char hex[]的区别是什么?在 putchar_hex 中为什么前者是对的?
讨论答案
Q1: printf 的原型、参数和返回值
函数原型:int printf(const char *format, ...);
参数:
format:格式字符串,包含普通字符和以%开头的格式说明符...:可变参数列表,个数和类型由format中的格式符决定
返回值:成功输出的字符总数(不含 \0)。如果发生输出错误,返回负值。
#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。
#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/short→intfloat→double
违反此规则(用 va_arg(ap, char))会导致读取了错误的字节数——栈上的实际存储是 int(4 字节),但 char 只读 1 字节。
Q3: 自实现 va_start 如何定位第一个可变参数?
自实现 va_start(ap, A) 等价于 ap = (int*)(&A) + 1。 它依赖两个假设:
- 栈帧中参数是按顺序连续排列的——最后一个固定参数的后面就是第一个可变参数
- 指针尺寸是
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):
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 分支:
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中复制到栈上的——浪费时间和内存
// 正确: 高效
void putchar_hex(const char c)
{
const char *hex = "0123456789ABCDEF"; // 只存一个 8 字节指针
// ...
}
// 错误: 浪费
void putchar_hex_bad(char c)
{
char hex[] = "0123456789ABCDEF"; // 每次在栈上创建 17 字节!
// ...
}在频繁调用高频函数中,这种看似微小的差异有实际的性能影响——这就是 // good 和 // bad 注释的价值。
课后练习
可变参数 max 函数:实现
int max(int count, ...),返回所有传入参数中的最大值。例如max(3, 10, 5, 20)→20。知识点提示:第一个参数指明后续参数个数、
va_arg循环取出所有值、最大值更新模式实现 mysprintf:基于 myprintf 实现
int mysprintf(char *buf, const char *format, ...),将格式化结果写入字符串缓冲区而非屏幕。知识点提示:将
putchar替换为写入缓冲区递增索引、buf末尾加\0、返回写入字符数printf 格式符统计:实现一个
count_formats(const char *fmt, ...)函数,接收和printf相同的参数但不输出,只统计%d、%x、%s、%c各出现了多少次。知识点提示:格式字符串遍历+分类、
va_list不消费参数(只遍历格式部分,用另一套 va_copy 跳过参数)
练习1: 可变参数 max
#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 实现
#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_buf 和 sprintf_pos 跟踪写入位置。
练习3: 格式符统计(不消费参数的 va_copy)
#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 不被修改。
参考资料
- ISO C99 Standard, Section 7.15 —
<stdarg.h>中 va_list/va_start/va_arg/va_end 的完整规范 - x86 Calling Conventions (cdecl) — cdecl 调用约定的栈帧布局与参数传递顺序
- The C Programming Language, Section 7.3 — Kernighan & Ritchie 对可变参数与
printf实现的经典讲解 - GCC Built-in va_arg — GCC 对
__builtin_va_list的内置实现与调用约定适配
"The computer was born to solve problems that did not exist before." — Bill Gates