C语言调用ONNX Runtime极简教程:SenseVoice-Small模型嵌入式C接口开发
本文介绍了如何在星图GPU平台上自动化部署sensevoice-small-语音识别-onnx模型(带量化后),并详细阐述了使用纯C语言和ONNX Runtime C API进行嵌入式集成的完整流程。该镜像的核心应用场景是实现离线、低延迟的实时语音识别,适用于智能家居、工业设备等资源受限的嵌入式环境,为开发者提供了从Python环境到生产级C语言部署的清晰路径。
C语言调用ONNX Runtime极简教程:SenseVoice-Small模型嵌入式C接口开发
如果你是一名嵌入式开发者,习惯了和C语言、内存、实时系统打交道,现在想把一个像SenseVoice-Small这样的语音模型塞进你的设备里,可能会觉得有点无从下手。毕竟,AI模型的世界似乎总是围绕着Python、PyTorch这些“高级”工具。
别担心,这篇文章就是为你准备的。我们将彻底抛开Python环境,直接深入到最底层,用纯C语言和ONNX Runtime的C API,手把手带你完成从零到一的集成。整个过程就像你平时写一个串口驱动或者文件系统一样,没有魔法,只有清晰的步骤和可运行的代码。我们的目标很明确:在一个资源受限、没有Python、甚至可能没有标准C库的嵌入式环境里,让SenseVoice-Small模型跑起来。
1. 环境准备:搭建你的纯C开发战场
首先,我们得把“战场”准备好。既然不用Python,那我们的武器库就是纯C的编译器和ONNX Runtime的C语言库。
1.1 获取ONNX Runtime C库
ONNX Runtime提供了预编译的C语言库,这是最省事的方法。前往ONNX Runtime的GitHub发布页面,找到对应你目标平台(比如Linux x64、ARM等)的版本。你需要下载的是那个包含 libonnxruntime.so(Linux)或 onnxruntime.dll/onnxruntime.lib(Windows)以及所有C头文件的包。
对于嵌入式交叉编译,你可能需要从源码构建。不过为了教程的简洁,我们假设你使用的是x86_64 Linux桌面环境进行开发和测试。将下载的库文件(如 libonnxruntime.so.1.14.0)和头文件目录(通常是 include)放到你项目方便引用的位置。
1.2 创建最小的C项目结构
接下来,创建一个干净的项目目录。我们的项目结构会非常简单,不依赖任何复杂的构建系统(如CMake),以便你看清每一个环节。
sensevoice_c_demo/
├── model/
│ └── sensevoice-small.onnx # 你的ONNX模型文件
├── audio/
│ └── test.wav # 一段测试用的16kHz单声道PCM音频
├── lib/
│ ├── libonnxruntime.so # ONNX Runtime动态库(或链接)
│ └── include/ # ONNX Runtime C头文件
├── src/
│ └── main.c # 我们的主程序
└── Makefile # 简单的编译脚本
关键点:确保你拥有SenseVoice-Small模型的ONNX格式文件。如果原始模型是其他格式(如PyTorch的 .pt),你需要先使用相应的工具(如 torch.onnx.export)将其导出为ONNX格式。这是ONNX Runtime能够加载的前提。
2. 核心概念:理解ONNX Runtime C API的工作流
在用C语言操作之前,我们先花几分钟,用嵌入式工程师熟悉的逻辑来理解一下ONNX Runtime C API的核心对象和工作流。它其实很像你操作一个外设:
- 环境(OrtEnv): 就像初始化硬件平台或RTOS内核。它是所有操作的基石,全局只需要一个。
- 会话选项(OrtSessionOptions): 就像配置一个外设(如UART的波特率、SPI的模式)。在这里,你可以设置线程数、是否使用GPU(如果支持)、优化级别等。
- 会话(OrtSession): 这是核心对象。相当于你成功加载并初始化了一个复杂的硬件加速器(比如一个DSP核)。创建会话时,需要传入模型文件路径和会话选项。
- 张量(OrtValue): 数据容器。无论是输入的音频数据,还是模型输出的特征,在C API里都被封装成OrtValue。你需要亲手管理它的内存(分配和释放)。
- 运行(OrtRun): 给硬件加速器喂数据并触发计算。你需要指定输入/输出节点的名称,以及对应的OrtValue数据。
整个流程可以概括为:创建环境 -> 配置选项 -> 加载模型创建会话 -> 准备输入数据(转成张量)-> 执行推理 -> 获取输出数据(从张量中提取)-> 清理资源。
记住这个流程,我们接下来用代码把它具象化。
3. 分步实践:从零编写C语言推理代码
现在,打开你的 src/main.c 文件,我们将一步步填充代码。我会把完整的代码块拆解,并配上详细的嵌入式风格注释。
3.1 引入头文件和定义辅助宏
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ONNX Runtime C API 头文件
#include <onnxruntime_c_api.h>
// 一些辅助宏,方便错误检查(嵌入式开发的好习惯)
#define ORT_CHECK(expr) \
do { \
OrtStatus* status = (expr); \
if (status != NULL) { \
const char* msg = OrtGetErrorMessage(status); \
fprintf(stderr, "ORT错误: %s (在 %s:%d)\n", \
msg, __FILE__, __LINE__); \
OrtReleaseStatus(status); \
exit(1); \
} \
} while (0)
// 假设的SenseVoice-Small模型信息
// 你需要根据实际的模型信息修改这些!
#define MODEL_PATH "../model/sensevoice-small.onnx"
#define INPUT_NAME "input" // 输入节点名
#define OUTPUT_NAME "output" // 输出节点名
// 假设模型输入是 [1, 1, 16000] -> (batch, channel, samples)
// 即1秒的16kHz单声道音频
#define EXPECTED_SAMPLES 16000
解释:ORT_CHECK 宏是我们错误处理的“看门狗”。任何ONNX Runtime C API调用(返回OrtStatus*)都应该用它包裹,一旦出错就打印信息并退出,避免程序在错误状态下继续运行,这在调试时非常有用。
3.2 初始化环境和会话
这是我们的 main 函数开头部分,负责搭建基础设施。
int main() {
printf("=== SenseVoice-Small C语言推理演示 ===\n");
// --- 1. 初始化ONNX Runtime环境 ---
// 相当于启动硬件平台
OrtEnv* env = NULL;
ORT_CHECK(OrtCreateEnv(ORT_LOGGING_LEVEL_WARNING, "SenseVoiceDemo", &env));
printf("[1/6] ONNX Runtime环境初始化成功。\n");
// --- 2. 创建会话选项 ---
// 相当于配置硬件参数
OrtSessionOptions* session_options = NULL;
ORT_CHECK(OrtCreateSessionOptions(&session_options));
// 设置一些常用选项(根据你的嵌入式环境调整)
// 例如,设置为单线程执行,更适合确定性强的嵌入式环境
ORT_CHECK(OrtSetIntraOpNumThreads(session_options, 1));
ORT_CHECK(OrtSetInterOpNumThreads(session_options, 1));
// 如果你有GPU并想尝试,可以启用(但嵌入式环境通常没有)
// OrtSessionOptionsAppendExecutionProvider_CUDA(session_options, 0);
printf("[2/6] 会话选项配置完成。\n");
// --- 3. 创建会话(加载模型) ---
// 相当于将固件加载到硬件加速器中
OrtSession* session = NULL;
ORT_CHECK(OrtCreateSession(env, MODEL_PATH, session_options, &session));
printf("[3/6] 模型 '%s' 加载成功,会话已创建。\n", MODEL_PATH);
这部分代码建立了运行模型的“舞台”。OrtCreateEnv 是起点,session_options 让你能精细控制模型如何运行(比如线程数)。OrtCreateSession 是关键时刻,它读取磁盘上的 .onnx 文件,在内存中准备好模型的计算图。
3.3 准备输入数据(音频预处理)
模型需要特定格式的输入。SenseVoice-Small通常需要归一化后的单声道PCM音频数据。这里我们模拟从文件读取并预处理。
// --- 4. 准备输入数据 ---
printf("[4/6] 准备输入音频数据...\n");
// 4.1 模拟加载音频数据(这里用随机数模拟,真实场景从文件读)
// 实际项目中,你需要写一个WAV/PCM的读取解析函数
float* input_data = (float*)malloc(EXPECTED_SAMPLES * sizeof(float));
if (input_data == NULL) {
fprintf(stderr, "内存分配失败!\n");
return 1;
}
// 模拟:生成一段“伪音频”数据(-1.0 到 1.0 之间)
for (size_t i = 0; i < EXPECTED_SAMPLES; ++i) {
input_data[i] = (float)rand() / RAND_MAX * 2.0f - 1.0f; // [-1.0, 1.0]
}
printf(" 已生成 %d 个模拟音频样本。\n", EXPECTED_SAMPLES);
// 4.2 创建输入张量的形状信息 [batch, channel, samples]
int64_t input_shape[] = {1, 1, EXPECTED_SAMPLES};
size_t input_shape_len = 3;
// 4.3 创建OrtMemoryInfo(描述内存位置,如CPU)
OrtMemoryInfo* memory_info = NULL;
ORT_CHECK(OrtCreateCpuMemoryInfo(OrtArenaAllocator, OrtMemTypeDefault, &memory_info));
// 4.4 创建输入OrtValue张量
OrtValue* input_tensor = NULL;
ORT_CHECK(OrtCreateTensorWithDataAsOrtValue(
memory_info,
input_data,
EXPECTED_SAMPLES * sizeof(float), // 数据总大小
input_shape,
input_shape_len,
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,
&input_tensor
));
// 注意:input_tensor现在持有input_data的引用,我们之后不能单独free input_data
// 正确的释放方式是通过OrtReleaseValue。
// 输入节点名数组(C API需要指针数组)
const char* input_names[] = {INPUT_NAME};
const OrtValue* input_tensors[] = {input_tensor};
printf(" 输入张量准备完毕。\n");
这是最关键也是最容易出错的一步。我们手动创建了 input_data 数组来存放音频样本,并定义了它的形状 [1, 1, 16000]。OrtCreateTensorWithDataAsOrtValue 这个函数将我们分配好的内存块“包装”成ONNX Runtime能识别的 OrtValue 对象。你需要确保数据类型(ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT)和模型期望的完全一致。
3.4 执行模型推理
数据准备好了,现在可以“点火”运行模型了。
// --- 5. 运行模型推理 ---
printf("[5/6] 运行模型推理...\n");
// 5.1 准备输出
// 首先,我们需要知道输出节点的名称(这里假设一个,复杂模型可能有多个)
const char* output_names[] = {OUTPUT_NAME};
OrtValue* output_tensor = NULL; // 输出张量将由API分配
// 5.2 执行推理!
ORT_CHECK(OrtRun(
session,
NULL, // 使用默认的RunOptions
input_names,
input_tensors,
1, // 输入数量
output_names,
1, // 输出数量
&output_tensor
));
printf(" 推理执行完成。\n");
OrtRun 函数是引擎。它接收会话、输入/输出名称列表和对应的张量指针,然后进行计算。注意,output_tensor 我们传入的是地址,API会为我们分配好内存并填充数据。
3.5 提取和解析输出结果
推理完成,我们需要从 output_tensor 这个“黑盒子”里把数据取出来。
// --- 6. 获取输出结果 ---
printf("[6/6] 解析输出结果...\n");
// 6.1 获取输出张量信息(类型、形状)
OrtTensorTypeAndShapeInfo* output_info = NULL;
ORT_CHECK(OrtGetTensorTypeAndShape(output_tensor, &output_info));
size_t num_dims;
ORT_CHECK(OrtGetDimensionsCount(output_info, &num_dims));
int64_t* output_shape = (int64_t*)malloc(num_dims * sizeof(int64_t));
ORT_CHECK(OrtGetDimensions(output_info, output_shape, num_dims));
printf(" 输出形状: [");
for (size_t i = 0; i < num_dims; ++i) {
printf("%lld", output_shape[i]);
if (i < num_dims - 1) printf(", ");
}
printf("]\n");
// 6.2 获取输出数据指针
float* output_data = NULL;
ORT_CHECK(OrtGetTensorMutableData(output_tensor, (void**)&output_data));
// 6.3 简单处理输出(这里以语音识别为例,输出可能是概率或特征)
// 假设输出是 [1, seq_len, vocab_size] 的logits
// 我们取第一个样本,第一个时间步,概率最高的前5个token
size_t total_elements = 1;
for (size_t i = 0; i < num_dims; ++i) {
total_elements *= output_shape[i];
}
printf(" 输出数据前5个值: ");
for (size_t i = 0; i < (total_elements < 5 ? total_elements : 5); ++i) {
printf("%.6f ", output_data[i]);
}
printf("...\n");
// 在实际的语音识别中,这里需要接一个CTC解码器或Transformer解码器
// 将output_data(logits)转换成文本。
printf("\n--- 推理成功! ---\n");
printf("(注:此处仅展示原始输出,完整的语音识别需后续解码步骤。)\n");
我们通过 OrtGetTensorTypeAndShape 和 OrtGetTensorMutableData 这两个函数,像拆解一个结构体一样,拿到了输出数据的形状和内存指针。现在,output_data 就指向了模型计算出的原始结果(比如声学特征或概率分布)。对于SenseVoice-Small,这通常是需要进一步解码才能变成文本的。
3.6 至关重要的资源清理
在嵌入式开发中,内存泄漏是大忌。我们必须亲手释放每一个申请的资源,顺序与创建相反。
// --- 7. 清理资源(顺序与创建相反)---
printf("\n开始清理资源...\n");
// 释放输出相关资源
free(output_shape);
OrtReleaseTensorTypeAndShapeInfo(output_info);
OrtReleaseValue(output_tensor); // 释放输出张量
// 释放输入相关资源
OrtReleaseValue(input_tensor); // 这会释放关联的input_data内存
OrtReleaseMemoryInfo(memory_info);
// 释放会话和环境
OrtReleaseSessionOptions(session_options);
OrtReleaseSession(session);
OrtReleaseEnv(env);
printf("所有资源已释放。程序退出。\n");
return 0;
}
注意 OrtReleaseValue(input_tensor) 的调用,它不但释放了 OrtValue 对象本身,也释放了我们最初通过 malloc 分配的 input_data 内存。所以之前我们不需要(也不应该)再调用 free(input_data)。
4. 编译与运行:让代码动起来
代码写完了,我们需要一个 Makefile 来编译它。
CC = gcc
CFLAGS = -std=c11 -O2 -Wall -Wextra
INCLUDES = -I./lib/include
LDFLAGS = -L./lib -lonnxruntime -lm -Wl,-rpath,./lib
TARGET = sensevoice_demo
SRC = src/main.c
all: $(TARGET)
$(TARGET): $(SRC)
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $^ $(LDFLAGS)
clean:
rm -f $(TARGET)
.PHONY: all clean
编译和运行:
- 确保
lib/libonnxruntime.so和lib/include目录存在且正确。 - 在项目根目录执行
make。 - 运行生成的可执行文件:
./sensevoice_demo。
如果一切顺利,你将在终端看到从环境初始化、数据准备、推理执行到结果输出的完整日志。恭喜你,你已经用纯C语言完成了一次神经网络推理!
5. 从演示到实战:关键问题与进阶建议
上面的代码是一个极简的演示。要把它变成真正的嵌入式产品代码,你还需要考虑以下几个关键点:
- 真实的音频预处理: 替换掉随机数生成。你需要编写或集成一个轻量级的WAV/PCM解析器,并实现模型要求的标准化(如均值归一化)。
- 动态形状处理: 我们的例子使用了固定长度(16000)。实际中,音频长度可变。你需要使用
OrtResize等API动态调整输入张量形状,或者自己在C端实现音频的分块(chunking)处理。 - 高效的内存管理: 在资源紧张的设备上,频繁的
malloc/free可能导致碎片。可以考虑使用静态内存池或预先分配好输入/输出缓冲区。 - 错误处理的健壮性: 我们的
ORT_CHECK宏直接exit,在产品中需要更优雅的错误恢复机制。 - 性能优化: 使用
OrtSetSessionGraphOptimizationLevel启用图优化;如果设备有NPU,探索使用对应的Execution Provider(如ARMNN、TensorRT Lite)。 - 模型探查: 如果不确定模型的输入/输出节点名称和形状,可以先用Python版的ONNX Runtime加载模型,通过
session.get_inputs()[0].name和session.get_outputs()来查看。
整个过程就像在调试一个新的硬件模块,需要仔细查阅数据手册(ONNX Runtime C API文档),耐心地配置寄存器(API参数),最终让它稳定地工作起来。虽然比Python脚本多了不少步骤,但换来的是对内存和计算过程的完全掌控,这正是嵌入式开发的精髓所在。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐
所有评论(0)