可变参数函数(Variadic Functions)
C 语言中的可变参数函数
在 C 语言里,可变参数函数(variadic functions) 是指可以接受数量不定的参数的函数。当函数调用时无法提前确定实参个数时,这一特性非常有用。它至少需要一个固定参数,其后可以跟任意数量的额外参数。
先看一个例子:
#include <stdio.h>
#include <stdarg.h>
// 可变参数函数:把传入的 n 个整数依次打印
void printalili(int n, ...) {
va_list args; // 1. 声明 va_list 变量
va_start(args, n); // 2. 用最后一个固定参数 n 初始化
for (int i = 0; i < n; i++){
printf("%d ", va_arg(args, int)); // 3. 依次取出 int 型实参
}
printf("\n");
va_end(args); // 4. 清理
}
int main() {
printalili(3, 1, 2, 3); // 输出:1 2 3
printalili(5, 1, 2, 3, 4, 5); // 输出:1 2 3 4 5
return 0;
}
运行结果
1 2 3
1 2 3 4 5
解释:printalili() 的第一个参数 n 是固定的,表示后面还有多少个可变参数。我们通过 va_list、va_start、va_arg、va_end 这组宏来访问这些可变实参。
A. 可变参数函数语法
至少需要一个固定形参,后面用省略号 ... 表示可变部分:
返回类型 函数名(固定形参, ...);
B. 访问可变参数的步骤(<stdarg.h> 提供)
-
创建变量列表对象 —
va_listva_list list; -
初始化 —
va_start()va_start(list, 最后一个固定形参); -
逐个取值(
获取下一个参数) —va_arg()类型 变量 = va_arg(list, 类型);注意:类型必须正确,且调用次数不能超过实际传入个数。因此通常要像上面那样把“个数”也作为固定参数传进来。
-
清理 —
va_end()va_end(list);
C. 更多示例
- 求和
#include <stdarg.h>
#include <stdio.h>
int getSum(int n, ...) {
int sum = 0;
va_list list;
va_start(list, n);
for (int i = 0; i < n; i++){
sum += va_arg(list, int);
}
va_end(list);
return sum;
}
int main() {
printf("1 + 2 = %d\n", getSum(2, 1, 2));
printf("3 + 4 + 5 = %d\n", getSum(3, 3, 4, 5));
printf("6 + 7 + 8 + 9 = %d\n", getSum(4, 6, 7, 8, 9));
return 0;
}
输出
1 + 2 = 3
3 + 4 + 5 = 12
6 + 7 + 8 + 9 = 30
- 混合类型(int 与 double 交替)
#include <stdio.h>
#include <stdarg.h>
void print(int n, ...) {
va_list args;
va_start(args, n);
for (int i = 0; i < n; i++) {
if (i % 2 == 0){
printf("Integer: %d\n", va_arg(args, int));
}
else{
printf("Float: %.2f\n", va_arg(args, double));
}
}
va_end(args);
}
int main() {
print(4, 10, 3.14, 20, 2.71);
return 0;
}
输出
Integer: 10
Float: 3.14
Integer: 20
Float: 2.71
系统级 I/O 调用(Input-Output System Calls)
输入/输出系统调用
系统调用(System calls)是程序向系统内核发出的请求,用来获得程序本身无法直接访问的服务。在 C 语言中,所有输入输出操作也是通过“输入-输出系统调用(input-output system calls)”完成的。程序通过调用内核,获得对显示器、键盘等输入输出设备的访问权限。
在讲解 I/O 系统调用之前,必须先了解“文件描述符(file descriptor)”的概念。
A. 文件描述符(File Descriptor)
文件描述符是一个整数,它在当前进程中唯一标识一个已打开的文件。每个进程在操作系统中都有一张文件描述符表,表项是指向文件表项(file table entry) 的指针。
文件表项是内存里的一个结构体,可视为已打开文件的“代理”。当进程请求打开某个文件时,内核会创建对应的文件表项,用来维护当前读写位置(文件偏移)等信息。
B. 标准文件描述符
任何一个进程启动时,它的文件描述符表里0、1、2 这三个描述符会被自动打开。默认情况下,这 3 个描述符都指向同一个文件表项,对应的设备文件是 /dev/tty(当前终端)。
- stdin(文件描述符 0) :标准输入流
我们从键盘敲下的任何字符,都通过 fd 0 从/dev/tty读入,进入进程。 - stdout(文件描述符 1) :标准输出流
进程要往屏幕打印的任何内容,都通过 fd 1 写到/dev/tty,最终显示在终端上。 - stderr(文件描述符 2) :标准错误流
进程报错时输出的信息,同样通过 fd 2 写到/dev/tty,把错误信息呈现到屏幕。
C. C语言中的输入/输出系统调用(Input-Output System Calls in C)
I/O 系统调用让程序直接访问操作系统的输入输出功能,可对文件操作、内存管理、设备通信实现完全控制。 C 语言共有 5 个输入输出系统调用:
1. create(创建)
create() 系统调用,用于在 C 程序中新建一个空文件。 函数原型定义在 <unistd.h>,权限宏定义在 <fcntl.h>。
1.1 语法:
#include <fcntl.h> // 权限宏
#include <unistd.h> // create 原型
int create(const char *filename, mode_t mode);
参数
- `filename`:要创建的文件名(可含路径)。
- `mode`:指定新文件的**访问权限**,常用三位八进制数,如 `0644`(rw-r--r--)。
返回值
- 成功:返回**第一个未使用的文件描述符**(通常是 3,因为 0、1、2 已被 stdin/stdout/stderr 占用)。
- 失败:返回 `-1`,并置 `errno`。
示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = creat("newfile.txt", 0644); // 创建文件,权限 0644
if (fd == -1) {
perror("Error creating file");
return 1;
}
printf("文件 'newfile.txt' 创建成功\n文件描述符:%d\n", fd);
close(fd); // 关闭文件描述符
return 0;
}
输出
文件 'newfile.txt' 创建成功
文件描述符:3
1.2 操作系统内部流程
- 在磁盘上新建一个空文件。
- 在内核中创建对应的文件表项(记录偏移、状态等)。
- 把进程文件描述符表中第一个空闲项指向该文件表项。
- 返回这个文件描述符;若失败则返回
-1。
2. open(打开)
open() 函数对应“打开”系统调用,用来打开已存在文件,也可创建新文件。 原型在 <unistd.h>,标志位宏在 <fcntl.h>。
2.1 语法
#include <fcntl.h>
#include <unistd.h>
int open(const char *path, int flags);
参数
- `path`:文件路径
– 绝对路径:以 `/` 开头,如 `/home/user/foo.txt`
– 相对路径:仅文件名,如 `foo.txt`(要求与可执行文件在同一目录)
- `flags`:打开方式,可用位或组合。常用标志:
| 标志 | 含义 |
|---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 读写 |
O_CREAT | 若文件不存在则创建 |
O_EXCL | 与 O_CREAT 合用;文件已存在时返回错误 |
O_APPEND | 追加模式,写指针置于末尾 |
O_NONBLOCK | 非阻塞打开 |
O_CLOEXEC | 执行 exec 族函数时自动关闭 |
O_TMPFILE | 创建无名临时文件 |
示例
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
extern int errno;
int main() {
// 若 foo.txt 不存在则创建,并以只读方式打开
int fd = open("foo.txt", O_RDONLY | O_CREAT);
printf("fd = %d\n", fd);
if (fd == -1) {
printf("错误号 %d\n", errno);
perror("Program");
}
return 0;
}
输出
fd = 3
2.2 操作系统内部流程
- 在磁盘定位指定文件(不存在且带
O_CREAT则新建)。 - 内核创建文件表项,记录读写偏移、状态等。
- 把进程文件描述符表中第一个空闲项指向该文件表项。
- 返回这个文件描述符;失败返回
-1并置errno。
3. close 系统调用
在 C 语言中,close 系统调用以 close() 函数的形式提供,它告诉操作系统你已经使用完一个文件描述符,并关闭该文件描述符指向的文件。它定义在 <unistd.h> 头文件中。
3.1 语法
close(fd);
参数
- `fd`:你想要关闭的文件的文件描述符。
返回值
- 成功时返回 0
- 出错时返回 -1
示例 1
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main() {
int fd1 = open("foo.txt", O_RDONLY);
if (fd1 < 0) {
perror("c1");
exit(1);
}
printf("打开的文件描述符 = %d\n", fd1);
// 使用 close 系统调用
if (close(fd1) < 0) {
perror("c1");
exit(1);
}
printf("文件描述符已关闭。\n");
}
输出:
打开的文件描述符 = 3
文件描述符已关闭。
4. read(读取)
read() 系统调用从文件描述符 fd 所指文件中,最多读取 cnt 字节到缓冲区 buf。 调用成功会更新文件的访问时间。函数原型在 <unistd.h>。
4.1 语法
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t cnt);
参数
- `fd`:已打开文件的文件描述符(由 `open()` 等返回)。
- `buf`:指向**足够大**的内存区域,用于存放读到的数据。
- `cnt`:请求读取的最大字节数。
返回值
- `>0`:实际读到的字节数(可能小于 `cnt`)。
- `=0`:已到达文件末尾(EOF)。
- `-1`:出错,或被信号中断,此时 `errno` 被设置。
示例 1
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
int fd, sz;
char *c = (char *)calloc(100, sizeof(char));
fd = open("foo.txt", O_RDONLY);
if (fd < 0) {
perror("r1");
exit(1);
}
sz = read(fd, c, 10);
printf("调用 read(%d, c, 10),返回实际读取 %d 字节。\n", fd, sz);
c[sz] = '\0';
printf("读到的内容是:%s\n", c);
return 0;
}
输出
调用 read(3, c, 10),返回实际读取 10 字节。
读到的内容是:0 0 0 foo.
示例 2(易错点)
假设 sample.txt 中只有 6 个 ASCII 字符 "foobar"。
int fd1 = open("sample.txt", O_RDONLY, 0);
int fd2 = open("sample.txt", O_RDONLY, 0);
read(fd1, &c, 1); // 读第 1 字节,fd1 偏移移到 1
read(fd2, &c, 1); // 读第 1 字节,fd2 偏移移到 1
printf("c = %c\n", c);
输出
c = f
解释:fd1 和 fd2 各自拥有独立的文件表项,因此文件偏移互不影响。第二次读取仍从文件头开始,所以读到的是 'f' 而不是 'o'。
5. write(写入)
write() 系统调用把缓冲区 buf 中的 cnt 字节数据写入与文件描述符 fd 关联的文件或套接字。
cnt 不能超过 INT_MAX(limits.h 中定义)。若 cnt 为 0,则 write() 直接返回 0,不做任何实际操作。 函数原型定义在 <unistd.h>。
语法
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t cnt);
参数
- `fd`:已打开的可写文件描述符。
- `buf`:待写入数据的起始地址。
- `cnt`:请求写入的字节数。
返回值
- `>0`:实际写入的字节数(可能小于 `cnt`)。
- `=0`:对常规文件表示已写到 EOF(罕见)。
- `-1`:出错或被信号中断;`errno` 被设置。
- 若**尚未写任何数据**就被信号打断,返回 `-1` 且 `errno = EINTR`。
- 若**已写部分数据**后被信号打断,返回**已写字节数**。
注意
- 文件必须以可写方式打开(
O_WRONLY、O_RDWR等)。 buf长度必须 ≥cnt,否则可能溢出。- 实际写入字节数可能小于请求值,需循环处理。
示例 1
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int sz;
int fd = open("foo.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("r1");
exit(1);
}
sz = write(fd, "hello geeks\n", strlen("hello geeks\n"));
printf("调用 write(%d, \"hello geeks\\n\", %ld),返回 %d\n", fd, strlen("hello geeks\n"), sz);
close(fd);
return 0;
}
输出
调用 write(3, "hello geeks\n", 12),返回 12
运行后查看 foo.txt,内容只剩 hello geeks;原有内容会被覆盖(O_TRUNC)。
示例 2
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
int fd[2];
char buf1[12] = "hello world";
char buf2[12];
fd[0] = open("foobar.txt", O_RDWR); // 读写方式打开
fd[1] = open("foobar.txt", O_RDWR); // 再打开一次,独立偏移
write(fd[0], buf1, strlen(buf1)); // 把 "hello world" 写入文件
write(1, buf2, read(fd[1], buf2, 12)); // 从文件读回并写到标准输出
close(fd[0]);
close(fd[1]);
return 0;
}
输出
hello world
解释:
fd[0]把字符串写进文件后,fd[0]的偏移移到末尾。fd[1]的偏移仍在文件头,因此能完整读出刚写入的内容。- 最后通过
write(1, ...)把读到的数据写到终端(标准输出),于是屏幕显示hello world。
6. 拓展对比
核心区别只有一句话: open/read/write/close 是系统调用(内核入口)fopen/fread/fwrite/fclose 是标准库函数(libc 封装)。
| 维度 | 系统调用 | 标准库 IO |
|---|---|---|
| 所属层级 | 内核接口(unistd.h) | C 标准库(libc) |
| 函数名 | open, read, write, close … | fopen, fread, fprintf, fwrite, fclose … |
| 返回类型 | 整数 文件描述符 fd | 结构体指针 **FILE *** |
| 缓冲策略 | 无缓冲,每次调用都进内核 | 带缓冲(全缓冲、行缓冲、无缓冲),减少系统调用次数 |
| 可移植性 | POSIX 为主,Windows 需专用 API | ANSI C,全平台通用 |
| 功能粒度 | 原始字节,只能 read/write | 提供格式化读写 fprintf/fscanf,字符串行读写 fgets/fputs 等 |
| 性能 | 频繁调用进内核,小块 IO 慢 | 用户空间缓冲,合并大块再进内核,快 |
| 错误处理 | 返回 -1,全局 errno | 返回 NULL 或短计数,用 ferror/feof 判断 |
| 文件偏移 | 手动 lseek | fseek/ftell,库自动管理缓冲区同步 |
| 适用场景 | 需要精确控制、设备文件、管道、socket、内存映射 | 普通文本/二进制文件、格式化输入输出、跨平台程序 |
一句话记忆:
- 想直接跟内核打交道、做底层开发 → 用
open/read/write - 想写普通应用、代码可移植、图方便 → 用
fopen/fprintf/fread
二者都能完成“创建、打开、读、写、关闭”这些动作,但实现路径、性能、可移植性、功能丰富度完全不同。两者本质上是不同抽象层级的解决方案,适用于不同的应用场景
信号(Signals)
C 语言中的信号(Signals)
信号是由操作系统产生并发送给进程的“软件中断”。例如,当用户在终端按下 Ctrl-C,或者其他进程向本进程发送信号时,就会触发信号。系统只提供固定集合的信号,每个信号用一个整数值标识,例如 SIGINT 的值为 2。
一、信号处理(Signal Handling)
在 C 语言中,信号默认由系统预设动作处理,但C语言也允许我们手动接管。这个过程称为“信号处理”:为特定信号编写自定义处理函数,从而决定程序在收到该信号时如何反应。
二、默认信号处理动作
每个信号都关联一个默认处理例程,常见动作有:
- Ign:忽略信号(什么都不做,直接返回)。
- Term:终止进程。
- Cont:让被暂停的进程继续运行。
- Stop:暂停(阻塞)进程。
示例(默认行为)
#include <stdio.h>
#include <signal.h>
int main() {
while (1) {
printf("hello world\n");
}
return 0;
}
运行后无限打印。按下 Ctrl-C,内核向进程发送 SIGINT,默认动作是立即终止进程。
三、自定义信号处理函数
几乎任何信号(SIGKILL 除外)的默认处理函数都可以被替换。自定义处理函数必须返回 void,且带一个 int 参数(信号编号)。
注册方法:使用 <signal.h> 提供的 signal() 函数。
语法:
signal(信号类型, 处理函数名);
示例:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void signalHandler(int sig) {
printf("Caught signal %d\n", sig);
exit(sig);
}
int main() {
signal(SIGINT, signalHandler); // 接管 Ctrl-C
while (1) {
printf("Hello World!\n");
}
return 0;
}
运行中按 Ctrl-C,将输出 Caught signal 2 然后程序退出。
四、手动产生信号
除了键盘触发,代码里也能主动发信号。
1. raise() —— 向当前进程自身发信号
语法:
raise(信号类型);
— 成功返回 0,失败返回非 0。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void signalHandler(int sig) {
printf("Interrupt handled: %d\n", sig);
exit(sig);
}
int main() {
signal(SIGINT, signalHandler);
raise(SIGINT); // 自己给自己发 SIGINT
return 0;
}
输出: Interrupt handled: 2
2. kill() —— 向指定进程(组)发信号
语法:
kill(pid, 信号类型);
- pid 为目标进程号。
示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handle_signal(int sig) {
printf("Received signal: %d\n", sig);
}
int main() {
signal(SIGINT, handle_signal);
pid_t pid = getpid(); // 拿到自己 PID
kill(pid, SIGINT); // 给自己发 SIGINT
return 0;
}
输出: Received signal: 2
五、常见信号一览
信号本质上是发送给进程的一个整数编号(1-31),表示特定事件的发生。当信号到达时,进程会中断当前执行流,转而处理信号
六、信号总结:
信号就是操作系统给进程发的“紧急短消息”,用来打断进程并让它“立刻去处理一件突发事件”。
它确实发生在内核层,但你可以把它当成一种最轻量级的进程间/内核-进程间异步通知机制。
A、把抽象概念换成生活比喻
-
你在办公室(进程)写代码,突然:
- 电话铃响(SIGINT)→ 你可以接,也可以无视,但默认就挂了(终止)。
- 大楼火警(SIGKILL)→ 保安(内核)直接把你拖走,没得商量。
- 同事拍你肩膀(SIGUSR1)→ 你们俩事先约定好拍肩膀=“去打印”,于是你去打印。
-
内核=大楼管理处,信号=各种颜色的便利贴,贴在工位上:
- 绿色:你自己贴给自己(raise)。
- 红色:别的同事(进程)让管理处帮你贴(kill)。
- 黑色:管理处必须强制执行(SIGKILL)。
B、常见真实使用场景
| 场景 | 谁发 | 发什么 | 进程怎么利用 |
|---|---|---|---|
| 用户想停掉跑飞的程序 | 终端驱动 | SIGINT (Ctrl-C) | 干净地退出,释放资源 |
| 子进程结束了,父进程要收尸 | 内核 | SIGCHLD | 在 handler 里 waitpid(),防止僵尸 |
| 定时器到期 | 内核 | SIGALRM | 网络库超时重传、sleep 被中断 |
| 服务器“优雅重启” | 运维脚本 | SIGTERM | 先关掉监听 socket,把现有请求处理完再 exit |
| 强制立即杀死顽固进程 | 运维脚本 | SIGKILL | 内核直接回收,不给进程反应时间 |
| 两个进程之间传递 1 字节通知 | 进程 A | SIGUSR1 / SIGUSR2 | 事先约定:收到 SIGUSR1=“数据已写进共享内存,请消费” |
| 程序自己段错误 | CPU + 内核 | SIGSEGV | 可以捕获做 core dump,事后调试 |
| 热升级二进制 | 父进程 | SIGHUP | 重新加载配置、重新打开日志文件 |
C、一句话总结
信号是操作系统级别的“异步中断”,给进程提供最廉价、最及时的通知手段:
- 只有几微秒延迟;
- 不占用 fd、不占用端口;
- 携带数据量极小(就一个编号);
- 任何进程都能发,内核也能发;
- 进程可以“先屏蔽再处理”,保证临界区不被打断。
掌握它,你就能:
- 让程序优雅地死(SIGTERM 处理)、安全地活(SIGCHLD 回收);
- 做父子进程同步、定时器、热加载、优雅重启;
- 在调试时捕获段错误并生成现场快照(core)。
一句话:信号是 Unix/Linux 世界里最老、最轻、最离不开的“进程短信”。
📋 信号处理相关 API
| 类别 | 函数原型 | 功能描述 | 返回值 | 关键特性 |
|---|---|---|---|---|
| 信号处理注册 | void (*signal(int sig, void (*func)(int)))(int); | 设置信号处理函数(简单版) | 原处理函数或 SIG_ERR | 传统方法,可移植性好 |
int sigaction(int sig, const struct sigaction *act, struct sigaction *oldact); | 设置信号处理行为(推荐) | 0 成功,-1 失败 | 更精细控制,现代标准,建议使用 | |
| 信号发送 | int kill(pid_t pid, int sig); | 向指定进程发送信号 | 0 成功,-1 失败 | pid>0:指定进程; pid=0:同组; pid=-1:所有有权进程 |
int raise(int sig); | 向当前进程发送信号 | 0 成功,非0 失败 | 相当于 kill(getpid(), sig) | |
unsigned int alarm(unsigned int seconds); | 设置定时器(SIGALRM) | 剩余秒数 | 单一定时器,精度秒级 | |
int pause(void); | 挂起进程直到信号到达 | 总是 -1 | 与信号处理配合使用 | |
| 信号集操作 | int sigemptyset(sigset_t *set); | 清空信号集 | 0 成功,-1 失败 | 初始化信号集 |
int sigfillset(sigset_t *set); | 填充所有信号到集合 | 0 成功,-1 失败 | 包含所有可用信号 | |
int sigaddset(sigset_t *set, int signum); | 添加信号到集合 | 0 成功,-1 失败 | 构建自定义信号集 | |
int sigdelset(sigset_t *set, int signum); | 从集合删除信号 | 0 成功,-1 失败 | 修改信号集 | |
int sigismember(const sigset_t *set, int signum); | 检查信号是否在集合中 | 1 在,0 不在 | 查询信号集成员 | |
| 信号屏蔽 | int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); | 进程信号掩码操作 | 0 成功,-1 失败 | how: SIG_BLOCK/SIG_UNBLOCK/SIG_SETMASK |
int sigpending(sigset_t *set); | 获取待处理信号集 | 0 成功,-1 失败 | 查看被阻塞但已到达的信号 | |
int sigsuspend(const sigset_t *mask); | 临时替换信号掩码并暂停 | -1 (总是) | 原子操作,避免竞态条件 | |
| 高级功能 | int sigwait(const sigset_t *set, int *sig); | 同步等待信号 | 0 成功,错误码失败 | 多线程中推荐使用 |
int sigqueue(pid_t pid, int sig, const union sigval value); | 发送信号并携带数据 | 0 成功,-1 失败 | 实时信号,可附加信息 | |
int sigaltstack(const stack_t *ss, stack_t *old_ss); | 设置信号处理栈 |
套接字编程(Socket Programming)
一. C 语言中的 Socket 编程
Socket 编程是一种将网络中两个节点连接起来以进行通信的方式。一个 socket(节点)在某个 IP 地址的特定端口上监听,而另一个 socket 则主动连接它。服务器端创建监听 socket,客户端则主动发起连接。 Socket 编程广泛应用于即时通讯、二进制数据流、文档协作、在线流媒体等平台。
示例:
在这个 C 程序中,我们在服务器和客户端之间交换一条“Hello”消息,以演示客户端/服务器模型。
服务器端代码(server.c)
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
int main(int argc, char const* argv[])
{
int server_fd, new_socket;
ssize_t valread;
struct sockaddr_in address;
int opt = 1;
socklen_t addrlen = sizeof(address);
char buffer[1024] = { 0 };
char* hello = "Hello from server";
// 创建 socket 文件描述符
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 强制 socket 绑定到端口 8080
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定 socket 到端口 8080
if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 开始监听
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr*)&address, &addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 读取客户端发送的数据(减去 1 是为了给字符串末尾留空字符)
valread = read(new_socket, buffer, 1024 - 1);
printf("%s\n", buffer);
// 向客户端发送响应
send(new_socket, hello, strlen(hello), 0);
printf("Hello message sent\n");
// 关闭连接
close(new_socket);
close(server_fd);
return 0;
}
客户端代码(client.c)
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
int main(int argc, char const* argv[])
{
int status, valread, client_fd;
struct sockaddr_in serv_addr;
char* hello = "Hello from client";
char buffer[1024] = { 0 };
// 创建 socket
if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("\n Socket creation error \n");
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将 IP 地址从文本转换为二进制格式
if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 连接到服务器
if ((status = connect(client_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr))) < 0){
printf("\nConnection Failed \n");
return -1;
}
// 向服务器发送消息
send(client_fd, hello, strlen(hello), 0);
printf("Hello message sent\n");
// 读取服务器响应
valread = read(client_fd, buffer, 1024 - 1);
printf("%s\n", buffer);
// 关闭 socket
close(client_fd);
return 0;
}
编译命令:
gcc client.c -o client
gcc server.c -o server
输出结果:
客户端输出:
Hello message sent
Hello from server
服务器输出:
Hello from client
Hello message sent
二. Socket 编程的组成
- 套接字(Socket)
套接字是程序访问网络、与其他进程/节点进行通信的核心组件之一。它只是一个 IP 地址与端口号的组合,充当通信的端点。
示例:192.168.1.1:8080,其中冒号前的 192.168.1.1 是 IP 地址,冒号后的 8080 是端口号。
套接字类型:
- TCP 套接字(流式套接字):提供可靠的、基于连接的通信(即 TCP 协议)。
- UDP 套接字(数据报套接字):提供无连接通信,速度更快但不可靠(即 UDP 协议)。
- 客户端-服务器模型
客户端-服务器模型是指 socket 编程所采用的架构:客户端与服务器彼此交互,以交换信息或服务。该架构允许客户端发送服务请求,服务器接收并处理这些请求,然后将响应返回给客户端。C 语言中的 Socket 编程是一种强大的网络通信手段。
三. 创建服务器端进程
服务器通过以下步骤创建:
1. 创建套接字
这一步调用 socket() 函数创建套接字。
sockfd = socket(domain, type, protocol);
参数:
- sockfd:套接字描述符,一个整数(类似文件句柄)
- domain:整数,指定通信域。同一主机内进程通信使用 POSIX 标准定义的 AF_LOCAL;基于 IPv4 的不同主机进程通信使用 AF_INET,IPv6 则使用 AF_INET6。
- type:通信类型
– SOCK_STREAM:TCP(可靠、面向连接)
– SOCK_DGRAM:UDP(不可靠、无连接)
- protocol:协议值,Internet Protocol(IP) 填 0,与 IP 报文协议字段值一致(详见 man protocols)。
2. 设置套接字选项
setsockopt 可修改 sockfd 引用的套接字选项。此步可选,但能实现地址与端口复用,防止“地址已在使用”这类错误。
setsockopt(sockfd, level, optname, optval, socklen_t optlen);
3. 绑定
套接字创建后,调用 bind() 将其绑定到 addr(自定义数据结构)指定的地址和端口号。示例代码中服务器绑定到本机,因此 IP 地址使用 INADDR_ANY。
bind(sockfd, sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:socket() 创建的套接字文件描述符
- addr:指向 struct sockaddr 的指针,内含要绑定的 IP 地址和端口号
- addrlen:addr 结构体的长度
4. 监听
服务器调用 listen() 使套接字进入被动模式,等待客户端发起连接。backlog 定义了 sockfd 挂起连接队列的最大长度。若队列已满且有新连接请求,客户端可能收到 ECONNREFUSED 错误。
listen(sockfd, backlog);
参数:
- sockfd:socket() 创建的套接字文件描述符
- backlog:表示服务器等待 accept 时,挂起连接队列的长度
5. 接受连接
服务器从监听套接字 sockfd 的挂起连接队列中取出第一个连接请求,调用 accept() 创建一个新的已连接套接字,并返回指向该套接字的新文件描述符。此时客户端与服务器连接建立,双方即可传输数据。
new_socket = accept(sockfd, sockaddr *addr, socklen_t *addrlen);
参数:
- sockfd:经过 socket() 和 bind() 的套接字文件描述符
- addr:指向 struct sockaddr 的指针,用于保存客户端的 IP 地址和端口号
- addrlen:指向地址结构体长度变量的指针
6. 发送 / 接收
在这一步,服务器可以与客户端收发数据。
send():向客户端发送数据
send(sockfd, *buf, len, flags);
参数:
- sockfd:socket() 返回的套接字描述符
- buf:指向待发送数据的缓冲区
- len:要发送的字节数
- flags:发送选项,通常填 0 表示默认行为
recv():接收客户端数据
recv(sockfd, *buf, len, flags);
参数:
- sockfd:socket() 返回的套接字描述符
- buf:指向用于存储接收数据的缓冲区
- len:要接收的字节数
- flags:接收选项,通常填 0 表示默认行为
7. 关闭
信息交换完成后,服务器调用 close() 关闭套接字,释放系统资源。
close(fd);
参数:
- fd:套接字的文件描述符
四. 创建客户端流程
按以下步骤创建客户端流程:
1. 套接字创建
这一步与服务器套接字创建方式相同,即创建套接字。
2. 连接
connect() 系统调用把 sockfd 所引用的套接字连接到 addr 指定的地址。addr 中需填写服务器的地址与端口。
connect(sockfd, sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:socket() 函数返回的套接字文件描述符。
- addr:指向包含服务器 IP 地址和端口号的 struct sockaddr 的指针。
- addrlen:addr 的大小。
3. 发送/接收
客户端可在此步骤与服务器收发数据,使用 send() 和 receive() 函数,方式与服务器相同。
4. 关闭
信息交换完成后,客户端也需调用 close() 关闭套接字并释放系统资源,方式与服务器相同。
套接字编程常见问题及解决方法 :
连接失败:确保客户端连接的 IP 地址和端口正确。
端口绑定错误:端口已被其他应用占用时绑定会失败,可换端口或关闭占用该端口的应用。
阻塞套接字:默认套接字为阻塞模式,accept() 或 recv() 会无限等待。如有需要可将套接字设为非阻塞模式。
五. 计算机网络中的套接字
套接字(Socket)是网络中两个程序之间双向通信链路的一端。套接字机制通过建立“命名的接触点”来实现进程间通信(IPC),数据就在这些接触点之间传输。
1. 套接字在网络中如何工作
就像用“pipe”系统调用创建管道一样,套接字通过“socket”系统调用创建。套接字在网络上提供**双向的、先进先出(FIFO)**的通信能力。
- 通信的每一端都会创建一个套接字。
- 每个套接字都有一个特定地址,该地址由 IP 地址 + 端口号 组成。
- 套接字通常用于客户端-服务器模型。
– 服务器端创建一个套接字,将其绑定到某个网络端口,然后等待客户端联系。
– 客户端创建一个套接字,并尝试连接到服务器的套接字。 - 连接一旦建立,双方即可开始传输数据。
2. 套接字的类型
套接字主要分为两类:
-
数据报套接字(Datagram Socket)
一种无连接的端点,用于发送和接收独立的数据包。它类似于邮筒:信件(数据)被投入邮筒,邮政系统收集后投递到另一个邮筒(接收端套接字)。数据可能乱序、重复或丢失,需应用层自行处理。 -
流式套接字(Stream Socket)
提供面向连接、按序、无记录边界的唯一数据流,并具备建立与拆除连接以及错误检测的机制。它类似于电话:先拨号建立连接,然后双方通话(数据传输),数据保证按序、可靠到达。
数据报套接字和流式套接字分别对应UDP和TCP,但严格来说是协议族+套接字类型的组合决定了最终使用的协议:
| 套接字类型 | 常用协议 | 对应协议特性 | 是否面向连接 | 是否可靠传输 |
|---|---|---|---|---|
SOCK_DGRAM(数据报) | IPPROTO_UDP → UDP | 无连接、固定最大长度、可能丢包/乱序/重复 | ❌ | ❌ |
SOCK_STREAM(流式) | IPPROTO_TCP → TCP | 面向连接、字节流、按序、可靠、重传、流量控制 | ✅ | ✅ |
调用 socket() 时,内核根据 domain(如 AF_INET)、type(SOCK_DGRAM/SOCK_STREAM)和 protocol(0 或显式指定)决定最终协议:
// 典型的 UDP 数据报套接字
int udp_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
// 典型的 TCP 流式套接字
int tcp_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
因此,日常说法可以简化为:
数据报套接字 ≈ UDP,流式套接字 ≈ TCP。
3.套接字编程常用函数
| 函数调用 | 说明 |
|---|---|
socket() | 创建一个套接字 |
bind() | 给套接字绑定地址/端口号,相当于“电话号码” |
listen() | 服务器准备好接收连接 |
connect() | 客户端主动发起连接 |
accept() | 服务器接受连接,类似“接听电话” |
write() / send() | 发送数据 |
read() / recv() | 接收数据 |
close() | 关闭连接 |
_Generic 关键字
_Generic 是 C 语言中的一个关键字,用于实现泛型代码。它可以根据传入参数的类型执行不同的语句。通过结合宏使用,它可以模拟函数重载的功能,并帮助编写类型安全的代码,从而避免为每种数据类型手动编写重复逻辑。_Generic 关键字最早在 C11 标准中引入。
1. _Generic 的语法结构
_Generic((expression),
type_1: statements,
type_2: statements,
.
.
default: statements
)
其中:
- `expression`:测试表达式的类型用于决定执行哪一段语句。
- `type_1, type_2...`:我们为这些类型定义了泛型代码。
- `default`:如果没有匹配的类型,则执行该语句。
语法类似于 switch 语句,根据表达式的类型选择对应的语句执行。如果表达式的类型与某个 type 匹配,就返回对应的结果;如果没有匹配项,则使用 default(如果提供了)。
在 C 中使用 _Generic 我们可以在代码中任何需要泛型逻辑的地方使用 _Generic。以下是一个示例:
示例:
#include <stdio.h>
int main(void)
{
// _Generic 根据参数的数据类型选择操作
printf("%d\n", _Generic(1.0L, float: 1, double: 2, long double: 3, default: 0));
printf("%d\n", _Generic(1L, float: 1, double: 2, long double: 3, default: 0));
printf("%d\n", _Generic(1.0L, float: 1, double: 2, long double: 3));
return 0;
}
输出:
3
0
3
2. 在宏中使用 _Generic
C 宏的一个主要缺点是缺乏类型检查,即宏可以对不同类型的变量(如 char、int、double 等)进行操作,而不会进行类型检查。我们可以使用 _Generic 在宏中为不同类型的参数指定不同的处理逻辑。
示例:
#include <stdio.h>
// 定义一个宏,使用 _Generic 为不同类型指定不同的返回值
#define geeks(T) _Generic((T), \
char* : "String", \
int : "Integer", \
long : "Long Integer", \
default : "Others")
int main(void)
{
// "A" 是字符串
printf("%s\n", geeks("A"));
// 5 是整数
printf("%s\n", geeks(5));
// 5.12 是浮点数,未在宏中定义
printf("%s", geeks(5.12));
return 0;
}
输出:
String
Integer
Others
可以看到,不同类型的参数会返回不同的结果。这种行为类似于 C++ 的函数重载,根据参数类型决定调用哪个版本的函数。因此,我们可以使用 _Generic 在 C 中实现类似函数重载的功能。
C 语言多线程(Multithreading in C)
线程是进程内的一条单一顺序流。由于线程具备进程的某些属性,因此有时也被称为“轻量级进程”。但与进程不同的是,线程之间并非彼此独立。它们会与其他线程共享代码段、数据段以及操作系统资源(如打开的文件和信号)。不过,与进程类似,线程也拥有自己独立的程序计数器(PC)、寄存器组和栈空间(每个线程拥有独立的线程控制块(TCB)——包含线程ID、程序计数器、寄存器组和栈)。
多线程(Multithreading)是一种编程技术,它将一个进程拆分成多个更小的单元——线程,这些线程可以同时运行。多线程广泛应用于 Web 服务器、游戏和实时系统等场景,用来并发处理用户输入、后台任务以及其他 I/O 操作。
(A). C 语言中的多线程
在 C 语言里,我们使用 POSIX 线程(pthreads)库 来实现多线程。该库提供了各种组件以及线程管理函数,构成了 C 语言多线程程序的基础。
pthread 库的定义位于头文件 <pthread.h> 中。通常我们不需要显式告诉链接器正在使用这个库,但如果编译出错,可加上以下标志重新编译:
gcc sourceFile.c -lpthread
下面看看如何在 C 程序中实现多线程。
1. 创建线程
第一步是创建线程并给它分配任务。要创建新线程,需使用 C 线程库提供的 pthread_create() 函数。它会初始化并启动线程,使其运行指定的函数(即线程的任务)。
语法
pthread_create(thread, attr, routine, arg);
参数说明:
- `thread`:指向 `pthread_t` 变量的指针,系统会把新线程的 ID 存储在这里。
- `attr`:指向线程属性对象的指针,用于定义线程属性。使用 `NULL` 表示采用默认属性。
- `routine`:线程将要执行的函数指针。该函数必须返回 `void *` 并接受一个 `void *` 参数。
- `arg`:传递给线程函数的单个参数。不需要参数时传 `NULL`。也可通过结构体或指针传递多个值。
示例1:
#include <pthread.h>
#include <stdio.h>
void* foo(void* arg) {
printf("Created a new thread");
return NULL;
}
int main() {
// 创建 pthread_t 变量用于存储线程 ID
pthread_t thread1;
// 创建新线程
pthread_create(&thread1, NULL, foo, NULL);
return 0;
}
输出
Created a new thread
在上面的程序中,主线程有可能在新线程 thread1 执行完毕前就结束,从而导致程序行为异常。因此,C 提供了让主线程等待指定线程执行完毕的功能。
2.等待线程结束
pthread_join() 函数可以让一个线程等待另一个线程终止,用于线程间的同步。
示例2:
#include <pthread.h>
#include <stdio.h>
void* foo(void* arg) {
printf("Thread is running.\n");
return NULL;
}
int main() {
pthread_t thread1;
pthread_create(&thread1, NULL, foo, NULL);
// 等待线程结束
pthread_join(thread1, NULL);
return 0;
}
输出
Thread is running.
3.显式终止线程
pthread_exit() 函数允许一个线程主动终止自己的执行。当线程需要结束并可选地向等待它的其他线程返回一个值时,就调用 pthread_exit()。
示例3:
#include <pthread.h>
#include <stdio.h>
void* foo(void* arg) {
printf("Thread is running.\n");
// 显式终止线程
pthread_exit(NULL);
printf("This will not be executed.\n");
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, foo, NULL);
// 等待被创建的线程结束
pthread_join(thread, NULL);
return 0;
}
输出
Thread is running.
4.请求取消线程
pthread_cancel() 函数用于“请求”取消一个线程。它会向目标线程发送一条取消请求,但是否真的终止,取决于该线程当前是否处于“可取消”状态以及它自身如何处理取消请求。
示例4:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void* myThreadFunc(void* arg) {
while(1) {
printf("Thread is running...\n");
sleep(1);
}
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, myThreadFunc, NULL);
sleep(5);
// 5 秒后请求取消线程
pthread_cancel(thread);
// 等待线程终止
pthread_join(thread, NULL);
printf("Main thread finished.\n");
return 0;
}
输出
Thread is running...
Thread is running...
Thread is running...
Thread is running...
Thread is running...
Thread is running...
Main thread finished.
5. 获取线程 ID
pthread_self() 该函数返回调用线程的线程 ID。在多线程程序中,当你需要识别或保存当前线程的 ID 时,这个函数非常有用。
示例5:
#include <pthread.h>
#include <stdio.h>
void* foo(void* arg) {
// 获取当前线程 ID
pthread_t thisThread = pthread_self();
printf("Current thread ID: %lu\n",
(unsigned long)thisThread);
return NULL;
}
int main() {
pthread_t thread1;
pthread_create(&thread1, NULL, foo, NULL);
pthread_join(thread1, NULL);
return 0;
}
输出
Current thread ID: 134681321096896
(B). 多线程常见问题
多线程能让程序同时运行多项任务,从而大幅提升性能。但它也伴随着一系列线程相关的问题与挑战,必须妥善处理,才能保证多线程程序高效运行。C 语言多线程中常见的典型问题如下:
竞态条件(Race Conditions)
当多个线程同时访问同一共享资源(如:访问同一个变量),且最终输出结果取决于线程执行的相对顺序时,就会发生竞态条件。该问题会导致程序行为不可预测,每次运行都可能产生不同结果。
示例:若两个线程同时对一个共享计数器变量进行“读取-修改-写入”操作,由于双方可能同时读写,最终计数值往往与预期不符。
死锁(Deadlocks)
当多个线程永久阻塞,彼此等待对方释放已占用的资源时,便形成死锁。通常的触发场景是:线程先占有部分资源,执行中途又申请额外资源,从而出现循环依赖。
示例:线程 A 已占用资源 1 和 2,正等待资源 3;线程 B 已占用资源 3,却在等待资源 2。两者互相等待,谁也无法继续。
饥饿(Starvation)
若某个线程被无限期地拒绝访问所需资源,就发生饥饿。该现象常见于基于优先级的调度算法,当算法持续偏向某些线程时,低优先级线程可能永远得不到执行机会。
示例:低优先级线程因高优先级线程不断存在,只得无限等待,无法获得 CPU 或关键资源。
(C). 线程同步
线程同步是一种机制,用于确保多个线程在访问共享资源时不会引发竞态条件、死锁或数据损坏等问题。线程同步技术控制线程如何交互或修改共享数据/资源,并保证某些“临界区”代码以受控方式执行。
1.互斥(Mutex)同步-技术方案
互斥是最基础的同步手段。它保证任一时刻只有一个线程能访问共享资源,从而避免竞态条件。
2.信号量(Semaphores)-技术方案
信号量是一种“发信号”机制,通过一个计数器来控制对共享资源的访问。可以是二元信号量(仅取 0 或 1),也可以是计数信号量(值大于 1),允许最多指定数目的线程同时访问资源。
3.条件变量(Condition Variables)-技术方案
条件变量让线程在某些条件成立前进入等待。它必须与互斥锁配合使用:线程在条件变量上等待并暂时释放互斥锁,直到被其他线程“通知”继续。
4.屏障同步(Barriers)-技术方案
屏障可将一组线程“卡”在执行的特定点,直到所有线程都到达该点后才一起继续。适用于需要分阶段并发计算的场景。
5.读写锁(Read-Write Locks)-技术方案
读写锁允许多个线程同时读取共享资源,但写操作必须独占。当读线程远多于写线程时,可显著提升性能。
(D).多线程的缺点
- 编程复杂度陡增:必须考虑同步、锁、原子操作、可见性等并发细节。
- 并发问题难防:竞态条件、死锁、饥饿等问题如影随形。
- 线程管理开销:创建、销毁、上下文切换都要时间和内存;线程数远超标核心数反而降速。
- Bug 风险高:并发缺陷难以复现、调试困难,程序行为可能“看运气”。