一、C 运行时环境
前面执行的用户程序都是使用汇编语言编写的。我们写的汇编还主动调用了exit系统调用。但是正常C语言程序不会主动调用exit。因此从execve进入用户程序到退出还是一些细节需要补充。
ISO C 标准定义了 main 函数可以定义为没有参数的函数,或者带有两个参数 argc 和 argv 的函数,表示命令行参数的数量和字符指针:
int main (int argc, char *argv[])
而对于 UNIX 系统,main 函数的定义有第三种方式:
int main (int argc, char *argv[], char *envp[])
其中,第三个参数 envp 表示系统环境变量的字符指针数组,环境变量字符串是形如 NAME=value 这样以 = 连接的字符串,比如 SHELL=/bin/sh。
在执行 main 函数之前,libc C 函数库需要首先为程序做初始化,比如初始化堆内存,初始化标准输入输出文件,程序参数和环境变量等,在 main 函数结束之后,还需要做一些收尾的工作,比如执行 atexit 3 注册的函数,和调用 exit 系统调用,让程序退出。所以 ld 默认的入口地址在 _start,而不是 main。
1.1、C 运行时环境实现
在定义 C 运行时环境是,首先我们的操作系统需要有自己的标准库 libc。因此我们需要修改makefile,生成自己的标准库:
$(BUILD)/lib/libc.o: $(BUILD)/lib/crt.o \ # 将内核实现的一些库封装为标准库
$(BUILD)/lib/crt1.o \
$(BUILD)/lib/string.o \
$(BUILD)/lib/vsprintf.o \
$(BUILD)/lib/stdlib.o \
$(BUILD)/lib/syscall.o \
$(BUILD)/lib/printf.o \
$(BUILD)/lib/assert.o \
ld -m elf_i386 -r $^ -o $@
$(BUILD)/builtin/%.out: $(BUILD)/builtin/%.o \ # 使用自己的标准库来编译链接用户程序
$(BUILD)/lib/libc.o \
ld -m elf_i386 -static $^ -o $@ -Ttext 0x1001000
这里有一点需要注意,之前的assert断言库的实现使用了内核的打印函数,用户程序是无法使用的。因此在lib中需要使用printf再次实现断言。重点是main函数执行前和执行后需要做的工作,这里在crt中实现:
#include <onix/types.h>
#include <onix/syscall.h>
#include <onix/string.h>
int main(int argc, char **argv, char **envp);
weak void _init() // libc 构造函数
{
}
weak void _fini() // libc 析构函数
{
}
int __libc_start_main(
int (*main)(int argc, char **argv, char **envp),
int argc, char **argv,
void (*_init)(),
void (*_fini)(),
void (*ldso)(), // 动态连接器
void *stack_end)
{
char **envp = argv + argc + 1;
_init();
int i = main(argc, argv, envp);
_fini();
exit(i);
}
如上所示,首先定义了构造函数和析构函数,这里没有定义函数体,只定义了框架,暂时用不到。__libc_start_main 为 libc 执行时的main函数,可以看到它调用了应用程序的main函数,并执行exit系统调用做了进程退出。程序开始的位置应该是_start。因此我们这里使用汇编定义了C 运行时启动文件的入口。如下:
[bits 32]
section .text
global _start
extern __libc_start_main
extern _init
extern _fini
extern main
_start:
xor ebp, ebp; 清除栈底,表示程序开场
pop esi; 栈顶参数为 argc
mov ecx, esp; 其次为 argv
and esp, -16; 栈对齐,SSE 需要 16 字节对齐
push eax; 感觉没什么用
push esp; 用户程序栈最大地址
push edx; 动态链接器
push _init; libc 构造函数
push _fini; libc 析构函数
push ecx; argv
push esi; argc
push main; 主函数
call __libc_start_main
ud2; 程序不可能走到这里,不然可能是其他什么地方有问题
_start 之所以是程序入口点,不是 C 标准规定的,而是 系统级 ABI(应用二进制接口, Application Binary Interface) 的约定。每个操作系统的可执行文件格式都有一个「入口点」(Entry Point)字段这个字段告诉内核:当加载可执行文件到内存后,从哪条指令开始执行,而这个地址,就是链接器根据 _start 符号来设置的。当然在链接时也可以人为改掉,入口不使用_start。
1.2、C 运行时环境测试
有了 C 运行时环境,用户程序就可以写C语言的代码了:
#ifdef ONIX
#include <onix/types.h>
#include <onix/stdio.h>
#include <onix/syscall.h>
#include <onix/string.h>
#else
#include <stdio.h>
#include <string.h>
#endif
int main(int argc, char const *argv[], char const *envp[])
{
for (size_t i = 0; i < argc; i++)
{
printf("%s\n", argv[i]);
}
for (size_t i = 0; 1; i++)
{
if (!envp[i])
break;
int len = strlen(envp[i]);
if (len >= 1022)
continue;
printf("%s\n", envp[i]);
}
return 0;
}
如上用户程序,定义两种方式,如果没有定义ONIX。则使用开发机操作系统的标准库来执行,如果定义了,那说明视图我们自己的操作系统中的 libc 标准库链接的。这个定义是在 makefile 中写死的。
编译时,ld 会把 hello.o 和 libc.o 一起合并成一个 ELF 可执行文件。 hello.o 里定义了 main。libc.o(由 crt1.o 等组成)里定义了 _start,链接器会自动把 _start 当成入口点。
二、参数和环境变量
调用 execve 系统调用时,操作系统会把参数 argv 和环境变量 envp 拷贝到用户栈顶,然后跳转到程序入口地址。参数和环境变量的内存布局如下图所示:
static int count_argv(char *argv[]) // 计算参数数量
{
if (!argv) // 如果没有参数,直接返回0
return 0;
int i = 0;
while (argv[i])
i++;
return i;
}
static u32 copy_argv_envp(char *filename, char *argv[], char *envp[])
{
int argc = count_argv(argv) + 1; // 计算参数数量
int envc = count_argv(envp); // 计算环境变量数量
u32 pages = alloc_kpage(4); // 分配内核内存,用于临时存储参数
u32 pages_end = pages + 4 * PAGE_SIZE;
char *ktop = (char *)pages_end; // 内核临时栈顶地址
char *utop = (char *)USER_STACK_TOP; // 用户栈顶地址
char **argvk = (char **)alloc_kpage(1); // 内核参数
argvk[argc] = NULL; // 以 NULL 结尾
char **envpk = argvk + argc + 1; // 内核环境变量
envpk[envc] = NULL; // 以 NULL 结尾
int len = 0;
for (int i = envc - 1; i >= 0; i--) // 拷贝 envp
{
len = strlen(envp[i]) + 1; // 计算长度
ktop -= len; // 得到拷贝地址
utop -= len;
memcpy(ktop, envp[i], len); // 拷贝字符串到内核
envpk[i] = utop; // 数组中存储的是用户态栈地址
}
for (int i = argc - 1; i > 0; i--) // 拷贝 argv
{
len = strlen(argv[i - 1]) + 1; // 计算长度
ktop -= len; // 得到拷贝地址
utop -= len;
memcpy(ktop, argv[i - 1], len); // 拷贝字符串到内核
argvk[i] = utop; // 数组中存储的是用户态栈地址
}
len = strlen(filename) + 1; // 拷贝 argv[0],程序路径
ktop -= len;
utop -= len;
memcpy(ktop, filename, len);
argvk[0] = utop;
ktop -= (envc + 1) * 4; // 将 envp 数组拷贝内核
memcpy(ktop, envpk, (envc + 1) * 4);
ktop -= (argc + 1) * 4; // 将 argv 数组拷贝内核
memcpy(ktop, argvk, (argc + 1) * 4);
ktop -= 4; // 为 argc 赋值
*(int *)ktop = argc;
assert((u32)ktop > pages);
len = (pages_end - (u32)ktop); // 将参数和环境变量拷贝到用户栈
utop = (char *)(USER_STACK_TOP - len);
memcpy(utop, ktop, len);
free_kpage((u32)argvk, 1); // 释放内核内存
free_kpage(pages, 4);
return (u32)utop;
}
然后再 execve 系统调用的实现中,进行环境变量的处理,这样就实现了把参数和用户变量拷贝到了用户栈顶。然后在shell定义几个环境变量进行测试:
static char *envp[] = {
"HOME=/",
"PATH=/bin",
NULL,
};
三、内建命令优化
在shell中我们实现的命令如 echo、ls、 cat 等都是直接写死了再osh.c文件中,有了参数之后,这个命令的实现就可以单独存放一个文件,不再需要全部写到shell中了。如echo的实现:
#ifdef ONIX
#include <onix/types.h>
#include <onix/stdio.h>
#include <onix/syscall.h>
#include <onix/string.h>
#else
#include <stdio.h>
#include <string.h>
#endif
int main(int argc, char const *argv[])
{
for (size_t i = 1; i < argc; i++)
{
printf(argv[i]);
if (i < argc - 1)
{
printf(" ");
}
}
printf("\n");
return 0;
}
如上echo单独实例,然后编译为echo.out文件,存放到bin目录下,这样shell的 execute函数就会走如下分支:
static void execute(int argc, char *argv[])
{
char *line = argv[0];
stat_t statbuf;
sprintf(buf, "/bin/%s.out", argv[0]);
if (stat(buf, &statbuf) == EOF)
{
printf("osh: command not found: %s\n", argv[0]);
return;
}
return builtin_exec(buf, argc - 1, &argv[1]);
}
首先会判断exec.out存在,然后去只执行这个文件。就是一个完整的执行用户程序的流程。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付