我们经常会看到头文件里面会有下列的结构。

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "C" {
#endif
/*...*/
#ifdef __cplusplus
}
#endif

而且我们也大都知道这是为了和C语言兼容。但是更具体的事情了?
实际上,这段代码是为了保证用C语言调用C++函数或者用C++调用C语言实现的函数,都能够顺利进行。不仅仅是单向的左右。如果没有加#ifdef cplusplus判断就是单向的吧。
大家都知道C++支持函数重载,而C语言是不支持的。所以,类似函数int add(int,int)很可能在链接的时候,符号是_add_int_int。而在C语言里面,还是_add。既然符号不一样,那就会找不到吧。这就发生在用C语言去调用C++实现的函数的时候。如果在C++头文件里面加上extern “C”声明,那么就会按照C语言的方式生成函数,自然能和C语言兼容了。
如下列代码,就能顺利运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//add.h
#ifndef ADD_H
#define ADD_H

//int add(int x, int y);

#ifdef __cplusplus
extern "C" {
#endif
int add(int x, int y);
#ifdef __cplusplus
}
#endif

#endif

//add.cpp
#include "add.h"

int add(int x, int y)
{
return x + y;
}

//main.c
#include <stdio.h>
#include "add.h"

int main()
{
add(1, 2);

return 0;
}

如果把头文件定义改成,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//add.h
#ifndef ADD_H
#define ADD_H

int add(int x, int y);

/*#ifdef __cplusplus
extern "C" {
#endif
int add(int x, int y);
#ifdef __cplusplus
}
#endif*/

#endif

main.c就无法链接到函数_add上面。提示:main.obj : error LNK2019: 无法解析的外部符号 _add,该符号在函数 _main 中被引用。
现在再反过来,用c语言实现add,c++实现main。代码如下,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//add.h
#ifndef ADD_H
#define ADD_H

int add(int x, int y);

/*#ifdef __cplusplus
extern "C" {
#endif
int add(int x, int y);
#ifdef __cplusplus
}
#endif*/

#endif

//add.c
#include "add.h"

int add(int x, int y)
{
return x + y;
}

//main.cpp
#include <stdio.h>
#include "add.h"

int main()
{
add(1, 2);

return 0;
}

结果还是无法链接,提示: error LNK2019: 无法解析的外部符号 “int
cdecl add(int,int)” (?add@@YAHHH@Z),该符号在函数 _main 中被引用。这是因为main.cpp用C++的方式去查找函数符号,_add_int_int,而add.c里面生成的符号是_add。因此无法链接上去。那么,能不能直接加上extern “C”就行了?
由于C语言里面不支持extern “C”,那么作为C语言的头文件就不能这么加,应该加上#ifdef __cplusplus的判断,再将所有的函数定义前加上extern “C”,就能保证该头文件同时兼容C语言和C++了。

以前在群里面发现有人说,引用可以被赋值,然后其推断出引用其实是可以重新指向其它对象的,而不是只能在初始化时确定所指对象,以后不能改变。如果是这样的话,引用和指针还有什么区别了。存在引用的好处就是,引用一经过定义就确定所指对象,以后不用测试其是否指向无效对象,也不用担心所指对象会改变。
下面来看看这段造成误解的代码吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

int main()
{
int nA = 10;
int nRef = nA;//必须初始化

cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

int nB = 11;
nRef = nB;//难道引用可以重新指向nB?
cout << "nB:" << nB << endl;
cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

nRef = 12;
cout << "nB:" << nB << endl;
cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

return 0;
}

运行结果:
从结果可以看到,用nB对nRef进行赋值后,nA的值也变成11了,由此可见nRef仍然指向nA。而再对nRef赋值12,只有nA变成12,而nB仍然是11。这些都可以很清楚的说明,引用所指的对象是没有变化的,一经初始化就不会改变。
下面再来看看,注释掉输出语句后的反汇编代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int main()
{
00EC48F0 push ebp
00EC48F1 mov ebp,esp
00EC48F3 sub esp,0E4h
00EC48F9 push ebx
00EC48FA push esi
00EC48FB push edi
00EC48FC lea edi,[ebp-0E4h]
00EC4902 mov ecx,39h
00EC4907 mov eax,0CCCCCCCCh
00EC490C rep stos dword ptr es:[edi]
int nA = 10;
00EC490E mov dword ptr [nA],0Ah
int nRef = nA;//必须初始化
00EC4915 lea eax,[nA]
00EC4918 mov dword ptr [nRef],eax

//cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

int nB = 11;
00EC491B mov dword ptr [nB],0Bh
nRef = nB;//难道引用可以重新指向nB?
00EC4922 mov eax,dword ptr [nRef]
00EC4925 mov ecx,dword ptr [nB]
00EC4928 mov dword ptr [eax],ecx
//cout << "nB:" << nB << endl;
//cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

nRef = 12;
00EC492A mov eax,dword ptr [nRef]
00EC492D mov dword ptr [eax],0Ch
//cout << "nB:" << nB << endl;
//cout << "nA:" << nA << ' ' << "nRef:" << nRef << endl;

return 0;
00EC4933 xor eax,eax
}

从以下几句能看到nRef实际上存储的是nA的地址。lea是取有效地址的意思。

1
2
3
int nRef = nA;//必须初始化
00EC4915 lea eax,[nA]
00EC4918 mov dword ptr [nRef],eax

从给引用赋值的汇编代码可以看出,实际上的动作是把nRef代表的地址放入一个寄存器,再取nB的值放入一个寄存器,然后将nB的值赋给nRef指向的内存。

1
2
3
4
nRef = nB;//难道引用可以重新指向nB?
00EC4922 mov eax,dword ptr [nRef]
00EC4925 mov ecx,dword ptr [nB]
00EC4928 mov dword ptr [eax],ecx

通常,我们知道,一个程序执行起来就是一个进程,一个进程里面至少包含一个线程。那么什么是模块了,被加载内存中的dll或者exe都是模块。
据说,windows有个数据结构叫做Module Database(MDB),专门代表模块。可执行程序(exe或者dll),包括其代码、资源等被加载到内存中,windows大概就用这个结果管理它吧。这个数据结构其实代表的就是一个PE文件的表头。
那么进程了,代表的又是什么了,既然模块表示的是加载到内存的pe文件。进程应该代表的是资源拥有者吧。比如说,地址空间、申请的内存、打开的文件、所有的线程、以及模块(加载的dll,本身的exe)。windows也有一个叫做Process Database(PDB)的数据结构负责管理它。这样看,进程是各种所有权的集合吧。
那么线程了。执行的线程表示的是模块中一段正在被执行的代码吧。线程是被cpu调度的基本单位,因此真正占有cpu时间片的是线程,而不是进程。也有一个叫做Thread Database(TDB)的数据结构来代表线程,里面会记录执行线程区域储存空间(Thread Local Storage,TLS)、讯息队列、handle表格等等。其实,只有在多cpu的机器上面才能实现真正的并行。在单cpu上面,只是在硬件计时器的通知下不断的切换线程。

上一篇文章里面讲到用allocator预分配空间已经在需要的时候才构造对象,其实还有其它的办法实现这一功能。operator new表达式也可以只分配内存而不初始化,但是返回的是void指针,因此没有allocator类型化的优点。同样placement new表达式只负责调用指定的构造函数初始化内存,不申请内存,与allocator的construct相比,这个表达式更加方便,可以使用任意类型的构造函数参数列表。但是,construct只能使用复制构造函数。
同样的,可以使用operator delete代替allocator的deallocate释放内存。使用析构函数代替allocator的destroy清理对象。下面我把原来使用allocator的代码改写成使用这些表达式的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <memory>
#include <algorithm>

template <typename T>
class Vector
{
public:
Vector() : elements(0), first_free(0), end(0) {}
~Vector()
{
for (T* p = first_free; p != elements;)
{
//allocator.destroy(--p);
--p;
p->~T();
}

if (elements)
{
//allocator.deallocate(elements, end - elements);
operator delete[](elements);
}
}

void push_back(const T t)
{
if (first_free == end)
{
reallocate();
}

//allocator.construct(first_free, t);//复制构造新对象
new (first_free) T(t);
++first_free;
}

void pop_back()
{
if (first_free != elements)
{
//allocator.destroy(--first_free);
--first_free;
first_free->~T();
}
}

private:
//std::allocator<T> allocator;
void reallocate()
{
std::ptrdiff_t size = first_free - elements;
std::ptrdiff_t newcapacity = 2 * std::max(size, 1);

//T* newelements = allocator.allocate(newcapacity);//申请新内存
T* newelements = static_cast<T*>(operator new[](newcapacity * sizeof(T)));

//newelements处直接复制构造
std::uninitialized_copy(elements, first_free, newelements);

for (T* p = first_free; p != elements;)
{
//allocator.destroy(--p);//析构原对象
--p;
p->~T();
}

if (elements)
{
//allocator.deallocate(elements, end - elements);//释放内存
operator delete[](elements);
}

elements = newelements;
first_free = elements + size;
end = elements + newcapacity;
}

T* elements;
T* first_free;
T* end;
};

//template <typename T> std::allocator<T> Vector<T>::allocator;

placement new表达式的一般形式是new (地址) 类型名(初始化列表),由于使用初始化列表直接构造对象,因此不用拷贝。而且比只能用复制构造函数构造的allocator的construct函数更方便。
比如,allocator alloc;string* sp = alloc.allocate(2);如果用定位new表达式,new (sp) string(b, e);(b,e是用2个迭代器构造string类型)。但是,用construct的话,则是alloc.construct(sp+1,string(b,e));可以清楚的看到,需要用迭代器构造个临时的string对象,再用这个临时对象调用复制构造函数。因此,会有细微的性能损失。最终要的是,有些类根本没有可以调用的复制构造函数,比如该函数是私有的,那么就必须使用placement new表达式了。
operator new和operator delete的使用方式类似于new和delete,具体的可以参看代码。

相信大家都用过vector,而且用多了之后会发现怎么还有一个我们基本上不会使用的模版参数Allocator。查阅msdn能够看到,vector的类型是template >class vector。那么这个Allocator到底是用来做什么的了。实际上,Allocator是stl里面用到的内存管理工具,因为这个时候new和delete已经不能满足要求了。
比如说,vector需要预分配内存,需要添加元素。如果使用new分配内存的话,那么在预分配内存的时候就会调用一次构造函数,添加元素的时候会调用一次赋值操作符。其实,这些操作都是冗余的,会降低效率。这也是stl和手写容器的最大区别。如果使用allocator,那么可以把分配内存和构造操作分开,在预分配内存的时候单纯分配空间,不构造,而在添加新元素的时候,调用复制构造函数。
要达到这样的要求,需要allocator提供对应的接口。实际上,std::allocator有allocate和deallocate负责内存申请释放,construct和destroy负责对象构造和析构。下面来解析相关模拟的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <memory>
#include <algorithm>

template <typename T>
class Vector
{
public:
Vector() : elements(0), first_free(0), end(0) {}
~Vector()
{
for (T* p = first_free; p != elements;)
{
allocator.destroy(--p);
}

if (elements)
{
allocator.deallocate(elements, end - elements);
}
}

void push_back(const T t)
{
if (first_free == end)
{
reallocate();
}

allocator.construct(first_free, t);//复制构造新对象
++first_free;
}

void pop_back()
{
if (first_free != elements)
{
allocator.destroy(--first_free);
}
}

private:
static std::allocator<T> allocator;
void reallocate()
{
std::ptrdiff_t size = first_free - elements;
std::ptrdiff_t newcapacity = 2 * std::max(size, 1);

T* newelements = allocator.allocate(newcapacity);//申请新内存
//newelements处直接复制构造
std::uninitialized_copy(elements, first_free, newelements);

for (T* p = first_free; p != elements;)
{
allocator.destroy(--p);//析构原对象
}

if (elements)
{
allocator.deallocate(elements, end - elements);//释放内存
}

elements = newelements;
first_free = elements + size;
end = elements + newcapacity;
}

T* elements;
T* first_free;
T* end;
};

template <typename T> std::allocator<T> Vector<T>::allocator;

在push_back里面,使用allocator.construct(first_free, t)初始化内容,相当于调用复制构造函数。如果空间已满,调用reallocate。在这里面用allocator.allocate(newcapacity)申请原来2倍大小的空间,这个操作不会调用构造函数。有意思的是接下来调用uninitialized_copy,这个函数直接在新地址上面使用原来的值构造对象,而不是赋值。然后,循环析构原来对象,释放原来内存。如果清楚了解复制构造函数和赋值操作的区别,那么理解allocator的意义是很容易的。关于这个,可以阅读我另外一篇文章
另外一个关于模版类静态成员的初始化,大家可以看到我直接写在头文件的最后一句了。如果写在源文件里面,那么特例化模版才能定义静态成员。而这样直接包含头文件就可以使用了,不信大家可以试试。我的环境是vs2013。

我们都知道平面可以表示为Ax+By+Cz+d=0,但是很多人不知道这个表达式隐含的意义。
从另一个角度,点法式来看平面,假设法线n=(a,b,c),平面上一点p0(x0,y0,z0),平面上任意一点p为(x,y,z)。那么平面可以表示为n
(p-p0)=0,即(a,b,c)(x-x0,y-y0,z-z0)=0,可以化简为ax+by+cz-ax0-by0-cz0=0。
上面两个式子一对比,就能发现a=kA,b=kB,c=kC,-a
x0-by0-cz0=kD。取k=1,就可以得到系数相等。所以,看到平面的一般式就能够得到法线为n=(A,B,C)。
根据上面的结论,进一步推广,我们可以把一般式改成点积的形式(A,B,C)(x,y,z)+D=0。这个就是平面的向量表示,即**nP+d=0。那么这个向量表示法有什么意义了。首先,在程序实现中,我们只需要保存法线n和系数d就可以了。
其次,d还代表从原点到平面上任意点的向量在法线上的投影长度。所以,利用这个信息,可以方便的求出任意点到平面的距离。
假设n为单位法线,假设任意点为q,q在平面上的投影为q’,那么向量
(q-q’)=kn。意思是向量(q-q’)肯定和法线共线,长度是其k倍。在上式两边乘以n,可以得到(q-q’)n=knn,即(qn-q’n)=k,即k=q*n-q’n。由于q’是平面上的点,所以q’n=-d,那么,k=qn+d。k即是点q到平面的长度,其中q,n,d都是已知的。运用这个式子的时候,必须得先归一化n**。
现在可以很清晰的看到,如果我们将平面表示为向量形式,而且n是归一化的,那么计算任意点到平面的距离是非常方便的。

三维模型上的标量场指的是三维模型上面的每个顶点都有一个标量值,从而构成了整体的标量场。如果能够以颜色代表这个标量值的大小,那么整体的标量场就能够方便的观察出来。一般来说,hls颜色模型的色相能够代表从红到蓝的渐进过度,因此可以用hsl转rgb的方法生成一维纹理。然后归一化标量场,将标量值作为一维的纹理坐标,从而显示出整个标量场。如何加载任意格式图片作为纹理,以及其它关于OpenGL纹理的内容,可以参考我其它文章。
但是这样比较麻烦。首先,需要颜色空间转换,还有使用的是一维纹理。其实,有更方便的做法。直接找一张代表从红到蓝的渐进过度纹理图片,加载这张图片生成纹理,使用二维纹理坐标,固定一维的坐标,如s,变化t。
对应的二维纹理图和实现效果:


这里需要注意一个问题,就是极限纹理坐标的跳变。比如说,纹理坐标很小接近0,可能就会直接跳到1去了,插值出来的颜色就会从红直接变成蓝,或者从蓝直接变成红,明显是不对的。如图所示,


处理这个问题的方法,有两种,一种是修改纹理坐标的模型为GL_MIRRORED_REPEAT的而不是一般的GL_REPEAT,第二个是将纹理坐标的范围压缩到大于0小于1之间。求的最大和最小标量值后,往外扩展下,再进行标量值的归一化。可以用下面的代码,处理。
float fDis = fMax - fMin;
fMin = fMin - fDis 0.001;
fMax = fMax + fDis
0.001;
这样正确的显示如下,


现在可以看到,本来是带蓝色中心的红带,变成了全部是红色的带了。

这篇文章是在我的另一篇文章“继承mfc的cwnd类渲染opengl”的基础上改进的。
AntTweakBar是一个条状的菜单库,类似于内嵌于渲染窗口的属性条,可以和OpenGL渲染窗口融为一体,效果很好看。以前见过有人用这个库,效果比较好看就关注了下。在网上找相关的资料,发现只有人在glut下使用。还有人因为在MFC下使用不成功,认为和MFC不兼容。其实,这个库只要给了渲染引擎就行了,然后把一些事情传给它就能交互了。
AntTweakBar提供的都是简单的C接口,所以非常方便和已有的界面框架整合,无论你用的是OpenGL还是DX渲染,无论你用的界面框架是glut还是glfw或者sdl等等都行。官方网站也提供了整合步骤
我现在介绍下,在我的框架里面的整合步骤。
第一步,在OnCreate函数里面调用TwInit(TW_OPENGL, NULL);初始化。
第二步,在OnCreate函数里面创建bar并且绑定变量。如,

1
2
3
4
5
*myBar;
myBar = TwNewBar("Test");
TwDefine(" Test refresh=0.5 color='96 216 224' alpha=0 text=dark");
static int nTest = 0;
TwAddVarRW(myBar, "Test", TW_TYPE_INT32, nTest, "test");

第三步,在OnSize里面调用TwWindowSize(cx, cy);
第四步,修改PreTranslateMessage实现为,

1
2
3
4
5
if (TwEventWin(pMsg->hwnd, pMsg->message, pMsg->wParam, pMsg->lParam))
{
return 1;
}
return CWnd::PreTranslateMessage(pMsg);

第五步,在RenderScene函数最后添加TwDraw(),但是得保证在SwapBuffers(m_hDC)之前添加这句。
第六步,在OnDestroy()中调用TwTerminate()释放资源。
综上所述,整合还是非常简单的。
另外,AntTweakBar提供TwDefine函数用于设置颜色,透明度等参数,可以调整效果,非常方便。还有一个需要注意的是,TwAddVarRW绑定的变量不能是局部变量,否则会出错。当你绑定的变量值变化时候,就是你和AntTweakBar交互的时候了。总之,非常方便使用吧。
最后,我要说的是,AntTweakBar自己会使用自身的光标,它的光标会覆盖MFC窗口的光标。如果,我们坚持使用MFC的光标的话,该怎么办了。只能禁止掉AntTweakBar的光标了。方法是,修改它的源码,重新编译成库。我们把TwMgr.cpp的针对windows系统的SetCursor函数内部实现注视掉就行了。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
void CTwMgr::SetCursor(CTwMgr::CCursor _Cursor)
{
/*if( m_CursorsCreated )
{
CURSORINFO ci;
memset(ci, 0, sizeof(ci));
ci.cbSize = sizeof(ci);
BOOL ok = ::GetCursorInfo(ci);
if( ok (ci.flags CURSOR_SHOWING) )
::SetCursor(_Cursor);
}*/
}

以下是实现效果,

有些时候,我们想知道鼠标点中了哪个物体或者哪个部分,更详细的是最靠近模型的哪个顶点或者哪条线,或者哪个面。这些选取问题有不同的解决办法。如果只是针对图元的选取,可以直接用OpenGL的选取模式实现。但是有的时候,情况更加复杂,比如我们想通过鼠标在模型上面绘制一条线,然后对模型进行剖分等。这就需要把鼠标点变换到三维顶点,再进一步的操作。
我在这里贴出2个我自己使用的函数,针对鼠标点投影视角空间和反投影。
投影:``` stylus
void CMesh::ScreenToModel(v2f point, v3f point3d)
{
float fWinX, fWinY, fWinZ;
int nX, nY;
GLdouble fX, fY, fZ;

    nX = point[0];
    nY = point[1];
    glReadBuffer(GL_BACK);
    glReadPixels(nX, nY, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, fWinZ);
    if (fabs(fWinZ - 1.0) > 1e-8)
    {
        fWinX = nX;
        fWinY = nY;
        gluUnProject(fWinX, fWinY, fWinZ, m_pMatMV, m_pMatProj, m_pViewport,
            fX, fY, fZ);
        point3d[0] = fX;
        point3d[1] = fY;
        point3d[2] = fZ;
    }
    else
    {
        point3d[0] = point3d[1] = 0.0;
        point3d[2] = 1.0;
    }

}

1
2
3
4
5
6
7
8
9
10
11
使用该函数必须注意的是,point必须已经转换为**视口点**,即point[1] = window_height - point[1] - 1。
反投影:``` stylus
void CMesh::ModelToScreen(v3f point3d, v2f point)
{
double fWinX, fWinY, fWinZ;
gluProject(point3d[0], point3d[1], point3d[2], m_pMatMV, m_pMatProj, m_pViewport,
fWinX, fWinY, fWinZ);

point[0] = (float)fWinX;
point[1] = (float)fWinY;
}

反投影得到的2维点同样是基于左下角为原点的视口点,要转换为鼠标点的话还得变换y值,即point[1] = window_height - point[1] - 1。
至于其它的实现方法,当然可以自己直接操作投影矩阵和模型矩阵,视口信息,进行转换,不过也是重复造轮子,但对于理解原理有帮助。
鼠标点投影到三维之后,就可以找到最近的模型点,或者通过射线之类求相交面。

我前段时间写了一篇关于MFC界面框架下,OpenGL全屏抗锯齿的文章。原以为这份代码可以方便的使用了,没想到今天就出问题了。这也许不能算在代码上的bug,或许是硬件的原因。今天我换了台电脑尝试了下,出现了很奇怪的现象,调试了很久才找到原因是开启了多重采样。这个调试过程也是让我非常痛苦的,写图形学程序对调试的经验和功底要求真的不浅啊。
我只能猜测是硬件的原因了。在学校里的时候,实验室的机器上运行得好好的,但是在这台电脑上就不行。这台电脑确实有点不一样,是妹子的mac,被我装了windows7在用着。估计是硬件不一样吧。再说我的多重采样是参照nehe的实现方法,也许实现方法不太兼容了。
有必要展示下这个奇葩的结果
从图片可以看出,本应该被遮挡的部分显示出来了。这个也不像完全是透明混合的效果,也不是深度测试的原因。总之是很奇怪的结果,我也调试过深度测试和混合,都没有用。最后,经过一番调试才知道是因为开启多重采样的原因。
只能把多重采样去掉,效果才显示正常了,并且程序的其它部分才正常,比如鼠标点和模型求交等。
在这里,不能不再次感叹调试的力量。我今天是实在没办法了,只能把以前版本的代码下载下来,替换不同的文件才找到bug的所在。因为当工程里面代码量太大的时候,注释掉某些部分,vs已经不一定会正确生成结果了,这种事情只能全部重新生成,才能保证代码真的更新了。
调试功底真的很重要。比如文件替换来排查错误的所在,打log,调试状态查看内存等。没有强悍的调试手段,很多事情真的是继续不下去的。