Programming with PTRACE, Part6 - 时间控制

不同的时间计算方法

程序运行会占用一小段时间(废话),事实上,我们有不止一种方法来表示一个程序运行了多长时间。最直观的应该是“墙上时间”,也就是说,你掐个秒表,看看程序从开始到结束用了多长时间。除此之外,还有“用户态时间”和“内核态时间”,这两个时间都是以CPU实际运算的时间,也就是CPU周期,来计数的。“用户态时间”就是程序在用户态执行的时间,包括程序所引用的库中的代码(比如STL),“内核态时间”就是指程序在内核态执行的时间,一般是各种系统调用(比如各种IO操作)。这两种时间和墙上时间的区别在于,因为CPU其实是在多个程序中快速切换的,所以在运行某个程序的时间里,CPU也处理了属于其他进程的任务,而且CPU切换任务也需要一定的时间(真的很短)。如果处于被调试状态,tracer的运行时间也会被计算在内,这些不属于这个进程的时间片也会被计算在这个进程的“墙上时间”里。所以一般以用户态时间和内核态时间的总和作为进程的运行时间。

在Linux系统里有一个叫time的命令可以查看一个命令执行了多长时间。这个命令有两个版本,一个是shell内置的,另一个是独立的可执行文件,可以用type time命令查看。虽然可执行版本功能更强一点,但内置的功能足够,这一点区别可以不管。用法是: time [命令] <参数>。给个例子:

time ffmpeg -i sample.mp4 target.mp3
...
5.42s user
0.10s system
100% cpu
5.520 total

Programming with PTRACE, Part5 - 内存管理

这个part主要讲解Linux的内存管理机制,以及如何查看并限制子进程的内存使用。

内存的划分

(嘛。。。这一部分也算是现学现卖的,如果大家觉得有什么讲的不到位的请翻下方的拓展阅读部分)
大家都知道,32位系统最大可以寻址4GB的地址空间(不考虑物理地址扩展),那么这个“地址”究竟指的是哪儿的地址呢?你可以写一个小程序,malloc一点内存,然后把地址打印出来,重复几次,你会发现,分配的内存几乎都在同一个位置。这是因为,对于程序来说,这些地址都是虚拟地址,虚拟地址空间对于每个进程都是独立的,也就是说,对于不同的进程,同样虚拟地址上的数据是不同的。
当然,数据肯定是存放在内存条上的,我们把可以直接读写内存条的地址叫做物理地址。物理地址以一定的方式映射到虚拟地址上,所以当程序试图访问虚拟地址时,系统要以一定方式把虚拟地址变成物理地址,这项工作通常是由MMU(内存管理单元)来完成的。内存的映射不是大块大块的,而是一小片一小片分别映射的,所以在虚拟地址上连续的地址可能在物理地址上相差十万八千里,这些一小片一小片的内存被称为“页”。
页的存在给内存分配带来了极大的灵活性,页可以存储在内存里,也可以存储在交换分区里,可以将同一块物理内存映射到不同进程的虚拟空间里(动态库经常这么干),甚至可以映射到磁盘上的某个文件。光说可能有点抽象,于是给幅图(来自Wikipedia)
内存页映射是不连续的
虚拟地址被分成多个段,数据有序存放于其中。这是32位Linux的新内存布局(Linux 2.6.7之后):
Linux新内存布局
如果你研究过可执行文件的结构,你就会发现,虚拟地址的段就是按可执行文件的段来填充的。另外,由于代码段的起点地址是固定的(0x08048000),所以编译器就可以预先算出函数的地址了。顺带一提,因为动态库加载时的虚拟地址是不固定的,不能预先计算出函数地址,所以要在编译时使用-fPIC选项生成位置无关代码,否则每次被一个新进程使用时都要进行重定位(可以理解为重新计算函数地址),并生成该动态库的一个副本,这样压根没有起到节约内存的作用。
扯远了,回来。尽管每个进程的虚拟地址空间时互相独立的,但并不意味着进程想访问哪儿就能访问的,比如3GB以上的区域,那儿是内核的领地。即使是堆段,也只能访问已申请的内存部分,非法的内存访问将会引发段错误(Segmentation Fault)。回到malloc()函数上,malloc最终会调用brkmmap系统调用,brk用于在堆中分配小块内存,mmap则用于在Memory Mapping Segment中分配大块内存。但是并不是每次malloc都会调用brk,这是因为分配的内存实在是太小了,而brk只能分配大一点的内存,所以C运行库(比如glibc)在收到一个malloc时会先用brk向系统“批发”一块大一点的内存,而收到后续分配请求时则把这块大内存“零售”给程序,直到售完再次brk。
如果有一个程序死循环单纯malloc内存,内存会不会被吃光呢?答案是不会(我不清楚是不是真的有如此单纯的系统真的会挂掉),因为系统发现,你只是分配了内存,却没有使用,于是它很机智地将那片内存设置为“可访问”,却没有把它映射到任何一个实际的内存页上!

Programming with PTRACE, Part4 - 系统调用进阶

这个part是Part2的延续,所以我强烈建议你弄明白Part2中的内容后再来看本part。那么进入正题,我将在这个部分讲解系统调用的参数传递顺序以及如何利用ptrace系统调用获得用户空间的数据。

参数与寄存器

我在Part2中提到过,系统调用的参数是以一定顺序保存在寄存器里的,那么这个顺序是什么呢?在man 2 syscall中有两张表格解释了这个问题,你也可以在这里看到,就在”Architecture calling conventions”下面。我知道很多人很懒,所以我就把这两张表格复制过来了。

arch/ABI instruction syscall # retval Notes
arm/OABI swi NR - a1 NR is syscall #
arm/EABI swi 0x0 r7 r0
blackfin excpt 0x0 P0 R0
i386 int $0x80 eax eax
ia64 break 0x100000 r15 r10/r8 bool error/errno value
parisc ble 0x100(%sr2, %r0) r20 r28
s390 svc 0 r1 r2 See below
s390x svc 0 r1 r2 See below
sparc/32 t 0x10 g1 o0
sparc/64 t 0x6d g1 o0
x86_64 syscall rax rax
arch/ABI arg1 arg2 arg3 arg4 arg5 arg6 arg7
arm/OABI a1 a2 a3 a4 v1 v2 v3
arm/EABI r0 r1 r2 r3 r4 r5 r6
blackfin R0 R1 R2 R3 R4 R5 -
i386 ebx ecx edx esi edi ebp -
ia64 out0 out1 out2 out3 out4 out5 -
parisc r26 r25 r24 r23 r22 r21 -
s390 r2 r3 r4 r5 r6 r7 -
s390x r2 r3 r4 r5 r6 r7 -
sparc/32 o0 o1 o2 o3 o4 o5 -
sparc/64 o0 o1 o2 o3 o4 o5 -
x86_64 rdi rsi rdx r10 r8 r9 -


Programming with PTRACE, Part3 - 进程的终止与信号

在Part2中,我们粗略了解了如何使用ptrace获得系统调用信息,即在一个大循环里不断获取程序信息,如果程序退出则停止循环。当然,那个判断异常简陋,几乎无法处理任何特殊情况。我将在本Part中详细解说各种异常情况的处理,同时讲解各种信号相关的问题。

一些重要的宏

在使用wait4后,程序的信息被存储在sta变量中,这些信息被存储在这个整数的不同二进制位上,这儿有一系列宏用于帮我们提取这些信息。以下信息是我对man 3 wait中相关部分的翻译,同时参考了这个页面

WIFEXITED   如果进程正常退出,返回一个非0值(通常是进程调用了`exit()`或是`_exit()`)
WIFSIGNALED 如果进程由于一个未被捕获的信号而被终止,返回一个非0值
WIFSTOPPED  当进程被停止(非终止)时,返回一个非0值(通常发生在当进程处于`traced`状态时)

WEXITSTATUS 当`WIFEXITED`为非0值,获得进程`main()`函数的返回值
WTERMSIG    如果`WIFSIGNALED`为非0值,获得引起进程终止的信号代码
WSTOPSIG    如果`WIFSTOPPED`为非0值,获得引起进程停止的信号代码

除了这六个,还有WIFCONTINUEDWCOREDUMP两个宏,不过我们用不到,我也没仔细研究,就不说了。
当进程自行终止时,WIFEXITED即为true,配套使用WEXITSTATUS获得返回值,不做过多解释。当子进程进行系统调用时,WIFSTOPPEDtrue,同时WSTOPSIG等于SIGTRAP(信号代码为7),我们可以用这种方法区分syscall-stopsignal-delivery-stop。当有一个外部信号要发送给子进程,这个信号会先到达父进程,使WIFSTOPPEDtrue,同时WSTOPSIG等于该信号的信号代码。父进程可以选择将这个信号继续传递或是不传递,甚至传递另一个信号给子进程。一旦信号真正到达子进程,就进入子进程自己的处理流程或是系统默认动作,可能触发WIFSIGNALED,比如SIGINT
在所有信号中,SIGKILL是一个例外,它不会经过父进程引发WIFSTOPPED,而是直接传递到子进程,引发WIFSIGNALED

Programming with PTRACE, Part2 - 系统调用入门

在这部分,我会介绍如何使用ptrace监控子进程的系统调用。我先将完整代码列在开头,你现在十有八九看不懂它,但我希望你在看完这篇文章后能彻底理解这段代码。(这段代码在64位系统上有效,32位系统请参照最后给32位系统的Tip手动修改源代码)

demo4.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/resource.h>
#include <sys/reg.h>
int main(){
puts("Parent started");
pid_t pid;
pid=fork();
if (pid<0){
puts("fork() failed");
return(-1);
}
if (pid==0){
ptrace(PTRACE_TRACEME,0,0,0);
puts("Child sleeping...");
sleep(1);
puts("Child exec...");
execlp("./target","target",NULL);
}else{
printf("Child PiD == %d\n",pid);
int sta=0;
struct rusage ru;
wait4(pid,&sta,0,&ru);
long rax_rt=ptrace(PTRACE_PEEKUSER,pid,8*RAX,0);
printf("Child execve() returned with %ld\n",rax_rt);
ptrace(PTRACE_SYSCALL,pid,0,0);
int intocall=1;
while(1){
wait4(pid,&sta,0,&ru);
if (WIFEXITED(sta)){
puts("Child Exited");
break;
}
long _ORIG_RAX=ptrace(PTRACE_PEEKUSER,pid,8*ORIG_RAX,0);
long _RAX=ptrace(PTRACE_PEEKUSER,pid,8*RAX,0);
if (intocall){
printf("Entering SYSCALL %ld .... ",_ORIG_RAX);
intocall=0;
}else{
printf("Exited with %ld\n",_RAX);
intocall=1;
}
ptrace(PTRACE_SYSCALL,pid,0,0);
}
}
}

Programming with PTRACE, Part1 - 起步

前言

本人作为一个信息学竞赛的参与者,在很久之前曾经试图自己写过一个Online Judge系统(允许用户上传源代码并在服务器上编译运行),考虑到安全因素,必须要对程序的行为进行限制,因此对ptrace进行了一番研究。网上有一份关于ptrace的很好的教程(Playing with ptrace),但是时间有点久了,而且没有涉及64位操作系统。因此,我决定写这份教程,基于64位Linux,尽力介绍一些新加入的功能,同时兼顾一下32位系统。另外,由于一开始的目的是“对程序的行为进行限制”,所以不会涉及到诸如设置断点之类的内容,相反,可能会涉及到其他关于系统资源管理的内容。
ptrace()是一个由Linux内核提供的系统调用。它允许一个用户态进程检查、修改另一个进程的内存和寄存器。这种技术被广泛用于gdb等调试器中。尽管这系列文章的标题叫做“Programming with PTRACE”,但在第一部分中,我将着重介绍Linux的进程和相关的几个重要函数。

fork(), vfork() 与 clone()

在Linux中,每一个进程都有一个唯一的编号,被称作pid(Process ID)。在Linux中,进程不能凭空产生(init进程是个例外),只能从一个已有进程衍生出来。原来的进程被称做父进程,衍生出来的进程叫子进程。一个系统中所有进程以父子关系相连接,形成一棵树,这棵“树”的树根就是init进程,它是在系统启动时被直接启动的,因此它没有父进程。并且系统中所有其他进程都直接或间接地是它的子进程。在Linux系统中,实现“把一个进程变成两个”这一功能的有三个系统调用,即fork()vfork()clone()

fork()的工作流程的确和叉子有几分相似之处,它将当前进程所有数据复制一份,产生一个和父进程一模一样的子进程。并在两个进程中返回不同的返回值。比如这段代码:

demo1.c
1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <unistd.h>
int main(int argc,char *argv[]){
int return_val;
puts("Program started.");
return_val=fork();
printf("fork() returned %d\n",return_val);
return 0;
}

将会输出

Program started.
fork() returned 5768
fork() returned 0

很明显地可以看到,puts()只被调用了一次而printf()被调用了两次,这说明在fork()前的一个进程变成了两个,而且fork()在两个进程中有不同的返回值(这就是“调用一次,返回两次”的来历)。fork()会返回0给子进程,返回子进程的pid给父进程,因此,我们很容易判断出fork() returned 0是由子进程打印的。在实际应用中,也通过if语句判断返回值的方法来决定执行不同的代码:

int pid=fork();
if (pid==0){
  //子进程的工作
}else{
  //父进程的工作
}

一般来说,子进程的工作就是调用exec族函数,启动另一个程序(把自己替换掉)。如果子进程还在执行而父进程已结束,那么它就成为“孤儿”进程,成为init进程的子进程。另外,请不要纠结那个if判断带来的性能损失,Linux的内核开发者都不纠结,你纠结什么呢?

有屏幕的地方就有烂苹果

如果你还不知道Bad Apple是什么东西,请移步这里
播放的原理很简单,就是不停的打印清屏再打印清屏。任何一个略有编程基础的人都能做到。比较令人头大的是如何把原视频转化为一个易于解析而且又不占地方的文件。
其实,借助FFmpegImageMagick和一点点的编程小技巧就可以轻松完成。

第一步当然是要去下一个视频文件,我已经下好了,叫做BadApple.mkv