简介:这份资源面向在IAR Embedded Workbench下开发STM8S003的嵌入式工程师与学习者,针对该芯片原生物理串口资源不足的问题,给出基于PC3、PC4引脚配合定时器实现软件模拟UART的完整工程。压缩包共53个文件,约418KB,以C源码与头文件为核心,辅以IAR工程配置、编译输出、调试脚本及日志等类型文件,覆盖从源码到可烧录固件的完整链路。资源中同时包含查询接收与中断接收两种模式的实现思路,涉及GPIO推挽/开漏配置、定时器波特率设定、发送波形生成与接收电平捕获等关键环节,并附有README说明导入与运行方式。目前已有423人学习下载,适合希望掌握资源受限MCU串口扩展、并在此基础上扩展多路模拟串口或适配不同波特率的开发者参考。
1. 为什么要在 IAR 里给 STM8S003 模拟串口
STM8S003 这颗芯片在低成本工控板、小家电控制板里出镜率极高,价格便宜、外设够用,但它有一个让很多人第一次上手就皱眉的问题:片上只有一个真正的 UART。当你已经用它接了 Modbus 从机、接了上位机调试口,再想挂一个串口屏、一个无线模块或者第二路传感器时,硬件串口就不够分了。这时候「模拟串口」就成了绕不开的方案——用两个普通 GPIO 加一个定时器,靠软件翻转电平把 UART 时序"拼"出来。
在 IAR EWSTM8 环境下做这件事,难点不在写代码,而在时序精度和中断干扰。STM8S003 跑 16MHz 时一条指令周期是 62.5ns,115200 波特率下一位只有 8.68μs,也就是约 139 个指令周期,留给你的容错窗口非常窄。这篇文章就是把这个过程拆开:怎么选定时器、怎么配 GPIO、发送和接收分别怎么写、IAR 里有哪些编译选项会偷偷影响时序,以及我踩过的那些坑。适合已经能用 IAR 点亮 STM8S003、想再榨出一路串口的嵌入式工程师。
2. 模拟串口的原理与 STM8S003 上的资源选型
2.1 模拟串口到底在模拟什么
UART 的物理层其实很简单:空闲时线是高电平,起始位拉低一位时间,然后 8 位数据(LSB 先发),可选校验位,最后停止位拉高。接收端在起始位下降沿之后,每隔 1 位时间采样一次。所谓模拟串口,就是用软件精确控制 GPIO 在正确的时间点翻转,让对面那个真正的 UART 芯片以为在跟一个标准串口说话。
发送相对简单:你掌握主动权,只要按位定时翻转就行。接收才是真正的难点,因为你不知道数据什么时候来,必须在起始位下降沿触发中断,然后在每一位的中点采样,避开边沿抖动。采样点偏早或偏晚,累积几个位之后就会采错。
STM8S003 上做模拟串口,核心资源就三样:一个空闲的定时器、两个 GPIO(一收一发)、以及足够快的中断响应。下面这张表是我在选型时对比的几个关键点:
| 资源 | 可选方案 | 我的选择与理由 |
|---|---|---|
| 定时器 | TIM1 / TIM2 / TIM4 | TIM4 是 8 位基本定时器,结构简单、中断向量独立,适合做位定时 |
| 发送引脚 | 任意 GPIO | 选带外部中断能力的口,方便调试时抓波形 |
| 接收引脚 | 带 EXTI 的 GPIO | 必须能触发外部中断,用于捕获起始位 |
| 时钟 | 内部 16MHz / 外部晶振 | 内部 RC 有 ±1% 以上偏差,115200 下会累积误差,建议外部晶振 |
2.2 为什么定时器选 TIM4 而不是 TIM2
TIM2 是 16 位通用定时器,功能强,但它的中断向量和多个功能复用,配置起来寄存器更多。TIM4 是 8 位定时器,只有一个自动重载寄存器 TIM4_ARR 和一个计数器 TIM4_CNTR,预分频也只有 3 位。做位定时只需要"每隔固定时间中断一次",TIM4 完全够用,而且中断服务程序短、响应确定。
关键计算:假设系统时钟 16MHz,预分频设为 1(即不分频),那定时器计数频率就是 16MHz。要得到 115200 波特率下一位的时间 8.68μs,需要计数值 = 16MHz × 8.68μs ≈ 139。但 TIM4 是 8 位,最大 255,139 放得下。如果波特率更低比如 9600,一位 104μs,计数值 1667 就超了,必须加预分频。所以预分频要根据波特率动态算,不能写死。
我一般会写一个宏来算:
// 系统时钟 16MHz,目标波特率 baud // TIM4 计数频率 = 16MHz / prescaler // 每位的计数值 = 计数频率 / baud // 要求该值 <= 255,且尽量接近整数以减小误差 #define SYS_CLK_HZ 16000000UL #define TIM4_PRESCALER 1UL // 不分频 #define BIT_TICKS(b) ((SYS_CLK_HZ / TIM4_PRESCALER) / (b)) // 115200 时 BIT_TICKS = 138.88 -> 取 139,实际波特率 115107,误差 0.08% // 9600 时 BIT_TICKS = 1666,超过 255,必须改预分频提示:TIM4 预分频寄存器 TIM4_PSCR 的值是 2 的幂次,写 0 表示 1 分频,写 1 表示 2 分频,以此类推。别直接写 1 以为是 1 分频。
2.3 GPIO 配置的两种模式差异
发送引脚配成推挽输出,这样高低电平都有驱动能力,上升沿陡峭。接收引脚配成上拉输入,因为 UART 空闲是高电平,靠内部上拉维持,外部设备拉低时能读到 0。这里有个容易翻车的点:STM8S003 的某些引脚上拉电阻是 40kΩ 左右,如果对端驱动能力弱或者线长,上升沿会变缓,采样点就要往后挪一点。我一般接收采样点设在位时间的 0.5 倍处,如果波形质量差,会调到 0.6 倍。
3. 在 IAR 里把发送和接收跑通
3.1 发送一个字节的完整时序
发送的核心是:拉低起始位 → 延时一位 → 依次发 8 位数据 → 拉高停止位。用 TIM4 做位定时,每进一次中断就发下一位。下面是我常用的发送实现:
#include "iostm8s003f3.h" volatile uint8_t tx_buf[32]; volatile uint8_t tx_len = 0; volatile uint8_t tx_idx = 0; volatile uint8_t tx_bit = 0; volatile uint8_t tx_data = 0; // 启动一次发送,把数据放进缓冲区 void soft_uart_send(const uint8_t *data, uint8_t len) { while (tx_len != 0); // 等上一次发完,简单粗暴但可靠 for (uint8_t i = 0; i < len; i++) { tx_buf[i] = data[i]; } tx_len = len; tx_idx = 0; tx_bit = 0; tx_data = tx_buf[0]; // 拉低起始位,同时启动 TIM4 TX_PIN_ODR &= ~TX_PIN_MASK; // 起始位 TIM4_CNTR = 0; TIM4_ARR = BIT_TICKS(115200) - 1; TIM4_CR1 |= 0x01; // 使能定时器 TIM4_IER |= 0x01; // 使能更新中断 } // TIM4 更新中断服务程序 #pragma vector = TIM4_UPD_OVF_IRQ __interrupt void TIM4_UPD_ISR(void) { TIM4_SR &= ~0x01; // 清中断标志 if (tx_bit < 8) { // 发数据位,LSB 先发 if (tx_data & 0x01) { TX_PIN_ODR |= TX_PIN_MASK; } else { TX_PIN_ODR &= ~TX_PIN_MASK; } tx_data >>= 1; tx_bit++; } else if (tx_bit == 8) { // 停止位 TX_PIN_ODR |= TX_PIN_MASK; tx_bit++; } else { // 一个字节发完,看还有没有下一个 tx_idx++; if (tx_idx < tx_len) { tx_data = tx_buf[tx_idx]; tx_bit = 0; TX_PIN_ODR &= ~TX_PIN_MASK; // 下一个起始位 } else { tx_len = 0; TIM4_CR1 &= ~0x01; // 关定时器 TIM4_IER &= ~0x01; } } }逻辑说明:soft_uart_send先把数据拷进缓冲区,然后拉低起始位并启动 TIM4。第一次中断发生在起始位结束的时刻,此时开始发 bit0。之后每次中断发一位,第 9 次中断发停止位,第 10 次中断判断是否还有下一个字节。参数上,BIT_TICKS(115200)算出 139,写入TIM4_ARR时要减 1,因为计数器从 0 数到 ARR 共 ARR+1 个周期。
注意:
while (tx_len != 0)这种忙等在实际项目里要加超时,否则一旦中断没进来会死循环。我一般加一个计数器,超过一定次数就强制清零。
3.2 接收端如何捕获起始位并采样
接收比发送麻烦。思路是:接收引脚配成外部中断,下降沿触发。一旦触发,说明起始位来了,立刻启动 TIM4,第一次中断设在1.5 位时间后,这样正好落在 bit0 的中点。之后每隔 1 位时间采样一次。
volatile uint8_t rx_data = 0; volatile uint8_t rx_bit = 0; volatile uint8_t rx_byte = 0; volatile uint8_t rx_ready = 0; // 接收引脚外部中断,下降沿触发 #pragma vector = EXTI_PORT_IRQ __interrupt void EXTI_ISR(void) { // 清中断标志(具体寄存器名按实际端口改) EXTI_SR1 &= ~RX_PIN_MASK; if (rx_bit == 0 && rx_ready == 0) { // 检测到起始位,启动定时器,第一次延时 1.5 位 TIM4_CNTR = 0; TIM4_ARR = (BIT_TICKS(115200) * 3 / 2) - 1; TIM4_CR1 |= 0x01; TIM4_IER |= 0x01; rx_bit = 1; } } // TIM4 中断里同时处理接收采样 // 注意:发送和接收如果共用一个定时器,需要状态机区分逻辑说明:起始位下降沿触发外部中断后,把 TIM4 的 ARR 设成 1.5 位时间,这样第一次更新中断正好在 bit0 中点。采样到 bit0 后,把 ARR 改回 1 位时间,后续每次中断采样一位。采满 8 位后,读停止位,置rx_ready标志。
参数上,BIT_TICKS(115200) * 3 / 2要保证不溢出 8 位。139 × 3 / 2 = 208,放得下。如果波特率更低,比如 9600,1666 × 3 / 2 就超了,必须用预分频把计数频率降下来。
3.3 IAR 工程里必须检查的三个编译选项
IAR 的优化等级会直接影响中断响应时间。我遇到过-Ohs高优化下模拟串口发送正常、接收丢字节的情况,原因是编译器把中断里的某些变量优化到寄存器,导致采样时序偏移。建议:
第一,中断服务程序加__interrupt关键字,IAR 会自动保存和恢复现场,不要手动写汇编。第二,优化等级先用-On或-Ol,等时序稳定后再尝试提高。第三,把中断里用到的全局变量加volatile,否则编译器可能认为它没变过。
另外,IAR 的链接器配置文件里要确认中断向量表正确映射。STM8S003 的 TIM4 更新中断向量地址是固定的,如果你用了自定义的.icf文件,检查TIM4_UPD_OVF_IRQ是否指向了正确的地址。
4. 避坑与常见问题排查
4.1 发送正常但接收全是乱码
现象:用逻辑分析仪看发送波形完全正确,但接收端读出来的字节跟发的不一样,或者全是 0xFF。
原因:采样点偏了。最常见的是第一次中断设在 1.0 位时间而不是 1.5 位,导致采到 bit0 的边沿而不是中点。另一个原因是外部中断响应延迟,STM8S003 从中断触发到进 ISR 有十几个周期的延迟,如果波特率高,这个延迟不能忽略。
解决:把第一次采样的 ARR 值从BIT_TICKS改成BIT_TICKS * 3 / 2,让采样点落在中点。如果还不行,用示波器抓接收引脚波形,量一下起始位下降沿到第一个数据位中点的实际时间,反推需要的计数值。
4.2 高波特率下丢字节
现象:115200 时连续发多个字节,接收端只能收到第一个,后面的全丢。
原因:发送和接收共用一个 TIM4,中断里既要处理发送又要处理接收,ISR 太长,导致下一个字节的起始位来了还没处理完。或者soft_uart_send里的忙等没有及时退出,阻塞了接收中断。
解决:发送和接收用两个独立的定时器,比如 TIM4 做发送、TIM2 做接收。如果定时器不够,把 ISR 精简到最少操作,只做 GPIO 翻转和计数器更新,其他逻辑放到主循环。另外,发送缓冲区不要用忙等,改成状态机,发完一个字节自动发下一个。
4.3 内部时钟下波特率误差累积
现象:用内部 16MHz RC 振荡器时,短帧正常,长帧(超过 10 个字节)后面几个字节出错。
原因:STM8S003 内部 RC 精度典型值 ±1%,115200 下每位误差 1% 就是 86ns,10 位累积 860ns,接近一位时间的 10%。如果对端也有误差,累积起来就采错。
解决:换外部晶振,或者把波特率降到 9600。如果必须用内部时钟,在协议层加校验和重传。我一般会在初始化时用 TIM1 的输入捕获测一下实际时钟频率,动态修正BIT_TICKS的值。
4.4 中断优先级冲突导致时序抖动
现象:系统里还有其他中断(比如 ADC、定时器)时,模拟串口偶尔出错,单独跑没问题。
原因:STM8S003 的中断优先级由 ITC 寄存器控制,如果其他中断优先级比 TIM4 高,会打断位定时 ISR,导致某一位时间变长。
解决:把 TIM4 和接收外部中断的优先级设成最高。STM8 的中断优先级通过ITC_SPR寄存器配置,具体写法:
// 把 TIM4 更新中断设为最高优先级(软件优先级 3) ITC_SPR5 = (ITC_SPR5 & 0x3F) | 0xC0; // 具体位域按手册调整注意:STM8 的中断优先级分硬件和软件两级,硬件优先级固定,软件优先级可调。模拟串口对时序敏感,建议把相关中断都设成最高软件优先级。
4.5 停止位被误判为下一个起始位
现象:接收完一个字节后,紧接着收到一个 0x00 或 0xFF 的假字节。
原因:停止位是高电平,但如果接收引脚在停止位期间被干扰拉低,外部中断会误触发,当成新的起始位。
解决:在接收完停止位后,暂时关闭外部中断,等 1 到 2 位时间后再打开。或者用状态机判断:只有当前不在接收状态时才响应起始位中断。我一般会在 ISR 里加一个rx_busy标志,接收期间忽略新的下降沿。
5. 用状态机把模拟串口做成可复用的模块
前面几章的代码是"能跑"的版本,但真正放到项目里,你会发现发送和接收互相干扰、中断里逻辑太杂、波特率改一下就要动好几处。我的做法是把它重构成一个基于状态机的模块,发送和接收各自独立,共用一个定时器但分时复用。
核心思路:TIM4 的更新中断频率固定为 1 位时间,ISR 里只做一件事——根据当前状态决定是发送还是接收,然后翻转 GPIO 或采样。状态机用两个变量表示:tx_state和rx_state。发送状态有 IDLE、START、DATA、STOP;接收状态有 IDLE、START、DATA、STOP。
typedef enum { UART_IDLE = 0, UART_START, UART_DATA, UART_STOP } uart_state_t; volatile uart_state_t tx_state = UART_IDLE; volatile uart_state_t rx_state = UART_IDLE; volatile uint8_t tx_shift = 0; volatile uint8_t rx_shift = 0; volatile uint8_t bit_cnt = 0; #pragma vector = TIM4_UPD_OVF_IRQ __interrupt void TIM4_UPD_ISR(void) { TIM4_SR &= ~0x01; // 发送状态机 switch (tx_state) { case UART_START: TX_PIN_ODR &= ~TX_PIN_MASK; tx_state = UART_DATA; bit_cnt = 0; break; case UART_DATA: if (tx_shift & 0x01) TX_PIN_ODR |= TX_PIN_MASK; else TX_PIN_ODR &= ~TX_PIN_MASK; tx_shift >>= 1; if (++bit_cnt >= 8) tx_state = UART_STOP; break; case UART_STOP: TX_PIN_ODR |= TX_PIN_MASK; tx_state = UART_IDLE; break; default: break; } // 接收状态机 switch (rx_state) { case UART_START: // 第一次中断在 1.5 位,采样 bit0 rx_shift = 0; bit_cnt = 0; rx_state = UART_DATA; // 注意:这里不 break,直接落到 DATA 采样 case UART_DATA: rx_shift >>= 1; if (RX_PIN_IDR & RX_PIN_MASK) rx_shift |= 0x80; if (++bit_cnt >= 8) rx_state = UART_STOP; break; case UART_STOP: rx_ready = 1; rx_state = UART_IDLE; break; default: break; } }逻辑说明:发送状态机在 START 状态拉低起始位,DATA 状态逐位输出,STOP 状态拉高。接收状态机在 START 状态做第一次采样(因为外部中断已经把 ARR 设成 1.5 位),然后落到 DATA 状态继续采样。这样 ISR 里没有循环、没有延时,执行时间固定,不会因为分支不同导致时序抖动。
参数上,bit_cnt是 8 位计数器,tx_shift和rx_shift是移位寄存器。波特率切换只需要改TIM4_ARR的值,状态机逻辑不变。如果要支持校验位,在 DATA 和 STOP 之间加一个 PARITY 状态即可。
这个模块我用了好几个项目,从 9600 到 115200 都跑过。最深的教训是:别在中断里做任何可能阻塞的事,包括调用库函数、访问 Flash、甚至读一个可能被其他中断修改的变量。有一次我在 ISR 里加了一句printf调试,结果整个时序全乱,查了半天才发现是串口打印本身又触发了中断。后来我养成习惯,ISR 里只碰寄存器和 volatile 变量,调试信息全部攒到主循环再输出。希望帮到你。
本文还有配套的精品资源,点击获取