About
RSS

Bit Focus


struct 禁区: 内存布局耦合与 C 语言 ABI 基础

Posted at 2011-08-08 09:07:25 | Updated at 2024-12-09 18:02:14

    假如现在有个某复杂系统需要存放人员信息. 人员类型定义类似:
struct person_t {
    name_t name;
    age_t age;
};
    这个系统的人员添加功能由两个部分通过动态库集成到一起, 后端的部分只负责拿到人物信息并做诸如存入数据库, 数据统计等工作, 它向前端提供了两种风格的接口, 分别是
void add_person_0(name_t name, age_t age);
void add_person_1(struct person_t const* person);
    两种看上去没有太多区别. 对于前一种接口, 使用该接口的代码很可能写成这样子
add_person_0("hideyosi", 16);
而对于后一种接口, 代码很可能这样写
struct person_t person = { "hideyosi", 16 };
add_person_1(&person);
    现在考察一次扩展性, 而且来最坏的情况: 接口提供方先行更新了动态库, 而使用方在一段时间内并不知情. 更新的内容包括, 在 nameage 之间插入一项 gender (当然前一种人员添加接口也有变动):
struct person_t {
    name_t name;
    gender_t gender;
    age_t age;
};

void add_person_0(name_t name, gender_t gender, age_t age);
void add_person_1(struct person_t const* person);
    这时会怎么样呢?

    显然两种情况都好不到那里去.
    对于 add_person_0 这个接口, 由于参数个数都改变了, 签名对不上号, 那么使用该接口的一方在通过动态库访问此函数时会少压入一个参数, 并且压入的第二个参数 age 对上的还是 gender 这个参数的位置. (在 C++ 中, 由于允许函数重载, 动态库中的函数名会因签名不同而被 name-mangling 成不同的函数, 因此会导致函数找不到错误. 当然这并不意味着 C 和 C++ 孰优孰劣, 因为这本来就是两个不同的语言, 只是在这一个问题上有不同的死法罢了.) 这样一调用函数程序立马崩掉不客气.
    而对于第二种使用, 因为使用方在初始化 person_t 时, 仍然按照旧的逻辑, 即在偏移为 0 的地方放上 name, 在偏移为 sizeof(name_t) 的地方放上 age, 然后将这样初始化的 person 结构体传递给接口提供方; 而此时提供方对这一片内存区的解释有很大不同, 至少它认为的 sizeof(person_t) 都比使用方给出的要多出一个 sizeof(gender_t). 使用这样的 person 就像拿着三年前的武汉市地图到今天的洪山广场游玩结果掉进工地不明不白地挂掉了一样.

    那么有没有什么无痛的方法能够在这种不同模块分离更新的环境下让程序仍然, 至少在新来的人可能都缺少性别的情况下, 能够继续像模像样地跑呢?
    要找到这个方案, 自然应该先看看问题出在什么地方. 这里问题的核心围绕着内存布局, 即双方在 person_t 这一结构体到底包含了什么数据需要达成一致, 换句话说就是因为 person_t, 双方紧耦合了.

    解决方案很自然的就是, 把所有跟 person_t 内存布局相关的代码全部移到一个模块 (当然是接口提供方那边) 里去, 并提供一套操作接口 (为了彻底不让使用方知道 person_t 的内存布局, 干脆只给个前置声明好了)
struct person_t; // 仅前置声明

struct person_t* new_person(); // 这里不可以有参数, 否则会如 add_person_1 一样发生悲剧
void delete_person(struct person_t* person);
void set_person_name(struct person_t* person, name_t name);
void set_person_age(struct person_t* person, age_t age);

void add_person(struct person_t* person);
    现在使用这一套接口的函数应该这么来搞
struct person_t* person = new_person();
if (person == NULL) {
    return /* HERE MUST BE SOME ERROR */;
}
set_person_name(person, "hideyosi");
set_person_age(person, 16);
add_person(person);
delete_person(person);
    现在如果加个 gender_t, 加就加吧, 反正都是接口提供方的事情了. 接口提供方需要做
    像上面说的这样, 调用起来 (往往是通过动态库调用) 对参数传递方式, 数据大小, 内存对齐方式, 参数压栈顺序甚至返回值如何传递等有严格的细节要求的接口, 便称之为应用程序二进制接口 (ABI). 不同的操作系统, 编译器通常都会统一规定一组 ABI.

    扩展阅读: 在 C 标准库中大名鼎鼎的 FILE* 也采用了类似的设计. 使用 FILE* 并不是直接访问其中的成员, 而是通过 fopen, fclose, fseek 等等函数去操作. 而 Linux API 就做得更绝了, 反正文件数据结构都存在内核里面, 给外面只暴露个 int file_descriptor 就行了吧.

Post tags:   C  ABI

Leave a comment:




Creative Commons License Your comment will be licensed under
CC-NC-ND 3.0


. Back to Bit Focus
NijiPress - Copyright (C) Neuron Teckid @ Bit Focus
About this site