Unix/Linux下C/C++开发技术概览

类别:编程语言 点击:0 评论:0 推荐:

 

1. 平台差异简介

Windows和Unix是当前两大主流操作系统平台,基于C/C++的开发人员经常会面临这两个平台之间的移植的问题。Unix作为一个开发式的系统,其下有出现了很多个分支,包括Sun的Solaris、IBM的AIX、HP Unix、SCO Unix、Free BSD、苹果的MAC OS以及开源的Linux等。对于这些Unix的分支操作系统,其实现又有很大的差别,因此开发人员又要针对这些不同的系统进行移植。本文的目的就是介绍一下Windows平台和Unix平台之间的差别,并简单介绍一下不同Unix分支操作系统之间的差别,在移植开发过程中的一些注意事项,同时简要介绍一下Unix下开发的一般流程和常用的开发调试工具。

关于平台之间的差异,主要是Windows平台和Unix平台之间的差异,这里着重介绍一下这两个平台在C/C++开发中存在的差异,其间会穿插介绍一些Unix不同分支之间的差异。

1.1语言特性的差异

       语言特性的差异,指的是不同操作系统平台中,实现C++/C时的一些细微的差异,忽略这些差异可能会带来一些特别隐蔽的错误。而且可能是致命的错误。所以,了解语言特性的差异,对于在Unix移植来说非常重要。如果考虑系统多多个平台支持,就必须了解在不同平台下语言特性的差异,从开发一开始就把这些因素考虑进去,这样才能最低限度的降低移植的过程中工作量。

1.1.1字节顺序的差异

       字节顺序指的主要是整型变量在内存中的存储方式。在计算机中,数据都是以二进制方式存储的,包括在内存和硬盘中。而计算机又以8位二进制作为一个存储单元。在32位系统中,一个整型的存储需要四个存储单元。也就是说要把一个32位的整数分割成位四段分别进行存储,而每一段的存储位置就是字节顺序的差异。为了清楚的表示每段存储的先后位置,我们用16进制来表示一段的值,下表列出了在Unix系统和Windows系统中整数20000在内存中的情况。

十六进制表示

0x00004E20

Windows内存表示

20 4E 00 00

Unix内存表示

00 00 4E 20

如表中所示,Windows中存储方式和该整数的16进制表示是相反,是一种低位在前高位在后的存储顺序。而Unix下的存储顺序和正常的16进制表示的顺序相同,称为高位在前低位在后的顺序。这种差异带来的问题,主要体现在以下几个方面:

Ø         网络通信时

当Windows和Unix之间发生网络数据传输,传输一个整型数据(如一个数据包的长度)的时候,如果不经处理直接把内存中的数据传输过去,那么在对方看来完全是另一个数据,这样就会造成问题。如Windows下面发送过去一个20000(0x00004E20),在Unix下面收到的数据就会被理解成541982720(0x204E0000),这简直是天壤之别。

Ø         文件存储和读取时

跟网络传输类似,如果在Windows下面把某个整数写到了文件中,然后在Unix下面打开这个文件读取该数据,就会出现跟上面类似的问题。

       这个问题主要体现在不同平台之间互操作时,在多平台开发过程中,尤其时在网络应用开发的时候,两个平台之间数据交互是非常普遍的,所以这个问题也就显的很普遍。解决这个问题的方法就是交互的双方采用一种相同的数据编码标准,就是数据在传输和存储的时候采用什么方法进行编码,具体的做法有一下几种:

1.  数字转换成字符传进行交互

2.  协商一个同意的字节顺序,根据自己平台的字节顺序还原数据

3.  采用其他标准的编码方式,如ASN1编码

 

跟这个问题类似,32位系统和64位系统的差异也会出现这样的问题,解决方法跟这个问题的解决方法相同。在32位系统和64位系统中,长整型(long)分别用32位和64位表示,这样,在不同系统之间交互的时候必然会出现整型数据表示方式不同的问题。目前大多数Windows系统都是32位的系统,而Unix中很多都是64位的,尤其是大型的服务器,所以这个问题必须引起重视。

1.1.2变量的作用域差异

在不同的系统下,由于编译器的不同,对变量作用域的实现机制也有所不同,这里以Windows下的VC和Solaris下的CC这两个编译器为例做一个简单的比较说明。

在C++的开发过程中,我们经常会有这样的用法:

       for(int i=0;i<num;i++)

       {

              …

       }

这是一种最常用的for循环的用法,因为其中i主要使用来控制循环,所以一般没有必要拿出来单独进行声明,只是放在for语句中一起声明。这里i、j等简单的变量就成了我们常用的变量,一般不按照编程规范那样为他们命名。就是这种声明方法,在Windows下和Solaris下有了不同的理解,i的作用域不同。我们先把作用域进行划分,如下:

       {

              …

              for(int i=0;i<num;i++)

II

              {

I

                     …

              }

              …

              …

       }

 

我们划分出I和II两个作用域,其中作用域II包含在作用域I当中。在Windows下,变量i的作用域是I的整个范围,而Solaris下的i的作用域只是II的范围。其实标准的C++语法应该是Solaris的做法,但是微软在实现的时候没有按照这个标准实现,这就引发了我们讨论的这个问题。由于这个差异,就引发了一些微妙而隐蔽的问题。先看一下下面两端代码。

A:

       for(int i=0;i<num;i++)

       {

              …

       }

       …

       for(i=0;i<num;i++)

       {

              …

       }

 

B:

       for(int i=0;i<num;i++)

       {

              …

       }

       …

       for(int i=0;i<num;i++)

       {

              …

       }

 

代码A在Windows下面可以正常编译,而在Solaris下面确编不过去,提示第二个for循环中变量i没有定义。相反代码B在Solaris下可以正常编译,而在Windows下面编不过去,提示第二个for循环中变量i重复定义。

在通常的情况下,我们会按照B的方法书写代码,而在Windows编译是出现错误,然后改成A的那种形式。这样,在Windows下就没有问题了,程序也可以编译过去了,但是到Solaris下时,有会出现问题,这是就不得不把i的声明拿到所有for循环的外面。当i的声明拿到for循环的外面时,真正的问题来了。首先提示一下,这样的一段代码是没有问题的:

C:

int i = 0;

if(cond)

{

       …

       for(int i=0;i<num;i++)

       {

              …

       }

       …

}

       这是一段正确的代码,虽然在外面已经定义了i,但是在for里面重新定义一个i也没有问题,这是C++的语法所允许的(java里面不允许这样做)。但就是因为这种C++语言的灵活机制,引发了问题的产生。

       问题产生源于程序中出现了A_B那样的代码,然后把i的声明拿到了外面。在后期维护的过程中,又在后面增加了一个循环,但是却是按照C的那种方式增加的,这样就产生了问题。请看如下代码:

       int i=0;

       char str1[10];

       char str2[10];

       strcpy(str1,”hello”);

       …

       for(i=0;i<20;i++)

       {

              …

I

       }

       …

       if(cond)

       {

              for(int i=0;i<10;i++)

III

              {

II

                     if(str1[i]==0)  break;

              }

              memcpy(str2,str1,i);

              str2[i]=0;

       }

       …

 

在上述代码,为了分析方便,我们把整段代码分成I、II和III三个作用域。其中作用域II就是整个if语句,实现的相当于一个strcpy函数的功能。II中的内容就好是我们上面说的后期维护中加入的,当然,实际情况并不像我们例子中这么明前,i的声明可能离我们的if语句很远,所以加入这段代码是不知道上面是否声明了i变量。而且,这段代码编译的时候也不回出错,不管是Windows还是Solaris(单独的一段II中的代码在Solaris下面编不过去)。在Windows下面,这段代码可以正常的运行,不回出现任何问题,因为II中的代码完全是根据Windows下的习惯编写的。但是在Solaris下面,这段代码就会出现内存越界的错误,虽然编译可以正常通过,但是实现的却不是程序员预期的目的。在执行memcpy的时候,那个i其实是外层声明的那个i,值是20,而str2和str1的大小之后10,所以就发生了读写内存越界。而程序员预想的,这个i是for循环算出来的str1字符串的长度,应该是5。

要解决这类问题,就得加强编程规范,杜绝这种错误代码的生成。从开始的时候就要意识到可能产生的问题,从而避免问题的发生。

1.1.3全局对象的初始化

       在C++中,初始化对象的时候系统会自动调用构造函数,因此我们习惯在构造函数中做一些初始化的工作,让自动自动为我们调用初始化的操作。其中,有些对象是静态分配的全局对象,就是在任何函数体外声明的对象,如:

              CMyObject g_Object;

       通常情况下,程序启动的时候,系统都会自动调用这个对象的构造函数对这个全局对象进行初始化,但是在某些系统中(SCO Unix),就不想我们期望的那样,也许这是编译器实现的一个bug,但是我们也不能忽视这个问题的存在。对于这种问题,我们可以通过显式创建对象的方法解决,如下:

              CMyObject* g_pObject = new CMyObject;

       这样,系统在启动的时候,就会执行new CMyObject来为对象分配空间,同时执行调用对象的构造函数来初始化对象。如果不想使用指针的方式引用该对象(为了安全因素,不想某个函数在程序运行期间把这个指针置空),那么我们可以采用另一种方法,如下:

              CMyObject& g_Object = *(new CMyObject);

       这样也可以达到对像创建和初始化的工作。虽然对于我们分配的这个对象没有进行释放操作,但是全局只有这么一次,所以不用担心内存泄漏问题。程序运行结束的时候,操作系统会自动释放掉程序所申请的所有内存,当然也包含这个对象。

1.1.4 语法检查的差异

不同操作系统下面有不同的编译器的实现,不同的编译器对语法要求的程度不同。在Windows下可以正常编译的代码,在Unix下就可能出现语法错误。1.1.2中就是一个典型的例子。另外,还有一些其他方面的语法检查的差异。

C是一中很灵活的语言,语法很自由,但是不同的平台下这中自由的程度也不同。Windows VC、Solaris CC和Linux gcc实现的都不错,但是有些其他的系统实现的就不是这么灵活,很多写法在他们下面都行不通。具体的记不太清了,在AIX和SCO Unix下面碰到很多这种情况。所以只能在移植的过程中逐渐的发现和改正。但是只要保证采用标准的书写规范,应该可以更少的产生这种错误。

有这样一段代码:

       if(NULL == pVar)

       {

              …

       }

这是在大多数平台下面很好的一种习惯,可以避免哪种把“==”写成“=”的错误,在编译期间就能发现。但是在SCO Unix下面,这种写法就会引发编译器的一个警告。这个例子能简单的说明一下不同编译器之间存在的差别。

1.2    操作系统特性的差异

不同的操作系统中都存在一些系统的限制,如打开文件句柄数的限制、Socket等待队列的限制、进程和线程堆栈大小的限制等,因此在开发的过程中,必须考虑到这些限制因素对程序的影响。当然,有些限制参数可以适当的调整,这就需要在发布程序的时候加以声明。另外,操作系统的容错性也对程序有影响。下面分别进行讨论。

1.2.1  文件描述符的限制

文件描述符最初是Unix下的一个概念,在Unix系统中,用文件描述符来表示文件、打开的socket连接等,跟Windows下HANDLE的概念类似。文件描述符是一种系统资源,系统对每个进程可以分配的文件描述符数量都有限制。以Solaris为例,默认情况下每个进程可以打开的文件描述符为1024个,系统的硬限制是8192(具体的值跟版本有关),也就是说可以调整到8192。在Unix系统下使用ulimit命令来获得系统的这些限制参数。一般情况下,这都是够用的,但是有一个例外,在32为的Solaris程序中,使用标准输入输出函数(stdio)进行文件的操作,最大的文件描述符不能超过256。比如说用fopen打开文件,出去系统占用的3个文件描述符(stdin、stdout和stderr),程序中只能再同时打开253个文件。如果使用open函数来打开文件,就没有这个限制,但是就不能够使用stdio中的那些函数进行操作了,是程序的通用性和灵活性有所降低。这是因为在stdio的FILE结构中,用一个unsigned char来表示文件描述符,所以只能表示0~255。

在网络程序的开发中,每一个网络连接也都占用一个文件描述符,如果程序打开了很多Socket连接(典型的例子就是使用了连接池技术),那么程序运行的时候可能用fopen打不开文件。

解决这个问题,可以采用一下几种方法:

1.  升级为64位系统或采用采用64位方式编译程序

2.  使用sys/io.h中的函数操作文件

3.  采用文件池技术,预留一部分文件描述符(3~255之间的),使用freopen函数来重用这些描述符。

至于采用哪种方法或者是否考虑系统中处理这个问题,就要视具体的情况而定了,那些不受这个限制影响的程序,可以不考虑这个问题。

1.2.2  进程和线程的限制

一般的操作系统对每个进程和线程可以使用的资源数都有限制,比如一个进程可以创建的线程数,一个进程可以打开的文件描述符的数量,进程和线程栈大小的限制和默认值等。

针对这些问题,首先要分析和考虑你的系统是一个什么样的规模,会不会收到这些限制的影响,如果需求大于系统的限制,可以通过适当的调整系统参数来解决,如果还不能解决,就得考虑采用多进程的方式来解决。

对于进程和线程的栈空间大小的限制,主要是线程栈空间的问题。一般的系统都有默认的线程栈空间大小,而且不同超作系统的默认值可能不同。在通常情况下,这些对程序没有影响,但是当程序的层次结构比较复杂,使用了过多的本地变量,这个限制可能就会对程序产生影响,导致栈空间溢出,这是一个比较严重的问题。不能通过调整系统参数来解决这个问题,但是可以通过相应的函数,在程序里面指定创建线程的栈空间的大小。但是具体该调整的数值应该适可而止,而不是越大越好。因为线程的栈空间过大的时候,就会影响到可创建线程的数量,虽然远没有达到系统多线程数的限制,但却可能因为系统资源占用过多导致分配内存失败。

Linux的线程是通过进程实现的,实际上是假的线程。如果程序只在Linux下运行,就可以考虑直接使用多进程技术来代替多线程,因为在Linux下多线程并不能带来多线程相对于多进程的优势。

1.2.3  网络通信能力的限制

对于网络编程来说,性能是最主要的因素。系统为了提高网络通信的性能,提供了很多辅助的技术,其中等待队列就是其中之一。

对于程序来说,在一个时间点只能处理一个网络连接请求,而如果同时来了多个网络连接请求的话,就会有很多请求失败。为了解决这个问题,操作系统提供了等待队列技术,就是处理不上的连接请求先放到系统的等待队列中,这样就可以提高网络连接的成功率。等待队列的创建也需要消耗系统资源,因此系统对等待队列的大小都有限制,程序中可以通过函数设定等待队列的大小,但是不能超过系统的硬性限制。下面列出了几个操作系统的最大等待队列的大小:

操作系统

最大等待队列

Windows 2000 Server

200

Windows XP Home

5

Solaris E250

128

上表中只简单列出了几个操作系统的等待队列参数,其他系统暂位列出,如果有兴趣可以自己作个简单的程序测试一下。所以这个问题就跟具体的系统环境有关了,不过我们可以在系统连接池的基础上再做一些工作,采用连接缓冲池技术。就是接受到网络连接请求以后,提交给出去线程处理的之前,先放到一个缓冲池中。这样可以接受更多的连接请求等待处理,能一定程度的提高系统的连接成功率。不过,跟系统的等待队列不同,这是通过软件方式实现的等待队列,而系统提供的连接池是从操作系统级来解决问题的,更接近硬件层次,所以效率肯定会不同。面对这类问题,首先还是得以调整系统连接池的大小,然后再采用其他辅助手段。

1.2.4  容错性的影响

采用C/C++开发程序,缓冲区溢出的错误非常普遍,但是系统运行程序的时候,对待运行期出现的这些错误的处理能力都不相同。总的来说,Windows系统的容错性最强,尤其是Debug版的程序,系统都加入了一些保护机制,能够保证出现一些小的错误以后,程序仍能够正常运行。Unix平台下面要求的就严格一些,有些系统更是容不得一点沙子,有一点错误就会出现宕机现象,这些跟操作系统的内存分配机制有关。Windows平台的程序分配内存的时候,一般都会多分出一些字节用于对齐,如果缓冲区溢出的不是太多,就不回对内存中其他变量的值造成影响,因此程序也能够正常运行。但是这种保护机制会带来更多的系统开销。这就是Windows程序移植到Unix下面稳定性降低的主要原因之一,也是为什么Windows系统会消耗那么多系统资源的原因。

要解决这类问题,就要进行更严格的测试和代码检查。同时,借助相关的测试工具,找出系统中隐藏的潜在的问题,不能放过任何一个可能产生的错误,尤其是编译过程中发现的警告信息。当然,这些工作都应该再移植前做的很充分,在移植后更应该加大测试的力度。

1.3    图形用户界面

Windows 和Unix 图形模型差异极大,这点是Unix和Windows程序开发最大的差别。UNIX 使用X Window 系统GUI,而Windows 使用GDI。虽然在概念上类似,但是X API 和GDI API 之间没有简单的对应。在Windows下面可以通过MFC等类库很方便的开发出图形用户界面的程序,而Unix下相对来说就麻烦了些,缺少哪种所见即所得的好的开发工具。Unix下的GUI程序开发,是一个比较复杂的过程,这里就不在详细介绍。如果要进行Unix下面GUI程序的开发的话,可以单独去查找相关的文档。

 

1.4    并发处理

并发处理包括多进程和多线程的概念,Windows和Unix的并发处理差别也比较大,但是基本上都能找到一组对应的函数来实现类似的功能。

在Windows下,创建进程和线程可以通过调用Windows的API来完成,或者通过调用MFC提供的并发处理类库来实现。在Unix下面创建进程通常使用fork函数,这跟Windows下面的多进程概念有所不同,相当于在当前位置给当前进程创建一个副本;而Windows下的创建进程大都是创建一个新的进程。Unix下的多线程操作,通过一组线程函数来完成,通常我们使用POSIX 的PTHREAD线程库来创建线程,但是在不同的Unix分支系统中,都包含又自己的本地线程库。如在Solaris下面的本地线程库,是一组以thr_开头的函数,而POSIX的线程函数一般都已pthread_开头。虽然有两种不同的线程库空我们选择,但在某一个特定的系统下,他们的实现实质都是一样的,而且基本上都能够找到对应的函数。为了程序的可移植性,建议采用POSIX的线程库。这是大多数Unix系统都支持的线程库,但是不同系统下实现的功能可能有所差别,可能只是实现了这个函数库的一个子集。

在有些Unix系统下,没有实现线程库,如SCO Unix,系统只提供多进程的开发方式。但是,如果为了实现程序代码的统一性,我们可以采用第三方提供的线程库。这里有一个叫FSU-threads的线程库供我们选择。这个线程库中实现了POSIX中定义的线程函数,而且是开源的,可以支持SunOS 4.1.x, Solaris 2.x, SCO UNIX, FreeBSD, Linux等系统。除此之外,还有ZThreads线程库等。

在Windows的线程库中,实现了互斥(Mutex)、事件(Event)、信号量(Semaphore)等同步对象,用于实现线程之间的同步。在Unix下面,线程同步主要使用互斥(mutex)和条件变量(cond),其中条件变量可以实现事件和信号量的功能。另外, POSIX还定义了一套信号量函数,跟线程函数不同,是一组以sem_开头的函数(POSIX 1003.1b semaphores)。但是这套函数就不想POSIX线程函数支持的那么广泛了,比如在AIX上就不支持POSIX的信号量函数,不过AIX系统下有另一组函数来实现信号量的功能(SystemV semaphores)。在很多Unix系统中,同时支持POSIX的信号量和SystemV的信号量,在Solaris下面还有一套自己的本地函数来实现信号量。下面分别列出Unix系统中的用于并发处理的主要的函数。

进程

       fork        创建进程

POSIX线程库

pthread_create                            创建一个信的线程

pthread_attr_init                  初始化一个线程属性对象

pthread_attr_destroy            释放一个线程属性对象

pthread_exit                       终止执行调用的线程

pthread_join                        把当前调用线程挂起,直到目标线程结束

pthread_setschedparam               设置线程的调度策略和优先级

pthread_getschedparam               获得线程的调度策略和优先级

pthread_sigmask                  改变/检查调用线程的信号掩码

pthread_kill                         发送信号到另一个线程

pthread_self                        返回当前线程的ID

pthead_mutex_init                初始化一个互斥量

pthread_mutexattr_init          初始化互斥量的属性对象

pthread_mutex_lock                    给一个互斥量加锁,如果互斥量已经被别的线程锁定,调用线程挂起,直到别的线程释放

pthread_mutex_unlock         释放互斥量(解锁)

ptherad_mutex_destroy               销毁一个互斥量

pthread_cond_init                初始化一个条件变量

pthread_condattr_init           初始化一个条件变量的属性对象

pthread_cond_wait              阻塞在一个条件变量上

pthread_cond_signal            解除下一个线程在条件变量的阻塞

pthread_cond_boradcast       解除所有线程在这个条件变量上的阻塞

pthread_cond_destroy          销毁一个条件变量

pthread_cancel                    请求结束一个线程

Solaris本地线程库

thr_create                           创建一个新线程

thr_exit                               终止调用线程

thr_join                               把当前调用线程挂起,直到目标线程结束

thr_yield                             用当前线程创建出另一个线程

thr_suspend                        挂起一个指定的线程

thr_continue                        恢复一个被挂起的线程

thr_setprio                          修改线程的优先级

thr_getprio                          获得线程的优先级

thr_sigsetmask                    改变/检查调用线程的信号掩码

thr_kill                                发送信号到另一个线程

thr_self                               返回当前线程的ID

thr_main                             标记为主线程

thr_mutex_init                     初始化一个互斥量

thr_mutex_lock                          给一个互斥量加锁,如果互斥量已经被别的线程锁定,调用线程挂起,直到别的线程释放

thr_mutex_unlock                释放互斥量(解锁)

thr_mutex_destroy               销毁一个条互斥量

thr_cond_init                       初始化一个条件变量

thr_cond_wait                     阻塞在一个条件变量上

thr_cond_signal                    解除下一个线程在条件变量的阻塞

thr_cond_boradcast             解除所有线程在这个条件变量上的阻塞

thr_cond_destroy                销毁任何状态的条件变量

rwlock_init                         初始化一个读写锁

rw_rdlock                           获得一个读写锁的读锁定

rw_wrlock                          获得一个读写锁的写锁定

rw_unlock                          解锁一个读写锁

POSIX信号量

sem_init                              初始化一个信号量

sem_destroy                        销毁一个信号量

sem_wait                                    等待获得一个信号量,获得后信号量的值减1,如果当前信号量值位0,当前线程阻塞,支持有别的线程释放信号量

sem_trywait                         尝试获得一个信号量,获得后信号量的值减1,如果当前信号量值位0,返回失败

sem_post                             释放一个信号量

sem_getvalue                       获得指定信号量的值

       System V信号量

semctl                                 对信号量进行一系列的控制

semget                                创建一个信号量,成功时返回信号的ID

semop                                 对信号进行操作

       Solaris的本地信号量,更接近于操作系统中我们学到的PV操作的信号灯

sema_init                             初始化一个信号灯(信号量)

sema_destroy                       销毁一个信号灯

sema_p                               执行信号灯的P操作

sema_p_sig                                 跟sema_p类似,当阻塞再这个函数的时候,如果线程收到一个信号,函数退出

sema_tryp                           尝试执行信号灯的P操作

sema_v                               执行信号灯的V操作

 

       为了方便使用,我在开发的过程中已经把上面常用的函数都封装成了类,兼容Windows和各种常见的Unix系统,而且网上还有很多这方面的代码资源可用。如果感兴趣的话可以向我索要。

1.5    网络通信

Socket(中文译名:套接字)最初在Unix上出现,并很快成为Unix上最流行的网络编程接口之一。后来,微软将它引入到Windows中并得到实现,于是从Windows 95、WinNT4开始,系统就内置了Winsock1.1,后来到了Windows98、Windows2000,它内置的Winsock DLL更新为Winsock2.2。

Windows下的Socket函数大体上和Unix下的Socket函数差不多,函数名称很参数用法都类似,只有一些细微的差别,某些参数的意义不同,而且对于Socket的属性控制也不太一样。Windows下面还对Socket函数进行了封装,有一系列相关类可用使用,简化网络编程的复杂性。Unix本身没有这些类库,但是我们也已经积累了很多这方面的经验和资源。我们有一组现成的类对Windows和Unix下的Socket函数进行了封装,上层只需要简单的调用即可,不用关心底层的差别。而且,这套类库也可以同时支持多种平台,可移植性非常好。

关于Socket开发就简单说这些,想深入了解的话请参考介绍Socket编程的一些相关资料。

 

2. 基本开发流程

在Unix下开发程序,不同于在Windows下开发,除了上面介绍的程序级的差别外,开发环境也有很大的差别。在Windows下面,大都使用集成开发环境进行开发,如MS Visual C++、Borland C++ Builder等。在Unix下面也有集成开发环境,如Sun 的Workshop、Linux下的KDevelop等,但是因为很多Unix开发都是移植,即现在Windows基本完成,然后在移植到Unix下,所以需要的代码量就不是太大,所以经常采用Makefile的方法,而不用IDE开发环境来重新创建工程。本章的主要内容就是介绍在Unix下开发的基本过程和一些开发调试工具。

2.1 代码的编写

对于移植工程来讲,基本代码都在Windows下完成,只需要吧代码传到Unix下,然后在Unix下面组织源码目录即可。对于传到Unix代码进行编辑,可以使用Unix下的vi工具来完成,也可以通过UltraEdit以Ftp方式打开Unix下的文件进行编辑。vi是Unix下面最常用的一个文本编辑器,后面将会介绍vi的一些基本用法。

有一点值得注意,Windows下文本里面的回车符包含两个字符‘\n’和‘\r’,而Unix下的文本里面的回车符只包含一个字符‘\n’。这样,如果上传问文件的时候没有选择正确的方式,应该使用文本方式上传的使用了二进制方式,或者应该使用二进制方式上传的使用了文本方式,那么在unix下都会出现问题,打开的文本当中每一行的行尾就会出现一个‘^M’字符。可以通过vi的匹配替换功能(稍后会做介绍)或者重新按照正确的方式上传来解决。

2.2 编译 2.2.1  简单编译

 

对于简单的程序,如只有几个源文件,可以直接使用编译器进行编译,或者把几条编译命令写在一个脚本文件里面,通过执行脚本文件实现工程的编译和连接。比如只有一个hello.cpp文件的工程,可以通过如下命令编译:

CC –o hello hello.cpp

其中CC是编译器,不同的系统下面可能有不同的编译器。一般来说,大多数Unix系统下的C编译器都叫cc,而C++编译器叫CC。Linux下面带的C编译器为gcc,C++编译器为g++。-o参数用来指定输出的目标的名称,也就是编译后执行程序的名称。这种情况下编译和连接一步完成。

对于稍微负责一些的程序,包含多个源文件的,可以编写一个编译脚本,相当于windows下的批处理。如下:

工程中包含hello.cpp、func.cpp、other.cpp,我们可以用如下脚本来实现工程的编译。

        CC –c hello.cpp

        CC –c func.cpp

        CC –c other.cpp

        CC –o hello hello.o func.o other.o

多个文件情况下,把编译和连接分开执行,先逐个编译源文件,然后再进行链接,形成最终的可执行程序。参数-c就是声明只进行编译操作。

2.2.2  使用Makefile

当工程达到一定的规模的时候,2.2.1中的做法显然是不能满足要求的,如果非要那样做,将会带来很大的工作量,而且还非常容易出错。这是我们就要使用Makefile来帮助我们完成工程的编译工作。

Makefile文件相当于一个工程文件,文件中描述了工程中的源代码、额外需要的库文件及其路径、额外需要的头文件路径已经编译器类型、编译参数等。通过make命令来调入Makefile进行工程的编译。当执行make命令是,会在当前目录下搜索名称为“Makefile”或者“makefile”的文件,作为当前编译的工程文件,也可一指定其他的工程文件,如make –f MyMakefile。

一个简单的Makefile文件内容如下:

#Makefile for Linux(RedHat)

宏定义

#目标程序名称

BIN_NAME=demo

 

#编译器及编译参数

CC = gcc

CXX = g++

CXXFLAGS = -g

 

文件列表

#源代码列表

SRCS=\

       demo.cpp \

       func.cpp

#目标文件列表,通过源代码列表生成

OBJS=${SRCS:.cpp=.o}

 

#依赖关系

#depends

all:${BIN_NAME}

.depends : ${SRCS}  #依赖规则

       @echo Creating depend information,please wait ...

依赖关系

       ${CXX} -M ${SRCS} > .depends   #执行的命令

-include .depends

#%.o:%.cpp

#     @echo Compling file: $? please wait ...

#     ${CXX} ${CXXFLAGS} -c $?

${BIN_NAME}:${OBJS}

       @echo Linking file: $@ please wait ...

       ${CXX} -o $@ ${OBJS}

clean:

       rm -f ${BIN_NAME} ${OBJS} *pure .depends

 

在Makefile文件中,大体来说分为“宏定义”、“文件列表”和“依赖关系”三个部分。宏定义中定义Makefile中可以使用的一些宏,定义好一个宏以后,在Makefile的后面部分可以通过宏引用来获得宏的值。像编译器、编译参数等通常都会定义成宏,然后通过修改宏的值就可以修改编译器类型及编译参数。在更复杂的Makefile中,宏定义中还会包含依赖的库文件、需要增加的库文件路径及头文件路径、程序中预定义的宏等。

其实文件列表也是一个宏,只是考虑到他的重要性,所以单独作为一个部分来介绍。在后面的依赖关系中,就会用到这个宏。文件列表中定义了工程包含的所有源文件和这些源文件编译以后生成的目标文件。目标文件可以通过源文件列表生成。上面我们使用${SRCS:.cpp=.o}来生成目标文件列表,意思就是说把源文件中所有扩展名为“.cpp”换成“.o”。

依赖关系部分是Makefile文件的关键,其中定义了多个依赖关系。每一个依赖关系又包括一个依赖规则和一组执行的命令。依赖规则由目标和依赖对象构成,也就是说这个目标依赖于指定的依赖对象。当执行make的时候,如果发现目标不存在或者目标的依赖对象的更新时间比目标还新,就会执行依赖关系中的命令,来完成对目标的编译。Makefile中可以定义多个目标,每个目标对应一个依赖关系和一组命令。执行make的时候,可以执行要编译的目标。如make clean表示检查执行目标clean。all是一个默认的目标,即执行make不加参数的时候,就会默认为要检查执行all这个目标。Makefile中可以为all目标指定依赖对象和要执行的命令。Makefile中,也可以只指定依赖关系,不指定要执行的命令,这种情况大多用于依赖对象也是一个定义的目标。Make在检查依赖关系的时候,如果发现依赖对象也是一个目标,就会先去检查这个目标的依赖关系,看是否需要重新编译这个目标,然后才会执行本依赖关系下面的命令。

为了实现源文件的编译,我们在依赖关系中需要定义源文件和目标文件之间的依赖关系,然后执行程序和这些目标文件之间建立一个依赖关系。这样就可以实现整个工程的编译了。上面的这个Makefile中,包含了两种定义这种依赖关系的方法,一种是通过编译器的“-M”命令来形成依赖关系文件,然后把这个文件的内容include到当前的依赖关系中,另一种是通过匹配替换的做法,直接通过源文件和目标文件的映射关系形成依赖关系。红色字体部分和蓝色字体部分分别代表这两种做法。“-M”参数是gcc/g++编译器特有的,其他编译器一般没有这个参数,应该使用第二种做法。

在不同的系统中,Makefile的语法会有一些细微的差别,在进行多平台移植的时候应该注意这个问题,分别对不同的系统编写不同的Makefile。

2.2.3  automake和autoconf

Makefile 基本结构虽然很简单,但是妥善运用这些规则就可以变换出许多不同的花样。却也因为这样,许多刚刚开始学习写Makefile 时会觉得没有规范可以遵循,每个人写出来的Makefile都不大一样,不知道从哪里下手,而且常常会受到自己的开发环境的限制,只要环境参数不同或者路径更改,可能 Makefile 就得跟着修改修改。虽然有 GNU Makefile Conventions (GNU Makefile惯例例)订出一些使用 GNU 程式设计时撰写 Makefile 的一些标准和规范,但是内容很长而且很复杂,并且经常作一些调整,为了减轻程序开发人员维护Makefile 的负担,因此出现了Automake。

程序设计者只需要写一些预先定义好的宏 (macro),提交给Automake处理后会产生一个可以供 Autoconf 使用的 Makefile.in文件。再配合利用 Autoconf产生的自动培植设置文件 configure 即可产生一份符合符合 GNU Makefile 惯例的 Makeifle 了。

       automake的基本用法,是先用autoscan搜索当前目录,然后形成一个configure.scan文件,然后以configure.scan文件为蓝本,形成一个configure.in文件,这是最终需要的文件。然后使用aclocal处理一下本地的宏,再使用autoconf生成一个configure脚本文件。configure脚本文件用来形成最终的makefile。之后编译一个Makefile.am,定义automake需要的一些宏,包括源文件列表等。执行automake,形成Makefile.in文件。这样,需要的准备工作基本完成。编译的时候,通过执行configure脚本,会自动生成Makefile文件,然后再执行make就可以了。

       关于automake的详细用法请查看参考资料。

2.3 运行和调试

跟Windows不统,Unix下面的文件只有有可执行的权限就可以执行,不像Windows那样依赖于扩展名。所以Unix下的执行程序大都没有扩展名,任何文件只要给了一个执行权限,就可以直接运行。可以通过chmod +x来给一个文件增加可执行的权限。编译后形成的目标文件,通常都已经有了执行的权限,所以可以直接执行。

2.3.1  设置运行环境

对于简单的程序来说,不用设置什么运行环境,直接运行即可。对于负责的程序来讲,可能执行程序和需要的共享库文件不在同一个目录下,就需要设置一些运行环境了。设置运行环境主要就是设置共享库的路径。Unix下的共享库,相当于Windows下面的动态库(dll文件),是一种以.so为扩展名的文件。系统的共享库存放在/usr/lib下面,程序运行的时候会自动去那个目录下面寻找需要的共享库。对于用户自己开发的共享库,一般不能放在/usr/lib目录下面,而是放在用户的程序目录下面。一个 比较复杂的程序,其目录结构分的比较清晰,并不是把所有的文件都放在一个目录下面。下面列出了一个典型的目录结构:

mainpath               程序主目录

mainpath/bin          执行程序目录

mainpath/lib           共享库目录

这种情况下,就需要设置程序的运行环境,指定库文件目录。设置运行环境其实就是在运行程序前设置若干个环境变量,其中共享库的路径就是其中之一,也可一设置一些程序需要的其他的环境变量。Unix下面用LD_LIBRARY_PATH来表示用户共享库的路径。设置完环境变量,就可以运行程序了。

有一种更方便的方法,就是编写执行脚本。Unix下面的脚本相当于Windows下的批处理文件,就是执行一系列的命令。本质上来说,脚本文件就是一个文本文件,编辑完成后增加一个可执行权限即可。在Unix下面的终端窗口,都是基于外壳(shell)的。在UNIX中大家最常使用Bourne Shell以及C Shell ,系统默认采用的是Bourne Shell。不同的shell有不同的编程语言,也就是说编写shell脚本的语言,用来实现一些更高级的功能。在脚本中,可以指定本脚本采用的语言。关于shell编程的详细信息,请查阅参考资料。一般情况下,Bourne Shell脚本文件以.sh为扩展名,C Shell脚本文件以.csh为扩展名。这里我们以Bourne Shell为例。

一个典型的程序目录中,一般存在3个脚本:执行脚本(run.sh)、停止进程脚本(kill.sh)和设置环境脚本(setenv.sh)。脚本文件一般都放在程序的主目录下面,进入主目录可以直接通过这些脚本来执行程序。下面简单列出了这几个脚本的内容。

setenv.sh:

       #!/bin/sh

path=`pwd`

LD_LIBRARY_PATH=$path/lib:$LD_LIBRARY_PATH

export LD_LIBRARY_PATH

      

run.sh:

       #!/bin/sh

path=`pwd`

LD_LIBRARY_PATH=$path/lib:$LD_LIBRARY_PATH

export LD_LIBRARY_PATH

cd bin

./demo

cd ..

      

       kill.sh:

#!/usr/bin/sh

for sid in `ps -e |grep demo |awk {'print $1'}`

do

kill $sid

done

      

       值得注意的一点是,外壳其实也是一个进程,运行脚本以后会创建一个新的进程。环境变量依赖于进程,也就是说脚本里面设置的环境变量在脚本完成后将会消失,不回影响外壳进程和其他进程。也就是说,正常情况下我们的setenv.sh相当于不起任何作用。不过可以通过另一种执行方法来保留脚本执行过程中设置的环境变量,即“. setenv.sh”。其实就是在执行命令的前面加上一个‘.’和空格。这样脚本中设置的环境变量就会作用域执行这个脚本的外壳进程,也会影响到以后在这个外壳下面运行的所有进程。

2.3.2  使用调试工具进行调试

程序的调试,是程序开发中最重要的一部分,通过调试来找到程序中的bug。Unix下面的程序调试工具有很多,包括IDE环境下的调试工具和字符界面的调试工具。这里我们主要介绍字符界面的调试工具。常用的调是工具是dbx,大部分Unix系统下都有。在linux下面,调试工具为gdb,功能跟dbx类似。

跟Windows类似,要以调试方式运行程序,就必须按照debug版来编译程序。Unix下面的编译一一般通过-g参数来声明使用debug方式进行编译,这样编译后的目标文件中就包含了源代码的信息。下面以gdb为例,介绍一下Unix下面程序调试的基本方法。

1)        以调试方式运行程序

在执行程序所在的目录下,执行gdb,以程序名称为参数即可进入gdb调试界面。如:gdb demo

2)        查看源文件,设置断点

在gdb下,使用list命令可以查看主函数所在的文件的内容。默认情况下,一次显示10行内容,再次执行list可以显示后面的10行。

确定要在哪行上设置断点,然后用break命令进行断点设置。如:

break 10 表示在第10行上设置一个断点。

break命令还可以指定要设置断点的文件(如果不是主函数所在的文件),如:

break func.cpp:10 表示在func.cpp的第10行上设置一个断点。

除了在具体的行上设置断点外,还可以在某个函数上设置断点,只要把行号换成函数名即可。

clear命令用来清楚当前行的断点,delete命令可以删除所有设置的断点。

3)        运行程序

设置完断点后,可以在gdb下通过run命令运行程序,如果程序需要什么参数,直接放在run命令后即可,如:

run –start

其中-start是程序允许需要的一个参数。

4)        断点处程序分析

当程序执行到断点的时候,就会定下来等待用户进行处理,这是可以通过一些命令来查看程序当前的运行状态,这些命令包括:

print        打印变量的值,只需要用变量名做命令参数即可

where     显示程序当前的调用栈

set variable 改变变量的值,可能会影响到程序后面运行的结果

5)        单步调试

在断点处处理完成后,可以继续往下执行程序,next命令用来执行一行代码,step命令用来跟踪到即将执行的函数中。continue命令可以继续运行当前程序,直到遇到下一个断点。

       上述流程中,简单介绍了一些如何使用使用gdb来进行程序的调试。gdb中的所有命令,都可以用简写来表示,只要写道能够区分处改命令的字母数即可,比如可以简单的输入n表示next命令。除了上面介绍的命令外,还有很多有用的命令。attach和detach命令,可以在程序正常运行的情况下把gdb帮定到一个进程中,进行调试,调试结束后恢复正常的程序运行,推出gdb对程序运行没有影响;pwd命令可以显示当前的工作目录,cd命令可以改变当前的工作目录;thread命令可以查看当前的线程ID。还有很多别的命令,可以通过help命令获得详细的帮助信息。

       除了dbx和gdb外,Unix下面还有很多调试工具,详细信息请看参考资料。

2.3.3  core dump

对于Unix开发人员来说,core dump是再熟悉不过的了。再Unix下面,core dump就是程序bug的最严重的体现。core dump又叫核心转储,当程序运行过程中发生异常,程序以外退出的时候,系统会把程序当前的内存状况存储在一个core文件中,就叫core dump。

既然core文件记录了程序运行时的情况,就可以通过core文件来分析错误的原因。使用gdb/dbx 运行程序名 core就可以把程序恢复到发生错误的哪一时刻,可以看到程序在执行哪行代码,调用什么函数时发生了错误。这是,可以借助调试工具的其他命令进行更细致的分析。个人觉得,Solaris下实现的dbx远比gdb强大的多,可以看到更多的信息。

值得注意的是,在linux下面有一个系统限制,就是core文件的大小。如果这个限制设成0,则程序发生异常以后不回产生core文件。如果想得到core文件,请使用ulimit –c命令来调整这个参数。ulimit –a命令可以查看到系统当前的限制参数情况。

3. 常用命令介绍

Unix下面的常用命令,跟Windows下的控制台命令有很大差别。Unix下的命令,可能是一个执行程序,或者一个可以执行的脚本,系统的命令一般都可以在/usr/bin/下面找到。另外需要说明的一点,Unix下面严格区分文件名的大小写,包括命令的大小写。下面简单介绍一下Unix系统下常用的几个命令。

3.1 常用命令列表

ls             列出当前目录下的文件,相当于Windows的dir。

cd            切换目录,和Windows下的cd命令相同。

mkdir       创建一个目录。

rm           删除一个文件或者目录。-r参数表示删除的是目录,-f参数表示强制删除。

mv           移动或者修改文件/目录名称。

cp            拷贝文件或目录,-r参数表示拷贝的对象是目录。

ps            查看当前系统的进程情况,-e是最常用的一个参数,表示列出所有进程。

man                查看手册,这是一个最常用的命令,通过他可以查看指定命令或者函数的使用手册。比如man ps就可以产看ps命令的帮助手册,man fopen可以查看fopen函数的用法。

find          在指定目录下查找指定的文件。

grep         在指定文件中查找指定的内容。

vi             编辑文本文件,后面会做详细的介绍。

tar           文件打包和解包工具,通过cvf参数打包,通过xvf解包

gzip                压缩工具,可以把文件压缩成gz文件,通过-d参数进行解压缩,常于tar命令一起使用。

3.2 命令的组合

在Unix下面,可以把多个命令组合使用,可以把一个命令的执行结果作为另一个命令的输入。其中,最常跟别的命令组合使用的命令就是grep。在2.3.1中的那个kill.sh脚本中,我们就使用了这样一个组合命令“ps -e |grep demo |awk {'print $1'}”,用来列出当前系统中所有名称为demo的进程的进程号。这种用法是我们开发过程中最常用的方法。另外如果我们想知道我们的程序的网络情况,可以通过netstat命令和grep命令来组合使用,获得指定端口的状态情况,比如netstat –a | grep 43001,这个命令将列出当前系统中所有在端口43001上的连接情况。

通常的grep命令,只能查找当前目录下的文件,比如:grep ‘OPEN_MAX’ *.h 就是查找当前目录下面的所有头文件中是否含有“OPEN_MAX”这个词。而find命令可以查找指定路径下面的所有子目录,发现所有匹配的文件,那么,我们就可以用grep命令和find命令组合,来实现子目录下的文件内容查找。命令格式如下:

grep 'OPEN_MAX' `find /usr/include -name '*.h'`

这样可以查找所有在/usr/include及其子目录下的头文件,看是否包含“OPEN_MAX”。执行的时候,先执行find命令,形成一个文件列表,然后把这个文件列表作为grep的一个参数来执行grep命令。这是命令组合的另一种方式。

3.3 vi简介

vi是Unix下最常用的一个文本编辑器,小巧而且功能强大,一次我们把它单独拿出来做一下介绍。如果想了解更详细的信息,请执行man vi查看其联机帮助文档。

3.3.1 命令模式和编辑模式

vi的工作模式包括命令模式和编辑模式两种。命令模式下可以执行vi中定义的一些命令,这些命令跟一些特定的键相对应,命令模式下所有的键盘相应将会作为命令来解释。编辑模式就是编辑文档的模式,在编辑模式下所有键盘的相应都为作为文档输入的内容。通过ESC键可以从编辑模式切换到命令模式。通过一些编辑命名可以从命令模式进入编辑模式。

3.3.2 基本命令

基本命令指的是在命令模式下,通过敲键执行的命令,这里我们介绍几个常用的命令:

i             在当前开始插入,进入编辑模式

I             在行首开始插入,进入编辑模式

a             在当前字符后追加,进入编辑模式

A            在行尾追加,进入编辑模式

x            删除当前光标处的字符

X            删除当前光标前面的字符

D            删除从光标位置到行尾的所有字符

dd           删除当前行

dw          删除当前的单词

u            取消刚才的操作

G            跳到文件末尾

此外,在命令模式下,通过输入‘/’可以进行查询,通过输入‘:’可以输入一些其他命令。输入‘:’可以输入的命令包括:

q            退出

w           保存

wq          保存退出

q!         强制退出,不保存

w!        对受保护的文件强制写,包括只读文件

set number     显示行号

数字       跳到某一行

3.3.3 查找和匹配

vi的查找功能也非常强大,命令模式下通过输入‘/’就可以进入查找模式,可以输入要查找的关键字。然后可以通过‘n’来查找下一处。

在输入‘:’后,还可以输入一些匹配替换的命令——“%s”。命令的格式为“:%s /str1/str2”,执行之后将把当前文件中所有str1替换成str2。举一个典型的例子,前面我们说过,Windows下的文本文件如果以二进制的方式传到了Unix下,那么vi打开的时候每行的行尾就会出现一个“^M”,我们可以用vi打开这个文件,然后通过vi的匹配替换功能去掉这些“^M”。命令格式如下:

       :%s /^M//

其中“^M”通过CTRL+V 和 CTRL+M来输入。

本文地址:http://com.8s8s.com/it/it25539.htm