多角度深入理解printf函数

一、函数简介

printf()函数属于C语言标准库函数,用于将格式化后的字符串输出到标准输出stdout。标准输出,即标准输出文件。printf()是和平台相关的函数。在PC上,printf()是输出到终端屏幕,终端屏幕即为对应的标准输出;在嵌入式设备上,printf()一般输出到串口,串口即为对应的标准输出。

1、常见的函数原型

int printf(const char *format, ...)

2、使用时需要包含头文件

#include <stdio.h>

3、调用格式

printf("<格式化字符串>", <参量表>);

参量表:包含0个或多个参量,每个参量可以是变量、常量、表达式、函数等。

格式化字符串:包含每个参量项对应的转换说明。

转换说明:把以二进制格式存储在计算机中的值转换成一系列字符(字符串)以便于显示。例如:

printf("Today is May %d.\n", 25);

其中%d的意思是把给定的值翻译成十进制整数文本并打印出来。

4、返回值

如果函数执行成功,则返回写入的字符个数;如果有输出错误,则返回一个负值(printf()的旧版本会返回不同的值)。

二、库函数编译链接过程

C编程的基本策略是,用程序把源代码文件转换为可执行文件(其中包含可直接运行的机器语言代码)。典型的C实现通过编译和链接两个步骤来完成这一过程。编译器把源代码转换成中间代码(这里我们主要介绍目标代码文件这种形式),链接器再把中间代码和其他代码合并,生成可执行文件。编译器和连接器的具体执行流程如下图所示(假设源文件只有一个concrete.c):

编译器和连接器

1、目标代码

源代码(concrete.c)的机器语言代码。

2、库代码

库函数的目标代码。

3、启动代码

针对不同的硬件,不同的操作系统有所差别。在PC上,启动代码充当着程序和操作系统之间的接口;在嵌入式设备上,启动代码完成特定硬件的初始化等操作。

一个完整的程序一般要包含目标代码、库代码和启动代码三个部分才能正确在机器上运行。

如果用户在程序中用到printf(),printf()就以printf()的库函数的目标代码形式被链接器链接进最后的可执行代码中去。

三、栈

1、RAM和栈的关系

RAM用来保存程序运行过程中正在使用的数据,由于不带操作系统的嵌入式工程RAM空间比较简单,下面以此为例进行介绍。下图是RAM的空间分配情况。

RAM空间分配

RW区:存放的是已经初始化且初始化不为0的全局变量和局部静态变量。

ZI区:存放未被初始化或者初始化为0的全局变量和局部静态变量。

:保存程序的局部变量,保存函数调用的传递的参数和返回值,自内存高地址向低地址方向生长。

SP栈顶指针位置根据代码和stack设定的大小计算出来的,没有固定在地址的末端。

RAM的空间分配情况在编译器编译之后,就确定了,可以在keil里通过查看编译后的map文件进行查看。

可以在map文件中查看__initial_sp设置的sp的初始地址,查看栈顶指针的位置。

2、有RAM了为什么还需要栈?

在C语言中,假设我们有这样的一个函数:int function(int a,int b)

调用时只要用result = function(1,2)这样的方式就可以使用这个函数。但是,当高级语言被编译成计算机可以识别的机器码时,有一个问题就凸现出来:在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机提供了一种被称为栈的数据结构来支持参数传递。

3、函数调用协议

函数调用协议会影响函数参数的入栈方式、栈内数据的清除方式、编译器函数名的修饰规则等,用户可以在IDE环境中根据需要进行设置。下面简单介绍两种调用协议__stdcall和__cdecl:

(1)调用协议常用场合

__stdcall:windows API默认的函数调用协议

__cdecl:C/C++默认的函数调用协议

(2)函数参数入栈方式

__stdcall:函数参数由右向左入栈

__cdecl:函数参数由右向左入栈

(3)栈内数据清除方式

__stdcall:函数调用结束后由被调用函数清除栈内数据

__cdecl:函数调用结束后由函数调用者清除栈内数据

(4)C语言编译器函数名称修饰规则

__stdcall:编译后,函数名被修饰为“_functionname@number”

__cdecl:编译后,函数名被修饰为“_functionname”

4、函数入栈

正常情况下,C语言函数的参数入栈规则为__stdcall,函数参数由右向左入栈。故,对于函数

void fun(int a, int b, int c) 
{ 
    int d; 
    ... 
}

有以下栈结构:

fun()函数栈结构

由此可以发现,函数的所有参数是存储在线性连续的栈空间中的。

四、可变参数实现原理

基于栈的线性连续存储的结构,这样就可以从可变参数函数中必须有的第一个普通参数来寻址后续所有可变参数的类型及其值。由此就可以达到printf()函数的可变参数实现,其实,这种理念不仅对printf()函数适用,对scanf()和其他自定义的可变参数的函数都适用。

1、参数传递

接下来,我通过以下函数对printf的参数传递进行说明。

printf("%d %d %d %d\n", n1, n2, n3, n4);

计算机或者芯片根据的变量的类型把变量的值放入栈中,然后控制权转到printf()函数,该函数根据转换说明从栈中取值。%d转换说明表明printf()应该读取4个字节,所以printf()读取栈中的前4个字节作为第一个值,依次类推。

2、可变参数实现

对于固定参数列表的函数,每个参数的名称、类型都是直接可见的,他们的地址也都是可以直接得到的。但是对于变长参数的函数,我们就没那么顺利了。不过我们可以从可变参数的函数中得到最左边的固定参数的地址。以下是printf()函数原型:

int printf(const char *format, ...)

由于栈是对我们开放的,不管是固定参数还是可变参数的传参过程都是一样,简单来讲都是栈操作。所以一旦我们知道某函数帧的栈上的一个固定参数的位置,我们完全有可能推导出其他变长参数的位置。

所以对下面这个函数而言

printf("%d %d %d %d\n", n1, n2, n3, n4);

我们可以得到

n1.addr = format.addr + x_sizeof(format); 
n2.addr = n1.addr + x_sizeof(n1); 
n3.addr = n2.addr + x_sizeof(n2); 
n4.addr = n3.addr + x_sizeof(n3);

考虑到字节对齐情况,我们把x_sizeof可以定义成

#define x_sizeof(TYPE) \ 
    (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

在《C程序设计语言》中,Ritchie提供了一个简易版printf函数:

#include<stdarg.h>

void minprintf(char *fmt, ...)
{
    va_list ap;
    char *p, *sval;
    int ival;
    double dval;

    va_start(ap, fmt);
    for (p = fmt; *p; p++) {
        if(*p != '%') {
            putchar(*p);
            continue;
        }
        switch(*++p) {
        case 'd':
            ival = va_arg(ap, int);
            printf("%d", ival);
            break;
        case 'f':
            dval = va_arg(ap, double);
            printf("%f", dval);
            break;
        case 's':
            for (sval = va_arg(ap, char *); *sval; sval++)
                putchar(*sval);
            break;
        default:
            putchar(*p);
            break;
        }
    }
    va_end(ap);
}

va_list:可以理解成了一个char *。

va_start:可以理解成得到第一个可变参数的地址。

va_arg:可以理解成得到下一个参数的地址。

va_end:可以理解成释放ap指针。

五、分级打印log方式实现

void user_log(uint8_t log_src, const char *format,...)
{
    if(log_src)
    {
        va_list args;
        __va_start(args,format); 
        vprintf(format,args);
        __va_end(args);
    }   
}

再通过定义不同的log_src源,就可实现分级打印log了。

实际使用中,由于编译器不允许可变参数的函数user_log在头文件中进行声明,我们通常会用一个宏来间接的声明user_log函数,具体如下所示。

#define USER_LOG user_log

六、PC上,printf输出方式

以linux系统为例,通过对printf()的层层追溯,我们可以得到以下调用关系:

函数调用

解释

printf()

 

vfprintf()

 

putc()

 

write()

linux系统调用

sys_write()

 

file->write

具体输出到哪里,取决于file结构对应的设备驱动是什么

七、嵌入式设备上,printf输出方式

需要用到printf串口重定向。

八、printf()实际用途

一般用于debug,或者打印必要的提示信息。

下面以嵌入式开发为例简单介绍一下软件debug常用的几种方式:

(1)printf()打印

(2)IDE工具的debug功能

(3)借助辅助工具,例如逻辑分析仪、示波器等等

参考文献

[1] C primer plus第6版 中文版

[2] https://blog.csdn.net/mmshixing/article/details/51679571

[3] https://blog.csdn.net/qinrenzhi/article/details/94997119

[4] https://blog.csdn.net/lxw907304340/article/details/50840006

[5] https://www.cnblogs.com/cpoint/p/3368993.html

[6] https://www.runoob.com/cprogramming/c-function-printf.html

[7] https://blog.csdn.net/qq_25544855/article/details/81146800

[8] https://blog.csdn.net/qq_37896973/article/details/78283900

[9] https://blog.csdn.net/songguozhi/article/details/3117673

注:转载请注明出处

微信公众号

喜欢我的还可以关注我的微信公众号:梦想园地,里面有你意想不到的惊喜哦!

发布了2 篇原创文章 · 获赞 1 · 访问量 200

猜你喜欢

转载自blog.csdn.net/qq_32299437/article/details/103847446