本文主要是梳理机器指令、汇编指令、系统调用(OS Kernel)、语言库、应用程序和OS Shell的层次关系。
一、概述
首先看一下软硬件间的关系,如图所示:
二、硬件 — CPU指令集
一般指令集专利持有者在设计指令集的时候,往往提供指令集对应的机器语言规范。 而为了方便,一般也会提供汇编语言规范。
(注意:CPU微架构设计厂商可能会对指令集进行微调。关于指令集设计者、微架构设计者两者的关系可以参考指令集、微架构、手机芯片(Soc)及ARM的介绍(偏硬件科普))
汇编语言(assembly language)是一种用于电子计算机、微处理器、微控制器或其他可编程器件的低级语言,亦称为符号语言。在汇编语言中,用
助记符
代替机器指令的操作码,用地址符号或标号
代替指令或操作数的地址。汇编语言又被称为第二代计算机语言。
汇编语言产生的原因:
对于绝大多数人来说,二进制程序是不可读的,当然有能人可以读,比如第一代程序员,但这类人快灭绝了,直接看二进制不容易看出来究竟做了什么事情,比如最简单的加法指令二进制表示为 00000011,如果它混在一大串01字符串中就很难把它找出来,所以汇编语言主要就是为了解决二进制编码的可读性及编写效率问题(CPU指令集机器码形式记不住呀…)。
即指令集有两种表示形式:机器码形式、汇编语言形式。比如 00000011 加法指令,对应的汇编指令是 ADD,在调用汇编器时就会把 ADD 翻译成 00000011。
常见的指令集以及汇编语言规范:
- x86(IA-32)、x86-64指令集(常见于PC端),对应有2家公司发布的不同汇编语言规范:
- intel公司发布的汇编语言规范,称
intel 汇编
:Windows派系(Microsoft),比较著名的汇编器有微软的masm和开源的nasm。 - AT&T公司发布的汇编语言规范,称
AT&T 汇编
:Unix派系(或者说GNU),比如g++编译器等。
- intel公司发布的汇编语言规范,称
- ARM指令集(常见于嵌入式、移动端设备,粗略统计覆盖95%左右的手段):ARM公司发布的汇编语言规范,称ARM 汇编(目前常见的是ARM 64汇编),见官网文档
汇编语言规范,是给汇编程序开发者看的,也是给编译器(主要是汇编器)看的,目的只有一个:保证汇编程序能通过汇编器转换成CPU兼容执行、实现逻辑功能的指令(二进制码)序列。如果你能编写自己的汇编器,完全可以定义自己的汇编语言规范
所以,汇编语言和机器语言是一一对应的吗?
在同一汇编规范下,它们是一一对应的。如果考虑到不同的汇编语言规范,它们就不是一一对应的了。在多数场合,笼统地说,汇编语言和机器语言是一一对应的(比如 00000011 就是 ADD)。所以汇编语言就和机器语言一样,很受硬件限制,移植性很差。
高级语言在执行时,需要 → 编译器翻译为汇编语言 → 汇编器翻译为机器语言,此时,才能够被CPU识别并执行。指令集直接操作硬件。
通常是先指定使用的指令集,然后去设计微架构(处理器硬件结构)。
三、操作系统
操作系统分为内核(Kernel)、壳层(Shell)
3.1 内核(Kernel)与系统调用
3.1.1 内核概述
内核(Kernel,又称核心)在计算机科学中是一个用来管理软件发出的数据I/O(输入与输出)要求的电脑程序,是现代操作系统中最基本的部分。
它是为众多应用程序提供对计算机硬件的安全访问的一部分软件,这种访问是有限的,并由内核决定一个程序在什么时候对某部分硬件操作多长时间。直接对硬件操作是非常复杂的。所以内核通常提供一种硬件抽象的方法,来完成这些操作。
总结:内核对硬件的操作进行封装,向上层(应用、Shell)提供接口,使上层使用硬件更方便,会对上层的硬件资源申请统一管理,当然也就意味上层的使用会受到一些限制。
严格地说,内核并不是计算机系统中必要的组成部分。有些程序可以直接地被调入计算机中执行;这样的设计,说明了设计者不希望提供任何硬件抽象和操作系统的支持;它常见于早期计算机系统的设计中。
但随着电脑技术的发展,最终,一些辅助性程序,例如程序加载器和调试器,被设计到机器内核当中,或者写入在只读记忆体里。这些变化发生时,操作系统内核的概念就渐渐明晰起来了。
3.1.2 系统调用
内核提供的接口称为系统调用,指运行在用户空间的程序访问操作系统内核所提供服务的接口(内核服务意味着更高的权限)。系统调用是代码方式使用,也就表示了用户(非编程人员)不能直接与内核进行交互。
系统调用是基于CPU指令集的,进行封装、扩展(比如扩展中断向量,实现一系列的系统调用)。类似于语言库中的API对系统调用的封装。
调用系统调用的两种方式:以Linux为例(其他系统也是类似),在 Linux 平台下有两种方式来使用系统调用:
- 利用封装后的 C 库(libc)。Linux、Windows、iOS等许多系统内核大部分都是C语言编写的,其中也有少部分的C++、C#等语言,所以大部分的系统调用通常是C、C++语言格式的
- 通过汇编直接调用。通过汇编语言来直接调用系统调用,是最高效地使用 Linux 内核服务的方法,因为最终生成的程序不需要与任何库进行链接,而是直接和内核通信。
- 第一种方式是对第二种方式的封装。如DOS、Linux 的系统调用都是通过中断汇编指令(int 0x80)来实现的。(见下面—系统调用的实现)
3.1.3 系统调用的实现
系统调用接口在实现中往往以软件中断(Software interrupt),简称INT(软中断)的方式提供,比如:
- Linux 使用
INT 0x80
(INT是汇编指令,0x80为参数,表示80号中断)作为系统调用接口。 - Windows使用 0x2E 号中断作为系统调用接口(从Windows XP Sp2开始,Windows开始采用一种新的系统调用方式)。
以Linux为例,在进行系统调用的时候:
- 发起int 0x80中断,并传入系统调用号。系统调用号:有一个
sys_call_table
是一个全局函数数组,存储所有系统调用的地址(可以查看include/linux/sys.h
文件),系统调用号是系统调用在该数组中的下标。 - 调用
set_system_gate
函数处理中断,首先从当前的用户态切换到内核态,然后找到中断向量表,根据中断编号(0x80),找到对应的中断处理程序 - 找到中断处理程序
system_call
(在linux/kernel/system_call.s
中,是汇编程序),根据系统调用号在sys_call_table
表中找到相应系统调用对应的函数入口。 - 调用
call汇编指令
,输入函数地址。
3.1.4 关于中断向量的一些补充
计算机启动时,引导扇区、系统内核会陆续的将一些中断填入中断向量表,并将中断向量指向自己编写的中断服务程序。
如操作系统启动过程的最后,由head.s
进入main.c
中,会进行一系列的初始化:内存、设备、时钟、中断等。其中:
trap_init()
函数,会初始化一些中断向量sched_init()
函数中会调用set_system_gate(0x80,&system_call)
,设置中断向量号0x80的中断描述符
系统也会为用户保留一些中断向量,用户可以将自己的中断服务程序写入这些中断向量中。不仅如此,用户还可以自己更改和完善系统已有的中断向量。相当于给INT指令增加参数(中断向量号)选项,且指定对应的调用程序。
引导扇区(实模式) → bootloader(实模式 → 保护模式,R0层权限) → 操作系统(保护模式,R0层权限) → 操作系统先加载的是内核,内核处于R0,shell已经是在用户态了
3.2 壳层(Shell)
操作系统存在的目的是方便用户的使用,而内核提供的代码形式的系统调用,是面向编程开发者的,所以需要一个壳层:在计算机科学中指“为用户提供用户界面”的软件,或者说是指操作系统中提供访问内核所提供之服务的程序
Shell又分为了CLI Shell(命令行)、GUI Shell(图形化)两种形式,用户都可以用来使用操作系统。区别:
- 样式区别:前者的页面是一行行的文字命令,后者是图形状的
- 程序(APP、命令行工具)的使用:前者需要用户记住每个程序的路径,然后输入特定的命令来调用对应的功能。后者会将所有的APP以图标的形式显示在桌面上,通过点击就可使用
- 本质:本质上没区别,都是对系统调用的封装,功能一定程度上都是共通的
3.3 目前的操作系统为例
对上面的知识点进行总结,以目前的操作系统为例:
针对不同的角色,操作系统提供了不同的用户界面(不同的操作系统提供的用户界面也不同)
- 对普通用户和管理员用户提供命令控制界面(也就是
Shell
):是一组不同操作命令组成的集合,每个命令实现用户所要求的不同功能,为用户提供相应的服务。- 包括GUI图形化界面、CLI命令行界面
- 用户利用这些操作命令来组织和控制作业的执行。
- 对编程人员提供编程界面:是一组系统调用的集合,这些系统调用允许编程人员请求操作系统内核提供的服务,开发能够满足用户服务需求的新的控制命令。
- 命令控制界面是基于编程界面,也就是系统调用之上开发完成的。
3.4 ABI与API
操作系统相关的有两类接口(意思并不是说只有OS才有ABI、API):
- API应用程序接口:源代码层次的接口,即操作系统提供了哪些系统调用(一般是C/C++编写),正确调用OS提供的API,才能被成功编译
- ABI应用二进制接口:机器码层次的接口,按照系统ABI编写(或转换成)的机器码文件(目标文件、可执行文件),才能成功与OS提供的库(也是目标文件,机器码)链接,然后成功被OS装载运行。
3.4.1 API
API(Application Programming Interface)
应用程序接口:定义了源代码和库之间的接口(函数名、参数、返回值、数据类型定义等),即规定源代码可以怎么使用库的功能,使得一套 源代码
可以在支持这个API的任何系统中 编译
。
3.4.2 ABI
ABI(Application Binary Interface)
应用二进制接口:是指两程序模块间的接口,通常其中一个程序模块会是库或操作系统所提供的服务,而另一边的模块则是用户所运行的程序(前面已经知道程序主模块、动态链接库都叫程序模块)。
规定了机器代码的书写格式(二进制应用程序应该怎么调用CPU指令集中的指令),使得一套编译好的二进制代码(目标文件、可执行文件)可以在兼容ABI的系统中,无需任何修改直接运行。
- 决定要不要采取既定的ABI(不论是否由官方提供),通常由编译器,操作系统或库的开发者来决定
ABI涵盖了各种细节,如:
- 数据类型的大小、布局和对齐;
- 调用约定(控制着函数的参数如何传送以及如何接受返回值),例如:
- 是所有的参数都通过栈传递,还是部分参数通过寄存器传递;
- 哪个寄存器用于哪个函数参数;
- 通过栈传递的第一个函数参数是最先push到栈上还是最后;
- 系统调用的编码和一个应用如何向操作系统进行系统调用;
- 以及在一个完整的操作系统ABI中,目标文件的二进制格式、程序库等等。
3.4.3 两者对比
- 定义层级
- API在源代码定义这些,则较为高端,并不直接相依于硬件,通常会是人类可阅读的代码。
- ABI在二进制代码(目标文件、可执行文件)层次定义了机器代码怎么写,此处所定义的界面相当低端并且相依于硬件。
- 标准化方面
- POSIX 标准、C99 标准,都是对 API 的规定。
- 有一些努力尝试标准化ABI,以减少销售商将程序移植到其他系统时所需的工作。然而,直到现在还没有很成功的例子,虽然Linux标准化工作组正在为Linux做这方面的努力。
四、语言标准库
编程语言的标准库是该语言的每种实现中都按例提供的库。在某些情况下,编程语言规格说明中会直接提及该库;另一些情况下,标准库的内容由编程社区中的非正式惯例决定。
根据宿主语言构成要素的不同,标准库可包含如下要素:
- 子程序
- 宏定义
- 全局变量
- 类别定义
- 模板
大多数标准库都至少含有如下常用组件的定义:
- 算法(例如排序算法)
- 数据结构(例如 表、 树、哈希表)
- 与宿主平台的交互,包括输入输出和操作系统调用
比如我们经典的C语言版“hello world”程序,使用C语言标准库的“printf”函数来输出一个字符串,“printf”函数对字符串进行一些必要的处理以后,最后会调用操作系统提供的系统调用。
各个操作系统,往终端输出字符串的API都不一样,在Linux下,它是一个 “write”
的系统调用,而在Windows下则是 “WriteConsole”
系统API。此外,还带有很多一些常用的函数。
Q1:如Java、Object-C语言库可不可以不调用C、C++语言格式的系统调用(API)?
当然可以,上文讲过,操作系统一般都提供了两种方式来使用系统调用:C语言库、汇编语言。系统调用对Linux、DOS来说只是int 0x80(中断)而已,所以不管语言库是什么语言编写的,(在Linux上运行的软件代码)只要能通过编译器翻译成对应Linux下的int 0x80的汇编语言就行了。
但是注意:封装高级语言格式的系统调用、设置0x80中断及对应的中断处理程序实现系统调用,都是操作系统提供的功能。
而且操作系统的系统调用实现是一定的,两种方式(其实是一种,C库本质上也是封装的汇编中断指令)最后找到的系统调用函数的实现(函数入口地址)都是一样的…
其实,编程人员开发时,用什么语言来调用系统调用是无所谓的,因为运行的时候,都需要编译、汇编成机器代码。从硬件角度来看(即最后的可执行文件),区别不大的(高级语言调用、汇编语言调用最后生成的机器码还是有些区别的)。
Q2:既然可以直接写汇编语言,而汇编语言又是直接对应CPU指令,即可以直接操作硬件资源,那为什么不能越过操作系统直接使用硬件?
不能。主语不要弄错,并不是汇编语言为什么不能操控硬件,是用户程序不能越过操作系统操控硬件。
核心问题:用户程序和系统调用的权限问题。
系统调用是操作系统内核的代码,拥有最高的权限。以x86架构为例,x86指令运行的权限是从Ring0到Ring3的,操作系统内核运行在R0,用户程序运行在R3。
有一些指令只能在R0执行,比如修改CR寄存器(mov cr0, eax),这种操作在用户态直接会报错。因为CPU会保存当前运行的代码的CPL(当前特权级别)和IOPL(IO权限),如果CPL/IOPL不符,CPU会抛出异常,丢给内核R0的代码去处理。这种错误,是执行到某条特定指令才会遇到的,不是加载的时候遇到的,是CPU行为,不是操作系统行为。
操作系统之所以不会被限制,是因为操作系统内核运行在R0上,对CPU有完整的控制权。
计算机刚启动时,属于实模式,从实模式切换到保护模式的过程中,默认是进入到R0里的,所以操作系统在启动的过程中,是自动获得了R0的权限的。而用户代码都是被操作系统启动的(装载、链接),此时操作系统能控制用户代码运行在什么级别上。
那么为什么操作系统的代码就可以切换特权级而用户的代码却不可以切换特权级?
实际上可以的。Windows可以加载用户驱动到内核(但受到一些限制),这就是把用户代码放到R0里执行的过程,Linux也有类似的东西存在。
五、编程开发的应用程序
内核 → 系统调用 → Shell
内核 → 系统调用 → 编程语言标准库 → 编程人员开发的应用程序
以我们日常使用的手机系统为例,应用程序与GUI Shell的关系其实并没有谁基于谁的关系,Shell本质上也是一个应用程序。都是依赖于内核提供的系统调用开发、运行的。只不过功能不同而已:
- 应用程序是编程人员为了实现某些功能(比如看书、听音乐等)开发的
- GUI Shell,它的功能是以图形化的方式管理所有的应用入口(并内置了一些应用程序比如文件管理、设置之类的),并负责将用户的一些交互行为转换为控制命令。
- 比如用户点击APP图标,打开APP,本质上是GUI Shell帮忙调用了系统调用:
fork()
创建进程 →execve()
执行指定的可执行文件等
- 比如用户点击APP图标,打开APP,本质上是GUI Shell帮忙调用了系统调用: