C语言Socket编程入门:手写TCP服务端与客户端实现计算返回 这个标题乍一看像是笔误SOKET其实对应的是英文SOCKET。这种拼写错误反而成了门槛的试金石真正关心技术的人一眼就看穿顺手搜一下还能搜到完整的网络编程教程。如果你刚接触C语言网络编程或者手头正好有个“服务端计算后返回结果给客户端”的小项目要做这篇文章就是照着能跑通、能理解、能改写成你自己需求的完整版本。先把这个项目最核心的骨架说清楚一台服务端程序监听某个端口等待客户端连接客户端连上来之后把两个整数发给服务端服务端算出它们的和或者任意你想做的计算再把结果返回给客户端。整个通信基于TCP协议用最底层的Socket API逐个函数写出来不依赖任何第三方库。它适合三类读者一是正在准备网络编程课设的学生二是想搞懂TCP通信流程的初学者三是需要用C语言做内部工具通信的开发者。麻雀虽小但socket绑定、监听、连接、收发数据、字节序转换这些网络编程的基本功一个都不少学完这个再去上手HTTP服务器、各类业务系统都会顺很多。1. 项目整体拆解服务端计算返回客户端到底在做什么1.1 标题背后对应的实际场景“服务端计算返回客户端”这句话放在真实业务里随处可见。你在银行App上查余额客户端把账号信息发给服务器服务器查数据库算出余额返回给你你在网页上提交订单浏览器把订单数据发给服务端服务端算好总价再返回。套路都是同一个客户端发请求、服务端算一算、再把结果送回去。不同的是真实项目大多用HTTP协议、用Spring Boot或Node.js这些框架Socket细节被封装得干干净净。而这个项目直接用C语言的Socket API把底层通信的每一环都摆在明面上相当于自己手写了一遍TCP交互流程。做完之后你会形成一种“底层视角”知道数据从客户端到服务端经过哪些系统调用知道端口、IP、套接字这些概念是怎样咬合在一起的。1.2 为什么一定要用Socket这套东西很多入门者会问写个加法计算直接在客户端本地算不就行了绕这么大一圈图什么问出这个问题说明还没理解网络编程的适用边界。客户端和服务端如果跑在同一台机器上确实没必要绕一圈但现实中服务端通常在其他机房、其他城市甚至承载着多个客户端的同时访问。计算逻辑如果在客户端本地做数据安全性、统一更新、集中管理等需求全部无从谈起。Socket在这里扮演的角色可以类比成“操作快递货车的方向盘”。TCP/IP协议负责把数据可靠地从一台机器搬运到另一台机器Socket则是操作系统提供给用户的调用接口。C语言离系统最近调用Socket API时你看到的不是封装后的黑盒而是socket、bind、listen、accept这些原生的系统调用每一步都在和内核打交道。这种透明感是后面学习一切上层网络框架的最大资本。1.3 通信模型一个最简单却完整的闭环这个项目用的是TCP协议通信模型是经典的一对一请求-响应。客户端主动发起连接服务端被动等待连接建立后客户端先发送数据服务端读取并计算再把结果写回最后双方关闭连接。整个过程是一个闭环非常适合用来理解网络通信的基本节奏。这里有个容易忽略的重点TCP是面向连接的流式协议不是“发一条收一条”那么简单。数据在传输过程中没有天然边界客户端send了8个字节服务端recv不一定正好拿到8个字节可能拿到4个也可能一次拿到16个。这就是后面要讲的“粘包/拆包”问题。这个Demo因为数据量极小一次收发大概率能对齐但你必须从一开始就知道TCP的流式本质后续写真实业务时才能避免踩坑。2. 核心API与网络编程原理速通2.1 常用Socket API对照表网络编程的常用API就那十几个翻来覆去就是连接、收发、关闭这三类操作。把它们的作用和位置理清楚整个通信流程就像一张地图一样清晰。API作用使用位置socket()创建套接字指定协议族和传输类型服务端和客户端最先调用bind()把套接字绑定到指定IP和端口仅服务端需要listen()把套接字转为监听状态等待连接仅服务端需要accept()接受客户端的连接请求返回新的套接字服务端循环调用connect()客户端发起连接请求仅客户端需要send()发送数据双方都能用recv()接收数据双方都能用close()关闭套接字双方用完都需要初次接触的人最容易糊涂的是accept明明服务端已经有一个socket为什么accept之后又返回一个新socket简单理解监听socket只负责等电话真正和客户端对话的是accept返回的那条专用线路。一个监听socket可以接受无数个连接每次accept都会生成一个新的连接socket这样服务端才能同时服务多个客户端。2.2 sockaddr_in里那些反直觉的字段socket编程绕不开一个结构体struct sockaddr_in。它用来描述一个IPv4地址和端口初始化的时候有三个字段必须亲手填sin_family、sin_port、sin_addr。用的时候我会习惯性先memset清零防止结构体里残留的垃圾数据干扰端口绑定。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 htonl(INADDR_ANY);这里最反直觉的是端口号和IP地址都要经过一个字节序转换函数才能填进去。比较省心的做法是端口用htons、IP用inet_pton这两个函数在99%的场景下足够。我自己早期踩过坑直接把3389这样的十进制数塞进sin_portbind成功但客户端永远连不上就是因为没走htons转换。2.3 字节序网络传输中的数字排序坑字节序是网络编程的一个经典入门门槛。计算机里多字节数据有两种存储顺序大端和小端。不同机器可能不一样但网络传输必须统一于是定义了“网络字节序”为大端。htons、htonl、ntohs、ntohl这四个函数就是主机字节序和网络字节序之间的翻译官。你可以把整数想象成英文句子。小端机器像一种倒着念的方言网络协议要求大家正着念所以发出去之前要转换收到之后也要转换回本地方言。这个项目里客户端要把两个int32_t发给服务端发送前用htonl把每个数转成网络字节序服务端收到后用ntohl转回主机字节序才能正常做加法。算完的结果再用htonl转成网络字节序发回去客户端收到后ntohl还原。如果不做这一步不同架构的机器通信时得到的结果会完全错乱。我在本地x86机器上测试时通常看不出问题因为x86是小端而网络字节序是大端转不转其实数值对不上只不过简单加法碰巧可能对。真正放到跨平台环境中这就是定时炸弹所以从一开始就必须养成转换的习惯。2.4 三次握手与accept/connect的对应关系TCP三次握手是经典面试题但很多人没搞懂它和Socket API的对应关系。客户端调用connect时内核会主动发出SYN包服务端的listen其实已经让内核在监听这个端口收到SYN后内核自动回复SYNACK客户端再回一个ACK三次握手完成。整个过程由内核协议栈自动处理应用层感知到的只是connect返回成功、accept返回一个新连接。所以accept并不是“等握手完成”的主动操作而是从内核的已完成连接队列里取出一个已经握手成功的连接。这个细节搞清楚了你对“accept返回客户端的IP和端口”这个行为会有更深的理解协议栈在握手时就记录了这些信息accept只是把它们交给你。项目中的client_addr就是靠这个拿到的。3. 服务端实现从监听端口到返回计算结果3.1 服务端的四个关键流程服务端代码的逻辑可以拆成四段创建监听socket、绑定并监听、接受连接并收发数据、关闭资源。写代码之前先把流程默背一遍比直接敲代码有效率得多。第一步socket()创建套接字指定AF_INET地址族和SOCK_STREAM流式套接字对应TCP。第二步bind()把套接字绑到本机某个端口上端口用htons转换IP可以直接用INADDR_ANY表示“绑定本机所有网卡”。第三步listen()把套接字变成被动监听状态backlog参数设置连接队列的最大长度。第四步accept()接受连接然后对新返回的连接socket进行recv、send操作。这个流程对应到生活场景就是装电话、报号码、开机等铃声、接起电话开始聊。每一步返回值都要检查判断是否小于0小于0就调用perror打印错误信息然后退出或继续这是网络程序最基本的健壮性要求。3.2 完整代码与逐段说明服务端完整代码我直接贴出来跟着注释读一遍然后自己敲一遍效果比复制粘贴好得多。这里的核心逻辑是接收客户端发来的8个字节按两个int32_t解析算出和用4个字节把结果返回。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include stdint.h #include arpa/inet.h #include sys/socket.h #define PORT 8888 int main(void) { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len sizeof(client_addr); server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)) 0) { perror(setsockopt); close(server_fd); exit(EXIT_FAILURE); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(server_fd); exit(EXIT_FAILURE); } if (listen(server_fd, 5) 0) { perror(listen); close(server_fd); exit(EXIT_FAILURE); } printf(server listening on port %d, waiting for client...\n, PORT); while (1) { client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(client connected: %s:%d\n, client_ip, ntohs(client_addr.sin_port)); int32_t nums[2] {0, 0}; ssize_t n recv(client_fd, nums, sizeof(nums), 0); if (n 0) { perror(recv); close(client_fd); continue; } else if (n 0) { printf(client closed connection\n); close(client_fd); continue; } int32_t a ntohl(nums[0]); int32_t b ntohl(nums[1]); int32_t result htonl(a b); if (send(client_fd, result, sizeof(result), 0) 0) { perror(send); } else { printf(calc: %d %d %d, result sent back\n, a, b, a b); } close(client_fd); } close(server_fd); return 0; }代码里有两个点值得展开说。第一setsockopt加SO_REUSEADDR这个选项作用是在服务端关闭后快速重启时避免出现“Address already in use”。开发调试阶段服务端经常按CtrlC中断端口还没释放干净没这个选项你就得等几十秒或者换端口非常烦人。第二accept后的client_fd用完要close但server_fd始终保留继续进入下一次accept等待。这个结构就是单线程服务端的经典循环一个接一个地处理客户端请求。3.3 一个容易踩的坑监听socket与连接socket我还想专门强调一下socket关闭的问题。很多人初学时会犯一个错在处理完一个客户端后顺手把server_fd也关了结果第二次循环accept直接失败。监听socket和连接socket生命周期不同前者是整个程序运行期间一直存在的后者才是每次连接的临时通道。另外accept返回的client_fd继承了监听socket的属性也处于阻塞模式。recv如果没有数据进程就会卡在那里这就是阻塞式网络编程的特点。对入门项目来说阻塞模式最简单直观但在生产环境基本都会配合select、poll或epoll使用非阻塞模式。理解这个瓶颈可以为后续学习I/O多路复用做铺垫。4. 客户端实现发数据、收结果4.1 客户端流程简析客户端的流程比服务端短了一大截创建socket、设置服务端地址、connect连接、send发送数据、recv接收结果、close关闭。没有了bind、listen、accept这三步因为客户端不需要固定端口内核会在connect时自动分配一个临时端口。我注意到不少初学者会把客户端和服务端的流程搞混对着客户端代码硬找bind找不到就犯迷糊。记住一个原则只有对外提供服务的程序才需要固定端口主动发起请求的一方用临时端口就够了。打电话时你的手机号码不需要提前告诉对方对方有来电显示才能看到本质上就是一个临时分配的号码。4.2 完整代码与细节说明客户端代码从命令行读取四个参数服务端IP、端口、两个整数然后组包发出等待返回结果。我把完整代码放在下面注意参数个数校验这一步缺了参数会让人排查半天。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include stdint.h #include arpa/inet.h #include sys/socket.h int main(int argc, char *argv[]) { if (argc ! 5) { fprintf(stderr, usage: %s server_ip port num1 num2\n, argv[0]); exit(EXIT_FAILURE); } int server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(atoi(argv[2])); if (inet_pton(AF_INET, argv[1], server_addr.sin_addr) 0) { perror(inet_pton); close(server_fd); exit(EXIT_FAILURE); } if (connect(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect); close(server_fd); exit(EXIT_FAILURE); } int32_t nums[2]; nums[0] htonl(atoi(argv[3])); nums[1] htonl(atoi(argv[4])); if (send(server_fd, nums, sizeof(nums), 0) 0) { perror(send); close(server_fd); exit(EXIT_FAILURE); } int32_t result 0; if (recv(server_fd, result, sizeof(result), 0) 0) { perror(recv); close(server_fd); exit(EXIT_FAILURE); } printf(server result: %d\n, ntohl(result)); close(server_fd); return 0; }inet_pton函数值得单独夸一夸。它把点分十进制字符串形式的IP转换成二进制网络字节序比老款的inet_addr好用关键是不需要再手动转字节序。IPv6时代它也能直接支持属于那种一学就会、一用就回不去的API。4.3 关于协议客户端和服务端是怎么约定“先发两个数再收一个数”服务端的计算逻辑很简单但这里面藏着一个网络编程的“潜规则”通信双方必须提前约定好数据格式。这个Demo的协议非常简陋我用自然语言描述就是“客户端发送8字节内容是两个int32_t整数服务端返回4字节内容是一个int32_t整数”。协议设计是网络编程的核心能力。真实项目里你会看到越来越多复杂的协议设计固定长度的头部加上变长的消息体、头部里写清消息长度和类型、或者用JSON这种自描述格式。这个Demo故意用最朴素的二进制定长格式就是为了让你先把收发数据这件最基础的事练熟。等你能在本地跑通这个协议再思考“如果我要传一个字符串怎么办”就会自然过渡到协议设计的深水区。5. 编译运行与功能扩展5.1 编译和运行方法代码写完之后编译这一步对新手反而是个小小的拦路虎。两个文件分别编译生成两个可执行程序然后先起服务端再起客户端。我用的编译命令如下gcc server.c -o server gcc client.c -o client然后开两个终端窗口第一个终端运行服务端./server服务端会打印出“server listening on port 8888”然后进入阻塞等待状态。第二个终端运行客户端./client 127.0.0.1 8888 100 23客户端打印“server result: 123”服务端终端也会打印“calc: 100 23 123, result sent back”。如果能看到这两行输出恭喜你整个通信闭环已经跑通了。这里有个新手操作细节程序是阻塞式的服务端跑起来后终端光标一直停在那里看起来像卡住了其实是listen在等待连接。很多初学者以为程序死了直接CtrlC然后再跑就报端口占用。这时候要么等一会儿再启动要么看一眼代码里是否加了SO_REUSEADDR这也是我为什么一开始就建议加这个选项的原因。5.2 用nc做快速测试不想写客户端也可以做测试Linux自带的nc命令就能模拟TCP连接。不过因为我们的协议是二进制数据直接敲键盘输入数字是不行的得用echo配合十六进制数据来发送。两个int32_t分别是100和23转换成十六进制并加上网络字节序就是echo -ne \x00\x00\x00\x64\x00\x00\x00\x17 | nc 127.0.0.1 8888 | xxd\x64是100的十六进制\x17是23的十六进制前面补了三个\x00凑足4字节。这条命令的输出会是一段十六进制其中最后4个字节\x00\x00\x00\x7b对应的十进制就是123。学会用nc测试能让你在写客户端之前先验证服务端是否正确这种“先验证后联调”的思路在真实开发中非常省时间。5.3 从“计算加法”扩展到更多方式标题里的“计算”没有限定成加法这个骨架天然支持各种扩展。想改成四则运算只需要在客户端组包时多传一个操作符字段服务端解析后再switch分支选择运算。想改成长字符串统计字符数就把消息体换成“长度前缀字符串内容”服务端读完按长度字段做处理。我建议你按“加需求但不加复杂度”的原则逐步演进。第一步把两个整数改成三个整数求和第二步加一个操作符字段第三步试试传输字符串。每走一步你对协议设计的理解都会加深一层。这个Demo的骨架就像一个发动机你不用换发动机只需要调整喷油参数就能让车子适应不同的路况。5.4 多客户端并发处理的思路当前服务端是单线程串行处理通俗讲就是一个客户端用完之前后面的客户端都得排队。想要支持多个客户端同时连接最经典的做法是fork出子进程来处理每个连接。父进程继续accept每来一个连接就fork一个子进程子进程里处理完就退出父进程再接着等下一个。while (1) { int client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) continue; pid_t pid fork(); if (pid 0) { close(server_fd); // 子进程不需要监听socket // 在这里处理client_fd的收发数据 close(client_fd); exit(0); } close(client_fd); // 父进程不需要连接socket }这个模式简单可靠但进程创建开销比较大。当并发量上来后主流方案会换成epoll搭配非阻塞I/O。这算是从入门到进阶的必经之路先把这个fork版本跑通再去折腾epoll体感会平滑很多。6. 常见问题、排查技巧与避坑手册6.1 高频问题速查表我整理了一份网络编程新手最容易踩的问题清单这些都是群里和评论区反复出现的问题直接拍下来对照排查能省一晚上调试时间。现象可能原因排查方法bind报Address already in use端口被上次运行残留占用加SO_REUSEADDR或换端口connect报Connection refused服务端没起来或端口不对先启动服务端再用netstat确认监听连上但客户端等不到结果协议没对上比如ip/port参数错位打印服务端收到的原始字节做检查收到结果数值不对字节序没转或结构体对齐问题确认htonl/ntohl各步骤是否成对第二次运行报错listen队列或端口状态异常重启终端检查后台是否还有旧进程程序启动后立即退出参数个数校验不通过检查argc与argv用法打印usage最让我印象深刻的是一个问题客户端连上了也发送了数据但服务端recv返回0然后直接关闭连接。这种情况通常是客户端send之后立刻close服务端还没收到数据连接就半关闭了。TCP是双向的close会走完整个关闭流程数据没来得及读就会出现这种诡异现象。解决方案很简单确认recv的返回值在代码里区分0对端关闭、正数收到数据和负数出错三种情况。6.2 粘包和拆包为什么不是这个Demo的重点我在前面反复提到TCP是流协议现在要告诉你一个残酷的现实这个Demo之所以能稳定跑通纯粹是因为数据量小、一次send对应一次recv的概率极大。真实网络中TCP可能把多次send的数据合并成一次收到也可能把一次send的数据拆成多次收到这就是粘包和拆包。严谨的写法应该是封装一个“读满指定字节数”的函数循环调用recv直到凑够目标长度。我贴一个实用函数这个函数往后很多项目都能直接用ssize_t recv_all(int fd, void *buf, size_t expected) { size_t total 0; while (total expected) { ssize_t n recv(fd, (char *)buf total, expected - total, 0); if (n 0) return n; // 出错或对端关闭 total n; } return total; }同理发送端有时也要用循环send确保数据全部发出。对Demo来说不写这个函数能跑但如果你抱着学习网络编程的初心最好还是把recv_all这种健壮性函数放进代码里哪怕只是放着就能让你提前养成正确习惯。6.3 我的几点实用心得这个项目我前前后后带过不少学弟学妹也见过各种各样的错误总结几条最值得分享的实操心得。第一学网络编程不要只看代码先画流程图。把socket、bind、listen、accept、connect、send、recv这些函数按顺序写到一张纸上标注出哪个函数在哪个角色里用再用箭头连出数据流向。流程图画清楚了代码错误基本一眼就能看出来根本不需要反复调试。第二调试时善用抓包工具。用tcpdump或者Wireshark抓一次回环接口的流量看三次握手、看数据包序列号你会发现很多抽象概念瞬间落地。我当年理解三次握手就是靠Wireshark抓本地包看SYN、ACK标志位比死记硬背树状图有效得多。第三先在本地用127.0.0.1跑通再去折腾局域网。有人一上来就改成局域网IP结果连不上又怀疑代码又怀疑防火墙折腾半天才发现是防火墙拦截了端口。调试的基础顺序从来都是本地通再换IP再换机器最后才是跨网络。最后再分享一个小技巧在服务端accept之后先打印客户端的IP和端口这样每次连接都能看到是谁连上来的。看起来只是一行printf但配合日志调试时你能清楚地区分“客户端没连上”和“连上了但数据没发对”这两种完全不同的故障场景。这个小习惯帮我排除过无数个“看起来像网络问题”的哑巴故障。