如何Mock系统调用

简介: ### 背景 ​   Linux下开发存储系统、网络库的时候会用到一系列Linux的系统调用,每一个系统调用都有一些出错的场景,有些场景很极端,比如内存使用达到上限、磁盘写满等,如果对其进行测试的话,很难去构造这样的一个场景,这个时候集成测试就显得力不存心了,只能靠单元测试来覆盖这些场景。现在的问题就是如何去mock这些系统调用,然后通过程序返回对应场景的错误码来模拟各种

背景

​   Linux下开发存储系统、网络库的时候会用到一系列Linux的系统调用,每一个系统调用都有一些出错的场景,有些场景很极端,比如内存使用达到上限、磁盘写满等,如果对其进行测试的话,很难去构造这样的一个场景,这个时候集成测试就显得力不存心了,只能靠单元测试来覆盖这些场景。现在的问题就是如何去mock这些系统调用,然后通过程序返回对应场景的错误码来模拟各种场景。也就是将对系统函数的依赖注入到程序中。

系统函数的依赖注入

​   目前实现系统函数的依赖注入的手段有很多,分为编译期注入,和运行期注入,至于什么是依赖注入可以参考知乎的一篇文章如何用最简单的方式解释依赖注入,下面介绍几种依赖注入的方法:

  • 虚函数实现依赖注入(运行期注入)

​   使用传统的面向对象的手法,借助运行期的延迟绑定实现注入和替换,自己实现一个System接口类,把程序用到的系统调用都用虚函数封装一层,然后在调用的时候不直接调用系统调用,而是调用的System对应的方法。这样代码的主动权就交给了System接口类了。写单元测试的时候将这个System接口类替换成我们自己的mock对象就可以。完整的示例代码如下:

  // system.h
  class System {
   public:
    virtual int  open(const char *path, int oflag, ...) = 0;
    virtual ssize_t read(int fildes, void *buf, size_t nbyte) = 0;
    virtual ssize_t write(int fildes, const void *buf, size_t nbyte) = 0;
    virtual int close(int fildes) = 0;

    static System* GetInstance();
    static void set_instance(System* instance) {
      instance_ = instance;
    }

   private:
    static System* instance_;
  };
  // 具体的实现
  class FileOps : public System {
   public:
    int open(const char *path, int oflag, ...) override;
    ssize_t read(int fildes, void *buf, size_t nbyte) override;
    ssize_t write(int fildes, const void *buf, size_t nbyte) override;
    int close(int fildes) override;
    static FileOps* GetInstance();
  };

  // system.cc
  System* System::instance_ = nullptr;
  // 默认实现是FileOps,mock的时候通过改变这个默认实现从而把主动权从默认实现转到了mock的实现
  System* System::GetInstance() { 
    if (!instance_) {
      instance_ = FileOps::GetInstance();
    }
    assert(instance_);
    return instance_;
  }

  int FileOps::open(const char *path, int oflag, ...) {
    return ::open(path, oflag, 0777);
  }

  ssize_t FileOps::read(int fildes, void *buf, size_t nbyte) {
    return ::read(fildes, buf, nbyte);
  }

  ssize_t FileOps::write(int fildes, const void *buf, size_t nbyte) {
    return ::write(fildes, buf, nbyte);
  }

  int FileOps::close(int fildes) {
    return ::close(fildes);
  }

  FileOps* FileOps::GetInstance() {
    static FileOps sys;
    return &sys;
  }
  // 正常调用 main.cc
  int main() {
    assert(System::GetInstance() != nullptr);
    int fd = System::GetInstance()->open("txt", O_RDWR|O_CREAT, 0777);
    assert(fd > 0);
    int ret = System::GetInstance()->write(fd, "12345", 5);
    assert(ret > 0);
    ret = System::GetInstance()->close(fd);
    assert(ret == 0);

    return 0;
  }

  // 测试的时候调用如下,模拟一个IO错误

  // 一个mock版本的实现 test.cc
  class MockFileOps : public System {
  public:
   int open(const char *path, int oflag, ...) override;
   ssize_t read(int fildes, void *buf, size_t nbyte) override;
   ssize_t write(int fildes, const void *buf, size_t nbyte) override;
   int close(int fildes) override;
   static MockFileOps* GetInstance();
  };

  int MockFileOps::open(const char *path, int oflag, ...) {
    return ::open(path, oflag, 0777);
  }

  ssize_t MockFileOps::read(int fildes, void *buf, size_t nbyte) {
    return ::read(fildes, buf, nbyte);
  }
  // 模拟的一个IO错误
  ssize_t MockFileOps::write(int fildes, const void *buf, size_t nbyte) {
    errno = EIO;
    return -1;
  }

  int MockFileOps::close(int fildes) {
    return ::close(fildes);
  }

  MockFileOps* MockFileOps::GetInstance() {
    static MockFileOps sys;
    return &sys;
  }

  int main() {
    // 改变默认实现
    System::set_instance(MockFileOps::GetInstance());
    assert(System::GetInstance() != nullptr);
    int fd = System::GetInstance()->open("txt", O_RDWR|O_CREAT, 0777);
    assert(fd > 0);
    int ret = System::GetInstance()->write(fd, "12345", 5);
    assert(ret ==  -1);    // 发生错误
    perror("write");
    ret = System::GetInstance()->close(fd);
    assert(ret == 0);

    return 0;
  }
  • 编译期延迟绑定(编译期注入)

​   创建一个命名空间,创建一系列和系统调用同名的方法,间接的调用系统调用,写测试代码的时候重新定义这些方法,这就相当于一份代码有了两份实现,根据编译的时候链接哪份代码来决定是否启用mock,这个看起来要比基于虚函数的要简单的多了。完整的示例代码如下:

  // file_ops.h
  namespace FileOps {
    int  open(const char *path, int oflag, ...);
    ssize_t read(int fildes, void *buf, size_t nbyte);
    ssize_t write(int fildes, const void *buf, size_t nbyte);
    int close(int fildes);
  }  // namespace FileOps

  // file_ops.cc
  namespace FileOps {

  int open(const char *path, int oflag, ...) {
    return ::open(path, oflag, 0777);
  }

  ssize_t read(int fildes, void *buf, size_t nbyte) {
    return ::read(fildes, buf, nbyte);
  }

  ssize_t write(int fildes, const void *buf, size_t nbyte) {
    return ::write(fildes, buf, nbyte);
  }

  int close(int fildes) {
    return ::close(fildes);
  }

  }  // namespace FileOps

  // mock_file_ops.cc

  namespace FileOps {

  int open(const char *path, int oflag, ...) {
    return ::open(path, oflag, 0777);
  }

  ssize_t read(int fildes, void *buf, size_t nbyte) {
    return ::read(fildes, buf, nbyte);
  }
  // 这里做了mock,改变了write的行为
  ssize_t write(int fildes, const void *buf, size_t nbyte) {
    errno = EIO;
    return -1;
  }

  int close(int fildes) {
    return ::close(fildes);
  }

  }  // namespace FileOps

  // 测试程序
  int main() {
    int fd = FileOps::open("txt", O_RDWR|O_CREAT, 0777);
    assert(fd > 0);
    int ret = FileOps::write(fd, "12345", 5);
    if (ret == -1) {
      perror("write:");
    }
    ret = FileOps::close(fd);
    assert(ret == 0);
    return 0;
  }

​   ​两种方法都比较好实现,前提是代码在一开始的时候就考虑过这些因素,并按照上述方式来编写,然后现实总是残酷的,面对一个已经编码完成的程序该如何为其编写系统调用的mock呢?就需要用到链接期垫片(link seam)的方法。

链接期垫片(link seam)

​   连接器垫片的方式一般情况有三种,如下:

  • Shadowing functions through linking order (override functions in libraries with new definitions in object files)
  • Wrapping functions with GNU's linker option -wrap (GNU Linux only)
  • Run-time function interception* with the preload functionality of the dynamic linker for shared libraries (GNU Linux and Mac OS X only)

​   第一种就是通过链接顺序来改变链接的对象,将要mock的对象重新实现一遍,链接的时候链接器会优先使用我们自己实现的同名函数,这样就可以将目标替换为要mock的对象了,完整代码如下:

//  一个待测试的对象
int main() {
  int fd = ::open("txt", O_RDWR|O_CREAT, 0777);
  assert(fd > 0);
  int ret = ::write(fd, "12345", 5);
  if (ret == -1) {
    perror("write:");
  }
  ret = ::close(fd);
  assert(ret == 0);
  return 0;
}

// 对目标进行mock,mock的对象是write系统调用
typedef ssize_t (*write_func_t)(int fildes, const void *buf, size_t nbyte);
// 通过dlsym的RTLD_NEXT获取write的下一个定义,也就是libc中的定义,如果想在mock中
// 调用真实的write系统调用不能直接用write,因为write已经被mock了,这样会导致一直递归下去
// 所以这里通过获取真实的write调用的地址,从而难道write的调用入口,这样既可以在mock中调用
// 真实的write调用了
write_func_t old_write_func =
    reinterpret_cast<write_func_t>(dlsym(RTLD_NEXT, "write"));

// 要mock的对象
extern "C" ssize_t write(int fildes, const void *buf, size_t nbyte) {
  errno = EIO;
  return -1;
}

​   另外一种就是Linux下独有的,通过gcc的--wrap选项可以指定要wrap的系统调用,那么相应的就回去调用带有__wrap前缀的对应系统调用实现,比如--wrap=write,那么在链接的时候就会链接到 __wrap_write,而真实的write调用变成了__real_write。完整代码例子如下:

// 测试程序
int main() {
  int fd = ::open("txt", O_RDWR|O_CREAT, 0777);
  assert(fd > 0);
  int ret = ::write(fd, "12345", 5);
  if (ret == -1) {
    perror("write:");
  }
  ret = ::close(fd);
  assert(ret == 0);
  return 0;
}

// mock对象
extern "C" ssize_t __real_write(int fildes, const void *buf, size_t nbyte);

extern "C" ssize_t __wrap_write(int fildes, const void *buf, size_t nbyte) {
  __real_write(fildes, buf, nbyte);
  errno = EIO;
  return -1;
}

​   最后一种就是给系统调用提供一份mock实现,并编译成动态库,然后通过LD_LIBRARY_PATH改变加载动态库的搜索路径让其优先搜索mock版本的动态库,或者是设置LD_PRELOAD环境变量,预先加载mock的动态库。

附录

目录
相关文章
|
6月前
引入系统调用
引入系统调用
45 0
|
6月前
|
JSON 数据格式
【有关调用第三方接口工具】
【有关调用第三方接口工具】
42 0
|
2月前
|
存储 缓存 NoSQL
webFilter实现mock接口
这段代码实现了一个名为 `MockFilter` 的类,继承自 `WebFilter` 接口,用于处理 HTTP 请求和响应。它通过从 Redis 缓存中获取配置信息来决定是否使用模拟数据或缓存数据来响应请求。如果开启了生产模式或关闭了模拟和缓存功能,则直接放行请求。否则,它会检查请求体并根据配置返回相应的模拟或缓存数据。同时,该过滤器支持对响应结果进行处理,并将结果存储回 Redis 中。
|
2月前
crash —— 如何获取某个系统调用入口代码?
crash —— 如何获取某个系统调用入口代码?
|
API 开发工具
【Pintos】实现自定义 UserProg 系统调用 | 添加 syscall-nr 系统调用号 | 编写新的参数调用宏
【Pintos】实现自定义 UserProg 系统调用 | 添加 syscall-nr 系统调用号 | 编写新的参数调用宏
116 0
|
存储 小程序 前端开发
小程序封装网络请求和拦截器
在开发小程序时,实际上我们通常需要封装网络请求和拦截器,以实现统一处理状态码和存储用户登录信息等功能。这样可以提高开发效率,减少代码重复,同时也可以提高代码的可维护性和可读性。
233 0
|
6月前
|
安全 Java 程序员
“系统调用”究竟是不是个函数?
- **系统调用**和普通**函数**有何区别? - 什么是**内核态** 和 **用户态**? - 操作系统如何让CPU切换状态? - 内中断、外中断、软中断、硬中断是什么意思? - 库函数和系统调
|
Shell
驱动开发:内核ShellCode线程注入
还记得`《驱动开发:内核LoadLibrary实现DLL注入》`中所使用的注入技术吗,我们通过`RtlCreateUserThread`函数调用实现了注入DLL到应用层并执行,本章将继续探索一个简单的问题,如何注入`ShellCode`代码实现反弹Shell,这里需要注意一般情况下`RtlCreateUserThread`需要传入两个最重要的参数,一个是`StartAddress`开始执行的内存块,另一个是`StartParameter`传入内存块的变量列表,而如果将`StartParameter`地址填充为`NULL`则表明不传递任何参数,也就是只在线程中执行`ShellCode`代码,利用
381 1
|
安全
驱动开发:取进程模块的函数地址
在笔者上一篇文章`《驱动开发:内核取应用层模块基地址》`中简单为大家介绍了如何通过遍历`PLIST_ENTRY32`链表的方式获取到`32位`应用程序中特定模块的基地址,由于是入门系列所以并没有封装实现太过于通用的获取函数,本章将继续延申这个话题,并依次实现通用版`GetUserModuleBaseAddress()`取远程进程中指定模块的基址和`GetModuleExportAddress()`取远程进程中特定模块中的函数地址,此类功能也是各类安全工具中常用的代码片段。
234 0
|
存储 API
驱动开发:内核文件读写系列函数
在应用层下的文件操作只需要调用微软应用层下的`API`函数及`C库`标准函数即可,而如果在内核中读写文件则应用层的API显然是无法被使用的,内核层需要使用内核专有API,某些应用层下的API只需要增加Zw开头即可在内核中使用,例如本章要讲解的文件与目录操作相关函数,多数ARK反内核工具都具有对文件的管理功能,实现对文件或目录的基本操作功能也是非常有必要的。
276 0