查看原文
其他

C 语言中的交互式编程

cookie CPP开发者 2021-07-20

(给CPP开发者加星标,提升C/C++技能)

英文:Chris Wellons,翻译:CPP开发者 / cookie

交互式编程是在程序运行时对其进行修改和扩展。对于一些非批处理程序,它在开发过程中需要做大量乏味的测试和调试。直到上周,我才知道如何在 C 语言中应用交互式编程。如何重新定义正在 C 程序中运行的函数。

上周在 Handmade Hero(第21~25天)中,Casey Muratori 将交互式编程添加到了游戏引擎中。这在游戏开发中是特别有用的,可能游戏开发者想要在玩的过程中去调整,而不必在每次调整后重新启动整个游戏。现在我已经看到他明显完成了。秘诀是将几乎整个应用构建为共享库。

这也严重的限制了程序的设计:它不能在全局或者静态变量中保留任何状态,尽管无论如何都应该避免这种情况。每次重载共享库时,全局状态都会丢失。在某些情况下,这还会限制C 标准库的使用,包括像malloc()之类的函数,但是否限制其使用具体取决于这些函数实现和链接的方式。例如,如果 C 标准库是静态链接的,具有全局状态的函数可能会将全局状态引入到共享库中。这很难去知道什么是安全使用的。C语言交互式编程在 Handmade Hero 中工作得还不错是因为内核游戏(作为共享库加载的部分)不使用外部库,包括标准库。

此外,共享库在使用函数指针时必须小心。在共享库重载后,函数指针的对象将不再存在。将交互式编程与面向对象的 C 结合使用时,这是一个实际的问题。

例子:The Game of Life

为了演示它是如何工作的,让我们看一个例子。我编写了一个简单的 Game of Life 的演示,该演示很容易修改。如果你想在类似 Unix 系统下跑跑它,可以在这里获取整个源代码。

https://github.com/skeeto/interactive-c-demo

快速入门

  1. 在一个终端运行make,然后./main,按r键使其不规则分布,按q键退出。

  2. 编辑game.c以改变 Game of Life 的规则,添加颜色等。

  3. 在另一个终端运行make,你的改变将立刻显示在原始程序中!

(GIF 动图帧数大于 300帧,超过微信平台限制了,故而用截图了)

在撰写本文时,Handmade Hero 是在 Windows 上编写的,所以 Casey 使用的是 DLL 和 Win32 API。但是也可以使用libdl在 Linux 或者任何其它类 Unix 系统上,接下来的例子就是这么用的。

该程序分为两部分:The Game of Life 共享库(“game”)和封装器(“main”),封装器的作用是加载共享库,当它更新的时候重载它并在一个定期的间隔调用它。因为封装器与“game”部分的操作无关,所以它能在另外的项目中几乎不改动的重复使用它。

为了避免在多个位置维护一堆函数指针,将“game”的API封装在一个结构中。这也消除了C编译器关于数据和函数指针混合的警告。Game_state结构的布局和内容对 game 本身来说是私有(private)的,封装器仅仅处理指向该结构的指针。

struct game_state;

struct game_api {

  struct game_state *(*init)();

  void (*finalize)(struct game_state *state);

  void (*reload)(struct game_state *state);

  void (*unload)(struct game_state *state);

  bool (*step)(struct game_state *state);

};

在该演示中,API由5个函数组成,前4个函数主要涉及装载和卸载。

  1. Init():分配并返回要传递给其他每个API调用的状态。程序启动时将调用一次,但重新加载后不会调用。如果我们担心在共享库中使用 malloc(),则封装器将负责执行实际的内存分配。

  2. Finalize():与init()相反,以释放游戏状态所拥有的所有资源。

  3. Reload():重新加载库后立即调用。这是在运行的程序中进行一些其他初始化的机会。通常,此功能为空。它仅在开发期间临时使用。

  4. Unload():在卸载库之前,在加载新版本之前调用。这是为库的下一版本准备的机会。如果您要非常小心的话,可以使用它来更新结构等。通常也为空。

  5. Step():定期调用以运行游戏。一个真正的游戏可能会具有更多这样的功能。

该库将提供一个填充的 API 结构作为全局变量 GAME_API。**这是整个共享库中唯一导出的符号!**所有函数都将声明为静态,包括该结构所引用的函数。

const struct game_api GAME_API = {

  .init   = game_init,

  .finalize = game_finalize,

  .reload  = game_reload,

  .unload  = game_unload,

  .step   = game_step

};

dlopen,dlsym和dlclose

该封装器的重点是用正确的顺序,在正确的时间调用dlopen(),dlsym(),dlclose()。该游戏被编译为libganme.so,这也就是被加载的东西。它在源代码中用./以强制将名称用作文件名。封装器追溯game结构中的所有内容。

const char *GAME_LIBRARY = "./libgame.so";

struct game {

  void *handle;

  ino_t id;

  struct game_api api;

  struct game_state *state;

};

该handle是dlopen()的返回值,id是共享库的索引节点,是stat()的返回值。其余的定义如上所示。为什么是索引节点?我们可以改用时间戳,但它是间接的。我们真正关心的是,共享对象文件实际上是否不同于已加载的文件。该文件永远不会在合适的位置进行更新,而是被编译器/连接器替换,所以时间戳并不重要。

使用索引节点比Handmade Hero简单的多。由于Windows的损坏文件锁定机制,游戏DLL在使用时不能被替换。要解决此限制,构建系统和加载器不得不依赖于随机生成的文件名。

void game_load(struct game *game)

该game_load()功能的目的是将游戏API加载到game结构中,但前提是尚未加载游戏API或已对其进行更新。由于它具有多个独立的故障条件,因此我们将对其进行部分检查。

struct stat attr;

if ((stat(GAME_LIBRARY, &attr) == 0) && (game->id != attr.st_ino)) {

首先,使用stat()来确定库的索引节点是否不同于已加载的索引节点。该id字段最初将为0,因此只要stat()返回成功,它将首次加载该库。

  if (game->handle) {

    game->api.unload(game->state);

    dlclose(game->handle);

  }

如果已经加载了库,请先将其卸载,请确保调用 unload()以通知库正在更新。**确保Dlclose()在dlopen()之前调用是至关重要的。**在我的系统上,dlopen()仅查看给定的字符串,而不查看其背后的文件。即使文件已在文件系统上被替换,dlopen()也会看到该字符串与已打开的库匹配,并返回指向旧库的指针。(这是一个错误吗?)句柄由libdl在内部进行引用计数。

  void *handle = dlopen(GAME_LIBRARY, RTLD_NOW);

最后加载游戏库。由于dlopen()的限制,这里存在一个竞态条件。在调用stat()之后,库可能已经再次更新。由于我们无法询问dlopen()打开的库的索引节点,因此我们无法得知。但是由于这只是在开发过程中使用,而不是在生产中使用,所以这没什么大不了的。

  if (handle) {

    game->handle = handle;

    game->id = attr.st_ino;

    */\* ... more below ... \*/*

  } else {

    game->handle = NULL;

    game->id = 0;

  }

如果dlopen()失败,它将返回NULL。在ELF的情况下,如果编译器/链接器仍在写出到共享库的过程中,则会发生这种情况。由于卸载已经完成,这意味着game_load返回时不会加载任何游戏。该结构的用户需要为此做好准备,它将需要稍后(即几毫秒)再尝试加载。当未加载任何库时,可以使用存根函数填充API

  const struct game_api *api = dlsym(game->handle, "GAME_API");

  if (api != NULL) {

    game->api = *api;

    if (game->state == NULL)

      game->state = game->api.init();

    game->api.reload(game->state);

  } else {

    dlclose(game->handle);

    game->handle = NULL;

    game->id = 0;

  }

当库无错误加载时,查找前面提到的GAME_API结构并将其复制到本地结构中。在进行函数调用时,进行复制而不是使用指针避免了一层重定向。如果尚未初始化游戏状态,则调用reload()函数以通知游戏它刚刚被重新加载。

如果查找GAME_API失败,请关闭句柄并将其视为失败。

主循环每次都调用game_load()。它就是这样!

int main(void)
{
    struct game game = {0};
    for (;;) {
        game_load(&game);
        if (game.handle)
            if (!game.api.step(game.state))
                break;
        usleep(100000);
    }
    game_unload(&game);
    return 0;
}

现在,我已经掌握了这项技术,很想用C语言OpenGL去开发一个完整的游戏,或许是另一个极限游戏开发。交互式开发的能力真的很令人着迷。


- EOF -


推荐阅读  点击标题可跳转

1、C++ Boost 智能指针详解

2、一文带你轻松掌握多种C++编程范式

3、lib 和 dll 的区别、生成以及使用详解


关于 C 语言的交互式编程,欢迎在评论中和我探讨。觉得文章不错,请点赞和在看支持我继续分享好文。谢谢!


关注『CPP开发者』

看精选C++技术文章 . 加C++开发者专属圈子

↓↓↓


点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存