电子产业一站式赋能平台

PCB联盟网

搜索
查看: 20|回复: 0
收起左侧

单片机C语言核心:指针、结构体、位操作

[复制链接]

314

主题

314

帖子

2902

积分

三级会员

Rank: 3Rank: 3

积分
2902
发表于 昨天 10:04 | 显示全部楼层 |阅读模式
关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞

文 | 无际(微信:2777492857)
全文约3900字,阅读大约需要 10 分钟
每个工程师都会有一段看到指针、结构体、位操作就头大的经历,特别是学完C语言,还没做过几个项目,基础不扎实的时候。
           
做嵌入式开发,它们可不是什么选修课,而是必修的核心技能。
           
为啥这么说?举例几点:
           
第一,单片机的灵魂在于控制硬件。而硬件寄存器就是内存里的特定地址。不会指针,你怎么直接、高效地访问这些地址?难道每次都靠库函数封装?那遇到库没提供的功能或者需要极致优化时,咋办?
           
第二,单片机资源(内存、CPU速度)通常很宝贵。指针用得好,可以避免大量数据拷贝,提升运行效率;结构体用得妙,能清晰地组织数据,方便管理;位操作更是精准控制硬件、节省内存空间的利器。反之,你的代码可能臃肿、缓慢,甚至在资源紧张的芯片上根本跑不起来。
           
第三,良好的结构体设计和清晰的位操作(配合宏定义),能让代码逻辑一目了然,易于维护和功能迭代。
           
第四,无法深入理解驱动层、底层协议栈、操作系统内核(如果你用RTOS的话)的工作原理。这些地方大量运用了指针、结构体和位操作。掌握不了这些,你可能长期停留在“调包侠”的阶段,难以成为真正独当一面的单片机系统工程师。
           
这篇文章将用最接地气的方式,带你重新认识并掌握单片机C语言的三大核心:指针、结构体、位操作。读完它,你将能够:
               
?理解指针在单片机开发中的真正价值和应用场景。
?学会如何利用结构体优雅地管理硬件寄存器和数据。
?熟练运用位操作,对硬件进行精准到比特级别的控制。
?写出更专业、更高效、更健壮的单片机代码。
?为自己向更高阶的单片机开发之路扫清障碍。
           
系好安全带,准备发车。
           
一、指针
很多初学者怕指针,主要是怕它指向不明或者操作失误导致程序崩溃。在有内存管理单元(MMU)的系统里,这确实是个大问题。但在大多数裸跑的单片机里,内存地址是直接对应的物理地址,指针更像是一个精确的门牌号,用好了就是神器。
           
为什么单片机离不开指针?
           
1.访问硬件寄存器:这是最最核心的用途。单片机的外设(GPIO, UART, SPI, I2C, Timer等)都是通过读写特定内存地址上的寄存器来控制的。这些地址是固定的,定义在芯片的数据手册(Datasheet)里。我们必须通过指针来操作它们。   

m53kyensfru64023895006.png

m53kyensfru64023895006.png

           
           
2.高效传递参数:向函数传递大型数据结构(比如一个配置信息结构体)时,直接传值会拷贝整个结构体,既慢又浪费栈空间。传递指向该结构体的指针,只需要传递一个地址(通常是2字节或4字节),效率极高。   
           
3.实现某些数据结构和算法:链表、缓冲区管理等,都离不开指针。
           
用指针,记住几点:
?明确指向:确保你的指针指向一个有效的、你想要操作的内存地址。对于硬件寄存器,地址是固定的;对于变量,要取其地址(&)。
           
?类型匹配:指针类型决定了它一次访问多少字节以及如何解释这些字节。char *一次访问1字节,int *(假设int为4字节)一次访问4字节。访问寄存器时,务必使用与寄存器位宽匹配的指针类型(通常是 unsigned int * 或 unsigned short * 或 unsigned char *)。
           
?volatile关键字:访问硬件寄存器或在中断服务程序中修改的全局变量时,务必使用 volatile 修饰指针(或指针指向的变量)。这防止编译器进行不当优化,确保每次都从内存中真实读写数据。
           
?空指针检查:虽然裸机环境相对简单,但养成检查指针是否为空(NULL)的好习惯总没错,特别是在处理动态分配或可能无效的指针时。
           
指针不是魔法,它就是地址。理解内存布局,知道你要操作哪里,指针就是你最得力的工具。
           
二、结构体
单独操作一个个寄存器地址,是不是感觉很零散,容易出错?如果一个外设(比如UART)有十几个关联的寄存器,你难道要定义十几个宏和指针?太不优雅了!这时,结构体闪亮登场。   
           
结构体在单片机中的妙用:
1.封装硬件寄存器组:这是结构体在单片机领域最光辉的应用!我们可以把一个外设的所有寄存器,按照它们在内存中的布局,定义成一个结构体。然后,只需要一个指向这个结构体类型的指针,就可以访问该外设的所有寄存器了。
  • // 假设一个简化的UART外设有以下寄存器 (地址连续)    // 0x40004400: UART_CR1 (控制寄存器1) - 32位    // 0x40004404: UART_SR (状态寄存器) - 32位    // 0x40004408: UART_DR (数据寄存器) - 32位    // 定义UART寄存器结构体    typedef struct    {        volatile unsigned int CR1; // 控制寄存器1        volatile unsigned int SR;  // 状态寄存器        volatile unsigned int DR;  // 数据寄存器        // ... 可能还有其他寄存器    } UART_TypeDef;    // 定义指向该结构体类型的指针,指向UART外设的基地址    #define UART1_BASE_ADDR (0x40004400)    UART_TypeDef *pUART1 = (UART_TypeDef *)UART1_BASE_ADDR;    // 操作寄存器变得非常直观    // 启用UART发送功能 (假设CR1的第3位是发送使能位 TE)    pUART1->CR1 |= (1 3);     // 检查发送数据寄存器是否为空 (假设SR的第7位是TXE标志)    while (!(pUART1->SR & (1 7)))    {        // 等待为空    }    // 发送一个字节 'A'    pUART1->DR = 'A';
               
    看,是不是比操作零散的地址宏清晰多了?代码可读性、可维护性大大提高。这正是所有标准外设库(如STM32 HAL/LL库、NXP MCUXpresso SDK等)都在用的方法。
               
    2.组织数据:管理设备状态、配置参数、通信协议的数据包等,用结构体来打包相关信息,再自然不过了。
               
    用结构体,关注几点:
    ?内存对齐:编译器可能会为了优化访问速度,在结构体成员之间插入填充字节,导致结构体大小不等于成员大小之和。在直接映射硬件寄存器时,要确保结构体成员的布局与硬件手册中的寄存器偏移完全一致。有时需要使用 __packed(不同编译器的关键字可能不同)或 #pragma pack 指令来控制内存对齐。不过,对于按顺序定义的32位或16位寄存器组,通常默认对齐就能正确工作。
               
    ?指针访问成员:通过指向结构体的指针访问成员时,使用 -> 运算符;通过结构体变量本身访问成员时,使用 . 运算符。别搞混了。
               
    ?typedef简化:使用 typedef 为结构体类型创建一个别名,代码更简洁。
               
    结构体是代码的组织者,善用它,你的代码会像书架一样整齐有序。
               
    三、位操作   
    单片机的寄存器里,每一位(bit)通常都有特定的含义,代表一个开关、一个状态标志、或者某个配置值的一部分。我们必须能够精确地操作这些位,而不是影响到旁边的位。
               
    为什么必须掌握位操作?
    1.精准控制硬件:寄存器的配置往往就是设置或清除其中的某几位。比如,控制一个GPIO引脚输出高电平,可能就是设置某个寄存器的某一位为1;启用某个中断,也是设置相应寄存器的某一位。
               
    2.节省内存:有时可以用一个字节(8位)的不同位来存储8个不同的布尔状态标志,而不是定义8个char或int变量,极大地节省了宝贵的RAM。
               
    3.协议解析与封装:很多通信协议(如CAN、I2C的某些部分)的数据格式是按位定义的,需要用位操作来解析收到的数据或封装要发送的数据。
               
    常用的位操作符和技巧:
               
    ?按位与 &:
    ?清零特定位 (Clear bits):reg & (~(1  将寄存器 reg 的第 n 位清零,其他位不变。~ 是按位取反。
    ?检查特定位是否为1 (Check bit):if (reg & (1  判断 reg 的第 n 位是否为1。
               
    ?按位或 |:
    ?设置特定位为1 (Set bits):reg | (1  将寄存器 reg 的第 n 位设置为1,其他位不变。
                   
    ?按位异或 ^:
    ?翻转特定位 (Toggle bits):reg ^ (1  将寄存器 reg 的第 n 位翻转(0变1,1变0),其他位不变。
               
    ?左移 :1  生成一个只有第 n 位是1,其余位是0的掩码(mask),是位操作中最常用的辅助工具。
    ?右移 >>:用于提取某个位或某个位域的值。例如,提取 reg 的第 n 位的值:(reg >> n) & 1。
               
    位操作实战示例:
  • // 假设 GPIOA_MODER 寄存器用于配置GPIO模式,每两位控制一个引脚// 引脚5需要配置为通用输出模式 (模式代码为 01)// 引脚5对应的位是 bit 11 和 bit 10#define GPIOA_MODER_ADDR (0x40020000) // 示例地址volatile unsigned int *pGPIOA_MODER = (volatile unsigned int *)GPIOA_MODER_ADDR;unsigned int reg_val;// 1. 读取当前寄存器值reg_val = *pGPIOA_MODER;// 2. 清除引脚5对应的模式位 (bit 11 和 bit 10)//    掩码为 (0b11 reg_val &= ~(3 10); // 3. 设置引脚5为通用输出模式 (01)//    模式值为 (0b01 reg_val |= (1 10);// 4. 将修改后的值写回寄存器*pGPIOA_MODER = reg_val;// 更简洁的原子操作 (如果寄存器支持直接位带操作或者用库函数,可能更佳)// 但上述读 - 改 - 写是通用且安全的方法,避免影响其他位           
    用位操作,注意:
    ?可读性:直接写 reg |= 0x08; 不如写 reg |= (1  清晰。最好使用宏定义来表示位的位置和掩码,例如 #define PIN5_MODE_MASK (3  和 #define PIN5_MODE_OUTPUT (1 。
               
    ?操作符优先级:位操作符的优先级通常低于算术运算符和比较运算符,不确定时多用括号 () 保证运算顺序。
               
    ?读 - 改 - 写:操作寄存器位时,最安全的方式是先读出整个寄存器的值,然后在本地变量中进行位修改,最后再把修改后的完整值写回寄存器。这避免了直接在寄存器上进行 |= 或 &= 操作时可能产生的竞态条件(尤其是在中断可能修改同一寄存器时)。
               
    指针、结构体、位操作,是深入单片机底层、拿捏硬件资源的“三板斧”。吃透它们,告别青涩,迈向硬核!

    end

    2zoyn5nzkij64023895106.jpg

    2zoyn5nzkij64023895106.jpg


    下面是更多无际原创的个人成长经历、行业经验、技术干货。
    1.电子工程师是怎样的成长之路?10年5000字总结
    2.如何快速看懂别人的代码和思维
    3.单片机开发项目全局变量太多怎么管理?
    4.C语言开发单片机为什么大多数都采用全局变量的形式
    5.单片机怎么实现模块化编程?实用程度让人发指!
    6.c语言回调函数的使用及实际作用详解

    7.手把手教你c语言队列实现代码,通俗易懂超详细!

    8.c语言指针用法详解,通俗易懂超详细!
  • 回复

    使用道具 举报

    发表回复

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则


    联系客服 关注微信 下载APP 返回顶部 返回列表