如果想成为一名合格的工程师,那肯定应该知道如何去分析应用逻辑,对于如何优化应用代码提升系统性能也应该能有自己的一套经验。而今天想要讨论的是,如何拓展自己的边界,让自己能够分析代码之外的模块,以及对我自己而言几乎是黑盒的 Linux 内核。
在很多情况下,应用的性能问题都需要通过分析内核行为来解决,因此,内核提供了非常多的指标供应用程序参考。当应用出现问题时,可以查看到底是哪些指标出现了异常,然后再做进一步分析。不过,这些内核导出的指标并不能覆盖所有的场景,可能面临的问题可能更加棘手:应用出现性能问题,可是系统中所有的指标都看起来没有异常。相信很多人都会此抓狂。那出现这种情况时,内核到底有没有问题呢,它究竟在搞什么鬼?我们探讨一下。
对linux应用开发者而言,应用程序的边界就是系统调用,进入到系统调用中就是Linux 内核了,所以,要想拓展分析问题的边界,首先我们需要知道该怎么去分析应用程序使用的系统调用函数。对于内核开发者而言,边界同样是系统调用,系统调用之外是应用程序。如果内核开发者想要拓展分析问题的边界,也需要知道如何利用系统调用去追踪应用程序的逻辑。
如何拓展你分析问题的边界?
不管大家是做linux应用,还是驱动的。对应用逻辑一无所知,或者是黑盒子的linux内核,都可以通过strace来追踪应用逻辑。strace 可以用来分析应用和内核的“边界”——系统调用。借助 strace,我们不仅能够了解应用执行的逻辑,还可以了解内核逻辑,如果作为应用开发者的,就可以借助这个工具来拓展分析应用问题的边界。strace可以跟踪进程的系统调用、特定的系统调用以及系统调用的执行时间。很多时候,我们通过系统调用的执行时间,就能判断出业务延迟发生在哪里。比如我们想要跟踪一个多线程程序的系统调用情况,那就可以这样使用 strace:
strace -T -tt -ff -p pid -o strace.out
在使用 strace 跟踪进程之前,可以先明白 strace 的工作原理,你不仅要知道怎样使用工具,更要明白工具的原理,这样在出现问题时,就能明白该工具是否适用了。
strace 工具的原理:
strace 工具的原理如下图所示。
对于正在运行的进程而言,strace 可以 attach 到目标进程上,这是通过 ptrace 这个系统调用实现的(gdb 工具也是如此),ptrace是 Linux 系统中一个强大的调试工具,它允许一个进程控制另一个进程,进行系统调用拦截、内存修改、寄存器操作等调试任务,ptrace 的 PTRACE_SYSCALL是 ptrace 中的一个选项,会去追踪目标进程的系统调用,通常用于调试系统调用(即对进程的系统调用进行跟踪和拦截)。目标进程被追踪后,每次进入 syscall,都会产生 SIGTRAP信号并暂停执行;追踪者通过目标进程触发的 SIGTRAP信号,就可以知道目标进程进入了系统调用,然后追踪者会去处理该系统调用,我们用 strace 命令观察到的信息输出就是该处理的结果;追踪者处理完该系统调用后,就会恢复目标进程的执行。被恢复的目标进程会一直执行下去,直到下一个系统调用。
可以发现,目标进程每执行一次系统调用都会被打断,等 strace 处理完后,目标进程才能继续执行,这就会给目标进程带来比较明显的延迟。
因此,在生产环境中不建议使用该命令,如果你要使用该命令来追踪生产环境的问题,那就一定要做好预案。假设我们使用 strace 跟踪到,线程延迟抖动是由某一个系统调用耗时长导致的,那么接下来我们该怎么继续追踪呢?这就到了应用开发者和运维人员需要拓展分析边界的时刻了,对内核开发者来说,这才算是分析问题的开始。
strace安装
如果
strace
没有安装,可以根据你使用的操作系统版本进行安装:在 Ubuntu/Debian 系统上:
sudo apt update sudo apt install strace
在 CentOS/RHEL 系统上:
sudo yum install strace
在 Fedora 系统上:
sudo dnf install strace
安装完后路径在:
/usr/bin/strace
strace 工具的使用
相关参数:
-c 统计每一系统调用的所执行的时间,次数和出错的次数等.
-d 输出strace关于标准错误的调试信息.
-f 跟踪由fork调用所产生的子进程.
-ff 如果提供-o filename,则所有进程的跟踪结果输出到相应的filename.pid中,pid是各进程的进程号.
-F 尝试跟踪vfork调用.在-f时,vfork不被跟踪.
-h 输出简要的帮助信息.
-i 输出系统调用的入口指针.
-q 禁止输出关于脱离的消息.
-r 打印出相对时间关于,,每一个系统调用.
-t 在输出中的每一行前加上时间信息.
-tt 在输出中的每一行前加上时间信息,微秒级.
-ttt 微秒级输出,以秒了表示时间.
-T 显示每一调用所耗的时间.
-v 输出所有的系统调用.一些调用关于环境变量,状态,输入输出等调用由于使用频繁,默认不输出.
-V 输出strace的版本信息.
-x 以十六进制形式输出非标准字符串
-xx 所有字符串以十六进制形式输出.
-a column 设置返回值的输出位置.默认 为40.
第一种使用方法:
/usr/bin/strace <command> 要使用结对路径
实验代码:
通过它启动要跟踪的进程 /usr/bin/strace ./aout
分析打印消息: 主要输出部分及其含义:
1.启动程序:
execve("./a.out", ["./a.out"], 0x7ffd865038d0 /* 24 vars */) = 0
这行表示
execve
系统调用被调用来执行./a.out
,并且成功返回(= 0
),这表明a.out
成功启动。
2.内存分配(brk):
brk(NULL) = 0x55e3ab7ce000
brk
是一个与内存分配相关的系统调用。在这里,brk
返回一个新的内存地址,表示程序为自己的堆分配了内存。
访问文件(access):
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
这表明程序尝试访问
/etc/ld.so.nohwcap
文件,但未找到(ENOENT
表示“没有该文件或目录”)。后续类似的调用也是在检查一些系统文件,例如
ld.so.cache
和libc.so.6
,这些文件与程序的动态链接有关。
打开文件(openat):
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
程序成功打开了/etc/ld.so.cache
文件,文件描述符是3
。
读取文件(read):
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\240\35\2\0\0\0\0\0"..., 832) = 832
程序正在读取ld.so.cache
文件的数据,长度为832
字节。该文件包含了动态链接器的信息。
内存映射(mmap):
mmap(NULL, 93075, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f6fe4484000
这是
mmap
系统调用,它映射了一个内存区域(大小为 93075 字节),并从文件描述符3
中加载数据(即之前打开的/etc/ld.so.cache
文件)。其他
mmap
调用也是类似的,它们负责将程序所需的动态库(例如libc.so.6
)映射到进程的内存中。
关闭文件(close):
close(3) = 0
关闭文件描述符3
,即/etc/ld.so.cache
文件。
出错的分析:
1.文件打开失败:
假设文件打开失败,因为文件路径不合法或权限不足。strace
输出可能如下:
$ strace ./a.out
execve("./a.out", ["./a.out"], 0x7fff22a9f1d0 /* 24 vars */) = 0
brk(NULL) = 0x55e3ab7ce000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = -1 EACCES (Permission denied)
write(2, "Failed to open file\n", 20) = 20
exit_group(1) = ?
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = -1 EACCES (Permission denied)
:这行表示程序尝试打开./text
文件,但由于权限不足,返回-1
,并且错误码是EACCES
(Permission denied)。write(2, "Failed to open file\n", 20)
:程序尝试向标准错误输出写入"Failed to open file\n"
,表示文件打开失败。
2. 写入失败:
假设文件系统已满,导致 write
系统调用失败。strace
输出如下:
$ strace ./a.out
execve("./a.out", ["./a.out"], 0x7fff5b4f2cd0 /* 24 vars */) = 0
brk(NULL) = 0x55f4c0a5e000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = 3
write(3, "小帅", 6) = -1 ENOSPC (No space left on device)
write(2, "Write failed\n", 12) = 12
exit_group(1) = ?
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = 3
:程序成功打开文件./text
,文件描述符是3
。write(3, "小帅", 6) = -1 ENOSPC (No space left on device)
:程序在写入"小帅"
时失败,错误码是ENOSPC
,表示磁盘空间已满。write(2, "Write failed\n", 12)
:程序向标准错误输出写入"Write failed\n"
,提示写入失败。
程序访问非法内存(崩溃):
假设程序尝试访问空指针或非法内存,导致崩溃。strace
输出如下:
$ strace ./a.out
execve("./a.out", ["./a.out"], 0x7ffd425fa350 /* 24 vars */) = 0
brk(NULL) = 0x55e5d9f9f000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = 3
write(3, "小帅", 6) = 6
write(3, NULL, 6) = -1 EFAULT (Bad address)
write(2, "Write failed\n", 12) = 12
exit_group(1) = ?
write(3, "小帅", 6) = 6
:程序成功写入"小帅"
字符串。write(3, NULL, 6) = -1 EFAULT (Bad address)
:程序尝试写入NULL
指针,导致write
返回错误,错误码是EFAULT
(Bad address),这通常是访问非法内存引起的崩溃。write(2, "Write failed\n", 12)
:程序输出"Write failed\n"
。
访问未映射的内存地址(内存访问违规):
另一个崩溃的例子是尝试访问未映射的内存地址:
$ strace ./a.out
execve("./a.out", ["./a.out"], 0x7ffd36fcd9b0 /* 24 vars */) = 0
brk(NULL) = 0x55c9e48ae000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "./text", O_RDWR|O_CREAT|O_APPEND, 0666) = 3
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4a80000000
write(3, "小帅", 6) = 6
write(3, "测试", 6) = 6
mmap(0x7f4a80010000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 EINVAL (Invalid argument)
write(2, "Memory mapping failed\n", 22) = 22
exit_group(1) = ?
mmap(0x7f4a80010000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 EINVAL (Invalid argument)
:程序尝试映射一个无效的内存地址(0x7f4a80010000
),导致mmap
返回EINVAL
错误,表示无效的参数。write(2, "Memory mapping failed\n", 22)
:程序写入"Memory mapping failed\n"
到标准错误输出。
分析方法:
- 检查程序依赖的动态库:从输出中可以看到,程序依赖于
libc.so.6
(标准 C 库)。如果某个库加载失败,通常会在strace
输出中看到mmap
或open
系统调用失败。 - 文件访问错误:如
/etc/ld.so.nohwcap
和/etc/ld.so.preload
等文件未找到(ENOENT
错误),可能并不会直接影响程序运行,但如果程序依赖这些文件,可能会导致问题。 - 内存分配问题:
brk
和mmap
显示程序在运行时分配了内存,如果程序内存分配失败或访问越界,strace
输出将帮助你找出问题的根源。
第二种使用方法
假设有一个简单的 C 程序 test.c
,演示如何使用 ptrace
和 PTRACE_SYSCALL
来跟踪系统调用:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>int main()
{
int fp;
char* buff = "小帅";// 打开文件时错误处理
fp = open("./text", O_RDWR | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
if (fp < 0)
{
perror("Failed to open file");
exit(-1);
}// 进入循环前做检查
if (buff == NULL)
{
fprintf(stderr, "Buffer is NULL\n");
close(fp); // 确保打开的文件会被关闭
exit(-1);
}// 无限循环写入文件
while (1)
{
ssize_t bytes_written = write(fp, buff, strlen(buff));// 处理 write 错误
if (bytes_written < 0)
{
perror("Write failed");
close(fp); // 出现写入错误时关闭文件
exit(-1);
}
else
{
printf("Written %zd bytes to file\n", bytes_written);
}// 检查是否发生了文件描述符错误
if (fp < 0)
{
perror("File descriptor error");
exit(-1);
}sleep(3); // 每 3 秒写入一次,防止 CPU 占用过高
}// 程序结束前关闭文件
close(fp);
return 0;
}
通过它启动要跟踪的进程 /usr/bin/strace ./aout
3. 解释
fork()
:父进程创建子进程。ptrace(PTRACE_SYSCALL, 12345, 0, 0)
:父进程通过PTRACE_SYSCALL
跟踪子进程的系统调用,并暂停它。wait4
和WIFSTOPPED(s)
:父进程等待子进程停止,表明子进程进入系统调用。SIGSTOP
和SIGTRAP
:当子进程执行系统调用时,父进程接收到SIGSTOP
信号来暂停它,并在系统调用执行完毕后接收到SIGTRAP
信号。
5. 注意事项
- PTRACE_TRACEME:子进程通过此调用告诉内核它将被父进程调试。
- PTRACE_SYSCALL:父进程通过此调用告诉内核在子进程每次进行系统调用时暂停。
- waitpid:父进程通过
waitpid
等待子进程的状态变化,可以获取子进程是否执行了系统调用或是否退出。
这只是一个简单的示范,实际的应用中,ptrace
和 PTRACE_SYSCALL
组合可以用来进行更复杂的调试和分析任务。
ptrace
和 PTRACE_SYSCALL
组合不仅可以用于跟踪系统调用,还可以用于更复杂的调试和分析任务,比如:
- 获取系统调用的参数和返回值
- 修改系统调用的参数或返回值
- 跟踪特定的系统调用
- 调试进程中的内存内容