4. C语言基础复习(下):函数、结构体、枚举、位操作
好,咱们接着聊。上一节我们把指针、数组、字符串这些硬骨头啃了一遍。这一节,咱们把剩下的几个关键知识点收个尾:函数、结构体、枚举,还有位操作。
这几个东西,在嵌入式开发里,尤其是写驱动、操作寄存器的时候,几乎是天天见。你想想看,一个单片机程序,本质上就是一堆函数在互相调用,然后用结构体来管理各种外设的配置参数,再用位操作去精准地控制某个引脚的电平。
4.1 函数:代码的积木块
函数这东西,说白了就是把一段逻辑封装起来,给它起个名字,方便重复调用。我刚开始学的时候,总觉得写函数麻烦,所有代码都塞在main里。结果一个项目下来,几千行代码挤在一起,找个bug找得我头皮发麻。
后来我学乖了。一个函数只做一件事,做好一件事。这就是所谓的“单一职责原则”。
4.1.1 函数的声明与定义
在C语言里,函数要先声明,后使用。声明就是告诉编译器:“嘿,我这里有个函数,长这样,你先记着。” 定义才是真正的实现。
// 声明
int add(int a, int b);
// 定义
int add(int a, int b) {
return a + b;
}
嗯,这里要注意:如果你把定义写在调用之前,那声明可以省略。但为了代码清晰,我建议你不管三七二十一,都在文件头部或者头文件里先声明一遍。这样别人看你的代码,一眼就知道这个文件提供了哪些功能。
4.1.2 参数传递:值传递与地址传递
这是个大坑。C语言里,函数参数默认是值传递。什么意思?就是你把一个变量丢进函数,函数拿到的是这个变量的一个副本。你在函数里怎么改,都不影响外面的原变量。
void swap_wrong(int a, int b) {
int temp = a;
a = b;
b = temp;
// 这里交换的是副本,外面的a和b纹丝不动
}
void swap_right(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
// 通过指针直接操作内存里的原数据
}
我在项目中遇到过好几次这种bug。调试的时候明明看到函数内部交换了,一出来值没变。当时我还怀疑是编译器出了问题,后来才反应过来——我传的是值,不是地址。
4.2 结构体:把数据捆在一起
结构体,就是把不同类型的数据打包成一个整体。在嵌入式里,这玩意儿太有用了。比如你要描述一个传感器的状态:有温度、湿度、还有状态标志。你总不能定义三个独立的变量吧?用结构体,一目了然。
struct SensorData {
float temperature;
float humidity;
uint8_t status; // 0:正常, 1:异常
};
4.2.1 结构体的定义与使用
定义结构体类型后,就可以用它来声明变量了。
struct SensorData sensor1;
sensor1.temperature = 25.6;
sensor1.humidity = 60.2;
sensor1.status = 0;
我个人习惯用 typedef 给结构体起个短名字,省得每次都要写 struct 关键字。
typedef struct {
float temperature;
float humidity;
uint8_t status;
} SensorData_t;
SensorData_t sensor2;
sensor2.temperature = 30.1;
4.2.2 结构体指针
在嵌入式里,结构体往往很大,如果按值传递,会复制整个结构体,既占内存又费时间。所以,我们通常传结构体指针。
void print_sensor(const SensorData_t *s) {
printf("温度: %.1f, 湿度: %.1f\n", s->temperature, s->humidity);
// 注意:用指针访问成员,用 -> 符号
}
这里用 const 修饰指针,意思是“我只读,不改你”。这是一种好习惯,能防止不小心修改了数据。
4.3 枚举:给数字起个名字
枚举,说白了就是给一堆整数常量起个有意义的名称。比如你要表示一周的七天,用 0,1,2,3,4,5,6 当然可以,但代码里到处都是魔法数字,过两天你自己都看不懂。
enum Weekday {
MONDAY, // 默认0
TUESDAY, // 1
WEDNESDAY, // 2
THURSDAY, // 3
FRIDAY, // 4
SATURDAY, // 5
SUNDAY // 6
};
在嵌入式里,我常用枚举来表示状态机的各种状态。
typedef enum {
STATE_IDLE,
STATE_INIT,
STATE_RUNNING,
STATE_ERROR
} SystemState_t;
SystemState_t current_state = STATE_IDLE;
你看,代码的可读性一下子就上来了。别人一看 STATE_RUNNING,就知道系统在运行中。比直接写个 2 强多了。
enum { ERR_NONE = 0, ERR_TIMEOUT = 100, ERR_CRC = 200 };
4.4 位操作:嵌入式工程师的看家本领
终于到了这个重头戏。位操作,是嵌入式开发里最基础也最重要的技能之一。你操作寄存器、控制GPIO、处理通信协议,都离不开它。
说白了,位操作就是直接对二进制位进行运算。C语言提供了6种位运算符:&(与)、|(或)、^(异或)、~(取反)、<<(左移)、>>(右移)。
4.4.1 常用的位操作技巧
我整理了一个表格,把最常用的几种操作列出来,你直接照着用就行。
| 操作 | 表达式 | 说明 |
|---|---|---|
| 置位(设为1) | reg |= (1 << n); |
将第n位设为1,其他位不变 |
| 清零(设为0) | reg &= ~(1 << n); |
将第n位设为0,其他位不变 |
| 翻转 | reg ^= (1 << n); |
将第n位取反 |
| 读取某位 | bit = (reg >> n) & 1; |
获取第n位的值(0或1) |
| 连续位清零 | reg &= ~(0xFF << n); |
将从第n位开始的8位清零 |
举个例子,假设你要控制一个LED灯,它连接在GPIOB的第5脚。你要让它亮,就是给那个引脚输出高电平。
// 假设 GPIOB->ODR 是输出数据寄存器
// 置位第5位,点亮LED
GPIOB->ODR |= (1 << 5);
// 清零第5位,熄灭LED
GPIOB->ODR &= ~(1 << 5);
你看,就这么简单。但要注意,千万不要写成 GPIOB->ODR = 1 << 5;,那样会把其他位全部清零,导致其他引脚输出异常。我刚开始就犯过这个错,结果一操作,整个端口的输出都乱了。
4.4.2 位域:结构体里的位操作
C语言还提供了一种更高级的位操作方式——位域。它允许你在结构体里,按位来定义成员变量的大小。
typedef struct {
uint8_t enable : 1; // 只占1位
uint8_t mode : 2; // 占2位
uint8_t status : 3; // 占3位
uint8_t reserved : 2; // 保留2位
} ControlReg_t;
这个结构体总共只占1个字节(8位)。你可以像访问普通结构体成员一样,直接操作这些位域。
ControlReg_t reg;
reg.enable = 1;
reg.mode = 2;
嗯,这里要提醒一下:位域虽然方便,但它的内存布局是编译器相关的。不同编译器,甚至同一个编译器的不同优化选项,都可能导致位域的排列顺序不一样。所以,在跨平台或者需要精确控制寄存器布局的场合,我建议你还是用宏定义加位操作的方式,更可靠。
#define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))。这样代码既清晰又高效,而且移植性好。
好了,这一节的内容就到这里。函数、结构体、枚举、位操作,这四个东西是嵌入式C语言的基石。你写任何嵌入式程序,都离不开它们。下一节,我们就要开始真正接触嵌入式系统的核心——中断与定时器了。做好准备,那才是真正精彩的部分。