4、C语言基础回顾(下):数组、指针、结构体、函数、预处理指令

好,咱们接着聊。上一节我们把C语言的基本语法和流程控制过了一遍,那些是「招式」。今天要讲的这几个东西——数组、指针、结构体、函数、预处理指令——才是真正的「内功心法」。嵌入式开发里,你写的每一行代码几乎都离不开它们。

我个人习惯把这一节叫做「C语言的脊梁」。为什么?因为你在操作寄存器、管理内存、传递数据时,全靠这些家伙撑场面。咱们一个一个来。

4.1 数组:连续内存的「集装箱」

数组说白了就是一堆相同类型的数据,在内存里挨个排好队。你给它们一个统一的名字,通过下标来访问。

// 定义一个存放5个整数的数组
int sensor_values[5] = {23, 45, 12, 67, 89};

// 访问第3个元素(下标从0开始)
int temp = sensor_values[2];  // 结果是12

这里有个坑,我刚开始做项目时踩过。 数组下标从0开始,不是从1。你写 sensor_values[5] 访问第6个元素?不好意思,那是越界访问。C语言不会提醒你,程序可能跑着跑着就崩了,或者更糟——它看起来正常,但数据已经乱了。

⚠️ 避坑指南: 我曾经在一个温控项目里,因为数组越界写坏了相邻的变量,导致PID控制参数被篡改,加热器直接满功率运行了半小时。幸好有硬件看门狗,不然就冒烟了。记住:数组下标永远从0开始,永远不要越界。

在嵌入式里,数组最常见的用途就是存放采样数据。比如ADC连续采集100个点,存到数组里,然后做滤波处理。

4.2 指针:C语言的「灵魂」

指针是什么?说白了,它就是地址。一个变量存的是数值,一个指针变量存的是另一个变量的内存地址。

很多初学者觉得指针难,其实你换个角度想:你家住在「幸福路100号」,这个地址就是指针。你通过这个地址找到你家,就是「解引用」。

int value = 42;
int *ptr = &value;   // ptr 存的是 value 的地址

printf("%d\n", *ptr);  // 输出 42,这就是解引用

为什么指针在嵌入式里这么重要? 三个原因:

  • 直接操作硬件寄存器:单片机的GPIO、定时器、串口,都是通过内存映射的寄存器来控制的。你要读写这些寄存器,就必须用指针。
  • 动态内存管理:虽然嵌入式里malloc用得少,但RTOS的任务栈、消息队列,底层全是指针在玩。
  • 高效传递数据:传一个结构体给函数,如果传值,要拷贝整个结构体;如果传指针,只传4个字节(32位机)。效率天差地别。

💡 核心要点: 指针就是地址,地址就是编号。32位单片机的地址范围是0x00000000到0xFFFFFFFF,每个地址对应一个字节。你操作指针,就是在操作内存本身。

来看一个操作寄存器的真实例子:

// 假设GPIOB端口的输出数据寄存器地址是0x40010C0C
#define GPIOB_ODR  (*(volatile unsigned int *)0x40010C0C)

// 让PB5输出高电平
GPIOB_ODR |= (1 << 5);

这行代码看着复杂,其实拆开看就明白了:0x40010C0C 是个地址,(unsigned int *) 把它转成指针,volatile 告诉编译器别优化这个地址的访问(因为硬件随时可能改变它),最前面的 * 就是解引用,操作这个地址里的内容。

🔧 我的经验: 写寄存器操作时,volatile 这个关键字绝对不能省。我曾经在一个电机驱动项目里,因为忘了加 volatile,编译器把寄存器读取优化掉了,结果电机转速死活不对。排查了两天才找到原因。

4.3 结构体:把数据「打包」

数组只能存同一类型的数据。但现实世界的数据往往是复合的——比如一个传感器节点,有ID、温度值、时间戳、状态标志。这时候就需要结构体了。

struct SensorNode {
    uint8_t  id;        // 节点ID
    float    temp;      // 温度值
    uint32_t timestamp; // 时间戳
    uint8_t  status;    // 状态:0=正常,1=告警
};

// 定义一个结构体变量
struct SensorNode node1 = {0x01, 25.6, 12345678, 0};

// 访问成员
node1.temp = 26.8;

结构体在嵌入式里有个特别重要的用法:定义寄存器映射。 比如一个定时器有十几个寄存器,你可以用一个结构体把它们组织起来:

typedef struct {
    volatile uint32_t CR1;    // 控制寄存器1
    volatile uint32_t CR2;    // 控制寄存器2
    volatile uint32_t SMCR;   // 从模式控制寄存器
    volatile uint32_t DIER;   // 中断使能寄存器
    volatile uint32_t SR;     // 状态寄存器
    volatile uint32_t CNT;    // 计数器值
    volatile uint32_t PSC;    // 预分频器
    volatile uint32_t ARR;    // 自动重装载值
} TIM_TypeDef;

// 然后直接映射到内存地址
#define TIM2  ((TIM_TypeDef *)0x40000000)

这样写代码就清晰多了:TIM2->ARR = 999; 比直接写地址好理解一万倍。

⚠️ 注意: 结构体在内存里可能有对齐问题。比如一个 char 后面跟一个 int,编译器可能会在中间填充几个字节。这在定义寄存器映射时是致命的——因为寄存器的地址是固定的,不能有填充。解决办法是使用 __attribute__((packed)) 来禁止对齐。

4.4 函数:代码的「积木块」

函数就是把一段逻辑封装起来,给它取个名字,需要的时候调用就行。嵌入式里的函数,我习惯分成三类:

  • 初始化函数:比如 GPIO_Init()UART_Init(),只在启动时调用一次。
  • 业务逻辑函数:比如 Read_Temperature()Send_Data(),在主循环里反复调用。
  • 中断服务函数:由硬件触发,不能由程序主动调用。
// 一个典型的温度读取函数
float Read_Temperature(void) {
    uint16_t adc_value;
    float temperature;
    
    adc_value = ADC_GetValue();      // 读取ADC值
    temperature = (adc_value * 3.3f / 4096.0f) * 100.0f;  // 换算成温度
    
    return temperature;
}

关于函数参数传递,我建议你记住一条原则: 如果参数是基本类型(int、char、float),传值就行;如果参数是数组或结构体,传指针。原因前面说过了——效率。

🔧 我的习惯: 函数名用「动词+名词」的格式,比如 Set_PWM_Duty()Get_Button_State()。这样看代码就像在读英文句子,可读性极好。另外,每个函数最好只做一件事,不要搞「万能函数」。

4.5 预处理指令:编译前的「魔法」

预处理指令是在编译之前处理的,说白了就是文本替换。最常用的三个:#define#include、条件编译。

#define 不只是定义常量

// 定义常量
#define LED_PIN  5

// 定义宏函数
#define MIN(a, b)  ((a) < (b) ? (a) : (b))

// 注意:宏函数里的参数一定要加括号
#define SQUARE(x)  ((x) * (x))

为什么参数要加括号?你想想看,如果写 SQUARE(1+2),不加括号就变成了 1+2*1+2,结果是5,不是9。这种bug非常隐蔽,我见过不止一个工程师在这里翻车。

条件编译:一套代码,多种硬件

嵌入式项目经常要适配不同型号的芯片。条件编译就是干这个的:

#define CHIP_TYPE  STM32F103

#if CHIP_TYPE == STM32F103
    #define FLASH_SIZE  512
#elif CHIP_TYPE == STM32F407
    #define FLASH_SIZE  1024
#else
    #error "Unsupported chip type!"
#endif

这样你只需要维护一套代码,换芯片时改一个宏定义就行。

💡 避坑指南: 我曾经在一个项目里用 #define 定义了一个跟库函数同名的宏,结果编译报错,排查了半天才发现是命名冲突。所以我的习惯是:自己定义的宏全部用大写字母,并且加上项目前缀,比如 MY_PROJ_LED_PIN

4.6 把它们串起来:一个完整的例子

说了这么多,咱们来个综合的例子。假设你要读取一个温度传感器的值,并通过串口发送出去:

#include "stm32f10x.h"   // 芯片头文件

// 宏定义
#define TEMP_CHANNEL  1
#define UART_BAUDRATE 115200

// 结构体:保存传感器数据
typedef struct {
    float    temperature;
    uint32_t timestamp;
    uint8_t  valid_flag;
} SensorData_t;

// 函数声明
void System_Init(void);
SensorData_t Read_Temperature(void);
void Send_Data_UART(SensorData_t *data);

// 全局变量
SensorData_t g_sensor_data;

int main(void) {
    System_Init();
    
    while(1) {
        g_sensor_data = Read_Temperature();
        Send_Data_UART(&g_sensor_data);
        
        // 延时1秒
        for(uint32_t i = 0; i < 1000000; i++);
    }
}

void System_Init(void) {
    // 初始化GPIO、ADC、UART等外设
    // 具体代码省略...
}

SensorData_t Read_Temperature(void) {
    SensorData_t data;
    uint16_t adc_val;
    
    adc_val = ADC_GetValue(TEMP_CHANNEL);
    data.temperature = (adc_val * 3.3f / 4096.0f) * 100.0f;
    data.timestamp = Get_System_Tick();
    data.valid_flag = 1;
    
    return data;
}

void Send_Data_UART(SensorData_t *data) {
    // 通过指针访问结构体成员
    UART_SendFloat(data->temperature);
    UART_SendString(" °C\n");
}

你看,这个例子里数组、指针、结构体、函数、预处理指令全用上了。这就是嵌入式开发的日常——用这些基础工具,搭出复杂的系统。

📌 最后说一句: C语言基础这部分,我建议你多写、多练。光看是学不会的。找个开发板,把今天讲的这些概念一个一个敲出来,跑起来。遇到bug不要怕,那是你进步最快的时候。

下一节我们开始讲嵌入式开发的核心——GPIO与寄存器操作。到时候你会真正感受到,指针和结构体在硬件层面有多强大。