少点代码,多点头发
本文已经收录至我的GitHub,欢迎大家踊跃star 和 issues。
面试官超级喜欢问hello world问题 特别是校招,我校招碰到过3次
其实很多看起来顺其自然简单的东西,背后是一套复杂的学问
记得很清楚第一次面试阿里巴巴的时候,面试官上来让我写一个hello world程序
当时我真的一面黑人问号的确认了三遍,面试官依旧淡定的说 是的
写完就让我聊hello world,一个hello world聊了一个小时
那时候面试是校招实习,聊完我真的怀疑人生了
这个问题非常考验应试者的计算机基础、自学能力以及对问题钻研的能力
要回答好这个问题,必须掌握计算机基础、操作系统、编译原理等知识才能给出一个完美的答案
来了,开聊了,还没关注我的记得关注我,一键三连
代码如上,现在看来很简单 怎么也不会想到这样的程序还会出错
不丢人的说,龙叔第一次在写这段代码的时候,这个简单的程序大概写了三四遍
好不容易倒腾完了,点击运行后 发现少了头文件
加上之后再运行,发现少了结尾的 ; 号
加上之后,发现少了return 0
就这样倒腾了好几遍,终于在控制台输出了hello world!!! ,那一刻我激动得笑出了声
于是骄傲的我赶紧趁热打铁,写了下面的版本
这两个版本的代码都是C语言写的,C语言课程应该是大学的通识课了,用这个语言讲,大家都能看的明白
运行结果:
外甥非常好奇,这hello world到底是怎么输出到屏幕的
龙叔也好奇过这个问题,只不过是在C语言学完之后才开始好奇
从冯·诺依曼的结构我们可以知道,计算机的基本组成部分如下:
程序,首先是通过输入设备,鼠标、键盘输入的
写好的代码在文本文件中,是需要存储的,此时就用到存储器,代码是存储在磁盘中的
当你点击运行时,你的代码会被读到内存中,在内存中的代码会经过编译器进行编译为可执行文件
编译后的文件经操作系统的进程去启动一个用户进程执行用户的可执行程序
中央处理器会去处理程序逻辑,将执行结果输出到输出设备即显示器
每个部分都有自己的工作,恪尽职守,这个在系统设计上叫模块清晰、功能完整
接下来就从几个方面好好说说这个 hello world,让面试官目瞪口呆下
代码输入这么简单的问题,还用龙叔讲??
如上图首先说下输入过程,此图做了一个浓缩,主要部件 键盘、主机(CPU、内存、磁盘)、显示器
代码输入过程看起来是蛮简单的,打开一个编辑器或者IDE,即可开始代码输入
刚开始学习推荐使用IDE,当然不是没有IDE就不能写代码
任何一个文本编辑器都可以进行代码输入
IDE(Integrated Development Environment) 集成开发环境,一般包括代码编辑器、编译器、调试器和图形用户界面等工具
比如写C&C with class 会下载 vc++、devC++、VS、Clion等等软件,很棒,工具能提高生产力
我习惯用Clion,IDE都是根据自己的需要来选择,用着爽就行
IDE是一个软件,集成度很高的软件 ,启动IDE意味着操作系统必须启动一个进程 该进程叫IDE进程
既然是集成 内部还有很多线程负责集成模块的工作
关于进程、线程 深层次的内容,后面文章会详细讲出 这里就先不展开了
IDE进程会被操作系统管理和调度
要明白这个问题得先说说键盘工作原理
键盘的基本原理就是实时监控按键,将按键信息送入计算机
在键盘的内部设计中有定位按键位置的键位扫描电路,当任何键被按下是 编码电路就会产生代码,这些代码会被送入接口电路,这些电路被称为键盘控制电路
根据键盘工作原理,分为编码键盘和非编码键盘
编码键盘:键盘控制电路的功能完全依靠硬件来自动完成 ,根据按键自动识别编码信息
非编码键盘:键盘控制电路的功能依靠 硬件 和 软件 共同完成
监控键盘的原理就是电位扫描,电位扫描分为逐行扫描法和行列扫描法
原来如此,原来键盘是这样工作的,从此我在飞速敲击键盘时 会更有力量了
这仅仅是键盘驱动进程拿到键盘输入的结果,应用程序是如何获得输入数据的呢?
键盘后台进程拿到结果后会放在自己的共享内存中,应用程序通过共享内存获取到键盘输入结果
上图中很明显看到键盘输入是会发生IO操作的,IO整体内容这里不展开,后面文章会更新
一顿操作,此时IDE会拿到键盘输入的代码,你的hello world代码终于在显示器中让你看到了
接下来说说躺在IDE中代码是如何运行出结果的
代码终于是敲好了,激动的你一般会想着要运行一手,迫不及待看到结果
别急再等等,我们书写的代码程序被称为源代码,CPU执行的是机器码,这个包含机器码的程序被称为可执行程序
先来看看源代码是如何变为可执行程序的
IDE是集成环境,很容易让初学者以为源代码直接被CPU执行了
其实不然
源代码必须经过编译器编译 才能成为二进制的可执行程序
IDE里面集成了 编译器 调试器 ,C语言的编译器 主要有GNU编译器套件中的GCC、Microsoft C 或称 MS C、Borland Turbo C 或称 Turbo C
编译过程是一个复杂的过程,接下来聊聊这个复杂的过程
编译是个过程的总称,其中还包括不同的阶段,源代码预处理阶段、编译优化阶段、汇编阶段、链接阶段
预处理器将对其中的伪指令(以# 开头的指令)和特殊符号进行处理,删除所有的注释,最后生成 .i文件
伪指令包括:
使用gcc命令可以输出.i文件
gcc -E helloWorld.cpp -o helloWorld.i
此时.i文件是删除了注释、宏替换、头文件也加载进来了,该文件比源代码文件大
内容太多,代码就不粘贴了,大家自行试验下
编译程序所要作的工作就是通过词法分析、语法分析、 语义分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码或汇编代码
词法分析和语法分析千万不要混淆了,校招面试的时候被面试官给绕了半天
词法分析器识别出Token,把字符串转换成一个个Token
Token包括关键字、标识符、字面量、操作符、界符等
为什么要这样做呢,把代码里的单词进行分类,编译器后面的阶段不就更好处理理解代码了嘛
语法分析阶段把Token串,转换成一个体现语法规则的树状数据结构,即抽象语法树AST
AST树反映了程序的语法结构
比如hello world代码经过语法分析之后会得到一个AST树
很多人疑惑为什么要把程序转换成AST这么一颗树呢?
因为编译器不像人能直接理解语句的含义,AST树更有结构性,后续阶段可以针对这颗树做各种分析
语义分析顾名思义就是理解语义,也就是理解程序要做什么
比如理解 "+" 符号是执行加法、"="号是执行赋值操作、"for"结构就是去执行循环等等
那到底怎么理解呢?
这个阶段要做的就是进行上下文分析,上下文分析包括引用消解、类型分析以及检查等等
引用消解:找到变量所在的作用域,一个变量作用范围属于全局还是局部作用域
类型识别:比如执行a=3,需要识别出变量a的类型,因为浮点数和整型执行不一样,要执行不同的运算方式
类型检查:比如 int b = 3,是否可以进行定义赋值,等号右边的表达式必须返回一个整型的数据或者能够自动转换成整型的数据,才能够对类型为整型的变量b进行赋值
经过语义分析后获得的信息(引用消解信息、类型信息),会在AST上进行标注,形成 带有标注的语法树,让编译器更好的理解程序的语义
在语法分析后有了程序的抽象语法树,在语义分析后有了 带有标注的AST 和符号表后,就可以深度优先遍历AST,并且一边遍历一边执行结点的语义规则
对于解释性语言整个遍历的过程就是执行代码的过程
解释性语言如Python 等,在遍历带有标注和符号表的抽象语法树即可开始执行
编译性语言需要生成目标代码,如C、C++
编译型语言需要生成目标代码,而解释性语言只需要解释器去执行语义就可以了
之前校招面试的时候,面试官看我把hello world讲的这么好,顺手问了句Java、Python 执行hello world的过程一样么?
当时愣了下,知道不一样 但是没解释的很清晰
对于不同架构的CPU,生成的汇编代码不同,如果优化是针对每一种汇编代码,那这个过程就相当复杂了
所以在生成目标代码之前增加一个过程,先生成一个 中间代码IR,统一优化后再生成目标代码
优化代码主要从分为本地优化、全局优化、过程间优化
本地优化:可用表达式分析、活跃性分析
全局优化:基于控制流图CFG作优化
过程间优化:跨越函数的优化,多个函数间作优化
说了一些干的,举个例子让大家理解下到底如何优化
活跃性分析就是将一些没有用到的代码删除,比如一些没有用到的变量
目标代码生成就是将优化后的IR代码翻译为汇编代码
翻译为汇编代码主要步骤是
编译阶段使用的指令
gcc -S helloWorld.cpp -o helloWorld.s
生成的汇编代码:
用的GCC版本信息如下
上面的编译阶段的生成的汇编代码还是人能看懂的,不是给机器直接执行的,机器执行的叫做机器码
机器码放在可执行文件中
unix环境中存在好几种目标文件:
不同的操作系统的可执行文件格式不同
汇编程序生成的实际上是第一种类型的目标文件,链接完成之后才能生成可执行文件
将汇编阶段生成的一个个的目标文件链接在一起生成可执行文件
其实很多人不理解为什么需要链接这个过程,明明汇编阶段已经生成目标代码
举个例子大家就明白了,日常做系统开发的时候,我们讲究系统功能模块化 现在都是微服务
一个复杂系统,往往会分成多个不同的子系统 子系统在拆分为不同的功能模块
链接的过程也和这个类似 一个复杂的软件需要拆分为多个不同的模块,每个模块独立编译
根据需要在 "组合" 起来,这个组装模块的过程就是 链接
比如main函数中调用了printf函数,mian函数在编译时并不知道printf函数的地址(每个模块都是单独编译的)
但是调用又必须知道函数地址才能发生调用关系
编译时暂时把这个地址搁置,链接时在进行地址修正
链接完成之后会形成一个可执行文件 ,可执行文件也叫ELF文件
这个ELF文件以及其他文件也够喝一壶,放在后面讲聊文件系统 一起聊
)装载就是把可执行程序加载到内存中,供后续的CPU执行
在linux命令行中我们经常这样执行一个可执行程序
./a.out
这样一下就把程序加载到内存中,加载完成之后直接执行了
其实你可以使用
strace ./a.out
这个命令可以看到所有的系统调用
可以看到 第一个执行的系统调用是 execve
通过 man execve 可以看到这个函数的描述
execve() executes the program pointed to by filename. filename must be either a binary executable, or a script starting with a line of the form:
? #! interpreter [optional-arg]
execve()执行文件指定的程序 文件必须是二进制可执行文件,或者执行一个以 shebang开头的脚本
Shebang 就是 #!
开头
通过查看Linux的execve源码如下
主要执行工作落在了 do_execve
上,继续看看 do_execve 源码
前面就是计算一些参数如argv、env 拷贝相关数据,最终装载程序执行search_binary_handler
list_for_each_entry
函数非常重要,这个函数遍历所有formats列表,找到当前系统合适的可装载格式
前面已经说过,linux 下可执行文件格式是ELF文件
retval = fmt->load_binary(bprm)
就是load可执行程序
load_binary是加载二进制文件啊,我们的程序明明是ELF文件
仔细看看load_binary的源码会发现里面有一个初始化,初始化的时候会做一个赋值替换为
或许到这里大家基本已经了解了,但还是疑惑怎么才能判断加载的ELF文件
可以去看看源码怎么写的 (源码太长,这里就不粘贴了 告诉你位置有兴趣的自己去看看)
源码位置:
有个函数叫 static int load_elf_binary(struct linux_binprm *bprm);
在 /fs/binfmt_elf.c Line 820
再看看我们的可执行程序头上长啥样 readelf -l a.out
即可查看可执行文件头部信息
解释器通过判断 Program Headers 中的 INTERP 的值得到该可执行程序的文件类型
我们的CPU执行程序的步骤是:
上面步骤是一个循环也称为CPU指令周期,CPU 的工作就是一个周期接着一个周期,周而复始。
更多关于CPU执行的问题,可以看看好朋友小林的 你不好奇 CPU 是如何执行任务的?
或者持续关注,后面我会更新关于CPU执行调度的文章
在Unix系统中,每个进程都会默认打开三种标准I/O 分别是STDIN、STDOUT和STDERR
printf源码
这只是第一次源码,愿意了解的可以看看vfprintf实现,你会发现底层使用了 缓冲输出
输出是一次output,也就是会经历一次从内存外部文件系统的数据转移
到这里基本就讲完了了hello world全部内容,讲完了不一定是讲透彻了
比如 关于文件系统的知识、IO知识、CPU调度知识、进程管理、内存管理等等知识都没法通过一篇文章说透彻
说实话一个小小的hello world藏着大学问,囊括的内容也实在是太丰富了
今天只是从整体上把控了一下,细节内容后面写操作系统会一一更新
我是龙叔,我们下期见
原文:https://www.cnblogs.com/zhonglongbo/p/14176636.html