
嵌入式开发者对于串口UART一定不会陌生。这个通信接口几乎每个项目都会接触到,而且也是许多工程师最早接触的通信协议。
我工作几年以后开始搞SOC项目,单片机几乎很少碰,也就没再去造“串口轮子了”。
我做过Linux平台的SPI、USB、以太网等各种通信开发,真正需要自己从零设计串口收发协议的机会反而越来越少。直到最近,一个新项目又把我拉回了这个熟悉的领域。
项目要求在Linux平台下以一个串口与多个设备通信,并且实现自定义通信协议。我原本以为这类工作对于年轻工程师来说并不算困难,于是便把任务交给了他们,之后没再过问。
没想到6个月过去了,协议始终存在BUG。
起初大家都认为,多路串口管理过于复杂,线程调度、缓存同步、设备切换……任何一个环节都可能埋下Bug。可是随着不断排查,一个令人意外的现象出现了,即使只连接一台串口设备,不涉及任何多路管理,CRC校验依然频繁出错。
于是各种猜测开始出现。
有人怀疑串口外设质量不好,有人认为现场干扰太大,甚至还有人开始怀疑板级驱动移植存在问题。听着听着,我发现锅已经慢慢往我这里飘了。
可是看看现场环境,板卡之间连线不过60cm,也没有强电、强磁设备,从经验来看,115200波特率3.3V信号幅度,产生误码的概率极小。当听到同事说误码率几乎每分钟都能复现后,我本想拿示波器探头的手又收了回来,断定这是软件带来的BUG。
然后我给他们提供思路,说:“先别猜,做个压力测试,把误码率测出来再说。”
现场却没人愿意真正去做,干嘛?都等我去做!我可不想周五加班。
我再建议建议,说:“不用自己写程序,直接用板子上的MiniCom配合ZMODEM给PC发文件就行,它自带CRC校验。”
刚一说出口我就打住了,ZMODEM的设计目标在于可靠传输。一旦发现CRC校验失败,它会自动重传数据,却无法告诉你中间到底发生了多少次误码。
看来,这个轮子终究还是得自己造。
为了验证串口误码,我写了一个最简单的测试程序,思路很简单:
发送端持续发送连续递增的数据,从00 01 02 03一直到FF 00 01,接收端只做一件事,判断后一字节是否等于前一字节加一,一旦不满足就立即打印Error。
本以为简单测试,十几分钟就能完成测试用例,然而真实测试竟然真的存在误码。
测试结果却十分奇怪,大约每收到200多个字节,就一定会出现一次误码。
真正的误码应该是随机的。电磁干扰会导致随机Bit翻转,波特率不匹配会导致随机丢字节,FIFO溢出会导致随机错位。
可是这次不同,每一次出错的位置虽然不同,但错误的数据却高度一致。测试用例稍作修改,不简单输出Error,还包含发现Error的字节序号。
原本输出应该是
08 09 0A 0B 0C 0D 0E ,
结果却变成了
Error index:5
08 09 0A 0B 0C 0A 0E
这一系列的错误也就是说, 0x0D 永远都会变成 0x0A 。每次都是同一个位置错误,这已经不像误码了。
看到这个现象,我脑子里首先闪过的是几个经典硬件差异。
可是很快,这些猜测都在左右脑互搏中被排除了。
原因很简单,整个测试发送和接收的都是uint8_t。CPU无论是大小端还是内存对齐,影响的都是多字节数据如何解释,不会把一个独立的0x0D变成0x0A。至于FIFO溢出的排查更简单,每次发送后等待几毫秒一定不会触发溢出。
既然不是硬件差异带来的BUG,那么一定还有别的东西夹在串口驱动和应用程序之间。
就在编写打印信息的时候,手指习惯地敲下printf("%x\r\n")。后面两个字节是为了兼容在Windows下也能正常阅读Linux的打印日志。
至于\r的ASCII码是多少,我其实并不清楚,但我记得\n对应的是0x0A。
会不会是历史遗留产物在做怪,我一直以为这两个老古董符号对现代影响仅限于显示设备,或许它们的根源来源于TTY设备。
于是我把遇到的BUG现象丢给了AI去解释。
很多开发者认为Linux串口的数据流只有两步,从UART -> read()。实际上,中间还有一个很多人平时几乎感觉不到的模块,从UART -> TTY Driver -> Line Discipline -> read()。正是这一层对数据进行了处理。
经过排查发现,TTY默认开启了一个名为ICRNL的功能。它只有一句话,CR转到NL,也就是0x0D变成0x0A。
至此,困扰团队几个月的串口误码终于真相大白。
这并不是Linux的Bug,而是一段延续了几十年的历史。
追溯到机械电传打字机时代:
CR 代表Carriage Return,让打印头回到行首;
LF 代表Line Feed,纸张向上移动一行。两个动作本来就是独立完成的。
后来不同计算机系统采用了不同的换行方式。Unix使用LF也就是0x0A,DOS和Windows使用 CR+LF 也就是 0x0D 0x0A,早期Macintosh则使用CR也就是0x0D。
为了兼容各种终端设备,Unix引入了TTY子系统,即Teletype,并提供了一系列字符转换功能。例如ICRNL表示收到CR自动转换成LF,INLCR表示收到LF转换成CR,IGNCR表示直接忽略CR。这些选项原本是为了终端交互设计的,而不是为了今天的二进制通信协议。
最终只需要关闭TTY的字符转换,或者直接使用cfmakeraw()将串口配置为Raw模式,问题立即消失,具体代码为:
没有修改驱动,没有修改硬件,没有修改协议,更没有所谓的串口干扰。真正的问题只是Linux默认保留的一项兼容终端的历史功能。
虽说30分钟解决了公司6个月遗留的历史BUG,可是今天的下班还是晚了。
许多工程问题并不是因为它复杂,而是因为大家一直在错误的方向上努力。真正的经验不只是知道答案,而是知道什么现象意味着什么原因。当一个错误在同一个地方反复出现时,它大概率就不是错误,而是系统在按照某种规则或者特性替你处理了数据。
本文转自媒体报道或网络平台,系作者个人立场或观点。我方转载仅为分享,不代表我方赞成或认同。若来源标注错误或侵犯了您的合法权益,请及时联系客服,我们作为中立的平台服务者将及时更正、删除或依法处理。
