本博客基于 Ubuntu 20.04.5 LTS,内核版本 5.4.34,通过 qemu 在 x86 的宿主机上模拟 ARM64 的环境。其它 Linux 发行版以及内核版本请自行对个别命令进行调整。
一、实验准备
在上一篇博客中,我们已经搭建了基于 x86 的 Linux 内核调试环境。搭建 ARM64 的调试环境和 x86 大同小异,本部分仅列出有差异的地方。
首先,由于个人电脑几乎都是使用基于 x86 架构的处理器,要编译和调试 ARM64 环境,必须安装交叉编译工具链和跨平台版 gdb,命令如下:
sudo apt-get install gcc-aarch64-linux-gnu sudo apt-get install libncurses5-dev build-essential git bison flex libssl-dev sudo apt install gdb-multiarch
其次,我们需要为 ARM64 新建一个内核编译配置文件,对这个新配置文件进行修改。
make defconfig ARCH=arm64 make menuconfig ARCH=arm64
和 x86 一样在菜单中选择如下编译配置,注意部分选项的位置略有不同。
Kernel hacking ---> Compile-time checks and compiler options ---> [*] Compile the kernel with debug info [*] Provide GDB scripts for kernel debugging [*] Kernel debugging Kernel Features ----> [] Randomize the address of the kernel image
另外 ,在前一篇文章的基础上还需要在终端提前 export 交叉编译选项再 make(否则编译的还是 x86 的内核),命令如下。
export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- make Image -j$(nproc) # 这里指定target为Image,只编译kernel不会编译modules,加快编译速度
同样地,编译 busybox 之前也要 export 交叉编译选项。
export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu-
最后,笔者发现使用 sudo apt install qemu 命令安装的 qemu 无法运行 ARM64 环境的虚拟机(可能是 apt 安装的虚拟机版本过低),需要先 sudo apt remove qemu 删除 apt 安装的虚拟机,再运行以下命令从源码编译安装。笔者这里选择 qemu 4.2.1 版本。
sudo apt-get install build-essential zlib1g-dev pkg-config libglib2.0-dev binutils-dev libboost-all-dev autoconf libtool libssl-dev libpixman-1-dev wget https://download.qemu.org/qemu-4.2.1.tar.xz tar xvJf qemu-4.2.1.tar.xz cd qemu-4.2.1 ./configure --target-list=x86_64-softmmu,x86_64-linux-user,arm-softmmu,arm-linux-user,aarch64-softmmu,aarch64-linux-user --enable-kvm make sudo make install
启动虚拟机的命令也要作相应修改。
qemu-system-aarch64 -m 128M -smp 1 -cpu cortex-a57 -machine virt -kernel linux-5.4.34/arch/arm64/boot/Image -initrd rootfs.cpio.gz -append "rdinit=/init console=ttyAMA0 loglevel=8" -nographic -s
如果要在 VSCode 上调试,还需要更改 .vscode 目录下的 launch.json 和 tasks.json,参考配置如下。
{ // launch.json // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "(gdb) linux", "type": "cppdbg", "request": "launch", "preLaunchTask": "vm", "program": "${workspaceRoot}/vmlinux", "miDebuggerPath":"/usr/bin/gdb-multiarch", "miDebuggerServerAddress": "localhost:1234", "args": [], "stopAtEntry": true, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb", "miDebuggerArgs": "-n", "targetArchitecture": "x64", "setupCommands": [ { "text": "dir .", "ignoreFailures": false }, { "text": "add-auto-load-safe-path ./", "ignoreFailures": false }, { "text": "-enable-pretty-printing", "ignoreFailures": true } ] } ] }
{ // tasks.json // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "vm", "type": "shell", "command": "qemu-system-aarch64 -m 128M -smp 1 -cpu cortex-a57 -machine virt -kernel arch/arm64/boot/Image -initrd ../rootfs-arm.cpio.gz -append \"rdinit=/init console=ttyAMA0 loglevel=8\" -nographic -s", "presentation": { "echo": true, "clear": true, "group": "vm" }, "isBackground": true, "problemMatcher": [ { "pattern": [ { "regexp": ".", "file": 1, "location": 2, "message": 3 } ], "background": { "activeOnStart": true, "beginsPattern": ".", "endsPattern": ".", } } ] }, { "label": "build linux", "type": "shell", "command": "make", "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": false, "group": "build" } } ] }
二、使用内嵌汇编触发 time/gettimeofday 系统调用
使用内嵌汇编触发 gettimeofday 的用户态 C 语言示例代码如下。
#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寄存器用于传递参数&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,运行以下命令将其编译为 ARM64 下的可执行文件。注意要使用静态编译,因为默认的动态链接编译产生的二进制文件并不会有 gettimeofday 系统调用的入口,只有相应的库函数。
aarch64-linux-gnu-gcc -o test test.c -static
将可执行文件放在上一篇文章的 rootfs 文件夹(内存文件系统根目录)中,用 ARM 环境下编译的 busybox 重新制作一个根文件系统,test 可执行文件就在虚拟机的根目录下了。
在 VSCode 中启动调试。首先在窗口左下角的断点设置处新增断点 __arm64_sys_gettimeofday(32 位 ARM 下是 sys_gettimeofday,注意不要搞错了),再在终端中执行 test,可以看到调试器成功在对应的内核函数处停了下来。
三、分析 time/gettimeofday 系统调用的执行过程
通过查看调用堆栈,我们能很容易地分析出 ARM64 下系统调用的执行过程。ARM64 架构下 Linux 系统调用由同步异常 svc 指令触发,当用户态(EL0 级)程序调用库函数 gettimeofday() 从而触发系统调用的时候,先把系统调用的参数依次放入 X0-X5 这 6 个寄存器(Linux 系统调用最多有 6 个参数,ARM64 函数调用参数可以使用 X0-X7 这 8 个寄存器),然后把系统调用号放在 X8 寄存器里,最后执行 svc 指令,CPU 即进入内核态(EL1 级)。本文使用内嵌汇编触发系统调用,我们也编写相应的汇编代码完成了上述过程。
svc指令一般会带一个立即数参数,一般是 0x0,但并没有被 Linux内核使用,而是把系统调用号放到了 X8寄存器里。
ARM64 架构的 CPU 中,Linux 系统调用(同步异常)和其他异常的处理过程大致相同。异常发生时,CPU 首先把异常的原因(比如执行 svc 指令触发系统调用)放在 ESR_EL1 寄存器里;把当前的处理器状态(PSTATE)放入 SPSR_EL1 寄存器里;把当前程序指针寄存器 PC 的值存入 ELR_EL1 寄存器里(保存断点),然后 CPU 通过异常向量表(vectors)基地址和异常的类型计算出异常处理程序的入口地址,即 VBAR_EL1 寄存器加上偏移量取得异常处理的入口地址,接着开始执行异常处理入口的第一行代码。这一过程是 CPU 硬件自动完成的,不需要程序干预。
随后,以 svc 指令对应的 el0_sync 为例,el0_sync 处的内核汇编代码首先做的就是保存异常发生时程序的执行现场(保存现场,即用户栈、通用寄存器等),然后根据异常发生的原因(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(32 位 ARM 下是 sys_gettimeofday)。
系统调用内核处理函数执行完成后,会将系统调用的返回值存放在 X0 寄存器中。
系统调用返回前,需要恢复异常发生时程序的执行现场(恢复现场),其中就包括恢复 ELR_EL1 和 SPSR_EL1 的值(原因是异常会发生嵌套,一旦发生异常嵌套 ELR_EL1 和 SPSR_EL1 的值就会随之发生改变)。最后内核调用异常返回指令 eret,CPU 硬件把 ELR_EL1 写回 PC,把 SPSR_EL1 写回 PSTATE,返回用户态继续执行用户态程序。如下图所示,该部分操作由 ret_to_user 函数中的 kernel_exit 0 完成。