5

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

 3 years ago
source link: https://blog.popkx.com/explanation-of-classic-interview-questions-in-c-language-section-20/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

同样一个问题,可能新手程序员和高手程序员都能解决,但是高手程序员往往能够写出运行效率更高的程序,这一点在C语言程序开发中尤为明显。这主要是因为高手们技术功底更扎实,能够对编写的代码做出适当的优化,写出较少冗余啰嗦的代码段。

3dcdb3f8f5f0b900ac7be4991d65fe19.png

“自作聪明”的C语言编译器

不过,即使高手程序员也是从新手走过来的,C语言能否对新手友好一点呢?当然可以了,事实上,现代C语言编译器已经很“聪明”了,自己就懂得对代码做一定的“优化”,尽可能的提升最终编译出的C语言程序的运行效率。

编译器常用的优化方法有:调整指令的执行顺序,充分利用CPU的指令流水线,以及将内存变量缓存到寄存器。因为CPU读写寄存器的速度远大于读写内存的速度,所以将内存变量缓存到寄存器可以提升最终程序运行的效率,这一点和将磁盘数据缓存到内存是一致的。

但是遗憾的是,C语言编译器有时会“聪明过了头”,自以为是的把游泳的语句优化掉了,反而导致程序不能正常工作。例如下面这几句代码,请看:

int led;
led = 0x01;
led = 0x02;
3de96012277c64cca96b37bb3bd66801.png

编译器在处理上面这几行C语言代码时,可能会认为 led=0x10; 没有意义,因为程序根本没有使用到该值,led 紧接着就被 0x02 覆盖了,所以此时编译器认为上面的C语言代码是和下面这两行C语言代码等价的:
int led;
led = 0x02;

这显然是不合适的。因为C语言常常用来编写一些硬件的驱动程序,如果 led 是某个硬件的控制寄存器,那么就算是简单的赋值也是有用的。假如 led = 0x01; 是开启第一个灯,led = 0x02; 是开启第二个灯,经过编译器优化之后,第一个灯就没能成功开启了。

这样“自作聪明”的编译器可能会害的开发人员怀疑设计的硬件有问题,O(∩_∩)O。

可能有的读者使用C语言并不编写硬件的驱动程序,那是不是就不会遇到编译器“自作聪明”带来的问题了呢?在看了下面这个实例后,相信读者自己就能得到答案了。

f9c0c2b38977b70974f6cebca51a90cc.png

来看看这个面试题

下面这道题目来自美国某著名嵌入式软件开发公司的一道面试题:

下面这段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;
}
1aa9de8e6a1dd2c7db69c3fccb652210.png

从上述C语言代码来看,main() 函数在创建线程 thread() 后,就开始等待 is_continue 变为 0,之后会输出 "hello world"。thread() 函数的确在睡眠一秒后,将 is_continue 置 0,这么看来,这段C语言程序应该会在约 1 秒后输出“hello world”了?

再来看看面试问题,题目要求使用 gcc 的 -O1 编译项,-O 表示编译器的优化等级,如下图,不指定优化等级时,默认时 -O0 优化项。

c475e6a208edab7d794c844e4eae6c55.png

现在我们编译上面这段C语言程序,执行之,得到如下结果:

c044ad96fb5560cf8d871f4ddfb36087.png

等待若干秒后,仍然不见程序输出“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;
}
cbe43ac2961b0307c2a4d4fa3475d5a2.png

编译并执行修改后的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 中,以保持一致。

ef84504b5c4b83aae90984c4129b5441.png

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
...

b4c2de7b2e135287afdaf0c791ddd49e.png

既然编译器优化这么不靠谱,是不是就不应该再使用它的优化选项了呢?

当然不是了,没必要因为这点小问题,就否定编译器的所有优化工作。事实上,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
721f8a1e9987ec6ea71e0948293ca151.png

使用 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;
}
918eb6cab38b0649859f7632547f4c9e.png

因为 *ptr 的值时易变的,随时都可能被改变,如果在执行 a=* ptr; 之后,b=* ptr; 之前,* ptr 的值改变了,a,b 就不相等了,square() 函数显然无法达到计算平方值的目的。因此 square() 函数更合适的定义应该时下面这样的:
int square(volatile int *ptr)
{
    int a = *ptr;
    return a*a;
}

从上面的例子可以看出,使用C语言编译器的优化项要求程序员具备扎实的基础,否则程序会表现出“难以理解”的现象。本节讨论了 volatile 关键字,它能够确保C语言程序每次读写变量都是直接从变量的原始内存中读写的,避免被编译器优化。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK