树莓派 Pico 的 UART,九成教程的开头都是uart_init(115200),好像串口之神就被这一行召唤。我一开始也这么干,直到给一块第三方串口屏写引导程序时遇到了邪门现象:同样是 115200,Pico 发出去的每一帧屏都能收到,屏返回的数据 Pico 却只能收到一半;把波特率降到 9600 又一切正常。那几天我盯着日志和串口助手来回切换,最后实在没办法,翻出 RP2040 数据手册和 ARM PrimeCell UART 寄存器手册,一行一行把 SDK 的初始化代码拆开重写了一遍,才明白问题根本不在波特率,而在接收中断挂起状态的处理。
这篇东西就是那几天的完整记录:从 RP2040 的 UART 外设在芯片里的时钟、复位和 GPIO 复用结构,到 DR、FR、LCR_H、CR 这些寄存器逐位拆解,再到不依赖 SDK 串口库、直接用寄存器地址手写一个可用驱动的完整过程。最后我会把波特率分频的计算方法、接收中断和 FIFO 的两个坑、以及硬件接线上的电平问题一并交代。适合手里有 Pico、已经能用 SDK 点灯收发串口、但遇到怪问题后感觉无从下手的嵌入式开发者,也适合准备从“调函数”走向“读手册”的人。
1. 为什么从寄存器写一个 Pico 串口程序?
1.1 SDK 的一行初始化代码背后藏了多少事
uart_init(115200)这行函数,很多人用了半年都不知道它内部做了什么。其实逻辑并不复杂:解除 UART 外设的复位,设置 TX/RX 引脚的复用功能,计算波特率分频系数,配置数据帧格式,最后打开发送和接收使能。一共五六个寄存器,十几个位域。
真正的问题在于,这些步骤全部被封装在 SDK 的函数里,正常使用时完全透明。一旦通信出现异常,就只能靠猜:是波特率不对?是引脚接反?是上位机配置错了?还是芯片根本没跑起来?我见过不少人排查串口问题时,先重装驱动、再换串口助手、最后换开发板,折腾一天发现是自己的引脚复用没配好。
从寄存器入手写一遍驱动,不是为了返祖,而是为了给自己建立一张“外设工作地图”。地图建起来之后,以后遇到任何 UART 相关的问题,你知道该去查哪个寄存器、该看哪个位。这种能力是看任何教程都学不来的,只能靠亲手把底层走一遍。
1.2 黑盒开发的典型症状集合
如果你符合下面任意一条,说明你已经到了需要读寄存器的时候。
- 能发不能收:发送方向数据正常,接收方向收不到或收到乱码。
- 接收丢字节:轮询或者中断方式下,间隔一段时间收不到数据,或者收到半截数据。
- 两边波特率表面对不上:明明两个设备都配成 115200,还是间歇性乱码。
- UART 中断不触发:开了接收中断,但死活进不了中断服务函数。
- 换了一个系统时钟频率之后,串口突然全部乱码。
这些现象单靠回调函数和 printf 是定位不了根因的,因为每一条都涉及具体的寄存器状态。比如“能发不能收”,可能是引脚复用错了,也可能是接收 FIFO 没有正确清空,还可能是错误状态位把后续接收堵死。这些在后面都会展开。
1.3 这篇文章的路线图
下面按“硬件架构 → 寄存器拆解 → 手写驱动 → 波特率核算 → 中断坑 → 硬件接线”的顺序走一遍。代码部分全部用地址映射直接访存,不调用 SDK 的uart_*函数,保证你看到的每一个步骤都对应到具体的寄存器操作。硬件部分主要讲我实际遇到的 GPIO 复用和电平匹配问题,这些在数据手册里都有,但一般是踩过坑才会真正记住。
2. RP2040 的 UART 硬件家底:两个串口、32 字节 FIFO 和 clk_peri
2.1 UART0/UART1 的资源分配与基地址
RP2040 芯片里有两个独立的 UART 外设,命名很直白:UART0 和 UART1。它们的基地址分别是:
| 外设 | 基地址 | NVIC 中断号 |
|---|---|---|
| UART0 | 0x40034000 | 20 |
| UART1 | 0x40038000 | 21 |
这两个外设的寄存器布局完全一致,所以同一套驱动代码只要换基地址和中断号就能复用。实现上,RP2040 的 UART 基于 ARM PrimeCell UART(PL011)这个 IP,但做了一处关键改动:发送和接收 FIFO 的深度从 PL011 标准的 16 字节加深到了 32 字节。
FIFO 深度不是小事。发送侧,你可以一口气往 TX FIFO 填 32 字节,然后去做别的事,UART 硬件会自动按波特率把字节发出去;接收侧,如果数据到了而 CPU 还在忙别的,最多能缓存 32 字节而不丢失。对需要频繁短报文交互的应用来说,这个深度让轮询发送和中断接收都从容很多。
2.2 时钟与复位门控:上电第一件必须做的事
UART 外设的工作时钟叫 UARTCLK,它来自时钟管理器里的 clk_peri 总线时钟。在官方 C SDK 的默认配置下,系统时钟 clk_sys 是 125MHz,clk_peri 通常也继承同一个频率。这里特别容易埋坑:如果你修改过系统时钟频率,比如为了跑更高主频把系统超到 250MHz,那么 clk_peri 也跟着变。此时如果还用旧的 125MHz 去算波特率分频器,实际波特率就会成比例漂移,通信自然乱码。
另一个更隐蔽的问题是复位。RP2040 上电后,大部分外设默认处于复位状态,包括 UART。如果复位没有释放,你写任何 UART 寄存器都不会生效。这也是很多从别的单片机转过来的人第一次用 Pico 时最容易忽略的地方。
复位操作在 RESETS 外设里完成。RESETS 基地址是 0x4000C000,其中 RESET 寄存器的第 1 位对应 UART0,第 2 位对应 UART1。对某个位写 0 表示释放复位,然后还要轮询 RESET_DONE 寄存器,确认对应位变成 1,外设才真正可用。
2.3 UART 的 GPIO 复用选项
RP2040 的每个 GPIO 都不只一个功能,通过设置 GPIO 控制寄存器里的 FUNCSEL 字段来选择。UART 功能对应的 FUNCSEL 值是 2。最常用的映射如下:
| 功能 | 引脚对(FUNCSEL=2) |
|---|---|
| UART0 TX | GPIO0 |
| UART0 RX | GPIO1 |
| UART1 TX | GPIO4 |
| UART1 RX | GPIO5 |
GPIO 的控制寄存器位于 IO_BANK0,基地址 0x40014000。GPIOx_CTRL 的偏移是 0x04 + 4 * x,FUNCSEL 占据低 5 位。
有一点需要特别强调:当 GPIO 被配置为 UART 这类外设功能时,引脚方向由外设自身控制,不需要再到 SIO 的 GPIO 输出使能寄存器里去设置方向。很多人不放心,额外去置位gpio_set_dir或者直接操作 OE 寄存器,反而可能干扰外设对引脚的驱动。正确做法是只设置 FUNCSEL,剩下的交给 UART 硬件。
3. 寄存器逐个拆解:从 DR 到 CR 的位级功能
3.1 UARTDR:读写数据之外的错误位
UART 的数据寄存器偏移是 0x00,这是唯一一个同时承担收发数据功能的寄存器。发送时向它写入要发送的字节,接收时从它读出收到的字节,低 8 位就是数据。
但它的高几个位藏着很容易被忽略的错误标志。bit8 是溢出错误,bit9 是 break 错误,bit10 是帧错误,bit11 是奇偶校验错误。接收方向一旦这些位变成 1,读取返回的数据是不可信的。如果程序不做处理,错误状态会残留并影响下一次接收,这是“接收一段时间后突然失灵”的常见原因之一。
清除错误的方式是往偏移 0x04 的 UARTECR 寄存器写任意值。这个地址和 UARTRSR 共用,读是读状态,写是清错误。后面手写驱动时会看到这个操作。
3.2 UARTFR:轮询模式的眼睛
标志寄存器偏移 0x18,它把 UART 的状态用几个位暴露出来。我们最常用到四个位:
| 位 | 名称 | 含义 |
|---|---|---|
| bit3 | BUSY | UART 正在发送或接收 |
| bit4 | RXFE | 接收 FIFO 空 |
| bit5 | TXFF | 发送 FIFO 满 |
| bit6 | RXFF | 接收 FIFO 满 |
轮询发送时,要等 TXFF 为 0 才能往 DR 写新数据;轮询接收时,要等 RXFE 为 0 才可以从 DR 读数据。
但在关闭串口或者想确保一帧数据完全发完的场景下,只等 TXFF 是不够的。TXFF 表示 FIFO 里还有空间,不代表字节已经移出移位寄存器。正确的做法是等 BUSY 位清零,这才说明当前传输彻底结束。
3.3 UARTIBRD/UARTFBRD:波特率分频器的两个半区
波特率分频器在这里被拆成了两个寄存器:整数部分在 0x24,小数部分在 0x28。这个设计源于 PL011,本质上是用一个“整数 + 6 位小数”的定点数表示分频系数。
分频公式是:
div = UARTCLK / (16 * baud) IBRD = floor(div) FBRD = round((div - IBRD) * 64)为什么要除以 16?因为 UART 接收端会在每个位周期的 16 个采样点中找中间点采样,这是一个经典的过采样技术。波特率发生器的输出频率实际是波特率的 16 倍。
FBRD 只有 6 位,也就是小数精度是 1/64。这个精度限制决定了任何非整除的波特率都会存在微小误差。误差本身通常很小,但不处理四舍五入进位就会出大问题,这一点我在第 5 章专门讲。
3.4 UARTLCR_H 与 UARTCR:帧格式和总开关
UARTLCR_H 偏移 0x2C,负责定义数据帧:数据位长度、停止位数量、奇偶校验模式,以及 FIFO 使能。8 数据位无校验 1 停止位是我们最常用的 8N1,对应 LCR_H 的值是 0x70。其中 FIFO 使能位是 bit4,数据位字段是 bit6:bit5,设为 0b11 表示 8 位数据。
这里有个非常容易踩的规范:LCR_H 只应该在 UART 禁用状态下修改。如果你在通信过程中直接改写 LCR_H,帧格式可能不会立即生效,或者产生不可预期的行为。所以正确的初始化顺序是:先配置波特率和 LCR_H,最后再打开 UART 使能。
UARTCR 偏移 0x30 是总控制寄存器。bit0 是 UART 总使能,bit8 是发送使能,bit9 是接收使能。这三者通常一起置位,但如果你只想用单向通信,可以只开对应的方向。
3.5 中断寄存器家族:IFLS、IMSC、RIS、MIS、ICR
UART 的中断系统涉及五个寄存器。IFLS(0x34)设置 FIFO 到什么深度触发中断;IMSC(0x38)是中断屏蔽置位/清除,写对应位为 1 使能中断;RIS(0x3C)是原始中断状态;MIS(0x40)是经过屏蔽后的中断状态,ISR 里读这个是标准姿势;ICR(0x44)是中断清除寄存器,写 1 清除对应挂起中断。
以接收中断为例,RXIM 对应 IMSC 的 bit4。初始化时要先把 ICR 里所有挂起位清一遍,再打开 IMSC,最后才让 NVIC 进入使能状态。顺序反了,开启中断的瞬间可能立刻误触发,很多人被第一颗“幽灵中断”打懵过。
4. 手写寄存器级 UART 驱动:从解除复位到收发完整代码
4.1 初始化函数:七步点亮串口
下面这段代码是纯寄存器操作,没有调用任何 SDK 串口库函数。先把宏定义清楚,方便对照数据手册。
#define UART0_BASE 0x40034000u #define UART1_BASE 0x40038000u #define UART_DR(base) (*(volatile uint32_t *)((base) + 0x00)) #define UART_ECR(base) (*(volatile uint32_t *)((base) + 0x04)) #define UART_FR(base) (*(volatile uint32_t *)((base) + 0x18)) #define UART_IBRD(base) (*(volatile uint32_t *)((base) + 0x24)) #define UART_FBRD(base) (*(volatile uint32_t *)((base) + 0x28)) #define UART_LCR_H(base) (*(volatile uint32_t *)((base) + 0x2C)) #define UART_CR(base) (*(volatile uint32_t *)((base) + 0x30)) #define UART_IFLS(base) (*(volatile uint32_t *)((base) + 0x34)) #define UART_IMSC(base) (*(volatile uint32_t *)((base) + 0x38)) #define UART_MIS(base) (*(volatile uint32_t *)((base) + 0x40)) #define UART_ICR(base) (*(volatile uint32_t *)((base) + 0x44)) #define RESETS_BASE 0x4000C000u #define RESETS_RESET (*(volatile uint32_t *)(RESETS_BASE + 0x00)) #define RESETS_RESET_DONE (*(volatile uint32_t *)(RESETS_BASE + 0x08)) #define UART0_RST_BIT (1u << 1) #define UART1_RST_BIT (1u << 2) #define IO_BANK0_BASE 0x40014000u #define GPIO_CTRL(n) (*(volatile uint32_t *)(IO_BANK0_BASE + 0x04 + 4u * (n)))初始化函数如下,以 UART0 和 GPIO0/GPIO1 为例:
void uart0_init_regs(uint32_t uart_clk_hz, uint32_t baud) { // 1. 释放 UART0 复位,并等待复位完成 RESETS_RESET &= ~UART0_RST_BIT; while ((RESETS_RESET_DONE & UART0_RST_BIT) == 0) { __asm volatile ("nop"); } // 2. 配置 GPIO0=UART0 TX、GPIO1=UART0 RX,FUNCSEL=2 GPIO_CTRL(0) = (GPIO_CTRL(0) & ~0x1Fu) | 2u; GPIO_CTRL(1) = (GPIO_CTRL(1) & ~0x1Fu) | 2u; // 3. 计算波特率分频 float div = (float)uart_clk_hz / (16.0f * (float)baud); uint32_t ibrd = (uint32_t)div; uint32_t fbrd = (uint32_t)((div - (float)ibrd) * 64.0f + 0.5f); if (fbrd >= 64u) { fbrd = 0u; ibrd++; } UART_IBRD(UART0_BASE) = ibrd; UART_FBRD(UART0_BASE) = fbrd; // 4. 配置 8N1,使能 FIFO,写 0x70 UART_LCR_H(UART0_BASE) = 0x70u; // 5. 清掉可能的错误残留 UART_ECR(UART0_BASE) = 0xFFu; // 6. 打开接收、发送、总使能 UART_CR(UART0_BASE) = (1u << 9) | (1u << 8) | (1u << 0); }步骤顺序是我反复核对数据手册后最稳妥的流程。第 3 步和第 4 步必须在使能 UART 之前完成,否则 LCR_H 会在 UART 运行时被写入,可能出现帧格式不一致。第 5 步清 ECR 是很多简化教程不会提的,但它能保证外设从一个干净状态开始。
4.2 发送函数:注意 FIFO 满和 BUSY 的区别
发送一个字符和一个字符串很简单:
void uart0_putc(char c) { // 等待 TX FIFO 有空位 while (UART_FR(UART0_BASE) & (1u << 5)) { __asm volatile ("nop"); } UART_DR(UART0_BASE) = (uint32_t)c; } void uart0_puts(const char *s) { while (*s) { uart0_putc(*s++); } // 等待最后一字节从移位寄存器发出,而不是只在 FIFO 里 while (UART_FR(UART0_BASE) & (1u << 3)) { __asm volatile ("nop"); } }uart0_puts末尾额外等待 BUSY 清零,这个细节在低波特率下尤其重要。如果你发送完立刻切换引脚功能或者让单片机进入低功耗模式,最后一个字节可能还没发完就被打断。等 BUSY 清零虽然会阻塞一点点时间,但换来的是确定性。
4.3 接收函数:错误位不处理会“粘住”接收
轮询接收的代码要同时处理数据、错误状态和 FIFO 空:
int uart0_getc(void) { // RX FIFO 空,返回 -1 if (UART_FR(UART0_BASE) & (1u << 4)) { return -1; } uint32_t dr = UART_DR(UART0_BASE); // bit8-bit11 是错误标志,直接返回 -2 if (dr & 0xF00u) { UART_ECR(UART0_BASE) = 0xFFu; // 清错误 return -2; } return (int)(dr & 0xFFu); }这个返回值的约定很实用:-1 表示没有数据,-2 表示收到了错误帧。调用方可以根据返回值决定重试还是报警。
我最初写的版本只在数据不为空时读 DR,读完之后不检查错误位,结果 UART 在连续收到几个坏帧之后彻底卡死。原因是错误状态没有被清除,后续接收一直受影响。所以如果你在轮询接收中发现数据“越来越稀疏”,先检查是不是忘了清 ECR。
5. 波特率误差核算:从数学到排查
5.1 分频公式与四舍五入
波特率分频的核心公式前面已经给出。我想再强调一个容易忽略的细节:FBRD 只有 6 位,能表示的小数精度是 1/64,约 1.56%。如果某个波特率下分频系数的小数部分恰好落在两个可表示值的中间,舍入误差最大可以达到 0.78% 左右。
这个误差对绝大多数 UART 通信来说都可接受,因为 UART 接收端的采样窗口有一定容错。但问题是,如果你在算法里只做了四舍五入,没有处理“FBRD 四舍五入后等于 64”的进位,分频系数就会少加 1/64,误差瞬间放大到约 1.56%,这已经逼近甚至超过很多接收端的容忍边界了。
我见过一个真实项目,波特率选择是 125MHz 时钟下的 345600,分频系数小数部分非常接近 1,代码里没有进位处理,FBRD 直接写入了溢出的 64。硬件只认低 6 位,实际变成 0,同时整数部分又没有加 1,导致实际波特率偏差超过 1%,对端单片机偶尔能同步,偶尔就乱码。排查到后来发现是一个四舍五入的边界条件,过程极其折磨。
5.2 常见时钟下的波特率对照表
下面这张表是 125MHz UARTCLK 下常见波特率的实际计算值,方便你快速对照。
| 目标波特率 | IBRD | FBRD | 实际波特率 | 误差 |
|---|---|---|---|---|
| 9600 | 813 | 51 | 9599.39 | 0.006% |
| 57600 | 135 | 41 | 57600.37 | 0.001% |
| 115200 | 67 | 52 | 115207.37 | 0.006% |
| 1000000 | 7 | 52 | 1000000.00 | 0.000% |
| 2000000 | 3 | 58 | 2000000.00 | 0.000% |
可以看到,125MHz 时钟下这几个常用波特率都非常准。但如果换到不是 125 的时钟频率,比如 48MHz,部分高波特率的误差会变大。所以我一直建议代码里不要写死 IBRD/FBRD,而是用传入时钟频率动态计算。
5.3 怀疑波特率出问题时怎么验证
先说结论:大多数“两边明明波特率一样还是乱码”的问题,根因并不是波特率计算,而是电平、接线、引脚复用或中断处理。所以如果你遇到乱码,先别急着改波特率。
正确的排查顺序是:先用示波器或者逻辑分析仪看 TX 引脚的信号。如果能抓到一个完整字节的波形,数一数每一位的宽度,和理论位宽对比,就能一锤定音确认波特率对不对。比如 115200 的位宽约 8.68 微秒,如果实测位宽明显偏大或偏小,再回头查代码里的时钟频率和分频计算。
如果没有示波器,可以用一个简单方法:连续发送一串 0x55(二进制是 01010101),在接收端单片机里测量信号翻转频率。0x55 的特点是位信号密集翻转,用 GPIO 中断或者输入捕获算一下翻转周期,也能粗略验证波特率是否准确。
6. 接收中断与 FIFO 的那些坑:一次漏数据 bug 的完整排查
6.1 场景还原:轮询正常但中断丢数据
我遇到的真实场景是:Pico 作为从设备,接收上位机每 100 毫秒发来的一帧 40 字节数据。用轮询接收时一帧不丢,改成中断接收后,大约每几十帧就会少一个字节,而且少的字节位置不固定。
第一反应是中断服务函数处理速度不够快。但 40 字节的数据量并不大,115200 波特率下每字节大约 87 微秒,40 字节也只要 3.5 毫秒,中断里根本没有耗时操作,CPU 完全来得及。后来加了一路 GPIO 翻转测量,才发现中断服务函数进是进了,但中间有一个字节被 UART 硬件判定为溢出,错误标志挂在了 DR 的高位。
6.2 根因:开中断前没清挂起位,FIFO 溢出后没清错误
当时的中断初始化顺序是:先置位 IMSC 的 RXIM,再清 ICR,最后使能 NVIC。结果在 IMSC 刚置位、ICR 还没清的空隙里,如果恰好有一个接收事件发生,中断就会提前挂起。等 NVIC 使能后,中断立刻触发,但此时 FIFO 里可能还没有有效数据,ISR 里读到的状态是错的,后续逻辑全乱。
更关键的是,我对溢出错误的处理不完整:读 DR 后发现错误位为真,只调用了 ECR 清除,但没把这次无效数据从 FIFO 里彻底消费掉。错误标志虽然在 ECR 写入后清了,FIFO 指针却还停在那个坏数据上,下一个有效字节写入时就会覆盖它,最终表现为“丢了一个字节”。
6.3 中断服务函数的正确姿势
下面是修正后的初始化片段和 ISR 骨架:
#define RX_BUF_LEN 256 static volatile uint8_t rx_buf[RX_BUF_LEN]; static volatile uint32_t rx_wr; void uart0_rx_irq_enable(void) { // 先清挂起,再配触发等级,最后开屏蔽 UART_ICR(UART0_BASE) = 0x7FFu; UART_IFLS(UART0_BASE) = (UART_IFLS(UART0_BASE) & ~0x7u) | 0x2u; // 1/2 满触发 UART_IMSC(UART0_BASE) |= (1u << 4); // RXIM NVIC_EnableIRQ(20); // UART0_IRQ } void UART0_IRQHandler(void) { uint32_t mis = UART_MIS(UART0_BASE); if (mis & (1u << 4)) { // 读到 RX FIFO 空为止 while (!(UART_FR(UART0_BASE) & (1u << 4))) { uint32_t dr = UART_DR(UART0_BASE); if (dr & 0xF00u) { UART_ECR(UART0_BASE) = 0xFFu; continue; } rx_buf[rx_wr] = (uint8_t)dr; rx_wr = (rx_wr + 1u) % RX_BUF_LEN; } } // 清掉已经处理的中断源 UART_ICR(UART0_BASE) = mis & 0x7FFu; }几个关键点。第一,初始化顺序必须是先 ICR、再 IFLS、再 IMSC、最后 NVIC,中间不要插入可能产生接收事件的操作。第二,ISR 里要一次把 FIFO 读到空,不要只读一个字节就退出,否则触发等级设置就没有意义。第三,错误数据和正常数据在 DR 里共用位置,遇到错误位要先清 ECR 再继续读,否则 FIFO 里剩下的数据也会受影响。
还有一个进阶技巧:如果数据帧较长、波特率又高,可以考虑把 IFLS 触发等级设到 3/4 或者 7/8,让 FIFO 积累更多数据后再进中断,减少中断频率。但要注意,触发等级越高,FIFO 溢出风险越大,需要根据你的实时性要求做权衡。
7. 引脚复用与电平匹配:硬件端的两个真实事故
7.1 用错功能编号,引脚纹丝不动
GPIO 复用是最常见的低级错误,但也是一个非常典型的事故场景。我接过一个项目,客户反馈程序下载后串口没有任何输出。检查代码,初始化流程完全正确,波特率也算了,复位也释放了,最后发现他们把 UART0 TX 配到了 GPIO2 上,然后 GPIO2 的 FUNCSEL 写的是 2。
问题在于 GPIO2 的 FUNCSEL=2 对应的并不是 UART0 TX,而是另一组外设功能。RP2040 的每个引脚都有固定的第二功能选择,不是任意引脚都能接任意外设。GPIO0 和 GPIO1 是 UART0 的标准 TX/RX,GPIO4 和 GPIO5 是 UART1 的标准 TX/RX。用错映射只能换引脚,代码层面没有“软交换”的可能。
所以拿到一个新板子,第一步就去数据手册里查引脚功能表,确认自己要用的引脚确实支持 UART 功能,以及对应的 FUNCSEL 值。别相信“反正都是 2,UART 都一样”这种说法,同一编号的 UART 功能在不同引脚上是不同的模块。
7.2 3.3V TTL 与 5V 设备之间的电平转换
Pico 的 GPIO 是 3.3V TTL 电平,这是 RP2040 的供电决定的。如果你要接的模块是 5V 逻辑电平,不能直接把 RX/TX 怼上去。
很多人觉得 UART 是单向传输,3.3V 发 5V 的风险不大。确实,Pico 的 TX 输出高电平只有 3.3V,绝大多数 5V 器件的输入高电平阈值在 2.0V 到 2.5V 之间,3.3V 勉强够用。但反过来,5V 设备的 TX 输出到 Pico 的 RX 引脚,5V 的高电平超过了 RP2040 的绝对最大额定值,长期使用有烧引脚的风险,严格说这是不可靠设计。
稳妥的做法是加电平转换。最简单的方案是用一个 5V 转 3.3V 的双向电平转换模块,市面上很多,逻辑原理是 MOSFET 双向电平转换,既能做 TX 方向也能做 RX 方向,不用关心谁收谁发。如果只是快速验证,也可以在 RX 线上串一个 1k 到 10k 的电阻做分压,但不推荐长期用。
我在一个用 FT232R 芯片的 USB 转 TTL 模块上踩过类似的坑:模块默认输出是 3.3V 或 5V 视跳线帽而定,跳线没注意,插到 Pico 上之后通信时好时坏。后来才发现是模块供电跳线选到了 5V,TX 输出高电平接近 5V,导致 Pico 侧偶尔识别异常。把跳线切到 3.3V 后恢复正常。
7.3 共地、TX/RX 交叉与模块供电的一次排查记录
串口接线有三条铁律:TX 接对方的 RX,RX 接对方的 TX,地线必须共地。第三条最容易被新手忽略。
我在调试一块 GPS 模块时遇到过非常诡异的现象:单独用 Pico 发的数据完全正常,但接上 GPS 模块后,Pico 收到的数据偶尔会出现连续错误字节。用万用表量 RX 引脚电平,发现波形在通讯瞬间被明显拉平了。最后发现是 GPS 模块和 Pico 分别由两个电源供电,两个电源之间没有共地,导致信号参考地不一致,电平判定在阈值边缘徘徊。
把两个系统的 GND 接在一起后,波形立刻正常,错误消失。这个案例让我之后在硬件调试清单里永远把“共地”放在第一位。
另一个同样常见的问题是 TX/RX 接反。现在很多串口模块为了引脚复用,把 TX 和 RX 标注得比较灵活,有的还支持自动交换。但如果你用的模块没有这个功能,接反之后的现象是:发送方向看起来正常,因为对方收到了;但接收方向完全没有反应。此时只要把 TX/RX 两根线互换,问题立刻解决。所以排查串口问题时,先把 TX/RX 对调一下,再开始查代码。
至于 FT232R 这类 USB 转 UART 模块,Windows 下偶尔会识别不到或者 COM 号被系统缓存占用。多数情况换一个 USB 口、在设备管理器里刷新一下,或者重新插拔一次就能解决。硬件环境干净,调软件才有意义。
最后说一个我这几年做串口调试养成的习惯:任何时候跑一个新的 UART 工程,我会先写一个最短的寄存器级自测程序——UART 的 TX 和 RX 短接,发一串已知数据再读回来。这个环回测试能快速验证引脚配置、波特率分频和收发通路是否正常。环回通过后再接外部设备,出问题时就能把范围缩小在外部电路。这个习惯帮我省掉了太多无谓的排错时间,也建议你试试。