简介:面向嵌入式开发者的STM8S003软件模拟串口实践工程,针对该型号原生物理串口资源不足的问题,在IAR Embedded Workbench环境下利用PC3、PC4引脚配合定时器实现UART时序,支持查询与中断两种接收模式。压缩包共53个文件、约418KB,以IAR工程配置(eww、ewp、ewd等)、C源码与头文件、编译中间产物及日志为主,工程结构完整,导入后即可编译调试。已有425人学习下载,适合具备一定STM8与GPIO、定时器基础的开发者参考。读者可从中获得GPIO与定时器配置、发送波形生成、接收电平捕获及中断服务处理的完整代码框架,并借助工程配置与说明文档快速复现实验,理解资源受限MCU上软件模拟串口的设计思路,为多路模拟串口、不同波特率适配等扩展打下基础。
1. 用两个 GPIO 把 STM8S003 的串口“变”出来
手上只有一颗 STM8S003F3P6,8K Flash、1K RAM,封装小到 20 个引脚,UART 只给了一组,结果项目里既要接一个调试口,又要接一个外设模块,引脚一数就发现不够用。这种时候最省事的做法不是换芯片,而是拿两个普通 GPIO 软件模拟一路串口出来。IAR 环境下做 STM8S003 模拟串口,核心就三件事:一个定时器负责位定时,一个外部中断负责起始位捕获,收发状态机负责拼字节。听起来简单,真正翻车的地方全在时序精度和中断优先级上。这篇笔记按“能不能用、怎么配、坑在哪”的顺序拆一遍,适合手里有 STM8S003 最小系统板、想在 IAR 里快速补一路串口的从业者,也适合刚接触 8 位机位操作的新手照着复现。
2. 模拟串口在 STM8S003 上的时序账:为什么选定时器而不是延时循环
软件模拟串口(bit-banging UART)的本质,是把一个字节的每一位按约定波特率摊到时间轴上,发送时逐位翻转引脚,接收时逐位采样。它不依赖芯片的 UART 外设,只依赖 GPIO 和定时能力。STM8S003 主频常见配置是内部 16MHz HSI 经分频得到 16MHz 或 8MHz,一条指令周期在 16MHz 下约 62.5ns,用_delay_us()这种空循环做位定时,理论可行,实际一开中断就抖得没法看。
2.1 位定时为什么必须交给定时器
串口帧结构是 1 个起始位(低)、8 个数据位(低位先发)、1 个停止位(高),无校验时共 10 位。以 9600bps 为例,每位宽 104.17μs,一个字节 1.04ms。如果用空循环延时,编译器优化等级一变、中断一插进来,位宽就漂了,接收端采样点偏到相邻位,直接出乱码。这是模拟串口最典型的玄学故障:发的时候好好的,收的时候偶尔错一个字节。
常见做法是用 STM8 的 TIM1 或 TIM2 做位定时源。TIM1 是 16 位高级定时器,功能多但配置重;TIM2 是 16 位通用定时器,够用且轻。发送时定时器按位周期溢出,在中断里翻引脚;接收时用外部中断抓起始位下降沿,然后启动定时器在每位中点采样。这样位宽由硬件计数器保证,不受主循环影响。
2.2 波特率误差怎么算、能容忍多少
定时器重装值算法:ARR = (F_CPU / 分频 / 波特率) - 1。假设 F_CPU = 16MHz,定时器不分频,9600bps 时ARR = 16000000/9600 - 1 ≈ 1665.67,取 1666,实际波特率 = 16000000/1667 ≈ 9598.1,误差 -0.02%,完全够用。115200bps 时ARR ≈ 137.9,取 138,实际 115107,误差 -0.08%,也在可接受范围。
提示:异步串口一般要求收发双方累计误差小于 3%~5%,单边控制在 2% 以内比较稳。8 位数据位、10 位帧,采样点在第 1.5 位、2.5 位……逐位推进,误差会累积到停止位,所以别贴着 5% 用。
真正麻烦的是低波特率配高分频。有人为了省电把定时器分频设成 128,9600bps 下ARR = 16000000/128/9600 - 1 ≈ 12.02,取 12,实际波特率 16000000/128/13 ≈ 9615,误差 +0.16%,看着还行,但重装值只有 13,中断响应抖动占的比重就大了。血泪经验是:模拟串口尽量让 ARR 落在 100 以上,分频能不分就不分。
2.3 引脚与中断资源怎么分配
STM8S003F3P6 的 20 个引脚里,可用作外部中断的端口有限。接收脚必须接能触发外部中断的 GPIO,比如 PD2、PD3 这类带 EXTI 的引脚;发送脚随便一个普通输出 GPIO 即可。下面这张表是我一般会先填的资源分配,配之前先对一遍,能省掉一半返工。
| 资源 | 用途 | 典型选择 | 注意点 |
|---|---|---|---|
| GPIO 输出 | 模拟 TX | PD4 等普通推挽输出 | 初始拉高,空闲态为高 |
| GPIO 输入 | 模拟 RX | PD2/PD3 带 EXTI | 上拉输入,抓下降沿 |
| 定时器 | 位定时 | TIM2 | 不与已用 PWM 冲突 |
| 中断 | 起始位捕获 | EXTI 端口中断 | 优先级要高于定时器 |
| 中断 | 位采样/移位 | TIM2 溢出中断 | 中断内代码要短 |
选型理由很直接:TIM2 在 STM8S003 上通常没被占用,16 位精度足够;EXTI 抓起始位比轮询可靠,轮询会漏掉帧头。如果项目里 TIM2 已经拿去做别的,退而用 TIM1 也行,但 TIM1 的寄存器多,配置时容易漏开TIM1_CR1的计数器使能。
3. IAR 工程落地:从 GPIO 配置到收发状态机
这一章是能直接抄的部分。工程基于 IAR EWSTM8,芯片选 STM8S003F3,库用 ST 标准外设库(StdPeriph)或直接寄存器操作都行,下面用寄存器写法,省得纠结库版本。整体思路:初始化 GPIO 和 TIM2,写发送函数(阻塞式逐位发),写接收中断(EXTI 抓起始位 + TIM2 采样),主循环里做字节拼装。
3.1 GPIO 与定时器初始化
#include "iostm8s003f3.h" #define F_CPU 16000000UL #define BAUDRATE 9600UL #define TIM2_ARR ((F_CPU / BAUDRATE) - 1) // 16MHz/9600-1 = 1666 /* 模拟串口引脚:TX = PD4,RX = PD2 */ void SoftUart_Init(void) { /* TX:推挽输出,空闲高电平 */ PD_DDR |= (1 << 4); PD_CR1 |= (1 << 4); PD_ODR |= (1 << 4); /* RX:上拉输入,等待下降沿 */ PD_DDR &= ~(1 << 2); PD_CR1 |= (1 << 2); /* 外部中断:PD2 下降沿触发 */ EXTI_CR1 &= ~0x03; // PD 端口灵敏度清零 EXTI_CR1 |= 0x02; // 仅下降沿触发 /* TIM2:16MHz 不分频,向上计数,暂不启动 */ TIM2_PSCR = 0x00; // 分频 1 TIM2_ARRH = (uint8_t)(TIM2_ARR >> 8); TIM2_ARRL = (uint8_t)(TIM2_ARR & 0xFF); TIM2_EGR = 0x01; // 手动产生更新事件,加载 ARR TIM2_SR1 = 0x00; // 清标志 TIM2_IER = 0x01; // 允许更新中断 }这段代码的逻辑:先把 TX 脚设成推挽输出并拉高,保证空闲态正确,否则接收方会误判成起始位;RX 脚设上拉输入,避免悬空误触发。EXTI_CR1的低两位控制 PD 端口灵敏度,写0x02是下降沿触发,这是抓起始位的关键。TIM2 的PSCR=0表示不分频,ARR按公式算出来,EGR写 1 强制加载预装载值,否则第一次溢出会用旧值。参数上唯一要改的是BAUDRATE,改完TIM2_ARR自动跟着变,但记得确认结果落在 100 以上。
3.2 发送:阻塞式逐位输出
/* 发送一个字节,阻塞式,调用前确保中断已开 */ void SoftUart_SendByte(uint8_t data) { uint8_t i; /* 起始位:拉低 */ PD_ODR &= ~(1 << 4); SoftUart_DelayOneBit(); /* 8 个数据位,低位先发 */ for (i = 0; i < 8; i++) { if (data & 0x01) PD_ODR |= (1 << 4); else PD_ODR &= ~(1 << 4); data >>= 1; SoftUart_DelayOneBit(); } /* 停止位:拉高 */ PD_ODR |= (1 << 4); SoftUart_DelayOneBit(); } /* 一位延时,用定时器轮询比空循环稳 */ static void SoftUart_DelayOneBit(void) { TIM2_CNTRH = 0; TIM2_CNTRL = 0; TIM2_CR1 |= 0x01; // 启动计数 while (!(TIM2_SR1 & 0x01)); // 等更新标志 TIM2_SR1 &= ~0x01; // 清标志 TIM2_CR1 &= ~0x01; // 停计数 }发送这里我选阻塞式而不是中断式,原因是发送期间本来就不能被打断太久,阻塞实现简单、时序可控,代价是发一个字节 CPU 被占约 1ms(9600bps)。SoftUart_DelayOneBit用定时器轮询而不是空循环,位宽由计数器保证,主频变化时只需改TIM2_ARR。注意TIM2_CR1的启动位和停止位操作要成对,漏了停止会导致下一次延时从上次计数值继续,位宽直接错乱。
3.3 接收:EXTI 抓起始位 + 定时器中点采样
volatile uint8_t rx_buf[16]; volatile uint8_t rx_head = 0, rx_tail = 0; static volatile uint8_t rx_byte, rx_bitcnt, rx_active = 0; /* PD 端口外部中断:起始位下降沿 */ #pragma vector = EXTI2_vector __interrupt void EXTI2_IRQHandler(void) { if (!rx_active) { rx_active = 1; rx_bitcnt = 0; rx_byte = 0; /* 半位后采样,对准起始位中点 */ TIM2_CNTRH = 0; TIM2_CNTRL = (uint8_t)(TIM2_ARR / 2); TIM2_CR1 |= 0x01; } } /* TIM2 更新中断:每位中点采样 */ #pragma vector = TIM2_OVR_UIF_vector __interrupt void TIM2_IRQHandler(void) { TIM2_SR1 &= ~0x01; if (!rx_active) return; if (rx_bitcnt < 8) { if (PD_IDR & (1 << 2)) rx_byte |= (1 << rx_bitcnt); rx_bitcnt++; /* 重装为整位,后续采样点逐位推进 */ TIM2_CNTRH = 0; TIM2_CNTRL = 0; } else { /* 停止位采样完,收一个字节 */ rx_active = 0; TIM2_CR1 &= ~0x01; rx_buf[rx_head] = rx_byte; rx_head = (rx_head + 1) & 0x0F; } }接收状态机的关键在第一次重装值:EXTI 进来时是起始位下降沿,此时把计数器设成ARR/2,半个位周期后溢出,正好落在起始位中点,之后每次重装 0,采样点就落在每个数据位的中点。这是模拟串口抗抖动最有效的一招,比“下降沿后延时 1.5 位再采样”稳得多。rx_active做重入保护,防止一帧没接完又被新下降沿打断。环形缓冲区用& 0x0F取模,长度 16,主循环里判断rx_head != rx_tail取数据即可。
注意:中断服务函数里不要做耗时操作,
rx_buf写入和指针更新要尽量短。如果主循环处理慢,缓冲区会覆盖,表现为“偶尔丢字节”,别急着怀疑波特率。
3.4 主循环拼装与回环验证
void main(void) { uint8_t ch; SoftUart_Init(); __enable_interrupt(); // 开全局中断 while (1) { if (rx_head != rx_tail) { ch = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) & 0x0F; SoftUart_SendByte(ch); // 回环:收到什么发什么 } } }回环测试是最快的验证手段:PC 端串口助手发一串字符,板子原样回显。能稳定回显说明收发时序都对;回显乱码先查波特率误差,回显丢字节查缓冲区,完全没反应查 EXTI 灵敏度和引脚方向。这套代码在 9600bps 下实测连续收发几千字节不丢,115200bps 下要谨慎,中断响应时间占比变大,建议把SoftUart_SendByte里的轮询延时换成更紧凑的写法。
4. 避坑与排查:模拟串口最容易翻车的五个地方
4.1 现象:发出去的数据 PC 端全是乱码
原因通常是波特率误差超限或 TX 空闲电平不对。先确认TIM2_ARR算得对不对,F_CPU宏和实际主频是否一致——有人时钟初始化改成 8MHz,宏还写着 16MHz,波特率直接翻倍。再确认 TX 脚初始化后是否拉高,推挽输出没设PD_CR1会变成开漏,高电平拉不上去。解决:用示波器或逻辑分析仪量一位宽度,9600bps 应为 104μs,偏差超过 3μs 就回头查时钟和 ARR。
4.2 现象:能收不能发,或发几个字节后卡死
多半是发送函数里定时器启停没配对。SoftUart_DelayOneBit里启动计数后如果中途被高优先级中断打断,回来时更新标志可能已被清掉,while死等。解决:发送期间临时关定时器中断(不是关全局中断),或者把等待改成带超时的轮询。另一个常见原因是TIM2_SR1清标志顺序不对,先读后写才有效,直接赋值在某些 STM8 批次上清不掉。
4.3 现象:接收偶尔错一位,且错的位置不固定
这是采样点漂移的典型表现。检查 EXTI 中断里第一次重装值是不是ARR/2,如果写成ARR或 0,采样点会落在位边缘,稍有抖动就采错。还要确认 EXTI 只开了下降沿,如果误开双边沿,停止位上升沿会再触发一次,把下一帧的起始位吃掉。解决:EXTI_CR1对应位严格写0x02,并在 EXTI 里加rx_active判断。
4.4 现象:和真实 UART 外设同时用时互相干扰
STM8S003 的 UART1 和模拟串口如果共用同一个端口的中断向量,或者定时器被两处同时配置,会出现“单独跑都好、一起跑就崩”。解决:模拟串口固定用 TIM2,真实 UART 用自身波特率发生器,互不占资源;中断优先级上把模拟串口接收设得比 UART 高,避免起始位被延迟响应。引脚分配提前画表,别等 PCB 打样完才发现 PD2 被占。
4.5 现象:低功耗模式下模拟串口失效
进 HALT 或 AWU 后定时器停振,模拟串口自然不工作。有人以为加个唤醒就行,结果唤醒后第一帧总是丢。原因是从停机到定时器稳定需要时间,起始位已经过去了。解决:低功耗场景下要么让模拟串口只做发送(发送前主动唤醒),要么在唤醒后丢弃第一帧、等线路空闲再收。这条没有银弹,选型阶段就得想清楚。
5. 进阶:把模拟串口做成可复用模块的几个习惯
做到能回环只是及格线,真正在项目里用起来,还得解决“换波特率不改代码”“收发不互相阻塞”“多路模拟串口共存”这几件事。我一般会把上面那套代码收成一个soft_uart.c/h,对外只暴露SoftUart_Init(baud)、SoftUart_Send(buf, len)、SoftUart_Read(buf, len)三个接口,波特率作为运行时参数传进去,内部用一张小表或现算 ARR。这样换 4800 还是 19200 只改调用处,不用动寄存器。
发送改成中断驱动是第二个习惯。阻塞发送在单任务里没问题,一旦主循环里有其他实时任务,1ms 的占用就碍事了。做法是把待发字节放进发送环形缓冲,SoftUart_Send只负责入队并启动定时器,TIM2 中断里按位翻引脚、发完一字节取下一个。注意发送和接收如果共用 TIM2,得用状态机区分当前是发还是收,或者干脆给发送单独用 TIM1,资源换清晰度。
第三个习惯是给模拟串口加一层轻量协议校验。软件模拟的位定时天生比硬件 UART 脆,长帧、连续帧更容易累积误差。常见做法是帧尾加 CRC8 或简单累加和,收到校验不过就丢帧重发。别小看这一层,现场干扰大的时候,它能把你从“偶发乱码查三天”里捞出来。
| 进阶项 | 做法 | 代价 |
|---|---|---|
| 波特率运行时可配 | ARR 现算,Init 传参 | 多几行除法,Flash 略增 |
| 发送中断化 | 发送环形缓冲 + 状态机 | 代码复杂度上升 |
| 帧校验 | CRC8 或累加和 | 每帧多 1 字节开销 |
| 多路模拟串口 | 每路独立定时器 + 独立 EXTI | 受限于定时器数量 |
最后说个验证习惯:每次改完时序相关代码,我一定先用逻辑分析仪抓一段连续收发,量起始位宽度、数据位中点、停止位位置,三个点都对上再上整机。模拟串口这东西,仿真器单步调试会破坏时序,断点一打全乱,只能靠抓波形。从那以后我每次动 TIM2 的 ARR 或 EXTI 配置,都强制走一遍“抓波形—对中点—跑回环”三步,省下的返工时间远比抓波形那几分钟多。希望帮到你。
本文还有配套的精品资源,点击获取