C 语言运行分析

C 语言运行分析

C 语言运行分析

我们编写的 C 语言代码都是从 main() 函数开始的。而实际的二进制代码是从 _start 开始的。从 _startmain 之间是一片魔法区域:编译器在这块区域中设置了环境变量、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 , 在这个函数内部进一步将栈中的数据拆分为 argcargv 参数作为用户 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);
}

由于这两段代码都只存在于库函数中,且是由编译器在编译时自动加入到代码中的,所以对用户来说没有任何感觉。

写在最后

使用函数调用来替代系统调用还存在一些需要解决的问题,例如用户态和内核态的切换等。

相关推荐
向宇it2 分钟前
【从零开始入门unity游戏开发之——C#篇03】变量和常量
开发语言·vscode·unity·c#·游戏引擎
重生之我是数学王子21 分钟前
ARM体系架构
linux·c语言·开发语言·arm开发·系统架构
飞的肖23 分钟前
RabbitMQ 安装、配置和使用介绍 使用前端js直接调用方式
开发语言·javascript·ruby
伍贰什丿32 分钟前
C语言学习day22:URLDownloadToFile函数/开发文件下载工具
c语言·c++·学习
earthzhang202133 分钟前
《深入浅出HTTPS》读书笔记(19):密钥
开发语言·网络协议·算法·https·1024程序员节
Cooloooo37 分钟前
Treap树堆【东北大学oj数据结构8-4】C++
开发语言·数据结构·c++
CN.LG42 分钟前
浅谈Java注解之CachePut
java·开发语言·spring
好开心331 小时前
2.17、vue的生命周期
java·开发语言·前端·javascript·vue.js·前端框架·ecmascript
PieroPc1 小时前
Python 写个 《系统信息采集工具》为重装系统做准备。。。
开发语言·python
破局缘1 小时前
apt文件问题ruby.list文件
开发语言·windows·ruby