news 2026/9/20 20:54:59

C语言Socket编程实战:手写TCP双端即时通讯完整教程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言Socket编程实战:手写TCP双端即时通讯完整教程

简介:这是一份以C语言实现双端即时通讯的教学演示项目,面向具备基础C语法、希望进阶网络编程的学习者,也适合高校网络编程课程作为实验参考。项目完整呈现了客户端与服务器从创建套接字、绑定地址、监听连接到收发消息、多线程处理请求的整个过程,覆盖TCP/IP协议应用、线程同步与互斥、简单的协议字段设计等关键知识点。资源共166个文件,压缩包约34.67MB,其中以.c源文件和.h头文件为主体,同时包含.exe可执行程序、.dll动态库以及txt、md、docx等使用与讲解文档,并带有Visual Studio解决方案文件,方便直接打开编译和运行。目前已有63人学习使用。借助这套源码和文档,学习者能快速搭建一个可运行的双端聊天程序;通过阅读服务端与客户端的模块划分,还可理解用户管理、消息编解码等真实通信系统结构,并在此基础上扩展功能,完成课程设计或自研项目。

1. 项目概述与核心价值

拿到手是一个以时间戳命名的压缩包,通讯技术_C语言_双端即时通讯_教学演示_1741863748.zip,这种命名方式一看就是教学场景下的产物,可能是某次课程设计、实验报告配套源码,或者是老师下发的演示工程。核心标签是三个:C语言、双端即时通讯、教学演示。拆开来看,它要解决的是通信原理课程里最经典的一个问题——用C语言在应用层实现两个进程之间的实时消息收发,也就是我们常说的Socket编程实战。这不只是把课本上的sendrecv念一遍,而是要把TCP三次握手、阻塞与非阻塞IO、多线程并发这些抽象概念,通过一个能跑起来的程序落地。

这类项目的典型应用场景包括:计算机网络课程的socket编程实验、Linux环境下C语言综合训练、或者嵌入式设备之间的简单指令交互。对学习者来说,它的价值在于用最少的代码量,把网络通信的完整链路打通:从socket创建、bind绑定、listen监听、accept接受连接,到数据收发、连接关闭,一条龙走完。相比Java或Python里封装好的网络库,C语言让你直面struct sockaddr_inhtons这些底层细节,踩过字节序的坑,才能真正理解网络协议栈是怎么工作的。

如果你正在学C语言但还没碰过网络编程,或者做课程设计想找个可复现的通讯demo,这个项目是很好的起点。它能帮你回答几个关键问题:两台电脑(或两个终端)之间到底是怎么通过IP和端口找到对方的?数据在网络上传输时为什么要转字节序?多客户端同时连接服务器时,为什么需要多线程或IO多路复用?下面我就从设计思路、核心代码、实操踩坑三个维度,把这个项目完整拆开讲透。

2. 整体设计与技术选型思路

2.1 为什么选C语言做即时通讯

很多人第一反应是:现在做即时通讯,随便用个WebSocket或者MQTT不香吗?非要用C语言折磨自己?但教学场景下,C语言恰恰是最合适的。原因在于,C语言的标准库没有封装任何网络细节,你必须自己调用操作系统提供的Socket API,而这一层API是所有高级语言网络库的基石。用过Python的socket模块,底层同样是BSD Socket;用过Java的ServerSocket,底层也是同样的机制。C语言让你直接站在这个基石上看问题,没有语法糖。

另外,C语言的指针和内存管理,在通讯程序里会真实地产生业务影响。比如接收缓冲区是定长数组,如果对方一次发过来的数据超过了缓冲区大小,会发生什么?长连接下如何维护每个客户端的FD?这些问题在C语言里无法逃避,而正是这些问题构成了网络编程的核心素养。

2.2 基于TCP还是UDP:教学演示的合理选择

双端即时通讯有两种主流实现路径:面向连接的TCP和面向无连接的UDP。教学演示项目通常会选TCP,因为即时通讯强调消息的可靠有序,TCP天然满足这两个点。三次握手建立连接,确认重传保证数据不丢,序号机制保证数据有序,这些特性让新手不需要处理丢包乱序的问题,可以把注意力集中在Socket API本身。

UDP虽然实现更简单(不需要listenaccept,一个sendto加上recvfrom就行),但教学的演示效果差一点。想象一下课堂演示时,客户端发一句"hello",服务器偶尔收不到,你还得解释为什么丢包,这堂课就砸了。所以,做教学演示,稳妥压倒一切,选TCP是共识。当然,如果你后续想扩展一个UDP版本的实时音视频传输,那另说。

2.3 双端通讯的整体架构

这个项目的"双端",指的是一台服务器端和一台客户端。严格来说,多客户端也算双端通讯的变体。从代码结构上看,逻辑可以分成三个模块:

  • 服务端模块:负责创建监听Socket,等待客户端接入,接收客户端消息并回送响应。为了能同时服务多个客户端,服务端通常会给每个客户端分配一个子线程(或者用select/poll/epoll处理多路复用)。
  • 客户端模块:负责主动连接服务端,发送用户输入的消息,接收并显示服务端回复。客户端可以只用一个主线程,因为只需要维护自己的一条连接。
  • 公共模块:定义通讯协议格式(比如消息长度、类型、内容)、公用的缓冲区大小、错误处理函数等。

整体流程如下:服务端启动后进入阻塞等待状态,客户端发起连接请求,三次握手成功后双方建立一条全双工通道,客户端不断从标准输入读取用户键入的消息,通过Socket发给服务端,服务端收到后做简单的处理(比如回显原消息、打印日志、或者根据指令返回特定数据),再原路返回给客户端。整个过程就是一次完整的应用层通讯闭环。

2.4 为什么需要多线程而不是单线程

如果只做一对一通讯,服务端在主循环里单线程处理就够了:accept一个连接,recv一条消息,send回去,然后继续循环。但真实场景中,服务端需要同时处理多个客户端,如果还在主线程里做recv,那么当一个客户端不发送数据时,服务端就会阻塞在recv上,无法处理其他客户端的请求。

教学演示项目中引入多线程,是性价比最高的方案。用pthread_create为每个客户端连接创建一个线程,线程函数中循环接收该客户端的数据。主线程继续回到accept等待新连接。这样既避免了阻塞问题,又成功引入了线程同步与资源管理的知识。相比select/epoll,多线程的代码更直观,更容易被初学者接受。Unix环境编程课程里"每次一个连接,每个连接一个线程"的原型,就是这个。

3. 核心细节解析与实操要点

3.1 socket API 全链路拆解

把完整的通讯流程拆成API调用序列,每个函数都需要理解其作用、参数和陷阱。先看服务端:

  1. socket(AF_INET, SOCK_STREAM, 0):创建套接字。AF_INET表示IPv4,SOCK_STREAM表示字节流(TCP),0表示自动选择当前协议族与套接字类型对应的协议,即TCP。
  2. bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)):将本地地址(IP+端口)绑定到socket上。这里需要把sockaddr_in结构体强制转换,因为sockaddr是通用结构。注意端口号不能随便选,小于1024的端口需要root权限。
  3. listen(sockfd, backlog):将socket设为监听模式,backlog是内核维护的连接队列长度。教学项目给5或10就够。
  4. accept(sockfd, (struct sockaddr*)&client_addr, &addrlen):接受客户端连接,返回一个新的fd用于和该客户端通讯。注意原listenfd不能用于收发数据,它只负责监听。
  5. recv(newfd, buf, sizeof(buf), 0):接收数据。有数据返回接收字节数,对端关闭返回0,出错返回-1。
  6. send(newfd, buf, strlen(buf), 0):发送数据,返回值是实际发送的字节数,可能少于要发送的长度,需要处理。
  7. close(fd):关闭连接,释放fd。

客户端则相对简单:

  1. socket()创建套接字。
  2. 填写服务端地址结构体,不需要bind(由系统自动分配临时端口),直接connect(fd, (struct sockaddr*)&server_addr, sizeof(server_addr))发起三次握手。
  3. 连接成功后sendrecv
  4. close

3.2 字节序问题和结构体填充

初学Socket最容易栽的坑就是结构体填充和字节序。struct sockaddr_in里包含四个重要字段:

  • sin_family:地址族,填AF_INET
  • sin_port:端口号,必须用htons()转换成网络字节序。为什么?因为网络传输用的是大端序,而x86等主流小端CPU存储用的是小端序,直接赋一个整型端口号例如8080,在内存里是90 1f 00 00,而网络需要的是00 00 1f 90。不转换就会出现端口对不上的问题。
  • sin_addr.s_addr:IP地址,必须用inet_addr()inet_pton()转换。inet_addr("127.0.0.1")返回的已经是网络字节序的整数。
  • sin_zero:填充字段,一般用bzero清零。

写代码时,教科书式的初始化应该是:

struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8888); server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

如果不做memset清零,sin_zero残留垃圾数据,某些老版本系统上会报Invalid argument。这个问题在Ubuntu 20.04上不明显,但在嵌入式设备或CentOS 7上偶尔会踩到,所以习惯一定要好。

3.3 缓冲区与粘包问题

TCP是字节流协议,没有消息边界。你在客户端执行一次send("hello"),服务端可能一次recv收到5个字节,也可能收到"hel",剩下的"lo"等下一次,甚至可能一次收到"hello world"的合并块,这就是粘包和半包问题。教学演示项目里,最简单的处理方式是:定长消息或按行分隔。

定长消息就是在每个消息前加上固定长度的头部,比如前4字节表示后续数据长度(网络字节序),接收方先recv4字节得到长度,再循环recv直到收满。这是主流做法,但代码量略多。

按行分隔则是把消息末尾加上\n,接收方逐个字节读,读到\n认为一条消息完毕。适合纯文本聊天,实现最简单:

char line[1024]; char c; int n = 0; while ((n = recv(fd, &c, 1, 0)) > 0) { if (c == '\n') { line[n] = '\0'; // 注意下标,这里应该用独立的索引 // 处理line break; } line[n++] = c; }

这种写法虽然效率低,但教学演示中完全够用,而且能直观看到TCP流式传输的特性——为什么按字节读?因为流就是字节序列,没有边界。

3.4 线程安全与资源回收

如果用多线程处理多客户端,线程之间共享的资源主要是用于打印的终端(标准输出)。如果不加锁,两个线程同时往终端打印消息,会互相穿插,出现乱码。解决办法是定义一个全局互斥锁:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void safe_print(const char* msg) { pthread_mutex_lock(&lock); printf("%s\n", msg); fflush(stdout); pthread_mutex_unlock(&lock); }

每次打印都用这个函数,而不是直接printf,这在长时间运行的服务端尤其重要。另一个容易被忽略的问题是:当一个客户端断开连接后,对应线程应该退出,但需要先pthread_detach自己,或者调用pthread_join回收,否则会产生僵尸线程。教学演示里最简单的办法是在线程回调函数里直接调pthread_detach(pthread_self()),让系统在线程结束时自动回收资源。

4. 实操过程与核心环节实现

4.1 环境准备与代码骨架

这个项目在Linux/macOS环境下最顺手,Windows上需要用Winsock库,API略有差异。我以Ubuntu 20.04/22.04为例,只需要系统自带gcc和标准库,不需要安装任何第三方依赖。创建项目目录,先写公共头文件comm.h

#ifndef COMM_H #define COMM_H #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <pthread.h> #define SERVER_PORT 8888 #define BUFFER_SIZE 1024 #define MAX_CLIENTS 10 #endif

4.2 服务端完整实现

服务端逻辑:加载代码骨架后,核心是主函数+线程处理函数。我写出关键部分并加上注释,方便直接抄作业。

#include "comm.h" pthread_mutex_t print_lock = PTHREAD_MUTEX_INITIALIZER; void safe_print(const char *msg) { pthread_mutex_lock(&print_lock); printf("[server] %s\n", msg); fflush(stdout); pthread_mutex_unlock(&print_lock); } void *handle_client(void *arg) { int client_fd = *(int *)arg; free(arg); pthread_detach(pthread_self()); char buffer[BUFFER_SIZE]; int nrecv; while (1) { memset(buffer, 0, sizeof(buffer)); nrecv = recv(client_fd, buffer, sizeof(buffer) - 1, 0); if (nrecv <= 0) { if (nrecv == 0) { safe_print("client disconnected."); } else { perror("recv"); } break; } buffer[nrecv] = '\0'; safe_print(buffer); // echo back if (send(client_fd, buffer, strlen(buffer), 0) < 0) { perror("send"); break; } } close(client_fd); return NULL; } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 重启不报端口占用 struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(SERVER_PORT); server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(listen_fd); exit(1); } if (listen(listen_fd, MAX_CLIENTS) < 0) { perror("listen"); close(listen_fd); exit(1); } printf("Server listening on port %d...\n", SERVER_PORT); while (1) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int *client_fd = malloc(sizeof(int)); *client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len); if (*client_fd < 0) { perror("accept"); free(client_fd); continue; } char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)); printf("New client: %s:%d\n", client_ip, ntohs(client_addr.sin_port)); pthread_t tid; pthread_create(&tid, NULL, handle_client, client_fd); } close(listen_fd); return 0; }

几个注意点说明一下。accept返回的client_fd我用了malloc分配,就是为了把它传给线程函数而不担心栈变量失效。如果你直接传&client_fd,下一次循环里该变量被覆盖,线程读到的就是错误的fd,这是一个典型的新手错误。SO_REUSEADDR允许服务端重启后立即复用端口,开发时特别有用。INADDR_ANY表示监听所有网卡接口,如果只想本机调试,可以换成inet_addr("127.0.0.1")

4.3 客户端完整实现

客户端简单很多,只保留连接、发送、接收的功能。为了让演示效果更直观,我用两个线程:一个线程接收用户输入并发送,另一个线程接收服务端消息并打印。否则如果客户端一边阻塞在recv一边等用户输入,两个事件会互卡。

#include "comm.h" int sock_fd; void *recv_thread(void *arg) { char buffer[BUFFER_SIZE]; int n; while (1) { memset(buffer, 0, sizeof(buffer)); n = recv(sock_fd, buffer, sizeof(buffer) - 1, 0); if (n <= 0) { printf("server closed connection.\n"); break; } buffer[n] = '\0'; printf("%s\n", buffer); } close(sock_fd); exit(0); } int main() { sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket"); exit(1); } struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(SERVER_PORT); inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("connect"); close(sock_fd); exit(1); } printf("connected to server. Type messages below:\n"); pthread_t tid; pthread_create(&tid, NULL, recv_thread, NULL); char line[BUFFER_SIZE]; while (1) { fgets(line, sizeof(line), stdin); line[strcspn(line, "\n")] = '\0'; if (strcmp(line, "exit") == 0) { send(sock_fd, "bye", 4, 0); break; } if (send(sock_fd, line, strlen(line), 0) < 0) { perror("send"); break; } } close(sock_fd); return 0; }

客户端里inet_ptoninet_addr更推荐,兼容IPv6,也更安全。fgets会保留换行符,用strcspn移除,这是处理标准输入的一个常用技巧。另外,exit命令是我自定义的退出逻辑,正好演示了应用层协议的雏形——通过约定特殊指令来实现业务功能。

4.4 编译运行与演示效果

切换到项目目录,分别编译服务端和客户端:

gcc server.c -o server -lpthread gcc client.c -o client -lpthread

注意-lpthread必须加,否则pthread_create会报未定义引用。运行时要先启动服务端,再启动客户端:

./server

另开一个终端:

./client

在客户端输入任意字符串,比如"hello server",回车后,服务端终端会打印[server] hello server,客户端屏幕上也会看到服务端回显的"hello server"(因为客户端有个接收线程在打印)。如果你同时开多个客户端,每个客户端发消息,服务端都能正确分发回显,这就直观展示了多线程并发处理多个客户端的效果。

5. 常见问题与排查技巧实录

5.1 端口被占用,bind失败

这是出现频率最高的问题。服务端异常退出(比如按了Ctrl+C)后,端口仍处于TIME_WAIT状态,再次启动会报Address already in use。解决方式两种:一是等几十秒让系统回收,二是在代码里设置SO_REUSEADDR,正如上面实现的。另外,可以用netstat -tlnp | grep 8888查看端口占用情况,用kill -9 PID强制释放。

5.2 客户端connect显示Connection refused

最可能的原因是服务端还没启动,或者端口不一致。检查服务端是否在运行,再确认客户端写的IP和端口是否与服务端监听的一致。如果服务端监听的是127.0.0.1,客户端连局域网IP也会失败。一句话:先telnet 127.0.0.1 8888测试端口通不通,排除防火墙干扰。

5.3 recv返回-1,errno是EINTR

当进程在慢系统调用中被信号打断时,recv会返回-1且errnoEINTR。这在网络编程里很常见,例如你按了Ctrl+C触发了SIGINT。处理方式通常是在recv调用后判断errno,如果是EINTR就继续接收而不是退出。为了简化,教学演示可以忽略,但为了严谨,可以在循环内加判断。

5.4 send数据时遇到SIGPIPE信号

如果客户端已经关闭连接,服务端依然调用send,内核会向进程发送SIGPIPE信号,这个信号的默认动作是终止进程。所以服务端在遇到对端断开后要立即退出线程、关闭fd,避免后续发送。更稳妥的做法是在send之前通过recv返回值判断连接状态,或者给进程设置signal(SIGPIPE, SIG_IGN)忽略该信号,让send返回EPIPE错误。教学演示中按上述代码逻辑,客户端断开后,recv返回0,线程就会退出,不会走到send,所以问题暴露不出来。但如果增加了心跳检测或定时发送逻辑,就要特别注意。

5.5 多线程打印串行

没有加锁的终端输出,多条消息会纠缠在一起。解决就是用pthread_mutex_lock包住printf。当你发现服务端有时打印的顺序很奇怪,不是按消息到达的先后顺序时,就能体会锁的重要性了。不过这里要说明,锁只能保证同一时刻只有一个线程进打印临界区,不能保证打印顺序和接收顺序完全一致,因为线程调度的时机不确定。想要严格按到达顺序打印,得做消息排队的线程池设计,这个已经超出基础教学范围了。

6. 提升方案与扩展思路

6.1 用select/poll实现单线程多路复用

多线程方案虽然直观,但每来一个客户端就创建一个线程,线程数量上去后资源消耗很大。在Linux下,更高效的方案是selectepoll。以select为例,把一个非阻塞socket加入监听集合,当fd有数据可读时会有事件通知。这套机制需要维护一个fd集合,代码的复杂度比多线程高,但不会出现上百线程导致上下文切换爆炸的问题。

教学演示做完多线程版本后,完全可以再写一个select版本对比。建议先跑通多线程版本,再改写成select版本,体会两种模型的差异,对你的理解会有质的提升。

6.2 自定义应用层协议

目前的代码只是简单回显,没有消息类型、发送者信息、时间戳等。如果要做一个真正像样的聊天室,可以定义协议头:

struct msg_header { uint32_t magic; // 魔数,用于校验 uint32_t type; // 1表示普通消息,2表示上线通知,3表示下线通知 uint32_t length; // 后面payload的长度 };

客户端先发header,再发payload;服务端根据type决定处理逻辑。如果再做服务端向所有在线客户端广播消息,就变成了一个简易聊天室。这个扩展非常推荐,因为很多网络实验课的"聊天软件"作业都停留在这个层面。

6.3 文件传输功能

双端通讯不只可以传文本,用同样的socket通道,读取文件内容到缓冲区,循环发送,接收端写入文件,即可实现文件传输。需要额外处理的是:文件大小大于缓冲区时要循环发送;文件命名需要在协议里传输(比如在payload前面放文件名);接收完成后要检查完整性(可以比对两侧文件大小,或者使用MD5校验)。C语言做MD5需要引第三方库,或者用popen调系统命令,也可以简化成只比对大小。教学项目里加上这个功能,会让演示效果升级一个档次。

6.4 加一个简单的登录认证

通过用户名密码进行验证,是即时通讯的基本需求。在连接建立后,客户端先发送一个认证字符串(比如LOGIN:admin:123456),服务端验证成功后再进入聊天阶段,否则关闭连接。这个功能的实现并不复杂,关键是理解"连接建立后先跑认证,再跑业务"这种协议状态切换的思想。结合前面的自定义协议头,可以实现更复杂的鉴权流程。

7. 最后的实战建议

做这个项目我最大的体会是:不要急着写代码,先把TCP状态图画一遍,再动手。很多初学者卡在bindaccept上,不是不会写,而是根本没搞清楚服务端和客户端各自的调用顺序。我建议你先实现一个最简单的单向通讯:客户端发一条消息,服务端接收打印后直接退出,什么都不回传。跑通这个,再逐步加上回显、多线程、退出指令。每一步都验证过了再走下一步,这样能避免一次写一大堆代码后不知道怎么排错。

另外,多开几个终端做实验,观察三次握手的状态变化。先在客户端connect之后、服务端accept之前,用netstat -t查看TCP连接处于SYN_RECV还是ESTABLISHED,你就能直观理解TCP状态机。这种"看得见摸得着"的体验,比单纯背状态转换图有用得多。

如果你在Windows上做这个实验,需要把socket相关函数换成Winsock版本,本质上是一样的,只是要额外WSAStartup初始化,并链接ws2_32库。不过我还是建议用Linux虚拟机或云服务器跑,少很多环境坑。最后那个zip压缩包里如果带了源码和实验报告,不妨对着源码逐行读一遍,看看原作者在缓冲区大小、错误处理、线程回收方面是怎么取舍的,经常能学到比自己埋头写更有价值的东西。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 20:54:37

T265+PX4视觉定位保姆级教程:从驱动安装到EKF2融合与MAVROS桥接

我第一次把 T265 接到 Pixhawk 上时&#xff0c;无人机在地面站里显示的位置跟实际位置永远差着 90 度&#xff0c;差点把满屋子设备撞翻。后来排查下来才发现&#xff0c;问题不在硬件&#xff0c;而在整个数据链路里有一层没人明说的坐标系转换。这篇文章想把这套链路完完整整…

作者头像 李华
网站建设 2026/9/20 20:53:36

AnySearch 的 MCP 接进 Cursor,模型 Base URL 填 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 20:49:54

Claude Code vs Codex:同一把 TaoToken Key 跑 pytest 夹具重构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 20:48:26

ICESAT-1/2激光测高数据可视化与去噪Python实践

简介&#xff1a;面向ICESAT系列卫星数据的科研与工程人员&#xff0c;这份Python程序包实现了光子计数与波形数据的加载、去噪和可视化&#xff0c;适用于冰川高度变化分析、全球气候变化研究等场景。资源压缩包共三十五个文件&#xff0c;整体约七百一十七兆&#xff0c;主要…

作者头像 李华