学而时习之:高级C编程语法总结

52 阅读35分钟

可变参数函数(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_listva_startva_argva_end 这组宏来访问这些可变实参。


A. 可变参数函数语法

至少需要一个固定形参,后面用省略号 ... 表示可变部分:

返回类型 函数名(固定形参, ...);

B. 访问可变参数的步骤(<stdarg.h> 提供)

  1. 创建变量列表对象 — va_list

    va_list list;
    
  2. 初始化 — va_start()

    va_start(list, 最后一个固定形参);
    
  3. 逐个取值(获取下一个参数) — va_arg()

    类型 变量 = va_arg(list, 类型);
    

    注意:类型必须正确,且调用次数不能超过实际传入个数。因此通常要像上面那样把“个数”也作为固定参数传进来。

  4. 清理 — va_end()

    va_end(list);
    

C. 更多示例

  1. 求和
#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

  1. 混合类型(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) 的指针。

文件表项是内存里的一个结构体,可视为已打开文件的“代理”。当进程请求打开某个文件时,内核会创建对应的文件表项,用来维护当前读写位置(文件偏移)等信息。 image.png

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,把错误信息呈现到屏幕。 image.png

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,因为 012 已被 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. 在内核中创建对应的文件表项(记录偏移、状态等)。
  3. 把进程文件描述符表中第一个空闲项指向该文件表项。
  4. 返回这个文件描述符;若失败则返回 -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_EXCLO_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 操作系统内部流程
  1. 在磁盘定位指定文件(不存在且带 O_CREAT 则新建)。
  2. 内核创建文件表项,记录读写偏移、状态等。
  3. 把进程文件描述符表中第一个空闲项指向该文件表项。
  4. 返回这个文件描述符;失败返回 -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

解释:fd1fd2 各自拥有独立的文件表项,因此文件偏移互不影响。第二次读取仍从文件头开始,所以读到的是 'f' 而不是 'o'

5. write(写入)

write() 系统调用把缓冲区 buf 中的 cnt 字节数据写入与文件描述符 fd 关联的文件或套接字。
cnt 不能超过 INT_MAXlimits.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_WRONLYO_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

解释:

  1. fd[0] 把字符串写进文件后,fd[0] 的偏移移到末尾。
  2. fd[1] 的偏移仍在文件头,因此能完整读出刚写入的内容。
  3. 最后通过 write(1, ...) 把读到的数据写到终端(标准输出),于是屏幕显示 hello world

6. 拓展对比

核心区别只有一句话: open/read/write/close系统调用(内核入口)fopen/fread/fwrite/fclose标准库函数(libc 封装)。

维度系统调用标准库 IO
所属层级内核接口(unistd.h)C 标准库(libc)
函数名open, read, write, closefopen, fread, fprintf, fwrite, fclose
返回类型整数 文件描述符 fd结构体指针 **FILE ***
缓冲策略无缓冲,每次调用都进内核带缓冲(全缓冲、行缓冲、无缓冲),减少系统调用次数
可移植性POSIX 为主,Windows 需专用 APIANSI C,全平台通用
功能粒度原始字节,只能 read/write提供格式化读写 fprintf/fscanf,字符串行读写 fgets/fputs
性能频繁调用进内核,小块 IO 慢用户空间缓冲,合并大块再进内核,快
错误处理返回 -1,全局 errno返回 NULL 或短计数,用 ferror/feof 判断
文件偏移手动 lseekfseek/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),表示特定事件的发生。当信号到达时,进程会中断当前执行流,转而处理信号

image.png

六、信号总结:

信号就是操作系统给进程发的“紧急短消息”,用来打断进程并让它“立刻去处理一件突发事件”。
它确实发生在内核层,但你可以把它当成一种最轻量级的进程间/内核-进程间异步通知机制


A、把抽象概念换成生活比喻

  1. 你在办公室(进程)写代码,突然:

    • 电话铃响(SIGINT)→ 你可以接,也可以无视,但默认就挂了(终止)。
    • 大楼火警(SIGKILL)→ 保安(内核)直接把你拖走,没得商量。
    • 同事拍你肩膀(SIGUSR1)→ 你们俩事先约定好拍肩膀=“去打印”,于是你去打印。
  2. 内核=大楼管理处,信号=各种颜色的便利贴,贴在工位上:

    • 绿色:你自己贴给自己(raise)。
    • 红色:别的同事(进程)让管理处帮你贴(kill)。
    • 黑色:管理处必须强制执行(SIGKILL)。

B、常见真实使用场景

场景谁发发什么进程怎么利用
用户想停掉跑飞的程序终端驱动SIGINT (Ctrl-C)干净地退出,释放资源
子进程结束了,父进程要收尸内核SIGCHLD在 handler 里 waitpid(),防止僵尸
定时器到期内核SIGALRM网络库超时重传、sleep 被中断
服务器“优雅重启”运维脚本SIGTERM先关掉监听 socket,把现有请求处理完再 exit
强制立即杀死顽固进程运维脚本SIGKILL内核直接回收,不给进程反应时间
两个进程之间传递 1 字节通知进程 ASIGUSR1 / 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 编程的组成

  1. 套接字(Socket)
    套接字是程序访问网络、与其他进程/节点进行通信的核心组件之一。它只是一个 IP 地址与端口号的组合,充当通信的端点。
    示例:192.168.1.1:8080,其中冒号前的 192.168.1.1 是 IP 地址,冒号后的 8080 是端口号。

套接字类型:

  • TCP 套接字(流式套接字):提供可靠的、基于连接的通信(即 TCP 协议)。
  • UDP 套接字(数据报套接字):提供无连接通信,速度更快但不可靠(即 UDP 协议)。
  1. 客户端-服务器模型
    客户端-服务器模型是指 socket 编程所采用的架构:客户端与服务器彼此交互,以交换信息或服务。该架构允许客户端发送服务请求,服务器接收并处理这些请求,然后将响应返回给客户端。C 语言中的 Socket 编程是一种强大的网络通信手段。 image.png

三. 创建服务器端进程

服务器通过以下步骤创建:

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),数据就在这些接触点之间传输。 image.png

1. 套接字在网络中如何工作

就像用“pipe”系统调用创建管道一样,套接字通过“socket”系统调用创建。套接字在网络上提供**双向的、先进先出(FIFO)**的通信能力。

  • 通信的每一端都会创建一个套接字。
  • 每个套接字都有一个特定地址,该地址由 IP 地址 + 端口号 组成。
  • 套接字通常用于客户端-服务器模型。
    – 服务器端创建一个套接字,将其绑定到某个网络端口,然后等待客户端联系。
    – 客户端创建一个套接字,并尝试连接到服务器的套接字。
  • 连接一旦建立,双方即可开始传输数据。

image.png

2. 套接字的类型

套接字主要分为两类:

  1. 数据报套接字(Datagram Socket)
    一种无连接的端点,用于发送和接收独立的数据包。它类似于邮筒:信件(数据)被投入邮筒,邮政系统收集后投递到另一个邮筒(接收端套接字)。数据可能乱序、重复或丢失,需应用层自行处理。

  2. 流式套接字(Stream Socket)
    提供面向连接按序无记录边界的唯一数据流,并具备建立与拆除连接以及错误检测的机制。它类似于电话:先拨号建立连接,然后双方通话(数据传输),数据保证按序、可靠到达。

数据报套接字流式套接字分别对应UDPTCP,但严格来说是协议族+套接字类型的组合决定了最终使用的协议:

套接字类型常用协议对应协议特性是否面向连接是否可靠传输
SOCK_DGRAM(数据报)IPPROTO_UDPUDP无连接、固定最大长度、可能丢包/乱序/重复
SOCK_STREAM(流式)IPPROTO_TCPTCP面向连接、字节流、按序、可靠、重传、流量控制

调用 socket() 时,内核根据 domain(如 AF_INET)、typeSOCK_DGRAM/SOCK_STREAM)和 protocol0 或显式指定)决定最终协议:

// 典型的 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 宏的一个主要缺点是缺乏类型检查,即宏可以对不同类型的变量(如 charintdouble 等)进行操作,而不会进行类型检查。我们可以使用 _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、程序计数器、寄存器组和栈)。

image.png

多线程(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).多线程的缺点

  1. 编程复杂度陡增:必须考虑同步、锁、原子操作、可见性等并发细节。
  2. 并发问题难防:竞态条件、死锁、饥饿等问题如影随形。
  3. 线程管理开销:创建、销毁、上下文切换都要时间和内存;线程数远超标核心数反而降速。
  4. Bug 风险高:并发缺陷难以复现、调试困难,程序行为可能“看运气”。