C进阶指南(1):整型溢出和类型提升、内存申请和管理

C语言可用于系统编程、嵌入式系统中,同时也是其他应用程序可能的实现工具之一。 当你对计算机编程怀有强烈兴趣的时候,却对C语言不感冒,这种可能性不大。想全方位地理解C语言是一件极具挑战性的事。

Peter Fačka 在2014年1月份写下了这篇长文,内容包括:类型提升、内存分配,数组转指针、显式内联、打桩(interpositioning)和矢量变换。原文挺长,伯乐在线分三篇发出,这是第一篇。

 

一、整型溢出和类型提升

多数C程序员以为,整型间的基本操作都是安全的。事实上,整型间基本操作也容易出现问题,例如下面的代码:

上述代码中,变量 被转换为无符号整型。这样一来,它的值不再是-1,而是 size_t 的最大值。变量i的类型之所以被转换,是因为 sizeof 操作符的返回类型是无符号的。具体参见C99/C11标准之常用算术转换一章:

“If the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type.”

若无符号整型类型的操作数的转换优先级不低于另一操作数,则有符号数转为无符号数的类型。

C标准中,size_t 被定义为不低于16位的无符号整型。通常 size_t 完全对应于 long。这样一来,int 和 size_t 的大小至少相等,可基于上述准则,强转为无符号整型。

(译者注:本人印象深刻的相关问题是“if(-1U > 0L)”在32、64位机器上的判断结果分别是什么,为什么;除long long外,long 类型在涉及兼容性的产品代码中应被禁用)

这个故事给了我们一个关于整型大小可移植性的观念。C标准并未定义shortintlonglong long 的确切大小及其无符号形式。标准仅限定了它们的最小长度。以x86_64架构为例,long 在Linux环境中是64比特,但在64位Windows系统中是32比特。为了使代码更具移植性,常见的方法是使用C99的 stdint.h 文件中定义的、指定长度的特殊类型,包括 uint16_tint32_t 等。此文件定义了三种整型类型:

  • 有确切长度的:uint8_t uint16_t,int32_t等
  • 有长度最小值的最短类型:uint_least8_t,uint_least16_t,int_least32_t等
  • 执行效率最高的有长度最小值的类型:uint_fast8_t,uint_fast16_t,int_fast32_t等

但不幸的是,仅依靠 stdint.h 并不能根除类型转换的困扰。C标准中“整型提升规则”中写道:

若int的表达范围足以覆盖所有的基础类型,此值将被转换为int;否则将转为unsigned int。这就叫做整型提升。整型提升过程中,所有其他的类型保持不变。

下述代码在32位平台中将返回65536,在16位平台上返回0:

无论C语言实现中,是否把未修饰的char看做有符号的,整型提升都连同符号一起把值保留下来。

如何实现char类型通常取决于硬件体系或操作系统,常由其平台的ABI(应用程序二进制接口)指定。如果你愿意自己尝试的话,char会被转为signed char,下述代码将打印出-128和-127,而不是128和129。x86架构中可用GCC的-funsigned-char参数切换到强制无符号提升。

二、内存申请和管理

malloc, calloc, realloc, free

使用malloc分配指定字节大小的、未初始化的内存对象。若入参值为0,其行为取决于操作系统实现,或者说,这是C和POSIX标准均未定义的行为。

若请求的空间大小为0,则结果视具体实现而定:返回值可以是空指针或特殊指针。

malloc(0) 通常返回有效的特殊指针。或者返回的值可成为 free 函数的参数,且函数不会错误退出。例如 free 函数对NULL指针不做任何操作。

因此,若空间大小参数是某个表达式的结果的话,要确保测试过整型溢出的情况。

一般说来,要分配一个元素大小相同的序列,可考虑使用 calloc 而非用表达式计算大小。同时 calloc 将把分配的内存初始化为0。像往常一样使用 free 释放分配的内存。

realloc 将改变已分配内存对象的大小。此函数返回一个指针,指针可能指向新的内存起始位置,内存大小取决于入参中请求的空间大小,内容不变。若新的空间更大,额外的空间未被初始化。若 realloc 入参中,指向旧对象的指针为NULL,并且大小非0,此行为等价于 malloc。若新的大小为0,且提供的指针非空,此时 realloc 的行为依赖于操作系统。

多数实现将尝试释放对象内存,返回NULL或与malloc(0)相同的返回值。例如在Windows中,此操作会释放内存并返回NULL。OpenBSD也会释放内存,但返回的指针指向的空间大小为0。

realloc 失败时会返回NULL,也因此断开与旧的内存对象的关联。所以不但要检查空间大小参数是否存在整型溢出,还要正确处理 realloc 失败时的对象大小。

2.1 避免致命错误

一般避免动态内存分配问题的方法无非是尽可能把代码写得谨慎、有防御性。本文列举了一些常见问题和少量避免这些问题的方法。

1) 重复释放内存

调用 free 可能导致此问题,此时入参指针可能为NULL(依照《C++ Primer Plus》,free(0)不会出现问题。译者注)、未使用 malloc 类函数分配的指针,或已经调用过 free realloc(realloc参数中大小填0,可释放内存。译者注)的指针。考虑下列几点可让代码更健壮:

  • 指针初始化为NULL,以防不能立即传给它有效值的情况
  • GCC和Clang都有-Wuninitialized参数来警告未初始化的变量
  • 静态和动态分配的内存不要用同一个变量
  • 调用 free 后要把指针设回为NULL,这样一来即便无意中重复释放也不会导致错误
  • 测试或调试时使用assert之类的断言(如C11中静态断言,译者注)

2) 访问未初始化的内存或空指针

代码中的检查规则应只用于NULL或有效的指针。对于去除指针和分配的动态内存间联系的函数或代码块,可在开头检查空指针。

3) 越界访问内存

(孔乙己式译者注:你能说出strcpy / strncpy / strlcpy的区别么,能的话这节就不必看)

访问内存对象边界之外的地方并不一定导致程序崩溃。程序可能使用损坏了的数据继续运行,其行为可能很危险,也可能是故意而为之,利用此越界操作来改变程序的行为,以此获取其他受限的数据,甚至注入可执行代码。 老套地人工检查数组和动态分配内存的边界是避免此类问题的主要方法。内存对象边界的相关信息必须人工跟踪。数组的大小可由sizeof操作符指出,但数组被转换为指针后,函数调用sizeof仅返回指针大小(视机器位数而定,译者注),而非原来的数组大小。

C11标准中边界检查接口Annex K定义了一些新的库函数集合,这些函数可用于替换标准库(如字符串和I/O操作)常见部分,它们更安全、更易于使用。例如[the slibc library][slibc]都是上述函数的开源实现,但接口不被广泛采用。基于BSD(或基于Mac OS X)的系统提供了strlcpystrlcat 函数来完成更好的字符串操作。其他系统可通过libbsd库调用它们。

许多操作系统提供了通过内存区域间接控制受保护内存的接口,以防止意外读/写操作,入Posxi mprotect。类似的间接访问的保护机制常用于所有的内存页。

2.2 避免内存泄露

内存泄露,常由于程序中未释放不再使用的动态分配的内存导致。因此,真正理解所需要的分配的内存对象的范围大小是很有必要的。更重要的是,要明白何时调用 free。但当程序复杂度增加时,要确定 free 的调用时机将变得更加困难。早期设计决策时,规划内存很重要。

以下是处理内存泄露的技能表:

1) 启动时分配

想让内存管理保持简单,一个方法是在启动时在堆中分配所有所需的内存。程序结束时,释放内存的重任就交给了操作系统。这种方法在许多场景中的效果令人满意,特别是当程序在一个批量操作中完成对输入的处理的情况。

2) 变长数组

如果你需要有着变长大小的临时存储,并且其生命周期在变量内部时,可考虑VLA(Variable Length Array,变长数组)。但这有个限制:每个函数的空间不能超过数百字节。因为C99指出边长数组能自动存储,它们像其他自动变量一样受限于同一作用域。即便标准未明确规定,VLA的实现都是把内存数据放到栈中。VLA的最大长度为SIZE_MAX字节。考虑到目标平台的栈大小,我们必须更加谨慎小心,以保证程序不会面临栈溢出、下个内存段的数据损坏的尴尬局面。

3) 自己编写引用计数

这个技术的想法是对某个内存对象的每次引用、去引用计数。赋值时,计数器会增加;去引用时,计数器减少。当引用计数变为0时,这意味着此内存对象不再被使用,可以释放。因为C不提供自动析构(事实上,GCC和Clang都支持cleanup语言扩展), 也不是重写赋值运算符,引用计数由调用retain/release的函数手动完成。更好的方式,是把它作为程序的可变部分,能通过这部分获取和释放一个内存对象的拥有权。但是,使用这种方法需要很多(编程)规范来防止忘记调用release(停止内存泄露)或不必要地调用释放函数(这将导致内存释放地过早)。若内存对象的生命期需要外部事件指出,或应用程序的数据结构隐含了某个内存对象的持有权的处理,无论何种情况,都容易导致问题。下述代码块含有简化了的内存管理引用计数。

如果你关心编译器的兼容性,可用 cleanup 属性在C中模拟自动析构。

上述方案的另一缺陷是提供对象地址让 cleanup_release 释放,而非引用计数值。这样一来,cleanup_release 必须在 references 数组中做开销大的查找操作。一种解决办法是,改变填充的接口为返回一个指向 struct mem_obj_t 的指针。另一种办法是使用下面的宏集合,这些宏能够创建保存引用计数值的变量并追加 clean 属性。

(译者注:##符号源自C99,用于连接两个变量的名称,一般用在宏里。如int a##b就会定义一个叫做ab的变量;__LINE__指代码行号,类似的还有__FUNCTION__或__func__和__FILE__,可用于打印调试信息;__attribute__符号来自gcc,主要用于指导编译器优化,也提供了一些如构造、析构、字节对齐等功能)

4) 内存池

若一个程序经过数阶段才能彻底执行,每阶段的开头都分配有内存池,需要分配内存时,就使用内存池的一部分。内存池的选择,要考虑分配的内存对象的生命周期,以及对象在程序中所属的阶段。每个阶段一旦结束,整个内存池就要立即释放。这种方法在记录型运行程序中特别有用,例如守护进程,它可能随着时间减少内存分段。下述代码是个内存池内存管理的仿真:

内存池的实现涉及非常艰难的任务。可能一些现有的库能很好地满足你的需求:

5) 数据结构

把数据存到正确的数据结构里,能解决很多内存管理问题。而数据结构的选择,大多取决于算法,这些算法访问数据、把数据保存到例如链表、哈希表或树中。按算法选择数据结构有额外的好处,例如能够遍历数据结构一次就能释放数据。因为标准库并未提供对数据结构的支持,这里列出几个支持数据结构的库:

6) 标记并清除垃圾收集器

处理内存问题的另一种方式,就是利用自动垃圾收集器的优势,自此从自己清除内存中解放出来。于引用计数中内存不再需要时清除机制相反,垃圾收集器在发生指定事件是被调用,如内存分配错误,或分配后超过了确切的阀值。标记清除算法是实现垃圾收集器的一种方式。此算法先为每个引用到分配内存的对象遍历堆,标记这些仍然可用的内存对象,然后清除未标记的内存对象。

可能C中最有名的类似垃圾收集器的实现是Boehm-Demers-Weiser conservative garbage collector 。使用垃圾收集器的瑕疵可能是性能问题,或向程序引入非确定性的延缓。另一问题是,这可能导致库函数使用 malloc,这些库函数申请的内存不受垃圾处理器监管,必须手动释放。

虽然实时环境无法接受不可预料的卡顿,仍有许多环境从中获取的好处远超过不足。从性能的角度看,甚至有性能提升。一些项目使用含有Mono项目GNU Objective C运行环境或Irssi IRC客户端的Boehm垃圾收集器。

其余两篇:

9 收藏 4 评论

关于作者:醉酒屠夫

(新浪微博:@起名字够烦) 个人主页 · 我的文章 · 2

相关文章

可能感兴趣的话题



直接登录
最新评论
  • 薛非   2014/07/10

    译文:
    若无符号整型的位数不低于另一操作数,则有符号数转为无符号数的类型。
    原文:
    "If the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type."

    不会翻译也不应该乱翻译吧
    贪污掉的原文也太多了

跳到底部
返回顶部