网络编程基础:socket、bind、listen、accept,TCP客户端服务器

说实话,很多嵌入式工程师一听到「网络编程」就头大。觉得那是搞Linux后台的人的事。我当年也这么想,直到有一次做智能网关项目,发现设备之间必须走TCP通信……嗯,硬着头皮啃下来后,发现其实就那么几个API来回倒腾。

今天咱们就把TCP网络编程这层窗户纸捅破。你只要搞懂四个函数——socketbindlistenaccept,再加上客户端的connect,就能写出能跑起来的网络程序。

1. 先搞清楚:TCP通信到底长啥样?

说白了,TCP就像打电话。服务器得先装个电话机(socket),然后绑定一个号码(bind),接着把电话线接好等着(listen),有人打进来就接听(accept)。客户端呢?拿起电话(socket),直接拨号(connect),接通了就开始聊天(send/recv)。

我画了张图,你看一眼就明白了:

TCP 服务器 socket() → 创建套接字 bind() → 绑定IP和端口 listen() → 开始监听 accept() → 接受连接 send() / recv() 通信 TCP 客户端 socket() → 创建套接字 connect() → 连接服务器 send() / recv() 通信 TCP三次握手
我的经验:初学者最容易搞混的是「服务器先启动还是客户端先启动」。记住:服务器必须提前跑起来,客户端才能连得上。就像你打电话,对方得先开机才行。

2. 四个核心函数,一个一个说

2.1 socket() — 创建通信端点

这个函数就是跟内核说:「嘿,给我一个网络通信的句柄。」返回的是一个文件描述符,你可以把它当成一个特殊的文件来读写。

#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET: IPv4协议
// SOCK_STREAM: 流式套接字,对应TCP
// 第三个参数0:自动选择协议

我在项目中遇到过一个问题:socket创建成功后,忘了检查返回值。结果后面bind的时候一直报错,查了半天才发现是socket创建失败。所以我的习惯是——每个系统调用后都检查返回值。

注意:socket()返回-1表示失败,errno会告诉你原因。常见错误:权限不够(EPERM)、内存不足(ENOMEM)、协议不支持(EPROTONOSUPPORT)。

2.2 bind() — 绑定地址和端口

bind说白了就是把你的套接字和一个具体的IP地址、端口号绑定在一起。服务器必须调用bind,客户端一般不用。

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
serv_addr.sin_port = htons(8888);               // 端口号

if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("bind failed");
    exit(1);
}

这里有个坑:htonlhtons是干嘛的?说白了就是字节序转换。网络字节序是大端,而x86是小端。你不转换的话,端口号就错了。我曾经见过一个同事,bind端口8080,结果实际绑到了32815,就是因为忘了htons。

2.3 listen() — 开始监听

bind之后,套接字还处于「未监听」状态。listen就是告诉内核:「我开始接客了,有连接请求就排队等着。」

if (listen(sockfd, 5) < 0) {
    perror("listen failed");
    exit(1);
}
// 第二个参数5:最大等待队列长度

第二个参数叫backlog,表示内核为这个套接字排队的最大连接数。你想想看,如果同时有10个客户端连过来,而你只设了3,那后面7个就会被拒绝。我一般设5~10,具体看你的应用场景。

2.4 accept() — 接受连接

accept是个阻塞函数。它一直等着,直到有客户端连上来,然后返回一个新的套接字——这个新套接字专门用来和这个客户端通信。

struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
if (connfd < 0) {
    perror("accept failed");
    exit(1);
}
printf("新客户端连接: %s:%d\n", 
       inet_ntoa(cli_addr.sin_addr), 
       ntohs(cli_addr.sin_port));
关键点:accept返回的是新的套接字,原来的监听套接字还在继续监听。一个负责「接客」,一个负责「聊天」,各司其职。

3. 客户端:connect() 就够了

客户端比服务器简单得多。创建socket后,直接connect到服务器的IP和端口就行。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8888);
inet_pton(AF_INET, "192.168.1.100", &serv_addr.sin_addr);

if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("connect failed");
    exit(1);
}
// 连接成功,开始通信...

connect也是阻塞的。它会触发TCP三次握手,握手成功才返回。如果服务器没启动或者网络不通,connect会超时返回-1。我建议你设一个超时时间,别让客户端卡死在那。

4. 完整示例:回声服务器

光说不练假把式。下面是一个完整的TCP回声服务器——客户端发什么,服务器就回什么。

// server.c - TCP回声服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int listenfd, connfd;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    char buf[1024];
    int n;

    // 1. 创建套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 绑定地址
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(8888);

    if (bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind");
        exit(1);
    }

    // 3. 开始监听
    if (listen(listenfd, 5) < 0) {
        perror("listen");
        exit(1);
    }

    printf("服务器已启动,监听端口8888...\n");

    // 4. 接受连接
    cli_len = sizeof(cli_addr);
    connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
    if (connfd < 0) {
        perror("accept");
        exit(1);
    }

    printf("客户端已连接\n");

    // 5. 通信循环
    while ((n = read(connfd, buf, sizeof(buf))) > 0) {
        write(connfd, buf, n);  // 原样返回
    }

    close(connfd);
    close(listenfd);
    return 0;
}
// client.c - TCP回声客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main() {
    int sockfd;
    struct sockaddr_in serv_addr;
    char buf[1024];
    int n;

    // 1. 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 连接服务器
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(8888);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

    if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("connect");
        exit(1);
    }

    printf("已连接到服务器\n");

    // 3. 发送数据
    while (fgets(buf, sizeof(buf), stdin) != NULL) {
        write(sockfd, buf, strlen(buf));
        n = read(sockfd, buf, sizeof(buf));
        write(STDOUT_FILENO, buf, n);
    }

    close(sockfd);
    return 0;
}

5. 避坑指南

我这些年做网络编程,踩过的坑不少。挑几个典型的说说:

  • 地址已使用(Address already in use):服务器崩溃后,端口还处于TIME_WAIT状态。解决方案是用setsockopt设置SO_REUSEADDR选项。
  • 阻塞问题:read和write默认是阻塞的。如果对方不发数据,read会一直等。我建议用非阻塞模式或者select/poll/epoll。
  • 粘包问题:TCP是流式协议,没有消息边界。你发两次数据,对方可能一次就收完了。解决办法是自定义应用层协议,比如加个长度头。
  • 字节序:永远记得用htonl/htons/ntohl/ntohs。不同平台字节序不一样,别偷懒。
我的习惯:每次写网络程序,我都会在代码开头加一句#define _GNU_SOURCE,这样可以用一些扩展功能。另外,调试时用strace跟踪系统调用,能快速定位问题。

6. 总结一下

TCP网络编程的核心就这几个API。服务器:socket → bind → listen → accept → read/write。客户端:socket → connect → read/write。你把这个流程刻在脑子里,以后遇到任何网络应用,底层都是这套东西。

嗯,最后提醒一句:别光看代码,自己动手敲一遍。我当年就是照着书抄了三遍,才真正理解每个参数的含义。网络编程这东西,纸上谈兵没用,得真刀真枪地跑起来。


公众号:蓝海资料掘金营,微信deep3321