嵌入式串口
嵌入式开发串口
一、串口发送
1.重定向(轮询阻塞发送)
//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变量,释放底层可能占用的资源并结束指针遍历,防止内存出错或指针乱指。
//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中断,完成闭环
//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. 核心代码实现
// 【致命雷区防范】必须设置 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)**是最稳定、最专业的解析方法。
核心思路:摒弃“等一整包数据全到了再处理”的思维,改为逐个字节处理。定义一系列的状态节点,数据像流水线一样一个个喂给状态机。只有当前字节满足当前状态的要求(例如等到了正确的帧头),状态机才会跳转到下一个状态,否则立即复位清零。
经典状态机流转框架:
// 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 批量接收均可兼容)。它像一张智能滤网,能自动过滤掉包与包之间的乱码和杂波,稳稳地拼装出完整的有效通信帧。