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)
    • 程序在调用某个操作时,立即返回,无需等待操作完成。
    • 如果操作未完成(如数据未准备好),程序会立即返回错误码(如 EAGAINEWOULDBLOCK),允许程序继续执行其他任务。

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*p2const 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 intunsigned 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 相等!
  • strncmpstrcmp 的“限定长度”版本
    • 功能比较两个字符串的前 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 的长度 小于 nstrncpy 会用 \0 填充剩余部分,直到总共写入 n 个字符。
        • 如果 src 的长度 大于或等于 nstrncpy 只复制前 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)