4、基于Socket的屏幕间通信:Server端实现、Client端实现、心跳包与重连机制
多屏互动,说白了就是让车里的几块屏幕能「说上话」。
中控屏要告诉仪表盘「导航开始了」,副驾屏要把视频流推给后排屏。这些消息怎么传?我个人的习惯是,直接用Socket。为什么?因为它够底层、够灵活,而且几乎所有车载系统都原生支持。
这一章,我们就来手撸一个基于Socket的屏幕间通信方案。包括Server端怎么写、Client端怎么连,以及最容易被忽视的——心跳包和重连机制。
4.1 为什么选Socket而不是其他方案?
你可能会问:现在不是有DDS、Some/IP这些中间件吗?
没错,那些方案在大型SOA架构里确实很香。但咱们做多屏互动,场景相对简单——就是几块屏幕之间传指令、传状态。用Socket,代码量少、依赖少、调试也方便。
我在项目里遇到过,用DDS做屏幕间通信,光配置QoS策略就折腾了两天。后来换成Socket,半天就搞定了。当然,如果你要上量产项目,该用DDS还是得用。但作为学习原型,Socket绝对是最佳切入点。
- 屏幕数量 ≤ 5块
- 通信频率 ≤ 100Hz
- 数据包大小 ≤ 64KB
- 不需要复杂的服务发现
满足以上条件,Socket方案完全够用。
4.2 Server端实现:谁来做「消息中转站」?
多屏通信,一般需要一个Server端。它不一定是单独的硬件,可以是某块屏幕上的一个后台进程。
我建议把中控屏作为Server。原因很简单:中控屏通常性能最强、内存最大,而且一直在线。
4.2.1 基础Server框架
先看一个最简的Server实现。我用的是Linux Socket API,因为车载系统基本都是Linux内核。
// server.c - 车载多屏通信Server端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
// 客户端列表
typedef struct {
int sockfd;
struct sockaddr_in addr;
char client_id[32];
int is_connected;
} ClientNode;
ClientNode clients[MAX_CLIENTS];
pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER;
// 初始化Server
int init_server() {
int server_fd;
struct sockaddr_in address;
// 创建Socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return -1;
}
// 设置SO_REUSEADDR,防止端口被占用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt failed");
return -1;
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
return -1;
}
if (listen(server_fd, MAX_CLIENTS) < 0) {
perror("listen failed");
return -1;
}
printf("Server started on port %d\n", PORT);
return server_fd;
}
// 处理客户端消息的线程
void *handle_client(void *arg) {
int client_index = *(int *)arg;
ClientNode *client = &clients[client_index];
char buffer[BUFFER_SIZE];
while (1) {
memset(buffer, 0, BUFFER_SIZE);
int bytes_read = read(client->sockfd, buffer, BUFFER_SIZE - 1);
if (bytes_read <= 0) {
// 客户端断开
printf("Client %s disconnected\n", client->client_id);
client->is_connected = 0;
close(client->sockfd);
break;
}
// 广播消息给所有其他客户端
pthread_mutex_lock(&clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i].is_connected && i != client_index) {
write(clients[i].sockfd, buffer, bytes_read);
}
}
pthread_mutex_unlock(&clients_mutex);
}
return NULL;
}
int main() {
int server_fd = init_server();
if (server_fd < 0) return -1;
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
while (1) {
int new_socket = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
if (new_socket < 0) {
perror("accept failed");
continue;
}
// 找个空位放新客户端
pthread_mutex_lock(&clients_mutex);
int slot = -1;
for (int i = 0; i < MAX_CLIENTS; i++) {
if (!clients[i].is_connected) {
slot = i;
break;
}
}
if (slot == -1) {
printf("Max clients reached, rejecting\n");
close(new_socket);
pthread_mutex_unlock(&clients_mutex);
continue;
}
clients[slot].sockfd = new_socket;
clients[slot].addr = client_addr;
clients[slot].is_connected = 1;
sprintf(clients[slot].client_id, "client_%d", slot);
pthread_mutex_unlock(&clients_mutex);
// 启动处理线程
pthread_t thread_id;
int *arg = malloc(sizeof(int));
*arg = slot;
pthread_create(&thread_id, NULL, handle_client, arg);
pthread_detach(thread_id);
printf("New client connected: %s\n", clients[slot].client_id);
}
close(server_fd);
return 0;
}
SO_REUSEADDR这个选项一定要加。我在调试时经常需要重启Server,不加这个选项,端口会被系统占用好几分钟,很烦人。
4.2.2 消息格式设计
光传原始字节流肯定不行。我们需要定义一套消息格式。我习惯用JSON,虽然解析开销大一点,但调试方便。
// 消息结构示例
{
"type": "navigation",
"from": "center_display",
"to": "instrument_cluster",
"timestamp": 1701234567,
"payload": {
"action": "start_navigation",
"destination": "北京西站",
"distance": 12500
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | 消息类型,如navigation、media、system |
| from | string | 发送方标识 |
| to | string | 接收方标识,broadcast表示广播 |
| timestamp | int64 | Unix时间戳,用于消息排序 |
| payload | object | 具体业务数据 |
4.3 Client端实现:怎么优雅地连上Server?
Client端相对简单,就是连上Server,然后收发消息。但这里有个坑:车载环境下,网络不是一直稳定的。屏幕启动顺序也不固定——可能仪表盘先启动,中控屏后启动。
4.3.1 带重试的Client连接
// client.c - 带自动重连的Client端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define SERVER_IP "192.168.1.100"
#define SERVER_PORT 8888
#define MAX_RETRY 5
#define RETRY_INTERVAL 2 // 秒
int connect_to_server() {
int sock = 0;
struct sockaddr_in serv_addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERVER_PORT);
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
return -1;
}
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
close(sock);
return -1;
}
return sock;
}
// 带重试的连接函数
int connect_with_retry() {
int sock;
int retry_count = 0;
while (retry_count < MAX_RETRY) {
sock = connect_to_server();
if (sock > 0) {
printf("Connected to server successfully\n");
return sock;
}
retry_count++;
printf("Connection attempt %d failed, retrying in %d seconds...\n",
retry_count, RETRY_INTERVAL);
sleep(RETRY_INTERVAL);
}
printf("Failed to connect after %d attempts\n", MAX_RETRY);
return -1;
}
int main() {
int sock = connect_with_retry();
if (sock < 0) {
return -1;
}
// 收发消息循环
char buffer[1024];
while (1) {
memset(buffer, 0, 1024);
int bytes = read(sock, buffer, 1023);
if (bytes <= 0) {
printf("Server disconnected, reconnecting...\n");
close(sock);
sock = connect_with_retry();
if (sock < 0) {
break;
}
continue;
}
// 处理收到的消息
printf("Received: %s\n", buffer);
}
close(sock);
return 0;
}
重试间隔不要设得太短。我曾经设成200ms,结果屏幕启动时,Server还没起来,Client疯狂重试,把CPU吃满了。建议至少2秒以上。
4.4 心跳包与重连机制:让连接「活」起来
TCP本身有保活机制,但默认超时是2小时。在车上,这显然不行。屏幕可能因为休眠、信号干扰等原因断开,我们需要快速感知并恢复。
4.4.1 为什么需要心跳?
说白了,就是让双方定期互报平安。Server知道Client还活着,Client也知道Server没挂。
我见过一个真实案例:某款车的副驾屏播放视频时突然卡住,就是因为网络短暂断开,但双方都没检测到,数据堵在缓冲区里出不去。
4.4.2 心跳包实现
// 心跳包结构
typedef struct {
int type; // 0: ping, 1: pong
int seq; // 序列号
long timestamp; // 时间戳
} HeartbeatPacket;
// Server端心跳检测线程
void *heartbeat_monitor(void *arg) {
while (1) {
sleep(5); // 每5秒检查一次
pthread_mutex_lock(&clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i].is_connected) {
// 发送ping
HeartbeatPacket ping = {0, i, time(NULL)};
write(clients[i].sockfd, &ping, sizeof(ping));
// 设置超时等待pong
struct timeval tv = {2, 0}; // 2秒超时
setsockopt(clients[i].sockfd, SOL_SOCKET, SO_RCVTIMEO,
&tv, sizeof(tv));
HeartbeatPacket pong;
int ret = read(clients[i].sockfd, &pong, sizeof(pong));
if (ret <= 0 || pong.type != 1) {
// 心跳超时,标记断开
printf("Heartbeat timeout for %s\n", clients[i].client_id);
clients[i].is_connected = 0;
close(clients[i].sockfd);
}
}
}
pthread_mutex_unlock(&clients_mutex);
}
return NULL;
}
// Client端心跳响应
void *heartbeat_responder(void *arg) {
int sock = *(int *)arg;
HeartbeatPacket packet;
while (1) {
int ret = read(sock, &packet, sizeof(packet));
if (ret <= 0) break;
if (packet.type == 0) { // 收到ping
HeartbeatPacket pong = {1, packet.seq, time(NULL)};
write(sock, &pong, sizeof(pong));
}
}
return NULL;
}
4.4.3 重连策略
心跳检测到断开后,Client需要自动重连。但重连不是简单的「断了就重连」,需要讲究策略。
| 场景 | 重连策略 | 说明 |
|---|---|---|
| 首次连接失败 | 指数退避重试 | 2s → 4s → 8s → 16s → 30s(上限) |
| 运行中断开 | 快速重试3次 | 每次间隔1秒,之后转为指数退避 |
| 长时间断开 | 降低重试频率 | 每30秒重试一次,避免耗电 |
| 屏幕休眠唤醒 | 立即重连 | 配合系统休眠唤醒事件触发 |
// 指数退避重连实现
int reconnect_with_backoff() {
int base_interval = 2; // 初始2秒
int max_interval = 30; // 最大30秒
int attempt = 0;
while (1) {
int sock = connect_to_server();
if (sock > 0) {
printf("Reconnected successfully\n");
return sock;
}
// 计算等待时间
int wait = base_interval * (1 << attempt);
if (wait > max_interval) wait = max_interval;
if (attempt < 3) wait = 1; // 前3次快速重试
printf("Reconnect attempt %d, waiting %d seconds\n", attempt + 1, wait);
sleep(wait);
attempt++;
}
}
重连成功后,最好发一条「恢复连接」的消息给Server。Server收到后,可以把断连期间缓存的消息推送给Client。这样用户体验会好很多,不会出现「导航到一半突然没声音了」的情况。
4.5 实战中的坑与避坑指南
做多屏通信,我踩过的坑不少。挑几个典型的说说:
- 粘包问题:TCP是流式协议,多条消息可能粘在一起。解决方案是定义消息边界,比如固定头部+长度字段,或者用特殊分隔符。我习惯用4字节长度头。
- 缓冲区溢出:接收缓冲区一定要够大。我曾经设成256字节,结果一条导航信息就超了,导致数据截断。建议至少4KB起步。
- 线程安全:多个Client线程同时写同一个Socket,不加锁会出乱序。用互斥锁保护写操作,或者每个连接单独一个发送队列。
- IP地址变化:车载网络可能用DHCP,IP会变。建议用主机名或者mDNS做服务发现,别写死IP。
不要在UI线程里做Socket操作。阻塞的read/write会卡住界面,用户会感觉「屏幕死机了」。一定要用独立线程或者异步IO。
4.6 小结
这一章我们完整走了一遍Socket通信的实现流程。从Server端的消息中转,到Client端的自动重连,再到心跳包的保活机制。这些东西看起来基础,但做好了,整个多屏互动的底座就稳了。
下一章,我们会在这个基础上,加入具体的业务逻辑——比如导航信息从中控屏同步到仪表盘,视频流从副驾屏推送到后排屏。到时候你会发现,底层的Socket通信写好了,上层业务就是「搭积木」的事。
嗯,今天就到这儿。代码都在上面了,建议你动手跑一遍。遇到问题,欢迎来交流。