流程
一看标题,读者朋友可能认为,这很简单啊。比如,要执行ls
,直接打开一个命令行终端,输入ls
,然后按下回车键。
这确实是执行应用程序的方法。可我们将探讨的是linux执行ls
等应用程序的流程。
用一句话来说,就是:execv
调用ls
等应用程序。
说得稍微详细一点:编写应用程序,打包应用程序到tar文件,把tar文件写入文件系统,操作系统解包tar文件,用execv
执行应用程序。
本文要探讨的,是在自己写的玩具操作系统上运行自己编写的echo
应用程序。
假装我们已经有了一个简略的操作系统,现在我们开始编写自己的echo
程序。
编写自己的应用程序
echo
// file:echo.c
#include "stdio.h"
int main(int argc, char **argv)
{
for(int i = 0; i < argc; i++){
printf("argv[%d] = %s\n", argv[i]);
}
return 0;
}
stdio.h
是我们在操作系统上自己编写的输入输出函数库。
_start
// file:start.asm
extern main
bits 32
global _start
_start:
push eax
push ecx
call main ; 调用echo.c中的main函数
add esp, 8
ret
start.asm
中是用nasm汇编写的_start
函数,调用echo.c
中的main
函数。
我们平时在linux上编写代码,只需要写echo.c
中的代码,不必写start.asm
,那是因为操作系统帮助我们做了start.asm
所做的工作。
现在我们在自己的简略的操作系统上写C程序,需要自己写start.asm
。
为什么需要是这样的模式?我也没弄明白。模板嘛,下次自己要在自己的操作系统上写应用程序的时候,照着模板写就行。
C运行时库
在echo.c
中使用了printf
函数。这个函数是由stdio.h
提供的。
回忆一下,我们在linux上写C程序时,是不是经常使用printf
、exit
、fork
等我们自己并未实现的函数?自己未实现却能够使用这些函数,是因为操作系统的C运行时库提供了这些函数。
我以为,C运行时库就是提供系统函数的那些二进制文件。
我们的C运行时库是这样制作的:把提供printf
、exit
等函数的二进制文件打包成一个文件pegasus_os.a
。
制作C运行时库的命令如下:
ar rcs lib/pegasus_os.a lib/printf.o lib/exit.o lib/fork.o lib/execv.o lib/unlink.o
编译
# 编译start.asm
nasm -I ../include -f elf -o start.o start.asm -m elf_i386
# 编译echo.c
gcc -I ../include -c -fno-builtin -Wall -o echo echo.c -m32
# 链接
ld -Ttext 0x1000 -o echo echo.o start.o ../lib/pegasus_os.a
产生二进制代码echo。这就是我们要安装的应用程序。
安装自己的应用程序
所谓安装应用程序,就是把应用程序写入到我们的操作系统的硬盘中。
平时,我们在linux操作系统上安装应用程序前,应用程序的源码包本来就在运行linux的操作系统上,所以,把源码编译成二进制代码后,二进制文件也在操作系统上,不需要再把二进制文件写入到操作系统所控制的硬盘。
打包
假如我们要把echo
、ls
等多个应用程序安装到我们的操作系统中,那么,可以把多个文件打包成一个tar文件,然后把这个tar文件写入硬盘。
打包命令如下:
# 把多个应用程序打包成inst.tar文件
tar -cvf ./inst.tar ./echo ./ls
写入硬盘
dd if=inst.tar ../80m.img bs=1 count=`ls -l inst.tar | awk -F " " '{printf $5}'` conv=notrunc
上面的命令把inst.tar
写入硬盘,写入的数据是inst.tar
所占用的字节数量。
count
设置写入block
的数值,而bs
设置block
的单位,bs=1
把block
的单位设置成1个字节。
上面的命令并不是完整的命令,应该再加上一个seek=N个字节
。
要在操作系统中读写inst.tar
文件,必须把inst.tar
文件纳入文件系统的管理之中。
例如,在文件系统中,我们记录inst.tar
存储在从第N个扇区开始的3个扇区中,用dd命令写入硬盘时就应该把inst.tar
写入到第N个扇区开始的位置。
解包
把inst.tar
写入文件系统,使用的是dd
命令,解包inst.tar
使用的却是操作系统中的方法。换句话说,操作系统中有段代码解包inst.tar
文件,并且把它包含的文件写入到文件系统中。
tar文件格式
tar文件结构概念示意图如下:
tar文件头 | 文件A | tar文件头 | 文件B | tar文件头 | 文件C |
---|
echo
、ls
等被打包成inst.tar
后,并未压缩文件,而只是按照tar文件头+文件数据
的格式把所有文件数据组合在一起创建一个新文件。
显然,加入了tar文件头
后,inst.tar
的占用的空间比echo
、ls
占用的空间之和大。
解包
伪代码
tar
文件头中包含许多信息,解包tar文件只用到两项:文件名和文件大小。
解包tar文件的伪代码如下:
void untar()
{
char buf[SECTOR_SIZE * 16];
int chunk = sizeof(buf);
while(1){
// 把inst.tar文件读取到buf中。
read(inst.tar, buf, SECTOR_SIZE);
if(length of buf is 0){
break;
}
tar_header = buf;
// 文件长度是八进制的字符串形式,需要转换成十进制整型数字。
file_size_str = tar_header->size;
int file_len = 0;
char *p = file_size_str;
while(*p){
file_len = file_len * 8 + (*p - '0');
p++;
}
filename = tar_header->name;
offset = tar_header的大小;
// 创建filename
open(filename);
while(bytes_left){
bytes = min(bytes_left, chunk);
// 把inst.tar文件读取到buf中。
int read_bytes = ((bytes - 1)/SECTOR_SIZE + 1) * SECTOR_SIZE;
read(inst.tar, buf, read_bytes);
// 把bytes字节的数据写入filename文件
write(filename, buf, bytes);
bytes_left -= bytes;
}
}
}
两个难点
获取文件大小
从tar头文件中获取的文件长度是一个八进制的字符串,例如"800"。把八进制的字符串转化为十进制整型数值的伪代码如下:
int file_len;
char file_size_str[20] = "800";
char *p = file_size_str;
while(*p){
file_len = (file_len * 8) + *p - '0';
p++;
}
读取tar文件中的数据
while(bytes_left){
bytes = min(bytes_left, chunk);
// 把inst.tar文件读取到buf中。
int read_bytes = ((bytes - 1)/SECTOR_SIZE + 1) * SECTOR_SIZE;
read(inst.tar, buf, read_bytes);
// 把bytes字节的数据写入filename文件
write(filename, buf, bytes);
bytes_left -= bytes;
}
读取inst.tar数据的时候,使用read(inst.tar, buf, bytes);
行不行?可以。但是,为了提升读取效率,读取单位是整数个扇区。
这种小技巧,费解,难想到,可读性差。
假如bytes = SECTOR_SIZE-1,那么,read_bytes = SECTOR_SIZE。
假如bytes = SECTOR_SIZE,那么,read_bytes = SECTOR_SIZE。
假如bytes = SECTOR_SIZE + 1,那么,read_bytes = 2 * SECTOR_SIZE。
解包操作的实质是把tar文件中的文件,例如ls
、echo
等写入文件系统中。
execv
当我们在自己的操作系统的终端中输入echo
,实质是执行execv(echo)
。这样就执行了echo
。