# 使用内嵌汇编触发 time/gettimeofday 系统调用
首先编写一个 test.c 程序,该程序会通过 gettimeofday 库函数来触发系统调用。
gettimeofday
是 C 库提供的函数,它封装了内核里的 sys_gettimeofday
系统调用。
在 ARM64 架构下 Linux 系统调用由同步异常 svc 指令触发。当用户态(EL0 级)程序调用库函数从而触发系统调用的时候,先把系统调用的参数依次放入 X0-X5 这 6 个寄存器( Linux 系统调用最多有 6 个参数,ARM64 函数调用参数可以使用 X0-X7 这 8 个寄存器),然后把系统调用号放在 X8 寄存器 里,最后执行 svc 指令,CPU 即进入内核态(EL1 级)。svc 指令一般会带一个立即数参数,一般是 0x0,但并没有被 Linux 内核使用,而是把系统调用号放到了 X8 寄存器 里。
#include <stdio.h> | |
#include <time.h> | |
#include <sys/time.h> | |
int main() | |
{ | |
time_t tt; | |
struct timeval tv; | |
struct tm *t; | |
#if 0 | |
gettimeofday(&tv,NULL); // 使用库函数的方式触发系统调用 | |
#else | |
asm volatile( // 使用内嵌汇编的方式触发系统调用 | |
"add x0, x29, 16\n\t" //X0 寄存器用于传递参数 & amp;tv | |
"mov x1, #0x0\n\t" //X1 寄存器用于传递参数 NULL | |
"mov x8, #0xa9\n\t" // 使用 X8 传递系统调用号 169 | |
"svc #0x0\n\t" // 触发系统调用 | |
); | |
#endif | |
tt = tv.tv_sec; //tv 是保存获取时间结果的结构体 | |
t = localtime(&tt); // 将世纪秒转换成对应的年月日时分秒 | |
printf("time: %d/%d/%d %d:%d:%d\n", | |
t->tm_year + 1900, | |
t->tm_mon, | |
t->tm_mday, | |
t->tm_hour, | |
t->tm_min, | |
t->tm_sec); | |
return 0; | |
} |
交叉编译 test.c,这里要使用静态编译,因为默认的动态链接编译产生的二进制文件并不会有 gettimeofday 系统调用的入口,只有相应的库函数。
aarch64-linux-gnu-gcc -o test test.c -static |
然后把 test 复制到根文件系统中,用 ARM 环境下编译的 busybox 重新制作一个根文件系统,test 可执行文件就在虚拟机的根目录下了。然后重新编译下:
make ARCH=arm64 Image -j8 CROSS_COMPILE=aarch64-linux-gnu- |
# 分析
启动 qemu:
qemu-system-aarch64 -m 512M -smp 4 -cpu cortex-a57 -machine virt -kernel arch/arm64/boot/Image -append "rdinit=/linuxrc nokaslr console=ttyAMA0 loglevel=8" -nographic -s |
在 VSCode 中启动调试。这里触发的系统调用对应的内核函数是__arm64_sys_gettimeofday。
ARM64 架构的 CPU 中的 系统调用和其他异常的处理过程大致相同。异常发生时,CPU 首先把异常的原因,这里是比如执行 svc 指令触发系统调用放在 ESR_EL1 寄存器里,然后把当前的处理器状态 PSTATE 放入 SPSR_EL1 寄存器里,把当前程序指针寄存器 PC 的值存入 ELR_EL1 寄存器里来保存断点,然后 CPU 通过异常向量表基地址和异常的类型计算出异常处理程序的入口地址,即 VBAR_EL1 寄存器加上偏移量取得异常处理的入口地址,接着开始执行异常处理入口的第一行代码。
以 svc 指令对应的 el0_sync 为例。
el0_sync 主要分为两部分:
- 第一部分实现从用户空间到内核空间的上下文切换: kernel_entry 0;
- 第二部是根据异常症状寄存器 esr_el1 判断异常原因,然后再进入具体处理函数。
el0_sync 首先执行 kernel_entry 0,将通用寄存器 x0~x29 保存到当前进程的内核栈,然后是从 SP_EL0、SPSR_EL1、ELR_EL1 寄存器中读取用户栈栈顶地址、发生异常时的处理器状态和返回地址,将这三个值以及发生异常时的 LR 寄存器中的值都保存到当前进程的内核栈中,以 struct pt_regs 结构体的格式保存在当前进程内核栈的栈底,完成保存现场过程。
系统调用是用户态执行 SVC 指令导致的,因此要进入 el0_svc 处理函数,根据 ESR_EL1 寄存器中的内容跳转到 el0_svc,el0_svc 会调用 el0_svc_handler、el0_svc_common 函数,将 X8 寄存器(regs->regs [8])中存放的系统调用号传递给 invoke_syscall 函数。
接着执行 invoke_syscall 函数,将通用寄存器中的内容传入 syscall_fn (),引出系统调用内核处理函数 __arm64_sys_gettimeofday,然后等系统调用内核处理函数执行完成,会将系统调用的返回值存放在 X0 寄存器中。
从系统调用返回前会处理一些工作(work_pending),比如处理信号、判断是否需要进程调度等,ret_to_user 的最后是 kernel_exit 0 负责恢复现场,与保存现场 kernel_entry 0 相对应,kernel_exit 0 的最后会执行 eret 指令系统调用返回。eret 指令所做的工作与 svc 指令相对应,eret 指令会将 ELR_EL1 寄存器里值恢复到程序指针寄存器 PC 中,把 SPSR_EL1 寄存器里的值恢复到 PSTATE 处理器状态中,同时会从内核态转换到用户态。