做嵌入式实时系统开发的人,多少都会碰到VxWorks。我最近在给某型控制器做网络模块,需要把实时任务里的采集数据通过UDP发给上位机,同时接收上位机的控制指令。原以为VxWorks上的socket编程和Linux差不了多少,真把代码烧进板子才发现,字节序、地址结构、任务阻塞、缓冲区、路由网卡……随便一个细节没注意,调试串口前就得耗一下午。这篇博客把我实现VxWorks下UDP通信代码的整个过程写透了,包括可直接拷贝的模板、排错思路和性能优化,给正在做飞控地面站、工控采集、设备间通信的同行一个参考。
1. VxWorks网络编程前必须搞清楚的几个底层差异
很多人在Linux上写UDP写得顺,转VxWorks后第一反应是“import头文件、调socket就行”。实际上VxWorks的BSD socket接口虽然是标准风格,但底层实时内核的特性决定了它有自己一套脾气。
1.1 网络协议栈不是默认就位
VxWorks镜像里如果没有包含网络组件,你调用socket()直接就返回ERROR,连编译都通不过。刚开始我拿一个精简BSP跑,串口终端一直报“socket create error”,查了一圈才发现问题在BSP配置上。因此准备工作里第一件事就是确认镜像里包含网络相关的INCLUDE组件。
# VxWorks shell里检查网络任务 i help或者更直接的方式,在config.h/项目配置里确认包含:
#define INCLUDE_NETWORK #define INCLUDE_UDP #define INCLUDE_BSD_SOCKET有些老工程还有INCLUDE_TCP,因为TCP和UDP共用协议栈,别漏了。另外,如果目标板上电后需要手动挂IP,可以在启动脚本或usrNetInit后执行:
ifconfig "fei0" inet 192.168.1.20 up我用的某款BSP网卡设备叫“fei0”,具体名称和BSP里的驱动绑定有关。在应用代码动态配置IP也可以,但建议大家直接放在启动脚本里,更可控。
1.2 sockaddr_in必须清零的版本学
在Linux上,你忘了bzero/初始化结构体可能也能跑,因为协议栈会自动忽略sin_zero。VxWorks不同版本对sockaddr_in的处理细节有差别,尤其在老版本5.5时代,sin_zero字段不为0时会直接影响bind的结果。
我现在的习惯是,任何socket地址结构,创建后第一件事:
struct sockaddr_in addr; memset((char *)&addr, 0, sizeof(addr));这一行别省。地址结构正确填了sin_family、sin_port、sin_addr后,即使sin_zero没清空,某些严格模式下也会返回EINVAL。我遇到过bind失败,errno没提示任何有用的东西,最后就是靠全清零解决的。有些新同事总嫌“多余”,后来在真实硬件上栽了跟头才明白。
1.3 字节序的“反直觉”
VxWorks跑在x86、ARM、PPC上都有,主流平台几乎全是小端。网络协议规定传输用大端。端口号和IP路径写进sockaddr_in之前必须转换。
addr.sin_port = htons(5000); // 主机序转网络序 addr.sin_addr.s_addr = htonl(INADDR_ANY);仅对于远程IP,我后来更偏好用inet_addr(),它返回的已经是网络序,直接赋值给s_addr即可:
remote.sin_addr.s_addr = inet_addr("192.168.1.10");新手最容易犯的错误是给端口直接赋值5000,不调htons。结果本机发送端口、接收端口完全错乱,双方都连不上。代码里如果把端口打出来看,5000变成0x1388,以小端方式解释,网络序就是0x8813,也就是34835。所以接收方明明绑定了5000,却永远收不到发到34835的包。
2. 一套能直接落地的UDP收发代码模板
下面这套模板我整理自实际项目,经过VxWorks 6.9/7.0环境验证。为了紧凑,做了简化,但关键函数都保留。
2.1 发送端:创建socket到sendto的完整路径
VxWorks里UDP发送的完整过程是:socket()创建一个数据报套接字,bind()把本地IP和端口固定,sendto()把数据发往目标地址。
#include <vxWorks.h> #include <sockLib.h> #include <inetLib.h> #include <netLib.h> #include <arpa/inet.h> #include <stdio.h> int udp_sendto(const char *targetIp, unsigned short targetPort, const char *data, int len) { int fd; struct sockaddr_in localAddr, remoteAddr; int addrLen; int ret; fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (fd == ERROR) { printf("socket() failed, errno=%d\n", errno); return -1; } memset((char *)&localAddr, 0, sizeof(localAddr)); localAddr.sin_family = AF_INET; localAddr.sin_port = htons(0); // 0表示由系统分配 localAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意本机接口 ret = bind(fd, (struct sockaddr *)&localAddr, sizeof(localAddr)); if (ret == ERROR) { printf("bind() failed, errno=%d\n", errno); close(fd); return -1; } memset((char *)&remoteAddr, 0, sizeof(remoteAddr)); remoteAddr.sin_family = AF_INET; remoteAddr.sin_port = htons(targetPort); remoteAddr.sin_addr.s_addr = inet_addr(targetIp); addrLen = sizeof(remoteAddr); ret = sendto(fd, data, len, 0, (struct sockaddr *)&remoteAddr, addrLen); if (ret == ERROR) { printf("sendto() failed, errno=%d\n", errno); close(fd); return -1; } close(fd); return ret; // 实际发送的字节数 }注意,UDP发送不需要像TCP那样connect,直接用sendto指定目标地址。也可以先connect到目标IP,然后使用send(),但sendto足够简单,也符合“无连接”的协议语义。
2.2 接收端:select超时控制的recvfrom
UDP接收的标准流程是socket+bind+recvfrom。但recvfrom默认阻塞,如果对方一直不发数据,任务会永远卡在accept/recv上。我在实时系统里通常用select实现超时控制,这样收发循环里可以定期做其他事,比如喂狗、打印状态。
int udp_recv_timeout(int port, char *buf, int maxLen, int timeoutMs) { int fd; struct sockaddr_in localAddr, remoteAddr; int remoteLen = sizeof(remoteAddr); int ret; fd_set rfd; struct timeval tv; fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (fd == ERROR) { printf("socket() failed, errno=%d\n", errno); return -1; } memset((char *)&localAddr, 0, sizeof(localAddr)); localAddr.sin_family = AF_INET; localAddr.sin_port = htons(port); localAddr.sin_addr.s_addr = htonl(INADDR_ANY); ret = bind(fd, (struct sockaddr *)&localAddr, sizeof(localAddr)); if (ret == ERROR) { printf("bind() failed, errno=%d\n", errno); close(fd); return -1; } FD_ZERO(&rfd); FD_SET(fd, &rfd); tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; ret = select(fd + 1, &rfd, NULL, NULL, &tv); if (ret == ERROR) { printf("select() failed, errno=%d\n", errno); close(fd); return -1; } if (ret == 0) { // 超时,没有数据 close(fd); return 0; } ret = recvfrom(fd, buf, maxLen, 0, (struct sockaddr *)&remoteAddr, &remoteLen); if (ret == ERROR) { printf("recvfrom() failed, errno=%d\n", errno); close(fd); return -1; } close(fd); return ret; }VxWorks的select和Linux很接近,但有一点要注意:fd_set的类型定义在selectLib.h里,必须明确包含。此外,老版本VxWorks的select对fd值上限有限制,如果需要同时监听多个fd,建议重新评估设计。
2.3 多任务并发共享socket的正确姿势
项目里一般会有多个任务同时收发数据。如果让两个任务共用同一个fd,一个在recvfrom阻塞,另一个同时调用sendto,部分驱动实现会互斥,但更多时候会出现竞争,导致数据异常。我踩到过一个问题:发送任务A在网络忙时,接收任务B的recvfrom偶尔返回错误,查下来就是共享fd没有加锁。
解决方案有两种:
- 每个任务单独创建socket,各自bind到不同端口或复用同一端口(通过SO_REUSEADDR)。
- 一个发送fd、一个接收fd,给公共资源加互斥信号量。
我推荐第二种。发送和接收本来就是两个不同方向,没必要为它们之间加锁。真正需要保护的是“同一个fd被多个任务同时write”这种情况。实在要共用,就包一个信号量:
SEM_ID g_udpSem; g_udpSem = semMCreate(SEM_Q_PRIORITY); // 发送时 semTake(g_udpSem, WAIT_FOREVER); sendto(fd, ...); semGive(g_udpSem);实际项目中,我会把UDP通信封装成一个模块,对外只留udpInit、udpSend、udpRecv三个接口,内部处理好fd、锁、缓冲区,主任务完全不用关心协议细节。
3. 真实项目里最容易踩的五个坑
代码本身写起来不难,难的是出了问题怎么定位。下面这几个坑我在VxWorks上做UDP通信时都踩过,有的甚至反复踩。提前给你排掉,省几包烟。
3.1 bind返回ERROR,errno却什么都没有
VxWorks下很多错误其实有errno,但有些BSP的驱动在失败时没有调用当前任务的errnoSet。结果就是你打印errno得到0,排查方向直接歪掉。
我后来养成了一个习惯:不仅打印errno,还要打印返回值和调用位置。
printf("bind failed, ret=%x errno=%d\n", ret, errno);如果errno为0,先检查sin_zero清零没有。其次是端口被占用。VxWorks下多个socket绑定同一端口,即使设置了SO_REUSEADDR,某些驱动实现也不支持,需要确保端口唯一。再者,检查bind传入的length是否为sizeof(struct sockaddr_in)。很多老代码喜欢用sizeof(struct sockaddr),这两个在VxWorks上恰好一样,但显式写sockaddr_in更安全。
3.2 UDP从不告诉你“发送失败”
TCP发送一个包给对方,如果对端没收到,TCP会重传,还打个日志。UDP不会。sendto返回成功,不代表对端真的收到了。我在调试一个无线链路时就栽在这上面:板子明明调用了sendto,返回值也正常,但对端电脑上抓包什么都看不到。
后来排查发现,目标网段配置错了,板子和上位机不在同一网段,路由器又丢弃了广播报文。更隐蔽的是,当网络驱动走到ARP查询但迟迟拿不到MAC地址时,sendto可能会阻塞几秒然后返回成功,但数据包实际没发出去。VxWorks的ARP缓存如果老化或冲突,就会这样。
结论:UDP通信必须结合抓包来验证,不能只看sendto返回值。在应用层做确认应答,或者至少在上位机Wireshark里确认是否有数据到达。
3.3 接收缓冲不足,UDP静默丢包
UDP的接收缓冲区大小由SO_RCVBUF决定。VxWorks默认值在不同版本、不同BSP下不一样。我碰上过某型板卡默认只有8KB,上位机一旦突发大批量数据,recvfrom就会偶发丢包,而丢包一点痕迹都没有。
排查方法很简单,在代码里用getsockopt读数:
int rcvbuf; int optLen = sizeof(rcvbuf); getsockopt(fd, SOL_SOCKET, SO_RCVBUF, (char *)&rcvbuf, &optLen); printf("SO_RCVBUF = %d\n", rcvbuf);如果掉包频发,直接调大:
int newBuf = 64 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, (char *)&newBuf, sizeof(newBuf));注意,有些协议栈实现会把你设置的值翻倍,因为内核要留出一部分做预留空间。别看到64K设进去变成131072就觉得代码写错了。
3.4 绑定了正确IP,包却从另一张网卡出去
多网卡设备上,socket绑定本机IP是192.168.1.20,路由表里默认出口却是192.168.9.1,结果sendto往外发时源IP变成了另一个接口的IP。上位机如果只按源IP过滤,就会丢包。
VxWorks的路由表可以用route命令查看,在shell里输入:
routeShow如果默认路由指向的接口不对,要么改路由,要么在bind时指定正确的本地IP,但源IP的选择最终还是由路由出口决定。最稳妥的做法是让目标网段的路由走对应接口,同时把socket的本地地址绑定到那个接口的IP上。具体操作依赖BSP,但核心思路是:UDP数据包源IP不是你“想着”哪个IP,而是路由表决定的出口IP。
3.5 任务栈太小,网络任务卡死
VxWorks下每个任务都有独立栈。UDP接收任务如果栈里放了大的局部缓冲区,比如char buf[4096],再叠加调用select、recvfrom的库函数栈消耗,任务栈很容易溢出。栈溢出后表现千奇百怪:有时候任务挂起,有时候系统直接重启,有时候莫名异常。
我把接收任务的栈从4KB调到16KB后,问题立刻消失。经验是,网络相关任务栈起步4KB真的不够,至少8KB,能上16KB最好。另外,用malloc动态分配缓冲区,而不是直接开数组,也能减小任务栈压力。
4. 验证UDP通路:从板内回环到抓包对照
代码写完,第一时间不是拿到实物上联网调,而是可以做分层验证。
4.1 先做板内自回环
如果板子上同时有两个网络接口,或者支持loopback接口,可以先在板内验证协议栈和代码本身。
我自己最常用的方式:在同一块板上用taskSpawn开两个任务,任务A绑定端口6000,往本机IP(比如192.168.1.20)的6001端口发送,任务B绑定6001接收,然后打印接收数据长度。只要能收发成功,说明socket创建、bind、字节序都没问题。
但要注意,有些精简BSP没有启用loopback接口,需要先用ifconfig创建设备,例如:
ifconfig "lo0" inet 127.0.0.1 up如果lo0不存在,也可以用目标板实际网卡IP做回环,数据会在协议栈内部转一圈,也能验证大部分路径。
4.2 与上位机对发的抓包校验
板内回环过了,再连上位机。先用网线把板子和电脑直连,或者接到同一个二层交换机上。电脑网卡设置成和板子同网段,比如192.168.1.x,板子配置192.168.1.20。
对上位机侧,最简单的方式是开一个UDP调试工具监听端口,比如监听9000。板子向192.168.1.10:9000发送数据,如果收不到,立刻在电脑上用Wireshark抓包:
- 看有没有ARP请求/应答,确认二层链路通不通;
- 看有没有发往目标IP的UDP包,确认三层路由对不对;
- 看源IP和源端口是否符合预期。
Wireshark的过滤栏写:
udp.port == 9000这样就能快速判断问题出在板子发送、链路转发还是电脑接收。
4.3 日志打印IP和端口最容易出错的地方
调试时把收到的数据来源地址打印出来很正常,但很多人在打印IP时翻车。因为sin_addr.s_addr是大端存放的,如果在x86小端机器上强转unsigned long再去打印,会发现IP完全错乱。
我直接用字节方式打印,省心:
void printSockaddr(struct sockaddr_in *addr) { unsigned char *ip = (unsigned char *)&addr->sin_addr.s_addr; printf("from %d.%d.%d.%d : %d\n", ip[0], ip[1], ip[2], ip[3], ntohs(addr->sin_port)); }因为s_addr在网络序下就是大端,字节数组从低地址开始正好是IP第一段到第四段。端口再ntohs转回主机序打印,就完全正确了。
5. 性能实测与优化:实时系统里UDP不能只追求“通”
在实时系统里,UDP通信除了通与不通,还要关注吞吐量、抖动和CPU占用。几个实测过的优化点分享出来。
5.1 别被默认缓冲区坑了
前面说过接收缓冲会丢包,同样发送缓冲也可能因为填充速率大于硬件发送能力而触发阻塞或丢包。如果是批量上报数据,建议发送频率稳定,且一次别塞太多。UDP没有拥塞控制,数据量超过链路带宽就是硬丢。
一个直观的调整项是SO_SNDBUF:
int newSndBuf = 32 * 1024; setsockopt(fd, SOL_SOCKET, SO_SNDBUF, (char *)&newSndBuf, sizeof(newSndBuf));注意,VxWorks的缓冲设置是“尽力而为”,最终可能受限于内核内存池大小。如果系统可用内存不足,setsockopt会返回失败。在调整前先查系统空闲内存:
memShow5.2 任务优先级和调度策略
UDP收发任务不能一味地设为最高优先级。如果优先级太高,它会抢占其他实时控制任务,导致控制系统不稳定;太低又可能被其他高频任务打断,造成收发延迟抖动。
我的经验是:把发送任务设为略低于关键控制任务的优先级,比如关键控制优先级90,发送/接收任务100或110。接收数据可以通过信号量通知处理任务,而不是让接收任务本身就是处理任务。这样既保证数据及时读取,又不会长时间霸占CPU。
5.3 减少数据拷贝的实用建议
recvfrom默认把内核缓冲区里的数据拷贝到用户缓冲区,这个过程在高速收发时是有成本的。减少拷贝最直接的方法是复用缓冲区,不要在循环里反复malloc、free。我在项目中维护了一块静态接收缓冲区和发送缓冲区,数据先填进去再sendto,收也一样,收到后直接处理,处理完再复用。
如果数据量极大,性能依然吃紧,就需要调研板卡驱动是否支持零拷贝IO,或者用内存共享方式绕过协议栈。但一般控制类通信用不上这么大流量,把代码写得干净,避免听信网上的花哨方案,反而更可靠。
最后说几句回到VxWorks UDP通信本身
我从最早调不通到后来顺手封装,最大的体会是:VxWorks下UDP通信代码不复杂,复杂的是排查环境。任何一次网络通信都有四层链路——物理、二层、三层、四层,问题出现时按顺序查,别一开始就怀疑代码。
如果你也是第一次在VxWorks上调UDP,建议先把本文的发送、接收模板跑通,再做板内回环,然后再对接上位机。只要字节序、缓冲区、路由、栈大小这四关都过了,剩下的就是业务逻辑了。等真正跑稳之后,再考虑把收发代码抽象成模块,后面换TCP、换接口都能少改很多。祝大家一版跑通,别像我当初那样在调试串口前耗掉太多光阴。