MENU

C语言项目不规范函数声明的隐患及其检测

March 29, 2022 • 程序阅读设置

一个线上问题

一服务进行现网版本发布。服务更新后,收到 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是该模块下所有该函数的声明和定义。

对于本文所述项目,还有两个特殊的大仓下一级子目录commframework,这是两个库,各个独立服务都会链接到上面。因此commframework当中的内容需要合并到每个独立模块中,当作它们自己的“一部分”。

宽松规则

宽松规则只对源文件扫描,不对头文件扫描。通过上述方法构造扫描结果后,我们可以遍历每一个模块下的每一个函数的所有声明和定义列表。显然,若一个函数在某源文件中存在声明但不存在定义,且项目还能够正常链接,这种情况就符合本文所描述的高危行为。

严格规则

严格规则用来匹配以下两条:

  • static 函数,其声明必须存在于同名头文件中,且定义所在的源文件必须包含该头文件。
  • 在头文件中声明的函数,必须存在定义,且定义所在的源文件必须存在于同名源文件中。

在已有的数据结构下,对于严格规则只需要少量开发判断文件名的逻辑,也可较简单地应用。

Archives Tip
QR Code for this page
Tipping QR Code