c语言高级编程指南1 (翻译)

原文地址http://pfacka.binaryparadise.com/articles/guide-to-advanced-programming-in-C.html

c语言是系统程序,嵌入式系统以及很多其他应用程序的一种选择。然而似乎对计算机不是特别感兴趣,才不会去接触c语言,熟悉c语言的各个方面,以及特别多的细节是一个巨大的挑战。本文试着提供较多的资料来阐述其中的一部分。包括:int类型的转换,内存分配,数组的指针转换,显式的内存函数,Interpositioning(不太理解) ,向量变化。

int 溢出和类型转换

很多c语言程序员都倾向于假设对int类型基本的操作是安全的,使用的时候不会过多的去审查。实际上这些操作很容易出问题。思考下后面的代码:
int main(int argc, char** argv) {
    long i = -1;


    if (i < sizeof(i)) {
         printf("OK\n");
    }
    else {
         printf("error\n");
    }


    return 0;
}

(本人注:结果是error,出乎很多人的意料吧,下面是作者的解释)

导致这样的原因是变量i被转换成了unsigned int类型。所以它的值不再是-1,而是size_t的最大值,这是由于sizeof操作符的类型导致的。
具体的原因在C99/C11标准的常用算术转换章节中找到:
“如果操作符中有unsinged int类型,并且操作符的优先级大于或者等于其他操作符的时候,需要将signed int转换为unsinged int.

size_t在c语言标准中被定义为至少16位的unsinged int 类型。通常size_t的位数是和系统相关的,int类型的大小和size_t至少是相等的,于是上述的规则强行的把变量转换为unsinged int.

(关于sizof的介绍可以查看http://blog.csdn.net/sword_8367/article/details/4868283)

在我们使用int类型大小的是,就会存在一些问题。c语言标准并没有明确的定义short, int ,long ,long long 以及他们unsinged版本的大小。只是把最小的大小强制规定了。以x86_64框架为例子,long类型在linux上为64个字节,相反在64位的windows上仍然是32个字节。为了使代码更好的移植,通常的方法是用长度固定的类型,例如unit16_t 或者int32_t, 他们在C99标准的stdint.h头文件中定义。下面三种int类型在那里被定义:
1明确大小的:uint8_t uint16_t int32_t 等等
2定义类型的最小长度的:uint_least9_t, uint_least16_t, int_least32_t等
3最高效,定义最小长度的:uint_fast8_t, uint_fast16_t, int_fast32_t等

但是不幸的是,使用stdint.h并不能避免所有的问题。”integral promotion rule"(int类型转换的规则)里面这样说:
If an int can represent all values of the original type, the value is converted to an int; otherwise, it is converted to an unsigned int. These are called the integer promotions. All other types are unchanged by the integer promotions.
如果一个int可以表现原始类型所有的值,那么这个值被转换为int,否则转换为unsinged int .这叫做int类型转换,所有其他的类型在int类型转换中不会被改变。
下面的代码在32为上结果为65536, 在16为机器上为0;
uint32_t sum()
{
    uint16_t a = 65535;
    uint16_t b = 1;
    return a+b;
}
int类型转换保持变量的符号,不过一个简单的char类型转换是被转换为有符号数还是无符号数呢?


通常char类型转换要依靠硬件结构和操作系统,通常在特定平台的程序二进制接口中被确定的。如果你发现char被提升为siged char ,下面的代码会打印-128,127(例如x86框架),否则为128,129.gcc加上编译选项-funsigned-char强制的将x86平台上提升为无符号数。
char c = 128;
char d = 129;
printf("%d,%d\n",c,d);


内存分配和内存管理

malloc, calloc,realloc,free
malloc分配一个以bytes为单位的,指定大小的,未初始化的内存空间。如果大小为0,返回结果取决于操作系统,在c语言或者POSIX中没有明确说明这个行为.

如果空间大小必须是0,这个结果由编译器决定:返回一个空指针或者是唯一的指针。

malloc(0)通常会返回一个唯一的合法的指针。任何一种返回方式,必须保证在调用free函数的时候,不能报错。其中空指针,free函数不会做任何操作。

所以如果以一个表达式的结果作为malloc的参数的时候,需要测试int越界。

size_t computed_size;

if (elem_size && num > SIZE_MAX / elem_size) {
    errno = ENOMEM;
    err(1, "overflow");
}

computed_size = elem_size*num;

void * calloc(size_t nelem, size_t elsize);
一般情况下,分配一系列相同大小的空间的时候,应该使用calloc,这样不用表达式去计算大小()。另外它会初始化内存空间为0.释放分配的空间,使用free.

void* realloc(void* ptr, unsigned newsize);
realloc将会改变之前分配内存的大小。函数返回的指针指向新的内存位置,里面的内容可能会和原来的内容有相同的部分。如果新分配的大小比原来的大,增加的空间就可能没有被初始化。如果参数中旧指针为空,大小不等于0,那么作用等同于malloc。如果参数中大小为0,旧指针非空,那么产生的结果取决于操作系统。

大部分操作系统去释放旧指针的内存,返回malloc(0)或者返回NULL.例如,windows会释放内存,并且返回NULL,OpenBSD也会释放内存,并且会返回指向大小为0的指针。

如果realloc失败了会返回NULL,并且会留下曾经分配的内存。所以不仅要检测参数是否溢出,当realloc分配失败的时候,还要正确处理旧的内存空间。
#include <stdio.h>
#include <stdint.h>
#include <malloc.h>
#include <errno.h>


#define VECTOR_OK            0
#define VECTOR_NULL_ERROR    1
#define VECTOR_SIZE_ERROR    2
#define VECTOR_ALLOC_ERROR   3


struct vector {
    int *data;
    size_t size;
};


int create_vector(struct vector *vc, size_t num) {


    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }


    vc->data = 0;
    vc->size = 0;


    /* check for integer and SIZE_MAX overflow */
    if (num == 0 || SIZE_MAX / num < sizeof(int)) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }


    vc->data = calloc(num, sizeof(int));


    /* calloc faild */
    if (vc->data == NULL) {
        return VECTOR_ALLOC_ERROR;
    }


    vc->size = num * sizeof(int);
    return VECTOR_OK;
}


int grow_vector(struct vector *vc) {


    void *newptr = 0;
    size_t newsize;


    if (vc == NULL) {
        return VECTOR_NULL_ERROR;
    }




    /* check for integer and SIZE_MAX overflow */
    if (vc->size == 0 || SIZE_MAX / 2 < vc->size) {
        errno = ENOMEM;
        return VECTOR_SIZE_ERROR;
    }


    newsize = vc->size * 2;


    newptr = realloc(vc->data, newsize);


    /* realloc faild; vector stays intact size was not changed */
    if (newptr == NULL) {
        return VECTOR_ALLOC_ERROR;
    }


    /* upon success; update new address and size */
    vc->data = newptr;
    vc->size = newsize;
    return VECTOR_OK;
}


避免致命错误

在动态内存申请上,避免错误的通用方法,小心翼翼的写代码,尽可能的做好异常保护。但是有很多常见的问题上,有一些方法可以避免他们。

1 )重复调用free导致崩溃

这个问题由free函数的参数为下列情况引起:空指针,或者指针没有用malloc等函数分配的(野指针),或者已经被free/recalloc释放了(野指针)。为了避免这个问题,可以采取下列方法:
1 如果不能立即给指针赋有效的值,那么在声明的时候,初始化指针为NULL,
2 gcc 和clang 都会对未初始化的变量进行警告。
3 不要用同一个指针去指向静态内存和动态内存。
4 在使用free之后,将指针设置为NULL, 这样如果你不小心又调用free,也不会出错。
5 为了避免两次释放,在测试和调试的时候,使用assert 或许类似的函数。


char *ptr = NULL;
/* ... */

void nullfree(void **pptr) {
    void *ptr = *pptr;
    assert(ptr != NULL)
    free(ptr);
    *pptr = NULL;
}


2 )通过空指针或者未初始化的指针访问内存。

使用上述规则,你的代码只需要处理空指针或有效的指针。只需要在函数或者代码段开始的时候,检测动态内存的指针是否为空。

3 )访问越界的内存

 访问越界的内存并不一定都会导致程序崩溃。程序可能继续操作使用错误的数据,产生危险的后果,或者程序可能利用这些操作,进入其他的分支,或者进入执行代码。逐步的人工检测数组边界和动态内存边界,是主要避免这些危险的主要方法。内存边界的信息可以人工跟踪。数组的大小可以用sizeof函数,但是有时候array也会被转换为指针,(例如在函数中,sizeof 会返回指针的大小,而不是数组。)c11标准中的接口Annex k 是边界检测的接口,定义了一系列新库函数,提供了一些简单安全的方法去代替标准库(例如string 和I/O操作) 还有一些开源的方法例如 slibc,但是他的接口没有广泛采用。基于BSD系统(包括Mac OS X)提供了strlcpy,strlcat函数,可以更好的进行字符串操作。对于其他系统可以使用libbsd libraray.很多操作系统提供了接口,控制获取内存区域,保护内存读写例如posix mporst,这些机制主要用于整个内存页的控制。

避免内存泄露

内存泄露是由于有些动态内存不在使用了,但是程序没有释放而导致的。所以真正理解分配的内存空间作用域,最重要的是 free函数什么时候调用。但是随着程序复杂性的增强,这个就会变得越来越困难,所以在开始的设计中需要加入内存管理的功能。下面是一些方法去解决这些问题:

1)启动的时候申请

 将所有需要的堆内存分配防止程序启动的时候可以让内存管理变得简单。在程序结束的时候由操作系统释放(这里的意思是程序结束调用free么?还是程序关闭后系统自己free)。在很多情况下,这个方法是令人满意的,特别是程序批处理输入,然后完成。 

 2)可变长度的数组

 如果你需要一个可变大小的临时存储空间,生命周期只在一个函数中,那么可以考虑使用VLA(可变长度数组)。但是使用它是受限制的,每个函数使用它的空间不能超过百个字节。因为可变长度数组在C99中定义的(C11优化)有自动存储区域,它和其他的自动变量一样有一定的范围。尽管标准没有明确指出,通常会将VLA放在栈空间中。 VLA的最大可以分配的内存空间大小为 SIZE_MAX字节。先要知道目标平台的栈空间大小,我们要谨慎使用,确保不出现栈溢出,或者读取内存段下面的错误数据。 

 3)人工引用计数

 这个技术的背后思想是记录每次分配和失去引用的数目。在每次分配引用的时候计数增加,每次失去引用的时候分配减少。当引用的数目为0的时候,表示内存空间不再使用了,然后进行释放。但是C语言不支持自动析构(实际上,GCC和Clang都支持cleanup扩展)但并不意味着要重写分配操作符,通过人工的调用retain/release来完成计数。 函数。换一个思路,程序中有多个地方会占用或者解除和一块内存空间的关系。即便是使用这个方法,要遵守很多准则来确保不会忘记调用release(导致内存泄露)或过多的调用(提前释放)。但是如果一个内存空间的生命期,是由由外部事件确定,并且程序的结构决定,它会用各种方法来处理内存空间,那么使用这种麻烦的方法也是很值得的。下面代码块是一个简单的引用计数去进行内存管理。 
 #include <stdlib.h>
#include <stdint.h>


#define MAX_REF_OBJ 100
#define RC_ERROR -1


struct mem_obj_t{
    void *ptr;
    uint16_t count;
};


static struct mem_obj_t references[MAX_REF_OBJ];
static uint16_t reference_count = 0;


/* create memory object and return handle */
uint16_t create(size_t size){


    if (reference_count >= MAX_REF_OBJ)
        return RC_ERROR;


    if (size){
        void *ptr = calloc(1, size);


        if (ptr != NULL){
            references[reference_count].ptr = ptr;
            references[reference_count].count = 0;
            return reference_count++;
        }
    }


    return RC_ERROR;
}


/* get memory object and increment reference counter */
void* retain(uint16_t handle){


    if(handle < reference_count && handle >= 0){
        references[handle].count++;
        return references[handle].ptr;
    } else {
        return NULL;
    }
}


/* decrement reference counter */
void release(uint16_t handle){
    printf("release\n");


    if(handle < reference_count && handle >= 0){
        struct mem_obj_t *object = &references[handle];


        if (object->count <= 1){
            printf("released\n");
            free(object->ptr);
            reference_count--;
        } else {
            printf("decremented\n");
            object->count--;
        }
    }
}



如果你不考虑各个编译器的兼容性,你可以使用cleanup attribute在c语言中模仿自动析构。
(参考http://blog.csdn.net/haozhao_blog/article/details/14093155
http://gcc.gnu.org/onlinedocs/gcc/Variable-Attributes.html)
void cleanup_release(void** pmem) {
    int i;
    for(i = 0; i < reference_count; i++) {
        if(references[i].ptr == *pmem)
           release(i);
    }
}


void usage() {
    int16_t ref = create(64);


    void *mem = retain(ref);
    __attribute__((cleanup(cleanup_release), mem));


    /* ... */
}




cleanup_release的另一个缺点是根据对象的地址去释放,而不是根据引用的个数。因此cleanup_release 在引用数组的查找上耗费巨大。一种补救的方法是是修改retain的接口,返回指向结构体mem_obj_t的指针。另外一种方法是用下面的宏,它创建变量去保存引用的数目,并且和cleanup attribute相关联。




/
* helper macros */
#define __COMB(X,Y) X##Y
#define COMB(X,Y) __COMB(X,Y)
#define __CLEANUP_RELEASE __attribute__((cleanup(cleanup_release)))


#define retain_auto(REF) retain(REF); int16_t __CLEANUP_RELEASE COMB(__ref,__LINE__) = REF


void cleanup_release(int16_t* phd) {
    release(*phd);
}


void usage() {
    int16_t ref = create(64);


    void *mem = retain_auto(ref);
    /* ... */
}



4 内存池

如果一个程序运行的时候会经过很多步骤,在每一个步骤开始的时候可能有内存池。任何时候程序需要分配内存的时候,其中的一个内存池就会被使用。根据分配内存的生命周期去选择内存池,并且内存池属于程序的某个阶段。在每一个阶段结束,内存池被立刻释放。这个方法在长期运行的程序十分受欢迎,例如守护进程,它可以在整体上降低内存的碎片化。下面是内存池管理的一个简单例子。

#include <stdlib.h>
#include <stdint.h>

struct pool_t{
    void *ptr;
    size_t size;
    size_t used;
};

/* create memory pool*/
struct pool_t* create_pool(size_t size) {
    struct pool_t* pool = calloc(1, sizeof(struct pool_t));


    if(pool == NULL)
        return NULL;


    if (size) {
        void *mem = calloc(1, size);


        if (mem != NULL) {
            pool->ptr = mem;
            pool->size = size;
            pool->used = 0;
            return pool;
        }
    }
    return NULL;
}


/* allocate memory from memory pool */
void* pool_alloc(struct pool_t* pool, size_t size) {


    if(pool == NULL)
        return NULL;


    size_t avail_size = pool->size - pool->used;


    if (size && size <= avail_size){
        void *mem = pool->ptr + pool->used;
        pool->used += size;
        return mem;
    }


    return NULL;
}


/* release memory for whole pool */
void delete_pool(struct pool_t* pool) {
    if (pool != NULL) {
        free(pool->ptr);
        free(pool);
    }
}




实现一个内存池,是一个比较困难的事情。或许一些存在的库可以满足你的需求。
GNU libc obstack
Samba talloc
Raven* Memory Pool System

5) 数据结构

很多内存管理的问题可以归结为使用正确的数据结构去存储数据。选择哪种数据结构主要是由访问数据,保存数据的算法需求来决定的,类似于使用链式表,哈希表,树等能带来额外的增益,例如遍历数据结构和快速释放数据。尽管在标准库中没有支持数据结构,但是下面有一些有用的库。


For traditional Unix implementation of linked lists and trees see BSD‘s queue.h and tree.h macros both are part of libbsd.
GNU libavl
Glib Data Types
For additional list see http://adtinfo.org/index.html


6 )标记和清理垃圾收集器

另一种方法是使用自动垃圾回收机制,从而减少人工的释放内存。指针引用是内存不使用的时候就想释放,而垃圾机制相反,是由特定事件触发的,例如内存分配失败,或者分配达到某个水平线。标记和扫除算法是实现垃圾机制的一种方法。一开始,它会遍历堆空间中所有的以前分配的内存引用,标记哪些还可以达到的引用,清理哪些没有被标记的引用。


或许,在c语言中最出名的垃圾收集机制为Boehm-Demers-Weiser conservative garbage collector。垃圾机制的缺点是性能开销和导致程序有不确定的停顿。另一个问题是有malloc引起的,它不能被垃圾回收机制管理,需要人工管理。
另外无法预知的停顿在实时系统中是不能接受的,但是很多环境上还是优点大于缺点。在性能的一方面,他们甚至宣称为高性能的。Mono project GNU Objective C runtime和Irssi IRC client都使用了Boehm GC。

c语言高级编程指南1 (翻译)

上一篇:设计模式(C++版)之(Singleton)单例模式


下一篇:设计模式(C++版)之(prototype) 原型模式