写一个IO profile和trace的插桩库,实现如下功能:
采用动态库,不需要用户修改一行代码
- profile功能:
read
和write
调用次数,平均IO操作的读大小,写大小,平均读写时间 - Trace功能:按照时间顺序输出每次IO调用信息(读写类型、读写大小等信息)
运行时库打桩
这部分的知识可以参考书本《深入理解计算机系统》第七章第13节的内容。
基本思想:给定一个需要打桩的目标函数(此次作业中的read
和write
),自己创建一个包装函数,其原型与目标函数完全一样。使用某种特殊的打桩机制,欺骗程序去调用包装函数而不是原来的目标函数。一般地,包装函数可以执行自己的逻辑(遂能记录调用次数、验证和追踪输入输出值等),调用原目标函数,再将原目标函数的返回值传递给调用者。
打桩可以分为:
- 编译时,使用的是C预处理器,需要访问程序源代码;
- 链接时,用的是静态链接器,需要访问程序的可重定位对象文件;
- 运行时,用的是动态链接器,只要访问可执行目标文件
这次作业用的是运行时打桩,基于动态链接器的LD_PRELOAD
环境变量。当加载和执行一个程序,需要解析未定义的引用时,动态链接器会先搜索LD_PRELOAD
的库,然后才搜索任何其他的库。
Profile
因为要追踪读写的调用次数、平均大小、平均时间,所以可以每次调用read
和write
时都根据传入的参数fd
文件描述符来累加对该文件的记录。每个文件与其文件描述符一一对应。这样的缺点是两次打开同一个文件就会被当作两次不同的记录了,这两次分别有一个次数、大小和时间的统计。
由于一般情况下,Linux系统默认一个进程最多只能打开1024个文件,所以据此开辟用于记录的数组。为了在close
之后能复用一些文件描述符,也记录一下某个fd
的当前状态(是否被占用)。
#define MAX_FD 1024
char fd_to_names[MAX_FD][512];// 记录fd对应的文件名
int fd_active[MAX_FD];// 记录当前这个fd是否被占用中
int read_cnt[MAX_FD] = {0};// 读的次数
int read_size[MAX_FD] = {0};// 读的总大小
double read_time[MAX_FD] = {0.0};// 读的总耗时
int write_cnt[MAX_FD] = {0};// 写的次数
int write_size[MAX_FD] = {0};// 写的总大小
double write_time[MAX_FD] = {0.0};// 写的总耗时
FILE * prof_fp = NULL;// profile输出的文件指针
FILE * trace_fp= NULL;// trace输出的文件指针
在每次调用open
打开某个文件时,需要对相应位置的数据记录做初始化:
ssize_t open(const char * pathname, int flags, mode_t mode) {
ssize_t (*openp)(const char*, int, mode_t);
char * error;
openp = dlsym(RTLD_NEXT, "open");
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
exit(1);
}
int fd = openp(pathname, flags, mode);
assert(fd_active[fd] == 0);// 返回的fd一定是未被占用的
fd_active[fd] = 1;
strcpy(fd_to_names[fd], pathname);
return fd;
}
在每次读和写时累加记录:
ssize_t read(int fd, void * buf, size_t count) {
ssize_t (*readp)(int, void *, size_t);
char * error;
readp = dlsym(RTLD_NEXT, "read");
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
exit(1);
}
double t = wall_time();
int rv = readp(fd, buf, count);
t = wall_time() - t;
read_cnt[fd]++;
read_size[fd] += rv;
read_time[fd] += t;
assert(trace_fp != NULL);
fprintf(trace_fp, "%20s %5s %15d %15.6lf\n", fd_to_names[fd], "RD", rv, t * 1e3);
return rv;
}
ssize_t write(int fd, const void * buf, size_t count) {
ssize_t (*writep)(int, const void *, size_t);
char * error;
writep = dlsym(RTLD_NEXT, "write");
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
exit(1);
}
double t = wall_time();
int rv = writep(fd, buf, count);
t = wall_time() - t;
write_cnt[fd]++;
write_size[fd] += rv;
write_time[fd] += t;
assert(trace_fp != NULL);
fprintf(trace_fp, "%20s %5s %15d %15.6lf\n", fd_to_names[fd], "WR", rv, t * 1e3);
return rv;
}
而当close
被调用时,则将对应fd
的数据整理(计算大小和时间的平均值)输出到IO.prof
的结果文件中,并清除该位置上的记录:
int close(int fd) {
int (*closep)(int);
char * error;
closep = dlsym(RTLD_NEXT, "close");
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
exit(1);
}
int ret = closep(fd);
if (read_cnt[fd] > 0) {
read_size[fd] /= read_cnt[fd];
read_time[fd] /= read_cnt[fd];
}
if (write_cnt[fd] > 0) {
write_size[fd] /= write_cnt[fd];
write_time[fd] /= write_cnt[fd];
}
fprintf(prof_fp, "%20s %8d %15d %15.6lf %8d %15d %15.6lf\n", \
fd_to_names[fd], read_cnt[fd], read_size[fd], read_time[fd] * 1e3, \
write_cnt[fd], write_size[fd], write_time[fd] * 1e3);
memset(fd_to_names[fd], 0, sizeof(fd_to_names[fd]));
fd_active[fd] = 0;
read_cnt [fd] = write_cnt [fd] = 0;
read_size[fd] = write_size[fd] = 0;
read_time[fd] = write_time[fd] = 0.0;
return ret;
}
Trace
要按照时间顺序输出每次IO调用信息(读写类型、读写大小等),只要在read
和write
函数中加上对相应文件句柄的记录即可。具体见上面代码中的fprintf(trace_fp,...)
部分。
由于这些库函数是运行时被待测试的可执行程序调用的,怎么能够在不修改可执行程序的情况下,将它们记录的东西(内存中在该动态库的数据段)输出出来呢?查了一下,可以设置在main
函数执行前和返回前执行两个函数,分别用__attribute__((__constructor__))
和__attribute__((__destructor__))
来修饰:
__attribute__((__constructor__)) void pre_func(void) {
printf("pre_func: fopen IO.prof and IO.trace\n");
prof_fp = fopen("IO.prof", "w+");
if (prof_fp == NULL) {
printf("unable to open IO.prof\n");
exit(1);
}
fprintf(prof_fp, " IO profile report\n");
fprintf(prof_fp, "%20s %8s %15s %15s %8s %15s %15s\n", \
"pathname", "RD_CNT", "RD_AVG_SIZE/B", "RD_AVG_TIME/ms", \
"WR_CNT", "WR_AVG_SIZE/B", "WR_AVG_TIME/ms");
trace_fp= fopen("IO.trace", "w+");
if (trace_fp == NULL) {
printf("unable to open IO.trace\n");
exit(1);
}
fprintf(trace_fp, " IO trace report\n");
fprintf(trace_fp, "%20s %5s %15s %15s\n", "pathname", "TYPE", "SIZE/B", "TIME/ms");
}
__attribute__((__destructor__)) void post_func(void) {
printf("post_func\n");
fclose(prof_fp);
fclose(trace_fp);
}
这两个函数,pre_func
负责建立profile和trace所需要的输出文件并打印表头,post_func
则负责结束输出。或许有更好的实现方法,这里就简单粗暴一点了。
测试验证
作业说从网上找一个IO程序验证该工具,在Github上搜了一下感觉没有直接能用的,所以干脆自己写了个小程序。随机决定每次运行时,所要创建的最大文件数;随机决定每次操作要针对的文件;随机决定每次操作的类型(读、写、关);随机决定每次写时的数据大小;随机决定每次读时的数据大小和起始位置。
/* testIO.cpp */
#include <cstdlib>
#include <cstdio>
#include <string.h>// strerror()
#include <unistd.h>// write(), read(), close()
#include <sys/types.h>// open()
#include <sys/stat.h>// open()
#include <fcntl.h>// open()
#include <errno.h>// errno, strerror()
#include <cmath>// rand()
#include <ctime>// time()
#include <sys/time.h>// gettimeofday()
#include <vector>
#include <unordered_map>
using namespace std;
#define MAX_FILES 1000
#define MAX_LEN 4096
char content[MAX_LEN];
char buf[MAX_LEN];
void init_content() {
printf("size of content: %d bytes\n", sizeof(content));
for (int i = 0; i < sizeof(content); i++) {
int j = (i + 33 > 126) ? ((i+33)%126 + 33) : (i + 33);
content[i] = (char)j;
}
}
double wall_time() {
struct timeval t;
gettimeofday(&t, NULL);
return 1.*t.tv_sec + 1.e-6*t.tv_usec;
}
typedef enum {OPEN = -1, READ, WRITE, CLOSE, IO_TYPE_CNT} IO_TYPE;
#define ERROR_OPEN(idx,fd) {printf("file idx %d of fd %d open failed: %s\n", idx, fd, strerror(errno)); return -1;}
#define ERROR_READ(idx,fd) {printf("read file ids %d of fd %d failed: %s\n", idx, fd, strerror(errno)); return -1;}
#define ERROR_WRITE(idx,fd) {printf("write file ids %d of fd %d failed: %s\n", idx, fd, strerror(errno)); return -1;}
int main()
{
srand(time(0));
int num_files = rand() % MAX_FILES + 1;// 随机决定进程最多拥有多少个文件号
printf("num files: %d\n", num_files);
init_content();// 初始化一段字符数组,之后反复用这里面的内容来写
unordered_map<int, int> idx_to_fd;
double startT = wall_time(), currT;
while ((currT = wall_time()) < startT + 2.0) {// 让程序运行够2秒
int idx = rand() % num_files;// 随机选取一个文件号
int rv;// return value of system calls
if (idx_to_fd.find(idx) == idx_to_fd.end()) {// 之前还没创建过这个文件
char filename[256];
sprintf(filename, "%d.txt", idx);// 该文件号对应的文件名
int fd = open(filename, O_RDWR|O_CREAT|O_TRUNC, 0666);
if (fd < 0) ERROR_OPEN(idx, fd);
idx_to_fd[idx] = fd;// 该文件对应的描述符
// 创建这个文件的同时就写入一些东西
if ((rv = write(fd, content, MAX_LEN)) < 0) ERROR_WRITE(idx, fd);
} else {
int fd = idx_to_fd[idx];
if (fd == -1) continue;// 之前已经关掉过这个文件,就不再用它了
int type = rand() % IO_TYPE_CNT;// 随机决定读写类型
if (type == READ) {
int file_len = lseek(fd, 0, SEEK_END);// 返回值就是文件指针距离文件开头的偏移量,也就是文件的长度
int offset = rand() % file_len;// 随机决定读的位置
lseek(fd, offset, SEEK_SET);
int count = rand() % MAX_LEN;// 随机决定读的量
if ((rv = read(fd, buf, count)) < 0) ERROR_READ(idx, fd);
lseek(fd, 0, SEEK_END);// 读完之后重新将文件指针恢复到末尾
} else if (type == WRITE) {
int count = rand() % MAX_LEN;
if ((rv = write(fd, content, count)) < 0) ERROR_WRITE(idx, fd);
} else {// CLOSE
close(fd);
idx_to_fd[idx] = -1;// 标记为已关闭
}
}
}
// 关掉所有仍活跃的fd
for (unordered_map<int,int>::iterator it = idx_to_fd.begin(); it != idx_to_fd.end(); it++) {
if (it->second != -1) {
close(it->second);
}
}
return 0;
}
编译,运行:
gcc -shared -fpic -o myIO.so myIO.c -ldl
g++ -o testIO.exe -std=c++11 testIO.cpp
LD_PRELOAD="./myIO.so" ./testIO.exe
得到输出结果:此次运行最多共有57个文件
查看IO.prof
文件如下:
查看IO.trace
文件如下。如预期,对每个文件的第一次操作都是写,大小为4096字节。第一次非4096字节的写是出现在之前已创建过的5.txt
,做了一次随机化的读操作;紧随其后有一个对55.txt
的随机化的写操作。