你真的了解printf函数吗?
因为笔者水平有限,文中一定存在一(大)些(量)错误,如果你发现了一些错误,请告诉我,我会以最快的速度改正,谢谢
\texttt{0000h} :前言
printf函数是我们OIer每天都要用的函数,但是你真的了解这个看起来不起眼的小函数吗?本文将带着你讲解linux系统下printf函数的实现原理。相信你读完这篇文章后,会对它有不一样的看法。
注:为分析方便起见,本文采用linux-0.01版本的源码(更高版本的笔者也分析不来)。下载地址
\texttt{0001h} :一些关于汇编语言的基础知识
寄存器:它是直接位于CPU内的高速存储位置.你可以把它理解为汇编中的全局变量,只不过速度比真正的全局变量快得多。常用的32位寄存器主要有eax、ebx、ecx等。
(堆)栈:本文中的栈均指运行时堆栈,它直接由CPU在内存中维护,是函数调用等操作的基础。它的操作类似于正常的栈数据结构。
这里讲的运行时堆栈与我们平时用的栈不是一个东西。运行时堆栈工作于系统层,用来处理函数调用,而我们平时用的栈是数据结构(,用来处理毒瘤题)。
更多信息见这里
\texttt{0002h} :\text{printf} 函数的声明
如果你在Dev-C++中在printf函数上点击右键,再选择“到定义”,你会看到这样一段代码:
int __cdecl printf(const char *fmt,...)
可以看到,它的定义和普通的函数有很大不同(毕竟是库函数嘛)。它和普通函数的不同主要在于前面的那个_cdecl和后面的那些"..."。
先来看看__cdecl吧。_cdecl是C/C++的默认调用规范。
调用规范这个词,对于大部分OIer而言是很陌生的,因为它涉及到很多和底层与汇编有关的知识,而这些东西和OI没多大关系。所以接下来笔者将尽量用OIer们能听懂的语言,把这个概念尽可能通俗的表达出来。
在一些系统函数或一些第三方函数库中,我们经常可以看见这样的函数声明:
int __cdecl foo1(int a, int b)
int __stdcall foo2(int a, int b)
...
它们中的__cdecl、 __stdcall就是调用规范。
那么调用规范到底是什么呢?
首先,一个函数肯定得有参数和返回值对吧?在C++下,我们想把参数传递给一个函数(比如foo(int a,int b),只需要这么写:
foo(val1,val2);
但是到了汇编的层面就不行了。为什么呢?因为在汇编语言中是没有“函数参数”这个概念的。你要调用一个函数(还是拿foo举例),你只能写:
call foo
发现什么没有?不能传递参数了!这是因为CALL指令只负责将控制流程跳转到目标地址,其他它就不管了!负责返回值的RET指令也存在类似的问题。所以传递参数和返回值的问题需要我们自己解决。这个问题的解决方法也有很多,比如用寄存器(寄存器可以理解为一个全局变量一样的东西)或者系统栈。但是现在又牵扯出来另一个问题:主调函数不知道被调函数的参数(返回值)种类和数量,也就没办法给它传参(接受返回值)。这个时候就需要调用规范出场了:它像一个协议,规定了怎么传参数,堆栈由谁清等问题。而我们现在要讲的__cdecl调用规范,就是调用规范中的一种。它规定,函数的参数从右至左压入栈中,同时堆栈由主调函数清理。
举个例子: 被调函数的声明为
void foo(int a, int b, int c)
那么主调函数可以这么写(汇编)
;do something
push c
push b
push a;从右至左压入栈中
call foo;调用
;清理堆栈,此处由于与文章内容无关不作展开。
所以,你明白了吧?
另:这里由于文(笔)章(者)篇(太)幅(懒)的关系,不对另外几种调用规范进行介绍,感兴趣的读者可自行参考这个.)
至于变长参数(就是那些"..."),你可以去看这个和这个(后者需要一定汇编基础)
\texttt{0003h} :\text{printf} 函数的函数体
在上一节中,我们只讲了printf的声明,并没有讲它的函数体。那么他的函数体在哪呢?如果你把stdio.h(cstdio)再往下翻一翻,你就会发现你的头脑中冒出来一个大大的问号:
printf的函数体去哪了?
事实上,你在标准库里是找不到它的函数体的。你的操作系统中会有一个链接库,操作系统的开发者会把这些底层库函数的实现放在那里。等到你的程序被编译时,编译器会到链接库中找到相应的可执行代码,直接贴到程序中。由于链接库中的代码都是编译过的可执行代码,所以你当然找不到了。
这个链接库在Linux系统下是libc.so,在Windows下是msvsrt.dll(msvc runtime的缩写)
但是,由于Linux是开源的操作系统,所以我们仍然有机会看到linux下printf的实现。Windows就别想了,开源世界的最大敌人可不是吹的。
虽然如此,但前一段时间Microsoft反常(?) 的开源了STL,在这。
在本文使用的Linux-0.01版本中,printf的实现在~/init/main.c中。如下所示:
//由于这个版本年代比较久远,所以这里的声明和之前讲的有所不同。
static int printf(const char *fmt, ...)
{
va_list args;//定义一个可变参数列表(这里不懂回去看变长参数)
int i;
va_start(args, fmt);//初始化args指向强制参数fmt的下一个参数
write(1,printbuf,i=vsprintf(printbuf, fmt, args));//这一堆乱七八糟的东西下面会讲
va_end(args);//释放args
return i;//没错printf是有返回值的!它的返回值其实就是vsprintf的返回值,也就是格式化后字串的长度。如果出现错误则返回-1
}
其他代码请自行看注释,我们这里只看那一堆乱七八糟的东西(也是本文的重点)。
把那堆东西拆出来看:
write(1,printbuf,i=vsprintf(printbuf, fmt, args));//args就是printf后面的那些变长参数,fmt就是未格式化的字符串
可以看到里面有个函数vsprintf,它的作用是格式化输入字符串并放到指定区域,举个例子:你写了这样一句:
printf("%d %d", 1, 2);
那么vsprintf的作用就是把"%d %d"换成“1 2”。
这里它会把格式化后的字符串放到printfbuf,也就是输出缓冲区中。printbuf是一个字符数组,它同样定义在~/init/main.c下。因为这个东西基本相当于一个大模拟,所以关于它的原理这里不作详述。
注意:经评论区@zhaojinxi 大佬指正,此处printbuf是个指针,所以执行vsprintf不会使printbuf这个变量本身的值发生改变,和参数的入栈顺序无关。也就是说,改变的是printbuf所指向的数组而不是printbuf本身。
里面还有一个函数write,它的定义在
~/kernel/write.c,如下:
#define __LIBRARY__//定义这个东西是因为unistd.h里的部分内嵌汇编代码有条件编译,必须定义了才能用
#include <unistd.h>
_syscall3(int,write,int,fd,const char *,buf,off_t,count)//重点
一个小技巧:以后想找库函数的源码,(大部分时候)可以到源码库里找和它同名的文件。现在的linux系统一般采用glibc的库函数实现,这个库可以到这里下载
另:write其实是linux系统提供的stub(桩代码,就是没有实际功能,只靠调用其他函数来实现其功能的代码,下文的printk函数也属于stub),它的功能是通过系统调用来实现的,具体见下文
可以看到,这个文件的核心代码只有一行,是调用一个叫_syscall3的东西。它的原型定义在~/include/unistd.h中,如下:
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
type __res; \
__asm__ volatile ("int @0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" (a),"c" (b),"d" (c)); \
if (__res<0) \
errno=-__res , __res = -1; \
return __res;\
}
代码中"@0x80"中的那个@实际上是
吐槽一下Linus(linux作者)的码风,这码风放到现在估计要被人打
这一堆乱七八糟的内嵌汇编看的人一脸懵,要讲清楚这个,还得先偏个题,讲讲linux系统的系统调用机制。
\texttt{0004h} :\text{Linux} 系统的系统调用机制
为了安全,Linux 中分为用户态和内核态两种运行状态。对于普通进程,平时都是运行在用户态下,仅拥有基本的运行能力。当进行一些敏感操作,比如说要打开文件(open)然后进行写入(write)、分配内存(malloc)时,就会切换到内核态。内核态进行相应的检查,如果通过了,则按照进程的要求执行相应的操作,分配相应的资源。这种机制被称为系统调用,用户态进程发起调用,切换到内核态,内核态完成,返回用户态继续执行,是用户态唯一主动切换到内核态的合法手段(exception 和 interrupt 是被动切换)。————《Linux系统调用过程分析》[1]
上面的那段话引自一位大佬的博客。由这段话可以看出,linux系统调用的执行流程如下:
①应用程序向内核发起系统调用。
②内核进行相应的检查。
③通过:按照进程的要求执行相应的操作。
这一步有个专有名词叫陷入内核态
④完成后,返回用户态继续执行。
本文只介绍①。因为想把以上步骤全部讲完且做到能让一位OIer看懂所需要的篇幅实在太长,且其他步骤与本文无关。
我们开始吧:
linux系统为了支持系统调用,向外提供了一个(在本文所用的linux-0.01版本中也是唯一一个)接口:0x80号软件中断。
这个接口目前已经被废弃,现在流行的系统调用方式是使用sysenter或syscall指令,具体见参考文献[1] (《Linux系统调用过程分析》)
中断是CPU中的一个概念,当一个中断被引发时,CPU会停止正在执行的指令,转而去执行事先定义好的中断处理程序,以此达到节省CPU时间的目的。这里的软件中断指的是由应用程序主动引起的中断,与此相对的是硬件中断,即由硬件引起的中断。关于中断,下文会详述。
看到0x80这个数字,是不是觉得有点眼熟?没错,上面那段_syscall3就是封装过的系统调用接口(系统提供的接口只有一个,但可以可以用宏封装出很多个参数不同的接口),它用int指令引发软件中断来实现系统调用。
正常情况下像这种封装过的接口一共会有七个,分别有一到七个参数,但本文所用的linux版本由于年代久远,一共只有三个接口。
但是,问题又来了:引发中断后,为啥就会引起系统调用呢?为了解释这个问题,我们先来再看看刚才的代码:
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
type __res; \
__asm__ volatile ("int @0x80" \ //引发软件中断
: "=a" (__res) \ //"=a"代表把__res的值存到eax中
: "0" (__NR_##name),"b" (a),"c" (b),"d" (c)); \ //传递相应的参数,__NR_##name下面会讲
if (__res<0) \ //发生错误的话内核中系统调用相关代码会返回-1,后面会讲
errno=-__res , __res = -1; \ //errno是linux中的一个全局变量,用于存放错误码
return __res; \ //否则返回__res
}
__NR##name就是把name(上面的函数名)拼接到\_NR后面。如name是write,__NR##name就是__NR_write,这个东西后面会派上大用场,此处先按下不表
可以看见,上面的核心代码其实只有"int @0x80"一句,想要知道上面问题的答案,就要知道它执行后会发生什么。用一句话来讲就是:在x86架构下,当int @0x80被执行时,CPU会通过中断向量(此处就是0x80)到中断描述符表(IDT)中找到相应的门描述符(这里就是syscall),然后根据门描述符执行相应的中断处理程序。
中断描述符表(IDT)是由操作系统维护的一张表,它存放了中断源和中断处理程序的映射关系。
中断向量是x86架构下的一个概念。它是一个8位无符号整数,用来给CPU识别中断源。上面的0x80就是一个中断向量。这两样东西在后面会详细讲。
现在来详细的讲一讲这个流程。
回想一下,上文提到过,我们正在研究的int @0x80属于软件中断,与此相对的还有硬件中断(还说下文会讲)。直觉来讲,硬件中断和软件中断肯定属于两码事,如果把它们直接放在一张表里面肯定会出乱子。所以,x86架构把中断向量分别映射到IDT中的三部分,如下所示:
注:异常是由CPU内部引起的中断,如除零错误或栈溢出,屏蔽中断就是由硬件产生的、可以关闭的中断,而非屏蔽中断指由硬件产生的,无法关闭的中断(异常也属于非屏蔽中断)。软件中断则指由软件引发的中断。由硬件产生的中断都是写死在IDT里的,软件中断则可以由操作系统或用户自定义。
另:虽然从48-255都是软件中断,但linux系统只使用了其中的一个(就是0x80),这也意味着我们可以利用系统提供的接口来自定义中断!
上文还提到过一个概念:门描述符,它是IDT的成员。它是干啥的呢?顾名思义:“门”描述符,就像一个门一样,想要引发中断,就要进这个门。(事实上进这个门是需要检查的,这个东西属于linux系统调用流程的第二步)
门描述符包含了中断处理程序所在段(段是x86内存管理中的一个概念,可以理解为一小块内存)的段选择子(用来在全局描述符表(GDT)中确定一个段的16位整数)和段内偏移量(就是中断处理程序的入口点距离这个段的开头有多远),所以应用程序可以通过它来找到相应的中断服务程序。
根据Intel的软件开发手册 ,门描述符分为以下几种类型:
-
traps(陷阱门) -
interrupt(中断门) -
task(任务门)
linux系统对以上几种门描述符作了更细的分类,在本文所用的linux系统中,只多了一种,就是system(系统门)。我们正在研究的0x80中断就属于这种(严格来说,system也属于traps(其实它就是用户态进程可以访问的一个traps),所以它是traps的子集,也就是说0x80也属于traps).
关于这三样东西本文不作详述(不然本文的题目应该改成《linux系统下的中断机制》了),如果你对这些感兴趣,请自行参考这个或《INTEL 80386 PROGRAMMER REFERENCE MANUAL》 (中文)。这里只需要知道我们正在研究的0x80中断属于system。
综上所述:int @0x80执行的过程就是一个调用中断的过程。先由int指令引发中断,再通过int后的中断向量找到门描述符,再通过门描述符找到中断处理程序并执行。
好不容易讲完了一个问题,你是不是松了一口气?但是别急着放松,上面还有一个问题没解决:为啥int @0x80执行后就会引起系统调用呢?我们接着来讲。
上面说0x80中断属于system,也就是traps。咦?在linux源码文件夹的kernel目录下怎么正好有个traps.c?打开看看,
发现里面有一个traps_init():
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
for (i=17;i<32;i++)
set_trap_gate(i,&reserved);
/* __asm__("movl $0x3ff000,%%eax\n\t"
"movl %%eax,%%db0\n\t"
"movl $0x000d0303,%%eax\n\t"
"movl %%eax,%%db7"
:::"ax");*/
}
以上一堆函数的源码在~\include\asm\system.h
这一大堆函数调用看上去在.....设置门描述符!可以看出set_trap_gate()是设置traps,set_system_gate()是设置system,还记得我们讲了这么久都在干啥吗?要搞清楚为啥int @0x80执行后就会引起系统调用!答案好像就在这!但是找了半天,也没找到一个set_system_gate(0x80,&system_call).索性全局搜索一下,最后发现在~\kernal\sched.c中的最后一行有个惊喜!
void sched_init(void)
{
int i;
struct desc_struct * p;
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
p = gdt+2+FIRST_TSS_ENTRY;
for(i=1;i<NR_TASKS;i++) {
task[i] = NULL;
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
ltr(0);
lldt(0);
outb_p(0x36,0x43); /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);//就是这里!Exciting!
}
到这里,一切似乎都结束了。让我们来做个复盘:
①应用程序执行int @0x80,引发软件中断
②CPU收到中断信号,停下当前的工作,根据中断向量0x80到IDT中找到对应的中断描述符system_call。
③控制流程转到system_call,执行它里面的程序。
现在还剩一个“小”问题:system_call又是怎么实现的呢?
事实上,这个问题一点也不小。根据我们上文讲的技巧,可以发现有个~\kernel\system_call.s这个里面就是内核中实现system_call的核心代码。话不多说,直接上代码:
.globl _system_call,_sys_fork,_timer_interrupt,_hd_interrupt,_sys_execve
.align 2
bad_sys_call: #如果系统调用出错
movl @-1,%eax #用eax返回-1.(还记得之前系统调用接口里的那句“if (__res<0) \ errno=-__res , __res = -1;”吗?就是从这出来的。)
iret #中断返回。(interrupt return)
.align 2
reschedule: #如果进程没就绪
pushl @ret_from_sys_call #把接下来要执行的ret_from_sys_call的地址入栈。这样当schedule返回时就会从ret_from_sys_call的地址继续执行
jmp _schedule #去调度(调整cpu时间,让没就绪的进程尽快就绪)
.align 2
_system_call: #系统调用主程序
cmpl @nr_system_calls-1,%eax #比较eax和nr_system_calls的大小,这个nr_system_calls是系统调用的最大数目
ja bad_sys_call
push %ds
push %es
push %fs #以上三行把段寄存器入栈,保护现场
pushl %edx
pushl %ecx
pushl %ebx #以上三行把参数入栈(这个参数就是之前在_syscall3中定义的参数,因为后面要调用函数,所以要按照调用函数的规矩来。)还剩一个eax是系统调用号,后面会讲
movl @0x10,%edx #0x10是内核数据段的段选择子
mov %dx,%ds
mov %dx,%es #以上两行把ds和es指向edx也就是内核数据段
movl @0x17,%edx #0x17是用户数据段的段选择子
mov %dx,%fs #把fs指向edx也就是用户数据段
call _sys_call_table(,%eax,4) #调用相应的函数。这句下面会讲
pushl %eax #eax入栈(后面还要用,保存一下)
movl _current,%eax #你看这不就用上了,这里的_current是一个指向当前进程的task_struct型指针(又及:它定义在sched.h中),这行代码的用处就是取得当前进程结构体的地址。
cmpl @0,state(%eax) #判断一下当前进程的状态是不是0,是0就说明它在运行,也就是不在就绪状态
jne reschedule #如果它不就绪当然要调度了,这个调度程序下面会讲
cmpl @0,counter(%eax) #如果这个进程已经就绪,但是时间片为0
je reschedule #也要去调度
ret_from_sys_call: #接下来从系统调用中返回
movl _current,%eax #同上文相关内容
cmpl _task,%eax #判断当前进程是否是0或1号进程,发给这两个进程的任何信号都会被系统屏蔽,这个task是一个定义在sched.h中的数组,直接引用数组名相当于取数组首地址,也就是task[0].
je 3f #如果是就跳到标号3(退出)
#懒癌发作,后面的懒得讲了(而且和文章主题也没多大关系),想看的可以去看参考文献[2](赵炯老师的《linux内核完全注释V3.0》(基于0.12版))
movl CS(%esp),%ebx
testl @3,%ebx
je 3f
cmpw @0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
2: movl signal(%eax),%ebx # signals (bitmap, 32 signals)
bsfl %ebx,%ecx # %ecx is signal nr, return if none
je 3f
btrl %ecx,%ebx # clear it
movl %ebx,signal(%eax)
movl sig_fn(%eax,%ecx,4),%ebx # %ebx is signal handler address
cmpl @1,%ebx
jb default_signal # 0 is default signal handler - exit
je 2b # 1 is ignore - find next signal
movl @0,sig_fn(%eax,%ecx,4) # reset signal handler address
incl %ecx
xchgl %ebx,EIP(%esp) # put new return address on stack
subl @28,OLDESP(%esp)
movl OLDESP(%esp),%edx # push old return address on stack
pushl %eax # but first check that it's ok.
pushl %ecx
pushl @28
pushl %edx
call _verify_area
popl %edx
addl @4,%esp
popl %ecx
popl %eax
movl restorer(%eax),%eax
movl %eax,%fs:(%edx) # flag/reg restorer
movl %ecx,%fs:4(%edx) # signal nr
movl EAX(%esp),%eax
movl %eax,%fs:8(%edx) # old eax
movl ECX(%esp),%eax
movl %eax,%fs:12(%edx) # old ecx
movl EDX(%esp),%eax
movl %eax,%fs:16(%edx) # old edx
movl EFLAGS(%esp),%eax
movl %eax,%fs:20(%edx) # old eflags
movl %ebx,%fs:24(%edx) # old return addr
3: popl %eax #就是把之前入栈的弹回去,没啥好说的
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret
根据代码中倒数第二条注释,这里不再对这一堆(坨)代码进行更多解释(我太弱了)。我们现在主要关注那行"下文会讲"的代码:
call _sys_call_table(,%eax,4)
别看上面九十几行代码,但是直接起作用的只有这一句。它的作用就是调用系统中定义好的中断处理程序,也就是本文最终所要研究的对象。
那么这句代码究竟是怎么起作用的呢?接着往下看吧。
\texttt{0005h} :\texttt{sys}\_\texttt{write} 函数
我们先来看看_sys_call_table,这是一个定义在sys.h中的数组。它长成这个样子:
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};
看上去乱七八糟的,没有头绪对不对?别急,还记得文中讲过两次"下文会讲"的那个__NR##name(系统调用号)嘛?先来看看它。它定义在~\include\unistd.h下,是一张很长的表,如下:
#define __NR_setup 0 /* used only by init, to get system going */
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
#define __NR_waitpid 7
#define __NR_creat 8
#define __NR_link 9
#define __NR_unlink 10
//....以此类推
对比一下,发现这张表和上面的那个sys_call_table咋这么像呢?感觉好像有联系?发现没有,sys_call_table中的第n个成员,在那张表里就是#define __NR_xxxx n,原来是这样!再来看看之前那句代码:
call _sys_call_table(,%eax,4)
科普一下AT&T汇编语法,上面那种写法叫做间接寻址,意思就是调用sys_call_table中的第eax乘4个成员。为啥要乘四呢?因为每个函数地址是4个字节
所以,这句代码实际上是在调用以sys_call_table的第系统调用号个成员为函数名的函数。那么再想想,之前write函数的定义长这样:
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
那就是说这里的__NR##name是__NR_write咯?查一下表,发现__NR_write是4,再看看sys_call_table,第四个成员是sys_write,也就是说,我们绕了这一大圈,调用的其实是这个函数!
赶紧看看这个函数的源码(~\fs\read_write.c):
//向文件中输出一个字符串
//中间有些东西看不懂没事,后面会讲
//fd是文件句柄(linux系统下用于唯一确定一个文件的整数,即一个文件的inode在filp中的下标,这里是1,即stdout),buf是要输出的字符串(这里是printbuf,输出缓冲区),count是将要输出的字符数量。
int sys_write(unsigned int fd,char * buf,int count)
{
struct file * file;//将要输出的文件
struct m_inode * inode;//这个文件的inode
//下面这行检查参数。如果fd大于能打开文件的最大数量(说明它已经超过了),或conut小于零,或file为NULL(注意那里是=而不是==,相当于已经给file赋过值了)
if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
return -EINVAL;//就返回错误码
if (!count)//如果count是0
return 0;//也会报错
inode=file->f_inode;//把函数里的inode变成我们要写的文件的inode
if (inode->i_pipe)//如果是管道文件
return (file->f_mode&2)?write_pipe(inode,buf,count):-1;//并且是以读方式打开的,就调用写管道文件函数,否则返回-1.
if (S_ISCHR(inode->i_mode))//如果是字符设备文件(信息以字符为单位传输的I/O设备)
return rw_char(WRITE,inode->i_zone[0],buf,count);//调用字符输出函数
//剧透一下,stdout就是上面这种
if (S_ISBLK(inode->i_mode))//如果是块设备文件(信息以block为单位传输的I/O设备)
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;//返回错误码
}
上面的代码注释中有许多前面没讲的概念,现在大概解释一下:
- 关于
inode
这是linux系统下的一个概念,目前似乎没有很准确的中文翻译。它是用来描述文件的一个结构体,包含文件的所有者、字节数、时间戳等信息,完整的定义在~\include\linux\fs.h的m_inode。
更多信息请参见这里
- 关于
file
这是linux系统下的文件结构体,它包含一个打开的文件的所有信息(注意是打开的文件,这是它和inode的不同之处)。内核中有一张表,叫做filp,它的成员是所有当前内核中打开的文件,类型就是file。用filp[fd]就可以找到某个打开的文件。
更多信息参考这里
接下来开始正式讲这个函数。
首先,这个函数的作用实际上是把某个字符串输出到某个文件中去。但是我们的目的是把它输出到终端(stdout)诶,怎么会调用一个输出到文件的函数呢?
这是因为unix的哲学是“一切皆为文件”。外围的输出设备也可以看成是文件,同样可以进行读写,这里的stdout也一样,它在filp中的下标是1(stdin是0,stderr是2),且属于字符设备文件。所以我们再来看看之前的第二个if:
if (S_ISCHR(inode->i_mode))//如果是字符设备文件(信息以字符为单位传输的I/O设备)
return rw_char(WRITE,inode->i_zone[0],buf,count);//调用字符输出函数
上面的rw_char函数定义在~\fs\char_dev.c,原型如下:
int rw_char(int rw,int dev, char * buf, int count)
{
crw_ptr call_addr;//这行和最后一行很特殊,下面会讲
if (MAJOR(dev)>=NRDEVS)//这里的MAJOR是设备号,NRDEVS定义在这个文件的上面,是系统中的最大设备号
panic("rw_char: dev>NRDEV");//如果超过了,就显示报错信息
if (!(call_addr=crw_table[MAJOR(dev)])) {//如果这个设备没有对应的驱动程序(注意这里给call_addr赋过值了)
printk("dev: %04x\n",dev);
panic("Trying to r/w from/to nonexistent character device");//也显示报错信息
}
return call_addr(rw,MINOR(dev),buf,count);//最后调用驱动程序
}
上面提到了一个call_addr,,这个东西相当奇怪,看上去是个变量,最后用的时候又像函数,两头通吃。这是怎么回事呢?先来看看它的定义(就在这个文件的上面):
typedef (*crw_ptr)(int rw,unsigned minor,char * buf,int count);
事实上,这是C语言的一个小技巧,一般书里没讲,叫做typedef函数指针。就是用typedef定义一个指向某种已给定参数表的函数的指针,然后将这个指针指向某个具体的函数,就可以直接调用这个函数了。这里在上面的第2个if中已经将call_addr指向了设备的驱动程序(就是crw_table的第MAJOR(dev)个值),所以最后可以直接调用。crw_table的定义如下:
static crw_ptr crw_table[]={
NULL, /* nodev */
NULL, /* /dev/mem */
NULL, /* /dev/fd */
NULL, /* /dev/hd */
rw_ttyx, /* /dev/ttyx */
rw_tty, /* /dev/tty */
NULL, /* /dev/lp */
NULL}; /* unnamed pipes */
linux下tty终端的主设备号是5,也就是说,调用的是rw_tty函数!
static int rw_tty(int rw,unsigned minor,char * buf,int count)
{
if (current->tty<0)//这个current->tty要是是-1就说明没有终端,具体见sched.h第95行.
return -EPERM;//返回错误码
return rw_ttyx(rw,current->tty,buf,count);//调用另一个函数
}
rw_ttyx的定义如下:
static int rw_ttyx(int rw,unsigned minor,char * buf,int count)
{
return ((rw==READ)?tty_read(minor,buf,count):
tty_write(minor,buf,count));//判断是不是要读,如果是就调用tty_read,否则调用tty_write.
}
rw_tty是终端读写操作函数,而rw_ttyx则是串口终端读写操作函数.
那么,tty_write又是怎么运行的呢?也像前两个函数一样,只是个stub吗?No,No,No,它可没你想的那么简单。
\texttt{0006h} :\texttt{tty} _\texttt{write} 与\texttt{con} _\texttt{write} 函数
先来看看tty_write的定义(~\kernel\tty_io.c):
//这里的channel是设备号(这里传进来的是次设备号(MINOR)而不是主设备号(MAJOR),是0而不是5),buf是缓冲区(要输出的数组),nr是要输出的字节数
int tty_write(unsigned channel, char * buf, int nr)
{
static cr_flag=0;
struct tty_struct * tty;
char c, *b=buf;//以下的b就等同于buf
//因为这个版本的linux只支持3种终端设备(分别是控制台终端(0)、串口终端1(1)和串口终端2(2)),所以channel不能大于2.
if (channel>2 || nr<0) return -1;//如果不满足条件,就返回错误码
tty = channel + tty_table;//这句相当于tty=tty_table[channel]
while (nr>0) {//如果nr大于零(意味着处理还没结束)
sleep_if_full(&tty->write_q);//要是写缓冲队列满了就让当前进程进入可中断睡眠
if (current->signal)//如果当前进程收到信号
break;//直接break
while (nr>0 && !FULL(tty->write_q)) {//假如处理还没结束,并且写缓冲队列还没满
c=get_fs_byte(b);//取缓冲区中的一个字符
if (O_POST(tty)) { //如果这个终端启用了处理后输出(如果没启用就不能处理)
if (c=='\r' && O_CRNL(tty))//如果c是回车符且这个终端开启了回车符转换行符标志
c='\n';//改成换行
else if (c=='\n' && O_NLRET(tty))//否则,如果c是换行符且这个中断启用了不输出回车符标志
c='\r';//改成回车
if (c=='\n' && !cr_flag && O_NLCR(tty)) {//如果c是换行符且回车标志(cr_flag)没启用而且换行转回车-换行标志没启用的话
cr_flag = 1;//把回车标志置1
PUTCH(13,tty->write_q);//在写缓冲队列中加一个回车,就是说这个字符用回车代替(回车的ASCII码是13)
continue;//跳出这一轮循环
}
if (O_LCUC(tty))//如果启用了小写转大写标志
c=toupper(c);//进行转换
}
b++; nr--;//b的首地址向前推进一格,剩下的长度减一(就是要处理的字符少了一个)
cr_flag = 0;//把回车标志置回0
PUTCH(c,tty->write_q);//把这个处理好的c放入tty写队列中
}
tty->write(tty);//上面的循环执行完了(或者写队列满了)之后调用这个tty的写函数输出
if (nr>0)//如果还有东西没处理完
schedule();//去调度
}
return (b-buf);//返回写的字节数
}
老规矩,解释一下上面代码里的一些概念。
- 写缓冲队列
这个队列定义在tty_struct中,它的内容就是即将输出到屏幕上的内容(至于怎样输出后面会讲)。
tty到底是啥?!
这个概念有点难讲,贴个链接吧。
行文至此,笔者相信大部分人都以为本文要结束了,对吧?
To Young To simple,还早的很呢,没看见上面还有个con_write没讲吗?
诶诶诶,这个con_write又是啥?别急,听我慢慢讲。
上面提到过一个tty->write,如果你认真看了代码就会发现,在上面一堆花里胡哨的处理后,真正输出字符串的其实是这个函数。看看它的定义吧:
void (*write)(struct tty_struct * tty);
很显然这是一个函数指针,它指向的就是con_write函数。那么它为什么会指向con_write呢?这还要代码中的一行开始讲起:
tty = channel + tty_table
这里不就是把tty赋了个值吗?怎么会和con_write扯上关系?先来看看tty_table的定义:
struct tty_struct tty_table[] = {
{
{0,
OPOST|ONLCR, /* change outgoing NL to CRNL */
0,
ICANON | ECHO | ECHOCTL | ECHOKE,
0, /* console termio */
INIT_C_CC},
0, /* initial pgrp */
0, /* initial stopped */
con_write,
{0,0,0,0,""}, /* console read-queue */
{0,0,0,0,""}, /* console write-queue */
{0,0,0,0,""} /* console secondary queue */
},{
{0, /*IGNCR*/
OPOST | ONLRET, /* change outgoing NL to CR */
B2400 | CS8,
0,
0,
INIT_C_CC},
0,
0,
rs_write,
{0x3f8,0,0,0,""}, /* rs 1 */
{0x3f8,0,0,0,""},
{0,0,0,0,""}
},{
{0, /*IGNCR*/
OPOST | ONLRET, /* change outgoing NL to CR */
B2400 | CS8,
0,
0,
INIT_C_CC},
0,
0,
rs_write,
{0x2f8,0,0,0,""}, /* rs 2 */
{0x2f8,0,0,0,""},
{0,0,0,0,""}
}
};
之前讲了,channel是0,那么看看第一个成员:
{
{0,
OPOST|ONLCR, /* change outgoing NL to CRNL */
0,
ICANON | ECHO | ECHOCTL | ECHOKE,
0, /* console termio */
INIT_C_CC},
0, /* initial pgrp */
0, /* initial stopped */
con_write,
{0,0,0,0,""}, /* console read-queue */
{0,0,0,0,""}, /* console write-queue */
{0,0,0,0,""} /* console secondary queue */
}
可以看到里面有个con_write。再来看看tty_struct的定义:
struct tty_struct {
struct termios termios;//因为termios是有六个成员的结构体,所以它要占六个参数
int pgrp;
int stopped;
void (*write)(struct tty_struct * tty);
struct tty_queue read_q;
struct tty_queue write_q;
struct tty_queue secondary;
};
诶,在和上面con_write函数相同的位置上就是write的定义诶,所以这个问题解决了。
接下来我们看看con_write函数(~\kernel\console.c):
//这个函数其实就是显卡驱动
void con_write(struct tty_struct * tty)
{
int nr;//这个是字符串的长度
char c;
nr = CHARS(tty->write_q);//CHARS的定义是(((a).head-(a).tail)&(TTY_BUF_SIZE-1)),TTY_BUF_SIZE是1023,二进制是8个1,和任何小于1023的数按位与都是其本身,和任何大于1023的数进行按位与都是(n-1023k)-1(k为任意非零实数),所以这里的长度nr是abs(真正的字符串长度 % 1023)-1,这里要这么写的用意应该是不让字符串长度大于1023。(所以之前main.c中的printbuf有1024个元素)
while (nr--) {
GETCH(tty->write_q,c);就是取write_q的最后一位给c
switch(state) {//这个state定义在这个文件的上面,初值为0,后面可能改
case 0://第一次进来就会执行这个case
if (c>31 && c<127) {//如果c是正常的字符(不是控制和扩展字符)
if (x>=columns) {//columns是屏幕上一行的最大宽度,这里就是说一行到头了,该换行了
x -= columns;//回车,这里x是光标的横坐标
pos -= columns<<1;//调整x在显存中的位置,因为显存中的地址偏移要乘二(每个字符要占两个字节,一个字节给字符,另一个字节给这个字符的属性,属性就是这个字符的前景色、颜色、高亮等特性),所以要左移一位
lf();//换行
}
__asm__("movb _attr,%%ah\n\t"
"movw %%ax,%1\n\t"
::"a" (c),"m" (*(short *)pos)
:"ax");//显示字符。(就是把字符写入显存中的相应位置(任何写入显存的东西都会被直接画到屏幕上),这里的"相应位置"是pos(已经强转为short了,因为显存中一个字符只有两个字节,那个attr就是前面说的字符属性(这个文件里有个函数叫csi_m(),就是改这个用的)))
pos += 2;//光标前进一格
x++;
} else if (c==27)//如果是ESC(ESC的ASCII码是27)
state=1;
else if (c==10 || c==11 || c==12)//如果是LF(换行),VT(垂直定位),FF(换页)
lf();//换行
else if (c==13)//如果是回车
cr();//回车
else if (c==ERASE_CHAR(tty))//如果是控制字符(ERASE_CHAR是一个宏,这里会被替换成\127,即退格键有兴趣的读者可以自行了解,这里不作展开)
del();//退格
else if (c==8) {//如果是退格键
if (x) {//且x不在行首
x--;
pos -= 2;//退格
}
} else if (c==9) {//如果是水平制表符(\t,相当于Tab)
c=8-(x&7);//这里类似上面的CHARS,7的二进制是3个1,在x<7时,x&7=x,反之则为x-7,所以c会被改成x和最近的8的倍数的距离
x += c;//把x移到8的倍数列上
pos += c<<1;
if (x>columns) {//同上
x -= columns;
pos -= columns<<1;
lf();
}
c=9;//把c改回去
}
break;
case 1:
//按下esc说明要开始输入控制码了
state=0;//先把state置回零
if (c=='[')//假如是[,说明是长命令模式
state=2;//就把state改成2然后跳走
else if (c=='E')//如果是esc+E,
gotoxy(0,y+1);//把光标移到上行开头
else if (c=='M')//esc+M
ri();//光标上移一行
else if (c=='D')//esc+D
lf();//回车
else if (c=='Z')//esc+z
respond(tty);//
else if (x=='7')
save_cur();//保存光标位置
else if (x=='8')
restore_cur();//恢复光标位置
//还有上面的两个x似乎应该是c?
break;
case 2:
for(npar=0;npar<NPAR;npar++)
par[npar]=0;//先把转义字符数组清零
npar=0;//再把npar指向首项
state=3;//跳到状态3
if (ques=(c=='?'))//如果c是?就直接break(注意这里顺便把ques也赋成了c)
break;
case 3:
//如果c是分号且par数组未满,
if (c==';' && npar<NPAR-1) {
npar++;
break;
} else if (c>='0' && c<='9') {
par[npar]=10*par[npar]+c-'0';
break;
} else state=4;
case 4:
//跳到这里说明用户正在输入终端控制码(貌似是VTxxx的终端?)
state=0;
switch(c) {
case 'G': case '`':
if (par[0]) par[0]--;
gotoxy(par[0],y);
break;
case 'A':
if (!par[0]) par[0]++;
gotoxy(x,y-par[0]);
break;
case 'B': case 'e':
if (!par[0]) par[0]++;
gotoxy(x,y+par[0]);
break;
case 'C': case 'a':
if (!par[0]) par[0]++;
gotoxy(x+par[0],y);
break;
case 'D':
if (!par[0]) par[0]++;
gotoxy(x-par[0],y);
break;
case 'E':
if (!par[0]) par[0]++;
gotoxy(0,y+par[0]);
break;
case 'F':
if (!par[0]) par[0]++;
gotoxy(0,y-par[0]);
break;
case 'd':
if (par[0]) par[0]--;
gotoxy(x,par[0]);
break;
case 'H': case 'f':
if (par[0]) par[0]--;
if (par[1]) par[1]--;
gotoxy(par[1],par[0]);
break;
case 'J':
csi_J(par[0]);
break;
case 'K':
csi_K(par[0]);
break;
case 'L':
csi_L(par[0]);
break;
case 'M':
csi_M(par[0]);
break;
case 'P':
csi_P(par[0]);
break;
case '@':
csi_at(par[0]);
break;
case 'm':
csi_m();
break;
case 'r':
if (par[0]) par[0]--;
if (!par[1]) par[1]=lines;
if (par[0] < par[1] &&
par[1] <= lines) {
top=par[0];
bottom=par[1];
}
break;
case 's':
save_cur();
break;
case 'u':
restore_cur();
break;
}
}
}
set_cursor();
}
显存的地址空间是0xb8000~0xbffff,但这段空间并不真实存在于物理内存中,而是被映射到显卡里真正的显存中。
讲到这里,你是否觉得本文要结束了呢?
没错,整个流程已经讲完了,再往后就没得讲了,非要讲的话就要开始讲显卡的硬件实现了。
所以,我们来梳理一下printf函数执行的整个流程:
第一步:你的程序调用了printf函数
第二步:你传进去的字符串被vsprintf函数格式化
第三步:格式化后的字符串连同其他参数一起被传进write函数,触发系统调用,陷入内核态
第四步:在system_call.s中,经过一番眼花缭乱的操作,开始执行sys_write函数
第五步:在sys_write函数中,因为终端是字符设备,所以调用rw_char函数进行读写
第六步:rw_char函数通过一个typedef函数指针调用rw_tty,并触发一个stub(rw_ttyx),最终调用tty_io函数。
第七步:tty_io函数根据一些终端标志位对字符串进行进一步处理,最后调用con_write函数进行真正的输出
第八步:在con_write函数中,你的字符串被写入显存,最终被显卡输出到显示屏上。
你看,从一个存放在内存中的字符串,到屏幕上的黑底白字和闪动的光标,一共要经历整整八个步骤。现在再回头看看那个平平无奇的函数,你还觉得它是个普通的函数吗?
printf("Hello,world!")
参考文献
[1]《Linux系统调用过程分析》 作者:binss
[2]《Linux内核完全注释 修正版v3.0》 作者:赵炯
[3]《跟我一起写操作系统》 作者:李德强
向以上参考文献的作者表示衷心的感谢!
更新
update-20200401------------------------
经评论区@zhaojinxi 大佬指正,改正一处错误。
update-20200402------------------------
修改一些细节。
update-2020403------------------------
改正一处错误。