C 语言运行分析
C 语言运行分析
我们编写的 C
语言代码都是从 main()
函数开始的。而实际的二进制代码是从 _start
开始的。从 _start
到 main
之间是一片魔法区域:编译器在这块区域中设置了环境变量、main
函数的参数等隐形信息,使我们的代码能够更好的运行 ------ 这片区域就是 C
语言运行时。
C
语言运行时 C RunTime
简称为 crt
。在我们编译的 C
代码编译时,C
语言编译器会自动在我们的代码前增加 crt
的相关代码。
C
库分析
以下以 musl
库进行 riscv64
交叉编译为例来进行说明:
shell
#编译命令
riscv64-linux-musl-gcc --static -no-pie -O2 main.c
main.c
代码
c
#include <stdio.h>
int main(int argc, char **argv)
{
printf("hello world!\n");
return 0;
}
其生成的可执行文件使用命令 objdump
查看
shell
riscv64-linux-musl-objdump -d a.out
shell
... ...
0000000000010156 <_start>:
10156: 00003197 auipc gp,0x3
1015a: 6aa18193 addi gp,gp,1706 # 13800 <__global_pointer$>
1015e: 850a mv a0,sp
10160: 00000593 li a1,0
10164: ff017113 andi sp,sp,-16
10168: a009 j 1016a <_start_c>
000000000001016a <_start_c>:
1016a: 410c lw a1,0(a0)
1016c: 00850613 addi a2,a0,8
10170: 4781 li a5,0
10172: 00003717 auipc a4,0x3
10176: fe673703 ld a4,-26(a4) # 13158 <_GLOBAL_OFFSET_TABLE_+0x50>
1017a: 00003697 auipc a3,0x3
1017e: fae6b683 ld a3,-82(a3) # 13128 <_GLOBAL_OFFSET_TABLE_+0x20>
10182: 00003517 auipc a0,0x3
10186: fbe53503 ld a0,-66(a0) # 13140 <_GLOBAL_OFFSET_TABLE_+0x38>
1018a: ac25 j 103c2 <__libc_start_main>
... ...
从上面的输入中可以看出,代码从 _start
开始运行经过 _start_c
进入 __libc_start_main
函数。我们从 musl
库中找到相应的代码。
_start
代码
这部分代码是汇编代码,是和构架有关的代码,这里生成的是 riscv64
的代码,所以在 arch/riscv64/crt_arch.h
中
assembly
__asm__(
".section .sdata,\"aw\"\n"
".text\n"
".global " START "\n"
".type " START ",%function\n"
START ":\n"
".weak __global_pointer$\n"
".hidden __global_pointer$\n"
".option push\n"
".option norelax\n\t"
"lla gp, __global_pointer$\n"
".option pop\n\t"
"mv a0, sp\n"
".weak _DYNAMIC\n"
".hidden _DYNAMIC\n\t"
"lla a1, _DYNAMIC\n\t"
"andi sp, sp, -16\n\t"
"tail " START "_c"
);
这段代码主要的作用是定义了 _start
段,并初始化了运行环境:
START
可以认为是一个宏,由于该部分的代码是作为头文件插入到代码中的,所以该宏会根据实际的定义而有所不同,一般被重定义为_start
;mv a0, sp
加载器会将环境变量和main
函数的外部参数存放在栈上,通过这条指令可以将栈上的数据转化为参数传递给后续的函数------根据abi
的规范,a0
寄存器中的存放被调用函数的第一个参数;addi sp, sp, -16
调整了栈指针;
_start_c
代码
_start_c
的代码有两种定义:
- 一种是直接在
crt/crt1.c
中直接定义; - 一种是在
crt/rcrt1.c
中通过宏#define _dlstart_c _start_c
来定义;
但无论是在那里定义,最后都会调用到 __libc_start_main
函数。
__libc_start_main
代码
c
int __libc_start_main(int (*main)(int,char **,char **), int argc, char **argv,
void (*init_dummy)(), void(*fini_dummy)(), void(*ldso_dummy)())
{
char **envp = argv+argc+1;
/* External linkage, and explicit noinline attribute if available,
* are used to prevent the stack frame used during init from
* persisting for the entire process lifetime. */
__init_libc(envp, argv[0]);
/* Barrier against hoisting application code or anything using ssp
* or thread pointer prior to its initialization above. */
lsm2_fn *stage2 = libc_start_main_stage2;
__asm__ ( "" : "+r"(stage2) : : "memory" );
return stage2(main, argc, argv);
}
这段代码对 libc
库进行初始化,并在最后通过 libc_start_main_stage2
函数调用了用户编写的 main
函数。
libc_start_main_stage2 函数
c
static int libc_start_main_stage2(int (*main)(int,char **,char **), int argc, char **argv)
{
char **envp = argv+argc+1;
__libc_start_init();
/* Pass control to the application */
exit(main(argc, argv, envp));
return 0;
}
应用前景
系统调用的问题
系统调用的方案是目前操作系统中常用的一种用户程序和内核程序通讯的一种方式,其利用了硬件提供的统一界面将用户程序和内核程序进行解耦,使得用户程序和内核程序可以单独进行开发而不相互影响。但这种方案需要先将用户的程序转换成系统调用的硬件格式(系统调用号、参数等),经硬件进行状态切换后再将转换后的硬件格式转回函数调用的格式,即 abi
格式 => 系统调用的硬件格式 => abi
格式。这在一定程度上增加了系统的开销,并且由于不同硬件的约束,系统调用的硬件格式不统一,需要增加硬件相关的代码------这部分通常使用汇编进行编写,增加了系统开发的难度。
这里给出的思路是:采用函数调用来替代系统调用 。
函数调用的优势和问题
函数调用没有了系统调用的格式转换问题,可以减少系统的开销;并且采用统一的 abi
格式使得系统开发工程师不需要对硬件的深入了解。
但函数调用由于其地址具有不确定性(每次编译后函数的位置都可能发生变化),这给该方案的应用带来了不小的挑战。
这里根据上面的分析,提出一种解决思路。
再探 _start_c
这里只分析最简单的 crt/crt1.c
中的代码
c
hidden void _start_c(long *p)
{
int argc = p[0];
char **argv = (void *)(p+1);
__libc_start_main(main, argc, argv, _init, _fini, 0);
}
在 crt_arch.h
中,已经通过汇编指令 mv a0, sp
将栈中的数据转化为参数传递给了 _start_c
函数,即函数中的 long *p
, 在这个函数内部进一步将栈中的数据拆分为 argc
和 argv
参数作为用户 main
函数的参数向后传递。
通过上面对 C
代码的运行分析,可以看出从 _start_c
开始,程序进行到 C
代码进行开发,并且此段代码是受编译器影响最小的一段代码;所以可以在此代码中做一些变化,以便适应我们新开发出的加载器。
我们将上面的代码更改一下:
c
unsigned long volatile abi_table = 0;
hidden void _start_c(long *p)
{
abi_table = *p;
p++;
int argc = p[0];
char **argv = (void *)(p+1);
__libc_start_main(main, argc, argv, _init, _fini, 0);
}
在内核中,用户程序加载器(loader)会提前将内核功能列表的指针保存到用户的栈上。
在上面的代码中设置一个系统功能地址列表的指针 abi_table
,用以指向内核提供的系统功能列表。在后续遇到需要调用系统提供的功能时,可以通过该指针和系统功能号(偏移量)来找到对应的功能的映射。这样就可以在用户程序中通过函数调用的方式来使用系统功能。
在库函数可以使用该函数:
c
#define SYS_PUTCHAR 2
extern unsigned long volatile abi_table;
void putschar(char c)
{
void (*func)(char c);
func = (abi_table + 8 * SYS_PUTCHAR);
func(c);
}
由于这两段代码都只存在于库函数中,且是由编译器在编译时自动加入到代码中的,所以对用户来说没有任何感觉。
写在最后
使用函数调用来替代系统调用还存在一些需要解决的问题,例如用户态和内核态的切换等。