☰
裸机UART驱动实战:i.MX6ULL串口初始化、中断接收与printf重定向
2026/10/20 17:57:04 网站建设 项目流程

给开发板接上显示器,不如先教会它说话和听话。串口就是这样一对嘴和耳朵:芯片把要打印的信息从TXD引脚送出去,外部工具从RXD引脚收进来;反过来外部输入从RXD进,芯片从URXD寄存器读走。我在i.MX6ULL上做裸机串口(UART)驱动,前后在好几块板子上折腾过,每次都会因为引脚配置、时钟源或波特率小数舍入这类小问题卡上一阵子。这篇内容面向想从寄存器层面理解串口、或者正在移植裸机UART驱动的开发者,把完整初始化、收发、中断接收和printf重定向的过程,以及我踩过的坑一起写清楚。我默认你已经能用点灯工程把开发板跑起来,知道怎么看参考手册,也准备了一块USB转TTL小板。

1. 为什么裸机串口要单独写一份驱动

1.1 串口在裸机工程里到底扮演什么角色

在Linux环境下串口有现成驱动,open、read、write就行,但裸机没有这些东西。点灯只能证明IO能拉高拉低,程序跑到了哪里、变量是不是期望值、外设芯片回没回数据,全都看不到。串口是裸机阶段性价比最高的调试手段,一个字符就是一条日志,一条日志就能告诉你函数有没有进来、状态机切到了哪里、错误码是什么。

很多人以为裸机UART驱动就是把寄存器抄一遍,实际不是。同样一块芯片,引脚默认也可能是GPIO,必须先把复用功能切到UART;模块时钟默认可能没开,不打开时钟寄存器写什么都是白写;波特率小数部分取整取错,发出来的全是乱码。串口驱动是第一个让你同时面对寄存器、时钟、引脚复用、中断这几个核心概念的驱动,啃完它,后面看I2C、SPI、PWM都会轻松很多。

1.2 完整的数据通路到底有多长

UART驱动要覆盖的链路其实很长:软件写UTXD,数据进入发送FIFO,硬件按波特率一位一位移出,经过片上引脚复用器选通到TXD引脚,再到板级USB转TTL芯片,最后在电脑串口工具出现。接收路径反过来,外部字节由RXD引脚进来,经过引脚复用器、接收移位寄存器、接收FIFO,最后被URXD读走。

如果你只配了UART寄存器而忘了引脚复用,数据永远到不了引脚;如果你只关注引脚却忘了时钟门控,寄存器写下去根本没反应。所以裸机UART驱动更像一次从寄存器到物理引脚的链路打通,而不是一个单点操作。我在实际调试中见过不少朋友卡了一两个小时,最后发现只是TXD和RXD接反,或者GND没有共地,这种低级问题在链路思维里一眼就能看出来。

1.3 方案选型:轮询还是中断

初版驱动我建议采用“轮询发送 + 中断接收”的组合。发送是CPU主动的,什么时间发、发多少内容由程序决定,等发送FIFO有空位再填下一个字节,这种忙等完全可接受。接收是另一回事,你根本不知道设备什么时候发数据,如果用轮询接收,主循环就必须一直盯在那里,别的逻辑全被阻塞。

中断接收的意义不是炫技,而是把UART接收从“主动等待”变成“被动通知”。外部数据到了,硬件打断CPU,ISR把数据搬进环形缓冲区,然后主程序有空了再去读。给耳朵加一个缓冲区,相当于给开发板装了一个语音信箱:没空接电话的时候,消息先存下来,回头再处理。DMA暂时不用上,先把轮询和中断这两种基础收发方案吃透,将来加DMA也只是在同一套状态机上做增量。

2. 寄存器级核心原理:UART模块到底是怎么工作的

2.1 把一次收发拆成流水线

UART发送像是一台自动封装机器:你把一个字节丢进UTXD,硬件自动加上起始位、数据位、可选的校验位和停止位,再从TXD引脚慢慢送出去。接收端则反过来:硬件检测到RXD引脚的下降沿后,认定这是一帧的开始,然后按照约定的波特率在数据位中间采样,拆掉起始位和停止位,把有效字节送进接收FIFO,最后通过URXD等你取走。

这里用16倍过采样,意思是一个数据位要被采样16次,硬件再判断这些采样点里哪个更稳定、更接近位中间,从而降低噪声误判。这也是为什么波特率必须精确:收发双方如果对每一位的时间理解不一致,起始位之后的采样点就会逐渐偏移,数据越长错得越离谱。

2.2 波特率是这样算出来的

i.MX6ULL的UART参考时钟常见为24MHz,波特率公式很简单:

baud = uart_clock / (16 * (UBRC + 1))

所以UBRC计算就是:

UBRC = uart_clock / (16 * baud) - 1

以24MHz、115200为例:

24_000_000 / (16 * 115200) = 13.02 UBRC = 13.02 - 1 = 12.02,向下取整为12

取UBRC=12之后,实际波特率是:

24_000_000 / (16 * 13) = 115384

误差只有0.16%,完全满足串口通信要求。这里最容易踩的坑是把13.02当成UBRC直接写13,此时divisor变成16*14=224,实际波特率只有107142,差得离谱。一定要先算出UBRC,再反向回代验证一遍。有些芯片还支持小数分频,但裸机入门阶段用整数UBRC足够,关键是误差控制在2%以内。

2.3 引脚复用和电气属性为什么不能省

芯片的TXD/RXD引脚出厂默认不一定就是UART功能,通常要先通过IOMUX把它切到复用模式。以UART4为例,典型写法是这样的:

/* 把UART4的TXD、RXD对应引脚复用为ALT0 */ IOMUXC->SW_MUX_CTL_PAD_UART4_TXD = 0; IOMUXC->SW_MUX_CTL_PAD_UART4_RXD = 0; /* 电气属性:上拉、驱动强度等,具体字段查参考手册 */ IOMUXC->SW_PAD_CTL_PAD_UART4_TXD = 0x10B0; IOMUXC->SW_PAD_CTL_PAD_UART4_RXD = 0x10B0;

注意这不是万能值,不同板子、不同电平域、不同信号线长度,对驱动强度和上下拉的要求不完全一样。我的建议是先照着手册选一个带内部上拉的组合,能用后再去调。输入引脚加上拉,是为了防止空闲时电平悬空被误判成数据;输出引脚驱动强度不够,接长线时信号边沿会变差,串口助手看到的还是乱码。

如果你不知道自己板子上的UART4有没有被引出,先查原理图。裸机没有设备树兜底,不是寄存器配置对就一定能在排针上量到波形。

2.4 状态标志位和数据寄存器速览

寄存器方向作用
URXD读接收数据寄存器,读它得到一字节数据
UTXD写发送数据寄存器,写它送出一字节数据
UCR1/UCR2读写控制寄存器,管开关、中断使能、数据格式
USR1/USR2读状态寄存器,报告发送就绪、接收有数据等状态
UBRC读写波特率分频除数

URXD和UTXD在不少参考手册里是同一个偏移地址,读是收、写是发,所以不要在代码里把它们定义成数组的两个成员,直接用结构体成员访问最稳妥。发送状态常用USR1里的TRDY位,接收状态常用USR2里的RDR位,多数SDK头文件里的宏名长这样:UART_USR1_TRDY_MASK和UART_USR2_RDR_MASK。具体命名每个工程略有差异,但思路完全一致。

3. 实操落地:从0到1点亮串口

3.1 初始化流程全解

初始化UART是一套固定顺序,我习惯按下面几步走,每一步都有自己的理由。

void uart4_init(unsigned int baud) { /* 1. 使能UART时钟,具体CCGR位以你头文件为准 */ CCM->CCGR3 |= CCM_CCGR3_CG8_MASK; /* 2. 引脚复用成UART4,并配置电气属性 */ IOMUXC->SW_MUX_CTL_PAD_UART4_TXD = 0; IOMUXC->SW_MUX_CTL_PAD_UART4_RXD = 0; IOMUXC->SW_PAD_CTL_PAD_UART4_TXD = 0x10B0; IOMUXC->SW_PAD_CTL_PAD_UART4_RXD = 0x10B0; /* 3. 先关闭串口,避免配置过程中模块出现不可控状态 */ UART4->UCR1 &= ~UART_UCR1_UARTEN_MASK; UART4->UCR2 = 0; /* 4. 设置波特率,默认参考时钟24MHz */ UART4->UBRC = (24000000U / (16U * baud)) - 1U; /* 5. 使能发送和接收,数据格式用默认的8N1 */ UART4->UCR2 = UART_UCR2_TXEN_MASK | UART_UCR2_RXEN_MASK; /* 6. 打开UART总开关 */ UART4->UCR1 |= UART_UCR1_UARTEN_MASK; /* 7. 读一次URXD,清掉残留状态 */ (void)UART4->URXD; }

为什么先关串口再配置?如果在引脚和时钟还没准备好的时候UART模块已经开始工作,它有可能往线上发送无效电平,或者因为状态不明把FIFO弄出垃圾数据。先关、配置、再开的顺序,是最稳妥的。

代码里的CCGR和IOMUX字段名只是概念演示,真正用哪个宏要看你的工程头文件。我的习惯是先把参考手册对应页打开,把宏名逐一对上号再编译,这样遇到问题能顺着文档排查。不要盲目复制别家工程的初始化,芯片版本和板卡引脚可能不一样。

3.2 发送接口:单字符、字符串、格式化输出

发送接口的核心就是等FIFO有空位,然后把数据填进去。

void uart_putc(char c) { while (!(UART4->USR1 & UART_USR1_TRDY_MASK)) ; UART4->UTXD = c; } void uart_puts(const char *s) { while (*s) uart_putc(*s++); }

TRDY为1表示发送FIFO可以接收新数据。为什么一定要等?因为UART是慢速外设,115200波特率下传一个字节大约要87微秒,CPU速度远快于串口,如果不等待就连续写UTXD,数据会被覆盖或者卡在FIFO里顺序错乱。串口助手里最常见的输出乱序,很多就是这里出了问题。

这里有一个非常影响体验的小细节:串口终端默认把“换行”解释成光标下移,但不会回到行首,所以你发\n而不发\r,终端会变成阶梯状。裸机串口调试建议统一在字符串末尾用\r\n,少走弯路。

3.3 接收接口:轮询读取和超时保护

轮询接收最直接,但一定要记得先查状态位再读数据。

int uart_getc(void) { while (!(UART4->USR2 & UART_USR2_RDR_MASK)) ; return (unsigned char)UART4->URXD; }

如果直接读URXD,而不是等RDR为1再读,你有可能读到FIFO里的旧数据,甚至读到空数据。实际场景里更实用的是非阻塞版本:

int uart_getc_nonblock(unsigned char *c) { if (!(UART4->USR2 & UART_USR2_RDR_MASK)) return 0; *c = (unsigned char)UART4->URXD; return 1; }

非阻塞接收的意义在于,主循环可以一边做别的事一边轮询。比如驱动一个状态机,每个循环周期都检查有没有新串口指令,有就处理,没有就继续流转。不要在主循环里写一个永久等待的uart_getc(),那等于把整个系统锁死在串口上。

读URXD之后,大多数情况下硬件会自动清掉RDR标志位。如果参考手册上明确写了需要软件清标志,那就按手册补一步。每个芯片的细节不同,但这种“先查状态、再读数据、最后确认标志”的套路是通用的。

3.4 把printf重定向到串口

很多裸机工程想直接打印变量值,最简单的方法是重定向printf。在ARM GCC工具链、newlib环境下,实现_write函数就能把printf的输出送到串口:

int _write(int file, char *ptr, int len) { int i; for (i = 0; i < len; i++) uart_putc(ptr[i]); return len; }

如果你的工程走的不是newlib,也可以实现fputc,效果类似。还有一种更轻量的方式,是自写一个debug_printf:

void debug_printf(const char *fmt, ...) { char buf[128]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); uart_puts(buf); }

这个方式不依赖标准库重定向,缓冲区大小自己控制,在裸机工程里更好移植。注意如果要用%f打印浮点数,链接时要加-u _printf_float,否则新库为了省空间默认不链接浮点格式化代码,你只会看到空输出或者f直接不打印。

printf重定向成功之后,还要注意别在中断服务函数里直接用printf。发送是轮询的,万一发送FIFO忙,printf会占用大量时间,中断里调用它等于给整个系统挖坑。

4. 用中断给耳朵上buff:带环形缓冲的接收方案

4.1 什么时候必须用中断

轮询接收只适合“程序暂时没有别的事,专门等一个字符”的场景。如果你在跑显示刷新、按键扫描、传感器读取,这些过程可能要花几毫秒,而对方恰好在这几毫秒里发来一串数据,等你回头查RDR时,数据早就被新数据顶掉了。

更常见的场景是模块主动上报数据:GPS、蓝牙、串口透传传感器,动不动就连续发几十字节。这时候就必须用中断接收,让UART模块在有数据时主动打断CPU,把字节读走。中断相对于轮询,不是把接收变快,而是把接收变成异步事件,CPU该干嘛还干嘛。

4.2 裸机中断初始化需要准备什么

用中断接收之前,先把三件事准备好:外设中断使能、中断控制器使能、全局中断打开。

外设侧通常是在UCR1里打开接收中断使能位,比如:

UART4->UCR1 |= UART_UCR1_RDIE_MASK;

中断控制器在i.MX6ULL上走的是GIC,裸机启动代码里一般已经有GIC_EnableIRQ之类的函数,把UART的中断号传进去即可:

GIC_EnableIRQ(UART4_IRQn); __enable_irq();

如果这些接口在你的工程里不存在,那就先把按键外部中断跑通。外部中断能进ISR,UART中断就成功了一多半,剩下的只是把外设中断源换成UART而已。向量表里UART4对应哪个IRQHandler,以你的启动文件为准,函数名通常就是UART4_IRQHandler。

4.3 环形缓冲区代码实现

环形缓冲区是裸机串口接收的标配,思路是先申请一块数组,用两个下标分别表示“下一个写入位置”和“下一个读出位置”。

#define UART_RX_BUF_SIZE 256 static volatile unsigned char s_rxBuf[UART_RX_BUF_SIZE]; static volatile unsigned int s_rxHead = 0; static volatile unsigned int s_rxTail = 0; static volatile unsigned int s_rxOverflow = 0; void UART4_IRQHandler(void) { while (UART4->USR2 & UART_USR2_RDR_MASK) { unsigned char ch = (unsigned char)UART4->URXD; unsigned int next = (s_rxHead + 1) % UART_RX_BUF_SIZE; if (next == s_rxTail) { s_rxOverflow = 1; } else { s_rxBuf[s_rxHead] = ch; s_rxHead = next; } } }

主程序读取:

int uart_read_byte(unsigned char *c) { if (s_rxHead == s_rxTail) return 0; *c = s_rxBuf[s_rxTail]; s_rxTail = (s_rxTail + 1) % UART_RX_BUF_SIZE; return 1; }

这里s_rxHead只在ISR里写,s_rxTail只在主程序里写,这是一个典型的生产者-消费者模型。数组长度256,实际最多缓存255字节,因为还留了一个空位区分“空”和“满”。如果不想浪费那一个字节,可以额外加一个计数变量,但裸机调试阶段这个结构已经够稳。

ISR里用while循环把所有已经到达的数据一次性搬进缓冲区,比读一个字节就返回要好。原因是UART的RDR标志位不会因为读了第一个字节就自动不再触发,如果FIFO里还有数据,标志位仍然为1,你读一个字节就返回,函数退出后硬件马上再次打断,中断开销变大。

4.4 中断服务函数书写禁区

ISR不是普通函数,第一原则是快进快出。不要在ISR里调用printf,不要在里面做延时,不要做耗时运算。正确姿势是把数据搬进环形缓冲区,然后立刻返回,真正的协议解析放到主程序里做。

共享变量一定要加volatile。否则编译器可能把s_rxHead优化到寄存器里,ISR和主程序各拿一份缓存,读出来的数据就会神出鬼没。这是我见过不止一次的问题:逻辑完全正确,但不开优化时正常,开了O2后串口数据时好时坏,最后查出来就是volatile缺失。

环形缓冲满时选择丢掉新数据,同时置一个溢出标志,这个策略在裸机阶段最省心。千万不要在ISR里尝试去处理“缓冲区满了怎么办”的业务逻辑,那会引入不可控的延迟。

5. 调试实录:常见问题与排查清单

5.1 按现象查原因速查表

现象常见原因排查方向
完全无输出时钟未使能、引脚复用没改、TXD接错线先量TXD引脚有没有波形
输出全是乱码波特率不一致、UART参考时钟不是24MHz、UBRC取整错误反向计算实际波特率,核对串口助手设置
只能发不能收没开RXEN、RXD引脚复用没配置、RXD没接进USB转TTL检查UCR2的RXEN,量RXD有没有波形
首字符丢失开UART后立刻发送,模块还没稳定初始化后加一个短暂延时再打印
长时间运行后卡死ISR写缓冲和主程序读缓冲竞争、环形缓冲溢出查head/tail是否错乱,补volatile
USB转TTL一插上电压被拉低板上串口引脚和USB转TTL供电不一致确认电平是3.3V还是5V,GND是否共地

这个表是我调试串口时最先参照的清单。绝大多数问题不是出在复杂寄存器上,而是出在“链路没通”或者“参数没对齐”这种最基础的地方。先把表上的项过一遍,再去啃寄存器,效率高得多。

5.2 几个容易被忽略的坑

第一个坑是参考时钟不是你以为的那个值。i.MX6ULL的UART参考时钟可以选择24MHz,也可能来自PLL分频,得看时钟控制寄存器里到底选了什么。我见过一个工程,初始化代码里写死24MHz,但实际已经把参考时钟切到了不同的分频系数,结果所有波特率都偏移得厉害。公式本身不难,难的是确认你代进公式的数字真是硬件实际使用的时钟。

第二个坑是板子原理图上的串口编号。有的开发板引出的是UART1,有的是UART2或UART4,而且旁边可能还有排针丝印和实际功能不一致的情况。不要凭经验猜,拿万用表量一下USB转TTL的TXD连接到了芯片哪个引脚,再对照复用表反查是哪一路UART。

第三个坑是串口终端和裸机程序对换行的理解不一致。模块返回的数据常常用\r\n结尾,而你自己打印日志时习惯用\n,看起来输出就会一行比一行高。终端软件大多有“发送CRLF”的选项,但裸机驱动里日志直接写成\r\n更好,一次到位。

第四个坑是中断使能顺序。先开全局中断,再开外设中断,可能会在初始化还没完成时突然来一个中断;反过来先开外设中断再开全局中断,不会漏事件。更稳妥的做法是:先配置完UART全部寄存器,再使能UART接收中断,最后打开全局中断。

5.3 用回环和逻辑分析仪快速切分问题

串口调不通时,我的固定顺序是先短接,再上工具。所谓回环,就是用一根杜邦线把开发板的TXD和RXD直接短接,然后调用uart_puts("test\r\n"),程序里再把收到的字符打印出来。如果自发自收能成功,说明UART模块本身、时钟、引脚、波特率都是对的,问题在外部链路。如果自发自收都不行,问题大概率在芯片内部配置上,先别去折腾USB转TTL。

外部链路部分,逻辑分析仪比串口助手更直观。把逻辑分析仪的通道夹在TXD引脚上,波特率设成和程序一致,然后触发一次发送。如果波形上能看到起始位、数据位、停止位,而且解码出来的字符就是你发的,说明芯片这一侧完全正常,问题在USB转TTL或者接线。如果连波形都没有,那就回头查引脚复用和时钟。

每次上板前我还会做一步很笨但很有效的检查:用万用表确认开发板的GND和USB转TTL的GND是连通的。串口的电平是相对地线的,两边地线不通,TXD和RXD接了也是白接。这个原因太常见,常见到很多人最后才发现。

我个人做完这套UART驱动之后,最大的体会是串口不是“外设”,而是一条通路。寄存器是阀门,时钟是能源,引脚复用是换轨,中断和环形缓冲区是路口管理。任何一环断了,数据都过不来。把这条通路完整走通,等于把裸机开发的底层骨架搭好了,后面再去碰其他接口,上手速度会完全不一样。

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

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

立即咨询