同样一个问题,可能新手程序员和高手程序员都能解决,但是高手程序员往往能够写出运行效率更高的程序,这一点在C语言程序开发中尤为明显。这主要是因为高手们技术功底更扎实,能够对编写的代码做出适当的优化,写出较少冗余啰嗦的代码段。
“自作聪明”的C语言编译器
不过,即使高手程序员也是从新手走过来的,C语言能否对新手友好一点呢?当然可以了,事实上,现代C语言编译器已经很“聪明”了,自己就懂得对代码做一定的“优化”,尽可能的提升最终编译出的C语言程序的运行效率。
编译器常用的优化方法有:调整指令的执行顺序,充分利用CPU的指令流水线,以及将内存变量缓存到寄存器。因为CPU读写寄存器的速度远大于读写内存的速度,所以将内存变量缓存到寄存器可以提升最终程序运行的效率,这一点和将磁盘数据缓存到内存是一致的。
但是遗憾的是,C语言编译器有时会“聪明过了头”,自以为是的把游泳的语句优化掉了,反而导致程序不能正常工作。例如下面这几句代码,请看:
int led;
led = 0x01;
led = 0x02;
编译器在处理上面这几行C语言代码时,可能会认为 led=0x10; 没有意义,因为程序根本没有使用到该值,led 紧接着就被 0x02 覆盖了,所以此时编译器认为上面的C语言代码是和下面这两行C语言代码等价的:
int led;
led = 0x02;
这显然是不合适的。因为C语言常常用来编写一些硬件的驱动程序,如果 led 是某个硬件的控制寄存器,那么就算是简单的赋值也是有用的。假如 led = 0x01; 是开启第一个灯,led = 0x02; 是开启第二个灯,经过编译器优化之后,第一个灯就没能成功开启了。
这样“自作聪明”的编译器可能会害的开发人员怀疑设计的硬件有问题,O(∩_∩)O。
可能有的读者使用C语言并不编写硬件的驱动程序,那是不是就不会遇到编译器“自作聪明”带来的问题了呢?在看了下面这个实例后,相信读者自己就能得到答案了。
来看看这个面试题
下面这道题目来自美国某著名嵌入式软件开发公司的一道面试题:
下面这段C语言代码使用 gcc -O1 编译执行,会输出什么?解释原因。
相关C语言代码如下,请看:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int is_continue = 1;
void* thread(void* p)
{
sleep(1);
is_continue = 0;
return NULL;
}
int main()
{
pthread_t ppid;
pthread_create(&ppid, NULL, thread, NULL);
while(is_continue);
printf("hello world\n");
return 0;
}
从上述C语言代码来看,main() 函数在创建线程 thread() 后,就开始等待 is_continue 变为 0,之后会输出 "hello world"。thread() 函数的确在睡眠一秒后,将 is_continue 置 0,这么看来,这段C语言程序应该会在约 1 秒后输出“hello world”了?
再来看看面试问题,题目要求使用 gcc 的 -O1 编译项,-O 表示编译器的优化等级,如下图,不指定优化等级时,默认时 -O0 优化项。
现在我们编译上面这段C语言程序,执行之,得到如下结果:
等待若干秒后,仍然不见程序输出“hello world”,程序应该陷入死循环了。为了验证猜想,对 main() 函数做适当修改——在while循环里增加打印语句,修改后的C语言代码如下,请看:
int main()
{
pthread_t ppid;
pthread_create(&ppid, NULL, thread, NULL);
int cnt = 0;
while(is_continue){
printf("cnt: %d\n", cnt++);
}
printf("hello world\n");
return 0;
}
编译并执行修改后的C语言代码,得到如下输出结果:
cnt: 0
cnt: 1
cnt: 2
...
cnt: 15406
cnt: 15407
cnt: 15408
cnt: 15409
...
这证明程序的确卡死在 while 循环里了。这是怎么回事呢?其实这也是C语言编译器“自作聪明”的优化了代码的结果,请看下面的分析:
在 main() 函数中读取 is_continue 变量时,为了提升读写速度,编译器优化时会把 is_continue 读取到寄存器 R 中,之后的 while 循环再需要读取时,就不再从 is_continue 的内存中读取了,而是直接从寄存器 R 中取值。如果在 main() 函数中还有其他代码修改了 is_continue,则会将修改后的新值覆盖到寄存器 R 中,以保持一致。
thread() 函数将变量 is_continue 的值由 1 修改为 0 时,main() 函数中寄存器 R 的值不会被改变,所以 main() 函数中的 while 循环条件始终为真,导致程序陷入死循环。
使用 objdump 命令查看编译后的 C语言程序的汇编代码,发现的确如此,while 循环并没有每次都从内存读取 is_continue 的值,而是始终比较 eax 寄存器。
$ gcc t.c -lpthread -O1 -g
$ objdump -dS a.out
a.out: file format elf64-x86-64
...
既然编译器优化这么不靠谱,是不是就不应该再使用它的优化选项了呢?
当然不是了,没必要因为这点小问题,就否定编译器的所有优化工作。事实上,C语言编译器为了避免“自作聪明”的优化程序员不希望优化的代码,提供了“volatile”关键字,它的一个使用例子如下:
volatile int led;
led = 0x01;
led = 0x02;
“volatile”的字面意思是“不稳定的,易变的”,使用它来修饰变量,编译器就知道了不该对此变量做优化,就算自己看着非常不爽,也只能全部照样执行。所以,将 is_continue 定义为 volatile 就能够避免上述面试题陷入死循环,因为 volatile 修饰的变量能够避免被编译器做优化,C语言程序每次读写 is_continue 时,都会直接从它原始内存中读写。
现在仅将 is_continue 修改为 volatile 的,如下:
volatile int is_continue = 1;
再次编译这段C语言程序并执行,发现输出终于与预期一致了(约1秒后,输出“hello world”):
# gcc t.c -O1 -lpthread
# ./a.out
hello world
使用 volatile 的一些注意事项
频繁的使用 volatile 显然很可能会增加代码尺寸以及降低性能,因此应合理的使用 volatile。另外,因为 volatile 修饰的变量时“易变的”,所以使用起来也要小心,下面这个题目我在不止一家公司的面试题中见过,请看:
int square(volatile int *ptr)
{
return (*ptr) * (*ptr);
}
square 函数用于计算一个整数的平方值,上面这段C语言代码有什么问题吗?
当然有问题了,编译器在编译 square 函数时,可能会将其与下面的C语言代码等价:
int square(volatile int *ptr)
{
int a = *ptr;
int b = *ptr;
return a*b;
}
因为 *ptr
的值时易变的,随时都可能被改变,如果在执行 a=* ptr; 之后,b=* ptr; 之前,* ptr 的值改变了,a,b 就不相等了,square() 函数显然无法达到计算平方值的目的。因此 square() 函数更合适的定义应该时下面这样的:
int square(volatile int *ptr)
{
int a = *ptr;
return a*a;
}
小结
从上面的例子可以看出,使用C语言编译器的优化项要求程序员具备扎实的基础,否则程序会表现出“难以理解”的现象。本节讨论了 volatile 关键字,它能够确保C语言程序每次读写变量都是直接从变量的原始内存中读写的,避免被编译器优化。