C语言第11节:指针(1)
1. 内存和地址
1.1 内存
内存是计算机系统中用于存储数据和指令的硬件设备。它可以被视为一个巨大的、有序的字节数组。
- 基本单位:内存的基本单位是字节(byte)。每个字节由8个位(bit)组成,可以存储0到255之间的一个数值。
- 内存模型:从程序员的角度来看,内存可以被想象成一个巨大的一维数组,每个元素都是一个字节。
- 内存容量:现代计算机的内存容量通常以GB(千兆字节)或TB(太字节)为单位。例如,8GB的内存意味着有8,589,934,592个字节可供使用。
- 内存类型:主要包括随机访问内存(RAM)和只读内存(ROM)。RAM是易失性存储器,断电后数据会丢失;ROM是非易失性存储器,断电后数据仍然保留。
内存的工作原理对于理解C语言中的指针概念至关重要。每当我们在程序中声明一个变量时,实际上是在内存中分配了一块空间来存储这个变量的值。
1.2 究竟该如何理解编址
编址是为内存中的每个字节分配唯一标识符(地址)的过程。这个过程使得CPU能够准确地访问和操作内存中的数据。
- 地址空间:地址空间是指可能的地址范围。在32位系统中,地址空间是2^32 (约42亿)个不同的地址,而在64位系统中,这个数字增加到2^{64}。
- 地址表示:内存地址通常以十六进制表示,因为十六进制更容易转换为二进制,而计算机内部使用二进制。例如,十六进制地址0x1000等同于十进制的4096。
- 字节寻址:大多数现代计算机系统使用字节寻址,这意味着每个字节都有一个唯一的地址。例如,一个32位整数占用4个字节,因此会有4个连续的地址与之关联。
- 大端序和小端序:这两种方式决定了多字节数据类型(如int或float)在内存中的存储顺序。大端序将最高有效字节存储在最低地址,而小端序则相反。
理解编址对于有效使用指针和进行底层内存操作至关重要。它让我们能够直接访问和操作内存中的数据,这是C语言强大功能的基础。
2. 指针变量和地址
2.1 取地址操作符(&)
取地址操作符&是C语言中用于获取变量内存地址的一元操作符。它的使用非常直观:只需在变量名前加上&符号。
int x = 10;
int* ptr = &x; // ptr现在存储x的地址
printf("x的值:%d\n", x);
printf("x的地址:%p\n", (void*)&x);
printf("ptr存储的地址:%p\n", (void*)ptr);
在上面的代码中:
- x是一个整型变量,值为10。
- &x返回x的内存地址。
- ptr是一个指针变量,存储了x的地址。
- %p是用于打印指针(地址)的格式说明符。
需要注意的是:
- 不是所有的表达式都可以取地址。例如,常量和某些表达式就不能取地址。
- 取地址操作符只能用于内存中的对象,不能用于寄存器变量。
2.2 指针变量和解引用操作符(*)
2.2.1 指针变量
那我们通过取地址操作符(&)拿到的地址是一个数值,比如:0x006FFD70 ,这个数值有时候也是需要存储起来,方便后期再使用的,那我们把这样的地址值存放在哪里呢?答案是:指针变量中。
指针变量是一种特殊的变量,用于存储内存地址。它的声明包括两部分:指针所指向的数据类型和星号(*)。
int* p; // 指向整数的指针
char* c; // 指向字符的指针
float* f; // 指向浮点数的指针
void* v; // 无类型指针,可以指向任何类型的数据
指针变量的特点:
- 指针变量本身也占用内存空间,通常是4字节(32位系统)或8字节(64位系统)。
- 指针变量可以被赋值、比较、进行算术运算等。
- 未初始化的指针变量包含垃圾值,使用它们可能导致程序崩溃或不可预知的行为。
2.2.2 如何拆解指针类型
指针类型由两部分组成:基本类型和星号(*)。理解这种结构有助于正确声明和使用指针。
- int* ptr: ptr是一个指向整数的指针
- char* str: str是一个指向字符的指针,通常用于表示字符串
- float* fptr: fptr是一个指向浮点数的指针
- int** pptr: pptr是一个指向整数指针的指针(二级指针)
解引用操作符(*)用于访问指针所指向的值:
int x = 10;
int* ptr = &x;
printf("x的值:%d\n", x);
printf("通过指针访问x的值:%d\n", *ptr);
*ptr = 20; // 通过指针修改x的值
printf("修改后x的值:%d\n", x);
在这个例子中,*ptr不仅可以用来读取x的值,还可以用来修改x的值。这展示了指针强大的间接访问能力。
2.2.3 解引用操作符
2.2.3.1 解引用操作符的作用
解引用操作符 *
的主要作用是通过指针访问数据。当我们有一个指针时,指针存储的是一个内存地址,而不是实际的数据。使用 *
可以告诉程序从这个地址中获取实际的数据。
int x = 10;
int *p = &x; // p是指向x的指针
int y = *p; // 解引用p,获得x的值
在上面的代码中:
int *p = &x;
:这行代码将x
的地址赋给了指针p
,使p
指向x
。int y = *p;
:使用解引用操作符*
,我们获取了指针p
所指向的值,也就是x
的值,将它赋给变量y
。
2.2.3.2 解引用的应用场景
解引用操作符在以下场景中特别有用:
- 访问指针指向的数据:当我们有一个指针并希望通过它访问它所指向的数据时,解引用操作符就派上用场。
- 指针修改数据:通过解引用指针,我们可以直接修改它所指向的内存地址中的数据。
例如:
*p = 20; // 通过指针p修改x的值为20
- 在动态内存分配中使用:动态内存分配返回的是指针,通过解引用我们可以访问和操作动态分配的内存。
2.2.3.3 解引用的注意事项
使用解引用操作符时,有几点需要注意:
- 指针必须指向有效的内存地址:如果指针未被初始化或指向了非法地址(如 NULL),解引用操作会导致运行时错误,甚至程序崩溃。
- 避免野指针:当指针指向的内存已经被释放,仍然去解引用会产生野指针问题,这可能导致未定义的行为。(后面会讲解)
- 解引用不同类型的指针:解引用操作符会按照指针类型去解读数据。因此,指针类型必须与其指向的数据类型一致,否则可能导致错误的数据访问。
2.3 指针变量的大小
指针变量的大小与系统的架构密切相关,而不是与它所指向的数据类型有关。
- 32位系统:所有类型的指针通常占用4字节
- 64位系统:所有类型的指针通常占用8字节
#include <stdio.h>int main() {int* p1;char* p2;double* p3;void* p4;printf("int* 大小: %zu 字节\n", sizeof(p1));printf("char* 大小: %zu 字节\n", sizeof(p2));printf("double* 大小: %zu 字节\n", sizeof(p3));printf("void* 大小: %zu 字节\n", sizeof(p4));return 0;
}
3. 指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,在同一个平台下,大小都是一样的,为什么还要有各种各样的指针类型呢?但其实指针类型是有特殊意义的。
3.1 指针的解引用
解引用是通过指针访问它所指向的内存位置的过程。解引用操作符*用于这个目的。
代码1:
#include <stdio.h>int main() {int x = 1001;int* ptr = &x;*ptr = 20; // 通过指针修改x的值printf("x = %d\n", x); // 输出:x = 20printf("*ptr = %d\n", *ptr); // 输出:*ptr = 20
}
代码2:
#include <stdio.h>int main() {int x = 1001;char* ptr = (char *) & x;*ptr = 20; // 通过指针修改x的值printf("x = %d\n", x); // 输出:x = 20printf("*ptr = %d\n", *ptr); // 输出:*ptr = 20
}
结论:指针的类型决定了对指针解引用的时候有多大的权限(一次能操作几个字节)。
解引用的重要性:
- 允许间接访问和修改数据
- 是实现动态内存分配的基础
- 使得复杂的数据结构(如链表、树等)的实现成为可能
注意事项:
- 解引用空指针或未初始化的指针会导致未定义行为,可能造成程序崩溃
- 在使用指针之前,始终确保它指向有效的内存位置
3.2 指针+ - 整数
观察一下代码
#include <stdio.h>
int main()
{int a = 123;int* p1 = &a;char* pc = (char *)&a;printf("&n =\t\t %p\n", &a);printf("p1 =\t\t %p\n", p1);printf("p1 + 1 =\t %p\n", p1 + 1);printf("pc =\t\t %p\n", pc);printf("pc + 1 =\t %p\n", pc + 1);
}
我们可以看出,char*
类型的指针变量+1跳过1个字节,int*
类型的指针变量+1跳过了4个字节。这就是指针变量的类型差异带来的变化。指针的算术运算是C语言中一个强大而复杂的特性。
int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr; // ptr指向数组的第一个元素printf("%d\n", *ptr); // 输出:10
ptr++; // 移动到下一个整数位置
printf("%d\n", *ptr); // 输出:20
ptr += 2; // 向前跳过两个整数
printf("%d\n", *ptr); // 输出:40
指针算术的关键点:
- ptr++使指针移动到下一个元素,而不是下一个字节
- 移动的字节数等于指针类型的大小(例如,对于int*,通常是4字节)
- 这种行为使得数组遍历变得简单高效
结论:当对指针进行加减运算时,实际的内存偏移量取决于指针的类型。
3.3 void* 指针
void*
是一种特殊的指针类型,可以指向任何类型的数据。它通常被称为"通用指针"。但是也有局限性,void*
类型的指针不能直接进行指针的±整数和解引用的运算。
int i = 10;
float f = 3.14;
char c = 'A';void* ptr;ptr = &i;
printf("整数值:%d\n", *(int*)ptr);ptr = &f;
printf("浮点数值:%f\n", *(float*)ptr);ptr = &c;
printf("字符值:%c\n", *(char*)ptr);
这样便是错误的:
#include <stdio.h>
int main()
{int a = 10;void *pa = &a;void *pc = &a;*pa = 10;*pc = 0;return 0;
}
void*
指针的特点:
- 可以存储任何类型的地址,提供了极大的灵活性
- 不能直接解引用,必须先转换为具体的类型
- 常用于通用内存分配函数(如malloc)的返回值
- 在进行指针算术时需要特别小心,因为
void*
没有具体的大小信息
4. 指针运算
4.1 指针+ - 整数
指针的加法运算是基于指针类型的大小进行的。当对指针进行加法操作时,地址的增加量等于指针类型的大小乘以添加的整数值。
int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;printf("%d\n", *ptr); // 输出:10
printf("%d\n", *(ptr+2)); // 输出:30
printf("%d\n", ptr[2]); // 输出:30 (等同于 *(ptr+2))ptr += 3; // 移动3个整数位置
printf("%d\n", *ptr); // 输出:40
数组 | 10 | 20 | 30 | 40 | 50 |
---|---|---|---|---|---|
下标 | 0 | 1 | 2 | 3 | 4 |
注意事项:
- 指针加法不改变原始数组
- 确保不要越界访问数组
- 指针减法的工作原理类似,但是向反方向移动
4.2 指针 - 指针
两个相同类型的指针相减会得到它们之间的元素数量,而不是字节数。这个操作通常用于计算数组中元素的距离。
int arr[5] = {10, 20, 30, 40, 50};
int* p1 = &arr[1];
int* p2 = &arr[4];ptrdiff_t diff = p2 - p1; // diff = 3
printf("p2和p1之间的元素数:%td\n", diff);// 使用指针遍历数组
for(int* p = arr; p < arr + 5; p++) {printf("%d ", *p);
}
关键点:
- 指针相减的结果类型是
ptrdiff_t
,这是一个有符号整数类型 - 只有同类型的指针才能相减
- 这种操作对于处理数组和动态分配的内存非常有用
4.3 指针的关系运算
指针可以使用关系运算符(<, <=, >, >=, ==, !=)进行比较。这在处理数组和其他连续内存结构时特别有用。
- **相等性比较(== 和 !=):**用于判断两个指针是否指向同一内存位置。
- **大小比较(<, >, <=, >=):**用于比较指针所指向的内存地址的相对位置。
- **指向同一数组的指针:**可以进行所有关系运算。
- **不同数组的指针:**只能进行相等性比较,其他比较可能导致未定义行为。
指针的关系运算在C语言中有着重要的应用,特别是在数组操作和内存管理中。以下是一个简单的示例,展示了指针关系运算的使用:
int arr[5] = {10, 20, 30, 40, 50};
int* start = arr;
int* end = arr + 5;while (start < end) {printf("%d ", *start);start++;
}
// 输出:10 20 30 40 50if (start == end) {printf("\n遍历完成\n");
}start = arr;
int* middle = arr + 2;
if (middle > start && middle < end) {printf("middle 指向数组中间元素\n");
}
在这个例子中:
- 我们使用指针的小于运算符(<)来遍历数组。这种方法比使用索引更加高效,因为它直接操作内存地址。
- 等于运算符(==)用于检查是否已经遍历完整个数组。
- 大于(>)和小于运算符一起使用,可以检查一个指针是否位于其他两个指针之间。
指针关系运算的注意事项:
- 只有指向同一数组(或同一内存块)的指针之间的比较才有意义。
- 不同类型的指针之间的比较通常没有意义,可能导致未定义行为。
- 空指针(NULL)可以与任何指针进行比较,通常用于检查指针是否有效。
指针关系运算在数组边界检查、链表操作和其他涉及内存管理的场景中非常有用。正确使用这些运算可以提高代码的效率和安全性。
5. const 修饰指针
5.1 指向常量的指针(pointer to constant)
语法:const int* ptr
或 int const* ptr
特点:指针指向的值不能通过指针来修改,但指针本身可以指向其他地方。
const int* ptr = &value;
*ptr = 10; // 错误!不能通过ptr修改所指向的值
ptr = &anotherValue; // 正确,可以改变指针指向
5.2 常量指针(constant pointer)
语法:int* const ptr
特点:指针本身的值(即所指向的地址)不能改变,但可以通过指针修改所指向的值。
int value = 5;
int* const ptr = &value;
*ptr = 10; // 正确,可以通过ptr修改所指向的值
ptr = &anotherValue; // 错误!不能改变指针指向
5.3 指向常量的常量指针(constant pointer to constant)
语法:const int* const ptr
特点:指针本身的值不能改变,指针指向的值也不能通过指针来修改。
const int* const ptr = &value;
*ptr = 10; // 错误!不能通过ptr修改所指向的值
ptr = &anotherValue; // 错误!不能改变指针指向
总结
- 记忆技巧:const在
*
左边修饰指向的值,在*
右边修饰指针本身。 - 使用const修饰指针可以增加代码的安全性和可读性。
- 在函数参数中使用const指针可以防止函数意外修改传入的数据。
6. 野指针(Dangling Pointer)
6.1 什么是野指针?
野指针是指向"无效"内存区域的指针。当一个指针指向的内存已经被释放或者访问超出了变量的作用域,但该指针仍然被使用时,就会产生野指针。野指针是一种非常危险的编程错误,因为它们可能导致不可预测的程序行为。
6.2 野指针的成因
6.2.1 未初始化的指针
当声明一个指针但没有给它赋予一个有效的地址时,这个指针就成为了野指针。
int* ptr; // 未初始化的指针
*ptr = 10; // 危险!ptr指向的是未知的内存位置
6.2.2 指针所指向的内存被释放
当使用free释放了指针所指向的内存,但没有将指针置为NULL时,就会产生野指针。
int* ptr = (int*)malloc(sizeof(int));
*ptr = 5;
free(ptr); // 内存被释放
// ptr现在是野指针
*ptr = 10; // 危险!访问已释放的内存
6.2.3 指针超出变量作用域
当函数返回一个指向局部变量的指针时,该指针会成为野指针,因为局部变量在函数结束时会被销毁。
int* getLocalVar() {int x = 5;return &x; // 危险!返回局部变量的地址
}
6.2.4 返回栈上内存的地址
类似于上一点,但特指返回栈上分配的数组或对象的地址。
int* createArray() {int arr[10] = {0};return arr; // 危险!返回栈上数组的地址
}
6.3 野指针的危害
- 程序崩溃:访问无效内存可能导致程序立即崩溃。
- 数据损坏:写入无效内存可能破坏其他变量的数据。
- 安全漏洞:可能被攻击者利用来执行恶意代码。
- 难以调试:野指针引起的问题可能在程序的其他部分显现,使得错误难以定位。
- 不确定的行为:程序可能看似正常运行,但实际上存在潜在的危险。
6.4 如何规避野指针
6.4.1 初始化指针
始终在声明指针时进行初始化,如果暂时没有有效地址,可以将其设置为NULL。
int* ptr = NULL; // 良好习惯
6.4.2 释放内存后将指针置空
手动管理内存时,在释放后立即将指针设为NULL。
int* ptr = (int*)malloc(sizeof(int));
free(ptr);
ptr = NULL; // 防止成为野指针
6.4.3 避免返回局部变量的地址
函数返回值应使用值传递或在堆上分配内存。
int* createInt() {int* ptr = (int*)malloc(sizeof(int));*ptr = 5;return ptr; // 返回堆内存地址,但记得在使用后释放
}
6.4.4 使用断言检查指针有效性
在关键操作前使用断言检查指针是否为NULL。
#include <assert.h>void usePointer(int* ptr) {assert(ptr != NULL);// 使用ptr
}
6.4.5 代码审查和静态分析
定期进行代码审查,使用静态分析工具(如Clang Static Analyzer、Cppcheck等)来检测潜在的野指针问题。
6.5 总结
野指针是C语言编程中的一个常见且危险的问题。通过遵循良好的编程实践、仔细管理内存、保持警惕,我们可以大大减少野指针带来的风险。理解并正确处理指针是编写健壮、安全的C程序的关键。持续学习和实践是避免野指针陷阱的最佳方法。
7. assert 断言
7.1 什么是assert断言?
assert是C语言中的一个宏,定义在<assert.h>
头文件中。它用于在程序中插入诊断点,帮助开发者发现程序中的逻辑错误。
7.2 assert的基本语法
#include <assert.h>
int main()
{assert(expression);return 0;
}
如果expression为假(即为0),assert会输出错误信息并终止程序执行。
示例:
assert(p != NULL);
上面代码在程序运行到这一行语句时,验证变量p 是否等于NULL 。如果确实不等于NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。
7.3 assert的工作原理
当assert的条件为假时,它会:
- 向stderr打印一条错误信息,包含文件名、行号和失败的表达式。
- 调用abort()函数终止程序执行。
7.4 assert的优点
- 帮助快速定位bug
- 提高代码的自文档化程度
- 在开发阶段捕获逻辑错误
- 可以在发布版本中轻松禁用
7.5 禁用assert
如果已经确认程序没有问题,不需要再做断言,就在#include <assert.h>
语句的前面,定义一个宏NDEBUG
。
#define NDEBUG
#include <assert.h>
这样可以通过定义NDEBUG
宏来禁用所有的assert()
assert()
一般我们可以在Debug
中使用,在Release
版本中选择禁用assert
就行(在VS 这样的集成开发环境中,在Release
版本中,直接就是优化掉了。)这样在debug
版本写有利于程序员排查问题,在Release
版本不影响用户使用时程序的效率。
在Release
版本中,可以也通过定义NDEBUG宏来禁用所有的assert:
#define NDEBUG
#include <assert.h>
或者在编译时使用-DNDEBUG选项。
8 指针的使用和传址调用
8.1 strlen
的模拟实现
strlen
是一个常用的字符串处理函数,用于计算字符串的长度(不包括结尾的空字符’\0’)。让我们深入探讨如何使用指针来模拟实现这个函数:
size_t my_strlen(const char* str) {const char* s;for (s = str; *s; ++s) {}return (s - str);
}
让我们逐步分析这个实现:
-
函数参数:
const char* str
表示一个指向常量字符的指针,确保函数不会修改原字符串。 -
初始化:
const char* s;
声明另一个指针 s,用于遍历字符串。 -
遍历循环:
for (s = str; *s; ++s) {}
- s 初始化为与 str 相同的地址
*s
为真(非零)时继续循环,即直到遇到’\0’- 每次迭代 s 向后移动一个字符
-
长度计算:
return (s - str);
利用指针减法计算遍历的字符数
这种实现方法高效且简洁,充分利用了指针的特性。它避免了使用额外的计数变量,直接通过指针的移动来确定字符串长度。
需要注意的是,这个实现假设输入的字符串是以’\0’结尾的。如果传入的是一个无效的字符串(没有结束符),这个函数可能会导致未定义行为。在实际应用中,可能需要添加额外的安全检查。
8.2 传值调用和传址调用
在C语言中,函数参数的传递有两种主要方式:传值调用和传址调用。
8.2.1 传值调用
传值调用时,函数接收的是参数的副本。函数内部对参数的修改不会影响原始值。
void swap_value(int a, int b) {int temp = a;a = b;b = temp;
}int main() {int x = 5, y = 10;swap_value(x, y);printf("x = %d, y = %d\n", x, y); // 输出: x = 5, y = 10return 0;
}
8.2.2 传址调用
传址调用时,函数接收的是参数的地址。函数可以通过这个地址修改原始值。
void swap_address(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}int main() {int x = 5, y = 10;swap_address(&x, &y);printf("x = %d, y = %d\n", x, y); // 输出: x = 10, y = 5return 0;
}
传址调用通常用于:
- 需要在函数内修改传入变量的值
- 传递大型结构体以提高效率
- 返回多个值(通过指针参数)
8.3 编写函数:交换两个变量的值
1. 传值调用(无效的交换)
#include <stdio.h>
void swap_value(int a, int b) {int temp = a;a = b;b = temp;
}int main() {int x = 5, y = 10;printf("Before swap: x = %d, y = %d\n", x, y);swap_value(x, y);printf("After swap: x = %d, y = %d\n", x, y);return 0;
}
让我们详细分析这个传值调用的过程:
- 当调用
swap_value(x, y)
时,x
和y
的值被复制到函数的参数a
和b
中。 - 在函数内部,
a
和b
是局部变量,它们只在函数内部有效。 - 函数执行交换操作,但这只影响
a
和b
的值,不会改变main
函数中的x
和y
。 - 函数结束后,
a
和b
被销毁,它们的值丢失。 main
函数中的x
和y
保持不变。
输出结果:
Before swap: x = 5, y = 10
After swap: x = 5, y = 10
这种方法无法实现交换,因为函数操作的是参数的副本,而不是原始变量。
2. 传址调用(有效的交换)
#include <stdio.h>
void swap_address(int *a, int *b) {int temp = *a;*a = *b;*b = temp;
}int main() {int x = 5, y = 10;printf("Before swap: x = %d, y = %d\n", x, y);swap_address(&x, &y);printf("After swap: x = %d, y = %d\n", x, y);return 0;
}
现在让我们详细分析传址调用的过程:
- 调用
swap_address(&x, &y)
时,x
和y
的地址被传递给函数。 - 函数参数
a
和b
是指针,它们存储了x
和y
的地址。 *a
表示 “a
指向的值”,即x
的值;*b
表示 “b
指向的值”,即y
的值。- 通过操作
*a
和*b
,函数直接修改了x
和y
的值。 temp
用于临时存储*a
的值,确保交换过程中不会丢失数据。- 交换完成后,
x
和y
的值在main
函数中被成功交换。
输出结果:
Before swap: x = 5, y = 10
After swap: x = 10, y = 5
这种方法成功实现了交换,因为函数通过指针直接操作了原始变量。
传值调用与传址调用的对比
特性 | 传值调用 | 传址调用 |
---|---|---|
参数传递方式 | 复制值 | 传递地址 |
函数内修改 | 不影响原变量 | 直接修改原变量 |
内存使用 | 创建新的副本,可能占用更多内存 | 只传递地址,内存效率更高 |
适用场景 | 不需要修改原变量时 | 需要修改原变量或传递大型数据结构时 |
安全性 | 较高,原数据不会被意外修改 | 需要小心处理,可能导致意外修改 |
总结
- 传值调用:
- 函数接收参数的副本
- 无法修改原始变量
- 适用于简单的数据传递,不需要修改原始数据的场景
- 传址调用:
- 函数接收参数的地址
- 可以通过指针修改原始变量
- 适用于需要在函数内修改变量值或传递大型数据结构的场景
在实际编程中,选择合适的调用方式取决于具体需求。传址调用在需要修改原始数据或处理大型数据结构时特别有用,但使用时需要格外小心,以避免意外修改数据。理解这两种调用方式的区别和适用场景,对于编写高效、正确的 C 程序至关重要。
—完—