☰
STM32 IO模拟串口实现:从时序原理到避坑指南
2026/10/20 10:10:10 网站建设 项目流程

简介:面向嵌入式开发者的STM32 IO模拟串口可运行源码工程,解决硬件串口被占用或资源受限时,无法使用专用UART模块的通信问题,适合资源受限项目或需要节省成本的设计场景。压缩包共10个文件,包含UART模拟核心C源码、主程序、头文件、工程构建所需的Makefile、链接脚本与启动文件,以及README说明文档,整体仅15KB,可直接编译验证。已有110人学习下载。源码完整演示了IO模拟串口的收发闭环:发送端支持延时函数与定时器两种延时方式,精确控制起始位、数据位与停止位的电平时序;接收端通过外部中断捕获起始位下降沿,再在定时器中断中按波特率采样后续数据位。工程内包括引脚配置、外部中断与定时器初始化、发送函数与接收中断函数等关键实现,便于快速移植到其他STM32项目,也可作为深入理解UART协议的实战参考。

1. 为什么放着现成串口不用,偏要拿 IO 模拟一个

做嵌入式开发遇到串口不够用,是特别常见的需求。比如某个项目从 4G 模组、GPS、调试串口三个外设挤两个 UART,最后只能拆掉一路调试口;比如某个某公司量产产品想省一颗成本,把原本用芯片自带串口的传感器通信改到任意 GPIO 上——这时候唯一能救场的方案,就是用普通 IO 引脚按 UART 协议时序去模拟一个串口出来。STM32 的 GPIO 翻转速度足够快,内核主频 72MHz 甚至 48MHz 的型号都能用软件把 9600、115200 波特率的收发跑稳定,代价只是占 CPU 和定时器资源。我把一套可运行源码的核心思路和踩过的坑整理在这里,新手能照步骤搭收发,熟手能直接看到参数边界和异常处理办法。

IO 模拟串口本质上不是玄学,就是按 UART 帧格式把电平按时序拉高拉低。发送端用普通 GPIO 输出模式在 Tx 引脚上构造起始位、8 位数据、停止位,接收端用外部中断捕捉起始位下降沿,再用定时器窗口去采样每一位电平。这个方案尤其适合资源受限、协议固定、波特率不高的场景,比如调试日志输出、低速传感器数据采集、Bootloader 升级通道。读完这篇文章,你能拿到最小可运行的发送和接收代码,知道怎么对着示波器或逻辑分析仪校准时序,也知道接收端在不同波特率下会踩哪些坑。

2. 模拟串口的时序模型:先搞清楚每一位是怎么卡出来的

2.1 UART 帧格式与位时间计算

串口通信一个字节的帧结构是固定的:空闲时总线保持高电平,发送前先拉低一个位宽作为起始位,然后从低位到高位逐位输出 8 个数据位,最后拉高至少一个位宽作为停止位。所以模拟发送的核心就是精确控制每位电平的持续时间。位时间的计算公式是1 / 波特率,以 115200 为例,每一位持续约 8.68 微秒;9600 波特率下每一位约 104.17 微秒。知道了这个时间,剩下的工作就是逐位翻转 GPIO。

发送端实现有两种常见做法:一种是纯延时函数配合空循环计数,适合不要求精确时间的场合;另一种用定时器精确定时,适合收发共用一套时间基准的场景。我一般建议发送用延时、接收用定时器加中断,这样逻辑最清晰,也好调试。下面先给一个最简单可跑的延时发送实现,再讲精度问题。

2.2 发送端最小实现:用延时翻转 GPIO 发出一个字节

以下是基于 STM32 标准外设库的发送函数,硬件环境是某 STM32F103 开发板,主频 72MHz,使用 PA0 作为模拟 Tx。

// 模拟串口发送引脚宏定义 #define SIM_UART_TX_GPIO_PORT GPIOA #define SIM_UART_TX_PIN GPIO_Pin_0 // 延时函数:大约延时 n 个微秒(72MHz 下粗略校准) void delay_us(uint32_t n) { for (uint32_t i = 0; i < n * 8; i++) { __NOP(); } } // 初始化 GPIO:推挽输出 void sim_uart_tx_init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = SIM_UART_TX_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(SIM_UART_TX_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(SIM_UART_TX_GPIO_PORT, SIM_UART_TX_PIN); // 空闲保持高 } // 发送一个字节 void sim_uart_send_byte(uint8_t byte) { // 起始位:拉低一个位时间 GPIO_ResetBits(SIM_UART_TX_GPIO_PORT, SIM_UART_TX_PIN); delay_us(bit_time_us); // 9600 波特率时这里是 104 // 8 个数据位:低位在前 for (uint8_t mask = 0x01; mask != 0; mask <<= 1) { if (byte & mask) { GPIO_SetBits(SIM_UART_TX_GPIO_PORT, SIM_UART_TX_PIN); } else { GPIO_ResetBits(SIM_UART_TX_GPIO_PORT, SIM_UART_TX_PIN); } delay_us(bit_time_us); } // 停止位:拉高一个位时间 GPIO_SetBits(SIM_UART_TX_GPIO_PORT, SIM_UART_TX_PIN); delay_us(bit_time_us); }

这段代码的核心逻辑是:每个位动作之后都用delay_us卡一个完整位宽,保证接收方采样点落在每个位的中间位置。bit_time_us是根据波特率计算的位时间整数微秒数,如果波特率是 9600,位时间是 104 微秒,直接代入即可;115200 波特率下位时间是 8.68 微秒,延时函数就要拆成微秒计数,精度稍差,更推荐用定时器方案。

2.3 延时函数的校准方法与误差边界

上面delay_us里的n * 8是空循环校准值,实际会随编译器优化级别、芯片主频、Flash 等待周期变化。校准方法有两个:一是用示波器或逻辑分析仪看单字节波形,数起始位到停止位之间的总时长,调整乘数;二是用 GPIO 翻转输出方波,对比频率计读数。误差边界要注意:如果位时间误差超过 5%,接收端在连续多字节传输时就会累积漂移,最终出现帧错误。空循环延时的优势是零额外资源,劣势是不能被中断打扰——中断一进来,延时就拉长,这一位就长了。

建议把delay_us放在不会被频繁抢占的中断优先级场景下使用,或者改成定时器查询方式,边等定时器标志边翻转引脚,但定时器查询本质还占 CPU。熟手一般直接给发送也挂一个定时器中断,在中断里逐位发送,但这会引入中断响应抖动,代码复杂度高不少。对多数日志输出场景,空循环方案焊死一个波特率不动,反而是最容易维护的。

3. 接收端实战:外部中断捕获起始位,定时器窗口采样数据位

3.1 接收时序拆解:下降沿启动,中点采样

接收比发送麻烦得多,因为你不知道对方什么时候开始发数据。UART 总线的空闲电平是高,起始位是一个拉低的位宽,所以接收端必须用一个能感知下降沿的机制来启动接收流程。STM32 的 GPIO 外部中断正是干这个的:配置为下降沿触发,一旦检测到起始位下降沿,立刻把引脚从中断模式切成普通输入模式,然后启动定时器,每隔一个位时间采样一次引脚电平。

采样的关键窗口在位的中间,也就是每一位宽度的大约 50% 位置。为什么不能在位的起点或终点采样?因为信号沿附近电平不稳定,加上线路电容、噪声影响,靠近上升沿或下降沿采样容易读到错误值。在位的中间采样是最宽容的。这里会踩一个特别典型的坑:外部中断触发后,如果立刻关中断切换模式,引脚可能还在下降沿抖动,直接读电平可能读到低,但这其实是起始位——正确处理方式是触发后延时半个位时间再开始采样。

3.2 定时器采样接收的完整实现

下面给一个基于定时器中断的接收实现,用定时器 2 做位采样时钟,每次中断读一次 Rx 引脚电平。假设主频 72MHz,定时器预分频 71(即 1MHz 计数频率),9600 波特率下采样周期对应的计数初值是 104。

#define SIM_UART_RX_GPIO_PORT GPIOA #define SIM_UART_RX_PIN GPIO_Pin_1 volatile uint8_t sim_uart_rx_buffer = 0; volatile uint8_t sim_uart_rx_done = 0; volatile uint8_t rx_bit_pos = 0; volatile uint8_t rx_current_byte = 0; // 外部中断初始化:PA1 下降沿触发 void sim_uart_rx_exti_init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = SIM_UART_RX_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(SIM_UART_RX_GPIO_PORT, &GPIO_InitStructure); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource1); EXTI_InitStructure.EXTI_Line = EXTI_Line1; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling; EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = EXTI1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); } // 定时器 2 初始化:用于生成位采样中断 void sim_uart_rx_timer_init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStructure.TIM_Period = 103; // 计数到 104 触发更新,即 104us TIM_InitStructure.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz,计数周期 1us TIM_InitStructure.TIM_ClockDivision = 0; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, DISABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }

这两个初始化函数把接收准备工作做完,下面是实际接收状态机逻辑。

// 外部中断服务函数:等待起始位 void EXTI1_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line1) != RESET) { EXTI_ClearITPendingBit(EXTI_Line1); // 检测到下降沿,禁止 EXTI,启动定时器准备采样 EXTI_InitStructure.EXTI_LineCmd = DISABLE; EXTI_Init(&EXTI_InitStructure); rx_bit_pos = 0; rx_current_byte = 0; TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); } } // 定时器中断:每个位时间采样一次 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); uint8_t pin_level = GPIO_ReadInputDataBit(SIM_UART_RX_GPIO_PORT, SIM_UART_RX_PIN); if (rx_bit_pos == 0) { // 起始位中点采样,应该读到低电平 // 实际开发中若读到高电平,说明是误触发,要复位 if (pin_level == 1) { TIM_Cmd(TIM2, DISABLE); // 重新使能 EXTI 继续等待 EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); } } else if (rx_bit_pos >= 1 && rx_bit_pos <= 8) { // 数据位采样,低位在前 if (pin_level) { rx_current_byte |= (0x01 << (rx_bit_pos - 1)); } } else if (rx_bit_pos == 9) { // 停止位采样,应读到高电平,此时整帧完成 TIM_Cmd(TIM2, DISABLE); sim_uart_rx_buffer = rx_current_byte; sim_uart_rx_done = 1; // 重新使能 EXTI EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); } rx_bit_pos++; } }

这段代码的思路是:定时器从起始位下降沿触发后立即启动,第一次中断正好落在起始位中点,之后每 104 微秒触发一次,依次对应数据位 0 到 7 和停止位。rx_bit_pos == 0那次中断是校验起始位还是低电平,不是就复位;第 2 到第 9 次中断读数据位,第 10 次读停止位并完成一帧。需要注意 EXTI 配置和定时器初值中的微秒映射,主频不同、预分频不同,数值都要重算。

4. 串口参数配置与三个必调点:波特率、位序、采样点

4.1 波特率与位时间的换算表

IO 模拟串口没有专门的波特率寄存器可设,一切都要换算成位时间。下表是常用波特率在 72MHz 主频、1MHz 定时器计数频率下的参数值:

波特率位时间(微秒)定时器计数初值误差说明
4800208207较稳定
9600104103推荐首选
192005251中断负载变大
384002625需关闭部分中断
1152008.688误差偏大,慎用于接收

由于 1MHz 计数无法整除 115200 对应的位时间,定时器初值必然带来约 3.5% 的单次采样误差。单看一位似乎可接受,但连续帧下起始位后采样的累计偏移会让高位出错。所以做 115200 接收时建议把定时器计数时钟提到 2MHz 或直接用主频计数来降低量化误差,代价是计数器溢出更快,中断频率更高。

4.2 数据位顺序:低位在前不要搞反

串口协议规定数据位是低位先发,这在芯片硬串口里是透明的,但软件模拟要手动移位。很多第一次写模拟发送的开发者会把mask初始化为0x80,结果对方收到字节高低位翻转,调试半天发现是移位方向错了。发送端mask应从0x01开始逐位左移,接收端(byte >> bit_pos) & 0x01采集,不要图省事把数据按位反转后发送。

建议在协议层把字节位序写成宏或函数注释,防止后续维护的人改错。这看起来是个小事,实际项目中我见过好几次因为发送和接收位序不统一导致数据错乱的翻车记录,收尾排查之后发现根本不是时序问题,而是移位写反了。

4.3 接收采样点偏移的补偿策略

采样点应当落在每位宽度的中间附近。定时器从下降沿触发开始计时,但下降沿触发到第一个中断响应之间会有中断响应延迟,延迟时间取决于当前系统中断负载。如果系统里有更高优先级的定时器中断在跑,接收中断可能被推迟几十微秒,这会导致采样点整体往后偏。

补偿策略有两种:一种是降低接收定时器中断的优先级,让它不被普通外设打断;另一种是在起始位采样时提前触发计数器,即在 EXTI 中断里先延时半个位时间再启动定时器,让第一次定时器中断落在数据位 0 的中间。第二种做法更稳妥,代价是实现时多一个延时函数。对 9600 波特率,半个位时间是 52 微秒,空循环延时就能满足精度。

5. 代码跑起来之后的避坑手册:现象、原因与解决方案

5.1 数据第一个字节一直是 0x00 或 0xFF

现象:用模拟接收和 USB-TTL 模块对发,收到的第一个字节总是固定值,后续字节偶尔能对。

原因:最常见的是外部中断在毛刺上误触发。Rx 引脚悬空或者被干扰拉低,EXTI 捕获到第一个下降沿,但对方尚未真正开始发送,定时器开始采样时采了一堆无意义电平。另一个原因是上位机串口助手发送时默认带 DTR/RTS 信号抖动,导致电平异常。

解决:接收 GPIO 必须启用内部上拉;如果线缆较长,建议在引脚到地之间加一个 10nF 电容滤除高频毛刺。代码上把起始位校验做强——第一次定时器中断必须读到低电平,否则立即复位状态机,重新等待下降沿。

5.2 发送端用示波器看到位宽偏大或偏小

现象:逻辑分析仪显示每个位的时间是设定值的 1.2 倍或 0.8 倍,接收方经常出现偶发乱码。

原因:空循环延时的乘数没有校准,编译优化等级从 O0 改成 O2 后循环耗时明显变化,或者函数被内联、循环变量被优化导致延时变化。插入volatile修饰可以防止编译器优化掉空循环,但不会校准绝对时间。

解决:不要靠肉眼数循环次数,写一个发送 0x55 的循环测试函数,0x55 的波形是稳定的高低交替,用示波器量一个字节总时长,目标值是 10 个位时间(起始位 + 8 个数据位 + 停止位)。实际值除以目标值得到修正系数,乘以delay_us的空循环乘数即可。

5.3 接收高波特率时中断响应不过来

现象:4800、9600 都正常,一改成 19200 或 38400 就大量丢字节,串口助手显示帧错误。

原因:波特率提高一倍,定时器中断频率提高一倍,如果系统里还有其他中断频繁抢占,CPU 在两次采样中断之间无法及时回到底层,状态机错过采样,帧就废了。另一个隐藏原因是定时器更新中断里做了太多事情,比如调用延时函数、读写长耗时外设。

解决:把接收位采样中断优先级提到最高,中断服务函数里只做引脚读取、移位、状态切换,绝不在中断里做协议解析或数据拷贝。如果还丢,就只能退回到 9600 或改用硬串口。模拟串口的边界就在这,别硬扛,产品选型阶段就要把资源算清楚。

5.4 定时器初值计算错了一位数,导致奇偶校验位全部错乱

现象:发送端存在偶发乱码,接收端收到的字节在特定值上出错,比如 0x01 接收成 0x81。

原因:把定时器预分频和初值搞混。预分频是 71 对应 1MHz 计数频率,初值是 103 对应 104 微秒周期。如果将初值误填为 104,计数到 105 才触发中断,位时间变成 105 微秒,多出的 1 微秒在每个字节的 10 位里累积 10 微秒,接近 10% 的漂移,采样点从位中间滑到位的边缘。

解决:每次改主板频率或定时器时钟源,都用公式重算一遍:采样计数初值 = (定时器时钟 / 波特率) - 1,不要套旧代码里的参数。用逻辑分析仪连续抓 5 个字节,确认起始位和每个数据位的上升沿/下降沿位置是否等间距。

5.5 收发共用同一个引脚导致电平冲突

现象:用一根线把模拟 Tx 和模拟 Rx 短路做自发自收测试,发送正常、接收乱码或完全无数据。

原因:GPIO 在同一时刻被配置为推挽输出和上拉输入,发送端拉低时接收端读到低没问题,发送端释放拉高时两个引脚可能存在驱动冲突,或者模式切换不及时。

解决:自发自收测试正确做法是:用一个引脚发送,另一个引脚接收,杜邦线或飞线连接两端。如果硬件上已经焊死单线,就把发送结束后把 Tx 引脚切到高阻输入模式,接收前再切回输出模式,但这样做的时序稳定性很差,只适合验证用,不推荐量产。

6. 进阶用法:用 DMA 样式的环形缓冲区配合模拟串口做日志输出

这个技巧特别适合把模拟串口用在调试日志场景。前面几章实现的是单字节收发,实际调试时一次要打几十上百字节,逐个字节阻塞发送会让主流程卡死几十毫秒。可以把发送改造成“先入环形缓冲区,后台持续发送”的模式,让主程序只负责往缓冲区写数据,中断服务函数或主循环后台把缓冲区里的字节挨个发出去。

实现思路是:定义一个大数组作为环形缓冲区,维护写指针和读指针。主程序调用sim_uart_log(char *s)时只做字符串拷贝到缓冲区,然后置一个发送忙标志。主循环里检查若当前没有发送动作且缓冲区非空,就取一个字节调用sim_uart_send_byte。如果想更高效,可以用定时器中断做后台发送,中断里判断上一个字节是否发送完成,是就直接取下一字节发出,发送本身不再阻塞任何业务代码。

环形缓冲区的大小按最大日志单条长度 + 余量设置,常见做法是 256 或 512 字节。满了怎么办?两个选择:覆盖最旧数据,适合只关心最新状态的场景;或者丢弃新数据,适合要完整链路日志的场景。我一般选覆盖最老的,因为嵌入式现场能看的往往是最近一段行为。

验证方法也很简单:写一个循环,每秒通过模拟串口发送一帧包含计数和系统状态的结构体,在 PC 端用串口工具记录 1 小时,统计乱码比例。如果乱码率低于万分之一,说明时序稳定;如果偶发丢帧,优先检查中断被其他高频中断打断的情况。

另外一个经常被忽略的习惯:发布代码前把 GPIO 初始化和定时器初值集中到一个sim_uart_config.h里,把波特率、主频、引脚号都用宏定义。这样换芯片型号、改引脚时只改一个头文件,别人接手也容易看懂。我早期做模拟串口就吃过亏——代码里散落着各种硬编码的延时乘数,换了一块主频不同的板子后所有时序全错,一个个文件翻着改,改得手都麻了。后来统一成参数表加注释,省了非常多血泪时间。希望这篇笔记能帮你把模拟串口一次跑通,少走这些弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询