第2章 嵌入式C语言基础:数据类型、运算符、流程控制、函数、指针、结构体

各位同学,欢迎来到第二章。

说实话,C语言是咱们嵌入式开发的“吃饭家伙”。你想想看,从点亮一个LED到控制发动机喷油,底层跑的都是C代码。我见过不少新人,上来就啃RTOS、啃CAN协议栈,结果连个指针都搞不明白,最后调试到崩溃。

这一章,咱们就把地基打牢。我会把我在车载ECU开发中踩过的坑、积累的经验,都揉碎了讲给你听。

2.1 数据类型:选对类型,省一半内存

嵌入式开发里,内存是金贵的。一个单片机可能只有几KB的RAM,你浪费一个字节,可能就少了一个功能。

2.1.1 基本数据类型

标准C有char、int、float、double。但在嵌入式里,我强烈建议你只用里定义的类型。为什么?因为标准int在不同平台长度不一样——在8位单片机上是16位,在32位单片机上是32位。你写个int count,换个芯片可能就溢出了。

类型位数范围我常用的场景
uint8_t80 ~ 255状态标志、小计数器
uint16_t160 ~ 65535ADC采样值、PWM占空比
uint32_t320 ~ 4.29e9时间戳、里程累计
int32_t32-2^31 ~ 2^31-1温度差值、速度偏差
float32±1.18e-38 ~ ±3.4e38PID系数、滤波值
我的习惯: 只要涉及外设寄存器、通信协议、状态机,一律用uint8_t/uint16_t/uint32_t。只有做数学运算时才用float。int?我基本不用。

2.1.2 枚举与联合体

枚举让代码可读性翻倍。比如定义ECU工作模式:

typedef enum {
    ECU_MODE_INIT = 0,
    ECU_MODE_NORMAL,
    ECU_MODE_DIAG,
    ECU_MODE_ERROR
} EcuMode_t;

你看,比用0、1、2、3清晰多了吧?

联合体(union)在解析CAN报文时特别好用。我曾经用union把一个uint32_t拆成4个字节,省去了移位操作:

typedef union {
    uint32_t raw;
    struct {
        uint8_t byte0;
        uint8_t byte1;
        uint8_t byte2;
        uint8_t byte3;
    } bytes;
} CanData_t;
注意: 联合体的字节序跟平台有关。小端模式下byte0是低字节,大端模式相反。我在调试一个J1939协议时,就因为没注意这个,整整查了两天bug。

2.2 运算符:别让优先级坑了你

运算符优先级是C语言里最容易出bug的地方之一。我见过最经典的错误:

// 错误写法
if (status & MASK == 0x01)  // 实际被解析为:if (status & (MASK == 0x01))

// 正确写法
if ((status & MASK) == 0x01)

为什么会这样?因为==的优先级高于&。记住一条铁律:拿不准优先级,就加括号。没人会笑话你括号多,但bug会。

2.2.1 位运算:嵌入式工程师的必修课

在ECU开发中,寄存器操作几乎全是位运算。设置一个GPIO引脚:

// 设置第3位为1
GPIO->BSRR = (1U << 3);

// 清除第3位
GPIO->BRR = (1U << 3);

// 读取第5位
uint8_t bit = (GPIO->IDR >> 5) & 0x01;

我个人习惯把常用的位操作封装成宏:

#define SET_BIT(reg, bit)   ((reg) |= (1U << (bit)))
#define CLR_BIT(reg, bit)   ((reg) &= ~(1U << (bit)))
#define GET_BIT(reg, bit)   (((reg) >> (bit)) & 0x01)

2.3 流程控制:状态机的灵魂

ECU软件本质上就是一个巨大的状态机。上电初始化、自检、正常运行、故障处理……每个阶段都有不同的行为。

2.3.1 if-else vs switch-case

分支少用if-else,分支多用switch-case。但要注意:switch-case里的变量必须是整型或枚举。我曾经看到有人用float做switch,编译直接报错。

switch (ecuState) {
    case ECU_MODE_INIT:
        initPeripherals();
        break;
    case ECU_MODE_NORMAL:
        runControlLoop();
        break;
    case ECU_MODE_ERROR:
        enterSafeState();
        break;
    default:
        // 兜底处理,防止意外状态
        resetECU();
        break;
}
避坑指南: 每个case后面别忘了break。我曾经在量产代码里漏了一个break,结果状态机从NORMAL直接滑到了ERROR,客户投诉说车子无故亮故障灯。从那以后,我写switch-case都会逐行检查break。

2.3.2 循环:while、for、do-while

在嵌入式里,循环常用于轮询、延时、数据搬运。比如等待外设就绪:

// 等待ADC转换完成,超时保护
uint32_t timeout = 1000;
while (!(ADC->SR & ADC_SR_EOC)) {
    if (--timeout == 0) {
        // 超时处理
        handleAdcTimeout();
        break;
    }
}

注意那个超时保护。没有它,一旦ADC卡死,你的程序就永远停在那里了。这在汽车上是绝对不允许的。

2.4 函数:模块化的基石

函数让代码可复用、可测试。在ECU开发中,我习惯把每个功能模块拆成独立的.c和.h文件。比如:

  • adc.c / adc.h —— ADC驱动
  • can.c / can.h —— CAN通信
  • pwm.c / pwm.h —— PWM输出
  • control.c / control.h —— 控制算法

2.4.1 函数设计原则

一个函数只做一件事。比如:

// 不好的设计:一个函数既读温度又控制风扇
void readTempAndControlFan(void);

// 好的设计:职责分离
uint16_t readTemperature(void);
void controlFan(uint16_t temp);

你想想看,如果哪天要改风扇控制逻辑,你只需要改controlFan(),完全不影响温度读取。这就是模块化的好处。

2.4.2 静态函数与全局函数

在.c文件里,只在本文件使用的函数加static。这能防止命名冲突,也告诉阅读者:这个函数是内部实现,别乱调用。

// adc.c
static void adc_hardwareInit(void) {
    // 初始化ADC寄存器
}

void adc_init(void) {
    adc_hardwareInit();
    // 其他初始化
}

2.5 指针:C语言的灵魂

指针是C语言最强大的特性,也是新人最容易翻车的地方。说白了,指针就是地址。你通过地址直接操作内存。

2.5.1 指针基础

uint8_t data = 0x55;
uint8_t *ptr = &data;  // ptr保存了data的地址

*ptr = 0xAA;  // 通过指针修改data的值
// 现在data变成了0xAA

在嵌入式里,指针最常见的用途是访问外设寄存器。比如:

// 将0x40020000地址映射为GPIO寄存器结构体指针
#define GPIOA ((GPIO_TypeDef *)0x40020000)

// 然后就可以用结构体方式访问寄存器
GPIOA->ODR |= (1U << 5);  // PA5输出高电平
我曾经踩过的坑: 指针没有初始化就使用。野指针会导致硬件错误(HardFault),在汽车上就是系统崩溃。记住:定义指针时要么赋初值,要么赋NULL。

2.5.2 函数指针:回调机制的基石

函数指针在ECU开发中常用于中断回调、定时器回调。比如:

// 定义函数指针类型
typedef void (*TimerCallback_t)(void);

// 注册回调函数
void timer_registerCallback(TimerCallback_t cb) {
    // 保存回调地址
}

// 使用示例
void myTimerHandler(void) {
    // 定时器触发时执行
}
timer_registerCallback(myTimerHandler);

你看,这样就把定时器的触发逻辑和具体处理逻辑解耦了。想换处理函数?换个参数就行。

2.6 结构体:组织数据的利器

结构体把相关的数据打包在一起。在ECU里,一个传感器的所有信息都可以用一个结构体表示:

typedef struct {
    uint16_t rawValue;      // 原始ADC值
    int16_t  temperature;   // 转换后的温度值(单位0.1℃)
    uint8_t  status;        // 传感器状态
    uint32_t timestamp;     // 采样时间戳
} SensorData_t;

2.6.1 结构体指针与传参

在函数间传递结构体时,永远用指针。为什么?因为结构体可能很大,传值会导致栈溢出。我见过一个新人把包含1024字节数组的结构体传值,结果栈直接爆了。

// 正确做法:传指针
void processSensorData(SensorData_t *data) {
    // 处理数据
    data->temperature = convertAdcToTemp(data->rawValue);
}

// 调用
SensorData_t sensor;
processSensorData(&sensor);

2.6.2 结构体对齐:看不见的内存浪费

结构体成员在内存中不是紧密排列的。编译器为了访问效率,会在成员之间插入填充字节。比如:

typedef struct {
    uint8_t  a;  // 1字节
    uint32_t b;  // 4字节
    uint16_t c;  // 2字节
} BadStruct_t;   // 实际占用12字节!

优化方法:按成员大小从大到小排列:

typedef struct {
    uint32_t b;  // 4字节
    uint16_t c;  // 2字节
    uint8_t  a;  // 1字节
} GoodStruct_t;  // 实际占用8字节,省了4字节
我的经验: 在定义通信协议的数据结构时,一定要用#pragma pack(1)强制1字节对齐,否则结构体大小跟协议定义不一致,通信会直接乱掉。我曾经因为这个,在CANoe上抓了一整天报文才发现问题。

小结

这一章的内容,说白了就是嵌入式C的“内功心法”。数据类型让你精准控制内存,运算符让你操作硬件,流程控制让你构建状态机,函数让你模块化,指针让你直接操作内存,结构体让你组织数据。

这些基础打牢了,后面学RTOS、学CAN协议、学AUTOSAR,都会轻松很多。下一章,咱们聊聊嵌入式开发中最重要的调试手段——串口和日志。到时候见。