3. C语言基础复习(上):数据类型、运算符、流程控制、数组与指针

各位同学,咱们今天聊聊C语言基础。说实话,很多嵌入式新手觉得C语言就是写写流水灯、算算加减法。但我在嵌入式这行摸爬滚打十几年,可以负责任地告诉你:C语言基础不牢,后面写驱动、调协议栈、搞RTOS,处处都是坑。

这一章咱们只讲干货。数据类型、运算符、流程控制、数组与指针,这四个东西是嵌入式C的骨架。你想想看,一个单片机程序,无非就是定义变量存数据,用运算符做运算,用流程控制决定下一步干啥,再用数组和指针去操作内存。嗯,就这么简单。

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

嵌入式系统资源有限。我见过有人用int存一个开关状态,结果一个int占4字节,而一个开关只需要1位。浪费啊!

C语言的基本数据类型,咱们得烂熟于心:

类型 关键字 典型大小(8位MCU) 取值范围
字符型 char 1字节 -128 ~ 127 或 0~255
短整型 short 2字节 -32768 ~ 32767
整型 int 2字节(8位机)或4字节(32位机) 取决于编译器
长整型 long 4字节 -2^31 ~ 2^31-1
无符号整型 unsigned int 同int 0 ~ 65535(16位)
浮点型 float 4字节 约±3.4e38

重要提醒:嵌入式开发中,我建议你养成用的习惯。比如uint8_t、uint16_t、uint32_t。为什么?因为int在不同平台大小不一样。你写个int,在STM32上是4字节,在51单片机上可能只有2字节。用stdint.h,代码移植性直接拉满。

我的习惯:能用uint8_t绝不用int。省内存是一方面,更重要的是——代码一看就知道这个变量最大不会超过255,逻辑清晰。

3.2 运算符:别小看位运算

运算符这块,加减乘除大家都会。但嵌入式里,位运算才是亲儿子。为什么?因为单片机寄存器操作,说白了就是置位、清零、取反、移位。

常用的位运算符:

  • & 按位与 —— 常用于清零某几位
  • | 按位或 —— 常用于置位某几位
  • ^ 按位异或 —— 常用于翻转某几位
  • ~ 按位取反 —— 常用于构造掩码
  • << 左移 —— 相当于乘以2的n次方
  • >> 右移 —— 相当于除以2的n次方

举个例子,你想把GPIOB端口的第3位输出高电平。寄存器操作一般是:

// 置位第3位,其他位不变
GPIOB->ODR |= (1 << 3);

我曾经遇到一个新手,他用加法去实现这个功能。结果因为之前的状态不确定,加法把其他位也改了。嗯,这就是不懂位运算的代价。

避坑指南:我曾经在调试一个I2C驱动时,发现SCL时钟总是不对。查了半天,原来是我用|=去置位一个位,但那个位之前被其他中断修改过。记住:位运算不是原子操作!在多中断环境下,要用临界区保护。

3.3 流程控制:if-else 和 switch-case 的选择

流程控制,说白了就是让程序知道什么时候该干什么。if-else和switch-case是最常用的。

我个人习惯:

  • 分支少于3个,用if-else。简单直观。
  • 分支多于3个,且条件是离散值(比如按键值、状态机状态),用switch-case。代码更清晰,编译器也容易优化成跳转表。

举个例子,一个简单的按键扫描:

uint8_t key_value = read_key();

switch(key_value) {
    case KEY_UP:
        volume_up();
        break;
    case KEY_DOWN:
        volume_down();
        break;
    case KEY_MENU:
        enter_menu();
        break;
    default:
        // 无效按键,忽略
        break;
}

你想想看,如果用if-else写,是不是又长又丑?而且switch-case在编译后,很多编译器会生成跳转表,执行效率比一串if-else高得多。

小技巧:写switch-case时,别忘了加default分支。哪怕你觉得所有情况都覆盖了,也加上。为什么?因为代码维护时,别人可能新增了一个case,忘了改default。我吃过这个亏,那次是产品量产了才发现某个按键没反应……

3.4 数组:连续内存的威力

数组,就是一堆相同类型的数据排排坐。在嵌入式里,数组最常见的用途是:

  • 存放采样数据(比如ADC采集的1024个点)
  • 作为缓冲区(比如UART接收缓冲)
  • 查表法(比如正弦波表、CRC表)

定义数组很简单:

uint16_t adc_buffer[1024];  // 1024个ADC采样值
char uart_rx_buf[64];       // 64字节的接收缓冲

但要注意,数组下标从0开始。我见过有人写adc_buffer[1024],结果数组只有1024个元素,下标0~1023。访问下标1024?那是越界访问,轻则数据错乱,重则程序跑飞。

重要:数组名就是首地址。比如adc_buffer等价于&adc_buffer[0]。这个特性,是连接数组和指针的桥梁。

3.5 指针:C语言的灵魂

指针,是C语言最难啃的骨头,也是嵌入式开发最强大的工具。说白了,指针就是地址。你定义一个变量,它在内存中有个地址。指针就是存这个地址的变量。

基本用法:

uint8_t data = 0x55;
uint8_t *ptr = &data;  // ptr指向data的地址

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

在嵌入式里,指针的典型应用:

  • 访问外设寄存器:比如把某个地址强转成指针,直接读写寄存器
  • 动态内存管理:malloc/free,但嵌入式里慎用,容易内存碎片
  • 函数指针:实现回调机制,比如定时器中断回调
  • 数组与指针的互换:*(array + i)等价于array[i]

举个例子,直接操作寄存器:

// 假设GPIOB_ODR的地址是0x40010C0C
#define GPIOB_ODR  ((volatile uint32_t *)0x40010C0C)

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

这里volatile关键字很重要。它告诉编译器:这个变量可能被硬件修改,别给我优化掉了。我曾经在优化等级开-O2时,忘了加volatile,结果寄存器写操作被编译器当成无用代码删掉了。嗯,那次排查了整整一天。

避坑指南:指针一定要初始化!野指针是嵌入式程序崩溃的头号杀手。我习惯在定义指针时就赋初值,要么指向合法地址,要么赋NULL。使用前判断一下:

uint8_t *ptr = NULL;

// 使用前检查
if(ptr != NULL) {
    *ptr = 0x55;
} else {
    // 错误处理
}

3.6 数组与指针的暧昧关系

数组和指针,其实是一对好基友。数组名在大多数情况下会被隐式转换成指向首元素的指针。但有一个例外:sizeof运算符。

uint8_t arr[10];
uint8_t *p = arr;

sizeof(arr);  // 结果是10,整个数组的大小
sizeof(p);    // 结果是4(32位系统),指针本身的大小

这个区别很重要。我见过有人写函数参数传数组,然后在函数里用sizeof想获取数组长度,结果只得到指针大小。正确的做法是:

void process_array(uint8_t *arr, uint16_t len) {
    for(uint16_t i = 0; i < len; i++) {
        arr[i] = i * 2;
    }
}

// 调用时
uint8_t my_arr[20];
process_array(my_arr, sizeof(my_arr));

嗯,把长度作为参数传进去,一劳永逸。

我的经验:在嵌入式里,我几乎不用多维数组。为什么?因为内存是线性的,多维数组在内存里也是按行优先存储。我更喜欢用一维数组,然后自己计算偏移量:arr[row * cols + col]。这样代码更灵活,也更容易理解内存布局。

3.7 总结与练习

这一章咱们复习了C语言最核心的基础。数据类型要选对,位运算要熟练,流程控制要清晰,数组和指针要理解透彻。这些东西,你写每一行嵌入式代码都会用到。

给你留个小练习:

  1. 定义一个uint8_t类型的数组,存放10个ADC采样值
  2. 用指针遍历这个数组,找出最大值和最小值
  3. 把最大值和最小值通过位运算打包成一个uint16_t(高8位是最大值,低8位是最小值)

这个练习涵盖了数据类型、数组、指针、位运算。做完了,你这一章就算过关了。

下一章咱们继续聊函数、结构体、内存管理。这些东西在嵌入式里同样重要。准备好了吗?


公众号:蓝海资料掘金营,微信deep3321