2005年02月06日

原始链接:http://www.xfocus.net/articles/200109/260.html

从程序员角度看ELF


创建时间:2001-09-15
文章属性:翻译
文章来源:http://www.xfocus.org
文章提交:alert7 (sztcww_at_sina.com)

从程序员角度看ELF



原文:《 ELF:From The Programmer’s Perspective》



作者:Hongjiu Lu <mailto: hjl@nynexst.com>

    NYNEX Science & Technology, Inc.

    500 Westchester Avenue

    White Plains, NY 10604, USA



翻译:alert7 <mailto: alert7@21cn.com

                      alert7@xfocus.org

             >



主页: http://www.xfocus.org

时间: 2001-9-10





★概要:



这片文档从程序员的角度讨论了linux的ELF二进制格式。介绍了一些ELF执行

文件在运行控制的技术。展示了如何使用动态连接器和如何动态装载ELF。

我们也演示了如何在LINUX使用GNU C/C++编译器和一些其他工具来创建共享的

C/C++库。



★1前言



最初,UNIX系统实验室(USL)开发和发布了Executable and linking Format

(ELF)这样的二进制格式。在SVR4和Solaris 2.x上,都做为可执行文件默认的

二进制格式。ELF比a.out和COFF更强大更灵活。结合一些适当的工具,程序员

使用ELF就可以在运行时控制程序的流程。





★2 ELF类型



三种主要的ELF文件类型:



.可执行文件:包含了代码和数据。具有可执行的程序。

    例如这样一个程序

    

    # file dltest

    dltest: ELF 32-bit LSB executable, Intel 80386, version 1,

        dynamically linked (uses shared libs), not stripped



.可重定位文件:包含了代码和数据(这些数据是和其他重定位文件和共享的

    object文件一起连接时使用的)

    例如这样文件



    # file libfoo.o

    libfoo.o: ELF 32-bit LSB relocatable, Intel 80386, version 1,

       not stripped



.共享object文件(又可叫做共享库):包含了代码和数据(这些数据是在连接

    时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为

    ld.so.1,libc.so.1 或者 ld-linux.so.1。

    例如这样文件

    

    # file libfoo.so

    libfoo.so: ELF 32-bit LSB shared object, Intel 80386, version

    1, not stripped



ELF section部分是非常有用的。使用一些正确的工具和技术,程序员就能

熟练的操作可执行文件的执行。



★3 .init和.fini sections



在ELF系统上,一个程序是由可执行文件或者还加上一些共享object文件组成。

为了执行这样的程序,系统使用那些文件创建进程的内存映象。进程映象

有一些段(segment),包含了可执行指令,数据,等等。为了使一个ELF文件

装载到内存,必须有一个program header(该program header是一个描述段

信息的结构数组和一些为程序运行准备的信息)。



一个段可能有多个section组成.这些section在程序员角度来看更显的重要。



每个可执行文件或者是共享object文件一般包含一个section table,该表

是描述ELF文件里sections的结构数组。这里有几个在ELF文档中定义的比较

特别的sections.以下这些是对程序特别有用的:



.fini

    该section保存着进程终止代码指令。因此,当一个程序正常退出时,        

    系统安排执行这个section的中的代码。

.init    

    该section保存着可执行指令,它构成了进程的初始化代码。

    因此,当一个程序开始运行时,在main函数被调用之前(c语言称为

    main),系统安排执行这个section的中的代码。



.init和.fini sections的存在有着特别的目的。假如一个函数放到

.init section,在main函数执行前系统就会执行它。同理,假如一

个函数放到.fini section,在main函数返回后该函数就会执行。

该特性被C++编译器使用,完成全局的构造和析构函数功能。



当ELF可执行文件被执行,系统将在把控制权交给可执行文件前装载所以相关

的共享object文件。构造正确的.init和.fini sections,构造函数和析构函数

将以正确的次序被调用。



★3.1 在c++中全局的构造函数和析构函数



在c++中全局的构造函数和析构函数必须非常小心的处理碰到的语言规范问题。

构造函数必须在main函数之前被调用。析构函数必须在main函数返回之后

被调用。例如,除了一般的两个辅助启动文件crti.o和crtn.o外,GNU C/C++

编译器–gcc还提供两个辅助启动文件一个称为crtbegin.o,还有一个被称为

crtend.o。结合.ctors和.dtors两个section,c++全局的构造函数和析构函数

能以运行时最小的负载,正确的顺序执行。





.ctors

    该section保存着程序的全局的构造函数的指针数组。



.dtors

    该section保存着程序的全局的析构函数的指针数组。    



ctrbegin.o

    有四个section:

    1 .ctors section

    local标号__CTOR_LIST__指向全局构造函数的指针数组头。在

    ctrbegin.o中的该数组只有一个dummy元素。



    [译注:

    # objdump -s -j .ctors                 

    /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/crtbegin.o



    /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/crtbegin.o:

    file format elf32-i386

    Contents of section .ctors:

    0000 ffffffff                             ....

    这里说的dummy元素应该就是指的是ffffffff

    ]



    2 .dtors section

    local标号__DTOR_LIST__指向全局析构函数的指针数组头。在

    ctrbegin.o中的该数组仅有也只有一个dummy元素。



    3 .text section

    只包含了__do_global_dtors_aux函数,该函数遍历__DTOR_LIST__

    列表,调用列表中的每个析构函数。

函数如下:



Disassembly of section .text:



00000000 <__do_global_dtors_aux>:

   0:   55                      push   %ebp

   1:   89 e5                   mov    %esp,%ebp

   3:   83 3d 04 00 00 00 00    cmpl   $0×0,0×4

   a:   75 38                   jne    44 <__do_global_dtors_aux+0×44>

   c:   eb 0f                   jmp    1d <__do_global_dtors_aux+0×1d>

   e:   89 f6                   mov    %esi,%esi

  10:   8d 50 04                lea    0×4(%eax),%edx

  13:   89 15 00 00 00 00       mov    %edx,0×0

  19:   8b 00                   mov    (%eax),%eax

  1b:   ff d0                   call   *%eax

  1d:   a1 00 00 00 00          mov    0×0,%eax

  22:   83 38 00                cmpl   $0×0,(%eax)

  25:   75 e9                   jne    10 <__do_global_dtors_aux+0×10>

  27:   b8 00 00 00 00          mov    $0×0,%eax

  2c:   85 c0                   test   %eax,%eax

  2e:   74 0a                   je     3a <__do_global_dtors_aux+0×3a>

  30:   68 00 00 00 00          push   $0×0

  35:   e8 fc ff ff ff          call   36 <__do_global_dtors_aux+0×36>

  3a:   c7 05 04 00 00 00 01    movl   $0×1,0×4

  41:   00 00 00

  44:   c9                      leave

  45:   c3                      ret

  46:   89 f6                   mov    %esi,%esi





    4 .fini section

    它只包含一个__do_global_dtors_aux的函数调用。请记住,它仅是

    一个函数调用而不返回的,因为crtbegin.o的.fini section是这个

    函数体的一部分。

函数如下:

Disassembly of section .fini:



00000000 <.fini>:

   0:   e8 fc ff ff ff          call   1 <.fini+0×1>





crtend.o

    也有四个section:



    1 .ctors section

    local标号__CTOR_END__指向全局构造函数的指针数组尾部。



    2 .dtors section

    local标号__DTOR_END__指向全局析构函数的指针数组尾部。



    3 .text section

    只包含了__do_global_ctors_aux函数,该函数遍历__CTOR_LIST__

    列表,调用列表中的每个构造函数。

函数如下:

00000000 <__do_global_ctors_aux>:

   0:   55                      push   %ebp

   1:   89 e5                   mov    %esp,%ebp

   3:   53                      push   %ebx

   4:   bb fc ff ff ff          mov    $0xfffffffc,%ebx

   9:   83 3d fc ff ff ff ff    cmpl   $0xffffffff,0xfffffffc

  10:   74 0c                   je     1e <__do_global_ctors_aux+0×1e>

  12:   8b 03                   mov    (%ebx),%eax

  14:   ff d0                   call   *%eax

  16:   83 c3 fc                add    $0xfffffffc,%ebx

  19:   83 3b ff                cmpl   $0xffffffff,(%ebx)

  1c:   75 f4                   jne    12 <__do_global_ctors_aux+0×12>

  1e:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx

  21:   c9                      leave

  22:   c3                      ret

  23:   90                      nop



    4 .init section

    它只包含一个__do_global_ctors_aux的函数调用。请记住,它仅是

    一个函数调用而不返回的,因为crtend.o的.init section是这个函

    数体的一部分。

函数如下:

Disassembly of section .init:



00000000 <.init>:

   0:   e8 fc ff ff ff          call   1 <.init+0×1>





crti.o

    在.init section中仅是个_init的函数标号。

    在.fini section中的_fini函数标号。



crtn.o

    在.init和.fini section中仅是返回指令。



Disassembly of section .init:



00000000 <.init>:

   0:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx

   3:   c9                      leave

   4:   c3                      ret

Disassembly of section .fini:



00000000 <.fini>:

   0:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx

   3:   c9                      leave

   4:   c3                      ret



编译产生可重定位文件时,gcc把每个全局构造函数挂在__CTOR_LIST上

(通过把指向构造函数的指针放到.ctors section中)。

它也把每个全局析构函挂在__DTOR_LIST上(通过把指向析构函的指针

放到.dtors section中)。



连接时,gcc在所有重定位文件前处理crtbegin.o,在所有重定位文件后处理

crtend.o。另外,crti.o在crtbegin.o之前被处理,crtn.o在crtend.o之后

被处理。



当产生可执行文件时,连接器ld分别的连接所有可重定位文件的ctors 和

.dtors section到__CTOR_LIST__和__DTOR_LIST__列表中。.init section

由所有的可重定位文件中_init函数组成。.fini由_fini函数组成。



运行时,系统将在main函数之前执行_init函数,在main函数返回后执行

_fini函数。





★4 ELF的动态连接与装载



★4.1 动态连接



当在UNIX系统下,用C编译器把C源代码编译成可执行文件时,c编译驱动器一般

将调用C的预处理,编译器,汇编器和连接器。



.     c编译驱动器首先把C源代码传到C的预处理器,它以处理过的宏和

    指示器形式输出纯C语言代码。



.    c编译器把处理过的C语言代码翻译为机器相关的汇编代码。



.    汇编器把结果的汇编语言代码翻译成目标的机器指令。结果这些

    机器指令就被存储成指定的二进制文件格式,在这里,我们使用的

    ELF格式。



.    最后的阶段,连接器连接所有的object文件,加入所有的启动代码和

    在程序中引用的库函数。



    下面有两种方法使用lib库

    

    –static library

    一个集合,包含了那些object文件中包含的library例程和数据。用

    该方法,连接时连接器将产生一个独立的object文件(这些

    object文件保存着程序所要引用的函数和数据)的copy。

    

    –shared library

    是共享文件,它包含了函数和数据。用这样连接出来的程序仅在可执行

    程序中存储着共享库的名字和一些程序引用到的标号。在运行时,动态

    连接器(在ELF中也叫做程序解释器)将把共享库映象到进程的虚拟

    地址空间里去,通过名字解析在共享库中的标号。该处理过程也称为

    动态连接(dynamic linking)



程序员不需要知道动态连接时用到的共享库做什么,每件事情对程序员都是

透明的。





★4.2 动态装载(Dynamic Loading)



动态装载是这样一个过程:把共享库放到执行时进程的地址空间,在库中查找

函数的地址,然后调用那个函数,当不再需要的时候,卸载共享库。它的执行

过程作为动态连接的服务接口。



在ELF下,程序接口通常在<dlfcn.h>中被定义。如下:



void *dlopen(const char * filename,int flag);

const char * dlerror(void);

const void * dlsym (void handle*,const char * symbol);

int dlclose(void * handle);



这些函数包含在libdl.so中。下面是个例子,展示动态装载是如何工作的。

主程序在运行时动态的装载共享库。一方面可指出哪个共享库被使用,哪个

函数被调用。一方面也能在访问共享库中的数据。



[alert7@redhat62 dl]# cat dltest.c

#include <stdio.h>

#include <stdlib.h>

#include <getopt.h>

#include <dlfcn.h>

#include <ctype.h>



typedef void (*func_t) (const char *);



void dltest(const char *s)

{

    printf(“From dltest:”);

    for (;*s;s++)

    {    

        putchar(toupper(*s));

    }

    putchar(‘\n’);

}



main(int argc,char **argv)

{

void *handle;

func_t fptr;

char * libname = “./libfoo.so”;

char **name=NULL;

char *funcname = “foo”;

char *param= “Dynamic Loading Test”;

int ch;

int mode=RTLD_LAZY;    



while ((ch = getopt(argc,argv,”a:b:f:l:”))!=EOF)

{

    switch(ch)

    {

    case ‘a’:/*argument*/

        param=optarg;

        break;

    case ‘b’:/*how to bind*/

        switch(*optarg)

        {

        case ‘l’:/*lazy*/

        mode = RTLD_LAZY;

        break;

        case ‘n’:/*now*/

        mode = RTLD_NOW;

        break;

        }

        break;

    case ‘l’:/*which shared library*/

        libname= optarg;

        break;

    case ‘f’:/*which function*/

        funcname= optarg;

    }

   }



handle = dlopen(libname,mode);

if (handle ==NULL)

{

fprintf(stderr,”%s:dlopen:’%s’\n”,libname,dlerror());

exit(1);

}



fptr=(func_t)dlsym(handle,funcname);

if (fptr==NULL)

{

fprintf(stderr,”%s:dlsym:’%s’\n”,funcname,dlerror());

exit(1);

}





name = (char **) dlsym(handle,”libname”);

if (name==NULL)

{

fprintf(stderr,”%s:dlsym:’libname’\n”,dlerror());

exit(1);

}



printf(“Call ‘%s’ in ‘%s’:\n”,funcname,*name);



/*call that function with ‘param’*/

(*fptr)(param);



dlclose(handle);

return 0;



}



这里有两个共享库,一个是libfoo.so一个是libbar.so。每个都用同样的全局

字符串变量libname,分别各自有foo和bar函数。通过dlsym,对程序来说,他们

都是可用的。



[alert7@redhat62 dl]# cat libbar.c

#include <stdio.h>



extern void dltest(const char *);

const char * const libname = “libbar.so”;



void bar (const char *s)

{

dltest(“Called from libbar.”);

printf(“libbar:%s\n”,s);

}





[alert7@redhat62 dl]# cat libfoo.c

#include <stdio.h>



extern void dltest (const char *s);

const char *const libname=”libfoo.so”;



void foo(const char *s)

{

    const char *saved=s;

    

    dltest(“Called from libfoo”);

    printf(“libfoo:”);

    for (;*s;s++);

    for (s–;s>=saved;s–)

    {

    putchar (*s);

    }

    putchar (‘\n’);

}



使用Makefile文件来编译共享库和主程序是很有用的。因为libbar.so和

libfoo.so也调用了主程序里的dltest函数。



[alert7@redhat62 dl] #cat Makefile

CC=gcc

LDFLAGS=-rdynamic

SHLDFLAGS=

RM=rm



all:dltest



libfoo.o:libfoo.c

    $(CC) -c -fPIC $<



libfoo.so:libfoo.o

    $(CC) $(SHLDFLAGS) -shared -o $@ $^



libbar: libbar.c

    $(CC) -c -fPIC $<



libbar.so:libbar.o

    $(CC) $(SHLDFLAGS) -shared -o $@ $^



dltest: dltest.o libbar.so libfoo.so

    $(CC) $(LDFLAGS) -o $@ dltest.o -ldl



clean:

    $(RM) *.o *.so dltest



处理流程:



[alert7@redhat62 dl]# export ELF_LD_LIBRARY_PATH=.

[alert7@redhat62 dl]# ./dltest

Call ‘foo’ in ‘libfoo.so’:

From dltest:CALLED FROM LIBFOO

libfoo:tseT gnidaoL cimanyD

[alert7@redhat62 dl]# ./dltest -f bar

bar:dlsym:’./libfoo.so: undefined symbol: bar’

[alert7@redhat62 dl]# ./dltest -f bar -l ./libbar.so

Call ‘bar’ in ‘libbar.so’:

From dltest:CALLED FROM LIBBAR.

libbar:Dynamic Loading Test





在动态装载进程中调用的第一个函数就是dlopen,它使得共享可库对

运行着的进程可用。dlopen返回一个handle,该handle被后面的dlsym

和dlclose函数使用。dlopen的参数为NULL有特殊的意思—它使得在

程序导出的标号和当前已经装载进内存的共享库导出的标号通过dlsym

就可利用。



在一个共享库已经装载进运行着的进程的地址空间后,dlsym可用来

获得在共享库中导出的标号地址。然后就可以通过dlsym返回的地址

来访问里面的函数和数据。



当一个共享库不再需要使用的时候,就可以调用dlclose卸载该函数库。

假如共享库在启动时刻或者是通过其他的dlopen调用被装载的话,该

共享库不会从调用的进程的地址空间被移走。



假如dlclose操作成功,返回为0。dlopen和dlsym如果有错误,将返回

为NULL。为了获取诊断信息,可调用dlerror.





★5 支持ELF的LINUX上的编译器GNU GCC



感谢Eric Youngdale (eric@aib.com),lan Lance Taylor (ian@cygnus.com)

还有许多为gcc支持ELF功能的默默做贡献的人。我们能用gcc和GNU的二进制

工具很容易的创建ELF可执行文件和共享库。



★5.1 共享C库 Shared C Library



在ELF下构造一个共享库比其他的容易的多。但是需要编译器,汇编器,

连接器的支持。首先,需要产生位置无关(position-independent)代码。

要做到这一点,gcc需要加上编译选项-fPIC

[alert7@redhat62 dl]# gcc -fPIC -O -c libbar.c



这时候就适合构造共享库了,加上-shared编译选项

[alert7@redhat62 dl]# gcc -shared -o libbar.so libbar.o



现在我们构造的libbar.so就可以被连接器(link editor)和动态连接器

(dynamic linker)。只要编译时带上-fPIC编译选项,可以把许多重定位

文件加到共享库中。为了把baz.o和共享库连接在一起,可以如下操作:

# gcc -O -c baz.c

# gcc -o baz baz.o -L. -lbar



在把libbar.so安装到动态连接器能找到的正确位置上之后,运行baz将

使libbar.so映象到baz的进程地址空间。内存中libbar.so的一份拷贝将

被所有的可执行文件(这些可执行程序连接时和它一块儿连接的或者

在运行时动态装载的)共享。



★5.2 共享C++库 Shared C++ Library



在共享c++库中主要的困难是如何对待构造函数和析构函数。

在SunOS下,构造和使用一个共享的ELF C库是容易的,但是在SunOS下不能

构造共享的C++库,因为构造函数和析构函数有特别的需求。为止,在ELF

中的.init和.init section提供了完美的解决方法。



当构造共享C++库时,我们使用crtbegin.o和crtend.o这两个特殊的版本,

(它们已经是经过-fPIC的)。对于连接器(link editor)来说,构造共享

的C++库几乎是和一般的可执行文件一样的。全局的构造函数和析构函数

被.init和.fini section处理(在上面3.1节中已经讨论过)。



但一个共享库被映射到进程的地址空间时,动态连接器将在传控制权给程序

之前执行_init函数,并且将为_fini函数安排在共享库不再需要的时候被

执行。



连接选项-shared是告诉gcc以正确的顺序放置必要的辅助文件并且告诉它将

产生一个共享库。-v选项将显示什么文件什么选项被传到了连接器

(link editor).



[alert7@redhat62 dl]# gcc -v -shared -o libbar.so libbar.o

Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/specs

gcc version egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)

/usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/collect2 -m elf_i386

-shared -o libbar.so /usr/lib/crti.o /usr/lib/gcc-lib/i386-redhat

    -linux/egcs-2.91.66/crtbeginS.o

-L/usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66

-L/usr/i386-redhat-linux/lib libbar.o -lgcc -lc –version-script

/usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/libgcc.map

-lgcc /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/crtendS.o

/usr/lib/crtn.o



crtbeginS.o和crtendS.o用-fPIC编译的两个特殊的版本。带上-shared

创建共享库是重要的,因为那些辅助的文件也提供其他服务。我们将在

5.3节中讨论。



★5.3 扩展的GCC特性



GCC有许多扩展的特性。有些对ELF特别的有用。其中一个就是__attribute__。

使用__attribute__可以使一个函数放到__CTOR_LIST__或者__DTOR_LIST__里。

例如:



[alert7@redhat62 dl]# cat miss.c



#include <stdio.h>

#include <stdlib.h>



static void foo(void) __attribute__ ((constructor));

static void bar(void) __attribute__ ((destructor));





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

{

        printf(“foo == %p\n”, foo);

        printf(“bar == %p\n”, bar);



        exit(EXIT_SUCCESS);

}



void foo(void)

{

        printf(“hi dear njlily!\n”);

}



void bar(void)

{

        printf(“missing u! goodbye!\n”);

}



[alert7@redhat62 dl]# gcc -o miss miss.c

[alert7@redhat62 dl]# ./miss

hi dear njlily!

foo == 0×8048434

bar == 0×8048448

missing u! goodbye!



我们来看看是否加到了.ctors和.dtors中。

[alert7@redhat62 dl]# objdump -s -j .ctors miss



miss:     file format elf32-i386



Contents of section .ctors:

8049504 ffffffff 34840408 00000000           ….4…….



[alert7@redhat62 dl]# objdump -s -j .dtors miss



miss:     file format elf32-i386



Contents of section .dtors:

8049510 ffffffff 48840408 00000000           ….H…….



已经把foo和bar地址分别放到了.ctors和.dors,显示34840408只是因为

x86上是LSB编码的,小端序。



__attribute__ ((constructor))促使函数foo在进入main之前会被自动调用。

__attribute__ ((destructor))促使函数bar在main返回或者exit调用之后

会被自动调用。foo和bar必须是不能带参数的而且必须是static void类型的

函数。在ELF下,这个特性在一般的可执行文件和共享库中都能很好的工作。





我们也可以创建自己的section,在这里我创建了一个alert7 section.

[alert7@redhat62 dl]# cat test.c

#include <stdio.h>

#include <stdlib.h>



static void foo(void) __attribute__ ((section (“alert7″)));

static void bar(void) __attribute__ ((section (“alert7″)));





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

{

        foo();



        printf(“foo == %p\n”, foo);

        printf(“bar == %p\n”, bar);

        bar();

        exit(EXIT_SUCCESS);

}



void foo(void)

{

        printf(“hi dear njlily!\n”);

}

void bar(void)

{

        printf(“missing u! goodbye!\n”);

}

[alert7@redhat62 dl]# gcc -o test test.c

[alert7@redhat62 dl]# ./test

hi dear njlily!

foo == 0×804847c

bar == 0×8048490

missing u! goodbye!



[alert7@redhat62 dl]# objdump -x test

….

Sections:

Idx Name          Size      VMA       LMA       File off  Algn

  0 .interp       00000013  080480f4  080480f4  000000f4  2**0

                  CONTENTS, ALLOC, LOAD, READONLY, DATA



12 alert7        00000026  0804847c  0804847c  0000047c  2**2

                  CONTENTS, ALLOC, LOAD, READONLY, CODE





[alert7@redhat62 dl]# objdump -D test

Disassembly of section alert7:



0804847c <foo>:

804847c:       55                      push   %ebp

804847d:       89 e5                   mov    %esp,%ebp

804847f:       68 de 84 04 08          push   $0×80484de

8048484:       e8 a3 fe ff ff          call   804832c <_init+0×70>

8048489:       83 c4 04                add    $0×4,%esp

804848c:       c9                      leave

804848d:       c3                      ret

804848e:       89 f6                   mov    %esi,%esi



08048490 <bar>:

8048490:       55                      push   %ebp

8048491:       89 e5                   mov    %esp,%ebp

8048493:       68 ef 84 04 08          push   $0×80484ef

8048498:       e8 8f fe ff ff          call   804832c <_init+0×70>

804849d:       83 c4 04                add    $0×4,%esp

80484a0:       c9                      leave

80484a1:       c3                      ret



在这里,我创建了一个自己的alert7 section,并把foo,bar两个函数放到了这个

section中。一般定义的函数都会放在.text section中。







★5.3.1 在C库中的初始化函数



另外一个GCC的特性是__attribute__( section (“sectionname”) ).使用这个,

能把一个函数或者是数据结构放到任何的section中。



static void

foo (int argc,char **argc,char **envp)

    __attribute__ ((section (“_libc_foo”)));



static void

foo (int argc,char **argv,char **envp)

{

}



static void

bar (int argc,char **argv,char **envp)

{

}



static void * __libc_subinit_bar__

    __attribute__ (( section (“_libc_subinit”)))=&(bar);



这里,我们把foo放到了_libc_foo section,把__libc_subinit_bar__放

到了_libc_subinit section中。在Linux C库中,_libc_subinit 是一个特别

的section,它包含了一个函数指针(有如下原型)的数组。



void (*) (int argc,char **argv,char **envp);



这里的argc,argv,envp跟在main中的有同样的意义。该section中的函数在进入

main函数之前就会被调用。这是很有用的,可用来在Linux C库中初始化一些

全局变量。



    [译注:_libc_subinit section真有这个特别的功能吗?我是没有试

    成功,如果有人试成功或者认为我理解有误的地方,千万记得mail给

    我:)

    测试程序如下:

    #include <stdio.h>

    #include <stdlib.h>

    static void foo(int argc,char **argv,char **envp)

    {

        printf("hi dear njlily!\n");

    }

    

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

    {

        printf(“foo == %p\n”, foo);

        exit(EXIT_SUCCESS);

    }

    

    static void * __libc_subinit_bar__

            __attribute__ (( section (“_libc_subinit”)))=&(foo);

    

    [alert7@redhat62 dl]# gcc -o test1 test1.c

    [alert7@redhat62 dl]# ./test1

    foo == 0×8048400

    :( 用objdump,显示已经创建了一个_libc_subinit section,并且

    该section前四个字节就是foo地址0×8048400

    

    ]







★5.4 利用GCC和GNU ld



这一些命令行的选项对GCC和GNU ld创建ELF时特别有用。-shared告诉gcc

产生一个共享库,该共享库能在连接时和其他的共享文件一起形成可执行

文件,该共享库也能在运行时装载进可执行文件的地址空间。使用-shared

是创建一个共享ELF库的首选方法。



另外一个有用的命令行选项是-Wl,ldoption,传递参数ldoption作为连接器

的选项。假如ldoption包含多个逗号,将分离成多个选项。



-static选项将产生一个和static库一道连接的可执行文件。当没有开启

-static选项时,连接器首先试着用共享库,假如共享版本不可用,然后

再试着用静态(static)库。



这里还有些特别的命令行选项对ELF来说特别的或者说是有用的。



-dynamic-linker file

    设置动态连接器(dynamic linker)的名字。默认的动态连接器

    或者是/usr/lib/libc.so.1或者是/usr/lib/libd1.so.1

    



-export-dynamic

    告诉连接器使在可执行文件中的所有标号对动态连接器可用。当一个

    动态装载进的共享库参考可执行文件中的标号,该标号一般在动态连

    接时是不可用时,这时候就特别有用。



-lfile

    加文件到需要连接的列表中。该选项可用在许多时候。ld将搜索它的

    path-list查找文件libfile.so(也就是说假如库为libbar.so,那么

    使用的时候就这样使用,-lbar),或者libfile.a(static版本的)。

    一些情况下,共享库名libfile.so会被存储在resulting executable

    或者是共享库中。当resulting executable或者是共享库被装载进内

    存,动态连接器也将把使用记录过的共享库装载到进程的地址空间去。

    在以后的事情情况下,把必要的函数和数据被拷贝到可执行文件,减

    少代码长度。



-m emulation

    仿效emulation连接器r.-V参数可列出所有可用的选项.



-M | -Map mapfile

    把连接map输出到标准输出或者一个mapfile文件里,该连接map含有

    关于标号被ld映象到了哪里的一些诊断信息,还有全局共同的存储

    分配信息。



-rpath directory

    加一个目录到运行时library的搜索路径。所有的-rpath参数被连接

    在一起然后传给动态连接器。它们被用来在运行时定位共享库。



-soname name

    当创建一个共享库时,指定的名字被放在共享库中。当和这个共享

    库连接的可执行文件被运行,动态连接器将试着map记录着的指定

    名字的共享库而不是传给连接器的文件名。



-static

    告诉连接器不要和任何共享库连接。



-verbose

    告诉连接器打印它每个要打开的文件名。



linux下gcc beta版本使用-dynamic-linker file选项设置动态连接器为

/lib/ld-linker.so.1。该选项可以使ELF和a.out共享库很好的共存。



有件事情是另人感兴趣的。



[alert7@redhat62 dl]# gcc -shared -o libbar.so libbar.o -lfoo



假如libfoo.so被用来创建共享库时,有趣的时候就会发生了。当libbar.so

被映象到进程的地址空间的时候,动态连接器也把libfoo.so映象到内存。

假如libbar.so需要libfoo.so的时候,这个特性非常有用。实际上使用

libbars.o库的程序编译时是不需要-lfoo的。假如archive版本的libfoo.a

被使用,当在libbar.a中的标号被libbar.o引用时,它将会被搜索到。假使在

libbar.so中包含libfoo.a甚至它们根本不被libbar.o使用,在这样的情况下

必须逐步把.o文件加到libbar.o中:



# rm -rf /tmp/foo

# mkdir /tmp/foo

# (cd /tmp/foo/;ar -x ……/libfoo.a)

# gcc -shared -o libbar.so libbar.o /tmp/foo/*.o

# rm -rf /tmp/foo



在libfoo.a中的.o文件必须用-fPIC编译或者至少和PIC(位置无关)是

兼容的。



当使用



static void * __libc_subinit_bar__

    __attribute__    ((section (“_libc_subinit”)))=&(bar);



来把一个标号放到一个没有被连接器定义的section中(在这里是

_libc_subinit).连接器将所有在_libc_subinit section中的标号共同

创建两个标号,一个是__start__libc_subinit和__stop__libc_subinit,

它们作为C的标志符被使用。



警告:

下面是完全可能的:连接器可能不能搜索到包含_libc_subinit section

的文件(该section中没有程序执行需要的标号)。这就使程序要确定使

_libc_subinit section能被连接器搜索得到。



一种解决的办法是:把一个dummy标号放到_libc_subinit section中,

在文件中定义它,使它参考引用_libc_subinit section.





★5.5 Linux下的ELF



在Linux下ELF的执行有独特的特性,这些特性对Linux的使用者来说是很有用

的。一些Linux自己的扩展跟Solaris ELF的执行很类似。



★5.5.1 ELF的宏(macros)



<gnu-stabs.h>中,定义了能维护标号的一些宏。



elf_alias(name1,name2)

    为标号name1定义一个化名name2.当文件中标号名已经被定义的时候

    应该是有很用的。



weak_alias(name1,name2)

    为标号name1定义一个弱化名name2。仅当name2没有在任何地方定义

    时,连接器就会用name1解析name2相关的符号。在文件中定义的

    标号name1也会同样处理。



elf_set_element(set,symbol)

    强迫标号成为set集合的元素。为每个set集合创建一个section.



symbol_set_declare(set)

    在该模块中宣告一个集合set.事实上宣告了两个标号:

    1  一个set的开始标号

    extern void * const __start_set

    2  一个set的结尾标号

    extern void * const __stop_set



symbol_set_first_element(set)

    返回一个指针(void * const *),指向set集合第一个元素。



symbol_set_end_p(set,ptr)

    假如ptr(void * const *)逐渐增加指向set的最后一个元素,

    就返回为true.



使用这些宏,程序员能任意从不同的源文件中创建列表。





★5.5.2 library(库)的定位和搜索路径



在Linux下,大部分系统的library库被安装在/usr/lib目录下。只有一些

基本的共享库被安装在/lib目录下。例如:libc.so,libcurses.so,libm.so

,libtermcap.so(各个版本对应的文件会有些不同),在其他部分被mount上

之前,那些文件是启动Linux系统所必须的。连接器默认的搜索路径是

/lib,/usr/lib,/usr/local/lib,/usr/i486-linux/lib。



环境变量LD_LIBRARY_PATH也可保存目录列表,用(:)分开,该变量被动态

连接器检查并用该变量指出的目录查找共享库。

例如:/usr/X11R6/lib:/usr/local/lib:告诉动态连接器查找共享库除了

现在在默认的目录查找外,然后在/usr/X11R6/lib目录,然后再是

/usr/local/lib目录,然后再是当前目录。



新的环境变量ELF_LD_LIBRARY_PATH扮演着和LD_LIBRARY_PATH类似的角色。

因为LD_LIBRARY_PATH也被老的a.out DLL linux的共享库使用。为了避免

来自DLL连接器的不必要的警告,对于在Linux下ELF的动态连接器来说,

最好使用LD_LIBRARY_PATH环境变量。



另外一个特性是/etc/ld.so.conf文件,该文件包含了一些目录列表。

例如:



/usr/X11R6/lib

/usr/lib

/usr/kerberos/lib

/usr/i486-linux-libc5/lib

/usr/lib/gconv

/usr/lib/qt-2.1.0/lib

/usr/lib/qt-1.45/lib



程序ldconfig将把/etc/ld.so.conf文件中列出的搜索目录中的所有的

共享库存储到/etc/ld.so.cache中。假如共享库已经被从默认的目录中

移走,Linux ELF动态连接库将在/etc/ld.so.cache文件中找该共享库。







★5.5.3 共享库的版本



在ELF系统上,假如两个共享库有同样的应用程序二进制接口(ABI)的子集

的话,对那些仅仅使用那些ABI子集的程序来说,这两个共享库是可以互相

通用的(当然那两个共享库有同样的函数功能)。



当一个库被改变,只要新的ABI和前面一个版本的共享库有100%的兼容的话,

所有和前面版本连接的程序在新的共享库下也能很好的运行。为了支持这,

foo库必须小心的维护:



1.这个共享库应该如下构造:



[alert7@redhat62 dl]# gcc -shared -Wl,-soname,libfoo.so.major \

    -o libfoo.so.major.minor.patch-level libfoo.o



动态连接器运行时将试着定位和映象libfoo.so.major而不管事实上用的共享

文件名libfoo.so.major.patch-level。



2.一个符号连接应该指向正确的共享库



[alert7@redhat62 dl]# ln -s libfoo.so.major.minor.patch-level \

    libfoo.so.major



3.当ABI改变和原来版本不兼容的时,主(major)版本号应该升级。



当搜索共享库的时候,Linux连接器将使用最新的共享库(它们有最高的

major,minor和patch level的版本号)。





★5.5.4 共享(shared)库和静态(static)库的混合连接



默认情况下,假如共享库可用,连接器会使用共享库。但是-Bdynamic和

-Bstatic提供了很好控制库的方法。它们可以决定用共享库还是用静态库。



传-Bdynamic和-Bstatic选项给连接器,如下操作:

# gcc -o main main.o -Wl,-Bstatic \

    -lfoo -Wl,-Bdynamic -lbar



# gcc -o main main.o -Wl,-Bstatic

告诉连接器所有的库(象libc等等)都使用静态的版本。





★5.5.5 装载附加的共享库



在ELF系统上,为了执行一个ELF文件,内核要把控制权交给动态连接器

ld-linux.so.1(在linux上动态连接器是ld-linux.so.1,版本不同也会不同的,

在默认的redhat6.2上是/lib/ld-linux.so.2)。在绝对路径/lib/ld-linux.so.1

以二进制存放着。假如动态连接器不存在,没有哪个ELF可执行文件能运行。



动态连接器执行以下一个步骤完成从程序到进程映象:



    1.分析可执行文件中的动态信息section,决定需要哪些库。



    2.定位和映象(map)那些共享库,并且分析它们动态信息section

      决定是否需要附加的共享库。



    3.为可执行程序和那些需要的共享库执行重定位。



    4.调用共享库中提供的任何初始化函数并且安排共享库提供的

      清除(cleanup)函数在共享库卸栽出进程空间的时候运行。



    5.传控制给程序



    6.为应用程序提供函数的迟延装定服务



    7.为应用程序提供动态转载服务。



环境变量LD_PRELOAD设置共享库名或者用”:”把文件名隔开。动态连接器在

任何那些请求的共享库之前把环境变量LD_PRELOAD的共享库装载到进程地址

空间去。例如:



# LD_PRELOAD=./mylibc.so myprog



这里./mylibc.so将第一时间map到程序myprog的空间。因为动态连接器在找

寻标号的时候总是使用第一次碰到的标号,所以我们可以使用LD_PRELOAD来

覆盖标准共享库中的函数。这个特性对程序员来说是很有用的,可用来在还

没有建好整个共享库的时候对单个函数功能先做调试实验。



我们可以这样:

#gcc -c -fPIC -O3 print.c

#gcc -shared print.o -o print.so.1.0

创建自己的共享连接库



★5.5.6 Linux下动态装载(Dynamic loading)



_dlinfo是动态连接接口库的一个函数。它列出所有映射到执行程序和通过

dlopen打开的每个共享库。它的输出类试以下:



List of loaded modules

    00000000 50006163 50006200 Exe 1

    50007000 5000620c 50006200 Lib 1 /lib/elf/libd1.so.1

    5000a000 500062c8 50006200 Lib 2 /lib/elf/libc.so.4

    50000000 50006000 00000000 Int 1 /lib/elf/ld-linux.so.1

    500aa000 08006f00 08005ff0 Mod 1 ./libfoo.so



Modules for application (50006200):

    50006163

    5000620c /lib/elf/libdl.so.1

    500062c8 /lib/elf/libc.so.4

    50006000 /lib/ld-linux.so.1

Modules for handle 8005ff0

    08006f00 ./libfoo.so

    500062c8 /lib/elf/lib.so.4

    50006163

    5000620c /lib/elf/libd1.so.1

    500062c8 /lib/elf/libc.so.4

    50006000 /lib/elf/ld-linux.so.1



以上可被用来解释动态的连接和动态的装载。



在linux支持ELF上配置的GCC假如使用了-rdynamic选项,它将把

-export-dynamic传给连接器。强烈建议使用动态装载。这就是为什么在

我们的Makefile例子中使用了LDFLAGS=-rdynamic。暂时,这个选项只能在

linux下使用。但是-Wl,-export-dynamic能在其他的平台上把-export-dynamic

传给GNU的连接器。



你能在GNU link editor的[3]和[4]部分找到它详细的描述。





★6 位置无关代码(PIC)的汇编语言编程



当用gcc指定-fPIC的时候,gcc将从C源代码中产生PIC的汇编语言代码。但有

时候,我们需要用汇编语言来产生PIC代码。



在ELF下,PIC的实现是使用基寄存器(base register)。在PIC下,所有的

标号引用都是通过基寄存器实现的,为此,要用汇编写PIC的话,必须保存

基寄存器(base register)。由于位置无关代码,控制传送指令的目的地址

必须被替换或者是在PIC情况下计算的。对X86机器来说,该基寄存器

(base register)就是ebx.这里我们将介绍在X86上安全的PIC汇编代码的

两种方法。这些技术在Linux C库中也被使用到。





★6.1 在C中内嵌汇编



gcc支持内嵌汇编的声明,可让程序员在C语言中使用汇编语言。当写LINUX系

统调用接口的时候这是很有用的,而无须使用机器相关指令。



在linux 下系统调用是通过int $0×80的。一般的,系统调用会有三个参数:



#include <sys/syscall.h>



extern int errno;



int read( int fd,void *buf ,size count)

{

long ret;



__asm__ __volatile__ (“int $0×80″

        :”=a”(ret)

        :”O”(SYS_read),”b”((long)fd),

         “c”((long)buf),”d”((long)count):”bx”);

    

    if (ret>=0)

    {

    return (int) ret:

    }

    errno=-ret;

retrun -1;

}



以上汇编代码把系统调用号SYS_read放到了eax中,fd到ebx中,buf到

ecx中,count到edx中,从int $0×80中返回值ret放在eax中。在不用

-fPIC的情况下,这样定义运行良好。带-fPIC的gcc应该要检查ebx是否被

被改变,并且应该在汇编代码里保存和恢复ebx。但是不幸的是,事实上不是

这样的。我们为了支持PIC必须自己写汇编代码。



#include <sys/syscall.h>



extern int errno;



int read( int fd,void *buf ,size count)

{

long ret;



__asm__ __volatile__ (“pushl %%ebx\n\t”

        ”movl %%esi,%%ebx\n\t”        

        ”int $0×80\n\t”

        ”popl %%ebx”

        :”=a”(ret)

        :”O”(SYS_read),”S”((long)fd),

         “c”((long)buf),”d”((long)count):”bx”);

    

    if (ret>=0)

    {

    return (int) ret:

    }

    errno=-ret;

return -1;

}



这里首先把fd放到esi中,然后保存ebx,把esi移到ebx,在int $0×80后恢复

ebx。这样保证ebx不被改变(除了在int $0×80中断调用中)。同样的原则也

适用于其他内嵌的汇编。



在任何时候,当ebx可能要被改变时,千万要记得保存和恢复ebx.





★6.2 用汇编语言编程



假如我们在系统调用时需要传5个参数时候,内嵌的汇编代码即使是PIC

的,也不能工作,因为x86没有足够的寄存器。我们需要直接用汇编语言

编写。



syscall(int syscall_number,…)的一般汇编代码如下:



    .file “syscall.S”

    .text

    .global syscall

    .global errno

    .align 16



syscall:

    pushl 5ebp

    movl %esp,%ebp

    pushl %edi

    pushl %esi

    pushl %ebx

    movl 8(%ebp),%eax    

    movl 12(%ebp),%ebx    

    movl 16(%ebp),%ecx    

    movl 20(%ebp),%edx    

    movl 24(%ebp),%esi    

    movl 28(%ebp),%edi    

    int $0×80

    test %eax,%eax

    jpe .LLexit

    negl %eax

    movl %eax,errno

    movl $-1, %eax



.LLexit:

    popl %ebx

    popl %esi

    popl %edi

    movl %ebp,%esp

    popl %ebp

    ret

    .type syscall,@function

.L_syscall_end:

    .size syscall,.L_syscall_end -syscall



在PIC下,我们必须通过GOT(global offset table)来访问任何全局变量

(除了保存在基寄存器ebx中的)。修改的代码如下:



.file “syscall.S”

.text

.global syscall

.global errno

.align 16

syscall:

    pushl %ebp

    movl %esp,%ebp

    pushl %edi

    pushl %esi

    pushl %ebx

    call .LL4

.LL4:

    popl %ebx

    addl $_GLOBAL_OFFSET_TABLE_+[.- .LL4],%ebx

    pushl %ebx

    movl 8(%ebp),%eax

    movl 12(%ebp),%ebx

    movl 16(%ebp),%ecx

    movl 20(%ebp),%edx

    movl 24(%ebp),%esi

    movl 28(%ebp),%edi

    int $0×80

    popl %ebx

    movl %eax,%edx

    test %edx,%edx

    jge .LLexit

    negl %edx

    movl errno@GOT(%ebx),%eax

    movl %edx,(%eax)

    movl $-1,%eax

.LLexit:

    popl %ebx

    popl %esi

    popl %edi

    movl %ebp,%esp

    popl %ebp

    ret

    .type syscall,@function

.L_syscall_end:

    .size syscall,.L_syscall_end-syscall



假如要得到PIC的汇编代码,但是又不知道如何写,你可以写一个C的,然后如下

编译:



#gcc -O -fPIC -S foo.c



它将告诉gcc产生汇编代码foo.s输出,根据需要,可以修改它。





★7 结束语



根据以上讨论的,我们可以得出这样的结论:ELF是非常灵活的二进制格式。

它提供了非常有用的功能。这种规范没有给程序和程序员太多限制。它使

创建共享库容易,使动态装载和共享库的结合更加容易。在ELF下,在C++

中,全局的构造函数和析构函数在共享库和静态库中用同样方法处理。





[译注:

    到此,文章是翻译好了,但里面的一些东西看起来可能

    有点问题,比如说_libc_subinit section没有他说的

    那个功能,-dynamic-linker选项在默认的redhat 6.2系统

    上不能用,_dlinfo动态连接接口库函数好象在linux没有实现

    等等一系列问题,欢迎讨论指正

    mailto: alert7@21cn.com

                 alert7@xfocus.org

]



参考:



1. Operating System API Reference:UNIX SVR4.2,UNIX Press,1992



2. SunOs 5.3 Linker and Libraries Manual,SunSoft ,1993.



3. Richard M.Stallman,Using and porting GNU CC for version 2.6,

   Free Software Foundation,September 1994.



4. Steve Chamberlain and Roland Pesch,Using ld:The GNU linker,ld

   version 2,Cygnus Support,January 1994.

2005年02月04日

这是一个在CU上讨论过的精华贴,刚才review自己帖子的时候把自己当时的代码挑出来了,或许会有用。
void trim ( char* ptr)

{

    char *p,*q,*pos;

    if ( ptr == NULL)

        return ;

    p = q = pos = ptr;

    for ( ;*p==’ ’||*p==’\t’;p++);

    

    q = pos = p;

    for ( ; *p != ’\0′; p++)

    {

        if ( *p!=’ ’&&*p!=’\t’)

            q = p;  

    }

    *(++q) = ’\0′; 

    memmove ( ptr,pos,q-pos+1);

    return ;

}

2005年01月27日

原文链接:http://lover_p.cstc.net.cn/lover_P/doc/Translations/CTraps/CTraps.htm

C语言陷阱和缺陷[1]

原著:Andrew Koenig – AT&T Bell Laboratories Murray Hill, New Jersey 07094
原文:收藏
翻译:lover_P
出处:本站


[译序]

那些自认为已经“学完”C语言的人,请你们仔细读阅读这篇文章吧。路还长,很多东西要学。我也是……

[概述]

C语言像一把雕刻刀,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C会伤到那些不能掌握它的人。本文介绍C语言伤害粗心的人的方法,以及如何避免伤害。

[内容]

 

0 简介

C语言及其典型实现被设计为能被专家们容易地使用。这门语言简洁并附有表达力。但有一些限制可以保护那些浮躁的人。一个浮躁的人可以从这些条款中获得一些帮助。

在本文中,我们将会看一看这些未可知的益处。这是由于它的未可知,我们无法为其进行完全的分类。不过,我们仍然通过研究为了一个C程序的运行所需要做的事来做到这些。我们假设读者对C语言至少有个粗浅的了解。

第一部分研究了当程序被划分为记号时会发生的问题。第二部分继续研究了当程序的记号被编译器组合为声明、表达式和语句时会出现的问题。第三部分研究了由多
个部分组成、分别编译并绑定到一起的C程序。第四部分处理了概念上的误解:当一个程序具体执行时会发生的事情。第五部分研究了我们的程序和它们所使用的常
用库之间的关系。在第六部分中,我们注意到了我们所写的程序也不并不是我们所运行的程序;预处理器将首先运行。最后,第七部分讨论了可移植性问题:一个能
在一个实现中运行的程序无法在另一个实现中运行的原因。

1 词法缺陷

编译器的第一个部分常被称为词法分析器(lexical analyzer)。词法分析器检查组成程序的字符序列,并将它们划分为记号(token)一个记号是一个有一个或多个字符的序列,它在语言被编译时具有一个(相关地)统一的意义。在C中, 例如,记号->的意义和组成它的每个独立的字符具有明显的区别,而且其意义独立于->出现的上下文环境。

另外一个例子,考虑下面的语句:

if(x > big) big = x;

该语句中的每一个分离的字符都被划分为一个记号,除了关键字if和标识符big的两个实例。

事实上,C程序被两次划分为记号。首先是预处理器读取程序。它必须对程序进行记号划分以发现标识宏的标识符。它必须通过对每个宏进行求值来替换宏调用。最后,经过宏替换的程序又被汇集成字符流送给编译器。编译器再第二次将这个流划分为记号。

在这一节中,我们将探索对记号的意义的普遍的误解以及记号和组成它们的字符之间的关系。稍后我们将谈到预处理器。

1.1 = 不是 ==

从Algol派生出来的语言,如Pascal和Ada,用:=表示赋值而用=表示比较。而C语言则是用=表示赋值而用==表示比较。这是因为赋值的频率要高于比较,因此为其分配更短的符号。

此外,C还将赋值视为一个运算符,因此可以很容易地写出多重赋值(如a = b = c),并且可以将赋值嵌入到一个大的表达式中。

这种便捷导致了一个潜在的问题:可能将需要比较的地方写成赋值。因此,下面的语句好像看起来是要检查x是否等于y

if(x = y)
foo();

而实际上是将x设置为y的值并检查结果是否非零。在考虑下面的一个希望跳过空格、制表符和换行符的循环:

while(c == ‘ ‘ || c = ‘\t’ || c == ‘\n’)
c = getc(f);

在与‘\t’进行比较的地方程序员错误地使用=代替了==。这个“比较”实际上是将‘\t’赋给c,然后判断c的(新的)值是否为零。因为‘\t’不为零,这个“比较”将一直为真,因此这个循环会吃尽整个文件。这之后会发生什么取决于特定的实现是否允许一个程序读取超过文件尾部的部分。如果允许,这个循环会一直运行。

一些C编译器会对形如e1 = e2的条件给出一个警告以提醒用户。当你趋势需要先对一个变量进行赋值之后再检查变量是否非零时,为了在这种编译器中避免警告信息,应考虑显式给出比较符。换句话说,将:

if(x = y)
foo();

改写为:

if((x = y) != 0)
foo();

这样可以清晰地表示你的意图。

1.2 &| 不是 &&||

容易将==错写为=是因为很多其他语言使用=表示比较运算。 其他容易写错的运算符还有&&&,或|||,这主要是因为C语言中的&|运算符于其他语言中具有类似功能的运算符大为不同。我们将在第4节中贴近地观察这些运算符。

1.3 多字符记号

一些C记号,如/*=只有一个字符。而其他一些C记号,如/*==,以及标识符,具有多个字符。当C编译器遇到紧连在一起的/*时,它必须能够决定是将这两个字符识别为两个分离的记号还是一个单独的记号。C语言参考手册说明了如何决定:“如果输入流到一个给定的字符串为止已经被识别为记号,则应该包含下一个字符以组成能够构成记号的最长的字符串”。因此,如果/是一个记号的第一个字符,并且/后面紧随了一个*,则这两个字符构成了注释的开始,不管其他上下文环境。

下面的语句看起来像是将y的值设置为x的值除以p所指向的值:

y = x/*p /* p 指向除数 */;

实际上,/*开始了一个注释,因此编译器简单地吞噬程序文本,直到*/的出现。换句话说,这条语句仅仅把y的值设置为x的值,而根本没有看到p。将这条语句重写为:

y = x / *p /* p 指向除数 */;

或者干脆是

y = x / (*p) /* p指向除数 */;

它就可以做注释所暗示的除法了。

这种模棱两可的写法在其他环境中就会引起麻烦。例如,老版本的C使用=+表示现在版本中的+=。这样的编译器会将

a=-1;

视为

a =- 1;

a = a – 1;

这会让打算写

a = -1;

的程序员感到吃惊。

另一方面,这种老版本的C编译器会将

a=/*b;

断句为

a =/ *b;

尽管/*看起来像一个注释。

1.4 例外

组合赋值运算符如+=实际上是两个记号。因此,

a + /* strange */ = 1

a += 1

是一个意思。看起来像一个单独的记号而实际上是多个记号的只有这一个特例。特别地,

p – > a

是不合法的。它和

p -> a

不是同义词。

另一方面,有些老式编译器还是将=+视为一个单独的记号并且和+=是同义词。

1.5 字符串和字符

单引号和双引号在C中的意义完全不同,在一些混乱的上下文中它们会导致奇怪的结果而不是错误消息。

包围在单引号中的一个字符只是书写整数的另一种方法。这个整数是给定的字符在实现的对照序列中的一个对应的值。因此,在一个ASCII实现中,‘a’和0141或97表示完全相同的东西。而一个包围在双引号中的字符串,只是书写一个有双引号之间的字符和一个附加的二进制值为零的字符所初始化的一个无名数组的指针的一种简短方法。

线面的两个程序片断是等价的:

printf(“Hello world\n”);

char hello[] = { ‘H’, ‘e’, ‘l’, ‘l’, ‘o’, ‘ ‘, ‘w’, ‘o’, ‘r’, ‘l’, ‘d’, ‘\n’, 0 };
printf(hello);

使用一个指针来代替一个整数通常会得到一个警告消息(反之亦然),使用双引号来代替单引号也会得到一个警告消息(反之亦然)。但对于不检查参数类型的编译器却除外。因此,用

printf(‘\n’);

来代替

printf(“\n”);

通常会在运行时得到奇怪的结果。

由于一个整数通常足够大,以至于能够放下多个字符,一些C编译器允许在一个字符常量中存放多个字符。这意味着用‘yes’代替“yes”将不会被发现。后者意味着“分别包含yes和一个空字符的四个连续存贮器区域中的第一个的地址”,而前者意味着“在一些实现定义的样式中表示由字符yes联合构成的一个整数”。这两者之间的任何一致性都纯属巧合。

2 句法缺陷

要理解C语言程序,仅了解构成它的记号是不够的。还要理解这些记号是如何构成声明、表达式、语句和程序的。尽管这些构成通常都是定义良好的,但这些定义有时候是有悖于直觉的或混乱的。

在这一节中,我们将着眼于一些不明显句法构造。

2.1 理解声明

我曾经和一些人聊过天,他们那时在书写在一个小型的微处理器上单机运行的C程序。当这台机器的开关打开的时候,硬件会调用地址为0处的子程序。

为了模仿电源打开的情形,我们要设计一条C语句来显式地调用这个子程序。经过一些思考,我们写出了下面的语句:

(*(void(*)())0)();

这样的表达式会令C程序员心惊胆战。但是,并不需要这样,因为他们可以在一个简单的规则的帮助下很容易地构造它:以你使用的方式声明它

每个C变量声明都具有两个部分:一个类型和一组具有特定格式的期望用来对该类型求值的表达式。最简单的表达式就是一个变量:

float f, g;

说明表达式fg——在求值的时候——具有类型float。由于待求值的时表达式,因此可以自由地使用圆括号:

float ((f));

者表示((f))求值为float并且因此,通过推断,f也是一个float

同样的逻辑用在函数和指针类型。例如:

float ff();

表示表达式ff()是一个float,因此ff是一个返回一个float的函数。类似地,

float *pf;

表示*pf是一个float并且因此pf是一个指向一个float的指针。

这些形式的组合声明对表达式是一样的。因此,

float *g(), (*h)();

表示*g()(*h)()都是float表达式。由于()*绑定得更紧密,*g()*(g())表示同样的东西:g是一个返回指float指针的函数,而h是一个指向返回float的函数的指针。

当我们知道如何声明一个给定类型的变量以后,就能够很容易地写出一个类型的模型(cast):只要删除变量名和分号并将所有的东西包围在一对圆括号中即可。因此,由于

float *g();

声明g是一个返回float指针的函数,所以(float *())就是它的模型。

有了这些知识的武装,我们现在可以准备解决(*(void(*)())0)()了。 我们可以将它分为两个部分进行分析。首先,假设我们有一个变量fp,它包含了一个函数指针,并且我们希望调用fp所指向的函数。可以这样写:

(*fp)();

如果fp是一个指向函数的指针,则*fp就是函数本身,因此(*fp)()是调用它的一种方法。(*fp)中的括号是必须的,否则这个表达式将会被分析为*(fp())。我们现在要找一个适当的表达式来替换fp。

这个问题就是我们的第二步分析。如果C可以读入并理解类型,我们可以写:

(*0)();

但这样并不行,因为*运算符要求必须有一个指针作为他的操作数。另外,这个操作数必须是一个指向函数的指针,以保证*的结果可以被调用。因此,我们需要将0转换为一个可以描述“指向一个返回void的函数的指针”的类型。

如果fp是一个指向返回void的函数的指针,则(*fp)()是一个void值,并且它的声明将会是这样的:

void (*fp)();

因此,我们需要写:

void (*fp)();
(*fp)();

来声明一个哑变量。一旦我们知道了如何声明该变量,我们也就知道了如何将一个常数转换为该类型:只要从变量的声明中去掉名字即可。因此,我们像下面这样将0转换为一个“指向返回void的函数的指针”:

(void(*)())0

接下来,我们用(void(*)())0来替换fp

(*(void(*)())0)();

结尾处的分号用于将这个表达式转换为一个语句。

在这里,我们就解决了这个问题时没有使用typedef声明。通过使用它,我们可以更清晰地解决这个问题:

typedef void (*funcptr)();
(*(funcptr)0)();

2.2 运算符并不总是具有你所想象的优先级

假设有一个声明了的常量FLAG是一个整数,其二进制表示中的某一位被置位(换句话说,它是2的某次幂),并且你希望测试一个整型变量flags该位是否被置位。通常的写法是:

if(flags & FLAG) …

其意义对于很多C程序员都是很明确的:if语句测试括号中的表达式求值的结果是否为0。出于清晰的目的我们可以将它写得更明确:

if(flags & FLAG != 0) …

这个语句现在更容易理解了。但它仍然是错的,因为!=&绑定得更紧密,因此它被分析为:

if(flags & (FLAG != 0)) …

这(偶尔)是可以的,如FLAG是1或0(!)的时候,但对于其他2的幂是不行的[2]

假设你有两个整型变量,hl,它们的值在0和15(含0和15)之间,并且你希望将r设置为8位值,其低位为l,高位为h。一种自然的写法是:

r = h << 4 + 1;

不幸的是,这是错误的。加法比移位绑定得更紧密,因此这个例子等价于:

r = h << (4 + l);

正确的方法有两种:

r = (h << 4) + l;

r = h << 4 | l;

避免这种问题的一个方法是将所有的东西都用括号括起来,但表达式中的括号过度就会难以理解,因此最好还是是记住C中的优先级。

不幸的是,这有15个,太困难了。然而,通过将它们分组可以变得容易。

绑定得最紧密的运算符并不是真正的运算符:下标、函数调用和结构选择。这些都与左边相关联。

接下来是一元运算符。它们具有真正的运算符中的最高优先级。由于函数调用比一元运算符绑定得更紧密,你必须写(*p)()来调用p指向的函数;*p()表示p是一个返回一个指针的函数。转换是一元运算符,并且和其他一元运算符具有相同的优先级。一元运算符是右结合的,因此*p++表示*(p++),而不是(*p)++

在接下来是真正的二元运算符。其中数学运算符具有最高的优先级,然后是移位运算符、关系运算符、逻辑运算符、赋值运算符,最后是条件运算符。需要记住的两个重要的东西是:

  1. 所有的逻辑运算符具有比所有关系运算符都低的优先级。
  2. 一位运算符比关系运算符绑定得更紧密,但又不如数学运算符。

在这些运算符类别中,有一些奇怪的地方。乘法、除法和求余具有相同的优先级,加法和减法具有相同的优先级,以及移位运算符具有相同的优先级。

还有就是六个关系运算符并不具有相同的优先级:==!=的优先级比其他关系运算符要低。这就允许我们判断ab是否具有与cd相同的顺序,例如:

a < b == c < d

在逻辑运算符中,没有任何两个具有相同的优先级。按位运算符比所有顺序运算符绑定得都紧密,每种与运算符都比相应的或运算符绑定得更紧密,并且按位异或(^)运算符介于按位与和按位或之间。

三元运算符的优先级比我们提到过的所有运算符的优先级都低。这可以保证选择表达式中包含的关系运算符的逻辑组合特性,如:

z = a < b && b < c ? d : e

这个例子还说明了赋值运算符具有比条件运算符更低的优先级是有意义的。另外,所有的复合赋值运算符具有相同的优先级并且是自右至左结合的,因此

a = b = c

b = c; a = b;

是等价的。

具有最低优先级的是逗号运算符。这很容易理解,因为逗号通常在需要表达式而不是语句的时候用来替代分号。

赋值是另一种运算符,通常具有混合的优先级。例如,考虑下面这个用于复制文件的循环:

while(c = getc(in) != EOF)
putc(c, out);

这个while循环中的表达式看起来像是c被赋以getc(in)的值,接下来判断是否等于EOF以结束循环。不幸的是,赋值的优先级比任何比较操作都低,因此c的值将会是getc(in)EOF比较的结果,并且会被抛弃。因此,“复制”得到的文件将是一个由值为1的字节流组成的文件。

上面这个例子正确的写法并不难:

while((c = getc(in)) != EOF)
putc(c, out);

然而,这种错误在很多复杂的表达式中却很难被发现。例如,随UNIX系统一同发布的lint程序通常带有下面的错误行:

if (((t = BTYPE(pt1->aty) == STRTY) || t == UNIONTY) {

这条语句希望给t赋一个值,然后看t是否与STRTYUNIONTY相等。而实际的效果却大不相同[3]

C中的逻辑运算符的优先级具有历史原因。B——C的前辈——具有和C中的&|运算符对应的逻辑运算符。尽管它们的定义是按位的 ,但编译器在条件判断上下文中将它们视为和&&||一样。当在C中将它们分开后,优先级的改变是很危险的[4]

2.3 看看这些分号!

C中的一个多余的分号通常会带来一点点不同:或者是一个空语句,无任何效果;或者编译器可能提出一个诊断消息,可以方便除去掉它。一个重要的区别是在必须跟有一个语句的ifwhile语句中。考虑下面的例子:

if(x[i] > big);
big = x[i];

这不会发生编译错误,但这段程序的意义与:

if(x[i] > big)
big = x[i];

就大不相同了。第一个程序段等价于:

if(x[i] > big) { }
big = x[i];

也就是等价于:

big = x[i];

(除非xibig是带有副作用的宏)。

另一个因分号引起巨大不同的地方是函数定义前面的结构声明的末尾[译注:这句话不太好听,看例子就明白了]。考虑下面的程序片段:

struct foo {
int x;
}

f() {

}

在紧挨着f的第一个}后面丢失了一个分号。它的效果是声明了一个函数f,返回值类型是struct foo,这个结构成了函数声明的一部分。如果这里出现了分号,则f将被定义为具有默认的整型返回值[5]

2.4 switch语句

通常C中的switch语句中的case段可以进入下一个。例如,考虑下面的C和Pascal程序片断:

switch(color) {
case 1: printf (“red”);
break;
case 2: printf (“yellow”);
break;
case 3: printf (“blue”);
break;
}

case color of
1: write (‘red’);
2: write (‘yellow’);
3: write (‘blue’);
end

这两个程序片断都作相同的事情:根据变量color的值是1、2还是3打印redyellowblue(没有新行符)。这两个程序片断非常相似,只有一点不同:Pascal程序中没有C中相应的break语句。C中的case标签是真正的标签:控制流程可以无限制地进入到一个case标签中。

看看另一种形式,假设C程序段看起来更像Pascal:

switch(color) {
case 1: printf (“red”);
case 2: printf (“yellow”);
case 3: printf (“blue”);
}

并且假设color的值是2。则该程序将打印yellowblue,因为控制自然地转入到下一个printf()的调用。

这既是C语言switch语句的优点又是它的弱点。说它是弱点,是因为很容易忘记一个break语句,从而导致程序出现隐晦的异常行为。说它是优点,是因为通过故意去掉break语句,可以很容易实现其他方法难以实现的控制结构。尤其是在一个大型的switch语句中,我们经常发现对一个case的处理可以简化其他一些特殊的处理。

例如,设想有一个程序是一台假想的机器的翻译器。这样的一个程序可能包含一个switch语句来处理各种操作码。在这样一台机器上,通常减法在对其第二个运算数进行变号后就变成和加法一样了。因此,最好可以写出这样的语句:

case SUBTRACT:
opnd2 = -opnd2;
/* no break; */
case ADD:

另外一个例子,考虑编译器通过跳过空白字符来查找一个记号。这里,我们将空格、制表符和新行符视为是相同的,除了新行符还要引起行计数器的增长外:

case ‘\n’:
linecount++;
/* no break */
case ‘\t’:
case ‘ ‘:

2.5 函数调用

和其他程序设计语言不同,C要求一个函数调用必须有一个参数列表,但可以没有参数。因此,如果f是一个函数,

f();

就是对该函数进行调用的语句,而

f;

什么也不做。它会作为函数地址被求值,但不会调用它[6]

2.6 悬挂else问题

在讨论任何语法缺陷时我们都不会忘记提到这个问题。尽管这一问题不是C语言所独有的,但它仍然伤害着那些有着多年经验的C程序员。

考虑下面的程序片断:

if(x == 0)
if(y == 0) error();
else {
z = x + y;
f(&z);
}

写这段程序的程序员的目的明显是将情况分为两种:x = 0x != 0。在第一种情况中,程序段什么都不做,除非y = 0时调用error()。第二种情况中,程序设置z = x + y并以z的地址作为参数调用f()

然而, 这段程序的实际效果却大为不同。其原因是一个else总是与其最近的if相关联。如果我们希望这段程序能够按照实际的情况运行,应该这样写:

if(x == 0) {
if(y == 0)
error();
else {
z = x + y;
f(&z);
}
}

换句话说,当x != 0发生时什么也不做。如果要达到第一个例子的效果,应该写:

if(x == 0) {
if(y ==0)
error();
}
else {
z = z + y;
f(&z);
}

3 链接

一个C程序可能有很多部分组成,它们被分别编译,并由一个通常称为链接器、链接编辑器或加载器的程序绑定到一起。由于编译器一次通常只能看到一个文件,因此它无法检测到需要程序的多个源文件的内容才能发现的错误。

在这一节中,我们将看到一些这种类型的错误。有一些C实现,但不是所有的,带有一个称为lint的程序来捕获这些错误。如果具有一个这样的程序,那么无论怎样地强调它的重要性都不过分。

3.1 你必须自己检查外部类型

假设你有一个C程序,被划分为两个文件。其中一个包含如下声明:

int n;

而令一个包含如下声明:

long n;


不是一个有效的C程序,因为一些外部名称在两个文件中被声明为不同的类型。然而,很多实现检测不到这个错误,因为编译器在编译其中一个文件时并不知道另一
个文件的内容。因此,检查类型的工作只能由链接器(或一些工具程序如lint)来完成;如果操作系统的链接器不能识别数据类型,C编译器也没法过多地强制
它。

那么,这个程序运行时实际会发生什么?这有很多可能性:

  1. 实现足够聪明,能够检测到类型冲突。则我们会得到一个诊断消息,说明n在两个文件中具有不同的类型。
  2. 你所使用的实现将intlong视为相同的类型。典型的情况是机器可以自然地进行32位运算。在这种情况下你的程序或许能够工作,好象你两次都将变量声明为long(或int)。但这种程序的工作纯属偶然。
  3. n的两个实例需要不同的存储,它们以某种方式共享存储区,即对其中一个的赋值对另一个也有效。这可能发生,例如,编译器可以将int安排在long的低位。不论这是基于系统的还是基于机器的,这种程序的运行同样是偶然。
  4. n的两个实例以另一种方式共享存储区,即对其中一个赋值的效果是对另一个赋以不同的值。在这种情况下,程序可能失败。

这种情况发生的里一个例子出奇地频繁。程序的某一个文件包含下面的声明:

char filename[] = “etc/passwd”;

而另一个文件包含这样的声明:

char *filename;

尽管在某些环境中数组和指针的行为非常相似,但它们是不同的。在第一个声明中,filename是一个字符数组的名字。尽管使用数组的名字可以产生数组第一个元素的指针,但这个指针只有在需要的时候才产生并且不会持续。在第二个声明中,filename是一个指针的名字。这个指针可以指向程序员让它指向的任何地方。如果程序员没有给它赋一个值,它将具有一个默认的0值(null[译注:实际上,在C中一个为初始化的指针通常具有一个随机的值,这是很危险的!]

这两个声明以不同的方式使用存储区,他们不可能共存。

避免这种类型冲突的一个方法是使用像lint这样的工具(如果可以的话)。为了在一个程序的不同编译单元之间检查类型冲突,一些程序需要一次看到其所有部分。典型的编译器无法完成,但lint可以。

避免该问题的另一种方法是将外部声明放到包含文件中。这时,一个外部对象的类型仅出现一次[7]

4 语义缺陷

一个句子可以是精确拼写的并且没有语法错误,但仍然没有意义。在这一节中,我们将会看到一些程序的写法会使得它们看起来是一个意思,但实际上是另一种完全不同的意思。

我们还要讨论一些表面上看起来合理但实际上会产生未定义结果的环境。我们这里讨论的东西并不保证能够在所有的C实现中工作。我们暂且忘记这些能够在一些实现中工作但可能不能在另一些实现中工作的东西,直到第7节讨论可以执行问题为止。

4.1 表达式求值顺序

一些C运算符以一种已知的、特定的顺序对其操作数进行求值。但另一些不能。例如,考虑下面的表达式:

a < b && c < d

C语言定义规定a < b首先被求值。如果a确实小于bc < d必须紧接着被求值以计算整个表达式的值。但如果a大于或等于b,则c < d根本不会被求值。

要对a < b求值,编译器对ab的求值就会有一个先后。但在一些机器上,它们也许是并行进行的。

C中只有四个运算符&&||?:,指定了求值顺序。&&||最先对左边的操作数进行求值,而右边的操作数只有在需要的时候才进行求值。而?:运算符中的三个操作数:abc,最先对a进行求值,之后仅对bc中的一个进行求值,这取决于a的值。,运算符首先对左边的操作数进行求值,然后抛弃它的值,对右边的操作数进行求值[8]

C中所有其它的运算符对操作数的求值顺序都是未定义的。事实上,赋值运算符不对求值顺序做出任何保证。

出于这个原因,下面这种将数组x中的前n个元素复制到数组y中的方法是不可行的:

i = 0;
while(i < n)
y[i] = x[i++];

其中的问题是y[i]的地址并不保证在i增长之前被求值。在某些实现中,这是可能的;但在另一些实现中却不可能。另一种情况出于同样的原因会失败:

i = 0;
while(i < n)
y[i++] = x[i];

而下面的代码是可以工作的:

i = 0;
while(i < n) {
y[i] = x[i];
i++;
}

当然,这可以简写为:

for(i = 0; i < n; i++)
y[i] = x[i];

4.2 &&||!运算符

C中有两种逻辑运算符,在某些情况下是可以交换的:按位运算符&|~,以及逻辑运算符&&||!。一个程序员如果用某一类运算符替换相应的另一类运算符会得到某些奇怪的效果:程序可能会正确地工作,但这纯属偶然。

&&||!运算符将它们的参数视为仅有“真”或“假”,通常约定0代表“假”而其它的任意值都代表“真”。这些运算符返回1表示“真”而返回0表示“假”,而且&&||运算符当可以通过左边的操作数确定其返回值时,就不会对右边的操作数进行求值。

因此!10是零,因为10非零;10 && 12是1,因为10和12都非零;10 || 12也是1,因为10非零。另外,最后一个表达式中的12不会被求值,10 || f()中的f()也不会被求值。

考虑下面这段用于在一个表中查找一个特定元素的程序:

i = 0;
while(i < tabsize && tab[i] != x)
i++;

这段循环背后的意思是如果i等于tabsize时循环结束,元素未被找到。否则,i包含了元素的索引。

假设这个例子中的&&不小心被替换为了&,这个循环可能仍然能够工作,但只有两种幸运的情况可以使它停下来。

首先,这两个操作都是当条件为假时返回0,当条件为真时返回1。只要xy都是1或0,x & yx && y都具有相同的值。然而,如果当使用了出了1之外的非零值表示“真”时互换了这两个运算符,这个循环将不会工作。

其次,由于数组元素不会改变,因此越过数组最后一个元素进一个位置时是无害的,循环会幸运地停下来。失误的程序会越过数组的结尾,因为&不像&&,总是会对所有的操作数进行求值。因此循环的最后一次获取tab[i]i的值已经等于tabsize了。如果tabsizetab中元素的数量, 则会取到tab中不存在的一个值。

4.3 下标从零开始

在很多语言中,具有n个元素的数组其元素的号码和它的下标是从1到n严格对应的。但在C中不是这样。

一个具有n个元素的C数组中没有下标为n的元素,其中的元素的下标是从0到n – 1。因此从其它语言转到C语言的程序员应该特别小心地使用数组:

int i, a[10];
for(i = 1; i <= 10; i++)
a[i] = 0;

这个例子的目的是要将a中的每个元素都设置为0,但没有期望的效果。因为for语句中的比较i < 10被替换成了i <= 10a中的一个编号为10的并不存在的元素被设置为了0,这样内存中a后面的一个字被破坏了。如果编译该程序的编译器按照降序地址为用户变量分配内存,则a后面就是i。将i设置为零会导致该循环陷入一个无限循环。

4.4 C并不总是转换实参

下面的程序段由于两个原因会失败:

double s;
s = sqrt(2);
printf(“%g\n”, s);

第一个原因是sqrt()需要一个double值作为它的参数,但没有得到。第二个原因是它返回一个double值但没有这样声名。改正的方法只有一个:

double s, sqrt();
s = sqrt(2.0);
printf(“%g\n”, s);

C中有两个简单的规则控制着函数参数的转换:(1)比int短的整型被转换为int;(2)比double短的浮点类型被转换为double。所有的其它值不被转换。确保函数参数类型的正确行使程序员的责任

因此,一个程序员如果想使用如sqrt()这样接受一个double类型参数的函数,就必须仅传递给它floatdouble类型的参数。常数2是一个int,因此其类型是错误的。

当一个函数的值被用在表达式中时,其值会被自动地转换为适当的类型。然而,为了完成这个自动转换,编译器必须知道该函数实际返回的类型。没有更进一步声名的函数被假设返回int,因此声名这样的函数并不是必须的。然而,sqrt()返回double,因此在成功使用它之前必须要声名。

实际上,C实现通常允许一个文件包含include语句来包含如sqrt()这些库函数的声名,但是对那些自己写函数的程序员来说,书写声名也是必要的——或者说,对那些书写非凡的C程序的人来说是有必要的。

这里有一个更加壮观的例子:

main() {
int i;
char c;
for(i = 0; i < 5; i++) {
scanf(“%d”, &c);
printf(“%d”, i);
}
printf(“\n”);
}

表面上看,这个程序从标准输入中读取五个整数并向标准输出写入0 1 2 3 4。实际上,它并不总是这么做。譬如在一些编译器中,它的输出为0 0 0 0 0 1 2 3 4。

为什么?因为c的声名是char而不是int。当你令scanf()去读取一个整数时,它需要一个指向一个整数的指针。但这里它得到的是一个字符的指针。但scanf()并不知道它没有得到它所需要的:它将输入看作是一个指向整数的指针并将一个整数存贮到那里。由于整数占用比字符更多的内存,这样做会影响到c附近的内存。

c附近确切是什么是编译器的事;在这种情况下这有可能是i的低位。因此,每当向c中读入一个值,i就被置零。当程序最后到达文件结尾时,scanf()不再尝试向c中放入新值,i才可以正常地增长,直到循环结束。

4.5 指针不是数组

C程序通常将一个字符串转换为一个以空字符结尾的字符数组。假设我们有两个这样的字符串st,并且我们想要将它们连接为一个单独的字符串r。我们通常使用库函数strcpy()strcat()来完成。下面这种明显的方法并不会工作:

char *r;
strcpy(r, s);
strcat(r, t);

这是因为r没有被 初始化为指向任何地方。尽管r可能潜在地表示某一块内存,但这并不存在,直到你分配它

让我们再试试,为r分配一些内存:

char r[100];
strcpy(r, s);
strcat(r, t);

这只有在st所指向的字符串不很大的时候才能够工作。不幸的是,C要求我们为数组指定的大小是一个常数,因此无法确定r是否足够大。然而,很多C实现带有一个叫做malloc()的库函数,它接受一个数字并分配这么多的内存。通常还有一个函数成为strlen(),可以告诉我们一个字符串中有多少个字符:因此,我们可以写:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t));
strcpy(r, s);
strcat(r, t);

然而这个例子会因为两个原因而失败。首先,malloc()可能会耗尽内存,而这个事件仅通过静静地返回一个空指针来表示。

其次,更重要的是,malloc()并没有分配足够的内存。一个字符串是以一个空字符结束的。而strlen()函数返回其字符串参数 中所包含字符的数量,但不包括结尾的空字符。因此,如果strlen(s)n,则s需要n + 1个字符来盛放它。因此我们需要为r分配额外的一个字符。再加上检查malloc()是否成功,我们得到:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t) + 1);
if(!r) {
complain();
exit(1);
}
strcpy(r, s);
strcat(r, t);

4.6 避免提喻法

提喻法(Synecdoche, sin-ECK-duh-key)是一种文学手法,有点类似于明喻或暗喻,在牛津英文词典中解释如下:“a more
comprehensive term is used for a less comprehensive or vice versa; as
whole for part or part for whole, genus for species or species for
genus, etc.(将全面的单位用作不全面的单位,或反之;如整体对局部或局部对整体、一般对特殊或特殊对一般,等等。)”

这可以精确地描述C中通常将指针误以为是其指向的数据的错误。正将常会在字符串中发生。例如:

char *p, *q;
p = “xyz”;

尽管认为p的值是xyz有时是有用的,但这并不是真的,理解这一点非常重要。p的值是指向一个有四个字符的数组中第0个元素的指针,这四个字符是‘x’‘y’‘z’‘\0′。因此,如果我们现在执行:

q = p;

pq会指向同一块内存。内存中的字符没有因为赋值而被复制。这种情况看起来是这样的:

要记住的是,复制一个指针并不能复制它所指向的东西

因此,如果之后我们执行:

q[1] = ‘Y’;

q所指向的内存包含字符串xYzp也是,因为pq指向相同的内存。

4.7 空指针不是空字符串

将一个整数转换为一个指针的结果是实现相关的(implementation-dependent),除了一个例外。这个例外是常数0,它可以保证被转换为一个与其它任何有效指针都不相等的指针。这个值通常类似这样定义:

#define NULL 0

但其效果是相同的。要记住的一个重要的事情是,当用0作为指针时它决不能被解除引用。换句话说,当你将0赋给一个指针变量后,你就不能访问它所指向的内存。不能这样写:

if(p == (char *)0) …

也不能这样写:

if(strcmp(p, (char *)0) == 0) …

因为strcmp()总是通过其参数来查看内存地址的。

如果p是一个空指针,这样写也是无效的:

printf(p);

printf(“%s”, p);

4.8 整数溢出

C语言关于整数操作的上溢或下溢定义得非常明确。

只要有一次操作数是无符号的,结果就是无符号的,并且以2n为模,其中n为字长。如果两个操作数都是带符号的,则结果是未定义的

例如,假设ab是两个非负整型变量,你希望测试a + b是否溢出。一个明显的办法是这样的:

if(a + b < 0)
complain();

通常,这是不会工作的。

一旦a + b发生了溢出,对于结果的任何赌注都是没有意义的。例如,在某些机器上,一个加法运算会将一个内部寄存器设置为四种状态:正、负、零或溢出。 在这样的机器上,编译器有权将上面的例子实现为首先将ab加在一起,然后检查内部寄存器状态是否为负。如果该运算溢出,内部寄存器将处于溢出状态,这个测试会失败。

使这个特殊的测试能够成功的一个正确的方法是依赖于无符号算术的良好定义,既要在有符号和无符号之间进行转换:

if((int)((unsigned)a + (unsigned)b) < 0)
complain();

4.9 移位运算符

两个原因会令使用移位运算符的人感到烦恼:

  1. 在右移运算中,空出的位是用0填充还是用符号位填充?
  2. 移位的数量允许使用哪些数?

第一个问题的答案很简单,但有时是实现相关的。如果要进行移位的操作数是无符号的,会移入0。如果操作数是带符号的,则实现有权决定是移入0还是移入符号位。如果在一个右移操作中你很关心空位,那么用unsigned来声明变量。这样你就有权假设空位被设置为0。

第二个问题的答案同样简单:如果待移位的数长度为n,则移位的数量必须大于等于0并且严格地小于n。因此,在一次单独的操作中不可能将所有的位从变量中移出。

例如,如果一个int是32位,且n是一个int,写n << 31n << 0是合法的,但n << 32n << -1是不合法的。

注意,即使实现将符号为移入空位,对一个带符号整数的右移运算和除以2的某次幂也不是等价的。为了证明这一点,考虑(-1) >> 1的值,这是不可能为0的。[译注:(-1) / 2的结果是0。]

5 库函数

每个有用的C程序都会用到库函数,因为没有办法把输入和输出内建到语言中去。在这一节中,我们将会看到一些广泛使用的库函数在某种情况下会出现的一些非预期行为。

5.1 getc()返回整数

考虑下面的程序:

#include <stdio.h>

main() {
char c;

while((c = getchar()) != EOF)
putchar(c);
}

这段程序看起来好像要讲标准输入复制到标准输出。实际上,它并不完全会做这些。

原因是c被声明为字符而不是整数。这意味着它将不能接收可能出现的所有字符包括EOF

因此这里有两种可能性。有时一些合法的输入字符会导致c携带和EOF相同的值,有时又会使c无法存放EOF值。在前一种情况下,程序会在文件的中间停止复制。在后一种情况下,程序会陷入一个无限循环。

实际上,还存在着第三种可能:程序会偶然地正确工作。C语言参考手册严格地定义了表达式

((c = getchar()) != EOF)

的结果。其6.1节中声明:

当一个较长的整数被转换为一个较短的整数或一个char时,它会被截去左侧;超出的位被简单地丢弃。

7.14节声明:

存在着很多赋值运算符,它们都是从右至左结合的。它们都需要一个左值作为左侧的操作数,而赋值表达式的类型就是其左侧的操作数的类型。其值就是已经付过值的左操作数的值。

这两个条款的组合效果就是必须通过丢弃getchar()的结果的高位,将其截短为字符,之后这个被截短的值再与EOF进行比较。作为这个比较的一部分,c必须被扩展为一个整数,或者采取将左侧的位用0填充,或者适当地采取符号扩展。

然而,一些编译器并没有正确地实现这个表达式。它们确实将getchar()的值的低几位赋给c。但在cEOF的比较中,它们却使用了getchar()的值!这样做的编译器会使这个事例程序看起来能够“正确地”工作。

5.2 缓冲输出和内存分配

当一个程序产生输出时,能够立即看到它有多重要?这取决于程序。

例如,终端上显示输出并要求人们坐在终端前面回答一个问题,人们能够看到输出以知道该输入什么就显得至关重要了。另一方面,如果输出到一个文件中,并最终被发送到一个行式打印机,只有所有的输出最终能够到达那里是重要的。

立即安排输出的显示通常比将其暂时保存在一大块一起输出要昂贵得多。因此,C实现通常允许程序员控制产生多少输出后在实际地写出它们。

这个控制通常约定为一个称为setbuf()的库函数。如果buf是一个具有适当大小的字符数组,则

setbuf(stdout, buf);

将告诉I/O库写入到stdout中的输出要以buf作为一个输出缓冲,并且等到buf满了或程序员直接调用fflush()再实际写出。缓冲区的合适的大小在<stdio.h>中定义为BUFSIZ

因此,下面的程序解释了通过使用setbuf()来讲标准输入复制到标准输出:

#include <stdio.h>

main() {
int c;

char buf[BUFSIZ];
setbuf(stdout, buf);

while((c = getchar()) != EOF)
putchar(c);
}

不幸的是,这个程序是错误的,因为一个细微的原因。

要知道毛病出在哪,我们需要知道缓冲区最后一次刷新是在什么时候。答案;主程序完成之后,作为库在将控制交回到操作系统之前所执行的清理的一部分。在这一时刻,缓冲区已经被释放了!

有两种方法可以避免这一问题。

首先,是用静态缓冲区,或者将其显式地声明为静态:

static char buf[BUFSIZ];

或者将整个声明移到主函数之外。

另一种可能的方法是动态地分配缓冲区并且从不释放它:

char *malloc();
setbuf(stdout, malloc(BUFSIZ));

注意在后一种情况中,不必检查malloc()的返回值,因为如果它失败了,会返回一个空指针。而setbuf()可以接受一个空指针作为其第二个参数,这将使得stdout变成非缓冲的。这会运行得很慢,但它是可以运行的。

6 预处理器

运行的程序并不是我们所写的程序:因为C预处理器首先对其进行了转换。出于两个主要原因(和很多次要原因),预处理器为我们提供了一些简化的途径。

首先,我们希望可以通过改变一个数字并重新编译程序来改变一个特殊量(如表的大小)的所有实例[9]

其次,我们可能希望定义一些东西,它们看起来象函数但没有函数调用所需的运行开销。例如,putchar()getchar()通常实现为宏以避免对每一个字符的输入输出都要进行函数调用。

6.1 宏不是函数

由于宏可以象函数那样出现,有些程序员有时就会将它们视为等价的。因此,看下面的定义:

#define max(a, b) ((a) > (b) ? (a) : (b))

注意宏体中所有的括号。它们是为了防止出现ab是带有比>优先级低的表达式的情况。

一个重要的问题是,像max()这样定义的宏每个操作数都会出现两次并且会被求值两次。因此,在这个例子中,如果ab大,则a就会被求值两次:一次是在比较的时候,而另一次是在计算max()值的时候。

这不仅是低效的,还会发生错误:

biggest = x[0];
i = 1;
while(i < n)
biggest = max(biggest, x[i++]);

max()是一个真正的函数时,这会正常地工作,但当max()是一个宏的时候会失败。譬如,假设x[0]是2、x[1]是3、x[2]是1。我们来看看在第一次循环时会发生什么。赋值语句会被扩展为:

biggest = ((biggest) > (x[i++]) ? (biggest) : (x[i++]));

首先,biggestx[i++]进行比较。由于i是1而x[1]是3,这个关系是“假”。其副作用是,i增长到2。

由于关系是“假”,x[i++]的值要赋给biggest。然而,这时的i变成2了,因此赋给biggest的值是x[2]的值,即1。

避免这些问题的方法是保证max()宏的参数没有副作用:

biggest = x[0];
for(i = 1; i < n; i++)
biggest = max(biggest, x[i]);

还有一个危险的例子是混合宏及其副作用。这是来自UNIX第八版的<stdio.h>putc()宏的定义:

#define putc(x, p) (–(p)->_cnt >= 0 ? (*(p)->_ptr++ = (x)) : _flsbuf(x, p))

putc()的第一个参数是一个要写入到文件中的字符,第二个参数是一个指向一个表示文件的内部数据结构的指针。注意第一个参数完全可以使用如*z++之类的东西,尽管它在宏中两次出现,但只会被求值一次。而第二个参数会被求值两次(在宏体中,x出现了两次,但由于 它的两次出现分别在一个:的两边,因此在putc()的一个实例中它们之中有且仅有一个被求值)。由于putc()中的文件参数可能带有副作用,这偶尔会出现问题。不过,用户手册文档中提到:“由于putc()被实现为宏,其对待stream可能会具有副作用。特别是putc(c, *f++)不能正确地工作。”但是putc(*c++, f)在这个实现中是可以工作的。

有些C实现很不小心。例如,没有人能正确处理putc(*c++, f)。另一个例子,考虑很多C库中出现的toupper()函数。它将一个小写字母转换为相应的大写字母,而其它字符不变。如果我们假设所有的小写字母和所有的大写字母都是相邻的(大小写之间可能有所差距),我们可以得到这样的函数:

toupper(c) {
if(c >= ‘a’ && c <= ‘z’)
c += ‘A’ – ‘a’;
return c;
}

在很多C实现中,为了减少比实际计算还要多的调用开销,通常将其实现为宏:

#define toupper(c) ((c) >= ‘a’ && (c) <= ‘z’ ? (c) + (‘A’ – ‘a’) : (c))

很多时候这确实比函数要快。然而,当你试着写toupper(*p++)时,会出现奇怪的结果。

另一个需要注意的地方是使用宏可能会产生巨大的表达式。例如,继续考虑max()的定义:

#define max(a, b) ((a) > (b) ? (a) : (b))

假设我们这个定义来查找abcd中的最大值。如果我们直接写:

max(a, max(b, max(c, d)))

它将被扩展为:

((a) > (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))) ?
(a) : (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))))

这出奇的庞大。我们可以通过平衡操作数来使它短一些:

max(max(a, b), max(c, d))

这会得到:

((((a) > (b) ? (a) : (b))) > (((c) > (d) ? (c) : (d))) ?
(((a) > (b) ? (a) : (b))) : (((c) > (d) ? (c) : (d))))

这看起来还是写:

biggest = a;
if(biggest < b) biggest = b;
if(biggest < c) biggest = c;
if(biggest < d) biggest = d;

比较好一些。

6.2 宏不是类型定义

宏的一个通常的用途是保证不同地方的多个事物具有相同的类型:

#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b, c;

这允许程序员可以通过只改变程序中的一行就能改变abc的类型,尽管abc可能声明在很远的不同地方。

使用这样的宏定义还有着可移植性的优势——所有的C编译器都支持它。很多C编译器并不支持另一种方法:

typedef struct foo FOOTYPE;

这将FOOTYPE定义为一个与struct foo等价的新类型。

这两种为类型命名的方法可以是等价的,但typedef更灵活一些。例如,考虑下面的例子:

#define T1 struct foo *
typedef struct foo * T2;

这两个定义使得T1T2都等价于一个struct foo的指针。但看看当我们试图在一行中声明多于一个变量的时候会发生什么:

T1 a, b;
T2 c, d;

第一个声明被扩展为:

struct foo * a, b;

这里a被定义为一个结构指针,但b被定义为一个结构(而不是指针)。相反,第二个声明中cd都被定义为指向结构的指针,因为T2的行为好像真正的类型一样。

7 可移植性缺陷

C被很多人实现并运行在很多机器上。这也正是在一个地方写的C程序应该能够很容易地转移到另一个编程环境中去的原因。

然而,由于有很多的实现者,它们并不和其他人交流。此外,不同的系统有不同的需求,因此一台机器上的C实现和另一台上的多少会有些不同。

由于很多早期的C实现都关系到UNIX操作系统,因此这些函数的性质都是专于该系统的。当一些人开始在其他系统中实现C时,他们尝试使库的行为类似于UNIX系统中的行为。

但他们并不总是能够成功。更有甚者,很多人从UNIX系统的不同版本入手,一些库函数的本质不可避免地发生分歧。今天,一个C程序员如果想写出对于不同环境中的用户都有用的程序就必须知道很多这些细微的差别。

7.1 一个名字中都有什么?

一些C编译器将一个标识符中的所有字符视为签名。而另一些在存贮标识符是会忽略一个极限之外的所有字符。C编译器产生的目标程序同将要被加载器进行处理以访问库中的子程序。加载器对于它们能够处理的名字通常应用自己的约束。

一个常见的加载器约束是所有的外部名字必须只能是大写的。面对这样的加载器约束,C实现者会强制要求所有的外部名字都是大写的。这种约束在C语言参考手册中第2.1节由所描述。

一个标识符是一个字符和数字序列,第一个字符必须是一个字母。下划线_算作字母。大写字母和小写字母是不同的。只有前八个字符是签名,但可以使用更多的字符。可以被多种汇编器和加载器使用的外部标识符,有着更多的限制:

这里,参考手册中继续给出了一些例子如有些实现要求外部标识符具有单独的大小写格式、或者少于八个字符、或者二者都有。

正因为所有这些,在一个希望可以移植的程序中小心地选择标识符是很重要的。为两个 子程序选择print_fieldsprint_float这样的名字不是个好办法。

考虑下面这个显著的函数:

char *Malloc(unsigned n) {
char *p, *malloc();
p = malloc(n);
if(p == NULL)
panic(“out of memory”);
return p;
}

这个函数是保证耗尽内存而不会导致没有检测的一个简单的办法。程序员可以通过调用Mallo()来代替malloc()。如果malloc()不幸失败,将调用panic()来显示一个恰当的错误消息并终止程序。

然而,考虑当该函数用于一个忽略大小写区别的系统中时会发生什么。这时,名字mallocMalloc是等价的。换句话说,库函数malloc()被上面的Malloc()函数完全取代了,当调用malloc()时它调用的是它自己。显然,其结果就是第一次尝试分配内存就会陷入一个递归循环并随之发生混乱。但在一些能够区分大小写的实现中这个函数还是可以工作的。

7.2 一个整数有多大?

C为程序员提供三种整数尺寸:普通、短和长,还有字符,其行为像一个很小的整数。C语言定义对各种整数的大小不作任何保证:

  1. 整数的四种尺寸是非递减的。
  2. 普通整数的大小要足够存放任意的数组下标。
  3. 字符的大小应该体现特定硬件的本质。

许多现代机器具有8位字符,不过还有一些具有7位获9位字符。因此字符通常是7、8或9位。

长整数通常至少32位,因此一个长整数可以用于表示文件的大小。

普通整数通常至少16位,因为太小的整数会更多地限制一个数组的最大大小。

短整数总是恰好16位。

在实践中这些都意味着什么?最重要的一点就是别指望能够使用任何一个特定的精度。非正式情况下你可以假设一个短整数或一个普通整数是16位的,而一个长整
数是32位的,但并不保证总是会有这些大小。你当然可以用普通整数来压缩表大小和下标,但当一个变量必须存放一个一千万的数字的时候呢?

一种更可移植的做法是定义一个“新的”类型:

typedef long tenmil;

现在你就可以使用这个类型来声明一个变量并知道它的宽度了,最坏的情况下,你也只要改变这个单独的类型定义就可以使所有这些变量具有正确的类型。

7.3 字符是带符号的还是无符号的?

很多现代计算机支持8位字符,因此很多现代C编译器将字符实现为8位整数。然而,并不是所有的编译器都按照同将的方式解释这些8位数。

这些问题在将一个char制转换为一个更大的整数时变得尤为重要。对于相反的转换,其结果却是定义良好的:多余的位被简单地丢弃掉。但一个编译器将一个char转换为一个int却需要作出选择:将char视为带符号量还是无符号量?如果是前者,将char扩展为int时要复制符号位;如果是后者,则要将多余的位用0填充。

这个决定的结果对于那些在处理字符时习惯将高位置1的人来说非常重要。这决定着8位的字符范围是从-128到127还是从0到255。这又影响着程序员对哈希表和转换表之类的东西的设计。

如果你关心一个字符值最高位置一时是否被视为一个负数,你应该显式地将它声明为unsigned char。这样就能保证在转换为整数时是基0的,而不像普通char变量那样在一些实现中是带符号的而在另一些实现中是无符号的。

另外,还有一种误解是认为当c是一个字符变量时,可以通过写(unsigned)c来得到与c等价的无符号整数。这是错误的,因为一个char值在进行任何操作(包括转换)之前转换为int。这时c会首先转换为一个带符号整数在转换为一个无符号整数,这会产生奇怪的结果。

正确的方法是写(unsigned char)c

7.4 右移位是带符号的还是无符号的?

这里再一次重复:一个关心右移操作如何进行的程序最好将所有待移位的量声明为无符号的。

7.5 除法如何舍入?

假设我们用ba得到商为q余数为r

q = a / b;
r = a % b;

我们暂时假设b > 0

我们期望abqr之间有什么关联?

  1. 最重要的,我们期望q * b + r == a,因为这是对余数的定义。
  2. 如果a的符号发生改变,我们期望q的符号也发生改变,但绝对值不变。
  3. 我们希望保证r >= 0r < b。例如,如果余数将作为一个哈希表的索引,它必须要保证总是一个有效的索引。

这三点清楚地描述了整数除法和求余操作。不幸的是,它们不能同时为真

考虑3 / 2,商1余0。这满足第一点。而-3 / 2的值呢?根据第二点,商应该是-1,但如果是这样的话,余数必须也是-1,这违反了第三点。或者,我们可以通过将余数标记为1来满足第三点,但这时根据第一点商应该是-2。这又违反了第二点。

因此C和其他任何实现了整数除法舍入的语言必须放弃上述三个原则中的至少一个。

很多程序设计语言放弃了第三点,要求余数的符号必须和被除数相同。这可以保证第一点和第二点。很多C实现也是这样做的。

然而,C语言的定义只保证了第一点和|r| < |b|以及当a >= 0b > 0r >= 0。 这比第二点或第三点的限制要小, 实际上有些编译器满足第二点或第三点,但不太常见(如一个实现可能总是向着距离0最远的方向进行舍入)。

尽管有些时候不需要灵活性,C语言还是足够可以让我们令除法完成我们所要做的、提供我们所想知道的。例如,假设我们有一个数n表示一个标识符中的字符的一些函数,并且我们想通过除法得到一个哈希表入口h,其中0 <= h <= HASHSIZE。如果我们知道n是非负的,我们可以简单地写:

h = n % HASHSIZE;

然而,如果n有可能是负的,这样写就不好了,因为h可能也是负的。然而,我们知道h > -HASHSIZE,因此我们可以写:

h = n % HASHSIZE;
if(n < 0)
h += HASHSIZE;

同样,将n声明为unsigned也可以。

7.6 一个随机数有多大?

这个尺寸是模糊的,还受库设计的影响。在PDP-11[10]机器上运行的仅有的C实现中,有一个称为rand()的函数可以返回一个(伪)随机非负整数。PDP-11中整数长度包括符号位是16位,因此rand()返回一个0到215-1之间的整数。

当C在VAX-11上实现时,整数的长度变为32位长。那么VAX-11上的rand()函数返回值范围是什么呢?

对于这个系统,加利福尼亚大学的人认为rand()的返回值应该涵盖所有可能的非负整数,因此它们的rand()版本返回一个0到231-1之间的整数。

而AT&T的人则觉得如果rand()函数仍然返回一个0到215之间的值 则可以很容易地将PDP-11中期望rand()能够返回一个小于215的值的程序移植到VAX-11上。

因此,现在还很难写出不依赖实现而调用rand()函数的程序。

7.7 大小写转换

toupper()tolower()函数有着类似的历史。他们最初都被实现为宏:

#define toupper(c) ((c) + ‘A’ – ‘a’)
#define tolower(c) ((c) + ‘A’ – ‘a’)

当给定一个小写字母作为输入时,toupper()将产生相应的大写字母。tolower()反之。这两个宏都依赖于实现的字符集,它们需要所有的大写字母和对应的小写字母之间的差别都是常数的。这个假设对于ASCII和EBCDIC字符集来说都是有效的,可能不是很危险,因为这些不可移植的宏定义可以被封装到一个单独的文件中并包含它们。

这些宏确实有一个缺陷,即:当给定的东西不是一个恰当的字符,它会返回垃圾。因此,下面这个通过使用这些宏来将一个文件转为小写的程序是无法工作的:

int c;
while((c = getchar()) != EOF)
putchar(tolower(c));

我们必须写:

int c;
while((c = getchar()) != EOF)
putchar(isupper(c) ? tolower(c) : c);

就这一点,AT&T中的UNIX开发组织提醒我们,toupper()tolower()都是事先经过一些适当的参数进行测试的。考虑这样重写这些宏:

#define toupper(c) ((c) >= ‘a’ && (c) <= ‘z’ ? (c) + ‘A’ – ‘a’ : (c))
#define tolower(c) ((c) >= ‘A’ && (c) <= ‘Z’ ? (c) + ‘a’ – ‘A’ : (c))

但要知道,这里c的三次出现都要被求值,这会破坏如toupper(*p++)这样的表达式。因此,可以考虑将toupper()tolower()重写为函数。toupper()看起来可能像这样:

int toupper(int c) {
if(c >= ‘a’ && c <= ‘z’)
return c + ‘A’ – ‘a’;
return c;
}

tolower()类似。

这个改变带来更多的问题,每次使用这些函数的时候都会引入函数调用开销。我们的英雄认为一些人可能不愿意支付这些开销,因此他们将这个宏重命名为:

#define _toupper(c) ((c) + ‘A’ – ‘a’)
#define _tolower(c) ((c) + ‘a’ – ‘A’)

这就允许用户选择方便或速度。

这里面其实只有一个问题:伯克利的人们和其他的C实现者并没有跟着这么做。 这意味着一个在AT&T系统上编写的使用了toupper()tolower()的程序,如果没有为其传递正确大小写字母参数,在其他C实现中可能不会正常工作。

如果不知道这些历史,可能很难对这类错误进行跟踪。

7.8 先释放,再重新分配

很多C实现为用户提供了三个内存分配函数:malloc()realloc()free()。调用malloc(n)返回一个指向有n个字符的新分配的内存的指针,这个指针可以由程序员使用。给free()传递一个指向由malloc()分配的内存的指针可以使这块内存得以重用。通过一个指向已分配区域的指针和一个新的大小调用realloc()可以将这块内存扩大或缩小到新尺寸,这个过程中可能要复制内存。

也许有人会想,真相真是有点微妙啊。下面是System V接口定义中出现的对realloc()的描述:

realloc改变一个由ptr指向的size个字节的块,并返回该块(可能被移动)的指针。 在新旧尺寸中比较小的一个尺寸之下的内容不会被改变。

而UNIX系统第七版的参考手册中包含了这一段的副本。此外,还包含了描述realloc()的另外一段:

如果在最后一次调用mallocrealloccalloc后释放了ptr所指向的块,realloc依旧可以工作;因此,freemallocrealloc的顺序可以利用malloc压缩存贮的查找策略。

因此,下面的代码片段在UNIX第七版中是合法的:

free (p);
p = realloc(p, newsize);

这一特性保留在从UNIX第七版衍生出来的系统中:可以先释放一块存储区域,然后再重新分配它。这意味着,在这些系统中释放的内存中的内容在下一次内存分配之前可以保证不变。因此,在这些系统中,我们可以用下面这种奇特的思想来释放一个链表中的所有元素:

for(p = head; p != NULL; p = p->next)
free((char *)p);

而不用担心调用free()会导致p->next不可用。

不用说,这种技术是不推荐的,因为不是所有C实现都能在内存被释放后将它的内容保留足够长的时间。然而,第七版的手册遗留了一个未声明的问题:realloc()的原始实现实际上是必须要先释放再重新分配的。出于这个原因,一些C程序都是先释放内存再重新分配的,而当这些程序移植到其他实现中时就会出现问题。

7.9 可移植性问题的一个实例

让我们来看一个已经被很多人在很多时候解决了的问题。下面的程序带有两个参数:一个长整数和一个函数(的指针)。它将整数转换位十进制数,并用代表其中每一个数字的字符来调用给定的函数。

void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)(‘-’);
n = -n;
}
if(n >= 10)
printnum(n / 10, p);
(*p)(n % 10 + ‘0′);
}

这个程序非常简单。首先检查n是否为负数;如果是,则打印一个符号并将n变为正数。接下来,测试是否n >= 10。如果是,则它的十进制表示中包含两个或更多个数字,因此我们递归地调用printnum()来打印除最后一个数字外的所有数字。最后,我们打印最后一个数字。

这个程序——由于它的简单——具有很多可移植性问题。首先是将n的低位数字转换成字符形式的方法。用n % 10来获取低位数字的值是好的,但为它加上‘0′来获得相应的字符表示就不好了。这个加法假设机器中顺序的数字所对应的字符数顺序的,没有间隔,因此‘0′ + 5‘5′的值是相同的,等等。尽管这个假设对于ASCII和EBCDIC字符集是成立的,但对于其他一些机器可能不成立。避免这个问题的方法是使用一个表:

void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)(‘-’);
n = -n;
}
if(n >= 10)
printnum(n / 10, p);
(*p)(“0123456789″[n % 10]);
}

另一个问题发生在当n < 0时。这时程序会打印一个负号并将n设置为-n。这个赋值会发生溢出,因为在使用2的补码的机器上通常能够表示的负数比正数要多。例如,一个(长)整数有k位和一个附加位表示符号,则-2k可以表示而2k却不能。

解决这一问题有很多方法。最直观的一种是将n赋给一个unsigned long值。然而,一些C便一起可能没有实现unsigned long,因此我们来看看没有它怎么办。

在第一个实现和第二个实现的机器上,改变一个正整数的符号保证不会发生溢出。问题仅出在改变一个负数的符号时。因此,我们可以通过避免将n变为正数来避免这个问题。

当然,一旦我们打印了负数的符号,我们就能够将负数和正数视为是一样的。下面的方法就强制在打印符号之后n为负数,并且用负数值完成我们所有的算法。如果我们这么做,我们就必须保证程序中打印符号的部分只执行一次;一个简单的方法是将这个程序划分为两个函数:

void printnum(long n, void (*p)()) {
if(n < 0) {
(*p)(‘-’);
printneg(n, p);
}
else
printneg(-n, p);
}

void printneg(long n, void (*p)()) {
if(n <= -10)
printneg(n / 10, p);
(*p)(“0123456789″[-(n % 10)]);
}

printnum()现在只检查要打印的数是否为负数;如果是的话则打印一个符号。否则,它以n的负绝对值来调用printneg()。我们同时改变了printneg()的函数体来适应n永远是负数或零这一事实。

我们得到什么?我们使用n / 10n % 10来获取n的前导数字和结尾数字(经过适当的符号变换)。调用整数除法的行为在其中一个操作数为负的时候是实现相关的。因此,n % 10有可能是正的!这时,-(n % 10)是正数,将会超出我们的数字字符数组的末尾。

为了解决这一问题,我们建立两个临时变量来存放商和余数。作完除法后,我们检查余数是否在正确的范围内,如果不是的话则调整这两个变量。printnum()没有改变,因此我们只列出printneg()

void printneg(long n, void (*p)()) {
long q;
int r;
if(r > 0) {
r -= 10;
q++;
}
if(n <= -10) {
printneg(q, p);
}
(*p)(“0123456789″[-r]);
}

8 这里是空闲空间

还有很多可能让C程序员误入迷途的地方本文没有提到。如果你发现了,请联系作者。在以后的版本中它会被包含进来,并添加一个表示感谢的脚注。

参考

《The C Programming Language》(Kernighan and Ritchie, Prentice-Hall
1978)是最具权威的C著作。它包含了一个优秀的教程,面向那些熟悉其他高级语言程序设计的人,和一个参考手册,简洁地描述了整个语言。尽管自1978
年以来这门语言发生了不少变化,这本书对于很多主题来说仍然是个定论。这本书同时还包含了本文中多次提到的“C语言参考手册”。

《The C Puzzle Book》(Feuer, Prentice-Hall, 1982)是一本少见的磨炼人们文法能力的书。这本书收集了很多谜题(和答案),它们的解决方法能够测试读者对于C语言精妙之处的知识。

《C: A Referenct Manual》(Harbison and Steele, Prentice Hall 1984)是特意为实现者编写的一本参考资料。其他人也会发现它是特别有用的——因为他能从中参考细节。


脚注

1. 这本书是基于图书《C Traps and Pitfalls》(Addison-Wesley, 1989, ISBN 0-201-17928-8)的一个扩充,有兴趣的读者可以读一读它。
2. 因为!=的结果不是1就是0。
3. 感谢Guy Harris为我指出这个问题。
4. Dennis Ritchie和Steve Johnson同时向我指出了这个问题。
5. 感谢一位不知名的志愿者提出这个问题。
6. 感谢Richard Stevens指出了这个问题。
7. 一些C编译器要求每个外部对象仅有一个定义,但可以有多个声明。使用这样的编译器时,我们何以很容易地将一个声明放到一个包含文件中,并将其定义放到其它地方。这意味着每个外部对象的类型将出现两次,但这比出现多于两次要好。
8. 分离函数参数用的逗号不是逗号运算符。例如在f(x, y)中,x和y的获取顺序是未定义的,但在g((x, y))中不是这样的。其中g只有一个参数。它的值是通过对x进行求值、抛弃这个值、再对y进行求值来确定的。
9. 预处理器还可以很容易地组织这样的显式常量以能够方便地找到它们。
10. PDP-11和VAX-11是数组设备集团(DEC)的商标。

2005年01月25日

看技术类的Blog,发现有些文章很好,以为原创,顿时佩服,google一下,又一堆,比较一下时间,新旧分明,再看Blog,确实没有注明是摘抄,也没有引用原来之页面,这样,总觉得不是很好,有失原则。
引用一下原创:MISRA–作为工业标准的C编程规范
只是对于其中Rule24和25没有领会 。

2005年01月21日

书写是形式还是目的?
昨天晚上想看看我在blogger上3年前注册的帐号还能不能激活,判断一下google对用户的策略,登陆主站发现竟然是中文界面,欣喜,然后醒目的就是中文的

AdSense 邀请

大意就是如果你的blog访问量比较大,可以做广告,然后可以换来收入。以前想过blog有啥用,人家说可以颠覆现在的新闻形式,现在问我blog有啥用,我可以说了,能挣钱。
利是建立在名之上的。写自己想的,感兴趣的人多了,还可以以此为生,但是怎么避免为了挣钱而写呢?

2005年01月18日

对于blog,看得多,写的少,所以以前就选择了bloglines,越来越慢,topim来了,看起来也还凑活。前两天机器崩溃,重装了firefox,重新搜索了一下插件,发现了sage,用了一下,感觉不错。能够导入导出,能够添加删除,还能和书签一起管理,简单而又干净,我喜欢。

2005年01月14日

这几天感觉gmail下蛋颇为频繁,google或许觉得gmail应该出正式版了。
先到先得。

2004年12月29日

其实这是个系列,我有个朋友要学Linux,而我偏偏会那么一点,我就大言不惭的声称要做老师。为了拿到红旗的认证,小伙子很认真,拿到一套套的题目做了让我评判,我就把我们的mail合并到一起了。

关于Linux的shell说法错误的是: B
A. 一个命令语言解释器
B. 编译型的程序设计语言
C. 能执行内部命令
D. 能执行外部命令

2. at命令中需要从标准输入中读入所有 的命令,不能指定_______选项。
A. -q
B. -b
C. -d
D. -f

3. 目录的可执行意味着: C
A. 目录下建立文件
B. 从该目录中删除文件
C. 可以从一个目录转到另一个目录
D. 可以查看该目录下的文件

4. 假设当前目录下有文件Makefile,下面是其内容:
pr1: prog.o subr.o
gcc ?o pr1 prog.o subr.o
prog.o: prog.c prog.h
gcc ?c ?l prog.o prog.c
subr.o: subr.c
gcc ?c ?o subr.o subr.c
clear:
rm ?f pr1*.0
现在执行命令%make clear 实际执行的命令是:
A. gcc ?o pr1 prog.o subr.o
B. gcc ?c ?l prog.o prog.c
C. gcc ?c ?o subr.o subr.c
D. rm ?f pr1*.0

5. 在tcsh中按_____A_____可以列出所有匹配的情况。
A. TAB
B. SHIFT
C. ^D
D. ALT

6. 在compress命令中,表示将压缩结果标准输出的命令是:
A. -f
B. -v
C. -c
D. -V

7. 用下列命令查看Linux使用了多少内存 A
A. cat /proc/meminfo
B. cat /bin/meminfo
C. vi /proc/meminfo
D. vi /user/local/meminfo

8. mesg n 表示: C
A. 拒绝他人用talk或write与你的终端通信
B. 不拒绝他人用talk或write与你的终端通信
C. 拒绝接收受邮件
D. 允许接收和发送邮件

9. 假设你有两个文件分别记录了两个不同的课程中登记的学生名字

,sub1,sub2。下面哪个命令能显示那些选了两们课程的学生? B
A. cat sub1 sub2 | uniq ?d
B. sort sub1 sub2 | uniq ?u
C. cat sub1 sub2|uniq ?du
D. sort sub1 sub2|uniq ?du

10. 命令gzip-dmyfile.tar.gz               A
A. 解压缩的使用方法
B. 在执行过解压缩的处理后,保留压缩文件
C. 不会额外占用磁盘空间
D. 在执行完此命令后有myfile文件

11. 用户口令文件存放在: A
A. /etc/passwd
B. /etc/hosts
C. /etc/user
D. /etc/mnt

12. ls显示不同类型的文件可以用不同的颜色区别,用户可以通过修改文件___A____实现。
A. /etc/DIR_COLORS
B. /etc/X11R6
C. /etc/NETWORKING
D. /etc/host.conf

13. 下面的哪一个shell具有自动补齐功能?
A. csh
B. tcsh
C. bash
D. sh

14. 合并数据列可以用命令: D
A. paste
B. look
C. cat
D. sort

15. crontab文件的格式是:  D
A. M D H m d cmd
B. M H D d m cmd
C. D M H m d cmd
D. M H D m d cmd

16. 在date命令中,日期域中的%d表示什么: A
A. 每个月的第几天(01。。31)
B. 日期
C. 月名
D. 月份

17. 在less命令中,-e 的意思是:
A. 当less第一次到达文件末尾时退出
B. 当less第二次到达文件末尾时退出
C. 当less未到达文件末尾时退出
D. 当less第一次到达文件末尾时退出

18. 我们可以使用 __A_ 来确定文件的类型。
A. file
B. which
C. man
D. locate

19. 超级用户(root)用___B__作默认的提示符。
A. $
B. #
C. ?
D. !

20. 删除整行文本的指令是___c____
A. d
B. yy
C. dd
D. q

21. 正则常规表达式中,表示“按字面上对待下面的字符”的符号是:
A. *
B. 、
C. $
D. @

22. shell自动化初始的变量有:
A. argv
B. cdpath
C. cwd
D. shell

23. 改变缺省shell的方法是:
A. chsh
B. csh
C. sh
D. 直接修改/etc/passwd文件

24. 红旗Linux的中文环境解决了: C
A. shell方式下的中文环境
B. 汉化的中文环境KDE
C. XWindow中的中文显示
D. XWindow中的中文打印

25. 用who命令可以查看用户的:   A
A. 用户名
B. 终端名
C. 注册的时间和日期
D. 用户口令

     

回复:

 2.没有填是不应该的,所有题目都是开卷,我们的目标不是说会做题目,而是按照题目考查的知识点学会这方面的知识,不要为了考试而考试。这句话我不再说了。所以这道题目你应该自己查帮助或者man或者种种方式来知道怎么填,并尽可能记住。
        3.这个题目不错,我也需要试了才能确认,我目前会选择B。你可以自己建立一个目录,然后,在这个目录下建立几个文件,然后修改一下目录的属性,看看去掉目录的执行属性后,看看对文件的哪些操作不能做了,然后得出结论,并把这个结论记录下来。这样有助于你的学习能力的提高。你应该修改一下你的学习方法和学习习惯。
        4.这个题目选D,对你来说有点儿偏,一个程序员需要掌握的,但是你应该机灵点,猜,如果真的是考试,这道题目很容易猜到答案的。
        5.我不知道,从来没有用过tcsh
        6.跟第二题一样,关于命令的题目你不会,你空着给我不合适吧,自己查查,然后把这个命令掌握了嘛。
        8.我也不知道。
        9.你答对了这道题目,但是对于uniq和sort这两个命令你一定要很好的掌握,很多熟悉的系统管理员都不知道这两个命令,你知道了有助于你在shell编程和工作中显得突出。

        14.答错了,paste命令和上面的两个命令在处理文档时非常有用。特别是处理log文件时,系统管理员很多时候都是在处理log文件。
        15,16,17.都是需要查帮助才能知道的,这几个命令我也用的不是很熟,而且crontab每次用的时候我都要查帮助,唉,惭愧,为了考试,你最好把他们记下来。less -e很少用到。date的格式也是写shell时常用的,因为你需要对于log文件命名并保存,和crontab 结合在一起用。
        20.你能对这道题目的其他几个命令的功能做个说明吗?或者说你的vi熟练程度你认为怎么样?
        21.实说正则表达式中哪个字符没有特殊含义,比如”*”肯定是有的,查查书,对于正则表达式有没有详细的解释,如果没有,就去看看grep的帮助。
        22.这个题目跟环境变量相关,答案不中要,自己学会了就知道怎么找答案了。看环境变量我知道有两种方法:env和set,与此相关的是如果你set了一个环境变量的值,你还要用export命令让他生效。比如你把一个路径加到环境变量PATH里头试试。
        23.选D,说明你对/etc/passwd中各个项都还不是很清楚啊,也能说明了解系统启动的过程很重要。
        24.不知道
        25.自己看,除了who这个命令,还有哪些命令可以查看跟当前用户以及用户登陆相关的信息,你知道吗?我现在能想起来的除了w命令外,好像没有了,用last可以查看各个用户最后登陆的时间,看看/var/log/下的一个log文件也可以看到。这又让我想起日志文件的重要性了,你有机会看看各个日志文件都是做什么用的,并且知道dmesg命令,也能知道有一个工具叫做logrotate,是用来循环备份日志用的。

2004年10月22日

刚来公司的时候我们的人事经理说你们每天工作时间可能只有四个小时,所以希望你们在这四个小时里能注意里集中……当时觉得我怎么可能每天只工作四个小时呢,那我们的工作效率该多低啊,以前的台湾公司可是那种著名的”女生当男生使,男生当畜生使”的公司.
现在我来公司半年多了…..
昨天,前天有个活儿干了半天都没搞定,昨天任务比较急,我暗下决心一定要解决问题.我算了一下我的工作时间,早晨刚来是要看新闻和blog的,无可非议, 我的生活习惯嘛…在9:40左右听到一个同事想看看我的网络小说的收集工具,给她演示了一下,又听从另一个同事的建议,说银河英雄传比较好看,下载 了,并且拿我的工具收藏了,但是那个工具很不好使,排序让我搞了将近半个钟头,郁闷…顺手把qq/msn打开,又跟mm聊天一会儿,再一看11:20 了,算了,看代码也没啥意思了,我们11:30吃饭.
中午12:15到13:00照例是网络小说时间,现在已经学会了,休息时间休息是很浪费时间的.想起了一个笑话:一个同事很痛苦的告诉我说,最近下午三点老是失眠,唉,都是工作压力太大了.
下午一定要好好工作,解决问题,果然决心很大,从13:20一直到15:45都在工作,其间只抽了两根烟,吃了一个苹果……有点儿困了,问题还是 没有搞定,用lp的话说,我是差生啊.眯一会儿,16:20,lp又说要图片,传好了,qq咋这么慢,顺便研究了一下qq文件共享的几种方法,想起来我还 有263邮箱,可以从网络文件夹里头弄,得意,收费的还是有好处的.又想起来昨天刚刚搞到的google_mail,不知道可不可以发很大附件的 Mail,最后还是决定用网络硬盘,搜了一下,哦,qq的竞争者UC也有网络硬盘,下载注册,一看竟然有32M之大,好东西,每天还可以发送免费短消息 15条,帅气.差点想写一个IM软件功能比较…..为我有这种冲动得意了一下,抽根烟去.
回来一看,考,竟然17:40了,唉,20分钟肯定是不能解决这个问题了,明儿再说吧….

总结了一下,想起来前几天刚刚看过的<<一个老程序员的一天>>,我还不老,我还有机会,我是不会放弃的…..

2004年10月14日

今天收到Donews的mail让我更新我的Blog,其实早在三年前我闲来无事的时候就知道了Blog,但是没有学会用也没有体会到其精妙, Blogger上的帐号倒是还在,荒芜许久了……现在忙起来了,没有那么多时间闲逛,更不用说写了,这个Blog就权当技术帖子的收藏夹了.说起 收藏夹,我有一个工具现在改名叫CyberAritcle了,收藏网络小说和技术文档都非常的好用,推荐一下.