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语言不会提醒你,程序可能跑着跑着就崩了,或者更糟——它看起来正常,但数据已经乱了。
在嵌入式里,数组最常见的用途就是存放采样数据。比如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与寄存器操作。到时候你会真正感受到,指针和结构体在硬件层面有多强大。