cs107 编程范式(十二)

376 阅读6分钟

编译和链接

首先,先看一个例子

#include <stdlib.h>
#include <stdio.h>
#include <assert.h>

int main() {
    void* mem = malloc(400);
    assert(mem != NULL);
    printf("Yay!hi");
    free(mem);
    return 0;
}

如果使用gcc编译器的话,会生成两个文件一个是mam.o,一个是a.out文件。如下图所示,这里名字是自定义的。

新建 Microsoft PowerPoint 演示文稿1.png

在链接的过程中不同的.o文件和标准库文件会进行组合,生成一个可执行文件(.out)。

现在我们对当前代码进行一定的改动,来看看对编译和链接会产生什么样的影响。

我们把#include <stdio.h>这行代码注释掉,大部分人会认为编译器会报错。因为如果不包含stdio.h头文件,那么在预处理处理之后,文件中不会包含printf函数的声明。但是这对部分编译器来说是正确的,对gcc来说,它只会报警告,但不会出错。

这是因为gcc编译器会判断当前语句像不像一个函数调用,它会根据这个函数调用来推测函数原型。例如对于printf函数,gcc编译器没有发现相应的函数声明,它会报一个警告,然后认为该函数的参数是一个字符串参数,只要与函数的定义相符合,这个函数调用就不会报错(gcc会将函数的返回值推测为int类型)。但是在当前printf函数之后再次调用printf函数的话,参数必须也是一个字符串参数,因为函数原型是推测出来的,它与实际的函数原型对比还是稍有区别的。

如果我们把stdlib.h头文件注释掉了,这时编译器会发出3个警告。当执行void* mem = malloc(400)这一句的时候,会推测malloc函数的原型是参数为int类型,返回值为int类型的函数。这时会产生两个警告,一是缺少函数声明,二是int类型赋值为void*类型。当执行到free时,会产生一个警告,缺少函数声明。

这里之所以会正确执行,是因为头文件所做的事情就是告诉编译器有哪些函数原型,而并没有说明这些函数的代码在哪里。链接阶段则负责去标准库中寻找这些代码。

如果我们把#include <assert.h>注释掉的话,编译器会认为assert(mem != NULL)是一个参数为布尔值,返回值为int类型的函数,因此在编译期间只会报出警告。在链接期间,由于标准库中不存在对应的函数,所以会报错。

原型存在的原因是为了让调用者和被调用者关于savedPC上面的活动记录的布局达成一致(也就是让函数的调用参数符合调用参数类型规定)。原型其实只涉及参数在活动记录中位于saved PC之上,在saved PC之下的部分则是被调用者负责的。当我们调用printf函数对应的代码时,我们需要确保活动记录中上半部信息的格式对应调用者和被调用者来说是一致的,我将一个字符串常量的地址作为参数,然后被调用者接管继续执行,printf函数接管之后会按照函数原型的形式来进行处理。

int main() 
{
    int num = 65;
    int length = strlen((char*)&num, num); 
    printf("length = %d\n", length);
    return 0;
}

你可能会觉得在链接时,会发现strlen只有一个参数而报错。但是实际上并不是这样的,在.o文件中并没有记录下某个调用有多少参数。在链接的时候,gcc只会考察符号名称,而并不检查形参类型。这样一来,函数的调用和签名就对应不上了,但是链接接阶段并不会管它,链接阶段做的事情只是查找strlen的定义。当汇编代码跳转到strlen函数中时,由于strlen函数中只有一个char*类型的参数,因此相应的汇编代码只会对图中这部分进行操作

捕获1.PNG

对应上述代码,老师的原话是只会报一个警告,但是我在测试的过程中会发生错误。想要解决这个问题,需要使用如下代码。

int strlen(char *s, int len);

int main() {
    int num = 65;
    int length = strlen((char*)&num, num);
    printf("length = %d\n", length);
    return 0;
}

对于大端系统来说,length等于0,这是因为num会被看做0x00000041,读到第一个字节就是0。对于小端系统来说,length等于1,这是因为num会被看做0x41000000,读到第二个字节为0。

int memcmp(void* v1);

{
    int n = 17;
    int m = memcmp(&n);
}

对于上述代码,程序在编译的过程中不会报错,但是在运行中可能会发生未知的错误。这是因为,函数的定义是如下所示

int memcmp(void *v1, void* v2, size_t num) {
...
}

函数会将saved pc上方的12个字节的数据认为成参数。(32位)

因此,可以看出gcc编译器给我们更大的自由,也更容易出错。

另外,在c++中可以进行函数重载,而在c中则不行。这是因为在编译时,c直接使用函数名作为符号,而c++则是根据参数和其对应的类型来进行生成。

C: CALL <memcmp>
C++: CALL <memcmp-void-p-void-p-int>

从上我们可以看出当c++中参数类型与定义出不符时,产生的符号也不同,所以c++在这一方面更加安全。

常见错误

seg fault: 对一个错误指针的解引用时出现。 bus errors

对于bus errors,我们来看一个例子

void *vp = ...;
*(short*)vp = 7;

当vp是奇地址时,就会发生bus errors。这是因为硬件会希望short类型的起始地址是偶数地址。

*(int*)vp = 4;

如果vp的首地址不是4的倍数,也会发生bus errors。

缓冲区溢出

int main() {
    int i;
    int array[4];
    for(i = 0; i <= 4; i++)
        array[i] = 0;
    return 0;
}

新建 Microsoft PowerPoint 演示文稿2.png

当执行到array[4] = 0时,会将i再次赋值为0,因此会进行无限循环。

int main() {
    int i;
    short array[4];
    for(i = 0; i <= 4; i++)
        array[i] = 0;
    return 0;
}

对于大端系统来说,可以正常执行;但是小端系统,则会陷入无限循环。大家可以尝试自己分析一下。

int main() {
    int array[4];
    int i;
    
    for(int i = 0; i <= 4; i++)
        array[i] -= 4;
       
    return 0;

对于这个程序来说,array[4]位置是saved pc,即call function后面一句的位置。当执行了-4操作之后,saved pc又指向了call function。因此,程序会陷入无限函数调用中。

注:这里的内存模型是老师自己定义的。