Browsed by
分类:Kernel

[Linux 0.11] Draft 6 IA32架构下多任务的硬件支持

[Linux 0.11] Draft 6 IA32架构下多任务的硬件支持

Overview

支持多任务的硬件结构为 Task Register (TR) Task State Segment (TSS), LDT 以及 Task Gate。而最核心的,存储任务上下文信息的就是 Task State Segment, 下面对其进行详细的说明

TSS基本数据结构

  • GDT, LDT
  • TSS(Task State Segment)  has its own descriptor called TSS Descriptor
  • Structure of 32 bit TSS, store the context and link to previous task, and 3 different privileged Stack

下面对一些比较关键的部分进行介绍

  • Previous Task Link: 存储的是上一个任务的选择符
  • LDT Segment Selector: 存储的是这个Task使用的LDT
  • I/O Map Base Address: I/O Map的基地址(要对 I/O Map 是什么进行进一步的解读: I/O Map 包含一个权限Map和一个Interrupt redirect Map)

TSS 描述符的一些说明

  • TSS Descriptor 用于描述 Task State Segment 的描述符,当选择符的TI Flag被置位的时候(即指向当前LDT)不可以访问TSS, 这种情况下,使用 CALL, JMP 会引发GP#, 使用IRET 会引发#TS(Invalid TSS Exception), TSS Descriptor只能被放在 GDT 中,不可以放在 LDT IDT 中
  • 下面是TSS描述符的结构

 Type 有两种 1001 1011, 前者表示该TSSD对应的Task处于 inactive 状态下,后者表示该 TSSD 对应的Task正处于Busy状态下

 

  • 每一个Task只能有一个TSS Descriptor对应
  • Task Gate Descriptor  做切换的时候不需要检查 CPL

 

任务切换过程

任务切换发生在下面几种情况下:

  • 当前运行的任务 JMP / CALL 了GDT中的一个TSS , 当 CALL / JMP 以 FAR 模式被调用的时候,如果 Segement Selector 指向的选择符是一个 TSSD, 那么就会发生 Task Switch, 忽略掉 CALL / JMP 的 offset。
  • 同样的,如果调用 JMP / CALL 而且 Selector 为 GDT / LDT 中的一个 Task Gate Descriptor(任务门描述符)也会发生 Task Switch
  • 发生中断/异常,且中断向量指向一个Task Gate Descriptor
  • IRET 指令被调用且 EFLAGS 寄存器的 NT Flag 被置位

 

任务切换过程比较繁琐,这里简单说明其过程

 

  1. 获取 TSS 的 Segment Selector, 如果是 JMP / CALL 则是直接给出的, 或者是通过中断指向的 Task Gate Descriptor 给出,如果是 IRET 指令,则查看当前 TSS 的 Previous Task Link (见上图 TSS结构图)获得 Selector
  2. 检查特权级别, 如果是 CALL / JMP 则需要检查特权级别,中断 和 IRET 不需要检查, INT n 指令引发的中断调用,会检查特权级别
  3. 检查 TSS 的合法性,以及其他必要检查
  4. 清除或者设置相应的标志位
  5. 将当前(old)任务状态保存到当前的TSS内,
  6. 如果任务切换不是因为 IRET 引起的,则将新任务的 TSSD 的 Busy 位置位
  7. 装载新的 Task Descriptor(Selector) 到 Task Register
  8. 将新的 Task State Segment(TSS) 中的上下文装入通用寄存器,段寄存器, EIP EFLAGS 等寄存器(见上图 TSS结构)
  9. 上下文(context)已经设置好了,开始执行新的任务(此时的EIP, CS等 寄存器已经是该TSS中给定的寄存器了,之前的任务完全转移走了)

下一篇文章会对Linux0.11中如何实现任务切换进行介绍

[Linux 0.11] Draft 2 80386 System Architecture

[Linux 0.11] Draft 2 80386 System Architecture

此文对 Intel 80386 的系统架构进行一定程度的说明

80386 在系统层面支持下面的功能

  • 保护
  • 内存管理
  • 多任务
  • IO
  • 异常和中断
  • 初始化
  • 协处理器,多处理技术
  • 调试功能

上述均通过系统寄存器以及指令来支持,下面会对这些进行介绍

系统寄存器(Registers)

系统寄存器分为如下几类

  • EFLAGS
  • 内存管理寄存器
  • 控制寄存器 (Control Registers)
  • 调试寄存器 (Debug Registers)
  • 测试寄存器 (Test Registers)

下面对每一类寄存器进行说明

System FLAGS

EFLAGS 中的 System FLAG 可以控制如 IO, 可屏蔽中断,任务切换,调试等功能。

  • IF: 用来控制系统是否接受(可屏蔽的)外部中断请求 IF = 0 为屏蔽
  • NT: 此位用于控制 Chain interrupted 以及 called tasks, 该位会影响 IRET 指令的操作方式
  • RF: debug 有关的 flag
  • TF: debug 有关的 flag,将处理器设置为单步执行模式,在这个模式下,所有的指令CPU都会产生一个异常,可以在每一个指令处停下来进行调试
  • VM: 用于进入 Virtual 8086 Mode 的 flag 这里不做解释

内存管理寄存器

与内存分段管理相关的寄存器,如全局描述符表,中断描述符表,也就是 GDT, LDT, IDT 和 Task 的描述信息存储用的寄存器

  • GDTR
  • LDTR
  • IDTR
  • TR   上述三个不用介绍,这个为 Task Register,用于指向当前执行的 Task 的相关元信息

控制寄存器

控制寄存器有 CR0, CR1, CR2, CR3 其中 CR1  留作 Intel 以后使用,在80386中没有实际用途。其余三个都可以通过,而且仅可以通过 MOV 指令来进行操作,将其从通用寄存器中装入,或者存入通用寄存器

  • CR0 对整个系统控制,不针对某一个任务,CR0 可以控制协处理器相关的操作(EM, ET, MP) 以及 开启保护模式 (PE) 开启分页模式 (PG) 获取任务切换状态(TS)
  • CR2 用来处理缺页异常,CR2 中存储的是触发缺页异常的线性地址的值
  • CR3 用来指定当前任务中页表所在的地址

CR2, CR3 均是分页相关的寄存器

调试寄存器

暂略

测试寄存器

单独为了 TLB 提供支持的寄存器,TLB (Translation Lookaside Buffer) 又称内存快表

—–

下面再对通用寄存器进行说明

通用寄存器

通用寄存器分为三类

  • 通用寄存器 General Register
  • 段寄存器 Segment Register
  • 状态寄存器 & 指令寄存器

通用寄存器

  • 可以做 32bit, 16bit, 8bit的寄存器为 EAX EBX ECX EDX
  • 可以做 32bit,16bit的寄存器 EBP ESI EDI ESP

分段寄存器

  • CS Code Segment 当前代码段的地址
  • SS Stack Segment 类似上文
  • DS Data Segment
  • ES  FS GS  同样为 Data Segment

状态和指令寄存器

  • EFLAGS 存储着状态
  • EIP 为指令寄存器,指向要取的指令

Stack 操作

Stack 操作使用的寄存器为 SS, ESP, EBP 分别介绍他们的作用

这里先来介绍一下 80386 CPU 对Stack的实现

Stack 在内存中实现, 指定不同的 Stack 的时候依靠切换 SS 寄存器 ( SS 寄存器指向内存段)

ESP 指针指向当前栈顶

EBP 指向该栈栈底,EBP 用来引用栈内的参数如 EBP + 4 这种

 

 

[Linux 0.11] Draft 1 Assembly

[Linux 0.11] Draft 1 Assembly

REP前缀

对 源 和 目的 地址的数据进行某些操作

汇编指令形式为 rep <INSTRUCTION>

INSTRUCTION 可以是 mov 系列(movb, movw, …) 也可以是 movs 系列,  还可以是cmps系列

该指令涉及到的寄存器有 标志寄存器(DF位) ECX ESI EDI

ECX是计数器,用来指定操作的次数 ESI 指定源地址 EDI 指定目的地址 (很好记辣 Source Index (SI) Destination Index (DI))

当处理 movs 的时候,如果 DF = 0 就是表示 ESI 指向要复制的块的开头,EDI 指向目的块的开头,DF = 0 的时候这两个就变成指向末尾了

 

JMPI 间接跳转

在实模式下,指定段地址和偏移量,将会设置IP以及CS

磁盘参数表

磁盘参数表存在中断向量0x41中 第二个磁盘的参数表存在0x46中

 

开启A20地址线

A20地址线关闭状态县 限制最多仅能寻址到1MB空间,更大的空间将会循环

开启方法如下:

 

字符串比较指令

SCAS, SCASB, SCASW, SCASD

AX, ES, DI FLAGS(DF)

比较 AL(AX)(EAX) 与 ES:[DI] 的值,不保留结果,仅保留FLAGS。

根据后缀(B,W,D)的不同,比较的字节数也不同

根据方向位(D FLAG)决定比较之后DI寄存器是增长还是减少

可以与REP前缀一起使用

设置页表

页表设置只需要用到

CR3寄存器 (页表的物理地址)

以及CR0寄存器(开启PG Flag)

任务切换相关

STR , LTR 装载,保存 Task Register (参数为段选择符)

 

Eudyptula Challenge 1 — 8 总结

Eudyptula Challenge 1 — 8 总结

TL;DR

(PS: 昨天竟然在一个非计算机领域的朋友口中听到TLNR好神奇)

从开始做eudyptula-challenge已经有一个多月了,也从原来的连第一关都会卡关好久到现在第八关只用了不到四十分钟的时间就写好了(虽然第八关的coding要求很简单,主要是要练习git send-mail的使用方法)随着Challenge的进行,之前关卡学到的一些东西难免会遗忘,因此在继续进行之前,现将前面学到的知识进行总结。注意: 本文不是Eudyptula Challenge的攻略,Eudyptula Challenge明确禁止了对答案代码的公布和分享,个人也赞同这种做法,毕竟是Challenge,需要你自己研究,而不是直接上网就能搜到答案(尽管现在的确能搜到答案了,比如某章鱼猫((

Intro

What is Eudyptula Challenge?

这里就引用官网的介绍了

The Eudyptula Challenge is a series of programming exercises for the Linux kernel, that start from a very basic “Hello world” kernel module, moving on up in complexity to getting patches accepted into the main Linux kernel source tree.

How does it work?

官网上也给了相应的介绍,不过窝认为从一个例子来介绍更为形象:

Eudyptula-Challenge的评判系统是由”A set of convoluted shell scripts that are slow to anger and impossible to debug.”(摘自官网)来进行的,也就是全部自动评测(不过有的时候它的只能程度让我猜想后面有人在操作*****(大雾

以一个Task为例, 当你注册成功Eudyptula Challenge之后,会收到一封说明邮件以及你的第一个任务,之后你所有的任务提交都将通过在第一个任务里给你分配的ID以plain-text mail的形式进行。

当你收到一个Task的时候,根据相应的知识完成任务要求的代码之后,你需要通过纯文本邮件客户端(如mutt)将proof运行结果的输出作为正文,将写好的代码作为附件(这里不能用gmail的plain-text模式,因为它会把邮件附件转为base64格式)通过客户端发送给little@eudyptula-challenge.org,之后你会收到一封通知邮件,表示系统已经成功收到你的提交(有可能收不到,因为脚本也许会卡住,这时候你如果等了一天还没收到,那你可以再次发送一封,不要频繁重发,小企鹅会生气的(不要问我怎么知道的Orz))

通知邮件的格式是这样的

可以看到上一个最近处理的提交时间,以及当前你所在Task的队列里有多少人在你的前面,一般来说从Task01-Task08都是平均一天之内就会回复你的Submit的结果,除非是脚本出故障了(

然后只需要等待结果,在此期间你可以看看番,学学其他有趣的东西比如日语,比如机器学习( “There is a lot more
in life other than Linux kernel programming.”

结果到达之后,如果你成功通过这个Task,那么会收到Congrats以及下一个Task的任务邮件,如果你没有通过,你会收到具有一定指导信息的来自小企鹅的建议(代码逻辑错误,不够精简,格式不正确,etc)

以上就是Eudyptula Challenge的一个完整的流程~ 那么下面开始总结

Task 01

主要掌握了编写module Makefile的方法,以及module的最最最基本的骨架, init函数和cleanup函数 ,以及如何设置mutt客户端(这个好像是弄得最久的?) 关于mutt客户端的设定方法,参考我的上一篇文章, 关于simplest module的编写 参考本文即可 http://www.tldp.org/LDP/lkmpg/2.6/html/x121.html

Task 02

编译内核,使用menuconfig, config或者xconfig设置内核参数,编译自定义的内核,了解了linux-git tree 以及 linux version的命名方式 参考 https://www.kernel.org/doc/html/latest/process/howto.html 中的kernel developing process

在这里简述一下kernel develop的过程

  • 当某一个4.x版本发行之后,会有两周的窗口期,这个窗口期内可以提交大的修改,以及feature patch,通常情况下,这些patch都已经在 -next tree里存在了几周
  • 两周窗口期后,-rc1 版本会发布 正如其名, make the kernel as rock solid as possible( rc = rock ) 进入到rc1的patch应当修复regression问题 [ 4.10-rc1]
  • 当Linus认为当前代码tree已经处于可以进行充分测试的状态的时候,那么将会发布新的-rc版本,大约一周会发布一个-rc版本 [4.10-rc8]
  • 大概经过6周的发布过程,内核变得稳定。[4.10 发布]

编译内核之后,提交dmesg和uname -a信息作为任务验证信息即可

 

Task 03 – 04

如何提交Patches https://www.kernel.org/doc/html/latest/process/submitting-patches.html 

内核代码风格

Task 05

编写USB热加载的模块 要求编写一个模块在USB键盘插入到机器的时候自动加载此模块

参考内核的相关文档(writing_usb_driver.tmpl) 如果想要实现hotplug的话,需要建立一个MODULE_DEVICE_TABLE,它用于告诉内核,这个module支持相应的设备(提供vendorID & DeviceID)

之后需要实现一个用于热加载的函数probe,然后就可以了

 

Task 06

这里要求实现一个misc character device了, 要求这个device能够进行读写,并且执行相应的读写之后的处理逻辑。

这里要求学会如何注册misc_device, 以及了解file_operations结构体,并且为misc_device编写read write两个file_operation

内核内存区和用户内存区不能进行直接共享,需要通过copy_from_user以及 copy_to_user这两个函数进行内核和用户内存的拷贝

另外有两个非常方便的wrapper函数 simple_read_from_buffer simple_write_to_buffer

Task 07

编译 linux-next kernel,不过这里被一个kernel的bug卡住,编译好的新kernel启动的时候hang住

Task 08

将Task 06写的device放到debugfs中,而不是/dev下,要求实现并发 并要求使用git send-mail发送patch

debugfs创建文件和dir均很简单,在此不进行阐述。

为了实现并发,需要信号量或者是自旋锁。这里使用了rw_mutex (多写多读)。

关于自旋锁,信号量之后会考虑写文章进行解释

然后就是git send-mail这里要注意在format-patch的时候就加上的几个参数

--subject-prefix  加上ID prefix

--in-reply-to 第一封邮件的MessageID

--thread 以thread的形式发送

关于git send mail的配置不在这里进行说明,可以很容易的找到相应的配置方法

 

 

ULK Chapter2 总结

ULK Chapter2 总结

本章的知识结构如下

地址转换过程

谈到内存地址,具体来讲有三种不同类型的地址,逻辑地址,线性地址,物理地址。

  • 逻辑地址对应内存的分段
  • 线性地址对应分页
  • 物理地址对应到硬件芯片上的内存单元的地址

MMU通过Segment Unit与Paging Unit两个硬件电路将一个逻辑地址转为物理地址, 具体转换过程如上代码段所示

下面对整个转换过程进行概述

段选择符

[0-1]RPL: 请求者特权级

[2]TI: Table Indicator, 指明是从GDT还是LDT中取出段描述符(Segment Descriptor)

[3-15]index:用来指定从GDT中第index项取出Segment Descriptor

段描述符

对段描述符的解释参考 [不是科普向?] RE: 从零开始的操作系统开发 第二集 中相应内容即可

Logical Addr ===> Linear Addr

逻辑地址的高16位为段选择符(Segment Selector)其余32位(或者64位)为偏移量(Offset)

流程如下

  1. 检查TI确定是从GDT(TI=0)还是从LDT(TI =1)中选择段描述符
  2. 从Segment Selector的index字段计算出段描述符的地址,计算方法 index * 8 (一个Segment Descriptor大小为8) 并与gdtr/ldtr中的内容相加得到Segment Descriptor
  3. 把逻辑地址的offset字段与Segment Descriptor中的Base字段相加,得到Linear Address

 

而Linux中的分段只是一个兼容性考虑,分段和分页的作用是重复的,分页能以更精细的粒度对内存进行管理,因而在Linux中分页是主要的手段,Linux分段中的用户代码段,数据段,内核代码段,数据段,都是以Base = 0 ,因而Linux下的逻辑地址和线性地址是一致的,(因为Base = 0 所以 Linear Address = Base + Offset = Offset) 即逻辑地址的偏移量和线性地址的值是相等的

 

Linear Addr ==> Physical Addr

至此我们已经得到了线性地址,下面需要将线性地址通过分页单元转为物理地址,下面以基本的分页模型对分页进行解释

80×86的常规分页结构为

页目录 页表 页框

如图

如图,Page Directory和PageTable的结构类似,都是由一些flag bit加上Field字段,Field字段表示页框的物理地址,如果是一个PageTable的话,那么这个页框就含有一页数据,Page Directory的话,页框内含有的是一个页的PageTable(页表的大小就是一个PageTable)

 

寻址的方式如下,首先根据CR3寄存器的内容,找到PageDirectory入口的物理地址,然后加上Linear Addr中的DIRECTORY,找到对应的PDE项,并根据此内容找到对应的Page Table的物理地址,并根据Linear Addr中的TABLE找到相应的Page即页框的物理地址,最后将此物理地址加上Linear Addr的OFFSET字段,最后得到物理地址

 

而在64位系统上,如果依旧采用这种最基本的分页结构的话,那么假设页框大小为4K,所以OFFSET位依旧为12bit,然后其余的52bit可以分给PT(Page Table的简称,下同)和PD(Page Directory的简称,下同),这样就会导致我们每个进程的页目录和页表变得非常非常多,超过256000项

因为这个原因,所以对于64位处理器的分页系统,都使用了更多级别的分页级别,如x86_64使用48位寻址,分页为4级线性地址分级为 9 + 9 + 9 + 9 + 12 如下图所示

原理都是一样的,只不过分页级别变多了

以上就是Logical Address ===> Physical Address的转换的大致流程

 

缓存技术

因为CPU Register的读取和内存的读取速度相差甚大,为了缩小这个差距,避免CPU等待过长的时间,使用缓存技术来将内存中的部分数据缓存在高速静态RAM(SRAM)里,即为高速缓存技术(Hardware Cache)

此外,将Logical Address转换为Physical Address也需要多次进行内存的读取(查找各级页表),为了加速此过程,引入了转换后援缓冲器Translation Lookaside Buffer(TLB)技术

Hardware Cache

对高速缓存的原理描述超出本文的范围,这里仅对高速缓存的读写方式进行一定说明:

在更新内存数据的时候,是同时更新高速缓存和内存的数据,还是仅仅更新缓存的数据,根据这个有两种不同的写策略

  • 通写(write-through): 既写RAM也写缓存
  • 回写(write-back): 只写缓存,只有当需要FLUSH的时候才更新RAM

操作系统可以针对不同的页框配置不同的告诉缓存管理策略,通过PCD位和PWT位

  • PCD表示是否对此页框启用告诉缓存
  • PWT表示是否采用write-through的策略

在Linux下,对全部的页框都启用告诉缓存,并且都采用write-back策略

TLB(快表)

TLB的作用是将Logical Address对应的Linear Address缓存起来,以加速对内存的访问

但是这个缓存是有失效期的,最明显的一个失效就是当PD都被替换了的时候,也就是cr3的寄存器内容更新了,硬件上,当cr3更新的时候,TLB的内容会自动失效

如何能够更好的利用TLB来加速访问是对Linux性能影响十分重要的一部分,为了避免多处理器系统上无用的TLB刷新,内核使用一种叫做Lazy TLB的技术,关于Lazy TLB技术的具体实现之后补充

不同的分页方式

上述已经对常规分页进行举例了,下面对不同的几种分页方式进行简单整理

扩展分页

80×86模型的另一种分页模式,页框大小为4MB而不是4KB,用于把大段连续的线性地址转换成相应的物理地址,因为没有了PT只有PD,节省了内存,同时PT—>Phy而不是PT–>PD–>Phy,少了一级转换效率也相应提高,

物理地址扩展(PAE)分页机制

早些时候,内存容量都很少,而当需要大内存(>4G)的服务器&程序出现的时候,上述的寻址方式就不能使用了,因而Intel将内存地址位数从32 –>36位(即将引脚从32个扩展到36个) 而之前的所有的地址转换都是从32位Linear Address转换到32位Physical Address的,需要有一种新的转换机制,将32bit linear addr->36bit phy addr,这种机制即为PAE机制,详情可以参考Intel手册 Vol3A相应的内容

Linux中的实现

对于Linux的具体处理将在之后在整理

 

 

 

将内核编译到自定义的目录下

将内核编译到自定义的目录下

参考wiki: https://wiki.archlinux.org/index.php/Kernels/Traditional_compilation

使用ArchLinux作为构建Linux的环境

方法如下 假设我们的customized directory为kernel-build

我们必须在kernel-tree目录结构下对内核进行编译,假设kernel-tree目录名为linux-kernel

下面是具体的操作:

在 make menuconfig 的时候,最好对内核版本进行修改,使之不会在make modules_install的时候覆盖掉现有内核的modules

修改途径为General Setup里的Local Version项

这样执行之后,应该在arch/ARCH/boot/下面存在bzImage内核镜像,并且modules应该被安装在了/lib/modules/<linux-version>-<localversion> 下

下一步操作, 修改linux.preset文件,并创建initrd文件

修改linux.preset文件, 修改完毕后的内容类似下面

我们需要修改ALL_kver, default_image, fallback_image三个选项,使其将image保存到指定的目录下

然后使用

创建initrd, initrd-fallback,目前kernel-build目录应该有如下文件:

然后使用

就可以运行我们自定义的Linux Kernel了,可以使用uname -a查看Kernel Version~

不过到这里我们仅仅完成了一个RAMDisk的Kernel,没有任何的文件系统被挂载,只有一个rescue filesystem和busybox的一些东西能使用,为了构建一个能够正常使用的Linux Image,包含一个Distro应该有的程序,下面将通过Linux From Scratch项目一步步构建一个可用的自己的Distro

[科普向?] Re: 从零开始的操作系统开发 第一集

[科普向?] Re: 从零开始的操作系统开发 第一集

Hmm, 果然还是开坑了~! 在学校智障的操作系统课设的发起下, 再加上每个程序员都有一个写一个自己的操作系统的公主梦(雾), 我们愉(作)快(死)地开坑啦~

以前曾经跟着 “30天自制操作系统” 玩过DIY操作系统, 不过那个书更像面向小白, 讲的东西也不够系统, 而且使用的是自己改过的nas汇编器, 因而不能算写过, 这一次则是真正的开坑啦~ (虽然课设时间很短写出一个完整的根本不可能不过慢慢写总会写完的你说对不喵~)

我们的开发过程在Bearychat上直播 neugeek.bearychat.com 的Toy-OS频道, 我们的git-repo 为 https://github.com/VOID001/toy-os 菊苣们不要喷, 既然挖了坑窝就不会不填(….你都已经挖了多少个坑了啊喂! (逃))

这个系列的文章将会记录在开发操作系统的整个过程中的一些经验&心得&吐槽 不知道会有多少集(

 

参考资料们:

  • MIT 的 XV6 源代码 & handbook
  • University of Birmingham 的 Writing an simple operating system from scratch
  • Quora, StackOverflow
  • Jiong Zhao Linux 0.11内核完全注释

 

我们使用的工具链:

  • GNU Assembler & GNU C Compiler
  • Qemu
  • Gdb
  • objcopy, objdump, binutils, elfutils
  • GNU Makefile

 

操作系统编写总览

什么是BIOS

参考阅读: http://whatis.techtarget.com/definition/BIOS-basic-input-output-system

我们的定位是写一个操作系统,那么首先我们应该了解,整个操作系统都应该由哪些模块构成, 那么就让我们从操作系统的启动说起, 说到这里就不得不说一下BIOS, BIOS是Basic Input and Output System, 是你的计算机加电运行后加载的第一个程序, 它是固化在你的EPROM内的一个程序片段

BIOS被加载之后, CPU便会去执行BIOS的代码,这时候, BIOS进行硬件自检, 保证硬件没有故障后, 就会加载操作系统, 同时BIOS也提供了一个通用的接口, 供我们用来与不同的外设如VGA显示器进行交互, 具体如何使用将会在下文中介绍

boot-sector

刚刚我们说到了, BIOS作为开机运行的第一个程序, 在进行硬件自检后, 便会装载操作系统, 可是这时候,操作系统还在磁盘(或者其他存储介质内), BIOS如何知道, 我们知道BIOS是由厂商写死在ROM上的, 我们的操作系统程序如果每次存放的位置都不一样的话, 岂不是每次都要去重新刷写EPROM? 当然没那么麻烦, BIOS和编写操作系统的程序员有一个约定, 那就是, 当自检完毕之后, BIOS会自动按顺序(你设定的Boot Sequence)检查每一个media的第一个扇区(0扇区)是不是Bootable,如果找到一个Bootable的扇区, 那么就加载这个扇区到内存中, 接下来会执行这个刚刚装入内存的程序, 这样, 我们就可以在这里执行对硬件初始化&装载操作系统程序等操作啦

对于CPU而言, 代码和数据都是二进制,那么如何区分这是一个bootable扇区呢? 流我们将bootable扇区称为 boot-sector,  为了让CPU能够识别这个扇区是boot-sector, 对boot-sector有如下的要求:

  •  必须是512Byte大小
  • 512Byte的末尾两个Byte应该被填充为0xaa55

拥有了这两个条件, 这个扇区才是一个boot-sector, BIOS才会去加载它, 下面是一个非常简单的,开机后就让CPU进入死循环的一个程序的binary文件, 这就是一个boot-sector

中间的0被省略,  因为little endian的原因 0xaa55 在实际存储的时候为 55 aa

切换到32bit-protected mode

上面我们的所有操作都是在16bit 实地址模式(即你访问的地址就是真实的物理地址)下进行的, 而 16bit的实地址模式可以访问的内存最大为 1.0615234375(数字是如何计算出来的, 参考内存分段管理的相关知识 0xffff * 16 + 0xffff) 只比 1MB多一点的空间, 这对于我们之后要写的操作系统, 以及我们要运行的程序是远远不够的, 那么接下来我们就要切换到32bit的虚拟地址模式下, 进行接下来的开发

操作系统的核心模块

在切换到32bit protect mode之后, 我们需要实现的是kernel system call, file system, multiprocess scheduling, 以及 支持我们的Keyboard和VGA Driver

其他(Misc)

为了让我们的操作系统可以交互, 我们需要实现一个Interactive Shell, 并且实现几个能够运行在我们的操作系统上的程序, 之后也许还会支持网络 & 图形界面, SDL Driver等

 

以上就是一个综述啦, 可以看出来这是一个不小的坑, 不过嘛, 很有趣对吧~!

 

我们的第一个Hello world 操作系统

AT&T汇编+GNU Assembler的一些比较坑爹的事

为了更好的和XV6产生一致性, 我们采用了 AT&T 汇编, 使用的为GNU Assembler 进行开发, 关于AT&T汇编与Intel汇编的区别, 这里有参考文章http://www.delorie.com/djgpp/doc/brennan/brennan_att_inline_djgpp.html

需要注意的问题是, 之前在使用nasm作为Assembler的时候可以直接通过 -f bin 指定输出的程序为 RAW格式的, 即为不含有任何ELF(Linux下的文件格式)的信息, 而使用as进行编译的时候, 目前我们还没有找到办法直接输出RAW binary文件, 而且, 在AT&T中貌似也没有可以方便的在0扇区的最末尾填充0xaa55的方法(Intel中我们通常使用 time 510-($-$$) db 0  来在整个扇区填满0,  之后 dw 0xaa55 ), 另外! 另外! 对于那些在Intel下的标号后定义的字符串, nasm可以轻松识别地址, 然而, 然而, 然而!(重要的事情说三遍)as 不会! as会认为那是一个外部符号, 等待链接的时候Relocate, 因而下面这段代码如果不进行链接的话, 这个’string’ 标号就会被用 0x0替换

导致结果出错

 

那么正确的姿势是什么呢, 下面这样可以生成一个正确的RAW binary image(假设我们的源文件叫做boot.s)

 

计算机刚刚启动之后的内存布局

上面的代码中的0x7c00 是什么鬼肯定有人要问, 我们下面就来解释一下, 在计算机启动完毕之后, 内存的布局如下:

 

Screenshot from 2016-07-05 20-06-57

(本图摘自Writing a Simple Operating System — from Scratch)

我们可以看到, 在低地址最下方有着中断向量表, 再上方就是BIOS的数据内容, 然后, 为了防止我们的boot-sector将BIOS Data/中断向量表的内容覆盖, BIOS将boot-sector加载到的地址为0x7c00处, 那么现在来解释一下为什么链接的时候要指定这个参数, 因为当你链接的时候, 那些需要Relocate的符号, 是按照一个实际地址 + 在本文件内的偏移量给定的, 而因为我们的boot-sector默认加载的位置为0x7c00, 那么我们的实际地址就应该为0x7c00, 这时候我们再想指定某个标签(如string) 就会在运行的时候将string Relocate到 0x7c00 + 在binary文件中的offset(可以通过hexdump看到)

Qemu with GDB

为了让我们的调试更愉.悦, 能够使用gdb对代码进行调试则是极好的 qemu支持远程调试功能, 在运行qemu的时候, 指定参数 -s(开启1234端口并且等待debugger链接) -S(先不要执行CPU指令) 后, 即可通过gdb连接到这个端口进行调试啦, 具体的操作方法如下

有了这个之后, 在操作系统的开发初期就能更好的看~代~码~啦

Writing our first Hello world OS

为了表示对LL的敬意, 我们准备在屏幕上输出 Hello Niconico, 之前已经说过了, BIOS提供了一部分通用的接口供我们和硬件打交道, 我们就不需要关心硬件的更具体的细节了, 这里我们就要用到这个接口, BIOS 将此接口通过中断的形式提供给我们. 为了在屏幕上输出一个字符, 我们通过给一些特定的寄存器赋值, 即汇编语言的参数传递, 类似C语言的参数传递. 一个简短的打印一个 字符’A’的代码如下:

我们为了实现打印一整串字符串,  一个稍微复杂点的程序如下:

这里注意, 我们被AT&T的汇编坑了好久的一个地方就是 mov $string, %bx . 刚开始, 我们写的是

mov string, %bx  这个代码一直没有办法打印出我们想要的字符串, 原因就是, 在ATT汇编中, 这句被解释为了, 将string 这个标号处对应的内容取来, 放到%bx中, 而我们想要实现的是: 将string这个标号对应的地址取来,放到bx中, 如果不加$, 所有的地址在ATT中都会被解释为”那个地方的内容” 一定要小心

以上代码可以在github获取到~ 这个版本对应的commit是  8c2f90aa8830edaf9ea10809797d14918efb463e  只要按照README装好需要的工具, 执行  make boot 就可以看到效果啦~

 

FisKyZcAmN3juq8lmTlENUyJ55tF

 

今天就到这里啦

[Linux C]ELF Trick — 获取程序内的函数名及地址

[Linux C]ELF Trick — 获取程序内的函数名及地址

这一切的起源来自于一个群的对话:

B 23:27:38
我定义了一个 C 的函数
abc_hello_world()
然后我想通过命令行通过参数
xxxxx abc hello world
把三个参数,自动作为函数名给弄进去
然后输出 那个函数的执行结果
在 C 里怎么搞啊
首先我们的第一想法都是 “宏”
网络部 R 籓绽23:29:13
我记得宏定义有一种方式可以拼接字符串吧
网络部 R 籓绽23:29:25
我没弄过 窝想想看
A 23:30:05
好像没办法实现这个,编译之后都是二进制了,函数名在优化过程中都没了
A 23:30:26
我试试宏
网络部 R 籓绽23:30:29
宏定义的确可以拼接字符串的说
网络部 R 籓绽 23:30:32
你试试看
然后, 这些抽风的程序员们感觉, 宏这种用法太无趣了(笑) 于是就有了下面这段对话
网络部 R 籓绽23:37:30
知道这个原理我们c就可以实现了吧
A 23:38:09
他是解释器,变量名会一直保存,但是 C 编译后就没有变量名了
网络部 R 籓绽 23:38:47
有的 c可以留符号表的
A  23:39:03
(⊙o⊙)…怎么做
网络部 R 籓绽 23:39:04
我们可不可以查符号表 找地址呢
网络部 R 籓绽 23:39:12
调试信息呀
B 23:39:15
查符号表地址那不就是函数指针了?
网络部 R 籓绽23:39:21

网络部 R 籓绽23:39:31
先查函数名然后
网络部 R 籓绽 23:39:36
找到函数的地址
网络部 R 籓绽 23:39:39
然后调用
A 23:39:51
(⊙o⊙)…阔以耶。。。
然后, 就如上文所说, 准备查symtab中的信息, 然后得到函数名以及对应的函数指针入口地址, 于是就有了下面这段代码
[因为今天考试, 代码的解释, 原理解释什么的, 等儿童节后再写哇卡卡卡]
先看程序运行结果
我们这个程序内有func1 func2 func3 extern_fun main 这么几个函数, 我们来看一下运行结果
ELF_DEMO
我们可以在程序内任意添加新的函数, 而不需要修改其他部分的代码, 重新编译之后, 即可通过输入那个函数的名称,调用相应的函数. 同时也可以编译链接多个源文件 (extern_fun 就是在其他文件内定义的函数) 并且调用到他们
下面是具体的实现代码:
 目前只能调用返回值 ,参数列表为空的函数, 改进的话, 可以实现多种参数,多种返回值的调用
[Linux C内核] 信号

[Linux C内核] 信号

下面对Linux 下的信号的产生, 响应机制, 以及可重入(reentrant)进行介绍~

[首先再次给网易云音乐点个赞~\(≧▽≦)/~, 在写博客的时候听着歌 而且还有歌词滚动, 真的太棒了 ~~~~]

[不行, 我要附上一张截图]

Netease-Blog

恩,心满意足的窝开始写文章了 ~~

什么是信号

在Interprocess Communication(IPC)的情形中,如何让不同的process进行通信是一个很重要的需要解决的问题, 而信号就是不同的进程之间通信的一种方式, 而在Linux下运行的每个程序, 都会受到内核的控制, 内核就通过信号告知进程一些控制信息, 如中断执行, 停止执行, 进程和进程之间也会进行信号的传递, 比如 父进程fork出的子进程, 子进程退出的时候会发送SIGCHLD信号给父进程, 告诉父进程, 我运行完啦~, 那么Linux是如何实现信号的捕捉,并且送达到相应的程序的呢? 下面我们举个具体例子来看一下

我们以最简单的情景: 用户在terminal下执行一个程序,然后由用户通过Ctrl+C打断程序的执行,返回到terminal下的整个过程中, 信号的流向进行说明

  •  输入命令运行程序, 在Shell下启动一个前台进程, 分配了相应的PCB
  • 程序运行过程中,用户按下 Ctrl+C, 因为有键盘输入的介入, 产生了硬件中断, CPU从用户态切换到内核态,去处理中断
  • 终端的相应驱动程序,将Ctrl+C解释为 SIGINT信号, 并记录在当前运行在终端中的程序的PCB中
  • 当要从内核态切换回用户态之前, 会对PCB中记录的信号, 进行处理, 检察到PCB中有 SIGINT信号, 于是对信号进行处理, 而目前这个信号的处理动作是终止进程执行, 于是这个程序终止了

信号分为实时信号和非实时信号, kill 指令后面可以接信号常量,  根据信号常量对应的数值区分实时/非实时信号 > 34的是实时信号 我们这里也值讨论 < 34的信号(即非实时的信号) 关于信号的更详细介绍, 参考 signal(7)即可

产生信号的条件

上面只说了通过Ctrl+C产生一个信号发送给进程, 那么还有什么方式产生一个信号呢? 下面列出主要的几种方式

  •  用户在终端进行某些按键的时候,  终端驱动程序 会发送信号给相应的进程
  • 硬件异常也会产生信号, 如当前进程执行了除以0指令, CPU的运算单元会产生异常
  • 一个进程调用 kill 系统函数可以发送信号给某个进程, 注意这里的kill不仅仅能够kill掉某个进程, kill可以用来发送各种信号给其他的进程
  • 当内核检测到某种软条件可以通过信号通知进程, 常见的是alarm产生的SIGALRM信号 & 管道通信产生的SIGPIPE信号

下面我们通过一个alarm 小程序查看一下产生信号的条件之一 软条件由内核产生信号:

执行该程序, 该程序会在1s(精确到 0.01s) 时间内数数, 然后时间到了就会退出, 我们用 time 运行一下看看

结果如下

可以看出来 这里就是产生了SIGALRM信号, 然后该进程接收到这个信号之后, 默认的处理动作就是退出程序的执行, 因而程序退出了, 之后我们会介绍如何更改默认的信号处理方式, 先不要着急~~

信号的本质

如果想要对信号进行更高级的操作的话, 那么我们需要了解信号的本质, 也就是信号在内核中到底是如何存储的, 如何表示的, 我们用一张图来说明信号在内核中的表示

Document_2016-05-27_20-24-40

黄色的是PCB, 紫色的就是PCB中负责存储信号相关信息的部分, 然后我们来看一下这个部分, 这个部分规定了该进程对每个信号的处理, 响应方式, pending位表示 信号是否已经到达(叫做产生更准确,不过我认为到达更形象), block位表示是否屏蔽这个信号的响应(即这个信号到来之后,将信号阻塞住)WARN:”阻塞”和”忽略这个信号” 不是一个概念,   “忽略这个信号” 是在信号递达之后的一个对信号的响应方式, 而阻塞, 这是不响应这个信号, 就好比你和你女票闹矛盾了, 你为了防止他不接电话,特地换了个新手机卡,给她打电话,结果电话关机(我们假设她真的是关机)[这是阻塞], 然后, 你等到晚上, 终于电话开机了, 然后你打过去, 她接起来,你开始哄她, 然而整个过程你女票一句话没说,并且淡定的挂了电话, [忽略]了你的信号, 是不是很生动且形象desu~~(怕被广大单身程序员打死的修罗已经逃走)

Hmm, 我们还是看上面那个图, 那个图上还有一部分我们没有说明, 就是 handler部分,  通过你工资就能看出来,这里指定了对每个信号的处理方式 对一个信号的处理方式有以下几种:

  •  默认处理方式 SIG_DFL 这个就是默认的处理方式啦,没什么解释的
  • SIG_IGN 忽略这个信号
  • handler_function 用户自己定义的handler函数~  这种方式又叫做 捕捉一个信号

相信通过上面的内容,大家对信号的产生, 递达 , 处理 有一个基本的了解了~~ 接下来我们就用一个具体的例子 演示一下上述内容

一个无法通过Ctrl + C退出的程序

下面先演示一个最简单的例子, 然后我们还会给出他的升级版, 一个除了****都怎样都无法退出的程序, 首先是第一个程序

这个程序运行后, 5秒钟之后才会退出, 之间无论你怎么按Ctrl+C都没法停下这个程序, 不过你会发现按完Ctrl+C之后, 输出的东西改变了, 有一位变成了1 , 这个稍后会进行解释

我们来解释一下这段代码, 先看main函数 首先我们用sigempty将信号集 set 清空 这里解释一下信号集:

信号集就是一个用于存储所有信号的信息的数据集合, 每一个信号的”有效/无效” 都用一个bit来进行表示, 至于具体哪个bit表示哪个信号,这个是不确定的, 由操作系统来决定, 所以对其的操作都要通过相应的函数来实现,而不能直接修改某个bit.

这里就是将所有的信号都设置为”无效” ,不过这里还没有进行任何操作, 要对信号进行操作的时候, 还要用其他的函数,将set作为参数传递进去. 这里可以将sigemptyset理解为 不选择任何一个信号进行操作, 和这个函数相对的是 sigfillset 意为选中所有信号进行操作

然后 我们将SIGINT信号选中, 使用 sigaddset, 他的兄弟函数是 sigdelset

然后 将选中的信号进行操作 sigprocmask, 设定屏蔽字, 让当前进程屏蔽 SIGINT, 即阻塞该信号

另外sigpending函数是取出现在产生但是还未递达的信号, 生成一个信号集, 存储它的信息, 然后使用自定义的一个函数打印出来了这个信号集的内容, 现在我们可以解释为什么按下Ctrl+C之后, 有一位变成了 1, 因为这个表示SIGINT, 信号的产生

然后我们来看下面这个加强版的, “任何”信号都杀不死的程序

这个代码不用再解释了, 我们直接暴力的屏蔽了所有的信号当然什么信号都没有用啦~~(可怕

这个程序运行起来之后, 只有一种方式不重启电脑停止这个程序: 那就是向该进程发送SIGKILL信号, 引用兔子的一句话, “没什么是一发SIGKILL不能杀死的, 如果有的话,就两发”. 可是SIGKILL为什么能够不受我们屏蔽字的影响, 而使程序退出呢? 注意我还特意对SIGKILL信号位选中, 生怕是因为我没选中才导致这个信号没被屏蔽.

The SIGKILL signal is sent to a process to cause it to terminate immediately (kill). In contrast to SIGTERM and SIGINT, this signal cannot be caught or ignored, and the receiving process cannot perform any clean-up upon receiving this signal.

根据wiki的解释, SIGKILL这个信号根本就没有被送到当前进程,当前进程没有接收到这个信号, 直接被Kernel杀死了(好可怜, 不明不白的就挂了) en, 这样就能解释同上面的问题了

信号捕捉的实现 自定义信号处理函数

下面我们通过一段代码实现一个自定义的闹钟demo, 当时间到了的时候, 会显示timeup并且退出, 期间可以通过Ctrl-C打断,打断后会输出一个萌萌的表情说明,然后退出, 代码如下

对于一个信号的处理方式的描述的结构体叫做 sigaction , sigaction也是改变信号相应响应方式的函数, 通过设定 sigaction 的 sa_handler字段, 再调用sigaction函数, 就可以修改相应的信号的处理方式了

可重入将在下一篇文章进行介绍

[C Linux 内核] 文件系统 #2

[C Linux 内核] 文件系统 #2

上文我们已经通过一个ext2文件系统的例子详细的查看了文件系统的各种属性, 数据是如何存储的

不过很显然有一个疑问: 如果一个inode只能存15个Block的索引项, 即之恩那个指向15个Datablock每一个Datablock大小又只是1KB, 那么一个文件的maxsize不就只有15KB了么, 下面就通过这个事实 , 对Linux ext文件系统的数据块寻址进行介绍

数据块寻址

对于我们上面那个例子里的ext2文件系统来说, 只有前面 12个块是直接指向一个存放数据的Datablock, 而 Block 12, 13, 14则都是间接寻址块 

所谓间接寻址块, 就是将本来用作存储文件数据的Datablock用作inode索引(即在这个Datablock中存储的是一个个Block index记录)

在上述sample filesystem中 , 总共有三个间接寻址块, Block 12 13 14  他们之间的关系如下图所示

inode search view

其中 浅蓝色表示真正存储数据的Datablock, 而浅绿色, 深绿色, 橙色 的 则表示一级, 二级, 三级Block索引, 以一级索引为例, 以及Block索引指向的是一个 Datablock, 这个Datablock 存放的是Block Index,而二级Block索引则指向一个Datablock ,这个Datablock存的是一系列的一级索引, 然后每一个一级索引又指向一个存放着Block Index的Datablock,以此类推 ,通过这个方法就大大扩充了一个文件的容量

文件&目录的操作函数

以下这些函数都是通过读取inode block, datablock 里相应的信息, 返回给调用者 / 对文件 or 目录进行操作

  •  只对inode进行操作的(取出/修改)
    • lstat, stat, fstat(取出)
    • access (取出)
    • chmod, fchmod(修改 st_mode)
    • chown, fchown, lchown(修改user, group)
    • utime(修改 atime, mtime)
  • 同时对inode, datablock进行操作的
    • truncate, ftruncate(截断文件(既可以向短截断,也可以向长截断))
    • symlink(符号链接, 创建一个新的inode, st_mode字段文件类型为symlink, 原文件路径根据长度可能保存在inode内或者分配一个datablock来存
    • unlink
    • rename
    • rmdir
  • 对目录进行操作的专用函数
    • opendir
    • readdir
    • closedir

关于每一个命令具体对应的磁盘上的inode, datablock的操作省略, 在《Linux C编程一站式学习》Chap29 中有介绍,本文也是对其的一个整理,总结

下面给出一个模拟 ls -R 的实现代码 参考K&R C

书上说这个代码有bug, 可能导致递归死循环,  我在这里将书上的一个地方进行了修改, 不知道是否解决了书上所说的问题, 代码中

我改成了

这样遇到symlink就不会递归进去, 不过就算递归进入symlink, 如果有一个symlink链接到它的父目录的话, 也会由于 too many symlink这样的错误导致程序的停止, 不会死循环, 所以目前为止还不清楚具体是哪种情况会让上面的代码死循环

 

VFS

下面说一下 VFS , Linux支持各种各样的文件系统格式 ,如 ext2, ext3, ext4, ntfs 等, 那么按照上面对文件系统的分析来看,不同文件系统都需要实现一套不同的stat, link, opendir … 等操作相应文件系统的函数, 而当将某个文件系统挂载到相应的文件夹下, 进去查看之后发现, 不同的文件系统在linux 下的表现都是一样的,这就是VFS的效果, Linux内核在各种不同的filesystem格式上做了一个抽象层,使得inode, 目录,等概念成为抽象层的概念

 

Linux的VFS维护了以下几个数据结构, 达到抽象文件系统的效果

  • super_block
  • inode
  • dentry
  • file

对于每一个打开的文件(FILE结构体)都指向一个dentry结构体, 每一个dentry又指向一个inode, inode指向super_block,对于不同的文件系统, 他们inode结构体内的 inode_operation 还有 file内的file_operation可能指向不同的操作, 而这些对于使用的用户(即我们开发者) 在使用系统调用的时候, 是透明的, 我们只要使用某个操作函数(如stat)然后内核自动会去调用正确的函数将正确的信息返回给用户

 

文件的引用数,dup&dup2

我们用 open(2)打开的同一个文件享有不同的两个file结构体, 他们保存有不同的文件打开标志(FLAG)和读写位置, 而dup dup2这两个函数, 则提供了可以让两个不同的文件描述符指向同一个file结构体这样的功能 , 如果某一个file结构体被多于一个fd指向, 那么, 这时候, file结构体的引用数就是一个关键的属性了, 引用数表明, 该file被多少个fd指向(引用),当用close关闭这个文件的一个fd的时候, 只要file的引用数不为0, 这个file结构体不会被释放. dup是直接复制一个新的文件描述符出来, 不能指定新fd的值, 而dup2可以指定新的fd的值,如果该fd被打开,则先关闭该新fd,再重新dup它