深入理解C语言

深入理解C语言

Dennis Ritchie  过世了,他发明了C语言,一个影响深远并彻底改变世界的计算机语言。一门经历40多年的到今天还长盛不衰的语言,今天很多语言都受到C的影响,C++,Java,C#,Perl, PHP, Javascript, 等等。但是,你对C了解吗?相信你看过本站的《C语言的谜题》还有《谁说C语言很简单?》,这里,我再写一篇关于深入理解C语言的文章,一方面是缅怀Dennis,另一方面是告诉大家应该如何学好一门语言。(顺便注明一下,下面的一些例子来源于这个slides

首先,我们先来看下面这个经典的代码:

int main()
{
    int a = 42;
    printf(“%d\n”, a);
}

从这段代码里你看到了什么问题?我们都知道,这段程序里少了一个#include <stdio.h> 还少了一个return 0;的返回语句。

不过,让我们来深入的学习一下,

  • 这段代码在C++下无法编译,因为C++需要明确声明函数
  • 这段代码在C的编译器下会编译通过,因为在编译期,编译器会生成一个printf的函数定义,并生成.o文件,链接时,会找到标准的链接库,所以能编译通过。
  •  但是,你知道这段程序的退出码吗?在ANSI-C下,退出码是一些未定义的垃圾数。但在C89下,退出码是3,因为其取了printf的返回值。为什么printf函数返回3呢?因为其输出了’4′, ‘2’,’\n’ 三个字符。而在C99下,其会返回0,也就是成功地运行了这段程序。你可以使用gcc的 -std=c89或是-std=c99来编译上面的程序看结果。
  • 另外,我们还要注意main(),在C标准下,如果一个函数不要参数,应该声明成main(void),而main()其实相当于main(…),也就是说其可以有任意多的参数。

我们再来看一段代码:

#include <stdio.h>
void f(void)
{
   static int a = 3;
   static int b;
   int c;
   ++a; ++b; ++c;
   printf("a=%d\n", a);
   printf("b=%d\n", b);
   printf("c=%d\n", c);
}
int main(void)
{
   f();
   f();
   f();
}

这个程序会输出什么?

  • 我相信你对a的输出相当有把握,就分别是4,5,6,因为那个静态变量。
  • 对于c呢,你应该也比较肯定,那是一堆乱数。
  • 但是你可能不知道b的输出会是什么?答案是1,2,3。为什么和c不一样呢?因为,如果要初始化,每次调用函数里,编译器都要初始化函数栈空间,这太费性能了。但是c的编译器会初始化静态变量为0,因为这只是在启动程序时的动作。
  • 全局变量同样会被初始化。

说到全局变量,你知道 静态全局变量和一般全局变量的差别吗?是的,对于static 的全局变量,其对链接器不可以见,也就是说,这个变量只能在当前文件中使用。

我们再来看一个例子:

#include <stdio.h>
void foo(void)
{
    int a;
    printf("%d\n", a);
}
void bar(void)
{
    int a = 42;
}
int main(void)
{
    bar();
    foo();
}

你知道这段代码会输出什么吗?A) 一个随机值,B) 42。A 和 B都对(在“在函数外存取局部变量的一个比喻”文中的最后给过这个例子),不过,你知道为什么吗?

  • 如果你使用一般的编译,会输出42,因为我们的编译器优化了函数的调用栈(重用了之前的栈),为的是更快,这没有什么副作用。反正你不初始化,他就是随机值,既然是随机值,什么都无所谓。
  • 但是,如果你的编译打开了代码优化的开关,-O,这意味着,foo()函数的代码会被优化成main()里的一个inline函数,也就是说没有函数调用,就像宏定义一样。于是你会看到一个随机的垃圾数。

下面,我们再来看一个示例:

#include <stdio.h>
int b(void) { printf(“3”); return 3; }
int c(void) { printf(“4”); return 4; }
int main(void)
{
   int a = b() + c();
   printf(“%d\n”, a);
}

这段程序会输出什么?,你会说是,3,4,7。但是我想告诉你,这也有可能输出,4,3,7。为什么呢? 这是因为,在C/C++中,表达的评估次序是没有标准定义的。编译器可以正着来,也可以反着来,所以,不同的编译器会有不同的输出。你知道这个特性以后,你就知道这样的程序是没有可移植性的。

我们再来看看下面的这堆代码,他们分别输出什么呢?

int a=41; a++; printf("%d\n", a);
int a=41; a++ & printf("%d\n", a);
int a=41; a++ && printf("%d\n", a);
int a=41; if (a++ < 42) printf("%d\n", a);
int a=41; a = a++; printf("%d\n", a);

只有示例一,示例三,示例四输出42,而示例二和五的行为则是未定义的。关于这种未定义的东西是因为Sequence Points的影响(Sequence Points是一种规则,也就是程序执行的序列点,在两点之间的表达式只能对变量有一次修改),因为这会让编译器不知道在一个表达式顺列上如何存取变量的值。比如a = a++,a + a++,不过,在C中,这样的情况很少。

下面,再看一段代码:(假设int为4字节,char为1字节)

struct X { int a; char b; int c; };
printf("%d,", sizeof(struct X));
struct Y { int a; char b; int c; char d};
printf("%d\n", sizeof(struct Y));

这个代码会输出什么?

a) 9,10
b)12, 12
c)12, 16

答案是C,我想,你一定知道字节对齐,是向4的倍数对齐。

  • 但是,你知道为什么要字节对齐吗?还是因为性能。因为这些东西都在内存里,如果不对齐的话,我们的编译器就要向内存一个字节一个字节的取,这样一来,struct X,就需要取9次,太浪费性能了,而如果我一次取4个字节,那么我三次就搞定了。所以,这是为了性能的原因。
  • 但是,为什么struct Y不向12 对齐,却要向16对齐,因为char d; 被加在了最后,当编译器计算一个结构体的尺寸时,是边计算,边对齐的。也就是说,编译器先看到了int,很好,4字节,然后是 char,一个字节,而后面的int又不能填上还剩的3个字节,不爽,把char b对齐成4,于是计算到d时,就是13 个字节,于是就是16啦。但是如果换一下d和c的声明位置,就是12了。

另外,再提一下,上述程序的printf中的%d并不好,因为,在64位下,sizeof的size_t是unsigned long,而32位下是 unsigned int,所以,C99引入了一个专门给size_t用的%zu。这点需要注意。在64位平台下,C/C++ 的编译需要注意很多事。你可以参看《64位平台C/C++开发注意事项》。

下面,我们再说说编译器的Warning,请看代码:

#include <stdio.h>
int main(void)
{
    int a;
    printf("%d\n", a);
}

考虑下面两种编译代码的方式 :

  • cc -Wall a.c
  • cc -Wall -O a.c

前一种是不会编译出a未初化的警告信息的,而只有在-O的情况下,才会有未初始化的警告信息。这点就是为什么我们在makefile里的CFLAGS上总是需要-Wall和 -O。

最后,我们再来看一个指针问题,你看下面的代码:

#include <stdio.h>
int main(void)
{
    int a[5];
    printf("%x\n", a);
    printf("%x\n", a+1);
    printf("%x\n", &a);
    printf("%x\n", &a+1);
}

假如我们的a的地址是:0Xbfe2e100, 而且是32位机,那么这个程序会输出什么?

  • 第一条printf语句应该没有问题,就是 bfe2e100
  • 第二条printf语句你可能会以为是bfe2e101。那就错了,a+1,编译器会编译成 a+ 1*sizeof(int),int在32位下是4字节,所以是加4,也就是bfe2e104
  • 第三条printf语句可能是你最头疼的,我们怎么知道a的地址?我不知道吗?可不就是bfe2e100。那岂不成了a==&a啦?这怎么可能?自己存自己的?也许很多人会觉得指针和数组是一回事,那么你就错了。如果是 int *a,那么没有问题,因为a是指针,所以 &a 是指针的地址,a 和 &a不一样。但是这是数组啊a[],所以&a其实是被编译成了 &a[0]。
  • 第四条printf语句就很自然了,就是bfe2e104。还是不对,因为是&a是数组,被看成int(*)[5],所以sizeof(a)是5,也就是5*sizeof(int),也就是bfe2e114。

看过这么多,你可能会觉得C语言设计得真扯淡啊。不过我要告诉下面几点Dennis当初设计C语言的初衷:

1)相信程序员,不阻止程序员做他们想做的事。

2)保持语言的简洁,以及概念上的简单。

3)保证性能,就算牺牲移植性。

今天很多语言进化得很高级了,语法也越来越复杂和强大,但是C语言依然光芒四射,Dennis离世了,但是C语言的这些设计思路将永远不朽。

(请勿用于商业用途,转载时请注明作者和出处)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

好烂啊有点差凑合看看还不错很精彩 (62 人打了分,平均分: 4.13 )
Loading...

深入理解C语言》的相关评论

  1. 我个人觉得这三条设计原则都有值得推敲的地方。
    1)相信程序员,不阻止程序员做他们想做的事。
    是不是在某些情况下,适当地做些限制会更好?现实中并不是所有程序员都能相信的。

    2)保持语言的简洁,以及概念上的简单。
    简洁这个词太过于抽象,汇编语言也很简洁,概念上更简单啊。

    3)保证性能,就算牺牲移植性。
    谁都要性能,关键是把性能放在多高的优先级。在如今的硬件性能下,是否可以和适当地降低性能的优先级。

  2. 第四条printf语句就很自然了,就是bfe2e104。 这句话有问题吧。
    a+1确实等于a+1*sizeof(int) ,而&a+1 应该等于 a + 1 *sizeof(a)。 a == &a是因为数组的首地址与数组的第一个元素的首地址一样,而真正一样的是 a == &a[0]。

  3. @编号一百零二 不好意思,不知道为什么没有看到后面的这一句话。汗。。
    “ 还是不对,因为是&a是数组,所以是sizeof(a),也就是5*sizeof(int),也就是bfe2e114。”,SORRY。

  4. @Charlie
    每个语言有不同的应用领域,对于C的应用领域,这么设计是很合适的。如果需要限制,大可用Java.
    关于第二条,简洁是说语法上比较干净,可以从pascal和cpp两个角度对比下;简单是说语义上抽象粒度比较合适,C的函数调用模型非常干练,有很漂亮的抽象能力的同时也能做很细致的工作——还是这么说,对它的应用领域是合适的。当然要说最简洁漂亮的还是莫过于lisp,可惜McCarthy也接着Ritchie去了……

  5. “这种未定义的东西又叫Sequence Points”?
    LZ我不得不怀疑你真的“理解”C语言么?或者只是一时口胡?

  6. 好多错别字~~~ 把“才会” 写成了 “再会”,把“扯淡”写成了“拉淡”~~望改正~~~

  7. 不清楚抽象机的模型就算了,sequence point这种几乎和expression的地位并列的概念是什么都讲不清楚……入门都成问题。
    深入理解?掰了点细节而已。
    “在C/C++中,表达的评估次序是没有标准定义的”?敢说清楚undefined和unspecified的区别么?
    “全局”变量?C++的就算了,确定C中真有这货?

  8. 最后一个,a 是地址 &a 还是地址,2个类型不同,一个是 int ,一个是int (*)[5],指向类型和大小有关。

  9. &a其实是被编译成了 &a[0]?又不得不怀疑LZ对C了解多少了。明明这里也不算是C的比较挫的阴暗面。
    我懒得剧透了,只是明确指出忽略“编译成了”的不妥当的说辞之后,这个说法是错的。提示:&a的值和&a[0]的值真的一致?数组名a作为表达式在什么情况下是左值?

  10. int b(void) { printf(“3”); return 3; }
    int c(void) { printf(“4”); return 4; }
    引号貌似不对。

  11. Regarding to the ‘&a’ in the statements:
    int a[5];
    printf(“%x\n”, &a);

    C FAQ 6.12: &a is a pointer to array of 5 ints

    int (*iptr)[5]=&a;

    in order to get the value of a[0], you will have to do: *(*iptr)

  12. 幻の上帝 :&a其实是被编译成了 &a[0]?又不得不怀疑LZ对C了解多少了。明明这里也不算是C的比较挫的阴暗面。我懒得剧透了,只是明确指出忽略“编译成了”的不妥当的说辞之后,这个说法是错的。提示:&a的值和&a[0]的值真的一致?数组名a作为表达式在什么情况下是左值?

    @幻の上帝

    楼主确实写的有很大问题,不是一个概念上的东西。
    “&a其实是被编译成了 &a[0]。 ”
    这个真的错的离谱了。第一个是指向数组的指针,第二是指向整形的指针. &a + 1 和 &a[0] + 1能一样吗?
    许多问题都没什么实际意义,这个确是概念上的绝对不能搞错的都没多少人来指正。当然应该是楼主为了表达某个意思随便写的。

  13. “关于这种未定义的东西又叫Sequence Points”orz

    你翻译人家的东西总要自己先看懂吧……

    1. Sequence Points是一种规则,也就是程序执行的序列点,在两点之间的表达式只能对变量有一次修改。我不是写教科书的,所以不能写得那么明确无误。不过谢谢你。

  14. int a[5];
    printf(“%x\n”, &a);
    这里的&a其实是被看成int(*)[5],所以sizeof才会是5*4

  15. @陈皓
    算了……既然意思是什么大概清楚,把你说的含糊的地方改掉吧。
    ISO C99
    5.1.2.3/2 Accessing a volatile object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects,11) which are changes in the state of the execution environment. Evaluation of an expression may produce side effects. At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place. (A summary of the sequence points is given in annex C.)
    ↑什么是sequence point。
    ISO C99
    4/2 If a “shall” or “shall not” requirement that appears outside of a constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this International Standard by the words “undefined behavior” or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe “behavior that is undefined”.
    6.5/2 Between the previous and next sequence point an object shall have its stored value
    modified at most once by the evaluation of an expression. Furthermore, the prior value
    shall be read only to determine the value to be stored.70)
    最后一个依据是判断具体的表达式没有sequence point的,整个Annex C+排中律,所以就算了。
    ↑这篇文章里的例子为什么未定义。

  16. @zy498420
    有问题。说的是形参的话没错,等价(类型都是指针)。要说实参的话……其实除了&和sizeof的操作数以及字符串字面量初始化之外的地方都有这种转换(类型从数组变成指针)。

  17. @亚当森
    不是“被看成int(*)[5]”,&a的类型就是int(*)[5]。倒是因为是printf的参数而且用%x所以可以看成unsigned……
    用printf输出指针最好用%p。

  18. 最后一个例子编译没通过,提示
    warning: format ‘%x’ expects type ‘unsigned int’, but argument 2 has type ‘unsigned int *’

  19. zy498420 :
    再次把那段著名的话搬出来:数组名可以被视作指针,(翻到c89文档下一页)当且仅当他作为函数实参传递给函数时。

    请教一下,如果标准对于数组到指针的隐式转换只有这么一种允许的情况的话,那么:
    int arr[] = {1, 2, 3};
    int* p = arr;
    这种代码该如何理解

  20. 没去度过标准,但是觉得 @幻の上帝 在24楼说的应该是对的。
    应该只有在声明函数形参时,数组名才被视作指针,其它情况下,都是隐式类型转换。
    只是很多国内的c教材,都会直接告诉读者数组就是指针。如谭浩强的那本,误导性极强。

  21. 水人儿 :

    zy498420 :
    再次把那段著名的话搬出来:数组名可以被视作指针,(翻到c89文档下一页)当且仅当他作为函数实参传递给函数时。

    请教一下,如果标准对于数组到指针的隐式转换只有这么一种允许的情况的话,那么:
    int arr[] = {1, 2, 3};
    int* p = arr;
    这种代码该如何理解
    什么乱七八糟的只有一种情况,C中如果在表达式中的数组名字就会转成指针,当然是指向特定类型的指针。int a[3][5];在表达式中a就会被转换成指向一个维度为5的数组的指针。printf(“%d\n”, sizeof(*a)的意思说明a是指向上述所说的指针,然后解引用得到一个一维数组,大小为20.同理printf(“%d\n”, *&a)了。至于楼主,printf(“%d %d\n”, *&a, *&a[0])就能看出区别。第一个解引用后得到的就是一个数组。

  22. @水人儿
    补依据。
    ISO C99
    6.3.2.1/3 Except when it is the operand of the sizeof operator or the unary & operator, or is a string literal used to initialize an array, an expression that has type “array of type” is converted to an expression with type “pointer to type” that points to the initial element of the array object and is not an lvalue. If the array object has register storage class, the behavior is undefined.
    PS.ISO C++类似,称为array-to-pointer conversion,属于standartd conversion。

  23. 但是,你知道为什么要字节对齐吗?还是因为性能。因为这些东西都在内存里,如果不对齐的话,我们的编译器就要向内存一个字节一个字节的取,这样一来,struct X,就需要取9次,太浪费性能了,而如果我一次取4个字节,那么我三次就搞定了。所以,这是为了性能的原因。
    —————————————————-
    我想说,这帖子一点都不深入……

    即使字节对齐,也可以按字节取,这玩意不完全取决于编译器,取决于硬件。
    对于某些硬件来说,即使字节对齐了,也要强制按byte读取
    某些时候为了处理流数据,要引入#pragma大神强制不对齐

  24. 推荐楼主把本文全文删除,感觉这个话题不太适合楼主,也没啥新东西。气场也震不住来吐槽的人。呵呵

  25. 借这个文章希望各位前辈能够解答我长久以来的几个问题,谢谢。

    以局部变量为例:
    int arr[] = {1, 2, 3};
    int *p = “123”;

    p在栈区,{1,2,3}在文字常量区(但是我至今也不知道文字常量区在内存哪里,是不是在text区?①),所以p的地址和”123″的地址完全不一样,据说文字常量区根本不能寻址。
    而arr在栈区,{1,2,3}也在栈区,有文章说arr和{1,2,3}完全在一起,所以arr的地址就是arr[1]的地址,这里我不理解,arr和{1,2,3}在内存里面到底如何排列的?而且如果{1,2,3}在栈区,那不是此时的{1,2,3}就可以寻址了?②

    再看局部变量时候的情况
    int arr[] = {1, 2, 3};
    int *p = arr;

    arr和p都存放在data区,但是此时的{1,2,3}又应该在内存的哪里呢?③

    以上①②③问题,再谢。

  26. @nswutong
    手误,问题改下

    以局部变量为例:
    int arr[] = {1, 2, 3};
    int *p = “123″;

    p在栈区,”123″在文字常量区(但是我至今也不知道文字常量区在内存哪里,是不是在text区?①),所以p的地址和”123″的地址完全不一样,据说文字常量区根本不能寻址。
    而arr在栈区,{1,2,3}也在栈区,有文章说arr和{1,2,3}完全在一起,所以arr的地址就是arr[1]的地址,这里我不理解,arr和{1,2,3}在内存里面到底如何排列的?而且如果{1,2,3}在栈区,那不是此时的{1,2,3}就可以寻址了?②

    再看全局变量时候的情况
    int arr[] = {1, 2, 3};
    int *p = arr;

    arr和p都存放在data区,但是此时的{1,2,3}又应该在内存的哪里呢?③

  27. 看来c的粉丝真多,似乎在哪儿看到过说,指针就是指针,数组就是数组,是不能等同的,还有就是数组名作为左值和作为右值是不一样的,以及作为函数参数也有变化….如int arry[10][10],那么 a,&a,&a[0],&a[0][0]其实是有不同的意思,虽说打印的值是一样的..

  28. Charlie :
    我个人觉得这三条设计原则都有值得推敲的地方。
    1)相信程序员,不阻止程序员做他们想做的事。
    是不是在某些情况下,适当地做些限制会更好?现实中并不是所有程序员都能相信的。
    2)保持语言的简洁,以及概念上的简单。
    简洁这个词太过于抽象,汇编语言也很简洁,概念上更简单啊。
    3)保证性能,就算牺牲移植性。
    谁都要性能,关键是把性能放在多高的优先级。在如今的硬件性能下,是否可以和适当地降低性能的优先级。

    呵呵 兄弟看样子是JAVA粉

  29. 特例有千变万化,我们只需要知道标准写法出现哪种结果就足够了。没必要把时间拿来研究这些特例

  30. @nswutong
    和c++一样,局部定义的引用(局部变量),编译器优化处理的结果是:arr一般都不占(栈)空间。因为引用是固定不能变化的。这就是楼上你注意到arr和实际的数组居然地址会一样的原因。

    对于常见的c++二进制内存模型,只有引用本身是一个实参或者非静态成员或者局部静态变量时,才会占一个指针的空间。全局变量或全局静态变量引用占的空间其实也可以在链接时优化掉(不一定所有的编译器都支持)。

  31. 局部定义本质就是一个常量(指针),所以会被编译器优化,但是未必指向一个常量。“12345678”这是一个指向也是常量的例子。

  32. 指针。。。地址。。。数组。。。搞来搞去就这么几件事(RISC示例):

    lw v0, 0(a0) ;load from mem

    addu t0, gp, -1234 ;load from .data
    lw v0, 4(t0)

    addiu sp, sp, -48 ; store/restore on/from stack
    sw s0, 24(sp)

    lw v1, 0(a0) ; pointer to pointer
    lw v0, 8(v1)

  33. @nswutong
    太纠结了,如果真想弄明白,看看汇编代码
    比如 int a = 5;
    可能就是 mov esp-16, 5, //5不过是个立即数,是不占用空间的

  34. 代码中引号有中文的 printf(“3”); 用 quotmarks-replacer 插件
    又是 42,它就是 int 类型的 “Hello World”
    相信程序员 的语言设计 这点说的到位,其实它就是汇编的一个前端语言
    另外 C 语言也是 小内核语言,能够自举的语言
    《谁说 C 语言很简单》,简单是美,我一直认为简单是赞美之词,程序员做的最重要的一件事就是化繁为简

  35. 这篇是投稿到了CSDN啊,哈
    用来纪念大师,不错!

    至于那些争论,个人觉得:选择合适的工具,做合适的事!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注