嵌入式入门这件事,说难也难,说简单也简单。难的是知识点太散,串口、GPIO、中断、DMA、RTOS、Linux,每一样都能单独写一本书;简单的是,只要抓住一条主线,把最核心的几个外设吃透,剩下的都是触类旁通。我带过不少刚入行的朋友,也见过太多人卡在"点灯之后不知道下一步干什么"的阶段。这篇内容就围绕嵌入式学习这条主线,把串口、HAL库、printf重定向、DMA这几个高频关键词串起来,讲清楚它们之间的关系、各自的学习优先级,以及在实际项目中怎么用、怎么避坑。不管你是刚买开发板的新手,还是已经能跑通几个例程但总觉得心里没底的进阶者,应该都能从里面找到对自己有用的东西。
1. 嵌入式学习路线的真实优先级排序
1.1 为什么大多数人从串口开始是对的
很多人拿到开发板第一件事是点灯,第二件事就是串口。这个顺序不是随便定的,背后有很实在的逻辑。点灯验证的是工具链和下载流程是否正常,而串口验证的是你和芯片之间能不能"对话"。没有串口,你后面调试任何外设都像蒙着眼睛走路——程序跑没跑起来、卡在哪一步、变量值对不对,全靠猜。
串口之所以成为嵌入式学习的第一个通信外设,原因有几个。第一,它的硬件成本极低,一根USB转串口线就能搞定,几乎每块开发板都引出了串口引脚。第二,它的协议简单到可以手算,起始位、数据位、校验位、停止位,时序一目了然,不像SPI、I2C那样需要对着时序图抠细节。第三,它是后续所有调试手段的基础,printf重定向、日志输出、shell交互,全都建立在串口能正常收发的前提上。
我一般建议新手在串口上至少花一周时间,不是把例程跑通就完事,而是要搞清楚:波特率怎么算出来的、为什么有时候会丢数据、中断接收和轮询接收的区别在哪、DMA模式又解决了什么问题。这几个问题想明白了,后面学其他通信外设会轻松很多。
1.2 从裸机到HAL库再到RTOS和Linux的阶梯
嵌入式学习有个很典型的阶梯结构,但很多人容易跳步。我见过不少人裸机还没写利索,就急着上RTOS;RTOS的任务调度还没搞明白,又去碰嵌入式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用的是fputc,GCC用的是_write,IAR又不一样。所以网上抄来的代码有时候编译不过,就是因为编译器不匹配。
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或者中断发送模式),就可能出现死锁。另外,如果串口初始化还没完成就调用printf,HAL_UART_Transmit会因为句柄状态不对而卡在超时等待里。
我个人的习惯是,在串口初始化完成之前绝对不调用printf。如果需要在初始化阶段输出调试信息,就用GPIO翻转或者干脆等初始化完再补打。另外,在中断里尽量不用printf,如果非要用,就改成往环形缓冲区里写,让主循环去发。
注意:HAL_UART_Transmit的最后一个参数是超时时间,用HAL_MAX_DELAY表示无限等待。在调试阶段这样写没问题,但在正式产品里最好给一个合理的超时值,避免因为硬件故障导致程序永久卡死。
4. DMA在串口收发中的实际价值与配置要点
4.1 DMA到底解决了什么问题
DMA全称是直接内存访问,它的作用是让数据在内存和外设之间搬运时不经过CPU。没有DMA的时候,串口每收一个字节,CPU就要进一次中断把数据读走;有了DMA,CPU只需要告诉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配置好了不代表它真的在按你预期的方式工作。我一般会用测速的方法来验证:让串口以固定波特率连续发送已知大小的数据,然后在接收端统计单位时间内收到的字节数,和理论值对比。
理论值的计算很简单:波特率除以10(1个起始位+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%以上的串口通信问题。关键是要按顺序来,不要跳步,否则容易在错误的方向上浪费时间。