GNOME-CN Planet

十二月 18, 2008

Absurd

系统程序员成长计划-并发(三)(上)

嵌套锁与装饰模式

在生产者-消费者的练习中,当由双向链表的实现者负责加锁时,一般都会遇到莫名其妙的死锁问题。有的读者可能已经查出来了原因是嵌套的加锁。比如在dlist_insert中调用了dlist_length,进入dlist_insert时已经加了一次锁,再调用dlist_length时又加了一次锁,这时就出现了死锁问题。

初学者遇到这个问题的时候,通常的做法是在调用dlist_length之前先解锁,调用完dlist_length后再重新加锁。这样是存在问题的:一个原子操作变成了几个原子操作,数据完整性得不到保证,在你重新加锁之前,其它线程可能利用这个空隙做了些别的事情。

有效解决这个问题的办法有两个,其一是实现一个内部版本的dlist_length,它在里面不加锁。其二是使用嵌套锁,允许同一个线程多次加锁。pthread有嵌套锁的实现,不过我们在这里不用它,原因是我们要提供一个更通用的解决方案。现在我们不再满足于实现一个双向链表,而是要实现一个跨平台的基础函数库。

在这里我们请读者实现一个嵌套锁,要求如下:

o 嵌套锁仍然兼容Locker接口。
o 嵌套锁的实现不依赖于特定平台。

by admin at 十二月 18, 2008 01:42 下午

十二月 17, 2008

Absurd

系统程序员成长计划-并发(二)(下)

面对这个需求,一些初学者可能有点蒙了。以前在学校的时候,对于课本后面的练习,我总是信心百倍,原因很简单,我确信这些练习不管它的出现方式有多么不同,但总是与前面学过的知识有关。记得《如何求解问题—现代启发式方法》中说过,正是这种练习的方式妨碍了我们解决问题的能力,在现实中解决问题时通常没有这么幸运。在《系统程序员成长计划》我把练习放前面,目标就是刺激读者去思考,在学习知识的同时学习解决问题的方法。

这里我们应该怎么分析呢?要在双向链表里加锁,第一是要区分单线程和多线程,要链接同一个库,而且不能用宏来控制。第二是不能依赖于特定平台,而锁本身恰恰又是依赖于平台的。怎么办?很明显这两个需求都要求锁的实现可以变化的:单线程版本它什么都不做,多线程版本中,不同的平台有不同的实现。

我们要做的就是隔离变化。变化怎么隔离?前面我们已经练习过几次用回调函数来隔离变化了,所有的读者都会想到这个方法,因为锁无非是具有两个功能:加锁和解锁,我们把它抽象成两个回调函数就行了。

这种方法是可行的。这里的情况与前面相比有点特殊:前面的回调函数都是些独立功能的函数,每个回调函数都有自己的上下文,而这里的多个回调函数具有相关的功能,并且共享同一个上下文(锁)。其次是这里的上下文(锁)是一个对象,有自己的生命周期,完成自己的使命后就应该被销毁。

这里我们引入接口(interface)这个术语,接口其实就是一个抽象的概念,它只定义调用者和实现者之间的契约,而不规定实现的方法。比如这里的锁就是一个抽象的概念,它有加锁/解锁两个功能,这是调用者和实现者之间的契约。但光有这个概念不能做任何事情,只有具体的锁才能被使用。至于具体的锁,不同的平台有不同的实现,但调用者不用关心。正因为调用者不用关心接口的实现方法,接口成了隔离变化最有力的武器。

在这里,锁是一个接口,双向链表是锁的调用者,有基于不同方式实现的锁。通过接口,双向链表把锁的变化隔离开来:区分单线程和多线程,隔离平台相关性。在C语言中,接口的朴素定义是:一组相关的回调函数及其共享的上下文。我们看看锁这个接口怎么定义:

struct _Locker;
typedef struct _Locker Locker;

typedef Ret  (*LockerLockFunc)(Locker* thiz);
typedef Ret  (*LockerUnlockFunc)(Locker* thiz);
typedef void (*LockerDestroyFunc)(Locker* thiz);

struct _Locker
{
    LockerLockFunc    lock;
    LockerUnlockFunc  unlock;
    LockerDestroyFunc destroy;

    char priv[0];
};

这里要注意三个问题:

o 接口一定要足够抽象,不能依赖任何具体实现的数据类型。接口一旦与某个具体实现关联了,另外一种实现就会遇到麻烦。比如这里你使用了pthread_mutex_t,那你要实现一个win32下的锁怎么办呢。

o 接口不能有create函数,但一定要有destroy函数。我们说过对象有自己的生命周期,创建它,使用它,然后销毁它。但接口只是一个概念,不可能通过这个概念凭空创建一个对象出来,对象只能通过具体实现来创建,所以接口不应该出现create自己的函数。一旦对象被创建出来,使用者应该在不再需要它时销毁它,在销毁对象时,如果还要知道它的实现方式才能销毁它,那就造成了调用者和实现者之间不必要的耦合,因此接口都要提供一个destroy函数,调用者可以直接销毁它。

o 这里的priv用来存放上下文信息,也就是具体实现需要用到的数据结构。像前面的回调函数一样,我们可以用一个void* ctx的成员来保存上下文信息。我们使用的char priv[0];技巧,有点额外的好处:只需要一次内存分配,而且可以分配刚好够用的长度(0到任意长度)。

前面我们使用回调函数,调用时要判断回调函数是否为空,每个地方都要重复这个动作,所以我们把这些判断集中起来好了:

static inline Ret locker_lock(Locker* thiz)
{
    return_val_if_fail(thiz != NULL && thiz->lock != NULL, RET_INVALID_PARAMS);

    return thiz->lock(thiz);
}

static inline Ret locker_unlock(Locker* thiz)
{
    return_val_if_fail(thiz != NULL && thiz->unlock != NULL, RET_INVALID_PARAMS);

    return thiz->unlock(thiz);
}

static inline void locker_destroy(Locker* thiz)
{
    return_if_fail(thiz != NULL && thiz->destroy != NULL);

    thiz->destroy(thiz);

    return;
}

下面我们来看看基于pthread_mutex的实现:

o 在locker_pthread.h中,提供一个创建函数。

Locker* locker_pthread_create(void);

o 在locker_pthread.c中,实现这些回调函数:

定义私有数据结构:

typedef struct _PrivInfo
{
    pthread_mutex_t mutex;
}PrivInfo;

创建对象:

Locker* locker_pthread_create(void)
{
    Locker* thiz = (Locker*)malloc(sizeof(Locker) + sizeof(PrivInfo));

    if(thiz != NULL)
    {
        PrivInfo* priv = (PrivInfo*)thiz->priv;

        thiz->lock    = locker_pthread_lock;
        thiz->unlock  = locker_pthread_unlock;
        thiz->destroy = locker_pthread_destroy;

        pthread_mutex_init(&(priv->mutex), NULL);
    }

    return thiz;
}

实现几个回调函数:

static Ret  locker_pthread_lock(Locker* thiz)
{
    PrivInfo* priv = (PrivInfo*)thiz->priv;

    int ret = pthread_mutex_lock(&priv->mutex);

    return ret == 0 ? RET_OK : RET_FAIL;
}
…

我简单说一下里面几个问题:

o malloc(sizeof(Locker) + sizeof(PrivInfo)); 前面的char priv[0]并不占空间,这是C语言新标准定义的,用于实现变长的buffer,它在这里的长度由sizeof(PrivInfo)决定。

o PrivInfo* priv = (PrivInfo*)thiz->priv; 这里的thiz->priv只是一个定位符,实际上等于(size_t)thiz+sizeof(Locker),帮我们定位到私有数据的内存地址上。

使用方法:

单线程版本:

DList* dlist = dlist_create(NULL, NULL, locker_pthread_create());

多线程版本:

DList* dlist = dlist_create(NULL, NULL, NULL);

接口在软件设计中占有非常重要的地位,它是隔离变化和降低复杂度最有力的武器,差不多所有的设计模式都与接口有关。后面我们会反复的练习,这里请读者仔细体会一下。

本节示例代码请到这里下载。

by admin at 十二月 17, 2008 01:58 下午

十二月 16, 2008

Absurd

系统程序员成长计划-并发(二)(上)

在生产者-消费者的练习中,大部分人选择了由调用者来加锁:作为生产者,往双向链表里插入数据时,先加锁,插入数据,然后解锁。作为消费者,从双向链表里取数据时,先加锁,删除数据,然后解锁。这是合理的,不过有点麻烦:每个调用者都要做这些动作,如果其中一个调用者忘记了解锁的步骤,就会造成死锁。而且调用者必须要清楚自己是在多线程下工作,这些代码放到单线程的环境中就不能使用了。

在很多情况下由实现者来加锁是比较好的选择,那样对调用者更为友好,可以避免出现一些不必要的错误。比如像目前Linux下流行的DBUS,它是一套进程间通信框架,它支持单线程和多线程版本,但调用者不需要明确加锁/解锁,也不需要连接不同的库或者用宏来控制,单线程版本和多线程版本的不同只是在一个初始化函数上。

这里我们请读者对前面实现的双向链表做点改进:

o 支持多线程和单线程版本。对于多线程版本,由实现者(在链表)加锁/解锁,对于单线程版本,其性能不受影响(或很小)。
o区分单线程版本和多线程版本时,不需要链接不同的库,或者要宏来控制,完全可以在运行时切换。
o 保持双向链表的通用性,不依赖于特定的平台。

by admin at 十二月 16, 2008 01:28 下午

系统程序员成长计划-写得又快又好的秘诀(二)

1.好与快的关系

几年前和一个朋友聊天时,他抱怨他的上司说,要我写得好又要写快,那怎么可能呢?我当时一愣,反问到,写不好怎么可能写得快?他也一愣。

传统观点认为在功能、成本(人*时间)和质量这个铁三角中,提高质量就意味投入更多成本或者减少一些功能。在功能不变的情况下,不可能在提高质量的同时降低开成成本(对个人来讲就是缩短开发时间)。我的朋友持的正是这种传统观点。

而根据我的经验来看,结论恰恰相反。每次我想以牺牲质量来加快速度的时候,结果反而花了更多时间,甚至可能到最后搞不定而放弃了。有了多次这样的经验之后,我决定把每一步都做好,从开发的前期来看,我花的时间比别人多一点,但从整个任务来看,我反而能以别人几倍的速度完成任务。时间长了,我形成了这样的观念:只有写得好才可能写得快。

两种观点截然相反,所以我们都愣了。虽然我相信我的经验没有错,但传统的铁三角定律是大师们总结出来的,也不可能出错。那是怎么回事呢?我开始到处查资料,但是没有一个人支持我的观点。我又不想这样放弃,后来我用了一个简单的办法进行推理,结果证明两个观点都有各自的适用范围。

这个推理过程很简单,把两种观点推向极端:

先看看以牺牲质量来追求进度的例子。我以前参加过两个大项目,其一个项目的BUG总数达到17000多个,耗时近三年后项目被取消。另一个项目的BUG总数也超过10000个,三年之后带着很多BUG发布了,结果可想而知,产品很快从市场上消失了。这两个项目在开始阶段都制定了极其可笑的项目计划,为了赶在这个根本不可能的最后期限前,都采用了牺牲质量的方式来提高开发速度,前期进展都很“顺利”,基本功能点很快就完成了,但是项目马上陷入了无止境的debug之中,开发人员的士气一下跌到谷底,管理层开始暴跳如雷。

如果这两个项目有超过170000个BUG,即使项目不取消,再做时间十年也做不完。由此可见:质量低到一定限度时,降低质量会延长项目时间,如果质量降到最低,那项目永远也不可能完成。这和我的观点一致:写不好就写不快。

再看看追求完美质量的例子。以前参与一个手机模拟器的开发,我们很快达到88%的真实度,半年之后达到95%的真实度,客户要98%的真实度。但是怎么努力也达不到这个标准,花了极大的代价才达到96%多一点,到后来项目被取消了。

如果要达到99%的真实度,即使项目不取消,再做十年也做不完。由此可见:质量高到一定程度,提高质量会延长项目时间,如果质量要高到最高,那任务远也不可能完成。这和传统观点一致,提高质量就要延长开发时间。

从两个极端往中间走,我们可以找到一个中间质量点。低于这个质量点,想以牺牲质量来赶进度,那只会适得其反,质量越低耗时越长。高于这个质量点,想提高质量就得增加成本,质量越高开发时间越长。这样两种观点就统一起来了。

如果在大多数项目中,这个中间质量点是可以作为高质量接受的,那我们就找到了又快又好的最佳方法。这个质量点到底是多少?呵,我可以告诉你,那是87.5。但是谁知道怎么去度量呢?没有人知道,只能凭感觉和经验了。

2.我们的时间花在哪里

经过这段时间的练习,大多数人都体会到敲代码不是耗费时间最多的地方,一个高效率的程序员,并不是打字比别人快,而他节省了别人浪费了的时间。我常说达到别人五倍的效率并不难,因为在软件开发中,大部分人的大部分时间都浪费掉了,你只要把别人浪费的时间省下来,你的效率就提高上去了。像在优化软件性能时采用的方法一样,要优化程序员的性能,我们要找出性能的瓶颈。也就是弄清楚我们的时间花在哪些地方,然后想办法省掉某些浪费了的时间。根据我的经验,耗费时间最多的地方有:

o 分析

需求分析通常是SPEC工程师(或者所谓的系统分析员)的任务,程序员也会参与到这个过程中,但程序员的任务主要是理解需求,然后分析如何实现它们,这个分析工作也就是软件设计。无论你是在计算机上用设计工具画出正规的软件架构图,还在纸上用自然语言描述出算法的逻辑,甚至在脑海中一闪而过的想法都是设计。设计其实就是打草稿,把你的想法进行推敲,最后得到可行的方案。设计文档只是设计的结果,是设计的表现形式,没有写设计文档,并不代表没有做设计(但是写设计文档可以加深你的思考)。

设计本身是一个思考过程,需要耗费大量时间,对于新手来说更是如此。前面几节中的需求并不难,理解它们只需要很少的时间,但要花不少时间去思考其实现的方法。这个时间因人而异,有的读者到最后也没有想出办法,这没有关系,没有人天生就会的,不会的原因只是因为你暂时还不知道常用的设计方法,甚至连基本数据结构和算法都不熟悉。

在后面的章节中,我们会一步步的深入学习各种常用设计方法,反复练习基本数据和算法,熟能生巧,软件设计也一样,在你什么都不懂的时候,不可能做出好的设计。你要学习各种经典的设计方法,开始可能生搬硬套,多写多练多思考,到后来就随心所欲了,设计的时间就会大大缩短。

o测试

要写得好自然离不开测试,初学者都有这个概念。他们忠实的使用了教科书上讲的方法,用scanf输入数据,做些操作之后,用printf打印来,这是一个完美的输入-处理-输出的过程。测试也就是要保证正确的输入能产生正确的输出,这种方法的原理是没有错的,但它们确实耗费了我们大量时间。

如果测试只需要做一次,这种方法还是可取的,问题是每次修改之后都要重复这个过程,耗费的时间就更多了。这种工作单调乏味,而且很难坚持做下去,单元测试做得不全面,就有更多BUG等着就调试了。时间久了,或者换人维护了,谁也搞不清楚什么样输入产生什么样的输出,结果可能是连测试也省了,那就等着把大量的时间浪费在调试上吧。总而言之,这种测试方法不好,我们需要更有效的测试方法才行。

o调试

测试时发现程序有BUG,自然要用调试器了,对一些人来说,调试是一件充满挑战和乐趣的事。而对大部分人来说,特别是对我这种做过两年专职调试的人来说,调试是件无趣无聊无用的工作。熟练使用调试器是必要的,在分析现有软件时,调试器是非常有用的工具。但在开发新软件时,调试器在浪费我们的时间。

调试器是最后一招,只有迫不得已时才使用。一位敏捷方法的高手说他已经记不得上次使用调试器是什么时候了,我想这就是为什么敏捷方法能够提高开发速度的原因吧。因为没有什么比一次性写好,不用调试器更快的方法了。

知道了浪费时间的地方,接下来几节中,我们将介绍避免浪费时间的方法。学完这些方法之后,我希望读者也能达到普通工程师五倍的效率,呵,读完本系列文章后之,希望你会达到更高。

by admin at 十二月 16, 2008 12:20 上午

十二月 15, 2008

Absurd

系统程序员成长计划-并发(一)(下)

Linux下的多线程编程使用pthread(POSIX Thread)函数库,使用时包含头文件pthread.h,链接共享库libpthread.so。这里顺便说一下gcc链接共享库的方式:-L用来指定共享库所在目录,系统库目录不用指定。-l用来指定要链接的共享库,只需要指定库的名字就行了,如:-lpthread,而不是-llibpthread.so。看起来有点怪,这样做的原因是共享库通常带有版本号,指定全文件名就意味着你要绑定到特定版本的共享库上,只指定名字则在可以运行时通过环境变量来选择要使用的共享库,这样能够给软件升级带来的方便。

pthread函数库的使用相对比较简单,读者可以在终端下运行man pthread_create阅读相关函数的手册,也可以到网上找些例子参考。具体使用方法我们就不讲了,这里介绍一下初学者常犯的错误:

o 用临时变量作为线程参数的问题。

#include <stdio.h>
#include <pthread.h>
#include <assert.h>

void* start_routine(void* param)
{
    char* str = (char*)param;

    printf("%s:%s\n", __func__, str);

    return NULL;
}

pthread_t create_test_thread()
{
    pthread_t id = 0;
    char str[] = "it is ok!";

    pthread_create(&id, NULL, start_routine, str);

    return id;
}

int main(int argc, char* argv[])
{
    void* ret = NULL;

    pthread_t id = create_test_thread();

    pthread_join(id, &ret);

    return 0;
}

分析:由于新线程和当前线程是并发的,谁先谁后是无法预测的。可能create_test_thread 已经执行完了,str已经被释放了,新线程才拿到这参数,此时它的内容已经无法确定了,打印出的字符串自然是随机的。

o 线程参数共享的问题。

#include <stdio.h>
#include <pthread.h>
#include <assert.h> 

void* start_routine(void* param)
{
    int index = *(int*)param;

    printf("%s:%d\n", __func__, index);

    return NULL;
}

#define THREADS_NR 10

void create_test_threads()
{
    int i = 0;

    void* ret = NULL;

    pthread_t ids[THREADS_NR] = {0};

    for(i = 0; i  THREADS_NR; i++)
    {
        pthread_create(ids + i, NULL, start_routine, &i);
    }

    for(i = 0; i  THREADS_NR; i++)
    {

        pthread_join(ids[i], &ret);
    }

    return ;
}

int main(int argc, char* argv[])
{
    create_test_threads();

    return 0;
}

分析:由于新线程和当前线程是并发的,谁先谁后是无法预测的。i在不断变化,所以新线程拿到的参数值是无法预知的,打印出的字符串自然也是随机的。

o 虚假并发。

#include <stdio.h>
#include <pthread.h>
#include <assert.h>

void* start_routine(void* param)
{

    int index = *(int*)param;

    printf("%s:%d\n", __func__, index);

    return NULL;
}

#define THREADS_NR 10

void create_test_threads()
{
    int i = 0;
    void* ret = NULL;

    pthread_t ids[THREADS_NR] = {0};

    for(i = 0; i  THREADS_NR; i++)
    {
        pthread_create(ids + i, NULL, start_routine, &i);
        pthread_join(ids[i], &ret);
    }

    return ;
}

int main(int argc, char* argv[])
{
    create_test_threads();

    return 0;
}

分析:因为pthread_join会阻塞直到线程退出,所以这些线程实际上是串行执行的,一个退出了,才创建下一个。当年一个同事写了一个多线程的测试程序,就是这样写的,结果没有测试出一个潜伏的问题,直到产品运行时,这个问题才暴露出来。

访问线程共享的数据时要加锁,让访问串行化,否则就会出问题。比如,可能你正在访问的双向链表的某个结点时,它已经被另外一个线程删掉了。加锁的方式有很多种,像互斥锁(mutex= mutual exclusive lock),信号量(semaphore)和自旋锁(spin lock)等都是常用的,它们的使用同样很简单,我们就不多说了。

在加锁/解锁时,初学者常犯两个错误:

o 存在遗漏解锁的路径。初学者常见的做法就是,进入某个临界函数时加锁,在函数结尾的地方解锁,我甚至见过这种写法:

{
/*这里加锁*/
…
    return …;
/*这里解锁*/
}

如果你也犯了这种错误,应该好好反思一下。有时候,return的地方太多,在某一处忘记解锁是可能的,就像内存泄露一样,只是忘记解锁的后果更严重。像下面这个例子:

Ret dlist_insert(DList* thiz, size_t index, void* data)
{
    DListNode* node = NULL;
    DListNode* cursor = NULL;

    return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);

    dlist_lock(thiz);

    if((node = dlist_create_node(thiz, data)) == NULL)
    {
        dlist_unlock(thiz);
        return RET_OOM;
    }

    if(thiz->first == NULL)
    {
        thiz->first = node;

        dlist_unlock(thiz);
        return RET_OK;
    }
    ...

    dlist_unlock(thiz);

    return RET_OK;
}

如果一个函数有五六个甚至更多的地方返回,遗忘一两个地方是很常见的,即使没有忘记,每个返回的地方都要去解锁和释放相关资源也是很麻烦的。在这种情况下,我们最好是实现单入口单出的函数,常见的做法有两种:

一种是使用goto语句(在linux内核里大量使用)。示例如下:

Ret dlist_insert(DList* thiz, size_t index, void* data)
{
    Ret ret = RET_OK;
    DListNode* node = NULL;
    DListNode* cursor = NULL;

    return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);

    dlist_lock(thiz);

    if((node = dlist_create_node(thiz, data)) == NULL)
    {
        ret = RET_OOM;
        goto done;
    }

    if(thiz->first == NULL)
    {
        thiz->first = node;

        goto done;
    }
    ...
done:
    dlist_unlock(thiz);

    return ret;
}

另外一种是使用do{}while(0);语句,出于受教科书的影响(不要用goto语句),我习惯了这种做法。示例如下:

Ret dlist_insert(DList* thiz, size_t index, void* data)
{
    Ret ret = RET_OK;
    DListNode* node = NULL;
    DListNode* cursor = NULL;

    return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);

    dlist_lock(thiz);

    do
    {
        if((node = dlist_create_node(thiz, data)) == NULL)
        {
            ret = RET_OOM;
            break;
        }

        if(thiz->first == NULL)
        {
            thiz->first = node;
            break;
        }
	...
    }while(0);

    dlist_unlock(thiz);

    return ret;
}

o 加锁顺序的问题。有时候为了提高效率,常常降低加锁的粒度,访问时不是用一个锁锁住整个数据结构,而是用多个锁来控制数据结构各个部分。这样一个线程访问数据结构的这部分时,另外一个线程还可以访问数据结构的其它部分。但是在有的情况下,你需要同时锁几个锁,这时就要注意了:所有线程一定要按相同的顺序加锁,相反的顺序解锁。否则就可能出现死锁,两个线程都拿到对方需要的锁,结果出现互相等待的情况。

by admin at 十二月 15, 2008 01:11 下午

系统程序员成长计划-写得又快又好的秘诀(六)

Save your work

“Ernst和Young所在的小组决定使用正规的开发理论—他们常用削减法,分阶段进行开发并具有中途交付能力。他们的步骤包括细致的分析和设计—正如本章描写的基本原则一样。而其他竞争者径直开始了编码,在开始几个小时里,Ernst和Young小组落后了。但到中午时Ernst和Young小组却是遥遥领先了,而到了这一天的最后,他们却失败了。导致失败的原因不是因为他们的正规方法,而是他们偶然错误的把工作文件覆盖了,最终他们比午餐时所做的估计少交付了一些功能,他们是被没有使用有效的源程序版本控制这个典型的错误给打败了。”

–摘自《快速软件开发》

前段时间看探索频道的《荒野求生秘技(Man & wild)》,我很喜欢这个节目也喜欢那个英国佬,甚至连重播都不会放过。他展示在沙漠、丛林、冰河和雪山等各种环境的求生秘技,他吃蜘蛛、白蚁、蝎子和蜥蜴,边吃边说这东西很恶心,但是里面含有非常的维生素,蛋白质和糖份,能够Save your life,所以要吃下去。

在Man & Code的世界里,环境好多了,不用面临危险,寻找水源和食物根本不需要什么秘诀。这里我们不需要求生秘技去Save your life,但我们需要一些习惯去Save your work。我说过作为一名高效的程序员,不是因为他打字比别人快,而是因为他省下了别人浪费的时间,有什么比成果被毁,从头再来更浪费时间呢?下面我介绍一些习惯,它们简单有效,根本算不上什么秘技,但它们能够Save your work,让你的工作稳步前进。

o 随时存盘

每次停电时,我都会听到有人惊呼,完了,我的代码没有保存!补回半小时或一个小时的工作不难,在一个好的工作环境里,这种情况一年也就会遇到几次,浪费的时间完全可以忽略不计。但是那种感觉很难受,会影响你的工作情绪,无缘无故的让你重做你的工作,和因为要改进去重做完全是两回事。在我以前工作过的一个公司,有段时间经常跳闸,每周都要停好几次,怎么也找不到原因,后来请人来查,据说是线路太长,静电引起的跳闸。经过那段时间的折磨,我养成了一个习惯:写代码的时候,平均30秒钟存盘一次。现在遇到停电,别人惊呼的时候,我开始闭目养神了。

o 使用版本控制系统

和一些老程序员聊天时(呵,其实我也老了),他们经常问起我们项目有没有使用版本控制系统,我说当然有了,大二的时候就我用Sourcesafe来管理用powerbuilder写的代码了,后来的工作中一直在使用不同的版本控制系统。接着他们开始讲述他们惨痛的经历…这些经历小则让他们项目延期,大则导致整个项目失败。

版本控制系统有很多功能,但对我个人来说,它最重要的功能是备份代码。每完成一个小功能,我都会把它提交(checkin)进去,如果我不小心删除了本地文件,或者某个做尝试的修改失败了,我可以恢复代码到前一个版本。不同团队有不同的规则,有的团队是不允许这样checkin的,他们只允许checkin经过严格测试的代码。如果是那样,你可以在本地建立自己的版本控制系统,初学者在学习时也可以这样做。现在有很多免费的版本控制系统可用,像CVS、SVN和GIT等等,我个人习惯用CVS,SVN是CVS的改进版,将来肯定会替代CVS的,所以推荐大家使用它。

o 定期备份

温伯格在《Quality Software Management: System Thinking》讲了一个有趣的故事,他以前去研究一些失败的案例,发现这些项目的失败都是因为欠佳的运气引起的:比如遭受到洪水、地震、火灾和流行感冒等灾害,项目主管们把自己描述成外部问题的受害者。他又对另外一些成功的项目进行研究,发现其中有些项目同样经历这些自然灾害,但是他们成功的完成了任务。区别只是在于成功项目的主管,采用积极预防措施,定期备份代码,把它们放到不同的地点。

以前在学校的时候,我有两台电脑,一台赛扬和一台486。我经常在上面重装系统,一会儿装Linux,一会儿装NT,一会儿又装Netware。虽然我经常把代码备份到不同的分区上,结果还不小心把所有分区全干掉了,让我痛心不已。那只是写的一些小程序,重写一遍问题也不大,但是对于专业程序员或一个软件团队来说,重写整个代码就不能接受了,所以需要更可靠的备份机制。

使用源代码管理系统还不能保证代码的安全,比如服务器硬盘损坏和办公室发生火灾等都是可能发生。团队里一定要有人负责定期备份源代码管理系统系统上的资料,作为初学者也应该有这种意识。另外,我发现有些朋友把重要的资料放在邮箱里,现在的邮箱容量很大,因为提供商会定期备份,非常安全,这倒是一个不错的主意。

o 状态不好就做点别的

女同胞有定期状态不佳的时候,男同胞也不是每天状态都很好。感冒了、丢东西了、或者家人争吵了,都会影响你的状态。状态不好的时候做事,往往是进一步退两步,甚至犯下严重的错误。有次我得了重感冒,居然在服务器的根目录下运行rm * -rf(删除全部文件),由于删除的时间太长,才让我发现删错地方了,吓得我出了一身冷汗,还好那台服务器不是运行着源代码管理系统,但还是浪费了我两天时间去重建服务器上的环境。

状态不好的时候编程也会犯一些低级错误,让你花费更多时间去调试。总要言之,状态不好的去做重要的事有害无益,这时你不防去做点别做的,比如看看其它模块的代码之类的,甚至完全放松去休息都比犯下严重的错误强。

by admin at 十二月 15, 2008 12:47 下午

十二月 14, 2008

Absurd

系统程序员成长计划-并发(一)(上)

这几年并发技术受到前所未有的关注:CPU进入多核时代,连手机芯片都使用三核的CPU(AP+BP+DSP集成到一颗芯片)了。天生具有并发能力的语言ErLang逐渐成为热点。网格和云计算开始进入实用阶段。还有一些新技术更是让我闻所未闻,初学者也不用被这些铺天盖地的名词吓倒。据笔者的经验来看,这些技术或许能够改变产业的格局,对人类生活造成重大影响,但从实现角度来看并不无多少革命,相反大部分都是传统技术的改进和应用。这几年我一直在研究开源的基础软件,实际上我没有发现多少“新”东西或者核心技术。要说真正的核心还是如序言中说的:战胜复杂度和应对变化。

作为系统程序员,掌握基础理论和经典的设计方法,比去追逐一些所谓的新技术要实用得多,基础打扎实了,学习新知识也是很容易的事。在接下来几节中,我们一起来学习传统的并发编程知识。在这里我们请读者完成下列任务:

了解linux下的多线程编程的基本方法,以双向链表为载体实现传统的生产者-消费者模型:一个线程往双向链表中加东西,另外一个线程从这里面取。

by admin at 十二月 14, 2008 12:27 下午

十二月 09, 2008

Absurd

系统程序员成长计划-写得又快又好的秘诀(五)

自动测试

手工测试比没有测试强一点,但是它存在的问题让它很难在实践中应用:手工输入数据的过程单调乏味,很难长期坚持。每次都要重新输入数据,浪费大量时间。测试用例不能累积,测试往往不完整。用人脑判断输出的正误,浪费人力也存在误差。要写得好测试自然不能省,要写得快就需要更好的测试方法。

更好的测试方法当然是自动测试了。幸运的是,刚进入这个行业我就接触了自动的测试 (呵,读本文的初学者就更幸运了),我的第一份正式工作是在测试组写测试程序。当时测试组也算是人才济济了,居然有几个北大毕业的,不过她们都不懂Linux,所以我被指派去为移植到Linux上的模块写测试程序。这些模块都有测试程序,但这些测试程序的功能太弱了,我的上司要求开发人员改进,但那些开发人员太自以为是了,根本不理我们,所以我们只好自己重写这些测试程序。模块很多,大概有50多个模块,熟悉这些模块也需要不少时间,按每两个工作日写一个测试程序,上司给我5个月时间。

记得第一个模块是RDFParser,RDF(资源描述框架)是XML的一种应用,RDFParser实际上是一个XML解析器,并包装成RDF要求的接口。由于我对C/C++还不太熟悉,对RDF更不熟悉了,花了两周时间才写出这个测试程序。运行起来有些不正常,我确信不是测试程序的问题,就去请开发人员帮忙来看一下。负责RDFParser的那个程序员是人大毕业,我没有见过第二个比他更自以为是的程序员了,他刚在我座位上坐下就很大声说,你们QA的人太蠢了!

当时一听就愣了,不过我是新来的,见上司都没反应,自然就忍了。我列举了一些证据是模块里面的问题,他听也不听,只是不断重复的说,不可能是我程序的问题,你们QA的人太蠢了,总是浪费我的时间。过了一会儿,他终于闭上了嘴巴,又等了一会儿才说,等会儿重新发个版本给你吧。后来又请他过来四五次,结果每次都是他的问题。

之后我再没有听到他说过你们QA的人太蠢了的话。为了避免让他抓到把柄来嘲笑测试组,我决定请他来查问题之前做更详细的测试。当时我写的测试程序和现在初学者写的测试程序没有两样,都是从教科书上学来的,先通过scanf从终端输入数据,调用被测函数,再把结果printf出来,这花了我太多时间。想到后面还有50多个模块的测试程序要写,这样下去不行,一定得想个办法。

后来我把输入的数据和期望的结果都写到一个INI文件中,测试程序从这个文件中读入数据,运行测试,再和预期结果比较,整个过程都自动化了。写了一个INI文件的解析器花了我一周时间,又重写了那个测试程序,整整花了我一个月时间完成RDFParser的测试程序。进度自然大落后了,还好上司知道后并没有责备我,让我慢慢做就好了。

写第二个测试程序时把INI解析的代码拷贝过去,再加一些调用模块的代码就写好了,第三个也是如此。写了几个之后,我发现了INI解析有个BUG,结果每个测试程序我都要去修改,想到维护起来太麻烦了,就把INI解析器的接口规范化了,编译成一个独立共享库。又写了几个测试程序,我写烦了,原因是测试程序无非就是读入数据,调用被测函数,再检查结果,这个过程太无聊了。想到后面还要把这个过程重复几十遍,郁闷了几天之后,突然灵机一动,我决定写了一个代码产生器来产生这些代码。开始的代码产生器用C写的,用一个简单的规则来描述被测函数,通过这些规则来产生测试程序。我把这些东西和INI解析器放在一个独立的库中,把它叫作TesterFrameWork,经过几个测试程序的验证和完善,后来利用这个TesterFrameWork,只要一两个小时就能完成一个测试程序了。有次请开发人员那边一个高手帮我查一个问题,他看一会儿我的TesterFrameWork之后,盯着我说,你太聪明了。我笑了笑说,刚刚开始写C/C++程序。

一年之后我知道了有个CPPUnit之后,为了赶时髦我把TesterFrameWork改名为CxxUnit,非典的时候放假无聊就把它重写了一遍放在cosoft上了(之后没有管过它,或许还在吧)。

一个大系统很难自动测试,而一个独立的模块则是最佳的自动测试单元。自动测试和单元测试几乎成了等价的概念,很多人都以为自动测试就是利用CPPUnit这样的单元测试框架写个测试程序而已,这完全是错误的,就像有人以为有个设计文档的模板,照着填空就能填出好设计一样。

我自己实现过单元测试框架,不是像有些人出于模仿去实现,而完全出于实际的需要,后来我也研究其它测试框架,应该说我对测试程序框架的认识比一般程序员要深刻。我认为测试程序框架可以减化一些测试程序的工作,但它与自动测试没有密切关系,用不用测试程序框架完全是个人喜好。用测试程序框架未必能写出好的测试程序,就像用C++未必能写出好的面向对象的程序一样。

虽然我顺利的完成了那个写测试程序的任务,但我一直被一个问题困扰:如何写测试用例,如何去检测结果?这是测试程序框架帮不上忙的。写测试用例还好说,通过边界值法,等价类法和路径覆盖法找到最常用的测试用例。检测结果呢?有人说很简单啊,判断返回值就好了。那我问一下dlist_insert返回OK,就真的OK了吗?如果一个函数根本没有返回值,那你怎么判断呢?

测试程序框架是敏捷论者提倡的,在我看来它根本不够敏捷:你要去学习它,了解它的运行机制,要包含它的头文件,链接它的库,有比不用它更敏捷么?重要的是它根本帮不上什么有用的忙。前面的问题折磨了我一段时间,于是得出一个可能有点偏激的结论:测试程序框架都是愚蠢的,你真正需要的,它根本帮不了你(我知道这样说会得罪一些用测试程序框架的朋友,如果你想找我讨论的话,请看完本节的附带示例代码再说)。

就在那个时候,我看到了孟岩老师翻译的《契约式设计(Design by Contract)》,读完之后豁然开朗。或许我还没有明白契约式设计的本质,但我确实知道了写自动测试程序的方法,下面我介绍一下:

o 在设计时,每个函数只完成单一的功能。单一功能的函数容易理解,也容易预测其行为。对测试来说,给定一些输入数据,就知道它的输出和影响,这样函数是最容易测试的。

o 在设计时,把函数分为查询和命令两类。查询函数只查询对象的状态,而不改变对象的状态。命令函数则只修改对象的状态,只返回其操作是否成功的标志,而不返回对象的状态。比如,dlist_length查询双向链表的长度,它不修改双向链表的任何状态。dlist_delete修改对象的状态(删除结点),并返回其操作是否成功,而不返回当前长度或者删除的结点之类的状态。

o 在设计时,把查询分为基本查询和复合查询两类。基本查询函数只查询单一的状态,而复合查询可以同时查询多个状态。比如,window_get_width返回窗口的宽度,这是基本查询函数,widget_get_rect返回窗口的左上角坐标,宽度和高度,这是复合查询函数。

o在实现时,检验输入数据,确认使用者正确的调用了函数。契约式设计规定了调用者和实现者双方的责任,调用者需要使用正确的参数,才能保证有正确的结果。政治家告诉我们,信任但要检查,所以作为实现者就需要检查输入参数是否违背了契约。那怎么检查呢?有人说,如果检查到无效参数就返回一个错误码。这当然可以,只是不太好,因为大多数人都没有检查返回值的习惯,如果每个地方都检查函数的返回值,也是件很繁琐的事,代码看起来也比较乱。通常我们只检查一些关键的地方,对于无效参数这样的错误,可能就无声无息的隐藏起来了,这样不好,因为隐藏得越深,发现的时间越晚,修改的代价越大。

在C++和JAVA里,如果参数不正确,通常是throw一个无效参数之类的异常,C语言里面没有异常这个概念,我们需要其它办法才行。有人推荐用assert来检查,这是一个好办法,assert只在调试版本中有效(没有定义NDEBUG),这样任何无效调用都在调试版本中暴露出来了。如果配合前面返回错误码的方法,在发布版本中也可能避免程序粗暴的死掉。使用方法如下:

    assert(thiz != NULL);
    if(thiz == NULL)
    {
        return DLIST_RET_INVALID_PARAMS;
    }

我一直使用这种方法,但是有个问题:无法用自动测试验证assert是否正常的触发了,当用错误的参数测试时,我期望assert被触发,但如果assert被触发了,自动程序测试就死掉了,自动测试程序死掉了,就无法继续验证下一个assert。这是一个悖论!

后来我从glib里面学了一招,它检查时不用assert,只是打印出一个警告,代码也简明了,按它的方式,我们这样检查:

return_val_if_fail(cursor != NULL, DLIST_RET_INVALID_PARAMS);

我们需要定义两个宏,一个用于无返回值的函数,一个用于有返回值的函数:

#define return_if_fail(p) if(!(p)) \
    {printf("%s:%d Warning: "#p" failed.\n", \
        __func__, __LINE__); return;}
#define return_val_if_fail(p, ret) if(!(p)) \
    {printf("%s:%d Warning: "#p" failed.\n",\
__func__, __LINE__); return (ret);}

这样一来,遇到无效参数时,可以看到一个警告信息,同时又不会影响自动测试。

o在测试时,用查询来验证命令。命令一般都有返回值,但只检查返回值是不够的。比如dlist_delete返回OK,它真的OK了吗?我们信任它,但还是要检查。怎么检查?很简单,用查询函数来检查对象的状态是不是预期的。

对于dlist_delete,我们预期:

1.输入无效参数,期望返回DLIST_RET_INVALID_PARAMS。
2.输入正确参数,期望:
	函数返回DLIST_RET_OK
	双向链表的长度减一。
	删除的位置的下一个元素被移到删除的位置。

在测试程序中检查时,因为任何不符合期望的结果都是BUG,所以我们用assert检查。这样有问题马上暴露出来了,定位错误比较容易,通常都不需要调试器。我们这样来检查:

    assert(dlist_length(dlist) == (n-i));
    assert(dlist_delete(dlist, 0) == DLIST_RET_OK);
    assert(dlist_length(dlist) == (n-i-1));
    if((i + 1)  n)
    {
         assert(dlist_get_by_index(dlist, 0, (void**)&data) == DLIST_RET_OK);
         assert((int)data == (i+1));
    }

(完整的例子请看本节的示例代码)

o在测试时,用基本查询去验证复合查询。基本查询和复合查询返回的应该一致。比如:

    Rect rect = {0};
    widget_get_rect(widget, &rect);
    assert(widget_get_width(widget) == rect.width);
    assert(widget_get_height(widget)== rect.height);

o在测试时,预期结果依赖其执行上下文,我们要按逻辑组织测试用例。前面调用的函数可能改变了对象的状态,为了简化测试,在每组测试用例开始时,都重置对象到初始状态。

o 在测试时,第一次只写基本的测试用例,以后逐渐累积,每次发现新的BUG就把相应的测试用例加进去。每次修改了代码就运行一遍自动测试,保证修改没有引起其它副作用。

按着上面的原则,应付正常模块的测试没有问题了,但是下面的情况仍然比较棘手:

o 带有GUI的应用程序。有GUI的程序会给自动的输入数据和检查结果带来困难,有些工具可以部分解决这个问题,特别是针对Win32下的GUI,我很少在Windows下写程序,所以对这方面了解不多。不过最好的办法还是用MVC模型等分离界面和实现,因为界面通常相对比较简单,可以手工测试,而实现的逻辑比较复杂,这部分可以自动测试。后面我们会专门讲解分离界面和实现的方法。

o 有随机数据输入。如果有些输入数据是内部随机产生的,那你根本无法预测它的输出结果和影响。比如游戏随机的步骤和无线网络信号的变化。对于我们可以控制的随机数据,可以提供额外的函数去获取这些数据。对于无法控制的随机输入数据,可以把它们隔离开,在自动测试中,使用固定的数据。

o 多线程运行的程序。多线程的程序也很难自动测试,比如向链表中插入一个元素,当你检查的时候,根本无法知道链表的长度是否增加,也无法知道刚才插入的位置是否是你插入的元素,因为这个时候,可能有另外一个线程已经把它删除了,或者又加入了新的数据。不过在单线程的自动测试通过之后,多线程的问题会大大减少,剩下的问题我们可以通过其它方式加以避免。

写自动测试程序会花你一些时间,但这个投资能带来最大的回报:减少后面调试时的浪费,提高代码的质量,更重要的是你可以安稳的睡个觉了。

本节示例请到这里下载。

by admin at 十二月 09, 2008 02:38 下午

十二月 08, 2008

Absurd

KJAVA虚拟机Hack笔记-用GTK+实现绘图操作

绘图操作是在mutable image上进行的,也就是画在GdkPixmap上的,由于GdkPixmap没有画圆和椭圆的函数,我选择用cairo来实现。大部分函数的实现很直观,调用cairo相应的函数就行了,gxpport_draw_arc比较麻烦一点,因为gxpport_draw_arc支持画椭圆,而cairo没有函数直接对应,我们可以通过cairo的变换函数来实现:

void gxpport_draw_arc(
  jint pixel, const jshort *clip,
  gxpport_mutableimage_native_handle dst,
  int dotted, int x, int y, int width, int height,
  int startAngle, int arcAngle)
{
    cairo_t* cr  = gxpport_get_dst_cairo(dst);
    double start = ANGLE_FROM_DEGREE(startAngle);
    double arc   = ANGLE_FROM_DEGREE(arcAngle);

    cairo_init_style(cr, pixel, clip, dotted);
    cairo_save (cr);
    cairo_translate (cr, x + width / 2.0, y + height / 2.0);
    cairo_scale (cr, width / 2.0, height / 2.0);
    cairo_arc(cr, 0.0, 0.0, 1., start, arc);
    cairo_restore (cr);
    cairo_stroke (cr);

    return;
}

其它函数的实现类似,这里不再重复了。另外有两个问题值得一提:

o 目标pixmap可能为空,这时要画在当前的窗口上,我们调用自己实现的lfpport_get_active_screen来获得当前窗口。

o其次是与GTK的绘制机制有关。要往GdkWinow上绘制东西,只能在expose消息里做,在expose之前GTK会clear窗口上的内容,之后才把绘制的内容刷新到屏幕上。而JAVA的绘制操作显然不是在expose消息里执行的。为了解决这个问题,我们需要建立另外一个pixmap,JAVA先绘制到这个pixmap上,而在expose消息里从这个pixmap拷过去。

我们这样处理窗口的expose事件:

static gboolean frame_on_expose_event                 (GtkWidget       *widget,
                                        GdkEventExpose  *event,
                                        gpointer         user_data)
{
    int w = 0;
    int h = 0;
    (void)event;
    (void)user_data;

    GdkDrawable* drawable = GDK_DRAWABLE(widget->window);
    GdkDrawable* back_buffer = g_object_get_data(G_OBJECT(drawable), "back_buffer");
    GdkGC* gc = gdk_gc_new(drawable);

    if(back_buffer != NULL)
    {
        gdk_drawable_get_size(drawable, &w, &h);
        gdk_draw_drawable(drawable, gc, back_buffer, 0, 0, 0, 0, w, h);
    }
    g_object_unref(G_OBJECT(gc));

    return TRUE;
}

by admin at 十二月 08, 2008 01:38 下午

十二月 07, 2008

Absurd

系统程序员成长计划-写得又快又好的秘诀(四)

避免常见错误

在C语言中,内存错误是最为人诟病的。这些错误让项目延期或者被取消,引发无数的安全问题,甚至出现人命关天的灾难。抛开这些大道理不谈,它们确实浪费了我们大量时间,这些错误引发的是随机现象,即使有一些先进工具的帮助,为了找到重现的路径,花上几天时间也不足为怪。如果能够在编写代码的时候避免这些错误,开发效率至少提高一倍以上,质量可以提高几倍了。这里列举一些常见的内存错误,供新手参考。

o 内存泄露

大家都知道,在堆上分配的内存,如果不再使用了,应该把它释放掉,以便后面其它地方可以重用。在C/C++中,内存管理器不会帮你自动回收不再使用的内存。如果你忘了释放不再使用的内存,这些内存就不能被重用了,这就造成了所谓的内存泄露。

把内存泄露列为首位,倒并不是因为它有多么严重的后果,而因为它是最为常见的一类错误。一两处内存泄露通常不至于让程序崩溃,也不会出现逻辑上的错误,加上进程退出时,系统会自动释放该进程所有相关的内存(共享内存除外),所以内存泄露的后果相对来说还是比较温和的。但是,量变会导致质变,一旦内存泄露过多以致于耗尽内存,后续内存分配将会失败,程序可能因此而崩溃。

现在PC机的内存够大了,加上进程有独立的内存空间,对于一些小程序来说,内存泄露已经不是太大的威胁。但对于大型软件,特别是长时间运行的软件,或者嵌入式系统来说,内存泄露仍然是致命的因素之一。

不管在什么情况下,采取谨慎的态度,杜绝内存泄露的出现,都是可取的。相反,认为内存有的是,对内存泄露放任自流都不是负责的。尽管一些工具可以帮助我们检查内存泄露问题,我认为还是应该在编程时就仔细一点,及早排除这类错误,工具只是用作验证的手段。

o 内存越界访问

内存越界访问有两种:一种是读越界,即读了不属于自己的数据,如果所读的内存地址是无效的,程度立刻就崩溃了。如果所读内存地址是有效的,在读的时候不会出问题,但由于读到的数据是随机的,它会产生不可预料的后果。另外一种是写越界,又叫缓冲区溢出,所写入的数据对别人来说是随机的,它也会产生不可预料的后果。

内存越界访问造成的后果非常严重,是程序稳定性的致命威胁之一。更麻烦的是,它造成的后果是随机的,表现出来的症状和时机也是随机的,让BUG的现象和本质看似没有什么联系,这给BUG的定位带来极大的困难。

一些工具可以够帮助检查内存越界访问的问题,但也不能太依赖于工具。内存越界访问通常是动态出现的,即依赖于测试数据,在极端的情况下才会出现,除非精心设计测试数据,工具也无能为力。工具本身也有一些限制,甚至在一些大型项目中,工具变得完全不可用。比较保险的方法还是在编程是就小心,特别是对于外部传入的参数要仔细检查。

我们来看一个例子:

#include <stdlib.h>
#include <string.h>

int main(int argc, char* argv[])
{
    char str[10];
    int array[10] = {0,1,2,3,4,5,6,7,8,9};

    int data = array[10];
    array[10] = data;

    if(argc == 2)
    {
        strcpy(str, argv[1]);
    }

    return 0;
}

这个例子中有两个错误是新手常犯的:

其一:int array[10] 定义了10个元素大小的数组,由于C语言中数组的索引是从0开始的,所以只能访问array[0]到array[9],访问array[10]就造成了越界错误。

其二:strcpy(str, argv[1]);这里是否存在越界错误依赖于外部输入的数据,这样的写法在正常下可能没有问题,但受到一点恶意攻击就完蛋了。除非你确定输入数据是在你控制内的,否则不要用strcpy、strcat和sprintf之类的函数,而要用strncpy、strncat和snprintf代替。

o 野指针。

野指针是指那些你已经释放掉的内存指针。当你调用free(p)时,你真正清楚这个动作背后的内容吗?你会说p指向的内存被释放了。没错,p本身有变化吗?答案是p本身没有变化。它指向的内存仍然是有效的,你继续读写p指向的内存,没有人能拦得住你。

释放掉的内存会被内存管理器重新分配,此时,野指针指向的内存已经被赋予新的意义。对野指针指向内存的访问,无论是有意还是无意的,都为此会付出巨大代价,因为它造成的后果,如同越界访问一样是不可预料的。

释放内存后立即把对应指针置为空值,这是避免野指针常用的方法。这个方法简单有效,只是要注意,当然指针是从函数外层传入的时,在函数内把指针置为空值,对外层的指针没有影响。比如,你在析构函数里把this指针置为空值,没有任何效果,这时应该在函数外层把指针置为空值。

o 访问空指针。

空指针在C/C++中占有特殊的地址,通常用来判断一个指针的有效性。空指针一般定义为0。现代操作系统都会保留从0开始的一块内存,至于这块内存有多大,视不同的操作系统而定。一旦程序试图访问这块内存,系统就会触发一个异常/信号。

操作系统为什么要保留一块内存,而不是仅仅保留一个字节的内存呢?原因是:一般内存管理都是按页进行管理的,无法单纯保留一个字节,至少要保留一个页面。保留一块内存也有额外的好处,可以检查诸如p=NULL; p[1]之类的内存错误。

在一些嵌入式系统(如arm7)中,从0开始的一块内存是用来安装中断向量的,没有MMU的保护,直接访问这块内存好像不会引发异常。不过这块内存是代码段的,不是程序中有效的变量地址,所以用空指针来判断指针的有效性仍然可行。

o 引用未初始化的变量。

未初始化变量的内容是随机的(有的编译器会在调试版本中把它们初始化为固定值,如0xcc),使用这些数据会造成不可预料的后果,调试这样的BUG也是非常困难的。

对于态度严谨的程度员来说,防止这类BUG非常容易。在声明变量时就对它进行初始化,是一个好的编程习惯。另外也要重视编译器的警告信息,发现有引用未初始化的变量,立即修改过来。

在下面这个例子中,全局变量g_count是确定的,因为它在bss段中,自动初始化为0了。临时变量a是没有初始化的,堆内存str是没有初始化的。但这个例子有点特殊,因为程序刚运行起来,很多东西是确定的,如果你想把它们当作随机数的种子是不行的,因为它们还不够随机。

#include <stdlib.h>
#include <string.h>

int g_count;

int main(int argc, char* argv[])
{
    int a;
    char* str = (char*)malloc(100);

    return 0;
}

o 不清楚指针运算。

对于一些新手来说,指针常常让他们犯糊涂。

比如int *p = …; p+1等于(size_t)p + 1吗

老手自然清楚,新手可能就搞不清了。事实上, p+n 等于 (size_t)p + n * sizeof(*p)

指针是C/C++中最有力的武器,功能非常强大,无论是变量指针还是函数指针,都应该非常熟练的掌握。只要有不确定的地方,马上写个小程序验证一下。对每一个细节了然于胸,在编程时会省下不少时间。

o 结构的成员顺序变化引发的错误。

在初始化一个结构时,老手可能很少像新手那样老老实实的,一个成员一个成员的为结构初始化,而是采用快捷方式,如:

Struct s
{
    int   l;
    char* p;
};

int main(int argc, char* argv[])
{
    struct s s1 = {4, "abcd"};

    return 0;
}

以上这种方式是非常危险的,原因在于你对结构的内存布局作了假设。如果这个结构是第三方提供的,他很可能调整结构中成员的相对位置。而这样的调整往往不会在文档中说明,你自然很少去关注。如果调整的两个成员具有相同数据类型,编译时不会有任何警告,而程序的逻辑可能相距十万八千里了。

正确的初始化方法应该是(当然,一个成员一个成员的初始化也行):

struct s
{
    int   l;
    char* p;
};

int main(int argc, char* argv[])
{
    struct s s1 = {.l=4, .p = "abcd"};

    return 0;
}

(有的编译器可能不支持新标准)

o 结构的大小变化引发的错误。

我们看看下面这个例子:

struct base
{
    int n;

};

struct s
{
    struct base b;
    int m;
};

在OOP中,我们可以认为第二个结构继承了第一结构,这有什么问题吗?当然没有,这是C语言中实现继承的基本手法。

现在假设第一个结构是第三方提供的,第二个结构是你自己的。第三方提供的库是以DLL方式分发的,DLL最大好处在于可以独立替换。但随着软件的进化,问题可能就来了。

当第三方在第一个结构中增加了一个新的成员int k;,编译好后把DLL给你,你直接把它给了客户了,让他们替换掉老版本。程序加载时不会有任何问题,在运行逻辑可能完全改变!原因是两个结构的内存布局重叠了。

解决这类错误的唯一办法就是重新编译全部代码。由此看来,动态库并不见得可以动态替换,如果你想了解更多相关内容,建议你阅读《COM本质论》。

o 分配/释放不配对。

大家都知道malloc要和free配对使用,new要和delete/delete[]配对使用,重载了类new操作,应该同时重载类的delete/delete[]操作。这些都是书上反复强调过的,除非当时晕了头,一般不会犯这样的低级错误。

而有时候我们却被蒙在鼓里,两个代码看起来都是调用的free函数,实际上却调用了不同的实现。比如在Win32下,调试版与发布版,单线程与多线程是不同的运行时库,不同的运行时库使用的是不同的内存管理器。一不小心链接错了库,那你就麻烦了。程序可能动则崩溃,原因在于在一个内存管理器中分配的内存,在另外一个内存管理器中释放时就会出现问题。

o 返回指向临时变量的指针

大家都知道,栈里面的变量都是临时的。当前函数执行完成时,相关的临时变量和参数都被清除了。不能把指向这些临时变量的指针返回给调用者,这样的指针指向的数据是随机的,会给程序造成不可预料的后果。

下面是个错误的例子:

char* get_str(void)
{
    char str[] = {"abcd"};

    return str;
}

int main(int argc, char* argv[])
{

    char* p = get_str();

    printf("%s\n", p);

    return 0;
}

下面这个例子没有问题,大家知道为什么吗?

char* get_str(void)
{
    char* str = {"abcd"};

    return str;
}

int main(int argc, char* argv[])
{

    char* p = get_str();

    printf("%s\n", p);

    return 0;
}

o 试图修改常量

在函数参数前加上const修饰符,只是给编译器做类型检查用的,编译器禁止修改这样的变量。但这并不是强制的,你完全可以用强制类型转换绕过去,一般也不会出什么错。

而全局常量和字符串,用强制类型转换绕过去,运行时仍然会出错。原因在于它们是放在.rodata里面的,而.rodata内存页面是不能修改的。试图对它们修改,会引发内存错误。

下面这个程序在运行时会出错:

int main(int argc, char* argv[])
{
    char* p = "abcd";
    *p = '1';

    return 0;
}

o 误解传值与传引用

在C/C++中,参数默认传递方式是传值的,即在参数入栈时被拷贝一份。在函数里修改这些参数,不会影响外面的调用者。如:

#include <stdlib.h>
#include <stdio.h>

void get_str(char* p)
{

    p = malloc(sizeof("abcd"));

    strcpy(p, "abcd");

    return;
}

int main(int argc, char* argv[])
{
    char* p = NULL;

    get_str(p);

    printf("p=%p\n", p);

    return 0;
}

在main函数里,p的值仍然是空值。当然在函数里修改指针指向的内容是可以的。

o 重名符号。

无论是函数名还是变量名,如果在不同的作用范围内重名,自然没有问题。但如果两个符号的作用域有交集,如全局变量和局部变量,全局变量与全局变量之间,重名的现象一定要坚决避免。gcc有一些隐式规则来决定处理同名变量的方式,编译时可能没有任何警告和错误,但结果通常并非你所期望的。

下面例子编译时就没有警告:

t.c

#include <stdlib.h>
#include <stdio.h>

int count = 0;

int get_count(void)

{
    return count;
}

main.c

#include <stdio.h>

extern int get_count(void);

int count;

int main(int argc, char* argv[])
{
    count = 10;

    printf("get_count=%d\n", get_count());

    return 0;

}

如果把main.c中的int count;修改为int count = 0;,gcc就会编辑出错,说multiple definition of `count’。它的隐式规则比较奇妙吧,所以还是不要依赖它为好。

o 栈溢出。

我们在前面关于堆栈的一节讲过,在PC上,普通线程的栈空间也有十几M,通常够用了,定义大一点的临时变量不会有什么问题。

而在一些嵌入式中,线程的栈空间可能只5K大小,甚至小到只有256个字节。在这样的平台中,栈溢出是最常用的错误之一。在编程时应该清楚自己平台的限制,避免栈溢出的可能。

o 误用sizeof。

尽管C/C++通常是按值传递参数,而数组则是例外,在传递数组参数时,数组退化为指针(即按引用传递),用sizeof是无法取得数组的大小的。

从下面这个例子可以看出:

void test(char str[20])
{
    printf("%s:size=%d\n", __func__, sizeof(str));
}  

int main(int argc, char* argv[])
{
    char str[20]  = {0};

    test(str);

    printf("%s:size=%d\n", __func__, sizeof(str));

    return 0;
}

[root@localhost mm]# ./t.exe
test:size=4
main:size=20

o 字节对齐。

字节对齐主要目的是提高内存访问的效率。但在有的平台(如arm7)上,就不光是效率问题了,如果不对齐,得到的数据是错误的。

所幸的是,大多数情况下,编译会保证全局变量和临时变量按正确的方式对齐。内存管理器会保证动态内存按正确的方式对齐。要注意的是,在不同类型的变量之间转换时要小心,如把char*强制转换为int*时,要格外小心。

另外,字节对齐也会造成结构大小的变化,在程序内部用sizeof来取得结构的大小,这就足够了。若数据要在不同的机器间传递时,在通信协议中要规定对齐的方式,避免对齐方式不一致引发的问题。

o 字节顺序。

字节顺序历来是设计跨平台软件时头疼的问题。字节顺序是关于数据在物理内存中的布局的问题,最常见的字节顺序有两种:大端模式与小端模式。

大端模式是高位字节数据存放在低地址处,低位字节数据存放在高地址处。

小端模式指低位字节数据存放在内存低地址处,高位字节数据存放在内存高地址处;

在普通软件中,字节顺序问题并不引人注目。而在开发与网络通信和数据交换有关的软件时,字节顺序问题就要特殊注意了。

o 多线程共享变量没有用valotile修饰。

关键字valotile的作用是告诉编译器,不要把变量优化到寄存器里。在开发多线程并发的软件时,如果这些线程共享一些全局变量,这些全局变量最好用valotile修饰。这样可以避免因为编译器优化而引起的错误,这样的错误非常难查。

o 忘记函数的返回值

函数需要返回值,如果你忘记return语句,它仍然会返回一个值,因为在i386上,EAX用来保存返回值,如果没有明确返回,EAX最后的内容被返回,所以EAX的内容是随机的。

by admin at 十二月 07, 2008 11:09 上午

十二月 04, 2008

Absurd

系统程序员成长计划-写得又快又好的秘诀(三)

代码阅读法

软件工程实践已经证明Code Review是提高代码质量最有效的手段之一,极限编程(XP)更是把Code Review推向极致,形成著名的结对编程工作方式,两个程序员在一台电脑前面工作,一个人编写程序,另一个Review输入每一行代码,写程序人的专注于目前细节上的工作,Review的人同时要从高层次考虑如何改进代码质量,两个人的角色会经常互换。

可惜我即没有结对编程的经验,也没有在CMM3(及以上)团队中工作过。不过现在我要介绍比结对编程更敏捷更轻量级,但是同样有效的Review方法。这种方法不需要其他程序员配合,有你自己就够了。为了把这种方法与传统的Code Review区分开来,我把它称为代码阅读法吧。

很多初学者包括一些有经验的程序员,在敲完代码的最后一个字符后,马上开始编译和运行,迫不急待的想看到自己的工作成果。快速反馈有助于满足自己的成就感,但是同时也会带来一些问题:

让编译器帮你检查语法错误可以省些时间,但程序员往往太专注这些错误了,以为改完这些错误就万事大吉了。其实不然,很多错误编译器是发现不了的,像内存错误和线程死锁等等,这些错误可能逃过简单的测试而遗留在代码中,直到集成测试或者软件发布之后才暴露出来,那时就要花更大代价去修改它们了。

修改完编译错误之后就是运行程序了,运行起来有错误,就轮到调试器上场了。花了不少时间去调试,发现无非是些低级错误,或许你会自责自己粗心大意,但是下次可能还是犯同样的错误。更严重的是这种debug & fix的方法,往往是头痛医头脚痛医脚,导致低质量的软件。

让编译器帮你检查语法错误,让调试器帮你查BUG,这是天经地义的事,但这确实是又慢又烂的方法。就像你要到离家东边1000米的地方开会,结果你往西边走,又是坐车又是搭飞机,花了一周时间,也绕着地球转了一周,终于到了会议室,你还大发感慨说,现代的交通工具真是发达啊。其实你往东走,走路也只要十多分钟就到了。不管你的调试技巧有多高,都不如一次性写好更高效。

我以前也一样,想赶时间结果花了更多时间,在经过很多痛苦的经历之后,我开始学会放松自己,让自己慢下来。写完程序之后,我会花些时间去阅读它,一遍两遍甚至多遍之后,才开始编译它,只要有时间,在通过测试之后,我还会阅读它们,每读一遍都有不同的收获,有时候会发现一些错误,有时候会做些改进,有时候也有新的想法。

下面是我在阅读自己代码时的一些方法:

o检查常见错误。

第一遍阅读时主要关注语法错误、代码排版和命名规则等等问题,只要看不顺眼就修改它们。读完之后,你的代码很少有低级错误,看起来也比较干净清爽。第二遍重点关注常见编程错误,比如内存泄露和可能的越界访问,变量没有初始化,函数忘记返回值等等,在后面的章节中,我会介绍这些常见错误,避免这些错误可以为你省大量的时间。如果有时间,在测试完成之后,还可以考虑是否有更好的实现方法,甚至尝试重新去实现它们。说了读者可能不相信,在学习编程的前几年,我经常重写整个模块,只我觉得能做得更好,能验证我的一些想法,或提高我的编程能力,即使连续几天加班到晚上十一点,我也要重写它们。

o模拟计算机执行。

常见错误是比较死的东西,按照检查列表一条一条的做就行了。有些逻辑通常不是这么直观的,这时可以自己模拟计算机去执行,假想你自己是计算机,读入这些代码时你会怎么处理。这种方法能有效的完善我们的思路,考虑不同的输入数据,各种边界值,这能帮助我们想到一些没有处理的情况,让程序的逻辑更严谨。

o假想讲给朋友听。

据说在Code Review时发现错误的,往往不是Review的人而是程序员自己。我也有很多这样的经历,在讲给别人听的时候,别人还没有听明白,自己已经发现里面存在的错误了。上大学时,我常常把写的或者学到的东西讲给隔壁寝室的一个同学听,他说他从我这里学到很多知识,其实我从讲的过程中,经常发现一些问题,对提高自己的能力大有帮助。可惜并不是随时都能找到好的听众,幸好我们有另外一个替代办法,记得刚开始写程序时看过一本书(忘记名字了),作者说他在写程序时,常常把思路讲给他的布娃娃听。我没有布娃娃当听众,讲给鼠标听总是有点怪怪的,所以就假想旁边有个朋友,我把自己的思路讲给他听,同时也假想他来质疑我。这种方法很效,能够让自己的思路更清晰,据说一些大师也经常使用这种方法。

这种代码阅读法会花你一些时间,但是可以省下更多调试时间,而且能够提高代码质量,可以说是名符其实的“又快又好的” 秘诀之一。至于读几遍合适,要根据情况而定,个人觉得读两到三遍是最佳的投资。

by admin at 十二月 04, 2008 02:51 下午

十二月 03, 2008

Absurd

KJAVA虚拟机Hack笔记-实现mutable image

mutable image在这里的意思是说可以在上面进行绘制操作的图片,它有点像VC中的DC,可以在上面贴图或者画直线填充矩形等等。在GTK+中实现的话,自然就用GdkPixmap了,GdkPixmap从GdkDrawable继承过来的,提供了各种常用的绘图操作。

gxpport_create_mutable 调用gdk_pixmap_new创建一个pixmap就好了,当然要记得检查资源限制,这个检查都是一样的,后面不再重复了。

gxpport_render_mutableregion 把一个pixmap绘制到另外一个pixmap上,调用gdk_draw_drawable就行了。transform的实现也不难,不过我还没有做,想到时候用cairo的变换来实现。

gxpport_render_mutableimage 先取出source的宽高,再调用gxpport_render_mutableregion。

gxpport_get_mutable_argb 先调用gdk_drawable_get_image,再调用gdk_image_get_pixel取出pixel数据。

gxpport_destroy_mutable 调用g_object_unref就行了。

gxpport_render_immutableimage和gxpport_render_immutableregion 调用gdk_draw_pixbuf就行了。在基于DirectFB的GTK+中,我遇到一点小问题,基本图形操作,比如画直线和显示文字等是调用cairo实现的。结果发现显示文字之后,再显示图片时颜色就不对了。调试了好久,才发现是因为显示文字时修改了Surface的Blit Flag。为了正常显示图片,在draw_pixbuf时需要调用gdk_gc_set_function设置blit flag为正常拷贝。

by admin at 十二月 03, 2008 02:04 下午

十二月 02, 2008

Absurd

程序员,为你的程序而骄傲吧

一些人认为程序员都有自大狂倾向,我不知道这种想法源于何处有何根据。谦虚是中华民族的传统美德,我们不但从小到大都受这类教育,而且在关于软件的方面的书中,也不忘提醒我们要谦虚谨慎,一些前辈更是无私的和我们分享要谦虚的经验。

在这样的多重教育下,难道我们还会顽固不改吗?每次代码评审时,我们都会说,不好意思,代码写得比较烂。每次移交工作时,我们都会说,不好意思,工作做得不好。每次发布软件时,我们都会说,不好意思,里面还有很多问题。你看,我们是何等的谦虚,也是何等的诚实(不是真的很烂吗)!

不是自大,相反,我们是太谦虚了。程序员会说,放心吧,这是我做的设计,这是我写的代码。或者是说,我是这方面的专家,有问题只管找我。你听过类似的话吗?至少我是很少听到的。当然,或许在简历上这样写过,但扪心自问,那是自大还是骗人?

谦虚已经不再是促进我们进步的力量,而是成了掩饰我们不负责任的外衣。代码评审前先谦虚一下,即使在评审过程中发现大量问题,也心安理得,反而证明了我们诚实的品质。在移交工作时谦虚一下,把烂摊子推给别人,我们不再内疚。在发布软件时,谦虚一下,把软件中的BUG视为理所当然。

我对Donald E. Knuth的景仰并非源于他的几本巨著,因为总共看了不到200页,也不是源于他那套著名的排版软件,因为从来都没有用过。而是源于他说过的一句话:

“我确信TEX的最后一个错误已经在1985年11月27日被发现并排除掉了。但是如果出于目前尚不知道的原因,TEX仍然潜伏有错误,我非常愿意付给第一个发现者$20.48元。(这一金额已是以前的两倍。我打算在本年内再增加一倍。你看我是多么自信!)”

大师李敖说: 表面上我是非常的狂傲,而我内心冷静得不得了。这话同样适用于Knuth。Knuth在说前面那句话之前,不知道把TEX的代码和设计检查了多少遍,考虑过多少种可能性,所以他确信没有BUG了。

Knuth这样说,决不是出于冲动或者虚荣心,而是出于一种敢于承担责任的勇气,一种对自己作品的自信,一种追求完美的态度。这种勇气、这种自信和这样态度正是我们所缺乏的!

试想,如果他说,我已经在TEX里发现了大量BUG,根据经验表明,发现的BUG越多,说明软件里残留的BUG也越多。这没有办法,测试只能证明软件有BUG,而不能证明软件没有BUG,大家先用着吧。

对比前后两者,我们是喜欢前者的勇气和自信还是后者的谦虚?至少我更欣赏前者的勇气和自信,更景仰那他那种追求完美的态度。

现实中这样的程序员太少了。以前有位同事,他是linux组的leader。当时我刚毕业,负责写一些小工具和测试程序。他负责一个重要模块,估计有三万来行代码,让我去测试它。我说现在太忙,可能没有时间。他微微一笑说,其实也不用测试,没什么问题的。没多久他走了,我继续呆了两年多,事实证明他说的没错,三万行代码中出现的BUG不超过5个!

没见过第二敢这样说的人,大家都是谦虚的好孩子。

这到底是水平问题,还是态度问题?什么时候我们才那样的水平,什么时候我们才那样的态度?什么时候我们才敢说,这是我写的,没有问题,放心的用吧!什么时候我们才会为我们的作品而骄傲?

by admin at 十二月 02, 2008 02:20 下午

共享库的初始化和~初始化函数分析

Win32下可以通过DllMain来初始化和~初始化动态库,而Linux下则没有与之完全对应的函数,但可以通过一些方法模拟它的部分功能。有人会说,很简单,实现_init/_fini两个函数就行了。好,我们来看看事实是不是这样的。

很多资料上都说可以利用_init/_fini来实现,而我从来没有测试成功过,原因是这两个函数都已经被gcc占用了。比如:

test.c

#include <stdio.h>

void _init(void)
{
    printf("%s", __func__);
}

void _fini(void)
{
    printf("%s", __func__);
}

编译结果:

[root@localhost soinit]# gcc -g test.c -shared -o libtest.so

/tmp/cc4DUw68.o(.text+0x0): In function `_init':
/work/test/soinit/test.c:5: multiple definition of `_init'
/usr/lib/gcc/i386-redhat-linux/4.0.0/../../../crti.o(.init+0x0): first defined here
/tmp/cc4DUw68.o(.text+0x1d): In function `_fini':
/work/test/soinit/test.c:10: multiple definition of `_fini'
/usr/lib/gcc/i386-redhat-linux/4.0.0/../../../crti.o(.fini+0x0): first defined here
collect2: ld returned 1 exit status

由此可见,这两个符号已经被编译器的脚手架代码占用了,我们不能再使用。编译器用这两个函数做什么?我们能不能抢占这两个函数,不用编译器提供的,而用我们自己的呢?先看看这两个的实现:

00000594 _fini>:

 594:   55                      push   %ebp
 595:   89 e5                   mov    %esp,%ebp
 597:   53                      push   %ebx
 598:   50                      push   %eax
 599:   e8 00 00 00 00          call   59e _fini+0xa>
 59e:   5b                      pop    %ebx
 59f:   81 c3 02 11 00 00       add    $0x1102,%ebx
 5a5:   e8 de fe ff ff          call   488 __do_global_dtors_aux>
 5aa:   58                      pop    %eax
 5ab:   5b                      pop    %ebx
 5ac:   c9                      leave
 5ad:   c3                      ret   

 0000041c _init>:

 41c:   55                      push   %ebp
 41d:   89 e5                   mov    %esp,%ebp
 41f:   83 ec 08                sub    $0x8,%esp
 422:   e8 3d 00 00 00          call   464 
 427:   e8 b8 00 00 00          call   4e4 
 42c:   e8 2b 01 00 00          call   55c __do_global_ctors_aux>
 431:   c9                      leave
 432:   c3                      ret

从以上代码中可以看出,这两个函数是用来初始化/~初始化全局变量/对象的,抢占这两个函数可能导致初始化/~初始化全局变量/对象出错。所以不能再打_init/_fini的主意,那怎么办呢?

使用全局对象

test.cpp

#include <stdio.h>

class InitFini
{
public:
    InitFini()
    {
        printf("%s\n", __func__);
    }

    ~InitFini()
    {
        printf("%s\n", __func__);
    }
};

static InitFini aInitFini;

extern "C" int test(int n)
{
    return n;
} 

main.c

int test(int n);
int main(int argc, char* argv[])
{
    test(1);
    return 0;
}

测试结果:

[root@localhost soinit]# ./t.exe

InitFini

~InitFini

那么这两个函数是怎么被调用的呢?我们在gdb里看看:

Breakpoint 3, InitFini (this=0xa507bc) at test.cpp:7
7                       printf("%s\n", __func__);

Current language:  auto; currently c++
(gdb) bt

#0  InitFini (this=0xa507bc) at test.cpp:7
#1  0x00a4f5e0 in __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at test.cpp:15
#2  0x00a4f611 in global constructors keyed to test () at test.cpp:21
#3  0x00a4f66a in __do_global_ctors_aux () from ./libtest.so
#4  0x00a4f4a9 in _init () from ./libtest.so
#5  0x002c8b4b in call_init () from /lib/ld-linux.so.2
#6  0x002c8c4a in _dl_init_internal () from /lib/ld-linux.so.2
#7  0x002bb83f in _dl_start_user () from /lib/ld-linux.so.2 

Breakpoint 4, ~InitFini (this=0x0) at test.cpp:9
9               ~InitFini()
(gdb) bt
#0  ~InitFini (this=0x0) at test.cpp:9
#1  0x00a4f5b3 in __tcf_0 () at test.cpp:15
#2  0x00303e6f in __cxa_finalize () from /lib/libc.so.6
#3  0x00a4f532 in __do_global_dtors_aux () from ./libtest.so
#4  0x00a4f692 in _fini () from ./libtest.so
#5  0x002c9058 in _dl_fini () from /lib/ld-linux.so.2
#6  0x00303c69 in exit () from /lib/libc.so.6
#7  0x002eddee in __libc_start_main () from /lib/libc.so.6
#8  0x080483b5 in _start ()

从以上信息可以看出,正是从_init/_fini两个函数调用过来的。

使用gcc扩展

毫无疑问,以上方法可行,但是在C++中才行。而C语言中,根本没有构造和析构函数。怎么办呢?这时我们可以使用gcc的扩展:

#include <stdio.h>

__attribute ((constructor)) void test_init(void)
{
    printf("%s\n", __func__);
}

__attribute ((destructor)) void test_fini(void)
{
    printf("%s\n", __func__);
}

int test(int n)
{
    return n;
}

测试结果:

[root@localhost soinit]# ./t.exe

test_init
test_fini

我们不防也看看这两个函数是怎么被调到的:

Breakpoint 3, test_init () at test.c:6
6               printf("%s\n", __func__);
(gdb) bt
#0  test_init () at test.c:6
#1  0x00860586 in __do_global_ctors_aux () from ./libtest.so
#2  0x00860439 in _init () from ./libtest.so
#3  0x002c8b4b in call_init () from /lib/ld-linux.so.2
#4  0x002c8c4a in _dl_init_internal () from /lib/ld-linux.so.2
#5  0x002bb83f in _dl_start_user () from /lib/ld-linux.so.2
(gdb) c 

Breakpoint 4, test_fini () at test.c:11
11              printf("%s\n", __func__);
(gdb) bt
#0  test_fini () at test.c:11
#1  0x008604d3 in __do_global_dtors_aux () from ./libtest.so
#2  0x008605ae in _fini () from ./libtest.so
#3  0x002c9058 in _dl_fini () from /lib/ld-linux.so.2
#4  0x00303c69 in exit () from /lib/libc.so.6
#5  0x002eddee in __libc_start_main () from /lib/libc.so.6
#6  0x080483b5 in _start ()

从以上信息可以看出,也是从_init/_fini两个函数调用过来的。

总结:正如一些资料上所说的,在linux下,_init/_fini是共享库的初始化和~初始化函数。但这两个函数是给gcc用的,我们不能直接使用它们,但可以用本文中提到另外两种方法来实现。

by admin at 十二月 02, 2008 02:18 下午

分布式计算的基本原理

从最近几次MMI设计会议讨论的结果来看,嵌入式程序员对于分布式计算知之甚少。他们对分布式计算有种恐惧,所以对分布式架构极力排斥。而他们的人数又占绝对优势,讨论N次,MMI的架构还是没有确定下来。分布式计算已经进入桌面环境,不是企业应用的专利了,像GNOME(GNU Network Object Model Environment)的名字本身就暗示着分布式计算了。本文介绍一下分布式的基本原理,揭开分布式神秘的面纱,让嵌入式程序员熟悉一下。

分布式计算可以分为以下几类:

传统的C/S模型。如HTTP/FTP/SMTP/POP/DBMS等服务器。客户端向服务器发送请求,服务器处理请求,并把结果返回给客户端。客户端处于主动,服务器处于被动。这种调用是显式的,远程调用就是远程调用,本地调用就是本地调用,每个细节你都要清楚,一点都含糊不得。

集群技术。近年来PC机的计算能力飞速发展,而服务器的计算能力,远远跟不上客户端的要求。这种多对一的关系本来就不公平,人们已经认识到靠提高单台服务器的计算能力,永远满足性能上的要求。一种称集群的技术出现了,它把多台服务器连接起来,当成一台服务器来用。这种技术的好处就是,不但对客户来说是透明的,对服务器软件来说也是透明的,软件不用做任何修改就可以在集群上运行。集群技术的应用范围也仅限于此,只能提高同一个软件的计算能力,而对于多个不同的软件协同工作无能为力。

通用型分布式计算环境。如CORBA/DCOM/ RMI/ DBUS等,这些技术(规范)差不多都有具有网络透明性,被调用的方法可能在另外一个进程中,也可能在另外一台机器上。调用者基本上不用关心是本地调用还是远程调用。当然正是这种透明性,造成了分布式计算的滥用,分布式计算用起来方便,大家以为它免费的。实际上,分布式计算的代价是可观的,据说跨进程的调用,速度可能会降低一个数量级,跨机器的调用,速度可能降低两个数量级。一些专家都建议减少使用分布式计算,即使要使用,也要使用粗粒度的调用,以减少调用的次数。

还其一些混合形式(SOAP?),这里不再多说。我们主要介绍第三种分布式模型,这类分布式模型即适用于企业级应用,也适用于桌面应用。有的专注于企业级应用(如CORBA),有的专注于桌面环境(如DBUS)。它们的实现原理都差不多,基本上都基于传统的RPC或者仿RPC实现的,下面介绍一下它们的基本原理。

我们先看一下分布式的最简模型:

model

在传统的方法中,调用一个对象的函数很简单:创建这个对象,然后调用它的函数就行了。而在分布式的环境中,对象在另外一个进程中,完全在不同的地址空间里,要调用它的函数可能有点困难了。

看看传统的C/S模型的请求方式,客户端把参数通过网络发给服务器,服务器根据参数要求完成相应的服务,然后把结果返回给客户端,客户端拿到结果了,一次请求算完成。由此看来,调用远程对象似乎并不难,问题在于这种方式不是网络透明的,每一个细节你都要自己处理,非常复杂。

要简化软件的设计,当然是网络操作透明化,调用者和实现者都无需关心网络操作。要做到这一点,我们可以按下列方法:

在客户端要引入一个代理(Proxy)对象。它全权代理实际对象,调用者甚至都不知道它是一个代理,可以像调用本地对象一样调用这个对象。当调用者调用Proxy的函数时,Proxy并不做实际的操作,而是把这些参数打包成一个网络数据包,并把这个数据包通过网络发送给服务器。

在服务器引入一个桩(Stub)对象,Stub收到Proxy发送的数据包之后,把数据包解开,重新组织为参数列表,并用这些参数就调用实际对象的函数。实际对象执行相关操作,把结果返回给Stub,Stub再把结果打包成一个网络数据包,并把这个数据包通过网络发送给客户端的Proxy。

Proxy收到结果数据包后,把数据包解开为返回值,返回给调用者。至此,整个操作完成了。怎么样,简化吧。

Proxy隐藏了客户端的网络操作,Stub隐藏了服务器端的网络操作,这就实现了网络透明化。你也许会说,根本没有简化,只是把网络操作隔离开了,仍然要去实现Proxy和Stub两个对象,一样的麻烦。

没错。不过仔细研究一下Proxy和Stub的功能,我们会发现,对于不同对象,这些操作都差不多,无非就是打包和解包而已,单调重复。单调重复的东西必然有规律可循,有规律可循就可以用代码产生器自动产生代码。

像DCOM和CORBA等也确实是这样做的,先用IDL语言描述出对象的接口,然后用IDL编译器自动产生Proxy和Stub代码,整个过程完全不需要开发人员操心。

打包和解包的专业术语叫做marshal和unmarshal,中文常用翻译为列集和散集。不过这两个词太专业了,翻译成中文之后更加让人不知所云。我想还是用打包和解包两个词更通俗一点。

在以上模型中,调用对象的方法,确实做到了网络透明化。读者可以会问,我要访问对象的属性怎么办呢?对象的属性就是变量,变量就一块内存区域,内存区域在不同的进程里完全是独立的,这看起来确实是一个问题。还记得很多关于软件设计书籍里面讲过的吗:不要暴露对象属性,调用者若要访问对象的属性,通过get/set方法去访问。这样不行了吗,对属性的访问转换为对对象方法的调用。

OK,调用对象的方法和访问对象的属性都解决了。还有重要的一点,如何创建对象呢。因为实际的对象并不固定在某台机器上,它的位置可能是动态的。甚至Proxy本身也不知道Stub运行在哪里。如果要让调用者来指定,创建对象的过程仍未达到网络透明化。通常的做法是引入一个第三方中介,这个第三方中介是固定的,可以通过一定的方法找到它。第三方中介负责在客户端的Proxy和服务器的Stub之间穿针引线。第三方中介通常有两种:一种是只负责帮客户端找到服务器,之后客户端与服务器直接通信。另外一种就是不但负责找到服务器,而且负责转发所有的请求。

以上的模型仍然不完整,因为现实中的对象并不是一直处理于被动的地位。而是在一定的条件下,会主动触发一些事件,并把这些事件上报给调用者。也就是说这是一个双向的动作,单纯的C/S模型无法满足要求,而要采用P2P的方式。原先的客户端同时作为一个服务器存,接受来自己服务器的请求。像COM里就是这样做的,客户端要注册对象的事件,就要实现一个IDispatch接口,给对象反过来调用。

自己实现时还要考虑以下几点:

o 传输抽象层。分布可能是跨进程也可能是跨机器。在不同的情况下,采用不同的通信方式,性能会有所不同。做一个传输抽象层,在不同的情况下,可选用不同的传输方式,是一种好的设计。

o 文本还是二进制。把数据打包成文本还是二进制?打包成文本的好处是,可移植性好,由于人也可以看懂,调试方便。坏处是速度稍慢,打包后的数据大小会明显变大。采用二进制的好处是,速度快,打包后的数据大小与打包前相差不大。坏处是不易调试,可移植性较差。

o 字节顺序和字节对齐。若采用二进制方式传输,可移植性是个问题。因为不同的机器上,字节顺序和字节对齐的方式都有些差异,在数据包中要加入这些说明,以提高可移植性。

by admin at 十二月 02, 2008 02:03 下午

插件式设计的架构模型与实例

           ----Do not call us, we will call you

插件式设计近年来非常流行,其中eclipse起了推波助澜的作用,提到插件式就会不由自主的想到eclipse。其实插件式设计并不是什么新事物,早在几十年前就有了。像X Server就是基于插件式设计的,除了核心功能外,它所有的扩展功能和设备驱动都是以插件方式加入进来的。

基于插件的设计好处很多:把扩展功能从框架中剥离出来,降低了框架的复杂度,让框架更容易实现。扩展功能与框架以一种很松的方式耦合,两者在保持接口不变的情况下,可以独立变化和发布。公开插件接口,让第三方有机会扩展应用程序的功能,有财大家一起发。另外,还可以让开源与闭源共存于一套软件,你的插件是开源还是闭源,完全由你自己决定。

基于插件设计并不神秘,相反它比起一团泥的设计更简单,更容易理解。各种基于插件设计的架构都有自己的特色,但从总体架构上看,其模型都大同小异。这里我们介绍一个简单的模型,并给出几个实例,希望对新手有所启发。

1. 基本架构

插件式设计的应用程序,基本上可以用上图来表示。当然,此图是一种较高层次的表示,实际的设计会更复杂一些。我们在这里为了阐述方便,不用故意搞得那么复杂。

应用程序由应用程序框架、插件接口、插件和公共函数库四部分组成。

应用程序框架负责应用程序的整体运作,它清楚程序整个流程,但并不知道每个过程具体要做什么。它在适当的时候调用一些插件,来完成真正的功能。

插件接口是一个协议,可能用IDL描述,可能是头文件,也可能一段文字说明。插件按照这个协议实现出来,就可以加入到应用程序中来。当然,对于复杂的系统,插件接口可能有多个,各自具有独立的功能。

插件是完成实际功能的实体,实现了要求的插件接口。尽管实现什么以及怎么实现,完全是插件自己的自由。在实际情况来,一般还是有些限制,因为插件接口本身可能就是一个限制。如,实现编译功能的插件,自然不能实现成一个聊天功能的插件。

公共函数库是一组函数或者类,应用程序框架和插件都可以调用。它通常是一个独立的动态库(DLL)。应用程序框架本身是公用的,是代码复用的一种方式。但并不是所有可复用代码都可以放在框架中,特别是插件会用到的公共代码,那会造成插件对框架的依赖。把这些公共代码提取到一个独立的库中,是一种好的方法。

另外,值得补充说明一下的是插件接口。插件接口通常有两种:

通用插件接口:这一类插件接口是通用的,你无法从接口函数看出这个插件的功能。它的接口函数通常有这些函数:

init : 用于初始化插件,通常在插件被加载时调用。

deinit:用于反初始化插件,通常在插件被卸载时调用。

run:让插件起动。

stop:让插件停止。

至于插件要完成什么功能,要插到哪里,在init函数里决定,它调用公共函数库里的函数把自己注册到框架中某个位置。

专用插件接口:这一类插件接口是专用的,看到它的接口函数说明,你就可以大致了解它的功能了。

加入插件的方式通常采用配置信息来实现,配置信息可以是注册表,也可以配置文件。也可以动态注册进来,或者把插件放到指定的位置。

下面我们来看几个实例:

2. 桌面设计

最近一段时间完成了桌面模块的设计和实现。按照以往的经验,桌面模块通常是变化最多的一个模块,SPEC总是在不断的调整的效果,不同客户要求实现具有个性化的桌面,直到产品快发布了,桌面的SPEC还在不停的修改。另外,在智能手机中,桌面占有特殊的地位,很多东西都可能往桌面里塞,桌面不但是各种功能的大杂烩,还是一些系统消息的中转站。

这个任务比较棘手,所以在设计时就分外小心。首先想到的就是采用插件式设计,把外围功能独立出来,尽量简化框架的实现。

插件:每一个最小功能单元都是一个插件,它可以是可见的,也可以是不可的,也可以是动态变化的。比如时间、电池电量、网络连接、信号强弱、新事件(如SMS、MMS、EMAL、ALARM和未接电话等)、应用程序快捷方式、左右操作按钮和其它处理系统事件的功能单元。每个插件都用一个.desktop来描述,这是遵循freedesktop.org的标准的。

桌面框架包括:状态栏、开始菜单、操作栏、桌面区、事件管理器和主题管理器。而状态栏、开始菜单、操作栏、桌面区和事件管理器都是容器,容纳各种插件。对于可见的插件,可以有自己的表现方式,也可以采用通用的表现方式。

公共函数库:一些抽象的类、实现插件的辅助类以及其它一些可能被公用的类。

插件接口:对于不可见的插件要求实现事件处理功能,可见的插件还要求实现绘制功能。

3. 模拟器设计

一个同事负责设计另外一个平台的PC模拟环境设计。在我的建议下,他对架构作了调整。调整后的架构非常简单,也可以认为是插件式的设计,它由下面几部分组成:

应用程序框架:负责模拟器基本功能,如模拟键盘和显示设备、换肤功能等。

插件:就是被模拟的平台,如microwindow及相应的手机应用程序。尽管运行时通常只有一个插件运行,这样做仍然有意义,如果要换成minigui或者其它平台时,模拟器不需要作任何修改。

公共函数库:它由应用程序框架初始化一些信息和回调函数,然后供插件(即microwindow)调用,插件利用它来实现显示和输入等驱动程序。

插件接口:如起动和停止模拟平台等。

4. GIMP

GIMP是一个功能强大的图形图像编辑器,典型的基于插件式的设计,在《unix编程艺术》中,作为插件式设计示例介绍过。

o 应用程序框架:GUI

o 插件:完成图像的各种转换和处理功能,如模糊、去斑和色彩调整等。

o 公共函数库:放在libgimp.so里。
o 插件接口:对GIMP感兴趣的朋友,可以到官方网站上去阅读更多的文档。

by admin at 十二月 02, 2008 01:56 下午

十二月 01, 2008

Absurd

KJAVA虚拟机Hack笔记-实现immutableimage

immutable image在这里的意思是说不能在上面进行绘制操作的图片,比如画直线和填充矩形等等。immutable image实际上就是图片在内存里面的表示,有点像VC中的Bitmap,用GTK+中实现的话,当然首选GdkPixbuf了。

里面最重要的函数要数gxpport_decodeimmutable_from_selfidentifying了,这个函数用来加载图片数据到一个immutable image对象中,,由于图片数据已经读入到内存中了,为了简单起见,我们把它写回到临时文件里,临时文件系统是用内存来模拟的,所以对性能的影响也是不会太大。实现如下:

得到文件格式:

err = imgdcd_image_get_info(srcBuffer, (unsigned int)length, &format, &w, &h);

加载图片到GdkPixbuf中:
对于jpg、png和gif文件,我们用下列方法加载:

pixbuf = gdk_pixbuf_load_from_data("jpg", (const char*)srcBuffer, length);
pixbuf = gdk_pixbuf_load_from_data("png", (const char*)srcBuffer, length);
pixbuf = gdk_pixbuf_load_from_data("gif", (const char*)srcBuffer, length);

gdk_pixbuf_load_from_data的实现就是先把文件写入临时文件,再调用gdk_pixbuf_new_from_file加载文件到GdkPixbuf中。

对于RAW的数据,调用gdk_pixbuf_new_from_data加载。

另就是要检查资源限制:

        w = gdk_pixbuf_get_width(pixbuf);
        h = gdk_pixbuf_get_height(pixbuf);

        res_size = w * h * 4;
        if (midpCheckResourceLimit(RSC_TYPE_IMAGE_IMMUT, res_size) == 0)
        {
            g_object_unref(G_OBJECT(pixbuf));
            *creationErrorPtr = IMG_NATIVE_IMAGE_RESOURCE_LIMIT;
            return;
        }

        *creationErrorPtr = IMG_NATIVE_IMAGE_NO_ERROR;
        midpIncResourceCount(RSC_TYPE_IMAGE_IMMUT, res_size);

如果虚拟机所用的资源超过指定值时,释放GdkPixbuf并返回错误RESOURCE_LIMIT。

其它函数实现就更简单了:

gxpport_createimmutable_from_immutableregion 调用gdk_pixbuf_new_subpixbuf从pixbuf上拷贝一块数据,再根据transform做些转换。

gxpport_decodeimmutable_from_argb 调用gdk_pixbuf_new_from_data生成pixbuf对象。

gxpport_get_immutable_argb 把pixbuf中的数据转换成argb格式的buffer。

gxpport_destroy_immutable 调用g_object_unref就行了。

gxpport_decodeimmutable_to_platformbuffer 原样保存就好了。

gxpport_loadimmutable_from_platformbuffer 调用gxpport_decodeimmutable_from_selfidentifying加载数据。

by admin at 十二月 01, 2008 01:37 下午

十一月 30, 2008

Absurd

系统程序员成长计划-写得又快又好的秘诀(一)

“快”是指开发效率高,“好”是指软件质量高。呵呵,写得又快又好的人就是高手了。记得这是林锐博士下的定义,读他那篇著名的《C/C++高质量编程》时,我还是个初学者,印象特别深。我现在仍然赞同他的观点,不过这里标题改为成为高手的秘诀,感觉就有点像标题党了,所以还是用比较通俗的说法吧。废话少说,请读者回顾一下这段时间的编程经验,回答下面两个问题:

1.快与好是什么关系?写得快就不能写得好?写得好就不能写得快?还是写得好才能写得快?是不是绕晕了?不过这确实是值得思考的问题。

2.我们的时间花在哪里了?记得刚来深圳时到华为面试,面试的人是我的学长。他问我,你一天能写多少行代码?我想了想说,100行吧。他用看外行的眼光看着我说,能写100行吗?我知道说错话了,赶快补充说,嗯,从整个项目来看可能没有吧。他才点了点头。一天只写100行代码?初学者可能觉得不可思议,以同时应付10个网友聊天的速度,写100行代码不用三分钟。不过,经过这段时间的练习后,我们想大家已经明白,敲代码不是花时间最多的地方,那时间又花到哪里去了呢?

by admin at 十一月 30, 2008 10:00 上午

大内高手—栈/堆

o 栈

栈作为一种基本数据结构,我并不感到惊讶,用来实现函数调用,这也司空见惯的作法。直到我试图找到另外一种方式实现递归操作时,我才感叹于它的巧妙。要实现递归操作,不用栈不是不可能,而是找不出比它更优雅的方式。

尽管大多数编译器在优化时,会把常用的参数或者局部变量放入寄存器中。但用栈来管理函数调用时的临时变量(局部变量和参数)是通用做法,前者只是辅助手段,且只在当前函数中使用,一旦调用下一层函数,这些值仍然要存入栈中才行。

通常情况下,栈向下(低地址)增长,每向栈中PUSH一个元素,栈顶就向低地址扩展,每从栈中POP一个元素,栈顶就向高地址回退。一个有兴趣的问题:在x86平台上,栈顶寄存器为ESP,那么ESP的值在是PUSH操作之前修改呢,还是在PUSH操作之后修改呢?PUSH ESP这条指令会向栈中存入什么数据呢?据说x86系列CPU中,除了286外,都是先修改ESP,再压栈的。由于286没有CPUID指令,有的OS用这种方法检查286的型号。

一个函数内的局部变量以及其调用下一级函数的参数,所占用的内存空间作为一个基本的单元,称为一个帧(frame)。在gdb里,f 命令就是用来查看指定帧的信息的。在两个frame之间通过还存有其它信息,比如上一层frame的分界地址(EBP)等。

关于栈的基本知识,就先介绍这么多,我们下面来看看一些关于栈的技巧及应用:

1. backtrace的实现

callstack调试器的基本功能之一,利用此功能,你可以看到各级函数的调用关系。在gdb中,这一功能被称为backtrace,输入bt命令就可以看到当前函数的callstack。它的实现多少有些有趣,我们在这里研究一下。

我们先看看栈的基本模型

参数N -------------↓高地址
参数…--------------函数参数入栈的顺序与具体的调用方式有关
参数 3
参数 2
参数 1
-----------------------------------------------------------
EIP-----------------返回本次调用后,下一条指令的地址
EBP-----------------保存调用者的EBP,然后EBP指向此时的栈顶。
临时变量1
临时变量2
临时变量3
临时变量…
临时变量N-----------↓低地址

要实现callstack我们需要知道以下信息:

o 调用函数时的指令地址(即当时的EIP)。

o 指令地址对应的源代码代码位置。

关于第一点,从上表中,我们可以看出,栈中存有各级EIP的值,我们取出来就行了。用下面的代码可以轻易实现:

#include <stdio.h>

int backtrace(void** BUFFER, int SIZE)
{
    int  n = 0;
    int* p = &n;
    int  i = 0;
    int ebp = p[1];
    int eip = p[2];

    for(i = 0; i < SIZE; i++)
    {
        BUFFER[i] = (void*)eip;

        p = (int*)ebp;

        ebp = p[0];

        eip = p
    }

    return SIZE;
}

#define N 4

static void test2()
{

    int i = 0;
    void* BUFFER[N] = {0};

    backtrace(BUFFER, N);

    for(i = 0; i < N; i++)
    {
        printf("%p\n",  BUFFER[i]);
    }

    return;
}

static void test1()
{
    test2();
}

static void test()
{
    test1();
}

int main(int argc, char* argv[])
{
    test();

    return 0;
}

程序输出:

0x8048460
0x804849c
0x80484a9
0x80484cc

关于第二点,如何把指令地址与行号对应起来,这也很简单。可以从MAP文件或者ELF中查询。Binutil带有一个addr2line的小工具,可以帮助实现这一点。

[root@linux bt]# addr2line  0x804849c -e bt.exe

/root/test/bt/bt.c:42

2. alloca的实现

大家都知道动态分配的内存,一定要释放掉,否则就会有内存泄露。可能鲜有人知,动态分配的内存,可以不用释放。Alloca就是这样一个函数,最后一个a代表auto,即自动释放的意思。

Alloca是在栈中分配内存的。即然是在栈中分配,就像其它在栈中分配的临时变量一样,在当前函数调用完成时,这块内存自动释放。

正如我们前面讲过,栈的大小是有限制的,普通线程的栈只有10M大小,所以在分配时,要量力而行,且不要分配过大内存。

Alloca可能会渐渐的退出历史舞台,原因是新的C/C++标准都支持变长数组。比如int array[n],老版本的编译器要求n是常量,而新编译器允许n是变量。编译器支持的这一功能完全可以取代alloca。

这不是一个标准函数,但像linux和win32等大多数平台都支持。即使少数平台不支持,要自己实现也不难。这里我们简单介绍一下alloca的实现方法。

我们先看看一个小程序,再看看它对应的汇编代码,一切都清楚了。

#include <stdio.h>

int main(int argc, char* argv[])
{
    int  n = 0;
    int* p = alloca(1024);

    printf("&n=%p p=%p\n", &n, p);

    return 0;
}

汇编代码为:

int main(int argc, char* argv[])
{

 8048394:       55                      push   %ebp
 8048395:       89 e5                   mov    %esp,%ebp
 8048397:       83 ec 18                sub    $0x18,%esp
 804839a:       83 e4 f0                and    $0xfffffff0,%esp
 804839d:       b8 00 00 00 00          mov    $0x0,%eax
 80483a2:       83 c0 0f                add    $0xf,%eax
 80483a5:       83 c0 0f                add    $0xf,%eax
 80483a8:       c1 e8 04                shr    $0x4,%eax
 80483ab:       c1 e0 04                shl    $0x4,%eax
 80483ae:       29 c4                   sub    %eax,%esp
        int  n = 0;
 80483b0:       c7 45 fc 00 00 00 00    movl   $0x0,0xfffffffc(%ebp)
        int* p = alloca(1024);
 80483b7:       81 ec 10 04 00 00       sub    $0x410,%esp
 80483bd:       8d 44 24 0c             lea    0xc(%esp),%eax
 80483c1:       83 c0 0f                add    $0xf,%eax
 80483c4:       c1 e8 04                shr    $0x4,%eax
 80483c7:       c1 e0 04                shl    $0x4,%eax
 80483ca:       89 45 f8                mov    %eax,0xfffffff8(%ebp)

        printf("&n=%p p=%p\n", &n, p);
 80483cd:       8b 45 f8                mov    0xfffffff8(%ebp),%eax
 80483d0:       89 44 24 08             mov    %eax,0x8(%esp)
 80483d4:       8d 45 fc                lea    0xfffffffc(%ebp),%eax
 80483d7:       89 44 24 04             mov    %eax,0x4(%esp)
 80483db:       c7 04 24 98 84 04 08    movl   $0x8048498,(%esp)
 80483e2:       e8 d1 fe ff ff          call   80482b8 <printf@plt>
        return 0;
 80483e7:       b8 00 00 00 00          mov    $0x0,%eax

}

其中关键的一条指令为:sub $0×410,%esp

由此可以看出实现alloca,仅仅是把ESP减去指定大小,扩大栈空间(记记住栈是向下增长),这块空间就是分配的内存。

3. 可变参数的实现。

对新手来说,可变参数的函数也是比较神奇。还是以一个小程序来说明它的实现。

#include <stdio.h>
#include <stdarg.h>

int print(const char* fmt, ...)
{
    int n1 = 0;
    int n2 = 0;
    int n3 = 0;
    va_list ap;
    va_start(ap, fmt);

    n1 = va_arg(ap, int);
    n2 = va_arg(ap, int);
    n3 = va_arg(ap, int);

    va_end(ap);

    printf("n1=%d n2=%d n3=%d\n", n1, n2, n3);

    return 0;
}

int main(int arg, char argv[])
{
    print("%d\n", 1, 2, 3);

    return 0;
}

我们看看对应的汇编代码:

int print(const char* fmt, ...)
{

 8048394:       55                      push   %ebp
 8048395:       89 e5                   mov    %esp,%ebp
 8048397:       83 ec 28                sub    $0x28,%esp
        int n1 = 0;
 804839a:       c7 45 fc 00 00 00 00    movl   $0x0,0xfffffffc(%ebp)
        int n2 = 0;
 80483a1:       c7 45 f8 00 00 00 00    movl   $0x0,0xfffffff8(%ebp)
        int n3 = 0;
 80483a8:       c7 45 f4 00 00 00 00    movl   $0x0,0xfffffff4(%ebp)
        va_list ap;
        va_start(ap, fmt);
 80483af:       8d 45 0c                lea    0xc(%ebp),%eax
 80483b2:       89 45 f0                mov    %eax,0xfffffff0(%ebp)

        n1 = va_arg(ap, int);
 80483b5:       8b 55 f0                mov    0xfffffff0(%ebp),%edx
 80483b8:       8d 45 f0                lea    0xfffffff0(%ebp),%eax
 80483bb:       83 00 04                addl   $0x4,(%eax)
 80483be:       8b 02                   mov    (%edx),%eax
 80483c0:       89 45 fc                mov    %eax,0xfffffffc(%ebp)
        n2 = va_arg(ap, int);
 80483c3:       8b 55 f0                mov    0xfffffff0(%ebp),%edx
 80483c6:       8d 45 f0                lea    0xfffffff0(%ebp),%eax
 80483c9:       83 00 04                addl   $0x4,(%eax)
 80483cc:       8b 02                   mov    (%edx),%eax
 80483ce:       89 45 f8                mov    %eax,0xfffffff8(%ebp)
        n3 = va_arg(ap, int);

 80483d1:       8b 55 f0                mov    0xfffffff0(%ebp),%edx
 80483d4:       8d 45 f0                lea    0xfffffff0(%ebp),%eax
 80483d7:       83 00 04                addl   $0x4,(%eax)
 80483da:       8b 02                   mov    (%edx),%eax
 80483dc:       89 45 f4                mov    %eax,0xfffffff4(%ebp)

       va_end(ap);
       printf("n1=%d n2=%d n3=%d\n", n1, n2, n3);
 80483df:       8b 45 f4                mov    0xfffffff4(%ebp),%eax
 80483e2:       89 44 24 0c             mov    %eax,0xc(%esp)
 80483e6:       8b 45 f8                mov    0xfffffff8(%ebp),%eax
 80483e9:       89 44 24 08             mov    %eax,0x8(%esp)
 80483ed:       8b 45 fc                mov    0xfffffffc(%ebp),%eax
 80483f0:       89 44 24 04             mov    %eax,0x4(%esp)
 80483f4:       c7 04 24 f8 84 04 08    movl   $0x80484f8,(%esp)
 80483fb:       e8 b8 fe ff ff          call   80482b8 <printf@plt>

        return 0;
 8048400:       b8 00 00 00 00          mov    $0x0,%eax
}

int main(int arg, char argv[])
{
 8048407:       55                      push   %ebp
 8048408:       89 e5                   mov    %esp,%ebp
 804840a:       83 ec 18                sub    $0x18,%esp
 804840d:       83 e4 f0                and    $0xfffffff0,%esp
 8048410:       b8 00 00 00 00          mov    $0x0,%eax
 8048415:       83 c0 0f                add    $0xf,%eax
 8048418:       83 c0 0f                add    $0xf,%eax
 804841b:       c1 e8 04                shr    $0x4,%eax
 804841e:       c1 e0 04                shl    $0x4,%eax
 8048421:       29 c4                   sub    %eax,%esp
        int n = print("%d\n", 1, 2, 3);
 8048423:       c7 44 24 0c 03 00 00    movl   $0x3,0xc(%esp)
 804842a:       00
 804842b:       c7 44 24 08 02 00 00    movl   $0x2,0x8(%esp)
 8048432:       00
 8048433:       c7 44 24 04 01 00 00    movl   $0x1,0x4(%esp)
 804843a:       00
 804843b:       c7 04 24 0b 85 04 08    movl   $0x804850b,(%esp)
 8048442:       e8 4d ff ff ff          call   8048394 <print>
 8048447:       89 45 fc                mov    %eax,0xfffffffc(%ebp)

        return 0;
 804844a:       b8 00 00 00 00          mov    $0x0,%eax

}

从汇编代码中,我们可以看出,参数是逆序入栈的。在取参数时,先让ap指向第一个参数,又因为栈是向下增长的,不断把指针向上移动就可以取出所有参数了。

o 堆

在内存分配算法一节中再详细讲解。

by admin at 十一月 30, 2008 09:59 上午

大内高手—内存模型

了解linux的内存模型,或许不能让你大幅度提高编程能力,但是作为一个基本知识点应该熟悉。坐火车外出旅行时,即时你对沿途的地方一无所知,仍然可以到达目标地。但是你对整个路途都很比较清楚的话,每到一个站都知道自己在哪里,知道当地的风土人情,对比一下所见所想,旅程可能更有趣一些。

类似的,了解linux的内存模型,你知道每块内存,每个变量,在系统中处于什么样的位置。这同样会让你心情愉快,知道这些,有时还会让你的生活轻更松些。看看变量的地址,你可以大致断定这是否是一个有效的地址。一个变量被破坏了,你可以大致推断谁是犯罪嫌疑人。

Linux的内存模型,一般为:

地址------------------------作用--------------------------说明

>=0xc0000000---------内核虚拟存储器-------------用户代码不可见区域
< 0xc0000000----------Stack(用户栈)------------ESP指向栈顶,向下增长
-------------------------------------------------空闲内存
>=0x40000000---------文件映射区------------------mmap的空间
< 0x40000000-------------------------------------空闲内存
---------------------Heap(运行时堆)--------------通过brk/sbrk系统调用扩大堆,向上增长。
---------------------.data、.bss(读写段)---------从可执行文件中加载
>=0x08048000---------.init、.text、.rodata-------从可执行文件中加载
< 0x08048000---------保留区域--------------------

很多书上都有类似的描述,本图取自于《深入理解计算机系统》p603,略做修改。本图比较清析,很容易理解,但仍然有两点不足。下面补充说明一下:

1. 第一点是关于运行时堆的。

为说明这个问题,我们先运行一个测试程序,并观察其结果:

#include <stdio.h>

int main(int argc, char* argv[])
{

    int  first = 0;
    int* p0 = malloc(1024);
    int* p1 = malloc(1024 * 1024);
    int* p2 = malloc(512 * 1024 * 1024 );
    int* p3 = malloc(1024 * 1024 * 1024 );

    printf("main=%p print=%p\n", main, printf);
    printf("first=%p\n", &first);
    printf("p0=%p p1=%p p2=%p p3=%p\n", p0, p1, p2, p3);

    getchar();

    return 0;
}

运行后,输出结果为:

main=0x8048404 print=0x8048324
first=0xbfcd1264
p0=0x9253008 p1=0xb7ec0008 p2=0x97ebf008 p3=0x57ebe008

o main和print两个函数是代码段(.text)的,其地址符合表一的描述。

o first是第一个临时变量,由于在first之前还有一些环境变量,它的值并非0xbfffffff,而是0xbfcd1264,这是正常的。

o p0是在堆中分配的,其地址小于0×4000 0000,这也是正常的。

o 但p1和p2也是在堆中分配的,而其地址竟大于0×4000 0000,与表一描述不符。

原因在于:运行时堆的位置与内存管理算法相关,也就是与malloc的实现相关。关于内存管理算法的问题,我们在后继文章中有详细描述,这里只作简要说明。在glibc实现的内存管理算法中,Malloc小块内存是在小于0×4000 0000的内存中分配的,通过brk/sbrk不断向上扩展,而分配大块内存,malloc直接通过系统调用mmap实现,分配得到的地址在文件映射区,所以其地址大于0×4000 0000。

从maps文件中可以清楚的看到一点:

00514000-00515000 r-xp 00514000 00:00 0
00624000-0063e000 r-xp 00000000 03:01 718192     /lib/ld-2.3.5.so
0063e000-0063f000 r-xp 00019000 03:01 718192     /lib/ld-2.3.5.so
0063f000-00640000 rwxp 0001a000 03:01 718192     /lib/ld-2.3.5.so
00642000-00766000 r-xp 00000000 03:01 718193     /lib/libc-2.3.5.so
00766000-00768000 r-xp 00124000 03:01 718193     /lib/libc-2.3.5.so
00768000-0076a000 rwxp 00126000 03:01 718193     /lib/libc-2.3.5.so
0076a000-0076c000 rwxp 0076a000 00:00 0
08048000-08049000 r-xp 00000000 03:01 1307138    /root/test/mem/t.exe
08049000-0804a000 rw-p 00000000 03:01 1307138    /root/test/mem/t.exe
09f5d000-09f7e000 rw-p 09f5d000 00:00 0          [heap]
57e2f000-b7f35000 rw-p 57e2f000 00:00 0
b7f44000-b7f45000 rw-p b7f44000 00:00 0
bfb2f000-bfb45000 rw-p bfb2f000 00:00 0          [stack]

2. 第二是关于多线程的。

现在的应用程序,多线程的居多。表一所描述的模型无法适用于多线程环境。按表一所述,程序最多拥有上G的栈空间,事实上,在多线程情况下,能用的栈空间是非常有限的。为了说明这个问题,我们再看另外一个测试:

#include <stdio.h>
#include <pthread.h>

void* thread_proc(void* param)
{
    int  first = 0;
    int* p0 = malloc(1024);
    int* p1 = malloc(1024 * 1024);

    printf("(0x%x): first=%p\n",    pthread_self(), &first);
    printf("(0x%x): p0=%p p1=%p \n", pthread_self(), p0, p1);

    return 0;
}

#define N 5

int main(int argc, char* argv[])
{

    int first = 0;
    int i= 0;
    void* ret = NULL;
    pthread_t tid[N] = {0};