Linux USB串口canonical模式读特定长度行丢失末尾换行符问题
问题描述
我正在测试一款仅基于可打印字符实现的串口通信协议。测试环境为PC通过USB连接Arduino开发板:
- PC端USB串口工作在canonical模式,配置为无回显、无流控、波特率9600
- 因需要实现读取超时逻辑,执行串口读操作前先调用
pselect等待事件 - Arduino端固件不对接收数据做任何处理,直接原样回显所有收到的字符
- PC端操作系统为Linux Neon,内核版本为5.13.0-40-generic
测试中发现异常:当PC发送特定长度的行数据经Arduino回传时,除末尾换行符(NL)丢失外其余数据接收均正常;执行下一次读操作时会返回仅包含此前丢失换行符的空行。其余长度的行数据收发完全正常,末尾换行符可正常接收。
该现象可稳定复现:发送包含换行符在内总长度为65字符的行时,仅能接收到64字符(末尾换行符丢失),其余行长度下收发均无异常。
复现代码
/* remote serial loop test 20220626 */ #include <errno.h> #include <fcntl.h> #include <stdio.h> #include <stdint.h> #include <stdlib.h> #include <sys/select.h> #include <string.h> #include <termios.h> #include <time.h> #include <unistd.h> #define TX_MINLEN 63 #define TX_MAXLEN 66 #define DATA_MAXLEN 128 #define LINK_DEVICE "/dev/ttyUSB0" #define LINK_SPEED B9600 #define RECEIVE_TIMEOUT 2000 int main() { int wlen; int retval; int msglen; uint8_t tx_data[257] = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'}; for (int i=16; i < 256; i++) tx_data[i] = tx_data[i & 0xf]; uint8_t rx_data[257]; /* serial interface */ char * device; int speed; int fd; fd_set fdset; struct timespec receive_timeout; struct timespec *p_receive_timeout = &receive_timeout; struct termios tty; /* open serial device in blocking mode */ fd = open(LINK_DEVICE, O_RDWR | O_NOCTTY); if (fd < 0) { printf("Error opening %s: %s\n",LINK_DEVICE,strerror(errno)); return -1; } /* prepare serial read by select to have read timeout */ FD_ZERO(&(fdset)); FD_SET(fd,&(fdset)); if (RECEIVE_TIMEOUT >= 0) { p_receive_timeout->tv_sec = RECEIVE_TIMEOUT / 1000; p_receive_timeout->tv_nsec = RECEIVE_TIMEOUT % 1000 * 1000000; } else p_receive_timeout = NULL; /* get termios structure */ if (tcgetattr(fd, &tty) < 0) { printf("Error from tcgetattr: %s\n", strerror(errno)); return -1; } /* set tx and rx baudrate */ cfsetospeed(&tty, (speed_t)LINK_SPEED); cfsetispeed(&tty, (speed_t)LINK_SPEED); /* set no modem ctrl, 8 bit, no parity, 1 stop */ tty.c_cflag |= (CLOCAL | CREAD); /* ignore modem controls */ tty.c_cflag &= ~CSIZE; tty.c_cflag |= CS8; /* 8-bit characters */ tty.c_cflag &= ~PARENB; /* no parity bit */ tty.c_cflag &= ~CSTOPB; /* only need 1 stop bit */ tty.c_cflag &= ~CRTSCTS; /* no hardware flowcontrol */ /* canonical mode: one line at a time (\n is line terminator) */ tty.c_lflag |= ICANON | ISIG; tty.c_lflag &= ~(ECHO | ECHOE | ECHONL | IEXTEN); /* input control */ tty.c_iflag &= ~IGNCR; /* preserve carriage return */ tty.c_iflag &= ~INPCK; /* no parity checking */ tty.c_iflag &= ~INLCR; /* no NL to CR traslation */ tty.c_iflag &= ~ICRNL; /* no CR to NL traslation */ tty.c_iflag &= ~IUCLC; /* no upper to lower case mapping */ tty.c_iflag &= ~IMAXBEL;/* no ring bell at rx buffer full */ tty.c_iflag &= ~(IXON | IXOFF | IXANY);/* no SW flowcontrol */ /* no output remapping, no char dependent delays */ tty.c_oflag = 0; /* no additional EOL chars, confirm EOF to be 0x04 */ tty.c_cc[VEOL] = 0x00; tty.c_cc[VEOL2] = 0x00; tty.c_cc[VEOF] = 0x04; /* set changed attributes really */ if (tcsetattr(fd, TCSANOW, &tty) != 0) { printf("Error from tcsetattr: %s\n", strerror(errno)); return -1; } /* wait for serial link hardware to settle, required by arduino reset * triggered by serial control lines */ sleep(2); /* empty serial buffers, both tx and rx */ tcflush(fd,TCIOFLUSH); /* repeat transmit and receive, each time reducing data length by 1 char */ for (int l=TX_MAXLEN; l > TX_MINLEN - 1; l--) { /* prepare data: set EOL and null terminator for current length */ tx_data[l] = '\n'; tx_data[l+1] = 0; /* send data */ int sent = write(fd,tx_data,l+1); /* receive data */ /* wait for received data or for timeout */ retval = pselect(fd+1,&(fdset),NULL,NULL,p_receive_timeout,NULL); /* check for error or timeout */ if (retval < 0) printf("pselect error: %d - %s\n",retval,strerror(errno)); else if (retval == 0) printf("serial read timeout\n"); /* there is enough data for a non block read: do read */ msglen = read(fd,&rx_data,DATA_MAXLEN); /* check rx data length */ if (msglen != l+1) printf("******** RX ERROR: sent %d, received %d\n",l+1,msglen); else continue; /* check received data, including new line if present */ for (int i=0; i < msglen; i++) { if (tx_data[i] == rx_data[i]) continue; else { printf("different rx data:|%s|\n",rx_data); break; } } /* clear RX buffer */ for (int i=0; i < msglen + 1; i++) rx_data[i] = 0; } }
排查思路与根因
这个问题是Linux tty核心层canonical模式的固定行缓冲长度限制导致的,和Arduino固件、USB转串口硬件都没有关系:
- Linux内核中tty canonical模式的默认行缓冲大小固定为64字节,对应内核编译宏
N_TTY_BUF_SIZE = 64,没有提供用户态修改接口。 - canonical模式下,tty层只要满足以下任意一个条件,就会把缓冲数据返回给用户态
read调用:- 收到换行符
\n或用户自定义的行结束符 - 收到EOF字符
- 行缓冲被填满
- 收到换行符
- 发送总长度65字节的行时,前64字节会先填满行缓冲,此时tty层立刻触发可读事件,
pselect返回,调用read只能拿到前64个字节——这时候作为第65字节的换行符还在串口传输链路中,尚未进入tty行缓冲。 - 等换行符后续被tty层接收时,前面的64字节已经被读走,tty会把这个换行符单独作为一个长度1字节的空行缓存,下一次调用
pselect/read时就会读到这个残留的换行符,和观测到的现象完全吻合。 - 长度小于65字节的行不会触发缓冲填满的提前返回,换行符会和前面的内容一起被缓冲,
read可以拿到完整带换行的行;测试范围内长度超过65字节的场景没有覆盖多段拆分逻辑,所以暂时没暴露问题。
修复方案
- 优先方案:把串口切换到raw非规范模式,自行在用户态实现行拆分和超时逻辑,不要依赖tty层自带的canonical行缓冲。这是串口自定义协议开发的通用做法,可以彻底规避内核行缓冲的所有隐藏限制。
- 兼容方案:如果必须使用canonical模式,
read拿到数据后要判断最后一个字节是不是换行符,如果不是就循环继续read,直到读到换行符为止。注意pselect触发可读事件仅代表内核有数据可读,不代表一整行已经接收完成。 - 代码bug修复:现有代码中
fd_set在循环中没有重新初始化,pselect返回后会修改fdset内容,后续循环的pselect行为属于未定义状态,需要每次调用pselect前都重新执行FD_ZERO和FD_SET重置监听集合。
内容的提问来源于stack exchange,提问作者Marcus
相关产品推荐
相关产品推荐

