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库,而应采用轻量级转换策略:

  1. 静态字符串预处理 :所有中文提示语(如“请描述图片内容”)在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

  2. 动态字符串构造 :若需拼接变量(如传感器读数),则使用 snprintf() 配合 %s 占位符,并确保目标缓冲区足够容纳UTF-8多字节字符。例如,一个汉字最多占3字节,故10个汉字需预留30字节空间。

  3. 串口参数匹配 :确认本地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推理、协议解析这些非实时任务交给更强大的伙伴

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐