LV01-01-应用编程基本概念-01-基础知识
本文主要是应用编程基本概念——开始应用编程学习前的一些基础学习的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。
点击查看使用工具及版本
PC端开发环境 | Windows | Windows11 |
Ubuntu | Ubuntu20.04.6的64位版本 | |
VMware® Workstation 17 Pro | 17.0.0 build-20800274 | |
终端软件 | MobaXterm(Professional Edition v23.0 Build 5042 (license)) | |
Win32DiskImager | Win32DiskImager v1.0 | |
Linux开发板环境 | Linux开发板 | 正点原子 i.MX6ULL Linux 阿尔法开发板 |
uboot | NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03) | |
linux内核 | linux-4.15(NXP官方提供) |
点击查看本文参考资料
分类 | 网址 | 说明 |
官方网站 | https://www.arm.com/ | ARM官方网站,在这里我们可以找到Cotex-Mx以及ARMVx的一些文档 |
https://www.nxp.com.cn/ | NXP官方网站 | |
https://www.nxpic.org.cn/ | NXP 官方社区 | |
https://u-boot.readthedocs.io/en/latest/ | u-boot官网 | |
https://www.kernel.org/ | linux内核官网 | |
参考资料 | linux-0.12 内核完全剖析 | kernel-0.12 · linux-0.12 (gitbooks.io) |
点击查看相关文件下载
分类 | 网址 | 说明 |
NXP | https://github.com/nxp-imx | NXP imx开发资源GitHub组织,里边会有u-boot和linux内核的仓库 |
https://elixir.bootlin.com/linux/latest/source | 在线阅读linux kernel源码 | |
nxp-imx/linux-imx/releases/tag/rel_imx_4.1.15_2.1.0_ga | NXP linux内核仓库tags中的rel_imx_4.1.15_2.1.0_ga | |
nxp-imx/uboot-imx/releases/tag/rel_imx_4.1.15_2.1.0_ga | NXP u-boot仓库tags中的rel_imx_4.1.15_2.1.0_ga | |
I.MX6ULL | i.MX 6ULL Applications Processors for Industrial Products | I.MX6ULL 芯片手册(datasheet,可以在线查看) |
i.MX 6ULL Applications ProcessorReference Manual | I.MX6ULL 参考手册(下载后才能查看,需要登录NXP官网) | |
Linux 0.12 | oldlinux-web/oldlinux-files | oldlinux-files/Linux-0.12 at master · oldlinux-web/oldlinux-files (github.com) |
一、嵌入式Linux组成
其实应用开发应该是最先学习的部分,它很多东西其实可以不依赖于arm开发板,我们直接在自己的虚拟机里面就能运行。嵌入式Linux包含哪些东西?嵌入式 Linux 系统,就相当于一套完整的 PC 软件系统。
很多人喜欢从系统启动流程开始学习:先学习裸机,裸机集合起来就是 uboot,再学习内核移植、驱动开发,接下来学习根文件系统,最后学习 APP 开发。但实际开发过程中就会发现uboot其实基本不用改,而且uboot比驱动开发还复杂。这里先学习上层的app开发,也就是应用层的开发,入门相对简单。后续再去学习更深入的东西。
二、系统调用
1. 内核态与用户态
早期工程师们在操作系统上编写程序的时候,自己写个程序可以访问别人的程序地址,甚至是操作系统占用的地址,这样就很容易一不小心就直接把操作系统给搞挂了,所以那个时候的程序员编写程序都得小心翼翼的。
计算机核心的资源一般有:内存,I/O端口,特殊机器指令等,这些资源必须得保护起来,规定哪些程序可以去访问,哪些程序不能去访问。所以引入了特权级别的概念,由硬件设备商直接来提供硬件级别的支持,最常见的就是给CPU指令集的权限分级来控制CPU的访问权限。比如 Intel CPU
指令集操作的权限由高到低划为4级:Ring0、Ring1、Ring2、Ring3,其中Ring0权限最高,可以使用所有CPU指令集,Ring3权限最低,仅能使用部分CPU指令,比如不能使用操作硬件资源的CPU指令:I/O操作、内存分配等操作;另外CPU处于Ring3状态不能访问Ring0的地址空间,包括代码和数据。
CPU指令集,就是CPU中用来计算和控制计算机系统的一套指令的集合,实现软件指挥硬件执行的媒介,常见的CPU指令集有X86、ARM、MIPS、Alpha、RISC等。
那么CPU是如何记录这些特权级信息的?
我们这里以80386CPU
为例,我们知道CPU里面有许多段寄存器(CS、DS、SS、ES、FS、GS等)。这些段寄存器里面存放段选择符(也叫段选择子):
段选择符中包含请求特权级RPL(CPL)字段,通过段选择符可以去查找全局描述符表GDT、局部描述符表LDT中对应的项,需要先进行特权级检查;这些项中都包含DPL字段(规定访问该段的权限级别),只有DPL >= max {CPL, RPL}
,才允许访问。
CPL很特殊,跟踪当前CPU正在执行的代码所在段的描述符中DPL的值,总是等于CPU的当前特权级.
内核态与用户态都是操作系统的层面的概念,和CPU硬件没有必然的联系;由于硬件已经提供了一套特权级使用的相关机制,Linux操作系统没有必要重新”造轮子”,直接使用了硬件的Ring0和Ring3
这两个级别的权限,也就是使用Ring3作为用户态,Ring0作为内核态。
那么为什么Linux系统仅使用了Ring0和Ring3
这两个级别?
因为CPU给的权限管理细度不够,比如Intel CPU
中Ring2
和Ring3
在操作系统里安全情况没有区别,Ring1
下的系统权限又需要经常调用Ring0
特权指令,频繁切换特权级成本过高,操作系统不如将Ring2
合并到Ring3
,将Ring1
划入Ring0
特权级。另一方面不是每种处理器都像x86
一样支持4个权限级别,有些处理器可能只支持2个级别,更少的特权级别,便于移植其他处理器架构上。我们再来看下linux的体系架构图:
我们可以发现Linux系统从整体上看,被划分为用户态和内核态:
- 内核态
内核态是处于操作系统的最核心处,Ring0
特权级,拥有操作系统的最高权限,能够控制所有的硬件资源,掌控各种核心数据,并且能够访问内存中的任意地址;由内核态统一管理这些核心资源,减少有限资源的访问和使用冲突;在内核里发生的任何程序异常都是灾难性的,会导致整个操作系统的崩溃。
- 用户态
用户态,就是我们通常编写程序的地方,处于Ring3
特权级,权限较低;这一层次的程序没有对硬件的直接控制权限,也不能直接访问地址的内存。在这种模式下,即使程序发生崩溃也不会影响其他程序,可恢复。
2. 什么是系统调用?
当计算机启动的时候,CPU处于Ring0状态,这个时候所有的指令都可以执行,通过主引导程序将磁盘扇区中的操作系统程序加载到内存中,从而启动操作系统(需要注意一下,本文的操作系统 以Linux0.12为例子)。
当Linux0.12启动的时候,是在权限最高级别的内核态运行的;同时对内存进行划分,划出一部分(内核区)专门给内核使用,这部分内存只能被内核使用;主内存区域给其他应用软件使用。
当操作系统启动完成后,CPU就切换到Ring3
级别上,操作系统同时进入用户态,之后的应用程序代码都运行在权限最低级别的用户态上,通常我们能编写的程序都运行在用户态上。
需要格外注意一下,CPU特权级其实并不会对操作系统的用户造成什么影响!我们可能会和Linux的用户权限搞混淆,无论是根用户(root),管理员,访客还是一般用户,它们都属于用户;而所有的用户代码都在用户态Ring3上执行,所有的内核代码都在内核态Ring0上执行,和Linux用户的身份权限并没有关系!
因为我们编写的程序都运行在用户态上,是无法对内存和I/O端口的访问,可以说基本上无法与外部世界交互,但是我们平时工作的时候访问磁盘、写文件,这些都是必要的需求,怎么办?
那就需要通过执行系统调用(system call),操作系统会切换到内核态,由内核去统一执行相关操作;当执行完操作系统再切换回用户态。这样方便集中管理,减少有限资源的访问和使用冲突。
系统调用是操作系统专门为用户态运行的进程与硬件设备之间进行交互提供了一组接口(API),是用户态主动要求切换到内核态的一种方式, 是 Linux 应用层进入内核的入口。不止 Linux 系统,所有的操作系统都会向应用层提供系统调用,应用程序通过系统调用来使用操作系统提供的各种服务。
通过系统调用, Linux 应用程序可以请求内核以自己的名义执行某些事情,例如打开磁盘中的文件、读写文件、关闭文件以及控制其它硬件外设。通过系统调用 API,应用层可以实现与内核的交互,其关系可通过下图简单描述:
内核提供了一系列的服务、资源、支持一系列功能,应用程序通过调用系统调用 API 函数来使用内核提供的服务、资源以及各种各样的功能。
3. 为什么要有系统调用?
其实前面学习系统调用概念的时候有提过,主要是计算机的各种硬件资源是有限的,为了更好的管理这些资源,用户进程是不允许直接操作的,所有对这些资源的访问都必须由操作系统控制,也就是说操作系统是使用这些资源的唯一入口。为此操作系统为用户态运行的进程与硬件设备之间进行交互提供了一组接口,这组接口就是所谓的系统调用。
在应用程序和硬件之间设置这样一个接口层有什么优点呢?
把用户从学习硬件设备的低级编程特性中解放出来。
系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间环节,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。比如这样就可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。
最重要的是,这些接口使得程序更具有可移植性,因为只要不同操作系统所提供的一组接口相同,那么在这些操作系统上就可以正确的编译和执行相同的程序。
4. 系统调用怎么实现的?
4.1 strace命令
在Linux系统中,strace命令是一个集诊断、调试、统计与一体的工具,可用来追踪调试程序,能够与其他命令搭配使用。Linux系统管理员可以在不需要源代码的情况下即可跟踪系统的调用。官网在这里:strace
strace命令会显示有关进程的系统调用的信息,这可以帮助确定一个程序使用的哪个函数,当然在系统出现问题时可以使用 strace定位系统调用过程中失败的原因,这是定位系统问题的很好的方法。这里只是简单提一下,知道有这么个工具,后续排查问题可能会比较有效。
怎么安装移植?可以看这篇笔记:01嵌入式开发/02IMX6ULL平台/LV03-应用开发/《LV07-04-strace命令-移植》
4.2 以库函数write为例
啊,没找到源码,参考下这个吧:linux-0.12/linux-0.12/lib/write.c at master · sumumm/linux-0.12 (github.com):
1 | /* |
write.c
这个文件主要是定义write的实现,_syscall3(*,write,*)
函数的主要功能是,向文件描述符fd指定的文件写入count个字节的数据到缓冲区buf中
需要注意一下#define __LIBRARY__
这个宏定义,这里定义直接原因是为了包括在unistd.h
中的内嵌汇编代码。
4.3 库函数扩展汇编宏
因为_syscall3
这个函数定义在/include/unistd.h
中,来看下源码(linux-0.12/linux-0.12/include/unistd.h at master · sumumm/linux-0.12 (github.com)):
1 | // /include/unistd.h |
只有在lib/write.c
中先定义了#define __LIBRARY__
,那么才能在/include/unistd.h
中,找到系统调用号和内嵌汇编_syscall3()
;不然就代表它不需要进行系统调用,这样就可以忽略unistd.h
中和系统调用相关的宏定义。其实我们可以把write.c中的write函数再重新整合一下:
1 | int write(int fd,const char* buf,off_t count) \ |
这样就能更容易明白#define __LIBRARY__
的作用。上面int $0x80"
表示调用系统中断0x80 ** ,其实系统调用的本质还是通过中断(0x80)去实现的**!
另外由于程序处于用户态无法直接操作硬件资源,所以需要进行系统调用,切换到内核态;也就是说用户程序如果使用库函数write
,会进行系统调用。而系统调用,其实就是去调用int 0x80
中断,然后把三个参数fd、buf、count
依次存入ebx、ecx、edx
寄存器。还有#define __NR_write 4
,定义了系统调用号;_NR_write
会被存入eax
寄存器;当调用返回后,从eax
取出返回值,存入__res
,建立了用户栈和内核栈的联系。至于__NR_write
的作用后面再学习。
4.4 int 0x80中断 调用对应的中断处理函数
我们来看下中断是调用对应的中断处理函数的流程图:
当发生中断的时候,CPU获取到中断向量号后,通过IDTR
,去查找IDT
中断描述符表,得到相应的中断描述符;然后根据描述符中的对应中断处理程序的入口地址,去执行中断处理程序.
早在linux0.12启动时,会进行调度程序初始化main.c/sched_init()
,其源码(linux-0.12/linux-0.12/kernel/sched.c at master · sumumm/linux-0.12 (github.com)):
1 | // /kernel/sched.c |
需要注意的是:在用户态和内核态运行的进程使用的栈是不同的,分别叫做用户栈和内核栈, 两者各自负责相应特权级别状态下的函数调用;所以当执行系统调用中断int 0x80
从用户态进入内核态时,会从用户栈切换到内核栈,系统调用返回时,还要切换回用户栈,继续完成用户态下的函数调用(这也叫做被中断进程上下文的保存与恢复)。
其中其关键作用的是,CPU会可以自动通过TR
寄存器找到当前进程的TSS
,然后根据里面ss0
和esp0
的值找到内核栈的位置,完成用户栈到内核栈的切换。
set_system_gate(0x80,&system_call)
这句整体作用是,设置系统调用中断门,将0x80
中断和函数system_call
绑定在一起,换句话说system_call
就是0x80
的中断处理函数。
4.5 检索系统调用函数表
我们接着去看system_call
函数的源码(linux-0.12/linux-0.12/kernel/sys_call.s at master · sumumm/linux-0.12 (github.com)):
1 | // /kernel/sys_call.s |
其中 _sys_call_table(,%eax,4)
,这里的eax
寄存器存放的就是_NR_write
系统调用号,_sys_call_table
是sys.h中的一个int (*)()
类型的数组,里面存的是所有的系统调用函数地址,也叫做系统调用函数表,所以__NR_write
也表示系统调用函数表中的索引值。
那为什么%eax * 4
乘上4呢?这是因为sys_call_table[]
指针每项4 个字节,这样被调用处理函数的地址=[_sys_call_table + %eax * 4]
。
我们再来看下sys_call_table
的定义(linux-0.12/linux-0.12/include/linux/sys.h at master · sumumm/linux-0.12 (github.com)):
1 | // /include/linux/sys.h |
可以知晓这里的call _sys_call_table(,%eax,4)
就是调用系统调用号所对应的内核系统调用函数sys_write
。
4.6 最终执行sys_write
sys_write
在fs下的read_write.c
(linux-0.12/linux-0.12/fs/read_write.c at master · sumumm/linux-0.12 (github.com)):
1 | // /fs/read_write.c |
至此库函数write,进行系统调用,最终调用了sys_write
这个函数。
4.7 总结
5. 内核态与用户态数据交互
到这里我们已经了解了系统调用的过程,还遗留一个问题需要去解决一下,就是内核态与用户态如何进行数据交互?回顾系统调用过程中,我们可以发现寄存器在其中起到了不可或缺的作用,linus在linux0.12
中也是采用类似的方法来进行数据交互。
我们这里继续以sys_write
函数为例,来看看里面的file_write(inode,file,buf,count);
,linux-0.12/linux-0.12/fs/file_dev.c at master · sumumm/linux-0.12 (github.com):
1 | // /fs/file_dev.c |
我们这里不详细看了,把目光聚焦于get_fs_byte
函数,我们来看下其源码(linux-0.12/linux-0.12/include/asm/segment.h at master · sumumm/linux-0.12 (github.com)):
1 | // include/asm/segment.h |
get_fs_byte
函数是从用户态拷贝一个字节的数据到内核态,而put_fs_byte
则恰恰相反,从内核态拷贝一个字节的数据到用户态。在系统调用运行整个过程中,DS和ES段寄存器指向内核数据空间,而FS段寄存器被设置为指向用户数据空间,这为啥?我们来看在/kernel/sys_call.s
(linux-0.12/linux-0.12/kernel/sys_call.s at master · sumumm/linux-0.12 (github.com))中_system_call
中的这段:
1 | _system_call: |
0x10
是全局描述符表GDT中内核数据段描述符的段值,0x17
是局部描述符表LDT中的任务的数据段描述符的段值,所以linux
这里利用FS
寄存器来完成内核数据空
间与用户数据空间
之间的数据复制,当进程从中断调用中退出时,寄存器会自动从内核栈弹出,快捷高效。
三、库函数
系统调用是内核直接向应用层提供的应用编程接口, 譬如 open、 write、read、 close 等,关于这些系统调用后面学习。 编写应用程序除了使用系统调用之外,我们还可以使用库函数。
库函数也就是 C 语言库函数, C 语言库是应用层使用的一套函数库, 在 Linux 下,通常以动态(.so)库文件的形式提供,存放在根文件系统/lib目录下, C 语言库函数构建于系统调用之上,也就是说库函数其实是由系统调用封装而来的,当然也不能完全这么说, 原因在于有些库函数并不调用任何系统调用,例如一些字符串处理函数 strlen()、 strcat()、 memcpy()、 memset()、 strchr()等等; 而有些库函数则会使用系统调用来帮它完成实际的操作,如库函数 fopen 内部调用了系统调用 open()来帮它打开文件、库函数 fread()就利用了系统调用 read()来完成读文件操作、 fwrite()就利用了系统调用 write()来完成写文件操作。
Linux 系统内核提供了一系列的系统调用供应用层使用, 我们直接使用系统调用就可以了呀,那为何还要设计出库函数呢?事实上, 有些系统调用使用起来并不是很方便, 于是就出现了 C 语言库,这些 C 语言库函数的设计是为了提供比底层系统调用更为方便、更为好用、 且更具有可移植性的调用接口。
库函数与系统调用
- 库函数是属于应用层,而系统调用是内核提供给应用层的编程接口,属于系统内核的一部分;
- 库函数运行在用户空间,调用系统调用会由用户空间(用户态)陷入到内核空间(内核态);
- 库函数通常是有缓存的,而系统调用是无缓存的,所以在性能、效率上,库函数通常要优于系统调用;
- 可移植性:库函数相比于系统调用具有更好的可移植性,通常对于不同的操作系统,其内核向应用层提供的系统调用往往都是不同,譬如系统调用的定义、功能、参数列表、返回值等往往都是不一样的;而对于 C 语言库函数来说,由于很多操作系统都实现了 C 语言库, C 语言库在不同的操作系统之间其接口定义几乎是一样的,所以库函数在不同操作系统之间相比于系统调用具有更好的可移植性。
从实现者的角度来看,系统调用与库函数之间有根本的区别,但从用户使用角度来看,其区别并不重要,它们都是 C 语言函数。 在实际应用编程中,库函数和系统调用都会使用到,所以对于我们来说,直接把它们当做是 C 函数即可,知道自己调用的函数是系统调用还是库函数即可,不用太过于区分它们之间的差别。所以应用编程简单点来说就是:开发 Linux 应用程序,通过调用内核提供的系统调用或使用 C 库函数来开发具有相应功能的应用程序。
四、标准C语言库函数
1. GNU C库
在 Linux 系统下,使用的 C 语言库为 GNU C 语言函数库(也叫作 glibc ,其网址为 The GNU C Library - GNU Project - Free Software Foundation),作为 Linux 下的标准 C 语言函数库。 我们点开官网,就可以看到下载的地方:
2. glibc版本?
我们怎么确定 Linux 系统的 glibc 版本 ?前面提到过了, C 语言库是以动态库文件的形式提供的,通常存放在/lib 目录,它的命名方式通常是
libc.so.6,不过这个是一个软链接文件,它会链接到真正的库文件。
进入到 Ubuntu 系统的/lib 目录下,我使用的 Ubuntu 版本为 20.04,在我的/lib 目录下并没有发现libc.so.6 这个文件, 其实是在/lib/x86_64-linux-gnu 目录下,进入到该目录:
可以看到 libc.so.6 链接到了 libc-2.23.so 库文件, 2.31表示的就是这个 glibc 库的版本号为 2.31。除此之外,我们还可以直接运行该共享库来获取到它的信息,如下所示:
从打印信息可以看到, 我所使用的 Ubuntu 系统对应的 glibc 版本号为 2.31。