C语言易错点汇总(二)

三、关键字

       我们都知道 ANSI C 标准中 C 语言共有 32 个关键字。后面 ISO 的 C99 标准和 C11 标准又分别增加了 5 个和 7 个关键字。本文无意介绍各个标准之间的恩恩怨怨,感兴趣的可以上网查查,包括后面新增的这 12 个关键字。本节将集中介绍 ANSI C 标准中的 32 个关键字,不过有些简单或是出现频率较低的关键字将略掉不提。先来和这个 32 个关键字打个照面:

autobreakcasecharconstcontinue

      default 

do
doubleelseenumextern

        float

forgotoif
intlongregisterreturnshortsignedsizeofstatic
structswitchtypedefunionunsigned

        void

volatile

       while 

    《C语言深度剖析》一书中作者对每一个关键字都进行了非常详细的剖析,尤其是那些容易出错的关键字。本文中部分例子也引用自此书,想了解更多关于此书中关键字一章的内容,可以上网搜该书的电子版。

3.1 if、else

3.1.1 if内的比较问题

       关于 if 内的比较表达式代码风格争论已久,比较流行的风格会要求如下:

bool in;
if (in) / if (!in)

int x;
if (x == 0) / if (x != 0)

int *ptr;
if (ptr == NULL) / if (ptr != NULL)

这么做完全合法,而且也非常清晰。但是阅读过Linux内核代码的人会发现,类似

bool in;
if (in) / if (!in)

int x;
if (x) / if (!x)

int *ptr;
if (ptr) / if (!ptr)

这样的简写形式比比皆是,因为内核代码风格一直秉承简洁之道。我们无意去争论谁优谁劣,但是正如 C 语言设计之初秉承的理念一样,程序员对代码唯一负责。采样哪种形式都可以,但请确保代码行为和你的预期一致。后面的风格只区分 0 和非 0,任何非 0 的值都为真。

3.1.2 else配对问题

if (a == 0)
    if (b > 1)
        printf("error\n");
else {
    b = 0;
}

       上面的代码不仅仅是初学者容易犯,即便是多年经验的 C 老手也有在此失误过的。else 和最近的 if 匹配,上例中的 else 实际上要和 if (b > 1) 这个 if 配对:

if (a == 0) {
    if (b > 1)
        printf("error\n");
    else {
        b = 0;
    }
}

       良好的代码风格是完全可以避免这样的问题的。Linux 内核开发者制定了一套编码风格,对代码格式和布局做出了规定。当然并不是说内核代码风格有多么优秀或者其他的代码风格有多拙劣。保持一致的编码风格是为了提高编程效率。我们都知道,内核代码是由全世界许许多多的程序员一起开发完成的,如果每个人的代码风格都迥异,那么不管是对于代码的维护还是阅读,都将是十分不便,所以保持一个统一的代码风格就显得格外必要。如果你是一个在 Linux 内核下面开发的程序员,那么了解这种内核代码风格是十分必要的。假如某一天你有幸成为一名内核代码贡献者,代码风格不好,你的补丁都会提交不上去,搞不好 Linus 大哥哥还会向你爆粗口。关于内核代码风格,可以参阅《Linux内核设计与实现》一书的第20章,作者对这个话题做了非常详细的举例说明,以及一些关于补丁和代码风格的实用工具。另外内核源码树 Documentation/CodingStyle 中也有 Linus 大哥哥对我们的谆谆教诲。

3.2 switch、case

       基本格式如下:

switch (branch) {
case A:
    /* do A thing */
    break;
case B:
    /* do B thing */
    break;
case C:
    /* do C thing */
    /* and then fall through */
case D:
    /* do D thing */
    break;
default:
    break;
}

注意点:

1、每个 case 结束后一定要加 break,否则会导致分支代码重叠,除非你是有意这么为之。即便如此,也建议显式地标明你就是要这么做,就像 case C 的处理。

2、default 分支最好加上,并且是处理那些真正的默认情况。这样做并非多此一举,可以避免让人误以为你忘了 default 处理。

3、case 后面只能是整型或字符型的常量或常量表达式,其实字符型常量本质上也是一个整数。

3.3 do、while、for

       C 语言一共有三种循环语句:

while () {

};

do {

} while();

for (;;) {

}

3.3.1 do-while循环 

       在 Linux 内核中,经常会碰到 do () {} while (0); 这样的语句,而且多数情况以宏定义的形式出现。有人认为既然是 while (0),那么就只执行一次吗?那加不加这个 do () {} while (0) 效果不都是一样的吗?其实这是内核中的一个编程技巧。先来看一个实例:

#define DUMP_WRITE(addr,nr)   do { memcpy(bufp, addr, nr); bufp += nr; } while(0)

do-while 循环是先执行循环体然后再来判断循环条件,while (0) 说明循环体会且只会被执行一次。既然如此,那我可不可以这样定义呢?

#define DUMP_WRITE(addr,nr)   memcpy(bufp, addr, nr); bufp += nr;

定义本身没有问题,但是如果用在代码上下文中情况就不一样了,比如:

if (addr)
    DUMP_WRITE(addr, nr);
else
    pr_err("pointer address is NULL!\n");

经过预处理以后就变成了这样:

if (addr)
    memcpy(bufp, addr, nr); bufp += nr;;
else
    pr_err("pointer address is NULL!\n");

注意到问题了吗?if 和 else之间插入了 bufp += nr; 这条语句!这样 else 因为找不到与之配对的 if 语句而导致编译报错。有人可能很快就会想到加花括号,变成如下这样:

#define DUMP_WRITE(addr,nr) { memcpy(bufp, addr, nr); bufp += nr; }

可惜展开以后还是报错:

if (addr)
    { memcpy(bufp, addr, nr); bufp += nr; };
else
    pr_err("pointer address is NULL!\n");

花括号后面的分号依旧把 else 和 if 分开了。如果用 do-while 循环则可以完美地避免上述问题:

if (addr)
    do { memcpy(bufp,addr,nr); bufp += nr; } while (0);
else
    pr_err("pointer addr is NULL!\n");

3.3.2 for循环

       for 循环一般用于循环次数已知的情形,当然也不绝对,内核源码中像

for (; ;) {
    /* do something */
    ......
}

这样的处理也是存在的。

注意点:

1、循环次数最好是采用 for (i = 0; i < 10; i++) 这样左闭右开的形式,由于循环次数计算错误导致的 bug 真的是痛心疾首,所以关于次数的问题最好是谨慎些。《C陷阱与缺陷》中作者用了很长的篇幅专门来说明这种边界计算的问题。

2、千万不要在循环体内修改循环变量,防止循环失控。

3、循环嵌套最好控制在 3 层 以内,否则建议重构你的代码。当循环嵌套超过 3 层,程序员对循环的理解能力会极大的降低。

4、break 是结束本层循环,如果循环只有一层,那么直接跳出该循环。如果循环有多层,那么跳出到上层循环。continue 是结束本次循环,进入下次循环,注意仍然在本层循环。

3.4 goto

       这是一个颇受争议的关键字,有人主张禁用,但是我认为过于极端了。滥用 goto 语句确实会造成程序结构混乱,造成一些难以发现的 bug,但是好的 goto 用法能让代码处理变得更高效。Linux内核中 goto 的使用非常广泛,不过用得最多的情形是在处理异常错误的时候,类似代码如下:

static int xxx_probe(xxx)
{
    ...
    if (!ptr1)
        goto err_ptr1;
    ...
    if (!ptr2)
        goto err_ptr2;
    ...
    if (!ptr3)
        goto err_ptr3;
    ...

    return 0;

err_ptr3:
    ...;
err_ptr2:
    ...;
err_ptr1:
    ...;
    return -1;
}

这种 goto 的错误处理用法简单而高效,只需要保证错误处理时注销、资源释放等与注册、资源申请时顺序相反,请仔细观察三个错误标签的代码顺序。

       争论应不应该禁用没有太大意义,我们真正要搞清楚的是 goto 的用法。

3.5 sizeof

       很多人一直以为 sizeof 是一个函数,因为它后面跟了一对圆括号。先看看下面这个例子:

int i = 0;
A、sizeof(int); B、sizeof(i); C、sizeof int; D、sizeof i;

毫无疑问,32 位系统下 A 和 B 的值为 4。那 C 呢?D 呢?在 32 位系统下,我们发现 D 的结果也为 4。sizeof 后面没有括号居然也行!那函数名后面没有括号行吗?由此可知 sizeof 绝非函数。那么 C 呢?通过测试,我们发现编译器报错了。不是说 sizeof 是个关键字,其后面的括号可以没有吗?那你想想 sizeof int 表示什么呢?int 前面加一个关键字?类型扩展?明显不正确,我们可以在 int 前加 unsigned,const 等关键字但不能加 sizeof。事实上,sizeof 在计算 变量 所占空间大小时,括号可以省略,而计算 类型 大小时不能省略。为了避免出错,建议大家不管是计算哪种情况都加上括号。

       下面是几个易混淆的问题:

int *p = NULL;
char *ch = NULL;
printf("sizeof(p) = %lu, sizeof(*p) = %lu; sizeof(ch) = %lu, sizeof(*ch) = %lu\n",
        sizeof(p), sizeof(*p), sizeof(ch), sizeof(*ch));

int a[100];
printf("sizeof(a) = %lu, sizeof(a[100]) = %lu, sizeof(&a) = %lu, sizeof(&a[0]) = %lu\n",
        sizeof(a), sizeof(a[100]), sizeof(&a), sizeof(&a[0]));

int b[100];
void fun(int b[100])
{
    printf("sizeof(b) = %lu\n", sizeof(b));
}

三个 printf 打印出来的值分别是多少?不清楚的地方一定要实际调试看看,在后面指针和数组一章我们再回过头讨论这几个问题。另外,假如

sizeof (int)*p

这个表达式编译不会报错,那它表示什么意思?通过再三调试,上面表达式的含义是:

sizeof(int) * p

p 可以是 char/int/float 等变量类型,但不能是指针和数组地址。

3.6 struct

       这个关键字绝对不好惹,Linux 内核源码中一个结构体少则上十行,多则上百行几百行的都有。而这些复杂的结构体正是阅读内核源码的一个巨大障碍之一。struct 非常类似面向对象语言中的 class,面向对象的设计思维在 Linux 内核源码中非常普遍。比如在驱动程序中,一个硬件设备会被抽象成一个 struct 结构体,里面的具体成员就用来描述这个硬件设备的各个属性特征,以及与其它硬件设备之间的关系。

3.6.1 变长数组

       所谓变长数组就是数组长度待定的数组。变长数组的使用场景一般在结构体中。把结构体的最后一个成员定义为一个变长数组,以此达到灵活分配内存的目的,在 Linux 内核中这种技巧随处可见。比如 USB 的 Mass Storage 驱动里就会用到这么一个结构体(为方便阅读,删掉了无关成员):

struct Scsi_Host {
    ...
	
    /*
     * We should ensure that this is aligned, both for better performance
     * and also because some compilers (m68k) don't automatically force
     * alignment to a long boundary.
     */
    unsigned long hostdata[0]  /* Used for storage of host specific stuff */
        __attribute__ ((aligned (sizeof(unsigned long))));
}

关键字__attribute__ 用于对变量指定特殊属性,是 GNU C 对标准 C 的扩展之一,感兴趣的可以了解一下,在这里不做过多说明,这里指定的是内存对齐属性,从注释我们也可以看出。关于内存对齐在接下来一小节会详细说明。回到本节主题,hostdata 是一个成员个数为 0 的数组,它的作用是为了在 struct Scsi_Host 这个结构体后面紧跟着分配另一个结构体空间,以达到这两个结构体相互依赖的目的。用法如下(删掉了无关代码):

static inline struct us_data *host_to_us(struct Scsi_Host *host)
{
    return (struct us_data *) host->hostdata;
}

static int storage_probe(struct usb_interface *intf,
			 const struct usb_device_id *id)
{
    struct Scsi_Host *host;
    struct us_data *us;

    ...

    /*
     * Ask the SCSI layer to allocate a host structure, with extra
     * space at the end for our private us_data structure.
     */
    host = scsi_host_alloc(&usb_stor_host_template, sizeof(*us));
    if (!host) {
        printk(KERN_WARNING USB_STORAGE
               "Unable to allocate the scsi host\n");
        return -ENOMEM;
    }

    us = host_to_us(host);

    ...
}

首先定义了两个结构体指针 struct Scsi_Host *host 和 struct us_data *us,这两个结构体在 Mass Storage 驱动里非常重要,在这里我们只是为了说明 C 语言里的变长数组特性,而不会去深究这两个结构体。然后通过 scsi_host_alloc() 这个函数分配了一个 struct Scsi_Host 结构体内存空间,并返回结构体地址,通过注释我们也可以猜出一二。暂时先把 scsi_host_alloc() 这个函数放一放,稍后我们再回过头来分析。接着往下看 us = host_to_us(host); 这条语句,展开以后就是

us = (struct us_data *) host->hostdata;

hostdata 是一个数组名,它是一个地址,然后通过类型强制转换赋值给了 us,两个结构体指针 host 和 us 就是通过这个变长数组名 hostdata 联系上了。接下来我们就来看看 scsi_host_alloc() 这个函数做了些什么:

struct Scsi_Host *scsi_host_alloc(struct scsi_host_template *sht, int privsize)
{
    struct Scsi_Host *shost;
    gfp_t gfp_mask = GFP_KERNEL;

    ...

    shost = kzalloc(sizeof(struct Scsi_Host) + privsize, gfp_mask);
    if (!shost)
        return NULL;

    ...
}

kzalloc() 函数是内核空间分配内存的接口,你可以把它和用户空间的 malloc() 作类比。第一个参数就是分配的内存大小,可以很清楚的看到,除了为 struct Scsi_Host 结构体分配 sizeof(struct Scsi_Host) 个字节外,还额外加了 privsize 个字节。注意到了吗?privsize 是上面传参传过来的,大小刚好是 sizeof(struct us_data)。在计算 sizeof(struct Scsi_Host) 时,变长数组并不会被计算在内,所以 hostdata 刚好是额外分配的 sizeof(struct us_data) 这片空间的首地址。内存示意图如下(假设开辟的这块内存首地址地址为 0xff810000):

变长数组的合法性是在 C99 标准中才加入的,但是 GNU C 对 ANSI C 标准进行了一系列扩展,其中就包括变长数组。所以变长数组的使用在 Linux 内核代码中随处可见。

3.6.2 内存对齐

       内存对齐的话题要搞清楚两个问题:什么是内存对齐?为什么要内存对齐?

       所谓内存对齐,是指变量的起始地址在内存中需要落在自然边界上。在内存中,某个变量的自然边界就是能被某个数整除的地址。这个 “某个数” 和 “某个变量”(包括位置)、编译器以及硬件体系都有关系。关于内存对齐有下面三个规则:

首先,每个成员分别按自己的方式对齐,并能最小化长度。
其次,复杂类型(如结构)的默认对齐方式是它最长的成员的对齐方式,这样在成员是复杂类型时,可以最小化长度。
然后,对齐后的长度必须是成员中最大的对齐参数的整数倍,这样在处理数组时可以保证每一项都边界对齐。

下面来逐条展开说明。每种变量类型的自然边界不一样,大体上来说 “某个数” 就是该变量所占的内存空间大小。

看下面这个例子:

struct test {
    char name[9];
    int score;
    struct test *next;
};

struct alignment {
    char a;
    short b;
    int c;
    char d[12];
    unsigned long int e;
    int f[3];
    struct test g;
    char *h;
};

int main()
{
    struct alignment al;
    struct test tt;
    char *p;

    printf("sizeof(pointer) is %lu\n\n", sizeof(p));

    printf("name: %lu\n", (unsigned long)tt.name - (unsigned long)&tt);
    printf("score: %lu\n", (unsigned long)&tt.score - (unsigned long)&tt);
    printf("test: %lu\n", (unsigned long)&tt.next - (unsigned long)&tt);
    printf("tt: %lu\n\n", sizeof(tt));

    printf("a: %lu\n", (unsigned long)&al.a - (unsigned long)&al);
    printf("b: %lu\n", (unsigned long)&al.b - (unsigned long)&al);
    printf("c: %lu\n", (unsigned long)&al.c - (unsigned long)&al);
    printf("d: %lu\n", (unsigned long)&al.d - (unsigned long)&al);
    printf("e: %lu\n", (unsigned long)&al.e - (unsigned long)&al);
    printf("f: %lu\n", (unsigned long)&al.f - (unsigned long)&al);
    printf("g: %lu\n", (unsigned long)&al.g - (unsigned long)&al);
    printf("h: %lu\n", (unsigned long)&al.h - (unsigned long)&al);
    printf("al: %lu\n", sizeof(al));

    return 0;
}

打印结果:

sizeof(pointer) is 8

name: 0
score: 12
test: 16
tt: 24

a: 0
b: 2
c: 4
d: 8
e: 24
f: 32
g: 48
h: 72
al: 80

TODO...

一个字或双字操作数跨越了 4 字节边界,或者一个四字操作数跨越了 8 字节边界,被认为是未对齐的,从而需要两次总线周期来访问内存。一个字起始地址是奇数但却没有跨越字边界被认为是对齐的,能够在一个总线周期中被访问。某些操作双四字的指令需要内存操作数在自然边界上对齐。如果操作数没有对齐,这些指令将会产生一个通用保护异常。双四字的自然边界是能够被 16 整除的地址。其他的操作双四字的指令允许未对齐的访问(不会产生通用保护异常),然而,需要额外的内存总线周期来访问内存中未对齐的数据。

3.6.3 根据成员地址找到结构体地址

       根据结构体的某个成员地址找到该结构体的地址,这个技巧在内核中使用非常广泛,而实现该技巧的正是著名的 container_of 宏函数。container_of 有内核第一宏的美誉其定义如下:

/**
 * container_of - cast a member of a structure out to the containing structure
 * @ptr:        the pointer to the member.
 * @type:       the type of the container struct this is embedded in.
 * @member:     the name of the member within the struct.
 *
 */
#define container_of(ptr, type, member) ({                      \
	const typeof( ((type *)0)->member ) *__mptr = (ptr);    \
	(type *)( (char *)__mptr - offsetof(type, member) );})

注意宏函数的三个参数。第一个参数 ptr 指向我们所说的结构体成员的指针;第二个参数不是变量,而是类型名,类型名正是我们所求地址的结构体;第三个参数是结构体成员名。

       为了更好的理解,我们用一个实际例子来说明:

struct city {
    char name[20];
    int position;
    unsigned int area;
    int people_count;
    strcut town tn;
    ......
};

struct city jingzhou = {
    .name = "jingzhou",
    .position = 7, /* means Hubei */
    .area = 1234,
    .people_count = 5678,
    .tn = xxx,
};

struct city *city = NULL;
struct town *town = &jingzhou.tn;

假设现在声明了一个结构体类型 city ,并且定义了一个结构体变量 jingzhou,并对其成员进行了初始化。现在已知有一个strcut town *类型的指针 town 指向了 jingzhou 的成员变量 tn,要求出结构体 jingzhou 的地址。如果使用 container_of 宏函数,那么可以非常方便的得到我们要求的地址:

city = container_of(town, struct city, tn);

我们现在抛开 container_of 这个现成的工具,如果要求出 jingzhou 的地址,那么很自然地,只需要求出 tn 这个成员在 jingzhou 内部的偏移量(假设是 offset)即可。对于本例而言,前面的成员变量都已知(只有name/position/area/people_count),offset 的值不难求出(注意内存对齐)。但是实际代码中,几乎不可能知道前面都有哪些成员变量,而且结构体类型也是千差万别,如果每碰到一次都要有针对性的专门计算一次 offset,这对内核开发者来说简直就是一种侮辱。于是内核开发者设计了这个 "万能公式" 般的宏函数,其基本思路当然也是求出 offset 值,然后用 town 的地址减去这个 offset,只不过其 offset 计算的精妙之处,让人惊叹。

       我们现在再来回过头细细分析。先只看第二行:

const typeof( ((type *)0)->member ) *__mptr = (ptr); 

首先是 0 地址的使用:((type *)0)->member,展开就是 ((strcut city *)0)->member,把 0 地址强转为 struct city *,这是合法的。typeof 的作用是根据括号里面的变量名求出该变量的类型名,对应到本例中就是 struct town。有人说 typeof 是 C 语言的关键字之一,但是我查了最新的 C99 和 C11标准新增的12个关键字,均没有 typeof,我认为应该是 GNU C 的扩展,但是也没有找到实锤的证据,哪位朋友如知道的更详细,还请不吝赐教。语句展开以后就变成了:

const struct town *__mptr = (town); 

定义了另外一个指针,而且把 town 赋值给它。有人可能会有疑问了,这不是多此一举吗?已经有了 town,为什么还要再额外定义一个指针,而且还是把 town 直接赋值过去?试想一下,如果使用  container_of 的编程人员不小心传过来的第一个参数 ptr 和 第三个参数 member 类型不一样怎么办呢?这条语句的作用就是为了防止这种情况的发生,因为如果不一样编译的时候就会提示报错或者肯定会有warning。不得不说内核开发者简直就是保姆般地考虑周到。

       再来来看第三行:

(type *)( (char *)__mptr - offsetof(type, member) );

最前面做了强转,毫无疑问,后面括号里计算出来的肯定是个地址,这个地址正是我们所要求的。括号里的 char * 强转是把指针加减操作的单位改为了1,也就是数学运算了。offsetof,根据名字也可以猜到七八,offset of,什么什么的偏移量。展开来我们才能知道这个什么什么到底是什么:

#define offsetof(TYPE, MEMBER)    ((size_t) &((TYPE *)0)->MEMBER)

又是 0 地址的使用,对 0 地址进行强转,相当于认为内存空间从 0 地址开始到 sizeof(TYPE) 这段空间装着一个 TYPE 的变量。具体到我们这里,TYPE 就是 struct city。所以,&((TYPE *)0)->MEMBER 就是第二个参数 MEMBER 的地址,强转为 size_t 就是我们梦寐以求的 offset 偏移量了。

       有人可能又会有疑问了,在 0 地址进行强转然后指向 MEMBER,这是合法的吗?这个就涉及到地址的引用和元素的引用之间的区别了。这里只是引用了地址,并没有对地址处的元素做任何改变,因此当然合法。这有点类似《C陷阱与缺陷》一书中谈到的对数组中越界元素的地址的引用。举例来说明(来自《C陷阱与缺陷》):

#define  N  1024
static char buffer[N];
static char *bufptr = &buffer[0];

buf_write(char *p, int len)
{
    while (--len > 0) {
        if (bufptr == &buffer[N])
            flushbuffer();
        *bufptr++ = *p++;
    }
}

为了说明引用数组越界元素的地址的问题,这里只是截取了相关的核心代码,所以只看这部分代码的话会有 buffer 处理的bug,原书中此例还用来解释了其他问题,如有兴趣可以看书中的例子,很详细。if 里面的判断使用了 &buffer[N],这个元素肯定是不存在的,但这里是合法的,因为这里并不需要引用这个元素,而只是引用它的地址,并且这个地址确实是存在的。ANSI C 明确允许这种用法:

数组中实际不存在的 "溢界" 元素的地址位于数组所占内存之后,这个地址可以用于进行赋值和比较。当然,如果要引用该元素,那就是非法的了。

3.7 union

       union 类型和结构体类型外形长的非常像,但是本质却差远了。union 类型在内存中同一时刻只能存储其中的一个成员,所有数据成员共享同一块内存空间。因此,union 类型占用的内存大小等于其所有成员中最大长度的那个。举例来说:

struct person {
    char name[7];
};

union utest {
    int a;
    char b;
    char *c;
    struct person d;
    double e;
    short f;
};

32 位系统下,上面 union 所有数据成员中最大长度的是结构体变量 d(注意内存对齐)和 double 变量 e,都是 8 字节,所以一个 union utest 类型的变量在内存中占用 8 个字节的空间。

3.7.1 大小端存储模式

       所谓大小端指的是数据在内存中的存放方式:

大端模式(Big_endian):数据的 高字节 存储在 低地址 中,低字节 则存放在 高地址 中。

小端模式(Little_endian):数据的 高字节 存储在 高地址 中,低字节 则存放在 低地址 中。

由于 union 类型所有数据成员共享同一块内存的这种特点,导致不同的存储模式对数据的结果有很大的影响。比如:

union {
    int i;
    char a[2];
} *p, u;

int main()
{
    p = &u;
    p->i = 0;
    p->a[0] = 0x12;
    p->a[1] = 0x34;

    printf("p->i = %d\n", p->i);
    return 0;
}

p->i 的值很显然会受大小端模式的影响。Linux 内核中有专门的的大小端转换函数,如果的你代码中有关于大小端情况的时候,要特别注意数据的存储。

3.8 signed、unsigned

       很明显我们关键要搞清楚负数在内存中的存储方式。在计算机系统中,数值一律用补码来表示(存储)。正数的补码与其原码一致;负数的补码:符号位为 1,其余位为该数绝对值的原码按位取反,然后整个数加 1。主要原因是使用补码,可以将符号位和其它位统一处理;同时,减法也可按加法来处理。另外,两个用补码表示的数相加时,如果最高位(符号位)有进位,则进位被舍弃。举例来说,对于用 char 类型来存储的 -1 和 1,两者内存中的存储值分别是 1111 1111b 和 0000 0001b,相加的时候结果为1 0000 0000b,但是对于 char 类型来说只能存储 8 位的数据,最高位第 9 位舍弃。

       有符号和无符号的取值范围就不列出来了,这里需要提醒一点的是,缺省情况下编译器认为数据是 signed 类型的,可以省略不写。请思考下面这段代码的输出结果:

int main()
{
    int i;
    char a[1000];

    for (i = 0; i < 1000; i++)
        a[i] = -1 - i;

    printf("%lu", strlen(a));
    return 0;
}

       按照负数补码的规则,-1 的补码为 0xff,-2 的补码为 0xfe…… 当 i 的值为 127 时,a[127] 的值为 -128,而 -128 是 char 类型数据能表示的最小负数。当 i 继续增加,a[128] 的值肯定不能是 -129,因为这时候发生了溢出,-129 需要 9 位才能存储下来,而 char 类型数据只有 8 位,所以最高位被丢弃。剩下的 8 位是原来 9 位补码的低 8 位的值,即 0x7f。当 i 继续增加到 255 的时候,-256 的补码的低 8 位为 0。然后当 i 增加到 256 时,-257 的补码的低 8 位全为 1,即低八位的补码为 0xff,如此又开始一轮新的循环……

       按照上面的分析,a[0] 到 a[254] 里面的值都不为 0,而 a[255] 的值为 0。strlen 函数是计算字符串长度的,并不包含字符串最后的 ‘\0’。而判断一个字符串是否结束的标志就是看是否遇到 ‘\0’,如果遇到 ‘\0’,则认为本字符串结束。分析到这里,strlen(a)的值为 255 就很清楚了。这个问题的关键就是要明白 char 类型默认情况下是有符号的,其表示的值的范围为[-128, 127],超出这个范围的值会产生溢出。另外还要清楚的就是负数的补码怎么表示。

       再看看下面的代码段有没有什么问题:

unsigned i;

for (i = 9; i >= 0; i--)
    printf("%u\n", i);

3.9 static

不要误以为关键字 static 很安静,其实它一点也不安静。

关键字 static 主要修饰变量和函数。

1、修饰变量。

       变量又分为局部和全局变量。

       静态全局变量,作用域仅限于变量被定义的文件中,其他文件即使用 extern 声明也没法使用他。准确地说作用域是从定义之处开始,到文件结尾处结束。同一个文件中就算在定义之处前面的那些代码行也不能使用它,想要访问就必须得在前面使用extern 声明,所以为了避免这种情况最好在文件顶端定义。

       静态局部变量,在函数体里面定义的,就只能在这个函数里用了,同一个文件中的其他函数也用不了。由于被 static 修饰的变量总是存在内存的静态区,所以即使这个函数运行结束,这个静态变量的值还是不会被销毁,函数下次使用时仍然能用到这个值。

2、修饰函数。

       函数前加 static 使其成为静态函数。但此处 static 的含义不是指存储方式,而是指函数的作用域仅局限于本文件内,故又称内部函数。使用内部函数的好处是:不同的人编写不同的函数时,不用担心自己定义的函数是否会与其它文件中的函数同名。

       Is that all?

3.9.1 进程的内存布局

       我们都知道静态(或全局)变量与普通临时变量最主要的差别就是生存期的长短,那为什么会有这个区别呢?是什么原因导致的呢?静态变量和普通临时变量存放的位置不一样吗?不一样的话分别放在哪儿呢?为什么静态(或全局)变量在整个程序执行期间都存在而其他文件却还是无法访问呢?

       这就涉及到程序在内存中的布局了。在开始介绍内存布局之前,有必要澄清进程和程序之间的区别。所谓进程,是指一个可执行程序的实例。进程属于操作系统的概念范畴,从内核的角度来说,进程就是著名的 tast_struct 数据结构,用来维护进程状态信息,这些信息包括进程ID号、虚拟内存表、打开文件描述符表、进程资源使用及限制、当前工作目录等等。从用户空间的角度来说,进程是一块内存空间,里面包含进程所要执行的代码及所使用的变量 。所谓程序,是指包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,所包括的内容如下。

       1、二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息(metainformation)。内核利用此信息来解释文件中的其他信息。历史上,UNIX 可执行文件曾有两种广泛使用的格式,分别为最初的 a.out(汇编程序输出)(是的,a.out 文件具有 a.out 格式,就像佛具有佛性)和更加复杂的 COFF(通用对象文件格式)。现在,大多数 Unix 实现(包括Linux)采用可执行连接格式(ELF),这一文件格式比老版本格式具有更多优点。在我的 Ubuntu14.04 上用 file 命令查看编译出来的可执行文件 a.out,可以看到确实是 ELF 格式。

a.out: ELF 64-bit LSB  executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=bd24a5862f3c65373e92f43b6565c55bf5d092f2, not stripped

       2、机器语言指令:对程序算法进行编码。

       3、程序入口地址:标识程序开始执行时的起始指令位置。

       4、数据:程序文件包含的变量初始值和程序使用的字面常量值(比如字符串)。

       5、符号表及重定位表:描述程序中函数和变量的位置及名称。这些表格有多种用途,其中包括调试和运行时的符号解析(动态链接)。

       6、共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。

       7、程序文件还包含许多其他信息,用以描述如何创建进程。

       程序是一个静态的可执行文件,程序跑起来了才是进程。一个可执行程序可以创建多个进程,或者反过来说,多个进程运行的可以是同一程序。如下代码可以演示这个情况。

int main()
{
    while (1)
        sleep(10);

    return 0;
}

在 shell 终端以后台运行的方式执行两次,可以看到系统创建了两个进程:

troy @ workpc 11:19:55:~/work$ ./a.out &
[1] 21329
troy @ workpc 11:20:03:~/work$ ./a.out &
[2] 21330
troy @ workpc 11:30:09:~/work$ ps -l
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000 21329 24467  0  80   0 -  1049 hrtime pts/24   00:00:00 a.out
0 S  1000 21330 24467  0  80   0 -  1049 hrtime pts/24   00:00:00 a.out
0 R  1000 21355 24467  0  80   0 -  3650 -      pts/24   00:00:00 ps
0 S  1000 24467  3262  0  80   0 -  7112 wait   pts/24   00:00:00 bash

       说清楚了进程和程序的区别,现在来说明进程的内存布局。每个进程所分配的内存由很多部分组成,通常称之为 “段(segment)”。

文本段:包含了进程运行的程序机器语言指令。文本段具有只读属性,以防止进程通过错误指针意外修改自身指令。因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。

初始化数据段:包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。

未初始化数据段:包含了未进行显式初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为 0。出于历史原因,此段常被称为 BSS 段。将全局变量和静态变量分为初始化和未初始化并分开存放,其主要原因在于程序在磁盘上存储时,没有必要为未经初始化的变量分配存储空间。相反,可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配这一空间。

栈(stack):是一个动态增长和收缩的段,由栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。

堆(heap):是可在运行时为变量动态分配内存的块区域。堆顶端称作 program break。

       下面这张图摘自《The Linux Programming Interface》(【德】 Michael Kerrisk 著)一书的第六章,展示了各种内存段在x86-32 体系结构中的布局。该图的顶部标记为 argv、environ 的空间用来存储程序命令行实参(通过 C 语言中 main() 函数的 argv 参数获得)和进程的环境列表。图中十六进制的地址会因内核配置和程序链接选项差异而有所不同。标灰的区域表示这些范围在进程虚拟地址空间中不可用,也就是说,没有为这些区域创建页表(稍后讨论)。注意到图中右下方的三个箭头了吗?Linux 为 C 语言编程环境提供了 3 个全局符号(symbol):etext、 edata 和 end,可在程序内使用这些符号以获取相应程序文本段、初始化数据段和非初始化数据段结尾处下一字节的地址。使用这些符号,必须显式声明如下:

extern char etext, edata, end;

       size 命令可显示二进制可执行文件的文本段、初始化数据段、BSS段的大小。

troy @ workpc 11:32:29:~/work$ size a.out 
   text	   data	    bss	    dec	    hex	filename
   1329	    568	      8	   1905	    771	a.out

       为了更好的说明各个段,我们结合上面的图和下面的这段代码来分析。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int a;
int b = 1;
static int c;
static int d = 2;

extern char etext, edata, end;

int main()
{
    static int e, f = 3;
    int g, h = 4;
    char *p;

    p = malloc(20);
    strcpy(p, "hello world");

    printf("%p, %p, %p\n", &etext, &edata, &end);
    printf("&a = %p, &b = %p, &c = %p, &d = %p\n", &a, &b, &c, &d);
    printf("&e = %p, &f = %p, &g = %p, &h = %p, p = %p, &p = %p\n",
        &e, &f, &g, &h, p, &p);
    printf("the address of literal string abc is %p\n", "abc");

    free(p);
    p = NULL;

    return 0;
}

打印结果如下:

troy @ workpc 11:57:09:~/work$ a.out 
0x4006cd, 0x601054, 0x601068
&a = 0x601060, &b = 0x601048, &c = 0x601058, &d = 0x60104c
&e = 0x60105c, &f = 0x601050, &g = 0x7fff3ac00290, &h = 0x7fff3ac00294, p = 0x149f010, &p = 0x7fff3ac00298
the address of literal string abc is 0x400745

从打印结果可知:

1、文本段在地址 0x4006cd 以下,初始化数据段的地址范围是:0x4006cd - 0x601054,BSS段的地址范围是:0x601054 - 0x601068。

2、变量  a、c、e 确实在 BSS 段内,而 b、d、f 则在初始化数据段内。

3、p 的值是 0x149f010,也就是由 malloc() 分配的一块内存空间的首地址,这块内存就在堆中。但是 p 本身存放在栈里,紧挨着临时变量 g 和 h。

4、常量字符串存放在初始化数据段中。

       接下来再重点介绍下 BSS 段和栈。

       BSS 段这个名字是 “Block Started by Symbol”(由符号开始的块)的缩写,它是旧式 IBM 704 汇编程序的一个伪指令,UNIX 借用了这个名字,至今依然沿用。由于 BSS 段只保存没有值的变量,所以事实上它并不需要保存这些变量的映像。运行时所需要的 BSS 段的大小记录在目标文件中,但 BSS 段(不像其他段)并不占据目标文件的任何空间。所以有些人也喜欢把它记作 “Better Save Space”(更有效地节省空间)。有兴趣的同学可以做个实验,在上面的代码基础上定义一个类似 int test[10000] 这样很大的数组,然后编译,看看 a.out 文件的大小;然后把 test 数组初始化,再编译看看 a.out 文件的大小。

       栈这块内存区域最显著的特性就是 “后进先出”,就像快餐店里的餐盘,这些餐盘就是栈里的栈帧(stack frames)。很明显这只是一个形象的类比,实际上栈会更灵活一点。对于一摞餐盘而言,当上面的餐盘没有拿掉的时候,我们无法拿到位于底层的餐盘,但是我们却可以通过一个全局指针来访问位于底层栈帧里的局部变量。计算机有一个专门的SP(Stack Pointer)寄存器,也就是栈指针,用来跟踪记录当前栈顶。每次调用函数的时候,系统就会在栈上新分配一个栈帧,当函数返回时,再从栈上将此帧移去。每个用户空间的栈帧包含如下信息:

1、函数实参和局部变量。这些变量是在函数被调用时自动创建的,因此在 C 语言中又叫 “自动变量”。函数返回时,由于栈帧会被释放,所以这些自动变量也会被销毁。前面说了 malloc() 分配的内存在堆中,那么通过 alloca() 分配的内存则在栈里。看看下面这个例子,有发现什么问题吗?

char *get_index_info(int idx)
{
    char *info[] = {
        "Linus Torvalds",
        "Brian W.Kernighan",
        "Dennis M.Ritchie",
        "Ken Thompson"
    };

    if (idx < 0 || idx >= sizeof(info) / sizeof(info[0]))
        return NULL;

    return info[idx];
}

2、函数调用的链接信息。每个函数都会用到一些 CPU 的寄存器,比如用来存放下一条将要执行的指令的程序计数器(PC)。每当一个函数调用另一个函数时,就会在被调用函数的栈帧中保存这些寄存器信息,以便被调用函数返回时能为调用者恢复这些寄存器,继续往下执行。另外,栈得以让函数能够实现嵌套调用。

       众所周知, goto 可以实现语句的跳转,但是不能跳出当前函数。库函数 setjmp() 和 longjmp() 则可以做到函数间的跳转(两个函数甚至可以来自不同的文件),其正是通过操作栈帧来实现的。感兴趣的同学可以自行查阅其使用方法,这里不做详细介绍。

       想了解更多关于这些段的知识,请查阅《C专家编程》一书的第六章或其他书籍。

3.9.2 虚拟内存管理

       上述关于进程内存布局的讨论忽略了一个事实:这一布局存在于虚拟内存中。像多数现代内核一样,Linux 也采用了虚拟内存管理技术。该技术利用了大多数程序的一个典型特征,即访问局部性,以求高效使用 CPU 和 物理内存资源。大多数程序都展现了两种类型的局部性:1、空间局部性,是指程序倾向于访问在最近访问过的内存地址附近的内存(由于指令是顺序执行的,且有时会按顺序处理数据结构);2、时间局部性,是指程序倾向于在不久的将来再次访问最近刚访问过的内存地址,比如循环。正是由于访问局部性特征,使得程序即便仅有部分地址空间存在于 RAM 中,依然可能得以执行。

       虚拟内存的规划之一是将每个程序使用的内存切割成小型的、固定大小的 “页” 单元。相应地,将 RAM 划分成一系列与虚存页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。程序未使用的页拷贝保存在交换区(swap area)内,交换区是磁盘空间中的保留区域。作为计算机 RAM 的补充,交换区的内容仅在需要时才会载入物理内存。若进程欲访问的页面目前并未驻留在物理内存中,将会发生页面错误,内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。

       为支持这一组织方式,内核需要为每个进程维护一张页表(page table),示意图如下。该页表描述了当前可为进程所用的所有虚拟内存页面的集合。页表中的每个条目要么指出一个虚拟页面在 RAM 中的所在位置,要么表明其当前驻留在磁盘上。在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而也无需为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将收到一个 SIGSEGV 信号。由于内核能够为进程分配和释放页(和页表条目),所以进程的有效虚拟地址范围在其生命周期中是可以发生变化的。

       虚拟内存的实现需要硬件中分页内存管理单元(PMMU)的支持。PMMU 把要访问的每个虚拟内存地址转换成相应的物理内存地址,当特定虚拟内存地址所对应的页没有驻留于 RAM 中时,将以页面错误通知内核。更多关于 MMU 的硬件知识可以参阅《嵌入式Linux应用开发完全手册》(韦东山 著)一书的第七章,作者以 ARM9 为例,详细介绍了 MMU 的地址映射规则。

       虚拟内存管理使进程的虚拟地址空间与 RAM 物理地址空间隔离开来,这带来许多优点。

       1、进程与进程、进程与内核相互隔离,所以一个进程不能读取或修改另一进程或内核的内存。这是因为每个进程的页表条目指向 RAM(或交换区)中截然不同的物理页面集合。

       2、适当情况下,两个或者更多进程能够共享内存。这是由于内核可以使不同进程的页表条目指向相同的 RAM 页。内存共享常发生于如下两种场景:①. 执行同一程序的多个进程,可共享一份程序代码副本(只读),另外当多个程序执行相同的程序文件或加载相同的共享库时,也会隐式地实现这一类型的共享;②. 在需要进程间通信的时候,进程可以使用 shmget() 和 mmap() 系统调用显式地请求与其他进程共享内存区。

       3、便于实现内存保护机制。我们可以对页表条目进行标记,以表示相关页面内容是可读、可写、可执行亦或是这些保护措施的组合。多个进程共享 RAM 页面时,允许每个进程对内存采取不同的保护措施。例如,一个进程可能以只读方式访问某页面,而另一进程则以读写方式访问同一页面。

       4、程序员和编译器、链接器之类的工具无需关注程序在 RAM 中的物理布局。

       5、程序的加载和运行会更快,因为需要驻留在内存中的仅是程序的一部分。而且,一个进程所占用的内存(即虚拟内存)能够超出 RAM 容量,在进程所需要的内存比物理内存还要大的情况下也可以正常运行。

       6、由于每个进程使用的 RAM 减少了,RAM 中同时可以容纳的进程数量就增多了。这增大了如下事件的概率:在任一时刻,CPU 都可执行至少一个进程,因而往往也会提高 CPU 的利用率。

       关于进程内存布局的话题就此打住了,想了解更多关于内存管理的知识请参阅相关书籍。

       留一个问题:内核空间的内存布局又是什么样的?

3.10 const

       const 是 constant 的缩写,是恒定不变的意思。我们都知道,被 const 修饰的变量在整个程序运行期间都不允许被修改。

3.10.1 const VS 常量

       const 推出的初始目的,是为了取代预编译指令,消除它的缺点,同时继承它的优点。缺点就是 #define 定义的宏常量没有类型检查,优点就是宏常量“只读”,整个程序运行期间都不会被修改。const 的只读属性是由编译器来保证的,编译过程中如果发现 const 变量被修改了就会报错。虽然都是不能被修改,但 const 修饰的还是变量,本质上和诸如 1、‘a’、“hello world” 等常量是有区别的。网上很流行的一个例子:

const int N = 10;
char name[N];

用 const 整型变量来作为数组的长度。是否会报错取决于编译器,如果是符合 ANSC C 标准的编译器,这段代码会报错,因为ANSI C 规定数组定义时长度必须是常量。但是 GCC 不会报错,不仅 const 只读变量的情况不会报错,用普通整形变量作为数组长度 GCC 也不会报错。

3.10.2 const修饰指针

       下面来看看热身运动第三题中的各个指针。

const int *ptr; /* 指针ptr指向的对象不可变,但是ptr本身可变 */
int const *ptr; /* 和上面的ptr一样 */
int * const ptr; /* 指针ptr指向的对象可变,ptr本身不可变 */
const int * const ptr; /* 指针ptr指向的对象和ptr本身都不可变 */

Talk is cheap, show me the code:

int a = 1;
const int b = 2;

int *p;
const int *p1;
int const *p2;
int * const p3_1 = &a;
int * const p3_2 = &b;
const int * const p4_1 = &a;
const int * const p4_2 = &b;

p = &a;
p = &b;

p1 = &a;
p1 = &b;

p2 = &a;
p2 = &b;

这段代码可以编译通过,但是会报两个warning:

test.c: In function ‘main’:
test.c:31:24: warning: initialization discards ‘const’ qualifier from pointer target type [enabled by default]
     int * const p3_2 = &b;
                        ^
test.c:36:7: warning: assignment discards ‘const’ qualifier from pointer target type [enabled by default]
     p = &b;
       ^

       四种 const 指针我们逐个来分析。

       p1 和 p2 的赋值语句均没有编译报错,这说明它俩既可以指向 a,也可以指向 b,也就是说 p1 和 p2 本身的值是可以改变的。但是不能通过 p1、p2来改变所指向的变量的值。诸如

*p1 = 12;
*p2 = 34;

这样的代码都会报错:error: assignment of read-only location ‘*p2’。报错的描述很严谨,不是 variable 而是 location,这个位置是只读的,不能进行赋值。

       p3_2 的初始化报错了,说初始化的时候指针目标类型丢弃了 const 修饰词。p3_2 指向的是 int 型变量,而不是 const int,而 b 是 const int,所以赋值的时候丢弃了 b 的 const 属性。编译器允许 int 和 const int 之间互相转换,只是 int 转化成 const int 不会报错(如 p1 = &a),const int 转化成 int 会报 warning(如上面两个警告)。稍后我们试图分析为什么可以转换的原因。

如果之后再试图让 p3_2 指向 a:

p3_2 = &a;

则会报错:error: assignment of read-only variable ‘p3_2’。

但是如果我们加上这样的语句:

*p3_2 = 10;

然后再把 b 的值打印出来,发现 b 变成10,也就是说通过 p3_2 改变了具有 const 属性的变量 b!C 语言的设计之初的哲学之一就是程序员对代码唯一负责,不要试图让编译器替你做很多工作。之所以在 p3_2 初始化的时候只是报 warning 而不是 error,是因为编译器的实现者认为我们只是想用到 b 的地址,而不会试图通过指针去改变它,否则你为什么在定义之初还要为 b 加上 const 属性呢?

       前面说了 p1 = &a; 不会报错,那么 const int * const ptr4_1 = &a; 当然也不会报错了。但是不要试图让 p4_1 再次指向别的地方,也不要试图通过 p4_1 去改变 a。

3.10.3 const与函数

       const 另一个重要应用场景就是和函数有关,包括修饰函数参数和函数返回值。当不希望函数体修改传过去的参数变量时,可以在定义该函数的时候指定这个传参为 const 类型,这样就可以防止一些有意无意的修改。C 库中很多函数都用到了这一技巧,比如:

char *strcat(char *dest, const char *src);
char *strcpy(char* dest, const char *src);
int strcmp(const char *s1, const char *s2);

strcat() 函数是在第一个字符串的末尾处添加第二个字符串的一份拷贝,很明显我们不会改变第二个指针,但是第一个会被改变,函数声明也正体现了这一点。

       const 也可以修饰函数的返回值,比如:

const char *foo(char *p, int i);

3.10.4 const变量到底存放在哪里?

int i;
int j = 10;
const int k;
const int l = 10;

int main()
{
    int m, n = 11;
    const int o, p = 10;

    printf("%p, %p, %p\n", &etext, &edata, &end);
    printf("%p, %p, %p, %p\n", &i, &j, &k, &l);
    printf("%p, %p, %p, %p\n", &m, &n, &o, &p);

    return 0;
}

打印结果如下:

0x40067d, 0x601044, 0x601050
0x601048, 0x601040, 0x60104c, 0x4006c4
0x7ffff8bf70a0, 0x7ffff8bf70a4, 0x7ffff8bf70a8, 0x7ffff8bf70ac

可以看到,const 限定符并不会改变变量的存储段。但是 l 的地址位置有点变化,j 还是在初始化数据段的高地址处,但是 l 却在初始化数据段的低地址处,靠近文本段的顶部。不知道读者有没有注意到,前面我们在打印各个段的变量时顺便把常量字符串也打印出来了,它的地址也是在初始化数据段的低地址处。所以编译器把全局的初始化了的 const 变量放在了常量地址块(仍然在初始化数据段内)。为了进一步验证这个结论,我们把上面代码的第二个 printf 改一改:

printf("%p, %p, %p, %p, %p, %p\n", &i, &j, &k, "abc", &l, "def");

打印结果如下:

troy @ workpc 14:33:48:~/work$ a.out 
0x40068d, 0x601044, 0x601050
0x601048, 0x601040, 0x60104c, 0x4006e4, 0x4006d4, 0x400700
0x7fff0c557a90, 0x7fff0c557a94, 0x7fff0c557a98, 0x7fff0c557a9c

进一步,我们在 main 函数里再定义一个变量:

const static int q = 12;

发现 q 的地址为 0x400718。这就说明,全局或者静态且已经初始化了的 const 变量被放在了和常量字符串一样的位置,但仍然在初始化数据段内,const 修饰其它变量不会改变变量的存储段。

       最后,根据上面实际调试的 const 变量位置,我们知道它和普通变量存放并没有特别之处,所以 const 变量在整个程序运行期间不能改变这一点是由编译器来保证的。

3.11 volatile

       volatile,中文意思是易变的、不稳定的,这个关键字主要是为了编译器而设计的。这就得从编译器的代码优化功能说起。请看下面的代码:

int x = 1;
int val1, val2;

val1 = x;

/* 其他没有使用x的代码 */
......

val2 = x;

一个聪明的优化器可能意识到你的这段代码使用了两次 x,并且前后都没有改变它的值。这个时候编译器便会 “自作主张”,把变量 x 的值临时存储在寄存器里。当 val2 需要 x 的时候,就可以直接从寄存器里取出 x 的值,而不用再次访问内存来读出 x 的值。这个过程就被称为缓存(caching)。很明显这的确提高了代码执行效率,因为编译器不会生成汇编代码重新从内存里取 x 的值。

       但是很遗憾,并不是每次我们都需要这种优化,特别是在和硬件打交道的驱动代码里。做过底层驱动开发的同学都知道,驱动代码里有些变量是用来保存一些硬件设备的状态信息的,比如用来记录当前设备是否在充电、是否充满了等等这些信息,这些信息最原始的值就保存在硬件设备的寄存器里。很显然,在软件代码层面我们不会去主动修改这些保存了硬件状态信息的变量,这就给了编译器优化的用武之地。稍不注意,你用到的这些状态信息可能就是从缓存里拿出来的。有的同学可能要说了,我平时写的驱动代码这些变量也没有特别地去加 volatile 修饰词,也没有碰到过问题啊。那是因为每次要用到这些信息的时候你的代码都会主动的更新变量,即重新去读寄存器,这就相当于给了变量 volatile 的属性。本人曾经就碰到过一个关于 volatile 的问题,调试的是龙讯一颗HDMI switch(3 IN - 1 OUT)芯片。这是一颗用 IIC 通信的芯片,当时驱动代码使用内核的 regmap 框架来实现 IIC 通信。使用 regmap 框架需要填充 struct regmap_config 这么一个结构体,其中有个 bool (*volatile_reg)(struct device *dev, unsigned int reg); 的回调指针需要实现,用来指出芯片的哪些寄存器值是 volatile 的。这个回调的注释信息如下:

 * @volatile_reg: Optional callback returning true if the register
 *          value can't be cached. If this field is NULL but
 *          volatile_table (see below) is not, the check is performed on
 *                such table (a register is volatile if it belongs to one of
 *                the ranges specified by volatile_table).

如果寄存器值不能被缓存,那你就需要返回 true。如果没有实现这个回调,那么 regmap core 就会使用 volatile table(如果有的话)里的寄存器信息。当时我既没有实现这个回调也没有填充这个 table,导致我在获取当前哪个 port 口有HDMI信号时一直无法准确获取到。后来用芯片原厂的 PC 端工具去查看的时候,发现寄存器实际上是有被更新到的。问题就出在 regmap core!它扮演了编译器的角色,优化了这个寄存器的值,当我使用 regmap_read 接口去读的时候 core 层实际上并未发起真正的 IIC 通信,而只是从缓存里拿出来给我了。

       优化显然是提高了代码执行效率,但是编译器并不知道我们什么时候需要这个优化,什么时候不需要这个优化。因此才有了 volatile 关键字。如果对变量没有使用 volatile 修饰词,那么编译器就会试着去优化代码;相反,如果对变量使用了 volatile,那么每次在用到这个变量时,编译器就会谨慎地重新从原始地址处(可能是内存,也可能是寄存器)读取这个变量的值。volatile 变量一般会用在以下几种情况:1、硬件设备的寄存器;2、中断函数可能会访问到的全局或静态变量;3、多线程编程中被几个任务共享的变量。

       以下定义语句有问题吗?

volatile const int time;
volatile int *p1;
int * volatile p2;

3.12 typedef

       typedef 关键字是给一个已经存在的 数据类型(注意:是类型不是变量)取一个别名,而非定义一个新的数据类型。在实际使用中,我们常常将一个结构体数据类型 typedef 成新的名字,比如:

typedef struct city {
    char name[20];
    int position;
    unsigned int area;
    int people_count;
    strcut town tn;
    ......
} City, *City_ptr;

这样定义以后,

struct city c1;
City c1; 

struct city *c2;
City *c2;
City_ptr c2;

c1 的两种定义和 c2 的三种定义就没有区别了。再来看看下面的定义:

const City_ptr c3;
City_ptr const c4;

c3 和 c4 是一样的类型吗?

3.12.1 typedef VS #define

       1、请看下面的定义语句:

#define INT32 int
unsigned INT32 i = 10;

typedef int INT32;
unsigned INT32 j = 10;

变量 i 的定义语句没问题,这很好理解。但是 j 的定义语句却会报错,因为用 typedef 取的别名不支持这种类型扩展,所以去掉 unsigned 才会合法。

       2、再请看下面两个 typedef:

typedef static int sint;
typedef const int cint;

第一个 typedef 也是非法,编译报错error: multiple storage classes in declaration specifiers,声明说明符中有多个存储类型。所谓存储类说的变量的存放位置以及其生命周期。typedef 本身是一种存储类的关键字,与 auto、extern、static、register 等关键字不能出现在同一条语句中。第二个 typedef 是合法的。以下引用摘自《C Primer Plus》(Fifth Edition)第十二章,更多关于存储类的相关知识可以直接查阅该书。

C语言中有 5 个作为存储类说明符的关键字,它们是 auto、register、static、extern 以及 typedef。关键字 typedef 与内存存储无关,由于语法原因被归入此类。特别地,不可以在一个声明中使用一个以上存储类说明符,这意味着不能将其他任一存储类说明符作为 typedef 的一部分。 

       3、再来看看下面的定义语句:

#define PCHAR char*
PCHAR p1, p2;

typedef char* pchar;
pchar p3, p4;

两组定义语句编译都没有问题,但是,这里的 p2 并不是 char 指针类型,而是一个 char 类型。这种错误很容易被忽略,所以这样用 #define 的时候要慎之又慎。关于 #define 还有很多内容值得研究,后面预处理一章会继续讨论。p3 和 p4 都是指针变量,受此启发,可以知道上面定义的 c3 和 c4 变量是一样的,const 修饰的都是指针本身。对于编译器来说,只认为 City_ptr 是一个类型名。

       不知道大家有没有留意到,其实上面所有关于 typedef 的代码可以说都是多余的,或者说用比不用并没有表现出什么优势,即使是我们说的给结构体取别名。事实上,Linux 内核开发者们强烈反对使用 typedef,理由是:

1、typedef 掩盖了数据的真实类型,很容易因此而犯错误;

2、使用 typedef 往往是因为想偷懒。有些程序员往往为了少敲几个字母而使用 typedef,比如 typedef unsigned char uchar。

当然 typedef 也有它施展身手的时候,当需要隐藏变量与体系结构相关的实现细节的时候,当某种类型将来有可能发生变化,而现有程序必须要考虑到向前兼容问题的时候,都需要 typedef。使用 typedef 要谨慎,只有在确实需要的时候再用它,如果仅仅是为了少敲打几下键盘,别使用它。

3.13 void

       1、GNU C 中 void 指针变量和其他类型的指针变量可以直接相互赋值,而不用指定强转类型;

       2、GNU C 中允许对 void 指针进行自增自减操作,步进值是1;

       3、函数没有返回值时要声明为 void 类型,缺省时返回的是 int 型。

 

 

 

鸣谢单位(排名不分先后):

1、《C语言深度剖析》,作者:陈正冲,石虎;

2、《C Traps and Pitfalls》(C缺陷与陷阱),作者:Andrew Koenig;

3、《Expert C Programming:Deep C Secrets》(C专家编程),作者:Peter van der Linden;

4、《The Linux Programming Interface》(Linux/UNIX 系统编程手册),作者:Michael Kerrisk;

5、《C Primer Plus》(Fifth Edition),作者:Stephen Prata。

  • 3
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值