发表时间:2022-03-26来源:网络
友情提示:本章内容最好的参考书籍推荐---《程序员的自我修养—链接、装载与库》。
毫无疑问,本章的内容是在不借助任何Windos工具的情况下,如何在Linux下进行操作系统的开发。其实,对于初学者来说,上一章的Windows环境是最好的方式,因为Linux对大多数人来说,都太过陌生了,光在Linux操作系统下进行开发环境的配置都会让你怀疑人生,更别说如何进入C语言内核了。
但是,有人觉得《30天》那本书上的工具太繁琐了,所以问我如果不想用那一套工具,该怎么办?还能怎么办?只能在Linux操作系统下用GCC那一套编译链接工具啊。
另外,由于我们是正在自制Linux操作系统,因此很有必要体验一下Linux的强大。什么?自制Linux操作系统,还要在Linux下进行?这个不奇怪,在计算机领域,这种“自举”行为比比皆是,计算机科学就是一个不断搭积木,越来越庞大和强大的过程。
一、 Linux环境配置
Linux版本众多,我选择的是长期支持版本:ubuntu-18.04.5。下面是我花了一周时间才完成的完整环境配置,请严格按顺序执行。
1.Ubuntu18.04安装镜像下载
2.VMware虚拟机安装Ubuntu18.10详细步骤
3.ubuntu18 默认root登录
4.解决Linux安装 VMware tools 工具的方法
注:安装之后就可以从windows复制粘贴内容到ubuntu
5.如何在VMWare的Ubuntu虚拟机中设置共享文件夹
6.Ubuntu 20.04 && Ubuntu 18.04 修改 apt 源
$ sudo vim /etc/apt/sources.list #阿里源 deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse sudo apt-get update sudo apt-get upgrade7.dpkg-dev, g++, gcc, libc6-dev, make 等
sudo apt-get install build-essential完善gcc编译安装环境:
sudo apt-get install gcc-multilib8.Ubuntu安装NASM和简单的使用教程
sudo apt-get install nasm9.安装bochs前
sudo apt-get install xorg-dev否则安装bochs时会报错:X windows libraries were not found.
10.Ubuntu下bochs的安装配置-自己动手写操作系统环境搭建
bochs下载地址
tar -vxzf bochs-2.6.11 ./configure \ --prefix=/usr/local/bochs \ --enable-debugger \ --enable-disasm \ --enable-iodebug \ --enable-x86-debugger \ --with-x \ --with-x11 make make install其中prefix是设置安装目录/usr/local/,且用enable-debugger打开调试功能。
把/usr/local/bochs/bin添进环境变量,以便任意目录下能启动bochs:
vi /etc/profile 添加: export PATH=$PATH:/usr/local/bochs/bin 并使之生效source /etc/profile11.配置bochs:在任意工作目录下。
(1) 准备二进制机器码启动文件boot.bin, 由boot.asm直接编译输出nasm boot.asm -o boot.bin
;-------boot.asm--------- mov ax, 0b800h mov ds, ax mov byte [0x00],'h' mov byte [0x02],'e' mov byte [0x04],'l' mov byte [0x06],'l' mov byte [0x08],'o' mov byte [0x0a],',' mov byte [0x0c],'w' mov byte [0x0e],'o' mov byte [0x10],'r' mov byte [0x12],'l' mov byte [0x14],'d' jmp $ times 510-($-$$) db 0 db 0x55,0xaa(2) 用bximage命令生成标准软盘镜像a.img
(3) 将二进制机器码启动文件写入软盘镜像a.img
dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc(4) 配置文件:bochsrc
floppya:1_44=a.img,status=inserted boot:floppy log:bochsout.txt mouse:enabled=0不带任何参数的Bochs并执行,Bochs将在当前目录顺序寻找以下文件作为默认配置文件:
.bochsrc bochsrc bochsrc.txt bochsrc.bxrc(仅对Windows有效)(5) 用命令bochs即可启动虚拟机调试操作系统
记住:安装bochs的目的纯粹是为了调试我们的自制操作系统。
12.Linux系统中VMware Workstation的安装及卸载
其中VMware Workstation 16下载地址
激活密钥:ZF3R0-FHED2-M80TY-8QYGC-NPKYF
安装后启动VMware可能会出现以下错误: 此主机不支持Intel VT-x。 此主机不支持"Intel EPT"硬件辅助的MMU虚拟化。 此主机似乎在禁用了VHV的虚拟机中运行。请确保在虚拟机 配置文件中启用了VHV. 模块"CPUIDEarly"启动失败。 无法启动虚拟机。解决办法:在最外层虚拟机的CPU设置中开启:虚拟化Inter VT-x/EPT或AMD-V/RVI(V)
黑苹果中嵌套虚拟机/开启VT(虚拟机嵌套安装)
安装Vmware的目的是用来验证我们的自制操作系统。
二、内核程序编译、链接和装载过程
首先看,我们的操作系统整体程序结构:

Linux下编译C语言编译工具是GCC,标准输出格式是ELF,因此,这次我们的核心问题是:操作系统如何从纯二进制的机器代码boot_setup.bin中顺利过渡到ELF32格式的kernel.bin中。
本章我不是解读ELF的理论内容,只说我的理解和实现过程。
(一) ELF格式标准

可以看出,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Sections)和节头表(Section header table)。
elf 文件格式的核心思想就是头中嵌头,是种层次化结构的格式。
ELF header 是个用来描述程序头和节头的“头”。 程序头和节头是用来描述某段程序和某节的头,见图中的Program header n和Section header n。 多个程序头就组成程序头表:Program header table,多个节头就组成节头表:Section header table。程序头表和节头表是程序头和节头的集合,因此相当于是个数组,数组的元素就是程序头或节头数据机构。 节就是指定具体的某些代码、数据或符号了,见图中的section n。它们之间的逻辑指向关系,用下图来描述更合适:

(二) Section heaer
理论很枯燥,我用C语言写个简单的打印字符串的程序来分析ELF格式。如果这个C程序能成功启动,就证明进入内核没有问题。
void printchar(char c,char line_x,char col_y,char corlor); void printstr(char *s,char start_line_x,char start_col_y,char corlor); void main(void) { char *str="kernel"; printstr(str,23,5,0x0c); while(1); } /*打印一个字符,参数:字符,行号,列号,字符颜色*/ void printchar(char c,char line_x,char col_y,char corlor) { *(char *)(0xb8000+line_x*160+2*col_y) =c; *(char *)(0xb8000+line_x*160+2*col_y+1) =corlor; } /*打印一个字符串,参数:字符串首地址,开始行号,开始列号,字符颜色*/ void printstr(char *s,char start_line_x,char start_col_y,char corlor) { do { printchar(*s,start_line_x,start_col_y,corlor); start_col_y++; } while (*(s++)!='\0'); }通过编译、链接后生成ELF32(详细命令后面会贴出来)格式的可执行文件kernel.bin,通过elf标准识别命令来读取该文件的内容:
readelf -e kernel.bin
打开整个文件,可以看到很多的节:section,程序中最重要的两个节: .text和.rodata,我们姑且把它叫做代码节和数据节,代码节的长度是0xaf,而数据节的长度是7(即字符串”kernel\0”)。所有的section从section 0---section 8数量一共是9个,这个和ELF header中是一致的:

按我们操作系统的设计,ELF格式的kernel.bin文件是已经被装载到内存中了的,现在又在kernel.bin中找到了代码节。按道理,我们只需要直接跳转到ELF文件中的代码节就可以继续运行了。可是内核程序运行过程中,还需要访问数据,虽然数据节也在kernel.bin文件也能找到,但是由于访问这些数据的机器代码指令是GCC编译器输出的,GCC编译链接源程序的时候,是不可能知道用户会把数据放在内存什么位置的,所以GCC会把每个数据节都指定一个内存地址:最终就体现在了每个节section对应的Addr字段。因此,我们为了能正常访问内核数据节,还需要做一个重要的事情:
把GCC编译链接出的ELF格式文件中的数据节全部挪动(复制)到相应的内存位置。这个和我们上一章《如何进入C语言内核程序(Windows开发环境)》中“必须要提前将可执行文件中的所有变量从文件区挪动到可执行文件中规定的堆区”是一个道理。
既然数据节采用这种机制,那代码节也可以采用这种机制,因此ELF文件采用相同的管理方式也会给每个代码节指定一个内存地址。当用户想运行代码节的时候,只需要把代码节的全部内容挪动(复制)到相应的内存位置,然后跳转到目标内存地址就能运行目标代码了。
故为了完成无论是代码节、数据节的数据复制,在ELF文件中,每个节section都指定了它在文件中的偏移(off)、它应该复制到的内存地址(Addr)以及它的长度(size)。具体到本例,代码节和数据节它们的文件偏移值很明确,上图中分别是:0x500和0x5af,由于代码节的长度是0xaf,可以看出数据节是紧挨着代码节的,中间没有间隙。由于数据节的长度是7,因此代码节和数据节在文件中的偏移位置就是:0x500---0x5b6。
关键是上面这些代码节和数据节的数据结构从哪里能获取到呢?首先,我们需要定位到section heaer n,通过section heaer n就可以定位到section n,定位过程如下图:

而要定位section heaer n,需要知道section heaer 0的位置以及每个section heaer的大小, 这两个值在ELF头中有明确的地方可以找到:

因此,我们通过以上过程,分析ELF文件中的section header是可以实现C程序内核加载的。
(三)Program heaer
用上面的方法虽然可以完成ELF文件的加载和执行,但是用得更多的却是通过是Program heaer的方式。这是因为ELF文件在节的基础上,又做了二次包装:program。可以把program理解成“段”,而描述每个“段”的数据结构是program header,具体到我们这个kernel.bin,它共有3个program header:

而且每个program header的数据结构和section header差异不大,都包括了最重要的offset、Aaddr和size等字段。program header总的数量也可以在ELF头中看到:

我们具体深入分析第一个program header 0,它的文件偏移值是0,长度是0x64c。这个长度已经跨过了之前的代码节和数据节:0x500---0x5b6。故可得出结论:program(段)是由节来组成的,多个节经过链接之后就被合并成一个段了。具体哪些节合并成了哪个段,可以通过elf文件的mapping信息部分看出:

可见,program(段) 0合并了三个节:.text、.rodata和.eh_frame,所以program(段) 0的数据总长度应是三者之和: 0xaf+0x7+0x94=0x14A。
而图中的实际长度是0x64c,和0x14A差得有点远。别着急,我们再仔细看,它的offset字段偏移值是0,就是说它是从ELF文件第一个字节开始截取的,因此截取内容除了以上三个节之外,还有ELF header,program header talbe等内容。
那我们怎么计算它多截取的长度呢?很简单,其实就是.text的第一条指令相对于EELF header装载后的开始地址偏移值。.text的第一条指令就是程序入口点地址,这个地址是0x0051500,而ELF header装载后的开始地址是0x0051000,所以,这个偏移值是:0x0051500-0x0051000=0x500。

因此,program(段) 0的长度刨去0x500便是:0x64c-0x500=0x14c。这个比我们计算出来的应该是0x14A还多出2个字节啊。仔细看一下,会发现:这是因为在.rodata和.eh_frame两个节之间有两个字节的位置间隙:0x5af+0x7=0x5b6,而.eh_frame在上图的值是0x5b8。
基于上面段是由节合并而成的原理,现在我们只需要取出program(段)数据就已经完全覆盖了程序的所有代码和数据了,所以用program header的方式更直接简单。
至于后面两个program header 1和program header 2,对本次程序没什么用,可能是用于别的用途。保守起见,一般是遍历所有的program header,把全部数据都复制到的内存相应的位置。
那最后,我们又到ELF文件中什么地方去取出这3个program header指定的数据结构呢?这里就必须要用到ELF header。见下图,我们要遍历所有的program header0,program header 1....首先必须要跳过elf header部分:

如果知道了program header0的起始位置以及每个program header的大小,就可以实现所有的program header遍历了。而这两个参数,在elf header结构中有明确规定:

这两个参数在elf文件的偏移处参考其定义标准,偏移值分别是:28和42。

那每个program header本身数据结构,是怎么样的呢?可以从ELF32的标准中得到答案:

其中,我们需要的就是标红的3个字段,这3个字段的偏移值分别是:4,8和16,分别代表的是数据源地址、数据目的地址和数据大小。这样,我们就能顺利完成这个工作:将所有program header数据结构描述的数据内容从ELF文件区复制指定的内存区。
(三) 内核映像装载
内核映像的装载其实就是:将所有program header数据结构描述的数据内容从ELF文件区复制指定的内存区,它的本质就是数据复制。分析完上面的原理,具体到本次我们的操作系统,最终的ELF内核映像装载原理总结为下图:
自制Linux操作系统内存组织和ELF内核加载运行原理说明:操作系统内核文件kernel.bin被装载进内存之后,我们的ELF文件物理上是直接贴在head.bin后面的,由于head.bin是system最开始的部分,system全部代码是被整体挪到了内存0地址的,因此ELF文件:kernel.bin放置的物理地址实际就是程序head.asm中最后一行的偏移,我们给这个物理地址起个专用名词:KERNEL_BIN_BASE_ADDR,所有数据的复制工作源地址都是基于基址:KERNEL_BIN_BASE_ADDR进行寻址的。
那么,内核映像的目的装载地址是怎么确定的呢?这个很简单,就是你想把内核映像代码放在什么位置自定义即可。我们还是借用上一章的思路,把内核映像的起始地址定义在0x00000---0xA0000的中间位置附近:0x51000,也即324KB处。故,我们用下面的GCC链接指令实现这个地址规划:
ld -m elf_i386 -Ttext 0x51500 -e main -Map kernel.map -o kernel.bin main.o kernela.obj要点1:关键字-Ttext是链接器ld用来指定最终代码段.text存放的内存地址。我们知道,在Linux下,链接器ld生成ELF可执行程序默认的内存地址是0x08048000,0x08048000是一个虚拟地址,Linux操作系统最终会跳转到这个虚拟地址从而启动ELF可执行程序。但是现在,我们是在自制操作系统,就必须要自己指定一个代码段.text的最终存放地址。
要点2:Linux下GCC编译器默认的入口地址是_start,由于我们这次工程的入口地址是C程序中的main(),因此还必须手动指定这个入口地址。当然也可以在C程序中把main函数换成start名称,但是我们还是保持一般习惯就好。
可以看到,内核映像虽然是复制到0x51000处,但是C程序main()真正的入口地址却是0x51500,我们给这个入口地址取了个名称:KERNEL_ENTRY_POINT。如前所述,这两个地址之间0x500的长度,其实就是包含了ELF header和program header等相关的数据。
具体到要将所有program header数据结构描述的数据内容从ELF文件区复制指定的内存区,编写的汇编程序如下:
;----------------- 将kernel.bin中的段拷贝到GCC链接的地址 ----------- kernel_init: xor eax, eax xor ebx, ebx ;ebx记录程序头表地址 xor ecx, ecx ;cx记录程序头表中的program header数量 xor edx, edx ;dx 记录program header尺寸,即e_phentsize mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小 mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1 个program header在文件中的偏移量 ; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值 add ebx, KERNEL_BIN_BASE_ADDR mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header .each_segment: cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。 je .PTNULL ;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size) push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址 push eax ; 压入函数memcpy的第二个参数:源地址 push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址 call mem_copy ; 调用mem_cpy完成段复制 add esp,12 ; 清理栈中压入的三个参数 .PTNULL: add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header loop .each_segment ret ;----------内存复制void mem_copy(dst,src,size); ------------ ;输入:栈中三个参数(dst,src,size) mem_copy: ;void mem_copy(dst,src,size); push ebp mov ebp, esp PUSH ESI PUSH EDI PUSH ECX mov edi, [ebp+8] ;datadst mov esi, [ebp+12] ;datasrc mov ecx, [ebp+16] ;datalen cmp ecx, 0 ;假如长度是0则不复制任何数据 je nocopy cgoon: MOV EAX,[ESI] ADD ESI,4 MOV [EDI],EAX ADD EDI,4 loop cgoon nocopy: POP ECX POP EDI POP ESI pop ebp RET另外,我们最好重新规划栈区。和上一章一样,我们把栈设置在内核映像0x51000的下面,最后跳转到内核映像的main()入口地址0x51500(KERNEL_ENTRY_POINT)即可。
;---放弃Linux源码,我们换成下面这段来跳转到内核C程序main()--- call kernel_init mov esp, 0x51000 ;设置操作系统最终的栈地址在324KB处。 ;如果能正常打印字符串,则说明栈地址设置没有问题 mov esi,mainmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色字体 mov edi, 0xb8000+22*160 ;指定显示在某行,显卡内存地址也需用绝对地址访问 call printnew jmp KERNEL_ENTRY_POINT ;最后跳转到C内核的main()入口地址三、内核数据验证
在上一章中,我们当时的实验有一个很重要的环节就是:C程序打印字符串“kernel”传递字符串位置指针参数问题。当时的实现方法是:链接器将编译输出的目标文件中的机器指令"68 [00000000]"最终变成了可执行程序中的机器指令"68 [00100000]",由于“kernel”字符串已经被我们提前复制到了堆区0x100000,因此最终实现成功打印。
那么,这次GCC编译器是不是也采用同样的套路呢?我们来跟踪分析一下。
之前分析过了,main()入口地址是从ELF文件的0x500偏移处开始的,代码节和数据节的长度一共是0xb6,我们通过xxd命令只查看kernel.bin的这部分内容:
2. 我们通过命令,把kernel.bin的代码节和数据节的机器代码单独提取出来,生产一个新的文件:code.out:
dd if=kernel.bin of=code.out skip=1280 bs=1c count=182可以用二进制工具查看code.out具体的内容:

3.现在,我们对把机器代码code.out做一个反汇编,并打开汇编语言程序:
objdump -D -b binary -m i386 code.out >>codeasm
上图圈红的地方就是打印字符串“kernel”的调用过程,可以看到,传递字符串“kernel”的内存地址指针参数,是通过eax入栈来实现的!
现在,我们来单独分析“kernel”字符串地址指针参数的值究竟是多少。
0: e8 88 00 00 00 call 0x8d 5: 05 fb 1a 00 00 add $0x1afb,%eax a: 55 push %ebp b: 8d 80 af e5 ff ff lea -0x1a51(%eax),%eax 11: 89 e5 mov %esp,%ebp 13: 6a 0c push $0xc 15: 6a 05 push $0x5 17: 6a 17 push $0x17 19: 50 push %eax 1a: e8 2c 00 00 00 call 0x4b程序第一条指令是call 0x8d,我们打开反汇编后的文件,看看0x8d的具体内容:

指令:mov (%esp),%eax的作用就是:把内存[ss:esp]的内容赋值到eax。这个语句设计得有点巧妙,具体到这里的上下文信息,它的作用是:通过EAX返回当前指令运行结束时所在的内存地址!过程:当开始执行函数调用call 0x8d指令时,当前EIP值入栈,进入函数体之后,马上就把栈顶的数据传递给eax,故eax就取到了函数调用时的EIP之值。那进入函数体之时,EIP之值是多少呢?显然是内核映像的起始地址+call 0x8d指令本身的长度:因为call 0x8d是我们的操作系统是跳转到内核映像入口地址:KERNEL_ENTRY_POINT之后,运行的第一条指令。由于操作系统CS指向的基地址是0,故执行到call 0x8d时,EIP值就等于KERNEL_ENTRY_POINT的绝对物理地址,所以就是0x00051500,但执行完call 0x8d指令之后,还要加上自身的长度5,故入栈的EIP值是0x00051500+5=0x00051505。故,最终执行完call 0x8d后,eax之值是:0x00051505。
接着往下看,和字符串指针参数eax有关的指令只有3句,我们单独摘取出来分析:
add $0x1afb,%eax ...... lea -0x1a51(%eax),%eax ...... push %eax执行指令add $0x1afb,%eax后,显然,eax=0x1afb+0x00051505=0x53100。
接着是指令:lea -0x1a51(%eax),%eax,那么eax的值变化是:
eax=eax-0x1a51 =0x53100-0x1a51=0x515af
那么“0x515af“就是字符串指针参数eax最终的入栈值,也既是C语言内核最终的机器代码就是通过这个内存地址----[0x515af]来访问字符串“kernel”的,那这个地址能成功访问到吗?请看下图:

可见,这个地址值刚刚好!因为我们之前已按要求通过program header 0早已把"kernel"字符串数据装载到内核映像所在的这个内存地址了。
这就是C语言内核程序能实现数据访问的机制和原理,可以看出,GCC编译器的实现过程比我们上一章的实现过程复杂多了,这也是不同编译器之间的差异之处吧!
行了,我们最后再将内核C程序完善一下:加入一个汇编程序kernela.asm进行链接。这是因为在内核开发中,汇编程序肯定会和C程序之间有相互调用关系,我们在这提前把环境配置好,我们整体测试方法是:在C程序里调用汇编程序里面的函数:asmfunc,在汇编程序里调用C程序里面的printstr函数,两次调用都支持参数传递。如果成功,则打印字符串“C->A->C”。
main程序:
void printchar(char c,char line_x,char col_y,char corlor); void printstr(char *s,char start_line_x,char start_col_y,char corlor); extern void asmfunc(char *s,char start_line_x,char start_col_y,char corlor); void main(void) { char *str="kernel"; printstr(str,23,20,0x0c); char *str2="C->A->C"; /*C调用汇编,汇编又调用C演示*/ asmfunc(str2,24,20,0x0c); while(1); } /*打印一个字符,参数:字符,行号,列号,字符颜色*/ void printchar(char c,char line_x,char col_y,char corlor) { *(char *)(0xb8000+line_x*160+2*col_y) =c; *(char *)(0xb8000+line_x*160+2*col_y+1) =corlor; } /*打印一个字符串,参数:字符串首地址,开始行号,开始列号,字符颜色*/ void printstr(char *s,char start_line_x,char start_col_y,char corlor) { do { printchar(*s,start_line_x,start_col_y,corlor); start_col_y++; } while (*(s++)!='\0'); }kernela.asm程序:
[BITS 32] GLOBAL asmfunc EXTERN printstr [SECTION .text] ;C语言调用汇编语言测试 ;void asmfunc(char *s,char start_line_x,char start_col_y,char corlor) asmfunc: push ebp mov ebp,esp ;由于push ebp导致栈顶又多挪动了4位 push dword [EBP+16+4] ;corlor push dword [EBP+12+4] ;start_col_y push dword [EBP+08+4] ;start_line_x push dword [EBP+04+4] ;*s ;汇编语言调用C语言测试 ;void printstr(char *s,char start_line_x,char start_col_y,char corlor) CALL printstr pop eax ;全是为了保持函数调用时push的栈平衡 pop eax pop eax pop eax pop ebp ret这里有个细节需要注意下:有些编译器(如上一章的Windows环境)要求汇编程序里面的函数标号必须要带下标符号"_"才能与C程序里面的对应函数相链接,而我这次Ubuntu的GCC编译器则没有这个要求,两边保持相同名称即可。
对其编译、链接(详细makefile见后),我们的操作系统启动之后,在Linux的Vmware下运行结果如下:

至此,历经千辛万苦,本次自制Linux操作系统的C语言内核已经成功装载,开发环境已经全部搭建完毕!我们可以用C语言进行大刀阔斧的开发操作系统内核了。
四、程序源代码
(一) 目录结构
1.一级目录
Linuxmy2为一级目录,并把Linuxmy2设置成Windows和Ubuntu共享的目录。

2.boot目录

3.kernel目录

(二) boot目录makefile
default : make boot_setup.bin bootsect.bin : bootsect.asm config.inc Makefile nasm bootsect.asm -o bootsect.bin -l bootsect.lst setup.bin : setup.asm config.inc Makefile nasm setup.asm -o setup.bin -l setup.lst head.bin : head.asm config.inc Makefile nasm head.asm -o head.bin -l head.lst boot_setup.bin : bootsect.bin setup.bin head.bin Makefile cat bootsect.bin setup.bin head.bin > boot_setup.bin clean : rm *.lst rm bootsect.bin rm setup.bin rm head.bin(三) kernel目录makefile
default : make kernel.bin kernela.obj : kernela.asm Makefile nasm -f elf kernela.asm -o kernela.obj -l kernela.lst main.o: main.c Makefile gcc -c -m32 -Os -o main.o main.c kernel.bin: main.o kernela.obj Makefile ld -m elf_i386 -Ttext 0x51500 -e main -Map kernel.map -o kernel.bin main.o kernela.obj clean: rm main.o rm kernela.lst rm kernel.map rm kernela.obj(四)一级目录makefile
default : make buildimg buildimg : boot/boot_setup.bin kernel/kernel.bin Makefile cat boot/boot_setup.bin kernel/kernel.bin > Linux.img run : make buildimg vmware -x /root/vmware/Linux/Linux.vmx运行指令make run能在Linux下自动启动vmware验证操作系统。
(五)head.asm程序
三大引导程序:bootsect.asm、setup.asm和head.asm,只有head.asm有所修改。详细内容如下:
config.inc:;%define debug ;不注释调试,注释用于生产 %ifdef debug isdebug equ 1 %else isdebug equ 0 %endif ;UTS_SYSNAME equ "Linux" ;UTS_NODENAME equ "(none)" ;UTS_RELEASE equ "0" ;UTS_VERSION equ "0.12" ;UTS_MACHINE equ "i386" DEF_INITSEG equ 0x9000 ;MBR程序挪动后的目标地址 DEF_SYSSEG equ 0x1000 ;SYSEM模块放置地址 DEF_SETUPSEG equ 0x9020 ;SETUP模块放置地址 KERNEL_ENTRY_POINT equ 0x00051500 ;内核程序main.c入口地址 ;------------- program type 定义 -------------- PT_NULL equ 0 ;ELF格式标准2.head.asm
;********************************************************* ;****Linux操作系统Nasm引导程序:head,制作者:Mr.Jiang*** ;*************2021-1-27********************************** %include "config.inc" SETUPSEG equ DEF_SETUPSEG ;全部同bootsect和setup SYSSEG equ DEF_SYSSEG _pg_dir equ 0x0000 ;页目录地址,大小4KB. pg0 equ 0x1000 ;第1个页表地址,大小4KB. pg1 equ 0x2000 ;第2个页表地址,大小4KB. pg2 equ 0x3000 ;第3个页表地址,大小4KB. pg3 equ 0x4000 ;第4个页表地址,大小4KB. _tmp_floppy_area equ 0x5000 ;软盘缓冲区地址. len_floppy_area equ 0x400 ;软盘缓冲区大小1KB [bits 32] ;指定代码为32位保护模式 jmp start ;这条伪指令不会执行任何操作,只在编译的时候起填充数字作用。 times _tmp_floppy_area+len_floppy_area-($-$$) db 0 ; ;一个语句实现页目录和页表地址区域清0,省去程序后面Linux源代码中的清0部分 ;使head程序从0x5000+0x400位置开始放置(仅除第一条jmp指令外)。 ;这里已经处于32位运行模式,首先设置ds,es,fs,gs为setup.s中构造的内核数据段 ;并将堆栈放置在stack_start指向的user_stack数组区,然后使用本程序后面定义的 ;新中断描述符表和全局段描述表。新全局段描述表中初始内容与setup.s中的基本一样, ;仅段限长从8MB修改成了16MB。stack_start定义在kernel/sched.c。它指向user_stack ;数组末端的一个长指针。设置这里使用的栈,姑且称为系统栈。但在移动到任务0执行 ;(init/main.c中137行)以后该栈就被用作任务0和任务1共同使用的用户栈了。 start: mov eax,2*8 ;加载数据段选择子(0x10) mov ds,eax ;把所有数据类段寄存器全部指向GDT的数据段地址 mov es,eax mov fs,eax mov gs,eax mov ss,eax mov esi,sysmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x0c ;颜色红 mov edi, 0xb8000+13*160 ;显示在第18行,显卡内存地址也需用绝对地址访问 call printnew mov esi,promsg mov cl, 0x0c mov edi, 0xb8000+15*160 ;显示在第20行 call printnew mov esi,headmsg mov cl, 0x0c mov edi, 0xb8000+16*160 ;显示在第22行 call printnew mov esp,0x1e25c ; 重新设置堆栈,暂时设置值参见书 ;《Linux内核设计的艺术_图解Linux操作系统架构 ; 设计与实现原理》P27 ; Linus源程序中是lss _stack_start,%esp ; _stack_start,。定义在kernel/sched.c,82-87行 ; 它是指向 user_stack数组末端的一个长指针 call setup_idt call setup_gdt jmp 1*8:newgdt ;改变CS的值来触发新GDT表生效 nop nop newgdt: ;如能正常打印则表明程序正常运行,新GDT表无问题 mov esi,gdtmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;颜色蓝 mov edi, 0xb8000+17*160 ;显示在第18行,显卡内存地址也需用绝对地址访问 call printnew ;call test_keyboard ;开键盘中断并按键测试,显示外部中断体系正常 sti ;开中断 int 00h ;手工系统中断调用,测试显示内部中断体系也正常 cli ;关掉中断 call A20open mov esi,a20msg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色 mov edi, 0xb8000+19*160 ;显示在第18行,显卡内存地址也需用绝对地址访问 call printnew call setup_paging ;分页存储设置页表 ;---放弃Linux源码,我们换成下面这段来跳转到内核C程序main()--- call kernel_init mov esp, 0x51000 ;设置操作系统最终的栈地址在320KB处。 ;如果能正常打印字符串,则说明栈地址设置没有问题 mov esi,mainmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色字体 mov edi, 0xb8000+22*160 ;指定显示在某行,显卡内存地址也需用绝对地址访问 call printnew jmp KERNEL_ENTRY_POINT ;最后跳转到C内核的main()入口地址 ;jmp KERNEL_BIN_BASE_ADDR+0x500;在不调用kernel_init的情况下,调试能否直接跳转到ELF中的main入口 ;0x500为main入口代码段在ELF文件中的偏移值 ;----------------- 将kernel.bin中的segment拷贝到编译的地址 ----------- kernel_init: xor eax, eax xor ebx, ebx ;ebx记录程序头表地址 xor ecx, ecx ;cx记录程序头表中的program header数量 xor edx, edx ;dx 记录program header尺寸,即e_phentsize mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小 mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1 个program header在文件中的偏移量 ; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值 add ebx, KERNEL_BIN_BASE_ADDR mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header .each_segment: cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。 je .PTNULL ;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size) push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址 push eax ; 压入函数memcpy的第二个参数:源地址 push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址 call mem_copy ; 调用mem_cpy完成段复制 add esp,12 ; 清理栈中压入的三个参数 .PTNULL: add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header loop .each_segment ret ;----------内存复制void mem_copy(dst,src,size); ------------ ;输入:栈中三个参数(dst,src,size) mem_copy: ;void mem_copy(dst,src,size); push ebp mov ebp, esp PUSH ESI PUSH EDI PUSH ECX mov edi, [ebp+8] ;datadst mov esi, [ebp+12] ;datasrc mov ecx, [ebp+16] ;datalen cmp ecx, 0 ;假如长度是0则不复制任何数据 je nocopy cgoon: MOV EAX,[ESI] ADD ESI,4 MOV [EDI],EAX ADD EDI,4 loop cgoon nocopy: POP ECX POP EDI POP ESI pop ebp RET ;Linux将内核的内存页表直接放在页目录之后,使用了4个表来寻址16 MB的物理内存。 ;如果你有多于16 Mb的内存,就需要在这里进行扩充修改。 ;每个页表长为4KB(1页内存页面),而每个页表项需要4个字节,因此一个页表共可存 ;1024个表项。一个页表项寻址4KB的地址空间,则一个页表就可以寻址4MB的物理内存。 setup_paging: ;首先对5页内存(1页目录 + 4页页表)清零。由于在程序第一行已经实现,此处可省。 ;mov ecx,10 ;xor eax,eax ;xor edi,edi ;页目录从0x000地址开始。 ;cld ;edi按递增方向 ;rep ;stosd ;eax内容存到es:edi所指内存位置处,且edi增4。 ;下面4句设置页目录表中的项。因为内核共有4个页表,所以只需设置4项(索引)。 ;页目录项的结构与页表中项的结构一样,4个字节为1项。 ;例如"pg0+7"表示:0x00001007,是页目录表中的第1项。 ;则第1个页表所在的地址 = 0x00001007 & 0xfffff000 = 0x1000; ;第1个页表的属性标志 = 0x00001007&0x00000fff = 0x07,表示该页存在、用户可读写。 ;一句指令就把页表的地址和属性完全完整定义了,这个写法设计得有点巧妙。 mov dword [_pg_dir],pg0+7 ;页表0索引 将直接覆盖0地址处的3字节长度jmp指令 mov dword [_pg_dir+4],pg1+7 ;页表1索引 mov dword [_pg_dir+8],pg2+7 ;页表2索引 mov dword [_pg_dir+12],pg3+7 ;页表3索引 ;下面填写4个页表中所有项的内容,共有:4(页表)*1024(项/页表)=4096项(0-0xfff), ;也即能映射物理内存 4096*4Kb = 16Mb。 ;每项的内容是:当前项所映射的物理内存地址 + 该页的标志(这里均为7)。 ;填写使用的方法是从最后一个页表的最后一项开始按倒退顺序填写。 ;每一个页表中最后一项在表中的位置是1023*4 = 4092. ;此最后一页的最后一项的位置就是pg3+4092。 mov edi,pg3+4092;edi->最后一页的最后一项。 mov eax,0xfff007;16Mb - 4096 + 7 (r/w user,p) */ ;最后1项对应物理内存页面的地址是0xfff000, ;加上属性标志7,即为0xfff007。 std ;方向位置位,edi值递减(4字节)。 goon: stosd sub eax,0x1000;每填写好一项,物理地址值减0x1000。 jge goon ;如果小于0则说明全添写好了。 jge是大于或等于转移指令 ;现在设置页目录表基址寄存器cr3,指向页目录表。cr3中保存的是页目录表的物理地址 ;再设置启动使用分页处理(cr0的PG标志,位31) xor eax,eax ;pg_dir is at 0x0000 */ # 页目录表在0x0000处。 mov cr3,eax ;cr3 - page directory start */ mov eax,cr0 or eax,0x80000000 ;添上PG标志。 mov cr0,eax ; set paging (PG) bit */ # 软盘缓冲区: 共保留1024项,填充数值0。在程序第一行已经实现,此处可省。 ;mov ecx,1024/4; ;xor eax,eax ;mov edi,_tmp_floppy_area ;软盘缓冲区从0x5000地址开始。 ;cld ;edi按递增方向 ;rep ;stosd ;eax内容存到es:edi所指内存位置处,且edi增4。 mov esi,pagemsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色字体 mov edi, 0xb8000+20*160 ;指定显示在某行,显卡内存地址也需用绝对地址访问 call printnew mov esi,asmmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色字体 mov edi, 0xb8000+21*160 ;指定显示在某行,显卡内存地址也需用绝对地址访问 call printnew ret ;用于测试A20地址线是否已经开启。采用的方法是向内存地址0x000000处写入任意 ;一个数值,然后看内存地址0x100000(1M)处是否也是这个数值。如果一直相同的话, ;就一直比较下去,也即死循环表示地址A20线没有选通,就不能使用1MB以上内存。 A20open: xor eax, eax inc eax mov [0x000000],eax cmp eax,[0x100000] je A20open ret printnew: ;保护模式下显示字符串, 以'$'为结束标记 mov bl ,[ds:esi] cmp bl, '$' je printover mov byte [ds:edi],bl inc edi mov byte [ds:edi],cl ;字符颜色 inc esi inc edi jmp printnew printover: ret setup_idt: ;暂时将所有的中断全部指向一个中断服务程序:ignore_int lea edx,[ignore_int] ;将ignore_int的有效地址(偏移值)值送edx mov eax,0x00080000 ;将选择符0x0008置入eax的高16位中。 mov ax,dx ;selector = 0x0008 = cs */ ;偏移值的低16位置入eax的低16位中。此时eax含有门 ;描述符低4字节的值。 mov dx,0x8E00 ;interrupt gate - dpl=0, present ;此时edx含有门描述符高4字节值,偏移地址高16位是0 lea edi,[_idt] ;_idt是中断描述符表的地址。 ;以上为单独一个中断描述符的设置方法 mov ecx,256 ;IDT表中创建256个中断描述符 ;将上面的中断描述符重复放置256次,让所有的中断全部指向一个中断服务程序:哑中断 rp_sidt: mov [edi],eax ;将哑中断门描述符存入表中。 mov [edi+4],edx ;edx内容放到 edi+4 所指内存位置处。 add edi,8 ; edi指向表中下一项。 loop rp_sidt lidt [idt_descr] ;加载中断描述符表寄存器值。 ret ;让所有的256中断都指向这个统一的中断服务程序 ignore_int: cli ;首先应禁止中断,以免中断嵌套 pushad ;进入中断服务程序首先保存32位寄存器 push ds ;再保存所有的段寄存器 push es push fs push gs push ss mov eax,2*8 ;进入断服务程序后所有数据类段寄存器都转到内核段 mov ds,eax mov es,eax mov fs,eax mov gs,eax mov ss,eax mov esi,intmsg ;保护模式DS=0,数据用绝对地址访问 mov cl, 0x09 ;蓝色 mov edi, 0xb8000+18*160 ;指定显示在某行,显卡内存需用绝对地址 call printnew pop ss ;恢复所有的段寄存器 pop gs pop fs pop es pop ds popad ; 所有32位寄存器出栈恢复 iret ;中断服务返回指令 align 2 ;按4字节方式对齐内存地址边界。 dw 0 ;这里先空出2字节,这样_idt长字是4字节对齐的。 ;下面是加载中断描述符表寄存器idtr的指令lidt要求的6字节操作数。 ;前2字节是idt表的限长,后4字节是idt表在线性地址空间中的32位基地址。 idt_descr: dw 256*8-1 ;idt contains 256 entries # 共256项,限长=长度 - 1。 dd _idt ret setup_gdt: lgdt [gdt_descr] ;加载全局描述符表寄存器。 ret align 2 ;按4字节方式对齐内存地址边界。 dw 0 ;这里先空出2字节,这样_gdt长字是4字节对齐的。 ;加载全局描述符表寄存器gdtr的指令lgdt要求的6字节操作数。前2字节是gdt表的限长, ;后4字节是gdt表的线性基地址。因为每8字节组成一个描述符项,所以表中共可有256项。 ;符号_gdt是全局表在本程序中的偏移位置。 gdt_descr: dw 256*8-1 dd _gdt sysmsg db '(iii) Welcome Linux---system!','$' promsg db '1.Now Already in Protect Mode','$' headmsg db '2.Run head.asm in system program','$' gdtmsg db '3.Reset GDT success:New CS\EIP normal','$' intmsg db '4.Reset IDT success:Unknown interrupt','$' a20msg db '5.Check A20 Address Line Stdate:Open','$' pagemsg db '6.Memory Page Store:Page Tables is set up','$' asmmsg db '7.Pure Asm Program:bootsect->setup->head(system) is Finished','$' mainmsg db '8.Now Come to C program entry:Main()','$';C程序main()的源码调试 ;IDT表和GDT表放在程序head的最末尾 ;中断描述符表:256个,全部初始化为0。 _idt: times 256 dq 0 ;idt is uninitialized # 256项,每项8字节,填0。 ;全局描述符表。其前4项分别是:空项、代码段、数据段、系统调用段描述符, ;后面还预留了252项的空间,用于放置新创建任务的局部描述符(LDT)和对应的 ;任务状态段TSS的描述符。 ;(0-nul,1-cs,2-ds,3-syscall,4-TSS0,5-LDT0,6-TSS1,7-LDT1,8-TSS2 etc...) _gdt: d ;NULL descriptor */ d ;16Mb */ # 0x08,内核代码段最大长度16MB。 d ;16Mb */ # 0x10,内核数据段最大长度16MB。 d ;TEMPORARY - don't use */ times 252 dq 0 ;space for LDT's and TSS's etc */ # 预留空间。 KERNEL_BIN_BASE_ADDR:
皓盘云建最新版下载v9.0 安卓版
53.38MB |商务办公
ris云客移动销售系统最新版下载v1.1.25 安卓手机版
42.71M |商务办公
粤语翻译帮app下载v1.1.1 安卓版
60.01MB |生活服务
人生笔记app官方版下载v1.19.4 安卓版
125.88MB |系统工具
萝卜笔记app下载v1.1.6 安卓版
46.29MB |生活服务
贯联商户端app下载v6.1.8 安卓版
12.54MB |商务办公
jotmo笔记app下载v2.30.0 安卓版
50.06MB |系统工具
鑫钜出行共享汽车app下载v1.5.2
44.7M |生活服务
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-02-15
2022-02-14