TCP 缓冲区:用户空间 stdio 与内核 Socket 缓冲
课程:尚硅谷《嵌入式 Linux 应用层开发》第 6 章 Socket 编程
依据:2026-09-30 10:18 录音转写;课程 PDF 第 290—296 页。
本节边界:从缓冲区的作用讲到 C 标准 I/O 的三种缓冲模式、setvbuf()、fflush()以及execve()实验;第 297 页开始的全缓冲独立实验未进入本次录音。
页码说明:录音从第 290 页开始,实际继续讲到第 296 页,因此按 290—296 页整理。
1. 本节知识路线
老师通过文件写入实验讲解缓冲模式,再把这个概念联系到网络编程。理解本节时,首先要把不同层次的“缓冲区”分开。
2. 三种容易混淆的缓冲区
假设程序要通过 TCP 发送一段数据,可能同时出现三种缓冲:
| 层次 | 典型对象 | 位于哪里 | 谁管理 |
|---|---|---|---|
| 应用缓冲 | char buf[1024]、malloc()内存 | 用户空间 | 程序员 |
| C 标准库缓冲 | FILE *背后的 stdio 缓冲 | 用户空间 | libc,可用setvbuf()调整 |
| Socket 缓冲 | 发送缓冲区、接收缓冲区 | 内核空间 | 内核网络协议栈 |
应用数组 │ send/write ▼ 内核 Socket 发送缓冲 │ TCP/IP、网卡驱动 ▼ 网络 │ 网卡驱动、TCP/IP ▼ 内核 Socket 接收缓冲 │ recv/read ▼ 应用数组而教材中的fopen()、fprintf()、setvbuf()、fflush()操作的是C 标准库的FILE *流缓冲,不能直接拿来控制 Socket 的内核收发缓冲。
3. 为什么要有缓冲区
一次函数调用、一次系统调用和一次设备访问都有成本。缓冲区先聚合一批小数据,再成批交给下一层,可以:
- 减少频繁系统调用;
- 减少小块设备访问或小包处理;
- 缓解生产数据和消费数据速度不一致;
- 让应用与设备、网络协议栈各自按合适节奏工作。
代价是数据可能暂时停留在某一层,因此“函数已经返回”不一定等于数据已经到达最终目标。
4. 网络编程中的内核缓冲区
4.1 接收方向
网络数据先由网卡驱动和协议栈处理,符合该连接的数据进入 Socket 接收缓冲区。应用调用recv()或read(),才把数据复制或交付到用户空间。
默认阻塞模式下:
- 接收缓冲区暂时没有数据时,
recv()通常等待; - 有数据时,
recv()返回当前可提供的字节; - 对端有序关闭发送方向且缓冲数据已经读完时,
recv()返回 0。
4.2 发送方向
send()通常把应用数据交给内核 Socket 发送缓冲区。调用成功只表示内核接受了返回值所表示的字节数,并不表示:
- 数据已经离开网卡;
- 对端内核已经收到;
- 对端应用已经执行
recv(); - 对端业务逻辑已经处理完成。
发送缓冲区空间不足时,阻塞 Socket 的send()可能等待;非阻塞 Socket 可能返回-1,并把errno设置为EAGAIN或EWOULDBLOCK。
4.3 应用能否控制内核 Socket 缓冲
录音中“用户无法干预”是入门化说法。应用不能直接读写内核缓冲区内部结构,但可以通过setsockopt()请求调整:
intsize=256*1024;setsockopt(fd,SOL_SOCKET,SO_SNDBUF,&size,sizeof(size));setsockopt(fd,SOL_SOCKET,SO_RCVBUF,&size,sizeof(size));系统会受默认值、上下限和内核策略约束,实际值应再用getsockopt()查询。当前基础阶段先理解数据路径,不需要急着调大缓冲区。
5. C 标准 I/O 的三种缓冲模式
FILE *流支持三种模式:
| 模式 | 宏 | 何时把用户空间缓冲交给底层写操作 |
|---|---|---|
| 全缓冲 | _IOFBF | 通常在缓冲区满、主动刷新或关闭流时 |
| 行缓冲 | _IOLBF | 通常在遇到换行、缓冲区满或主动刷新时 |
| 无缓冲 | _IONBF | 每次 stdio 输出尽快调用底层写操作 |
常见默认行为:
- 普通文件流通常采用全缓冲;
stdout连接终端时通常采用行缓冲;stderr默认不缓冲;stdout被重定向到普通文件时,通常会变成全缓冲。
“无缓冲”只表示跳过 stdio 的用户空间缓冲。底层仍可能经过内核页缓存、设备缓存或 Socket 缓冲,所以不能理解为直接写到物理介质或直接到达网络对端。
6.setvbuf():设置FILE *的缓冲模式
#include<stdio.h>intsetvbuf(FILE*stream,char*buf,intmode,size_tsize);| 参数 | 含义 |
|---|---|
stream | 已打开的FILE *流,例如stdout或fopen()返回值 |
buf | 用户提供的缓冲内存;传NULL时由 libc 分配 |
mode | _IOFBF、_IOLBF或_IONBF |
size | 缓冲区大小;无缓冲模式通常写 0 |
返回 0 表示成功,非 0 表示设置失败。
6.1 必须在什么时候调用
setvbuf()应在流打开之后、对该流执行其他读写操作之前调用:
FILE*fp=fopen("testfile.txt","w");if(fp==NULL){perror("fopen");return1;}if(setvbuf(fp,NULL,_IOLBF,BUFSIZ)!=0){fprintf(stderr,"setvbuf failed\n");fclose(fp);return1;}fputs("hello\n",fp);若自己提供buf,这块内存在流关闭前必须一直有效,不能提前释放,也不能是已经离开作用域的局部数组。
6.2 三种常见写法
setvbuf(fp,NULL,_IOFBF,BUFSIZ);/* 全缓冲 */setvbuf(fp,NULL,_IOLBF,BUFSIZ);/* 行缓冲 */setvbuf(fp,NULL,_IONBF,0);/* 无缓冲 */教材为buf == NULL的示例多处把size写成 0。对_IONBF这样写很常见;为了让全缓冲和行缓冲意图清楚,练习时使用BUFSIZ更容易理解。
7.fflush():主动刷新用户空间输出缓冲
#include<stdio.h>intfflush(FILE*stream);对于输出流,fflush(fp)会把该FILE *中尚未提交的用户空间数据交给底层写函数:
if(fflush(fp)==EOF){perror("fflush");}fflush(NULL)会刷新所有打开的输出流。
7.1fflush()不等于物理落盘
数据路径可能是:
stdio 用户空间缓冲 │ fflush ▼ 内核文件页缓存 │ fsync 等机制 ▼ 存储设备缓存与物理介质因此:
fflush()解决 libc 缓冲尚未交给内核的问题;- 需要保证普通文件数据提交给存储设备时,还要检查
fflush()、取得文件描述符并按需求调用fsync(); - 即使调用
fsync(),硬件自身缓存和断电保证仍取决于设备与系统设计。
本节实验只验证“文件中是否已经能看到数据”,不讨论断电持久性。
8. 为什么用execve()做实验
intexecve(constchar*path,char*constargv[],char*constenvp[]);execve()成功后,用新程序替换当前进程的代码、数据、堆和栈,并且不会返回。原程序保存在用户空间的 stdio 缓冲也随原进程映像消失。
默认情况下,未设置FD_CLOEXEC的文件描述符可以跨execve()保留;但FILE *及其 libc 缓冲属于原程序的用户空间状态,新程序不会替原程序自动调用fflush()。
这正是教材实验的观察点:
fopen 创建 FILE 流 ↓ fprintf 写入 libc 缓冲 ↓ 没有 fflush 或 fclose ↓ execve 成功替换程序 ↓ 原 libc 缓冲未写出,文件可能为空不能简单说execve()“把所有东西全部销毁”:进程 ID 保持不变,很多进程属性和未设置FD_CLOEXEC的文件描述符会保留;被替换的是进程映像及不被规范保留的状态。
9. 实验一:全缓冲数据没有及时写出
下面是教材思路的精简版:
#include<stdio.h>#include<unistd.h>intmain(void){FILE*fp=fopen("testfile.txt","w");if(fp==NULL){perror("fopen");return1;}if(setvbuf(fp,NULL,_IOFBF,BUFSIZ)!=0){fprintf(stderr,"setvbuf failed\n");fclose(fp);return1;}fputs("hello",fp);/* 可能仍停留在 libc 缓冲 */char*argv[]={"true",NULL};char*envp[]={NULL};execve("/usr/bin/true",argv,envp);perror("execve");/* 只有 execve 失败才执行 */fclose(fp);return1;}若execve()成功,程序没有机会执行fclose(fp);hello又没有触发全缓冲刷新,因此文件可能仍为空。
如果execve()失败,它会返回原程序,后续fclose()会刷新数据,所以观察结果会不同。实验时必须确认execve()确实成功。
10. 实验二:使用fflush()主动刷新
在execve()前增加:
if(fflush(fp)==EOF){perror("fflush");fclose(fp);return1;}此时hello已经从 stdio 用户空间缓冲提交给底层写操作。即使随后成功执行execve(),文件中也能看到该内容。
11. 实验三:设置无缓冲
在首次写入前设置:
if(setvbuf(fp,NULL,_IONBF,0)!=0){fprintf(stderr,"setvbuf failed\n");fclose(fp);return1;}fputs("hello",fp);fputs()不再把数据长期留在 stdio 缓冲中,所以在execve()前已经发起底层写操作,文件中能够看到hello。
这不表示每次调用已经物理写入磁盘,只能说明 stdio 层没有继续积压该数据。
12. 实验四:行缓冲是否遇到换行
12.1 没有换行
setvbuf(fp,NULL,_IOLBF,BUFSIZ);fputs("hello",fp);缓冲区未满、没有换行、没有fflush()或fclose(),随后成功execve(),数据可能没有写出。
12.2 带换行
setvbuf(fp,NULL,_IOLBF,BUFSIZ);fputs("hello\n",fp);输出换行符会触发行缓冲刷新,因此execve()前数据已经交给底层写操作。
12.3 实验结论表
| 模式与操作 | 写入内容 | execve()前是否触发 stdio 刷新 | 文件观察结果 |
|---|---|---|---|
| 全缓冲 | hello | 通常否 | 可能为空 |
全缓冲 +fflush() | hello | 是 | 可看到hello |
| 无缓冲 | hello | 每次尽快写出 | 可看到hello |
| 行缓冲 | hello | 通常否 | 可能为空 |
| 行缓冲 | hello\n | 换行触发 | 可看到一行内容 |
13. 这和 TCP 程序有什么关系
本节使用普通文件讲FILE *缓冲,但对网络程序有三条直接启发。
13.1send()成功不代表对端收到
数据进入本机内核发送缓冲后,TCP 还要完成分段、发送、确认和可能的重传。若业务需要确认“对方程序已处理”,必须在应用协议中设计响应消息。
13.2recv()一次不保证读到完整消息
Socket 接收缓冲是字节流。数据可能分多次到达,也可能把多次send()的数据一起提供给一次recv()。程序需要循环接收并根据应用协议判断消息边界。
13.3 不要用fflush()控制普通 Socket 描述符
fflush()的参数是FILE *,作用于 stdio 缓冲。普通的 Socket 文件描述符使用send()、write()、recv()、read(),并由内核管理收发缓冲。
虽然可以通过fdopen()把描述符包装成FILE *,但这会额外引入 stdio 缓冲层,基础 Socket 练习阶段不建议这样做。
14. 什么时候数据会自动刷新
对输出FILE *流,常见触发条件包括:
- 全缓冲区填满;
- 行缓冲流输出换行符;
- 显式调用
fflush(); - 调用
fclose(); exit()或从main()正常返回时,C 运行库关闭并刷新输出流。
以下情况不能依赖自动刷新:
- 成功调用
execve(); - 调用
_exit(); - 进程收到导致立即终止的信号;
- 进程崩溃或机器掉电。
录音最后提到“缓冲区会用定时器定期自动刷写”。这不是 C 标准 stdio 三种模式的通用规则,写可移植程序时不能依赖一个未明确说明的定时刷新机制。
15. 常见错误排查
| 现象 | 优先检查 |
|---|---|
printf()后终端没有立即显示 | 是否缺少换行;stdout是否被重定向;是否需要fflush(stdout) |
| 文件创建了但内容为空 | 是否仍在 stdio 缓冲;是否未执行fflush()/fclose()就execve()或异常退出 |
setvbuf()没有效果 | 是否已经对该流执行过其他 I/O;返回值是否非 0 |
| 换行没有触发写出 | 流是否确实设置为_IOLBF;是否检查了setvbuf()返回值 |
fflush()后仍担心掉电丢失 | fflush()只到内核,按持久性要求考虑fsync() |
send()返回正数但对端没打印 | 正返回只表示本机内核接受相应字节;检查对端读取和应用协议 |
recv()数据不完整 | TCP 无消息边界;按返回长度累计和解析 |
| Socket 发送阻塞 | 内核发送缓冲可能暂时没有空间;检查对端接收速度与阻塞模式 |
16. 录音与教材表述校正
- 转写中的
receive结合代码应为recv();多处刷机应为“刷新”或“刷写”。 fopen()返回FILE *流对象,open()才直接返回整数文件描述符。setvbuf()的第一个参数是FILE *stream,不是文件描述符。- C 标准库缓冲、应用自建数组和内核 Socket 缓冲属于不同层次。
_IONBF只关闭 stdio 用户空间缓冲,不会绕过内核页缓存或 Socket 缓冲。fflush()只刷新 C 库用户空间缓冲,不保证物理落盘。execve()成功后替换进程映像,不会自动刷新旧程序的 stdio 缓冲。- 未设置
FD_CLOEXEC的文件描述符默认可以跨execve()保留,但旧FILE *及其缓冲状态不能继续使用。 - 普通文件通常全缓冲,终端上的
stdout通常行缓冲,stderr默认无缓冲;不能把一种默认模式套到所有流。 - Socket 内核缓冲由内核管理,但应用可以用
SO_SNDBUF、SO_RCVBUF请求调整大小。 send()返回成功只表示本机内核接受了相应数据,不表示对端应用已收到。- stdio 没有可移植的“等待固定时间就自动刷新”保证,不应依赖录音最后描述的定时刷新。
17. 本节最低掌握标准
学完后应能回答:
- 应用数组、stdio 缓冲和内核 Socket 缓冲分别在哪里?
- 全缓冲、行缓冲、无缓冲各在什么条件下写出?
- 普通文件、终端
stdout和stderr常见默认模式分别是什么? setvbuf()为什么必须在首次读写前调用?fflush()能否保证数据已经物理落盘?- 为什么
execve()前没有刷新的FILE *数据可能丢失? execve()后文件描述符和FILE *有什么区别?- 为什么
_IONBF仍然可能经过内核缓存? send()返回成功为什么不等于对端应用收到?recv()为什么可能一次只返回部分数据?
最低实践要求:
- 能用
setvbuf()分别设置三种模式; - 能用
fflush()检查并刷新输出流; - 能复现“无换行不刷、带换行刷出”的行缓冲实验;
- 能画出 TCP 数据从用户数组到内核缓冲再到网络的路径;
- 能说明 stdio 缓冲与 Socket 内核缓冲的区别。
18. PDF 页码与录音时间索引
| 主题 | PDF 页码 | 录音时间 |
|---|---|---|
| 缓冲区概念与输入、输出缓冲 | 290—291 | 00:01—00:41 |
| 全缓冲、行缓冲、无缓冲 | 291 | 00:42—01:28 |
setvbuf()与fflush() | 292 | 01:29—02:12 |
建立fopen、fprintf、execve实验 | 292—293 | 02:13—04:48 |
全缓冲未刷写现象与fflush() | 293—294 | 04:49—06:19 |
_IONBF无缓冲实验 | 293—294 | 06:20—07:12 |
_IOLBF无换行与带换行实验 | 294—296 | 07:13—07:43 |
| 录音末尾的定时刷新说法 | 296 附近 | 07:44—07:58 |
| 全缓冲独立实验 | 297 页起 | 本次录音未讲 |
进一步核对资料:
- Linux man-pages:setbuf/setvbuf(3)
- Linux man-pages:fflush(3)
- Linux man-pages:execve(2)
- Linux man-pages:send(2)
- Linux man-pages:recv(2)
- Linux man-pages:socket(7)
19. 下一步学习
教材第 297—299 页会继续完成全缓冲和手动fflush()实验;随后从第 299 页开始使用netstat观察 TCP 连接建立与断开状态。
建议按这个顺序继续:
完成全缓冲和 fflush 对照实验 ↓ 运行单连接 TCP 服务端和客户端 ↓ 用 ss 或 netstat 观察 LISTEN 与 ESTABLISHED ↓ 理解 send 只是写入本机内核缓冲 ↓ 循环处理部分发送和部分接收这部分为后续 TCP Server/Client 练习提供运行时理解:程序出现“已经发送但对端没显示”“一次没有接收完整”“发送线程卡住”等现象时,要先判断数据停留在应用缓冲、stdio 缓冲还是内核 Socket 缓冲。