
i.MX6ULL-Linux 基础 && QT移植 && C 学习笔记

A 移植概念相关
1. Uboot启动流程
flowchart TD A[芯片上电] --> B[Boot ROM
固化在芯片内部的只读存储器] B --> C[根据启动拨码开关选择源
如: eMMC, SD Card, NAND Flash等] C --> D[Boot ROM 读取启动介质头信息
获取IVT, DCD等数据] D --> E{校验并加载
第一阶段引导程序} E -- 例如SPL --> F[初始化时钟, DDR等关键外设] F -- 跳转 --> G[加载U-Boot第二阶段至DDR] G --> H[U-Boot 第二阶段初始化
各种驱动和环境变量] H --> I{启动内核或进入命令行} I -- 无操作或命令 --> J[加载Linux内核] I -- 用户打断 --> K[进入U-Boot命令行] J --> L[启动操作系统]
2. Uboot移植
1. 获取基础代码
通过参考NXP官方的板子,在这个板子的Uboot代码基础上进行修改,我们参考的是mx6ull_14x14_evk_emmc_defconfig(硬件设计参考了哪块官方板卡,就使用哪块板卡的U-Boot作为基础)
2。编译官方的代码
编译官方代码,一是检查代码是否能成功编译,二是可以尝试代码是否可以在自己板子上运行,如果能运行,可以观察哪些驱动有问题,需要修改
3.创建新板配置
添加默认配置文件: 在 configs/目录下复制并修改官方板的配置文件(如 defconfig),更改名称和关键选项(指定新的板级头文件路径)
添加板级头文件 在 include/configs/目录下复制并修改官方板的头文件(如 .h)。这是最主要的配置中心,用于设置DDR容量、环境变量、驱动使能(如网络、LCD)、设备地址等。
添加板级代码目录: 在 board/vendor/(如 board/freescale/)下复制并修改官方板的板级目录。需要修改其中的 Makefile, Kconfig, 主源文件(如 .c文件)等,以适配新板的初始化代码(如GPIO、时钟)。
4.修改驱动
这个板子主要修改两部分具体驱动,重点都是修改环境变量和参数与引脚配置
- LCD屏幕
需要根据自己的屏幕来修改,这里主要修改了
(xres, yres):分辨率,修改为LCD面板的实际物理分辨率,例如从默认的480x272修改为1024x600
pixfmt:像素格式,也就是一个像素点是多少位
pixclock:像素时钟,每个像素时钟周期的长度,单位为皮秒。
panel:panel环境变量主要用于指定当前使用的 LCD 屏幕的类型或名称,在板级头文件中
name:结构体(t display_info_t)中个有个成员变量mode,也是一个结构体(fb_videomode),其中的成员(.name),也是指定LCD 屏幕的类型或名称,panel和name的值需要一样
然后可以通过启动uboot,设置环境变量panel
1 | setenv panel TFT7016 |
将环境变量保存到EMMC中,然后重新启动,检查LED状态,若还有问题,继续检查代码是否修改完善
- 网络驱动
正点原子的 I.MX6U-ALPHA 开发板提供了这两个网络接口,其中 ENET1 和 ENET2 都使用 SR8201F 作为 PHY 芯片。NXP 官方的I.MX6ULL EVK 开发板使用 KSZ8081 这颗 PHY 芯片
因为修改了PHY芯片,所以网络驱动修改的核心是适配PHY芯片型号(驱动+地址)和正确控制复位引脚(时序+延时),正点原子教程通过删除原厂扩展芯片逻辑,添加自定义GPIO复位,并满足SR8201F的时序要求来完成移植
修改完成后通过配置相关变量
1 | setenv ipaddr 192.168.1.55 //开发板 IP 地址 |
然后通过ping命令检测是否修改成功
- 配置环境变量bootcmd & bootargs
环境变量bootcmd
bootcmd 保存着 uboot 默认命令,uboot 倒计时结束以后就会执行 bootcmd 中的命令。这些命令一般都是用来启动 Linux 内核的,比如读取 EMMC 或者 NAND Flash 中的 Linux 内核镜像文件和设备树文件到 DRAM 中,然后启动 Linux 内核。可以在 uboot 启动以后进入命令行设置 bootcmd 环境变量的值。如果 EMMC 或者 NAND 中没有保存 bootcmd 的值,那么 uboot 就会使用默认的值,板子第一次运行 uboot 的时候都会使用默认值来设置 bootcmd 环境变量,可以通过修改uboot文件的内容,设置bootcmd的默认值
下面主要说一下bootcmd具体内容:
1 | mmc dev 1 //切换到 EMMC |
总结就是从EMMC中读取zImage和dtb文件到内存中,然后启动linux
当然在后面学习驱动的时候,通常是通过tftp来下载zImage和dtb文件到内存中,通过NFS挂载根文件系统
1 | tftp 80800000 zImage; |
bootargs
bootargs 保存着 uboot 传递给 Linux 内核的参数,分别是
console:console 用来设置 linux 终端(或者叫控制台),也就是通过什么设备来和 Linux 进行交互,是串口还是 LCD 屏幕?如果是串口的话应该是串口几等等。一般设置串口作为 Linux 终端,这样我们就可以在电脑上通过 SecureCRT 来和 linux 交互了。这里设置 console 为 ttymxc0,因为 linux启动以后I.MX6ULL 的串口 1 在 linux 下的设备文件就是/dev/ttymxc0,在 Linux 下,一切皆文件。
root:t 用来设置根文件系统的位置,例如root=/dev/mmcblk1p2 用于指明根文件系统存放在mmcblk1 设备的分区 2 中,而在学习驱动的时候,我们通常使用nfs挂载
1 | //nfs挂载(其中的网络ip地址等要根据自己实际使用的更改) |
root 后面有“rootwait rw”,rootwait 表示等待 mmc 设备初始化完成以后再挂载,否则的话mmc 设备还没初始化完成就挂载根文件系统会出错的。rw 表示根文件系统是可以读写的,不加rw 的话可能无法在根文件系统中进行写操作,只能进行读操作
附一张正点原子手册详解
4.启动Uboot
有两种启动方式,从EMMC启动 从网络启动(nfs),前面已经说过了,启动需用用到的文件zImage(内核映像文件)和dtb文件(设备树文件),将在内核源文件编译后生成
3. 内核启动流程(NULL)
很明显,我肯定没弄懂启动流程,所以附两个流程图大致了解一下
graph TD A[stext入口] --> B[关闭MMU/D-cache] B --> C[校验处理器ID
__lookup_processor_type] C --> D[校验启动参数
__vet_atags] D --> E[创建页表
__create_page_tables] E --> F[使能MMU
__enable_mmu] F --> G[切换上下文
__mmap_switched] G --> H[start_kernel初始化] H --> I[rest_init创建进程] I --> J[创建init进程 PID=1] I --> K[创建kthreadd进程 PID=2] I --> L[进入idle进程 PID=0] J --> M[kernel_init_freeable] M --> N[挂载根文件系统
prepare_namespace] M --> O[执行用户空间init程序] O --> P[用户空间初始化]
graph LR 内核态 -->|mount rootfs| 根文件系统 根文件系统 -->|exec /sbin/init| 用户态
4. 内核移植
1. 获取代码-编译-启动
通常使用Linux 图形配置界面,uboot也有,但是因为修改部分比较少,所以未使用
然后复制添加设备树文件和开发板默认配置文件,编译下载仅板子测试,注意要先挂载一份好的根文件系统,否则会启动失败
2. 修改驱动
- CPU 主频修改 当前 CPU 支持 198MHz、396MHz、528Mhz 和 792MHz 四种频率切换,其中调频策略为 ondemand,也就是定期检查负载,然后根据负载情况调节 CPU 频率。
- 修改 EMMC 驱动
使能 8 线 EMMC 驱动,Linux 内核驱动里面 EMMC 默认是 4 线模式的,4 线模式肯定没有 8 线模式的速度快,在设备树中修改
关闭 EMMC 1.8V 供电选项,因为板子的EMMC供电应该为3.3V
- 底板网络驱动修改
和之前的uboot原因一样,因为使用的网络PHY芯片不同,前三点都在设备树文件中修改
- 修改 SR8201F 的复位以及网络时钟引脚驱动
- 中修改 fec1 和 fec2 节点的 pinctrl-0 属性
- 修改 SR8201F 的 PHY 地址
- 修改 fec_main.c 文件 根据 SR8201F 收据手册上的要求,SR8201F 在复位结束以后需要等待至少 150ms 才能操作 SR8201F,因此这里添加了一个 200ms 的延时
3. 网络驱动测试
修改好设备树和 Linux 内核以后重新编译一下,得到新的 zImage 镜像文件和 dtb 设备树文件,使用网线连接后,ifconfig测试有几个网卡,然后打开网卡,用ping指令测试是否有网络
下面是正点原子总结的移植步骤:
总结一下移植步骤:
①、在 Linux 内核中查找可以参考的板子,一般都是半导体厂商自己做的开发板。
②、编译出参考板子对应的 zImage 和.dtb 文件。
③、使用参考板子的 zImage 文件和.dtb 文件在我们所使用的板子上启动 Linux 内核,看能
否启动。
④、如果能启动的话就万事大吉,如果不能启动那就悲剧了,需要调试 Linux 内核。不过
一般都会参考半导体官方的开发板设计自己的硬件,所以大部分情况下都会启动起来。启动
Linux 内核用到的外设不多,一般就 DRAM(Uboot 都初始化好的)和串口。作为终端使用的串口
一般都会参考半导体厂商的 Demo 板。
⑤、修改相应的驱动,像 NAND Flash、EMMC、SD 卡等驱动官方的 Linux 内核都是已经
提供好了,基本不会出问题。重点是网络驱动,因为 Linux 驱动开发一般都要通过网络调试代
码,所以一定要确保网络驱动工作正常。如果是处理器内部 MAC+外部 PHY 这种网络方案的
话,一般网络驱动都很好处理,因为在 Linux 内核中是有外部 PHY 通用驱动的。只要设置好复
位引脚、PHY 地址信息基本上都可以驱动起来。
⑥、Linux 内核启动以后需要根文件系统,如果没有根文件系统的话肯定会崩溃,所以确定 Linux
内核移植成功以后就要开始根文件系统的构建。
5. 根文件系统的构建
根文件系统首先是内核启动时所 mount(挂载)的第一个文件系统,内核代码像文件保存在根文件系统中,而系统引导启动程序会在根文件系统挂载之后从中把一些基本的初始化脚本和服务等加载到内存中去运行。
教程中是使用BusyBox 构建根文件系统,主要修改两部分 添加编译器 添加中文支持,选择动态编译后编译 busybox,会得到一个基础的根文件系统(BusyBox的工作也就到此为止)
然后是向根文件系统中添加向根文件系统添加 lib 库(动态库,如果是静态编译则不用,但是静态库很占内存,并且可能导致dns不能使用)和一些基础文件目录(如 dev、proc、mnt、sys、tmp 和 root 等),这里的lib库通过交叉编译器工具中获取,并且没有裁剪,是为了避免初学时因库文件缺失而带来的各种问题,裁剪是后续优化的工作,目前全部添加,内存就已经达到124MB。
目前如果直接nfs挂载根文件系统启动,会提示还缺少几个文件/etc/init.d/rcS /etc/fstab /etc/inittab,rcS 是个 shell 脚本,Linux 内核启动以后需要启动一些服务,而 rcS 就是规定启动哪些文件的脚本文件,fstab 在 Linux 开机以后自动配置哪些需要自动挂载的分区,inittab定义系统启动、关闭、重启等不同阶段应该执行什么动作以及在哪个终端上执行
最后就可以启动根文件系统,来测试是否构建成功了(可以通过运行c文件,测试添加开机自启动文件,测试网络,添加dns来测试)
6. 烧写系统
至此,三个部分,Uboot 内核 根文件系统,已经全部构建完成,就可以烧写整个系统到板子里开始学习linux驱动了,但是我们移植都是通过网络来测试的,在实际的产品开发中肯定不可能通过网络来运行,所以需要通过NXP 官方提供的 MfgTool 工具通过 USB OTG 口来烧写系统,具体方法也是修改官方的烧写脚本,在文件里面添加自己移植好的uboot,dtv,kernel,根文件系统,然后烧写到板子里。
B 驱动概念相关
1. Linux的内存管理
在 32位 linux系统重,通常会把内存映射为4GB,通过内存管理单元 MMU实现虚拟和物理地址的转换
- 程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
- 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
这里面会用到页表—内存管理的核心数据结构之一,32位系统下通常使用2级表或3级表
- 页全局目录(PGD)
- 页中间目录(PMD)(可选)
- 页表(PT)
驱动开发中如何通过物理地址得到虚拟地址
这里就涉及到了物理内存和虚拟内存之间的转换,需要用到两个函数ioremap 和 iounmap,当然如果使用设备树的可以使用 of_iomap 来直接获取设备节点对应的虚拟地址。
1 |
|
虚拟内存的优缺点
- 优点
扩大地址空间 每个进程独占一个4G空间,让每个线程都认为自己有很大的内存,虽然真实物理内存没那么多
内存保护 防止不同进程之间对物理内存的争夺,可以实现对特定内存实现读写保护,防止恶意篡改
避免内存碎片 虽然物理地址可能不连续,但是映射虚拟地址上可以连续
内存共享 可以实现进程间的通信,不同进程的虚拟地址可以指向同一个物理地址,实现内存共享(例如共享库),节省内存空间
- 缺点
虚拟内存需要额外构建数据结构(页 页表 页框 分别对应物理内存 查询使用 虚拟内存),占用空间
增加执行时间,虚拟地址到物理地址的转化过程
关于“申请虚拟内存不立即分配物理内存”
“只要不读写这个虚拟内存,操作系统就不会分配物理内存”——在原理上是正确的
现代操作系统(如Linux)的malloc等函数在申请内存时,分配的是虚拟内存。操作系统仅在程序实际访问(读取或写入)这块内存时,才会通过“缺页中断”机制为其分配真正的物理内存页,这个过程称为“按需分配”。
所以,单纯分配大量虚拟内存,理论上不会消耗对等的物理内存
2. 字符设备驱动
linux系统将设备分为3类:字符设备、块设备、网络设备
通常是应用层调用一些API函数,访问到内核里的驱动程序,然后再调用硬件外设,如下图
flowchart TD subgraph A [用户空间] direction TB A1[应用程序
open, close, read, write等API] A2[库函数
C库等] end subgraph B [内核空间] direction TB B1[系统调用接口
sys_open, sys_close, sys_read, sys_write] B2[驱动程序
open, close, read, release等函数] end C[硬件设备] A1 --调用--> A2 A2 --触发--> B1 B1 --分发--> B2 B2 --操作--> C
字符设备驱动基础编写流程
模块入口与出口 module_init, module_exit 指定驱动加载和卸载时执行的函数
设备号申请 alloc_chrdev_region(动态) 或 register_chrdev_region(静态)
初始化并注册 cdev (描述字符设备的结构体) cdev_init, cdev_add
自动创建设备节点 class_create, device_create
实现文件操作函数 定义并实现file_operations中的open, read, write, release等
资源释放与注销 cdev_del, unregister_chrdev_region, device_destroy, class_destroy 在模块退出时逆序释放所有资源
3. 设备树
设备树(Device Tree),将这个词分开就是“设备”和“树”,描述设备树的文件叫做 DTS(Device Tree Source),这个 DTS 文件采用树形结构描述板级设备,也就是开发板上的设备信息,比如CPU 数量、 内存基地址、IIC 接口上接了哪些设备、SPI 接口上接了哪些设备等等
设备都是以节点的形式“挂”到设备树上的,因此要想获取这个设备的其他属性信息,必须先获取到这个设备的节点,。Linux 内核给我们提供了一系列的函数来获取设备树中的节点或者属性信息,OF函数
下面演示一下获取设备节点和读取节点里的内容(不涉及错误处理)
1 |
|
设备树中提供硬件连接和配置的静态描述,是pinctrl和GPIO子系统等配置信息的载体
3. pinctrl 和 gpio 子系统实验
- pinctrl 子系统
要使用 pinctrl 子系统,我们需要在设备树里面设置 PIN 的配置信息,然后pinctrl 子系统要根据你提供的信息来配置 PIN 功能,一般会在设备树里面创建一个节点来描述 PIN 的配置信息
举个例子:
1 | pinctrl_test: testgrp { |
通常会将这个节点放在iomuxc节点下,pinctrl 驱动程序是通过读取“fsl,pins”属性值来获取 PIN 的配置信息
- GPIO 子系统
pinctrl 子系统重点是设置 PIN(有的 SOC 叫做 PAD)的复用和电气属性,如果 pinctrl 子系统将一个 PIN 复用为 GPIO 的话,那么接下来就要用到 gpio 子系统了。gpio 子系统顾名思义,就是用于初始化 GPIO 并且提供相应的 API 函数,比如设置 GPIO为输入输出,读取 GPIO 的值等。gpio 子系统的主要目的就是方便驱动开发者使用 gpio,驱动开发者在设备树中添加 gpio 相关信息,然后就可以在驱动程序中使用 gpio 子系统提供的 API函数来操作 GPIO,Linux 内核向驱动开发者屏蔽掉了 GPIO 的设置过程,极大的方便了驱动开发者使用 GPIO。
举个例子:
1 | test { |
下面是一些常用API函数:
gpio_request() 用于申请一个 GPIO 管脚
gpio_free() 不使用某个 GPIO 了,通过该函数释放
gpio_get_value() 用于获取某个 GPIO 的值(0 或 1)
gpio_set_value() 用于设置某个 GPIO 的值
- 常见流程
- 添加 pinctrl 节点:设置 PIN 的复用和电气属性。
- 添加设备节点:配置 GPIO 的输入、输出或中断功能。
- 检查 PIN 冲突:确保 PIN 未被其他外设占用,避免资源冲突。
4. 处理并发与竞争的方法
Linux是一个多任务操作系统,肯定会存在多个任务共同操作同一段内存或者设备的情况,多个任务甚至中断都能访问的资源叫做共享资源,就和共享单车一样。在驱动开发中要注意对共享资源的保护,也就是要处理对共享资源的并发访问
在linux下,引发竞争问题的原因主要有这几种:
- 多线程并发,多个线程同时操作某个文件
- 抢占式并发,高优先级抢占访问了某个低优先级正在访问的文件
- 中断式并发,多是硬件中断抢占访问了当前线程正在访问的资源
- 多核(SMP)并发,多个核心同时访问某个资源
- 原子操作
一般原子操作用于变量或者位操作,原子操作就是指不能再进一步分割的操作,在linux内核中通过 atomic_t 结构体来实现
1 | //原子整形变量 |
通过定义原子变量,保证变量修改的过程中不被其它资源访问
位操作也是很常用的操作,Linux 内核也提供了一系列的原子位操作 API 函数,只不过原子位操作不像原子整形变量那样有个 atomic_t 的数据结构,原子位操作是直接对内存进行操作
下面是一些常用API函数:
1 | void set_bit(int nr, void *p); //将p地址的地nr位置1 |
- 自旋锁
原子操作只能对整型变量或者位进行保护,而实际使用不可能只有这些简单的临界区,所以引入自旋锁
对于自旋锁而言,如果自旋锁正在被线程 A 持有,线程 B 想要获取自旋锁,那么线程 B 就会处于忙循环-旋转-等待状态,线程 B 不会进入休眠状态或者说去做其他的处理,而是会一直傻傻的在那里“转圈圈”的等待锁
linux内核中使用 spinlock_t结构体 来表示自旋锁
下面是相关的API函数
1 | spinlock_t lock; //首先定义自旋锁 |
下面介绍一个概念:死锁
举几个例子:
- 线程A获取自旋锁(此时自旋锁会自动禁止抢占),但是如果在线程A中却调用的一些休眠或者阻塞的函数,使得A主动放弃了CPU使用权,此时B线程开始运行了,如果B线程也想获取锁,可是锁在A线程,并且内核抢占也被关闭了,那B运行不了,A也无法运行,锁也无法释放,死锁便发生了
- 同理线程A获取自旋锁,此时中断发生了,抢走了CPU使用权,如果中断也想要自旋锁,中断就会一直自旋,那么A无法执行和释放锁,中断也无法继续执行,那么就会发生死锁
- A线程获取自旋锁,然后再临界区递归申请自旋锁1,那么线程A就会进入自旋,因为自己还没有释放锁,那么就会形成死锁
如何解决这两种死锁:
- 第一种就是杜绝在临界区执行休眠或者阻塞相关函数
- 第二种就是在获取自旋锁之前,禁止本地中断,同时linux内核提供了相关API函数
- 第三种就是杜绝递归申请自旋锁
1 | void spin_lock_irq(spinlock_t *lock) 禁止本地中断,并获取自旋锁。 |
- 读写锁 顺序锁
锁类型 | 读并发 | 写并发 | 读写并发 |
---|---|---|---|
读写锁 | 可以 | 不可以 | 不可以 |
顺序锁 | 可以 | 不可以 | 可以 |
自旋锁 | 不可以 | 不可以 | 不可以 |
- 信号量 互斥体
这个在rt_thread中已经说明过,这里主要展示linux内核中相关API函数
1 | struct semaphore sem; /* 定义信号量 */ |
5. Linux内核定时器
定时器是我们最常用到的功能,一般用来完成定时功能,这里介绍Linux内核中定时器的使用方法,这里不展开系统节拍和时钟说明,主要说明在驱动中相关API函数如何使用
Linux 内核使用全局变量 jiffies 来记录系统从启动以来的系统节拍数,系统启动的时候会将 jiffies 初始化为 0
内核定时器并不是周期性运行的,超时以后就会自动关闭,因此如果想要实现周期性定时,那么就需要在定时处理函数中重新开启定时器,Linux 内核使用 timer_list 结构体表示内核定时器
1 | struct timer_list { |
下面是一个完整例子,包含初始化定时器结构体,注册定时器,定时器中断每隔1s打印一次消息,打印10次后不再打印
1 | struct timer_list timer; //定义一个定时器 |
上面个例子中,msecs_to_jiffies(1000)函数,可以将1000ms转化为jffies的单位,来实现符合s,ms的延时中断
在不使用中断的时候,linux内核也提供短延时函数如下:
1 | //分别为纳秒,微妙和毫秒延时 |
6. Linux内核中断框架
…待补充
7. 阻塞/非阻塞IO
8. 异步通知
9. Linux IIC框架
10. Linux Input子系统
C QT有关驱动移植和根文件系统的构建
1.触摸屏/显示相关
我这里使用的触摸屏ic引脚芯片是 gt911 ,如果读者使用的触摸屏是其它芯片的可以看相关视频学习驱动
,gt911具体驱动内容可以看这篇文章:基于imx6ull的gt911移植
2.支持QT的根文件系统的构建
具体教程可以参考文末的正点原子QT移植指南,这里简略介绍一下
这里的环境搭建与正点原子驱动教程中的一致,都是 arm-poky-linux-gnueabi-gcc 编译器
然后是下载编译触摸插件: tslib,这个插件在编写gt911触摸屏驱动之后验证的过程中也已经使用过
下载并编译ARM平台下的QT5.12.9源码 (按照正点原子的教程可能会出现的报错)如下
.Done. /home/mouse/Linux/tool/qt-everywhere-src-5.12.9/qtbase/bin/qmake: 1: /home/mouse/Linux/tool/qt-everywhere-src-5.12.9/qtbase/bin/qmake: Syntax error: word unexpected (expecting ")")
解决办法是删掉解压的qt源码,然后重新解压再编译 (我的问题是这样解决的)下一步是将编译好的tslib插件和QT移植到最初学习驱动构建的busybox文件系统中,其中tslib再gt911已经移植过,QT移植过程类似,都是打包,解压然后配置环境变量
最后连接显示屏然后执行QT测试文件,如果屏幕上显示qt的图片,并且能触摸,那么就大功告成啦!
/usr/lib/arm-qt/examples/widgets/animation/animatedtiles/animatedtiles
当然在后面制作自己的QT应用的时候,需要使用安装QT的交叉编译器,然后才能在Ubuntu下编译出能在IMX6u上运行的程序,这个正点原子的教程也有,我这里列出安装之后环境变量的默认路径
/opt/fsl-imx-x11/4.1.15-2.1.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi
,后期使用的时候只用先是能环境变量,然后在QT工程目录下执行qmake即可生成Makefile文件,然后make生成最终文件,拷贝到开发板上就可以运行啦
3.基于QT的简易TCP软件
这里就不贴源码了,因为其实比较简单,具体QT相关内容可以等我整理相关内容文章,后面是打算尝试整合这阵子学习的内容,做一个智能家居类的物联网项目
D 编程相关概念(C语言 & Linux)
1.C语言相关
1.1 空指针 & 野指针
空指针(Null Pointer) 它不指向任何有效的内存地址。在C语言中,我们通常使用 NULL 宏来表示空指针,它的值通常被定义为整数 0。空指针的概念很简单:它就是一个不指向任何地方的指针。
空指针的使用非常安全,因为大多数现代操作系统都会在程序试图访问空指针时立即终止程序,从而防止可能的数据损坏或安全漏洞。这种行为使得空指针成为检测指针是否有效的有用工具。当我们声明一个指针但还没有让它指向任何有效的内存地址时,最好将其初始化为空指针。
1 | int *ptr = NULL; |
野指针(Dangling Pointer),它是指向无效内存或者已被释放的内存的指针。与空指针不同,野指针指向的是一个随机的内存位置,这个位置可能包含任何数据,甚至可能是另一个正在运行的程序的数据。使用野指针可能导致程序崩溃、数据损坏或安全漏洞。
下面介绍五种产生野指针的场景:
1.指向临时变量的指针
1 |
|
- 未初始化的指针
1 |
|
- 使用释放后的指针
1 |
|
- 指针越界访问
1 |
|
- 指针被多次释放
1 |
|
1.2 标识符 & 关键字 & 保留字
标识符是指程序员在编程过程中用于命名变量、函数、类、模块或其他对象的名字。一个有效的标识符应该遵循特定的命名规则,具体规则可能因编程语言而异,比如不能以数字开头,不能与关键字相同等
关键字是指编程语言中具有特殊意义和用途的预定义单词,比如if else int等,由于关键字有特殊的含义,因此它们不能用作普通标识符。
保留字是指在当前版本的编程语言中被保留但尚未使用的标识符,有的说明中会把它与关键字混为一谈,但实际还是不一样的。当然,它也不能当做普通标识符,比如做变量等
1.3 static关键字
static 修饰的变量会放在静态区,而我们平常的局部变量等放在了栈区,动态申请的内存,比如malloc则放在堆区
1:在函数中声明变量时, static 关键字指定变量只初始化一次,并在之后调用该函数时保留其状态,比如在一个函数中记录进入该函数多少次
2️:在声明变量时,变量具有静态持续时间,并且除非您指定另一个值。
3:在全局和/或命名空间范围 (在单个文件范围内声明变量或函数时) static 关键字指定变量或函数为内部链接,即外部文件无法引用该变量或函数,通常在linux中声明函数,使得该函数只有当前文件可以访问
4:static 关键字 没有赋值时,默认赋值为 0
5:static修饰局部变量时,会改变局部变量的存储位置(静态区),从而使得局部变量的生命周期变长,但作用域不变,比如函数里面static修饰的变量在主函数里面无法使用,同时不能用extern引用
1.4 volatile关键字
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
举个例子:
1 |
|
如果这段代码在debug模式(即编译器不优化)下运行,那么结果是:
i = 10
i = 32
debug模式下这是正常结果,但是如果是release版本(编译器优化后版本),那么结果可能是:
i = 10
i = 10
因为如果变量没有volatile修饰,那么编译器在优化的时候,可能不会去该变量的内存地址访问该变量,而是访问之前与该变量相等的变量(在这里就是a,因为之前写了a = i),那么就可能会造成错误的结果
在嵌入式开发,或者多线程的linux开发中,尝尝会有很多线程任务同时访问同一个变量,那么为了防止被编译器优化,会使用该关键字
同理,volatile也可以修饰指针,指向易变数据的指针,这时如果有任务通过该指针访问变量的时候,也会直接通过内存地址访问,而不会使用编译器事先缓存的值
应用时,在嵌入式里,通常会用volatile修饰一些寄存器地址,然后我们通过宏->地址修改变量的时候,也会直接访问,而不会被优化
c++部分
volatile关键字除了修饰变量和指针,也能用来修饰类的成员函数。这表示该成员函数可以被 volatile对象调用,并且在函数内部对待对象的成员变量时,会遵循 volatile的语义(即每次访问都从内存中读取最新值,而不是使用可能被编译器缓存的副本)
1.5 static关键字
关键字const用来定义常量,如果一个变量被const修饰,那么它的值就不能再被改变
与define的不同在于:
- 预编译指令只是对值进行简单的替换,不能进行类型检查
- 可以保护被修饰的东西,防止意外修改,增强程序的健壮性
- 编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。
- 常量指针 & 指针常量
常量指针是指不能通过该指针修改指向的变量,但是可以通过其它途径修改这个变量,同时这个指针是可以被修改的。
1 | int a = 1; |
指针常量是指 指针自己不能被修改,但指向的内容可以被修改
1 | int a = 1; |
当然还有套娃的 指向常量的常指针: 即不能修改指针本身,也不能修改指针指向的变量
1 | int a = 1; |
- cosnt修饰函数参数
和之前介绍的三种情况一样,也是为了在函数中 防止修改指针 或 防止修改指针指向的变量 或 二者都要
- cosnt修饰函数返回值
如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const 修饰的同类型指针
1 | const int * Fun(void); |
- 修饰全局变量
修饰后的全局变量一半存储在只读数据段,局部变量一般在栈区,也有可能被编译器优化
修饰全局变量是为了防止程序其他部分无意或恶意地修改该全局变量,从而保护数据的完整性,提高程序的健壮性
1.6 使用头文件时双引号和尖括号的区别是什么?
使用双引号,会先在当前项目路径下查找该头文件,如果查不到,才会去系统目录下查找
使用尖括号,会直接去系统目录下查找该文文件
1.7 输出重定向
输出重定向是指把程序的输出,除了输出在屏幕上以外的另外选择, 比如说,输出到一个文件里
在嵌入式里,比如重定向到串口的发送函数中,这样就可以通过printf直接向串口发送消息调试
1 | //重定义fputc函数 |
链接器的行为: 许多C标准库(如ARM的MicroLib或某些Newlib配置)会将 fputc定义为弱符号(weak symbol)。这意味着如果您在工程中自己定义了一个同名的强符号函数,链接器会优先使用您的实现,从而**“覆盖”**库中的默认版本。这是重定向能够实现的底层机制。
printf的依赖链: 当程序调用 printf时,printf会解析格式字符串并最终多次调用 fputc来输出每一个字符。当我们重定义了 fputc,这些字符就会被发送到串口,而不是标准的输出设备。
2.Linux相关
2.1 如何通过结构体的成员,来获得该结构体的指针
使用 container_of 宏,内核函数调用常常给函数传入的是结构体成员地址,然后在函数里面又想使用这个结构体里面的其他成员变量,所以就引发了这样的问题,这个宏用来解决这个问题。
定义:
1 |
使用方法:
1 | container_of(ptr, type, member); |
ptr
:指向结构体成员的指针。type
:包含该成员的结构体类型。member
:结构体中的成员名称。
原理:container_of
宏的核心思想是通过成员变量的地址,反推出整个结构体的起始地址。它利用了结构体成员在内存中的偏移量(offsetof
),通过指针运算实现。
步骤解析:
(type *)0
:将 0 转换为type *
类型,表示一个虚拟的结构体指针。&((type *)0)->member
:通过虚拟指针访问member
,得到该成员相对于结构体起始地址的偏移量。(通过这个虚拟的指针去访问结构体中的成员 member。编译器在编译时,会根据结构体 type的内存布局规则(如内存对齐)来确定 member的位置)(char *)(ptr) - (char *)(&((type *)0)->member)
:将传入的成员指针ptr
减去偏移量,得到结构体的起始地址。(type *)
:将结果转换为type *
类型,返回结构体的指针。
示例代码:
1 |
|
输出:
1 | Original struct address: 0x7ffee4b8c8f0 |
container_of
宏是 Linux 内核中非常重要的工具,广泛用于从结构体成员指针获取整个结构体指针。它的实现依赖于指针运算和偏移量计算,避免了显式的类型转换和冗余代码,提高了代码的可读性和可维护性。
2.2 什么时候从用户态进入内核态
其中,系统调用是主动的,另外两种是被动的。
a、系统调用 (直接调用系统接口或通过库函数调用)
b、异常 (捕捉信号处理异常 signal)
c、设备中断
参考文献
留言
有问题请指出,你可以选择以下方式:
- 在下方评论区留言
- 邮箱留言
- Title: i.MX6ULL-Linux 基础 && QT移植 && C 学习笔记
- Author: H_Haozi
- Created at : 2025-08-20 19:30:55
- Updated at : 2025-10-14 16:17:18
- Link: https://redefine.ohevan.com/2025/08/20/embedded_imx6ull/
- License: This work is licensed under CC BY-NC-SA 4.0.