本篇对应书籍第五章5.3--5.4的内容
本篇章简单介绍了 ELF 文件格式,以及如何将 C 语言程序(内核程序)编译链接拷贝到硬盘上并通过内核加载器 loader 程序加载执行。最后简单介绍了一下特权级
本篇难点:
- ELF文件解析
本篇坑点:
- 在64位Linux系统下编译的32位程序出现多余的section(已解决)
第一个 C 程序
平时写程序,会用到各种库,其中的标准库一般是系统调用的封装,虽然用户程序也可以直接使用系统调用,但效果基本上还是不如直接用标准库来的好
接下来要用C语言来写内核了
C语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言
C语言为什么能写操作系统呢?C语言是怎么控制CPU的呢?
C语言程序的生成过程是,先将源程序编译
成目标文件
(C代码编译成汇编代码,再由汇编代码生成二进制目标文件),再将目标文件
进行链接
成二进制可执行文件
这个里有两个过程:编译、链接,下面以一个简单的C语言程序为例,介绍一下这个过程
main.c:
int main(){
while(1);
return 0;
}
使用 gcc 命令进行编译:
gcc -c -o ../main.o main.c
-c:编译,汇编到目标代码,不进行链接,直接生成目标文件
-o:输出到文件
在这个阶段目标文件中的符号是未编址的(重定位),这个工作是在链接中进行
用 file 命令查看文件信息:
selph@selph-bochs:~/文档/OS_Study/kernel/source$ file ../main.o
../main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
当前文件是ralocatable,可重定位文件,也就是未进行链接(重定位)
用 nm 命令查看符号信息:
selph@selph-bochs:~/文档/OS_Study/kernel/source$ nm ../main.o
0000000000000000 T main
可以看到,程序中的符号(函数名、变量名)的地址尚未确定
使用 ld 命令进行链接,这么链接的好处是可以指定最终生成文件的起始虚拟地址:
ld ../main.o -Ttext 0xc0001500 -e main -o ../main.bin
-Ttext:指定程序的起始虚拟地址
-e:指定程序的起始地址,链接器默认把_start作为入口函数
在使用 gcc 编译的时候,如果没有使用 -c 参数,则会被编译器自动添加其他的运行库的代码,程序会大很多
更改一次代码要使用这么多命令,容易出错,写成脚本更方便使用:
WriteKerneltoDisk.sh:
#!/bin/bash
gcc -c -o ./kernel/main.o ./kernel/source/main.c && ld ./kernel/main.o -Ttext 0xc0001500 -e main -o ./kernel/kernel.bin && dd if=./kernel/kernel.bin of=./hd60M.img bs=512 count=200 seek=9 conv=notrunc
此脚本仅在 32 位 linux ,例如 Ubuntu 16.04 LTS i386 上有用,如果像我一样用的是 64位 Linux(Ubuntu 20.10),则需要使用下文
运行Bochs
小节的脚本
ELF 文件格式
Windows 下是 PE 文件格式,Linux下则是 ELF文件(relocatable 文件、shared object 文件、executable 文件)
程序中最重要的部分是段(segment)和节(section),段是由节组成的,多个节经过链接就合成为段。
段和节的头信息通过一种结构来存储,就是程序头表(段头表)和节头表
ELF在链接时和运行时文件形态不一样:
链接后,程序运行的代码、数据等资源都在段中,本着够用就行的原则,有关节和其他内容的学习这里就不多关注了,参考资料列出了ELF解析的参考链接,那里有较为完整的内容
ELF Header
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[16]; //存储字符信息
Elf32_Half e_type; //目标文件类型
Elf32_Half e_machine; //elf目标体系结构
Elf32_Word e_version; //版本信息
Elf32_Addr e_entry; //操作系统运行程序时,将控制权转交到的虚拟地址
Elf32_Off e_phoff; //程序头表的文件偏移地址
Elf32_Off e_shoff; //节头表的文件偏移地址
Elf32_Word e_flags; //处理器相关标识
Elf32_Half e_ehsize; //elf header 字节大小
Elf32_Half e_phentsize; //程序头表每个条目的字节大小,条目的数据结构是Elf32_Phdr
Elf32_Half e_phnum; //程序头表中条目数量(段数量)
Elf32_Half e_shentsize; //节头表每个条目的字节大小
Elf32_Half e_shnum; //节头表中条目的数量(节数量)
Elf32_Half e_shstrndx; //指明string name table在节头表中的索引index
}Elf32_Ehdr;
e_ident
e_ident是一个16字节大小数组:
e_type
e_machine
Program Header
Elf32_Phdr 类似 GDT 中段描述符的作用,用来描述位于磁盘上的程序的一个段
在ELF_Header中,我们可以得到Program Header的索引地址(e_phoff)、段数量(e_phnum)、表项大小(e_phentsize)。然后我们来看一下Program Header中表项的结构定义:
typedef struct{
Elf32_Word p_type; //此段的作用类型
Elf32_Off p_offset; //本段在文件内的起始偏移字节
Elf32_Addr p_vaddr; //本段在内存中的起始虚拟地址
Elf32_Addr p_paddr; //仅用于与物理地址相关的系统中
Elf32_Word p_filesz;//本段在文件中的大小
Elf32_Word p_memsz; //本段在内存中的大小
Elf32_Word p_flage; //本段相关的标识
Elf32_Word p_align; //本段在文件和内存中的对齐方式,如果是1或0,则不对齐,否则应该是2的整数次幂
} Elf32_phdr;
p_type
取值 | 代表 | 含义 |
---|---|---|
00 | PT_NULL | 此数组元素未用。结构中其他成员都是未定义的。 |
01 | PT_LOAD | 此数组元素给出一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。文件中的字节被映射到内存段开始处。如果 p_memsz 大于 p_filesz,“剩余”的字节要清零。p_filesz 不能大于 p_memsz。可加载的段在程序头部表格中根据 p_vaddr 成员按升序排列。 |
02 | PT_DYNAMIC | 数组元素给出动态链接信息。 |
03 | PT_INTERP | 数组元素给出一个 NULL 结尾的字符串的位置和长度,该字符串将被当作解释器调用。这种段类型仅对与可执行文件有意义(尽管也可能在共享目标文件上发生)。在一个文件中不能出现一次以上。如果存在这种类型的段,它必须在所有可加载段项目的前面。 |
04 | PT_NOTE | 此数组元素给出附加信息的位置和大小。 |
05 | PT_SHLIB | 此段类型被保留,不过语义未指定。包含这种类型的段的程序与 ABI不符。 |
06 | PT_PHDR | 此类型的数组元素如果存在,则给出了程序头部表自身的大小和位置,既包括在文件中也包括在内存中的信息。此类型的段在文件中不能出现一次以上。并且只有程序头部表是程序的内存映像的一部分时才起作用。如果存在此类型段,则必须在所有可加载段项目的前面。 |
0x70000000 | PT_LOPROC | 此范围的类型保留给处理器专用语义。 |
0x7fffffff | PT_HIPROC | 此范围的类型保留给处理器专用语义。 |
p_flage
将内核载入内存
这里回顾一下到目前为止的计算机开机运行的过程:
计算机启动后,运行的第一个程序是BIOS,BIOS检查、初始化硬件之后,将0盘0道1扇区(CHS方式)的主引导记录 MBR 程序加载到内存0x7C00的位置并跳转过去;
MBR(大小:512 字节) 里含有引导记录,可以选择加载指定的系统(多系统情况下),加载则是通过跳转到指定的操作系统的内核加载器程序 loader 进行,从硬盘中读取出 loader 程序到内存中并跳转过去;
内核加载器 loader 程序的工作是将内核 kernel 程序从硬盘中加载到内存中,然后将加载进来的内核程序安置到相应的虚拟内存地址,然后跳转过去执行。
就像接力棒一样,逐个交给下一棒。
到目前为止,我们的硬盘使用情况如下:
- 第 0 扇区:MBR,大小:512字节,占用1个扇区
- 第 2 扇区:loader,大小:1342字节,占用4个扇区
方便起见,kernel 程序就写入 第 9 扇区
到目前为止的使用的内存情况如下:
- 0x000~0x3FF:1K,中断向量表
- 0x400~0x4FF:256B,BIOS
- 0x500~0x7BFF:30K,可用区域
- 0x7C00~0x7DFF:512B,MBR被加载到此处
- 0x7E00~9FBFF:608K,可用区域
程序执行到 loader 后,MBR的使命已经结束了,可以被覆盖抹杀掉了,仿佛 MBR 不曾存在一样
偷个懒,按书上作者提到的使用的地址走,内核文件 kernel.bin 加载到 0x70000 地址上
修改 include/boot.inc
添加如下行
KERNEL_START_SECTOR equ 0x9
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_ENTRY_POINT equ 0xc0001500
PT_NULL equ 0
修改 loader.S 加载内核 1
第一步,加载内核:将 kernel.bin 读取到内存中
在启用分页机制之前,将 kernel.bin 从硬盘中读取到内存中:
;----加载 kernel----
mov eax, KERNEL_START_SECTOR ;kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ;从硬盘读出后写入的地址
mov ecx, 200 ;读入的扇区数
call rd_disk_m_32 ;从硬盘读取文件到内存,上面eax,ebx,ecx是参数
;----启用 分页机制----
;创建页目录和页表并初始化页内存位图
call setup_page
rd_disk_m_32 函数是 MBR 程序中 rd_disk_m_16 函数的 32 位版本,实现原理差不多一样,只是用到的寄存器变成32位的了
把 rd_disk_m_32 程序写好加到 loader.S 的结尾:
;----读取文件到内存----
;参数
;eax :扇区号
;ebx :待读入的地址
;ecx :读入的扇区数
rd_disk_m_32:
mov esi ,eax ;备份eax
mov di ,cx ;备份cx
;读写硬盘
;1---设置要读取的扇区数
mov dx ,0x1f2 ;设置端口号,dx用来存储端口号的,要写入待读入的扇区数量
mov al ,cl
out dx ,al ;读取的扇区数
mov eax ,esi ;恢复eax
;2---将LBA地址存入0x1f3~0x1f6
;LBA 7~0位写入端口0x1f3
mov dx ,0x1f3
out dx ,al
;LBA 15~8位写入端口0x1f4
mov cl ,8
shr eax ,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
mov dx ,0x1f4
out dx ,al
;LBA 24~16位写入端口0x1f5
shr eax ,cl
mov dx ,0x1f5
out dx ,al
shr eax ,cl
and al ,0x0f ;设置lba 24~27位
or al ,0xe0 ;设置7~4位是1110表示LBA模式
mov dx ,0x1f6
out dx ,al
;3---向0x1f7端口写入读命令0x20
mov dx ,0x1f7
mov al ,0x20
out dx ,al
;4---检测硬盘状态
.not_ready:
;同写入命令端口,读取时标示硬盘状态,写入时是命令
nop
in al ,dx
and al ,0x88 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al ,0x08
jnz .not_ready
;5---0x1f0端口读取数据
mov ax ,di ;要读取的扇区数
mov dx ,256 ;一个扇区512字节,一次读取2字节,需要读取256次
mul dx ;结果放在ax里
mov cx ,ax ;要读取的次数
mov dx ,0x1f0
.go_on_read:
in ax, dx
mov [ebx], ax ;bx是要读取到的内存地址
add ebx, 0x02
loop .go_on_read ;循环cx次
ret
修改 loader.S 加载内核 2
第二步,初始化内核,依据ELF规范,将内核文件中的段展开到内存中相应位置
也就是说要找个地方存放内核的映像
内存要尽量往低了选,考虑到 0x900 存放的是 loader,loader 开始部分是 GDT,且 loader 大小不超过 2000 字节,为了不覆盖到 GDT,所以内存选择是 0x900 + 2000 = 0x10d0,取个整数,就是0x1500作为映像的入口地址
初始化代码如下:在开启分页重新加载 gdt 之后,初始化 kernel 并跳转到 kernel 文件的入口
;开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr]
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新 gdt
enter_kernel:
call kernel_init
mov esp, 0xc009f000 ;给栈选个高地址且不影响内存其他位置的地方
jmp KERNEL_ENTRY_POINT
kernel_init 函数:
;----将 kernel.bin 中的 segmeng 拷贝到编译的地址----
;此时,kernel.bin 已经被读取到内存 KERNEL_BIN_BASE_ADDR 位置上了
kernel_init:
xor eax, eax
xor ebx, ebx ;ebx 记录程序头表文件内偏移地址,即e_phoff
xor ecx, ecx ;cx 记录程序头表中的 program header 数量
xor edx, edx ;dx 记录 program header 尺寸,即 e_phentsize
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ;偏移文件 42 字节处是 e_phentsize
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ;偏移文件 28 字节处是 e_phoff,表示第一个程序头在文件的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ;获取程序头表第一个程序头的地址(基地址 + 偏移量)
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ;偏移文件 44 字节处是 e_phnum,表示程序头的数量
.each_segment:
cmp byte [ebx + 0x0], PT_NULL ;若相等,则表示程序头没使用
je .PTNULL
;为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
;参数 size:待复制的大小
push dword [ebx + 16] ;偏移程序头 16 字节处是 p_filesz 本段在文件内的大小
;参数 src:源地址
mov eax, [ebx + 4] ;偏移程序头 4 字节处是 p_offset 本段在文件内的偏移大小
add eax, KERNEL_BIN_BASE_ADDR ;加上基地址 = 物理地址
push eax
;参数 dst:目的地址
push dword [ebx + 8] ;偏移程序头 8 字节处是 p_vaddr 本段在内存中的虚拟地址
call mem_cpy
add esp, 12
.PTNULL:
add ebx, edx ;程序头的地址 + 程序头的大小 = 下一个程序头的地址
loop .each_segment ;复制下一个程序头
ret
mem_cpy 函数:
;----逐字节拷贝 mem_cpy(dst, src, size)---
mem_cpy:
cld ;控制进行字符串操作时esi和edi的递增方式,cld增大,sld减小
push ebp
mov ebp, esp
push ecx ;rep指令用到了ecx,外层指令也用到了ecx,所以备份
mov edi, [ebp + 8] ;dst
mov esi, [ebp + 12] ;src
mov ecx, [ebp + 16] ;size
rep movsb ;逐字节拷贝
;恢复环境
pop ecx
pop ebp
ret
运行 Bochs
【坑】64 位系统编译出来的程序用不了
64位 Linux 编译时出现 .note.gnu.property 段导致 loader程序运行出错
贼坑,太坑了,作者书是2016年3月出版,如今2021年1月,作者当时使用的32位系统进行讲解,如今我用64位系统并不能跟着讲解走,因为这期间linux发生了很多变化,导致现在 64 位 linux 编译出来的程序不能直接用
折腾到大半夜,又折腾完一整个上午+中午+下午,整整一天,终于给解决了
在我当前的Ubuntu 20.10 系统上,就会 CPU 异常,导致程序直接退出,虚拟机直接关机,经过一番一番的调试,我发现问题出在了初始化内核那一块代码那里,我又安装了一个 Ubuntu 16.04 LTS i386 系统的虚拟机,在那个虚拟机上,编译运行一气呵成,没有任何问题
难道是两个系统编译出来的程序还不一样?我用readelf -a
命令查看对比了两边编译出来的程序,还真不一样:
64 位系统编译出来的程序会比 32 位的多很多个程序头,其中有个地址是 0x08048000,经过调试发现,以这个位置为目的地址进行文件内容拷贝的时候会出现异常(页表里就没对这个地址进行映射),按书上的理说,在内核初始化的时候,并没有往 0x08048000 地址写东西,而这个段是什么呢?是下图这个东西:
经过一番搜索资料,了解到这个.note.gnu.property
是 ld 命令链接的时候链接进来的,解决方法如下:
可以把链接完的程序中的这个节直接给删掉,使用strip
命令即可
由于内核文件会经常编译、链接、删节、写入硬盘这一系列操作,所以直接把这套操作写成脚本直接来调用就好了,这里脚本我写好了,WriteKerneltoDisk.sh:
#!/bin/bash
gcc -m32 -c -o ./kernel/main.o ./kernel/source/main.c && \
ld ./kernel/main.o -N -Ttext=0xc0001500 -e main -m elf_i386 -o ./kernel/kernel.bin && \
strip --remove-section=.note.gnu.property ./kernel/kernel.bin && \
dd if=./kernel/kernel.bin of=./hd60M.img bs=512 count=200 seek=9 conv=notrunc
通过将.note.gnu.property
节删掉,导致那几个多出来的程序段的大小为 0,从而不会向 0x08048000 地址写入任何东西。
更新:这个脚本仅在本章适用,后面写库程序的时候,还要把库程序也编译,链接进来
运行结果
运行结果如下:
下一条指令长度为 2 字节,指令内容为跳转到 2 字节之前,也就是死循环,正好与代码中的while(0)
一致,说明程序运行起来了!太不容易了这一章。
特权级深入浅出
特权级分4个等级,0,1,2,3,数字越小特权越高,计算机在启动的时候,是在 0 特权级启动的,操作系统内核也处于 0 特权级,它要直接控制硬件,系统程序分别位于 1 ,2 两个特权级,一般是虚拟机、驱动程序等系统服务,用户程序一般在 3 特权级
代码特权转移的信息存在 TSS 结构中,通过 TSS 获取转移后的栈
代码特权转移的权限存在 CS.RPL(CPL)中,会根据目标段 DPL 权限来判断是否能转移
TSS 简介
TSS,Task State Segment,任务状态段,是一种数据结构,用于存储任务的环境
首先,任务是什么?任务是计算机执行的一个程序,这个程序按特权级划分可以分为用户部分和内核部分两部分,这两部分加起来才是完整的一个程序,完整的任务会经历特权级从用户级到内核级的变化。
任务是由处理器在执行,任务在特权级变换的本质是处理器当前特权级在变化,处理器处于不同特权级下,使用的是不同特权级的栈,每个特权级下都有且仅有一个栈
特权级转移分两种:
- 从低到高转移:可通过中断门、调用门等手段实现
- 从高到低转移:仅能通过调用返回实现
处理器从低特权级向高特权级转移的时候,会去查询当前任务的 TSS 数据结构,获取当前任务目标特权级的栈信息,并跳转过去,在这个过程中,跳转的时候会将当前地址记录下来存到跳转过去的栈中,从高向低转移的时候,也就直接知道如何跳转了,所以 TSS 结构只需要存储3个特权级的信息即可
TSS 是硬件支持的系统数据结构,和 GDT 一样,会被存到一个寄存器里,TSS 由 Task Register 加载
CPL、DPL简介
计算机特权级的标签体现在DPL、CPL、RPL下
CS 段选择子的 0-1 位是 RPL ,称为请求特权级,也叫当前特权级
代码段描述符中的 DPL 是 CPU 当前的特权级 CPL ,表示正在执行的代码段的特权级
当前特权级 CPL 保存在 CS 段寄存器 0-1 位的 RPL 部分,代码请求其他段的内容的时候,会检查其他段描述符中的 DPL,如果目标段的 DPL 等级比当前 CPL 高,则不能访问,如果目标段是代码段的话,则只能同级访问,
如果允许访问了,则会将目标段的 DPL 保存在当前 CS 寄存器的 RPL 里,成为当前的 CPL
之前提到的段描述符的 type 位的 C位,一致性代码段,指的是如果当前段是转移后的目标段,当前的特权级一定大于等于转移的特权级,转移后特权级不进行变化,还是转移前的特权级
代码段才有一致性非一致性区分,数据段可没有
门、调用门
门就像段描述符一样,也是一种描述符结构
四种门的结构:
门的作用:
-
调用门
call和jmp指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。call指令使用调用门可以实现向高特权代码转移,jmp指令使用调用门只能实现向平级代码转移。 -
中断门
以int指令主动发中断的形式实现从低特权向高特权转移,Linux系统调用便用此中断门实现。
-
陷阱门
以int3指令主动发中断的形式实现从低特权向高特权转移,一般是编译器在调试的时候用
-
任务门
任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务 门的选择子或任务 TSS 的选择子。
IO 特权级
IO 读写特权是由标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位图决定的,它们用来指定执行 IO 操作的最小特权级。
特权级深入浅出这一章节实在读不进去,回头有机会单独来学一学特权级的那些事
参考资料
- ELF文件格式解析(完):https://www.52pojie.cn/thread-591986-1-1.html
- 如何防止ld添加.note.gnu.property:https://stackoverflow.com/questions/52084184/how-do-i-prevent-ld-from-adding-note-gnu-property
- 操作系统真象还原第五章:https://blog.csdn.net/zhwenx3/article/details/109153203
- 《操作系统 真象还原》第 5 章 5.3,5.4