暂时未有相关云产品技术能力~
0 引言1 简介2 安全策略3 Snap接口调用4 总结0 引言snap和flatpak都是新一代跨Linux发行版的软件包管理技术,上一篇我们简单介绍了flatpak的原理,今天我们接着简要介绍snap的安全机制。1 简介snap是Canoncial公司提出的新一代linux包管理工具,致力于将所有linux发行版上的包格式统一,做到“一次打包,到处使用”。目前,snap已经可以在包括Ubuntu、Fedora、Mint等多个Linux发行版上使用。首先,我们来了解下snap相关的各种名词:snap新一代跨Linux发行版的软件包管理技术,支持各大主流Linux发行版,通过Linux内核安全机制保证用户数据安全,彻底解决包依赖关系相关问题,并大大简化应用软件的打包工序。snap同时提供安装及管理snap包的命令行工具以及图形工具。snapd管理snap软件包的后台服务(守护进程,基于systemd、AppArmor和seccomp实现)。kernel snap使用snap格式打包的内核,包含内核镜像及内核模块。OS snap使用snap格式重新打包的rootfs,包含了运行和管理snap的基本资源。当你第一次安装snap时,OS snap首先被安装。snapcraft将软件打包成snap格式的打包工具集。snappy原指完全基于snap构建的系统,此名称已弃用,现统一称为Ubuntu Core,即Ubuntu的全snap操作系统,有别于传统基于deb包的classic Ubuntu。2 安全策略snap应用以沙箱的方式运行。系统通过一些机制限制应用访问资源的权限来实现其安全特性,比如通过对内核安全机制AppArmor,Seccomp等的配置实现的沙箱和snap文件系统等。开发者不必过多了解系统安全机制的细节。下面简要说明snap使用的部分安全策略。SandboxLinux Sandbox是根据内核中支持的一些安全机制实现的进程访问控制方式。通常通过为进程分配随机uid,将进程置于chroot环境和为进程uid配置Capability等方式将进程置于严格受限的一种状态下。snap应用使用这种方式运行在系统为其分配的沙箱环境中。security policy ID每一个snap应用程序命令都有唯一的security policy ID,系统将此ID与命令绑定,由此可以为同一snap包内的不同程序配置不同的安全策略。作为系统识别命令的标示,当程序安装和运行时,系统会根据其Security Policy ID为其分配资源。在沙箱中运行的snap应用之间的通信控制也通过此ID来进行配置。snap应用的security policy ID的命名规则为snap.name.command: 例如,hello程序的security policy ID即为snap.hello.hello。snap文件系统snap文件系统被划分为具有只读和读写两种不同权限的区域,每个snap应用有其独有的受限文件目录,如下图所示:可以通过如下方式查看某应用的文件访问权限:$ snap install hello hello 2.10 from 'canonical' installed $ snap run --shell hello.hello $ env | grep SNAP SNAP_USER_COMMON=/home/kylin/snap/hello/common # 单用户所有版本应用的可写目录 SNAP_LIBRARY_PATH=/var/lib/snapd/lib/gl:/var/lib/snapd/void # 增加到LD_LABRARY_PATH的目录 SNAP_COMMON=/var/snap/hello/common # 所有用户所有版本应用的可写目录 SNAP_USER_DATA=/home/kylin/snap/hello/20 # 单用户指定版本应用的可写目录 SNAP_DATA=/var/snap/hello/20 # 所有用户指定版本应用的可写目录由此可见,一个snap应用具有写权限的目录是极其有限的,并且每个snap应用都有其独立的可写目录。snap文件系统对snap应用相关目录的权限配置说明,这种方式实现了应用与应用,应用与系统之间的隔离。同时这种方式对snap应用的升级和回滚提供了很好的支持,升级时只需将确定版本的相关目录复制到更高版本的对应目录,而回退只需删除更高版本的目录。AppArmorAppArmor是一个强制访问控制策略,在内核层面对进程可访问的资源进行控制。 在snap应用程序安装时,系统会为其中的每一个命令生成其特有的AppArmor配置文件。内核对可执行程序的Capability限制也可以通过AppArmor来配置。当执行应用程序中的命令时,AppArmor机制可保证此命令不会越权访问。 作为一种内核中的安全机制,在ubuntu classic系统中也同时支持AppArmor提供的机制。但与classic系统不同,snap系统对程序的访问控制更加严格,基本做到“只满足程序执行所需的最小权限”。SeccompSeccomp是一个内核接口访问过滤器,snap应用程序通过其独有的过滤器访问内核接口。在snap应用程序启动之前会自动配置过滤器。Seccomp在snap系统中的作用类似于AppArmor。都是控制应用程序对系统资源的访问。3 Snap接口调用snap应用被严格限制在上面介绍的安全策略下,但snap应用之间也需要进行通信,比如硬件驱动作为一个snap应用肯定要为使用这个硬件的应用提供接口和服务。下面就简单说明一下snap应用之间的通信机制。3.1 默认安全策略在没有特殊配置时,snap应用使用默认的安全策略,其中包含之前提到的snap文件系统中的默认目录访问控制以及以下部分策略:snap应用安装目录的只读权限。共享内存的读写权限(ie. /dev/shm/snap.SNAP_NAME.*)相同应用的不同进程之间互相发送signal的权限3.2 安装模式snap通过不同的安装模式提供不同的资源访问控制。Devmodedevmode即为开发模式。使用以下命令将应用安装在此模式下:$ snap install hello --devmode $ snap list Name Version Rev Developer Notes core 16-2.26.9 2381 canonical - hello 2.10 20 canonical devmode pc 16.04-0.8 9 canonical - pc-kernel 4.4.0-83.106 68 canonical -此模式为应用程序提供完全的访问权限,但会在日志中记录程序的越权行为。在devmode下,snap应用只能访问/snap/下的文件。Classic此模式将取消所有访问限制,不会在日志中记录越权行为。在classic模式下,snap应用可以访问/下的文件。3.3 Interfaces除去默认安全策略为其提供的资源外,snap应用没有权限访问系统其它资源。若snap应用需要使用系统资源或其它应用程序提供的资源,需通过interfaces机制配置接口。interfaces接口分为两种,slot(服务提供者)和plug(服务使用者)。snap应用访问受限资源的示意如下:注意:操作系统在snap系统中也作为snap应用的形式存在。如图所示,通过配置snap应用的plug和slot即可实现snap应用的互相访问。查看系统上已经存在的plug和slot:$ snap interfaces Slot Plug :account-control - :alsa - :autopilot-introspection - :bluetooth-control - :browser-support - :camera - :classic-support classic :core-support core:core-support-plug .........下面以一个例子说明plug和slot的使用。name: blue ... apps: blue: command: bin/blue slots: [bluez]以上文件可作为一个蓝牙设备驱动程序的snap包打包控制文件。当此应用被安装时,系统将为其分配security policy ID为snap.blue.blue并包含规则:当blue启动时为其创建bluez slot。要在其它应用中使用这个slot提供的功能,则打包控制文件如下所示:name: blue-client ... apps: blue-client: command: bin/blue-client plugs: [bluez]同理,当此应用被安装时,系统将为其分配security policy ID为snap.blue-client.blue-client,并包含规则:允许此应用与snap.blue.blue通信。同时,提供slot的安全规则也会改写为:snap.blue.blue允许snap.blue-client.blue-client与其进行通信。下图说明了snap应用与应用之间在严格的分隔限制下的互相通信。snap系统由snap应用组成,包括系统和内核都以snap包的形式出现在系统中。各个snap包之间通过interfaces互相提供服务来完成协同工作,同时各个应用又不失自身的独立性。4 总结snap系统提供了强大的安全系统。与传统linux发行版相比,snap系统中的应用更加独立、安全,同时对snap应用权限的配置也更加简单。在日益增长的嵌入式和物联网需求与日益严峻的系统安全形势下,snap系统表现出了比传统linux发行版更突出的优势。
5.3 IOThe “io” controller regulates the distribution of IO resources. This controller implements both weight based and absolute bandwidth or IOPS limit distribution; however, weight based distribution is available only if cfq-iosched is in use and neither scheme is available for blk-mq devices.5.3.1 IO接口文件io.statA read-only nested-keyed file.Lines are keyed by MIN device numbers and not ordered. The following nested keys are defined.键值描述rbytesBytes readwbytesBytes writtenriosNumber of read IOswiosNumber of write IOsdbytesBytes discardeddiosNumber of discard IOsAn example read output follows:8:16 rbytes=1459200 wbytes=314773504 rios=192 wios=353 dbytes=0 dios=0 8:0 rbytes=90430464 wbytes=299008000 rios=8950 wios=1252 dbytes=50331648 dios=3021io.cost.qosA read-write nested-keyed file with exists only on the root cgroup.This file configures the Quality of Service of the IO cost model based controller (CONFIG_BLK_CGROUP_IOCOST) which currently implements “io.weight” proportional control. Lines are keyed by MIN device numbers and not ordered. The line for a given device is populated on the first write for the device on “io.cost.qos” or “io.cost.model”. The following nested keys are defined.键值描述enableWeight-based control enablectrl“auto” or “user”rpctRead latency percentile [0, 100]rlatRead latency thresholdwpctWrite latency percentile [0, 100]wlatWrite latency thresholdminMinimum scaling percentage [1, 10000]maxMaximum scaling percentage [1, 10000]The controller is disabled by default and can be enabled by setting “enable” to 1. “rpct” and “wpct” parameters default to zero and the controller uses internal device saturation state to adjust the overall IO rate between “min” and “max”.When a better control quality is needed, latency QoS parameters can be configured. For example:8:16 enable=1 ctrl=auto rpct=95.00 rlat=75000 wpct=95.00 wlat=150000 min=50.00 max=150.0shows that on sdb, the controller is enabled, will consider the device saturated if the 95th percentile of read completion latencies is above 75ms or write 150ms, and adjust the overall IO issue rate between 50% and 150% accordingly.The lower the saturation point, the better the latency QoS at the cost of aggregate bandwidth. The narrower the allowed adjustment range between “min” and “max”, the more conformant to the cost model the IO behavior. Note that the IO issue base rate may be far off from 100% and setting “min” and “max” blindly can lead to a significant loss of device capacity or control quality. “min” and “max” are useful for regulating devices which show wide temporary behavior changes - e.g. a ssd which accepts writes at the line speed for a while and then completely stalls for multiple seconds.When “ctrl” is “auto”, the parameters are controlled by the kernel and may change automatically. Setting “ctrl” to “user” or setting any of the percentile and latency parameters puts it into “user” mode and disables the automatic changes. The automatic mode can be restored by setting “ctrl” to “auto”.io.cost.modelA read-write nested-keyed file with exists only on the root cgroup.This file configures the cost model of the IO cost model based controller (CONFIG_BLK_CGROUP_IOCOST) which currently implements “io.weight” proportional control. Lines are keyed by MIN device numbers and not ordered. The line for a given device is populated on the first write for the device on “io.cost.qos” or “io.cost.model”. The following nested keys are defined.键值描述ctrl“auto” or “user”modelThe cost model in use - “linear”When “ctrl” is “auto”, the kernel may change all parameters dynamically. When “ctrl” is set to “user” or any other parameters are written to, “ctrl” become “user” and the automatic changes are disabled.When “model” is “linear”, the following model parameters are defined.键值描述`[rw]bps``[rw]seqiops``[rw]randiops`From the above, the builtin linear model determines the base costs of a sequential and random IO and the cost coefficient for the IO size. While simple, this model can cover most common device classes acceptably.The IO cost model isn’t expected to be accurate in absolute sense and is scaled to the device behavior dynamically.If needed, tools/cgroup/iocost_coef_gen.py can be used to generate device-specific coefficients.io.weightA read-write flat-keyed file which exists on non-root cgroups. The default is “default 100”.The first line is the default weight applied to devices without specific override. The rest are overrides keyed by MIN device numbers and not ordered. The weights are in the range [1, 10000] and specifies the relative amount IO time the cgroup can use in relation to its siblings.The default weight can be updated by writing either “default WEIGHT”. Overrides can be set by writing “MIN MAJ:$MIN default”.An example read output follows:default 100 8:16 200 8:0 50io.maxA read-write nested-keyed file which exists on non-root cgroups.BPS and IOPS based IO limit. Lines are keyed by MIN device numbers and not ordered. The following nested keys are defined.键值描述rbpsMax read bytes per secondwbpsMax write bytes per secondriopsMax read IO operations per secondwiopsMax write IO operations per secondWhen writing, any number of nested key-value pairs can be specified in any order. “max” can be specified as the value to remove a specific limit. If the same key is specified multiple times, the outcome is undefined.BPS and IOPS are measured in each IO direction and IOs are delayed if limit is reached. Temporary bursts are allowed.Setting read limit at 2M BPS and write at 120 IOPS for 8:16:echo "8:16 rbps=2097152 wiops=120" > io.maxReading returns the following:8:16 rbps=2097152 wbps=max riops=max wiops=120Write IOPS limit can be removed by writing the following:echo "8:16 wiops=max" > io.maxReading now returns the following:8:16 rbps=2097152 wbps=max riops=max wiops=maxio.pressureA read-only nested-key file which exists on non-root cgroups.Shows pressure stall information for IO. See PSI - Pressure Stall Information for details.5.3.2 回写(writeback)Page cache is dirtied through buffered writes and shared mmaps and written asynchronously to the backing filesystem by the writeback mechanism. Writeback sits between the memory and IO domains and regulates the proportion of dirty memory by balancing dirtying and write IOs.The io controller, in conjunction with the memory controller, implements control of page cache writeback IOs. The memory controller defines the memory domain that dirty memory ratio is calculated and maintained for and the io controller defines the io domain which writes out dirty pages for the memory domain. Both system-wide and per-cgroup dirty memory states are examined and the more restrictive of the two is enforced.cgroup writeback requires explicit support from the underlying filesystem. Currently, cgroup writeback is implemented on ext2, ext4, btrfs, f2fs, and xfs. On other filesystems, all writeback IOs are attributed to the root cgroup.There are inherent differences in memory and writeback management which affects how cgroup ownership is tracked. Memory is tracked per page while writeback per inode. For the purpose of writeback, an inode is assigned to a cgroup and all IO requests to write dirty pages from the inode are attributed to that cgroup.As cgroup ownership for memory is tracked per page, there can be pages which are associated with different cgroups than the one the inode is associated with. These are called foreign pages. The writeback constantly keeps track of foreign pages and, if a particular foreign cgroup becomes the majority over a certain period of time, switches the ownership of the inode to that cgroup.While this model is enough for most use cases where a given inode is mostly dirtied by a single cgroup even when the main writing cgroup changes over time, use cases where multiple cgroups write to a single inode simultaneously are not supported well. In such circumstances, a significant portion of IOs are likely to be attributed incorrectly. As memory controller assigns page ownership on the first use and doesn’t update it until the page is released, even if writeback strictly follows page ownership, multiple cgroups dirtying overlapping areas wouldn’t work as expected. It’s recommended to avoid such usage patterns.The sysctl knobs which affect writeback behavior are applied to cgroup writeback as follows.vm.dirty_background_ratio, vm.dirty_ratioThese ratios apply the same to cgroup writeback with the amount of available memory capped by limits imposed by the memory controller and system-wide clean memory.vm.dirty_background_bytes, vm.dirty_bytesFor cgroup writeback, this is calculated into ratio against total available memory and applied the same way as vm.dirty[_background]_ratio.5.3.3 IO延迟(IO Latency)This is a cgroup v2 controller for IO workload protection. You provide a group with a latency target, and if the average latency exceeds that target the controller will throttle any peers that have a lower latency target than the protected workload.The limits are only applied at the peer level in the hierarchy. This means that in the diagram below, only groups A, B, and C will influence each other, and groups D and F will influence each other. Group G will influence nobody::[root] / | \ A B C / \ | D F GSo the ideal way to configure this is to set io.latency in groups A, B, and C. Generally you do not want to set a value lower than the latency your device supports. Experiment to find the value that works best for your workload. Start at higher than the expected latency for your device and watch the avg_lat value in io.stat for your workload group to get an idea of the latency you see during normal operation. Use the avg_lat value as a basis for your real setting, setting at 10-15% higher than the value in io.stat.5.3.4 IO延迟节流如何工作io.latency is work conserving; so as long as everybody is meeting their latency target the controller doesn’t do anything. Once a group starts missing its target it begins throttling any peer group that has a higher target than itself. This throttling takes 2 forms:Queue depth throttling. This is the number of outstanding IO’s a group is allowed to have. We will clamp down relatively quickly, starting at no limit and going all the way down to 1 IO at a time.Artificial delay induction. There are certain types of IO that cannot be throttled without possibly adversely affecting higher priority groups. This includes swapping and metadata IO. These types of IO are allowed to occur normally, however they are “charged” to the originating group. If the originating group is being throttled you will see the use_delay and delay fields in io.stat increase. The delay value is how many microseconds that are being added to any process that runs in this group. Because this number can grow quite large if there is a lot of swapping or metadata IO occurring we limit the individual delay events to 1 second at a time.Once the victimized group starts meeting its latency target again it will start unthrottling any peer groups that were throttled previously. If the victimized group simply stops doing IO the global counter will unthrottle appropriately.5.3.5 IO延迟接口文件io.latencyThis takes a similar format as the other controllers.“MAJOR:MINOR target=<target time in microseconds”io.statIf the controller is enabled you will see extra stats in io.stat in addition to the normal ones.depthThis is the current queue depth for the group.avg_latThis is an exponential moving average with a decay rate of 1/exp bound by the sampling interval. The decay rate interval can be calculated by multiplying the win value in io.stat by the corresponding number of samples based on the win value.winThe sampling window size in milliseconds. This is the minimum duration of time between evaluation events. Windows only elapse with IO activity. Idle periods extend the most recent window.5.4 PIDThe process number controller is used to allow a cgroup to stop any new tasks from being fork()’d or clone()’d after a specified limit is reached.The number of tasks in a cgroup can be exhausted in ways which other controllers cannot prevent, thus warranting its own controller. For example, a fork bomb is likely to exhaust the number of tasks before hitting memory restrictions.Note that PIDs used in this controller refer to TIDs, process IDs as used by the kernel.5.4.1 PID接口文件pids.maxA read-write single value file which exists on non-root cgroups. The default is “max”.Hard limit of number of processes.pids.currentA read-only single value file which exists on all cgroups.The number of processes currently in the cgroup and its descendants.Organisational operations are not blocked by cgroup policies, so it is possible to have pids.current > pids.max. This can be done by either setting the limit to be smaller than pids.current, or attaching enough processes to the cgroup such that pids.current is larger than pids.max. However, it is not possible to violate a cgroup PID policy through fork() or clone(). These will return -EAGAIN if the creation of a new process would cause a cgroup policy to be violated.5.5 CpusetThe “cpuset” controller provides a mechanism for constraining the CPU and memory node placement of tasks to only the resources specified in the cpuset interface files in a task’s current cgroup. This is especially valuable on large NUMA systems where placing jobs on properly sized subsets of the systems with careful processor and memory placement to reduce cross-node memory access and contention can improve overall system performance.The “cpuset” controller is hierarchical. That means the controller cannot use CPUs or memory nodes not allowed in its parent.5.5.1 cpuset接口文件cpuset.cpusA read-write multiple values file which exists on non-root cpuset-enabled cgroups.It lists the requested CPUs to be used by tasks within this cgroup. The actual list of CPUs to be granted, however, is subjected to constraints imposed by its parent and can differ from the requested CPUs.The CPU numbers are comma-separated numbers or ranges. For example:# cat cpuset.cpus 0-4,6,8-10An empty value indicates that the cgroup is using the same setting as the nearest cgroup ancestor with a non-empty “cpuset.cpus” or all the available CPUs if none is found.The value of “cpuset.cpus” stays constant until the next update and won’t be affected by any CPU hotplug events.cpuset.cpus.effectiveA read-only multiple values file which exists on all cpuset-enabled cgroups.It lists the onlined CPUs that are actually granted to this cgroup by its parent. These CPUs are allowed to be used by tasks within the current cgroup.If “cpuset.cpus” is empty, the “cpuset.cpus.effective” file shows all the CPUs from the parent cgroup that can be available to be used by this cgroup. Otherwise, it should be a subset of “cpuset.cpus” unless none of the CPUs listed in “cpuset.cpus” can be granted. In this case, it will be treated just like an empty “cpuset.cpus”.Its value will be affected by CPU hotplug events.cpuset.memsA read-write multiple values file which exists on non-root cpuset-enabled cgroups.It lists the requested memory nodes to be used by tasks within this cgroup. The actual list of memory nodes granted, however, is subjected to constraints imposed by its parent and can differ from the requested memory nodes.The memory node numbers are comma-separated numbers or ranges. For example:# cat cpuset.mems 0-1,3An empty value indicates that the cgroup is using the same setting as the nearest cgroup ancestor with a non-empty “cpuset.mems” or all the available memory nodes if none is found.The value of “cpuset.mems” stays constant until the next update and won’t be affected by any memory nodes hotplug events.cpuset.mems.effectiveA read-only multiple values file which exists on all cpuset-enabled cgroups.It lists the onlined memory nodes that are actually granted to this cgroup by its parent. These memory nodes are allowed to be used by tasks within the current cgroup.If “cpuset.mems” is empty, it shows all the memory nodes from the parent cgroup that will be available to be used by this cgroup. Otherwise, it should be a subset of “cpuset.mems” unless none of the memory nodes listed in “cpuset.mems” can be granted. In this case, it will be treated just like an empty “cpuset.mems”.Its value will be affected by memory nodes hotplug events.cpuset.cpus.partitionA read-write single value file which exists on non-root cpuset-enabled cgroups. This flag is owned by the parent cgroup and is not delegatable.It accepts only the following input values when written to.“root” - a partition root “member” - a non-root member of a partitionWhen set to be a partition root, the current cgroup is the root of a new partition or scheduling domain that comprises itself and all its descendants except those that are separate partition roots themselves and their descendants. The root cgroup is always a partition root.There are constraints on where a partition root can be set. It can only be set in a cgroup if all the following conditions are true.Setting it to partition root will take the CPUs away from the effective CPUs of the parent cgroup. Once it is set, this file cannot be reverted back to “member” if there are any child cgroups with cpuset enabled.A parent partition cannot distribute all its CPUs to its child partitions. There must be at least one cpu left in the parent partition.Once becoming a partition root, changes to “cpuset.cpus” is generally allowed as long as the first condition above is true, the change will not take away all the CPUs from the parent partition and the new “cpuset.cpus” value is a superset of its children’s “cpuset.cpus” values.Sometimes, external factors like changes to ancestors’ “cpuset.cpus” or cpu hotplug can cause the state of the partition root to change. On read, the “cpuset.sched.partition” file can show the following values.“member” Non-root member of a partition “root” Partition root “root invalid” Invalid partition rootIt is a partition root if the first 2 partition root conditions above are true and at least one CPU from “cpuset.cpus” is granted by the parent cgroup.A partition root can become invalid if none of CPUs requested in “cpuset.cpus” can be granted by the parent cgroup or the parent cgroup is no longer a partition root itself. In this case, it is not a real partition even though the restriction of the first partition root condition above will still apply. The cpu affinity of all the tasks in the cgroup will then be associated with CPUs in the nearest ancestor partition.An invalid partition root can be transitioned back to a real partition root if at least one of the requested CPUs can now be granted by its parent. In this case, the cpu affinity of all the tasks in the formerly invalid partition will be associated to the CPUs of the newly formed partition. Changing the partition state of an invalid partition root to “member” is always allowed even if child cpusets are present.The “cpuset.cpus” is not empty and the list of CPUs are exclusive, i.e. they are not shared by any of its siblings.The parent cgroup is a partition root.The “cpuset.cpus” is also a proper subset of the parent’s “cpuset.cpus.effective”.There is no child cgroups with cpuset enabled. This is for eliminating corner cases that have to be handled if such a condition is allowed.5.6 设备控制器设备控制器管理对设备文件的访问,包括创建新的设备文件(使用mknod),访问已存在的设备文件。cgroupv2设备控制器没有接口文件,是在cgroup BPF之上实现的。为了控制对设备文件的访问,用户需要创建BPF_CGROUP_DEVICE类型的bpf程序,并将其附加到对应的cgroup上。一旦尝试访问某个设备文件,对应的BPF程序就会被执行,依赖这个返回值,访问是否成功还是失败(-EPERM)。BPF_CGROUP_DEVICE类型的程序会接受一个bpf_cgroup_dev_ctx数据类型的指针,其描述了尝试访问的设备:访问类型(mknod/read/write)和设备(type,major和minor)。如果程序返回0,则访问失败并返回-EPERM,否则访问成功。在内核源代码目录下有一个BPF_CGROUP_DEVICE程序的示例,tools/testing/selftests/bpf/dev_cgroup.c。5.7 RDMAThe “rdma” controller regulates the distribution and accounting of RDMA resources.5.7.1 RDMA接口文件rdma.maxA readwrite nested-keyed file that exists for all the cgroups except root that describes current configured resource limit for a RDMA/IB device.Lines are keyed by device name and are not ordered. Each line contains space separated resource name and its configured limit that can be distributed.The following nested keys are defined.键值描述hca_handleMaximum number of HCA Handleshca_objectMaximum number of HCA ObjectsAn example for mlx4 and ocrdma device follows:mlx4_0 hca_handle=2 hca_object=2000 ocrdma1 hca_handle=3 hca_object=maxrdma.currentA read-only file that describes current resource usage. It exists for all the cgroup except root.An example for mlx4 and ocrdma device follows:mlx4_0 hca_handle=1 hca_object=20 ocrdma1 hca_handle=1 hca_object=235.8 HugeTLBThe HugeTLB controller allows to limit the HugeTLB usage per control group and enforces the controller limit during page fault.5.8.1 HugeTLB接口文件hugetlb.<hugepagesize>.currentShow current usage for “hugepagesize” hugetlb. It exists for all the cgroup except root.hugetlb.<hugepagesize>.maxSet/show the hard limit of “hugepagesize” hugetlb usage. The default value is “max”. It exists for all the cgroup except root.hugetlb.<hugepagesize>.eventsA read-only flat-keyed file which exists on non-root cgroups.maxThe number of allocation failure due to HugeTLB limithugetlb.<hugepagesize>.events.localSimilar to hugetlb..events but the fields in the file are local to the cgroup i.e. not hierarchical. The file modified event generated on this file reflects only the local events.5.9 Misc5.9.1 perf_eventperf_event controller, if not mounted on a legacy hierarchy, is automatically enabled on the v2 hierarchy so that perf events can always be filtered by cgroup v2 path. The controller can still be moved to a legacy hierarchy after v2 hierarchy is populated.5.10 非规范化的信息This section contains information that isn’t considered to be a part of the stable kernel API and so is subject to change.5.10.1 CPU controller root cgroup process behaviourWhen distributing CPU cycles in the root cgroup each thread in this cgroup is treated as if it was hosted in a separate child cgroup of the root cgroup. This child cgroup weight is dependent on its thread nice level.For details of this mapping see sched_prio_to_weight array in kernel/sched/core.c file (values from this array should be scaled appropriately so the neutral - nice 0 - value is 100 instead of 1024).5.10.2 IO controller root cgroup process behaviourRoot cgroup processes are hosted in an implicit leaf child node. When distributing IO resources this implicit child node is taken into account as if it was a normal child cgroup of the root cgroup with a weight value of 200.6 命名空间容器环境中用cgroup和其它一些namespace来隔离进程,但/proc/$PID/cgroup文件可能会泄露潜在的系统层信息。例如:$ cat /proc/self/cgroup 0::/batchjobs/container_id1 # <-- cgroup 的绝对路径,属于系统层信息,不希望暴露给隔离的进程因此引入了 cgroup namespace,以下简写为cgroupns(类似于network namespace简写为netns)。6.1 基础cgroup namespace提供了一种机制,用来虚拟/proc/$PID/cgroup文件和cgroup挂载点的视角。clone(2)和unshare(2)系统调用,使用CLONE_NEWCGROUP克隆标志,可以创建新的cgroup namespace命名空间。运行在该命名空间中的进程将/proc/$PID/cgroup输出限制为cgroupns root根目录。cgroupns root是创建cgroup namespace的进程所在的cgroup。如果没有cgroup namespace,/proc/$PID/cgroup文件显示的是进程所属的cgroup的完整路径。比如,我们在配置容器时,会设置一组cgroup和namespaces,用来隔离进程,但是,/proc/$PID/cgroup可能会向隔离进程泄露潜在的系统信息。例如:# cat /proc/self/cgroup 0::/batchjobs/container_id1路径信息/batchjobs/container_id1被认为是系统数据,不想暴露给隔离的进程们。cgroup namespace就是用来隐匿这种路径信息的方法。例如,在没有创建cgroup namespace之前:# ls -l /proc/self/ns/cgroup lrwxrwxrwx 1 root root 0 2014-07-15 10:37 /proc/self/ns/cgroup -> cgroup:[4026531835] # cat /proc/self/cgroup 0::/batchjobs/container_id1在创建一个新的命名空间并取消共享后,我们只能看到根路径:# ls -l /proc/self/ns/cgroup lrwxrwxrwx 1 root root 0 2014-07-15 10:35 /proc/self/ns/cgroup -> cgroup:[4026532183] # cat /proc/self/cgroup 0::/对于多线程的进程,任何一个线程通过unshare创建新cgroupns时,整个进程(所有线程)都会进入到新的cgroupns。这对v2 hierarchy是很自然的事情,但对v1来说,可能是不期望的行为。cgroup namespace的生命周期:只要满足下面两个条件之一,该命名空间就处于激活状态中:命名空间中还有进程存活挂载的文件系统中,还有对象固定到这个cgroup namespace上当其中最后一个进程退出,或挂载的文件系统卸载后,cgroup namespace就会被销毁,但cgroupns root和真实的cgroup仍然存在。6.2 The Root and Viewscgroup namespace的根空间cgroupns root就是调用unshare(2)的进程所在的cgroup。例如,/batchjobs/container_id1这个cgroup中的进程调用了unshare,那么/batchjobs/container_id1就成了cgroupns root根空间。对于init_cgroup_ns,这是真正的root(/)cgroup。即使创建命名空间的进程迁移到了另外一个cgroup中,cgroupns root cgroup也不会发生改变:# ~/unshare -c # 在某个cgroup中取消命名空间的共享 # cat /proc/self/cgroup 0::/ # mkdir sub_cgrp_1 # echo 0 > sub_cgrp_1/cgroup.procs # cat /proc/self/cgroup 0::/sub_cgrp_1每个进程都会得到特定的命名空间视角,可以通过/proc/$PID/cgroup查看。运行在命名空间中的进程,只能看到它们root cgroup。对于一个取消共享的cgroup命名空间:# sleep 100000 & [1] 7353 # echo 7353 > sub_cgrp_1/cgroup.procs # cat /proc/7353/cgroup 0::/sub_cgrp_1对于初始cgroup命名空间,能够看到真正的cgroup路径:$ cat /proc/7353/cgroup 0::/batchjobs/container_id1/sub_cgrp_1在兄弟cgroup命名空间中(也就是说,另一个起源于不同cgroup的命名空间),将会显示相对于它自己的根命名空间的相对cgroup路径。例如,进程(7353)的根命名空间是/batchjobs/container_id2,则它看到的会是:# cat /proc/7353/cgroup 0::/../container_id2/sub_cgrp_1相对路径都是以/开头,这是表示它是相对于调用者根命名空间的。6.3 在 cgroupns 之间迁移进程cgroup命名空间中的进程,如果能够正确访问外部的cgroup,则能够迁入或迁出命名空间的root位置。假设命名空间的root位置在/batchjobs/container_id1,并且能够从该命名空间访问全局hierarchy:# cat /proc/7353/cgroup 0::/sub_cgrp_1 # echo 7353 > batchjobs/container_id2/cgroup.procs # cat /proc/7353/cgroup 0::/../container_id2注意,这类配置不鼓励。cgroup命名空间内的任务只应该暴露给它自己的cgroupns hierarchy。还可以使用setns(2)将进程迁移到其它cgroup命名空间中,前提条件是:进程对自己当前用户的命名空间具有CAP_SYS_ADMIN能力进程对目标cgroup命名空间中的用户命名空间具有CAP_SYS_ADMIN能力将一个进程附加到另一个cgroup命名空间中不会发生隐含的cgroup改变。预期是将要附加的进程迁移到目标cgroup的命名空间的root位置。6.4 与其它命名空间的交互具体命名空间的cgroup hierarchy可以由non-init cgroup命名空间内的进程挂载# mount -t <fstype> <device> <dir> $ mount -t cgroup2 none $MOUNT_POINT这将会挂载默认的unified cgroup hierarchy(可以理解为文件系统),这会把cgroup命名空间的root位置作为文件系统的root节点。这个操作需要用户和挂载命名空间具有CAP_SYS_ADMIN权限能力。/proc/self/cgroup文件的虚拟化,加上限制cgroup hierarchy的视角(通过命名空间私有的cgroupfs挂载),实现了容器内部一个独立的cgroup视角。7 内核编程的信息This section contains kernel programming information in the areas where interacting with cgroup is necessary. cgroup core and controllers are not covered.7.1 Filesystem Support for WritebackA filesystem can support cgroup writeback by updating address_space_operations->writepages to annotate bio’s using the following two functions.wbc_init_bio(@wbc, @bio)Should be called for each bio carrying writeback data and associates the bio with the inode’s owner cgroup and the corresponding request queue. This must be called after a queue (device) has been associated with the bio and before submission.wbc_account_cgroup_owner(@wbc, @page, @bytes)Should be called for each data segment being written out. While this function doesn’t care exactly when it’s called during the writeback session, it’s the easiest and most natural to call it as data segments are added to a bio.With writeback bio’s annotated, cgroup support can be enabled per super_block by setting SB_I_CGROUPWB in ->s_iflags. This allows for selective disabling of cgroup writeback support which is helpful when certain filesystem features, e.g. journaled data mode, are incompatible.wbc_init_bio() binds the specified bio to its cgroup. Depending on the configuration, the bio may be executed at a lower priority and if the writeback session is holding shared resources, e.g. a journal entry, may lead to priority inversion. There is no one easy solution for the problem. Filesystems can try to work around specific problem cases by skipping wbc_init_bio() and using bio_associate_blkg() directly.8 废弃的v1特性不支持多个hierarchy不支持所有的v1挂载选项所有的tasks文件被移除,使用cgroup.procs,而且不排序cgroup.clone_children文件被移除/proc/cgroups对于v2没有意义。在root使用cgroup.controllers文件代替9 Issues with v1 and Rationales for v29.1 多层hierarchy带来的问题v1允许任意数量的hierarchy,每个hierarchy可使用任意数量的controller。看上去提供了高度灵活性,实际上却没有用。For example, as there is only one instance of each controller, utility type controllers such as freezer which can be useful in all hierarchies could only be used in one. The issue is exacerbated by the fact that controllers couldn’t be moved to another hierarchy once hierarchies were populated. Another issue was that all controllers bound to a hierarchy were forced to have exactly the same view of the hierarchy. It wasn’t possible to vary the granularity depending on the specific controller.In practice, these issues heavily limited which controllers could be put on the same hierarchy and most configurations resorted to putting each controller on its own hierarchy. Only closely related ones, such as the cpu and cpuacct controllers, made sense to be put on the same hierarchy. This often meant that userland ended up managing multiple similar hierarchies repeating the same steps on each hierarchy whenever a hierarchy management operation was necessary.Furthermore, support for multiple hierarchies came at a steep cost. It greatly complicated cgroup core implementation but more importantly the support for multiple hierarchies restricted how cgroup could be used in general and what controllers was able to do.There was no limit on how many hierarchies there might be, which meant that a thread’s cgroup membership couldn’t be described in finite length. The key might contain any number of entries and was unlimited in length, which made it highly awkward to manipulate and led to addition of controllers which existed only to identify membership, which in turn exacerbated the original problem of proliferating number of hierarchies.Also, as a controller couldn’t have any expectation regarding the topologies of hierarchies other controllers might be on, each controller had to assume that all other controllers were attached to completely orthogonal hierarchies. This made it impossible, or at least very cumbersome, for controllers to cooperate with each other.In most use cases, putting controllers on hierarchies which are completely orthogonal to each other isn’t necessary. What usually is called for is the ability to have differing levels of granularity depending on the specific controller. In other words, hierarchy may be collapsed from leaf towards root when viewed from specific controllers. For example, a given configuration might not care about how memory is distributed beyond a certain level while still wanting to control how CPU cycles are distributed.9.2 Thread Granularitycgroup v1 allowed threads of a process to belong to different cgroups. This didn’t make sense for some controllers and those controllers ended up implementing different ways to ignore such situations but much more importantly it blurred the line between API exposed to individual applications and system management interface.Generally, in-process knowledge is available only to the process itself; thus, unlike service-level organization of processes, categorizing threads of a process requires active participation from the application which owns the target process.cgroup v1 had an ambiguously defined delegation model which got abused in combination with thread granularity. cgroups were delegated to individual applications so that they can create and manage their own sub-hierarchies and control resource distributions along them. This effectively raised cgroup to the status of a syscall-like API exposed to lay programs.First of all, cgroup has a fundamentally inadequate interface to be exposed this way. For a process to access its own knobs, it has to extract the path on the target hierarchy from /proc/self/cgroup, construct the path by appending the name of the knob to the path, open and then read and/or write to it. This is not only extremely clunky and unusual but also inherently racy. There is no conventional way to define transaction across the required steps and nothing can guarantee that the process would actually be operating on its own sub-hierarchy.cgroup controllers implemented a number of knobs which would never be accepted as public APIs because they were just adding control knobs to system-management pseudo filesystem. cgroup ended up with interface knobs which were not properly abstracted or refined and directly revealed kernel internal details. These knobs got exposed to individual applications through the ill-defined delegation mechanism effectively abusing cgroup as a shortcut to implementing public APIs without going through the required scrutiny.This was painful for both userland and kernel. Userland ended up with misbehaving and poorly abstracted interfaces and kernel exposing and locked into constructs inadvertently.9.3 Competition Between Inner Nodes and Threadscgroup v1 allowed threads to be in any cgroups which created an interesting problem where threads belonging to a parent cgroup and its children cgroups competed for resources. This was nasty as two different types of entities competed and there was no obvious way to settle it. Different controllers did different things.The cpu controller considered threads and cgroups as equivalents and mapped nice levels to cgroup weights. This worked for some cases but fell flat when children wanted to be allocated specific ratios of CPU cycles and the number of internal threads fluctuated - the ratios constantly changed as the number of competing entities fluctuated. There also were other issues. The mapping from nice level to weight wasn’t obvious or universal, and there were various other knobs which simply weren’t available for threads.The io controller implicitly created a hidden leaf node for each cgroup to host the threads. The hidden leaf had its own copies of all the knobs with leaf_ prefixed. While this allowed equivalent control over internal threads, it was with serious drawbacks. It always added an extra layer of nesting which wouldn’t be necessary otherwise, made the interface messy and significantly complicated the implementation.The memory controller didn’t have a way to control what happened between internal tasks and child cgroups and the behavior was not clearly defined. There were attempts to add ad-hoc behaviors and knobs to tailor the behavior to specific workloads which would have led to problems extremely difficult to resolve in the long term.Multiple controllers struggled with internal tasks and came up with different ways to deal with it; unfortunately, all the approaches were severely flawed and, furthermore, the widely different behaviors made cgroup as a whole highly inconsistent.This clearly is a problem which needs to be addressed from cgroup core in a uniform way.9.4 Other Interface Issuescgroup v1 grew without oversight and developed a large number of idiosyncrasies and inconsistencies. One issue on the cgroup core side was how an empty cgroup was notified - a userland helper binary was forked and executed for each event. The event delivery wasn’t recursive or delegatable. The limitations of the mechanism also led to in-kernel event delivery filtering mechanism further complicating the interface.Controller interfaces were problematic too. An extreme example is controllers completely ignoring hierarchical organization and treating all cgroups as if they were all located directly under the root cgroup. Some controllers exposed a large amount of inconsistent implementation details to userland.There also was no consistency across controllers. When a new cgroup was created, some controllers defaulted to not imposing extra restrictions while others disallowed any resource usage until explicitly configured. Configuration knobs for the same type of control used widely differing naming schemes and formats. Statistics and information knobs were named arbitrarily and used different formats and units even in the same controller.cgroup v2 establishes common conventions where appropriate and updates controllers so that they expose minimal and consistent interfaces.9.5 Controller Issues and Remedies9.5.1 MemoryThe original lower boundary, the soft limit, is defined as a limit that is per default unset. As a result, the set of cgroups that global reclaim prefers is opt-in, rather than opt-out. The costs for optimizing these mostly negative lookups are so high that the implementation, despite its enormous size, does not even provide the basic desirable behavior. First off, the soft limit has no hierarchical meaning. All configured groups are organized in a global rbtree and treated like equal peers, regardless where they are located in the hierarchy. This makes subtree delegation impossible. Second, the soft limit reclaim pass is so aggressive that it not just introduces high allocation latencies into the system, but also impacts system performance due to overreclaim, to the point where the feature becomes self-defeating.The memory.low boundary on the other hand is a top-down allocated reserve. A cgroup enjoys reclaim protection when it’s within its effective low, which makes delegation of subtrees possible. It also enjoys having reclaim pressure proportional to its overage when above its effective low.The original high boundary, the hard limit, is defined as a strict limit that can not budge, even if the OOM killer has to be called. But this generally goes against the goal of making the most out of the available memory. The memory consumption of workloads varies during runtime, and that requires users to overcommit. But doing that with a strict upper limit requires either a fairly accurate prediction of the working set size or adding slack to the limit. Since working set size estimation is hard and error prone, and getting it wrong results in OOM kills, most users tend to err on the side of a looser limit and end up wasting precious resources.The memory.high boundary on the other hand can be set much more conservatively. When hit, it throttles allocations by forcing them into direct reclaim to work off the excess, but it never invokes the OOM killer. As a result, a high boundary that is chosen too aggressively will not terminate the processes, but instead it will lead to gradual performance degradation. The user can monitor this and make corrections until the minimal memory footprint that still gives acceptable performance is found.In extreme cases, with many concurrent allocations and a complete breakdown of reclaim progress within the group, the high boundary can be exceeded. But even then it’s mostly better to satisfy the allocation from the slack available in other groups or the rest of the system than killing the group. Otherwise, memory.max is there to limit this type of spillover and ultimately contain buggy or even malicious applications.Setting the original memory.limit_in_bytes below the current usage was subject to a race condition, where concurrent charges could cause the limit setting to fail. memory.max on the other hand will first set the limit to prevent new charges, and then reclaim and OOM kill until the new limit is met - or the task writing to memory.max is killed.The combined memory+swap accounting and limiting is replaced by real control over swap space.The main argument for a combined memory+swap facility in the original cgroup design was that global or parental pressure would always be able to swap all anonymous memory of a child group, regardless of the child’s own (possibly untrusted) configuration. However, untrusted groups can sabotage swapping by other means - such as referencing its anonymous memory in a tight loop - and an admin can not assume full swappability when overcommitting untrusted jobs.For trusted jobs, on the other hand, a combined counter is not an intuitive userspace interface, and it flies in the face of the idea that cgroup controllers should account and limit specific physical resources. Swap space is a resource like all others in the system, and that’s why unified hierarchy allows distributing it separately.
译者序本文翻译自2021年Linux 5.10内核文档:Control Group v2,它是描述cgroupv2用户空间侧的设计、接口和规范的权威文档。原文非常全面详细,本文只翻译了目前感兴趣的部分,其他部分保留原文。 另外,由于技术规范的描述比较抽象,因此翻译时加了一些系统测试输出、内核代码片段和 链接,便于更好理解。由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。以下是译文。1 引言2 基本操作3 资源分配模型4 接口文件5 控制器6 命名空间7 内核编程的信息8 废弃的v1特性9 Issues with v1 and Rationales for v2本文是描述cgroup v2设计、接口和规范的权威文档。未来所有改动/变化都需反应到本文档中。v1的文档见 cgroup-v1。本文描述cgroup v2所有用户空间可见的部分,包括cgroup core和各controller。1 引言1.1 术语cgroup是control group的缩写,并且首字母永远不大写(never capitalized)。单数形式(cgroup)指这个特性,或用于 “cgroup controllers” 等术语中的修饰词。复数形式(cgroups)显式地指多个cgroup。1.2 cgroup是什么?cgroup是以受控的、可配置的方法,按照树形层级结构组织进程,并依据层次结构分配系统资源的一种机制。1.2.1 cgroup组成cgroup主要由两部分组成:核心(core):主要负责层级化地组织进程;控制器(controllers):大部分控制器负责cgroup层级中特定类型的系统资源的分配,少部分工具类控制器用于其它目的。1.2.2 cgroup与进程/线程的关系cgroup以树形结构组织:系统中每个进程有且仅属于一个cgroup;一个进程的所有线程属于同一个cgroup;创建子进程时,继承其父进程的cgroup;一个进程可以被迁移到其它cgroup;迁移一个进程时,子进程(后代进程)不会自动跟着一起迁移;1.2.3 控制器遵循特定的结构规范,可以选择性地针对一个cgroup启用或禁用某些控制器;控制器的所有行为都是分层结构化的。如果一个cgroup启用了某个控制器,那这个cgroup中的、以及子层次结构中的所有进程都会受控制。在层次结构中,靠近root节点上的资源限制设置,不会被子层次上的设置覆盖。2 基本操作2.1 挂载与v1不同,cgroup v2只有单个层级树(single hierarchy)。 用如下命令挂载cgroup v2 hierarchy(cgroup v2 hierarchy等效于一个文件系统):# mount -t <fstype> <device> <dir> mount -t cgroup2 none $MOUNT_POINT2.1.1 控制器与v1/v2绑定关系cgroupv2文件系统的magic number是0x63677270(“cgrp”)。所有支持v2且未绑定到v1的控制器,会被自动绑定到v2 hierarchy,出现在root层级中。v2中未在使用的控制器,可以绑定到其它层级中(hierarchies)。这说明,我们可以以完全向后兼容的方式混用v2和v1。示例:**ubuntu 20.04同时挂载cgroupv1/cgroupv2**(译者添注)查看 ubuntu 20.04(5.11内核)cgroup相关的挂载点:~$ mount | grep cgroup tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755,inode64) cgroup2 on /sys/fs/cgroup/unified type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate) cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,name=systemd) cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct) cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio) cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices) cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb) cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids) cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer) cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma) cgroup on /sys/fs/cgroup/misc type cgroup (rw,nosuid,nodev,noexec,relatime,misc)可以看到,系统同时挂载了cgroup和cgroup2:cgroup v2是单一层级树,因此只有一个挂载点(第二行)/sys/fs/cgroup/unified,这就是上一小节所说的root层级。cgroup v1根据控制器类型(cpuset/cpu,cpuacct/hugetlb/...),挂载到不同位置。接下来看哪些控制绑定到了 cgroup v2:$ ls -ahlp /sys/fs/cgroup/unified/ total 0 -r--r--r-- 1 root root 0 cgroup.controllers -rw-r--r-- 1 root root 0 cgroup.max.depth -rw-r--r-- 1 root root 0 cgroup.max.descendants -rw-r--r-- 1 root root 0 cgroup.procs -r--r--r-- 1 root root 0 cgroup.stat -rw-r--r-- 1 root root 0 cgroup.subtree_control -rw-r--r-- 1 root root 0 cgroup.threads -rw-r--r-- 1 root root 0 cpu.pressure -r--r--r-- 1 root root 0 cpu.stat drwxr-xr-x 2 root root 0 init.scope/ -rw-r--r-- 1 root root 0 io.pressure -rw-r--r-- 1 root root 0 memory.pressure drwxr-xr-x 121 root root 0 system.slice/ drwxr-xr-x 3 root root 0 user.slice/只有cpu/io/memory等少量控制器(大部分还在cgroup v1中,系统默认使用v1)。最后看几个控制器文件的内容,加深一点直观印象,后面章节会详细解释这些分别表示什么意思:$ cd /sys/fs/cgroup/unified $ cat cpu.pressure some avg10=0.00 avg60=0.00 avg300=0.00 total=2501067303 $ cat cpu.stat usage_usec 44110960000 user_usec 29991256000 system_usec 14119704000 $ cat io.pressure some avg10=0.00 avg60=0.00 avg300=0.00 total=299044042 full avg10=0.00 avg60=0.00 avg300=0.00 total=271257559 $ cat memory.pressure some avg10=0.00 avg60=0.00 avg300=0.00 total=298215 full avg10=0.00 avg60=0.00 avg300=0.00 total=2298432.1.2 控制器在v1和v2之间切换控制器在当前hierarchy中已经不再被引用,才能移动到其它hierarchy。由于每个cgroup控制器状态是异步销毁的,从v1卸载之后可能会有引用持续一段时间,因此,可能不会立即出现在v2的hierarchy中。类似地,一个控制器只有被完全禁用之后,才能被移出v2 hierarchy中,且可能过一段时间才能在v1 hierarchy中可用;此外,由于控制器间的依赖,其它控制器也可能需要被禁用。在 v2和 v1 之间动态移动控制器对开发和手动配置很有用,但 强烈建议不要在生产环境这么做。建议在系统启动、控制器开始使用之后, 就不要再修改hierarchy和控制器的关联关系了。另外,迁移到v2时,系统管理软件可能仍然会自动挂载v1 cgroup文件系统, 因此需要在系统启动过程中劫持所有的控制器,因为启动之后就晚了。为方便测试,内核提供了cgroup_no_v1=allows配置,可完全禁用v1控制器(强制使用v2)。2.1.3 cgroupv2挂载选项前面mount命令没指定任何特殊参数。目前支持如下mount选项:nsdelegate考虑将cgroup namespaces作为委托边界。该选项是系统范围内的,只能在mount时设置,或者从init命名空间卸载时修改。在non-init命名空间挂载时会忽略mount命令的选项设置。memory_localeventsOnly populate memory.events with data for the current cgroup, and not any subtrees. This is legacy behaviour, the default behaviour without this option is to include subtree counts. This option is system wide and can only be set on mount or modified through remount from the init namespace. The mount option is ignored on non-init namespace mounts.memory_recursiveprotRecursively apply memory.min and memory.low protection to entire subtrees, without requiring explicit downward propagation into leaf cgroups. This allows protecting entire subtrees from one another, while retaining free competition within those subtrees. This should have been the default behavior but is a mount-option to avoid regressing setups relying on the original semantics (e.g. specifying bogusly high ‘bypass’ protection values at higher tree levels).2.2 组织进程和线程2.2.1 进程初始状态下,只有root cgroup,所有进程都属于这个cgroup。创建子cgroup,只需创建一个子目录即可:# mkdir $CGROUP_NAME读取该文件,会列出属于该cgroup的所有进程标识符(PID)。PID并未排序。同一个PID可能出现多次:一个进程迁移到另一个cgroup后又回来;读文件期间PID被重用了;都可能发生这种情况。一个cgroup可以拥有多个子cgroup,构成一个属性结构。每个cgroup有一个可读写的接口文件(cgroup.procs)。迁移cgroup:通过写进程的PID到目标cgroup的cgroup.procs文件:每次write(2)调用只能迁移一个进程;如果一个进程包含多个线程,写任何一个线程的PID,该进程所有线程都会迁移;如果父进程fork一个子进程,则新的子进程属于执行fork操作的父进程所属的cgroup;进程退出(exit)后,仍然留在退出时它所属的cgroup,直到这个进程被收割(reaped);僵尸进程不会出现在cgroup.procs文件中,也不能迁移到其它cgroup。销毁cgroup:删除对应的目录即可:# rmdir $CGROUP_NAME如果一个cgroup没有任何子进程、或激活进程,则可以通过删除该目录来销毁对应的cgroup;如果一个cgroup没有任何子进程,但有僵尸进程,则认为是空的,可以删除。查看进程所属的cgroup信息:cat /proc/$PID/cgroup会列出进程所属的所有cgroup之间关系。如果是cgroupv1,则该文件包含多行,每一行代表一个层级(hierarchy)。cgroupv2的每一项永远是0::$PATH的格式:# cat /proc/842/cgroup ... 0::/test-cgroup/test-cgroup-nestedcgroupv1的显示格式(译者添注):$ cat /proc/\$$/cgroup 13:blkio:/user.slice 12:pids:/user.slice/user-1000.slice/user@1000.service 11:cpu,cpuacct:/user.slice 10:freezer:/ 9:devices:/user.slice 8:misc:/ 7:net_cls,net_prio:/ 6:perf_event:/ 5:rdma:/ 4:memory:/user.slice/user-1000.slice/user@1000.service 3:cpuset:/ 2:hugetlb:/ 1:name=systemd:/user.slice/user-1000.slice/user@1000.service/apps.slice/apps-org.gnome.Terminal.slice/vte-spawn-12708505-7a30-4f4a-99a6-682552c4e4b3.scope 0::/user.slice/user-1000.slice/user@1000.service/apps.slice/apps-org.gnome.Terminal.slice/vte-spawn-12708505-7a30-4f4a-99a6-682552c4e4b3.scope如果一个进程变成僵尸进程(zombie),并且与它关联的 cgroup 随后被删掉了,那行尾会出现 (deleted) 字样:# cat /proc/842/cgroup ... 0::/test-cgroup/test-cgroup-nested (deleted)2.2.2 线程cgroup v2 supports thread granularity for a subset of controllers to support use cases requiring hierarchical resource distribution across the threads of a group of processes. By default, all threads of a process belong to the same cgroup, which also serves as the resource domain to host resource consumptions which are not specific to a process or thread. The thread mode allows threads to be spread across a subtree while still maintaining the common resource domain for them.Controllers which support thread mode are called threaded controllers. The ones which don’t are called domain controllers.Marking a cgroup threaded makes it join the resource domain of its parent as a threaded cgroup. The parent may be another threaded cgroup whose resource domain is further up in the hierarchy. The root of a threaded subtree, that is, the nearest ancestor which is not threaded, is called threaded domain or thread root interchangeably and serves as the resource domain for the entire subtree.Inside a threaded subtree, threads of a process can be put in different cgroups and are not subject to the no internal process constraint - threaded controllers can be enabled on non-leaf cgroups whether they have threads in them or not.As the threaded domain cgroup hosts all the domain resource consumptions of the subtree, it is considered to have internal resource consumptions whether there are processes in it or not and can’t have populated child cgroups which aren’t threaded. Because the root cgroup is not subject to no internal process constraint, it can serve both as a threaded domain and a parent to domain cgroups.The current operation mode or type of the cgroup is shown in the “cgroup.type” file which indicates whether the cgroup is a normal domain, a domain which is serving as the domain of a threaded subtree, or a threaded cgroup.On creation, a cgroup is always a domain cgroup and can be made threaded by writing “threaded” to the “cgroup.type” file. The operation is single direction:# echo threaded > cgroup.typeOnce threaded, the cgroup can’t be made a domain again. To enable the thread mode, the following conditions must be met.As the cgroup will join the parent’s resource domain. The parent must either be a valid (threaded) domain or a threaded cgroup.When the parent is an unthreaded domain, it must not have any domain controllers enabled or populated domain children. The root is exempt from this requirement.Topology-wise, a cgroup can be in an invalid state. Please consider the following topology:A (threaded domain) - B (threaded) - C (domain, just created)C is created as a domain but isn’t connected to a parent which can host child domains. C can’t be used until it is turned into a threaded cgroup. “cgroup.type” file will report “domain (invalid)” in these cases. Operations which fail due to invalid topology use EOPNOTSUPP as the errno.A domain cgroup is turned into a threaded domain when one of its child cgroup becomes threaded or threaded controllers are enabled in the “cgroup.subtree_control” file while there are processes in the cgroup. A threaded domain reverts to a normal domain when the conditions clear.When read, “cgroup.threads” contains the list of the thread IDs of all threads in the cgroup. Except that the operations are per-thread instead of per-process, “cgroup.threads” has the same format and behaves the same way as “cgroup.procs”. While “cgroup.threads” can be written to in any cgroup, as it can only move threads inside the same threaded domain, its operations are confined inside each threaded subtree.The threaded domain cgroup serves as the resource domain for the whole subtree, and, while the threads can be scattered across the subtree, all the processes are considered to be in the threaded domain cgroup. “cgroup.procs” in a threaded domain cgroup contains the PIDs of all processes in the subtree and is not readable in the subtree proper. However, “cgroup.procs” can be written to from anywhere in the subtree to migrate all threads of the matching process to the cgroup.Only threaded controllers can be enabled in a threaded subtree. When a threaded controller is enabled inside a threaded subtree, it only accounts for and controls resource consumptions associated with the threads in the cgroup and its descendants. All consumptions which aren’t tied to a specific thread belong to the threaded domain cgroup.Because a threaded subtree is exempt from no internal process constraint, a threaded controller must be able to handle competition between threads in a non-leaf cgroup and its child cgroups. Each threaded controller defines how such competitions are handled.2.3 [Un]populated NotificationEach non-root cgroup has a “cgroup.events” file which contains “populated” field indicating whether the cgroup’s sub-hierarchy has live processes in it. Its value is 0 if there is no live process in the cgroup and its descendants; otherwise, 1. poll and [id]notify events are triggered when the value changes. This can be used, for example, to start a clean-up operation after all processes of a given sub-hierarchy have exited. The populated state updates and notifications are recursive. Consider the following sub-hierarchy where the numbers in the parentheses represent the numbers of processes in each cgroup:A(4) - B(0) - C(1) \ D(0)A, B and C’s “populated” fields would be 1 while D’s 0. After the one process in C exits, B and C’s “populated” fields would flip to “0” and file modified events will be generated on the “cgroup.events” files of both cgroups.2.4 管理控制器(controlling controllers)2.4.1 启用和禁用每个 cgroup 都有一个 cgroup.controllers 文件, 其中列出了这个 cgroup 可用的所有控制器:$ cat cgroup.controllers cpu io memory默认没有启用任何控制。启用或禁用是通过写 cgroup.subtree_control 文件完成的:$ echo "+cpu +memory -io" > cgroup.subtree_control只有出现在 cgroup.controllers 中的控制器才能被启用。如果像上面的命令一样,一次指定多个操作,那它们要么全部功能,要么全部失败;如果对同一个控制器指定了多个操作,最后一个是有效的。启用 cgroup 的某个控制器,意味着控制它在子节点之间分配目标资源(target resource)的行为。 考虑下面的 sub-hierarchy,括号中是已经启用的控制器:A(cpu,memory) - B(memory) - C() \ D()A 启用了 cpu 和 memory,因此会控制它的 child(即 B)的 CPU 和 memory 使用;B 只启用了 memory,因此 C 和 D 的 memory 使用量会受 B 控制,但 CPU 可以随意竞争(compete freely)。控制器限制 children 的资源使用方式,是创建或写入 children cgroup 的接口文件。 还是以上面的拓扑为例:在 B 上启用 cpu 将会在 C 和 D 的 cgroup 目录中创建 cpu. 开头的接口文件;同理,禁用 memory 时会删除对应的 memory. 开头的文件。这也意味着cgroup目录中所有不以 cgroup.开头的控制器接口文件 —— 在管理上,都属于 parent cgroup 而非当前 cgroup 自己。2.4.2 自顶向下启用(top-down constraint)资源是自顶向下(top-down)分配的,只有当一个 cgroup 从 parent 获得了某种资源,它 才可以继续向下分发。这意味着:只有父节点启用了某个控制器,子节点才能启用;对应到实现上,所有非根节点(non-root)的 cgroup.subtree_control 文件中, 只能包含它的父节点的 cgroup.subtree_control 中有的控制器;另一方面,只要有子节点还在使用某个控制器,父节点就无法禁用之。2.4.3 无内部进程限制只有当一个 non-root cgroup 中没有任何进程时,才能将其domain resource分配给它的children。换句话说,只有那些没有任何进程的domain cgroup,才能将它们的domain controllers写到其children的cgroup.subtree_control 文件中。这种方式保证了在给定的 domain controller 范围内,所有进程都位于叶子节点上, 因而避免了 child cgroup 内的进程与 parent 内的进程竞争的情况,便于 domain controller 扫描 hierarchy。但 root cgroup 不受此限制。对大部分类型的控制器来说,root 中包含了一些没有与任何 cgroup 相关联的进程和匿名资源占用 (anonymous resource consumption),需要特殊对待。root cgroup 的资源占用是如何管理的,因控制器而异(更多信息可参考 Controllers 小结)。注意,在parent的cgroup.subtree_control启用控制器之前,这些限制不会生效。 这非常重要,因为它决定了创建 populated cgroup children 的方式。 要控制一个 cgroup 的资源分配,这个 cgroup 需要:创建children cgroup,将自己所有的进程转移到 children cgroup 中,在它自己的 cgroup.subtree_control中启用控制器。2.5 委派2.5.1 委派模型A cgroup can be delegated in two ways. First, to a less privileged user by granting write access of the directory and its “cgroup.procs”, “cgroup.threads” and “cgroup.subtree_control” files to the user. Second, if the “nsdelegate” mount option is set, automatically to a cgroup namespace on namespace creation.Because the resource control interface files in a given directory control the distribution of the parent’s resources, the delegatee shouldn’t be allowed to write to them. For the first method, this is achieved by not granting access to these files. For the second, the kernel rejects writes to all files other than “cgroup.procs” and “cgroup.subtree_control” on a namespace root from inside the namespace.The end results are equivalent for both delegation types. Once delegated, the user can build sub-hierarchy under the directory, organize processes inside it as it sees fit and further distribute the resources it received from the parent. The limits and other settings of all resource controllers are hierarchical and regardless of what happens in the delegated sub-hierarchy, nothing can escape the resource restrictions imposed by the parent.Currently, cgroup doesn’t impose any restrictions on the number of cgroups in or nesting depth of a delegated sub-hierarchy; however, this may be limited explicitly in the future.2.5.2 委派限制A delegated sub-hierarchy is contained in the sense that processes can’t be moved into or out of the sub-hierarchy by the delegatee.For delegations to a less privileged user, this is achieved by requiring the following conditions for a process with a non-root euid to migrate a target process into a cgroup by writing its PID to the “cgroup.procs” file.The writer must have write access to the “cgroup.procs” file. The writer must have write access to the “cgroup.procs” file of the common ancestor of the source and destination cgroups. The above two constraints ensure that while a delegatee may migrate processes around freely in the delegated sub-hierarchy it can’t pull in from or push out to outside the sub-hierarchy.For an example, let’s assume cgroups C0 and C1 have been delegated to user U0 who created C00, C01 under C0 and C10 under C1 as follows and all processes under C0 and C1 belong to U0:~~~~~~~~~~~~~ - C0 - C00 ~ cgroup ~ \ C01 ~ hierarchy ~ ~~~~~~~~~~~~~ - C1 - C10Let’s also say U0 wants to write the PID of a process which is currently in C10 into “C00/cgroup.procs”. U0 has write access to the file; however, the common ancestor of the source cgroup C10 and the destination cgroup C00 is above the points of delegation and U0 would not have write access to its “cgroup.procs” files and thus the write will be denied with -EACCES.For delegations to namespaces, containment is achieved by requiring that both the source and destination cgroups are reachable from the namespace of the process which is attempting the migration. If either is not reachable, the migration is rejected with -ENOENT.2.6 指导原则2.6.1 Organize Once and ControlMigrating a process across cgroups is a relatively expensive operation and stateful resources such as memory are not moved together with the process. This is an explicit design decision as there often exist inherent trade-offs between migration and various hot paths in terms of synchronization cost.As such, migrating processes across cgroups frequently as a means to apply different resource restrictions is discouraged. A workload should be assigned to a cgroup according to the system’s logical and resource structure once on start-up. Dynamic adjustments to resource distribution can be made by changing controller configuration through the interface files.2.6.2 避免命名冲突Interface files for a cgroup and its children cgroups occupy the same directory and it is possible to create children cgroups which collide with interface files.All cgroup core interface files are prefixed with “cgroup.” and each controller’s interface files are prefixed with the controller name and a dot. A controller’s name is composed of lower case alphabets and ‘_’s but never begins with an ‘_’ so it can be used as the prefix character for collision avoidance. Also, interface file names won’t start or end with terms which are often used in categorizing workloads such as job, service, slice, unit or workload.cgroup doesn’t do anything to prevent name collisions and it’s the user’s responsibility to avoid them.3 资源分配模型cgroup controllers implement several resource distribution schemes depending on the resource type and expected use cases. This section describes major schemes in use along with their expected behaviors.3.1 权重A parent’s resource is distributed by adding up the weights of all active children and giving each the fraction matching the ratio of its weight against the sum. As only children which can make use of the resource at the moment participate in the distribution, this is work-conserving. Due to the dynamic nature, this model is usually used for stateless resources.All weights are in the range [1, 10000] with the default at 100. This allows symmetric multiplicative biases in both directions at fine enough granularity while staying in the intuitive range.As long as the weight is in range, all configuration combinations are valid and there is no reason to reject configuration changes or process migrations.“cpu.weight” proportionally distributes CPU cycles to active children and is an example of this type.3.2 限制A child can only consume upto the configured amount of the resource. Limits can be over-committed - the sum of the limits of children can exceed the amount of resource available to the parent.Limits are in the range [0, max] and defaults to “max”, which is noop.As limits can be over-committed, all configuration combinations are valid and there is no reason to reject configuration changes or process migrations.“io.max” limits the maximum BPS and/or IOPS that a cgroup can consume on an IO device and is an example of this type.3.3 保护A cgroup is protected upto the configured amount of the resource as long as the usages of all its ancestors are under their protected levels. Protections can be hard guarantees or best effort soft boundaries. Protections can also be over-committed in which case only upto the amount available to the parent is protected among children.Protections are in the range [0, max] and defaults to 0, which is noop.As protections can be over-committed, all configuration combinations are valid and there is no reason to reject configuration changes or process migrations.“memory.low” implements best-effort memory protection and is an example of this type.3.4 分配A cgroup is exclusively allocated a certain amount of a finite resource. Allocations can’t be over-committed - the sum of the allocations of children can not exceed the amount of resource available to the parent.Allocations are in the range [0, max] and defaults to 0, which is no resource.As allocations can’t be over-committed, some configuration combinations are invalid and should be rejected. Also, if the resource is mandatory for execution of processes, process migrations may be rejected.“cpu.rt.max” hard-allocates realtime slices and is an example of this type.4 接口文件4.1 格式All interface files should be in one of the following formats whenever possible:New-line separated values (when only one value can be written at once) VAL0\n VAL1\n ... Space separated values (when read-only or multiple values can be written at once) VAL0 VAL1 ...\n Flat keyed KEY0 VAL0\n KEY1 VAL1\n ... Nested keyed KEY0 SUB_KEY0=VAL00 SUB_KEY1=VAL01... KEY1 SUB_KEY0=VAL10 SUB_KEY1=VAL11... ...For a writable file, the format for writing should generally match reading; however, controllers may allow omitting later fields or implement restricted shortcuts for most common use cases.For both flat and nested keyed files, only the values for a single key can be written at a time. For nested keyed files, the sub key pairs may be specified in any order and not all pairs have to be specified.4.2 约定Settings for a single feature should be contained in a single file.The root cgroup should be exempt from resource control and thus shouldn’t have resource control interface files.The default time unit is microseconds. If a different unit is ever used, an explicit unit suffix must be present.A parts-per quantity should use a percentage decimal with at least two digit fractional part - e.g. 13.40.If a controller implements weight based resource distribution, its interface file should be named “weight” and have the range [1, 10000] with 100 as the default. The values are chosen to allow enough and symmetric bias in both directions while keeping it intuitive (the default is 100%).If a controller implements an absolute resource guarantee and/or limit, the interface files should be named “min” and “max” respectively. If a controller implements best effort resource guarantee and/or limit, the interface files should be named “low” and “high” respectively.In the above four control files, the special token “max” should be used to represent upward infinity for both reading and writing.If a setting has a configurable default value and keyed specific overrides, the default entry should be keyed with “default” and appear as the first entry in the file.The default value can be updated by writing either “default VAL”.When writing to update a specific override, “default” can be used as the value to indicate removal of the override. Override entries with “default” as the value must not appear when read.For example, a setting which is keyed by major:minor device numbers with integer values may look like the following:# cat cgroup-example-interface-file default 150 8:0 300The default value can be updated by:# echo 125 > cgroup-example-interface-file或者# echo "default 125" > cgroup-example-interface-fileAn override can be set by:# echo "8:16 170" > cgroup-example-interface-fileand cleared by:# echo "8:0 default" > cgroup-example-interface-file # cat cgroup-example-interface-file default 125 8:16 170For events which are not very high frequency, an interface file “events” should be created which lists event key value pairs. Whenever a notifiable event happens, file modified event should be generated on the file.4.3 核心接口文件All cgroup core files are prefixed with “cgroup.”cgroup.typeA read-write single value file which exists on non-root cgroups.When read, it indicates the current type of the cgroup, which can be one of the following values.A cgroup can be turned into a threaded cgroup by writing “threaded” to this file.“domain” : A normal valid domain cgroup.“domain threaded” : A threaded domain cgroup which is serving as the root of a threaded subtree.“domain invalid” : A cgroup which is in an invalid state. It can’t be populated or have controllers enabled. It may be allowed to become a threaded cgroup.“threaded” : A threaded cgroup which is a member of a threaded subtree.cgroup.procsA read-write new-line separated values file which exists on all cgroups.When read, it lists the PIDs of all processes which belong to the cgroup one-per-line. The PIDs are not ordered and the same PID may show up more than once if the process got moved to another cgroup and then back or the PID got recycled while reading.A PID can be written to migrate the process associated with the PID to the cgroup. The writer should match all of the following conditions.When delegating a sub-hierarchy, write access to this file should be granted along with the containing directory.In a threaded cgroup, reading this file fails with EOPNOTSUPP as all the processes belong to the thread root. Writing is supported and moves every thread of the process to the cgroup.It must have write access to the “cgroup.procs” file.It must have write access to the “cgroup.procs” file of the common ancestor of the source and destination cgroups.cgroup.threadsA read-write new-line separated values file which exists on all cgroups.When read, it lists the TIDs of all threads which belong to the cgroup one-per-line. The TIDs are not ordered and the same TID may show up more than once if the thread got moved to another cgroup and then back or the TID got recycled while reading.A TID can be written to migrate the thread associated with the TID to the cgroup. The writer should match all of the following conditions.When delegating a sub-hierarchy, write access to this file should be granted along with the containing directory.It must have write access to the “cgroup.threads” file.The cgroup that the thread is currently in must be in the same resource domain as the destination cgroup.It must have write access to the “cgroup.procs” file of the common ancestor of the source and destination cgroups.cgroup.controllersA read-only space separated values file which exists on all cgroups.It shows space separated list of all controllers available to the cgroup. The controllers are not ordered.cgroup.subtree_controlA read-write space separated values file which exists on all cgroups. Starts out empty.When read, it shows space separated list of the controllers which are enabled to control resource distribution from the cgroup to its children.Space separated list of controllers prefixed with ‘+’ or ‘-‘ can be written to enable or disable controllers. A controller name prefixed with ‘+’ enables the controller and ‘-‘ disables. If a controller appears more than once on the list, the last one is effective. When multiple enable and disable operations are specified, either all succeed or all fail.cgroup.eventsA read-only flat-keyed file which exists on non-root cgroups. The following entries are defined. Unless specified otherwise, a value change in this file generates a file modified event.populated1 if the cgroup or its descendants contains any live processes; otherwise, 0.frozen1 if the cgroup is frozen; otherwise, 0.cgroup.max.descendantsA read-write single value files. The default is “max”.Maximum allowed number of descent cgroups. If the actual number of descendants is equal or larger, an attempt to create a new cgroup in the hierarchy will fail.cgroup.max.depthA read-write single value files. The default is “max”.Maximum allowed descent depth below the current cgroup. If the actual descent depth is equal or larger, an attempt to create a new child cgroup will fail.cgroup.statA read-only flat-keyed file with the following entries:nr_descendantsTotal number of visible descendant cgroups.nr_dying_descendantsTotal number of dying descendant cgroups. A cgroup becomes dying after being deleted by a user. The cgroup will remain in dying state for some time undefined time (which can depend on system load) before being completely destroyed.A process can’t enter a dying cgroup under any circumstances, a dying cgroup can’t revive.A dying cgroup can consume system resources not exceeding limits, which were active at the moment of cgroup deletion.cgroup.freezeA read-write single value file which exists on non-root cgroups. Allowed values are “0” and “1”. The default is “0”.Writing “1” to the file causes freezing of the cgroup and all descendant cgroups. This means that all belonging processes will be stopped and will not run until the cgroup will be explicitly unfrozen. Freezing of the cgroup may take some time; when this action is completed, the “frozen” value in the cgroup.events control file will be updated to “1” and the corresponding notification will be issued.A cgroup can be frozen either by its own settings, or by settings of any ancestor cgroups. If any of ancestor cgroups is frozen, the cgroup will remain frozen.Processes in the frozen cgroup can be killed by a fatal signal. They also can enter and leave a frozen cgroup: either by an explicit move by a user, or if freezing of the cgroup races with fork(). If a process is moved to a frozen cgroup, it stops. If a process is moved out of a frozen cgroup, it becomes running.Frozen status of a cgroup doesn’t affect any cgroup tree operations: it’s possible to delete a frozen (and empty) cgroup, as well as create new sub-cgroups.5 控制器5.1 CPUThe “cpu” controllers regulates distribution of CPU cycles. This controller implements weight and absolute bandwidth limit models for normal scheduling policy and absolute bandwidth allocation model for realtime scheduling policy.In all the above models, cycles distribution is defined only on a temporal base and it does not account for the frequency at which tasks are executed. The (optional) utilization clamping support allows to hint the schedutil cpufreq governor about the minimum desired frequency which should always be provided by a CPU, as well as the maximum desired frequency, which should not be exceeded by a CPU.WARNING: cgroup2 doesn’t yet support control of realtime processes and the cpu controller can only be enabled when all RT processes are in the root cgroup. Be aware that system management software may already have placed RT processes into nonroot cgroups during the system boot process, and these processes may need to be moved to the root cgroup before the cpu controller can be enabled.5.1.1 CPU接口文件All time durations are in microseconds.cpu.statA read-only flat-keyed file. This file exists whether the controller is enabled or not.It always reports the following three stats:and the following three when the controller is enabled:nr_periodsnr_throttledthrottled_usecusage_usecuser_usecsystem_useccpu.weightA read-write single value file which exists on non-root cgroups. The default is “100”.The weight in the range [1, 10000].cpu.weight.niceA read-write single value file which exists on non-root cgroups. The default is “0”.The nice value is in the range [-20, 19].This interface file is an alternative interface for “cpu.weight” and allows reading and setting weight using the same values used by nice(2). Because the range is smaller and granularity is coarser for the nice values, the read value is the closest approximation of the current weight.cpu.maxA read-write two value file which exists on non-root cgroups. The default is “max 100000”.The maximum bandwidth limit. It’s in the following format:$MAX $PERIODwhich indicates that the group may consume upto PERIOD duration. “max” for MAX is updated.cpu.pressureA read-only nested-key file which exists on non-root cgroups.Shows pressure stall information for CPU. See PSI - Pressure Stall Information for details.cpu.uclamp.minA read-write single value file which exists on non-root cgroups. The default is “0”, i.e. no utilization boosting.The requested minimum utilization (protection) as a percentage rational number, e.g. 12.34 for 12.34%.This interface allows reading and setting minimum utilization clamp values similar to the sched_setattr(2). This minimum utilization value is used to clamp the task specific minimum utilization clamp.The requested minimum utilization (protection) is always capped by the current value for the maximum utilization (limit), i.e. cpu.uclamp.max.cpu.uclamp.maxA read-write single value file which exists on non-root cgroups. The default is “max”. i.e. no utilization cappingThe requested maximum utilization (limit) as a percentage rational number, e.g. 98.76 for 98.76%.This interface allows reading and setting maximum utilization clamp values similar to the sched_setattr(2). This maximum utilization value is used to clamp the task specific maximum utilization clamp.5.2 MemoryThe “memory” controller regulates distribution of memory. Memory is stateful and implements both limit and protection models. Due to the intertwining between memory usage and reclaim pressure and the stateful nature of memory, the distribution model is relatively complex.While not completely water-tight, all major memory usages by a given cgroup are tracked so that the total memory consumption can be accounted and controlled to a reasonable extent. Currently, the following types of memory usages are tracked.Userland memory - page cache and anonymous memory.Kernel data structures such as dentries and inodes.TCP socket buffers.The above list may expand in the future for better coverage.5.2.1 Memory接口文件All memory amounts are in bytes. If a value which is not aligned to PAGE_SIZE is written, the value may be rounded up to the closest PAGE_SIZE multiple when read back.memory.current A read-only single value file which exists on non-root cgroups.The total amount of memory currently being used by the cgroup and its descendants.memory.min A read-write single value file which exists on non-root cgroups. The default is “0”.Hard memory protection. If the memory usage of a cgroup is within its effective min boundary, the cgroup’s memory won’t be reclaimed under any conditions. If there is no unprotected reclaimable memory available, OOM killer is invoked. Above the effective min boundary (or effective low boundary if it is higher), pages are reclaimed proportionally to the overage, reducing reclaim pressure for smaller overages.Effective min boundary is limited by memory.min values of all ancestor cgroups. If there is memory.min overcommitment (child cgroup or cgroups are requiring more protected memory than parent will allow), then each child cgroup will get the part of parent’s protection proportional to its actual memory usage below memory.min.Putting more memory than generally available under this protection is discouraged and may lead to constant OOMs.If a memory cgroup is not populated with processes, its memory.min is ignored.memory.low A read-write single value file which exists on non-root cgroups. The default is “0”.Best-effort memory protection. If the memory usage of a cgroup is within its effective low boundary, the cgroup’s memory won’t be reclaimed unless there is no reclaimable memory available in unprotected cgroups. Above the effective low boundary (or effective min boundary if it is higher), pages are reclaimed proportionally to the overage, reducing reclaim pressure for smaller overages.Effective low boundary is limited by memory.low values of all ancestor cgroups. If there is memory.low overcommitment (child cgroup or cgroups are requiring more protected memory than parent will allow), then each child cgroup will get the part of parent’s protection proportional to its actual memory usage below memory.low.Putting more memory than generally available under this protection is discouraged.memory.high A read-write single value file which exists on non-root cgroups. The default is “max”.Memory usage throttle limit. This is the main mechanism to control memory usage of a cgroup. If a cgroup’s usage goes over the high boundary, the processes of the cgroup are throttled and put under heavy reclaim pressure.Going over the high limit never invokes the OOM killer and under extreme conditions the limit may be breached.memory.max A read-write single value file which exists on non-root cgroups. The default is “max”.Memory usage hard limit. This is the final protection mechanism. If a cgroup’s memory usage reaches this limit and can’t be reduced, the OOM killer is invoked in the cgroup. Under certain circumstances, the usage may go over the limit temporarily.In default configuration regular 0-order allocations always succeed unless OOM killer chooses current task as a victim.Some kinds of allocations don’t invoke the OOM killer. Caller could retry them differently, return into userspace as -ENOMEM or silently ignore in cases like disk readahead.This is the ultimate protection mechanism. As long as the high limit is used and monitored properly, this limit’s utility is limited to providing the final safety net.memory.oom.group A read-write single value file which exists on non-root cgroups. The default value is “0”.Determines whether the cgroup should be treated as an indivisible workload by the OOM killer. If set, all tasks belonging to the cgroup or to its descendants (if the memory cgroup is not a leaf cgroup) are killed together or not at all. This can be used to avoid partial kills to guarantee workload integrity.Tasks with the OOM protection (oom_score_adj set to -1000) are treated as an exception and are never killed.If the OOM killer is invoked in a cgroup, it’s not going to kill any tasks outside of this cgroup, regardless memory.oom.group values of ancestor cgroups.memory.events A read-only flat-keyed file which exists on non-root cgroups. The following entries are defined. Unless specified otherwise, a value change in this file generates a file modified event.Note that all fields in this file are hierarchical and the file modified event can be generated due to an event down the hierarchy. For for the local events at the cgroup level see memory.events.local.low The number of times the cgroup is reclaimed due to high memory pressure even though its usage is under the low boundary. This usually indicates that the low boundary is over-committed. high The number of times processes of the cgroup are throttled and routed to perform direct memory reclaim because the high memory boundary was exceeded. For a cgroup whose memory usage is capped by the high limit rather than global memory pressure, this event’s occurrences are expected. max The number of times the cgroup’s memory usage was about to go over the max boundary. If direct reclaim fails to bring it down, the cgroup goes to OOM state. oom The number of time the cgroup’s memory usage was reached the limit and allocation was about to fail.This event is not raised if the OOM killer is not considered as an option, e.g. for failed high-order allocations or if caller asked to not retry attempts.oom_kill The number of processes belonging to this cgroup killed by any kind of OOM killer. memory.events.local Similar to memory.events but the fields in the file are local to the cgroup i.e. not hierarchical. The file modified event generated on this file reflects only the local events. memory.stat A read-only flat-keyed file which exists on non-root cgroups.This breaks down the cgroup’s memory footprint into different types of memory, type-specific details, and other information on the state and past events of the memory management system.All memory amounts are in bytes.The entries are ordered to be human readable, and new entries can show up in the middle. Don’t rely on items remaining in a fixed position; use the keys to look up specific values!If the entry has no per-node counter(or not show in the mempry.numa_stat). We use ‘npn’(non-per-node) as the tag to indicate that it will not show in the mempry.numa_stat.anon Amount of memory used in anonymous mappings such as brk(), sbrk(), and mmap(MAP_ANONYMOUS) file Amount of memory used to cache filesystem data, including tmpfs and shared memory. kernel_stack Amount of memory allocated to kernel stacks. percpu(npn) Amount of memory used for storing per-cpu kernel data structures. sock(npn) Amount of memory used in network transmission buffers shmem Amount of cached filesystem data that is swap-backed, such as tmpfs, shm segments, shared anonymous mmap()s file_mapped Amount of cached filesystem data mapped with mmap() file_dirty Amount of cached filesystem data that was modified but not yet written back to disk file_writeback Amount of cached filesystem data that was modified and is currently being written back to disk anon_thp Amount of memory used in anonymous mappings backed by transparent hugepages inactive_anon, active_anon, inactive_file, active_file, unevictable Amount of memory, swap-backed and filesystem-backed, on the internal memory management lists used by the page reclaim algorithm.As these represent internal list state (eg. shmem pages are on anon memory management lists), inactive_foo + active_foo may not be equal to the value for the foo counter, since the foo counter is type-based, not list-based.slab_reclaimable Part of “slab” that might be reclaimed, such as dentries and inodes. slab_unreclaimable Part of “slab” that cannot be reclaimed on memory pressure. slab(npn) Amount of memory used for storing in-kernel data structures. workingset_refault_anon Number of refaults of previously evicted anonymous pages. workingset_refault_file Number of refaults of previously evicted file pages. workingset_activate_anon Number of refaulted anonymous pages that were immediately activated. workingset_activate_file Number of refaulted file pages that were immediately activated. workingset_restore_anon Number of restored anonymous pages which have been detected as an active workingset before they got reclaimed. workingset_restore_file Number of restored file pages which have been detected as an active workingset before they got reclaimed. workingset_nodereclaim Number of times a shadow node has been reclaimed pgfault(npn) Total number of page faults incurred pgmajfault(npn) Number of major page faults incurred pgrefill(npn) Amount of scanned pages (in an active LRU list) pgscan(npn) Amount of scanned pages (in an inactive LRU list) pgsteal(npn) Amount of reclaimed pages pgactivate(npn) Amount of pages moved to the active LRU list pgdeactivate(npn) Amount of pages moved to the inactive LRU list pglazyfree(npn) Amount of pages postponed to be freed under memory pressure pglazyfreed(npn) Amount of reclaimed lazyfree pages thp_fault_alloc(npn) Number of transparent hugepages which were allocated to satisfy a page fault. This counter is not present when CONFIG_TRANSPARENT_HUGEPAGE is not set. thp_collapse_alloc(npn) Number of transparent hugepages which were allocated to allow collapsing an existing range of pages. This counter is not present when CONFIG_TRANSPARENT_HUGEPAGE is not set. memory.numa_stat A read-only nested-keyed file which exists on non-root cgroups.This breaks down the cgroup’s memory footprint into different types of memory, type-specific details, and other information per node on the state of the memory management system.This is useful for providing visibility into the NUMA locality information within an memcg since the pages are allowed to be allocated from any physical node. One of the use case is evaluating application performance by combining this information with the application’s CPU allocation.All memory amounts are in bytes.The output format of memory.numa_stat is:type N0=<bytes in node 0> N1=<bytes in node 1> ... The entries are ordered to be human readable, and new entries can show up in the middle. Don’t rely on items remaining in a fixed position; use the keys to look up specific values!The entries can refer to the memory.stat.memory.swap.current A read-only single value file which exists on non-root cgroups.The total amount of swap currently being used by the cgroup and its descendants.memory.swap.high A read-write single value file which exists on non-root cgroups. The default is “max”.Swap usage throttle limit. If a cgroup’s swap usage exceeds this limit, all its further allocations will be throttled to allow userspace to implement custom out-of-memory procedures.This limit marks a point of no return for the cgroup. It is NOT designed to manage the amount of swapping a workload does during regular operation. Compare to memory.swap.max, which prohibits swapping past a set amount, but lets the cgroup continue unimpeded as long as other memory can be reclaimed.Healthy workloads are not expected to reach this limit.memory.swap.max A read-write single value file which exists on non-root cgroups. The default is “max”.Swap usage hard limit. If a cgroup’s swap usage reaches this limit, anonymous memory of the cgroup will not be swapped out.memory.swap.events A read-only flat-keyed file which exists on non-root cgroups. The following entries are defined. Unless specified otherwise, a value change in this file generates a file modified event.high The number of times the cgroup’s swap usage was over the high threshold. max The number of times the cgroup’s swap usage was about to go over the max boundary and swap allocation failed. fail The number of times swap allocation failed either because of running out of swap system-wide or max limit. When reduced under the current usage, the existing swap entries are reclaimed gradually and the swap usage may stay higher than the limit for an extended period of time. This reduces the impact on the workload and memory management.memory.pressure A read-only nested-key file which exists on non-root cgroups.Shows pressure stall information for memory. See PSI - Pressure Stall Information for details.5.2.2 使用指导“memory.high” is the main mechanism to control memory usage. Over-committing on high limit (sum of high limits > available memory) and letting global memory pressure to distribute memory according to usage is a viable strategy.Because breach of the high limit doesn’t trigger the OOM killer but throttles the offending cgroup, a management agent has ample opportunities to monitor and take appropriate actions such as granting more memory or terminating the workload.Determining whether a cgroup has enough memory is not trivial as memory usage doesn’t indicate whether the workload can benefit from more memory. For example, a workload which writes data received from network to a file can use all available memory but can also operate as performant with a small amount of memory. A measure of memory pressure - how much the workload is being impacted due to lack of memory - is necessary to determine whether a workload needs more memory; unfortunately, memory pressure monitoring mechanism isn’t implemented yet.5.2.3 Memory所有权A memory area is charged to the cgroup which instantiated it and stays charged to the cgroup until the area is released. Migrating a process to a different cgroup doesn’t move the memory usages that it instantiated while in the previous cgroup to the new cgroup.A memory area may be used by processes belonging to different cgroups. To which cgroup the area will be charged is in-deterministic; however, over time, the memory area is likely to end up in a cgroup which has enough memory allowance to avoid high reclaim pressure.If a cgroup sweeps a considerable amount of memory which is expected to be accessed repeatedly by other cgroups, it may make sense to use POSIX_FADV_DONTNEED to relinquish the ownership of memory areas belonging to the affected files to ensure correct memory ownership.
(备注: 不显示声明就是基于V1版本来讲解的)1 什么是 cgroups2 为什么我们需要 cgroups3 crgoups 是如何实现的4 如何使用 cgroups5 `cgroup V2`版本有什么不一样6 总结1 什么是 cgroups1.1 基本概念cgroups机制是用来限制一个进程或者多个进程的资源。概念:Subsystem(子系统): 每种可以控制的资源都被定义成一个子系统,比如CPU子系统,Memory子系统。Control Group(控制组): cgroup是用来对一个subsystem(子系统)或者多个子系统的资源进行控制。Hierarchy(层级): Control group使用层次结构 (Tree) 对资源做划分。参考下图:每个层级都会有一个根节点, 子节点是根节点的比重划分。关系:一个子系统最多附加到一个层级(Hierarchy)上。一个层级(Hierarchy)可以附加多个子系统。1.2 进程和cgroup的关系一个进程限制内存和CPU资源,就会绑定到CPU cgroup和Memory cgroup的节点上,CPU cgroup 节点和Memory cgroup节点属于两个不同的层级(Hierarchy)。进程和cgroup 节点是多对多的关系,因为一个进程涉及多个子系统,每个子系统可能属于不同的层次结构(Hierarchy)。如图:上图P代表进程,因为多个进程可能共享相同的资源,所以会抽象出一个CSS_SET,每个进程会属于一个CSS_SET 链表中,同一个CSS_SET下的进程都被其管理。一个CSS_SET关联多个cgroup节点,也就是关联多个子系统的资源控制,那么CSS_SET和cgroup节点就是多对多的关系。参考下 CSS_SET 结构定义:#ifdef CONFIG_CGROUPS /* Control Group info protected by css_set_lock */ struct css_set __rcu *cgroups; 关联的cgroup 节点 /* cg_list protected by css_set_lXock and tsk->alloc_lock */ struct list_head cg_list; // 关联所有的进程 #endif2 为什么我们需要 cgroups我们希望能够细粒度的控制资源,我们可以为一个系统的不同用户提供不同的资源使用量,比如一个学校的校园服务器,老师用户可以使用15%的资源,学生用户可以使用5%的资源。我们可以用 cgroups 机制做到。3 crgoups 是如何实现的3.1 cgroups 数据结构每个进程都会指向一个 CSS_SET 数据结构。(上文进程和cgroups关系已经提供过)参考源码:struct task_struct { //进程的数据结构 ... #ifdef CONFIG_CGROUPS /* Control Group info protected by css_set_lock */ struct css_set __rcu *cgroups; 关联的cgroup 节点 /* cg_list protected by css_set_lXock and tsk->alloc_lock */ struct list_head cg_list; // 关联所有的进程 #endif ... }一个 CSS_SET关联多个cgroup_subsys_state对象,cgroup_subsys_state指向一个cgroup子系统。所以进程和cgroup是不直接关联的,需要通过cgroup_subsys_state对象确定属于哪个层级,属于哪个cgroup节点。参考下CSS_SET源码:一个 cgroup hierarchy(层次)其实是一个文件系统, 可以挂载在用户空间查看和操作。我们可以查看、绑定任何一个cgroup节点下的所有进程Id(PID)。实现原理: 通过进程的fork和退出,从 CSS_SET attach 或者 detach 进程。3.2 cgroups文件系统上面我们了解到进程和cgroup的关系,那么在用户空间内的进程是如何使用cgroup功能的呢?cgroup通过VFS文件系统将功能暴露给用户,用户创建一些文件,写入一些参数即可使用,那么用户使用crgoup功能会创建哪些文件?文件如下:tasks文件: 列举绑定到某个cgroup的所有进程ID(PID).cgroup.procs文件: 列举一个cgroup节点下的所有线程组ID.notify_on_release flag文件:填 0或1,表示是否在cgroup中最后一个task退出时通知运行release agent,默认情况下是 0,表示不运行。release_agent文件: 指定 release agent执行脚本的文件路径(该文件在最顶层cgroup目录中存在),在这个脚本通常用于自动化umount无用的cgroup每个子系统也会创建一些特有的文件。3.3 什么是 VFS 文件系统VFS是一个内核抽象层,能够隐藏具体文件系统的实现细节,从而给用户态进程提供一套统一的 API 接口。VFS 使用了一种通用文件系统的设计,具体的文件系统只要实现了 VFS 的设计接口,就能够注册到 VFS 中,从而使内核可以读写这种文件系统。 这很像面向对象设计中的抽象类与子类之间的关系,抽象类负责对外接口的设计,子类负责具体的实现。其实,VFS本身就是用c语言实现的一套面向对象的接口。3.4 clone_children标志是干什么的这个标志只会影响 cpuset子系统,如果这个标志在 cgroup 中开启,一个新的cpuset子系统cgroup节点 的配置会继承它的父级cgroup节点配置。4 如何使用 cgroups我们创建一个cgroup,使用到cpuset这个cgroup子系统,可以按照下面的步骤:mount -t tmpfs cgroup_root /sys/fs/cgroupmkdir /sys/fs/cgroup/cpusetmount -t cgroup -ocpuset cpuset /sys/fs/cgroup/cpuset通过创建和写入新的配置到/sys/fs/cgroup/cpuset虚拟文件系统,创建新的cgroup启动一个父进程任务得到进程PID,写入到 /sys/fs/cgroup/cpuset的tasks文件中fork,exec或者clone父进程任务。举个例子,我们可以创建一个cgroup,命名为Charlie,包含CPU资源2到3核,memory节点为1,操作如下:mount -t tmpfs cgroup_root /sys/fs/cgroup mkdir /sys/fs/cgroup/cpuset mount -t cgroup cpuset -ocpuset /sys/fs/cgroup/cpuset cd /sys/fs/cgroup/cpuset mkdir Charlie cd Charlie echo 2-3 > cpuset.cpus echo 1 > cpuset.mems echo $$ > tasks ## 查看cgroup信息 sh # sh是进入当前cgroup cat /proc/self/cgroup默认只有root具有cgroup的操作权限。5 cgroup V2版本有什么不一样不同于v1版本,cgroup v2版本只有一个层级 Hierarchy(层级)。cgroup v2的层级可以通过下面的命令进行挂载:# mount -t cgroup2 none $MOUNT_POINTcgroupv2文件系统有一个根cgroup,以0x63677270数字来标识,所有支持v2版本的子系统控制器会自动绑定到 v2的唯一层级上并绑定到根cgroup。没有使用cgroup v2版本的进程,也可以绑定到v1版本的层级上,保证了前后版本的兼容性。在v2版本中,因为只有一个层级,所有进程只绑定到cgroup的叶子节点。如图:节点说明:父节点开启的子系统控制器控制到儿子节点,比如A节点开启了memory controller,那么C节点``cgroup就可以控制进程的memory。叶子节点不能控制开启哪些子系统的controller,因为叶子节点关联进程Id。所以非叶子节点不能控制进程的使用资源。cgroup v2的cgroup目录下文件说明:cgroup.procs文件,用来关联 进程Id。这个文件在V1版本使用列举线程组Id的。cgroup.controllers文件(只读)和cgroup.subtree_control文件是用来控制 子cgroup节点可以使用的 子系统控制器。tasks文件用来关联进程信息,只有叶子节点有此文件。5.1 为什么这么改造?v1版本为了灵活一个进程可能绑定多个层级(Hierarchy),但是通常是每个层级对应一个子系统,多层级就显得没有必要。所以一个层级包含所有的子系统就比较简单容易管理。5.2 线程模式cgroup v2版本支持线程模式,将 threaded 写入到cgroup.type就会开启Thread模式。当开始线程模式后,一个进程的所有线程属于同一个cgroup,会采用Tree结构进行管理。6 总结通过对 cgroup的学习,大致了解Linux crgoup的数据结构,v2版本层级结构的优化和支持线程模式的功能。
4 其它工具4.1 seccmop-bpf.hseccomp-bpf.h是由开发人员编写的一个十分便捷的头文件,用于开发seccomp-bpf 。该头文件已经定义好了很多常见的宏,如验证系统架构、允许系统调用等功能,十分便捷,如下所示。... define VALIDATE_ARCHITECTURE \ BPF_STMT(BPF_LD+BPF_W+BPF_ABS, arch_nr), \ BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ARCH_NR, 1, 0), \ BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL) define EXAMINE_SYSCALL \ BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr) define ALLOW_SYSCALL(name) \ BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_##name, 0, 1), \ BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW) define KILL_PROCESS \ BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL) ...4.2 应用示例(seccomp_policy.c)#include <fcntl.h> #include <stdio.h> #include <string.h> #include <unistd.h> #include <assert.h> #include <linux/seccomp.h> #include <sys/prctl.h> #include "seccomp-bpf.h" void install_syscall_filter() { struct sock_filter filter[] = { /* Validate architecture. */ VALIDATE_ARCHITECTURE, /* Grab the system call number. */ EXAMINE_SYSCALL, /* List allowed syscalls. We add open() to the set of allowed syscalls by the strict policy, but not close(). */ ALLOW_SYSCALL(rt_sigreturn), #ifdef __NR_sigreturn ALLOW_SYSCALL(sigreturn), #endif ALLOW_SYSCALL(exit_group), ALLOW_SYSCALL(exit), ALLOW_SYSCALL(read), ALLOW_SYSCALL(write), ALLOW_SYSCALL(open), KILL_PROCESS, }; struct sock_fprog prog = { .len = (unsigned short)(sizeof(filter)/sizeof(filter[0])), .filter = filter, }; assert(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0); assert(prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == 0); } int main(int argc, char **argv) { int output = open("output.txt", O_WRONLY); const char *val = "test"; printf("Calling prctl() to set seccomp with filter...\n"); install_syscall_filter(); printf("Writing to an already open file...\n"); write(output, val, strlen(val)+1); printf("Trying to open file for reading...\n"); int input = open("output.txt", O_RDONLY); printf("Note that open() worked. However, close() will not\n"); close(input); printf("You will not see this message--the process will be killed first\n"); }执行结果$ ./seccomp_policy Calling prctl() to set seccomp with filter... Writing to an already open file... Trying to open file for reading... Note that open() worked. However, close() will not Bad system call4.3 seccomp-tools一款用于分析seccomp的开源工具,项目地址:https://github.com/david942j/seccomp-tools主要功能:Dump:从可执行文件中自动转储 seccomp BPFDisasm: 将 seccomp BPF 转换为人类可读的格式Asm:使编写seccomp规则类似于编写代码Emu: 模拟 seccomp 规则安装sudo apt install gcc ruby-dev gem install seccomp-tools使用$ seccomp-tools dump ./simple_syscall_seccomp line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x05 0xc000003e if (A != ARCH_X86_64) goto 0007 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005 0004: 0x15 0x00 0x02 0xffffffff if (A != 0xffffffff) goto 0007 0005: 0x15 0x01 0x00 0x0000003b if (A == execve) goto 0007 0006: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0007: 0x06 0x00 0x00 0x00000000 return KIL从输出中可知禁用了execve系统调用。5 使用Seccomp保护Docker的安全Seccomp技术被用在很多应用程序上以保护系统的安全性,Docker支持使用seccomp来限制容器的系统调用,不过需要启用内核中的CONFIG_SECCOMP。$ grep CONFIG_SECCOMP= /boot/config-$(uname -r) CONFIG_SECCOMP=y当使用docker run启动一个容器时,Docker会使用默认的seccomp配置文件来对容器施加限制策略,该默认文件是以json格式编写,在300多个系统调用中禁用了大约44个系统调用,可以在Moby项目中找到该源码。$ sudo docker run --rm -it ubuntu /bin/bash root@9e271f2056bd:/# root@9e271f2056bd:/# bash root@9e271f2056bd:/# ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 10 pts/0 00:00:00 bash 13 pts/0 00:00:00 ps root@9e271f2056bd:/# grep -i seccomp /proc/1/status Seccomp: 2 Seccomp_filters: 1 root@9e271f2056bd:/#Docker中默认的配置文件提供了最大限度的包容性,除了默认的选择之外,Docker允许我们自定义该配置文件来灵活的对容器的系统调用进行限制。5.1 示例1:以白名单的形式允许特定的系统调用文件名称为example.json:{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32" ], "syscalls": [ { "names": [ "arch_prctl", "sched_yield", "futex", "write", "mmap", "exit_group", "madvise", "rt_sigprocmask", "getpid", "gettid", "tgkill", "rt_sigaction", "read", "getpgrp" ], "action": "SCMP_ACT_ALLOW", "args": [], "comment": "", "includes": {}, "excludes": {} } ] }解释:defaultAction : 指定默认的seccomp操作,具体的可选参数上面已经介绍过了,最常用的无非是SCMP_ACT_ALLOW、SCMP_ACT_ERRNO。这里选择SCMP_ACT_ERRNO,表示默认禁止全部系统调用,以白名单的形式在赋予可用的系统调用。architectures : 系统架构,不同的系统架构系统调用可能不同。syscalls:指定系统调用以及对应的操作,name定义系统调用名,action对应的操作,这里表示允许name里边中的系统调用,args对应系统调用参数,可以为空。这样,在使用 docker run 运行容器时,就可以使用 --security-opt 选项指定该配置文件来对容器进行系统调用定制。$ docker run --rm -it --security-opt seccomp=/path/to/seccomp/example.json hello-world5.2 示例2:禁止容器创建文件夹,就可以用黑名单的形式禁用mkdir系统调用文件名称seccomp_mkdir.json:{ "defaultAction": "SCMP_ACT_ALLOW", "syscalls": [ { "name": "mkdir", "action": "SCMP_ACT_ERRNO", "args": [] } ] }$ sudo docker run --rm -it --security-opt seccomp=seccomp_mkdir.json busybox /bin/sh Unable to find image 'busybox:latest' locally latest: Pulling from library/busybox 405fecb6a2fa: Pull complete Digest: sha256:fcd85228d7a25feb59f101ac3a955d27c80df4ad824d65f5757a954831450185 Status: Downloaded newer image for busybox:latest / # / # ls bin dev etc home proc root sys tmp usr var / # mkdir test mkdir: can't create directory 'test': Operation not permitted / #当然也可以不加任何seccomp策略启动容器,只需要在启动选项中加上--security-opt seccomp=unconfined即可。5.3 zazzaz seccomp 是一个可以为容器自动生成json格式的seccomp文件的开源工具,项目地址:https://github.com/pjbgf/zaz。主要用法为:zaz seccomp docker IMAGE COMMAND它能够为特定的可执行文件定制系统调用,以只允许特定的操作,禁止其他操作。举个例子:为alpine中的ping命令生成seccomp配置文件$ sudo ./zaz seccomp docker alpine "ping -c5 8.8.8.8" > seccomp_ping.json $ cat seccomp_ping.json | jq '.' { "defaultAction": "SCMP_ACT_ERRNO", "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32" ], "syscalls": [ { "names": [ "arch_prctl", "bind", "clock_gettime", "clone", "close", "connect", "dup2", "epoll_pwait", "execve", "exit", "exit_group", "fcntl", "futex", "getpid", "getsockname", "getuid", "ioctl", "mprotect", "nanosleep", "open", "poll", "read", "recvfrom", "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", "set_tid_address", "setitimer", "setsockopt", "socket", "write", "writev" ], "action": "SCMP_ACT_ALLOW" } ] }如上所示,zaz检测到了33个系统调用,使用白名单的形式过滤系统调用。那它以白名单的形式生成的系统调用能否很好的过滤系统系统呢?是否能够满足运行ping命令,而不能运行除了它允许的系统调用之外的命令呢?做个测试,首先用下面Dockerfile构建一个简单的镜像。// Dockerfile FROM alpine:latest CMD ["ping","-c5","8.8.8.8"]构建成功后,使用默认的seccomp策略启动容器,没有任何问题,可以运行。$ sudo docker build -t pingtest . $ sudo docker run --rm -it pingtest PING 8.8.8.8 (8.8.8.8): 56 data bytes 64 bytes from 8.8.8.8: seq=0 ttl=127 time=42.139 ms 64 bytes from 8.8.8.8: seq=1 ttl=127 time=42.646 ms 64 bytes from 8.8.8.8: seq=2 ttl=127 time=42.098 ms 64 bytes from 8.8.8.8: seq=3 ttl=127 time=42.484 ms 64 bytes from 8.8.8.8: seq=4 ttl=127 time=42.007 ms --- 8.8.8.8 ping statistics --- 5 packets transmitted, 5 packets received, 0% packet loss round-trip min/avg/max = 42.007/42.274/42.646 ms接着我们使用上述zaz生成的策略试试。$ sudo docker run --rm -it --security-opt seccomp=seccomp_ping.json pingtest docker: Error response from daemon: failed to create shim: OCI runtime create failed: container_linux.go:380: starting container process caused: close exec fds: open /proc/self/fd: operation not permitted: unknown.容器并没有成功启动,在创建OCI的时候就报错了,报错原因是operation not permitted,这个报错上面似乎提到过,是想要使用的系统调用被禁用的缘故,可能zaz这种白名单的模式鲁棒性还是不够强,而且Docker更新那么多次,zaz缺乏维护导致捕获的系统调用不足,在容器启动过程中出现了问题。奇怪的是,当我在此运行同样的命令,却引发了panic报错:No error following JSON procError payload。$ sudo docker run --rm -it --security-opt seccomp=seccomp_ping.json pingtest docker: Error response from daemon: failed to create shim: OCI runtime create failed: runc did not terminate successfully: exit status 2: panic: No error following JSON procError payload. goroutine 1 [running]: github.com/opencontainers/runc/libcontainer.parseSync(0x56551adf30b8, 0xc000010b20, 0xc0002268a0, 0xc00027f9e0, 0x0) github.com/opencontainers/runc/libcontainer/sync.go:93 +0x307 github.com/opencontainers/runc/libcontainer.(*initProcess).start(0xc000297cb0, 0x0, 0x0) github.com/opencontainers/runc/libcontainer/process_linux.go:440 +0x5ef github.com/opencontainers/runc/libcontainer.(*linuxContainer).start(0xc000078700, 0xc000209680, 0x0, 0x0) github.com/opencontainers/runc/libcontainer/container_linux.go:379 +0xf5 github.com/opencontainers/runc/libcontainer.(*linuxContainer).Start(0xc000078700, 0xc000209680, 0x0, 0x0) github.com/opencontainers/runc/libcontainer/container_linux.go:264 +0xb4 main.(*runner).run(0xc0002274c8, 0xc0000200f0, 0x0, 0x0, 0x0) github.com/opencontainers/runc/utils_linux.go:312 +0xd2a main.startContainer(0xc00025c160, 0xc000076400, 0x1, 0x0, 0x0, 0xc0002275b8, 0x6) github.com/opencontainers/runc/utils_linux.go:455 +0x455 main.glob..func2(0xc00025c160, 0xc000246000, 0xc000246120) github.com/opencontainers/runc/create.go:65 +0xbb github.com/urfave/cli.HandleAction(0x56551ad3b040, 0x56551ade81e8, 0xc00025c160, 0xc00025c160, 0x0) github.com/urfave/cli@v1.22.1/app.go:523 +0x107 github.com/urfave/cli.Command.Run(0x56551aa566f5, 0x6, 0x0, 0x0, 0x0, 0x0, 0x0, 0x56551aa5f509, 0x12, 0x0, ...) github.com/urfave/cli@v1.22.1/command.go:174 +0x579 github.com/urfave/cli.(*App).Run(0xc000254000, 0xc000132000, 0xf, 0xf, 0x0, 0x0) github.com/urfave/cli@v1.22.1/app.go:276 +0x7e8 main.main() github.com/opencontainers/runc/main.go:163 +0xd3f : unknown.这种报错或许是不应该的,我尝试在网上寻找报错的相关信息,类似的情况很少,而且并不是每次运行都是出现这种panic,正常情况下应该是operation not permitted,这是由于我们的白名单没有完全包括必须的系统调用导致的。目前将此情况汇报给了Moby issue,或许能够得到一些解答。类似panic信息:https://bugzilla.redhat.com/show_bug.cgi?format=multiple&id=1714183无论是哪种报错,看起来都是runc出了问题,尝试解决这个问题,我们就要知道Docker到底是如何在运行时加载seccomp?当我们要创建一个容器的时候 ,容器守护进程 Dockerd会请求containerd来创建一个容器 , containerd 收到请求后,也并不会直接去操作容器,而是创建一个叫做 containerd-shim 的进程,让这个进程去操作容器,之后containerd-shim会通过OCI去调用容器运行时runc来启动容器, runc启动完容器后本身会直接退出,containerd-shim则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程 。也就是说调用顺序为Dockerd -> containerd -> containerd-shim -> runc启动一个容器ubuntu,并在容器中再运行一个bash$ sudo docker run --rm -it ubuntu /bin/bash root@ef57fff95b80:/# bash root@ef57fff95b80:/# ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 9 pts/0 00:00:00 bash 12 pts/0 00:00:00 ps查看调用栈,containerd-shim(28051-28129)并没有被施加seccomp,而容器内的两个bash(1 -> 28075;9->28126)被施加了seccomp策略。# pstree -p | grep containerd-shim |-containerd-shim(28051)-+-bash(28075)---bash(28126) | |-{containerd-shim}(28052) | |-{containerd-shim}(28053) | |-{containerd-shim}(28054) | |-{containerd-shim}(28055) | |-{containerd-shim}(28056) | |-{containerd-shim}(28057) | |-{containerd-shim}(28058) | |-{containerd-shim}(28059) | |-{containerd-shim}(28060) | `-{containerd-shim}(28129) # grep -i seccomp /proc/28051/status Seccomp: 0 # grep -i seccomp /proc/28075/status Seccomp: 2 # grep -i seccomp /proc/28126/status Seccomp: 2 # grep -i seccomp /proc/28052/status Seccomp: 0 ... # grep -i seccomp /proc/28129/status Seccomp: 0也就是说对容器施加seccomp是在container-shim启动之后,在调用runc的时候出现了问题,是否我们的seccomp策略也要将runc所必须的系统调用考虑进去呢?Zaz是否考虑了容器启动时候的runc所必须的系统调用?这就需要捕获容器在启动时,runc所必要的系统调用了。5.4 Sysdig为了获取容器运行时runc用了哪些系统调用,可以有很多方法,比如ftrace、strace、fanotify等,这里使用sysdig来监控容器的运行,sisdig是一款原生支持容器的系统可见性工具,项目地址:https://github.com/draios/sysdig。具体的安装和使用方法可以参考GitHub上给出的详细教程,这里只做简单介绍。安装完成后,直接在命令行运行sysdig,不加任何参数, sysdig 会捕获所有的事件并将其写入标准输出 :$ sysdig 285304 01:21:51.270700399 7 sshd (50485) > select 285306 01:21:51.270701716 7 sshd (50485) < select res=2 285307 01:21:51.270701982 7 sshd (50485) > rt_sigprocmask 285308 01:21:51.270702258 7 sshd (50485) < rt_sigprocmask 285309 01:21:51.270702473 7 sshd (50485) > rt_sigprocmask 285310 01:21:51.270702660 7 sshd (50485) < rt_sigprocmask 285312 01:21:51.270702983 7 sshd (50485) > read fd=13(<f>/dev/ptmx) size=16384 285313 01:21:51.270703971 1 sysdig (59131) > switch next=59095 pgft_maj=0 pgft_min=1759 vm_size=280112 vm_rss=18048 vm_swap=0 ...默认情况下,sysdig 在一行中打印每个事件的信息,格式如下%evt.num %evt.time %evt.cpu %proc.name (%thread.tid) %evt.dir %evt.type %evt.args其中evt.num 是递增的事件编号evt.time 是事件时间戳evt.cpu 是捕获事件的 CPU 编号proc.name 是生成事件的进程的名称thread.tid 是产生事件的TID,对应单线程进程的PIDevt.dir 是事件方向,> 表示进入事件,< 表示退出事件evt.type 是事件的名称,例如“open”或“read”evt.args 是事件参数的列表。在系统调用的情况下,这些往往对应于系统调用参数,但情况并非总是如此:出于简单或性能原因,某些系统调用参数被排除在外。启动一个终端A,输入以下命令进行监控,container.name指定捕获容器名为ping,proc.name指定进程名为runc的包,保存为runc.scap.$sysdig -w runc.scap container.name=ping&&proc.name=runc接着在另一个终端B启动该容器:$sudo docker run --rm -it --name=ping pingtest PING 8.8.8.8 (8.8.8.8): 56 data bytes 64 bytes from 8.8.8.8: seq=0 ttl=127 time=44.032 ms 64 bytes from 8.8.8.8: seq=1 ttl=127 time=42.069 ms 64 bytes from 8.8.8.8: seq=2 ttl=127 time=42.066 ms 64 bytes from 8.8.8.8: seq=3 ttl=127 time=42.073 ms 64 bytes from 8.8.8.8: seq=4 ttl=127 time=42.112 ms --- 8.8.8.8 ping statistics --- 5 packets transmitted, 5 packets received, 0% packet loss round-trip min/avg/max = 42.066/42.470/44.032 ms执行完毕后,在终端A使用ctrl+c停止捕获,并筛选捕获的内容,只留系统调用,将结果保存到runc_syscall.txt中,这样我们就得到了启动容器时runc使用了哪些系统调用。$ sysdig -p "%syscall.type" -r runc.scap | runc_syscall.txt $ cat -n runc_syscall.txt ... 3437 rt_sigaction 3438 exit_group 3439 procexit可以发现筛选出的系统调用数还是有很多的,其中包含很多重复的系统调用,这里可以简单的写一个脚本,进行过滤,通过过滤后,一共有72个系统调用。$ python analyse.py runc_syscall.txt Filter syscall num: 72 filter syscall:['clone', 'close', 'prctl', 'getpid', 'write', 'unshare', 'read', 'exit_group', 'procexit', 'setsid', 'setuid', 'setgid', 'sched_getaffinity', 'openat', 'mmap', 'rt_sigprocmask', 'sigaltstack', 'gettid', 'rt_sigaction', 'mprotect', 'futex', 'set_robust_list', 'munmap', 'nanosleep', 'readlinkat', 'fcntl', 'epoll_create1', 'pipe', 'epoll_ctl', 'fstat', 'pread', 'getdents64', 'capget', 'epoll_pwait', 'newfstatat', 'statfs', 'getppid', 'keyctl', 'socket', 'bind', 'sendto', 'getsockname', 'recvfrom', 'mount', 'fchmodat', 'mkdirat', 'symlinkat', 'umask', 'mknodat', 'fchownat', 'unlinkat', 'chdir', 'fchdir', 'pivot_root', 'umount', 'dup', 'sethostname', 'fstatfs', 'seccomp', 'brk', 'fchown', 'setgroups', 'capset', 'execve', 'signaldeliver', 'access', 'arch_prctl', 'getuid', 'getgid', 'geteuid', 'getcwd', 'getegid']将zaz生成的系统调用与我们捕获的系统调用合二为一,系统调用数到了85个。如下:{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32" ], "syscalls": [ { "names": [ "clone", "close", "prctl", "getpid", "write", "unshare", "read", "exit_group", "procexit", "setsid", "setuid", "setgid", "sched_getaffinity", "openat", "mmap", "rt_sigprocmask", "sigaltstack", "gettid", "rt_sigaction", "mprotect", "futex", "set_robust_list", "munmap", "nanosleep", "readlinkat", "fcntl", "epoll_create1", "pipe", "epoll_ctl", "fstat", "pread", "getdents64", "capget", "epoll_pwait", "newfstatat", "statfs", "getppid", "keyctl", "socket", "bind", "sendto", "getsockname", "recvfrom", "mount", "fchmodat", "mkdirat", "symlinkat", "umask", "mknodat", "fchownat", "unlinkat", "chdir", "fchdir", "pivot_root", "umount", "dup", "sethostname", "fstatfs", "seccomp", "brk", "fchown", "setgroups", "capset", "signaldeliver", "access", "getuid", "getgid", "geteuid", "getcwd", "getegid", "arch_prctl", "clock_gettime", "connect", "dup2", "execve", "exit", "ioctl", "open", "poll", "rt_sigreturn", "set_tid_address", "setitimer", "setsockopt", "socket", "writev" ], "action": "SCMP_ACT_ALLOW" } ] }通过该文件再次运行容器,发现可以成功运行!$ sudo docker run -it --rm --security-opt seccomp=seccomp_ping.json pingtest PING 8.8.8.8 (8.8.8.8): 56 data bytes 64 bytes from 8.8.8.8: seq=0 ttl=127 time=43.424 ms 64 bytes from 8.8.8.8: seq=1 ttl=127 time=42.873 ms 64 bytes from 8.8.8.8: seq=2 ttl=127 time=42.336 ms 64 bytes from 8.8.8.8: seq=3 ttl=127 time=48.164 ms 64 bytes from 8.8.8.8: seq=4 ttl=127 time=42.260 ms --- 8.8.8.8 ping statistics --- 5 packets transmitted, 5 packets received, 0% packet loss round-trip min/avg/max = 42.260/43.811/48.164 ms尝试运行其他命令,有些命令由于缺乏必须的系统调用,会出现Operation not permitted的报错。$ sudo docker run -it --rm --security-opt seccomp=seccomp_ping.json pingtest ls ls: .: Operation not permitted $ sudo docker run -it --rm --security-opt seccomp=seccomp_ping.json pingtest mkdir test mkdir: can't create directory 'test': Operation not permitted6 参考链接BPF操作码seccomp_rule_addseccomp和seccomp bfpseccomp 概述seccomp沙箱机制 & 2019ByteCTF VIPprctl(2) — Linux manual pageseccomp-toolslibseccompdocker seccompDocker seccomp 与OCI
5 函数接口描述功能接口描述。这些API描述不包含底层的SMC或HVC调用。但是,这些函数却都遵守SMCCC调用规约。如果实现了EL2却没有实现EL3,则hypervisor使用HVC为运行在EL1的Guest OS提供调用支持。调用格式都是一样的。PSCI函数只能由非安全空间发起调用(EL1或EL2)。5.1 PSCI_VERSION功能描述返回PSCI实现的版本号。参数uint32 Function ID: 0x8400 0000返回值uint32:位[31:16]-主版本号;位[15:0]-次版本号;注意对于没有实现的PSCI函数,返回NOT_SUPPORTED。5.2 CPU_SUSPEND功能描述挂起CPU核或它之上的更高拓扑结构中的节点。用于空闲状态管理,期望CPU核唤醒时从之前的执行位置继续执行。参数对于powerdown请求,调用者必须保存复位重新运行时所需要的状态。也就是说保存的上下文必须是调用者在发生powerdown调用之前power_state参数所指示的电源级别(power_level字段)下所有可见的状态。(就是调用者自己保存自己的状态,被调用者不管)对于掉电请求,调用者无需执行Cache或一致性操作。PSCI实现者完成(PSCI实现侧负责内存一致性)。调用者不能假设powerdown请求使用指定的entry point地址返回。因为,powerdown可能不能完成,比如因为中断挂起。也有可能因为与其它核的协调,真正进入的是浅睡眠模式(相比请求的休眠模式)。因此,PSCI实现可能将请求的powerdown状态降为standby状态。如果降为standby状态,PSCI实现返回到PSCI调用之后的指令,而不是指定的entry_point入口地址。此时,返回码也是成功的。如果发生比较早的wakeup事件,实现也是返回下一条指令,返回码也是成功的,也有可能成功的在指定的entry point地址处返回。正确的唤醒事件必须能够保证恢复到之前的状态。CPU_SUSPEND调用传递的入口地址必须是调用者视角下的物理地址。上下文标识符只对调用者有意义。PSCI实现者保存,唤醒时在返回的异常级别下,再传递给CPU核,通过该值,恢复掉电前的上下文。INVALID_PARAMETERS:如果发生下面的情况,就会返回该错误。INVALID_ADDRESS如果传递的入口地址,PSCI实现者认为是非法的,就返回该值。PSCI 1.0之前使用INVALID_PARAMETERS代替该值。在OS协调模式下,如果发生以下两种情况,就会返回DENIED:在OS协调模式下,如果系统的状态和请求状态不一致,会返回INVALID_PARAMETERS,不同之处在于:为高于核电源级别的拓扑节点请求低功耗电源状态该节点中至少一个子节点与请求的电源状态不兼容(比如,一个核发起请求,将系统级节点置于powerdown状态,但是,该系统节点中的另一个核处于retention状态时,就会返回参数错误。)提供的power_state参数不正确。预期是与平台固件表(如ACPI或FDT一致)在OS协调模式下,发生以下两种条件时,也会返回参数错误:在DENIED情况中,不一致的核必须运行中。错误会出现在调用者和实现者两侧。在INVALID_PARAMETERS情况中,不一致的节点必须处于低功耗状态,不一致只能通过调用者(OS)的错误产生。为高于核电源级别的拓扑节点请求低功耗电源状态所有与请求不一致的核必须处于运行中,而不是低功耗状态原始格式PSCI 1.0之前的版本支持的形式。当使用这种格式时,PSCI_FEATURES使用CPU_SUSPEND功能ID返回的标志字段的bit[1]位被设置为0。各比特位的意义:位域描述31:26保留,必须是025:24PowerLevel23:17保留,必须是016StateType15:0StateID扩展StateID:对于一个硬件平台,支持每个核、簇或整个系统固定组合状态。这些状态产生了一组合法的power_state值。这些状态应该通过固件表(如ACPI或FDT)表示给OSPM。为此,PSCI 1.0引入了一个新的扩展StateID格式。这种格式对于PSCI的实现者来说更为灵活,方便开发者实现PSCI,可以通过ACPI或FDT电源状态的描述进行改进。这样的情况下,原先的格式有些字段就多余了。使用这种格式的时候,PSCI_FEATURES函数的返回标志中的bit[1]会被设为1(传递CPU_SUSPEND功能ID)。注意:一种实现中不可能混用这两种格式。下表是power_state参数的位域(扩展StateID格式)。位域描述31保留,必须是030StateType29:28保留,必须是027:0StateID推荐的编码格式:参考前面。StateID示例编码0,表示standby或retention状态;1,表示powerdown状态。另外,还说明entry_point_address和context_id的值合法;Level 0: 核Level 1: 簇Level 2: 系统PowerLevel,定义的电源域级别,也就是表示是核,簇还是系统层电源请求。PSCI 1.0之前的版本称为AffinityLevel。但是电源域级别的命名,却是实现者定义的。一般情况下,按照如下方式命名:StateType:状态类型StateID:状态ID对请求的组合电源状态进行标识。一般是实现者定义的。在OS协调模式下,StateID必须能够表示哪个核是最后一个进入idle状态的。这些信息必须体现在FDT或ACPI固件表中,以便在请求电源状态时,将这些信息添加到StateID字段中。推荐编码格式可以参考第6.5小节。位域描述15:12核是电源等级中的最后一个• 0: Core Level• 1: Cluster Level• 2: System Level11:8系统级局部电源状态:• 0: Run• 2: Retention• 3: Powerdown7:4簇级局部电源状态:• 0: Run• 2: Retention• 3: Powerdown3:0核级局部电源状态:• 0: Run• 1: standby• 2: Retention• 3: Powerdown0x8400 0001-SMC32版本0xC400 0001-SMC64版本uint32 Function ID:uint32 power_state从PSCI 1.0开始,支持两种格式。entry_point_address唤醒时,程序继续执行的起始地址。可以是PA(物理地址)或IPA(中间物理地址)。context_id该参数只对调用者有用。PSCI实现者只需保留一下该参数的备份即可。从掉电状态唤醒时,PSCI将该值写入到R0、W0或X0通用寄存器中,进入异常的程序会通过该寄存器将保存的上下文内容恢复。调用者的责任在发起CPU_SUSPEND调用之前,非安全空间必须遵守以下规则:调用者必须处理可能的错误码:PSCI实现者的责任:状态协调在平台协调者模式中,调用者通过power_state参数传递的指定进入的电源状态,在语义上不是强制的。相反,它代表的是调用者容忍的最深的电源状态。此种情况下,是通过PSCI实现真正进入的电源状态。为此,如果一个核没有调用CPU_ON而上电,或者调用了CPU_OFF而关闭的情况下,假定该核进入了最深的电源状态。而在OS协调模式中,调用者显式请求某个特定的电源状态,而不是让PSCI实现决定。实现必须遵循请求,除非与实现当前的状态不一致。PSCI实现者的责任:与可信OS或PF进行交互PSCI实现必须能够与可信OS或SP进行通信。交互方法请参考ARMv8-A的Firmware Framework。因为某些原因,可信OS或SP可能不兼容某种特殊的状态。这种情况下,ARM建议:可信OS或SP使用自定义的机制与非安全空间通信,保证它的限制可以被非安全空间的代码考虑。PSCI实现者的责任:Cache和内存一致性管理Powerdown状态要求清除Cache。PSCI实现者必须在掉电一个节点之前,为该节点中所有的Cache和正在最后关闭的那个核执行清除操作。另外,PSCI实现还需要在启动阶段执行对Cache的失效操作,除非这是硬件能够自动完成的。在相关处理器和互连IP的技术参考手册中可以看到,上电或掉电应该遵守的顺序。PSCI实现者的责任:返回状态当从standby状态返回时,对于调用者来说,CPU核的状态应该没有变化,除了定时器和由于唤醒中断造成的CPU interface的变化之外。对于核来说,standby状态和使用WFI指令没有什么不同。唯一的不同就是,调用SMC指令造成的寄存器变化。R0或W0返回的值是错误码。对于standby状态,成功时返回SUCCESS。对于powerdown状态,如果成功不会返回,因为唤醒时,从传递的入口地址处开始执行。如果不成功,返回错误码,表明错误原因。返回值int32:SUCCESS;INVALID_PARAMETERS;INVALID_ADDRESS;DENIED;注意对于没有实现的PSCI函数,返回NOT_SUPPORTED。5.3 CPU_OFF功能描述关闭核。用于hotplug。只能使用CPU_ON调用重新开启一个核。参数0x8400 0002uint32 Function ID:返回值int32:成功不会返回;否则返回DENIED。5.4 CPU_ON功能描述启动一个核。有两种情况:(1)启动阶段时调用;(2)该核之前被CPU_OFF关闭。参数[24:31]: 必须是0[16:23]: 匹配MPIDR.Aff2位域[8:15]: 匹配MPIDR.Aff1位域[0:7]: 匹配MPIDR.Aff0位域[40:63]: 必须是0[32:39]: 匹配MPIDR.Aff3位域[24:31]: 必须是0[16:23]: 匹配MPIDR.Aff2位域[8:15]: 匹配MPIDR.Aff1位域[0:7]: 匹配MPIDR.Aff0位域0x8400 0003-SMC32版本0xC400 0003-SMC64版本uint32 Function ID: 功能IDuint32/uint64 target_cpu:目标核MPIDR寄存器的备份。如果是AArch32:如果是AArch64:uint32/uint64 entry_point_address:入口地址当核返回到非安全异常级时必须执行的地址。SMC64版本时,是64位的物理地址或中间物理地址;SMC32版本时,是32位的物理地址或中间物理地址;uint32/uint64 context_id:上下文地址当核返回到非安全异常级时:SMC64版本时,该值必须保存在X0寄存器;SMC32版本时,该值必须保存在R0寄存器。需要把该地址的上下文内容恢复(堆栈、执行状态、中断状态等)。返回值int32:SUCCESS,成功则返回该值;INVALID_PARAMETERS,描述了一个无效的MPIDR;INVALID_ADDRESS,ATF认为传递进来的入口地址非法;ALREADY_ON,ATF认为该核已经启动;ON_PENDING,已经发起了CPU_ON请求,ATF还未处理;INTERNAL_FAILURE,因为物理原因,不能启动CPU核。5.5 AFFINITY_INFO功能描述请求某个亲和力等级上的信息。参数0:target_affinity中的所有位域都是有效的。在不支持硬件线程化的处理器系统中,target_affinity将会表示单个核。1:表示target_affinity中,忽略Aff0位域。target_affinity表示亲和力等级为1的处理单元。2:表示target_affinity中,忽略Aff0和Aff1位域。target_affinity表示亲和力等级为2的处理单元。3:表示target_affinity中,忽略Aff0、Aff1和Aff2位域。target_affinity表示亲和力等级为3的处理单元。0x8400 0004-SMC32版本0xC400 0004-SMC64版本uint32 Function ID:target_affinity同CPU_ON的target_cpu参数格式一样。(SMC32或SMC64)lowest_affinity_level表示target_affinity参数中有效的最低亲和力级别。该参数允许AFFINITY_INFO调用者请求大于0的亲和力等级的信息。可能的值:从PSCI 1.0版本开始,AFFINITY_INFO不再需要支持高于0的亲和级别。返回值int32:2 ON_PENDING,亲和力对象正在转换为ON状态的过程中;1 OFF,亲和力对象中,所有核都关闭;0 ON,亲和力对象中,至少有一个核开启;INVALID_PARAMETERS(PSCI 1.0以上,请求亲和力值大于0的请求会返回该值);DISABLED,由于物理原因禁止。5.6 MIGRATE功能描述(可选的)请求将可信OS迁移到另一个核上。参数0x8400 0005-SMC32版本0xC400 0005-SMC64版本uint32 Function ID:target_cpu同CPU_ON调用的target_cpu参数一样。返回值int32:SUCCESS,成功则返回该值;NOT_SUPPORTED,不支持该功能,或不需要迁移;INVALID_PARAMETERS,描述了一个无效的MPIDR;DENIED,可信OS启动,但是不可迁移;INTERNAL_FAILURE,因为物理原因,不能迁移;NOT_PRESENT,可信OS不在请求的核上。5.7 MIGRATE_INFO_TYPE功能描述(可选的)请求可信OS支持多核的情况。参数0x8400 0006uint32 Function ID:返回值int32:0 支持单核迁移的可信OS。可信OS只能运行在一个核上。可信OS支持迁移功能,可以被迁移到任意一个核上。如果尝试对运行可信OS的核调用CPU_OFF,请求会被拒绝(DENIED)。1 不支持单核迁移的可信OS。可信OS只能运行在一个核上。可信OS不支持迁移功能。调用MIGRATE会被拒绝。2 可信OS既不存在、也不需要迁移。这类系统不要求调用者使用MIGRATE功能。如果硬要调用,返回NOT_SUPPORTED。NOT_SUPPORTED 调用操作系统可以认为等价于返回值为2的情况。5.8 MIGRATE_INFO_UP_CPU功能描述(可选的)返回单核可信OS所在的核。参数0x8400 0007-SMC32版本0xC400 0007-SMC64版本uint32 Function ID:返回值可能是32位或64位:UNDEFINED:如果 MIGRATE_INFO_TYPE调用返回2或NOT_SUPPORTED;基于MPIDR的值:格式与CPU_ON调用中的target_cpu参数一样。5.9 SYSTEM_OFF功能描述关闭系统。SYSTEM_OFF提供了一个系统关闭的接口。在调用该接口之前,调用者必须将所有的核置于已知状态。调用也只能是由非安全空间发起。一旦发起该调用,PSCI实现将会完全关闭最高等级的电源(也就是系统电源)。启动必须是冷启动。参数0x8400 0008uint32 Function ID:返回值不需要返回。5.10 SYSTEM_RESET功能描述提供系统冷复位的方法。参数0x8400 0009uint32 Function ID:返回值不需要返回。5.11 SYSTEM_RESET2功能描述(可选)对SYSTEM_RESET的扩展,PSCI 1.1引入。提供:架构相关的reset方法供应商提供的reset方法参数Bit[31],保留的话就必须为0.Bits[30:0]设为1,则使用供应商提供的reset方法;设为0,则为架构提供的reset方法;0x0 SYSTEM_WARM_RESET.其它值保留.对于供应商提供的reset方法,这些位的意义由供应商定义。对于架构提供的reset方法,定义如下:0x8400 0012-SMC32版本0xC400 0012-SMC64版本uint32 Function ID:reset_type32位值,被分为两部分:cookie32位或64位值。用来传递额外的reset信息。返回值int32:SUCCESS,成功不返回NOT_SUPPORTED;INVALID_PARAMETERS;5.12 MEM_PROTECT功能描述(可选)通过在将内存移交给操作系统加载程序之前,重写这段内存,来提供针对冷重启攻击的保护。PSCI 1.1引入。参数0x8400 0013uint32 Function ID:enable32位值,非0值表示内存保护被启动。0值表示禁止保护功能。返回值int32:成功,则返回之前的使能状态:0,表示之前是被禁止的;1,表示之前是使能的。失败,则返回NOT_SUPPORTED。5.13 MEM_PROTECT_CHECK_RANGE功能描述(可选)可以检查某段内存是否被MEM_PROTECT保护。PSCI 1.1引入。参数0x8400 0014-SMC32版本0xC400 0014-SMC64版本uint32 Function ID:uint32/64 base要检查的内存的基地址;uint32/64 length要检查的内存的长度;返回值int32:SUCCESSDENIEDNOT_SUPPORTED5.14 PSCI_FEATURES功能描述查询SMCCC_VERSION或者某个PSCI功能是否被实现。PSCI 1.0引入。参数0x8400 000Auint32 Function ID:psci_func_id功能ID:PSCI或SMCCC_VERSION返回值如果功能实现,则意义如下:psci_func_id标志位描述CPU_SUSPEND功能ID[31:2]保留,等于0[1]0,power_state使用原始格式(PSCI 2.0)1,power_state新的扩展StateID格式[0]0,不支持OS协调方式1,支持OS协调方式其它功能ID[31:0]保留都是05.15 CPU_FREEZE功能描述(可选)将核置于供应商自定义的低功耗状态中。与CPU_OFF不同,中断仍然可以传递给该核。但是,该核一直会处于低功耗状态中,直到CPU_ON调用将其启动。PSCI 1.0引入。参数0x8400 000Buint32 Function ID:返回值int32:成功,不返回。失败,则返回NOT_SUPPORTED或DENIED。5.16 CPU_DEFAULT_SUSPEND功能描述(可选)将核置于供应商自定义的低功耗状态中。与CPU_SUSPEND不同的是,不需要指定power_state参数。PSCI 1.0引入。参数0x8400 000C-SMC32版本0xC400 000C-SMC64版本uint32 Function ID:entry_point_address参考CPU_SUSPEND;context_id参考CPU_SUSPEND;返回值int32:SUCCESSINVALID_ADDRESS5.17 NODE_HW_STATE功能描述(可选)返回系统的电源域拓扑结构中一个节点的硬件状态。PSCI 1.0引入。参数0x8400 000D-SMC32版本0xC400 000D-SMC64版本uint32 Function ID:target_cpu参考CPU_ON;power_level表示想要请求的节点,在电源域拓扑结构的层级。这是供应商自定义的,但是0保留给CPU核。返回值int32:2 HW_STANDBY:返回2,表示处于standby或retention电源状态;1 HW_OFF:返回1,表示处于powerdown状态;0 HW_ON:返回0,表示处于run状态;NOT_SUPPORTEDINVALID_PARAMETERS5.18 SYSTEM_SUSPEND功能描述语义等价于CPU_SUSPEND,将系统置于最低功耗状态。该调用是实现system suspend-to-RAM的基础(ACPI规范中描述的S2和S3状态)。值得注意的是,系统进入S2或S3状态,需要几个前提条件。系统中所有设备必须与进入该系统挂起状态兼容,可能需要在调用之前,优雅地处理各个外设。这些前提条件不在本文的讨论范围内。SYSTEM_SUSPEND仅限于提供进入S2或S3状态的机制,所有必要的条件都由调用OS满足。尽管ACPI将suspend-to-RAM功能分为S2或S3两种状态,但是PSCI只提供了一个API。同SYSTEM_SHUTDOWN和SYSTEM_RESET一样,该函数适用于调用OS的机器视角。为了使用该函数调用,调用者必须使用CPU_OFF关闭所有的核,但保留一个核。剩下的这个核调用SYSTEM_SUSPEND,传递entry_point_address和context_id参数(唤醒时用),进入挂起状态。调用者(OS)可以在调用SYSTEM_SUSPEND之前,使用AFFINITY_INFO函数保证所有其它核都已关闭。参数0x8400 000E-SMC32版本0xC400 000E-SMC64版本uint32 Function ID:entry_point_address:参考CPU_SUSPENDcontext_id:参考CPU_SUSPEND返回值返回32位值。NOT_SUPPORTEDINVALID_ADDRESSALREADY_ON成功不会返回;失败则返回:5.19 PSCI_SET_SUSPEND_MODE功能描述(可选)设置电源状态协调方式。PSCI 1.0引入。参数0: 平台协调方式1: OS协调方式0x8400 000Fuint32 Function ID:mode返回值int32SUCCESSNOT_SUPPORTEDINVALID_PARAMETERSDENIED5.20 PSCI_STAT_RESIDENCY功能描述(可选)返回冷启动之后,在某种状态下的度过时间。PSCI 1.0引入。参数0x8400 0010-SMC32版本0xC400 0010-SMC64版本uint32 Function ID:target_cpu格式与CPU_ON调用相同;power_state指定的电源状态。返回值可能是32位或64位。返回处于指定电源状态的时间。5.21 PSCI_STAT_COUNT功能描述(可选)返回冷启动之后,进入某种状态下的次数。PSCI 1.0引入。参数0x8400 0010-SMC32版本0xC400 0010-SMC64版本uint32 Function ID:target_cpu格式与CPU_ON调用相同;power_state指定的电源状态。返回值可能是32位或64位。进入某种状态下的次数。5.22 错误码定义值SUCCESS0NOT_SUPPORTED-1INVALID_PARAMETERS-2DENIED-3ALREADY_ON-4ON_PENDING-5INTERNAL_FAILURE-6NOT_PRESENT-7DISABLED-8INVALID_ADDRESS-96 其它实现细节6.1 PSCI调用流程6.1.1 CPU_SUSPEND、CPU_DEFAULT_SUSPEND和SYSTEM_SUSPEND调用流程6.1.2 CPU_OFF调用流程6.1.3 CPU_ON调用流程
1 介绍本文主要是在ARM架构的不同异常等级上工作的软件之间,提供一个标准的电源管理接口。这些软件,比如Linux、Hypervisor、安全Firmware和可信OS之间必须能够实现互相操作。而这些软件可能由不同厂商提供,本标准就是为这些软件的集成提供便利。这些PSCI接口有利于电源管理代码通用化、模块化:CPU核的空闲管理CPU核的动态添加、删除,以及辅核引导系统关机和复位该接口规范不会包含动态电压频率调节(DVFS)或设备电源管理(比如,GPU等外设的管理).该接口规范设计用来与硬件探测技术(如ACPI和FDT)等配合使用,并不是要取代ACPI或FDT。本文描述了PSCI版本1.1,1.0和0.2。对于0.2,还提供了一个勘误更新。本文包含以下内容:第1章 提供介绍和文档引用第2-4章 提供背景知识,包括:PSCI的设计目的电源状态术语的定义发送PSCI请求的方法ARM架构知识第5章 提供PSCI功能的主要描述第6章 提供PSCI功能的实现细节第7-8章 提供PSCI规范的修订历史和术语表1.1 ARM官方文档下面这些文档包含与本文档相关的信息:[1] ARM Architecture Reference Manual ARMv7-A and ARMv7-R edition (ARM DDI 0406).[2] Embedded Trace Macrocell Architecture Specification (ARM IHI 0014).[3] Program Flow Trace Architecture Specification (ARM IHI 0035).[4] SMC调用约定(ARM DEN 0028).[5] ARM Architecture Reference Manual ARMv8, for ARMv8-A architecture profile (ARM DDI 0487).[6] ACPI规范.[7] PSCI设备树定义.[8] ARM可信固件(ATF).[9] Power Control System Architecture Specification (ARM DEN 0050).2 背景知识支持电源管理的操作系统,能够动态改变CPU核的电源状态,根据进行负载均衡,努力降低功耗。电源管理技术主要分为两类:空闲管理:当在某个核上,OS的内核没有线程可以调度时,可以将该核置于clock-gated、retention、power-gated状态。但是,该核对于OS而言还是可用的。Hotplug热插拔:根据对算力的需求变化,而将CPU核置于离线、在线。离线时,OS负责将所有中断和线程从核上迁移走;当在线时,OS负责重新进行负载均衡。尽管由单个供应商提供嵌入式系统的软件可能更简单,但事实并非如此。为了安全,ARM将硬件架构划分为4个不同的异常级(EL)以及2个安全状态,以便支持不同特权的软件分区。下表是基于ARM架构的硬件平台上的软件划分:AArch32AArch64软件和供应商NS_EL0 (PL0)NS_EL0非特权应用,如APPNS_EL1 (PL1)NS_EL1富操作系统内核,如Linux、Windows、iOSNS_EL2 (PL2)NS_EL2Hypervisor(Citrix、VMWare、OK-Labs)S_EL0 (PL0)S_EL0可信OS的应用S_EL3 (PL1)S_EL1可信OS的内核(Trustonic)S_EL3 (PL1)S_EL3安全Monitor,执行安全固件(ATF)AArch32是ARMv8架构的32位执行状态,在ARMv8架构之前采用的执行状态。在ARMv7处理器中,异常级的概念是隐含的,相关文档也并未说明。那时候,虚拟化扩展提供EL2功能,安全扩展提供EL3功能,安全状态由特权级(PL-Privilege Level)标识异常的层次结构。更多信息,参考第3.2节。因为不同供应商的不同操作系统都可运行在ARM系统上,电源管理就需要一个协作的方法。考虑到运行在EL1上的类OS软件或运行在EL2上的hypervisor软件,一般处于非安全状态,如果想要进入idle状态、给一个核上电或掉电、或复位或关闭系统,更高异常级的监控软件必须能够响应这种电源状态变化的请求。同样,如果某个wakeup事件改变了核的电源状态,那么这些监管软件需要执行诸如恢复上下文之类的操作。PSCI提供了不同监管软件之间互操作和集成的标准接口定义。本文档会详细描述这类接口,以及在idle、hotplug、shutdown和reset情况下如何使用它们。3 假设和建议本文档定义了在不同监管软件之间协调电源管理的API。这种API允许监管软件请求给核上电、掉电,安全上下文的核间迁移(可信OS需要)。本文中,假设EL2和EL3都已经实现。3.1 PSCI用途PSCI具有下面的用途:提供给监控程序通用接口,可以在下列情况时,管理功耗:CPU核的空闲管理;系统中的CPU核动态添加和删除,也称之为hotplug热插拔;其它辅核的启动;将受信任的操作系统上下文从一个核迁移到另一个核;系统关机和复位。提供给监控管理程序通用接口,结合FDT和ACPI描述去支持电源管理代码的泛化。PSCI不包括:外设电源管理 和 动态电压和频率调节(DVFS)。PSCI不提供给监控软件电源状态表示。但是,可以和ACPI或FDT等硬件描述技术结合使用。3.2 异常级别、ARMv7特权级别和最高特权级别ARMv8架构明确提出了EL的概念,也定义了安全状态下软件执行特权的层次结构。ARMv7架构中,EL概念是隐含在架构体系中的:虚拟化扩展提供了EL2的功能(只存在非安全状态下)。安全扩展提供了EL3功能,包含对两种安全状态的支持。控制是在Monitor模式下完成的,该模式也只存在于安全状态下。ARMv7使用PL(特权级)的概念描述软件执行的特权层级结构。因为Monitor模式的存在,ARMv7的非安全状态和安全状态是非对称的,如下所述:非安全状态,特权层次结构是:PL0, 非特权,适用于User模式PL1, OS级特权,适用于System, FIQ, IRQ, Supervisor, Abort和Undefined模式PL2, hypervisor特权,适用于Hyp模式安全状态,特权层次结构是:Secure PL0, 非特权,仅适用于User模式Secure PL1, 可信OS和Monitor级特权,适用于System, FIQ, IRQ, Supervisor, Abort, Undefined和Monitor模式AArch32执行状态下,异常级和之前的运行模式对应关系如下:非安全状态:EL0: User 模式EL1: System, FIQ, IRQ, Supervisor, Abort和Undefined模式EL2: Hyp模式安全状态:Secure EL0: User 模式EL3: System, FIQ, IRQ, Supervisor, Abort, Undefined和Monitor模式本文档中如果不特殊声明,则使用异常级别(EL)的术语:通常情况下,提及的EL1和EL0就是指非安全EL1和EL0,除非有特殊说明;3.3 ARM架构上的软件栈ARM设备上可能有的软件栈,如下所示:如图所示,非安全空间中的拥有特权的代码:Rich-OS内核:比如,Linux或Windows,运行在非安全EL1。如果运行在Hypervisor之上,则Rich-OS内核作为hypervisor的一个客户机运行。Hypervisor:运行在EL2,只有非安全状态(新ARMv8架构中,已经存在安全状态的hypervisor)。安全空间中拥有特权的代码:安全平台固件(SPF):芯片或OEM厂商提供。也是系统启动阶段,运行的第一段程序。它提供的服务有,平台初始化、可信OS的安装、SMC调用的调度执行。有些调用的目标可能是SPF,另一些则可能是可信OS。SPF可以运行在EL3,也可以运行在安全EL1(但是,条件是EL3运行在AArch64状态)。ARM提供了一个开源的可信固件代码。可信OS:为normal空间提供安全服务,即为安全应用提供一个运行时环境。在AArch32状态下,可信OS运行在安全EL3,如果是AArch64状态,运行在EL1。PSCI规范主要关注安全、非安全世界之间的电源管理接口。它提供了发送电源管理请求的方法。所以,为了处理这些请求,SPF必须包含PSCI实现。另外,PSCI实现也可能需要在SPF和可信OS之间建立通信。当然,它们之间的处理根据厂商不同而有所不同。尽管,PSCI主要关注安全和非安全世界之间的电源管理请求,但是,也可以在Rich OS和hypervisor之间使用。3.4 通道在不同的异常级别之间提供可以传送消息的通道(一般使用SMC异常指令实现),这样才能提供相同的PSCI电源管理接口。具体参考SMCCC。3.5 安全软件和电源管理许多可信OS不支持SMP。即使在多核平台上,也是运行在某个指定的核上。所以,期望发起SMC调用的核与可信OS运行的核是同一个。不支持多核,可以保证可信OS小而美,容易通过功能安全认证。基于ARM架构的系统通常会包含一个电源控制器,或者电源管理电路,以便管理CPU核的电源。它会提供许多电源管理功能,比如将核、簇或者集群转变为低功耗状态。在低功耗状态,核可以完全关掉,也可以不执行代码而处于静默状态。ARM 强烈推荐由安全空间控制电源状态的变化。否则,在进入低功耗状态之前,不能清除安全状态(包括安全cache的清零)。其它的电源管理,比如动态电源性能管理(通过调节电压和频率实现)不在本接口规范的范围内。3.6 虚拟化和CPU核电源管理策略虚拟机可以分为两种类型Type-1: 有时候称为 native 或 bare metal。通俗理解的话,就是hypervisor代码直接接管硬件,对上提供统一的虚拟隔离空间。所以,Guest OS看到的都是虚拟设备。Type-2: 有时候称为hosted,或者托管类型hypervisor。这类虚拟程序需要依赖一个主机OS,作为宿主。该Host OS看到的是真实硬件,但是Guest OS看到的是虚拟设备。这是一个泛泛的分类,可能会有许多变种。ARMv8架构允许在EL1或EL2运行一个Type-2类型的hypervisor,这更加模糊了类型-1和类型-2的差异。但是,对于电源管理来说,不论哪种类型的hypervisor,都需要捕获这种PSCI调用。从电源管理和虚拟化的角度来看的话,有两种类型的OSPM:物理OSPM: 这个概念包含管理物理电源状态的软件。虚拟OSPM: 这是存在于虚拟机的Guest OS中的OSPM,它管理的是虚拟的,而不是物理电源状态。对于type-2 hypervisor,物理OSPM存在于主机OS中。电源管理策略由运行在EL1的Rich OS负责管理。物理OSPM就包含在这种Rich OS中。在该层,直接拥有物理核的视角。下图的左边部分就是示例。对于本文涉及的电源管理,type-2 hypervisor的行为取决于调用者。如果调用者是Host OS,hypervisor允许调用直接穿透到安全平台固件(SPF)。在这种情况下,hypervisor只需执行必要的操作,比如保存powerdown时的状态。然后,使用调用者传递过来的参数调用SPF。如果没有特殊操作,hypervisor甚至不会捕获来自Host OS的调用,直接将其路由给SPF。Guest OS使用虚拟OSPM,也是通过PSCI API发出电源管理请求,但是发送的目标是虚拟核和虚拟电源状态。hypervisor会捕获这些请求,然后将其发送到物理OSPM。然后,由物理OSPM决定是否请求真实的物理电源管理。对于虚拟机来说,电源管理到hypervisor就结束了。对于type-1 hypervisor,电源管理策略通常是hypervisor自身管理的。如上图右半部分所示。hypervisor实现物理OSPM模块。这种情况下,虚拟机拥有的是虚拟核。由hypervisor决定虚拟机的虚拟电源状态是否请求物理电源控制,如果需要,则使用PSCI API调用安全平台固件SPF。同样,Guest OS也使用PSCI API接口将虚拟电源管理请求发送给hypervisor。对于虚拟机来说,电源管理到hypervisor就结束了。在某些情况下,type-1 hypervisor委托一个特权Guest OS管理电源。这种情况下,物理OSPM在特权Guest OS中实现。大概的电源管理方法与type-2 hypervisor类似。4 PSCI使用场景和要求4.1 空闲管理当一个核处于idle状态时,OSPM将其置于低功耗状态。通常,选择进入不同的电源状态,会有不同的entry和exit延迟,也会有不同的功耗。想要进入哪种电源状态,依赖于核重新工作的时间。除了核之外,电源状态也可能依赖于SoC中的其它组件的活动。每种状态都由一组组件的状态共同决定,进入该状态时,这些组件通过时钟控制(clock-gated)或电源控制(power-gated)。这些状态有时候也描述为浅睡眠或深度睡眠。通常,文献描述使用X标识深度睡眠,Y标识浅睡眠:X状态应该是Y状态的超集。X状态比Y状态更省电。从低功耗状态进入运行状态所需要的时间称为唤醒延迟。通常,深度睡眠状态具有更长的唤醒延迟。尽管空闲电源管理是由核上的线程行为引起的,但是OSPM将硬件平台设置的状态,也可能会影响除CPU核之外的其它组件。比如,如果SoC中的最后一个核进入idle状态,OSPM就可以考虑整个SoC的电源状态了。此时的选择也会受系统中的其它组件影响,所以,应该在SPF、hypervisor、OS之间协调电源管理。典型的例子是,当所有核,和其它请求者都处于空闲状态时,将系统置于一种状态,在这种状态下,将上下文保存在内存中(内存不断刷新中)。OSPM必须提供必要的电源管理软件基础设施,确定能够对电源状态作出正确的选择。在空闲管理中,当一个核被置于低功耗状态,它可能随时被唤醒事件激活,比如说中断。ARM架构划分的电源状态有四种:RunCPU核上电,且可以正常运行的状态。StandbyCPU核上电。通过WFI或WFE指令进入该状态,由唤醒事件唤醒。此过程中,CPU核保持所有状态。也就是说,从standby到run状态,不会复位CPU核。CPU核的上下文都会被保持,唤醒即可访问这些内容。处于该核所在电源域的外部调试器(debugger),能够访问debug寄存器。换句话说,standby状态不会影响调试器的使用。RetentionCPU核的状态,包括debug设置等,保存在低功耗的保持寄存器中,这样允许CPU核至少可以关闭部分电源。从低功耗状态到运行态的转变,不用复位CPU核。低功耗转变到运行态时,原先保存的状态数据从保持寄存器中恢复。所以,从OS的角度来说,Retention和Standby状态没有什么差别,除了恢复时的入口地址,延迟和使用上的一些限制之外。但是,对于外部debugger来说,就不一样了,外部debug请求事件会被挂起,debug寄存器无法访问。Powerdown该状态下,CPU核会被掉电。软件需要保存所有的核状态数据。从掉电到恢复运行,需要:上电后,需要复位CPU核恢复之前保存的核的状态数据Powerdown状态对上下文是破坏性的。不仅仅是CPU核的状态数据,如果是更深的休眠状态,可能包括GIC或者平台依赖的其它一些IP核也会掉电。所以,相关数据或状态必须保存。根据debug和trace电源域的组织架构,其上下文内容也有可能会丢失。所以,OS必须提供保存和恢复这些内容的机制。对于OS来说,standby和retention都是一样的,除了debugger之外。所以,后面我们使用standby代表这两种状态。ARM期望在具有最高特权的异常级别上,实现管理电源控制器的代码(通常就是ATF)。那么就必须提供接口,以便OSPM将CPU核置于低功耗状态的消息事件,从不同的异常级层层往下传递(假设越往下,异常级别越高)。而PSCI就是这样的一种机制,将OSPM的电源请求传递给下一个异常级EL。对于standby状态,直接使用WFI或WFE指令即可进入。但是,更深层的standby或retention状态,则要求对电源控制器进行编程,而PSCI仅提供访问电源控制器的接口,并隐藏了与平台相关的代码。对于Powerdown状态,则要求提供每一级EL下保存和恢复上下文的接口。而且,对于Powerdown状态还要求一个return地址。这地址是被唤醒时,OS期望的开始运行的地方。对于Powerdown状态,CPU核从reset复位向量(安全状态)开始运行。待完成初始化,则跳转到Powerdown之前要求的那个返回地址处开始执行。PSCI提供了传递返回地址和上下文的参数。4.2 电源状态系统拓扑多核系统中,不同的电源域控制系统的不同部分。每一个电源域可能是一个或多个PE(CPU核、协处理器、GPU),内存(Cache、DRAM),簇内、簇间一致性部件等组成。电源域中的每个组件的电源状态,都可以影响电源域中的其它组件。虽然,从物理上来说,电源域不是一个必须存在的层次结构。但是,从软件的角度来说,必须进行一个逻辑上的划分。如果想要改变电源域的电源状态,必须对其依赖项进行排序。比如,共享Cache的电源域,以及使用共享Cache的CPU核的电源域,它们之间的依赖关系。在这样一个系统中,为了保证数据的一致性,必须先关闭CPU核的电源,然后再关闭共享Cache的电源。上图展示了一个系统级的电源域的拓扑结构的示例。它拥有两个子电源域,每一个包含一个cluster簇,支持一组cluster电源状态。每一个cluster电源域,又包含两个子电源域,每一个包含一个CPU核,并额外支持一个电源状态。从硬件的角度来看,一个系统被划分为多个单独或共享的电源域。每个电源域都可以表示为电源域拓扑树中的一个节点。兄弟电源域是互斥的。父电源域由子电源域共享。树中的各种级别(示例中的核、cluster簇和系统)称为电源级别。较高的级别,更接近树的根(系统),较低的级别更接近树叶(核)。4.2.1 局部电源状态和组合电源状态上面拓扑结构中的各个节点都有自己的电源状态,我们称为局部电源状态。当处于空闲核上的OS请求电源状态的改变时,不仅需要请求改变CPU核的局部电源状态,还需要请求改变父节点的电源状态。比如,上图的示例中,假设核1是簇0内最后进入空闲状态的,对于OS来说,就需要同时为簇0和核1请求电源状态改变。这种组合的电源状态我们就称为组合电源状态。这种组合电源状态可不是随意组合的,而是电源等级越高的节点上,其电源状态越浅。换句话说,子节点进入休眠的程度应该大于等于父节点。规则如下:电源等级高的节点掉电(Powerdown),电源等级低的节点也必须掉电;电源等级高的节点保持(Retention),电源等级低的节点只能是保持或掉电;电源等级高的节点待机(Standby),电源等级低的节点只能是待机、保持或掉电;电源等级高的节点运行(Run),电源等级低的节点可以是任意状态。如下表所示:系统级簇级核级RunRunStandbyRunRunRetentionRunRunPowerdownRunRetentionRetentionRunRetentionPowerdownRunPowerdownPowerdownRetentionRetentionRetentionRetentionRetentionPowerdownRetentionPowerdownPowerdownPowerdownPowerdownPowerdown4.2.2 亲和力的层次结构ARM系统通常是多核、或多簇的处理器。本文档使用亲和力的层次结构描述CPU核和簇的架构关系。亲和力层次结构往往直接对应系统的电源拓扑,但这也不是绝对的。4.2.3 电源状态协调高层的节点进入某种局部电源状态,必须与子节点的电源状态进行协调。比如说,想要使一个簇(cluster)进入Powerdown状态,那么该簇内所有的核也必须进入Powerdown状态。实现方式就是,其它核都进入Powerdown状态,最后一个核再把自己和簇置于Powerdown状态。对于这种对子节点的电源状态进行协调的方式,PSCI支持两种:平台协调模式、OS发起模式。平台协调模式这是默认的电源协调模式。在该模式下,PSCI实现者(如ATF等)负责协调电源状态。当某个核上没有任务时,OSPM为该核、以及所在簇请求允许范围内的最深休眠状态。因为该核的电源状态改变请求,可能影响其父节点,所以,PSCI实现者得根据簇内所有节点的情况选择最深的休眠状态。实际上,电源状态请求表达了两种限制:PSCI实现就会根据这两个限制,为指定的节点内所有核,选择一个最深的电源状态。下表展示了OSPM请求不同的电源组合状态,而PSCI最终能够响应的状态。假设簇1一直处于掉电状态中。通常情况下,电源状态越深,唤醒延迟越长。所以,我们假设保持状态比掉电状态具有更短的唤醒延迟。但是,事实并非如此。根据第2个限制,PSCI实现者必须满足每个核唤醒时间的请求。假设双核系统,具有3层系统状态,状态A、B、C,电源休眠程度A < B < C,而唤醒延迟则是A < C < B。如果核0选择状态B,而核1选择状态C,系统将会进入状态A。状态B和C,不能同时满足2个核的要求。PSCI 1.0之前的版本只支持平台协调模式。调用者请求的电源状态已经是最深休眠状态了;调用者请求的电源状态的唤醒延迟不能比这更长了;OS协调模式PSCI 1.0引入,这种模式将电源协调的权利交给了OS。这种模式下,只有节点内的最后一个核进入空闲状态,OSPM才会为该节点申请空闲状态。相比平台协调方式,OS协调方式则是将怎么选择最合适的休眠节点交给了OS,PSCI实现者只需实现,给我什么请求,我就响应什么动作即可。示例如下表所示:如表所示,我们可以看到OS视角和PSCI实现的视角有些不同的地方(红色标记)。这是因为OS虽然请求了,但是PSCI实现还未响应导致的。在上电的时候也会发生,因为PSCI实现会比OS更早看到CPU核。为了实现OS协调方式,必须解决竞争问题。PSCI实现必须怀疑与自己视角不一样的电源状态请求;OS必须指明哪个核是最后一个,而且还要指明该核处于哪一级电源中,如,是簇内的最后一个核,还是系统内最后的一个核。4.3 CPU热插拔和辅核启动CPU热插拔的概念不需要再重复。但是,CPU热插拔和空闲管理中的powerdown还是有一些差异:当核被拔出时,监控软件会停止在中断和线程处理中对该核的所有使用。调用监控软件认为核不再可用。想要使用该核,必须发送命令将核再插入。唤醒事件不会唤醒被拔出的核。操作系统通常在主核上完成内核的引导过程,然后再启动辅核。所以,对于支持热插拔的系统来说,辅核的启动和hotplug的操作是相同的,可以提供一套接口。对于运行在单核上的可信OS,移除该核可能不可行,除非把可信OS迁移到其它核上。PSCI提供具有下列属性的接口:监控软件可以请求给一个核上电。监控软件还必须提供一个启动地址,作为它从安全固件退出时,继续执行的地方。通过提供一个入口地址,监控软件可以直接在自己的地址空间中干一些特殊的事情。为此,监控软件还必须使用内部的per-CPU数据结构保存这些值。监控软件可以请求给一个核掉电。并且还能通知更高异常级别上的软件。监控软件还能请求将可信OS迁移到另一个核上。监控软件,狭义上理解为操作系统的内核。4.4 系统级的shutdown、reset和suspendPSCI提供了接口,允许OS请求系统system shutdown、system reset和system suspend(suspend-to-RAM)。芯片供应商应该提供这些函数的统一实现,它们与监控软件是独立的。没有提供suspend-to-disk,这是因为它是系统关机的一种特殊情况。这儿,system的意思是从整台机器的视角看待问题。也就是说,不是单一关闭某个核或者簇那么简单。当然,运行在虚拟机中的客户机OS,如果调用这些接口不会发生物理状态的变化。但是,如果没有hypervisor,或者调用者是hypervisor,则会导致电源状态的物理变化。即使调用者在物理机器上运行,术语系统可能也不是指整个物理机器。例如,假设一个高级服务器系统由多个单板组成,每个单板具有一个BMC (board management controller),每个单板包含多个SoC。这样的系统可以在每个SoC上运行一个OS实例。在本例中,用于关闭系统的PSCI命令应用于单个SoC,而关闭整个单板需要通过管理接口访问BMC,而这个管理接口是调用操作系统或PSCI实现无法访问的。在本文档中,术语系统仅指对操作系统可见的机器视图。反映到本文档中,就是指一个SOC。
通过GDB调试代码的便利性无需赘言。我们直接以调试meta-hypervisor为示例进行说明。准备工作代码代码请参考meta-hypervisor和meta-demos。代码里边有详细的说明文档。QEMU安装qemu-system-aarch64,版本要求大于5.0。启动QEMU启动QEMU的命令:qemu_cmd:=qemu-system-aarch64 run: platform @$(qemu_cmd) -nographic\ -M virt,secure=on,virtualization=on,gic-version=3 \ -cpu cortex-a53 -smp 4 -m 4G\ -bios $(atf-fip)\ -device loader,file="$(meta_image)",addr=0x50000000,force-raw=on\ -device virtio-net-device,netdev=net0\ -netdev user,id=net0,net=192.168.42.0/24,hostfwd=tcp:127.0.0.1:5555-:22\ -device virtio-serial-device -chardev pty,id=serial3 -device virtconsole,chardev=serial3\ -S -s-bios $(atf-fip)atf-fip是指编译出的ATF和U-boot的二进制文件。-device loader,file="$(meta_image)",addr=0x50000000,force-raw=onmeta_image指的是meta-hypervisor的二进制代码。addr=0x50000000指的是加载到物理内存0x50000000地址处。-M virt,secure=on,virtualization=on,gic-version=3指定需要使用的machine类型,virt是qemu提供的一个通用machine,可以同时支持arm32和arm64(部分cortex不支持),-M help可以列出所有支持的machine列表。secure=on是支持安全空间。virtualization=on是支持虚拟化扩展。gic-version=3是支持GICv3通用中断控制器类型。-cpu cortex-a53 -smp 4 -m 4G-cpu cortex-a53:指定模拟的CPU类型。可以使用-cpu help,查看当前支持的CPU类型。-m 4G:指定内存大小。-smp 4:指定CPU核的数量,默认是1。-s -S:可选,调试参数。-S,可以让qemu加载image到指定位置后停止运行,等待gdb连接;-s, 等价于--gdb tcp::1234,启动gdb server并默认监听1234端口。启动gdb客户端再打开一个命令行窗口:gdb-multiarch --tui ./bin/qemu-aarch64-virt/meta.elf启动后的界面,如下所示:连接gdb-server:(gdb) target remote localhost:1234在cpu_init函数打breakpoint。然后,执行命令continue。回到gdb-server端,输入go 0x50000000开始运行程序。gdb客户端,执行单步执行,最后发现是mem_init_vm_config(config_addr)函数执行出现问题。打印变量print/x config_addr,结果是1。而我们配置平台的物理内存是从0x40000000处开始的。这样,等于我们访问非法物理地址。用VSCode可视化环境调试vscode中集成了gdb功能,我们可以用它来图形化的调试meta-hypervisor。首先,我们按下ctrl+alt+D,调出debug窗口,选择创建launch.json文件,添加vscode的gdb配置文件(.vscode/launch.json):{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "(gdb-multiarch) Launch", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/wrkdir/srcs/meta/bin/qemu-aarch64-virt/meta.elf", "cwd": "${workspaceFolder}", "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb-multiarch", "miDebuggerServerAddress": "localhost:1234" } ] }这里对几个重点参数做一些说明:program: 调试的符号文件miDebuggerPath:gdb的路径, 这里需要注意的是,由于我们是arm64内核,因此需要用gdb-multiarch来进行调试miDebuggerServerAddress:对端地址,qemu会默认使用1234这个端口配置完成之后,可以直接启动GDB, 连接上meta-hypervisor(gdb-server端的启动顺序参考前面的一段),界面如下所示:在vscode中,可以设置断点,进行单步调试。可以查看变量、通用寄存器、系统寄存器、浮点寄存器等。还可以查看调用栈的过程。具体如下红色方框和文字注释所示:
我们在编写裸机程序(baremetal)、虚拟化管理程序(hypervisor)和操作系统(OS)时,Debug分析程序是必不可少的。不像linux内核,有大量的调试方法,很多裸机程序、hypervisor没有完善的调试分析方法。异常相关寄存器但也不是无计可施,在硬件上,ARM架构为程序的异常行为提供了详细的寄存器:ESR_ELx寄存器(x=1,2,3)保存发生异常时的特征,比如异常分类(ESR_ELx.EC)、异常具体原因(ESR_ELx.ISS)等。ELR_ELx寄存器(x=1,2,3)保存发生异常时,保存要返回的地址,一般情况下就是发生异常时的指令地址。FAR_ELx寄存器(x=1,2,3)保存错误地址。HPFAR_EL2寄存器保存stage-2阶段的地址转换发生的错误IPA地址。ARM从硬件架构上设计了4层异常级:EL0、EL1、EL2和EL3。不同特权等级的程序,运行在不同的异常级上。本文从hypervisor虚拟机管理程序的角度,讲解如何利用这些寄存器,对程序的异常情况进行分析。hypervisor本身的abort异常我们以meta-hypervisor出现的具体异常为例:esr_el2 = 0x97010046 elr_el2 = 0xfd8000005880 far_el2 = 0xfd8000005880在这儿,esr_el2的值为0x97010046,对应的位域为:ECILISS10010111_0000_0001_0000_0000_0100_0110EC = 100101:说明是数据abort异常,但是没有发生异常级改变(EL2);或者,当支持嵌套虚拟化时与VNCR_EL2相关的访问产生的数据abort异常。ISS编码(数据abort异常的具体原因)ISVSASSSESRTSFARVNCRLSTFnVEACMS1PTWWnRDFSC2423-222120-1615141312-111098765-01000000010000000001000110通过上面各个位域的信息,综合得出:把W1寄存器中一个字节的数据写入内存时发生的错误。我们再来看汇编代码中,0xfd8000005880地址处的内容:void *memset(void *dest, uint32_t c, uint32_t count) { // ......省略 *d = c; fd8000005874: b94007e0 ldr w0, [sp, #4] fd8000005878: 12001c01 and w1, w0, #0xff fd800000587c: f9400fe0 ldr x0, [sp, #24] fd8000005880: 39000001 strb w1, [x0] d++; fd8000005884: f9400fe0 ldr x0, [sp, #24] fd8000005888: 91000400 add x0, x0, #0x1 fd800000588c: f9000fe0 str x0, [sp, #24] }代码中,fd8000005880: 39000001 strb w1, [x0]确实是往x0寄存器中的地址写入一个字节。这正好与我们对异常原因分析的结果相同。说明异常正是memset函数发生的错误。ISV: 1, 说明23-14位保存着合法指令的异常信息SAS: 00, 说明访问字节数据时产生的错误SSE: 0, 字节访问不要求符号扩展SRT: 00001,错误指令的Wt/Xt/Rt操作数的寄存器编号SF: 0, 指令访问的是32位通用寄存器AR: 0, 指令没有aquire/release语义VNCR:0, 保留LST: 00, 产生abort异常的指令未指定FnV: 0, FAR寄存器是合法的EA: 0, 表示不是外部abortCM: 0, 表示错误不是由cache维护指令产生的S1PTW: 0, 表示不是stage-2错误WnR: 0, 表示写内存DFSC:000110,L2地址翻译错误如果memset函数只有一处调用的话,Bug原因结合代码就很容易分析出来了。但是,我们自己编写的hypervisor中有很多处调用memset函数的地方。所以,就文中的bug示例而言,目前还不能分析出原因。所以,我们需要使用qemu模拟器,通过gdb进行单步调试,看看出问题的代码位置(参见下一篇《QEMU+GDB调试ARM程序》)。Guest OS的abort异常我们设计的hypervisor支持Guest OS触发的4类异常,具体定义如下:abort_handler_t abort_handlers[64] = { [ESR_EC_DALEL] = aborts_data_lower, [ESR_EC_SMC64] = smc64_handler, [ESR_EC_SYSRG] = sysreg_handler, [ESR_EC_HVC64] = hvc64_handler };ESR_EC_HVC64 = 0x16:用于处理Guest OS发起的HVC调用,我们设计使用HVC指令在VM之间建立通信。ESR_EC_SMC64 = 0x17:用于处理Guest OS发起的SMC调用,我们知道ARM规定了PSCI规范,通过将电源管理等代码在ATF代码中实现,这样就实现了资源的安全管理。PSCI规范的底层就是通过SMC指令实现的。hypervisor需要将Guest OS发起的虚拟核的PSCI调用转发给物理核。ESR_EC_SYSRG = 0x18:模拟寄存器和外设。因为Guest OS需要访问一些特殊寄存器和外设,而外设有时候需要多个VM共享,hypervisor对其进行模拟。ESR_EC_DALEL = 0x24:用于处理Guest OS发生的abort异常。比如,Guest OS访问我们未指定的物理内存。对于ESR_EL2寄存器的分析,与前面的一段一样,不在具体详述。而HPFAR_EL2寄存器保存着出错的IPA地址,通过该地址,我们就可以知道,Guest OS访问哪块内存出错,就能解决某些bug了。
1 AArch64启动过程2 初始化异常向量表3 初始化寄存器4 配置MMU和Cache5 使能NEON和浮点6 改变异常级别1 AArch64启动过程AArch64启动过程,其实就是初始化必要的硬件环境。大概有以下内容:初始化异常向量表初始化寄存器配置MMU和Cache使能NEON和浮点改变异常级别2 初始化异常向量表参见ARM架构体系透视5.2-初始化异常-AArch64和ARM架构体系透视5.3-初始化异常-AArch323 初始化寄存器涉及的寄存器:通用目的寄存器堆栈指针寄存器系统控制寄存器3.1 初始化通用目的寄存器为什么需要初始化寄存器?ARM processors use some non-reset flip-flops. This can cause X-propagation issues in simulations. Register initialization helps reduce the possibility of the issue.翻译成大白话就是:上电复位时,寄存器的值不确定。需要初始化到确定状态。下面是一个初始化通用寄存器为0的示例:MOV X0, XZR MOV X1, XZR MOV X2, XZR MOV X3, XZR MOV X4, XZR MOV X5, XZR MOV X6, XZR MOV X7, XZR MOV X8, XZR MOV X9, XZR MOV X10, XZR MOV X11, XZR MOV X12, XZR MOV X13, XZR MOV X14, XZR MOV X15, XZR MOV X16, XZR MOV X17, XZR MOV X18, XZR MOV X19, XZR MOV X20, XZR MOV X21, XZR MOV X22, XZR MOV X23, XZR MOV X24, XZR MOV X25, XZR MOV X26, XZR MOV X27, XZR MOV X28, XZR MOV X29, XZR MOV X30, XZR如果处理器实现了NEON和FP扩展,还需要初始化相应的浮点寄存器。MSR CPTR_EL3, XZR MSR CPTR_EL2, XZR FMOV D0, XZR FMOV D1, XZR FMOV D2, XZR FMOV D3, XZR FMOV D4, XZR FMOV D5, XZR FMOV D6, XZR FMOV D7, XZR FMOV D8, XZR FMOV D9, XZR FMOV D10, XZR FMOV D11, XZR FMOV D12, XZR FMOV D13, XZR FMOV D14, XZR FMOV D15, XZR FMOV D16, XZR FMOV D17, XZR FMOV D18, XZR FMOV D19, XZR FMOV D20, XZR3.2 初始化堆栈指针寄存器某些指令可以隐含地使用堆栈指针寄存器(SP),比如push和pop。在使用之前,必须进行初始化。多核系统中,堆栈指针必须指向不同的内存地址,避免覆盖彼此的堆栈区域。如果使用了不同异常级别的SP寄存器,必须全部初始化。下面的示例,展示了如何初始化当前异常级上的SP寄存器。假设SP指向的堆栈位于stack_top,堆栈大小是CPU_STACK_SIZE字节。ADR X1, stack_top ADD X1, X1, #4 MRS X2, MPIDR_EL1 AND X2, X2, #0xFF // X2 == CPU编号 MOV X3, #CPU_STACK_SIZE MUL X3, X2, X3 // 为每一个CPU核创建独立的堆栈空间 SUB X1, X1, X3 MOV SP, X13.3 初始化系统控制寄存器某些系统寄存器上电时不会复位。因此,必须在使用它们之前,根据软件需求初始化这些寄存器。下面的示例展示了如何初始化HCR_EL2、SCTLR_EL2和SCTLR_EL1这些系统控制寄存器。MSR HCR_EL2, XZR LDR X1, =0x30C50838 MSR SCTLR_EL2, X1 MSR SCTLR_EL1, X1这儿,只是个简单的示例,需要初始化的系统寄存器有许多。理论上,应该初始化所有没有reset值的系统寄存器。但是,某些寄存器是实现时定义的reset值,这依赖于某个处理器的具体实现。关于通用系统控制寄存器,请参考ARMv8架构官方手册以及CPU相关厂商的技术参考手册(TRM)。4 配置MMU和CacheMMU和cache配置包含以下内容:清除、失效Cache设置MMU使能MMU和Cache4.1 清除、失效Cachereset之后,高速缓存Cache中的内容都是非法的。ARMv8-A处理器复位后,硬件自动失效所有Cache,所以,软件无需处理。但是,在某些情况下,仍然需要清除、失效D-Cache,比如某个Core核心掉电的过程中。下面的示例,展示了如何在EL3,通过循环调用DC CISW指令,清除、失效L1级D-Cache。可以作为参考,修改其它等级上的Cache或者其它Cache操作。// 禁止L1-Cache MRS X0, SCTLR_EL3 // 读取SCTLR_EL3 BIC X0, X0, #(0x1 << 2) // 禁止D-Cache MSR SCTLR_EL3, X0 // 写SCTLR_EL3 // 使D-Cache无效,以使代码通用 // 首先,计算Cache大小,然后循环遍历每个set + way MOV X0, #0x0 // X0 = Cache level MSR CSSELR_EL1, X0 // 0x0是L1-Dcache;0x2是L2-Dcache. MRS X4, CCSIDR_EL1 // 读取Cache大小的标识 AND X1, X4, #0x7 ADD X1, X1, #0x4 // X1 = Cache Line大小 LDR X3, =0x7FFF AND X2, X3, X4, LSR #13 // X2 = Cache Set Number – 1. LDR X3, =0x3FF AND X3, X3, X4, LSR #3 // X3 = Cache Associativity Number – 1. CLZ W4, W3 // X4 = way position in the CISW instruction. MOV X5, #0 // X5 = way counter way_loop. way_loop: MOV X6, #0 // X6 = set counter set_loop. set_loop: LSL X7, X5, X4 ORR X7, X0, X7 // Set way. LSL X8, X6, X1 ORR X7, X7, X8 // Set set. DC cisw, X7 // Clean and Invalidate cache line. ADD X6, X6, #1 // Increment set counter. CMP X6, X2 // Last set reached yet? BLE set_loop // If not, iterate set_loop, ADD X5, X5, #1 // else, next way. CMP X5, X3 // Last way reached yet? BLE way_loop // If not, iterate way_loop.4.2 设置MMUARMv8-A处理器使用VMSAv8-64虚拟内存架构执行下面的操作:物理地址到虚拟地址的转换确定内存属性,检查访问权限地址转换由地址页表定义,并由MMU管理。每个异常级都有一个专用的地址页表。地址页表必须在使能MMU之前建立好。VMSAv8-64使用64位地址描述符,描述地址页表中的每一项。它支持:最高48位输入、输出地址三种颗粒度:4K、16K、64K最高4级地址页表遍历更多细节,请参考ARMv8架构官方手册中的AArch64 Virtual Memory System Architecture部分内容。下面两个例子,构建一个EL3转换页表,颗粒度大小为4K,内存大小为4G。0-1G配置为正常的可缓存内存1-4G配置为Device-nGnRnE内存页表包含512个2M大小的Level2内存块和3个1G大小的Level1内存块。示例1:首先,初始化页表控制寄存器。然后,循环使用store指令构建页表,很方便移植。/* 初始化转换表控制寄存器 */ LDR X1, =0x3520 // 4G空间、4K颗粒度 // Inner-shareable MSR TCR_EL3, X1 // Normal Inner and Outer Cacheable. LDR X1, =0xFF440400 // ATTR0 Device-nGnRnE ATTR1 Device. MSR MAIR_EL3, X1 // ATTR2 Normal Non-Cacheable. // ATTR3 Normal Cacheable. ADR X0, ttb0_base // ttb0_base必须是4K对齐的地址 MSR TTBR0_EL3, X0 /** * 1. 使用store指令,循环往页表所在内存写入表项 * 2. 建立Level-1转换表 * 3. 第1项指向Level-2页表(level2_pagetable) */ LDR X1, = level2_pagetable // 必须是4K对齐的地址 LDR X2, =0xFFFFF000 AND X2, X1, X2 // NSTable=0 APTable=0 XNTable=0 PXNTable=0. ORR X2, X2, 0x3 STR X2, [X0], #8 /* 第2项是1G大小的内存块(0x40000000 ~ 0x7FFFFFFF)*/ LDR X2, =0x40000741 // Executable Inner and Outer Shareable. STR X2, [X0], #8 // R/W at all ELs secure memory // AttrIdx=000 Device-nGnRnE. /* 第3项是1G大小的内存块(0x80000000 ~ 0xBFFFFFFF)*/ LDR X2, =0x80000741 STR X2, [X0], #8 /* 第4项是1G大小的内存块(0xC0000000 ~ 0xFFFFFFFF)*/ LDR X2, =0xC0000741 STR X2, [X0], #8 // 建立Level-2转换表 LDR X0, =level2_pagetable // level2_pagetable基地址 LDR X2, =0x0000074D // Executable Inner and Outer Shareable. // R/W at all ELs secure memory. // AttrIdx=011 Normal Cacheable. MOV X4, #512 // Level-2表项个数512 LDR X5, =0x00200000 // 每次增加2M地址空间 loop: STR X2, [X0], #8 // 每项占据2个WORD大小 ADD X2, X2, X5 SUBS X4, X4, #1 BNE loop示例2:在编译时,创建一个section作为转换页表。该方法对于模拟来说,速度很快。使用GNU汇编语法编写。上面示例1中,初始化转换表控制寄存器的代码仍然需要。/* 定义一个宏,存放小端字节序的64位值 */ .macro PUT_64B high, low .word \low .word \high .endm /* 创建一个表项,指向下一级的页表 */ .macro TABLE_ENTRY PA, ATTR PUT_64B \ATTR, (\PA) + 0x3 .endm /* 创建一个表项,指向一个1G大小的内存块 */ .macro BLOCK_1GB PA, ATTR_HI, ATTR_LO PUT_64B \ATTR_HI, ((\PA) & 0xC0000000) | \ATTR_LO | 0x1 .endm /* 创建一个表项,指向一个2M大小的内存块 */ .macro BLOCK_2MB PA, ATTR_HI, ATTR_LO PUT_64B \ATTR_HI, ((\PA) & 0xFFE00000) | \ATTR_LO | 0x1 .endm .align 12 // 按照2^12大小对齐,也就是满足4K大小的颗粒度 ttb0_base: TABLE_ENTRY level2_pagetable, 0 BLOCK_1GB 0x40000000, 0, 0x740 BLOCK_1GB 0x80000000, 0, 0x740 BLOCK_1GB 0xC0000000, 0, 0x740 .align 12 // 按照2^12大小对齐,也就是满足4K大小的颗粒度 level2_pagetable: .set ADDR, 0x000 // 当前page的地址 .rept 0x200 BLOCK_2MB (ADDR << 20), 0, 0x74C .set ADDR, ADDR+2 .endr4.3 使能MMU和cache在使能MMU和cache之前,必须先初始化它们。所有的ARMv8-A处理器要求,为了让MMU和Cache支持硬件一致性(hardware coherency),必须先设置SMPEN标志位。下面的示例,展示了如何设置SMPEN标志位,并使能MMU和Cache的方法。/* 位于CPUECTLR寄存器中 */ MRS X0, S3_1_C15_C2_1 ORR X0, X0, #(0x1 << 6) // SMP标志位 MSR S3_1_C15_C2_1, X0 /* 使能MMU和Cache */ MRS X0, SCTLR_EL3 ORR X0, X0, #(0x1 << 2) // C标志位(D-Cache) ORR X0, X0, #(0x1 << 12) // I标志位(I-Cache) ORR X0, X0, #0x1 // M标志位(MMU) MSR SCTLR_EL3, X0 DSB SY ISB5 使能NEON和浮点在AArch64状态下,不需要使能对NEON和FP寄存器的访问。但是,仍然可以捕获对NEON和FP寄存器的访问。下面的示例,展示了如何在所有异常级下,禁止捕获对NEON和FP寄存器的访问。/* 在EL3和EL2,禁止捕获对这些寄存器的访问 */ MSR CPTR_EL3, XZR MSR CPTR_EL3, XZR /* 在EL1和EL0,禁止捕获对这些寄存器的访问 */ MOV X1, #(0x3 << 20) // 设置FPEN,禁止EL1对这些寄存器的访问 MSR CPACR_EL1, X1 ISB6 改变异常级别ARMv8-A架构新引入了4个异常级别:EL0、EL1、EL2、EL3。有时候,在测试用例中,必须在这些异常级之间进行切换。陷入异常或从异常返回时,处理器都会改变异常级。对于异常级的更多细节,请参考ARMv8架构官方手册中的Exception levels部分内容。6.1 EL3 → EL0(AArch64)reset之后,处理器会进入EL3。此时,较低的异常级EL的控制寄存器和异常状态没有明确。为了进入较低异常级,必须初始化相关执行状态和控制寄存器。然后,调用ERET指令,执行一个伪造的异常返回。下面的示例,展示了如何从EL3切换到非安全EL0的过程:/* 在进入EL2之前,初始化SCTLR_EL2和HCR_EL2寄存器,保存相关值 */ MSR SCTLR_EL2, XZR MSR HCR_EL2, XZR /* 确定EL2执行状态 */ MRS X0, SCR_EL3 ORR X0, X0, #(1<<10) // RW EL2执行状态是AArch64AArch64. ORR X0, X0, #(1<<0) // NS EL1 is Non-secure world. MSR SCR_EL3, x0 MOV X0, #0b01001 // DAIF=0000 MSR SPSR_EL3, X0 // M[4:0]=01001 EL2h must match SCR_EL3.RW // Determine EL2 entry. ADR X0, el2_entry // el2_entry points to the first instruction of MSR ELR_EL3, X0 // EL2 code. ERET el2_entry: /* 在进入EL1时,初始化SCTLR_EL1寄存器 */ MSR SCTLR_EL1, XZR /* 确定EL1执行状态 */ MRS X0, HCR_EL2 ORR X0, X0, #(1<<31) // RW=1 EL1执行状态是AArch64 MSR HCR_EL2, X0 MOV X0, #0b00101 // DAIF=0000 MSR SPSR_EL2, X0 // M[4:0]=00101 EL1h must match HCR_EL2.RW. ADR X0, el1_entry // el1_entry points to the first instruction of MSR ELR_EL2, X0 // EL1 code. ERET el1_entry: /* 确定EL0执行状态 */ MOV X0, #0b00000 // DAIF=0000 M[4:0]=00000 EL0t. MSR SPSR_EL1, X0 ADR x0, el0_entry // el1_entry指向`EL0`代码的第一条指令 MSR ELR_EL1, X0 ERET el0_entry: /* EL0代码 */6.2 AArch64 EL2 → AArch32 EL1有时候,可能在不同的异常级上混合执行状态。比如,较高异常级使用AArch64状态,而较低异常级被允许运行在AArch64或AArch32状态下。因此,有可能从较高异常的AArch64状态,切换到较低异常级的AArch32状态。下面的示例,展示了如何从AArch64 EL2切换到AArch32 EL1。/* 在进入EL1时,初始化SCTLR_EL1寄存器 */ MSR SCTLR_EL1, XZR MRS X0, HCR_EL2 BIC X0, X0, #(1<<31) // RW=0 EL1执行状态是AArch32 MSR HCR_EL2, X0 MOV X0, #0b10011 // DAIF=0000 MSR SPSR_EL2, X0 // M[4:0]=10011 EL1 is SVC mode must match HCR_EL2.RW. // Determine EL1 Execution state. ADR X0, el1_entry // el1_entry points to the first instruction of SVC MSR ELR_EL2, X0 // mode code. ERET el1_entry: /* EL1代码 */
6 虚拟化异常硬件使用中断发送信号给软件。比如,GPU使用中断通知它已经完成帧的渲染。在支持虚拟化的系统中,这部分就更为复杂了。某些中断可能是hypervisor本身处理。其它的中断可能分配到VM中,由其中的软件进行处理。另外,当接收到中断时,中断的目标VM可能没在运行中。这就意味着,你需要一些机制支持hypervisor处理EL2上的中断。另外,还需要一些机制,转发中断到特定的VM或者特定的vCPU上。为了使能这些机制,ARMv8架构支持虚拟中断:vIRQ、vFIQ和vSError。这些虚拟中断的行为与物理中断(IRQ、FIQ和SError类似,但只能在EL0或EL1上执行时发出信号。在EL2或EL3上执行时,是不可能接收到虚拟中断的。注意:安全状态的虚拟化支持是在ARMv8.4-A扩展中引入的。为了在安全EL0/1中,发出虚拟中断的信号,需要支持安全EL2并使能它。否则,在安全状态下是不会发送虚拟中断信号的。6.1 使能虚拟中断为了发送虚拟中断到EL0/1,hypervisor必须设置HCR_EL2寄存器中相关的路由标志位。比如,为了使能vIRQ中断信号,必须设置HCR_EL2.IMO标志位。这种设置,将物理IRQ中断路由到EL2,然后,由hypervisor使能虚拟中断,发送信号到EL1。理论上,可以配置VM接收物理FIQ中断和虚拟IRQ中断。实际上,这是不同寻常的。VM通常只接收虚拟中断信号。6.2 产生虚拟中断产生虚拟中断,有两种机制:由CPU核内部产生,通过HCR_EL2中的一些控制位实现。使用GICv2或更新架构的中断控制器。(参考另一篇文章《GICv3-软件概述》的第8章)让我们从机制1开始。HCR_EL2中,有3个标志位控制虚拟中断的产生:VI:设置该标志位注册一个vIRQ中断。VF:设置该标志位注册一个vFIQ中断。VSE:设置该标志位注册一个vSError中断。设置这些标志位,等价于中断控制器产生一个中断信号给vCPU。产生的虚拟中断收到PSTATE屏蔽,就像常规中断那样。这种机制简单易用,但缺点就是,只提供了产生该中断自身的一种方法。hypervisor需要在VM中模拟中断控制器的操作。总的来说,通过陷入、模拟的方式涉及到开销问题,对于频繁的操作,尤其是中断,最好避免。第二种方法是使用ARM提供的通用中断控制器(GIC),产生虚拟中断。从GICv2开始,通过提供物理CPU接口和虚拟CPU接口,中断控制器可以发送物理中断和虚拟中断两种信号。如下图所示:两种接口是一样的,除了一个发送物理中断信号而另外一个发送虚拟中断信号之外。hypervisor可以将虚拟CPU接口映射到VM,这样,VM中的软件就可以直接和GIC通信。这种方法的优点是,hypervisor只需要配置虚拟接口即可,不需要模拟它。这种方法减少了需要陷入到EL2中执行的次数,因此也就减少了虚拟化中断的开销。虽然,GICv2可以与ARMv8-A一起使用,但更常见的是使用GICv3或GICv4。6.3 转发中断到vCPU的示例到目前为止,我们已经看了虚拟中断是如何被使能和产生的。下面就让我们看一下,将虚拟中断转发到vCPU的示例。在该例子中,我们假设一个物理外设被分配给VM,如下所示:步骤如下:物理外设发送中断信号到GIC。GIC产生物理中断异常,可以是IRQ或FIQ,被路由到EL2(设置HCR_EL2.IMO/FMO标志位)。hypervisor识别外设,并确定已经分配给VM。然后,判断中断应该被转发到哪个vCPU。hypervisor配置GIC,将物理中断以虚拟中断的形式转发给vCPU。然后,GIC发送vIRQ或vFIQ信号。但是,当在EL2上执行时,处理器会忽略掉这类虚拟中断信号。hypervisor将控制权返还给vCPU。此时,处理器处于vCPU中(EL0或EL1),就可以接收来自GIC的虚拟中断。这个虚拟中断同样受制于PSTATE异常掩码的屏蔽。该示例展示了一个物理中断,如何被转发为虚拟中断的过程。这个例子对应于在讲解Stage-2地址转换一节时的直通设备。对于虚拟外设,hypervisor能够产生虚拟中断,而无需将其连接到一个物理中断上。6.4 中断掩码和虚拟中断在异常模型中,我们介绍了PSTATE中的中断掩码位,PSTATE.I用于IRQ,PSTATE.F用于FIQ,且PSTATE.A用于SError。当在虚拟化环境中工作时,这些掩码的工作方式有些不同。例如,对于IRQ,我们已经看到设置HCR_EL2.IMO做了两件事:路由物理IRQ中断到EL2使能在EL0和EL1中的vIRQ中断信号的发送此设置还会改变应用PSTATE.I掩码的方式。当在EL0和EL1时,如果HCR_E2.IMO==1,PSTATE.I对vIRQ进行操作,而非pIRQ。7 虚拟化通用定时器ARM架构提供了通用定时器,是每个处理器中一组标准化的定时器。通用定时器包含一组比较器,每个比较器与通用系统计数器进行比较。当比较器的值等于或小于系统计数器时,就会产生一个中断。下图中,我们可以通用定时器(橙色),由一组比较器和计数器模块组成。下图展示了一个具有两个vCPU的hypervisor的示例系统:在示例中,我们忽略hypervisor在vCPU之间执行上下文切换时花费的开销。4ms物理时间(挂钟时间)内,每个vCPU运行了2ms。如果vCPU0在T=0时设置比较器,让其3ms之后产生中断,中断会按照预期产生吗?或者,你希望在虚拟时间(vCPU所经历的时间)2ms之后中断,还是在挂钟时间2ms之后中断?ARM架构提供了这两种功能,具体使用依赖于虚拟化的用途。让我们看一下硬件架构是如何做到的。运行在vCPU上的软件可以访问2个定时器:EL1物理定时器EL1虚拟定时器EL1物理定时器与系统计数器产生的计数进行比较。可以使用这个定时器给出挂钟时间,即物理CPU的执行时间。挂钟时间,英文名称为wall-clock time,也可以理解为物理CPU的执行时间。EL1虚拟定时器与虚拟计数进行比较。虚拟计数等于物理计数减去偏移量。hypervisor在一个寄存器CNTOFF_EL2中,为当前被调度的vCPU指定偏移量。这就允许它隐藏该vCPU未被调度执行时流逝的时间。为了阐述这个概念,我们扩展前面的示例,如下图所示:在6ms的时间周期内,每个vCPU都运行了3ms。hypervisor可以使用偏移量寄存器让虚拟计数仅仅表示vCPU的运行时间。或者,hypervisor可以设置偏移量为零,这意味着虚拟时间等于物理时间。本示例中,展示的系统计数是1ms。实际上,这个频率是不现实的。我们推荐系统计数器使用1MHz到50MHz之间的频率(也就是1us→20ns计数时间间隔)。8 虚拟化主机扩展下图展示了一个软件和异常级别对应关系的简化版本:可以看到独立hypervisor和ARM异常级别的对应关系。hypervisor运行在EL2上,VM运行在EL0/1上。对于托管型hypervisor这种架构是有问题的。我们知道,通常情况下,内核运行在EL1,但是虚拟化的控制操作在EL2。这意味着,Host OS内核的大部分代码位于EL1,一小部分代码运行于EL2(用于控制虚拟化)。这种设计效率不高,因为它涉及到额外的上下文切换。想要使内核运行在EL2,需要处理运行在EL1和EL2上的一些差异。但是,这些差异被限制到少数子系统中,比如早期引导阶段。支持DynamIQ异构技术的处理器(Cortex-A55、Cortex-A75和Cortex-A76)支持虚拟化主机扩展(VHE)。8.1 在EL2运行Host OSVHE由HCR_EL2寄存器的两个位进行控制:E2H:控制是否使能VHE功能;TGE:当使能了VHE,控制EL0是Guest还是Host。下表总结了典型的设置:执行E2HTGEGuest内核(EL1)10Guest应用(EL0)10Host内核(EL2)11*Host应用(EL0)11当发生异常,从VM退出,进入hypervisor时,TGE最初为0。软件必须在运行Host OS内核主要部分之前设置该位。典型设置如下图所示:8.2 虚拟地址空间下图展示了在引入VHE之前,EL0/1的虚拟地址空间布局如下:在内存管理模型中,EL0/EL1具有两个区域。习惯上,上面的区域称为内核空间,下面的区域称为用户空间。但是,从右侧的图中可以看出,EL2只有底部的一个地址空间。造成这种差异是因为,一般情况下,hypervisor不会直接托管应用程序。这意味着,hypervisor无需划分内核空间和用户空间。分配上面的区域给内核空间,下面的区域给用户空间,仅仅是约定。ARM架构没有强制这么做。EL0/1虚拟地址空间也支持地址空间标识符(ASID),但是EL2不支持。这还是因为hypervisor通常不会托管应用程序。为了允许EL2上有效执行Host OS,我们需要添加第二个区域和ASID的支持。使能HCR_EL2.E2H可以解决这个问题,如下图所示:在EL0中,HCR_EL2.TGE控制使用哪个虚拟地址空间:EL1空间,还是EL2空间。具体使用哪个空间依赖于应用程序运行在Host OS(TGE==1),还是Guest OS(TGE==0)。8.3 重定向寄存器访问前面我们已经知道,使能VHE会改变EL2虚拟地址空间的布局。但是,我们还有一个问题,MMU的配置。这是因为,我们的内核会访问_EL1寄存器,如TTBR0_EL1,而不是_EL2寄存器,如TTBR0_EL2。为了在EL2运行相同的二进制代码,我们需要将对EL1寄存器的访问重定向到EL2的等价寄存器上。使能E2H,就能实现这个功能。如下图所示:但是,这种重定向给我们带来了新问题。hypervisor仍然需要访问真实的_EL1寄存器,以便实现任务切换。为了解决这个问题,一组寄存器别名被引入,后缀为_EL12或_EL02。当在EL2使用时(E2H==1),访问这些别名寄存器就会访问真实的EL1寄存器,以便实现上下文切换。如下图所示:8.4 异常通常,HCR_EL2.IMO/FMO/AMO路由标志位控制着物理异常被路由到EL1还是EL2。当在EL0上执行(TGE==1)时,所有的物理异常路由到EL2,除非通过SCR_EL3寄存器控制路由到EL3。这种情况下,与HCR_EL2路由标志位的实际值无关。这是因为应用程序作为Host OS的子进程在执行,而不是作为Guest OS。因此,异常应该被路由到运行在EL2上的Host OS中。9 嵌套虚拟化理论上,hypervisor还可以运行在一个VM之中。这个被称为嵌套虚拟化:我们称第一个hypervisor为Host Hypervisor,在VM内部的hypervisor为Guest Hypervisor。在ARMv8.3-A扩展之前,就可以通过在EL0中运行Guest Hypervisor而实现在VM中运行一个Guest Hypervisor。但是,这要求大量的软件模拟,导致比较差的性能。通过ARMv8.3-A扩展的特性,可以在EL1上运行Guest Hypervisor。添加了ARMv8.4-A扩展之后,这个过程更加有效率,尽管仍然需要Host Hypervisor中的一些操作。9.1 Guest Hypervisor访问虚拟化控制寄存器我们不想Guest Hypervisor直接访问虚拟化控制寄存器。因为直接访问可能潜在允许VM破坏沙箱,或获取主机平台的信息。这种潜在的问题与我们前面讨论陷入和模拟一节时面临的问题一样。Guest Hypervisor运行在EL1。HCR_EL2中新添加的标志位允许Host Hypervisor捕获Guest Hypervisor对虚拟化控制寄存器的访问:HCR_EL2.NV:硬件嵌套虚拟化总开关HCR_EL2.NV1:使能一组额外的陷入(trap)HCR_EL2.NV2:使能对内存的重定向VNCR_EL2(NV2==1):指向内存中的一个结构ARMv8.3-A添加了NV和NV1控制位。从EL1访问_EL2寄存器,通常是未定义的,这种访问会造成到EL1的异常。而NV和NV1控制位则将这种异常陷入到EL2。这就允许运行在EL1上的Guest Hypervisor,使用运行在EL2上的Host Hypervisor模拟某些操作。NV标志位还能捕获EL1的ERET指令。下图展示了Guest Hypervisor设置和进入虚拟机的过程:Guest Hypervisor访问_EL2寄存器会陷入到EL2。Host Hypervisor会记录Guest Hypervisor的配置信息。Guest Hypervisor尝试进入它的Guest VM(Guest的Guest VM),这种尝试就是调用ERET指令,而ERET指令会被EL2捕获。Host Hypervisor检索Guest的Guest的配置,并加载该配置信息到合适的寄存器中。然后,Host Hypervisor清除NV标志位,并进入Guest的Guest执行。这种方法的问题是,Guest Hypervisor每次访问EL2寄存器都会陷入。在两个vCPU或VM之间执行任务切换时,需要访问许多寄存器,导致大量的陷入异常。而异常进入和退出会带来开销。一个更好的方法是获取EL2寄存器的配置,只有在调用ERET指令时陷入到Host Hypervisor。引入ARMv8.4-A扩展后,这成为可能。当设置了NV2标志位后,EL1访问_EL2寄存器被重定向到内存中的一个数据结构。Guest Hypervisor可以根据需要读写这些寄存器,而无需任何陷入。当然,调用ERET指令仍然会陷入到EL2,此时,Host Hypervisor重新检索内存中的配置信息。后面的过程与前面的方法一致,如下图所示:Guest Hypervisor访问_EL2寄存器被重定向到内存中的一个数据结构。数据结构的位置由Host Hypervisor使用VNCR_EL2寄存器指定。Guest Hypervisor调用ERET指令,尝试进入它的Guest VM(Guest的Guest VM)。ERET指令被EL2捕获。Host Hypervisor检索Guest的Guest的配置,并加载该配置信息到合适的寄存器中。然后,Host Hypervisor清除NV标志位,并进入Guest的Guest执行。这种方法的优点是陷入更少,因此,进入Host Hypervisor的次数也更少。10 安全空间的虚拟化虚拟化是在ARMv7-A架构引入的。那时的Hyp模式等价于AArch32状态的EL2,只有在非安全状态可用。ARMv8.4-A扩展添加了对安全EL2的支持,是一个可选配置。如果处理器支持安全EL2,需要在EL3中使能SCR_EL3.EEL2标志位。设置该标志位允许进入EL2,且使能安全状态下的虚拟化。在安全虚拟化可用之前,EL3通常运行安全状态切换软件和平台固件。这是因为我们想要尽量减少EL3 中的软件数量,让EL3更容易安全。安全虚拟化允许我们将平台固件移动到EL1。虚拟化为平台固件和可信内核提供单独的安全分区。下图说明了这一点:10.1 Secure EL2和两个IPA空间ARM架构定义了两个物理地址空间:Secure和Non-secure。在非安全状态中,VM的Stage-1地址转换的输出总是非安全的。因此,Stage-2地址转换只有一个IPA空间需要处理。安全状态下,VM的Stage-1地址转换的输出可以是安全地址,也可以是非安全地址。地址转换表中描述符中的NS标志位控制输出是安全,还是非安全地址空间。这意味着对于Stage-2地址转换有两个IPA空间需要处理,如下图所示:与Stage-1页表不同,Stage-2页表项中没有NS位。对于特定的IPA 空间,所有转换都可以产生安全物理地址或非安全物理地址。这种转换由一个寄存器位控制。通常,非安全IPA转换为非安全PA,而安全IPA转换为安全PA。11 虚拟化的成本虚拟化的成本是当hypervisor需要为VM服务时,需要在VM和hypervisor之间切换时花费的时间。在ARM系统中,这种成本的最低限是:31个64位通用目的寄存器(X0→X30)32个128位浮点/SIMD寄存器(V0→V31)2个堆栈指针寄存器(SP_EL0,SP_EL1)通过LDP和STP指令,hypervisor只需要32个指令保存和恢复这些寄存器。真正的虚拟化性能损失依赖于硬件平台和hypervisor的设计。12 小测验问:Type-1型hypervisor和Type-2型的区别是什么?答:Type-2型运行在Host OS之上,Type-1型没有Host OS。问:安全状态和非安全状态有多少个IPA空间?答:安全状态有2个IPA空间:安全和非安全。非安全状态有一个IPA空间。问:在哪个异常级别中可以使用虚拟中断?答:虚拟中断只有在EL0或EL1中执行,并且只有设置HCR_EL2中相应的路由标志位才能启用。问:SMMU是什么?如何使用SMMU进行虚拟化?答:SMMU是系统MMU,为非处理器的主控制器提供地址翻译服务。在虚拟化中,SMMU可以给主控制器(如DMA控制器)和VM一样的内存视角。问:HCR_EL2.EH2标志位如何影响MSR TTBRO_EL1,x0在EL2上的执行?答:当E2H==0,该指令写TTBR0_EL1寄存器;当E2H==1,写操作被重定向到TTBR0_EL2。问:VMID是什么?它的作用是什么?答:VMID是虚拟机标识符。用来标记VM的TLB项,以便来自不同VM的TLB项可以在TLB中共存。问:陷入(Trap)是什么?它如何用于虚拟化?答:陷入可以造成合法操作触发异常,并将该操作陷入到更高特权级的软件上。在虚拟化中,陷入允许hypervisor检测某个操作何时执行,然后模拟这些操作。13 其它参考文章与本文相关的一些参考文章:内存管理异常模型ARM虚拟化:性能和架构的意义:关于基于ARM架构的系统虚拟化成本的背景读物Arm community:ARM官方论坛,可以提问问题,查找文章和博客下面是一些其它主题的参考内容:13.1 虚拟化的介绍Xen项目KVM的通用知识13.2 虚拟化概念GICv3/v4软件概述Virtio的背景知识14 接下来的计划打算开发一个轻量级的hypervisor,只实现对VM的分区隔离。hypervisor本身不参与主动调度VM的执行。计划如下:在QEMU模拟器上实现一个hypervisor,支持裸机程序(EL1)的运行在QEMU模拟器上实现一个hypervisor,支持Linux的运行实现两个虚拟机之间的通信选择一个硬件平台运行,初步选择RK3399使用Rust语言重写该hypervisor另外,读者也可以按照Spawn a Linux virtual machine on Arm using QEMU (KVM) 这篇文章,基于ARM模拟平台建立开源的XEN和KVM hypervisor。
1 概述2 虚拟化简介3 AArch64虚拟化4 `Stage-2`地址转换5 指令的陷入和模拟6 虚拟化异常7 虚拟化通用定时器8 虚拟化主机扩展9 嵌套虚拟化10 安全空间的虚拟化11 虚拟化的成本12 小测验13 其它参考文章14 接下来的计划1 概述本文描述了ARMv8-64的虚拟化支持。讨论主题包括stage-2地址转换、虚拟异常和陷入。本文主要介绍基本的虚拟化理论,并给出一些hypervisor如何利用虚拟硬件特性的示例。不会讨论如何写一个具体的hypervisor,或解释如何从头写一个hypervisor。文章的最后,有一些问题可以用来检测你的学习程度。通过本文,首先,你将学习到两种类型的hypervisor,以及它们与ARM架构的异常级别(EL)的关系。其次,你将能够解释陷入操作,以及如何使用它们模拟操作。最后,你将了解hypervisor能够产生哪些虚拟异常,并描述相关机制。1.1 准备工作假设你对虚拟化有一个基本的认识,包括虚拟机是什么,以及hypervisor的角色。还应该熟悉内存管理中的异常模型和地址转换。2 虚拟化简介首先,我们引入一些hypervisor和虚拟化理论的入门知识。如果,你已经非常熟悉这些概念,请跳过本段。在本文中,我们使用术语hypervisor来表示负责创建、管理和调度虚拟机(VM)的软件。2.1 虚拟化为什么重要?虚拟化是一项使用广泛的技术,支撑着几乎所有的现代云计算和企业基础设施。通过虚拟化,开发人员可以在单个机器上运行多个操作系统,以便可以在不损害主机环境的情况下测试软件。虚拟化在服务器中很流行,对虚拟化的支持也是大多数服务器级处理器的要求。因为虚拟化带给了数据中心想要的特性,包括:隔离:虚拟化的核心是为运行在单个物理系统上的多个虚拟机提供隔离。这种隔离允许互不信任的计算环境共享物理系统。例如,两个竞争对手可以在数据中心共享一台物理机器,但不能访问彼此的数据。高可用性:虚拟化允许在物理机器之间无缝并透明的迁移工作负载。这种常用于将工作负载从故障的硬件平台上迁移出来,以便维护、替换出错的硬件平台。负载均衡:为了优化数据中心的硬件和电力预算,充分利用硬件平台是非常重要的。这可以通过虚拟机的迁移,或在物理机器上托管合理的工作负载实现。这意味着尽可能地挖掘物理机器的容量。基于此,可以为数据中心提供商提供最好的电力预算,也为算力租户提供最佳性能。沙箱: 虚拟机可以为应用程序提供沙箱运行环境。比如,旧应用程序或开发中的软件,都可以运行在虚拟机中。运行在虚拟机中,可以阻止程序的漏洞或缺陷、甚至是恶意程序破坏运行在物理机器上的其它应用程序。2.2 独立或托管hypervisorhypervisor可以分为两大类:独立hypervisor,也称为Type-1型hypervisor;托管hypervisor,也称为Type-2型hypervisor。我们首先看一下Type-2型hypervisor。在Type-2型的配置中,Host OS完全掌控着硬件平台和它的所有资源,包括CPU和物理内存。下图为一个Type-2型hypervisor的示意图:Virtual Box或VMware就是这种类型的hypervisor。这种hypervisor的好处是,Host OS可以充分利用已有的OS功能管理硬件,也就是不用再开发大量的驱动程序。运行在被托管的虚拟机中的OS,我们称之为Guest OS。接下来,我们看一下Type-1型hypervisor从图中可以看出,该设计中没有Host OS的存在。hypervisor直接运行在硬件之上,完全掌控硬件平台及其所有资源,包括CPU和物理内存。与托管型hypervisor一样,独立hypervisor也可以托管虚拟机。这些虚拟机可以运行一个或多个完整的Guest OS。ARM平台上最常用的两个开源hypervisor是Xen(独立,Type-1型)和KVM(托管,Type-2型)。本文在阐述一些要点时,会用这两个hypervisor作为示例。当然,还有许多其它可用的hypervisor,包括开源或私有的。2.3 全虚拟化和半虚拟化虚拟机的经典定义是一个独立的、隔离的计算环境,与真实的物理机器没有区别。尽管可以在ARM平台上完全模拟真实的机器,但这通常不是一种有效的方式。比如,模拟的网卡设备非常慢,因为Guest OS每次访问模拟寄存器,都必须由hypervisor处理。频繁的陷入导致比直接访问物理设备的寄存器,代价高昂的多。作为替代方案,是修改Guest OS。让运行在虚拟机中的Guest OS意识到,自己是运行在虚拟机中,同时,在hypervisor提供性能更好的虚拟设备,Guest OS可以获得更好的访问性能。(简单地理解,全虚拟化中,每次访问寄存器都需要切换到hypervisor中执行,而半虚拟化中,将多次寄存器访问合并为一次I/O操作,减少hypervisor的切换次数,以提高性能)Xen就是半虚拟化的代表,也是它推广了半虚拟化这个概念。使用Xen的虚拟化方案,需要修改Guest OS,以便让其可以在虚拟硬件平台上运行,而不是一个物理机器上。这种修改完全是为了提高性能。在今天,包括ARM在内,大多数架构都支持硬件虚拟化,Guest OS基本上不需要修改就可以运行。除了少数几种I/O设备,比如块存储设备和网络设备,它们使用半虚拟化的设备和驱动程序。这种半虚拟化的I/O设备包括VirtIO和Xen PV Bus。2.4 虚拟机和虚拟CPU理解虚拟机(VM)和虚拟CPU(vCPU)的区别是很重要的。虚拟机包含一个或多个vCPU,如下图所示:VM和vCPU的概念,在我们理解文章中的某些主题时非常有用。比如,一个物理内存页可以被分配给一个VM,那么该VM中所有的vCPU都可以访问这个内存页。但是,一个虚拟中断,只能被传送到目标vCPU上。严格意义上,应该使用虚拟处理单元(vPE)的概念,而不是vCPU。对于ARM架构实现的机器来说,PE是通用术语。本文使用vCPU的概念而不是vPE,是因为大部分人对此概念比较熟悉。但是,在ARM架构规范中,使用vPE的术语。3 AArch64虚拟化运行在EL2或更高异常级别上的软件,可以访问控制虚拟化:Stage-2地址转换EL1/0指令和寄存器访问的捕获虚拟异常的产生异常级别(EL),各层上运行的软件以及安全、非安全状态的对应关系,如下图所示:值得注意的是,安全状态的EL2是灰色的。这是因为Secure EL2的支持并不总是可用的(ARMv8.4扩展)。这将在安全虚拟化一节中讨论。ARM架构中一些其它的虚拟化扩展特性,包括:安全虚拟化主机虚拟化扩展-支持托管(Type-2型)hypervisor嵌套虚拟化4 Stage-2地址转换4.1 Stage-2地址转换概念Stage-2地址转换允许hypervisor对虚拟机中的内存有一个全局视角。具体来说,就是hypervisor能够控制VM访问的哪些内存映射的系统资源,以及这些资源在VM地址空间中的位置。能够控制VM的内存访问,对于隔离和沙箱运行是非常重要的。Stage-2地址转换可以保证VM只能看见分配给它的资源,而无法访问分配给其它VM或hypervisor的资源。对于内存地址转换来说,Stage-2地址转换属于第二阶段。为了支持该功能,需要一组新的地址转换表,称为Stage-2页表。操作系统(OS)控制一组地址页表,将自己的虚拟地址空间映射到它认为的物理地址空间上。但是,OS想要访问真正的物理地址,还需要经历第二阶段的地址转换。这个第二阶段的地址转换由hypervisor控制。OS控制的地址转换称为Stage-1地址转换,hypervisor控制的地址转换称为Stage-2地址转换。OS认为的物理内存空间称为中间物理地址(IPA)空间。Stage-2阶段使用的页表格式与Stage-1类似。但是,页表中某些属性的处理是不同的,比如内存类型是Normal或Device,是直接编码到页表项中的,而不是通过MAIR_ELx寄存器的标志位进行判断。4.2 虚拟机标识符(VMID)每个虚拟机都有一个标识符,称为VMID。VMID用来给TLB项进行标记,这样就可以知道相应的项属于哪个VM。通过这种标记的方法,就可以允许同时在TLB中存在不同VM的地址转换。VMID存储在VTTBR_EL2寄存器中,可以是8位或16位。由VTCR_EL2.VS标志位控制。16位的VMID是在ARMv8.1-A架构扩展中引入的。EL2和EL3的地址转换不需要使用VMID进行标记,因为它们不属于Stage-2地址转换。4.3 VMID和ASID的组合使用我们知道,TLB表项也可以使用地址空间标识符(ASID)进行标记。应用进程由OS指定ASID,该进程中的所有TLB表项都会被该ASID标记。这意味着属于不同应用进程的TLB表项可以在TLB中共存,从而不存在一个应用进程使用了不属于它的TLB表项。每个VM都有自己的ASID命名空间。比如,两个VM可能都使用了ASID=5,但是对于它们来说,是不同的事物。所以,ASID和VMID的结合是非常重要的。4.4 内存属性的组合和覆盖Stage-1和Stage-2地址映射都包含了属性,像内存类型和访问权限。内存管理单元(MMU)会组合两个阶段的属性,给出最终的属性结果。MMU从两者之中选择更严格的属性,如下图所示:在本示例中,内存的Device类型比Normal类型更严格。因此,最终要访问的就是Device类型内存。如果,我们颠倒两个阶段的内存类型指定,也就是Stage-1是Normal,Stage-2是Device,那么,结果是一样的。这种属性结合方法适用于大部分情况,但是,有时候,hypervisor可能想要覆盖这种行为。比如,在VM的早期引导启动阶段,HCR_EL2.CD:强制所有Stage-1阶段的属性都是非缓存的(Non-cacheable)。HCR_EL2.DC:强制Stage-1阶段的属性为回写可缓存的正常内存(Normal、Write-Back Cacheable)。HCR_EL2.FWB:允许Stage-2覆盖Stage-1阶段的属性,而不是前面的常规属性结合方式。(这样,hypervisor可以阻止虚拟机访问某些关键的外设,以防该虚拟机中的Guest OS被恶意破坏后,进一步访问关键设备)。HCR_EL2.FWB是ARMv8.4-A扩展的引入的。4.5 模拟MMIO同真实的物理地址空间一样,一个VM的IPA空间,包含内存和外设,如下图所示:VM使用IPA地址中的外设区域,访问真实的物理外设(通常是直接分配的外设,也称为直通设备)和虚拟外设。虚拟外设完全是由hypervisor使用软件模拟的,如下图所示:已分配的外设是已经分配给VM的真实物理设备,映射到其IPA地址空间中。这就允许运行在VM中的软件可以直接与外设进行交互。虚拟外设是hypervisor使用软件模拟的一个设备。相应的Stage-2页表项标记为fault。VM中的软件认为它在直接跟外设交互,实际上,每次访问都会触发一个Stage-2的fault异常,hypervisor在异常处理程序中模拟外设的访问。为了模拟外设,hypervisor不仅需要知道要访问哪个外设,而且需要知道访问外设中的哪个寄存器,是读还是写寄存器,访问的大小,以及传输数据的寄存器。为了处理异常,异常模型引入了FAR_ELx寄存器。当处理Stage-1的fault异常时,该寄存器会报告触发异常的虚拟地址。但是,此时的虚拟地址对hypervisor是没有用的,因为通常hypervisor不知道Guest OS如何配置它的虚拟地址空间。对于Stage-2阶段的fault异常,有一个额外的寄存器HPFAR_EL2,它将报告发生abort的IPA地址。因为hypervisor可以控制IPA地址空间,所以,它可以使用这个信息确定需要模拟的寄存器。异常模型展示了ESR_ELx寄存器如何报告异常的信息。对于通用目的寄存器load或store触发的Stage-2阶段的fault异常,会提供额外的信息。这些信息包括访问的大小,源还是目的寄存器,以及允许hypervisor决定对虚拟外设的访问类型。下图展示了捕获异常,并模拟访问的过程:这个过程分为三步:VM尝试访问虚拟外设。在本示例中,访问虚拟UART的接收FIFO。这次访问会被阻塞在stage-2地址转换阶段,产生abort,陷入到EL2。abort异常会将异常的信息,比如访问的字节数、目标寄存器以及它是load还是store,写入到寄存器ESR_EL2。abort异常还会将异常的IPA地址,写入到寄存器HPFAR_EL2中。hypervisor读取ESR_EL2和HPFAR_EL2,识别要访问的虚拟外设寄存器。根据这些信息,hypervisor模拟相应的操作。然后,通过ERET指令返回到vCPU。之后的执行从LDR之后的指令开始。4.6 系统内存管理单元(SMMU)到目前为止,我们已经考虑了来自处理器的不同访问类型。系统中的其它主控制器,比如DMA控制器也会被分配给VM使用。我们还需要一些方法,将Stage-2阶段的保护扩展到这些主控制器上。先考虑不使用虚拟化的系统,和其DMA控制器布局,如下图所示:该DMA控制器通过内核空间的驱动程序进行访问。该内核驱动程序因为与内核在同一个地址空间中,能够保证OS内存访问不被破坏。也就是,应用程序不能通过DMA访问它不应该访问的内存。再来考虑相同的系统,但是OS运行在VM中,如下图所示:在该系统中,hypervisor使用Stage-2地址转换为VM提供隔离。也就是说,虚拟机能够访问的内存完全是由hypervisor控制的Stage-2页表决定的。如果直接允许VM中的驱动与DMA控制器交互,将会产生两个问题:隔离:DMA控制器不属于Stage-2页表,可以破坏VM的沙箱。地址空间:由于存在两个阶段的地址转换,导致内核相信PA就是IPA。而DMA控制器仍然能够看见真实的PA,因此,内核和DMA控制器就有了不同的内存视角。为了解决这个问题,hypervisor可以捕获VM和DMA的每次交互,提供必要的模拟行为。当内存碎片化时,这个过程非常低效且是有问题的。一个替代方案是,扩展Stage-2地址转换机制,让其也能够对其它主控制器对内存的访问进行管理,比如,DMA控制器。也就是为这些主控制器也提供一个MMU管理单元,我们称之为系统内存管理单元(简称为SMMU,有时也称IOMMU)。hypervisor负责对SMMU进行编程,这样,其它主控制器,比如本例中的DMA,就和VM具有一样的内存视角了。这个方案解决了我们上面提出的两个问题。SMMU能够增强VM之间的隔离,保证独立的主控制器不会破坏沙箱环境。而且,SMMU也给了VM和分配给VM的主控制器一致的内存视角。当然了,虚拟化不是SMMU的唯一使用场景。对于其它使用情况不再本文的讨论范围,后续再专门写文章讨论。5 指令的陷入和模拟有时候,hypvervisor需要模拟VM中的操作。比如,VM中的软件想要配置跟电源管理或cache一致性有关的一些底层的处理器控制。通常,我们不想VM直接访问这些控制寄存器,因为,它们可能被用来破会隔离,或者影响系统中的其它VM。当执行给定的操作时,比如读取一个寄存器,陷入会产生异常。hypervisor需要这种能力去捕获VM的操作,就像配置底层的一些控制寄存器一样,而不会影响其它VM。ARMv8架构提供了这种捕获VM操作并模拟它们的陷入控制标志位。当配置了某种陷入异常之后,VM执行某个特定的操作,将会造成异常,从而陷入到更高级别的异常级(EL)中。进而,hypervisor能够利用这些陷入异常模拟VM中的操作。比如,执行等待中断(WFI)指令,会将CPU置入低功耗状态。如果设置了HCR_EL2.TWI==1,在EL0或EL1执行WFI指令,就会在EL2产生一个异常。注意:陷入(Trap)不仅仅是给虚拟化使用的。在EL3和EL1一样可以控制陷入。但是,陷入对虚拟化软件特别重要。本文仅讨论与虚拟化相关的陷入操作。在WFI例子中,OS通常在idle循环中执行执行WFI指令。对于虚拟机中的Guest OS,hypervisor能够捕获这种操作,然后调度不同的vCPU执行,如下图所示:5.1 表示某些寄存器的虚拟值另一个使用陷入的例子是表示某些寄存器的虚拟值。比如,ID_AA64MMFR0_EL1,表示处理器支持的内存相关的一些特性。尤其是在启动阶段,OS可能会读取这些值,判断内核是否应该使能某些功能。对此,hypervisor可能想给Guest OS表达一个不同的值,称为虚拟值。为此,hypervisor使能相关陷入标志位。当VM读取该寄存器时,发生陷入异常,hypervisor确定是哪种陷入触发的,然后,模拟该操作。在本例中,hypervisor使用ID_AA64MMFR0_EL1的虚拟值填充目的寄存器,如下图所示:陷入异常,也可以用于懒惰上下文切换(lazy context switching)。比如,通常情况下,OS在引导启动阶段初始化MMU配置寄存器(TTBR<n>_EL1、TCR_EL1和MAIR_EL1),之后,不会再重新设置。hypervisor可以利用这个习惯优化上下文切换,仅仅在上下文切换时恢复这些寄存器,而不用保存它们。但是,启动之后,OS也可能会对其重新编程。为了避免造成问题,hypervisor可以设置HCR_EL2.TVM这个陷入使能位。设置之后,任何尝试写MMU相关的寄存器都会产生陷入异常到EL2中,允许hypervisor检测是否需要更新它保存的这些寄存器的副本。注意:我们使用陷入(trapping)和路由(routing)表示独立,但是相关的概念。回忆一下,陷入是当执行特定的操作造成异常。路由是指一旦异常产生就会被带到的异常级别。5.2 MIDR和MPIDR使用陷入模拟一些操作需要大量的计算。VM的操作产生陷入异常到EL2,hypervisor确定、模拟该操作,然后,返回到Guest OS中。表示特性的寄存器,像ID_AA64MMFR0_EL1,OS不常访问。这意味着,hypervisor模拟这种操作所执行的代码而带来的性能损失是可以接受的。对于那些需要频繁访问的寄存器,或者性能关键代码中访问的寄存器,就需要避免这种计算负载。这类寄存器和其可能值的示例,如下所示:MIDR_EL1:处理器类型,比如Cortex-A53。MPIDR_EL1:亲和力寄存器,比如处理器2的核1。hypervisor希望Guest OS能够看见这些寄存器的虚拟值,但是每次访问都陷入。对于这些寄存器,ARMv8架构提供了代替方案:VPIDR_EL2:EL1读取MIDR_EL1时返回的值。VMPIDR_EL2:EL1读取MPIDR_EL1时返回的值。hypervisor可以在进入VM之前,设置这些寄存器。如果VM中的软件读取MIDR_EL1或MPIDR_EL1,硬件自动返回虚拟值,而无需陷入到EL2处理。注意:VMPIDR_EL2和VPIDR_EL2没有定义复位值。所以,在第一次进入到EL1之前,启动代码必须初始化这几个虚拟寄存器。这在裸机程序中尤为重要。
简介这是一篇ARM官方出版的一篇文章的译文,原文可以参考《GICv3 and GICv4 Software Overview Release B》。文章主要是从软件的角度如何理解、使用GICv3/v4架构,以及与GICv2的一些差异。通过学习本文,尤其是当你在基于ARMv8架构的SoC芯片上,开发裸机程序、安全软件、hypervisor和各种操作系统内核的时候,大有裨益。翻译本文的初衷,也是因为前段时间开发了一个基于ARMv8架构的轻量级hypervisor,在开发中断虚拟化的代码时,从头到尾把本文研读了好几遍,并将其翻译成中文。希望能够给大家提供一些学习上的助力。目录内容2 介绍→主要介绍GIC架构历史3 GICv3基础→概念理解,尤其是编程模型的理解4 GIC配置→如何配置GIC的各种寄存器,使其正常工作5 处理中断→讲解中断的处理流程6 LPI配置→理解ITS服务和基于消息的中断7 SGI中断→如何发送接收软中断8 虚拟化→如何在虚拟化环境下管理虚拟中断9 GICv4→虚拟LPI的直接注入文章内容因为文章内容比较多,感觉用公众号阅读太长了,请点击下面的原文链接进行阅读。下期预告后面打算围绕如何开发一个轻量级hypervisor,开展一个专题(直到完成一个可用的hypervisor为止),敬请期待。(因为工作的原因,更新有时候会慢,请大家多谅解!)上期主题:通往内核的大门(异常向量表_AArch64)下期主题:AArch64虚拟化概述
1 概述2 背景知识3 什么是机密计算4 ARM CCA扩展5 CCA硬件架构6 CCA软件架构7 问题1 概述在本文中,我们看一下现代计算系统中机密计算的角色,以及实现原理。然后,描述了ARM的机密计算架构(CCA)如何在ARM硬件平台上实现机密计算。通过本文,能够学习到:什么是机密计算描述一个复杂的可信链理解Realm是ARM的CCA架构引入的受保护的执行环境知道Realm VM虚拟机如何在CCA架构中,创建、管理和执行TEE环境和Realm环境的差异如何在Realm空间中建立可信环境2 背景知识本文假设你已经熟悉ARM架构的异常模型和内存管理模型。如果不熟悉,请参考ARM官方文档AArch64 Exception model和AArch64 Memory management guides。CCA架构的操作涉及到虚拟机和虚拟化的知识。如果不熟悉这些概念,请参考AArch64 virtualization。如果不熟悉ARM安全知识,请参考Introduction to security。3 什么是机密计算?机密计算是通过在硬件支撑的安全可信环境中执行计算,进而保护使用的数据的一种手段。这种保护使代码和数据免于特权软件和硬件固件的观察和修改。机密计算环境中的应用和操作系统期望执行环境与系统中的其它非可信组件隔离开。在没有显式授权的情况下,平台的其它组件都不能访问机密计算环境中的数据。3.1 ARM CCA架构的条件ARM CCA系统不需要信任大型、复杂的软件栈或可能影响它的外设(如DMA访问的设备)。这样,ARM CCA就尽量削减了和软件栈、硬件开发者之间的关系。假设一种场景:安全架构师将工作负载部署到一个云服务器上,如果不知道hypervisor的开发者是谁,他可能不敢使用。因为hypervisor是未知的,这可能导致架构师对执行环境缺乏安全感。这时候,就需要考虑ARM CCA架构了。ARM CCA架构允许安全架构师将工作负载安全地部署到云服务器上,而无需考虑底层代码的可信度。底层代码包括hypervisor或运行在安全空间的代码。想要使用CCA架构,系统必须提供:提供隔离不信任组件的执行环境;提供一种机制,建立执行环境,并初始化为可信任状态的环境。在本文中,我们将阐述ARM CCA架构如何通过硬件和软件满足这些条件。4 ARM CCA扩展ARM CCA架构允许部署应用或虚拟机(VM),而阻止特权软件(如hypervisor)访问。但是,通常情况下,正是这些特权软件管理着资源,比如内存等。这种情况下,特权软件确实可以访问应用程序或虚拟机(VM)的内存。ARM CCA允许hypervisor控制VM,但是剥夺了其访问VM的代码、寄存器和数据的权力。隔离是通过创建受保护的VM执行空间实现的,这个空间称为Realm。Realm在代码执行和数据访问上与正常空间完全隔离。ARM CCA架构是通过硬件扩展和特殊固件达到的这种隔离。在ARM CCA架构中,这种硬件扩展成为Realm 管理扩展(RME)。RME与特殊的固件交互,这个固件称为Realm管理监控器(RMM)。4.1 Realm空间Realm是ARM的CCA环境,可以被正常空间的Host动态分配。Host是管理应用程序或虚拟机(VM)的监控程序。后面为了方便理解,我们以hypervisor作为Host进行阐述。Realm和硬件平台的初始状态可以被认证。认证可以保证在使用Realm运行机密计算之前,建立可信的Realm环境。所以,Realm空间无需从非安全空间的hypervisor继承信任。hypervisor负责资源分配和管理,Realm空间虚拟机的调度管理。但是,hypervisor不能观察或修改Realm中执行的指令。同样,hypervisor负责创建和销毁Realm的虚拟机空间,同时还要负责申请和释放内存页。为了支持CCA架构,hypervisor需要修改。除了继续保留原有的功能,也就是管理、控制非机密计算的虚拟机之外,还需要与CCA架构的固件(尤其是RMM)进行通信。4.2 Realm空间和Root空间ARMv8-A架构的TrustZone扩展提供了安全、非安全空间。这儿的空间指的是PE的安全状态和物理地址空间的组合。PE执行的安全状态决定了PE可以访问的物理地址空间。在安全状态下,PE可以访问安全、非安全物理地址空间,而在非安全状态下,它只能访问非安全物理地址空间。正常空间通常指的是非安全状态和非安全物理地址空间的组合。CCA架构作为ARMv9-A的一部分,引入RME(Realm管理扩展)。该扩展引入了两个额外的空间,Realm空间和root空间。Root空间是Root安全状态和Root物理地址空间的组合。此时的PE运行在EL3异常级别。特别需要注意的是,Root空间的物理地址空间与安全空间的物理地址空间是隔离的。这是与ARMv8-A的TrustZone主要的不同,TrustZone中EL3的代码没有专门的物理地址空间,而是使用安全空间的物理地址。现在,TrustZone应用代码运行在S_EL2/1/0,Monitor运行在root空间中,也就是EL3。Realm空间类似于TrustZone的安全空间。Realm空间由Realm安全状态和物理地址空间组成。Realm的代码运行在R_EL2、R_EL1和R_EL0。运行在Realm空间的监控软件可以访问正常空间的内存,这样,可以建立共享缓存。下图展示了四个空间,以及它们与SCR_EL3的NS、NSE标志位的关系:Root空间允许可信的引导启动和在不同空间进行切换。PE复位后,进入Root空间。Realm空间提供了VM的执行环境,这些VM与正常、安全空间隔离开。Realm中的VM需要正常空间中的hypervisor进行控制。为此,CCA需要提供:RME,架构所需要的硬件扩展,提供Realm VM执行的隔离环境;RMM,架构所需要的软件,用来管理hypervisor发来的Realm创建、执行的请求。在没有RME扩展的时候,空间的切换是通过SCR_EL3.NS标志位完成的。切换到安全空间时,EL3的软件设置NS = 0;切换到非安全空间时,设置NS = 1。添加了RME扩展后,增加了SCR_EL3.NSE标志位。下表是标志位和四个空间的切换关系:SCR_EL3.NSSCR_EL3.NSEWorldEL0EL1EL2EL300NormalEL0EL1EL2-10SecureS-EL0S-EL1S-EL2-01RealmR-EL0R-EL1R-EL2-11Root---EL34.3 TrustZone和Realm的差异所有ARM-A架构规范中,TrustZone都是作为一个扩展选项存在。该扩展为代码和数据提供了一个安全可信的隔离环境。从ARMv8.4-A架构开始,安全空间也添加了虚拟化的支持,允许在安全空间中管理多个安全分区,从而运行多个可信OS。运行在S_EL2的安全分区管理器(SPM),负责安全分区的管理工作。SPM就类似于正常空间中的hypervisor。实际系统中,可信操作系统(TOS)是可信链中的一环,它由更高特权的固件程序进行验证,某些系统中,这些特权固件就是SPM。这意味着TOS的开发依赖于更高特权级别的固件开发者。有两种方法启动TOS的执行:Rich OS进入空闲状态,调用SMC指令,通过Monitor调用TOS;(Rich OS通常指Linux、Windows、Android)TOS的专用中断。安全类型-1的中断用于TOS的执行。在正常空间断言的安全类型-1中断通过Monitor调用TOS。Realm VM不同于TOS或者可信应用程序,因为Realm VM受到正常空间中的hypervisor控制,hypervisor负责创建Realm VM、分配内存。Realm VM和可信OS的不同是:Realm没有任何物理中断使能。所有Realm的中断都是hypervisor虚拟的,然后通过传递给RMM的命令传递进去。这意味着一个受影响的hypervisor可能会阻止Realm VM的执行。hypervisor初始化Realm的执行和内存访问。hypervisor不必验证Realm。Realm有一个独立的可信链,与正常、安全空间的不同。Realm也与控制它的hypervisor完全隔离。也就是说,hypervisor初始化Realm,但是没有能力访问Realm的数据和内存。Realm和TrustZone最大的不同就是:安全扩展和Realm扩展设计目的不同。TrustZone是为芯片供应商和OEM提供的,用来设计硬件平台特有的服务。Realm是为普通开发者设计的,提供一个运行系统代码的环境,与复杂的业务逻辑隔离开。可信是指机密性(Confidentiality)、整体性(Integrity)和真实性(Authenticity),具体解释如下:机密性,ARM CCA环境中的代码或状态不能被其它空间中的代码窥测到,即使它是特权代码。整体性,ARM CCA环境中的代码或状态不能被其它空间中的代码修改,即使它是特权代码。真实性,代码或状态能够被其它代码修改,但是这些更改都是可验证的。Realm和TrustZone另一处不同就是:TrustZone中的代码能够为系统提供机密性、整体性和真实性,而Realm只能提供给系统机密性和整体性。ARM机密计算架构中提供的4个空间中,安全空间和Realm空间是完全隔离的。这意味着可信应用不必关注Realm VM的执行,而Realm VM也不必关心可信应用的执行。5 CCA硬件架构本文描述了Realm管理扩展(RME),其赋能PE运行Realm代码。5.1 Realm空间的要求下图是融合了RME扩展的完整CCA架构视图:Realm空间必须能够执行代码,访问内存和可信设备,并且与其它非root空间和设备完全隔离。同其它空间一样,Realm空间具有3个异常级别R_EL0、R_EL1和R_EL2,其中Realm VM运行在R_EL1和R_EL0。Realm管理监控程序(RMM)运行在R_EL2。关于RMM的描述可以参考Arm CCA Software Stack。空间隔离是通过RME架构扩展实现的,其允许控制内存管理、代码执行、隔离Realm的内容上下文和数据。隔离意味着PE、加密单元、Realm、Root空间等访问会产生错误异常而被阻止。下图中,展示了通过正常空间的hypervisor创建Realm VM并对其进行控制的架构,但是Realm VM的执行在Realm空间中Realm VM的启动过程是hypervisor发送命令给Monitor,然后,Monitor转发给RMM。Monitor是不同空间的“传送门”。它连接着正常空间、安全空间、和Realm空间,保证各个空间的隔离,同时在需要的时候提供通信。5.2 CCA内存管理TrustZone安全扩展提供了两种物理地址空间(PAS):非安全物理空间安全物理空间RME又提供了两种物理地址空间:Realm物理地址空间Root物理地址空间下图展示了各个物理地址空间和系统内存空间之间的对应关系:各个物理地址空间之间的访问是硬件定义的,如下表所示:空间类型非安全PAS安全PASRealm PASRoot PAS非安全√×××安全√√××Realm√×√×Root√√√√如表所示,root空间可以访问所有的物理地址空间。也就是说,root空间在必要时,可以对非安全、安全和Realm的物理地址空间进行转换。为了保证各个物理地址空间的隔离,ARM推出了一项新的硬件单元,称为颗粒度保护表(Granule Protection Table(GPT))。该表会跟踪内存页是用于Realm地址空间、安全地址空间、还是非安全地址空间,MMU单元进行地址转换之前,会检查这个表。EL3的Monitor会动态更新这个GPT表,也就是说,物理内存可能在这几个空间中进行转换。访问非法地址空间会产生一个新的fault,称为颗粒度保护错误(Granule Protection Fault (GPF))。GPC使能、GPT内容和GPF路由都由root空间进行控制。为了保证隔离,Realm空间中的资源必须在Realm自己的内存空间中,也就是Realm物理地址空间的一部分。但是,Realm可能会访问非安全空间的资源,比如使能消息传递。所以,Realm能够访问Realm和非安全物理地址空间。运行在Realm空间的VM,也需要同时访问Realm物理地址空间和非安全物理地址空间。访问不同的物理地址空间是通过Realm的stage-2阶段地址转换表中的NS标志位实现的。下图展示了完整的地址转换流程和GPC检查的位置。在图中,TTD指的是地址转换表描述符,GPTD指的是颗粒度保护表描述符:如果对ARM的2阶段地址转换过程不熟悉,请参考AARCH64 Memory Management。只是需要注意的是,在EL3的stage-1页表项中增加了一个NSE标志位-非安全扩展标志位,用以控制monitor访问4个不同的物理地址空间。不管是非安全空间、Realm空间还是安全空间都能在EL1或EL2完成stage-1地址转换,如果必要,还可以完成stage-2地址转换。RME扩展在完成地址转换后,增加了颗粒度保护检查(GPC),根据GPT检查所有的物理地址和物理地址空间,决定是否可以访问还是产生fault。GPT表存储在Root空间的内存中,保证与其它空间的隔离。GPT的创建和修改只能在root空间中进行,由Monitor或其它可信固件完成。SMMU转换管理也纳入到GPC的检查过程中。5.3 认证运行在Realm中的代码负责管理机密数据或运行机密算法。所以,这部分代码必须知道它是运行在真实的ARM CCA环境中,而不是一个模拟场景。代码还需要知道是否被正确加载,而不是被篡改过。最后,还需要知道整个平台运行在正常模式,而不是debug模式,从而造成机密泄露。这个建立信任的过程称为Attestation(认证)。认证过程分为两部分:硬件平台认证;Realm初始状态认证;这两部分结合起来产生认证报告,realm中的代码可以随时请求访问这些报告。这些报告可以用来验证平台和realm空间中代码的有效性。硬件平台认证包含证明芯片和固件的真实性,从而证明Realm是真实的。这需要硬件的支持,需要提供硬件的标识。同样,硬件还需要支持关键固件镜像的度量,比如Monitor、RMM和其它影响安全的控制器固件(比如电源控制器)。6 CCA软件架构ARM的CCA架构带来了组件的增加,比如RME,再比如固件,尤其是Monitor和RMM。本段介绍CCA架构的软件栈。6.1 软件栈概述运行在Realm中的VM与正常空间隔离开,VM的启动和控制由正常空间的hypervisor管理。为了实现Realm VM的隔离执行,ARM引入了一个新的组件,称为RMM(Realm管理监控器),运行在R_EL2异常级别。RMM负责管理通信和内容切换。RMM只提供机制,不管策略,比如具体是哪个VM运行,或者分配哪块内存给VM等都不管。这些策略交给hypervisor完成。RMM还通过提供stage-2页表,在Realm空间中为各个Realm VM提供隔离空间。可以与安全、非安全空间通信的Monitor,也会提供接口给RMM。Monitor运行在EL3,与平台相关,为系统中各个可信模块提供服务。它提供给RMM一个特定的接口,用于处理来自hypervisor或者Realm VM的请求。如果这个接口定义好,RMM完全可以是一个通用代码段。下图就是一个在realm空间中运行着机密计算的realm vm的CCA架构平台RMM是运行在Realm空间的控制管理软件,响应来自正常空间的hypervisor的请求,从而允许其管理Realm VM的执行。RMM通过Monitor控制非安全物理地址空间和Realm物理地址空间的转换。SMC指令允许RMM、hypervisor和SPM陷入到Monitor中,为所有运行在EL2的软件和Monitor建立了一个通信通道。下图展示了CCA架构SMC指令的调用过程。每个空间中,EL2的代码调用SMC指令,陷入到EL3的Monitor。这是hypervisor通过Monitor与RMM进行通信的基础。6.2 RMMRMM是Realm空间的固件,用来管理Realm空间中的虚拟机执行,并与正常空间的hypervisor进行交互。RMM运行在R_EL2。与正常空间的hypervisor的交互,可以分为机制和策略(能够清晰地抽象出机制和策略是一个优秀工程师的基本素质吧)。hypervisor负责策略:何时创建、销毁VM;何时为VM添加、移除内存;调度VM的执行;RMM给hypervisor策略提供支撑:提供操控Realm页表的服务,用于VM创建、销毁和Realm内存的申请、释放;提供Realm上下文管理。用于调度时保存、恢复上下文;支持中断;拦截PSCI调用,用于电源管理请求。RMM还向Realm VM提供认证和加密服务;还有,RMM为Realm VM提供以下安全原语:RMM验证主机请求的正确性;RMM为Realm VM提供隔离空间;RMM规范定义了两个通信通道,允许在正常空间的hypervisor和Realm VM之间进行通信。hypervisor→RMM的通信通道称为Realm 管理接口(RMI)。RMM→Realm VM的通信通道称为Realm 服务接口(RSI)。RSI是RMM提供的服务。6.3 Realm管理接口RMI是RMM和正常空间的hypervisor之间的接口。RMI允许hypervisor发送指令给RMM,进而管理Realm VM。RMI响应hypervisor调用的SMC指令。RMI提供的服务包括Realm VM的创建、数量、执行和销毁。下图展示了hypervisor、RMM和Monitor之间的RMI:6.4 Realm服务接口RSI是Realm VM和RMM之间的接口。RSI是RMM提供Realm VM额外服务的接口。这些服务包括加密和认证服务。RSI也是Realm VM向RMM申请内存管理的接口。下图展示了RSI在Realm VM和RMM之间的位置:7 问题7.1 CCA架构下,认证是什么意思?CCA架构的认证分为两部分:平台认证和Realm认证。平台认证是通过硬件验证固件和芯片的状态安全;Realm认证是指检查Realm的初始状态。7.2 在基于RME扩展的系统中,允许访问物理内存的最后屏障是什么?在完成所有的虚拟地址(VA)→物理地址(PA)的转换之后,RME扩展增加了颗粒度保护检查(GPC)。具体的实现由Monitor根据GPT表进行管理,该表也是Monitor固件创建的。7.3 RMM存在的两个接口是什么?第一个接口是RMI,提供Realm VM和正常空间的hypervisor通信服务。第二个接口是RSI,允许RMM接收Realm VM发起的服务请求。上期主题:通往内核的大门(异常向量表_AArch64)下期主题:分门别类处理异常
1 建立向量表2 建立向量表的示例3 使能中断异常初始化的过程:建立异常向量表异常路由和屏蔽配置1 建立向量表AArch64中,reset不再是异常向量表的一部分,它有专用的配置输入管脚和寄存器。其余的异常存储在异常向量表中。1.1 Reset向量AArch64中,处理器的开始执行位置是跟处理器的实现有关的,由硬件输入管脚RVBARADDR控制,可以在RVBAR_EL3中读取该地址。启动(boot)代码应该放在该地址处。1.2 向量表每个异常级别都有专门的异常向量表,分别存储在下面的寄存器中:VBAR_EL3VBAR_EL2VBAR_EL1AArch64的向量表与AArch32的不同。每个向量占用128字节,最多包含32个指令。向量表按照2K大小对齐。初始化的时候,将向量表的基地址写入到VBAR_ELx寄存器中即可。关于向量表更多的细节,可以参考ARM官方文档ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture规范中异常向量表部分。下表展示了向量表的组成:地址异常类型描述VBAR_Eln+0x000Synchronous异常EL与异常前的EL相同,且使用SP_EL00x080IRQ/vIRQ0x100FIQ/vFIQ0x180Serror/vSError0x200Synchronous异常EL与异常前的EL相同, 且使用SP_ELx0x280IRQ/vIRQ0x300FIQ/vFIQ0x380Serror/vSError0x400Synchronous异常前的EL比异常EL低,异常前系统模式为aarch640x480IRQ/vIRQ0x500FIQ/vFIQ0x580Serror/vSError0x600Synchronous异常前的EL比异常EL低,异常前系统模式为aarch320x680IRQ/vIRQ0x700FIQ/vFIQ0x780Serror/vSError2 建立向量表的示例示例1:展示了reset之后,如何初始化向量表基地址寄存器LDR X1, = vector_table_el3 MSR VBAR_EL3, X1 LDR X1, = vector_table_el2 MSR VBAR_EL2, X1 LDR X1, = vector_table_el1 MSR VBAR_EL1, X1示例2:展示了AArch64状态下一个典型的异常向量表.balign 0x800 // 向量表2k(2048字节)大小对齐 Vector_table_el3: curr_el_sp0_sync: // synchronous处理程序 // 来自当前EL的异常,使用SP0 .balign 0x80 curr_el_sp0_irq: // IRQ中断处理程序 // 来自当前EL的异常,使用SP0 .balign 0x80 curr_el_sp0_fiq: // FIQ快速中断处理程序 // 来自当前EL的异常,使用SP0 .balign 0x80 curr_el_sp0_serror: // Serror系统错误的处理程序 // 来自当前EL的异常,使用SP0 .balign 0x80 curr_el_spx_sync: // synchronous处理程序 // 来自当前EL的异常,使用SPx .balign 0x80 curr_el_spx_irq: // IRQ中断处理程序 // 来自当前EL的异常,使用SPx .balign 0x80 curr_el_spx_fiq: // FIQ快速中断处理程序 // 来自当前EL的异常,使用SPx .balign 0x80 curr_el_spx_serror: // Serror系统错误的处理程序 // 来自当前EL的异常,使用SPx .balign 0x80 lower_el_aarch64_sync: // synchronous处理程序 // 来自低EL且处于AArch64的异常 .balign 0x80 lower_el_aarch64_irq: // IRQ中断处理程序 // 来自低EL且处于AArch64的异常 .balign 0x80 lower_el_aarch64_fiq: // FIQ快速中断处理程序 // 来自低EL且处于AArch64的异常 .balign 0x80 lower_el_aarch64_serror:// Serror系统错误的处理程序 // 来自低EL且处于AArch64的异常 .balign 0x80 lower_el_aarch32_sync: // synchronous处理程序 // 来自低EL且处于AArch32的异常 .balign 0x80 lower_el_aarch32_irq: // IRQ中断处理程序 // 来自低EL且处于AArch32的异常 .balign 0x80 lower_el_aarch32_fiq: // FIQ快速中断处理程序 // 来自低EL且处于AArch32的异常 .balign 0x80 lower_el_aarch32_serror:// Serror系统错误的处理程序 // 来自低EL且处于AArch32的异常3 使能中断异常分为异步和同步异常,异步异常通俗的讲就是我们常规意义上的中断,同步异常就是我们常规意义上的异常中断包括SError、IRQ和FIQ。这些中断在reset之后,默认是屏蔽掉的。因此,如果想要获取SError、IRQ和FIQ,必须设置路由规则,并清除掉屏蔽。另外,想要使能中断,还应该初始化中断控制器,使其发送中断请求给处理器,但这不是本文的范围。3.1 中断路由规则中断的路由规则,决定了中断发生时,哪个异常级别处理该中断。如果要路由到EL3,需要设置SCR_EL3.{EA,IRQ,FIQ}。示例3:展示了如何将SError、IRQ和FIQ路由到EL3异常级别的设置MRS X0, SCR_EL3 ORR X0, X0, #(1<<3) // 设置EA位 ORR X0, X0, #(1<<1) // 设置IRQ位 ORR X0, X0, #(1<<2) // 设置FIQ位 MSR SCR_EL3, X0想把中断路由到EL2而不是EL3,必须设置HCR_EL2.{AMO,FMO,IMO}并清除SCR_EL3.{EA,IRQ,FIQ}。示例4:展示如何将SError、IRQ和FIQ路由到EL2异常级别的设置MRS X0, HCR_EL2 ORR X0, X0, #(1<<5) // 设置AMO位 ORR X0, X0, #(1<<4) // 设置IMO位 ORR X0, X0, #(1<<3) // 设置FMO位 MSR HCR_EL2, X0如果中断没有设置路由到EL3或EL2,默认路由到EL1。3.2 中断的掩码中断是否被屏蔽,取决于下面的因素:中断被路由到的目标异常级别PSTATE.{A,I,F}的值目标异常级别低于当前异常级别,不管PSTATE.{A,I,F}的值是多少,该中断都被屏蔽(隐含规则);目标异常级别等于当前异常级别,如果PSTATE.{A,I,F}设置为1,则异常被屏蔽;目标异常级别高于当前异常级别,且目标异常级别是EL2或EL3,不管PSTATE.{A,I,F}的值是多少,异常都会被接收;目标异常级别高于当前异常级别,且目标异常级别是EL1, 如果PSTATE.{A,I,F}设置为1,则异常被屏蔽;示例5:展示如何在PSTATE中清除SError、IRQ和FIQ的掩码// 使能SError, IRQ和FIQ MSR DAIFClr, #0x7更多关于使能中断的细节,查看ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture规范中的异步异常的类型、路由、屏蔽和优先级部分。
1 Cache管理和DMA数据2 Cache管理和写指令数据3 Cache管理和非Cache或直写数据4 Cache重影和页着色5 总结站在巨人的肩膀上,才能看得更远。If I have seen further, it is by standing on the shoulders of giants.牛顿这是向MIPS架构移植软件的问题系列之第二篇。上一篇《MIPS架构深入理解8-向MIPS架构移植软件之大小端问题》中,我们讨论了大小端对于移植代码的影响。那么本文,我们再从Cache理解一下对于移植代码的影响,尤指底层代码或操作系统代码。在之前的文章《MIPS高速缓存机制 》中,我们已经了解了初始化和正确操作Cache的方法。本段主要讲解一些可能出现的问题,并解释如何处理这些问题。大部分时候,Cache对软件都是不可见的,只是一个加速系统执行的工具。也就是编程人员无需干预。但是,当需要处理DMA控制器及其类似的事物时,考虑把Cache作为一个独立的内存缓存会很有帮助,如下图所示:我们知道,Cache和内存之间的传输总是以16字节或32字节对齐的内存块作为传输单元。即使CPU只是读取一个字节,仍然会加载这样的内存块到Cache行中。理想情况下,内存的状态与CPU请求的所有操作都是最新的,每个有效的Cache行都保存一份正确的内存备份。不幸的是,实际的系统根本就达不到这种理想的情况。假设每次复位之后都初始化缓存,并且也不存在《MIPS高速缓存机制》一文中提到的Cache重影问题。Cache和内存之间还是会存在数据不一致的问题。如下:Cache中的旧数据:当CPU向Cache的内存区写入数据时,它会更新Cache中的备份,同时写入内存。但是,如果通过其它方式更新了内存,那么Cache中的备份就有可能成为旧数据。比如,DMA控制器写内存,或者,CPU往内存中写入新指令,I-Cache继续保持原先的指令等。所以,编程人员应该注意,硬件是不会自动处理这些情况的。内存中的旧数据:当CPU写数据到Cache行中(回写),数据不会立即复制到内存中。稍后,CPU读取数据,直接读取Cache拷贝,一切没有问题。但是,如果不是CPU读取数据,而是其它控制器直接从内存读取数据,就会获取旧值。比如,向外传送的DMA。为此,MIPS架构提供了Cache指令,可以根据需要调用它们,消除这种内存和Cache的不一致性。这些指令可以将最新的Cache数据写回到内存中,或者根据内存的最新状态失效对应的Cache行中的内容。当然了,我们可以把数据映射到非Cache的内存区,比如kseg1区域。比如,网络控制器,映射一段非Cache的内存区保存读写的数据和标志位;这样可以方便快速的读取数据,因为不需要同步Cache和内存中的数据。相同的,内存映射的I/O寄存器,最好也映射到非Cache的内存区,通过kseg1或其它非Cache内存区中的指针进行访问。如果为I/O使用经过Cache的内存区,可能发生坏事情。如果你需要使用TLB映射硬件寄存器,从而进行访问,你可以标记页转换为非Cache内存区(当然了,这不经常使用)。当I/O寄存器的内存地址不在低512M物理地址空间的时候,该方法是非常有用的。还有的使用情况就是映射类似图像帧缓冲区为使用Cache的内存区,充分利用CPU的Cache充填和回写的block读写速度,提高像素帧的刷新频率。但是,每次图像帧的访问,都需要失效和回写Cache,显式地管理Cache。有一些嵌入式CPU,可能会提供一些奇怪但好用的Cache选项,请仔细检查对应芯片的手册。1 Cache管理和DMA数据Cache管理和DMA数据传输是一个很容易出错的地方,即使很有经验的编程者也常常会犯错。对此,不要犯怵;只要清晰地知道自己想干什么以及怎么干,就能让Cache和DMA传输正常工作。比如,当从网络上接收到数据后,DMA设备会直接把数据存进内存,大部分MIPS系统不会更新Cache–即使某些Cache行中持有的地址落在DMA传输更新的内存区域中。随后,如果CPU读取这些Cache行的数据,将会读取Cache中旧的、过时的数据;就CPU而言,没有被告知内存中已经有了新数据,Cache中的数据仍然是合法的。为了避免这种情况,你的程序必须在CPU尝试读取落在DMA缓冲区对应地址范围的数据前,主动失效对应Cache行中的内容。应该将DMA缓冲区的边界和Cache行的边界对齐,这样更容易管理。对于通过DMA向外传输数据,比如网络通信,你必须在允许DMA设备传输数据之前,完全确保Cache中的数据都已经更新到对应的内存发送区域里了。也就是说,在你的程序写完需要由DMA发送的数据信息之后,必须强制写回所有的落在DMA控制器映射的内存地址范围的Cache行中的内容到内存中。只有这样,才能安全启动DMA传输。有些MIPS架构CPU,为了避免显式的回写操作,配置为直写式Cache。但是,这种方案有一个缺点,直写式Cache会造成总体性能上更慢,也会增加系统的电源功耗。当然,你也可以通过映射DMA的传输数据区到非Cache内存地址区,避免显式的失效和回写操作。这也是不推荐的一种方式,因为从整体上会降低系统的性能。因为使用Cache读写内存的速度肯定要快于直接从内存读取数据。最好的建议就是使用Cache,只有下面的情况避免使用Cache:I/O寄存器:MIPS架构没有专门的I/O指令。所以,所有的外设寄存器都必须被映射到一段内存地址空间上。如果使用Cache,会发生一些奇怪的事情。DMA描述符数组:复杂的DMA控制器和CPU共享控制和状态信息,这些信息保存在内存中的描述符数据结构中。通常,CPU使用这些描述符结构体创建一个待发送数据信息的列表,然后,只需告诉DMA控制器开始工作即可。如果你的系统使用描述符结构,请将其映射到非Cache内存地址区域。移植性比较好的操作系统,比如Linux,不管是复杂的、不可见的Cache,还是简单的Cache,都能很好的适配。即,Linux一般提供一组很完备的API,供驱动编写者使用。2 Cache管理和写指令数据假设,我们想在程序的执行过程中,产生新的代码,然后跳转到新代码中执行。常见的示例有在线更新程序。必须确保正确的Cache行为。如果不注意,这个过程中,可能会在两个阶段带来非预期的结果:首先,如果你使用的是回写式D-Cache,你写的指令数据在没有触发相关Cache行的回写操作之前,一直停留在Cache中,并没有写入到内存中。如果,此时CPU尝试执行这些新的代码指令,因为仍然在D-Cache中,CPU无法访问到它们。所以,当CPU写完新指令数据后,首先要做的就是执行回写操作,保证数据写入到内存中。其次,不管你使用的是哪种类型的D-Cache,在你把指令数据写入到内存中后,你的I-Cache里仍然持有着这些地址之前的数据。所以,在CPU执行新写的代码指令之前,软件首先应该失效I-Cache中的相关行。当然了,你也可以使用非Cache区域保存新的代码指令,然后执行它们。但是,这毕竟放弃了Cache的加速效果不是。我们在《MIPS高速缓存机制》一文中描述的Cache管理指令都是协处理器CP0指令,只有特权级的代码才能使用。一般情况下,DMA操作也是内核完成的,这些都没有异议存在。但是,当用户态的应用程序也想要这样写指令,然后执行的话(比如,现在的即时性的解释性语言),却无法访问这些指令。所以,MIPS32/64提供了synci指令,它可以执行D-Cache的回写操作和I-Cache的失效操作。具体可以参考MIPS指令集参考。3 Cache管理和非Cache或直写数据如果你混合使用Cache和非Cache程序地址访问同一段物理内存空间,你需要清楚这意味什么。使用非Cache程序地址往物理内存中写入数据,可能会造成D-Cache或I-Cache中保留一份过时的拷贝(相同地址)。使用非Cache程序地址直接从内存中加载数据,可能是旧数据,而最新的数据还停留在Cache中。上电复位后,在引导系统进入一个已知状态的底层代码中,使用Cache和非Cache程序地址引用同一段物理地址空间是非常有用,甚至是有非常有必要的。但是,对于运行中的代码,一般不要这样做。而且,不管是使用Cache程序地址,还是使用非Cache程序地址访问物理内存,一定要保证它的一致性。4 Cache重影和页着色我们在《MIPS高速缓存机制》一文中已经描述了Cache重影的根源。L1级Cache使用虚拟地址作为索引,而使用物理地址作为Tag标签,如果索引的范围大于、等于2个page页,就可能发生Cache重影。索引范围等于一组Cache的大小,所以,使用4KB大小的page页的话,在8KB大小的直接映射Cache或着32KB大小的4路组相关的Cache上就可能会发生Cache重影。发生Cache重影会有什么后果呢?在进程上下文切换的时候,必须首先清空Cache,要不然,上个进程映射的物理地址,可能与新进程映射的物理地址相同,导致同一物理地址在Cache上有2份拷贝,可能会导致意想不到的后果。再比如,使用共享内存的时候,多个进程的虚拟地址都可能引用这个数据,如果发生Cache重影,那么也会导致共享内存中的数据不正确。为此,聪明的软件工程师们想了一个巧妙的技巧:页着色技术,又称为Cache着色,其实都是一回事,叫法不一样而已。具体的做法就是,假定page页的大小是4K,然后给每一个page页分配一个颜色(此处的颜色就是一种区分叫法而已,没有任何实际动作),使用虚拟地址的某几个比特位来标记颜色。当然,也可以选择使用物理地址中的某些比特位标记颜色。相同颜色的虚拟地址对应一组Cache。所以,两个虚拟地址想要指向同一个物理地址的数据,必须具有不同的页颜色。也就是说,页着色技术要求页分配程序把任一给定的物理地址映射到具有相同颜色的虚拟地址上。颜色数是否与Cache的way数相等?应该是相等的。比如说,Linux操作系统,多个虚拟地址可能都会访问一个物理页(共享库)。大部分时候,操作系统OS对于共享数据的虚拟地址的对齐肯定满足要求-共享进程也可以不使用相同的地址,但是,我们必须保证不同的虚拟地址必须是64K的倍数,所以不同的虚拟地址具有相同的颜色。也就避免了Cache重影。这可能消耗更多的虚拟内存,但是虚拟内存又不值钱,对吧?😀想象一下,加入数据都是只读的,Cache重影还会有影响吗?当然是没有什么问题了。但是,必须保证你的程序知道,在失效某个数据的时候,Cache的其它地方还有一份拷贝。随着带有虚拟内存管理的操作系统OS在嵌入式和消费者电子产品市场的广泛应用,越来越多的MIPS架构CPU,在硬件层面就消除了Cache重影。相信随着时间的推移,这个问题也许就不存在了吧。5 总结本文我们主要是从MIPS32/64架构规范展开讨论的,在实际的芯片设计开发过程中,越来越多的公司,考虑硬件层面消除Cache的副作用。毕竟,要软件人员时刻保持Cache的一致性太难了。这也是MIPS架构硬件从简,软件辅助的设计思路带来的弊端;也是与X86和ARM架构的竞争中败下来的原因。所以,对于Cache,我们可以不必过多忧虑,针对具体的芯片具体分析就可以了。但是,对于上面的知识点,如果掌握了的话,不管是在开发驱动程序,还是开发操作系统,亦或是移植别的软件工程到MIPS架构上,都是有百利无一害的。
1 MIPS架构移植软件时常见的问题2 什么是字节序:WORD、BYTE和BIT2.1 位、字节、字和整形3 软件和字节序3.1 可移植性和字节序4 硬件和字节序4.1 建立连接字节序不一致的总线4.2 建立字节序可配置的连接4.3 对字节序问题的一些错误认知5 在MIPS架构上编写支持任意字节序的软件6 可移植性和大小端无关代码站在巨人的肩膀上,才能看得更远。If I have seen further, it is by standing on the shoulders of giants.牛顿科学巨匠尚且如此,何况芸芸众生呢。我们不可能每个软件都从头开始搞起。大部分时候,我们都是利用已有的软件,不管是应用软件,还是操作系统。所以,对于MIPS架构来说,完全可以把在其它架构上运行的软件拿来为其所用。但是,这是一个说简单也简单,说复杂也复杂的工作。为什么这么说呢?如果你要采用的软件,其可移植性比较好的话,可能只需要使用支持MIPS架构的编译器重新编译一遍就可以了;如果程序只是为特定的硬件平台编写的话(大部分嵌入式软件都是如此),可能处处是坑。而像Linux系统,在编写应用或者系统软件的时候,一般都会考虑可移植性。所以说,基于Linux的软件一般都可以直接编译使用。但是,像现在流行的一些实时操作系统,比如、μC/OS、Free-RTOS、RT-Thread或其它一些基于微内核的系统,它们的程序一般不通用,需要修改才能在其它平台上运行。而且,越往底层越难移植,几乎所有嵌入式系统上的驱动程序都不能直接使用。而且,嵌入式系统软件通常好几年才会发生一次重大设计更新,所以,如果坚持考虑软硬件上的接口兼容并不合理,尤其是考虑到成本效益的时候。本文就是总结一些在移植代码或者编写代码时,应该需要特别关注的一些点。虽说本文主要以MIPS架构为主线进行讲解,但是其中的一些思想和方法,对其它架构同样适用。我们应该学会举一反三,灵活运用。1 MIPS架构移植软件时常见的问题以下是一些比较常见的问题:大小端计算机的世界分为大端(big-endian)和小端(little-endian)两个阵营。为了二者兼容,MIPS架构一般都可以配置到底使用大端还是小端模式。所以,我们应该彻底理解这个问题,不要在这个问题上栽跟头。内存布局和对齐大部分时候,我们可以假定C声明的数据结构在内存中的布局是不可移植的。比如,使用C的结构体表示从输入文件或者网络上接收的数据的时候。还有,对于指针或者union型数据,通过不同方法引用的时候,也会存在风险。但是,内存布局还与一些其它的约定有关(比如寄存器的使用,参数传递和堆栈等)。管理Cache对于嵌入式系统来说,大部分时候采用的都是微处理器,可能并没有实现硬件Cache。但是,随着半导体技术的发展,现在的高端工业处理器一般都带有Cache,只是对于系统软件来说是不可见而已(比如,大部分处理器把Cahce可能带有的副作用都由硬件进行处理,软件不需要管理)。但是,大部分MIPS架构的CPU为了保持硬件的简单,而将一些Cache的副作用暴漏给软件,需要软件进行处理。关于这部分内容,我们后面会进行阐述。内存访问序在大部分的嵌入式或者消费电子产品中,一般都挂载了许多子系统,这些子系统一般通过一条总线,比如PCIe总线、AHB总线、APB总线等进行通信。虽然方便了我们对系统进行扩展,但是也带来了不可预知的问题。比如,CPU和I/O设备之间的信息需要缓存处理,招致不可见的延时;或者它们被拆分成几个数据流,扔到总线上,但是对于到达目的地的顺序却没有保障。关于这部分内容,我们后面会进行阐述。编程语言对于语言,当然大部分时候使用C语言了。但是,对于MIPS架构来说,有些事情可能使用汇编语言编写更好。讲解这部分内容的时候,主要涉及inline汇编、内存映射I/O寄存器和MIPS架构可能出现的各种缺陷。2 什么是字节序:WORD、BYTE和BITWORD最早是由Danny Cohen在1980年引入计算机科学的。在他的文章中,以其独有的幽默和智慧指出,通信系统分为两大阵营,分别是字节寻址访问和整数寻址访问。在乔纳森·斯威夫特(Jonathan Swift)的《格列佛游记》(Gulliver’s Travels)中,little-endians派和big-endians派就如何吃一个煮熟的鸡蛋展开了一场战争。斯威夫特讽刺的是18世纪的宗教争端问题,双方都不知道他们的分歧是完全武断的。科恩的笑话很受欢迎,这个词也就流传了下来。这个问题不仅仅体现在通信上,对于代码的可移植性也有影响。计算机程序总是在处理不同类型的数据序列:迭代字符串中的字符,数组中的WORD类型元素,以及二进制表示的BIT位。C程序员普遍认为,所有这些变量以字节为单位在内存中顺序排列的-比如,memcpy()函数能够复制任何数据,不论什么数据类型。而且,使用C语言编写的I/O系统也将I/O操作以字节进行建模,你才能够使用read()和write()之类的函数读写包含任何数据类型的内存块。这样,一个计算机写数据,另一个计算机读数据。那么,我们不禁想,第二台计算机是如何理解第一台计算所写的数据的呢?另外,我们不止一次地被提醒,要小心数据填充和对齐。因为这对于数据搬运会产生很大的影响。比如说,因为填充的原因,想要完整准确地传递float型数据就变得很难,所以,浮点数据存在精度问题。但是,我们期望至少能够正确表述整形数据,而”字节序”就是个拦路虎。比如说,一个32位整型数,用16进制进行表示为0x12345678,而读进来却为0x78563412,发生了字节交换。想要理解为什么,我们需要追溯一下字节序的发展历史。2.1 位、字节、字和整形我们知道一个32位的int型数据,是由32个比特位组成的,它们每一位都有自己的意义,就像我们熟悉的10进制那样,每一位分别表示个、十、百、千、…以此类推,对于二进制,bit0代表1,bit1代表2,bit3代表4,bit4代表8,…。对于一个可以按字节访问的内存来说,32位整数占据4个字节。如何从比特位的视角表述整形数,有两种选择:一派,将低有效位(LS)放在前,也就是存储在内存的低地址里;而另一派,将高有效位(MS)放在前。科恩将其分别称为小端和大端。最早的时候,DEC的微型计算机是小端,而IBM的大型机是大端。彼时,两个阵营互不妥协。有一个细节需要特殊提一下,大小端字节序的问题只有能够按字节访问的时候才会有。1960年代之前的电脑都是按照WORD大小进行组织:包括指令,整型数和内存宽度都是WORD大小。所以,不存在字节序的大小端问题。我们在读写10进制数据的时候,习惯于从左到右,高有效位在左,低有效位在右。BYTE最早引入计算机,是为了方便将CHAR型字符打包成WORD,然后进行数据的交互。1970年代,一位IBM的老工程师花费了大量的时间,研究大量的内存dump列表,每个WORD大小的数据代表一组字符。这样看起来,使用小端字节序没有必要。大端字节序更有利于使用和阅读。但是,将数字的高有效位写在左端,字节顺序也是自左向右增加,这样和从右到左对bit位进行编号的行为不一致。于是,IBM将一个高有效位标记为bit0。看起来如下图所示:但是,根据数据的算术意义对bit位进行编号更自然,也就是说,标记为N的bit位,其算术意义就是2^N。这样,就可以把bit0-7存储在字节0中。显然,这种方式就变成了小端模式。显然,这种方式不利于阅读,但是对于习惯于将内存看成是一个字节型的大数组的人来说,就会非常有意义。通过上面的讨论,可以看出,两幅图中,内容都是相同的,只是最高有效位(MS)和最低有效位(LS)进行了互换,当然,bit位的顺序也发生了互换。IBM主导的大端模式,看到的是被分割成字节的WORD;而Intel主导的小端模式看到的是构建WORD的字节序列。毋庸置疑的是,对于不同的人群,它们都非常有用。它们都有自己的优点,就看你怎么选择了。让我们回到上面的问题。假设一个16进制类型的数据0x12345678,二进制形式为00010010 00110100 01010110 01111000。如果传送给一个具有相反字节序的系统,你肯定期望看到所有的位是相反的:00011110 01101010 00101100 01001000,16进制为0x1E6A2C48。但是,为什么我们上边却说是0x78563412。的确,在某些情况下完全可以实现上面的位反转:有些通讯链路先发送最高有效位,另一些则先发送最低有效位。但是,在上世纪70年代,更多地使用8位的字节作为计算机内部和计算机通信系统的基本单元。通常,通信系统使用字节构建消息流,由硬件决定哪一位首先被发送出去。与此同时,每个微控制器系统都使用8位宽的外设控制器(更宽的控制器是为高端设备预留的),这些外设一般都使用8位端口,bit0-bit7,最高有效位是bit7。对此,没有任何争议,每个字节都采用小端字节序。从那以后,一直保持到现在。而早期的微处理器系统,都是8位CPU,使用8位总线和一个8位的内存进行通信,所以,根本不存在字节序问题。Intel的8086是一个16位的小端系统。当摩托罗拉在1978年左右推出68000微处理器时,他们推崇IBM的大型机架构。不管是处于对IBM的敬仰,还是为了区别于Intel,他们选择了大端模式。但是,它们无法违反8位外设控制器的习惯,于是,每一个8位的摩托罗拉的外设通过交错的数据总线与68000进行连接。这就是,我们为什么说收到0x78563412数据的原因。于是,68000家族系列使用如下图所示的字节序:68000及其后继产品被大多数成功的UNIX服务器和工作站所使用(尤其是SUN公司)。所以,当MIPS架构和其它RISC指令集架构的CPU在1980年代出现时,他们的设计者为了兼容大小端字节序,都设置了配置选项,可以自由选择使用大小端模式。但是,从68000开始,大端模式就指68000风格的大端字节序,其bit位和字节序相反。当你配置MIPS架构CPU为大端模式时,就如上图所示。选择不同的大小端模式,可能会影响你阅读CPU和寄存器手册。尤其是对于位操作指令,向左移动和向右移动的区别,位操作指令的参数位置等。通过上面的讨论,我们知道,大小端字节序对于软硬件的影响分为2类:软件的话,比如移植软件和数据通信;硬件的话,如不兼容子系统和总线之间的连接问题。对此,我们分别进行阐述。3 软件和字节序对于软件来说,字节序的定义如下:如果CPU或编译器中,一个整型数的最低寻址字节存储的是最低8位,那么就是小端模式;如果最低寻址字节存储的是最高8位,那么就是大端模式。可以通过下面的代码,验证你的CPU是大端还是小端模式。#include <stdio.h> main () { union { int as_int; short as_short[2]; char as_char[4]; } either; either.as_int = 0x12345678; if (sizeof(int) == 4 && either.as_char[0] == 0x78) { printf ("Little endian\n"); } else if (sizeof(int) == 4 && either.as_char[0] == 0x12) { printf ("Big endian\n"); } else { printf ("Confused\n"); } }严格说来,软件字节序是编译器工具链的一个属性。只要你愿意,可以产生任何字节序的代码。但是对于像MIPS架构这样的可字节寻址的CPU,内部使用32位算术运算,这会导致硬件效率降低;因此,我们接下来,主要谈论的是CPU的字节序。当然了,内存地址空间中字节布局的问题也同样适用于其它数据类型。比如浮点数据类型,文本字符串,甚至是机器指令的32位操作码。对于这些非整型数据类型来说,算术意义根本没有存在的价值。当软件要处理的数据类型大于硬件能够管理的数据类型时,字节序问题完全就成为软件的一种约定了,可以是任何字节序。当然了,最好还是与硬件本身的约定保持一致。3.1 可移植性和字节序只要应用程序不从外界获取数据,或避免使用不同的整型数据类型访问同一个数据块(如上面我们故意那样做的那样),CPU的字节序对你的应用程序就是不可见的。也就是说,你的代码是可移植的。但是,应用程序不可能接受这些限制。你可能必须处理外部发送过来的数据,或者需要把硬件寄存器映射到内存上,便于访问。不管哪种应用,你都需要准确知道编译器如何访问内存。这好像没有什么,但是经验告诉我们,字节序是最容易混淆的,因为很难描述这个问题。大小端两种方案起源于勾画和描述数据的不同方式,它们在各自的视角都没有什么问题。如上所述,大端模式通常围绕WORD来组织其数据结构。如下图1所示。虽然按照IBM约定,将最高有效位(MS)标记为位0更为美观,但是,现在已经不在那样做了。而小端模式更主要从软件方面抽象数据结构,将计算机的内存视为一个字节类型的数组。如上图2所示。小端模式没有将数据看作是数值型的,所以倾向于把低有效位存放在左边。所以,软件大小端的字节序问题,归根结底就是一个习惯的问题:究竟习惯于从左到右,还是从右到左对bit位进行编号。每个人的习惯不同,这也是字节序问题容易混淆的根源。4 硬件和字节序前面我们已经看见,CPU内部的字节序问题,只有在能够同时提供WORD字长的数据和按字节访问的内存系统中才会出现。同样,当系统与具有多字节宽度的总线进行连接时,也会存在字节序问题。当通过总线传输多个字节数据时,数据中的每个字节都有自己的存储地址。如果总线上传输数据的低地址字节,被编为低编号,那么这条总线就是小端模式;反之,如果使用高编号对数据的低地址字节进行编号,那么就是大端模式总线。可字节寻址的CPU,在它们传送数据的时候会声明是大端还是小端字节序。英特尔和DEC的CPU是小端模式;摩托罗拉680x0和IBM的CPU是大端模式。MIPS架构CPU可以支持大小端两种模式,需要上电时进行配置。许多其它RISC指令集架构的CPU也都遵循MIPS架构的思路,选择大小端可配置的方式:这在使用一个新的CPU替换已经存在的系统时是个优点,如果旧系统遵循小端模式,新的CPU也配置为小端模式;反之亦然。假设硬件工程师按照比特位的顺序把系统串联在一起,这本身没有什么错。但是,如果你的系统包含总线、CPU和外设,而它们的字节序不匹配时,会很麻烦。只能哪种方式更简单一些,使用哪一种。位顺序一致/字节序被打乱很显然,设计者可以按照位顺序的方式,把两条总线接到一起。这样,每个WORD的位顺序没有变化,但是位编号和字节是不同的,那么,两边内存中的字节序列也是不同的。任何小于总线宽度或没有按照总线宽度进行排列的数据,在总线上传输时,都会被破坏顺序,并按照总线宽度发生字节交换。这看上去要比软件问题严重。软件产生错误字节序的数据,根据数据类型仍能找到,因为没有破坏数据类型的边界;这是这个数据已经没有意义。但是,硬件却会打乱数据类型的边界(除非,数据恰好以总线宽度对其)。这儿有一个问题。如果通过总线接口进行传输的数据,总是按照WORD大小对齐,然后按照比特位的编号进行接线,那么就会隐藏大小端字节序的问题。也就避免了软件再对其进行转换。但是,硬件工程师很难知道,设计的系统上的接口以后会传输什么数据。所以,应该小心应对这个问题。字节地址一致/整数被打乱设计者可以按字节地址进行连线,也就是保证两端的相同字节存储在相同的地址。这样,字节通道内的比特位的顺序必然不一致。至少,整个系统可以把数据看作字节数组,只是数组元素的比特位是相反的。对于大多数情况下,字节地址乱序副作用更明显。所以,我们推荐使用“字节地址一致”方法进行连线。因为在处理、传输数据时,程序员更希望将内存看作为字节数组。其它数据类型一般也是据此构建的。不幸的是,有时候使用位编号一致,好像在原理图上更为自然。想要说服硬件工程师修改他们的原理图是一件很难的事情哦。这个大家都懂的😀!!!并不是每个系统内的连接都很重要。假设,我们有一个32位地址范围内的内存系统,直接与CPU相连。CPU的接口可以不包含字节寻址,只需将地址总线上的低2位置0即可。与此相反的是,大部分CPU使能字节存取。在内部,CPU将每个字节通道和内存中的字节地址相关联。我们可以想象得到,无论连线如何,内存系统都不受影响。内存按照任何一种连线被写入,只要再按照同样的方式读取出来就可以。虽然,大部分情况下,内存一般都继承了CPU的大小端模式。但是,这个连接无关于字节序1。但是,上面的例子是个陷阱,千万不要以为简单的CPU/RAM架构不存在大小端的字节序问题。下面我们列举在构建内存系统时不能忽略CPU字节序问题的情况:如果你的系统使用的是预先烧录到ROM内存中的固件时,硬件地址总线和字节数据通道与系统的连接方式必须与ROM编程时假设的方式是一致的。通俗的讲,现在是ROM,程序数据是预先写入到ROM中的,也就是大小端方式固定了,那么它与系统总线的连接必须是一致的大小端方式。尤其是对于指令来说,这很重要,因为它决定了取出的指令中操作码的字节序。使用DMA直接传输数据到内存中时,字节序就很重要。当CPU没有使能字节地址寻址,而使用一个字节大小的码表示该字节在WORD地址中的位置时(这在MIPS架构CPU中很常见),那么硬件必须能够正确解析CPU想要读写的是哪个字节,也就是必须知道CPU正在使用的大小端模式。这个需要仔细阅读芯片手册。下面我们将分析硬件工程师如何构建一个字节地址一致的系统。4.1 建立连接字节序不一致的总线假设我们有一个64位的CPU,配置为大端模式,将其与一个小端模式的32位PCI总线相连。下图展示了如何连线,以获得CPU和PCI两端看上去都一致的字节地址。因为CPU是64位,而PCI总线是32位,所以,根据32位WORD宽的地址中的bit2,将64位总线分成两组,与32位PCI总线进行连接。比特位1和比特位0是每个WORD中的其中一个字节地址。CPU的64位总线是大端模式,高编号的位携带的是低地址,这个从字节通道的编号能够看出来。4.2 建立字节序可配置的连接上面的方法毕竟是固定的,一旦完成硬件设计就无法改动了。如果我们想要实现一个类似于总线开关设备,用它进行切换,让CPU既可以工作在大端模式,也可以工作在小端模式,如下图所示。在这儿,我们称这个总线开关设备为字节通道交换器,而不是字节交换器。主要是想强调这个设备不管是开,还是关,都不会影响传输数据的大小。有了这个设备,我们就可以根据需要,认为关闭或者打开它,存取字节一致或者不一致的数据。字节通道交换器所做的就是无论你的CPU设置为大端模式还是小端模式,CPU和不匹配的外设总线或设备之间,数据总能按照想要的序列进行交换。正常情况下,在CPU和内存之间不需要添加字节通道交换器。因为它们之间的连接本身就是快速且是并联的,添加字节通道交换器的代价比较昂贵。综上所述,我们将CPU和内存视为一个整体。然后,在CPU和系统其它部分之间增加一个字节通道交换器。这样,无论CPU配置成什么工作模式,字节序就不再是一个问题了。4.3 对字节序问题的一些错误认知每当遇到大小端的字节序问题时,我们的第一反应往往是:这个问题可能是一个硬件缺陷。然而,事情往往没有那么简单。比如下面的2个例子,有时候必须需要编程人员的干预。可配置的I/O控制器:一些新的I/O设备和系统控制器本身就可以自由配置成大端或者小端模式。想要使用这些特性之前,必须仔细阅读芯片手册。尤其是,可以使用跳线帽进行选择时,而不是固定在某种工作模式下时。这些特性一般在大块数据传输时使用,其余的字节序问题,比如访问位编码的设备寄存器或者共享内存的控制位等问题,留给编程人员进行单独处理。可以根据传输类型进行字节交换的硬件:如果你正在尝试设计一些字节交换硬件,意图解决整个问题。可以肯定的告诉你,这条路行不通。软件问题没有任何一个可以一劳永逸的硬件解决方案。比如,一个实际系统中的许多传输都是以数据高速缓存作为单位的。他们可能包含不同大小和对其格式的任意数据组合。可能无法知道数据的边界在哪里,也就意味着没有办法确定所需的字节交换配置。有条件的字节交换除了增加混乱之外,没有什么多大用处。除了无条件的字节通道交换器之外,任何做法都是用来骗人的东西。5 在MIPS架构上编写支持任意字节序的软件你可能会想,我是否可以写一个正确运行在MIPS CPU上的程序,不论它被配置为大端模式,还是小端模式。或者编写一个可以运行在任意配置的板子上的驱动程序。很遗憾,这是一个很棘手的问题。最多也就是在引导程序中的某一小部分里可以这样写。下面是一些指导原则。MIPS架构指令集中能够实现字节加载的指令如下所示:lbu t0, 1(zero)上面这条语句的作用是:取字节地址1处的字节,加载到寄存器t0的最低有效位上(0-7),其余部分填充0。这条指令本身描述是与字节序无关的。但是,大端模式下,数据将从CPU数据总线的位16-23进行读取;小端模式下,将从CPU数据总线的位8-15位进行加载。MIPS CPU内部,有个硬件单元负责把有效的字节从它们各自的字节通道中,加载到内存寄存器的正确位置上。这个负责操纵数据加载的硬件逻辑能够适应所有的加载大小、地址和对齐方式的组合(包括load/store和左右移位指令等)。正是这个特性使得MIPS CPU能够配置大小端工作模式。当你重新配置MIPS CPU的字节序时,正是改变了这个操纵数据加载的硬件逻辑单元的行为。为了配合CPU大小端的可配置性,大部分的MIPS工具链都能够在编译flag中添加一个选项,编译产生任何字节序的代码。如果你设置了MIPS架构的CPU与系统不匹配的字节序,将会发生一些预料不到的事情。比如,软件可能会迅速崩溃,因为对于字节的读取可能会获取垃圾数据。在重新配置CPU的同时,最好重新配置解码CPU的时钟逻辑[^5]。我们这儿选择位编号一致的方法,而不是字节地址一致的方法。之所以选择位编号一致的方法是因为,MIPS的指令都是按位进行编码的(32位指令集宽度)。这样的话,存放代码指令的ROM,不管是大端模式的CPU,还是小端模式的CPU都有意义。从而,可以共享这段引导程序。这不是完美无缺的,如果ROM包含非32位对齐的任何数据都将会被打乱。许多年前,Algorithmics公司的MIPS主板的ROM中,就使用了这种适应大小端模式的代码检测,主ROM程序是否与CPU的大小端模式匹配,如果不匹配,就会打印下面的帮助信息:Emergency - wrong endianness configured.单词Emergency被存放在一个C字符串中。现在,我们已经能够理解为什么ROM程序的开头,往往会有下面这么几行奇幻的代码了。.align 4 .ascii "remEcneg\000\000\000y"上面定义了一个文本字符串Emergency,包含标准C的终止符null和2个字节的填充。下图以大端模式为视角,展示了这个单词在内存中的布局。如果使用了小端模式,就会打印上面的帮助信息。通过上面的示例,我们可以看出编写适应大小端模式的代码是可能的。但是,要注意当把代码加载到ROM中时,加载工具应该区分大端模式和小端模式,确保能够把数据写入到正确的位置上。6 可移植性和大小端无关代码如果确实需要编写支持大小端模式的代码,用于方便移植(笔者在移植函数库libmath的时候,就看到这样的代码)。可以按照下面的代码模板进行编写。通常,大部分的MIPS工具链定义BYTE_ORDER作为字节序选择的宏选择开关。#if BYTE_ORDER == BIG_ENDIAN /* 大端模式版本代码... */ #else /* 小端模式版本代码... */ #endif如果确实需要,你可以选择使用上面的模板编写不同的分支,分别处理大端模式和小端模式的代码。但是,还是尽量编写与字节序无关的代码,CPU处于哪种模式下,就编写哪种模式下的代码。所有从外部数据源或设备接收数据的引用都有潜在的字节序问题。但是,根据系统的布线方式,你能够生成双向工作的代码。在不同的字节序之间接线只有两种方式:一种保持字节地址不变,另一种保持位编号不变。在系统特定范围内访问具体的外设寄存器,字节序可以保持与二者之一保持一致。如果你的外设通常被映射为字节地址兼容,那么你应该按照字节操作进行编程。如果为了效率或者处于不得已,想要一次传输多个字节,你需要编写根据字节序进行打包和解包的代码。如果你的外设与32位WORD兼容,通常按照总线宽度进行读写操作。那就是32位或64位的读写操作。
本章旨在帮助读者阅读MIPS汇编代码。本文中专注于32位MIPS指令集。本文主要的目标读者是习惯于C语言编程,但是,有时候不得不读懂一些汇编代码甚至做一些小范围的改动的开发者,比如操作系统移植时启动代码start.S文件的阅读与修改。如果想要深入研究汇编程序如何编写,请参考所使用的MIPS工具链的说明文档。阅读MIPS汇编代码,不仅仅需要熟悉各个机器指令,因为它还包括许多MACRO,这些宏由GNU工具链识别,将其展开成真正的机器指令。这些宏的存在是为了更方便地编写汇编程序。另外,MIPS汇编器还提供了许多伪指令或伪操作,用来管理代码布局、控制指令序列以及实施优化等。通常,编程人员会将汇编代码以更具有可读性的源文件(后缀.S)传递给预处理器,由预处理器进行宏展开、别名替换等等操作,然后形成真正的预处理后的汇编文件(后缀.s表示)。9.1 简单示例下面是C函数库中的strcmp()函数实现。通过这个示例,我们将展示一些基本的汇编语法和手动优化代码的内容。int strcmp (char *a0, char *a1) { char t0, t1; while (1) { t0 = a0[0]; a0 += 1; t1 = a1[0]; a1 += 1; if (t0 == 0) break; if (t0 != t1) break; } return (t0 - t1); }在这个初始版的strcmp实现函数中,每次迭代过程需要执行2个if语句和2个读取内存操作(访问数组)。这每一个操作会产生一个延时点,比如说读取内存时,其它指令无法读取内存,但是可以在CPU上执行。而在这个while循环中,却没有足够的不需要分支预测和存取内存的操作填充这个时间段的CPU执行。所以,这其实没有最大化CPU的执行效率。而且上面的代码,每次迭代过程只能比较一次字符串。下面,我们对上面的代码进行简单的改进。最大的变化就是,单次迭代可以执行2次比较,而且还把其中的一次存取操作放到循环的最后:int strcmp (char *a0, char *a1) { char t0, t1, t2; /* 第1次迭代的读取a0操作放在循环之外 */ t0 = a0[0]; while (1) { /* 第1个字节 */ t1 = a1[0]; if (t0 == 0) break; a0 += 2; if (t0 != t1) break; /* 第2个字节 */ t2 = a0[-1]; /* 此处的a0已被增加 */ t1 = a1[1]; /* 不再增加a1 */ if (t2 == 0) /* 汇编代码中的标签t21处 */ return t2-t1; a1 += 2; if (t1 != t2) /* 汇编代码中的标签t21处 */ return t2-t1; t0 = a0[0]; } /* 汇编代码中的标签t01处 */ return t0-t1; } 将上面的代码展开为MIPS汇编代码: #include <mips/asm.h> #include <mips/regdef.h> LEAF(strcmp) .set noreorder lbu t0, 0(a0) 1: lbu t1, 0(a1) beq t0, zero,.t01 # 读取造成延时 addu a0, a0, 2 # 分支造成延时 bne t0, t1, .t01 lbu t2, -1(a0) # 分支造成延时 lbu t1, 1(a1) # 读取造成延时 beq t2, zero,.t21 addu a1, a1, 2 # 分支造成延时 beq t2, t1, 1b lbu t0, 0(a0) # 分支造成延时 .t21: j ra subu v0, t2, t1 # 分支造成延时 .t01: j ra subu v0, t0, t1 # 分支造成延时 .set reorder END(strcmp)我们先来分析上面汇编代码的每一部分的作用:#include:它的作用和其它高级语言的#include是一样的,可以将包含的文件在本文件中展开,进行文本替换。mips/asm.h 文件定义了宏LEAF和END;mips/regdef.h 文件定义了MIPS架构32个通用寄存器$0-$31的别称,比如上面的t0和a0等。宏LEAF:定义如下:#define LEAF(name) \ .text; \ .globl name; \ .ent name; \ name:宏LEAF与下面的END一起使用,定义相当于叶子函数的子程序,供其它汇编程序调用。我们知道非叶子函数需要做更多工作,比如保存变量,保存返回地址等等。除非是有特殊目的,一般不用在汇编程序中实现这样的函数,用C语言写更好。LEAF包含的内容:.text将后面的代码添加到目标文件用.txt标记的文本段。.globl将name标记为global全局符号。供整个程序调用。.ent没有实际作用,仅仅是告诉编译器从此处开始。name标签,汇编子程序真正开始的地方。宏END:定义如下:#define END(name) \ .size name,.-name; \ .end name.size出现在符号表中的大小。.end标记结束.set伪指令:设置汇编器的工作方式。默认情况下,汇编器尝试填充分支指令和存取指令造成的空闲时间,通过重新排列指令。也就是说,大部分时候都不需要关心汇编代码中的指令执行顺序所带来的性能问题。.set mips0,使用原本的指令集;.set mips3,使用MIPS IV中的指令(64位兼容32位);.set mips32,使用32位指令集;.set mips64,使用64位指令集;.set noreorder和.set reorder:告知汇编器是否重新对指令进行顺序进行排序。.set push和.set pop:分别是保存所有设置、弹出所有设置。.set at和.set noat:是否允许汇编程序中使用at寄存器。.set mipsn:n,是一个从0到5的数字,或是数字32或64。指定使用的指令集。标签1:同其它汇编语言一样,就是一个程序调转的地址别名。1f:后面的f表示forward,代表向前跳转;1b:后面的b表示back,代表向后跳转。指令:真正的可执行指令。因为使用了伪指令.set noreorder,所以分支、存取指令导致的延时就会被加入进来。9.2 语法概述上面我们对汇编代码已经有了一些感官上的认识,下面我们就系统地学习一下汇编的语法。9.2.1 代码风格,分隔符和标识符我们都比较熟悉C代码,基本规则差不多,只需要注意一些特别的地方即可。必须有行结束符,每行可以有多个指令语句,只要使用分隔符;分割即可。行尾#注释。如果使用C预处理器,也可以使用/*注释内容*/这种C风格的注释。标签和变量的标识符可以是C语言中任何合法的字符,甚至可以包含$和.。常用1-99的数字标记LABEL。强烈建议使用MIPS惯用寄存器命名。但你需要包含头文件mips/regdef.h;如果选择直接使用通用目的寄存器名称,则使用$3这之类的命名方式。通用寄存器的编号从0-31。常量和字符串可以按照C风格使用。9.3 指令的通用规则MIPS汇编器对一些常用的实现作了处理,形成了自己的伪指令。通俗地讲,就是程序开发者以更友好的方式写代码,汇编器将其拆解成多条具体的机器指令。9.3.1 算术、逻辑指令MIPS架构算术、逻辑指令是三目操作运算指令,也就是说,它们具有2个输入和一个输出。例如:表达式d = s + t写成汇编形式为addu d,s,t。但是复杂指令集的风格一般是2个操作数。为此,汇编器将目标寄存器d作为源寄存器s使用。如果编写代码时,省略s,写成addu d,t,汇编器生成最终的机器码时,会将其展开为addu d,d,t。像neg、not等单寄存器指令都是一个或者多个三寄存器指令合成的。汇编器希望这些指令最多使用2个寄存器,所以,negu d,s => subu d,zero,s not d => nor d,zero,d最常用的寄存器到寄存器操作是move d,s。汇编器将这种最常见的操作转换为or d,zero,s。9.3.2 立即数运算在汇编或者机器指令中,编入指令中的常数称为立即数。许多算术和逻辑运算使用16位立即数替换t。但是,有时候16位立即数不能满足我们的要求,我们需要对其进行扩展,扩展方式有两种:符号扩展或零扩展到32位。怎样选择取决于操作,通常,算术操作使用符号扩展,逻辑操作使用零扩展。虽然使用立即数的操作和不使用立即数的操作指令不相同,比如addu和addiu(立即数)。但是,编程人员无需关注这些细节,汇编器会自动检测是否为立即数,从而选择正确的机器指令:addu $2, $4, 64 => addiu $2, $4, 64但是,如果立即数太大,16位立即数无法满足,则需要汇编器额外帮助。它自动把常数加载到临时寄存器at/$1中,然后使用它执行操作:addu $4, 0x12345 => li at, 0x12345 addu $4, $4, atli指令,载入立即数专用伪指令,所以它不是实际的机器指令。当32位的立即数值位于±32K之内时,汇编器使用单条指令addiu加上$0寄存器实现li指令语句;如果立即数的16-31位都为0,则使用ori指令实现li指令;如果立即数的0-15位都为0,则可以直接使用lui指令将数据搬运到寄存器中。其它情况,根据需要选择使用lui还是ori指令来实现。li $3, -5 => addiu $3, $0, -5 li $4, 0x8000 => ori $4, $0, 0x8000 li $5, 0x120000 => lui $5, 0x12 li $6, 0x12345 => lui $6, 0x1 ori $6, $6, 0x2345但是,将伪汇编指令展开为多条指令时,如果此时使用了.set noreorder伪指令,则会非常麻烦。如果在一个分支延迟槽中使用多指令宏,汇编器会发出警告。9.3.3 64/32位指令我们在前面看到,MIPS体系结构扩展到64位(第2.7.3节)时非常注意确保MIPS32程序的行为保持不变,即使它们在MIPS64机器上运行;在MIPS64机器中,MIPS32指令的执行总是将任何GP寄存器的32位上半部分设置为所有1或所有0(反映第31位的值)。MIPS64架构完全兼容MIPS32架构,执行MIPS32指令时,总是使用通用寄存器的高32位,也就是偶数号寄存器。许多32位指令可以直接在64位系统上被使用,比如按位逻辑操作,但是,算术操作却不能。加减乘除、移位都需要实现新的指令。新指令就是在旧指令的基础上前缀d(表示double)来实现的。比如,旧加法指令addu,新指令就是daddu。9.4 寻址模式MIPS架构的寻址模式非常简单,就是寄存器+偏移量的方式,偏移量的范围是−32768~32767(也就是16位的立即数)。如果编程人员想要使用其它寻址方式,汇编器将会使用寄存器+偏移量的方式进行组合实现。这些其它寻址方式如下所示:直接寻址:也就是访问某个标签,其指向某个数据或者变量。直接寻址+索引:使用寄存器指定偏移量。常量寻址:直接作为32位地址使用。寄存器间接寻址:寄存器+偏移量,但是此处的偏移量等于0。我们直接看下面的示例:#源码实现 => MIPS汇编器实现 lw $2, ($3) => lw $2, 0($3) lw $2, 8+4($3) => lw $2, 12($3) lw $2, addr => lui at, %hi(addr) lw $2, %lo(addr)(at) sw $2, addr($3) => lui at, %hi(addr) addu at, at, $3 sw $2, %lo(addr)(at)符号addr可以是下面任何一种:一个可重定位的符号,比如标签和变量名称;一个可重定位的符号+常量表达式(汇编器或链接器会解析);32位常量表达式(比如设备配置寄存器的绝对地址)。%hi()和%lo()代表地址的高16位和低16位。它并不是直接把一个32位的WORD分成2个16位的半字。因为lw指令把16位的偏移量解释为带符号的立即数。也就是说,如果bit15等于1的话,%lo(addr)的值就会是负值。所以,我们需要增加%hi(addr)进行补偿,如下所示:addr%hi(addr)%lo(addr)0x1234.56780x12340x56780x1000.80000x10010x8000la宏指令实现加载地址,与li宏指令及其类似,只是一个加载地址,一个加载立即数:la $2, 4($3) => addiu $2, $3, 4 la $2, addr => lui at, %hi(addr) addiu $2, at, %lo(addr) la $2, addr($3) => lui at, %hi(addr) addiu $2, at, %lo(addr) addu $2, $2, $3原则上,la指令可以通过使用ori指令避免%lo()为负值的时候。但是load/store指令使用一个带符号位的16位地址偏移量(这样在访问内存的时候更方便),导致linker链接器已经使用了这种修复地址的技术。所以,la指令为了避免linker需要理解两种不同的修复地址的方法,而选择使用add指令实现。9.4.1 gp相对寻址MIPS指令集使用32操作数的结果就是,访问某个地址通常需要两条指令实现:lw $2, addr => lui at, %hi(addr) lw $2, %lo(addr)(at)如果在程序中,大量使用global或static数据,会使编译后的代码非常臃肿,执行效率低下。早期的MIPS编译器引入一种小技巧修复这个问题,称为gp相对寻址(gp->global pointer)。这个技术需要编译器、汇编器、链接器和启动代码的配合才能实现。启动代码start_up.S中把所有较小的变量和常数存入一段内存区域;然后设置寄存器$28(被称为gp指针或gp寄存器)指向这段内存区域的中间位置(链接器会创建一个特殊的符号gp,指向该内存区域的中间位置。启动代码执行load或store指令之前,必须把gp的值加载到gp寄存器中)。但是要求所有的变量所占的空间不超过64KB,也就是上下各32KB。现在,访问某个变量的指令就变成了下面这样:lw $2, addr => lw $2, addr - _gp(at)可以看出,上面的实现,最终只会生成一条机器指令。显然,这可以节省代码量。但是,这里存在的问题是在编译各个模块的时候,编译器和汇编器必须决定哪些变量可以通过gp访问。通常要求所包含的对象小于一定的字节数(默认是8个字节)。这个限制可以通过编译汇编选项-G n进行控制,如果n等于0,则是将这个优化选项关闭。虽然这是一个非常有用的小技巧,但是也有许多小陷阱需要留意。下面是一些避免陷阱的一些措施:可写的、已初始化过的数据项显式地存放到.sdata数据段。全局通用数据必须正确声明大小:.comm smallobj, 4 .comm bigobj, 100对外可见的变量必须使用.extern进行声明:.extern smallext, 4大部分的汇编器都是在使用变量之前处理声明,除此之外,不予理会。程序的运行方式决定了这种方法是否可行。许多实时操作系统使用一段独立的代码实现内核,应用程序通过大范围的子程序调用接口调用内核函数。没有一个有效的方法,可以在内核代码和应用程序代码的gp之间来回切换。这种情况下,应用程序或者OS必须至少一个使用-G 0进行编译。如果使用了-G 0选项编译了某个模块,那么与该模块相关的所有链接库也都得需要使用-G 0选项进行编译。否则,会给出一些稀奇古怪的错误信息。9.5 目标文件和内存布局本段我们主要对MIPS架构常见的内存布局做个简要的介绍,也对内存布局和目标文件之间的关系提出了几个重要的点。了解代码加载到系统内存中的方式对我们很有帮助,尤其是,代码第一次在系统硬件上运行时。MIPS架构常见的内存布局如图9-1所示。汇编程序中,使用下面这些标记各个段:.text, .rdata, 和 .data应该在数据和指令之前添加正确的段标识符,比如:.rdata msg:.asciiz "Hello world!\n" .data table: .word 1 .word 2 .word 3 .text func:sub sp, 64 ...图9-1 程序的各个目标代码段和内存布局.lit4和.lit8段:浮点常数数据段主要是传递给li.s或li.d宏指令的参数。有些汇编器和链接器会合并相同的常数以节省空间。如果使能了-G n编译选项,也有可能使用gp相对寻址,将.lit4和.lit8浮点常数段存放到全局的小数据那个特殊的数据段中。.bss、.comm和.lcomm数据段未初始化数据段。用来存储C代码中所有的静态和全局未初始化的数据。对于FORTRAN程序来说,使用.comm关键字。必须按照字节指定数据的大小。程序在链接阶段按照最大空间获取内存。如果定义为已初始化的变量,就会使用定义的值和内存大小。未初始化和初始化是针对程序编写阶段的一个概念。实际上,C语言中,静态变量或全局变量如果没有被显式初始化,那么,在程序的启动之前,应该将其设为0。这部分工作是由操作系统或者启动代码完成的。.sdata、小数据段和.sbss这几个段需要工具链的支持。如果工具链在编译代码的时候,想要使用特地保留的寄存器gp,指向小巧紧凑的一个小的数据对象,以便实现对数据的高效访问。就需要这几个段代替常规的.data和.bss段。注意,.sbss并不是一个合法的伪指令。如果数据项使用.comm或.lcomm进行声明,而且占用空间小于传递给汇编程序的-G值,工具链就把数据分配到.sbss段。隐含的.lit4和.lit8段也会被包含进小数据段,取决于-G阈值的设定。如果使用gp相对寻址方法,gp就会被使用小数据段的中间地址进行初始化。.section指定段区,提供一些特殊的控制标志(一般与具体的代码或者工具链相关联),需要参考工具链手册。9.5.1 实际的内存布局上图所示的内存布局一般适用于存储在ROM上,且是一个裸机程序的时候。对于使用操作系统的场合,内存布局比较复杂,需要考虑引导程序的分布、操作系统代码的存储、搬运等等。这又是一个比较深入的话题了。我们会在讨论uboot或者pmon等引导程序的时候,再深入研究。在实际的应用中,只读的代码数据区一般远离读写内存区。另外,堆栈是系统地址空间非常重要的区域。但是,汇编器一般无法像.text或.data区域那样,控制堆栈。通常,需要运行的程序对堆栈进行初始化。stack使用寄存器sp设置为可用内存的顶部(一般以8字节为边界)。heap一般使用一个全局指针变量进行访问,这个全局变量由malloc()函数等调用。通常被初始化为end符号表示的值,其是由连接器根据所有声明的变量计算出的一个最高地址。符号说明上图的右边还有一些特殊的符号,如下表所示。这是由链接器自动生成的一些符号,用来程序方便查找起始和结束位置的。是类Unix系统流传下来的习惯。当然,也有一些是MIPS架构特有的。这个需要查看具体的编译工具链。下表中标记着√的符号,一般都是有的。符号标准?意义ftext代码段开始etext√代码段结束fdata初始化数据段的开始edata√初始化数据段的结束fbss非初始化数据段的开始end√非初始化数据段的结束
MIPS架构中,中断、异常、系统调用以及其它可以中断程序正常执行流的事件统称为异常(exception),统一由异常处理机制进行处理。异常和中断概念在不同架构上的含义区别:MIPS架构将所有可以中断程序执行流的事件称为异常;X86架构将所有可以中断程序执行流的事件称为中断,我们日常所见的狭义上的中断,也就是外部中断,称之为异步中断;而狭义上的异常称为同步中断;ARM架构将这两个概念合起来使用-异常中断类似于MIPS架构的异常概念。在阅读相关书籍的时候,请注意区分这些概念。MIPS架构所涉及的事件,都有哪些呢?外部事件来自CPU核外的外部中断。内存转换异常这常常发生在对内存进行访问的时候,比如虚地址到物理地址转换表中无法有效转换时,或者尝试访问一个写保护的页时。其它需要内核修复的非常情况这一般不是致命的事件,实际上可能需要软件进行处理。比如,由于浮点指令而导致的浮点异常,在多任务实现中非常有用。再比如,非对齐的加载在一个系统中可能当作错误,但是,在另一个系统中由软件进行处理。程序或硬件检测到的错误包括:访问不存在的指令、用户权限下非法的指令、在相应的SR位被禁止时执行协处理器的指令、整数溢出、地址对齐出错、用户态访问内核态地址空间等。数据完整性问题CRC校验错误等。系统调用和陷阱系统调用,debug时断点等。在进一步分析异常和中断之前,先来理解一个概念,什么是精确异常?1 精确异常在MIPS架构的文档中,我们经常看到一个术语”精确异常”,英文称之为precise exception。那到底什么是精确异常,什么是非精确异常呢?在通过流水线获取最佳性能的CPU中,体系结构的顺序执行模型其实是硬件巧妙维护的假象。如果硬件设计不够完美,异常就可能导致该假象暴露。当异常中断正在执行的线程时,CPU的流水线中肯定还有几条处于不同阶段尚未完成的指令。如果我们想要从异常返回时,继续不受破坏地执行被打断的程序执行流,那么流水线中的每条指令都必须要执行完,从异常返回时,仿佛什么都没有发生才行。一个CPU体系结构具备精确异常的特性,必须满足任何异常发生时,都必须确定的指向某条指令,这条指令就是产生异常的指令。而在该指令之前的指令必须都执行完,异常指令和后续指令好像都没有发生。所以,当说异常是精确异常时,处理异常的软件就可以忽略CPU实现的时序影响。MIPS架构的异常基本上都是精确异常。其构成要素满足:明确的罪证异常处理完成后,CPU的EPC寄存器指向重新执行的正确地址。大部分情况下,指向异常指令所在的地址。但是,如果异常发生在分支延时槽上的指令时,EPC寄存器指向前面的分支指令:如果指向异常指令,分支指令会被忽略;而指向分支指令,可以重新执行异常。当异常发生在分支延时槽时,Cause寄存器的BD标志位会被设置。异常尽量出现在指令序列中,而不是流水线的某个阶段异常可能会发生在流水线的各个阶段,这带来了一个潜在的危险。比如,一个load指令直到流水线的地址转换完成阶段才会发生异常,通常这已经晚了。如果下一条指令在取指时发生地址异常(刚好在流水线的开始阶段),此时,第二条指令的异常首先发生,这与我们的构想不一致。为了避免这个问题,异常检测到后不是立即执行,这个事件只是被记录并沿着流水线向下传递。在大多数的CPU设计中,都会标记一个特殊的流水线阶段作为检测异常的地方。如果较久指令后面才检测到的异常到达这个检测点,异常记录就会被立即抛弃。这保证了永远执行最新的异常。对于上面的问题,第二条指令带来的取指问题就会被忽略。反正当我们继续执行时,它还会发生。后续指令无效因为流水线的原因,当异常发生时,异常指令后面的指令已经开始执行了。但是硬件保证这些指令产生的效果不会影响寄存器和CPU的状态,从而保证这些指令好像没有执行一样。MIPS实现精确异常的代价高昂,因为它限制了流水线的作用范围。尤其是FPU硬件单元。我们前面讲过,浮点指令不能遵守MIPS架构的5级流水线,需要更多级的流水线才能完成。所以,浮点单元一般都有自己独立的流水线。这种现状导致跟在MIPS浮点指令后的指令必须在确认浮点指令不会产生异常后才能提交自己的状态。1.1 非精确异常-历史上的MIPS架构CPU的乘法器早期的MIPS架构乘法和除法指令,因为执行周期不固定。比如,乘法需要4-10个周期,除法占用15-30个周期。读流水线的影响不确定,所以,存在非精确异常的情况。但是符合MIPS32规范的CPU通过规避,已经不存在这个问题了。2 异常发生的时机既然异常是精确的,那么从程序员的角度看,异常发生的时机就是确定的,没有歧义:异常之前执行的最后一条指令就是一场受害指令的最后一条。如果该异常不是中断,受害指令就是刚刚结束ALU阶段的指令。但是,需要注意的是,MIPS架构不承诺精确的中断延时,中断信号到达CPU之前可能需要花一个或者几个时钟周期重新同步。3 异常向量表:异常处理开始的地方我们知道,CPU使用硬件或者软件分析异常,然后根据类型将CPU派发到不同的入口点。这个过程就是中断响应。如果通过硬件,直接根据中断输入信号就能在不同的入口点处理中断,称为向量化中断。比如,常见的ARM架构的Cortex-M系列基本上就是采用向量化中断的方式。历史上,MIPS架构CPU很少使用向量化中断的方式,主要是基于以下几个方面的考虑。首先,向量化中断在实践中并没有我们想象的那么有用。大部分操作系统中,中断处理程序共享代码(为了节约寄存器之类的目的),因此,常见的作法就是硬件或者微代码将CPU派发到不同的入口点,在这儿,OS再跳转到共同处理程序,根据中断编号进行处理。其次,由硬件所做的异常分析,相比软件而言非常有限。而且现在的CPU来说,代码的执行速度也足够快。总结来说,高端CPU的时钟频率肯定远远快于外设,所以写一个中断通用处理程序完全可以满足性能要求。所以,自从在MIPS32架构上添加了向量化中断之后,几乎没有人使用。但是,MIPS架构上,并不是所有的异常都是平等的,他们之间也是有优先级区分的,总结如下:用户态地址的TLB重填异常对于实现受保护的操作系统而言,地址转换异常会特别频繁。因为TLB表只能保存适量的虚拟地址到物理地址的映射。对于维护着大量映射表的OS来说,必须保证TLB重填异常的执行时间。为此,MIPS架构CPU将TLB重填异常作为单独的一个异常入口点,这样经过优化,可以保证13个时钟周期内完成TLB重填过程。保证了系统读取内存的效率。64位地址空间的TLB重填异常对于64位地址空间,同上面的原理一样。MIPS引入了XTLB重填异常,保留一个单独的入口点。初始化时的中断向量入口点(不使用Cache访问)为了获取良好的异常处理性能,中断入口点应该位于经过Cache的内存区。但是,在系统启动的初始阶段,Cache还未初始化,所以不能使用。所以,MIPS架构保留了一段地址空间,不经过Cache访问,专门用来作为冷启动时的异常入口点。SR(BEV)标志位可以把异常入口点进行平移。奇偶/ECC错误异常MIPS32架构CPU的内存数据错误只有在Cache中使用时才会发现,然后产生自陷。所以,不管SR(BEV)的标志位是什么,奇偶/ECC错误异常的入口点总是位于不经过Cache的地址空间。复位把复位看作另外一种异常。这是很有道理的,尤其是许多CPU对于冷复位和热启动使用相同入口点的时候。中断这个很好理解。但是,MIPS架构可以允许把不同的中断设置为不同的入口点。但是,这样软件也就丧失了调整中断优先级的控制,需要软件、硬件开发工程师协商。为了效率,所有异常入口点都位于不需要地址映射的内存区域,不经过Cache的kseg1空间,经过cache的kseg0空间。当SR(BEV)等于1时,异常入口地址位于kseg1,且是固定的;当SR(BEV=0)时,就可以对EBase寄存器进行编程来平移所有入口点,比如说,kseg0的某个区域。当使用多处理器系统时,想使各个CPU的异常入口点不同时,这个功能就很用了。对于32位地址的0x80000000和64位地址的0xFFFFFFFF80000000而言是一样的。所以,下表只用32位地址表示出异常入口点。表中的BASE代表设置到EBase寄存器中的异常基址。最初的异常向量间的距离默认是128字节(0x80),可能是因为最初的MIPS架构师觉得32条指令足够编写基本的异常处理例程了,不需要浪费太多内存。但是现代系统一般不会这么节省。下面是发生异常时,MIPS架构CPU的处理过程:设置EPC寄存器指向重新开始的地址;设置SR(EXL)标志位,强迫CPU进入内核态并禁止中断;设置Cause寄存器,软件可以读取它获知异常原因。地址转换异常时,还要设置BadVaddr寄存器。内存管理系统异常还要设置一些MMU相关寄存器;然后CPU开始从异常入口点取指令,之后就取决于软件如何处理了。这时候,异常处理程序运行在异常模式(SR(EXL)标志位被置),而且不会修改SR寄存器的其余部分。对于常规的异常处理程序保存其状态,将控制权交给更为复杂的软件执行。异常模式下,只是保证系统安全地保存关键的状态,包括旧SR值。异常处理程序工作在异常模式下,不会再响应外部中断。所以,对于TLB未命中异常处理程序(也就是TLB重填异常处理程序)来说,如果读取TLB表(像Linux内核,一般将映射表保存在kseg2段地址空间中)时,发生页表地址读取异常时,程序会再次返回到异常程序入口点。Cause寄存器和地址异常相关的寄存器(BadAddr,EntryHi,甚至Context和Xcontext)都会被定位到访问页表时的TLB未命中异常相关的信息上。但是EPC寄存器的值仍然指向最初造成TLB未命中的指令处。这样的话,通用异常程序修复kseg2中的页表未命中问题(也就是将页表的地址合法化),然后,就返回到用户程序。因为我们没有修复任何与第一次地址miss相关的信息,所以,此时用户程序会再次发生地址miss。但是,页表的地址miss问题已经修复,不会再产生二次嵌套地址异常。这时候,TLB异常处理程序就会执行上面的代码,加载页表中的页表映射关系到TLB中。4 异常处理:基本过程MIPS异常处理程序的基本步骤:保存被中断程序的状态:在异常处理程序的入口点,需要保存少量的被中断程序的状态。所以,第一步工作就是为保存这些状态提供必要的空间。MIPS架构习惯上保留k0和k1寄存器,用它们指向某段内存,用来保存某些需要保存的寄存器。派发异常:查询Cause寄存器的ExcCode域,获取异常码。通过异常码,允许OS定义不同的函数处理不同的异常。构建异常处理程序的运行环境:复杂的异常处理例程一般使用高级语言(比如,C语言)实现。所以,需要建立一段堆栈空间,保存被中断程序可能使用的任何寄存器,从而允许被调用的C异常处理例程可以修改这些寄存器。某些操作系统可能在派发异常之前进行这一步的处理。执行异常处理(一般使用C语言实现):做你想做的任何事情。准备返回工作:需要从C代码返回到派发异常的通用代码中。在这儿,恢复被保存的寄存器,另外,通过修改SR寄存器到刚发生异常时的值,CPU也从异常模式返回到内核态。从异常返回:从异常状态返回时,有可能从内核态向低级别的运行态进行切换。为了系统安全的原因,这步工作必须是一个原子操作。基于这个目的,MIPS架构的CPU提供了一条指令,eret,完成从异常的返回:它清除SR(EXL)标志位,返回到EPC寄存器保存的地址处开始执行。5 嵌套异常嵌套异常概念很好理解,就是异常处理程序中,再次发生异常。就像上面我们描述的TLB未命中异常处理程序中,再次发生读取页表地址miss异常一样。但是,嵌套异常也分为2种:一种就是上面TLB未命中异常嵌套TLB未命中异常,这种不需要人为干预EPC和SR状态寄存器;另外一种,就需要我们必须保存被中断程序的EPC寄存器和SR寄存器内容。虽然,MIPS架构为异常处理程序保留了通用目的寄存器k0和k1。但是,旧异常程序一旦重启,不能完全信赖这两个寄存器。因为这时候,k0和k1可能被插入进来的异常处理程序使用过。如果想要异常处理程序能够适合嵌套使用,必须使用某些内存位置保存这些寄存器值。所有这些需要保存的数据组成的数据结构通常被称为异常帧;嵌套的多个异常帧通常存储在栈上。每个这样的异常处理程序都会消耗栈的资源,所以不能任意嵌套异常。通常的处理机制是,异常嵌套的层数和中断优先级的个数相同,高优先级可以嵌套低优先级,同优先级不能嵌套。在异常处理程序的设计过程中,应该尽量避免所有的异常:中断可以通过SR(IE)标志位进行屏蔽;其它异常可以通过恰当的软件规则避免。比如,内核态(大多数异常处理程序工作在该模式下)不会发生特权违反异常,程序可以避免寻址错误和TLB未命中异常。尤其是处理高优先级的异常时,这样的原则很重要。6 异常处理例程下面是一个非常简单的异常处理程序,只是在增加计数器的值:.set noreorder .set noat xcptgen: la k0, xcptcount # 得到计数器的地址 lw k1, 0(k0) # 加载计数器 addu k1, 1 # 增加计数值 sw k1, 0(k0) # 存储计数器 eret # 返回到程序 .set at .set reorder此处的计数器xcptcount最好位于kseg0中,这样在读写它时就不会得到TLB未命中异常。7 中断MIPS架构的异常机制是通用的,但是说实话,有两种异常发生的次数比其他所有的加起来都多。一个就是TLB未命中异常;另一个就是中断。而且中断响应的时间要求很严格。中断是非常重要的,所以我们单独讲解:中断资源:描述你要用到的东西。实现中断优先级:虽然,MIPS架构中所有中断都是平等的,但是,有时候我们还是需要不同优先级的中断的。临界区、禁止中断、信号量如何实现,如何使用。7.1 MIPS-CPU上的中断资源MIPS架构的CPU在Cause寄存器中有一组8个独立的中断标志位,其中的2个中断位是软件中断,比如说,计数器和定时器使用。有时侯,计数器/定时器中断也可能和外部中断共享一个中断,但这多半不是一个好主意。每个时钟周期都会对中断输入信号进行采样,如果使能,就会导致中断发生。CPU是否响应某个中断,由寄存器SR中的相关位控制,下面是三个相关控制域:全局中断使能位SR(IE),必须设置为1;否则,不会响应任何中断。SR(EXL)(异常级)和SR(ERL)(错误级)如果被设置,则禁止中断(任何异常一旦发生,它们中的一个会被立即置位)。状态寄存器SR中还有8个中断屏蔽位SR(IM),分别对应Cause寄存器中的8个中断位。中断屏蔽位设置为1,使能相应的中断位;如果设置为0,则禁止相应的中断。软件中断位的作用是什么?为什么要在Cause寄存器中提供2个中断标志位,一旦被设置,立即触发一个中断,除非被屏蔽。根源就在于”除非被屏蔽“。我们知道系统中,中断任务也分为高优先级和低优先级。软件中断无疑为处理低优先级中断任务提供了一种比较完美的机制。当高优先级的中断处理完成后,软件将打开中断屏蔽位,挂起的软件中断将会发生。为什么不使用软件模拟同样的效果呢?既然已经提供了中断处理机制,捎带脚的实现软件中断,减少软件的负荷,岂不是既方便又实惠。为了查找哪个中断输入信号是有效的,需要查看Cause寄存器。所有的中断优先级都是相同的,较旧的CPU使用通用异常入口点。但是MIPS32/64架构CPU为中断提供了一个可选的不同的异常入口点,这能节省几个时钟周期。通过Cause寄存器的IV标志位进行使能。中断处理的正常步骤如下:查询Cause寄存器的IP域,并和中断屏蔽位域SR(IM)进行逻辑and操作,以获得有效的、使能的中断请求。中断可能不止一个。选择一个合适的中断进行处理。先高优先级,后低优先级。但这一步完全由软件决定。保存旧中断屏蔽位SR(IM)。当然你也可能已经在主异常处理程序中保存了整个SR寄存器。修改中断屏蔽位SR(IM),禁止与当前中断具有相同优先级或者比当前中断的优先级更低的所有中断。为可能的嵌套异常处理保存状态,比如寄存器等。改变CPU的状态,为中断例程的执行提供合适的环境。这儿,允许嵌套中断和异常。设置全局中断使能标志位SR(IE),允许高优先级中断被处理。还需要改变CPU的特权级别寄存器SR(KSU)保证你从异常状态改变到内核态。离开异常模式,需要清除SR(EXL)标志位。调用中断例程在返回的时候,需要再一次禁止中断,恢复中断之前的寄存器并继续中断任务的执行。要这样做,就需要置位SR(EXL)。但实际上,当在恢复异常刚刚发生时的整个SR寄存器时,可能已经隐含的置位了SR(EXL)。当对SR寄存器做出改变时,必须小心CP0协处理器的遇险问题。7.2 软件的中断优先级策略MIPS架构对所有的中断一视同仁,而如果你想实现不同优先级的中断怎么办呢?首先,为我们的中断系统定义一个策略:系统软件在CPU运行过程中,自始至终维护着一个中断优先级表(IPL)。每个中断源分配到其中一个中断优先级;如果CPU处于最低优先级,所有中断都被允许。这是正常状态下,软件使用的中断行为;如果CPU处于最高优先级,所有中断被阻止。中断处理程序不仅可以按照分配给具体中断源的优先级IPL运行,它们还允许程序员升高和降低IPL。驱动程序和硬件通信,或者中断处理程序中,经常需要在临界代码段禁止中断,所以,程序员可以通过临时升高IPL,禁止某个设备的中断。这样设计的一个系统,只要高于IPL设置的中断可以继续响应,而且不会受低IPL中断的影响。这样,我们就可以很好地区分对响应时间严格的中断。类Unix系统一般都是基于这种思想进行设计的,一般使用4到6个IPL优先级。当然还有其它的方式实现中断系统,但是这样一个简单的策略,具有以下的特点:固定优先级:任何IPL,当前指定的中断以及更低优先级IPL的中断会被禁止。相同IPL优先级的中断通常遵循FIFO-先进先出的原则。任何给定的IPL都有对应的执行代码,是唯一的。简单的嵌套调度(IPL0以上):除了最低优先级,只有更高优先级没有中断要处理,就会返回到较低优先级进行处理。在最低优先级时,一般情况下,会有一个调度器,负责不同任务间CPU使用权的分配。MIPS架构的CPU在不同中断级别之间进行转换时,必须修改状态寄存器(SR),因为其包含所有的中断控制位。有一些系统上,中断优先级的切换还会要求修改外部中断控制器,比如X86的高级可编程中断控制器-APIC,还需要维护一些全局变量。但是,这儿我们先不关注这些,先着重理解一下SR中断控制位如何影响IPL。同协处理器的访问一样,SR寄存器同样不能直接访问,所以需要我们编写一段汇编代码对其进行读取、修改:mfc0 t0, SR 1: or t0, things_to_set and t0, ˜(things_to_clear) 2: mtc0 t0, SR ehb上面的代码,先是从SR寄存器中读取原先的数值,然后通过or或者and操作,修改想要的操作位,最后再写回到SR寄存器中。而最后的ehb指令是遇险屏障指令,保证在运行后面的代码之前,前面的内容安全的写入到寄存器中了。上面的代码我们不得不考虑一个问题,如果在执行过程中,被打断怎么办?所以,我们需要对SR的修改操作是原子操作。7.3 原子性以及对SR的原子修改对于原子操作的概念我们之前已经多次提到,故在此不再累述。如果有需要,请看之前的文章。执行原子操作的代码段一般称为临界区。对于单处理器系统,只要关闭中断,就可以保护临界区代码的执行。这很简单粗暴啊,但是有效就行。对于多处理器系统而言,禁止中断不能保证RMW(读-修改-写)的步骤是原子操作。所以,MIPS架构必须提供原子性操作。MIPS架构实现原子性操作的方法:如果你所使用的CPU是基于MIPS32v2版本架构的,可以使用di指令代替mfc0。di会自动清除SR(IE)标志位,返回SR原始值到一个通用寄存器中。但是,这个功能在此版本上还是一个兼容性功能,所以你需要特别注意你的CPU是否支持这条指令。一种可能的方法就是在中断代码中保存之前的SR值,在返回之前再恢复SR寄存器的值,就像我们在进程切换时,保存恢复所有的通用寄存器一样。如果是这样,非原子的RMW操作也没关系,即使中断进来,旧SR值也不会被改变。基于MIPS架构的类Unix操作系统一般采用这个方法。但是,这种方法还是有缺点。比如,我们想要禁止某个中断的时候,无法实现,因为需要修改SR寄存器,本身我们引出这个话题就是因为修改SR寄存器。再比如,有些系统可能想要在运行过程中修改优先级,以轮转分配中断到CPU,实现中断负载平衡。再一种方法就是,使用系统调用禁止中断:在系统调用中进行位操作(置位、清除),更新状态寄存器)。这里,利用了系统调用是异常实现的一个隐含特性,异常模式下,它会自动禁止中断。所以,可以安全地进行位操作并更新状态寄存器。当系统调用返回的时候,全局中断会自动使能。ARM和X86架构有专门的禁止中断的指令。系统调用看上去负荷还是有点重,虽然执行时间不一定很长。但是,需要编程者在异常派发代码中,将这个系统调用和其它异常处理程序理清楚。基本上所有系统都会实现的方法:使用test-and-set指令构建原子操作,从而满足临界代码区的保护要求,而不必禁止中断。而且,这种机制适用于多核处理器或者硬件多线程系统。细节参考下一节。7.4 允许中断的临界区:MIPS式的信号量众所周知,信号量是实现临界代码区的一种事实约定(当然扩展的信号量可以做更多事情)。简单的信号量也可以称为互斥锁。信号量实质上是并发运行的进行共享的一个内存位置,通过某种设置,一次只能由一个进程访问。对于信号量的理解,我们之前已经写过文章,请参考《Linux内核33-信号量》。信号量的使用如下代码段所示:wait(sem); /* 临界代码区 */ signal(sem);为了叙述方便,我们假设信号量就是0,1两个值,1表示未使用,0表示在使用。那么,wait()函数就是等待值为1。如果等到,进行P操作,信号量的值减1,并返回。道理很简单,唯一的要求是硬件可以实现减1操作的原子性,换句话说,就是硬件必须提供test-and-set这样的原子操作指令。不管是中断,还是多核系统,都不能影响这个原子操作的正确性。大部分的CPU都有这样特殊的指令:X86通过在指令前面添加lock前缀锁住总线实现原子操作;ARM通过ldrex和strex独占指令实现原子操作,早期版本的ARM架构使用swp指令;对于支持X86-多核的系统而言,使用test-and-set过程代价非常大。实际上,其执行过程是:所有共享内存都必须停止,使用信号量的用户获取该值,完成test-and-set操作,然后将结果同步到每一份备份中。因为一些偶尔使用的重要数据,而占用了整个总线,这对于大型多核平台,牺牲了很多性能。如果能够不在每次都必须严格保证原子性的情况下,实现test-and-set操作要高效得多。换句话说,就是尝试set操作,如果是原子的,就成功;不是原子的,就重新尝试。完全由软件决定是否set成功,前提是软件能够知道set是否成功。于是,MIPS架构为支持操作系统的原子操作,特地加了一组指令ll/sc。它们这样来使用:atomic_block: ll XX1, XXX2 …. sc XX1, XXX2 beq XX1, zero, automic_block ….在ll/sc中间写上你要执行的代码体,这样就能保证写入的代码体是原子执行的(不会被抢占的)。其实,LL/sc两语句自身并不保证原子执行,但他耍了个花招:用一个临时寄存器XX1,执行LL后,把XXX2中的值载入XX1中,然后会在CPU内部置一个标志位,我们不可见,并保存XXX2的地址,CPU会监视它。在中间的代码体执行的过程中,如果发现XXX2的内容变了(即是别的线程执行了,或是某个中断发生了),就自动把CPU内部那个标志位清0。执行sc 时,把XX1的内容(可能已经是新值了)存入XXX2中,并返回一个值存入XX1中,如果标志位还为1,那么这个返回的值就为1;如果标志位为0,那么这 个返回值就为0。为1的话,就表明这对指令中间的代码是一次性执行完成的,而不是中间受到了某些中断,那么原子操作就成功了;为0的话,就表明原子操作没 成功,执行后面beq指令时,就会跳转到ll指令重新执行,直到原子操作成功为止。所以,我们要注意,插在LL/sc指令中间的代码必须短小。据经验,一般原子操作的循环不会超过3次。我们再回头分析wait()函数的实现,参考下面的代码。在这儿,sem是一个0/1信号量:wait: la t0, sem TryAgain: ll t1, 0(t0) bne t1, zero, WaitForSem li t1, 1 sc t1, 0(t0) beq t1, zero, TryAgain /* 成功获取锁 */ jr ra这儿,添加了WaitForSem标签,用来处理如果一直申请锁失败的情况下,需要做的处理。可以用来实现阻塞等待或者非阻塞等待。ll/sc是为多核系统设计的,但是,对于单核系统也非常有价值,因为不涉及关闭中断。避免了上面提出的使用过程中,禁止中断的问题。并可以在处理最坏中断延时的情况下发挥作用,这对于嵌入式系统非常重要。7.5 MIPS32/64架构CPU的中断向量化和EIC中断MIPS32规范的第二版中,引入了两个新的特性,使中断的处理更为高效。这两个特性就是向量化中断和EIC模式。向量化中断,发生中断异常时,根据中断的输入信号,从8个入口地址中选择一个开始执行的地址。如果两个中断同时发生,硬件选择中断号高的执行。向量化中断通过IntCtl(VS)设置,对于不同中断入口地址间距给出了几种不同的选择(零值导致所有中断都是用同样的入口点,这就回到了传统的做法。嵌入式系统常常有大量的中断信号,远远超过传统的MIPS架构CPU的6个硬件输入。在EIC模式下,这6个以前相互独立的信号变成一个6位的二进制数:0代表没有中断,1-63表示不同的中断码。每个非0的中断码都有自己的中断入口点,允许适当设计的中断控制器能够分派给CPU处理的事件多达63个。向量化中断在复杂的系统没有使用的原因是因为,因为其它一些约束条件,牺牲掉向量化中断省下的几个时钟周期,并不影响系统的整体性能。向量化中断一般只有在嵌入式CPU中使用。
本章我们从硬件底层开始,首先研究TLB机制以及如何设置。在此基础上分别研究裸机程序和操作系统下内存管理机制。1 TLB/MMU硬件TLB是把程序地址或者虚拟地址转换成物理地址的硬件电路。地址转换是实现安全OS的安全特性的关键。基于MIPS架构的CPU,转换页表项的大小是4K,我们称之为页(page)。虚拟地址的低12位是在物理内存上的偏移量,换句话说,虚拟地址的低12位等于物理地址的低12位。其实,MIPS架构的CPU完全支持访问更大的页(大于4K),这对于特殊场景下的应用很有用。尤其是现在AI人工智能这么火,许多应用直接把算法和模型数据存放到内存上,需要使用上百G、甚至更大的内存。这时,就需要访问更大的页。但是,本文不讨论更大的页表转换。转换表中的每一项包含一个VPN(虚拟地址页编号)和一个PFN(物理页帧编号)。当程序给出一个虚拟地址后,和TLB中的每一个VPN进行比较,如果匹配,就给出对应的PFN。具体的比较复杂,由硬件电路实现。所以,通常TLB只有16到64项。转换表中的每一项除了VPN之外,还包含一些标志位。这些标志位允许OS控制实际的物理地址的属性,比如只读(read-only),或指定数据可以被缓存,也就是存储到Cache中。现代MIPS架构CPU为了效率,在上面的基础上进行扩展,将每个TLB项包含两个独立的物理页帧,由两个连续的虚拟地址页进行映射。也就是说,每一个TLB项,包含1个VPN和2个PFN,因为虚拟地址是连续的,所以VPN自动加1访问下一个物理内存页。图6-1是一个兼容MIPS32/64规范的TLB项定义。它们的控制使用协处理器0(CP0)寄存器实现。图中的标签就是寄存器的名称。在实际的使用过程中,TLB太小了,肯定不能包含所有虚拟地址到物理地址的映射。所以,软件只是把TLB作为最近常用地址转换的一个缓存。当需要的地址转换不在TLB中时,产生异常,异常处理程序计算并加载正确的地址转换关系。这样做,效率肯定包含所有的地址映射效率高,但这是一个综合平衡的结果。2 TLB/MMU寄存器上面描述了TLB的工作机制,那么想要控制它实现我们想要的地址转换,就必须有控制它的寄存器和相应的指令。相关寄存器位于协处理器0(CP0)中。下面我们会一一描述这些寄存器:EntryHi:在协处理器0中的编号为10。EntryLo0-1:在协处理器0中的编号为2/3。PageMask:在协处理器0中的编号为5。1-3项描述的这些寄存器一起构成了一个TLB项所需要的一切。所有对TLB的读写都要经过它们。其中,EntryHi存有VPN和ASID。ASID域具有双重职责,因为它还记录了当前进程的地址空间标识符。64位CPU中,EntryHi扩展到64位,但是对于32位软件仍然保持32位布局不变。兼容MIPS32/64规范的CPU每一项映射2个连续的VPN到2个不同的PFN上,物理内存页的PFN和存取权限标志由EntryLo0和EntryLo1两个寄存器单独指定。PageMask可以用来创建映射大于4K的页。Index:在协处理器0中的编号为0。对TLB项的索引。操作指令靠这个寄存器寻址TLB项。Random:在协处理器0中的编号为1。产生伪随机数(实际上是一个自由运行的计数器)。表示tlbwr指令写新TLB项时随机指定的位置。当在异常处理中,重新填充TLB时,随机替换TLB表项使用。可以节省时间。Context:在协处理器0中的编号为4。XContext:在协处理器0中的编号为20。6-7两项描述的寄存器是辅助寄存器,用来加速TLB重填异常处理程序的处理过程。高位可读写,低位取自未能命中的地址中的VPN。通俗的说,就是标记内存映射表在内存中的位置和映射关系的。具体看后面的介绍。2.1 TLB关键域描述1下面对关键的域进行描述:VPN2(虚拟页编号)程序地址或虚拟地址的高位(低位0-13略去)。其中,位0-12属于页内偏移,但是位12并不参与查找。每一页映射大小为4K的页,位13自动在两个可能的输出值之间进行选择。refill异常发生后,将自动设置此域,以匹配无法转译的程序地址或虚拟地址。如果想要不同的TLB项或尝试TLB探测时,必须手动进行设定。MIPS32/64规范中允许EntryHi的最大虚拟地址区域,达到64位,然而当前的通用CPU只能实现40位。如果你想要知道可用的虚拟地址多大,可以将EntryHi寄存器全写1,然后重新读回,还为1的位就是有效位。VPN2中超过CPU实际使用的高位地址必须全写0或者1,和R域的最高有效位要匹配。也就是说,核心态使用地址高位必须全为1,否则全为0。如果使用的是32位指令集,这一切自动发生,不需要我们管理。因为这种工作模式下,所有的寄存器包含的值都是一个32位数的64位有符号扩展。你可以把它理解成就是一个32位寄存器。从图6-2中,可以看出还有一些位填充为0:这些位并不是没用,有些CPU可以配置支持1KB大小的页,这样VPN2的位需要向下扩展2位。ASID(地址空间标识符)这一部分的作用同ARM架构的ASID作用是一样的。可以保证应用程序的地址空间互相隔离,不受彼此的影响。异常不会影响该域,所以refill异常之后,该域对当前的进程仍然是有效的。支持多进程的操作系统使用该域表示当前有效的地址空间。但是在使用指令tlbr指令检查TLB项的时候必须十分小心:该操作会重写整个EntryHi寄存器,所以执行之后,必须恢复正确的当前ASID的值。R(64位版本才有)R值区域名描述0xuseg用户态可访问的虚拟存储器的低地址区1xsseg管理态可访问的空间(管理态是可选的)2xkphysCore专用的大物理内存窗口3xkseg核心态空间PageMask:寄存器允许设置TLB域来映射更大的页。具体可以允许的页大小如下表所示:24-2120-1716-13页大小0000000000004KB00000000001116KB00000000111164KB000000111111256KB0000111111111MB0011111111114MB11111111111116MB如果你的CPU支持1KB大小的页,在PageMask底部还要有两个额外的位,对它的设置,遵循同样的模式。2.2 TLB关键域描述2-EntryLo0-1图6-3分别展示了64位和32位版本的EntryLo寄存器:PFN保存物理地址的高位,和VPN2中的高位是映射关系。MIPS32架构的CPU外部物理内存的接口限制到2^32字节的范围,但是EntryLo潜在支持多达2^38字节的物理范围(26位的PFN,支持2^26个物理页,每个大小4K)。C包含3位,最初是为多处理器系统的Cache一致性设计的,设置一致性属性,有些手册称之为CCA。OS往往知道哪些内存页不需要在多个Cache之间实现一致性,比如,只有一个CPU核使用的内存页,再比如只读的内存页,它们可以经过Cache访问,但是不需要考虑一致性。所以,关闭Cache的snooping和交互可以让系统更有效率。但有时候,嵌入式系统也会使用该域,用来选择Cache的工作方式,比如标记某个具体的页为write-though式管理,也就是说,访问标记为这种管理方式的页,所有的写操作都同时直接写入主内存和Cache中。常用的值为:2,不用Cache(uncached);3,可用Cache,但是不要一致性检查(Cacheable noncoherent)。D(脏位)写内存使能标志位。1,写使能允许;0,如果尝试写,产生陷阱。V(合法标志位)如果为0,尝试访问该地址都导致异常。用来设置某个物理地址不可访问。G(全局标志)置1时,TLB项只匹配VPN域内容,不管ASID域的内容是否匹配EntryHi中的值。这样提供了一种机制,可以实现所有进程共享某个地址空间。需要注意的是,虽然有两个EntryLo寄存器,但是只有一个G位,如果EntryLo0的G位和EntryLo1的不同,那就会坏事。未使用的PFN位和填充为0的位无论填写0或1,硬件统统忽略。2.3 选择TLB项的寄存器Index寄存器取值范围0~总项数-1。具体读一项的时候,手动设置Index;如果使用tlbp搜索某个TLB项时,Index会自动增加。Index不需要使用很多位,目前为止,MIPS架构的CPU没有超过128项。bit31有特殊意义,当检测到未匹配项的时候由tlbp置位。使用bit31,看起来像负数,很容易测试。Random寄存器保存到TLB中的索引,CPU每执行一次指令就计数一次(向下递减计数)。写数据指令tlbwr使用这个值作为写入TLB中的位置。可以用来实现随机替换策略。正常使用时,不需要读写Random寄存器。硬件复位时,将Random设置为最大值-TLB项的最高编号,每个时钟周期递减计数,递减到最小值后又回绕到最大值,周而复始。Wired寄存器有时候,我们可能需要一些永久转换的地址项,基于MIPS架构的OS文档中一般称为wired。设置了Wired寄存器后,凡是索引值小于该值的表项不受随机替换的影响。写入Wired寄存器时,Random寄存器被复位到TLB顶部。2.4 页表存取辅助寄存器-Context和XContext寄存器当给出的虚拟地址不在TLB表中时,CPU发生异常,未能转换的虚拟地址已经在BadVAddr寄存器中了。虚拟地址中对应VPN域的位也会被写入到EntryHi(VPN2)中,从而为未命中的地址建立新的TLB项。为了进一步加速这种异常的处理过程,Context或XContext寄存器用来记录保存在内存中的页表指针。通过它,可以快速查找定义的虚拟内存映射表。MIPS32架构的CPU只有Context寄存器,可以帮助填充32位的虚拟地址。MIPS64架构的CPU增加了XContext寄存器,用来扩展虚拟地址空间(达到40位。如图6-4所示:XContext寄存器是MIPS64架构唯一没有精确定义各个域边界的寄存器:XContext(BadVPN2)域在支持超过40位虚拟地址空间的CPU上自动向上增长,并且将R和PTEBase域向左推移(要想保证放得下,必须自动缩小后者)。各个域的具体参考如下:Context(PTEBase):用于页表管理。保存想要保存的内存页表的基地址。该基地址的低22位为0,也就是以4M为边界。虽然在物理内存或者未映射的内存上提供对齐很低效,但是这样设计的目的是把该表存储到kseg2映射区域内。Context(BadVPN2)/XContext(BadVPN2):跟随在TLB未命中异常之后,这个域被自动填入BadVAddr寄存器中的高位值,也就是VPN域。这儿的数字2,表示连续的虚拟地址页对应独立的两个物理内存页。BadVPN2的值从第4位开始,是因为PTEBase表中的项都是16字节大小的表项。如果,我们地址是32位,且不需要那么多的软件状态标志位,则页表的项可以使用8字节。这就是Linux没有按照约定使用Context寄存器的原因。XContext(PTEBase):物理内存比较大时用的页表基址寄存器。如果页表非常大,可以存储在巨大内核使用的地址空间内(xkseg区域)。XContext(R):标志TLB未命中发生的地址空间。具体的值可以参考如下:R值区域名描述0xuseg用户态可访问的虚拟内存的低地址区1xsseg管理态可访问的空间(管理态是可选的)2对应未映射的地址段,未使用3xkseg内核态映射空间(包含老kseg2)再次提醒:Linux并不这样使用Context/XContext寄存器。3 TLB/MMU指令相关指令:tlbr和tlbwi读写TLB表项,也就是在TLB项和EntryHi和EntryLo0-1寄存器之间搬运数据。使用Index寄存器中的值作为索引,正常情况下,这两个指令应该不常用。如果你确实修改了某个TLB项,记得恢复ASID域的值,因为该域的值也会被覆盖掉。tlbwr拷贝EntryHi、EntryLo和PageMask寄存器的内容到由Random寄存器随机索引的某个TLB项中。当你选择随机替换策略时,这可以节省你自己产生随机数的时间。实践中,tlbwr经常被TLB重填异常处理程序调用,写一个新的TLB项,而tlbwi指令可以在任何地方使用。tlbp遍历TLB表。搜索TLB表,查看是否有与EntryHi寄存器中的VPN和ASID相匹配的项。如果有,把对应项的索引写入到Index寄存器中;如果没有,则设置Index寄存器的bit31,这个值看上去是一个负值,更好判断。需要注意的是,tlbp不会从TLB中读取数据,必须在后面执行指令tlbr读取数据。在大部分的CPU中,TLB地址转换都被纳入流水线的操作流程中,以便提高效率。这时,TLB的这些指令操作不能完全适配标准的管道流水线。所以,在使用了上面这些指令后,立马使用相关虚拟地址的指令可能会产生危险,这个问题我们之前的文章分析过。为了避免这个问题,通常在kseg0非转换区域进行TLB的维护工作。4 TLB编程TLB表的设置过程是:将想要的值写入到EntryHi和EntryLo寄存器中,然后使用tlbwr或tlbwi指令拷贝到相应的TLB表中。处理TLB重填异常的时候,硬件自动将虚拟地址的VPN和ASID域写入到EntryHi寄存器中。一定注意,不要创建两个相同的虚拟地址映射关系。如果TLB包含重复的项,尝试转换这个地址的时候,会潜在地破坏CPU芯片。一些CPU为了在这种情况保护自身,会关闭TLB硬件单元,并设置相应的SR(TS)标志位。此时,TLB只能复位才能工作。系统软件一般不会读取TLB表项。但是,如果确实需要读取它们,则使用tlbp遍历匹配到需要的虚拟地址对应的TLB项,把对应的索引值写入到Index寄存器。然后使用tlbr指令读取TLB项相应的值到EntryHi和EntryLo0-1寄存器中。使用过程中,不要忘记保存和恢复EntryHi寄存器,因为ASID域非常重要。有时候在阅读相关CPU文档的时候,可能会看到带有”ITLB”和”DTLB”字样的指令,它们分别执行指令和数据地址的转换。它们主要执行L1级Cache中地址的转译工作,这些操作完全由硬件进行管理,软件无需干预。当你写入到主TLB表中某一项时,它们会自动失效。4.1 重填过程如果程序试图访问任何需要转译的地址(通常是用户态使用的地址空间kuseg和内核态使用的kseg2段),如果TLB表中没有对应的转换映射,CPU就会发出一个TLB重填异常。我们知道TLB一般很小,而应用程序所需要的地址空间都很大,无法一次在TLB表完全展现。所以,内核OS一般都在内存中维护着一些页表,它们保存着虚拟地址到物理地址的映射关系,我们称这些表为虚拟内存映射表。把TLB作为一个内存映射表的一个缓存。为了提高效率,这些页表中的数据项直接就是按照TLB表项的内容进行排列组合的数据;为了更快访问这些页表,把这些页表的位置和结构保存到Context或XContext寄存器中,作为访问这些页表的指针。MIPS架构系统一般在kseg0段运行OS代码,这段地址不需要地址转换。所以,TLB未命中一般发生在用户态程序中。为了加速异常处理程序的执行,提供了几个特殊的硬件特性。首先,重填异常处理程序位于内存的低地址区,不会被其它异常使用;其次,使用一些小技巧保证虚拟内存映射表存储于内核虚拟地址空间上(kseg2或64位中对应的内核虚拟地址空间中),这样,这些页表所在的物理内存就不需要映射到用户态虚拟地址空间上了;最后,Context或XContext寄存器可以直接从内存中的虚拟地址映射表中访问正确的映射关系了。当然了,这些过程都不是强制的。在小型的嵌入式系统上,TLB完全可以映射到固定的物理内存或者进行很少的转换,这时候,TLB就不需要作为一个缓存而存在了。甚至有些大型OS也不使用上面这种处理方式,比如Linux。因为它与Linux对于虚拟内存的管理策略不同。因为Linux内核的地址映射对所有进程都相同。后面我们再专门分析,基于MIPS架构的Linux内存管理方式。4.2 使用ASIDASID设计的目的就是将内存区域进行安全划分,保证不同进程的地址空间安全。使用方法就是设置TLB项中的ASID域为对应的值,并且EntryLo0-1(G)标志位为0,就可以只访问EntryHi(ASID)匹配的项了。ASID占用8位,允许同时映射多达256个不同的地址空间,而不用在进程切换的时候清除TLB。如果ASID用尽,需要把不需要的进程从TLB中清除。抛弃其所有的映射,就可以把ASID重新指定给其它进程了。4.3 Random和Wired寄存器一般情况下,TLB使用随机替换原则。所以,为了效率MIPS架构CPU提供了一个Random寄存器来简化实现。但是,有时候确实需要一些TLB项常驻TLB表中。MIPS架构提供Wired寄存器实现这个需求。写入Wired寄存器一个值,0~wired-1范围的值Random寄存器就不会再产生。它仍然递减,到了wired寄存器的值时,就会返回到最大值。通过这种方式将TLB索引在0到wired-1中的项永久保留在TLB表中。5 对硬件友好的页表和重填机制类Unix的OS为MIPS架构提供了一种特殊的地址转换机制。把所有的地址空间划分为一个线性数组,使用VPN索引,与EntryLo寄存器的位域匹配。这样成对的TLB项需要16个字节保存,2*64位。这种处理方式减少了重填异常处理程序的负荷,但是带来了其它问题。因为每8K的用户空间地址占用一个16字节的表项,整个2GB的用户空间就占用4MB大小的页表,这是一个相当大的内存空间。我们知道,用户空间的地址一般是在底部填充代码和数据,顶部是堆栈(向下增长),这样中间有一个巨大的空隙。MIPS架构借鉴了DEC的VAX体系结构的启发,把页表存入内核态的虚拟地址空间(kseg2或xkseg)。这样的话,节省了物理空间:中间不用的空闲不需要为其提供物理内存分配。这种使用线性数组映射所有用户虚拟地址的方法,提供了一种在进程切换时,不需要遍历所有虚拟地址空间就可以切换虚拟地址空间的简单机制。进程切换时,改变ASID值,kseg2地址空间内指向页表的指针自动就会重映射到正确的页表上。是不是很巧妙???MIPS架构通过Context寄存器(64架构使用扩展寄存器XContext)支持这种线性页表。如果页表是以4M为边界,使用页表的起始地址的高位填充Context寄存器中的PTEBase域,然后,跟随在重填异常之后,Context寄存器就会自动包含重填需要的页表中的项的地址。但是,这种方案有一个问题,就是TLB重填异常处理程序本身可能产生TLB重填异常,因为kseg2中存储的映射页表并不在TLB中。但是,硬件对这个问题进行了修复。如果嵌套TLB异常发生,此时,CPU已经处于异常模式了。在MIPS架构的CPU中,异常模式中的TLB重填被定位到通用异常入口点,在那里进行检查并处理。更多介绍请继续往下看。5.1 TLB未命中处理程序TLB未命中异常发生时,如果状态寄存器SR中的EXL标志位没有被置位,总是会跳转到CPU特定的入口点,开始执行。下面是一个MIPS32架构的CPU或者MIPS64架构的CPU被当作32位的CPU,处理TLB未命中的处理程序。.set noreorder .set noat TLBmiss32: mfc0 k1, C0_CONTEXT # (1) lw k0, 0(k1) # (2) lw k1, 8(k1) # (3) mtc0 k0, C0_ENTRYLO0 # (4) mtc0 k1, C0_ENTRYLO1 # (5) ehb # (6) tlbwr # (7) eret # (8) .set at .set reorder分析:(1)行通常情况下,k0和k1通用寄存器是为底层异常处理程序保留的寄存器。所以,可以直接使用这两个寄存器。(2-5)行把Context执行的页表映射关系写入到EntryLo0和EntryLo1寄存器中,Context的内容发生异常时自动加载。如图6-4所示,MIPS32/64架构的Context寄存器为成对的物理地址映射保留了16字节的空间(每个物理页的映射需要8字节),尽管MIPS32的EntryLo0和EntryLo1只是32位寄存器。这是为了和64位架构兼容而进行的设计。在这儿,为什么交错执行lw/mtc0指令序列?这是为了效率。我们之前已经多次说过load指令会有一个延时槽,这儿是对延时槽的最大化利用。如果kseg2区间的地址转换不在页表中,发生嵌套异常怎么办?后面再讲解。(6)行执行遇险屏障(其它架构比如ARM和x86,一般称为内存屏障指令)。如果直接调用tlbwr指令,因为MIPS32架构无法保证此时EntryLo1寄存器的内容已经准备好被使用。所以,加上一条执行遇险屏障,保证数据的安全使用。(7)随机替换,将EntryLo0和EntryLo1寄存器的内容写入到TLB项中。(8)异常返回指令。从异常返回到EPC寄存器中的地址位置并且清除SR(EXL)标志位。如果在TLB重填异常处理程序中,访问页表的地址时发生miss情况怎么办?(页表的地址位于kseg2空间中,并不在页表中保存)。前面我们提到过,这种情况返回到通用异常处理程序入口点。Cause寄存器和地址异常相关的寄存器(BadAddr,EntryHi,甚至Context和Xcontext)都会被定位到访问页表时的TLB未命中异常相关的信息上。但是EPC寄存器的值仍然指向最初造成TLB未命中的指令处。这样的话,通用异常程序修复kseg2中的页表未命中问题(也就是将页表的地址合法化),然后,就返回到用户程序。因为我们没有修复任何与第一次地址miss相关的信息,所以,此时用户程序会再次发生地址miss。但是,页表的地址miss问题已经修复,不会再产生二次嵌套地址异常。这时候,TLB异常处理程序就会执行上面的代码,加载页表中的页表映射关系到TLB中。5.2 XTLB未命中处理MIPS64架构的CPU有2个特殊的入口点。其中一个,和MIPS32架构CPU共享,用来处理32位地址空间的转换;另一个入口点为64位架构提供,供其寻址更大的地址空间。状态寄存器中的3个标志位:UX、SX、和KX,它们负责在转换失败时,根据CPU的特权等级选择要使用的异常处理程序。当相关的状态位(用户模式的SR(UX)标志位)被置位时,TLB未命中异常使用一个不同的向量,那应该是一个加载巨大地址空间转换表的例程。处理程序的代码和32版本的差不多,除了使用64位宽的寄存器和用XContext寄存器代替Context之外。.set noreorder .set noat TLBmissR4K: dmfc0 k1, C0_XCONTEXT ld k0, 0(k1) ld k1, 8(k1) dmtc0 k0, C0_ENTRYLO0 dmtc0 k1, C0_ENTRYLO1 ehb tlbwr eret .set at .set reorder需要主要的是,此时的页表结构比较庞大,需要保存在巨大的xkseg地址空间中。上面的方式不是完全必须的,基于MIPS架构的Linux版本就没有使用这种方式。Linux内核多级页表管理虚拟内存的方式,我们会专门写一篇文章介绍。6 MIPS架构中TLB的使用场景如果你要运行的系统是全功能的操作系统,比如说Linux,对TLB的使用不需要你的关注。但是,对于实时OS,你可能想知道TLB是否有用。因为MIPS架构的TLB提供了一种通用目的地址转换服务,你可以根据应用灵活运用它。TLB机制,允许在page的粒度上,转换任何虚拟地址到物理地址。如果在TLB表中的映射可以容纳所需的所有转换,那么就不需要支持TLB重填异常或单独在内存中保存一个页表。TLB也允许你定义一些地址是临时的,或者永久不可用的,从而对这些位置的访问导致一个异常来运行操作系统的某些服务例程。通过使用ASID,可以在用户空间实现多任务间的地址空间安全。你还可以对内存进行写保护。应用可能有许多,下面举几个代表性的例子:访问不方便的物理地址空间:正常情况下,MIPS架构的硬件寄存器位于物理地址范围0~512MB时比较方便,可以通过kseg1地址空间内的某个指针对其进行访问。但是,有时候硬件无法在这个区域,可以把高物理内存的空间映射到一个方便的地址空间,比如kseg2。与此相关的TLB转换标志必须保证不经过Cache访问这个区域。异常处理程序的内存访问:默认情况下,保留k0和k1寄存器给异常处理程序,用来进行上下文的保存。但是,如果你不想使用k0和k1呢?这就会带来麻烦,因为MIPS架构的CPU,除了32个通用寄存器之外,没有任何地方可以用来保存。所以,这种情况下,你可以使用TLB映射一个或多个物理页作为读写内存,使用zero寄存器作为基址寄存器,如果是正的偏移量,就访问kuseg区域的前32KB,如果是负的偏移量,就访问kseg2的后32KB。如果不使用TLB,这就无法实现。在没有虚拟内存的系统中,用来实现可扩展的堆和栈:即使在没有虚拟内存的系统中,扩展堆栈并监视其使用情况也是很有用的。在这种情况下,需要使用TLB映射堆和栈的地址,使用TLB-miss事件决定是否分配更多内存或者判断应用程序是否失去控制。仿真硬件:如果某个硬件有时候存在,有时候不存在。通过将寄存器映射到某个区域上,访问这个地址就可以直接访问硬件,如果硬件不存在,调用软件处理程序。TLB核心的思想就是,通过转换适配,将其变为一个通用的资源,使得硬件开发人员更简单。7 实时操作系统中的内存管理思想前面的讨论我们主要针对的是非实时操作系统,比如类Unix-OS操作系统。但是,对于嵌入式OS来说,大部分情况下要简单的多。比如说风河公司的VxWorks等,基本上都是运行在单个地址空间,且提供多线程的能力。彼此之间,没有任务间的保护,所有的功能都被实现在一个大的应用程序中。对于多种多样的嵌入式系统,是否使用复杂的操作系统(比如说,Linux),目前没有一个统一的标准。如果使用,你可以获得更丰富的编程环境,任务间的保护,更加简洁的接口等。但是同时,也失去了CPU一些执行效率,且需要更大的物理内存;还要牺牲一些实时性。所以,对于机顶盒,DVD播放器和网络路由器等使用Linux比较合适,而像其它一些可靠性、实时性要求比较高的一些场合需要使用实时操作系统,甚至是裸机程序。当然了,Linux是开源的,这本身就是一种优势。你可以修改源代码,实现自己一些特定的功能。但是,对于我们开发者来说,可能会面对各种情况。所以,深入硬件实现机制,在此基础之上,灵活运用各种硬件,选择或实现合适的软件是非常重要的。尤其是面对一个新的内存管理系统。需要做的第一件事情就是,搞明白内存映射,包括软件视角的虚拟地址映射和硬件视角的物理地址映射。正是因为选择了相对简单的虚拟地址映射方式,才使得Unix系统内存管理系统相对描述起来简单。但是,嵌入式系统情况非常复杂,有的根本就没有MMU,有的某些地址不需要映射(比如kseg0和kseg1)。这就需要具体问题具体分析了。
1 引言现代CPU中,为了提高CPU的执行效率,高速缓存必不可少。关于Cache工作原理可以参考我之前的文章与ARM等架构相同,MIPS架构CPU也是采用多级cache。我们这里关心的是L1级缓存:I-cache和D-cache。通过这种哈弗结构,指令和数据读取可以同时进行。2 Cache工作原理从概念上讲,Cache是一个关联性内存,数据存入其中,可以通过关键字进行查找。对于高速Cache而言,关键字就是完整的内存地址。因为想要查询高速Cache,就必须与每一个关键字进行比较,所以合理使用Cache非常重要。下图是一个直接映射的Cache,这是MIPS架构早期使用的Cache原型。这种原型的好处就是简单直接好理解,也是后来Cache发展的垫脚石。它的工作方式就是,使用地址中的低位作为index,索引在Cache中的位置,也就是位于哪一行。当CPU发出某个地址后,使用地址中的高位与Cache中的tag位进行比较,如果相同,则称为”命中”;否则,”未命中”。命中,则将Cache中的数据拷贝到CPU寄存器中;如果没有命中,则重新从内存中读取数据,并将其加载到对应的Cache位置中。如果每行包含多个Word数据的话,则使用地址的最低几位进行区分。这样直接映射的Cache有优点也有缺点:优点是,一一直接映射,遍历时间肯定短,可以让CPU跑的更快。但同时也带来了缺点,假设你正在交替使用2个数据,而且这2个数据共享Cache中的同一个位置(它们地址中的低位刚好一样)。那么,这2个数据会不断地将对方从Cache中替换出来,效率将急剧下降。关于全关联Cache和直接映射Cache的概念可以参考文章Linux内核2-Cache基本原理如果是全关联高速缓存不会存在这种问题,但是遍历时间变长,而且设计复杂。折中的方案就是,使用一个2路关联Cache,效果相当于运行了两个直接映射的并行Cache。内存中的数据可以映射到这两路Cache中的任意一个上。如图4.2所示:如图所示,对于一个地址,需要比较两次。四路组相关联的Cache在片上高速缓存中也比较常见(比如ARM架构)。在多路相关联的Cache中,替换Cache中的哪个位置具有多路选择。理想的方法肯定是替换”最近最少使用”的缓存line,但是要维护严格个LRU原则,意味着每次读取Cache时,需要更新每个Cache line的LRU位。而且对于超过4路组相关联的Cache,维护严格的LRU信息变得不切实际。实际的缓存都是使用”最近最少填充”这样的折中算法来选择要替换的缓存line。当然,这也是有代价的。相比直接映射的Cache,多路组相关联的Cache在缓存芯片和控制器间需要更多的总线进行连接。这意味太大的Cache无法集成到单个芯片中,这也是放弃直接映射Cache的因素之一。当运行一段时间后,Cache肯定就会填满,再获取新的内存数据时,需要从Cache中替换数据出去。这时候就要考虑Cache和内存的一致性问题。如果Cache中的数据比内存中的新,就需要将这些数据写回到内存中。这就带来一个问题:Cache如何处理写操作。3 早期MIPS架构CPU的write-though缓存上面的讨论中,好像假定从内存中读取数据。但是CPU不只是读,还有写。write-though缓存就是不管三七二十一,数据总是写入内存中。如果数据需要在Cache中有一个备份,那么这个备份也要更新。这样做,我们不用管Cache和内存的数据是否一致。直接把Cache中替换出的数据丢弃即可。如果写数据很多,我们等到所有的写操作完成,会降低CPU的执行速度。不过这个问题可以修正,通过使用一个称为write-buffer(写缓冲器)的FIFO方式的缓存区保存所有要写入内存的数据。然后由内存控制器读取数据,并完成写操作。早期的MIPS处理器有一个直接映射的Cache和一个write-buffer。只要主存系统能够很好地消化这些以CPU平均速率产生的写操作即可。但是,CPU的发展速度太快了。很快,CPU的速度就超过了主内存系统可以合理消化CPU所有写操作的临界点了。4 MIPS CPU的回写高速缓存尽管早期的MIPS架构的CPU使用简单的透写Cache,但是,CPU的频率提升太快了,很快透写Cache就无法满足CPU写操作的需求,大大拖慢了系统的运行速速。解决方案就是把要写的数据保存到Cache中。且在对应的Cache行中标记dirty位。后面根据这个标志位再写回到相应的内存中。如果当前Cache中没有要写入地址对应的数据,我们可以直接把数据写到内存中,不用管高速缓存。5 高速缓存设计中的一些其它考虑上世纪的80年代后期和90年代初期,研究人员开始研究如何构建Cache。下面我们看看,Cache的发展都经历了哪些历程:关于物理寻址/虚拟寻址:我们知道,CPU正在运行OS时,应用程序的数据和指令地址都会被转换成物理地址,继而访问实际的物理内存。如果Cache单纯地工作在物理地址上,很容易管理(后面我们再介绍为什么)。但是,虚拟地址需要尽快的启动Cache遍历才能使系统运行的更快些。虚拟地址的问题在于,它们不是唯一的:运行在不同地址空间的应用程序可以共享某段物理内存而存储不同的数据。每当我们在不同地址空间进行上下文切换时,都需要重新初始化整个Cache。在过去的许多年,对于小容量的Cache,这都是常见的处理方式。但是,对于大容量的Cache,这不太高效,而且还要在Cache的tag中包含一个标识地址空间的标识符。对于L1级Cache,许多MIPS架构CPU使用虚拟地址作为快速索引,使用物理地址作为Cache中的tag,而不是使用虚拟地址+地址空间标识符作为cache行的tag标签。这样的设计,Cache行中的物理地址是唯一的,CPU在遍历Cache的同时也就完成了虚拟地址到物理地址的转换,采用物理地址作为tag,还有一个微妙的问题:不同任务中的地址不同(虚拟地址不同)。因为使用虚拟地址作为Cache索引,所以相同的物理地址有可能在Cache中存在不同的项。这样的现象称为Cache重影(Cache alias)。许多MIPS架构的CPU硬件上没有这种检测机制,避免Cache重影,而是留待OS内存管理者去解决这个问题。关于Cache行大小的选择:Cache行大小指的是一个tag标签对应的32位长度的数据个数。早期MIPS的缓存使用一个tag对应一个word的方式,但是,通常每个tag对应多个word数据更有利,尤其是内存管理系统支持burst读取方式时。现在MIPS架构CPU倾向于使用4个word大小或者8个word大小的Cache行。但是对于L2和L3级Cache来说,它们会使用更大的行。如果发生Cache丢失,整个行都会被填充。关于指令和数据Cache:MIPS架构L1级Cache总是分为I-Cache和D-Cache。这是因为指令和数据的性质决定的,指令是只读的,数据是可读写的,分开为两个Cache可以提高读写效率。但是,L2级缓存很少这样划分,毕竟硬件过于复杂且昂贵。6 管理Cache在之前的文章中,我们已经知道MIPS架构的CPU有两个固定大小的512MB的内存空间可以映射到物理内存上。其中,一个称为kseg0,另一个称为kseg1。通常,OS运行在kseg0上,而使用kseg1构建那些不需要经过Cache的物理内存访问。如果物理内存的实际空间大于512MB,其超出部分使用这两个内存空间是无法访问的。当然,你可以设置TLB(转换表)进行地址映射,每个TLB项都有标志表明是否经过Cache。要想在程序中使用Cache,必须经过正确的配置,保证有无Cache,对于物理内存的访问,尤其是DMA访问,都必须是正确的。X86架构和ARM架构,Cache的管理是硬件实现的,无需编程人员的干预Cache的一致性。不幸的是,MIPS架构因其设计理念不同,Cache还需要编程人员通过代码保证其一致性。上电后,Cache的内容是随机的,必须进行初始化才能够使用。一般情况下,引导程序负责这部分初始化工作,这是一个非常复杂的配置过程。一旦CPU运行起来,只有三种情况需要CPU进行干预,如下所示:DMA设备从内存读取数据之前:假设一个外设使用DMA方式从内存读取数据,这一步就非常重要了。如果D-Cache是write-back类型的,有程序在DMA访问之前修改了D-Cache中的内容,还没有写回到内存中;恰巧这些数据是DMA访问的数据。这种情况下,CPU无法知晓它应该从Cache上获取最新数据。所以,在DMA设备启动从内存读取数据之前,如果所访问的数据在Cache中,必须写回到内存中,不论是不是被修改过。DMA写数据到内存中:如果DMA设备想要加载数据到内存中,失效Cache中相关数据是非常重要的。否则,CPU读到的就是过时的数据。必须在CPU使用DMA数据之前失效Cache项,但常见做法是DMA启动之前就失效Cache。指令写入:当CPU写指令代码到内存中,然后再执行它们。所以,在运行之前,必须保证所有的指令都写回到内存中,并且保证失效对应的I-Cache项。MIPS架构CPU的D-Cache和I-Cache没有任何关系。在最新的CPU上,一个用户特权级的指令synci保证必要的同步,使刚刚保存的指令可以运行。现代CPU都在朝着硬件管理的方向发展,所以,这一部分仅供参考。7 L2和L3两级CacheL1级Cache一般小而快速,与CPU核紧密相关;L2级和L3级Cache在容量和访问速度上介于内存和L1级Cache之间。目前为止,一般就使用到L2级缓存。8 Loongson2k1000的Cache配置lonngson2k1000的结构示意图如下所示:一级交叉开关连接两个CPU核(C0和C1)、两个二级Cache(S0和S1)以及IO子网络(Cache访问路径)。二级交叉开关连接两个二级Cache、内存控制器(MC)、启动模块(SPI或者LIO)以及IO子网络(Uncache访问路径)。IO子网络连接一级交叉开关,以减少处理器访问延迟。IO子网络中包括需要DMA的模块(PCIE、GMAC、SATA、USB、HDA/I2S、NAND、SDIO、DC、GPU、VPU、CAMERA 和加解密模块)和不需要DMA的模块,需要DMA的模块可以通过Cache或者Uncache方式访问内存。9 对MIPS32/64高速缓存的编程兼容MIPS32/64架构的CPU一般具有write-back功能的高速Cache。要想对Cache进行编程,硬件必须具有下面的操作可能:失效某个Cache区域:将某个地址范围内的数据清除出Cache,这样下一次引用的时候就会从内存中重新读取数据。指令cache HitInvalidate形式和load/store指令一样,给出一个虚拟地址。将虚拟地址所引用的Cache行失效。在地址范围内每个一个Cache行大小的地址上重复该命令。回写某段内存:将该地址范围内的已经被修改的Cache行写回到主内存中。cache HitWritebackInvalidate将虚拟地址引用的数据所在的Cache行写回到主内存中,并失效这个Cache行。失效整个Cache:指令为cache IndexInvalidate。其虚拟地址作为索引参数,按照Cache行大小从0开始递增。初始化Cache:设置tag通常涉及到对CP0 TagLo寄存器清零,以及对每一个Cache行执行Cache IndexStoreTag指令操作。数据校验位的预填充可以使用指令cache Fill。9.1 Cache指令Cache指令的使用方式跟load/store指令类似,使用通用寄存器+16位有符号地址偏移的方式。但是添加了一个操作域(5位),用来决定操作那个Cache,如何查找line,以及对line做什么处理。你可以使用汇编直接编写相应的操作,最好还是使用宏定义之类的C方法,更为方便和易于阅读。操作域中的高2位指定操作哪个Cache:0 = L1 I-cache 1 = L1 D-cache 2 = L3 cache, if fitted 3 = L2 cache, if fitted将Cache相关命令可以分为3类:hit型操作:给出一个地址,从Cache中查找,如果找到则执行操作;否则什么也不干。寻址型操作:如果寻址的数据不在Cache中,则从内存中加载对应的数据到Cache中。索引型操作:根据需要用虚拟地址的低位选出在Cache行中的一个字节,然后是Cache某路中的Cache行地址,然后就是第几路。具体格式如下,所示一旦选中了某行,CPU所能做的操作如下表4-2所示。但是,与MIPS32/64兼容的CPU必须实现基本的三个操作:索引型失效、Index Store Tag、和Hit Writeback invalidate。表4-2 Cache行上的可用操作取值命令功能0Index invalidate失效行。如果Cache行被修改过,则将数据写回到内存。当初始化Cache的时候,这是最好的方法。如果Cache被奇偶保护,还需要填充正确的校验位。参见下面的Fill命令。1Index Load Tag读取Cache行的Tag,数据加载到TagLo/TagHi寄存器。几乎不用2Index Store Tag设置tag。初始化时必须每行都执行该操作。3Hit invalidate失效但不回写数据。有可能会丢失数据。4Hit Writebackinvalidate失效且回写数据。这是常用失效Cache方法。5Fill填充Cache行的数据位。奇偶保护时使用。6Hit writeback强制回写内存。7Fetch and Lock正常的加载Cache操作。9.2 Cache初始化和Tag/Data寄存器为了诊断和维护,最好能够读写Cache的Tag标签。为此,MIPS32/64定义了一对32位寄存器:TagLo和TagHi,使用它们对Cache的Tag部分进行管理。大部分时候TagHi寄存器不是必须的:除非你的物理内存地址空间超过36位;否则,只使用TagLo寄存器就足够了。有些寄存器可能使用不同的寄存器分别与I-Cache和D-Cache进行通信,比如ITagLo等。这两个寄存器反映了Cache的Tag标签位,具体实现依赖于CPU的实现。唯一可以保证的事情是:如果寄存器中的值全为0,代表一个合法的数据Tag,表示相应的Cache行中不含有效数据。所以,这是一个初始化Cache的方法:设置寄存器的值为0,存储Tag值到Cache中,就可以使Cache从未知状态进入初始化状态。MIPS32/64定义了store和load两种Cache操作,但它们是可选的,具体依赖于CPU实现。Tag包含Cache索引之外的所有必须位。所以,L1级Cache的Tag长度等于物理地址位数减去L1级Cache索引的位数(对于一个16K大小的4路组相关联Cache来说,索引的位数是12位。)。TagLo(PTagLo)可以容纳24位,这样一个高速Cache最多可以支持36位。TagLo(PState)包含状态位。9.3 CacheErr、ERR和ErrorEPC寄存器:Memory/Cache错误处理CPU的Cache是内存系统的重要组成部分。对于高可靠性系统来说,监视Cache数据完整性是非常有必要的。数据完整性检查理想上来说,应该是end-to-end:数据产生或者进入系统时就一起计算校验位,一起存放;在数据使用之前,再次执行检查。这样的检查不仅能够发现存储器的错误而且能够发现复杂的总线错误,和数据与CPU的交互过程中产生的错误。基于这个原因,MIPS架构CPU通常在Cache中提供错误检查。如同主内存一样,既可以使用简单的奇偶校验,也可以是复杂的ECC错误纠错码。MIPS架构的CPU内存系统是以8位宽度为最小处理单元,所以内存模块提供64位数据检查和8位校验位。也可以将数据校验简单理解为,系统每8个字节产生一个额外的位。一个字节的错误数据有50%的概率产生正确的校验位,所以,在64位总线上的随机垃圾数据及其校验位每256次中将有一次逃脱检测。有些系统要求比这更高。ECC的计算更为复杂,因为它涉及整个64位数据和8位校验位。ECC是排除内存系统中随机错误的一个强大工具。理想情况下,存储系统采用什么样的校验机制,Cache也应该采用相同的机制。根据CPU的不同,可以采用奇偶位、纠错码ECC,或者什么都不做。如果发生错误,CPU产生特殊的错误陷阱。这时,如果Cache包含坏数据,异常向量位于非Cache的地址空间上(Cache发生了错误,还在上面运行是不是很愚蠢?)。如果系统使用ECC,硬件要么自己纠正错误,要么提供给软件足够多的信息去修复数据。CacheErr寄存器的域取决于具体实现,需要查阅CPU手册。9.4 设置Cache大小和配置对于兼容MIPS32/64架构的CPU,Cache大小,Cache结构,行大小都可以通过协处理器0(CP0)的Config1-2寄存器获取。9.5 初始化程序通用的初始化程序示例:开辟一些内存用来填充Cache,至于内存中存储什么数据没有关系。如果开启了奇偶校验或ECC功能,还需要填充正确的奇偶校验位。一个很好的技巧就是保留内存的低32K空间,用来填充Cache,直到完成Cache初始化。如果使用不经过Cache的数据填充,自然包含正确的校验位。对于较大的L2级Cache,这个32K的空间可能不够,需要做一些其它的处理。将TagLo寄存器设为0,对Cache进行初始化。cache IndexStoreTag指令使用TagLo寄存器强制失效一个Cache行并清除Tag奇偶位。禁止中断先初始化I-Cache,然后是D-Cache。下面是I-Cache初始化的C代码部分。for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) /* 清除Tag并失效 */ Index_Store_Tag_I (addr); for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) /* 填充一次,数据校验是正确的 */ Fill_I (addr); for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) /* 再次失效,谨慎但并非严格必要 */ Index_Store_Tag_I (addr);D-Cache初始化代码。D-Cache侧没有fill等价的操作,所以只能使用Cache空间对其进行加载,完全依赖于正常的miss处理过程。/* 清除所有的Tag */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr); /* 加载每行(使用Cached地址空间)*/ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) junk = *addr; /* 清除所有Tag */ for (addr = KSEG0; addr < KSEG0 + size; addr += lnsize) Index_Store_Tag_D (addr);9.6 失效或回写Cache对应的存储区这一步时,注意访问的地址参数一定是合法的,比如一些I/O设备内存的地址或物理内存地址。几乎总是使用命中型操作来失效Cache或者回写内存。大于大块地址空间,也许使用索引型操作更快。这个需要自己选择。简单示例:PI_cache_invalidate (void *buf, int nbytes) { char *s; for (s = (char *)buf; s < buf+nbytes; s += lnsize) Hit_Invalidate_I (s); }只要buf是程序地址,就没有必要使用特殊地址。如果直接使用物理地址p来进行失效,只要p在物理空间的低512M空间内,加上常量0x80000000就对应kseg0地址空间:PI_cache_invalidate (p + 0x80000000, nbytes);10 Cache效率问题(这一部分的内容主要是芯片设计者应该考虑的问题)据研究表明,CPU的性能很大程度上取决于其Cache的性能。尤其是嵌入式系统,需要在Cache和内存性能上节约。CPU大概有50%-65%的时间在等待Cache重填。而系统等待Cache重填的时间取决于两个因素:平均指令的Cache未命中率:Cache未命中数除以执行的指令数。其实,每千条指令的Cache未命中数是一个更有用的度量标准。Cache未命中/重填的开销:内存系统重填Cache并重启CPU执行所花费的时间。我们为什么把Cache的未命中率定义为平均指令的Cache未命中率,而不是平均CPU访问内存的未命中数。那是因为Cache未命中率的影响因素有很多,有一些甚至无法预料。比如,x86架构CPU的寄存器个数比较少,同样的程序,基于x86架构编译就比MIPS架构编译多产生load和store操作(比如,函数传递较多参数时)。但是,x86编译器会将额外的load和store操作作用到堆栈上,代替使用寄存器。那么这一小段内存的使用率就会非常高,Cache的效率也就更高。所以,总体上来说,每千条指令的未命中数受影响的差别没那么大。下面我们列举一些提高系统运行的方法:降低Cache未命中数增大Cache。但是代价高昂。64K大小的Cache可能比CPU其它所有部分(不包含FPU浮点单元)所占据的硅面积都大。增加Cache的组关联度。但是4路组Cache之后,再增加对性能几乎影响不大。再增加一级Cache。代价也很大。这也是目前都是两级Cache的原因。通过软件降低Cache未命中数。小程序容易实现,规模化的程序很难实现。降低Cache重填的开销尽可能快的重启CPU执行不到万不得已不要停止CPU执行:多线程化CPUCache-miss延时对程序性能的不利影响可以被抑制但是无法避免。所以,更为有效的方法就是在CPU上运行多个线程,这样可以在彼此等待期间使用空闲的CPU资源。11 软件对Cache效率的影响在运行大量应用程序的复杂系统中,Cache性能是一个性能平衡的过程,不太好优化软件提高执行效率。但是对于单个应用程序的嵌入式系统中,可以通过一些特殊的处理提高其性能。在分析之前,我们先把Cache未命中数按照产生的原因进行分类:第一次访问必然都是cache-miss。替换不可避免,在程序的运行过程中,需要不断地从Cache中替换、重填数据。抖动在四路组相关联的Cache中(更多路的情况在MIPS架构CPU中很少见),有四个位置可以保存特定内存位置上的数据。(对于直接映射Cache,仅有一个)。假设你的程序大量使用几段地址的代码,这些地址有一个特点就是它们的低位都非常接近,也就是使用相同的Cache行。那么如果这几段地址的个数大于Cache的组关联度时,它们之间就会频繁发生互相替换的情况,从而降低系统性能。(大多数研究表明,大于四路组关联的Cache,性能基本上不再提高)。通过上面的了解,解决Cache效率的可以分为下面几个方面:使程序更加精简这应该是一个程序员的至高境界。但是很难做到。大部分时候也就是通过编译优化选项实现,但是过度优化往往会增大程序。使频繁调用的代码更加短小一种方法是,分析代码在典型应用下的使用最频繁的函数,然后按照函数的执行时间递减顺序在内存中排列函数。这意味着频繁使用的函数不会相互争夺Cache的位置。强制一些重要常用的代码常驻Cache该方法怀疑中。总之一句话,软件要提高Cache的效率太难了,让硬件去实现吧。12 Cache重影这个问题是怎么产生的呢?这是因为,对于MIPS架构的CPU的L1级缓存来说,通常使用虚拟地址作为索引,物理地址作为Tag标签。这样可以提高性能:如果我们使用物理地址作为索引,只有等通过TLB转换成实际的物理地址后,我们才能遍历Cache。但是这导致了Cache重影。大部分CPU转换地址的单元是4K大小。这意味虚拟地址的低12位无需转换。只要你的Cache不超过4KB,虚拟地址的索引和物理地址的索引是相同的,这是OK的。其实,只要是Cache的跨度单元不超过4KB就没问题。在组相关联的Cache中,每个索引访问几个内存点(一路对应一个内存点)。所以,即使是16kB大小的Cache,虚拟地址的索引也是与物理地址的索引相同的,不会发生Cache重影。但是设想一下,如果Cache的索引范围达到8K的情形(32k四路组相关联的Cache就是这样)。你可能会从两个不同的虚拟地址访问同一个物理页:例如虚拟地址可能是连着的两页-地址分别从0和4K处开始。这样,如果程序访问地址0处的数据,就会加载该数据到某个Cache索引为0的位置。如果此时再从另一个地址4K处访问同一个物理内存上的数据,再次从内存中取出数据加载到Cache索引为4K的位置。现在,对于同一数据在Cache中存在两个备份,彼此之间无法知晓。这就是Cache重影。如果仅仅是只读还好。但是,如果正在写的数据重影就很可能导致灾难性的后果。MIPS架构的L2级Cache总是使用物理地址索引和Tag,所以不会存在Cache重影。总结来说,重影的出现是L1级Cache在设计之初就引入的一个潜在问题。但是,如果使用了L2级Cache,就不会出现问题。如果没有使用L2级Cache,则只要保证虚拟地址到物理地址的映射间隔是64K的整数倍既可以。
边缘计算中将大量算力分拆到各个边缘节点上,这让工程师们不得不想象,是否容器和无服务器框架能够在边缘设备上取代虚拟化。现在,云服务/云计算的应用如火如荼,大量的应用也带来算力和数据安全的压力。于是,大家纷纷将云计算向网络的边缘迁移,更加靠近数据的源头。这些边缘计算大部分都运行在虚拟环境下,但是,也有人质疑:虚拟化边缘计算服务器是否有意义?边缘计算的确切含义和实现方式还有争论。有些人从智能设备的视角理解边缘计算,而有些人则认为中间的网关设备是边缘计算的主战场,甚至,还有些人认为边缘计算应该是成千上万的微数据中心。尽管大家对边缘计算的部署场景认识不一致,但是,无一例外都认可边缘计算应该尽可能靠近数据的源头。边缘计算和应用于数据中心的云计算有着很大不同。如果把云计算比作个人通用PC,那么,边缘计算可以认为是嵌入式设备。所以,边缘计算注定不是一个可以统一的模型,而是根据具体应用场景下的多技术融合。比如说,大部分时候管理员只能通过远程控制实现边缘计算服务器的管理;再比如,边缘节点通常具有空间和功耗等严格限制,很难向已有的系统中添加容量或者修改已有的架构;甚至有时候,边缘节点还需要特殊的硬件设备去连接其它的边缘节点。移动计算和IoT的蓬勃发展,必然推动计算向边缘节点迁移。海量数据的产生,导致传统的大数据中心数据延时和网络带宽瓶颈的问题被放大,且不能应用于边缘计算。与此同时,新兴技术的发展使得边缘计算实现成为可能,甚至比云计算更为高效经济。因为边缘计算解决了集中式模型的局限性。边缘计算服务器和虚拟化为了使边缘计算更为高效,一些团队开始直接在硬件上运行容器或无服务器架构,避免使用hypervisor和VM带来的负荷问题。某些情况下,这可能是好事。但是,即使是在边缘节点上,虚拟化技术也有其优点:灵活、安全、可维护性和资源利用率高等。所以,在未来,虚拟化仍然会是边缘计算场景下一项重要的技术,至少对于路由网关或微数据中心是这样的。即使,应用程序运行在容器中,它们仍可以运行在VM之上。不仅仅是研究学者把VM视为边缘计算的基本组件,相信通过VM可以实现更快的配置服务和在服务器之间迁移应用。在产业界,比如风河公司(Wind River)的开源项目-StarlingX:将Titanium Cloud项目的一些组件通过OpenStack基金会进行管理而开源。该项目的目标之一就是解决边缘计算服务器的虚拟化需求。代码包含一个虚拟化基础设施管理器(VIM)以及VIM辅助组件。VMware也在致力于边缘计算,提供由vSAN支持的超聚合基础设施软件。商家可以通过VMware vSphere和VMware Pulse IoT Center使用该软件支持边缘计算场景。提供了一个安全、企业级IoT设备管理和监控的系统。当然了,还有许多其它厂商正在发展边缘计算。虽然,边缘计算并不意味着虚拟化,但是也绝不是排斥虚拟化,事实上,我们应该拥抱虚拟化。边缘管理伴随着边缘计算的兴起,对虚拟环境的管理也遇到了许多挑战。缺乏行业标准,无疑加剧了管理的复杂性。把计算资源从数据中心迁移到网络边缘后,无疑增加了设备和应用的管理难度,尤其是那些需要远程控制的设备。管理人员必须找到部署这些系统、执行可持续维护和监视基础设施与应用程序的性能问题和故障点的方法,并能够解决容错和灾难恢复等问题。如果只是管理一个边缘计算环境,并不困难。难的是如何管理多个边缘计算环境,为其配置为不同功能的平台。例如,有些系统可能运行VM,有一些可能运行容器,还有的可能两者兼而有之。有些系统运行在不同的硬件上,使用不同的API和协议,运行不同的应用和服务。管理员必须能够协调所有的边缘计算环境,还要允许它们独立操作。边缘计算还处于技术摸索阶段,整个网络管理能力还没有跟上。但是,边缘计算的管理还不是唯一的挑战。用于边缘计算的服务器往往还有资源限制,使得更改物理架构和平衡工作负载的波动变得很困难。这些挑战往往只靠VM无法战胜。另外,边缘计算还要面临着不同资源设备之间/不同边缘系统之间的互操作问题。而且现在也没有行业标准,配置也不同,使得边缘计算管理更加困难。边缘计算还有一个重要的挑战就是,如何保证敏感数据和隐私的安全。边缘计算分布式的特点,使得攻击维度增多,整个网络系统更容易受到破坏,不同的配置也增加了风险。比如,一个系统运行在容器中,其它的可能运行在裸机程序上,从而导致保证安全的方法存在差异。分布式特点还使得解决遵守法律和法规的问题变得困难。而且,边缘计算还带来可能未被发现的入侵风险。
1 引言1.1 什么是协处理器0前面我们已经对MIPS架构CPU有了粗略的了解。显然,它提供了众多优秀的功能。但是,应用的场景不同,往往需要CPU做的事情也不一样,这就需要必须能够对CPU以及它提供的功能进行有选择的配置。这是协处理器诞生的根本原因。ARM架构也使用协处理器进行控制,称为协处理器15,(cp15)。MIPS架构CPU使用协处理器0进行CPU的配置和管理。那么,它到底能够干什么呢?CPU配置Cache控制异常、中断控制:中断或异常发生时的行为和处理的定义。内存管理单元控制其它工作:定时器(timer)、事件计数器(event)、奇偶/错误校验。一些与CPU紧密相关,而又不便通过I/O进行访问的功能,都会被添加到协处理器0中进行控制。1.2 包含的寄存器对于相关的寄存器,在此,不再详述。使用时,参阅相关的数据手册即可。2 CPU控制指令2.1 写CPU控制寄存器的指令mtc0 s, <n> # 把数据拷贝到协处理器0这条指令的作用是把通用寄存器s中的值拷贝到协处理器的寄存器n中,数据位数是32位。大部分的协处理器寄存器是32位的,对于少数的64位协处理器寄存器可以使用dmtc0指令进行操作。这是设置CPU控制寄存器的唯一方法。32位架构的时候,最多有32个协处理器寄存器。但是MIPS32/64架构扩展到了256个寄存器,为了向前兼容,在指令中添加select域来控制多个寄存器。比如mtc0 s, $12, 1select域的值等于1,其作用就是把通用寄存器s的值写入到协处理器的寄存器12组中的编号为1的寄存器中。也就是说,寄存器12可以有多个具体的寄存器,使用select域选择对应的哪个寄存器。2.2 读取CPU控制寄存器的指令mfc0 d, $n # 把协处理器第n个寄存器中的值写入到通用寄存器d中上述指令的作用是把协处理器0中的第n个寄存器中的内容读取到通用寄存器d中。2.3 特殊的控制指令eret所有的架构的CPU在面对特权等级切换的时候(一般就是异常返回时),都会面临一个问题:一方面,在返回用户态程序之前就降低特权等级,那么会立即引发一个异常指令访问的二次异常;另一方面,如果返回到用户程序之后再降低特权等级,那么可能会被恶意程序利用内核态运行某些指令。解决这个问题的办法就是,保证异常返回时的指令是原子操作。MIPS架构的CPU提供了这个指令eret。3 特殊寄存器的使用场景上电后:需要设置SR寄存器,使CPU进入一个可工作的状态。处理异常:在异常入口处,不会保存任何程序计数器,只把返回地址存入EPC寄存器中。MIPS架构CPU硬件对于堆栈一无所知,所以发生异常时,无法打印堆栈中的数据。(ARM和X86硬件可以保存堆栈,所以,发生异常时,可以打印堆栈中的关键数据)。对于MIPS架构,程序发生异常时,只能看EPC寄存器中的值,然后通过反汇编得到执行代码的地址,从而获取到导致异常的代码大概位置。充分利用异常发生时的信息,是调试程序的一种有效手段。MIPS架构也为异常处理程序保留了2个寄存器v0和v1。我们的程序可以把一些异常需要的重要信息保存在这儿。但是,通用寄存器极易发生变化,大部分时候,这两个寄存器不建议使用。可以通过查看Cause寄存器,判断属于哪类异常,从而做相应的处理。从异常返回时:保存返回地址到EPC寄存器中。不论是何种异常,返回时,都要恢复SR寄存器和特权等级、使能中断并消除异常带来的影响。最后eret指令返回用户程序并复位SR(EXL)寄存器。中断:通过SR寄存器中的中断控制位,可以设置哪些中断具有更高的优先级。虽然,MIPS架构硬件没有提供中断优先级,但是软件可以任意设置。一些特殊的指令:比如系统调用(syscall)和调试断点(break),还有一些CPU实现了一些特殊的指令。4 CP0协处理器操作时可能发生的问题我们知道CPU的指令是按照流水线的方式执行。有可能,操作协处理器的指令还没执行彻底,其它指令就已经开始执行了。如何才能保证CP0的操作生效后,再执行相关指令呢?因为MIPS架构的设计理念是 硬件尽量简单,辅以软件实现。所以,早期的软件开发人员使用nop操作,保证操作协处理器的正确性。但是,这无疑增加了软件开发人员的难度。于是,MIPS32/64架构定义了新的指令:避险指令。三个避险指令:ehb指令消除执行危险。早期的MIPS架构CPU把这个当做一个nop操作。jr.hb和jalr.hb指令跳转寄存器指令,用来消除指令危险。最常见的使用方式就是替换普通的子程序返回和子程序调用指令。旧架构上,这两个指令还是会被解释成jr和jalr指令。在这些CPU上,指令会清除CPU的管道流水线。而且大部分时候,对于不遵守MIPS32/64架构规范的CPU还会提供必要的延时。4.1 指令危险指令危险和用户危险通常发生在改变CP0状态的时候(比如,改变某个寄存器、TLB项、或者一个cache行),这会影响我们普通的取值指令(在某些情况下,还会影响load/store指令访问内存的方式)。我们必须规避这种不可控的风险。在改变CP0操作之后,添加危险屏障指令,消除这种可能产生的不可控的危险。这类危险都有:改变TLB项:在受影响的内存页上取指、加载和存储数据。改变EntryHi寄存器(ASID域)非全局映射内存区域上的取指、加载和存储数据。改变到ERL模式从kuseg内存区域取指、加载和存储数据。cache指令改变cache行在受影响的line上取指、加载和存储数据。改变watchpoint寄存器在匹配的地址上取指、加载和存储数据影子寄存器设置发生改变任何使用通用寄存器的情况(执行危险)修改CP0寄存器,禁止中断仍然能够被中断的指令(异常危险)它们中大部分都是指令危险,可以使用jr.hb或jalr.hb指令避免这种指令危险。4.2 CP0指令间的危险mfc0、tlbwi、tlbwr、tlbr指令、读取CP0的cache指令以及tlbp指令都依赖于CP0寄存器中的值。所以,这些指令执行时,有可能发生执行危险。为了保证安全,可以在可以在读取CP0寄存器值的指令之前,添加ehb指令。
架构这个词,英文是architecture,牛津词典对其解释为the design and structure of a computer system。所以,这个词体现的是设计和结构,也就是说,是一个抽象机器或通用模型概念上的描述,而不是一个真实机器的实现。这就好比一辆手动挡车,无论是前轮驱动还是后轮驱动,它的油门总是在右,离合器在左。这里,油门和离合器的位置就相当于架构,前轮还是后轮驱动是具体实现。所以,相同的架构,实现未必相同。当然了,如果你是一个拉力赛车手,在湿滑的路上高速行驶时,前轮驱动还是后轮驱动就很重要了。计算机也是一样,如果对某个方面有特殊的需求,实现的细节就很重要了。通常,CPU架构由指令集和寄存器组成。术语-指令集和架构在语义上非常接近,所以,有时候你也会见到这两个词的组合缩写-指令集架构(ISA)。对于MIPS指令集架构描述最好的,肯定是MIPS公司出版的MIPS32和MIPS64架构规范。MIPS32是MIPS64的一个子集,用于描述具有32位通用目的寄存器的CPU。为了简单,我们缩写为MIPS32/64。生产MIPS架构CPU的公司,尽量兼容MIPS32/64规范。在MIPS32/64规范之前,已经发布了多版的MIPS架构。但是,这些旧架构只是规定了软件使用的指令和资源,并没有定义操作系统所需要的CPU控制机制,而是将其认为应该在实现时定义。通俗地讲,早期版本的MIPS架构对CPU控制单元的硬件实现不做约束,由芯片制造商在实现时自己实现。这意味着,对于可移植操作系统需要做更多的工作,去适配因此而带来的差异。好消息是,几乎每一个版本的MIPS架构,都有一个作为所有实现的父版本存在。MIPS I:最早的32位处理器(R2000/3000)使用的指令集,几乎每一个MIPS架构CPU都可以运行这些指令。MIPS II:为没有投产的MIPS-R6000机器定义的指令集。MIPS-II是MIPS32的前身。MIPS III:为R4000引入的64位指令集。MIPS IV:在MIPS-III基础上添加了浮点指令,R10000和R5000硬件实现中使用。MIPS V:添加了2个奇怪的SIMD浮点操作指令,但是没有具体的CPU实现。大多是作为MIPS64架构的可选部分-单精度对(paired-single)-出现。MIPS32、MIPS64:1998年,由从Silicon Graphics公司分拆出来的MIPS Technologies Inc.公司制定的标准。该标准第一次纳入了CPU控制的功能,由协处理器0实现。MIPS32是MIPS-II的超集,MIPS64是MIPS-IV的超集(还以可选的方式包含了MIPS-V的大部分)。大多数1999年之后设计的MIPS架构CPU都兼容这些标准。所以,在后面的描述中,我们使用MIPS32/64作为基础架构。到目前为止,MIPS32/64规范已经发布到了第6版。指令集扩展的规范化—ASE我们一直强调,RISC和保持指令集小没有关系。事实上,RISC的简单性,更容易让人进行扩展。随着MIPS架构的CPU出现在嵌入式系统中,许多新的指令如雨后春笋般地冒出来。MIPS32/64吸收了一些,同时也提供了一种扩展机制ASE(Application-Specific instruction set Extensions)。ASE作为MIPS32/64的扩展存在,可以通过配置寄存器进行选择。下面是一些选项:MIPS16e:类似于ARM架构的thumb指令的一种扩展。是一种旧扩展。基本不用。MDMX:类似于英特尔的MMX扩展的早期版本。但是,MDMX从来没有实现。SmartMIPS:为提高MIPS架构的加密性能而扩展的一个模块。这个功能扩展还是比较有用的,尤其是在当下这个特别注重数据安全的时代。MT:将硬件多线程技术添加到MIPS核中。2005年,第一次出现在MIPS公司的34-K系列产品中。DSP:音视频处理指令,将饱和和SIMD算法运用到小整数上。看上去比MDMX更有用。2005年,开始在MIPS公司的24-K和34-K系列中推出。MIPS32/64规范还有一些可选项,它们不能被看作为指令集的扩展:浮点单元:协处理器1控制。CP2:协处理器2,用户自定义。很少有人使用。CorExtend:用户自定义指令集。2002/2003年大肆炒作的一个概念,ARM和Tensilica公司也宣布支持。EJTAG:调试工具。单精度对:浮点单元的扩展,提供SIMD操作。每条指令可以同时操作2个单精度值。MIPS-3D:通常和单精度对结合使用,提供了一些指令,用于3D场景渲染时的浮点矩阵运算。2.1 MIPS汇编语言的风格初探本部分对汇编语言只做一个简单的介绍,详细的理解后面会再展开。我们或多或少地已经接触过汇编语言,下面是MIPS架构的一小段汇编代码:# 注释 entrypoint: # 标签 addu $1, $2, $3 # 基于寄存器的加法,等价于 $1 = $2 + $3跟大部分的汇编语言一样,基于行的分割语言。原生注释符号是#,编译器会忽略掉#后面的所有文本。但是可以在一行中插入多条语句,使用;进行分割。标签(label)使用:开始,可以包含各类符号。标签可以定义代码的入口点和数据存储的开始位置。MIPS汇编程序可以使用数字标记的通用寄存器,也可以使用C语言的预处理器和一些标准头文件,这样就可以使用寄存器的别称(关于别称请参考下一节)。当然了,如果使用C预处理器,注释也可以使用C风格。大多数指令是三目运算指令,目的寄存器在左边(与X86相反)。subu $1, $2, $3代表的表达式是:$1 = $2 - $3;目前,了解这些就足够了。2.2 寄存器MIPS有32个通用寄存器($0-$31),各寄存器的功能及汇编程序中使用约定如下:下表描述32个通用寄存器的别名和用途寄存器别名使用$0$zero常量0$1$at保留给汇编器$2-$3$v0-$v1函数返回值$4-$7$a0-$a3函数调用参数$8-$15$t0-$t7临时寄存器$16-$23$s0-$s7保存寄存器$24-$25$t8-$t9临时寄存器$26-$27$k0-$k1保留给系统$28$gp全局指针$29$sp堆栈指针$30$fp帧指针$31$ra返回地址详细的寄存器使用说明$0:即$zero,该寄存器总是返回零,为0这个有用常数提供了一个简洁的编码形式。比如,下面的代码:move $t0,$t1(伪指令常用)实际为:add $t0,$0,$t1使用伪指令可以简化任务,汇编程序提供了比硬件更丰富的指令集。$1:即$at,该寄存器为汇编保留。由于I型指令的立即数字段只有16位,在加载大常数时,编译器或汇编程序需要 把大常数拆开,然后重新组合到寄存器里。比如加载一个32位立即数需要 lui(装入高位立即数)和addi两条 指令。像MIPS程序拆散和重装大常数由汇编程序来完成,汇编程序必需一个临时寄存器来重组大常数,这也是为汇编 保留$at的原因之一。$2..$3:($v0-$v1)用于子程序的非浮点结果或返回值。对于子程序如何传递参数及如何返回,MIPS范围有一套约 定,堆栈中少数几个位置处的内容装入CPU寄存器,其相应内存位置保留未做定义,当这两个寄存器不够存 放返回值时,编译器通过内存来完成。$4..$7:($a0-$a3)用来传递前四个参数给子程序,不够的用堆栈。a0-a3和v0-v1以及ra一起来支持子程序/过程调用,分别用以传递参数,返回结果和存放返回地址。当需要使用更多的寄存器时,就需要堆栈了,MIPS编译器总是为参数在堆栈中留有空间以防有参数需要存储。$8..$15:($t0-$t7)临时寄存器,子程序可以使用它们而不用保留。$16..$23:($s0-$s7)保存寄存器,在过程调用过程中需要保留(被调用者保存和恢复,还包括$fp和$ra)。MIPS提供了临时寄存器和保存寄存器,这样就减少了寄存器溢出(spilling,即将不常用的变量放到存储器的过程), 编译器在编译一个叶(leaf)过程(不调用其它过程的过程)的时候,总是在临时寄存器分配完了才使用需要 保存的寄存器。$24..$25:($t8-$t9)同($t0-$t7)$26..$27:($k0,$k1)为操作系统/异常处理保留,至少要预留一个。异常(或中断)是一种不需要在程序中显示调用的过程。MIPS有个叫异常程序计数器(exception program counter,EPC)的寄存器,属于CP0寄存器,用于保存造成异常的那条指令的地址。查看控制寄存器的唯一方法是把它复制到通用寄存器里,指令mfc0 (move from system control)可以将EPC中的地址复制到某个通用寄存器中,通过跳转语句(jr),程序可以返 回到造成异常的那条指令处继续执行。MIPS程序员都必须保留两个寄存器$k0和$k1,供操作系统使用。发生异常时,这两个寄存器的值不会被恢复,编译器也不使用k0和k1,异常处理函数可以将返回地址放到这两个中的任何一个,然后使用jr跳转到造成异常的指令处继续执行。$28:($gp)为了简化静态数据的访问,MIPS软件保留了一个寄存器:全局指针gp(global pointer,$gp)。全局指针只存储静态数据区中的运行时决定的地址,在存取位于gp值上下32KB范围内的数据时,只需要一条以gp为基指针的指令即可。在编译时,数据须在以gp为基指针的64KB范围内。$29:($sp)堆栈指针寄存器。MIPS硬件并不直接支持堆栈,你可以把它用于别的目的,但为了使用别人的程序或让别人使用你的程序, 还是要遵守这个约定的,但这和硬件没有关系。$30:($fp)存放栈帧指针寄存器。为支持MIPS架构的GNU C编译器保留的,MIPS公司自己的C编译器没有使用,而把这个寄存器当作保存寄存器使用($s8),这节省了调用和返回开销,但增加了代码生成的复杂性。$31:($ra)存放返回地址。MIPS有个jal(jump-and-link,跳转并链接)指令,在跳转到某个地址时,把下一条指令的 地址放到$ra中,用于支持子程序调用。例如,调用程序把参数放到$a0~$a3,然后使用jal指令跳转到子程序执行;被调用过程完成后,把结果放到$v0,$v1寄存器中,然后使用jr $ra返回。2.3 整数乘法硬件单元实现乘法的操作有多种方式:在标准整数流水线上实现简单乘法操作(例如通过移位即可实现的乘法操作),对于复杂的乘法操作则由软件实现。早期的SPARC处理器就是这样干的。另外一种避免复杂乘法操作的方法就是,在浮点单元中实现整数乘法。Motorola公司曾经昙花一现的88000系列就是这样实现的。但是,这违反了MIPS架构中浮点单元作为可选项存在的定义。而MIPS架构的CPU具有一个特殊用途的整数乘法单元,独立于主流水线之外。它实现的基本操作是,将两个通用寄存器大小的值相乘,得到一个2倍于寄存器大小的结果,存储到乘法单元中。指令mfhi和mflo分别将结果拷贝到2个特定的通用寄存器中。因为乘法操作执行比较慢,所以乘法单元硬件实现乘法结果寄存器互锁。后续指令如果过早读取结果的话,CPU会停止执行,直到乘法操作完成。嵌入式编程小技巧:能用移位实现的乘除操作,就不要使用*和/运算。整数乘法单元同样可以完成除法操作,lo寄存器保存商,hi寄存器保存余数。乘法操作占用大约4-12个时钟周期,除法操作大约20-80个时钟周期(具体依赖于实现)。有些CPU还有乘法单元流水线(ARM架构就是这样实现的),也就是说,乘法操作可以在每个时钟周期都可以执行,不用再等待上一个操作完成。MIPS32/64规范还包含一个mul三目乘法指令,将结果的低字节保存到一个通用目的寄存器中。也就是说,这个指令只能计算相乘的结果小于寄存器大小的情况。这个指令还是执行互锁操作,也就是说等到操作完成,才能读取结果;高度优化的软件,仍然会使用分立的指令分别执行乘法操作和读取乘法结果。有些基于MIPS32/64规范的CPU还有累乘操作,连续乘法操作的结果会被相加后保存到lo/hi寄存器中。乘除操作从不会产生异常:即使除零操作(但是结果是不可预料的)。编译器通常产生额外的指令检查错误并捕捉错误,比如说除零操作。指令mthi和mtlo,用来拷贝通用目的寄存器的值到内部寄存器中。这对于异常返回时,恢复hi和lo的值是必不可少的,除此之外,可能很少使用。2.4 加载与存储:寻址方式MIPS架构的CPU寻址方式只有一种:寄存器索引寻址。任何load和store指令都可以写成下面这样:lw $1, offset($2)可以使用任何寄存器作为目的或源寄存器。offset是一个有符号的16位数(所以,范围是−32768~32768);要加载的地址是寄存器$2+offset的值。offset可用于索引结构体成员,数组成员或者函数栈上的变量;再或者配合gp寄存器访问全局静态变量(static和extern)。汇编器提供了一种直接寻址的写法,但是在编译时,会将其转换成上面的机器指令格式。更复杂的双寄存器寻址或者可变址索引寻址都必须使用多条指令才能实现。也就是说,我们在编写或者看到的汇编代码中,复杂的寻址指令都是编译器提供的伪指令,在编译阶段,编译器会将其转换成真正的机器指令。2.5 存储器与寄存器的数据类型MIPS架构CPU单条指令可以可以存取1-8个字节。2.5.1 整数数据类型字节(byte)和半字(halfword)在load时,分为两种情况。带符号扩展指令lb和lh,将值加载到32位寄存器的低有效位,用符号位(字节的话是bit7,半字的话是bit15)填充高有效位。数据类型字节数助记符dword8ldword4lwhalfword2lhbyte1lb无符号指令lbu和lhu实施0扩展;也就是说,将具体的值加载到32位寄存器的低有效位,将高有效位填充0。比如:在地址t1处存储着值0xFE(可以解释为-2或者254(无符号)),分别使用有符号指令和无符号指令进行读取:lb t2, 0(t1) lbu t3, 0(t1)那么加载完成后,t2=0xFFFFFFFFE(一个32位的有符号数-2),t3=0x000000FE(252)。上面是按照32位描述的,对于64位也是适用的,只是操作位数扩大一倍而已。上述短整数向长整数扩展的细微差异是C语言移植的历史原因造成的,现代C标准有明确的的规则消除可能的歧义。像MIPS这类的机器,不能直接执行8位或16位算术运算,如果涉及到short或char型变量的表达式,就要求编译器插入额外的指令保证运算正确;这应该尽量避免。当你移植代码到MIPS架构的CPU上,涉及到小整数时,要充分考虑哪些变量可以使用int型。2.5.2 非对齐load和storeMIPS架构的load和store操作必须是对齐的,halfword加载以2字节为边界,32位以4字节为边界。load指令如果访问非对齐地址会产生自陷(trap)。因为CISC指令集架构比如X86架构确实能够处理非对齐load和store,所以,当你移植这上面的软件到MIPS架构上时,可能会遇到问题。也许,你会说,我可以写一个trap处理程序,在其中,模拟非对齐load操作;从而对应用程序隐藏这个硬件细节。除非,非对齐的访问比较少,否则,性能会比较差。有时候,可能确实需要访问非对齐的数据。MIPS架构确实也提供了一个ulw宏指令,由两个指令组成,比一个个字节的加载,移位,再相加,更高效。还有一个宏指令ulh,使用2个load,一个移位和一个位或操作组合而成,提供非对齐的半字加载操作。默认,C编译器会正确对齐所有数据,但是也有例外情况(比如,从文件中导入数据或者与其它CPU共享数据时),这时候可能要求能够有效地处理非对齐的整数。所以,有些编译器允许指定数据的类型为非对齐的,从而产生特殊的代码来处理。2.5.3 内存中的浮点数从内存中加载浮点数到浮点寄存器中,没有任何限制。对于32位处理器,允许加载单精度值到偶数编号的浮点寄存器中。但是,你也能够使用宏指令l.d加载双精度值。如下所示:l.d $f2, 24(t1)编译器会展开为两条指令:lwc1 $f2, 24(t1) lwc1 $f3, 28(t1)在64位机器上,l.d是ldc1机器指令的优选别名。遵循MIPS/SGI规则的任何C编译器都会将double型浮点数按照8字节对齐。32位处理器没有这个对齐要求,但还是这样做是向后兼容:如果加载一个非8字节对齐的地址处的内容,64位CPU会陷入自陷。2.6 汇编语言的合成指令前边我们或多或少提及了一些编译器的伪指令等概念,也可以成为合成指令。因为它是编译器通过多条指令合成的一个伪指令。为什么需要伪指令呢?因为MIPS架构只有一种寻址方式。如果我想加载一个立即数到寄存器中,需要先把立即数的地址拷贝到寄存器中,然后再使用load指令从相应的地址处加载立即数,需要两条指令。本身,汇编程序就够晦涩了,现在我只想加载个立即数,还要让我记住两条指令,这太不人道了。所以,伟大的GNU工程中的汇编器提供了合成指令。还是加载立即数,现在,我只需要使用li(等于load immediate)合成指令就可以写了。合成指令的命名是不是也很直接。最后由编译器生成两条机器指令。此处,又再一次体现了MIPS架构的设计理念:硬件尽量简单,辅以软件实现。编译器提供的辅助有:加载32位立即数:直接加载立即数。从内存加载数据:你可以编码一个load,实现从内存中读取变量。汇编器会把变量地址的高字节存储在临时寄存器中,然后使用地址的低字节作为偏移量作为load的操作数进行转译(等效于load t0, lo_addr(t1),在这儿t1是临时寄存器,存放地址的高字节hi_addr)。当然,这不适用于C函数中定义的变量,因为它们要么是在寄存器中,要么在堆栈上。提供更有效的访问内存变量的方式(gp寄存器):如果C程序包含大量对static或extern变量的引用,每个load/store操作都需要两条指令,这也是一笔不小的开销。为此,一些编译器就通过实时运行时的gp指针完成这个优化。在编译或者汇编阶段,选择某些变量,把它们聚集到一起组成一块小的区域(不能超过64K)。把中间位置的变量地址存储在gp寄存器(也就是$28)中。后面这需要将gp寄存器作为基址,通过偏移进行访问即可。通过gp相关的load和store,存取这些变量只需要一条指令即可。相关的优化选项是-G,如果是-G 0则代表关闭优化。更多类型的分支指令:合成更多的分支指令。不同形式的指令:实现单目运算符,比如not和neg等。也提供了双目运算符。真正的机器指令只支持三目运算。隐藏分支延时槽:汇编器可以优化分支延时槽的使用,比如,把它认为正确的分支指令之前的指令填入分支延时槽中。但是,大部分时候,它没有那么牛逼,只是在分支延时槽中填入了nop操作而已。如果你不想汇编器改变指令的任何顺序,可以使用汇编伪指令.set noreorder进行指定;允许的话,就是.set reorder。隐藏加载延时槽:编译器可以检测load指令前后的语句是否尝试立即使用load结果,如果是,则可能上下移动一下指令。非对齐转换:非对齐load/store指令(ulh和ulw等)。其它的流水线校正:一些指令对旧CPU有一些额外的限制(比如说使用乘法单元的指令)。如果想要查看汇编机器代码,可以借助反汇编工具objdump。2.7 基本地址空间MIPS架构具有两种特权模式,用户模式和内核模式。现在,我们讨论MIPS架构对内存空间的分配使用情况。下图是32位架构下的内存布局:从上图可以看出,将内存空间分为了4部分:kuseg(地址范围0x0000.0000–7FFF.FFFF,低2GB):用户态使用的地址空间。必须带有MMU内存管理单元的CPU才能使用这段地址空间。对于没有MMU的处理器,该地址空间的使用取决于实现。但是,为了在没有MMU的硬件上,你写的程序可移植,应该避免使用这段区域。kseg0(地址范围0x8000.0000–9FFF.FFFF,512MB):最高位清零就是物理地址,相当于直接映射物理内存的低512M。这段内存总是通过cache进行访问,所以在使用之前必须配置好Cache。主要用途:如果不使用MMU,则用来保存程序和数据;如果使用MMU,则存放OS内核程序。kseg1(地址范围0xA000.0000–BFFF.FFFF,512MB):前面的3位清零就是物理地址。也被映射到物理地址的低512M。但是,访问不通过Cache。系统重启时,唯一能访问的地址空间。复位后的启动入口点就位于这段地址空间(0xBFC00000)。而物理地址的启动入口点就在地址0x1FC00000。因此,初始化启动程序ROM一般使用这个区域,还有许多作为I/O寄存器使用。kseg2(地址范围0xC000.0000–FFFF.FFFF,1GB):内核态可以访问的地址空间,前提是使用MMU。除非是开发操作系统,否则一般不会使用这个空间。有时候,会把这段地址空间分为两部分,分别称为kseg2和kseg3。kseg2就保留给管理模式使用,如果使用了管理模式的话。2.7.1 简单系统的物理寻址对于非常简单的系统,大部分时候物理内存不会超过512MB。所以只需使用kseg0和kseg1的地址空间即可。但是,如果实在需要,可以将转换项存放于内存管理单元的TLB中,从而访问更高地址的内存。另外,如果是64位CPU,还可以使用额外的空间访问。2.7.2 内核与用户特权级别在内核特权下(CPU启动)可以做任何事情。在用户模式,访问高于2GB以上的地址是非法的,会产生自陷(trap)。如果CPU有MMU,意味着,用户模式下的地址必须经过MMU的转译才能访问物理内存,这样可以阻止用户模式下的程序非法访问内核模式的地址空间。这也意味着,如果MIPS架构的CPU上运行的是一个没有内存映射的OS内核,则用户特权级是多余的。另外,在用户模式下,一些指令,尤其是OS需要的CPU控制指令是非法的。改变内核/用户特权模式,不会改变任何行为,只是意味着某些功能在用户模式被禁止。在内核态,CPU能够访问低地址空间,就像它们处于用户模式一样,也使用相同的方式进行转换。另外还需要注意的是,虽然看上去内核模式专门为操作系统设计的;用户模式处理日常的工作。然而,事实并非如此。很多简单的系统(包括许多实时操作系统)一直处于内核模式运行。2.7.3 64位地址映射MIPS架构的地址总是通过一个寄存器的值加上16位的偏移计算得到。而在64位MIPS架构CPU中,寄存器的位数是64位,所以可以访问的地址空间是2^64,这样巨大的地址空间可以任由我们分配,如下图所示。在上图中,我们可以看出,64位内存地址的扩展部分都位于32位内存地址的中间,这是一个很奇怪的实现技巧。我们知道,MIPS架构在短整数向长整数扩展时,使用了带符号位的扩展方式。在64位CPU上模拟32位指令集时,寄存器的低32位保存实际的地址值,高32位根据bit31位作为符号位进行扩展,这样32位的程序实际访问的是64位程序空间的最低2GB和最高2GB程序空间。这样,扩展的内存映射把最低空间和最高空间用作和32位系统一样的地址空间,扩展的空间就位于这中间了。事实上,这么大的地址空间大部分时候根本没有意义,除非你正在实现一个虚拟内存操作系统,要不然基本用不上;因此,许多MIPS64用户还是把指针定义为32位长度。这些未映射的地址空间可以用来突破kseg0和kseg1的512MB的限制,但是,这完全可以通过内存管理单元(TLB)实现。2.8 流水线可见性关于流水线的可见性,在之前的文章中已经涉及过,比如分支延迟和load延迟。任何一个带有流水线的CPU,如果有指令不能满足一个时钟周期执行完的要求的话,都会面临时序延迟的问题。如果架构设计者隐藏这些时序延迟问题,那么编程模型相对于编程人员就会变得相对容易,但是硬件实现就会复杂。而如果把时序延迟问题暴露给编程人员,让他们通过软件规避这些问题,硬件实现容易了,但是软件设计就会变得复杂。所以,这是一个平衡和选择的问题。我们知道,MIPS架构的设计理念是:硬件尽量简单,辅以软件实现。所以,MIPS架构把一些流水线的时序延迟问题暴露给编程人员或者编译器去优化实现。下面,我们总结一下这些时序延迟问题:分支延迟:所有的MIPS架构CPU,紧跟在分支指令后面的指令不论分支指令是否发生跳转都会执行。所以,编程者或者编译器可以选择一条合适的指令放到分支指令的后面,提高CPU的执行效率。最差的情况也要填充一个nop指令。编译器默认情况下,就是填充nop指令。加载(load)延迟:优化编译器和编程者应该意识到一次load操作所花费的时间,不要尝试立即使用loading中的数据。load延迟会影响系统性能,硬件设计者尽量保证为load之后的下一条指令准备好数据。浮点单元(协处理器1)的问题:浮点运算花费多个时钟周期,典型的MIPS架构FPU硬件有一个某种程度上相对独立的流水线单元。MIPS硬件必须对用户隐藏这些FPU流水线。FP运算被允许和后面的指令并行计算,增加CPU的执行效率。如果在计算没有完成的时候读取结果寄存器,CPU应该停止执行等待计算完成。真正深度优化的编译器拥有指令重复率表和每个目标CPU延迟表,但是,我们大部分时候不想依赖这些。CPU控制指令的问题:这个是需要慎重对待的东西。当你改变CP0中相应位的时候,潜在地可能正在影响流水线上的各个阶段。MIPS32/64规范在第二版后,对这方面进行了改善。与CP0的交互可以分为两部分:一种情况是先前对CP0的操作可能会影响后一条指令的取值,这也是最麻烦的,称为指令遇险;另一种情况就是执行遇险。MIPS架构提供了两种屏障(barrier)指令规避这些情况的发生:一种是用于执行遇险;另外的是加强的分支指令,可以保障在发生指令遇险时的安全。在第二版之前,没有提供相关的屏障指令。需要编程者阅读相关的CPU手册,发现应该添加几条填充指令避免这些副作用的发生。这部分的内容跟ARM的内存无序相关问题类似。ARM的解决手段要么锁总线,要么添加内存屏障指令rmb()。
众多RISC精简指令集架构中,MIPS架构是最优雅的”舞者”。就连它的竞争者也为其强大的影响力所折服。DEC公司的Alpha指令集(现在已被放弃)和HP的Precision都受其影响。虽说,优雅不足以让其在残酷的市场中固若金汤,但是,MIPS架构还是以最简单的设计成为每一代CPU架构中,执行效率最快的那一个。作为一个从学术项目孵化而来的成果,简单的架构是MIPS架构商业化的需要。它拥有一个小的设计团队,制造和商业推广交给它的半导体合作伙伴们。结果就是,在半导体行业领域中,它拥有广泛的合作制造商:LSI逻辑、LSI、东芝、飞利浦、NEC和IDT等都是它的合作伙伴。值得一提的是,国内的龙芯买断了它的指令集架构,成为芯片国产化的佼佼者。在低端CPU领域,MIPS架构昙花一现。目前主要应用于嵌入式行业,比如路由器领域,几乎占据了大部分的市场。MIPS架构的CPU是RISC精简指令CPU中的一种,诞生于RISC学术研究最为活跃的时期。RISC(精简指令集计算机)这个缩略语是1986年-1989年之间诞生的所有新CPU架构的一个标签TAG,它为新诞生的这些高性能架构提供了想法上的创新。有人形象的说,”RISC是1984年之后诞生的所有计算机架构的基础”。虽说,略显夸张,但也是无可争议的事实,谁也无法忽略RISC精简指令集的那些先驱们所做的贡献。MIPS领域最无法忽视的贡献者是Stanford大学的MIPS项目。之所以命名成MIPS,即可以代表microcomputer without interlocked pipeline stages-无互锁流水线的微处理器的英文意思,又可以代表millions of instructions per second每秒执行百万指令的意思,真是一语双关。看起来,MIPS架构主要研究方向还是CPU的流水线架构,让它如何更高效地工作。那接下来,我们就从流水线开始讲起。流水线的互锁是影响CPU指令执行效率的关键因素之一。1.1 流水线假设有一家餐馆,我们称之为Evie的炸鱼薯条店。在其中,每一名顾客排队取餐(炸鳕鱼、炸薯片、豌豆糊、一杯茶)。店员装好盘子后,顾客找桌子坐下来就餐。这是,我们常见的餐馆就餐方式。Evie的薯条是当地最好的小吃。所以,每当赶大集的时候,长长的队伍都要延伸到店外。所以,当隔壁的木屐店关门的时候,Evie盘下来,扩大了店面,增加了桌子。但是,这还是于事无补。因为忙碌的市民根本没空坐下来喝杯茶。(因为Evie还没有找到排长队的根本原因,......)Evie炸鳕鱼和Bert的薯条是店里的招牌,来的顾客都会点这两样。但是他们只有一个柜台,所以,当有一名顾客执行点一杯茶的时候,而恰好他又在点鳕鱼和薯条的顾客后面,那他只能等待了.....。终于有一天,Evie想起了一个绝妙的主意:他们把柜台延长,Evie、Bert、Dionysus和Mary四名店员一字排开;每当顾客进来,Evie负责鳕鱼装盘,Bert添加薯条,Dionysus盛豌豆糊,Mary倒茶并结账。这样,一次可以有四名顾客同时被服务,多么天才的想法啊。再也没有长长的排队人群,Evie的店收入增加了......。这就是流水线,将重复的工作分成若干份,每个人负责其中一份。虽然每名顾客总的服务时间延长,但是,同时有四名顾客被服务,提高了整个取餐付账的效率。下图1-1就是Evie店里的流水线结构图。那么,我们将CPU执行一条指令分解成取指令、解码、查找操作数、执行运算、存储结果五步操作的话,是不是跟上面Evie的店里的流水线就极其类似了呢。本质上讲,等待执行的程序就是一个个指令的队列,等待CPU一个个执行。流水线当然不是RSIC指令集的新发明,CSIC复杂指令集也采用流水线的设计。差异就是,RSIC重新设计指令集,使流水线更有效率。那到底什么是制约流水线效率的关键因素呢?1.1.1 制约流水线效率的因素我们都知道那个著名的”木桶效应”:决定木桶的装水量的是最短的那个木头,而不是最长的。同样的,如果我们保证指令执行的每一步都占用相同时间的话,那么这个流水线将是一个完美的高效流水线。但现实往往是残酷的,CPU的工作频率远远大于读写内存的工作频率(都不是一个量级)。让我们回到Evie的店中。顾客Cyril是一个穷人,经常缺钱,所以在Mary没有收到他的钱之前,Evie就不会为他服务。那么,现在Cyril就卡在Evie的位置处,知道Mary处理完前面三名顾客,再给他结完账,Evie才会继续为他服务。所以,在这个取餐的流水线上,Cyril就是一个麻烦制造者,因为他需要一个他人正在使用资源(Mary结账)。(这种情况可以对应CPU存储指令使用锁总线的方式保证对内存的独占访问。)有时候,Daphne和Lola分别买一部分食物,然后彼此之间共享。比如说,Lola买不买薯条,取决于Daphne是否购买一杯茶。因为光吃薯条不喝点饮料的话,也许Daphne会噎着或者齁着。那么,Lola就会在售卖员Bert处着急等待Daphne在Mary处买一杯茶,这中间就发生了时间上的空隙,我们将其称为流水线上的间隙。(这是不是很像条件分支?)当然了,不是所有的依赖关系都是坏的结果。假设有一名顾客Frank,总是模仿Fred点餐,也许Frank是Fred的粉丝呢。这其实蕴涵着通过Cache命中提高存取内存和分支预测工作效率的基础。你当然在想,把这些麻烦的顾客剔除出去,不就是一个效率超高的流水线吗?但是,Evie还要在这儿生活,怎么可能得罪顾客呢。计算机处理器的行业大佬Intel现在也面临着这样的问题:不可能不兼容以前的软件,完全另起炉灶搞一个新的架构的话,可能会流失很多客户。于是,只能在旧的架构上缝缝补补又十年啊。这也给了RSIC指令集发展的大好机会。1.1.2 流水线和Cache计算机CPU处理速度和内存读取速度的匹配问题是提高CPU工作效率的关键,也就是”木桶效应”的那个短板。So,为了加速对内存的访问,CPU设计中引入了Cache。所谓的Cache,就是一个小的高速内存,用来拷贝内存中的一段数据。Cache中的最小数据单元是line,每个line对应一小段内存地址(常见的line大小为64字节)。每个Line不仅包含从主内存读取的数据,还包括其地址信息(TAG)和状态信息。当CPU想要访问内存中的数据时,先由内存管理单元搜索Cache,如果数据存在,则立即返回给CPU,这称为Cache命中;如果不存在,则称为Cache未命中,此时,内存管理单元再去主内存中查找相关数据,返回给CPU并在Cache中留下备份。Cache当然不知道CPU下一步需要什么数据,所以它只能保留CPU最近使用过的数据。如果需要为新拷贝的主内存数据,它就会选择合适的数据丢弃,这涉及到Cache替换策略算法。Cache大约9成时间能够提供CPU想要的数据,所以大大提高了CPU读取数据的速率,从而提高了流水线的工作效率。因为指令不同于数据,是只读属性,所以,MIPS架构采用哈弗结构,将数据Cache和指令Cache分开。这样就可以同时读取指令和读写变量了。1.2 MIPS架构5级流水线图1.2: MIPS-5级流水线MIPS本身就是基于流水线优化设计的架构,所以,将MIPS指令分为5个阶段,每个阶段占用固定的时间,在此,固定的时间其实就是处理器的时钟周期(有2个指令花费半个时钟周期,所以,MIPS的5级流水线实际上占据4个时钟周期)。所有的指令都是严格遵守流水线的各个阶段,即使某个阶段什么也不做。这样做的结果就是,只要Cache命中,每个时钟周期CPU都会启动一条指令。让我们看看每一个阶段都做了什么:取指令-IF从I-Cache中取要执行的指令。读寄存器-RD取CPU寄存器中的值。算术、逻辑运算-ALU执行算术或逻辑运算。(浮点运算和乘除运算在一个时钟周期内无法完成,我们以后再写文章专门讲解FPU相关的知识)读写内存-MEM也就是读写D-Cache。至于这儿为什么说读写数据缓存,是因为内存的读写速度实在太慢了,无法满足CPU的需要。所以出现了D-Cache这种高速缓存。但即便如此,在读写D-Cache期间,平均每4条指令就会有3条指令什么也做不了。但是,每条指令在这个阶段都应该是独占数据总线的,不然会造成访问D-Cache冲突。这就是内存屏障和总线锁存在的理由。写回寄存器-Writeback将结果写入寄存器。当然,上面的流水线只是一个理论上的模型而已。但实际上,有一些MIPS架构的CPU会有更长的流水线或者其它的不同。但是,5级流水线架构确是这一切的出发点和基础。流水线的严格规定,限制了指令可以做的事情。首先,所有的指令具有相同的长度(32位),读取指令使用相同的时间。这降低了流水线的复杂度,比如,指令中没有足够的位用来编码复杂的寻址模式。这种限制当然也有不利:基于X86架构的程序,平均指令长度也只有大约3个字节多一点。所以,MIPS架构占用更多的内存空间。第二,MIPS架构的流水线设计排除了对内存变量进行任何操作的指令的实现。内存数据的获取只能在阶段4,这对于算术逻辑单元有点延迟。内存访问只能通过load或store指令进行。(MIPS架构的汇编也是最简单易懂的代码之一)尽管有这些问题,但是MIPS架构的设计者也在思考,如何使CPU可以被编译器更加简单高效地优化。一些编译器高效优化的要求和流水线的设计要求是兼容的,所以MIPS架构的CPU具有32个通用寄存器,使用具有三个操作数的算术/逻辑指令。那些复杂的特殊目的的指令也是编译器不愿意产生的。通俗地讲,编译器能不用复杂指令就不用复杂指令。1.3 RISC和CISC对比我们如何区分RISC和CISC指令集定义上的区别。在我看来,RISC就是架构和指令集关系的描述。20世纪80年代中期,诞生了一批新的架构,在这些架构中,巧用指令集以最大化这些基于流水线实现的架构的效率。这些被巧用的指令集就被称为精简指令集,采用这些指令集的架构也就称为精简指令集计算机(RISC)。基于RSIC精简指令集设计的CPU架构有SPARC、MIPS、PowerPC、HP Precision、DEC Alpha和ARM。与此形成鲜明对比的是,CISC复杂指令集计算机与流水线的实现没有多大关系。CISC的设计出发点主要是从代码的易用性上考虑的。1985年之后的计算机架构,基本上都是基于RISC实现的。CISC主要是1985年之前的架构使用。比如英特尔公司的X86架构和摩托罗拉公司的680x系列。总结来说,RISC和CISC的共同点都是对指令集的描述,但是RISC对于CPU的流水线架构的实现影响比较大,而CISC指令集对于架构的影响不大。虽然,现在的X86架构大量借鉴了RISC的一些实现技巧,用来提升自己的性能。但其本质上还是复杂指令集计算机(CISC)架构。1.4 MIPS架构的发展纵观MIPS架构的近40年的发展历程,虽经历过辉煌,但现在也日渐式微。网上有许许多多关于MIPS架构的评论或者见解。笔者对于市场一窍不通,故不在此班门弄斧。但是我本人还是非常欣赏MIPS架构的设计理念:强调软硬件协同提高性能,同时简化硬件设计。咱们在此提一下国内的龙芯公司,号称”国产芯”。它由于直接买断了MIPS指令集的授权,所以不受技术封锁的影响。而且,MIPS指令集的授权和ARM指令集的授权有着本质上的不同:MIPS授权后,允许设计厂商自行对架构或者指令集进行自定义;但是ARM授权不允许厂商对其授权的架构进行自定义(当然了,近些年,ARM也已经授权了苹果、高通等公司可以自行定义ARM授权的架构)。所以,龙芯选择MIPS是技术上的选择,也是时代的选择。虽然,最近几年RISC-V开源指令集非常火热,但是其上的软件生态同样需要布局。而龙芯已经在MIPS架构上花费了20年的人力物力,也已经有了一些技术上的沉淀。完全掉头转向RISC-V开源指令集也是不太现实的。希望龙芯能够在CPU领域继续深耕,逐步完善生态系统,实现真正的国产芯片自主化吧。1.5 MIPS和CISC的对比大部分的程序员对汇编语言的认知都来源于X86架构,毕竟是最早的CPU架构之一。但是,当你看见基于MIPS架构的汇编代码时,你还是得到一些惊喜。我个人的感觉就是,基于MIPS架构的汇编语言理解起来还是比较容易的,毕竟它是精简指令集。但是,它又有一些程序代码设计上的奇技淫巧,需要我们额外理解。我们将通过以下几个方面进行归纳总结:为了提高流水线的效率而对MIPS指令操作所施加的限制;极度简单的load/store操作;有意省略的一些操作;指令集的一些预想不到的特性;流水线操作中对程序员可见的那些点。最初提出MIPS设想的斯坦福大学的研究小组,特别关注能够实现的简短的流水线架构。后来的事实也证明,他们的判断是完全正确的,由流水线而引申出的许多设计决定被证明,能够更容易、更快速地实现更高的性能。1.5.1 MIPS指令集的限制所有的指令都是32位长度:这意味着没有指令仅占用2个或3个字节的内存空间(也就是说,通常情况下,MIPS架构的二进制文件比X86架构大百分之二十或三十),也没有指令超过4个字节。这样的结果就是,只通过一条指令无法操作32位常数。因为一个32位指令,没有足够的位为操作数和目标寄存器进行编码。MIPS架构的设计者为两条指令保留了26位,这两条特殊的指令就是跳转jump指令,一个跳转到指定的目标地址,一个跳转到子程序。其它的指令都只有16位留给常数。于是,加载任意一个32位的常数,都需要2条指令才能实现,条件分支被限制到64K的作用范围。指令操作必须适合流水线:指令的每一步操作都必须在流水线的正确阶段执行,且必须在一个时钟周期内完成。比如,寄存器写回操作只提供写一个值到寄存器中,所以指令在这个阶段只能改变某个寄存器的内容。乘除指令无法在一个时钟周期内完成。MIPS架构的CPU使用的策略就是,将这部分操作分配到单独的一个流水线上进行操作(我们在其它文章中,再讨论这个话题)。3个操作数的指令:编译器偏爱三个操作数的运算,对于复杂的表达式能够有更大的优化空间。而算术/逻辑运算指令不需要存储操作,所以有足够的位表示两个源操作寄存器和一个目的寄存器。32个通用寄存器:通用寄存器的个数是由软件需求驱动的,32个通用寄存器是现代计算机架构中常用的数量。如果使用16个寄存器并不能完全满足现代编译器的需要,而使用32个寄存器对于C编译器是完全足够的,足以覆盖最大最复杂的函数调用关系。但是,使用64个寄存器需要占用指令中更多的位去编码寄存器,也会增加上下文切换时的负荷(需要保存的寄存器更多)。寄存器$0:寄存器$0总是返回一个0常数。0是最常用的一个常数,直接用一个寄存器表示,可以减少常数向寄存器的加载操作。指令不含条件码:即使相比其它RISC架构,MIPS指令集也具有一个重要特性就是没有任何条件标志。许多架构使用进位、零等多个标志。像X86等CISC复杂指令集架构的指令中有一些位专门表示是否根据结果设置这些标志位。就是一些RISC指令集架构也保留了一些这样的标志位,比如说ARM,尽管通常只有比较指令可以设置这些标志位。MIPS架构决定使用寄存器保存这些信息:比较指令根据结果设置通用寄存器,条件分支指令检查判断这些通用寄存器。这样的操作,非常有利于流水线架构的实现,因为这样的机制,比较/分支指令不需要再依赖于算术/逻辑操作,也就是说,它们之间彼此都是独立的,流水线的实现也就更简单。它们之间的逻辑关系由软件实现,这也是MIPS架构的设计理念:强调软硬件结合,简化硬件设计。有效的条件分支指令要求,必须在半个时钟周期内做出是否要跳转的决定;MIPS架构通过尽可能简单地测试条件是否满足实现,比如,判断某个寄存器的值是否为符号位或者等于0,再比如,判断两个寄存器的值是否相等。1.5.2 寻址和内存访问访问内存都是先load/store到寄存器中:算术指令如果直接操作内存变量会破坏简化流水线设计的理念。所以,对内存变量进行操作的时候,先将其加载到寄存器中,然后再对寄存器进行算术逻辑操作。完成后,将将结果再存储到内存中对应的位置。只有一种数据寻址模式,寄存器寻址:几乎所有的加载和存储都是通过寄存器基址加上16位的偏移实现的。字节寻址:MIPS架构中的寄存器是一个整体,所有的操作都是对整个寄存器的操作。所以,无法实现字节或者半字这样的操作。但是,C语言之类的语法又要求可以按照字节或半字进行操作。MIPS架构采取的方式就是,提供一组load/store指令,分别加载字节、半字或WORD大小的内存变量。一旦数据加载到寄存器中,它就看作为一个寄存器长度大小的数据(比如说,32位架构就是32位整数,64位架构就被看作为64位整数)。所以,对于这些字节或半字的load操作,还需要考虑符号位。于是,又延伸出两种加载指令的形式:符号扩展或零扩展。load/store操作必须对齐:MIPS架构内存访问必须是按对齐方式进行的。字节可以是任何地址,但是半字就必须是偶数地址对齐,WORD必须是4字节对齐的方式。CISC指令集架构的微处理器可以从任意地址处读取一个4字节的数据,代价就是需要多花费一些时钟周期。但是,MIPS指令集一些特殊的指令,以简化未正确对齐的地址上load和store的工作。跳转指令:指令的长度限制为32位,对于想要大范围跳转的分支指令是一个很大的问题。MIPS指令中最小的操作码域是6位,为跳转的目的地址保留了26位。因为内存中的指令代码都是4字节对齐的,也就是说,最低2位不需要保存,那么允许访问的程序范围就是2^28,等于256MB。这个地址不是相对于PC(程序计数器)的,而是被解释为256M的代码段中一个绝对地址。这样以来,对于大于256M的单个程序非常不便。虽然,可以使用寄存器保存跳转目标,然后再使用跳转指令跳转到32位地址的任何地方。条件分支指令只有16位的偏移量,对于4字节对齐的内存空间,其访问的范围是2^18B。但是这儿的地址可以解释为相对PC寄存器的正负范围。所以,编译器只有知道目标地址在分支指令前后128KB的范围内才能正确地编码条件分支指令。1.5.3 MIPS没有的特性没有字节或半字算术运算:所有的算术和逻辑操作都是基于32位完成的。操作字节或者半字要求更多的额外资源和更多的操作码,所以,一般不推荐使用。但是,如果程序中显式地使用short或者char类型的数据进行运算,支持MIPS架构的编译器必须额外地插入一些机器指令,保证结果能够像在真正的16位或8位机器上那样正确运行。没有对堆栈寄存器的特定支持:虽然,传统意义上的MIPS汇编代码确定也会定义一个寄存器作为堆栈指针寄存器,但是,硬件上没有规定那个寄存器是特定的sp寄存器。而像ARM和X86架构有一个特定的sp寄存器。我们都知道,对于函数调用的实现,有一些约定俗成的格式,比如说System V ABI。有一种推荐的子程序调用时堆栈栈帧布局,这样可以混合使用汇编语言和C语言编程,使用不同的编译器选项进行编译。但是这一切和硬件都没有关系,需要人为实现。堆栈的pop操作不符合流水线的执行,因为它要写两个寄存器(来自堆栈的数据和增加的指针值)。最少的子程序支持:跳转指令也与我们习惯上的认知有所不同:具有跳转(jump)和链接(link)跳转指令,把返回地址写入到一个固定的寄存器中。默认使用$31作为返回地址寄存器。这比把返回地址保存到堆栈中更为简单,而且还有许多优点。举两个例子让你对这种论断有一个直观感受:第一,它把分支指令和内存访问指令分离开来;第二,当调用不需要保存返回地址的子程序时,有助于提高效率。最少的中断处理:很难看到比这做得更少的硬件了。它把程序重新运行的地址保存到一个特定寄存器中,修改机器状态,然后禁止中断。做完这些后,跳转到一段保存到低内存中的预定义好的程序,之后的工作完全由软件控制。其实,现在处理器对于中断都是基于能少则少的原则进行处理。硬件上,MIPS架构则是只保存了一个重新运行的地址,而像X86架构,还需要保存eflags、cs、eip、ss和esp等寄存器。所以,MIPS的中断处理更为简单。最少的异常处理:异常的硬件处理其实同中断处理一样。MIPS架构把中断看作为异常的一种,MIPS的异常涵盖了CPU想要中断所有顺序的执行,调用软件处理程序所产生的所有事件。比如中断、试图访问物理地址不存在的虚拟内存或者其它事情都可以产生异常。还有比如故意植入的trap陷阱指令,像为了访问内核态程序的系统调用都是一种异常。所有的异常都导致CPU的控制权传递给一个固定的入口点。对于任何异常,MIPS架构的CPU不会存储任何东西到堆栈上,也不会写内存或者保存任何寄存器。一切都由你自己决定。这与ARM和X86架构都是不一样的。按照约定,MIPS架构也保留了2个通用寄存器,让异常程序可以自举(在MIPS架构的CPU上,不使用寄存器是无法工作的)。但是,对于一个旨在多架构上运行的、允许中断或陷阱(trap)的通用系统,这两个寄存器的值随时会发生变化,最后不要使用它们。1.5.4 MIPS架构流水线的延迟前面我们讨论的都是简化CPU的设计带来的一些结果。但是,为了使指令集对流水线更友好,也产生了一些奇怪的效应,想要理解它们并不容易:分支延迟: 如上图的流水线结构图所示,当一个jump指令在读取阶段时,又产生了新的PC寄存器值,jump指令后的指令也被启动了。MIPS架构规定,分支指令后的指令总是在分支目标指令之前执行。跟随在分支指令后的指令位置被称为分支延迟槽,具体物理意义有点抽象,对应上图的话,就是横向上的一格。对于分支延迟槽,如果硬件不做任何特殊处理的话,决定是否跳转以及跳转的目标地址等,这些工作就会在ALU阶段结束时才能完成,此时即使是在下下个流水线槽都来不及提供一个指令地址。但是分支指令的重要性足以给其特殊处理,从上图可以看出,通过特殊的处理,ALU阶段可以在半个时钟周期内就使目标地址可用。连同取指令提前的半个周期,刚好在下下个流水线槽得到分支目标地址作为指令开始执行。所以,CPU控制单元执行的顺序是,分支指令,分支延迟槽指令,然后是分支目标指令,中间没有延时。如何利用好这个分支延迟槽,就是编译器或者汇编程序编写者的责任了。可以适当安排位于分支延迟槽中的指令做些有用的工作。也可以把不影响执行顺序的指令安排到分支延迟槽中执行。对于条件分支指令,这个比较复杂,至少保证位于分支延迟槽的指令对两个分支都是无害的。如果是在没有可以安排的指令,可以添加一个nop指令。这也是我们经常在MIPS架构的汇编代码中看到的处理方式。数据加载延迟(加载延时槽):简化的流水线另一个结果就是,当下一条指令到达ALU阶段的时候,上一条load指令的数据才开始从cache或内存中到达。也就是说,load指令后的下一条指令还是不能使用数据的。那么load指令后的位置,就称为加载延时槽。带有优化的编译器总是尝试利用这个加载延时槽。有时候,编译器会把这个位置填充一个nop操作。最新的MIPS架构CPU上,load操作也是采用了互锁机制:如果你尝试过早使用这个数据,CPU会停止执行,等待这个数据到达。但是,在早期的CPU上,没有互锁机制,过早的使用这个数据,会产生不可预料的结果。
一直对室内定位技术比较感兴趣,但是没有做过相关开发。根据自己对室内定位技术的理解,GPS或者北斗系统擅长于室外空旷区域,对于室内定位就显得力不从心了。目前,室内定位技术主要有WiFi、蓝牙或惯性导航等,定位效果不是十分理想,笔者曾经做过基于WiFi信号强度衰减模型的隧道定位,但是受制于隧道恶劣的环境、WiFi信号易被干扰、定位精度差(误差大约在1米左右)、还有电源供电这些问题,最后实际使用效果不如预期。所以,将注意力转移到了UWB技术。超宽带技术能够实时处理环境信息,如位置、移动及其与UWB设备间的距离,这些信息已精确到几厘米,这为系统增添了空间感知能力,从而将推动一系列激动人心的新应用的开发。那么关于UWB一些基本概念和大概原理,请参考NXP公司的这篇文章。深度解读UWB技术:厘米级安全实时定位是如何实现的?至于目前做的比较好的厂家,可以参考知乎上的一篇帖子:后来Ubisense在剑桥大学实验室研究发现UWB的非载波脉冲,非常适合用来做无线电定位研究,并于2003年成立公司推出了正式的定位产品,成为UWB定位的鼻祖,成为全球最专业的定位公司。国内起家最早的是唐恩科技,他们从1998年开始做室外RTK GPS定位,是振华港机的合作伙伴。 2013年底,爱尔兰的一家名叫Decawave公司推出了DW1000射频芯片,解决射频部分的问题,国内的北京(高校系)、无锡(国家物联传感基地系)、成都(电子科大系)、深圳(RFID系)等公司(诸如清研、沃旭、恒高、联睿、品铂......基本都是14年以后注册成立的公司),才开始了对UWB定位技术研究。https://www.zhihu.com/question/55503992目前感觉NXP联合三星公司在大力推广UWB技术。后期会持续关注UWB技术,并分享相关文章。
1 前言今天在理解读写自旋锁的实现的时候,看到了WFE指令,对其不理解。通过调查,弄清楚了它的来龙去脉,记录一下。在此,还要特别感谢窝窝科技的这篇文章【ARM WFI和WFE指令】,让我茅塞断开。WFI(Wait for interrupt)和WFE(Wait for event)是两个让ARM核进入低功耗待机模式的指令,由ARM架构规范定义,由ARM核实现。2 WFI和WFE1)共同点WFI和WFE的功能非常类似,以ARMv8-A为例(参考DDI0487A_d_armv8_arm.pdf的描述),主要是“将ARMv8-A PE(Processing Element, 处理单元)设置为低功耗待机状态”。需要说明的是,ARM架构并没有规定“低功耗待机状态”的具体形式。因而,可以由IP核自由发挥,根据ARM的建议,一般可以实现为standby(关闭clock、保持供电)、dormant、shutdown等等。但有个原则,不能造成内存一致性的问题。以Cortex-A57 ARM core为例,它把WFI和WFE实现为“put the core in a low-power state by disabling the clocks in the core while keeping the core powered up”,即我们通常所说的standby模式,保持供电,关闭clock。2)不同点那它们的区别体现在哪呢?主要体现进入和退出的方式上。对WFI来说,执行WFI指令后,ARM核会立即进入低功耗待机模式,直到有WFI唤醒事件发生。而WFE则稍微不同,执行WFE指令后,根据事件寄存器(一个单bit的寄存器,每个PE一个)的状态,有两种情况:如果事件寄存器为1,该指令会把它清零,然后执行完成(不会standby);如果事件寄存器为0,和WFI类似,进入低功耗待机模式,直到有WFE唤醒事件发生。WFI唤醒事件和WFE唤醒事件可以分别让ARM核从WFI和WFE状态唤醒,这两类事件大部分相同,如任意的IRQ中断、FIQ中断等等。一些细微的差别,可以参考“DDI0487A_d_armv8_arm.pdf“的描述。而最大的不同是,WFE可以被任何PE上执行的SEV指令唤醒。所谓的SEV指令,就是一个用来改变事件寄存器的指令,有两个:SEV会修改所有PE上的寄存器;SEVL,只修改本PE的寄存器值。下面让我们看看WFE这种特殊设计的使用场景。3 使用场景1)WFIWFI一般用于cpu空闲时。2)WFEWFE的一个典型使用场景,是用在spinlock中(可参考arch_spin_lock,对arm64来说,位于arm64/include/asm/spinlock.h中)。spinlock的功能,是在不同CPU核之间,保护共享资源。使用WFE的流程是:CPU核1访问资源,申请lock,获得资源。CPU核2访问资源,此时资源不空闲,执行WFE指令,让CPU核进入低功耗待机模式。CPU核1释放资源,释放lock,释放资源,同时执行SEV指令,唤醒核2。CPU核2申请lock,获得资源。我们在学习spinlock的时候,已经知道,当申请lock失败的时候,CPU核会进入忙等待,比如著名的x86架构。而ARM本身就是低功耗处理器的代名词,所以通过在申请lock的过程中,插入WFE指令,可以节省一点功耗,充分将低功耗设计发挥到了极致。4 补充当没有获取spinlock的时候,CPU核会调用wfe,等待其他cpu使用sev来唤醒自己。在ARM64中,arch_spin_unlock并没有显示的调用sev来唤醒其他cpu,而是通过stlr指令完成的。在ARM ARM文档中有说:在执行store操作的时候,如果要操作的地址被标记为exclusive的,那么global monitor的状态会从exclusive access变成open access,同时会触发一个事件,唤醒wfe中的cpu。
讲解这部分之前,我们先阐述一个概念-内核控制路径:就是一段在内核态执行的代码,比如说,异常处理程序,中断处理程序,系统调用处理,内核线程等等在内核态执行的代码。所以,内核态程序被激活的方式有:系统调用(异常的一种)异常中断内核线程上面的任意一种方式,都可以让CPU执行内核态的代码。比如,I/O设备引发一个中断,相应的内核态程序,首先,应该是保存内核态堆栈中的CPU寄存器的内容;然后,执行中断处理程序;最后,再恢复这些寄存器的内容。所以,在后面的描述中,我们使用内核控制路径这个术语代替一段可执行的内核态代码这种表述。使用内核控制路径的好处就是,它是从英语直译过来的,可能会更好地表达程序代码执行的顺序性,是一个过程;这样在描述中断嵌套时更有意义。内核控制路径可以任意嵌套;如下图所示,用户态的程序被中断打断,进入内核态响应中断;而这时候又来了其它中断,就会响应最新的中断,以此类推;但是,执行完一个中断处理程序之后,会回到之前的状态执行。这样,最后又回到了用户态。图4-3 内核控制路径的一个嵌套异常的示例允许内核控制路径嵌套的代价就是中断处理程序不能阻塞,也就是说,中断处理程序运行时不能发生进程切换。恢复执行嵌套内核控制路径的所有数据都存储在内核态堆栈中,而该堆栈又和当前进程紧紧绑定在一起。通俗的说,中断处理程序相当于当前进程的资源,切换进程之前该中断资源必须释放掉。假设内核没有bug,那么大部分的异常发生在用户态。实际上,要么是编程错误,要么是调试器故意触发的。而页错误异常发生在内核态,它是内核在访问物理地址时不存在引发的异常。处理这样的异常,内核挂起当前进程,切换到新进程,直到该请求页可用。因为页错误异常绝不会引发进一步的异常,所以,有关联的内核控制路径最多是2个(第一个是系统调用造成的,第二个是页错误造成的)。也就是说,页错误的异常最多嵌套2层。和异常相反,尽管内核代表当前进程处理这些中断,但是,I/O设备引发的中断和当前进程没有直接数据引用的关系。事实上,给定一个中断,无法推断出是哪个进程在运行。所以,中断的执行不会引起进程的切换,也就可以无限嵌套处理。中断处理程序可以打断中断或异常处理程序执行,但是反过来,异常不能打断中断处理程序。中断处理程序绝对不能包含页错误的操作,因为这会诱发进程切换。Linux嵌套执行中断或异常处理程序的两个主要原因是:为了提高可编程中断控制器和设备控制器的吞吐量。内核正在处理一个中断的时候,能够及时响应另一个中断。实现没有中断优先级的模型。这可以简化内核代码并提高可移植性。在多核系统中,几个中断或异常处理程序可能会并发执行。更重要的是,异常处理程序可能由于进程切换,造成在一个CPU上启动,然后迁移到另一个CPU上执行。
在分析这三个系统调用之前,先来理解一下进程的4要素:执行代码每个进程或者线程都要有自己的执行代码,不论是独立的,还是共享的一段代码。私有堆栈空间进程具有自己独立的堆栈空间,但是对于线程来说它是共享的。进程控制块(task_struct)不论是进程还是线程,都有自己的task_struct。Linux对于线程的实现采用”轻进程”。有独立的内存空间线程具有独立的内存空间,而线程共享内存空间。Linux内核用于创建进程的系统调用有3个,它们的实现分别为:fork、vfork、clone。它们的作用如下表所示:调用描述clone创建轻量级进程(也就是线程),pthread库基于此实现vfork父子进程共享资源,子进程先于父进程执行fork创建父进程的完整副本下面我们来看一下3个函数的区别:1. clone()创建轻量级进程,其拥有的参数是:fn指定新进程执行的函数。当从函数返回时,子进程终止。函数返回一个退出码,表明子进程的退出状态。arg指向fn()函数的参数。flags一些标志位,低字节是表示当子进程终止时发送给父进程的信号,通常是SIGCHLD信号。其余的3个字节是一组标志,如下表所示:名称描述CLONE_VM共享内存描述符和所有的页表CLONE_FS共享文件系统CLONE_FILES共享打开的文件CLONE_SIGHAND共享信号处理函数,阻塞和挂起的信号等CLONE_PTRACEdebug用,父进程被追踪,子进程也追踪CLONE_VFORK父进程挂起,直到子进程释放虚拟内存资源CLONE_PARENT设置子进程的父进程是调用者的父进程,也就是创建兄弟进程CLONE_THREAD共享线程组,设置相应的线程组数据CLONE_NEWNS设置自己的命令空间,也就是有独立的文件系统CLONE_SYSVSEM共享System V IPC可撤销信号量操作CLONE_SETTLS为轻量级进程创建新的TLS段CLONE_PARENT_SETTID写子进程PID到父进程的用户态变量中CLONE_CHILD_CLEARTID设置时,当子进程exit或者exec时,给父进程发送信号child_stack指定子进程的用户态栈指针,存储在子进程的esp寄存器中。父进程总是给子进程分配一个新的栈。tls指向为轻量级进程定义的TLS段数据结构的地址。只有CLONE_SETTLS标志设置了才有意义。ptid指定父进程的用户态变量地址,用来保存新建轻量级进程的PID。只有CLONE_PARENT_SETTID标志设置了才有意义。ctid指定新进程保存PID的用户态变量的地址。只有CLONE_CHILD_SETTID标志设置了才有意义。clone()其实是一个C库中的封装函数,它建立新进程的栈并调用sys_clone()系统调用。sys_clone()系统调用没有参数fn和arg。事实上,clone()把fn函数的指针保存到子进程的栈中return地址处,指针arg紧随其后。当clone()函数终止时,CPU从栈上获取return地址并执行fn(arg)函数。下面我们看一个C代码示例,看看clone()函数的使用:#include <stdio.h> #include <stdlib.h> #include <malloc.h> #include <linux/sched.h> #include <signal.h> #include <sys/types.h> #include <unistd.h> #define FIBER_STACK 8192 int a; void *stack; int do_something() { printf("This is the son, and my pid is:%d, and a = %d\n", getpid(), ++a); free(stack); exit(1); } int main() { void * stack; a = 1; /* 为子进程申请系统堆栈 */ stack = malloc(FIBER_STACK); if(!stack) { printf("The stack failed\n"); exit(0); } printf("Creating son thread!!!\n"); clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM|CLONE_VFORK, 0);//创建子线程 printf("This is the father, and my pid is: %d, and a = %d\n", getpid(), a); exit(1); }上面的代码就相当于实现了一个vfork(),只有子进程执行完并释放虚拟内存资源后,父进程执行。执行结果是:Creating son thread!!! This is the son, and my pid is:3733, and a = 2 This is the father, and my pid is: 3732, and a = 2它们现在共享堆栈,所以a的值是相等的。2. fork()linux将fork实现为这样的clone()系统调用,其flags参数指定为SIGCHLD信号并清除所有clone标志,child_stack参数是当前父进程栈的指针。父进程和子进程暂时共享相同的用户态堆栈。然后采用 写时复制技术,不管是父进程还是子进程,在尝试修改堆栈时,立即获得刚才共享的用户态堆栈的一个副本。也就是成为了一个单独的进程。#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main(void) { int count = 1; int child; child = fork(); if(child < 0){ perror("fork error : "); } else if(child == 0){ // child process printf("This is son, his count is: %d (%p). and his pid is: %d\n", ++count, &count, getpid()); } else{ // parent process printf("This is father, his count is: %d (%p), his pid is: %d\n", count, &count, getpid()); } return EXIT_SUCCESS; }上面代码的执行结果:This is father, his count is: 1 (0xbfdbb384), his pid is: 3994 This is son, his count is: 2 (0xbfdbb384). and his pid is: 3995可以看出,父子进程的PID是不一样的,而且堆栈也是独立的(count计数一个是1,一个是2)。3. vfork()将vfork实现为这样的clone()系统调用,flags参数指定为SIGCHLD|CLONE_VM|CLONE_VFORK信号,child_stack参数等于当前父进程栈指针。vfork其实是一种过时的应用,vfork也是创建一个子进程,但是子进程共享父进程的空间。在vfork创建子进程之后,阻塞父进程,直到子进程执行了exec()或exit()。vfork最初是因为fork没有实现COW机制,而在很多情况下fork之后会紧接着执行exec,而exec的执行相当于之前的fork复制的空间全部变成了无用功,所以设计了vfork。而现在fork使用了COW机制,唯一的代价仅仅是复制父进程页表的代价,所以vfork不应该出现在新的代码之中。实际上,vfork创建的是一个线程,与父进程共享内存和堆栈空间:我们看下面的示例代码:#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main() { int count = 1; int child; printf("Before create son, the father's count is:%d\n", count); if(!(child = vfork())) { printf("This is son, his pid is: %d and the count is: %d\n", getpid(), ++count); exit(1); } else { printf("This is father, pid is: %d and count is: %d\n", getpid(), count); } }执行结果为:Before create son, the father's count is:1 This is son, his pid is: 3564 and the count is: 2 This is father, pid is: 3563 and count is: 2从运行结果看,vfork创建的子进程(线程)共享了父进程的count变量,所以,子进程修改count,父进程的count值也改变了。另外由vfork创建的子进程要先于父进程执行,子进程执行时,父进程处于挂起状态,子进程执行完,唤醒父进程。除非子进程exit或者execve才会唤起父进程,看下面程序:#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main() { int count = 1; int child; printf("Before create son, the father's count is:%d\n", count); if(!(child = vfork())) { int i; for(i = 0; i < 100; i++) { printf("Child process & i = %d\n", i); count++; if(i == 20) { printf("Child process & pid = %d;count = %d\n", getpid(), ++count); exit(1); } } } else { printf("Father process & pid = %d ;count = %d\n", getpid(), count); } }执行结果为:Before create son, the father's count is:1 Child process & i = 0 Child process & i = 1 Child process & i = 2 Child process & i = 3 Child process & i = 4 Child process & i = 5 Child process & i = 6 Child process & i = 7 Child process & i = 8 Child process & i = 9 Child process & i = 10 Child process & i = 11 Child process & i = 12 Child process & i = 13 Child process & i = 14 Child process & i = 15 Child process & i = 16 Child process & i = 17 Child process & i = 18 Child process & i = 19 Child process & i = 20 Child process & pid = 3755;count = 23 Father process & pid = 3754 ;count = 23从上面的结果可以看出,父进程总是等子进程执行完毕后才开始继续执行。总结clone、vfork和fork是根据不同的需求而开发的。clone 参数比较多,可以实现的控制就比较多,clone的设计初衷是给pthread线程库的开发提供支持的。其实用它也完全可以实现另外两种系统调用。vfork是一个过时的系统调用,当时是因为写时复制(COW)技术还没有。所以才设计了这个子进程先于父进程的执行的创建进程的系统调用。fork就是一个创建完整进程的调用。clone、vfork和fork在内核层都是调用的_do_fork()这个函数。
3.8 定义复杂函数复杂函数必须能够调用其它函数,且能够计算任意复杂度的表达式,还能正确地返回到调用者中。考虑下面的示例,具有3个参数和2个局部变量的函数:.global func func: pushq %rbp # 保存基址指针 movq %rsp, %rbp # 设置新的基址指针 pushq %rdi # 第一个参数压栈 pushq %rsi # 第二个参数压栈 pushq %rdx # 第三个参数压栈 subq $16, %rsp # 给2个局部变量分配栈空间 pushq %rbx # 保存应该被调用者保存的寄存器 pushq %r12 pushq %r13 pushq %r14 pushq %r15 ### 函数体 ### popq %r15 # 恢复被调用者保存的寄存器 popq %r14 popq %r13 popq %r12 popq %rbx movq %rbp, %rsp # 复位栈指针 popq %rbp # 恢复之前的基址指针 ret # 返回到调用者这个函数需要追踪的信息比较多:函数参数,返回需要的信息,局部变量空间等等。考虑到这个目的,我们使用基址指针寄存器%rbp。栈指针%rsp指向新栈的栈顶,而%rbp指向新栈的栈底。%rsp和%rbp之间的这段空间就是函数调用的栈帧。还有就是,函数需要调用寄存器计算表达式,也就是上面的%rbx、%r12、%r13、%r14、%r15、%rbp、%rsp。这些寄存器可能已经在调用者函数体内被使用,所以我们不希望被调用函数内部破坏这些寄存器的值。这就需要在被调用函数中保存这些寄存器的值,在返回之前,再恢复这些寄存器之前的值。下图是func函数的栈布局:图3 X86-64栈布局示例基址指针寄存器(%rbp)位于栈的起始处。所以,在函数体内,完全可以使用基址变址寻址方式,去引用参数和局部变量。参数紧跟在基址指针后面,所以参数0的位置就是-8(%rbp),参数1的位置就是-16(%rbp),依次类推。接下来是局部变量,位于-32(%rbp)地址处。然后保存的寄存器位于-48(%rbp)地址处。栈指针指向栈顶的元素。下面是一个复杂函数的C代码示例:compute: function integer ( a: integer, b: integer, c: integer ) = { x:integer = a+b+c; y:integer = x*5; return y; }将其完整地转换成汇编代码,如下所示:.global compute compute: ##################### preamble of function sets up stack pushq %rbp # save the base pointer movq %rsp, %rbp # set new base pointer to rsp pushq %rdi # save first argument (a) on the stack pushq %rsi # save second argument (b) on the stack pushq %rdx # save third argument (c) on the stack subq $16, %rsp # allocate two more local variables pushq %rbx # save callee-saved registers pushq %r12 pushq %r13 pushq %r14 pushq %r15 ######################## body of function starts here movq -8(%rbp), %rbx # load each arg into a register movq -16(%rbp), %rcx movq -24(%rbp), %rdx addq %rdx, %rcx # add the args together addq %rcx, %rbx movq %rbx, -32(%rbp) # store the result into local 0 (x) movq -32(%rbp), %rbx # load local 0 (x) into a register. movq $5, %rcx # load 5 into a register movq %rbx, %rax # move argument in rax imulq %rcx # multiply them together movq %rax, -40(%rbp) # store the result in local 1 (y) movq -40(%rbp), %rax # move local 1 (y) into the result #################### epilogue of function restores the stack popq %r15 # restore callee-saved registers popq %r14 popq %r13 popq %r12 popq %rbx movq %rbp, %rsp # reset stack to base pointer. popq %rbp # restore the old base pointer ret # return to caller下面有转换为汇编的代码段。代码是正确的,但不是完美的。事实证明,这个函数不需要使用寄存器%rbx和%r15,所以不需要保存和恢复他们。同样的,我们也可以把参数就保留在寄存器中而不必把它们压栈。结果也不必存入局部变量y中,而是可以直接写入到%rax寄存器中。这其实就是编译器优化功能的一部分。4 ARM汇编最新的ARM架构是ARMv7-A(32位)和ARMv8-A(64位)。本文着重介绍32位架构,最后讨论一下64位体系架构的差异。ARM是一个精简指令计算机(RISC)架构。相比X86,ARM使用更小的指令集,这些指令更易于流水线操作或并行执行,从而降低芯片复杂度和能耗。但由于一些例外,ARM有时候被认为是部分RISC架构。比如,一些ARM指令执行时间的差异使流水线不完美,为预处理而包含的桶形移位器引入了更复杂的指令,还有条件指令减少了一些潜在指令的执行,导致跳转指令的使用减少,从而降低了处理器的能耗。我们侧重于编写编译器常用到的指令,更复杂的内容和程序语言的优化留到以后再研究。4.1 寄存器和数据类型32位ARM架构拥有16个通用目的寄存器,r0~r15,使用约定如下所示:名称别名目的r0-通用目的寄存器r1-通用目的寄存器...--r10-通用目的寄存器r11fp栈帧指针,栈帧起始地址r12ip内部调用临时寄存器r13sp栈指针r14lr链接寄存器(返回地址)r15pc程序计数器除了通用目的寄存器,还有2个寄存器:当前程序状态寄存器(CPSR)和程序状态保存寄存器(SPSR),它们不能被直接访问。这两个寄存器保存着比较运算的结果,以及与进程状态相关的特权数据。用户态程序不能直接访问,但是可以通过一些操作的副作用修改它们。ARM使用下面的后缀表示数据大小。它们与X86架构不同!如果没有后缀,汇编器假设操作数是unsigned word类型。有符号类型提供正确的符号位。任何word类型寄存器不会再有细分且被命名的寄存器。后缀数据类型大小BByte8 位HHalfword16 位WWORD32 位-Double Word64 位SBSigned Byte8 位SHSigned Halfword16 位SWSigned Word32 位-Double Word64 位4.2 寻址模式与X86不同,ARM使用两种不同的指令分别搬运寄存器之间、寄存器与内存之间的数据。MOV拷贝寄存器之间的数据和常量,而LDR和STR指令拷贝寄存器和内存之间的数据。MOV指令可以把一个立即数或者寄存器值搬运到另一个寄存器中。ARM中,用#表示立即数,这些立即数必须小于等于16位。如果大于16位,就会使用LDR指令代替。大部分的ARM指令,目的寄存器在左,源寄存器在右。(STR是个例外)。具体格式如下:模式示例立即数MOV r0, #3寄存器MOV r1, r0MOV指令后面添加标识数据类型的字母,确定传输的类型和如何传输数据。如果没有指定,汇编器假定为word。从内存中搬运数据使用LDR和STR指令,它们把源寄存器和目的寄存器作为第一个参数,要访问的内存地址作为第二个参数。简单情况下,使用寄存器给出地址并用中括号[]标记:LDR Rd, [Ra] STR Rs, [Ra]Rd,表示目的寄存器;Rs,表示源寄存器;Ra,表示包含内存地址的寄存器。(必须要注意内存地址的类型,可以使用任何内存地址访问字节数据,使用偶数地址访问半字数据等。)ARM寻址模式模式示例文本LDR Rd, =0xABCD1234绝对地址LDR Rd, =label寄存器间接寻址LDR Rd, [Ra]先索引-立即数LDR Rd, [Ra, #4]先索引-寄存器LDR Rd, [Ra, Ro]先索引-立即数&WritebackLDR Rd, [Ra, #4]!先索引-寄存器&WritebackLDR Rd, [Ra, Ro]!后索引-立即数LDR Rd, [Ra], #4后索引-寄存器LDR Rd, [Ra], Ro如上表所示,LDR和STR支持多种寻址模式。首先,LDR能够加载一个32位的文本值(或绝对地址)到寄存器。(完整的解释请参考下一段内容)。与X86不同,ARM没有可以从一个内存地址拷贝数据的单指令。为此,首先需要把地址加载到一个寄存器,然后执行一个寄存器间接寻址:LDR r1, =x LDR r2, [r1]为了方便高级语言中的指针、数组、和结构体的实现,还有许多其它可用的寻址模式。比如,先索引模式可以添加一个常数(或寄存器)到基址寄存器,然后从计算出的地址加载数据:LDR r1, [r2, #4] ; # 载入地址 = r2 + 4 LDR r1, [r2, r3] ; # 载入地址 = r2 + r3有时候可能需要在把计算出的地址中的内容读取后,再把该地址写回到基址寄存器中,这可以通过在后面添加感叹号!实现。LDR r1, [r2, #4]! ; # 载入地址 = r2 + 4 然后 r2 += 4 LDR r1, [r2, r3]! ; # 载入地址 = r2 + r3 然后 r2 += r3后索引模式做相同的工作,但是顺序相反。首先根据基址地址执行加载,然后基址地址再加上后面的值:LDR r1, [r2], #4 ; # 载入地址 = r2 然后 r2 += 4 LDR r1, [r2], r3 ; # 载入地址 = r2 然后 r2 += r3通过先索引和后索引模式,可以使用单指令实现像我们经常写的C语句b = a++。STR使用方法类似。在ARM中,绝对地址以及其它长文本更为复杂些。因为每条指令都是32位的,因此不可能将32位的地址和操作码(opcode)一起添加到指令中。因此,长文本存储在一个文本池中,它是程序代码段中一小段数据区域。使用与PC寄存器相关的加载指令,比如LDR,加载文本类型数据,这样的文本池可以引用靠近load指令的±4096个字节数据。这导致有一些小的文本池散落在程序中,由靠近它们的指令使用。ARM汇编器隐藏了这些复杂的细节。在绝对地址和长文本的前面加上等号=,就代表向汇编器表明,标记的值应该存储在一个文本池中,并使用与PC寄存器相关的指令代替。例如,下面的指令,把x的地址加载到r1中,然后取出x的值,存入r2寄存器中。LDR r1, =x LDR r2, [r1]下面的代码展开后,将会从相邻的文本池中加载x的地址,然后加载x的值,存入r2寄存器中。也就是,下面的代码与上面的代码是一样的。LDR r1, .L1 LDR r2, [r1] B .end .L1: .word x .end:4.3 基本算术运算ARM的ADD和SUB指令,使用3个地址作为参数。目的寄存器是第一个参数,第二、三个参数作为操作数。其中第三个参数可以是一个8位的常数,或者带有移位的寄存器。使能进位的加、减法指令,将CPSR寄存器的C标志位写入到结果中。这4条指令如果分别后缀S,代表在完成时是否设置条件标志(包括进位),这是可选的。指令示例加ADD Rd, Rm, Rn带进位加ADC Rd, Rm, Rn减SUB Rd, Rm, Rn带进位减SBC Rd, Rm, Rn乘法指令的工作方式与加减指令类似,除了将2个32位的数字相乘能够产生一个64位的值之外。普通的MUL指令舍弃了结果的高位,而UMULL指令把结果分别保存在2个寄存器中。有符号的指令SMULL,在需要的时候会把符号位保存在高寄存器中。指令示例乘法MUL Rd, Rm, Rn无符号长整形UMULL RdHi, RdLo, Rm, Rn有符号长整形SMULL RdHi, RdLo, Rm, RnARM没有除法指令,因为它不能在单个流水线周期中执行。因此,需要除法的时候,调用外部标准库中的函数。逻辑指令在结构上和算术指令非常相似,如下图所示。特殊的是MVN指令,执行按位取反然后将结果保存到目的寄存器。指令示例位与AND Rd, Rm, Rn位或ORR Rd, Rm, Rn位异或EOR Rd, Rm, Rn位置0BIC Rd, RM, Rn取反并移动MVN Rd, Rn4.4 比较和跳转比较指令CMP比较2个值,将比较结果写入CPSR寄存器的N(负)和Z(零)标志位,供后面的指令读取使用。如果比较一个寄存器值和立即数,立即数必须作为第二个操作数:CMP Rd, Rn CMP Rd, #imm另外,也可以在算术指令后面添加S标志,以相似的方式更新CPSR寄存器的相应标志位。比如,SUBS指令是两个数相减,保存结果,并更新CPSR。ARM跳转指令操作码意义操作码意义B无条件跳转BL设置lr寄存器为下一条指令的地址并跳转BX跳转并切换状态BLXBL+BX指令的组合BEQ相等跳转BVS溢出标志设置跳转BNE不等跳转BVC溢出标志清除跳转BGT大于跳转BHI无符号>跳转BGE大于等于跳转BHS无符号>=跳转BLT小于跳转BLO无符号<跳转BLE小于等于跳转BLS无符号<=跳转BMI负值跳转BPL>= 0跳转各种跳转指令参考CPSR寄存器中之前的值,如果设置正确就跳到相应的地址(标签表示)执行。无条件跳转指令就是一个简单的B。比如,从0累加到5:MOV r0, #0 loop: ADD r0, r0, 1 CMP r0, #5 BLT loop再比如,如果x大于0,则给y赋值为:10;否则,赋值为20:LDR r0, =x LDR r0, [r0] CMP r0, #0 BGT .L1 .L0: MOV r0, #20 B .L2 .L1: MOV r0, #10 .L2: LDR r1, =y STR r0, [r1]BL指令用来实现函数调用。BL指令设置lr寄存器为下一条指令的地址,然后跳转到给定的标签(比如绝对地址)处执行,并将lr寄存器的值作为函数结束时的返回地址。BX指令跳转到寄存器中给定的地址处,最常用于通过跳转到lr寄存器而从函数调用中返回。BLX指令执行的动作跟BL指令一样,只是操作对象换成了寄存器中给定的地址值,常用于调用函数指针,虚函数或其它间接跳转的场合。ARM指令集的一个重要特性就是条件执行。每条指令中有4位表示16中可能的条件,如果条件不满足,指令被忽略。上面各种类型的跳转指令只是在最单纯的B指令上应用了各种条件而已。这些条件几乎可以应用到任何指令。例如,假设下面的代码片段,哪个值小就会自加1:if(a<b) { a++; } else { b++; }代替使用跳转指令和标签实现这个条件语句,我们可以前面的比较结果对每个加法指令设置条件。无论那个条件满足都被执行,而另一个被忽略。如下面所示(假设a和b分别存储在寄存器r0和r1中):CMP r0, r1 ADDLT r0, r0, #1 ADDGE r1, r1, #14.5 栈栈是一种辅助数据结构,主要用来存储函数调用历史以及局部变量。按照约定,栈的增长方向是从髙地址到低地址。sp寄存器保存栈指针,用来追踪栈顶内容。为了把寄存器r0压入栈中,首先,sp减去寄存器的大小,然后把r0存入sp指定的位置:SUB sp, sp, #4 STR r0, [sp]或者,可以使用一条单指令完成这个操作,如下所示:STR r0, [sp, #-4]!这儿,使用了先索引并write-back的寻址方式。也就是说,sp先减4,然后把r0的内容存入sp-4指向的地址处,然后再把sp-4写入到sp中。ARM调用习惯总结前4个参数存储在r0、r1、r2 和r3中;其余的参数按照相反的顺序存入栈中;如果需要,调用者必须保存r0-r3和r12;调用者必须始终保存r14,即链接寄存器;如果需要,被调用者必须保存r4-r11;结果存到r0寄存器中。PUSH伪指令可以压栈的动作,还可以把任意数量的寄存器压入栈中。使用花括号{}列出要压栈的寄存器列表:PUSH {r0,r1,r2}出栈的动作正好与压栈的动作相反:LDR r0, [sp] ADD sp, sp, #4使用后索引模式LDR r0, [sp], #4使用POP指令弹出一组寄存器:POP {r0,r1,r2}与X86不同的是,任何数据项(从字节到双word)都可以压入栈,只要遵守数据对齐即可。4.6 函数调用《The ARM-Thumb Procedure Call Standard》描述了ARM的寄存器调用约定,其摘要如下:ARM寄存器分配:寄存器目的谁保存r0参数0不保存r1参数1调用者保存r2参数2调用者保存r3参数3调用者保存r4临时被调用者保存.........r10临时被调用者保存r11栈帧指针被调用者保存r12内部过程调用者保存r13栈指针被调用者保存r14链接寄存器调用者保存r15程序计数器保存在r14为了调用一个函数,把参数存入r0-r3寄存器中,保存lr寄存器中的当前值,然后使用BL指令跳转到指定的函数。返回时,恢复lr寄存器的先前值,并检查r0寄存器中的结果。比如,下面的C语言代码段:int x=0; int y=10; int main() { x = printf("value: %d\n",y); }其编译后的ARM汇编格式为:.data x: .word 0 y: .word 10 S0: .ascii "value: %d\012\000" .text main: LDR r0, =S0 @ 载入S0的地址 LDR r1, =y @ 载入y的地址 LDR r1, [r1] @ 载入y的值 PUSH {ip,lr} @ 保存ip和lr寄存器的值 BL printf @ 调用printf函数 POP {ip,lr} @ 恢复寄存器的值 LDR r1, =x @ 载入x的地址 STR r0, [r1] @ 把返回的结果存入x中 .end4.7 定义叶子函数因为使用寄存器传递函数参数,所以编写一个不调用其它函数的叶子函数非常简单。比如下面的代码:square: function integer ( x: integer ) = { return x*x; }它的汇编代码可以非常简单:.global square square: MUL r0, r0, r0 @ 参数本身相乘 BX lr @ 返回调用者但是,很不幸,对于想要调用其他函数的函数,这样的实现就无法工作,因为我们没有正确建立函数使用的栈。所以,需要一种更为复杂的方法。4.8 定义复杂函数复杂函数必须能够调用其它函数并计算任意复杂度的表达式,然后正确地返回到调用者之前的状态。还是考虑具有3个参数和2个局部变量的函数:func: PUSH {fp} @ 保存栈帧指针,也就是栈的开始 MOV fp, sp @ 设置新的栈帧指针 PUSH {r0,r1,r2} @ 参数压栈 SUB sp, sp, #8 @ 分配2个局部变量的栈空间 PUSH {r4-r10} @ 保存调用者的寄存器 @@@ 函数体 @@@ POP {r4-r10} @ 恢复调用者的寄存器 MOV sp, fp @ 复位栈指针 POP {fp} @ 恢复之前的栈帧指针 BX lr @ 返回到调用者通过上面的代码,我们可以看出,不管是ARM架构的函数实现还是X86架构系列的函数实现,本质上都是一样的,只是指令和寄存器的使用不同。图4 ARM栈布局示例同样考虑下面一个带有表达式计算的复杂函数的C代码:compute: function integer ( a: integer, b: integer, c: integer ) = { x: integer = a+b+c; y: integer = x*5; return y; }将其完整地转换成汇编代码,如下所示:.global compute compute: @@@@@@@@@@@@@@@@@@ preamble of function sets up stack PUSH {fp} @ save the frame pointer MOV fp, sp @ set the new frame pointer PUSH {r0,r1,r2} @ save the arguments on the stack SUB sp, sp, #8 @ allocate two more local variables PUSH {r4-r10} @ save callee-saved registers @@@@@@@@@@@@@@@@@@@@@@@@ body of function starts here LDR r0, [fp,#-12] @ load argument 0 (a) into r0 LDR r1, [fp,#-8] @ load argument 1 (b) into r1 LDR r2, [fp,#-4] @ load argument 2 (c) into r2 ADD r1, r1, r2 @ add the args together ADD r0, r0, r1 STR r0, [fp,#-20] @ store the result into local 0 (x) LDR r0, [fp,#-20] @ load local 0 (x) into a register. MOV r1, #5 @ move 5 into a register MUL r2, r0, r1 @ multiply both into r2 STR r2, [fp,#-16] @ store the result in local 1 (y) LDR r0, [fp,#-16] @ move local 1 (y) into the result @@@@@@@@@@@@@@@@@@@ epilogue of function restores the stack POP {r4-r10} @ restore callee saved registers MOV sp, fp @ reset stack pointer POP {fp} @ recover previous frame pointer BX lr @ return to the caller构建一个合法栈帧的形式有多种,只要函数使用栈帧的方式一致即可。比如,被调函数可以首先把所有的参数和需要保存的寄存器压栈,然后再给局部变量分配栈空间。(当然了,函数返回时,顺序必须正好相反。)还有一种常用的方式就是,在将参数和局部变量压栈之前,为被调函数执行PUSH {fp,ip,lr,pc},将这些寄存器压入栈中。尽管这不是实现函数的严格要求,但是以栈回溯的形式为调试器提供了调试信息,可以通过函数的调用栈,轻松地重构程序的当前执行状态。与前面描述X86_64的示例时一样,这段代码也是有优化的空间的。事实证明,这个函数不需要保存寄存器r4和r5,当然也就不必恢复。同样的,参数我们也不需要非得保存到栈中,可以直接使用寄存器。计算结果可以直接写入到寄存器r0中,不必再保存到变量y中。这其实就是ARM相关的编译器所要做的工作。4.9 ARM-64位支持64位的ARMv8-A架构提供了两种扩展模式:A32模式-支持上面描述的32位指令集;A64模式-支持64位执行模式。这就允许64位的CPU支持操作系统可以同时执行32位和64位程序。虽然A32模式的二进制执行文件和A64模式不同,但是有一些架构原理是相同的,只是做了一些改变而已:字宽度A64模式的指令还是32位大小的,只是寄存器和地址的计算是64位。寄存器A64具有32个64位的寄存器,命名为x0-x31。x0是专用的0寄存器:当读取时,总是返回0值;写操作无效。x1-x15是通用目的寄存器,x16和x17是为进程间通信使用,x29是栈帧指针寄存器,x30是lr链接寄存器,x31是栈指针寄存器。(程序寄存器(PC)用户态代码不可直接访问)32位的值可以通过将寄存器命名为w#来表示,而不是使用数据类型后缀,在这儿#代表0-31。指令A64模式的指令大部分和A32模式相同,使用相同的助记符,只是有一点小差异。分支预测不再是每条指令的一部分。相反,所有的条件执行代码必须显式地执行CMP指令,然后执行条件分支指令。LDM/STM指令和伪指令PUSH/POP不可用,必须通过显式地加载和存储指令序列实现。(使用LDP/STP,在加载和存储成对的寄存器时更有效率)。调用习惯当调用函数的时候,前8个参数被存储到寄存器x0-x7中,其余的参数压栈。调用者必须保留寄存器x9-x15和x30,而被调用者必须保留x19-x29。返回值的标量部分存储到x0中,而返回值的扩展部分存储到x8中。5 参考本文对基于X86和ARM架构的汇编语言的核心部分做了阐述,可以满足大部分需要了。但是,如果需要了解各个指令的细节,可以参考下面的文档。Intel64 and IA-32 Architectures Software Developer Manuals. Intel Corp., 2017. http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.htmlSystem V Application Binary Interface, Jan Hubicka, Andreas Jaeger, Michael Matz, and Mark Mitchell (editors), 2013. https://software.intel.com/sites/default/files/article/402129/mpx-linux64-abi.pdfARM Architecture Reference Manual ARMv8. ARM Limited, 2017. https://static.docs.arm.com/ddi0487/bb/DDI0487B_b_armv8_arm.pdf.The ARM-THUMB Procedure Call Standard. ARM Limited, 2000. http://infocenter.arm.com/help/topic/com.arm.doc.espc0002/ATPCS.pdf.回到顶部
1 引言为了阅读Linux内核源代码,是需要一些汇编语言知识的。因为与架构相关的代码基本上都是用汇编语言编写的,所以掌握一些基本的汇编语言语法,能够更好地理解Linux内核源代码,甚至可以对各种架构的差异有一个更深入的理解。大部分人可能认为汇编语言晦涩难懂,阅读手册又冗长乏味。但是,经过本人的经验,可能常用的指令也就是30个。许多其它的指令都是解决特定的情况而出现,比如浮点运算和多媒体指令。所以,本文就从常用指令出发,基于GNU汇编语言格式,对x86_64架构和ARM架构下的指令做了一个入门介绍。学习完这篇文章,希望可以对汇编有一个基本的理解,并能够解决大部分问题。阅读本文需要一些硬件架构的知识。必要的时候,可以翻阅Intel Software Developer Manual和ARM Architecture Reference Manual。2 开源汇编工具对于相同的芯片架构,不同的芯片制造商或者其它开源工具可能会有不同的语法格式。所以,本文支持GNU编译器和汇编器,分别是gcc和as(有时候也称为gas)。将C代码转换成汇编代码,是一种非常好的学习方式。所以,可以通过在编译选项中加入-S标志,生成汇编目标文件。在类Unix系统,汇编源代码文件使用.s的后缀标记。比如,运行gcc -S hello.c -o hello.s编译命令,编译hello程序:#include <stdio.h> int main( int argc, char *argv[] ) { printf("hello %s\n","world"); return 0; }可以在hello.s文件中看到如下类似的输出:.file "test.c" .data .LC0: .string "hello %s\n" .LC1: .string "world" .text .global main main: PUSHQ %rbp MOVQ %rsp, %rbp SUBQ $16, %rsp MOVQ %rdi, -8(%rbp) MOVQ %rsi, -16(%rbp) MOVQ $.LC0, %rax MOVQ $.LC1, %rsi MOVQ %rax, %rdi MOVQ $0, %rax CALL printf MOVQ $0, %rax LEAVE RET从上边的汇编代码中可以看出,汇编代码大概由三部分组成:伪指令伪指令前缀一个小数点.,供汇编器、链接器或者调试器使用。比如,.file记录最初的源文件名称,这个名称对调试器有用;.data,表明该部分的内容是程序的数据段;.text,表明接下来的内容是程序代码段的内容;.string,表示一个数据段中的字符串常量;.global main,表示符号main是一个全局符号,可以被其它代码模块访问。标签标签是由编译器产生,链接器使用的一种引用符号。本质上,就是对代码段的一个作用域打上标签,方便链接器在链接阶段将所有的代码拼接在一起。所以,标签就是链接器的一种助记符。汇编指令真正的汇编代码,其实就是机器码的助记符。GNU汇编对大小写不敏感,但是为了统一,我们一般使用大写。汇编代码编译成可执行文件,可以参考下面的代码编译示例:% gcc hello.s -o hello % ./hello hello world把汇编代码生成目标文件,然后可以使用nm工具显示代码中的符号,参考下面的内容:% gcc hello.s -c -o hello.o % nm hello.o 0000000000000000 T main U printfnm -> 是names的缩写,nm命令主要是用来列出某些文件中的符号(换句话说就是一些函数和全局变量)。上面的代码显示的符号对于链接器都是可用的。main出现在目标文件的代码段(T),位于地址0处,也就是说位于文件的开头;printf未定义(U),因为它需要从库文件中链接。但是像.LC0之类的标签出现,因为它们没有使用.global,所以说对于链接器是无用的。编写C代码,然后编译成汇编代码。这是学习汇编一个好的开始。回到顶部3 X86汇编语言X86是一个通用术语,指从最初的IBM-PC中使用的Intel-8088处理器派生(或兼容)的一系列微处理器,包括8086、80286、386、486以及其它许多处理器。每一代cpu都增加了新的指令和寻址模式(从8位到16位再到32位)。同时还保留了与旧代码的向后兼容性。各种竞争对手(如AMD)生产的兼容芯片也实现了相同的指令集。但是,到了64位架构的时候,Intel打破了这个传统,引入了新的架构(IA64)和名称(Itanium),不再向后兼容。它还实现了一种新的技术-超长指令字(VLIW),在一个Word中实现多个并发操作。因为指令级的并发操作可以显著提升速度。AMD还是坚持老方法,实现的64位架构(AMD64)向后兼容Intel和AMD芯片。不论两种技术的优劣,AMD的方法首先赢得了市场,随后Intel也生产自己的64位架构Intel64,并与AMD64和它自己之前的产品兼容。所以,X86-64是一个通用术语,包含AMD64和Intel64架构。X86-64是复杂指令集CISC的代表。3.1 寄存器和数据类型X86-64具有16个通用目的64位寄存器:12345678raxrbxrcxrdxrsirdirbprsp910111213141516r8r9r10r11r12r13r14r15说它们是通用寄存器是不完全正确的,因为早期的CPU设计寄存器是专用的,不是所有的指令都能用到每一个寄存器。从名称上就可以看出来,前八个寄存器的作用,比如rax就是一个累加器。AT&T语法-Intel语法GNU使用传统的AT&T语法,许多类Unix操作系统使用这种风格,与DOS和Windows上用的Intel语法是不同的。下面一条指令是符合AT&T语法:MOVQ %RSP, %RBPMOVQ是指令,%表明RSP和RBP是寄存器。AT&T语法,源地址在前,目的地址在后。Intel语法省略掉%,参数顺序正好相反。同样的指令,如下所示:MOVQ RBP, RSP所以,看%就能区分是AT&T语法,还是Intel语法。随着设计的发展,新的指令和寻址模式被添加进来,使得这些寄存器几乎一样了。其余的指令,尤其是和字符串处理相关的指令,要求使用rsi和rdi寄存器。另外,还有两个寄存器专门为栈指针寄存器(rsp)和基址指针寄存器(rbp)保留。最后的8个寄存器没有特殊的限制。随着处理器从8位一直扩展到64位,有一些寄存器还能拆分使用。rax的低八位是一个8位寄存器al,接下来的8位称为ah。如果把rax的低16位组合起来就是ax寄存器,低32位就是累加器eax,整个64位才是rax寄存器。这样设计的目的是向前兼容,具体可以参考下图:图1: X86 寄存器结构r8-r15,这8个寄存器具有相同的结构,就是命名机制不同。图2: X86 寄存器结构为了简化描述,我们还是着重讲64位寄存器。但是,大多数编译器支持混合模式:一个字节可以表示一个布尔型;32位对于整数运算就足够了,因为大多数程序不需要大于2^32以上的整数值;64位类型常用于内存寻址,能够使虚拟地址的空间理论上可以达到1800万TB(1TB=1024GB)。3.2 寻址模式MOV指令可以使用不同的寻址模式,在寄存器和内存之间搬运数据。使用B、W、L和Q作为后缀,添加在指令后面,决定操作的数据的位数:后缀名称大小BBYTE1 字节(8位)WWORD2 字节(16位)LLONG4 字节(32位)QQUADWORD8 字节(64位)MOVB移动一个字节,MOVW移动2个字节,MOVL移动4个字节,MOVQ移动8个字节。在某些情况下,可以省略掉这个后缀,编译器可以推断正确的大小。但还是建议加上后缀。MOV指令可以使用下面几种寻址模式:全局符号一般给其定义一个简单的名称,通过这个名称来引用,比如x、printf之类的。编译器会将其翻译成绝对地址或用于地址计算。立即数使用美元符号$标记,比如$56。但是立即数的使用是有限制范围的。寄存器使用寄存器寻址,比如%rbx。间接引用通过寄存器中包含的地址进行寻址,比如(%rsp),表示引用%rsp指向的那个值。基址变址寻址在间接引用的基础上再加上一个常数作为地址进行寻址。比如-16(%rcx),就是寄存器rcx中的地址再减去16个字节的地址处的内容。这种模式对于操作堆栈,局部变量和函数参数非常重要。复杂地址寻址比如,D(RA,RB,C),就是引用*RA + RB * C + D*计算后的地址处的值。RA和RB是通用目的寄存器,C可以是1、2、4或8,D是一个整数位移。这种模式一般用于查找数组中的某一项的时候,RA给出数组的首地址,RB计算数组的索引,C作为数组元素的大小,D作为相对于那一项的偏移量。下表是不同寻址方式下加载一个64位值到%rax寄存器的示例:寻址模式示例全局符号MOVQ x, %rax立即数MOVQ $56, %rax寄存器MOVQ %rbx, %rax间接引用MOVQ (%rsp), %rax基址变址寻址MOVQ -8(%rbp), %rax复杂地址寻址MOVQ -16(%rbx,%rcx,8), %rax大部分时候,目的操作数和源操作数都可以使用相同的寻址模式,但是也有例外,比如MOVQ -8(%rbx), -8(%rbx),源和目的都使用基址变址寻址方式就是不可能的。具体的就需要查看手册了。有时候,你可能需要加载变量的地址而不是其值,这对于使用字符串或数组是非常方便的。为了这个目的,可以使用LEA指令(加载有效地址),示例如下:寻址模式示例全局符号LEAQ x, %rax基址变址寻址LEAQ -8(%rbp), %rax复杂地址寻址LEAQ -16(%rbx,%rcx,8), %rax3.3 基本算术运算你需要为你的编译器提供四种基本的算术指令:整数加法、减法、乘法和除法。ADD和SUB指令有两个操作数:源操作目标和既作源又作目的的操作目标。比如:ADDQ %rbx, %rax将%rbx加到%rax上,把结果存入%rax。这必须要小心,以免破坏后面可能还用到的值。比如:c = a+b+b这样的语句,转换成汇编语言大概是下面这样:MOVQ a, %rax MOVQ b, %rbx ADDQ %rbx, %rax ADDQ %rbx, %rax MOVQ %rax, cIMUL乘法指令有点不一样,因为通常情况下,两个64位的整数会产生一个128位的整数。IMUL指令将第一个操作数乘以rax寄存器中的内容,然后把结果的低64位存入rax寄存器中,高64位存入rdx寄存器。(这里有一个隐含操作,rdx寄存器在指令中并没有提到)比如,假设这样的表达式c = b*(b+a),将其转换成汇编语言;在这儿,a、b、c都是全局整数。MOVQ a, %rax MOVQ b, %rbx ADDQ %rbx, %rax IMULQ %rbx MOVQ %rax, cIDIV指令做相同的操作,除了最后的处理:它把128位整数的低64位存入rax寄存器,高64位存入rdx寄存器,然后除以指令中的第一个操作数。商存入rax寄存器,余数存入rdx寄存器。(如果想要取模指令,只要rdx寄存器的值即可。)为了正确使用除法,必须保证两个寄存器有必要的符号位。如果被除数低64位就可以表示,但是是负数,那么高64位必须都是1,才能完成二进制补码操作。CQO指令可以实现这个特殊目的,将rax寄存器的值的符号位扩展到rdx寄存器中。比如,一个数被5整除:MOVQ a, %rax # 设置被除数的低64位 CQO # 符号位扩展到%rdx IDIVQ $5 # %rdx:%rax除以5,结果保存到%rax自增和自减指令INC、DEC,操作数必须是一个寄存器的值。例如,表达式a = ++b转换成汇编语句后:MOVQ b, %rax INCQ %rax MOVQ %rax, b MOVQ %rax, a指令AND、OR和XOR,提供按位操作。按位操作意味着把操作应用到操作数的每一位,然后保存结果。所以,AND $0101B $0110B就会产生结果$0100B。同样,NOT指令对操作数的每一位执行取反操作。比如,表达式c = (a & ˜b),可以转换成下面这样的汇编代码:MOVQ a, %rax MOVQ b, %rbx NOTQ %rbx ANDQ %rax, %rbx MOVQ %rbx, c这里需要注意的是,算术位操作与逻辑bool操作是不一样的。比如,如果你定义false为整数0,true为非0。在这种情况下,$0001是true,而NOT $0001B的结果也是true!要想实现逻辑bool操作,需要使用CMP比较指令。与MOV指令一样,各种算术指令能在不同寻址模式下工作。但是,对于一个编译器项目,使用MOV指令搬运寄存器之间或者寄存器与立即数之间的值,然后仅使用寄存器操作,会更加方便。3.4 比较和跳转使用JMP跳转指令,我们就可以创建一个简单的无限循环,使用rax累加器从0开始计数,代码如下:MOVQ $0, %raxloop: INCQ %rax JMP loop但是,我们大部分时候需要的是一个有限的循环或者if-then-else这样的语句,所以必须提供计算比较值并改变程序执行流的指令。大部分汇编语言都提供2个指令:比较和跳转。CMP指令完成比较。比较两个不同的寄存器,然后设置EFLAGS寄存器中对应的位,记录比较的值是相等、大于还是小于。使用带有条件跳转的指令自动检查EFLAGS寄存器并跳转到正确的位置。指令意义JE如果相等跳转JNE如果不相等跳转JL小于跳转JLE小于等于跳转JG大于跳转JGE大于等于跳转下面是使用%rax寄存器计算0到5累加值的示例:MOVQ $0, %raxloop: INCQ %rax CMPQ $5, %rax JLE loop下面是一个条件赋值语句,如果全局变量x大于0,则全局变量y=10,否则等于20:MOVQ x, %rax CMPQ $0, %rax JLE .L1 .L0: MOVQ $10, $rbx JMP .L2 .L1: MOVQ $20, $rbx .L2: MOVQ %rbx, y注意,跳转指令要求编译器定义标签。这些标签在汇编文件内容必须是唯一且私有的,对文件外是不可见的,除非使用.global伪指令。标签像.L0、.L1等是由编译器根据需要生成的。3.5 栈栈是记录函数调用过程和局部变量的一种数据结构,也可以说,如果没有栈,C语言的函数是无法工作的。%rsp寄存器称为栈指针寄存器,永远指向栈顶元素(栈的增长方向是向下的)。为了把%rax寄存器的内容压入栈中,我们必须把%rsp寄存器减去8(%rax寄存器的大小),然后再把%rax寄存器内容写入到%rsp寄存器指向的地址处:SUBQ $8, %rsp MOVQ %rax, (%rsp)从栈中弹出数据,正好相反:MOVQ (%rsp), %rax ADDQ $8, %rsp如果仅仅是抛弃栈中最近的值,可以只移动栈指针正确的字节数即可:ADDQ $8, %rsp当然了,压栈和出栈是常用的操作,所以有专门的指令:PUSHQ %rax POPQ %rax需要注意的是,64位系统中,PUSH和POP指令被限制只能使用64位值,所以,如果需要压栈、出栈比这小的数必须使用MOV和ADD实现。3.6 函数调用先介绍一个简单的栈调用习惯:参数按照相反的顺序被压入栈,然后使用CALL调用函数。被调用函数使用栈上的参数,完成函数的功能,然后返回结果到eax寄存器中。调用者删除栈上的参数。但是,64位代码为了尽可能多的利用X86-64架构中的寄存器,使用了新的调用习惯。称之为System V ABI,详细的细节可以参考ABI接口规范文档。这儿,我们总结如下:前6个参数(包括指针和其它可以存储为整形的类型)依次保存在寄存器%rdi、%rsi、%rdx、%rcx、%r8和%r9。前8个浮点型参数依次存储在寄存器%xmm0-%xmm7。超过这些寄存器个数的参数才被压栈。如果函数接受可变数量的参数(如printf),则必须将%rax寄存器设置为浮动参数的数量。函数的返回值存储在%rax。另外,我们也需要知道其余的寄存器是如何处理的。有一些是调用者保存,意味着函数在调用其它函数之前必须保存这些值。另外一些则由被调用者保存,也就是说,这些寄存器可能会在被调用函数中修改,所以被调用函数需要保存调用者的这些寄存器的值,然后从被调用函数返回时,恢复这些寄存器的值。保存参数和结果的寄存器根本不需要保存。下表详细地展示了这些细节:表-System V ABI寄存器分配表寄存器目的谁保存%rax结果不保存%rbx临时被调用者保存%rcx参数4不保存%rdx参数3不保存%rsi参数2不保存%rdi参数1不保存%rbp基址指针被调用者保存%rsp栈指针被调用者保存%r8参数5不保存%r9参数6不保存%r10临时调用者保存%r11临时调用者保存%r12临时被调用者保存%r13临时被调用者保存%r14临时被调用者保存%r15临时被调用者保存为了调用函数,首先必须计算参数,并把它们放置到对应的寄存器中。然后把2个寄存器%r10和%r11压栈,保存它们的值。然后发出CALL指令,它会把当前的指令指针压入栈,然后跳转到被调函数的代码位置。当从函数返回时,从栈中弹出%r10和%r11的内容,然后就可以利用%rax寄存器的返回结果了。这是一个C代码示例:int x=0; int y=10; int main() { x = printf("value: %d\n",y); }翻译成汇编语言大概是:.data x: .quad 0 y: .quad 10 str: .string "value: %d\n" .text .global main main: MOVQ $str, %rdi # 第一个参数保存到%rdi中,是字符串类型 MOVQ y, %rsi # 第二个参数保存到%rsi中,是y MOVQ $0, %rax # 0个浮动参数 PUSHQ %r10 # 保存调用者保存的寄存器 PUSHQ %r11 CALL printf # 调用printf POPQ %r11 # 恢复调用者保存的寄存器 POPQ %r10 MOVQ %rax, x # 保存结果到x RET # 从main函数返回3.7 定义叶子函数因为函数参数保存到寄存器中,所以写一个不调用其它函数的叶子函数是非常简单的。比如,下面的代码:square: function integer ( x: integer ) = { return x*x; }可以简化为:.global square square: MOVQ %rdi, %rax # 拷贝第一个参数到%rax IMULQ %rax # 自己相乘 # 结果保存到%rax RET # 返回到调用函数中不幸的是,这对于还要调用其它函数的函数是不可行的,因为我们没有为其建立正确的栈。所以,需要一个复杂方法实现通用函数。
(文章大部分转载于:https://consen.github.io/2018/01/17/debug-linux-kernel-with-qemu-and-gdb/)排查Linux内核Bug,研究内核机制,除了查看资料阅读源码,还可通过调试器,动态分析内核执行流程。QEMU模拟器原生支持GDB调试器,这样可以很方便地使用GDB的强大功能对操作系统进行调试,如设置断点;单步执行;查看调用栈、查看寄存器、查看内存、查看变量;修改变量改变执行流程等。编译调试版内核对内核进行调试需要解析符号信息,所以得编译一个调试版内核。$ cd linux-4.14 $ make menuconfig $ make -j 20这里需要开启内核参数CONFIG_DEBUG_INFO和CONFIG_GDB_SCRIPTS。GDB提供了Python接口来扩展功能,内核基于Python接口实现了一系列辅助脚本,简化内核调试,开启CONFIG_GDB_SCRIPTS参数就可以使用了。Kernel hacking ---> [*] Kernel debugging Compile-time checks and compiler options ---> [*] Compile the kernel with debug info [*] Provide GDB scripts for kernel debugging构建initramfs根文件系统Linux系统启动阶段,boot loader加载完内核文件vmlinuz后,内核紧接着需要挂载磁盘根文件系统,但如果此时内核没有相应驱动,无法识别磁盘,就需要先加载驱动,而驱动又位于/lib/modules,得挂载根文件系统才能读取,这就陷入了一个两难境地,系统无法顺利启动。于是有了initramfs根文件系统,其中包含必要的设备驱动和工具,boot loader加载initramfs到内存中,内核会将其挂载到根目录/,然后运行/init脚本,挂载真正的磁盘根文件系统。这里借助BusyBox构建极简initramfs,提供基本的用户态可执行程序。编译BusyBox,配置CONFIG_STATIC参数,编译静态版BusyBox,编译好的可执行文件busybox不依赖动态链接库,可以独立运行,方便构建initramfs。$ cd busybox-1.28.0 $ make menuconfig选择配置项:Settings ---> [*] Build static binary (no shared libs)执行编译、安装:$ make -j 20 $ make install会安装在_install目录:$ ls _install bin linuxrc sbin usr创建initramfs,其中包含BusyBox可执行程序、必要的设备文件、启动脚本init。这里没有内核模块,如果需要调试内核模块,可将需要的内核模块包含进来。init脚本只挂载了虚拟文件系统procfs和sysfs,没有挂载磁盘根文件系统,所有调试操作都在内存中进行,不会落磁盘。$ mkdir initramfs $ cd initramfs $ cp ../_install/* -rf ./ $ mkdir dev proc sys $ sudo cp -a /dev/{null,console,tty,tty1,tty2,tty3,tty4} dev/ $ rm linuxrc $ vim init $ chmod a+x init $ ls $ bin dev init proc sbin sys usrinit文件内容:#!/bin/busybox sh mount -t proc none /proc mount -t sysfs none /sys exec /sbin/init打包initramfs:$ find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz调试$ cd busybox-1.28.0 $ qemu-system-i386 -s -kernel ./linux-4.4.203/arch/i386/boot/bzImage -initrd ./initramfs.cpio.gz -nographic -append "console=ttyS0"-s是-gdb tcp::1234缩写,监听1234端口,在GDB中可以通过target remote localhost:1234连接;-kernel指定编译好的调试版内核;-initrd指定制作的initramfs;-nographic取消图形输出窗口,使QEMU成简单的命令行程序;-append "console=ttyS0"将输出重定向到console,将会显示在标准输出stdio。启动后的根目录, 就是initramfs中包含的内容:/ # ls bin dev init proc root sbin sys usr由于系统自带的GDB版本为7.2,内核辅助脚本无法使用,重新编译了一个新版GDB。我的系统比较新,所以gdb版本是7.11,所以不需要重新编译。$ cd gdb-7.9.1 $ ./configure --with-python=$(which python2.7) $ make -j 20 $ sudo make install启动GDB:$ cd linux-4.14 $ /usr/local/bin/gdb vmlinux (gdb) target remote localhost:1234使用内核提供的GDB辅助调试功能:(gdb) apropos lx function lx_current -- Return current task function lx_module -- Find module by name and return the module variable function lx_per_cpu -- Return per-cpu variable function lx_task_by_pid -- Find Linux task by PID and return the task_struct variable ...(此处省略若干行) lx-symbols -- (Re-)load symbols of Linux kernel and currently loaded modules lx-version -- Report the Linux Version of the current kernel (gdb) lx-cmdline console=ttyS0在函数cmdline_proc_show设置断点,虚拟机中运行cat /proc/cmdline命令即会触发。(gdb) b cmdline_proc_show Breakpoint 1 at 0xffffffff81298d99: file fs/proc/cmdline.c, line 9. (gdb) c Continuing. Breakpoint 1, cmdline_proc_show (m=0xffff880006695000, v=0x1 <irq_stack_union+1>) at fs/proc/cmdline.c:9 9 seq_printf(m, "%s\n", saved_command_line); (gdb) bt #0 cmdline_proc_show (m=0xffff880006695000, v=0x1 <irq_stack_union+1>) at fs/proc/cmdline.c:9 #1 0xffffffff81247439 in seq_read (file=0xffff880006058b00, buf=<optimized out>, size=<optimized out>, ppos=<optimized out>) at fs/seq_file.c:234 ......(此处省略) (gdb) p saved_command_line $2 = 0xffff880007e68980 "console=ttyS0"获取当前进程《深入理解Linux内核》第三版第三章–进程,讲到内核采用了一种精妙的设计来获取当前进程。Linux把跟一个进程相关的thread_info和内核栈stack放在了同一内存区域,内核通过esp寄存器获得当前CPU上运行进程的内核栈栈底地址,该地址正好是thread_info地址,由于进程描述符指针task字段在thread_info结构体中偏移量为0,进而获得task。相关汇编指令如下:movl $0xffffe000, %ecx /* 内核栈大小为8K,屏蔽低13位有效位。 andl $esp, %ecx movl (%ecx), p指令运行后,p就获得当前CPU上运行进程的描述符指针。然而在调试器中调了下,发现这种机制早已经被废弃掉了。thread_info结构体中只剩下一个字段flags,进程描述符字段task已经删除,无法通过thread_info获取进程描述符了。而且进程的thread_info也不再位于进程内核栈底了,而是放在了进程描述符task_struct结构体中,见提交sched/core: Allow putting thread_info into task_struct和x86: Move thread_info into task_struct,这样也无法通过esp寄存器获取thread_info地址了。(gdb) p $lx_current().thread_info $5 = {flags = 2147483648}thread_info这个变量好像没有了,打印结果显示没有这个成员这样做是从安全角度考虑的,一方面可以防止esp寄存器泄露后进而泄露进程描述符指针,二是防止内核栈溢出覆盖thread_info。Linux内核从2.6引入了Per-CPU变量,获取当前指针也是通过Per-CPU变量实现的。(gdb) p $lx_current().pid $50 = 77 (gdb) p $lx_per_cpu("current_task").pid $52 = 77补充在gdb中输入命令apropos lx,没有任何输出,说明无法调用python辅助函数。(gdb) apropos lx从stackoverflow网站上找到一篇文章gdb-lx-symbols-undefined-command,里边提到:gdb -ex add-auto-load-safe-path /path/to/linux/kernel/source/root Now the GDB scripts are automatically loaded, and lx-symbols is available.但是,按照上面进行操作后,进入gdb调试画面后,提示:To enable execution of this file add add-auto-load-safe-path /home/qemu2/qemu/linux-4.4.203/scripts/gdb/vmlinux-gdb.py line to your configuration file "/home/qemu2/.gdbinit". To completely disable this security protection add set auto-load safe-path / line to your configuration file "/home/qemu2/.gdbinit".上面的意思是,为了能够使能vmlinux-gdb.py的执行,需要添加add-auto-load-safe-path /home/qemu2/qemu/linux-4.4.203/scripts/gdb/vmlinux-gdb.py这行代码到我的配置文件/home/qemu2/.gdbinit中。但是,查看我的系统环境没有这个文件,于是自己新建了一个文件,并把上面的代码加入进入。但是在执行source ./.gdbinit命令时,提示add-auto-load-safe-path这个命令找不到,于是干脆把set auto-load safe-path /这行代码添加到配置文件/home/qemu2/.gdbinit中,再执行source ./.gdbinit命令,没有错误发生。于是启动内核代码,然后在另一个命令行窗口中执行gdb调试,就像上面的操作一样,显示:function lx_current -- Return current task function lx_module -- Find module by name and return the module variable function lx_per_cpu -- Return per-cpu variable function lx_task_by_pid -- Find Linux task by PID and return the task_struct variable function lx_thread_info -- Calculate Linux thread_info from task variable lx-dmesg -- Print Linux kernel log buffer lx-list-check -- Verify a list consistency lx-lsmod -- List currently loaded modules lx-ps -- Dump Linux tasks lx-symbols -- (Re-)load symbols of Linux kernel and currently loaded modules至此,终于可以安心调试内核了。参考:Tips for Linux Kernel DevelopmentHow to Build A Custom Linux Kernel For QemuLinux Kernel System DebuggingDebugging kernel and modules via gdbBusyBox simplifies embedded Linux systemsCustom InitramfsPer-CPU variablesLinux kernel debugging with GDB: getting a task running on a CPUgdb-kernel-debugging
2023年05月