☰
UART串口实战:从寄存器配置到printf重定向的嵌入式调试指南
2026/10/18 7:29:59 网站建设 项目流程

1. 从Day1到Day3之后,我为什么决定把第四天留给串口

说实话,我前三天是有点浮躁的。Day1搭建开发环境,Day2点亮板载LED,Day3用按键控制灯亮灭。做完这些之后我一度觉得嵌入式好像也就这么回事——配置几个寄存器、写几个while(1)循环、编译下载,似乎已经摸到门道了。但当我试着往程序里加一句“你跑到这里了”的提示信息,才发现自己连最基本的手段都没有:屏幕在哪?我怎么知道代码执行到哪一行?程序死循环了怎么定位?

这才意识到,前三天学的都是“输出”——LED亮灭是输出,但那是给眼睛看的。真正要被系统自己利用的信息通道,我手里一个都没有。一位做过产品的朋友跟我讲,嵌入式开发里有一句话叫“不会用串口的调试等于蒙眼写代码”,我当时还不太信,直到第四天真正把串口跑通,我才知道这后半句有多贴切。

从那天之后,我的学习思路变了。我决定不管后面要学I2C还是SPI,先把UART串口这块彻底啃明白,因为它不仅是设备之间通信最常用、最基础的外设,更是我对自己写的代码进行观测和反馈的“眼睛”和“嘴巴”。这篇Day4笔记,就是记录我从零开始理解UART、亲手调通收发、最后被各种细节坑到又重新爬起来的完整过程。如果你是正在学嵌入式的朋友,希望这份记录能帮你少走一点我走过的弯路。

2. 串口为什么是嵌入式世界的“标准语言”

2.1 在GPIO之外,第二种“让信息流动起来”的方式

点灯和读按键的本质,是用GPIO的电平高低来传递二值信息——高电平是一种意思,低电平是另一种意思。这种方式在单板内部非常直观,但一旦信息量变多就会捉襟见肘。按键有4个,就要4个引脚;要传一个字节的数据,就要8根线。如果信息量再大一点,GPIO的方式直接就是灾难。

UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)之所以被叫做“通用”,是因为它用一根发送线、一根接收线就能完成双向通信,数据一位一位地按时间顺序传出去。这就像你们宿舍楼里只有一根晾衣绳,但所有人都能共用:每个人把自己的衣服(字节)一件一件挂上去(逐位发送),对面的同学再按顺序取下来(逐位接收)。成本低、适应性广,几乎每一款MCU都内置了UART外设。

2.2 异步通信:我和对方没有时钟线怎么对齐

UART全称里最需要留意的词是“异步”。SPI通信里主设备会主动生成一根时钟线,时钟的每个沿都告诉从设备“现在读这根数据线”;而UART没有这根线。那么问题来了:接收方凭什么知道要在哪个时间点去采样数据线?

答案就是提前约定好节奏。双方都按照同一个“拍子”来采样,这个拍子的频率就是波特率(Baud Rate),单位是bps(每秒传输的位数)。比如波特率9600,代表每秒发送9600个bit。只要双方都卡在9600的节拍上,接收方就能知道什么时候该去看电平。

但在这里有个非常关键的细节:波特率只是一个“平均节奏”,双方本质上还是各自用自己的时钟去计时。所以收发两端允许存在一定偏差,但偏差不能太大。一般UART协议要求波特率误差在2%以内,超过这个范围接收端就会采错位。这也解释了后面我遇到的很多乱码问题,根因都是“自以为设置了同样的波特率”,实际上两侧真正跑出来的频率差得远了。

2.3 一帧数据的组成:起始位、数据位、停止位

异步通信最怕的问题是:接收端不知道“什么时候开始”。如果发送方不说话时保持高电平,接收方要怎么区分“这里有一段话开始”和“这里还是沉默”?

UART的解决方法是靠边沿来宣告开始。空闲状态时数据线保持高电平;当发送方准备发送一字节数据时,先拉低电平一个位时间,这个被刻意拉低的位就是起始位(Start Bit)。接收方看到这个高电平跳变到低电平的边沿,就知道“好,开始读数据了”,随后按约定的波特率在数据位的中间位置采样。

数据位通常有5到8位可选,默认是8位,按低字节先行(LSB first)的顺序发送。数据位之后是停止位(Stop Bit),将电平拉回高电平并保持1位或2位时间,用来给双方一个“这一字节结束了”的喘息时机。整个过程用一句话概括就是:高电平打底,跳变宣告开始,按节奏读数据,停在高电平收尾。

在实际编程中,串口配置通常就是设置这几个参数:波特率、数据位、停止位、是否有校验位。比如那句耳熟能详的“115200, 8, N, 1”,意思就是波特率115200,8位数据位,无校验(None),1位停止位。看似只是一个固定的组合,但它定义了通信双方从电平到语义的全部规则。

3. 动手配通UART:寄存器操作里的每一处细节

3.1 为什么我选择了寄存器操作而不是只调库函数

我用的是一块ARM Cortex-M内核的MCU,开发环境里提供了完整的HAL库,理论上调两个结构体就能把串口初始化好。但在Day4刚开始时,我坚持自己操作寄存器,原因是这些时间花得值。库函数把配置封装成黑盒,出了问题你只能通过现象猜测原因;而我这个阶段真正缺的不是“能不能跑通”,而是“跑通了之后遇到故障会排”。寄存器操作虽然繁琐,但每一步都要求我知道自己在干什么——这几位的含义是什么、为什么要写这个值的每一位。

当然这不代表HAL库或者标准外设库没有价值,而是说对于一个想长期做嵌入式的人来说,至少要有一次把寄存器手册和配置代码对照着看的经历。越过这个过程直接调库,以后调试时的思维深度会差很多。

3.2 时钟先行的原则:没有时钟,一切寄存器都是空谈

打开开发板原理图和数据手册,我发现最容易被新手忽略的步骤其实是打开外设时钟。很多MCU的外设时钟默认是关闭的,不打开时钟的情况下访问外设寄存器,读到的值要么不对、要么直接触发硬件错误。

以我用的这颗MCU为例,UART外设挂在APB总线上,需要通过RCC(Reset and Clock Control)寄存器组来使能UART的时钟。这个操作放在整个配置流程的最前面,顺序不能换。此外还需要配置引脚复用功能,因为MCU的引脚往往具有多种功能,默认状态可能是GPIO,必须把它切换成UART模式。这段配置去查数据手册的GPIO复用功能表就能找到对应的AF编号,不同芯片编号不同,一定要看自己手上那颗的具体资料。

3.3 波特率的生成:一个让人豁然开朗的公式

波特率不是随便设定的,它由外设时钟经过分频之后得到。常见的公式是:

BRR = 外设时钟频率 / (16 × 目标波特率)

当目标波特率是115200,APB外设时钟是16MHz时,计算结果是:

16000000 / (16 × 115200) ≈ 8.68

这个结果不是整数,意味着实际的波特率不可能精确等于115200,只能取近似值。取8或9得到的实际波特率分别会有偏差。而UART协议允许约2%的误差,所以只要计算结果在这个范围内就是可以用的。这也是为什么常见波特率(9600、19200、115200)能在各种晶振频率下通用,因为偏差都被控制在了可接受范围内。

但在具体芯片上,有些MCU的BRR寄存器还区分了整数部分和小数部分,甚至需要额外的分数分频配置。我第一次移植代码到另一块开发板时忽略了这一点,结果波特率偏差超过了容忍度,接收端直接乱码。这个问题花了我将近一小时才定位到——最后查数据手册里的波特率计算表,才发现我填BRR的方式不对。

3.4 发送一个字节:代码背后的四个寄存器动作

以我的MCU为例,UART初始化完成后,发送一个字节需要这几步:

  1. 等待发送数据寄存器为空(TXE标志置位),意思是前一个字节已经被移出,现在可以写新的数据。
  2. 将待发送的字节写入数据寄存器。
  3. 如果开启了发送完成中断,则等待TC标志表明整帧数据已经发完。
  4. 重复上述过程发送下一个字节。

这里最容易被新手忽略的是“等待TXE”这一步。我第一次写发送函数时没检查这个标志,直接往数据寄存器里写,结果连续发送多个字节时出现丢数据的情况:第一个字节正常,后续字节错乱或丢失。原因就是前一个字节尚未完全移出时,新的写入把旧数据给顶掉了。

以下是简化的寄存器操作示例代码,展示的是核心逻辑,具体寄存器名称以你的芯片手册为准。

// 伪代码:UART发送单字节 void uart_send_byte(uint8_t byte) { // 等待发送数据寄存器为空 while (!(UART->SR & UART_SR_TXE)); // 写入数据寄存器 UART->DR = byte; } // 伪代码:UART发送字符串 void uart_send_string(const char *str) { while (*str) { uart_send_byte((uint8_t)*str++); } }

这个过程跑通之后,我在串口调试助手里看到第一行“Hello, UART!”时,那种感觉比Day2点亮LED还有成就感。因为我知道这串字符不是一个LED的亮灭,而是真真实实的数据在线上流动,经过电平变化、采样、移位,最终被另一端的电脑解码还原。

4. 从只会说到学会听:UART接收的轮询、中断与缓冲区

4.1 接收的对称性:数据也能从线缆走回来

发送和接收在硬件层面是对称的。发送用TX引脚,接收用RX引脚;对方设备的TX要接到我的RX,我的TX接到对方的RX,地线相连,这就是标准的三线制UART连接。调试的时候最容易犯的低级错误就是把TX接TX、RX接RX,那无论如何都是收不到数据的。

接收端的工作流程比发送稍微复杂一点:硬件检测到起始位之后,自动按波特率采样数据线,把8位数据位拼成一个字节,放进接收数据寄存器,同时把RXNE(接收数据非空)标志置位。程序要做的第一件事是读取数据寄存器把这些字节拿走,否则下一个字节到达时,硬件就会把旧数据覆盖掉,产生溢出错误(ORE)。

4.2 轮询接收的代价:为了收一个字节,CPU一直在傻等

最容易实现的接收方式是轮询:程序进入一个while循环,反复检查RXNE标志是否置位,一旦置位就读取数据寄存器的值。

// 伪代码:轮询接收一个字节 uint8_t uart_receive_byte(void) { // 等待接收数据寄存器非空 while (!(UART->SR & UART_SR_RXNE)); return (uint8_t)(UART->DR & 0xFF); }

这个方法逻辑上没有任何问题,但实际使用时非常笨重。因为单片机的任务往往不止是收串口数据,还要扫描按键、刷新显示器、控制电机。如果程序被这个轮询循环卡住,CPU就没工夫做其他事情了。而如果同时开着多个外设,这种“一人干活全家等待”的模式会让整个系统的实时性荡然无存。

4.3 中断接收:让CPU只在“有数据到了”的时候干活

中断的思路完全不一样。平时CPU该干嘛干嘛,不用管串口。当硬件层完整接收了一个字节,它会向CPU发出一个中断请求,CPU临时放下手头的事情,跳进中断服务函数(ISR)里把数据读走,然后回到中断之前继续工作。这个机制很像一个忙碌的店长:不会一直站在门口等顾客,而是前台响铃了才过去处理。

中断接收的代码核心是配置好UART接收中断使能位,然后在中断服务函数里读取DR寄存器,并将读取到的字节存入一个数组或环形缓冲区。用环形缓冲区的好处在于:中断函数负责往里写,主循环负责往外取,两者各干各的,互不阻塞。这个模式在之后学习DMA、传感器数据采集时都能复用。

配置中断的步骤大致是:

  1. 在UART配置中使能接收中断(RXNEIE)。
  2. 在NVIC(嵌套向量中断控制器)中使能对应的UART全局中断,并设置优先级。
  3. 编写中断服务函数,在函数中判断标志位,读取数据。
  4. 中断服务函数里及时清除标志位,防止同一中断反复触发。

这里有个经验之谈:中断服务函数里不要做太多事情,尤其是不能放耗时的操作比如延时、printf或者复杂计算。中断执行期间主程序是停摆的,长时间占用中断会让整个系统的实时性崩坏。正确做法是中断里只做“快速存取”,比如把字节丢进缓冲区就立即返回。

5. 串口调试中的翻车现场:乱码、锁死与数据错乱排查记

5.1 乱码的第一嫌疑人:波特率偏差

我在跑通发送后做的第一件事,就是把波特率从115200改成9600,想试试不同速率的效果。结果串口助手里一片乱码。这个现象很典型,90%的乱码问题都出在“两侧波特率不一致”或“某一侧实际波特率偏差过大”上。

排查思路很有讲究:不要一上来就怀疑硬件,先确认配置。我最常用的方法是用示波器或者逻辑分析仪抓TX引脚的波形,数一下一个数据位的实际时间长度。如果不方便接仪器,就换一个思路:用已知正常的波特率组合逐个尝试,比如把电脑端波特率逐一换成9600、19200、38400、57600、115200,总有一个能对上。一旦对上,就能确认问题出在“配置不一致”而不是“硬件坏了”。

如果所有常见波特率都不对,那就要怀疑时钟源了。有些MCU内部RC振荡器精度只有1%到3%,如果代码没配置外部晶振,直接跑内部RC时钟,波特率偏差很可能超过2%的容忍线。

5.2 一个让我卡了半小时的坑:只发不收和只收不发

发送跑通后,我尝试回环测试——把TX和RX短接,让程序发送数据并同时接收。理想情况下,自己发出去的字节应该被自己收到。但实际运行时,程序发送正常,接收端却啥都没有。

排查顺序从接线开始。我把杜邦线重新插了一遍,确认TX和RX没有接反后,再查代码里的引脚复用配置。最后发现问题出在一个非常隐蔽的地方:UART的接收功能默认是关闭的,必须在控制寄存器里显式使能接收模式(RE位)。否则引脚电平是接上了,但外设内部根本不采样数据。这类问题靠现象很难猜,因为在很多示例代码里发送和接收是同时配置好的,我抄代码时没注意到接收使能那一行。

5.3 数据错乱的另一种来源:溢出错误没有处理

当接收中断函数里处理速度太慢,或者缓冲区太小,新到的字节会把还没读走的旧数据覆盖掉,硬件会记录一个溢出错误。如果程序对溢出错误标志不管不顾,串口就会进入一种“看似工作但数据全乱”的状态。

处理方法是明确的:中断服务函数里,如果检测到ORE标志,就先读一次数据寄存器把它清掉,或者直接复位接收相关标志,保证后续数据能正常接收。这个细节在我后来写的协议解析代码里帮了大忙——一个简单的“先检查溢出标志、再读取数据”的顺序,避免了很多潜伏的偶发错乱。

// 伪代码:中断服务函数中的经典处理顺序 void UART_ISR(void) { // 如果溢出,先读走DR并清标志 if (UART->SR & UART_SR_ORE) { uint8_t tmp = UART->DR; // 丢弃这个字节,因为数据已经丢失 } // 正常接收一个字节 if (UART->SR & UART_SR_RXNE) { uint8_t byte = (uint8_t)(UART->DR & 0xFF); ring_buffer_push(byte); } }

5.4 调试助手的正确用法:不要只看HEX,也要看文本

串口调试助手是嵌入式调试最常用的工具之一,但用法上有很多门道。第一次调试时我推荐切到HEX显示模式,因为HEX模式能直观显示每个字节的实际值,不会因为ASCII编码问题遮挡信息。比如我发送字符串“OK”,HEX模式下显示0x4F 0x4B,一目了然。文本模式下如果波特率或编码没对上,你看到的可能是各种奇怪的符号,反而会误导排查方向。

另外串口助手的“发送新行”选项往往默认勾选,意味着每次点击发送会附带\r\n。在调试协议时要注意,这个附加的字符可能恰好打乱你的包结构。我后来写数据打包解析时,就专门取消了自动换行选项,完全由程序决定发送什么字节、发送多少字节。

6. printf重定向:让串口化身调试利器

6.1 为什么嵌入式里也想用printf

在PC上写C语言程序,printf是再自然不过的调试方式。到了MCU上,标准库的printf默认是往“终端设备”输出,而MCU没有终端。但如果我们能把标准库的输出重定向到UART的发送函数上,那么printf里的每一个字符都会自动变成串口线上的一帧帧数据,配合电脑端的串口调试助手,MCU就有了一个“虚拟控制台”。

重定向的本质是实现标准库底层依赖的字符输出函数。在ARM Compiler环境下,常见做法是重写fputc函数;在GCC环境下则是重写_write函数。不同编译器环境细节不一样,但核心逻辑相同:函数内部调用UART发送单字节的函数。

// 伪代码:重定向printf到UART(以GCC环境为例) int _write(int file, char *ptr, int len) { for (int i = 0; i < len; i++) { uart_send_byte((uint8_t)ptr[i]); } return len; }

这段代码实现之后,程序中所有printf("value = %d\n", x)的调用都会通过UART发送出去。在串口助手里看到变量实时数值的感觉,和用LED灯闪烁次数来代表数字完全是两个世界。

6.2 重定向之后的新坑:浮点打印与缓冲问题

printf重定向成功后,第二个容易踩的坑是浮点打印。很多编译器的默认配置为了压缩体积,不启用浮点格式化支持。当我的代码里出现%f格式时,串口里打印出来的是空字符串或者乱码。排查方法是查看编译器的格式化选项,显式启用浮点支持,或者干脆用整数替换,先把浮点数放大若干倍打印整数部分,再打印小数部分。

另一个问题是缓冲。标准库的printf有些版本是有缓冲的,当输出量不大或者程序异常死机时,缓冲区里的内容可能根本不会送出来。解决办法是直接改用无缓冲方式输出,或者在printf之后强制调用fflush。我曾经遇到过一种诡异情况:程序运行正常时什么都打印,但一进入硬件错误后就什么也不出,排查很久才发现是缓冲区的问题——程序在崩溃前没有机会冲刷缓冲,那些调试信息就像“写好了没寄出去的信”一样躺在缓冲区里。

6.3 自己封装一个简易日志:远比想象中实用

printf重定向之后,我又往前走了一步,写了一个极简日志模块,封装了不同级别的输出函数。大概长这样:

#define LOG_LEVEL_INFO 0 #define LOG_LEVEL_WARN 1 #define LOG_LEVEL_ERROR 2 void log_info(const char *fmt, ...); void log_warn(const char *fmt, ...); void log_error(const char *fmt, ...);

内部实现里加了时间戳、级别标识和换行处理。这么做的好处是:当系统里同时跑着传感器读取、协议解析、状态机切换等多个模块时,一眼就能从串口日志里区分出不同重要程度的信息,而不是所有内容混在一起难以阅读。这个习惯在之后调试一个多设备通信的程序时帮了大忙——靠日志按时间戳排序,我很快就锁定了某一次数据突变的准确发生时间,进而找到了发送时序冲突的根因。

7. Day4的学习复盘:我的千万要记牢清单

经过一整天的折腾,我把关于UART的认知整理成了一份极简清单,每一行都是一次踩坑之后的记牢。

第一,时钟使能永远在最前面。无论是GPIO还是UART,先检查外设时钟是否打开,这是MCU开发的第一层基础。很多人写代码会把引脚配置写在外设配置后面,但如果外设时钟没开,后面的所有操作都可能在错误状态下进行。

第二,TX接RX、RX接TX,不是TX接TX。这个错误低级但高频发生,接线前先看原理图,接好线后先做回环测试,把问题在源头上排除。

第三,检查波特率偏差时,第一反应不是换晶振,而是先确认两侧配置完全一致,再用逻辑分析仪或者示波器实测波形。

第四,接收数据之后要及时读取数据寄存器,及时处理溢出标志。这些“随手动作”不是可选项,是保证一个系统长稳运行的硬性要求。

第五,中断服务函数里不做耗时操作。这句话我在无数文章里看到过,但只有自己写过一段奇慢无比的中断处理、导致主循环卡死后才真正理解其分量。

第六,printf重定向是性价比最高的生产力工具。它让MCU从“哑巴”变成了“会说话的调试助手”,也让我后续学习更复杂的外设时,手里始终多了一双眼睛。

Day4的最大收获不是写通了一个UART收发程序,而是第一次理解到:嵌入式开发里的任何一个功能模块,都不是简单地“抄一段初始化代码”就能掌握的。你多花一小时去理解寄存器每一位的含义、去排查一个乱码问题、去把中断流程思考清楚,换来的是一整套调试思维能力的提升。这写进代码里看不见,但以后每写一个外设驱动你都会感觉到它的存在。

我自己的下一步计划是带着UART的方向感,去啃定时器和PWM。串口今天帮我看清了数据是怎么流动的,明天它还会继续帮我看清事件是怎么定时的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询