1. 嵌入式C语言基础:指针与数组的底层原理、const与volatile关键字、位运算操作、结构体与联合体在寄存器编程中的应用

各位同学,欢迎来到嵌入式控制算法实战的第一课。

说实话,我见过太多工程师,算法推得飞起,结果一上单片机就翻车。为什么?C语言基础不扎实。尤其是嵌入式底层的那些“坑”,你不踩一遍,永远不知道代码是怎么死的。

今天这一章,咱们就把嵌入式C语言里最核心、也最容易出问题的几个点,掰开了揉碎了讲清楚。我保证,不扯虚的,全是实战经验。

1.1 指针与数组:其实它们没那么神秘

很多新手觉得指针难,其实说白了,指针就是一个“存放地址的变量”。你想想看,我们平时用变量存数字,用指针存的就是那个数字在内存里的门牌号。

核心理解:

  • int a = 10; —— 我申请了一个房间,里面放了数字10。
  • int *p = &a; —— 我记下了这个房间的门牌号。
  • *p = 20; —— 我通过门牌号进去,把里面的数字改成了20。

数组呢?数组名其实就是个指针常量。我在项目中遇到过有人问:“为什么数组名不能自增?” 嗯,因为它是常量啊,你总不能把门牌号本身给改了吧?

// 数组名是首地址,不能修改
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;   // 等价于 int *p = &arr[0];

// 下面这行会报错:arr++ 是非法的
// arr++;  

// 但指针可以
p++;  // 现在 p 指向 arr[1]

我的小技巧: 在嵌入式里操作数据缓冲区时,我习惯用指针而不是数组下标。因为指针自增的效率往往比 arr[i] 的索引计算要高那么一点点。别小看这一点,在中断里跑高频循环时,差别就出来了。

1.2 const 与 volatile:两个容易被忽略的“保命符”

这两个关键字,我建议你把它刻在脑子里。尤其是 volatile,我见过太多线上事故,就是因为忘了加它。

1.2.1 const:不只是“只读”

const 修饰的变量,不是说它一定存在 ROM 里,而是告诉编译器:“哥们,这玩意儿你别试图去改它。” 我个人的习惯是,所有不会变的参数,比如系统时钟频率、PID 系数表,统统加上 const。这样既能防止手滑改错,又能让编译器帮你优化。

// 查表法算正弦波,表不会变
const int16_t sin_table[256] = {
    0, 804, 1608, ...  // 实际数据省略
};

// 如果试图修改,编译器会报错
// sin_table[0] = 100;  // 错误!

1.2.2 volatile:告诉编译器“别自作聪明”

volatile 的意思是“易变的”。它告诉编译器:这个变量的值可能在程序流程之外被改变,比如硬件寄存器、中断服务程序里修改的变量。

我曾经踩过的坑: 有一次调试一个电机驱动,中断里更新了一个标志位,主循环里判断这个标志。结果优化等级一开,死活进不去。查了两天,发现是编译器觉得那个标志位在循环里没被修改,直接优化掉了。加上 volatile 后,问题立刻解决。

// 中断里用的标志位,必须加 volatile
volatile uint8_t g_timer_flag = 0;

void TIM2_IRQHandler(void) {
    if (TIM_GetITStatus(TIM2, TIM_IT_Update)) {
        g_timer_flag = 1;  // 硬件修改了这个值
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
    }
}

int main(void) {
    while(1) {
        if (g_timer_flag) {  // 不加 volatile,这里可能永远读不到1
            g_timer_flag = 0;
            // 执行定时任务
        }
    }
}

记住这个口诀: 硬件相关的变量、中断共享的变量、多任务共享的变量,一律加 volatile。宁可多写,不要漏写。

1.3 位运算操作:嵌入式工程师的“瑞士军刀”

位运算,说白了就是直接操作二进制位。在嵌入式里,寄存器操作、状态标志判断、数据压缩,哪哪都离不开它。

我个人最常用的几个操作:

操作 含义 示例
& 按位与(清零某位) REG &= ~(1 << 3); // 清零第3位
| 按位或(置位某位) REG |= (1 << 5); // 置位第5位
^ 按位异或(翻转某位) REG ^= (1 << 2); // 翻转第2位
~ 按位取反 REG &= ~(0x0F << 4); // 清零4-7位
<< / >> 左移/右移 val = (data >> 8) & 0xFF; // 取高8位
// 实际寄存器操作:配置GPIO输出模式
// 假设 GPIO_CRL 寄存器,低4位控制一个引脚
#define GPIO_CRL   (*(volatile uint32_t *)0x40010800)

// 将第0个引脚配置为推挽输出(模式:0011)
GPIO_CRL &= ~(0x0F << 0);   // 先清零
GPIO_CRL |= (0x03 << 0);    // 再置位

我的习惯: 写位操作时,一定要用宏定义把位掩码写好。别在代码里直接写数字,不然三个月后你自己都看不懂。比如 #define PIN0_MASK (0x0F << 0),这样一看就明白。

1.4 结构体与联合体:寄存器编程的“神器”

结构体和联合体,在嵌入式里最大的用处就是——操作寄存器。你想想看,一个外设可能有几十个寄存器,每个寄存器又有不同的位域。如果用一堆宏定义去操作,代码会变得又臭又长。

我习惯的做法是:用结构体把寄存器组映射起来,用联合体位域来访问寄存器的具体位。

1.4.1 结构体映射寄存器组

// 以STM32的USART为例
typedef struct {
    volatile uint32_t SR;    // 状态寄存器,偏移0x00
    volatile uint32_t DR;    // 数据寄存器,偏移0x04
    volatile uint32_t BRR;   // 波特率寄存器,偏移0x08
    volatile uint32_t CR1;   // 控制寄存器1,偏移0x0C
    volatile uint32_t CR2;   // 控制寄存器2,偏移0x10
    volatile uint32_t CR3;   // 控制寄存器3,偏移0x14
    volatile uint32_t GTPR;  // 保护时间和预分频器,偏移0x18
} USART_TypeDef;

// 然后直接映射到地址
#define USART1    ((USART_TypeDef *)0x40013800)
#define USART2    ((USART_TypeDef *)0x40004400)

// 使用起来非常直观
USART1->DR = 'A';           // 发送一个字符
while(!(USART1->SR & (1 << 6))); // 等待发送完成

1.4.2 联合体位域:精确控制每一位

有时候我们需要单独操作一个寄存器的某一位或某几位。用位运算当然可以,但代码可读性差。联合体+位域结构体,是更好的选择。

// 定义一个联合体,既可以按字节访问,也可以按位访问
typedef union {
    uint8_t byte;  // 整体访问
    struct {
        uint8_t bit0 : 1;
        uint8_t bit1 : 1;
        uint8_t bit2 : 1;
        uint8_t bit3 : 1;
        uint8_t bit4 : 1;
        uint8_t bit5 : 1;
        uint8_t bit6 : 1;
        uint8_t bit7 : 1;
    } bits;        // 按位访问
} REG_BYTE_t;

// 使用示例
REG_BYTE_t *p_reg = (REG_BYTE_t *)0x40010800;
p_reg->bits.bit3 = 1;  // 只置位第3位,不影响其他位
uint8_t val = p_reg->byte;  // 读取整个寄存器

注意: 位域的顺序和内存布局是编译器相关的。不同MCU的编译器可能不一样。我建议你在使用前,先确认一下编译器的位域分配规则。否则,你以为操作的是bit0,实际上可能是bit7。

1.5 实战经验总结

好了,讲了这么多,我最后给你总结几条铁律:

  1. 指针操作数组时,注意边界。 我见过有人把数组越界当成“小问题”,结果把栈给踩了,程序跑飞。嵌入式里没有操作系统保护,越界就是死机。
  2. const 和 volatile 可以一起用。 比如只读的硬件寄存器:const volatile uint32_t *reg = (uint32_t *)0x4000;。const 表示你不能写它,volatile 表示它可能被硬件改变。
  3. 位运算优先用无符号数。 有符号数的右移是算术右移,会补符号位,容易出问题。我习惯所有位运算都用 uint32_t 或 uint8_t。
  4. 结构体映射寄存器时,一定要确认对齐。 有些编译器会默认4字节对齐,导致结构体大小和实际寄存器布局不一致。可以用 __attribute__((packed)) 强制紧凑排列。

最后说一句: 嵌入式C语言,说白了就是“用代码控制硬件”。你写的每一行代码,最终都会变成电信号在芯片里跑。理解底层原理,不是为了炫技,而是为了写出更可靠、更高效的代码。嗯,这一章就到这里,下一章我们开始讲真正的控制算法实现。