SoC仿真环境中自定义printf函数的实现
博主在做SoC芯片级验证编写c语言测试激励的时候发现printf函数都被一个自定义的打印函数给替代了,所以就学习了一下这些函数的实现过程及为什么要在SoC仿真环境中自定义打印函数而不是直接调用c标准库的printf函数。下面先介绍我做过的一个arm核和一个riscv核两个SoC项目中自定义printf函数的实现方式。
1.arm核SoC仿真环境中的自定义a_printf函数
a_printf 函数使用了可变参数(...)来模拟标准库的 printf 功能,其中va_list,va_end
和va_start
是处理可变参数函数(类似printf
)的核心宏,用于访问不确定数量的函数参数。
va_list
:声明一个指针,用于遍历可变参数列表
va_start
:初始化va_list
,使其指向第一个可变参数
va_end
:用于清理 va_list
指针,结束可变参数的访问,防止内存或资源泄漏(必须和 va_start
配对使用)
a_printf函数的核心是将格式化逻辑委托给 vprintfmt
函数(下面会讲),并通过强制转换的 putchar_custom
回调函数实现输出目标定制(例如输出到仿真器控制台、串口或内存缓冲区)。
void a_printf(const char* fmt, ...)
{// 1. 初始化可变参数列表va_list ap; // 定义可变参数指针(通常实现为char*)va_start(ap, fmt); // 将ap指向fmt之后的第一个参数(通过栈指针或寄存器定位)// 2. 调用核心格式化引擎// 参数说明:// (void*)putchar_custom - 将字符输出函数强制转换为泛型指针// 0 - 输出设备的上下文(未使用)// fmt - 格式化字符串// ap - 可变参数列表vprintfmt((void*)putchar_custom, 0, fmt, ap);// 3. 清理可变参数列表va_end(ap);
}
vprintfmt
函数实现了一个轻量级格式化输出引擎,其核心功能是解析类似printf
的格式字符串(如%d
、%s
),通过回调函数putch
逐字符输出结果,支持整数(十进制/十六进制/八进制)、字符、字符串等基础格式化,并允许控制对齐、填充、宽度等格式,专为嵌入式或仿真环境设计,避免了标准库printf
的资源开销和硬件依赖,可直接重定向输出到自定义设备(如仿真器控制台或内存映射IO),vprintfmt
函数代码比较复杂,大致了解作用就行。
/*** 格式化输出核心函数(类似标准库的vprintf)* @param putch 字符输出回调函数,参数为(int字符, void*用户数据)* @param putdat 传递给putch的额外数据* @param fmt 格式字符串(如"%d %s")* @param ap 可变参数列表*/
static void vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
{register const char* p; // 临时字符串指针const char* last_fmt; // 记录格式字符串回溯位置register int ch, err; // 当前字符和错误码(寄存器优化)unsigned long long num; // 存储数值参数int base, lflag, width, precision, altflag; // 格式控制参数char padc; // 填充字符(空格或0)// 主循环:处理整个格式字符串while (1) {// 处理普通字符(非'%'部分)while ((ch = *(unsigned char *) fmt) != '%') {if (ch == '\0') return; // 遇到字符串结束符则退出fmt++; // 移动格式字符串指针putch(ch, putdat); // 输出当前字符}fmt++; // 跳过'%'字符/* 开始处理格式化指令(如%-08d中的符号、宽度等)*/last_fmt = fmt; // 记录当前位置(用于错误回溯)padc = ' '; // 默认填充空格width = -1; // 默认无宽度限制precision = -1; // 默认无精度限制lflag = 0; // 默认非long类型altflag = 0; // 默认无替代格式(如0x前缀)reswitch:// 解析格式控制字符switch (ch = *(unsigned char *) fmt++) {/* 标志位处理('-', '0', '#'等)*/case '-': padc = '-'; goto reswitch; // 左对齐case '0': padc = '0'; goto reswitch; // 用0填充case '#': altflag = 1; goto reswitch; // 替代格式(如0x)/* 宽度处理(数字或*)*/case '1'...'9': // 数字宽度(如%10d)for (precision = 0; ; ++fmt) {precision = precision * 10 + ch - '0';ch = *fmt;if (ch < '0' || ch > '9') break;}goto process_precision;case '*': // 动态宽度(如%*d)precision = va_arg(ap, int);goto process_precision;case '.': // 精度开始标记if (width < 0) width = 0;goto reswitch;/* 类型长度修饰(l)*/case 'l':lflag++; // long/long long标志goto reswitch;/* 具体数据类型处理 */case 'c': // 字符(%c)putch(va_arg(ap, int), putdat);break;case 's': // 字符串(%s)if ((p = va_arg(ap, char *)) == NULL)p = "(null)"; // NULL保护// 处理对齐和填充if (width > 0 && padc != '-') // 右侧填充for (width -= strnlen(p, precision); width > 0; width--)putch(padc, putdat);// 输出字符串内容for (; (ch = *p) != '\0' && (precision < 0 || --precision >= 0); p++)putch(ch, putdat);// 左侧填充(左对齐时)for (; width > 0; width--)putch(' ', putdat);break;/* 数值类型处理 */case 'd': // 有符号十进制(%d)num = getint(&ap, lflag);if ((long long)num < 0) { // 处理负数putch('-', putdat);num = -(long long)num;}base = 10;goto signed_number;case 'u': // 无符号十进制(%u)base = 10;goto unsigned_number;case 'o': // 八进制(%o)base = 8;goto unsigned_number;case 'p': // 指针(%p)lflag = 1; // 强制long类型putch('0', putdat); // 输出0x前缀putch('x', putdat);/* 继续执行x的case */case 'x': // 十六进制(%x/%X)base = 16;unsigned_number:num = getuint(&ap, lflag); // 获取无符号数signed_number:printnum(putch, putdat, num, base, width, padc); // 实际数字打印break;case '%': // 转义%%输出%putch(ch, putdat);break;default: // 未知格式指令putch('%', putdat); // 原样输出%fmt = last_fmt; // 回溯到格式开始位置break;}}
}
2.riscv核SoC仿真环境中的自定义tb_printf函数
tb_printf函数和上面的a_printf函数类似,代码如下
int tb_printf(char *fmt, ...)
{char buf[1024]; // 输出缓冲区(栈空间,固定1024字节)char *p; // 缓冲区指针va_list args; // 可变参数列表int n = 0; // 返回值(当前未使用,始终返回0)/ /初始化可变参数列表 va_start(args, fmt); // 将 args 指向第一个可变参数// 格式化字符串处理 // 将格式化后的字符串写入 buf,返回实际长度(未使用返回值)ee_vsprintf(buf, fmt, args); //清理可变参数列表va_end(args); // 结束可变参数访问//准备输出p = buf; // 让指针指向格式化后的字符串起始位置//调用汇编级输出函数// 将缓冲区内容通过底层硬件接口输出(如串口/UART)asm_print(p);return n; // 返回0
}
其中调用的asm_print是使用riscv汇编函数写的,代码如下可供参考
.globl asm_print //伪指令,用于声明一个符号(通常是函数或变量名)为全局可见的,使得该符号可以被其他文件或模块访问
asm_print:nop // 对齐或调试占位
asm_contex_save:ASM_CONTEX_SAVE() // 保存调用者寄存器上下文mv s1, a0 // s1 = 字符串起始地址(a0为第一个参数)
asm_print_body:lw s2, 0x0(s1) // 加载32位字到s2li s3, chip_base | print_addr // s3 = 硬件输出地址sw s2, 0x0(s3) // 将32位字写入硬件addi s1, s1, 4 // 指针后移4字节andi s4, s2, 0xff // 检查字节0(位7:0)beqz s4, asm_print_endsrli s2, s2, 8 // 右移8位andi s4, s2, 0xff // 检查字节1(位15:8)beqz s4, asm_print_endsrli s2, s2, 8 // 右移8位andi s4, s2, 0xff // 检查字节2(位23:16)beqz s4, asm_print_endsrli s2, s2, 8 // 右移8位andi s4, s2, 0xff // 检查字节3(位31:24)beqz s4, asm_print_endj asm_print_body // 继续循环asm_print_end:nop // 对齐或调试占位
asm_contex_restore:ASM_CONTEX_RESTORE() // 恢复调用者寄存器ret // 函数返回
asm_print函数 通过32位字批量写入方式将字符串内容输出到指定的硬件地址(如串口,UART)。其特点是每次读取4字节数据写入硬件,并逐字节检测NULL终止符;通过保存/恢复寄存器上下文保证函数安全性,适用于嵌入式系统或内核早期的低层调试输出。
在实际使用中虽然tb_printf函数可以带参数打印信息,而asm_print函数仅能打印字符串,但asm_print函数仿真速度很快,所以我一般用asm_print函数。
3.为什么SoC仿真要自定义printf
?
-
硬件无关性
标准printf
依赖UART/OS驱动,仿真环境可能没有真实硬件。自定义函数直接输出到仿真器控制台或日志。 -
节省资源
标准库printf
代码大(含浮点等),自定义版可精简到几百字节,适合Flash/ROM受限的嵌入式场景。 -
确定性输出
仿真需要严格时序同步,标准printf
可能有缓冲延迟,自定义函数确保即时输出。 -
扩展功能
可添加仿真专用功能,如自动打时间戳、非标准格式(%r
打印寄存器值)。 -
裸机兼容性
无OS环境(如Bootloader)可能无C库支持,自定义printf
不依赖运行时。 -
安全可靠
避免标准库潜在的缓冲区溢出,自定义实现可严格限制输出长度,防止崩溃。