多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

嵌入式入门主线:串口、HAL库、printf重定向与DMA实战指南

嵌入式入门主线:串口、HAL库、printf重定向与DMA实战指南 嵌入式入门这件事说难也难说简单也简单。难的是知识点太散串口、GPIO、中断、DMA、RTOS、Linux每一样都能单独写一本书简单的是只要抓住一条主线把最核心的几个外设吃透剩下的都是触类旁通。我带过不少刚入行的朋友也见过太多人卡在点灯之后不知道下一步干什么的阶段。这篇内容就围绕嵌入式学习这条主线把串口、HAL库、printf重定向、DMA这几个高频关键词串起来讲清楚它们之间的关系、各自的学习优先级以及在实际项目中怎么用、怎么避坑。不管你是刚买开发板的新手还是已经能跑通几个例程但总觉得心里没底的进阶者应该都能从里面找到对自己有用的东西。1. 嵌入式学习路线的真实优先级排序1.1 为什么大多数人从串口开始是对的很多人拿到开发板第一件事是点灯第二件事就是串口。这个顺序不是随便定的背后有很实在的逻辑。点灯验证的是工具链和下载流程是否正常而串口验证的是你和芯片之间能不能对话。没有串口你后面调试任何外设都像蒙着眼睛走路——程序跑没跑起来、卡在哪一步、变量值对不对全靠猜。串口之所以成为嵌入式学习的第一个通信外设原因有几个。第一它的硬件成本极低一根USB转串口线就能搞定几乎每块开发板都引出了串口引脚。第二它的协议简单到可以手算起始位、数据位、校验位、停止位时序一目了然不像SPI、I2C那样需要对着时序图抠细节。第三它是后续所有调试手段的基础printf重定向、日志输出、shell交互全都建立在串口能正常收发的前提上。我一般建议新手在串口上至少花一周时间不是把例程跑通就完事而是要搞清楚波特率怎么算出来的、为什么有时候会丢数据、中断接收和轮询接收的区别在哪、DMA模式又解决了什么问题。这几个问题想明白了后面学其他通信外设会轻松很多。1.2 从裸机到HAL库再到RTOS和Linux的阶梯嵌入式学习有个很典型的阶梯结构但很多人容易跳步。我见过不少人裸机还没写利索就急着上RTOSRTOS的任务调度还没搞明白又去碰嵌入式Linux。结果就是每个都懂一点每个都不精。合理的路径应该是这样的先用寄存器或者标准库把GPIO、串口、定时器、中断这几个基础外设写一遍理解底层是怎么操作的。然后过渡到HAL库感受一下抽象层带来的便利和代价。接着在裸机基础上引入状态机和环形缓冲区处理稍微复杂一点的逻辑。等到单线程处理不过来了再上RTOS。最后如果项目需要跑文件系统、网络协议栈、图形界面再考虑嵌入式Linux。这个阶梯不是绝对的但每一步都有它存在的意义。跳过寄存器直接学HAL库你会不知道HAL_UART_Transmit里面到底干了什么跳过裸机环形缓冲区直接上RTOS你会不理解为什么队列和信号量要那样设计。1.3 不同基础的人该怎么分配时间如果你是完全零基础我建议的时间分配是这样的前两周集中搞定开发环境搭建、GPIO、串口这三个目标是能独立写出一个通过串口接收命令控制LED的程序。接下来两周啃定时器和中断把按键消抖、PWM输出、输入捕获这几个典型场景跑一遍。再往后两周学DMA和ADC理解数据搬运和模拟量采集。一个月之后你基本具备了看懂大多数入门项目的能力。如果你有单片机基础但没接触过HAL库那重点就放在理解HAL库的设计哲学上。HAL库把很多底层操作封装成了句柄和回调好处是移植方便坏处是效率不如直接操作寄存器而且出问题时排查链路更长。你需要花时间搞清楚HAL_UART_Receive_IT和HAL_UART_Receive_DMA这两个函数的内部流程知道它们分别在什么时候触发回调、什么时候返回错误。如果你已经会裸机想往Linux方向走那串口依然是你最好的朋友。嵌入式Linux下调试串口用的是/dev/ttySx或者/dev/ttyUSBx操作方式和裸机完全不同但底层原理是相通的。你在裸机上积累的波特率、流控、中断这些概念在Linux下照样用得上。2. 串口配置里那些容易翻车的细节2.1 波特率、时钟源和误差计算波特率这个东西看起来就是填个数字的事但实际项目中因为波特率不对导致通信失败的情况太常见了。根本原因在于波特率不是凭空产生的它是从系统时钟分频出来的分频系数不一定是整数所以实际波特率和理论值之间会有误差。以STM32为例串口的时钟源通常是APB总线时钟。假设APB2时钟是72MHz你要配115200的波特率分频系数就是72M除以115200约等于625。这个625会拆成整数部分和小数部分写进BRR寄存器。如果除不尽就会有误差。一般来说误差在2%以内通信是可靠的超过3%就可能出现偶发丢包或者完全收不到数据。我遇到过好几次这样的情况代码里明明写的115200串口助手也是115200但就是收不到数据。最后查出来是系统时钟配置错了实际APB时钟不是72MHz而是36MHz导致实际波特率变成了57600。所以配置串口之前一定要先确认系统时钟树知道自己用的那条APB总线到底跑多少频率。提示用CubeMX配置串口时它会自动帮你算好分频系数并显示实际波特率。如果实际值和目标值差太多它会标红警告。养成看这个警告的习惯能省掉很多排查时间。2.2 中断接收与轮询接收的取舍轮询接收就是在一个while循环里不断调用接收函数看有没有数据。这种方式最简单但CPU利用率极低而且一旦主循环里有其他耗时操作就可能错过数据。中断接收则是数据到了触发中断在中断服务函数里把数据读走。效率高很多但要注意中断服务函数里不能做太耗时的事情。我一般这样建议如果串口数据量很小比如偶尔发个命令轮询就够了代码简单不容易出问题。如果数据是连续不断的比如传感器以100Hz往上发数据那必须用中断或者DMA。中断方式下每收到一个字节就进一次中断如果波特率很高中断频率会非常可观。115200波特率下每秒最多传11520个字节也就是每秒一万多次中断。这个频率对STM32来说还能扛住但如果同时还有别的中断就要考虑优先级和中断嵌套的问题了。2.3 环形缓冲区中断和主循环之间的缓冲带中断接收有个经典问题中断里收到的数据放哪如果直接在主循环里处理处理速度跟不上接收速度数据就会丢。解决办法就是加一个环形缓冲区中断负责往缓冲区里写主循环负责从缓冲区里读两边各干各的通过读写指针来协调。环形缓冲区的实现不复杂核心就是两个指针写指针在中断里更新读指针在主循环里更新。当写指针追上读指针时说明缓冲区满了这时候要么丢弃新数据要么覆盖旧数据取决于你的策略。当读指针追上写指针时说明缓冲区空了主循环就等着。这里有个坑要注意读写指针的更新必须是原子的。在32位单片机上一个指针的读写通常是一条指令不会被打断所以一般不用加锁。但如果你用的是8位机或者指针操作被编译器拆成了多条指令那就需要在读写指针时关中断。我见过有人在这个地方翻车现象是偶尔丢一包数据查了很久才定位到指针竞争。3. HAL库下printf重定向的完整实现与坑点3.1 为什么需要重定向以及它到底做了什么printf是C标准库里的函数默认输出到stdout。在单片机上没有屏幕也没有控制台stdout是没有去向的。重定向就是告诉printf把你要输出的字符通过串口发出去。这样你就能用printf(value %d\n, val)这种方式在串口助手上看变量值调试效率比点灯高到不知道哪里去了。重定向的原理其实很简单。C标准库的printf最终会调用一个叫fputc或者_write的底层函数来输出单个字符。我们只需要重写这个函数在里面调用HAL_UART_Transmit把字符发出去就行。不同的编译器调用的底层函数不一样Keil MDK用的是fputcGCC用的是_writeIAR又不一样。所以网上抄来的代码有时候编译不过就是因为编译器不匹配。3.2 Keil和GCC下的两种写法在Keil MDK环境下标准做法是重写fputc函数#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }这段代码放在main.c里然后确保勾选了Use MicroLIB。MicroLIB是Keil提供的一个精简版C库它不依赖操作系统适合单片机使用。如果不勾选MicroLIB标准库会尝试初始化一堆你用不到的东西可能导致程序卡死。在GCC环境下比如STM32CubeIDE或者PlatformIO要重写的是_write函数#include unistd.h int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }注意_write一次可以发多个字符比fputc一个字符一个字符发效率高。如果你用的是newlib-nano可能还需要在链接选项里加上-u _printf_float才能支持浮点数打印否则printf(%f)会输出空或者乱码。3.3 重定向之后printf变慢甚至卡死的原因printf重定向之后最常见的两个问题一是输出乱码二是程序卡死。乱码通常是波特率不匹配或者时钟配置错误导致的前面已经讲过。还有一种可能是串口引脚配置错了比如TX和RX接反了或者复用功能没开。卡死的原因就更有意思了。HAL_UART_Transmit是一个阻塞函数它会一直等到数据发完才返回。如果你在中断服务函数里调用printf而串口发送又依赖中断比如用了DMA或者中断发送模式就可能出现死锁。另外如果串口初始化还没完成就调用printfHAL_UART_Transmit会因为句柄状态不对而卡在超时等待里。我个人的习惯是在串口初始化完成之前绝对不调用printf。如果需要在初始化阶段输出调试信息就用GPIO翻转或者干脆等初始化完再补打。另外在中断里尽量不用printf如果非要用就改成往环形缓冲区里写让主循环去发。注意HAL_UART_Transmit的最后一个参数是超时时间用HAL_MAX_DELAY表示无限等待。在调试阶段这样写没问题但在正式产品里最好给一个合理的超时值避免因为硬件故障导致程序永久卡死。4. DMA在串口收发中的实际价值与配置要点4.1 DMA到底解决了什么问题DMA全称是直接内存访问它的作用是让数据在内存和外设之间搬运时不经过CPU。没有DMA的时候串口每收一个字节CPU就要进一次中断把数据读走有了DMACPU只需要告诉DMA控制器从串口数据寄存器搬到内存这个地址搬100个字节然后就可以去干别的事了搬完了DMA会通知CPU。这个差别在低速场景下不明显但在高速或者大数据量场景下就是天壤之别。举个例子串口以921600波特率连续接收数据每秒大概9万个字节。如果用中断方式CPU每秒要进9万次中断基本上什么都干不了了。用DMA的话CPU只需要在DMA搬完一批数据后处理一次负担小了几个数量级。DMA在发送方向同样有用。用HAL_UART_Transmit发送一大段数据时CPU要一直等着发完。用HAL_UART_Transmit_DMA的话函数调用后立刻返回CPU可以继续执行其他代码DMA在后台把数据发完再触发发送完成回调。4.2 串口DMA接收的三种模式对比串口DMA接收有三种常见模式各有各的适用场景。第一种是定长接收就是你知道每次要收多少字节调用HAL_UART_Receive_DMA时指定长度收满了触发回调。这种方式最简单适合协议固定长度的场景比如某些传感器模块每次返回固定字节数的数据包。第二种是空闲中断加DMA这是最常用的不定长接收方案。DMA一直在后台接收数据当串口总线空闲超过一个字节时间时触发空闲中断在中断里计算DMA已经搬了多少个字节然后处理这批数据。这种方式适合Modbus、AT指令这类不定长协议。第三种是循环模式DMA加双缓冲DMA配置成循环模式缓冲区首尾相连配合半传输完成中断和传输完成中断可以实现连续不断的数据流处理。这种方式适合音频流、高速数据采集这类场景。模式适用场景优点缺点定长接收固定长度协议实现简单逻辑清晰长度不固定时无法使用空闲中断DMA不定长协议灵活CPU占用低需要处理空闲中断标志循环DMA双缓冲连续数据流无缝接收不丢数据实现复杂调试难度高4.3 空闲中断DMA接收的完整实现思路空闲中断加DMA是我在实际项目里用得最多的方案这里把关键步骤拆开讲。第一步初始化DMA通道配置为从串口数据寄存器搬到内存缓冲区模式选Normal不是Circular因为我们要在空闲中断里手动重启DMA。第二步开启串口空闲中断。在HAL库中空闲中断的处理需要自己写因为HAL库默认不处理这个中断。具体做法是在串口的中断服务函数里判断IDLE标志然后清除标志并调用回调。第三步在空闲中断回调里用__HAL_DMA_GET_COUNTER获取DMA剩余未搬运的字节数用缓冲区总长度减去这个值就是本次收到的字节数。然后处理数据处理完后重新调用HAL_UART_Receive_DMA启动下一次接收。这里有个细节很容易出错重新启动DMA接收之前要先调用HAL_UART_DMAStop或者__HAL_DMA_DISABLE把DMA停掉否则新的接收配置不会生效。我见过有人直接调用HAL_UART_Receive_DMA结果第二次接收的数据长度不对就是因为DMA还在运行状态。void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint16_t recv_len BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理recv_len个字节的数据 process_data(rx_buffer, recv_len); HAL_UART_Receive_DMA(huart1, rx_buffer, BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }4.4 DMA测速怎么判断DMA配置是否真的生效DMA配置好了不代表它真的在按你预期的方式工作。我一般会用测速的方法来验证让串口以固定波特率连续发送已知大小的数据然后在接收端统计单位时间内收到的字节数和理论值对比。理论值的计算很简单波特率除以101个起始位8个数据位1个停止位就是每秒能传的字节数。比如115200波特率理论最大吞吐是11520字节每秒。如果你用DMA接收统计出来的速率应该接近这个值。如果差很多说明DMA没配好或者中间有丢数据。测速的时候要注意发送端和接收端的波特率必须严格一致发送端最好是硬件流控或者固定间隔发送避免因为发送端的问题影响测试结果。另外统计的时候要把处理数据的时间也算进去如果处理太慢导致DMA缓冲区溢出测出来的速率也会偏低。5. 从串口出发的嵌入式进阶方向5.1 串口封装成C模块的工程化思路把串口代码写在一个main.c里做实验没问题但项目稍微大一点就会乱。我的做法是把串口封装成一个独立的C模块提供统一的接口。模块对外提供几个函数初始化、发送、注册接收回调、获取接收缓冲区。内部维护一个环形缓冲区和一个状态机。初始化函数负责配置硬件和开启中断发送函数把数据放进发送缓冲区然后启动发送接收中断把数据写进接收缓冲区并调用用户注册的回调。这样封装的好处是换一个串口只需要改初始化里的句柄上层业务代码完全不用动。如果项目里同时用了多个串口每个串口实例化一个结构体就行互不干扰。封装的时候有个细节要注意回调函数是在中断上下文里执行的所以回调里不能做耗时操作也不能调用可能阻塞的函数。如果业务逻辑比较复杂回调里只做数据搬运把处理放到主循环里。5.2 串口调试在PID调参中的实际用法PID调参是嵌入式控制类项目绕不开的环节而串口是调参时最好的帮手。我的做法是在PID计算函数里把设定值、实际值、输出值通过串口按固定格式发出来比如printf(%d,%d,%d\n, setpoint, actual, output)。然后在电脑端用串口助手或者Python脚本接收画成曲线。这样调参比盲调快得多。你能直观看到超调量有多大、震荡频率是多少、稳态误差有多少。根据曲线调整Kp、Ki、Kd一般几轮就能找到比较合适的参数。发送数据的时候要注意频率。如果PID计算频率是1kHz每毫秒发一次数据115200波特率下每毫秒最多发11个字节根本不够用。这时候要么降低发送频率比如每10次计算发一次要么提高波特率要么用DMA发送把数据攒够一批再发。5.3 嵌入式Linux下串口操作的差异从裸机转到嵌入式Linux串口操作的方式完全变了。裸机下你直接操作寄存器Linux下你操作的是设备文件/dev/ttySx。打开串口用open配置参数用termios结构体读写用read和write。虽然操作方式变了但底层概念是相通的。波特率、数据位、停止位、校验位这些参数在termios里都有对应的字段。你在裸机上理解的串口原理在Linux下照样适用。Linux下串口编程有个坑要注意默认情况下终端是行缓冲的也就是说你write的数据可能不会立刻发出去要等到缓冲区满了或者遇到换行符才发。解决办法是用tcsetattr把串口配置成原始模式关闭行缓冲和回显。另外Linux下查看串口设备可以用ls /dev/ttyS*和ls /dev/ttyUSB*查看串口被哪个程序占用可以用lsof /dev/ttyS0。这些命令在调试的时候很实用。5.4 嵌入式AI和FPGA方向与串口的关系现在嵌入式AI和FPGA是很热的方向很多人关心从串口这条线怎么往那边走。我的看法是串口是基础但不是终点。嵌入式AI方面你可能会用K210、Jetson Nano这类平台跑神经网络。这些平台和上位机或者传感器之间的通信很多时候还是走串口。你在STM32上积累的串口协议设计、数据打包解包、错误处理这些经验在AI项目里照样用得上。FPGA方面用FPGA实现串口收发是一个经典入门项目。你需要用Verilog或者VHDL写波特率发生器、发送状态机、接收状态机。这个过程会让你对串口的理解从配置寄存器深入到每一个比特怎么翻转。做过FPGA串口之后再回头看STM32的串口配置会有一种豁然开朗的感觉。不管往哪个方向走串口作为最基础最通用的通信接口都值得花时间吃透。它就像学编程要先学Hello World一样是嵌入式的必修课。6. 几个我踩过的坑和对应的排查思路6.1 串口被占用导致下载失败这个坑我踩过不止一次。现象是程序编译没问题但下载的时候提示无法连接目标。排查了半天发现是串口助手还开着占用了串口导致下载器无法通过串口和芯片通信。在Windows下查看串口被哪个程序占用可以用设备管理器看端口号然后用任务管理器找对应的进程。或者用Process Explorer这类工具直接搜索串口设备名。在Linux下就简单了lsof /dev/ttyUSB0一条命令就能看到哪个进程占用了串口。养成习惯下载程序之前先关掉串口助手或者用带自动释放功能的调试工具。6.2 DMA接收第一次正常第二次丢数据这个问题我在用空闲中断DMA方案时遇到过。第一次接收完全正常第二次开始就丢数据或者收到乱码。查了很久才定位到原因空闲中断里没有正确停止DMA就重新启动了接收导致DMA的计数器没有复位。正确的做法是在空闲中断里先调用HAL_UART_DMAStop这个函数会停止DMA并复位相关状态然后再调用HAL_UART_Receive_DMA重新启动。顺序不能反否则DMA的传输计数器还是上次的剩余值算出来的接收长度就是错的。6.3 printf输出浮点数显示为空这个问题通常出现在GCC环境下。newlib-nano默认不链接浮点数打印功能所以printf(%f)会输出空或者直接不输出。解决办法是在链接选项里加上-u _printf_float强制链接浮点数支持。代价是代码体积会增加几KB如果Flash紧张就要权衡一下。另一个办法是不用printf打印浮点数而是把浮点数拆成整数部分和小数部分分别打印。比如printf(%d.%02d, (int)val, (int)((val - (int)val) * 100))。这样虽然麻烦一点但不依赖浮点数打印库代码体积小。6.4 串口DMA发送时数据被覆盖用DMA发送时HAL_UART_Transmit_DMA函数调用后会立刻返回但数据还在发送中。如果你在发送完成之前又调用了一次发送函数或者修改了发送缓冲区的内容就会导致发出的数据不对。解决办法是维护一个发送状态标志发送完成回调里清除标志发送函数里检查标志如果上一次还没发完就等待或者返回错误。更好的做法是用一个发送队列把要发的数据排队DMA发完一个自动发下一个。6.5 波特率对但就是收不到数据这种情况我遇到过几次最后查出来的原因五花八门。有一次是TX和RX接反了有一次是串口助手的流控设置和代码里的流控设置不一致还有一次是开发板上的串口跳线帽没插。排查这类问题的顺序是先确认硬件连接TX接RX、RX接TX、GND接GND再确认串口助手的参数和代码一致包括波特率、数据位、停止位、校验位、流控然后确认代码里的串口引脚配置和实际使用的引脚一致最后用示波器或者逻辑分析仪看TX引脚上有没有波形。如果TX有波形但接收端收不到那就是接收端的问题如果TX没波形那就是发送端的问题。这套排查思路看起来简单但能覆盖90%以上的串口通信问题。关键是要按顺序来不要跳步否则容易在错误的方向上浪费时间。
返回列表