博客
返回文章列表
技术

嵌入式串口

·23

嵌入式开发串口

一、串口发送

1.重定向(轮询阻塞发送)

c

//hal库实现
int fputc(int ch, FILE *f) 
{
    // 阻塞式发送单个字符
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);//这里调用发送api
    return ch;
}

//标准库实现
#include "stm32f10x.h" // 请根据你的具体芯片系列修改头文件,例如 stm32f4xx.h
#include <stdio.h>

int fputc(int ch, FILE *f) {
    /* 1. 将单个字符写入 USART1 的数据寄存器 (DR) */
    USART_SendData(USART1, (uint8_t)ch);
    
    /* 2. 轮询等待发送完成标志位 (TC: Transmission Complete) 被置位 */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET) {
        // 阻塞等待,直到单字节数据完全发送出去
    }
    
    return ch;
}

2.用dma发送(非阻塞发送)

为了让自定义的 DMA 打印函数能像标准 printf 一样支持随意的格式和变量输入,代码中使用了 C 语言的可变参数(不定参数)技巧

前置条件: 要使用可变参数机制,必须在文件头部引用标准库:#include <stdarg.h>

核心运作流程解析:

  • va_list args; 声明一个用于处理可变参数的变量。va_list 是该库中定义的一种特殊数据类型,你可以把它理解为一个“参数指针”,用来按顺序定位和提取后续传入的各个未知变量。
  • va_start(args, format); 初始化指针。它的作用是告诉编译器,从固定参数 format 之后的位置开始,把 args 指针指向第一个不定参数的实际内存地址,准备开始提取。
  • vsnprintf (或 vsprintf) 核心“打包”动作。它会自动读取 args 里的变量,并将其与 format 字符串中的占位符(如 %d, %s)一一对应组合。拼接成最终的完整字符串后,直接塞进我们准备好的发送缓存数组中。
  • va_end(args); 安全收尾。打包完成后,调用它来清理 args 变量,释放底层可能占用的资源并结束指针遍历,防止内存出错或指针乱指。
c
//hal库实现
#include "stdarg.h"

#define TX_BUFFER_SIZE 128
uint8_t Serial_TxData[TX_BUFFER_SIZE];

uint16_t APP_Printf_DMA(char *format, ...)
{
    // 1. 等待上一次 UART/DMA 发送完成
    // 在 HAL 库中,直接检查串口的状态机比直接查 DMA 状态更可靠
    while (huart1.gState != HAL_UART_STATE_READY) {
        // 阻塞等待直到串口空闲
    }
    
    // 2. 变参处理与字符串格式化
    va_list args;
    va_start(args, format);
    uint16_t len = vsnprintf((char *)Serial_TxData, TX_BUFFER_SIZE, format, args);
    va_end(args);

    // 3. 启动 DMA 发送
    // HAL库会自动处理源地址、传输大小和 DMA 使能
    HAL_UART_Transmit_DMA(&huart1, Serial_TxData, len);

    return len;
}

//标准库实现
#include "stm32f10x.h" 
#include <stdio.h>
#include <stdarg.h>

#define TX_BUFFER_SIZE 128
uint8_t Serial_TxData[TX_BUFFER_SIZE];

// STM32F1 的 USART1_TX 映射在 DMA1 的 Channel4
#define UARTx_TX_DMA_CHANNEL   DMA1_Channel4

uint16_t APP_Printf_DMA(char *format, ...) 
{
    // 1. 等待上一次 DMA 传输完成
    // 在标准库中,最稳妥的方法是检查 DMA 剩余数据量是否为 0
    while (DMA_GetCurrDataCounter(UARTx_TX_DMA_CHANNEL) != 0) {
        // 阻塞等待,直到上一次的数据全部搬运到串口
    }
    
    // 2. 变参处理与字符串格式化(与底层库无关,代码不变)
    va_list args;
    va_start(args, format);
    uint16_t len = vsnprintf((char *)Serial_TxData, TX_BUFFER_SIZE, format, args);
    va_end(args);

    // 3. 重新触发 DMA 发送
    // 【关键】标准库规定:必须先关闭 DMA 通道,才能修改 CNDTR 寄存器(传输数据量)
    DMA_Cmd(UARTx_TX_DMA_CHANNEL, DISABLE);
    
    // 填入本次需要发送的字节数
    DMA_SetCurrDataCounter(UARTx_TX_DMA_CHANNEL, len);
    
    // 重新开启 DMA,数据开始自动发送
    DMA_Cmd(UARTx_TX_DMA_CHANNEL, ENABLE);

    return len;
}

二、串口解析(暂时只有hal库)

1.ringbuffer (环形缓冲区) 核心使用指南

详细的底层原理可参考《环形缓冲区笔记》,以下为实际工程中的标准使用流程与接口说明:

核心变量定义 (构建“仓库”与“管理员”)

  • uint8_t ring_mem[RING_BUF_SIZE]; 物理存储区:这是实际存放数据的“仓库”数组。注意,其大小(RING_BUF_SIZE)通常必须被定义为 2 的幂次方,以便底层通过位运算(掩码)实现高效取模。
  • ringbuffer_st uart_ring; 缓冲区控制块(句柄):相当于仓库的“管理员”。这个结构体内部维护了缓冲区的核心状态,包括读指针(Tail)、写指针(Head)、容量大小以及指向物理内存数组的指针。

核心操作 API (操作流程)

  • ringbuffer_init (初始化与绑定) 在系统启动时调用。它的作用是将物理内存数组(ring_mem)与控制结构体(uart_ring)进行绑定,并初始化读写指针和掩码,完成缓冲区的“开仓”准备工作。
  • ringbuffer_write (数据入列) 执行写操作。将外部接收到的数据(例如 DMA 搬运过来的串口数据)逐字节写入缓冲区。内部会自动向前推进写指针(Head),如果触发写满保护则会自动丢弃溢出数据。
  • ringbuffer_get_count (查询待处理数据量) 获取当前状态。通过计算读写指针的差值,快速获取当前缓冲区内尚未被读取的有效数据个数。通常在主循环(或任务)中不断轮询此函数,作为判断是否需要执行数据解析的触发条件。
  • ringbuffer_read (数据出列) 执行读操作。将数据从缓冲区中按先入先出(FIFO)的顺序提取到临时数组中供业务逻辑解析。读取完毕后,内部会自动推进读指针(Tail),从而释放出相应的存储空间供后续新数据写入。

2.dma+环形缓冲区解析

打开中断,等数据传输满了就跳到中断,中断里面把数据放进环形缓冲区,然后再一次开启dma中断,完成闭环

c
//hal库

//hal库里面的中断是每开一次,只会运行一次的
void APP_UART_Init(void) 
{
    ringbuffer_init(&uart_ring, ring_mem, RING_BUF_SIZE);

    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_rx_buf, DMA_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
}


//这个是中断回调函数,*huart这个句柄记录着这个串口的所有信息,Size的话就是记录着这次收到了多少个字节
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) 
    {
        ringbuffer_write(&uart_ring, dma_rx_buf, Size);
        
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_rx_buf, DMA_BUF_SIZE);
        __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);//关闭半满中断
    }
}

void uart_task(void) 
{
    if (huart1.gState != HAL_UART_STATE_READY) return;
    uint16_t count = ringbuffer_get_count(&uart_ring);
    if(count==0)return;
    count=(count > RING_BUF_SIZE)?RING_BUF_SIZE:count;
    uint16_t read_len = ringbuffer_read(&uart_ring, process_buf, count); 
}

三、串口中的常用api介绍(HAL库)

外设的学习都是从轮询->中断->dma这样子一步步走过来,后面的iic等外设都是如此

分类 / API 函数名工作模式阻塞状态功能说明核心特点与适用场景
【轮询模式 (Polling)】
HAL_UART_Transmit()发送阻塞死等,直到指定的字节数全部发送完毕或超时最简单但最耗 CPU。适合 fputc 重定向 (printf) 或系统初始化阶段打日志。
HAL_UART_Receive()接收阻塞死等,直到接收满指定的字节数或超时极少使用。因为不知道数据什么时候来,CPU 会一直干等,极易引发系统卡死。
【中断模式 (Interrupt)】
HAL_UART_Transmit_IT()发送非阻塞开启发送中断并立即返回,硬件每发完 1 字节进 1 次中断适合偶尔发送短小数据,主程序不卡顿,但频繁进中断会消耗 CPU 资源
HAL_UART_Receive_IT()接收非阻塞开启接收中断并立即返回,收到指定长度后触发完成回调经典用法是每次只接收 1 个字节,在回调里拼装协议包,防止漏包。
【DMA 模式 (Direct Memory Access)】
HAL_UART_Transmit_DMA()发送非阻塞配置好 DMA 源地址和长度后立即返回,硬件全自动搬运发送性能最强。适合大批量数据回显、配合你写的发送环形缓冲区使用。
HAL_UART_Receive_DMA()接收非阻塞开启 DMA 接收并立即返回,收到指定长度后触发完成回调适合接收固定长度的大数据包。
HAL_UARTEx_ReceiveToIdle_DMA()接收非阻塞开启 DMA 接收,并同步开启空闲中断 (IDLE)不定长数据接收的“现代黄金标准”。配合环形缓冲区,是你之前代码里完美避开 CPU 负担的核心。
【常用回调函数 (User Callback)】
HAL_UART_TxCpltCallback()事件响应-发送完成后的回调函数 (Tx Complete)当 IT 或 DMA 发送完最后一字节时自动触发。常用于:释放发送信号量、接力发送下一包。
HAL_UART_RxCpltCallback()事件响应-接收完成后的回调函数 (Rx Complete)当 IT 或 DMA 接收满指定的长度时自动触发。
HAL_UARTEx_RxEventCallback()事件响应-扩展接收事件回调(专属于 ToIdle 系列)当触发 IDLE 中断(一帧结束)或接收满时进入。参数里会自动携带本次实际接收到的字节数

串口数据解析常用方法笔记

在串口通信中,通过底层的 DMA 和环形缓冲区(RingBuffer)将数据无阻塞地接收后,接下来的核心任务就是数据解析。根据通信协议类型的不同(ASCII 字符串或二进制/十六进制帧),业界通常采用以下三种核心解析方法。


一、 字符串解析法 (ASCII 协议)

这种方法最常见于各种无线通信模块(如 ESP8266 WIFI、蓝牙模块的 AT 指令)或开发板的上位机调试通信。指令通常以文本形式发送,并以 \r\n (回车换行) 作为结束符。

核心思路:从环形缓冲区中提取出带有结束符 \0 的完整字符串,利用 C 语言标准库 <string.h><stdio.h> 里的函数进行“文本匹配”或“格式化提取”。

常用 C 标准库 API 盘点:

  • strstr() (查找子串):用于判断是否收到某条基础指令。
    • 示例if (strstr((char*)buf, "OK\r\n")) { /* 处理逻辑 */ }
  • strcmp() / strncmp() (精准比对):用于严格校验特定指令的头部或全貌。
    • 示例if (strncmp((char*)buf, "START", 5) == 0)
  • sscanf() (格式化提取大神器):非常适合从包含数字的指令中直接提取变量参数。
    • 示例:收到 "AT+PWM=850",可通过 sscanf((char*)buf, "AT+PWM=%d", &pwm); 直接拿到整型 850。
  • strtok() (字符串分割):用于处理带有多个参数的逗号分隔数据(如 CSV 格式)。

⚠️ 避坑指南:在调用上述字符串 API 前,必须确保传入的数组末尾补上了 \0。因为接收缓冲区读出的往往是纯字节流,缺少 \0 会导致标准库函数越界读取内存,直接引发 MCU HardFault(死机)异常。


二、 结构体映射法 (固定长度二进制帧)

当进行高频、大数据量的单片机互联(如飞控、无人车电机驱动、高频传感器通信)时,由于文本解析效率极低且占用带宽,工业界通常采用十六进制数据包(二进制帧)。 常见的经典帧格式为:[帧头 2字节] [指令码 1字节] [有效数据 4字节] [校验和 1字节],总长度固定。

核心思路:当已知每一包数据的长度和数据类型完全固定时,我们不需要手动去把字节移位、拼接。我们可以利用 C 语言的特性,定义一个与协议字段完全对应的结构体(像一个“模具”),直接将接收到的字节数组首地址“强制类型转换”为该结构体指针,从而实现数据的零拷贝、全自动提取

1. 核心代码实现

c
// 【致命雷区防范】必须设置 1 字节对齐!防止编译器自动插入内存空位导致字节错位!
#pragma pack(push, 1) 
typedef struct {
    uint16_t header;    // 帧头,占用 2 字节 (例如 0x55AA,需注意系统的大小端模式)
    uint8_t  cmd;       // 指令控制码,占用 1 字节
    float    temp_val;  // 传感器有效数据,占用 4 字节 (标准 IEEE 754 浮点数)
    uint8_t  checksum;  // 校验和,占用 1 字节 (如 CRC8 或累加和)
} SensorFrame_t;
#pragma pack(pop)   // 恢复编译器默认的对齐设置

// 假设 process_buf 是 DMA 接收到的 8 字节原始数组
// 解析时的操作(指针魔法):
SensorFrame_t *frame = (SensorFrame_t *)process_buf;

// 像访问普通变量一样直接读取,C 语言会自动帮你处理底层的字节拼接
if (frame->header == 0x55AA && check_crc(process_buf, len) == frame->checksum) {
    float my_temp = frame->temp_val; // 直接拿到 float 数据,无需手写移位拼接代码
}

2. 原理深度剖析:指针强转是如何工作的?

假设串口收到了一包 8 个字节的数据,底层的原始内存是这样连续排列的十六进制数字: AA 55 01 00 00 80 3F EB

如果使用传统方法提取中间的 4 字节浮点数,需要非常繁琐的位运算或共用体。但使用结构体映射法时,语句 (SensorFrame_t *)process_buf 相当于把 SensorFrame_t 这个“模具”直接盖在了这 8 个字节的内存上:

  • 第 1~2 字节 (AA 55):自动落入 header 格子,读取时被当作 uint16_t
  • 第 3 字节 (01):自动落入 cmd 格子,读取时被当作 uint8_t
  • 第 4~7 字节 (00 00 80 3F):自动落入 temp_val 格子,读取时 CPU 会直接按 float 规则解码(1.0f)。
  • 第 8 字节 (EB):自动落入 checksum 格子。

数据对号入座,代码极其简洁。

3. ⚠️ 必知必会:为什么一定要写 #pragma pack(push, 1)

这是使用该方法最容易导致程序崩溃的“天坑”——内存对齐机制

为了让 CPU 读取内存的速度最快,C 语言编译器默认会进行内存对齐(通常是凑成 4 的倍数)。如果不加 #pragma pack,编译器会在结构体中偷偷塞入空白字节

  • header (占 2 字节)
  • cmd (占 1 字节)
  • 【编译器偷偷填充 1 个空白字节,为了凑够 4 字节对齐】
  • temp_val (占 4 字节)

一旦编译器改变了结构体的大小,你的“模具”格子就变大了。当你把它盖到紧凑的串口数据 process_buf 上时,从 temp_val 开始的所有数据都会发生错位,读出来的浮点数将是天文数字。

因此,#pragma pack(push, 1) 的作用就是强制命令编译器:“当前结构体必须按 1 字节严丝合缝地排列,绝对不允许插入任何空白字节!”保证软件上的结构体大小与硬件接收到的物理字节流完全一致。

三、 状态机解析法 (不定长帧的终极方案)

在实际的工业复杂电磁环境或不稳定的通信链路中,串口数据经常会出现**“粘包”(多包数据连在一起)或者“断帧”**(一包数据被切成好几段送达)。前两种方法在这种恶劣情况下极易崩溃。此时,**状态机(State Machine)**是最稳定、最专业的解析方法。

核心思路:摒弃“等一整包数据全到了再处理”的思维,改为逐个字节处理。定义一系列的状态节点,数据像流水线一样一个个喂给状态机。只有当前字节满足当前状态的要求(例如等到了正确的帧头),状态机才会跳转到下一个状态,否则立即复位清零。

经典状态机流转框架:

c
// 1. 定义解析过程中可能经历的所有状态
typedef enum {
    STATE_WAIT_HEADER1, // 等待帧头1 (例如 0xAA)
    STATE_WAIT_HEADER2, // 等待帧头2 (例如 0x55)
    STATE_READ_LEN,     // 读取当前数据包指示的 Payload 长度
    STATE_READ_PAYLOAD, // 根据长度,循环读取有效数据
    STATE_CHECK_CRC     // 接收校验码并进行比对
} ParserState_e;

ParserState_e rx_state = STATE_WAIT_HEADER1; // 初始化状态机

// 2. 将缓冲区读出的数据逐个字节传入此函数
void protocol_parse_byte(uint8_t byte) 
{
    switch (rx_state) {
        case STATE_WAIT_HEADER1:
            if (byte == 0xAA) rx_state = STATE_WAIT_HEADER2;
            break;
            
        case STATE_WAIT_HEADER2:
            if (byte == 0x55) rx_state = STATE_READ_LEN;
            else rx_state = STATE_WAIT_HEADER1; // 校验失败,立刻打回原形
            break;
            
        case STATE_READ_LEN:
            expected_len = byte;     // 记录本帧应接收的长度
            payload_idx = 0;
            rx_state = STATE_READ_PAYLOAD;
            break;
            
        case STATE_READ_PAYLOAD:
            payload_buf[payload_idx++] = byte; // 收集有效数据
            if (payload_idx >= expected_len) rx_state = STATE_CHECK_CRC; // 收集够了,准备校验
            break;
            
        case STATE_CHECK_CRC:
            if (byte == calculate_crc(payload_buf, expected_len)) {
                // 🎉 校验通过,完整且正确地收到一帧数据!执行业务逻辑!
                execute_command(payload_buf);
            }
            rx_state = STATE_WAIT_HEADER1; // 解析完一包,状态机复位,准备接客下一包
            break;
    }
}

状态机的核心优势:高度免疫干扰。它完全不挑剔底层接收方式(中断单字节接收 或 DMA 批量接收均可兼容)。它像一张智能滤网,能自动过滤掉包与包之间的乱码和杂波,稳稳地拼装出完整的有效通信帧。

相关文章