M4: C Read-Eval-Print-Loop (crepl)
简化题意:维护一个小型 C REPL。用户可以先输入函数定义,把它们加入当前进程的符号环境;之后再输入表达式,程序把表达式包装成函数、动态编译、动态装载并执行,输出结果。
Lab 4: crepl 与动态链接接口笔记
1. 这题真正要做的事
这题表面上像“解释器”,但实现上并不是自己解析并执行 C 表达式,而是把 gcc 和动态链接器当作后端:
- 用户输入函数定义。
- 程序生成临时
.c文件。 - 调用
gcc -shared -fPIC编译出.so。 - 用
dlopen把.so加入当前进程。 - 用户输入表达式时,再生成一个只包含 wrapper 的临时
.so。 - 用
dlsym找到 wrapper 函数地址并调用,得到表达式值。
所以核心不变式是:
- 已定义函数必须能被后续函数和后续表达式看到。
- 语法错误、未定义符号、编译失败都要被识别出来,而不是默默返回成功。
2. 整体运行模型
可以把 crepl 理解成“进程内不断扩展的符号环境”:
用户输入函数定义 -> 生成临时 temp.c -> gcc -shared -fPIC temp.c -o tempN.so -> dlopen(tempN.so, RTLD_NOW | RTLD_GLOBAL) -> 新函数加入当前进程的全局符号环境
用户输入表达式 -> 生成临时 expr.c -> expr.c 中写入历史函数原型 + wrapper -> gcc -shared -fPIC expr.c -o exprN.so -> dlopen(exprN.so, RTLD_NOW | RTLD_GLOBAL) -> dlsym(handle, "expr") -> 调用 expr() 得到结果这里有两层“可见性”:
- 编译期可见性:新
.c文件里要有旧函数的声明,不然编译器不知道test_func()的类型。 - 运行期可见性:旧函数所在
.so必须已经被dlopen(..., RTLD_GLOBAL)加入全局环境,不然新库装载时找不到符号。
3. compile_and_load_function() 的过程
compile_and_load_function() 的任务不是“存字符串”,而是把这段函数定义真正变成当前进程里可用的符号。
典型过程:
- 打开临时
temp.c。 - 把历史函数原型写进去。
- 把本次函数定义写进去。
fork出子进程。- 子进程
execve("/usr/bin/gcc", argv, environ)执行:gcc -shared -fPIC temp.c -o tempN.so - 父进程
waitpid等编译结束。 - 若编译成功,则
dlopen(tempN.so, RTLD_NOW | RTLD_GLOBAL)。 - 成功后从函数定义里提取函数原型,保存给后续代码生成使用。
这里“保存函数原型”不是为了调用,而是为了后面生成新 .c 文件时补声明,例如:
int f(int x);int g() { return f(42); }4. evaluate_expression() 的过程
表达式不能直接 dlsym,因为 dlsym 找的是符号地址,而不是“裸表达式”。
所以表达式必须先被包装成一个真正的函数。
例如用户输入:
test_eval() / 2需要临时生成类似:
int test_eval();int expr() { return test_eval() / 2; }然后流程与定义函数类似:
- 写临时
expr.c。 gcc -shared -fPIC expr.c -o exprN.so。dlopen(exprN.so, RTLD_NOW | RTLD_GLOBAL)。dlsym(handle, "expr")得到函数指针。- 调用
expr(),把返回值写进*result。
这里 wrapper 最关键的一点是:必须 return expression;,否则函数返回值未定义。
5. 关键接口
5.1 装载共享库:dlopen
void *dlopen(const char *filename, int flags);本实验里最重要的 flags:
RTLD_NOW:现在就解析符号,失败立刻暴露。RTLD_GLOBAL:把这个库导出的符号放进全局可见环境,供后续新库使用。
如果前面定义了:
int f() { return 42; }后面定义:
int g() { return f() + 1; }那么 f 所在的库必须是 RTLD_GLOBAL 加载的,否则装载 g 时可能找不到 f。
5.2 查找 wrapper:dlsym
void *dlsym(void *handle, const char *symbol);用法:
int (*fun)() = dlsym(handle, "expr");这里 expr 必须是你生成的 wrapper 名字。
dlsym 找到的是符号地址,所以表达式必须先包成函数。
5.3 错误信息:dlerror
char *dlerror(void);当 dlopen 或 dlsym 失败时,它能给出“未定义符号”“库打不开”等人类可读错误信息。
调动态链接问题时很有用。
5.4 行编辑和历史:readline / add_history
char *readline(const char *prompt);void add_history(const char *line);作用:
readline提供可编辑输入行。add_history把输入加入历史,之后上下键才能翻。
注意:
- 只有
readline()不够;不调用add_history(),上下键没有历史可翻。 - 链接时还需要
-lreadline。
6. Makefile 与链接
本实验的主程序本身要链接:
-ldl:为了dlopen/dlsym-lreadline:为了readline/add_history
它们本质上属于链接选项,应放进 LDFLAGS,而不是混在只管编译参数的 CFLAGS 里。
7. 本地测试思路
这题的本地验证可以分三层:
-
能否编译
crepl本身:make cleanmake -
手动 REPL 冒烟:
int f() { return 42; }f()21 + 21int g() { return f() / 2; }g()undefined_function()21 + -
运行
tests.c里的UnitTest- 这些测试会直接调用
compile_and_load_function()和evaluate_expression()。 - 如果
main()是无限 REPL,测试触发方式要额外处理,否则程序不会自然退出。
- 这些测试会直接调用
8. 这次实现里踩过的坑
- 把所有动态库都输出到同一个固定路径,如
/tmp/tmp.so或/tmp/expr.so,导致dlopen复用旧对象,新代码没有真正装入。 - 把共享库路径写成
.c,把源码文件和.so混淆。 execve("usr/bin/gcc", ...)少了开头的/;execve不帮你查PATH。- 子进程
execve失败后没有立刻_exit(...),导致失败路径被误当成功路径继续执行。 - 写完临时
.c后没fclose就编译,结果gcc读到半截文件。 - 只保存了旧函数的存在,却没在新生成的
.c文件里写旧函数声明,导致编译期找不到test_func()的类型。 - 误以为“之前已经
dlopen过函数库”就不需要声明;实际上编译期声明和运行期符号解析是两回事。 - 表达式 wrapper 写成
int expr(){ expression; },忘了return,函数返回值未定义。 dlsym要找的是函数名,不是裸表达式;所以必须显式生成expr()之类的 wrapper。- 在字符串拼接时只覆盖
_buffer,导致前序函数原型没真的写进临时源码。 - 以为用了
readline就天然支持上下键历史;实际上还要add_history()。 - 头文件加上了
readline,但 Makefile 没加-lreadline,最终报undefined reference to readline。
9. 总结
- 这题的本质不是“自己实现 C 解释器”,而是“把编译器和动态链接器接成一个 REPL”。
gcc -shared -fPIC负责把输入代码变成共享对象。dlopen(..., RTLD_NOW | RTLD_GLOBAL)负责把函数定义累积进当前进程的符号环境。dlsym负责从表达式 wrapper 中找到真正可调用的函数入口。- 编译期声明和运行期符号可见性必须同时满足,才能让“后定义代码调用先定义函数”稳定成立。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时






