基于PWM与中断的软件UART实现:以MMC2001为例的嵌入式通信方案
1. 项目概述与核心价值
在嵌入式开发的老手圈里,有个经典场景你一定不陌生:项目板上那颗MCU,硬件UART(通用异步收发器)就一个,但需求上偏偏需要两个甚至更多的串口来连接传感器、显示屏或者调试终端。这时候,要么换芯片增加成本,要么就得在软件上动脑筋。软件UART(Software UART),或者说软件串行通信接口(Software SCI),就是解决这个矛盾的经典方案。它不是魔法,而是利用MCU现有的通用定时器资源,通过精密的软件时序控制,模拟出硬件UART的收发行为。今天,我就以飞思卡尔(现恩智浦)经典的MMC2001这款基于M•CORE内核的32位RISC微控制器为例,带你从头到尾拆解一个利用PWM(脉宽调制)模块和中断驱动实现的、最高支持19200波特的半双工软件UART。
为什么选MMC2001?因为它内置了多达6个独立的PWM通道,这些定时器精度高、可灵活配置,是生成精准位定时的绝佳硬件基础。整个方案完全用C语言实现,核心思想是让PWM定时器在每一位数据的中心点产生中断,在中断服务程序里完成单个比特的读取或写入。这样做最大的好处是解放CPU:通信过程几乎不占用主循环资源,主程序可以安心处理其他任务,特别适合在简单的轮询系统或小型实时操作系统中作为可调用模块。虽然实现的是半双工通信(不能同时收发),但对于很多主从式问答协议或者单向数据流场景,已经完全够用。接下来,我会把官方应用笔记里的骨架,填充上我十多年摸爬滚打积累的实战细节、配置背后的“为什么”、以及那些容易踩坑的注意事项,让你不仅能看懂代码,更能理解设计逻辑,并顺利移植到你自己的项目中去。
2. 核心设计思路与硬件资源剖析
2.1 为什么选择PWM和中断?
实现软件UART,核心是解决“定时”问题。串行通信的每一位都需要在精确的时间窗口内被采样或输出。常见的“位碰撞”(Bit Banging)方法是在主循环中死等延时,这无疑会完全霸占CPU。而更优雅的做法是利用硬件定时器。
在MMC2001上,我们选择了PWM模块而非普通的定时器,原因有三:其一,PWM模块本身就是一个带自动重载功能的定时器,其周期寄存器(PWMPR)和计数器为我们提供了稳定的时间基准。其二,PWM模块通常配有独立的中断源(当计数器达到周期值时触发),这正好契合我们“每个比特时间产生一次中断”的需求。其三,PWM通道通常有对应的外部引脚,虽然在本方案中我们不使用其PWM输出功能,但可以将其配置为通用I/O(GPIO),方便我们直接控制发送电平或读取接收电平。
中断驱动是整个方案的灵魂。发送时,PWM5定时器每过一个位时间(如104µs @ 9600波特)中断一次,在中断服务程序sci_tx()中送出下一位。接收时,首先由一个外部中断(INT6引脚)捕获起始位的下降沿,然后启动PWM4定时器,并在其中断服务程序sci_receive()中于每位中点采样数据。这种“事件驱动”模型,使得CPU只在需要处理数据位的瞬间被短暂唤醒,其余时间均可休眠或处理其他任务,极大地提升了系统效率。
2.2 资源分配与协议定义
基于MMC2001的资源,我们做如下分配:
- 发送通道(TX):使用PWM5。
- 定时:PWM5的定时器用于产生位周期中断。
- 引脚:PWM5对应的引脚被配置为通用输出(GPIO),直接驱动TX信号线。
- 接收通道(RX):使用PWM4和外部中断INT6。
- 定时:PWM4的定时器用于产生接收采样中断。
- 触发:INT6引脚(属于EDGE端口)配置为下降沿触发,用于检测起始位。
- 引脚:INT6引脚作为RX信号输入。注意:PWM4的引脚在本方案中并未使用。
通信协议采用最常见的异步串行格式:
- 数据格式:1个起始位(逻辑0),8个数据位(LSB先发),1个停止位(逻辑1),无校验位。
- 波特率:最高支持19200。代码示例以9600波特率进行演示和计算。
- 工作模式:半双工。由全局标志
transmit控制,发送时禁止接收中断,防止自我干扰。
2.3 关键计算:如何用PWM产生精准的波特率?
这是理解整个方案的基础。MMC2001的系统时钟为32MHz。PWM模块有一个时钟预分频器(CLKSEL),可以对其进行分频得到参考时钟f_ref。
以9600波特率为例,计算步骤如下:
- 确定位时间:
T_bit = 1 / 9600 Hz ≈ 104.1667 µs。 - 选择预分频:为了获得较高的定时分辨率,我们选择较小的分频比。设置CLKSEL=000,即4分频。此时PWM的参考时钟为:
f_ref = 32 MHz / 4 = 8 MHz,周期T_ref = 1 / 8 MHz = 125 ns。 - 计算周期寄存器值:我们需要PWM每
T_bit时间产生一次中断(即计数器从0计数到PWMPR值的时间)。所以,PWMPR = T_bit / T_ref = 104.1667 µs / 125 ns ≈ 833.33。取整为833。- 实际波特率:
Baud_actual = f_ref / PWMPR = 8 MHz / 833 ≈ 9603.8,误差约为0.04%,远小于RS-232标准允许的误差(通常<3%),完全可用。
- 实际波特率:
- 接收起始位采样:为了在起始位的中间点进行采样以避开边沿抖动,接收PWM4的初始周期被设置为位时间的一半,即
PWMPR = 833 / 2 ≈ 416。在确认起始位有效后,再将其改回833,用于后续数据位的采样。
注意:预分频比(CLKSEL)和周期寄存器(PWMPR)共同决定了波特率。提高预分频比可以降低
f_ref,从而用更小的PWMPR值实现低波特率,但会牺牲定时分辨率。设计时需要权衡。公式为:Baud = f_sys / (Prescaler * PWMPR)。
3. 软件架构与模块化实现
整个软件UART驱动可以清晰地划分为初始化、发送引擎、接收引擎三大模块,并通过一组全局变量进行状态协同。
3.1 全局变量与状态机
使用全局变量是在中断服务程序(ISR)与主程序之间共享数据的简便方法。关键变量如下:
// 在 main.h 中定义 static u1 *tx_buffer; // 发送数据缓冲区指针 static u1 rx_buffer[RX_BUFFER_LENGTH]; // 接收数据缓冲区 static s2 tx_length; // 待发送数据长度 static s2 rx_length; // 已接收数据长度(索引) static s2 tx_state = 0; // 发送状态机:0=起始位,1=数据位,2=停止位 static u1 mask = MASK_INIT; // 位掩码,用于从字节中提取特定位,初始为0x01 (LSB) static u1 j=0, k=0; // j: 发送缓冲区字节索引; k: 接收位索引(0为起始位) bool transmit = FALSE; // 发送标志,TRUE时禁止接收发送状态机(tx_state)是发送逻辑的核心。它是一个简单的3状态机:
- 状态0:处理起始位。将TX引脚拉低,然后状态转为1。
- 状态1:处理8个数据位。根据当前
mask检查tx_buffer[j]的对应位,决定输出高低电平。每处理完一位,mask左移一位。当mask移出字节(变为0)时,表示8位数据发送完毕,状态转为2。 - 状态2:处理停止位。将TX引脚拉高,重置
mask为0x01,tx_state归0,并递增字节索引j。如果j等于tx_length,则停止PWM5定时器,结束本次发送。
3.2 初始化函数sci_init()深度解析
初始化是搭建舞台的关键一步,任何配置错误都会导致通信失败。我们逐行分析sci_init()的关键操作。
1. 发送PWM5配置
PWM_A_SetRegister(tx_pwmptr, PWM_A_PWMCR_SWITCH, tx_pwmptr->PWMCR = PWM_A_IRQEN_MASK | PWM_A_DATA_MASK | PWM_A_DIR_MASK | PWM_A_DIV_4 );PWM_A_IRQEN_MASK:使能PWM周期匹配中断。这是发送时序的发动机。PWM_A_DATA_MASK:将PWM数据位(DATA)初始化为1。结合DIR配置为输出,这使得TX引脚在空闲时保持高电平(符合RS-232空闲状态)。PWM_A_DIR_MASK:将PWM5引脚配置为通用输出(GPIO),而不是PWM输出模式。PWM_A_DIV_4:设置时钟预分频为4,得到8MHz参考时钟。
2. 设置PWM5周期(波特率)
PWM_A_UpdateOutput(tx_pwmptr, FALSE, 833, 416);- 参数
833:写入周期寄存器(PWMPR),决定中断频率,即波特率。 - 参数
416:写入脉宽寄存器(PWMWR)。注意:在GPIO模式下,脉宽值不影响输出,只要小于周期值即可。这里设为周期的一半是习惯做法。 FALSE:不立即加载(LOAD=0)。新的周期值会在当前周期结束后生效,避免波形毛刺。
3. 接收PWM4与外部中断INT6配置接收侧的配置稍复杂,因为它涉及两个中断源(INT6和PWM4)的协作。
// 配置PWM4,仅使能中断,时钟分频同PWM5 PWM_A_SetRegister(rx_pwmptr, PWM_A_PWMCR_SWITCH, rx_pwmptr->PWMCR |= PWM_A_IRQEN_MASK); PWM_A_UpdateOutput(rx_pwmptr, FALSE, 416, 208); // 初始周期为半位时间,用于起始位中点采样 // 配置INT6引脚为下降沿敏感 edgeportptr->EPPAR |= EPPAR_EPPA6_FALLING_EDGE_MASK;- 关键点:PWM4的初始周期是416(半位时间)。这是因为在
INT6检测到下降沿后,我们希望在起始位的正中间进行第一次采样,以避开信号边沿的抖动区域。定时器在半位时间后中断,正好位于起始位中点。
4. 中断控制器(INTC)配置这是将硬件中断与我们的C语言函数连接起来的桥梁。
INTC_A_Init(intctlr, VBA, &funcs); // 设置中断向量表基地址到内部RAM起始(0x30000000) // 关联中断源与服务函数 INTC_A_SetISF(intctlr, INTSRC_PWM5_BITNO, INTSRC_PWM5_MASK, (ddErr_t(*)(void *, void *))sci_tx, NULL, NULL); INTC_A_SetISF(intctlr, INTSRC_INT6_BITNO, INTSRC_INT6_MASK, (ddErr_t(*)(void *, void *))sci_rx, NULL, NULL); INTC_A_SetISF(intctlr, INTSRC_PWM4_BITNO, INTSRC_PWM4_MASK, (ddErr_t(*)(void *, void *))sci_receive, NULL, NULL); // 全局使能这三个中断源,并开启处理器的快速中断 INTC_A_IntEnable(intctlr, INTSRC_PWM4_MASK | INTSRC_PWM5_MASK | INTSRC_INT6_MASK, TRUE, TRUE);INTC_A_SetISF:此函数将特定的中断号(如INTSRC_PWM5_BITNO)与我们编写的函数(如sci_tx)绑定。当PWM5的周期匹配中断发生时,CPU会自动跳转到sci_tx函数执行。INTC_A_IntEnable:这个函数做了两件重要的事:一是在快速中断使能寄存器(FIER)中置位PWM4、PWM5、INT6对应的位,允许它们产生中断请求;二是在程序状态寄存器(PSR)中设置异常使能(EE)和快速中断使能(FE)位,打开CPU的中断总开关。
3.3 发送引擎:sci_send()与sci_tx()协作
发送由主程序主动调用sci_send()函数触发。
void sci_send(u1 *buffer_ptr, s2 buffer_length) { transmit = TRUE; // 置位发送标志,阻止接收中断 tx_length = buffer_length; // 保存长度 tx_buffer = buffer_ptr; // 保存缓冲区指针 j = 0; // 重置字节索引 tx_state = 0; // 状态机复位到“起始位” mask = MASK_INIT; // 位掩码复位 PWM_A_Start(tx_pwmptr); // 启动PWM5定时器,开始发送 }这个函数非常直观:准备好数据,然后启动定时器。真正的发送工作完全由中断服务程序sci_tx()完成。一旦PWM5定时器启动,它就会像节拍器一样,每隔一个位时间(104µs)触发一次中断,调用sci_tx()。
sci_tx()函数就是一个状态机的执行器。每次被调用,它根据tx_state决定当前该发送起始位、数据位还是停止位,并通过宏SCI_TX_ON或SCI_TX_OFF操作PWM5控制寄存器的DATA位,从而改变TX引脚的电平。发送完一个字节的停止位后,如果还有后续字节,则状态机复位,继续发送下一个字节;如果所有字节发送完毕,则调用PWM_A_Stop(tx_pwmptr)停止定时器,并清除transmit标志,允许系统再次接收。
3.4 接收引擎:sci_rx()与sci_receive()的握手
接收过程是事件驱动的,始于一个不可预测的下降沿。
- 起始位检测(
sci_rx):当INT6引脚出现下降沿(起始位开始),硬件触发中断,调用sci_rx()。该函数首先清除中断标志(EPFR_EPF6_MASK),然后检查transmit标志。如果系统不在发送状态,它就启动PWM4定时器。注意:此时PWM4的周期是半位时间(416)。 - 起始位验证与数据位采样(
sci_receive):PWM4第一次中断发生在半位时间后,即起始位的正中间。在sci_receive()中,我们首先屏蔽发送中断(FIER &= ~INTSRC_PWM5_MASK),防止发送干扰接收时序。然后采样INT6引脚电平:- 如果为低电平,确认是有效的起始位(不是噪声毛刺)。此时,将PWM4的周期改为全位时间(833),并将内部位索引
k加1,跳过对起始位的后续处理。 - 如果为高电平,说明是噪声,函数直接返回,PWM4会因计数器停止而不再中断(需要超时处理,本例未实现,是潜在优化点)。
- 如果为低电平,确认是有效的起始位(不是噪声毛刺)。此时,将PWM4的周期改为全位时间(833),并将内部位索引
- 数据位接收:此后,PWM4以全位时间为周期中断。每次中断(
k从1到8),执行接收操作:rx_buffer[rx_length] >>= 1;:将已接收的字节右移一位,为新的最高位腾出空间。rx_buffer[rx_length] |= (edgeportptr->EPDR & EPDR_EPD6_MASK) << 1;:采样INT6引脚电平。如果为高,则将其值(1)移到字节的最高位(MSB)并存入。由于是LSB先收,这里采用右移拼接的方式。
- 停止位处理与结束:当
k=9时(8个数据位已收),检查采样到的电平是否为高(停止位)。如果是,则停止PWM4定时器,完成一个字节的接收:rx_length递增,重新使能INT6中断以等待下一个字节的起始位,并将PWM4周期重置为半位时间,为下一次接收做准备。
4. 关键代码详解与移植要点
4.1 发送引脚控制宏的奥秘
在main.h中,控制TX引脚电平的宏定义非常精炼:
#define SCI_TX_OFF tx_pwmptr->PWMCR &= ~PWM_A_DATA_MASK #define SCI_TX_ON tx_pwmptr->PWMCR |= PWM_A_DATA_MASK为什么直接操作PWMCR寄存器?因为我们将PWM5配置为了GPIO输出模式。在这种模式下,PWM_A_DATA_MASK对应的位(第7位)直接控制引脚的电平输出。置1为高,清0为低。这种直接寄存器操作速度极快,满足了中断服务程序对时效性的苛刻要求。
4.2 中断服务程序中的关键操作
在ISR中,有两项操作至关重要且容易被忽略:
- 清除中断标志:在
sci_tx()和sci_receive()的开头,都有这样一行:
这行代码的作用是读取PWM控制寄存器中的中断请求位(IRQ)。在MMC2001的许多外设中,读某个特定的状态寄存器或位就能清除其中断标志。这里是通过“读-清零”操作来清除PWM周期匹配中断标志,防止中断持续触发。对于INT6中断,则在tx_pwmptr->PWMCR & PWM_A_IRQ_MASK; // 或 rx_pwmptr->PWMCR & PWM_A_IRQ_MASK;sci_rx()中通过edgeportptr->EPFR |= EPFR_EPF6_MASK;来显式清除边沿标志。 - 防止中断重入:虽然PWM中断是周期性的,但理论上一个中断处理未完,下一个中断又到来的可能性极低(因为ISR执行时间远小于104µs)。但良好的习惯是,在进入关键的、非幂等的操作前,可以考虑临时关闭该中断,操作完再打开。本例中通过操作FIER寄存器来屏蔽发送中断,就是一种保护。
4.3 移植到其他MCU的思考
这个软件UART方案的核心思想是通用的,但移植时需要关注以下几点:
- 定时器选择:目标MCU需要有至少两个可编程的、能产生中断的通用定时器或PWM模块。一个用于发送位定时,一个用于接收采样定时。
- 中断系统:了解目标MCU的中断向量表结构、中断使能/禁止的方法、以及中断标志清除机制。可能需要对
INTC_A_*系列的API进行重写或替换。 - 引脚控制:找到可以配置为通用输入输出的引脚,并了解其寄存器级的控制方法,以替代
SCI_TX_ON/OFF宏和EPDR采样操作。 - 全局变量与状态机:这部分C代码逻辑可以完全复用。
- 时间精度:根据系统时钟和定时器分频能力,重新计算产生目标波特率所需的定时器重载值。务必评估时钟误差和中断延迟对波特率误差的影响。
5. 实战调试技巧与常见问题排查
即使代码逻辑清晰,在实际硬件上调试软件UART也常会遇到各种问题。下面是我总结的一些实战经验和排查清单。
5.1 调试工具准备
- 逻辑分析仪:这是调试软件UART的神器。用它同时抓取TX、RX引脚波形,可以直观地看到起始位、数据位、停止位的宽度和电平,以及位与位之间的时间间隔,精准定位时序问题。
- 示波器:用于观察信号质量,检查是否有过冲、振铃或毛刺,特别是在长线通信时。
- 串口调试助手:连接一个已知良好的USB转串口模块到你的软件UART,发送已知数据包,对比收发内容。
5.2 常见问题与解决方案
| 现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 完全无发送信号 | 1. PWM定时器未启动。 2. TX引脚未配置为输出。 3. 中断未正确使能。 | 1. 检查sci_send()是否被调用,以及PWM_A_Start是否成功。2. 确认 PWMCR寄存器中DIR和MODE位已正确配置为GPIO输出。3. 使用调试器单步跟踪 sci_init(),确认INTC_A_IntEnable执行后,FIER和PSR相关位是否置1。检查中断向量表地址是否正确。 |
| 发送波形畸变或位宽不准 | 1. 波特率计算错误。 2. 系统时钟配置错误。 3. 中断服务程序执行时间过长。 | 1. 用逻辑分析仪测量位时间,反推实际波特率。核对f_sys、预分频、PWMPR值的计算。2. 确认MCU的主时钟源(晶振、PLL)已正确配置并稳定运行。 3. 优化 sci_tx()函数,确保其执行时间远小于位时间(如9600波特下远小于104µs)。避免在ISR中进行复杂计算或函数调用。 |
| 能发送不能接收 | 1. INT6外部中断未触发。 2. 接收PWM4定时器未启动。 3. 起始位验证失败。 | 1. 确认INT6引脚配置为下降沿触发(EPPAR)。用示波器或逻辑分析仪确认有下降沿到达该引脚。2. 在 sci_rx()中设置断点,看下降沿后是否执行。检查transmit标志是否被意外置位。3. 在 sci_receive()中检查半位时间后采样到的起始位电平。可能是硬件连接问题(如电平反相)或噪声导致采样为高。 |
| 接收数据错位或乱码 | 1. 采样点不在位中心。 2. LSB/MSB顺序弄反。 3. 中断嵌套或优先级冲突。 | 1.这是最常见的问题。调整接收PWM4的初始相位。可以尝试微调半位时间的值(如415或417),用逻辑分析仪观察采样点是否对准位中心。 2. 核对 sci_receive()中数据拼接逻辑。本例是LSB先收,采用右移拼接。如果协议是MSB先收,逻辑需要调整。3. 确保UART中断的优先级足够高,不会被其他长时间中断阻塞。如果使用了操作系统,注意关中断/开中断的保护。 |
| 通信一段时间后死机 | 1. 中断标志未清除,导致无限进入中断。 2. 缓冲区溢出。 3. 全局变量在中断和主程序中被非原子访问。 | 1.务必检查每个ISR开头的中断标志清除操作。这是嵌入式中断编程的铁律。 2. 本例接收缓冲区 rx_buffer是固定大小的。主程序必须及时读取并处理数据,否则新数据会覆盖旧数据。可以增加缓冲区满的判断。3. 对于 tx_length,rx_length等在多处访问的变量,如果主程序在读取时被中断修改,可能导致数据不一致。对于8位或32位MCU上的简单类型,通常单条指令可完成读写,风险较低,但复杂场景需考虑使用临界区保护。 |
5.3 性能优化与扩展建议
- 全双工扩展:本方案是半双工。要实现全双工,需要再增加一个独立的PWM定时器用于接收位定时(因为发送和接收的位定时是独立且同时进行的),并可能需增加一个外部中断引脚用于另一路的起始位检测。全局状态管理会更复杂。
- 更高波特率:波特率上限受限于两个因素:一是CPU执行ISR的速度,二是定时器的最小分辨率。要提升到38400甚至115200,需要优化ISR代码至极致(使用内联汇编关键部分),并可能需提高系统时钟或使用更高分辨率的定时器。
- 加入超时机制:当前的接收逻辑假设一帧数据是连续的。如果传输中途断线,PWM4定时器会一直运行并中断。可以增加一个“帧超时”定时器,在收到起始位后启动,若在10-11个位时间内未收到停止位,则复位接收状态机。
- 使用DMA:对于高速或大数据量传输,可以考虑用DMA来搬运发送/接收缓冲区中的数据,进一步减轻CPU负担。但这需要MCU支持DMA触发与PWM或GPIO事件联动,实现复杂度较高。
实现一个稳定可靠的软件UART,是对开发者嵌入式系统理解深度的一次很好检验。它涉及到底层硬件寄存器操作、中断系统、精确定时以及状态机设计等多个核心知识点。把这个MMC2001的案例吃透,其设计模式和调试思路完全可以迁移到其他ARM Cortex-M、AVR、PIC等主流MCU平台上。当你手头的芯片UART不够用时,这份自己打造的“软串口”无疑会成为你最得力的工具之一。
