Browsed by
分类:gcc

[科普向?] 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

 

今天就到这里啦

[C Linux内核] 文件与I/O

[C Linux内核] 文件与I/O

系统调用 Hello world

先看一个例子,利用系统调用sys_write用汇编实现的向stdout输出hello world

将_start作为 ELF linker or loader的 外部可以使用的符号(通过 .global) 并且在.data段内定义一个标号msg,代表字符串Hello world 的首地址,  定义len的时候用到的 . 是一个用来代替当前段内地址(在每一个段开头, “.” 的值都会初始化回0)因而, len的值就是当前地址减去msg的首地址, 换句话说,就是 字符串常量”Hello world\n”长度, 上面的代码进行了两次系统调用, 第一次, 是调用sys_write 第二次是调用 sys_exit, 第一次传递的参数有三个, 代码注释已经写明了

将上面的代码 汇编,链接, 运行

即可得到我们想要的结果并且正常退出

上述的汇编代码, 在C语言中的实现是这样的

调用系统调用 write之后,再调用_exit退出 ,下面对C语言内提供的内核 I/O操作函数进行总结

Read More Read More

C语言可变参数函数实现 & 分析

C语言可变参数函数实现 & 分析

先给出一个样例程序, 实现一个打印每一个传给它的元素并且换行

上面的代码已经很清晰了, 下面简单说明一下如何编写一个可变参数函数

1.定义函数, 注意参数列表的第一个参数一定要有,就算没有用到也不能为空, 而且必须有名字, 这个是提供给va_start作为参数的

2.在自定义的函数定义 va_list 型变量(ap), 作为一会儿使用的指向变参的指针

3.然后使用 va_start将 (ap)指向 参数列表中的第一个元素

4.然后使用 va_arg,获取 “…” 中的参数, 每使用一次, 都会获取下一个参数的值

5.最后使用 va_end, C标准要求在函数调用结束前使用va_end

变参的原理通过函数调用的原理可以解释通顺

变参实际上就是在函数调用栈帧上压入不确定个数个参数,a

然后一个指针从第一个参数一直移动到最后一个, (注意内存对齐) 即可

压栈的汇编代码如下:

这里是将参数压栈的代码

待解决问题: 如果用格式化字符串+可变参数中出现了 char 型变量的话, 这个变量要作为int型在va_arg里接受,而不是char, 不然会编译报警告, 并且不能运行目标文件

编译警告:

运行错误:

[1] 14258 illegal hardware instruction (core dumped) ./a.out

 

ELF 文件格式初步探索

ELF 文件格式初步探索

在Linux下编译一个汇编程序需要的工具为 as(汇编器) ld(链接器)

e.g: 一个非常简单的AT&T汇编程序

.section .globl 这种叫做 伪操作, .section将代码分为若干段

_start 是 symbol(符号)  符号在汇编程序中代表一个地址 .globl 表示一个符号会被链接器使用 as –32 将 汇编代码编译为 relocatable object file elf32_i386

 

解读ELF 文件格式

对于汇编 , 链接器来看 , elf文件是由 Section Header Table 描述的一系列Section 集合, 而执行一个ELF文件的时候, 在loader看来 它是由 Programer Header Table 描述的一系列Segment的集合

对于程序运行的时候 Program Header Table 是必须

程序链接的时候 Section Header Table 是必须的

对于一个在运行的时候动态链接的程序, Program Header Table 和 Section Header Table 都是需要的, 因为程序既要加载 也要动态链接

ELF文件可以简单分为以下部分:(通过readelf 打印出来的 一个汇编程序的 结构)

汇编代码如下:

 

ELF 文件由ELF Header 和各个Section组成 上面给出的文件是一个 *.o 文件的 ELF Header , 通过ELF Header 可以计算出Section Header 在文件中的位置

上面的内容是Section Header Table中含有的各个段的信息, 因为这是一个 Relocatable Object File(*.o) 还没有被链接,  因此所有的加载到内存中的地址都是 0 , 这里的Offset表示每个Section在 ELF文件中的位置, Size 为每个Section的大小, 通过这些信息,可以将ELF文件的结构做出来 如图所示:

 

通过hexdump -C 可以查看整个二进制文件的每一位内容, 有了上面的结构图, 可以很容易的和二进制内容对应起来

 

注意, .bss段中没有任何信息, 在Section 中只占一个Section Header , 没有对应的Section, .shstrtab 用来保存各个Section的名字 .strtab 保存程序中的符号的名字  .rel.text 提供重定位需要的信息, 告诉linker 哪些地方要重定位 .symtab是符号表  Ndx表示这个符号在哪个Section内 Section的编号从 Section Header Table里查到 

.text 段保存的就是程序的汇编代码了

Executable ELF Format

上面我们说的都是Relocatable Object File 而不是 Executable File  我们下面来看一下 Object File 在链接之后成为Executable 有什么区别

我们将刚刚的 max.o 编译为 a.out 看ELF Header

这个Header 和 之前的Header没有太大区别 , Type 变为了EXEC, 并且多了Program Header, 下面我们看一下Section Header

对比前面的可以发现, section header少了一些 , .rel.text .bss 没有了, .rel.text只是用于链接的信息, 链接之后就没有用了 (如果使用 strip 会将符号表symtab 以及 保存符号的Section .strtab也删除, 只保留 .data 和 .shstrtab)

下面看一下多出来的Program Header

两个Program Header 一个是从 0x00 -> 0x97 (通过查看该Program 的Section Header Table 能够看出来 这部分内容就是ELF Header 加上 .text的内容 (根据Size和 Offset来看), 这个被加载到了0x08048000 这个虚拟地址处 (物理地址无意义) 而虽然0x08048000-0x08049097 的空间比 0x00 — 0x97占的空间要大, 但是第二个Segment却没有加载到紧接着它0x08048097之后, 原因是  MMU 的权限保护机制 权限保护是以页面为单位的,也就是说,每一个页面只能设置一种保护权限, 而 .text 以及 ELF Header 的权限是 Read Execute 而 .data段的权限是Read Write 二者权限不同, 应该放在不同的页面内, 内存每一个页面的大小是由Align列表示的, 在x86平台一页为 4K 大小(0x1000) 另外要注意的就是, 每个Segment在文件内的Offset多少,加载到页面内的时候, 要有相同的Offset, 所以第二个Segment加载到了 0x08049097 而不是 0x08049000

以上就是对ELF文件格式的一个简单的解读

 

 

Calling Conventions 调用惯例

Calling Conventions 调用惯例

摘自维基, 暂时先搬运, 以后会加入自己的理解(又挖坑了 QWQ)

调用惯例涉及以下几点:

1. 函数参数的空间分配顺序

2. 这些参数存储在什么地方(是全部放在栈上,还是部分放在栈上,部分放在寄存器里,还是全部放在寄存器里)

3.被调用的函数需要保留哪些寄存器给调用他的函数

4.调用函数的时候, 保护现场, 恢复现场的顺序.

 __cdecl:
1> 参数按从右到左的顺序传递,放于栈中
2> 栈的清空由主调函数完成

 __stdcall:
    1> 参数按从右到左的顺序传递,放于栈中
2> 栈的清空由被调函数完成

 __fastcall:
1> 前两个参数要求不超过32bits,分别放入ECX和EDX,其余参数按从右到左的顺序传递,放于栈中
2> 参数由被调函数弹出栈

Thiscall,仅用于C++中类的成员函数:
1> 参数按从右到左的顺序传递,放于栈中。this放于ECX中
2> 栈的清空有被调函数完成

下面通过对cdecl 和 stdcall Calling convention 的区别的研究, 学习calling convention的相关知识

cdecl 是 C语言默认的调用惯例 , 也就是说, 如果你什么不加, default为cdecl ,这个调用惯例 , 要求caller清理堆栈, callee不用清理堆栈 , 用cdecl可以实现可变参数的函数, 另一种方式 stdcall, 是由 callee清理堆栈 , caller不负责堆栈的清理 ,下面是两个不同的调用的C代码及汇编   (注意, 在gcc中 想指定Calling Convention 需要用这样的形式 , 以 stdcall为例: __attribute__((__stdcall__)) , 另外注意, 想要看到Calling Convention的效果要将代码编译为 32位的程序而不是64位 , 提供一个编译指令  gcc -m32 -g -O0 xxx.c -o xxx.out 

call.h

stdcall.c

stdcall.s(part)

这里

显示出来了, 是由 add函数清理的堆栈 : ret $0x8 表明了是callee (即 add函数) 清理了堆栈,  在主函数没有清理堆栈 ,

另外一段代码, 只把这个代码里的STDCALL换成CDECL, 然后得到的汇编代码如下:

这里

是在主函数对堆栈进行清理的

那么现在就可以猜测,如果我在调用一个函数的时候使用不同的Calling Convention会导致程序异常么? 通过cdecl 和stdcall的对堆栈的处理, 做出如下猜想:

如果caller 用STDCALL的形式调用 callee 声明为CDECL的形式, 那么由于STDCALL将清理堆栈的任务交给callee, CDECL将清理堆栈的任务交给caller, 那么就导致 caller 和 callee谁都没有清理堆栈, 反过来 ,如果caller是CDECL callee 是 STDCALL 那么这样, 就导致 caller 和 callee 都对堆栈进行了清理, 也就是堆栈被重复释放了两次 下面通过代码来验证这个猜想

* PS: 这里如何实现 Convention的混合使用,有一定技巧,我采用的方式是函数指针, 先定义一个函数A, 用ConventionA ,再定义一个函数指针B指向A, 不过B的Convention和A的不同 代码如下:

sample: caller is CDECL callee is STDCALL

可以看出, 我们定义了函数add 并且声明了函数指针 fun指向add, 然后在main里调用了fun, 为了保证结果的直观性, 这里使用了内联汇编,  将esp ebp在函数调用前后的值都保存下来了, 作为对比 内联汇编的格式可以在这里找到 https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html 然后, 运行程序, 观察结果, 因为每次程序初始化的时候esp, ebp指向的地址有所不同, 但是调用前后, esp位置应该是相同的, 运行程序得到的一个可能结果如下

注意看, 这里nowesp 和 preesp 差了8 , 这个意味什么稍后再说, 我们再将CDECL 和 STDCALL的位置互换 , 重新编译运行 得到的结果如下:

我们知道 在IA32程序中, 栈的增长是向低地址增长的, 也就是说, 压栈会导致esp减小, 那么 我们来解释一下之前的现象, 在第一次试验中, esp最终增加了 8 也就意味着, esp被多清理了一次, caller清理好堆栈之后, callee又清理了一次, 导致esp最后”错位”了8个字节, 而第二次试验, esp最后减少了8, 这就是二者都没有清理堆栈的后果, 栈内还存着传过去的参数的信息, 这样两周情况, 都可能导致程序崩溃, 也就是ABI不兼容导致的程序崩溃

给add函数增加一个参数int c 然后修改下面的函数指针和函数调用之后, 再次运行代码, 发现esp最后差12, 也进一步说明了, 这个esp的异常值是因为没有清理堆栈内的参数或者二次清理堆栈内的参数表导致的

另外 如果将程序里两个Calling Convention替换成一样的, 不论是CDECL还是都是STDCALL, 那么 esp在调用前后都是保持不变的,

不过之前写的代码都没有引起程序崩溃, 下面这段代码做到了使程序崩溃, 然后之要将 Calling Convention改为一样的, 就避免掉了崩溃 , 这段代码为什么会让它崩溃的原因还在研究, 个人猜测是因为: 循环中的这个计数变量被存在了栈内, 因此由于每次调用函数栈都会错位, 最后这个i就出问题了