深入理解C语言内存空间、函数指针(三)(重点是函数指针)
文章目录
- 内存分配思想
- 只读空间:
- 数据段
- 堆空间
- 栈空间
- 函数基本
- 值传递
- 连续空间传递
- 结构体变量
- 数组名 标签
- 连续空间的只读性,
- 函数指针
内存分配思想
内存属性
1、大小
2、在哪里(也就是内存地址空间很多,我们需要知道在哪里,当产品需要对内存分配的时候。一般都是默认随机的。)
section 分段概念
内存分配的学习主要目的是:C语言编译的几个段的理解,什么变量放在什么位置,有助于一些其他项目的理解。
函数名就可以理解为一块代码的代名词,类似于是数组的标签。只不过数组的空间是一块一块的,连续的,
地址查看使用的是%p。
一般变量(全局和局部)和函数的地址分配空间是不一样的,
局部变量一般在高地址
全局变量、代码一般在低地址(仅作为参考,)
内核空间(高地址,这一段的地址空间,是不能访问的。)
栈空间 局部变量
运行时堆空间,malloc
静态段:只读空间
全局变量地址空间(初始化空间、未初始化空间) DATA、BSS段
只读数据段 (例如hello word) TEXT段
代码段 地址 TEXT段
只读空间:
代码段属于:只读空间,
因为代码写什么样子就是什么样子,在运行过程中肯定不会进行自动修改,
字符串是在可读空间?
text data bss dec hex
一般text、data、bss都是在静态数据,
编译过程,就是重新分配内存空间过程。
数据段
size build
text 代码段
data 数据段
bss 未初始化的数据段
这三个影响代码量的大小。
程序运行过程中,栈空间是不断的申请和释放。
局部变量在栈空间,当这个函数执行的时候就是临时存储一些这个函数的变量,当执行完这个函数以后,直接就会释放掉这个变量占用的内存空间,也就是出栈,
进入一个函数,内存就是开辟一个空间,存储这个函数的所有的局部变量,函数执行完成以后,栈空间就释放了,指针就不会指向这个开辟的空间。
并且栈空间不会占用静态段,运行的时候才会出现。
dss和data都是全局变量。
只不过如果我定义了以下
int b;
则会认为这个变量在dss段,因为没有写等号,所以在bss段,但是编译器会默认给0。也就是属于未初始化数据段。
DATA 全局初始化以后的变量存放的空间,
局部变量都是在栈空间,
static int a;
这种情况下,都是放在全局空间,可能是bss空间,因为没有初始化。
如果是
static int a=0;
就会在DATA空间。
nm查看静态空间的段名,首地址
访问的属性还是局部的,但是数据放在全局段中(静态段)。
原本这个地址就是存放的是a,不会被释放掉,这个地址一直会存放a这个变量。 占据整个代码量的大小。
堆空间
运行时的空间:运行时函数内部使用的变量。函数一旦返回就释放,生存周期是函数内。
运行时可以自由分配和释放的空间,是堆空间。自己管理。
生存周期是由程序员决定。
主要是:分配和释放,
分配:malloc,返回一个分配好的地址。只需要接收返回的地址。
常用用法
char * p
p = (char *)malloc();对于这个* p的读法,由设计决定。
只是提供一个没有任何内容的空间,给我们用
输入参数:分配内存的大小,单位是Byte。
malloc( 5 * sizeof(int) )
if(p == NULL)
{做一个判断,是否分配成功。
}
释放:
free(p)
栈空间:
静态空间:只读,整个程序结束时,是释放内存,生存周期最长。
栈空间
栈空间(Stack)是程序运行时的重要内存区域,主要用于存储与函数调用、中断处理等相关的临时数据。
- 函数调用上下文
- 返回地址(LR):子函数执行完毕后需返回父函数的位置,由链接寄存器(LR,X30)保存,调用子函数前会压入栈中。
- 帧指针(FP):指向当前函数栈帧的底部(高地址),用于界定函数栈边界,通常由X29寄存器保存,入栈后形成函数调用链。
- 调用者寄存器:部分需跨函数保留的寄存器(如ARMv7的R4-R11,ARM64的X19-X28)会在子函数中压栈保护,防止被覆盖。
- 局部变量
- 函数内部定义的非静态局部变量(如
int a;
)存储在栈帧中,生命周期仅限于函数执行期间,函数返回后自动释放。 - 示例:函数内数组、结构体等临时变量。
- 函数参数
- 超出寄存器容量的参数:ARM调用约定中,前几个参数通过寄存器传递(如ARM64的X0-X7),超出部分会压入调用者的栈空间。
- 可变参数函数:如
printf()
的多余参数需通过栈传递。
- 中断/异常上下文
- 发生中断或异常时,CPU自动将关键寄存器(PC、LR、CPSR等) 压入当前模式栈(如IRQ模式栈),用于恢复现场。
- 中断服务程序(ISR)中的局部变量也占用栈空间。
- 临时数据与中间结果
- 编译器生成的临时计算结果(如复杂表达式中间值)。
- 寄存器溢出:当寄存器不足时,部分中间变量暂存到栈中
ARM栈空间的核心作用是支撑函数调用链与临时数据存储,具体包括:函数返回地址(LR)、帧指针(FP)、局部变量、多余参数、中断上下文及编译器临时数据。其设计遵循架构规范(如AAPCS),通过栈指针(SP)和帧指针(FP)协同管理栈帧边界。开发中需警惕栈溢出风险,尤其在资源受限的嵌入式系统中。
栈顶地址(Stack Top Address)是计算机科学中栈(Stack)这一数据结构的关键概念,指栈中最后一个被插入元素的内存地址。栈是一种后进先出(LIFO)的线性表,所有操作(插入/删除)仅在栈顶进行。
- 操作唯一性:所有入栈(
PUSH
)和出栈(POP
)操作均通过修改栈顶地址完成:- 入栈:栈顶地址向低地址移动 → 新元素存入新地址。
- 出栈:栈顶地址向高地址移动 → 释放当前元素。
- 核心功能:
- 存储函数调用的返回地址、参数、局部变量;
- 实现递归和中断处理时的上下文保护。
函数基本
函数名也是连续空间的代名词,
函数具备三要素
1、函数名—标签(通过地址访问)
2、输入参数
3、返回值
定义函数,必须具备三要素,不然编译器是不知道这个是函数的
void fun (int, int, char) 顺序不同传递效果也不同。
{实现具体代码
}
如何用指针保存函数?
char *p
char (*p)[10] 读数组int (*p) (int, int, char) 保存的是函数地址
右边优先级高,
函数名称
定义函数,调用函数。
int (*myshow)(const char *,...)
一定需要将*myshow增加括号,这样我们就知道myshow是一个指针,
printf("hello word\n")
myshow = printf();函数名本身也是一个存储地址的,标签? myshow = ( int (*) (const char *, ....) )0x804832; /* 假设这个地址就是printf函数的地址,我们直接拿到这个地址用。前提是我们已经知道这个地方就是一个函数,因此我们将这个地址变成一个函数声明。 */
首先是变成一个*
(*)0x804832
又需要知道对应的函数返回值类型
(char *)0x804832
又因为是读函数所以还需要增加输入,并且*的修饰关系也需要注意
myshow = (int (*) (const char *,.....)) 0x804832
等于(*)修饰的是后面的一堆内容。
就是将这个地址(里面存的是函数),赋值给myshow,然后后续调用这个myshow就是相当于是访问这个函数。接着就是调用这个函数,
myshow("===================\n")不是太理解?函数
函数名就是一个地址这个一定要谨记。
输入参数:
实参是要传递的真是数据。
形参是接受的数据,要传递进去的。
实参传递给形参,
传递的形式:
copy
不用管数据是什么,直接就copy给接受的数据,
#include <stdio.h>void myswap(int buf) //int可以理解为,我在编译的时候预留了4个字节,作为接收器。
{}int main()
{int = 20;myswap( );return 0;
}
值传递
#include <stdio.h>void myswap(int a, int b) //int可以理解为,我在编译的时候预留了4个字节,作为接收器。首先要留出来类型,不然没办法接受。
{int c;c = a;a = b;b = c;}int main()
{int a = 20;int b = 30;myswap(a,b);return 0;
}
swap的变量是在栈空间,所以用完就释放掉了。出栈。
另外一个角度就是,只是值传递,
形参的目的是预先分配,不然过来的值,没有地方存放呐,
我传递的地址,就不一样了。
#include <stdio.h>void myswap(int *a, int *b) //int可以理解为,我在编译的时候预留了4个字节,作为接收器。首先要留出来类型,不然没办法接受。
{int c;c = *a;*a = *b;*b = c;这个内容操作的地址存储的值,其实这个地方不是太理解?通过地址去改变里面的内容,利用*p去实现。说白了可以通过*p去改变变量的值。
}int main()
{int a = 20;int b = 30;myswap(&a,&b);return 0;
}
值传递 可以看出来是一种保护
地址传递 就是一种修改了。
连续空间传递
数组和结构体
结构体变量
struct abc{int a;int b;int c;
}struct abc buf;传递实参:
fun(buf)传递形参 形式的接收,一定要保证形式是一样的,因此需要搞一个和buf一样的结构空间,因此就需要写出来一个。这个a1就是和buf的形式一样,不然没办法接收buf,然后将buf里面的内容copy到a1。
void fun(struct abc a1)另外一种就是地址传递
void fun(struct abc *a2)
再次啰嗦一下,struct abc想想成int就可以了。在资源不是充足的情况下,一般结构体使用指针的形式传递,这样可以节约内存。
数组名 标签
int abc[10];
这个abc是一个连续空间的首地址,这是编译器能看出来的。直接就摒弃了值传递,直接就是地址传递fun(abc) //abc就是一个这个数组的首地址,void fun(int *p) //利用数组标签
{}void fun(int p[10])
这样写实际上编译器还是只关系p的首地址,说白了这样写只是为了给人看,让别人一眼就能看出来,哦 这个是传递的数组是包含10个元素。
{}
一般在连续空间传递的时候,使用的是地址传递。
连续空间的只读性,
数组、结构体、字符串
函数指针
函数指针的声明 核心目的正是存储函数的首地址,但更重要的是保留函数的类型信息(包括返回值类型和参数列表)
举个例子,如果我们
int a =10;
int *p = &a;
b = *p;
解释:可以知道b=10,这因为首先我们知道了指针变量p里面存储的一个int类型变量地址,因此我们在解引用的时候编译器是知道怎么使用这个地址的,这样就使得b=10.
int a = 10; // 声明一个整型变量a,并赋值为10。编译器为a分配一块内存(比如地址0x1000),在这块内存中存储了数值10。
int *p = &a; // 声明一个指向整型的指针变量p。// &a 获取变量a的地址(即0x1000)。// 将地址0x1000 赋值给指针p。所以p存储的值就是0x1000,且p知道它指向的内存存储的是一个int。
int b = *p; // *p 是解引用操作:读取指针p所指向的内存地址(0x1000)处的值。// 因为p的类型是int*,编译器知道它指向一个int。// 因此,编译器会从地址0x1000开始,读取sizeof(int)个字节(通常是4字节),并将这4个字节解释为一个整数。// 这个整数就是10,然后将10赋值给整型变量b。
如果这个指针不声明什么类型,那么“因此,编译器会从地址0x1000开始,读取sizeof(int)个字节(通常是4字节),并将这4个字节解释为一个整数。”是不能成立的,就导致这个10可能取不出来。
-
指针存储地址:
p = &a;
这句执行后,变量p
里面存储的值就是变量a
在内存中位置的地址(如0x1000
)。它没有存a
的值10
,存的是a
在哪里。 -
指针带有类型信息: 声明
int *p;
中的int *
部分极其关键。这告诉编译器:p
是一个指针。p
指向的是整数类型 (int
) 的数据。
-
解引用依赖类型信息:
b = *p;
这条语句起作用的关键正是编译器理解了p
的类型是int *
:- 找地址: 编译器先知道要去
p
存储的地址(0x1000
)取值。 - 知道读多少字节:
p
的类型 (int *
) 告诉编译器,0x1000
处存储的是一个int
。编译器知道int
在特定系统上占多少字节(比如4个字节)。所以它会从这个地址开始连续读4个字节。 - 知道如何解释:
p
的类型 (int *
) 还告诉编译器,读取出来的4个字节要按照整数的二进制表示格式来解释(比如小端序、大端序等)。 - 正确赋值: 把这样解释出来的整数值(即10)赋给
b
。
- 找地址: 编译器先知道要去
-
地址(
0x1000
)就像是一个门牌号。 -
指针类型(
int *
)就像是贴在门上的标签,写着“这里面住着一个整数(整整齐齐的,占4个房间/字节)”。 -
解引用(
*p
)就像是你拿着门牌号去找这个门,并且看了门上的标签,所以你知道:1) 找到正确的门(地址);2) 进门后往里面走多远(读4个字节);3) 把里面看到的东西理解成一个数字(解释为整数)。
没有类型信息(没有门上的标签),编译器拿着地址也不知道该怎么读、读多少字节、解释成什么类型的数据。
以上啰嗦了一堆,是为了铺垫出下面这解释**函数指针**。
既然变量可以通过指针访问或者获取,那么函数同样也可以通过指针获取或者访问。但是我们要先明确函数指针是什么样子,
void (*)(void)
前面一个void表示一个无返回值
后面一个void表示不需要输入参数
同理我也可以声明其他类型的函数指针
void (*)(int):表示没有返回值,并且输入参数是int类型int (*)(void):表示返回值是int类型 并且没有输入参数某结构体类型 (*)(float):表示函数的返回值是某结构体类型,输入参数是float类型。如:struct MyStruct {int data;
};
// 声明函数指针
struct MyStruct (*funcPtr)(float);
一定要加括号。
- 指针函数:返回指针的函数(如
struct MyStruct* func(float)
)。 - 函数指针:指向函数的指针(如
struct MyStruct (*funcPtr)(float)
)
函数指针的声明语法 void (*funcPtr)(void)
明确了两点:
- 存储函数地址:
funcPtr = foo
将foo
函数的入口地址存入指针变量funcPtr
。 - 保留类型信息:通过声明中的
(void)
(无参数)和void
(无返回值),编译器知道如何正确调用该函数。
同理只要看到 void (*funcPtr)(void)
这种就是函数指针声明,并且funcPtr
就是存储函数的入口地址的变量,而两边的void
就是告诉编译器通过声明中的 (void)
(无参数)和 void
(无返回值),编译器知道如何正确调用该函数。 理解的思路和指针变量是完全一样的,因此我声明了什么类型的函数指针,那么我就只能存储什么类型的函数。不然就是犯指针变量的错误例如
float a =10;
int *p = &a; 错误,完全错误!!!!
b = *p;
这就相当于是驴唇不对马嘴,同理在函数指针这个地方一样,如果声明的和实际的函数类型不一样,就会出现驴唇不对马嘴。然后就意味着访问出错,一不小心就会出现踩内存或者直接编译就不会通过,这样更好。
结合例子进行错误示范:
若直接使用普通指针变量(如 void* p
)存储函数地址:
void* p = (void*)foo; // 强制类型转换存储地址
此时 p
虽然存储了 foo
的地址,但丢失了类型信息。后续无法直接通过 p
调用函数:
p(); // 错误!编译器无法识别 p 是可调用函数
(*p)(); // 错误!同上
普通指针变量的问题
1、无法直接调用函数:编译器无法将 void*
识别为函数指针类型,因此 p()
或 (*p)()
会触发编译错误
2、类型不安全:若强制调用(如 ((void (*)(void))p)()
),需手动匹配类型。一旦签名不匹配(如参数错误),会导致未定义行为(崩溃、数据损坏)
函数指针是 “类型化的地址”,普通指针是 “无类型地址”。
正确方式(函数指针):
void foo(void) { printf("Hello"); }
void (*funcPtr)(void) = foo; // 声明函数指针并初始化
funcPtr(); // 正确:输出 "Hello"
函数指针的核心价值在于:
- 动态调用:运行时切换不同函数(如策略模式)。
- 回调机制:将函数作为参数传递(如
qsort
的比较函数)。 - 类型安全:编译器可检查参数和返回值类型是否匹配。
- 必须声明函数指针变量:目的是存储函数地址 并保留完整的调用签名(返回类型 + 参数类型)。
- 不可用普通指针替代:普通指针(如
void* p
)存储函数地址后,无法直接调用函数,且强制转换易引发运行时错误。 - 核心价值:实现动态调用、回调等高级功能,同时确保类型安全。
知道了函数指针的作用,那么我们就在上面的基础上再结合typedef
关键字使用。
首先typedef是一个能起别名的关键字
typedef int a;
这是我们明确的int
类型变量我们通过关键字起了一个别名a
。
typedef void (*pFunction)(void);
typedef
的作用:从变量定义到类型别名
- 未使用
typedef
时:
void (*pFunction)(void);
表示声明了一个函数指针变量pFunction
,指向无参数、无返回值的函数。 - 使用
typedef
后:
typedef void (*pFunction)(void);
将pFunction
定义为类型别名,代表“指向无参数、无返回值函数的指针”这一类型本身。
此时pFunction
不是变量,而是类型名称(类似于int
或char*
)。
类比于变量的理解,其实一样的,变量你就能看懂,int a;
:表示a是一个int类型变量,但是typedef int a;
a就变成了int类型。
通过“去掉 typedef
分析原类型”的方法理解别名定义:
- 原声明:
void (*pFunction)(void)
→ 变量pFunction
是函数指针。 - 加上
typedef
:typedef void (*pFunction)(void)
→pFunction
成为该函数指针的类型名。
关键:typedef
将变量名(如pFunction
)转换为类型名,原变量定义中的标识符成为新类型的名称。
同理放在函数这里效果也是一样的,只是函数在声明的时候比较复杂一点,看起来唬人,其实本质是一样的。所以从这个地方就能看出来,在C语言中这些基本语法要从本质上去理解清楚,不然你当代码复杂的时候,你会发现你看起来很吃力,但是其实都是一样的道理,都是从内存出发,访问不同的内存,只不过我们在访问内存的时候根据不同的类型,去告诉编译器访问不同的字节,其本质的效果都是一样的。所以不要害怕,一定要从本质层面理解,机理层面理解,这样才能一通百通。
void (*func1)(void) = foo; // 未用别名,语法更复杂
void (*func2)(void) = bar;
typedef void (*pFunction)(void); // 定义函数指针类型别名
pFunction myFunc = foo; // 使用别名声明变量pFunction func1 = foo; // func1 指向函数 foo
pFunction func2 = bar; // func2 指向函数 bar
此时在看上面这两段代码是不是豁然开朗,变得通俗易懂。
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。