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 实战经验总结
好了,讲了这么多,我最后给你总结几条铁律:
- 指针操作数组时,注意边界。 我见过有人把数组越界当成“小问题”,结果把栈给踩了,程序跑飞。嵌入式里没有操作系统保护,越界就是死机。
- const 和 volatile 可以一起用。 比如只读的硬件寄存器:
const volatile uint32_t *reg = (uint32_t *)0x4000;。const 表示你不能写它,volatile 表示它可能被硬件改变。 - 位运算优先用无符号数。 有符号数的右移是算术右移,会补符号位,容易出问题。我习惯所有位运算都用 uint32_t 或 uint8_t。
- 结构体映射寄存器时,一定要确认对齐。 有些编译器会默认4字节对齐,导致结构体大小和实际寄存器布局不一致。可以用
__attribute__((packed))强制紧凑排列。
最后说一句: 嵌入式C语言,说白了就是“用代码控制硬件”。你写的每一行代码,最终都会变成电信号在芯片里跑。理解底层原理,不是为了炫技,而是为了写出更可靠、更高效的代码。嗯,这一章就到这里,下一章我们开始讲真正的控制算法实现。