你真的了解printf函数吗?

· · 个人记录

因为笔者水平有限,文中一定存在一(大)些(量)错误,如果你发现了一些错误,请告诉我,我会以最快的速度改正,谢谢

\texttt{0000h}:前言

printf函数是我们OIer每天都要用的函数,但是你真的了解这个看起来不起眼的小函数吗?本文将带着你讲解linux系统下printf函数的实现原理。相信你读完这篇文章后,会对它有不一样的看法。

注:为分析方便起见,本文采用linux-0.01版本的源码(更高版本的笔者也分析不来)。下载地址

\texttt{0001h}:一些关于汇编语言的基础知识

寄存器:它是直接位于CPU内的高速存储位置.你可以把它理解为汇编中的全局变量,只不过速度比真正的全局变量快得多。常用的32位寄存器主要有eaxebxecx等。

(堆)栈:本文中的栈均指运行时堆栈,它直接由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"中的那个@实际上是,洛谷博客不知道为啥,在代码里打出那个符号就会导致格式爆炸,只能用这种方法了,下同。顺便提一下,在AT&T汇编中,在一个数前加代表这是一个立即数(其实就是常数)

吐槽一下Linus(linux作者)的码风,这码风放到现在估计要被人打

这一堆乱七八糟的内嵌汇编看的人一脸懵,要讲清楚这个,还得先偏个题,讲讲linux系统的系统调用机制。

\texttt{0004h}:\text{Linux}系统的系统调用机制

为了安全,Linux 中分为用户态和内核态两种运行状态。对于普通进程,平时都是运行在用户态下,仅拥有基本的运行能力。当进行一些敏感操作,比如说要打开文件(open)然后进行写入(write)、分配内存(malloc)时,就会切换到内核态。内核态进行相应的检查,如果通过了,则按照进程的要求执行相应的操作,分配相应的资源。这种机制被称为系统调用,用户态进程发起调用,切换到内核态,内核态完成,返回用户态继续执行,是用户态唯一主动切换到内核态的合法手段(exception 和 interrupt 是被动切换)。————《Linux系统调用过程分析》[1]

上面的那段话引自一位大佬的博客。由这段话可以看出,linux系统调用的执行流程如下:

①应用程序向内核发起系统调用。

②内核进行相应的检查。

③通过:按照进程的要求执行相应的操作。

这一步有个专有名词叫陷入内核态

④完成后,返回用户态继续执行。

本文只介绍①。因为想把以上步骤全部讲完且做到能让一位OIer看懂所需要的篇幅实在太长,且其他步骤与本文无关。

我们开始吧:

linux系统为了支持系统调用,向外提供了一个(在本文所用的linux-0.01版本中也是唯一一个)接口:0x80号软件中断。

这个接口目前已经被废弃,现在流行的系统调用方式是使用sysentersyscall指令,具体见参考文献[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的软件开发手册 ,门描述符分为以下几种类型:

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,&divide_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()是设置trapsset_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;//返回错误码
}

上面的代码注释中有许多前面没讲的概念,现在大概解释一下:

这是linux系统下的一个概念,目前似乎没有很准确的中文翻译。它是用来描述文件的一个结构体,包含文件的所有者、字节数、时间戳等信息,完整的定义在~\include\linux\fs.hm_inode

更多信息请参见这里

这是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中,它的内容就是即将输出到屏幕上的内容(至于怎样输出后面会讲)。

这个概念有点难讲,贴个链接吧。

行文至此,笔者相信大部分人都以为本文要结束了,对吧?

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------------------------

改正一处错误。