You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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转串口硬件都没有关系:

  1. Linux内核中tty canonical模式的默认行缓冲大小固定为64字节,对应内核编译宏N_TTY_BUF_SIZE = 64,没有提供用户态修改接口。
  2. canonical模式下,tty层只要满足以下任意一个条件,就会把缓冲数据返回给用户态read调用:
    • 收到换行符\n或用户自定义的行结束符
    • 收到EOF字符
    • 行缓冲被填满
  3. 发送总长度65字节的行时,前64字节会先填满行缓冲,此时tty层立刻触发可读事件,pselect返回,调用read只能拿到前64个字节——这时候作为第65字节的换行符还在串口传输链路中,尚未进入tty行缓冲。
  4. 等换行符后续被tty层接收时,前面的64字节已经被读走,tty会把这个换行符单独作为一个长度1字节的空行缓存,下一次调用pselect/read时就会读到这个残留的换行符,和观测到的现象完全吻合。
  5. 长度小于65字节的行不会触发缓冲填满的提前返回,换行符会和前面的内容一起被缓冲,read可以拿到完整带换行的行;测试范围内长度超过65字节的场景没有覆盖多段拆分逻辑,所以暂时没暴露问题。
修复方案
  • 优先方案:把串口切换到raw非规范模式,自行在用户态实现行拆分和超时逻辑,不要依赖tty层自带的canonical行缓冲。这是串口自定义协议开发的通用做法,可以彻底规避内核行缓冲的所有隐藏限制。
  • 兼容方案:如果必须使用canonical模式,read拿到数据后要判断最后一个字节是不是换行符,如果不是就循环继续read,直到读到换行符为止。注意pselect触发可读事件仅代表内核有数据可读,不代表一整行已经接收完成。
  • 代码bug修复:现有代码中fd_set在循环中没有重新初始化,pselect返回后会修改fdset内容,后续循环的pselect行为属于未定义状态,需要每次调用pselect前都重新执行FD_ZERO和FD_SET重置监听集合。

内容的提问来源于stack exchange,提问作者Marcus

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 08:06:20