ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

串口发送为何不能加延时?从UART标志位到DMA的工程实践

2026/10/3 7:06:40 拓冰建站 浏览量
串口发送为何不能加延时?从UART标志位到DMA的工程实践 做串口通信平台这几年我经常在代码评审里看到一句话“这里加个延时等它发完。”每次看到我都想追问一句你在等谁发完UART模块还是对端应用这个问题看似低级却几乎决定了整个通信平台的稳定性。串口发送不能加延时这句话听起来像老工程师的玄学实际底层逻辑非常朴素延时是在用时间做猜测而硬件真正需要的是状态确认。猜在低速、空闲资源充裕的情况下偶尔能蒙对可一旦波特率拉到115200、460800甚至奔着200万去错一次的代价就是整帧数据报废运气差一点还会触发串口溢出和一串连锁故障。这篇文章不打算复述手册我只想把在这类串口通信平台上踩过的坑和总结下来的办法一次性讲清楚为什么发送路径上不该放延时、我给自己定的平台开发五条守则是哪五条、协议缓冲区被多个模块反复引用时怎么用三步修掉、以及波特率干到2M后真正卡你的不是芯片而是一根线。如果你正在做设备端通信、上位机联调或者总线协议开发这篇应该能帮你少走几条弯路。1. 串口发送为什么不能加延时1.1 先把“发送完成”的定义弄清楚串口发送并不是调一个API然后数据就瞬间出去了。以最常用的UART外设为例数据从内存到TX引脚中间至少经过两个硬件环节数据寄存器TDR和移位寄存器Shift Register。你启动一次发送数据先从内存写入TDR硬件再把TDR的数据搬运到移位寄存器然后由波特率时钟一点一点把10位或11位数据从引脚踢出去。这里就有两个关键标志TXE表示TDR已经空出来可以写入下一个字节TC表示移位寄存器也空了整个字节真正发送完。很多人在串口发送里加延时本质是想等一个结果但等的是“我猜这个时间应该够”而不是去问硬件“你到了哪一步”。延时一猜就可能猜早或猜晚。猜早了的典型场景是前一个字节还在移位寄存器里慢慢往外挪你觉着延时够了直接往TDR里写第二个字节。如果硬件恰好没来得及把内容搬走新数据会把没发完的旧数据盖掉发出去的内容就是错乱的一帧。猜晚了的场景更常见明明早就发完了你还在傻等CPU被白白拖住整个任务的实时性垮掉。低速时比如9600波特率8个数据位加起始停止位一共10个位每字节要1.04ms你加个1~2ms延时看起来还挺配。但同一个延时搬到115200一个字节只有86.8us你加1ms等于白白浪费11个字节的时间到了2M波特率一个字节5us延时1ms是200个字节的发包窗口。这个账一算就明白延时不是不可以用而是它是一个和时间强绑定的经验值波特率一变、对端负载一变经验值立刻失效。1.2 延时带来的连锁反应比想象中严重第一类是阻塞发送里加延时。这是最常见的写法发送函数往串口寄存器里塞完数据后Delay一下再返回。表面看没问题实际上你的主循环或者调用线程被一个和业务无关的等待时间卡死。比如一个2M波特率的模块一包数据100字节理论发送时间只要0.5ms结果你延时5ms整个系统的吞吐量直接掉90%。还有更隐蔽的问题如果这块延时是在中断服务函数里执行情况会迅速恶化。串口接收是有硬件FIFO的只要FIFO满了而CPU还在某个中断里循环延时新到的数据就被硬件丢弃这时去读状态寄存器会看到一个溢出错误ORE。很多“串口偶尔丢第一帧”的bug最后查出来都是中断里带着delay处理器服务不及时。第二类是流控延时被滥用。有些做AT指令模组的同学发完一条指令后总是Delay几百毫秒等应答再把答应对齐到buffer尾部。这个做法在低速、单设备的测试环境里能跑通一旦接入平台、多路通信并发几百毫秒的固定延时会造成两个问题一是应答解析永远慢半拍协议效率极低二是延时期间新到的数据被堆积缓冲区溢出后丢的往往不是噪声而是最关键的命令响应。我曾经在一台设备上看到调试串口抓出来两个应答被拼到一块就是因为发送函数在中间插了一个延时把接收端的处理窗口挤没了。还有一个更让人头疼的场景STM32延时函数delay卡死。这个热搜词我特别有共鸣很多人写for循环延时或者用SysTick延时结果在调试中一进中断就再也出不来。原因多半不是延时函数本身有bug而是中断优先级配错、临界区没保护导致延时期间某个外设中断被卡死整个系统看起来像是delay把串口搞坏了。实际上惹祸的仍然是“用延时去等一个不确定的事件节点”这个思想。如果你把等待对象从时间换成硬件标志绝大多数卡死问题都能绕开。1.3 无延时发送应该怎么写既然不推荐延时那正确的发送姿势是什么按性能从低到高有三条路第一轮询标志位。发送前查一遍TXE为空就填下一个字节所有字节填完再查一次TC确认最后一字节真正移位完成。这种写法在低速、短报文、CPU不在乎阻塞的场景里非常直观至少它不再猜时间而是确认状态。第二中断发送。开TXE中断在中断里把缓冲区的下一个字节写入TDR全部写完关中断并回调应用层。这种方式把等待交给了硬件中断CPU在等待期间可以去做其他事。第三DMA发送。直接把内存地址和长度配置给DMA控制器启动后由DMA自动把数据搬进TDR发完触发完成中断。AT32、STM32这类带DMA的芯片都支持这个玩法这也是目前平台化串口通信里用得最多的路径。我自己的习惯是平台内部发送全部走队列DMA应用层往队列里塞数据驱动层注册一个DMA完成回调回调里给队列腾位并通知应用。在这个模型里压根没有“发送延时”这个概念因为数据的节奏完全由DMA完成中断驱动。代码大致是这样void uart_send_via_dma(platform_uart_t *huart, uint8_t *buf, uint16_t len) { // 先等上一包发送完成DMA完成标志再更新DMA描述符 while (huart-dma_busy) { // 这里没有delay而是让出线程或挂起等待 } huart-dma_busy 1; memcpy(huart-dma_buffer, buf, len); // 拷贝到自己可控的缓冲里 DMA_ConfigAndStart(huart, huart-dma_buffer, len); } void dma_tx_complete_isr(platform_uart_t *huart) { huart-dma_busy 0; huart-tx_done_cb(huart-dma_buffer_len); }注意上面的while等待里也不要真的死等更好的做法是在完成中断里通过信号量或任务通知唤醒发送线程。这样整个链路里没有一处delay波特率高低只影响DMA的搬运速度不影响系统调度。很多人纠结“DMA和串口发送之间要不要加个延时让方向切换”那是RS485场景下面第4章会专门提。1.4 延时什么时候确实是必要的把话说绝也不科学。延时在串口通信里至少有三个合理场景第一协议对端需要处理时间。比如某些传感器收到指令后要等50ms才能吐出数据这个等待不是为了让串口发完而是为了让对端应用完成内部处理。这种延时应该由协议层管理而不是驱动层。第二硬件上电稳定。模块上电初期电源没有完全稳住你立刻发配置命令可能石沉大海这时候的延时属于对外设时序的补偿。第三RS485半双工切换。发送完成后总线方向需要从发送切换到接收方向上拉/下拉电阻、收发器切换都需要一点时间一部分人会用延时满足但我更推荐用TC发送完成标志触发方向切换引脚再补一个几微秒到十几微秒的稳定延时这个延时是依据数据手册算出来的硬参数不是拍脑袋的“多等等”。所以“串口发送为什么不能加延时”的真正答案不是“禁止延时”而是“不要在发送路径上使用盲目的、固定的、与状态无关的延时”。把延时的位置从驱动层挪到协议层把延时依据从“感觉够了”换成“手册写了”它就没有原罪。2. 平台开发我给自己定的五条守则为什么要把“平台”单独拎出来讲因为单片机的串口示例代码和真正用在产品里的通信平台是两码事。示例代码是一个串口、一个函数、一个while循环就能跑平台则是多通道、多协议、收发并发、还要应对故障上报。在我维护过的串口通信平台里平台的定义就是“一群不太相干的模块被稳定地拼装在一起”。这些年里我逐渐给自己定了五条守则每一条基本都是用血泪换来的。2.1 守则一底层缓冲对外只读所有写入走接口平台开发最容易埋雷的事就是把内部收发缓冲区的指针直接暴露给业务层。业务线程一高兴直接pBuf[0]0xAA驱动线程正读着同一个数组一个字节错乱整帧校验挂掉。我给平台的第一个规矩就是底层缓冲绝不作为公共变量传递业务层要发包必须调用平台提供的发送接口由接口负责拷贝入队要读收包只能在消息回调里读回调返回后数据就不再有效。这个约束在代码上可能只是多写几个函数但长期维护的价值非常大因为它让所有数据流的终点变得可控。2.2 守则二协议层与驱动层必须解耦第二件事是把协议解析和串口驱动彻底分开。最常见的错误写法是驱动收到一个字节就往协议解析函数里丢协议解析里再去查RTC、查flash、甚至在中断里直接组帧上报。一旦这么做协议层一改驱动层跟着改驱动层换个芯片协议层全部重写。我推荐的方式是驱动只管收发字节和帧边界把完整的帧通过回调扔给协议层协议层只干协议的事校验、解包、分发。这样两边的开发可以交给不同的人也能分别做单元测试。协议重引用问题第3章会详细说之所以反复出现往往就是因为这个解耦没做好导致多个模块都去抢同一个缓冲区。2.3 守则三超时控制只允许一个模块管串口平台最忌讳各模块各自定义延时。A模块收到包后自己等5msB模块又等10ms两个模块叠起来系统实际时序完全不可预测。我的规矩是所有超时包括等待响应、等待发送完成、等待对端ACK都放进一个统一的超时管理模块以tick或独立软件定时器为基准每个业务需要超时就去注册一个截止时间由调度循环统一检查。这样也顺手解决了“中断里不能延时”的问题中断只负责置位标志真正的超时判断发生在任务上下文。我见过很多同事在产品里到处加Delay还振振有词说串口本来就慢结果测量一下发现CPU有30%的时间都花在毫无意义的等待上。统一超时管理之后这些等待要么消失要么变成可量化、可观测的状态机排查问题容易得多。2.4 守则四缓冲区容量按波特率算不靠拍脑袋缓冲区该开多大这是平台开发最容易拍脑袋的地方。我有一个很笨但很管用的计算公式串口在波特率B下每秒进入的字节数在8N1格式下是B/10一毫秒的数据量就是B/10000字节左右。如果协议最大帧长是L字节接收缓冲至少是L加“处理最坏抖动时间内的到达数据量”处理抖动通常取一个调度周期保守点取2~5ms。举个例子200万波特率一毫秒大约进来200字节如果你的主循环调度周期是2ms那接收缓冲至少要有400字节再算上一帧100字节500字节起步才安全。很多溢出丢帧问题根子就是缓冲只开了128字节而2M波特率下1ms就能灌进来200字节。2.5 守则五错误必须上报不许静默吞掉平台里最让人抓狂的不是报错而是什么都不报。DMA发送失败、接收溢出、队列满、帧校验错这些事件如果不记录下来出了问题你只能对着逻辑分析仪从头猜。我的最后一个守则是平台每个模块都要有一个错误计数器或错误回调至少留一个全局错误位让上层能把“丢帧”和“溢出”明确区分开。开发期还可以把这些错误直接打印到调试串口量产期则保留计数器方便远程诊断。别小看这个习惯很多时候“偶发乱码”查不出来就是因为没有证据链。有了错误计数器你会发现在高波特率下丢帧往往能跟线材、干扰或者缓冲溢出精确对应起来。3. 协议重引用一个折磨了我一周的修复“协议重引用”这四个字可能不少同学听着陌生。我把它翻译成人话同一份协议缓冲区被多个模块以引用方式共享每个模块都认为数据是自己的于是出现读、写互相踩踏。这类bug最典型的三个特征数据漂移、校验失败、系统偶发复位。3.1 问题现场数据漂移与校验失败我印象最深的一次是维护一个多通道通信平台设备同时用串口接传感器用USB日志口上报数据。某天测试发现传感器数据每过几秒就出现一次奇偶校验错但用示波器看波形电平干净得很。当时先怀疑波特率做了校准没用怀疑DMA配置反复查寄存器也没问题。最后被迫打开全量日志才注意到一个细节日志线程打印的收发帧有时候帧头会凭空变掉原本应该是0xAA 0x55有时会变成0xAA 0x5A。这个“5A”从哪来的追踪下去发现协议解析后的业务线程会把帧尾的某个字段回写到缓冲区做标记日志线程在另一个上下文用同一个缓冲区指针打印。业务线程还没来得及写日志已经打印了一半等打印完业务线程又把那个位置改成标记值。两边引用的是同一块内存表现就是数据漂移。3.2 定位同一个缓冲区被三个人同时抓把根因翻出来后我发现这个缓冲区的引用方有三个接收中断负责往里写新帧、业务线程负责解包并回写标记、日志线程负责把帧内容送出。任何两个引用方在时间上重叠就会出现脏读脏写。这其实是一个典型的共享可变状态问题。更麻烦的是平台当时为了省内存把接收缓冲、发送缓冲和协议解析共用同一块RAM美其名曰“内存复用”。内存是省了但模块之间完全失去了隔离任何一个引用方的动线都会干扰另外两个。3.3 三步修复断引用、建缓冲池、加快照第一步切断共享引用。把业务线程的回写行为彻底取消改成直接通过接口返回解析结果解析结果使用独立结构体不再回写原始帧缓冲。这一步的核心思想是消除“两个人都拿同一个指针”的前提。第二步建立独立的协议缓冲池。接收中断收到完整一帧后立刻把内容深拷贝到缓冲池里的一个自由节点节点放入队列所有权转移给业务线程业务线程用完释放节点归还缓冲池。日志线程需要打印时从业务线程手里拿消息而不是去抓接收中断的缓冲区。第三步在关键链路上加快照与序列号。每次协议帧分派给业务线程前生成一个轻量快照包含帧长度、CRC校验值和单调递增的序列号任何一步发现序列号不连续或者CRC与快照对不上上层就知道发生了重引用或其他异常能主动记录而不是盲目继续。修复后的伪代码我贴一下结构很清晰void rx_frame_ready(platform_uart_t *huart) { frame_node_t *node (frame_node_t *)pool_alloc(); if (node NULL) { error_counter[ERR_POOL_EMPTY]; return; } memcpy(node-data, huart-dma_rx_buf, huart-rx_len); // 第一步先拷贝 node-len huart-rx_len; node-seq global_frame_seq; node-crc calc_crc16(node-data, node-len); task_post(TASK_PROTOCOL, node); // 第二步所有权转移 usb_log_send((snapshot_t){.seq node-seq, .len node-len, .crc node-crc}); // 第三步快照 }这个修复带来两个直接收益一是所有业务模块再也不碰接收中断的原始缓冲区数据竞争从根上消失二是协议栈的安全性从“靠运气”变成“靠校验”每帧都有序列号和CRC异常路径可以被日志精确还原。整个修复花了一周去定位真正改代码只用了半天。回过头想如果一开始平台就遵循第2章的守则一和守则二这个问题根本不会出现。4. 200万波特率的线材门槛串口速度上到200万波特率之后最常被忽视的变量是物理层。很多人调不通2M串口第一反应是刷驱动、改协议但实际数据在示波器上早就难看得不能看了。200万波特率一秒200万个电平跳变一个位只有500纳秒这个量级下任何一点分布电容、接触电阻、寄生电感都会在信号上留下痕迹。4.1 2Mbps到底意味着什么先做个基础换算。8N1格式下一帧10位2M波特率每秒能传200KB数据。听起来没多大但每个bit的时间是1/2000000秒也就是500ns。我们常说调试串口在115200时波形“挺干净”那个bit时间是8.68us2M时bit时间缩短到原来的1/17。收发器采样一个bit通常要取中间位置就在250ns那个点附近判断电平高低。如果线材让信号上升沿变缓比如本该10ns跳变被拖到300ns采样点看到的就不是逻辑电平而是一个介于高低电平之间的“灰色地带”。这就是为什么同样的代码在115200上稳如老狗改成2M就乱码横飞。4.2 线材参数电容、线径、双绞与屏蔽线材对高速串口的核心影响是分布电容。普通扁平排线、杜邦线的线间电容大概每米几十到一百多皮法线越长电容越大再叠加驱动端的输出阻抗就形成一个RC低通滤波器。你想象一下本来要跳变3.3V的方波经过RC之后变成一条缓慢爬升的斜线爬升时间超过bit宽度时接收端还能不能认出“1”全靠运气。第二是线径和材质。线径太细、材质含铜量不够电阻偏大信号压降明显长距离下甚至直接掉到逻辑门限以下。第三是双绞和屏蔽。双绞能抵消一部分电磁串扰屏蔽层能挡住外部干扰同时也能提供一个干净的参考回流路径。我做了一个简易对照表方便大家按自己手头的线材先做判断线材类型典型线间电容2M波特率下可用长度抖动表现普通杜邦线/面包板跳线80~150pF/m10~20cm以内边沿明显变缓长了就乱码常规扁平排线60~100pF/m30~50cm中短距离勉强可用屏蔽双绞线24AWG以上40~60pF/m2~3m边沿保持良好抗干扰强同轴电缆50/75Ω80~120pF/m1m左右特性阻抗匹配时表现稳定但线间串扰需另测提醒一下这个表是我在常见的3.3V TTL电平、无终端匹配的场景下实测的经验值不是绝对标尺。不同芯片驱动能力、不同线缆厂家会有差异但它足够帮你判断“是不是线在拖后腿”。如果你的项目跑到2M第一板烧录测试最好直接用最短的屏蔽双绞线确认芯片链路本身没问题再去测实际线材的极限。4.3 时钟、收发器与隔离方案线材之外200万波特率对时钟精度和收发器的要求也到了另一个级别。串口接收端一般允许的波特率误差在±2%~±3%左右积少成多超过这个范围接收端采样点就会滑出bit窗口。115200时一个bit是8.68us误差3%是260ns不算难2M时一个bit只有500ns3%就是15ns对晶振精度、USB转串口芯片内部的时钟校准、MCU的PLL配置都提出了硬要求。这也是“波特率校准”这个词会在串口高频场景出现的直接原因。如果你的MCU只有一个内部RC跑2M会非常危险我建议至少换外部晶振或者使用支持时钟自动校准功能的芯片跑之前先做一次位误差测试发一串0x55、0xAA用示波器看每个bit中点是否稳定对准。另外如果必须做隔离9600波特率时随便挑一个低速光耦都行因为一个bit有100us光耦那点上升下降时间完全不影响判定。但2M波特率下普通光耦根本跟不上信号会在光耦里被削成三角波。真要隔离得选高速数字隔离器或者支持10Mbps以上的光耦而且隔离电源的地要处理好否则地弹噪声比不隔离还严重。这一点和“9600波特率用什么光耦隔离最合适”其实是同一个问题的两个极端低速时随便选高速时务必看压摆率和传播延迟。4.4 线材够不够格用三步实测说话判断一组线材能不能撑住2M最靠谱的办法不是看参数表而是现场测。我一般会按三步来第一步环回压力测试。用一个GPIO把TX和RX短接或者连一块带2M串口的板子让设备以2M持续发送随机数注意用CRC校验每一帧跑10分钟看误码帧数。误码率为0时线材基本合格。第二步示波器看眼图。在RX引脚上测对端发来的信号重点看波形高低电平是否清晰上升沿斜率是否够陡有没有明显的振铃。如果眼图“眼睛”闭着哪怕误码暂时为零我也建议换更短的线再测一次。第三步逐步加压。用同一根线拉长距离比如从0.5m到1m到2m记录每个长度下的误码率。很多时候你会发现原来1m是分水岭过了这根线CRC错误率直接暴涨。这时不要犹豫要么换更好的线要么降波特率。我还在实际项目中总结过一个线材门槛的保守值2M波特率下普通杜邦线最好不要超过20厘米屏蔽双绞线最好控制在3米以内超过这个范围即便当下没丢帧温度和干扰一变就可能翻车。别拿“我跑过5米没问题”来赌量产稳定性串口这东西物理层先稳了软件才有发挥的空间。最后分享一个我自己很受用的经验。每次调高波特率遇到诡异性状我先不动代码先把桌面那根来路不明的杜邦线换成最短、最粗、带屏蔽的那根再重跑一次。十次里有七八次问题直接消失。剩下的两三次才会暴露驱动层、协议层或者上层逻辑的真bug。串口通信排障的顺序永远建议是先物理层再驱动再协议。这个顺序能帮你省掉大量无意义的代码调试也能让你真正理解“串口发送不能加延时”那句老话背后其实是在守护整个通信链路的实时性与确定性。