C 语言中的交互式编程
(给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
快速入门
在一个终端运行
make
,然后./main
,按r键
使其不规则分布,按q键
退出。编辑
game.c
以改变 Game of Life 的规则,添加颜色等。在另一个终端运行
make
,你的改变将立刻显示在原始程序中!
在撰写本文时,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个函数主要涉及装载和卸载。
Init():分配并返回要传递给其他每个API调用的状态。程序启动时将调用一次,但重新加载后不会调用。如果我们担心在共享库中使用
malloc()
,则封装器将负责执行实际的内存分配。Finalize():与
init()
相反,以释放游戏状态所拥有的所有资源。Reload():重新加载库后立即调用。这是在运行的程序中进行一些其他初始化的机会。通常,此功能为空。它仅在开发期间临时使用。
Unload():在卸载库之前,在加载新版本之前调用。这是为库的下一版本准备的机会。如果您要非常小心的话,可以使用它来更新结构等。通常也为空。
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 -
关于 C 语言的交互式编程,欢迎在评论中和我探讨。觉得文章不错,请点赞和在看支持我继续分享好文。谢谢!
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
↓↓↓
点赞和在看就是最大的支持❤️