包含标签 OS 的文章

c语言补充说明

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 3-1 保护模式 3-2 内存分页 3-3 保护模式补充 4-1 引入c语言 稍有经验的同学肯定知道,编译得到的二进制文件中有text, bss, data, rodata等段。我们今天试着剖析一下这些概念。 segment & section segment和section在中文某些场景下都被翻译成段,很容易搞混。我们今天这里不再翻译,直接使用这两个词。 代码经编译器编译链接后得到ELF文件,操作系统将ELF文件加载至内存中并运行。这里涉及到2个过程,链接与装载。 代码编译得到可重定位文件(.o)后,链接器(ld)对可重定位文件中的符号进行地址重定位,并最终得到可执行文件。 操作系统将可执行文件通过分页机制加载至内存之后执行。加载时,不同的页对应的属性(访问权限)不同,比如代码部分是只读可执行的,其它段是可写的等。 ELF文件作为2个过程的桥梁,就存在链接视图(Linking View)和执行视图(Execution View)两种视图。其中,section对应链接过程,segment对应装载(运行)过程。 链接时,根据不同的功能,ELF文件会划分位多个section。装载时,操作系统以页(4K)为单位进行,如果不足一个页,也要进行对齐。这时如果有多个section,每个section占用1个页,几乎每个section都会在对齐时导致一部分内存空间浪费。而对于操作系统来说,只关注每个page的读写等属性。所以操作系统就将相同读写书写的section对应为一个segment进行加载。 readelf -l可以查看section与segment对应关系: ELF文件格式 ELF header ELF header格式是固定的,在/usr/include/elf.h中有定义: /* The ELF file header. This appears at the start of every ELF file. */ #define EI_NIDENT (16) typedef struct { unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */ Elf32_Half e_type; /* Object file type */ Elf32_Half e_machine; /* Architecture */ Elf32_Word e_version; /* Object file version */ Elf32_Addr e_entry; /* Entry point virtual address */ Elf32_Off e_phoff; /* Program header table file offset */ Elf32_Off e_shoff; /* Section header table file offset */ Elf32_Word e_flags; /* Processor-specific flags */ Elf32_Half e_ehsize; /* ELF header size in bytes */ Elf32_Half e_phentsize; /* Program header table entry size */ Elf32_Half e_phnum; /* Program header table entry count */ Elf32_Half e_shentsize; /* Section header table entry size */ Elf32_Half e_shnum; /* Section header table entry count */ Elf32_Half e_shstrndx; /* Section header string table index */ } Elf32_Ehdr; e_entry就是程序总入口地址 e_phoff对应程序头表(Program header table)在文件中的偏移量 e_phentsize程序头表中每个条目(entry)的大小 e_phnum程序头表中条目数量,对应segment个数 Program header table 程序头表(Program header table)保存运行视图中的segment信息。……

阅读全文

中断

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 3-1 保护模式 3-2 分页 3-3 保护模式补充 4-1 c语言 4-2 c语言补充 ……

阅读全文

引入c语言

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 3-1 保护模式 3-2 内存分页 3-3 保护模式补充 编译过程 前面我们都是使用汇编进行编程,从这节开始引入c语言进行编写。我们知道C语言相对汇编语言,属于高级语言(^_^)。高级语言是需要编译成机器语言才可以执行的。为了让编译器编译得到的结果能够方便的在操作系统上执行,操作系统定义了二进制可执行文件的格式。linux上使用的就是ELF文件格式。 我们这次只是开发操作系统,没有做自己的C编译器,还是要使用gcc等编译器。所以我们的操作系统就要去兼容gcc编译得到的ELF文件结果。历史上,gcc也是在linux操作系统之前被开发出来的。 #include <stdio.h> int main() { printf("hello world\n"); return 0; } 我们用hello world走一遍这个流程。 我的环境是64位的,我们要编译32位的结果,所以中间会有一些特殊的编译选项。为了省事儿,也可以搭建32位的开发编译环境。 预处理 gcc -E hello.c -o hello.i 我们可以看到,7行的hello.c源文件经过预处理后,得到了733行的hello.i的文本文件。 编译器 gcc -S hello.i -o hello.s -m32 编译后得到了hello.s的汇编代码。这里,-m32是为了指定得到32位的结果,否则一些寄存器长度会错误。 汇编器 gcc -c hello.s -o hello.o -m32 经汇编之后,得到的是ELF 32-bit LSB relocatable 文件。 链接 ld -m elf_i386 hello.o -lc -e main 链接之后,得到的就是ELF 32-bit LSB executable可执行文件。……

阅读全文

保护模式补充

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 3-1 保护模式 3-2 内存分页 保护 前面讲过,保护模式的保护,是指CPU在进行内存跳转、访问时,增加了很多检查,确保内存访问安全。在分段和分页机制中,我们也看到了,段选择子,段描述符等都有很多的权限相关的标记位,CPU就是根据这些标记位及一系列访问规则判断是否有权限访问的,即进行内存保护。 数据段&代码段 段描述符中的S位(44位),表明段描述符的类型: S=0, 系统段/门描述符 S=1, 数据段/代码段 段描述符中的TYPE位(40~43位),表明段描述符的类型。一个段可能为: TYPE值 数据段/代码段 系统段/门描述符 0 只读 未定义 1 只读,已访问 可用286 TSS 2 读/写 LDT 3 读/写,已访问 忙的286 TSS 4 只读,向下扩展 286 调用门 5 只读,向下扩展,已访问 任务门 6 读/写,向下扩展 286 中断门 7 读/写,向下扩展,已访问 286 陷阱门 8 只执行 未定义 9 只执行、已访问 可用386 TSS A 执行/读 未定义 B 执行/读、已访问 忙的386 TSS C 只执行、一致代码 386 调用门 D 只执行、一致代码、已访问 未定义 E 执行/读、一致代码 286 中断门 F 执行/读、一致代码、已访问 386 陷阱门 从这么多种类型定义中,可以预期整个检查规则很复杂(@_@)。其中门描述符(gate)用于中断发生时的检查,CPU确认特权级等设定符合约束后,才能进入相应的处理程序。……

阅读全文

内存分页

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 3-1 保护模式 逻辑地址,线性地址,物理地址 程序编译后,程序内部使用的地址是段内偏移量,这个地址是逻辑地址。 程序加载运行时,操作系统负责多个程序的调度。程序使用的段内偏移量(逻辑地址),加上段内偏移量,就是线性地址。 如果没有分页机制,那线性地址=物理地址。启用分页机制之后,线性地址通过转换,才能对应为物理地址。 因此,逻辑地址经过分段机制转换后,得到线性地址;再经过分页机制转换后,得到物理地址。 分页 为什么要分页 段的长度不定,在分配内存时,可能会发生内存中的空闲区域小于要加载的段,或者空闲区域远远大于要加载的段。在前一种情况下,需要另外寻找合适的空闲区域;在后一种情况下,分配会成功,但太过于浪费。为了解决这个问题,从386开始,引入了分页机制。分页功能从总体上来说,是用长度固定的页来代替长度不一定的段,藉此解决因段长度不同而带来的内存空间管理问题。 页框,页表,页目录表 操作系统将线性地址按固定大小组织,每一份称之为一页(page)。页大小可以为4K,1M等,一般为4KB。物理地址按照同样大小进行划分,称为页框(frame)。操作系统需要记录线性地址与物理地址的映射关系,这个关系就记录在页表(page table)中。页表中的每一项称为PTE。 实际中,为了节省内存,会使用多级页表,即为页表再创建一个页目录表(page direcotry table)。页目录表中每的一项,称之为PDE,都指向不同的页表。 分页寻址 逻辑地址经过分段后,得到32位的线性地址。启用分页机制后,32位的线性地址会再次划分为3部分,分别表示 22~31位:指向页目录表中的某一项PDE 12~21位:指向具体某个页表中的某一项 0~11位:页内偏移量 假设我们采用2级页表,我们可以计算一下: 每个页大小4KB,12位的业内偏移量恰好可以寻址4KB偏移地址。 22~31位共10位,最多可以表示1024个不同值,因此页目录表最多只能有1024个PDE 同理,12~21位共10位,对应页表最多页也只能有1024个PTE 1024个PDE * 1024个PTE * 4KB = 4GB,分页之后能够寻址的最大空间还是4GB 启用分页机制 启用分页机制,需要进行如下3步操作: 创建页表 将cr3寄存器设置为页目录表地址 将cr0寄存器pg位置1 内存布局设计 现在的内存布局是我从其它地方抄来的。 截图中显示了页目录表,及第1页表。 页目录表中的第0项及第768项,均对应第1页表。 页目录表中第1023项(即最后一项),对应页目录表本身。 第1页表,只填写了0~255项,对应1M(4K*256)物理内存。 页目录表第0项表示线性地址最开端的4M地址,页目录表第768项对应线性地址3G开始的4M内存。均对应第1页表。当前我们在第1页表只填充了1M内存。所以线性地址开始的1M内存(0~1M)与3G开始的1M内存(3G~3G+1M),对应同一块物理内存。 这样划分,是因为之前所有的代码都是在线性地址的开始(0~1M)内存实现的。但我们会将4G内存空间划分为用户空间(0~3G)和内核空间(3G~4G)。通过将页目录表第0项与第768项指向同一块内存,则可以在使用线性地址3G以上的内核空间地址继续访问之前的1M内存。 测试 测试方法和以前相同,通过在屏幕打日志的方式判断是否执行成功。……

阅读全文

保护模式

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 2-2 确认软盘内容执行 CPU保护模式 我们知道,操作系统是对计算机各种硬件资源进行管理的,通过提供各种API,方便各种应用使用计算机硬件完成各自功能。因此,开发操作系统需要受硬件实现限制,也可以理解为按照硬件接口约定调用硬件能力。比如启动扇区的最后两个字节必须是0xAA 0x55,这就是一种约定,不这样做就没法正常启动。对于386指令集的CPU,这样的硬件接口约定和指导汇总就是《Intel 80386 程序员参考手册》。 随着CPU功能的日益强大,这种硬件上的约定越来越多,其中很多还是特定历史原因造成的,或为了保持兼容性而设计的。这就导致我们需要了解的细节很多,而且不会很直观。保护模式就是实现操作系统过程中将会遇到的第一座大山,因为我们要借助CPU硬件能力实现。 实模式&保护模式 我们前面访问内存是采用的[CS:IP]=CS<<4+IP的方式就是实模式下的内存寻址方式。Intel 8086 CPU只有16位,到了80386时代(286存在时间很短),CPU进入了32位,这时再也不能采用之前8086 16位CPU的寻址方式了。同时,Intel在硬件设计中加入了很多内存访问检查等保护机制。为了与之前的CPU特性区分,Intel将8086对应的16位运行模式称为实模式,将386及之后的x86CPU对应的32位运行模式称为保护模式。 因为x86CPU是兼容之前8086CPU的,而且x86CPU启动后默认运行在实模式下。这也是为什么我们前面的代码放在最新的x86CPU上也可以运行的很happy的原因。 保护模式寻址方式 保护模式下,CPU分段寻址方式与实模式不再相同。操作系统将内存进行分段,并将分段信息以数组的形式存储在内存中。数组的每一项对应一个分段信息,称为段描述符(Segment Descriptor),这个数组被称之为段描述符表。全局有一个总的段描述符表,称为全局段描述符表(GDT),当然,也有局部段描述符表(LDT)。 可以想想,所有的内存寻址都需要通过GDT表进行,为了加速内存寻址,CPU提供了GDTR寄存器用于存储GDT对应的内存起始地址。同时,CPU提供了LGDT指令用于将GDT的内存起始地址加载到GDTR寄存器中。 有了全局段描述符表GDT,段寄存器中的值就不再像实模式下那样,是段的起始地址了。而是用来筛选出GDT表中对应的某一项的,称之为段选择子(Segment selector)。 这样,保护模式下的寻址方式大概如下: 操作系统开发者在内存中按格式创建出GDT 操作系统启动后通过LGDT指令将内存中GDT的起始地址加载到GDTR中 寻址时,[CS:IP]中,段寄存器的值被CPU理解为段选择子,通过CPU某些运算检查,从GDTR中找出某一项段描述符 段描述符通过CPU某些运算检查,得到段基址,加上偏移地址,得到最终地址结果 怎么样,被这些概念绕晕了吧? ^_^ 保护 也许你会好奇,第3步的段选择子不应该就是一个数组下标吗?第4步中的段描述符不就是一个内存起始地址吗?如果让我来设计CPU,我也会这么干。但历史上,从8086到80386发展过程中,也许发生了很多因为内存访问导致的bug,导致Intel决定,在CPU中设计一套内存访问控制,权限检查机制。 因此,第3步段选择子除了数组下表的功能外,还存储了一些其它用于内存访问检查的信息,第4步段描述符除了存储内存起始地址,也存储了其它对应信息。这些信息在内存访问时,CPU会按照设计规则对齐进行检查,防止非法访问。这也就是保护模式中所谓的保护的意思。 数据结构 段描述符 段选择子 GDTR寄存器 从实模式到保护模式 从实模式切换到保护模式,要进行如下3步操作,这些操作都是Intel CPU要求的。 打开A20 Gate 加载GDT 将CR0的PE位置1 A20 Gate历史背景 8086下,CPU是16位的,地址总线20位,对应1M内存。当程序访问大于1M的地址时,系统会贴心的对地址按1M求模。这个技术称为wrap-around。 到了保护模式,我们需要访问大于1M的内存,需要关闭wrap-around机制。而这个机制,因为历史原因,被键盘控制器上的第21根地址线(A20 Gate)控制。当打开A20 Gate时,才能突破1M的内存限制。 创建并记载GDT 我们将内存空间划分为3个段。根据规定,第1个段必须为空。因为GDT中4项如下: ;0描述符 dd 0x00000000 dd 0x00000000 ;1描述符(4GB代码段描述符) dd 0x0000ffff dd 0x00cf9800 ;2描述符(4GB数据段描述符) dd 0x0000ffff dd 0x00cf9200 ;3描述符(28Kb的视频段描述符) dd 0x80000007 dd 0x00c0920b lgdt_value: dw $-gdt-1 ;高16位表示表的最后一个字节的偏移(表的大小-1) dd gdt ;低32位表示起始位置(GDT的物理地址) 跳转至32位模式 protect_mode: ;进入32位 lgdt [lgdt_value] in al, 0x92 or al, 0000_0010b out 0x92, al cli mov eax, cr0 or eax, 1 mov cr0, eax jmp dword SELECTOR_CODE:main [bits 32] ;正式进入32位 main: 测试 64位视频段描述符,我们展开后如下图:……

阅读全文

确认软盘内容执行

1-1 从上电到引导扇区 1-2 启动补充说明 2-1 读取并执行软盘内容 上一节我们根据内存地址推算,确定0xc400位置保存着我们存储在软盘中的sys.bin内容,并跳转到这个地址位置开始执行。但我们如何确认代码正确执行了呢? hlt指令 CPU负载 当前在sys.bin中只有一个死循环,如果程序中出现一个死循环,那会将所有空闲CPU资源占满,即运行死循环的CPU负载会飙到100%。在真实机器上这点可能不好确认,但如果在虚拟机中运行操作系统,我们可以很容易的在宿主机上观察到这点。 hlt指令 当前我们的OS还没有任何实质性业务,就这样空跑CPU比较浪费。hlt指令就是为了解决这个问题的,它可以使CPU进入暂停状态,不执行任何操作。 我们修改sys.asm代码如下,在死循环中插入hlt指令。 org 0xc400 fin: hlt jmp fin 再试试效果: bochs Debug 我们使用bochs虚拟机来运行我们写的OS,bochs提供了很多调试功能,可以看到cpu寄存器,内存等实时的数据。这样可以更直观的看到我们的OS运行情况。 这里我们用到两个调试命令: 打断点 b 0xc400 反编译内存内容 u /10 使用b在0xc400处设置断点,运行后断点命中: 命中断点后,使用反编译指令u反编译0xc400开始的2行代码,可以看到,与我们写的内容基本一致。 跳转指令不完全一致,但可以猜到是给予当前行$-3位置处。 添加打印 我们也可以通过在sys.asm调用BIOS的打印中断int 10h来往屏幕写内容,确认代码是否执行成功。 mov ax, 0xb800 mov gs, ax mov byte [gs:0xa0],'s' mov byte [gs:0xa2],'y' mov byte [gs:0xa4],'s' 可以看到,sys.asm中的打印代码成功输出了,因此可以确认sys.asm已经被正确执行了。……

阅读全文

读取并执行软盘内容

1-1 从上电到引导扇区 1-2 启动补充说明 根据前面的内容我们知道,上电之后,根据CPU硬件电路设计,会自动将软盘第1扇区加载并执行。如果我们实现的OS只需要使用512个字节的代码,那就不需要主动读取软盘,只依靠CPU的硬件设计自动读取第1扇区就可以了。但这显然不可能。 所以接下来,我们就依靠第1扇区这512个字节的代码量,读取更多的软盘内容。 读取软盘内容至内存 我们将自己写的操作系统的代码编译成二进制文件后,存储在软盘上。然后利用第1个扇区的代码(开机后被BIOS自动加载至0x7c00处并开始执行),将软盘上的内容拷贝到内存中。拷贝完成后,再跳转到我们拷贝的目的内存地址,开始执行。 软盘的结构 上图来自《30天自制操作系统》 从上图中,我们可以看到,软盘有一圈有18个扇区Sector,从外向内分为80个柱面Cylinder,正反两面分别对应不同的磁头Heads来读写,这称之为CHS寻址模式,对应三部分的首字母。前面提到每个扇区可以存储512个字节,所以整个软盘共可以存储512*80*18*2=1440KB 读取1个扇区 通过调用BIOS int 13中断,可以读取软盘数据,调用代码如下: MOV AX, 0x0820 ; 内存目的地址 0x08200 MOV ES, AX MOV CH, 0 ; 柱面0 MOV DH, 0 ; 磁头0 MOV CL, 2 ; 扇区2 MOV AH, 0x02 ; AH=0x02 : 读盘 MOV AL, 1 ; 1个扇区 MOV BX, 0 MOV DL, 0x00 ; A驱动器 INT 0x13 ; 调用BIOS int 13中断 读取10个柱面 如果上面的代码封装为一个函数,那读取整个软盘的逻辑用c语言描述大约如下:……

阅读全文

启动补充说明

1-1 从上电到引导扇区 1-2 启动补充说明 清理屏幕 上一节我们通过在显存地址直接写入字符,打印除了“hello”。但因为显存内存储着BIOS启动过程中的一些显示信息,所以屏幕看着很乱。这一节我们在打印字符之前,先讲显存内的内容清空,再进行打印,这样看着清爽些。 清空显存,当然有个笨办法,就是像打印“hello”一样,从0xb8000开始,向显存写入空白或不可见字符。但这个比较累。还有一个办法就是调用bios提供的int 10中断。 int10中断 org 07c00h ; 告诉编译器程序加载到7c00处 ;----中断10h,卷屏,实现清屏 mov ax, 0x0600 mov bx, 0x0700 mov cx, 0 mov dx, 0x184f int 0x10 ;----直接往显存中写数据 mov ax, 0xb800 mov gs, ax mov byte [gs:0x00],'h' mov byte [gs:0x02],'e' mov byte [gs:0x04],'l' mov byte [gs:0x06],'l' mov byte [gs:0x08],'o' times 510-($-$$) db 0 ; 填充剩下的空间,使生成的二进制代码恰好为512字节 dw 0xaa55 ; 结束标志 以上代码中,我们先设置好参数,然后调用int 10中断,实现了清屏操作。效果如下: 关于INT 10h的更多细节,可以看这里 wikipedia INT 10h。……

阅读全文

从上电到引导扇区

1-1 从上电到引导扇区 1-2 启动补充说明 上电 我们知道,现在使用的计算机都是存储程序计算机(冯·诺依曼计算机)。计算机运行时一边从内存中读取指令,一边执行指令。进一步,可能也知道对于8086 CPU,段寄存器 CS、指令寄存器 IP共同构成的地址[CS:IP]指向当前执行的指令。 但计算机上电开机之后,CS与IP中内容均为空,其它寄存器、内存的内容也为空。类似牛顿力学体系中的第一推动力一样,计算机上电后第一步该如何开始呢?牛顿给的答案是上帝提供了第一推动力。类似的,计算机启动第一步也只能通过体系外的某种方式解决。 上电后执行软件前只能靠硬件。CPU的硬件设计为上电后默认将CS置为0xF000, IP置为0xFFF0,即上电后默认执行的第一条指令位于[CS:IP]=0xFFFF0处的。而这个地址处存储的是BIOS。 BIOS 我们都知道计算机启动后会运行BIOS(basic input output system),进一步可能也知道BIOS存储在ROM中,当然也知道相对RAM,ROM掉电后信息不会丢失。计算机上电后,硬件将指令地址置为BIOS所在的0xFFFF0处,开始执行BIOS内容。 BIOS会先进行加电自检(POST, Power-On Self-Test),测试各项硬件是否正常等。 加电自检小插曲 我前一阵(2019年底)组装了一台AMD的电脑(AMD, YES!!!)。不考虑打游戏,只用来沉迷于学习,所以预算里没有考虑显卡。等所有零件到了之后迫不及待装好之后上电,然后显示器黑屏,主板上有一个标有VGA的LED红灯常亮。 想了一小会立即意识到,现在Intel的CPU基本都带了核显(大多数人觉得很鸡肋),但AMD的CPU并没有。BIOS加电自检挂这里过不去了。 后来没办法,买了一张二手的GTX960作为亮机卡。 BIOS中断 BIOS还会在内存中起始的4K处0x00000-0x003FF建立中断向量表,以及位于0x0E05B-0x0FFFE处对应的中断服务程序。 BIOS提供的中断服务可以查阅网上资料,参考资料中也提供了一份。我们会用到如下几个: 中断号 描述 int 10h 显示服务 int 13h 低级磁盘服务 int 19h 加电自检后载入操作系统 调用BIOS中断时,可能需要设置一些参数。比如int 10h中断用来在屏幕上显示信息,就需要指定显示的字符串内容,显示的颜色,位置等信息。中断信息如何设置,BIOS对此都有详细的说明。 加载bootsec BIOS加电自检后使用int 19h中断将软盘中的第一个扇区(512 byte)中的内容复制到内存0x7C00h处,然后开始执行。在Linux中,第1个扇区的内容被称为bootsec。 对于OS来说,我们可以认为计算机加电后直到将bootsec中的内容载入内存并开始执行,这些都是自动完成的,我们只需要关注从bootsec代码开始执行之后的部分就可以了。 bootsec之后的事情我们也可以大致设想一下:依靠bootsec这512个字节的代码,我们会继续读取软盘上更多的代码,然后依赖读入的代码完成更多的工作,直至整个系统运行起来。 一个简单的bootsec样例 CPU硬件设计师,BIOS系统开发等费了九牛二虎之力,终于在上电之后把OS自身最开始的512个字节的bootsec给运行起来了。作为“全村的希望”,当然是希望我们再接再励把OS运行起来的。但这里,我们不管这么多,先写一个hello world再说吧。 org 07c00h ; 告诉编译器程序加载到7c00处 ;----直接往显存中写数据 mov ax, 0xb800 mov gs, ax mov byte [gs:0x00],'h' mov byte [gs:0x02],'e' mov byte [gs:0x04],'l' mov byte [gs:0x06],'l' mov byte [gs:0x08],'o' times 510-($-$$) db 0 ; 填充剩下的空间,使生成的二进制代码恰好为512字节 dw 0xaa55 ; 结束标志 上图中,0xb800是显存开始的地址。显存中,每2个字节对应显示字符,第1个字节为字符对应ASCII码,第2个字节为字符对应的颜色。屏幕每行有80个字符,共24行。这里,我们我们只修改了屏幕左上角开始的前5个字符对应的ASCII码,所以在左上角显示出了hello5个字符。……

阅读全文