ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UART串口驱动开发实战:从STM32寄存器配置到Linux termios编程与DMA优化

UART串口驱动开发实战:从STM32寄存器配置到Linux termios编程与DMA优化 1. 从点灯到串口为什么UART是嵌入式驱动开发的第二道坎搞嵌入式的人都有一个共识能把LED点明白只算刚摸到门槛真正开始跟外部世界对话是从UART开始的。你想想单片机再强跑得再快如果它不能把内部的状态、传感器的数据、调试的信息吐出来那它就是一座孤岛。UART串口就是这座孤岛和外界之间的第一座桥而且是最简单、最可靠、最经得起时间考验的那座桥。我做了十多年嵌入式驱动带过不少新人发现一个很有意思的现象几乎所有人学驱动都是从GPIO点灯开始但真正让他们产生“我在做嵌入式”这种感觉的是第一次通过串口在电脑上看到自己打印出来的那行“Hello World”。那种感觉跟点灯完全不一样点灯只是证明“我能控制一个引脚”而串口打印证明的是“我能让芯片跟外界交流”。这一期我们就专门把UART串口这个东西掰开揉碎讲清楚。不管你是刚接触STM32的新手还是从51单片机转过来的老玩家又或者是做Linux驱动、FPGA、DSP的同行UART都是绕不过去的基本功。我会从协议原理讲到寄存器配置从裸机驱动讲到Linux下的串口编程从DMA优化讲到实际项目中踩过的坑尽量把这件事讲透。先说说UART到底是个什么东西。UART全称Universal Asynchronous Receiver/Transmitter翻译过来叫通用异步收发器。注意“异步”这两个字这是它跟SPI、I2C最本质的区别。异步意味着没有时钟线收发双方靠事先约定好的波特率来同步数据。这就好比两个人约好了每隔一秒钟说一个字虽然没有指挥在打拍子但双方心里都有数。这种方式的优点是省线两根线就能全双工通信缺点是双方对时间的把握必须足够准误差大了就会读错位。UART的典型应用场景太多了调试打印、模块通信比如GPS、蓝牙、WiFi模组、工业控制RS232、RS485、固件升级ISP下载、设备间短距离通信等等。你手里那块STM32开发板上的USB转串口芯片你电脑上用的串口调试助手你路由器固件烧写时用的那个接口背后全是UART在干活。这篇文章适合谁看如果你正在学STM32的串口配置如果你在Linux下写串口通信程序总是丢数据如果你在调试RS485总线时遇到各种奇怪问题如果你想知道DMA加串口怎么配合才能不丢包那这篇内容应该能帮到你。我会尽量少讲教科书上的废话多讲实际工程中真正用得上的东西。2. UART协议核心机制拆解从起始位到停止位的完整对话2.1 一帧数据是怎么组成的很多人用串口用了好几年其实从来没认真想过一帧数据到底长什么样。你配置串口的时候填的那些参数——波特率、数据位、停止位、校验位——它们到底在物理层面上意味着什么我画不了图但可以用文字给你描述清楚。UART通信时数据线在空闲状态保持高电平。当发送方要发数据时先把线拉低一个位的时间这就是起始位。接收方检测到这个下降沿就知道“有人要说话了”然后按照约定的波特率开始采样。紧接着起始位之后是数据位通常是8位从最低位开始发。数据位之后是可选的校验位用来做简单的错误检测。最后是停止位把线拉回高电平表示这一帧结束了。你可以把这想象成发电报先敲一下桌子表示“注意”然后开始发内容发完了再敲一下表示“完毕”。起始位和停止位就是这“敲桌子”的动作它们本身不携带信息但保证了收发双方能对齐。这里有个细节值得注意接收方并不是在起始位下降沿那一刻就开始读数据而是在检测到起始位之后延迟半个位周期再开始采样。为什么因为下降沿可能带有抖动在位的正中间采样最稳定。这就是所谓的“中点采样”策略。好的UART接收器还会做三取二投票就是在位的中点附近采样三次取多数结果进一步提高抗干扰能力。2.2 波特率的本质与误差容忍度波特率这个词听起来很专业其实说白了就是每秒传输的位数。9600波特率意味着每秒传9600个位每个位持续约104微秒。115200波特率下每个位约8.68微秒。这个时间决定了收发双方必须在这个时间窗口内完成采样。那波特率误差能容忍多少理论上如果双方波特率完全一致那接收方永远在位的中点采样最准确。但实际上晶振有误差分频计算也有舍入误差所以双方波特率总会有偏差。一般来说UART能容忍的累积误差在半个位周期以内也就是5%左右。但这是最坏情况实际工程中建议控制在2%以内。我见过很多人用STM32的时候随便选个波特率比如用72MHz主频去分频得到115200算出来的分频系数不是整数实际波特率跟标称值差了3%以上结果就是偶尔能通偶尔丢包。这种问题特别难查因为不是完全不工作而是“大部分时候正常”。所以配置波特率的时候一定要算一下实际误差。以STM32F103为例72MHz的PCLK2要得到115200波特率分频系数是72000000/(16115200)39.0625。寄存器里只能写整数39实际波特率变成72000000/(1639)115384误差0.16%这个可以接受。但如果你的时钟是36MHz分频系数是19.53取19的话实际波特率是118421误差2.8%这就比较危险了。2.3 全双工、半双工与单线半双工标准UART是全双工的TX和RX两根线独立工作可以同时收发。但有些场景下为了省线或者省引脚会用到半双工模式。半双工就是一根线同一时刻只能收或者发。STM32的单线半双工模式就是把TX和RX合并到一根线上内部自动切换方向。这里有个实际工程中经常遇到的问题单线半双工怎么跟全双工设备连接答案是需要外部电路做方向控制。通常用一个三极管或者专用的收发器芯片把单线半双工信号转换成全双工的TX和RX。RS485就是典型的半双工总线它用一对差分线传输通过DE/RE引脚控制收发方向。我踩过的一个坑是在单线半双工模式下发送完成之后必须及时释放总线否则会阻塞对方的回复。STM32的HAL库提供了HAL_HalfDuplex_EnableTransmitter和HAL_HalfDuplex_EnableReceiver函数来切换方向但切换时机很关键。如果发送完成中断里立刻切到接收可能最后一个停止位还没发完就会把总线拉低导致冲突。稳妥的做法是等TCTransmission Complete标志置位后再切换。2.4 硬件流控什么时候需要什么时候不需要RTS和CTS是UART的硬件流控信号。RTS是Request To Send表示“我准备好接收了”CTS是Clear To Send表示“你可以发给我了”。当接收方的缓冲区快满了就拉高RTS告诉对方暂停发送。什么时候需要硬件流控当你的数据量大、处理速度跟不上、又没有DMA的时候。比如你用115200波特率接收数据每字节约87微秒来一个如果CPU被其他中断占用了几百微秒没有流控的话数据就丢了。有了流控接收方可以在缓冲区快满时通知发送方暂停等处理完了再继续。但大多数嵌入式场景下我们不用硬件流控。为什么因为多两根线麻烦而且很多模块根本不支持流控。替代方案是用DMA接收让DMA在后台搬运数据CPU只需要处理完整的帧。或者用环形缓冲区加中断只要缓冲区够大偶尔的处理延迟不会丢数据。3. 从寄存器到HAL库STM32串口驱动的完整配置流程3.1 时钟与引脚配置容易被忽略的第一步很多人拿到STM32的串口例程直接复制粘贴就能跑但一旦换到另一个型号或者另一组引脚就出问题。根本原因是对时钟和引脚配置理解不够。STM32的USART1挂在APB2总线上USART2/3挂在APB1上。使能时钟的时候要区分清楚。比如STM32F103USART1在APB2时钟是72MHzUSART2在APB1时钟是36MHz。这两个时钟频率不同算波特率分频的时候要分别计算。引脚配置方面TX要配成复用推挽输出RX要配成浮空输入或者上拉输入。我一般推荐RX配上拉输入这样当对方没连接或者没发送时RX线保持高电平不会因为悬空而产生随机噪声导致误触发。这个细节在调试时特别重要我遇到过好几次因为RX悬空导致串口不断收到0x00的问题。以STM32F103C8T6的USART1为例PA9是TXPA10是RX。配置代码大概是这样// 使能GPIOA和USART1时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // 配置PA9为复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置PA10为上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure);如果你用的是STM32F4系列引脚配置多了一个复用功能选择寄存器AFR需要指定具体的AF编号。比如USART1的PA9/PA10对应AF7。这个在HAL库的HAL_GPIO_Init里会自动处理但如果你用寄存器操作就要注意。3.2 波特率计算不只是填个数字那么简单前面提到了波特率误差的问题这里展开说一下计算方法。STM32的波特率计算公式是波特率 fCK / (16 * USARTDIV)其中USARTDIV是一个定点数整数部分在BRR寄存器的[15:4]位小数部分在[3:0]位。比如要得到115200fCK72MHzUSARTDIV 72000000 / (16 * 115200) 39.0625整数部分是39小数部分是0.0625。小数部分乘以16得到1所以BRR寄存器的值应该是(39 4) | 1 0x271。实际波特率 72000000 / (16 * 39.0625) 115200误差为0。但如果你直接写39实际波特率就是115384误差0.16%。HAL库的HAL_UART_Init函数会自动计算BRR值你只需要在UART_HandleTypeDef结构体里填上正确的波特率。但前提是你传给HAL的时钟频率是对的。很多人用CubeMX生成代码时钟树配置错了导致HAL计算出来的波特率偏差很大。所以用CubeMX的时候一定要检查时钟树确认APB总线的实际频率。3.3 中断接收与环形缓冲区稳定接收的关键轮询方式接收串口数据是最简单的但也是最不实用的。你总不能一直让CPU在那里等数据吧。中断方式就好多了每收到一个字节触发一次中断在中断里把数据存到缓冲区。但直接在中断里处理数据也有问题。如果数据量大中断太频繁CPU大部分时间都在进出中断主循环没时间干活。而且如果中断处理时间太长可能影响其他中断的响应。我的做法是用环形缓冲区加中断。中断里只做一件事把接收到的字节存到环形缓冲区然后更新写指针。主循环从环形缓冲区读数据处理完整的帧。这样中断处理时间极短不会阻塞其他中断。环形缓冲区的实现要点缓冲区大小最好是2的幂这样可以用位与运算代替取模提高效率写指针只在中断里修改读指针只在主循环里修改不需要加锁需要判断缓冲区满的情况满了就丢弃新数据或者覆盖旧数据看具体需求#define UART_BUF_SIZE 256 // 必须是2的幂 static uint8_t uart_buf[UART_BUF_SIZE]; static volatile uint16_t uart_wr 0; static volatile uint16_t uart_rd 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint16_t next (uart_wr 1) (UART_BUF_SIZE - 1); if (next ! uart_rd) { // 缓冲区未满 uart_buf[uart_wr] data; uart_wr next; } // 满了就丢弃也可以选择覆盖 } } int uart_read(uint8_t *buf, int len) { int count 0; while (count len uart_rd ! uart_wr) { buf[count] uart_buf[uart_rd]; uart_rd (uart_rd 1) (UART_BUF_SIZE - 1); } return count; }这个模式我用了很多年在各种项目里都很稳。关键点是中断里只搬数据不做任何耗时操作。3.4 DMA接收彻底解放CPU如果你的串口数据量很大比如接了GPS模块每秒输出几百字节或者接了高速通信模块那中断方式可能还是不够。这时候就该DMA上场了。DMA接收的原理是配置DMA通道让它在串口收到数据时自动把数据从数据寄存器搬到内存缓冲区。CPU完全不用管等一帧数据收完了再处理。STM32的串口DMA接收有两种模式普通模式和循环模式。普通模式适合定长数据接收循环模式适合不定长数据接收。对于不定长数据常用的技巧是用DMA加空闲中断。空闲中断在总线空闲一个字节时间后触发表示一帧数据结束了。// 配置DMA接收 HAL_UART_Receive_DMA(huart1, dma_buf, DMA_BUF_SIZE); // 在空闲中断里处理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算收到的数据长度 uint16_t len DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理数据 process_data(dma_buf, len); // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, dma_buf, DMA_BUF_SIZE); } }这个方案我在多个项目中用过115200波特率下连续接收几KB数据都不丢包。注意DMA缓冲区要足够大至少能装下一帧最大长度的数据。4. Linux下的串口编程从设备节点到termios配置4.1 找到你的串口设备在Linux下搞串口第一步是找到设备节点。常见的串口设备节点有/dev/ttyS0、/dev/ttyS1等物理串口/dev/ttyUSB0、/dev/ttyUSB1等USB转串口/dev/ttyACM0等USB CDC设备/dev/ttyAMA0等树莓派等ARM板上的串口怎么知道哪个设备对应哪个串口可以用ls -l /dev/serial/by-id/查看这个目录下的链接名字包含了设备的厂商和序列号信息非常直观。或者用dmesg | grep tty查看内核启动时识别的串口信息。Ubuntu下查看串口设备的命令ls /dev/ttyS* ls /dev/ttyUSB* dmesg | grep tty ls -l /dev/serial/by-id/如果你用的是虚拟机比如VMware或者VirtualBox需要先把USB转串口设备从宿主机分配到虚拟机里。VMware的操作是虚拟机设置 - USB控制器 - 添加设备。VirtualBox类似在设备 - USB里勾选对应的设备。这一步经常被忽略很多人插上USB转串口线发现虚拟机里看不到设备就是因为没做USB透传。4.2 termios配置Linux串口编程的核心Linux下串口编程的核心是termios结构体。它包含了波特率、数据位、停止位、校验位、流控等所有配置。跟STM32的寄存器配置相比termios的配置更抽象但逻辑是一样的。#include termios.h #include fcntl.h #include unistd.h int fd open(/dev/ttyUSB0, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios options; tcgetattr(fd, options); // 设置波特率 cfsetispeed(options, B115200); cfsetospeed(options, B115200); // 8位数据位无校验1位停止位 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSTOPB; // 1位停止位 options.c_cflag ~CSIZE; options.c_cflag | CS8; // 8位数据位 // 启用接收忽略调制解调器控制线 options.c_cflag | (CLOCAL | CREAD); // 原始模式不做任何处理 options.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); options.c_iflag ~(IXON | IXOFF | IXANY); options.c_oflag ~OPOST; // 设置超时 options.c_cc[VMIN] 0; options.c_cc[VTIME] 10; // 1秒超时 tcsetattr(fd, TCSANOW, options);这段代码是Linux串口编程的模板几乎每个项目都是在这个基础上改。几个关键点O_NOCTTY不要把这个串口当成控制终端否则程序退出时可能影响系统O_NDELAY非阻塞模式读不到数据时立即返回CLOCAL | CREAD忽略调制解调器状态线启用接收VMIN和VTIME控制read函数的阻塞行为。VMIN0VTIME10表示最多等1秒有数据就立即返回4.3 读写操作与数据丢失问题Linux下串口读写用read和write函数就行。但很多人会遇到数据丢失的问题尤其是接收大量数据的时候。数据丢失的常见原因有几个第一read函数读得太慢。如果你的程序在两次read之间做了很多其他事情串口缓冲区可能溢出。Linux内核的串口缓冲区默认是4KB左右115200波特率下大概0.35秒就填满了。解决办法是提高read频率或者用select/poll监听串口有数据就立即读。第二termios配置不对。比如VMIN和VTIME设置不当导致read函数的行为不符合预期。VMIN0VTIME0表示完全非阻塞有数据就读没数据立即返回。VMIN1VTIME0表示至少读到一个字节才返回。VMIN0VTIME0表示等待VTIME个0.1秒有数据就返回。第三串口被其他程序占用了。Linux下串口是独占设备一个程序打开了另一个程序就打不开。如果你发现串口打不开先检查是不是有别的程序在用。可以用lsof /dev/ttyUSB0查看哪个进程占用了串口。# 查看串口被哪个程序占用 lsof /dev/ttyUSB0 fuser /dev/ttyUSB04.4 串口参数的可移植性问题Linux下的波特率是用宏定义的比如B115200、B9600。但有些非标准波特率比如B500000不是所有平台都支持。如果你需要非标准波特率可以用termios2结构体和ioctl的TCSETS2命令直接设置波特率数值。#include asm/termbits.h #include sys/ioctl.h struct termios2 tio; ioctl(fd, TCGETS2, tio); tio.c_cflag ~CBAUD; tio.c_cflag | BOTHER; tio.c_ispeed 500000; tio.c_ospeed 500000; ioctl(fd, TCSETS2, tio);这个方法在x86和ARM Linux上都能用但需要内核支持。嵌入式Linux一般都没问题。5. 调试实战那些年我们踩过的串口坑5.1 串口调试助手怎么选怎么用串口调试助手是每个嵌入式工程师的必备工具。Windows下常用的有SSCOM、友善串口调试助手、XCOM等。Linux下可以用minicom、picocom、screen。我个人的习惯是Windows下用SSCOMLinux下用picocom。SSCOM的功能很全支持HEX显示、定时发送、多条发送、数据校验等。几个实用技巧开启时间戳方便分析数据间隔用多条发送功能模拟协议交互用文件保存功能记录长时间的数据注意HEX显示和ASCII显示的切换很多时候数据看起来是乱码切到HEX就清楚了picocom的使用# 安装 sudo apt install picocom # 连接串口 picocom -b 115200 /dev/ttyUSB0 # 退出CtrlA 然后 CtrlXminicom的配置稍微复杂一点需要先sudo minicom -s进入配置界面设置串口参数。5.2 常见问题速查表问题现象可能原因排查方法完全收不到数据TX/RX接反交换TX和RX试试收到乱码波特率不匹配检查双方波特率设置偶尔丢数据波特率误差大计算实际波特率误差数据多了就丢缓冲区溢出加大缓冲区或用DMA只能收不能发流控配置错误检查RTS/CTS配置上电后收到0x00RX悬空RX加上拉电阻通信距离短TTL电平衰减改用RS232或RS485干扰严重没有屏蔽或共地加屏蔽线确保共地5.3 几个真实案例的排查过程案例一STM32串口打印正常但接收数据偶尔出错。查了半天发现是波特率误差问题。客户用的是内部HSI时钟精度只有1%左右加上分频误差累积起来超过了容忍范围。换成外部晶振后问题消失。这个案例告诉我们对通信可靠性要求高的场景一定要用外部晶振。案例二Linux下串口接收大量数据时丢包。用cat /proc/tty/driver/serial查看串口状态发现overrun计数在增加。原因是程序在两次read之间做了太多事情。改成用select监听有数据立即读问题解决。案例三RS485总线通信不稳定。查到最后发现是终端电阻的问题。RS485总线两端需要各接一个120欧姆的终端电阻中间节点不需要。客户在每个节点都接了终端电阻导致总线负载过重信号幅度下降。去掉多余的终端电阻后通信稳定。案例四USB转串口在Windows下能用在Linux下不能用。原因是FT232R芯片的驱动问题。Linux内核自带ftdi_sio驱动但有些定制版的内核没有编译进去。用lsmod | grep ftdi检查驱动是否加载没有的话需要手动加载或者重新编译内核。5.4 硬件层面的注意事项串口通信虽然是软件配置为主但硬件层面的问题往往更难查。几个经验TTL电平的串口传输距离很短一般不超过1米。超过这个距离就要考虑转成RS232传输距离约15米或者RS485传输距离可达1200米。我见过有人用TTL串口拉了两米多的线结果通信时好时坏换成RS485后立刻稳定。共地问题也很关键。两个设备通过串口连接时GND必须连在一起。如果两个设备分别供电不共地的话信号电平没有参考点通信必然出问题。我遇到过用光耦隔离串口的场景光耦两端必须分别供电否则隔离就失去意义了。如果通信环境干扰大比如附近有电机、变频器建议用屏蔽线并且屏蔽层单端接地。双端接地可能形成地环路反而引入干扰。6. 进阶话题DMA、FIFO与高性能串口设计6.1 串口DMA的深入理解DMA这个东西用好了是神器用不好是灾难。串口DMA接收的核心是让DMA在后台搬运数据CPU只在必要时介入。STM32的串口DMA有几个关键配置传输方向外设到内存接收或内存到外设发送优先级多个DMA通道同时工作时高优先级的先响应数据宽度字节、半字、字模式普通模式传完就停或循环模式传完自动重载对于接收我一般用循环模式加空闲中断。循环模式下DMA缓冲区是一个环形DMA写指针不断前进写满后自动回到开头。空闲中断触发时当前写指针的位置就是这一帧数据的结束位置。但这里有个问题如果一帧数据特别长超过了DMA缓冲区大小就会覆盖前面的数据。所以DMA缓冲区要足够大至少能装下最大帧长度的两倍。对于发送用普通模式就行。发送前把数据准备好启动DMA发完了触发发送完成中断。注意发送完成中断和发送寄存器空中断的区别TC是最后一个位都发完了TXE是数据寄存器空了可以写下一个数据。用DMA发送时等TC中断才能确保数据真正发出去了。6.2 串口FIFO的硬件与软件实现很多高端MCU的串口自带硬件FIFO比如STM32H7系列的USART有16字节的FIFO。硬件FIFO的好处是减少中断次数比如设置FIFO阈值为8收到8个字节才触发一次中断CPU效率大大提高。如果你的MCU没有硬件FIFO可以用软件模拟。软件FIFO就是前面说的环形缓冲区本质上是一样的。区别在于硬件FIFO由硬件自动管理软件FIFO需要中断服务程序来搬运数据。软件FIFO的设计要点读写指针用volatile修饰防止编译器优化缓冲区大小用2的幂用位与代替取模区分“空”和“满”的判断可以用一个计数器或者保留一个空位中断里只写不读主循环里只读不写避免竞争6.3 串口协议设计从裸数据到可靠帧串口本身只负责传输字节流不负责帧的划分和错误检测。实际项目中我们需要在串口之上设计一层协议。最简单的协议是固定长度帧比如每帧10个字节第1个字节是帧头0xAA最后一个是校验和。接收方每收到10个字节就检查帧头和校验和。稍微复杂一点的是变长帧用帧头和帧尾界定比如0xAA开头0x55结尾中间是数据。这种协议的问题是如果数据里恰好出现0xAA或0x55就会误判。解决办法是用转义字符比如把数据里的0xAA转义成0xAA 0x010x55转义成0xAA 0x02。更可靠的是用长度字段比如帧头0xAA第二个字节是长度后面是数据最后是CRC校验。接收方先读帧头再读长度然后根据长度读数据最后校验CRC。这种协议效率高也不怕数据里出现特殊字节。我在实际项目中常用的协议格式| 帧头(1B) | 长度(1B) | 命令(1B) | 数据(NB) | CRC16(2B) |帧头固定0x5A长度是数据段的长度命令表示帧类型CRC16校验从长度到数据的全部内容。这个协议简单可靠实现起来也不复杂。6.4 串口通信的性能优化如果你的串口通信量很大比如每秒几百KB那性能优化就很重要了。几个方向第一提高波特率。115200不够就上921600甚至3Mbps。但高波特率对硬件要求高线要短晶振要准。第二用DMA减少CPU占用。DMA搬运数据不占CPU时间CPU只需要处理完整的帧。第三优化协议。减少不必要的字节比如用二进制协议代替ASCII协议。ASCII协议可读性好但效率低比如发送数字1234ASCII需要4个字节二进制只需要2个字节。第四用双缓冲。一个缓冲区在接收数据时另一个缓冲区在处理数据交替使用。这样接收和处理可以并行不会互相阻塞。7. 跨平台串口开发从STM32到Linux到FPGA7.1 STM32串口开发的几个实用技巧用STM32CubeMX生成串口代码很方便但有几个地方要注意第一CubeMX生成的代码默认开启了串口全局中断但如果你不用中断接收可以在NVIC配置里关掉减少不必要的开销。第二HAL库的HAL_UART_Receive函数是阻塞的在中断里调用会死锁。中断里要用HAL_UART_Receive_IT或者HAL_UART_Receive_DMA。第三HAL库的串口发送函数HAL_UART_Transmit也是阻塞的发送大量数据时会占用CPU。可以用HAL_UART_Transmit_IT或者HAL_UART_Transmit_DMA。第四重定向printf到串口是调试利器但要注意printf本身是阻塞的在中断里调用printf可能导致问题。可以用snprintf格式化到缓冲区然后用DMA发送。// 重定向printf到USART1 int __io_putchar(int ch) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; } // 或者用DMA版本非阻塞 void uart_printf(const char *fmt, ...) { static char buf[128]; va_list args; va_start(args, fmt); int len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); HAL_UART_Transmit_DMA(huart1, (uint8_t *)buf, len); }7.2 Linux串口编程的高级话题Linux下串口编程除了基本的termios配置还有一些高级话题串口的多线程处理。一个线程负责读一个线程负责写用互斥锁保护共享资源。读线程用select或者epoll监听串口有数据就读。写线程从发送队列取数据写到串口。串口的信号处理。串口设备支持SIGIO信号可以配置成有数据时触发信号在信号处理函数里读数据。但信号处理函数里能做的事情有限一般不建议这么做。串口的权限管理。普通用户默认没有权限访问串口设备需要把用户加到dialout组或者用sudo运行。更好的做法是写一个udev规则自动设置串口设备的权限。# 把用户加到dialout组 sudo usermod -a -G dialout $USER # 或者创建udev规则 # /etc/udev/rules.d/50-usb-serial.rules SUBSYSTEMtty, ATTRS{idVendor}0403, ATTRS{idProduct}6001, MODE06667.3 FPGA实现UARTVerilog代码要点用FPGA实现UART是数字电路设计的经典练习。核心模块包括波特率发生器、发送模块、接收模块。波特率发生器就是一个计数器计数到fCLK/baudRate时产生一个使能脉冲。发送模块在使能脉冲到来时按照状态机依次输出起始位、数据位、校验位、停止位。接收模块在检测到起始位后在每位的中点采样。Verilog实现的关键点用状态机控制发送和接收流程接收采样时做三取二投票提高抗干扰能力用FIFO缓冲收发数据匹配不同时钟域注意跨时钟域的信号同步用两级触发器消除亚稳态// 波特率发生器 always (posedge clk) begin if (baud_cnt BAUD_DIV - 1) begin baud_cnt 0; baud_tick 1; end else begin baud_cnt baud_cnt 1; baud_tick 0; end end // 接收状态机 always (posedge clk) begin case (rx_state) IDLE: if (!rx) begin rx_state START; bit_cnt 0; end START: if (baud_tick) begin rx_state DATA; end DATA: if (baud_tick) begin rx_data[bit_cnt] rx; if (bit_cnt 7) rx_state STOP; else bit_cnt bit_cnt 1; end STOP: if (baud_tick) begin rx_state IDLE; rx_done 1; end endcase end7.4 不同平台串口开发的对比平台配置方式中断/DMA调试工具适用场景STM32寄存器/HAL库支持SSCOM裸机、RTOSLinuxtermios支持picocom应用层开发FPGAVerilog自定义逻辑分析仪高速、定制ArduinoSerial库有限支持串口监视器快速原型DSP寄存器/CSL支持CCS信号处理8. 串口调试的独家避坑经验8.1 硬件连接的几个致命错误TX接TXRX接RX。这是新手最常犯的错误。记住一个原则发送对接收接收对发送。A的TX接B的RXA的RX接B的TX。不共地。两个设备通过串口连接GND必须连在一起。我见过有人用两个独立的电源给两个设备供电只连了TX和RX结果通信时好时坏。原因是两个电源的地电位不同信号电平没有共同的参考点。电平不匹配。STM32的串口是3.3V TTL电平如果直接接5V的串口设备可能烧坏STM32的引脚。反过来3.3V的输出接5V的输入5V设备可能识别不到高电平。这种情况下需要电平转换电路。8.2 软件配置的常见陷阱波特率不匹配。这是最常见的问题。双方必须用相同的波特率。如果你不确定对方的波特率可以逐个尝试常用的值9600、19200、38400、57600、115200。数据位、停止位、校验位不匹配。默认配置是8N18位数据位无校验1位停止位但有些设备用7E1或者8E1。如果通信不正常检查这些参数。流控配置错误。如果双方都开启了硬件流控但RTS/CTS没有连接通信会阻塞。如果不确定先关闭流控。8.3 调试工具的使用技巧用示波器或者逻辑分析仪看波形。当串口通信出问题时用示波器看TX和RX的波形是最直接的排查方法。你可以看到波特率是否正确数据位是否完整有没有干扰。用串口调试助手的HEX模式。很多时候数据看起来是乱码切到HEX模式就能看出规律。比如你发送的是ASCII字符串但收到的是HEX数据说明对方可能在做二进制协议。用回环测试。把TX和RX短接发送什么就应该收到什么。这是验证串口硬件和驱动是否正常的最简单方法。8.4 几个容易被忽略的细节串口线太长。TTL串口的传输距离一般不超过1米。如果线太长信号衰减和干扰会导致通信失败。解决办法是降低波特率或者改用RS232/RS485。电源噪声。如果串口通信时好时坏可能是电源噪声导致的。在串口线附近加磁环或者在电源上加滤波电容可以改善。晶振精度。前面反复提到波特率精度取决于晶振精度。如果对通信可靠性要求高一定要用外部晶振不要用内部RC振荡器。中断优先级。如果串口中断优先级太低可能被其他中断阻塞导致数据丢失。如果优先级太高可能影响其他关键中断。需要根据实际需求平衡。9. 串口在典型项目中的应用实例9.1 用串口调试PID参数做电机控制或者温控项目时PID参数整定是个麻烦事。每次改参数都要重新编译下载效率很低。用串口可以实时调整PID参数。做法是在串口接收中断里解析命令比如“P1.5”表示设置比例系数为1.5。主循环里用当前的PID参数计算输出。这样不用重新下载程序就能调参效率提高很多。// 简单的命令解析 void process_command(char *cmd) { if (cmd[0] P cmd[1] ) { pid_kp atof(cmd[2]); printf(Kp set to %.2f\n, pid_kp); } else if (cmd[0] I cmd[1] ) { pid_ki atof(cmd[2]); printf(Ki set to %.2f\n, pid_ki); } else if (cmd[0] D cmd[1] ) { pid_kd atof(cmd[2]); printf(Kd set to %.2f\n, pid_kd); } }9.2 串口OTA升级用串口做固件升级是很常见的需求。基本流程是设备进入Bootloader通过串口接收新的固件数据写入Flash然后跳转到新固件运行。协议设计要点握手设备发送就绪信号上位机确认传输分包发送固件数据每包带序号和CRC校验全部传输完成后做整体校验确认设备确认升级成功跳转到新固件STM32的串口OTA一般用YModem协议很多串口调试助手都支持。YModem协议有完善的握手、分包、重传机制可靠性很高。9.3 串口与传感器通信很多传感器模块通过串口输出数据比如GPS模块、激光测距模块、空气质量传感器等。跟这些模块通信的关键是理解它们的协议格式。以GPS模块为例它输出NMEA 0183格式的ASCII语句比如$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47解析NMEA语句的方法先找帧头$然后按逗号分割字段最后校验后面的校验和。校验和是$和之间所有字符的异或。int nmea_checksum(const char *s) { int sum 0; while (*s *s ! *) { sum ^ *s; } return sum; }9.4 串口在工业控制中的应用工业控制中RS485是最常用的串口物理层。它用差分信号传输抗干扰能力强传输距离远支持多点通信。RS485总线的基本规则总线两端各接一个120欧姆终端电阻所有节点A接AB接B主站轮询从站应答避免冲突总线空闲时保持高电平Modbus RTU是RS485上最常用的协议。它的帧格式是地址(1B) 功能码(1B) 数据(NB) CRC16(2B)。地址0是广播所有从站都接收但不应答。10. 串口开发的未来与个人经验总结串口这个东西从计算机诞生之初就有了到现在几十年过去了依然是最常用的通信接口之一。为什么因为它简单、可靠、成本低。虽然USB、以太网、WiFi这些高速接口发展很快但在嵌入式领域串口依然是不可替代的。我个人的经验是把串口学好不仅仅是学会配置几个寄存器更重要的是理解异步通信的本质、缓冲区的设计、协议的分层。这些思想在SPI、I2C、CAN、USB等接口上都是通用的。最后分享几个我在实际项目中总结的小技巧第一永远不要相信“默认配置”。每次拿到一个新的模块或者新的芯片先确认串口参数波特率、数据位、停止位、校验位、流控。这些参数不匹配后面全是白费功夫。第二调试串口问题时先硬件后软件。用示波器看波形确认硬件连接和电平正常再去查软件配置。很多问题其实是硬件问题软件查半天也查不出来。第三串口接收一定要用缓冲区。不管数据量大小中断里只搬数据主循环里处理。这个习惯能避免90%的丢数据问题。第四协议设计要考虑异常情况。帧头错了怎么办CRC错了怎么办超时了怎么办把这些异常处理好了通信才真正可靠。第五多动手。串口这东西看十遍书不如动手调一次。找个开发板接上USB转串口自己写代码收发数据遇到问题解决问题这才是最快的成长方式。串口是嵌入式工程师的基本功也是连接数字世界和物理世界的桥梁。把这座桥修好后面的路才能走得稳。
返回列表