计算机基础:ADT(Abstract Data Type)抽象数据类型 (1)
1 指针、结构体、函数指针
1.1 普通指针
int *p;
-
定义:p 是一个指针变量 ,存储 int 类型变量的内存地址
-
核心:指针 = 地址
1.1.1 例-局部变量没有初始化
C++
#include<stdio.h>
int main()
{
int *p; // 【关键】局部指针变量,**没有初始化**
printf("p = %p\n",p);
return 0;
}
C
编译/运行/GDB调试:
gcc -g -O0 test1.c -o test1
./test1
gdb-multiarch ./test1


Int *p;是栈上局部自动变量,不会自动被初始化为NULL(0)
未被初始化的局部变量,内存是随机值->野指针。
C
p p
$1 = (int *) 0xff00000000
打印指针变量p内部存储的地址值。随机垃圾值(野指针)。
C
p *p
Cannot access memory at address 0xff00000000
对 p 解引用,尝试读取0xff00000000地址里的内存。
GDB 尝试读取地址 0xff00000000 处的 4 字节整数(int),但该内存区域不可访问,报错。
C
p &p
$2 = (int **) 0xfffffffffecf8
打印指针变量p自己所在的内存地址。
地址0xfffffffffecf8:是 64 位 Linux用户态栈的合法高地址,完全可正常访问!
1.1.2 例-指针没有赋值,直接解引用
C
int *p = NULL;
int *p = NULL; 作用:**消灭野指针**
野指针(int *p;):指向**随机垃圾地址**,*p 可能崩溃、可能偷偷篡改内存(隐形 bug,极难排查)
空指针(int *p=NULL):指向**固定地址 0**,*p 直接崩溃(显性 bug,一眼就能发现)




1.1.3 例-正确的指针使用方法
-
定义:声明指针变量
-
赋值:让指针指向合法内存地址
-
操作:安全解引用读写数据
C++
#include<stdio.h>
int main()
{
int *p = NULL;
int a = 1;
p = &a;
printf("p = %p &p = %p &a = %p\n",p,&p,&a);
printf("*p =%d\n",*p);
return 0;
}



| 表达式 | 含义 | 本次调试结果 | 状态 |
|---|---|---|---|
| &p | 指针变量p自己的地址 | 0xffffffffece0 | 合法栈地址 |
| p | 指针指向的目标地址 | 0xffffffffecdc = &a | 合法有效地址 |
| *p | 读取指针指向的数据 | 1 = 变量 a 的值 | 安全可访问 |
C
include<stdio.h>
#include<stdlib.h>
int main()
{
int *p = NULL;
p = malloc(sizeof(int));
if(p == NULL)
{
printf("error \n");
return 1;
}
*p = 1;
printf("p = %p,*p=%d\n",p,*p);
free(p);
p = NULL;
return 0;
}

C
malloc 正确使用四步曲:
定义指针 → int *p = NULL;
分配内存 → p = malloc(...);
校验返回值 → if(p == NULL)
使用 + 释放 → *p = 1 + free(p)
两个铁律
1 只要用 malloc,必须检查是否分配成功
malloc 失败 = 没内存了,无连续空间,参数错、堆损坏、没开堆、权限限制等等。
2 malloc 和 free 必须成对出现
C
短小临时变量 → 用栈(自动管理,最简单)
大临时变量、需要跨函数传递 → 用malloc 堆(用完 free)
全程都要、长期保存不变 → 全局变量 /static 静态变量
栈、堆都是临时中间变量;全局静态才是常驻数据
栈变量函数结束就销毁,所以不能把栈变量地址赋值给外部指针,会变成野指针;
但是malloc 堆的地址可以随便传、随便存,因为不 free 就一直有效。
C
int* test()
{
int a; // 栈变量,函数结束销毁
return &a; // ❌ 野指针!致命错误
int *p=malloc(sizeof(int)); // 堆,函数结束依然存在
return p; // ✅ 完全安全,可以返回
}
1.2 指针与数组
1.2.1 经典用法
数组名 = 数组首元素的地址(指针的经典用法)
C
int arr[3] = {1,2,3};
int *p = arr; // 等价于 p = &arr[0]
p[0] ↔ arr[0]; // 指针可以像数组一样访问元素
p[0] ↔ *(p+0)
arr[0] ↔ *(arr+0)
C++
#include<stdio.h>
#include<stdlib.h>
int main()
{
int arr[3] = {1,2,3};
int *p = arr; // 等价于 p = &arr[0]
printf("&p = %p p = %p p[0]=%p\n",&p,p,&p[0]);
printf("p[0] = %d\n",p[0]);
return 0;
}

&p 指针变量p自己的地址
p 指针指向的数组的首地址
&p0 数组首地址
1.2.2 malloc的4种使用场景
场景 1:需要超大内存(栈空间太小,会溢出)
-
栈内存很小(通常只有 1~8MB)
-
定义超大数组,栈直接崩溃,必须用 malloc
C
// ❌ 错误:栈放不下100万个int
// int big_arr[1000000];
// ✅ 正确:用malloc申请堆内存(堆空间极大)
int *p = malloc(1000000 * sizeof(int));
C
#include <stdio.h>
int main() {
// 错误:栈放不下 100万个int(约4MB,触发栈溢出)
int big_array[3000000];
printf("定义成功");
return 0;
}

栈溢出、段错误崩溃。

C
ulimit -s
//8192(单位 KB)= 8MB 栈上限
正确的代码:
C
include <stdio.h>
#include <stdlib.h>
int main()
{
int *p = malloc(sizeof(int)*3000000);
if(p == NULL)
{
printf("error\n");
return 1;
}
p[99999]=1;
printf("p[99999]=%d\n",p[99999]);
free(p);
p = NULL;
}

场景 2:需要跨函数使用数据(栈内存函数结束就销毁)
栈上的数组 / 变量,函数执行完自动消失,想让数据存活,必须用 malloc:
错误的用法:
C
int *create_data() {
int arr[3] = {1,2,3}; // 栈数组,函数结束销毁
return arr; // ❌ 野指针,无效
int *p = malloc(3*sizeof(int)); // 堆内存,函数结束还在
p[0]=1;
return p; // ✅ 安全,外部可以正常使用
}
C++
#include <stdio.h>
int *create_arr() {
int arr[3] = {1,2,3}; // 栈数组,函数结束销毁
return arr; // 错误:返回野指针!
}
int main() {
int *p = create_arr(); // p指向已销毁的内存
printf("%d", p[0]); // 崩溃/乱码
return 0;
}

正确的用法:
C
include<stdio.h>
#include<stdlib.h>
int *create_arr() {
int arr[3] = {1,2,3};
int *p = NULL;
p = malloc(3*sizeof(int));
if(p == NULL)
{
return NULL; //return a pointer type value
}
p[0] = arr[0];
p[1] = arr[1];
p[2] = arr[2];
return p;
}
int main() {
int *p = create_arr();
if(p == NULL)
{
return -1;
}
printf("p[0] = %d\n", p[0]);
free(p);
p = NULL;
return 0;

场景 3:需要动态大小的数组(运行时才知道长度)
栈数组必须用常量定义大小,动态长度只能用 malloc:
C
int n;
scanf("%d", &n); // 运行时才输入数组长度
// ❌ 错误:栈数组不能用变量定义大小(部分编译器不支持)
// int arr[n];
// ✅ 正确:malloc动态分配
int *p = malloc(n * sizeof(int));
C
//推荐
#include <stdio.h>
#include <stdlib.h>
int main() {
int n;
printf("请输入数组长度:");
scanf("%d", &n); // 运行时才确定大小
// 1. 动态分配 n 个int(核心:变量n作为长度)
int *p = (int *)malloc(n * sizeof(int));
if (p == NULL) return 1;
// 2. 赋值使用
for (int i = 0; i < n; i++) {
p[i] = i + 1;
}
// 3. 打印
for (int i = 0; i < n; i++) {
printf("%d ", p[i]);
}
free(p);
p = NULL;
return 0;
}
C++
//不推荐
#include <stdio.h>
int main() {
int n;
printf("请输入数组长度:");
scanf("%d", &n);
// 错误:栈数组不能用变量n定义大小(非标准语法)
int arr[n];
return 0;
}
C
C89 标准:"栈数组不能用变量定义",也是跨平台通用的安全写法。
变长数组(VLA)是 C99 的扩展,虽然 GCC 支持,但有几个明显的缺点:
可移植性差:换 MSVC、C++ 编译器就编译失败。
依然有栈溢出风险:n太大还是会崩溃,和普通栈数组一样。
调试不友好:GDB 里对 VLA 的支持不如普通数组和 malloc 数组。
**为了避免栈溢出、保证可移植性,运行时确定大小的数组,永远用malloc申请堆内存**
C++
//关于malloc申请空间,是否需要强制转换问题:不要加强制转换,并始终检查返回值。
#include <stdio.h>
// ❌ 忘记包含 stdlib.h
int main() {
// 1. 加了强转(地狱级错误)
int *p = (int *)malloc(4);
// 编译器隐式认为 malloc 返回 int
// 强转让编译器不报错!
// 64位系统:64位指针 截断为 32位int → 指针作废 → 段错误崩溃!
// 2. 不加 强转(安全)
int *p = malloc(4);
// 编译器直接报错:类型不匹配!
// 你立刻就能发现「忘记加头文件」
}
| 情况 | 是否推荐强制转换 | 原因 |
|---|---|---|
| 纯C项目,已包含 <stdlib.h> | 不推荐 | 冗余,可能隐藏头文件遗漏的错误 |
| 未包含 <stdlib.h> 且强制转换 | 危险 | 隐藏隐式声明导致的内存错误 |
| 需要兼容C++编译器 | 可能需要 | 但建议用 new 或条件编译 |
场景 4:需要长期保存数据(程序全程使用)
栈内存随函数销毁,全局 / 静态不够灵活,用 malloc 堆内存长期保存。
C
#include <stdio.h>
#include <stdlib.h>
int main() {
// 需求:临时存储10个数据,用完立即释放
int *data = malloc(10 * sizeof(int));
if (data == NULL) return 1;
// 使用数据
data[0] = 100;
printf("临时数据:%d\n", data[0]);
// 用完立刻释放(归还内存,不浪费)
free(data);
data = NULL;
// 后续程序不再占用内存,比全局变量更灵活!
printf("数据已释放,内存归还系统\n");
return 0;
}
C
栈内存:函数返回就丢失,无法"长期保存"。
全局/静态:虽然全程存在,但大小固定,无法动态增长,且作用域过大(全局可见),不利于模块化。
堆内存:生命周期由程序员控制,大小可在运行时决定,适合在多个函数间共享、大小未知或需要动态变化的数据。
1.3 指针作为函数参数
传递地址,可直接修改函数外部的变量(传值无法修改)
C
void change(int *a) { *a = 10; }
C++
#include <stdio.h>
// 1. 普通传值:无法修改外部变量(错误示范)
void change_val(int a) {
a = 10; // 只改副本,外部变量不变
printf("函数内(传值):a = %d\n", a);
}
// 2. 指针作为参数:可以修改外部变量(正确写法)
void change_ptr(int *a) {
*a = 10; // 解引用,直接修改外部原变量
printf("函数内(指针):*a = %d\n", *a);
}
int main() {
int num1 = 1;
int num2 = 1;
// 测试1:普通传值
change_val(num1);
printf("函数外(传值):num1 = %d\n\n", num1);
// 测试2:指针传参(传递变量地址 &num2)
change_ptr(&num2);
printf("函数外(指针):num2 = %d\n", num2);
return 0;
}

1.4 结构体
定义:自定义复合数据类型
成员访问:普通变量用 .,指针用 ->
C++
// 定义结构体
struct Student {
char name[10];
int age;
};
// 使用
struct Student s = {"Tom", 18};
s.age = 19; // 普通变量用.
struct Student *sp = &s;
sp->age = 20; // 结构体指针用->
C
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
// 定义结构体
struct Student {
char name[10];
int age;
};
int main()
{
// 使用
struct Student s = {"Tom", 18};
s.age = 19; // 普通变量用.
printf("s.name=%s s.age=%d \n",s.name,s.age);
struct Student *sp = &s;
sp->age = 20; // 结构体指针用->
snprintf(sp->name, sizeof(sp->name), "%s", "Ki");
printf("s.name=%s s.age=%d \n",s.name,s.age);
printf("s.name=%s s.age=%d \n",sp->name,sp->age);
printf("%p %p \n",&sp->name,&s.name);
struct Student *p = malloc(sizeof(struct Student)*10);
snprintf(p->name,sizeof(p->name),"%s","Jimmy");
p->age = 21;
printf("p.name=%s p.age=%d \n",p->name,p->age);
}

C
结构体变量 访问成员 ➜ 用 点号 .
结构体指针 访问成员 ➜ 用 箭头 ->
1.5 函数指针
指向函数的指针 ,存储函数的内存入口地址
声明格式:返回值类型 (*指针名)(参数类型列表)
示例:void (*fp)(int) → 指向「参数 int、返回值 void」的函数
C
#include <stdio.h>
// 普通函数
void say_hello(int times) {
for(int i=0;i<times;i++) printf("Hello\n");
}
int main() {
// 声明函数指针并赋值
void (*fp)(int) = say_hello;
fp(3); // 调用
return 0;
}
C
// 1. 引入标准输入输出库(printf需要)
#include <stdio.h>
// 2. 定义普通函数
// 返回值:void(无返回值),参数:int times(打印次数)
void say_hello(int times) {
for(int i=0;i<times;i++) printf("Hello\n");
}
// 3. 程序入口函数
int main() {
// 4. 函数指针 声明 + 赋值
// void (*fp)(int):声明函数指针fp
// say_hello:函数名 = 函数的内存地址,直接赋值给fp
void (*fp)(int) = say_hello;
// 5. 通过函数指针调用函数
// 等价于直接调用 say_hello(3)
fp(3);
// 6. 程序正常结束
return 0;
}



void (*fp)(int); 表示 fp 是一个指针,指向一个参数为 int、返回值为 void 的函数。
C
可以直接用函数名赋值,因为函数名就是该函数的地址。
fp = say_hello; // 正确
fp = &say_hello; // 也正确,取地址符可选
fp(3)等价于(*fp)(3) ,等价于 say_hello(3);
函数名 say_hello 本身代表函数的入口地址,类型是 void (*)(int)。
当用户声明 void (*fp)(int) = say_hello; 后,fp 是一个函数指针变量,存储的就是这个地址。
对函数指针解引用 *fp 得到的是函数标识符(function designator),在表达式中它会隐式转换回函数指针。
所以 *fp 作为一个值,与 fp 相同(都是地址)。
因此,fp(3) 和 (*fp)(3) 两种写法完全等价,编译器都会生成同样的 blr x1 指令。
C
p fp → 0xaaaaaaaa0758 <say_hello>
p *fp → {void (int)} 0xaaaaaaaa0758 <say_hello>
p fp 直接打印指针的值(函数地址)。
p *fp 打印的是 fp 所指向的函数,但 GDB 为了便于查看,仍会显示该函数的地址。
实际上,**函数没有"变量地址"的概念**,GDB 智能地展示了它的入口地址。
fp 的类型是 void (*)(int),而 *fp 的类型是 void (int)(函数类型)。
但在绝大多数表达式中,函数类型会自动退化为指针,所以可以忽略。
p &fp → 0xfffffffffed8
fp 本身是一个指针变量,它存储在栈上(或静态存储区,取决于作用域)。
&fp 是指向这个指针变量的指针,类型为 void (**)(int)(指向函数指针的指针)。
C
0xaaaaaaaa07bc <+24>: mov w0, #0x3
0xaaaaaaaa07c0 <+28>: blr x1
x1 寄存器中存放的就是 fp 的值,即 say_hello 函数的地址
info register x1 -> 0xaaaaaaaa0758
blr x1 执行间接跳转:跳转到 x1 中存储的地址,同时把返回地址保存在链接寄存器 x30 中。
函数指针 / 回调的本质就是间接调用。把函数地址当作数据传递,通过间接跳转执行。
C
内存中:
栈地址 内容(值)
0xfffffffffed8 → 0xaaaaaaaa0758 (这是变量 fp 的内容)
|
└── 指向代码段中的函数入口
代码段地址 机器码
0xaaaaaaaa0758 → say_hello 的第一条指令 (stp x29, x30, ...)
fp → 就是 0xaaaaaaaa0758(函数入口)
*fp → 函数本身,在 GDB 中打印仍显示 0xaaaaaaaa0758
&fp → 0xfffffffffed8(指针变量自己的地址)
函数指针的应用:
1.5.1 实现回调函数(Callback)
回调函数是一种设计模式,它的核心思想是:
-
用户事先写好一个函数(回调函数);
-
把这个函数的地址(函数指针)交给另一个框架/系统函数;
-
当特定事件发生时,框架自动调用用户的函数。
这实现了 "注册-触发" 的解耦逻辑,让框架和业务逻辑分离。
1.5.1.1 例子
C++
#include <stdio.h>
#include <unistd.h> // 模拟定时器用sleep
// 1. 定义回调函数类型:无返回值,接收一个int参数
typedef void (*TimerCallback)(int);
// 2. 定时器框架函数:接收超时时间和用户的回调函数
void set_timer(int timeout, TimerCallback cb) {
printf("定时器启动,%d秒后触发回调...\n", timeout);
sleep(timeout); // 模拟内核定时器等待
cb(timeout); // 超时事件发生,调用用户注册的回调
}
// 3. 用户自定义的回调函数:定时器超时后要执行的操作
void on_timer_expire(int seconds) {
printf("✅ 定时器超时!已等待%d秒,执行用户定义的操作\n", seconds);
printf("(比如:发送心跳包、刷新UI、触发业务逻辑)\n");
}
int main() {
printf("=== 定时器回调演示 ===\n");
set_timer(2, on_timer_expire); // 注册回调函数
return 0;
}

1.5.1.2 分析
回调注册机制的本质就是"函数指针作为参数"的编程模式。
C
回调函数:on_timer_expire
框架函数:set_timer
事件:定时器超时。超时后框架自动调用 on_timer_expire

C
main 函数调用 set_timer(2, on_timer_expire),传入超时秒数 2 和回调函数的地址。
进入 set_timer:打印"定时器启动,2秒后触发回调..."。
调用 sleep(2):模拟内核定时器等待,程序阻塞 2 秒。
2 秒后 sleep 返回:定时器超时事件发生。
执行 cb(timeout):cb 指向 on_timer_expire,因此实际调用 on_timer_expire(2)。
进入 on_timer_expire:参数 seconds 得到值 2,打印超时信息,执行用户定义的操作。
返回到 set_timer,再返回到 main,程序结束。
TimerCallback cb 是一个函数指针参数,使得 set_timer 可以调用任意符合该签名的用户函数,增强了通用性。
- TimerCallback 是通过 typedef 定义的一个函数指针类型:
C
typedef void (*TimerCallback)(int);
-
它表示:指向返回值为 void、参数只有一个 int 的函数的指针类型。
-
cb 就是这个类型的参数变量,它接收一个函数的地址(例如 on_timer_expire 的地址)。
-
在 set_timer 函数内部,通过 cb(timeout) 来调用用户传入的那个函数,就好像直接调用原函数一样。这实现了"框架不关心具体做什么,只负责到时调用"的解耦。
GDB debug:
在GDB中设置以下三个关键位置的断点:
-
断点1:set_timer的入口,观察其如何接收回调函数地址。
-
断点2:set_timer中调用回调函数cb(timeout)的那一行。
-
断点3:on_timer_expire的入口,观察它被调用时参数的传递。





set_timer 的反汇编:
C
0x0000aaaaaaaa0840 <+40>: ldr x1, [sp, #16] ; ① 从栈中加载 cb 的地址到寄存器 x1
0x0000aaaaaaaa0844 <+44>: ldr w0, [sp, #28] ; ② 加载 timeout 到 w0(第一个参数)
0x0000aaaaaaaa0848 <+48>: blr x1 ; ③ 间接跳转:跳转到 x1 中存储的地址
-
blr x1 中的 BLR 指令是 "Branch with Link to Register" 的缩写,为 跳转到寄存器中保存的地址,同时将返回地址保存到 x30(链接寄存器)。
-
此时 x1 中保存的值是0xaaaaaaaa0858。通过 p cb 可知,就是 0xaaaaaaaa0858,正是 on_timer_expire 函数的入口地址。
所以 blr x1 等价于调用 on_timer_expire。这是一种间接调用(indirect call),而不是直接写死在指令中的 bl on_timer_expire。
| 层级 | 概念 | 解释 | 类比 |
|---|---|---|---|
| 设计思想 | 回调 (Callback) | 一种程序设计的模式,指将一段可执行代码(A)作为参数传递给其他代码(B),并约定由B在特定时刻来执行A。它的本质是解耦。 | 你是一个系统管理员(B),你把"遇到系统错误就给我发邮件"这个行动指令(A)告诉监控软件(B),让软件在发现错误时自动执行这个指令。 |
| 实现技术 | 函数指针 (Function Pointer) | 在C/C++等语言中用于实现"可执行代码作为参数传递"这一思想的具体技术工具。它存储了函数在内存中的入口地址。 | "发邮件"这个指令,在代码层面,就是通过 '函数指针' 来指代的。 |
| 实体定义 | TimerCallback | 这是一个类型。我们用 typedef 声明了一种新的函数指针类型,规定了回调函数必须是"无返回值、接收一个int参数"的形式。 | 这是一种"指令模板",规定了有效的邮件指令必须是"包含发件人、收件人、主题、内容"四个字段的格式。 |
| 实体地址 | on_timer_expire | 这是一个函数。它的函数名被用作函数指针,代表该函数在内存中的首地址。它必须符合TimerCallback定义的签名,才能被用作回调。 | 这是你写的一条具体指令:发邮件(管理员, 本人, "系统错误", "详细信息如下..."),格式完全符合模板。整个指令的首地址就是它的逻辑起点。 |
直接调用 vs. 间接调用
| 调用方式 | 汇编指令 | 特点 |
|---|---|---|
| 普通函数调用 | bl <func_addr> | 目标地址直接编码在指令中,编译时确定 |
| 回调函数调用 | blr x1 (或 call *%rax 等) | 目标地址来自寄存器/内存,运行时动态确定 |
on_timer_expire 这个函数本身,无论被当作普通函数调用还是当作回调调用,它的机器码是完全一样的。
- "普通函数"和"回调函数"只是调用方式的区分,而不是函数的某种固有属性。
C
on_timer_expire(2); // 直接调用 → 生成 bl on_timer_expire
set_timer(2, on_timer_expire); // 作为回调注册 → 内部生成 blr x1
-
普通调用:编译器知道要去哪里,直接 bl 跳转。
-
回调调用:编译器不知道具体去哪里(因为是函数指针参数),只能生成 blr 或 call *reg 这种间接跳转。
所以,回调机制的本质,就是把函数的地址作为数据传递,然后用间接跳转指令去执行它。
C
//GDB: set_timer(2, on_timer_expire);
普通函数调用:
main -> set_timer : 0x0000aaaaaaaa08ac <+32>: bl 0xaaaaaaaa0818 <set_timer>
0xaaaaaaaa0818 <set_timer> //set_timer函数的首地址
回调函数调用:
set_timer -> on_timer_expire: 0x0000aaaaaaaa0848 <+48>: blr x1
info registers x1
x1 0xaaaaaaaa0858 187649984432216
p cb
$13 = (TimerCallback) 0xaaaaaaaa0858 <on_timer_expire>
//x1 中为0xaaaaaaaa0858 为 on_timer_expire函数的首地址
1.5.1.3 回调函数的历史
C
"回调函数"这一概念并非由某个人在一夜间发明,而是伴随着函数式编程思想的兴起而逐步演化形成的。
其核心逻辑------"后续传递风格"(Continuation-Passing Style, CPS),
最早出现在1975年由 Gerald Jay Sussman 和 Guy L. Steele, Jr. 共同完成的一份AI备忘录中。
他们当时的设计初衷,是希望探索一种更强大的计算表达形式:让函数能接收一个额外的"后续逻辑"作为参数,
并在完成计算后将结果自动传递给该逻辑,而非仅仅通过返回值传递。
这种新编程风格,最终落地于他们共同设计的 Scheme 语言。
因此,尽管callback这个词是后来才被广泛使用的,但Sussman和Steele提出的"后续传递风格",
开启了将"代码段作为参数传递"的先河。从编程思想的演变来看,可以将他们视为回调函数概念的奠基人。
1.5.1.4 回调函数技术总结:
C
1. 回调函数的实现,是进一步改进了编译器吗?
回调函数的实现,并没有"改进"编译器,恰恰相反,它是对现有编译器能力的一种应用和展示。
函数指针是基础能力:回调函数基于的"函数指针"概念,是C语言从设计之初就内建的核心功能,
编译器天生支持获取、存储和调用函数指针。
编译器并不特殊对待"回调":在编译器眼中,cb(timeout); 的调用与 on_timer_expire(timeout);
的调用仅仅是寻址方式不同(间接 vs 直接)。它并不会因为"这是个回调"而为其生成特殊代码。
一个有趣的"负面"效应:由于编译器在编译时通常无法确定函数指针究竟会指向哪个函数,
因此它无法对这种间接调用进行内联优化。这导致回调调用相比直接调用会有微小的性能损耗,
这是间接调度无法避免的成本。
简而言之,回调函数是编程思想层面的巧妙应用,其底层实现依赖的是编译器早已具备的"函数指针"能力。
"将一段可执行代码作为参数传递给另一段代码"的思想。
C
2. 回调函数的运行时的本质
编译器扮演的角色:编译器在编译时完成了"基础设施"的构建:
分配地址:它为所有函数(包括 on_timer_expire)在代码段分配了唯一的内存入口地址。
生成代码:它生成机器指令,用于在运行时将这个地址读取到寄存器中,以及通过 call 指令跳转。
类型检查:在编译期,它会检查函数指针TimerCallback的类型是否与on_timer_expire的签名匹配,
以确保后续的安全调用。
运行时的关键玩家---CPU:真正负责执行"调用"这一动作的,是CPU:
当程序执行到 set_timer 内部,遇到 cb(timeout);
这条语句时,CPU会读取寄存器中保存的函数地址(例如 on_timer_expire 在内存代码段中的地址)。
然后,CPU执行一条间接调用指令(如 call *%rax),将程序的控制权跳转到该地址指向的机器指令处,
开始执行回调函数的代码。
同时,CPU会按照调用约定,默默处理好参数的传递(将 timeout 的值放入第二个参数寄存器或压入栈中)
和栈帧的管理。
所以,整个"传递地址、调用函数、传递参数"的流程,是编译器(负责编译时布局和类型校验)和
CPU(负责运行时指令跳转和参数传递)协同工作的结果。
1.5.1.5 应用场景
| 领域 | 框架/技术 | 回调的应用 |
|---|---|---|
| GUI 编程 | Qt、GTK、Win32 | 按钮点击、鼠标移动等事件绑定回调函数 |
| 网络编程 | libcurl、Node.js、epoll | 数据到达、连接完成时调用用户回调 |
| 嵌入式 RTOS | FreeRTOS、Zephyr | 定时器回调、任务通知、中断下半部处理 |
| 操作系统内核 | Linux VFS、设备驱动(file_operations) | 系统调用如 read/write 最终调用驱动注册的回调 |
| 异步 I/O | POSIX AIO、io_uring | 读写完成后触发回调函数 |
| 数据库 | sqlite3_exec() 的回调 | 每一行查询结果调用用户函数处理 |
| 游戏引擎 | Unity (C# 委托)、Unreal | 动画结束、碰撞事件的回调 |
| 移动开发 | Android RILD、Binder | 回调处理 Modem 上报、进程间通信完成 |
| Web 开发 | JavaScript 事件循环、Ajax Promise | setTimeout、网络请求 onload 回调 |
1.5.2 实现多态 / 接口封装(底层开发核心)
在底层开发(如操作系统、驱动、嵌入式系统)中,"多态"通常指:同一套接口函数,根据不同的具体对象或场景,表现出不同的行为。
结构体 + 函数指针 = 定义统一接口
C
定义一个结构体,其中包含若干个函数指针。
不同的具体实现各自提供一套自己的函数实现,并填充这个结构体。
上层代码 只依赖这个结构体中的函数指针,不关心具体是哪个实现。运行时根据传入的结构体指针自动调用对应的函数。
编译时解耦、运行时绑定。
1.5.2.1 例子
C++
// ========== 接口定义(相当于 C++ 的抽象基类) ==========
#include <stdio.h>
// 动物的 "虚函数表"
typedef struct AnimalVtbl {
void (*speak)(void); // 叫声接口
void (*move)(void); // 运动接口
} AnimalVtbl;
// "动物对象"(相当于 C++ 的对象)
typedef struct Animal {
const AnimalVtbl *vptr; // 指向虚函数表
const char *name; // 动物名称(额外的数据)
} Animal;
// ========== 具体实现:狗 ==========
void dog_speak(void) { printf("🐕 汪汪!\n"); }
void dog_move(void) { printf("🐕 摇尾巴跑过去\n"); }
const AnimalVtbl dog_vtbl = { dog_speak, dog_move };
Animal create_dog(void) {
Animal dog = { &dog_vtbl, "旺财" };
return dog;
}
// ========== 具体实现:猫 ==========
void cat_speak(void) { printf("🐈 喵喵~\n"); }
void cat_move(void) { printf("🐈 悄无声息地跳上柜子\n"); }
const AnimalVtbl cat_vtbl = { cat_speak, cat_move };
Animal create_cat(void) {
Animal cat = { &cat_vtbl, "咪咪" };
return cat;
}
// ========== 上层多态调用函数(不关心具体动物) ==========
void animal_perform(const Animal *a) {
printf("【%s】", a->name);
a->vptr->speak(); // 通过函数指针调用多态行为
a->vptr->move();
printf("---\n");
}
// ========== 主程序 ==========
int main() {
Animal dog = create_dog();
Animal cat = create_cat();
animal_perform(&dog);
animal_perform(&cat);
return 0;
}

1.5.2.2 分析






1)函数地址是编译时已知的,所以可以打印。
2)全局变量(如 dog_vtbl)也是编译时分配好地址,可以打印(实际上用户打印 dog_vtbl 成功了)。
3)局部变量(栈上的 dog, cat)只有在运行时分配,且 main 开始时未初始化,所以此时查看它们没有意义。
4)vptr 的地址(即某个对象中 vptr 字段的地址),须等到该对象构造函数执行后才可得到。
代码段(.text)存放函数指令,数据段(.data/.rodata)存放全局变量(如 dog_vtbl, cat_vtbl, 字符串常量),栈段存放局部变量(如 main 中的 dog 和 cat 对象)。

Java
(gdb) p &dog_vtbl
$1 = (const AnimalVtbl *) 0xaaaaaaabfd58 <dog_vtbl>
(gdb) p dog_vtbl
$2 = {speak = 0xaaaaaaaa08d8 <dog_speak>,
move = 0xaaaaaaaa08f8 <dog_move>}
(gdb) p create_dog
$1 = {Animal (void)} 0xaaaaaaaa0918 <create_dog>
(gdb) p create_cat
$2 = {Animal (void)} 0xaaaaaaaa0980 <create_cat>
(gdb) p animal_perform
$3 = {void (const Animal *)} 0xaaaaaaaa09a8 <animal_perform>
(gdb) p dog_vtbl
$4 = {speak = 0xaaaaaaaa08d8 <dog_speak>,
move = 0xaaaaaaaa08f8 <dog_move>}
(gdb) p dog_speak
$5 = {void (void)} 0xaaaaaaaa08d8 <dog_speak>
(gdb) p dog_move
$6 = {void (void)} 0xaaaaaaaa08f8 <dog_move>
......
(gdb) p dog
$1 = {vptr = 0xaaaaaaabfd58 <dog_vtbl>, name = 0xaaaaaaaa0ac0 "旺财"}
(gdb) p dog.name
$2 = 0xaaaaaaaa0ac0 "旺财"
(gdb) p dog.vptr
$3 = (const AnimalVtbl *) 0xaaaaaaabfd58 <dog_vtbl>
(gdb) p dog.vptr.speak
$4 = (void (*)(void)) 0xaaaaaaaa08d8 <dog_speak>
(gdb) p dog.vptr.move
$5 = (void (*)(void)) 0xaaaaaaaa08f8 <dog_move>
(gdb) p cat_vtbl
$9 = {speak = 0xaaaaaaaa0940 <cat_speak>,
move = 0xaaaaaaaa0960 <cat_move>}
(gdb) p cat
$12 = {vptr = 0xaaaaaaabfd68 <>, name = 0xaaaaaaaa0b00 "咪咪"}
(gdb) p cat.vptr
$13 = (const AnimalVtbl *) 0xaaaaaaabfd68 <cat_vtbl>
(gdb) p cat.name
$14 = 0xaaaaaaaa0b00 "咪咪"
(gdb) p cat.vptr.speak
$15 = (void (*)(void)) 0xaaaaaaaa0940 <cat_speak>


C++
// ========== 接口定义(相当于 C++ 的抽象基类) ==========
#include <stdio.h>
// 动物的 "虚函数表"
typedef struct AnimalVtbl {
void (*speak)(void); // 叫声接口
void (*move)(void); // 运动接口
} AnimalVtbl;
// "动物对象"(相当于 C++ 的对象)
typedef struct Animal {
const AnimalVtbl *vptr; // 指向虚函数表
const char *name; // 动物名称(额外的数据)
} Animal;
// ========== 具体实现:狗 ==========
void dog_speak(void) { printf("🐕 汪汪!\n"); } //0xaaaaaaaa08d8 代码段 (.text)
void dog_move(void) { printf("🐕 摇尾巴跑过去\n"); }//0xaaaaaaaa08f8 代码段 (.text)
/*
0xaaaaaaaa0000 0xaaaaaaaa1000 0x1000 0x0 r-xp /home/.../polymorph (代码段)
0xaaaaaaaaabf000 0xaaaaaaaaac0000 0x1000 0xf000 r--p /home/.../polymorph (只读数据段)
0xaaaaaaaaac0000 0xaaaaaaaaac1000 0x1000 0x10000 rw-p /home/.../polymorph (读写数据段)
0xaaaaaaaaac1000 0xaaaaaaaaac2000 0x210000 0x0 rw-p [heap]
*/
const AnimalVtbl dog_vtbl = { dog_speak, dog_move }; //0xaaaaaaabfd58 存储在 .data 段
/*
(gdb) p &dog_vtbl
$1 = (const AnimalVtbl *) 0xaaaaaaabfd58 <dog_vtbl>
(gdb) p dog_vtbl
$2 = {speak = 0xaaaaaaaa08d8 <dog_speak>,
move = 0xaaaaaaaa08f8 <dog_move>}
*/
Animal create_dog(void) { //0xaaaaaaaa0918
Animal dog = { &dog_vtbl, "旺财" };//0xaaaaaaabfd58 0xaaaaaaaa0ac0
return dog;
}
// ========== 具体实现:猫 ==========
void cat_speak(void) { printf("🐈 喵喵~\n"); } //0xaaaaaaaa0940
void cat_move(void) { printf("🐈 悄无声息地跳上柜子\n"); } //0xaaaaaaaa0960
const AnimalVtbl cat_vtbl = { cat_speak, cat_move }; //0xaaaaaaabfd68
Animal create_cat(void) { //0xaaaaaaaa0980
Animal cat = { &cat_vtbl, "咪咪" };//0xaaaaaaabfd68 0xaaaaaaaa0b00
return cat;
}
// ========== 上层多态调用函数(不关心具体动物) ==========
void animal_perform(const Animal *a) { //0xaaaaaaaa09a8
printf("【%s】", a->name);
a->vptr->speak(); // 通过函数指针调用多态行为
a->vptr->move();
printf("---\n");
}
// ========== 主程序 ==========
int main() {
Animal dog = create_dog();
Animal cat = create_cat();
animal_perform(&dog);
animal_perform(&cat);
return 0;
}

C
typedef struct AnimalVtbl 只是类型定义,不分配任何内存,也不产生代码。
它告诉编译器 AnimalVtbl 是什么结构。typedef 是编译时指令,它不产生任何机器码或数据,
只影响编译器的类型检查。typedef 本身没有运行时映像。
typedef struct AnimalVtbl { ... } AnimalVtbl;
是类型别名定义,编译后不占用任何内存段。实际占用内存的是 dog_vtbl 和 cat_vtbl 这些全局变量。
运行时,在 main 函数的栈帧上为 dog 和 cat 分配内存。
每个对象通过工厂函数初始化,使其 vptr 指向全局数据段中对应的虚函数表(如 dog_vtbl),
name 指向只读数据段中的字符串常量。
animal_perform 参数是 const Animal *a,它接受指向任何 Animal 对象的指针。
函数内部通过 a->vptr->speak() 和 a->vptr->move() 调用,不区分具体动物。
这正是"框架"或"模板"的含义:同一套代码,根据传入对象的实际类型执行不同的具体行为。
它实现了算法的骨架与具体实现的分离。
| 概念 | 在 polymorph.c 中的体现 | 对应底层机制 |
|---|---|---|
| 接口定义 | AnimalVtbl 结构体 | 类型定义(无运行时开销) |
| 虚函数表 | dog_vtbl, cat_vtbl 全局变量 | 存储在 .data 段,包含函数指针 |
| 对象 | Animal 结构体,含 vptr 和 name | 栈上分配,通过指针关联虚表和字符串 |
| 多态调用 | animal_perform 通过 a->vptr->speak() | 间接调用(blr x1),运行时动态绑定 |
| 框架 | animal_perform 函数 | 与回调框架类似,解耦调用者和实现者 |
1.5.2.3 指针变量与函数指针的区别
| 项目 | 数据指针 | 函数指针 |
|---|---|---|
| 定义 | int *p; | void (*f)(void); |
| 指向对象 | 数据(变量、数组、结构体等) | 函数(可执行代码) |
| 解引用 | *p 得到数据值 | (*f)() 或 f() 调用函数 |
| 算术运算 | 支持 p+1 等(以指向类型大小为单位) | 不支持 f+1(标准C禁止) |
| 典型用途 | 传递数据、动态内存、数组遍历 | 回调、多态、延迟绑定 |
核心相同点:都是存储地址的变量。核心不同点:所指对象类型不同,且函数指针不支持算术运算。
C
指针变量相关的符号含义
假设 int a = 10; int *p = &a;
p :指针变量本身,其存储的值为 &a(变量 a 的地址)
&p :指针变量 p 自身在内存中的地址(指针的地址)
*p :对 p 解引用,得到 p 所指向的对象的值(即 a 的值 10)
C
函数指针的"解引用"
对于 void (*speak)(void);,speak 是函数指针。
调用方式:speak(); 或 (*speak)(); 两者完全等价。
标准规定函数设计符会自动转换为函数指针,所以直接写函数名或指针名即可调用,显式解引用是可选的。
函数指针可以解引用,只是通常省略。
a->vptr->speak() 中:a->vptr 指向虚表,speak 是虚表内的函数指针,
取出该指针后直接调用(隐式解引用)。
C
const AnimalVtbl cat_vtbl = {...}; // cat_vtbl 是结构体变量
Animal cat = { &cat_vtbl, "咪咪" };
cat_vtbl 是一个结构体对象的名字,它代表整个结构体的内容。
在C语言中,结构体变量名不会自动转换为地址(与数组名不同)。
&cat_vtbl 取该结构体变量的地址,类型为 const AnimalVtbl*,与 Animal 中 vptr 的类型匹配。
字符串 "咪咪" 是数组(字符数组),数组名在表达式中退化为指向首元素的指针,因此不需要写 &。
如果写成 Animal cat = { cat_vtbl, "咪咪" };,编译器会报错:试图用结构体值初始化指针。

1.5.2.4 多态 / 接口封装的历史
C
1. 理论奠基与语言实践:Simula与Smalltalk
1967年:Simula 67诞生
谁:由挪威计算中心的 Ole-Johan Dahl 和 Kristen Nygaard 设计开发。
为解决了什么问题:为了解决在 FORTRAN 等早期语言中开发大型复杂系统
(特别是用于模拟"真实世界"的复杂系统,如人机系统)时,过程式语言的结构化能力不足,
导致数据和操作难以有效组织的问题。
贡献:Simula 67被广泛认为是第一个面向对象的编程语言,首次引入了"类(class)"和"对象(object)"的概念,
将数据和行为封装在一起。
1970年代:Smalltalk的完善
谁:由 Alan Kay 等在施乐帕洛阿尔托研究中心(Xerox PARC)提出。
贡献:它在Simula的基础上,首次明确、系统地实现了对象的 [封装] 、 [继承] 和 [消息传递] 机制,
并对 [多态] 给出了里程碑式的解决方案,极大地丰富了面向对象编程的理论。
💡 2. "多态"概念的提出
1967年:Christopher Strachey
事件:Christopher Strachey 非正式地向程序设计语言引入了"多态(Polymorphism)"这一概念。
为解决了什么问题:Strachey在分析函数时发现,有些函数可以在不同类型上工作或表现出不同行为。
为了更理论化地描述和分类这种特性,他正式提出了"多态"的概念。
C语言中的实用主义实践
虽然Simula和Smalltalk提供了完整的理论,但真正让"多态/接口封装"思想在系统级和嵌入式开发领域大规模应用的,
是C语言的"接口/实现分离"模式。硬件厂商可以通过一个统一的函数指针接口表来对接上层,
这几乎是一种强制性的工业标准,它完美地实践了接口封装的思想,
使得不同厂商的硬件(Modem)能够对接同一个上层操作系统(Android),实现了硬件无关性。
1.5.2.5 多态 / 接口封装的技术原理总结:
在 C 语言中,通过函数指针模拟面向对象的多态和接口封装,其核心原理可以归纳为以下三点:
-
虚函数表(Virtual Table)
-
定义一个结构体,其中包含若干函数指针,表示一组相关的接口(如 speak、move)。
-
每个具体实现(狗、猫)提供自己的函数实现,并创建一个全局只读的虚表实例,将这些函数地址填入。
-
-
对象结构体包含指向虚表的指针
-
对象结构体(如 Animal)的第一个成员(或任意成员)是一个指向虚表的指针 vptr。
-
对象中还可能包含其他数据(如 name),这些数据与行为分离,但通过对象捆绑在一起。
-
-
间接调用实现动态绑定
-
上层函数(如 animal_perform)接收对象指针,通过 对象->vptr->speak() 方式调用。
-
底层编译为间接跳转指令(如 ARM64 的 blr x1,x86-64 的 call rax),根据对象的实际虚表地址跳转到对应的函数。
-
这种"调用者不关心具体类型,运行时根据对象决定行为"的机制,就是多态。
-
接口封装则体现在:animal_perform 只依赖 Animal 结构体及其虚表,与具体动物实现解耦。
-
核心要点:多态不是语言特性,而是一种设计模式,通过函数指针表 + 间接调用实现。它与回调函数的本质一致(函数地址作为数据传递,间接跳转执行),只是回调通常只有一个函数指针,而多态表包含多个。
1.5.3 信号处理例子(signal注册回调)
1.5.3.1 核心前置知识
-
Linux 信号:软件中断(如 Ctrl + C 会发送 SIGINT 信号,默认终止程序)
-
signal () 函数:系统调用,作用是注册信号回调函数
- 原型:signal(信号编号, 回调函数指针)
-
回调本质:
-
用户写好处理函数 → 交给内核
-
信号触发时 → 内核自动调用用户的函数(不用用户手动调用)
-
C
signal(信号, 函数指针) = 注册信号回调
回调函数:用户写逻辑,内核自动调用
这是 Linux 系统编程 / 嵌入式开发 的核心用法
本质:函数指针 + 异步事件触发
1.5.3.2 signal 函数的参数与机制
C
函数原型:
版本1:
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
版本2:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
signum:要捕获的信号编号,如 SIGINT (2, Ctrl+C)、SIGTERM (15, 终止)、SIGSEGV (11, 段错误) 等。
handler:信号处理方式,可以是:
函数指针:用户自定义的回调函数,原型必须是 void func(int);
SIG_IGN:忽略该信号。
SIG_DFL:恢复默认行为(通常是终止进程)。
C
注册:signal 通过系统调用 rt_sigaction 将用户提供的 handler 地址和信号编号存入内核的信号处理表。
触发:当进程收到指定信号时(如 Ctrl+C 产生 SIGINT),内核会中断进程的正常执行流。
调用:内核在用户态栈上建立信号帧,跳转到注册的 handler 地址执行(间接调用,类似 blr)。
返回:handler 执行完毕后,通过特殊的 sigreturn 系统调用恢复进程被中断前的上下文。
注意:signal 的行为因 Unix 版本而异(有 System V 和 BSD 两种风格),现代程序推荐使用更可靠的sigaction
1.5.3.3 例子
C++
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 回调函数:由框架(内核)在收到 SIGINT 时调用
void handle_sigint(int sig) {
printf("in handler\n");
fflush(stdout);//强制刷新输出缓冲区,确保信息立刻出现在终端,即使 GDB 中断或程序异常也不会丢失
printf("\n收到信号 %d (SIGINT),执行用户定义的回调逻辑\n", sig);
printf("可以在这里做清理、重置或退出操作\n");
// 恢复默认行为,避免下次退出(可选)
// signal(SIGINT, SIG_DFL);
}
int main() {
// 注册回调:将 handle_sigint 的地址传给 signal 框架
signal(SIGINT, handle_sigint);
printf("程序运行中,按下 Ctrl+C 触发回调...\n");
while(1) {
pause(); // 等待信号
}
return 0;
}
C
gcc -g3 -O0 signal.c -o signal
-g3:
生成最详细的调试信息,包括宏定义、#define 等
允许 GDB 查看宏(如 SIGINT 的实际值),并能精确定位到源文件行号
-O0:
关闭所有编译器优化
保证源代码与汇编指令一一对应,变量不会被优化掉,断点不会乱跳,函数调用栈清晰


1.5.3.4 分析

signal(SIGINT, handle_sigint);
signal注册成功。

执行signal SIGINT,调用handle_sigint函数。
在GDB debug中 Ctrl+c 可以使用signal SIGINT命令。
如果在GDB调试过程中,程序被卡中,可以使用"另一个终端用 kill -INT $(pidof signal) 发送信号"。
C
kill -INT $(pidof signal)
当 GDB 内部的 signal SIGINT 命令无法正常触发回调时,作为外部验证手段。
这个方法在调试异步事件、信号、中断时非常常用------当调试工具自身干扰了时序时,用外部独立的方式触发事件,可以快速确认底层功能是否正常。


汇编代码分析:




- main 调用 signal
C
0xaaaaaaaa0930: adrp x0, 0xaaaaaaaaa0000 ; 计算地址池基址
0xaaaaaaaa0934: add x1, x0, #0x8d8 ; x1 = 0xaaaaaaaa08d8
(handle_sigint 的地址)
0xaaaaaaaa0938: mov w0, #0x2 ; w0 = 2 (SIGINT)
0xaaaaaaaa0940: bl 0xaaaaaaaa0740 <signal@plt> ; 调用 signal@plt
-
参数传递(ARM64 调用约定):
-
第一个参数 SIGINT (2) 放入 w0
-
第二个参数 handle_sigint 的函数地址放入 x1
-
-
跳转:bl signal@plt 通过 PLT 跳转到 C 库中的 __bsd_signal 函数(即 signal 的实现)。
所以 main 直接调用了 signal 函数,并传入 SIGINT 和 handle_sigint 地址作为参数。
- main 调用 handle_sigint
main 并没有直接调用 handle_sigint。 在 main 的汇编中,没有任何 bl 或 blr 指令指向 0xaaaaaaaa08d8。 handle_sigint 只是作为函数指针被传递给 signal 框架,由框架(内核)在信号发生时间接调用。
- signal 内部处理 handle_sigint
C
0x0000ffff7e2cae8: bl 0xffffff7e2cbeb0 <__GI__sigaction>
-
__bsd_signal 最终调用 __GI__sigaction(系统调用 rt_sigaction 的包装)。
-
这个系统调用将 handle_sigint 的地址(保存在 x1 中)存入内核的进程信号处理表。
-
当进程收到 SIGINT 时,内核会通过这个保存的地址间接调用 handle_sigint。
C
signal 内部处理流程:
signal 函数(实际是 __bsd_signal)主要做两件事:
将用户提供的 handler 地址(如 0xaaaaaaaa08d8)
和信号编号(SIGINT)打包成内核要求的 struct sigaction。
在反汇编中,可以看到 __bsd_signal 最终调用了 __GI__sigaction(地址 0xffff7e2cae8 处的 bl 指令),
这就是系统调用 rt_sigaction 的封装。
通过系统调用进入内核,将 handler 地址保存在当前进程的 sigaction 数组中(位于内核维护的进程控制块内)。
之后,当进程收到 SIGINT,内核会:
中断进程当前执行(比如 pause() 系统调用)
从 sigaction 数组中取出 sa_handler(即 0xaaaaaaaa08d8)
在用户态栈上构造信号帧,然后将程序计数器 PC 设置为该地址,执行 eret 返回用户态,
从而间接跳转到 handle_sigint。
C
strace log:
strace -e trace=rt_sigaction,signal,pause,rt_sigreturn,kill -o strace.log ./signal


-
SIGINT 是信号编号(2)。
-
{sa_handler=0xc49f152208d8} 就是 handle_sigint 函数的地址。
-
sa_handler 地址 0xc49f152208d8 与之前 GDB 中看到的 0xaaaaaaaa08d8不一致是由于 ASLR(地址空间布局随机化)和PIE(位置无关可执行文件)。
| 函数 | 调用方式 | 说明 |
|---|---|---|
| signal | 直接调用(bl signal@plt) | main 主动调用注册函数 |
| handle_sigint | 间接调用(由内核通过函数指针触发) | main 不直接调用,仅传递地址 |
1.5.3.5 对比回调函数例子和信号处理
| 对比维度 | 普通回调例子(如 set_timer) | 信号处理例子(signal) |
|---|---|---|
| 框架 | 用户态库函数(如 set_timer) | 操作系统内核(信号机制) |
| 注册接口 | set_timer(timeout, callback) | signal(SIGINT, handle_sigint) |
| 回调函数类型 | void (*TimerCallback)(int) | void (*sighandler_t)(int) |
| 事件触发条件 | 定时器超时(sleep 返回) | 信号发生(如 Ctrl+C、kill -INT) |
| 回调调用者 | 框架函数 set_timer 内部(通过 blr x1) | 内核(通过设置 pc 并 eret) |
| 解耦效果 | 上层业务与定时机制解耦 | 用户逻辑与操作系统信号机制解耦 |
| 是否已内置 | 需要自己实现框架(如 set_timer) | 操作系统已提供,直接使用 |
核心共同点:
-
都是 注册 -- 事件 -- 调用 的模式。
-
用户只提供函数地址,框架负责在特定时刻间接调用该函数。
-
底层都依赖函数指针和间接跳转(用户态用 blr,内核态类似但通过 eret)。
signal 的特殊性: 框架是操作系统内核,因此回调函数执行时的上下文是"信号上下文",有诸多限制(如只能调用异步信号安全函数)。但就回调的本质而言,它与你在用户态看到的 set_timer、polymorph.c 中的多态调用完全一致。
signal 就是操作系统提供的一个现成的"框架函数",用户把回调函数地址交给它,当特定事件(信号)发生时,操作系统会自动调用你的函数。这正是回调函数模式在操作系统层的经典体现。
1.5.3.6 同步信号 vs 异步信号
| 同步信号 | 由程序自身执行引起,与当前指令同步发生 | 硬件异常(除零、段错误)、非法指令、kill 发送给自身等 | SIGSEGV(段错误)、SIGFPE(浮点异常)、SIGILL(非法指令)、SIGTRAP | 同步,可以定位到出错的指令 |
|---|---|---|---|---|
| 异步信号 | 由外部事件(其他进程、终端、定时器)产生,与主程序执行流异步 | 外部 kill、终端中断 Ctrl+C、定时器 SIGALRM | SIGINT、SIGTERM、SIGUSR1、SIGCHLD | 异步,无法预知何时发生 |
关键区别:
-
同步信号发生时,程序通常已经处于非法状态(例如访问无效内存),信号处理函数执行完毕后,往往无法返回到原指令(除非修复了问题)。
-
异步信号是外部事件的正常通知,处理函数返回后,被中断的系统调用可能返回 EINTR,或者自动重启(如果设置了 SA_RESTART)。
1.5.3.7 使用POSIX 标准重写signal程序
C
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 定义 sighandler_t 类型(如果系统未定义,可自己定义;但 <signal.h> 通常已定义)
// 为了明确展示,这里自行定义,与 POSIX 标准一致
typedef void (*sighandler_t)(int);
// 回调函数:由框架(内核)在收到 SIGINT 时调用
void handle_sigint(int sig) {
printf("in handler\n");
fflush(stdout); // 强制刷新输出缓冲区
printf("\n收到信号 %d (SIGINT),执行用户定义的回调逻辑\n", sig);
printf("可以在这里做清理、重置或退出操作\n");
// signal(SIGINT, SIG_DFL); // 可选:恢复默认行为
}
int main() {
// 方案1:直接使用类型转换(如果 signal 函数原型期待 sighandler_t)
signal(SIGINT, (sighandler_t)handle_sigint);
// 方案2:显式声明一个 sighandler_t 变量并赋值(更符合题意)
// sighandler_t my_handler = handle_sigint;
// signal(SIGINT, my_handler);
printf("程序运行中,按下 Ctrl+C 触发回调...\n");
while (1) {
pause(); // 等待信号
}
return 0;
}
使用 sighandler_t 类型显式声明信号处理函数主要有以下好处:
- 提高代码可读性
sighandler_t 直接表明这是一个信号处理函数类型,比裸写的 void (*)(int) 更语义化。
当用户看到 signal(SIGINT, handle_sigint); 时,如果 handle_sigint 的类型已经是 sighandler_t,读者无需去查看其定义就知道它是合法的信号处理函数。
- 减少重复与错误
如果代码中有多处需要声明或使用信号处理函数指针(例如存储到数组、传递参数),用 typedef 可以只写一次类型,避免重复冗长的 void (*)(int)。
修改类型时(比如增加 sig_info 参数)只需改一处,不会遗漏。
- 便于管理和维护复杂场景
当有多个信号处理函数(如 handle_sigint, handle_sigterm, handle_sigusr1)时,统一用 sighandler_t 声明,代码风格一致,易于维护。
如果需要定义函数指针数组,sighandler_t handlersNSIG 比 void (*handlersNSIG)(int) 更简洁清晰。
- 符合 POSIX 标准与惯例
POSIX 已经定义了 sighandler_t(在 <signal.h> 中),使用它意味着用户的代码更标准化,更容易被其他开发者理解。很多系统编程书籍和开源项目(如 Linux 内核的某些接口)都采用这种类型别名。
- 更易于编写自文档化的代码
在函数原型、结构体成员、函数参数中使用 sighandler_t 直接传达了"这是一个信号处理函数"的意图,而不需要额外注释。
一旦程序规模变大,类型别名对可维护性的提升非常显著。
例如:
C
使用typedef void (*sighandler_t)(int):
sighandler_t old_int = signal(SIGINT, my_handler);
sighandler_t old_term = signal(SIGTERM, my_term_handler);
// 后续可以轻松储存或恢复旧的处理函数
不使用 typedef 则:
void (*old_int)(int) = signal(SIGINT, my_handler);
void (*old_term)(int) = signal(SIGTERM, my_term_handler);
既冗长又容易出错。
结论:使用 sighandler_t 或类似的类型别名是推荐的良好的编程习惯。