issue24 实现一个简单的CRT
四月 20, 2024 [Linux 与系统] #Linux #Shell 与命令行 #obsidian #操作系统原理 #编译链接issue24 实现一个简单的CRT
本文介绍如何实现自己的CRT(c runtime library)来完成标准库glibc的部分工作,该CRT只提供了read, write, close, exit这几个系统调用,完整代码在mini_crt
本文所编写的代码在下面环境下测试通过
$ cat /etc/issue
Ubuntu 18.04.1 LTS
$ uname -a
Linux c1 4.15.0-36-generic #39-Ubuntu SMP Mon Sep 24 16:19:09 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
$ gcc --version
gcc (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0
$ ld --version
GNU ld (GNU Binutils for Ubuntu) 2.30
首先,我们需要实现一个入口函数, 它需要取得argc和argv, 然后调用main函数, 最终调用exit系统调用退出程序
我们平时编写的依赖于glibc的ELF可执行程序默认入口是_start, 定义在glibc/sysdeps/x86_64/start.S, 它是通过在链接控制脚本中指定的,可以通过$ ld -verbose查看ld使用的链接控制脚本
$ ld -verbose | grep ENTRY
ENTRY(_start)
_start函数除了上面描述的入口函数的功能还做了许多工作,这里不细究,部分内容可以参考我的另一篇文章glibc的构造函数和析构函数
实现的入口函数如下(entry.c)
#include "util.h"
extern int main(int argc, char **argv);
void mini_crt_entry()
{
int argc;
char **argv;
int status = 42;
char* rbp_reg = 0;
asm("mov %%rbp, %0 \n\t":"=m"(rbp_reg):);
argc = *((int *)(rbp_reg + 8));
argv = (char **)(rbp_reg + 16);
status = main(argc, argv);
exit(status);
}
argc和argv是从当前栈顶往高地址方向偏移8字节和16字节拿到的
为什么从堆栈中取得这argc和argv呢? 因为当ELF加载器(ELF Loader)将ELF文件加载到内存后,会初始化进程堆栈,包括argc, argv等, 所以在程序进入mini_crt_entry前, 进程堆栈中已经存放了argc和argv了,如下表所示,。进程堆栈初始化的细节可以看我的另一篇文章进程堆栈初始化
position content size(bytes) + comment
-------------------------------------------------------------------------------------------------
stack pointer -> [argc = number of args] 8
[argv[0](pointer)] 8
[argv[1](pointer)] 8
[argv[...](pointer)] 8 * x
[argv[n-1](pointer)] 8
[argv[n](pointer)] 8 (=NULL)
为什么是从偏移8字节开始取得呢?
因为函数执行的第一条指令就是保存堆栈基地址: push %rbp(重新强调下本文代码是基于64位机器编写的,因此是rbp寄存器), 栈增长了8字节, 所以我们需要从偏移8字节位置拿到argc
接下来在util.c中实现read, write, close和exit函数去调用真正的系统调用, 由于是在64位系统下编写, 所以使用了syscall来调用系统调用, 看懂下面代码需要了解Extended Asm(扩展汇编)的知识
#include "util.h"
long read(int fd, void *buf, long count)
{
long ret;
asm("movq $0, %%rax \n\t"
"movq %1, %%rdi \n\t"
"movq %2, %%rsi \n\t"
"movq %3, %%rdx \n\t"
"syscall \n\t"
"movq %%rax, %0 \n\t"
:"=m"(ret)
:"m"(fd),"m"(buf),"m"(count)
:"rax", "rdi", "rsi", "rdx");
return ret;
}
long write(int fd, const void *buf, long count)
{
long ret;
asm("movq $1, %%rax \n\t"
"movq %1, %%rdi \n\t"
"movq %2, %%rsi \n\t"
"movq %3, %%rdx \n\t"
"syscall \n\t"
"movq %%rax, %0 \n\t"
:"=m"(ret)
:"m"(fd),"m"(buf),"m"(count)
:"rax", "rdi", "rsi", "rdx");
return ret;
}
int open(const char *pathname, int flags, int mode)
{
long fd;
asm("movq $2, %%rax \n\t"
"movq %1, %%rdi \n\t"
"movq %2, %%rsi \n\t"
"movq %3, %%rdx \n\t"
"syscall \n\t"
"movq %%rax, %0 \n\t"
:"=m"(fd)
:"m"(pathname),"m"(flags),"m"(mode)
:"rax", "rdi", "rsi", "rdx");
return fd;
}
int close(int fd)
{
long ret;
asm("movq $3, %%rax \n\t"
"movq %1, %%rdi \n\t"
"syscall \n\t"
"movq %%rax, %0 \n\t"
:"=m"(ret)
:"m"(fd)
:"rax", "rdi");
return ret;
}
void exit(int status)
{
asm("movq $60, %%rax \n\t"
"movq %0, %%rdi \n\t"
"syscall \n\t"
:
:"m"(status));
}
util.h代码如下
#define O_RDONLY 00
#define O_WRONLY 01
#define O_RDWR 02
long read(int fd, void *buf, long count);
long write(int fd, const void *buf, long count);
int open(const char *pathname, int flags, int mode);
int close(int fd);
void exit(int status);
到这里一个简单的crt就完成了, 将代码编译成静态库
$ gcc -c -fno-builtin -nostdlib entry.c util.c
$ ar -rs minicrt.a util.o entry.o
编译选项
- -fno-builtin参数关闭gcc的内置函数功能, 默认情况下gcc会把strlen, strcmp, exit等常用函数展开它内部的实现
- -nostdlib表示不使用来自glibc, gcc的库文件和启动文件
接下来编写测试程序去使用这个库而不是默认的glibc, 该程序从命令行参数读取文件名, 然后根据文件名读取1KB内容输出到标准输出(文件描述符0)
#include "util.h"
int main(int argc, char **argv)
{
int fd;
long count = 0;
char buf[1024] = {0};
if (argc == 2) {
fd = open(argv[1], O_RDONLY, 0);
count = read(fd, buf, 1024);
write(1, buf, count);
close(fd);
}
return 0;
}
编译, 链接, 运行如下
$ gcc -c -fno-builtin -nostdlib test.c
$ ld -static -e mini_crt_entry test.o minicrt.a -o test
$ ./test test.c
#include "util.h"
int main(int argc, char **argv)
{
int fd;
long count = 0;
char buf[1024] = {0};
if (argc == 2) {
fd = open(argv[1], O_RDONLY, 0);
count = read(fd, buf, 1024);
write(1, buf, count);
close(fd);
}
return 0;
}
其中链接选项-e mini_crt_entry指定程序入口为mini_crt_entry函数
至此, 一个简单的C语言运行时库就完成了, 下面讲解编写该crt所遇到的坑
函数read, write的中的变量ret类型不能为int
比如我在read函数中将变量ret定义为int, 会导致错误Segmentation fault (core dumped)
使用objdump反编译查看汇编代码,比较ret为long和int情况有什么不同
# 这是ret为long的汇编代码
0000000000400159 <read>:
400159: 55 push %rbp
40015a: 48 89 e5 mov %rsp,%rbp
40015d: 89 7d ec mov %edi,-0x14(%rbp)
400160: 48 89 75 e0 mov %rsi,-0x20(%rbp)
400164: 48 89 55 d8 mov %rdx,-0x28(%rbp)
400168: 48 c7 c0 00 00 00 00 mov $0x0,%rax
40016f: 48 8b 7d ec mov -0x14(%rbp),%rdi
400173: 48 8b 75 e0 mov -0x20(%rbp),%rsi
400177: 48 8b 55 d8 mov -0x28(%rbp),%rdx
40017b: 0f 05 syscall
40017d: 48 89 45 f8 mov %rax,-0x8(%rbp)
400181: 48 8b 45 f8 mov -0x8(%rbp),%rax
400185: 5d pop %rbp
400186: c3 retq
# 这是ret为int的汇编代码
0000000000400159 <read>:
400159: 55 push %rbp
40015a: 48 89 e5 mov %rsp,%rbp
40015d: 89 7d ec mov %edi,-0x14(%rbp)
400160: 48 89 75 e0 mov %rsi,-0x20(%rbp)
400164: 48 89 55 d8 mov %rdx,-0x28(%rbp)
400168: 48 c7 c0 00 00 00 00 mov $0x0,%rax
40016f: 48 8b 7d ec mov -0x14(%rbp),%rdi
400173: 48 8b 75 e0 mov -0x20(%rbp),%rsi
400177: 48 8b 55 d8 mov -0x28(%rbp),%rdx
40017b: 0f 05 syscall
40017d: 48 89 45 fc mov %rax,-0x4(%rbp)
400181: 8b 45 fc mov -0x4(%rbp),%eax
400184: 48 98 cltq
400186: 5d pop %rbp
400187: c3 retq
区别在于40017d处的指令,mov %rax,-0x8(%rbp)和mov %rax,-0x4(%rbp),为什么右边这条指令会导致崩溃,原因在于该指令使用的偏移量有问题,导致指令push %rbp在栈中保存的值(main函数的堆栈基地址)被覆盖。下面我使用编译选项-g,然后用gdb调试证明堆栈确实被破坏了
(gdb) disas
Dump of assembler code for function read:
=> 0x0000000000400159 <+0>: push %rbp
0x000000000040015a <+1>: mov %rsp,%rbp
0x000000000040015d <+4>: mov %edi,-0x14(%rbp)
0x0000000000400160 <+7>: mov %rsi,-0x20(%rbp)
0x0000000000400164 <+11>: mov %rdx,-0x28(%rbp)
0x0000000000400168 <+15>: mov $0x0,%rax
0x000000000040016f <+22>: mov -0x14(%rbp),%rdi
0x0000000000400173 <+26>: mov -0x20(%rbp),%rsi
0x0000000000400177 <+30>: mov -0x28(%rbp),%rdx
0x000000000040017b <+34>: syscall
0x000000000040017d <+36>: mov %rax,-0x4(%rbp)
0x0000000000400181 <+40>: mov -0x4(%rbp),%eax
0x0000000000400184 <+43>: cltq
0x0000000000400186 <+45>: pop %rbp
0x0000000000400187 <+46>: retq
End of assembler dump.
(gdb) p $rbp
$1 = (void *) 0x7fffffffe418
(gdb) b *read+46
Breakpoint 3 at 0x400187: file util.c, line 15.
(gdb) c
Continuing.
Breakpoint 3, 0x0000000000400187 in read (fd=3, buf=0x7fffffffe008, count=1024) at util.c:15
15 }
(gdb) p $rbp
$2 = (void *) 0x7fff00000000
可以看到read函数一开始保存的rbp寄存器的值为0x7fffffffe418,当函数准备返回时,rbp寄存器的值却变成了0x7fff00000000,下面图示有助于理解是怎么被覆盖的

最后返回到main函数后堆栈基地址是错误的,那么使用该错误地址+偏移量去访问结果是未定义的
References
- 《程序员自我修养》Chapter13
- glibc的构造函数和析构函数
- 进程堆栈初始化