为什么抱:非汉字字符串偏移代码偏移‘cpu’,怎么解决啊?

这一节先写一个简单的汇编程序. 输出cpu的出产厂商. 不对语法, 寄存器等内容进行深入讨论, 只是整体上先有个认知印象.

简单来说, Linux下的可执行程序文件中, 最重要的三个部分是: 数据段, 代码段, bss段. 关于可执行文件, 以及目标文件的内容构成, 其实这是一个十分复杂的话题, 这里不进行深入讨论, 你可以简单的理解为:

  1. 可执行文件由段(section)组成. 每个可执行文件中存在多个段. 段是一种划分可执行二进制程序内容的手段
    1. 数据段存储了程序运行期间的所有数据. 典型的有: 在C代码中定义且初始化了的全局变量, 函数内的静态变量.
    2. 代码段中存储了程序运行期间的所有指令. 可以理解为你在C代码中所写的所有逻辑语句, 循环语句, 函数调用, 函数定义等, 都在这里
    3. bss段比较特殊. 对于一些未初始化的全局变量和函数内的静态变量, 这部分数据登记在bss段. 这里之所以用登记这个说法, 是因为bss段在可执行程序的文件中, 是不占用长度的. 即这个段占用的字节数其实是0. 你可以这样理解: 这些根据语言标准, 初始值为0的变量, 不需要将它们存储在数据段中, 因为它们的值均是0, 所以简单的登记一下就行了, 程序运行起来后, 凡是登记在这里的变量, 统一给赋值为0即可.

上面的简单理解, 用于应付学习汇编其实并不够, 上面的理解, 其实也存在很多错误的地方. 如果有兴趣深究的话, 建议阅读 <程序员的自我修养: 链接, 装载与库> 这本书的的第三章: 目标文件里面有什么. 其中详细讲解了ELF文件的组成, 这里就不展开篇幅讲解了.

既然现在我们简单的认为, 可执行二进制文件就由三部分构成, 那么反过来理解, 程序的编译链接其实就是把高级语言的代码, 转换成一个二进制文件的过程. 也就是说通过代码, 来构造数据段, 代码段, 与bss段的过程. 同样的道理其实也适用于汇编语言, 不同的, 高级语言对使用者完全封装了的概念, 但在汇编语言中, 所谓的编译器最主要的工作, 只是把汇编代码, 转换为机器指令, 而关于如何分配, 定义段, 则是需要程序员自己手动负责.

所以从逻辑上来讲, 我们写的第一个程序需要做以下几件事:

  1. 定义必要的段. 鉴于程序比较简单, 我们应该不需要声明类似于C语言中的未初始化的全局及静态变量, 所以可以省略掉bss段, 只需要声明且定义代码段数据段即可
  2. 在数据段中写上程序运行所需要的数据. 我们需要输出cpu的制造厂商, 最起码需要一个字符串常量, 类似于这台电脑的CPU生产厂商其实是xxxx这种常量.
  3. 在代码段中写上程序运行的逻辑, 这些逻辑通过汇编语言书写, 最终会被转换成cpu执行指令. 这里的逻辑包括两部分
    1. 通过某种手段, 询问CPU它的生产厂商是什么, 并得到一个字符串的回答
    2. 把生产厂商(字符串, 比如因特尔三个字), 通过程序逻辑, 拼接到我们的字符串常量后面去
    3. 最终把拼接完成的字符串, 通过某种手段, 输出到屏幕上去

具体到实际实施上, 就需要了解汇编语言的一点基础用法, 包括:

  1. 定义段的语法: GNU汇编使用.section命令语句声明一个段.
  2. 定义全局符号: .globl命令用心定义一个全局符号
  3. 定义程序运行的起始点: GNU汇编中, 默认以_start标签所标示的代码点, 为程序入口点

上面的讲解肯定有很多你听不明白, 理解不了的东西, 不要紧, 我们先直接来看这个程序的全文

# 以下为调用显示函数的代码 # 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定

2.1 程序中的数据段

我们通过.section .data, 声明了一个数据段. 这里需要注意的是, 在汇编程序中, 是没有所谓的类型的. 我们之所以把这个段叫数据段, 并不是因为这个段的名字叫.data, 也并不是因为它是程序中第一个段, 而是因为: 这个段中存储了我们需要使用的字符串常量, 即程序运行中所需要的数据.

也就是说, 所谓段的类型, 其实是程序员出于对程序结构功能的划分, 人为制造出来的概念. 你可以声明一个叫.text的段, 但里面存储的是数据, 也可以声明一个名为.data的段, 里面写着指令. 更可以声明一个叫.fuckyou的段, 里面你爱存什么存什么.

而我们一般情况下, 约定俗成的把数据段命名为.data, 把代码段命名为.text等等, 其实是因为: 这些约定俗成的段名, 是GNU编译器编译高级语言, 特别是C代码时, 对各个功能段的命名.

  1. 通过.ascii声明了一个字符串, 这个字符串有42个字符, 其中从索引28开始, 至索引40结束, 即字符串中的12个x, 是预留的空位, 用心在取到CPU厂商名字后, 把CPU厂商的名字写在其中.

通过单词+冒号的方式, 可以在汇编程序中声明一个符号. 这种写法有点类似于C语言中的label, 标签. 你现在可以这样简单的理解符号: 它就是一个变量, 或函数名!

所以, 用C的思维去看待数据段, 其实就做了一件事:

2.2 程序中的代码段

我们还通过_start:, 声明了一个符号, 名为_start, 目前我们可以简单的理解为, 这是一个函数, 名为_start.

_start:以下, 都是汇编语句, 可以简单的理解为, 这就是函数的内部实现.

一个陌生的命令, 是.globl _start. 即是.globl命令. 目前你可以简单的理解为, 符号经过.globl修饰后, 就是一个全局符号. 类比于C语言中的函数或变量, 所谓的全局符号就是: 全局变量, 以及非static函数

cpuid是一个特殊的指令, 当你在汇编代码中使用cpuid时, 其实是在向cpu询问: 你从哪来? 到哪去? 家里几头牛? 地里几亩地?

寄存器eax中的值, 决定了你问的具体是哪一个问题. 所以我们在询问之前, 先把eax寄存器的值设置为0: 这其实是在问: 你是哪家厂商生产的?

eax设置为不同的值, 其实是在向cpu提出不同的问题. 并且对于不同的问题, cpu回应问题的方式也不同. 但就询问厂商这个请求来说, cpu会将厂商的名字, 分别放在三个寄存器中去. 分别是:

  1. ebx寄存器, 32位, 4字节, 里面放着厂商名字的前四个字符
  2. edx寄存器, 里面放着厂商名字的中间的四个字符
  3. ecx寄存器, 里面放着厂商名字的后四个字符

即是, 在cpuid这条指令执行时: cpu会去读取寄存器eax的值, 以确认你的提问到底是什么内容. 我们在cpuid这条指令之前, 赋值eax寄存器的值为0, 其实这是询问厂商名称的命令.

cpuid这条指令执行之后, cpu会将厂商的名称, 共12个字符, 切成三块, 分别放在三个寄存器中. 即下一步, 我们需要把三个寄存器中的内容, 挪到我们在数据段声明的字符串中, 并把其中的12个x给替换掉.

cpuid指令还可以询问很多其它内容, 有关这个指令的详情, 请参考x86指令参考文档

以下四个语句, 就是在执行这个操作

这里比较奇怪的是, 引入了一个名为edi的寄存器. 这里先感性认识一下. 至于为什么要引入edi, 以及这四条语句为什么要这样写, 后续章节再介绍

总之, 忽略掉细节, 这四条语句执行完毕之后, 我们定义在数据段中的字符串, 内容就会变成类似于下面这样:

2.2.3 将字符串输出到屏幕上

在学习高级语言时, 我们向屏幕输出内容, 一般都是调用语言的类库接口, 比如C中的printf, C++中的std::cout <<等. 这些类似背后做了什么工作, 高级语言的使用者是不关心的.

所以这里, 我们需要先回想一下, 从硬件到软件的抽象层次, 我们以"在屏幕上显示内容"为例子, 在这个过程中, 如果你全C中的printf来输出一串字符串, 其实要经过这么几个封装层级:

  1. 操作系统层级. printf函数在各个操作系统上都可用. 但在其背后, 对于不同的操作系统, printf其实调用的是操作系统中输出内容的接口. 对于Linux系统来说, 在这一层, printf函数, 调用的是write系统调用, 即syscall
  2. 硬件驱动程序层级. 操作系统背后是千差万别的硬件, 对于输出字符来说, 可能是老式的CRT大屁股显示器, 也可能是VGA接口上的投影仪, 也可能是DELL的24寸液晶显示器, 甚至有可能是一个绘图仪, 打印机什么的. 显示设备可能是彩色的, 也可能是黑白的, 对于彩色显示器, 可能最高支持16位色, 32位色等等乱七八糟的. 但是操作系统不可能内部囊括所有显示设备的信息. 对于操作系统来说, 要输出一些内容并显示出来, 操作系统只是把这部分内容扔在一个中转站中. 可以简单的理解这个中转站就是所谓的显存. 操作系统将层层传递下来的字符串, 扔到内存空间中一块特定的区域, 也就是显存中, 然后就不管了, 至于这个东西怎么显示, 那就是显示设备驱动程序的责任了. 驱动程序是连接硬件与软件的桥梁, 显示设备的驱动程序在读取显存的内容后, 将内容进行翻译, 翻译成电压, 电流, 扔给硬件.
  3. 最终, 硬件接受到最简朴的电压, 电流的变换, 控制着设备上的像素变化, 这个字符串才显示到你面前.

以上的描述中, 有很多错误的地方, 对于程序员来说, 特别是上层程序员来说, 这样的认知和理解是无伤大雅的, 因为在上面的第三层, 所谓的我称为其为硬件驱动层级上, 其实有很多复杂的事情. 但这对于上层程序员来说, 并不是必须要了解的细节.

现在回头来想, 当我们用C语言输出中时, 我们位于最高的层级, 我们直接调用printf. 而当我们使用汇编语言时, 我们要输出一个字符串, 我们位于哪一层? 我们调用的是哪一层的接口呢?

严格的来说, 当使用汇编语言时, 并没有限定我们非得在某一层, 我们既可以调用libc中的printf函数: 是的, C语言中可以内联汇编, 当然汇编代码是可以调用C库的. 也可以位于操作系统层级, 我们可以通过特殊的方法调用write系统调用. 我们更可以直接写显存(可能绕过操作系统的屏障要做一些额外的工作), 甚至于, 我们可以在硬件驱动程序中去完成这个任务: 硬件驱动程序也是由C和汇编编写的. 总之, 用汇编要完成"输出字符串"这项任务, 其实只要位于软件层面上, 都可以, 无非就是每一层的实现难度不一样而已.

而我们学习汇编的目的, 不是进行驱动开发, 也不是为了研究操作系统的实现, 而是通过学习汇编

  1. 通过汇编语言的内联, 来优化高级语言编写的代码

换句话说, 我们学习汇编, 脚下踏着的还是操作系统. 我们并不是要用汇编日天日地, 而是使用汇编, 在操作系统的肩膀上, 做高级语言很难做到的细致活. 再换个说法, 其实就是用汇编去写应用程序, 我们应用汇编的层次, 和C语言的层次是一样的. 所以, 回到输出串的话题上, 最适合我们的方法是: 调用操作系统的接口, 即write系统调用.

所以在示例程序中, 我们这样写:

 # 以下为调用显示函数的代码
 # 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定
 
在这里你可以这样简单的理解:

  1. Linux操作系统本身提供了很多系统调用.
  2. 系统调用类似于函数调用. 在系统调用之前, 需要将要调用的命令号, 以及调用所需要的参数, 填写在各个寄存器中
  3. 而是写给Linux操作系统看的. 操作系统被软中断后, 会查看相应的寄存器, 以确认用户到底想干嘛, 然后给出回应. 在这个过程中, 当操作系统被int $0x80中断后, 操作系统会跳转执行内核中的一些代码去完成用户的请求. 在整个过程中, cpu并不知道中断前后发生了什么, 它只是机械的执行指令, 而这个指令是用户的汇编代码里写的, 还是受软中断而执行的操作系统内核代码, cpu是不知情的.
 
所以上面的五行语句, 其实就做了两件事:
  1. write系统调用所需要的所有参数, 写在各个寄存器中
  2. 调用int $0x80, 触发软中断, 将控制权交接给操作系统, 由操作系统内核代码接管cpu. 完成内容输出.
 
怎么样? 是不是像极了一次高级语言中的函数调用? 是的, 就是这样, 汇编语言也是这样. 没有什么复杂的.
而至于write系统调用背后发生了哪些故事, 如何写显存, 显示设备驱动程序如何工作, 显示器如何点亮像素, 和我们就没什么关系了, 我们也不关心.
Linux提供了数量众多的系统调用, 截止目前, 已经有300多个, 关于Linux系统调用的参考文档, 可以参考, 在这个页面, 可以查询到一个系统调用, 名为sys_write, 即是我们上面说的write系统调用.

2.2.4 优雅的退出程序

 
在C语言中, 有一个很有意思的函数, 叫exit, 而我们写的汇编程序, 要优雅的退出, 也需要做类似的事情. 这个系统调用在, 名称叫sys_exit, 即是我们这个示例程序最后两行做的事情:


"汇编语言"本身, 是一个范畴很大的概念, 多数科班出身的程序员, 大多都读过这样的一本书: , 有很多高校甚至在本科学习阶段, 将本书列为汇编语言的教学教材, 这本书讲的汇编的目的是什么呢? 其实和我们的目的是完全不同的. 这本书的教学目的在我看来, 主要是:
  1. 让学生了解寄存器, cpu, 内存等硬件, 与软件的联系
 
这样的教学目的, 有一个很大的盲点就是: 学完这本书之后, 你几乎还是什么有用的东西都做不出来! 它对于科班学生计算机思维的培养很有用, 但对于实际工作应用, 基本作用为0
而我们学习汇编的目的是什么呢? 我们学习汇编的目的很功利:
  1. 我们希望了解一些被编译链接隐藏起来的细节实现
  2. 在高级语言表面上得不到的功能, 我们希望用汇编去实现.
  3. 使用内联汇编去优化我们的业务二进制包, 使在一些特殊应用场景下的代码跑的更快.
 
典型的汇编在工程上的应用, 就是C/C++中的协程库, 而我厂的更是一个标杆. 这样的汇编才是有用的. libco库中, 最核心的协程切换, 寥寥不到100行汇编, 就是这100行汇编, 撑起了微信后台开发的核心. 这样的汇编, 才是有用的.
注意, 我不是在批王爽这本书没卵用, 并不是. 王爽的这本书写的非常好, 十分好, 只是王爽老师写的这本书, 不适用于我们这种"功利的目的".
所以, 我们学习汇编有以下几个点需要注意:
  1. 我们在Linux平台下, 以AT&T语法去学习汇编. 因为这是GNU编译器在编译阶段生成汇编的格式. 也是binutils工具链的服务对象.
  2. 学习x86 32位汇编是一个过渡. 因为x86_64位汇编的学习, 基本上没有成体系的书籍去介绍引领. 我们需要先通过学习32位汇编, 把汇编中普适性的概念, 思想, 最佳实践学习到手, 之后再从32位汇编转向64位汇编.
  3. 我们脚踏操作系统平台, 不向下深挖. 确切的说, 是脚踏Linux系统调用
 
这样学习汇编, 需要先行了解编译, 链接的一些基础知识, 所以, 建议大家有空看看这一本书: , 特别是书中的第三章.

下表描述了贯穿于本文中的一些概念:

是“虚拟地址”而不是“物理地址”。为什么不是“物理地址”呢?因为数据在内存的位置经常在变,这样可以节省内存开支、避开错误的内存位置等的优势。同时用户并不需要知道具体的“真实地址”,因为系统自己会为程序准备好内存空间的(只要内存足够大)
包含以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次都读不成功则放弃这次的程序加载操作。

2.3 准备其它扇区的内容

如果此时将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”。


我的操作是这样的,目录ipl下的文件(ipl的上一级目录有z_tools文件):

world"字符串(但不知道此笔记中有无拼写错误,不必太纠结结果哈)。


[1] 计算机(BIOS)将软盘启动区程序读到0x7c00~ 0x7dff,为了保证内存中的指令参数和编译器编译后的指令参数一直,需要在汇编程序中使用ORG0x7c00语句;IPL将启动区的程序加载到0xff;IPL读启动区后一个扇区的内容到内存0xff;以此类推… (装载启动区程序到内存地址空间0x7c00~ 0x7dff是计算机(BIOS)规定;从0x8000 内存地址空间存软盘程序是开发操作系统人的规定,因为这段内存没有被其它程序占用)。

[2] 从计算机开机到操作系统的启动,CPU的控制权的操纵者为:FFFF0H(实模式)-->BIOS(实模式)-->(操作系统)程序。


我要回帖

更多关于 汉字字符串偏移 的文章

 

随机推荐