大家好,我是贺老师,嵌入式 AI 工程师,《嵌入式AI:让单片机学会思考》课程主理人,专注AI在MCU上的落地实践。
很多人一看到"嵌入式AI要学 C++",第一反应就是去补模板、继承、多态、STL、设计模式。真到 MCU 上部署模型时,最常用、最关键的并不是这些。
嵌入式AI里真正决定你能不能把模型接起来、把输入输出处理对、把内存控制住的,主要是几类基础但非常硬的内容:数组、指针、引用、const、数据类型、结构体、类、静态内存和函数封装。
这些内容如果掌握得扎实,模型输入组织、特征处理、推理调用、结果解析就会顺很多;如果这些地方不稳,模型训练得再好,部署端一样会出问题。
一、数组、指针、引用、const:最先要啃透的四个点
1. 数组:输入、输出、特征缓冲区几乎都离不开它
在嵌入式AI里,数组最常见的用途有三种:存原始采样数据、存预处理后的特征数据、存模型输入输出缓冲区。
float acc_x[128];
float acc_y[128];
float acc_z[128];
如果模型输入要求三轴数据按连续内存排布,常见写法是:
float input[128 * 3];
for (int i = 0; i < 128; i++) {
input[i * 3 + 0] = acc_x[i];
input[i * 3 + 1] = acc_y[i];
input[i * 3 + 2] = acc_z[i];
}
这里必须真正理解两件事。第一,数组本质上是一段连续内存。第二,你必须清楚每个元素在内存里的位置,不然模型输入顺序很容易拼错。
**实战建议:**嵌入式AI里数组长度通常明确、固定。窗口长度是 128,就直接写成 128。项目早期优先追求稳定和可控,不要一开始就把输入缓冲区设计成复杂的动态大小。
2. 指针:不是为了复杂,而是为了传内存地址
在 MCU 项目里,很多函数不会直接传一个大数组对象,而是传数组首地址。也就是传指针。
void Normalize(float* data, int len, float mean, float std)
{
for (int i = 0; i < len; i++) {
data[i] = (data[i] - mean) / std;
}
}
调用时:
float input[128];
Normalize(input, 128, 0.5f, 0.2f);
这里 float* data 指向数组首地址,函数内部直接修改原始缓冲区。这种写法在预处理阶段极常见。
更完整一点的输入输出接口通常会这样写:
void ExtractFeature(const int16_t* raw, int raw_len, float* feature, int feature_len)
{
for (int i = 0; i < feature_len && i < raw_len; i++) {
feature[i] = raw[i] / 32768.0f;
}
}
这里有输入指针,有输出指针,也有长度参数。嵌入式里只要用指针,就要同时把长度管住,否则越界问题非常难查。
3. 引用:更适合明确存在的输出对象
如果一个参数确定存在,而且函数要直接修改它,引用通常比指针更干净。
bool RunInference(const float* input, int len, int& predicted_label, float& score)
{
if (input == nullptr || len <= 0) {
return false;
}
predicted_label = 2;
score = 0.91f;
return true;
}
调用时:
int label;
float prob;
if (RunInference(input, 128, label, prob)) {
// 使用 label 和 prob
}
这个接口比 int* 和 float* 更清楚,因为它表达的意思很明确:这两个输出对象一定存在,不允许为空。
4. const:把输入输出边界写清楚
嵌入式AI项目里,很多错误不是算法错误,而是某个函数无意中改了本不该改的数据。const 就是用来防这种问题的。
extern const unsigned char g_model[];
extern const int g_model_len;
void Process(const float* input, int len);
class ModelInfo
{
public:
int GetInputSize() const
{
return input_size_;
}
private:
int input_size_ = 128;
};
最实用的规则很简单:
-
模型数据、参数表、标签表,能加
const就加。 -
函数只读输入,不改输入,就把参数写成
const T*或const T&。 -
成员函数不改对象状态,就加尾部
const。
二、和模型部署直接相关的 C++ 内容
1. 数据类型必须非常清楚
嵌入式AI里最怕的一类错误,就是类型用错。尤其是 float、int8_t、uint8_t、int16_t 这几类。
int16_t adc_buffer[256];
float feature[64];
int8_t model_input[128];
uint8_t image_input[96 * 96];
| 类型 | 常见用途 | 使用重点 |
|---|---|---|
| int16_t | 原始 ADC / 传感器采样值 | 注意量程、符号位、原始单位 |
| float | 预处理结果、浮点模型输入 | 方便计算,但占 RAM 更多 |
| int8_t | 量化模型输入输出 | 必须结合 scale / zero-point |
| uint8_t | 图像数据、字节流、模型数据 | 注意无符号范围 0~255 |
量化时不能直接强转:
model_input[i] = (int8_t)feature[i]; // 这种写法通常不对
应该带着缩放关系去做:
int8_t QuantizeFloatToInt8(float x, float scale, int zero_point)
{
int value = (int)(x / scale) + zero_point;
if (value > 127) value = 127;
if (value < -128) value = -128;
return (int8_t)value;
}
2. 结构体:把相关数据打成一个整体
只要项目稍微完整一点,结构体就一定会用到。最常见的是打包一帧采样数据、一次推理结果或者一组配置参数。
struct ImuSample
{
int16_t ax;
int16_t ay;
int16_t az;
int16_t gx;
int16_t gy;
int16_t gz;
};
struct InferenceResult
{
int label;
float score;
uint32_t time_ms;
};
结构体最大的价值就是:让函数参数更清楚,模块之间传数据更稳,不再到处散落着很多相关变量。
3. 类:不需要复杂,但要会封装模块
嵌入式AI里最常用的类,通常都不复杂。它们最重要的作用,是把"内部状态 + 外部操作"封装在一起。
class SlidingWindow
{
public:
SlidingWindow() : count_(0) {}
void Push(float value)
{
if (count_ < kSize) {
buffer_[count_++] = value;
} else {
for (int i = 0; i < kSize - 1; i++) {
buffer_[i] = buffer_[i + 1];
}
buffer_[kSize - 1] = value;
}
}
const float* Data() const
{
return buffer_;
}
int Size() const
{
return count_;
}
private:
static const int kSize = 128;
float buffer_[kSize];
int count_;
};
这类简单类非常适合用在滑窗缓冲区、特征提取器、模型调用器这类模块上。目的不是为了"写得高级",而是为了让模块边界更清楚。
三、嵌入式AI里最容易被忽视的一块:静态内存和生命周期
1. 尽量少依赖动态内存
很多桌面程序喜欢用 new、delete、malloc、free,但在 MCU 项目里,尤其是 AI 项目里,更推荐直接静态分配。
static uint8_t tensor_arena[8 * 1024];
static int8_t input_buffer[128];
static float feature_buffer[64];
静态分配的好处非常直接:内存占用可预估、生命周期清楚、不容易出现内存碎片、系统更稳。
2. 必须搞清楚对象什么时候失效
下面这段代码就是典型错误:
float* GetBuffer()
{
float temp[128];
return temp;
}
因为 temp 是局部数组,函数退出后这段内存就不再有效。更稳妥的写法,要么由外部传入缓冲区,要么明确使用静态对象:
void FillBuffer(float* out, int len)
{
for (int i = 0; i < len; i++) {
out[i] = 0.0f;
}
}
嵌入式AI里很多诡异问题,最后都能追到生命周期没理清楚。
3. 减少不必要的数据拷贝
很多性能问题并不是推理本身慢,而是你前处理阶段反复复制数组。能原地处理就原地处理。
void NormalizeInPlace(float* data, int len, float mean, float std)
{
for (int i = 0; i < len; i++) {
data[i] = (data[i] - mean) / std;
}
}
如果一路从原始采样拷到临时数组 1,再拷到临时数组 2,再拷到模型输入,RAM 和运行时间都会被浪费掉。
原始采样归一化特征构造模型输入推理尽量复用缓冲区,避免一路复制数据
四、真正写项目时最实用的东西:函数拆分和模块组织
1. 一条完整链路,建议至少拆成五步
不要把所有逻辑都塞进 main.cpp。更好的做法,是按数据流拆分。
bool ReadSensor(ImuSample& sample);
void UpdateWindow(const ImuSample& sample);
void BuildFeature(const ImuSample* window, int win_len, float* feature, int feature_len);
bool RunModel(const float* feature, int feature_len, int& label, float& score);
void HandleResult(int label, float score);
这样一来,采样、预处理、特征、推理、后处理各自边界都很清楚。出问题时,你也能很快定位到底是哪一段出了问题。
2. 函数参数一定要写清楚输入和输出
不推荐这种含糊写法:
void Process(float* a, int b, float* c, int d);
更推荐这样:
bool BuildInputFeature(const int16_t* raw_data,
int raw_len,
float* feature_out,
int feature_len);
变量名本身就把职责写清楚了。后面维护时,代码可读性会高很多。
3. 错误处理不要偷懒
嵌入式AI最怕"悄悄出错"。表面程序在跑,实际上输入长度错了、缓冲区没填满、输出索引读错了。
bool RunModel(const float* input, int len, int& label)
{
if (input == nullptr) {
return false;
}
if (len != 128) {
return false;
}
label = 0;
return true;
}
如果项目更完整一点,可以直接定义状态码:
enum class Status
{
Ok = 0,
NullPointer,
InvalidLength,
ModelError
};
然后把函数统一改成返回 Status,这样调试时会清楚很多。
最实用的练习方式
自己写一个最小工程,把下面这条链完整打通:
-
采一段传感器数据,放进数组
-
做一次归一化
-
转换成模型输入类型
-
调用推理函数
-
读取类别和分数
把这条链真正写通,比继续看很多概念更有效。
最后总结
学习嵌入式AI,不需要先把 C++ 学成"大而全"。最值得优先掌握的,是那些直接影响模型部署的硬能力。
-
第一层:
数组、指针、引用、const。先把输入输出缓冲区和读写边界管明白。
-
第二层:
数据类型、结构体、类。把采样数据、特征数据、推理结果和模块接口组织清楚。
-
第三层:
静态内存和生命周期。知道数据该放哪,知道对象什么时候失效,知道怎么少拷贝。
-
第四层:
函数拆分和模块组织。让采样、预处理、推理、后处理形成一条清晰的数据链。
把这几层练扎实了,后面无论接哪种 MCU 侧 AI 框架,都会轻松很多。
因平台推荐规则变化,多点赞和在看**,我们才能常出现在你的推送里。**