C语言经典面试题详解第20节

同样一个问题,可能新手程序员和高手程序员都能解决,但是高手程序员往往能够写出运行效率更高的程序,这一点在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语言程序每次读写变量都是直接从变量的原始内存中读写的,避免被编译器优化。

阅读更多:   C语言
添加新评论

icon_redface.gificon_idea.gificon_cool.gif2016kuk.gificon_mrgreen.gif2016shuai.gif2016tp.gif2016db.gif2016ch.gificon_razz.gif2016zj.gificon_sad.gificon_cry.gif2016zhh.gificon_question.gif2016jk.gif2016bs.gificon_lol.gif2016qiao.gificon_surprised.gif2016fendou.gif2016ll.gif