STM32 可移植教程 03:USART 串口通信——让开发板能“对话“(实战篇)

STM32 可移植教程 03:USART 串口通信------让开发板能"对话"(实战篇)

第一篇你点亮了 LED,第二篇你用状态机管了两个按键。但有一件事从第一篇到现在一直没解决:你怎么知道程序里发生了什么?

想看看按键状态机当前在哪个状态?想知道长按事件有没有触发?想看看 s_mode 切到哪个值了?------你只能靠一颗 LED 猜。LED 亮、灭、闪、快闪......信息量不超过 3 个 bit。

第二篇调试按键状态机的时候,你是不是经历过这种时刻:按了一下按键,LED 没反应------但到底是按键没检测到?还是事件没触发?还是模式切换逻辑写错了?LED 回答不了这些问题。你只能加断点,停下来,看变量,再跑------调一个按键逻辑可能要反复下载十几次。

真正高效的调试,是让开发板自己把信息发出来。 发到你的电脑上,你想看什么变量就打印什么变量,想看什么状态就打印什么状态。

这就是本篇要做的事:printf 输出重定向到串口,让 STM32 能对你说话。

如果你还没看前两篇:本篇依赖第一篇搭建的 VSCode 环境 + LED 驱动(app_led.h/c),以及第二篇的按键状态机(app_key.h/c)。如果前两篇还没跑通,建议先回去做完------环境有了、按键能控制 LED 了,这第三篇才能顺利往下走。

本篇目标

最终现象:

text 复制代码
PC 串口助手实时显示:
  "Hello, count = 1, LED mode = 0"
  "[KEY1] short press -> switch to mode 1"
  "[KEY2] short press -> fast blink 100ms (mode 1)"
  "[KEY1] long press -> back to mode 0"
  ...
同时在串口助手敲入命令:
  敲 "mode 2" → 板子切到 SOS 模式
  敲 "led on" → LED 立即亮
  敲 "status" → 板子汇报当前状态
按键控制 + 串口命令,两个通道各自独立,同时生效。

跑通标准:

text 复制代码
串口助手收到开机打印信息
每秒打印一次计数器和当前模式
按下 KEY1/KEY2,串口实时显示事件信息
在串口助手敲 "mode 1" → 板子切到快闪模式
在串口助手敲 "status" → 板子汇报当前状态
按键和串口命令可以交叉使用,互不冲突
编译无 error
全程 HAL_GetTick 非阻塞,无 HAL_Delay

准备工作

项目 说明
上一篇工程 已完成第二篇 02_key_input,按键状态机 + 双按键模式切换正常工作
USB 转串口模块 CH340、CP2102 或 FT232,本篇开始必备,后面每一篇都会用到
杜邦线 母对母,至少 3 根(TX、RX、GND)
串口助手 SSCOM、PuTTY、MobaXterm、或 VSCode 的 Serial Monitor 插件均可
第三篇工程 建议复制 02_key_input 改名为 03_usart_printf

USB 转串口模块长什么样?

这是一个拇指大小的小板子,通常蓝色或紫色 PCB。一头是 USB 口(插电脑),另一头是一排 4~6 个排针,典型丝印看下面这张图就够了。

![淘宝搜"CH340 模块 USB 转 TTL",几块钱一个。买的时候选 3.3V/5V 兼容的------STM32 的 GPIO 是 3.3V 电平,用 5V 电平的模块可能会烧引脚。

注意:你开发板上的 ST-Link 是 SWD 协议,用来下载程序和调试的。它不走 UART 协议,不能当串口用。串口通信需要专门的 USB 转串口芯片(CH340/CP2102/FT232)。除非你用的是 Nucleo 板(板载 ST-Link 集成了 VCP 虚拟串口),否则必须外接这个模块。

找到 CH340 的 COM 口

模块插到电脑上之后,打开 Windows 设备管理器,展开"端口 (COM 和 LPT)"------你会看到一个类似 USB-SERIAL CH340 (COM5) 的设备。记住这个 COM 号,后面打开串口助手时要选它。

如果设备管理器里出现黄色感叹号,说明驱动没装。去搜"CH340 驱动"下载安装(wch.cn 官网有),装完重新插拔 USB 就行了。

认识 USART:板子和电脑怎么"说话"

在写代码之前,你需要理解三个概念:什么是串行通信、为什么双方要"约好"一个速度、以及那两根线(TX/RX)到底怎么接。

串行 vs 并行

计算机内部的数据总线是并行的------8 位、16 位、32 位数据同时传输。但你要是用 8 根线 + 1 根地线把 STM32 和电脑连起来------又占引脚又怕干扰,不现实。

串行通信 就是把一个字节打散成 8 个 bit,排好队,一个一个地从一根线上发过去。收的那一端再把这 8 个 bit 拼回一个字节。

![STM32 内部的 USART 外设就是专门做这件事的------把并行字节转成串行 bit 流发出去(TX),把收到的串行 bit 流转回并行字节(RX)。

波特率:两个人必须用同一个节奏说话

串行通信没有额外的时钟线------接收方不知道发送方什么时候开始发数据。所以双方必须提前约好一个速度。

这个速度就叫波特率(Baud Rate),单位是 bps(bits per second,每秒多少位)。最常见的三个值是:

波特率 常见用途
9600 比较慢,很多老设备或长线调试会用
115200 最常用,本篇就用这个
921600 较高速,适合短距离、接线可靠的场景
![你必须保证 CubeMX 配的波特率 = 串口助手选的波特率。 差一个数字就全是乱码。这是串口调试中最常见的问题------收到一堆 ???? 或看不懂的字符,99% 是波特率没对上。

数据帧:8N1

除了速度,双方还要约定一个字节怎么"打包"。最常见的格式叫 8N1

![- 8 = 8 个数据位(一字节)

  • N = No parity(无校验位------不加额外的错误检测位)
  • 1 = 1 个停止位

一个字节实际传输 1+8+1 = 10 个 bit。115200 / 10 ≈ 11520 字节/秒,大约 11KB/s。打印几行文字绰绰有余。

USART 和 UART 有区别吗?

USART 多了一个 S(Synchronous),能做同步通信(多一根时钟线)。但实际使用中几乎 100% 用的都是异步模式(Asynchronous),此时 USART = UART,两个词可以混用。

TX/RX 交叉:最容易接错的线

USART 通信只需要两根数据线 + 一根地线:

STM32 引脚 方向 USB 转串口模块引脚
PA9(TX)--- 发送 RXD--- 接收
PA10(RX)--- 接收 TXD--- 发送
GND GND

TX 接 RX、RX 接 TX------交叉连接。 你的嘴(TX)对对方的耳朵(RX),反过来也一样。TX 接 TX、RX 接 RX 是不通的。

还有一项必不可少:GND 必须连通。 两边没有共同的参考地,信号电平就像漂在海上的船------接收方不知道哪里是"高"哪里是"低",看到的全是乱码。

![## 硬件连接详解

接线实操

拿出来你的 USB 转串口模块,一般能看到 4~6 个引脚排针。你需要接的只有三根:

  • 模块 RXD 接 STM32 PA9 / USART1_TX
  • 模块 TXD 接 STM32 PA10 / USART1_RX
  • 模块 GND 接 STM32 GND

模块上的丝印是站在模块的角度写的。 模块的 TXD 意思是"模块从这里往外发数据",所以要接到 STM32 的 RX(接收引脚)。模块的 RXD 意思是"模块从这里收数据",所以要接到 STM32 的 TX(发送引脚)。

![### 一个快速验证接线的方法

不确定接对了没?用串口助手发数据,把模块的 TX 和 RX 短接(用一根杜邦线直接连起来)。如果串口助手发出去的数据立刻原样显示回来(这叫"回环测试"),说明模块本身是好的,驱动也装对了。

![### 电平匹配:3.3V 还是 5V?

STM32 的 GPIO 是 3.3V 电平。USB 转串口模块通常支持 3.3V 和 5V 两种电平,通过跳线或短路焊盘选择。确认你的模块跳线在 3.3V 一侧。 如果模块只有 5V 档------STM32 的很多引脚是 5V 耐受的(标注 FT 的引脚,PA9 和 PA10 都是),但长期 5V 工作不推荐。

CubeMX 配置步骤

1. 复制工程

02_key_input 整个文件夹复制一份,重命名为 03_usart_printf。删掉 build 目录(如果存在的话),用 VSCode 打开新目录。

2. 打开 .ioc 并添加 USART1

双击 03_usart_printf.ioc

左侧 ConnectivityUSART1,Mode 选择 Asynchronous。右侧参数面板会出现默认配置:

参数 含义
Mode Asynchronous 异步模式
Baud Rate 115200 Bits/s 波特率
Word Length 8 Bits 一个数据包 8 位
Parity None 无校验
Stop Bits 1 1 个停止位

这就是 8N1,不需要改动任何默认值。

配置完成后,CubeMX 芯片引脚图上 PA9 和 PA10 会被标记为 USART1_TX 和 USART1_RX。

![### 3. 确认 KEY1、KEY2、LED 引脚都在

总共用到的引脚:

引脚 User Label 功能 来源
PB5 LED GPIO_Output 第一篇
PA0 KEY1 GPIO_Input, Pull-up 第二篇
PC13 KEY2 GPIO_Input, Pull-up 第二篇
PA9 --- USART1_TX 本篇新增
PA10 --- USART1_RX 本篇新增(用于接收命令)

4. 生成代码

Project Manager → Toolchain = Makefile → GENERATE CODE

生成后打开 Core/Src/main.c,你会看到 CubeMX 自动增加了 MX_USART1_UART_Init() 函数(约 20 行),里面按你刚才填的参数初始化了 huart1

Core/Inc/main.h 顶部也多了一行:

c 复制代码
extern UART_HandleTypeDef huart1;

这个 huart1 就是 USART1 的"句柄"(handle)------一个包含了 USART1 所有配置和状态的结构体。后面所有串口操作都需要传这个 &huart1

5. 打开 USART1 接收中断

串口发送不需要中断(在 fputc 里直接调用阻塞发送就行),但串口接收需要中断------你不知道 PC 什么时候发数据过来,不能傻等。

.ioc 里继续配置:

  1. Connectivity → USART1 → NVIC Settings 标签页
  2. 勾选 USART1 global interrupt → Enabled
    ![勾选后点 GENERATE CODE 重新生成。这次 Core/Src/stm32f1xx_it.c 里会出现:
c 复制代码
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);
}

这一行是 CubeMX 自动生成的------USART1 的任何中断(发送完成、接收完成、错误等)都会先进 USART1_IRQHandler,然后 HAL 库根据中断类型分发到对应的回调函数。我们只关心"接收完成"回调,后面会在 app_uart.c 里实现它。

完整代码:app_uart

新建两个文件:

  • Core/Inc/app_uart.h
  • Core/Src/app_uart.c

在 Makefile 的 C_SOURCES 里加入 Core/Src/app_uart.c \

app_uart.h

c 复制代码
#ifndef APP_UART_H
#define APP_UART_H

#include "main.h"
#include <stdint.h>

void App_UART_Init(void);
void App_UART_Print(const char *text);
void App_UART_PrintBytes(const uint8_t *data, uint16_t len);

/** 串口接收到的命令 */
typedef enum {
    APP_UART_CMD_NONE = 0,
    APP_UART_CMD_MODE_0,      /* "mode 0" */
    APP_UART_CMD_MODE_1,      /* "mode 1" */
    APP_UART_CMD_MODE_2,      /* "mode 2" */
    APP_UART_CMD_LED_ON,      /* "led on"  */
    APP_UART_CMD_LED_OFF,     /* "led off" */
    APP_UART_CMD_STATUS,      /* "status"  */
    APP_UART_CMD_UNKNOWN,     /* 不认识   */
} App_UART_Cmd;

/** 启动串口接收中断(初始化后调用一次) */
void App_UART_StartRX(void);

/** 主循环调用:检查是否有新命令。无命令返回 APP_UART_CMD_NONE */
App_UART_Cmd App_UART_Poll(void);

#endif

前三个是发送函数:

  • App_UART_Init() --- 初始化串口模块
  • App_UART_Print(text) --- 发送一个 C 字符串。内部调用 strlen 算出长度,然后用 HAL_UART_Transmit 一次性发送整个字符串
  • App_UART_PrintBytes(data, len) --- 发送指定长度的原始字节数据

后三个是接收相关:

  • App_UART_Cmd --- 命令枚举,和 App_KeyEvent 一样的思路:把输入事件抽象成一个枚举值
  • App_UART_StartRX() --- 启动中断接收。本质是调用 HAL_UART_Receive_IT 开始监听第一个字节
  • App_UART_Poll() --- 主循环里调用,有命令就返回,没命令就返回 APP_UART_CMD_NONEApp_Key_GetEvent() 是同一个设计模式------读者应该已经很熟悉了

app_uart.c

c 复制代码
#include "app_uart.h"
#include <stdio.h>
#include <string.h>

/*
 * 默认使用 USART1。
 * 如果你的串口是 USART2 或 USART3:
 *   在编译选项里加 -DAPP_UART_HANDLE=huart2
 *   或者在 Include 之前 #define APP_UART_HANDLE huart2
 */
#ifndef APP_UART_HANDLE
#define APP_UART_HANDLE  huart1
#endif

extern UART_HandleTypeDef APP_UART_HANDLE;

void App_UART_Init(void)
{
    /* CubeMX 已经初始化了 huart1,这里暂时为空 */
}

void App_UART_Print(const char *text)
{
    if (text == NULL) return;

    HAL_UART_Transmit(&APP_UART_HANDLE,
                      (uint8_t *)text,
                      (uint16_t)strlen(text),
                      HAL_MAX_DELAY);
}

void App_UART_PrintBytes(const uint8_t *data, uint16_t len)
{
    if (data == NULL || len == 0) return;

    HAL_UART_Transmit(&APP_UART_HANDLE,
                      (uint8_t *)data,
                      len,
                      HAL_MAX_DELAY);
}

/*
 * ============================================================
 * printf 重定向:本篇最核心的一个函数
 * ============================================================
 *
 * 你在 main.c 里写 printf("Hello\n"),C 标准库做两件事:
 *   1. 格式化:把 "Hello\n" 加上你的参数,生成最终字符串
 *   2. 输出:把字符串的每个字符依次交给 fputc() 输出
 *
 * 在 PC 上,fputc() 写到屏幕/文件。
 * 在 STM32 上,我们重写 fputc(),让每个字符通过 USART 发出去。
 * 结果:printf 的全部输出就到了串口助手。
 *
 * ── 弱符号(weak symbol)机制 ──
 *
 * C 标准库(newlib)里已经定义了一个 fputc,但加了 __weak 属性:
 *
 *   __attribute__((weak)) int fputc(int ch, FILE *f) { return ch; }
 *
 * 这是一个"弱符号"------链接器在链接时,如果找到了另一个同名的
 * "强符号"(没有 __weak 的),就用强符号覆盖弱符号。
 *
 * 你在下面定义的 fputc 就是强符号,所以最终 printf 调用的
 * 是你的版本------字符全进了 USART。
 */
int fputc(int ch, FILE *f)
{
    uint8_t data = (uint8_t)ch;

    /*
     * 串口经典坑:\n 只换行不回车。
     * \n (LF, ASCII 10) = 光标下移一行
     * \r (CR, ASCII 13) = 光标回到行首
     *
     * 串口助手收到 \n 只换行,不回到第一列:
     *   Hello
     *        World
     *
     * 加上 \r 才是真正的换行:
     *   Hello
     *   World
     *
     * 所以 fputc 在遇到 \n 时自动在前面补一个 \r。
     */
    if (ch == '\n') {
        uint8_t cr = '\r';
        HAL_UART_Transmit(&APP_UART_HANDLE, &cr, 1u, HAL_MAX_DELAY);
    }

    HAL_UART_Transmit(&APP_UART_HANDLE, &data, 1u, HAL_MAX_DELAY);

    return ch;
}

这段代码你应该逐行理解的东西:

  1. #ifndef APP_UART_HANDLE --- 和第一篇的 APP_LED_ON_LEVEL、第二篇的 APP_KEY_ACTIVE_LEVEL 一样。默认用 USART1,换板子时可以覆盖为 USART2 或 USART3。可移植的习惯从第一篇贯穿到第三篇。

  2. HAL_UART_Transmit 的三个参数 --- &APP_UART_HANDLE(用哪个串口)、数据指针、数据长度、HAL_MAX_DELAY(超时时间)。HAL_MAX_DELAY 的意思是"一直等,等到全部发完为止",大约 0xFFFFFFFF 毫秒(≈49 天),实际上等于无限等待。

  3. 弱符号覆盖 --- 你不需要修改 C 标准库的任何代码,只需要在自己的 .c 文件里写一个同名函数,链接器自动选你的版本。这是 C 语言里最优雅的"注入"机制之一。

    ![ ,4. \n\r\n 自动转换 --- 代码里正常写 \n,串口上正常显示换行。每次手动写 \r\n 太容易忘,放在 fputc 里一劳永逸。

    ![### 串口接收:中断 + 行缓冲(追加在 app_uart.c 末尾)

上面完成了"板子说话"。下面的代码让"板子听话"。

思路很简单:

  1. 用一个行缓冲区 rx_line[32] 攒字符
  2. HAL 的 RX 中断每收到一个字节就调用 HAL_UART_RxCpltCallback
  3. 在回调里把字节写入 rx_line,遇到 \r\n 就解析整行命令
  4. 解析结果存成 App_UART_Cmd,主循环通过 App_UART_Poll() 取走

App_Key_GetEvent() 的相似之处: 按键状态机在中断/轮询里检测到按键事件后存起来,主循环调用 App_Key_GetEvent() 取走。串口接收完全一样------中断里收到完整命令行后存起来,主循环调用 App_UART_Poll() 取走。事件产生和事件消费分开------这是贯穿第二篇到第三篇的核心设计模式。

![```c

/* ── 串口接收:中断 + 行缓冲 ── */

#include <stdbool.h>

static char rx_line32;

static uint8_t rx_idx = 0;

static uint8_t rx_byte; /* 中断接收的单个字节 */

static bool rx_cmd_ready = false;

static App_UART_Cmd rx_cmd = APP_UART_CMD_NONE;

/* 解析一行命令。支持的命令见前面 App_UART_Cmd 枚举 */

static App_UART_Cmd parse_line(const char *line)

{

if (strcmp(line, "mode 0") == 0) return APP_UART_CMD_MODE_0;

else if (strcmp(line, "mode 1") == 0) return APP_UART_CMD_MODE_1;

else if (strcmp(line, "mode 2") == 0) return APP_UART_CMD_MODE_2;

else if (strcmp(line, "led on") == 0) return APP_UART_CMD_LED_ON;

else if (strcmp(line, "led off") == 0) return APP_UART_CMD_LED_OFF;

else if (strcmp(line, "status") == 0) return APP_UART_CMD_STATUS;

else return APP_UART_CMD_UNKNOWN;

}

void App_UART_StartRX(void)

{

HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1);

}

App_UART_Cmd App_UART_Poll(void)

{

if (rx_cmd_ready) {

rx_cmd_ready = false; /* 取走即消费 */

return rx_cmd;

}

return APP_UART_CMD_NONE;

}

/* ── HAL 回调:USART 收到一个字节时由 HAL 自动调用 ── */

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)

{

if (huart != &APP_UART_HANDLE) return;

复制代码
/*
 * \r (回车, ASCII 13) 或 \n (换行, ASCII 10) 表示一行结束。
 * 两种都处理------串口助手可能发 \r\n,也可能只发 \n。
 */
if (rx_byte == '\r' || rx_byte == '\n') {
    if (rx_idx > 0) {
        rx_line[rx_idx] = '\0';
        rx_cmd = parse_line(rx_line);
        rx_cmd_ready = true;
        rx_idx = 0;
    }
} else if (rx_idx < sizeof(rx_line) - 1) {
    rx_line[rx_idx++] = (char)rx_byte;
}

/* 重新使能接收------每次处理完都要再次调用,否则只收一个字节就停了 */
HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1);

}

复制代码
**逐行理解:**

1. **`rx_line[32]`** --- 行缓冲区。为什么 32 字节?"mode 0" 才 6 个字符,32 绰绰有余。命令协议简单,不需要大缓冲区。

2. **`parse_line()`** --- `strcmp` 逐条匹配。命令不认识就返回 `APP_UART_CMD_UNKNOWN`,主循环会打印 "unknown command" 提示用户。

3. **`HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1)`** --- 这行在两个地方出现:
   - `App_UART_StartRX()` 里初次调用------启动接收
   - 回调末尾再次调用------"重新武装"中断,准备收下一个字节
   
   如果不重新调用,收完一个字节后 USART 就不会再触发中断了。**这是一个常见的坑。**

4. **`rx_cmd_ready` 标志** --- 中断只负责"攒够一行 → 解析 → 置标志",不做任何耗时操作。**中断回调里绝对不能调用 `printf`**(`printf` 内部会调用阻塞的 `HAL_UART_Transmit`,在中断上下文里阻塞是致命的)。所有的 printf 响应都在主循环里完成。

5. **`HAL_UART_RxCpltCallback` 的参数 `huart`** --- 如果你同时用了多个串口(比如 USART1 + USART2),所有串口的接收完成中断都会进这个函数。所以第一步就要 `if (huart != &APP_UART_HANDLE) return;` 过滤。

### 一个常见的疑问

**`HAL_MAX_DELAY` 会不会让程序卡住?**

会。`HAL_UART_Transmit` 是阻塞的------它要等到 USART 硬件把数据全部移出 TX 引脚之后才返回。在 115200 波特率下,发一个字节大约需要 87 微秒,一行 `printf("Hello, count = 12345\r\n")` 大约 40 个字符 × 87μs ≈ 3.5ms。

3.5ms 听起来不多,但在这期间 CPU 不能干任何别的事。如果你的主循环每圈都 printf 好几行,按键状态机的 Tick 间隔会被拉长到十几毫秒甚至几十毫秒,消抖窗口会受影响。

**现阶段怎么办:** 用 `HAL_GetTick()` 控制 printf 的频率(比如每秒只打一行),这样阻塞时间平摊到 1000ms 里就不算什么了。工程延申里会给出更彻底的解决方案。

## main.c 调用方式

本篇 main.c 是在第二篇双按键 + LED 模式切换器的基础上,给每个按键事件加上 printf 输出,再每秒打印一次计数器。

### 1. 包含头文件(USER CODE BEGIN Includes)

```c
/* USER CODE BEGIN Includes */
#include "app_led.h"
#include "app_key.h"
#include "app_uart.h"
#include <stdio.h>
/* USER CODE END Includes */

2. 变量定义(USER CODE BEGIN PV)

c 复制代码
/* USER CODE BEGIN PV */
static App_Key s_key1;
static App_Key s_key2;

typedef enum {
    MODE_SWITCH = 0,
    MODE_FAST,
    MODE_SOS,
    MODE_COUNT
} LedMode;

static LedMode   s_mode            = MODE_SWITCH;
static bool      s_continuous_mode = false;
static uint32_t  s_blink_tick      = 0;
static uint8_t   s_sos_step        = 0;
static uint32_t  s_sos_tick        = 0;

/* 定时打印(非阻塞) */
static uint32_t  s_print_tick      = 0;
static uint32_t  s_print_count     = 0;
/* USER CODE END PV */

3. 初始化(USER CODE BEGIN 2)

c 复制代码
/* USER CODE BEGIN 2 */
App_LED_Init();
App_Key_Init(&s_key1, KEY1_GPIO_Port, KEY1_Pin, APP_KEY_ACTIVE_LEVEL);
App_Key_Init(&s_key2, KEY2_GPIO_Port, KEY2_Pin, APP_KEY_ACTIVE_LEVEL);
App_UART_Init();
App_UART_StartRX();   /* 启动接收中断------开始监听 PC 命令 */

/* 开机打印------验证串口一通就正常工作 */
printf("\r\n");
printf("================================\r\n");
printf(" STM32 USART Printf Demo\r\n");
printf(" KEY1: mode switch\r\n");
printf(" KEY2: action\r\n");
printf(" Commands: mode 0/1/2 | led on/off | status\r\n");
printf("================================\r\n\r\n");
/* USER CODE END 2 */

4. while 循环

把第二篇的按键事件配上 printf,再加一个每秒打印一次的计数器。

c 复制代码
/* USER CODE BEGIN WHILE */
while (1)
{
    App_Key_Tick(&s_key1);
    App_Key_Tick(&s_key2);

    /* ====== 串口命令:和按键一样,改变 s_mode ====== */
    App_UART_Cmd cmd = App_UART_Poll();
    switch (cmd) {
    case APP_UART_CMD_MODE_0:
        s_mode            = MODE_SWITCH;
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        printf("[CMD] mode 0 (SWITCH)\r\n");
        break;

    case APP_UART_CMD_MODE_1:
        s_mode            = MODE_FAST;
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        printf("[CMD] mode 1 (FAST)\r\n");
        break;

    case APP_UART_CMD_MODE_2:
        s_mode            = MODE_SOS;
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        printf("[CMD] mode 2 (SOS)\r\n");
        break;

    case APP_UART_CMD_LED_ON:
        App_LED_On();
        s_continuous_mode = false;   /* 手动控制 LED 时退出自动模式 */
        s_sos_step        = 0;
        printf("[CMD] LED ON\r\n");
        break;

    case APP_UART_CMD_LED_OFF:
        App_LED_Off();
        s_continuous_mode = false;
        s_sos_step        = 0;
        printf("[CMD] LED OFF\r\n");
        break;

    case APP_UART_CMD_STATUS:
        printf("[STATUS] mode=%d, count=%lu\r\n",
               s_mode, s_print_count);
        break;

    case APP_UART_CMD_UNKNOWN:
        printf("[CMD] unknown command\r\n");
        break;

    default: break;
    }

    /* ====== KEY1:模式切换 ====== */
    App_KeyEvent e1 = App_Key_GetEvent(&s_key1);
    switch (e1) {
    case APP_KEY_EVENT_PRESS:
        s_mode            = (LedMode)((s_mode + 1) % MODE_COUNT);
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        printf("[KEY1] short press -> switch to mode %d\r\n", s_mode);
        break;

    case APP_KEY_EVENT_LONG_PRESS:
        s_mode            = MODE_SWITCH;
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        printf("[KEY1] long press -> back to mode 0\r\n");
        break;

    default: break;
    }

    /* ====== KEY2:在当前模式下执行操作 ====== */
    App_KeyEvent e2 = App_Key_GetEvent(&s_key2);

    switch (s_mode) {

    case MODE_SWITCH:
        if (e2 == APP_KEY_EVENT_PRESS) {
            App_LED_Toggle();
            printf("[KEY2] short press -> toggle LED (mode 0)\r\n");
        }
        break;

    case MODE_FAST:
        if (e2 == APP_KEY_EVENT_PRESS) {
            s_continuous_mode = true;
            s_blink_tick      = HAL_GetTick();
            printf("[KEY2] short press -> fast blink 100ms (mode 1)\r\n");
        }
        if (e2 == APP_KEY_EVENT_LONG_PRESS) {
            s_continuous_mode = true;
            s_blink_tick      = HAL_GetTick();
            printf("[KEY2] long press -> burst blink 50ms (mode 1)\r\n");
        }
        if (e2 == APP_KEY_EVENT_RELEASE) {
            s_continuous_mode = false;
            App_LED_Off();
            printf("[KEY2] release -> stop blink (mode 1)\r\n");
        }
        break;

    case MODE_SOS:
        if (e2 == APP_KEY_EVENT_PRESS) {
            s_continuous_mode = true;
            s_sos_step        = 0;
            s_sos_tick        = HAL_GetTick();
            printf("[KEY2] short press -> start SOS (mode 2)\r\n");
        }
        if (e2 == APP_KEY_EVENT_RELEASE) {
            s_continuous_mode = false;
            s_sos_step        = 0;
            App_LED_Off();
            printf("[KEY2] release -> stop SOS (mode 2)\r\n");
        }
        break;

    default: break;
    }

    /* ====== 持续行为:快闪 / SOS ====== */
    if (s_continuous_mode) {
        uint32_t now = HAL_GetTick();

        if (s_mode == MODE_FAST) {
            uint32_t interval = App_Key_IsPressed(&s_key2) ? 50u : 100u;
            if (now - s_blink_tick >= interval) {
                s_blink_tick = now;
                App_LED_Toggle();
            }
        }
        else if (s_mode == MODE_SOS) {
            if (now - s_sos_tick >= 100) {
                s_sos_tick = now;

                static const uint8_t seq[] = {
                    1,0, 1,0, 1,0,
                    2,2,2,0, 2,2,2,0, 2,2,2,0,
                    1,0, 1,0, 1,0,
                    0,0, 0,0, 0,0, 0,0,
                };
                uint8_t cmd = seq[s_sos_step];
                s_sos_step = (s_sos_step + 1) % (sizeof(seq));

                if (cmd == 1 || cmd == 2) App_LED_On();
                else                        App_LED_Off();
            }
        }
    }

    /* ====== 定时打印计数器 ====== */
    {
        uint32_t now = HAL_GetTick();
        if (now - s_print_tick >= 1000) {
            s_print_tick = now;
            s_print_count++;
            printf("Hello, count = %lu, LED mode = %d\r\n",
                   s_print_count, s_mode);
        }
    }

    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

    /* USER CODE END 3 */
}

对比第二篇的 while 循环,本篇加了四种东西:

  1. printf("...") 事件日志 --- 每个按键事件触发时,printf 一行描述。以前你只能通过 LED 闪不闪来猜"事件触发了吗",现在串口上直接告诉你。
  2. 开机打印 --- 初始化时输出几行信息,让你一眼确认串口通了。
  3. s_print_tick / s_print_count --- 用 HAL_GetTick() 做非阻塞的每秒一次计数和打印。注意这里没有用 HAL_Delay(1000),和三篇以来的非阻塞原则一致。
  4. 串口命令处理 --- App_UART_Poll() + switch(cmd),和按键事件处理一模一样的模式。命令改变的是同一份变量(s_modes_continuous_mode),所以按键和命令自然共存。

核心工程思维:程序分"做事的代码"和"汇报的代码"。 做事的代码(状态机、LED 控制)是程序的骨架。汇报的代码(printf)是程序的感官。汇报不影响骨架的逻辑------你把所有 printf 删掉,程序行为完全不变。但有了汇报,你从黑盒变成了白盒。

第二个工程思维:多种输入源操作同一份状态。 按键可以切模式,串口命令也可以切模式,两种输入各走各的通道(App_Key_GetEvent vs App_UART_Poll),但最终修改的是同一组变量。这就是"输入与逻辑解耦"的雏形。

实战:用 printf 追踪状态机

前面说 printf 是用来调试的。那就拿第二篇的状态机来练手------在 app_key.c 的状态机里加上调试输出,让你亲眼看到状态机的每一次跳转

在 app_key.c 里加调试打印

app_key.c 顶部加上 #include <stdio.h>,然后在每个状态跳转的地方加 printf。

但有一个问题:app_key.c 本身不应该依赖串口------它是"纯逻辑"模块。所以更好的做法是用一个调试开关

c 复制代码
/* app_key.c 顶部 */
#include <stdio.h>

/* 调试开关:定义 APP_KEY_DEBUG 以启用状态机追踪 */
#ifdef APP_KEY_DEBUG
#define KEY_TRACE(fmt, ...)  printf("[FSM] " fmt "\r\n", ##__VA_ARGS__)
#else
#define KEY_TRACE(fmt, ...)  ((void)0)
#endif

然后在状态机关键跳转处加一行:

c 复制代码
case KS_IDLE:
    if (down) {
        s_state      = KS_DEBOUNCE_DOWN;
        s_entry_tick = now;
        KEY_TRACE("IDLE -> DEBOUNCE_DOWN");
    }
    break;

case KS_DEBOUNCE_DOWN:
    if (!down) {
        s_state = KS_IDLE;
        KEY_TRACE("DEBOUNCE_DOWN -> IDLE (bounce!)");
    } else if (now - s_entry_tick >= APP_KEY_DEBOUNCE_MS) {
        s_state = KS_PRESSED;
        s_entry_tick = now;
        SetEvent(APP_KEY_EVENT_PRESS);
        KEY_TRACE("DEBOUNCE_DOWN -> PRESSED (stable, PRESS event)");
    }
    break;

用法:想调试状态机的时候,在编译时加 -DAPP_KEY_DEBUG(Makefile 的 CFLAGS 里或直接在代码顶部 #define APP_KEY_DEBUG)。不想打印了就删掉这行定义------所有 KEY_TRACE 都变成空,不产生任何代码。

这种"调试宏"是嵌入式开发的标准操作。 你需要调试时打开,发布时关掉,一行代码不改,只改一个 define。

实际效果

打开 APP_KEY_DEBUG 后,下载,按下按键再松开,串口助手会输出:

text 复制代码
[FSM] IDLE -> DEBOUNCE_DOWN
[FSM] DEBOUNCE_DOWN -> PRESSED (stable, PRESS event)
[FSM] PRESSED -> DEBOUNCE_UP
[FSM] DEBOUNCE_UP -> IDLE (stable, RELEASE event)

这就是你会看到的最有价值的信息。 四个状态跳转,清清楚楚。如果有一次跳转没发生(比如消抖失败,DEBOUNCE_DOWN 直接跳回了 IDLE),你也会看到------(bounce!) 那一行解释了为什么。

和 LED 比起来:看 LED 你只知道"好像按下有效了/好像没效"。看这个,你知道状态机在哪个状态、走了哪条分支、为什么。

这就是串口打印给调试带来的质变。

编译、下载和验证

编译

Ctrl+Shift+B 或终端 make -j8

常见编译报错:

报错信息 原因 修复
undefined reference to 'fputc' app_uart.c 没加入 Makefile 的 C_SOURCES Core/Src/app_uart.c \
unknown type name 'FILE' app_uart.c 里少了 #include <stdio.h> 加上
'huart1' undeclared CubeMX 没启用 USART1 回 CubeMX 确认 USART1 Mode = Asynchronous

下载

bash 复制代码
make flash

打开串口助手

  1. 确认 USB 转串口模块已插入电脑
  2. 打开串口助手(SSCOM / PuTTY / VSCode Serial Monitor 等)
  3. 选择正确的 COM 口(去设备管理器确认)
  4. 115200 波特率、8 数据位、1 停止位、无校验、无流控
  5. 开启本地回显(SSCOM 里叫"显示发送"、PuTTY 里叫"Local echo")------否则你敲命令时看不到自己打了什么
  6. 点击"打开串口"
    ![### 验证步骤
步骤 操作 期望串口输出 期望 LED 行为
1 上电/复位 看到开机信息 + "Hello, count = 1" ---
2 等 2 秒 "Hello, count = 2" ... "Hello, count = 3" ---
3 按 KEY1 一次 [KEY1] short press -> switch to mode 1 LED 灭
4 按 KEY2 一次 [KEY2] short press -> fast blink 100ms (mode 1) LED 快闪
5 松 KEY2 [KEY2] release -> stop blink (mode 1) LED 灭
6 串口助手敲 mode 0 回车 [CMD] mode 0 (SWITCH) LED 灭
7 按 KEY2 一次 [KEY2] short press -> toggle LED (mode 0) LED 翻转
8 串口助手敲 mode 2 回车 [CMD] mode 2 (SOS) LED 灭
9 串口助手敲 status 回车 [STATUS] mode=2, count=... ---
10 串口助手敲 led off 回车 [CMD] LED OFF LED 灭(SOS 停止)
11 串口助手敲 xyz 回车 [CMD] unknown command ---

如果串口输出和上表一致,LED 行为也和上表一致------按键控制和串口命令完全共存,互不干扰。

工程延申:printf 的阻塞代价与非阻塞 TX

阻塞到底有多大影响?

我们来算一笔账。在 115200 波特率下,发一个字节约需 87μs。主循环里那行计数器 printf:

c 复制代码
printf("Hello, count = %lu, LED mode = %d\r\n", s_print_count, s_mode);

这大约 40 个字符 = 40 × 87μs ≈ 3.5ms 。在主循环里跑按键 Tick(几十微秒)、跑 LED 逻辑(几微秒)------所有有用的工作加起来不到 100 微秒。然后 printf 一跑就是 3.5ms,占了整个循环时间的 97%。

![虽然我们把打印频率控制在了每秒一次,所以 平均影响不大。但如果某一次打印恰好和按键消抖窗口重叠------按键状态机在 3.5ms 的 printf 期间完全没有被 Tick,消抖可能就因为漏了几次采样而失效。

现阶段能接受吗? 能。每秒一行 printf,阻塞时间占比 ≈ 3.5ms / 1000ms = 0.35%。对按键消抖(20ms 窗口)的影响微乎其微。但你要知道这个代价的存在。

解决方案:中断驱动的环形缓冲区

产品级的做法是:代码往一个内存缓冲区里写数据(极快,微秒级),USART 的TX 完成中断 从缓冲区取数据发出去(硬件自动,不占 CPU)。两者完全解耦。

![下面给出一个最小实现。你现在可以不敲,但理解它的思路对后面的 DMA 篇章很有帮助。

c 复制代码
/* 环形缓冲区:非阻塞串口 TX ------ 工程延申,可不敲入工程 */

#define TX_BUF_SIZE  256

static uint8_t  tx_buf[TX_BUF_SIZE];
static uint16_t tx_head = 0;   /* 写入位置 */
static uint16_t tx_tail = 0;   /* 发送位置 */
static bool     tx_busy = false;

/* 往缓冲区写一个字节。缓冲区满则丢弃(非阻塞) */
static bool tx_buf_put(uint8_t byte)
{
    uint16_t next = (tx_head + 1) % TX_BUF_SIZE;
    if (next == tx_tail) return false;   /* 满 */
    tx_buf[tx_head] = byte;
    tx_head = next;
    return true;
}

/* 从缓冲区取一个字节发送。调用者保证缓冲区非空 */
static uint8_t tx_buf_get(void)
{
    uint8_t byte = tx_buf[tx_tail];
    tx_tail = (tx_tail + 1) % TX_BUF_SIZE;
    return byte;
}

static bool tx_buf_empty(void)
{
    return tx_head == tx_tail;
}

/* 启动一次 TX 中断发送 */
static void tx_start(void)
{
    if (!tx_buf_empty() && !tx_busy) {
        tx_busy = true;
        uint8_t byte = tx_buf_get();
        HAL_UART_Transmit_IT(&APP_UART_HANDLE, &byte, 1);
    }
}

/* TX 完成中断回调(HAL 自动调用) */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    tx_busy = false;
    tx_start();  /* 发下一个字节 */
}

/* 非阻塞 fputc ------ 字符写入缓冲区即返回 */
int fputc(int ch, FILE *f)
{
    if (ch == '\n') {
        tx_buf_put('\r');
    }
    tx_buf_put((uint8_t)ch);
    tx_start();   /* 触发发送 */
    return ch;
}

原理:

  1. fputc 不再调用阻塞的 HAL_UART_Transmit,而是把字节写入 tx_buf 环形缓冲区------写内存只需要几十纳秒,完全不阻塞。
  2. tx_start() 检查缓冲区非空且 USART 空闲,就启动一次中断发送(HAL_UART_Transmit_IT)。
  3. 发完一个字节后,HAL 自动调用 HAL_UART_TxCpltCallback,里面继续取下一个字节发送。
  4. USART 以 115200 的速度慢慢发,CPU 照常跑主循环。两者各干各的。

这个方案是串口非阻塞发送的标准解法。 目前阶段你不需要把它敲进工程------阻塞 printf 足够调试用了。但当你后面做到需要高频日志、或者产品级固件时,回来翻这一段就有现成的模板。

移植到其他板子的修改点

修改点 为什么 在哪里改
USART 外设 有些板子的 PA9/PA10 已被占用,需要用 USART2/USART3 CubeMX 选另一个 USART,代码里 #define APP_UART_HANDLE huart2
TX/RX 引脚 CubeMX 会自动重映射,但还是要确认引脚和模块连对了 看 CubeMX Pinout 视图
波特率 遇到乱码先降到 9600 确认接线,调通后再升回 115200 CubeMX 和串口助手两边同步改
VCP 串口 Nucleo/Discovery 板子的板载 ST-Link 自带了虚拟串口(VCP),不需要外接 CH340 模块 设备管理器里 ST-Link 下面会多出一个 COM 口,直接用

一些最小系统板(如 STM32F103C8T6 Blue Pill)的 PA9/PA10 可能不引出来。这种情况下用 USART2(PA2/PA3)或 USART3(PB10/PB11)。CubeMX 里选了 USART2 之后,代码侧定义 APP_UART_HANDLEhuart2 就行了。

常见问题排查

1. 串口助手完全空白------什么都收不到

按顺序检查 怎么做
① 模块的 RXD 是否接 STM32 PA9 (TX) 模块丝印可能写 TXD/RXD,记住:接 STM32 TX 的要选模块的"收"脚(RXD)
② GND 是否连通 STM32 GND ↔ 模块 GND------没共地是空白/乱码的第一大原因
③ COM 口对不对 设备管理器 → 端口,看模块对应的 COM 号。插拔模块前后对比
④ 波特率对不对 CubeMX = 115200 = 串口助手
MX_USART1_UART_Init 是否被调用 main.c 里有这个函数调用吗

2. 收到全是乱码 / 问号 / 看不懂的字符

按顺序检查 怎么做
① 波特率 这是 99% 乱码的原因。 CubeMX 和串口助手必须完全一致
② 数据格式 两端都必须是 8N1
③ GND 共地 没接地 = 电平基准漂移 = 乱码
④ 电平 模块电压跳线在 3.3V

3. 编译通过、下载成功,串口助手也有输出,但 LED 行为不正常了

HAL_MAX_DELAY 导致 printf 阻塞太久,按键 Tick 间隔被拉大,消抖失效。临时验证:在 main 循环里删掉 printf 那行(只保留按键逻辑),看 LED 是否恢复正常。如果是------说明 printf 太频繁了,降低打印频率(比如从每圈打印改成每 1000ms 打印一次)。

4. fputc 编译报重复定义

你的工程里可能有两个地方定义了 fputc。检查是否在多个 .c 文件里写了 int fputc(int ch, FILE *f)。只需要一个地方定义(app_uart.c)。

5. 串口助手上看到"KEY1 short press"输出了三次(虽然 LED 只闪了一次)

printf 本身没有问题------按键状态机确实只触发了一次 PRESS 事件。如果你看到重复输出,检查是不是在主循环的多个位置对同一个事件做了多次 printf。一个 App_KeyEvent 变量只在 switch(event) 里处理一次。

6. 每次下载后串口助手显示乱码几秒然后正常

这是正常的。STM32 复位期间,TX 引脚电平不稳定,串口助手可能收到一些垃圾字节。开机后先输出一个 \r\n 清一下行,然后正式开始打印,这个问题就不会影响了。

7. 串口助手里敲命令没反应------板子不响应

按顺序检查 怎么做
① 本地回显开了吗 没开回显 = 敲了命令也看不到自己打的字。打开串口助手的"本地回显"/"显示发送"选项
② 发送格式对吗 有些串口助手默认发 hex(十六进制),切到"ASCII"或"字符"模式
③ 结尾加回车了吗 板子以 \r\n 作为命令结束标志。只敲字符不敲回车 = 命令永远攒不够一行
④ NVIC 中断开了吗 CubeMX → USART1 → NVIC Settings → USART1 global interrupt 是否勾选
App_UART_StartRX() 调了吗 main.c 初始化里有没有这行?没调用 = 中断从未启动

8. 敲命令后板子回显了 "unknown command"

命令格式不对。支持的命令只有:mode 0mode 1mode 2led onled offstatus。注意大小写、空格------"Mode 0"、"mode0" 都识别不了。

9. 敲了 mode 1 但是 LED 没反应------隔了好几秒才切过去

USART RX 引脚 (PA10) 悬空或松了?引脚悬空 = 电气噪声触发随机中断 = 行缓冲区被垃圾字节填满。

本篇小结

收获 具体来说
串口通信基础 波特率 = 双方约好的速度;8N1 = 通用数据格式;TX/RX 交叉 = 嘴对耳朵
fputc 重定向 C 标准库的弱符号机制------不改库代码,写同名函数就能覆盖默认行为
弱符号(weak symbol) 标准库的 fputc 加了 __weak,你的同名函数不加------链接器选你的。这是 C 语言最优雅的注入机制
串口接收中断 HAL_UART_Receive_IT + 行缓冲------每个字节触发中断,攒够一行解析命令
多输入源共存 按键(App_Key_GetEvent)和串口命令(App_UART_Poll)操作同一份状态,各走各的通道
从黑盒到白盒 按键事件、状态机状态、模式切换------全部 printf 出来。调试从"猜"变成"看"
调试宏 KEY_TRACE 模式------需要时 #define,不需要时删掉,一行不改
中断上下文约束 中断回调里只做轻量操作(存字节、置标志),耗时操作(printf)丢给主循环
非阻塞串口(工程延申) 环形缓冲区 + TX 中断------CPU 和 USART 完全解耦。当前调试够用,产品级要用这个模式
三篇以来的非阻塞原则延续 HAL_GetTick() 控制 printf 频率,不用 HAL_Delay------从第一篇到现在始终如一

串口通信是嵌入式开发的"基础设施"。后面的篇章里------定时器状态、ADC 采样值、I2C 通信结果------全部通过串口输出到电脑上看。双向通信(TX + RX)的打通,意味着板子和电脑之间有了完整的对话能力:板子汇报状态,电脑下达命令。

printf 是嵌入式开发最重要的调试工具,没有之一。 学会用 printf,你就告别了"LED 二进制猜状态"的原始时代。加上串口命令,你就有了远程控制的能力------后面所有的调试和交互都建立在这条通道上。

练习

  1. 改打印频率: 把 1000ms 改成 200ms,用秒表对着串口助手看打印速度是否确实变成了每秒 5 行。再改到 50ms------观察按键操作是否开始出现延迟或失灵。这个实验让你直观感受 printf 频率和系统响应性的关系。

  2. 打印状态机完整路径:app_key.c 里加上 KEY_TRACE 宏(参照上面的代码),编译时定义 APP_KEY_DEBUG。按下按键、按住不放、松开------观察串口输出的完整状态跳转路径。确认你看到了消抖失败时 DEBOUNCE_DOWN -> IDLE (bounce!) 的跳变。

  3. 格式化输出模式名:printf("LED mode = %d", s_mode) 改成打印文字而不是数字:mode 0 打 "SWITCH",mode 1 打 "FAST",mode 2 打 "SOS"。提示:写一个辅助函数 const char* ModeName(LedMode m) 返回字符串指针。

  4. 加入运行时间戳: 在每行 printf 前面加上 HAL_GetTick() 的值(格式:[12345ms] Hello, count = 1)。这样你可以算出任意两个事件之间隔了多少毫秒。提示:printf("[%lums] ...", HAL_GetTick(), ...)

  5. 添加新命令: 在命令表里加一条 blink 命令------敲 blink 就相当于在 MODE_FAST 下按了一次 KEY2(开始快闪)。你需要修改 app_uart.cparse_line()app_uart.h 的命令枚举,再在 main.c 的 switch 里添加处理。这个练习让你透彻理解"添加新命令"的完整流程。

  6. 命令回显: 在中断回调收到 \r\n 时,除了解析命令,还让板子把收到的命令行原样回发(printf("[ECHO] %s\r\n", rx_line))。观察回显和命令响应在串口助手上的输出顺序。注意:前面说过中断里不能调 printf------那怎么实现回显?提示:设一个 rx_echo_pending 标志,在主循环的 App_UART_Poll() 里处理。

  7. 环形缓冲区实验: 如果前 6 个练习都做完了还有余力,尝试把环形缓冲区代码加入 app_uart.c,替换阻塞的 fputc。把打印频率调到每圈一次,观察非阻塞版的 LED 控制和命令响应是否依然流畅。

下一篇预告

本篇打通了 STM32 和 PC 之间的双向通信------板子能说(TX/printf)也能听(RX/命令)。下一篇开始接触 STM32 的定时器:用硬件定时器替代 HAL_GetTick() 的软件轮询,实现精确的 LED PWM 调光和毫秒级定时任务。定时器是嵌入式开发最核心的外设之一,也是后面 DMA、输入捕获等高级功能的基础。

](https://i-blog.csdnimg.cn/direct/6470da6716e64a8182500b26a3119918.png)

相关推荐
蓝天居士1 小时前
INA226芯片资料(5)
嵌入式硬件·芯片资料
常州晟凯电子科技1 小时前
君正T32/T33开发笔记之快启系统演示程序编译和运行
人工智能·笔记·嵌入式硬件·物联网
踏着七彩祥云的小丑2 小时前
嵌入式测试学习第35 天:蓝牙、WiFi嵌入式设备测试基础概念
单片机·嵌入式硬件·学习
嵌入式-老费2 小时前
esp32开发与应用(深度睡眠)
嵌入式硬件
CQU_JIAKE3 小时前
6.13【A】
单片机·嵌入式硬件
Passionate.Z3 小时前
基于FPGA的CLAHE自适应限制对比度直方图均衡算法硬件verilog实现
图像处理·嵌入式硬件·算法·fpga开发·fpga
Mr..Jackey12 小时前
瑞佑 RUI Builder 图形化 UI 设计工具
arm开发·人工智能·单片机·ui·人机交互·ra8889·lcd控制芯片
西城微科方案开发15 小时前
多品类电子秤一体化PCBA整体方案
单片机·嵌入式硬件·电子秤
火花页.15 小时前
【正点原子ZYNQ领航者7020】PS端GPIO中断→按键控制LED实验
单片机·嵌入式硬件