第3章:网络基础与Socket编程

各位好,我是老张。今天咱们聊聊嵌入式安防里最基础、也最绕不开的一块——网络编程。说实话,我见过太多工程师,协议栈移植做得挺好,一到Socket编程就翻车。为什么?因为底层细节没吃透。

这一章,我会带你把TCP/IP协议栈、Socket API、阻塞与非阻塞、还有select/poll/epoll这些模型,一个一个掰开揉碎了讲。嗯,都是我在项目里踩过的坑,你直接拿去用就行。

3.1 TCP/IP协议栈回顾

先简单回顾一下。TCP/IP协议栈,说白了就是四层模型:应用层、传输层、网络层、网络接口层。我们做嵌入式安防,最常打交道的是传输层——TCP和UDP。

我个人习惯,把TCP看作“挂号信”——必须确认收到,丢了重发。UDP呢,就是“明信片”——扔出去就不管了。安防场景里,视频流、报警数据,大部分用TCP保证可靠性;但有些实时性要求高的,比如门禁控制,偶尔也用UDP。

我记得有一次,客户反馈摄像头画面偶尔卡顿。查了半天,发现是TCP重传机制在作祟——网络抖动时,TCP拼命重传,反而把缓冲区堵死了。后来我们改成UDP加应用层ACK,问题就解决了。所以,别迷信TCP,它也有自己的脾气。

核心要点:嵌入式安防中,TCP用于控制指令和关键数据,UDP用于实时音视频流。选型时,要权衡可靠性和实时性。

3.2 Socket API详解

Socket,就是网络通信的“插座”。你想想看,两个设备要通信,总得有个接口吧?Socket就是这个接口。

常用的API就那么几个:socket()bind()listen()accept()connect()send()recv()。流程也很固定:服务端先创建socket,绑定地址,监听,然后accept等待客户端连接;客户端创建socket,connect连上去,然后收发数据。

这里有个坑,我必须要说。很多新手在bind()的时候,喜欢用INADDR_ANY,觉得省事。但如果你有多网卡,比如一个以太网、一个4G模块,INADDR_ANY会绑定到所有接口。我曾经在项目里,就因为没指定具体IP,导致数据从4G模块走了,白白浪费流量。所以,建议明确指定IP地址。

// 服务端示例
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = inet_addr("192.168.1.100"); // 明确指定IP
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, 5);
int clientfd = accept(sockfd, NULL, NULL);
// 然后收发数据...
小技巧:嵌入式设备内存有限,记得设置socket的接收和发送缓冲区大小,用setsockopt()SO_RCVBUFSO_SNDBUF。我一般设成16KB或32KB,够用又不浪费。

3.3 阻塞与非阻塞模式

阻塞模式,就是“死等”。你调用recv(),如果没有数据,线程就卡在那里,直到数据来了才返回。非阻塞模式呢,就是“问一下,没有就拉倒”。recv()立即返回,有数据就返回数据长度,没有就返回-1,并设置errnoEAGAINEWOULDBLOCK

在安防设备里,我建议主控线程用非阻塞模式。为什么?因为你要同时处理多个事情——网络数据、传感器输入、报警输出。如果阻塞在recv()上,其他事情就全耽误了。我曾经在一个门禁项目里,用了阻塞模式,结果网络一卡,整个门禁系统都响应不了,差点被客户骂死。

设置非阻塞很简单:

// 方法1:创建时设置
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

// 方法2:创建后设置
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
注意:非阻塞模式下,accept()connect()也会变成非阻塞。特别是connect(),它会立即返回,你需要用select()poll()去检查连接是否成功。别问我怎么知道的,都是泪。

3.4 select/poll/epoll模型对比

好了,重点来了。非阻塞模式解决了“死等”的问题,但你怎么知道哪个socket有数据了?总不能一个个去轮询吧?这时候就需要I/O多路复用模型了。

三种模型,我一个个说。

3.4.1 select模型

select是最古老的,几乎所有平台都支持。它的原理是:你把一堆socket丢给select,它告诉你哪些可读、可写、有异常。然后你再一个个去处理。

但select有个硬伤——它最多只能监视1024个文件描述符。在安防系统里,如果你要同时管理几百个摄像头,1024根本不够用。而且,每次调用select,都要把整个fd_set从用户态拷贝到内核态,效率很低。

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
struct timeval tv = {5, 0}; // 5秒超时
int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(sockfd, &readfds)) {
    // 有数据可读
}

3.4.2 poll模型

poll解决了select的1024限制。它用链表来管理文件描述符,理论上没有上限。但poll依然有性能问题——每次调用,还是要遍历所有fd,而且也是全量拷贝。

我记得在做一个NVR项目时,同时管理了500多个连接。用poll,每次循环都要遍历500多个fd,CPU占用率直接飙到80%。后来换成epoll,CPU降到15%。差距就是这么明显。

3.4.3 epoll模型

epoll是Linux下的王者。它有三个核心函数:epoll_create()epoll_ctl()epoll_wait()

epoll的高明之处在于:它只返回“有事件”的fd,而不是让你遍历所有fd。而且,它用mmap映射内核和用户空间,避免了数据拷贝。说白了,就是“只通知你,不让你白干活”。

int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

struct epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < nfds; i++) {
    if (events[i].events & EPOLLIN) {
        // 处理sockfd的数据
    }
}
选型建议:
  • 连接数少(<100),且跨平台要求高:用select
  • 连接数中等(100-1000),且不想用Linux特性:用poll
  • 连接数多(>1000),且是Linux平台:必须用epoll

在嵌入式安防设备里,我几乎只用epoll。因为摄像头数量动不动就几百上千,select和poll根本扛不住。

3.5 实战中的避坑指南

最后,分享几个我踩过的坑,你记一下:

  • 信号打断:在阻塞模式下,recv()可能被信号打断,返回-1且errnoEINTR。记得重新调用,或者用sigaction设置SA_RESTART
  • TCP粘包:TCP是流协议,没有边界。你发两次数据,接收方可能一次就收完了。解决方案:自定义应用层协议,比如固定包头+包体长度。
  • 心跳机制:TCP长连接可能被中间设备断开,但应用层不知道。我习惯在应用层加心跳包,每30秒发一次,连续3次没收到就断开重连。
  • 非阻塞connect:非阻塞模式下,connect()返回-1且errnoEINPROGRESS。你需要用select()epoll监听可写事件,然后检查SO_ERROR来判断连接是否成功。
我的习惯:在嵌入式安防设备里,我通常用epoll + 非阻塞socket + 应用层心跳。这套组合拳,能处理上千个连接,CPU占用率还不到20%。你试试看,绝对好用。

好了,这一章就到这里。网络编程是嵌入式安防的基石,你把它吃透了,后面协议栈移植和优化就轻松多了。下一章,咱们聊聊具体的协议栈移植——LwIP和uIP,看看怎么把它们塞进你的MCU里。