结构体传参,到底该传值还是传指针?

摘自:一枚嵌入式码农

链接:https://mp.weixin.qq.com/s/6XxiOWJs4tZcoRjzrGqnMQ

目录

写嵌入式代码的人,十有八九在结构体传参这件事上纠结过。有人说"一律用指针,省内存";也有人踩过指针被意外修改的坑。这篇文章不讲大道理,就聊聊实际开发中怎么选。

一次代码审查引发的讨论

前段时间帮同事做代码审查,看到一个函数:

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,需要改就用指针。 适合你项目的,就是对的。

相关推荐
sycmancia1 小时前
C++——函数模板的概念和意义
c++
闻缺陷则喜何志丹1 小时前
【巴什博弈 线性筛】P8901 [USACO22DEC] Circular Barn S|普及+
c++·数学·洛谷·巴什博弈·线型筛
样例过了就是过了1 小时前
LeetCode热题100 电话号码的字母组合
数据结构·c++·算法·leetcode·dfs
Yusei_05232 小时前
C++17入门
c++
xuxie992 小时前
N2 中断
单片机·嵌入式硬件
biubiuibiu2 小时前
选择适合的硬盘:固态与机械硬盘的对比与推荐
c++·算法
Godspeed Zhao2 小时前
现代智能汽车系统——MCULess2
单片机·嵌入式硬件·汽车
子繁~~2 小时前
STM32开发文档:
stm32·单片机·嵌入式硬件
Zevalin爱灰灰2 小时前
零基础入门学用物联网(ESP8266) 第一部分 基础知识篇(二)
单片机·物联网·嵌入式·esp8266