目录

数组与指针

数组指针

指针数组

函数指针

函数指针数组

const和指针

sizeof和指针和数组

strlen和字符数组

库函数的模拟实现

memcpy

memmove

strstr

strcpy

strlen(unsigned int)

strcmp

自定义类型

内存对齐

联合

一、引言

二、共用体的定义和基本语法

三、共用体的使用

3.1 声明共用体变量

3.2 给共用体成员赋值

3.3 共用体的内存布局

四、共用体的应用场景

4.1 节省内存空间

4.2 处理不同类型的数据

五、共用体使用的注意事项

六、总结

数据存储

整形存储规则,原反补

原码、反码和补码

使用补码存储的原因

不同类型的整形存储

大小端及如何判断

什么大端小端:

浮点数存储(了解)

IEEE 754标准

存储格式的转换

浮点型存储的精度问题

总结

类型提升和截断

 一、类型提升(自动转换,保精度)

二、截断(强制转换,可能丢数据)

 三、避坑关键点

编译链接

 宏

 一、 #define  基础:标识符与宏定义

二、 #define  高级技巧

三、文件包含( #include )

四、条件编译(按需求选择性编译)

五、其他预处理指令

六、宏 vs 函数(核心区别)

编译链接过程

(一)预处理

(二)编译

(三)汇编

(四)链接

一、预处理阶段:代码的初步雕琢

(一)#include 指令:头文件的无缝嵌入

(二)#define 指令:灵活多变的文本替换

(三)条件编译指令:按需编译的智慧选择

二、编译阶段:代码的深度解析与转换

(一)词法分析:字符流的精准拆分

(二)语法分析:构建代码的逻辑结构

(三)语义分析:确保代码的语义准确性

(四)代码生成:迈向机器语言的关键一步

三、汇编阶段:从符号语言到机器指令的飞跃

四、链接阶段:整合资源,成就可执行程序

main.c

utils.c

五、实际操作与工具使用


数组与指针

数组指针

数组指针本质还是指针

int*p1[10]

int(*p1)[10]

第一个是指针数组,第二个是数组指针,

 解释:p先和*结合,说明p是一个指针变量,然后指着指向的是一个大小为10个整型的数组。所以p是一个指针,指向一个数组,叫数组指针。
//这里要注意:[]的优先级要高于*号的,所以必须加上()来保证p先和*结合

若是二维数组int arr[3][5];

int(*p1)[10]中*p指向数组中第一个元素的地址,及&arr[0],他内部又有5个连续空间构成一维数组。

*(*p+i)*(*(p+i))区别

*(*p+i)访问第一个一维数组的第i下标元素

*(*(p+i))访问第i个一维数组的首元素

指针数组

本质:数组元素均为指针的数组。
定义: 数据类型 *数组名[长度] ,如 int *p[3]; (3个int指针)。
作用:存储多个地址(如变量、数组、字符串地址),便于批量操作。
例: char *strs[] = {"a", "b"}; (指针数组存储字符串常量地址)。
与数组指针区别:先算 [] (数组),元素是指针。

函数指针

- 本质:指向函数的指针变量,存储函数的入口地址。
- 定义格式: 数据类型 (*指针名)(参数类型列表) 
例: int (*ptr)(int, int); (指向返回值为 int 、参数为 int,int 的函数)。
- 赋值:指针名 = 函数名(无需取地址符 & )

int add(int a, int b) { return a+b; }  
ptr = add;  // 或 ptr = &add;(可选) 

 
 
- 调用: (*指针名)(参数)  或  指针名(参数) 

int result = (*ptr)(3, 5);  // 或 ptr(3,5);  

- 用途
- 回调函数:作为参数传入其他函数,实现灵活逻辑(如排序函数的比较规则)。
- 函数跳转表:通过数组存储多个函数指针,根据条件调用不同函数。
 

int (*funcs[])(int) = {func1, func2, func3};  
int res = funcs[1](10);  // 调用func2  


 
- 与指针函数区别
- 函数指针: (*ptr)() ,先算 *ptr (指针解引用),再算 () (函数调用),即指向函数的指针。
- 指针函数: ptr() ,先算 () (函数调用),再算 * (返回指针),即返回值为指针的函数。

函数指针数组


- 本质:数组元素为函数指针,存多个函数入口地址。
- 定义: 数据类型 (*数组名[长度])(参数列表) ,如 int (*fp[2])(int,int); 
- 初始化:直接赋值函数名(如 fp[0]=add; )。
- 调用: 数组名[i](参数)  或  (*数组名[i])(参数) 。
- 用途:函数调度表、替代条件判断,提升代码灵活性。

const和指针

sizeof和指针和数组

//一维数组

int a[] = {1,2,3,4};
printf("%d\n",sizeof(a));
printf("%d\n",sizeof(a+0));
printf("%d\n",sizeof(*a));
printf("%d\n",sizeof(a+1));
printf("%d\n",sizeof(a[1]));
printf("%d\n",sizeof(&a));
printf("%d\n",sizeof(*&a));
printf("%d\n",sizeof(&a+1));
printf("%d\n",sizeof(&a[0]));
printf("%d\n",sizeof(&a[0]+1));

//字符数组

char arr[] = {'a','b','c','d','e','f'};
printf("%d\n", sizeof(arr));
printf("%d\n", sizeof(arr+0));
printf("%d\n", sizeof(*arr));
printf("%d\n", sizeof(arr[1]));
printf("%d\n", sizeof(&arr));
printf("%d\n", sizeof(&arr+1));
printf("%d\n", sizeof(&arr[0]+1));

char arr[] = "abcdef";
printf("%d\n", sizeof(arr));
printf("%d\n", sizeof(arr+0));
printf("%d\n", sizeof(*arr));
printf("%d\n", sizeof(arr[1]));
printf("%d\n", sizeof(&arr));
printf("%d\n", sizeof(&arr+1));
printf("%d\n", sizeof(&arr[0]+1));

char *p = "abcdef";
printf("%d\n", sizeof(p));
printf("%d\n", sizeof(p+1));
printf("%d\n", sizeof(*p));
printf("%d\n", sizeof(p[0]));
printf("%d\n", sizeof(&p));
printf("%d\n", sizeof(&p+1));
printf("%d\n", sizeof(&p[0]+1));

//二维数组

int a[3][4] = {0};
printf("%d\n",sizeof(a));
printf("%d\n",sizeof(a[0][0]));
printf("%d\n",sizeof(a[0]));
printf("%d\n",sizeof(a[0]+1));
printf("%d\n",sizeof(*(a[0]+1)));
printf("%d\n",sizeof(a+1));
printf("%d\n",sizeof(*(a+1)));
printf("%d\n",sizeof(&a[0]+1));
printf("%d\n",sizeof(*(&a[0]+1)));
printf("%d\n",sizeof(*a));
printf("%d\n",sizeof(a[3]));

strlen和字符数组

//字符数组

char arr[] = {'a','b','c','d','e','f'};
printf("%d\n", strlen(arr));
printf("%d\n", strlen(arr+0));
printf("%d\n", strlen(*arr));
printf("%d\n", strlen(arr[1]));
printf("%d\n", strlen(&arr));
printf("%d\n", strlen(&arr+1));
printf("%d\n", strlen(&arr[0]+1));

char arr[] = "abcdef";
printf("%d\n", strlen(arr));
printf("%d\n", strlen(arr+0));
printf("%d\n", strlen(*arr));
printf("%d\n", strlen(arr[1]));
printf("%d\n", strlen(&arr));
printf("%d\n", strlen(&arr+1));
printf("%d\n", strlen(&arr[0]+1));

char *p = "abcdef";
printf("%d\n", strlen(p));
printf("%d\n", strlen(p+1));
printf("%d\n", strlen(*p));
printf("%d\n", strlen(p[0]));
printf("%d\n", strlen(&p));
printf("%d\n", strlen(&p+1));
printf("%d\n", strlen(&p[0]+1));

笔试题

笔试题1
int main()
{
    int a[5] = { 1, 2, 3, 4, 5 };
    int *ptr = (int *)(&a + 1);
    printf( "%d,%d", *(a + 1), *(ptr - 1));
    return 0;
}
//程序的结果是什么?

笔试题2
//由于还没学习结构体,这里告知结构体的大小是20个字节
struct Test
{
 int Num;
 char *pcName;
 short sDate;
 char cha[2];
 short sBa[4];
}*p;
//假设p 的值为0x100000。 如下表表达式的值分别为多少?
int main()
{
 printf("%p\n", p + 0x1);
 printf("%p\n", (unsigned long)p + 0x1);
 printf("%p\n", (unsigned int*)p + 0x1);
 return 0;
}

笔试题3
intmain()
{
    inta[4] = { 1, 2, 3, 4 };
    int*ptr1= (int*)(&a+1);
    int*ptr2= (int*)((int)a+1);
    printf( "%x,%x", ptr1[-1], *ptr2);    
    return0;
}

笔试题4
#include <stdio.h>
intmain()
{
    inta[3][2] = { (0, 1), (2, 3), (4, 5) };    int*p;
    p=a[0];
    printf( "%d", p[0]);
    return0;
}

笔试题5
int main()
{
    inta[5][5];
    int(*p)[4];
    p=a;
    printf( "%p,%d\n", &p[4][2] -&a[4][2], &p[4][2] -&a[4][2]);    return0;
}

笔试题6
int main()
{
    intaa[2][5] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };    int*ptr1= (int*)(&aa+1);
    int*ptr2= (int*)(*(aa+1));
    printf( "%d,%d", *(ptr1-1), *(ptr2-1));
    return0;
}

笔试题7
int main()
{
    char*c[] = {"ENTER","NEW","POINT","FIRST"};
    char**cp[] = {c+3,c+2,c+1,c};
    char***cpp=cp;
    printf("%s\n", **++cpp);
    printf("%s\n", *--*++cpp+3);
    printf("%s\n", *cpp[-2]+3);
    printf("%s\n", cpp[-1][-1]+1);
    return0;
}

笔试题8
int main()
{
    char*c[] = {"ENTER","NEW","POINT","FIRST"};
    char**cp[] = {c+3,c+2,c+1,c};
    char***cpp=cp;
    printf("%s\n", **++cpp);
    printf("%s\n", *--*++cpp+3);
    printf("%s\n", *cpp[-2]+3);
    printf("%s\n", cpp[-1][-1]+1);
    return0;
}

库函数的模拟实现

memcpy

memcpy  用于从源内存地址的起始位置开始复制若干个字节到目标内存地址。函数原型为: void *memcpy(void *dest, const void *src, size_t n); 。这里的  n  是要复制的字节数。 memcpy  可以用于复制任何类型的数据,包括结构体等。使用时要注意源和目标内存区域不能重叠,否则结果是未定义的。例如:

 memcpy  一般通过按字节或者按字(根据机器字长)的方式进行内存复制。以简单的按字节复制为例,其实现代码如下:

memmove

 memmove  在实现时会先判断源和目标内存区域是否重叠,如果重叠,则会从后往前进行复制,以确保数据的正确性。可以处理源和目标内存区域重叠的情况,这是它与  memcpy  的主要区别。其实现代码如下:

strstr

strcpy

注意:

1.将字符串1内容全部拷入字符串2,源字符串必须以'\0'结束

2.拷贝不能拷贝给指针。指针是常量字符串,不可修改

3.返回目标空间起始地址

 strcpy  的实现通常是逐个字符地将源字符串中的字符复制到目标字符串,直到遇到字符串结束符  '\0' 。其简单的实现代码如下:

strlen(unsigned int)

返回'\0'之前的字符个数

strcmp

自定义类型

内存对齐

在C语言中, sizeof 是一个非常实用的操作符,用于计算数据类型或变量在内存中所占的字节数。当涉及到计算结构体( struct )大小时,情况会变得稍微复杂一些,因为这里存在内存对齐的概念。本文将深入探讨使用 sizeof 求 struct 大小的注意事项。

内存对齐原则

数据成员对齐规则:结构体的每个数据成员都有自己的对齐要求。例如, char 类型通常占用1个字节,其对齐要求是1字节; int 类型通常占用4个字节,其对齐要求是4字节(在32位系统下)。编译器会在数据成员之间插入填充字节(padding),以确保每个数据成员都位于其对齐边界上。

结构体整体对齐规则:结构体本身也有一个对齐要求,通常是其最大数据成员对齐要求的倍数。例如,如果一个结构体中最大的数据成员是 double (8字节对齐),那么整个结构体的对齐要求就是8字节。

示例分析

 

示例1分析

-  char a :占用1字节,在内存中的起始地址是0。

-  int b :对齐要求是4字节,因此编译器会在 a 后面填充3个字节,使 b 的起始地址为4(4是4的倍数), b 占用4字节(地址4 - 7)。

-  char c :占用1字节,在地址8处存储。

- 结构体整体对齐要求是4字节(因为 int 的对齐要求是4字节),所以结构体总大小为12字节(1 + 3 + 4 + 1 + 3),最后的3个字节是填充字节,以确保结构体整体大小是4的倍数。

示例2分析

-  char a :占用1字节,起始地址0。

-  char c :占用1字节,起始地址1。

-  int b :对齐要求4字节,因此在 c 后面填充2个字节,使 b 起始地址为4(4是4的倍数), b 占用4字节(地址4 - 7)。

- 结构体整体对齐要求是4字节,总大小为8字节(1 + 1 + 2 + 4)

注意事项总结

结构体成员顺序影响大小:在定义结构体时,合理安排成员顺序可以减少内存占用。将占用字节数小的数据成员放在一起,占用字节数大的数据成员放在一起,有助于减少填充字节。

了解编译器和平台特性:编写跨平台代码时,要充分考虑不同编译器和平台的对齐规则差异,避免因内存对齐问题导致代码在不同环境下表现不一致。

避免不必要的填充:如果对内存使用非常敏感,可以通过调整结构体定义或使用编译器指令来减少填充字节,提高内存利用率。

在C语言中使用 sizeof 计算结构体大小时,内存对齐是一个关键因素。深入理解内存对齐的规则和影响因素,能够帮助我们编写出更高效、更健壮的代码。

联合

一、引言

在 C 语言中,共用体(Union)是一种特殊的数据类型,它允许不同的数据类型共享同一块内存空间。与结构体(Struct)不同,结构体中的每个成员都有自己独立的内存位置,而共用体的所有成员共享同一段内存。这种特性使得共用体在某些特定场景下非常有用,比如节省内存空间或者处理不同类型的数据。

二、共用体的定义和基本语法

共用体的定义方式与结构体类似,使用  union  关键字。以下是一个简单的共用体定义示例:

c

union Data {
    int i;
    float f;
    char c;
};

在这个例子中, union Data  定义了一个共用体类型,它包含三个成员:一个整数  i 、一个浮点数  f  和一个字符  c 。这些成员共享同一块内存空间,其大小为成员中占用内存最大的那个成员的大小。在大多数系统中, float  类型占用 4 个字节, int  类型通常也占用 4 个字节, char  类型占用 1 个字节,所以  union Data  占用 4 个字节的内存。

三、共用体的使用

3.1 声明共用体变量

可以使用以下方式声明共用体变量:

c

union Data myData;
3.2 给共用体成员赋值

由于共用体成员共享内存,同一时刻只能有一个成员的值是有效的。例如:

c

myData.i = 10;
printf("myData.i = %d\n", myData.i);

myData.f = 3.14;
printf("myData.f = %f\n", myData.f);

myData.c = 'A';
printf("myData.c = %c\n", myData.c);



在上述代码中,先给  myData.i  赋值,然后输出其值。接着给  myData.f  赋值,此时  myData.i  的值已经被覆盖,因为它们共享同一块内存。最后给  myData.c  赋值, myData.f  的值也被覆盖。

3.3 共用体的内存布局

为了更好地理解共用体的内存共享机制,我们可以通过打印内存地址来观察:

c

#include <stdio.h>



union Data {
    int i;
    float f;
    char c;
};



int main() {
    union Data myData;

    printf("Address of myData: %p\n", &myData);
    printf("Address of myData.i: %p\n", &myData.i);
    printf("Address of myData.f: %p\n", &myData.f);
    printf("Address of myData.c: %p\n", &myData.c);
    return 0;
}

运行上述代码,可以看到  myData 、 myData.i 、 myData.f  和  myData.c  的地址是相同的,这表明它们确实共享同一块内存空间。

四、共用体的应用场景

4.1 节省内存空间

当程序中需要处理不同类型的数据,但这些数据不会同时使用时,可以使用共用体来节省内存。例如,在一个简单的学生信息管理系统中,学生可能是本科生或者研究生,本科生有学号,研究生有学号和导师信息。可以使用共用体来存储学生的身份相关信息:

#include <stdio.h>
#include <string.h>


union StudentInfo {
    int studentId;
    struct {
        int studentId;
        char advisor[50];
    } graduate;
};

struct Student {
    char name[50];
    char type[10]; // "undergraduate" 或 "graduate"
    union StudentInfo info;
};



int main() {
    struct Student student1, student2;

    // 本科生信息
    strcpy(student1.name, "Alice");
    strcpy(student1.type, "undergraduate");
    student1.info.studentId = 1001;

    // 研究生信息
    strcpy(student2.name, "Bob");
    strcpy(student2.type, "graduate");
    student2.info.graduate.studentId = 2001;
    strcpy(student2.info.graduate.advisor, "Professor Smith");

    if (strcmp(student1.type, "undergraduate") == 0) {
        printf("Undergraduate: %s, ID: %d\n", student1.name, student1.info.studentId);
    }

    if (strcmp(student2.type, "graduate") == 0) {
        printf("Graduate: %s, ID: %d, Advisor: %s\n", student2.name, student2.info.graduate.studentId, student2.info.graduate.advisor);
    }

    return 0;
}
4.2 处理不同类型的数据

在一些设备驱动程序或者网络协议处理中,可能需要根据不同的情况处理不同类型的数据。共用体可以方便地实现这种功能。例如,在一个简单的通信协议中,数据包可能包含不同类型的数据:

#include <stdio.h>

union PacketData {
    int intData;
    float floatData;
    char stringData[20];
};

struct Packet {
    int type; // 1: int, 2: float, 3: string
    union PacketData data;
};

int main() {
    struct Packet packet1, packet2, packet3;

    // 整数类型数据包
    packet1.type = 1;
    packet1.data.intData = 42;

    // 浮点数类型数据包
    packet2.type = 2;
    packet2.data.floatData = 1.618;

    // 字符串类型数据包
    packet3.type = 3;
    strcpy(packet3.data.stringData, "Hello, world!");

    switch (packet1.type) {
        case 1:
            printf("Packet 1 (int): %d\n", packet1.data.intData);
            break;
        case 2:
            printf("Packet 2 (float): %f\n", packet2.data.floatData);
            break;
        case 3:
            printf("Packet 3 (string): %s\n", packet3.data.stringData);
            break;
    }
    return 0;
}



五、共用体使用的注意事项

内存覆盖问题:由于共用体成员共享内存,要特别注意在使用时不要意外地覆盖了有用的数据。在给一个成员赋值之前,确保之前的值已经不再需要。

数据类型转换:在访问共用体的不同成员时,要注意数据类型的转换。例如,从一个存储整数的共用体成员读取数据并将其作为浮点数使用时,可能会得到不正确的结果,除非进行适当的类型转换。

结构体和共用体的嵌套:在使用结构体和共用体嵌套时,要理清内存布局和成员访问的逻辑,避免出现错误。

六、总结

共用体是 C 语言中一个强大而灵活的特性,它通过共享内存空间为我们提供了节省内存和处理不同类型数据的有效方式。在实际编程中,合理使用共用体可以提高程序的效率和灵活性,但同时也需要注意其使用的特殊性,避免出现内存和数据类型相关的问题。希望通过本文的介绍和示例,读者能够更好地理解和应用 C 语言中的共用体。

数据存储

整形存储规则,原反补

在计算机科学的底层世界里,数据存储是基石般的存在。不同数据类型,如整形与浮点型,其存储方式犹如独特的密码,隐藏着计算机高效运行的秘密。理解它们,是深入掌握编程与计算机原理的关键。

原码、反码和补码

计算机存储整形数据,通常以二进制补码形式。在此之前,先了解原码和反码。

原码是数值的直观二进制表示,最高位为符号位,0代表正数,1代表负数。以8位二进制表示整数为例,+5的原码是00000101,-5的原码是10000101 。

反码计算规则:正数反码与原码相同负数反码是原码除符号位外各位取反。所以,-5的反码是11111010。

补码则是在反码基础上,正数补码同原码,负数补码是反码最低位加1。因此,-5的补码为11111011。

使用补码存储的原因

补码存储优势显著。

其一,简化运算规则,将减法转换为加法。比如计算5 - 3,可变为5 + (-3),利用补码做加法,统一了加减法运算,降低硬件实现复杂度。

其二,解决0的表示唯一性问题。原码和反码中,0有+0和-0两种表示,补码中0仅有一种表示00000000,让运算更便捷、统一。

不同类型的整形存储

常见整形类型有short、int、long等,占用字节数不同。32位系统中,short一般占2字节(16位),int占4字节(32位),long占4字节(64位系统中,long可能占8字节 )。不同位数决定了它们表示数值范围不同。如16位short类型,范围是-32768到32767,最高位为符号位,剩下15位表示数值,最小值是1000000000000000(即-32768),最大值是0111111111111111(即32767)。

代码示例

#include <stdio.h>

int main() {
    short num1 = -5;
    int num2 = 10;
    long num3 = 100000L;

    // 输出数据的值
    printf("short类型的num1值为: %d\n", num1);
    printf("int类型的num2值为: %d\n", num2);
    printf("long类型的num3值为: %ld\n", num3);
    return 0;
}

这段C语言代码展示了不同整形类型的使用。通过 printf 函数输出它们的值,让我们直观看到整形数据的存储和使用方式。运行代码,会在控制台输出各变量的值。

大小端及如何判断

什么大端小端:


大端(存储)模式,是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中;
小端(存储)模式,是指数据的低位保存在内存的低地址中,而数据的高位,,保存在内存的高地址中。

为什么会有大小端模式之分呢?这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8bit。但是在C语言中除了8bit的char之外,还有16bit的short型,32bit的long型(要看具体的编译器),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。

例如一个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么 0x11 为高字节, 0x22为低字节。对于大端模式,就将 0x11 放在低地址中,即 0x0010 中, 0x22 放在高地址中,即 0x0011 中。小端模式,刚好相反。我们常用的 X86 结构是小端模式,而 KEIL C51 则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式

//代码1
#include <stdio.h>
int check_sys()
{
 int i = 1;
 return (*(char *)&i);
}
int main()
{
 int ret = check_sys();
 if(ret == 1)
 {
 printf("小端\n");
 }
 else
 {
 printf("大端\n");
 }
 return 0;
}
//代码2
int check_sys()
{
 union
 {
 int i;
 char c;
 }un;
 un.i = 1;
 return un.c;
}

浮点数存储(了解)

IEEE 754标准

现代计算机存储浮点型数据多遵循IEEE 754标准。它将浮点型数据表示为符号位(S)、指数位(E)和尾数位(M)三部分。

根据国际标准IEEE(电气和电子工程协会) 754,任意一个二进制浮点数V可以表示成下面的形式:
(-1)^S * M * 2^E
(-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
M表示有效数字,大于等于1,小于2。
2^E表示指数位

单精度浮点型(float,占4字节,32位),符号位占1位,指数位占8位,尾数位占23位。

双精度浮点型(double,占8字节,64位),符号位占1位,指数位占11位,尾数位占52位。

存储格式的转换

把十进制浮点数转换为IEEE 754标准存储格式,

步骤如下:

先确定符号位,正数为0,负数为1;

再将小数部分转二进制,如0.625二进制是0.101;

接着规范化二进制数为1.xxxx \times 2^n形式,0.625规范化后是1.01 \times 2^{-1} ,指数n为-1;

最后,指数部分加偏移量(单精度偏移量127,双精度偏移量1023)得存储用指数位。

此例中,-1加127得126,即指数位为01111110 ,尾数位是规范化后小数部分去掉整数部分1,即01。所以,0.625单精度浮点型存储形式为0 01111110 01000000000000000000000。

浮点型存储的精度问题

因尾数位位数有限,浮点型存储存在精度问题。比如十进制0.1,二进制是无限循环小数0.0001100110011\cdots ,有限尾数位下只能近似存储,导致浮点数运算可能有精度误差。如一些编程语言中,0.1 + 0.2结果不精确等于0.3,而是接近0.3的近似值。

代码示例

a = 0.1
b = 0.2
print(a + b)

这段Python代码简单演示了浮点数运算的精度问题。运行代码,输出结果并非精确的0.3,而是近似值,体现浮点型存储的精度限制。

总结

整形和浮点型在计算机存储形式上截然不同。整形以补码存储,用于精确存储整数,运算规则简单。浮点型遵循IEEE 754标准存储,适合表示实数,但存在精度问题。理解它们的存储形式,能帮我们在编程时合理选择数据类型,避免潜在问题,编写出更优质的程序。无论是大型软件开发,还是科学计算,这些基础知识都是坚实的后盾。

类型提升和截断


 一、类型提升(自动转换,保精度)

1. 整型提升(短整型→int)
 
- 规则: char/short 参与运算时先转 int (若 short 最大值≤ int 则转 int ,否则转 unsigned int )。
- 示例:

char a = 0xF0;  // 二进制11110000  
char b = 0x0F;  // 二进制00001111  
int c = a + b;  // a和b先转int(0xFFFFFFF0和0x0000000F),相加得0xFFFFFFFF(即-1)

2. 算术转换(跨类型运算,低→高)
 
- 顺序: char/short → int → unsigned int → long → unsigned long → double 。
- 示例:

float f = 3.14;  
long l = 100;  
double d = f + l;  // l转double(100.0),结果为103.14  


二、截断(强制转换,可能丢数据)

 1. 整型截断(高字节→低字节) 


- 规则:直接丢弃高位,保留目标类型对应字节数。
- 示例:

int a = 0x12345678;  
char b = (char)a;  // 取低8位0x78(即120)  
// 若a为负数(如-1,二进制全1),截断后char可能为-1(取决于是否符号扩展)

2. 浮点→整型截断(去小数位)
 
- 规则:直接舍去小数部分,不四舍五入。
- 示例:

double x = 5.9;  
int y = (int)x;  // y=5  


 
三、避坑关键点

1. 隐式提升别忽略:
- 例: if (char_var > 127) 可能出错,因 char_var 会转 int ,负数会扩展为大整数。
2. 截断前算范围:
- 转 char 前确保数值在-128~127(有符号)或0~255(无符号)内。
3. 强制转换加注释:
int big = 1000;  
char small = (char)big;  // 注释:截断1000到char,预期结果1000-256*3=232  
 
4. 浮点转整型用floor/ceil:
- 若需四舍五入,用 int y = (int)(x + 0.5) (但注意负数场景)。
 

编译链接


 宏


 一、 #define  基础:标识符与宏定义
 

1. 标识符定义
 
- 语法: #define 名称 内容 (无分号,内容可换行,换行前加  \ )
- 作用:简单文本替换,常用于定义常量、简化关键字
- 示例:

#define MAX 1000       // 常量
#define reg register  // 简化关键字 
#define do_forever for(;;)  // 代码片段


 
- 注意:别加  ; ,否则条件判断易出错(如  #define MAX 1000;  会导致  if(...) max = MAX;  语法错误)
 
2. 宏定义(带参数)
 
- 语法: #define 宏名(参数) 替换文本 (参数紧接宏名,无空格)
- 问题:直接替换可能因运算符优先级出错,需用括号包裹
- 示例:
// 错误写法:a+1 会被直接替换,导致 (a+1)*(a+1) 变成 a+1*a+1
#define SQUARE(x) x*x  
// 正确写法:用括号确保运算顺序
#define SQUARE(x) ((x)*(x))  
 
- 进阶:宏参数可结合  # (字符串化)、 ## (符号拼接)
 


二、 #define  高级技巧
 


1.  # :参数字符串化
 
- 作用:把宏参数转成字符串常量
- 示例: 

#define PRINT(VALUE) printf("the value of "#VALUE" is %d\n", VALUE)
int i = 10;
PRINT(i+3);  // 输出:the value of i+3 is 13


2.  ## :符号拼接
 
- 作用:把宏参数与其他符号拼接成新标识符
- 示例:

#define ADD_TO_SUM(num, value) sum##num += value  
ADD_TO_SUM(5, 10);  // 等价于 sum5 += 10;

3. 多行宏(换行用  \  连接)
 

- 作用:定义复杂逻辑的宏(如包含多条语句)

- 示例:  

#define SWAP(a, b) { \
    int temp = a; \
    a = b; \
    b = temp; \
}  
int x=1, y=2;
SWAP(x, y); // 替换后完成交换

4. 宏的副作用
 
- 风险:参数若含自增(如  x++ ),因多次替换可能导致逻辑错误
- 示例:

#define MAX(a, b) ((a) > (b) ? (a) : (b))  
int x=5, y=8;
// 实际执行:z = ((x++) > (y++) ? (x++) : (y++)) → x=6, y=10, z=9
int z = MAX(x++, y++);  


三、文件包含( #include )
 


1. 两种包含方式

语法 查找路径 场景 
 #include "file"   先当前目录 → 再标准库路径 包含本地自定义头文件 
 #include <file>   直接去标准库路径查找 包含系统/库头文件 

2. 避免重复包含
 
- 问题:嵌套包含会导致头文件内容重复,编译报错
- 解决:
- 方法1:条件编译(通用写法)

#ifndef HEADER_NAME_H  
#define HEADER_NAME_H  
// 头文件内容  
#endif  

- 方法2: #pragma once (编译器支持,更简洁)
 


四、条件编译(按需求选择性编译)
 

1. 常用指令
 

指令 作用 示例 
#if 常量 条件为真时编译代码     #if DEBUG_LEVEL > 1  
 #elif 常量   多分支条件  - 
 #else   条件不满足时编译    -   
 #endif   结束条件编译   -  
 #ifdef 符号   符号定义过则编译  #ifdef _DEBUG  
 #ifndef 符号   符号未定义过则编译  #ifndef _WIN32  

2. 应用场景
 
- 调试代码开关: #define _DEBUG  控制调试日志输出
- 跨平台兼容: #ifdef _WIN32  区分 Windows 和 Linux 代码
 

五、其他预处理指令
 

指令 作用 示例 
#undef    取消已定义的宏/标识符  #undef MAX  
 #error 编译时触发错误,中断编译     #error "版本不兼容"  
 #pragma   编译器指令(如对齐、警告控制)  #pragma pack(4)  
 #line   重置  __LINE__  宏的值(调试用)  #line 100 "newfile"  

六、宏 vs 函数(核心区别)
 

特性 函数 
代码替换 每次调用都插入代码,可能增大体积 只定义一次,调用时跳转 
执行速度 无调用开销,更快 有调用/返回开销,稍慢 
类型检查 无类型检查,更灵活 强类型检查,更安全 
调试难度 替换后代码难调试 可逐语句调试 
递归支持 不支持递归 支持递归 
特性 函数
执行时机 预处理阶段替换(编译前) 编译后,运行时调用 
类型检查 无类型检查(传啥都行) 强类型检查(参数类型必须匹配) 
代码体积 多次调用会重复插入代码 → 体积大
只定义一次,调用时跳转 → 体积小 
调试难度 替换后代码难调试(看不到宏逻辑) 可逐语句调试 
适用场景 简单运算、代码片段复用、条件编译
复杂逻辑、需类型安全、需调试场景 

编译链接过程

(一)预处理

预处理阶段承担着多项重要任务,包括头文件展开、宏替换、条件编译以及去掉注释等操作。假设有 Stack.h  、 Stack.cpp  、 Test.cpp 等文件,经过预处理后,会生成 Stack.i 和 Test.i 。这一阶段为后续的编译工作做了前期的准备,将代码整理成更便于编译器处理的形式。

(二)编译

编译阶段主要是对代码进行语法检查,并生成汇编代码。在此过程中,会生成 Stack.s 和 Test.s 文件。如果代码中存在语法错误,就会在这个阶段被编译器检测出来并报错。语法检查涵盖了对变量声明、语句结构、函数调用等多方面的规则校验,只有通过语法检查的代码才能顺利进入后续阶段。

(三)汇编

汇编阶段的任务是将汇编代码转换成二进制机器码,生成 Stack.o 和 Test.o 目标文件。这是将高级语言逐步转化为机器能够直接执行的指令的关键步骤,二进制机器码是计算机硬件能够直接理解和执行的指令形式。

(四)链接

链接阶段会把各个目标文件以及所依赖的库文件等进行链接,最终生成可执行程序(如 xxx.exe 或 a.out )。在链接过程中,会处理函数的声明和定义之间的关联,找到函数的具体实现位置。例如,在多个源文件中调用的函数,链接器会确保函数的声明和定义能够正确匹配,使得程序在运行时能够准确找到并执行相应的函数代码。

一、预处理阶段:代码的初步雕琢

预处理阶段作为编译的起始点,预处理器如同一位勤劳的“前期准备师”,严格按照预处理指令对代码展开精心处理。这些以“#”开头的指令,是 C 语言编程中频繁使用的得力工具,它们在代码正式编译之前,对代码的结构和内容进行初步的调整和整合。

(一)#include 指令:头文件的无缝嵌入

#include 指令扮演着“文件整合者”的角色,它的主要任务是将指定的头文件内容原汁原味地插入到当前代码中。

#include <stdio.h>
#include "myheader.h"

int main() {
    int num = 10;
    printMessage(); // 假设 myheader.h 中声明了该函数
    printf("The number is: %d\n", num);
    return 0;
}

在上述代码中, #include <stdio.h> 引入了标准输入输出头文件,使得我们可以使用 printf 函数。而 #include "myheader.h" 则用于引入自定义头文件,其中可能包含了 printMessage 函数的声明。预处理器会将 stdio.h 和 myheader.h 的内容分别插入到相应位置,为后续代码调用相关函数提供支持。

(二)#define 指令:灵活多变的文本替换

 #define 指令堪称一个功能强大的“文本魔术师”,它主要用于定义宏。

#define PI 3.1415926
#define SQUARE(x) ((x) * (x))

int main() {
    double radius = 5.0;
    double area = PI * SQUARE(radius);
    printf("The area of the circle is: %lf\n", area);
    return 0;
}

这里定义了常量宏 PI 和带参数的宏 SQUARE 。在预处理阶段, PI 会被替换为 3.1415926 , SQUARE(radius) 会被替换为 ((radius) * (radius))  。这种宏替换机制让代码更加简洁,并且在需要修改常量或宏定义时,只需在一处修改即可。

(三)条件编译指令:按需编译的智慧选择

#ifdef 、 #ifndef 、 #endif 等条件编译指令为我们提供了一种根据不同条件选择性编译代码的强大能力。

#define DEBUG

int main() {
    int x = 10;
#ifdef DEBUG
    printf("Debugging: x = %d\n", x);
#endif
    // 其他业务逻辑代码
    return 0;
}

由于定义了 DEBUG 宏, printf 语句会被保留在预处理后的代码中,用于输出调试信息。若没有定义 DEBUG 宏,该语句会被删除,不参与后续编译。

经过预处理阶段,会生成一个中间文件。这个文件中包含了所有被展开的头文件内容,以及经过替换后的宏,并且不再包含预处理指令,成为了一个纯粹的 C 语言代码文件。不过,由于头文件的大量展开,文件的规模可能会有显著的增大。但这也为后续的编译阶段提供了完整且统一的代码基础。

二、编译阶段:代码的深度解析与转换

编译阶段是整个编译过程的核心环节,犹如一位经验丰富的“代码分析师”,编译器会对预处理后的代码进行全面而深入的分析和转换,依次进行词法分析、语法分析、语义分析,并最终生成汇编语言代码。

(一)词法分析:字符流的精准拆分

词法分析器就像是一个极其细致的“文字分拣机器人”,它把输入的连续字符流转化为单词序列。例如对于代码:

int count = 15 + num * 2;

词法分析器会将其分解为“int”(关键字)、“count”(标识符)、“=”(运算符)、“15”(常量)、“+”(运算符)、“num”(标识符)、“*”(运算符)、“2”(常量)、“;”(分隔符)等单词。

(二)语法分析:构建代码的逻辑结构

语法分析器依据 C 语言的语法规则,对词法分析器输出的单词序列进行处理,构建语法树。以代码:

if (a > 10 && b < 20) {
    result = a + b;
}

为例,语法分析器会检查 if 语句的条件表达式以及语句块是否符合语法规则,若不符合则报错

(三)语义分析:确保代码的语义准确性

语义分析器会检查代码语义和类型是否正确。如代码:

int a = "hello";

语义分析器会发现类型不匹配错误,因为 int 类型不能直接赋值字符串。

(四)代码生成:迈向机器语言的关键一步

在完成前面的分析后,编译器根据目标平台生成汇编语言代码。不同平台生成的汇编代码不同。例如对于简单加法运算:

int add(int x, int y) {
    return x + y;
}

int main() {
    int num1 = 5, num2 = 3;
    int sum = add(num1, num2);
    return 0;
}

在 x86 平台和 ARM 平台下, add 函数生成的汇编代码在指令和寄存器使用上会有差异。

三、汇编阶段:从符号语言到机器指令的飞跃

在汇编阶段,汇编器将汇编语言代码翻译成机器语言指令,并处理符号地址。

以简单的汇编代码片段为例:

asm
section.data
    num1 dd 5
    num2 dd 3

section.text
    global _start
_start:
    mov eax, [num1]
    mov ebx, [num2]
    add eax, ebx

    ; 后续可能还有其他处理,这里简化示例

汇编器会将 mov 、 add 等汇编指令翻译成目标机器能理解的二进制机器码,并将 num1 、 num2 等符号地址转换为实际内存地址,生成包含机器语言指令等内容的目标文件。

四、链接阶段:整合资源,成就可执行程序

链接阶段,链接器将多个目标文件和库文件进行链接。假设我们有两个源文件 main.c 和 utils.c :

main.c
#include <stdio.h>
extern int add(int a, int b);

int main() {
    int num1 = 4, num2 = 6;
    int result = add(num1, num2);
    printf("The result is: %d\n", result);
    return 0;
}

utils.c
  1. c
    int add(int a, int b) {
        return a + b;
    }

 main.c 中声明了外部函数 add , utils.c 中定义了 add 函数。编译后生成 main.o 和 utils.o ,链接器会将它们链接起来,解决 add 函数的符号引用问题,并将可能用到的标准库函数(如 printf )的代码也链接进来,最终生成可执行文件。

五、实际操作与工具使用

在实际开发中,我们通常使用 GCC 来完成编译过程。

分步执行编译过程如下:

预处理: gcc -E test.c -o test.i  ,对 test.c 进行预处理,结果输出到 test.i 。

编译: gcc -S test.i -o test.s  ,将预处理后的 test.i 编译成汇编代码 test.s 。

汇编: gcc -c test.s -o test.o  ,把汇编代码 test.s 汇编成目标文件 test.o 。

链接: gcc test.o -o test  ,将目标文件 test.o 链接成可执行文件 test 。

也可直接使用 gcc test.c -o test  ,让 GCC 自动完成四个阶段,生成可执行文件 test 。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐