这一节先写一个简单的汇编程序. 输出cpu的出产厂商. 不对语法, 寄存器等内容进行深入讨论, 只是整体上先有个认知印象.
简单来说, Linux下的可执行程序文件中, 最重要的三个部分是: 数据段
, 代码段
, bss段
. 关于可执行文件, 以及目标文件的内容构成, 其实这是一个十分复杂的话题, 这里不进行深入讨论, 你可以简单的理解为:
段(section)
组成. 每个可执行文件中存在多个段. 段是一种划分可执行二进制程序内容的手段
数据段
存储了程序运行期间的所有数据. 典型的有: 在C代码中定义且初始化了的全局变量, 函数内的静态变量.
代码段
中存储了程序运行期间的所有指令. 可以理解为你在C代码中所写的所有逻辑语句, 循环语句, 函数调用, 函数定义等, 都在这里
bss段
比较特殊. 对于一些未初始化的全局变量和函数内的静态变量, 这部分数据登记在bss段. 这里之所以用登记
这个说法, 是因为bss段
在可执行程序的文件中, 是不占用长度的. 即这个段占用的字节数其实是0
. 你可以这样理解: 这些根据语言标准, 初始值为0的变量,
不需要将它们存储在数据段中, 因为它们的值均是0, 所以简单的登记一下就行了, 程序运行起来后, 凡是登记在这里的变量, 统一给赋值为0即可.
上面的简单理解, 用于应付学习汇编其实并不够, 上面的理解, 其实也存在很多错误的地方. 如果有兴趣深究的话, 建议阅读 <程序员的自我修养: 链接, 装载与库>
这本书的的第三章: 目标文件里面有什么
. 其中详细讲解了ELF文件的组成, 这里就不展开篇幅讲解了.
既然现在我们简单的认为, 可执行二进制文件就由三部分构成, 那么反过来理解, 程序的编译链接其实就是把高级语言的代码, 转换成一个二进制文件的过程. 也就是说通过代码
, 来构造数据段
, 代码段
, 与bss段
的过程. 同样的道理其实也适用于汇编语言, 不同的,
高级语言对使用者完全封装了段
的概念, 但在汇编语言中, 所谓的编译器最主要的工作, 只是把汇编代码, 转换为机器指令
, 而关于如何分配, 定义段, 则是需要程序员自己手动负责.
所以从逻辑上来讲, 我们写的第一个程序需要做以下几件事:
未初始化的全局及静态变量
, 所以可以省略掉bss段
, 只需要声明且定义代码段
与数据段
即可
这台电脑的CPU生产厂商其实是xxxx
这种常量.
因特尔
三个字), 通过程序逻辑, 拼接到我们的字符串常量后面去
具体到实际实施上, 就需要了解汇编语言的一点基础用法, 包括:
.section
命令语句声明一个段.
.globl
命令用心定义一个全局符号
_start
标签所标示的代码点, 为程序入口点
上面的讲解肯定有很多你听不明白, 理解不了的东西, 不要紧, 我们先直接来看这个程序的全文
# 以下为调用显示函数的代码 # 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定
我们通过.section .data
, 声明了一个数据段. 这里需要注意的是, 在汇编程序中, 段
是没有所谓的类型的. 我们之所以把这个段叫数据段
, 并不是因为这个段的名字叫.data
, 也并不是因为它是程序中第一个段, 而是因为: 这个段中存储了我们需要使用的字符串常量,
即程序运行中所需要的数据.
也就是说, 所谓段的类型
, 其实是程序员出于对程序结构功能的划分, 人为制造出来的概念. 你可以声明一个叫.text
的段, 但里面存储的是数据, 也可以声明一个名为.data
的段, 里面写着指令. 更可以声明一个叫.fuckyou
的段, 里面你爱存什么存什么.
而我们一般情况下, 约定俗成的把数据段命名为.data
, 把代码段命名为.text
等等, 其实是因为: 这些约定俗成的段名, 是GNU编译器编译高级语言, 特别是C代码时, 对各个功能段的命名.
.ascii
声明了一个字符串, 这个字符串有42个字符, 其中从索引28开始, 至索引40结束, 即字符串中的12个x
, 是预留的空位, 用心在取到CPU厂商名字后, 把CPU厂商的名字写在其中.
通过单词+冒号
的方式, 可以在汇编程序中声明一个符号
. 这种写法有点类似于C语言中的label, 标签
. 你现在可以这样简单的理解符号
: 它就是一个变量, 或函数名!
所以, 用C的思维去看待数据段, 其实就做了一件事:
我们还通过_start:
, 声明了一个符号, 名为_start
, 目前我们可以简单的理解为, 这是一个函数, 名为_start
.
在_start:
以下, 都是汇编语句, 可以简单的理解为, 这就是函数的内部实现.
一个陌生的命令, 是.globl _start
. 即是.globl
命令. 目前你可以简单的理解为, 符号经过.globl
修饰后, 就是一个全局符号. 类比于C语言中的函数或变量, 所谓的全局符号就是: 全局变量, 以及非static函数
cpuid
是一个特殊的指令, 当你在汇编代码中使用cpuid
时, 其实是在向cpu询问: 你从哪来? 到哪去? 家里几头牛? 地里几亩地?
寄存器eax
中的值, 决定了你问的具体是哪一个问题. 所以我们在询问之前, 先把eax
寄存器的值设置为0
: 这其实是在问: 你是哪家厂商生产的?
将eax
设置为不同的值, 其实是在向cpu
提出不同的问题. 并且对于不同的问题, cpu回应问题的方式也不同. 但就询问厂商这个请求来说, cpu会将厂商的名字, 分别放在三个寄存器中去. 分别是:
ebx
寄存器, 32位, 4字节, 里面放着厂商名字的前四个字符
edx
寄存器, 里面放着厂商名字的中间的四个字符
ecx
寄存器, 里面放着厂商名字的后四个字符
即是, 在cpuid
这条指令执行时: cpu会去读取寄存器eax
的值, 以确认你的提问到底是什么内容. 我们在cpuid
这条指令之前, 赋值eax
寄存器的值为0
, 其实这是询问厂商名称的命令.
在cpuid
这条指令执行之后, cpu会将厂商的名称, 共12个字符, 切成三块, 分别放在三个寄存器中. 即下一步, 我们需要把三个寄存器中的内容, 挪到我们在数据段声明的字符串中, 并把其中的12个x
给替换掉.
cpuid
指令还可以询问很多其它内容, 有关这个指令的详情, 请参考x86指令参考文档
以下四个语句, 就是在执行这个操作
这里比较奇怪的是, 引入了一个名为edi
的寄存器. 这里先感性认识一下. 至于为什么要引入edi
, 以及这四条语句为什么要这样写, 后续章节再介绍
总之, 忽略掉细节, 这四条语句执行完毕之后, 我们定义在数据段中的字符串, 内容就会变成类似于下面这样:
在学习高级语言时, 我们向屏幕输出内容, 一般都是调用语言的类库接口, 比如C中的printf
, C++中的std::cout <<
等. 这些类似背后做了什么工作, 高级语言的使用者是不关心的.
所以这里, 我们需要先回想一下, 从硬件到软件的抽象层次, 我们以"在屏幕上显示内容"为例子, 在这个过程中, 如果你全C中的printf
来输出一串字符串, 其实要经过这么几个封装层级:
printf
函数在各个操作系统上都可用. 但在其背后, 对于不同的操作系统, printf
其实调用的是操作系统中输出内容的接口. 对于Linux系统来说, 在这一层, printf
函数, 调用的是write
系统调用, 即syscall
显存
. 操作系统将层层传递下来的字符串, 扔到内存空间中一块特定的区域, 也就是显存
中, 然后就不管了, 至于这个东西怎么显示, 那就是显示设备驱动程序的责任了. 驱动程序是连接硬件与软件的桥梁,
显示设备的驱动程序在读取显存的内容后, 将内容进行翻译, 翻译成电压, 电流, 扔给硬件.
以上的描述中, 有很多错误的地方, 对于程序员来说, 特别是上层程序员来说, 这样的认知和理解是无伤大雅的, 因为在上面的第三层, 所谓的我称为其为硬件驱动层级
上, 其实有很多复杂的事情. 但这对于上层程序员来说, 并不是必须要了解的细节.
现在回头来想, 当我们用C语言输出中时, 我们位于最高的层级, 我们直接调用printf
. 而当我们使用汇编语言时, 我们要输出一个字符串, 我们位于哪一层? 我们调用的是哪一层的接口呢?
严格的来说, 当使用汇编语言时, 并没有限定我们非得在某一层, 我们既可以调用libc
中的printf
函数: 是的, C语言中可以内联汇编, 当然汇编代码是可以调用C库的. 也可以位于操作系统层级, 我们可以通过特殊的方法调用write
系统调用.
我们更可以直接写显存(可能绕过操作系统的屏障要做一些额外的工作), 甚至于, 我们可以在硬件驱动程序中去完成这个任务: 硬件驱动程序也是由C和汇编编写的. 总之, 用汇编要完成"输出字符串"这项任务, 其实只要位于软件层面上, 都可以, 无非就是每一层的实现难度不一样而已.
而我们学习汇编的目的, 不是进行驱动开发, 也不是为了研究操作系统的实现, 而是通过学习汇编
换句话说, 我们学习汇编, 脚下踏着的还是操作系统. 我们并不是要用汇编日天日地, 而是使用汇编, 在操作系统的肩膀上, 做高级语言很难做到的细致活. 再换个说法, 其实就是用汇编去写应用程序, 我们应用汇编的层次, 和C语言的层次是一样的. 所以, 回到输出串的话题上, 最适合我们的方法是: 调用操作系统的接口, 即write
系统调用.
所以在示例程序中, 我们这样写:
# 以下为调用显示函数的代码
# 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定
在这里你可以这样简单的理解:
Linux操作系统本身提供了很多系统调用.
系统调用类似于函数调用. 在系统调用之前, 需要将要调用的命令号, 以及调用所需要的参数, 填写在各个寄存器中
Linux操作系统
看的. 操作系统被软中断后, 会查看相应的寄存器, 以确认用户到底想干嘛, 然后给出回应. 在这个过程中, 当操作系统被int $0x80
中断后, 操作系统会跳转执行内核中的一些代码去完成用户的请求. 在整个过程中, cpu
并不知道中断前后发生了什么, 它只是机械的执行指令,
而这个指令是用户的汇编代码里写的, 还是受软中断而执行的操作系统内核代码, cpu是不知情的.
所以上面的五行语句, 其实就做了两件事:
write
系统调用所需要的所有参数, 写在各个寄存器中
int $0x80
, 触发软中断, 将控制权交接给操作系统, 由操作系统内核代码接管cpu. 完成内容输出.
怎么样? 是不是像极了一次高级语言中的函数调用? 是的, 就是这样, 汇编语言也是这样. 没有什么复杂的.
而至于write
系统调用背后发生了哪些故事, 如何写显存, 显示设备驱动程序如何工作, 显示器如何点亮像素, 和我们就没什么关系了, 我们也不关心.
Linux提供了数量众多的系统调用, 截止目前, 已经有300多个, 关于Linux系统调用的参考文档, 可以参考, 在这个页面, 可以查询到一个系统调用, 名为sys_write
, 即是我们上面说的write
系统调用.
在C语言中, 有一个很有意思的函数, 叫exit
, 而我们写的汇编程序, 要优雅的退出, 也需要做类似的事情. 这个系统调用在, 名称叫sys_exit
, 即是我们这个示例程序最后两行做的事情:
"汇编语言"本身, 是一个范畴很大的概念, 多数科班出身的程序员, 大多都读过这样的一本书: , 有很多高校甚至在本科学习阶段, 将本书列为汇编语言的教学教材, 这本书讲的汇编的目的是什么呢? 其实和我们的目的是完全不同的. 这本书的教学目的在我看来, 主要是:
这样的教学目的, 有一个很大的盲点就是: 学完这本书之后, 你几乎还是什么有用的东西都做不出来! 它对于科班学生计算机思维的培养很有用, 但对于实际工作应用, 基本作用为0
而我们学习汇编的目的是什么呢? 我们学习汇编的目的很功利:
典型的汇编在工程上的应用, 就是C/C++中的协程库, 而我厂的更是一个标杆. 这样的汇编才是有用的.libco
库中, 最核心的协程切换, 寥寥不到100行汇编, 就是这100行汇编, 撑起了微信后台开发的核心. 这样的汇编, 才是有用的.
注意, 我不是在批王爽这本书没卵用, 并不是. 王爽的这本书写的非常好, 十分好, 只是王爽老师写的这本书, 不适用于我们这种"功利的目的".
所以, 我们学习汇编有以下几个点需要注意:
binutils
工具链的服务对象.
这样学习汇编, 需要先行了解编译, 链接的一些基础知识, 所以, 建议大家有空看看这一本书: , 特别是书中的第三章.
下表描述了贯穿于本文中的一些概念:
是“虚拟地址”而不是“物理地址”。为什么不是“物理地址”呢?因为数据在内存的位置经常在变,这样可以节省内存开支、避开错误的内存位置等的优势。同时用户并不需要知道具体的“真实地址”,因为系统自己会为程序准备好内存空间的(只要内存足够大) |
包含以EXE文件为代表的“可执行文件”、以DLL文件为代表的“动态链接库”。为什么用“镜像”?这是因为他们常常被直接“复制”到内存,有“镜像”的某种意思。看来西方人挺有想象力的哦^0^ |
英文全称Relatively Virtual Address。偏移(又称“相对虚拟地址”)。相对镜像基址的偏移。 |
节是PE文件中代码或数据的基本单元。原则上讲,节只分为“代码节”和“数据节”。 |
x86都是32位的,IA-64都是64位的。64位Windows需要做的只是修改PE格式的少数几个域。这种新的格式被称为PE32+。它并没有增加任何新域,仅从PE格式中删除了一个域。其余的改变就是简单地把某些域从32位扩展到64位。在大部分情况下,你都能写出同时适用于32位和64位PE文件的代码。
EXE文件与DLL文件的区别完全是语义上的。它们使用的是相同的PE格式。惟一的不同在于一个位,这个位用来指示文件应该作为EXE还是DLL。甚至DLL文件的扩展名也完全也是人为的。你可以给DLL一个完全不同的扩展名,例如.OCX控件和控制面板小程序(.CPL)都是DLL。
PE文件总体上分为“头”和“节”。“头”是“节”的描述、简化、说明,“节”是“头”的具体化。
PE文件的头分为DOS头、NT头、节头。注意,这是本人的分法,在此之前并没有这种分法。这样分法会更加合理,更易理解。因为这三个部分正好构成SizeOfHeaders所指的范围,所以将它们合为“头”。这里的3个头与别的文章的头的定义会有所区别。
节头紧跟在NT头后面。
阅读《30天自制操作系统》—川合秀实[ – 03.16],[03.27整理]笔记。
将《30天自制操作系统》简称为“书”。对于书中的工具,可以专门对其笔记。
软盘的第一个扇区之所以有那么多死板的规定,是因为软盘的第一个扇区有特殊的作用:计算机首先从磁头0面最初一个扇区开始读软盘,然后检查这个扇区最后2个字节的内容。如果这最后2个字节不是55AAH,计算机会认为这张盘上没有所需的启动程序,就会报一个不能启动的错误;如果计算机确认了第一个扇区的最后2个字节正好是55AAH,那它就认为这个扇区的开头是启动程序,并开始执行这个程序。故而软盘的第一个扇区被称为启动区(boot
计算机(BIOS)只会读软盘的第一个扇区的内容到内存,软盘其它扇区的内容靠执行第一个扇区内的程序载入。
IPL(Initial program loader,启动程序加载器)是软盘启动区内的程序,这个程序的功能为加载软盘各个扇区的内容到内存中。在“”中,第一个扇区内的二进制程序的作用是显示字符串“Hello World”,并没有加载软盘扇区内容到内存的功能。像这样的软盘的第一个扇区可以不叫“启动区”,其内的程序也可不叫“IPL”,分别叫它们“Hello
制作IPL时需要遵循2点:
[1] BIOS 13h对启动区的格式要求(非程序运行所必须的东西);
[2] 在启动区内的代码要有载入其它扇区内容到内存的功能。
磁头0和磁头1所在面的磁道号相同的两个磁道被称为一个柱面。软盘内用于存储二进制信息的(黑色)介质被称为磁盘。
启动区的内容被加载到内存的0x7c00~ 0x7dff,就让软盘后续区内容跟启动区内容相接,将后续内容拷贝到0xdff。
;FAT12格式软盘启动区规定内容(非必须) DB "HARIBOTE" ;启动区的名称,可以是任意8字节的字符串 DW 1 ;FAT的起始位置(一般从第一个扇区开始) DW 224 ;根目录的大小(一般设成224) DW 2880 ;读磁盘(软盘内存数据的介质)的大小(必须是2880扇区) DW 2 ;磁头个数(必是2) DD 0 ;不使用分区,必是0 ;程序主体:读磁盘内容到内存 MOV AX,0 ;根据程序被加载的地址初始化寄存器 ;读磁盘内容到内存的程序 ADD SI,1 ;记录操作磁盘失败的次数 ;加载磁盘内容后、CPU执行完新任务后进入休眠状态[1] ipl10.nas功能:将软盘前10个柱面(没有读启动区)的内容装载到内存地址空间0x0fff中(从0x8000处开始装载不能被正确执行,估计是因为0x8000处有F0H FFH FFH 3字节内容)。()
[2] “读磁盘内容到内存”部分以磁盘的“磁头”、“柱面”、“扇区”作为“最外层”、“次外层”、“内层”循环的条件,在读完磁头0上柱面的一面时就切换到磁头1读柱面的另一面。
[3] ipl10.nas用si寄存器来记录读一个扇区不成功的次数,如果读一个扇区读5次都读不成功则放弃这次的程序加载操作。
如果此时将ipl10.nas下载到软盘中并运行,IPL会将软盘前10个柱面的内容拷贝到内存地址空间0x0fff中。虽然如果拷贝其它扇区内容不成功时会有“load error”的错误提示,但笔记还想执行一下IPL拷贝的程序。
需要保证源程序中的各偏移值跟程序在内存中的各偏移值一致,否则需要在用ORG伪指令。见“”的ORG部分。
;因为以下这段程序将会被加载到0x8200处 ;后续扇区内容,从偏移0x8200开始 HLT ;省电:让CPU进入休眠状态,当复位信号或者中断来临时, JMP sleep ;CPU再执行HLT的下一条指令,或者转去执行中断程序, ;当中断处理完后又执行一次HLT,CPU将再次进入休眠状态只要IPL正确的将软盘10个柱面的内容加载到内存0x0fff后,就应该跳到0x08200处执行拷贝的代码,最后让CPU进入休眠。如果IPL拷贝失败,则显示load error并让CPU休眠。
将这段程序和ipl10.nas结合在一起:
;FAT12格式软盘启动区规定内容(非必须) DB "HARIBOTE" ;启动区的名称,可以是任意8字节的字符串 DW 1 ;FAT的起始位置(一般从第一个扇区开始) DW 224 ;根目录的大小(一般设成224) DW 2880 ;读磁盘(软盘内存数据的介质)的大小(必须是2880扇区) DW 2 ;磁头个数(必是2) DD 0 ;不使用分区,必是0 ;程序主体:读磁盘内容到内存 MOV AX,0 ;根据程序被加载的地址初始化寄存器 ;读磁盘内容到内存的程序 ADD SI,1 ;记录操作磁盘失败的次数 ;加载磁盘内容后、CPU执行完新任务后进入休眠状态 ;因为以下这段程序将会被加载到0x8200处 ;后续扇区内容,从偏移0x8200开始 HLT ;省电:让CPU进入休眠状态,当复位信号或者中断来临时, JMP sleep ;CPU再执行HLT的下一条指令,或者转去执行中断程序, ;当中断处理完后又执行一次HLT,CPU将再次进入休眠状态打开“!cons_ne.bat”,用nask.exe依据2.3中结合的程序输出helloos.img,再运行“install.bat”命令将helloos.img下载到软盘上。再将软盘插入另一台相同计算机中,以软盘方式启动,得到如下结果:
Figure1. IPL加载其他扇区程序后再执行其它扇区内容的结果
我重新运行了一遍,可以显示“hello world”。
world"字符串(但不知道此笔记中有无拼写错误,不必太纠结结果哈)。 |
[1] 计算机(BIOS)将软盘启动区程序读到0x7c00~ 0x7dff,为了保证内存中的指令参数和编译器编译后的指令参数一直,需要在汇编程序中使用ORG0x7c00语句;IPL将启动区的程序加载到0xff;IPL读启动区后一个扇区的内容到内存0xff;以此类推… (装载启动区程序到内存地址空间0x7c00~ 0x7dff是计算机(BIOS)规定;从0x8000 内存地址空间存软盘程序是开发操作系统人的规定,因为这段内存没有被其它程序占用)。
[2] 从计算机开机到操作系统的启动,CPU的控制权的操纵者为:FFFF0H(实模式)-->BIOS(实模式)-->(操作系统)程序。