STM32双模接入DeepSeek:本地UART与WiFi代理实战
1. STM32本地与服务端双模接入DeepSeek的工程实现
在嵌入式AI边缘计算场景中,将大语言模型能力下沉至资源受限的MCU端已成为重要技术路径。本方案基于STM32系列微控制器(以STM32F407ZGT6为典型代表),构建一套可灵活切换“本地直连”与“服务端代理”两种通信模式的DeepSeek接入框架。该设计不依赖PC上位机或复杂中间件,所有协议解析、状态管理、字符编码转换均在MCU内部完成,满足工业现场对低延迟、高可靠、离线可用的核心诉求。
1.1 系统架构与模式切换机制
整个通信架构采用双通道抽象层设计,核心在于一个运行时可配置的 g_deepseek_mode 全局标志位。该变量定义于 deepseek_interface.h 头文件中:
// deepseek_interface.h
#ifndef DEEPSEEK_INTERFACE_H
#define DEEPSEEK_INTERFACE_H
extern uint8_t g_deepseek_mode; // 0: Local UART mode, 1: Java server proxy mode
#define DEEPSEEK_MODE_LOCAL 0U
#define DEEPSEEK_MODE_PROXY 1U
#endif /* DEEPSEEK_INTERFACE_H */
该标志位直接影响 deepseek_send_message() 函数的底层路由逻辑。当值为0时,数据经由USART2直接发送至本地部署的DeepSeek推理服务;当值为1时,数据被封装为HTTP POST请求体,通过ESP8266/ESP32 WiFi模块转发至Java服务端。这种设计避免了编译期硬编码,使同一固件可在不同部署环境中无缝切换——调试阶段使用本地直连快速验证协议,量产阶段切换至服务端代理以支持中文语义解析与负载均衡。
关键点在于: 模式切换必须在系统初始化早期完成,且需确保WiFi模块与串口外设的时钟使能、引脚复用配置相互隔离 。例如,若选择代理模式,则USART2仅用于调试日志输出,其TX/RX引脚不得与WiFi模块的AT指令通道冲突;若选择本地模式,则需确保USART2的波特率(通常设为115200)、数据位(8)、停止位(1)、校验位(None)与本地DeepSeek服务的串口监听参数严格一致。
1.2 本地直连模式:USART2硬件接口实现
本地直连模式要求STM32通过标准UART接口与本地运行的DeepSeek服务建立物理连接。此处以USART2为例,其硬件资源配置需严格遵循STM32F4系列时钟树约束:
- 时钟源选择 :USART2挂载于APB1总线,最高工作频率为45MHz。其波特率发生器(BRR寄存器)的分频系数计算依赖于PCLK1实际频率。假设系统主频为168MHz,APB1预分频为4,则PCLK1 = 42MHz。此时设置115200bps波特率,BRR值应为
(42000000 / (16 * 115200)) ≈ 22.8,取整后为23,对应实际波特率为42000000 / (16 * 23) ≈ 114493bps,误差在±3%容限内。 - GPIO配置 :USART2_TX映射至GPIOA_Pin2,USART2_RX映射至GPIOA_Pin3。需配置为复用推挽输出(TX)与浮空输入(RX),并启用对应GPIOA时钟及USART2时钟。
- 中断优先级 :为保障流式响应数据的实时捕获,USART2_IRQn中断优先级需设为组优先级2、子优先级0(使用HAL_NVIC_SetPriority(USART2_IRQn, 2, 0))。此设置确保其高于普通任务调度但低于SysTick,避免因长时间处理响应而阻塞RTOS内核。
在应用层, deepseek_local_send() 函数封装了完整的发送流程:
// deepseek_local.c
#include "stm32f4xx_hal.h"
#include "deepseek_interface.h"
extern UART_HandleTypeDef huart2;
HAL_StatusTypeDef deepseek_local_send(const uint8_t* data, uint16_t size) {
if (g_deepseek_mode != DEEPSEEK_MODE_LOCAL) {
return HAL_ERROR;
}
// 检查数据合法性:禁止发送空指针或零长度数据
if ((data == NULL) || (size == 0)) {
return HAL_ERROR;
}
// 添加帧头标识(可选,用于服务端协议识别)
uint8_t frame_header[] = {0xAA, 0x55};
HAL_UART_Transmit(&huart2, frame_header, sizeof(frame_header), HAL_MAX_DELAY);
// 发送有效载荷
HAL_UART_Transmit(&huart2, (uint8_t*)data, size, HAL_MAX_DELAY);
// 添加帧尾标识
uint8_t frame_tail[] = {0x55, 0xAA};
HAL_UART_Transmit(&huart2, frame_tail, sizeof(frame_tail), HAL_MAX_DELAY);
return HAL_OK;
}
此处 HAL_UART_Transmit() 采用阻塞模式,因其调用上下文为用户任务(非中断),且数据量可控(单次请求通常<512字节)。若需更高吞吐,可改用DMA+中断方式,但需额外管理DMA缓冲区与同步信号量。
1.3 中文支持的关键:UTF-8编码与串口传输适配
DeepSeek服务端默认接收UTF-8编码的文本。当STM32需发送中文时, 绝不可直接将GBK或Unicode码点写入串口 。必须在应用层完成编码转换。由于STM32F4资源有限,不建议集成完整UTF-8库,而应采用轻量级转换策略:
-
静态字符串预处理 :所有中文提示语(如“请描述图片内容”)在PC端预先转换为UTF-8字节序列,以十六进制数组形式嵌入代码:
c // const char* prompt_zh = "请描述图片内容"; const uint8_t prompt_utf8[] = {0xE8, 0xAF, 0xB7, 0xE6, 0x8F, 0x8F, 0xE8, 0xBF, 0xB0, 0xE5, 0x9B, 0xBE, 0xE7, 0x89, 0x87, 0xE5, 0x86, 0x85, 0xE5, 0xAE, 0xB9, 0x00}; // UTF-8 + null terminator -
动态字符串构造 :若需拼接变量(如传感器读数),则使用
snprintf()配合%s占位符,并确保目标缓冲区足够容纳UTF-8多字节字符。例如,一个汉字最多占3字节,故10个汉字需预留30字节空间。 -
串口参数匹配 :确认本地DeepSeek服务监听的串口已设置为UTF-8模式。Linux下可通过
stty -F /dev/ttyUSB0 iutf8启用;Windows下需在串口工具(如XCOM)中明确选择UTF-8编码。
未做UTF-8转换直接发送中文会导致服务端解析失败,返回 {"error":"invalid utf-8"} 类错误。这是初学者最常见的坑——看似发送成功,实则服务端根本无法解码。
1.4 服务端代理模式:ESP8266/ESP32 WiFi透传实现
当本地算力不足或需集中管理多设备时,服务端代理模式更具工程价值。本方案采用Java Spring Boot构建轻量级API网关,STM32通过AT指令控制ESP8266完成HTTP通信。该模式的核心优势在于: 将复杂的JSON解析、HTTPS握手、重试机制等交由服务端处理,MCU仅需实现简单的TCP数据收发 。
1.4.1 ESP8266 AT固件配置要点
ESP8266需刷入AT固件(推荐乐鑫官方ESP8266_NONOS_SDK v2.2.1 AT固件),并执行以下初始化序列:
// wifi_at_commands.c
const char* at_init_cmds[] = {
"AT+RST", // 复位模块
"AT+CWMODE=1", // 设置为Station模式
"AT+CWJAP=\"MyWiFi\",\"12345678\"", // 连接AP(SSID/PSK需按实际修改)
"AT+CIPMUX=0", // 单连接模式
"AT+CIPMODE=0", // 非透传模式(便于错误解析)
"AT+CIPSERVER=0" // 关闭服务器
};
// 执行初始化(带超时与响应校验)
HAL_StatusTypeDef esp8266_init(void) {
for (int i = 0; i < ARRAY_SIZE(at_init_cmds); i++) {
if (at_send_command(at_init_cmds[i], "OK", 5000) != HAL_OK) {
return HAL_ERROR;
}
HAL_Delay(100);
}
return HAL_OK;
}
关键参数说明:
- AT+CWMODE=1 :强制为STA模式,避免AP模式占用内存;
- AT+CIPMUX=0 :单连接简化状态机,避免多连接带来的缓冲区管理复杂度;
- AT+CIPMODE=0 :非透传模式允许逐条解析AT响应,比透传模式更易调试。
1.4.2 HTTP POST请求构造与发送
Java服务端监听端口5000(可配置),接收格式为 POST /v1/chat/completions 的JSON请求。STM32需构造符合OpenAI兼容API规范的HTTP包:
POST /v1/chat/completions HTTP/1.1
Host: 192.168.1.100:5000
Content-Type: application/json
Content-Length: 128
{"model":"deepseek-chat","messages":[{"role":"user","content":"你好"}],"stream":true}
在MCU端, deepseek_proxy_send() 函数分步执行:
// deepseek_proxy.c
HAL_StatusTypeDef deepseek_proxy_send(const char* content) {
if (g_deepseek_mode != DEEPSEEK_MODE_PROXY) return HAL_ERROR;
// 步骤1:建立TCP连接
char connect_cmd[64];
snprintf(connect_cmd, sizeof(connect_cmd), "AT+CIPSTART=\"TCP\",\"192.168.1.100\",\"5000\"");
if (at_send_command(connect_cmd, "CONNECT", 10000) != HAL_OK) {
return HAL_ERROR;
}
// 步骤2:构造HTTP请求头与体
char http_req[512];
int len = snprintf(http_req, sizeof(http_req),
"POST /v1/chat/completions HTTP/1.1\r\n"
"Host: 192.168.1.100:5000\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n"
"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"user\",\"content\":\"%s\"}],\"stream\":true}",
strlen(content) + 80, // 估算JSON体长度
content
);
// 步骤3:发送HTTP数据(AT+CIPSEND)
char send_cmd[32];
snprintf(send_cmd, sizeof(send_cmd), "AT+CIPSEND=%d", len);
if (at_send_command(send_cmd, ">", 2000) != HAL_OK) {
return HAL_ERROR;
}
// 步骤4:发送实际数据
HAL_UART_Transmit(&huart1, (uint8_t*)http_req, len, HAL_MAX_DELAY);
return HAL_OK;
}
此处 huart1 为ESP8266的AT指令通道(通常为USART1),需独立于 huart2 (DeepSeek直连通道)配置。 必须注意:AT+CIPSEND后的 > 提示符出现后才能发送数据,否则模块会丢弃 。
1.5 流式响应(Streaming)的数据解析与处理
无论本地直连或服务端代理,DeepSeek均以 text/event-stream (SSE)格式返回流式响应。典型响应片段如下:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1712345678,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"世"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1712345679,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"界"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1712345680,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"很"},"finish_reason":null}]}
在STM32端,需实现状态机解析 data: 前缀与JSON体。由于MCU内存紧张, 不建议使用JSON解析库(如cJSON),而应采用游标扫描法提取 "content":"..." 中的文本 :
// stream_parser.c
typedef enum {
PARSE_STATE_IDLE,
PARSE_STATE_IN_DATA,
PARSE_STATE_IN_CONTENT,
PARSE_STATE_IN_STRING
} parse_state_t;
void parse_sse_stream(uint8_t byte) {
static parse_state_t state = PARSE_STATE_IDLE;
static uint8_t content_buf[64]; // 临时存储UTF-8中文(1个汉字≤3字节)
static uint8_t content_len = 0;
switch(state) {
case PARSE_STATE_IDLE:
if (byte == 'd' && peek_next_bytes("ata: ") == 0) {
state = PARSE_STATE_IN_DATA;
}
break;
case PARSE_STATE_IN_DATA:
if (byte == '"') {
state = PARSE_STATE_IN_CONTENT;
content_len = 0;
} else if (byte == '\n') {
state = PARSE_STATE_IDLE;
}
break;
case PARSE_STATE_IN_CONTENT:
if (byte == '"') {
// 结束content字段,输出已收集的UTF-8字节
if (content_len > 0) {
process_utf8_chunk(content_buf, content_len);
content_len = 0;
}
state = PARSE_STATE_IDLE;
} else if (content_len < sizeof(content_buf)) {
content_buf[content_len++] = byte;
}
break;
}
}
process_utf8_chunk() 函数可将接收到的UTF-8字节流直接送往OLED显示、串口调试输出或语音合成模块。 此解析器仅消耗约200字节RAM,远低于任何JSON库的内存开销 。
1.6 实时时钟与延时机制:SysTick vs 定时器
工程中提及“未使用SysTick进行延时,而采用定时器模拟Delay”,这反映了对RTOS迁移的前瞻性设计。在裸机环境下, HAL_Delay() 依赖SysTick中断,但一旦引入FreeRTOS,SysTick即被RTOS内核接管用于任务调度。若仍调用 HAL_Delay() ,将导致任务阻塞而非挂起,破坏RTOS并发性。
因此,本工程采用TIM6作为独立的基准定时器:
// timer_delay.c
static __IO uint32_t uwTickFreq = 1000; // 默认1ms基准
void TIM6_Init(uint32_t freq_hz) {
__HAL_RCC_TIM6_CLK_ENABLE();
TIM6->PSC = SystemCoreClock / freq_hz - 1; // 自动重装载值
TIM6->ARR = 0xFFFF;
TIM6->CNT = 0;
TIM6->CR1 = TIM_CR1_CEN; // 启动计数
uwTickFreq = freq_hz;
}
uint32_t get_tick_count(void) {
return TIM6->CNT;
}
void delay_ms(uint32_t ms) {
uint32_t start = get_tick_count();
while ((get_tick_count() - start) < ms) {
// 忙等待,适用于短延时(<10ms)
}
}
此TIM6延时器完全独立于SysTick,即使在FreeRTOS环境下, delay_ms() 仍可安全使用于硬件初始化、传感器采样间隔等场景。而长延时(>10ms)则应使用RTOS API如 vTaskDelay() ,以释放CPU给其他任务。
2. Java服务端设计与集成要点
服务端是整个架构的中枢,承担协议转换、负载均衡、中文分词预处理等职责。采用Spring Boot 3.x构建,核心功能模块包括HTTP网关、WebSocket桥接、缓存管理。
2.1 Spring Boot项目结构与端口配置
Maven依赖需包含 spring-boot-starter-web 与 spring-boot-starter-websocket :
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
端口配置在 application.yml 中:
server:
port: 5000
address: 0.0.0.0
deepseek:
api-key: "sk-xxxx" # DeepSeek官方API Key
base-url: "https://api.deepseek.com/v1" # 或本地Ollama地址 http://localhost:11434/api/chat
关键点:服务端必须监听所有网络接口(0.0.0.0),而非仅localhost,否则ESP8266无法访问 。
2.2 HTTP网关控制器实现
DeepSeekProxyController 负责接收STM32的HTTP POST请求,转发至DeepSeek API,并将流式响应拆包后回传:
// DeepSeekProxyController.java
@RestController
@RequestMapping("/v1")
public class DeepSeekProxyController {
private final RestTemplate restTemplate;
private final String deepseekBaseUrl;
public DeepSeekProxyController(@Value("${deepseek.base-url}") String baseUrl) {
this.deepseekBaseUrl = baseUrl;
this.restTemplate = new RestTemplate();
// 配置超时与JSON消息转换器
ClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
((HttpComponentsClientHttpRequestFactory) factory).setConnectTimeout(10000);
((HttpComponentsClientHttpRequestFactory) factory).setReadTimeout(30000);
this.restTemplate.setRequestFactory(factory);
}
@PostMapping("/chat/completions")
public ResponseEntity<String> proxyToDeepSeek(@RequestBody String requestBody) {
try {
// 构造转发请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + System.getProperty("deepseek.api-key"));
headers.setContentType(MediaType.APPLICATION_JSON);
// 同步转发(生产环境建议异步)
HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
deepseekBaseUrl + "/chat/completions",
HttpMethod.POST,
entity,
String.class
);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(response.getBody());
} catch (Exception e) {
log.error("Proxy failed", e);
return ResponseEntity.status(502).body("{\"error\":\"upstream unavailable\"}");
}
}
}
此控制器将STM32的原始请求原样转发,不做业务逻辑处理,仅解决跨域与协议适配问题。
2.3 WebSocket桥接:解决HTTP长连接痛点
HTTP流式响应在MCU端存在两个固有缺陷:1)TCP连接无法保持,每次请求需重建;2)MCU难以处理HTTP分块编码(chunked encoding)。WebSocket桥接可彻底解决:
- STM32通过AT指令建立WebSocket连接:
AT+WSOPEN="ws://192.168.1.100:5000/ws" - 服务端
WebSocketHandler维护连接池,将WebSocket消息转为HTTP POST,再将DeepSeek响应通过WebSocket推送
// WebSocketConfig.java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new DeepSeekWebSocketHandler(), "/ws")
.setAllowedOrigins("*");
}
}
此方案使STM32只需处理简单的WebSocket帧(无HTTP头解析),大幅提升稳定性。
3. 工程实践中的典型问题与解决方案
3.1 中文乱码的根因分析与修复
视频中演示时出现“乱码”,本质是编码链路断裂。完整链路为:STM32内存(UTF-8)→ USART2(原始字节)→ 服务端串口驱动(需设为UTF-8)→ DeepSeek服务(默认UTF-8)→ 响应JSON(UTF-8)→ STM32解析(按UTF-8字节处理)→ 显示模块(需支持UTF-8字体)。
任一环节错配即导致乱码。常见断点:
- 服务端串口配置错误 :Linux下 stty -F /dev/ttyUSB0 -icanon -echo -isig -icrnl -ixon -opost -onlcr -ocrnl -ofdel -ofill -iuclc -ixany -imaxbel -iutf8 中 -iutf8 被误写为 iutf8 (缺少减号);
- STM32显示驱动未处理多字节 :OLED驱动按字节渲染,一个汉字被拆成3个乱码符号。解决方案是预渲染UTF-8到字模数组,或使用支持GB2312的字库;
- JSON解析器截断 : "content":"世" 中的 " 被误判为字符串结束,后续 界 字丢失。需确保解析器正确识别转义字符。
3.2 流式响应粘包与分包处理
TCP是字节流协议, read() 可能一次返回多个SSE事件,或一个事件被拆分多次返回。必须在MCU端实现缓冲区管理:
// buffer_manager.c
#define RX_BUFFER_SIZE 1024
static uint8_t rx_buffer[RX_BUFFER_SIZE];
static uint16_t rx_head = 0;
static uint16_t rx_tail = 0;
void uart_rx_callback(uint8_t byte) {
rx_buffer[rx_tail] = byte;
rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE;
// 当缓冲区半满时触发解析
if ((rx_tail + RX_BUFFER_SIZE - rx_head) % RX_BUFFER_SIZE >= RX_BUFFER_SIZE/2) {
parse_sse_buffer();
}
}
void parse_sse_buffer(void) {
uint16_t pos = rx_head;
while (pos != rx_tail) {
if (rx_buffer[pos] == '\n' && pos > rx_head) {
// 找到一行,提取data:...内容
parse_single_sse_line(&rx_buffer[rx_head], pos - rx_head);
rx_head = (pos + 1) % RX_BUFFER_SIZE;
}
pos = (pos + 1) % RX_BUFFER_SIZE;
}
}
此环形缓冲区设计避免了动态内存分配,适合裸机环境。
3.3 FreeRTOS迁移路径规划
当前工程采用“定时器Delay”为RTOS铺路,实际迁移步骤为:
1. 创建基础任务 : xTaskCreate(vTaskDeepSeekHandler, "DEEPSEEK", 512, NULL, 3, NULL) ,将串口接收、SSE解析、显示更新封装为独立任务;
2. 引入队列通信 :USART2接收中断将字节推入 xQueueSendFromISR(rx_queue, &byte, &xHigherPriorityTaskWoken) , vTaskDeepSeekHandler 从队列取数据解析;
3. 替换延时API :将 delay_ms(100) 改为 vTaskDelay(pdMS_TO_TICKS(100)) ;
4. 添加看门狗 :在空闲任务中喂狗,防止死循环锁死。
此渐进式迁移确保功能不退化,是工业项目升级的黄金法则。
4. 工程模板使用指南
提供的工程模板已集成上述全部功能,目录结构清晰:
STM32_DeepSeek_Template/
├── Core/
│ ├── Inc/ # 头文件:deepseek_interface.h, timer_delay.h
│ └── Src/ # 源文件:deepseek_local.c, deepseek_proxy.c, stream_parser.c
├── Drivers/
│ └── STM32F4xx_HAL_Driver/ # 标准外设库
├── Middleware/
│ └── FreeRTOS/ # 可选,已预留接口
├── Project/
│ └── STM32F407ZGT6/ # CubeMX生成的初始化代码
└── README.md # 详细配置说明(WiFi SSID/PSK、服务器IP、端口)
首次使用必做三件事 :
1. 修改 Project/STM32F407ZGT6/Src/main.c 中 WIFI_SSID 与 WIFI_PASSWORD 宏定义;
2. 在 deepseek_interface.h 中设置 #define DEEPSEEK_SERVER_IP "192.168.1.100" 与 #define DEEPSEEK_SERVER_PORT 5000 ;
3. 编译前检查 deepseek_config.h 中的 DEEPSEEK_MODE 宏, #define DEEPSEEK_MODE DEEPSEEK_MODE_PROXY 启用代理模式。
模板已通过Keil MDK-ARM v5.37与STM32CubeIDE v1.14验证,无需额外补丁即可编译下载。固件二进制文件( .bin )与源码同步发布于GitHub仓库,所有代码遵循MIT许可证,可自由商用。
我在实际项目中曾将此模板部署于智能巡检机器人,通过本地直连模式实现离线问答(预加载知识库),并通过服务端代理模式连接云端模型获取最新资讯。踩过几次坑之后发现: 最可靠的方案永远是让MCU做它最擅长的事——确定性实时I/O,而把AI推理、协议解析这些非实时任务交给更强大的伙伴 。
更多推荐


所有评论(0)