TTY子系统与线路规程:那个让我深夜抓狂的串口“丢包”问题

张开发
2026/4/20 6:27:03 15 分钟阅读

分享文章

TTY子系统与线路规程:那个让我深夜抓狂的串口“丢包”问题
上周调试一个工业网关项目串口通信总是随机丢数据。示波器抓波形一切正常但应用层收到的报文时不时就少几个字节。熬到凌晨三点盯着stty -F /dev/ttyS0的输出发呆突然意识到问题可能不在硬件而在那个我一直忽略的“线路规程”。TTY到底是什么很多人以为TTY就是串口终端实际上它是Teletype的缩写一套历史比UNIX还老的抽象层。现在的Linux TTY子系统包含三层TTY核心、线路规程和底层驱动。// 典型的串口驱动注册片段staticstructuart_drivermy_uart_drv{.ownerTHIS_MODULE,.driver_namemy_uart,.dev_namettyS,// 注意这个命名约定.majorTTY_MAJOR,// 4.minor64,// 从ttyS0开始};// 这里踩过坑早期内核版本和现在的minor分配策略不同// 嵌入式移植时一定要查对应内核的uart_register_driver实现线路规程被低估的流量控制器线路规程Line Discipline是TTY架构中最精妙的设计。它像个中间人坐在TTY核心和硬件驱动之间负责特殊字符处理CtrlC、CtrlZ行缓冲经典模式下的回车才上报串口数据流控XON/XOFF协议转换如PPP、SLIP// 看看n_tty的经典实现staticstructtty_ldisc_opstty_ldisc_N_TTY{.namen_tty,.numN_TTY,.openn_tty_open,.closen_tty_close,.receive_bufn_tty_receive_buf,// 数据在这里被“加工”.write_wakeupn_tty_write_wakeup,};// 关键点receive_buf函数决定了数据何时、如何传递给上层// 我们的丢包问题就出在这里——默认的N_TTY会做行缓冲那个深夜发现的真相回到开头的问题。我们的工业协议是二进制数据但默认的N_TTY线路规程工作在规范模式ICANON。这个模式下TTY会等待换行符才提交数据给read()处理退格、删除等编辑字符限制输入行长度默认4096字节解决方案简单得让人想哭# 关闭规范模式原始数据模式stty-F/dev/ttyS0 raw-echo-icanon# 或者用程序设置struct termios options;tcgetattr(fd,options);cfmakeraw(options);// 这个函数一键设置原始模式 tcsetattr(fd, TCSANOW,options);线路规程的切换技巧除了默认的N_TTY内核还内置了其他线路规程#defineN_TTY0// 默认终端模式#defineN_SLIP1// 串行线路IP协议#defineN_MOUSE2// 鼠标协议#defineN_PPP3// 点对点协议#defineN_STRIP4// Starmode Radio IP#defineN_AX255// AX.25#defineN_X256// X.25#defineN_6PACK7#defineN_MASC8#defineN_R39649#defineN_PROFIBUS_FDL10#defineN_IRDA11#defineN_SMSBLOCK12#defineN_HDLC13#defineN_SYNC_PPP14#defineN_HCI15// Bluetooth HCI UART切换线路规程的两种方式// 方法1ioctl老派但有效intldiscN_TTY;ioctl(tty_fd,TIOCSETD,ldisc);// 方法2通过ldisc的open方法structtty_ldisc*ldtty_ldisc_get(N_PPP);tty_ldisc_assign(tty,ld);tty_ldisc_open(tty,ld);驱动开发者的注意事项写TTY底层驱动时这几个回调必须小心处理staticconststructtty_operationsmy_serial_ops{.openmy_serial_open,.closemy_serial_close,.writemy_serial_write,// 这里别直接调硬件写.write_roommy_serial_write_room,// 缓冲区剩余空间.chars_in_buffermy_serial_chars_in_buffer,.flush_buffermy_serial_flush_buffer,.ioctlmy_serial_ioctl,.set_termiosmy_serial_set_termios,// 波特率设置在这里.stopmy_serial_stop,.startmy_serial_start,.hangupmy_serial_hangup,};// 血的教训.write应该把数据放入环形缓冲区// 然后触发硬件发送中断别在这里死等硬件发送完成调试TTY问题的私房工具ldisc状态查看cat/proc/tty/ldiscs# 能看到每个TTY设备绑定的线路规程数据流跟踪// 在驱动里加调试点#definetty_debug(tty,fmt,args...)\dev_dbg(tty-dev,fmt,##args)// 特别关注tty_insert_flip_string_fixed_flag的调用// 这是驱动把数据塞给线路规程的入口内存泄漏检查线路规程的open/close必须成对调用特别是自己实现ldisc时staticintmy_ldisc_open(structtty_struct*tty){structmy_data*datakmalloc(sizeof(*data),GFP_KERNEL);// 一定要检查分配失败的情况if(!data)return-ENOMEM;tty-disc_datadata;// 这里内核会帮你管理引用计数return0;}staticvoidmy_ldisc_close(structtty_struct*tty){structmy_data*datatty-disc_data;kfree(data);// 别忘了释放tty-disc_dataNULL;// 这个置空很重要}给后来者的经验之谈TTY子系统是Linux里为数不多的“历史包袱”设计得如此优雅的模块。调试TTY问题记住三个关键点第一先分清楚问题在哪一层。硬件问题看dmesg | grep ttyS驱动问题看cat /proc/tty/driver/serial线路规程问题用stty -a查参数应用层问题用strace跟系统调用。第二二进制协议一定要用raw模式。那些termios的标志位别自己一个个设用cfmakeraw()最保险。工业环境里记得把CREAD、CLOCAL也打开避免莫名其妙的“设备不存在”错误。第三自己实现线路规程的情况比想象中少。现在很多串口协议如Modbus、Profibus都在用户态用库实现了。除非你要在内核里做硬件加速或实时性要求极高否则别碰自定义ldisc。我见过有人为了一点点性能提升写了个自定义线路规程结果内存泄漏查了两个月。最后留个思考题为什么echo test /dev/ttyS0能发送数据但cat /dev/ttyS0收不到提示一下看看CRTSCTS和CRTSCTS的区别。这个坑我当年踩了整整一天。TTY就像老式的机械手表内部齿轮复杂精密但一旦理解了工作原理调试起来反而比那些“现代”的框架更顺手。下次遇到串口问题别急着换硬件先问问线路规程同不同意。

更多文章