论坛首页 编程语言技术论坛

在ANSI C下设计和实现简便通用signal-slot机制——一种平台相关但易于移植的,lambda表达式风格的,经由抵抗编译器而得的方案

浏览 12416 次
精华帖 (15) :: 良好帖 (0) :: 新手帖 (0) :: 隐藏帖 (0)
作者 正文
   发表时间:2009-10-19  
大家肯定都能想到这要通过宏来实现,虽然书上万遍提醒宏不能滥用——不知道这个规则哪里来的(1),简直是舍本逐末——但是我了解下来,很多人是根本不用宏,也不会用宏。再不让用,等于就废了令代码更规范的一个用力工具。没关系,我也曾经对宏产生出惧怕的心理,特别是在经历过噩梦一般的MFC后。

我们先把宏要展开的东西确定了之后再来想办法。前面已经有了Signal类型的例子,我们需要展开的只是可变的那个部分——就是像这样:

int var1;
float var2;
...

直到结构定义结束的大括号前的全部附加数据字段。因为分号是合法的分隔符,所以完全可以把这部分字段写在一行里,那就是:int var1; float var2; ...。不难看出,如果让用户来填充这部分内容,我们只需要把预定义的Signal类型跟用户定义部分连接在一起就可以了,如下:

#define SIGNAL(a) struct { Signal; a }

这里假设Signal是内部使用的数据类型。因为我们希望用户直接可以写”SIGNAL(int a) signal_a;“这样直接来声明一个signal,代码会少很多,所以采用匿名结构类型。

这太容易了,但离我们要的整洁程度还有一点点距离,能不能类似于boost用的占位符来缺省表示这些变量名,这样用户就只需要填写类型就可以了,天长日久又能少写不少代码——这是懒人们的想法。就是SIGNAL(int, float) 能展开为

struct { Signal; int _1; int _2 }

宏展开后肯定是一行,所以就直接写成一行了。

这得用上boost.preprocess——噗通,有人会不会大跌眼镜。这可能有两个因素,一是boost是个C++库,二是了解一二的人可能会对它使用的技巧感到头晕目眩。我是属于后者,前者实际上不用担心,因为在宏这方面,相对于C目前的C++没有任何的改进,甚至还没有引入C99的变参宏。

我和大多数又懒又笨的人有着类似的想法,讨厌“玩弄”技巧。所以在整个后面过程中不会有什么花样,都是一些极为基本的想法和知识。

但是每每看到牛人们把技巧玩弄成一种系统的“知识”时,还是不得不心悦诚服。boost.proprocess就具有这样的特点。我们知道宏是不允许嵌套的,所以没人去想到像写程序一样去写宏,比如循环、递归这样的结构。通过boost.preprocess可以定义这样的宏,比如做加法运算,就是类似:

SUM(1, 2),得到的结果是3
SUM(1, 2, 3, 4),得到的结果是10

注意这是宏变换出来的结果,是不是简直匪夷所思——一个文本替换的工具可以进行运算。

boost.proprecess是boost里所有库中被依赖最重的部分之一,通过宏,可以把一些库的最小接口归纳出来,极大的方便了使用,比如前面举例的BOOST_FOREACH宏。

有关实现机理这里有一篇网上的文章(2)说的比较简洁明了,最后的关键在于循环展开,类似于穷举法。还可以看一眼boost.binary(3)——类似手段的一个应用。

其实无论它自己的实现中包含怎样的技巧,使用它还是很容易的,这符合我前面的关于boost的观点。可以参考boost核心成员所作的入门指导(4)(作为《C++ Template Metaprogramming》这本书的附录,也可以看出其重要性)。关于boost.proprecess就介绍到这里,让我们用实现代码直接举例吧。

我们定义一个被循环调用的宏,实际过程中如果不知道要做什么,这一步可以放在后头做。

#define __SIGNAL_FILEDS(z, n, seq) \
  BOOST_PP_SEQ_ELEM(n, seq) \
  BOOST_PP_CAT(_, BOOST_PP_INC(n)); \

注:前面加上两个下划线,是因为我们希望它只局限于内部使用同时避免跟外界重名,这被称为“丑化”(ugly)。在库中如果所写的代码可能暴露到外部,但希望用户不要访问,一般会这么做,提醒到用户这不是一个接口。

然后,定义循环体来调用它就可以了。

#define SIGNAL(n, tuple) \
  struct { \
    BOOST_PP_REPEAT(n, __SIGNAL_FILEDS, BOOST_PP_TUPLE_TO_SEQ(n, tuple)) \
  } \

首先解释一下这里用到的数据结构。最基本是SEQUENCE(简写为SEQ),相当于一个可以反复展开的宏参数顺序列表(a)(b)(c)...,这些括号中的文本构成了一个序列,可以供预定义好的一些宏来访问,比如BOOST_PP_SEQ_ELEM(n, seq),用于访问(展开)这样列表中的第n个参数。

然后类似于for或者while等流程控制语句,BOOST_PP_REEAT可以对一个SEQUENCE进行循环遍历,第一个参数是循环次数,第二个参数是调用的宏,第三个参数就是一个SEQUENCE。

还有数据结构TUPLE或者ARRAY,它们是直接被逗号分隔的宏参数,类似于(a, b, c...)或者(3, (a, b, c)),这里的3表示TUPLE的大小。受制于宏的机制,在预处理中无法直接得出(a, b, c)这样一个列表中参数的数目,所以要额外提供这么一个参数。

可以在这些类似集合的数据结构上做一些同样的操作,它们之间也可以相互转换。例如上面用的BOOST_PP_TUPLE_TO_SEQ宏用于把一个TUPLE转换成SEQ,以被循环所接受。

调用的宏包含3个参数,第一个是下次循环到达的次数,用于优化目的,一般可以不用理会。然后是本次循环次数n,和SEQUENCE集合。

剩下的BOOST_PP_INC用于对一个数字进行加1运算,然后BOOST_PP_CAT可以把两段文本连接为一整个,功能上和宏的##运算符一致,但跟##不展开任何参数不同,它允许参数本身也是一个宏。组合它们就逐一产生出所要的_1, _2等所要的变量名来。

所以如果写

SIGNAL(2,(int, float))

就生成

struct { int _1; float _2; }

比起直接写是不是要简洁一些,这里稍微有些难堪的是”2"这个参数。前面已经说明过为什么要有它了,如果使用SEQUENCE的话可以省略掉,但必须类似这样来写:

SIGNAL(int)(float)

考虑到一般人使用参数的习惯,我偏向保留使用TUPLE的写法。可以通过定义一系列的宏来简略掉参数,就是:

#define SIGNAL0() SIGNAL(0, ())
#define SIGNAL1(...) SIGNAL(1, (__VA_ARGS__))
#define SIGNAL2(...) SIGNAL(2, (__VA_ARGS__))
...

然后就可以用SIGNAL2(int, float)来代替SIGNAL(2, (int, float))。

不要责备这里使用了C99的变参宏扩展,如果你的编译器不支持,完全可以把参数写全,我只是稍微偷了些懒。我倒是想,要是变参宏能够提供一个额外的参数数目来,再结合boost.proprecess那该多漂亮啦。

好了,你可以把这些宏录入到测试代码中,一般编译器提供有-E以及-P的参数——gcc当然支持,可以展开宏,试试是不是有些意思。

聪明人对比这后面的写法跟前面的写法,会发现没啥大的差别——后者只是少些几个变量名而已,还绕了不小的圈子。这里这么写也是为了后面其他地方也可能要用到有关,如果觉得不好,完全可以按自己的喜好来。不久还可以看到,前面写法的相比也有一个好处——用户可以完全按自己定义的名字去访问参数字段。

(1) 有一个说法是:Bjarne Stroustrup在担任AT&T大规模程序设计部门负责人的期间发现,将近50%的问题是由宏引起的。
(2) http://www.cppblog.com/kevinlynx/archive/2008/08/20/59451.html
(3) http://www.boost.org/doc/libs/1_38_0/boost/utility/binary.hpp
(4) http://www.boostpro.com/tmpbook/preprocessor.html
0 请登录后投票
   发表时间:2009-10-22   最后修改:2009-10-22
有了signal,下面该是slot了。且慢,经过前面的分析,我们知道slot相似的部分应该不是什么问题,那些不清楚的地方还不是十分明朗,我们先把周边琐碎的问题处理掉,最后再集中解决它。

沿着signal的部分往下,已经知道了signal的定义,我们如何发出它。我们知道大概样子应该是SIGNAL_EMIT(实际参数列表)。这里的实际参数列表对应着前面我们定义的占位符字段,也就是_1,_2,等等。这很容易,就是一些赋值语句,最后应该有一个调用slot的地方,我们假设它是一个函数,大约是signal_emit(Signal *)这种形式,因为参数已经包括在Signal类型里了。所以SIGNAL_EMIT宏展开的形式是——我们以SIGNAL2(int, float)这个类型的signal举例,也就是如果SIGNAL_EMIT(signal, 10, 0.5)展开会得到:

signal._1 = 10;
signal._2 = 0.5;
signal_emit(&signal);

考虑到实际使用中我们不一定能访问了实例,而对于实例指针却不存在问题,所以实际的代码应该是以指针为参数,也就是SIGNAL_EMIT(signal_ptr, 10, 0.5),这里使用signal_ptr明确含义,同时也避免一些可能的重名:

(signal_ptr)->_1 = 10;
(signal_ptr)->_2 = 0.5;
signal_emit(signal_ptr);

对指针的形式不了解的情况下把它用括号括起来是个好习惯,比如如果使用者这样调用宏:SIGNAL_EMIT(&signal, 10, 0.5),没有括号的话展开的代码就错了。

好了,有了展开式,写宏就是体力活了,参考之前的循环,我们不然得出:

#define __SIGNAL_PARAMS(z, n, signal_params) \
  BOOST_PP_CAT((BOOST_PP_TUPLE_ELEM(2, 0, signal_params))->_, BOOST_PP_INC(n)) \
  = \
  BOOST_PP_SEQ_ELEM(n, BOOST_PP_TUPLE_ELEM(2, 1, signal_params));

#define SIGNAL_EMIT(n, signal_ptr, params) \
  BOOST_PP_REPEAT(n, __SIGNAL_PARAMS, (signal_ptr, BOOST_PP_TUPLE_TO_SEQ(n, params))) \
  __signal_emit((Signal *)(signal_ptr));

最后一行的调用形式还不确定,我们先大概写一下。

然后类似的,为了使用上的方便,我们定义一系列不同数目参数的宏:

#define SIGNAL0_EMIT(signal_ptr) SIGNAL_EMIT(0, signal_ptr, ())
#define SIGNAL1_EMIT(signal_ptr, ...) SIGNAL_EMIT(1, signal_ptr, (__VA_ARGS__))
#define SIGNAL2_EMIT(signal_ptr, ...) SIGNAL_EMIT(2, signal_ptr, (__VA_ARGS__))
#define SIGNAL3_EMIT(signal_ptr, ...) SIGNAL_EMIT(3, signal_ptr, (__VA_ARGS__))
#define SIGNAL4_EMIT(signal_ptr, ...) SIGNAL_EMIT(4, signal_ptr, (__VA_ARGS__))
#define SIGNAL5_EMIT(signal_ptr, ...) SIGNAL_EMIT(5, signal_ptr, (__VA_ARGS__))
#define SIGNAL6_EMIT(signal_ptr, ...) SIGNAL_EMIT(6, signal_ptr, (__VA_ARGS__))
#define SIGNAL7_EMIT(signal_ptr, ...) SIGNAL_EMIT(7, signal_ptr, (__VA_ARGS__))
#define SIGNAL8_EMIT(signal_ptr, ...) SIGNAL_EMIT(8, signal_ptr, (__VA_ARGS__))
#define SIGNAL9_EMIT(signal_ptr, ...) SIGNAL_EMIT(9, signal_ptr, (__VA_ARGS__))

这里的“...”依然是C99的变参宏形式,如果觉得不妥,可以把1-9个参数都写全。是不是感到有所不爽?我也一样。继续说上一二,这里有两个问题,一个是下面的定义跟上面有重复之嫌,比如对于C89而言既然要写全所有的参数,那么宏完全可以直接在下面定义就可以了,不需要上面的循环过程。还有一个是,如果还有更多的参数怎么办?理论上参数可以无限递增下去。

对于前者,的确没啥好的理由去解释,毕竟要受制于宏。但牛人们都喜欢这么用,看BOOST里的代码,同时都会有两种形式。如果碰上对宏支持有缺陷的编译器时,就一个个手写宏,否则就采用循环。也许他们和我一样骨子里希望有个什么可以偷懒的法子吧。

早期写程序,看到厂家提供的案例,一个简单的问题都定义出无数的函数和过程来,一大堆参数调用来调用去,自己写往往就是一串语句下来直接解决掉。随着项目做的多了,渐渐就明白那样的好处了——如果过程中一旦出现什么变化,别人的代码会改动非常小,或者几乎没有改动,只是参数上的调整而已。而自己的代码就得彻底推到重来,这就是因为自己就没有对代码的行为归纳总结。说到底,是自己考虑问题的时候,没有考虑可能出现的变化。设计模式也是如此,灵活性来自于哪里?就来自于你对问题可能发生的变化都考虑到了,体现到代码上就是各个调用接口,写代码的工作相当于教会计算机帮我们做事情。

那么对于参数数目上的意见呢?以前看到人家项目的代码里,经常出些类似的一堆不同参数的LOG定义,非常不以为然。慢慢的也可以理解了,毕竟实际我们遇到的大多数问题是普通的问题,那种极端的问题再特别对付也不迟。就算是编译器内部也是存在限制的,只是限制远远超出我们潜在的需求,所以不必为此吹毛求疵。

废话一堆,言归正传。对于这里使用的宏名称我不是十分有把握,如果大家有什么更好的建议可以提出来,接下来对于具体一个SIGNAL2(int, float) signal的调用就是:

SIGNAL2_EMIT(10, 0.5);

至于后面具体怎么做的,要不可以不用关心,要不就跟着后面慢慢了解吧。

signal_emit的实现必然离不开slot,那那我们看看signal_connect呢,这就该是定义slot的地方了,更是离不开,所以不可避免的,我们要进入整个过程中最关键的部分:如何生成一个slot?

让我们先大概比划一下,模仿boost.signal所做的,假设我们有个signal——这个就是具体的名字吧,我们需要:

signal.connect(bind(...));

bind部分生成了一个函数对象作为slot,在C里面我们实现不了,但如果能达到我们的目标的话,格式应该是类似的:

SIGNAL_CONNECT(signal, bind(...));

对一个对象,我们可以试图用结构来代替,前面我们也说过slot携带参数的问题,所以应该跟Signal类型是类似的,这个类型的实例可以通过几个方式——一个是在signal_connect里生成,一个是bind返回,还有就是预先定义好——来完成。

最后一个灵活性最大,我们实验过程中就先采用它吧。

那么bind的形式可能是bind(slot, ...)这个样子,无论是宏还是函数,这里必须生成一些必要的数据提交给signal,以最终在signal_emit的时候可以调用某个过程。因为即便是宏,也只能产生本地的代码,这些代码是无法提交到signal_emit过程里的。而signal_emit本身是不能参与到连接过程中的,那样就达不到解耦的目的。

从前面boost.signal的例子我们可以了解到,这样一个过程生成的至少应该是对一个函数指针的绑定,那么我们如何在signal_emit中调用这样一个函数呢?它的参数应该来自于signal和slot的组合,这肯定是变化的。

那么假设signal_emit可以调用一个变参函数,C标准中有<stdarg.h>,允许我们使用一些宏来获取这些变化的参数,最终我们再转换到对函数的调用,这依然逃脱不了怎么产生调用的过程,因为不同函数,参数数目和大小是不同的,必须有明确的参数列表,编译器才能够产生出正确的调用代码来。

让我们进一步分析,我们知道大部分调用是通过栈来传递参数的,如果相同类型或大小的参数,在调用栈中出现的位置也应该是一致的。因此变参函数中可以不考虑参数的类型,可以按照其大小来获取参数,可以预定义一系列尺寸的类型来适配它。但是最终的函数调用是不能这么做的,因为调用参数的数目和大小是一个排列组合问题,很快就膨胀到无法罗列的地步。在C标准中没有规定变参函数和普通函数参数间的匹配问题,所以也无法用变参函数来代替普通函数。在gcc中如果强行这么做编译器会给出一个警告,并在执行到掉用时直接挂起程序。

再分析下去,就要考虑到不同编译器不同平台上的调用约定,或者叫二进制应用接口(ABI)(1),这肯定是做不到兼容的,而且对于大多数程序员而言这也超出了他们需要了解的范畴,这样的代码移植起来是十分困难的。比如x86平台上参数大多数是通过栈传递的,而对于我手头的ARM这样的RISC平台,可以利用寄存器多的优势,把前三个参数通过寄存器传递。

也许是我的能力还没有到达掌握它的地步,总之,这条路是走不下去了。

让我们回到连接过程再考虑考虑,如果不考虑函数绑定的问题,我们需要的大概是这样形式的调用:

SIGNAL_CONNECT(signal, slot, ...);

省略号代表信号发出时我们需要执行的语句,那么如果我们直接用实际的程序代码来代替会怎么样呢?这样,前面讨论如何调用函数根本不是个问题,直接写调用代码就完事了,既然可以写调用代码,那写其他代码也不是问题,对编译器来看肯定都是一样的。这很自然的让我想到了语句块,也就是假设我们有被大括号括起来的这么一些语句表达式:{ ... },我们需要的是在signal发生的时候,程序能够进入到这个语句块中进行执行,并在末尾出返回到signal发生的地方——一般就是signal_emit函数体里。

继续来看signal_connect过程,也就是上面SIGNAL_CONNECT(...)宏展开后的形式是:

signal_connect(signal, slot);
{
...
}
下面就是signal发出时才执行的代码,在正常程序流程时,connect完毕,会顺序执行它们,这一般肯定是我们不想要的结果,但这不是问题,最简单的方法我们可以用一个goto语句跳过它,就是:

signal_connect(signal, slot);
goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
...
}
END_OF_SLOT:

接下来一个问题是,我该如何返回呢,就是在signal发出,执行到花括号结束后能够返回到发出的函数体里。

我们有没有在运行时让程序可以跳来跳去的办法呢?相对于前面说的,这样我们的大脑就不用跳来跳去了。

(1) http://en.wikipedia.org/wiki/Application_binary_interface
0 请登录后投票
   发表时间:2009-10-28  
本应该是昨天写的,结果被一封来信打断了我的生活——这跟程序一样,在不停被打断的过程中执行着。

出发到现在,大家肯定被平淡的旅途折磨累了,但每个人都明白真正考验人的地方还没有到。前方就是高山丛林,狂风巨浪,还有那妖魔诱人心弦的歌声;阳光沿着高耸的雪山照射下来,散出耀眼的光芒;头顶上,巨鹰盘旋着,时时做出俯冲直下的态势。。。仿佛有声音在呼唤:好吧,一起来战胜艰险吧。

上面的想法是如此的直接,以至于在我的脑海里它似乎已经存在了很多年,恍惚记忆中是非常久远的事情了;又或者反复考量过它很多回,但从来没有被实现过。

说起前面那种跨越语句块的跳转,对C比较熟悉,或者在linux/unix下做过开发的人大多都会想到setjmp/longjmp函数。相对于goto语句,这种跳转被称作长跳转。如果你还不知道它有什么用,不妨去看看王胜祥写的系列文章(1)。

C前辈高手们留话说:longjmp和setjmp玩得不熟,就不要自称为C语言高手。(2)当然这是仁者见仁智者见智的事,实际上如果不是linux/unix下signal中断(系统信号)的需要,很多人恐怕都不会遇上它们。而且这对和系统底层有着密切联系的函数竟然出现在C标准中也多少让我感到有些诧异。莫非那些制定标准的大牛们认定了语言里需要这样一种机制?不管那么多,能帮助我完成后面的工作那就要谢谢它。

我们先不细究它们,简单讲一下setjmp/longjmp的标准用法。setjmp函数用于把程序当前状态保存在一个类型为jmpbuf的缓冲区里,然后在程序的任何地方我们都可以通过以它为参数调用longjmp函数来跳转到之前调用setjmp函数的地方,并且以另外一个整数值参数作为那个setjmp函数的返回值——因为规定第一次调用setjmp函数的返回值为0,所以可以区分出程序是顺序执行下来的还是从其他地方跳转回来的。

初次了解到这个旮旯的话,肯定立刻恍然大悟了:前面我们要的功能实现起来十分简单啊。那我就把代码罗列出来吧。

首先,我们需要在signal和slot结构里增加上述jmpbuf的字段以供前后两次跳转使用。

我们定义基本的Signal和Slot结构类型(定义前需要包含头文件#include <setjmp.h>):

struct __Signal {
  jmp_buf environment;
  struct __Slot *slot;
};

struct __Slot {
  jmp_buf environment;
  struct __Signal *signal;
};

除了供跳转用的jmpbuf字段,在signal和slot的结构中还各自包含对方的指针,一个方便signal发出时调用,一个用于记录signal-slot对的连接。

connect过程简单如下:

void __signal_connect(struct __Signal *signal, struct __Slot *slot)
{
signal->slot = slot;
slot->signal = signal;
}

然后根据前面说的,就可以写出一个大致功能的signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  if (setjmp(signal->environment) == 0) {  // 这里设置返回点
    /* 跳转到slot代码处,取1为入口返回值 */
    longjmp(signal->slot->environment, 1);
  }
}

disconnect过程暂不关心,略过。

为了方便追踪,我们在代码中先不用宏,但尽量模拟宏展开后的样子,所以测试程序如下,方便起见,前后代码就都放在一个c文件里:

// 声明一个带有两个参数,分别为int,float型的signal
struct {
struct __Signal signal;
int _1;
float _2;
} signal;

int main()
{
struct __Slot slot;  // slot暂不携带其他信息

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
__signal_connect((struct __Signal *)&signal, &slot);
if (setjmp(slot.environment) != 0) {  // 如果是跳转而来的话
   /* 这里就是slot代码了,添加打印信息,表示成功 */
   printf("int=%d, float=%f\n", signal._1, signal._2);  // 看看我们的占位符是如何使用的:)
}
/* 这里SIGNAL_CONNECT结束 */

/* 以下是顺序执行的代码,我们模拟SIGNAL_EMIT宏展开的代码调用前面的signal-slot */
signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);  // 调用处只需要signal的信息

/* 大功告成 */
return 0;
}

代码是如此简单,我稍微解释一下之前相关的部分——就是占位符。因为我们的占位符实际上就是结构的成员,所以代码使用起来就是访问结构成员的写法,是不是失去了一些神秘感?我之前说到过signal参数命名的问题。在这种情况下,似乎用有意义的名字反而更好些,比如int代表是数量,而float代表是价格,是不是分别用amount和price来的更清楚些?当然也可以把这个信息体现在signal的命名上,但那样名字又会太长;如果交由用户命名,signal_emit部分的赋值代码用户也必须负责(也可能会少一些赋值语句)。总的来说,我认为它们没有太多优劣之分,所以后面还会坚持使用_1,_2。。。这样的形式,就当是坚持一个梦想吧。

好了,现在可以编译运行一下——“怎么?”。比我聪明的人肯定立即就察觉到这样的程序肯定过不了关;如果你和我一样笨,那也没关系,运行一下就知道了。如果你对setjmp和longjmp是怎么回事并不清楚,那么跟着也就慢慢知道的。

上述程序在不同的编译器和优化参数下会有不同的结果,我们唯一能确认到的地方就是的确能执行到slot里的代码——打印出信息来,但后面就不能按设想的正常工作了。这究竟是怎么回事?我们必须了解到setjmp/longjmp的工作机理了。

由计算机结构原理可知反应计算机当前执行状态的信息莫过于就是一堆寄存器了,计算机通过这些寄存器的状态变换来逐一执行机器指令。所以对于内存不发生变化的情况下,一个寄存器集合就相当于当前上下文环境的一个快照(这也是为什么我们把jmpbuf字段命名为environment,或者也可以叫context)。如果进一步理解程序的执行,我们可以看出来,这里头最关键的部分是对堆栈部分的保存和恢复,因为函数的调用和返回必须通过堆栈来实现。所以setjmp和longjmp函数就是完成了这样一些工作来做到跳转到之前的某个工作现场的。

那么如果来回跳转会发生什么情况呢,相当于我们需要跳转到过去,做一些事然后再跳转回来;或者对于setjmp/longjmp这么一对匹配的调用来说,我们需要在一对和一对之间形成交叉,这种事情常常是有些令人不放心的。

这里我们就分析最关键的栈的变化情况,如下示意的,一个栈的空间变化是后进先出,这可以用于在函数调用之间保存局部变量,调用参数和返回地址,当一个调用产生时,我们可以说生成一个新的栈帧来对应它。

(图三)

对于signal-slot而言,slot所在的函数其栈帧在前,而调用signal的函数栈帧在后,对于上面的程序而言就分别是main和__signal_emit函数的栈帧,因为当signal调用产生时,需要跳转到之前的main函数中,也就是必须恢复到之前进入到main函数的栈帧,才能恢复当时的执行现场。同时栈顶指针(如果有的话)重新指回到当初main栈帧的顶部,以便于下一个调用的来临。不难得出,此时在从main到signal发出这一段的函数调用栈帧已经被释放,而且当有新调用产生的时候必然覆盖掉曾经出现过栈涨部分。也就是说我们肯定无法再返回到signal调用函数体里,即便是可以恢复到之前栈帧位置上也是如此。这就相当于你可以回到过去,但不能做任何事情一样来改变因果关系一样。


(图四)
如图四,使用两对setjmp/longjmp函数是无法工作的。

(1) http://www.csai.cn/incSearch/search_author.asp?in=%CD%F5%CA%A4%CF%E9
(2) http://www.limodev.cn/blog/archives/340
  • 大小: 2.9 KB
  • 大小: 3.5 KB
0 请登录后投票
   发表时间:2009-10-28  
那是不是可以保留一对setjmp/longjmp,另外一个跳转再考虑其他的办法。这应该是可以的,那么保留哪一对呢?稍微分析一下就可以得出答案:我们需要保留是从slot中返回的那一对。因为前面的跳转会破坏堆栈,这不是我们想要的结果,我们希望的是程序还沿着现有的层次继续执行回去,只是调用那一刻的代码转移到slot中来执行。

那么跳转到slot中该如何实现?我们需要的是相当于goto这样的功能,只是不局限于一个语句块的内部,这个时候就需要一些平台相关的代码来解决它了。

其实想到这样一种跳转方式来实现signal-slot,就是因为我这次面对的是一个特定平台(ARM)特定编译器(ADS)下的开发。关于ARM我虽然了解不多,但也知道它有个pc寄存器,可以用于获得当前程序指令的地址——也就是说,应当可以获得前面示例中的BEGIN_OF_SLOT前后的地址,这样我可以通过嵌入汇编的方式,实现整个signal-slot连接和跳转过程。

然而在实现过程中,我发现除了这个地方之外其他的部分都可以不需要与平台相关联,所以决心把它做成通用的一个实现。在我所知的一些平台上,获取程序执行的地址都是有办法的——几乎所有编译器都支持某种形式的嵌入汇编,如果我们能把这里需要的代码量压缩到只有几句的话,那么移植起来就不会是太累人的事;而且,有些编译器甚至不需要通过汇编来获取这个地址,比如gcc。

说到gcc,请允我先补充一下有关内容。以前javaeye的TrustNo1在谈论到c这种非函数式语言中实现lambda表达式的问题时,指出他需要的是可以在函数体内部任意地方临时定义一个函数的功能。gcc对c的扩展中是支持这种形式的,称为嵌套函数。(1)

有了嵌套函数,上面讨论的问题就都不存在了,我们可以传递其指针让signal可以调用它并返回。简单示例一下代码如下(只列出不同部分):

struct __Slot {
void *func_addr;
struct __Signal *signal;
};

void __signal_emit(struct __Signal *signal)
{
((void(*)())(signal->slot->func_addr))();
}

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
void func() {
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
slot.func_addr = &func;
__signal_connect((struct __Signal *)&signal, &slot);
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

的确非常好,可惜这个扩展不被其他编译器接受,连替代的办法也没有。

所以还是回到我们上面讨论的办法来吧。为了不过早嵌入到丑陋繁琐的汇编代码细节里,我们先借用一下gcc的另外一个扩展——它支持一种取得标签地址的运算符"&&"(2)。至少如果可行的话,其他平台上我们有替代的办法。

于是我们把代码改成下面的样子(仍然只列出跟上例不同的地方):

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct _Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

编译运行看看?哈,一点也不奇怪,程序立即就当掉了。可以跟踪执行一下,你会发现跳转进入slot后,根本不存在对printf函数的调用代码,这是因为编译器已经把它优化掉了。对于上述这种硬goto的情况,包括if(false),while(false)等类似的代码,大多数编译器都会毫不留情把代码砍掉,所以一些用于产生debug信息的代码利用这个特性来在不同编译条件下切换以不影响到最终代码的质量。

那我们应该怎么办?这个不难,我们只需给goto语句附加一个编译器无法优化的条件就可以了。这个办法很多,最通用的一种是增加一个volatile变量,显式的阻止可能存在的优化。我这里采用是另外一个办法,因为它汇编出来的代码要少些:

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
if (slot.func_addr + 1) goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct __Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

这里对func_addr这个指针附加一个递增运算,如果不递增是不行的,因为编译器仍然会识别出func_addr不为0。但递增后它就不确认了。但实际上func_addr不可能在递增后归0,所以仍然是可行的。

编译运行!啊,可喜可贺?可以看到打印语句已经被执行到了,只是打印出来的东西还是不对,怎么回事?

稍加分析就不难理解,根据之前的栈帧分析,我们可以知道,这个时候进入到slot的栈帧已经不是之前进入main函数的栈帧了,所以所有的对局部变量的访问都不可能恢复到过去的地址上去,而编译器却对此毫无所知,也就是说不通过对main的调用,进入到main中任何地址执行,其状态都是不确定的。

那怎么办?有一种办法是把signal,slot转移到全局或者静态变量上,这种变量的地址是在编译时就已经确定并且不会改变的,那么代码中所有访问它们的地方在任何时候执行的效果都是一样的。我们来试试看:

int main()
{

static struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;  // 我们没有用到slot,所以暂时可以不变


/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
if (slot.func_addr + 1) goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct __Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

不好意思,因为全局signal的命名和系统头文件产生冲突,我这里选择了静态变量的办法。

编译执行!终于可以松了一口气——结果证明这个办法是可行的。

全局(静态)变量虽然帮助我们达到了目标,但这个方法显然离目标还差了很远,只是勉强凑活在一些没办法时候用用。根据我的实际使用经验看,的确很多时候signal成为全局或者准全局的可能性还是蛮大的,但这里的slot就不一定了。我们为了灵活优化的目的,没有时刻在堆上分配slot——这样使用起来,能够局部声明的话,还是有些优势的。

如果我们可以使用栈上的变量,而不是重新产生新的的话,对slot内部的语句块也有大大的便利,这样它会更像是lambda表达式,而不仅限于一两句简单的调用/转移语句。

(1) http://gcc.gnu.org/onlinedocs/gcc/Nested-Functions.html#Nested-Functions
(2) http://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html#Labels-as-Values
0 请登录后投票
   发表时间:2009-10-30  
在快速的一段行程后,我们先停下来休整一下。

虽然存在很多潜在的问题,得到这样的结果还是有些兴奋的,以致于在我贴出的代码里有不少的错误。但这不是什么大问题,让我们来重新审视一下周边一些琐碎的细节,希望整个设计也有着“非凡”的表现,而不要因为小地方的纰漏导致它的失败。比如可移植性,比如宏——我有些迫不及待的想把它们一口气写完。

因为是对已有认识的总结,所以写出来的东西还是会有条理一些,不会像当初尝试那般存在无数的反复。我们先考虑一下移植性,把不同平台上的代码也写出来,虽然对有经验的人来说,这往往可以放到后面一些。

我们的关键就是获取跳转的地址,正如前面说的,大多数情况下我们要借助编译器提供的内嵌汇编来实现。对于不同平台,不同编译器,如何实现也会有一些细节上的差异,比如ARM平台下,可以直接读取程序计数器计数器。但是因为ARM的流水线机制,读取得到的值为当前指令地址值加8个字节(也就是下两个指令的地址,在某些平台上还可能是加12个字节)。对于x86平台下,没有直接访问EIP寄存器的方法,但是gcc支持一个$符号,链接器链接时会替换它为实际地址,对于不需要重定位的代码而言,这是可以工作的。更通用的解决办法是,可以通过一个call调用,然后从堆栈上来获取它的值。当然效率会低一些,而且可能影响到CPU的分支预测,有兴趣的人可以参考网络上一些讨论(1)(2)(3)

不过对于我们的需求而言,如果编译器/内嵌汇编支持获取标签地址,就变得非常简单了。

对于MS VC++,代码如下(这是写到这里时我刚刚在VC下尝试):
#include <stdio.h>
#include <setjmp.h>

struct __Signal {
   jmp_buf environment;
   struct __Slot *slot;
};

struct __Slot {
  unsigned char signaling;
  void *func_addr;
  struct __Signal *signal;
};

void __signal_connect(struct __Signal *signal, struct __Slot *slot)
{
  slot->signaling = 0; // 初始化调用标记
  signal->slot = slot;
  slot->signal = signal;
}

void __signal_emit(struct __Signal *signal)
{
   if (setjmp(signal->environment) == 0) {  // 这里设置返回点
     signal->slot->signaling = 1; // 设置调用标记
     ((void(*)())(signal->slot->func_addr))();
   }
}

int main()
{
  static struct {
    struct __Signal signal;
    int _1;
    float _2;
  } signal;

  static struct __Slot slot;

  /* 这里开始是SIGNAL_CONNECT宏展开的代码 */
  __signal_connect((struct __Signal *)&signal, &slot);

  /******** 内嵌汇编 *********/
  __asm {
    mov eax, BEGIN_OF_SLOT
    mov slot.func_addr, eax
    BEGIN_OF_SLOT:
  }
  /**************************/

  /* signal跳转进入的slot部分 */
  if (slot.signaling) {
    {
      /* 真正的slot代码 */
      printf("int=%d, float=%f\n", signal._1, signal._2);
    }
    longjmp(((struct __Signal *)&signal)->environment, 1);
  }
  /* 这里SIGNAL_CONNECT结束 */

  signal._1 = 5;
  signal._2 = 10.0;
  __signal_emit((struct __Signal *)&signal);

  /* 大功告成 */
  return 0;
}

可以看出来内嵌汇编部分其实十分简单,但仔细观察就会发现其他地方跟之前代码相比也出现了变化。这是因为我们的汇编代码块独立出来后,无法直接实现对slot部分的代码跨越,因为signal跳转回来以后的地址肯定要落在汇编代码块内部,我们也可以通过某些汇编代码来实现跨越,但那样又增加了汇编部分的难度,而且破坏了代码模块性。所以最终我选择的是增加一个标记变量signaling来实现这个跳转;(顺便承认个错误,上次说的goto语句附加的判断条件里func_addr需要递增是有问题的,因为代码里对func_addr的赋值放在了__signal_connect函数调用前,而调用完后,slot的状态肯定是未知的,所以编译器不会做我所说的优化,如果赋值放在__signal_connect函数调用后,则需要递增处理)同时,也省略了对goto的使用,因为那样的话,实际使用时候会需要很多不同的标号,对宏实现是不友好的。

因为跳转回来后,还得使用slot变量,所以这次slot变量也变成静态的了(不能使用signal的公共部分,因为signal状态是未知的)。

隐患虽然越来越多,先不管它,我继续贴上ARM平台ADS编译器下的代码,其他部分是一样的,仅仅是汇编部分不同:

  __asm {
    MOV slot.func_addr, pc;
    ADD slot.func_addr, __SIGNAL_SLOT_FUNC_OFFSET;
    NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP;
  }

因为ADS内嵌汇编不支持取址操作,所以我们只能使用通过pc寄存器获得当前指令地址的办法。但实际slot的地址应该在当前指令的后面某个地方,再加上恼人的流水线问题,这个地址是不能确定的。但这不妨碍我们解决它,实现的办法就是增加一段“无效”指令段——也就是一连串的NOP,只需要让跳转地址落到这段指令里,然后继续执行到实际代码中去就可以了。通过给pc地址附加某个足够大的值,可以令其往后跳过一部分可能还要执行到的指令,并且不会超出“无效”指令段的范围就可以了。也就是:“无效”指令段的长度 > pc流水线预取量 + 给pc附加偏移量 > 有效指令长度。虽然嵌入汇编的代码并不跟机器指令一一对应,但基本可以估计出有效指令长度不会超出某个范围。所以,最终预定义的__SINGAL_SLOT_FUNC_OFFSET如下:

#ifndef __thumb
  #define __SIGNAL_SLOT_FUNC_OFFSET #32
#else
  #define __SIGNAL_SLOT_FUNC_OFFSET #17
#endif

ARM是32位定长指令,所以我们8个NOP的长度是32;对于thumb指令集它的大小差不多是一般指令的一半,同时跳转地址的最后一位为1表示使用thumb指令集。对于RVDS来说不存在这个问题,但我没有测试。

更多的平台限于条件我无法测试,感兴趣的可以自己尝试,有问题也欢迎前来交流。

然后在进一步实现前,看看整个signal-slot过程里前面忽略掉的一些地方,以保证我们基本的代码可以满足大部分情形下的需要。比如disconnect——这个应该不是问题,还有呢?是多播,它在实现里还没有谈起过。就是说我们试验的代码是一个signal单独连接一个slot,如果要连接到多个slot上怎么办?如果是signal来管理slot,那么随便选择一种集合类型的数据结构——比如数组什么的,应该都可以实现。但实际我们选择的是用户申明slot,这些slot可能分布在程序的各个角落的,可能是静态(全局)变量,栈上分配的,或者堆上分配的等。稍微有些基础的人——或者说是我这个层次的水平吧,这个时候应该都会想到链表。好吧,那我们就来试试,理论上使用单链表就可以达到目的,但一般我会考虑个别时候的需要,使用双链表,修改slot结构如下:

struct __Slot {
  unsigned char signaling;
  void *func_addr;
  struct __Signal *signal;
  struct __Slot *prev;
  struct __Slot *next;
};

没问题的话,我们就一并把connect,emit以及disconnect过程都实现了吧。

void __signal_connect(struct __Signal *signal, struct __Slot *slot)
{
  struct __Slot **p = &signal->slot;
  assert(slot->signal == 0); // 预防一个slot连接多次
  slot->signal = signal;
  slot->signaling = 0;
  slot->prev = 0;
  slot->next = 0;
  while (*p) {
    slot->prev = (*p);
    p = &(*p)->next;
  }
  (*p) = slot;
}

上面函数有一个细节条件是,第一次调用它之前,signal的slot字段和slot的signal字段必须初始化为0。

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
((void(*)())(signal->slot->func_addr))();
    }
    slot->signaling = 0;  // 未知用处,可略
    slot = slot->next;
  }
}

void __signal_disconnect(struct __Signal *signal, struct __Slot *slot)
{
  if (slot->prev) {
    slot->prev->next = slot->next;
  } else {
    signal->slot = slot->next;
  }
  if (slot->next) {
    slot->next->prev = slot->prev;
  }
  slot->signal = 0;
}

对于disconnect函数而言,因为slot包含有signal的信息,所以可以不需要signal参数,出于习惯,我保留了它。

大的功能框架都有了,我们来写宏吧,看看结果有多“漂亮”。

首先补充初始化signal以及slot的宏,提醒用户使用时候不要遗漏了什么:

#define SIGNAL_INIT(signal_ptr) ((struct __Signal *)(signal_ptr))->slot = 0;
#define SLOT_INIT(slot_ptr) (slot_ptr)->signal = 0;

一致起见,所有的宏参数都使用指针变量。

然后是最关键的SIGNAL_CONNECT宏,前面我们已经多次使用过其展开的代码了,其中用户需要提供的信息有三个部分:signal,slot以及slot代码块,这可以作为SIGNAL_CONNECT宏的三个参数。有人也许会有疑问,代码块可能有很多条语句,难道也可以当作一个参数吗?答案是只要不在括号外使用“,”操作符就可以了。这个条件一般都满足,而且也不存在障碍(实在不行,我们还有变参宏来对付)。那么使用宏的写法可能就是:

SIGNAL_CONNECT(signal, slot, ...)

省略号是语句部分,但是这样看起来有些乱,是不是可以考虑把它们括在一起,比如用大括号{},(为了显眼,之前示例中slot的代码块我特地使用了花括号{ printf(...); }把它分割开来。)它天生就是语句块的标志符,而且看起来也更像一个函数体,比如用户还可以在其首部定义变量。当然这完全是使用习惯的问题,但是为了使我们的宏调用看起来更加专业一些,我考虑使用小括号来把语句部分括起来,这样语句部分更像是一个lambda表达式参数,就是:

SIGNAL_CONNECT(signal, slot, (...))

那么我们的宏实现中需要把这对小括号转换为大括号,这可以通过一个最简单的宏实现:

#define __SLOT_BLOCK(a) a

然后在SIGNAL_CONNECT宏中这么用:

#define SIGNAL_CONNECT(signal_ptr, slot_ptr, statement)   \
  __signal_connect((struct __Signal *)(signal_ptr), slot_ptr);   \
  __SIGNAL_SLOT_ENTRY(slot_ptr);   \
  if ((slot_ptr)->signaling > 0) {   \
    {   \
      __SLOT_BLOCK statement;   \
    }   \
    longjmp(((struct __Signal *)(signal_ptr))->environment, 1);   \
  }

其中,对于不同的平台,我们需要定义不同的__SIGNAL_SLOT_ENTRY宏。

gcc代码,使用__LINE__宏生成不同的标签:

#define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  (slot_ptr)->func_addr = && BOOST_PP_CAT(LABEL_BEGIN_, __LINE__); \
  BOOST_PP_CAT(LABEL_BEGIN_, __LINE__):

vc代码,就是内嵌汇编部分:

#define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  __asm { \
    mov eax, BEGIN_OF_SLOT \
    mov (slot_ptr)->func_addr, eax \
    BEGIN_OF_SLOT: \
  }

ads代码,类似:

#define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  __asm { \
    MOV (slot_ptr)->func_addr, pc; \
    ADD (slot_ptr)->func_addr, __SIGNAL_SLOT_FUNC_OFFSET; \
    NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP; \
  }

等等,诸如此类。

SIGNAL_EMIT宏,跟过去的一样。

SIGNAL_DISCONNECT宏,很简单就是函数调用:

#define SIGNAL_DISCONNECT(signal_ptr, slot_ptr) \
  __signal_disconnect((struct __Signal *)(signal_ptr), slot_ptr);

下面把所有用到的宏补全:

/* SIGNAL 宏 */
#define __SIGNAL_FILEDS(z, n, seq) \
  BOOST_PP_SEQ_ELEM(n, seq) \
  BOOST_PP_CAT(_, BOOST_PP_INC(n));

#define SIGNAL(n, tuple) \
  struct { \
    struct __Signal signal; \
    BOOST_PP_REPEAT(n, __SIGNAL_FILEDS, BOOST_PP_TUPLE_TO_SEQ(n, tuple)) \
  }

#define SIGNAL0() SIGNAL(0, ())
#define SIGNAL1(...) SIGNAL(1, (__VA_ARGS__))
#define SIGNAL2(...) SIGNAL(2, (__VA_ARGS__))
#define SIGNAL3(...) SIGNAL(3, (__VA_ARGS__))
#define SIGNAL4(...) SIGNAL(4, (__VA_ARGS__))
#define SIGNAL5(...) SIGNAL(5, (__VA_ARGS__))
#define SIGNAL6(...) SIGNAL(6, (__VA_ARGS__))
#define SIGNAL7(...) SIGNAL(7, (__VA_ARGS__))
#define SIGNAL8(...) SIGNAL(8, (__VA_ARGS__))
#define SIGNAL9(...) SIGNAL(9, (__VA_ARGS__))

/* SIGNAL_EMIT 宏 */
#define __SIGNAL_PARAMS(z, n, signal_params) \
BOOST_PP_CAT((BOOST_PP_TUPLE_ELEM(2, 0, signal_params))->_, BOOST_PP_INC(n)) \
= \
BOOST_PP_SEQ_ELEM(n, BOOST_PP_TUPLE_ELEM(2, 1, signal_params));

#define SIGNAL_EMIT(n, signal_ptr, params) \
BOOST_PP_REPEAT(n, __SIGNAL_PARAMS, (signal_ptr, BOOST_PP_TUPLE_TO_SEQ(n, params))) \
__signal_emit((struct __Signal *)(signal_ptr));

#define SIGNAL0_EMIT(signal_ptr) SIGNAL_EMIT(0, signal_ptr, ())
#define SIGNAL1_EMIT(signal_ptr, ...) SIGNAL_EMIT(1, signal_ptr, (__VA_ARGS__))
#define SIGNAL2_EMIT(signal_ptr, ...) SIGNAL_EMIT(2, signal_ptr, (__VA_ARGS__))
#define SIGNAL3_EMIT(signal_ptr, ...) SIGNAL_EMIT(3, signal_ptr, (__VA_ARGS__))
#define SIGNAL4_EMIT(signal_ptr, ...) SIGNAL_EMIT(4, signal_ptr, (__VA_ARGS__))
#define SIGNAL5_EMIT(signal_ptr, ...) SIGNAL_EMIT(5, signal_ptr, (__VA_ARGS__))
#define SIGNAL6_EMIT(signal_ptr, ...) SIGNAL_EMIT(6, signal_ptr, (__VA_ARGS__))
#define SIGNAL7_EMIT(signal_ptr, ...) SIGNAL_EMIT(7, signal_ptr, (__VA_ARGS__))
#define SIGNAL8_EMIT(signal_ptr, ...) SIGNAL_EMIT(8, signal_ptr, (__VA_ARGS__))
#define SIGNAL9_EMIT(signal_ptr, ...) SIGNAL_EMIT(9, signal_ptr, (__VA_ARGS__))

/* SLOT 宏 */
#define SLOT struct __Slot

我们用这些宏,把示例代码重写一下:

int main()
{
  static SIGNAL2(int, float) signal;

  static SLOT slot;

  SIGNAL_INIT(&signal);
  SLOT_INIT(&slot);

  SIGNAL_CONNECT(&signal
, &slot
, (
    printf("int=%d, float=%f\n", signal._1, signal._2);
    )
);

  SIGNAL2_EMIT(&signal, 5, 10.0);

  SIGNAL_DISCONNECT(&signal, &slot);

  return 0;
}

是不是一下子简洁清爽了好多:)

附件test.c是完整的测试代码,可以在gcc环境下编译运行。

(1) http://stackoverflow.com/questions/599968/reading-program-counter-directly
(2) http://forum.osdev.org/viewtopic.php?f=13&t=3735&start=0
(3) http://www.programmersheaven.com/mb/x86_asm/341598/341639/re-retreiving-the-value-of-eip-register/
0 请登录后投票
   发表时间:2009-11-03  
若问起如何想到这样时,我只能说是模仿——从前面的内容大家应该都可以了解到。所以要感谢谢牛人们带来的创意设计,借用一句话:只有想不到的,没有做不到的。不过看到这里百般折腾的摆弄,牛人们也肯定会不以为然:有这个闲工夫早就搞一门新语言了。呵呵,插科打诨一下,别以为意。

但是,还有许多问题没有解决掉,前面还不知道会有有怎样的艰险?让我们继续启航吧——小舟颤颤巍巍的驶向了大海深处。

可能很多人只是通过文章来了解信息,并没有真正去试过,所以我说过的东西并没有经过充分的验证,至少明显其中有不少手误。这些就罢了,对于拿到test.c文件的人,如果你的测试通过了,那得恭喜一下了。不过,我想你肯定没有打开编译优化开关,现在我们继续做一些测试,给gcc命令加上-On参数,n可以从1一直到6,会有怎样的结果?

一旦打开了优化开关,就像打开了潘多拉的魔盒,随着优化深度的不同,各种奇怪事物纷纷出现:可能程序没有任何反应了,也可能输出了结果但挂起了。总之程序异常退出了,结果非常糟糕。怎么办?别急着关上它,让我们一探魔盒里的究竟,看看是不是如传说所言那样最终输出美好的结果。

不过程序出现异常倒不是什么意外的事情,实际上,最初很快就实现跳转并输出正确结果反而有些出乎我的意料。现在为了更好的追踪定位错误,我们还是先回到宏展开的代码吧:

int main()
{
  static struct {
    struct __Signal signal;
    int _1;
    float _2;
  } signal;

  static struct __Slot slot;

  /* 这里开始是SIGNAL_CONNECT宏展开的代码 */
  __signal_connect((struct __Signal *)&signal, &slot);

  /* 设定跳转地址 */
  (&slot)->func_addr = &&LABEL_BEGIN;
  LABEL_BEGIN:

  /* signal跳转进入的slot部分 */
  if (slot.signaling) {      
    {
      /* 真正的slot代码 */
      printf("int=%d, float=%f\n", signal._1, signal._2);
    }
    longjmp(((struct __Signal *)&signal)->environment, 1);
  }
  /* 这里SIGNAL_CONNECT结束 */

  signal._1 = 5;
  signal._2 = 10.0;
  __signal_emit((struct __Signal *)&signal);

  /* 大功告成 */
  return 0;
}

再补充一段signal emit的代码:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
((void(*)())(slot->func_addr))();  // 上次的代码这里有误
    }
    /* 跳转返回后的地方 */
    slot->signaling = 0;
    slot = slot->next;
  }
}

这里需要关注三个地方分别是:设定跳转地址,signal跳转进入的slot部分,以及跳转返回后的地方。

设定跳转地址这个地方的问题有些“无厘头”,如果是借助汇编实现的,反而一般没有问题。当其他问题都解决掉以后,我手头编译器(gcc 4.x)结果显示,在-O2优化设定下,程序将没有任何反应,而其他优化级别无论高低,皆能执行到正常的slot部分——打印出结果。跟踪调试代码,发现func_addr被设定到main函数开始前的某个地址处,所以陷入了死循环。据我的分析,应该是LABEL_BEGIN并没有被程序流程真正使用到,所以优化器认为这个值是多少并不重要,解决的办法就是增加一个真正使用到这个标签的语句,如下:

slot.func_addr = &&LABEL_BEGIN;
{
   void * volatile temp = slot.func_addr;
   if (temp) goto LABEL_BEGIN;
   slot->func_addr = temp;
}
LABEL_BEGIN:

这里附加了一个volatile变量来阻止对goto的优化,而且在goto后面增加了一句实际无用的赋值语句,原因是你不能在紧挨标签前一句跳转到标签,那样优化器把那句话忽略,又回到原先的情景。

下一步是signal跳转进入slot的部分,当优化级别高到一定程度,程序同样也会没有任何结果的非法结束。反汇编跟踪显示,这一段没有设想中应有的代码。这是因为编译器识别出slot的signaling字段的值恒为0(我们在__signal_connect初始化它),所以认为这段代码不会被执行到而忽略。为什么编译器会识别到一个函数体中的执行结果(后面会指出,通过调用一个无用函数是阻止编译器优化的办法之一)?这是因为测试程序中我们把函数和调用者放在同一个.c编译单元中,优化级别提高以后,会直接内联到调用者代码里,所以优化器得出这样的结果。至于后面跳转中的赋值,那是非正常的程序流程,编译器自然不会把它们关联起来。

实际使用中,因为__signal_connect可以作为库的一部分摆放到独立的.c文件,独立的编译,也就自然避免了某些类似的问题。这里我们先采取一个临时的措施来阻止这个优化,把slot变成volatile的:

volatile static struct __Slot slot;

解释完这两个边角问题后,下面进入了我们主要需要对付的地方——跳转。

关心细节的人肯定会注意到我们使用函数调用方法来实现跳转的。通过把执行地址强制转换为一个函数,并调用它,最终程序流程会执行到我们想要跳转的地方。函数调用肯定不是跳转这么简单的一件事情,不过方便起见我们使用了它——反正后面的longjmp会回到跳转之前,无论跳转里面做了什么事情都无关紧要。当然,这也可以通过汇编方式的实现,在x86平台下可以通过一条长跳转指令,跟微软的thunk差不多的办法;但是在ARM ADS/RVDS平台上,内嵌汇编是不允许这种跳转的,因此函数调用是既方便也更通用的办法。

但是实际上我们进入的slot并不是一个函数,干活的指令虽然还是那些,但是没有了进入到一个函数后必须的一些初始化工作,同时正常流程下编译器估计会运行到的状态都已经不存在了,也就是编译器可能做出一些无法预计的优化,这原本应该是有效的,但现在变成错误了。这种情况下,代码还能工作实在是件值得庆幸的事情。

好吧,程序的确能打印出些什么,为什么之后就挂掉了呢?这关键还在之前讨论过的调用栈上。对于一个通常的函数调用而言,为了在调用后正常回到原始程序,它必须对之前的堆栈加以保护,还可能包括一些寄存器,编译器根据不同平台上的使用惯例,来假设函数调用前后的环境——我们之前说过它大概对应着一个寄存器集合。

保护堆栈的方法就是每个函数必须形成和使用自己的栈帧,我们以容易接触到的x86平台为例,对于x86上c函数调用约定一般大家都知道cdecl,pascal等之类,后者被微软用于windows回调函数。cdecl是标准c使用的,我们就以它为例,它要求调用前,参数自右向左入栈,调用完成后,调用者负责恢复这一部分堆栈,这样方便不定参数的传递。最后调用发生时,指令call会将其下一条指令地址压入栈,这样函数返回后的ret指令会弹出并沿之继续执行。一般对栈的使用上会有两个指针,一个栈顶指针(栈指针),和一个栈基指针(或者叫栈帧指针),x86上分别是esp和ebp寄存器。任何对栈的操作esp寄存器自动改变,保证始终指向栈顶。ebp是由函数本身管理的,每次进入函数时,函数会保护之前的ebp(压栈),并把当前栈顶指针位置作为新的ebp值,这样使用的局部变量统统可以交由一个ebp带一个偏移量来实现,对于通常使用的的满递减栈(full descending)而言,这个偏移量是一个负值。这里不使用esp的原因是因为它在函数体内如果仍然是可能/需要变化的。最终函数退出的时候,再恢复之前的ebp值——它是上一个函数所使用的,这样所有的栈帧都可以通过ebp进行回溯。(1)

我们用一个图来直观的表现栈帧的细节,图五:

x86上函数进入和退出的标准惯例代码,进入时执行的指令是:

push ebp       ;保存之前的栈基指针
mov ebp, esp   ;令ebp指向当前栈顶位置,
sub esp, x     ;x是函数使用的auto(局部)变量的字节大小,这样栈顶指针就知道该函数栈帧的末尾处。

退出时,恢复到它们进入函数前状态:

mov esp, ebp
pop ebp
ret

对于不同的平台,使用栈的方法大体是差不多的,比如ARM下,习惯上r13用作为栈顶指针sp,r11用作栈帧指针,当然具体指令中这不是绝对的。

这里参考的地方还有很多(2)(3)(4)(5)——恕我不能一一列举,都是些本地高手的链接,我深知这是抬高声望的捷径:)

很显然,对于我们的slot调用过程而言,尽管如上述退出我们可以不关心,但是它并没有进入的那一部分。这样,slot中使用的栈帧仍然是调用前那个函数的,虽然如果通过调用,栈指针已经被更新过,但那本来就不会影响到之前的栈帧。这里出现两个情况,一个是对局部变量的访问实际上落在之前栈帧向后的空间,甚至可能越出了到达栈顶空闲部分(看变量的偏移值),当然目前我们没有使用局部变量可以放过这一个点。二是如果slot中产生函数调用时,其压入栈的参数位于栈顶之前(出于优化,编译器会预先计算好包括压栈参数在内所需的栈帧尺寸,一次性把栈指针扩展到后头,然后通过mov指令而不是push指令加快压栈进程),同样覆盖了之前函数的栈帧空间。总之,没有了当前slot的栈帧,对栈的操作都是危险的。如图六:

这怎么办呢?当然了解到平台ABI细节,可以通过内嵌汇编实现。但这不方便。更加简洁的办法是,给slot附加一个函数入口,也就是跳转时进入一个空函数,该函数负责分配栈帧,保存设定栈帧指针的工作,然后跳转过来就没问题了。后面的工作编译器可以自动帮助完成,我们只需要负责分配栈帧空间就可以了。这可以通过申明一个字节型数组来实现,具体要多大栈帧,就指定多大的数组,也许不一定确切知道这个大小的值,但只要保证大到一定程度没问题就可以了;可以在所有局部使用变量包括函数参数的字节大小上再给一个足够的富余量就可以了;甚至你可以大概估算一个大的多的值。

我们定义__signal_slot_invoke函数如下:

void __signal_slot_invoke(struct __Slot *slot)
{
  volatile unsigned char temp[1024];    // 1024是一个太大到足够的值
  ((void(*)())(slot->func_addr))();
  temp[0] = temp[0];      // 阻止尾调用优化及数组忽略
}

然后重写__signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  void(* volatile func)(struct __Slot *) = &__signal_slot_invoke;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
      func(slot);
    }
    slot->signaling = 0;  // 未知用处,可略
    slot = slot->next;
  }
}

我们这里定义了一个volatile 的func函数指针,再通过它来调用实际的__signal_slot_invoke函数,这是因为要避免因为跟__signal_emit代码在同一个文件里出现的内联函数优化,然后数组伪栈肯定也是volatile的。还有一个小的细节是我们在调用后加了一条temp[0] = temp[0]语句,它是无法被优化的,但也是永远不会执行到的。这是因为如果前一句函数调用如果是函数体内最后一句语句的话,仍然会产生一个尾调用优化——编译器会在调用前回复到之前函数的堆栈上。这很好理解,因为再也不会访问局部变量了。

程序到这里,应该就可以运行了。因为我们的代码很简单,没有其他影响到的地方,那就把余下的细节留待需要的时候再讲吧。这里先总结一下对抗编译器优化的办法:

1、最直观通用的办法就是使用volatile关键字,通过给变量附加volatile关键字,程序对每一个使用到它的表达式都不会进行优化,不会预先计算它的值,不会把它的值保存在寄存器里,而是严格按照执行的次序,在需要的时候从内存中重新读取。
2、是通过一个不可见的函数调用,把需要刷新的变量作为可以改变的参数(地址)传递过去,也许实际上这个函数没做什么特别的动作,但编译器无法进行任何优化假设。
3、其他可能的办法。(有待梳理)

(1) http://en.wikipedia.org/wiki/X86_calling_conventions
(2) http://blog.csdn.net/normalnotebook/archive/2006/06/25/833458.aspx
(3) http://blog.solrex.cn/articles/call-stack-and-satck-frame.html
(4) http://liyiwen.iteye.com/blog/345525
(5) http://blog.chinaunix.net/u/13392/showart_70276.html
  • 大小: 4.4 KB
  • 大小: 5.1 KB
0 请登录后投票
   发表时间:2009-11-05  
抵抗编译器的优化贯穿着始终,它成了实现过程中我遭遇的最繁琐的任务。因为没有一个标准的指导和熟练的反应,常常是写着写着突然发现不能工作了,再回过头来花很长时间查找。由此也可见编译器工作者们的辛劳,谢谢他们——尽管让我吃了不少苦头。

趟过荆棘密布,危机四伏的沼泽地后,接下来面对的就是此行的主要任务之一:绑定本地变量。自搭起signal-slot的框架以来,我们还没有引用过一次局部变量,这个问题肯定一直缠绕在大家心头。

根据前面对堆栈的分析,我们可以知道,slot引用的栈帧已经不是它所在母体函数的栈帧,也就是说编译器肯定无法帮助实现引用到之前局部变量的值。当然你仍然可以使用相同的变量名,但它们的值在初始化之前都是不确定的,就像刚进入到母体函数一样。这肯定是不行的,要知道signal-slot机制,还有闭包,委托等等的带来的一个直观的体验就是,你可以把本不相干的变量融合在一起形成一个“临时对象”,其中不属于参数部分的变量又称为自由变量(1)。

如何绑定这些自由变量是除调用/跳转外另一个主要问题,前面我们知道全局变量这种特殊类型自由变量是不存在问题的,常量也不存在问题,它们可以直接在slot中引用,剩下要解决的就是对局部变量的引用问题。

编译器不能帮助引用到之前变量值的原因是,进入slot的栈帧指针已经出现变化,我们现在让它指向了一片临时数组产生的伪栈中。根据局部变量的引用方法——通过栈帧指针加上偏移量——变量相对偏移量没有变,所以变量的引用都落在当前伪栈中。如果能帮栈帧指针恢复到过去那当然是最好的,但这样还有个隐患,你不能在slot中调用其他函数,因为这样会覆盖母体函数之后新生出来的栈——跟我们最早的问题一样。那么还能怎么办?想必很多人都想得到,这可以通过一次“栈复制”来解决。如果我们知道前后的栈帧指针,把之前栈帧的内容复制到之后的栈帧中,保持偏移量不变就可以重新从新栈中获得过去的变量值。因为我们要求数组伪栈必须大于函数栈,所以复制和引用都是不会出现问题的。(图七)

可以通过汇编访问保存在寄存器里的栈帧指针,我们需要更加通用的C解决方案。这也不是什么难事,通过在栈上设定一个“锚”变量,然后通过前后地址值相减,我们同样可以获得两个栈帧之间的偏移量,它也等于所有变量的偏移量。

当前的地址可以直接求值获得,但之前的地址呢?没有之前的地址,就变成“刻舟求剑”了。没关系,它可以放在slot里传递。但slot如果也是局部变量呢?嗯,这个矛盾不能在自身内部解决,必须再引进一个外部变量。这有两个手段:一是通过一个全局变量,在调用前后对其赋值;这个方法有些缺陷,不能并发使用,或者在访问前后必须通过锁互斥之类的手段。锁互斥一般需要依赖于线程库之类的,移植起来比较麻烦;另外一个方法是在跳转中设定一个参数来获取,对函数调用方式的跳转,可以直接通过函数参数传递它,也就是:

void __signal_slot_invoke(struct __Slot *slot)
{
  volatile unsigned char temp[SLOT_STACK_FRAME_SIZE];    // SLOT_STACK_FRAME_SIZE是某个预定义的值
  ((void(*)())(slot->func_addr))(slot);
  temp[0] = temp[0];
}

当然,这要求我们在进入slot的部分添加一些代码来获得它,这些代码依赖于平台调用约定。不过它移植起来起来要方便一些,而且可以和之前获取地址的部分合并在一起——跳转后刚好落在它后面。比如,在x86 gcc平台上,重新定义宏:

#define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  (slot_ptr)->func_addr = && BOOST_PP_CAT(LABEL_BEGIN_, __LINE__); \
  if ((size_t)((slot_ptr)->func_addr) + 1) goto BOOST_PP_CAT(LABEL_END_, __LINE__); \
  BOOST_PP_CAT(LABEL_BEGIN_, __LINE__): \
  { \
    size_t __esp; \
    __asm__ __volatile__("movl %%esp, %0":"=m"(__esp)::"ax","bx","cx","dx","si","di","memory"); \
    (slot_ptr) = *(SLOT **)(__esp + sizeof(void *)); \
  } \
  BOOST_PP_CAT(LABEL_END_, __LINE__):

花括号里面的就是获取函数参数的汇编代码,这是AT&T格式的,没接触过的看起来可能有些吃力,我稍微解释一下。

__asm__和__volatile__都是gcc的扩展关键字,前者不用说了,后者是阻止对其中的汇编代码进行优化,虽然我们这里没什么可以优化的,但为了确保不出问题,以及可能潜在的修订我们就始终加上它吧。

嵌入的汇编代码部分被冒号“:”分隔为四个部分,第一个部分是指令部分,第二个是输出参数部分,第三个是输入参数部分,输入输出参数对应着指令中%引用的除寄存器外的变量。最后一部分告诉编译器有哪些值可能被修改了,提示编译器在汇编前后做出相应的处理。

这里的__esp变量我们用来保存汇编中获取的栈指针——x86平台上就是esp寄存器的值。movl指令就是把第一个参数的值放到第二个参数里,跟MASM格式的源和目的恰好反过来。参数部分就一个输出变量__esp,它前面的"=m"限定符表示它是一个内存变量;最后面我们指示从ax-di所有的通用寄存器都出现了变化,内存也出现了变化;其实汇编代码对大部分都没做改变,这么用是为了阻止后面的代码优化,下面涉及的时候再解释。

根据cdecl调用约定,最左面的参数位于栈顶,因为调用本身还有一个压入返回地址的操作,所以进入调用代码后,参数位于栈顶地址加上返回地址大小的位置上,加法是因为对递减栈而言其栈顶方向是低地址。32位平台上,地址大小是4个字节,但我们这里没有指定,而是通过对一个void *类型取sizeof得到其大小;虽然实际上是一样的,但考虑到潜在的移植性,我们使用更加通用的办法。然后对地址做加法运算,我们是转换到一个size_t类型上而不是通常的int,这同样是为了移植性。对于某些平台而言,void *(或者任意的指针类型)和int大小是不一定相等的,c99为此专门定义有intptr_t/uintptr_t类型在其标准库头文件stdint.h中(2),有一些讨论(3)指出了c89平台上应该如何处理。我们这里使用size_t这个大家熟知的类型,因为一个对象最大应该可以充满整个寻址空间,所以它的大小应该等于指针类型的大小。

这样,在跳转后,无论如何声明的slot,我们仍然可以确保slot指针指在同一个对象上,因为它是通过层层函数调用传递而来的。类似的,对于ARM ADS平台,重新定义同一个宏:

__inline static void __signal_slot_dummy() { }

#define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  { \
    volatile size_t __anti_optimize = (size_t)(slot_ptr); \
    __asm { \
      MOV (slot_ptr)->func_addr, pc; \
      ADD (slot_ptr)->func_addr, __SIGNAL_SLOT_FUNC_OFFSET; \
      MOV r0, __anti_optimize; \
      NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP; \
      MOV slot_ptr, r0; \
      BL __signal_slot_dummy, {}, {}, {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11}; \
    } \
  }

因为跳转代码落在汇编代码中,所以我们是在一整个汇编中来串接跳转前后的变化。这里必须要有预防一些优化的手段,对于寄存器变化部分,没有等价于gcc那种完备的嵌入汇编功能;不过好在对于调用未知函数,可以指定这件事情,所以我们添加了一个空的内联函数调用;后面的三个花括号分别用于指定输入寄存器,输出寄存器,及变化了的寄存器。同样,我们把所有的通用寄存器都指定为变化了的,究竟为什么这么做看下面。

我想其他大多数平台都可以通过类似的定义来实现,移植起来不是什么困难的事。

有了slot指针,我们可以做到想传递什么就传递什么。我们在结构中增加栈帧指针(实际上是“锚”变量的地址):

顺便把一个前面的边角问题处理好:有关栈帧大小的定义。它不是事先可以知道的,当然可以尽量大,但大到多少合适,这也是没有边际的。一个比较合理的解决方案是交给用户定义。因为数组大小必须是常量(C99有变长数组),所以只能交给宏(SLOT_STACK_FRAME_SIZE)来完成。我们最好是在用户定义slot的那个c文件的头部定义这个量,方便用户定位和修改(有办法可以断言出用户定义的不够用)。这种情况下,我们必须在头文件里定义__signal_slot_invoke函数,那么我们必须把它定义为static的,甚至是inline的(消除告警),然后为了让使用用户slot文件中的这个本地函数,我们可以让它的地址保存到slot结构中,代码如下:

struct __Slot {
  unsigned char signaling;
  void *func_addr;
  void *stack_addr;
  void (*signal_slot_invoke)(struct __Slot *);
  struct __Signal *signal;
  struct __Slot *prev;
  struct __Slot *next;
};

重写__signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
      slot->signal_slot_invoke(slot);
    }
    slot->signaling = 0;  // 未知用处,可略
    slot = slot->next;
  }
}

然后是测试程序代码,清楚起见,我们先不用之前定义的宏(但是模拟它):

int main()
{
  struct {
    struct __Signal signal;
    int _1;
    float _2;
  } signal;

  struct __Slot slot;

  /* 这里开始是SIGNAL_CONNECT宏展开的代码 */
  {
    SLOT *__slot_ptr = (SLOT *)&slot;
    volatile size_t __slot_stack_offset;

    __signal_connect((struct __Signal *)(&signal), __slot_ptr);

    /* 设定invoke调用 */
     __slot_ptr->signal_slot_invoke = &__signal_slot_invoke;

    /* 设定跳转地址 */
    __slot_ptr->func_addr = &&LABEL_BEGIN;

    /* 设定栈帧地址 */
    __slot_ptr->stack_addr = (void *)&__slot_stack_offset;
 
    if ((size_t)(__slot_ptr->func_addr) + 1) goto LABEL_END: // 正常流程跳过下面获取地址参数一段
    LABEL_BEGIN:
    {
      size_t __esp;
      __asm__ __volatile__("movl %%esp, %0":"=m"(__esp)::"ax","bx","cx","dx","si","di","memory");
      __slot_ptr = *(SLOT **)(__esp + sizeof(void *));
    }
    LABEL_END:

    /* signal跳转进入的slot部分 */
    if (__slot_ptr->signaling) {
      __slot_stack_offset = (size_t)(&__slot_stack_offset);
      __slot_stack_offset = (size_t)(__slot_ptr->stack_addr) - __slot_stack_offset;
      {
        /* 用户slot代码 */
memcpy((void *)&signal, (void *)((size_t)(&signal) + __slot_stack_offset), sizeof(signal));
        printf("int=%d, float=%f\n", signal._1, signal._2);
      }
      longjmp(((struct __Signal *)__slot_ptr->signal)->environment, 1);
    }
  }
  /* 这里SIGNAL_CONNECT结束 */

  signal._1 = 5;
  signal._2 = 10.0;
  __signal_emit((struct __Signal *)&signal);

  /* 大功告成 */
  return 0;
}

整个SIGNAL_CONNECT宏展开成为一个单独的语句块,以方便我们使用一些内置变量,这是C89强制要求的。实际上,即使C99和C++允许在语句块内部的任何地方定义局部变量,把局部语句和其使用的资源组合成块——就是使用{}括在一起,也是一个良好的编程习惯。它至少令你的代码看起来更加整洁,前面代码也常常可以看到这种用法。

除了内部必须使用的__slot_ptr这个slot指针变量外,还有一个用于计算栈帧偏移兼求取栈帧地址的变量——任何一个局部变量都可以充当“锚变量”。它必须是volatile以在后面的计算中避免编译器会优化成预先求值。这两句话联合起来完成计算偏移量的过程:

      __slot_stack_offset = (size_t)(&__slot_stack_offset);
      __slot_stack_offset = (size_t)(__slot_ptr->stack_addr) - __slot_stack_offset;

和通常不一样,我们必须一步步求值,以强迫程序必须每次从内存中读取值并按次序计算它。如果把它们写在一个表达式里,编译器优化后,就可能因为预测优化导致问题。这个过程也可以用一个函数调用来实现。

然后用户定义的代码中对要使用的变量可以通过一个memcpy调用来实现“栈复制”的过程。

memcpy((void *)&signal, (void *)((size_t)(&signal) + __slot_stack_offset), sizeof(signal));

这里有一个隐藏的要对抗编译器优化的表达式,就是取地址(&)操作符。比如上面我们已经有过一次获取栈帧地址的操作:

    __slot_ptr->stack_addr = (void *)&__slot_stack_offset;

在进入slot后,同样要再获取一次,在代码不复杂,寄存器够用的情况下,编译器会把之前的求地址得到的结果放在寄存器里,然后再第二次赋值时重复使用——这跟赋值对象的volatile属性没关系;这样的话,那“锚”就没有跟着船一起前进,得到的偏移值恒为0。如果在RISC这种寄存器较多的机器上,类似的优化问题会更加突出。比如对于ARM,甚至出现多个栈帧指针寄存器的情况。所以在之前的汇编代码里,我们都指定所有的寄存器必须作废。

这个地方有没有什么可以替代的方法,回答是不一定。对于取地址操作而言,一种可能就是如果之前没有进行过同样的操作,那应该不用担心后面的取地址运算会在之前进行。对于我们的stack_addr,可以类似goto语句这么绕过去:

if ((size_t)(__slot_ptr->func_addr) + 1) __slot_ptr->stack_addr = (void *)&__slot_stack_offset;

因为编译器不知道前面的求值是否进行过,所以就不会做优化了。

但是对于用户行为就无法估算了,即便我们的确有办法确保slot中使用的变量都没有事先被求过地址,但也没法保证其他优化不被进行,所以这里安全的方法只有依赖于编译器了。如果没有类似作废寄存器的方法,有些编译器可以在局部打开或关闭优化,请参考自己的编译器文档。

根据代码我们可以重新得到我们的宏:

#define __SLOT_STACK_OFFSET __slot_stack_offset
#define __SLOT_PTR __slot_ptr

#define SIGNAL_CONNECT(signal_ptr, slot_ptr, statement, finalization) \
  { \
    SLOT *__SLOT_PTR = (SLOT *)slot_ptr; \
    volatile size_t __SLOT_STACK_OFFSET; \
    __SLOT_PTR->stack_addr = (size_t)&__SLOT_STACK_OFFSET; \
    __signal_connect((struct __Signal *)(signal_ptr), __SLOT_PTR); \
    __SIGNAL_SLOT_ENTRY(__SLOT_PTR); \
    if (__SLOT_PTR->signaling > 0) { \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET; \
      __SLOT_STACK_OFFSET = __SLOT_PTR->stack_addr - __SLOT_STACK_OFFSET; \
      { \
__SLOT_BLOCK statement; \
      } \
      longjmp(__SLOT_PTR->signal->environment, 1); \
    }                                                                   \
  }

对于用户要引用的局部变量,也就是memcpy语句,可以通过一个宏来实现:

#define SLOT_REUSE_LOCAL_VAR(a) \
  memcpy((void *)&a, (void *)((size_t)&a + __SLOT_STACK_OFFSET), sizeof(a));

用户slot代码如下:
(
  SLOT_REUSE_LOCAL_VAR(signal);
  printf("int=%d, float=%f\n", signal._1, signal._2);
)

如果你觉得内存拷贝代价有些高,你可以直接用指针运算来引用变量,但使用上必须自己指定类型:

#define SLOT_LOCAL_VAR(a, type) \
  (*(type *)((size_t)&a + __SLOT_STACK_OFFSET))

也就是用户slot代码写成这样:

(
  printf("int=%d, float=%f\n", SLOT_LOCAL_VAR(signal._1, int), SLOT_LOCAL_VAR(signal._2, float));
)

最后,如果在并发程序里,可能你在宿主函数也要知会slot过程对局部变量的改变,就是把变量复制会宿主栈帧里:

#define SLOT_UPDATE_LOCAL_VAR(a) \
  memcpy((void *)((size_t)&a + __SLOT_STACK_OFFSET), (void *)&a, sizeof(a));


到这一步,我们的实现已经非常接近于最终的情景了,栈帧断言部分大家可以自己先补充。我总是觉得宏名取的不是很合适,但还没有想到更好的办法,等最后全部完成后,大家可以帮助挑一挑。

(1) http://en.wikipedia.org/wiki/Free_variables_and_bound_variables
(2) http://en.wikipedia.org/wiki/Stdint.h
(3) http://stackoverflow.com/questions/502811/sizeof-int-sizeof-void
  • 大小: 4.7 KB
0 请登录后投票
   发表时间:2009-11-06  
完成了绑定局部变量的任务之后对成功就满怀信心了,让我们再接再厉,一口气把剩下的任务完成,要攻克的堡垒只剩下最后一座了:绑定易失变量的值,以及堆空间的分配和释放,让我们在夕阳落山之前解决它,然后可以美美的睡上一觉。

先说说上次栈帧断言的事,通过宏定义的栈帧大小很难预先计算——用户可能随时修改代码;尽管一个较大的值比较安全,但一方面这不是绝对的,另一方面在一些场合(比如嵌入式)我们要使用紧凑的栈来节约空间,这就要避免栈溢出的情况。尽管一般来说因为栈分配是编译期的事所以无法做到动态进行(c99有动态数组,一些平台上有alloc栈分配函数),但如果能通过断言在运行时给出错误提示那也不错了。

为了得出栈帧大小,我们必须有栈帧起始地址,栈帧结束地址或者附近位置的地址。比如,如果局部变量按顺序排列的话,起始地址可以看作是第一个变量的地址,而结束地址是最后一个变量的地址(附加上函数调用入栈参数的最大长度)。第一个无法预先得到,第二个理论上也存在问题,我们预先假设的顺序也不一定成立——编译器不会保证它。但对进入到slot的栈帧,我们是可以得到起始地址的,就是我们定义的数组伪栈,我们可以把它的地址放到slot里:

  slot->stack_frame_head = (void *)&temp[SLOT_STACK_FRAME_SIZE-1]; // 注意栈递减情况下,起始地址在最后一个字节上

那么栈尾呢?我们要的是应该的栈尾位置,跟定义的伪栈大小无关。这里的解决办法是如果我们知道原先栈的栈尾位置,可以通过和局部变量映射的同样办法映射到偏移后的地址上。那只要得到原先的栈尾位置就可以了:考虑到一旦产生函数调用后,新函数里的变量就肯定位于之前栈的上面了,也就是说可以得到大于并挨着原先栈尾的位置。我们可以在signal connect函数里,对局部变量求地址:

  slot->stack_frame_tail = (void *)&p;

然后在CONNECT宏中求得__SLOT_STACK_OFFSET之后的位置添加:

      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
     - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \

好了,赶紧回到我们的最终任务上,前面我们绑定的局部变量有一个要求,就是必须按照例子中的那样,main中调用signal的时候,自己还没有退出,但实际使用中可无法保证这一点,一个是,我们可能在一个子函数中完成连接,在调用还没产生前已经返回了,还有一个是,如果并发的情况,你不知道那个函数还有没有结束,如果结束了,和前一种情况一样,它们使用的栈空间已经被释放,你从那个位置复制过来的值就“失效”了。

这个时候我们必须要有可以在堆上分配并获取这些值的办法。方案就是把这些值附加到slot上,然后slot自然也是不能消失的,在调用产生的时候,可以恢复它们。slot附加参数的办法自然和signal是类似的,两种方法,一是和现有signal方案一样,一种是我们讨论过但没有采纳的动态分配方案。这里我们选择了和signal的不同的方案,因为slot不像signal需要预先设计,我觉得这种方案要灵活些。

我们定义参数的类型:

struct __SlotArg {
  size_t addr;
  size_t size;
  void *value;
};

它其中包含三个部分,一个是栈帧上的偏移,一个是大小,还有一个指向它在堆上分配的值的空间。为什么这样设计,请继续往下看。

我们定义完整的slot类型,让它包括一个参数部分:

struct __Slot {
  unsigned int argc;
  struct __SlotArg *argv;
  int signaling;
  void *func_addr;
  void *stack_addr;
  void *stack_frame_head;
  void *stack_frame_tail;
  void (*signal_slot_invoke)(struct __Slot *);
  struct __Signal *signal;
  struct __Slot *next;
  struct __Slot *prev;
};

其中的argc表示参数的数量,argv自然指向一个类似于参数数组(或其他集合)的东西。为了提高效率我们采用的是数组,这样可以在一次完成对所有变量所占空间的分配。那么携带参数的方法就是一个变参函数:

void __slot_fetch_args(struct __Slot *slot, unsigned int count, ...)
{
  unsigned int i;
  size_t size;
  char *p;
  va_list ap;
  slot->argc = count;
  // 预分配足够的a参数描述数组空间
  size = sizeof(struct __SlotArg) * count;
  p = (char *)size;
  slot->argv = (struct __SlotArg *)malloc(size);

  // 通过函数参数完成对参数描述数组的赋值
  va_start(ap, count);
  for (i = 0; i < count; i++) {
    slot->argv[i].addr = (size_t)(va_arg(ap, void *));
    slot->argv[i].size = va_arg(ap, size_t);
    size += slot->argv[i].size;
  }
  va_end(ap);
  // 在预分配的空间上一次分配完所有变量值存储的空间
  slot->argv = (struct __SlotArg *)realloc(slot->argv, size);
  p += (size_t)slot->argv;
  for (i = 0; i < count; i++) {
    slot->argv[i].value = p;
    // 保存值
    memcpy(slot->argv[i].value, (void *)(slot->argv[i].addr), slot->argv[i].size);
    p += slot->argv[i].size;
  }
}

函数的第一个参数不用说了,第二个参数是所携带参数的个数,也就是slot->argc,接下来的参数传递,是按照大小,和地址两两逐个进行的,这是因为不同的参数肯定有不同的类型,我们不需要处理类型(也无法处理),只需要处理内容(值)就可以了。从注释我们可以看到,上诉参数类型只是用于描述参数的,真正的参数附着在参数描述数组的后面,我们把它们的在堆上的地址保存在参数描述数组里。

然后进入到slot后,我们可以通过一个简单的过程,一次性复制所有参数到当前栈帧上供接下来引用,因为参数描述的addr是前局部变量的地址,因此很容易转换到当前帧上:

void __slot_commit_args(struct __Slot *slot, size_t slot_stack_offset)
{
  unsigned int i;
  for (i = 0; i < slot->argc; i++) {
    memcpy((void *)((size_t)slot->argv[i].addr - slot_stack_offset), slot->argv[i].value, slot->argv[i].size);
  }
}

为了使用上的方便,我们分别用SLOT_FETCH_LOCAL_VAR和SLOT_COMMIT_LOCAL_VAR宏来代替它们:

#define __SLOT_PARAMS(z, n, seq) \
  , &BOOST_PP_SEQ_ELEM(n, seq), sizeof(BOOST_PP_SEQ_ELEM(n, seq))

#define __SLOT_VA_ARGS(n, tuple) \
  n BOOST_PP_REPEAT(n, __SLOT_PARAMS, BOOST_PP_TUPLE_TO_SEQ(n, tuple))

#define SLOT_FETCH_LOCAL_VAR(slot_ptr, n, a) \
  __slot_fetch_args(slot_ptr, __SLOT_VA_ARGS(n, a));

// #define SLOT_FETCH_LOCAL_VAR0(slot_ptr) SLOT_FETCH_LOCAL_VAR(slot_ptr, 0, ())
#define SLOT_FETCH_LOCAL_VAR1(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 1, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR2(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 2, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR3(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 3, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR4(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 4, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR5(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 5, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR6(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 6, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR7(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 7, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR8(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 8, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR9(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 9, (__VA_ARGS__))

#define SLOT_COMMIT_LOCAL_VAR() \
  __slot_commit_args(__SLOT_PTR, __SLOT_STACK_OFFSET);

同样如果你不想用栈复制,可以直接引用堆上的变量,同样需要自己指定类型:

#define SLOT_ARG(n, type) \
  (*((type *)__SLOT_PTR->argv[n].value))

最后我们的slot如果需要释放,也需要释放变量分配的空间:

#define SLOT_FREE_ARGS(slot_ptr) \
  if ((slot_ptr)->argc > 0) { \
    free((slot_ptr)->argv); \
    (slot_ptr)->argc = 0; \
  }

SLOT的初始化需要增加argc字段:

#define SLOT_INIT(slot_ptr) \
  (slot_ptr)->argc = 0; \
  (slot_ptr)->signal = 0;

差不多完成了。且慢,我们还没处理完连接部分,比如signal,slot单方面释放问题,还有如果是堆上分配的空间,到底如何被释放,比如我们的堆上的slot连接完了之后,signal如果disconnect掉它,它就变成悬浮的对象了(signal并不知道这一点),我们需要有一个最终处理过程:这可以通过signal指定不同signaling标记再调用一次slot完成,重写signal_disconnect函数,增加这一调用:

void __signal_disconnect(struct __Signal *signal, struct __Slot *slot)
{
  if (slot->prev) {
    slot->prev->next = slot->next;
  } else {
    signal->slot = slot->next;
  }
  if (slot->next) {
    slot->next->prev = slot->prev;
  }
  /* 通知slot连接被释放 */
  slot->signaling = -1;
  if (setjmp(signal->environment) == 0) {
    slot->signal_slot_invoke(slot);
  }
}

重写我们SIGNAL_CONNECT宏,增加一个finalization表达式参数:

#define SIGNAL_CONNECT(signal_ptr, slot_ptr, statement, finalization) \
  { \
    SLOT * __SLOT_PTR = (SLOT *)(slot_ptr); \
    struct __Signal * __signal_ptr; \
    volatile size_t __SLOT_STACK_OFFSET; \
    __SLOT_PTR->stack_addr = (void *)&__SLOT_STACK_OFFSET; \
    __SLOT_PTR->signal_slot_invoke = &__signal_slot_invoke; \
    __signal_connect((struct __Signal *)(signal_ptr), __SLOT_PTR); \
    __SIGNAL_SLOT_ENTRY(__SLOT_PTR); \
    __signal_ptr = __SLOT_PTR->signal; \
    if (__SLOT_PTR->signaling > 0) { \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET; \
      __SLOT_STACK_OFFSET = (size_t)(__SLOT_PTR->stack_addr) - __SLOT_STACK_OFFSET; \
      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
     - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \
      { \
__SLOT_BLOCK statement; \
      } \
      longjmp(__signal_ptr->environment, 1); \
    } else if (__SLOT_PTR->signaling < 0) { \
      __SLOT_PTR->signal = 0; \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET; \
      __SLOT_STACK_OFFSET = (size_t)(__SLOT_PTR->stack_addr) - __SLOT_STACK_OFFSET; \
      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
     - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \
      { \
__SLOT_BLOCK finalization; \
      } \
      longjmp(__signal_ptr->environment, 1); \
    } \
  }

然后是单独释放signal或者slot的宏:

#define SIGNAL_FREE(signal_ptr) \
  while (signal->slot) { \
    __signal_disconnect(signal, signal->slot); \
  } \

#define SLOT_FREE(slot_ptr) \
  if ((slot_ptr)->signal) __signal_disconnect((slot_ptr)->signal, slot_ptr); \
  SLOT_FREE_ARGS(slot_ptr)

让我们来测试一下:

void connect(struct __Signal *signal)
{
  int i = 5;
  float j = 10.0;

  SLOT *slot = (SLOT *)malloc(sizeof(SLOT));

  /* 携带局部变量i,j当前值 */
  SLOT_FETCH_LOCAL_VAR2(slot, i, j);

  SIGNAL_CONNECT(signal, slot
, (
                    /* 提交携带的值 */
    SLOT_COMMIT_LOCAL_VAR();
    printf("int=%d, float=%f\n", i, j);
  )
, (
                    /* 释放slot参数及自身空间 */
    SLOT_FREE(slot);
    free(slot);
    )
  );

}

int main()
{

  SIGNAL2(int, float) signal;

  SLOT slot;


  SLOT_INIT(&slot);
  SIGNAL_INIT(&signal);

  /* 子函数中的连接 */ 
  connect((struct __Signal *)&signal);

  /* 宿主内部连接 */
  SIGNAL_CONNECT(&signal, &slot
, (
    SLOT_COMMIT_LOCAL_VAR();
    SLOT_REUSE_LOCAL_VAR(signal);
    printf("int=%d, float=%f\n", signal._1, signal._2);
  )
  , ()
  );

  SIGNAL2_EMIT(&signal, 5, 10);
 
  SIGNAL_DISCONNECT(&signal, &slot);

  return 0;
}

自此所有的工作基本就告成了,当然始终还存在些微小的修饰,以及高阶功能的增强,如果有人感兴趣了,留待一起讨论改善。

从这里的实现还可以知道,结合signal/slot,我们可以在c中完成类似lambda表达式这种函数式编程功能,你可以定义一系列参数的FUNCTOR宏,来模拟函数对象,然后作为参数进行传递。这些工作就交给大家自己完成了。

附件是目前为止的代码,欢迎大家下载测试并完善。
0 请登录后投票
   发表时间:2009-11-08  
补充一个图,并修正上次测试例子里的错误遗漏:

void connect(struct __Signal *signal)
{
int i = 5;
float j = 10.0;

SLOT *slot = (SLOT *)malloc(sizeof(SLOT));

/* 携带局部变量i,j当前值 */
SLOT_FETCH_LOCAL_VAR2(slot, slot, i, j);

SIGNAL_CONNECT(signal, slot
, (
/* 提交携带的值 */
SLOT_COMMIT_LOCAL_VAR();
printf("int=%d, float=%f\n", i, j);
)
, (
/* 提交携带的值 */
SLOT_COMMIT_LOCAL_VAR();
/* 释放slot参数及自身空间 */
SLOT_FREE(slot);
free(slot);
)
);

}
  • 大小: 4.7 KB
0 请登录后投票
   发表时间:2009-11-08   最后修改:2009-11-08
除了一般性的事件驱动式编程外,可以引用到这里signal/slot机制的地方就是函数式编程的需求,函数对象是一种在许多语言里被反复实现的东西,不过因为signal/slot本身就是一种特殊的函数调用机制,所以我并没有立即扩充这方面的实现,如果有人需要可以自己实现或者一起讨论它。

我们这里用另外一个实际需求作为用例来展示lambda表达式的运用:异步/并发,也就是一般意义上的线程。在Java在面向对象语言中引入了各种完整的运行机制后,线程作为一个语言特性也得到了大家的关注,这里有一篇文章(1)比较完整的描述了在一个面向对象语言下应该如何规划线程的问题,最终作者提出了一个新的asychronous关键字,他希望可以直接修饰一个方法为异步而免去了调用者的烦恼。尽管在我看来这是不太合适的,但也反应了我们需要更方便的办法来规划这件事情。

作者的的想法还特别借鉴了任务的概念。在一些实时系统中,比如我在用的UCOS-II,没有线程这个东西,并发运行的单元是任务,它们一般是在系统启动时就初始化好的;因为要保证系统的实时性,任务是严格按照优先级的设定来调度的。不过习惯了一般系统里的线程后,我总是会把一些可能需要等待耗时的代码作为异步方式运行;虽然在一般的子任务里的仍然可以继续创建任务,但它是非常繁琐的事情,例如需要分配和指定堆栈,指定优先级(不能冲突),通过长长的参数列表调用函数,以及同样的变量/值传递的问题。这种暂时性的创建过程大部分都是重复的,所以我第一个想法就是如何归整一下,能够让程序自动来完成。

有了signal-slot机制,这种想法的实现就更加方便了,我可以指定一个lambda表达式作为异步运行的单元,就是像这样,在代码里需要异步执行一些东西的时候写:

ASYNC_RUN(...);// 省略号里是异步执行的代码

实现它不是十分困难的事情,UCOS-II的代码我已经有了。现在类似的,我们用gcc环境里通常有的phtread库来演示一下这个过程。相对而言,这会更简单一些——没有堆栈和优先级的分配问题。

我们大概知道需要调用的lambda表达式在结构里由一对signal/slot成员来表示,然后ASYNC_RUN的宏展开后相当于发出signal,让它自动调用slot的代码,也就是上述的省略号部分;当然这是在线程被创建之后,也就是说我们还有一个通用的函数,作为入口点用于调用pthread_create函数来创建线程,然后在其中调用signal就可以了。

数据结构我们定义如下:

struct __AsyncTask {
  pthread_t pid;
  pthread_mutex_t mutex;
  pthread_cond_t running;
  SIGNAL0() signal;
  SLOT slot;
};

其中running成员表示线程是否运行,后面你会看到它的用途:

代码部分我们可以交给两个函数进行,一个__async_create和一个__async_run来完成。

void __async_create(struct __AsyncTask *async_task)
{
  pthread_mutex_init(&async_task->mutex, NULL);
  pthread_cond_init(&async_task->running, NULL);

  pthread_mutex_lock(&async_task->mutex);

  // 将 async_task作为参数传递给执行线程
  pthread_create(&async_task->pid, NULL, (void *(*)(void *))&__async_run, (void *)async_task);

  // 等待线程运行并复制完栈
  pthread_cond_wait(&async_task->running, &async_task->mutex);

  pthread_mutex_unlock(&async_task->mutex);
}

void * __async_run(struct __AsyncTask *async_task)
{
  SIGNAL0_EMIT(&async_task->signal);
  SIGNAL_DISCONNECT(&async_task->signal, &async_task->slot);
  pthread_cond_destroy(&async_task->cond);
  return 0;
}

然后就是定义ASYNC_RUN宏:

#define ASYNC_RUN(a) \
  { \
    struct __AsyncTask __async_task; \
    SIGNAL_INIT(&__async_task.signal); \
    SLOT_INIT(&__async_task.slot); \
    SIGNAL_CONNECT(&__async_task.signal \
                   , &__async_task.slot \
                   , ( \
                       SLOT_REPLICATE_LOCAL_STACK(); \
                       pthread_mutex_lock(&__async_task.mutex); \
                       pthread_cond_signal(&__async_task.running); \
                       pthread_mutex_unlock(&__async_task.mutex); \
                       { \
                         a; \
                       } \
                     ) \
                   , () \
                  ); \
    __async_create(&__async_task); \
  }

这其中需要注意的地方就是并发时,在线程还没有开始运行可能生成线程的函数已经结束了,但这样我们就无法引用到局部自由变量了。为了达成这个目的,当然可以使用SLOT_FETCH_LOCAL_VAR()宏,但我们这里因为确切知道signal发出的时候——几乎同时进行,所以可以用一个锁互斥的操作,让生成函数等待线程运行并复制完本地栈之后再继续运行,复制栈的工作交给SLOT_REPLICATE_LOCAL_STACK宏,前面我们已经描述过得到栈帧大小的办法,所以我们可以省略掉对SLOT_REUSE_LOCAL_VAR宏的使用(注意:但不包括函数的参数部分,如果要使用参数部分的值,另定义局部变量赋值并使用它),这样显得更加傻瓜化一些。SLOT_REPLICATE_LOCAL_STACK宏如下:

#define SLOT_REPLICATE_LOCAL_STACK() \
SLOT_LOCAL_VAR(__SLOT_PTR, struct __Slot *) = __SLOT_PTR; \
SLOT_LOCAL_VAR(__SLOT_STACK_OFFSET, size_t) = __SLOT_STACK_OFFSET; \
SLOT_LOCAL_VAR(__signal_ptr, struct __Signal *)= __signal_ptr; \
memcpy((void *)((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET), __SLOT_PTR->stack_frame_tail \
        , (size_t)__SLOT_PTR->stack_frame_head + __SLOT_STACK_OFFSET - (size_t)__SLOT_PTR->stack_frame_tail);

         
增加一些辅助设施,得到线程句柄的宏,等等:

#define ASYNC_PTHREAD_ID() __async_task.pid
#define ASYNC_LOCAL_VAR SLOT_LOCAL_VAR
...

最后是测试程序:

int main()
{
  int i = 5;
  float j = 10.0;
  volatile pthread_t pid;
  ASYNC_RUN(
    ASYNC_LOCAL_VAR(pid) = ASYNC_PTHREAD_ID();
    printf("int=%d, float=%f\n", i, j);
    );

  while (pid == 0);
  pthread_join(pid, NULL);
  return 0;
}


并发是编程中要处理的一大问题,我们这里只涉及一个非常小的局部,如果可能的话,将来我们将单独涉猎它。

到此为止,整个介绍过程应该可以圆满结束了,欢迎大家多提宝贵意见,帮助我继续改进它。

“咦,这位愁眉苦脸的同学还有什么问题吗?”

“嗯。。。啊。。。”

“哦,你的意思是说,虽然大部分代码是C写就的,但还是存在一小片段的代码无法用C实现,而你的平台上的这种特定方案难以实现是吗?”

这的确是可能的,需要在某些平台上使用汇编可能是不太切实际的要求——“不过没有关系,我这里还有一个用C实现的方案”。

”?。。。“

“是的,虽然它仍然不能保证百分之百的兼容,但相信绝大多数情况下是可行的。最重要的是,它是由纯ANSI C实现的”。

“不过今天的时间不多了,我们留待下次再讲吧”,说完某人就转身离去,后头砸来一片臭鸡蛋。

(1) http://www.ibm.com/developerworks/cn/java/j-king/
0 请登录后投票
论坛首页 编程语言技术版

跳转论坛:
Global site tag (gtag.js) - Google Analytics