资讯动态

UART串口驱动开发与调试实战:从寄存器到Linux平台

发布时间:2026/10/8 17:15:24 来源:尧图企业网站定制
这期接着上一期的话题聊一聊嵌入式开发里几乎绕不过去的UART串口。做过几个项目之后你会发现不管板子是MCU还是嵌入式Linux不管调试接口是bootloader还是应用日志十次联调里八次都要先从串口下手。作为驱动开发工程师我接手一个板子的第一件事就是把UART打通因为它就是整个系统的神经末梢后面所有外设调试基本都要靠它输出信息。这个主题的覆盖面比名字看上去要大得多从芯片寄存器读写、中断和DMA调度到Linux下tty子系统与termios配置再到现场常见的乱码、丢数据、串口被占用等问题每一步背后都有对应的为什么。这篇文章结合我在STM32、GD32以及嵌入式Linux平台上的实际调试经验梳理UART驱动开发与调试的完整框架。适合刚接触驱动开发的入门者也适合想系统性补全串口知识的中级工程师。我会尽量少讲套话多用参数计算和真实排错过程说话让文章可以直接落地到你的项目里。1. UART协议本质与驱动开发的核心思路1.1 串口的异步到底异步在哪里很多人一听到异步串行通信就开始头大其实拆开看很直白。UART的英文全称是Universal Asynchronous Receiver/Transmitter异步意味着收发双方没有独立的时钟线所有时序都靠约定好的波特率来对齐。那它是怎么保证不跑偏的看一帧数据的波形就明白了。串口空闲时TX线保持在高电平要发数据的时候线路先拉低一个位的时间这就是起始位。接收方在检测到这个下降沿后启动自己的采样时钟然后按波特率在后续每个位周期的中间点采样数据位接着是可选的校验位最后是停止位高电平通常1位或2位。停止位结束线路恢复空闲高电平等待下一帧。这个机制相当于两个人在没有节拍器的情况下对话但提前商量好每句话的语速每个人开口之前先咳嗽一声表示注意我要说了说完之后停顿一下表示我说完了。只要语速一致校准一次就能对得上。这也是为什么收发双方必须配置一致的波特率、数据位、停止位和校验位任何一项不匹配都可能导致一帧数据被拆得七零八落。UART帧最常见的是8N1也就是8个数据位、无校验、1个停止位。但实际项目里还会见到7E1、8E2这类组合比如某些工业仪表和老式GPS模块就喜欢用7位数据加偶校验。驱动开发里有一个很容易忽略的点校验位如果配置错了接收端会把校验误差当成正常帧收下来偶尔一两次可能不影响但长报文传输时错误率会急剧上升而且很难排查。后来我养成了一个习惯拿到对端设备的规格书先把帧格式标红配置代码里也顺手写上注释防止以后自己把自己坑了。说完协议帧还得提一下电平标准。UART协议本身只是定义时序逻辑但物理层电压有TTL、RS232、RS485三种常见形态。TTL是3.3V或5V逻辑电平适用于板级通信RS232用正负电压典型±5V到±15V适合短距离设备间连接RS485是差分信号抗干扰能力强能跑上千米。驱动开发人员经常要做电平转换和收发方向控制后文在排查章节我会再展开。1.2 驱动开发的三件套寄存器、中断、DMA从驱动开发视角看一个完整UART驱动要处理的职责可以拆成五块初始化时钟管脚外设参数、单字节收发、批量数据收发、错误处理溢出、帧错误、校验错误、断线、以及与功耗管理配合的唤醒逻辑。实现收发路径的方式有三种轮询、中断和DMA它们之间是典型的取舍关系。轮询模式最简单主循环里不停查接收标志位但代价是CPU被占死收发稍多一点就卡界面所以它只适合调试阶段或者极低速场景。中断模式是项目主力接收端每来一字节就触发一次中断CPU在中断里把数据搬进内存缓冲区发送端则靠TXE发送寄存器空中断持续喂数据。DMA模式则是把搬数据这件事从CPU手里完全交给DMA控制器适合大数据量、高速率传输。选型原则其实就一句话短数据用中断长数据用DMA调试用轮询。比如一个传感器的数据帧只有几个字节用DMA反而要处理半传输中断、完成中断这些状态纯属增加复杂度但如果是一路4G模块的日志输出每秒几百KB的数据不用DMA的话中断风暴会拖垮整个系统。在架构上我习惯把UART驱动至少分成三层硬件抽象层负责寄存器读写和管脚控制协议层负责组帧拆帧、校验、以及Modbus这类协议解析再往上才是应用接口。这样分层的好处是换芯片时只需要改最底层协议层和应用层可以做到复用。后面章节的所有代码和配置思路也都按这个分层来讲。2. 寄存器级UART驱动实战拆解2.1 管脚复用与时钟配置第一步最容易翻车不管是STM32还是GD32UART外设要工作必须满足两个前提外设时钟使能、GPIO复用模式配置正确。以STM32F103系列为例USART1的TX和RX默认在PA9和PA10但这不是绝对的需要查数据手册的AFIO复用映射表确认。PA9要配置为复用推挽输出AF_PPPA10配置为复用开漏输入实际常配为浮空输入或带上拉。很多人在这里会翻车调了半天寄存器发出去的数据用示波器看TX引脚就是没有波形。检查后发现要么忘了使能GPIOA时钟要么忘了使能USART1时钟要么就是开了外设但没设置管脚的复用功能。刚开始用HAL库的人更容易被这个问题迷惑因为HAL底层帮你做了很多事一旦你手动操作部分寄存器而不改动它的初始化逻辑很容易两边打架。GD32F470系列也一样但它的GPIO复用方式叫AFIO映射需要调用gpio_af_set()函数同时注意RCU时钟树里USARTx和外设总线的挂载关系。我个人写驱动时有一个固定套路按顺序检查时钟是否使能、管脚复用功能是否设置、模式是否是AF_PP/AF_OD、速率是否足够、最终外设使能位是否打开。这个过程看起来繁琐但能省掉后面大量排查时间。2.2 波特率计算手算一遍给你看波特率不是随便填一个数字就行。以STM32的USART为例波特率寄存器BRR的值由外设时钟和期望波特率算出来公式是USARTDIV PCLK / (16 * BAUD)其中PCLK是UART挂载的总线时钟。假设APB2时钟是72MHz我们要配置115200波特率那么USARTDIV 72000000 / (16 * 115200) 39.0625BRR寄存器里整数部分是39十六进制0x27小数部分是需要比较精确表示的这里0.0625对应16分之一的整数倍即0.0625 × 16 1所以BRR 0x271。如果算出来的小数不是0.0625这样的整倍数关系那就只能取最接近的值然后验算一下误差。波特率误差有一个行业惯例通常要求控制在±2%以内超过这个范围通信距离稍长或者对端芯片采样点比较苛刻时就会出乱码。建议每个项目启动时把波特率计算过程写在代码注释里比如72MHz / 115200 - BRR0x271误差0%这样后续移植到不同主频芯片时能快速定位是不是波特率误差引发的通信问题。还有一个容易被忽略的点外部晶振的精度会影响实际波特率。特别是用内部RC振荡器跑高速串口时温度一变化波特率漂移就可能超过2%。工业产品如果对时序敏感尽量用外部晶振或者选支持时钟校准的芯片。早年我做过一块板子常温下跑115200很稳一到高低温箱测试就频繁乱码最后查出来就是内部RC振荡器温漂超了。2.3 中断收发与环形缓冲区设计中断收发是驱动开发里最能体现基本功的部分。初始化顺序通常是使能外设时钟-配置GPIO复用-配置USART参数、使能收发-配置NVIC优先级-使能接收中断。关键点在中断服务程序里。接收数据时RXNE读数据寄存器非空标志置位读DR寄存器即可清标志溢出错误ORE标志也会在数据被覆盖时置位需要在中断里额外检查并清除否则后续接收会停摆。发送时用TXE标志判断发送寄存器是否空闲但要注意如果只判断TXE而不等TC发送完成最后一字节可能还没从移位寄存器发完就被关闭外设导致数据丢失。实际项目几乎不会只做单字节收发而是要在内存里维护一个环形缓冲区。这个环形缓冲是后面所有数据解析的基础我在驱动层定义两个指针一个写指针由中断写一个读指针由应用层读。只要保证单生产者单消费者模型即写入只发生在中断、读取只发生在主循环读写指针的操作就不需要加锁性能很好。缓冲区判空判满的写法有讲究常见做法是让数组长度保持2的N次幂然后用位与操作代替取模运算。判断空的条件是head tail判断满的条件是 (tail 1) % size head。当然如果数据量大还可以在中断里采用过半中断策略来处理后文DMA章节会和DMA的半传输中断一起讲。2.4 DMA传输配置以及我踩过的对齐坑当串口数据速率超过一定程度用中断每字节搬一次就不再划算了。DMA模式可以让UART外设和内存之间直接搬运数据基本不占CPU。配置接收DMA最常见的手法是把DMA设为循环模式配合一个固定大小的环形缓冲并把DMA中断分成两个触发点传输一半触发一次传输完全完成再触发一次。这样数据被分成两段交替处理处理器在每一段空闲时处理上一段数据相当于天然形成双缓冲。这也是UART DMA接收一边收一边处理的正确姿势。发送DMA更容易踩坑。配置DMA源地址指向要发送的内存触发源选择USART_TX_DMA然后使能DMA通道和外设的DMA请求。这里有一个细节DMA传输完成不代表串口已经发完因为数据可能还在移位寄存器里。必须等待USART的TC标志置位才能确认整帧数据已经全部发送完毕然后才能关闭DMA或者复位状态。还有对齐问题。某些DMA控制器要求缓冲区地址按4字节对齐尤其是32位总线宽度搬运时如果给了一个未对齐的地址DMA要么不工作要么产生总线错误。我踩过一次很典型的坑在栈上定义一个局部数组作为DMA缓冲区编译器给了个未对齐地址结果数据搬运总是缺几个字节查了半天发现是对齐问题。后来统一用全局数组或者强制按4字节对齐修饰问题立刻消失。3. Linux下的UART驱动与用户态编程3.1 从设备节点到驱动栈到了嵌入式Linux平台UART的玩法就不一样了。应用层面对的不再是寄存器而是设备节点。常见的有ttyS0、ttyS1这类硬件串口节点也有ttyUSB0这种USB转串口芯片生成的节点还有ttyAMA0这种平台串口。USB转串口芯片里FT231X和FT232R是经常碰到的一类。内核里对应的驱动模块叫ftdi_sio多数发行版和嵌入式内核都默认编译进去了。驱动是否加载成功可以用lsusb查看设备ID再用dmesg看内核日志。如果插上USB转串口后设备节点没有出现通常就是内核缺少对应驱动或设备树没有配置。全志V3S这类片上系统各个串口之间差异不小。设备树里需要正确配置uart节点的时钟、管脚复用和别名。有一个细节经常让人困惑内核启动阶段的console和用户态的tty设备对应关系并不一定直观比如kernel启动日志输出在ttyS0但进入系统后应用打开/dev/ttyS0却可能发现无法通信因为console占用和设备节点权限等原因。事前理清设备树和启动参数能省很多时间。对嵌入式Linux驱动开发来说真正从零写一个串口驱动的情况其实不多因为内核已经提供了完整的serial core框架重点是理解uart_driver、uart_port、uart_ops这三个核心结构体之间的关系。用面向对象的思路理解它们uart_driver是设备的管理入口uart_port指明具体端口uart_ops定义了startup、shutdown、set_termios、tx_empty等回调函数。这个框架的熟悉程度直接影响你日后调外设驱动和做内核移植的深度。3.2 termios配置实操从open到数据收发在Linux下做串口编程本质上就是在和termios结构体打交道。我经常遇到有人直接抄代码但没搞懂每行的作用导致现场问题没法自己解决。下面给一个最常见的配置模板按我的习惯一步步来int fd open(/dev/ttyUSB0, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) return -1; struct termios opt; tcgetattr(fd, opt); cfsetispeed(opt, B115200); cfsetospeed(opt, B115200); opt.c_cflag | (CLOCAL | CREAD); opt.c_cflag ~CRTSCTS; opt.c_cflag ~CSIZE; opt.c_cflag | CS8; opt.c_cflag ~PARENB; opt.c_cflag ~CSTOPB; opt.c_iflag ~(IXON | IXOFF | IXANY); opt.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); opt.c_oflag ~OPOST; opt.c_cc[VMIN] 1; opt.c_cc[VTIME] 0; tcsetattr(fd, TCSANOW, opt);逐行解释几个关键点。CLOCAL表示忽略调制解调器状态不设置的话一旦检测不到DCD信号read可能被挂起CREAD表示使能接收。CRTSCTS是硬件流控位如果接线没有接RTS/CTS必须清零否则收不到数据。CS8和PARENB、CSTOPB组合起来就是8N1。ICANON是规范模式如果不清除输入会被按行缓冲read要等换行符才返回ECHO不清除的话发送出去的数据会回显到接收端OPOST是输出处理影响串口发送时是否把换行符做转换。这些都必须关掉才能拿到裸数据流。VMIN和VTIME是read的等待策略在非阻塞文件描述符配合下VMIN1表示至少收到1字节才返回VTIME0表示不限制等待时间。很多人配置完依旧收不到数据排查顺序一般是串口设备节点是否存在、当前用户是否在dialout组、配置是否被TCSAFLUSH而不是TCSANOW刷掉、fd是否被fcntl设置成了O_NONBLOCK。前两个问题实际占比非常高。3.3 调试工具链和Windows下的占用排查工欲善其事必先利其器。Linux下查看串口设备最常用的是ls /dev/ttyUSB*和dmesg | grep -i tty需要确认驱动是否加载可以用lsmod | grep ftdi_sio要查看当前串口被谁占用lsof是最直接的它会列出打开设备节点的进程PID和进程名。如果lsof没装也可以去/proc目录里找。比如打开/proc/pid/fd找到指向/dev/ttyUSB0的符号链接反查PID再通过ps确认是哪个进程。这个方法在嵌入式系统里尤其好用因为很多精简系统的lsof不一定齐全。Windows下的逻辑类似。Win7里查看串口被哪个程序占用我常用的办法是设备管理器里找到串口号先确认端口号然后用Process Explorer这类工具搜索COM3字符串直接列出占用句柄的进程。比去注册表翻Hardware\DeviceMap\SerialComm更直观尤其当你明确知道当前用的是COM几时。调试工具方面Linux下我常用minicom、picocom和screen用习惯了效率很高。Windows下的串口调试助手很多但我建议特别注意两点不要默认勾选发送新行或者追加回车换行否则会多出两个字节不要乱点DTR/RTS控制位这两个信号在一些设备上会被当作复位或唤醒信号误触发对方重启这个问题曾经困扰过一个同事好久。4. 高频问题排查与性能优化实录4.1 乱码、丢数据、打不开从现象反推根因串口联调问题高度集中在几个现象上下面这张表是我实际排查时常用的对照表现象可能原因优先排查方向收到乱码波特率不一致、时钟偏差大、收发电平标准不匹配先测量波形确认波特率实际值再检查配置偶发丢字节缓冲区太小、读取频率低、流控未开、中断被抢占加大缓冲区开启硬件流控降低中断优先级冲突首帧数据丢失上电方向切换太快485收发切换没有延时发送时先置发送使能延迟几个字节时间再发数据无法打开串口设备节点不存在、无权限、被进程独占dmesg查节点usermod加dialout组lsof查占用数据只有一半RXNE/ORE处理不当溢出后停止接收中断里先清ORE标志确保DR读走这是一份速查表但实际排查时我的经验是先把最容易验证的硬件链路排除掉用示波器或逻辑分析仪看TX/RX波形确认有没有帧、波特率对不对、有没有毛刺干扰再回到软件层面。波形正常说明硬件链路没大问题软件配置错误就会被快速聚焦。4.2 光耦隔离能传多远以及远距离传输的取舍工业场景里经常要把TTL电平的UART通过光耦隔离后再传输目的是切断地环路、抑制干扰。很多人问TTL UART通过光耦能传多远答案没有固定值取决于光耦响应速度、线缆类型和波特率。常规光耦如6N137响应速度可以达到10Mbit/s级别但实际传输距离受线缆电容衰减影响明显。实测下来115200波特率用普通杜邦线拉长到几米就会出现眼图变差如果用双绞线并且控制好线缆电容几十米也能稳定跑。但这并不意味着推荐你这么做长距离传输的最优解永远是换成RS485差分信号。TTL加光耦适合的是隔离需求比如变频器旁边采集信号而不是用来替代物理层规范。远距离高频还有一个容易忽略的问题阻抗不匹配会引起振铃和反射表现为帧尾多出毛刺或者数据误码。解决办法一是降低波特率二是加终端电阻三是用屏蔽双绞线且屏蔽层单点接地避免形成地环路。4.3 Linux串口接收数据丢失的深挖Linux下从串口接收数据丢失是嵌入式开发里的经典疑难杂症因为它的可能原因好几个层用户态问题、内核tty层问题、USB转串口芯片问题。我一般按由外到内的顺序做三分法排查。先确认是应用读得太慢还是数据根本没进内核。简单做法是加一个高强度读取线程循环调用read并把数据计数打印出来如果这样读数比业务逻辑下多很多说明用户态读取效率和调度有问题。此时优先调整读取策略小数据包频繁读取在系统负载高时很容易丢建议一次读大buffer比如每次read 4096字节同时把fd设置成阻塞模式配合select/poll多路复用而不是忙轮询。如果高强度读取仍然丢就要怀疑USB转串口芯片的latency timer。FTDI默认延迟是16ms数据到了芯片后要攒一攒才上报USB这在高频小数据包场景下很容易造成内核接收突发和丢失。解决方案是设置latency timer为1ms或者使用ftdi_sio驱动里的low_latency选项来降低延迟。最后还要检查内核line discipline层是否做了特殊处理。串口默认的实现可能把某些字节当成控制字符处理所以应用层必须设置raw模式把ICANON、ECHO、ISIG全部关掉否则数据可能在行规程层被吃掉。这个我遇到过不止一次。4.4 高可靠接收环形队列加状态机做嵌入式设备时间长了会发现很多时候丢数据的根源不在单点而在整体的处理模型。最稳的做法是把中断/DMA写入数据和应用层处理数据彻底解耦中间加一个线程安全的环形队列。环形队列的实现要明确生产者和消费者。UART接收中断是唯一生产者应用线程是唯一消费者只要保证两个指针都是原子操作或由同一内存模型保护就不需要加锁。需要注意避免在队列满时直接丢弃数据而不通知上层我习惯的做法是维护一个溢出计数器一旦溢出就把计数值加1并清空旧的未读数据这样即使数据丢了上层也知道丢了多少而不是误以为数据完整。在更小型的MCU上类似思路也可以用在按键扫描上。按键非阻塞扫描本质和串口接收一样都可以用状态机和缓冲区管理把硬件事件先攒起来主循环再按节奏处理。嵌入式开发里这种模式其实是共通的会了一个另一个上手很快。5. 学习路线与面试题参考5.1 从点亮串口到嵌入式架构师的基本功序列很多初学者问UART驱动到底要学到什么程度我梳理了一条比较务实的学习路径。第一阶段在裸机上通过寄存器操作实现循环收发能看懂原理图、手册和寄存器数据手册知道波特率怎么算。第二阶段加入中断接收和环形缓冲区完成一次变长数据的可靠接收同时会用示波器和逻辑分析仪验证波形。第三阶段进入嵌入式Linux掌握termios配置、tty驱动框架、设备树中串口节点配置能够用minicom、pyserial完成自动化联调。第四阶段把串口融入整个系统设计考虑多任务线程模型、DMA与中断的取舍、异常恢复机制、低功耗唤醒策略。对照这个路径嵌入式架构师和普通驱动开发者的差异不在于会不会调波特率而在于能不能在设计阶段就预估到串口对系统的影响比如中断优先级会不会拖累实时任务DMA缓冲区会不会和内存管理冲突数据协议里有没有防粘包半包的机制。这些能力要靠项目积累但至少要知道方向在哪里。5.2 几个面试必问的UART问题结合我参与过的面试UART方向的高频问题集中在这些点上UART和USART的区别USART多了同步时钟和时钟引脚所以既能异步又能同步UART帧为什么需要起始位和停止位起始位提供时间基准停止位保证帧间隔波特率误差怎么计算就是前面说的BRR公式加误差验证中断接收如何防止数据覆盖核心是环形缓冲加溢出标志UART和SPI、I2C的区别一个是异步、一个带时钟、一个带地址适用场景完全不同环形缓冲区为什么不需要锁前提是单生产者单消费者流控开与不开有什么区别不开流控时接收端处理不过来就会丢数据。这些问题背后其实就一个逻辑串口是嵌入式系统里最基础的通信外设考察的是你对时序、中断、缓冲区、任务模型这些基本概念的掌握程度而不只是背配置代码。回答的时候如果能用手画波形图再结合你自己项目里的真实问题比单纯背理论强得多。最后分享一个习惯可能也是这么多年做串口调试最值钱的经验任何串口问题第一件事不是翻代码而是拿示波器或逻辑分析仪看波形。确认波特率、起始位、数据位这些物理层面的东西往往能省掉半天时间。软件做得再花哨波形不对一切都白搭。第二件事是在代码里加几个简单的错误计数器比如溢出次数、帧错误次数、断线次数数据一异常先看这几个数值比猜快得多。项目细节各不相同但这套思路从MCU到Linux都是一样的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑