网络通信模块:Socket 编程的封装、TCP/UDP 客户端与服务端模块、简单的 HTTP 请求模块
网络通信,说白了就是让两个程序隔着网线聊天。我做了这么多年嵌入式,发现很多工程师一碰到网络编程就头大——其实没那么可怕。今天我就带你把这层窗户纸捅破。
为什么我们要封装 Socket?
你想想看,每次写网络程序都要重复那些 bind、listen、accept 的套路,烦不烦?我个人习惯是把这些底层操作封装成几个简单的接口。这样上层业务逻辑根本不用关心什么套接字描述符、地址结构体,直接调用 tcp_server_start() 就行了。
核心思想:把复杂的系统调用藏起来,对外暴露业务语义明确的函数。
Socket 编程的封装策略
我在项目中遇到过最头疼的事:一个团队五个人写网络模块,每个人对错误处理的风格都不一样。有人直接 exit,有人返回 -1,还有人默默吞掉错误。后来我定了一套封装规范,大家照着写,代码质量立马就上来了。
基础封装层
这一层负责处理最原始的 Socket 系统调用。我建议你至少封装这几个函数:
socket_create()—— 创建套接字,自动处理协议族选择socket_bind()—— 绑定地址,自动处理地址重用选项socket_listen()—— 监听连接,设置合理的 backlogsocket_accept()—— 接受连接,返回封装后的连接句柄socket_connect()—— 主动连接,带超时机制
// 我常用的封装风格
typedef struct {
int fd;
struct sockaddr_in addr;
socklen_t addr_len;
} socket_t;
socket_t* tcp_server_create(uint16_t port) {
socket_t *s = malloc(sizeof(socket_t));
if (!s) return NULL;
s->fd = socket(AF_INET, SOCK_STREAM, 0);
if (s->fd < 0) {
free(s);
return NULL;
}
int opt = 1;
setsockopt(s->fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&s->addr, 0, sizeof(s->addr));
s->addr.sin_family = AF_INET;
s->addr.sin_addr.s_addr = htonl(INADDR_ANY);
s->addr.sin_port = htons(port);
s->addr_len = sizeof(s->addr);
return s;
}
我的小技巧:封装时一定要把错误码也带出来。我习惯在 socket_t 结构体里加一个 errno 字段,这样上层调用后可以统一查错。
TCP 客户端与服务端模块
TCP 是面向连接的,说白了就是先打电话再聊天。服务端得等着,客户端主动拨号。这个模型很直观,但实现细节里坑不少。
服务端模块设计
我一般把服务端拆成三个层次:
- 监听层 —— 负责创建监听套接字,处理 accept
- 会话层 —— 管理每个连接的读写缓冲区
- 业务层 —— 处理具体的协议逻辑
这样做的好处是,哪天你想从单线程改成多线程,只需要改会话层,业务代码完全不用动。
// 服务端模块的核心接口
typedef void (*on_connect_cb)(socket_t *client, void *ctx);
typedef void (*on_data_cb)(socket_t *client, const char *data, size_t len, void *ctx);
typedef struct {
socket_t *server;
on_connect_cb on_connect;
on_data_cb on_data;
void *ctx;
} tcp_server_t;
int tcp_server_start(tcp_server_t *srv) {
if (bind(srv->server->fd,
(struct sockaddr*)&srv->server->addr,
srv->server->addr_len) < 0) {
return -1;
}
listen(srv->server->fd, 10);
// 事件循环
while (1) {
socket_t *client = malloc(sizeof(socket_t));
client->addr_len = sizeof(client->addr);
client->fd = accept(srv->server->fd,
(struct sockaddr*)&client->addr,
&client->addr_len);
if (client->fd > 0) {
srv->on_connect(client, srv->ctx);
// 处理数据...
}
}
}
客户端模块设计
客户端相对简单,但要注意超时处理。我曾经在一个项目里没加超时,结果网络断了,程序卡在 connect 那里整整两分钟。嗯,这个教训很深刻。
// 带超时的客户端连接
int tcp_client_connect(socket_t *s, const char *ip, uint16_t port, int timeout_ms) {
// 设置非阻塞
int flags = fcntl(s->fd, F_GETFL, 0);
fcntl(s->fd, F_SETFL, flags | O_NONBLOCK);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip, &addr.sin_addr);
connect(s->fd, (struct sockaddr*)&addr, sizeof(addr));
// 用 select 实现超时
struct timeval tv = {timeout_ms / 1000, (timeout_ms % 1000) * 1000};
fd_set set;
FD_ZERO(&set);
FD_SET(s->fd, &set);
int ret = select(s->fd + 1, NULL, &set, NULL, &tv);
if (ret <= 0) {
return -1; // 超时或出错
}
// 恢复阻塞模式
fcntl(s->fd, F_SETFL, flags);
return 0;
}
注意:非阻塞 connect 成功后,需要用 getsockopt 检查 SO_ERROR,确认连接是否真的建立成功。我见过有人只检查 select 返回值,结果连接其实失败了还在发数据。
UDP 客户端与服务端模块
UDP 是无连接的,就像发短信——你发出去就不管了。速度快,但不可靠。我一般在音视频传输或者日志上报场景用 UDP。
UDP 的封装比 TCP 简单得多,因为不需要维护连接状态。核心就两个操作:sendto 和 recvfrom。
// UDP 模块的简洁封装
typedef struct {
int fd;
struct sockaddr_in addr;
} udp_socket_t;
int udp_send(udp_socket_t *s, const char *data, size_t len,
const char *ip, uint16_t port) {
struct sockaddr_in dest;
dest.sin_family = AF_INET;
dest.sin_port = htons(port);
inet_pton(AF_INET, ip, &dest.sin_addr);
return sendto(s->fd, data, len, 0,
(struct sockaddr*)&dest, sizeof(dest));
}
int udp_recv(udp_socket_t *s, char *buf, size_t buf_size,
char *src_ip, uint16_t *src_port) {
struct sockaddr_in src;
socklen_t src_len = sizeof(src);
int ret = recvfrom(s->fd, buf, buf_size, 0,
(struct sockaddr*)&src, &src_len);
if (ret > 0) {
inet_ntop(AF_INET, &src.sin_addr, src_ip, INET_ADDRSTRLEN);
*src_port = ntohs(src.sin_port);
}
return ret;
}
经验之谈:UDP 缓冲区要设大一点。我一般设 64KB,因为有些嵌入式系统的默认缓冲区只有 8KB,大包直接丢了。
简单的 HTTP 请求模块
HTTP 协议其实就是在 TCP 上面加了一层文本协议。说白了就是按照固定格式发一段文本,然后解析返回的文本。我封装 HTTP 模块时,只关注最常用的 GET 和 POST 方法。
HTTP 请求的构造
// 构造 HTTP GET 请求
char* http_build_get(const char *host, const char *path) {
char *req = malloc(512);
snprintf(req, 512,
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Connection: close\r\n"
"\r\n",
path, host);
return req;
}
// 构造 HTTP POST 请求
char* http_build_post(const char *host, const char *path,
const char *body, const char *content_type) {
char *req = malloc(1024);
snprintf(req, 1024,
"POST %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
path, host, content_type, strlen(body), body);
return req;
}
响应解析
解析 HTTP 响应时,我最头疼的就是分块传输编码。后来我干脆只处理 Content-Length 的情况,遇到 chunked 就报错。实际项目中,99% 的场景 Content-Length 就够了。
// 简单的 HTTP 响应解析
typedef struct {
int status_code;
char *body;
size_t body_len;
} http_response_t;
http_response_t* http_parse_response(const char *raw) {
http_response_t *resp = malloc(sizeof(http_response_t));
// 解析状态行
sscanf(raw, "HTTP/1.1 %d", &resp->status_code);
// 找空行
const char *header_end = strstr(raw, "\r\n\r\n");
if (!header_end) return NULL;
// 找 Content-Length
const char *cl = strstr(raw, "Content-Length:");
if (cl) {
sscanf(cl, "Content-Length: %zu", &resp->body_len);
}
// body 在空行之后
resp->body = strdup(header_end + 4);
return resp;
}
完整调用链:构造请求 → TCP 发送 → 接收响应 → 解析响应。我封装了一个 http_request() 函数,把这三步合在一起,上层调用者只需要传 URL 和参数就行。
知识体系总览
下面这张图把整个网络通信模块的封装层次画清楚了。你从上往下看,每一层都依赖下一层,但上层完全不知道下层的实现细节。
模块化设计的几个原则
最后,我总结几条我在实际项目中反复验证过的原则:
- 接口要小 —— 一个模块对外暴露的函数不要超过 10 个。多了就说明职责不单一。
- 错误处理要统一 —— 要么全部返回错误码,要么全部用回调。混着用会把人逼疯。
- 内存管理要清晰 —— 谁分配谁释放,这个规矩一定要定死。我见过太多内存泄漏都是因为模块之间互相推卸责任。
- 超时机制不能省 —— 网络通信最怕死等。每个阻塞操作都要有超时,这是血的教训。
最后说一句:封装不是越厚越好。我见过有人把 Socket 封装了七八层,最后查个 bug 要翻遍所有代码。适度的封装是艺术,过度的封装是灾难。