1 同步、异步

1.1 同步与异步编程的区别

  • ‌同步(Synchronous)‌:
    任务按代码顺序‌阻塞执行‌,必须等待当前操作完成后才能执行下一操作。程序执行流在时间上严格耦合,逻辑简单但效率较低。

    类比1:排队买奶茶,必须等前一人完成才能轮到自己。

    类比2:想象你在厨房做饭,严格按照步骤执行:洗菜 → 切菜 → 炒菜,每一步都必须等前一步完成,整个过程是顺序且阻塞的。

  • ‌异步(Asynchronous)‌:
    任务触发后‌无需等待完成‌,程序继续执行后续代码。结果通过回调函数、事件循环或 Promise 等机制处理,效率高但逻辑复杂,适用于耗时长的操作(如网络请求、文件读写、硬件交互)。

    类比1:餐厅点餐后继续聊天,厨师后台备餐,完成后通知取餐。

    类比2:下单外卖(异步操作)→ 打扫房间(无需等待外卖)→ 外卖送达时收到通知,外卖的准备过程与打扫房间并行进行,外卖完成时通过通知(回调)告知你。

关键选择:任务是否依赖前序结果?是 → 同步;否 → 异步

1.2 代码示例说明

  • 同步示例:顺序执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>
#include <unistd.h> // 用于 sleep 函数

// 模拟耗时任务
void task1() {
printf("任务1开始\n");
sleep(2); // 模拟耗时2秒
printf("任务1完成\n");
}

void task2() {
printf("任务2开始\n");
sleep(1); // 模拟耗时1秒
printf("任务2完成\n");
}

int main() {
// 同步执行:先执行task1,再执行task2
task1();
task2();
return 0;
}

// 输出结果:
// 任务1开始
// 任务1完成
// 任务2开始
// 任务2完成

特点:任务2必须等待任务1完成后才能开始。程序总耗时 = 2秒(task1) + 1秒(task2) = 3秒。

  • 异步示例:并发执行(使用线程模拟)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <pthread.h> // 使用POSIX线程库
#include <unistd.h>

// 模拟耗时任务(异步执行)
void* task1(void* arg) {
printf("任务1开始\n");
sleep(2); // 模拟耗时2秒
printf("任务1完成\n");
return NULL;
}

void* task2(void* arg) {
printf("任务2开始\n");
sleep(1); // 模拟耗时1秒
printf("任务2完成\n");
return NULL;
}

int main() {
pthread_t thread1, thread2;

// 异步执行:同时启动两个线程
pthread_create(&thread1, NULL, task1, NULL);
pthread_create(&thread2, NULL, task2, NULL);

// 等待线程结束(主函数不退出)
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);

return 0;
}

// 输出结果(顺序可能不同):
// 任务1开始
// 任务2开始
// 任务2完成
// 任务1完成

特点:任务1和任务2同时运行,互不等待。程序总耗时 = 2秒(以耗时最长的任务为准)。

2 阻塞、非阻塞

2.1 阻塞与非阻塞的区别

  • 阻塞(Blocking)
    • 程序在调用某个操作时,必须等待该操作完成才能继续执行后续代码。
    • 如果操作未完成(如等待数据读取),程序会卡住(暂停),直到操作完成。
  • 非阻塞(Non-blocking)
    • 程序在调用某个操作时,立即返回,无需等待操作完成。
    • 如果操作未完成(如数据未准备好),程序会立即返回错误码(如 EAGAIN 或 EWOULDBLOCK),允许程序继续执行其他任务。

2.2. 形象化比喻

  • 阻塞:
    想象你去餐厅点餐,服务员需要10分钟准备食物。你必须一直等在原地,直到食物上桌才能离开。
    • 程序行为:调用 read() 读取文件时,如果没有数据,程序会卡住,直到数据到达。
  • 非阻塞:
    想象你去餐厅点餐,服务员告诉你“食物需要10分钟,你可以先去逛街,做好后我们会通知你”。你立即离开,去干其他事,等服务员通知你后再回来取餐。
    • 程序行为:调用 read() 时,如果没有数据,程序立即返回,并继续执行其他任务(如轮询或事件驱动)。

2.3 代码示例说明

  • 阻塞模式:文件读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <unistd.h>

int main() {
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("fopen");
return 1;
}

char buffer[100];
// 阻塞读取:如果没有数据,程序会一直等待
printf("等待数据...\n");
size_t bytes_read = fread(buffer, 1, sizeof(buffer), fp);
printf("读取到 %zu 字节数据\n", bytes_read);

fclose(fp);
return 0;
}

特点:如果 data.txt 中没有数据,fread() 会无限等待,程序卡住。适用于数据一定存在的场景(如读取本地文件)。

  • 非阻塞模式:文件读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

int main() {
int fd = open("data.txt", O_RDONLY | O_NONBLOCK); // 设置非阻塞模式
if (fd == -1) {
perror("open");
return 1;
}

char buffer[100];
ssize_t bytes_read;

while (1) {
bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("数据未准备好,继续等待...\n");
sleep(1); // 模拟轮询
continue;
} else {
perror("read");
break;
}
} else {
printf("读取到 %zd 字节数据\n", bytes_read);
break;
}
}

close(fd);
return 0;
}

特点:如果 data.txt 中没有数据,read() 会立即返回 EAGAIN 或 EWOULDBLOCK,程序不会卡住。通过轮询或事件驱动(如 select() / epoll)处理后续逻辑,适合高并发场景。

2.4 同步、异步、阻塞、非阻塞四个概念

2.4.1 四种组合模式

  • 同步阻塞 (Synchronous Blocking)

想象你去餐厅吃饭,点完菜后必须坐在餐桌前等待,直到服务员把菜端上来,期间不能做其他事情。这就是同步阻塞模式,你需要主动等待结果,并且在等待过程中不能做其他事情。

  • 同步非阻塞 (Synchronous Non-blocking)

还是在餐厅,你点完菜后可以在餐厅里自由活动(比如去洗手间、和朋友聊天),但需要时不时回到餐桌前询问服务员菜是否做好了。这就是同步非阻塞模式,你不需要一直等待,但需要主动轮询结果。

  • 异步阻塞 (Asynchronous Blocking)

这种模式比较少见,因为异步通常与非阻塞结合使用。想象你去餐厅吃饭,点完菜后坐在餐桌前等待,但服务员会在菜做好后主动通知你。虽然你可以做其他事情,但你选择坐在那里等待,这就是异步阻塞模式。

  • 异步非阻塞 (Asynchronous Non-blocking)

最理想的模式。你点完菜后可以离开餐厅去逛街,服务员会在菜做好后打电话通知你。这就是异步非阻塞模式,你不需要等待,也不需要主动询问,系统会在任务完成时通知你。

模式 解释
同步阻塞 这是最严格的模式,必须顺序执行,且阻塞过程不能干其他事情,必须等上面的任务完成。
同步非阻塞 这种模式也是顺序执行,但是阻塞过程可以干其他的事情,但是必须主动去询问上面的任务是否完成。
异步阻塞 这种模式比较少
异步非阻塞 这种模式最合理也最常见,异步/并发执行,发起任务后可立即返回,也无需主动发起询问任务是否完成,系统会在任务完成时通知你。

2.4.2 程序示例

(一)同步非阻塞(Synchronous Non-blocking)

场景:模拟一个“等待文件数据”的任务,但调用方不会被阻塞,而是需要主动轮询检查数据是否准备好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <unistd.h> // for sleep()
#include <pthread.h>

// 模拟文件数据是否准备好的状态变量
int data_ready = 0;
char data[] = "Hello, Synchronous Non-blocking!";

// 模拟耗时任务(如读取文件)
void* prepare_data(void* arg) {
sleep(3); // 模拟耗时3秒
data_ready = 1; // 数据准备完成
return NULL;
}

int main() {
pthread_t thread;

// 启动异步任务(模拟读取文件)
pthread_create(&thread, NULL, prepare_data, NULL);

printf("主线程开始轮询检查数据是否准备好...\n");

// 同步非阻塞:主动轮询检查数据是否准备好
while (!data_ready) {
printf("数据未准备好,继续轮询...\n");
sleep(1); // 每隔1秒检查一次
}

printf("数据已准备好: %s\n", data);
pthread_join(thread, NULL); // 等待线程结束
return 0;
}

运行流程

  1. 主线程启动一个子线程模拟读取文件(耗时3秒)。
  2. 主线程进入轮询状态,每隔1秒检查 data_ready 是否为1。主线程必须不断检查状态(data_ready 是否为1),这会消耗一定的CPU资源(如频繁调用 printf 或检查变量)。
  3. 子线程3秒后设置 data_ready = 1。
  4. 主线程检测到数据就绪后继续执行。

关键点

  • 同步:主线程必须等待子线程完成任务(data_ready == 1)才能继续。
  • 非阻塞:主线程不会被挂起,而是主动轮询检查状态(while (!data_ready))。
  • 缺点:轮询浪费CPU资源(频繁检查 data_ready)。

(二)异步非阻塞(Asynchronous Non-blocking)

场景:同样模拟“等待文件数据”,但调用方完全不阻塞,而是通过回调函数处理结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

// 回调函数类型定义
typedef void (*Callback)(const char*);

// 模拟耗时任务(如读取文件)
void* prepare_data_async(void* arg) {
sleep(3); // 模拟耗时3秒
Callback callback = (Callback)arg;
callback("Hello, Asynchronous Non-blocking!"); // 完成后调用回调
return NULL;
}

int main() {
printf("主线程开始执行其他任务...\n");

// 定义回调函数
void on_data_ready(const char* result) {
printf("数据已准备好(通过回调): %s\n", result);
}

// 启动异步任务,传递回调函数
pthread_t thread;
pthread_create(&thread, NULL, prepare_data_async, (void*)on_data_ready);

// 主线程继续执行其他任务
for (int i = 1; i <= 5; ++i) {
printf("主线程正在做其他事情...%d秒\n", i);
sleep(1);
}

pthread_join(thread, NULL); // 等待线程结束(实际中可能不需要)
return 0;
}

运行流程

  1. 主线程启动子线程模拟读取文件(耗时3秒),并传递一个回调函数 on_data_ready。
  2. 主线程 立即返回,继续执行其他任务(打印 主线程正在做其他事情...)。
  3. 子线程3秒后完成任务,调用回调函数 on_data_ready 输出结果。
  4. 主线程和子线程并行执行,主线程无需等待。

关键点

  • 异步:主线程无需等待任务完成,任务完成后通过回调通知主线程。
  • 非阻塞:主线程完全不阻塞,可以继续执行其他任务。
  • 优点:无需轮询,资源利用率高。

2.4.3 图解对比

1
2
3
4
5
6
7
8
9
10
11
12
13
// 同步非阻塞:
主线程:
├─ 启动异步任务(子线程准备数据) → 子线程开始执行
├─ 进入轮询循环:
│ ├─ 检查 data_ready? → 否 → 执行其他任务(如 sleep(1))
│ └─ data_ready == 1 → 退出循环,继续执行后续逻辑
└─ 数据就绪后处理结果

// 异步非阻塞:
主线程:
├─ 启动异步任务(子线程准备数据) → 子线程开始执行
├─ 立即返回 → 继续执行其他任务(如打印信息)
└─ 数据就绪后,系统自动调用回调函数处理结果

2.5 挂起、阻塞、等待三个概念

在编程中,挂起(Suspend)、阻塞(Block)和等待(Wait)这三个术语经常用于描述任务或线程的状态,它们都与任务不能继续执行有关,但发生的原因和机制有所不同。

2.5.1 挂起(Suspend)

挂起通常指的是任务被外部因素(如操作系统、用户请求)强行暂停执行,并且任务的状态被保存起来,以便后续恢复执行。挂起通常与调度有关,可能因为系统资源不足、用户干预或者调试等原因,属于被动原因。

  • 特点:任务被挂起后,它不再参与调度,不占用CPU,直到被恢复(Resume)。挂起操作可以由外部控制。
  • 形象化理解:就像你在看视频时按下了暂停键,视频画面停止在当前帧,当你再次按下播放键时,视频会从暂停的地方继续播放。

2.5.2 阻塞(Block)

阻塞指的是任务因为等待某个资源(如I/O操作完成、锁的释放、信号量等)而主动让出CPU,进入等待状态。当资源可用时,任务会被唤醒继续执行。

  • 特点:任务主动放弃CPU,进入阻塞状态,直到等待的条件满足。阻塞通常与同步机制(如互斥锁、条件变量)或I/O操作相关。
  • 形象化理解:你在等公交车,但车还没来,于是你坐在车站的长椅上休息(阻塞),直到公交车来了(条件满足),你才站起来上车(继续执行)。

2.5.3 等待(Wait)

等待是一个更广义的概念,它通常指任务因为某些条件不满足而暂停执行。等待可以是阻塞的一种表现形式,也可以包括其他形式的暂停(如忙等待)。在具体上下文中,等待往往与特定的同步原语(如条件变量)一起使用。

  • 特点:等待通常意味着任务在某个条件上停留,直到条件满足。它可以是阻塞的(让出CPU)也可以是非阻塞的(忙等待,即循环检查条件)。
  • 形象化理解:你在餐厅等位,你可以选择坐在椅子上休息(阻塞等待)或者不断去问前台还有多久(忙等待)。

2.5.4 联系与区别

  • 联系:三者都表示任务暂时不能执行,但挂起和阻塞通常都是被动或主动地让出CPU,而等待则是一个更通用的术语。
  • 区别:
    • 挂起通常是由外部发起的,任务自身无法控制,而阻塞和等待往往是任务主动的行为(等待某个条件)。
    • 阻塞是等待的一种方式,即让出CPU的等待(非忙等),而等待也可以包括忙等待(不释放CPU,循环检查条件)。
    • 挂起后任务的状态被保存,且不参与调度;阻塞的任务则处于等待队列中,当条件满足时会被唤醒;等待则可能处于阻塞状态,也可能处于运行状态(忙等待)。

3 并发、并行

3.1 核心定义与本质区别

  • 1. 并发(Concurrency)
    • 定义:指多个任务在同一时间段内交替执行,但同一时刻只有一个任务在运行。
    • 本质:通过 CPU 时间片轮转(或事件驱动),让用户感觉多个任务同时进行,但实际是单核 CPU “伪同时” 处理。
    • 类比场景:
      像一个厨师同时处理切菜、炒菜、煮汤三件事:切菜时暂停炒菜,煮汤时暂停切菜,通过快速切换完成所有任务。虽然看起来同时在做,但同一时间只做一件事。
  • 2. 并行(Parallelism)
    • 定义:多个任务在同一时刻真正同时执行,依赖多核 CPU 或多台计算机同时处理。
    • 本质:利用硬件资源(如多核处理器)将任务分解为多个子任务,同时并行计算。
    • 类比场景:
      多个厨师分工合作,一人切菜、一人炒菜、一人煮汤,各自独立工作,同一时间内不同任务同步进行。

3.2 关键差异对比表

对比维度 并发(Concurrency) 并行(Parallelism)
执行方式 任务交替执行(伪同时) 任务同时执行(真同时)
硬件依赖 单核 CPU 即可(通过时间片切换) 需要多核 CPU 或多处理器(硬件支持)
解决问题 处理任务间的协作、资源竞争等复杂逻辑 加速计算密集型任务(如大数据处理)
典型场景 服务器处理多客户端请求、GUI 界面响应 科学计算、3D 渲染、区块链挖矿
编程模型 多线程、异步回调、事件循环 多进程、MPI(消息传递接口)、GPU 并行计算

3.3 程序示例

  • 并发编程:多线程实现,通过pthread_create创建线程,多个线程共享 CPU 时间片。
1
2
3
4
5
6
7
8
9
10
11
#include <pthread.h>
void* task1(void* arg) { /* 任务1逻辑 */ }
void* task2(void* arg) { /* 任务2逻辑 */ }
int main() {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, task1, NULL);
pthread_create(&tid2, NULL, task2, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
  • 并行编程:多核并行,使用 OpenMP 指令让编译器自动将循环分配到多个核心。
1
2
3
4
5
6
7
8
9
10
#include <omp.h>
int main() {
int i, sum = 0;
#pragma omp parallel for reduction(+ : sum)
for (i = 0; i < 10000; i++) {
sum += i;
}
printf("Sum: %d\n", sum);
return 0;
}

4 回调函数callback

4.1 回调函数的基本概念

回调函数是一种函数指针的应用,回调函数作为参数传递给另一个函数(称为 “调用者”),允许调用者在特定事件或条件发生时执行这个函数。简单来说,回调函数是由他人调用的函数,而非直接在代码中调用。

(函数指针,啥语言都一样,只不过各自有各自叫法,本质就是函数指针,指向函数的指针,真因为有函数指针,你才可以将函数作为变量传递给另一个函数)

4.2 回调函数与普通函数的区别

特性 普通函数 回调函数
调用方式 由程序员显式调用(如 func()) 作为参数传递给其他函数,由其他函数调用
控制权 由程序员决定何时调用 由调用者(函数)决定何时调用
灵活性 固定逻辑,无法动态改变调用的逻辑 动态传递逻辑,灵活性更高
应用场景 普通流程控制(如计算、判断) 异步操作、事件驱动、解耦逻辑

4.3. 为什么要使用回调函数

  • (1)解耦与灵活性
    • 解耦:调用者不需要知道回调函数的具体实现细节,只需知道它的接口(参数和返回值)。
      例如:硬件驱动模块不需要知道用户如何处理传感器数据,只需调用回调函数。
    • 灵活性:可以在运行时动态切换不同的回调函数,适应不同需求。
      例如:一个排序算法可以接受不同的比较函数作为回调,实现升序或降序排序。
  • (2)异步处理
    • 在嵌入式系统中,许多操作是异步的(如定时器、中断、通信协议)。
      回调函数可以在这些异步事件发生时通知主程序,避免阻塞主流程。
  • (3)事件驱动
    • 回调函数常用于事件驱动的场景(如按键按下、传感器触发)。
      例如:当用户按下按钮时,系统会调用预先注册的回调函数处理按键事件。

4.4 回调函数的常见使用场景

  • (1)硬件中断处理
    • 在嵌入式系统中,当硬件(如按键、定时器)触发中断时,会调用回调函数处理中断事件。
      例如:定时器每隔1秒触发一次,调用回调函数更新系统时间。
  • (2)通信协议
    • 在串口通信或网络通信中,数据接收完成时会调用回调函数处理数据。
      例如:当串口接收到一帧数据后,调用用户定义的回调函数解析数据。
  • (3)状态机或事件驱动框架
    • 在事件驱动的框架中,回调函数用于处理不同的状态或事件。
      例如:GUI系统中,用户点击按钮时调用回调函数执行对应操作。
  • (4)通用库的设计
    • 通用库(如排序、过滤算法)通过回调函数允许用户自定义逻辑。
      例如:C标准库的 qsort 函数通过回调函数实现自定义排序规则。

4.5 举例嵌入式场景:模拟定时器回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <unistd.h> // 用于 sleep 函数

// 定义回调函数类型
typedef void (*TimerCallback)(void);

// 模拟定时器(每隔1秒触发一次)
void start_timer(TimerCallback callback, int duration_seconds) {
while (1) {
sleep(duration_seconds); // 等待指定时间
callback(); // 调用回调函数
}
}

// 用户定义的回调函数
void timer_handler() {
static int count = 0;
printf("定时器触发第 %d 次\n", ++count);
}

int main() {
// 启动定时器并注册回调函数
start_timer(timer_handler, 1);
return 0;
}

5 C语言小知识点

5.1 volatile关键字

(一)volatile的主要作用

  • 防止编译器优化

编译器在进行优化时,可能会假设某些变量的值在代码块中不会被修改(除非程序显式更改)。volatile 的作用就是打破这种假设,确保每次访问变量时都直接从内存中读取,避免因为优化导致程序行为异常。

  • 应用于特殊场景
    • 硬件寄存器(Memory-Mapped I/O):硬件设备的状态寄存器可能被外部硬件修改,程序必须每次读取最新值。
    • 中断服务程序(ISR)中修改的变量:主程序需要感知到中断中对变量的修改。
    • 多线程环境中的共享变量:其他线程可能修改了变量的值。
    • 信号处理函数中修改的变量:主程序需要根据信号处理函数的修改做出响应。
    • 调试变量:某些调试信息可能由外部调试器修改。

(二)使用 volatile 的示例

示例 :模拟中断服务修改变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

volatile int flag = 0; // 使用 volatile

void* thread_func(void* arg) {
sleep(2); // 模拟中断延迟
flag = 1; // 模拟中断服务修改变量
return NULL;
}

int main() {
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);

while (flag == 0) {
// 等待 flag 被设置为 1
}

printf("Flag is now 1!\n");
pthread_join(tid, NULL);
return 0;
}

说明:

  • flag 被声明为 volatile,表示其值可能在主程序之外被修改(如中断线程)。
  • 如果未使用 volatile,编译器可能会将 flag 缓存到寄存器中,主循环可能无法检测到 flag 的变化,导致程序陷入死循环。
  • 由于是线程间通信,更推荐使用线程同步机制(如互斥锁、条件变量),但 volatile 是基础保障。

(三)不使用 volatile 的后果

如果不使用 volatile,编译器可能会做出以下优化行为,从而引发程序错误:

编译器优化行为 后果
将变量值缓存到寄存器 无法检测到变量在程序之外的修改
删除重复读取操作 导致循环无法退出,或逻辑错误
重新排序指令 导致硬件行为异常或数据不一致
1
2
3
4
5
6
7
int flag = 0;

void check_flag() {
while (flag == 0) {
// 编译器可能将 flag 读入寄存器并缓存
}
}

如果 flag 在中断中被设置为 1,但由于未使用 volatile,编译器可能将循环优化为:

1
2
3
4
mov r0, #0
loop:
cmp r0, #0
beq loop

即,寄存器值始终为 0,导致循环永不退出。

简要解释原因:

(四)何时需要使用 volatile

场景 是否需要 volatile
硬件寄存器 ✅ 必须使用
中断服务程序中修改的变量 ✅ 必须使用
多线程共享变量 ✅ 推荐使用,但更应使用线程同步机制
信号处理函数中修改的变量 ✅ 必须使用
单线程中未被修改的局部变量 ❌ 不需要

(五)小结

  • volatile 的核心作用是禁止编译器对变量进行优化,确保每次访问都从内存中读取。
  • 它适用于外部因素可能修改变量值的场景,如硬件寄存器、中断、多线程和信号处理。
  • 在多线程或并发编程中,volatile 是基础保障,但不能替代线程同步机制(如互斥锁、原子操作)。
  • 不使用 volatile 时,编译器可能优化掉变量访问,导致程序行为与预期不符,甚至陷入死循环。

5.2 指针

5.2.1 指针常量

(一)指针常量基本概念

定义:指针常量是说指针本身是常量,不能改变指针的指向,本质是常量。
特点:声明后指针指向的地址不可改变,但可以通过指针修改该地址上的值。
理解:指针常量——指针的常量,加一个的字豁然开朗。 “指针的”是形容词,去掉形容词就剩常量,也就是说指针常量是一个常量,是指针的常量。

进一步说明1:指针常量本质上是一个常量,常量具有不可修改的特性。也就是说指针常量定义赋值后就不可以修改。 但是该指针指向的地址里面存储的东西是可以进行修改的(常量除外)。

进一步说明2:指针常量本质上还是常量,而常量最重要的性质: ① 不可被修改。② 声明和定义时必须被初始化。指针常量具有常量的以上两种特性。而指针常量和整形常量一样,不同的是指针常量。只能存储的是地址,整形常量中存储的是,整形数。指针常量能指向常量,也能指向变量。

代码形式:int *const p1;

代码示例:

1
2
3
4
5
6
7
8
9
// 定义两个变量
int a = 10;
int b = 20;
// 定义一个指针常量,必须初始化
int* const p = &a;
// 允许修改指向的变量的值
*p = 15;
// 修改指针指向的变量则报错,read-only variable 'p'
p = &b;
(二)指针常量的使用场景

指针常量的核心价值是「固定指针指向,防止意外修改指针的绑定关系」,主要有两个核心场景:

场景 1:硬件编程 —— 指向固定地址的硬件寄存器

硬件寄存器(如温度寄存器、控制寄存器)的地址是固定的,使用指针常量可以固定指针指向该寄存器地址,防止误修改指针指向,同时允许修改寄存器的数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main()
{
// 假设0x10000000是某个硬件温度寄存器的固定物理地址
// 指针常量:固定指向该寄存器地址,无法修改指向
int* const temp_reg = (int*)0x10000000;

// 合法操作:修改寄存器的值(设置温度阈值为25℃)
*temp_reg = 25;
printf("设置温度阈值:%d℃\n", *temp_reg); // 输出:25℃

// 非法操作:修改指针指向(寄存器地址固定,不允许更改)
// int* const new_reg = (int*)0x20000000;
// temp_reg = new_reg; // 编译器报错

return 0;
}

场景 2:C++ 类成员 —— 固定指向某个资源,防止指向被篡改

当类需要持有一个固定资源的指针(如内存缓冲区、设备句柄),不允许后续修改指针指向时,可使用指针常量作为类成员(必须在构造函数初始化列表中初始化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
using namespace std;

class BufferManager
{
private:
// 指针常量:固定指向缓冲区,初始化后无法修改指向
int* const buffer_ptr;
// 缓冲区大小
const int buffer_size;

public:
// 构造函数初始化列表:必须初始化指针常量和常量成员
BufferManager(int* init_buffer, int size) : buffer_ptr(init_buffer), buffer_size(size) {}

// 合法操作:修改缓冲区内容
void writeBuffer(int index, int value) {
if (index >= 0 && index < buffer_size) {
buffer_ptr[index] = value; // 修改缓冲区数据
}
}

// 读取缓冲区内容
int readBuffer(int index) {
if (index >= 0 && index < buffer_size) {
return buffer_ptr[index];
}
return -1;
}
};

int main()
{
// 定义缓冲区数组
int buffer[5] = {1, 2, 3, 4, 5};
BufferManager manager(buffer, 5);

// 写入缓冲区
manager.writeBuffer(2, 30);
// 读取缓冲区
cout << "缓冲区索引2的值:" << manager.readBuffer(2) << endl; // 输出:30

// 无法修改buffer_ptr的指向(指针常量,类外部也无法访问修改)
return 0;
}

5.2.2 常量指针

(一)常量指针基本概念

定义:指针指向的内容是常量,指向常量的指针,本质是指针。
含义:可以通过指针访问数据,但不能通过指针修改数据,可以保护数据。
理解:常量指针——常量的指针。“常量的”是形容词,去掉形容词就剩指针,也就是说常量指针是一个指针,指向常量。

进一步说明1:常量指针本质上还是指针,不过前面的修饰语“常量”,需要注意的是常量指针,本身可以指向常量,可以指向变量。其常量修饰,只是限定了,其不能通过指针引用改变指向变量或常量的值。

进一步说明2:指针指向的内容不一定是常量,只是通过该指针无法修改数据而已。

代码形式:int const*p2或const int*p3(两种书写形式是等价的 )

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 普通变量
int variable = 10;
// 常量
const int constant = 20;

// 声明常量指针,不用必须初始化
const int *ptr;

// 常量指针可以指向普通变量
ptr = &variable;
printf("指向变量时,*ptr = %d\n", *ptr); // 输出: 10
// 尝试通过常量指针修改值(这会导致编译错误)
// *ptr = 30; // 错误!不能修改常量指针指向的值

// 指针重新指向常量
ptr = &constant;
printf("指向常量时,*ptr = %d\n", *ptr); // 输出: 20

// 指针可以改变指向,例如指向另一个变量
int another = 42;
ptr = &another;
printf("重新指向后,*ptr = %d\n", *ptr); // 输出: 42
(二)常量指针的使用场景

常量指针的核心价值是「保护数据不被意外修改」,同时保留指针灵活指向的能力,主要有两个核心场景:

场景 1:函数参数传递 —— 保护输入参数不被误修改

常用于函数参数,表示函数不会通过该指针修改目标数据,例如 void printArray(const int *arr, int size) ,确保函数只读取arr中的数据,不在函数内部改变arr中的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <string.h>

// 常量指针作为参数:保护传入的字符串不被函数内部修改
int getStrLength(const char* str)
{
if (str == NULL)
return 0;
// *str = 'H'; // 非法:无法修改常量指针指向的内容,避免误改原始字符串
return strlen(str);
}

int main()
{
char str1[] = "Hello";
char str2[] = "World";

// 允许指针指向不同字符串(体现常量指针指向可改的特性)
printf("str1长度:%d\n", getStrLength(str1)); // 输出:5
printf("str2长度:%d\n", getStrLength(str2)); // 输出:5

return 0;
}

场景 2:访问只读内存数据 —— 避免修改只读区域导致程序崩溃

字符串常量(如 "Hello Pointer")存储在程序的只读数据区,使用常量指针指向它,可以防止误修改只读内存,避免程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main()
{
// 字符串常量存储在只读内存区,用const char*指向(推荐写法)
const char* str = "Hello, Pointer!";
printf("字符串:%s\n", str);

// 非法操作:修改只读内存内容,会导致程序崩溃或编译器报错
// *str = 'h'; // 编译器警告,运行时崩溃

// 合法操作:修改指针指向(指向其他字符串常量)
str = "Hi, Pointer!";
printf("新字符串:%s\n", str); // 输出:Hi, Pointer!

return 0;
}

5.2.3 补充:普通指针与常量

‌在C语言和C++中,普通指针(如 int*)设计用于指向变量,允许通过指针修改所指数据;而常量(如 const int)的值在定义后不应被修改,因此普通指针不能直接指向常量,以避免意外修改常量值。

1
2
const int value = 10;
int* ptr = &value; // 错误:不能将常量地址赋给普通指针

5.2.4 指针函数

指针函数比较好理解,简单说就是返回值为指针类型的函数。

1
2
3
4
5
int* getArrayPointer(int index) 
{
static int arr[] = {10, 20, 30, 40};
return &arr[index]; // 返回数组中某元素的地址
}

5.2.5 函数指针

在 C 语言中,函数在内存中会占用一段连续的存储空间,它的 “入口地址”(函数第一条指令的内存地址)可以被获取并存储—— 而用于存储函数入口地址的指针,就是「函数指针」。

  • 普通指针(如 int* p中的p):存储的是数据变量的内存地址;
  • 函数指针(如void (*PrintInfo)(Person*);中 PrintInfo):存储的是函数的内存入口地址;
  • 核心价值:通过函数指针可以间接调用函数,还能实现 “方法绑定”“回调函数”“多态模拟” 等高级功能(之前用结构体模拟面向对象,就是用它绑定对象方法)。

函数指针的格式

1
2
3
4
5
6
// 通用格式
返回值类型 (*函数指针变量名)(参数类型列表);

// 对应示例代码
void (*PrintInfo) (Person*);
void (*pFunc) (int, char);

typedef 结合函数指针时,是给 “函数指针类型” 起一个别名,而非给函数指针变量起别名,基本格式:

1
2
3
4
5
6
// 通用格式:给「返回值类型 (*)(参数类型列表)」这个函数指针类型起别名 TypeName
typedef 返回值类型 (*TypeName)(参数类型列表);

// 示例:给「void (*)(int, char)」类型起别名 FuncPtr
typedef void (*FuncPtr)(int, char);
typedef void (*PersonPrintFunc)(Person*);
代码片段 含义说明
typedef 关键字:用于定义类型别名,此处是给函数指针类型起别名。
PersonPrintFunc 最终的类型别名:后续可以用它直接声明函数指针变量,无需重复写繁琐语法。
(*PersonPrintFunc) 括号不可省略:表示这是一个函数指针类型(而非指针函数)。
void 该函数指针类型指向的函数,返回值为 void
(Person*) 该函数指针类型指向的函数,参数列表为 Person* 类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

// 准备3个同类型函数:返回int,参数(int, int)
int add(int a, int b) { return a+b; }
int sub(int a, int b) { return a-b; }
int mul(int a, int b) { return a*b; }

int main() {
// ========== 写法1:原生标准写法 ==========
int (*p1)(int, int);
int (*p2)(int, int);
int (*p3)(int, int);
p1 = add; p2 = sub; p3 = mul;
printf("原生写法:%d, %d, %d\n", p1(2,3), p2(5,1), p3(4,5));

// ========== 写法2:typedef别名写法 ==========
typedef int (*CalcFunc)(int, int); // 第一步:起别名
CalcFunc q1, q2, q3; // 第二步:用别名定义多个指针,一行搞定
q1 = add; q2 = sub; q3 = mul;
printf("typedef写法:%d, %d, %d\n", q1(2,3), q2(5,1), q3(4,5));

return 0;
}

// 运行结果(两者完全一致):
// 原生写法:5, 4, 20
// typedef写法:5, 4, 20

5.3 结构体的前向声明

结构体前向声明(forward declaration of a struct)是 C 语言中一种常见的编程技巧,它的作用是:在不定义结构体具体内容的情况下,提前告诉编译器“这个结构体类型存在”。

(一)语法形式

1
struct MyStruct;  // 这就是结构体的前向声明

这行代码的意思是:“编译器,请记住有一个叫 MyStruct 的结构体类型,我现在不告诉你它里面有什么成员,但你要知道它存在。”

其实有点类似于使用extern声明一个外部变量,或者文件上方声明一个函数句柄,然后在文件下方实现函数。

(二)示例使用

1
2
3
4
5
6
7
8
9
/* mylib.h(公共头文件)  */

// 只声明结构体存在,不暴露内部成员
struct MyObject;

// 提供操作函数(用户只能通过这些函数操作对象)
struct MyObject* create_object(void);
void destroy_object(struct MyObject* obj);
void do_something(struct MyObject* obj);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* mylib.c(实现文件) */

#include "mylib.h"
#include <stdlib.h>

// 真正定义结构体内部
struct MyObject {
int secret_value;
char buffer[100];
};

struct MyObject* create_object(void) {
return calloc(1, sizeof(struct MyObject));
}

void destroy_object(struct MyObject* obj) {
free(obj);
}

void do_something(struct MyObject* obj) {
obj->secret_value = 42;
}
1
2
3
4
5
6
7
8
9
10
/* main.c(用户代码) */

#include "mylib.h"

int main() {
struct MyObject* obj = create_object();
do_something(obj);
destroy_object(obj);
return 0;
}

6 内存单位解析

6.1 基本定义

  • KB(Kilobyte)
    • 含义:KB 是 千字节(KiloByte),表示存储容量的单位。
    • 换算:1 KB = 1024 字节(B)(基于二进制,即 2¹⁰)。
    • 用途:常用于描述文件大小、内存分配、存储容量等。
    • 示例:一个文本文件大小为 5 KB,表示占用 5 × 1024 = 5120 字节的空间。
  • kb(kilobit)
    • 含义:kb 是 千比特(kiloBit),表示数据传输速率的单位。
    • 换算:1 kb = 1000 比特(bit)(基于十进制,即 10³)。
    • 用途:常见于网络带宽描述(如宽带速率、下载速度)。
    • 示例:网络速度标注为 100 Mbps(兆比特每秒),即 100,000 kbps。

6.2 大小写区别

  • B vs b
    • B(Byte):字节,是计算机存储的基本单位,1 字节 = 8 位(bit)。
    • b(bit):位,是二进制数据的最小单位(0 或 1)。
    • 关键区别:
      • 1 Byte = 8 bit,即 1 B = 8 b。
    • KB 与 Kb 的关系:
      • 1 KB = 8 Kb(因为 1 字节 = 8 位)。
      • 1 kB = 8 kb(如果 kB 表示 1000 字节,kb 表示 1000 位)。
  • K vs k
    • K(大写):通常表示 1024(基于二进制,即 2¹⁰),用于存储容量(如 KB、MB、GB)。
    • k(小写):通常表示 1000(基于十进制,即 10³),用于数据传输速率(如 kb、MBps)。

7 C语言结构体面向对象

C 语言虽非面向对象语言,但可通过「结构体(存储数据)+ 函数指针(绑定操作方法)」的组合,模拟面向对象的三大核心特性:封装、继承、多态。

C 语言实现思路:

  1. 用「结构体」存储对象的属性(数据成员);
  2. 把操作该结构体的函数指针存入结构体,实现 “方法与数据绑定”;
  3. 通过「不透明指针(不完全类型)」隐藏结构体内部细节,实现信息隐藏。

示例代码:封装一个「Person」对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 头文件:person.h(对外暴露接口,隐藏内部实现)
#ifndef PERSON_H
#define PERSON_H

// 不完全类型声明(不透明指针):外部无法知晓Person的内部结构,实现信息隐藏
typedef struct Person Person;

// 构造函数:创建Person对象(分配内存+初始化属性+绑定方法)
Person* Person_Create(const char* name, int age);
// 析构函数:销毁Person对象(释放内存)
void Person_Destroy(Person* person);

// 对外暴露的方法接口(操作Person对象)
void Person_PrintInfo(Person* person);
void Person_SetAge(Person* person, int new_age);
int Person_GetAge(Person* person);

#endif // PERSON_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 源文件:person.c(实现内部细节,对外不可见)
#include "person.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 结构体定义(封装属性:姓名、年龄;封装方法:函数指针)
struct Person
{
// 数据成员(属性)
char name[20];
int age;

// 函数指针(方法:绑定操作数据的函数)
void (*PrintInfo)(Person*); // 打印信息
void (*SetAge)(Person*, int); // 设置年龄
int (*GetAge)(Person*); // 获取年龄
};

// 方法实现:打印Person信息
static void Person_PrintInfo_Impl(Person* person) {
if (person == NULL) return;
printf("姓名:%s,年龄:%d\n", person->name, person->age);
}

// 方法实现:设置Person年龄
static void Person_SetAge_Impl(Person* person, int new_age) {
if (person == NULL || new_age < 0) return;
person->age = new_age;
}

// 方法实现:获取Person年龄
static int Person_GetAge_Impl(Person* person) {
if (person == NULL) return -1;
return person->age;
}

// 构造函数实现:初始化对象
Person* Person_Create(const char* name, int age) {
// 分配内存
Person* person = (Person*)malloc(sizeof(Person));
if (person == NULL || name == NULL) return NULL;

// 初始化数据成员
strncpy(person->name, name, sizeof(person->name) - 1);
person->name[sizeof(person->name) - 1] = '\0'; // 确保字符串结束
person->age = (age >= 0) ? age : 0;

// 绑定函数指针(方法与数据关联)
person->PrintInfo = Person_PrintInfo_Impl;
person->SetAge = Person_SetAge_Impl;
person->GetAge = Person_GetAge_Impl;

return person;
}

// 析构函数实现:释放内存
void Person_Destroy(Person* person) {
if (person != NULL) {
free(person);
person = NULL;
}
}

// 对外接口:调用内部方法(也可直接通过person->PrintInfo调用)
void Person_PrintInfo(Person* person) {
if (person != NULL && person->PrintInfo != NULL) {
person->PrintInfo(person);
}
}

void Person_SetAge(Person* person, int new_age) {
if (person != NULL && person->SetAge != NULL) {
person->SetAge(person, new_age);
}
}

int Person_GetAge(Person* person) {
if (person != NULL && person->GetAge != NULL) {
return person->GetAge(person);
}
return -1;
}

8 C语言常用库函数

8.1 字符串相关函数

  • strlen 是 计算字符串长度 的函数,它定义在标准库头文件 <string.h> 中。
    • 功能:返回一个 C 风格字符串(以 \0 结尾的字符数组)中 不包括结尾空字符 \0 的字符个数。
    • 返回类型:size_t(通常是无符号整数类型,如 unsigned int 或 unsigned long)。
    • 注意:strlen 不会统计字符串末尾的 \0。
1
size_t strlen(const char *s);
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <string.h> // 必须包含这个头文件

int main() {
char str[] = "Hello";
printf("字符串的长度是: %zu\n", strlen(str));
return 0;
}
// 输出结果
// 字符串的长度是:5
  • strcmp 是 C 语言中用于比较两个字符串的重要函数,定义在 <string.h> 头文件中。
    • 功能:按字典顺序(即 ASCII 值逐字符比较)比较两个 C 风格字符串。
    • 返回值:
      • 等于 0:两个字符串完全相同。
      • 小于 0:第一个字符串在字典序中小于第二个字符串。
      • 大于 0:第一个字符串在字典序中大于第二个字符串。
1
2
3
int strcmp(const char *s1, const char *s2);
- s1 和 s2 是要比较的两个字符串(指向它们首字符的指针)。
- 比较过程会一直进行,直到遇到不同的字符或遇到 \0(字符串结束符)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <string.h>

int main() {
char str1[] = "apple";
char str2[] = "banana";
char str3[] = "apple";

int result1 = strcmp(str1, str2); // "apple" vs "banana"
int result2 = strcmp(str1, str3); // "apple" vs "apple"

printf("strcmp(\"%s\", \"%s\") = %d\n", str1, str2, result1);
printf("strcmp(\"%s\", \"%s\") = %d\n", str1, str3, result2);

// 实际使用中通常这样判断
if (strcmp(str1, str3) == 0) {
printf("str1 和 str3 相等!\n");
}

return 0;
}
// 输出结果
// strcmp("apple", "banana") = -1
// strcmp("apple", "apple") = 0
// str1 和 str3 相等!
  • strncmp 是 strcmp 的“限定长度”版本
    • 功能:比较两个字符串的前 n 个字符,和 strcmp 一样,它也是按字典序(ASCII 值)逐字符比较。
    • 一旦发现不同字符,或已比较了 n 个字符,或遇到任一字符串的结尾 \0,就停止比较。
    • 返回值规则(与 strcmp 相同):
      • 等于 0:前 n 个字符完全相同。
      • 小于 0:第一个字符串的前 n 个字符 小于 第二个。
      • 大于 0:第一个字符串的前 n 个字符 大于 第二个。

⚠️ 注意:即使某个字符串长度小于 n,只要在遇到 \0 之前都相等,也会正常返回结果。

1
2
3
int strncmp(const char *s1, const char *s2, size_t n);
- s1, s2:要比较的两个字符串。
- n:最多比较的字符数(类型为 size_t,无符号整数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>
#include <string.h>

int main() {
char str1[] = "Hello, world!";
char str2[] = "Hello, C!";
char str3[] = "Hello, universe!";

// 比较前 6、7 个字符
int r1 = strncmp(str1, str2, 6); // "Hello," vs "Hello,"
int r2 = strncmp(str1, str3, 7); // "Hello, " vs "Hello, "

printf("strncmp(str1, str2, 6) = %d\n", r1); // 应该是 0
printf("strncmp(str1, str3, 7) = %d\n", r2); // 应该是 0

// 比较前 8 个字符
int r3 = strncmp(str1, str2, 8); // "Hello, w" vs "Hello, C"
printf("strncmp(str1, str2, 8) = %d\n", r3);
if (r3 > 0) {
printf("str1 的前8字符 > str2 的前8字符\n");
}

return 0;
}

// 输出结果
// strncmp(str1, str2, 6) = 0
// strncmp(str1, str3, 7) = 0
// strncmp(str1, str2, 8) = 52
// str1 的前8字符 > str2 的前8字符
  • strcpy 是 C 语言标准库中的一个函数,用于将一个字符串复制到另一个字符串中。 其原型如下:
    • 关键特性
      • 覆盖目标字符串:strcpy 会将目标字符串 dest 的原有内容覆盖。
      • 终止符处理:strcpy 会自动在目标字符串末尾添加空字符 \0。
      • 安全风险:如果目标字符串空间不足,会导致缓冲区溢出,存在安全风险。
1
2
3
4
5
char *strcpy(char *dest, const char *src);

- dest:目标字符串的指针,即要复制到的字符串。
- src:源字符串的指针,即要复制的字符串。
- 返回值:指向目标字符串 dest 的指针。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <string.h>

int main() {
char src[] = "Hello, World!";
char dest[50]; // 确保目标字符串有足够的空间

// 使用 strcpy 复制字符串
strcpy(dest, src);

// 输出结果
printf("Source: %s\n", src);
printf("Destination: %s\n", dest);

return 0;
}
// 输出结果
// Source: Hello, World!
// Destination: Hello, World!
  • strncpy 是 C 语言中用于复制字符串的一个函数,它是 strcpy 的“限定长度”版本,定义在 <string.h> 头文件中。
    • 功能:从源字符串 src 最多复制 n 个字符到目标缓冲区 dest。
    • 它的主要目的是防止缓冲区溢出(如果使用得当)。
    • 重要行为特点(容易出错!):
      • 最多复制 n 个字符
        • 如果 src 的长度 小于 n,strncpy 会用 \0 填充剩余部分,直到总共写入 n 个字符。
        • 如果 src 的长度 大于或等于 n,strncpy 只复制前 n 个字符,且不会自动添加结尾的 \0!

❗ 这意味着:strncpy 不保证目标字符串以 \0 结尾!
如果 src 长度 ≥ n,你必须手动添加 \0,否则 dest 不是一个合法的 C 字符串!

1
2
3
4
5
6
char *strncpy(char *dest, const char *src, size_t n);

- dest:目标数组(必须足够大,至少 n 字节)。
- src:源字符串。
- n:要复制的最大字符数(包括是否包含 \0)。
- 返回值:指向 dest 的指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <string.h>

int main() {
char src[] = "Hello, world!";
char dest1[20];
char dest2[8]; // 只能容纳 7 个字符 + '\0'

// 情况1:n 大于源字符串长度
strncpy(dest1, src, sizeof(dest1) - 1);
dest1[sizeof(dest1) - 1] = '\0'; // 保险起见,手动加 \0(其实这里不需要,因为 src 较短)
printf("dest1: %s\n", dest1);

// 情况2:n 小于源字符串长度(危险!)
strncpy(dest2, src, sizeof(dest2) - 1); // 只复制 7 个字符
dest2[sizeof(dest2) - 1] = '\0'; // ⭐ 必须手动加 \0!
printf("dest2: %s\n", dest2);

return 0;
}
// 输出结果
// dest1: Hello, world!
// dest2: Hello,
  • sprintf 是 C 语言标准库中的一个函数,用于将格式化的数据写入字符串缓冲区。
1
2
3
4
5
6
int sprintf(char *buffer, const char *format, ...);

- buffer:目标字符串缓冲区的指针,用于存储格式化后的结果。
- format:格式化字符串,包含要写入的文本以及格式说明符(如 %d, %f, %s 等)。
- ...:可变参数列表,其数量和类型由格式字符串中的格式说明符决定。
- 返回值:成功时返回写入的字符总数(不包括终止空字符 \0);失败时返回负数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int main() {
char buffer[50]; // 确保缓冲区有足够的空间
int num = 42;
float pi = 3.14159;
char str[] = "Hello";

// 使用 sprintf 格式化数据
int len = sprintf(buffer, "Number: %d, Pi: %.2f, String: %s", num, pi, str);

// 输出结果
printf("Formatted string: %s\n", buffer);
printf("Length of formatted string: %d\n", len);

return 0;
}
// 输出结果
// Formatted string: Number: 42, Pi: 3.14, String: Hello
// Length of formatted string: 27
  • snprintf 函数的作用
    • 功能:将格式化的数据写入一个字符数组(字符串缓冲区),最多写入 n 个字符(包括结尾的 \0)。
    • 它是 sprintf 的“安全版本”——不会写入超过指定大小的内存,因此能防止缓冲区溢出。
    • ✅ 核心优势:
      • 自动以 \0 结尾(只要 n > 0)。
      • 严格限制写入长度,即使格式化结果很长,也只写入 n - 1 个字符 + 1 个 \0。
      • 支持所有 printf 风格的格式说明符(如 %d, %s, %f 等)。
1
2
3
4
5
6
7
8
9
int snprintf(char *str, size_t n, const char *format, ...);

- str:目标缓冲区(字符数组)。
- n:最大写入字符数(包括结尾的 \0)。
- format:格式化字符串(如 "Hello %s, you are %d years old")。
- ...:可变参数,对应格式说明符。
- 返回值:
如果成功,返回完整格式化结果的字符数(不包括 \0),即使它被截断了。
如果 n == 0,不写入任何内容,但返回所需长度(可用于动态分配内存)。

示例 1:安全复制字符串(替代 strcpy / strncpy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int main() {
char src[] = "Hello, world!";
char dest[10]; // 只能容纳 9 个字符 + '\0'

int ret = snprintf(dest, sizeof(dest), "%s", src);

printf("返回值: %d\n", ret); // 13("Hello, world!" 的长度)
printf("dest 内容: \"%s\"\n", dest); // "Hello, wo"(被截断,但安全!)

// 检查是否截断
if (ret >= (int)sizeof(dest)) {
printf("警告:字符串被截断了!\n");
}

return 0;
}
// 输出结果
// 返回值: 13
// dest 内容: "Hello, wo"
// 警告:字符串被截断了!

示例 2:格式化拼接(类似 sprintf,但安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
char buffer[50];
char name[] = "Alice";
int age = 25;

snprintf(buffer, sizeof(buffer), "Name: %s, Age: %d", name, age);

printf("%s\n", buffer); // 输出:Name: Alice, Age: 25

return 0;
}
// 输出结果
// Name: Alice, Age: 25
  • sscanf 函数的作用

    • 功能:从一个字符串(const char *str)中,按照指定的格式(format)提取数据,并存入对应的变量中。
    • 常用于解析结构化文本,比如日期、坐标、配置项、日志行等。

    💡 可以把它理解为 “反向的 sprintf”:

    • sprintf 把变量 → 拼成字符串
    • sscanf 把字符串 → 拆成变量
1
2
3
4
5
6
int sscanf(const char *str, const char *format, ...);

- str:要解析的源字符串。
- format:格式控制字符串(如 "%d %s")。
- ...:可变参数,是要写入的变量的地址(注意:要加 &,除非是指针或数组)。
- 返回值:成功匹配并赋值的项目数量(即成功转换的数据个数)。如果一开始就匹配失败,返回 0;如果遇到文件结束(这里指字符串结束),可能返回 EOF(但很少见)。

示例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main() {
char date_str[] = "2026-01-06";
int year, month, day;

int ret = sscanf(date_str, "%d-%d-%d", &year, &month, &day);

if (ret == 3) {
printf("日期解析成功:%04d年%02d月%02d日\n", year, month, day);
} else {
printf("日期格式错误!\n");
}

return 0;
}
// 输出结果
// 日期解析成功:2026年01月06日

示例2:跳过部分字段(使用 * 忽略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

int main() {
char log[] = "INFO 2026-01-06 14:30 User logged in";

char level[10];
char message[100];

// 跳过日期和时间(不保存)
int ret = sscanf(log, "%s %*s %*s %99[^\n]", level, message);

if (ret == 2) {
printf("日志级别: %s\n", level);
printf("消息内容: %s\n", message);
}

return 0;
}
// 解释
// %*s 表示读取一个字符串但不赋值(忽略)。
// %99[^\n] 表示读取最多 99 个非换行字符(常用于读取剩余整行)。

// 输出结果
// 日志级别: INFO
// 消息内容: User logged in
  • strstr 函数的作用
    • 功能:在主字符串 haystack 中查找子字符串 needle 第一次出现的位置。
    • 如果找到,返回指向主字符串中该位置的指针;
    • 如果没找到,返回 NULL。
    • 它区分大小写,并且包括空字符 \0 的匹配逻辑(如果 needle 是空字符串 "",则返回 haystack 本身)。
1
2
3
4
5
6
7
8
9
char *strstr(const char *haystack, const char *needle);

- haystack:要被搜索的主字符串。
- needle:要查找的子字符串。
- 返回值:
成功:指向 haystack 中第一次出现 needle 的位置(类型为 char*)。
失败:NULL。

⚠️ 注意:返回的是原字符串中的地址,不是新分配的内存,不要 free()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <string.h>

int main() {
char text[] = "Hello, welcome to C programming!";
char *result = strstr(text, "welcome");

if (result != NULL) {
printf("找到了!位置: %s\n", result);
// 输出从 "welcome" 开始的剩余字符串
} else {
printf("未找到子字符串。\n");
}

return 0;
}
// 输出结果
// 找到了!位置: welcome to C programming!
  • atof 函数的作用

    • 功能:将一个表示数字的 C 字符串(const char*) 转换为对应的 double 类型浮点数。
    • 它会跳过开头的空白字符(如空格、制表符),然后尽可能多地解析合法的浮点数格式,直到遇到无法识别的字符为止。

    ✅ 支持的格式包括:

    • 整数:"123"
    • 小数:"123.456"
    • 科学计数法:"1.23e4" 或 "1.23E-5"
    • 带正负号:"-3.14", "+2.718"
1
2
3
4
5
6
7
double atof(const char *str);

- 参数:str —— 要转换的字符串。
- 返回值:转换后的 double 值。
如果字符串无法转换(如 "abc"),则返回 0.0。
如果字符串部分可转换(如 "3.14abc"),则转换到第一个非法字符为止(结果为 3.14)。
⚠️ 注意:atof 不会报告错误!它无法区分“真的 0”和“转换失败返回的 0”。如果需要错误检测,应使用更安全的 strtod。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <stdlib.h> // 必须包含 stdlib.h

int main() {
char str1[] = "3.14159";
char str2[] = " -2.5e3 "; // 前后有空格
char str3[] = "123.45abc"; // 后面有非法字符
char str4[] = "hello"; // 完全无法转换

double d1 = atof(str1);
double d2 = atof(str2);
double d3 = atof(str3);
double d4 = atof(str4);

printf("atof(\"%s\") = %.6f\n", str1, d1);
printf("atof(\"%s\") = %.6f\n", str2, d2);
printf("atof(\"%s\") = %.6f\n", str3, d3);
printf("atof(\"%s\") = %.6f\n", str4, d4); // 输出 0.000000

return 0;
}
// 输出结果
// atof("3.14159") = 3.141590
// atof(" -2.5e3 ") = -2500.000000
// atof("123.45abc") = 123.450000
// atof("hello") = 0.000000
  • strtod 函数的作用
    • 功能:将字符串开头的合法浮点数部分转换为 double 类型。
1
2
3
4
5
6
7
8
9
10
11
12
double strtod(const char *str, char **endptr);

- str:要转换的源字符串。
- endptr(可选):
如果不为 NULL,strtod 会把指向第一个无法转换的字符的指针存入 *endptr。
如果整个字符串都有效,*endptr 指向结尾的 \0。
如果没做任何转换(如输入 "abc"),*endptr 会被设为 str 本身。
返回值:
成功:转换后的 double 值;
溢出:返回 ±HUGE_VAL(正/负无穷),并设置 errno = ERANGE;
无有效数字:返回 0.0,但可通过 endptr 判断是否真的为 0。
✅ 需要包含头文件:<stdlib.h>,有时还需 <errno.h> 来检查溢出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main() {
char *inputs[] = {
"3.14159",
" -2.5e3 ",
"123.45abc",
"hello",
"0.0",
"1e500", // 可能溢出
""
};
int n = sizeof(inputs) / sizeof(inputs[0]);

for (int i = 0; i < n; i++) {
char *end;
errno = 0; // 每次调用前清零 errno

double val = strtod(inputs[i], &end);

// 情况1:完全没有转换(非法输入)
if (end == inputs[i]) {
printf("❌ 输入 \"%s\":无有效数字\n", inputs[i]);
}
// 情况2:部分转换(有额外字符)
else if (*end != '\0') {
printf("⚠️ 输入 \"%s\":转换为 %g,剩余未识别: \"%s\"\n", inputs[i], val, end);
}
// 情况3:完全转换成功
else if (errno == ERANGE) {
printf("❗ 输入 \"%s\":数值溢出!结果 = %g\n", inputs[i], val);
}
else {
printf("✅ 输入 \"%s\":成功转换为 %g\n", inputs[i], val);
}
}

return 0;
}
// 输出结果
// ✅ 输入 "3.14159":成功转换为 3.14159
// ✅ 输入 " -2.5e3 ":成功转换为 -2500
// ⚠️ 输入 "123.45abc":转换为 123.45,剩余未识别: "abc"
// ❌ 输入 "hello":无有效数字
// ✅ 输入 "0.0":成功转换为 0
// ❗ 输入 "1e500":数值溢出!结果 = inf
// ❌ 输入 "":无有效数字

8.2 内存相关函数

memcpy(void* dest, const void* src, size_t n)‌

memmove(void* dest, const void* src, size_t n)‌

memset(void* s, int c, size_t n)‌

memcmp(const void* s1, const void* s2, size_t n)‌