免费注册 查看新帖 |

Chinaunix

  平台 论坛 博客 文库
最近访问板块 发新帖
楼主: TiGEr.zZ
打印 上一主题 下一主题

[C] 在ANSI C下设计和实现简便通用的signal-slot机制 [复制链接]

论坛徽章:
0
11 [报告]
发表于 2009-10-16 21:40 |只看该作者
模仿的对象有了,可以展开工作了。这个时候也许有人会有疑问,明明是要用标准C来实现,怎么举了一堆C++的例子;用C++也罢了,干嘛又扯BOOST这种诡异的东西?这是为了更好的抽象。一般来说C++的抽象能力要胜过C一筹,所以同样的问题用C++来分析,可能更加容易抓住本质。如果到这里你没有疑问了,可以跳过下面两段的内容。

若是继续问起什么是抽象,让我解释起来恐怕就吃力了——抽象本身就是一个很抽象的概念。书上对这个问题的解释也是十分复杂的,我这里从不同角度谈一下。简单说来,我们对事物的认识来自于整个大脑的反射。比如经过在社会上生活一段时间后,大部分人都可以迅速直接的理解日常生活中常用的词汇,无论它的含义是多么深奥;但如果见到一个完全陌生的孤立单词,无论它多么浅显可能也无从去理解它;如果这个单词的含义比较复杂,这个时候需要一个人详细解释它的来龙去脉可能才能明白;等到你反复见过或者使用过几次之后,再使用它的时候就可能轻而易举,和常用的词汇没有区别。这就是经过了一个抽象过程,把具有复杂内涵的事物直接反映到大脑里。这种映射过程本身会有千差万别,但只要不影响到对应关系就可以。幼儿的学习过程开始只是单纯的模仿,有些人即使到了成年后可能也并没有去了解过那些习以为常的认识究竟来自于哪里,连抽象的过程也免了。

抽象的一个主要作用就是为了大脑可以更加迅速对事物作出反应。通过抽象把无关紧要的地方封闭掉,抓住关键特征,降低外在复杂性,更好了帮助我们认知事物。人类语言就是一个抽象集合的一个代表,通过增加单词或者赋予单词更多的涵义我们可以通过各种简洁巧妙的方式表达出想要表达的意思。相比起来,虽然程序设计中的跟计算机打交道的语言能力还很弱小,但我们也可以通过类型,函数以及分层等手段达到类似的效果。

我所知道的牛人之一Trustno1认为好的语言是它的代码熵值能够逼近于问题域的的熵值,这里有他翻译的论文(1),不妨了解一下。类似的,一个好的设计可以把问题域的关键特征暴露出来,从而让使用者可以直接的理解和操纵。举例而言,比如汽车,对操作者而言就是需要速度和方向的控制,表现为油门和方向盘;但在某些时候,受制于内部的机制,我们不得不给问题附加上本不属于它的内容,即使这样的设计也要足够简洁明了的反应问题本身。还是汽车为例,档位就是这么一个东西。虽然C++语言在大多数情况下和C不存在本质差异,但借助于模板,在类型表达方面还是具备一些C不具有的能力。BOOST库正是这种能力的一个美妙演示,虽然在内部,受制于语言的不完善,存在一些古怪的技巧;但在外部接口上,它都是经过精心设计和良久考验的,在大多数情况下能够帮助你卸除思维上的负担。

这一点上,boost.signal做得已经非常完美了。所以后面的工作只是尽量去模拟它,另外某些地方可能还需要增加一些档位。

因为在C中没有类的概念,所以第一件事情就是分解我们要处理的对象。在C中这完全不是问题,可以通过结构和方法来代替类。网络上已经有了非常全面关于如何在C语言中实现面向对象设计的文章,这里我推荐一个具体而完整的实现(2)。

这样signal类就可以由一个signal结构代替,然后其成员方法可以由一些以signal结构为参数的自由函数代替,比如signal_connect(signal *, ...),signal_emit(signal *, ...)以及signal_disconnect(....)。最终,一般必不可少的,我们需要一些宏定义来让使用的一些固定范式的代码能够自动生成,从而暴露出最简化的使用接口。也就是最终我们面对的可能是SIGNAL_CONNECT(...),SIGNAL_EMIT(...),SIGNAL_DISCONNECT(...)等这样一些宏;为了一致性,有的时候即使是宏对应的就是一个函数的情况下,我们也会定义它,这样可以让使用者不用分神去关注一些跟事情本身无关的方面。

通过前面的介绍,大家应该能够了解到跟传统回调不同的关键在于我们如何传递更多的参数。在C++中这是通过泛型实现的,在C中我们没有对应的手段,但这其实并不困难,因为我们可以把参数附着在结构中一起传递,最坏的情况也不过于我们可能会丢失它们的类型。也就是signal中不仅有一些库内部要使用的信息,还要暴露出部分接口供用户往其中附加数据,以便于signal发出的时候,同过它转移这些数据给真正的调用过程。

虽然传递参数不仅仅说是定义它而已,但是这个问题我们肯定要多次遇到,因为我们知道回调发生还有部分数据在在建立slot连接时附着的,所以我决定先花些时间解决它,后面会更安心些。另外这个问题比较容易:P

有两种方法可以实现这个过程,一个是在结构中添加新的字段,每个参数表现为一个新的字段;还有一种就是通过signal自身管理这些数据,它可以提供调用的接口,以便用户往其中提交参数,并自动的分配内存来维护这些参数。前一个种方法会破坏类型的一致性,但后一种方法会失去类型的安全性,并且往往需要更多的代码。

关于类型的一致性,其实并不难解决,看过C中实现面向对象的继承的方法后相信都知道如何解决。我们可以把数据附着在库中所需要的signal结构的后面来保证整个结构跟它的兼容性,这是因为C中规定结构中第一个数据成员的地址等于结构的地址。也就是说,类似下面一些结构都可以强制转换到一个signal类型上,为了突出类型,我们用首字母大些Signal表达signal类型。

Signal { ... };
Signal1 { Signal sig; int var1 };
Signal2 { Signal sig; float var1; int var2 };
...
类似可以一直扩展下去。
在库中,比如调用signal_connect(Signal *, ...)函数的地方,可以在用户提交的最终Signal1, Signal2等等类型上附加强制类型转换,也就是定义宏

#define SIGNAL_CONNECT(signal, ...)  signal_connect((Signal *) signal, ...)

以上省略号都是表示未知内容,而不是语法所许可的,后面如果没有特别指明的地方都是如此。

我觉得这个方法可行,就先采用它看看,如果后面不合适了我们再修改它。接下来就是如何具体声明Signal及其继承类型结构了,在前面我们已经看到BOOST中非常完美的表达方法:signal(函数原型),这在C中是无法做到的。

一种最直接的方法,就是上面直接写出的那样,但这样会跟使用者添加一些负担,它必须在每个使用的地方定义类型,并且这个结构中包含他原本不关心的东西。另外,这个工作大部分都是重复的。

上面提及的C面向对象实现中有实现一般class定义的例子,但仍然解决不了重复的问题。既然Signal只是一个特定的概念,我们只需要面向这个概念的接口;假设我们的回调对象是一个类似void(int, float)的函数,对于boost.signal,我们写为signal<void(int, float)>。这里的关键是参数部分。虽然boost可以支持带返回值的回调,但这个问题很容易以多附加一个参数的方式代替。所以,问题就退化为我们是不是有什么办法,可以让用户直接写SIGNAL(int, float)来定义这么一个signal呢?

(1) http://www.javaeye.com/topic/452701
(2) http://www.state-machine.com/devzone/cplus_3.0_manual.pdf

论坛徽章:
0
12 [报告]
发表于 2009-10-17 09:52 |只看该作者
好累 顶一下

论坛徽章:
0
13 [报告]
发表于 2009-10-19 23:00 |只看该作者
大家肯定都能想到这要通过宏来实现,虽然书上万遍提醒宏不能滥用——不知道这个规则哪里来的(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
14 [报告]
发表于 2009-10-22 14:58 |只看该作者
有了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

[ 本帖最后由 TiGEr.zZ 于 2009-10-22 15:01 编辑 ]

论坛徽章:
0
15 [报告]
发表于 2009-10-28 00:21 |只看该作者
本应该是昨天写的,结果被一封来信打断了我的生活——这跟程序一样,在不停被打断的过程中执行着。

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

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

说起前面那种跨越语句块的跳转,对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/sea ... =%CD%F5%CA%A4%CF%E9
(2)http://www.limodev.cn/blog/archives/340

图三.GIF (2.91 KB, 下载次数: 113)

图三.GIF

图四.GIF (3.45 KB, 下载次数: 111)

图四.GIF

论坛徽章:
0
16 [报告]
发表于 2009-10-28 12:17 |只看该作者
那是不是可以保留一对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/gc ... ml#Nested-Functions
(2) http://gcc.gnu.org/onlinedocs/gc ... ml#Labels-as-Values

论坛徽章:
0
17 [报告]
发表于 2009-10-28 14:26 |只看该作者
再做个pdf的吧

论坛徽章:
0
18 [报告]
发表于 2009-11-03 00:28 |只看该作者
若问起如何想到这样时,我只能说是模仿——从前面的内容大家应该都可以了解到。所以要感谢谢牛人们带来的创意设计,借用一句话:只有想不到的,没有做不到的。不过看到这里百般折腾的摆弄,牛人们也肯定会不以为然:有这个闲工夫早就搞一门新语言了。呵呵,插科打诨一下,别以为意。

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

可能很多人只是通过文章来了解信息,并没有真正去试过,所以我说过的东西并没有经过充分的验证,至少明显其中有不少手误。这些就罢了,对于拿到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/normalnoteb ... 6/06/25/833458.aspx
(3) http://blog.solrex.cn/articles/call-stack-and-satck-frame.html
(4) http://liyiwen.javaeye.com/blog/345525
(5) http://blog.chinaunix.net/u/13392/showart_70276.html

图五.GIF (4.42 KB, 下载次数: 89)

图五.GIF

图六.GIF (5.1 KB, 下载次数: 100)

图六.GIF

论坛徽章:
0
19 [报告]
发表于 2009-11-05 16:51 |只看该作者
抵抗编译器的优化贯穿着始终,它成了实现过程中我遭遇的最繁琐的任务。因为没有一个标准的指导和熟练的反应,常常是写着写着突然发现不能工作了,再回过头来花很长时间查找。由此也可见编译器工作者们的辛劳,谢谢他们——尽管让我吃了不少苦头。

趟过荆棘密布,危机四伏的沼泽地后,接下来面对的就是此行的主要任务之一:绑定本地变量。自搭起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

图七.GIF (4.75 KB, 下载次数: 93)

图七.GIF

论坛徽章:
0
20 [报告]
发表于 2009-11-06 17:02 |只看该作者
完成了绑定局部变量的任务之后对成功就满怀信心了,让我们再接再厉,一口气把剩下的任务完成,要攻克的堡垒只剩下最后一座了:绑定易失变量的值,以及堆空间的分配和释放,让我们在夕阳落山之前解决它,然后可以美美的睡上一觉。

先说说上次栈帧断言的事,通过宏定义的栈帧大小很难预先计算——用户可能随时修改代码;尽管一个较大的值比较安全,但一方面这不是绝对的,另一方面在一些场合(比如嵌入式)我们要使用紧凑的栈来节约空间,这就要避免栈溢出的情况。尽管一般来说因为栈分配是编译期的事所以无法做到动态进行(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.addr = (size_t)(va_arg(ap, void *));
    slot->argv.size = va_arg(ap, size_t);
    size += slot->argv.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.value = p;
    // 保存值
    memcpy(slot->argv.value, (void *)(slot->argv.addr), slot->argv.size);
    p += slot->argv.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.addr - slot_stack_offset), slot->argv.value, slot->argv.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宏,来模拟函数对象,然后作为参数进行传递。这些工作就交给大家自己完成了。

附件是目前为止的代码,欢迎大家下载测试并完善。

sigslot.zip

7.03 KB, 下载次数: 68

您需要登录后才可以回帖 登录 | 注册

本版积分规则 发表回复

  

北京盛拓优讯信息技术有限公司. 版权所有 京ICP备16024965号-6 北京市公安局海淀分局网监中心备案编号:11010802020122 niuxiaotong@pcpop.com 17352615567
未成年举报专区
中国互联网协会会员  联系我们:huangweiwei@itpub.net
感谢所有关心和支持过ChinaUnix的朋友们 转载本站内容请注明原作者名及出处

清除 Cookies - ChinaUnix - Archiver - WAP - TOP