1. 自定义串口协议的设计原理与工程实现

在嵌入式系统开发中,串口通信是最基础、最广泛使用的外设接口之一。标准的UART/USART硬件仅提供字节流传输能力,不包含任何应用层语义。当项目需求超出简单透传场景时——例如需要从PC端下发时间参数、设备需按指定格式解析并执行业务逻辑、或在多设备组网中实现可靠的数据分帧与校验——就必须在应用层构建自定义协议。本节将基于STM32 HAL库,以实际工程视角,系统性地剖析一个轻量级、可扩展、鲁棒性强的自定义串口协议的设计思想、状态机实现、字符编码处理及时间同步机制。

1.1 协议设计的核心约束与目标

自定义协议并非天马行空的自由发挥,其设计必须严格遵循嵌入式系统的资源约束和实时性要求。本例协议(以下简称“左括号-右括号协议”)的工程目标明确:

  • 最小化内存占用 :MCU RAM极其宝贵,协议解析缓冲区必须可控且紧凑。本方案采用单次接收、动态解析的策略,避免为未知长度数据预分配大块内存。
  • 零依赖、零阻塞 :不引入RTOS任务同步原语(如信号量、队列),不使用 HAL_UART_Receive 等阻塞式API,确保主循环和中断服务函数(ISR)职责清晰、响应及时。
  • 容错性优先 :协议必须能容忍PC端发送的任意垃圾数据、重复起始符、缺失结束符等常见错误。核心逻辑不因非法输入而崩溃或进入不可恢复状态。
  • 可验证、可调试 :所有关键状态(如接收开始、接收中、接收完成)均通过LCD1602直观显示,便于快速定位协议解析问题。

该协议的物理层基于USART1,工作在115200波特率、8位数据位、1位停止位、无校验的标准配置。其应用层帧结构定义为:

[任意前导数据] [左括号 '〈'] [有效载荷] [右括号 '〉'] [任意尾随数据]

其中, 是ASCII字符(十进制值171和187),作为帧定界符。有效载荷部分即为MCU需要提取并处理的全部信息,其内容、长度完全由上位机决定,MCU仅负责无条件截取两个定界符之间的字节序列。

1.2 硬件与外设初始化:时钟、引脚与中断

协议的稳定运行始于精确的硬件配置。本工程基于STM32F103C8T6(Cortex-M3内核),使用STM32CubeMX生成基础代码框架,并在此基础上进行深度定制。

时钟树配置 是所有外设工作的基石。USART1挂载于APB2总线,其时钟源为PLL输出。在 SystemClock_Config() 函数中,需确保:
- HSE(外部高速晶振)被正确启用并作为PLL的输入源。
- PLL倍频系数设置为9,使系统主频(SYSCLK)稳定在72MHz。
- APB2总线(HCLK)频率为72MHz,APB1总线(PCLK1)为36MHz。
- USART1的时钟使能位( RCC_APB2ENR_USART1EN )必须置1。

GPIO引脚配置 直接关联通信可靠性。USART1的TX(PA9)和RX(PA10)必须配置为复用推挽输出(TX)和浮空输入(RX)模式,并启用上拉电阻(对RX引脚尤为重要,可抑制噪声)。此配置在 MX_GPIO_Init() 中完成,关键代码片段如下:

// 配置PA9 (USART1_TX) 为复用推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置PA10 (USART1_RX) 为浮空输入(上拉由外部电路或内部启用)
GPIO_InitStruct.Pin = GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP; // 启用内部上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

中断系统配置 是实现非阻塞接收的关键。USART1的接收中断(RXNE)必须被使能,并在NVIC中设置合适的抢占优先级。在 MX_USART1_UART_Init() 中, huart1.Init.Mode 需设置为 UART_MODE_TX_RX ,并在 HAL_UART_Init() 之后立即调用 HAL_UART_Receive_IT() 启动首次中断接收。NVIC配置位于 MX_NVIC_Init() 中:

// 使能USART1中断通道
HAL_NVIC_SetPriority(USART1_IRQn, 2, 0); // 抢占优先级2,子优先级0
HAL_NVIC_EnableIRQ(USART1_IRQn);

此处的抢占优先级(2)需高于SysTick(通常为0)但低于最高优先级(0),以确保在RTOS环境下不会被其他高优先级中断长时间阻塞,同时又能及时响应串口数据。

1.3 接收状态机:从字节流到有效帧的蜕变

标准的 HAL_UART_Receive_IT() API每次只能接收固定长度的数据,这与变长协议帧的需求相悖。因此,必须构建一个基于单字节接收的状态机,其核心在于 HAL_UART_RxCpltCallback() 回调函数。

首先,定义必要的全局变量以维持状态:

#define RX_BUFFER_SIZE 20
uint8_t uart1_rx_buffer[RX_BUFFER_SIZE]; // 接收缓冲区
uint8_t uart1_rx_index = 0;              // 当前写入索引
uint8_t uart1_rx_flag = 0;               // 接收完成标志位
uint8_t uart1_start_received = 0;        // 左括号已接收标志

uart1_rx_buffer 是核心数据容器,其大小(20字节)远大于典型时间字符串(如”2030-10-01”仅10字节),为未来扩展预留空间,同时避免频繁的内存管理开销。

状态机逻辑在 HAL_UART_RxCpltCallback() 中实现,其流程图可抽象为三个状态:
1. IDLE(空闲) :等待左括号 (ASCII 171)的到来。
2. RECEIVING(接收中) :左括号已收到,将后续所有字节存入缓冲区,直至遇到右括号 (ASCII 187)或缓冲区满。
3. COMPLETE(完成) :右括号收到,标记 uart1_rx_flag = 1 ,准备被主循环处理。

具体代码实现如下:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        uint8_t received_byte = 0;
        HAL_UART_Receive(&huart1, &received_byte, 1, HAL_MAX_DELAY); // 读取接收到的字节

        if (received_byte == 171) { // ASCII '〈'
            // 进入RECEIVING状态,清空缓冲区并重置索引
            memset(uart1_rx_buffer, 0, sizeof(uart1_rx_buffer));
            uart1_rx_index = 0;
            uart1_start_received = 1;
        } else if (received_byte == 187 && uart1_start_received) { // ASCII '〉' and we are in RECEIVING state
            // 进入COMPLETE状态
            uart1_rx_buffer[uart1_rx_index] = '\0'; // 添加字符串终止符
            uart1_rx_flag = 1;
            uart1_start_received = 0; // 重置状态,等待下一帧
        } else if (uart1_start_received) {
            // 在RECEIVING状态下,存储有效载荷
            if (uart1_rx_index < (RX_BUFFER_SIZE - 1)) {
                uart1_rx_buffer[uart1_rx_index++] = received_byte;
            }
            // 若缓冲区满,丢弃后续字节,保持状态机健壮性
        }
        // 无论何种情况,立即重新启动下一次单字节接收
        HAL_UART_Receive_IT(&huart1, &received_byte, 1);
    }
}

此实现的关键点在于:
- 原子性重启动 :每次回调的末尾都调用 HAL_UART_Receive_IT() ,确保中断链不断。这是实现“永不丢失”接收的底层保障。
- 边界安全 :对 uart1_rx_index 进行严格检查( < (RX_BUFFER_SIZE - 1) ),防止缓冲区溢出导致栈破坏。
- 状态隔离 uart1_start_received 标志位清晰地划分了IDLE与RECEIVING状态,避免了因连续多个 字符导致的逻辑混乱。

1.4 LCD1602驱动集成:协议结果的可视化验证

协议解析的最终价值在于驱动下游设备。本工程选用经典的字符型液晶LCD1602,其驱动逻辑被封装为独立模块,便于复用。驱动的核心是精确的时序控制,通过GPIO模拟时序信号(RS、RW、E、D0-D7)。

lcd1602.c 中,关键函数包括:
- LCD1602_Init() :完成初始化序列(功能设置、显示开关、清屏、输入模式设置)。
- LCD1602_ClearLine(uint8_t line) :清空指定行(line=1为第一行,line=2为第二行),通过发送 0x01 指令实现。
- LCD1602_WriteString(uint8_t line, uint8_t pos, char *str) :在指定行列位置写入字符串。

在主循环中,当 uart1_rx_flag == 1 时,即表示一帧有效数据已就绪,此时应执行以下操作:

if (uart1_rx_flag) {
    LCD1602_ClearLine(2); // 清空第二行,避免旧数据残留
    LCD1602_WriteString(2, 0, (char*)uart1_rx_buffer); // 将解析出的字符串显示在第二行
    uart1_rx_flag = 0; // 清除标志位,等待下一帧
}

此段代码体现了嵌入式开发中的黄金法则: 所有外设操作必须在主循环中完成,中断服务函数(ISR)只做最轻量级的状态标记 。将耗时的LCD写入操作放在主循环,可以避免ISR过长导致的系统抖动和中断丢失。

1.5 字符编码与数字转换:从ASCII到可计算数据

串口传输的本质是字节流,上位机发送的“2030-10-01”在MCU端是一串ASCII码: 0x32, 0x30, 0x33, 0x30, 0x2D, 0x31, 0x30, 0x2D, 0x30, 0x31 。要让MCU理解并运算这些数据,必须进行字符到数字的转换。这是一个嵌入式开发中的高频痛点,其本质是理解ASCII编码表。

1.5.1 ASCII数值转换原理

数字字符‘0’-‘9’在ASCII表中是连续的,其十六进制值为 0x30 0x39 。因此,将一个数字字符转换为其对应的整数值,最高效的方法是 减法运算

char digit_char = '5'; // 其ASCII值为0x35
uint8_t digit_value = digit_char - '0'; // 0x35 - 0x30 = 5

此方法无需查表、无函数调用开销,是裸机编程中的首选。

1.5.2 多位数解析:手动拼接与标准库函数

对于“12:34:56”这样的时间字符串,需要提取小时、分钟、秒三个独立的整数。有两种主流实现方式:

方式一:手动索引与拼接(推荐用于资源敏感场景)

// 假设uart1_rx_buffer内容为 "12:34:56"
uint8_t hour = (uart1_rx_buffer[0] - '0') * 10 + (uart1_rx_buffer[1] - '0');
uint8_t minute = (uart1_rx_buffer[3] - '0') * 10 + (uart1_rx_buffer[4] - '0');
uint8_t second = (uart1_rx_buffer[6] - '0') * 10 + (uart1_rx_buffer[7] - '0');

此方法直接、高效、无额外RAM开销,适用于已知固定格式的字符串。

方式二:标准库函数 atoi() / atof() (推荐用于格式灵活场景)
atoi() (ASCII to integer)和 atof() (ASCII to float)是C标准库函数,位于 stdlib.h 头文件中。它们能自动跳过前导空格,并识别正负号,直到遇到非数字字符(如 : - \0 )为止。其使用示例如下:

#include <stdlib.h>
// 提取分钟:从uart1_rx_buffer[3]开始,即"34:56"的起始
uint8_t minute = atoi((char*)&uart1_rx_buffer[3]);
// 提取秒:从uart1_rx_buffer[6]开始,即"56"的起始
uint8_t second = atoi((char*)&uart1_rx_buffer[6]);

atoi() 的内部实现即是一个状态机,它会遍历字符数组,对每个数字字符执行 (c - '0') ,并累加计算: value = value * 10 + digit 。选择哪种方式取决于项目需求:若追求极致性能与确定性,选手动;若追求代码简洁与维护性,且RAM充足,选标准库。

1.6 时间同步协议:从解析到本地运行

本协议的终极目标是实现MCU与PC的时间同步。当PC发送 〈2030-10-01〉 后,MCU不仅要在LCD上显示,更要将其作为本地实时时钟(RTC)的初始值。由于F103系列MCU无内置RTC,我们采用软件计时器模拟。

1.6.1 时间结构体与软件RTC

定义一个 struct tm 兼容的时间结构体:

typedef struct {
    uint8_t second;
    uint8_t minute;
    uint8_t hour;
    uint8_t day;
    uint8_t month;
    uint16_t year;
} rtc_time_t;

rtc_time_t current_time = {0}; // 全局时间变量
1.6.2 解析与初始化

uart1_rx_flag 被置位后,立即进行时间解析与初始化:

if (uart1_rx_flag) {
    // 清空LCD第二行
    LCD1602_ClearLine(2);

    // 解析年月日(假设格式为 YYYY-MM-DD)
    current_time.year = (uart1_rx_buffer[0]-'0')*1000 + (uart1_rx_buffer[1]-'0')*100 +
                        (uart1_rx_buffer[2]-'0')*10 + (uart1_rx_buffer[3]-'0');
    current_time.month = (uart1_rx_buffer[5]-'0')*10 + (uart1_rx_buffer[6]-'0');
    current_time.day = (uart1_rx_buffer[8]-'0')*10 + (uart1_rx_buffer[9]-'0');

    // 显示解析结果
    LCD1602_WriteString(2, 0, "SYNC OK!");
    uart1_rx_flag = 0;
}
1.6.3 毫秒级定时器与时间递增

利用SysTick中断(默认每1ms触发一次)来驱动软件RTC。在 SysTick_Handler() 中,维护一个毫秒计数器,并在达到1000ms时更新秒计数:

volatile uint32_t ms_counter = 0;

void SysTick_Handler(void)
{
    HAL_IncTick();
    ms_counter++;
    if (ms_counter >= 1000) {
        ms_counter = 0;
        // 更新秒
        current_time.second++;
        if (current_time.second >= 60) {
            current_time.second = 0;
            current_time.minute++;
            if (current_time.minute >= 60) {
                current_time.minute = 0;
                current_time.hour++;
                if (current_time.hour >= 24) {
                    current_time.hour = 0;
                }
            }
        }
    }
}

此软件RTC虽不如硬件RTC精准,但对于大多数工业控制、数据采集场景已足够。

1.7 双向通信:PC指令下发与MCU状态回传

一个完整的通信闭环包含“下发”与“回传”。在完成时间同步后,MCU需按约定周期(如每秒)将当前时间回传给PC。

1.7.1 回传格式定义

回传数据同样遵循自定义协议,格式为:

〈HH:MM:SS〉

例如,当时间为23:59:59时,发送 〈23:59:59〉

1.7.2 非阻塞发送实现

为避免 HAL_UART_Transmit() 阻塞主循环,采用轮询+状态机的方式。定义发送状态:

typedef enum {
    TX_IDLE,
    TX_BUSY,
    TX_COMPLETE
} tx_state_t;

tx_state_t uart1_tx_state = TX_IDLE;
uint8_t uart1_tx_buffer[15]; // 足够容纳 "〈23:59:59〉\0"

在主循环中,当 TX_IDLE 且需要发送时,构造发送缓冲区并启动发送:

if (uart1_tx_state == TX_IDLE) {
    // 格式化时间字符串到uart1_tx_buffer
    sprintf((char*)uart1_tx_buffer, "〈%02d:%02d:%02d〉", 
            current_time.hour, current_time.minute, current_time.second);
    // 启动非阻塞发送
    HAL_UART_Transmit_IT(&huart1, uart1_tx_buffer, strlen((char*)uart1_tx_buffer));
    uart1_tx_state = TX_BUSY;
}

在发送完成回调中更新状态:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        uart1_tx_state = TX_COMPLETE;
    }
}

主循环可依据 TX_COMPLETE 状态决定何时发起下一次发送,从而实现精确的1秒间隔。

1.8 工程实践中的典型陷阱与规避策略

在将上述理论付诸实践时,工程师常会遭遇一些隐蔽的“坑”,以下是基于多年项目经验的总结:

陷阱一:中断服务函数(ISR)中执行耗时操作
- 现象 :在 HAL_UART_RxCpltCallback() 中直接调用 LCD1602_WriteString() ,导致LCD刷新缓慢、串口接收卡顿甚至死机。
- 根源 :LCD1602的写入操作涉及数十微秒的延时,严重阻塞了中断响应。
- 规避 :严格遵守“ISR只做状态标记”的原则。所有外设操作移至主循环,在 while(1) 中轮询标志位。

陷阱二:未处理缓冲区溢出
- 现象 :PC端持续发送超长数据(如1000个字符),MCU的 uart1_rx_buffer 被写爆,覆盖相邻变量,导致程序行为不可预测。
- 根源 :状态机缺少对 uart1_rx_index 的边界检查。
- 规避 :在 HAL_UART_RxCpltCallback() 中加入硬性判断 if (uart1_rx_index < (RX_BUFFER_SIZE - 1)) ,溢出时静默丢弃。

陷阱三:字符串未正确终止
- 现象 :LCD显示乱码,或 strlen() 返回异常大的值。
- 根源 uart1_rx_buffer 在接收完成后未添加 \0 终止符。
- 规避 :在状态机进入 COMPLETE 状态时,强制执行 uart1_rx_buffer[uart1_rx_index] = '\0';

陷阱四:未重启动接收中断
- 现象 :MCU只能接收第一个字节,后续数据全部丢失。
- 根源 HAL_UART_Receive_IT() 只启动一次,接收完成后中断被禁用。
- 规避 :在 HAL_UART_RxCpltCallback() 的末尾,无条件调用 HAL_UART_Receive_IT() ,形成“接收-回调-再接收”的闭环。

2. 协议扩展与工程化思考

一个优秀的嵌入式协议设计,其生命力不仅在于解决当前问题,更在于其可扩展性与鲁棒性。本节将探讨如何在本例协议基础上,向工业级应用演进。

2.1 协议健壮性增强:校验与重传

当前协议缺乏错误检测能力。在电磁干扰严重的工业现场,一个比特的翻转即可导致整个时间解析失败。引入校验是提升可靠性的第一步。

方案:异或校验(XOR Checksum)
在协议帧中增加一个校验字节,其值为 之间所有字节的异或结果。上位机在发送前计算校验值并附加在 之后;MCU在接收完成后,对缓冲区中所有字节(不含 )进行异或,若结果为0,则校验通过。

// 计算校验值(伪代码)
uint8_t checksum = 0;
for (int i = 0; i < uart1_rx_index; i++) {
    checksum ^= uart1_rx_buffer[i];
}
// 发送端:发送 "〈PAYLOAD〉CHECKSUM"
// 接收端:接收后,计算checksum,若非0则丢弃整帧

此方案计算开销极小,仅需一个字节的存储空间,是资源受限MCU的理想选择。

2.2 协议可扩展性:命令-响应模式

当前协议是单向的“数据下发”。一个成熟的设备固件应支持双向命令交互,例如:
- PC发送 〈CMD:RESET〉 ,MCU执行软复位。
- PC发送 〈CMD:GET_VER〉 ,MCU回传 〈VER:V1.2.0〉

这要求协议从“纯数据帧”升级为“命令帧”。其核心是引入命令标识符(Command ID)和参数字段。一个通用的扩展帧结构可定义为:

〈CMD_ID|PARAMS|CRC〉

其中 CMD_ID 为1字节命令码, PARAMS 为可变长参数, CRC 为校验。MCU端需维护一个命令分发表(Command Dispatch Table),根据 CMD_ID 调用对应的处理函数。这种设计将协议逻辑与业务逻辑解耦,极大提升了代码的可维护性。

2.3 开发调试技巧:利用串口作为调试信道

在协议开发过程中,一个高效的调试手段是将另一个串口(如USART2)配置为调试输出口,专门用于打印 printf() 级别的日志。这比依赖LCD1602直观得多,因为后者屏幕小、刷新慢。

main.c 中初始化USART2:

// MX_USART2_UART_Init() 与 USART1 类似,但使用PB10/PB11引脚
// 在主循环中,可随时插入调试信息
printf("Time Sync: %02d:%02d:%02d\n", current_time.hour, current_time.minute, current_time.second);

此技巧能将协议解析的每一步(如“收到左括号”、“收到第3个字节”、“校验成功”)实时输出到PC端串口助手,是定位协议状态机逻辑错误的利器。

3. 总结:从协议到工程能力的跃迁

本文所阐述的“左括号-右括号”协议,其技术复杂度并不高,但它却是一个绝佳的工程思维训练场。它迫使开发者直面嵌入式开发的核心命题: 如何在有限的硬件资源、严格的实时性约束和不可靠的通信信道下,构建一个稳定、可预测、可维护的软件系统?

从时钟树的精确配置,到GPIO引脚的电气特性考量;从中断优先级的权衡,到状态机的边界条件分析;从ASCII编码的底层理解,到 atoi() 函数的内部实现;再到软件RTC的精度权衡与调试信道的巧妙利用——每一个环节都不是孤立的知识点,而是相互咬合、共同构成一个完整工程解决方案的齿轮。

一个资深嵌入式工程师的价值,不在于他能否写出一个能跑起来的Demo,而在于他能否预见并规避那些在量产阶段才会暴露的、代价高昂的隐患。例如,本文中反复强调的“ISR中禁止耗时操作”,其背后是无数个因中断延迟导致看门狗复位、通信超时、电机失控的真实案例。这些经验,无法从教科书中学来,只能在一次次踩坑与填坑中沉淀。

因此,当你下次面对一个新的通信协议需求时,请不要急于打开IDE敲下第一行代码。先问自己几个问题:这个协议的最小内存 footprint 是多少?它的最差响应时间是多少?当上位机发送畸形数据时,我的MCU会进入什么状态?这些问题的答案,才是区分一个合格工程师与一个卓越工程师的真正分水岭。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐