一个线上问题
一服务进行现网版本发布。服务更新后,收到 core dump 告警。查看 core file,从函数调用栈帧、地址、行号等信息来看,已经有部分内存遭到污染。栈帧出现了重复,gdb 显示的行号也显然不可能发生问题。好在通过 core file 函数名进行代码分析后,比较及时地找到了问题原因。
下面用一段简单代码来说明:
// example_1.h
void func(int x, int *p);
// example_1.c
void func(int x, int *p)
{
*p = calc_something_by_x(x);
}
// example_2.c
extern void func(int *p); // extern 显然不是必须的
void do_something(struct Context *ctx, int *p)
{
func(p);
}
能够看出,do_something中调用的func的参数列表与实际实现不一致,连参数数量都不一样。查阅代码提交记录,发现某前一版本func函数声明为:
void func(int *p);
后被修改为当前版本。而 example_2.c
作者在开发过程中直接复制粘贴了原始版本的func
函数签名并调用。example_1
的实现被修改后,因 translation unit 不同,两个文件都能够通过编译。链接时,由于项目是C语言,函数生成的符号名并不会像C++经过 name mangling 后带上类型信息,而是单纯的函数名。链接也能成功。
项目构建能够成功,导致漏修改的问题并没有在开发过程中发现。
do_something
实际运行时调用func
是缺少参数的,func
按调用约定获取参数p
值是一个脏值。当p
地址被写入时,可能因为地址不合法立刻被操作系统感知;也有可能写坏了其他的位置——也许完全没有影响,也许继续运行一段时间,整个系统会越来越混乱。
通过一些项目相关工具,查询到正式上线之前,相关代码有被运行过,但并未发现任何相关的 core dump。除被执行次数确实极少以外,推测原因是该问题并非立刻必现 segment fault(取决于脏值的实际内容),而测试环境服务的重新发布、重新启动较频繁,很可能还未发生致命的内存错误,进程就已经重启了。
由此我们可以看出,这种不规范的代码习惯,隐蔽性很强,且造成的后果极其严重。
提交前干预——代码规范化
大型项目开发时间长、经手人数多、代码仓库大。真实线上故障暴露此问题,说明项目代码仓库中已经存在大量的不使用头文件引用外部函数,而直接复制粘贴函数签名的行为。此处提出几条规范,在开发和review过程中如果严格执行,则可以相当程度地防止类似问题再现。
- 除
static
修饰的函数外,源文件中不得含有任何函数声明。 - 头文件、源文件中,如函数用
inline
修饰,只允许定义,不允许纯声明。 - 非
static
函数,其声明必须存在于同名头文件中,且定义所在的源文件必须包含该头文件。 - 在头文件中声明的函数,必须存在定义,且定义所在的源文件必须存在于同名源文件中。
- 业务逻辑代码无特殊理由不得使用
extern
修饰函数。
提交后干预——检测工具化
若已经有类似问题代码被提交,或是存量代码需要扫描,则必须通过自动化工具实现。比较“正统”的解决方法显然应当是分析 AST。如果真的要介入编译流程,即使使用clang+LLVM,所需开发时间也较长,不是几个小时能够解决的(希望能够以最快速度排查现有代码,防止出现其他故障)。此处提出一个基于简单词法语法分析、规则检测的方案。
前置处理
待扫描文件列表
首先根据项目实际情况,整理需要分析的目录、文件,并输出文件列表。这种功能比较简单,使用几个命令即可。假设采用大仓模式,根目录下每个子目录属于一个“模块”(公共库/二进制程序),但dep
目录属于第三方库需要排除。假设扫描工作目录为/funcdecl_tool/
find .. | grep "\\.c$" > source_list.txt
这样就可以很快地处理出需要扫描的文件列表。
统一格式化
人类在编写代码时,换行、空格、符号前后跟随等行为并不确定。为了更加便于分析,我们对所有待分析文件进行格式化。选用clang-format
即可。这里主要希望将函数声明完全收敛在一行内(即修饰字、返回类型、参数等不换行)。给.clang-format
加入一个选项即可,其他的都不是必须的:
ColumnLimit: 2000
选取一个显然较大的值,就能收敛在一行内了。把刚才整理好的待分析列表用xargs
喂给clang-format
,进行格式化,前置处理工作完成。
函数提取
此处提出一个简单的思路。Ctags
是一个用来从源代码中生成标识符索引的工具,如果我们使用它进行基于文本的静态分析,能低成本地获取到所需的信息。例如:
ctags -x --c-kinds=pf --fields=+S <file_name>
其中:
-x
用制表符打印(不重要)--c-kinds=pf
提取C语言prototype和function--fields=+S
附加提取签名
其输出结果形如:
check_worldtype_belong_and_redirect prototype 37 ../zonesvr/checkverify.c int check_worldtype_belong_and_redirect(LPCONNIDXINFO pstConnIdx, uint8_t bWorldType, char chSendRedirect);
get_app_license_verify_result function 231 ../zonesvr/checkverify.c static int get_app_license_verify_result(LPCONNIDXINFO pstConnIdx, const char *szOpenId, const char *pszGoogleAppLicenseMsg, const char *pszGoogleAppLicenseSignature) {
get_rsa function 158 ../zonesvr/checkverify.c static RSA *get_rsa() {
我们从每一行可以依次获取到:
- 函数名
- 元素类型(prototype 原型/声明;function 函数/定义)
- 行号
- 文件路径
- 出现行的内容(
clang-format
处理后)
此外,从最后附上的原始文本中,通过提取extern
/static
/inline
等关键字,还可以获取到修饰符信息。
enum class ElementType {
kUnknown,
kFunction,
kPrototype
};
class Record {
public:
std::string name_;
ElementType type_;
int line_;
std::string file_;
bool is_static_;
bool is_inline_;
Record(const std::string &data) : type_(ElementType::kUnknown) {
std::stringstream ss(data);
thread_local std::string type_str;
type_str.clear();
ss >> name_ >> type_str >> line_ >> file_str_;
if (type_str == "function") {
type_ = ElementType::kFunction;
} else if (type_str == "prototype") {
type_ = ElementType::kPrototype;
}
if (data.find("inline ") != std::string::npos) {
is_inline_ = true;
}
if (data.find("static ") != std::string::npos) {
is_static_ = true;
}
}
friend std::ostream &operator<<(std::ostream &out, const Record &record) {
out << record.name_ << "\t" << record.file_ << "\t" << record.line_;
return out;
}
};
规则检测
预处理
在大仓模式中,假设每个服务模块是一个子目录。那么全量扫描的函数列表,应当遵从的归属关系是:模块->模块下文件->函数。我们边读取ctags
结果边构造每个模块的数据结构:一个映射,key是函数名,value是该模块下所有该函数的声明和定义。
对于本文所述项目,还有两个特殊的大仓下一级子目录comm
和framework
,这是两个库,各个独立服务都会链接到上面。因此comm
和framework
当中的内容需要合并到每个独立模块中,当作它们自己的“一部分”。
宽松规则
宽松规则只对源文件扫描,不对头文件扫描。通过上述方法构造扫描结果后,我们可以遍历每一个模块下的每一个函数的所有声明和定义列表。显然,若一个函数在某源文件中存在声明但不存在定义,且项目还能够正常链接,这种情况就符合本文所描述的高危行为。
严格规则
严格规则用来匹配以下两条:
- 非
static
函数,其声明必须存在于同名头文件中,且定义所在的源文件必须包含该头文件。 - 在头文件中声明的函数,必须存在定义,且定义所在的源文件必须存在于同名源文件中。
在已有的数据结构下,对于严格规则只需要少量开发判断文件名的逻辑,也可较简单地应用。