ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

QNX串口通信工程实践:termios、select与粘包组帧

QNX串口通信工程实践:termios、select与粘包组帧

简介:面向 QNX 嵌入式开发初学者的一份串口通信入门示例,围绕实时操作系统下通过 /dev/term/* 设备节点操作串口展开,帮助读者理解波特率、数据位、停止位、校验位与握手协议等参数配置,并建立从打开设备到读写的完整认识。源码围绕串口打开、参数配置、数据收发和关闭等环节组织,重点体现 ioctl、read、write 的调用关系,以及 struct termios 与 tcsetattr 的设置方式和返回值检查思路,对掌握配置流程与错误处理较有帮助。压缩包共 13 个文件,约 13KB,以 makefile、.mk 构建脚本和 C 源码为主,辅以工程配置及编译产物,规模精简,便于在 QNX 环境中对照阅读与编译。已有 642 人学习,可作为嵌入式调试、设备控制等场景的入门参考,也能为后续理解中断、流控与多线程同步等问题打下基础。

1. 调试串口被占用后,业务串口该怎么写

手上这块工控板只有一个调试串口,业务口全被 RS485 占着,偏偏现场要求把一路传感器数据原样转发出去。不少人的第一反应是写个while(read())循环,结果在 QNX 上跑起来不是读不到数据,就是连续运行十几个小时后写端阻塞卡死。原因在于 QNX 的串口本质上是一个资源管理器,/dev/ser1背后是devc-ser*驱动,你用的是一套 POSIX 字符设备接口加 termios 的既有规则,只是它的消息传递模型、线程调度和readcond这类私有接口,把配置不匹配暴露得特别明显。这份 qnx 串口程序资源,做的就是把这套东西封成一个能直接编译、能上板、能改协议的最小工程,适合刚接手 QNX BSP、需要用串口对接外设的开发者。

2. 架构与目录:QNX 串口程序的四层结构和文件分工

QNX 上的串口程序和 Linux 下的写法七成相似、三成要命。相似的部分是open/read/write/tcsetattr那一套,要命的部分集中在线程优先级、超时语义和驱动的启动方式上。所以这个工程的分层不是为了好看,而是把"会变的部分"和"几乎不改的部分"切开:驱动层和接口层是 BSP 和系统给的,封装层和组帧层才是你真正要动手的地方。先把层次划清楚,后面调参、换协议、加第二路串口,才不会牵一发动全身。

一个请求从传感器到业务回调,走的链路是这样的:UART 收到字节触发中断,devc-ser8250在中断服务里把数据搬进输入队列,然后唤醒正在read()上阻塞的客户端线程;客户端线程被唤醒后从内核态拷出数据,交给组帧状态机;状态机攒够一整帧、校验通过之后,回调业务函数。这条链路上任何一环的优先级低于同机上的计算线程,在 QNX 的抢占式调度下都会表现为"偶发丢字节",而不是报错。这是动手前必须建立的第一个认知。

2.1 四层结构:从内核资源管理器到业务回调

驱动层由 BSP 提供,x86 平台一般是devc-ser8250,ARM SoC 平台常见devc-serminiuart、devc-serpl011这类。它负责 UART 寄存器读写、FIFO 管理、中断处理、波特率分频,向上注册/dev/ser1、/dev/ser2这样的设备节点。这一层几乎不需要改,但必须会启动它、会看它的启动参数、会在节点没出现时判断是驱动没跑还是硬件没上电。

接口封装层对应工程里的serial_port.c,它把 fd、termios 配置、超时等待、全量读写包成几个函数。之所以单独抽一层,是因为 termios 的字段又多又互相关联,散在业务代码里改一次错一次。集中到一处之后,换波特率、开关流控、改校验位都只动一个地方。

组帧层对应proto_frame.c,处理帧头识别、长度域解析、CRC 校验、半包缓存和粘包拆分。串口是字节流,一次read()拿到 1 个字节还是 200 个字节完全看时序,如果你在业务代码里假设"一次读就是一帧",那上线之后一定会遇到半包。把这层独立出来,业务侧就只面对"完整帧回调"。

业务层就是main.c:解析命令行、创建读线程和写线程、注册帧回调、打印日志。这一层尽量薄,薄到换一个传感器协议只需要改回调函数体。

2.2 目录结构与各文件职责

工程目录我一般这么组织,文件不多,但每个都有明确边界:

文件职责改动频率
src/main.c参数解析、线程创建、回调注册、退出清理中
src/serial_port.c/.hopen、termios 配置、select超时、全量读写低
src/proto_frame.c/.h帧头、长度域、CRC16、半包状态机高
src/ring_buffer.c/.h读写线程之间的缓冲,带互斥保护低
include/*.h对外接口和帧结构体定义中
build/Makefileqcc交叉编译规则低
scripts/run_on_target.sh上板之后的启动与日志重定向中

改动频率那一列很能说明问题:真正需要反复迭代的是proto_frame.c,因为每家传感器的帧格式都不一样;serial_port.c写对一次之后基本不动。如果你的代码里serial_port.c天天在改,多半是把协议逻辑漏进去了。

2.3 编译与环境:qcc、QNX_TARGET 和上板部署

QNX 的交叉编译器是qcc,本质是 gcc 的包装,靠-V参数选择目标变体。SDP 装好之后先 source 环境脚本,把QNX_HOST、QNX_TARGET指到位,qcc才能找到头文件和库:

# build/Makefile —— 先 source qnxsdp-env.sh,再执行 make CC = qcc # -V 后面跟目标变体:aarch64 用 gcc_ntoaarch64le,armv7 用 gcc_ntoarmv7le,x86_64 用 gcc_ntox86_64 CFLAGS = -Vgcc_ntoaarch64le -Wall -O2 -I../include LDFLAGS = -Vgcc_ntoaarch64le TARGET = qnx_serial_demo OBJS = main.o serial_port.o proto_frame.o ring_buffer.o $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $(OBJS) %.o: ../src/%.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)

-V这一项漏了的话,qcc会用宿主机默认变体编译,出来的二进制拷到板子上会直接报"无法执行"或者链接阶段找不到libc.so。判断方法很简单:file qnx_serial_demo看一眼架构,和板子的uname -m对得上再拷。

部署环节有个血泪经验:如果板子上的系统镜像裁剪过,缺运行时库,最省事的办法是加-static静态链接,二进制会大几 MB,但省掉一堆ldd排查。动态链接的话,把libc.so、libgcc_s.so一起拷到目标机的/lib或者用LD_LIBRARY_PATH指过去。

# 目标机上:确认架构、赋执行权限、前台跑一次看日志 uname -m chmod +x /tmp/qnx_serial_demo /tmp/qnx_serial_demo -d /dev/ser1 -b 115200

3. 初始化链路:termios 原始模式与 devc-ser* 驱动的对接细节

串口程序的第一次失败,九成发生在初始化阶段,而且症状都是"open 成功但读不到数据"。这是因为open()成功只说明设备节点存在、权限够,不代表驱动已经正确绑定了硬件、更不代表波特率和对面一致。把初始化拆成驱动侧和应用侧两段看,问题就很好定位:驱动侧决定/dev/ser1这个节点能不能用、默认波特率是多少;应用侧决定这次通信实际用什么参数。

3.1 驱动先起来:/dev/ser1 是怎么出现的

串口驱动不是内核自动加载的,得在启动脚本里显式拉起来。QNX 的启动脚本一般写在 build 文件里,是个[+script]段落。驱动跑起来之后设备节点才存在,所以后面要跟一个waitfor,否则业务进程抢在前面启动,open()直接 ENOENT:

# build 文件里的启动脚本片段:先拉驱动,再等节点,最后起业务进程 devc-ser8250 -e -b115200 -u1 0x3f8,4 & waitfor /dev/ser1 /tmp/qnx_serial_demo -d /dev/ser1 -b 115200 &

-u1 0x3f8,4表示第 1 个单元用 I/O 端口0x3f8、中断号 4,对应的就是/dev/ser1;-b115200是驱动侧的默认波特率;-e打开硬件流控 RTS/CTS。这里有个坑要说清楚:驱动侧的-b和你程序里cfsetospeed()设的波特率是两回事,后者会覆盖前者,但两者不一致时中间会有一小段以错误波特率通信的窗口,对端如果已经在发数据,那几帧就是乱码。

不同 BSP 的devc-ser*参数名不完全统一,devc-serminiuart和devc-ser8250的选项就不完全一样。最靠得住的办法是上板子敲use devc-ser8250看当前镜像里这个驱动的真实用法,不要照抄网上的参数表。

3.2 应用侧:open 加 termios 把串口切成原始模式

应用侧第一件事是打开设备并切到原始模式。原始模式的意思是关掉行编辑、回显、信号处理这些终端特性,让内核把收到的字节原样交给你。不切的话,0x03会被解释成中断信号,0x0D会被转成0x0A,二进制协议直接崩掉:

/* serial_port.c —— 打开串口并切成原始模式 */ #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <string.h> #include <errno.h> int serial_open(const char *dev, speed_t baud) { /* O_NOCTTY:不要把自己变成控制终端;O_NONBLOCK:open 本身不阻塞 */ int fd = open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", dev, strerror(errno)); return -1; } struct termios tio; /* 必须先用 tcgetattr 取当前值,直接 memset 会把 BSP 预置的 c_cflag 一起清掉 */ if (tcgetattr(fd, &tio) != 0) { close(fd); return -1; } cfmakeraw(&tio); /* 清 ICANON/ECHO/ISIG/IXON 等,等价于手工清位 */ cfsetispeed(&tio, baud); cfsetospeed(&tio, baud); tio.c_cflag |= (CS8 | CLOCAL | CREAD); /* 8 数据位、忽略调制解调器控制线、允许接收 */ tio.c_cflag &= ~(PARENB | CSTOPB); /* 无校验、1 位停止位 */ tio.c_cflag &= ~CRTSCTS; /* 硬件流控默认关,需要时再单独打开 */ tio.c_iflag &= ~(IXON | IXOFF | IXANY); /* 软件流控关掉,否则收到 0x11/0x13 会被吞 */ tio.c_cc[VMIN] = 0; /* 配合 select/O_NONBLOCK 用,读不到就返回 */ tio.c_cc[VTIME] = 0; /* 不在驱动层做超时,超时交给 select 控制 */ if (tcsetattr(fd, TCSANOW, &tio) != 0) { close(fd); return -1; } tcflush(fd, TCIOFLUSH); /* 清掉打开前残留在收发 FIFO 里的脏数据 */ return fd; }

几个参数值得单独说。O_NOCTTY不加的话,如果当前进程没有控制终端,这个串口会变成它的控制终端,之后收到某些字符会往进程组发信号,很难查。CLOCAL表示忽略 DCD/DSR 这些调制解调器状态线,做设备对接时基本都要开,不开的话某些板子上read()会一直返回 0。CREAD是允许接收,某些老代码忘了置这一位,现象就是只能发不能收。

VMIN=0、VTIME=0是一种常见组合,含义是"有多少读多少,没有就立刻返回"。这时候read()会返回 0 或者 -1/EAGAIN,所以超时控制必须由select()或者readcond()来做,不能指望read()自己等。

顺带提一句,如果目标平台的 BSP 头文件里没有cfmakeraw()(不常见但存在),手工把c_lflag里的ICANON、ECHO、ECHOE、ISIG四位清掉,效果一样。

3.3 参数怎么改:波特率、校验、流控的对应关系

改参数的时候别一个一个试,按这张表对号入座,改完拿示波器或者另一台机器回环验证一次,比反复烧板子快得多:

需求要改的字段常见错误
改波特率cfsetispeed/cfsetospeed或c_cflag的CBAUD位只改了驱动-b,没改 termios
7 位数据 + 偶校验c_cflag &= ~CSIZE; c_cflag |= CS7 | PARENB;忘了先清CSIZE,8 位和 7 位叠加成未定义组合
2 位停止位c_cflag |= CSTOPB;对端是 1 位停止位,表现为固定位置乱码
硬件流控c_cflag |= CRTSCTS;且驱动加-e只改了一边,发送 FIFO 满后write永久阻塞
二进制透传清IXON/IXOFF,清ICANON/ISIG数据里的 0x11/0x13 被当成流控字符吞掉

速度常量用B115200这种宏,别直接写数字。非标准波特率(比如 250000)需要用c_cflag里的CBAUDEXT加自定义分频,这时候必须查驱动支持的最大时钟频率,devc-ser8250是通过-c指定输入时钟的,时钟给错,波特率会整体偏移,现象是"能通信但误码率很高"。

4. 收发核心:select 超时、readcond 定长读取与粘包状态机

初始化对了之后,真正决定程序稳不稳的是收发那几百行。串口的问题有很强的欺骗性:短时间跑没事,连续跑三天开始偶发丢包;单步调试没事,全速跑就粘包。这些都不是玄学,是读超时策略和缓冲策略没设计好。这一章把读线程、判包、写线程三段拆开讲,每段都给能直接抄的代码。

4.1 读线程:用 select 做超时,别在 read 上死等

最原始的写法是阻塞read()等一个字节。这种写法在单串口小工具里能用,一旦要做多路串口或者需要定时上报心跳,就会互相拖死。正确做法是把 fd 设成非阻塞,用select()统一等,超时时间自己定:

/* serial_port.c —— 等串口可读,单位毫秒;>0 有数据,0 超时,-1 出错 */ #include <sys/select.h> int serial_wait_readable(int fd, int timeout_ms) { fd_set rfds; struct timeval tv; FD_ZERO(&rfds); FD_SET(fd, &rfds); tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; /* EINTR 要单独重试,QNX 上定时器信号打断 select 是常态 */ int rc; do { rc = select(fd + 1, &rfds, NULL, NULL, &tv); } while (rc < 0 && errno == EINTR); return rc; }

select()在 QNX 上对字符设备是支持的,底层走的是资源管理器的select回调,驱动在中断里唤醒等待队列,不会忙等。超时值不要设成 0,那就是纯轮询,白烧 CPU;也不要设太大,读线程的超时周期通常就是你的心跳周期,50 到 200 毫秒比较合适。返回 -1 且errno == EINTR时必须重试,否则定时器信号一来,这一轮数据就漏了。

4.2 QNX 特有的 readcond:一次读够 N 个字节

select()加read()的组合有个小缺陷:select告诉你"有数据",但read可能只拿到 1 个字节,剩下的还在路上。对定长协议来说,这意味着一堆状态机判断。QNX 提供了一个别的系统上没有的接口readcond(),可以指定"最少读够多少字节"和"两段超时",一次调用解决:

/* 至少读够 min 字节,或等够 timeout 个十分之一秒才返回 */ ssize_t n = readcond(fd, buf, sizeof(buf), 16, /* min:攒够 16 字节就返回 */ 1, /* time:字节间隔超时,单位 0.1 秒 */ 10); /* timeout:总超时,单位 0.1 秒 */

三个参数的含义:min是最少字节数,time是字节间超时(收到第一个字节后,隔多久没新字节就算这一段结束),timeout是总超时。返回值可能小于min,表示超时了但确实收到了一些。这个接口特别适合"帧头 + 定长载荷"的协议:直接读够一帧长度,比状态机拆包省事。用之前建议在目标机上use readcond确认当前版本的确切语义,不同版本在边界条件上略有差别。

4.3 组帧状态机:粘包和半包都在这里消化

不管用不用readcond,最终都要有一层状态机兜底,因为串口只保证字节顺序,不保证边界。下面这段是按"帧头 0xAA55 + 小端长度域 + CRC16"设计的,换成你自己的协议只需要改这三个常量:

/* proto_frame.c —— 半包缓存 + 粘包拆分,上层只拿完整帧 */ #include <stdint.h> #define FRAME_HEAD0 0xAA #define FRAME_HEAD1 0x55 #define FRAME_MAX 256 static uint8_t s_buf[FRAME_MAX]; static uint16_t s_len; /* 已收到的字节数 */ static uint16_t s_need; /* 本帧声明的总长度,0 表示还没解析出来 */ /* 喂一个字节;返回 1 表示攒够一整帧,*out/*out_len 指向 s_buf 里的数据 */ int frame_feed(uint8_t byte, uint8_t **out, uint16_t *out_len) { /* 找帧头:帧头之前的垃圾数据直接丢,避免错位后一直对不齐 */ if (s_len == 0 && byte != FRAME_HEAD0) return 0; if (s_len == 1 && byte != FRAME_HEAD1) { s_len = (byte == FRAME_HEAD0) ? 1 : 0; /* 0xAA 0xAA 0x55 这种要能重新对齐 */ return 0; } if (s_len < FRAME_MAX) { s_buf[s_len++] = byte; } else { s_len = 0; s_need = 0; /* 超长帧丢弃,防止缓冲区溢出 */ return 0; } /* 第 3、4 字节是长度域(小端),到齐就能算整帧长度 */ if (s_len == 4 && s_need == 0) { s_need = (uint16_t)(s_buf[2] | (s_buf[3] << 8)); if (s_need < 6 || s_need > FRAME_MAX) { /* 长度荒谬,整帧丢掉重新找头 */ s_len = 0; s_need = 0; return 0; } } if (s_need && s_len == s_need) { uint16_t crc = (uint16_t)(s_buf[s_need - 2] | (s_buf[s_need - 1] << 8)); if (crc16_modbus(s_buf, s_need - 2) != crc) { s_len = 0; s_need = 0; /* 校验错,丢弃整帧等下一个帧头 */ return 0; } *out = s_buf; *out_len = s_need; s_len = 0; s_need = 0; /* 复位,接着处理粘在后面的下一帧 */ return 1; } return 0; }

有个细节必须写进注释里:返回 1 的时候*out指向静态缓冲区,调用方必须在下一次frame_feed之前把数据拷走或者处理完,否则会被后续字节覆盖。这是静态缓冲区方案最大的隐患,也是我见过最多的一类"数据莫名其妙变了"的问题。稳妥一点的做法是上层回调里立刻做一次memcpy,或者干脆改成传入外部缓冲区。

配套的读线程骨架是这样,每次read到多少喂多少,粘包由状态机自己迭代:

/* main.c —— 读线程:一次读到 N 字节就全部喂给状态机,靠循环消化粘包 */ uint8_t buf[128]; for (;;) { if (serial_wait_readable(fd, 100) <= 0) continue; /* 超时就回去做心跳 */ ssize_t n = read(fd, buf, sizeof(buf)); if (n <= 0) continue; for (ssize_t i = 0; i < n; i++) { uint8_t *frame; uint16_t flen; if (frame_feed(buf[i], &frame, &flen)) { handle_frame(frame, flen); /* 业务回调,内部立刻拷走数据 */ } } }

4.4 写侧:全量写循环与 tcdrain

写侧最容易翻车的地方是把write()的返回值当成"写完了"。非阻塞模式下write可能只写一部分,硬件流控打开且对端不读时甚至一直返回 EAGAIN。必须循环到全部写完,并且给一个总超时,别让线程永远卡在那儿:

/* serial_port.c —— 循环写直到写完,超时返回 -1 */ int serial_write_all(int fd, const uint8_t *buf, size_t len, int timeout_ms) { size_t off = 0; while (off < len) { ssize_t n = write(fd, buf + off, len - off); if (n > 0) { off += (size_t)n; continue; } if (n < 0 && (errno == EAGAIN || errno == EINTR)) { /* 发送队列满或者被信号打断,等可写再试;等不到就放弃这一帧 */ if (serial_wait_writable(fd, timeout_ms) <= 0) return -1; continue; } return -1; } return 0; }

write()返回成功只代表数据进了驱动的发送队列,不代表已经上了线。如果后面的逻辑是"发完就关串口"或者"发完就重启设备",中间必须加tcdrain(fd),它会阻塞到发送队列真正排空。少了这一步,最后几个字节经常会丢,而且丢得毫无规律。

多线程同时写同一个串口的话,加一把互斥锁,并且把锁的属性设成优先级继承:

pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); /* 防优先级反转 */ pthread_mutex_init(&s_tx_lock, &attr);

QNX 是抢占式实时调度,低优先级的写线程持有锁的时候被高优先级线程抢占,不开优先级继承就会出现"高优先级线程等一把低优先级线程的锁,等了很久"的经典反转,现象是偶发性的写延迟尖峰。

5. 避坑与排查:收不到、乱码、写卡死的五类现场

串口问题的排查顺序比技巧重要。上来就改代码是大忌,先确认物理层和设备节点,再确认驱动参数,最后才怀疑应用逻辑。下面这个顺序是我踩了足够多次之后固定下来的,基本能在十分钟内把问题圈定到某一层。

5.1 上板之后的排查顺序

第一步看设备节点:ls -l /dev/ser*。节点不存在,说明驱动没起来或者硬件没被识别,去slog2info里搜驱动名,看有没有资源冲突或者中断申请失败的记录。第二步看进程:pidin ar | grep devc-ser,确认驱动进程活着,顺便看它的启动参数是不是你以为的那组。第三步做物理回环,把 TX 和 RX 短接,然后cat /dev/ser1同时在另一个终端往里写数据,能收到就说明驱动和硬件都通,问题一定在应用层。第四步才是跑业务程序,同时开着slog2info -w看有没有异常日志。

# 目标机上的四步确认,顺序不要跳 ls -l /dev/ser* pidin ar | grep devc-ser cat /dev/ser1 & # 回环测试:TX/RX 短接后,往串口写什么就该看到什么 slog2info -w | grep -i ser

第三步的cat特别有用,它绕过了你所有的应用代码,是判断"到底是驱动问题还是我的问题"的分水岭。我见过好几次是应用层把 termios 配错了,cat一切正常。

5.2 五类高频现场:现象、原因、解决

第一类:open 成功,read 一直返回 0。现象是程序不报错,但就是没数据,日志里连一行超时都没有。原因通常是c_cflag里没置CREAD,或者CLOCAL没开导致驱动在等 DCD 信号;另一个常见原因是驱动侧波特率和 termios 设置的波特率不一致,对端发过来的全被当成噪声丢掉了。解决方式是先用cat /dev/ser1验证链路,再打印tcgetattr的结果和驱动参数逐项对比。别信"我记得配了",打印出来看。

第二类:数据里固定少几个字节,且少的总是 0x11 或 0x13。这是软件流控没关的典型特征,XON(0x11)和XOFF(0x13)被驱动当成流控字符消费掉了,不会交给应用。二进制协议里这两个字节出现频率不低,所以症状是"大部分帧正常,偶尔校验错"。解决办法就是c_iflag &= ~(IXON | IXOFF | IXANY),同时确认cfmakeraw确实被调用了。

第三类:高位数据错乱,或者中文变乱码。原因多半是数据位没设对。有的人图省事直接写tio.c_cflag = CS8 | CREAD | CLOCAL;一行覆盖,看着是 8 位,但把驱动预置的时钟选择位、CBAUD位一起清了,波特率就变成了默认值。正确姿势永远是先tcgetattr、再改位、再tcsetattr。另外 7 位数据加校验的组合如果忘了先清CSIZE,会拼出一个未定义的组合,行为完全看驱动实现。

第四类:连续跑十几个小时后 write 卡死。现象是进程还在,pidin能看到,但业务停了,用调试器 attach 上去发现线程停在write里。原因是硬件流控开着,对端有一段时间不读,发送 FIFO 满了之后write就阻塞;如果代码里没设超时,那就是永久阻塞。解决办法是open用O_NONBLOCK,写之前select等可写并给超时,超时就丢帧并记日志,别让一个不听话的对端拖死整个进程。

第五类:多线程收发时数据错乱或者延迟尖峰。现象是偶发性的:帧内容对不上,或者某次写操作延迟从 1 毫秒跳到几十毫秒。原因有两个,一是收发共用了一个缓冲区却没加锁,二是互斥量没开优先级继承导致反转。解决办法是固定"单读线程 + 单写线程"的模型,中间用环形缓冲解耦,锁统一用PTHREAD_PRIO_INHERIT。这个模型还有个好处:读线程可以设成较高优先级保证不丢字节,写线程优先级低一点,业务计算再低一点,整条链路的时延就可控了。

提示:把上面五类的判断依据做成一张检查表贴在工位上,比每次重新推理快得多。尤其是第二类和第三类,症状相似但改的地方完全不同。

6. 二次开发:把示例协议层换成你自己的帧格式

拿到这份 qnx 串口程序之后,真正要动的只有proto_frame.c和main.c里的回调,其余部分基本可以原样保留。改协议的时候按这个顺序来,能少走弯路:先把帧结构体定义清楚(帧头几个字节、长度域是大端还是小端、有没有序列号、CRC 是 Modbus 还是 CCITT),然后只改frame_feed里的三个常量和一个校验函数,最后在handle_frame里写业务。长度域大小端搞反是最常见的错误,现象是"帧头对得上,但长度算出来几百上千,直接被FRAME_MAX挡掉",所以调试期把s_need算出来的值打出来看一眼,比什么都快。

如果要接第二路串口,不要复制一份serial_port.c。把 fd 和配置收进一个结构体,serial_open返回这个结构体指针,读线程按fd区分,组帧状态机也改成每个实例一份(把s_buf/s_len/s_need搬进结构体)。这样加第三路、第四路都是改配置的事。同理,handle_frame建议做成函数指针注册:

/* 按设备号注册不同的处理函数,新增一路串口只加一行注册 */ typedef void (*frame_cb_t)(const uint8_t *frame, uint16_t len); int serial_register_cb(int port_idx, frame_cb_t cb);

验证方法上,我习惯先在 PC 侧用脚本模拟对端,把各种边界情况打一遍:半包(一次只发 3 个字节)、粘包(两帧拼在一个write里)、错帧(故意改 CRC)、超长帧(长度域写 0xFFFF)。这四种都过了,再上板子联调实物。板子上验证时优先看两个指标:连续跑 24 小时的 CRC 错误计数,以及write超时的次数。这两个数都是 0,才算真的稳。

还有个容易忽略的点:QNX 上可以用devctl()拿驱动的线路状态,判断发送队列是不是排空了,比单纯依赖tcdrain更灵活,具体命令和返回结构以目标机上use devctl和对应驱动源码为准,别照抄。从那以后我每次动串口代码,上板第一件事都是先cat /dev/ser1做一遍回环,确认物理链路没问题再谈应用逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表