3. C语言基础回顾:指针与数组的深层理解、结构体与联合体、volatile关键字、位操作技巧
好,咱们直接进入正题。这一章是给后面所有图像处理算法打地基的。你别看这些知识点基础,我在嵌入式图像处理这行干了十几年,发现很多bug最后追根溯源,都是这些基础没吃透。尤其是做无操作系统开发,你手里就一个裸机,一个编译器,出了问题连个调试器都未必好使。所以,这一章我带你把这些坑提前踩一遍。
3.1 指针与数组:不只是“指向”那么简单
很多新手觉得指针就是地址,数组就是连续内存。这话没错,但太浅了。在图像处理里,一张640x480的灰度图,就是480个数组,每个数组640个字节。你操作像素,本质上就是在玩指针偏移。
3.1.1 数组名真的是指针吗?
不是。数组名是一个常量指针。它指向数组首元素,但你没法给它赋值。比如:
int img[100];
int *p = img; // 正确,p可以指向别处
img = p; // 编译错误!img是常量
我个人习惯用 p 来遍历,而不是直接动数组名。为什么?因为数组名一旦被修改,你就再也找不到数组的起始地址了。这在做图像ROI(感兴趣区域)裁剪时特别容易犯错。
3.1.2 指针加减法的“步长”陷阱
指针加1,到底加了多少?取决于指针的类型。这是C语言最容易被忽略的“隐藏乘法”。
char *pc = (char*)0x1000;
int *pi = (int*)0x1000;
pc++; // 实际地址:0x1001
pi++; // 实际地址:0x1004(假设int占4字节)
在图像处理中,我们经常用 uint8_t* 来操作像素。但如果你不小心定义成了 uint32_t*,然后做指针加法,那偏移量就完全错了。你想想看,一个像素点占1字节,你一次跳4个字节,画面不撕裂才怪。
3.1.3 二维数组与指针数组
图像是二维的。在C语言里,有两种表示方式:
| 方式 | 定义 | 特点 |
|---|---|---|
| 二维数组 | uint8_t frame[480][640]; | 内存连续,访问快,但大小固定 |
| 指针数组 | uint8_t *rows[480]; | 每行可单独分配,灵活,但多一次间接寻址 |
我个人在裸机环境下,强烈推荐用二维数组。为什么?因为内存连续,DMA传输时只需要一个起始地址和一个长度。而指针数组,你得逐行配置DMA描述符,麻烦不说,还容易出错。
3.2 结构体与联合体:数据组织的艺术
结构体把相关数据打包,联合体让不同视角共享同一块内存。在嵌入式图像处理里,这两个东西用好了,代码能简洁一大半。
3.2.1 结构体对齐:编译器偷偷干的“好事”
你定义了一个结构体,以为它的大小就是各成员之和?太天真了。编译器会为了对齐,在成员之间插入填充字节。
struct ImageHeader {
uint16_t width; // 2字节
uint32_t height; // 4字节
uint8_t format; // 1字节
}; // 你以为7字节?实际可能是12字节!
为什么会这样?因为 height 是4字节,它必须从4的倍数地址开始。所以 width 后面被塞了2个填充字节。这在做结构体序列化、或者直接映射到硬件寄存器时,是致命的。
#pragma pack(1) 强制1字节对齐。但要注意,强制对齐后,某些CPU访问未对齐地址会触发异常。
3.2.2 联合体:同一块内存的“多副面孔”
联合体在图像处理中,最常见的用法是解析像素格式。比如一个32位的ARGB像素,你可以把它当作一个 uint32_t 整体操作,也可以拆成四个8位分量。
typedef union {
uint32_t value;
struct {
uint8_t b;
uint8_t g;
uint8_t r;
uint8_t a;
} channels;
} ARGBPixel;
ARGBPixel pixel;
pixel.value = 0xFFAABBCC;
// 现在 pixel.channels.r 就是 0xBB
嗯,这里要注意:字节序问题。在小端模式下,低地址存的是低位字节。上面的代码在x86上没问题,但在某些ARM核上,你可能需要调整结构体成员的顺序。我曾经在移植一个图像库时,就因为没注意字节序,颜色通道全反了,调试了一整个下午。
3.3 volatile关键字:别让编译器“优化”掉你的逻辑
这个关键字,在无操作系统环境下,几乎是每个变量都要考虑要不要加的。它的作用就一句话:告诉编译器,这个变量随时可能被意外改变,别给我瞎优化。
3.3.1 什么时候必须用volatile?
- 硬件寄存器映射:比如图像传感器的控制寄存器,你写一个值,硬件可能立刻改变它。
- 中断服务程序共享的变量:主循环和中断都访问同一个标志位。
- 多任务环境下的共享变量(虽然咱们现在讲无OS,但有些简单调度器也算)。
// 错误示例:没有volatile
uint8_t frame_ready = 0;
void DMA_IRQHandler() {
frame_ready = 1; // 中断中置位
}
void main() {
while (!frame_ready); // 编译器可能优化成:读取一次寄存器,然后死循环!
process_frame();
}
你想想看,如果没有 volatile,编译器发现 while 循环里没有修改 frame_ready,它可能直接把 frame_ready 的值加载到寄存器里,然后无限比较那个寄存器值。中断改了内存,但寄存器没变,程序就卡死了。
volatile 没加。打印函数恰好阻止了编译器优化,掩盖了问题。从那以后,所有与硬件相关的变量,我第一件事就是加 volatile。
3.4 位操作技巧:用最少的指令做最多的事
嵌入式环境里,资源就是一切。一个位操作,可能比乘除法快几十倍。图像处理中,像素的打包、解包、颜色空间转换,处处都是位操作。
3.4.1 常用位操作宏
我习惯把这些定义成宏,方便阅读也方便移植:
#define SET_BIT(reg, bit) ((reg) |= (1U << (bit)))
#define CLEAR_BIT(reg, bit) ((reg) &= ~(1U << (bit)))
#define READ_BIT(reg, bit) (((reg) >> (bit)) & 1U)
#define TOGGLE_BIT(reg, bit) ((reg) ^= (1U << (bit)))
// 批量操作
#define SET_BITS(reg, mask) ((reg) |= (mask))
#define CLEAR_BITS(reg, mask) ((reg) &= ~(mask))
3.4.2 图像处理中的位操作实战
比如,你要从一个16位的RGB565像素中提取红色分量(高5位):
uint16_t pixel = 0xF800; // 红色
uint8_t red = (pixel >> 11) & 0x1F; // 提取高5位
再比如,你要把三个5-6-5的分量合成一个RGB565像素:
uint16_t rgb565 = ((r & 0x1F) << 11) | ((g & 0x3F) << 5) | (b & 0x1F);
这些操作,说白了就是移位和掩码。但你要注意,移位操作在ARM Cortex-M系列上,一个时钟周期就能完成。而如果你用乘除法去算 r * 2048,那可能要几十个周期。在实时图像处理中,每一帧几百万个像素,这个差距就是天壤之别。
3.4.3 位域:结构体里的“位操作糖”
C语言允许你在结构体里定义位域,直接按位访问:
struct RGB565 {
uint16_t blue : 5;
uint16_t green : 6;
uint16_t red : 5;
};
但说实话,我个人不太推荐在嵌入式里用位域。为什么?因为位域的布局是编译器相关的。有的编译器从低位开始排,有的从高位开始排。而且位域的读写操作,编译器可能会生成多条指令,反而比手动移位慢。所以,我建议你手动写移位和掩码,虽然代码看起来啰嗦一点,但可控性高得多。