简介:这份VxWorks串口通信示例程序面向嵌入式开发者与实时系统学习者,基于TestUart工程演示串口驱动API的完整调用流程,涵盖端口初始化、波特率/数据位/校验位配置、收发数据、中断服务及多线程协作等关键环节,并附有错误检查与日志调试思路,适合用于开发板调试或设备间通信。压缩包共25个文件,以main.c/usart.c源码、cce编译单元、lst列表、obj目标文件、hex可执行映像及map/sym映射符号文件等为主,包体仅26KB,结构紧凑,便于直接查看工程配置与编译产出。已有765人学习使用。通过这份示例,读者可以快速掌握VxWorks下串口编程的底层机制与标准API用法,也可参考源码中的任务创建、信号量同步和中断注册方式,迁移到实际工业或嵌入式通信项目中。
1. 从open("/tyCo/0")返回-1说起:这份VxWorks串口示例程序能帮你少走一个月弯路
某开发者在接手一个基于VxWorks的采集设备项目时,新换的串口传感器模块在Linux调试板上一切正常,移植到VxWorks目标机上,open("/tyCo/0", O_RDWR, 0)直接返回-1,errno指向设备不存在。翻遍BSP配置才发现,VxWorks的串口不是"调驱动、开设备、读写"这么简单,它牵涉到I/O子系统注册、设备编号映射、ioctl参数设置、select超时语义等一系列约定。这份VxWorks串口通信示例程序正是为了解决这类问题而生:它把串口从打开、配置、发送、接收到select多路复用收包的全套代码写成了可直接编译运行的最小工程,覆盖了查询方式和超时保护两种收包路径。适合两类人:一类是从Linux/RT-Thread转VxWorks的嵌入式开发者,一类是在老设备上维护遗留代码、想快速确认串口硬件是否正常的现场工程师。下文从架构到代码逐层拆开讲。
2. 技术底座与工程结构:VxWorks串口I/O的完整链路
2.1 VxWorks的I/O系统:文件描述符背后的设备注册机制
VxWorks的串口访问建立在它独有的I/O系统之上,理解这套机制比记住API更重要。在Linux里,串口是/dev/ttyS0这样的字符设备,内核驱动在init时注册好,应用层只管open。在VxWorks里,串口驱动是由BSP(板级支持包)里的sysSerial.c在系统启动阶段调用ttyDevAdd()注册到I/O子系统的,注册时给设备起一个名字,最常见的就是"tyCo/0"、"tyCo/1"。
这个注册动作决定了一件关键的事:open("/tyCo/0")找的其实是I/O系统里的一张设备链表,而不是文件系统里的路径。如果BSP里没调ttyDevAdd,或者串口驱动初始化函数没被执行,open就一定失败。另一个常见认知偏差是设备编号:tyCo/0不必然对应主板上的COM1,它只表示该BSP注册的第一个串口,具体对应哪路UART硬件要看sysSerial.c里TY_CO_DEVICE表的排列顺序。
I/O系统之上还有一层文件描述符抽象。open成功返回一个小整数fd,之后read/write/ioctl全部通过这个fd操作,这与POSIX接口一致,所以从Linux转过来的开发者对API本身不会陌生,真正的坑都在设备和配置语义上。示例程序里有一句注释值得留意:驱动层的输出缓冲区是固定大小的,写满后write会阻塞,这个行为与Linux的串口阻塞模式类似。
2.2 示例程序目录结构与文件职责
这份示例工程的结构不算复杂,但每个文件的职责边界很清晰,适合作为后续二次开发的骨架。整体目录如下:
serial_demo/ ├── src/ │ ├── serial_demo.c # 主程序:初始化、发送、接收、select循环 │ ├── serial_config.c # 串口参数配置封装 │ └── serial_demo.h # 对外宏定义与函数声明 ├── Makefile # 基于GNU Make的编译脚本 ├── README.md # 使用说明与硬件连接方式 └── target/ └── serialDemo.out # 编译产物,供Workbench下载serial_demo.c是整个工程的入口,main函数里完成open、ioctl、taskSpawn(创建接收任务)、发送测试数据四件事;serial_config.c把波特率、数据位、校验位这些参数的设置逻辑单独抽出来,目的是让使用者不用关心FIOBAUD背后的实现;serial_demo.h里定义了缓冲区大小、超时时间、串口设备路径这三个最常改的宏。
Makefile按照VxWorks的编译规则编写,使用交叉编译器将源码编译成可下载到目标机的可执行文件。对于刚接触的开发者在Workbench里直接建一个可下载内核模块(Downloadable Kernel Module)工程,把这几个.c文件加进去编译即可,Makefile更多是给喜欢命令行编译的人准备的替代路径。
2.3 构建方式:Workbench工程与命令行Makefile两条路
实际开发中编译方式取决于你的目标机类型。如果是仿真环境或x86目标机,通常在Workbench里创建DKM工程,添加源码文件后直接Build;如果是交叉编译到PowerPC、ARM目标板,需要确认交叉编译器前缀(比如powerpc-linux-gnu-gcc和VxWorks自带的dl模块链接方式不同)。这里有血的教训:VxWorks的DKM链接的是内核符号表,编译时不需要链接串口库,运行时由内核解析,所以Makefile里别画蛇添足加-lpthread这类参数。
示例程序在README里给了两种运行方式:一是Workbench里连接目标机后,把serialDemo.out下载到内存,然后在Shell里执行serialDemo任务启动;二是用VxWorks的bootloader把文件放到FTP服务器上,通过网络下载到目标机再运行。第一种方式在调试阶段最常用,因为可以直接看到printf输出和taskSpawn的信息。
3. 核心模块拆解:串口从打开、配置到收发一帧数据的完整流程
3.1 打开与初始化:open + ioctl组合的正确姿势
先看打开串口的代码。示例程序里serial_demo.h定义了设备路径宏,默认指向tyCo/0,如果你要用第二个串口改成tyCo/1即可。
/* serial_demo.h 关键宏定义 */ #define SERIAL_DEVICE "/tyCo/0" #define RX_BUF_SIZE 256 #define TX_BUF_SIZE 128 #define SELECT_TIMEOUT 3 /* 秒 */ /* serial_demo.c 打开串口 */ int serial_open(const char *dev_path) { int fd; /* O_RDWR表示可读可写,O_NOCTTY告知驱动这不是控制终端 */ fd = open(dev_path, O_RDWR | O_NOCTTY, 0); if (fd == ERROR) { perror("open serial"); return ERROR; } /* 清空收发缓冲区,确保设备处于干净状态 */ ioctl(fd, FIOFLUSH, 0); return fd; }open失败时perror会把errno对应的错误信息打印出来,这是第一个排查依据。比如errno是S_objLib_OBJ_NOT_FOUND,基本可以断定设备没注册,去查BSP里有没有调用ttyDevAdd;如果errno是S_ioLib_NO_FD说明系统文件描述符表满了,常见原因是任务反复open没close,fd泄漏到上限。
FIOFLUSH这个ioctl命令常见做法是初始化时执行一次,作用是清空驱动内部收发缓冲区的残留数据。如果不做这一步,设备刚上电时可能因为线路噪声收到几个垃圾字节,后续程序会把这几个字节当成真实数据解析导致帧错位。
3.2 发送路径:write的阻塞边界与残缺写处理
串口发送的代码写得很有代表性。它不像教科书示例那样write一次就完事,而是处理了"残缺写"的情况——write返回值小于请求长度时,原因可能是驱动缓冲区不足,也可能是被信号打断。
/* serial_demo.c 发送数据 */ int serial_send(int fd, const char *data, int len) { int written = 0; int ret; while (written < len) { ret = write(fd, data + written, len - written); if (ret == ERROR) { /* EINTR表示被信号打断,重试即可 */ if (errno == EINTR) { continue; } perror("serial write"); return ERROR; } written += ret; } return written; }这段代码的价值在于它把"写串口"这件事的边界条件想清楚了。VxWorks的write默认是阻塞模式,当驱动输出缓冲区满时任务会挂起等待,直到缓冲区腾出空间。如果不做循环写入,在大数据量传输时可能出现一次write只写入一半数据的情况,后续代码如果直接用原始len做判断就会出错。
补充说明一下EINTR:VxWorks里如果任务被信号中断,write可能返回ERROR且errno被置为EINTR,这不代表硬件出错,重试写入即可。很多新手在这里直接return ERROR,导致一次串口通信失败就永久放弃后续发送。
3.3 接收路径:select + read的非阻塞循环
接收是串口程序里最容易出问题的地方,因为read是同步阻塞的,如果串口一直没数据,任务会永久阻塞在read上。示例程序的解法是用select做超时保护:指定时间内有数据就read,没数据就返回超时,任务可以继续做别的处理。
/* serial_demo.c select接收循环 */ int serial_recv_with_select(int fd, char *buf, int buf_len, int timeout_sec) { fd_set read_fds; struct timeval timeout; int ret; int n_ready; FD_ZERO(&read_fds); FD_SET(fd, &read_fds); timeout.tv_sec = timeout_sec; timeout.tv_usec = 0; /* 等待数据可读,超时时间由调用方控制 */ n_ready = select(fd + 1, &read_fds, NULL, NULL, &timeout); if (n_ready == ERROR) { perror("select"); return ERROR; } if (n_ready == 0) { /* 超时无数据 */ return 0; } /* 此时fd可读,read不会阻塞 */ if (FD_ISSET(fd, &read_fds)) { ret = read(fd, buf, buf_len); if (ret == ERROR) { perror("serial read"); return ERROR; } return ret; } return 0; }select的第一个参数是fd+1,这是VxWorks与POSIX保持一致的细节,代表监视的描述符范围上限。timeout_sec传入3表示最多等3秒,如果3秒内串口来数据立刻返回,否则返回0。注意select返回0和返回ERROR是两回事:0是超时属于正常情况,ERROR才是真的异常。
这种接收方式的取舍是实时性:select有最小调度延迟,数据到达和设备可读事件通知之间可能隔一个tick(默认60Hz下约16ms),对绝大多数串口采集场景足够了。如果要求微秒级响应,需要改走中断驱动+信号量的方式,后面改造章节会讲。
3.4 参数表:波特率、数据校验与FIOBAUD的设置逻辑
串口参数配置封在serial_config.c里,核心是一个ioctl调用加几个宏。这个函数在工程里被main调用,打开设备后立即执行。
/* serial_config.c 配置串口参数 */ int serial_config(int fd) { /* FIOBAUD是VxWorks特有的波特率设置命令 */ if (ioctl(fd, FIOBAUD, 9600) == ERROR) { perror("set baud"); return ERROR; } /* 数据位/校验位/停止位通过termios风格ioctl设置 */ /* 部分BSP也支持直接使用TCSETS设置完整参数 */ return 0; }串口参数对照表如下,调试时按设备手册往上填。
| 参数项 | 设置方式 | 常见取值 | 注意事项 |
|---|---|---|---|
| 波特率 | ioctl(fd, FIOBAUD, 9600) | 9600/115200 | 双方必须一致,高速时注意线材质量 |
| 数据位 | TCSETS结构体设置 | 8/7/5 | 最常见是8,奇偶校验时用7 |
| 校验位 | TCSETS结构体设置 | 无/奇/偶 | 与设备手册保持一致 |
| 停止位 | TCSETS结构体设置 | 1/2 | 多数设备用1位 |
| 流控 | TCSETS结构体设置 | 无/硬件RTS/CTS | 硬件流控需要接线支持 |
FIOBAUD这个命令是VxWorks驱动扩展的ioctl号,在Linux上对应的是termios的TCSETS。也就是说,VxWorks裁剪了POSIX termios支持,额外保留了FIOBAUD这类查询命令。如果你在代码里同时看到这两个接口不奇怪,老驱动更认FIOBAUD,新BSP则两者都支持。设置波特率失败时先确认设备的实际波特率是标准值还是非标值(比如28800这种),非标准波特率需要驱动支持divisor自定义,不是每个BSP都做得到。
4. 跑通与调试:从下载到loopback回环验证,附避坑清单
4.1 目标机部署与任务启动步骤
把编译好的serialDemo.out下载到目标机有几种路径,最直接的是在Workbench的Debug Configurations里新建一个VxWorks Downloadable Kernel Module Run Configuration,目标机地址填开发板的IP,点击下载后会在Shell里自动执行main。
手动在Shell里运行也完全可以。连接目标机Shell后输入:
ld < serialDemo.out sp serial_demo_mainld命令把模块加载进内存,sp是taskSpawn的Shell缩写,后面的serial_demo_main是程序入口函数名。这里有个细节:如果你的main函数名与C库里的main冲突,会出现符号重复,示例程序特意把入口命名为serial_demo_main就是为了规避这个冲突。
运行后观察Shell输出,正常情况下应该看到初始化完成、发送成功等printf打印。如果没有打印,先检查目标机连接和内核符号表,用lkup serial来确认模块里的符号是否已经加载进内核。
4.2 Loopback回环测试:不接外部设备验证收发链路
串口调试最有价值的操作是回环测试。拿一根杜邦线或短接跳线,把开发板串口DB9的2脚(RXD)和3脚(TXD)短接,或者TTL电平的TX和RX短接,这样发送的数据会原路返回,形成一个闭环。
在示例程序里做回环验证时,执行流程是这样的:
- 调用serial_send发送一段字符串,比如"hello tty"
- 数据从TXD发出,经过短接线直接进入RXD
- select在超时时间内检测到可读事件,read把数据读回来
- 程序把收到的数据打印到Shell
如果回环测试能收到自己发出去的数据,证明串口硬件、驱动、应用层读写链路整条都是通的。这时候接外部设备还不通,问题就出在外部设备配置(波特率/校验位/地址)或者接线上了。回环测试是排查"串口收发异常"时最有性价比的手段,强烈建议拿到新板子第一件事就做。
4.3 避坑指南:五个高频串口问题的现象、原因与解决
问题一:open("/tyCo/0")返回-1
现象:程序启动即报错,perror输出显示设备不存在或资源不可用。
原因:最常见是BSP没注册串口设备。检查sysSerial.c中是否调用了ttyDevAdd,以及系统启动代码里串口初始化函数有没有执行。另一个可能是设备号不对,你的板子第一个串口是tyCo/1而不是tyCo/0。
解决:在Shell里执行devs命令列出所有已注册设备,看到tyCo/0才说明设备存在;没看到就去查BSP配置。也可以换一个编号试open,确认是否设备号偏移。
问题二:打开成功但收不到数据
现象:发送正常,外部设备也确认有数据发出,但read一直超时。
原因:波特率或校验位设置不一致是头号嫌疑。外部设备是115200,你的程序配的9600,收到的全是乱码或根本不解码。
解决:用示波器或逻辑分析仪抓TXD/RXD波形,数一下位宽算实际波特率。更简单的办法是拿到设备手册查默认参数,逐项对照。排查时将校验位设为无、停止位设为1、数据位设为8,这是串口世界的默认值,多数设备在这种配置下能通。
问题三:数据丢字节
现象:接收长帧数据时,中间少了几个字节,或者帧尾被截断。
原因:select+read在应用层有调度延迟,如果驱动缓冲区溢出,新数据会覆盖旧数据。常见于波特率较高(比如460800)且主循环处理逻辑过重的情况。
解决:在select返回后立即read,read调用后立刻把数据搬到应用层缓冲区,避免在中断上下文和任务上下文之间长时间滞留。必要时调大驱动缓冲区,这需要改BSP的SERIAL_BUFFER_SIZE宏重新编译内核。
问题四:select一直立即返回超时
现象:接收循环一直在跑,但每次select都是超时返回0,CPU占用率居高不下。
原因:timeout结构体用完后被内核修改了值,下一次循环再调用select时timeout变成了0。select返回后timeout的值是实现相关的,POSIX允许内核把剩余时间写回。
解决:每次调用select前重新给timeout赋值,示例程序里timeout在函数内部定义并初始化,天然规避了这个问题。如果你把timeout定义成全局变量并复用,务必在每次select前重置。
问题五:程序退出后串口状态异常
现象:程序崩溃或任务删除后,再次open同一个串口设备报资源忙。
原因:VxWorks的驱动在文件描述符关闭前不会释放资源。任务被强杀(taskDelete)时,fd没有自动close,驱动层的设备锁还持有。
解决:在任务退出路径里务必close(fd)。如果任务可能被外部强杀,考虑在异常处理钩子里做清理。更规范的做法是接收任务用自杀式退出(任务末尾close再return),不要让别人来kill。
5. 二次开发:把示例程序改造成可复用的串口驱动模块
5.1 封装成独立串口库:接口设计与调用约定
示例程序直接把收发逻辑写在main流程里,多任务同时访问同一个串口时容易出问题。我的改造习惯是把它封装成一个带锁的串口操作库,对外暴露四个接口,内部用互斥信号量保护共享资源。
/* serial_lib.h 对外接口 */ typedef struct { int fd; SEM_ID sem; /* 互斥信号量 */ char rx_buf[256]; int rx_len; } SerialPort; SerialPort *serial_create(const char *dev_path, int baud); int serial_write_data(SerialPort *port, const char *buf, int len); int serial_read_data(SerialPort *port, char *buf, int len, int timeout); void serial_destroy(SerialPort *port);serial_create内部完成open和ioctl配置;serial_write_data和serial_read_data在操作前后做semTake/semGive,保证同一时刻只有一个任务访问串口;serial_destroy负责close并删除信号量。这样一个模块可以同时服务多个任务:一个任务负责接收解析,另一个任务按需发送控制指令。
封装后的调用方代码干净很多,业务代码不需要再关心fd和errno细节。调用示例:
SerialPort *port = serial_create("/tyCo/0", 115200); char cmd[] = {0xAA, 0x55, 0x01}; serial_write_data(port, cmd, 3); char resp[64]; int n = serial_read_data(port, resp, 64, 2);5.2 中断驱动与信号量的实时性改造方向
如果select轮询的延迟满足不了需求,改造方向是中断驱动。VxWorks的串口驱动收到一个字节会在中断上下文调用回调函数,业务层可以注册一个自定义回调把数据放进环形缓冲,同时释放一个二进制信号量通知接收任务取数据。
伪代码示意:
void uart_rx_isr(void *arg) { /* 把收到的字节放入环形缓冲 */ ring_buf_push(&g_rx_ring, uart_get_char()); /* 唤醒等待数据的任务 */ semGive(g_rx_sem); } void rx_task(void) { while (1) { semTake(g_rx_sem, WAIT_FOREVER); char c = ring_buf_pop(&g_rx_ring); /* 处理单个字节或组帧 */ } }这种方案牺牲了一点代码复杂度,换来了中断级的响应速度。适用的场景是:高波特率、数据量密集、且每个字节都需要立即处理。如果只是普通的传感器数据采集,select完全够用,不必为了性能而过度设计。
5.3 验证清单与复盘习惯
改完串口模块,我每次都会强制走一遍验证清单,顺序固定,缺一不可:
- 回环测试:短接TX/RX,确认自收自发正常
- 外部设备连调:接真实设备,用固定帧交互,核对内容逐字节一致
- 长时间稳定性:跑12小时连续收发,确认无内存增长、无丢包累积
- 异常恢复:拔插串口线,确认程序能够通过串口状态查询识别故障并恢复
这份清单源自某次现场项目翻车的复盘。当时一个采集设备的串口程序开发时一切正常,交付后客户现场运行两天就出现数据卡死,排查发现是串口线接触不良导致驱动缓冲区溢出后状态机错乱。从那以后我每次改完串口代码,都强制把"异常恢复"环节加入验证清单,测试时故意热插拔串口线,确认程序能在5秒内自动重建通信链路。希望帮到你。
串口这块东西看着简单,真正调起来全是细节。把这份示例程序的每个调用、每个errno、每次超时的语义啃透,你在VxWorks上就少了一块最鸡肋的黑匣子。
本文还有配套的精品资源,点击获取