摘 要

  本文按照哈尔滨工业大学计算机系统课程大作业要求,以hello程序为例,从预处理编译开始,到IO管理结束,通过在Linux下用各种工具对hello程序进行分析,详细的分析了hello从程序到进程的过程,以及从程序开始从磁盘加载进入内存到程序结束退出内存的整个过程,分析了其中操作系统的处理方式,以此来加强自己对计算机系统的认识,并希望能够帮助到其它想要了解计算机系统的同学。

关键词:计算机系统;Linux;程序编译;进程管理;内存映射;虚拟内存;

第1章 概述

1.1 Hello简介

  P2P(From Program to Progress):程序员敲击键盘得到程序(Program)hello.c,预处理得到hello.i,编译得到hello.s,汇编得到可重定向文件hello.o,再与其它用到的可重定向文件一起链接形成了最终的可执行程序hello.out,再bash-shell中加载程序,bash-shell会fork形成两个进程,在子进程中指向execve装载并运行hello.out,此时hello才成为了进程(Progress)。

图1 Program to Process 流程图

  020(From Zero to Zero):hello.out在bash-shell运行execve时与虚拟内存建立映射关系,CPU通过MMU把VA转化成PA,通过缺页处理的方式把程序从虚拟内存中加载到物理内存中,之后又加载到L3,L2,L1Cache,而bash-shell会在hello开始运行后,执行waitpid等待hello执行结束,执行结束后回收hello在内存中的所有痕迹。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

硬件:X64 CPU;2.90GHz;32G RAM;512G SSD

软件环境:Windows 11 家庭中文版;Vmware 15.5;Ubuntu 21.04 64位

开发调试工具:VS code;gcc;gdb;

1.3 中间结果

文件名描述
hello.c源程序
hello.ihello.c预处理得到的预编译处理文件
hello.shello.i编译得到的汇编文件
hello.ohello.s汇编之后的可重定位目标文件
hello.o.elfreadelf -a hello.o
hello.o.objdumpobjdump -r -d hello.o
testprintf.c测试printf调用的测试程序
testprintf.stestprintf.c编译得到的文件
tty.cgetchar源码来自鸿蒙仓库 kernel/linux/linux-5.10/arch/x86/boot/tty.c
dprintf.cprintf源码来自鸿蒙仓库 device/board/talkweb/niobe407/liteos_m/bsp/src/dprintf.c

1.4 本章小结

  从程序到进程,对于用户来讲,就一个双击或是一个指令的简单操作,但是其背后的操作是非常复杂的,从Program到Progress离不开编译器,也离不开shell;计算机可以长时间稳定运行也离不开从Zero到Zero的内存管理策略;我们可以流畅的运行多个程序,离不开虚拟内存的管理方案,离不开巧妙的多级缓存机制。

第2章 预处理

2.1 预处理的概念与作用

  预处理(或称预编译)是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。预处理指令指示在程序正式编译前就由编译器进行的操作,可放在程序中任何位置。

  当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。C语言提供多种预处理功能,主要处理#开始的预编译指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。

2.2在Ubuntu下预处理的命令

  在Ubuntu下,使用gcc的-E参数对程序进行预处理,还可以添加-o参数让结果重定向输入到某以特定文件中,下面例子为将hello.c预处理后,结果重定向输出到hello.i文件中。

图2 预处理

2.3 Hello的预处理结果解析

  预处理过程中,会把用#include所引入的头文件的实际地址解析出来,同时也会解析该头文件所用#include引入的头文件,一直递归解析下去。如下图所示,hello.c中用到了stdio.h,stdio.h中又用到了libc-header-start.h……

图3 预处理——头文件的解析

  除了头文件解析工作以外,还会把程序里用#define,#ifdef等等指令,根据实际逻辑进行对程序的删减,因此在预处理后得到的文件中是不含#ifdef,#elif等等指令的。如图所示,左边的为stdio.h,右边为hello.i文件中的,snprintf,vsnprintf几乎原封不动保留了,而vasprintf因为#if条件为假,所以在预处理时直接被删除了。

图4 预处理——预处理器指令的处理

2.4 本章小结

  在C语言中,源代码通过gcc的-E参数可以选择只开启预编译选项生成预编译处理完成的.i文件。预编译过程中,会解析程序用到所有头文件的绝对路径,除此之外还会根据预处理器指令将不需要的代码删除等一系列操作。

第3章 编译

3.1 编译的概念与作用

  把预处理的文件进行一系列的语法分析并且进行优化后生成的相应的汇编指令叫做编译,其作用为hello.i文件生成hello.s文件,方便下一步生成二进制文件。

3.2 在Ubuntu下编译的命令

gcc hello.i -S -o hello.c

图5 编译

3.3 Hello的编译结果解析

3.3.1 局部变量int

图6 局部变量int被存在栈中

3.3.2 字符串常量

  printf中写的格式化字符串被当作局部的字符串常量处理,globl项说明了该常量作用范围为main函数内。

图7 字符串常量解析结果

3.3.3 算术操作符 ++

图8 ++操作符的解析结果

3.3.4 关系操作符 <

  i<8在进行编译时,被解析成了i<=7,或许是为了减小编码长度,在这个例子中可能没有体现出来,但是如果8换成256,则无法用一个字节存储,而换成小于等于255就刚好可以用一个字节表示。

图9 <操作符的解析结果

3.3.5 关系运算符 !=

图10 !=操作符的解析结果

3.3.7 下标访问 []

图11 下标运算符的解析结果

3.3.8 函数传参

  绝大多数函数传参,都是按照尽可能使用寄存器传递的原则,用rdi,rsi,rdx等一系列寄存器进行传递,当参数大于7个时,第七个以及以后的参数会放到栈进行传递,如下图所示:

图12 多参数传递

  但是printf函数与其它函数传参方式稍有不同,它传参是把格式化字符串的头指针作为第一个参数,而其它参数会按照从左到右的方式压栈,然后把第一个参数的指针作为参数传递过去,如下图所示:

图13 printf的函数传参

  在hello.c中的printf的传递也是如此:

图14 hello.c里printf的传参解析

3.3.9 函数返回值

  函数的返回值都是通过rax寄存器来进行传递的,如图,调用atoi函数,其返回值又作为sleep函数的参数传递。

图15 函数的返回值解析

3.3.10 条件控制 if

  if语句一般是通过cmp和jmp系列指令配合实现的,基本和原结构保持一致。

图16 条件控制if解析

3.3.11 条件控制 for

  for循环是转化成类似go-to语句的结构。

图17 条件控制for解析

3.4 本章小结

  编译阶段进行了将代码从C语言转化成了与机器指令一一对应的汇编语言,便利了之后的一系列处理。在编译过程中,会把局部变量储存在栈中,把字符串常量存储在堆中,对于算数运算符和关系运算符,汇编语言和C语言并无太多差别,不过在一些时候编译器会把小于(大于)转化成等价的小于等于(大于等于),下标运算符号[i]都是通过数组指针+8(指针长度)*i实现的,大多数函数传参都尽可能使用寄存器进行传递,而printf稍有例外。if控制语句的汇编语言与C语言的if结构基本类似,而for控制语句则更接近于go-to控制语句。

第4章 汇编

4.1 汇编的概念与作用

  把生成的汇编指令逐条翻译成机器可以识别的形式,即机器码。起作用是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并把结果保存在目标文件hello.o当中。hello.o文件是一个二进制文件。

4.2 在Ubuntu下汇编的命令

gcc hello.s -c -o hello.o

图18 汇编

4.3 可重定位目标elf格式

4.3.1 ELF头

  描述了该文件的一些基本信息,类别,大小,格式等等。

图19 ELF头

4.3.1 节头表

  节头表是节头的集合,节头描述了每个节的名字,类型,偏移地址,大小等基本信息。

图20 节头表

4.3.1 .rela.text段

  可重定位的text段,存放了程序所用到的代码以及一些存放在堆中的数据.rodata,如hello.c中的字符串常量”用法:“Hello 学号 姓名 秒数!\n”。

图21 .rela.text段

4.4 Hello.o的结果解析

4.4.1 分支跳转

  在.s文件中,分支跳转是使用段名作为标识的,而在.o文件中,是用地址的偏移量来表示的。因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。

图22 分支跳转对比

4.4.2 函数调用

  函数调用与分支跳转类似,在.s中是使用函数名来进行标识的,而在.o文件中是当前位置的下一条指令,因为这些被调用的函数都是在其它库里,现在还不能确定其位置,需要等到静态链接或者动态链接后才能确定其具体的位置。

图23 函数调用对比

4.4.3 数据访问

  对于存放在堆中的数据(全局变量,字符串常量),在.o文件中,是通过如:.LC1(%rip)的形式进行访问的,而在.o文件中是0(%rip),这同样是因为在堆中的数据需要等到运行时才能确定,使用了0(%rip)的形式进行占位。

图24 .rodata数据段中的数据访问的对比

4.5 本章小结

  从hello.s到hello.o,第一次地址引入进来,将一些可以确定地址的地方用实际的偏移量表示,不可以确定地址的地方先暂时用特殊写法标识出来等待后续链接时候再进行处理。

第5章 链接

5.1 链接的概念与作用

  链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接使得分离编译成为可能,能够将一个大型的应用程序分解成为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/10/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/10/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out

图25 链接

5.3 可执行目标文件hello的格式

  在ELF头中,记录了该文件的类型,版本信息,数据表示方式,程序入口地址等信息。

图26 out文件的ELF头

  节头描述了每个节的名字,类型,偏移地址,大小等基本信息,其中.text段(存放程序的段)的地址与ELF头中程序入口地址一致。

图27 out文件节头表(部分)

  程序头中包括所指向段的类型、其在ELF文件中的偏移地址、大小,映射到内存的虚拟地址信息,段的读写权限等等。

图28 out文件程序头表

5.4 hello的虚拟地址空间

  程序的入口(.text段起始地址)0x4010f0并不是我们平时认为的main函数,而是一个_start函数,在该函数结束后会调用__libc_start_main,然后才会调用我们用户写的main函数。

图29 程序的真正入口_start函数

  .rodata在ELF文件中描述在0x402000处,在该处可以发现有两个字符串,对应.s文件中的LC0和LC1,可以发现两个字符串是连续存储的(中间以’\0’分割)。

图30 rodata段在内存中的存储

  在程序头表中第二个LOAD段的起始位置0x401000到结束位置0x4012f5(起始位置+偏移量)之间存储的就是所有的执行代码。

图31 第二个LOAD段的开始和结束

5.5 链接的重定位过程分析

5.5.1 分支跳转

  在.o文件中,分支跳转是采用偏移量的方式进行跳转的,而在.out文件中,分支跳转时采用直接跳转到具体的地址实现的。

图32 out与.o文件分支跳转的对比(左边为out文件)

5.5.2 函数调用

  在.o文件中,函数的调用时直接用当前的下一个地址占位,而在.out文件中是跳转到一个【函数名@plt】的函数的地址处。

图33 out与.o文件函数调用的对比(左边为out文件)

5.5.3 数据访问

  在.o文件中,对于rodata中的数据(全局变量,字符串常量)的访问是用0(%rip)的形式占位标记的。而在out文件中直接根据给出了具体的偏移量。

图34 out与.o文件数据访问的对比(左边为out文件)

5.6 hello的执行流程

如图所示:

图35 hello的执行过程

5.7 Hello的动态链接分析

  在out文件中,调用共享库的函数,都是通过PLT进行调用的,而程序中都是调用【函数名@plt】的函数,而这些函数都会指向同一个地址。

图36 PLT在内存中的形式

  所有的调用共享库最终都会到0x401026这里,而这里的jmp语句是跳转到0x404010内存所存储的地址处,在dl_init前,这里的地址是空的。

图37 dl_init前 0x404010的值

  在dl_init后,0x404010的值会被改变,而这个地址指向的是ld-2.33.so动态库中的一个函数地址,该函数会根据PLT压入的值调用对应的函数。

图38 dl_init后 0x404010的值

5.8 本章小结

  链接阶段把汇编阶段用偏移量表示的调用,跳转改用了实际的虚拟地址,而调用共享库的函数则构建了一个PLT表,在程序运行前进行动态链接,确定查表的函数地址,通过这个查表函数来进行对共享库中函数的调用。这个查表函数是动态链接器在初始化时为程序构建的,构建完成后会把函数的调用地址存放到指定的内存空间中。

第6章 hello进程管理

6.1 进程的概念与作用

  进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。它是对正在运行程序过程的抽象,在实现上是一种数据结构,清晰的刻画了动态系统的内部规律,方便了系统对程序的调度和管理。

6.2 简述壳Shell-bash的作用与处理流程

  Shell是一种命令行解释器,其读取用户输入的字符串命令,解释并且执行命令。它是一种特殊的应用程序,介于系统调用/库与应用程序之间,其提供了运行其他程序的的接口。它可以是交互式的,即读取用户输入的字符串;也可以是非交互式的,即读取脚本文件并解释执行,直至文件结束。如果是运行某一个程序,则会执行fork,在fork之后在子进程中execve装载程序,然后在父进程中等待该程序(前台进程)执行完毕后对其进行回收操作,如果是后台进程则不需要等待;如果是内置指令,则执行该指令对应的操作。

6.3 Hello的fork进程创建过程

  shell判断输入字符串“./hello.out 120L022112 孙崇林3”不是内置指令,则执行执行fork系统调用,shell的进程将分化成两个进程父进程和子进程,此时hello还未成为进程。

6.4 Hello的execve过程

  在子进程中,执行shell执行execve系统调用,将hello.out装载入内存,覆盖了原先的内存信息,此时,hello正式成为进程,下一步开始执行。

6.5 Hello的进程执行

  结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

  在Linux系统中,进程调度采用的是抢占式多任务处理,分为就绪态,运行态,等待态三种状态,对于每个核来讲,同一时间内处于运行态的进程只能有一个,当运行态中的进程用光了分配给他的时间片后,会回到就绪态与其优先级相同的队列队尾,然后加载就绪态中优先级最高的队列并处于队首的进程进入运行态执行,如果程序在运行过程中执行了sleep这样的等待函数就会从运行态退出进入等待态,等待结束后再进入就绪态排队。

  进程状态的切换是利用上下文切换机制实现的,上下文切换本质是异常处理机制,运行态的程序由于某种原因(时间片用完,需要等待等等)引发了该异常,进入内核态,在内核态中完成了对进程的状态改变以及决定了下一个进入运行态的程序,并加载入CPU执行。

  在Hello中,程序加载成功后进入就绪态等待进入运行态,进入运行态后打印相应的字符串,打印完成后会调用sleep,因而进入等待态,休眠结束后进入就绪态等待进入运行态,依次循环,直到for循环结束,结束后会因为需要等待用户输入再次进入等待态等待用户执行,用户执行完后,该进程经过一系列退出操作后,会发出SIGCHLD信号,等待被shell回收。

图39 hello的进程执行

6.6 hello的异常与信号处理

  在hello的正常执行过程中,一般只会在进程结束后,向父进程发送SIGCHLD信号,让父进程完成对自己的回收工作。

  在hello运行过程中,按回车会引起中断,I/O中断,但由于程序内没有对应的处理程序,在中断进入内核态中又会回到之前执行的指令,所以程序看起来是没有任何变化的。

  而在hello运行过程中,按Ctrl+C,hello会直接结束,这时由于Ctrl+C会发送一个SIGINT信号给hello,而SIGINT信号的默认操作是终止进程,在hello中没有对SIGINT信号进行捕捉所以会执行默认操作终止进程。

图40 Ctrl+C发送SIGINT信号结束进程

  按Ctrl+Z,会发送SIGTSTP信号,该信号默认操作是暂停进程,即让进程处于等待态,可以通过fg命令让其回到前台进程继续执行,也可以通过kill命令发送SIGINT信号杀死进程。

图41 fg,kill等命令执行

6.7本章小结

  shell在用户输入命令后,解析命令,如果不是内置命令则执行fork,在子进程中execve加载hello程序,hello至此成为独立的进程,hello运行过程中反复进行了运行态,等待态,就绪态的切换,最终结束后向shell发送SIGCHLD信号结束了自己的一生。

第7章 hello的存储管理

7.1 hello的存储器地址空间

  逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为
[段标识符:段内偏移量]。

  线性地址也叫虚拟地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。

  物理地址是指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

  在汇编代码中我们看到的地址只是一个段内偏移量,需要加上DS数据段的基地址才能构成线性地址,在x86保护模式下,段描述符占8个字节无法放在段寄存器(2个字节)中,所以把段描述符整理成一张表(GDT),放在了内存中,而段寄存器中存放改段描述符的索引值,而需要的时候根据这个索引值取内存中找,找到后,基地址+偏移量便得到了线性地址。

图42 从逻辑地址到线性地址

7.3 Hello的线性地址到物理地址的变换-页式管理

  程序在加载时,并不会全部的加载到内存中,而是在磁盘上建立虚拟内存,按照页交换的方式在需要的时候加载进入内存中,而CPU对内存的访问是通过MMU把线性地址(虚拟地址)转化成物理地址进行读写操作的。

图43 CPU通过MMU对内存的访问

7.4 TLB与四级页表支持下的VA到PA的变换

  MMU把虚拟地址(VA)转化成物理地址(PA)是通过查询页表(PTE)实现的,PTE中存储了虚拟页到物理页的映射关系,PTE是常驻内存的一个表,如果CPU每次查询都去访问内存访问PTE的话速度太慢,所以引入了TLB(与Cache类似),TLB是MMU中一个小的具有较高相联度的缓存,其运行机理类似于Cache,只不过存储的只是PTE而已,通过这样的缓存极大的提高了PTE的访问效率,但对于地址空间位64位的系统来讲,PTE占用了非常多的内存,于是引入了多级页表,第一级页表常驻内存,而其它的级数只在用到的时候创建放入内存中,这样极大的减少了内存的需要。

  在访问时,MMU通过把根据虚拟地址查表一级PTE,PTE根据虚拟地址指向下一级页表,下一级页表又根据虚拟地址指向下下级页表,到第四级页表时查询得到具体的物理页号(PPN),根据PPN和VPO(虚拟页面偏移量与物理页面偏移量PPO相同),就可以访问到具体的物理内存了。

图44 k级页表翻译

7.5 三级Cache支持下的物理内存访问

  在三级Cache支持下,CPU访问物理内存首先会去Cache
L1中找需要访问的内存是否已被缓存,是否有效,如果已被缓存且有效就称为Cache命中,直接就对其进行读写即可,如果Cache L1没有找到,就去L2中寻找,如果还是没有就去L3中寻找,如果依旧没有,这时才会访问内存,把需要访问的内存附近的整一Cache line都加载进入Cache L3,L2,L1中,然后再进行读写操作。

图45 Core i7 芯片封装

7.6 hello进程fork时的内存映射

  在程序执行fork时,创建当前进程的的mm_struct,
vm_area_struct和页表的原样副本。并把两个进程中每个页面标记为只读,并将两个进程中的每个区域结构都标记成写时复制,这样在fork完后两个进程任意一个对内存进行修改时都会创建一个新的页面,保证了两个进程的内存独立。

图46 内存映射——写复制

7.7 hello进程execve时的内存映射

  在执行shell执行execve时,会删除当前进程虚拟地址的用户部分中的已存在的区域结构,为新程序的代码、数据、bss和栈区域创建新的区域结构,再银蛇共享区域,最后设置程序计数器,使其指向代码区域的入口(_start函数)。

7.8 缺页故障与缺页中断处理

  因为程序运行时,不会将所有的数据都加载到内存中,而是放在磁盘上等需要的时候再加载进入内存。如果程序运行需要的数据还未从磁盘上加载进入内存,也就是MMU查找PTE时没有找到对应的地址,此时就会引发缺页故障异常,而缺页故障程序会根据当前内存状态,选择牺牲内存中的一个内存页,然后把磁盘上的物理页加载进入内存中。缺页异常处理完后,会跳转到引发缺页故障的那条指令重新执行。

图47 缺页故障

7.9动态存储分配管理

  在C语言中,动态内存时采用显示分配器(malloc,free)实现的,在程序中调用malloc申请内存(在堆中),在分配完的内存前后会额外用一些地址空间用来标记分配的该片内存的大小,再次调用malloc会根据这些表示找到空闲空间进行申请,而调用free则会根据标识释放与之前申请相对应的内存空间。

  printf在运行时,是从前往后扫描格式化字符串的,每执行一个格式化字符串就会比较输出的大小,根据这个大小决定是否要调用malloc申请内存。

7.10本章小结

  在Linux下系统为了实现对物理内存的屏蔽,引入了虚拟内存的概念,它为进程隐藏了物理内存这一概念,让内存管理变得更加简单。利用局部性原理,实现程序加载的多级缓存,在减少了对内存需要的同时也兼顾了程序的运行效率。无论是TLB还是Cache其策略都是相同的,把经常用的东西放到速度快的地方,而需要写回时都采用了延迟写回的策略,只有在逼不得已的情况下才进行写回,这一点与内存的映射类似,把能一起用的东西放到同一个地方,只有在需要对这个地方需要修改时才进行真正的复制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

  在Linux下,所有的IO设备都被模型化为文件,所有对这些IO设备的操作均看作文件的读写操作。

8.2 简述Unix IO接口及其函数

8.2.1 open

函数原型:int open(const char *pathname, int flags);

  解释:一个应用程序通过此方法来要求内核打开相应的文件,内核返回一个非负整数,叫做文件描述符,后续所有的操作都基于这个文件描述符。

8.2.2 close

函数原型:int close(int fd);

  解释:成功的时候返回0,现在异常的时候返回-1.关闭文件会通知内核已经完成访问该文件,不能重复关闭同一个文件。

8.2.3 read

函数原型:ssize_t read(int fd, void *buf, size_t count);

  解释:读取文件会从当前文件位置复制字节到内存,然后更新文件位置(前提是文件支持seeking),返回从文件fd读取到buf的字节数。

8.2.4 write

函数原型:ssize_t write(int fd, const void *buf, size_t count);

  解释:写入文件会将字节从内存复制到当前文件位置,然后更新当前文件位置(前提是文件支持seeking)。

8.2.5 lseek

函数原型:off_t lseek(int fd, off_t offset, int whence);

  解释:对于每个打开的文件,内核保存着一个文件位置k,表示从文件开头起始字节的偏移量,默认为0。应用程序可以通过lseek显示的设置k的值。

8.3 printf的实现分析

  由于实在看不懂libc中printf的源码,也没找到Linux中printf的实现源码,所以接下来的分析是基于liteOS-m的printf的源码。

图48 print源码LiteOS-m

  第52行,stdarg.h中提供的一个宏,用处是获取可变参数的下一个值,如果我们的printf参数是这样的(”%d
%d”,a,b),那么ap的值就是a的地址,在3.3.8节中提到过printf可变参数的传递是全部压入栈中,得到了a的地址,那么就相当于得到了整个参数列表。

  第53行,调用vsnprintf_s,这个函数是用来把格式化字符串根据实际参数转化成需要输出的字符串的,返回值为这个字符串的长度。

  第55行,调用dputs,该函数用于向指定文件中写入字符串,而这里调用的时候传入的写入函数为UartPutc,也就是串口写入的函数,因为LiteOS-m一般用于嵌入式系统,其printf是通过串口通信在电脑上显示的,所以这里是向串口中写入字符串。

图49 dputs源码LiteOS-m

  如果是在Linux下,那么这里就应该是调用UnixIO接口的write,因为Linux把所有的IO设备都当作了文件,屏幕也是IO设备的一种,所以往指定位置写入数据后字符显示驱动子程序完成从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)的转化,显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

图50 getchar源码-Linux

  getchar的实现关键在第92行的intcall,这个函数是一个中断调用函数,第一个参数是中断调用的中断号,这里的0x16应该是键盘的中断号,第二个参数调用前的寄存器对应的结构体,第三个参数是调用后的寄存器结构体,这一步是为了防止中断调用过程中寄存器值被意外更改,而键盘中断处理程序把获得的值存放在了oreg.al中。

8.5本章小结

  本章的讲述了linux系统下的I/O设备的管理方法,这套管理方法为软件提供了统一而一致的接口来控制复杂而通常大不相同的I/O硬件设备。同时利用hello
world这一实例来茶阐述了系统级别的printf和getchar是怎么实现的。

结论

  hello在程序员通过键盘输入保存在磁盘上以.c文件存储,之后经过预处理,编译,汇编,链接等一系列过程,它从人能看懂的文本文件变成了机器能够看懂的二进制文件。

  之后,在shell加载hello,shell根据输入判断,不是内置指令,于是先执行fork,变成了两个进程,此时复制了一份虚拟内存并且都映射到物理内存中相同的地址空间中,并把他们标记成为写复制,之后在子进程中调用execve装载hello的程序,此时会把虚拟内存中原有的区域结构删除,建立新的区域结构,至此hello成为了独立的进程。hello加载进入内存之后,首先会进行动态链接,动态链接器会根据hello的需要构建一个查表函数,而hello通过这个查表函数来进行对共享库函数的调用,在hello执行完毕之后,如果函数内部没有调用exit,__lib_start_main函数会帮我们调用exit退出,在退出后会给shell发送一个SIGCHLD信号,shell收到这个信息后会释放之前用于存储hello信息的一些内存空间,这就是hello从Zero到Zero的一生。

  通过本次大作业,系统的认识了整个计算机系统结构,在这个过程中遇到了非常多不懂的东西,但大部分通过搜索引擎也得到了最基本的认识,不禁感叹计算机系统是多么复杂而又精密的系统,在感叹的同时也感受到了前辈们在计算机系统方面付出的努力,由衷的敬佩各位前辈。计算机从出现到如今,只有短短80年不到,而发展却是如此的迅猛,从简简单单的电子管电路,到如此精密的系统,不禁感叹,人类的智慧是如此神奇而美妙。

附件

文件名描述
hello.c源程序
hello.ihello.c预处理得到的预编译处理文件
hello.shello.i编译得到的汇编文件
hello.ohello.s汇编之后的可重定位目标文件
hello.o.elfreadelf -a hello.o
hello.o.objdumpobjdump -r -d hello.o
testprintf.c测试printf调用的测试程序
testprintf.stestprintf.c编译得到的文件
tty.cgetchar源码来自鸿蒙仓库 kernel/linux/linux-5.10/arch/x86/boot/tty.c
dprintf.cprintf源码来自鸿蒙仓库 device/board/talkweb/niobe407/liteos_m/bsp/src/dprintf.c

参考文献

[1]Linux进程状态模型示例.https://blog.csdn.net/ganfanren00001/article/details/124770567

[2] Shell简介:Bash的功能与解释过程(一)Shell简介.https://zhuanlan.zhihu.com/p/128654625

[3] Linux进程管理.https://blog.csdn.net/weixin_37709708/article/details/117375193

[4] 从逻辑地址到线性地址.https://blog.csdn.net/weixin_46381158/article/details/118067786

[5] Linux内存管理:逻辑地址到线性地址和物理地址的转换.https://blog.csdn.net/pi9nc/article/details/21031651

[6] 利用printf调用malloc.https://blog.csdn.net/azraelxuemo/article/details/123933079

[7]内存管理1:为什么需要虚拟内存?https://zhuanlan.zhihu.com/p/404813126

[8] linux kernel – intcall.https://blog.csdn.net/sinat_32662325/article/details/50428551

[9] 对printf源码的分析.https://blog.csdn.net/Nod_Mouse/article/details/114654965

[10] printf源代码的分析.https://blog.csdn.net/smallfish0315/article/details/46812081