原创之--------printf详解
最近摆弄2440开发板,想要研究printf的实现。google一下,发现结果不能令人恭维,几乎无一例外的在谈论C语言的可变参数,对其他的关键问题却只字不提。所以我想写这篇文章,记录一下自己的学习经历,也希望能够给其他人带来一些启发。
早在大学二年级学习C语言的时候,在Turbo C下写程序。printf,一个小黑框显示一些数字,诶!感觉很奇妙,但至于更深层的东西,虽然有疑问,但能力有限,因此没有深入研究过。如今发现,这个问题看似简单,其实不然,这其中牵涉到许多东西,比如汇编语言,操作系统,中断等。这里先声明,本人不是计算机专业出身,所学东西全靠自学,因此这里所讲到的大部分东西对计算机专业的来说可能不正确。欢迎大家提出宝贵意见.邮箱zdl110110163.com,或QQ467026758。
现在说正题,首先C语言,与其说是一种编程语言,不如说是一个标准,如果你要遵循这个标准,你就要实现它的功能。printf函数就是一个例子,windows有它的实现,linux,unix也有自己的实现。C语言的可移植性可能就来自这里吧。printf函数通常叫输出函数,我觉得应该叫”字符串格式化函数”比较贴切。为什么这么说呢,我先贴一段CSDN里边的代码,原文地址可以参考这里-CSDN论坛。
#include <stdarg.h> #include <stdio.h> /* * Conver int to string based on radix (usually 2, 8, 10, and 16) */ char *itoa(int num, char *str, int radix) { char string[] = "0123456789abcdefghijklmnopqrstuvwxyz"; char* ptr = str; int i; int j; while (num) { *ptr++ = string[num % radix]; num /= radix; if (num < radix) { *ptr++ = string[num]; *ptr = '\0'; break; } } j = ptr - str - 1; for (i = 0; i < (ptr - str) / 2; i++) { int temp = str[i]; str[i] = str[j]; str[j--] = temp; } return str; } /* * A simple printf function. Only support the following format: * Code Format * %c character * %d signed integers * %i signed integers * %s a string of characters * %o octal * %x unsigned hexadecimal */ int vprintf( const char* format, ...) { va_list arg; int done = 0; va_start (arg, format); //done = vfprintf (stdout, format, arg); while( *format != '\0') { if( *format == '%') { if( *(format+1) == 'c' ) { char c = (char)va_arg(arg, int); putc(c, stdout); } else if( *(format+1) == 'd' || *(format+1) == 'i') { char store[20]; int i = va_arg(arg, int); char* str = store; itoa(i, store, 10); while( *str != '\0') putc(*str++, stdout); } else if( *(format+1) == 'o') { char store[20]; int i = va_arg(arg, int); char* str = store; itoa(i, store, 8); while( *str != '\0') putc(*str++, stdout); } else if( *(format+1) == 'x') { char store[20]; int i = va_arg(arg, int); char* str = store; itoa(i, store, 16); while( *str != '\0') putc(*str++, stdout); } else if( *(format+1) == 's' ) { char* str = va_arg(arg, char*); while( *str != '\0') putc(*str++, stdout); } // Skip this two characters. format += 2; } else { putc(*format++, stdout); } } va_end (arg); return done; } int main(int argc, char* argv[]) { int n = 255; char str[] = "hello, world!"; printf("n = %d\n", n); printf("n = %i\n", n); printf("n = %o\n", n); printf("n = %x\n", n); printf("first char = %c\n", str[0]); printf("str = %s\n", str); printf("%s\tn = %d\n", str, n); // Test vprintf function printf("---------------vprintf--------------\n"); vprintf("n = %d\n", n); vprintf("n = %i\n", n); vprintf("n = %o\n", n); vprintf("n = %x\n", n); vprintf("first char = %c\n", str[0]); vprintf("str = %s\n", str); vprintf("%s\tn = %d\n", str, n); return 0; } ---------------------------result: n = 255 n = 255 n = 377 n = ff first char = h str = hello, hello, n = 255 ---------------my_printf-------------- n = 255 n = 255 n = 377 n = ff first char = h str = hello, hello, n = 255
下边是微软的代码
int __cdecl printf ( const char *format, ... ) /* * stdout 'PRINT', 'F'ormatted */ { va_list arglist; int buffing; int retval; va_start(arglist, format); _ASSERTE(format != NULL); _lock_str2(1, stdout); buffing = _stbuf(stdout); retval = _output(stdout,format,arglist); _ftbuf(buffing, stdout); _unlock_str2(1, stdout); return(retval); }
我们可以看到,在两者的代码中都用到其他的函数,比如putc,_ftbuf,_unlock_str2……这些完成什么工作我们不得而知。
下边大致讲一下我对printf整个过程的理解:首先利用C语言的可变参数对要输出的字符串进行格式化,将相应参数送入寄存器然后产生中断,比如windows下的int 21h,linux下的 int 80h.中断产生后,根据中断向量表进入由操作系统设置好的中断处理函数,至于中断处理函数如何让字符在显示器上显示出来,我们后边慢慢说。
我这里拿linux0.11版本做一下分析:
main.c中的printf函数:
static int printf (const char *fmt, ...)
// 产生格式化信息并输出到标准输出设备stdout(1),这里是指屏幕上显示。参数'*fmt'指定输出将
// 采用的格式,参见各种标准C 语言书籍。该子程序正好是vsprintf 如何使用的一个例子。
// 该程序使用vsprintf()将格式化的字符串放入printbuf 缓冲区,然后用write()将缓冲区的内容
// 输出到标准设备(1--stdout)。
{
va_list args;
int i;va_start (args, fmt);
write (1, printbuf, i = vsprintf (printbuf, fmt, args));
va_end (args);
return i;
}
我们看到这里调用了write函数,我们跳转到write函数的定义处,位于unistd.h头文件中,那么,write函数实现在哪里呢?我通过查找,找到这个:
//// 写文件系统调用函数。
// 该宏结构对应于函数:int write(int fd, const char * buf, off_t count)
// 参数:fd - 文件描述符;buf - 写缓冲区指针;count - 写字节数。
// 返回:成功时返回写入的字节数(0 表示写入0 字节);出错时将返回-1,并且设置了出错号
_syscall3 (int, write, int, fd, const char *, buf, off_t, count);
那么_syscall3这个到底是什么呢,我们进一步跟踪发现_syscall3的定义位于unistd.h头文件中:
// 有3 个参数的系统调用宏函数。type name(atype a, btype b, ctype c)
// %0 - eax(__res),%1 - eax(__NR_name),%2 - ebx(a),%3 - ecx(b),%4 - edx(c)。
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ( "int $0x80" \
: "=a" (__res) \
: "" (__NR_##name), "b" ((long)(a)), "c" ((long)(b)), "d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
从上面我们可以看出,_syscall3实际上是一个宏,我们调用的write函数实际上被上面的内嵌汇编代码所替代。我们可以清楚得看到,调用了int 80中断,更重要的一句在于(__NR_##name……这一句,这一句的作用就是跳转到中断处理函数。这里我们调用write函数,经过这句,我们调用__NR_write所代表的中断处理函数。我们继续看__NR_write。查找发现__NR_write位于unistd.h中:
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
我们发现__NR_write的值为4,这个值有什么用呢?我们学过操作系统都知道,中断都是通过中断向量表(我不清楚这样叫对不对,大家理解就行)来实现中断处理的,而我们这个__NR_write就是我们要调用的中断处理函数在中断向量表中的索引。好了,下边只要我们找到了中断向量表,查找一下,就应该找到对应的中断处理函数了。在linux0.11版本中,中断向量表在sys.h头文件中。
sys.h文件部分代码:
extern int sys_write (); // 写文件。 (fs/read_write.c, 83)
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};
通过上面的代码我们可以看到,我们用索引4找到了sys_write函数,同时我也在中断向量表的上边找到了sys_write函数的定义,我们可以看到它的实现位于read_write.c文件中,跟踪过去:
int sys_write (unsigned int fd, char *buf, int count)
{
struct file *file;
struct m_inode *inode;// 如果文件句柄值大于程序最多打开文件数NR_OPEN,或者需要写入的字节计数小于0,或者该句柄
// 的文件结构指针为空,则返回出错码并退出。
if (fd >= NR_OPEN || count < 0 || !(file = current->filp[fd]))
return -EINVAL;
// 若需读取的字节数count 等于0,则返回0,退出
if (!count)
return 0;
// 取文件对应的i 节点。若是管道文件,并且是写管道文件模式,则进行写管道操作,若成功则返回
// 写入的字节数,否则返回出错码,退出。
inode = file->f_inode;
if (inode->i_pipe)
return (file->f_mode & 2) ? write_pipe (inode, buf, count) : -EIO;
// 如果是字符型文件,则进行写字符设备操作,返回写入的字符数,退出。
if (S_ISCHR (inode->i_mode))
return rw_char (WRITE, inode->i_zone[0], buf, count, &file->f_pos);
// 如果是块设备文件,则进行块设备写操作,并返回写入的字节数,退出。
if (S_ISBLK (inode->i_mode))
return block_write (inode->i_zone[0], &file->f_pos, buf, count);
// 若是常规文件,则执行文件写操作,并返回写入的字节数,退出。
if (S_ISREG (inode->i_mode))
return file_write (inode, file, buf, count);
// 否则,显示对应节点的文件模式,返回出错码,退出。
printk ("(Write)inode->i_mode=%06o\n\r", inode->i_mode);
return -EINVAL;
}
由于在类unix系统中,标准输入输出都是被当成“文件”来对待的,所以在上面的代码中,我们通过文件描述符找到对应的文件,判断文件类型,然后调用不同的函数。我们的标准输入输出应该是字符型文件,所以我们重点看rw_char这个函数。
int
rw_char (int rw, int dev, char *buf, int count, off_t * pos)
{
crw_ptr call_addr;// 如果设备号超出系统设备数,则返回出错码。
if (MAJOR (dev) >= NRDEVS)
return -ENODEV;
// 若该设备没有对应的读/写函数,则返回出错码。
if (!(call_addr = crw_table[MAJOR (dev)]))
return -ENODEV;
// 调用对应设备的读写操作函数,并返回实际读/写的字节数。
return call_addr (rw, MINOR (dev), buf, count, pos);
}
这里我们看到了类似中断的方法,crw_table定义如下
// 字符设备读写函数指针表。
static crw_ptr crw_table[] = {
NULL, /* nodev *//* 无设备(空设备) */
rw_memory, /* /dev/mem etc *//* /dev/mem 等 */
NULL, /* /dev/fd *//* /dev/fd 软驱 */
NULL, /* /dev/hd *//* /dev/hd 硬盘 */
rw_ttyx, /* /dev/ttyx *//* /dev/ttyx 串口终端 */
rw_tty, /* /dev/tty *//* /dev/tty 终端 */
NULL, /* /dev/lp *//* /dev/lp 打印机 */
NULL
};
我们接下来看看rw_ttyx函数,rw_tty跟rw_ttyx类似。
//// 串口终端读写操作函数。
// 参数:rw - 读写命令;minor - 终端子设备号;buf - 缓冲区;cout - 读写字节数;
// pos - 读写操作当前指针,对于终端操作,该指针无用。
// 返回:实际读写的字节数。
static int
rw_ttyx (int rw, unsigned minor, char *buf, int count, off_t * pos)
{
return ((rw == READ) ? tty_read (minor, buf, count) :
tty_write (minor, buf, count));
}
// tty 数据结构。
struct tty_struct
{
struct termios termios; // 终端io 属性和控制字符数据结构。
int pgrp; // 所属进程组。
int stopped; // 停止标志。
void (*write) (struct tty_struct * tty); // tty 写函数指针。
struct tty_queue read_q; // tty 读队列。
struct tty_queue write_q; // tty 写队列。
struct tty_queue secondary; // tty 辅助队列(存放规范模式字符序列),
};// tty 等待队列数据结构。
struct tty_queue
{
unsigned long data; // 等待队列缓冲区中当前数据指针字符数[??])。
// 对于串口终端,则存放串行端口地址。
unsigned long head; // 缓冲区中数据头指针。
unsigned long tail; // 缓冲区中数据尾指针。
struct task_struct *proc_list; // 等待进程列表。
char buf[TTY_BUF_SIZE]; // 队列的缓冲区。
};
tty_write函数部分代码:
//// tty 写函数。
// 参数:channel - 子设备号;buf - 缓冲区指针;nr - 写字节数。
// 返回已写字节数。
int
tty_write (unsigned channel, char *buf, int nr)
{
static cr_flag = 0;
struct tty_struct *tty;
char c, *b = buf;// 本版本linux 内核的终端只有3 个子设备,分别是控制台(0)、串口终端1(1)和串口终端2(2)。
// 所以任何大于2 的子设备号都是非法的。写的字节数当然也不能小于0 的。
if (channel > 2 || nr < 0)
return -1;
// tty 指针指向子设备号对应ttb_table 表中的tty 结构。
tty = channel + tty_table;。。。。。。省略一部分代码
// 若字节全部写完,或者写队列已满,则程序执行到这里。调用对应tty 的写函数,若还有字节要写,
// 则等待写队列不满,所以调用调度程序,先去执行其它任务。
tty->write (tty);
if (nr > 0)
我们从红色部分可以看到,此时tty设备应该查找tty_table数组,随后调用我们跟中进去发现调用了tty设备的写函数。以下代码:红色部分就是控制台的写函数
struct tty_struct tty_table[] = {
{
{ICRNL, /* change incoming CR to NL *//* 将输入的CR 转换为NL */
OPOST | ONLCR, /* change outgoing NL to CRNL *//* 将输出的NL 转CRNL */
0, // 控制模式标志初始化为0。
ISIG | ICANON | ECHO | ECHOCTL | ECHOKE, // 本地模式标志。
0, /* console termio */// 控制台termio。
INIT_C_CC}, // 控制字符数组。
0, /* initial pgrp */// 所属初始进程组。
0, /* initial stopped */// 初始停止标志。
con_write, // tty 写函数指针。
{0, 0, 0, 0, ""}, /* console read-queue */// tty 控制台读队列。
{0, 0, 0, 0, ""}, /* console write-queue */// tty 控制台写队列。
{0, 0, 0, 0, ""} /* console secondary queue */// tty 控制台辅助(第二)队列。
}, {
{0, /* no translation */// 输入模式标志。0,无须转换。
0, /* no translation */// 输出模式标志。0,无须转换。
B2400 | CS8, // 控制模式标志。波特率2400bps,8 位数据位。
0, // 本地模式标志0。
0, // 行规程0。
INIT_C_CC}, // 控制字符数组。
0, // 所属初始进程组。
0, // 初始停止标志。
rs_write, // 串口1 tty 写函数指针。
{0x3f8, 0, 0, 0, ""}, /* rs 1 */// 串行终端1 读缓冲队列。
{0x3f8, 0, 0, 0, ""}, // 串行终端1 写缓冲队列。
{0, 0, 0, 0, ""} // 串行终端1 辅助缓冲队列。
}, {
{0, /* no translation */// 输入模式标志。0,无须转换。
0, /* no translation */// 输出模式标志。0,无须转换。
B2400 | CS8, // 控制模式标志。波特率2400bps,8 位数据位。
0, // 本地模式标志0。
0, // 行规程0。
INIT_C_CC}, // 控制字符数组。
0, // 所属初始进程组。
0, // 初始停止标志。
rs_write, // 串口2 tty 写函数指针。
{0x2f8, 0, 0, 0, ""}, /* rs 2 */// 串行终端2 读缓冲队列。
{0x2f8, 0, 0, 0, ""}, // 串行终端2 写缓冲队列。
{0, 0, 0, 0, ""} // 串行终端2 辅助缓冲队列。
}
};
接下来我们继续看con_write函数,代码太多,我就省略了。它的功能就是从缓冲队列中取出字符,处理之后复制到显存中。我们知道计算机有显卡,显卡将内部的数据发送到显示器,显示器就能显示相应的字符。我们要做的就是不断更新显卡的内容(地址好像是0xb8000)。这里我要强烈推荐《IBM PC 8086汇编语言》这本书,里边对这部分内容讲解的非常好。
在linux main.c 文件main函数中调用了:
tty_init (); // tty 初始化。 (kernel/chr_dev/tty_io.c,105 行)
//// tty 终端初始化函数。
// 初始化串口终端和控制台终端。
void
tty_init (void)
{
rs_init (); // 初始化串行中断程序和串行接口1 和2。(serial.c, 37)
con_init (); // 初始化控制台终端。(console.c, 617)
}
有兴趣的读者可以看看con_init()的代码,里边包括了获取显卡的模式,复制字符串到显卡等内容。
到此为止,一个printf的介绍基本结束了,这其中有很多需要修改的地方,以后有时间慢慢整理。
Linus Torvalds 21岁能写出一个内核,而我,一个奔三的程序员连个printf都写不出来。我只能感慨,同样是人,差距咋这么大呢?囧囧囧