[APM32F4] GPIO模拟串口的另一种实现方式?非阻塞+时间戳解析法详解

[复制链接]
DKENNY 发表于 2025-8-28 13:09 | 显示全部楼层 |阅读模式
本帖最后由 DKENNY 于 2025-8-29 11:15 编辑

#申请原创# #技术资源#  @21小跑堂

1. 为什么需要模拟串口?
      在搞嵌入式开发的时候,串口(UART)经常不够用。APM32F407这块芯片挺强的,但它的硬件串口就那么几个。比如,你想接个GPS模块、蓝牙模块、调试串口,再加个外部控制器,串口一下就用完了。更烦的是,项目干到一半,客户突然说:“再加个RS232接口吧!”这时候要是改硬件,成本高得吓人,时间也来不及。咋办?模拟串口来救场!
9489868afe3fa0c76d.png

      模拟串口其实就是拿普通的GPIO引脚(就是芯片上那些输入输出的脚),用软件模仿UART协议的信号变化(1个起始位、8个数据位、1个停止位,低位先传),让它跟真的串口一样能收能发。好处是超灵活,哪个GPIO空着就能拿来用!不过,它也有点小毛病:
      - 速度慢点:一般用9600bps最稳,我试过19200bps还行,再快就可能掉链子,因为软件控制时间没硬件那么准。
      - 吃资源:得用GPIO、定时器和中断,系统得多干点活。
      - 有点复杂:收数据最麻烦,得精确抓住信号的每次变化;发数据简单点,但也得小心别把系统搞宕机了。
      这篇文章就分享一下,咋用“非阻塞+时间戳解析法”搞定模拟串口。啥叫非阻塞?就是不用死等(比如不用delay那种傻等),CPU还能干别的。时间戳解析法是啥?就是每次信号电平一变(高变低或低变高),就记下时间点,等收到一串信号后,统一算出数据是啥。

本文目录
- 1.为什么需要模拟串口?
- 2.模拟串口实现的两种思路
- 3.时间戳解析法详细步骤
- 4.硬件与环境
- 5.实现细节与注意事项
- 6.测试
- 7.与逐位采样法的对比
- 8.总结与优化方向
- 9.常见问题解答

2. 模拟串口实现的两种思路
      模拟串口最难的地方是收数据,发数据就简单多了,因为发数据是咱们的MCU自己控制节奏,想咋发就咋发,但收数据得老老实实等着别人送信号过来。下面我把两种常见的收数据办法讲清楚,重点聊时间戳解析法,另一个叫逐位采样法的就简单说说(网上有大佬写得很详细了,比如这篇: APM32F4XX GPIO模拟USART应用 (https://bbs.21ic.com/forum.php?mod=viewthread&tid=3325026),感兴趣可以去看看)。

2.1 逐位采样法(简单介绍)
      逐位采样法是最传统的模拟串口收数据方法,逻辑很直观,适合新手理解。它的核心思路是:
      1. 检测起始位:用外部中断检测Rx引脚的下降沿(起始位就是低电平),然后启动一个定时器。
      2. 逐位采样:定时器每隔一个位时间(比如9600bps就是104μs)触发一次中断,去读Rx引脚电平(0或1)。
      3. 组装字节:连续采样8次,拼成8位数据,就是一个字节。
      4. 验证停止位:第9次采样检查停止位(高电平),确认收完。
      举个例子:假设你要收0x37(二进制00110111),信号顺序是:起始位(0)、00110111、停止位(1),假设以LSB格式发送。定时器每104μs采样一次,依次读到0(起始位)、1、1、1、0、1、1、0、0、1(停止位),组装后逆序就是00110111,也就是0x37。
      优点
      - 逻辑简单,像搭积木一样,容易实现。
      - 适合低速通信,代码调试也直观。
      缺点
      - 每收一个字节要10次中断(起始+8数据+停止),中断频率高,CPU压力大。
      - 对定时器精度要求高,时序稍微有点偏差就容易采错。
      因为逐位采样法中断太多,本文就不细讲了,接下来重点讲时间戳解析法。

2.2 时间戳解析法(重点讲解)
      时间戳解析法比上面那个“聪明”多了。它的思路是:每次Rx引脚电平一变(不管高变低还是低变高),就用外部中断记下当前时间(用定时器计数),最后等一个字节收完(大概9.5个位时间),统一分析这些时间点,把数据还原出来。

1. 什么是时间戳解析法?
      UART信号其实就是一连串的高低电平,比如9600bps下,每个位时间是104μs(1/9600秒)。一个字节包括10位(1起始位+8数据位+1停止位),总时长大约1040μs。时间戳解析法不是每个位都去采样,而是:
      - 捕获边沿:用外部中断检测Rx引脚的每次电平翻转(高变低或低变高都算)。
      - 记录时间:用一个高精度定时器(比如TMR2)把每次翻转的时间点都记下来,形成一个“时间戳”数组。
      - 整体解析:等一个字节收完(大约9.5个位时间),一次性分析这些时间戳,推算出每个位到底是0还是1。
      其实你可以把它想成“记账本”,每次电平一变就记一笔,最后统一算账。
      这种方法的核心就是:UART信号的电平翻转点里藏着数据。比如某个数据在某些位会翻转,我们只要把这些翻转的时间点记下来,最后就能推算出每个位的值。

2. 为什么要用时间戳解析法?
      和逐位采样法比,时间戳解析法有这些优点:
      - 中断少:只在电平翻转和收完一个字节时中断,比如有的数据只需要6次边沿中断+1次超时中断,总共7次,而逐位采样法要10次。
      - 效率高:数据解析集中在一次中断里完成,CPU不用频繁切换上下文,省事省力。
      - 容错强:用时间戳分析,即使信号有点抖动,也能正确还原,容错性更好。
      当然,缺点是解析逻辑稍微复杂点,要处理时间戳数组,但对复杂系统来说,效率提升很明显。
2166768afe4067aff9.png

3. 时间戳解析法详细步骤(重点)
      下面咱们就拿9600bps、0x37(二进制00110111)这个例子,手把手拆开讲讲时间戳解析法到底怎么搞。
      这里我们还是假设数据是以LSB格式发送的哈。0x37对应的帧如下。
4866968afe4111bf05.png

3.1 硬件配置
      - Rx引脚:比如选PA0,设置成外部中断,下降沿触发(因为UART起始位就是低电平)。
      - 定时器
         - TMR2:搞成1MHz计数(1μs分辨率),专门用来记每次边沿的时间戳。
         - TMR3:定时9.5个位时间(104μs × 9.5 ≈ 988μs),用来判断一个字节啥时候收完。
      - 系统时钟:84MHz,分频后变成1MHz(84分频),这样时间戳才够精细。
9427068afe4191695e.png

3.2 捕获边沿时间戳
      1. 起始位触发:PA0一检测到下降沿(也就是起始位来了),外部中断立马触发:
         - 先把TMR2计数器清零(TMR2->CNT = 0),从头开始计时。
         - 启动TMR3,定时988μs,等着判断啥时候收完。
         - 第一个时间戳(0μs)直接丢到数组`timerecode[0]`里。
      2. 后续边沿:每次Rx引脚电平一变(不管高变低还是低变高),外部中断都触发,把TMR2当前计数值记到`timerecode`数组。
      3. 时间戳数组:拿0x37举例,信号时序是:
         - 起始位:0μs,下降沿。
         - 第1位(1):104μs,上升沿(0→1)。
         - 第4位(0):416μs,下降沿(1→0)。
         - 第5位(1):520μs,上升沿(0→1)。
         - 第7位(0):728μs,下降沿(1→0)。
         - 停止位(1):936μs,上升沿(0→1)。
         最后时间戳数组就是`[0,104, 416, 520, 728, 936]`。

3.3 接收超时与解析

4117568afe420ce41d.png
      - 当TMR3中断触发(988μs后),说明一个字节收完了。
      - 这时候就要开始解析`timerecode`数组里的内容(拿0x37举例):
         1. 归一化时间戳:我们把所有时间戳都除以104μs,得到`[0, 1, 4, 5, 7, 9]`,这就表示翻转发生在第0、1、4、5、7、9位。
         2. 去掉起始位和停止位:把0(起始位)和9(停止位)去掉,剩下`[1, 4, 5, 7]`,这些就是数据位翻转点。
         3. 解析数据
            - 先把状态a设为0(因为起始位是低电平,信号刚开始是0)。
            - 然后从第1位到第8位挨个儿检查:
                  - 如果当前位在[1, 4, 5, 7]这个翻转点列表里,就把状态a反过来(0变1,1变0)。
                  - 每次检查完,右移byte(数据字节),如果a是1,就把byte的最高位设为1。
            - 举例说明(以0x37为例,二进制00110111):
                  - 第1位:在[1]里,a从0变成1,byte变成10000000(第1位是1)。
                  - 第2位:不在翻转点,a还是1,byte右移变成11000000。
                  - 第3位:不在翻转点,a还是1,byte右移变成11100000。
                  - 第4位:在[4]里,a从1变成0,byte右移变成01110000。
                  - 第5位:在[5]里,a从0变成1,byte右移变成10111000。
                  - 第6位:不在翻转点,a还是1,byte右移变成11011100。
                  - 第7位:在[7]里,a从1变成0,byte右移变成01101110。
                  - 第8位:不在翻转点,a还是0,byte右移变成00110111。
            - 最后,byte是00110111,就是0x37,这就是时间戳解析的核心思想。

附:时间戳解析核心代码
  1. // 该函数实现了基于时间戳的UART模拟串口接收数据字节解析功能。
  2. // 输入参数nums为捕获到的时间戳数量,返回解析出的数据字节。

  3. int process_byte(int nums)
  4. {
  5.     int i;
  6.     uint8_t a = 0, byte = 0;
  7.     uint8_t new_array[8] = {0};

  8.     // 将时间戳归一化到位时间,并四舍五入
  9.     for (i = 0; i <= nums; i++)
  10.     {
  11.         timerecode[i] += ONE_BIT_TMRE / 2; // 四舍五入
  12.         timerecode[i] /= ONE_BIT_TMRE;     // 转换为位序号
  13.     }

  14.     // 如果最后一个时间戳大于等于9,说明包含停止位,去掉
  15.     if (timerecode[nums] >= 9)
  16.     {
  17.         nums--;
  18.     }

  19.     // 如果没有有效数据,直接返回0
  20.     if (nums <= 0)
  21.     {
  22.         return 0;
  23.     }

  24.     int find_max = nums;

  25.     // 计算每个位的翻转点,填充到new_array
  26.     for (i = 0; i < find_max; i++)
  27.     {
  28.         find_array[i] = timerecode[i + 1]; // 记录翻转发生的位序号

  29.         if (find_array[i] != 0)
  30.         {
  31.             new_array[find_array[i] - 1] = find_array[i]; // 标记翻转点
  32.         }
  33.     }

  34.     // 遍历8位数据,按翻转点解析数据位
  35.     for (i = find_array[0] - 1; i < 8; i++)
  36.     {
  37.         byte >>= 1; // 右移一位,为当前位腾出空间

  38.         if (i == new_array[i] - 1)
  39.         {
  40.             a = !a; // 遇到翻转点,电平取反
  41.         }

  42.         if (a)
  43.         {
  44.             byte |= 0x80; // 当前位为1,设置最高位
  45.         }
  46.     }

  47.     return byte; // 返回解析出的数据字节
  48. }

3.4 存储数据
      这里我设置了一个结构体,如下。
  1. typedefstruct
  2. {
  3.    uint8_t rxov;          // 溢出标志
  4.    uint8_t rxlen;         // 接收长度
  5.    uint8_t rxbuff_idx;    // 接收缓冲区索引
  6.    uint8_t rx_decode_flag;// 解码标志
  7.    uint8_t RxBuff[VIR_RXBUFF_SIZ];
  8.    uint8_t RXREG;
  9.    uint8_t send_flag;     // 发送标志
  10.    uint8_t send_max;      // 发送缓冲区最大长度
  11.    uint8_t send_cnt;      // 当前发送计数
  12.    uint8_t send_mode;     // 0: 阻塞发送,1: 中断发送
  13.    uint8_t sendbuff[VIR_TXBUFF_SIZ];
  14.    uint8_t TXREG;
  15. } VIRTUAL_UART_t;
      - 解析出来的字节直接丢到接收缓冲区`RxBuff`里,同时把索引`rxbuff_idx`往后挪一下。
      - 设置个解码标志`rx_decode_flag`,告诉主程序“有新数据啦”。
      - 如果缓冲区满了,就把溢出标志`rxov`拉起来,提醒你要处理数据了。

3.5 解析逻辑的通用性
      时间戳解析法不光能搞定0x37,啥字节都能玩。举几个例子你就明白了:
      - 0xC811001000
         - 时间戳是`[0, 416, 520, 728]`,归一化后是`[0, 4, 5, 7]`。
         - 解析过程:前3位都没翻转(0),第4位翻转(1),第5位又翻回去(0),第6位没翻转(0),第7位再翻转(1),第8位没翻转(0),最后逆序就是`11001000`,也就是0xC8。
      - 0x0100000001
         - 时间戳是`[0, 104, 208]`,归一化后是`[0, 1, 2]`。
         - 解析过程:第1位翻转(1),第2位翻转回去(0),后面都没翻转(0),逆序就是`00000001`,也就是0x01。
      - 0xA510100101
         - 时间戳是`[0, 104, 208, 312, 416,624, 728, 832]`,归一化后是`[0,1, 2, 3, 4, 6, 7, 8]`。
         - 解析过程:前4位翻转得很勤快,第5位没翻转,第6-8位又翻转,逆序就是`10100101`,也就是0xA5。
      总结一下:翻转点就是电平变化,解析时从第一个翻转点开始,逐位看状态,最后逆序输出就能还原出正确的字节。

3.6 发送部分的实现思路
      发数据这块其实比接收简单多了,就是用定时器控制Tx引脚(比如PA1)电平怎么变:
      - 定时器配置:用TMR7,周期104μs(9600bps),每隔104μs就来一次中断。
      - 发送流程
         1. 要发的字节先丢到`TXREG`,然后启动TMR7。
         2. TMR7每104μs触发一次中断,依次输出:
              - 先发起始位(0)。
              -然后8个数据位(LSB优先,低位在前)。
              - 最后发停止位(1)。
         3. 如果要发很多字节,就把数据都丢到`sendbuff`缓冲区,靠回调函数`send_remain_byte`一个个发。
      - 非阻塞设计:用`send_flag`和`send_cnt`这俩变量管理多字节发送,发完了就关掉TMR7。

附:发送流程核心代码
  1. // 该函数实现了非阻塞方式发送指定长度的数据字节到虚拟串口缓冲区。
  2. // 如果当前正在发送数据或数据长度超出缓冲区限制,则返回错误码。
  3. // 否则,将数据复制到发送缓冲区,初始化发送状态,并启动发送过程。

  4. int vu_send_some_byte_noblock(uint8_t *data, int len)
  5. {
  6.     // 如果虚拟串口正在发送数据,直接返回错误码-1
  7.     if (VirtualUart.send_flag)
  8.     {
  9.         return -1;
  10.     }

  11.     // 如果数据长度超过发送缓冲区大小,返回错误码-2
  12.     if (len > VIR_TXBUFF_SIZ)
  13.     {
  14.         return -2;
  15.     }

  16.     // 将待发送数据复制到虚拟串口的发送缓冲区
  17.     memcpy(VirtualUart.sendbuff, data, len);
  18.     // 设置发送模式为1,表示正在发送
  19.     VirtualUart.send_mode = 1;
  20.     // 将第一个字节写入发送寄存器,准备发送
  21.     VirtualUart.TXREG = VirtualUart.sendbuff[0];
  22.     // 启动定时器TMR7,开始发送过程
  23.     TMR7->CTRL1_B.CNTEN = ENABLE;
  24.     // 拉低GPIOA的1号引脚,可能用于指示发送状态
  25.     GPIO_ResetBit(GPIOA, GPIO_PIN_1);
  26.     // 设置发送标志,表示正在发送
  27.     VirtualUart.send_flag = 1;
  28.     // 记录本次要发送的字节总数
  29.     VirtualUart.send_max = len;
  30.     // 记录已发送的字节数,初始为1(第一个字节已写入寄存器)
  31.     VirtualUart.send_cnt = 1;
  32.     // 返回0,表示发送启动成功
  33.     return 0;

3.7 非阻塞设计的优势
      时间戳解析法配合非阻塞发送,最大好处就是让CPU很轻松:
      - 接收:只在边沿和超时才中断,像0x37这种只要7次中断(6次边沿+1次超时)。
      - 发送:多字节数据靠缓冲区和中断慢慢发,CPU不用死等。
      - 系统友好:主程序还能干别的活,特别适合多任务环境。

4. 硬件与环境
      - MCU:APM32F407,168MHz系统时钟。
      - 引脚
            - Rx:PA0(外部中断,下降沿触发)。
            - Tx:PA1(GPIO输出)。
            - 调试串口:PA9(Tx)、PA10(Rx),115200bps,用于输出调试信息。
      - 定时器
            - TMR2:1MHz(168分频),记录时间戳。
            - TMR3:988μs,接收超时。
            - TMR7:104μs,发送控制。
      - 波特率:9600bps,位时间104μs。
      - 开发环境:Keil uVision5,APM32F4xx标准库。

5. 实现细节与注意事项
      这里先上一份实现的总体流程图哈。
3387368afe434142c8.png

5.1 初始化
      - GPIO:PA0得设成输入(上拉),用来收数据,PA1是推挽输出,默认拉高,负责发数据。
      - 定时器
         - TMR2:主频168MHz分下来,168分频就是1MHz,周期0xFFFF,专门用来计时间戳。
         - TMR3:周期988,主要用来判断一帧数据收完没。
         - TMR7:周期104,专门控制发数据的节奏。
      - 中断:PA0一有下降沿就触发EINT0,TMR3和TMR7都开了更新中断,分别管接收和发送。

附:GPIO和定时器初始化代码
  1. // 虚拟串口初始化函数,完成GPIO、定时器和外部中断的配置
  2. void VirtualUart_Init(void)
  3. {
  4.     // 定义GPIO配置结构体
  5.     GPIO_Config_T gpioConfig;
  6.     // 定义定时器基础配置结构体
  7.     TMR_BaseConfig_T tmrConfig;
  8.     // 定义外部中断配置结构体
  9.     EINT_Config_T eintConfig;

  10.     // 使能GPIOA时钟
  11.     RCM_EnableAHB1PeriphClock(RCM_AHB1_PERIPH_GPIOA);
  12.     // 使能TMR2、TMR3、TMR7定时器时钟
  13.     RCM_EnableAPB1PeriphClock(RCM_APB1_PERIPH_TMR2 | RCM_APB1_PERIPH_TMR3 | RCM_APB1_PERIPH_TMR7);
  14.     // 使能系统配置控制器时钟(用于外部中断)
  15.     RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_SYSCFG);

  16.     // 配置PA0为输入模式,上拉,用作Rx
  17.     gpioConfig.mode = GPIO_MODE_IN;
  18.     gpioConfig.pupd = GPIO_PUPD_UP;
  19.     gpioConfig.pin = GPIO_PIN_0;
  20.     GPIO_Config(GPIOA, &gpioConfig);

  21.     // 配置PA1为推挽输出模式,50MHz速度,用作Tx
  22.     gpioConfig.mode = GPIO_MODE_OUT;
  23.     gpioConfig.otype = GPIO_OTYPE_PP;
  24.     gpioConfig.speed = GPIO_SPEED_50MHz;
  25.     gpioConfig.pin = GPIO_PIN_1;
  26.     GPIO_Config(GPIOA, &gpioConfig);
  27.     // 默认Tx引脚输出高电平(空闲态)
  28.     GPIO_SetBit(GPIOA, GPIO_PIN_1);

  29.     // 配置TMR2为1MHz计数(用于时间戳记录)
  30.     tmrConfig.clockDivision = TMR_CLOCK_DIV_1;
  31.     tmrConfig.countMode = TMR_COUNTER_MODE_UP;
  32.     tmrConfig.period = 0xFFFF;
  33.     tmrConfig.division = 84 - 1; // 84MHz / 84 = 1MHz
  34.     tmrConfig.repetitionCounter = 1;
  35.     TMR_ConfigTimeBase(TMR2, &tmrConfig);
  36.     // 启动TMR2
  37.     TMR_Enable(TMR2);

  38.     // 配置TMR3为988μs定时(接收超时判断)
  39.     tmrConfig.period = 988 - 1;
  40.     tmrConfig.division = 84 - 1;
  41.     TMR_ConfigTimeBase(TMR3, &tmrConfig);
  42.     // 使能TMR3更新中断
  43.     TMR_EnableInterrupt(TMR3, TMR_INT_UPDATE);
  44.     // 使能TMR3中断向量
  45.     NVIC_EnableIRQRequest(TMR3_IRQn, 0, 1);

  46.     // 配置TMR7为104μs定时(发送位时序控制)
  47.     tmrConfig.period = 104 - 1;
  48.     tmrConfig.division = 84 - 1;
  49.     TMR_ConfigTimeBase(TMR7, &tmrConfig);
  50.     // 使能TMR7更新中断
  51.     TMR_EnableInterrupt(TMR7, TMR_INT_UPDATE);
  52.     // 使能TMR7中断向量
  53.     NVIC_EnableIRQRequest(TMR7_IRQn, 0, 2);

  54.     // 配置PA0为外部中断输入
  55.     SYSCFG_ConfigEINTLine(SYSCFG_PORT_GPIOA, SYSCFG_PIN_0);
  56.     // 配置EINT0为双边沿触发(上升沿和下降沿),用于捕获Rx电平变化
  57.     eintConfig.line = EINT_LINE_0;
  58.     eintConfig.mode = EINT_MODE_INTERRUPT;
  59.     eintConfig.trigger = EINT_TRIGGER_RISING_FALLING;
  60.     eintConfig.lineCmd = ENABLE;
  61.     EINT_Config(&eintConfig);
  62.     // 使能EINT0中断向量
  63.     NVIC_EnableIRQRequest(EINT0_IRQn, 0, 0);

  64.     // 清零虚拟串口结构体,初始化状态
  65.     memset(&VirtualUart, 0, sizeof(VirtualUart));
  66. }

5.2 接收流程
      - 外部中断:PA0一有下降沿就触发,马上把TMR2当前计数值记下来,同时启动TMR3。
      - 时间戳记录:每次有边沿变化,就把时间戳丢进`timerecode`数组,`edge_count`也跟着加1。
      - 解析:TMR3一到点就中断,自动调用`process_byte`解析时间戳,结果存进`RxBuff`。

5449568afe43e08686.png

附:外部中断与时间戳记录代码
  1. /**
  2. * @brief 外部中断0服务函数,用于捕获Rx引脚的电平变化并记录时间戳
  3. */
  4. void EINT0_IRQHandler(void)
  5. {
  6.     // 判断EINT_LINE_0中断标志是否被置位
  7.     if (EINT_ReadIntFlag(EINT_LINE_0) == SET)
  8.     {
  9.         // 清除EINT_LINE_0中断标志
  10.         EINT_ClearIntFlag(EINT_LINE_0);

  11.         // 如果是第一个边沿(起始位),需要初始化定时器
  12.         if (edge_count == 0)
  13.         {
  14.             TMR2->CNT = 0;                 // 清零TMR2计数器,开始计时
  15.             TMR3->CTRL1_B.CNTEN = ENABLE;  // 启动TMR3,用于接收超时判断
  16.         }

  17.         // 记录当前TMR2计数值到时间戳数组
  18.         timerecode[edge_count++] = TMR2->CNT;
  19.     }
  20. }

  21. /**
  22. * @brief TMR3定时器中断服务函数,用于接收超时后解析数据
  23. */
  24. void TMR3_IRQHandler(void)
  25. {
  26.     // 判断TMR3更新中断标志是否被置位
  27.     if (TMR_ReadIntFlag(TMR3, TMR_INT_UPDATE) == SET)
  28.     {
  29.         // 清除TMR3更新中断标志
  30.         TMR_ClearIntFlag(TMR3, TMR_INT_UPDATE);
  31.         TMR3->CTRL1_B.CNTEN = DISABLE; // 停止TMR3,防止重复进入中断

  32.         // 如果有捕获到边沿,进行数据解析
  33.         if (edge_count > 0)
  34.         {
  35.             // 调用process_byte解析时间戳,得到接收字节
  36.             VirtualUart.RXREG = process_byte(edge_count - 1);
  37.             // 存入接收缓冲区
  38.             VirtualUart.RxBuff[VirtualUart.rxbuff_idx++] = VirtualUart.RXREG;

  39.             // 判断缓冲区是否溢出
  40.             if (VirtualUart.rxbuff_idx >= VIR_RXBUFF_SIZ)
  41.             {
  42.                 VirtualUart.rxov = 1; // 设置溢出标志
  43.             }

  44.             VirtualUart.rx_decode_flag = 1; // 设置解码完成标志
  45.         }

  46.         edge_count = 0; // 清零边沿计数,准备下一帧接收
  47.     }
  48. }

5.3 发送流程
      - 单字节发送:要发一个字节,先把数据塞进`TXREG`,然后启动TMR7,定时器会帮你一位一位地发,10位(起始+8数据+停止)全包了。
      - 多字节发送:要发一串数据,就把它们都丢进`sendbuff`,TMR7中断会通过回调函数一个个发出去。
      - 状态管理:`send_flag`和`send_mode`这俩变量专门用来盯着当前发没发完,单发还是多发。

5048868afe44e170e4.png

附:发送中断服务函数代码
  1. /**
  2. * @brief TMR7定时器中断服务函数,用于按位时序发送虚拟串口数据
  3. */
  4. void TMR7_IRQHandler(void)
  5. {
  6.     // 判断TMR7更新中断标志是否被置位
  7.     if (TMR_ReadIntFlag(TMR7, TMR_INT_UPDATE) == SET)
  8.     {
  9.         // 清除TMR7更新中断标志
  10.         TMR_ClearIntFlag(TMR7, TMR_INT_UPDATE);

  11.         // 判断当前是否为多字节发送模式
  12.         if (VirtualUart.send_mode)
  13.         {
  14.             // 调用tim_send_byte发送一位,传入回调函数send_remain_byte
  15.             if (tim_send_byte(send_remain_byte) == 0)
  16.             {
  17.                 // 如果发送标志已清零,说明全部发送完成
  18.                 if (VirtualUart.send_flag == 0)
  19.                 {
  20.                     TMR3->CTRL1_B.CNTEN = DISABLE; // 关闭TMR3
  21.                     TMR7->CNT = 0;                 // 清零TMR7计数器
  22.                 }
  23.             }
  24.         }
  25.         else
  26.         {
  27.             // 单字节发送,回调为NULL
  28.             if (tim_send_byte(NULL) == 0)
  29.             {
  30.                 TMR3->CTRL1_B.CNTEN = DISABLE; // 关闭TMR3
  31.                 TMR7->CNT = 0;                 // 清零TMR7计数器
  32.             }
  33.         }
  34.     }
  35. }

  36. /**
  37. * @brief 发送单字节数据,启动发送流程
  38. * @param dat 待发送的数据字节
  39. * @return 0表示发送启动成功,1表示当前正在发送,无法启动
  40. */
  41. uint8_t send_a_byte(uint8_t dat)
  42. {
  43.     // 如果当前正在发送,直接返回1
  44.     if (VirtualUart.send_flag)
  45.     {
  46.         return 1;
  47.     }

  48.     VirtualUart.TXREG = dat;                // 将数据写入发送寄存器
  49.     TMR7->CTRL1_B.CNTEN = ENABLE;           // 启动TMR7定时器
  50.     GPIO_ResetBit(GPIOA, GPIO_PIN_1);       // 拉低Tx引脚,输出起始位
  51.     VirtualUart.send_flag = 1;              // 设置发送标志
  52.     return 0;                               // 返回0,表示启动成功
  53. }

  54. /**
  55. * @brief 发送剩余字节(多字节发送时的回调函数)
  56. */
  57. void send_remain_byte(void)
  58. {
  59.     // 判断是否已发送完所有字节
  60.     if (VirtualUart.send_cnt >= VirtualUart.send_max)
  61.     {
  62.         VirtualUart.send_flag = 0;          // 发送完成,清零发送标志
  63.     }
  64.     else
  65.     {
  66.         VirtualUart.TXREG = VirtualUart.sendbuff[VirtualUart.send_cnt++]; // 取下一个字节
  67.         GPIO_ResetBit(GPIOA, GPIO_PIN_1);   // 拉低Tx引脚,输出起始位
  68.     }
  69. }

  70. /**
  71. * @brief 定时器驱动的按位发送函数,每次调用发送一位
  72. * @param Callback 发送完成后的回调函数(多字节发送用)
  73. * @return 1表示还未发送完,0表示本字节发送完成
  74. */
  75. uint8_t tim_send_byte(void (*Callback)(void))
  76. {
  77.     static int sendidx = 0; // 静态变量,记录当前发送到第几位
  78.     sendidx++;              // 发送位计数加1

  79.     if (sendidx <= 8)
  80.     {
  81.         // 发送数据位,取TXREG最低位输出到Tx引脚
  82.         GPIO_WriteBitValue(GPIOA, GPIO_PIN_1, (VirtualUart.TXREG & 0x01) ? BIT_SET : BIT_RESET);
  83.         VirtualUart.TXREG >>= 1; // 右移一位,准备下次发送
  84.     }
  85.     else if (sendidx == 9)
  86.     {
  87.         GPIO_SetBit(GPIOA, GPIO_PIN_1); // 发送停止位,Tx引脚拉高
  88.     }
  89.     else if (sendidx == 10)
  90.     {
  91.         sendidx = 0; // 复位位计数,准备下一个字节

  92.         if (Callback != NULL)
  93.         {
  94.             Callback(); // 多字节发送,调用回调函数发送下一个字节
  95.         }
  96.         else
  97.         {
  98.             VirtualUart.send_flag = 0; // 单字节发送,清零发送标志
  99.         }

  100.         return 0; // 返回0,表示本字节发送完成
  101.     }

  102.     return 1; // 返回1,表示还未发送完
  103. }

5.4 注意事项
      - 时钟精度:主频84MHz分出来1MHz,时间戳分辨率能做到1μs,精度杠杠的。
      - 缓冲区管理:`RxBuff`要是满了,`rxov`标志就会被拉起来,记得及时处理,不然数据就丢了。
      - 中断优先级:EINT0的优先级一定要比TMR3/TMR7高,这样时间戳才不会丢。
      - 波特率调整:想换成19200bps?把`ONE_BIT_TMRE`改成52μs,再调一下TMR3/TMR7的周期就行。

319168afe45a2a5b1.png


6. 测试
      实验现象也是可观,用一根串口线PC连接PA0(RX),PA1(TX),不知道是不是因为抖动还是其他什么原因,我这里把TX、RX都接上会出现外部中断一直触发的情况,也就是串口一直在接收数据,为了避免出现这种情况,这里是分开测试的。

1. 测试TX
      串口线只接了一根线PA1,PA0是悬空的。

4979468afe46058156.png

      每隔一段时间,开发板发送固定字符串,串口调试助手正常看到数据打印,成功。

2. 测试RX
      同样,使用一根串口线连接PC和开发板,开发板上只接了PA0,PA1是悬空的。用串口调试助手随便发送一串数据,变量添加到watch窗口,程序全速运行,注意,如果是debug,这里一定要全速运行,因为数据是一个一个字节解析的,不是一次解析一整串,随后点击stop,就可以看到rxbuff中的数据,跟发送的也是一致的。我这里的printf只是打印第一个字符,不是接收到的一整串字符,有想法的兄弟可以后续优化一下。
3235568afe46ce5ba5.png

7. 与逐位采样法的对比
      逐位采样法是每发一位就中断一次,比如0x37得中断10次;时间戳解析法只要6次边沿+1次超时中断,效率高多了。虽然解析逻辑看着复杂点,但用数组一处理,代码其实也挺清楚好维护。实测下来,时间戳解析法能稳稳地传48字节,低速通信场景很合适。
特性
逐位采样法
时间戳解析法
外部中断作用
只检测起始位下降沿,拉起定时器
所有边沿(上升沿+下降沿)都抓,时间戳全记下来
定时器中断频率
每104μs就中断一次,一共10次(1起始+8数据+1停止)
TMR3只中断一次(9.5位超时),TMR2一直计数但不打断
数据获取方式
定时器中断里读Rx引脚电平,拼成字节
边沿时间戳全记数组里,最后一起解析出字节
中断次数(以0x37为例)
1次外部中断+10次定时器中断=11次
6次外部中断(边沿)+1次超时中断=7次
解析逻辑
就是移位拼字节,没啥算法
要归一化时间戳、找翻转点,逻辑复杂点
资源占用
中断多,CPU累
中断少,效率高
实现难度
简单明了,适合新手
逻辑复杂点,但对系统友好
适用场景
简单用、低速通信
复杂系统、多任务环境

8. 总结与优化方向
      时间戳解析法就是靠着把每次边沿的时间戳都记下来,最后一口气解析数据,这样能大大减少中断次数,系统效率也就上去了。它的精髓就是时间戳得记得准,解析逻辑得跟得上,特别适合像APM32F407这种高性能MCU。跟逐位采样法比,时间戳法更适合复杂点的系统,但写代码的时候你得对中断和定时器有点门道。
      还能怎么优化?
      - 支持更高波特率:可以再优化定时器分频,搞到19200bps甚至更高,收发更快更稳。
      - 错误检测:加点奇偶校验、超时检测啥的,出错能第一时间发现。
      - 多路模拟串口:多用几个GPIO和定时器,能同时搞多个虚拟串口。
      - 动态波特率:能根据外部设备自动调`ONE_BIT_TMRE`,用起来更灵活。

9. 常见问题解答
Q1:怎么判断起始位?
      用时间戳解析法,起始位就是整个接收流程的第一步。因为UART协议规定,每个字节都得先来个低电平的起始位(平时Rx线是高的,来了数据先拉低)。具体咋搞的?
      思路解析
      时间戳解析法就是靠外部中断盯着Rx引脚的电平变化,主要看下降沿(高变低),因为这就是起始位。流程如下:
      1. Rx引脚怎么配
         - 比如PA0,设成输入模式,开外部中断,触发条件就选下降沿
         - 只要Rx线从高变低(空闲时是高,来了起始位就低),就会触发中断。
      2. 第一次下降沿就是起始位
         - 外部中断一触发,程序就判断是不是第一次(`edge_count== 0`),如果是,先把定时器计数器清零,记下时间戳。
         - 同时启动两个定时器:
           - TMR2:1MHz计数,专门记后面每个边沿的时间戳。
           - TMR3:定时9.5位(988μs,9600bps),用来判断一帧收完没。
      3. 怎么区分起始位和后面那些边沿
         - 第一次下降沿就是起始位,程序会把TMR2清零,时间戳0存进数组(`timerecode[0]`)。
         - 后面不管高变低还是低变高,只要有边沿就继续触发中断,时间戳都记进数组,但这些其实是数据位的翻转。
         - 起始位的唯一性就是靠第一次下降沿保证的,后面时间戳都用来解析数据。
      4. 为啥能保证是起始位?
         - UART协议规定,空闲时Rx线高,起始位就是第一个低电平(下降沿)。
         - 外部中断只盯下降沿,能排除杂波和无效信号。
         - 万一Rx线空闲时有短暂低电平(噪声),TMR3的988μs超时机制会自动重置接收状态,不怕误判。

      代码里怎么写的?
      在`virtual_uart.c`里,外部中断处理函数`EINT0_IRQHandler`就是这么干的:

  1. void EINT0_IRQHandler(void)
  2. {
  3.      if (EINT_ReadIntFlag(EINT_LINE_0) == SET)
  4.      {
  5.           EINT_ClearIntFlag(EINT_LINE_0);

  6.           if (edge_count == 0)
  7.           {
  8.                 TMR2->CNT = 0;
  9.                 TMR3->CTRL1_B.CNTEN = ENABLE;
  10.           }

  11.           timerecode[edge_count++] = TMR2->CNT;
  12.      }
  13. }
      - 起始位逻辑
            - PA0(Rx)一有下降沿就进`EINT0_IRQHandler`。
            - 如果`edge_count == 0`,说明是第一次边沿(起始位),就把TMR2清零,TMR3启动。
            - 时间戳`timerecode[0] = 0`,专门标记起始位。
      - 后续边沿:每次有边沿,`edge_count`加1,TMR2当前值记进`timerecode`数组。

      关键点
      - 起始位就是靠PA0的下降沿外部中断来判定,硬件级别的。
      - `edge_count == 0`保证只有第一次下降沿才初始化,后面只记时间戳。
      举个实际例子
      比如收0x37(00110111):
      - 空闲时PA0高电平。
      - 起始位来了,PA0从高到低,进EINT0中断,`edge_count = 0`,TMR2清零,TMR3启动,`timerecode[0] = 0`。
      - 数据位翻转(第1位1→第4位0→第5位1→第7位0→停止位1)依次触发中断,时间戳都记下来,比如`[104,416, 520, 728, 936]`。
      - TMR3中断(988μs后)触发,解析时间戳,最后还原出0x37。

Q2:代码里定时器到底怎么配的?为啥不是每104μs都中断?
      误区说明:接收不是每104μs都中断。
      时间戳解析法,接收这块根本不是靠定时器每104μs中断,而是靠外部中断(边沿触发)TMR3的超时中断。只有发送才用TMR7每104μs中断控制Tx引脚电平,接收逻辑完全不一样。具体说说:
      1. 接收怎么触发
         -  外部中断(EINT0:PA0设成沿触发(`EINT_Config`里`EINT_TRIGGER_RISING_FALLING`),Rx引脚一有电平变化就中断,TMR2当前值记下来。
         -  TMR3:只在起始位时启动,定时988μs(9.5位),判断一帧收完没。它只中断一次,不是每104μs都来。
      2. 边沿采样怎么实现
         -  采样时机:不是定时器定时采样,而是Rx引脚一有边沿就中断,TMR2当前值存进`timerecode`数组。
         -  TMR2作用:1MHz计数,专门记每个边沿的时间点,不会中断,只是一直计数。
         -  代码里怎么写
           - `VirtualUart_Init`里,外部中断初始化:
  1. SYSCFG_EXTILineConfig(EINT_PORT_SOURCE_GPIOA, EINT_LINE_SOURCE_0);
  2. eintConfig.line = EINT_LINE_0;
  3. eintConfig.mode = EINT_MODE_INTERRUPT;
  4. eintConfig.trigger = EINT_TRIGGER_RISING_FALLING; // 沿触发
  5. eintConfig.lineCmd = ENABLE;
  6. EINT_Config(&eintConfig);
  7. NVIC_EnableIRQ(EINT0_IRQn);
              这就是把PA0设成下降沿触发中断。
          - `EINT0_IRQHandler`里,时间戳记录:
  1. timerecode[edge_count++] = TMR2->CNT;
             每次有边沿,TMR2当前值存数组。
      3. 为啥不是每104μs中断?
         - 接收这块根本没用定时器每104μs中断。中断来源:
           - EINT0:Rx引脚电平一变就中断,次数看数据位翻转多少(比如0x37有6次边沿)。
           - TMR3:只在988μs后中断一次,表示一帧收完,调用`process_byte`解析。
         - 每104μs中断是发送用的(TMR7),控制Tx引脚发每一位。接收完全靠边沿触发,不用定时采样。
      4.和逐位采样法有啥区别?
         - 逐位采样法确实是定时器每104μs中断一次,采样Rx引脚电平(10次)。
         - 时间戳解析法只在电平翻转时中断(通常比10次少),TMR3只中断一次,效率高多了。

      代码里边沿采样点?
      - 外部中断触发:`EINT0_IRQHandler`就是边沿采样的核心,Rx引脚一有沿触发就记TMR2值。
      - TMR2一直计数:`VirtualUart_Init`里TMR2设成1MHz计数:
  1. timConfig.prescaler = 84 - 1;
  2. TMR_Config(TMR2, &timConfig);
  3. TMR_Enable(TMR2);
        TMR2一直跑,时间戳分辨率高。
      - TMR3超时:`TMR3_IRQHandler`在988μs后中断,处理时间戳数组:
  1. void TMR3_IRQHandler(void)
  2. {
  3.     if (TMR_ReadIntFlag(TMR3, TMR_INT_UPDATE) == SET)
  4.     {
  5.         TMR_ClearIntFlag(TMR3, TMR_INT_UPDATE);
  6.         TMR3->CTRL1_B.CNTEN = DISABLE;

  7.         if (edge_count > 0)
  8.         {
  9.             VirtualUart.RXREG = process_byte(edge_count - 1);
  10.             VirtualUart.RxBuff[VirtualUart.rxbuff_idx++] = VirtualUart.RXREG;

  11.             if (VirtualUart.rxbuff_idx >= VIR_RXBUFF_SIZ)
  12.             {
  13.                  VirtualUart.rxov = 1;
  14.             }

  15.             VirtualUart.rx_decode_flag = 1;
  16.         }
  17.         edge_count = 0;
  18.     }

Q. 问题总结
      1. 起始位怎么判定
         - PA0的下降沿外部中断(EINT0)一触发,第一次就清零TMR2并启动TMR3,时间戳0记下来。
         - `EINT0_IRQHandler`里`edge_count == 0`保证只认第一次下降沿。
      2. 边沿采样怎么做
         - 采样就是靠外部中断(`EINT0_IRQHandler`),Rx引脚一有下降沿就记TMR2值。
         - TMR2一直计数,TMR3只在988μs后中断一次,最后解析数据。
      - 接收这块根本不用每104μs中断,和发送那套完全不一样。

      本文仅仅是提出模拟串口的另外一种可实现的方案哈,两种方案(逐位采样,时间戳解析)都是可行的,可根据实际项目自行调整使用哪种方案,欢迎各位大佬讨论交流。

附:模拟串口时间戳解析实现工程源码
Virtual_uart.zip (7.7 MB, 下载次数: 0)


Gfan 发表于 2025-8-29 14:02 | 显示全部楼层
小海人工顶帖又见大佬新文章,这次文章以APM32F407为例,讲了使用普通GPIO引脚模拟串口的实现方法,提供了更加高效、低资源消耗的GPIO模拟串口方案噢
 楼主| DKENNY 发表于 2025-8-29 16:27 | 显示全部楼层
Gfan 发表于 2025-8-29 14:02
小海人工顶帖又见大佬新文章,这次文章以APM32F407为例,讲了使用普通GPIO引脚模拟串口的实现方法,提供了 ...

您需要登录后才可以回帖 登录 | 注册

本版积分规则

60

主题

107

帖子

16

粉丝
快速回复 在线客服 返回列表 返回顶部