C专家编程 第2章 这不是Bug,而是语言特性 2.3 误做之过

发布于:2023-02-03 ⋅ 阅读:(340) ⋅ 点赞:(0)

    误做之过 就是程序中有误导性质或是不适当的特性
    例如与C语言的简洁有关(部分与符号的过度复用有关),与操作符的优先级有关。
    骆驼背上的重载
    C语言存在的一个问题就是它太简洁了,仅增加、修改或删除一个字符就会使原先的程序变成另外一个仍然有效却全然不同的程序。更糟的是,许多符号是被“重载”的---在不同的上下文环境里有不同的意义。甚至有些关键字也因重载而具有好几种意义,这也是C语言的作用域规则对程序员不那么清晰的主要原因。
           C语言中的符号重载
    符号                 意义
    static                在函数内部,表示该变量的值在各个调用间一直保持延续性
                            在函数这一级,表示该函数只对本文件可见
    extern              用于函数定义,表示全局可见(属于冗余的)
                            用于变量,表示它在其他地方定义
    void                 作为函数的返回类型,表示不返回任何值
                            在指针声明中,表示通用指针的类型
                            位于参数列表中,表示没有参数
    *                       乘法运算符
                            用于指针,间接引用
                            在声明中,表示指针
    &                      位的AND操作符
                            取地址操作符
    =                      赋值符
    ==                    比较运算符
    <=                    小于等于运算符
    <<=                  左移复合赋值运算符
    <                      小于运算符
                            #include指令的左定界符
    ()                      在函数定义中,包围形式参数表
                            调用一个函数
                            改变表达式的运算次序
                            将值转换为其他类型(强制类型转换)
                            定义带参数的宏
                            包围sizeof操作符的操作数(如果它是类型名)
    除此之外,还有一些符号具有多个容易混淆的意思。
    p = N * sizeof *q;
    提示:接下来的一条语句是:
    r = malloc(p); 
    答案是这里只有一个乘号,因为sizeof操作符把指针q指向的东西(即*q)作为操作数,它返回q所指向对象的类型的字节数,便于malloc函数分配内存。当sizeof的操作数是一个类型名时,两边必须加上括号(这常常使人误会它是个函数),但操作数如果是变量则不必加括号。
    //1.
    #include <stdio.h>
    #include <stdlib.h>
    
    int main() {
        //1.1
        int p;
        const int N = 10;
        int r = 100;
        int *q = &r;
        /* 第一个“*”是乘号,第二个“*”是解引用符号 
         * *q作为sizeof的操作数
         */ 
        p = N * sizeof *q;
        printf("p = %d\n", p);
        //1.2
        int size = 10;
        int *psize = (int *)malloc(sizeof(int) * size);
        int test = 100;
        int apple = sizeof(int) * test;
        /* int apple_2 = sizeof(int) *psize; 
        ** can't pass compilation:
        ** [Error] invalid operands to binary * (have 'long long unsigned int' and 'int *')
        */ 
        free(psize);
        /*apple表示sizeof(int)乘以test的结果*/ 
        printf("apple = %d\n", apple);
        
        return EXIT_SUCCESS;
    }
输出:

    一个符号所表达的意思越多,编译器就越难检测到这个符号在你的使用中所存在的异常情况。

    //2.
    “有些运算符的优先级是错误的”
    *p.f == *(p.f);
    int *ap[] == int *(ap[]);
    int *fp() == int *(fp());
    val & mask != 0 == val & (mask != 0)
    c = getchar() != EOF == c = (getchar() != EOF)
    msb << 4 + lsb ==  msb << (4 + lsb)
    i = 1, 2 == (i = 1), 2
    我们知道逗号运算符的值就是最右边操作数的值。但在这里,赋值符的优先级更高,所以实际情况应该是(i = 1),2 //i的值为1
    i赋值为1,接着执行常量2的运算,计算结果丢弃。最终,i的结果是1而不是2。

    软件信条
    &&、||操作符的优先级关系问题是怎样产生的
    在C的早期,&和&&合用同一个操作符,|和||也是如此,它继承了B和BCPL中的概念“真值上下文”。就是在if和while等后面需要一个布尔值的时候,&和|就被解释成现在的&&和||。如果在一般的表达式里,就被解释成位操作符,也就是现在的样子。
    在真值上下文里,存在“顶层运算符”的概念。&和|的优先级跟现在一样。
    if (a == b & c == d)
    计算次序
    在表达式中如果有布尔操作,算术运算,位操作符等混合运算,始终应该在适当的地方加上括号,使之清楚明了。
    在优先级和结合性规则告诉你那些符号组成一个意群的同时,这些意群内部进行计算的次序始终是未定义的。
    有些专家建议:乘法和除法先与加法和减法,在涉及其他的操作符时一律加上括号,这是一条很好的建议。

    //3.
    x = f() + g() * h();
    g()和h()的返回值先组成一个意群,执行乘法运算,但g()和h()的调用可能以任何顺序出现(g()的调用不一定早于h())。类似,f()可能在乘法之前也可能在乘法之后调用,还可能在g()和h()之间调用。唯一可以确定的就是乘法会在加法之前执行(因为乘法的结果就是加法运算的操作数之一)。大部分编程语言并未明确规定操作数计算的顺序。之所以未做定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器中的值。
    小启发
    “结合性”是什么意思?
    结合性:它是仲裁者,在几个操作符具有相同的优先级是决定先执行哪一个。
    每个操作符拥有某一级别的优先级,同时也拥有左结合性或右结合性。在一个不含括号的表达式中,优先级决定了操作数之间的“紧密”程度。例如,在表达式a * b + c中,先执行乘法a * b,而不是加法b + c。
    许多操作符的优先级是相同的。这时,操作符的结合性就开始发挥作用了。在表达式中如果有几个优先级相同的操作符,结合性就起仲裁的作用,由它决定哪个操作符先执行。像下面的表达式:
    int a, b = 1, c = 2;
    a = b = c; 
    所有的赋值符(包括复合赋值符)都具有右结合性,就是说表达式中最右边的操作最先执行,然后从右到左依次执行,左结合性与之相反。
    结合性只用于表达式中出现两个以上相同优先级的操作符的情况。
    如果计算表达式的值时需要考虑结合性,那么最好把这个表达式一分为二或者使用括号
    大部分表达式里各个操作数计算的顺序就是不确定的,它的目的是为了让编译器设计者选取最合适的方法来产生最快的代码。 
    &&和||操作符严格按照从左到右的顺序依次计算两个操作数,当结果提前得知时便忽略剩余的计算。但是,在函数调用中,各个参数的计算顺序是不确定的。
    //4.
    早期gets()函数中的Bug导致了Internet蠕虫
    人们发现蠕虫繁殖的途径之一就是脆弱的finger防护进程,这个称为in.fingerd的finger防护进程,使用了标准I/O库函数gets()。 
   gets()函数正式的任务是从流中读入一个字符串。它的调用者会告诉它把读入的字符放在什么地方,但是,gets函数并不检查缓冲区的空间,事实上它也无法检查缓冲区的空间。如果函数的调用者提供了一个指向堆栈的指针,并且gets()函数读入的字符数量超过了缓冲区的空间,gets()函数愉快地将多出来的字符继续写入到堆栈中,这就覆盖了堆栈原来的内容。

    int main(argc, argv)
        char *argv[]; {
        char line[512];
        ...
        gets(line);
    }

    line是个能容纳512字符的数组,它是在堆栈上自动分配的。当用户的输入超过了finger防护规定的512字符时,gets()函数将会继续把多出来的字符压到堆栈中。
    可以在字符串实参中设置正确的二进制模式来修改堆栈中的过程活动记录,改变函数的返回地址。结果,程序的执行流不会返回到函数调用点的位置,而是跳转到一个特殊的指令序列(也是精心布置在堆栈中的),它将调用execv()函数用一个Shell替换正在运行的映像程序。这样,就是与远程机器上的Shell对话,而不是finger防护进程。你可以发布命令,把一份病毒的副本传播到其他的机器上。 
            黑客的机器                                    提供finger服务的machine2
            输入命令    ---      网络连接    ---    目标机器
        向machine2的finger程序                1.finger防护程序启动 
        发送“非常长的字符串,包括           2.从堆栈中gets()它的参数 
        二进制数据”                                    3.“非常长的字符串”改写返回地址
                                                               4.把控制流转到同样位于堆栈上的黑客程序
                                                               5.用Shell取代finger进程
                Internet蠕虫如何获得远程机器的控制特权 

    具有讽刺意味的是,gets()函数是个过时的函数,用于和最初版本的可移植的I/O函数库保持兼容并已在十多年被标准I/O库函数所取代。在C语言的官方手册中,强力建议用fgets()函数彻底取代gets()函数。fgets()函数对读入的字符数设置了一个限制,这样就不会超出缓冲区范围,应该把gets(line);替换为: 
    if (fgets(line, sizeof(line), stdin) == NULL) {
        exit(1);
    }

本文含有隐藏内容,请 开通VIP 后查看