从零编写linux0.11 - 第十一章 可执行文件
编程环境:Ubuntu 20.04、gcc-9.4.0
代码仓库:https://gitee.com/AprilSloan/linux0.11-project
linux0.11源码下载(不能直接编译,需进行修改)
本章目标
本章会加载并运行 elf 格式可执行文件,但是功能还不够完善,不支持动态编译,不能运行太大的文件。
1.elf 可执行文件介绍
本节的内容主要参考《程序员的自我修养》一书,该书包含了较为全面的可执行文件加载的知识。不仅介绍了 elf 文件结构,还讲解了程序装载和动态链接的内容。阅读完本书后,相信你会对程序运行有更深刻的理解。
首先,现在存在不同的可执行文件格式,linux0.11 原本采用的是 a.out 格式,但是这种格式已经被淘汰了。如今 linux 采用的是 elf 格式,windows 采用的是 PE 格式。因为 linux 和 windows 可执行文件的格式不同,所以 windows 的程序不能在 linux 上运行。
elf 文件主要有以下几个部分:文件头、节头、程序头、代码数据、重定位表、符号表、字符串表等。动态链接还有动态符号表和重定位表,但这里只会讲静态链接的相关知识。
我们的代码只会用到文件头,程序头和代码数据。文件头用来找到程序头,程序头用来找到代码数据。
为了直观地了解 elf 格式可执行文件,我们来查看这种文件的结构是怎样的。
代码仓库的 libc 目录下已经搭建好一个编译环境,main.c 的代码如下所示:
#include <stdio.h>
void start()
{
__asm__("call main" :::);
}
int main(int argc, char *argv[])
{
printf("Hello World!\n");
printf("argc: %d\n", argc);
printf("argv[0]: %s\n", argv[0]);
while (1);
return 0;
}
用 start 调用 main 函数是为了保证栈不出错,如果直接运行 main 函数的话,main 函数会修改栈内容,导致不能正确访问 argc 和argv。
打开终端,进入该目录,执行 make 指令,就会编译出一个名为 main 的可执行文件,执行下面的执行查看文件类型:
ai@ubuntu:~/Desktop/linux0.11-project/libc$ file main
main: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped
这代表 main 是32位小端的 elf 可执行文件,适用于 Intel 80386 平台,版本为1,静态链接,未剔除符号表信息。这些都是 elf 文件的基本信息,这些信息保存在文件头里。运行下面的指令查看 elf 头信息。
ai@ubuntu:~/Desktop/linux0.11-project/libc$ readelf -h main
ELF 头:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
类别: ELF32
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: EXEC (可执行文件)
系统架构: Intel 80386
版本: 0x1
入口点地址: 0x80000000
程序头起点: 52 (bytes into file)
Start of section headers: 11128 (bytes into file)
标志: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 4
Size of section headers: 40 (bytes)
Number of section headers: 14
Section header string table index: 13
文件头的数据结构如下所示。
// elf.h
typedef struct elfhdr {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
结构体成员的含义如下所示:
成员 | readelf输出结果与含义 |
---|---|
e_ident | Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 类别: ELF32 数据: 2 补码,小端序 (little endian) Version: 1 (current) OS/ABI: UNIX - System V ABI 版本: 0 |
e_type | 类型: EXEC (可执行文件) ELF文件类型 |
e_machine | 系统架构: Intel 80386 ELF文件的CPU平台属性,相关常量以 EM_ 开头 |
e_version | 版本: 0x1 ELF 版本号。一般为常数1 |
e_entry | 入口点地址: 0x80000000 入口地址,规定 ELF 程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令,可重定位文件一般没有入口地址,则这个值为0 |
e_phoff | 程序头起点: 52 (bytes into file) 程序头在文件中的偏移,也就是从文件的第52个字节开始是程序头 |
e_shoff | Start of section headers: 11128 (bytes into file) 段表在文件中的偏移 |
e_flags | 标志: 0x0 ELF 标志位,用来识别一些 ELF 文件平台相关的属性。 |
e_ehsize | Size of this header: 52 (bytes) ELF 文件头大小 |
e_phentsize | Size of program headers: 32 (bytes) 程序头描述符的大小 |
e_phnum | Number of program headers: 4 程序头描述符数量 |
e_shentsize | Size of section headers: 40 (bytes) 段表描述符的大小 |
e_shnum | Size of section headers: 14 (bytes) 段表描述符数量 |
e_shstrndx | Section header string table index: 13 段表字符串表所在的段在段表中的下标。 |
e_ident 的前4个字符必须是 0x7f,0x45(E),0x4c(L),0x46(F)。不然这个文件就不是 elf 文件。
通过 e_phoff 找到程序头描述符的位置。
程序头的结构可以通过如下的命令看到:
ai@ubuntu:~/Desktop/linux0.11-project/libc$ readelf -l main
Elf 文件类型为 EXEC (可执行文件)
Entry point 0x80000000
There are 4 program headers, starting at offset 52
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x001000 0x80000000 0x80000000 0x0144c 0x01868 RWE 0x1000
NOTE 0x002420 0x80001420 0x80001420 0x0001c 0x0001c R 0x4
GNU_PROPERTY 0x002420 0x80001420 0x80001420 0x0001c 0x0001c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
Section to Segment mapping:
段节...
00 .text .text.__x86.get_pc_thunk.ax .text.__x86.get_pc_thunk.bx .text.__x86.get_pc_thunk.si .rodata .eh_frame .note.gnu.property .got.plt .bss
01 .note.gnu.property
02 .note.gnu.property
03
LOAD 段包含了代码和数据,我们需要将这个段加载到操作系统中。该段在文件中的偏移是 0x1000,文件长度为 0x144c,内存长度为 0x1868。为什么这两个长度不一样呢?在文件中并没有 bss 段的数据(bss 的数据全为0,只需要保存 bss 段的长度即可),当加载到内存时,需要向 bss 段填充0。也就是说,内存长度 - 文件长度 = bss 段长度。
程序头的定义如下:
// elf.h
typedef struct elf_phdr {
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_flags;
Elf32_Word p_align;
} Elf32_Phdr;
各成员的含义如下:
成员 | 含义 |
---|---|
p_type | “Segment” 的类型,基本上我们在这里只关注 LOAD 类型的程序头 |
p_offset | “Segment” 在文件中的偏移 |
p_vaddr | “Segment” 的第一个字节在进程虚拟地址空间的起始地址,整个程序头表中,所有 LOAD 类型的元素按照从小到大排列 |
p_paddr | “Segment” 的物理装载地址 |
p_filesz | “Segment” 在 elf 文件中所占空间的长度 |
p_memsz | “Segment” 在进程虚拟地址空间中占用的长度 |
p_flags | “Segment” 的权限属性,比如可读 “R”、可写 “W” 和可执行 “X” |
p_align | “Segment” 的对齐属性。实际对齐字节等于2的 p_align 次。比如 p_align 等于10,那么实际的对齐属性就是2的10次方,即1024字节 |
通过 p_offset 就能找到代码和数据的起始地址。
我的代码很简单,只需要了解这两个部分就可以开始写代码了,如果你想了解更多的知识,还是去看看《程序员的自我修养》吧。毕竟这只是一篇小小的博客,装不下书里太多的内容。
另外,linux 中也有运行 windows 可执行文件的方法,安装 wine 就可以执行部分 PE 格式的可执行文件。毕竟只要知道 PE 格式的结构,就能够对它进行解析。如果需要动态库(.dll)支持,那就没法加载了。
2.打印可执行文件信息
这节开始编写代码。运行可执行文件的系统调用是 execve。
# system_call.s
.align 4
sys_execve:
lea EIP(%esp), %eax # 保存栈中eip的地址
pushl %eax
call do_execve
addl $4, %esp
ret
在执行int 0x80
进入内核后,会将 ss、esp、eflags、cs、eip 依次入栈。第4行代码想要保存栈中 eip 的地址。加载可执行文件后,需要设置新的栈,重新设置程序运行地址,就会修改 eip 和 esp 的值。第5行将地址入栈,作为 do_execve 函数的参数,方便对 eip 和 esp 进行修改。
可以看到 do_execve 有5个参数。eip 是在 sys_execve 中入栈的,filename、argv、envp 是在 system_call 中入栈的,这个 tmp 又是在什么入栈的?是在call *sys_call_table(, %eax, 4)
指令执行后入栈的,call 命令会将 eip 入栈,ret 会将 eip 出栈。所以,tmp 的值是 call 的下一条指令。
// exec.c
int do_execve(unsigned long *eip, long tmp, char *filename, char **argv, char **envp)
{
struct elfhdr elf_ex;
struct m_inode *inode;
struct buffer_head *bh;
struct elf_phdr *elf_phdata;
int i;
int e_uid, e_gid;
if ((0xffff & eip[1]) != 0x000f)
panic("execve called from supervisor mode");
inode = namei(filename);
if (!inode)
return -ENOENT;
if (!S_ISREG(inode->i_mode)) // 必须是普通文件
return -EACCES;
i = inode->i_mode;
e_uid = (i & S_ISUID) ? inode->i_uid : current->euid;
e_gid = (i & S_ISGID) ? inode->i_gid : current->egid;
if (current->euid == inode->i_uid)
i >>= 6;
else if (current->egid == inode->i_gid)
i >>= 3;
if (!(i & 1) && !((inode->i_mode & 0111) && suser())) // 必须是可执行文件
return -ENOEXEC;
bh = bread(inode->i_dev, inode->i_zone[0]);
if (!bh)
return -EACCES;
elf_ex = *((struct elfhdr *)bh->b_data);
if (elf_ex.e_ident[0] != 0x7f ||
strncmp((char *)&elf_ex.e_ident[1], "ELF",3) != 0)
return -ENOEXEC;
if(elf_ex.e_type != ET_EXEC || elf_ex.e_machine != EM_386)
return -ENOEXEC;
elf_phdata = (struct elf_phdr *)(bh->b_data + elf_ex.e_phoff);
printk("Type: 0x%x\n", elf_phdata->p_type);
printk("Offset: 0x%x\n", elf_phdata->p_offset);
printk("VirtAddr: 0x%x\n", elf_phdata->p_vaddr);
printk("PhysAddr: 0x%x\n", elf_phdata->p_paddr);
printk("FileSiz: 0x%x\n", elf_phdata->p_filesz);
printk("MemSiz: 0x%x\n", elf_phdata->p_memsz);
printk("Flg: 0x%x\n", elf_phdata->p_flags);
printk("Align: 0x%x\n", elf_phdata->p_align);
current->euid = e_uid;
current->egid = e_gid;
return 0;
}
eip[1] 是 cs,在用户态 cs 的值为 0xf,在内核态 cs 的值为 0x8。如果0xffff & eip[1]
的结果为 0xf,说明是用户在调用程序。如果不是 0xf,那就可能是内核在调用该函数,明显有问题。
namei 的功能与 open_namei 相似,它会根据文件路径找到文件的 inode。如果没找到,说明文件路径有问题。
可执行文件是普通文件,如果你想执行一个目录或字符设备,那肯定是不能执行的。
用户必须对文件有执行权限。如果你使用chmod 666 main
把可执行文件的执行权限清理掉,那肯定也是不能执行的。
第31-33行:使用 bread 将文件头和程序头的信息读取到内存中,它们都在可执行文件的第一个逻辑块中。
第37-39行:elf 文件的前4个字符必须是 0x7f 和 ELF。不然这就不是一个 elf 文件。
第41-42行:我们的代码只能在x86 CPU 平台上运行。ET_EXEC 代表这是可执行文件,ET_REL 代表是可重定位文件(一般为 .o),ET_DYN代表是共享目标文件(一般为 .so)。
第44-52行:找到 elf 文件的程序头,并将程序头信息打印出来。
// namei.c
struct m_inode *namei(const char *pathname)
{
const char *basename;
int inr, dev, namelen;
struct m_inode *dir;
struct buffer_head *bh;
struct dir_entry *de;
dir = dir_namei(pathname, &namelen, &basename);
if (!dir) {
return NULL;
}
if (!namelen) {
return dir;
}
bh = find_entry(&dir, basename, namelen, &de);
if (!bh) {
iput(dir);
return NULL;
}
inr = de->inode;
dev = dir->i_dev;
brelse(bh);
iput(dir);
dir = iget(dev, inr);
if (dir) {
dir->i_atime = CURRENT_TIME;
dir->i_dirt = 1;
}
return dir;
}
dir_namei 会找到文件所在目录的 inode,find_entry 会在目录逻辑块中找到文件名,通过 de 返回文件 inode 号,iget 会读取文件的 inode,最后将文件的 inode 指针返回。
// main.c
static char *argv_rc[] = {"/bin/sh", NULL};
static char *envp_rc[] = {"HOME=/", NULL};
void init(void)
{
setup();
open("/dev/tty0", O_RDWR, 0);
dup(0);
dup(0);
if (fork() == 0)
execve("/usr/root/main", argv_rc, envp_rc);
while (1);
}
我们使用操作系统难道就只是运行操作系统内部的代码吗?肯定不是,连个 shell 都没有,谁愿意用啊!所以就需要用 execve 函数运行自己的程序。但完整的 execve 函数会将当前进程的代码替换为可执行文件的代码,而 init 的代码还没运行完,自然不希望代码被替换掉,于是乎,就创建子进程,在子进程中执行 execve。
main 是通过 libc 目录中的代码编译得到的可执行文件,将文件放入 rootimage 的方法会在第4节介绍。
下面来看看运行结果。
这个结果对不对呢?下面是 readelf 读取的程序头信息。
可以看到,二者是一样的。
这个 0x80000000 的虚拟地址是怎么来的?这是由 libc 目录下的 linker.lds 文件指定的。这里将程序的入口设置为 start 函数,代码段的起始地址设置为 0x8000000。
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(start)
SECTIONS
{
. = 0x80000000;
.text :
{
_text = .;
*(.text)
_etext = .;
}
. = ALIGN(8);
.data :
{
_data = .;
*(.data)
_edata = .;
}
.bss :
{
_bss = .;
*(.bss)
_ebss = .;
}
_end = .;
}
3.传递参数和环境变量
// main.c
static char *argv_rc[] = {"/bin/sh", NULL};
static char *envp_rc[] = {"HOME=/", NULL};
void init(void)
{
setup();
open("/dev/tty0", O_RDWR, 0);
dup(0);
dup(0);
if (fork() == 0)
execve("/usr/root/main", argv_rc, envp_rc);
while (1);
}
argv_rc 和 envp_rc 必须以 NULL 结尾。argv_rc 是传入的参数,envp 是环境变量。调用 fork 后子进程也能访问到这两个变量,但是 execve 函数会清除页表,argv 和 envp 的内容就会被丢弃,那怎么传递这两个变量呢?
execve 不仅会清除页表,也会向进程添加页面。这一节我们会为进程添加页面作为栈,把变量写入栈中。
// exec.c
int do_execve(unsigned long *eip, long tmp, char *filename, char **argv, char **envp)
{
struct elfhdr elf_ex;
struct m_inode *inode;
struct buffer_head *bh;
struct elf_phdr *elf_phdata;
int i, argc, envc;
int e_uid, e_gid;
unsigned long page[MAX_ARG_PAGES]; // 页面地址
unsigned long p = PAGE_SIZE * MAX_ARG_PAGES - 4; // 栈地址
unsigned long base;
unsigned int elf_entry, elf_brk;
unsigned int start_code, end_code, end_data;
int retval;
if ((0xffff & eip[1]) != 0x000f)
panic("execve called from supervisor mode");
for (i = 0; i < MAX_ARG_PAGES; i++)
page[i] = 0;
...
elf_phdata = (struct elf_phdr *)(bh->b_data + elf_ex.e_phoff);
if (elf_phdata->p_type == PT_LOAD) {
start_code = elf_phdata->p_vaddr;
end_code = elf_phdata->p_vaddr + elf_phdata->p_filesz;
end_data = end_code;
elf_brk = elf_phdata->p_vaddr + elf_phdata->p_memsz;
}
elf_entry = (unsigned int)elf_ex.e_entry - ELF_START_MMAP;
brelse(bh);
argc = count(argv);
envc = count(envp);
p = copy_strings(argc, argv, page, p);
p = copy_strings(envc, envp, page, p);
if (!p) {
retval = -ENOMEM;
goto exec_error;
}
base = get_base(current->ldt[1]); // 代码段基地址
free_page_tables(base, get_limit(0x0f));
free_page_tables(base, get_limit(0x17));
p = (unsigned long)create_tables((char *)p, argc, envc);
p += change_ldt(end_code - start_code, page) - MAX_ARG_PAGES * PAGE_SIZE;
current->brk = elf_brk - ELF_START_MMAP + base;
current->end_code = end_code - ELF_START_MMAP + base;
current->end_data = end_data - ELF_START_MMAP + base;
current->start_stack = p;
current->euid = e_uid;
current->egid = e_gid;
eip[0] = elf_entry; // eip
eip[3] = p; // esp
return 0;
exec_error:
iput(inode);
for (i = 0; i < MAX_ARG_PAGES; i++)
free_page(page[i]);
return retval;
}
这一次添加了不少变量。page 数组用来保存栈页面的基地址,老实说一个页面都根本用不完,毕竟页面的大小为 4K,我们不会传这么多参数给程序。p 代表了栈地址,之后会把它赋值给 esp。
第22-29行:程序的第一个程序头必定是 LOAD 段,但程序可能不只有一个 LOAD 段,这里只是处理简单的情况,更复杂的情况以后再讨论。这几行代码会获取程序的相关信息。之前说过,p_vaddr 到 p_filesz 是代码段和数据段,p_filesz 到 p_memsz 之间是 bss 段,p_memsz 以上的空间是堆和栈,堆从 bss 段之后开始向上增长,栈从进程的数据段界限开始向下增长,如下图所示。elf_entry 代表了 main 函数的地址。
第32-33行:计算参数和环境变量的个数。
第34-35行:申请页面用来保存 argv 和 envp 里的字符串,由于保存了字符串,栈指针也会发生改变。
第36-39行:由于 copy_strings 函数会申请页面,出现错误的情况下,需要将页面释放掉。
第42-43行:释放进程拥有的所有的页面,第41行释放进程代码段的页面,第42行释放进程数据段的页面。
第44行:设置进程的代码段和数据段的长度。将栈的页面添加到进程的地址空间中,这个过程会修改页目录项和页表项。
第45行:创建指向参数和环境变量的指针。copy_strings 只是将 “/bin/sh” 字符串写入页面中,我们还需要创建指向字符串的指针,以及指向指针的指针(argv 和 envp)。具体情况参见下面的图。
第54-55行:设置栈里的 eip 和 esp。退出系统调用时会将栈里的值加载到寄存器中。
第58-61行:如果发生错误,需要将 inode 和申请的页面释放掉,并返回错误号。
// exec.c
static int count(char **argv)
{
int i = 0;
char **tmp = argv;
if (tmp)
while (get_fs_long((unsigned long *)(tmp++)))
i++;
return i;
}
计算参数个数和环境变量个数的函数不难,因为 argv 和 envp 必须以 NULL 结尾,直接遍历就能得到数量。
// exec.c
static unsigned long copy_strings(int argc, char **argv, unsigned long *page, unsigned long p)
{
char *tmp, *pag = NULL;
int len, offset = 0;
while (argc-- > 0) {
// 获取字符串的首地址
tmp = (char *)get_fs_long(((unsigned long *)argv) + argc);
if (!tmp)
panic("argc is wrong");
// 计算字符串的长度
len = 0;
do {
len++;
} while (get_fs_byte(tmp++));
if (p - len < 0) {
return 0;
}
// 将字符串写入页面
while (len) {
--p;
--tmp;
--len;
if (--offset < 0) {
offset = p % PAGE_SIZE;
pag = (char *)page[p / PAGE_SIZE];
if (!pag) {
page[p / PAGE_SIZE] = get_free_page();
pag = (char *)page[p / PAGE_SIZE];
if (!pag)
return 0;
}
}
*(pag + offset) = get_fs_byte(tmp);
}
}
return p;
}
参数 page 数组用来保存栈的页面,参数 p 是新栈的栈指针。
这个函数主要有三步:获取字符串的首地址,计算字符串的长度,将字符串写入页面,直至把所有字符串都写入到页面中。我们为参数和环境变量预留了32个页面,即 128KB 空间,除非是故意,不然是不会将这32个页面用完,然后运行到第18行代码。如果没有足够的页面存放字符串,就申请空闲页面,并将地址存入 page 数组中。
我们的程序一般会将 main 函数定位为int main(int argc, char *argv[])
的形式,从未访问到参数。argc 是参数个数,argv 是字符指针数组,通过它可以访问到一系列指向字符串的指针。我们需要将这两个值连同环境变量指针一起入栈。
// exec.c
static unsigned long *create_tables(char *p, int argc, int envc)
{
unsigned long *argv, *envp;
unsigned long *sp;
sp = (unsigned long *)(0xfffffffc & (unsigned long)p); // 4字节对齐
sp -= envc + 1;
envp = sp;
sp -= argc + 1;
argv = sp;
put_fs_long((unsigned long)envp, --sp);
put_fs_long((unsigned long)argv, --sp);
put_fs_long((unsigned long)argc, --sp);
while (envc-- > 0) {
put_fs_long((unsigned long)p, envp++);
while (get_fs_byte(p++)); // 找到下一个字符串的首地址
}
put_fs_long(0, envp);
while (argc-- > 0) {
put_fs_long((unsigned long)p, argv++);
while (get_fs_byte(p++));
}
put_fs_long(0, argv);
return sp;
}
得知了参数个数和环境变量个数之后,就能计算出 argc 和 envp 的值,它们都指向指针数组,数组里的指针再指向字符串的首地址,数组以0结尾。
第15-23行代码将指针从低地址到高地址依次存放,都以0结尾。最后的结果应该如下所示。0xbffffd0 是 argc,0xbffffd4 是 argv,0xbffffd8 是 envp,0xbffffdc 是 argv[0],0xbffffe0 是 argv[1],0xbffffe4 是 envp[0],0xbffffe8 是 envp[1]。
// memory.c
unsigned long put_page(unsigned long page, unsigned long address)
{
unsigned long tmp, *page_table;
if (page < LOW_MEM || page >= HIGH_MEMORY)
printk("Trying to put page %p at %p\n", page, address);
if (mem_map[(page - LOW_MEM) >> 12] != 1)
printk("mem_map disagrees with %p at %p\n", page, address);
page_table = (unsigned long *)((address >> 20) & 0xffc); // 页目录项
if (*page_table & 1) // 是否存在页表
page_table = (unsigned long *)(0xfffff000 & *page_table);
else {
tmp = get_free_page();
if (!tmp)
return 0;
*page_table = tmp | 7; // 页表已存在,可读可写,用户可访问页表中的页
page_table = (unsigned long *)tmp;
}
page_table[(address >> 12) & 0x3ff] = page | 7; // 页面已存在,可读可写,用户可访问该页
return page;
}
put_page 会将页面映射到进程的地址空间中。page 是页面的物理地址,address 是要映射的虚拟地址。假如页面的物理地址是 0x100000,要映射的虚拟地址是 0x8000000,映射之后,我们就能通过访问 0x8000000 地址就能得到 0x100000 地址的数据。使用虚拟地址的主要原因是减少内存资源浪费,具体请看操作系统教科书。
第10行代码会找到页目录项,它记载了页表的地址和状态。页目录项的最低位是 P(Present),如果 P 为1说明页表存在,第12行得到页表的首地址。如果 P 为0说明页表不存在,我们需要先创建页表,将页表地址和状态保存到页目录项。
第20行会设置页表项的值,高20位是页面的物理地址,低12位是页面的属性。
因为 put_page 的页面之前没被使用过,不在高速缓冲中,所以不需要 invalidate 刷新高速缓冲。
// sched.h
#define _set_limit(addr, limit) \
__asm__("push %%edx\n\t" \
"movw %%dx,%0\n\t" \
"rorl $16,%%edx\n\t" \
"movb %1,%%dh\n\t" \
"andb $0xf0,%%dh\n\t" \
"orb %%dh,%%dl\n\t" \
"movb %%dl,%1\n\t" \
"pop %%edx" \
:: "m"(*(addr)), \
"m"(*((addr) + 6)), \
"d"(limit))
#define set_limit(ldt, limit) _set_limit(((char *)&(ldt)), (limit - 1) >> 12)
// exec.c
static unsigned long change_ldt(unsigned long text_size, unsigned long *page)
{
unsigned long code_limit, data_limit, data_base;
int i;
code_limit = text_size + PAGE_SIZE - 1;
code_limit &= 0xFFFFF000;
data_limit = 0x4000000;
set_limit(current->ldt[1], code_limit);
set_limit(current->ldt[2], data_limit);
data_base = get_base(current->ldt[2]);
data_base += data_limit;
for (i = MAX_ARG_PAGES - 1; i >= 0; i--) {
data_base -= PAGE_SIZE;
if (page[i])
put_page(page[i], data_base);
}
return data_limit;
}
这个函数会设置进程代码段和数据段的长度,并且将栈所在的页面映射到进程的地址空间中。_set_limit 需要参考 gdt 的结构进行理解。
其实 execve 函数的内容已经差不多了,最多再添加几行代码。不知道我这么说会不会让你觉得很奇怪。毕竟,我们还没读取代码段和数据段到内存中,页面映射也没有做,怎么就快完了?其实,加载代码段和数据段的代码并不在 execve 中。那么在哪里加载代码和数据呢?
让我们捋一下代码。首先在用户态调用 execve 函数,进入内核调用 sys_execve,再调用 do_execve,释放进程的所有页面,将栈的页面映射到进程的地址空间,设置 eip 和 esp ,退出系统调用。退出系统调用后会发生什么事情?进程没有 eip 地址所在的页面,于是触发 page fault。上次讲 page fault 还是在进程创建的时候。
# page.s
page_fault:
xchgl %eax, (%esp)
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10, %edx
mov %dx, %ds
mov %dx, %es
mov %dx, %fs
movl %cr2, %edx # 获得触发异常的线性地址
pushl %edx
pushl %eax
testl $1, %eax
jne 1f
call do_no_page
je 2f
1: call do_wp_page
2: addl $8, %esp
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret
第3行将出错原因保存在 eax 中,此时 eax 的值为 4,意思是用户态触发错误,读操作触发错误,由一个不存在的页触发错误。fork 后子进程写数据触发的 page fault,eax 的值是5。所以我们能够通过 eax 的最低位判断是哪种原因触发的错误。
// memory.c
void do_no_page(unsigned long error_code, unsigned long address)
{
panic("execve incurs page fault!");
}
具体的处理将在下一节介绍,这一节就简单地打印一句话就好了。运行结果也确实触发了 page fault。
4.加载代码和数据
我们想在缺页异常中读取可执行文件的代码和数据,不过在缺页异常中我们不知道文件路径或者其他的信息,怎么办呢?我们可以在 task_struct 结构体中添加一个成员,用于记录可执行文件的 inode。
// sched.h
struct task_struct {
...
struct m_inode *pwd; // 当前目录的inode
struct m_inode *root; // 根目录的inode
struct m_inode *executable; // 可执行文件的inode
unsigned long close_on_exec; // 运行可执行文件时关闭文件句柄位图
struct file *filp[NR_OPEN]; // 进程打开的文件
struct desc_struct ldt[3]; // 任务局部描述符表。0-空,1-代码段,2-数据和堆栈段
struct tss_struct tss; // 进程的任务状态段信息
};
executable 就是新添加的成员,我们需要在 fork 和 exit 中对它进行处理,处理方法与 pwd 和 root 一样。
// exec.c
int do_execve(unsigned long *eip, long tmp, char *filename, char **argv, char **envp)
{
...
if (current->executable)
iput(current->executable);
current->executable = inode;
for (i = 0; i < 32; i++)
current->sigaction[i].sa_handler = NULL;
eip[0] = elf_entry; // eip
eip[3] = p; // esp
return 0;
exec_error:
iput(inode);
for (i = 0; i < MAX_ARG_PAGES; i++)
free_page(page[i]);
return retval;
}
在 do_execve 中我们要设置 executable 的值,这样才能在缺页异常中使用,另外这里还初始化了信号处理函数。
// memory.c
void do_no_page(unsigned long error_code, unsigned long address)
{
int nr[4];
unsigned long tmp;
unsigned long page;
int block, i;
address &= 0xfffff000;
tmp = address - current->start_code;
if (!current->executable || tmp >= current->end_data) {
get_empty_page(address);
return;
}
if (share_page(tmp))
return;
page = get_free_page();
if (!page)
oom();
block = 4 + tmp / BLOCK_SIZE; // 从第4个逻辑块开始才是LOAD段
for (i = 0; i < 4; block++, i++)
nr[i] = bmap(current->executable, block);
bread_page(page, current->executable->i_dev, nr);
i = tmp + 4096 - current->end_data;
tmp = page + 4096;
while (i-- > 0) {
tmp--;
*(char *)tmp = 0;
}
if (put_page(page, address))
return;
free_page(page);
oom();
}
address 是出现缺页异常的虚拟地址,第9行代码得到所缺页面的首地址。tmp 代表页面相对代码段起始地址的偏移。
第11-14行:进程没有加载可执行文件,或者缺页的地址超出了界限,这些情况明显出现了问题,就只为其映射一个空闲页面。
第15行:判断可执行文件是否被其他进程加载到内存中,如果已经加载到内存中,只需要将页面映射到当前进程中就可以了。如果没有,就接着执行下面的代码。
第17行:申请一个空闲的页面,
第21行:之前的小节曾讲过,LOAD段在文件中的偏移是 0x1000,就是4个逻辑块的大小,所以我们需要跳过开始的4个逻辑块。
第22-23行:系统的1页是 4K,所以需要读取4个逻辑块,这里是在寻找逻辑块的块号。
第24行:将逻辑块读取到页面中。
第25-30行:将页面的剩余空间填充0。假如可执行文件的代码数据的总长度是4000,将这4000个字符读取到页面中,页面剩余的96个字符需要填充0。
第31行:将页面映射到进程的地址空间中,退出缺页异常后就能够正常运行了。
// memory.c
static int share_page(unsigned long address)
{
struct task_struct **p;
if (!current->executable)
return 0;
if (current->executable->i_count < 2)
return 0;
for (p = &LAST_TASK; p > &FIRST_TASK; --p) {
if (!*p)
continue;
if (current == *p)
continue;
if ((*p)->executable != current->executable)
continue;
if (try_to_share(address, *p))
return 1;
}
return 0;
}
首先检查是否加载了可执行文件。除了当前进程外,至少还得有一个进程加载了该可执行文件,这样才能共享可执行文件,所以 i_count 值至少为2。
第10-19行:检查是否有其他进程加载了这个可执行文件,如果有,就进入 try_to_share 函数。
// memory.c
static int try_to_share(unsigned long address, struct task_struct *p)
{
unsigned long from;
unsigned long to;
unsigned long from_page;
unsigned long to_page;
unsigned long phys_addr;
from_page = to_page = ((address >> 20) & 0xffc);
from_page += ((p->start_code >> 20) & 0xffc);
to_page += ((current->start_code >> 20) & 0xffc);
from = *(unsigned long *)from_page;
if (!(from & 1))
return 0;
from &= 0xfffff000;
from_page = from + ((address >> 10) & 0xffc);
phys_addr = *(unsigned long *)from_page;
if ((phys_addr & 0x41) != 0x01) // 页面是否被修改
return 0;
phys_addr &= 0xfffff000;
if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM)
return 0;
to = *(unsigned long *)to_page;
if (!(to & 1)) { // 当前进程是否存在该页表
to = get_free_page();
if (to)
*(unsigned long *)to_page = to | 7;
else
oom();
}
to &= 0xfffff000;
to_page = to + ((address >> 10) & 0xffc); // 找到页表项
if (1 & *(unsigned long *)to_page)
panic("try_to_share: to_page already exists");
*(unsigned long *)from_page &= ~2; // 只读
*(unsigned long *)to_page = *(unsigned long *)from_page; // 修改页表项
invalidate();
phys_addr -= LOW_MEM;
phys_addr >>= 12;
mem_map[phys_addr]++; // 引用数加1
return 1;
}
address = (当前进程所缺页面的虚拟地址 - 进程代码段起始地址),p 代表另一个加载了相同可执行文件的进程。我们得先检查另一个进程是否已经读取了这个页面的内容,如果没读取,就无法共享。
第10-12:计算两个进程的页目录项。找到页面对应的页表地址。
第14-16行:如果另一个进程还不存在这个页表,说明该进程还未读取该页面,就无法共享页面。
第17-19行:找到另一个进程的页表项。
第21-22行:页表项的第7位是脏位,如果页面数据被修改过,我们肯定不能给当前进程使用这个页面。
第26-33行:检查当前进程是否存在该页表,如果没有页表就创建一个,并赋予页表属性。
第34-37行:找到当前进程的页表项,如果该页表项已经映射了页面,那么肯定是出问题了。
第39-40行:将页面设置为只读,不然一个进程修改了数据,另一个进程访问这个数据得到的不是预期结果。不过这样会出问题吧,如果共享的是数据段,那么岂不是不让修改了。
第42-44行:更新页面的引用计数。
get_empty_page 函数很简单,我就不多讲了。
// buffer.c
#define COPYBLK(from, to) \
__asm__("cld\n\t" \
"rep\n\t" \
"movsl\n\t" \
:: "c"(BLOCK_SIZE / 4), \
"S"(from), "D"(to));
void bread_page(unsigned long address, int dev, int b[4])
{
struct buffer_head *bh[4];
int i;
for (i = 0; i < 4; i++)
if (b[i]) {
bh[i] = getblk(dev, b[i]);
if (bh[i])
if (!bh[i]->b_uptodate)
ll_rw_block(READ, bh[i]);
}
else
bh[i] = NULL;
for (i = 0; i < 4; i++, address += BLOCK_SIZE)
if (bh[i]) {
wait_on_buffer(bh[i]);
if (bh[i]->b_uptodate)
COPYBLK((unsigned long)bh[i]->b_data, address);
brelse(bh[i]);
}
}
bread_page 会读取4个逻辑块到文件缓冲区,再把数据从文件缓冲区转移到页面中。一个文件缓冲区的大小是1K,没办法做映射。
好了,终于说完了操作系统的所有代码,来看看要运行结果了。如下所示,可以看到无论是 argc 还是 argv 都正确打印了数值。
等等,qemu 的汇编数据有点问题啊。这是缺页异常后回到用户态的情况,Assembly 标签下的数据有点问题,和 main 里的数据不匹配。我只能说,这是正常情况。可以看到下面打印的 0x8000000 地址的数据是正常的,继续执行程序也是没有问题的。
下面是将可执行文件放入软盘的方法。
mkdir dir
sudo mount -t minix chapter_11/4th/rootimage dir
cp libc/main dir/usr/root/
sync
sudo umount dir
rmdir dir
这里假设是在工程的根目录下执行。将文件系统以 minix 文件系统格式挂载到 dir 目录,将可执行文件放入目录中,同步数据,然后解挂文件系统。
这样你就可以尝试运行自己的程序了。不过请注意,目前的 C 库几乎什么也没有,所以也不能完成稍难的任务。当然,你也可以尝试自己完成 C 库。
linux0.11 的内存模型只支持与地址无关的程序,在使用静态库的情况下,代码过多会被编译成与地址相关的程序,这种程序是无法在本系统中使用的。因为没有动态库的解析代码,所以没法使用动态库。就只能编点小程序自娱自乐。感觉这个功能好鸡肋呀。
本章小节
这章的代码参考了 linux0.11 和 linux1.2 的可执行文件相关的代码,一开始出现了不少 bug,比如一开始编译出与地址相关的程序,一直没发现问题在哪儿;又比如 qemu 上的可执行文件的汇编代码不对,我一直以为是没加载对。
现在在看 shell 的代码,一开始我准备是看 bash 源码的,但是 parse.y 我看不懂,又没有 lex 文件,直接放弃了。之后想编译一个32位的 bash 程序,但是最后编出来的程序是与地址相关的,搞不定啊!没办法,只能用 busybox 凑合了,不知道怎么在 busybox 上做 TAB 自动补全,问题一大堆啊。