28、网络编程安全:socket错误处理、数据序列化安全、协议解析防御、超时机制
网络编程,说白了就是让两个程序隔着网线聊天。但这条网线不太靠谱——数据可能丢、可能乱、可能被人偷看,甚至可能有人故意发垃圾数据搞破坏。我做了十几年嵌入式网络开发,踩过的坑比代码行数还多。今天咱们就聊聊怎么把网络通信写得更结实。
28.1 socket错误处理:别让程序死得不明不白
很多新手写socket代码,喜欢这样:
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
send(sock, data, len, 0);
recv(sock, buf, size, 0);
嗯,看着挺顺眼。但实际跑起来,任何一个步骤都可能失败。我见过一个产品,就因为没检查connect返回值,程序一直往一个不存在的服务器发数据,日志里全是「发送成功」——其实全丢黑洞里了。
正确的做法是这样:
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1) {
// 记录错误码 errno
log_error("socket() failed: %s", strerror(errno));
return -1;
}
struct timeval timeout = {5, 0}; // 5秒超时
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
log_error("connect() failed: %s", strerror(errno));
close(sock);
return -1;
}
ssize_t sent = send(sock, data, len, 0);
if (sent == -1) {
log_error("send() failed: %s", strerror(errno));
} else if (sent != len) {
log_warn("send() partial: sent %zd of %zu", sent, len);
}
这里有个细节:send() 可能只发送了部分数据。我早期就吃过这个亏,以为一次send就能发完所有数据。后来养成了循环发送的习惯:
ssize_t send_all(int sock, const void *data, size_t len) {
const char *ptr = (const char*)data;
size_t remaining = len;
while (remaining > 0) {
ssize_t sent = send(sock, ptr, remaining, 0);
if (sent == -1) {
if (errno == EINTR) continue; // 被信号中断,重试
return -1; // 真正的错误
}
ptr += sent;
remaining -= sent;
}
return len;
}
28.2 数据序列化安全:别让结构体直接往外扔
很多嵌入式工程师喜欢这样:
struct Packet {
uint32_t id;
uint32_t length;
char data[256];
};
struct Packet pkt;
send(sock, &pkt, sizeof(pkt), 0);
你想想看,这有什么问题?问题大了去了。
- 字节序问题:大端和小端机器之间直接传结构体,数据全乱套
- 对齐问题:不同编译器、不同架构的结构体填充不一样
- 版本兼容问题:结构体一改,新旧版本就聊不到一块了
我建议的做法是:使用明确的序列化格式。最简单的方案是逐字段手动序列化:
// 序列化:主机字节序 -> 网络字节序
int serialize_packet(const struct Packet *pkt, uint8_t *buf, size_t buf_size) {
if (buf_size < 4 + 4 + pkt->length) {
return -1; // 缓冲区不够
}
uint32_t id = htonl(pkt->id);
uint32_t len = htonl(pkt->length);
memcpy(buf, &id, 4);
memcpy(buf + 4, &len, 4);
memcpy(buf + 8, pkt->data, pkt->length);
return 8 + pkt->length;
}
// 反序列化:网络字节序 -> 主机字节序
int deserialize_packet(const uint8_t *buf, size_t buf_size, struct Packet *pkt) {
if (buf_size < 8) {
return -1; // 至少要有头部
}
uint32_t id, len;
memcpy(&id, buf, 4);
memcpy(&len, buf + 4, 4);
pkt->id = ntohl(id);
pkt->length = ntohl(len);
if (pkt->length > 256) {
return -1; // 长度检查!防止缓冲区溢出
}
if (buf_size < 8 + pkt->length) {
return -1; // 数据不够
}
memcpy(pkt->data, buf + 8, pkt->length);
return 8 + pkt->length;
}
如果项目复杂,可以考虑使用 protobuf、MessagePack 这类成熟的序列化库。但在嵌入式环境里,我更喜欢自己写一个轻量级的 TLV(Type-Length-Value)格式:
// TLV 格式:1字节类型 + 2字节长度 + 数据
int tlv_encode(uint8_t type, const void *value, uint16_t len,
uint8_t *buf, size_t buf_size) {
if (buf_size < 3 + len) return -1;
buf[0] = type;
buf[1] = (len >> 8) & 0xFF;
buf[2] = len & 0xFF;
memcpy(buf + 3, value, len);
return 3 + len;
}
28.3 协议解析防御:别信对方发来的任何数据
网络编程的第一条铁律:永远不要信任对端发来的数据。你想想看,如果对方是个恶意程序,或者线路被干扰了,什么奇怪的数据都可能出现。
协议解析时,必须做以下几件事:
- 魔数检查:每个包开头放一个固定魔数,比如 0xAA55,用来快速过滤垃圾数据
- 长度校验:声明的长度不能超过缓冲区大小,也不能小于头部大小
- 范围检查:枚举值、索引等必须在合法范围内
- 完整性校验:CRC32 或 MD5 校验和
来看一个完整的协议解析示例:
#define MAGIC 0xAA55
#define MAX_PAYLOAD 1024
struct ProtocolHeader {
uint16_t magic;
uint16_t length; // 包含头部在内的总长度
uint32_t crc32;
uint8_t type;
};
int parse_packet(const uint8_t *raw, size_t raw_len, struct Packet *out) {
// 1. 最小长度检查
if (raw_len < sizeof(struct ProtocolHeader)) {
log_warn("packet too short: %zu", raw_len);
return -1;
}
// 2. 魔数检查
struct ProtocolHeader *hdr = (struct ProtocolHeader*)raw;
if (ntohs(hdr->magic) != MAGIC) {
log_warn("bad magic: 0x%04X", ntohs(hdr->magic));
return -1;
}
// 3. 长度检查
uint16_t total_len = ntohs(hdr->length);
if (total_len < sizeof(struct ProtocolHeader)) {
log_warn("length too small: %u", total_len);
return -1;
}
if (total_len > MAX_PAYLOAD + sizeof(struct ProtocolHeader)) {
log_warn("length too large: %u", total_len);
return -1;
}
if (raw_len < total_len) {
log_warn("incomplete packet: need %u, have %zu", total_len, raw_len);
return -1;
}
// 4. CRC校验
uint32_t expected_crc = calculate_crc32(raw + sizeof(uint32_t),
total_len - sizeof(uint32_t));
uint32_t received_crc = ntohl(hdr->crc32);
if (expected_crc != received_crc) {
log_warn("CRC mismatch: expected 0x%08X, got 0x%08X",
expected_crc, received_crc);
return -1;
}
// 5. 类型检查
uint8_t type = hdr->type;
if (type >= MAX_PACKET_TYPE) {
log_warn("unknown packet type: %u", type);
return -1;
}
// 解析成功
out->type = type;
out->length = total_len - sizeof(struct ProtocolHeader);
memcpy(out->data, raw + sizeof(struct ProtocolHeader), out->length);
return 0;
}
28.4 超时机制:别让程序死等
网络通信最怕什么?最怕程序卡在 recv() 上不动了。服务器宕机、网线断了、防火墙丢包……任何一个环节出问题,你的程序就可能永远等下去。
我个人的习惯是:每个网络操作都必须有超时。有三种实现方式:
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| setsockopt SO_RCVTIMEO | 简单,一行代码搞定 | 精度不高,不能区分超时和错误 | 简单的客户端程序 |
| select/poll | 可同时监控多个socket,精度高 | 代码稍复杂 | 需要同时处理多个连接 |
| 非阻塞 + epoll | 高性能,适合大量连接 | 代码最复杂 | 高并发服务器 |
对于嵌入式设备,我推荐用 select,它简单可靠:
int recv_with_timeout(int sock, void *buf, size_t len, int timeout_ms) {
fd_set readfds;
struct timeval tv;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
int ret = select(sock + 1, &readfds, NULL, NULL, &tv);
if (ret == 0) {
errno = ETIMEDOUT;
return -1; // 超时
}
if (ret == -1) {
return -1; // 错误
}
// socket可读,执行recv
return recv(sock, buf, len, 0);
}
还有一个容易被忽略的点:连接超时。connect() 默认超时时间很长(Linux 上大约 20-30 秒)。如果对方服务器没开,你的程序就会卡住。解决办法是把 socket 设为非阻塞,然后用 select 等它:
int connect_with_timeout(int sock, const struct sockaddr *addr,
socklen_t addrlen, int timeout_ms) {
// 设为非阻塞
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
int ret = connect(sock, addr, addrlen);
if (ret == 0) {
// 立即连接成功
fcntl(sock, F_SETFL, flags); // 恢复阻塞模式
return 0;
}
if (errno != EINPROGRESS) {
return -1; // 真正的错误
}
// 等待连接完成
fd_set writefds;
struct timeval tv;
FD_ZERO(&writefds);
FD_SET(sock, &writefds);
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
ret = select(sock + 1, NULL, &writefds, NULL, &tv);
if (ret <= 0) {
return -1; // 超时或错误
}
// 检查连接是否成功
int error = 0;
socklen_t errlen = sizeof(error);
getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &errlen);
if (error != 0) {
errno = error;
return -1;
}
fcntl(sock, F_SETFL, flags); // 恢复阻塞模式
return 0;
}
28.5 知识体系总览
下面这张图总结了网络编程安全的四个核心维度:
这四个维度缺一不可。socket错误处理保证程序不会因为一次失败就崩溃;数据序列化保证不同平台之间能正确通信;协议解析防御防止恶意数据攻击;超时机制保证程序不会死锁。把这四点做好了,你的网络程序才算真正健壮。
公众号:蓝海资料掘金营,微信deep3321