« Delve 迷你地下城冒险游戏 | 返回首页 | silenceisdefeat 关掉了 TCP Forwarding »

给你的模块设防

我们设计任何一个模块,都应当对其实现细节尽可能的隐藏。只留下有限的入口和外部通讯。这些入口如何定义是重中之重。大多数情况下,我们都在模仿已有的系统来设计,所以对貌似理所当然的接口定义不以为然,以为天生就应该是那样,而把过多精力放在了如何做更好(更优化)的实现上。对接口设计方面缺乏深度的思考,使得在面对新领域时,或是随心所欲,或是不知所措。

即使是有成熟设计的模块,用户依然可能使用错误。模块的设计者不能要求模块的使用者对其内部实现了然于胸。不能指望自己能写出完善的文档去告诫使用者,当你怎样用的时候会用错。即使写出了完善的文档,也不能指望每个人都仔细的读过。就算读了,也有百密一疏的时候。越是专有的模块,越不能指望文档或是口述的教导,更不能指望程序员去精读源码。人生苦短,如无特别的理由,逐行去读同僚的代码,何不自己重写一遍?你设计的系统若用出了问题,与其怪别人用错,不如怪自己没设计好。MSDN 洋洋洒洒文字以 G 计算,也属无奈之举。依然有无数人犯下那些被人提及的错误。

现举一个一切 C 语言程序员都用过的模块的设计:内存管理模块。

标准 API 为三个:

malloc

free

realloc

大多数程序员都以为这理所当然。直到接触到 gc 的方式管理内存,方知另有一片天地。即使在 C 库中引为标准,也并不是所有内存管理器都承认这种简洁的。比如在 Windows 的 API 中,HeapAlloc 系列的内存管理模块的 API 就更复杂一些。

当我们考察 malloc 这组 API ,我们会发现其实它并不是绝对可靠的。稍有经验的 C 程序员就会指出:我们不能给 free 传入随意的指针。一个典型的错误就是对一个由 malloc 分配出的地址,调用 free 两次。

次要一点的一个问题是,分配了一块内存,但丢失了地址指针。导致了无法释放内存。

还有一些问题,比如分配了一个内存块,没有初始化内容,而直接引用了里面不可预知的数据;或是释放了一块内存,但依旧引用里面的数据。读写一个内存地址时,超过了分配时的边界;没有检查分配失败的情况;realloc 扩展内存后,地址发生变化,但有地方依旧引用老的地址。

为了防备这些问题,最常用的方法不是反复告戒程序员规避这些错误,而是给模块本身设防。让程序员犯错误时及早发现。当然,如果条件允许,应该更谨慎的定义模块接口,甚至在发现设计问题后,重新设计。

内存管理模块,很多情况下是由第三方提供的。我们不太会去研究其源代码并做出修改。即使由自己开发,也希望其足够独立,在软件的发行版中提供最高的性能(而不因为过多的错误检查而拖慢系统)。

给 api 加个壳是个很不错的方案。


malloc ,分配一块内存,返回这块内存的内存地址。按照 malloc 的 ISO 标准,当分配失败的时候,返回空指针。当试图请求分配 0 字节长的内存块时,返回值依赖实现:可以返回空指针,或是返回一个有效内存地址。返回的内存块里的数据未定义。

根据我们自己项目的事情情况,以及开发团队的开发规范。我们可以加强 api 的定义。对于一些无定义的特性,应该尽量去除,保证其不会被用到(使用断言)。以我个人的经验,我所开发的软件中,软件使用的内存总量可以被预估出来,不可能超过系统能提供的内存上限。这时,我不需要 malloc 对分配失败返回空指针。一旦出现这种情况,一定是我错误的传递了请求的长度值。这时,我会在自定义的壳中检查返回值,并断言它一定不为空指针。

注:每次内存分配都成功的断言,不一定在每个项目中都成立。有些情况下,允许内存分配失败,并判断返回失败情况还是有必要的。

对于分配零字节长度内存的情况,我倾向于统一使用一致的设定,即返回一个唯一有效的内存地址,让 malloc(0) 等价于 malloc(1) 。这可以很好的保持于 C++ 的 new 行为一致。( C++ 中,使用 operator new 时,若申请的长度为 0 ,按标准所定,是不会返回空指针的)

对于内存块的内容,我倾向于做一次填充。这可以减少出错的随机性。但不要填充成 0 。这极有可能掩盖掉没有正确初始化内存的问题。在 x86 的机器下,可以考虑填充 0xCC 。0xCC 在 IA32 指令集中是 int3 中断的机器码。万一函数指针错误跳转到数据区,可以自动触发一次调试中断。而且 0xCCCCCCCC 在大多数操作系统下,是个无效地址。

free ,释放一块由 malloc 或 realloc 分配的内存。按标准,free 可以接受空指针,并不做任何事情。这一点在自己设计内存管理器时,一定需要遵循。free 后的内存块的数据处于未定义状态。如果我们给 free 加上壳的话,我的个人建议是对 free 后的内存块做数据填充。这样可以帮助提前发现程序中引用以释放的内存。

realloc ,改变已分配的内存块的大小。如果内存块无法在原地址扩展,在搬移到新地址上时,realloc 会复制原有的数据。根据标准的定义,当 realloc 扩展内存失败时,返回空指针,并保留由有的内存块。这反而会导致程序员的一些疏忽,在某些对内存管理比较苛刻的库设计中,让内存泄露。(忘记释放 realloc 扩展失败后的原地址)有些系统(例如 freeBSD)提供了另一个相似的 api ,reallocf 来回避这个问题。

所幸,很多软件中,我们可以按上面 malloc 的处理方案来规避这个问题。扩展内存失败往往意味着别的地方出了 bug 。在壳中断言 realloc 一定成功即可。


如何找到多次 free 同一块内存,如何找到内存的写越界 ?虽没有万无一失的方案,但我们可以在壳中多做一些工作来尽可能的提前发现问题。

典型的方法是给分配出来的内存块前后加上狗牌。例如:

#define DOGTAG_VALID 0xbadf00d #define DOGTAG_FREE 0x900dca7e #define DOGTAG_TAIL 0xd097a90 struct cookie { size_t sz; int tag; }; void * my_malloc(size_t sz) { if (sz == 0) sz = 1; struct cookie * c = malloc(sizeof(struct cookie) + sz + sizeof(int)); assert(c != NULL); c->sz = sz; c->tag = DOGTAG_VALID; int * tail = (int *)((char *)(c+1) + sz); *tail = DOGTAG_TAIL; memset(c+1, 0xCC, sz); return c+1; } void my_free(void *p) { if (p==NULL) return; struct cookie * c = p; --c; assert(c->tag != DOGTAG_FREE); assert(c->tag == DOGTAG_VALID); int *tail = (int *)((char *)p + c->sz); assert(*tail == DOGTAG_TAIL); c->tag = DOGTAG_FREE; memset(p, 0xCC , c->sz); free(c); }

如何检测内存泄露?即申请的内存块没有释放。

这个问题应该分类讨论。

以我个人的习惯,有些内存块在整个软件的生命期中,只会被申请一次,而随程序退出而由系统回收。主动调用 free 是一种额外的负担,更容易引起错误。这种做法,在 C++ 程序中是不推荐的。但 C++ 软件的诸多常见 bug 和难以解决的问题,正在于单件的构造析构次序。但在基于 C 的软件中,这类做法却很常见。

我的个人喜好是,增加一个 api 专门用来分配不会动态释放的内存。这类内存打上特殊的 tag ,一旦 free 它就会断言出错。

而另一些动态管理的内存,则双向链表链起来。软件退出的时,检查这个链表。很多人在 MFC 的框架中已经见识了这个做法,这里不再列出详细的代码。


如何给已有的内存管理器加壳?

有些 C 库,比如 GNU C 库,提供了钩子。设置这些钩子可以加上壳帮助你调试代码。如果你用的 C 库没提供这个能力,通常的解决方案是在头文件中使用宏替换。如果是 C++ 代码,可以重定义全局的 new / delete 运算符。在 MFC 中,采用的就是宏替换 new 和 delete 运算符的作法。这个做法是为了记录使用这些运算符的源代码位置。但这种做法有一些弊病,下面我会推荐一个在基于 C 开发的软件中更灵活的方案。

首先说说直接宏替换,可能存在的问题:

#define malloc(sz) malloc_proxy(sz)

这样做在大多数情况下都是有效的。但当我们想把 malloc 作为函数指针传递时会有些麻烦。其实我们可以采用另一个方案。

typedef void* (*malloc_f)(size_t sz); #define malloc malloc_proxy() malloc_f malloc_proxy() { return my_malloc; }

如果我们希望在调用 malloc 时,记录调用的源代码位置,并记录在 cookie 中,或是断言出错的时候输出到标准错误设备中。可以这样。

#define malloc malloc_proxy(__FILE__ , __LINE__) malloc_f malloc_proxy(const char *filename, int line) { g_filename = filename; g_line = line; return my_malloc; }

这里采用了全局变量来记录源码信息,而没有通过参数传递。在多线程环境可能会有一点小问题,但是无伤大雅。也容易回避。自定义的壳从全局变量去获得源代码信息,也更容易保持接口一致。

接下来我们只需要扩展 cookie 结构,来提供更多调试信息。

注:在设计 cookie 时,可以考虑把 cookie 前面第一项放无关紧要的信息,或空出来。至少不要把狗牌放在最前面。这是因为,free 对内存块的破坏是不可预知的。如果想检测出多同一内存的多次释放,需要对内存管理器有一定的了解,尽量回避 free 对内存内容的改写。大多数高效的内存管理模块都采用了 freelist 技术。这种技术会利用已释放的内存块的开头区域存放 freelist 指针。如果你把狗牌设在这个位置,可能会被改写掉。

Comments

您好,请问三个狗牌值是随意取的吗?

这期的内容感觉很丰满,建议云风多写一些这样的主题,一点疑问就是那个malloc_proxy的更多需求是什么?,我直接在malloc的时候断言不就OK了???

@mickalpeng

正文都写了。

云风老师,看了您的my_malloc,其中的思想在《编程精粹》中也有提到,不过他是用在数据初始化的时候,即数据初始化的时候给初始值一个极大值(类似OxCC),既为了指令集的中断,同时如果出现了错误也可以在调试的时候很清楚的看到这个错误的值(VC调试环境实现)。
而my_malloc为什么要防止出现请求地址为0的情况呢,是为了接口一致性(不是很确定),不知道我的理解对不..

狗牌的那几个值有特殊意义吗?
我想利用这个狗牌去检查内存区是否有效,是否是野指针.在怀疑有问题的地方先check下.
我想有两点:
1, 指针区是有访问权限的,这个检查下tag.
2, 指针指向的地址本身就没有访问权限.

粉丝淘http://www.fanstao.cn 来踩啦!小站不错O(∩_∩)O~

对于assert我有一个很好的经验分享一下,自己定义一个
assert_eqi断言两个整数相等,如果不等的话,打印出两个整数的值,这样便于调试

云风很关心语言的细节,这点我很喜欢。恩,狗牌这个提法不错!

@Raymond

assert 两次是可以更准确的从 assert 的 log 中看出具体问题。

关于对齐问题,如果所在平台必须要求字对齐,那么就再做一下。

如果不是必须要求,那么就无所谓。性能在这里已经损失很多,不是问题了。

这种方式真是比较有趣,很少这样用过,我碰到的内存问题用valgrind就解决了,还没有碰到特别复杂的内存问题。

assert(c->tag != DOGTAG_FREE);
assert(c->tag == DOGTAG_VALID);
这样有什么考虑吗?我觉得第二个可以了呀

cloud
不考虑对齐的问题吗?tail可能处于一个非对齐的地址,这个在一些系统上可能会有问题。

看到一篇类似的文章,供参考:
用内存管理器的钩子函数跟踪内存泄漏
http://www.limodev.cn/blog/archives/1289

这个memset仅在某些情况下会有帮助, 当内存刚分配还未被赋值,或已被释放。但函数指针并非总是恰好错误跳入这些区域。另在非IA32机器上,程序一般不会做4bytes的对齐优化,连续4个0xCC几率会变小。也许只在DEBUG时使用memset会划算些。

@leesoft

谢谢提醒,是我随手写的时候的疏忽。

应当是先 memset 再 free 。

这个 memset 对于找出潜在的问题是有必要的。

good tips. 不过觉得memset显得多余,而且free以后再memset可能会使代码移植性变差。

DOGTAG_TAIL 为什么跟根据内存大小对齐呢? 多分配1个可能会影响性能

malloc_proxy 返回函数指针,从没这样玩过,受教了。

贡献一个如何让new 和delete 传入__FILE__, __LINE__.

#define new (m_setOwner (__FILE__,__LINE__,__FUNCTION__),false) ? NULL : new
#define delete (m_setOwner (__FILE__,__LINE__,__FUNCTION__),false) ? m_setOwner("",0,"") : delete

这点比较赞,可以较容易的追踪到泄漏的内存是谁申请的
#define malloc malloc_proxy(__FILE__ , __LINE__)

最近在移植Windows CE的EBOOT,由于公司先前版本的EBOOT接口设计不太合理,导致移植被起来相当的痛苦!

醍醐灌顶,很特别的方法啊!

绝对的沙发

Post a comment

非这个主题相关的留言请到:留言本