给开发板接上显示器不如先教会它说话和听话。串口就是这样一对嘴和耳朵芯片把要打印的信息从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接收从“主动等待”变成“被动通知”。外部数据到了硬件打断CPUISR把数据搬进环形缓冲区然后主程序有空了再去读。给耳朵加一个缓冲区相当于给开发板装了一个语音信箱没空接电话的时候消息先存下来回头再处理。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取UBRC12之后实际波特率是24_000_000 / (16 * 13) 115384误差只有0.16%完全满足串口通信要求。这里最容易踩的坑是把13.02当成UBRC直接写13此时divisor变成16*14224实际波特率只有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_printfvoid 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();如果这些接口在你的工程里不存在那就先把按键外部中断跑通。外部中断能进ISRUART中断就成功了一多半剩下的只是把外设中断源换成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是否错乱补volatileUSB转TTL一插上电压被拉低板上串口引脚和USB转TTL供电不一致确认电平是3.3V还是5VGND是否共地这个表是我调试串口时最先参照的清单。绝大多数问题不是出在复杂寄存器上而是出在“链路没通”或者“参数没对齐”这种最基础的地方。先把表上的项过一遍再去啃寄存器效率高得多。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驱动之后最大的体会是串口不是“外设”而是一条通路。寄存器是阀门时钟是能源引脚复用是换轨中断和环形缓冲区是路口管理。任何一环断了数据都过不来。把这条通路完整走通等于把裸机开发的底层骨架搭好了后面再去碰其他接口上手速度会完全不一样。