【C语言】第三章:内存与指针(指针上篇)
系列说明:在学习阶段我就开始写C语言的笔记并发表博客,本篇是我对学习阶段笔记的整理,希望和大家共同进步。如有遗漏欢迎大家指出。
运行环境:本系列所有演示代码均在Visual Studio 2022(x64)环境下编写。
目录
- 内存和地址
- 指针变量和地址
- 指针变量类型的意义
- 指针运算
- const 修饰指针
- 野指针
- assert 断言
- 传值调用与传址调用
一、正文
1. 内存和地址
计算机的CPU处理数据时需要从内存中读取。内存被划分为一个个内存单元,每个内存单元大小取1 个字节。每个内存单元都有一个编号,就像宿舍的门牌号——在计算机中这个编号叫地址,C语言中给地址起的新名字叫:指针。
内存单元的编号 == 地址 == 指针
编址原理:内存并不记录每个字节的地址,而是通过硬件设计完成。32位机器有32根地址总线,每根线有0/1两种状态,32根线可以表示2^32种地址;64位机器则有2^64种。这就是为什么 32 位平台指针占 4 字节、64 位平台指针占 8 字节。
2. 指针变量和地址
在C语言中创建变量就是向内存申请空间。&是取地址操作符,取出变量的地址;*是解引用操作符,通过地址访问指向的对象。
demo1_address.c —— 取地址、指针变量、指针大小
// demo1_address.c - 内存与地址:取地址、指针变量、指针大小#include<stdio.h>intmain(){inta=10;charch='w';// 取地址操作符 &,取出变量第一个字节的地址int*pa=&a;// 指针变量:存放 int 类型变量的地址char*pc=&ch;// 指针变量:存放 char 类型变量的地址// 打印地址(%p)printf("&a = %p\n",&a);printf("pa = %p\n",pa);// 解引用操作符 *:通过地址访问指向的对象*pa=20;// 等价于 a = 20printf("a = %d\n",a);// 指针变量的大小:与类型无关,取决于平台位数printf("sizeof(char*) = %zu\n",sizeof(char*));printf("sizeof(int*) = %zu\n",sizeof(int*));printf("sizeof(double*) = %zu\n",sizeof(double*));return0;}预期终端输出(地址值每次运行不同):
&a = 000000CE56FCF764 pa = 000000CE56FCF764 a = 20 sizeof(char*) = 8 sizeof(int*) = 8 sizeof(double*) = 8
运行结果:&a与pa相同;*pa = 20修改了 a;64位平台下所有指针大小都是 8 字节
结论:指针变量的大小与类型无关,只取决于平台位数(32位平台为4字节,64位平台为8字节)。
int*中的int表示指针指向的是int类型的对象。
3. 指针变量类型的意义
既然指针大小与类型无关,为什么还要各种指针类型?因为类型决定了解引用权限和步长。
3.1 解引用的权限
int*解引用能访问4 个字节,char*解引用只能访问1 个字节。
demo2_ptype.c —— 指针类型意义:权限与步长
// demo2_ptype.c - 指针类型的意义:解引用权限与步长#include<stdio.h>intmain(){intn=0x11223344;// 1. 解引用权限:int* 改4个字节,char* 改1个字节int*pi=&n;// *pi = 0; // 若执行,n 的4个字节全变为0char*pc=(char*)&n;// 强制转换成 char**pc=0;// 只把第一个字节改为0printf("n = 0x%x\n",n);// 结果 0x11223300(小端机器低位字节被改)// 2. 指针步长:+1 跳过多少个字节取决于类型intarr[3]={1,2,3};printf("arr = %p\n",arr);printf("arr+1 = %p\n",arr+1);// int*,跳过4字节printf("(char*)arr+1 = %p\n",(char*)arr+1);// char*,跳过1字节return0;}预期终端输出(地址值每次运行不同):
n = 0x11223300 arr = 000000BAD9EFFEE8 arr+1 = 000000BAD9EFFEEC (char*)arr+1 = 000000BAD9EFFEE9结论:
- 指针的类型决定了解引用的权限(一次操作几个字节)。
- 指针的类型决定了指针±1的步长(向前/向后走一步有多远)。
3.2 void* 指针
void*是无具体类型的指针(泛型指针),可以接收任意类型的地址。但void*不能直接进行解引用和指针±整数的运算,因为它不知道步长。void*常用于函数参数,接收不同类型数据的地址,实现泛型编程效果(后续 qsort 会用到)。
4. 指针运算
指针的基本运算有三种:指针±整数、指针-指针、指针的关系运算。
demo3_ptrop.c —— 指针运算
// demo3_ptrop.c - 指针运算:指针±整数、指针-指针、关系运算#include<stdio.h>// 用指针-指针求字符串长度(\0 之前字符个数)intmy_strlen(constchar*s){constchar*p=s;while(*p!='\0')p++;return(int)(p-s);// 两个指针相减得到元素个数}intmain(){intarr[10]={1,2,3,4,5,6,7,8,9,10};intsz=sizeof(arr)/sizeof(arr[0]);int*p=arr;// 数组名是首元素地址// 1. 指针+整数 遍历数组printf("指针+整数遍历: ");inti=0;for(i=0;i<sz;i++){printf("%d ",*(p+i));}printf("\n");// 2. 指针-指针:计算元素个数int*last=&arr[9];printf("arr 的元素个数 = %d\n",(int)(last-p)+1);// 3. 指针关系运算 + 指针-指针求字符串长度printf("my_strlen(\"abcdef\") = %d\n",my_strlen("abcdef"));// 4. 指针关系运算遍历数组printf("关系运算遍历: ");p=arr;while(p<arr+sz){printf("%d ",*p);p++;}printf("\n");return0;}预期终端输出:
指针+整数遍历: 1 2 3 4 5 6 7 8 9 10 arr 的元素个数 = 10 my_strlen("abcdef") = 6 关系运算遍历: 1 2 3 4 5 6 7 8 9 10
运行结果:*(p+i)访问数组元素;指针-指针得到元素个数;p < arr+sz关系运算遍历
5. const 修饰指针
const用来限制变量的修改。当const修饰指针时,放在*的左边还是右边意义不同:
int*p;// 无 const,都可变intconst*p;// const 在 * 左边:指向的内容不可改,指针本身可变int*constp;// const 在 * 右边:指针本身不可改,指向内容可通过指针改intconst*constp;// 左右都有:都不可改demo4_const_ptr.c —— const 修饰指针四种情形
// demo4_const_ptr.c - const 修饰指针的四种情形#include<stdio.h>// 无 const:指针和指向内容都可变voidtest1(){intn=10;intm=20;int*p=&n;*p=20;// ok,可修改指向内容p=&m;// ok,可修改指针本身printf("test1 ok\n");}// const 在 * 左边:指向的内容不可通过指针修改,指针本身可变voidtest2(){intn=10;intm=20;constint*p=&n;// *p = 20; // 错误!不能修改指向内容p=&m;// ok,指针本身可变printf("test2 ok\n");}// const 在 * 右边:指针本身不可变,指向的内容可通过指针修改voidtest3(){intn=10;intm=20;int*constp=&n;*p=20;// ok,可修改指向内容// p = &m; // 错误!不能修改指针本身printf("test3 ok, n = %d\n",n);}// * 左右都有 const:两者都不可变voidtest4(){intn=10;constint*constp=&n;// *p = 20; // 错误// p = &n; // 错误printf("test4 ok\n");}intmain(){test1();test2();test3();test4();return0;}预期终端输出:
test1 ok test2 ok test3 ok, n = 20 test4 ok记忆:
const在*左边,修饰的是指针指向的内容(不能通过指针改内容);const在*右边,修饰的是指针本身(指针不能再指向别处)。
6. 野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。
6.1 野指针成因
- 指针未初始化:局部变量指针未初始化,默认是随机值。
- 指针越界访问:指针指向范围超出数组范围。
- 指针指向的空间释放:如返回局部变量的地址,函数结束空间已销毁。
6.2 如何规避野指针
- 指针初始化:明确指向就赋值;不知道指向哪里就赋
NULL(NULL 的值是0,0地址无法读写)。 - 小心指针越界:只能访问申请过的空间。
- 不再使用时及时置 NULL,使用前检查有效性:约定 NULL 指针不访问,使用前判断是否为空。
- 避免返回局部变量的地址。
demo5_wild_ptr.c —— 规避野指针
// demo5_wild_ptr.c - 野指针:规避方法演示#include<stdio.h>#include<assert.h>// 错误示范(野指针成因之一):返回局部变量的地址// 编译器会警告 C4172,n 出函数即销毁,地址无效。// int* test_bad()// {// int n = 100;// return &n;// }intmain(){// 1. 指针初始化:明确指向就赋值,否则置 NULLintnum=10;int*p1=#// 明确指向int*p2=NULL;// 不知道指向哪里就置 NULL// 2. 使用前检查有效性if(p2!=NULL){printf("%d\n",*p2);}else{printf("p2 是空指针,不访问\n");}// 3. 数组越界会变成野指针intarr[10]={0};int*p=arr;inti=0;for(i=0;i<10;i++){*(p++)=i;// 结束后 p 已指向数组外}p=NULL;// 不再使用及时置空// 4. assert 断言检查assert(p1!=NULL);// 表达式为真则继续,否则报错终止printf("p1 = %d\n",*p1);return0;}预期终端输出:
p2 是空指针,不访问 p1 = 107. assert 断言
assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,不符合就报错终止运行。
assert(p!=NULL);- 若表达式为真(非零),assert 不产生作用,程序继续。
- 若表达式为假(零),assert 报错,在 stderr 写入错误信息(含表达式、文件名、行号)。
好处:自动标识文件和行号;可通过定义NDEBUG宏在编译期关闭所有 assert:
#defineNDEBUG#include<assert.h>
assert有运行时开销,一般 Debug 版本使用、Release 版本通过 NDEBUG 关闭。
demo6_assert.c —— assert 断言
// demo6_assert.c - assert 断言的使用与 NDEBUG 关闭#include<stdio.h>#include<assert.h>// 用 assert 检查形参有效性intmy_strlen(constchar*str){assert(str!=NULL);// 若 str 为 NULL,程序报错终止intcount=0;while(*str){count++;str++;}returncount;}intmain(){printf("len = %d\n",my_strlen("hello"));// 传 NULL 会触发 assert 失败(测试时可取消注释)// printf("len = %d\n", my_strlen(NULL));return0;}预期终端输出:
len = 58. 传值调用与传址调用
学习指针的目的是解决问题。以"交换两个整型变量的值"为例:
- 传值调用:形参是实参的临时拷贝,交换形参不影响实参 →交换失败。
- 传址调用:把变量的地址传给函数,函数通过地址操作主调函数的变量 →交换成功。
demo7_strlen_sim.c —— strlen 模拟 + 传值/传址对比
// demo7_strlen_sim.c - strlen 模拟实现 + 传值/传址调用对比#include<stdio.h>#include<assert.h>// 计数器法模拟 strlenintmy_strlen(constchar*str){assert(str!=NULL);intcount=0;while(*str){count++;str++;}returncount;}// 传值调用:无法交换voidswap_value(intx,inty){inttmp=x;x=y;y=tmp;}// 传址调用:通过地址交换voidswap_ptr(int*px,int*py){inttmp=*px;*px=*py;*py=tmp;}intmain(){// strlen 模拟printf("my_strlen(\"abcdef\") = %d\n",my_strlen("abcdef"));// 传值 vs 传址inta=3;intb=5;printf("交换前: a=%d b=%d\n",a,b);swap_value(a,b);printf("传值后: a=%d b=%d\n",a,b);swap_ptr(&a,&b);printf("传址后: a=%d b=%d\n",a,b);return0;}预期终端输出:
my_strlen("abcdef") = 6 交换前: a=3 b=5 传值后: a=3 b=5 传址后: a=5 b=3
运行结果:strlen 模拟返回6;传值调用交换失败,传址调用交换成功
结论:如果函数只是用主调函数变量的值计算,用传值调用;如果函数要修改主调函数的变量,必须用传址调用。
声明:本文为个人学习笔记,基于笔者学习内容整理,代码均在 VS2022 环境验证通过。