摘自:一枚嵌入式码农
目录
写嵌入式代码的人,十有八九在结构体传参这件事上纠结过。有人说"一律用指针,省内存";也有人踩过指针被意外修改的坑。这篇文章不讲大道理,就聊聊实际开发中怎么选。
一次代码审查引发的讨论
前段时间帮同事做代码审查,看到一个函数:
c
void LED_SetColor(LED_Config_t *config)
{
PWM_SetDuty(config->ch_r, config->duty_r);
PWM_SetDuty(config->ch_g, config->duty_g);
PWM_SetDuty(config->ch_b, config->duty_b);
}
这个 LED_Config_t 就三个通道号加三个占空比,总共 6 个 uint8_t,一共才 6 个字节。我说这里没必要传指针,直接传值就行。同事不同意:"传指针是好习惯,省得拷贝,性能好。"
这个说法对不对?对,但不全对。在嵌入式环境里,"省不省"要看省的是什么、代价又是什么。
我们先从底层看清楚传值和传指针到底发生了什么,再来判断。
传值和传指针,栈上差了什么
假设有一个 12 字节的结构体,在 Cortex-M(32 位)平台上,两种调用方式在栈上的表现是这样的:

看起来差别很明显:传指针只要 4 字节,传值要把整个结构体拷一遍。但注意------当结构体很小的时候(比如 ≤ 8 字节),这个"优势"几乎不存在。 在 ARM Cortex-M 上,小结构体可以直接通过寄存器传递(R0~R3),根本不需要压栈,传值反而比传指针更快------因为指针还多了一次间接寻址的开销。
所以问题就变成了:结构体多大算"大",多小算"小"?中间地带怎么选?
这些场景,用指针没毛病
这些场景,用指针没毛病
当结构体超过 16 字节(经验值),传值就要在栈上拷贝一大块数据。嵌入式任务栈本来就不宽裕,大结构体传值容易栈溢出,还浪费 CPU 周期。
c
/* 通信帧结构,几十上百字节很正常 */
typedef struct {
uint8_t header;
uint8_t cmd;
uint16_t length;
uint8_t payload[256];
uint16_t crc;
} CommFrame_t;
/* 传指针,栈上只多了 4 字节 */
void Comm_ParseFrame(const CommFrame_t *frame);
注意这里的 const------如果函数内不需要修改原数据,加上 const 是必须的。 它明确告诉调用者和编译器:"我只读不写"。这不是风格问题,是安全问题。
场景二:函数需要修改调用者的数据
这是指针的核心能力------要在函数内部改变外部数据,就得传地址。传值只会改到副本,外面看不到变化。
c
typedef struct {
uint16_t adc_raw;
float voltage;
uint8_t is_valid;
} SensorData_t;
/* 采集函数直接把结果写入调用者的结构体 */
void Sensor_Read(SensorData_t *data)
{
data->adc_raw = ADC_GetValue(CH_TEMP);
data->voltage = data->adc_raw * 3.3f / 4096.0f;
data->is_valid = (data->adc_raw > 100) ? 1 : 0;
}
场景三:多个函数需要共享同一份数据
在嵌入式系统里,经常有一个"系统状态"结构体被多个模块读取或更新。传指针让大家操作同一块内存,而不是各自拿拷贝各改各的。
c
typedef struct {
uint8_t mode;
uint16_t error_code;
uint32_t run_time_sec;
} SysStatus_t;
static SysStatus_t g_sys_status;
/* 多个模块通过指针访问同一份状态 */
void Display_Update(const SysStatus_t *status);
void Logger_Record(const SysStatus_t *status);
void Comm_Report(const SysStatus_t *status);
这些场景,别用指针
用指针不是"高级写法",用错了反而埋坑。
场景一:结构体很小,传值更干净
回到开头那个 LED 配置的例子。6 字节的结构体,在 Cortex-M 上大概率直接走寄存器传递,传值性能和传指针几乎一样,甚至更好。更重要的是------传值让函数没有副作用,调试时不用担心原数据被改。
c
typedef struct {
uint8_t ch_r, ch_g, ch_b;
uint8_t duty_r, duty_g, duty_b;
} LED_Config_t; /* 6 字节,传值即可 */
void LED_SetColor(LED_Config_t config)
{
PWM_SetDuty(config.ch_r, config.duty_r);
PWM_SetDuty(config.ch_g, config.duty_g);
PWM_SetDuty(config.ch_b, config.duty_b);
}
场景二:需要防止数据被意外篡改
在中断或多任务环境下,如果把结构体指针传给某个函数,而指针指向的内存随时可能被另一个上下文修改,读到的数据就可能前后不一致。传值反而更安全------相当于在调用瞬间给数据拍了"快照"。
bash
中断/RTOS 多任务环境下的数据竞争问题:
─────────────────────────────────────────
传指针 (有风险):
Task A: 读 p->field_1 ──→ 得到旧值
↑ 中断/任务切换,Task B 修改了 field_2
Task A: 读 p->field_2 ──→ 得到新值 数据不一致!
传值 (安全):
拷贝瞬间: field_1=旧值, field_2=旧值 ──→ 整体一致
之后 Task B 怎么改都不影响这份副本
当然,如果结构体很大,传值开销也很可观,这时更好的做法是传指针配合加锁。这里只是说明"传指针不是永远都对"。
场景三:函数返回小结构体
C99 以后,函数可以直接返回结构体。对于小型结构体,编译器通常会优化为寄存器返回,写出来既简洁又安全:
c
typedef struct {
int16_t x;
int16_t y;
} Point_t; /* 4 字节 */
Point_t Touch_GetPos(void)
{
Point_t pos;
pos.x = Touch_ReadX();
pos.y = Touch_ReadY();
return pos; /* 编译器优化后,通过寄存器返回 */
}
这比传一个输出指针 void Touch_GetPos(Point_t *out) 干净得多------调用者不需要先声明变量再传地址,一行搞定:Point_t p = Touch_GetPos();
最后
"结构体一律传指针"是一个流传很广的经验,但经验不等于教条。嵌入式开发里,栈空间可能只有几百字节,CPU 周期要精打细算,代码可能跑在中断上下文里------这些约束决定了不能无脑套用桌面开发的习惯。
建议很简单:小结构体传值,大结构体传指针,只读加 const,需要改就用指针。 适合你项目的,就是对的。