This commit is contained in:
Exusial 2021-08-06 11:00:15 +08:00
parent 3345f1f677
commit 936be1ce44
149 changed files with 20101 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
build/
.vscode/
.idea
source/_build/

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

33
Makefile Normal file
View File

@ -0,0 +1,33 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile deploy
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
view:
make html && firefox build/html/index.html
deploy:
@make clean
@make html
@rm -rf docs
@cp -r build/html docs
@touch docs/.nojekyll
@git add -A
@git commit -m "Deploy"
@git push

View File

@ -1 +1,3 @@
# uCore-Tutorial-Book
Working....

2
all.sh Normal file
View File

@ -0,0 +1,2 @@
make clean && make html && google-chrome build/html/index.html

35
make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

425
outline.md Normal file
View File

@ -0,0 +1,425 @@
# 更新记录
### 2021-01-04更新
Chaptter4 添加实现的过程描述:改进内存隔离的好处;
### 2020-12-20更新
将文件描述符从 Chapter7 移动到 Chapter6。
### 2020-12-02更新
根据讨论更新了 Chapter1-Chapter7 到分割线之前的内容作为 Tutorial 的第一部分,即让系统能够将所有的资源都利用起来。第二部分则讨论如何做的更好。在 12 月 26 日之前尽可能按照大纲完成多个不同版本的 demo。
[https://shimo.im/sheets/wV3VVxl04EieK3y1/MODOC](https://shimo.im/sheets/wV3VVxl04EieK3y1/MODOC)是目前的系统调用一览表,预计只需要实现 14 个系统调用就能初步满足要求。
### 2020-11-30更新
更新了Chapter2。
合并了Chapter3/Chapter4为Chapter3目前覆盖范围为Chapter1-Chapter5。
### lab 设计2020-11-01
#### 可能的章节与代码风格
* 新 OS 实验的目的是:“**强化学生对 OS 的整体观念**”。OS的目的是满足应用需求为此需要一定的硬件支持和自身逐步增强的功能。鼓励学生自己从头写有参考实现、强化整体观、step-by-step。
* **整个文档的风格是应用**导向的,每个 step 的任务一定不是凭空而来、而是**应用**的需求。每一章都是为了解决一个应用具体需求而要求OS要完成的功能这个功能需要一定的硬件支持。
* 每个章节给出完整可运行且带有完整注释(可以通过 rustdoc 工具生成 html 版)的代码。
* 文档中给出重要的代码片段(照顾到纸质版的读者,事实上在网页版给出代码的链接即可)而并不需要完整的代码,但是需要有完整的执行流程叙述,对于边界条件有足够的讨论。在文档中插入的代码不带有注释,而是将解释放到文档的文字部分。
* 类似xv6每一章的小节描述一项小功能是如何实现的不同小节之间可能有一定的先后关系也有可能是并列的。
* 尽可能讲清楚设计背后的思想与优缺点。
* 在讲解OS设计方面尽量做到与语言无关。在讲解例子的时候应该有对应的C和rust版本。
* 在某些具体例子中最好能体现rust比c强
* 2020-10-28前几章 Chapter1-4 需要等具体实现出来之后再规划章节。
# 章节大纲
## Chapter0 Hello world! 之旅(偏概述)
### 主要动机:
参考 csapp 第一章,站在一个相对宏观的视角解释一个非常简单的 hello world! 程序是在哪些硬件/软件的支持下得以编译/运行起来的。
helloworld.c 如何被编译器编译成执行程序,且如何被操作系统执行的。
gcc
strace
## Chapter1 裸机应用优先级1
### 主要动机
支持应用进行计算与结果输出。
在裸机上输出 Hello world就像在其他 OS 上一样。
app列表
* hello_world输出字符串
* count_sum累加一维数组的和并输出结果
备注:不需要输入功能
### 内核应完成功能
内存地址空间:
知道自己在内存的哪个位置。理解编译器生成的代码。
init基本初始化
主要是硬件加电后的硬件初始化以前是OS做后面给BIOS, bootloader等完成初步初始化。OS需要知道内存大小IO分布。
write函数输出字符串
驱动串口的初始化,能够通过串口输出。
exit函数表明程序结束
其它(不是主要的):
在 qemu/k210 平台上基于 RustSBI 跳转到内核,打印调试信息,支持内核堆内存分配。
### 章节分布
基本上和第二版/第三版一致。注意需要考虑上面的应用和功能。
## Chapter2批处理系统优先级1
### 主要动机
内核不会被应用程序破坏
### 用户程序
支持应用进行计算与结果输出。在裸机上输出 Hello world就像在其他 OS 上一样。但应用程序无法破坏内核,但能得到内核的服务。
app列表
* hello_world输出字符串。
* count_sum累加一维数组的和并输出结果。
### 内核应完成功能
设置好内核和用户运行的栈,内核初始化完成后通过 sret 跳转到用户程序进行执行,然后在用户程序系统调用的时候完成特权级切换、上下文保存/恢复及栈的切换
按顺序加载运行多个应用程序。当应用程序出错(非法指令基于 RustSBI 不容易完成,比如访问非法的物理地址)之后直接杀死应用程序并切换到下一个。
### 新增系统调用
* sys_write向串口写
* sys_exit 表明任务结束。
### 实现备注
将编译之后的用户镜像和内核打包到一起放到内存上
分离用户和内核特权级保护OS用户需要请求内核提供的服务
##
## Chapter3 分时多任务系统之一非抢占式调度优先级1
### 主要动机
提高整个应用的CPU利用率
多任务,因此需要实现任务切换,可采用如下方法:
* 批处理在内存中放多个程序执行完一个再执行下一个。当执行IO操作时采用的是忙等的方式效率差。
* 非抢占切换CPU和I/O设备之间速度不匹配矛盾程序之间的公平性。当一个程序主动要求暂停或退出时换另外一个程序执行CPU计算。
*>> 这时,可能需要引入中断(但中断不是本章主要的内容,如果不引入更好)。*
### 用户程序
两个程序放置在一个不同的固定的物理地址上(这样不需要页表机制等虚存能力),完成的功能为:一个程序完成一些计算&输出主动暂停OS切换到另外一个程序执行交替运行。
* count_multiplication一维数组的乘法并输出结果
* count_sum累加一维数组的和并输出结果
* [wyf 的具体实现]三个输出小程序,详见[here](https://github.com/rcore-os/rCore-Tutorial-v3/tree/ch3-coop/user/src/bin)
### 内核应完成功能
实现通过 sys_yield 交出当前任务的 CPU 所有权,通过 sys_exit 表明任务结束。需要为每个任务分配一个用户栈和内核栈,且需要实现类似 switch 用来任务切换的函数。
* sys_yield让出CPU
* sys_exit退出当前任务并让出 CPU
### 实现备注
重点是实现switch
当所有任务运行结束后退出内核
## Chapter3 分时多任务系统之二 抢占式调度优先级1
### 主要动机
进一步提高整个应用的CPU利用率/交互性与任务之间的公平性
因此需要实现强制任务切换,并引入中断,可采用如下方法:
* 时钟中断:基于时间片进行调度
* (不在这里引入)串口中断:在发出输出请求后,不是轮询忙等,而是中断方式响应
### 用户程序
* [wyf 的具体实现]三个计算质数幂次的小程序,外加一个 sleep 的程序。[here](https://github.com/rcore-os/rCore-Tutorial-v3/tree/ch3/user/src/bin)
### 内核应完成功能
实现时钟/串口中断处理,以及基于中断的基本时间片轮转调度
### 新增系统调用
* sys_get_time返回当前的 CPU 时钟周期数
## Chapter4 内存隔离安全性地址空间优先级1
### 主要动机
* 更好地支持应用(包括内核)的动态内存需求。首先:在内核态实现动态内存分配(这是物理内存),这样引入了堆的概念
* 更好地支持在内核中对非法地址的访问的检查。在内核态实现页表机制,这样内核访问异常地址也能及时报警。
* 提高应用间的安全性(通过页机制实现隔离)
* 附带好处:应用程序地址空间可以相同,便于应用程序的开发
### 用户程序
应用程序与上一章基本相同,只不过应用程序的地址空间起始位置应该相同。而且这一章需要将 ELF 链接进内核而不是二进制镜像。
特别的,可以设置访问其他应用程序地址空间或是访问内核地址空间的应用程序,内核会将其杀死。
在用户库使用 sbrk 申请动态分配空间而不是放在数据段中。
### 内核应完成功能
* 内核动态内存分配器(对于 Rust 而言,对于 C 仍可以考虑静态分配)
* 物理页帧分配器
* 页表机制,特别是用户和内核地址空间的隔离(参考 xv6
* ELF 解析和加载(在内核初始化的时候完成全部的地址空间创建和加载即可)
### 新增系统调用
* sys_sbrk拓展或缩减当前应用程序的堆空间大小
### 建议实现过程:
1. 在Chapter1的基础上实现基本的物理内存管理机制即连续内存的动态分配。
2. 在Chapter1的基础上实现基本的页表机制。
3. 然后再合并到Chapter3上。
## Chapter5 进程及重要系统调用优先级1
### 主要动机
应用以进程的方式进行运行简化了应用开发的负担OS也更好管理
引入重要的进程概念整合Chapt1~4的内容抽象出进程实现一系列相关机制及 syscall
### 用户程序
shell程序 user_shell以及一些相应的测试
### 内核应完成功能
实现完整的子进程机制,初始化第一个用户进程 initproc。
### 新增系统调用
* sys_fork
* sys_wait(轮询版)
* sys_exec
* sys_getpid
* sys_yield更新
* sys_exit 更新
* sys_read终端需要从串口读取命令
## Chapter6 文件系统与进程间通信优先级1
### 主要动机
进程之间需要进行一些协作。本章主要是通过管道进行通信。
同时,需要引入文件系统,并通过文件描述符来访问对应类型的 Unix 资源。
### 用户程序
简单的通过 fork 和子进程共享管道的测试;
【可选】强化shell程序的功能支持使用 | 进行管道连接。
### 内核应完成功能
实现管道。
将字符设备(标准输入/输出)和管道封装为通过文件描述符访问的文件。
### 新增系统调用
* sys_pipe目前对于管道的 read/write 只需实现轮询版本。
* sys_close作用是关闭管道
## Chapter7 数据持久化优先级1
### 主要动机
实现数据持久化存储。
### 用户程序
多种不同大小的文件读写。
### 内核应完成功能
实现另一种在块设备上持久化存储的文件。
文件系统不需要实现目录。
### 新增系统调用
* sys_open创建或打开一个文件
# ----------------------------分割线-------------------------------------------------
## Chapter6 单核同步互斥优先级1需要划分为单核/多核两部分)
### 主要动机:
应用之间需要在操作系统的帮助下有序共享资源(如串口,内存等)。
解释内核中已有的同步互斥问题,并实现阻塞机制。
### 内核应完成功能:
实现死锁检测机制,并基于阻塞机制实现 sys_sleep 和 sys_wait 以及 sys_kill
### 新增系统调用:
sys_sleep 以及 sys_wait/sys_kill 的更新
### 章节分布:
#### 基于原子指令实现自旋锁
* 讨论并发冲突的来源(单核/多核)
* 关中断/自旋/自旋关中断锁各自什么情况下能起作用,在课上还讲到一种获取锁失败直接 yield 的锁
* 原子指令与内存一致性模型简介
* 具体实现
* 需要说明的是,课上的锁是针对于同一时刻只能有一个进程处于临界区之内。但是 Rust 风格的锁,也就是 Mutex 更加类似于一个管程(尽管 Rust 语言并没有这个概念),它用来保护一个数据结构,保证同一时间只有一个进程对于这个数据结构进行操作,自然保证了一致性。而 xv6 里面的锁只能保护临界区,相对而言对于数据结构一致性的保护就需要更加复杂的讨论。
#### 死锁检测
#### 阻塞的同步原语:条件变量
简单讨论一下其他的同步原语。
* 课上提到的信号量和互斥量(后者是前者的特例)保护的都是某一个临界区
#### 基于条件变量实现 sys_sleep
#### 基于条件变量重新实现 sys_wait
#### 更新 sys_kill 使得支持 kill 掉正在阻塞的进程
## ChapterX IPC优先级1
### 主要动机:
应用之间需要交换信息
### 内核应完成功能:
* pipe
* shared mem
### 新增系统调用:
## Chapter8 设备驱动优先级2
### 主要动机:
应用可以把I/O 设备用起来。
### 内核应完成功能:
实现块设备驱动和串口驱动,理解同步/异步两种驱动实现方式
#### 背景知识:设备驱动、设备寄存器、轮询、中断
#### 设备树(可选)
#### 实现 virtio_disk 块设备的块读写(同步+轮询风格)
#### 实现 virtio_disk 块设备的块读写(异步+中断风格)
#### 实现串口设备的异步输入和同步输出
* 参考 xv6可以在内核里面维护一个 FIFO这样即使串口本身没有 FIFO 也可以
## Chapter9 Unix 资源文件优先级1
### 主要动机:
应用可以通过单一接口(文件)访问磁盘来保存信息和访问其他外设
Unix 万物皆文件,将文件作为进程可以访问的内核资源单位
### 内核应完成功能:
支持三种不同的 Unix 资源:字符设备(串口)、块设备(文件系统)、管道
### 新增系统调用:
sys_open/sys_close
### 背景知识Unix 万物皆文件/进程对于文件的访问方式
#### file 抽象接口
* 支持 read/write 两种操作,表示 file 到地址空间中一块缓冲区的读写操作
#### 字符设备路线
* 直接将串口设备驱动封装一下即可。
#### 文件系统路线
* 分成多个子章节,等实现出来之后才知道怎么写
#### 管道路线
* 一个非常经典的读者/写者问题。
### ChapterX 虚存管理优先级2
### 主要动机:
提高应用执行的效率(侧重内存)
- 支持物理内存不够的情况
- copy on write
### 内核应完成功能:
### 新增系统调用:
### Chapter10 多核(可选)
### 主要动机:
提高应用执行的并行执行效率(侧重多处理器)
### 内核应完成功能:
### 新增系统调用:
#### 多核启动与 IPI
#### 多核调度
### Chapter11多核下的同步互斥可选
### 主要动机:
提高应用并行执行下的正确性(侧重多处理器)
### 内核应完成功能:
### 新增系统调用:
#### 多核启动与 IPI
#### 多核调度
## Appendix A Rust 语言快速入门与练习题
## Appendix B 常见构建工具的使用方法
比如 Makefile\ld 等。
## Appendix C RustSBI 与 Kendryte K210 兼容性设计
## 其他附录…

2
show.sh Normal file
View File

@ -0,0 +1,2 @@
make html && google-chrome build/html/index.html

View File

@ -0,0 +1,91 @@
/* Dracula Theme v1.2.5
*
* https://github.com/zenorocha/dracula-theme
*
* Copyright 2016, All rights reserved
*
* Code licensed under the MIT license
* http://zenorocha.mit-license.org
*
* @author Rob G <wowmotty@gmail.com>
* @author Chris Bracco <chris@cbracco.me>
* @author Zeno Rocha <hi@zenorocha.com>
*/
.highlight .hll { background-color: #111110 }
.highlight { background: #282a36; color: #f8f8f2 }
.highlight .c { color: #6272a4 } /* Comment */
.highlight .err { color: #f8f8f2 } /* Error */
.highlight .g { color: #f8f8f2 } /* Generic */
.highlight .k { color: #ff79c6 } /* Keyword */
.highlight .l { color: #f8f8f2 } /* Literal */
.highlight .n { color: #f8f8f2 } /* Name */
.highlight .o { color: #ff79c6 } /* Operator */
.highlight .x { color: #f8f8f2 } /* Other */
.highlight .p { color: #f8f8f2 } /* Punctuation */
.highlight .ch { color: #6272a4 } /* Comment.Hashbang */
.highlight .cm { color: #6272a4 } /* Comment.Multiline */
.highlight .cp { color: #ff79c6 } /* Comment.Preproc */
.highlight .cpf { color: #6272a4 } /* Comment.PreprocFile */
.highlight .c1 { color: #6272a4 } /* Comment.Single */
.highlight .cs { color: #6272a4 } /* Comment.Special */
.highlight .gd { color: #962e2f } /* Generic.Deleted */
.highlight .ge { color: #f8f8f2; text-decoration: underline } /* Generic.Emph */
.highlight .gr { color: #f8f8f2 } /* Generic.Error */
.highlight .gh { color: #f8f8f2; font-weight: bold } /* Generic.Heading */
.highlight .gi { color: #f8f8f2; font-weight: bold } /* Generic.Inserted */
.highlight .go { color: #44475a } /* Generic.Output */
.highlight .gp { color: #f8f8f2 } /* Generic.Prompt */
.highlight .gs { color: #f8f8f2 } /* Generic.Strong */
.highlight .gu { color: #f8f8f2; font-weight: bold } /* Generic.Subheading */
.highlight .gt { color: #f8f8f2 } /* Generic.Traceback */
.highlight .kc { color: #ff79c6 } /* Keyword.Constant */
.highlight .kd { color: #8be9fd; font-style: italic } /* Keyword.Declaration */
.highlight .kn { color: #ff79c6 } /* Keyword.Namespace */
.highlight .kp { color: #ff79c6 } /* Keyword.Pseudo */
.highlight .kr { color: #ff79c6 } /* Keyword.Reserved */
.highlight .kt { color: #8be9fd } /* Keyword.Type */
.highlight .ld { color: #f8f8f2 } /* Literal.Date */
.highlight .m { color: #bd93f9 } /* Literal.Number */
.highlight .s { color: #f1fa8c } /* Literal.String */
.highlight .na { color: #50fa7b } /* Name.Attribute */
.highlight .nb { color: #8be9fd; font-style: italic } /* Name.Builtin */
.highlight .nc { color: #50fa7b } /* Name.Class */
.highlight .no { color: #f8f8f2 } /* Name.Constant */
.highlight .nd { color: #f8f8f2 } /* Name.Decorator */
.highlight .ni { color: #f8f8f2 } /* Name.Entity */
.highlight .ne { color: #f8f8f2 } /* Name.Exception */
.highlight .nf { color: #50fa7b } /* Name.Function */
.highlight .nl { color: #8be9fd; font-style: italic } /* Name.Label */
.highlight .nn { color: #f8f8f2 } /* Name.Namespace */
.highlight .nx { color: #f8f8f2 } /* Name.Other */
.highlight .py { color: #f8f8f2 } /* Name.Property */
.highlight .nt { color: #ff79c6 } /* Name.Tag */
.highlight .nv { color: #8be9fd; font-style: italic } /* Name.Variable */
.highlight .ow { color: #ff79c6 } /* Operator.Word */
.highlight .w { color: #f8f8f2 } /* Text.Whitespace */
.highlight .mb { color: #bd93f9 } /* Literal.Number.Bin */
.highlight .mf { color: #bd93f9 } /* Literal.Number.Float */
.highlight .mh { color: #bd93f9 } /* Literal.Number.Hex */
.highlight .mi { color: #bd93f9 } /* Literal.Number.Integer */
.highlight .mo { color: #bd93f9 } /* Literal.Number.Oct */
.highlight .sa { color: #f1fa8c } /* Literal.String.Affix */
.highlight .sb { color: #f1fa8c } /* Literal.String.Backtick */
.highlight .sc { color: #f1fa8c } /* Literal.String.Char */
.highlight .dl { color: #f1fa8c } /* Literal.String.Delimiter */
.highlight .sd { color: #f1fa8c } /* Literal.String.Doc */
.highlight .s2 { color: #f1fa8c } /* Literal.String.Double */
.highlight .se { color: #f1fa8c } /* Literal.String.Escape */
.highlight .sh { color: #f1fa8c } /* Literal.String.Heredoc */
.highlight .si { color: #f1fa8c } /* Literal.String.Interpol */
.highlight .sx { color: #f1fa8c } /* Literal.String.Other */
.highlight .sr { color: #f1fa8c } /* Literal.String.Regex */
.highlight .s1 { color: #f1fa8c } /* Literal.String.Single */
.highlight .ss { color: #f1fa8c } /* Literal.String.Symbol */
.highlight .bp { color: #f8f8f2; font-style: italic } /* Name.Builtin.Pseudo */
.highlight .fm { color: #50fa7b } /* Name.Function.Magic */
.highlight .vc { color: #8be9fd; font-style: italic } /* Name.Variable.Class */
.highlight .vg { color: #8be9fd; font-style: italic } /* Name.Variable.Global */
.highlight .vi { color: #8be9fd; font-style: italic } /* Name.Variable.Instance */
.highlight .vm { color: #8be9fd; font-style: italic } /* Name.Variable.Magic */
.highlight .il { color: #bd93f9 } /* Literal.Number.Integer.Long */

View File

@ -0,0 +1,3 @@
.wy-nav-content {
max-width: 1200px !important;
}

View File

@ -0,0 +1,56 @@
附录 ARust 系统编程入门
=============================
.. toctree::
:hidden:
:maxdepth: 4
.. .. note::
.. **Rust 语法卡片:外部符号引用**
.. extern "C" 可以引用一个外部的 C 函数接口(这意味着调用它的时候要遵从目标平台的 C 语言调用规范)。但我们这里只是引用位置标志
.. 并将其转成 usize 获取它的地址。由此可以知道 ``.bss`` 段两端的地址。
.. **Rust 语法卡片:迭代器与闭包**
.. 代码第 7 行用到了 Rust 的迭代器与闭包的语法,它们在很多情况下能够提高开发效率。如读者感兴趣的话也可以将其改写为等价的 for
.. 循环实现。
.. .. _term-raw-pointer:
.. .. _term-dereference:
.. .. warning::
.. **Rust 语法卡片Unsafe**
.. 代码第 8 行,我们将 ``.bss`` 段内的一个地址转化为一个 **裸指针** (Raw Pointer),并将它指向的值修改为 0。这在 C 语言中是
.. 一种司空见惯的操作,但在 Rust 中我们需要将他包裹在 unsafe 块中。这是因为Rust 认为对于裸指针的 **解引用** (Dereference)
.. 是一种 unsafe 行为。
.. 相比 C 语言Rust 进行了更多的语义约束来保证安全性(内存安全/类型安全/并发安全),这在编译期和运行期都有所体现。但在某些时候,
.. 尤其是与底层硬件打交道的时候,在 Rust 的语义约束之内没法满足我们的需求,这个时候我们就需要将超出了 Rust 语义约束的行为包裹
.. 在 unsafe 块中,告知编译器不需要对它进行完整的约束检查,而是由程序员自己负责保证它的安全性。当代码不能正常运行的时候,我们往往也是
.. 最先去检查 unsafe 块中的代码,因为它没有受到编译器的保护,出错的概率更大。
.. C 语言中的指针相当于 Rust 中的裸指针,它无所不能但又太过于灵活,程序员对其不谨慎的使用常常会引起很多内存不安全问题,最常见的如
.. 悬垂指针和多次回收的问题Rust 编译器没法确认程序员对它的使用是否安全,因此将其划到 unsafe Rust 的领域。在 safe Rust 中,我们
.. 有引用 ``&/&mut`` 以及各种功能各异的智能指针 ``Box<T>/RefCell<T>/Rc<T>`` 可以使用,只要按照 Rust 的规则来使用它们便可借助
.. 编译器在编译期就解决很多潜在的内存不安全问题。
Rust编程相关
--------------------------------
- `OS Tutorial Summer of Code 2020Rust系统编程入门指导 <https://github.com/rcore-os/rCore/wiki/os-tutorial-summer-of-code#step-0-%E8%87%AA%E5%AD%A6rust%E7%BC%96%E7%A8%8B%E5%A4%A7%E7%BA%A67%E5%A4%A9>`_
- `Stanford 新开的一门很值得学习的 Rust 入门课程 <https://reberhardt.com/cs110l/spring-2020/>`_
- `一份简单的 Rust 入门介绍 <https://zhuanlan.zhihu.com/p/298648575>`_
- `《RustOS Guide》中的 Rust 介绍部分 <https://simonkorl.gitbook.io/r-z-rustos-guide/dai-ma-zhi-qian/ex1>`_
- `一份简单的Rust宏编程新手指南 <http://blog.hubwiz.com/2020/01/30/rust-macro/>`_
Rust系统编程pattern
---------------------------------
- `Arc<Mutex<_>> in Rust <https://aeshirey.github.io/code/2020/12/23/arc-mutex-in-rust.html>`_
- `Understanding Closures in Rust <https://medium.com/swlh/understanding-closures-in-rust-21f286ed1759>`_
- `Closures in Rust <https://zhauniarovich.com/post/2020/2020-12-closures-in-rust/>`_

368
source/appendix-b/index.rst Normal file
View File

@ -0,0 +1,368 @@
附录 B常见工具的使用方法
========================================
.. toctree::
:hidden:
:maxdepth: 4
分析可执行文件
------------------------
对于Rust编译器生成的执行程序可通过各种有效工具进行分析。如果掌握了对这些工具的使用那么在后续的开发工作中对碰到的各种奇怪问题就进行灵活处理和解决了。
我们以Rust编译生成的一个简单的“Hello, world”应用执行程序为分析对象看看如何进行分析。
让我们先来通过 ``file`` 工具看看最终生成的可执行文件的格式:
.. code-block:: console
$ cargo new os
$ cd os; cargo build
Compiling os v0.1.0 (/tmp/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.26s
$ file target/debug/os
target/debug/os: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ......
$
.. _term-elf:
.. _term-metadata:
从中可以看出可执行文件的格式为 **可执行和链接格式** (Executable and Linkable Format, ELF),硬件平台是 x86-64。在 ELF 文件中,
除了程序必要的代码、数据段(它们本身都只是一些二进制的数据)之外,还有一些 **元数据** (Metadata) 描述这些段在地址空间中的位置和在
文件中的位置以及一些权限控制信息,这些元数据只能放在代码、数据段的外面。
rust-readobj
^^^^^^^^^^^^^^^^^^^^^^^
我们可以通过二进制工具 ``rust-readobj`` 来看看 ELF 文件中究竟包含什么内容,输入命令:
.. code-block:: console
$ rust-readobj -all target/debug/os
首先可以看到一个 ELF header它位于 ELF 文件的开头:
.. code-block:: objdump
:linenos:
:emphasize-lines: 8,19,20,21,24,25,26,27
File: target/debug/os
Format: elf64-x86-64
Arch: x86_64
AddressSize: 64bit
LoadName:
ElfHeader {
Ident {
Magic: (7F 45 4C 46)
Class: 64-bit (0x2)
DataEncoding: LittleEndian (0x1)
FileVersion: 1
OS/ABI: SystemV (0x0)
ABIVersion: 0
Unused: (00 00 00 00 00 00 00)
}
Type: SharedObject (0x3)
Machine: EM_X86_64 (0x3E)
Version: 1
Entry: 0x5070
ProgramHeaderOffset: 0x40
SectionHeaderOffset: 0x32D8D0
Flags [ (0x0)
]
HeaderSize: 64
ProgramHeaderEntrySize: 56
ProgramHeaderCount: 12
SectionHeaderEntrySize: 64
SectionHeaderCount: 42
StringTableSectionIndex: 41
}
......
.. _term-magic:
- 第 8 行是一个称之为 **魔数** (Magic) 独特的常数,存放在 ELF header 的一个固定位置。当加载器将 ELF 文件加载到内存之前,通常会查看
该位置的值是否正确,来快速确认被加载的文件是不是一个 ELF 。
- 第 19 行给出了可执行文件的入口点为 ``0x5070``
- 从 20-21 行中,我们可以知道除了 ELF header 之外,还有另外两种不同的 header分别称为 program header 和 section header
它们都有多个。ELF header 中给出了其他两种header 的大小、在文件中的位置以及数目。
- 从 24-27 行中,可以看到有 12 个不同的 program header它们从文件的 0x40 字节偏移处开始,每个 56 字节;
有64个section header,它们从文件的 0x2D8D0 字节偏移处开始,每个 64 字节;
有多个不同的 section header下面是个具体的例子
.. code-block:: objdump
......
Section {
Index: 14
Name: .text (157)
Type: SHT_PROGBITS (0x1)
Flags [ (0x6)
SHF_ALLOC (0x2)
SHF_EXECINSTR (0x4)
]
Address: 0x5070
Offset: 0x5070
Size: 208067
Link: 0
Info: 0
AddressAlignment: 16
EntrySize: 0
}
每个 section header 则描述一个段的元数据。
其中,我们看到了代码段 ``.text`` 需要被加载到地址 ``0x5070`` ,大小 208067 字节,。
它们分别由元数据的字段 Offset、 Size 和 Address 给出。。
我们还能够看到程序中的符号表:
.. code-block::
Symbol {
Name: _start (37994)
Value: 0x5070
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
Symbol {
Name: main (38021)
Value: 0x51A0
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
里面包括了我们写的 ``main`` 函数的地址以及用户态执行环境的起始地址 ``_start`` 函数的地址。
因此,从 ELF header 中可以看出ELF 中的内容按顺序应该是:
- ELF header
- 若干个 program header
- 程序各个段的实际数据
- 若干的 section header
rust-objdump
^^^^^^^^^^^^^^^^^^^^^^^
如果想了解正常的ELF文件的具体指令内容可以通过 ``rust-objdump`` 工具反汇编ELF文件得到
.. code-block:: console
$ rust-objdump -all target/debug/os
具体结果如下:
.. code-block:: objdump
505b: e9 c0 ff ff ff jmp 0x5020 <.plt>
Disassembly of section .plt.got:
0000000000005060 <.plt.got>:
5060: ff 25 5a 3f 04 00 jmpq *278362(%rip) # 48fc0 <_GLOBAL_OFFSET_TABLE_+0x628>
5066: 66 90 nop
Disassembly of section .text:
0000000000005070 <_start>:
5070: f3 0f 1e fa endbr64
5074: 31 ed xorl %ebp, %ebp
5076: 49 89 d1 movq %rdx, %r9
5079: 5e popq %rsi
507a: 48 89 e2 movq %rsp, %rdx
507d: 48 83 e4 f0 andq $-16, %rsp
5081: 50 pushq %rax
5082: 54 pushq %rsp
5083: 4c 8d 05 86 2c 03 00 leaq 208006(%rip), %r8 # 37d10 <__libc_csu_fini>
508a: 48 8d 0d 0f 2c 03 00 leaq 207887(%rip), %rcx # 37ca0 <__libc_csu_init>
5091: 48 8d 3d 08 01 00 00 leaq 264(%rip), %rdi # 51a0 <main>
5098: ff 15 d2 3b 04 00 callq *277458(%rip) # 48c70 <_GLOBAL_OFFSET_TABLE_+0x2d8>
......
00000000000051a0 <main>:
51a0: 48 83 ec 18 subq $24, %rsp
51a4: 8a 05 db 7a 03 00 movb 228059(%rip), %al # 3cc85 <__rustc_debug_gdb_scripts_section__>
51aa: 48 63 cf movslq %edi, %rcx
51ad: 48 8d 3d ac ff ff ff leaq -84(%rip), %rdi # 5160 <_ZN2os4main17h717a6a6e05a70248E>
51b4: 48 89 74 24 10 movq %rsi, 16(%rsp)
51b9: 48 89 ce movq %rcx, %rsi
51bc: 48 8b 54 24 10 movq 16(%rsp), %rdx
51c1: 88 44 24 0f movb %al, 15(%rsp)
51c5: e8 f6 00 00 00 callq 0x52c0 <_ZN3std2rt10lang_start17hc258028f546a93a1E>
51ca: 48 83 c4 18 addq $24, %rsp
51ce: c3 retq
51cf: 90 nop
......
从上面的反汇编结果,我们可以看到用户态执行环境的入口函数 ``_start`` 以及应用程序的主函数 ``main`` 的地址和具体汇编代码内容。
rust-objcopy
^^^^^^^^^^^^^^^^^^^^^^^
当前的ELF执行程序有许多与执行无直接关系的信息如调试信息等可以通过 ``rust-objcopy`` 工具来清除。
.. code-block:: console
$ rust-objcopy --strip-all target/debug/os target/debug/os.bin
$ ls -l target/debug/os*
-rwxrwxr-x 2 chyyuu chyyuu 3334992 1月 19 22:26 target/debug/os
-rwxrwxr-x 1 chyyuu chyyuu 297200 1月 19 22:59 target/debug/os.bin
$ ./target/debug/os.bin
Hello, world!
可以看到经过处理的ELF文件 ``os.bin`` 在文件长度上大大减少了,但也能正常执行。
另外,当将程序加载到内存的时候,对于每个 program header 所指向的区域,我们需要将对应的数据从文件复制到内存中。这就需要解析 ELF 的元数据
才能知道数据在文件中的位置以及即将被加载到内存中的位置。但如果我们不需要从 ELF 中解析元数据就知道程序的内存布局
(这个内存布局是我们按照需求自己指定的),我们可以手动完成加载任务。
具体的做法是利用 ``rust-objcopy`` 工具删除掉 ELF 文件中的
所有 header 只保留各个段的实际数据得到一个没有任何符号的纯二进制镜像文件:
.. code-block:: console
$ rust-objcopy --strip-all target/debug/os -O binary target/debug/os.bin
这样就生成了一个没有任何符号的纯二进制镜像文件。由于缺少了必要的元数据,我们的 ``file`` 工具也没有办法
对它完成解析了。而后,我们可直接将这个二进制镜像文件手动载入到内存中合适位置即可。
qemu 平台上可执行文件和二进制镜像的生成流程
----------------------------------------------
make & Makefile
^^^^^^^^^^^^^^^^^^^^^^^
首先我们还原一下可执行文件和二进制镜像的生成流程:
.. code-block:: makefile
# os/Makefile
TARGET := riscv64gc-unknown-none-elf
MODE := release
KERNEL_ELF := target/$(TARGET)/$(MODE)/os
KERNEL_BIN := $(KERNEL_ELF).bin
$(KERNEL_BIN): kernel
@$(OBJCOPY) $(KERNEL_ELF) --strip-all -O binary $@
kernel:
@cargo build --release
这里可以看出 ``KERNEL_ELF`` 保存最终可执行文件 ``os`` 的路径,而 ``KERNEL_BIN`` 保存只保留各个段数据的二进制镜像文件 ``os.bin``
的路径。目标 ``kernel`` 直接通过 ``cargo build`` 以 release 模式最终可执行文件,目标 ``KERNEL_BIN`` 依赖于目标 ``kernel``,将
可执行文件通过 ``rust-objcopy`` 工具加上适当的配置移除所有的 header 和符号得到二进制镜像。
我们可以通过 ``make run`` 直接在 qemu 上运行我们的应用程序qemu 是一个虚拟机,它完整的模拟了一整套硬件平台,就像是一台真正的计算机
一样,我们来看运行 qemu 的具体命令:
.. code-block:: makefile
:linenos:
:emphasize-lines: 11,12,13,14,15
KERNEL_ENTRY_PA := 0x80020000
BOARD ?= qemu
SBI ?= rustsbi
BOOTLOADER := ../bootloader/$(SBI)-$(BOARD).bin
run: run-inner
run-inner: build
ifeq ($(BOARD),qemu)
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
else
@cp $(BOOTLOADER) $(BOOTLOADER).copy
@dd if=$(KERNEL_BIN) of=$(BOOTLOADER).copy bs=128K seek=1
@mv $(BOOTLOADER).copy $(KERNEL_BIN)
@sudo chmod 777 $(K210-SERIALPORT)
python3 $(K210-BURNER) -p $(K210-SERIALPORT) -b 1500000 $(KERNEL_BIN)
miniterm --eol LF --dtr 0 --rts 0 --filter direct $(K210-SERIALPORT) 115200
endif
qemu
^^^^^^^^^^^^^^^^^^^^^^^
注意其中高亮部分给出了传给 qemu 的参数。
- ``-machine`` 告诉 qemu 使用预设的硬件配置。在整个项目中我们将一直沿用该配置。
- ``-bios`` 告诉 qemu 使用我们放在 ``bootloader`` 目录下的预编译版本作为 bootloader。
- ``-device`` 则告诉 qemu 将二进制镜像加载到内存指定的位置。
可以先输入 Ctrl+A ,再输入 X 来退出 qemu 终端。
.. warning::
**FIXME使用 GDB 跟踪 qemu 的运行状态**
k210 平台上可执行文件和二进制镜像的生成流程
-------------------------------------------------------
对于 k210 平台来说,只需要将 maix 系列开发板通过数据线连接到 PC然后 ``make run BOARD=k210`` 即可。从 Makefile 中来看:
.. code-block:: makefile
:linenos:
:emphasize-lines: 13,16,17
K210-SERIALPORT = /dev/ttyUSB0
K210-BURNER = ../tools/kflash.py
run-inner: build
ifeq ($(BOARD),qemu)
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
else
@cp $(BOOTLOADER) $(BOOTLOADER).copy
@dd if=$(KERNEL_BIN) of=$(BOOTLOADER).copy bs=128K seek=1
@mv $(BOOTLOADER).copy $(KERNEL_BIN)
@sudo chmod 777 $(K210-SERIALPORT)
python3 $(K210-BURNER) -p $(K210-SERIALPORT) -b 1500000 $(KERNEL_BIN)
miniterm --eol LF --dtr 0 --rts 0 --filter direct $(K210-SERIALPORT) 115200
endif
在构建目标 ``run-inner`` 的时候,根据平台 ``BOARD`` 的不同,启动运行的指令也不同。当我们传入命令行参数 ``BOARD=k210`` 时,就会进入下面
的分支。
- 第 13 行我们使用 ``dd`` 工具将 bootloader 和二进制镜像拼接到一起,这是因为 k210 平台的写入工具每次只支持写入一个文件,所以我们只能
将二者合并到一起一并写入 k210 的内存上。这样的参数设置可以保证 bootloader 在合并后文件的开头,而二进制镜像在文件偏移量 0x20000 的
位置处。有兴趣的读者可以输入命令 ``man dd`` 查看关于工具 ``dd`` 的更多信息。
- 第 16 行我们使用烧写工具 ``K210-BURNER`` 将合并后的镜像烧写到 k210 开发板的内存的 ``0x80000000`` 地址上。
参数 ``K210-SERIALPORT`` 表示当前 OS 识别到的 k210 开发板的串口设备名。在 Ubuntu 平台上一般为 ``/dev/ttyUSB0``
- 第 17 行我们打开串口终端和 k210 开发板进行通信,可以通过键盘向 k210 开发板发送字符并在屏幕上看到 k210 开发板的字符输出。
可以输入 Ctrl+] 退出 miniterm。
其他工具和文件格式说明的参考
-------------------------------------------------------
- `链接脚本(Linker Scripts)语法和规则解析(翻译自官方手册) <https://blog.csdn.net/m0_47799526/article/details/108765403>`_
- `Make 命令教程 <https://www.w3cschool.cn/mexvtg/>`_

View File

@ -0,0 +1,18 @@
附录 C深入机器模式RustSBI
=================================================
.. toctree::
:hidden:
:maxdepth: 4
RISC-V指令集的SBI标准规定了类Unix操作系统之下的运行环境规范。这个规范拥有多种实现RustSBI是它的一种实现。
RISC-V架构中存在着定义于操作系统之下的运行环境。这个运行环境不仅将引导启动RISC-V下的操作系统 还将常驻后台,为操作系统提供一系列二进制接口,以便其获取和操作硬件信息。 RISC-V给出了此类环境和二进制接口的规范称为“操作系统二进制接口”即“SBI”。
SBI的实现是在M模式下运行的特定于平台的固件它将管理S、U等特权上的程序或通用的操作系统。
RustSBI项目发起于鹏城实验室的“rCore代码之夏-2020”活动它是完全由Rust语言开发的SBI实现。 现在它能够在支持的RISC-V设备上运行rCore教程和其它操作系统内核。
RustSBI项目的目标是制作一个从固件启动的最小Rust语言SBI实现为可能的复杂实现提供参考和支持。 RustSBI也可以作为一个库使用帮助更多的SBI开发者适配自己的平台以支持更多处理器核和片上系统。
当前项目实现源码https://github.com/luojia65/rustsbi

View File

@ -0,0 +1,7 @@
RISCV汇编相关
=========================
- `RISC-V Assembly Programmer's Manual <https://github.com/riscv/riscv-asm-manual/blob/master/riscv-asm.md>`_
- `RISC-V Low-level Test Suits <https://github.com/riscv/riscv-tests>`_
- `CoreMark®-PRO comprehensive, advanced processor benchmark <https://github.com/RISCVERS/coremark-pro>`_
- `riscv-tests的使用 <https://stackoverflow.com/questions/39321554/how-do-i-use-the-riscv-tests-suite>`_

22
source/appendix-d/2rv.rst Normal file
View File

@ -0,0 +1,22 @@
RISCV硬件相关
=========================
Quick Reference
-------------------
- `Registers & ABI <https://five-embeddev.com/quickref/regs_abi.html>`_
- `Interrupt <https://five-embeddev.com/quickref/interrupts.html>`_
- `ISA & Extensions <https://five-embeddev.com/quickref/isa_ext.html>`_
- `Toolchain <https://five-embeddev.com/quickref/tools.html>`_
- `Control and Status Registers (CSRs) <https://five-embeddev.com/quickref/csrs.html>`_
- `Accessing CSRs <https://five-embeddev.com/quickref/csrs-access.html>`_
- `Assembler & Instructions <https://five-embeddev.com/quickref/instructions.html>`_
ISA
------------------------
- `User-Level ISA, Version 1.12 <https://five-embeddev.com/riscv-isa-manual/latest/riscv-spec.html>`_
- `4 Supervisor-Level ISA, Version 1.12 <https://five-embeddev.com/riscv-isa-manual/latest/supervisor.html>`_
- `Vector Extension <https://five-embeddev.com/riscv-v-spec/draft/v-spec.html>`_
- `RISC-V Bitmanip Extension <https://five-embeddev.com/riscv-bitmanip/draft/bitmanip.html>`_
- `External Debug <https://five-embeddev.com/riscv-debug-spec/latest/riscv-debug-spec.html>`_
- `ISA Resources <https://five-embeddev.com/riscv-isa-manual/>`_

View File

@ -0,0 +1,9 @@
附录 DRISC-V相关信息
=================================================
.. toctree::
:hidden:
:maxdepth: 4
1asm
2rv

View File

@ -0,0 +1,46 @@
为何要写这本操作系统书
==================================================
现在国内外已有一系列优秀的操作系统教材,例如 William Stallings 的《Operating Systems Internals and Design Principles》Avi Silberschatz 、 Peter Baer Galvin 和 Greg Gagne 的《Operating System Concepts》
Remzi H. Arpaci-Dusseau 和 Andrea C. Arpaci-Dusseau 的《Operating Systems: Three Easy Pieces》等。
然而,从我们自 2000 年以来的教学实践来看,某些经典教材对操作系统的概念和原理很重视,但还有如下一些问题有待改进:
- 原理与实践脱节:缺乏在操作系统的概念/原理与操作系统的设计/实现之间建立联系的桥梁,导致学生发现操作系统实现相关的实验
与操作系统的概念相比,有较大的鸿沟。
- 缺少历史发展的脉络:操作系统的概念和原理是从实际操作系统设计与实现过程中,从无到有逐步演进而产生的,有其发展的历史渊源
和规律。但目前的大部分教材只提及当前主流操作系统的概念和原理,有“凭空出现”的感觉,学生并不知道这些内容出现的前因后果。
- 忽视硬件细节或用复杂硬件:很多教材忽视或抽象硬件细节,使得操作系统概念难以落地。部分教材把 x86 作为操作系统实验的硬件
参考平台,缺乏对当前快速发展的 RISC-V 等体系结构的实验支持,使得学生在操作系统实验中可能需要花较大代价了解相对繁杂的 x86 硬件细节,影响操作系统实验的效果。
这些问题增加了学生学习和掌握操作系统的难度。我们想通过尝试解决上面三个问题来缓解学生学习操作系统的压力提升他们的兴趣让他们能够在一个学期内比较好地掌握操作系统。为应对“原理与实践脱节”的问题我们强调了实践先行实践引领原理的理念。MIT 教授 Frans Kaashoek 等师生设计实现了基于 UNIX v6 的 xv6 教学操作系统用于每年的本科操作系统课的实验中并在课程讲解中把原理和实验结合起来在国际上得到了广泛的认可。这些都给了我们很好的启发经过十多年的实践对一个计算机专业的本科生而言设计实现一个操作系统包括CPU有挑战但可行前提是实际操作系统要小巧并能体现操作系统的核心思想。这样就能够让学生加深对操作系统原理和概念的理解能让操作系统原理和概念落地。
为应对“缺少历史发展的脉络”的问题,我们重新设计操作系统实验和教学内容,按照操作系统的历史发展过程来建立多个相对独立的小实验,每个实验体现了操作系统的一个微缩的历史,并从中归纳总结出操作系统相关的概念与原理,并在教学中引导学生理解这些概念和原理是如何一步一步演进的。
为应对“忽视硬件细节或用复杂硬件”的问题我们在硬件x86, ARM, MIPS, RISC-V 等和编程语言C, C++, Go, Rust 等)选择方面进行了多年尝试。在 2017 年引入了 RISC-V 架构作为操作系统实验的硬件环境,在 2018 年引入 Rust 编程语言作为开发操作系统的编程语言,使得学生以相对较小的开发和调试代价能够用 Rust 语言编写运行在 RISC-V 上的操作系统。我们简化了形象化、可视化操作系统的概念和原理的过程,目的是让学生可以把操作系统的概念和原理直接对应到程序代码、硬件规范和操作系统的实际执行中,加强学生对操作系统内涵的实际体验和感受。
所以本书的目标是以简洁的 RISC-V 架构为底层硬件基础,根据上层应用从小到大的需求,按 OS 发展的历史脉络,逐步讲解如何设计并实现满足这些需求的“从小到大”的多个“小”操作系统,并在设计实现操作系统的过程中,逐步解析操作系统各种概念与原理的知识点,对应的做到有“理”可循和有“码”可查,最终让读者通过主动的操作系统设计与实现来深入地掌握操作系统的概念与原理。
在具体撰写过程中,第零章是对操作系统的一个概述,让读者对操作系统的历史、定义、特征等概念上有一个大致的了解。后面的每个章节体现了操作系统的一个微缩的历史发展过程,即从对应用由简到繁的支持的角度出发,每章会讲解如何设计一个可运行应用的操作系统,满足应用的阶段性需求。从而读者可以通过对应配套的操作系统设计实验,了解如何从一个微不足道的“小”操作系统,根据应用需求,添加或增强操作系统功能,逐步形成一个类似 UNIX 的相对完善的“小”操作系统。每一步都小到足以让人感觉到易于掌控,而在每一步结束时,你都有一个可以工作的“小”操作系统。另外,通过足够详尽的测试程序,可以随时验证读者实现的操作系统在每次更新后是否正常工作。由于实验的代码规模和实现复杂度在一个逐步递增的可控范围内,读者可以结合对应于操作系统设计实验的进一步的原理讲解,来建立操作系统概念原理和实际实现的对应关系,从而能够通过操作系统实验的实践过程来加强对理论概念的理解,通过理论概念来进一步指导操作系统实验的实现与改进。
在你开始阅读与实践本书讲解的内容之前你需要决定用什么编程语言来完成操作系统实验。你可以用任何你喜欢的编程语言和你喜欢的CPU上来实现操作系统。我们推荐的编程语言是 Rust ,我们推荐的架构是 RISC-V。
..
chyyuu有一个比较大的ascii图画出我们做出的各种OSes。
.. note::
**选择C有什么有好处和缺点呢**
- 事实上, C 语言就是为写 UNIX 而诞生的。Dennis Ritchie 和 KenThompson 没有期望设计一种新语言能帮助高效简洁地开发复杂的应用业务逻辑,只是希望用一种简洁的方式来代替难以使用的汇编语言抽象出计算机的行为,便于编写控制计算机硬件的操作系统。
- 使用C 语言写OS不需要学习新的语言Rust语言相比python和java等语言更难上手。使用C语言可以更快地理解框架的整体内容。
- C 语言的指针既是天使又是魔鬼。它灵活且易于使用,但语言本身几乎不保证安全性,且缺少有效的并发支持。这导致内存和并发漏洞成为当前基于 C 开发的主流操作系统的噩梦。
- Rust 语言具有与 C 一样的硬件控制能力,且大大强化了安全编程。从某种角度上看,新出现的 Rust 语言的核心目标是解决 C 的短板,取代 C 。所以用 Rust 写 OS 具有很好的开发和运行的体验。
**目前常见的指令集架构是 x86 和 ARM ,为何要推荐 RISC-V **
- 目前为止最常见的架构是 x86 和 ARM ,它们已广泛应用在服务器、台式机、移动终端和很多嵌入式系统中。它们需要支持非常多的软件系统和应用需求,导致它们越来越复杂。
- x86 后向兼容的策略确保了它的江湖地位,但导致其丢不掉很多已经比较过时的硬件设计,让操作系统疲于适配这些硬件特征。
- x86 和 ARM 在商业上都很成功,其广泛使用使得其 CPU 硬件逻辑越来越复杂,且不够开放,不能改变,不是开源的,提高了操作系统开发者的学习难度。
- 从某种角度上看,新出现的 RISC-V 的核心目标是灵活适应未来的 AIoT 场景,保证基本功能,提供可配置的扩展功能。其开源特征使得学生都可以方便地设计一个 RISC-V CPU。
- 写面向 RISC-V 的 OS 的代价仅仅是你了解 RISC-V 的 Supervisor 特权模式,知道 OS 在 Supervisor 特权模式下的控制能力。

View File

@ -0,0 +1,118 @@
什么是操作系统
================================================
.. toctree::
:hidden:
:maxdepth: 5
站在一万米的代码空间维度看
----------------------------------
现在的通用操作系统是一个复杂的系统软件,比如 Linux 操作系统达到了千万行的 C 源代码量级。在学习操作系统的初期,我们没有必要去分析了解这样大规模的软件。但这样的软件也是有其特有的一些特征。首先,它称为系统软件,简单理解它就是在一个计算机系统范围内使用的软件,管的是整个计算机系统。如果这样来看,一个编辑软件,如 Vi Emacs 就不能算了。
而在计算机中安装的 Rust 标准库(类似的有 C 标准库 libc 等)可以算是一个。
如果我们站在一万米的高空来看 :ref:`操作系统 <computer-hw-sw>` 可以发现操作系统这个软件干的事主要有两件一是向下管理计算机硬件和各种外设二是向上给应用软件提供各种服务帮助。我们可对其进一步描述操作系统是一个可以管理CPU、内存和各种外设并管理和服务应用软件的软件。这样的描述也是大多数操作系统教材上对操作系统的一个比较概括的定义。为了完成这些工作操作系统需要知道如何与硬件打交道如何更好地面向应用软件做好服务这就有一系列操作系统相关的理论、抽象、设计等来支持如何做和做得好的需求。
.. image:: computer-hw-sw.png
:align: center
:scale: 50 %
:name: computer-hw-sw
如果看看我们的身边, Android 应用运行在 ARM 处理器上 Android 操作系统的执行环境中,微软的 Office 应用运行在 x86-64 处理器上 Windows 操作系统的执行环境中Web Server应用运行在 x86-64 处理器上 Linux 操作系统的执行环境中, Web app 应用运行在 x86-64 或 ARM 处理器上 Chrome OS 操作系统的执行环境中。而在一些嵌入式环境中,操作系统以运行时库的形式与应用程序紧密结合在一起,形成一个可在嵌入式硬件上单一执行的嵌入式应用。所以,在不同的应用场景下,操作系统的边界也是不同的,我们可以把运行时库、图形界面支持库等这些可支持不同应用的系统软件 (System Software) 也看成是操作系统的一部分。
站在应用程序的角度来看,我们可以发现常见的应用程序其实是运行在由硬件、操作系统、运行时库、图形界面支持库等所包起来的一个 :ref:`执行环境 (Execution Environment) <exec-env>` 中,应用程序只需根据与系统软件约定好的应用程序二进制接口 (ABI, Application Binary Interface) 来请求执行环境提供的各种服务或功能,从而完成应用程序自己的功能。基于这样的观察,我们可以把操作系统再简单一点地定义为: **应用程序的软件执行环境** 。从这个角度出发,操作系统可以包括运行时库、图形界面支持库等系统软件,也能适应在操作系统发展的不同历史时期对操作系统的概括性描述和定义。
.. image:: EE.png
:align: center
:name: exec-env
站在计算机发展的百年时间尺度看
----------------------------------
虽然电子计算机的出现距今才仅仅七十年左右,但计算机技术和操作系统已经发生了巨大的变化。从计算机发展的短暂的历史角度看,操作系统也是从无到有地逐步发展起来的。操作系统主要完成控制硬件控制和为应用程序提供服务这些必不可少的功能,它的历史与计算机的发展史密不可分。操作系统的内涵和功能随着历史的发展也在一直变化、改进中。如今在二十一世纪初期的大众眼中,操作系统就是他们的手机/终端上的软件系统,包括各种应用程序集合,图形界面和网络浏览器是其中重要的组成部分。
其实操作系统的内涵和外延随着历史的发展也一直在变化并没有类似于“1+1=2”这样的明确定义。参考生物的进化史我们也给操作系统的进化历史做一个简单的概述从中可以看到操作系统在各个时间段上包含什么具有什么样的特征。但无论操作系统的内在实现和具体目标如何变化其管理计算机硬件给应用提供服务的核心定位没有变化。
寒武纪生物大爆发时代
~~~~~~~~~~~~~~~~~~~~~~
电子计算机在 1946 年最开始出现的时候是没有操作系统 (Operating System) 的,只有操作员 (Operator) 。启动,扳开关,装卡片/纸带等比较辛苦的工作都是计算机操作员或者用户自己完成。操作员/用户带着记录有程序和数据的卡片 (Punch Card) 或打孔纸带去操作机器。装好卡片/纸带后,启动卡片/纸带阅读器,把程序和数据读入计算机内存中之后,计算机就开始工作,并把结果也输出到卡片/纸带或显示屏上,最后程序停止。
由于过低的人工操作效率浪费了计算机的宝贵机时,所以就引入监控程序 (Monitor) 辅助完成输入、输出、加载、运行程序等工作,这是现代操作系统的起源,类似寒武纪生物大爆发中的“三叶虫”。一般情况下,计算机每次只能执行一个任务, CPU 大部分时间都在等待人的缓慢操作。这个初级的“辅助操作”过程一直持续到 20 世纪 50 年代。
.. note::
可以在 :ref:`本书第一章 <link-chapter1>` 看到初级的“三叶虫”操作系统其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序的开发与运行。
泥盆纪鱼类时代和二叠纪两栖动物时代
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在 20 世纪 50~60 年代,计算机发展到大型机阶段,而所对应的早期操作系统非常多样化、专用化,生产商生产出针对各自硬件的专用操作系统,大部分用汇编语言编写,这导致操作系统的进化比较缓慢,但进化在持续进行,从“手工操作”进化到了“批处理”阶段和“多道程序”阶段。在 1964 年, IBM 公司开发了面向 System/360 系列机器的统一可兼容的操作系统—— OS/360 ,它是一种批处理操作系统。为了能充分地利用计算机系统,应尽量使该系统连续运行,减少空闲时间,所以批处理操作系统把一批作业(古老的术语,可理解为现在的程序)以脱机方式输入到磁带上,并使这批作业能一个接一个地连续处理,流程如下:
1. 将磁带上的一个作业装入内存;
2. 操作系统把运行控制权交给该作业;
3. 当该作业处理完成后,控制权被交还给操作系统;
4. 重复1-3的步骤处理下一个作业直到所有作业处理完毕。
批处理操作系统分为单道批处理系统和多道批处理系统。单道批处理操作系统只能管理内存中的一个(道)作业,无法充分利用计算机系统中的所有资源,致使系统整体性能较差。多道批处理操作系统能管理内存中的多个(道)作业,可比较充分地利用计算机系统中的所有资源,提升系统整体性能。
多道批处理操作系统为此采用了多道程序设计技术,就是指允许同时把多个程序放入内存,并允许它们交替在 CPU 中运行,它们共享系统中的各种硬、软件资源。当一道程序因 I/O 请求而暂停运行时, CPU 便立即转去运行另一道程序。
虽然批处理操作系统提高了系统的执行效率,但其缺点是人机交互性差。如果程序员的代码出现错误,必须重新编码,上传内存,再执行。这需要花费以小时和天为单位的时间开销,使得程序员修改和调试程序很不方便。
.. note::
可以在 :ref:`本书第二章 <link-chapter2>` 看到批处理操作系统的设计实现,以及支持一个一个地执行应用程序的运行过程。而在 :ref:`本书第三章 <link-chapter3>` 的前三节可以看到支持协作式多道程序的操作系统的设计实现,以及支持应用程序主动放弃 CPU 以提高系统整体执行效率的过程。
侏罗纪与白垩纪的爬行动物时代
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20 世纪 60 年代末,提高人机交互方式的分时操作系统越来越崭露头角。分时是指多个用户和多个程序以很小的时间间隔来共享使用同一台计算机上的 CPU 和其他硬件/软件资源。1964 年贝尔实验室、麻省理工学院及美国通用电气公司共同参与研发了一个目标远大的操作系统MULTICS (MULTiplexed Information and Computing System) ,它是一套安装在大型主机上、支持多人多任务的操作系统。 MULTICS 以兼容分时系统 (CTSS) 做基础,建置在美国通用电力公司的大型机 GE-645 ,目标是连接 1000 部终端机,支持 300 位用户同时上线。因 MULTICS 项目的工作进度过于缓慢1969 年 AT&T 的 Bell 实验室从 MULTICS 研发中撤出。但贝尔实验室的两位软件工程师 Thompson 与 Ritchie 借鉴了一些重要的 MULTICS 理念,以 C 语言为基础发展出 UNIX 操作系统。UNIX 操作系统的早期版本是完全免费的,可以轻易获得并随意修改,所以它得到了广泛的接受。后来,它成为开发小型机操作系统的起点。由于早期的广泛应用,它已经成为分时操作系统的典范。
.. note::
可以在 :ref:`本书第三章 <link-chapter3>` 的第四节可以看到分时操作系统的设计实现,以及操作系统可强制让应用程序被动放弃 CPU 使得应用可以公平共享系统中的软硬件资源。并且 UNIX 还有虚存、文件、进程等当前操作系统的关键特性,这些内容也在本书的第四章~第七章中有详细的设计描述。
古近纪哺乳动物时代
~~~~~~~~~~~~~~~~~~~~~~~
20 世纪 70 年代,微型处理器的发展使计算机的应用普及至中小企及个人爱好者,推动了个人计算机 (PC, Personal Computer) 的发展,也进一步推动了面向个人使用的操作系统的出现。其代表是由微软公司在 20 世纪 80 年代为个人计算机开发的 DOS/Windows 操作系统,其特点是简单易用,特别是基于 Windows 操作系统的 GUI 界面,极大地简化了一般用户使用计算机的难度,使得计算机得到了快速的普及。这里需要注意的是,第一个带 GUI 界面的个人计算机原型起源于伟大却又让人扼腕叹息的施乐帕洛阿图研究中心 (PARC, Palo Alto Research Center) PARC 研发出的带有图标、弹出式菜单和重叠窗口的图形交互界面 (GUI, Graphical User Interface),可利用鼠标的点击动作来进行操控,这是当今我们所使用的 GUI 系统的基础。支持便捷的图形交互界面也成为自 20 世纪 70 年代以来操作系统的主要特征之一。
.. note::
目前支持 GUI 交互接口的操作系统设计实现在本书中还没有对应的章节。但其操作系统的内核其实与分时操作系统的设计实现思路基本是一致的。如果在本书设计的简单分时操作系统的基础上,添加一个图形外设的驱动和一个简单的 GUI 窗口系统,也许是一个有趣的实验内容。
第四纪智人时代
~~~~~~~~~~~~~~~~~~~~~
21 世纪以来, Internet 和移动互联网的迅猛发展使得在服务器领域和个人终端的应用与需求大增。iOS 和 Android 操作系统是21世纪个人终端操作系统的代表Linux 在巨型机到数据中心服务器操作系统中占据了统治地位。以 Android 系统为例Android 操作系统是一个包括 Linux 操作系统内核、基于 Java 的中间件、用户界面和关键应用软件的移动设备软件栈集合。这里介绍一下广泛用在服务器领域和个人终端中的操作系统内核--Linux 操作系统内核。1991 年 8 芬兰学生 Linus Torvalds \(林纳斯·托瓦兹\) 在 comp.os.minix 新闻组贴上了以下这段话 
你好所有使用 minix 的人 -我正在为 386 ( 486 ) AT 做一个免费的操作系统 ( 只是为了爱好 )...″
而他所说的“爱好″成为了大家都知道的 Linux 操作系统内核。这个时代的操作系统的特征是联网,发挥网络的吞吐量和低延迟是这个时代的网络操作系统追求的目标。 
.. note::
目前支持联网的操作系统设计实现在本书中还没有对应的章节。但其操作系统的内核其实与分时操作系统的设计实现思路基本是一致的。如果在本书设计的简单分时操作系统的基础上,添加一个网卡外设的驱动和一个简单的网络协议栈,也许是另一个有趣的实验内容。
二十一世纪神人时代
~~~~~~~~~~~~~~~~~~~~~~~~~
当前大数据、人工智能、机器学习、高速移动互联网络、AR/VR 对操作系统等系统软件带来了新的挑战。如何有效支持和利用这些技术是未来操作系统的方向。
在 2020 年,我们看到了华为逐步推出的鸿蒙系统;小米也推出了物联网软件平台小米 Vela ;阿里推出了 AliOS Thing腾讯推出了Tencent OS苹果公司接连推出 A14、M1 等基于 ARM 的 CPU逐步开始淘汰 X86CPU微软推出 Windows 10 IoTGoogle 推出 Fuchsia OS也都在做着各种云、边、端的技术调整和创新。
大家好像都意识到,不仅仅是人工智能和机器学习,下一个具有分布式特征的操作系统的新突破即将到来,并试图通过这种具有分布式特征的操作系统带来的连贯用户体验,打通从数据中心、服务器、桌面、移动端、边缘设备等的整个 AI 和物联网 (IoT, Internet of Things) 的生态。也许这个时代的未来操作系统与之前的操作系统相比,其最大的不同是跳出了单个设备节点,通过高速的无线网络从多种维度来管理多个设备,形成分布式操作系统。
.. note::
目前支持AIoT的操作系统设计实现在本书中还没有对应的章节不过我们的同学也设计了
`zCore操作系统 <https://github.com/rcore-os/zCore>`_
欢迎看完本书的同学能够尝试参与或独立设计面向未来的操作系统。
.. note::
本节内容部分参考了尤瓦尔·赫拉利所著的“人类简史”、“未来简史” 。

View File

@ -0,0 +1,44 @@
操作系统的接口
================================================
.. toctree::
:hidden:
:maxdepth: 5
站在使用操作系统的角度会比较容易对操作系统的功能产生初步的认识。操作系统内核是一个需要提供各种服务的软件,其服务对象是应用程序,而用户(这里可以理解为一般使用计算机的人)是通过应用程序的服务间接获得操作系统的服务的,因此操作系统内核藏在一般用户看不到的地方。但应用程序需要访问操作系统获得操作系统的服务,这就需要通过操作系统的接口才能完成。操作系统的接口的形式就是上一节提到的应用程序二进制接口 (ABI, Application Binary Interface)。但操作系统不是简单的一个函数库的编程接口 (API, Application Programming Interface) ,它的接口需要考虑安全因素,使得应用软件不能直接读写操作系统内部函数的地址空间,为此,操作系统设计了一套安全可靠的接口,我们称为系统调用接口 (System Call Interface),应用程序可以通过系统调用接口请求获得操作系统的服务,但不能直接调用操作系统的函数和全局变量;操作系统提供完服务后,返回应用程序继续执行。
.. note::
**API 与 ABI 的区别**
应用程序二进制接口 ABI 是不同二进制代码片段的连接纽带。ABI 定义了二进制机器代码级别的规则主要包括基本数据类型通用寄存器的使用参数的传递规则以及堆栈的使用等等。ABI 是用来约束链接器 (Linker) 和汇编器 (Assembler) 的。基于不同高级语言编写的应用程序、库和操作系统,如果遵循同样的 ABI 定义,那么它们就能正确链接和执行。
应用程序编程接口 API 是不同源代码片段的连接纽带。API 定义了一个源码级(如 C 语言)函数的参数,参数的类型,函数的返回值等。因此 API 是用来约束编译器 (Compiler) 的:一个 API 是给编译器的一些指令它规定了源代码可以做以及不可以做哪些事。API 与编程语言相关,如 LibC 是基于 C 语言编写的标准库,那么基于 C 的应用程序就可以通过编译器建立与 LibC 的联系,并能在运行中正确访问 LibC 中的函数。
对于实际操作系统而言,具有大量的服务接口,比如目前 Linux 有三百个系统调用接口。下面列出了一些相对比较重要的操作系统接口或抽象:
* 进程(即程序运行过程)管理:复制创建进程 fork 、退出进程 exit 、执行进程 exec 等。
* 同步互斥的并发控制:信号量 semaphore 、管程 monitor 、条件变量 condition variable 等。
* 进程间通信:管道 pipe 、信号 signal 、事件 event 等。
* 虚存管理:内存空间映射 mmap 、改变数据段地址空间大小 sbrk 、共享内存 shm 等。
* 文件I/O操作读 read 、写 write 、打开 open 、关闭 close 等。
* 外设I/O操作外设包括键盘、显示器、串口、磁盘、时钟 ...,但接口均采用了文件 I/O 操作的通用系统调用接口。
.. note::
上述表述在某种程度上说明了操作系统对计算机硬件重要组成的抽象和虚拟化,使得应用程序只需基于对简单的抽象概念的访问来到达对计算机系统资源的使用:
* 文件 (File) 是外设的一种抽象和虚拟化。特别对于存储外设而言,文件是持久存储的抽象。
* 地址空间 (Address Space) 是对内存的抽象和虚拟化。
* 进程 (Process) 是对计算机资源的抽象和虚拟化。而其中最核心的部分是对CPU的抽象与虚拟化。
.. image:: run-app.png
:align: center
:name: run-app
有了这些接口,简单的应用程序就不用考虑底层硬件细节,可以在操作系统的服务支持和管理下简洁地完成其应用功能了。在现阶段,也许大家对进程、文件、地址空间等抽象概念还不了解,在接下来的章节会对这些概念有进一步的介绍。

View File

@ -0,0 +1,199 @@
操作系统抽象
================================================
.. toctree::
:hidden:
:maxdepth: 5
..
chyyuu我觉得需要给出执行环境EETask...上下文函数trap,task进程...),执行流等的描述。
并且有一个图,展示这些概念的关系。这些概念能够有链接,指向进一步实际定义或使用的地方。
接下来读者可站在操作系统实现的角度来看操作系统。操作系统为了能够更好地管理计算机系统并为应用程序提供便捷的服务,在计算机和操作系统的技术研究和发展的过程中,形成了一系列的核心概念,奠定了操作系统内核设计与实现的基础。
.. note::
在本书中,下面的抽象表示不会仅仅就是一个文字的描述,还会在后续章节对具体操作系统设计与运行的讲述中,以具体化的静态数据结构,动态执行对物理/虚拟资源的变化来展示。从而让读者能够建立操作系统抽象概念与操作系统具体实验之间的内在联系。
执行环境
----------------------------------------
**执行环境** (Execution Environment) 是一个内涵很丰富且有一定变化的一个术语,它主要负责给在其上执行的软件提供相应的功能与资源,并可在计算机系统中形成多层次的执行环境。对于现在直接运行在裸机硬件 (Bare-Metal) 上的操作系统,其执行环境是 *计算机的硬件*
在寒武纪时期的计算机系统中,还没有操作系统,所以对于直接运行在裸机硬件上的应用程序而言,其执行环境也是 *计算机的硬件*
随着计算机技术的发展,应用程序下面形成了一层比较通用的函数库,这使得应用程序不需要直接访问硬件了,它所需要的功能(比如显示字符串)和资源(比如一块内存)都可以通过函数库的函数来帮助完成。在第二个阶段,应用程序的执行环境就变成了 *函数库* -> *计算机硬件* ,而这时函数库的执行环境就是计算机的硬件。
.. image:: basic-EE.png
:align: center
:name: basic-ee
再进一步,操作系统取代了函数库来访问硬件,函数库通过访问操作系统的系统服务来进一步给应用程序
提供丰富的功能和资源。在第三个阶段,应用程序的执行环境就变成了 *函数库* -> *操作系统* -> *计算机硬件*
在后面又出现了基于 Java 语言的应用程序,在函数库和操作系统之间,多了一层 Java 虚拟机,此时 Java 应用
程序的执行环境就变成了 *函数库* -> *Java 虚拟机* -> *操作系统* -> *计算机硬件* 。在云计算时代,在传统操作系统与
计算机硬件之间多了一层 Hypervisor/VMM ,此时应用程序的执行环境变成了 *函数库* -> *Java 虚拟机* -> *操作系统* -> *Hypervisor/VMM* -> *计算机硬件*
.. _term-ee-switch:
另外CPU在执行过程中可以在不同层次的执行环境之间可以切换这称为 **执行环境切换** 。这主要是通过特定的 API 或 ABI 来完成的,这样不同执行环境的软件就能实现数据交换与互操作,而且还保证了彼此之间有清晰的隔离。
.. image:: complex-EE.png
:align: center
:name: complex-ee
对于应用程序的执行环境而言其具体的内容是多变的但应用程序只能看到执行环境直接提供给它的接口API 或 ABI这使得应用程序所能得到的服务取决于执行环境提供给它这套接口。当然执行环境中的内在功能如对于应用程序的资源调度与管理等也会对应用程序的执行效率可靠性等提供间接的支持。所以操作系统是属于或等于应用程序执行环境的软件部分其形态可以是一个库也可以是一个虚拟机等或者它们的某种组合形式。
基于上面的介绍,我们可以给应用程序的执行环境一个基本的定义:执行环境是一个概念,一种机制,用来完成应用程序在运行时的数据与资源管理、应用程序的生存期等方面的处理,它定义了应用程序有权访问的其他数据或资源,并决定了应用程序的行为限制范围。
.. _term-ccf:
普通控制流
----------------------
回顾一下编译原理课上的知识,程序的控制流 (Flow of Control or Control Flow) 是指以一个程序的指令、语句或基本块为单位的执行序列。再回顾一下计算机组成原理课上的知识,处理器的控制流是指处理器中程序计数器的控制转移序列。最简单的一种控制流(没有异常或中断产生的前提下)是一个“平滑的”序列,其中每个要执行的指令地址在内存中都是相邻的。如果站在程序员的角度来看控制流,会发现控制流是程序员编写的程序的执行序列,这些序列是程序员预设好的。程序运行时能以多种简单的控制流(顺序、分支、循环结构和多层嵌套函数调用)组合的方式,来一行一行的执行源代码(以编程语言级的视角),也是一条一条的执行汇编指令(以汇编语言级的视角)。对于上述的不同描述,我们可以统称其为普通控制流 (CCFCommon Control Flow简称 控制流) 。在应用程序视角下,它只能接触到它所在的执行环境,不会跳到其他执行环境,所以应用程序执行基本上是以普通控制流的形式完成整个运行的过程。
.. _term-ecf:
异常控制流
--------------------------------------
应用程序在执行过程中,如果出现外设中断或 CPU 异常,处理器执行的前一条指令和后一条指令会位于两个完全不同的位置,即不同的执行环境 。比如,前一条指令还在应用程序的代码段中,后一条指令就跑到操作系统的代码段中去了,这就是一种控制流的“突变”,即控制流脱离了其所在的执行环境,并产生 :ref:`执行环境的切换 <term-ee-switch>`
应用程序 *感知* 不到这种异常的控制流情况,这主要是由于操作系统把这种情况 *透明* 地进行了执行环境的切换和对各种异常情况的处理,让应用程序从始至终地 *认为* 没有这些异常控制流的产生。
简单地说,异常控制流 (ECF, Exceptional Control Flow) 是处理器在执行过程中的突变,其主要作用是通过硬件和操作系统的协同工作来响应处理器状态中的特殊变化。比如当应用程序正在执行时,产生了时钟外设中断,导致操作系统打断当前应用程序的执行,转而进入 **操作系统** 所在的执行环境去处理时钟外设中断。处理完毕后,再回到应用程序的执行环境中被打断的地方继续执行。
.. note::
本书是从操作系统的角度来给出的异常控制流的定义。
在“深入理解计算机系统”CSAPP一书中对异常控制流也给出了相关定义
系统必须能对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获,也不一定和程序的执行相关。现代系统通过使控制流发生突变对这些情况做出反应。我们称这种突变为异常控制流( Exceptional Control Flow,ECF)
我们这里的异常控制流不涉及C++/Java等编程语言级的exception机制。
.. _term-context:
.. _term-ees:
上下文或执行环境的状态
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
站在硬件的角度来看普通控制流或异常控制流的具体执行过程,我们会发现从控制流起始的某条指令开始记录,指令可访问的所有物理资源,包括自带的所有通用寄存器、特权级相关特殊寄存器、以及指令访问的内存等,会随着指令的执行而逐渐发生变化。
这里我们把控制流在执行完某指令时的物理资源内容,即确保下一时刻能继续 *正确* 执行控制流指令的物理资源内容称为控制流的 **上下文** (Context) ,也可称为控制流所在执行环境的状态。
这里需要理解控制流的上下文对控制流的 *正确* 执行的影响。如果在某时刻,由于某种有意或无意的原因,控制流的上下文发生了不是由于控制流本身的指令产生的变化,并使得控制流执行接下来的指令序列时出现了偏差,并最终导致执行过程或执行结果不符合预期,这种情形称为没有正确执行。 所以,我们这里说的控制流的上下文是指仅会影响控制流正确执行的有限的物理资源内容。
如果一个控制流是属于某个函数,那么这个控制流的上下文简称为函数调用上下文。如果一个控制流是属于某个应用程序的,那么这个控制流的上下文简称为应用程序上下文。如果把某 :ref:`进程 <term-process>` 看做是运行的应用程序,那么这个属于某个应用程序的控制流可简称为某进程上下文。如果一个控制流是属于操作系统,那么这个控制流的上下文简称为操作系统上下文。如果一个控制流是属于操作系统中处理中断/异常/陷入的那段代码,那么这个控制流的上下文简称为中断/异常/陷入的上下文。
那么随着CPU的执行各种前缀的上下文执行环境的状态会在不断的变化。
如果出现了处理器在执行过程中的突变(即异常控制流)或转移(如多层函数调用),需要由维持执行环境的软硬件协同起来,保存发生突变或转移前的当前的执行环境的状态(比如突变或函数调用前一刻的指令寄存器,栈寄存器和其他一些通用寄存器等内容),并在完成突变处理或被调用函数后,恢复突变或转移前的执行环境的状态。这是由于完成与突变相关的执行会破坏突变前的执行环境状态(比如上述各种寄存器的内容),导致如果不保存状态,就无法恢复到突变前执行环境,继续正常的普通控制流的执行。
对于异常控制流的上下文保存与恢复,主要是通过 CPU 和操作系统(手动编写在栈上保存与恢复寄存器的指令)来协同完成;对于函数转移控制流的上下文保存与恢复,主要是通过编译器(自动生成在栈上保存与恢复寄存器的指令)来帮助完成的。
在操作系统中,需要处理三类异常控制流:外设中断 (Device Interrupt) 、陷入 (Trap) 和异常 (Exception也称Fault Interrupt)。
.. _term-execution-flow:
执行流或执行历史
------------------------
无论是操作系统还是应用程序,它在某一段时间上的执行过程会让处理器执行一系列程序的指令,并对计算机的物理资源的内容(即上下文)进行了改变。如果结合上面的抽象概念更加细致地表述一下,可以认为在它从开始到结束的整个执行过程中,截取其中一个时间段,在这个时间段中,它所执行的指令流形成了这个时间段的控制流,而控制流中的每条执行的指令和它执行后的上下文,形成由二元组<指令指针,上下文><pccontext>)构成的有序序列,我们用 **执行流** (Execution Flow) 或 **执行历史** (Execution History) 来表示这个二元组有序序列。它完整描述了操作系统或应用程序在一段时间内执行的指令流以及计算机物理资源的变化过程。
中断
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
外设 **中断** (Interrupt) 由外部设备引起的外部 I/O 事件如时钟中断、控制台中断等。外设中断是异步产生的,与处理器的执行无关。
.. image:: interrupt.png
:align: center
:name: interrupt
异常
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**异常** (Exception) 是在处理器执行指令期间检测到不正常的或非法的内部事件(如除零错、地址访问越界)。
.. image:: exception.png
:align: center
:name: exception
陷入
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**陷入** (Trap) 是在程序中使用请求操作系统服务的系统调用而引发的有意事件。
.. image:: syscall.png
:align: center
:name: syscall
在后面的叙述中,如果没有特别指出,我们将用简称中断、陷入、异常来区分这三种异常控制流。
.. note::
本书是从操作系统的角度来给出的中断 (Interrupt) 、陷入 (Trap) 和异常 (Exception)的定义。
在不同的书籍中,对于中断 、陷入和异常的定义会有一些差别。有的书籍把中断、陷入和异常都统一为一种中断,表示程序的当前控制流被打断了,要去执行不属于这个控制流的另外一个没有程序逻辑先后关系的控制流;也有书籍把这三者
统一为一种异常表示相对于程序的正常控制流而言出现了的一种没有程序逻辑先后关系的异常控制流。甚至也有书籍把这三者统一为一种陷入表示相对于程序的正常控制流而言CPU会陷入到
操作系统内核中去执行。
在RISC-V的特权级规范文档中“陷入” 包含中断和异常,而原来意义上的陷入(trap系统调用)只是exception中的一种情况。另外还有一种 “软件中断” 它是指软件可以通过写特定寄存器mip/sip的特定位MSIP/SSIP/USIP来产生的中断。而异常和中断有严格的区分在记录产生的异常或中断类型的特定寄存器mcause/scause寄存器最高位为 ``0`` 表示异常,最高位为 ``1`` 表示中断。进一步的详细信息可以可参考RISC-V的特权级规范文档和后面的章节。
这些都是从不同的视角来阐释中断、陷入和异常,并没有一个唯一精确的解释。对于读者而言,重点是了解这些术语在后续章节的操作系统设计实现中所表示的具体含义和特征。
.. _term_process:
进程
----------------------------------
进程 (Process) 的一个经典定义是一个正在运行的程序的实例。在计算机系统中,我们可以“同时”运行多个程序,这个“同时”,其实是操作系统给用户造成的一个“幻觉”。在操作系统上运行一个程序时,我们会得到一个“幻觉”,就好像我们执行的一个程序是整个计算机系统中当前运行的唯一的程序,能够独占使用处理器、内存和外设。而且程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
.. image:: prog-illusion.png
:align: center
:name: prog-illusion
计算机系统中运行的每个程序都是运行在某个进程的上下文中。这里的上下文是指程序在运行中的状态。运行的状态包括:内存中的代码和数据,栈、堆、当前执行的指令位置(程序计数器的内容)、当前执行时刻的各个通用寄存器中的值,各种正在访问的资源的集合。进程上下文如下图所示:
.. image:: context-of-process.png
:align: center
:name: context-of-process
我们知道,处理器是计算机系统中的硬件资源。为了提高处理器的利用率,操作系统需要让处理器足够忙,即让不同的程序轮流占用处理器来运行。如果一个程序因某个事件而不能运行下去时,就通过进程上下文切换把处理器占用权转交给另一个可运行程序。进程上下文切换如下图所示:
.. image:: context-switch.png
:align: center
:name: context-switch
基于上面的介绍,我们可以给进程一个更加准确的定义:一个进程是一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程。
操作系统中的进程管理需要采用某种调度策略将处理器资源分配给程序并在适当的时候回收,并且要尽可能充分利用处理器的硬件资源。
地址空间
----------------------------------
**地址空间** (Address Space) 是对物理内存的虚拟化和抽象,也称虚存 (Virtual Memory)。它就是操作系统通过处理器中的内存管理单元 (MMU, Memory Management Unit) 硬件的支持而给应用程序和用户提供一个大的(可能超过计算机中的内存条容量)、一致的(连续的地址空间)、私有的(其他应用程序无法破坏)的存储空间。这需要操作系统将内存和硬盘结合起来管理,为用户提供一个容量比实际内存大得多的虚拟存储器,并且需要操作系统为应用程序分配内存空间,使用户存放在内存中的程序和数据彼此隔离、互不侵扰。操作系统中的虚存管理与处理器的 MMU 密切相关,在启动虚存机制后,软件通过 CPU 访问的每个虚拟地址都需要通过 CPU 中的 MMU 转换为一个物理地址来进行访问。下面是虚拟的地址空间与物理内存和物理磁盘映射的图示:
.. image:: address-space.png
:align: center
:name: address-space
文件
----------------------------------
**文件** (File) 主要用于对持久存储的抽象,并进一步扩展到为外设的抽象。具体而言,文件可理解为存放在持久存储介
比如硬盘、光盘、U盘等方便应用程序和用户读写的数据。以磁盘为代表的持久存储介质的数据访问单位是一个扇区或一个块而在内存中的数据访问单位是一个字节或一个字。这就需要操作系统通过文件来屏蔽磁盘与内存差异尽量以内存的读写方式来处理持久存储的数据。当处理器需要访问文件中的数据时可通过操作系统把它们装入内存。文件管理的任务是有效地支持文件的存储、
检索和修改等操作。
下面是文件对磁盘的抽象映射图示:
.. image:: file-disk.png
:align: center
:name: file-disk
从一个更高和更广泛的层次上看,各种外设虽然差异很大,但也有基本的读写操作,可以通过文件来进行统一的抽象,并在操作系统内部实现中来隐藏对外设的具体访问过程,从而让用户可以以统一的文件操作来访问各种外设。这样就可以把文件看成是对外设的一种统一抽象,应用程序通过基本的读写操作来完成对外设的访问。

View File

@ -0,0 +1,66 @@
操作系统的特征
================================================
.. toctree::
:hidden:
:maxdepth: 5
基于操作系统的四个抽象,我们可以看出,从总体上看,操作系统具有五个方面的特征:虚拟化 (Virtualization)、并发性 (Concurrency)、异步性、共享性和持久性 (Persistency)。操作系统的虚拟化可以理解为它对内存、CPU 的抽象和处理;并发性和共享性可以理解为操作系统支持多个应用程序“同时”运行;异步性可以从操作系统调度、中断处理对应用程序执行造成的影响等几个方面来理解;持久性则可以从操作系统中的文件系统支持把数据方便地从磁盘等存储介质上存入和取出来理解。
虚拟性
----------------------------------
内存虚拟化
~~~~~~~~~~~~~~
首先来看看内存的虚拟化。程序员在写应用程序的时候,不用考虑其程序的起始内存地址要放到计算机内存的具体某个位置,而是用字符串符号定义了各种变量和函数,直接在代码中便捷地使用这些符号就行了。这是由于操作系统建立了一个 *地址固定* *空间巨大* 的虚拟内存给应用程序来运行,这是 **空间虚拟化** 。这里的每个符号在运行时是要对应到具体的内存地址的。这些内存地址的具体数值是什么?程序员不用关心。为什么?因为编译器会自动帮我们把这些符号翻译成地址,形成可执行程序。程序使用的内存是否占得太大了?在一般情况下,程序员也不用关心。
.. note::
还记得虚拟地址(逻辑地址)的描述吗?
实际上,编译器 (Compiler比如 gcc) 和链接器 (linker比如 ld) 也不知道程序每个符号对应的地址应该放在未来程序运行时的哪个物理内存地址中。所以,编译器的一个简单处理办法就是,设定一个固定地址(比如 0x10000作为起始地址开始存放代码代码之后是数据所有变量和函数的符号都在这个起始地址之后的某个固定偏移位置。假定程序每次运行都是位于一个不会变化的起始地址。这里的变量指的是全局变量其地址在编译链接后会确定不变。但局部变量是放在堆栈中的会随着堆栈大小的动态变化而变化。这里编译器产生的地址就是虚拟地址。
这里,编译器和链接器图省事,找了一个适合它们的解决办法。当程序要运行的时候,这个符号到机器物理内存的映射必须要解决了,这自然就推到了操作系统身上。操作系统会把编译器和链接器生成的执行代码和数据放到物理内存中的空闲区域中,并建立虚拟地址到物理地址的映射关系。由于物理内存中的空闲区域是动态变化的,这也导致虚拟地址到物理地址的映射关系是动态变化的,需要操作系统来维护好可变的映射关系,确保编译器“固定起始地址”的假设成立。只有操作系统维护好了这个映射关系,才能让程序员只需写一些易于人理解的字符串符号来代表一个内存空间地址,且编译器只需确定一个固定地址作为程序的起始地址就可以不用考虑将来这个程序要在哪里运行的问题,从而实现了 **空间虚拟化**
应用程序在运行时不用考虑当前物理内存是否够用。如果应用程序需要一定空间的内存,但由于在某些情况下,物理内存的空闲空间可能不多了,这时操作系统通过把物理内存中最近没使用的空间(不是空闲的,只是最近用得少)换出(就是“挪地”)到硬盘上暂时缓存起来,这样空闲空间就大了,就可以满足应用程序的运行时内存需求了,从而实现了 **空间大小虚拟化**
CPU 虚拟化
~~~~~~~~~~~~~~
再来看 CPU 虚拟化。不同的应用程序可以在内存中并发运行,相同的应用程序也可有多个拷贝在内存中并发运行。而每个程序都“认为”自己完全独占了 CPU 在运行,这是”时间虚拟化“。这其实也是操作系统给了运行的应用程序一个幻象。其实是操作系统把时间分成小段,每个应用程序占用其中一小段时间片运行,用完这一时间片后,操作系统会切换到另外一个应用程序,让它运行。由于时间片很短,操作系统的切换开销也很小,人眼基本上是看不出的,反而感觉到多个程序各自在独立”并行“执行,从而实现了 **时间虚拟化**
.. note::
并行 (Parallel) 是指两个或者多个事件在同一时刻发生;而并发 (Concurrent) 是指两个或多个事件在同一时间间隔内发生。
对于单 CPU 的计算机而言,各个”同时“运行的程序其实是串行分时复用一个 CPU ,任一个时刻点上只有一个程序在 CPU 上运行。
这些虚拟性的特征给应用程序的开发和执行提供了非常方便的环境,但也给操作系统的设计与实现提出了很多挑战。
并发性
----------------------------------
操作系统为了能够让 CPU 充分地忙起来并充分利用各种资源,就需要给很多任务给它去完成。这些任务是分时完成的,由操作系统来完成各个应用在运行时的任务切换。并发性虽然能有效改善系统资源的利用率,但也带来了对共享资源的争夺问题,即同步互斥问题;执行时间的不确定性问题,即并发程序在执行中是走走停停,断续推进的。并发性对操作系统的设计也带来了很多挑战,一不小心就会出现程序执行结果不确定,程序死锁等很难调试和重现的问题。
异步性
----------------------------------
在这里,异步是指由于操作系统的调度和中断等,会不时地暂停或打断当前正在运行的程序,使得程序的整个运行过程走走停停。在应用程序运行的表现上,特别它的执行完成时间是不可预测的。但需要注意,只要应用程序的输入是一致的,那么它的输出结果应该是符合预期的。
共享性
----------------------------------
共享是指多个应用并发运行时,宏观上体现出它们可同时访问同一个资源,即这个资源可被共享。但其实在微观上,操作系统在硬件等的支持下要确保应用程序互斥或交替访问这个共享的资源。比如两个应用同时访问同一个内存单元,从宏观的应用层面上看,二者都能正确地读出同一个内存单元的内容。而在微观上,操作系统会调度应用程序的先后执行顺序,在数据总线上任何一个时刻,只有一个应用去访问存储单元。
持久性
----------------------------------
操作系统提供了文件系统来从可持久保存的存储介质(磁盘, SSD 等,以后以硬盘来代表)中取数据和代码到内存中,并可以把内存中的数据写回到硬盘上。硬盘在这里是外设,具有持久性,以文件系统的形式呈现给应用程序。
.. note::
文件系统也可看成是操作系统对存储外设如硬盘、SSD 等)的虚拟化。
这种持久性的特征进一步带来了共享属性,即在文件系统中的文件可以被多个运行的程序所访问,从而给应用程序之间实现数据共享提供了方便。即使掉电,存储外设上的数据还不会丢失,可以在下一次机器加电后提供给运行的程序使用。持久性对操作系统的执行效率提出了挑战,如何让数据在高速的内存和慢速的硬盘间高效流动是需要操作系统考虑的问题。

View File

@ -0,0 +1,267 @@
实验环境配置
============
.. toctree::
:hidden:
:maxdepth: 4
本节我们将完成环境配置并成功运行 uCore-Tutorial-v3 。整个流程分为下面几个部分:
- 系统环境配置
- Riscv下 C 开发环境配置
- Qemu 模拟器安装
- 其他工具安装
- 运行 uCore-Tutorial
系统环境配置
-------------------------------
目前实验仅支持 Ubuntu18.04 + 操作系统。对于 Windows10 和 macOS 上的用户,可以使用 VMware 或
VirtualBox 安装一台 Ubuntu18.04 虚拟机并在上面进行实验。
特别的Windows10 的用户可以通过系统内置的 WSL2 虚拟机(请不要使用 WSL1来安装 Ubuntu 18.04 / 20.04 。
步骤如下:
- 升级 Windows 10 到最新版Windows 10 版本 18917 或以后的内部版本)。注意,如果
不是 Windows 10 专业版,可能需要手动更新,在微软官网上下载。升级之后,
可以在 PowerShell 中输入 ``winver`` 命令来查看内部版本号。
- 「Windows 设置 > 更新和安全 > Windows 预览体验计划」处选择加入 “Dev 开发者模式”。
- 以管理员身份打开 PowerShell 终端并输入以下命令:
.. code-block::
# 启用 Windows 功能:“适用于 Linux 的 Windows 子系统”
>> dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
# 启用 Windows 功能:“已安装的虚拟机平台”
>> dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
# <Distro> 改为对应从微软应用商店安装的 Linux 版本名,比如:`wsl --set-version Ubuntu 2`
# 如果你没有提前从微软应用商店安装任何 Linux 版本,请跳过此步骤
>> wsl --set-version <Distro> 2
# 设置默认为 WSL 2如果 Windows 版本不够,这条命令会出错
>> wsl --set-default-version 2
- `下载 Linux 内核安装包 <https://docs.microsoft.com/zh-cn/windows/wsl/install-win10#step-4---download-the-linux-kernel-update-package>`_
- 在微软商店Microsoft Store中搜索并安装 Ubuntu18.04 / 20.04。
C 开发环境配置
-------------------------------------------
这一大步和下面的Docker安装方式大家可以选用一种。
首先我们需要安装好RISC-V配套的gcc。首先选择一个位置放置gcc的可执行二进制文件。我们选择一个常用的位置。这个位置也可以大家自己指定。
.. code-block:: bash
cd /usr/local
之后直接下载预编译好的Risc-v工具链
.. code-block:: bash
sudo wget https://static.dev.sifive.com/dev-tools/freedom-tools/v2020.08/riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14.tar.gz
解压缩:
.. code-block:: bash
tar xzvf riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14.tar.gz
文件名改短:
.. code-block:: bash
mv riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14 riscv64-unknown-elf-gcc
这里就算安装完成了。接下来我们要把gcc的二进制文件路径添加到PATH之中这样我们才能在任意目录直接运行它。将以下指令添加到home下的.bashrc之中可以一劳永逸地添加(如果你使用的是自己的路径请更换路径的前缀/usr/local到你自己的路径)
.. code-block:: bash
export PATH="/usr/local/riscv64-unknown-elf-gcc/bin:$PATH"
接下来继续安装用于交叉编译的musl-gcc,这里我们仍然使用/usr/local存放它。下面的步骤和上一步的安装是一样的
.. code-block:: bash
cd /usr/local
sudo wget https://more.musl.cc/9.2.1-20190831/x86_64-linux-musl/riscv64-linux-musl-cross.tgz
tar xzvf riscv64-linux-musl-cross.tgz
将路径添加到PATH之中:
.. code-block:: bash
export PATH="/usr/local/riscv64-linux-musl-cross/bin:$PATH"
我们的项目使用cmake搭建因此还需要安装cmake。
.. code-block:: bash
sudo apt install cmake
如果是第一次启动虚拟机,需要执行:
.. code-block:: bash
sudo apt update
sudo apt upgrade
至于 C 开发环境推荐直接使用Vscode作为IDE来进行。可以在其中安装C的插件来使用自动补全和lint。
Docker 环境安装(可选,已完成上述步骤的可以忽略)
----------------------------------------
使用配置好的Docker容器可以免于自己安装上面的一系列包当然会多出配置Docker的工作量。
- 首先安装Docker: https://www.docker.com/get-started
- 接着拉取我们的Docker镜像文件
.. code-block:: bash
docker pull nzpznk/oslab-c-env
docker pull registry.cn-hangzhou.aliyuncs.com/nzpznk/oslab-c-env
- 查看下载的镜像文件docker image ls (nzpznk/oslab-c-env or registry.cn-hangzhou.aliyuncs.com/nzpznk/oslab-c-env)
- 使用下载的镜像文件创建一个名字叫container_name(可自己指定)的容器并获得容器的bash shell: docker run -it --name container_name image_name /bin/bash
- 一些常用的指令:
.. code-block:: bash
# 检查运行中的容器
docker ps
# 检查所有容器(包括停止了的)
docker ps -a
# 停止 / 启动容器
docker stop/start container_name
# 获取一个运行中的docker容器的bash shell:
docker exec -it container_name /bin/bash
# 删除一个已经停止的容器:
docker rm container_name
- 之后就是把我们宿主机的文件挂载在docker容器上.在使用 docker run 启动容器时你可以将目录挂载到容器上这样就可以从docker容器访问到本地主机的某个文件夹.
.. code-block:: bash
docker run -it --name container_name --mount type=bind,src=[absolute path of folder in host machine],dst=[absolute path in container] image_name /bin/bas
Qemu 模拟器安装
----------------------------------------
这里的内容和rCore文档中的一致。我们需要使用 Qemu 5.0.0 版本进行实验,而很多 Linux 发行版的软件包管理器默认软件源中的 Qemu 版本过低,因此
我们需要从源码手动编译安装 Qemu 模拟器。
首先我们安装依赖包,获取 Qemu 源代码并手动编译:
.. code-block:: bash
# 安装编译所需的依赖包
sudo apt install autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev \
gawk build-essential bison flex texinfo gperf libtool patchutils bc \
zlib1g-dev libexpat-dev pkg-config libglib2.0-dev libpixman-1-dev git tmux python3
# 下载源码包
# 如果下载速度过慢可以使用我们提供的百度网盘链接https://pan.baidu.com/s/1z-iWIPjxjxbdFS2Qf-NKxQ
# 提取码 8woe
wget https://download.qemu.org/qemu-5.0.0.tar.xz
# 解压
tar xvJf qemu-5.0.0.tar.xz
# 编译安装并配置 RISC-V 支持
cd qemu-5.0.0
./configure --target-list=riscv64-softmmu,riscv64-linux-user
make -j$(nproc)
.. note::
注意,上面的依赖包可能并不完全,比如在 Ubuntu 18.04 上:
- 出现 ``ERROR: pkg-config binary 'pkg-config' not found`` 时,可以安装 ``pkg-config`` 包;
- 出现 ``ERROR: glib-2.48 gthread-2.0 is required to compile QEMU`` 时,可以安装
``libglib2.0-dev`` 包;
- 出现 ``ERROR: pixman >= 0.21.8 not present`` 时,可以安装 ``libpixman-1-dev`` 包。
另外一些 Linux 发行版编译 Qemu 的依赖包可以从 `这里 <https://risc-v-getting-started-guide.readthedocs.io/en/latest/linux-qemu.html#prerequisites>`_
找到。
之后我们可以在同目录下 ``sudo make install`` 将 Qemu 安装到 ``/usr/local/bin`` 目录下,但这样经常会引起
冲突。个人来说更习惯的做法是,编辑 ``~/.bashrc`` 文件(如果使用的是默认的 ``bash`` 终端),在文件的末尾加入
几行:
.. code-block:: bash
# 请注意qemu-5.0.0 的父目录可以随着你的实际安装位置灵活调整
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0/riscv64-softmmu
export PATH=$PATH:/home/shinbokuow/Downloads/built/qemu-5.0.0/riscv64-linux-user
随后即可在当前终端 ``source ~/.bashrc`` 更新系统路径,或者直接重启一个新的终端。
此时我们可以确认 Qemu 的版本:
.. code-block:: bash
qemu-system-riscv64 --version
qemu-riscv64 --version
K210 真机串口通信
------------------------------
为了能在 K210 真机上运行 Tutorial我们还需要安装基于 Python 的串口通信库和简易的串口终端。
.. code-block:: bash
pip3 install pyserial
sudo apt install python-serial
GDB 调试支持
------------------------------
``os`` 目录下 ``make debug`` 可以调试我们的内核,这需要安装终端复用工具 ``tmux`` ,还需要基于 riscv64 平台的 gdb 调试器 ``riscv64-unknown-elf-gdb`` 。该调试器包含在 riscv64 gcc 工具链中,工具链的预编译版本可以在如下链接处下载:
- `Ubuntu 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-linux-ubuntu14.tar.gz>`_
- `macOS 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-apple-darwin.tar.gz>`_
- `Windows 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-w64-mingw32.zip>`_
- `CentOS 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-linux-centos6.tar.gz>`_
解压后在 ``bin`` 目录下即可找到 ``riscv64-unknown-elf-gdb`` 以及另外一些常用工具 ``objcopy/objdump/readelf`` 等。
运行 rCore-Tutorial-v3
------------------------------------------------------------
在 Qemu 平台上运行
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
如果是在 Qemu 平台上运行,只需在 ``os`` 目录下 ``make run`` 即可。在内核加载完毕之后,可以看到目前可以用的
应用程序。 ``usertests`` 打包了其中的很大一部分,所以我们可以运行它,只需输入在终端中输入它的名字即可。
.. image:: qemu-final.gif
之后,可以先按下 ``Ctrl+A`` ,再按下 ``X`` 来退出 Qemu。
在 K210 平台上运行
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
如果是在 K210 平台上运行则略显复杂。
首先,我们需要将 MicroSD 插入 PC 来将文件系统镜像拷贝上去。
.. image:: prepare-sd.gif
.. warning::
``os/Makefile`` 中我们默认设置 MicroSD 在当前操作系统中可以用设备 ``SDCARD=/dev/sdb`` 访问。你可以使用 ``df -hT`` 命令来确认在你的环境中 MicroSD 是哪个设备,
并在 ``make sdcard`` 之前对 ``os/Makefile````SDCARD`` 配置做出适当的修改。不然,这有可能导致 **设备 /dev/sdb 上数据丢失**
随后,我们将 MicroSD 插入 K210 开发板,将 K210 开发板连接到 PC ,然后进入 ``os`` 目录 ``make run BOARD=k210``
在 K210 开发板上跑 Tutorial 。
.. image:: k210-final.gif
之后,可以按下 ``Ctrl+]`` 来退出串口终端。
到这里恭喜你完成了实验环境的配置可以开始阅读教程的正文部分了可以直接clone下面的仓库来开始OS之旅
.. code-block:: bash
git clone https://github.com/DeathWish5/ucore-Tutorial.git

View File

@ -0,0 +1,72 @@
K210 开发板相关问题
=====================================================
我们采用的真实硬件平台 Kendryte K210 在设计的时候基于 RISC-V 特权级架构 1.9.1 版本,它发布于 2016 年,目前已经不被
主流工具链所支持了。麻烦的是, 1.9.1 版本和后续版本确实有很多不同。为此RustSBI 做了很多兼容性工作,使得基于新版规范
的软件几乎可以被不加修改的运行在 Kendryte K210 上。在这里我们先简单介绍一些开发板相关的问题。
K210 相关 Demo 和文档
--------------------------------------------
- `K210 datasheet <https://cdn.hackaday.io/files/1654127076987008/kendryte_datasheet_20181011163248_en.pdf>`_
- `K210 官方 SDK <https://github.com/kendryte/kendryte-standalone-sdk>`_
- `K210 官方 SDK 文档 <https://canaan-creative.com/wp-content/uploads/2020/03/kendryte_standalone_programming_guide_20190311144158_en.pdf>`_
- `K210 官方 SDK Demo <https://github.com/kendryte/kendryte-standalone-demo>`_
- `K210 Demo in Rust <https://github.com/laanwj/k210-sdk-stuff>`_
K210 相关工具
--------------------------------------------
JTAG 调试
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- `一篇 Blog <https://blog.sipeed.com/p/727.html>`_
- `Sipeed 工程师提供的详细配置文档 <https://github.com/wyfcyx/osnotes/blob/master/book/sipeed_rv_debugger_k210.pdf>`_
- `MaixDock OpenOCD 调试配置 <https://github.com/wyfcyx/osnotes/blob/master/book/openocd_ftdi.cfg>`_
烧写
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- `kflash.py <https://github.com/sipeed/kflash.py>`_
- `kflash_gui <https://github.com/sipeed/kflash_gui>`_
K210 可用内存大小
--------------------------------------------
K210 的内存是由 CPU 和 KPU 共享使用的,如果想要 CPU 能够使用全部的 :math:`8\text{MiB}` 需要满足三个条件:
- KPU 不能处于工作状态;
- PLL1 必须被使能;
- PLL1 的 clock gate 必须处于打开状态。
否则, CPU 仅能够使用 :math:`6\text{MiB}` 内存。
我们进行如下操作即可让 CPU 使用全部 :math:`8\text{MiB}` 内存(基于官方 SDK
.. code-block:: c
sysctl_pll_enable(SYSCTL_PLL1);
syscyl_clock_enable(SYSCTL_CLOCK_PLL1);
K210 的频率
--------------------------------------------
默认情况下K210 的 CPU 频率为 403000000 ,约 :math:`400\text{MHz}` 。而计数器 ``mtime`` CSR 增长的频率为
CPU 频率的 1/62 ,约 :math:`6.5\text{MHz}`
K210 的 MMU 支持
--------------------------------------------
K210 有完善的 SV39 多级页表机制,然而它是基于 1.9.1 版本特权级架构的,和我们目前使用的有一些不同。不过在 RustSBI
的帮助下,本项目中完全看不出 Qemu 和 K210 两个平台在这方面的区别。详情请参考
`RustSBI 的设计与实现 <https://github.com/luojia65/DailySchedule/blob/master/2020-slides/RustSBI%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0.pdf>`_
的 P11 。
K210 的外部中断支持
--------------------------------------------
K210 的 S 特权级外部中断不存在(被硬件置为零),因此任何软件/硬件代理均无法工作。为此RustSBI 专门提供了一个新的 SBI
call ,让 S 模式软件可以编写 S 特权级外部中断的 handler 并注册到 RustSBI 中,在中断触发的时候由 RustSBI 调用该
handler 处理中断。详情请参考 `RustSBI 的设计与实现 <https://github.com/luojia65/DailySchedule/blob/master/2020-slides/RustSBI%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0.pdf>`_
的 P12 。

BIN
source/chapter0/EE.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

16
source/chapter0/index.rst Normal file
View File

@ -0,0 +1,16 @@
.. _link-chapter0:
第零章:操作系统概述
==============================================
.. toctree::
:maxdepth: 4
0intro
1what-is-os
2os-interface
3os-hw-abstract
4os-features
5setup-devel-env
6hardware

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
source/chapter0/run-app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
source/chapter0/syscall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

136
source/chapter1/0intro.rst Normal file
View File

@ -0,0 +1,136 @@
引言
=====================
本章导读
--------------------------
..
这是注释我觉得需要给出执行环境EETask...等的描述。
并且有一个图,展示这些概念的关系。
本章展现了操作系统一个功能:让应用与硬件隔离,简化了应用访问硬件的难度和复杂性。
大多数程序员的第一行代码都从 ``Hello, world!`` 开始,当我们满怀着好奇心在编辑器内键入仅仅数个字节,再经过几行命令编译(靠的是编译器)、运行(靠的是操作系统),终于在黑洞洞的终端窗口中看到期望中的结果的时候,一扇通往编程世界的大门已经打开。在本章第一节 :doc:`1app-ee-platform`可以看到用Rust语言编写的非常简单的“Hello, world”应用程序。
不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 **编译器** 为主的开发环境;运行应用程序执行码所依赖的是以 **操作系统** 为主的执行环境。
本章主要是设计和实现建立在裸机上的执行环境,从中对应用程序和它所依赖的执行环境有一个全面和深入的理解。
本章我们的目标仍然只是输出 ``Hello, world!`` ,但这一次,我们将离开舒适区,基于一个几乎空无一物的平台从零开始搭建我们自己的高楼大厦,而不是仅仅通过一行语句就完成任务。所以,在接下来的内容中,我们将描述如何让 ``Hello, world!`` 应用程序逐步脱离对编译器、运行时和操作系统的现有复杂依赖,最终以最小的依赖需求能在裸机上运行。这时,我们也可把这个能在裸机上运行的 ``Hello, world!`` 应用程序称为一种支持输出字符串的非常初级的寒武纪“三叶虫”操作系统,它其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序在裸机上的开发与运行。输出字符串功能好比是三叶虫的眼睛,有了它,我们就有了最基本的调试功能,即通过在代码中的不同位置插入特定内容的输出语句来实现对程序运行的调试。
.. note::
在操作系统发展历史上在1956年就诞生了操作系统GM-NAA I/O并且被实际投入使用它的一个主要任务就是"自动加载运行一个接一个的程序"。
实践体验
---------------------------
本章设计实现了一个支持显示字符串应用的简单操作系统--“三叶虫”操作系统。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch1
在 qemu 模拟器上运行本章代码看看一个小应用程序是如何在QEMU模拟的计算机上运行的
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发板连接到 PC并在上面运行本章代码看看一个小应用程序是如何在真实计算机上运行的
.. code-block:: console
$ cd os
$ make run BOARD=k210
.. warning::
**FIXME: 提供 wsl/macOS 等更多平台支持**
如果顺利的话,以 qemu 平台为例,将输出:
.. code-block::
[rustsbi] RustSBI version 0.1.1
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] Kernel entry: 0x80200000
Hello, world!
.text [0x80200000, 0x80202000)
.rodata [0x80202000, 0x80203000)
.data [0x80203000, 0x80203000)
boot_stack [0x80203000, 0x80213000)
.bss [0x80213000, 0x80213000)
Panicked at src/main.rs:46 Shutdown machine!
除了 ``Hello, world!`` 之外还有一些额外的信息,最后关机。
.. note::
RustSBI是啥
:doc:`../appendix-c/index` 可以进一步了解RustSBI。
本章代码树
------------------------------------------------
.. code-block::
./os/src
Rust 4 Files 118 Lines
Assembly 1 Files 11 Lines
├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│   ├── rustsbi-k210.bin(可运行在 k210 真实硬件平台上的预编译二进制版本)
│   └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)
├── LICENSE
├── os(我们的内核实现放在 os 目录下)
│   ├── Cargo.toml(内核实现的一些配置文件)
│   ├── Makefile
│   └── src(所有内核的源代码放在 os/src 目录下)
│   ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│   ├── entry.asm(设置内核执行环境的的一段汇编代码)
│   ├── lang_items.rs(需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│   ├── linker-k210.ld(控制内核内存布局的链接脚本以使内核运行在 k210 真实硬件平台上)
│   ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│   ├── main.rs(内核主函数)
│   └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)
├── README.md
├── rust-toolchain(控制整个项目的工具链版本)
└── tools(自动下载的将内核烧写到 k210 开发板上的工具)
├── kflash.py
├── LICENSE
├── package.json
├── README.rst
└── setup.py
本章代码导读
-----------------------------------------------------
操作系统虽然是软件但它不是常规的应用软件需要运行在没有操作系统的裸机环境中。如果采用通常编程方法和编译手段无法开发出操作系统。其中一个重要的原因编译器编译出的应用软件在缺省情况下是要链接标准库Rust编译器和C编译器都是这样的而标准库是依赖于操作系统如Linux、Windows等的。所以本章主要是让读者能够脱离常规应用软件开发的思路理解如何开发没有操作系统支持的操作系统内核。
为了做到这一步,首先需要写出不需要标准库的软件并通过编译。为此,先把一般应用所需要的标准库的组件给去掉,这会导致编译失败。然后在逐步添加不需要操作系统的极少的运行时支持代码,让编译器能够正常编译出不需要标准库的正常程序。但此时的程序没有显示输出,更没有输入等,但可以正常通过编译,这样就为进一步扩展程序内容打下了一个 **可正常编译OS** 的前期基础。具体可看 :ref:`移除标准库依赖 <term-remove-std>` 一节的内容。
由于操作系统代码无法象应用软件那样可以有方便的调试Debug功能。这是因为应用之所以能够被调试也是由于操作系统提供了方便的调试相关的系统调用。而我们不得不再次认识到需要运行在没有操作系统的裸机环境中当然没法采用依赖操作系统的传统调试方法了。所以我们只能采用 ``print`` 这种原始且有效的调试方法。这样,第二步就是让脱离了标准库的软件有输出,这样,我们就能看到程序的运行情况了。为了简单起见,我们可以先在用户态尝试构建没有标准库的支持显示输出的最小运行时执行环境,比较特别的地方在于如何写内嵌汇编完成简单的系统调用。具体可看 :ref:`构建用户态执行环境 <term-print-userminienv>` 一节的内容。
接下来就是尝试构建可在裸机上支持显示的最小运行时执行环境。相对于用户态执行环境,读者需要能够做更多的事情,比如如何关机,如何配置软件运行所在的物理内存空间,特别是栈空间,如何清除 ``bss`` 段,如何通过 ``RustSBI````SBI_CONSOLE_PUTCHAR`` 接口简洁地实现的信息输出。这里比较特别的地方是需要了解 ``linker.ld`` 文件中对OS的代码和数据所在地址空间布局的描述以及基于RISC-V 64的汇编代码 ``entry.asm`` 如何进行栈的设置和初始化以及如何跳转到Rust语言编写 ``rust_main`` 主函数中,并开始内核最小运行时执行环境的运行。具体可看 :ref:`构建裸机执行环境 <term-print-kernelminienv>` 一节的内容。

View File

@ -0,0 +1,234 @@
应用程序执行环境与平台支持
================================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节介绍了如何设计实现一个提供显示字符服务的用户态执行环境和裸机执行环境,以支持一个应用程序显示字符串。显示字符服务的裸机执行环境和用户态执行环境向下直接或间接与硬件关联,向上可通过函数库给应用提供 **显示字符** 的服务。这也说明了不管执行环境是简单还是复杂,设计实现上是否容易,它都体现了不同操作系统的共性特征--给应用需求提供服务。在某种程度上看,执行环境的软件主体就可称为是一种操作系统。
执行应用程序
-------------------------------
我们先在Linux上开发并运行一个简单的“Hello, world”应用程序看看一个简单应用程序从开发到执行的全过程。作为一切的开始让我们使用 Cargo 工具来创建一个 Rust 项目。它看上去没有任何特别之处:
.. code-block:: console
$ cargo new os --bin
我们加上了 ``--bin`` 选项来告诉 Cargo 我们创建一个可执行项目而不是库项目。此时,项目的文件结构如下:
.. code-block:: console
$ tree os
os
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
其中 ``Cargo.toml`` 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 ``src`` 目录下,目前为止只有 ``main.rs`` 一个文件,让我们看一下里面的内容:
.. code-block:: rust
:linenos:
:caption: 最简单的 Rust 应用
fn main() {
println!("Hello, world!");
}
进入 os 项目根目录下,利用 Cargo 工具即可一条命令实现构建并运行项目:
.. code-block:: console
$ cargo run
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 1.15s
Running `target/debug/os`
Hello, world!
如我们预想的一样,我们在屏幕上看到了一行 ``Hello, world!`` 。但是,需要注意到我们所享受到的编程和执行程序的方便性并不是理所当然的,背后有着从硬件到软件的多种机制的支持。特别是对于应用程序的运行,是需要有一个强大的执行环境来帮助。接下来,我们就要看看有操作系统加持的强大的执行环境。
应用程序执行环境
-------------------------------
如下图所示,现在通用操作系统(如 Linux 等)上的应用程序运行需要下面一套多层次的执行环境栈的支持:
.. _app-software-stack:
.. figure:: app-software-stack.png
:align: center
应用程序执行环境栈:图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级执行环境,黑色块则表示相邻两层执行环境之间的接口。
.. _term-execution-environment:
我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的功能强大的函数接口,使得仅需少量的源代码就能完成复杂的功能。但是这些库的功能不仅限于此,事实上它们属于应用程序的 **执行环境** (Execution Environment),在我们通常不会注意到的地方,它们还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印 ``Hello, world!`` 时使用的 ``println!`` 宏正是由 Rust 标准库 std 和 GNU Libc 库等提供的。
.. _term-system-call:
从内核/操作系统的角度看来,它上面的一切都属于用户态,而它自身属于内核态。无论用户态应用如何编写,是手写汇编代码,还是基于某种编程语言利用其标准库或三方库,某些功能总要直接或间接的通过内核/操作系统提供的 **系统调用** (System Call) 来实现。因此系统调用充当了用户和内核之间的边界。内核作为用户态的执行环境,它不仅要提供系统调用接口,还需要对用户态应用的执行进行监控和管理。
.. note::
**Hello, world! 用到了哪些系统调用?**
从之前的 ``cargo run`` 的输出可以看出之前构建的可执行文件是在 target/debug 目录下的 os 。在 Ubuntu 系统上,可以通过 ``strace`` 工具来运行一个程序并输出程序运行过程当中向内核请求的所有的系统调用及其返回值。我们只需输入 ``strace target/debug/os`` 即可看到一长串的各种系统调用。
其中,容易看出与 ``Hello, world!`` 应用实际执行相关的只有两个系统调用:
.. code-block::
[输出字符串]
write(1, "Hello, world!\n", 14) = 14
[程序退出执行]
exit_group(0)
其参数的具体含义我们暂且不在这里进行解释。
其余的系统调用基本上分别用于函数库和内核两层执行环境的初始化工作和对于上层的运行期监控和管理。之后,随着应用场景的复杂化,我们需要更强的抽象能力,也会实现这里面的一些系统调用。
.. _term-isa:
从硬件的角度来看,它上面的一切都属于软件。硬件可以分为三种: 处理器 (Processor) ——它更常见的名字是中央处理单元 (CPU, Central Processing Unit),内存 (Memory) 还有 I/O 设备。其中处理器无疑是其中最复杂同时也最关键的一个。它与软件约定一套 **指令集体系结构** (ISA, Instruction Set Architecture),使得软件可以通过 ISA 中提供的汇编指令来访问各种硬件资源。软件当然也需要知道处理器会如何执行这些指令:最简单的话就是一条一条执行位于内存中的指令。当然,实际的情况远比这个要复杂得多,为了适应现代应用程序的场景,处理器还需要提供很多额外的机制,而不仅仅是让数据在 CPU 寄存器、内存和 I/O 设备三者之间流动。
.. _term-abstraction:
.. note::
**多层执行环境都是必需的吗?**
除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和操作系统内核并不是必须存在的:
它们都是对下层资源进行了 **抽象** (Abstraction),并为上层提供了一套执行环境(也可理解为一些服务功能)。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。但抽象同时也是一种限制,会丧失一些应有的灵活性。比如,当你在考虑在项目中应该使用哪个函数库的时候,就常常需要这方面的权衡:过多的抽象和过少的抽象自然都是不合适的。理解应用的需求也很重要。一个能合理满足应用需求的操作系统设计是操作系统设计者需要深入考虑的问题。这也是一种权衡,过多的服务功能和过少的服务功能自然都是不合适的。
实际上,我们通过应用程序的特征和需求来判断操作系统需要什么程度的抽象和功能。
- 如果函数库和内核都不存在,那么我们就是在手写汇编代码,这种方式具有最高的灵活性,抽象能力则最低,基本等同于硬件。我们通常用这种方式来实现一些架构相关且仅通过编程语言无法描述的小模块或者代码片段。
- 如果仅存在函数库而不存在内核,意味着我们不需要内核提供的抽象。在嵌入式场景就常常会出现这种情况。嵌入式设备虽然也包含 CPU、内存和 I/O 设备,但是它上面通常只会同时运行一个或几个功能非常简单的小应用程序,其定位就是那种功能单一的场景,比如人脸识别打卡系统等。我们常用的操作系统如 Windows/Linux/macOS 等的抽象都支持同时运行很多应用程序,在嵌入式场景是过抽象或功能太多,用力过猛的。因此,常见的解决方案是仅使用函数库构建单独的应用程序或是用专为应用场景特别裁减过的轻量级内核管理少数应用程序。
.. note::
**“用力过猛”的现代操作系统**
对于如下更简单的小应用程序,我们可以看到“用力过猛”的现代操作系统提供的执行环境支持:
.. code-block:: rust
//ch1/donothing.rs
fn main() {
//do nothing
}
它只是想显示一下几乎感知不到的存在感。在编译后再运行,可以看到的情况是:
.. code-block:: console
$ rustc donothing.rs
$ ./donothing
$ (无输出)
$ strace ./donothing
(多达 93 行的输出,表明 donothing 向 Linux 操作系统内核发出了93次各种各样的系统调用)
execve("./donothing", ["./donothing"], 0x7ffe02c9ca10 /* 67 vars */) = 0
brk(NULL) = 0x563ba0532000
arch_prctl(0x3001 /* ARCH_??? */, 0x7fff2da54360) = -1 EINVAL (无效的参数)
......
平台与目标三元组
---------------------------------------
.. _term-platform:
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到可执行文件的时候需要知道程序要在哪个 **平台** (Platform) 上运行。这里 **平台** 主要是指CPU类型、操作系统类型和标准运行时库的组合。从上面给出的 :ref:`应用程序执行环境栈 <app-software-stack>` 可以看出:
- 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致;
- 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是 ISA 不同的话,对上提供的指令集和寄存器都不同。
它们都会导致最终生成的可执行文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种情况下,源代码是 **跨平台** 的。而另一些编译器则已经预设好了一个固定的目标平台。
.. _term-target-triplet:
我们可以通过 **目标三元组** (Target Triplet) 来描述一个目标平台。它一般包括 CPU 架构、CPU 厂商、操作系统和运行时库,它们确实都会控制可执行文件的生成。比如,我们可以尝试看一下之前的 ``Hello, world!`` 的目标平台是什么。这可以通过打印编译器 rustc 的默认配置信息:
.. code-block:: console
$ rustc --version --verbose
rustc 1.51.0-nightly (d1aed50ab 2021-01-26)
binary: rustc
commit-hash: d1aed50ab81df3140977c610c5a7d00f36dc519f
commit-date: 2021-01-26
host: x86_64-unknown-linux-gnu
release: 1.51.0-nightly
LLVM version: 11.0.1
从其中的 host 一项可以看出默认的目标平台是 ``x86_64-unknown-linux-gnu``,其中 CPU 架构是 x86_64CPU 厂商是 unknown操作系统是 linux运行时库是gnu libc封装了Linux系统调用并提供POSIX接口为主的函数库。这种无论编译器还是其生成的可执行文件都在我们当前所处的平台运行是一种最简单也最普遍的情况。但是很快我们就将遇到另外一种情况。
讲了这么多,终于该介绍我们的主线任务了。我们希望能够在另一个硬件平台上运行 ``Hello, world!``,而与之前的默认平台不同的地方在于,我们将 CPU 架构从 x86_64 换成 RISC-V。
.. note::
**为何基于 RISC-V 架构而非 x86 系列架构?**
x86 架构为了在升级换代的同时保持对基于旧版架构应用程序/内核的兼容性,存在大量的历史包袱,也就是一些对于目前的应用场景没有任何意义,但又必须花大量时间正确设置才能正常使用 CPU 的奇怪设定。为了建立并维护架构的应用生态,这确实是必不可少的,但站在教学的角度几乎完全是在浪费时间。而新生的 RISC-V 架构十分简洁,架构文档需要阅读的核心部分不足百页,且这些功能已经足以用来构造一个具有相当抽象能力的内核了。
可以看一下目前 Rust 编译器支持哪些基于 RISC-V 的平台:
.. code-block:: console
$ rustc --print target-list | grep riscv
riscv32gc-unknown-linux-gnu
riscv32i-unknown-none-elf
riscv32imac-unknown-none-elf
riscv32imc-unknown-none-elf
riscv64gc-unknown-linux-gnu
riscv64gc-unknown-none-elf
riscv64imac-unknown-none-elf
这里我们选择的是 ``riscv64gc-unknown-none-elf``,目标三元组中的 CPU 架构是 riscv64gc厂商是 unknown操作系统是 noneelf表示没有标准的运行时库表明没有任何系统调用的封装支持但可以生成ELF格式的执行程序。这里我们之所以不选择有
linux-gnu 系统调用支持的版本 ``riscv64gc-unknown-linux-gnu``,是因为我们只是想跑一个 ``Hello, world!``没有必要使用现在通用操作系统所提供的那么高级的抽象和多余的操作系统服务。而且我们很清楚后续我们要开发的是一个操作系统内核它必须直面底层物理硬件bare-metal来提供更大的操作系统服务功能已有操作系统如Linux提供的系统调用服务对这个内核而言是多余的。
.. note::
**RISC-V 指令集拓展**
由于基于 RISC-V 架构的处理器可能用于嵌入式场景或是通用计算场景,因此指令集规范将指令集划分为最基本的 RV32/64I 以及若干标准指令集拓展。每款处理器只需按照其实际应用场景按需实现指令集拓展即可。
- RV32/64I每款处理器都必须实现的基本整数指令集。在 RV32I 中,每个通用寄存器的位宽为 32 位;在 RV64I 中则为 64 位。它可以用来模拟绝大多数标准指令集拓展中的指令,除了比较特殊的 A 拓展,因为它需要特别的硬件支持。
- M 拓展:提供整数乘除法相关指令。
- A 拓展:提供原子指令和一些相关的内存同步机制,这个后面会展开。
- F/D 拓展:提供单/双精度浮点数运算支持。
- C 拓展:提供压缩指令拓展。
G 拓展是基本整数指令集 I 再加上标准指令集拓展 MAFD 的总称,因此 riscv64gc 也就等同于 riscv64imafdc。我们剩下的内容都基于该处理器架构完成。除此之外 RISC-V 架构还有很多标准指令集拓展,有一些还在持续更新中尚未稳定,有兴趣的读者可以浏览最新版的 RISC-V 指令集规范。
Rust 标准库与核心库
----------------------------------
我们尝试一下将当前的 ``Hello, world!`` 程序的目标平台换成 riscv64gc-unknown-none-elf 看看会发生什么事情:
.. code-block:: console
$ cargo run --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error[E0463]: can't find crate for `std`
|
= note: the `riscv64gc-unknown-none-elf` target may not be installed
.. _term-bare-metal:
在之前的开发环境配置中,我们已经在 rustup 工具链中安装了这个目标平台支持因此并不是该目标平台未安装的问题。这个问题只是单纯的表示在这个目标平台上找不到Rust 标准库 std。我们之前曾经提到过编程语言的标准库或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在任何操作系统支持于是 Rust 并没有为这个目标平台支持完整的标准库 std。类似这样的平台通常被我们称为 **裸机平台** (bare-metal)。
.. note::
**Rust语言标准库**
Rust 语言标准库是让 Rust 语言开发的软件具备可移植性的基础,类似于 C 语言的 LibC 标准库。它是一组最小的、经过实战检验的共享抽象,适用于更广泛的 Rust 生态系统开发。它提供了核心类型,如 Vec 和 Option、类库定义的语言原语操作、标准宏、I/O 和多线程等。默认情况下,所有 Rust crate 都可以使用 std 来支持 Rust 应用程序的开发。但 Rust 语言标准库的一个限制是,它需要有操作系统的支持。所以,如果你要实现的软件是运行在裸机上的操作系统,就不能直接用 Rust 语言标准库了。
幸运的是Rust 有一个对 std 裁剪过后的核心库 core这个库是不需要任何操作系统支持的相对的它的功能也比较受限但是也包含了 Rust 语言相当一部分的核心机制可以满足我们的大部分需求。Rust 语言是一种面向系统(包括操作系统)开发的语言,所以在 Rust 语言生态中,有很多三方库也不依赖标准库 std 而仅仅依赖核心库 core。对它们的使用可以很大程度上减轻我们的编程负担。它们是我们能够在裸机平台挣扎求生的最主要倚仗也是大部分运行在没有操作系统支持的 Rust 嵌入式软件的必备。
于是,我们知道在裸机平台上我们要将对于标准库 std 的引用换成核心库 core。但是做起来其实还要有一些琐碎的事情需要解决。

View File

@ -0,0 +1,177 @@
.. _term-remove-std:
移除标准库依赖
==========================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
为了很好地理解一个简单应用所需的服务如何体现,本节将尝试开始构造一个小的执行环境,可建立在 Linux 之上,也可直接建立在裸机之上,我们称为“三叶虫”操作系统。作为第一步,本节将尝试移除之前的 ``Hello world!`` 程序对于 Rust std 标准库的依赖,使得它能够编译到裸机平台 RV64GC 或 Linux-RV64 上。
移除 println! 宏
----------------------------------
我们首先在 ``os`` 目录下新建 ``.cargo`` 目录,并在这个目录下创建 ``config`` 文件,并在里面输入如下内容:
.. code-block:: toml
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
.. _term-cross-compile:
这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。事实上这是一种编译器运行所在的平台与编译器生成可执行文件的目标平台不同分别是后者和前者的情况。这是一种 **交叉编译** (Cross Compile)。
..
chyyuu解释一下交叉编译
当然,这只是使得我们之后在 ``cargo build`` 的时候不必再加上 ``--target`` 参数的一个小 trick。如果我们现在 ``cargo build`` ,还是会和上一小节一样出现找不到标准库 std 的错误。于是我们开始着手移除标准库,当然,这会产生一些副作用。
我们在 ``main.rs`` 的开头加上一行 ``#![no_std]`` 来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core。编译器报出如下错误
.. error::
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
我们之前提到过, println! 宏是由标准库 std 提供的,且会使用到一个名为 write 的系统调用。现在我们的代码功能还不足以自己实现一个 println! 宏。由于使用了系统调用也不能在核心库 core 中找到它,所以我们目前先通过将它注释掉来绕过它。
提供语义项 panic_handler
----------------------------------------------------
.. error::
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: `#[panic_handler]` function required, but not found
在使用 Rust 编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误导致程序无法继续向下运行的时候手动或自动调用 panic! 宏来并打印出错的位置让我们能够意识到它的存在并进行一些后续处理。panic! 宏最典型的应用场景包括断言宏 assert! 失败或者对 ``Option::None/Result::Err`` 进行 ``unwrap`` 操作。
在标准库 std 中提供了 panic 的处理函数 ``#[panic_handler]``,其大致功能是打印出错位置和原因并杀死当前应用。可惜的是在核心库 core 中并没有提供,因此我们需要自己实现 panic 处理函数。
.. note::
**Rust 语法卡片:语义项 lang_items**
Rust 编译器内部的某些功能的实现并不是硬编码在语言内部的,而是以一种可插入的形式在库中提供。库只需要通过某种方式告诉编译器它的某个方法实现了编译器内部的哪些功能,编译器就会采用库提供的方法来实现它内部对应的功能。通常只需要在库的方法前面加上一个标记即可。
我们开一个新的子模块 ``lang_items.rs`` 保存这些语义项,在里面提供 panic 处理函数的实现并通过标记通知编译器采用我们的实现:
.. code-block:: rust
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
注意panic 处理函数的函数签名需要一个 ``PanicInfo`` 的不可变借用作为输入参数,它在核心库中得以保留,这也是我们第一次与核心库打交道。之后我们会从 ``PanicInfo`` 解析出错位置并打印出来,然后杀死应用程序。但目前我们什么都不做只是在原地 ``loop``
移除 main 函数
-----------------------------
.. error::
.. code-block::
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: requires `start` lang_item
编译器提醒我们缺少一个名为 ``start`` 的语义项。我们回忆一下,之前提到语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 ``main`` 函数)开始执行。事实上 ``start`` 语义项正代表着标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
最简单的解决方案就是压根不让编译器使用这项功能。我们在 ``main.rs`` 的开头加入设置 ``#![no_main]`` 告诉编译器我们没有一般意义上的 ``main`` 函数,并将原来的 ``main`` 函数删除。在失去了 ``main`` 函数的情况下,编译器也就不需要完成所谓的初始化工作了。
至此,我们成功移除了标准库的依赖并完成裸机平台上的构建。
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
目前的代码如下:
.. code-block:: rust
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_items;
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
本小节我们固然脱离了标准库,通过了编译器的检验,但也是伤筋动骨,将原有的很多功能弱化甚至直接删除,看起来距离在 RV64GC 平台上打印 ``Hello world!`` 相去甚远了(我们甚至连 println! 和 ``main`` 函数都删除了)。不要着急,接下来我们会以自己的方式来重塑这些基本功能,并最终完成我们的目标。
分析被移除标准库的程序
-----------------------------
对于上面这个被移除标准库的应用程序,通过了编译器的检查和编译,形成了二进制代码。但这个二进制代码是怎样的,它能否被正常执行呢?我们可以通过一些工具来分析一下。
.. code-block:: console
[文件格式]
$ file target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ......
[文件头信息]
$ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os
File: target/riscv64gc-unknown-none-elf/debug/os
Format: elf64-littleriscv
Arch: riscv64
AddressSize: 64bit
......
Type: Executable (0x2)
Machine: EM_RISCV (0xF3)
Version: 1
Entry: 0x0
......
}
[反汇编导出汇编程序]
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它好像是一个合法的 RV64 执行程序,但通过 ``rust-readobj`` 工具进一步分析,发现它的入口地址 Entry 是 ``0`` ,这就比较奇怪了,地址从 0 执行,好像不对。再通过 ``rust-objdump`` 工具把它反汇编,可以看到没有生成汇编代码。所以,我们可以断定,这个二进制程序虽然合法,但它是一个空程序。这不是我们希望的,我们希望有具体内容的执行程序。为什么会这样呢?原因是我们缺少了编译器需要找到的入口函数 ``_start``
在下面几节,我们将建立有支持显示字符串的最小执行环境。
.. note::
**在 x86_64 平台上移除标准库依赖**
有兴趣的同学可以将目标平台换回之前默认的 ``x86_64-unknown-linux-gnu`` 并重复本小节所做的事情,比较两个平台从 ISA 到操作系统
的差异。可以参考 `BlogOS 的相关内容 <https://os.phil-opp.com/freestanding-rust-binary/>`_
.. note::
本节内容部分参考自 `BlogOS 的相关章节 <https://os.phil-opp.com/freestanding-rust-binary/>`_

View File

@ -0,0 +1,299 @@
.. _term-print-userminienv:
构建用户态执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中执行环境的功能。
在这一小节,我们介绍如何进行 **执行环境初始化**
在这里,我们先设计实现一个最小执行环境以支持最简单的用户态 ``Hello, world!`` 程序,再改进这个最小执行环境,支持对裸机应用程序。这样设计实现的原因是,
它能帮助我们理解这两个不同的执行环境在支持同样一个应用程序时的的相同和不同之处这将加深对执行环境的理解并对后续写自己的OS和运行在OS上的应用程序都有帮助。
所以,本节将先建立一个用户态的最小执行环境,即 **恐龙虾** 操作系统。
用户态最小化执行环境
----------------------------
在上一节,我们构造的二进制程序是一个空程序,其原因是 Rust 编译器找不到执行环境的入口函数,于是就没有生产后续的代码。所以,我们首先要把入口函数
找到。通过查找资料发现Rust编译器要找的入口函数是 ``_start()`` ,于是我们可以在 ``main.rs`` 中添加如下内容:
.. code-block:: rust
// os/src/main.rs
#[no_mangle]
extern "C" fn _start() {
loop{};
}
对上述代码重新编译,再用分析工具分析,可以看到:
.. code-block:: console
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
[文件格式]
$ file target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ......
[文件头信息]
$ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os
File: target/riscv64gc-unknown-none-elf/debug/os
Format: elf64-littleriscv
Arch: riscv64
AddressSize: 64bit
......
Type: Executable (0x2)
Machine: EM_RISCV (0xF3)
Version: 1
Entry: 0x11120
......
}
[反汇编导出汇编程序]
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
Disassembly of section .text:
0000000000011120 <_start>:
; loop {}
11120: 09 a0 j 2 <_start+0x2>
11122: 01 a0 j 0 <_start+0x2>
通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它依然是一个合法的 RV64 执行程序,但通过 ``rust-readobj`` 工具进一步分析,发现它的入口地址 Entry 是 ``0x11120`` ,这好像是一个合法的地址。再通过 ``rust-objdump`` 工具把它反汇编,可以看到生成汇编代码!
所以,我们可以断定,这个二进制程序虽然合法,但它是一个空程序。这不是我们希望的,我们希望有具体内容的执行程序。为什么会这样呢?
仔细读读这两条指令,发现就是一个死循环的汇编代码,且其第一条指令的地址与入口地址 Entry 的值一致。这已经是一个合理的程序了。如果我们用 ``qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os`` 执行这个程序,可以看到好像就是在执行死循环。
我们能让程序正常退出吗?我们把 ``_start()`` 函数中的循环语句注释掉,重新编译并分析,看到其汇编代码是:
.. code-block:: console
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
Disassembly of section .text:
0000000000011120 <_start>:
; }
11120: 82 80 ret
看起来是有内容(具有 ``ret`` 函数返回汇编指令)且合法的执行程序。但如果我们执行它,就发现有问题了:
.. code-block:: console
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os
段错误 (核心已转储)
*段错误 (核心已转储)* 是常见的一种应用程序出错,而我们这个非常简单的应用程序导致了 Linux 环境模拟程序 ``qemu-riscv64`` 崩溃了!为什么会这样?
.. _term-qemu-riscv64:
.. note::
QEMU有两种运行模式 ``User mode`` 模式,即用户态模拟,如 ``qemu-riscv64`` 程序能够模拟不同处理器的用户态指令的执行并可以直接解析ELF可执行文件加载运行那些为不同处理器编译的用户级Linux应用程序ELF可执行文件在翻译并执行不同应用程序中的不同处理器的指令时如果碰到是系统调用相关的汇编指令它会把不同处理器如RISC-V的Linux系统调用转换为本机处理器如x86-64上的Linux系统调用这样就可以让本机Linux完成系统调用并返回结果再转换成RISC-V能识别的数据给这些应用。 ``System mode`` 模式,即系统态模式,如 ``qemu-system-riscv64`` 程序能够模拟一个完整的基于不同CPU的硬件系统包括处理器、内存及其他外部设备支持运行完整的操作系统。
回顾一下最开始的输出 ``Hello, world!`` 的简单应用程序,其入口函数名字是 ``main`` ,编译时用的是标准库 std 。它可以正常执行。再仔细想想,当一个应用程序出错的时候,最上层为操作系统的执行环境会把它给杀死。但如果一个应用的入口函数正常返回,执行环境应该优雅地让它退出才对。没错!目前的执行环境还缺了一个退出机制。
先了解一下,操作系统会提供一个退出的系统调用服务接口,但应用程序调用这个接口,那这个程序就退出了。这里先给出代码:
.. _term-llvm-syscall:
.. code-block:: rust
// os/src/main.rs
#![feature(llvm_asm)]
const SYSCALL_EXIT: usize = 93;
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
llvm_asm!("ecall"
: "={x10}" (ret)
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
: "memory"
: "volatile"
);
}
ret
}
pub fn sys_exit(xstate: i32) -> isize {
syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
}
#[no_mangle]
extern "C" fn _start() {
sys_exit(9);
}
``main.rs`` 增加的内容不多,但还是有点与一般的应用程序有所不同,因为它引入了汇编和系统调用。如果你看不懂上面内容的细节,没关系,在第二章的第二节 :doc:`/chapter2/2application` 会有详细的介绍。这里只需知道 ``_start`` 函数调用了一个 ``sys_exit`` 函数来向操作系统发出一个退出服务的系统调用请求并传递给OS的退出码为 ``9``
我们编译执行以下修改后的程序:
.. code-block:: console
$ cargo build --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.26s
[$?表示执行程序的退出码,它会被告知 OS]
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $?
9
可以看到,返回的结果确实是 ``9`` 。这样,我们在没有任何显示功能的情况下,勉强完成了一个简陋的用户态最小化执行环境。
上面实现的最小化执行环境貌似能够在 Linux 操作系统上支持只调用一个 ``SYSCALL_EXIT`` 系统调用服务的程序,但这也说明了
在操作系统的支持下,实现一个基本的用户态执行环境还是比较容易的。其中的原因是,操作系统帮助用户态执行环境完成了程序加载、程序退出、资源分配、资源回收等各种琐事。如果没有操作系统,那么实现一个支持在裸机上运行应用程序的执行环境,就要考虑更多的事情了,或者干脆简化一切可以不必干的事情(比如对于单个应用,不需要调度功能等)。
在裸机上的执行环境,其实就是之前提到的“三叶虫”操作系统。
有显示支持的用户态执行环境
----------------------------
没有显示功能,终究觉得缺了点啥。在没有通常开发应用程序时常用的动态调试工具的情况下,其实能显示字符串,就已经能够满足绝大多数情况下的调试需求了。
Rust 的 core 库内建了以一系列帮助实现显示字符的基本 Trait 和数据结构,函数等,我们可以对其中的关键部分进行扩展,就可以实现定制的 ``println!`` 功能。
实现输出字符串的相关函数
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先封装一下对 ``SYSCALL_WRITE`` 系统调用。这个是 Linux 操作系统内核提供的系统调用,其 ``ID`` 就是 ``SYSCALL_WRITE``
.. code-block:: rust
const SYSCALL_WRITE: usize = 64;
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
然后实现基于 ``Write`` Trait 的数据结构,并完成 ``Write`` Trait 所需要的 ``write_str`` 函数,并用 ``print`` 函数进行包装。
.. code-block:: rust
struct Stdout;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
sys_write(1, s.as_bytes());
Ok(())
}
}
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
最后,实现基于 ``print`` 函数实现Rust语言 **格式化宏** ( `formatting macros <https://doc.rust-lang.org/std/fmt/#related-macros>`_ )。
.. code-block:: rust
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}
上面的代码没有读懂?没关系,你只要了解到应用程序发出的宏调用 ``println!`` 就是通过上面的实现,一步一步地调用,最终通过操作系统提供的 ``SYSCALL_WRITE`` 系统调用服务,帮助我们完成了字符串显示输出。这就完成了有显示支持的用户态执行环境。
接下来,我们调整一下应用程序,让它发出显示字符串和退出的请求:
.. code-block:: rust
#[no_mangle]
extern "C" fn _start() {
println!("Hello, world!");
sys_exit(9);
}
整体工作完成!当然,我们实现的很简陋,用户态执行环境和应用程序都放在一个文件里面,以后会通过我们学习的软件工程的知识,进行软件重构,让代码更清晰和模块化。
现在,我们编译并执行一下,可以看到正确的字符串输出,且程序也能正确结束!
.. code-block:: console
$ cargo build --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $?
Hello, world!
9
.. 下面出错的情况是会在采用 linker.ld加入了 .cargo/config
.. 的内容后会出错:
.. .. [build]
.. .. target = "riscv64gc-unknown-none-elf"
.. .. [target.riscv64gc-unknown-none-elf]
.. .. rustflags = [
.. .. "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
.. .. ]
.. 重新定义了栈和地址空间布局后才会出错
.. 段错误 (核心已转储)
.. 系统崩溃了借助以往的操作系统内核编程经验和与下一节调试kernel的成果经验我们直接定位为是 **栈** (Stack) 没有设置的问题。我们需要添加建立栈的代码逻辑。
.. .. code-block:: asm
.. # entry.asm
.. .section .text.entry
.. .globl _start
.. _start:
.. la sp, boot_stack_top
.. call rust_main
.. .section .bss.stack
.. .globl boot_stack
.. boot_stack:
.. .space 4096 * 16
.. .globl boot_stack_top
.. boot_stack_top:
.. 然后把汇编代码嵌入到 ``main.rs`` 中,并进行微调。
.. .. code-block:: rust
.. #![feature(global_asm)]
.. global_asm!(include_str!("entry.asm"));
.. #[no_mangle]
.. #[link_section=".text.entry"]
.. extern "C" fn rust_main() {
.. 再次编译执行,可以看到正确的字符串输出,且程序也能正确结束!

View File

@ -0,0 +1,561 @@
.. _term-print-kernelminienv:
构建裸机运行时执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节开始我们将着手自己来实现裸机上的最小执行环境,即我们的“三叶虫”操作系统,并能在裸机上运行 ``Hello, world!`` 程序。
有了上一节实现的用户态的最小执行环境,我们可以稍加改造,就可以完成裸机上的最小执行环境了。与上节不同,需要关注地方主要是:
- 物理内存的 DRAM 位置(放应用程序的地方)和应用程序的内存布局(如何在 DRAM 中放置应用程序的各个部分)
- SBI 的字符输出接口(执行环境提供的输出字符服务,可以被应用程序使用)
- 应用程序的初始化(起始的指令位置,对 ``栈 stack````bss`` 的初始化)
了解硬件组成和裸机启动过程
----------------------------
在这一小节,我们介绍如何进行 **执行环境初始化** 。我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。
``Hello, world!`` 程序为例,在目前广泛使用的操作系统上,它就至少需要经历以下层层递进的初始化过程:
- 启动OS硬件启动后会有一段代码一般统称为bootloader对硬件进行初始化让包括内核在内的系统软件得以运行
- OS准备好应用程序执行的环境要运行该应用程序的时候内核分配相应资源将程序代码和数据载入内存并赋予 CPU 使用权,由此应用程序可以运行;
- 应用程序开始执行:程序员编写的代码是应用程序的一部分,它需要标准库/核心库进行一些初始化工作后才能运行。
不过我们的目标是实现在裸机上执行的应用。由于目标平台 ``riscv64gc-unknown-none-elf`` 没有任何操作系统支持,我们只能禁用标准库并移除默认的 main 函数
入口。但是最终我们还是要将 main 函数恢复回来并且输出 ``Hello, world!`` 的。因此,我们需要知道具体需要做哪些初始化工作才能支持
应用程序在裸机上的运行。
而这又需要明确三点:首先,应用程序的裸机硬件系统是啥样子的?其次,系统在做这些初始化工作之前处于什么状态;最后,在做完初始化工作也就是即将执行 main 函数之前又处于什么状态。比较二者
即可得出答案。
硬件组成
^^^^^^^^^^^^^^^^^^^^^^
我们采用的是QEMU软件 ``qemu-system-riscv64`` 来模拟一台RISC-V 64计算机具体的硬件规格是
- 外设16550A UARTvirtio-net/block/console/gpu等和设备树
- 硬件特权级priv v1.10 user v2.2
- 中断控制器可参数化的CLINT核心本地中断器、可参数化的PLIC平台级中断控制器
- 可参数化的RAM内存
- 可配置的多核 RV64GC M/S/U mode CPU
这里列出的硬件功能很多还用不上,不过在后面的章节中会逐步用到上面的硬件功能,以支持更加强大的操作系统能力。
在QEMU模拟的硬件中物理内存和外设都是通过对内存读写的方式来进行访问下面列出了QEMU模拟的物理内存空间。
.. code-block:: c
// qemu/hw/riscv/virt.c
static const struct MemmapEntry {
hwaddr base;
hwaddr size;
} virt_memmap[] = {
[VIRT_DEBUG] = { 0x0, 0x100 },
[VIRT_MROM] = { 0x1000, 0xf000 },
[VIRT_TEST] = { 0x100000, 0x1000 },
[VIRT_RTC] = { 0x101000, 0x1000 },
[VIRT_CLINT] = { 0x2000000, 0x10000 },
[VIRT_PCIE_PIO] = { 0x3000000, 0x10000 },
[VIRT_PLIC] = { 0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) },
[VIRT_UART0] = { 0x10000000, 0x100 },
[VIRT_VIRTIO] = { 0x10001000, 0x1000 },
[VIRT_FLASH] = { 0x20000000, 0x4000000 },
[VIRT_PCIE_ECAM] = { 0x30000000, 0x10000000 },
[VIRT_PCIE_MMIO] = { 0x40000000, 0x40000000 },
[VIRT_DRAM] = { 0x80000000, 0x0 },
};
到现在为止,其中比较重要的两个是:
- VIRT_DRAMDRAM的内存起始地址是 ``0x80000000`` 缺省大小为128MB。在本书中一般限制为8MB。
- VIRT_UART0串口相关的寄存器起始地址是 ``0x10000000`` ,范围是 ``0x100`` ,我们通过访问这段特殊的区域来实现字符输入输出的管理与控制。
.. _term-bootloader:
裸机启动过程
^^^^^^^^^^^^^^^^^^
.. note::
**QEMU 模拟 CPU 加电的执行过程**
CPU加电后的执行细节与具体硬件相关我们这里以QEMU模拟器为具体例子简单介绍一下。
这需要从 CPU 加电后如何初始化如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言它模拟了一台标准的RISC-V64计算机。我们启动QEMU时可设置一些参数在RISC-V64计算机启动执行前先在其模拟的内存中放置好BootLoader程序和操作系统的二进制代码。这可以通过查看 ``os/Makefile`` 文件中包含 ``qemu-system-riscv64`` 的相关内容来了解。
- ``-bios $(BOOTLOADER)`` 这个参数意味着硬件内存中的固定位置 ``0x80000000`` 处放置了一个BootLoader程序--RustSBI:doc:`../appendix-c/index` 可以进一步了解RustSBI。
- ``-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)`` 这个参数表示硬件内存中的特定位置 ``$(KERNEL_ENTRY_PA)`` 放置了操作系统的二进制代码 ``$(KERNEL_BIN)````$(KERNEL_ENTRY_PA)`` 的值是 ``0x80200000``
当我们执行包含上次参数的qemu-system-riscv64软件就意味给这台虚拟的RISC-V64计算机加电了。此时CPU的其它通用寄存器清零
而PC寄存器会指向 ``0x1000`` 的位置。
这个 ``0x1000`` 位置上是CPU加电后执行的第一条指令固化在硬件中的一小段引导代码它会很快跳转到 ``0x80000000`` 处,
即RustSBI的第一条指令。RustSBI完成基本的硬件初始化后
会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80200000`` ,执行操作系统的第一条指令。
这时我们的编写的操作系统才开始正式工作。
为啥在 ``0x80000000`` 放置 ``Bootloader`` 因为这是QEMU的硬件模拟代码中设定好的 ``Bootloader`` 的起始地址。
为啥在 ``0x80200000`` 放置 ``os`` ?因为这是 ``Bootloader--RustSBI`` 的代码中设定好的 ``os`` 的起始地址。
.. note::
**操作系统与SBI之间是啥关系**
SBI是RISC-V的一种底层规范操作系统内核与实现SBI规范的RustSBI的关系有点象应用与操作系统内核的关系后者向前者提供一定的服务。只是SBI提供的服务很少
能帮助操作系统内核完成的功能有限但这些功能很底层很重要比如关机显示字符串等。通过操作系统内核也能直接实现但比较繁琐如果RustSBI提供了服务
那么操作系统内核直接调用就好了。
.. warning::
**FIXME: 提供一下分析展示**
实现关机功能
----------------------------
如果在裸机上的应用程序执行完毕并通知操作系统后那么“三叶虫”操作系统就没事干了实现正常关机是一个合理的选择。所以我们要让“三叶虫”操作系统能够正常关机这是需要调用SBI提供的关机功能 ``SBI_SHUTDOWN`` ,这与上一节的 ``SYSCALL_EXIT`` 类似,
只是在具体参数上有所不同。在上一节完成的没有显示功能的用户态最小化执行环境基础上,修改后的代码如下:
.. _term-llvm-sbicall:
.. code-block:: rust
// bootloader/rustsbi-qemu.bin 直接添加的SBI规范实现的二进制代码给操作系统提供基本支持服务
// os/src/sbi.rs
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
let mut ret;
unsafe {
llvm_asm!("ecall"
: "={x10}" (ret)
: "{x10}" (arg0), "{x11}" (arg1), "{x12}" (arg2), "{x17}" (which)
...
// os/src/main.rs
const SBI_SHUTDOWN: usize = 8;
pub fn shutdown() -> ! {
sbi_call(SBI_SHUTDOWN, 0, 0, 0);
panic!("It should shutdown!");
}
#[no_mangle]
extern "C" fn _start() {
shutdown();
}
也许有同学比较迷惑,应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问
RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall``
这其实是没有问题的虽然指令一样但它们所在的特权级和特权级转换是不一样的。简单地说应用程序位于最弱的用户特权级User Mode操作系统位于
很强大的内核特权级Supervisor ModeRustSBI位于完全掌控机器的机器特权级Machine Mode通过 ``ecall`` 指令,可以完成从弱的特权级
到强的特权级的转换。具体细节可以看下一章的进一步描述。在这里只要知道如果“三叶虫”操作系统正确地向RustSBI发出了停机的SBI服务请求
那么RustSBI能够通知QEMU模拟的RISC-V计算机停机``qemu-system-riscv64`` 软件能正常退出)就行了。
下面是编译执行,结果如下:
.. code-block:: console
# 编译生成ELF格式的执行文件
$ cargo build --release
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
Finished release [optimized] target(s) in 0.15s
# 把ELF执行文件转成bianary文件
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin
#加载运行
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
# 无法退出,风扇狂转,感觉碰到死循环
这样的结果是我们不期望的。问题在哪?仔细查看和思考,操作系统的入口地址不对!对 ``os`` ELF执行程序通过rust-readobj分析看到的入口地址不是
RustSBIS约定的 ``0x80200000`` 。我们需要修改 ``os`` ELF执行程序的内存布局。
设置正确的程序内存布局
----------------------------
.. _term-linker-script:
我们可以通过 **链接脚本** (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 ``os/src/linker.ld`` 而非使用默认的内存布局:
.. code-block::
:linenos:
:emphasize-lines: 5,6,7,8
// os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
具体的链接脚本 ``os/src/linker.ld`` 如下:
.. code-block::
:linenos:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``
第 3 行定义了一个常量 ``BASE_ADDRESS````0x80200000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件
中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够
看到这样的格式:
.. code-block::
.rodata : {
*(.rodata)
}
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为
``<ObjectFile>(SectionName)``,表示目标文件 ``ObjectFile`` 的名为 ``SectionName`` 的段需要被放进去。我们也可以
使用通配符来书写 ``<ObjectFile>````<SectionName>`` 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件
中各个常见的段 ``.text, .rodata .data, .bss`` 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,
且每个段都有两个全局符号给出了它的开始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext````etext`` )。
为了说明当前实现的正确性,我们需要讨论这样一个问题:
1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80200000`` 开头的区域上?
在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80200000`` ,然后从这里开始往高地址放置各个段。第一个被放置的
``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码,
它在所有段中最早被放置在我们期望的 ``0x80200000`` 处。
这样一来,我们就将运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到
最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os``
通过分析,我们看到 ``0x80200000`` 处的代码是我们预期的 ``_start()`` 函数的内容。我们采用刚才的编译运行方式进行试验,发现还是同样的错误结果。
问题出在哪里?这时需要用上 ``debug`` 大法了。
.. code-block:: console
# 在一个终端执行如下命令:
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 -S -s
# 在另外一个终端执行如下命令:
$ rust-gdb target/riscv64gc-unknown-none-elf/release/os
(gdb) target remote :1234
(gdb) break *0x80200000
(gdb) x /16i 0x80200000
(gdb) si
结果发现刚执行一条指令,整个系统就飞了( ``pc`` 寄存器等已经变成为 ``0`` 了)。再一看, ``sp`` 寄存器是一个非常大的值 ``0xffffff...`` 。这就很清楚是
**栈 stack** 出现了问题。我们没有设置好 **栈 stack** 好吧,我们需要考虑如何合理设置 **栈 stack**
正确配置栈空间布局
----------------------------
为了说明如何实现正确的栈,我们需要讨论这样一个问题:应用函数调用所需的栈放在哪里?
需要有一段代码来分配并栈空间,并把 ``sp`` 寄存器指向栈空间的起始位置(注意:栈空间是从上向下 ``push`` 数据的)。
所以,我们要写一小段汇编代码 ``entry.asm`` 来帮助建立好栈空间。
从链接脚本第 32 行开始,我们可以看出 ``entry.asm`` 中分配的栈空间对应的段 ``.bss.stack`` 被放入到可执行文件中的
``.bss`` 段中的低地址中。在后面虽然有一个通配符 ``.bss.*`` ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。
这里需要注意的是地址区间 :math:`[\text{sbss},\text{ebss})` 并不包括栈空间,其原因后面再进行说明。
我们自己编写运行时初始化的代码:
.. code-block:: asm
:linenos:
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 :math:`64\text{KiB}` 的空间用作接下来要运行的程序的栈空间,
这块栈空间的栈顶地址被全局符号 ``boot_stack_top`` 标识,栈底则被全局符号 ``boot_stack`` 标识。同时,这块栈空间单独作为一个名为
``.bss.stack`` 的段,之后我们会通过链接脚本来安排它的位置。
从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数
调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 ``boot_stack`` ,也就是栈溢出的情形,虽然这有
可能导致严重的错误。第二条指令则是通过伪指令 ``call`` 函数调用 ``rust_main`` ,这里的 ``rust_main`` 是一个我们稍后自己编写的应用
入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp随后跳转到应用入口即可。这两条指令单独作为一个名为
``.text.entry`` 的段,且全局符号 ``_start`` 给出了段内第一条指令的地址。
接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main``
.. code-block:: rust
:linenos:
:emphasize-lines: 4,8,10,11,12,13
// os/src/main.rs
#![no_std]
#![no_main]
#![feature(global_asm)]
mod lang_items;
global_asm!(include_str!("entry.asm"));
#[no_mangle]
pub fn rust_main() -> ! {
loop {}
}
背景高亮指出了 ``main.rs`` 中新增的代码。
第 4 行中,我们手动设置 ``global_asm`` 特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过
``include_str!`` 宏将同目录下的汇编代码 ``entry.asm`` 转化为字符串并通过 ``global_asm!`` 宏嵌入到代码中。
从第 10 行开始,
我们声明了应用的入口点 ``rust_main`` ,这里需要注意的是需要通过宏将 ``rust_main`` 标记为 ``#[no_mangle]`` 以避免编译器对它的
名字进行混淆,不然的话在链接的时候, ``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main`` 从而导致链接失败。
这样一来我们就将“三叶虫”操作系统编写完毕了。再次使用上节中的编译生成和运行操作我们看到QEMU模拟的RISC-V 64计算机 **优雅** 地退出了!
.. code-block:: console
$ qemu-system-riscv64 \
> -machine virt \
> -nographic \
> -bios ../bootloader/rustsbi-qemu.bin \
> -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi] Kernel entry: 0x80200000
# “优雅”地退出了。
清空 .bss 段
----------------------------------
与内存相关的部分太容易出错了。所以,我们再仔细检查代码后,发现在嵌入式系统中常见的 **清零 .bss段** 的工作并没有完成。
由于一般应用程序的 ``.bss`` 段在程序正式开始运行之前会被执环境(系统库或操作系统内核)固定初始化为零,因此在 ELF 文件中,为了节省磁盘空间,只会记录 ``.bss`` 段的位置,且应用程序的假定在它执行前,其 ``.bss段`` 的数据内容都已是 ``全0``
如果这块区域不是全零且执行环境也没提前清零那么会与应用的假定矛盾导致程序出错。对于在裸机上执行的应用程序其执行环境就是QEMU模拟硬件+“三叶虫”操作系统内核)将可执行文件加载到内存的时候,并负责将 ``.bss`` 所分配到的内存区域全部清零。
落实到我们正在实现的“三叶虫”操作系统内核,我们需要提供清零的 ``clear_bss()`` 函数。此函数属于执行环境,并在执行环境调用
应用程序的 ``rust_main`` 主函数前,把 ``.bss`` 段的全局数据清零。
.. code-block:: rust
:linenos:
// os/src/main.rs
fn clear_bss() {
extern "C" {
fn sbss();
fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) }
});
}
在程序内自己进行清零的时候,我们就不用去解析 ELF此时也没有 ELF 可供解析)了,而是通过链接脚本 ``linker.ld`` 中给出的全局符号
``sbss````ebss`` 来确定 ``.bss`` 段的位置。
我们可以松一口气了。接下来我们要让“三叶虫”操作系统要实现“Hello, world”输出
添加裸机打印相关函数
----------------------------------
与上一节为输出字符实现的代码片段相比,裸机应用的执行环境支持字符输出的代码改动会很小。
下面的代码基于上节有打印能力的执行环境的基础上做的变动。
.. code-block:: rust
const SBI_CONSOLE_PUTCHAR: usize = 1;
pub fn console_putchar(c: usize) {
syscall(SBI_CONSOLE_PUTCHAR, [c, 0, 0]);
}
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
//sys_write(STDOUT, s.as_bytes());
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}
可以看到主要就只是把之前的操作系统系统调用改为了SBI调用。然后我们再编译运行试试
.. code-block:: console
$ cargo build
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/debug/os --strip-all -O binary target/riscv64gc-unknown-none-elf/debug/os.bin
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/debug/os.bin,addr=0x80200000
[rustsbi] Version 0.1.0
.______       __    __      _______.___________.  _______..______   __
|   _  \     |  |  |  |    /       |           | /       ||   _  \ |  |
|  |_)  |    |  |  |  |   |   (----`---|  |----`|   (----`|  |_)  ||  |
|      /     |  |  |  |    \   \       |  |      \   \    |   _  < |  |
|  |\  \----.|  `--'  |.----)   |      |  |  .----)   |   |  |_)  ||  |
| _| `._____| \______/ |_______/       |__|  |_______/    |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi] Kernel entry: 0x80200000
Hello, world!
可以看到,在裸机上输出了 ``Hello, world!`` 而且qemu正常退出表示RISC-V计算机也正常关机了。
接着我们可提高“三叶虫”操作系统处理异常的能力,即给异常处理函数 ``panic`` 增加显示字符串能力。主要修改内容如下:
.. code-block:: rust
// os/src/main.rs
#![feature(panic_info_message)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
if let Some(location) = info.location() {
println!("Panicked at {}:{} {}", location.file(), location.line(), info.message().unwrap());
} else {
println!("Panicked: {}", info.message().unwrap());
}
shutdown()
}
我们尝试从传入的 ``PanicInfo`` 中解析 panic 发生的文件和行数。如果解析成功的话,就和 panic 的报错信息一起打印出来。我们需要在
``main.rs`` 开头加上 ``#![feature(panic_info_message)]`` 才能通过 ``PanicInfo::message`` 获取报错信息。
但我们在 ``main.rs````rust_main`` 函数中调用 ``panic!("It should shutdown!");`` 宏时,整个模拟执行的结果是:
.. code-block:: console
$ cargo build --release
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os \
--strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin
$ qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi] Kernel entry: 0x80200000
Hello, world!
Panicked at src/main.rs:95 It should shutdown!
可以看到产生panic的地点在 ``main.rs`` 的第95行与源码中的实际位置一致到这里我们基本上算是完成了第一章的实验内容
实现了支持应用程序在裸机上显示字符串的“三叶虫”操作系统。但也能看出,这个操作系统很脆弱,只能支持一个简单的易用,在本质上
是一个提供方便服务接口的库。“三叶虫”操作系统还需进化,提升能力。
在下一章,我们将进入“敏迷龙”操作系统的设计与实现。
.. note::
**Rust 小知识:错误处理**
Rust 中常利用 ``Option<T>````Result<T, E>`` 进行方便的错误处理。它们都属于枚举结构:
- ``Option<T>`` 既可以有值 ``Option::Some<T>`` ,也有可能没有值 ``Option::None``
- ``Result<T, E>`` 既可以保存某个操作的返回值 ``Result::Ok<T>`` ,也可以表明操作过程中出现了错误 ``Result::Err<E>``
我们可以使用 ``Option/Result`` 来保存一个不能确定存在/不存在或是成功/失败的值。之后可以通过匹配 ``if let`` 或是在能够确定
的场合直接通过 ``unwrap`` 将里面的值取出。详细的内容可以参考 Rust 官方文档。

View File

@ -0,0 +1,327 @@
理解应用程序和执行环境
==================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
在前面几节,我们进行了大量的实验。接下来是要消化总结和归纳理解的时候了。
本节主要会进一步归纳总结执行程序和执行环境相关的基础知识:
- 物理内存与物理地址
- 函数调用与栈
- 调用规范
- 程序内存布局
- 执行环境
如果读者已经了解,可直接跳过,进入下一节。
.. _term-physical-address:
.. _term-physical-memory:
物理内存与物理地址
----------------------------
物理内存是计算机体系结构中一个重要的组成部分。在存储方面CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
是,该下标通常不以 0 开头,而通常以 ``0x80000000`` 开头。总结一下的话就是, CPU 可以通过物理地址来 *逐字节* 访问物理内存中保存的
数据。
值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和
地址对齐的问题。由于这并不是重点,我们在这里不展开说明。
.. _function-call-and-stack:
函数调用与栈
----------------------------
从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 :math:`\{a_n\}`,那么这个序列会符合怎样的模式呢?
.. _term-control-flow:
其中最简单的无疑就是 CPU 一条条连续向下执行指令,也即满足递推式 :math:`a_{n+1}=a_n+L`,这里我们假设该平台的指令是定长的且均为
:math:`L` 字节(常见情况为 2/4 字节)。但是执行序列并不总是符合这种模式,当位于物理地址 :math:`a_n` 的指令是一条跳转指令的时候,
该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 **控制流** (Control Flow) 的多种不同结构,比如分支结构(如 if/switch 语句)
和循环结构(如 for/while 语句)。用来实现上述两种结构的跳转指令,只需实现跳转功能,也就是将 pc 寄存器设置到一个指定的地址即可。
.. _term-function-call:
另一种控制流结构则显得更为复杂: **函数调用** (Function Call)。我们大概清楚调用函数整个过程中代码执行的顺序,如果是从源代码级的
视角来看,我们会去执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行。那么我们如何用汇编指令来实现
这一过程?首先在调用的时候,需要有一条指令跳转到被调用函数的位置,这个看起来和其他控制结构没什么不同;但是在被调用函数返回的时候,我们
却需要返回那条跳转过来的指令的下一条继续执行。这次用来返回的跳转究竟跳转到何处,在对应的函数调用发生之前是不知道的。比如,我们在两个不同的
地方调用同一个函数,显然函数返回之后会回到不同的地址。这是一个很大的不同:其他控制流都只需要跳转到一个 *编译期固定下来* 的地址,而函数调用
的返回跳转是跳转到一个 *运行时确定* (确切地说是在函数调用发生的时候)的地址。
.. image:: function-call.png
:align: center
:name: function-call
对此,指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在 RISC-V 架构上,有两条指令即符合这样的特征:
.. list-table:: RISC-V 函数调用跳转指令
:widths: 20 30
:header-rows: 1
:align: center
* - 指令
- 指令功能
* - :math:`\text{jal}\ \text{rd},\ \text{imm}[20:1]`
- :math:`\text{rd}\leftarrow\text{pc}+4`
:math:`\text{pc}\leftarrow\text{pc}+\text{imm}`
* - :math:`\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}`
- :math:`\text{rd}\leftarrow\text{pc}+4`
:math:`\text{pc}\leftarrow\text{rs}+\text{imm}`
.. _term-source-register:
.. _term-immediate:
.. _term-destination-register:
.. note::
**RISC-V 指令各部分含义**
在大多数只与通用寄存器打交道的指令中, rs 表示 **源寄存器** (Source Register) imm 表示 **立即数** (Immediate)
是一个常数,二者构成了指令的输入部分;而 rd 表示 **目标寄存器** (Destination Register)它是指令的输出部分。rs 和 rd
可以在 32 个通用寄存器 x0~x31 中选取。但是这三个部分都不是必须的,某些指令只有一种输入类型,另一些指令则没有输出部分。
.. _term-pseudo-instruction:
从中可以看出,这两条指令除了设置 pc 寄存器完成跳转功能之外,还将当前跳转指令的下一条指令地址保存在 rd 寄存器中。
(这里假设所有指令的长度均为 4 字节,在不使用 C 标准指令集拓展的情况下成立)
在 RISC-V 架构中,
通常使用 ra(x1) 寄存器作为其中的 rd ,因此在函数返回的时候,只需跳转回 ra 所保存的地址即可。事实上在函数返回的时候我们常常使用一条
**伪指令** (Pseudo Instruction) 跳转回调用之前的位置: ``ret`` 。它会被汇编器翻译为 ``jalr x0, 0(x1)``,含义为跳转到寄存器
ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中保存这一步被省略。
总结一下,在进行函数调用的时候,我们通过 jalr 指令
保存返回地址并实现跳转;而在函数即将返回的时候,则通过 ret 指令跳转之前的下一条指令继续执行。这两条指令实现了函数调用流程的核心机制。
由于我们是在 ra 寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化,不然在 ret 之后就会跳转到错误的位置。事实上编译器
除了函数调用的相关指令之外确实基本上不使用 ra 寄存器。也就是说,如果在函数中没有调用其他函数,那 ra 的值不会变化,函数调用流程
能够正常工作。但遗憾的是,在实际编写代码的时候我们常常会遇到函数 **多层嵌套调用** 的情形。我们很容易想象,如果函数不支持嵌套调用,那么编程将会
变得多么复杂。如果我们试图在一个函数 :math:`f` 中调用一个子函数,在跳转到子函数 :math:`g` 的同时ra 会被覆盖成这条跳转指令的
下一条的地址,而 ra 之前所保存的函数 :math:`f` 的返回地址将会 `永久丢失`
.. _term-function-context:
.. _term-activation-record:
因此若想正确实现嵌套函数调用的控制流我们必须通过某种方式保证在一个函数调用子函数的前后ra 寄存器的值不能发生变化。但实际上,
这并不仅仅局限于 ra 一个寄存器,而是作用于所有的通用寄存器。这是因为,编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的
子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。因此这是必要的。
我们将由于函数调用,在控制流转移前后需要保持不变的寄存器集合称之为 **函数调用上下文** (Context) 或称 **活动记录** (Activation Record),利用这一概念
,则在函数调用前后需要保持不变的寄存器集合被称为函数调用上下文。
.. _term-save-restore:
由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在
内存中的一个区域 **保存** (Save) 函数调用上下文中的寄存器;而之后我们会从内存中同样的区域读取并 **恢复** (Restore) 函数调用上下文
中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
.. _term-callee-saved:
.. _term-caller-saved:
- **被调用者保存** (Callee-Saved) 寄存器,即被调用的函数保证调用它前后,这些寄存器保持不变;
- **调用者保存** (Caller-Saved) 寄存器,被调用的函数可能会覆盖这些寄存器。
从名字中可以看出,函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下:
- 调用者:首先保存不希望在函数调用过程中发生变化的调用者保存寄存器,然后通过 jal/jalr 指令调用子函数,返回回来之后恢复这些寄存器。
- 被调用者:在函数开头保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,在退出之前恢复这些寄存器。
.. _term-prologue:
.. _term-epilogue:
我们发现无论是调用者还是被调用者,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 **开场白** (Prologue) 和
**收场白** (Epilogue),它们会由编译器帮我们自动插入。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。对于
它而言,如果在执行的时候需要修改被调用者保存寄存器,而必须在函数开头的开场白和结尾的收场白处进行保存;对于调用者保存寄存器则可以没有任何
顾虑的随便使用,因为它在约定中本就不需要承担保证调用者保存寄存器保持不变的义务。
.. note::
**寄存器保存与编译器优化**
这里值得说明的是,调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器的一个子集。对于调用者而言,那些内容
并不重要,即使在调用子函数的时候被覆盖也不影响函数执行的调用者保存寄存器不会被编译器保存;而对于被调用者而言,在其执行过程中没有
使用到的被调用者保存寄存器也无需保存。编译器作为寄存器的使用者自然知道在这两个场景中,分别有哪些值得保存的寄存器。
从这一角度也可以理解为何要将函数调用上下文分成两类:可以在尽可能早的时候优化掉一些无用的寄存器保存与恢复。
.. _term-calling-convention:
调用规范
----------------
**调用规范** (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:
1. 函数的输入参数和返回值如何传递;
2. 函数调用上下文中调用者/被调用者保存寄存器的划分;
3. 其他的在函数调用流程中对于寄存器的使用方法。
调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数
接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。
.. note::
**RISC-V 架构上的 C 语言调用规范**
RISC-V 架构上的 C 语言调用规范可以在 `这里 <https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf>`_ 找到。
它对通用寄存器的使用做出了如下约定:
.. list-table:: RISC-V 寄存器功能分类
:widths: 20 20 40
:align: center
:header-rows: 1
* - 寄存器组
- 保存者
- 功能
* - a0~a7
- 调用者保存
- 用来传递输入参数。特别的 a0 和 a1 用来保存返回值。
* - t0~t6
- 调用者保存
- 作为临时寄存器使用,在函数中可以随意使用无需保存。
* - s0~s11
- 被调用者保存
- 作为临时寄存器使用,保存后才能在函数中使用。
剩下的 5 个通用寄存器情况如下:
- zero(x0) 之前提到过,它恒为零,函数调用不会对它产生影响;
- ra(x1) 是调用者保存的,不过它并不会在每次调用子函数的时候都保存一次,而是在函数的开头和结尾保存/恢复即可,因为在执行期间即使被
覆盖也没有关系。看上去和被调用者保存寄存器保存的位置一样,但是它确实是调用者保存的。
- sp(x2) 是被调用者保存的。这个之后就会提到。
- gp(x3) 和 tp(x4) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。它们的用途在后面的章节会提到。
更加详细的内容可以参考 Cornell 的 `课件 <http://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf>`_
.. _term-stack:
.. _term-stack-pointer:
.. _term-stack-frame:
之前我们讨论了函数调用上下文的保存/恢复时机以及寄存器的选择,但我们并没有详细说明这些寄存器保存在哪里,只是用“内存中的一块区域”草草带过。实际上,
它更确切的名字是 **栈** (Stack) 。 sp(x2) 常用来保存 **栈指针** (Stack Pointer),它是一个指向了内存中已经用过的位置的一个地址。在
RISC-V 架构中,栈是从高地址到低地址增长的。在一个函数中,作为起始的开场白负责分配一块新的栈空间,其实它只需要知道需要空间的大小,然后将 sp
的值减小相应的字节数即可,于是物理地址区间 :math:`[\text{新sp},\text{旧sp})` 对应的物理内存便可以被这个函数用来函数调用上下文的保存/恢复
以及其他工作,这块物理内存被称为这个函数的 **栈帧** (Stackframe)。同理,函数中作为结尾的收场白负责将开场白分配的栈帧回收,这也仅仅需要
将 sp 的值增加相同的字节数回到分配之前的状态。这也可以解释为什么 sp 是一个被调用者保存寄存器。
.. figure:: CallStack.png
:align: center
函数调用与栈帧:如图所示,我们能够看到在程序依次调用 a、调用 b、调用 c、c 返回、b 返回整个过程中栈帧的分配/回收以及 sp 寄存器的变化。
图中标有 a/b/c 的块分别代表函数 a/b/c 的栈帧。
.. _term-lifo:
.. note::
**数据结构中的栈与实现函数调用所需要的栈**
从数据结构的角度来看,栈是一个 **后入先出** (Last In First Out, LIFO) 的线性表,支持向栈顶压入一个元素以及从栈顶弹出一个元素
两种操作,分别被称为 push 和 pop。从它提供的接口来看它只支持访问栈顶附近的元素。因此在实现的时候需要维护一个指向栈顶
的指针来表示栈当前的状态。
我们这里的栈与数据结构中的栈原理相同,在很多方面可以一一对应。栈指针 sp 可以对应到指向栈顶的指针,对于栈帧的分配/回收可以分别
对应到 push/pop 操作。如果将我们的栈看成一个内存分配器,它之所以可以这么简单,是因为它回收的内存一定是 *最近一次分配* 的内存,
从而只需要类似 push/pop 的两种操作即可。
在合适的编译选项设置之下,一个函数的栈帧内容可能如下图所示:
.. figure:: StackFrame.png
:align: center
函数栈帧中的内容
它的开头和结尾分别在 sp(x2) 和 fp(s0) 所指向的地址。按照地址从高到低分别有以下内容,它们都是通过 sp 加上一个偏移量来访问的:
- ra 寄存器保存其返回之后的跳转地址,是一个调用者保存寄存器;
- 父亲栈帧的结束地址 fp是一个被调用者保存寄存器
- 其他被调用者保存寄存器 s1~s11
- 函数所使用到的局部变量。
因此,栈上实际上保存了一条完整的函数调用链,通过适当的方式我们可以实现对它的跟踪。
至此,我们基本上说明了函数调用是如何基于栈来实现的。不过我们可以暂时先忽略掉这些细节,因为我们现在只是需要在初始化阶段完成栈的设置,也就是
设置好栈指针 sp 寄存器,后面的函数调用相关机制编译器会帮我们自动完成。麻烦的是, sp 的值也不能随便设置。至少我们需要保证它仍在物理内存上,
而且不能与程序的其他代码、数据段相交,因为在函数调用的过程中,栈区域里面的内容会被修改。如何保证这一点呢?此外,之前我们还提到我们编写的
初始化代码必须放在物理地址 ``0x80020000`` 开头的内存上,这又如何做到呢?事实上,这两点都需要我们接下来讲到的程序内存布局的知识。
程序内存布局
----------------------------
.. _term-section:
.. _term-memory-layout:
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在
程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可用的存储空间。事实上
我们还可以根据其功能进一步把两个部分划分为更小的单位: **段** (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的
**内存布局** (Memory Layout)。一种典型的程序相对内存布局如下:
.. figure:: MemoryLayout.png
:align: center
一种典型的程序相对内存布局
代码部分只有代码段 ``.text`` 一个段,存放程序的所有汇编代码。
数据部分则还可以继续细化:
.. _term-heap:
- 已初始化数据段保存程序中那些已初始化的全局数据,分为 ``.rodata````.data`` 两部分。前者存放只读的全局数据,通常是一些常数或者是
常量字符串等;而后者存放可修改的全局数据。
- 未初始化数据段 ``.bss`` 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,也即将这块区域逐字节清零;
- **堆** (heap) 区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
- 栈区域 stack 不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内。它向低地址增长。
.. note::
**局部变量与全局变量**
在一个函数的视角中,它能够访问的变量包括以下几种:
- 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前 sp 加上一个偏移量来访问的;
- 全局变量:保存在数据段 ``.data````.bss`` 中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于
gp 加上一个偏移量来访问的。
- 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 *直接* 访问栈上或者全局数据段中的 **编译期确定大小** 的变量。
因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量
放在栈帧里面,也可以作为全局变量放在全局数据段中。
我们可以将常说的编译流程细化为多个阶段(虽然输入一条命令便可将它们全部完成):
.. _term-compiler:
.. _term-assembler:
.. _term-linker:
.. _term-object-file:
1. **编译器** (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
2. **汇编器** (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 **目标文件** (Object File)
3. **链接器** (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
每个目标文件都有着自己局部的内存布局,里面含有若干个段。在链接的时候,链接器会将这些内存布局合并起来形成一个整体的内存布局。此外,每个目标文件
都有一个符号表,里面记录着它需要从其他文件中寻找的外部符号和能够提供给其他文件的符号,通常是一些函数和全局变量等。在链接的时候汇编器会将
外部符号替换为实际的地址。
.. note::
本节内容部分参考自:
- `RISC-V C 语言调用规范 <https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf>`_
- `Notes from Cornell CS3410 2019Spring <http://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf>`_
- `Lecture from Berkeley CS61C 2018Spring <https://inst.eecs.berkeley.edu/~cs61c/sp18/lec/06/lec06.pdf>`_
- `Lecture from MIT 6.828 2020 <https://pdos.csail.mit.edu/6.828/2020/lec/l-riscv-slides.pdf>`_

View File

@ -0,0 +1,153 @@
chapter1练习
=====================================================
.. toctree::
:hidden:
:maxdepth: 4
- 本节难度: **低**
编程作业
-------------------------------
彩色化 LOG
+++++++++++++++++++++++++++++++
lab1 的工作使得我们从硬件世界跳入了软件世界,当看到自己的小 os 可以在裸机硬件上输出 ``hello world`` 是不是很高兴呢但是为了后续的一步开发更好的调试环境也是必不可少的第一章的练习要求大家实现更加炫酷的彩色log。
详细的原理不多说,感兴趣的同学可以参考 `ANSI转义序列 <https://zh.wikipedia.org/wiki/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97>`_ ,现在执行如下这条命令试试
.. code-block:: console
$ echo -e "\x1b[31mhello world\x1b[0m"
如果你明白了我们是如何利用串口实现输出,那么要实现彩色输出就十分容易了,只需要用需要输出的字符串替换上一条命令中的 ``hello world``,用期望颜色替换 ``31(代表红色)`` 即可。
.. warning::
以下内容仅为推荐实现,不是练习要求,有时间和兴趣的同学可以尝试。
我们推荐实现如下几个等级的输出,输出优先级依次降低:
.. list-table:: log 等级推荐
:header-rows: 1
:align: center
* - 名称
- 颜色
- 用途
* - ERROR
- 红色(31)
- 表示发生严重错误,很可能或者已经导致程序崩溃
* - WARN
- 黄色(93)
- 表示发生不常见情况,但是并不一定导致系统错误
* - INFO
- 蓝色(34)
- 比较中庸的选项,输出比较重要的信息,比较常用
* - DEBUG
- 绿色(32)
- 输出信息较多,在 debug 时使用
* - TRACE
- 灰色(90)
- 最详细的输出,跟踪了每一步关键路径的执行
我们可以输出比设定输出等级以及更高输出等级的信息,如设置 ``LOG = INFO``,则输出 ``ERROR````WARN````INFO`` 等级的信息。简单 demo 如下,输出等级为 INFO:
.. image:: color-demo.png
为了方便使用彩色输出,我们要求同学们实现彩色输出的宏或者函数,用以代替 print 完成输出内核信息的功能,它们有着和 prinf 十分相似的使用格式,要求支持可变参数解析,形如:
.. code-block:: rust
// 这段代码输出了 os 内存空间布局,这到这些信息对于编写 os 十分重要
info!(".text [{:#x}, {:#x})", s_text as usize, e_text as usize);
debug!(".rodata [{:#x}, {:#x})", s_rodata as usize, e_rodata as usize);
error!(".data [{:#x}, {:#x})", s_data as usize, e_data as usize);
.. code-block:: c
info("load range : [%d, %d] start = %d\n", s, e, start);
在以后,我们还可以在 log 信息中增加线程、CPU等信息只是一个推荐不做要求这些信息将极大的方便你的代码调试。
实验要求
+++++++++++++++++++++++++++++++
- 实现分支ch1。
- 完成实验指导书中的内容,在裸机上实现 ``hello world`` 输出。
- 实现彩色输出宏(只要求可以彩色输出,不要求 log 等级控制,不要求多种颜色)。
- 隐形要求:可以关闭内核所有输出。从 lab2 开始要求关闭内核所有输出(如果实现了 log 等级控制,那么这一点自然就实现了)。
- 利用彩色输出宏输出 os 内存空间布局,即:输出 ``.text````.data````.rodata````.bss`` 各段位置,输出等级为 ``INFO``
challenge: 支持多核,实现多个核的 boot。
实验检查
+++++++++++++++++++++++++++++++
- 实验目录要求(Rust)
.. code-block::
├── os(内核实现)
│   ├── Cargo.toml(配置文件)
│   ├── Makefile (要求 make run LOG=xxx 可以正确执行,可以不实现对 LOG 这一属性的支持,设置默认输出等级为 INFO)
│   └── src(所有内核的源代码放在 os/src 目录下)
│   ├── main.rs(内核主函数)
│   └── ...
├── reports
│   ├── lab1.md/pdf
│   └── ...
├── README.md其他必要的说明
├── ...
报告命名 labx.md/pdf统一放在 reports 目录下。每个实验新增一个报告,为了方便修改,检查报告是以最新分支的所有报告为准。
- 检查
.. code-block:: console
$ cd os
$ git checkout ch1
$ make run LOG=INFO
可以正确执行(可以不支持LOG参数只有要彩色输出就好),可以看到正确的内存布局输出,根据实现不同数值可能有差异,但应该位于 ``linker.ld`` 中指示 ``BASE_ADDRESS`` 后一段内存,输出之后关机。
tips
+++++++++++++++++++++++++++++++
- 对于 Rust, 可以使用 crate `log <https://docs.rs/log/0.4.14/log/>`_ ,推荐参考 `rCore <https://github.com/rcore-os/rCore/blob/master/kernel/src/logging.rs>`_
- 对于 C可以实现不同的函数注意不推荐多层可变参数解析有时会出现不稳定情况也可以参考 `linux printk <https://github.com/torvalds/linux/blob/master/include/linux/printk.h#L312-L385>`_ 使用宏实现代码重用。
- 两种语言都可以使用 ``extern`` 关键字获得在其他文件中定义的符号。
问答作业
-------------------------------
1. 为了方便 os 处理,M态软件会将 S 态异常/中断委托给 S 态软件请指出有哪些寄存器记录了委托信息rustsbi 委托了哪些异常/中断?(也可以直接给出寄存器的值)
2. 请学习 gdb 调试工具的使用(这对后续调试很重要),并通过 gdb 简单跟踪从机器加电到跳转到 0x80200000 的简单过程。只需要描述重要的跳转即可,只需要描述在 qemu 上的情况。
3. tips:
- 事实上进入 rustsbi 之后就不需要使用 gdb 调试了。可以直接阅读代码。`rustsbi起始代码 <https://github.com/rustsbi/rustsbi-qemu/blob/main/rustsbi-qemu/src/main.rs#L146>`_
- 可以使用示例代码 Makefile 中的 ``make debug`` 指令。
- 一些可能用到的 gdb 指令:
- ``x/10i 0x80000000`` : 显示 0x80000000 处的10条汇编指令。
- ``x/10i $pc`` : 显示即将执行的10条汇编指令。
- ``x/10xw 0x80000000`` : 显示 0x80000000 处的10条数据格式为16进制32bit。
- ``info register``: 显示当前所有寄存器信息。
- ``info r t0``: 显示 t0 寄存器的值。
- ``break funcname``: 在目标函数第一条指令处设置断点。
- ``break *0x80200000``: 在 0x80200000 出设置断点。
- ``continue``: 执行直到碰到断点。
- ``si``: 单步执行一条汇编指令。
报告要求
-------------------------------
- 简单总结本次实验你编程的内容。控制在5行以内不要贴代码
- 由于彩色输出不好自动测试,请附正确运行后的截图。
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

230
source/chapter1/ch1.py Normal file
View File

@ -0,0 +1,230 @@
from manimlib.imports import *
class Test(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
left_line = Line(start = np.array([-1, -4, 0]), end = np.array([-1, 4, 0]))
left_line.set_color(BLACK)
self.add(left_line)
right_line = Line(start = np.array([1, -4, 0]), end = np.array([1, 4, 0]))
right_line.set_color(BLACK)
self.add(right_line)
STACKFRAME_HEIGHT = 1.0
STACKFRAME_WIDTH = 2.0
for i in range(0, 4):
stack_frame = Rectangle(height=1.0, width=2.0, stroke_color=BLACK, stroke_width=5, stroke_opacity=0.1)
stack_frame.set_y(i * STACKFRAME_HEIGHT - 1)
self.add(stack_frame)
left_text = TextMobject("sp + %d" % (8*i,))
left_text.next_to(stack_frame, LEFT)
left_text.set_color(BLACK)
self.add(left_text)
high_address = TextMobject("High Address", color=BLUE)
#high_address.to_corner(UL, buff=0.5)
high_address.to_edge(TOP, buff=0).shift(RIGHT*(STACKFRAME_WIDTH+1))
self.add(high_address)
self.add(DashedLine(color=BLACK).next_to(high_address, LEFT, buff=0))
low_address = TextMobject("Low Address", color=BLUE)
low_address.to_edge(BOTTOM, buff=0).shift(RIGHT*(STACKFRAME_WIDTH+1))
self.add(low_address)
self.add(DashedLine(color=BLACK).next_to(low_address, LEFT, buff=0))
class CallStack(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
# constants
BLOCK_WIDTH = 1
BLOCK_TOP = 2
HORIZONTAL_GAP = 1
ADDR_ARROW_LENGTH = 1.5
left_line = Line(np.array([-0.5, -3, 0]), np.array([-0.5, 3, 0]), color=BLACK)
right_line = Line(np.array([0.5, -3, 0]), np.array([0.5, 3, 0]), color=BLACK)
high_addr = TextMobject("High Address", color=BLACK).scale(0.7).move_to(np.array([5, 3, 0]), LEFT)
low_addr = TextMobject("Low Address", color=BLACK).scale(0.7).move_to(np.array([5, -3, 0]), LEFT)
addr_arrow = Arrow(color=BLACK, stroke_width=3) \
.rotate(angle=90 * DEGREES, axis=IN).move_to(np.array([5.5, 0, 0])) \
.scale(3.5)
addr_arrow.add(
TextMobject("grow", color=BLACK)
.next_to(addr_arrow.get_center(), RIGHT, buff=0.1)
.scale(0.7)
)
self.add(high_addr, low_addr, addr_arrow)
stack_frame_a = Rectangle(width=BLOCK_WIDTH, height=1.5, stroke_color=BLACK)
stack_frame_a.set_fill(color=BLUE, opacity=0.8)
stack_frame_a.add(TextMobject("a", color=BLACK))
stack_frame_a.shift(UP*BLOCK_TOP)
stack_frame_b = Rectangle(width=BLOCK_WIDTH, height=1, stroke_color=BLACK)
stack_frame_b.set_fill(color=RED, opacity=0.8)
stack_frame_b.add(TextMobject("b", color=BLACK))
stack_frame_b.next_to(stack_frame_a, DOWN, buff=0)
stack_frame_c = Rectangle(width=BLOCK_WIDTH, height=2, stroke_color=BLACK)
stack_frame_c.set_fill(color=GREEN, opacity=0.8)
stack_frame_c.add(TextMobject("c", color=BLACK))
stack_frame_c.next_to(stack_frame_b, DOWN, buff=0)
vgroup_a = VGroup(left_line, right_line, stack_frame_a)
vgroup_ab = vgroup_a.deepcopy().add(stack_frame_b)
vgroup_abc = vgroup_ab.deepcopy().add(stack_frame_c)
horizontal_group = [vgroup_a, vgroup_ab, vgroup_abc, vgroup_ab.deepcopy(), vgroup_a.deepcopy()]
labels = [
"Call a",
"Call b",
"Call c",
"c returned",
"b returned",
]
for i in range(0, 5):
# 0->2, 1->3, 2->4, 3->3, 4->2
m = {0: 2, 1: 3, 2: 4, 3: 3, 4: 2}
arrow = Arrow(color=BLACK).scale(0.25)
arrow.next_to(horizontal_group[i][m[i]].get_corner(DL), LEFT, buff=0)
arrow.add(TextMobject("sp", color=BLACK).next_to(arrow.get_left(), LEFT, buff=0).scale(0.7))
label = TextMobject(labels[i], color=BLACK).scale(0.7).to_edge(TOP, buff=0.1)
horizontal_group[i].add(arrow, label)
horizontal_group[i].shift((i - 2) * (BLOCK_WIDTH + HORIZONTAL_GAP) * RIGHT)
self.add(horizontal_group[i])
class StackFrame(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
# constants
STACK_HEIGHT_HALF = 3.5
left_line = Line(np.array([-1, -STACK_HEIGHT_HALF, 0]), np.array([-1, STACK_HEIGHT_HALF, 0]), color=BLACK)
right_line = Line(np.array([1, -STACK_HEIGHT_HALF, 0]), np.array([1, STACK_HEIGHT_HALF, 0]), color=BLACK)
self.add(left_line, right_line)
father_stack_frame = Rectangle(width=2, height=1.5, fill_color=BLUE, fill_opacity=1.0).set_y(2.3)
father_stack_frame.set_stroke(color=BLACK)
father_stack_frame.add(TextMobject("Father", color=BLACK).scale(0.5).next_to(father_stack_frame.get_center(), UP, buff=0.1))
father_stack_frame.add(TextMobject("StackFrame", color=BLACK).scale(0.5)\
.next_to(father_stack_frame[1], DOWN, buff=0.2))
ra = Rectangle(width=2, height=0.7, fill_color=YELLOW_E, fill_opacity=1.0).next_to(father_stack_frame, DOWN, buff=0)
ra.set_stroke(color=BLACK)
ra.add(TextMobject("ra", color=BLACK).scale(0.5).move_to(ra))
fp = Rectangle(width=2, height=0.7, fill_color=TEAL_E, fill_opacity=1.0).next_to(ra, DOWN, buff=0)
fp.set_stroke(color=BLACK)
fp.add(TextMobject("prev fp", color=BLACK).scale(0.5).move_to(fp))
callee_saved = Rectangle(width=2, height=1.3, fill_color=ORANGE, fill_opacity=1.0).next_to(fp, DOWN, buff=0)
callee_saved.set_stroke(color=BLACK)
callee_saved.add(TextMobject("Callee-saved", color=BLACK).scale(0.5).move_to(callee_saved))
local_var = Rectangle(width=2, height=1.6, fill_color=MAROON_E, fill_opacity=0.7).next_to(callee_saved, DOWN, buff=0)
local_var.set_stroke(color=BLACK)
local_var.add(TextMobject("Local Variables", color=BLACK).scale(0.5).move_to(local_var))
current_sp = Arrow(color=BLACK).next_to(local_var.get_corner(DL), LEFT, buff=0).scale(0.25, about_edge=RIGHT)
current_sp.add(TextMobject("sp", color=BLACK).scale(0.5).next_to(current_sp.get_left(), LEFT, buff=0.1))
current_fp = Arrow(color=BLACK).next_to(father_stack_frame.get_corner(DL), LEFT, buff=0).scale(.25, about_edge=RIGHT)
current_fp.add(TextMobject("fp", color=BLACK).scale(0.5).next_to(current_fp.get_left(), LEFT, buff=0.1))
upper_bound = Arrow(color=BLACK)\
.rotate(90*DEGREES, IN)\
.next_to(ra.get_corner(UR), DOWN, buff=0)\
.shift(0.3*RIGHT)\
.scale(1.2, about_edge=UP)\
.set_stroke(width=3)
upper_bound.tip.scale(0.4, about_edge=UP)
lower_bound = Arrow(color=BLACK)\
.rotate(90*DEGREES, OUT)\
.next_to(local_var.get_corner(DR), UP, buff=0)\
.shift(0.3*RIGHT)\
.scale(1.2, about_edge=DOWN)\
.set_stroke(width=3)
lower_bound.tip.scale(0.4, about_edge=DOWN)
current_stack_frame = TextMobject("Current StackFrame", color=BLACK).scale(0.5)\
.next_to((upper_bound.get_center()+lower_bound.get_center())*.5, RIGHT, buff=0.1)
upper_dash = DashedLine(color=BLACK).next_to(ra.get_corner(UR), RIGHT, buff=0)\
.scale(0.7, about_edge=LEFT)
lower_dash = DashedLine(color=BLACK).next_to(local_var.get_corner(DR), RIGHT, buff=0)\
.scale(0.7, about_edge=LEFT)
prev_fp_p1 = DashedLine(fp[1].get_right() + np.array([0.1, 0, 0]), fp[1].get_right() + np.array([1.2, 0, 0]), color=RED)
delta_y = father_stack_frame.get_top()[1] - prev_fp_p1.get_right()[1]
prev_fp_p2 = DashedLine(prev_fp_p1.get_right(), prev_fp_p1.get_right()+delta_y*UP, color=RED)
prev_fp_p3 = DashedLine(prev_fp_p2.get_end(), father_stack_frame.get_corner(UR), color=RED)
prev_fp_p3.add_tip(0.1)
self.add(father_stack_frame, ra, fp, callee_saved, local_var, current_sp, current_fp)
self.add(upper_bound, lower_bound, current_stack_frame, upper_dash, lower_dash)
self.add(prev_fp_p1, prev_fp_p2, prev_fp_p3)
class MemoryLayout(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
# constants
STACK_HEIGHT_HALF = 4
left_line = Line(np.array([-1, -STACK_HEIGHT_HALF, 0]), np.array([-1, STACK_HEIGHT_HALF, 0]), color=BLACK)
right_line = Line(np.array([1, -STACK_HEIGHT_HALF, 0]), np.array([1, STACK_HEIGHT_HALF, 0]), color=BLACK)
self.add(left_line, right_line)
text = Rectangle(width=2, height=1.5, stroke_color=BLACK).set_y(-3)
#text.set_fill(color=GREEN, opacity=1.0)
text.add(TextMobject(".text", color=BLACK).scale(0.7).move_to(text))
rodata = Rectangle(width=2, height=.75, stroke_color=BLACK).next_to(text, UP, buff=0)
rodata.add(TextMobject(".rodata", color=BLACK).scale(.7).move_to(rodata))
data = Rectangle(width=2, height=.75, stroke_color=BLACK).next_to(rodata, UP, buff=0)
#data.set_fill(color=TEAL_E, opacity=1.0)
data.add(TextMobject(".data", color=BLACK).scale(0.7).move_to(data))
bss = Rectangle(width=2, height=.75, stroke_color=BLACK).next_to(data, UP, buff=0)
#bss.set_fill(color=MAROON_E, opacity=1.0)
bss.add(TextMobject(".bss", color=BLACK).scale(0.7).move_to(bss))
heap = Rectangle(width=2, height=1, stroke_color=BLACK).next_to(bss, UP, buff=0)
#heap.set_fill(color=GRAY, opacity=1.0)
heap.add(TextMobject("heap", color=BLACK).scale(0.7).move_to(heap))
stack = Rectangle(width=2, height=1, stroke_color=BLACK).set_y(3)
#stack.set_fill(color=BLUE_E, opacity=0.8)
stack.add(TextMobject("stack", color=BLACK).scale(0.7).move_to(stack))
stack_down = Arrow(color=BLACK).rotate(90*DEGREES, IN).next_to(stack, DOWN, buff=0)\
.scale(0.35, about_edge=UP)
stack_down.tip.scale(0.5, about_edge=UP)
heap_up = Arrow(color=BLACK).rotate(90*DEGREES, OUT).next_to(heap, UP, buff=0)\
.scale(0.35, about_edge=DOWN)
heap_up.tip.scale(0.5, about_edge=DOWN)
low_addr = TextMobject("Low Address", color=BLACK).to_edge(BOTTOM, buff=0.05).shift(3*RIGHT)
high_addr = TextMobject("High Address", color=BLACK).to_edge(TOP, buff=.05).shift(3*RIGHT)
division = DashedLine(color=BLACK).next_to(text.get_corner(UL), LEFT, buff=0).scale(1.5)
data_mem_division = DashedLine(color=BLACK).next_to(stack.get_corner(UL), LEFT, buff=0).scale(1.5)
code_mem_division = DashedLine(color=BLACK).next_to(text.get_corner(DL), LEFT, buff=0).scale(1.5)
data_mem = TextMobject("Data Memory", color=BLACK).move_to((division.get_center()+data_mem_division.get_center())*.5)\
.scale(.8)\
.shift(LEFT*.3)
code_mem = TextMobject("Code Memory", color=BLACK).move_to((division.get_center()+code_mem_division.get_center())*.5)\
.scale(.8)\
.shift(LEFT*.3)
self.add(text, rodata, data, bss, heap, stack)
self.add(stack_down, heap_up)
self.add(low_addr, high_addr)
self.add(division, data_mem_division, code_mem_division)
self.add(data_mem, code_mem)

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

17
source/chapter1/index.rst Normal file
View File

@ -0,0 +1,17 @@
.. _link-chapter1:
第一章:应用程序与基本执行环境
==============================================
.. toctree::
:maxdepth: 4
0intro
1app-ee-platform
2remove-std
3-1-mini-rt-usrland
3-2-mini-rt-baremetal
4understand-prog
5exercise

177
source/chapter2/0intro.rst Normal file
View File

@ -0,0 +1,177 @@
引言
================================
本章导读
---------------------------------
..
chyyuu有一个ascii图画出我们做的OS。
本章展现了操作系统一系列功能:
- 通过批处理支持多个程序的自动加载和运行
- 操作系统利用硬件特权级机制,实现对操作系统自身的保护
上一章,我们在 RV64 裸机平台上成功运行起来了 ``Hello, world!`` 。看起来这个过程非常顺利,只需要一条命令就能全部完成。但实际上,在那个计算机刚刚诞生的年代,很多事情并不像我们想象的那么简单。 当时,程序被记录在打孔的卡片上,使用汇编语言甚至机器语言来编写。而稀缺且昂贵的计算机由专业的管理员负责操作,就和我们在上一章所做的事情一样,他们手动将卡片输入计算机,等待程序运行结束或者终止程序的运行。最后,他们从计算机的输出端——也就是打印机中取出程序的输出并交给正在休息室等待的程序提交者。
实际上,这样做是一种对于珍贵的计算资源的浪费。因为当时的计算机和今天的个人计算机不同,它的体积极其庞大,能够占满一整个空调房间,像巨大的史前生物。管理员在房间的各个地方跑来跑去、或是等待打印机的输出的这些时间段,计算机都并没有在工作。于是,人们希望计算机能够不间断的工作且专注于计算任务本身。
.. _term-batch-system:
**批处理系统** (Batch System) 应运而生。它的核心思想是:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 *自动* 加载下一个程序到内存并开始执行。这便是最早的真正意义上的操作系统。
.. _term-privilege:
程序总是难免出现错误。但人们希望一个程序的错误不要影响到操作系统本身,它只需要终止出错的程序,转而运行执行序列中的下一个程序即可。如果后面的程序都无法运行就太糟糕了。这种 *保护* 操作系统不受有意或无意出错的程序破坏的机制被称为 **特权级** (Privilege) 机制,它实现了用户态和内核态的隔离,需要软件和硬件的共同努力。
本章主要是设计和实现建立支持批处理系统的泥盆纪“邓式鱼”操作系统,从而对可支持运行一批应用程序的执行环境有一个全面和深入的理解。
本章我们的目标让泥盆纪“邓式鱼”操作系统能够感知多个应用程序的存在,并一个接一个地运行这些应用程序,当一个应用程序执行完毕后,会启动下一个应用程序,直到所有的应用程序都执行完毕。
.. image:: deng-fish.png
:align: center
:name: fish-os
实践体验
---------------------------
本章我们的批处理系统将连续运行三个应用程序,放在 ``user/src/bin`` 目录下。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch2
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发板连接到 PC并在上面运行本章代码
.. code-block:: console
$ cd os
$ make run BOARD=k210
如果顺利的话,我们可以看到批处理系统自动加载并运行所有的程序并且正确在程序出错的情况下保护了自身:
.. code-block::
[rustsbi] RustSBI version 0.1.1
<rustsbi-logo>
[rustsbi] Platform: QEMU (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] Kernel entry: 0x80200000
[kernel] Hello, world!
[kernel] num_app = 3
[kernel] app_0 [0x8020b028, 0x8020c048)
[kernel] app_1 [0x8020c048, 0x8020d100)
[kernel] app_2 [0x8020d100, 0x8020e4b8)
[kernel] Loading app_0
Hello, world!
[kernel] Application exited with code 0
[kernel] Loading app_1
Into Test store_fault, we will insert an invalid store operation...
Kernel should kill this application!
[kernel] PageFault in application, core dumped.
[kernel] Loading app_2
3^10000=5079
3^20000=8202
3^30000=8824
3^40000=5750
3^50000=3824
3^60000=8516
3^70000=2510
3^80000=9379
3^90000=2621
3^100000=2749
Test power OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/batch.rs:61 All applications completed!
本章代码树
-------------------------------------------------
.. code-block::
./os/src
Rust 10 Files 311 Lines
Assembly 2 Files 58 Lines
├── bootloader
│   ├── rustsbi-k210.bin
│   └── rustsbi-qemu.bin
├── LICENSE
├── os
│   ├── build.rs(新增:生成 link_app.S 将应用作为一个数据段链接到内核)
│   ├── Cargo.toml
│   ├── Makefile(修改:构建内核之前先构建应用)
│   └── src
│   ├── batch.rs(新增:实现了一个简单的批处理系统)
│   ├── console.rs
│   ├── entry.asm
│   ├── lang_items.rs
│   ├── link_app.S(构建产物,由 os/build.rs 输出)
│   ├── linker-k210.ld
│   ├── linker-qemu.ld
│   ├── main.rs(修改:主函数中需要初始化 Trap 处理并加载和执行应用)
│   ├── sbi.rs
│   ├── syscall(新增:系统调用子模块 syscall)
│   │   ├── fs.rs(包含文件 I/O 相关的 syscall)
│   │   ├── mod.rs(提供 syscall 方法根据 syscall ID 进行分发处理)
│   │   └── process.rs(包含任务处理相关的 syscall)
│   └── trap(新增Trap 相关子模块 trap)
│   ├── context.rs(包含 Trap 上下文 TrapContext)
│   ├── mod.rs(包含 Trap 处理入口 trap_handler)
│   └── trap.S(包含 Trap 上下文保存与恢复的汇编代码)
├── README.md
├── rust-toolchain
├── tools
│   ├── kflash.py
│   ├── LICENSE
│   ├── package.json
│   ├── README.rst
│   └── setup.py
└── user(新增:应用测例保存在 user 目录下)
├── Cargo.toml
├── Makefile
└── src
├── bin(基于用户库 user_lib 开发的应用,每个应用放在一个源文件中)
│   ├── 00hello_world.rs
│   ├── 01store_fault.rs
│   └── 02power.rs
├── console.rs
├── lang_items.rs
├── lib.rs(用户库 user_lib)
├── linker.ld(应用的链接脚本)
└── syscall.rs(包含 syscall 方法生成实际用于系统调用的汇编指令,
各个具体的 syscall 都是通过 syscall 来实现的)
本章代码导读
-----------------------------------------------------
相比于上一章的操作系统,本章操作系统有两个最大的不同之处,一个是支持应用程序在用户态运行,且能完成应用程序发出的系统调用;另一个是能够一个接一个地自动运行不同的应用程序。所以,我们需要对操作系统和应用程序进行修改,也需要对应用程序的编译生成过程进行修改。
首先改进应用程序,让它能够在用户态执行,并能发出系统调用。这其实就是上一章中 :ref:`构建用户态执行环境 <term-print-userminienv>` 小节介绍内容的进一步改进。具体而言,编写多个应用小程序,修改编译应用所需的 ``linker.ld`` 文件来 :ref:`调整程序的内存布局 <term-app-mem-layout>` ,让操作系统能够把应用加载到指定内存地址后顺利启动并运行应用程序。
应用程序运行中,操作系统要支持应用程序的输出功能,并还能支持应用程序退出。这需要完成 ``sys_write````sys_exit`` 系统调用访问请求的实现。 具体实现涉及到内联汇编的编写以及应用与操作系统内核之间系统调用的参数传递的约定。为了让应用在还没实现操作系统之前就能进行运行测试我们采用了Linux on RISC-V64 的系统调用参数约定。具体实现可参看 :ref:`系统调用 <term-call-syscall>` 小节中的内容。 这样写完应用小例子后,就可以通过 ``qemu-riscv64`` 模拟器进行测试了。
写完应用程序后,还需实现支持多个应用程序轮流启动运行的操作系统。这里首先能把本来相对松散的应用程序执行代码和操作系统执行代码连接在一起,便于 ``qemu-system-riscv64`` 模拟器一次性地加载二者到内存中并让操作系统能够找到应用程序的位置。为把二者连在一起需要对生成的应用程序进行改造首先是把应用程序执行文件从ELF执行文件格式变成Binary格式通过 ``rust-objcopy`` 可以轻松完成然后这些Binary格式的文件通过编译器辅助脚本 ``os/build.rs`` 转变变成 ``os/src/link_app.S`` 这个汇编文件的一部分并生成各个Binary应用的辅助信息便于操作系统能够找到应用的位置。编译器会把把操作系统的源码和 ``os/src/link_app.S`` 合在一起,编译出操作系统+Binary应用的ELF执行文件并进一步转变成Binary格式。
操作系统本身需要完成对Binary应用的位置查找找到后通过 ``os/src/link_app.S`` 中的变量和标号信息完成会把Binary应用拷贝到 ``user/src/linker.ld`` 指定的物理内存位置OS的加载应用功能。在一个应执行完毕后还能加载另外一个应用这主要是通过 ``AppManagerInner`` 数据结构和对应的函数 ``load_app````run_next_app`` 等来完成对应用的一系列管理功能。
这主要在 :ref:`实现批处理操作系统 <term-batchos>` 小节中讲解。
为了让Binary应用能够启动和运行操作系统还需给Binary应用分配好执行环境所需一系列的资源。这主要包括设置好用户栈和内核栈在应用在用户态和内核在内核态需要有各自的栈实现Trap 上下文的保存与恢复让应用能够在发出系统调用到内核态后还能回到用户态继续执行完成Trap 分发与处理等工作。由于涉及用户态与内核态之间的特权级切换细节的汇编代码,与硬件细节联系紧密,所以 :ref:`这部分内容 <term-trap-handle>` 是本章中理解比较困难的地方。如果要了解清楚需要对涉及到的CSR寄存器的功能有清楚的认识。这就需要看看 `RISC-V手册 <http://crva.ict.ac.cn/documents/RISC-V-Reader-Chinese-v2p1.pdf>`_ 的第十章或更加详细的RISC-V的特权级规范文档了。有了上面的实现后就剩下最后一步实现 **执行应用程序** 的操作系统功能,其主要实现在 ``run_next_app`` 函数中 。

View File

@ -0,0 +1,236 @@
特权级机制
=====================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
为了保护我们的批处理操作系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要 CPU 提供一种特权级隔离机制使CPU在执行应用程序和操作系统内核的指令时处于不同的特权级。本节主要介绍了特权级机制的软硬件设计思路以及RISC-V的特权级架构包括特权指令的描述。
特权级的软硬件协同设计
------------------------------------------
实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。在上一节里,操作系统和应用紧密连接在一起,形成一个应用程序来执行。随着应用需求的增加,操作系统也越来越大,会以库的形式存在;同时应用自身也会越来越复杂。由于操作系统会给多个应用提供服务,所以它可能的错误会比较快地被发现,但应用自身的错误可能就不会很快发现。由于二者通过编译器形成一个应用程序来执行,即使是应用本身的问题,也会导致操作系统受到连累,从而可能导致整个计算机系统都不可用了。
所以,计算机专家就想到一个方法,能否让相对安全可靠的操作系统不受到应用程序的破坏,运行在一个安全的执行环境中,而让应用程序运行在一个无法破坏操作系统的执行环境中?
为确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
- 应用程序不能访问任意的地址空间(这个在第四章会进一步讲解,本章不会讲解)
- 应用程序不能执行某些可能破会计算机系统的指令(本章的重点)
假设有了这样的限制,我们还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级软件都只能做高特权级软件允许它做的,且低特权级软件的超出其能力的要求必须寻求高特权级软件的帮助。在这里的高特权级软件就是低特权级软件的软件执行环境。
为了完成这样的特权级需求,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破会计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行,如果在用户态特权级的执行环境中执行这些指令,会产生异常。处理器在执行不同特权级的执行环境下的指令前进行特权级安全检查。
为了让应用程序获得操作系统的函数服务,采用传统的函数调用方式(即通常的 ``call````ret`` 指令或指令组合将会直接绕过硬件的特权级保护检查。所以要设计新的指令执行环境调用Execution Environment Call简称 ``ecall`` )和执行环境返回(Execution Environment Return简称 ``eret`` )
- ``ecall`` 具有用户态到内核态的执行环境切换能力的函数调用指令RISC-V中就有这条指令
- ``eret`` 具有内核态到用户态的执行环境切换能力的函数返回指令RISC-V中有类似的 ``sret`` 指令)
但硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自己的保护。首先,操作系统需要提供相应的控制流,能在执行 ``eret`` 前准备和恢复用户态执行应用程序的上下文。其次,在应用程序调用 ``ecall`` 指令后,能够保存用户态执行应用程序的上下文,便于后续的恢复;且还要坚持应用程序发出的服务请求是安全的。
.. note::
在实际的CPU如x86、RISC-V等设计了多达4种特权级。对于一般的操作系统而言其实只要两种特权级就够了。
RISC-V 特权级架构
------------------------------------------
RISC-V 架构中一共定义了 4 种特权级:
.. list-table:: RISC-V 特权级
:widths: 30 30 60
:header-rows: 1
:align: center
* - 级别
- 编码
- 名称
* - 0
- 00
- 用户/应用模式 (U, User/Application)
* - 1
- 01
- 监督模式 (S, Supervisor)
* - 2
- 10
- H, Hypervisor
* - 3
- 11
- 机器模式 (M, Machine)
其中,级别的数值越大,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。
之前我们给出过支持应用程序运行的一套 :ref:`执行环境栈 <app-software-stack>` ,现在我们站在特权级架构的角度去重新看待它:
.. image:: PrivilegeStack.png
:align: center
:name: PrivilegeStack
.. _term-see:
和之前一样,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈。其中
内核代码运行在 S 模式上;应用程序运行在 U 模式上。运行在 M 模式上的软件被称为 **监督模式执行环境** (SEE, Supervisor Execution Environment)
,这是站在运行在 S 模式上的软件的视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE它需要在相比 S 模式更高的特权级下运行,
一般情况下在 M 模式上运行。
.. note::
**按需实现 RISC-V 特权级**
RISC-V 架构中,只有 M 模式是必须实现的,剩下的特权级则可以根据跑在 CPU 上应用的实际需求进行调整:
- 简单的嵌入式应用只需要实现 M 模式;
- 带有一定保护能力的嵌入式系统需要实现 M/U 模式;
- 复杂的多任务系统则需要实现 M/S/U 模式。
- 到目前为止,(Hypervisor, H)模式的特权规范还没完全制定好。所以本书不会涉及。
之前我们提到过,执行环境的其中一种功能是在执行它支持的上层软件之前进行一些初始化工作。我们之前提到的引导加载程序会在加电后对整个系统进行
初始化,它实际上是 SEE 功能的一部分,也就是说在 RISC-V 架构上引导加载程序一般运行在 M 模式上。此外,编程语言的标准库也会在执行程序员
编写的逻辑之前进行一些初始化工作,但是在这张图中我们并没有将其展开,而是统一归类到 U 模式软件,也就是应用程序中。
回顾第一章,当时只是实现了简单的支持单个裸机应用的库级别的“三叶虫”操作系统,它和应用程序全程运行在 S 模式下,应用程序很容易破坏没有任何保护的执行环境--操作系统。而在后续的章节中我们会涉及到RISC-V的 M/S/U 三种特权级:其中应用程序和用户态支持库运行在 U 模式的最低特权级;操作系统内核运行在 S 模式特权级(在本章表现为一个简单的批处理系统),形成支撑应用程序和用户态支持库的执行环境;而第一章提到的预编译的 bootloader -- ``RustSBI`` 实际上是运行在更底层的 M 模式特权级下的软件,是操作系统内核的执行环境。整个软件系统就由这三层运行在不同特权级下的不同软件组成。
在特权级相关机制方面本书正文中我们重点关心RISC-V的 S/U 特权级, M 特权级的机制细节则是作为可选内容在 :doc:`/appendix-c/index` 中讲解,有兴趣的读者可以参考。
.. _term-ecf:
.. _term-trap:
执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些情况导致需要用到执行环境中提供的功能,
因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 **不一定**
伴随着 CPU 的 **特权级切换** 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流
(顺序、循环、分支、函数调用)不同的 **异常控制流** (ECF, Exception Control Flow) 被称为 **异常Exception**
.. _term-exception:
用户态应用直接触发从用户态到内核态的 **异常控制流** 的原因总体上可以分为两种:执行 ``Trap类异常`` 指令和执行了会产生 ``Fault类异常`` 的指令 。``Trap类异常`` 指令
就是指用户态软件为获得内核态操作系统的服务功能而发出的特殊指令。 ``Fault类`` 的指令是指用户态软件执行了在内核态操作系统看来是非法操作的指令。下表中我们给出了 RISC-V 特权级定义的会导致从低特权级到高特权级的各种 **异常**
.. list-table:: RISC-V 异常一览表
:align: center
:header-rows: 1
:widths: 30 30 60
* - Interrupt
- Exception Code
- Description
* - 0
- 0
- Instruction address misaligned
* - 0
- 1
- Instruction access fault
* - 0
- 2
- Illegal instruction
* - 0
- 3
- Breakpoint
* - 0
- 4
- Load address misaligned
* - 0
- 5
- Load access fault
* - 0
- 6
- Store/AMO address misaligned
* - 0
- 7
- Store/AMO access fault
* - 0
- 8
- Environment call from U-mode
* - 0
- 9
- Environment call from S-mode
* - 0
- 11
- Environment call from M-mode
* - 0
- 12
- Instruction page fault
* - 0
- 13
- Load page fault
* - 0
- 15
- Store/AMO page fault
.. _term-environment-call:
其中断点(Breakpoint) 和 **执行环境调用** (Environment call) 两个异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 ``陷入``
``trap`` 类指令)是通过在上层软件中执行一条特定的指令触发的:当执行 ``ebreak``
这条指令的之后就会触发断点陷入异常;而执行 ``ecall`` 这条指令的时候则会随着 CPU 当前所处特权级而触发不同的 ``陷入`` 情况。从表中可以看出,当 CPU 分别
处于 M/S/U 三种特权级时执行 ``ecall`` 这条指令会触发三种陷入。
.. _term-sbi:
.. _term-abi:
在这里我们需要说明一下执行环境调用 ``ecall`` ,这是一种很特殊的会产生 ``陷入`` 的指令, :ref:`上图 <PrivilegeStack>` 中相邻两特权级软件之间的接口正是基于这种陷入
机制实现的。M 模式软件 SEE 和 S 模式的内核之间的接口被称为 **监督模式二进制接口** (Supervisor Binary Interface, SBI),而内核和
U 模式的应用程序之间的接口被称为 **应用程序二进制接口** (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— **系统调用**
(syscall, System Call) 。而之所以叫做二进制接口,是因为它和在同一种编程语言内部调用接口不同,是汇编指令级的一种接口。事实上 M/S/U
三个特权级的软件可能分别由不同的编程语言实现,即使是用同一种编程语言实现的,其调用也并不是普通的函数调用执行流,而是**陷入异常控制流** ,在该过程中会
切换 CPU 特权级。因此只有将接口下降到汇编指令级才能够满足其通用性和灵活性。
可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的、且不会产生什么撼动高特权级软件的事情,一旦低特权级软件的要求超出了其能力范围,
就必须寻求高特权级软件的帮助。因此,在一条执行流中我们经常能够看到特权级切换。如下图所示:
.. image:: EnvironmentCallFlow.png
:align: center
:name: environment-call-flow
.. _term-csr:
其他的异常则一般是在执行某一条指令的时候发生了某种错误(如除零、无效地址访问、无效指令等),或处理器认为处于当前特权级下执行当前指令是高特权级指令或会访问不应该访问的高特权级的资源(可能危害系统)。碰到这些情况,就需要需要将控制转交给高特权级的软件(如操作系统)来处理。当处理错误恢复后,则可重新回到低优先级软件去执行;如果不能回复错误,那高特权级软件可以杀死和清除低特权级软件,免破坏整个执行环境。
.. _term-csr-instr:
RISC-V的特权指令
^^^^^^^^^^^^^^^^^^^^^^^^^
与特权级无关的一般的指令和通用寄存器 ``x0~x31`` 在任何特权级都可以任意执行。而每个特权级都对应一些特殊指令和 **控制状态寄存器** (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不只是具有有读写 CSR 的指令,还有其他功能的特权指令。
如果低优先级下的处理器执行了高优先级的指令,会产生非法指令错误的异常,于是位于高特权级的执行环境能够得知低优先级的软件出现了该错误,这个错误一般是不可恢复的,此时一般它会将上层的低特权级软件终止。这在某种程度上体现了特权级保护机制的作用。
在RISC-V中会有两类低优先级U模式下运行高优先级S模式的指令
- 指令本身属于高特权级的指令,如 ``sret`` 指令表示从S模式返回到U模式
- 指令访问了 :ref:`S模式特权级下才能访问的寄存器 <term-s-mod-csr>` 或内存如表示S模式系统状态的 **控制状态寄存器** ``sstatus`` 等。
.. list-table:: RISC-V S模式特权指令
:align: center
:header-rows: 1
:widths: 30 60
* - 指令
- 含义
* - sret
- 从S模式返回U模式。在U模式下执行会产生非法指令异常
* - wfi
- 处理器在空闲时进入低功耗状态等待中断。在U模式下执行会尝试非法指令异常
* - sfence.vma
- 刷新TLB缓存。在U模式下执行会尝试非法指令异常
* - 访问S模式CSR的指令
- 通过访问 :ref:`sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR <term-s-mod-csr>` 来改变系统状态。在U模式下执行会尝试非法指令异常
在下一节中,我们将看到 :ref:`在U模式下的用户态应用程序 <term-csr-instr-app>` 如果执行上述S模式特权指令指令将会产生非法指令异常从而看出RISC-V的特权模式设计在一定程度上提供了对操作系统的保护。
..
* - mret
- 从M模式返回S/U模式。在S/U模式下执行会产生非法指令异常
随着特权级的逐渐降低,硬件的能力受到限制,
从每一个特权级看来,比它特权级更低的部分都可以看成是它的应用。(这个好像没啥用?)
M 模式是每个 RISC-V CPU 都需要实现的模式,而剩下的模式都是可选的。常见的模式组合:普通嵌入式应用只需要在 M 模式上运行;追求安全的
嵌入式应用需要在 M/U 模式上运行;像 Unix 这样比较复杂的系统这需要 M/S/U 三种模式。
RISC-V 特权级规范中给出了一些特权寄存器和特权指令...
重要的是保护,也就是特权级的切换。当 CPU 处于低特权级的时候如果发生了错误或者一些需要处理的情况CPU 会切换到高特权级进行处理。这个
就是所谓的 Trap 机制。
RISC-V 架构规范分为两部分: `RISC-V 无特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf>`_
`RISC-V 特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMFDQC-and-Priv-v1.11/riscv-privileged-20190608.pdf>`_
RISC-V 无特权级规范中给出的指令和寄存器无论在 CPU 处于哪个特权级下都可以使用。

View File

@ -0,0 +1,373 @@
实现应用程序
===========================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
本节主要讲解如何设计实现被批处理系统逐个加载并运行的应用程序。它们是假定在 U 特权级模式运行的前提下而设计、编写的。实际上,如果应用程序的代码都符合它要运行的某特权级的约束,那它完全可能在某特权级中运行。保证应用程序的代码在 U 模式运行是我们接下来将实现的批处理系统的任务。其涉及的设计实现要点是:
- 应用程序的内存布局
- 应用程序发出的系统调用
从某种程度上讲,这里设计的应用程序与第一章中的最小用户态执行环境有很多相同的地方。即设计一个应用程序,能够在用户态通过操作系统提供的服务完成自身的功能。
应用程序设计
-----------------------------
应用程序的实现放在项目根目录的 ``user`` 目录下,它和第一章的裸机应用不同之处在于以下几点。
项目结构
^^^^^^^^^^^^^^^^^^^^^^
我们看到 ``user/src`` 目录下面多出了一个 ``bin`` 目录。``bin`` 里面有多个文件,每个文件都是一个用户程序,目前里面有三个程序,分别是:
- ``00hello_world``:在屏幕上打印一行 ``Hello, world!``
- ``01store_fault``:访问一个非法的物理地址,测试批处理系统是否会被该错误影响;
- ``02power``:一个略微复杂的、行为不断在计算和打印字符串间切换的程序。
批处理系统会按照文件名开头的编号从小到大的顺序加载并运行它们。
打开其中任意一个文件,会看到里面只有一个 ``main`` 函数,因此这很像是我们日常利用高级语言编程,只需要在单个文件中给出主逻辑的实现即可。
我们还能够看到代码中尝试引入了外部库:
.. code-block:: rust
#[macro_use]
extern crate user_lib;
这个外部库其实就是 ``user`` 目录下的 ``lib.rs`` 以及它引用的若干子模块中。至于这个外部库为何叫 ``user_lib`` 而不叫 ``lib.rs``
所在的目录的名字 ``user`` ,是因为在 ``user/Cargo.toml`` 中我们对于库的名字进行了设置: ``name = "user_lib"`` 。它作为
``bin`` 目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库。
``lib.rs`` 中我们定义了用户库的入口点 ``_start``
.. code-block:: rust
:linenos:
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start() -> ! {
clear_bss();
exit(main());
panic!("unreachable after sys_exit!");
}
第 2 行使用 Rust 的宏将 ``_start`` 这段代码编译后的汇编代码中放在一个名为 ``.text.entry`` 的代码段中,方便我们在后续链接的时候
调整它的位置使得它能够作为用户库的入口。
而从第 4 行开始我们能够看到进入用户库入口之后,首先和第一章一样手动清空需要被零初始化 ``.bss`` 段(很遗憾到目前为止底层的批处理系统还
没有这个能力,所以我们只能在用户库中完成),然后是调用 ``main`` 函数得到一个类型为 ``i32`` 的返回值。
第 5 行我们调用后面会提到的用户库提供的 ``exit`` 接口退出应用程序并将这个返回值告知批处理系统。
我们还在 ``lib.rs`` 中看到了另一个 ``main``
.. code-block:: rust
:linenos:
#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
panic!("Cannot find main!");
}
第 1 行,我们使用 Rust 的宏将其函数符号 ``main`` 标志为弱链接。这样在最后链接的时候,虽然在 ``lib.rs````bin`` 目录下的某个
应用程序都有 ``main`` 符号,但由于 ``lib.rs`` 中的 ``main`` 符号是弱链接,链接器会使用 ``bin`` 目录下的应用主逻辑作为 ``main``
这里我们主要是进行某种程度上的保护,如果在 ``bin`` 目录下找不到任何 ``main`` ,那么编译也能够通过,并会在运行时报错。
为了上述这些链接操作,我们需要在 ``lib.rs`` 的开头加入:
.. code-block:: rust
#![feature(linkage)]
.. _term-app-mem-layout:
内存布局
^^^^^^^^^^^^^^^^^^^^^^
``user/.cargo/config`` 中,我们和第一章一样设置链接时使用链接脚本 ``user/src/linker.ld`` 。在其中我们做的重要的事情是:
- 将程序的起始物理地址调整为 ``0x80400000`` ,三个应用程序都会被加载到这个物理地址上运行;
- 将 ``_start`` 所在的 ``.text.entry`` 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 ``0x80400000`` 就已经进入了
用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
- 提供了最终生成可执行文件的 ``.bss`` 段的起始和终止地址,方便 ``clear_bss`` 函数使用。
其余的部分和第一章基本相同。
.. _term-call-syscall:
系统调用
^^^^^^^^^^^^^^^^^^^^^^
在子模块 ``syscall`` 中我们作为应用程序来通过 ``ecall`` 调用批处理系统提供的接口,由于应用程序运行在 U 模式, ``ecall`` 指令会触发
名为 ``Environment call from U-mode`` 的异常,并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于
S 模式的批处理系统和 U 模式的应用程序之间,从上一节我们可以知道,这个接口可以被称为 ABI 或者系统调用。现在我们不关心底层的批处理系统如何
提供应用程序所需的功能,只是站在应用程序的角度去使用即可。
在本章中,应用程序和批处理系统之间约定如下两个系统调用:
.. code-block:: rust
:caption: 第二章新增系统调用
/// 功能:将内存中缓冲区中的数据写入文件。
/// 参数:`fd` 表示待写入文件的文件描述符;
/// `buf` 表示内存中缓冲区的起始地址;
/// `len` 表示内存中缓冲区的长度。
/// 返回值:返回成功写入的长度。
/// syscall ID64
fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize;
/// 功能:退出应用程序并将返回值告知批处理系统。
/// 参数:`xstate` 表示应用程序的返回值。
/// 返回值:该系统调用不应该返回。
/// syscall ID93
fn sys_exit(xstate: usize) -> !;
我们知道系统调用实际上是汇编指令级的二进制接口,因此这里给出的只是使用 Rust 语言描述的版本。在实际调用的时候,我们需要按照 RISC-V 调用
规范在合适的寄存器中放置系统调用的参数,然后执行 ``ecall`` 指令触发 Trap。在 Trap 回到 U 模式的应用程序代码之后,会从 ``ecall``
下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。
在 RISC-V 调用规范中,和函数调用的情形类似,约定寄存器 ``a0~a6`` 保存系统调用的参数, ``a0~a1`` 保存系统调用的返回值。有些许不同的是
寄存器 ``a7`` 用来传递 syscall ID这是因为所有的 syscall 都是通过 ``ecall`` 指令触发的,除了各输入参数之外我们还额外需要一个寄存器
来保存要请求哪个系统调用。由于这超出了 Rust 语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ``ecall`` 指令的插入:
.. code-block:: rust
:linenos:
// user/src/syscall.rs
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
llvm_asm!("ecall"
: "={x10}" (ret)
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
: "memory"
: "volatile"
);
}
ret
}
第 3 行,我们将所有的系统调用都封装成 ``syscall`` 函数,可以看到它支持传入 syscall ID 和 3 个参数。
第 6 行开始,我们使用 Rust 提供的 ``llvm_asm!`` 宏在代码中内嵌汇编,在本行也给出了具体要插入的汇编指令,也就是 ``ecall``,但这并不是
全部,后面我们还需要进行一些相关设置。这个宏在 Rust 中还不稳定,因此我们需要在 ``lib.rs`` 开头加入 ``#![feature(llvm_asm)]``
此外,编译器无法判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。
Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
.. code-block:: rust
llvm_asm!(assembly template
: output operands
: input operands
: clobbers
: options
);
下面逐行进行说明。
第 7 行指定输出操作数。这里由于我们的系统调用返回值只有一个 ``isize`` ,根据调用规范它会被保存在 ``a0`` 寄存器中。在双引号内,我们
可以对于使用的操作数进行限制,由于是输出部分,限制的开头必须是一个 ``=`` 。我们可以在限制内使用一对花括号再加上一个寄存器的名字告诉
编译器汇编的输出结果会保存在这个寄存器中。我们将声明出来用来保存系统调用返回值的变量 ``ret`` 包在一对普通括号里面放在操作数限制的
后面,这样可以把变量和寄存器建立联系。于是,在系统调用返回之后我们就能在变量 ``ret`` 中看到返回值了。注意,变量 ``ret`` 必须为可变
绑定,否则无法通过编译,这也说明在 unsafe 块内编译器还是会进行力所能及的安全检查。
第 8 行指定输入操作数。由于是输入部分,限制的开头不用加上 ``=`` 。同时在限制中设置使用寄存器 ``a0~a2`` 来保存系统调用的参数,以及
寄存器 ``a7`` 保存 syscall ID ,而它们分别 ``syscall`` 的参数变量 ``args````id`` 绑定。
第 9 行用于告知编译器插入的汇编代码会造成的一些影响以防止编译器在不知情的情况下误优化。常用的使用方法是告知编译器某个寄存器在执行嵌入
的汇编代码中的过程中会发生变化。我们这里则是告诉编译器:程序在执行嵌入汇编代码中指令的时候会修改内存。这能给编译器提供更多信息以生成正确的代码。
第 10 行用于告知编译器将我们在程序中给出的嵌入汇编代码保持原样放到最终构建的可执行文件中。如果不这样做的话,编译器可能会把它和其他代码
一视同仁并放在一起进行一些我们期望之外的优化。为了保证语义的正确性,一些比较关键的汇编代码需要加上该选项。
上面这一段汇编代码的含义和内容与第一章中的 :ref:`第一章中U-Mode应用程序中的系统调用汇编代码 <term-llvm-syscall>` 的是一致的。与 :ref:`第一章中的RustSBI输出到屏幕的SBI调用汇编代码 <term-llvm-sbicall>` 涉及的汇编指令一样,但传递参数的寄存器的含义是不同的。有兴趣的读者可以回顾第一章的 ``console.rs````sbi.rs``
.. note::
**Rust 语法卡片:内联汇编**
我们这里使用的 ``llvm_asm!`` 宏是将 Rust 底层 IR LLVM 中提供的内联汇编包装成的,更多信息可以参考 `llvm_asm 文档 <https://doc.rust-lang.org/unstable-book/library-features/llvm-asm.html>`_
在未来的 Rust 版本推荐使用功能更加强大且方便易用的 ``asm!`` 宏,但是目前还未稳定,可以查看 `inline-asm RFC <https://doc.rust-lang.org/beta/unstable-book/library-features/asm.html>`_ 了解最新进展。
于是 ``sys_write````sys_exit`` 只需将 ``syscall`` 进行包装:
.. code-block:: rust
:linenos:
// user/src/syscall.rs
const SYSCALL_WRITE: usize = 64;
const SYSCALL_EXIT: usize = 93;
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
pub fn sys_exit(xstate: i32) -> isize {
syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
}
.. _term-fat-pointer:
注意 ``sys_write`` 使用一个 ``&[u8]`` 切片类型来描述缓冲区,这是一个 **胖指针** (Fat Pointer),里面既包含缓冲区的起始地址,还
包含缓冲区的长度。我们可以分别通过 ``as_ptr````len`` 方法取出它们并独立的作为实际的系统调用参数。
我们将上述两个系统调用在用户库 ``user_lib`` 中进一步封装,从而更加接近在 Linux 等平台的实际体验:
.. code-block:: rust
:linenos:
// user/src/lib.rs
use syscall::*;
pub fn write(fd: usize, buf: &[u8]) -> isize { sys_write(fd, buf) }
pub fn exit(exit_code: i32) -> isize { sys_exit(exit_code) }
我们把 ``console`` 子模块中 ``Stdout::write_str`` 改成基于 ``write`` 的实现,且传入的 ``fd`` 参数设置为 1它代表标准输出
也就是输出到屏幕。目前我们不需要考虑其他的 ``fd`` 选取情况。这样,应用程序的 ``println!`` 宏借助系统调用变得可用了。
参考下面的代码片段:
.. code-block:: rust
:linenos:
// user/src/console.rs
const STDOUT: usize = 1;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
write(STDOUT, s.as_bytes());
Ok(())
}
}
``exit`` 接口则在用户库中的 ``_start`` 内使用,当应用程序主逻辑 ``main`` 返回之后,使用它退出应用程序并将返回值告知
底层的批处理系统。
编译生成应用程序二进制码
-------------------------------
这里简要介绍一下应用程序的自动构建。只需要在 ``user`` 目录下 ``make build`` 即可:
1. 对于 ``src/bin`` 下的每个应用程序,在 ``target/riscv64gc-unknown-none-elf/release`` 目录下生成一个同名的 ELF 可执行文件;
2. 使用 objcopy 二进制工具将上一步中生成的 ELF 文件删除所有 ELF header 和符号得到 ``.bin`` 后缀的纯二进制镜像文件。它们将被链接
进内核并由内核在合适的时机加载到内存。
实现操作系统前执行应用程序
-----------------------------------
我们还没有实现操作系统,能提前执行或测试应用程序吗?可以! 这是因为我们除了一个能模拟一台RISC-V 64 计算机的全系统模拟器 ``qemu-system-riscv64`` 外,还有一个 :ref:`直接支持运行RISC-V64 用户程序的半系统模拟器qemu-riscv64 <term-qemu-riscv64>`
.. note::
如果想让用户态应用程序在Linux和在我们自己写的OS上执行效果一样需要做到二者的系统调用的接口是一样的包括系统调用编号参数约定的具体的寄存器和栈等
.. _term-csr-instr-app:
假定我们已经完成了编译并生成了ELF 可执行文件格式的应用程序,我们就可以来试试。首先看看应用程序执行 :ref:`RV64的S模式特权指令 <term-csr-instr>` 会出现什么情况。
.. note::
下载编译特权指令的应用需要获取
.. code-block:: console
$ git clone -b v4-illegal-priv-code-csr-in-u-mode-app https://github.com/chyyuu/os_kernel_lab.git
$ cd os_kernel_lab/user
$ make build
我们先看看代码:
.. code-block:: rust
:linenos:
// usr/src/bin/03priv_intr.rs
...
println!("Hello, world!");
unsafe {
llvm_asm!("sret"
: : : :
);
}
...
在上述代码中,在显示 ``Hello, world`` 字符串后,会执行 ``sret`` 特权指令。
.. code-block:: rust
:linenos:
// usr/src/bin/04priv_intr.rs
...
println!("Hello, world!");
let mut sstatus = sstatus::read();
sstatus.set_spp(SPP::User);
...
在上述代码中,在显示 ``Hello, world`` 字符串后,会读写 ``sstatus`` 特权CSR。
.. code-block:: console
$ cd user
$ cd target/riscv64gc-unknown-none-elf/release/
$ ls
00hello_world 01store_fault 02power
03priv_intr 04priv_csr
...
# 上面的文件就是ELF格式的应用程序
$ qemu-riscv64 ./03priv_intr
Hello, world!
非法指令 (核心已转储)
# 执行特权指令出错
$ qemu-riscv64 ./04priv_csr
Hello, world!
非法指令 (核心已转储)
# 执行访问特权级CSR的指令出错
看来RV64的特权级机制确实有用。那对于一般的应用程序``qemu-riscv64`` 模拟器下能正确执行吗?
.. code-block:: console
$ cd user
$ cd target/riscv64gc-unknown-none-elf/release/
$ ls
00hello_world 01store_fault 02power
03priv_intr 04priv_csr
...
# 上面的文件就是ELF格式的应用程序
$ qemu-riscv64 ./00hello_world
Hello, world!
# 正确显示了字符串
$ qemu-riscv64 01store_fault
qemu-riscv64 01store_fault
Into Test store_fault, we will insert an invalid store operation...
Kernel should kill this application!
段错误 (核心已转储)
# 故意访问了一个非法地址导致应用和qemu-riscv64被Linux内核杀死
$ qemu-riscv64 02power
3^10000=5079
3^20000=8202
3^30000=8824
3^40000=5750
3^50000=3824
3^60000=8516
3^70000=2510
3^80000=9379
3^90000=2621
3^100000=2749
Test power OK!
# 正确地完成了计算
三个应用都能够执行并顺利结束是由于得到了本机操作系统Linux的支持。我们期望我们在下一节开始实现的泥盆纪“邓式鱼”操作系统也能够正确上面的应用程序。

View File

@ -0,0 +1,222 @@
.. _term-batchos:
实现批处理操作系统
==============================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
目前本章设计的批处理操作系统--泥盆纪“邓式鱼”操作系统,还没有文件/文件系统的机制与设计实现,所以还缺少一种类似文件系统那样的松耦合灵活放置应用程序和加载执行应用程序的机制。这就需要设计一种简洁的程序放置和加载方式,能够在批处理操作系统与应用程序之间建立联系的纽带。这主要包括两个方面:
- 静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
这里与硬件相关且比较困难的地方是如何让在内核态的批处理操作系统启动应用程序,且能让应用程序在用户态正常执行。本节会讲大致过程,而具体细节将放到下一节具体讲解。
将应用程序链接到内核
--------------------------------------------
在本章中,我们把应用程序的二进制镜像文件作为内核的数据段链接到内核里面,因此内核需要知道内含的应用程序的数量和它们的位置,这样才能够在运行时
对它们进行管理并能够加载到物理内存。
``os/src/main.rs`` 中能够找到这样一行:
.. code-block:: rust
global_asm!(include_str!("link_app.S"));
这里我们引入了一段汇编代码 ``link_app.S`` ,它一开始并不存在,而是在构建的时候自动生成的。当我们使用 ``make run`` 让系统成功运行起来
之后,我们可以先来看一看里面的内容:
.. code-block:: asm
:linenos:
# os/src/link_app.S
.align 3
.section .data
.global _num_app
_num_app:
.quad 3
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_2_end
.section .data
.global app_0_start
.global app_0_end
app_0_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin"
app_0_end:
.section .data
.global app_1_start
.global app_1_end
app_1_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
app_1_end:
.section .data
.global app_2_start
.global app_2_end
app_2_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin"
app_2_end:
可以看到第 13 行开始的三个数据段分别插入了三个应用程序的二进制镜像,并且各自有一对全局符号 ``app_*_start, app_*_end`` 指示它们的
开始和结束位置。而第 3 行开始的另一个数据段相当于一个 64 位整数数组。数组中的第一个元素表示应用程序的数量,后面则按照顺序放置每个应用
程序的起始地址,最后一个元素放置最后一个应用程序的结束位置。这样每个应用程序的位置都能从该数组中相邻两个元素中得知。这个数组所在的位置
同样也由全局符号 ``_num_app`` 所指示。
这个文件是在 ``cargo build`` 的时候,由脚本 ``os/build.rs`` 控制生成的。有兴趣的读者可以参考其代码。
找到并加载应用程序二进制码
-----------------------------------------------
能够找到并加载应用程序二进制码的应用管理器 ``AppManager`` 是“邓式鱼”操作系统的核心组件。我们在 ``os````batch`` 子模块中实现一个应用管理器,它的主要功能是:
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
应用管理器 ``AppManager`` 结构体定义
如下:
.. code-block:: rust
struct AppManager {
inner: RefCell<AppManagerInner>,
}
struct AppManagerInner {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
unsafe impl Sync for AppManager {}
这里我们可以看出,上面提到的应用管理器需要保存和维护的信息都在 ``AppManagerInner`` 里面,而结构体 ``AppManager`` 里面只是保存了
一个指向 ``AppManagerInner````RefCell`` 智能指针。这样设计的原因在于:我们希望将 ``AppManager`` 实例化为一个全局变量使得
任何函数都可以直接访问,但是里面的 ``current_app`` 字段表示当前执行到了第几个应用,它会在系统运行期间发生变化。因此在声明全局变量
的时候一种自然的方法是利用 ``static mut``。但是在 Rust 中,任何对于 ``static mut`` 变量的访问都是 unsafe 的,而我们要尽可能
减少 unsafe 的使用来更多的让编译器负责安全性检查。
此外,为了让 ``AppManager`` 能被直接全局实例化,我们需要将其标记为 ``Sync``
.. note::
**为什么对于 static mut 的访问是 unsafe 的**
**为什么要将 AppManager 标记为 Sync**
可以参考附录ARust 快速入门的并发章节。
.. _term-interior-mutability:
于是,我们利用 ``RefCell`` 来提供 **内部可变性** (Interior Mutability)
所谓的内部可变性就是指在我们只能拿到 ``AppManager`` 的不可变借用,意味着同样也只能
拿到 ``AppManagerInner`` 的不可变借用的情况下依然可以修改 ``AppManagerInner`` 里面的字段。
使用 ``RefCell::borrow/RefCell::borrow_mut`` 分别可以拿到 ``RefCell`` 里面内容的不可变借用/可变借用,
``RefCell`` 会在运行时维护当前它管理的对象的已有借用状态,并在访问对象时进行借用检查。于是 ``RefCell::borrow_mut`` 就是我们实现内部可变性的关键。
我们这样初始化 ``AppManager`` 的全局实例:
.. code-block:: rust
lazy_static! {
static ref APP_MANAGER: AppManager = AppManager {
inner: RefCell::new({
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = unsafe { num_app_ptr.read_volatile() };
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManagerInner {
num_app,
current_app: 0,
app_start,
}
}),
};
}
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。注意其中对于切片类型的使用能够很大程度上简化编程。
这里我们使用了外部库 ``lazy_static`` 提供的 ``lazy_static!`` 宏。要引入这个外部库,我们需要加入依赖:
.. code-block:: toml
# os/Cargo.toml
[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
``lazy_static!`` 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置一个初始值,但是有些全局变量依赖于运行期间
才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,也即重新设置初始值之后才能使用。如果我们手动实现的话有诸多不便之处,
比如需要把这种全局变量声明为 ``static mut`` 并衍生出很多 unsafe code。这种情况下我们可以使用 ``lazy_static!`` 宏来帮助我们解决
这个问题。这里我们借助 ``lazy_static!`` 声明了一个 ``AppManager`` 结构的名为 ``APP_MANAGER`` 的全局实例,且只有在它第一次被使用到
的时候才会进行实际的初始化工作。
因此,借助 Rust 核心库提供的 ``RefCell`` 和外部库 ``lazy_static!``,我们就能在避免 ``static mut`` 声明的情况下以更加优雅的Rust风格使用全局变量。
``AppManagerInner`` 的方法中, ``print_app_info/get_current_app/move_to_next_app`` 都相当简单直接,需要说明的是 ``load_app``
.. code-block:: rust
:linenos:
unsafe fn load_app(&self, app_id: usize) {
if app_id >= self.num_app {
panic!("All applications completed!");
}
println!("[kernel] Loading app_{}", app_id);
// clear icache
llvm_asm!("fence.i" :::: "volatile");
// clear app area
(APP_BASE_ADDRESS..APP_BASE_ADDRESS + APP_SIZE_LIMIT).for_each(|addr| {
(addr as *mut u8).write_volatile(0);
});
let app_src = core::slice::from_raw_parts(
self.app_start[app_id] as *const u8,
self.app_start[app_id + 1] - self.app_start[app_id]
);
let app_dst = core::slice::from_raw_parts_mut(
APP_BASE_ADDRESS as *mut u8,
app_src.len()
);
app_dst.copy_from_slice(app_src);
}
这个方法负责将参数 ``app_id`` 对应的应用程序的二进制镜像加载到物理内存以 ``0x80400000`` 开头的位置,这个位置是批处理操作系统和应用程序
之间约定的常数地址,回忆上一小节中,我们也调整应用程序的内存布局以同一个地址开头。第 8 行开始,我们首先将一块内存清空,然后找到待加载应用
二进制镜像的位置,并将它复制到正确的位置。它本质上是把数据从一块内存复制到另一块内存,从批处理操作系统的角度来看是将它数据段的一部分复制到了它
程序之外未知的地方。在这一点上也体现了冯诺依曼计算机的 ``代码即数据`` 的特征。
.. _term-dcache:
.. _term-icache:
注意第 7 行我们插入了一条奇怪的汇编指令 ``fence.i`` ,它是用来清理 i-cache 的。我们知道缓存是存储层级结构中提高访存速度的很重要一环。
而 CPU 对物理内存所做的缓存又分成 **数据缓存** (d-cache) 和 **指令缓存** (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。在取指
的时候,对于一个指令地址, CPU 会先去 i-cache 里面看一下它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过
总线和内存通信。通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,我们会修改会被 CPU 取指的内存
区域,这会使得 i-cache 中含有与内存中不一致的内容。因此我们这里必须使用 ``fence.i`` 指令手动清空 i-cache ,让里面所有的内容全部失效,
才能够保证正确性。
.. warning::
**模拟器与真机的不同之处**
至少在 Qemu 模拟器的默认配置下,各类缓存如 i-cache/d-cache/TLB 都处于机制不完全甚至完全不存在的状态。目前在 Qemu 平台上,即使我们
不加上刷新 i-cache 的指令,大概率也是能够正常运行的。但在 K210 真机上就会看到错误。
``batch`` 子模块对外暴露出如下接口:
- ``init`` :调用 ``print_app_info`` 的时候第一次用到了全局变量 ``APP_MANAGER`` ,它也是在这个时候完成初始化;
- ``run_next_app`` :批处理操作系统的核心操作,即加载并运行下一个应用程序。当批处理操作系统完成初始化或者一个应用程序运行结束或出错之后会调用
该函数。我们下节再介绍其具体实现。

View File

@ -0,0 +1,626 @@
.. _term-trap-handle:
实现特权级的切换
===========================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
由于有特权级机制的存在应用程序在用户态特权级运行时是无法直接通过函数调用访问处于内核态特权级的批处理操作系统内核中的函数的。所以会通过某种机制进行特权级之间的切换使得用户态应用程序可以得到内核态操作系统函数的服务。本节将讲解在RISC-V 64处理器提供的U/S特权级下批处理操作系统和应用程序如何相互配合完成特权级切换的。
RISC-V特权级切换
---------------------------------------
特权级切换的起因
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们知道,批处理操作系统被设计为运行在 S 模式这是由RustSBI提供的 SEE(Supervisor Execution Environment) 所保证的;而应用程序被设计为运行在 U 模式,这个则是由我们的批处理操作系统提供的 AEE(Application Execution Environment)
所保证的。批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序之前进行一些初始化工作,并监控应用程序的执行,具体体现在:
- 当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
- 当应用程序发起系统调用即发出Trap )之后,需要到批处理操作系统中进行处理;
- 当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
- 当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用 ``sys_exit`` 来实现的)。
这些处理都涉及到特权级切换,因此都需要硬件和操作系统协同提供的特权级切换机制。
特权级切换相关的控制状态寄存器
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当从一般意义上讨论 RISC-V 架构的 Trap 机制时,通常需要注意两点:
- 在触发 Trap 之前 CPU 运行在哪个特权级;
- 以及 CPU 需要切换到哪个特权级来处理该 Trap 并在处理完成之后返回原特权级。
但本章中我们仅考虑当 CPU 在 U 特权级运行用户程序的时候触发 Trap并切换到 S 特权级的批处理操作系统的对应服务代码来进行处理。
.. _term-s-mod-csr:
在 RISC-V 架构中,关于 Trap 有一条重要的规则:在 Trap 前的特权级不会高于Trap后的特权级。因此如果触发 Trap 之后切换到 S 特权级(下称 Trap 到 S
说明 Trap 发生之前 CPU 只能运行在 S/U 特权级。但无论如何,只要是 Trap 到 S 特权级,操作系统就会使用 S 特权级中与 Trap 相关的 **控制状态寄存器** (CSR, Control and Status Register) 来辅助 Trap
处理。我们在编写运行在 S 特权级的批处理操作系统中的 Trap 处理相关代码的时候就需要使用如下所示的S模式的CSR寄存器。
.. list-table:: 进入 S 特权级 Trap 的相关 CSR
:header-rows: 1
:align: center
:widths: 30 100
* - CSR 名
- 该 CSR 与 Trap 相关的功能
* - sstatus
- ``SPP`` 等字段给出 Trap 发生之前 CPU 处在哪个特权级S/U等信息
* - sepc
- 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址
* - scause
- 描述 Trap 的原因
* - stval
- 给出 Trap 附加信息
* - stvec
- 控制 Trap 处理代码的入口地址
.. note::
**S模式下最重要的 sstatus 寄存器**
注意 ``sstatus`` 是 S 特权级最重要的 CSR可以从很多方面控制 S 特权级的CPU行为和执行状态。
.. chy
我们在这里先给出它在 Trap 处理过程中的作用。
特权级切换
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
大多数的 Trap (陷入) 发生的场景都是在执行某条指令(如 ``ecall`` 之后CPU 发现触发了一个 Trap并需要进行特殊处理并涉及到 :ref:`执行环境切换 <term-ee-switch>` 。具体而言,用户态执行环境中的应用程序通过 ``ecall`` 指令
向内核态执行环境在的操作系统请求某项服务功能,那么处理器和操作系统会完成到内核态执行环境的切换,并在操作系统完成服务后,再次切换到用户态执行环境,然后应用程序会紧接着``ecall`` 指令的后一条指令位置处继续执行,参考 :ref:`图示 <environment-call-flow>`
.. chy : 这条触发 Trap 的指令和进入 Trap 之前执行的最后一条指令不一定是同一条。
.. chy 下面的内容合并到第零章的os 抽象一节中, 执行环境切换, context等
_term-execution-of-thread:
回顾第一章的 :ref:`函数调用与栈 <function-call-and-stack>` ,我们知道在一个固定的 CPU 上,只要有一个栈作为存储空间,我们就能以多种
普通控制流(顺序、分支、循环结构和多层嵌套函数调用)组合的方式,来一行一行的执行源代码(以编程语言级的视角),也是一条一条的执行汇编指令
(以汇编语言级的视角)。只考虑普通控制流,那么从某条指令开始记录,该 CPU 可用的所有资源,包括自带的所有通用寄存器(包括虚拟的描述当前执行
指令地址的寄存器 pc )和当前特权级可用的 CSR 以及位于内存中的一块栈空间,它们会随着指令的执行而逐渐发生变化。这种局限在普通控制流(相对于 :ref:`异常控制流 <term-ecf>` 而言)之内的
连续指令执行和与之同步的对相关资源的改变我们用一个新名词 **执行流** (Execution Flow) 来命名。执行流的状态是一个由它衍生出来的
概念,表示截止到某条指令执行完毕所有相关资源(包括寄存器、栈)的状态集合,它完整描述了自记录起始之后该执行流的指令执行历史。
.. note::
实际上 CPU 还有其他资源可用:
- 内存除了与执行流绑定的栈之外的其他存储空间,比如程序中的数据段;
- 外围 I/O 设备。
它们也会在执行期间动态发生变化。但它们可能由多条执行流共享,难以清晰的从中单独区分出某一条执行流的状态变化。因此在执行流概念中,
我们不将其纳入考虑。
.. chy 内容与第一段有重复
让我们通过从 U 特权级 Trap 到 S 特权级的切换过程(这是一个特例,实际上在 Trap 的前后,特权级也可以不变)来分析一下在 Trap 前后发生了哪些事情。首先假设CPU 正处在 U 特权级跑着应用程序的代码。在执行完某一条指令之后, CPU 发现一个
中断/异常被触发,于是必须将应用执行流暂停,先 Trap 到更高的 S 特权级去执行批处理操作系统提供的相应服务代码,等待执行
完了之后再回过头来恢复到并继续执行应用程序的执行流。
.. chy 内容与第一段有重复
我们可以将 CPU 在 S 特权级执行的那一段指令序列也看成一个控制流,因为它全程只是以普通控制流的模式在 S 特权级执行。这个控制流的意义就在于
处理 Trap ,我们可以将其称之为 **Trap 专用** 控制流,它在 Trap 触发的时候开始,并于 Trap 处理完毕之后结束。于是我们可以从执行环境切换和控制流的角度来看待
操作系统对Trap 的整个处理过程: CPU 从用户态执行环境中的应用程序的普通控制流转到内核态执行环境中的 Trap 执行流,然后再切换回去继续运行应用程序。站在应用程序的角度, 由操作系统和CPU协同完成的Trap 机制对它是完全透明的,无论应用程序在它的哪一条指令执行结束后进入 Trap ,它总是相信在 Trap 结束之后 CPU 能够在与被打断的时候"相同"的执行环境中继续正确的运行应用程序的指令。
.. chy
.. note::
.. chy 内容与第一段有重复
这里所说的相同并不是绝对相同,但是其变化是完全能够被应用程序预知到的。比如应用程序通过 ``ecall`` 指令请求底层高特权级软件的功能,
由调用规范它知道 Trap 之后 ``a0~a1`` 两个寄存器会被用来保存返回值,所以会发生变化。这个信息是应用程序明确知晓的,但某种程度上
确实也体现了执行流的变化。
应用程序被切换回来之后需要从暂停的位置恢复并继续执行,这需要在切换前后维持应用程序的上下文保持不变。应用程序的上下文可以分为通用寄存器和栈两部分。
由于每个 CPU 在不同特权级下共享一套通用寄存器,所以在运行操作系统的 Trap
处理过程中,操作系统也会用到这些寄存器,这将应用程序的上下文。因此,就和函数调用需要保存函数调用上下文/活动记录一样,在执行操作系统的 Trap 处理过程的最开始,
即修改这些寄存器之前,我们需要在某个地方(就是某内存块或内核的栈)保存这些寄存器并在后续恢复这些寄存器。
除了通用寄存器之外还有一些可能在处理 Trap 过程中会被修改的
CSR比如 CPU 所在的特权级。我们要保证它们的变化在我们的预期之内,比如对于特权级而言应该是 Trap 之前在 U 特权级,处理 Trap 的时候在 S
特权级,返回之后又需要回到 U 特权级。而对于栈问题则相对简单,只要两个执行流用来记录执行历史的栈所对应的内存区域不相交,就不会产生令我们
头痛的覆盖问题,也就无需进行保存/恢复。
执行流切换的相关机制一部分由硬件帮我们完成,另一部分则需要由操作系统来实现。
.. _trap-hw-mechanism:
特权级切换的硬件控制机制
-------------------------------------
当 CPU 执行完一条指令并准备从用户特权级 Trap 到 S 特权级的时候,硬件会自动帮我们做这些事情:
- ``sstatus````SPP`` 字段会被修改为 CPU 当前的特权级U/S
- ``sepc`` 会被修改为 Trap 回来之后默认会执行的下一条指令的地址。当 Trap 是一个异常的时候,它实际会被修改成 Trap 之前执行的最后一条
指令的地址。
- ``scause/stval`` 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 ``stvec`` 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后开始向下执行。
.. note::
**stvec 相关细节**
在 RV64 中, ``stvec`` 是一个 64 位的 CSR在中断使能的情况下保存了中断处理的入口地址。它有两个字段
- MODE 位于 [1:0],长度为 2 bits
- BASE 位于 [63:2],长度为 62 bits。
当 MODE 字段为 0 的时候, ``stvec`` 被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 ``BASE<<2``
CPU 会跳转到这个地方进行异常处理。本书中我们只会将 ``stvec`` 设置为 Direct 模式。而 ``stvec`` 还可以被设置为 Vectored 模式,
有兴趣的读者可以自行参考 RISC-V 指令集特权级规范。
而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 ``sret`` 来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照 ``sstatus````SPP`` 字段设置为 U 或者 S
- CPU 会跳转到 ``sepc`` 寄存器指向的那条指令,然后开始向下执行。
从上面可以看出硬件主要负责特权级切换、跳转到异常处理入口地址(要在使能异常/中断前设置好)以及在 CSR 中保存一些只有硬件才方便探测到的硬件内的 Trap
相关信息。这基本上都是硬件不得不完成的事情,剩下的工作都交给软件,让软件能有更大的灵活性。
用户栈与内核栈
--------------------------------
在 Trap 触发的一瞬间, CPU 就会切换到 S 特权级并跳转到 ``stvec`` 所指示的位置。但是在正式进入 S 特权级的 Trap 处理之前,上面
提到过我们必须保存原执行流的寄存器状态,这一般通过栈来完成。但我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。
..
chy:我们在一个作为用户栈的特别留出的内存区域上保存应用程序的栈信息,而 Trap 执行流则使用另一个内核栈。
使用两个不同的栈是为了安全性:如果两个执行流使用同一个栈,在返回之后应用程序就有能力看到 Trap 执行流的
历史信息,比如内核一些函数的地址,这样会带来安全隐患。于是,我们要做的是,在批处理操作系统中加入一段汇编代码中,实现从用户栈切换到内核栈,
并在内核栈上保存应用程序执行流的寄存器状态。
我们声明两个类型 ``KernelStack````UserStack`` 分别表示用户栈和内核栈,它们都只是字节数组的简单包装:
.. code-block:: rust
:linenos:
// os/src/batch.rs
const USER_STACK_SIZE: usize = 4096 * 2;
const KERNEL_STACK_SIZE: usize = 4096 * 2;
#[repr(align(4096))]
struct KernelStack {
data: [u8; KERNEL_STACK_SIZE],
}
#[repr(align(4096))]
struct UserStack {
data: [u8; USER_STACK_SIZE],
}
static KERNEL_STACK: KernelStack = KernelStack { data: [0; KERNEL_STACK_SIZE] };
static USER_STACK: UserStack = UserStack { data: [0; USER_STACK_SIZE] };
常数 ``USER_STACK_SIZE````KERNEL_STACK_SIZE`` 指出内核栈和用户栈的大小分别为 :math:`8\text{KiB}` 。两个类型是以全局变量
的形式实例化在批处理操作系统的 ``.bss`` 段中的。
我们为两个类型实现了 ``get_sp`` 方法来获取栈顶地址。由于在 RISC-V 中栈是向下增长的,我们只需返回包裹的数组的终止地址,以用户栈
类型 ``UserStack`` 为例:
.. code-block:: rust
:linenos:
impl UserStack {
fn get_sp(&self) -> usize {
self.data.as_ptr() as usize + USER_STACK_SIZE
}
}
于是换栈是非常简单的,只需将 ``sp`` 寄存器的值修改为 ``get_sp`` 的返回值即可。
.. _term-trap-context:
接下来是Trap上下文即数据结构 ``TrapContext`` ),类似前面提到的函数调用上下文,即在 Trap 发生时需要保存的物理资源内容,并将其一起放在一个名为
``TrapContext`` 的类型中,定义如下:
.. code-block:: rust
:linenos:
// os/src/trap/context.rs
#[repr(C)]
pub struct TrapContext {
pub x: [usize; 32],
pub sstatus: Sstatus,
pub sepc: usize,
}
可以看到里面包含所有的通用寄存器 ``x0~x31`` ,还有 ``sstatus````sepc`` 。那么为什么需要保存它们呢?
- 对于通用寄存器而言,两条执行流运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在 Trap 控制流中只是会执行 Trap 处理
相关的代码,但依然可能直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存。既然如此我们就只能全部保存了。但这里也有一些例外,
``x0`` 被硬编码为 0 ,它自然不会有变化;还有 ``tp(x4)`` 除非我们手动出于一些特殊用途使用它,否则一般也不会被用到。它们无需保存,
但我们仍然在 ``TrapContext`` 中为它们预留空间,主要是为了后续的实现方便。
- 对于 CSR 而言,我们知道进入 Trap 的时候,硬件会立即覆盖掉 ``scause/stval/sstatus/sepc`` 的全部或是其中一部分。``scause/stval``
的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。
而对于 ``sstatus/sepc`` 而言,它们会在 Trap 处理的全程有意义(在 Trap 执行流最后 ``sret`` 的时候还用到了它们),而且确实会出现
Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在 ``sret`` 之前恢复原样。
Trap 管理
-------------------------------
特权级切换的核心是对Trap的管理。这主要涉及到如下一下内容
- 应用程序通过 ``ecall`` 进入到内核状态时操作系统保存被打断的应用程序的Trap 上下文。
- 操作系统根据与Trap相关的CSR寄存器内容完成系统调用服务的分发与处理。
- 操作系统完成系统调用服务后需要恢复被打断的应用程序的Trap 上下文,并通 ``sret`` 让应用程序继续执行。
接下来我们具体介绍上述内容。
Trap 上下文的保存与恢复
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先是具体实现 Trap 上下文保存和恢复的汇编代码。
.. _trap-context-save-restore:
在批处理操作系统初始化的时候,我们需要修改 ``stvec`` 寄存器来指向正确的 Trap 处理入口点。
.. code-block:: rust
:linenos:
// os/src/trap/mod.rs
global_asm!(include_str!("trap.S"));
pub fn init() {
extern "C" { fn __alltraps(); }
unsafe {
stvec::write(__alltraps as usize, TrapMode::Direct);
}
}
这里我们引入了一个外部符号 ``__alltraps`` ,并将 ``stvec`` 设置为 Direct 模式指向它的地址。我们在 ``os/src/trap/trap.S``
中实现 Trap 上下文保存/恢复的汇编代码,分别用外部符号 ``__alltraps````__restore`` 标记,并将这段汇编代码中插入进来。
Trap 处理的总体流程如下:首先通过 ``__alltraps`` 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 ``trap_handler`` 函数
完成 Trap 分发及处理。当 ``trap_handler`` 返回之后,使用 ``__restore`` 从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条
``sret`` 指令回到应用程序执行。
首先是保存 Trap 上下文的 ``__alltraps`` 的实现:
.. code-block:: riscv
:linenos:
# os/src/trap/trap.S
.macro SAVE_GP n
sd x\n, \n*8(sp)
.endm
.align 2
__alltraps:
csrrw sp, sscratch, sp
# now sp->kernel stack, sscratch->user stack
# allocate a TrapContext on kernel stack
addi sp, sp, -34*8
# save general-purpose registers
sd x1, 1*8(sp)
# skip sp(x2), we will save it later
sd x3, 3*8(sp)
# skip tp(x4), application does not use it
# save x5~x31
.set n, 5
.rept 27
SAVE_GP %n
.set n, n+1
.endr
# we can use t0/t1/t2 freely, because they were saved on kernel stack
csrr t0, sstatus
csrr t1, sepc
sd t0, 32*8(sp)
sd t1, 33*8(sp)
# read user stack from sscratch and save it on the kernel stack
csrr t2, sscratch
sd t2, 2*8(sp)
# set input argument of trap_handler(cx: &mut TrapContext)
mv a0, sp
call trap_handler
- 第 7 行我们使用 ``.align````__alltraps`` 的地址 4 字节对齐,这是 RISC-V 特权级规范的要求;
- 第 8 行的 ``csrrw`` 原型是 :math:`\text{csrrw rd, csr, rs}` 可以将 CSR 当前的值读到通用寄存器 :math:`\text{rd}` 中,然后将
通用寄存器 :math:`\text{rs}` 的值写入该 CSR 。因此这里起到的是交换 sscratch 和 sp 的效果。在这一行之前 sp 指向用户栈, sscratch
指向内核栈(原因稍后说明),现在 sp 指向内核栈, sscratch 指向用户栈。
- 第 12 行,我们准备在内核栈上保存 Trap 上下文,于是预先分配 :math:`34\times 8` 字节的栈帧,这里改动的是 sp ,说明确实是在内核栈上。
- 第 13~24 行,保存 Trap 上下文的通用寄存器 x0~x31跳过 x0 和 tp(x4),原因之前已经说明。我们在这里也不保存 sp(x2),因为我们要基于
它来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存 Trap 上下文的地址区间为 :math:`[\text{sp},\text{sp}+8\times34)`
按照 ``TrapContext`` 结构体的内存布局,它从低地址到高地址分别按顺序放置 x0~x31最后是 sstatus 和 sepc 。因此通用寄存器 xn
应该被保存在地址区间 :math:`[\text{sp}+8n,\text{sp}+8(n+1))` 。 在这里我们正是这样基于 sp 来保存这些通用寄存器的。
为了简化代码x5~x31 这 27 个通用寄存器我们通过类似循环的 ``.rept`` 每次使用 ``SAVE_GP`` 宏来保存,其实质是相同的。注意我们需要在
``Trap.S`` 开头加上 ``.altmacro`` 才能正常使用 ``.rept`` 命令。
- 第 25~28 行,我们将 CSR sstatus 和 sepc 的值分别读到寄存器 t0 和 t1 中然后保存到内核栈对应的位置上。指令
:math:`\text{csrr rd, csr}` 的功能就是将 CSR 的值读到寄存器 :math:`\text{rd}` 中。这里我们不用担心 t0 和 t1 被覆盖,
因为它们刚刚已经被保存了。
- 第 30~31 行专门处理 sp 的问题。首先将 sscratch 的值读到寄存器 t2 并保存到内核栈上,注意它里面是进入 Trap 之前的 sp 的值,指向
用户栈。而现在的 sp 则指向内核栈。
- 第 33 行令 :math:`\text{a}_0\leftarrow\text{sp}`,让寄存器 a0 指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址,
这是由于我们接下来要调用 ``trap_handler`` 进行 Trap 处理,它的第一个参数 ``cx`` 由调用规范要从 a0 中获取。而 Trap 处理函数
``trap_handler`` 需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的 syscall ID 和
对应参数。我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值。
.. _term-atomic-instruction:
.. note::
**CSR 相关原子指令**
RISC-V 中读写 CSR 的指令通常都能只需一条指令就能完成多项功能。这样的指令被称为 **原子指令** (Atomic Instruction)。这里
的原子的含义是“不可分割的最小个体”,也就是说指令的多项功能要么都不完成,要么全部完成,而不会处于某种中间状态。
``trap_handler`` 返回之后会从调用 ``trap_handler`` 的下一条指令开始执行,也就是从栈上的 Trap 上下文恢复的 ``__restore``
.. _code-restore:
.. code-block:: riscv
:linenos:
.macro LOAD_GP n
ld x\n, \n*8(sp)
.endm
__restore:
# case1: start running app by __restore
# case2: back to U after handling trap
mv sp, a0
# now sp->kernel stack(after allocated), sscratch->user stack
# restore sstatus/sepc
ld t0, 32*8(sp)
ld t1, 33*8(sp)
ld t2, 2*8(sp)
csrw sstatus, t0
csrw sepc, t1
csrw sscratch, t2
# restore general-purpuse registers except sp/tp
ld x1, 1*8(sp)
ld x3, 3*8(sp)
.set n, 5
.rept 27
LOAD_GP %n
.set n, n+1
.endr
# release TrapContext on kernel stack
addi sp, sp, 34*8
# now sp->kernel stack, sscratch->user stack
csrrw sp, sscratch, sp
sret
- 第 8 行比较奇怪我们暂且不管,假设它从未发生,那么 sp 仍然指向内核栈的栈顶。
- 第 11~24 行负责从内核栈顶的 Trap 上下文恢复通用寄存器和 CSR 。注意我们要先恢复 CSR 再恢复通用寄存器,这样我们使用的三个临时寄存器
才能被正确恢复。
- 在第 26 行之前sp 指向保存了 Trap 上下文之后的内核栈栈顶, sscratch 指向用户栈栈顶。我们在第 26 行在内核栈上回收 Trap 上下文所
占用的内存,回归进入 Trap 之前的内核栈栈顶。第 27 行,再次交换 sscratch 和 sp现在 sp 重新指向用户栈栈顶sscratch 也依然保存
进入 Trap 之前的状态并指向内核栈栈顶。
- 在应用程序执行流状态被还原之后,第 28 行我们使用 ``sret`` 指令回到 U 特权级继续运行应用程序执行流。
.. note::
**sscratch CSR 的用途**
在特权级切换的时候,我们需要将 Trap 上下文保存在内核栈上,因此需要一个寄存器暂存内核栈地址,并以它作为基地址来依次保存 Trap 上下文
的内容。但是所有的通用寄存器都不能够用来暂存,因为它们都需要被保存,如果覆盖掉它们会影响应用执行流的执行。
事实上我们缺少了一个重要的中转寄存器,而 ``sscratch`` CSR 正是为此而生。从上面的汇编代码中可以看出,在保存 Trap 上下文的时候,它
起到了两个作用:首先是保存了内核栈的地址,其次它作为一个中转站让 sp 目前指向的用户栈的地址可以暂时保存下来。于是,我们仅需一条
``csrrw`` 指令就完成了从用户栈到内核栈的切换,这是一种极其精巧的实现。
Trap 分发与处理
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Trap 在使用 Rust 实现的 ``trap_handler`` 函数中完成分发和处理:
.. code-block:: rust
:linenos:
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Exception(Exception::UserEnvCall) => {
cx.sepc += 4;
cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
}
Trap::Exception(Exception::StoreFault) |
Trap::Exception(Exception::StorePageFault) => {
println!("[kernel] PageFault in application, core dumped.");
run_next_app();
}
Trap::Exception(Exception::IllegalInstruction) => {
println!("[kernel] IllegalInstruction in application, core dumped.");
run_next_app();
}
_ => {
panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval);
}
}
cx
}
- 第 4 行声明返回值为 ``&mut TrapContext`` 并在第 25 行实际将传入的 ``cx`` 原样返回,因此在 ``__restore`` 的时候 a0 在调用
``trap_handler`` 前后并没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶,和此时 sp 的值相同,我们 :math:`\text{sp}\leftarrow\text{a}_0`
并不会有问题;
- 第 7 行根据 scause 寄存器所保存的 Trap 的原因进行分发处理。这里我们无需手动操作这些 CSR ,而是使用 Rust 的 riscv 库来更加方便的
做这些事情。要引入 riscv 库,我们需要:
.. code-block:: toml
# os/Cargo.toml
[dependencies]
riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }
- 第 8~11 行,发现 Trap 的原因是来自 U 特权级的 Environment Call也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面
sepc让其增加 4。这是因为我们知道这是一个由 ``ecall`` 指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条 ``ecall``
指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序执行流从 ``ecall`` 的下一条指令
开始执行。因此我们只需修改 Trap 上下文里面的 sepc让它增加 ``ecall`` 指令的码长,也即 4 字节。这样在 ``__restore`` 的时候 sepc
在恢复之后就会指向 ``ecall`` 的下一条指令,并在 ``sret`` 之后从那里开始执行。这属于我们之前提到过的——用户程序能够预知到的执行流
状态所发生的变化。
用来保存系统调用返回值的 a0 寄存器也会同样发生变化。我们从 Trap 上下文取出作为 syscall ID 的 a7 和系统调用的三个参数 a0~a2 传给
``syscall`` 函数并获取返回值。 ``syscall`` 函数是在 ``syscall`` 子模块中实现的。
- 第 12~20 行,分别处理应用程序出现访存错误和非法指令错误的情形。此时需要打印错误信息并调用 ``run_next_app`` 直接切换并运行下一个
应用程序。
- 第 21 行开始,当遇到目前还不支持的 Trap 类型的时候,我们的批处理操作系统整个 panic 报错退出。
对于系统调用而言, ``syscall`` 函数并不会实际处理系统调用而只是会根据 syscall ID 分发到具体的处理函数:
.. code-block:: rust
:linenos:
// os/src/syscall/mod.rs
pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
match syscall_id {
SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
SYSCALL_EXIT => sys_exit(args[0] as i32),
_ => panic!("Unsupported syscall_id: {}", syscall_id),
}
}
这里我们会将传进来的参数 ``args`` 转化成能够被具体的系统调用处理函数接受的类型。它们的实现都非常简单:
.. code-block:: rust
:linenos:
// os/src/syscall/fs.rs
const FD_STDOUT: usize = 1;
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
match fd {
FD_STDOUT => {
let slice = unsafe { core::slice::from_raw_parts(buf, len) };
let str = core::str::from_utf8(slice).unwrap();
print!("{}", str);
len as isize
},
_ => {
panic!("Unsupported fd in sys_write!");
}
}
}
// os/src/syscall/process.rs
pub fn sys_exit(xstate: i32) -> ! {
println!("[kernel] Application exited with code {}", xstate);
run_next_app()
}
- ``sys_write`` 我们将传入的位于应用程序内的缓冲区的开始地址和长度转化为一个字符串 ``&str`` ,然后使用批处理操作系统已经实现的 ``print!``
宏打印出来。注意这里我们并没有检查传入参数的安全性,即使会在出错严重的时候 panic还是会存在安全隐患。这里我们出于实现方便暂且不做修补。
- ``sys_exit`` 打印退出的应用程序的返回值并同样调用 ``run_next_app`` 切换到下一个应用程序。
.. _ch2-app-execution:
执行应用程序
-------------------------------------
当批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,我们要调用 ``run_next_app`` 函数切换到下一个应用程序。此时 CPU 运行在
S 特权级,而它希望能够切换到 U 特权级。在 RISC-V 架构中,唯一一种能够使得 CPU 特权级下降的方法就是通过 Trap 返回系列指令,比如
``sret`` 。事实上,在运行应用程序之前要完成如下这些工作:
- 跳转到应用程序入口点 ``0x80400000``
- 将使用的栈切换到用户栈。
- 在 ``__alltraps`` 时我们要求 ``sscratch`` 指向内核栈,这个也需要在此时完成。
- 从 S 特权级切换到 U 特权级。
它们可以通过复用 ``__restore`` 的代码更容易的实现。我们只需要在内核栈上压入一个相应构造的 Trap 上下文,再通过 ``__restore`` ,就能
让这些寄存器到达我们希望的状态。
.. code-block:: rust
:linenos:
// os/src/trap/context.rs
impl TrapContext {
pub fn set_sp(&mut self, sp: usize) { self.x[2] = sp; }
pub fn app_init_context(entry: usize, sp: usize) -> Self {
let mut sstatus = sstatus::read();
sstatus.set_spp(SPP::User);
let mut cx = Self {
x: [0; 32],
sstatus,
sepc: entry,
};
cx.set_sp(sp);
cx
}
}
``TrapContext`` 实现 ``app_init_context`` 方法,修改其中的 sepc 寄存器为应用程序入口点 ``entry`` sp 寄存器为我们设定的
一个栈指针,并将 sstatus 寄存器的 ``SPP`` 字段设置为 User 。
``run_next_app`` 函数中我们能够看到:
.. code-block:: rust
:linenos:
:emphasize-lines: 10,11,12,13,14
// os/src/batch.rs
pub fn run_next_app() -> ! {
let current_app = APP_MANAGER.inner.borrow().get_current_app();
unsafe {
APP_MANAGER.inner.borrow().load_app(current_app);
}
APP_MANAGER.inner.borrow_mut().move_to_next_app();
extern "C" { fn __restore(cx_addr: usize); }
unsafe {
__restore(KERNEL_STACK.push_context(
TrapContext::app_init_context(APP_BASE_ADDRESS, USER_STACK.get_sp())
) as *const _ as usize);
}
panic!("Unreachable in batch::run_current_app!");
}
在高亮行所做的事情是在内核栈上压入一个 Trap 上下文,其 sepc 是应用程序入口地址 ``0x80400000`` ,其 sp 寄存器指向用户栈,其 sstatus
``SPP`` 字段被设置为 User 。``push_context`` 的返回值是内核栈压入 Trap 上下文之后的栈顶,它会被作为 ``__restore`` 的参数(
回看 :ref:`__restore 代码 <code-restore>` ,这时我们可以理解为何 ``__restore`` 的开头会做
:math:`\text{sp}\leftarrow\text{a}_0` )使得在 ``__restore`` 中 sp 仍然可以指向内核栈的栈顶。这之后,就和一次普通的
``__restore`` 一样了。
.. note::
有兴趣的读者可以思考: sscratch 是何时被设置为内核栈顶的?
..
马老师发生甚么事了?
--
这里要说明目前只考虑从 U Trap 到 S ,而实际上 Trap 的要素就有Trap 之前在哪个特权级Trap 在哪个特权级处理。这个对于中断和异常
都是如此,只不过中断可能跟特权级的关系稍微更紧密一点。毕竟中断的类型都是跟特权级挂钩的。但是对于 Trap 而言有一点是共同的,也就是触发
Trap 不会导致优先级下降。从中断/异常的代理就可以看出从定义上就不允许代理到更低的优先级。而且代理只能逐级代理,目前我们能操作的只有从
M 代理到 S其他代理都基本只出现在指令集拓展或者硬件还不支持。中断的情况是如果是属于某个特权级的中断不能在更低的优先级处理。事实上
这个中断只可能在 CPU 处于不会更高的优先级上收到(否则会被屏蔽),而 Trap 之后优先级不会下降Trap 代理机制决定),这样就自洽了。
--
之前提到异常是说需要执行环境功能的原因与某条指令的执行有关。而 Trap 的定义更加广泛一些,就是在执行某条指令之后发现需要执行环境的功能,
如果是中断的话 Trap 回来之后默认直接执行下一条指令,如果是异常的话硬件会将 sepc 设置为 Trap 发生之前最后执行的那条指令,而异常发生
的原因不一定和这条指令的执行有关。应该指出的是,在大多数情况下都是和最后这条指令的执行有关。但在缓存的作用下也会出现那种特别极端的情况。
--
然后是 Trap 到 S就有 S 模式的一些相关 CSR以及从 U Trap 到 S硬件会做哪些事情包括触发异常的一瞬间以及处理完成调用 sret
之后)。然后指出从用户的视角来看,如果是 ecall 的话, Trap 回来之后应该从 ecall 的下一条指令开始执行,且执行现场不能发生变化。
所以就需要将应用执行环境保存在内核栈上(还需要换栈!)。栈存在的原因可能是 Trap handler 是一条新的运行在 S 特权级的执行流,所以
这个可以理解成跨特权级的执行流切换,确实就复杂一点,要保存的内容也相对多一点。而下一章多任务的任务切换是全程发生在 S 特权级的执行流
切换,所以会简单一点,保存的通用寄存器大概率更少(少在调用者保存寄存器),从各种意义上都很像函数调用。从不同特权级的角度来解释换栈
是出于安全性,应用不应该看到 Trap 执行流的栈,这样做完之后,虽然理论上可以访问,但应用不知道内核栈的位置应该也有点麻烦。
--
然后是 rust_trap 的处理,尤其是奇妙的参数传递,内部处理逻辑倒是非常简单。
--
最后是如何利用 __restore 初始化应用的执行环境,包括如何设置入口点、用户栈以及保证在 U 特权级执行。

View File

@ -0,0 +1,142 @@
chapter2练习
=====================================================
.. toctree::
:hidden:
:maxdepth: 4
- 本节难度: **低**
编程练习
-------------------------------
简单安全检查
+++++++++++++++++++++++++++++++
lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们可以在用户态输出信息。但是 os 在提供服务的同时,还有保护 os 本身以及其他用户程序不受错误或者恶意程序破坏的功能。
由于还没有实现虚拟内存,我们可以在用户程序中指定一个属于其他程序字符串,并将它输出,这显然是不合理的,因此我们要对 sys_write 做检查:
- sys_write 仅能输出位于程序本身内存空间内的数据,否则报错。
实验要求
+++++++++++++++++++++++++++++++
- 实现分支: ch2。
- 完成实验指导书中的内容,能运行用户态程序并执行 sys_writesys_exit 系统调用。
- 为 sys_write 增加安全性检查,并通过 `Rust测例 <https://github.com/DeathWish5/rCore_tutorial_tests>`_ 中 chapter2 对应的所有测例,测例详情见对应仓库。
challenge: 支持多核,实现多个核运行用户程序。
实验约定
++++++++++++++++++++++++++++++
在第二章的测试中,我们对于内核有如下仅仅为了测试方便的要求,请调整你的内核代码来符合这些要求。
- 用户栈大小必须为 4096且按照 4096 字节对齐。这一规定可以在实验4开始删除仅仅为通过 lab2/3 测例设置。
.. _inherit-last-ch-changes:
.. note::
**如何快速继承上一章练习题的修改**
从这一章开始,在完成本章习题之前,首先要做的就是将上一章框架的修改继承到本章的框架代码。出于各种原因,实际上通过 ``git merge`` 并不是很方便,这里给出一种打 patch 的方法,希望能够有所帮助。
1. 切换到上一章的分支,通过 ``git log`` 找到你在此分支上的第一次 commit 的前一个 commit 的 ID ,复制其前 8 位,记作 ``base-commit`` 。假设分支上最新的一次 commit ID 是 ``last-commit``
2. 确保你位于项目根目录 ``rCore-Tutorial-v3`` 下。通过 ``git diff <base-commit> <last-commit> > <patch-path>`` 即可在 ``patch-path`` 路径位置(比如 ``~/Desktop/chx.patch`` )生成一个描述你对于上一章分支进行的全部修改的一个补丁文件。打开看一下,它给出了每个被修改的文件中涉及了哪些块的修改,还附加了块前后的若干行代码。如果想更加灵活进行合并的话,可以通过 ``git format-patch <base-commit>`` 命令在当前目录下生成一组补丁,它会对于 ``base-commit`` 后面的每一次 commit 均按照顺序生成一个补丁。
3. 切换到本章分支,通过 ``git apply --reject <patch-path>`` 来将一个补丁打到当前章节上。它的大概原理是对于补丁中的每个被修改文件中的每个修改块,尝试通过块的前后若干行代码来定位它在当前分支上的位置并进行替换。有一些块可能无法匹配,此时会生成与这些块所在的文件同名的 ``*.rej`` 文件,描述了哪些块替换失败了。在项目根目录 ``rCore-Tutorial-v3`` 下,可以通过 ``find . -name *.rej`` 来找到所有相关的 ``*.rej`` 文件并手动完成替换。
4. 在处理完所有 ``*.rej`` 之后,将它们删除并 commit 一下。现在就可以开始本章的实验了。
实验检查
++++++++++++++++++++++++++++++
- 实验目录要求(Rust)
.. code-block::
├── os(内核实现)
│   ├── build.rs (在这里实现用户程序的打包)
│   ├── Cargo.toml(配置文件)
│   ├── Makefile (要求 make run 可以正确执行,尽量不输出调试信息)
│   ├── src(所有内核的源代码放在 os/src 目录下)
│   ├── main.rs(内核主函数)
│   ├── ...
├── reports
│   ├── lab2.md/pdf
│   └── ...
├── README.md其他必要的说明
├── ...
参考示例目录结构。目标用户目录 ``../user/build/bin``
- 检查
.. code-block:: console
$ git checkout ch2
$ cd os
$ make run
可以正确执行正确执行目标用户测例,并得到预期输出(详见测例注释)。
注意:如果设置默认 log 等级,从 lab2 开始关闭所有 log 输出。
简答题
-------------------------------
1. 正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。目前由于一些其他原因,这些问题不太好测试,请同学们可以自行测试这些内容(参考 `前三个测例 <https://github.com/DeathWish5/rCore_tutorial_tests/tree/master/user/src/bin>`_ ),描述程序出错行为,同时注意注明你使用的 sbi 及其版本。
2. 请结合用例理解 `trap.S <https://github.com/rcore-os/rCore-Tutorial-v3/blob/ch2/os/src/trap/trap.S>`_ 中两个函数 ``__alltraps````__restore`` 的作用,并回答如下几个问题:
1. L40刚进入 ``__restore`` 时,``a0`` 代表了什么值。请指出 ``__restore`` 的两种使用情景。
2. L46-L51这几行汇编代码特殊处理了哪些寄存器这些寄存器的的值对于进入用户态有何意义请分别解释。
.. code-block:: riscv
ld t0, 32*8(sp)
ld t1, 33*8(sp)
ld t2, 2*8(sp)
csrw sstatus, t0
csrw sepc, t1
csrw sscratch, t2
3. L53-L59为何跳过了 ``x2````x4``
.. code-block:: riscv
ld x1, 1*8(sp)
ld x3, 3*8(sp)
.set n, 5
.rept 27
LOAD_GP %n
.set n, n+1
.endr
4. L63该指令之后``sp````sscratch`` 中的值分别有什么意义?
.. code-block:: riscv
csrrw sp, sscratch, sp
5. ``__restore``:中发生状态切换在哪一条指令?为何该指令执行之后会进入用户态?
6. L13该指令之后``sp````sscratch`` 中的值分别有什么意义?
.. code-block:: riscv
csrrw sp, sscratch, sp
7. 从 U 态进入 S 态是哪一条指令发生的?
3. 程序陷入内核的原因有中断和异常(系统调用),请问 riscv64 支持哪些中断 / 异常?如何判断进入内核是由于中断还是异常?描述陷入内核时的几个重要寄存器及其值。
4. 对于任何中断,``__alltraps`` 中都需要保存所有寄存器吗?你有没有想到一些加速 ``__alltraps`` 的方法?简单描述你的想法。
报告要求
-------------------------------
- 简单总结与上次实验相比本次实验你增加的东西控制在5行以内不要贴代码
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

133
source/chapter2/ch2.py Normal file
View File

@ -0,0 +1,133 @@
from manimlib.imports import *
class EnvironmentCallFlow(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
os = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
app = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
app.shift(np.array([-4, 0, 0]))
see = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
see.shift(np.array([4, 0, 0]))
self.add(os, app, see)
os_text = TextMobject("OS", color=BLACK).next_to(os, UP, buff=0.2)
app_text = TextMobject("Application", color=BLACK).next_to(app, UP, buff=0.2)
see_text = TextMobject("SEE", color=BLACK).next_to(see, UP, buff=.2)
self.add(os_text, app_text, see_text)
app_ecall = Rectangle(height=0.5, width=2.0, color=BLACK, fill_color=BLUE, fill_opacity=1.0)
app_ecall.move_to(app)
app_ecall_text = TextMobject("ecall", color=BLACK).move_to(app_ecall)
self.add(app_ecall, app_ecall_text)
app_code1 = Rectangle(width=2.0, height=2.0, color=BLACK, fill_color=GREEN, fill_opacity=1.0)
app_code1.next_to(app_ecall, UP, buff=0)
self.add(app_code1)
app_code2 = Rectangle(width=2.0, height=2.5, color=BLACK, fill_color=GREEN, fill_opacity=1.0)
app_code2.next_to(app_ecall, DOWN, buff=0)
self.add(app_code2)
app_code1_text = TextMobject("U Code", color=BLACK).move_to(app_code1).shift(np.array([-.15, 0, 0]))
app_code2_text = TextMobject("U Code", color=BLACK).move_to(app_code2).shift(np.array([-.15, 0, 0]))
self.add(app_code1_text, app_code2_text)
os_ecall = Rectangle(height=.5, width=2.0, color=BLACK, fill_color=BLUE, fill_opacity=1.0)
os_ecall.move_to(os)
os_ecall_text = TextMobject("ecall", color=BLACK).move_to(os_ecall)
self.add(os_ecall, os_ecall_text)
os_code1 = Rectangle(width=2.0, height=2.0, color=BLACK, fill_color=PURPLE, fill_opacity=1.0).next_to(os_ecall, UP, buff=0)
os_code1_text = TextMobject("S Code", color=BLACK).move_to(os_code1).shift(np.array([-.15, 0, 0]))
os_code2 = Rectangle(width=2.0, height=2.5, color=BLACK, fill_color=PURPLE, fill_opacity=1.0).next_to(os_ecall, DOWN, buff=0)
os_code2_text = TextMobject("S Code", color=BLACK).move_to(os_code2).shift(np.array([-.15, 0, 0]))
self.add(os_code1, os_code2, os_code1_text, os_code2_text)
app_ecall_anchor = app_ecall.get_center() + np.array([0.8, 0, 0])
app_front = Line(start=app_ecall_anchor+np.array([0, 2, 0]), end=app_ecall_anchor, color=RED)
app_front.add_tip(tip_length=0.2)
self.add(app_front)
os_ecall_anchor = os_ecall.get_center() + np.array([0.8, 0, 0])
os_front = Line(start=os_ecall_anchor+np.array([0, 2, 0]), end=os_ecall_anchor, color=RED)
os_front.add_tip(tip_length=.2)
self.add(os_front)
trap_to_os = DashedLine(start=app_ecall_anchor, end=os_ecall_anchor+np.array([0, 2, 0]), color=RED)
trap_to_os.add_tip(tip_length=.2)
self.add(trap_to_os)
see_entry = see.get_center()+np.array([0.8, 2, 0])
see_exit = see_entry+np.array([0, -4, 0])
see_code = Rectangle(width=2.0, height=see_entry[1]-see_exit[1], color=BLACK, fill_color=GRAY, fill_opacity=1.0).move_to(see)
self.add(see_code)
see_text = TextMobject("M Code", color=BLACK).move_to(see_code).shift(np.array([-.15, 0, 0]))
self.add(see_text)
see_front = Line(start=see_entry, end=see_exit, color=RED).add_tip(tip_length=.2)
self.add(see_front)
trap_to_see = DashedLine(start=os_ecall_anchor, end=see_entry, color=RED).add_tip(tip_length=.2)
self.add(trap_to_see)
os_back_anchor = os_ecall_anchor+np.array([0, -.5, 0])
trap_back_to_os = DashedLine(start=see_exit, end=os_back_anchor, color=RED).add_tip(tip_length=.2)
self.add(trap_back_to_os)
os_exit = os_back_anchor+np.array([0, -2, 0])
os_front2 = Line(start=trap_back_to_os, end=os_exit, color=RED).add_tip(tip_length=.2)
self.add(os_front2)
app_back_anchor = app_ecall_anchor+np.array([0, -.5, 0])
trap_back_to_app = DashedLine(start=os_exit, end=app_back_anchor, color=RED).add_tip(tip_length=.2)
self.add(trap_back_to_app)
app_front2 = Line(start=app_back_anchor, end=app_back_anchor+np.array([0, -2, 0]), color=RED)
app_front2.add_tip(tip_length=.2)
self.add(app_front2)
u_into_s = TextMobject("U into S", color=BLACK).next_to(app_ecall, RIGHT, buff=0).shift(np.array([0, .5, 0])).scale(0.5)
s_back_u = TextMobject("S back to U", color=BLACK).next_to(app_ecall, RIGHT, buff=0).shift(np.array([-.3, -1, 0])).scale(0.5)
s_into_m = TextMobject("S into M", color=BLACK).next_to(os_ecall, RIGHT, buff=0).shift(np.array([0, .5, 0])).scale(.5)
m_back_s = TextMobject("M back to S", color=BLACK).next_to(os_ecall, RIGHT, buff=0).shift(np.array([-.3, -1, 0])).scale(.5)
self.add(u_into_s, s_back_u, s_into_m, m_back_s)
class PrivilegeStack(Scene):
CONFIG = {
"camera_config": {
"background_color": WHITE,
},
}
def construct(self):
os = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
os_text = TextMobject("OS", color=BLACK).move_to(os)
self.add(os, os_text)
sbi = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=BLACK, fill_opacity=1.0)
sbi.next_to(os, DOWN, buff=0)
sbi_text = TextMobject("SBI", color=WHITE).move_to(sbi)
self.add(sbi, sbi_text)
see = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
see.next_to(sbi, DOWN, buff=0)
see_text = TextMobject("SEE", color=BLACK).move_to(see)
self.add(see, see_text)
abi0 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=BLACK, fill_opacity=1.0)
abi0.next_to(os, UP, buff=0).align_to(os, LEFT)
abi0_text = TextMobject("ABI", color=WHITE).move_to(abi0)
self.add(abi0, abi0_text)
abi1 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=BLACK, fill_opacity=1.0)
abi1.next_to(os, UP, buff=0).align_to(os, RIGHT)
abi1_text = TextMobject("ABI", color=WHITE).move_to(abi1)
self.add(abi1, abi1_text)
app0 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
app0.next_to(abi0, UP, buff=0)
app0_text = TextMobject("App", color=BLACK).move_to(app0)
self.add(app0, app0_text)
app1 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=WHITE, fill_opacity=1.0)
app1.next_to(abi1, UP, buff=0)
app1_text = TextMobject("App", color=BLACK).move_to(app1)
self.add(app1, app1_text)
line0 = DashedLine(sbi.get_right(), sbi.get_right() + np.array([3, 0, 0]), color=BLACK)
self.add(line0)
line1 = DashedLine(abi1.get_right(), abi1.get_right() + np.array([3, 0, 0]), color=BLACK)
self.add(line1)
machine = TextMobject("Machine", color=BLACK).next_to(see, RIGHT, buff=.8)
supervisor = TextMobject("Supervisor", color=BLACK).next_to(os, RIGHT, buff=.8)
user = TextMobject("User", color=BLACK).next_to(app1, RIGHT, buff=.8)
self.add(machine, supervisor, user)

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

15
source/chapter2/index.rst Normal file
View File

@ -0,0 +1,15 @@
.. _link-chapter2:
第二章:批处理系统
==============================================
.. toctree::
:maxdepth: 4
0intro
1rv-privilege
2application
3batch-system
4trap-handling
5exercise

304
source/chapter3/0intro.rst Normal file
View File

@ -0,0 +1,304 @@
引言
========================================
本章导读
--------------------------
..
chyyuu有一个ascii图画出我们做的OS。
本章展现了操作系统一系列功能:
- 通过提前加载应用程序到内存,减少应用程序切换开销
- 通过协作机制支持程序主动放弃处理器,提高系统执行效率
- 通抢占机制支持程序被动放弃处理器提高不同程序对处理器资源使用的公平性也进一步提高了应用对I/O事件的响应效率
上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到相同的一块内存区域。
而计算机硬件在快速发展内存容量在逐渐增大处理器的速度也在增加外设IO性能方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善但处理器的空闲程度加大了。于是科学家就开始考虑在内存中尽量同时驻留多个应用这样处理器的利用率就会提高。但只有一个程序执行完毕后或主动放弃执行处理器才能执行另外一个程序。这种运行方式称为 **多道程序**
协作式操作系统
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
早期的计算机系统大部分是单处理器计算机系统。当处理器进一步发展后它与IO的速度差距也进一步拉大。这时计算机科学家发现**多道程序** 运行方式下一个程序如果不让出处理器其他程序是无法执行的。如果一个应用由于IO操作让处理器空闲下来或让处理器忙等那其他需要处理器资源进行计算的应用还是没法使用空闲的处理器资源。于是就想到让应用在执行IO操作时可以主动 **释放处理器** ,让其他应用继续执行。当然执行 **放弃处理器** 的操作算是一种对处理器资源的直接管理,所以应用程序可以发出这样的系统调用,让操作系统来具体完成。这样的操作系统就是支持 **多道程序** 协作式操作系统。
抢占式操作系统
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
计算机科学家很快发现,编写应用程序的科学家(简称应用程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的执行情况,很难(也没必要)有提高整个系统利用率上的大局观。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行。这导致应用程序员在编写程序时,无法做到在程序的合适位置放置 **放弃处理器的系统调用请求** ,这样系统的整体利用率还是无法提高。
所以站在系统的层面还是需要有一种办法能强制打断应用程序的执行来提高整个系统的效率让在整个系统中执行的多个程序之间占用计算机资源的情况相对公平一些。根据计算机系统的硬件设计为提高I/O效率外设可以通过硬件中断机制来与处理机进行I/O交互操作。这种硬件中断机制·可随时打断应用程序的执行并让操作系统来完成对外设的I/O响应。
而操作系统可进一步利用某种以固定时长为时间间隔的外设中断(比如时钟中断)来强制打断一个程序的执行,这样一个程序只能运行一段时间(可以简称为一个时间片, Time Slice就一定会让出处理器且操作系统可以在处理外设的I/O响应后让不同应用程序分时占用处理器执行并可通过程序占用处理器的总执行时间来评估运行的程序对处理器资源的消耗。
.. _term-task:
我们可以把一个程序在一个时间片上占用处理器执行的过程称为一个 **任务** (Task),让操作系统对不同程序的 **任务** 进行管理。通过平衡各个程序在整个时间段上的任务数,就达到一定程度的系统公平和高效的系统效率。在一个包含多个时间片的时间段上,会有属于不同程序的多个任务在轮流占用处理器执行,这样的操作系统就是支持 **分时多任务** 的抢占式操作系统。
本章所介绍的多道程序和分时多任务系统都有一些共同的特点:在内存中同一时间可以驻留多个应用。所有的应用都是在系统启动的时候分别加载到内存的不同区域中。由于目前计算机系统中只有一个处理器,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将处理器分配给它们才能开始执行。一旦应用开始执行,它就处于运行状态了。
本章主要是设计和实现建立支持 **多道程序** 的二叠纪“锯齿螈”初级操作系统、支持多道程序的三叠纪“始初龙”协作式操作系统和支持 **分时多任务** 的三叠纪“腔骨龙”抢占式操作系统,从而对可支持运行一批应用程序的多种执行环境有一个全面和深入的理解,并可归纳抽象出 **任务** **任务切换** 等操作系统的概念。
.. note::
读者也许会有疑问:由于只有一个 处理器,即使这样做,同一时间最多还是只能运行一个应用,还浪费了更多的内存来把所有
的应用都加载进来。那么这样做有什么意义呢?
读者可以带着这个问题继续看下去。后面我们会介绍这样做到底能够解决什么问题。
实践体验
-------------------------------------
.. _term-multiprogramming:
.. _term-time-sharing-multitasking:
**多道程序** (Multiprogramming) 和 **分时多任务** (Time-Sharing Multitasking) 对于应用的要求是不同的,因此我们分别为它们编写了不同的应用,代码也被放在两个不同的分支上。对于它们更加深入的讲解请参考本章正文,我们在引言中仅给出运行代码的方法。
获取多道程序的代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch3-coop
获取分时多任务系统的代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch3
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发板连接到 PC并在上面运行本章代码
.. code-block:: console
$ cd os
$ make run BOARD=k210
多道程序的应用分别会输出一个不同的字母矩阵。当他们交替执行的时候,以 k210 平台为例,我们将看到字母行的交错输出:
.. code-block::
[rustsbi] Version 0.1.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
AAAAAAAAAA [1/5]
BBBBBBBBBB [1/2]
CCCCCCCCCC [1/3]
AAAAAAAAAA [2/5]
BBBBBBBBBB [2/2]
CCCCCCCCCC [2/3]
AAAAAAAAAA [3/5]
Test write_b OK!
[kernel] Application exited with code 0
CCCCCCCCCC [3/3]
AAAAAAAAAA [4/5]
Test write_c OK!
[kernel] Application exited with code 0
AAAAAAAAAA [5/5]
Test write_a OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:97 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt
分时多任务系统应用分为两种。编号为 00/01/02 的应用分别会计算质数 3/5/7 的幂次对一个大质数取模的余数,并会将结果阶段性输出。编号为 03 的
应用则会等待三秒钟之后再退出。以 k210 平台为例,我们将会看到 00/01/02 三个应用分段完成它们的计算任务,而应用 03 由于等待时间过长总是
最后一个结束执行。
.. code-block::
[rustsbi] RustSBI version 0.1.1
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210 (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x22
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
power_3 [10000/200000]
power_3 [20000/200000]
power_3 [30000/200000power_5 [10000/140000]
power_5 [20000/140000]
power_5 [30000/140000power_7 [10000/160000]
power_7 [20000/160000]
power_7 [30000/160000]
]
power_3 [40000/200000]
power_3 [50000/200000]
power_3 [60000/200000]
power_5 [40000/140000]
power_5 [50000/140000]
power_5 [60000/140000power_7 [40000/160000]
power_7 [50000/160000]
power_7 [60000/160000]
]
power_3 [70000/200000]
power_3 [80000/200000]
power_3 [90000/200000]
power_5 [70000/140000]
power_5 [80000/140000]
power_5 [90000/140000power_7 [70000/160000]
power_7 [80000/160000]
power_7 [90000/160000]
]
power_3 [100000/200000]
power_3 [110000/200000]
power_3 [120000/]
power_5 [100000/140000]
power_5 [110000/140000]
power_5 [120000/power_7 [100000/160000]
power_7 [110000/160000]
power_7 [120000/160000200000]
power_3 [130000/200000]
power_3 [140000/200000]
power_3 [150000140000]
power_5 [130000/140000]
power_5 [140000/140000]
5^140000 = 386471875]
power_7 [130000/160000]
power_7 [140000/160000]
power_7 [150000/160000/200000]
power_3 [160000/200000]
power_3 [170000/200000]
power_3 [
Test power_5 OK!
[kernel] Application exited with code 0
]
power_7 [160000/160000]
7180000/200000]
power_3 [190000/200000]
power_3 [200000/200000]
3^200000 = 871008973^160000 = 667897727
Test power_7 OK!
[kernel] Application exited with code 0
Test power_3 OK!
[kernel] Application exited with code 0
Test sleep OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:98 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt. Type: 0, reason: 0
输出结果看上去有一些混乱,原因是用户程序的每个 ``println!`` 往往会被拆分成多个 ``sys_write`` 系统调用提交给内核。有兴趣的同学可以参考 ``println!`` 宏的实现。
另外需要说明的是一点是:与上一章不同,应用的编号不再决定其被加载运行的先后顺序,而仅仅能够改变应用被加载到内存中的位置。
本章代码树
---------------------------------------------
.. code-block::
:linenos:
:emphasize-lines: 14
./os/src
Rust 16 Files 481 Lines
Assembly 3 Files 84 Lines
├── bootloader
│   ├── rustsbi-k210.bin
│   └── rustsbi-qemu.bin
├── LICENSE
├── os
│   ├── build.rs
│   ├── Cargo.toml
│   ├── Makefile
│   └── src
│   ├── batch.rs(移除:功能分别拆分到 loader 和 task 两个子模块)
│   ├── config.rs(新增:保存内核的一些配置)
│   ├── console.rs
│   ├── entry.asm
│   ├── lang_items.rs
│   ├── link_app.S
│   ├── linker-k210.ld
│   ├── linker-qemu.ld
│   ├── loader.rs(新增:将应用加载到内存并进行管理)
│   ├── main.rs(修改:主函数进行了修改)
│   ├── sbi.rs(修改:引入新的 sbi call set_timer)
│   ├── syscall(修改:新增若干 syscall)
│   │   ├── fs.rs
│   │   ├── mod.rs
│   │   └── process.rs
│   ├── task(新增task 子模块,主要负责任务管理)
│   │   ├── context.rs(引入 Task 上下文 TaskContext)
│   │   ├── mod.rs(全局任务管理器和提供给其他模块的接口)
│   │   ├── switch.rs(将任务切换的汇编代码解释为 Rust 接口 __switch)
│   │   ├── switch.S(任务切换的汇编代码)
│   │   └── task.rs(任务控制块 TaskControlBlock 和任务状态 TaskStatus 的定义)
│   ├── timer.rs(新增:计时器相关)
│   └── trap
│   ├── context.rs
│   ├── mod.rs(修改:时钟中断相应处理)
│   └── trap.S
├── README.md
├── rust-toolchain
├── tools
│   ├── kflash.py
│   ├── LICENSE
│   ├── package.json
│   ├── README.rst
│   └── setup.py
└── user
├── build.py(新增:使用 build.py 构建应用使得它们占用的物理地址区间不相交)
├── Cargo.toml
├── Makefile(修改:使用 build.py 构建应用)
└── src
├── bin(修改:换成第三章测例)
│   ├── 00power_3.rs
│   ├── 01power_5.rs
│   ├── 02power_7.rs
│   └── 03sleep.rs
├── console.rs
├── lang_items.rs
├── lib.rs
├── linker.ld
└── syscall.rs
本章代码导读
-----------------------------------------------------
本章的重点是实现对应用之间的协作式和抢占式任务切换的操作系统支持。与上一章的操作系统实现相比,有如下一些不同的情况导致实现上也有不同:
- 多个应用同时放在内存中,所以他们的起始地址是不同的,且地址范围不能重叠
- 应用在整个执行过程中会暂停或被抢占,即会有主动或被动的任务切换
对于第一个不同,需要对应用程序的地址空间布局进行调整,每个应用的地址空间都不相同,且不能重叠。这并不要修改应用程序本身。通过一个脚本 ``build.py`` 来让编译器在编译不同应用时用到的链接脚本 ``linker.ld`` 中的 ``BASE_ADDRESS`` 都不同,且有足够大的地址间隔。这样就可以让每个应用所在的内存空间是不同的。
对于第二个不同,需要实现任务切换,这就需要在上一章的 ``trap`` 上下文切换的基础上,再加上一个 ``task`` 上下文切换,才能完成完整的任务切换。这里面的关键数据结构是表示应用执行上下文的 ``TaskContext`` 和具体完成上下文切换的汇编语言编写的 ``__switch`` 函数。一个应用的执行需要被操作系统管理起来,这是通过 ``TaskControlBlock`` 数据结构来表示应用执行上下文的动态过程和动态状态(运行态、就绪态等)。再加上让应用程序第一次执行的前期初始化准备而建立的 ``TaskManager`` 数据结构的全局变量实例 ``TASK_MANAGER`` ,形成了本章的难点部分。
当然,但应用程序可以在用户态执行后,还需要有新的系统调用 ``sys_yield`` 的实现来支持应用自己的主动暂停;还要添加对时钟中断的处理,来支持抢占应用执行的抢占式切换。有了时钟中断,就可以在一定时间内打断应用的执行,并主动切换到另外一个应用,这部分主要是通过对 ``trap_handler`` 函数中进行扩展,来完成在时钟中断产生时可能进行的任务切换。 ``TaskManager`` 数据结构的成员函数 ``run_next_task`` 来实现基于任务控制块的切换,并会具体调用 ``__switch`` 函数完成硬件相关部分的任务上下文切换。
如果理解了上面的数据结构和相关函数的关系和访问情况,那么就可以比较容易理解本章改进后的操作系统。

View File

@ -0,0 +1,141 @@
多道程序放置与加载
=====================================
本节导读
--------------------------
在本章的引言中我们提到每个应用都需要按照它的编号被分别放置并加载到内存中不同的位置。本节我们就来介绍它是如何实现的。通过具体实现,可以看到多个应用程序被一次性地加载到内存中,这样在切换到另外一个应用程序执行会很快,不像前一章介绍的操作系统,还要有清空前一个应用,然后加载当前应用的过程与开销。
但我们也会了解到,每个应用程序需要知道自己运行时在内存中的不同位置,这对应用程序的编写带来了一定的麻烦。而且操作系统也要知道每个应用程序运行时的位置,不能任意移动应用程序所在的内存空间,即不能在运行时根据内存空间的动态空闲情况,把应用程序调整到合适的空闲空间中。
..
chyyuu有一个ascii图画出我们做的OS在本节的部分。
多道程序放置
----------------------------
与第二章相同,所有应用的 ELF 都经过 strip 丢掉所有 ELF header 和符号变为二进制镜像文件,随后以同样的格式通过 ``link_user.S`` 在编译的时候直接链接到内核的数据段中。不同的是,我们对相关模块进行了调整:在第二章中应用的加载和进度控制都交给 ``batch`` 子模块,而在第三章中我们将应用的加载这部分功能分离出来在 ``loader`` 子模块中实现,应用的执行和切换则交给 ``task`` 子模块。
注意,我们需要调整每个应用被构建时候使用的链接脚本 ``linker.ld`` 中的起始地址 ``BASE_ADDRESS`` 为它实际会被内核加载并运行的地址。也就是要做到:应用知道自己会被加载到某个地址运行,而内核也确实能做到将它加载到那个地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,通用性很低。事实上,目前应用程序的编址方式是基于绝对位置的而并没做到与位置无关,内核也没有提供相应的重定位机制。
.. note::
对于编址方式,需要再回顾一下编译原理课讲解的后端代码生成技术,以及计算机组成原理课的指令寻址方式的内容。可以在 `这里 <https://nju-projectn.github.io/ics-pa-gitbook/ics2020/4.2.html>`_ 找到更多有关
位置无关和重定位的说明。
由于每个应用被加载到的位置都不同,也就导致它们的链接脚本 ``linker.ld`` 中的 ``BASE_ADDRESS`` 都是不同的。实际上,
我们写了一个脚本 ``build.py`` 而不是直接用 ``cargo build`` 构建应用的链接脚本:
.. code-block:: python
:linenos:
# user/build.py
import os
base_address = 0x80400000
step = 0x20000
linker = 'src/linker.ld'
app_id = 0
apps = os.listdir('src/bin')
apps.sort()
for app in apps:
app = app[:app.find('.')]
lines = []
lines_before = []
with open(linker, 'r') as f:
for line in f.readlines():
lines_before.append(line)
line = line.replace(hex(base_address), hex(base_address+step*app_id))
lines.append(line)
with open(linker, 'w+') as f:
f.writelines(lines)
os.system('cargo build --bin %s --release' % app)
print('[build.py] application %s start with address %s' %(app, hex(base_address+step*app_id)))
with open(linker, 'w+') as f:
f.writelines(lines_before)
app_id = app_id + 1
它的思路很简单,在遍历 ``app`` 的大循环里面只做了这样几件事情:
- 第 16~22 行,找到 ``src/linker.ld`` 中的 ``BASE_ADDRESS = 0x80400000;`` 这一行,并将后面的地址
替换为和当前应用对应的一个地址;
- 第 23 行,使用 ``cargo build`` 构建当前的应用,注意我们可以使用 ``--bin`` 参数来只构建某一个应用;
- 第 25~26 行,将 ``src/linker.ld`` 还原。
多道程序加载
----------------------------
应用的加载方式也和上一章的有所不同。上一章中讲解的加载方法是让所有应用都共享同一个固定的加载物理地址。也是因为这个原因,内存中同时最多只能驻留一个应用,当它运行完毕或者出错退出的时候由操作系统的 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过调用 ``loader`` 子模块的 ``load_apps`` 函数实现的:
.. code-block:: rust
:linenos:
// os/src/loader.rs
pub fn load_apps() {
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = get_num_app();
let app_start = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
// clear i-cache first
unsafe { llvm_asm!("fence.i" :::: "volatile"); }
// load apps
for i in 0..num_app {
let base_i = get_base_i(i);
// clear region
(base_i..base_i + APP_SIZE_LIMIT).for_each(|addr| unsafe {
(addr as *mut u8).write_volatile(0)
});
// load app from data section to memory
let src = unsafe {
core::slice::from_raw_parts(
app_start[i] as *const u8,
app_start[i + 1] - app_start[i]
)
};
let dst = unsafe {
core::slice::from_raw_parts_mut(base_i as *mut u8, src.len())
};
dst.copy_from_slice(src);
}
}
可以看出,第 :math:`i` 个应用被加载到以物理地址 ``base_i`` 开头的一段物理内存上,而 ``base_i`` 的计算方式如下:
.. code-block:: rust
:linenos:
// os/src/loader.rs
fn get_base_i(app_id: usize) -> usize {
APP_BASE_ADDRESS + app_id * APP_SIZE_LIMIT
}
我们可以在 ``config`` 子模块中找到这两个常数。从这一章开始, ``config`` 子模块用来存放内核中所有的常数。看到 ``APP_BASE_ADDRESS`` 被设置为 ``0x80400000`` ,而 ``APP_SIZE_LIMIT`` 和上一章一样被设置为 ``0x20000`` ,也就是每个应用二进制镜像的大小限制。因此,应用的内存布局就很明朗了——就是从 ``APP_BASE_ADDRESS`` 开始依次为每个应用预留一段空间。
这样,我们就说明了多个应用是如何被构建和加载的。
执行应用程序
----------------------------
当多道程序的初始化放置工作完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app 函数切换到下一个应用程序。此时 CPU 运行在 S 特权级的操作系统中,而操作系统希望能够切换到 U 特权级去运行应用程序。这一过程与上章的 :ref:`执行应用程序 <ch2-app-execution>` 一节的描述类似。相对不同的是,操作系统知道每个应用程序预先加载在内存中的位置,这就需要设置应用程序返回的不同 Trap 上下文Trap上下文中保存了 放置程序起始地址的``epc`` 寄存器内容):
- 跳转到应用程序(编号 :math:`i` )的入口点 :math:`\text{entry}_i`
- 将使用的栈切换到用户栈 :math:`\text{stack}_i`
二叠纪“锯齿螈”操作系统
------------------------
这样,我们的二叠纪“锯齿螈”操作系统就算是实现完毕了。
..
chyyuu有一个ascii图画出我们做的OS。

View File

@ -0,0 +1,173 @@
任务切换
================================
本节导读
--------------------------
在上一节实现的二叠纪“锯齿螈”操作系统还是比较原始,一个应用会独占 CPU 直到它出错或主动退出。操作系统还是以程序的一次执行过程(从开始到结束)作为处理器切换程序的时间段。为了提高效率,我们需要引入新的操作系统概念 **任务****任务切换****任务上下文**
如果把应用程序执行的整个过程进行进一步分析,可以看到,如果程序访问 IO 或睡眠等待时,其实是不需要占用处理器的,于是我们可以把应用程序的不同时间段的执行过程分为两类,占用处理器执行有效任务的计算阶段和不必占用处理器的等待阶段。这些按时间流连接在一起的不同类型的多个阶段形成了一个我们熟悉的“暂停-继续...”组合的 :ref:`执行流或执行历史 <term-execution-flow>` 。从开始到结束的整个执行流就是应用程序的整个执行过程。
本节的重点是操作系统的核心机制—— **任务切换** 。 任务切换支持的场景是:一个应用在运行途中便会主动交出 CPU 的使用权,此时它只能暂停执行,等到内核重新给它分配处理器资源之后才能恢复并继续执行。
任务的概念形成
---------------------------------
..
chyyuu程序执行过程的图示。
如果操作系统能够在某个应用程序处于等待阶段的时候,把处理器转给另外一个处于计算阶段的应用程序,那么只要转换的开销不大,那么处理器的执行效率就会大大提高。当然,这需要应用程序在运行途中能主动交出 CPU 的使用权,此时它处于等待阶段,等到操作系统让它再次执行后,那它就可以继续执行了。
.. _term-task:
.. _term-task-switch:
到这里,我们就把应用程序的一个计算阶段的执行过程(也是一段执行流)称为一个 **任务** ,所有的任务都完成后,应用程序也就完成了。从一个程序的任务切换到另外一个程序的任务称为 **任务切换** 。为了确保切换后的任务能够正确继续执行,操作系统需要支持让任务的执行“暂停”和“继续”。
.. _term-task-context:
我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些物理资源被称为 **任务上下文 (Task Context)**
这里,大家开始在具体的操作系统中接触到了一些抽象的概念,其实这些概念都是具体代码的结构和代码动态执行过程的文字表述而已。
不同类型的上下文与切换
---------------------------------
在执行流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复任务上下文。任务的一次切换涉及到被换出和即将被换入的两条执行流(分属两个任务),通常它们都需要共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们:
- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 <function-call-and-stack>` 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 <term-function-context>` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。
- 第二章《批处理系统》中第一次涉及到了某种异常Trap控制流即两条执行流的切换需要保存和恢复 :ref:`系统调用Trap上下文 <term-trap-context>` 。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件
提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S 特权级,有能力处理应用执行过程中提出的请求或遇到的状况。
应用程序与操作系统打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用执行流和在 S 特权级运行的 Trap 执行流操作系统的陷入处理部分之间的切换。Trap 执行流是在 Trap 触发的一瞬间生成的,它和原应用执行流有着很密切的联系,因为它唯一的目标就是处理 Trap 并恢复到原应用执行流。而且,由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 执行流在“负重前行”。Trap 执行流需要把 **Trap 上下文** 保存在自己的
内核栈上,里面包含几乎所有的通用寄存器,因为在 Trap 处理过程中它们都可能被用到。如果有需要的话,可以回看
:ref:`Trap 上下文保存与恢复 <trap-context-save-restore>` 小节。
任务切换的设计与实现
---------------------------------
本节的任务切换的执行过程是第二章的 Trap 之后的另一种异常控制流,都是描述两条执行流之间的切换,如果将它和 Trap 切换进行比较,会有如下异同:
- 与 Trap 切换不同,它不涉及特权级切换;
- 与 Trap 切换不同,它的一部分是由编译器帮忙完成的;
- 与 Trap 切换相同,它对应用是透明的。
事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 模式的操作系统中进行进一步处理即进入了操作系统的Trap执行流的时候其 Trap 执行流可以调用一个特殊的 ``__switch`` 函数。这个函数表面上就是一个普通的函数调用:在 ``__switch`` 返回之后,将继续从调用该函数的位置继续向下执行。但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 ``__switch`` 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是它之前切换到的那一条)切换回来继续执行并最终返回。不过,从实现的角度讲, ``__switch`` 和一个普通的函数之间的差别仅仅是它会换栈。
.. image:: task_context.png
当 Trap 执行流准备调用 ``__switch`` 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用 ``__switch`` 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 **任务上下文** (Task Context)。我们会在稍后介绍里面需要包含哪些寄存器。至于保存的位置,我们将任务上下文直接压入内核栈的栈顶,从这一点上来说它和函数调用一样。
这样需要保存的信息就已经确实的保存在内核栈上了,而恢复的时候我们要从任务上下文的位置——也就是这一时刻内核栈栈顶的位置找到被保存的寄存器快照进行恢复,这个位置也需要被保存下来。对于每一条被暂停的 Trap 执行流,我们都用一个名为 ``task_cx_ptr`` 的变量来保存它栈顶的任务上下文的地址。利用 C 语言的引用来描述的话就是:
.. code-block:: C
TaskContext *task_cx_ptr = &task_cx;
由于我们要用 ``task_cx_ptr`` 这个变量来进行保存任务上下文的地址,自然也要对任务上下文的地址进行修改。于是我们还需要指向 ``task_cx_ptr`` 这个变量的指针 ``task_cx_ptr2``
.. code-block:: C
TaskContext **task_cx_ptr2 = &task_cx_ptr;
接下来我们同样从栈上内容的角度来看 ``__switch`` 的整体流程:
.. image:: switch-1.png
.. image:: switch-2.png
Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 执行流,因此 ``__switch`` 有两个参数,第一个参数代表它自己,第二个参数则代表即将切换到的那条 Trap 执行流。这里我们用上面提到过的 ``task_cx_ptr2`` 作为代表。在上图中我们假设某次 ``__switch`` 调用要从 Trap 执行流 A 切换到 B一共可以分为五个阶段在每个阶段中我们都给出了 A 和 B 内核栈上的内容。
- 阶段 [1]:在 Trap 执行流 A 调用 ``__switch`` 之前A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换出去的,它的栈顶还有额外的一个任务上下文;
- 阶段 [2]A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 ``task_cx_ptr``,只需写入指向它的指针 ``task_cx_ptr2`` 指向的内存即可;
- 阶段 [3]:这一步极为关键。这里读取 B 的 ``task_cx_ptr`` 或者说 ``task_cx_ptr2`` 指向的那块内存获取到 B 的内核栈栈顶位置,并复制给 ``sp`` 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 **换栈也就实现了执行流的切换** 。正是因为这一步, ``__switch`` 才能做到一个函数跨两条执行流执行。
- 阶段 [4]CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。
- 阶段 [5]:对于 B 而言, ``__switch`` 函数返回,可以从调用 ``__switch`` 的位置继续向下执行。
从结果来看,我们看到 A 执行流 和 B 执行流的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复了上下文并在 CPU 上执行。
下面我们给出 ``__switch`` 的实现:
.. code-block:: riscv
:linenos:
# os/src/task/switch.S
.altmacro
.macro SAVE_SN n
sd s\n, (\n+1)*8(sp)
.endm
.macro LOAD_SN n
ld s\n, (\n+1)*8(sp)
.endm
.section .text
.globl __switch
__switch:
# __switch(
# current_task_cx_ptr2: &*const TaskContext,
# next_task_cx_ptr2: &*const TaskContext
# )
# push TaskContext to current sp and save its address to where a0 points to
addi sp, sp, -13*8
sd sp, 0(a0)
# fill TaskContext with ra & s0-s11
sd ra, 0(sp)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr
# ready for loading TaskContext a1 points to
ld sp, 0(a1)
# load registers in the TaskContext
ld ra, 0(sp)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# pop TaskContext
addi sp, sp, 13*8
ret
我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 ``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 <term-calling-convention>` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。
阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器进行保存,从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器:
.. code-block:: rust
:linenos:
// os/src/task/context.rs
#[repr(C)]
pub struct TaskContext {
ra: usize,
s: [usize; 12],
}
这里面只保存了 ``ra`` 和被调用者保存的 ``s0~s11````ra`` 的保存很重要,它记录了 ``__switch`` 返回之后应该到哪里继续执行,从而在切换回来并 ``ret`` 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将这段汇编代码中的全局符号 ``__switch`` 解释为一个 Rust 函数:
.. code-block:: rust
:linenos:
// os/src/task/switch.rs
global_asm!(include_str!("switch.S"));
extern "C" {
pub fn __switch(
current_task_cx_ptr2: *const usize,
next_task_cx_ptr2: *const usize
);
}
我们会调用该函数来完成切换功能而不是直接跳转到符号 ``__switch`` 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复调用者保存寄存器的汇编代码。
仔细观察的话可以发现 ``TaskContext`` 很像一个普通函数栈帧中的内容。正如之前所说, ``__switch`` 的实现除了换栈之外几乎就是一个普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。
剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 ``__switch`` 的两个参数相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。
..
chyyuu有一个内核态切换的例子。

View File

@ -0,0 +1,443 @@
多道程序与协作式调度
=========================================
本节导读
--------------------------
上一节我们已经介绍了任务切换是如何实现的,最终我们将其封装为一个函数 ``__switch`` 。但是在实际使用的时候,我们需要知道何时调用该函数,以及如何确定传入函数的两个参数——分别代表正待换出和即将被换入的两条 Trap 执行流。本节我们就来介绍任务切换的第一种实际应用场景:多道程序。
本节的一个重点是展示进一步增强的操作系统管理能力的和对处理器资源的相对高效利用。为此,对 **任务** 的概念进行进一步扩展和延伸:形成了
- 任务运行状态:任务从开始到结束执行过程中所处的不同运行状态:未初始化、准备执行、正在执行、已退出
- 任务控制块:管理程序的执行过程的任务上下文,控制程序的执行与暂停
- 任务相关系统调用:应用程序和操作系统直接的接口,用于程序主动暂停 ``sys_yield`` 和主动退出 ``sys_exit``
本节的代码可以在 ``ch3-coop`` 分支上找到。
多道程序背景与 yield 系统调用
-------------------------------------------------------------------------
还记得第二章中介绍的批处理系统的设计初衷吗?它是注意到 CPU 并没有一直在执行应用程序,在一个应用程序运行结束直到下一个应用程序开始运行的这段时间,可能需要操作员取出上一个程序的执行结果并手动进行程序卡片的替换,这段空档期对于宝贵的 CPU 计算资源是一种巨大的浪费。于是批处理系统横空出世,它可以自动连续完成应用的加载和运行,并将一些本不需要 CPU 完成的简单任务交给廉价的外围设备,从而让 CPU 能够更加专注于计算任务本身,大大提高了 CPU 的利用率。
.. _term-input-output:
尽管 CPU 一直在跑应用了,但是其利用率仍有上升的空间。随着应用需求的不断复杂,有的时候会在内核的监督下访问一些外设,它们也是计算机系统的另一个非常重要的组成部分,即 **输入/输出** (I/O, Input/Output) 。CPU 会将请求和一些附加的参数写入外设,待外设处理完毕之后, CPU 便可以从外设读到请求的处理结果。比如在从作为外部存储的磁盘上读取数据的时候CPU 将要读取的扇区的编号以及读到的数据放到的物理地址传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。
在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上,这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。
我们暂时考虑 CPU 只能 *单方面* 通过读取外设提供的寄存器来获取外设请求处理的状态。多道程序的思想在于:内核同时管理多个应用。如果外设处理的时间足够长,那我们可以先进行任务切换去执行其他应用,在某次切换回来之后,应用再次读取设备寄存器,发现请求已经处理完毕了,那么就可以用拿到的完整的数据继续向下执行了。这样的话,只要同时存在的应用足够多,就能保证 CPU 不必浪费时间在等待外设上,而是几乎一直在进行计算。这种任务切换,是通过应用进行一个名为 ``sys_yield`` 的系统调用来实现的,这意味着它主动交出 CPU 的使用权给其他应用。
这正是本节标题的后半部分“协作式”的含义。一个应用会持续运行下去,直到它主动调用 ``sys_yield`` 来交出 CPU 使用权。内核将很大的权力下放到应用,让所有的应用互相协作来最终达成最大化 CPU 利用率,充分利用计算资源这一终极目标。在计算机发展的早期,由于应用基本上都是一些简单的计算任务,且程序员都比较遵守规则,因此内核可以信赖应用,这样协作式的制度是没有问题的。
.. image:: multiprogramming.png
上图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。开始时,某个应用(蓝色)向外设提交了一个请求,随即可以看到对应的外设(紫色)开始工作。但是它要工作相当长的一段时间,因此应用(蓝色)不会去等待它结束而是会调用 ``sys_yield`` 主动交出 CPU 使用权来切换到另一个应用(绿色)。另一个应用(绿色)在执行了一段时间之后调用了 ``sys_yield`` ,此时内核决定让应用(蓝色)继续执行。它检查了一下外设的工作状态,发现请求尚未处理完,于是再次调用 ``sys_yield`` 。然后另一个应用(绿色)执行了一段时间之后 ``sys_yield`` 再次切换回这个应用(蓝色),这次的不同是它发现外设已经处理完请求了,于是它终于可以向下执行了。
上面我们是通过“避免无谓的外设等待来提高 CPU 利用率”这一切入点来引入 ``sys_yield`` 。但其实调用 ``sys_yield`` 不一定与外设有关。随着内核功能的逐渐复杂,我们还会遇到很多其他类型的需要等待其完成才能继续向下执行的事件,我们都可以立即调用 ``sys_yield`` 来避免等待过程造成的浪费。
.. note::
**sys_yield 的缺点**
请读者思考一下, ``sys_yield`` 存在哪些缺点?
当应用调用它主动交出 CPU 使用权之后,它下一次再被允许使用 CPU 的时间点与内核的调度策略与当前的总体应用执行情况有关,很有可能远远迟于该应用等待的事件(如外设处理完请求)达成的时间点。这就会造成该应用的响应延迟不稳定,有可能极高。比如,设想一下,敲击键盘之后隔了数分钟之后才能在屏幕上看到字符,这已经超出了人类所能忍受的范畴。
但也请不要担心,我们后面会有更加优雅的解决方案。
我们给出 ``sys_yield`` 的标准接口:
.. code-block:: rust
:caption: 第三章新增系统调用(一)
/// 功能:应用主动交出 CPU 所有权并切换到其他应用。
/// 返回值:总是返回 0。
/// syscall ID124
fn sys_yield() -> isize;
然后是用户库对应的实现和封装:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_yield() -> isize {
syscall(SYSCALL_YIELD, [0, 0, 0])
}
// user/src/lib.rs
pub fn yield_() -> isize { sys_yield() }
注意 ``yield`` 是 Rust 的关键字,因此我们只能将应用直接调用的接口命名为 ``yield_``
接下来我们介绍内核应如何实现该系统调用。
任务控制块与任务运行状态
---------------------------------------------------------
在第二章批处理系统中我们只需知道目前执行到第几个应用就行了,因为同一时间内核只管理一个应用,当它出错或退出之后内核会
将其替换为另一个。然而,一旦引入了任务切换机制就没有那么简单了,同一时间内核需要管理多个未完成的应用,而且我们不能对
应用完成的顺序做任何假定,并不是先加入的应用就一定会先完成。这种情况下,我们必须在内核中对每个应用分别维护它的运行
状态,目前有如下几种:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
#[derive(Copy, Clone, PartialEq)]
pub enum TaskStatus {
UnInit, // 未初始化
Ready, // 准备运行
Running, // 正在运行
Exited, // 已退出
}
.. note::
**Rust 语法卡片:#[derive]**
通过 ``#[derive(...)]`` 可以让编译器为你的类型提供一些 Trait 的默认实现。
- 实现了 ``Clone`` Trait 之后就可以调用 ``clone`` 函数完成拷贝;
- 实现了 ``PartialEq`` Trait 之后就可以使用 ``==`` 运算符比较该类型的两个实例,从逻辑上说只有
两个相等的应用执行状态才会被判为相等,而事实上也确实如此。
- ``Copy`` 是一个标记 Trait决定该类型在按值传参/赋值的时候取移动语义还是复制语义。
.. _term-task-control-block:
仅仅有这个是不够的,内核还需要保存一个应用的更多信息,我们将它们都保存在一个名为 **任务控制块** (Task Control Block) 的数据结构中:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
pub struct TaskControlBlock {
pub task_cx_ptr: usize,
pub task_status: TaskStatus,
}
impl TaskControlBlock {
pub fn get_task_cx_ptr2(&self) -> *const usize {
&self.task_cx_ptr as *const usize
}
}
可以看到我们还在 ``task_cx_ptr`` 字段中维护了一个上一小节中提到的指向应用被切换出去的时候,它内核栈栈顶的任务上下文的指针。而在任务切换函数 ``__switch`` 中我们需要用这个 ``task_cx_ptr`` 的指针作为参数并代表这个应用,于是 ``TaskControlBlock`` 还提供了获取这个指针的指针 ``task_cx_ptr2`` 的方法 ``get_task_cx_ptr2``
任务控制块非常重要。在内核中,它就是应用的管理单位。在后面的章节我们还会不断向里面添加更多内容。
任务管理器
--------------------------------------
我们还需要一个全局的任务管理器来管理这些用任务控制块描述的应用:
.. code-block:: rust
// os/src/task/mod.rs
pub struct TaskManager {
num_app: usize,
inner: RefCell<TaskManagerInner>,
}
struct TaskManagerInner {
tasks: [TaskControlBlock; MAX_APP_NUM],
current_task: usize,
}
unsafe impl Sync for TaskManager {}
其中仍然使用到了变量与常量分离的编程风格:字段 ``num_app`` 仍然表示任务管理器管理的应用的数目,它在 ``TaskManager`` 初始化之后就不会发生变化;而包裹在 ``TaskManagerInner`` 内的任务控制块数组 ``tasks`` 以及表示 CPU 正在执行的应用编号 ``current_task`` 会在执行应用的过程中发生变化:每个应用的运行状态都会发生变化,而 CPU 执行的应用也在不断切换。
再次强调,这里的 ``current_task`` 与第二章批处理系统中的含义不同。在批处理系统中,它表示一个既定的应用序列中的执行进度,隐含着在该应用之前的都已经执行完毕,之后都没有执行;而在这里我们只能通过它知道 CPU 正在执行哪个应用,而不能获得其他应用的任何信息。
我们在使用之前初始化 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` (为此也需要将 ``TaskManager`` 标记为 ``Sync``
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
lazy_static! {
pub static ref TASK_MANAGER: TaskManager = {
let num_app = get_num_app();
let mut tasks = [
TaskControlBlock { task_cx_ptr: 0, task_status: TaskStatus::UnInit };
MAX_APP_NUM
];
for i in 0..num_app {
tasks[i].task_cx_ptr = init_app_cx(i) as * const _ as usize;
tasks[i].task_status = TaskStatus::Ready;
}
TaskManager {
num_app,
inner: RefCell::new(TaskManagerInner {
tasks,
current_task: 0,
}),
}
};
}
- 第 5 行:调用 ``loader`` 子模块提供的 ``get_num_app`` 接口获取链接到内核的应用总数,后面会用到;
- 第 6~9 行:创建一个初始化的 ``tasks`` 数组,其中的每个任务控制块的运行状态都是 ``UnInit`` 代表尚未初始化;
- 第 10~12 行:依次对每个任务控制块进行初始化,将其运行状态设置为 ``Ready`` ,并在它的内核栈栈顶压入一些初始化
的上下文,然后更新它的 ``task_cx_ptr`` 。一些细节我们会稍后介绍。
- 从第 14 行开始:创建 ``TaskManager`` 实例并返回。
实现 sys_yield 和 sys_exit
----------------------------------------------------------------------------
``sys_yield`` 的实现用到了 ``task`` 子模块提供的 ``suspend_current_and_run_next`` 接口:
.. code-block:: rust
// os/src/syscall/process.rs
use crate::task::suspend_current_and_run_next;
pub fn sys_yield() -> isize {
suspend_current_and_run_next();
0
}
这个接口如字面含义,就是暂停当前的应用并切换到下个应用。
同样, ``sys_exit`` 也改成基于 ``task`` 子模块提供的 ``exit_current_and_run_next`` 接口:
.. code-block:: rust
// os/src/syscall/process.rs
use crate::task::exit_current_and_run_next;
pub fn sys_exit(exit_code: i32) -> ! {
println!("[kernel] Application exited with code {}", exit_code);
exit_current_and_run_next();
panic!("Unreachable in sys_exit!");
}
它的含义是退出当前的应用并切换到下个应用。在调用它之前我们打印应用的退出信息并输出它的退出码。如果是应用出错也应该调用该接口,不过我们这里并没有实现,有兴趣的读者可以尝试。
那么 ``suspend_current_and_run_next````exit_current_and_run_next`` 各是如何实现的呢?
.. code-block:: rust
// os/src/task/mod.rs
pub fn suspend_current_and_run_next() {
mark_current_suspended();
run_next_task();
}
pub fn exit_current_and_run_next() {
mark_current_exited();
run_next_task();
}
它们都是先修改当前应用的运行状态,然后尝试切换到下一个应用。修改运行状态比较简单,实现如下:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
fn mark_current_suspended() {
TASK_MANAGER.mark_current_suspended();
}
fn mark_current_exited() {
TASK_MANAGER.mark_current_exited();
}
impl TaskManager {
fn mark_current_suspended(&self) {
let mut inner = self.inner.borrow_mut();
let current = inner.current_task;
inner.tasks[current].task_status = TaskStatus::Ready;
}
fn mark_current_exited(&self) {
let mut inner = self.inner.borrow_mut();
let current = inner.current_task;
inner.tasks[current].task_status = TaskStatus::Exited;
}
}
``mark_current_suspended`` 为例。它调用了全局任务管理器 ``TASK_MANAGER````mark_current_suspended`` 方法。其中,首先获得里层 ``TaskManagerInner`` 的可变引用,然后根据其中记录的当前正在执行的应用 ID 对应在任务控制块数组 ``tasks`` 中修改状态。
接下来看看 ``run_next_task`` 的实现:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
fn run_next_task() {
TASK_MANAGER.run_next_task();
}
impl TaskManager {
fn run_next_task(&self) {
if let Some(next) = self.find_next_task() {
let mut inner = self.inner.borrow_mut();
let current = inner.current_task;
inner.tasks[next].task_status = TaskStatus::Running;
inner.current_task = next;
let current_task_cx_ptr2 = inner.tasks[current].get_task_cx_ptr2();
let next_task_cx_ptr2 = inner.tasks[next].get_task_cx_ptr2();
core::mem::drop(inner);
unsafe {
__switch(
current_task_cx_ptr2,
next_task_cx_ptr2,
);
}
} else {
panic!("All applications completed!");
}
}
}
``run_next_task`` 使用任务管理器的全局实例 ``TASK_MANAGER````run_next_task`` 方法。它会调用 ``find_next_task`` 方法尝试寻找一个运行状态为 ``Ready`` 的应用并返回其 ID 。注意到其返回的类型是 ``Option<usize>`` ,也就是说不一定能够找到,当所有的应用都退出并将自身状态修改为 ``Exited`` 就会出现这种情况,此时 ``find_next_task`` 应该返回 ``None`` 。如果能够找到下一个可运行的应用的话,我们就可以分别拿到当前应用 ``current`` 和即将被切换到的应用 ``next````task_cx_ptr2`` ,然后调用 ``__switch`` 接口进行切换。如果找不到的话,说明所有的应用都运行完毕了,我们可以直接 panic 退出内核。
注意在实际切换之前我们需要手动 drop 掉我们获取到的 ``TaskManagerInner`` 的可变引用。因为一般情况下它是在函数退出之后才会被自动释放,从而 ``TASK_MANAGER````inner`` 字段得以回归到未被借用的状态,之后可以再借用。如果不手动 drop 的话,编译器会在 ``__switch`` 返回,也就是当前应用被切换回来的时候才 drop这期间我们都不能修改 ``TaskManagerInner`` ,甚至不能读(因为之前是可变借用)。正因如此,我们需要在 ``__switch`` 前提早手动 drop 掉 ``inner``
于是 ``find_next_task`` 又是如何实现的呢?
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
impl TaskManager {
fn find_next_task(&self) -> Option<usize> {
let inner = self.inner.borrow();
let current = inner.current_task;
(current + 1..current + self.num_app + 1)
.map(|id| id % self.num_app)
.find(|id| {
inner.tasks[*id].task_status == TaskStatus::Ready
})
}
}
``TaskManagerInner````tasks`` 是一个固定的任务控制块组成的表,长度为 ``num_app`` ,可以用下标 ``0~num_app-1`` 来访问得到每个应用的控制状态。我们的任务就是找到 ``current_task`` 后面第一个状态为 ``Ready`` 的应用。因此从 ``current_task + 1`` 开始循环一圈,需要首先对 ``num_app`` 取模得到实际的下标,然后检查它的运行状态。
.. note::
**Rust 语法卡片:迭代器**
``a..b`` 实际上表示左闭右开区间 :math:`[a,b)` ,在 Rust 中,它会被表示为类型 ``core::ops::Range`` ,标准库中为它实现好了 ``Iterator`` trait因此它也是一个迭代器。
关于迭代器的使用方法如 ``map/find`` 等,请参考 Rust 官方文档。
我们可以总结一下应用的运行状态变化图:
.. image:: fsm-coop.png
第一次进入用户态
------------------------------------------
在应用真正跑起来之前,需要 CPU 第一次从内核态进入用户态。我们在第二章批处理系统中也介绍过实现方法,只需在内核栈上压入构造好的 Trap 上下文,然后 ``__restore`` 即可。本章的思路大致相同,但是有一些变化。
当一个应用即将被运行的时候,它会被 ``__switch`` 过来。如果它是之前被切换出去的话,那么此时它的内核栈上应该有 Trap 上下文和任务上下文,切换机制可以正常工作。但是如果它是第一次被执行怎么办呢?这就需要它的内核栈上也有类似结构的内容。我们是在创建 ``TaskManager`` 的全局实例 ``TASK_MANAGER`` 的时候来进行这个初始化的。
.. code-block:: rust
// os/src/task/mod.rs
for i in 0..num_app {
tasks[i].task_cx_ptr = init_app_cx(i) as * const _ as usize;
tasks[i].task_status = TaskStatus::Ready;
}
当时我们进行了这样的操作。 ``init_app_cx`` 是在 ``loader`` 子模块中定义的:
.. code-block:: rust
// os/src/loader.rs
pub fn init_app_cx(app_id: usize) -> &'static TaskContext {
KERNEL_STACK[app_id].push_context(
TrapContext::app_init_context(get_base_i(app_id), USER_STACK[app_id].get_sp()),
TaskContext::goto_restore(),
)
}
impl KernelStack {
fn get_sp(&self) -> usize {
self.data.as_ptr() as usize + KERNEL_STACK_SIZE
}
pub fn push_context(&self, trap_cx: TrapContext, task_cx: TaskContext) -> &'static mut TaskContext {
unsafe {
let trap_cx_ptr = (self.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;
*trap_cx_ptr = trap_cx;
let task_cx_ptr = (trap_cx_ptr as usize - core::mem::size_of::<TaskContext>()) as *mut TaskContext;
*task_cx_ptr = task_cx;
task_cx_ptr.as_mut().unwrap()
}
}
}
这里 ``KernelStack````push_context`` 方法先压入一个和之前相同的 Trap 上下文,再在它上面压入一个任务上下文,然后返回任务上下文的地址。这个任务上下文是我们通过 ``TaskContext::goto_restore`` 构造的:
.. code-block:: rust
// os/src/task/context.rs
impl TaskContext {
pub fn goto_restore() -> Self {
extern "C" { fn __restore(); }
Self {
ra: __restore as usize,
s: [0; 12],
}
}
}
它只是将任务上下文的 ``ra`` 寄存器设置为 ``__restore`` 的入口地址。这样,在 ``__switch`` 从它上面恢复并返回之后就会直接跳转到 ``__restore`` ,此时栈顶是一个我们构造出来第一次进入用户态执行的 Trap 上下文,就和第二章的情况一样了。
需要注意的是, ``__restore`` 的实现需要做出变化:它 **不再需要** 在开头 ``mv sp, a0`` 了。因为在 ``__switch`` 之后,``sp`` 就已经正确指向了我们需要的 Trap 上下文地址。
``rust_main`` 中我们调用 ``task::run_first_task`` 来开始应用的执行:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
impl TaskManager {
fn run_first_task(&self) {
self.inner.borrow_mut().tasks[0].task_status = TaskStatus::Running;
let next_task_cx_ptr2 = self.inner.borrow().tasks[0].get_task_cx_ptr2();
let _unused: usize = 0;
unsafe {
__switch(
&_unused as *const _,
next_task_cx_ptr2,
);
}
}
}
pub fn run_first_task() {
TASK_MANAGER.run_first_task();
}
这里我们取出即将最先执行的编号为 0 的应用的 ``task_cx_ptr2`` 并希望能够切换过去。注意 ``__switch`` 有两个参数分别表示当前应用和即将切换到的应用的 ``task_cx_ptr2`` ,其第一个参数存在的意义是记录当前应用的任务上下文被保存在哪里,也就是当前应用内核栈的栈顶,这样之后才能继续执行该应用。但在 ``run_first_task`` 的时候,我们并没有执行任何应用, ``__switch`` 前半部分的保存仅仅是在启动栈上保存了一些之后不会用到的数据,自然也无需记录启动栈栈顶的位置。
因此,我们显式声明了一个 ``_unused`` 变量,并将它的地址作为第一个参数传给 ``__switch`` ,这样保存一些寄存器之后的启动栈栈顶的位置将会保存在此变量中。然而无论是此变量还是启动栈我们之后均不会涉及到,一旦应用开始运行,我们就开始在应用的用户栈和内核栈之间开始切换了。这里声明此变量的意义仅仅是为了避免覆盖到其他数据。
三叠纪“始初龙”协作式操作系统
---------------------------------
简介与画图!!!

View File

@ -0,0 +1,301 @@
分时多任务系统与抢占式调度
===========================================================
本节导读
--------------------------
本节的重点是操作系统对中断的处理和对应用程序的抢占。为此,对 **任务** 的概念进行进一步扩展和延伸:
- 分时多任务:操作系统管理每个应用程序,以时间片为单位来分时占用处理器运行应用。
- 时间片轮转调度:操作系统在一个程序用完其时间片后,就抢占当前程序并调用下一个程序执行,周而复始,形成对应用程序在任务级别上的时间片轮转调度。
分时多任务系统的背景
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. _term-throughput:
上一节我们介绍了多道程序,它是一种允许应用在等待外设时主动切换到其他应用来达到总体 CPU 利用率最高的设计。那个时候,应用是不太注重自身的运行情况的,即使它 yield 交出 CPU 资源之后需要很久才能再拿到,使得它真正在 CPU 执行的相邻两时间段距离都很远,应用也是无所谓的。因为它们的目标是总体 CPU 利用率最高,可以换成一个等价的指标: **吞吐量** (Throughput) 。大概可以理解为在某个时间点将一组应用放进去,要求在一段固定的时间之内执行完毕的应用最多,或者是总进度百分比最大。因此,所有的应用和编写应用的程序员都有这样的共识:只要 CPU 一直在做实际的工作就好。
.. _term-background-application:
.. _term-interactive-application:
.. _term-latency:
从现在的眼光来看,当时的应用更多是一种 **后台应用** (Background Application) ,在将它加入执行队列之后我们只需定期确认它的运行状态。而随着技术的发展,涌现了越来越多的 **交互式应用** (Interactive Application) ,它们要达成的一个重要目标就是提高用户操作的响应速度,这样才能优化应用的使用体验。对于这些应用而言,即使需要等待外设或某些事件,它们也不会倾向于主动 yield 交出 CPU 使用权,因为这样可能会带来无法接受的延迟。也就是说,应用之间相比合作更多的是互相竞争宝贵的硬件资源。
.. _term-cooperative-scheduling:
.. _term-preemptive-scheduling:
如果应用自己很少 yield ,内核就要开始收回之前下放的权力,由它自己对 CPU 资源进行集中管理并合理分配给各应用,这就是内核需要提供的任务调度能力。我们可以将多道程序的调度机制分类成 **协作式调度** (Cooperative Scheduling) ,因为它的特征是:只要一个应用不主动 yield 交出 CPU 使用权,它就会一直执行下去。与之相对, **抢占式调度** (Preemptive Scheduling) 则是应用 *随时* 都有被内核切换出去的可能。
.. _term-time-slice:
.. _term-fairness:
现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。一般将 **时间片** (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。调度算法需要考虑:每次在换出之前给一个应用多少时间片去执行,以及要换入哪个应用。可以从性能和 **公平性** (Fairness) 两个维度来评价调度算法,后者要求多个应用分到的时间片占比不应差距过大。
时间片轮转调度
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. _term-round-robin:
简单起见,本书中我们使用 **时间片轮转算法** (RR, Round-Robin) 来对应用进行调度,只要对它进行少许拓展就能完全满足我们续的需求。本章中我们仅需要最原始的 RR 算法,用文字描述的话就是维护一个任务队列,每次从队头取出一个应用执行一个时间片,然后把它丢到队尾,再继续从队头取出一个应用,以此类推直到所有的应用执行完毕。
本节的代码可以在 ``ch3`` 分支上找到。
RISC-V 架构中的中断
-----------------------------------
.. _term-interrupt:
.. _term-sync:
.. _term-async:
时间片轮转调度的核心机制就在于计时。操作系统的计时功能是依靠硬件提供的时钟中断来实现的。在介绍时钟中断之前,我们先简单介绍一下中断。
**中断** (Interrupt) 和我们第二章中介绍的 用于系统调用的 **陷入** ``Trap`` 一样都是异常 ,但是它们被触发的原因确是不同的。对于某个处理器核而言, **陷入** 与发起 **陷入** 的指令执行是 **同步** (Synchronous) 的, **陷入** 被触发的原因一定能够追溯到某条指令的执行;而中断则 **异步** (Asynchronous) 于当前正在进行的指令,也就是说中断来自于哪个外设以及中断如何触发完全与处理器正在执行的当前指令无关。
.. _term-parallel:
.. note::
**从底层硬件的角度区分同步和异步**
从底层硬件的角度可能更容易理解这里所提到的同步和异步。以一个处理器传统的五级流水线设计而言,里面含有取指、译码、算术、
访存、寄存器等单元,都属于执行指令所需的硬件资源。那么假如某条指令的执行出现了问题,一定能被其中某个单元看到并反馈给流水线控制单元,从而它会在执行预定的下一条指令之前先进入异常处理流程。也就是说,异常在这些单元内部即可被发现并解决。
而对于中断,可以想象为想发起中断的是一套完全不同的电路(从时钟中断来看就是简单的计数和比较器),这套电路仅通过一根导线接入进来,当想要触发中断的时候则输入一个高电平或正边沿,处理器会在每执行完一条指令之后检查一下这根线,看情况决定是继续执行接下来的指令还是进入中断处理流程。也就是说,大多数情况下,指令执行的相关硬件单元和可能发起中断的电路是完全独立 **并行** (Parallel) 运行的,它们中间只有一根导线相连,除此之外指令执行的那些单元就完全不知道对方处于什么状态了。
在不考虑指令集拓展的情况下RISC-V 架构中定义了如下中断:
.. list-table:: RISC-V 中断一览表
:align: center
:header-rows: 1
:widths: 30 30 60
* - Interrupt
- Exception Code
- Description
* - 1
- 1
- Supervisor software interrupt
* - 1
- 3
- Machine software interrupt
* - 1
- 5
- Supervisor timer interrupt
* - 1
- 7
- Machine timer interrupt
* - 1
- 9
- Supervisor external interrupt
* - 1
- 11
- Machine external interrupt
RISC-V 的中断可以分成三类:
.. _term-software-interrupt:
.. _term-timer-interrupt:
.. _term-external-interrupt:
- **软件中断** (Software Interrupt)
- **时钟中断** (Timer Interrupt)
- **外部中断** (External Interrupt)
另外,相比异常,中断和特权级之间的联系更为紧密,可以看到这三种中断每一个都有 M/S 特权级两个版本。中断的特权级可以决定该中断是否会被屏蔽,以及需要 Trap 到 CPU 的哪个特权级进行处理。
在判断中断是否会被屏蔽的时候,有以下规则:
- 如果中断的特权级低于 CPU 当前的特权级,则该中断会被屏蔽,不会被处理;
- 如果中断的特权级高于与 CPU 当前的特权级或相同,则需要通过相应的 CSR 判断该中断是否会被屏蔽。
以内核所在的 S 特权级为例,中断屏蔽相应的 CSR 有 ``sstatus````sie````sstatus````sie`` 为 S 特权级的中断使能,能够同时控制三种中断,如果将其清零则会将它们全部屏蔽。即使 ``sstatus.sie`` 置 1 ,还要看 ``sie`` 这个 CSR它的三个字段 ``ssie/stie/seie`` 分别控制 S 特权级的软件中断、时钟中断和外部中断的中断使能。比如对于 S 态时钟中断来说,如果 CPU 不高于 S 特权级,需要 ``sstatus.sie````sie.stie`` 均为 1 该中断才不会被屏蔽;如果 CPU 当前特权级高于 S 特权级,则该中断一定会被屏蔽。
如果中断没有被屏蔽,那么接下来就需要 Trap 进行处理,而具体 Trap 到哪个特权级与一些中断代理 CSR 的设置有关。默认情况下,所有的中断都需要 Trap 到 M 特权级处理。而设置这些代理 CSR 之后,就可以 Trap 到低特权级处理,但是 Trap 到的特权级不能低于中断的特权级。事实上所有的异常默认也都是 Trap 到 M 特权级处理的,它们也有一套对应的异常代理 CSR ,设置之后也可以 Trap 到低优先级来处理异常。
我们会在 :doc:`/appendix-c/index` 中再深入介绍中断/异常代理。在正文中我们只需要了解:
- 包括系统调用(即来自 U 特权级的环境调用)在内的所有异常都会 Trap 到 S 特权级处理;
- 只需考虑 S 特权级的时钟/软件/外部中断,且它们都会被 Trap 到 S 特权级处理。
默认情况下,当 Trap 进入某个特权级之后,在 Trap 处理的过程中同特权级的中断都会被屏蔽。这里我们还需要对第二章介绍的 Trap 发生时的硬件机制做一下补充,同样以 Trap 到 S 特权级为例:
- 当 Trap 发生时,``sstatus.sie`` 会被保存在 ``sstatus.spie`` 字段中,同时 ``sstatus.sie`` 置零,这也就在 Trap 处理的过程中屏蔽了所有 S 特权级的中断;
- 当 Trap 处理完毕 ``sret`` 的时候, ``sstatus.sie`` 会恢复到 ``sstatus.spie`` 内的值。
.. _term-nested-interrupt:
也就是说,如果不去手动设置 ``sstatus`` CSR ,在只考虑 S 特权级中断的情况下,是不会出现 **嵌套中断** (Nested Interrupt) 的。嵌套中断是指在处理一个中断的过程中再一次触发了中断从而通过 Trap 来处理。由于默认情况下一旦进入 Trap 硬件就自动禁用所有同特权级中断,自然也就不会再次触发中断导致嵌套中断了。
.. note::
**嵌套中断与嵌套 Trap**
嵌套中断可以分为两部分:在处理一个中断的过程中又被同特权级/高特权级中断所打断。默认情况下硬件会避免前一部分,也可以通过手动设置来允许前一部分的存在;而从上面介绍的规则可以知道,后一部分则是无论如何设置都不可避免的。
嵌套 Trap 则是指处理一个 Trap 过程中又再次发生 Trap ,嵌套中断算是嵌套 Trap 的一部分。
.. note::
**RISC-V 架构的 U 特权级中断**
目前RISC-V 用户态中断作为代号 N 的一个指令集拓展而存在。有兴趣的读者可以阅读最新版的 RISC-V 特权级架构规范一探究竟。
时钟中断与计时器
------------------------------------------------------------------
由于需要一种计时机制RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器统计处理器自上电以来经过了多少个内置时钟的时钟周期。在 RV64 架构上,该计数器保存在一个 64 位的 CSR ``mtime`` 中,我们无需担心它的溢出问题,在内核运行全程可以认为它是一直递增的。
另外一个 64 位的 CSR ``mtimecmp`` 的作用是:一旦计数器 ``mtime`` 的值超过了 ``mtimecmp``,就会触发一次时钟中断。这使得我们可以方便的通过设置 ``mtimecmp`` 的值来决定下一次时钟中断何时触发。
可惜的是,它们都是 M 特权级的 CSR ,而我们的内核处在 S 特权级,是不被硬件允许直接访问它们的。好在运行在 M 特权级的 SEE 已经预留了相应的接口,我们可以调用它们来间接实现计时器的控制:
.. code-block:: rust
// os/src/timer.rs
use riscv::register::time;
pub fn get_time() -> usize {
time::read()
}
``timer`` 子模块的 ``get_time`` 函数可以取得当前 ``mtime`` 计数器的值;
.. code-block:: rust
:linenos:
// os/src/sbi.rs
const SBI_SET_TIMER: usize = 0;
pub fn set_timer(timer: usize) {
sbi_call(SBI_SET_TIMER, timer, 0, 0);
}
// os/src/timer.rs
use crate::config::CLOCK_FREQ;
const TICKS_PER_SEC: usize = 100;
pub fn set_next_trigger() {
set_timer(get_time() + CLOCK_FREQ / TICKS_PER_SEC);
}
- 代码片段第 5 行, ``sbi`` 子模块有一个 ``set_timer`` 调用,是一个由 SEE 提供的标准 SBI 接口函数,它可以用来设置 ``mtimecmp`` 的值。
- 代码片段第 14 行, ``timer`` 子模块的 ``set_next_trigger`` 函数对 ``set_timer`` 进行了封装,它首先读取当前 ``mtime`` 的值,然后计算出 10ms 之内计数器的增量,再将 ``mtimecmp`` 设置为二者的和。这样10ms 之后一个 S 特权级时钟中断就会被触发。
至于增量的计算方式, ``CLOCK_FREQ`` 是一个预先获取到的各平台不同的时钟频率,单位为赫兹,也就是一秒钟之内计数器的增量。它可以在 ``config`` 子模块中找到。10ms 的话只需除以常数 ``TICKS_PER_SEC`` 也就是 100 即可。
后面可能还有一些计时的操作,比如统计一个应用的运行时长的需求,我们再设计一个函数:
.. code-block:: rust
// os/src/timer.rs
const MSEC_PER_SEC: usize = 1000;
pub fn get_time_ms() -> usize {
time::read() / (CLOCK_FREQ / MSEC_PER_SEC)
}
``timer`` 子模块的 ``get_time_ms`` 可以以毫秒为单位返回当前计数器的值,这让我们终于能对时间有一个具体概念了。实现原理就不再赘述。
我们也新增一个系统调用方便应用获取当前的时间,以毫秒为单位:
.. code-block:: rust
:caption: 第三章新增系统调用(二)
/// 功能:获取当前的时间,以毫秒为单位。
/// 返回值:返回当前的时间,以毫秒为单位。
/// syscall ID169
fn sys_get_time() -> isize;
它在内核中的实现只需调用 ``get_time_ms`` 函数即可。
抢占式调度
-----------------------------------
有了时钟中断和计时器,抢占式调度就很容易实现了:
.. code-block:: rust
// os/src/trap/mod.rs
match scause.cause() {
Trap::Interrupt(Interrupt::SupervisorTimer) => {
set_next_trigger();
suspend_current_and_run_next();
}
}
我们只需在 ``trap_handler`` 函数下新增一个分支,当发现触发了一个 S 特权级时钟中断的时候,首先重新设置一个 10ms 的计时器,然后调用上一小节提到的 ``suspend_current_and_run_next`` 函数暂停当前应用并切换到下一个。
为了避免 S 特权级时钟中断被屏蔽,我们需要在执行第一个应用之前进行一些初始化设置:
.. code-block:: rust
:linenos:
:emphasize-lines: 9,10
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
println!("[kernel] Hello, world!");
trap::init();
loader::load_apps();
trap::enable_timer_interrupt();
timer::set_next_trigger();
task::run_first_task();
panic!("Unreachable in rust_main!");
}
// os/src/trap/mod.rs
use riscv::register::sie;
pub fn enable_timer_interrupt() {
unsafe { sie::set_stimer(); }
}
- 第 9 行设置了 ``sie.stie`` 使得 S 特权级时钟中断不会被屏蔽;
- 第 10 行则是设置第一个 10ms 的计时器。
这样,当一个应用运行了 10ms 之后,一个 S 特权级时钟中断就会被触发。由于应用运行在 U 特权级,且 ``sie`` 寄存器被正确设置,该中断不会被屏蔽,而是 Trap 到 S 特权级内的我们的 ``trap_handler`` 里面进行处理,并顺利切换到下一个应用。这便是我们所期望的抢占式调度机制。从应用运行的结果也可以看出,三个 ``power`` 系列应用并没有进行 yield ,而是由内核负责公平分配它们执行的时间片。
目前在等待某些事件的时候仍然需要 yield ,其中一个原因是为了节约 CPU 计算资源,另一个原因是当事件依赖于其他的应用的时候,由于只有一个 CPU ,当前应用的等待可能永远不会结束。这种情况下需要先将它切换出去,使得其他的应用到达它所期待的状态并满足事件的生成条件,再切换回来。
.. _term-busy-loop:
这里我们先通过 yield 来优化 **轮询** (Busy Loop) 过程带来的 CPU 资源浪费。在 ``03sleep`` 这个应用中:
.. code-block:: rust
// user/src/bin/03sleep.rs
#[no_mangle]
fn main() -> i32 {
let current_timer = get_time();
let wait_for = current_timer + 3000;
while get_time() < wait_for {
yield_();
}
println!("Test sleep OK!");
0
}
它的功能是等待 3000ms 然后退出。可以看出,我们会在循环里面 ``yield_`` 来主动交出 CPU 而不是无意义的忙等。尽管我们不这样做,已有的抢占式调度还是会在它循环 10ms 之后切换到其他应用,但是这样能让内核给其他应用分配更多的 CPU 资源并让它们更早运行结束。
三叠纪“腔骨龙”抢占式操作系统
---------------------------------
简介与画图!!!

View File

@ -0,0 +1,182 @@
chapter3练习
=======================================
- 本节难度: **并不那么简单了!早点动手**
编程作业
--------------------------------------
stride 调度算法
+++++++++++++++++++++++++++++++++++++++++
lab3中我们引入了任务调度的概念可以在不同任务之间切换目前我们实现的调度算法十分简单存在一些问题且不存在优先级。现在我们要为我们的 os 实现一种带优先级的调度算法stide 调度算法。
算法描述如下:
(1) 为每个进程设置一个当前 stride表示该进程当前已经运行的“长度”。另外设置其对应的 pass 值只与进程的优先权有关系表示对应进程在调度后stride 需要进行的累加值。
(2) 每次需要调度时,从当前 runnable 态的进程中选择 stride 最小的进程调度。对于获得调度的进程 P将对应的 stride 加上其对应的步长 pass。
(3) 一个时间片后,回到上一步骤,重新调度当前 stride 最小的进程。
可以证明,如果令 P.pass = BigStride / P.priority 其中 P.priority 表示进程的优先权(大于 1而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。
其他实验细节:
- stride 调度要求进程优先级 :math:`\geq 2`,所以设定进程优先级 :math:`\leq 1` 会导致错误。
- 进程初始 stride 设置为 0 即可。
- 进程初始优先级设置为 16。
tips: 可以使用优先级队列比较方便的实现 stride 算法,但是我们的实验不考察效率,所以手写一个简单粗暴的也完全没问题。
实验要求
+++++++++++++++++++++++++++++++++++++++++
- 完成分支: ch3。
- 完成实验指导书中的内容,实现 sys_yield实现协作式和抢占式的调度。
- 实现 stride 调度算法,实现 sys_gettime, sys_set_priority 两个系统调用并通过 `Rust测例 <https://github.com/DeathWish5/rCore_tutorial_tests>`_ 中 chapter3 对应的所有测例,测例详情见对应仓库,系统调用具体要求参考 `guide.md <https://github.com/DeathWish5/rCore_tutorial_tests/blob/master/guide.md>`_
.. _gettime-semantic-diff:
.. note::
**sys_gettime 在测例和教程正文中语义的不同**
为了更加贴近 POSIX 标准系统调用接口,在测例中 ``sys_gettime`` 需要将当前时间保存在一个 ``TimeVal`` 中,但是在用户库 ``user_lib`` 中的 ``get_time`` 函数仍然是以毫秒为单位,它的实现方式是将 ``TimeVal`` 中的秒数 ``sec`` 和微秒数 ``usec`` 转化为合计的毫秒数。因此,如果基于实验框架来做的话, ``sys_gettime`` 在内核中的实现需要发生变化。
另外需要注意的是,在修改之后, ``sys_gettime`` 和 POSIX 标准接口也仅仅做到了格式相同。在 POSIX 标准接口中 ``sys_gettime`` 统计当前相对 1970-01-01 00:00:00 +0000 (UTC) 过去的时间,而我们并没有用到任何 RTC 外设,只能做到统计自开机之后过去的时间。
需要说明的是 lab3 有3类测例``ch3_0_*`` 用来检查基本 syscall 的实现,``ch3_1_*`` 基于 yield 来检测基本的调度,``ch3_2_*`` 基于时钟中断来测试 stride 调度算法实现的正确性。测试时可以分别测试 3 组测例,使得输出更加可控、更加清晰。
challenge: 实现多核,可以并行调度。
实验约定
+++++++++++++++++++++++++++++++++++++++
在第三章的测试中,我们对于内核有如下仅仅为了测试方便的要求,请调整你的内核代码来符合这些要求:
- 人为限制一个程序执行的最大时间(如 5s超过就杀死。这一规定可以在实验4开始删除仅仅为通过 lab3 测例设置。
- 用户栈大小必须为 4096且按照 4096 字节对齐。这一规定可以在实验4开始删除仅仅为通过 lab2 测例设置。
实验检查
++++++++++++++++++++++++++++++++++++++++
- 实验目录要求
目录要求不变(参考 lab1 目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。
目标用户目录 ``../user/build/bin``
- 检查
可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。
简答作业
--------------------------------------------
(1) 简要描述这一章的进程调度策略。何时进行进程切换?如何选择下一个运行的进程?如何处理新加入的进程?
(2) 在 C 版代码中,同样实现了类似 RR 的调度算法,但是由于没有 VecDeque 这样直接可用的数据结构Rust 很棒对不对C 版代码的实现严格来讲存在一定问题。大致情况如下C 版代码使用一个进程池(也就是一个 struct proc 的数组)管理进程调度,当一个时间片用尽后,选择下一个进程逻辑在 `chapter3相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch3/kernel/proc.c#L60-L74>`_ ,也就是当第 i 号进程结束后,会以 i -> max_num -> 0 -> i 的顺序遍历进程池直到找到下一个就绪进程。C 版代码新进程在调度池中的位置选择见 `chapter5相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch5/kernel/proc.c#L90-L98>`_ ,也就是从头到尾遍历进程池,找到第一个空位。
(2-1) 在目前这一章chapter3两种调度策略有实质不同吗考虑在一个完整的 os 中,随时可能有新进程产生,这两种策略是否实质相同?
(2-2) 其实 C 版调度策略在公平性上存在比较大的问题,请找到一个进程产生和结束的时间序列,使得在该调度算法下发生:先创建的进程后执行的现象。你需要给出类似下面例子的信息(有更详细的分析描述更好,但尽量精简)。同时指出该序列在你实现的 stride 调度算法下顺序是怎样的?
.. list-table:: 调度顺序举例
:header-rows: 1
:align: center
* - 时间点
- 0
- 1
- 2
- 3
- 4
- 5
- 6
- 7
* - 运行进程
-
- p1
- p2
- p3
- p1
- p4
- p3
-
* - 事件
- p1、p2、p3产生
-
- p2 结束
- p4 产生
- p1 结束
- p4 结束
- p3 结束
-
产生顺序p1、p2、p3、p4。第一次执行顺序: p1、p2、p3、p4。没有违反公平性。
其他细节:允许进程在其他进程执行时产生(也就是被当前进程创建)/结束(也就是被当前进程杀死)。
(3) stride 算法深入
stride 算法原理非常简单,但是有一个比较大的问题。例如两个 pass = 10 的进程,使用 8bit 无符号整形储存 stride p1.stride = 255, p2.stride = 250在 p2 执行一个时间片后,理论上下一次应该 p1 执行。
- 实际情况是轮到 p1 执行吗?为什么?
我们之前要求进程优先级 >= 2 其实就是为了解决这个问题。可以证明,**在不考虑溢出的情况下**, 在进程优先级全部 >= 2 的情况下,如果严格按照算法执行,那么 STRIDE_MAX STRIDE_MIN <= BigStride / 2。
- 为什么?尝试简单说明(传达思想即可,不要求严格证明)。
已知以上结论,**考虑溢出的情况下**,我们可以通过设计 Stride 的比较接口,结合 BinaryHeap 的 pop 接口可以很容易的找到真正最小的 Stride。
- 请补全如下 ``partial_cmp`` 函数(假设永远不会相等)。
.. code-block:: rust
use core::cmp::Ordering;
struct Stride(u64);
impl PartialOrd for Stride {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
// ...
}
}
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
false
}
}
例如使用 8 bits 存储 stride, BigStride = 255, 则:
- (125 < 255) == false
- (129 < 255) == true
报告要求
-------------------------------
- 简单总结与上次实验相比本次实验你增加的东西控制在5行以内不要贴代码
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。
参考信息
-------------------------------
如果有兴趣进一步了解 stride 调度相关内容,可以尝试看看:
- `作者 Carl A. Waldspurger 写这个调度算法的原论文 <https://people.cs.umass.edu/~mcorner/courses/691J/papers/PS/waldspurger_stride/waldspurger95stride.pdf>`_
- `作者 Carl A. Waldspurger 的博士生答辩slide <http://www.waldspurger.org/carl/papers/phd-mit-slides.pdf>`_
- `南开大学实验指导中对Stride算法的部分介绍 <https://nankai.gitbook.io/ucore-os-on-risc-v64/lab6/tiao-du-suan-fa-kuang-jia#stride-suan-fa>`_
- `NYU OS课关于Stride Scheduling的Slide <https://cs.nyu.edu/~rgrimm/teaching/sp08-os/stride.pdf>`_
如果有兴趣进一步了解用户态线程实现的相关内容,可以尝试看看:
- `user-multitask in rv64 <https://github.com/chyyuu/os_kernel_lab/tree/v4-user-std-multitask>`_
- `绿色线程 in x86 <https://github.com/cfsamson/example-greenthreads>`_
- `x86版绿色线程的设计实现 <https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/>`_
- `用户级多线程的切换原理 <https://blog.csdn.net/qq_31601743/article/details/97514081?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control&dist_request_id=&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control>`_

BIN
source/chapter3/ch3.pptx Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

14
source/chapter3/index.rst Normal file
View File

@ -0,0 +1,14 @@
.. _link-chapter3:
第三章:多道程序与分时多任务
==============================================
.. toctree::
:maxdepth: 4
0intro
1multi-loader
2task-switching
3multiprogramming
4time-sharing-system
5exercise

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

247
source/chapter4/0intro.rst Normal file
View File

@ -0,0 +1,247 @@
引言
==============================
本章导读
-------------------------------
..
chyyuu有一个ascii图画出我们做的OS。
本章展现了操作系统一系列功能:
- 通过动态内存分配,提高了应用程序对内存的动态使用效率
- 通过页表的虚实内存映射机制,简化了编译器对应用的地址空间设置
- 通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全
- 通过页表的虚实内存映射机制,可以实现空分复用(提出,但没有实现)
.. _term-illusion:
.. _term-time-division-multiplexing:
.. _term-transparent:
上一章,我们分别实现了多道程序和分时多任务系统,它们的核心机制都是任务切换。由于多道程序和分时多任务系统的设计初衷不同,它们在任务切换的时机和策略也不同。有趣的一点是,任务切换机制对于应用是完全 **透明** (Transparent) 的,应用可以不对内核实现该机制的策略做任何假定(除非要进行某些针对性优化),甚至可以完全不知道这机制的存在。
在大多数应用(也就是应用开发者)的视角中,它们会独占一整个 CPU 和特定连续或不连续的内存空间。当然通过上一章的学习我们知道在现代操作系统中出于公平性的考虑我们极少会让独占CPU这种情况发生。所以应用自认为的独占CPU只是内核想让应用看到的一种 **幻象** (Illusion) ,而 CPU 计算资源被 **时分复用** (TDM, Time-Division Multiplexing) 的实质被内核通过恰当的抽象隐藏了起来,对应用不可见。
与之相对,我们目前还没有对内存管理功能进行有效的管理,仅仅是把程序放到某处的物理内存中。在内存访问方面,所有的应用都直接通过物理地址访问物理内存,这使得应用开发者需要了解繁琐的物理地址空间布局,访问内存也很不方便。在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都直接通过物理地址访问物理内存。这会带来以下问题:
- 首先,内核提供给应用的内存访问接口不够透明,也不好用。由于应用直接访问物理内存,这需要它在构建的时候就需要规划自己需要被加载到哪个地址运行。为了避免冲突可能还需要应用的开发者们对此进行协商,这显然是一件在今天看来不可理喻且极端麻烦的事情。
- 其次,内核并没有对应用的访存行为进行任何保护措施,每个应用都有整块物理内存的读写权力。即使应用被限制在 U 特权级下运行,它还是能够造成很多麻烦:比如它可以读写其他应用的数据来窃取信息或者破坏它的正常运行;甚至它还可以修改内核的代码段来替换掉原本的 ``trap_handler`` 来挟持内核执行恶意代码。总之,这造成系统既不安全、也不稳定。
- 再次,目前应用的内存使用空间在其运行前已经限定死了,内核不能灵活地给应用程序提供的运行时动态可用内存空间。比如一个应用结束后,这个应用所占的空间就被释放了,但这块空间无法动态地给其它还在运行的应用使用。
因此为了防止应用胡作非为本章将更好的管理物理内存并提供给应用一个抽象出来的更加透明易用、也更加安全的访存接口这就是基于分页机制的虚拟内存。站在应用程序运行的角度看就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间Address Space
实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题:
- 硬件中物理内存的范围是什么?
- 哪些物理内存空间需要建立页映射关系?
- 如何建立页表使能分页机制?
- 如何确保OS能够在分页机制使能前后的不同时间段中都能正常寻址和执行代码
- 页目录表(一级)的起始地址设置在哪里?
- 二级/三级等页表的起始地址设置在哪里,需要多大空间?
- 如何设置页目录表项的内容?
- 如何设置其它页表项的内容?
- 如果要让每个任务有自己的地址空间,那每个任务是否要有自己的页表?
- 代表应用程序的任务和操作系统需要有各自的页表吗?
- 在有了页表之后,任务和操作系统之间应该如何传递数据?
如果能解决上述问题,我们就能设计实现具有超强防护能力的侏罗纪“头甲龙”操作系统。并可更好地理解地址空间,虚拟地址等操作系统的抽象概念与操作系统的虚存具体实现之间的联系。
..
chyyuu在哪里讲解虚存的设计与实现
实践体验
-----------------------
本章的应用和上一章相同,只不过由于内核提供给应用的访存接口被替换,应用的构建方式发生了变化,这方面在下面会深入介绍。
因此应用运行起来的效果与上一章是一致的。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch4
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
将 Maix 系列开发板连接到 PC并在上面运行本章代码
.. code-block:: console
$ cd os
$ make run BOARD=k210
如果顺利的话,我们将看到和上一章相同的运行结果(以 K210 平台为例):
.. code-block::
[rustsbi] RustSBI version 0.1.1
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210 (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x22
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
.text [0x80020000, 0x8002b000)
.rodata [0x8002b000, 0x8002e000)
.data [0x8002e000, 0x8004c000)
.bss [0x8004c000, 0x8035d000)
mapping .text section
mapping .rodata section
mapping .data section
mapping .bss section
mapping physical memory
[kernel] back to world!
remap_test passed!
init TASK_MANAGER
num_app = 4
power_3 [10000/300000power_5 [10000/210000]
power_5 [20000/210000]
power_5 [30000/210000]
...
(mod 998244353)
Test power_7 OK!
[kernel] Application exited with code 0
power_3 [290000/300000]
power_3 [300000/300000]
3^300000 = 612461288(mod 998244353)
Test power_3 OK!
[kernel] Application exited with code 0
Test sleep OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:112 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt. Type: 0, reason: 0
本章代码树
-----------------------------------------------------
.. code-block::
:linenos:
:emphasize-lines: 56
./os/src
Rust 22 Files 1334 Lines
Assembly 3 Files 88 Lines
├── bootloader
│   ├── rustsbi-k210.bin
│   └── rustsbi-qemu.bin
├── LICENSE
├── os
│   ├── build.rs
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── Makefile
│   └── src
│   ├── config.rs(修改:新增一些内存管理的相关配置)
│   ├── console.rs
│   ├── entry.asm
│   ├── lang_items.rs
│   ├── link_app.S
│   ├── linker-k210.ld(修改:将跳板页引入内存布局)
│   ├── linker-qemu.ld(修改:将跳板页引入内存布局)
│   ├── loader.rs(修改:仅保留获取应用数量和数据的功能)
│   ├── main.rs(修改)
│   ├── mm(新增:内存管理的 mm 子模块)
│   │   ├── address.rs(物理/虚拟 地址/页号的 Rust 抽象)
│   │   ├── frame_allocator.rs(物理页帧分配器)
│   │   ├── heap_allocator.rs(内核动态内存分配器)
│   │   ├── memory_set.rs(引入地址空间 MemorySet 及逻辑段 MemoryArea 等)
│   │   ├── mod.rs(定义了 mm 模块初始化方法 init)
│   │   └── page_table.rs(多级页表抽象 PageTable 以及其他内容)
│   ├── sbi.rs
│   ├── syscall
│   │   ├── fs.rs(修改:基于地址空间的 sys_write 实现)
│   │   ├── mod.rs
│   │   └── process.rs
│   ├── task
│   │   ├── context.rs(修改:构造一个跳转到不同位置的初始任务上下文)
│   │   ├── mod.rs(修改,详见文档)
│   │   ├── switch.rs
│   │   ├── switch.S
│   │   └── task.rs(修改,详见文档)
│   ├── timer.rs
│   └── trap
│   ├── context.rs(修改:在 Trap 上下文中加入了更多内容)
│   ├── mod.rs(修改:基于地址空间修改了 Trap 机制,详见文档)
│   └── trap.S(修改:基于地址空间修改了 Trap 上下文保存与恢复汇编代码)
├── README.md
├── rust-toolchain
├── tools
│   ├── kflash.py
│   ├── LICENSE
│   ├── package.json
│   ├── README.rst
│   └── setup.py
└── user
├── build.py(移除)
├── Cargo.toml
├── Makefile
└── src
├── bin
│   ├── 00power_3.rs
│   ├── 01power_5.rs
│   ├── 02power_7.rs
│   └── 03sleep.rs
├── console.rs
├── lang_items.rs
├── lib.rs
├── linker.ld(修改:将所有应用放在各自地址空间中固定的位置)
└── syscall.rs
本章代码导读
-----------------------------------------------------
本章涉及的代码量相对多了起来,也许同学们不知如何从哪里看起或从哪里开始尝试实验。这里简要介绍一下“头甲龙”操作系统的大致开发过程。
我们先从简单的地方入手,那当然就是先改进应用程序了。具体而言,主要就是把 ``linker.ld`` 中应用程序的起始地址都改为 ``0x0`` ,这是假定我们操作系统能够通过分页机制把不同应用的相同虚地址映射到不同的物理地址中。这样我们写应用就不用考虑物理地址布局的问题,能够以一种更加统一的方式编写应用程序,可以忽略掉一些不必要的细节。
为了能够在内核中动态分配内存,我们的第二步需要在内核增加连续内存分配的功能,具体实现主要集中在 ``os/src/mm/heap_allocator.rs`` 中。完成这一步后我们就可以在内核中用到Rust的堆数据结构了``Vec````Box`` 等,这样内核编程就更加灵活了。
操作系统如果要建立页表,首先要能管理整个系统的物理内存,这就需要知道物理内存哪些区域放置内核的代码、数据,哪些区域则是空闲的等信息。所以需要了解整个系统的物理内存空间的范围,并以物理页帧为单位分配和回收物理内存,具体实现主要集中在 ``os/src/mm/frame_allocator.rs`` 中。
页表中的页表项的索引其实是虚拟地址中的虚拟页号,页表项的重要内容是物理地址的物理页帧号。为了能够灵活地在虚拟地址、物理地址、虚拟页号、物理页号之间进行各种转换,在 ``os/src/mm/address.rs`` 中实现了各种转换函数。
完成上述工作后,基本上就做好了建立页表的前期准备。我们就可以开始建立页表,这主要涉及到页表项的数据结构表示,以及多级页表的起始物理页帧位置和整个所占用的物理页帧的记录。具体实现主要集中在 ``os/src/mm/page_table.rs`` 中。
一旦使能分页机制,那么内核中也将基于虚地址进行虚存访问,所以在给应用添加虚拟地址空间前,内核自己也会建立一个页表,把整个物理地址空间通过简单的恒等映射对应到一个虚拟地址空间中。后续的应用在执行前,也需要建立一个虚拟地址空间,这意味着第三章的 ``task`` 将进化到第五章的拥有独立页表的进程 。虚拟地址空间需要有一个数据结构管理起来,这就是 ``MemorySet`` ,即地址空间这个抽象概念所对应的具象体现。在一个虚拟地址空间中,有代码段,数据段等不同属性且不一定连续的子空间,它们通过一个重要的数据结构 ``MapArea`` 来表示和管理。围绕 ``MemorySet`` 等一系列的数据结构和相关操作的实现,主要集中在 ``os/src/mm/memory_set.rs`` 中。比如内核的页表和虚拟空间的建立在如下代码中:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
lazy_static! {
pub static ref KERNEL_SPACE: Arc<Mutex<MemorySet>> = Arc::new(Mutex::new(
MemorySet::new_kernel()
));
}
完成到这里,我们就可以使能分页机制了。且我们应该有更加方便的机制来给支持应用运行。在本章之前,都是把应用程序的所有元数据丢弃从而转换成二进制格式来执行,这其实把编译器生成的 ELF 执行文件中大量有用的信息给去掉了,比如代码段、数据段的各种属性,程序的入口地址等。既然有了给应用运行提供虚拟地址空间的能力,我们就可以利用 ELF 执行文件中的各种信息来灵活构建应用运行所需要的虚拟地址空间。在 ``os/src/loader.rs`` 中可以看到如何获取一个应用的 ELF 执行文件数据,而在 ``os/src/mm/memory_set`` 中的 ``MemorySet::from_elf`` 可以看到如何通过解析 ELF 来创建一个应用地址空间。
对于有了虚拟地址空间的 *任务* ,我们可以把它叫做 *进程* 了。操作系统为此需要扩展任务控制块 ``TaskControlBlock`` 的管理范围,使得操作系统能管理拥有独立页表和虚拟地址空间的应用程序的运行。相关主要的改动集中在 ``os/src/task/task.rs`` 中。
由于代表应用程序运行的进程和管理应用的操作系统各自有独立的页表和虚拟地址空间,所以这就出现了两个比较挑战的事情。一个是由于系统调用、中断或异常导致的应用程序和操作系统之间的 Trap 上下文切换不像以前那么简单了,因为需要切换页表,这需要看看 ``os/src/trap/trap.S`` ;还有就是需要对来自用户态和内核态的 Trap 分别进行处理,这需要看看 ``os/src/trap/mod.rs``:ref:`跳板的实现 <term-trampoline>` 中的讲解。
另外一个挑战是,在内核地址空间中执行的内核代码常常需要读写应用地址空间的数据,这无法简单的通过一次访存交给 MMU 来解决,而是需要手动查应用地址空间的页表。在访问应用地址空间中的一块跨多个页数据的时候还需要注意处理边界条件。可以参考 ``os/src/syscall/fs.rs````os/src/mm/page_table.rs`` 中的 ``translated_byte_buffer`` 函数的实现。
实现到这,应该就可以给应用程序运行提供一个方便且安全的虚拟地址空间了。

View File

@ -0,0 +1,309 @@
Rust 中的动态内存分配
========================================================
本节导读
--------------------------
到目前为止,如果将我们的内核也看成一个应用,那么其中所有的变量都是被静态分配在内存中的,这样在对空闲内存的使用方面缺少灵活性。我们希望能在操作系统中提供动态申请和释放内存的能力,这样就可以加强操作系统对各种以内存为基础的资源分配与管理。
在应用程序的视角中,动态内存分配中的内存,其实就是操作系统管理的“堆 Heap”。但现在要实现操作系统那么就需要操作系统自身能提供动态内存分配的能力。如果要实现动态内存分配的能力需要操作系统需要有如下功能
- 初始时能提供一块大内存空间作为初始的“堆”。在没有分页机制情况下,这块空间是物理内存空间,否则就是虚拟内存空间。
- 提供在堆上分配一块内存的函数接口。这样函数调用方就能够得到一块地址连续的空闲内存块进行读写。
- 提供释放内存的函数接口。能够回收内存,以备后续的内存分配请求。
- 提供空闲空间管理的连续内存分配算法。能够有效地管理空闲快,这样就能够动态地维护一系列空闲和已分配的内存块。
- (可选)提供建立在堆上的数据结构和操作。有了上述基本的内存分配与释放函数接口,就可以实现类似动态数组,动态字典等空间灵活可变的堆数据结构,提高编程的灵活性。
考虑到我们是用Rust来编程的为了在接下来的一些操作系统的实现功能中进一步释放 Rust 语言的强表达能力来减轻我们的编码负担本节我们尝试在内核中支持动态内存分配以可以使用各种需要动态内存支持的Rust功能如Vec、HashMap等。
静态与动态内存分配
----------------------------------------------
静态分配
^^^^^^^^^^^^^^^^^^^^^^^^^
若在某一时间点观察一个应用的地址空间,可以看到若干块连续内存,每一块都对应于一个生命周期尚未结束的变量。这个变量可能
是一个局部变量,它来自于当前正在执行的函数或者当前函数调用栈上某个正在等待调用返回的函数的栈帧,也即它是被分配在
栈上;这个变量也可能是一个全局变量,它被分配在数据段中。它们有一个共同点:在编译的时候编译器已经知道它们类型的字节大小,
于是给它们分配一块等大的内存将它们存储其中,这块内存在变量所属函数的栈帧/数据段中的位置也已经被固定了下来。
.. _term-static-allocation:
这些变量是被 **静态分配** (Static Allocation) 的,这一过程来源于我们在程序中对变量的声明,在编译期由编译器完成。
如果应用仅使用静态分配,它也许可以应付绝大部分的需求,但是某些情况则不够灵活。比如,需要将一个文件读到内存进行处理,
而且必须将文件一次性完整读进来处理才能正确。此时,可以选择声明一个栈上的局部变量或者数据段中的全局变量作为缓冲区来暂存
文件的内容。但在编程的时候我们并不知道待处理的文件的大小,只能根据经验将缓冲区的大小设置为某一固定常数。在代码真正运行
的时候,如果待处理的文件很小,那么缓冲区多出的部分是被浪费掉的,也拉高了应用的内存占用;如果待处理的文件很大,应用则
无法正常运行。就像缓冲区的大小设置一样,还有很多其他的问题来源于某些数据结构需求的内存大小取决于应用的实际运行情况。
动态分配
^^^^^^^^^^^^^^^^^^^^^^^^^
.. _term-dynamic-allocation:
此时,使用 **动态分配** (Dynamic Allocation) 则可以解决这个问题。动态分配就是指应用不仅在自己的地址空间放置那些
自编译期开始就大小固定、用于静态内存分配的逻辑段(如全局数据段、栈段),还另外放置一个大小可以随着应用的运行动态增减
的逻辑段,它的名字叫做堆。同时,应用还要能够将这个段真正管理起来,即支持在运行的时候从里面分配一块空间来存放变量,而
在变量的生命周期结束之后,这块空间需要被回收以待后面的使用。如果堆的大小固定,那么这其实就是一个连续内存分配问题,
我们课上所介绍到的那些算法都可以随意使用。取决于应用的实际运行状况,每次分配的空间大小可能会有不同,因此也会产生外碎片。
如果在某次分配的时候发现堆空间不足,我们并不会像上一小节介绍的那样移动变量的存放位置让它们紧凑起来从而释放间隙用来分配
(事实上它很难做到这一点),
一般情况下应用会直接通过系统调用(如类 Unix 内核提供的 ``sbrk`` 系统调用)来向内核请求增加它地址空间内堆的大小,之后
就可以正常分配了。当然,这一类系统调用也能缩减堆的大小。
鉴于动态分配是一项非常基础的功能,很多高级语言的标准库中都实现了它。以 C 语言为例C 标准库中提供了如下两个动态分配
的接口函数:
.. code-block:: c
void* malloc (size_t size);
void free (void* ptr);
其中,``malloc`` 的作用是从堆中分配一块大小为 ``size`` 字节的空间,并返回一个指向它的指针。而后续不用的时候,将这个
指针传给 ``free`` 即可在堆中回收这块空间。我们通过返回的指针变量来间接访问堆上的空间,而无法直接进行
访问。事实上,我们在程序中能够 *直接* 看到的变量都是被静态分配在栈或者全局数据段上的,它们大小在编译期已知,比如这里
一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的
事情。
除了可以灵活利用内存之外,动态分配还允许我们以尽可能小的代价灵活调整变量的生命周期。一个局部变量被静态分配在它所在函数
的栈帧中,一旦函数返回,这个局部变量的生命周期也就结束了;而静态分配在数据段中的全局变量则是在应用的整个运行期间均存在。
动态分配允许我们构造另一种并不一直存在也不绑定于函数调用的变量生命周期:以 C 语言为例,可以说自 ``malloc`` 拿到指向
一个变量的指针到 ``free`` 将它回收之前的这段时间,这个变量在堆上存在。由于需要跨越函数调用,我们需要作为堆上数据代表
的变量在函数间以参数或返回值的形式进行传递,而这些变量一般都很小(如一个指针),其拷贝开销可以忽略。
而动态内存分配的缺点在于:它背后运行着连续内存分配算法,相比静态分配会带来一些额外的开销。如果动态分配非常频繁,可能会产生很多无法使用的空闲空间碎片,甚至可能会成为应用的性能瓶颈。
.. _rust-heap-data-structures:
Rust 中的堆数据结构
------------------------------------------------
Rust 的标准库中提供了很多开箱即用的堆数据结构,利用它们能够大大提升我们的开发效率。
.. _term-smart-pointer:
首先是一类 **智能指针** (Smart Pointer) 。智能指针和 Rust 中的其他两类指针也即裸指针 ``*const T/*mut T``
以及引用 ``&T/&mut T`` 一样,都指向地址空间中的另一个区域并包含它的位置信息。但不同在于,它们携带的信息数量不等,
需要经过编译器不同等级的安全检查,可靠性和灵活程度也不同。
.. _term-borrow-check:
- 裸指针 ``*const T/*mut T`` 基本等价于 C/C++ 里面的普通指针 ``T*`` ,它自身的内容仅仅是一个地址。它最为灵活,
但是也最不安全。编译器只能对它进行最基本的可变性检查, :ref:`第一章 <term-raw-pointer>` 曾经提到,对于裸指针
解引用访问它指向的那块数据是 unsafe 行为,需要被包裹在 unsafe 块中。
- 引用 ``&T/&mut T`` 自身的内容也仅仅是一个地址,但是 Rust 编译器会在编译的时候进行比较严格的 **借用检查**
(Borrow Check) ,要求引用的生命周期必须在被借用的变量的生命周期之内,同时可变借用和不可变借用不能共存,一个
变量可以同时存在多个不可变借用,而可变借用同时最多只能存在一个。这能在编译期就解决掉很多内存不安全问题。
- 智能指针不仅包含它指向的区域的地址,还含有一些额外的信息,因此这个类型的字节大小大于平台的位宽,属于一种胖指针。
从用途上看,它不仅可以作为一个媒介来访问它指向的数据,还能在这个过程中起到一些管理和控制的功能。
在 Rust 中,与动态内存分配相关的智能指针有如下这些:
- ``Box<T>`` 在创建时会在堆上分配一个类型为 ``T`` 的变量,它自身也只保存在堆上的那个变量的位置。而和裸指针或引用
不同的是,当 ``Box<T>`` 被回收的时候,它指向的——也就是在堆上被动态分配的那个变量也会被回收。
- ``Rc<T>`` 是一个单线程上使用的引用计数类型, ``Arc<T>`` 与其功能相同,只是它可以在多线程上使用。它提供了
多所有权,也即地址空间中同时可以存在指向同一个堆上变量的 ``Rc<T>`` ,它们都可以拿到指向变量的不可变引用来
访问这同一个变量。而它同时也是一个引用计数,事实上在堆上的另一个位置维护了堆上这个变量目前被引用了多少次,
也就是存在多少个 ``Rc<T>`` 。这个计数会随着 ``Rc<T>`` 的创建或复制而增加,并当 ``Rc<T>`` 生命周期结束
被回收时减少。当这个计数变为零之后,这个计数变量本身以及被引用的变量都会从堆上被回收。
- ``Mutex<T>`` 是一个互斥锁,在多线程中使用,它可以保护里层被动态分配到堆上的变量同一时间只有一个线程能对它
进行操作,从而避免数据竞争,这是并发安全的问题,会在后面详细说明。同时,它能够提供
:ref:`内部可变性 <term-interior-mutability>```Mutex<T>`` 时常和 ``Arc<T>`` 配套使用,因为它是用来
保护多个线程可能同时访问的数据,其前提就是多个线程都拿到指向同一块堆上数据的 ``Mutex<T>`` 。于是,要么就是
这个 ``Mutex<T>`` 作为全局变量被分配到数据段上,要么就是我们需要将 ``Mutex<T>`` 包裹上一层多所有权变成
``Arc<Mutex<T>>`` ,让它可以在线程间进行传递。请记住 ``Arc<Mutex<T>>`` 这个经典组合,我们后面会经常用到。
之前我们通过 ``RefCell<T>`` 来获得内部可变性。可以将 ``Mutex<T>`` 看成 ``RefCell<T>`` 的多线程版本,
因为 ``RefCell<T>`` 是只能在单线程上使用的。而且 ``RefCell<T>`` 并不会在堆上分配内存,它仅用到静态内存
分配。
这和 C++ 很像, ``Box<T>`` 可以对标 C++ 的 ``std::unique_ptr`` ;而 ``Arc<T>`` 则类似于 C++ 的
``std::shared_ptr``
.. _term-collection:
.. _term-container:
随后,是一些 **集合** (Collection) 或称 **容器** (Container) 类型,它们负责管理一组数目可变的元素,这些元素
的类型相同或是有着一些同样的特征。在 C++/Python/Java 等高级语言中我们已经对它们的使用方法非常熟悉了,对于
Rust 而言,我们则可以直接使用以下容器:
- 向量 ``Vec<T>`` 类似于 C++ 中的 ``std::vector``
- 键值对容器 ``BTreeMap<K, V>`` 类似于 C++ 中的 ``std::map``
- 有序集合 ``BTreeSet<T>`` 类似于 C++ 中的 ``std::set``
- 链表 ``LinkedList<T>`` 类似于 C++ 中的 ``std::list``
- 双端队列 ``VecDeque<T>`` 类似于 C++ 中的 ``std::deque``
- 变长字符串 ``String`` 类似于 C++ 中的 ``std::string``
下面是一张 Rust 智能指针/容器及其他类型的内存布局的经典图示,来自
`这里 <https://docs.google.com/presentation/d/1q-c7UAyrUlM-eZyTo1pd8SZ0qwA_wYxmPZVOQkoDmH4/edit#slide=id.p>`_
.. image:: rust-containers.png
可以发现,在动态内存分配方面 Rust 和 C++ 很像,事实上 Rust 有意从 C++ 借鉴了这部分优秀特性。让我们先来看其他一些语言
使用动态内存的方式:
.. _term-reference-counting:
.. _term-garbage-collection:
- C 语言仅支持 ``malloc/free`` 这一对操作,它们必须恰好成对使用,否则就会出现错误。比如分配了之后没有回收,则会导致
内存溢出;回收之后再次 free 相同的指针,则会造成 Double-Free 问题;又如回收之后再尝试通过指针访问它指向的区域,这
属于 Use-After-Free 问题。总之,这样的内存安全问题层出不穷,毕竟人总是会犯错的。
- Python/Java 通过 **引用计数** (Reference Counting) 对所有的对象进行运行时的动态管理,一套 **垃圾回收**
(GC, Garbage Collection) 机制会被自动定期触发,每次都会检查所有的对象,如果其引用计数为零则可以将该对象占用的内存
从堆上回收以待后续其他的对象使用。这样做完全杜绝了内存安全问题,但是性能开销则很大,而且 GC 触发的时机和每次 GC 的
耗时都是无法预测的,还使得性能不够稳定。
.. _term-raii:
C++ 的 **资源获取即初始化** (RAII, Resource Acquisition Is Initialization) 风格则致力于解决上述问题。
RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑定到一个变量上。以 ``Box<T>`` 为例,在它被
创建的时候,会在堆上分配一块空间保存它指向的数据;而在 ``Box<T>`` 生命周期结束被回收的时候,堆上的那块空间也会
立即被一并回收。这也就是说,我们无需手动回收资源,它会和绑定到的变量同步由编译器自动回收,我们既不用担心忘记回收更不
可能回收多次;同时,由于我们很清楚一个变量的生命周期,则该资源何时被回收也是完全可预测的,我们也明确知道这次回收
操作的开销。在 Rust 中,不限于堆内存,将某种资源的生命周期与一个变量绑定的这种 RAII 的思想无处不见,甚至这种资源
可能只是另外一种类型的变量。
在内核中支持动态内存分配
--------------------------------------------------------
如果要在操作系统内核中支持动态内存分配,则需要实现在本节开始介绍的一系列功能:初始化堆、分配/释放内存块的函数接口、连续内存分配算法。相对于C语言而言如果用Rust语言实现它在 ``alloc`` crate中设定了一套简洁规范的接口只要实现了这套接口内核就可以很方便地支持动态内存分配了。
上边介绍的那些与堆相关的智能指针或容器都可以在 Rust 自带的 ``alloc`` crate 中找到。当我们使用 Rust 标准库
``std`` 的时候可以不用关心这个 crate ,因为标准库内已经已经实现了一套堆管理算法,并将 ``alloc`` 的内容包含在
``std`` 名字空间之下让开发者可以直接使用。然而我们的内核是在禁用了标准库(即 ``no_std`` )的裸机平台,核心库
``core`` 也并没有动态内存分配的功能,这个时候就要考虑利用 ``alloc`` 库了。
``alloc`` 库需要我们提供给它一个 ``全局的动态内存分配器`` ,它会利用该分配器来管理堆空间,从而使得它提供的堆数据结构可以正常
工作。具体而言,我们的动态内存分配器需要实现它提供的 ``GlobalAlloc`` Trait这个 Trait 有两个必须实现的抽象接口:
.. code-block:: rust
// alloc::alloc::GlobalAlloc
pub unsafe fn alloc(&self, layout: Layout) -> *mut u8;
pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
可以看到,它们类似 C 语言中的 ``malloc/free`` ,分别代表堆空间的分配和回收,也同样使用一个裸指针(也就是地址)
作为分配的返回值和回收的参数。两个接口中都有一个 ``alloc::alloc::Layout`` 类型的参数,
它指出了分配的需求,分为两部分,分别是所需空间的大小 ``size`` ,以及返回地址的对齐要求 ``align`` 。这个对齐要求
必须是一个 2 的幂次,单位为字节数,限制返回的地址必须是 ``align`` 的倍数。
.. note::
**为何 C 语言 malloc 的时候不需要提供对齐需求?**
在 C 语言中所有对齐要求的最大值是一个平台有关的很小的常数比如8 bytes消耗少量内存即可使得每一次分配都符合这个最大
的对齐要求。因此也就不需要区分不同分配的对齐要求了。而在 Rust 中,某些分配的对齐要求可能很大,就只能采用更
加复杂的方法。
之后,只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 ``#[global_allocator]`` 语义项标记即可。由于该
分配器的实现比较复杂,我们这里直接使用一个已有的伙伴分配器实现。首先添加 crate 依赖:
.. code-block:: toml
# os/Cargo.toml
buddy_system_allocator = "0.6"
接着,需要引入 ``alloc`` 库的依赖,由于它算是 Rust 内置的 crate ,我们并不是在 ``Cargo.toml`` 中进行引入,而是在
``main.rs`` 中声明即可:
.. code-block:: rust
// os/src/main.rs
extern crate alloc;
然后,根据 ``alloc`` 留好的接口提供全局动态内存分配器:
.. code-block:: rust
:linenos:
// os/src/mm/heap_allocator.rs
use buddy_system_allocator::LockedHeap;
use crate::config::KERNEL_HEAP_SIZE;
#[global_allocator]
static HEAP_ALLOCATOR: LockedHeap = LockedHeap::empty();
static mut HEAP_SPACE: [u8; KERNEL_HEAP_SIZE] = [0; KERNEL_HEAP_SIZE];
pub fn init_heap() {
unsafe {
HEAP_ALLOCATOR
.lock()
.init(HEAP_SPACE.as_ptr() as usize, KERNEL_HEAP_SIZE);
}
}
- 第 7 行,我们直接将 ``buddy_system_allocator`` 中提供的 ``LockedHeap`` 实例化成一个全局变量,并使用
``alloc`` 要求的 ``#[global_allocator]`` 语义项进行标记。注意 ``LockedHeap`` 已经实现了 ``GlobalAlloc``
要求的抽象接口了。
- 第 11 行,在使用任何 ``alloc`` 中提供的堆数据结构之前,我们需要先调用 ``init_heap`` 函数来给我们的全局分配器
一块内存用于分配。在第 9 行可以看到,这块内存是一个 ``static mut`` 且被零初始化的字节数组,位于内核的
``.bss`` 段中。 ``LockedHeap`` 也是一个被互斥锁保护的类型,在对它任何进行任何操作之前都要先获取锁以避免其他
线程同时对它进行操作导致数据竞争。然后,调用 ``init`` 方法告知它能够用来分配的空间的起始地址和大小即可。
我们还需要处理动态内存分配失败的情形,在这种情况下我们直接 panic
.. code-block:: rust
// os/src/main.rs
#![feature(alloc_error_handler)]
// os/src/mm/heap_allocator.rs
#[alloc_error_handler]
pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! {
panic!("Heap allocation error, layout = {:?}", layout);
}
最后,让我们尝试一下动态内存分配吧!
.. chyyuu 如何尝试???
.. code-block:: rust
:linenos:
// os/src/mm/heap_allocator.rs
#[allow(unused)]
pub fn heap_test() {
use alloc::boxed::Box;
use alloc::vec::Vec;
extern "C" {
fn sbss();
fn ebss();
}
let bss_range = sbss as usize..ebss as usize;
let a = Box::new(5);
assert_eq!(*a, 5);
assert!(bss_range.contains(&(a.as_ref() as *const _ as usize)));
drop(a);
let mut v: Vec<usize> = Vec::new();
for i in 0..500 {
v.push(i);
}
for i in 0..500 {
assert_eq!(v[i], i);
}
assert!(bss_range.contains(&(v.as_ptr() as usize)));
drop(v);
println!("heap_test passed!");
}
其中分别使用智能指针 ``Box<T>`` 和向量 ``Vec<T>`` 在堆上分配数据并管理它们,通过 ``as_ref````as_ptr``
方法可以分别看到它们指向的数据的位置,能够确认它们的确在 ``.bss`` 段的堆上。
.. note::
本节部分内容参考自 `BlogOS 的相关章节 <https://os.phil-opp.com/heap-allocation/>`_

View File

@ -0,0 +1,214 @@
地址空间
=====================================
本节导读
--------------------------
直到现在,我们的操作系统给应用看到的是一个非常原始的物理内存空间,可以简单地理解为一个可以随便访问的大数组。为了限制应用访问内存空间的范围并给操作系统提供内存管理的灵活性,计算机硬件引入了各种内存保护/映射硬件机制如RISC-V的基址-边界翻译和保护机制、x86的分段机制、RISC-V/x86/ARM都有的分页机制。它们的共同之处在于CPU访问的数据和指令内存地址是虚地址需要进行转换形成合法的物理地址或产生非法的异常。为了用好这种硬件机制操作系统需要升级自己的能力。
操作系统为了更好地管理这两种形式的内存,并给应用程序提供统一的访问接口,即应用程序不需要了解虚拟内存和物理内存的区别的,操作系统提出了 ``地址空间 Address Space`` 抽象,并在内核中建立虚实地址空间的映射机制,给应用程序提供一个虚拟的内存环境。
本节将结合操作系统的发展历程回顾来介绍 ``地址空间 Address Space`` 抽象的实现策略
是如何变化的。
虚拟地址与地址空间
-------------------------------
地址虚拟化出现之前
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们之前介绍过,在最早整套硬件资源只用来执行单个裸机应用的时候,并不存在真正意义上的操作系统,而只能算是一种应用
函数库。那个时候,物理内存的一部分用来保存函数库的代码和数据,余下的部分都交给应用来使用。从功能上可以将应用
占据的内存分成几个段:代码段、全局数据段、堆和栈等。当然,由于就只有这一个应用,它想如何调整布局都是它自己的
事情。从内存使用的角度来看,批处理系统和裸机应用很相似:批处理系统的每个应用也都是独占内核之外的全部内存空间,
只不过当一个应用出错或退出之后,它所占据的内存区域会被清空,而序列中的下一个应用将自己的代码和数据放置进来。
这个时期,内核提供给应用的访存视角是一致的,因为它们确实会在运行过程中始终独占一块固定的内存区域,每个应用开发者
都基于这一认知来规划程序的内存布局。
后来,为了降低等待 I/O 带来的无意义的 CPU 资源损耗,多道程序出现了。而为了提升用户的交互式体验,提高生产力,分时
多任务系统诞生了。它们的特点在于:应用开始多出了一种“暂停”状态,这可能来源于它主动 yield 交出 CPU 资源,或是在
执行了足够长时间之后被内核强制性换出。当应用处于暂停状态的时候,它驻留在内存中的代码、数据该何去何从呢?曾经有一种
做法是每个应用仍然和在批处理系统中一样独占内核之外的整块内存,当暂停的时候,内核负责将它的代码、数据保存在磁盘或
硬盘中,然后把即将换入的应用保存在磁盘上的代码、数据恢复到内存,这些都做完之后才能开始执行新的应用。
不过,由于这种做法需要大量读写内存和外部存储设备,而它们的速度都比 CPU 慢上几个数量级,这导致任务切换的开销过大,
甚至完全不能接受。既然如此,就只能像我们在第三章中的做法一样,限制每个应用的最大可用内存空间小于物理内存的容量,这样
就可以同时把多个应用的数据驻留在内存中。在任务切换的时候只需完成任务上下文保存与恢复即可,这只是在内存的帮助下保存、
恢复少量通用寄存器,甚至无需访问外存,这从很大程度上降低了任务切换的开销。
在本章的引言中介绍过第三章中操作系统的做法对应用程序开发带了一定的困难。从应用开发的角度看,需要应用程序决定自己会被加载到哪个物理地址运行,需要直接访问真实的
物理内存。这就要求应用开发者对于硬件的特性和使用方法有更多了解,产生额外的学习成本,也会为应用的开发和调试带来不便。从
内核的角度来看,将直接访问物理内存的权力下放到应用会使得它难以对应用程序的访存行为进行有效管理,已有的特权级机制亦无法
阻止很多来自应用程序的恶意行为。
加一层抽象加强内存管理
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
为了解决这种困境,抽象仍然是最重要的指导思想。在这里,抽象意味着内核要负责将物理内存管理起来,并为上面的应用提供
一层抽象接口,从之前的失败经验学习,这层抽象需要达成下面的设计目标:
- *透明* :应用开发者可以不必了解底层真实物理内存的硬件细节,且在非必要时也不必关心内核的实现策略,
最小化他们的心智负担;
- *高效* :这层抽象至少在大多数情况下不应带来过大的额外开销;
- *安全* :这层抽象应该有效检测并阻止应用读写其他应用或内核的代码、数据等一系列恶意行为。
.. _term-address-space:
.. _term-virtual-address:
最终,到目前为止仍被操作系统内核广泛使用的抽象被称为 **地址空间** (Address Space) 。某种程度上讲,可以将它看成一块
巨大但并不一定真实存在的内存。在每个应用程序的视角里,操作系统分配给应用程序一个范围有限(但其实很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等),因此应用程序可以在划分给它的地址空间中随意规划内存布局,它的
各个段也就可以分别放置在地址空间中它希望的位置(当然是操作系统允许应用访问的地址)。应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址
作为索引来读写物理内存上的数据一样。这种地址被称为 **虚拟地址** (Virtual Address) 。当然,操作系统要达到 **地址空间** 抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的 ``MMU````TLB`` 等硬件机制。
从此,应用能够直接看到并访问的内存就只有操作系统提供的地址空间,且它的任何一次访存使用的地址都是虚拟地址,无论取指令来执行还是读写
栈、堆或是全局数据段都是如此。事实上,特权级机制被拓展,使得应用不再具有通过物理地址直接访问物理内存的能力。应用所处的执行环境在安全方面被进一步强化,形成了用户态特权级和地址空间的二维安全措施。
由于每个应用独占一个地址空间,里面只含有自己的各个段,于是它可以随意规划
各个段的分布而无需考虑和其他应用冲突;同时,它完全无法窃取或者破坏其他应用的数据,毕竟那些段在其他应用的地址空间
内,鉴于应用只能通过虚拟地址读写它自己的地址空间,这是它没有能力去访问的。这是 **地址空间** 抽象对应用程序执行的安全性和稳定性的一种保障。
.. image:: address-translation.png
.. _term-mmu:
.. _term-address-translation:
我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要扩展硬件功能来加速地址转换过程(回忆 *计算机组成原理* 课上讲的 ``MMU````TLB`` )。
增加硬件加速虚实地址转换
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
开始回顾一下 **计算机组成原理** 课。如上图所示,当应用取指或者执行
一条访存指令的时候它都是在以虚拟地址为索引读写自己的地址空间。此时CPU 中的 **内存管理单元**
(MMU, Memory Management Unit) 自动将这个虚拟地址进行 **地址转换** (Address Translation) 变为一个物理地址,
也就是物理内存上这个应用的数据真实被存放的位置。也就是说,在 MMU 的帮助下,应用对自己地址空间的读写才能被实际转化为
对于物理内存的访问。
事实上,每个应用的地址空间都可以看成一个从虚拟地址到物理地址的映射。可以想象对于不同的应用来说,该映射可能是不同的,
即 MMU 可能会将来自不同两个应用地址空间的相同虚拟地址翻译成不同的物理地址。要做到这一点,就需要硬件提供一些寄存器
,软件可以对它进行设置来控制 MMU 按照哪个应用的地址空间进行地址转换。于是,将应用的数据放到物理内存并进行管理,而
在任务切换的时候需要将控制 MMU 选用哪个应用的地址空间进行映射的那些寄存器也一并进行切换,则是作为软件部分的内核需
要完成的工作。
回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的
幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此,应用只需、也只能看到它独占整个地址空间的幻象,而
藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置。
地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,操作系统内核如何规划应用数据放在物理内存的位置,
而 MMU 又如何进行地址转换也都是不同的。下面我们简要介绍几种曾经被使用的策略,并探讨它们的优劣。
分段内存管理
-------------------------------------
.. image:: simple-base-bound.png
.. _term-slot:
曾经的一种做法如上图所示:每个应用的地址空间大小限制为一个固定的常数 ``bound`` ,也即每个应用的可用虚拟地址区间
均为 :math:`[0,\text{bound})` 。随后,就可以以这个大小为单位,将物理内存除了内核预留空间之外的部分划分为若干
个大小相同的 **插槽** (Slot) ,每个应用的所有数据都被内核放置在其中一个插槽中,对应于物理内存上的一段连续物理地址
区间,假设其起始物理地址为 :math:`\text{base}` ,则由于二者大小相同,这个区间实际为
:math:`[\text{base},\text{base}+\text{bound})` 。因此地址转换很容易完成,只需检查一下虚拟地址不超过地址空间
的大小限制(此时需要借助特权级机制通过异常来进行处理),然后做一个线性映射,将虚拟地址加上 :math:`\text{base}`
就得到了数据实际所在的物理地址。
.. _term-bitmap:
可以看出这种实现极其简单MMU 只需要 :math:`\text{base,bound}` 两个寄存器,在地址转换进行比较或加法运算即可;
而内核只需要在任务切换的同时切换 :math:`\text{base}` 寄存器(由于 :math:`\text{bound}` 是一个常数),内存
管理方面它只需考虑一组插槽的占用状态,可以用一个 **位图** (Bitmap) 来表示,随着应用的新增和退出对应置位或清空。
.. _term-internal-fragment:
然而,它的问题在于:浪费的内存资源过多。注意到应用地址空间预留了一部分,它是用来让栈得以向低地址增长,同时允许堆
往高地址增长(支持应用运行时进行动态内存分配)。每个应用的情况都不同,内核只能按照在它能力范围之内的消耗内存最多
的应用的情况来统一指定地址空间的大小,而其他内存需求较低的应用根本无法充分利用内核给他们分配的这部分空间。
但这部分空间又是一个完整的插槽的一部分,也不能再交给其他应用使用。这种在地址空间内部无法被充分利用的空间被称为
**内碎片** (Internal Fragment) ,它限制了系统同时共存的应用数目。如果应用的需求足够多样化,那么内核无论如何设置
应用地址空间的大小限制也不能得到满意的结果。这就是固定参数的弊端:虽然实现简单,但不够灵活。
为了解决这个问题,一种分段管理的策略开始被使用,如下图所示:
.. image:: segmentation.png
注意到内核开始以更细的粒度,也就是应用地址空间中的一个逻辑段作为单位来安排应用的数据在物理内存中的布局。对于每个
段来说,从它在某个应用地址空间中的虚拟地址到它被实际存放在内存中的物理地址中间都要经过一个不同的线性映射,于是
MMU 需要用一对不同的 :math:`\text{base/bound}` 进行区分。这里由于每个段的大小都是不同的,我们也不再能仅仅
使用一个 :math:`\text{bound}` 进行简化。当任务切换的时候,这些对寄存器也需要被切换。
简单起见,我们这里忽略一些不必要的细节。比如应用在以虚拟地址为索引访问地址空间的时候,它如何知道该地址属于哪个段,
从而硬件可以使用正确的一对 :math:`\text{base/bound}` 寄存器进行合法性检查和完成实际的地址转换。这里只关注
分段管理是否解决了内碎片带来的内存浪费问题。注意到每个段都只会在内存中占据一块与它实际所用到的大小相等的空间。堆
的情况可能比较特殊,它的大小可能会在运行时增长,但是那需要应用通过系统调用向内核请求。也就是说这是一种按需分配,而
不再是内核在开始时就给每个应用分配一大块很可能用不完的内存。由此,不再有内碎片了。
.. _term-external-fragment:
尽管内碎片被消除了,但内存浪费问题并没有完全解决。这是因为每个段的大小都是不同的(它们可能来自不同的应用,功能
也不同),内核就需要使用更加通用、也更加复杂的连续内存分配算法来进行内存管理,而不能像之前的插槽那样以一个比特
为单位。顾名思义,连续内存分配算法就是每次需要分配一块连续内存来存放一个段的数据。
随着一段时间的分配和回收,物理内存还剩下一些相互不连续的较小的可用连续块,其中有一些只是两个已分配内存块之间的很小的间隙,它们自己可能由于空间较小,已经无法被
用于分配,被称为 **外碎片** (External Fragment) 。
如果这时再想分配一个比较大的块,
就需要将这些不连续的外碎片“拼起来”,形成一个大的连续块。然而这是一件开销很大的事情,涉及到极大的内存读写开销。具体而言,这需要移动和调整一些已分配内存块在物理内存上的位置,才能让那些小的外碎片能够合在一起,形成一个大的空闲块。如果连续内存分配算法
选取得当,可以尽可能减少这种操作。课上所讲到的那些算法,包括 first-fit/worst-fit/best-fit 或是 buddy
system其具体表现取决于实际的应用需求各有优劣。
那么,分段内存管理带来的外碎片和连续内存分配算法比较复杂的
问题可否被解决呢?
分页内存管理
--------------------------------------
仔细分析一下可以发现,段的大小不一是外碎片产生的根本原因。之前我们把应用的整个地址空间连续放置在物理内存中,在
每个应用的地址空间大小均相同的情况下,只需利用类似位图的数据结构维护一组插槽的占用状态,从逻辑上分配和回收都是
以一个固定的比特为单位,自然也就不会存在外碎片了。但是这样粒度过大,不够灵活,又在地址空间内部产生了内碎片。
若要结合二者的优点的话,就需要内核始终以一个同样大小的单位来在物理内存上放置应用地址空间中的数据,这样内核就可以
使用简单的插槽式内存管理,使得内存分配算法比较简单且不会产生外碎片;同时,这个单位的大小要足够小,从而其内部没有
被用到的内碎片的大小也足够小,尽可能提高内存利用率。这便是我们将要介绍的分页内存管理。
.. image:: page-table.png
.. _term-page:
.. _term-frame:
如上图所示,内核以页为单位进行物理内存管理。每个应用的地址空间可以被分成若干个(虚拟) **页面** (Page) ,而
可用的物理内存也同样可以被分成若干个(物理) **页帧** (Frame) ,虚拟页面和物理页帧的大小相同。每个虚拟页面
中的数据实际上都存储在某个物理页帧上。相比分段内存管理,分页内存管理的粒度更小,应用地址空间中的每个逻辑段都
由多个虚拟页面组成,而每个虚拟页面在地址转换的过程中都使用一个不同的线性映射,而不是在分段内存管理中每个逻辑段
都使用一个相同的线性映射。
.. _term-virtual-page-number:
.. _term-physical-page-number:
.. _term-page-table:
为了方便实现虚拟页面到物理页帧的地址转换,我们给每个虚拟页面和物理页帧一个编号,分别称为 **虚拟页号**
(VPN, Virtual Page Number) 和 **物理页号** (PPN, Physical Page Number) 。每个应用都有一个不同的
**页表** (Page Table) ,里面记录了该应用地址空间中的每个虚拟页面映射到物理内存中的哪个物理页帧,即数据实际
被内核放在哪里。我们可以用页号来代表二者,因此如果将页表看成一个键值对,其键的类型为虚拟页号,值的类型则为物理
页号。当 MMU 进行地址转换的时候,它首先找到给定的虚拟地址所在的虚拟页面的页号,然后查当前应用的页表根据虚拟页号
找到物理页号,最后按照虚拟地址在它所在的虚拟页面中的相对位置相应给物理页号对应的物理页帧的起始地址加上一个偏移量,
这就得到了实际访问的物理地址。
在页表中通过虚拟页号不仅能查到物理页号,还能得到一组保护位,它限制了应用对转换得到的物理地址对应的内存的使用方式。
最典型的如 ``rwx`` ``r`` 表示当前应用可以读该内存; ``w`` 表示当前应用可以写该内存; ``x`` 则表示当前应用
可以从该内存取指令用来执行。一旦违反了这种限制则会触发异常被内核捕获到。通过适当的设置,可以检查一些应用明显的
错误:比如应用修改自己本应该只读的代码段,或者从数据段取指令来执行。
当一个应用的地址空间比较大的时候,页表里面的项数会很多(事实上每个虚拟页面都应该对应页表中的一项,上图中我们已经
省略掉了那些未被使用的虚拟页面导致它的容量极速膨胀已经不再是像之前那样数个寄存器便可存下来的了CPU 内也没有
足够的硬件资源能够将它存下来。因此它只能作为一种被内核管理的数据结构放在内存中,但是 CPU 也会直接访问它来查页表,
这也就需要内核和硬件之间关于页表的内存布局达成一致。
由于分页内存管理既简单又灵活它逐渐成为了主流RISC-V 架构也使用了这种策略。后面我们会基于这种机制,自己来动手从物理内存抽象出应用的地址空间来。
.. note::
本节部分内容参考自 `Operating Systems: Three Easy Pieces <http://pages.cs.wisc.edu/~remzi/OSTEP/>`_
教材的 13~16 小节。

View File

@ -0,0 +1,455 @@
实现 SV39 多级页表机制(上)
========================================================
本节导读
--------------------------
在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性可读可写可执行等组成结构页号帧号偏移量等访问的空间范围等以及如何用Rust语言来设计有类型的页表项。
虚拟地址和物理地址
------------------------------------------------------
内存控制相关的CSR寄存器
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
默认情况下 MMU 未被使能,此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接
访问物理内存。我们可以通过修改 S 特权级的一个名为 ``satp`` 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存
地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。
.. note::
M 特权级的访存地址被视为一个物理地址还是一个需要经历和 S/U 特权级相同的地址转换的虚拟地址取决于硬件配置,在这里我们不会进一步探讨。
.. chyyuu M模式下应该访问的是物理地址
.. image:: satp.png
:name: satp-layout
上图是 RV64 架构下 ``satp`` 的字段分布。当 ``MODE`` 设置为 0 的时候,代表所有访存都被视为物理地址;而设置为 8
的时候SV39 分页机制被启用,所有 S/U 特权级的访存被视为一个 39 位的虚拟地址,它们需要先经过 MMU 的地址转换流程,
如果顺利的话,则会变成一个 56 位的物理地址来访问物理内存;否则则会触发异常,这体现了该机制的内存保护能力。
虚拟地址和物理地址都是字节地址39 位的虚拟地址可以用来访问理论上最大 :math:`512\text{GiB}` 的地址空间,
而 56 位的物理地址在理论上甚至可以访问一块大小比这个地址空间的还高出几个数量级的物理内存。但是实际上无论是
虚拟地址还是物理地址,真正有意义、能够通过 MMU 的地址转换或是 CPU 内存控制单元的检查的地址仅占其中的很小
一部分,因此它们的理论容量上限在目前都没有实际意义。
地址格式与组成
^^^^^^^^^^^^^^^^^^^^^^^^^^
.. image:: sv39-va-pa.png
.. _term-page-offset:
我们采用分页管理,单个页面的大小设置为 :math:`4\text{KiB}` ,每个虚拟页面和物理页帧都对齐到这个页面大小,也就是说
虚拟/物理地址区间 :math:`[0,4\text{KiB})` 为第 :math:`0` 个虚拟页面/物理页帧,而
:math:`[4\text{KiB},8\text{KiB})` 为第 :math:`1` 个,以此类推。 :math:`4\text{KiB}` 需要用 12 位字节地址
来表示,因此虚拟地址和物理地址都被分成两部分:它们的低 12 位,即 :math:`[11:0]` 被称为 **页内偏移**
(Page Offset) ,它描述一个地址指向的字节在它所在页面中的相对位置。而虚拟地址的高 27 位,即 :math:`[38:12]`
它的虚拟页号 VPN同理物理地址的高 44 位,即 :math:`[55:12]` 为它的物理页号 PPN页号可以用来定位一个虚拟/物理地址
属于哪一个虚拟页面/物理页帧。
地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号,
在页表中查到其对应的物理页号如果存在的话最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址。
.. _high-and-low-256gib:
.. note::
**RV64 架构中虚拟地址为何只有 39 位?**
在 64 位架构上虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的。
SV39 分页模式规定 64 位虚拟地址的 :math:`[63:39]` 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个
不合法的虚拟地址。通过这个检查之后 MMU 再取出低 39 位尝试将其转化为一个 56 位的物理地址。
也就是说,所有 :math:`2^{64}` 个虚拟地址中,只有最低的 :math:`256\text{GiB}` (当第 38 位为 0 时)
以及最高的 :math:`256\text{GiB}` (当第 38 位为 1 时)是可能通过 MMU 检查的。当我们写软件代码的时候,一个
地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分,尽管它们已经巨大的超乎想象了;而本节中
我们专注于介绍 MMU 的机制,强调 MMU 看到的真正用来地址转换的虚拟地址只有 39 位。
地址相关的数据结构抽象与类型定义
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
正如本章第一小节所说,在分页内存管理中,地址转换的核心任务在于如何维护虚拟页号到物理页号的映射——也就是页表。不过在具体
实现它之前,我们先将地址和页号的概念抽象为 Rust 中的类型,借助 Rust 的类型安全特性来确保它们被正确实现。
首先是这些类型的定义:
.. code-block:: rust
// os/src/mm/address.rs
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysAddr(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtAddr(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysPageNum(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtPageNum(pub usize);
.. _term-type-convertion:
上面分别给出了物理地址、虚拟地址、物理页号、虚拟页号的 Rust 类型声明,它们都是 Rust 的元组式结构体,可以看成
usize 的一种简单包装。我们刻意将它们各自抽象出来而不是都使用 usize 保存,就是为了在 Rust 编译器的帮助下进行
多种方便且安全的 **类型转换** (Type Convertion) 。
首先,这些类型本身可以和 usize 之间互相转换,以物理地址 ``PhysAddr`` 为例,我们需要:
.. code-block:: rust
// os/src/mm/address.rs
impl From<usize> for PhysAddr {
fn from(v: usize) -> Self { Self(v) }
}
impl From<PhysAddr> for usize {
fn from(v: PhysAddr) -> Self { v.0 }
}
前者允许我们从一个 ``usize`` 来生成 ``PhysAddr`` ,即 ``PhysAddr::from(_: usize)`` 将得到一个 ``PhysAddr``
;反之亦然。其实由于我们在声明结构体的时候将字段公开了出来,从物理地址变量 ``pa`` 得到它的 usize 表示的更简便方法
是直接 ``pa.0``
.. note::
**Rust 语法卡片:类型转换之 From 和 Into**
一般而言,当我们为类型 ``U`` 实现了 ``From<T>`` Trait 之后,可以使用 ``U::from(_: T)`` 来从一个 ``T``
类型的实例来构造一个 ``U`` 类型的实例;而当我们为类型 ``U`` 实现了 ``Into<T>`` Trait 之后,对于一个 ``U``
类型的实例 ``u`` ,可以使用 ``u.into()`` 来将其转化为一个类型为 ``T`` 的实例。
当我们为 ``U`` 实现了 ``From<T>`` 之后Rust 会自动为 ``T`` 实现 ``Into<U>`` Trait
因为它们两个本来就是在做相同的事情。因此我们只需相互实现 ``From`` 就可以相互 ``From/Into`` 了。
需要注意的是,当我们使用 ``From`` Trait 的 ``from`` 方法来构造一个转换后类型的实例的时候,``from`` 的参数
已经指明了转换前的类型,因而 Rust 编译器知道该使用哪个实现;而使用 ``Into`` Trait 的 ``into`` 方法来将当前
类型转化为另一种类型的时候,它并没有参数,因而函数签名中并没有指出要转化为哪一个类型,则我们必须在其他地方 *显式*
指出目标类型。比如,当我们要将 ``u.into()`` 绑定到一个新变量 ``t`` 的时候,必须通过 ``let t: T`` 显式声明
``t`` 的类型;又或是将 ``u.into()`` 的结果作为参数传给某一个函数,那么这个函数的函数签名中一定指出了传入位置
的参数的类型Rust 编译器也就明确知道转换的类型。
请注意,解引用 ``Deref`` Trait 是 Rust 编译器唯一允许的一种隐式类型转换,而对于其他的类型转换,我们必须手动
调用类型转化方法或者是显式给出转换前后的类型。这体现了 Rust 的类型安全特性,在 C/C++ 中并不是如此,比如两个
不同的整数/浮点数类型进行二元运算的时候,编译器经常要先进行隐式类型转换使两个操作数类型相同,而后再进行运算,导致
了很多数值溢出或精度损失问题。Rust 不会进行这种隐式类型转换,它会在编译期直接报错,提示两个操作数类型不匹配。
其次,地址和页号之间可以相互转换。我们这里仍以物理地址和物理页号之间的转换为例:
.. code-block:: rust
:linenos:
// os/src/mm/address.rs
impl PhysAddr {
pub fn page_offset(&self) -> usize { self.0 & (PAGE_SIZE - 1) }
}
impl From<PhysAddr> for PhysPageNum {
fn from(v: PhysAddr) -> Self {
assert_eq!(v.page_offset(), 0);
v.floor()
}
}
impl From<PhysPageNum> for PhysAddr {
fn from(v: PhysPageNum) -> Self { Self(v.0 << PAGE_SIZE_BITS) }
}
其中 ``PAGE_SIZE``:math:`4096` ``PAGE_SIZE_BITS``:math:`12` ,它们均定义在 ``config`` 子模块
中,分别表示每个页面的大小和页内偏移的位宽。从物理页号到物理地址的转换只需左移 :math:`12` 位即可,但是物理地址需要
保证它与页面大小对齐才能通过右移转换为物理页号。
对于不对齐的情况,物理地址不能通过 ``From/Into`` 转换为物理页号,而是需要通过它自己的 ``floor````ceil`` 方法来
进行下取整或上取整的转换。
.. code-block:: rust
// os/src/mm/address.rs
impl PhysAddr {
pub fn floor(&self) -> PhysPageNum { PhysPageNum(self.0 / PAGE_SIZE) }
pub fn ceil(&self) -> PhysPageNum { PhysPageNum((self.0 + PAGE_SIZE - 1) / PAGE_SIZE) }
}
我们暂时先介绍这两种最简单的类型转换。
页表项的数据结构抽象与类型定义
-----------------------------------------
第一小节中我们提到,在页表中以虚拟页号作为索引不仅能够查到物理页号,还能查到一组保护位,它控制了应用对地址空间每个
虚拟页面的访问权限。但实际上还有更多的标志位,物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为
**页表项** (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。
.. image:: sv39-pte.png
上图为 SV39 分页模式下的页表项,其中 :math:`[53:10]`:math:`44` 位是物理页号,最低的 :math:`8`
:math:`[7:0]` 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 *页表项的对应虚拟页面* 来表示索引到
一个页表项的虚拟页号对应的虚拟页面):
- 仅当 V(Valid) 位为 1 时,页表项才是合法的;
- R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指;
- U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
- G 我们暂且不理会;
- A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
- D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。
让我们先来实现页表项中的标志位 ``PTEFlags``
.. code-block:: rust
// os/src/main.rs
#[macro_use]
extern crate bitflags;
// os/src/mm/page_table.rs
use bitflags::*;
bitflags! {
pub struct PTEFlags: u8 {
const V = 1 << 0;
const R = 1 << 1;
const W = 1 << 2;
const X = 1 << 3;
const U = 1 << 4;
const G = 1 << 5;
const A = 1 << 6;
const D = 1 << 7;
}
}
`bitflags <https://docs.rs/bitflags/1.2.1/bitflags/>`_ 是一个 Rust 中常用来比特标志位的 crate 。它提供了
一个 ``bitflags!`` 宏,如上面的代码段所展示的那样,可以将一个 ``u8`` 封装成一个标志位的集合类型,支持一些常见的集合
运算。它的一些使用细节这里不展开,请读者自行参考它的官方文档。注意,在使用之前我们需要引入该 crate 的依赖:
.. code-block:: toml
# os/Cargo.toml
[dependencies]
bitflags = "1.2.1"
接下来我们实现页表项 ``PageTableEntry``
.. code-block:: rust
:linenos:
// os/src/mm/page_table.rs
#[derive(Copy, Clone)]
#[repr(C)]
pub struct PageTableEntry {
pub bits: usize,
}
impl PageTableEntry {
pub fn new(ppn: PhysPageNum, flags: PTEFlags) -> Self {
PageTableEntry {
bits: ppn.0 << 10 | flags.bits as usize,
}
}
pub fn empty() -> Self {
PageTableEntry {
bits: 0,
}
}
pub fn ppn(&self) -> PhysPageNum {
(self.bits >> 10 & ((1usize << 44) - 1)).into()
}
pub fn flags(&self) -> PTEFlags {
PTEFlags::from_bits(self.bits as u8).unwrap()
}
}
- 第 3 行我们让编译器自动为 ``PageTableEntry`` 实现 ``Copy/Clone`` Trait来让这个类型以值语义赋值/传参的时候
不会发生所有权转移,而是拷贝一份新的副本。从这一点来说 ``PageTableEntry`` 就和 usize 一样,因为它也只是后者的
一层简单包装,解释了 usize 各个比特段的含义。
- 第 10 行使得我们可以从一个物理页号 ``PhysPageNum`` 和一个页表项标志位 ``PTEFlags`` 生成一个页表项
``PageTableEntry`` 实例;而第 20 行和第 23 行则分别可以从一个页表项将它们两个取出。
- 第 15 行中,我们也可以通过 ``empty`` 方法生成一个全零的页表项,注意这隐含着该页表项的 V 标志位为 0
因此它是不合法的。
后面我们还为 ``PageTableEntry`` 实现了一些辅助函数(Helper Function),可以快速判断一个页表项的 V/R/W/X 标志位是否为 1以 V
标志位的判断为例:
.. code-block:: rust
// os/src/mm/page_table.rs
impl PageTableEntry {
pub fn is_valid(&self) -> bool {
(self.flags() & PTEFlags::V) != PTEFlags::empty()
}
}
这里相当于判断两个集合的交集是否为空集,部分说明了 ``bitflags`` crate 的使用方法。
多级页表原理
-------------------------------
页表的一种最简单的实现是线性表,也就是按照地址从低到高、输入的虚拟页号从 :math:`0` 开始递增的顺序依次在内存中
(我们之前提到过页表的容量过大无法保存在 CPU 中)放置每个虚拟页号对应的页表项。由于每个页表项的大小是 :math:`8`
字节,我们只要知道第一个页表项(对应虚拟页号 :math:`0` )被放在的物理地址 :math:`\text{base_addr}` ,就能
直接计算出每个输入的虚拟页号对应的页表项所在的位置。如下图所示:
.. image:: linear-table.png
:height: 400
:align: center
事实上,对于虚拟页号 :math:`i` ,如果页表(每个应用都有一个页表,这里指其中某一个)的起始地址为
:math:`\text{base_addr}` ,则这个虚拟页号对应的页表项可以在物理地址 :math:`\text{base_addr}+8i` 处找到。
这使得 MMU 的实现和内核的软件控制都变得非常简单。然而遗憾的是,这远远超出了我们的物理内存限制。由于虚拟页号有
:math:`2^{27}` 种,每个虚拟页号对应一个 :math:`8` 字节的页表项,则每个页表都需要消耗掉 :math:`1\text{GiB}`
内存!应用的数据还需要保存在内存的其他位置,这就使得每个应用要吃掉 :math:`1\text{GiB}` 以上的内存。作为对比,
我们的 K210 开发板目前只有 :math:`8\text{MiB}` 的内存,因此从空间占用角度来说,这种线性表实现是完全不可行的。
线性表的问题在于:它保存了所有虚拟页号对应的页表项,但是高达 :math:`512\text{GiB}` 的地址空间中真正会被应用
使用到的只是其中极小的一个子集(本教程中的应用内存使用量约在数十~数百 :math:`\text{KiB}` 量级),也就导致
有意义并能在页表中查到实际的物理页号的虚拟页号在 :math:`2^{27}` 中也只是很小的一部分。由此线性表的绝大部分空间
其实都是被浪费掉的。
那么如何进行优化呢?核心思想就在于 **按需分配** ,也就是说:有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用
多大的内存用来保存映射。这是因为,每个应用的地址空间最开始都是空的,或者说所有的虚拟页号均不合法,那么这样的页表
自然不需要占用任何内存, MMU 在地址转换的时候无需关心页表的内容而是将所有的虚拟页号均判为不合法即可。而在后面,
内核已经决定好了一个应用的各逻辑段存放位置之后,它就需要负责从零开始以虚拟页面为单位来让该应用的地址空间的某些部分
变得合法,反映在该应用的页表上也就是一对对映射顺次被插入进来,自然页表所占据的内存大小也就逐渐增加。
这种思想在计算机科学中得到了广泛应用:为了方便接下来的说明,我们可以举一道数据结构的题目作为例子。设想我们要维护
一个字符串的多重集,集合中所有的字符串的字符集均为 :math:`\alpha=\{a,b,c\}` ,长度均为一个给定的常数
:math:`n` 。该字符串集合一开始为空集。我们要支持两种操作,第一种是将一个字符串插入集合,第二种是查询一个字符串在当前
的集合中出现了多少次。
.. _term-trie:
简单起见,假设 :math:`n=3` 。那么我们可能会建立这样一颗 **字典树** (Trie)
.. image:: trie.png
字典树由若干个节点(图中用椭圆形来表示)组成,从逻辑上而言每个节点代表一个可能的字符串前缀。每个节点的存储内容
都只有三个指针,对于蓝色的非叶节点来说,它的三个指针各自指向一个子节点;而对于绿色的叶子节点来说,它的三个指针不再指向
任何节点,而是具体保存一种可能的长度为 :math:`n` 的字符串的计数。这样,对于题目要求的两种操作,我们只需根据输入的
字符串中的每个字符在字典树上自上而下对应走出一步,最终就能够找到字典树中维护的它的计数。之后我们可以将其直接返回或者
加一。
注意到如果某些字符串自始至终没有被插入,那么一些节点没有存在的必要。反过来说一些节点是由于我们插入了一个以它对应的字符串
为前缀的字符串才被分配出来的。如下图所示:
.. image:: trie-1.png
一开始仅存在一个根节点。在我们插入字符串 ``acb`` 的过程中,我们只需要分配 ``a````ac`` 两个节点。
注意 ``ac`` 是一个叶节点,它的 ``b`` 指针不再指向另外一个节点而是保存字符串 ``acb`` 的计数。
此时我们无法访问到其他未分配的节点,如根节点的 ``b/c`` 或是 ``a`` 节点的 ``a/b`` 均为空指针。
如果后续再插入一个字符串,那么 **至多分配两个新节点** ,因为如果走的路径上有节点已经存在,就无需重复分配了。
这可以说明,字典树中节点的数目(或者说字典树消耗的内存)是随着插入字符串的数目逐渐线性增加的。
读者可能很好奇,为何在这里要用相当一部分篇幅来介绍字典树呢?事实上 SV39 分页机制等价于一颗字典树。 :math:`27` 位的
虚拟页号可以看成一个长度 :math:`n=3` 的字符串,字符集为 :math:`\alpha=\{0,1,2,...,511\}` ,因为每一位字符都
:math:`9` 个比特组成。而我们也不再维护所谓字符串的计数,而是要找到字符串(虚拟页号)对应的页表项。
因此,每个叶节点都需要保存 :math:`512`:math:`8` 字节的页表项,一共正好 :math:`4\text{KiB}`
可以直接放在一个物理页帧内。而对于非叶节点来说,从功能上它只需要保存 :math:`512` 个指向下级节点的指针即可,
不过我们就像叶节点那样也保存 :math:`512` 个页表项,这样所有的节点都可以被放在一个物理页帧内,它们的位置可以用一个
物理页号来代替。当想从一个非叶节点向下走时,只需找到当前字符对应的页表项的物理页号字段,它就指向了下一级节点的位置,
这样非叶节点中转的功能也就实现了。每个节点的内部是一个线性表,也就是将这个节点起始物理地址加上字符对应的偏移量就找到了
指向下一级节点的页表项(对于非叶节点)或是能够直接用来地址转换的页表项(对于叶节点)。
.. _term-multi-level-page-table:
.. _term-page-index:
这种页表实现被称为 **多级页表** (Multi-Level Page-Table) 。由于 SV39 中虚拟页号被分为三级 **页索引**
(Page Index) ,因此这是一种三级页表。
非叶节点的页表项标志位含义和叶节点相比有一些不同:
- 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
- 只有当V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表。
- 注意: 当V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
在这里我们给出 SV39 中的 R/W/X 组合的含义:
.. image:: pte-rwx.png
:align: center
:height: 250
.. _term-huge-page:
.. note::
**大页** (Huge Page)
本教程中并没有用到大页的知识,这里只是作为拓展,不感兴趣的读者可以跳过。
事实上正确的说法应该是:只要 R/W/X 不全为 0 就会停下来,直接从当前的页表项中取出物理页号进行最终的地址转换。
如果这一过程并没有发生在多级页表的最深层,那么在地址转换的时候并不是直接将物理页号和虚拟地址中的页内偏移接
在一起得到物理地址,这样做会有问题:由于有若干级页索引并没有被使用到,即使两个虚拟地址的这些级页索引不同,
还是会最终得到一个相同的物理地址,导致冲突。
我们需要重新理解将物理页号和页内偏移“接起来”这一行为,它的本质是将物理页号对应的物理页帧的起始物理地址和
页内偏移进行求和,前者是将物理页号左移上页内偏移的位数得到,因此看上去恰好就是将物理页号和页内偏移接在一起。
但是如果在从多级页表往下走的中途停止,未用到的页索引会和虚拟地址的 :math:`12` 位页内偏移一起形成一个
位数更多的页内偏移,也就对应于一个大页,在转换物理地址的时候,其算法仍是上述二者求和,但那时便不再是简单的
拼接操作。
在 SV39 中,如果使用了一级页索引就停下来,则它可以涵盖虚拟页号的前 :math:`9` 位为某一固定值的所有虚拟地址,
对应于一个 :math:`1\text{GiB}` 的大页;如果使用了二级页索引就停下来,则它可以涵盖虚拟页号的前
:math:`18` 位为某一固定值的所有虚拟地址,对应于一个 :math:`2\text{MiB}` 的大页。以同样的视角,如果使用了
所有三级页索引才停下来,它可以涵盖虚拟页号为某一个固定值的所有虚拟地址,自然也就对应于一个大小为
:math:`4\text{KiB}` 的虚拟页面。
使用大页的优点在于,当地址空间的大块连续区域的访问权限均相同的时候,可以直接映射一个大页,从时间上避免了大量
页表项的索引和修改,从空间上降低了所需节点的数目。但是,从内存分配算法的角度,这需要内核支持从物理内存上分配
三种不同大小的连续区域( :math:`4\text{KiB}` 或是另外两种大页),便不能使用更为简单的插槽式管理。权衡利弊
之后,本书全程只会以 :math:`4\text{KiB}` 为单位进行页表映射而不会使用大页特性。
那么 SV39 多级页表相比线性表到底能节省多少内存呢?这里直接给出结论:设某个应用地址空间实际用到的区域总大小为
:math:`S` 字节,则地址空间对应的多级页表消耗内存为 :math:`\frac{S}{512}` 左右。下面给出了详细分析,对此
不感兴趣的读者可以直接跳过。
.. note::
**分析 SV39 多级页表的内存占用**
我们知道,多级页表的总内存消耗取决于节点的数目,每个节点
则需要一个大小为 :math:`4\text{KiB}` 物理页帧存放。不妨设某个应用地址空间中的实际用到的总空间大小为 :math:`S`
字节,则多级页表所需的内存至少有这样两个上界:
- 每映射一个 :math:`4\text{KiB}` 的虚拟页面,最多需要新分配两个物理页帧来保存新的节点,加上初始就有一个根节点,
因此消耗内存不超过
:math:`4\text{KiB}\times(1+2\frac{S}{4\text{KiB}})=4\text{KiB}+2S`
- 考虑已经映射了很多虚拟页面,使得根节点的 :math:`512` 个孩子节点都已经被分配的情况,此时最坏的情况是每次映射
都需要分配一个不同的最深层节点,加上根节点的所有孩子节点并不一定都被分配,从这个角度来讲消耗内存不超过
:math:`4\text{KiB}\times(1+512+\frac{S}{4\text{KiB}})=4\text{KiB}+2\text{MiB}+S`
虽然这两个上限都可以通过刻意构造一种地址空间的使用来达到,但是它们看起来很不合理,因为它们均大于 :math:`S` ,也就是
元数据比数据还大。其实,真实环境中一般不会有如此极端的使用方式,更加贴近
实际的是下面一种上限:即除了根节点的一个物理页帧之外,地址空间中的每个实际用到的大小为 :math:`T` 字节的 *连续* 区间
会让多级页表额外消耗不超过 :math:`4\text{KiB}\times(\lceil\frac{T}{2\text{MiB}}\rceil+\lceil\frac{T}{1\text{GiB}}\rceil)`
的内存。这是因为,括号中的两项分别对应为了映射这段连续区间所需要新分配的最深层和次深层节点的数目,前者每连续映射
:math:`2\text{MiB}` 才会新分配一个,而后者每连续映射 :math:`1\text{GiB}` 才会新分配一个。由于后者远小于前者,
可以将后者忽略,最后得到的结果近似于 :math:`\frac{T}{512}` 。而一般情况下我们对于地址空间的使用方法都是在其中
放置少数几个连续的逻辑段,因此当一个地址空间实际使用的区域大小总和为 :math:`S` 字节的时候,我们可以认为为此多级页表
消耗的内存在 :math:`\frac{S}{512}` 左右。相比线性表固定消耗 :math:`1\text{GiB}` 的内存,这已经相当可以
接受了。
上面主要是对一个固定应用的多级页表进行了介绍。在一个多任务系统中,可能同时存在多个任务处于运行/就绪状态,它们的多级页表
在内存中共存,那么 MMU 应该如何知道当前做地址转换的时候要查哪一个页表呢?回到 :ref:`satp CSR 的布局 <satp-layout>`
其中的 PPN 字段指的就是多级页表根节点所在的物理页号。因此,每个应用的地址空间就可以用包含了它多级页表根节点所在物理页号
``satp`` CSR 代表。在我们切换任务的时候, ``satp`` 也必须被同时切换。
最后的最后,我们给出 SV39 地址转换的全过程图示来结束多级页表原理的介绍:
.. image:: sv39-full.png
:height: 600
:align: center

View File

@ -0,0 +1,618 @@
实现 SV39 多级页表机制(下)
========================================================
本节导读
--------------------------
本节我们继续来实现 SV39 多级页表机制。这还需进一步了解和管理当前已经使用是或空闲的物理页帧,这样操作系统才能给应用程序动态分配或回收物理地址空间。有了有效的物理内存空间的管理,操作系统就能够在物理内存空间中建立多级页表(页表占用物理内存),为应用程序和操作系统自身建立虚实地址映射关系,从而实现虚拟内存空间,即给应用“看到”的地址空间。
物理页帧管理
-----------------------------------
从前面的介绍可以看出物理页帧的重要性:它既可以用来实际存放应用的数据,也能够用来存储某个应用多级页表中的一个节点。
目前的物理内存上已经有一部分用于放置内核的代码和数据,我们需要将剩下可用的部分以单个物理页帧为单位管理起来,
当需要存放应用数据或是应用的多级页表需要一个新节点的时候分配一个物理页帧,并在应用出错或退出的时候回收它占有
的所有物理页帧。
可用物理页的分配与回收
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先,我们需要知道物理内存的哪一部分是可用的。在 ``os/src/linker.ld`` 中,我们用符号 ``ekernel`` 指明了
内核数据的终止物理地址,在它之后的物理内存都是可用的。而在 ``config`` 子模块中:
.. code-block:: rust
// os/src/config.rs
pub const MEMORY_END: usize = 0x80800000;
我们硬编码整块物理内存的终止物理地址为 ``0x80800000`` 。 而 :ref:`之前 <term-physical-memory>` 提到过物理内存的
起始物理地址为 ``0x80000000`` ,这意味着我们将可用内存大小设置为 :math:`8\text{MiB}`
实际上在 Qemu 模拟器上可以通过设置使用更大的物理内存,但这里我们希望
它和真实硬件 K210 的配置保持一致,因此设置为仅使用 :math:`8\text{MiB}` 。我们用一个左闭右开的物理页号区间来表示
可用的物理内存,则:
- 区间的左端点应该是 ``ekernel`` 的物理地址以上取整方式转化成的物理页号;
- 区间的右端点应该是 ``MEMORY_END`` 以下取整方式转化成的物理页号。
这个区间将被传给我们后面实现的物理页帧管理器用于初始化。
我们声明一个 ``FrameAllocator`` Trait 来描述一个物理页帧管理器需要提供哪些功能:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
trait FrameAllocator {
fn new() -> Self;
fn alloc(&mut self) -> Option<PhysPageNum>;
fn dealloc(&mut self, ppn: PhysPageNum);
}
即创建一个实例,还有以物理页号为单位进行物理页帧的分配和回收。
我们实现一种最简单的栈式物理页帧管理策略 ``StackFrameAllocator``
.. code-block:: rust
// os/src/mm/frame_allocator.rs
pub struct StackFrameAllocator {
current: usize,
end: usize,
recycled: Vec<usize>,
}
其中各字段的含义是:物理页号区间 :math:`[\text{current},\text{end})` 此前均 *从未* 被分配出去过,而向量
``recycled`` 以后入先出的方式保存了被回收的物理页号(注意我们已经自然的将内核堆用起来了)。
初始化非常简单。在通过 ``FrameAllocator````new`` 方法创建实例的时候,只需将区间两端均设为 :math:`0`
然后创建一个新的向量;而在它真正被使用起来之前,需要调用 ``init`` 方法将自身的 :math:`[\text{current},\text{end})`
初始化为可用物理页号区间:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
impl FrameAllocator for StackFrameAllocator {
fn new() -> Self {
Self {
current: 0,
end: 0,
recycled: Vec::new(),
}
}
}
impl StackFrameAllocator {
pub fn init(&mut self, l: PhysPageNum, r: PhysPageNum) {
self.current = l.0;
self.end = r.0;
}
}
接下来我们来看核心的物理页帧分配和回收如何实现:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
impl FrameAllocator for StackFrameAllocator {
fn alloc(&mut self) -> Option<PhysPageNum> {
if let Some(ppn) = self.recycled.pop() {
Some(ppn.into())
} else {
if self.current == self.end {
None
} else {
self.current += 1;
Some((self.current - 1).into())
}
}
}
fn dealloc(&mut self, ppn: PhysPageNum) {
let ppn = ppn.0;
// validity check
if ppn >= self.current || self.recycled
.iter()
.find(|&v| {*v == ppn})
.is_some() {
panic!("Frame ppn={:#x} has not been allocated!", ppn);
}
// recycle
self.recycled.push(ppn);
}
}
- 在分配 ``alloc`` 的时候,首先会检查栈 ``recycled`` 内有没有之前回收的物理页号,如果有的话直接弹出栈顶并返回;
否则的话我们只能从之前从未分配过的物理页号区间 :math:`[\text{current},\text{end})` 上进行分配,我们分配它的
左端点 ``current`` ,同时将管理器内部维护的 ``current`` 加一代表 ``current`` 此前已经被分配过了。在即将返回
的时候,我们使用 ``into`` 方法将 usize 转换成了物理页号 ``PhysPageNum``
注意极端情况下可能出现内存耗尽分配失败的情况:即 ``recycled`` 为空且 :math:`\text{current}==\text{end}`
为了涵盖这种情况, ``alloc`` 的返回值被 ``Option`` 包裹,我们返回 ``None`` 即可。
- 在回收 ``dealloc`` 的时候,我们需要检查回收页面的合法性,然后将其压入 ``recycled`` 栈中。回收页面合法有两个
条件:
- 该页面之前一定被分配出去过,因此它的物理页号一定 :math:`<\text{current}`
- 该页面没有正处在回收状态,即它的物理页号不能在栈 ``recycled`` 中找到。
我们通过 ``recycled.iter()`` 获取栈上内容的迭代器,然后通过迭代器的 ``find`` 方法试图
寻找一个与输入物理页号相同的元素。其返回值是一个 ``Option`` ,如果找到了就会是一个 ``Option::Some``
这种情况说明我们内核其他部分实现有误,直接报错退出。
下面我们来创建 ``StackFrameAllocator`` 的全局实例 ``FRAME_ALLOCATOR``
.. code-block:: rust
// os/src/mm/frame_allocator.rs
use spin::Mutex;
type FrameAllocatorImpl = StackFrameAllocator;
lazy_static! {
pub static ref FRAME_ALLOCATOR: Mutex<FrameAllocatorImpl> =
Mutex::new(FrameAllocatorImpl::new());
}
这里我们使用互斥锁 ``Mutex<T>`` 来包裹栈式物理页帧分配器。每次对该分配器进行操作之前,我们都需要先通过
``FRAME_ALLOCATOR.lock()`` 拿到分配器的可变借用。注意 ``alloc`` 中并没有提供 ``Mutex<T>`` ,它
来自于一个我们在 ``no_std`` 的裸机环境下经常使用的名为 ``spin`` 的 crate ,它仅依赖 Rust 核心库
``core`` 提供一些可跨平台使用的同步原语,如互斥锁 ``Mutex<T>`` 和读写锁 ``RwLock<T>`` 等。
.. note::
**Rust 语法卡片:在单核环境下使用 Mutex<T> 的原因**
在编写一个多线程的应用时,加锁的目的是为了避免数据竞争,使得里层的共享数据结构同一时间只有一个线程
在对它进行访问。然而,目前我们的内核运行在单 CPU 上,且 Trap 进入内核之后并没有手动打开中断,这也就
使得同一时间最多只有一条 Trap 执行流并发访问内核的各数据结构,此时应该是并没有任何数据竞争风险的。那么
加锁的原因其实有两点:
1. 在不触及 ``unsafe`` 的情况下实现 ``static mut`` 语义。如果读者还有印象,
:ref:`前面章节 <term-interior-mutability>` 我们使用 ``RefCell<T>`` 提供了内部可变性去掉了
声明中的 ``mut`` ,然而麻烦的在于 ``static`` ,在 Rust 中一个类型想被实例化为一个全局变量,则
该类型必须先告知编译器自己某种意义上是线程安全的,这个过程本身是 ``unsafe`` 的。
因此我们直接使用 ``Mutex<T>`` ,它既通过 ``lock`` 方法提供了内部可变性,又已经在模块内部告知了
编译器它的线程安全性。这样 ``unsafe`` 就被隐藏在了 ``spin`` crate 之内,我们无需关心。这种风格
是 Rust 所推荐的。
2. 方便后续拓展到真正存在数据竞争风险的多核环境下运行。
这里引入了一些新概念,比如什么是线程,又如何定义线程安全?读者可以先不必深究,暂时有一个初步的概念即可。
我们需要添加该 crate 的依赖:
.. code-block:: toml
# os/Cargo.toml
[dependencies]
spin = "0.7.0"
在正式分配物理页帧之前,我们需要将物理页帧全局管理器 ``FRAME_ALLOCATOR`` 初始化:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
pub fn init_frame_allocator() {
extern "C" {
fn ekernel();
}
FRAME_ALLOCATOR
.lock()
.init(PhysAddr::from(ekernel as usize).ceil(), PhysAddr::from(MEMORY_END).floor());
}
这里我们调用物理地址 ``PhysAddr````floor/ceil`` 方法分别下/上取整获得可用的物理页号区间。
分配/回收物理页帧的接口
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
然后是真正公开给其他子模块调用的分配/回收物理页帧的接口:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
pub fn frame_alloc() -> Option<FrameTracker> {
FRAME_ALLOCATOR
.lock()
.alloc()
.map(|ppn| FrameTracker::new(ppn))
}
fn frame_dealloc(ppn: PhysPageNum) {
FRAME_ALLOCATOR
.lock()
.dealloc(ppn);
}
可以发现, ``frame_alloc`` 的返回值类型并不是 ``FrameAllocator`` 要求的物理页号 ``PhysPageNum`` ,而是将其
进一步包装为一个 ``FrameTracker`` 。这里借用了 RAII 的思想,将一个物理页帧的生命周期绑定到一个 ``FrameTracker``
变量上,当一个 ``FrameTracker`` 被创建的时候,我们需要从 ``FRAME_ALLOCATOR`` 中分配一个物理页帧:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
pub struct FrameTracker {
pub ppn: PhysPageNum,
}
impl FrameTracker {
pub fn new(ppn: PhysPageNum) -> Self {
// page cleaning
let bytes_array = ppn.get_bytes_array();
for i in bytes_array {
*i = 0;
}
Self { ppn }
}
}
我们将分配来的物理页帧的物理页号作为参数传给 ``FrameTracker````new`` 方法来创建一个 ``FrameTracker``
实例。由于这个物理页帧之前可能被分配过并用做其他用途,我们在这里直接将这个物理页帧上的所有字节清零。这一过程并不
那么显然,我们后面再详细介绍。
当一个 ``FrameTracker`` 生命周期结束被编译器回收的时候,我们需要将它控制的物理页帧回收掉 ``FRAME_ALLOCATOR`` 中:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}
这里我们只需为 ``FrameTracker`` 实现 ``Drop`` Trait 即可。当一个 ``FrameTracker`` 实例被回收的时候,它的
``drop`` 方法会自动被编译器调用,通过之前实现的 ``frame_dealloc`` 我们就将它控制的物理页帧回收以供后续使用了。
.. note::
**Rust 语法卡片Drop Trait**
Rust 中的 ``Drop`` Trait 是它的 RAII 内存管理风格可以被有效实践的关键。之前介绍的多种在堆上分配的 Rust
数据结构便都是通过实现 ``Drop`` Trait 来进行被绑定资源的自动回收的。例如:
- ``Box<T>````drop`` 方法会回收它控制的分配在堆上的那个变量;
- ``Rc<T>````drop`` 方法会减少分配在堆上的那个引用计数,一旦变为零则分配在堆上的那个被计数的变量自身
也会被回收;
- ``Mutex<T>````lock`` 方法会获取互斥锁并返回一个 ``MutexGuard<'a, T>`` ,它可以被当做一个 ``&mut T``
来使用;而 ``MutexGuard<'a, T>````drop`` 方法会将锁释放,从而允许其他线程获取锁并开始访问里层的
数据结构。锁的实现原理我们先不介绍。
``FrameTracker`` 的设计也是基于同样的思想,有了它之后我们就不必手动回收物理页帧了,这在编译期就解决了很多
潜在的问题。
最后做一个小结:从其他模块的视角看来,物理页帧分配的接口是调用 ``frame_alloc`` 函数得到一个 ``FrameTracker``
(如果物理内存还有剩余),它就代表了一个物理页帧,当它的生命周期结束之后它所控制的物理页帧将被自动回收。下面是
一段演示该接口使用方法的测试程序:
.. code-block:: rust
:linenos:
:emphasize-lines: 9
// os/src/mm/frame_allocator.rs
#[allow(unused)]
pub fn frame_allocator_test() {
let mut v: Vec<FrameTracker> = Vec::new();
for i in 0..5 {
let frame = frame_alloc().unwrap();
println!("{:?}", frame);
v.push(frame);
}
v.clear();
for i in 0..5 {
let frame = frame_alloc().unwrap();
println!("{:?}", frame);
v.push(frame);
}
drop(v);
println!("frame_allocator_test passed!");
}
如果我们将第 9 行删去,则第一轮分配的 5 个物理页帧都是分配之后在循环末尾就被立即回收,因为循环作用域的临时变量
``frame`` 的生命周期在那时结束了。然而,如果我们将它们 move 到一个向量中,它们的生命周期便被延长了——直到第 11 行
向量被清空的时候,这些 ``FrameTracker`` 的生命周期才结束,它们控制的 5 个物理页帧才被回收。这种思想我们立即
就会用到。
多级页表实现
-----------------------------------
页表基本数据结构与访问接口
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们知道SV39 多级页表是以节点为单位进行管理的。每个节点恰好存储在一个物理页帧中,它的位置可以用一个物理页号来
表示。
.. code-block:: rust
:linenos:
// os/src/mm/page_table.rs
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}
impl PageTable {
pub fn new() -> Self {
let frame = frame_alloc().unwrap();
PageTable {
root_ppn: frame.ppn,
frames: vec![frame],
}
}
}
每个应用的地址空间都对应一个不同的多级页表,这也就意味这不同页表的起始地址(即页表根节点的地址)是不一样的。因此 ``PageTable`` 要保存它根节点的物理页号 ``root_ppn`` 作为页表唯一的区分标志。此外,
向量 ``frames````FrameTracker`` 的形式保存了页表所有的节点(包括根节点)所在的物理页帧。这与物理页帧管理模块
的测试程序是一个思路,即将这些 ``FrameTracker`` 的生命周期进一步绑定到 ``PageTable`` 下面。当 ``PageTable``
生命周期结束后,向量 ``frames`` 里面的那些 ``FrameTracker`` 也会被回收,也就意味着存放多级页表节点的那些物理页帧
被回收了。
当我们通过 ``new`` 方法新建一个 ``PageTable`` 的时候,它只需有一个根节点。为此我们需要分配一个物理页帧
``FrameTracker`` 并挂在向量 ``frames`` 下,然后更新根节点的物理页号 ``root_ppn``
多级页表并不是被创建出来之后就不再变化的,为了 MMU 能够通过地址转换正确找到应用地址空间中的数据实际被内核放在内存中
位置,操作系统需要动态维护一个虚拟页号到页表项的映射,支持插入/删除键值对,其方法签名如下:
.. code-block:: rust
// os/src/mm/page_table.rs
impl PageTable {
pub fn map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, flags: PTEFlags);
pub fn unmap(&mut self, vpn: VirtPageNum);
}
- 我们通过 ``map`` 方法来在多级页表中插入一个键值对,注意这里我们将物理页号 ``ppn`` 和页表项标志位 ``flags`` 作为
不同的参数传入而不是整合为一个页表项;
- 相对的,我们通过 ``unmap`` 方法来删除一个键值对,在调用时仅需给出作为索引的虚拟页号即可。
.. _modify-page-table:
在这些操作的过程中我们自然需要访问或修改多级页表节点的内容。每个节点都被保存在一个物理页帧中,在多级页表的架构中我们是以
一个节点被存放在的物理页帧的物理页号作为指针指向该节点,这意味着,对于每个节点来说,一旦我们知道了指向它的物理页号,我们
就需要能够修改这个节点的内容。前面我们在使用 ``frame_alloc`` 分配一个物理页帧之后便立即将它上面的数据清零其实也是一样
的需求。总结一下也就是说,至少在操作某个多级页表或是管理物理页帧的时候,我们要能够自由的读写与一个给定的物理页号对应的
物理页帧上的数据。
在尚未启用分页模式之前,内核和应用的代码都可以通过物理地址直接访问内存。而在打开分页模式之后,分别运行在 S 特权级
和 U 特权级的内核和应用的访存行为都会受到影响,它们的访存地址会被视为一个当前地址空间( ``satp`` CSR 给出当前
多级页表根节点的物理页号)中的一个虚拟地址,需要 MMU
查相应的多级页表完成地址转换变为物理地址,也就是地址空间中虚拟地址指向的数据真正被内核放在的物理内存中的位置,然后
才能访问相应的数据。此时,如果想要访问一个特定的物理地址 ``pa`` 所指向的内存上的数据,就需要对应 **构造** 一个虚拟地址
``va`` ,使得当前地址空间的页表存在映射 :math:`\text{va}\rightarrow\text{pa}` ,且页表项中的保护位允许这种
访问方式。于是,在代码中我们只需访问地址 ``va`` ,它便会被 MMU 通过地址转换变成 ``pa`` ,这样我们就做到了在启用
分页模式的情况下也能从某种意义上直接访问内存。
.. _term-identical-mapping:
这就需要我们提前扩充多级页表维护的映射,使得对于每一个对应于某一特定物理页帧的物理页号 ``ppn`` ,均存在一个虚拟页号
``vpn`` 能够映射到它,而且要能够较为简单的针对一个 ``ppn`` 找到某一个能映射到它的 ``vpn`` 。这里我们采用一种最
简单的 **恒等映射** (Identical Mapping) ,也就是说对于物理内存上的每个物理页帧,我们都在多级页表中用一个与其
物理页号相等的虚拟页号映射到它。当我们想针对物理页号构造一个能映射到它的虚拟页号的时候,也只需使用一个和该物理页号
相等的虚拟页号即可。
.. _term-recursive-mapping:
.. note::
**其他的映射方式**
为了达到这一目的还存在其他不同的映射方式,例如比较著名的 **页表自映射** (Recursive Mapping) 等。有兴趣的同学
可以进一步参考 `BlogOS 中的相关介绍 <https://os.phil-opp.com/paging-implementation/#accessing-page-tables>`_
这里需要说明的是,在下一节中我们可以看到,应用和内核的地址空间是隔离的。而直接访问物理页帧的操作只会在内核中进行,
应用无法看到物理页帧管理器和多级页表等内核数据结构。因此,上述的恒等映射只需被附加到内核地址空间即可。
内核中访问物理页帧的方法
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. _access-frame-in-kernel-as:
于是,我们来看看在内核中应如何访问一个特定的物理页帧:
.. code-block:: rust
// os/src/mm/address.rs
impl PhysPageNum {
pub fn get_pte_array(&self) -> &'static mut [PageTableEntry] {
let pa: PhysAddr = self.clone().into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut PageTableEntry, 512)
}
}
pub fn get_bytes_array(&self) -> &'static mut [u8] {
let pa: PhysAddr = self.clone().into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut u8, 4096)
}
}
pub fn get_mut<T>(&self) -> &'static mut T {
let pa: PhysAddr = self.clone().into();
unsafe {
(pa.0 as *mut T).as_mut().unwrap()
}
}
}
我们构造可变引用来直接访问一个物理页号 ``PhysPageNum`` 对应的物理页帧,不同的引用类型对应于物理页帧上的一种不同的
内存布局,如 ``get_pte_array`` 返回的是一个页表项定长数组的可变引用,可以用来修改多级页表中的一个节点;而
``get_bytes_array`` 返回的是一个字节数组的可变引用,可以以字节为粒度对物理页帧上的数据进行访问,前面进行数据清零
就用到了这个方法; ``get_mut`` 是个泛型函数,可以获取一个恰好放在一个物理页帧开头的类型为 ``T`` 的数据的可变引用。
在实现方面,都是先把物理页号转为物理地址 ``PhysAddr`` ,然后再转成 usize 形式的物理地址。接着,我们直接将它
转为裸指针用来访问物理地址指向的物理内存。在分页机制开启前,这样做自然成立;而开启之后,虽然裸指针被视为一个虚拟地址,
但是上面已经提到这种情况下虚拟地址会映射到一个相同的物理地址,因此在这种情况下也成立。注意,我们在返回值类型上附加了
静态生命周期泛型 ``'static`` ,这是为了绕过 Rust 编译器的借用检查,实质上可以将返回的类型也看成一个裸指针,因为
它也只是标识数据存放的位置以及类型。但与裸指针不同的是,无需通过 ``unsafe`` 的解引用访问它指向的数据,而是可以像一个
正常的可变引用一样直接访问。
.. note::
**unsafe 真的就是“不安全”吗?**
下面是笔者关于 ``unsafe`` 一点可能不太正确的理解,不感兴趣的读者可以跳过。
当我们在 Rust 中使用 unsafe 的时候,并不仅仅是为了绕过编译器检查,更是为了告知编译器和其他看到这段代码的程序员:
**我保证这样做是安全的** ” 。尽管,严格的 Rust 编译器暂时还不能确信这一点。从规范 Rust 代码编写的角度,
我们需要尽可能绕过 unsafe ,因为如果 Rust 编译器或者一些已有的接口就可以提供安全性,我们当然倾向于利用它们让我们
实现的功能仍然是安全的,可以避免一些无谓的心智负担;反之,就只能使用 unsafe ,同时最好说明如何保证这项功能是安全的。
这里简要从内存安全的角度来分析一下 ``PhysPageNum````get_*`` 系列方法的实现中 ``unsafe`` 的使用。为了方便
解释,我们可以将 ``PhysPageNum`` 也看成一种 RAII 的风格,即它控制着一个物理页帧资源的访问。首先,这不会导致
use-after-free 的问题,因为在内核运行全期整块物理内存都是可以访问的,它不存在被释放后无法访问的可能性;其次,
也不会导致并发冲突。注意这不是在 ``PhysPageNum`` 这一层解决的,而是 ``PhysPageNum`` 的使用层要保证任意两个线程
不会同时对一个 ``PhysPageNum`` 进行操作。读者也应该可以感觉出这并不能算是一种好的设计,因为这种约束从代码层面是很
难直接保证的,而是需要系统内部的某种一致性。虽然如此,它对于我们这个极简的内核而言算是很合适了。
.. chyyuu 上面一段提到了线程???
建立和拆除虚实地址映射关系
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
接下来介绍建立和拆除虚实地址映射关系的 ``map````unmap`` 方法是如何实现的。它们都依赖于一个很重要的过程,也即在多级页表中找到一个虚拟地址对应的页表项。
找到之后,只要修改页表项的内容即可完成键值对的插入和删除。在寻找页表项的时候,可能出现页表的中间级节点还未被创建的情况,
这个时候我们需要手动分配一个物理页帧来存放这个节点,并将这个节点接入到当前的多级页表的某级中。
.. code-block:: rust
:linenos:
// os/src/mm/address.rs
impl VirtPageNum {
pub fn indexes(&self) -> [usize; 3] {
let mut vpn = self.0;
let mut idx = [0usize; 3];
for i in (0..3).rev() {
idx[i] = vpn & 511;
vpn >>= 9;
}
idx
}
}
// os/src/mm/page_table.rs
impl PageTable {
fn find_pte_create(&mut self, vpn: VirtPageNum) -> Option<&mut PageTableEntry> {
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&mut PageTableEntry> = None;
for i in 0..3 {
let pte = &mut ppn.get_pte_array()[idxs[i]];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
let frame = frame_alloc().unwrap();
*pte = PageTableEntry::new(frame.ppn, PTEFlags::V);
self.frames.push(frame);
}
ppn = pte.ppn();
}
result
}
}
- ``VirtPageNum````indexes`` 可以取出虚拟页号的三级页索引,并按照从高到低的顺序返回。注意它里面包裹的
usize 可能有 :math:`27` 位,也有可能有 :math:`64-12=52` 位,但这里我们是用来在多级页表上进行遍历,因此
只取出低 :math:`27` 位。
- ``PageTable::find_pte_create`` 在多级页表找到一个虚拟页号对应的页表项的可变引用方便后续的读写。如果在
遍历的过程中发现有节点尚未创建则会新建一个节点。
变量 ``ppn`` 表示当前节点的物理页号,最开始指向多级页表的根节点。随后每次循环通过 ``get_pte_array``
取出当前节点的页表项数组,并根据当前级页索引找到对应的页表项。如果当前节点是一个叶节点,那么直接返回这个页表项
的可变引用;否则尝试向下走。走不下去的话就新建一个节点,更新作为下级节点指针的页表项,并将新分配的物理页帧移动到
向量 ``frames`` 中方便后续的自动回收。注意在更新页表项的时候,不仅要更新物理页号,还要将标志位 V 置 1
不然硬件在查多级页表的时候,会认为这个页表项不合法,从而触发 Page Fault 而不能向下走。
于是, ``map/unmap`` 就非常容易实现了:
.. code-block:: rust
// os/src/mm/page_table.rs
impl PageTable {
pub fn map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, flags: PTEFlags) {
let pte = self.find_pte_create(vpn).unwrap();
assert!(!pte.is_valid(), "vpn {:?} is mapped before mapping", vpn);
*pte = PageTableEntry::new(ppn, flags | PTEFlags::V);
}
pub fn unmap(&mut self, vpn: VirtPageNum) {
let pte = self.find_pte_create(vpn).unwrap();
assert!(pte.is_valid(), "vpn {:?} is invalid before unmapping", vpn);
*pte = PageTableEntry::empty();
}
}
只需根据虚拟页号找到页表项,然后修改或者直接清空其内容即可。
.. warning::
目前的实现方式并不打算对物理页帧耗尽的情形做任何处理而是直接 ``panic`` 退出。因此在前面的代码中能够看到
很多 ``unwrap`` ,这种使用方式并不为 Rust 所推荐,只是由于简单起见暂且这样做。
为了方便后面的实现,我们还需要 ``PageTable`` 提供一种不经过 MMU 而是手动查页表的方法:
.. code-block:: rust
:linenos:
// os/src/mm/page_table.rs
impl PageTable {
/// Temporarily used to get arguments from user space.
pub fn from_token(satp: usize) -> Self {
Self {
root_ppn: PhysPageNum::from(satp & ((1usize << 44) - 1)),
frames: Vec::new(),
}
}
fn find_pte(&self, vpn: VirtPageNum) -> Option<&PageTableEntry> {
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&PageTableEntry> = None;
for i in 0..3 {
let pte = &ppn.get_pte_array()[idxs[i]];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
return None;
}
ppn = pte.ppn();
}
result
}
pub fn translate(&self, vpn: VirtPageNum) -> Option<PageTableEntry> {
self.find_pte(vpn)
.map(|pte| {pte.clone()})
}
}
- 第 5 行的 ``from_token`` 可以临时创建一个专用来手动查页表的 ``PageTable`` ,它仅有一个从传入的 ``satp`` token
中得到的多级页表根节点的物理页号,它的 ``frames`` 字段为空,也即不实际控制任何资源;
- 第 11 行的 ``find_pte`` 和之前的 ``find_pte_create`` 不同之处在于它不会试图分配物理页帧。一旦在多级页表上遍历
遇到空指针它就会直接返回 ``None`` 表示无法正确找到传入的虚拟页号对应的页表项;
- 第 28 行的 ``translate`` 调用 ``find_pte`` 来实现,如果能够找到页表项,那么它会将页表项拷贝一份并返回,否则就
返回一个 ``None``
.. chyyuu 没有提到from_token的作用???

View File

@ -0,0 +1,589 @@
内核与应用的地址空间
================================================
本节导读
--------------------------
页表 ``PageTable`` 只能以页为单位帮助我们维护一个虚拟内存到物理内存的地址转换关系,它本身对于计算机系统的整个虚拟/物理内存空间并没有一个全局的描述和掌控。操作系统通过不同页表的管理,来完成对不同应用和操作系统自身所在的虚拟内存,以及虚拟内存与物理内存映射关系的全面管理。这种管理是建立在地址空间的抽象上的。本节
我们就在内核中通过基于页表的各种数据结构实现地址空间的抽象,并介绍内核和应用的虚拟和物理地址空间中各需要包含哪些内容。
实现地址空间抽象
------------------------------------------
逻辑段:一段连续地址的虚拟内存
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们以逻辑段 ``MapArea`` 为单位描述一段连续地址的虚拟内存。所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表
可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧,具有可读/可写/可执行等属性。
.. code-block:: rust
// os/src/mm/memory_set.rs
pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}
其中 ``VPNRange`` 描述一段虚拟页号的连续区间,表示该逻辑段在地址区间中的位置和长度。它是一个迭代器,可以使用 Rust
的语法糖 for-loop 进行迭代。有兴趣的读者可以参考 ``os/src/mm/address.rs`` 中它的实现。
.. note::
**Rust 语法卡片:迭代器 Iterator**
Rust编程的迭代器模式允许你对一个序列的项进行某些处理。迭代器iterator是负责遍历序列中的每一项和决定序列何时结束的控制逻辑。对于如何使用迭代器处理元素序列和如何实现 Iterator trait 来创建自定义迭代器的内容,可以参考 `Rust 程序设计语言-中文版第十三章第二节 <https://kaisery.github.io/trpl-zh-cn/ch13-02-iterators.html>`_
``MapType`` 描述该逻辑段内的所有虚拟页面映射到物理页帧的同一种方式,它是一个枚举类型,在内核当前的实现中支持两种方式:
.. code-block:: rust
// os/src/mm/memory_set.rs
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum MapType {
Identical,
Framed,
}
其中 ``Identical`` 表示之前也有提到的恒等映射,用于在启用多级页表之后仍能够访问一个特定的物理地址指向的物理内存;而
``Framed`` 则表示对于每个虚拟页面都需要映射到一个新分配的物理页帧。
当逻辑段采用 ``MapType::Framed`` 方式映射到物理内存的时候, ``data_frames`` 是一个保存了该逻辑段内的每个虚拟页面
和它被映射到的物理页帧 ``FrameTracker`` 的一个键值对容器 ``BTreeMap`` 中,这些物理页帧被用来存放实际内存数据而不是
作为多级页表中的中间节点。和之前的 ``PageTable`` 一样,这也用到了 RAII 的思想,将这些物理页帧的生命周期绑定到它所在的逻辑段
``MapArea`` 下,当逻辑段被回收之后这些之前分配的物理页帧也会自动地同时被回收。
``MapPermission`` 表示控制该逻辑段的访问方式,它是页表项标志位 ``PTEFlags`` 的一个子集,仅保留 U/R/W/X
四个标志位,因为其他的标志位仅与硬件的地址转换机制细节相关,这样的设计能避免引入错误的标志位。
.. code-block:: rust
// os/src/mm/memory_set.rs
bitflags! {
pub struct MapPermission: u8 {
const R = 1 << 1;
const W = 1 << 2;
const X = 1 << 3;
const U = 1 << 4;
}
}
地址空间:一系列有关联的逻辑段
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
地址空间是一系列有关联的逻辑段,这种关联一般是指这些逻辑段属于一个运行的程序(目前把一个运行的程序称为任务,后续会称为进程)。用来表明正在运行的应用所在执行环境中的可访问内存空间,在这个内存空间中,包含了一系列的不一定连续的逻辑段。这样我们就有任务的地址空间,内核的地址空间等说法了。地址空间使用 ``MemorySet`` 类型来表示:
.. code-block:: rust
// os/src/mm/memory_set.rs
pub struct MemorySet {
page_table: PageTable,
areas: Vec<MapArea>,
}
它包含了该地址空间的多级页表 ``page_table`` 和一个逻辑段 ``MapArea`` 的向量 ``areas`` 。注意 ``PageTable``
挂着所有多级页表的节点所在的物理页帧,而每个 ``MapArea`` 下则挂着对应逻辑段中的数据所在的物理页帧,这两部分
合在一起构成了一个地址空间所需的所有物理页帧。这同样是一种 RAII 风格,当一个地址空间 ``MemorySet`` 生命周期结束后,
这些物理页帧都会被回收。
地址空间 ``MemorySet`` 的方法如下:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MemorySet {
pub fn new_bare() -> Self {
Self {
page_table: PageTable::new(),
areas: Vec::new(),
}
}
fn push(&mut self, mut map_area: MapArea, data: Option<&[u8]>) {
map_area.map(&mut self.page_table);
if let Some(data) = data {
map_area.copy_data(&mut self.page_table, data);
}
self.areas.push(map_area);
}
/// Assume that no conflicts.
pub fn insert_framed_area(
&mut self,
start_va: VirtAddr, end_va: VirtAddr, permission: MapPermission
) {
self.push(MapArea::new(
start_va,
end_va,
MapType::Framed,
permission,
), None);
}
pub fn new_kernel() -> Self;
/// Include sections in elf and trampoline and TrapContext and user stack,
/// also returns user_sp and entry point.
pub fn from_elf(elf_data: &[u8]) -> (Self, usize, usize);
}
- 第 4 行, ``new_bare`` 方法可以新建一个空的地址空间;
- 第 10 行, ``push`` 方法可以在当前地址空间插入一个新的逻辑段 ``map_area`` ,如果它是以 ``Framed`` 方式映射到
物理内存,还可以可选地在那些被映射到的物理页帧上写入一些初始化数据 ``data``
- 第 18 行, ``insert_framed_area`` 方法调用 ``push`` ,可以在当前地址空间插入一个 ``Framed`` 方式映射到
物理内存的逻辑段。注意该方法的调用者要保证同一地址空间内的任意两个逻辑段不能存在交集,从后面即将分别介绍的内核和
应用的地址空间布局可以看出这一要求得到了保证;
- 第 29 行, ``new_kernel`` 可以生成内核的地址空间,而第 32 行的 ``from_elf`` 则可以应用的 ELF 格式可执行文件
解析出各数据段并对应生成应用的地址空间。它们的实现我们将在后面讨论。
在实现 ``push`` 方法在地址空间中插入一个逻辑段 ``MapArea`` 的时候,需要同时维护地址空间的多级页表 ``page_table``
记录的虚拟页号到页表项的映射关系,也需要用到这个映射关系来找到向哪些物理页帧上拷贝初始数据。这用到了 ``MapArea``
提供的另外几个方法:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MapArea {
pub fn new(
start_va: VirtAddr,
end_va: VirtAddr,
map_type: MapType,
map_perm: MapPermission
) -> Self {
let start_vpn: VirtPageNum = start_va.floor();
let end_vpn: VirtPageNum = end_va.ceil();
Self {
vpn_range: VPNRange::new(start_vpn, end_vpn),
data_frames: BTreeMap::new(),
map_type,
map_perm,
}
}
pub fn map(&mut self, page_table: &mut PageTable) {
for vpn in self.vpn_range {
self.map_one(page_table, vpn);
}
}
pub fn unmap(&mut self, page_table: &mut PageTable) {
for vpn in self.vpn_range {
self.unmap_one(page_table, vpn);
}
}
/// data: start-aligned but maybe with shorter length
/// assume that all frames were cleared before
pub fn copy_data(&mut self, page_table: &mut PageTable, data: &[u8]) {
assert_eq!(self.map_type, MapType::Framed);
let mut start: usize = 0;
let mut current_vpn = self.vpn_range.get_start();
let len = data.len();
loop {
let src = &data[start..len.min(start + PAGE_SIZE)];
let dst = &mut page_table
.translate(current_vpn)
.unwrap()
.ppn()
.get_bytes_array()[..src.len()];
dst.copy_from_slice(src);
start += PAGE_SIZE;
if start >= len {
break;
}
current_vpn.step();
}
}
}
- 第 4 行的 ``new`` 方法可以新建一个逻辑段结构体,注意传入的起始/终止虚拟地址会分别被下取整/上取整为虚拟页号并传入
迭代器 ``vpn_range`` 中;
- 第 19 行的 ``map`` 和第 24 行的 ``unmap`` 可以将当前逻辑段到物理内存的映射从传入的该逻辑段所属的地址空间的
多级页表中加入或删除。可以看到它们的实现是遍历逻辑段中的所有虚拟页面,并以每个虚拟页面为单位依次在多级页表中进行
键值对的插入或删除,分别对应 ``MapArea````map_one````unmap_one`` 方法,我们后面将介绍它们的实现;
- 第 31 行的 ``copy_data`` 方法将切片 ``data`` 中的数据拷贝到当前逻辑段实际被内核放置在的各物理页帧上,从而
在地址空间中通过该逻辑段就能访问这些数据。调用它的时候需要满足:切片 ``data`` 中的数据大小不超过当前逻辑段的
总大小,且切片中的数据会被对齐到逻辑段的开头,然后逐页拷贝到实际的物理页帧。
从第 36 行开始的循环会遍历每一个需要拷贝数据的虚拟页面,在数据拷贝完成后会在第 48 行通过调用 ``step`` 方法,该
方法来自于 ``os/src/mm/address.rs`` 中为 ``VirtPageNum`` 实现的 ``StepOne`` Trait感兴趣的读者可以阅读
代码确认其实现。
每个页面的数据拷贝需要确定源 ``src`` 和目标 ``dst`` 两个切片并直接使用 ``copy_from_slice`` 完成复制。当确定
目标切片 ``dst`` 的时候,第 ``39`` 行从传入的当前逻辑段所属的地址空间的多级页表中手动查找迭代到的虚拟页号被映射
到的物理页帧,并通过 ``get_bytes_array`` 方法获取能够真正改写该物理页帧上内容的字节数组型可变引用,最后再获取它
的切片用于数据拷贝。
接下来介绍对逻辑段中的单个虚拟页面进行映射/解映射的方法 ``map_one````unmap_one`` 。显然它们的实现取决于当前
逻辑段被映射到物理内存的方式:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MemoryArea {
pub fn map_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum) {
let ppn: PhysPageNum;
match self.map_type {
MapType::Identical => {
ppn = PhysPageNum(vpn.0);
}
MapType::Framed => {
let frame = frame_alloc().unwrap();
ppn = frame.ppn;
self.data_frames.insert(vpn, frame);
}
}
let pte_flags = PTEFlags::from_bits(self.map_perm.bits).unwrap();
page_table.map(vpn, ppn, pte_flags);
}
pub fn unmap_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum) {
match self.map_type {
MapType::Framed => {
self.data_frames.remove(&vpn);
}
_ => {}
}
page_table.unmap(vpn);
}
}
- 对于第 4 行的 ``map_one`` 来说,在虚拟页号 ``vpn`` 已经确定的情况下,它需要知道要将一个怎么样的页表项插入多级页表。
页表项的标志位来源于当前逻辑段的类型为 ``MapPermission`` 的统一配置,只需将其转换为 ``PTEFlags`` ;而页表项的
物理页号则取决于当前逻辑段映射到物理内存的方式:
- 当以恒等映射 ``Identical`` 方式映射的时候,物理页号就等于虚拟页号;
- 当以 ``Framed`` 方式映射的时候,需要分配一个物理页帧让当前的虚拟页面可以映射过去,此时页表项中的物理页号自然就是
这个被分配的物理页帧的物理页号。此时还需要将这个物理页帧挂在逻辑段的 ``data_frames`` 字段下。
当确定了页表项的标志位和物理页号之后,即可调用多级页表 ``PageTable````map`` 接口来插入键值对。
- 对于第 19 行的 ``unmap_one`` 来说,基本上就是调用 ``PageTable````unmap`` 接口删除以传入的虚拟页号为键的
键值对即可。然而,当以 ``Framed`` 映射的时候,不要忘记同时将虚拟页面被映射到的物理页帧 ``FrameTracker``
``data_frames`` 中移除,这样这个物理页帧才能立即被回收以备后续分配。
内核地址空间
------------------------------------------
.. _term-isolation:
在本章之前,内核和应用代码的访存地址都被视为一个物理地址直接访问物理内存,而在分页模式开启之后,它们都需要通过 MMU 的
地址转换变成物理地址再交给 CPU 的访存单元去访问物理内存。地址空间抽象的重要意义在于 **隔离** (Isolation) ,当我们
在执行每个应用的代码的时候,内核需要控制 MMU 使用这个应用地址空间的多级页表进行地址转换。由于每个应用地址空间在创建
的时候也顺带设置好了多级页表使得只有那些存放了它的数据的物理页帧能够通过该多级页表被映射到,这样它就只能访问自己的数据
而无法触及其他应用或是内核的数据。
.. _term-trampoline:
启用分页模式下,内核代码的访存地址也会被视为一个虚拟地址并需要经过 MMU 的地址转换,因此我们也需要为内核对应构造一个
地址空间,它除了仍然需要允许内核的各数据段能够被正常访问之后,还需要包含所有应用的内核栈以及一个
**跳板** (Trampoline) 。我们会在本章的最后一节再深入介绍跳板的机制。
下图是软件看到的 64 位地址空间在 SV39 分页模式下实际可能通过 MMU 检查的最高 :math:`256\text{GiB}` (之前在
:ref:`这里 <high-and-low-256gib>` 中解释过最高和最低 :math:`256\text{GiB}` 的问题):
.. image:: kernel-as-high.png
:name: kernel-as-high
:align: center
:height: 400
可以看到,跳板放在最高的一个虚拟页面中。接下来则是从高到低放置每个应用的内核栈,内核栈的大小由 ``config`` 子模块的
``KERNEL_STACK_SIZE`` 给出。它们的映射方式为 ``MapPermission`` 中的 rw 两个标志位,意味着这个逻辑段仅允许
CPU 处于内核态访问,且只能读或写。
.. _term-guard-page:
注意相邻两个内核栈之间会预留一个 **保护页面** (Guard Page) ,它是内核地址空间中的空洞,多级页表中并不存在与它相关的映射。
它的意义在于当内核栈空间不足(如调用层数过多或死递归)的时候,代码会尝试访问
空洞区域内的虚拟地址,然而它无法在多级页表中找到映射,便会触发异常,此时控制权会交给 trap handler 对这种情况进行
处理。由于编译器会对访存顺序和局部变量在栈帧中的位置进行优化,我们难以确定一个已经溢出的栈帧中的哪些位置会先被访问,
但总的来说,空洞区域被设置的越大,我们就能越早捕获到这一错误并避免它覆盖其他重要数据。由于我们的内核非常简单且内核栈
的大小设置比较宽裕,在当前的设计中我们仅将空洞区域的大小设置为单个页面。
下面则给出了内核地址空间的低 :math:`256\text{GiB}` 的布局:
.. image:: kernel-as-low.png
:align: center
:height: 400
四个逻辑段 ``.text/.rodata/.data/.bss`` 被恒等映射到物理内存,这使得我们在无需调整内核内存布局 ``os/src/linker.ld``
的情况下就仍能和启用页表机制之前那样访问内核的各数据段。注意我们借用页表机制对这些逻辑段的访问方式做出了限制,这都是为了
在硬件的帮助下能够尽可能发现内核中的 bug ,在这里:
- 四个逻辑段的 U 标志位均未被设置,使得 CPU 只能在处于 S 特权级(或以上)时访问它们;
- 代码段 ``.text`` 不允许被修改;
- 只读数据段 ``.rodata`` 不允许被修改,也不允许从它上面取指;
- ``.data/.bss`` 均允许被读写,但是不允许从它上面取指。
此外, :ref:`之前 <modify-page-table>` 提到过内核地址空间中需要存在一个恒等映射到内核数据段之外的可用物理
页帧的逻辑段,这样才能在启用页表机制之后,内核仍能以纯软件的方式读写这些物理页帧。它们的标志位仅包含 rw ,意味着该
逻辑段只能在 S 特权级以上访问,并且只能读写。
下面我们给出创建内核地址空间的方法 ``new_kernel``
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
extern "C" {
fn stext();
fn etext();
fn srodata();
fn erodata();
fn sdata();
fn edata();
fn sbss_with_stack();
fn ebss();
fn ekernel();
fn strampoline();
}
impl MemorySet {
/// Without kernel stacks.
pub fn new_kernel() -> Self {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map kernel sections
println!(".text [{:#x}, {:#x})", stext as usize, etext as usize);
println!(".rodata [{:#x}, {:#x})", srodata as usize, erodata as usize);
println!(".data [{:#x}, {:#x})", sdata as usize, edata as usize);
println!(".bss [{:#x}, {:#x})", sbss_with_stack as usize, ebss as usize);
println!("mapping .text section");
memory_set.push(MapArea::new(
(stext as usize).into(),
(etext as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::X,
), None);
println!("mapping .rodata section");
memory_set.push(MapArea::new(
(srodata as usize).into(),
(erodata as usize).into(),
MapType::Identical,
MapPermission::R,
), None);
println!("mapping .data section");
memory_set.push(MapArea::new(
(sdata as usize).into(),
(edata as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
println!("mapping .bss section");
memory_set.push(MapArea::new(
(sbss_with_stack as usize).into(),
(ebss as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
println!("mapping physical memory");
memory_set.push(MapArea::new(
(ekernel as usize).into(),
MEMORY_END.into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
memory_set
}
}
``new_kernel`` 将映射跳板和地址空间中最低 :math:`256\text{GiB}` 中的所有的逻辑段。第 3 行开始,我们从
``os/src/linker.ld`` 中引用了很多表示了各个段位置的符号,而后在 ``new_kernel`` 中,我们从低地址到高地址
依次创建 5 个逻辑段并通过 ``push`` 方法将它们插入到内核地址空间中,上面我们已经详细介绍过这 5 个逻辑段。跳板
是通过 ``map_trampoline`` 方法来映射的,我们也将在本章最后一节进行讲解。
应用地址空间
------------------------------------------
现在我们来介绍如何创建应用的地址空间。在前面的章节中,我们直接将丢弃所有符号的应用二进制镜像链接到内核,在初始化的时候
内核仅需将他们加载到正确的初始物理地址就能使它们正确执行。但本章中,我们希望效仿内核地址空间的设计,同样借助页表机制
使得应用地址空间的各个逻辑段也可以有不同的访问方式限制,这样可以提早检测出应用的错误并及时将其终止以最小化它对系统带来的
恶劣影响。
在第三章中,每个应用链接脚本中的起始地址被要求是不同的,这样它们的代码和数据存放的位置才不会产生冲突。但是这是一种对于应用开发者
极其不友好的设计。现在,借助地址空间的抽象,我们终于可以让所有应用程序都使用同样的起始地址,这也意味着所有应用可以使用同一个链接脚本了:
.. code-block::
:linenos:
/* user/src/linker.ld */
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x0;
SECTIONS
{
. = BASE_ADDRESS;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
.rodata : {
*(.rodata .rodata.*)
}
. = ALIGN(4K);
.data : {
*(.data .data.*)
}
.bss : {
*(.bss .bss.*)
}
/DISCARD/ : {
*(.eh_frame)
*(.debug*)
}
}
我们将起始地址 ``BASE_ADDRESS`` 设置为 :math:`\text{0x0}` ,显然它只能是一个地址空间中的虚拟地址而非物理地址。
事实上由于我们将入口汇编代码段放在最低的地方,这也是整个应用的入口点。
我们只需清楚这一事实即可,而无需像之前一样将其硬编码到代码中。此外,在 ``.text````.rodata`` 中间以及 ``.rodata``
``.data`` 中间我们进行了页面对齐,因为前后两个逻辑段的访问方式限制是不同的,由于我们只能以页为单位对这个限制进行设置,
因此就只能将下一个逻辑段对齐到下一个页面开始放置。相对的, ``.data````.bss`` 两个逻辑段由于限制相同,它们中间
则无需进行页面对齐。
下图展示了应用地址空间的布局:
.. image:: app-as-full.png
:align: center
:height: 400
左侧给出了应用地址空间最低 :math:`256\text{GiB}` 的布局:从 :math:`\text{0x0}` 开始向高地址放置应用内存布局中的
各个逻辑段,最后放置带有一个保护页面的用户栈。这些逻辑段都是以 ``Framed`` 方式映射到物理内存的,从访问方式上来说都加上
了 U 标志位代表 CPU 可以在 U 特权级也就是执行应用代码的时候访问它们。右侧则给出了最高的 :math:`256\text{GiB}`
可以看出它只是和内核地址空间一样将跳板放置在最高页,还将 Trap 上下文放置在次高页中。这两个虚拟页面虽然位于应用地址空间,
但是它们并不包含 U 标志位,事实上它们在地址空间切换的时候才会发挥作用,请同样参考本章的最后一节。
``os/src/build.rs`` 中,我们不再将丢弃了所有符号的应用二进制镜像链接进内核,而是直接使用 ELF 格式的可执行文件,
因为在前者中内存布局中各个逻辑段的位置和访问限制等信息都被裁剪掉了。而 ``loader`` 子模块也变得极其精简:
.. code-block:: rust
// os/src/loader.rs
pub fn get_num_app() -> usize {
extern "C" { fn _num_app(); }
unsafe { (_num_app as usize as *const usize).read_volatile() }
}
pub fn get_app_data(app_id: usize) -> &'static [u8] {
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = get_num_app();
let app_start = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
assert!(app_id < num_app);
unsafe {
core::slice::from_raw_parts(
app_start[app_id] as *const u8,
app_start[app_id + 1] - app_start[app_id]
)
}
}
它仅需要提供两个函数: ``get_num_app`` 获取链接到内核内的应用的数目,而 ``get_app_data`` 则根据传入的应用编号
取出对应应用的 ELF 格式可执行文件数据。它们和之前一样仍是基于 ``build.rs`` 生成的 ``link_app.S`` 给出的符号来
确定其位置,并实际放在内核的数据段中。
``loader`` 模块中原有的内核和用户栈则分别作为逻辑段放在内核和用户地址空间中,我们无需再去专门为其定义一种类型。
在创建应用地址空间的时候,我们需要对 ``get_app_data`` 得到的 ELF 格式数据进行解析,找到各个逻辑段所在位置和访问
限制并插入进来,最终得到一个完整的应用地址空间:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MemorySet {
/// Include sections in elf and trampoline and TrapContext and user stack,
/// also returns user_sp and entry point.
pub fn from_elf(elf_data: &[u8]) -> (Self, usize, usize) {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map program headers of elf, with U flag
let elf = xmas_elf::ElfFile::new(elf_data).unwrap();
let elf_header = elf.header;
let magic = elf_header.pt1.magic;
assert_eq!(magic, [0x7f, 0x45, 0x4c, 0x46], "invalid elf!");
let ph_count = elf_header.pt2.ph_count();
let mut max_end_vpn = VirtPageNum(0);
for i in 0..ph_count {
let ph = elf.program_header(i).unwrap();
if ph.get_type().unwrap() == xmas_elf::program::Type::Load {
let start_va: VirtAddr = (ph.virtual_addr() as usize).into();
let end_va: VirtAddr = ((ph.virtual_addr() + ph.mem_size()) as usize).into();
let mut map_perm = MapPermission::U;
let ph_flags = ph.flags();
if ph_flags.is_read() { map_perm |= MapPermission::R; }
if ph_flags.is_write() { map_perm |= MapPermission::W; }
if ph_flags.is_execute() { map_perm |= MapPermission::X; }
let map_area = MapArea::new(
start_va,
end_va,
MapType::Framed,
map_perm,
);
max_end_vpn = map_area.vpn_range.get_end();
memory_set.push(
map_area,
Some(&elf.input[ph.offset() as usize..(ph.offset() + ph.file_size()) as usize])
);
}
}
// map user stack with U flags
let max_end_va: VirtAddr = max_end_vpn.into();
let mut user_stack_bottom: usize = max_end_va.into();
// guard page
user_stack_bottom += PAGE_SIZE;
let user_stack_top = user_stack_bottom + USER_STACK_SIZE;
memory_set.push(MapArea::new(
user_stack_bottom.into(),
user_stack_top.into(),
MapType::Framed,
MapPermission::R | MapPermission::W | MapPermission::U,
), None);
// map TrapContext
memory_set.push(MapArea::new(
TRAP_CONTEXT.into(),
TRAMPOLINE.into(),
MapType::Framed,
MapPermission::R | MapPermission::W,
), None);
(memory_set, user_stack_top, elf.header.pt2.entry_point() as usize)
}
}
- 第 9 行,我们将跳板插入到应用地址空间;
- 第 11 行,我们使用外部 crate ``xmas_elf`` 来解析传入的应用 ELF 数据并可以轻松取出各个部分。
:ref:`此前 <term-elf>` 我们简要介绍过 ELF 格式的布局。第 14 行,我们取出 ELF 的魔数来判断
它是不是一个合法的 ELF 。
第 15 行,我们可以直接得到 program header 的数目,然后遍历所有的 program header 并将合适的区域加入
到应用地址空间中。这一过程的主体在第 17~39 行之间。第 19 行我们确认 program header 的类型是 ``LOAD``
这表明它有被内核加载的必要,此时不必理会其他类型的 program header 。接着通过 ``ph.virtual_addr()``
``ph.mem_size()`` 来计算这一区域在应用地址空间中的位置,通过 ``ph.flags()`` 来确认这一区域访问方式的
限制并将其转换为 ``MapPermission`` 类型(注意它默认包含 U 标志位)。最后我们在第 27 行创建逻辑段
``map_area`` 并在第 34 行 ``push`` 到应用地址空间。在 ``push`` 的时候我们需要完成数据拷贝,当前
program header 数据被存放的位置可以通过 ``ph.offset()````ph.file_size()`` 来找到。 注意当
存在一部分零初始化的时候, ``ph.file_size()`` 将会小于 ``ph.mem_size()`` ,因为这些零出于缩减可执行
文件大小的原因不应该实际出现在 ELF 数据中。
- 我们从第 40 行开始处理用户栈。注意在前面加载各个 program header 的时候,我们就已经维护了 ``max_end_vpn``
记录目前涉及到的最大的虚拟页号,只需紧接着在它上面再放置一个保护页面和用户栈即可。
- 第 53 行则在应用地址空间中映射次高页面来存放 Trap 上下文。
- 第 59 行返回的时候,我们不仅返回应用地址空间 ``memory_set`` ,也同时返回用户栈虚拟地址 ``user_stack_top``
以及从解析 ELF 得到的该应用入口点地址,它们将被我们用来创建应用的任务控制块。

View File

@ -0,0 +1,796 @@
基于地址空间的分时多任务
==============================================================
本节导读
--------------------------
本节我们介绍如何基于地址空间抽象而不是对于物理内存的直接访问来实现第三章的分时多任务系统。这样,我们的应用编写会更加方便,与操作系统的关联也松耦合一些,操作系统自身的安全性也得到了加强。
建立并开启基于分页模式的虚拟地址空间
--------------------------------------------
当 SBI 实现(本项目中基于 RustSBI初始化完成后 CPU 将跳转到内核入口点并在 S 特权级上执行,此时还并没有开启分页模式
,内核的每一次访存仍被视为一个物理地址直接访问物理内存。而在开启分页模式之后,内核的代码在访存的时候只能看到内核地址空间,
此时每次访存将被视为一个虚拟地址且需要通过 MMU 基于内核地址空间的多级页表的地址转换。这两种模式之间的过渡在内核初始化期间
完成。
创建内核地址空间
^^^^^^^^^^^^^^^^^^^^^^^^
我们创建内核地址空间的全局实例:
.. code-block:: rust
// os/src/mm/memory_set.rs
lazy_static! {
pub static ref KERNEL_SPACE: Arc<Mutex<MemorySet>> = Arc::new(Mutex::new(
MemorySet::new_kernel()
));
}
从之前对于 ``lazy_static!`` 宏的介绍可知, ``KERNEL_SPACE`` 在运行期间它第一次被用到时才会实际进行初始化,而它所
占据的空间则是编译期被放在全局数据段中。这里使用经典的 ``Arc<Mutex<T>>`` 组合是因为我们既需要 ``Arc<T>`` 提供的共享
引用,也需要 ``Mutex<T>`` 提供的互斥访问。在多核环境下才能体现出它的全部能力,目前在单核环境下主要是为了通过编译器检查。
``rust_main`` 函数中,我们首先调用 ``mm::init`` 进行内存管理子系统的初始化:
.. code-block:: rust
// os/src/mm/mod.rs
pub use memory_set::KERNEL_SPACE;
pub fn init() {
heap_allocator::init_heap();
frame_allocator::init_frame_allocator();
KERNEL_SPACE.lock().activate();
}
可以看到,我们最先进行了全局动态内存分配器的初始化,因为接下来马上就要用到 Rust 的堆数据结构。接下来我们初始化物理页帧
管理器(内含堆数据结构 ``Vec<T>`` )使能可用物理页帧的分配和回收能力。最后我们创建内核地址空间并让 CPU 开启分页模式,
MMU 在地址转换的时候使用内核的多级页表,这一切均在一行之内做到:
- 首先,我们引用 ``KERNEL_SPACE`` ,这是它第一次被使用,就在此时它会被初始化,调用 ``MemorySet::new_kernel``
创建一个内核地址空间并使用 ``Arc<Mutex<T>>`` 包裹起来;
- 接着使用 ``.lock()`` 获取一个可变引用 ``&mut MemorySet`` 。需要注意的是这里发生了两次隐式类型转换:
1. 我们知道
``lock````Mutex<T>`` 的方法而不是 ``Arc<T>`` 的方法,由于 ``Arc<T>`` 实现了 ``Deref`` Trait ,当
``lock`` 需要一个 ``&Mutex<T>`` 类型的参数的时候,编译器会自动将传入的 ``&Arc<Mutex<T>>`` 转换为
``&Mutex<T>`` 这样就实现了类型匹配;
2. 事实上 ``Mutex<T>::lock`` 返回的是一个 ``MutexGuard<'a, T>`` ,这同样是
RAII 的思想,当这个类型生命周期结束后互斥锁就会被释放。而该类型实现了 ``DerefMut`` Trait因此当一个函数接受类型
``&mut T`` 的参数却被传入一个类型为 ``&mut MutexGuard<'a, T>`` 的参数的时候,编译器会自动进行类型转换使
参数匹配。
- 最后,我们调用 ``MemorySet::activate``
.. code-block:: rust
:linenos:
// os/src/mm/page_table.rs
pub fn token(&self) -> usize {
8usize << 60 | self.root_ppn.0
}
// os/src/mm/memory_set.rs
impl MemorySet {
pub fn activate(&self) {
let satp = self.page_table.token();
unsafe {
satp::write(satp);
llvm_asm!("sfence.vma" :::: "volatile");
}
}
}
``PageTable::token`` 会按照 :ref:`satp CSR 格式要求 <satp-layout>` 构造一个无符号 64 位无符号整数,使得其
分页模式为 SV39 ,且将当前多级页表的根节点所在的物理页号填充进去。在 ``activate`` 中,我们将这个值写入当前 CPU 的
satp CSR ,从这一刻开始 SV39 分页模式就被启用了,而且 MMU 会使用内核地址空间的多级页表进行地址转换。
我们必须注意切换 satp CSR 是否是一个 *平滑* 的过渡:其含义是指,切换 satp 的指令及其下一条指令这两条相邻的指令的
虚拟地址是相邻的(由于切换 satp 的指令并不是一条跳转指令, pc 只是简单的自增当前指令的字长),
而它们所在的物理地址一般情况下也是相邻的,但是它们所经过的地址转换流程却是不同的——切换 satp 导致 MMU 查的多级页表
是不同的。这就要求前后两个地址空间在切换 satp 的指令 *附近* 的映射满足某种意义上的连续性。
幸运的是,我们做到了这一点。这条写入 satp 的指令及其下一条指令都在内核内存布局的代码段中,在切换之后是一个恒等映射,
而在切换之前是视为物理地址直接取指,也可以将其看成一个恒等映射。这完全符合我们的期待:即使切换了地址空间,指令仍应该
能够被连续的执行。
注意到在 ``activate`` 的最后,我们插入了一条汇编指令 ``sfence.vma`` ,它又起到什么作用呢?
让我们再来回顾一下多级页表:它相比线性表虽然大量节约了内存占用,但是却需要 MMU 进行更多的隐式访存。如果是一个线性表,
MMU 仅需单次访存就能找到页表项并完成地址转换,而多级页表(以 SV39 为例,不考虑大页)最顺利的情况下也需要三次访存。这些
额外的访存和真正访问数据的那些访存在空间上并不相邻,加大了多级缓存的压力,一旦缓存缺失将带来巨大的性能惩罚。如果采用
多级页表实现,这个问题会变得更为严重,使得地址空间抽象的性能开销过大。
.. _term-tlb:
为了解决性能问题,一种常见的做法是在 CPU 中利用部分硬件资源额外加入一个 **快表**
(TLB, Translation Lookaside Buffer) 它维护了部分虚拟页号到页表项的键值对。当 MMU 进行地址转换的时候,首先
会到快表中看看是否匹配,如果匹配的话直接取出页表项完成地址转换而无需访存;否则再去查页表并将键值对保存在快表中。一旦
我们修改了 satp 切换了地址空间,快表中的键值对就会失效,因为它还表示着上个地址空间的映射关系。为了 MMU 的地址转换
能够及时与 satp 的修改同步,我们可以选择立即使用 ``sfence.vma`` 指令将快表清空,这样 MMU 就不会看到快表中已经
过期的键值对了。
.. note::
**sfence.vma 是一个屏障**
对于一种仅含有快表的 RISC-V CPU 实现来说,我们可以认为 ``sfence.vma`` 的作用就是清空快表。事实上它在特权级
规范中被定义为一种含义更加丰富的内存屏障,具体来说: ``sfence.vma`` 可以使得所有发生在它后面的地址转换都能够
看到所有排在它前面的写入操作,在不同的平台上这条指令要做的事情也都是不同的。这条指令还可以被精细配置来减少同步开销,
详情请参考 RISC-V 特权级规范。
检查内核地址空间的多级页表设置
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
调用 ``mm::init`` 之后我们就使能了内核动态内存分配、物理页帧管理,还启用了分页模式进入了内核地址空间。之后我们可以
通过 ``mm::remap_test`` 来检查内核地址空间的多级页表是否被正确设置:
.. code-block:: rust
// os/src/mm/memory_set.rs
pub fn remap_test() {
let mut kernel_space = KERNEL_SPACE.lock();
let mid_text: VirtAddr = ((stext as usize + etext as usize) / 2).into();
let mid_rodata: VirtAddr = ((srodata as usize + erodata as usize) / 2).into();
let mid_data: VirtAddr = ((sdata as usize + edata as usize) / 2).into();
assert_eq!(
kernel_space.page_table.translate(mid_text.floor()).unwrap().writable(),
false
);
assert_eq!(
kernel_space.page_table.translate(mid_rodata.floor()).unwrap().writable(),
false,
);
assert_eq!(
kernel_space.page_table.translate(mid_data.floor()).unwrap().executable(),
false,
);
println!("remap_test passed!");
}
其中分别通过手动查内核多级页表的方式验证代码段和只读数据段不允许被写入,同时不允许从数据段上取指。
.. _term-trampoline:
跳板的实现
------------------------------------
上一小节我们看到无论是内核还是应用的地址空间,最高的虚拟页面都是一个跳板。同时应用地址空间的次高虚拟页面还被设置为用来
存放应用的 Trap 上下文。那么跳板究竟起什么作用呢?为何不直接把 Trap 上下文仍放到应用的内核栈中呢?
回忆曾在第二章介绍过的 :ref:`Trap 上下文保存与恢复 <trap-context-save-restore>` 。当一个应用 Trap 到内核的时候,
``sscratch`` 已经指出了该应用内核栈的栈顶,我们用一条指令即可从用户栈切换到内核栈,然后直接将 Trap 上下文压入内核栈
栈顶。当 Trap 处理完毕返回用户态的时候,将 Trap 上下文中的内容恢复到寄存器上,最后将保存着应用用户栈顶的 ``sscratch``
与 sp 进行交换,也就从内核栈切换回了用户栈。在这个过程中, ``sscratch`` 起到了非常关键的作用,它使得我们可以在不破坏
任何通用寄存器的情况下完成用户栈和内核栈顶的 Trap 上下文这两个工作区域之间的切换。
然而,一旦使能了分页机制,一切就并没有这么简单了,我们必须在这个过程中同时完成地址空间的切换。
具体来说,当 ``__alltraps`` 保存 Trap 上下文的时候,我们必须通过修改 satp 从应用地址空间切换到内核地址空间,
因为 trap handler 只有在内核地址空间中才能访问;
同理,在 ``__restore`` 恢复 Trap 上下文的时候,我们也必须从内核地址空间切换回应用地址空间,因为应用的代码和
数据只能在它自己的地址空间中才能访问,内核地址空间是看不到的。
进而,地址空间的切换不能影响指令的连续执行,这就要求应用和内核地址空间在切换地址空间指令附近是平滑的。
.. _term-meltdown:
.. note::
**内核与应用地址空间的隔离**
目前我们的设计是有一个唯一的内核地址空间存放内核的代码、数据,同时对于每个应用维护一个它们自己的地址空间,因此在
Trap 的时候就需要进行地址空间切换,而在任务切换的时候无需进行(因为这个过程全程在内核内完成)。而教程前两版以及
:math:`\mu` core 中的设计是每个应用都有一个地址空间,可以将其中的逻辑段分为内核和用户两部分,分别映射到内核和
用户的数据和代码,且分别在 CPU 处于 S/U 特权级时访问。此设计中并不存在一个单独的内核地址空间。
之前设计方式的优点在于: Trap 的时候无需切换地址空间,而在任务切换的时候才需要切换地址空间。由于后者比前者更容易
实现,这降低了实现的复杂度。而且在应用高频进行系统调用的时候能够避免地址空间切换的开销,这通常源于快表或 cache
的失效问题。但是这种设计方式也有缺点:即内核的逻辑段需要在每个应用的地址空间内都映射一次,这会带来一些无法忽略的
内存占用开销,并显著限制了嵌入式平台(如我们所采用的 K210 )的任务并发数。此外,这种做法无法应对处理器的 `熔断
(Meltdown) 漏洞 <https://cacm.acm.org/magazines/2020/6/245161-meltdown/fulltext>`_ ,使得恶意应用能够以某种方式看到它本来无权访问的地址空间中内核部分的数据。将内核与地址空间隔离
便是修复此漏洞的一种方法。
经过权衡,在本教程中我们参考 MIT 的教学 OS `xv6 <https://github.com/mit-pdos/xv6-riscv>`_
采用内核和应用地址空间隔离的设计。
我们为何将应用的 Trap 上下文放到应用地址空间的次高页面而不是内核地址空间中的内核栈中呢?原因在于,假如我们将其放在内核栈
中,在保存 Trap 上下文之前我们必须先切换到内核地址空间,这就需要我们将内核地址空间的 token 写入 satp 寄存器,之后我们
还需要有一个通用寄存器保存内核栈栈顶的位置,这样才能以它为基址保存 Trap 上下文。在保存 Trap 上下文之前我们必须完成这
两项工作。然而,我们无法在不破坏任何一个通用寄存器的情况下做到这一点。因为事实上我们需要用到内核的两条信息:内核地址空间
的 token 还有应用内核栈顶的位置,硬件却只提供一个 ``sscratch`` 可以用来进行周转。所以,我们不得不将 Trap 上下文保存在
应用地址空间的一个虚拟页面中以避免切换到内核地址空间才能保存。
为了方便实现,我们在 Trap 上下文中包含更多内容(和我们关于上下文的定义有些不同,它们在初始化之后便只会被读取而不会被写入
,并不是每次都需要保存/恢复):
.. code-block:: rust
:linenos:
:emphasize-lines: 8,9,10
// os/src/trap/context.rs
#[repr(C)]
pub struct TrapContext {
pub x: [usize; 32],
pub sstatus: Sstatus,
pub sepc: usize,
pub kernel_satp: usize,
pub kernel_sp: usize,
pub trap_handler: usize,
}
在多出的三个字段中:
- ``kernel_satp`` 表示内核地址空间的 token
- ``kernel_sp`` 表示当前应用在内核地址空间中的内核栈栈顶的虚拟地址;
- ``trap_handler`` 表示内核中 trap handler 入口点的虚拟地址。
它们在应用初始化的时候由内核写入应用地址空间中的 TrapContext 的相应位置,此后就不再被修改。
让我们来看一下现在的 ``__alltraps````__restore`` 各是如何在保存和恢复 Trap 上下文的同时也切换地址空间的:
.. code-block:: riscv
:linenos:
# os/src/trap/trap.S
.section .text.trampoline
.globl __alltraps
.globl __restore
.align 2
__alltraps:
csrrw sp, sscratch, sp
# now sp->*TrapContext in user space, sscratch->user stack
# save other general purpose registers
sd x1, 1*8(sp)
# skip sp(x2), we will save it later
sd x3, 3*8(sp)
# skip tp(x4), application does not use it
# save x5~x31
.set n, 5
.rept 27
SAVE_GP %n
.set n, n+1
.endr
# we can use t0/t1/t2 freely, because they have been saved in TrapContext
csrr t0, sstatus
csrr t1, sepc
sd t0, 32*8(sp)
sd t1, 33*8(sp)
# read user stack from sscratch and save it in TrapContext
csrr t2, sscratch
sd t2, 2*8(sp)
# load kernel_satp into t0
ld t0, 34*8(sp)
# load trap_handler into t1
ld t1, 36*8(sp)
# move to kernel_sp
ld sp, 35*8(sp)
# switch to kernel space
csrw satp, t0
sfence.vma
# jump to trap_handler
jr t1
__restore:
# a0: *TrapContext in user space(Constant); a1: user space token
# switch to user space
csrw satp, a1
sfence.vma
csrw sscratch, a0
mv sp, a0
# now sp points to TrapContext in user space, start restoring based on it
# restore sstatus/sepc
ld t0, 32*8(sp)
ld t1, 33*8(sp)
csrw sstatus, t0
csrw sepc, t1
# restore general purpose registers except x0/sp/tp
ld x1, 1*8(sp)
ld x3, 3*8(sp)
.set n, 5
.rept 27
LOAD_GP %n
.set n, n+1
.endr
# back to user stack
ld sp, 2*8(sp)
sret
- 当应用 Trap 进入内核的时候,硬件会设置一些 CSR 并在 S 特权级下跳转到 ``__alltraps`` 保存 Trap 上下文。此时
sp 寄存器仍指向用户栈,但 ``sscratch`` 则被设置为指向应用地址空间中存放 Trap 上下文的位置,实际在次高页面。
随后,就像之前一样,我们 ``csrrw`` 交换 sp 和 ``sscratch`` ,并基于指向 Trap 上下文位置的 sp 开始保存通用
寄存器和一些 CSR ,这个过程在第 28 行结束。到这里,我们就全程在应用地址空间中完成了保存 Trap 上下文的工作。
- 接下来该考虑切换到内核地址空间并跳转到 trap handler 了。第 30 行我们将内核地址空间的 token 载入到 t0 寄存器中,
第 32 行我们将 trap handler 入口点的虚拟地址载入到 t1 寄存器中,第 34 行我们直接将 sp 修改为应用内核栈顶的地址。
这三条信息均是内核在初始化该应用的时候就已经设置好的。第 36~37 行我们将 satp 修改为内核地址空间的 token 并使用
``sfence.vma`` 刷新快表,这就切换到了内核地址空间。最后在第 39 行我们通过 ``jr`` 指令跳转到 t1 寄存器所保存的
trap handler 入口点的地址。注意这里我们不能像之前的章节那样直接 ``call trap_handler`` ,原因稍后解释。
- 当内核将 Trap 处理完毕准备返回用户态的时候会 *调用* ``__restore`` ,它有两个参数:第一个是 Trap 上下文在应用
地址空间中的位置,这个对于所有的应用来说都是相同的,由调用规范在 a0 寄存器中传递;第二个则是即将回到的应用的地址空间
的 token ,在 a1 寄存器中传递。由于 Trap 上下文是保存在应用地址空间中的,第 44~45 行我们先切换回应用地址空间。第
46 行我们将传入的 Trap 上下文位置保存在 ``sscratch`` 寄存器中,这样 ``__alltraps`` 中才能基于它将 Trap 上下文
保存到正确的位置。第 47 行我们将 sp 修改为 Trap 上下文的位置,后面基于它恢复各通用寄存器和 CSR。最后在第 64 行,
我们通过 ``sret`` 指令返回用户态。
接下来还需要考虑切换地址空间前后指令能否仍能连续执行。可以看到我们将 ``trap.S`` 中的整段汇编代码放置在
``.text.trampoline`` 段,并在调整内存布局的时候将它对齐到代码段的一个页面中:
.. code-block:: diff
:linenos:
# os/src/linker.ld
stext = .;
.text : {
*(.text.entry)
+ . = ALIGN(4K);
+ strampoline = .;
+ *(.text.trampoline);
+ . = ALIGN(4K);
*(.text .text.*)
}
这样,这段汇编代码放在一个物理页帧中,且 ``__alltraps`` 恰好位于这个物理页帧的开头,其物理地址被外部符号
``strampoline`` 标记。在开启分页模式之后,内核和应用代码都只能看到各自的虚拟地址空间,而在它们的视角中,这段汇编代码
被放在它们地址空间的最高虚拟页面上,由于这段汇编代码在执行的时候涉及到地址空间切换,故而被称为跳板页面。
那么在产生trap前后的一小段时间内会有一个比较 **极端** 的情况即刚产生trap时CPU已经进入了内核态即Supervisor Mode但此时执行代码和访问数据还是在应用程序所处的用户态虚拟地址空间中而不是我们通常理解的内核虚拟地址空间。在这段特殊的时间内CPU指令
为什么能够被连续执行呢?这里需要注意:无论是内核还是应用的地址空间,跳板的虚拟页均位于同样位置,且它们也将会映射到同一个实际存放这段
汇编代码的物理页帧。也就是说,在执行 ``__alltraps````__restore`` 函数进行地址空间切换的时候,应用的用户态虚拟地址空间和操作系统内核的内核态虚拟地址空间对切换地址空间的指令所在页的映射方式均是相同的,这就说明了这段切换地址空间的指令控制流仍是可以连续执行的。
现在可以说明我们在创建用户/内核地址空间中用到的 ``map_trampoline`` 是如何实现的了:
.. code-block:: rust
:linenos:
// os/src/config.rs
pub const TRAMPOLINE: usize = usize::MAX - PAGE_SIZE + 1;
// os/src/mm/memory_set.rs
impl MemorySet {
/// Mention that trampoline is not collected by areas.
fn map_trampoline(&mut self) {
self.page_table.map(
VirtAddr::from(TRAMPOLINE).into(),
PhysAddr::from(strampoline as usize).into(),
PTEFlags::R | PTEFlags::X,
);
}
}
这里我们为了实现方便并没有新增逻辑段 ``MemoryArea`` 而是直接在多级页表中插入一个从地址空间的最高虚拟页面映射到
跳板汇编代码所在的物理页帧的键值对,访问方式限制与代码段相同,即 RX 。
最后可以解释为何我们在 ``__alltraps`` 中需要借助寄存器 ``jr`` 而不能直接 ``call trap_handler`` 了。因为在
内存布局中,这条 ``.text.trampoline`` 段中的跳转指令和 ``trap_handler`` 都在代码段之内汇编器Assembler和链接器Linker会根据 ``linker-qemu/k210.ld`` 的地址布局描述,设定电子指令的地址,并计算二者地址偏移量
并让跳转指令的实际效果为当前 pc 自增这个偏移量。但实际上我们知道由于我们设计的缘故,这条跳转指令在被执行的时候,
它的虚拟地址被操作系统内核设置在地址空间中的最高页面之内,加上这个偏移量并不能正确的得到 ``trap_handler`` 的入口地址。
**问题的本质可以概括为:跳转指令实际被执行时的虚拟地址和在编译器/汇编器/链接器进行后端代码生成和链接形成最终机器码时设置此指令的地址是不同的。**
加载和执行应用程序
------------------------------------
扩展任务控制块
^^^^^^^^^^^^^^^^^^^^^^^^^^^
为了让应用在运行时有一个安全隔离且符合编译器给应用设定的地址空间布局的虚拟地址空间,操作系统需要对任务进行更多的管理,所以任务控制块相比第三章也包含了更多内容:
.. code-block:: rust
:linenos:
:emphasize-lines: 6,7,8
// os/src/task/task.rs
pub struct TaskControlBlock {
pub task_cx_ptr: usize,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
}
除了应用的地址空间 ``memory_set`` 之外,还有位于应用地址空间次高页的 Trap 上下文被实际存放在物理页帧的物理页号
``trap_cx_ppn`` ,它能够方便我们对于 Trap 上下文进行访问。此外, ``base_size`` 统计了应用数据的大小,也就是
在应用地址空间中从 :math:`\text{0x0}` 开始到用户栈结束一共包含多少字节。它后续还应该包含用于应用动态内存分配的
堆空间的大小,但我们暂不支持。
更新对任务控制块的管理
^^^^^^^^^^^^^^^^^^^^^^^^^^^
下面是任务控制块的创建:
.. code-block:: rust
:linenos:
// os/src/config.rs
/// Return (bottom, top) of a kernel stack in kernel space.
pub fn kernel_stack_position(app_id: usize) -> (usize, usize) {
let top = TRAMPOLINE - app_id * (KERNEL_STACK_SIZE + PAGE_SIZE);
let bottom = top - KERNEL_STACK_SIZE;
(bottom, top)
}
// os/src/task/task.rs
impl TaskControlBlock {
pub fn new(elf_data: &[u8], app_id: usize) -> Self {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
let task_status = TaskStatus::Ready;
// map a kernel-stack in kernel space
let (kernel_stack_bottom, kernel_stack_top) = kernel_stack_position(app_id);
KERNEL_SPACE
.lock()
.insert_framed_area(
kernel_stack_bottom.into(),
kernel_stack_top.into(),
MapPermission::R | MapPermission::W,
);
let task_cx_ptr = (kernel_stack_top - core::mem::size_of::<TaskContext>())
as *mut TaskContext;
unsafe { *task_cx_ptr = TaskContext::goto_trap_return(); }
let task_control_block = Self {
task_cx_ptr: task_cx_ptr as usize,
task_status,
memory_set,
trap_cx_ppn,
base_size: user_sp,
};
// prepare TrapContext in user space
let trap_cx = task_control_block.get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.lock().token(),
kernel_stack_top,
trap_handler as usize,
);
task_control_block
}
}
- 第 15 行,我们解析传入的 ELF 格式数据构造应用的地址空间 ``memory_set`` 并获得其他信息;
- 第 16 行,我们从地址空间 ``memory_set`` 中查多级页表找到应用地址空间中的 Trap 上下文实际被放在哪个物理页帧;
- 第 22 行,我们根据传入的应用 ID ``app_id`` 调用在 ``config`` 子模块中定义的 ``kernel_stack_position`` 找到
应用的内核栈预计放在内核地址空间 ``KERNEL_SPACE`` 中的哪个位置,并通过 ``insert_framed_area`` 实际将这个逻辑段
加入到内核地址空间中;
.. _trap-return-intro:
- 第 30~32 行,我们在应用的内核栈顶压入一个跳转到 ``trap_return`` 而不是 ``__restore`` 的任务上下文,这主要是为了能够支持对该应用的启动并顺利切换到用户地址空间执行。在构造方式上,只是将 ra 寄存器的值设置为 ``trap_return`` 的地址。 ``trap_return`` 是我们后面要介绍的
新版的 Trap 处理的一部分。
这里我们对裸指针解引用成立的原因在于:我们之前已经进入了内核地址空间,而我们要操作的内核栈也是在内核地址空间中的;
- 第 33 行开始我们用上面的信息来创建任务控制块实例 ``task_control_block``
- 第 41 行我们需要初始化该应用的 Trap 上下文,由于它是在应用地址空间而不是在内核地址空间中,我们只能手动查页表找到
Trap 上下文实际被放在的物理页帧,然后通过之前介绍的 :ref:`在内核地址空间读写特定物理页帧的能力 <access-frame-in-kernel-as>`
获得在用户空间的 Trap 上下文的可变引用用于初始化:
.. code-block:: rust
// os/src/task/task.rs
impl TaskControlBlock {
pub fn get_trap_cx(&self) -> &'static mut TrapContext {
self.trap_cx_ppn.get_mut()
}
}
此处需要说明的是,返回 ``'static`` 的可变引用和之前一样可以看成一个绕过 unsafe 的裸指针;而 ``PhysPageNum::get_mut``
是一个泛型函数,由于我们已经声明了总体返回 ``TrapContext`` 的可变引用则Rust编译器会给 ``get_mut`` 泛型函数针对具体类型 ``TrapContext``
的情况生成一个特定版本的 ``get_mut`` 函数实现。在 ``get_trap_cx`` 函数中则会静态调用``get_mut`` 泛型函数的特定版本实现。
- 第 42 行我们正式通过 Trap 上下文的可变引用来对其进行初始化:
.. code-block:: rust
:linenos:
:emphasize-lines: 8,9,10,18,19,20
// os/src/trap/context.rs
impl TrapContext {
pub fn set_sp(&mut self, sp: usize) { self.x[2] = sp; }
pub fn app_init_context(
entry: usize,
sp: usize,
kernel_satp: usize,
kernel_sp: usize,
trap_handler: usize,
) -> Self {
let mut sstatus = sstatus::read();
sstatus.set_spp(SPP::User);
let mut cx = Self {
x: [0; 32],
sstatus,
sepc: entry,
kernel_satp,
kernel_sp,
trap_handler,
};
cx.set_sp(sp);
cx
}
}
和之前相比 ``TrapContext::app_init_context`` 需要补充上让应用在 ``__alltraps`` 能够顺利进入到内核地址空间
并跳转到 trap handler 入口点的相关信息。
在内核初始化的时候,需要将所有的应用加载到全局应用管理器中:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
struct TaskManagerInner {
tasks: Vec<TaskControlBlock>,
current_task: usize,
}
lazy_static! {
pub static ref TASK_MANAGER: TaskManager = {
println!("init TASK_MANAGER");
let num_app = get_num_app();
println!("num_app = {}", num_app);
let mut tasks: Vec<TaskControlBlock> = Vec::new();
for i in 0..num_app {
tasks.push(TaskControlBlock::new(
get_app_data(i),
i,
));
}
TaskManager {
num_app,
inner: RefCell::new(TaskManagerInner {
tasks,
current_task: 0,
}),
}
};
}
可以看到,在 ``TaskManagerInner`` 中我们使用向量 ``Vec`` 来保存任务控制块。在全局任务管理器 ``TASK_MANAGER``
初始化的时候,只需使用 ``loader`` 子模块提供的 ``get_num_app````get_app_data`` 分别获取链接到内核的应用
数量和每个应用的 ELF 文件格式的数据,然后依次给每个应用创建任务控制块并加入到向量中即可。我们还将 ``current_task`` 设置
为 0 ,于是将从第 0 个应用开始执行。
回过头来介绍一下应用构建器 ``os/build.rs`` 的改动:
- 首先,我们在 ``.incbin`` 中不再插入清除全部符号的应用二进制镜像 ``*.bin`` ,而是将构建得到的 ELF 格式文件直接链接进来;
- 其次,在链接每个 ELF 格式文件之前我们都加入一行 ``.align 3`` 来确保它们对齐到 8 字节,这是由于如果不这样做, ``xmas-elf`` crate 可能会在解析 ELF 的时候进行不对齐的内存读写,例如使用 ``ld`` 指令从内存的一个没有对齐到 8 字节的地址加载一个 64 位的值到一个通用寄存器。而在 k210 平台上,由于其硬件限制,这会触发一个内存读写不对齐的异常,导致解析无法正常完成。
为了方便后续的实现,全局任务管理器还需要提供关于当前应用与地址空间有关的一些信息:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
impl TaskManager {
fn get_current_token(&self) -> usize {
let inner = self.inner.borrow();
let current = inner.current_task;
inner.tasks[current].get_user_token()
}
fn get_current_trap_cx(&self) -> &mut TrapContext {
let inner = self.inner.borrow();
let current = inner.current_task;
inner.tasks[current].get_trap_cx()
}
}
pub fn current_user_token() -> usize {
TASK_MANAGER.get_current_token()
}
pub fn current_trap_cx() -> &'static mut TrapContext {
TASK_MANAGER.get_current_trap_cx()
}
通过 ``current_user_token````current_trap_cx`` 分别可以获得当前正在执行的应用的地址空间的 token 和可以在
内核地址空间中修改位于该应用地址空间中的 Trap 上下文的可变引用。
改进 Trap 处理的实现
------------------------------------
为了能够支持地址空间,让我们来看现在 ``trap_handler`` 的改进实现:
.. code-block:: rust
:linenos:
// os/src/trap/mod.rs
fn set_kernel_trap_entry() {
unsafe {
stvec::write(trap_from_kernel as usize, TrapMode::Direct);
}
}
#[no_mangle]
pub fn trap_from_kernel() -> ! {
panic!("a trap from kernel!");
}
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let cx = current_trap_cx();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
...
}
trap_return();
}
由于应用的 Trap 上下文不在内核地址空间,因此我们调用 ``current_trap_cx`` 来获取当前应用的 Trap 上下文的可变引用
而不是像之前那样作为参数传入 ``trap_handler`` 。至于 Trap 处理的过程则没有发生什么变化。
注意到,在 ``trap_handler`` 的开头还调用 ``set_kernel_trap_entry````stvec`` 修改为同模块下另一个函数
``trap_from_kernel`` 的地址。这就是说,一旦进入内核后再次触发到 S 的 Trap则会在硬件设置一些 CSR 之后跳过寄存器
的保存过程直接跳转到 ``trap_from_kernel`` 函数,在这里我们直接 ``panic`` 退出。这是因为内核和应用的地址空间分离
之后,从 U 还是从 S Trap 到 S 的 Trap 上下文保存与恢复实现方式和 Trap 处理逻辑有很大差别,我们不得不实现两遍而
不太可能将二者整合起来。这里简单起见我们弱化了从 S 到 S 的 Trap ,省略了 Trap 上下文保存过程而直接 ``panic``
``trap_handler`` 完成 Trap 处理之后,我们需要调用 ``trap_return`` 返回用户态:
.. code-block:: rust
:linenos:
// os/src/trap/mod.rs
fn set_user_trap_entry() {
unsafe {
stvec::write(TRAMPOLINE as usize, TrapMode::Direct);
}
}
#[no_mangle]
pub fn trap_return() -> ! {
set_user_trap_entry();
let trap_cx_ptr = TRAP_CONTEXT;
let user_satp = current_user_token();
extern "C" {
fn __alltraps();
fn __restore();
}
let restore_va = __restore as usize - __alltraps as usize + TRAMPOLINE;
unsafe {
llvm_asm!("fence.i" :::: "volatile");
llvm_asm!("jr $0"
:: "r"(restore_va), "{a0}"(trap_cx_ptr), "{a1}"(user_satp)
:: "volatile"
);
}
panic!("Unreachable in back_to_user!");
}
- 第 11 行,在 ``trap_return`` 的开头我们调用 ``set_user_trap_entry`` 来让应用 Trap 到 S 的时候可以跳转到
``__alltraps`` 。注意我们把 ``stvec`` 设置为内核和应用地址空间共享的跳板页面的起始地址 ``TRAMPOLINE`` 而不是
编译器在链接时看到的 ``__alltraps`` 的地址,因为启用分页模式之后我们只能通过跳板页面上的虚拟地址来实际取得
``__alltraps````__restore`` 的汇编代码。
- 之前介绍的时候提到过 ``__restore`` 需要两个参数:分别是 Trap 上下文在应用地址空间中的虚拟地址和要继续执行的应用
地址空间的 token 。第 12 和第 13 行则分别准备好这两个参数。
- 最后我们需要跳转到 ``__restore`` 切换到应用地址空间从 Trap 上下文中恢复通用寄存器并 ``sret`` 继续执行应用。它的
关键在于如何找到 ``__restore`` 在内核/应用地址空间中共同的虚拟地址。第 18 行我们展示了计算它的过程:由于
``__alltraps`` 是对齐到地址空间跳板页面的起始地址 ``TRAMPOLINE`` 上的, 则 ``__restore`` 的虚拟地址只需在
``TRAMPOLINE`` 基础上加上 ``__restore`` 相对于 ``__alltraps`` 的偏移量即可。这里 ``__alltraps``
``__restore`` 都是指编译器在链接时看到的内核内存布局中的地址。在第 21 行我们使用 ``jr`` 指令完成了跳转的任务。
- 在开始执行应用之前,第 20 行我们需要使用 ``fence.i`` 指令清空指令缓存 i-cache 。这是因为,在内核中进行的一些操作
可能导致一些原先存放某个应用代码的物理页帧如今用来存放数据或者是其他应用的代码i-cache 中可能还保存着该物理页帧的
错误快照。因此我们直接将整个 i-cache 清空避免错误。
当每个应用第一次获得 CPU 使用权即将进入用户态执行的时候,它的内核栈顶放置着我们在
:ref:`内核加载应用的时候 <trap-return-intro>` 构造的一个任务上下文:
.. code-block:: rust
// os/src/task/context.rs
impl TaskContext {
pub fn goto_trap_return() -> Self {
Self {
ra: trap_return as usize,
s: [0; 12],
}
}
}
``__switch`` 切换到它的时候,这将会跳转到 ``trap_return`` 并第一次返回用户态。
改进 sys_write 的实现
------------------------------------
同样由于内核和应用地址空间的隔离, ``sys_write`` 不再能够直接访问位于应用空间中的数据,而需要手动查页表才能知道那些
数据被放置在哪些物理页帧上并进行访问。
为此,页表模块 ``page_table`` 提供了将应用地址空间中一个缓冲区转化为在内核空间中能够直接访问的形式的辅助函数:
.. code-block:: rust
:linenos:
// os/src/mm/page_table.rs
pub fn translated_byte_buffer(
token: usize,
ptr: *const u8,
len: usize
) -> Vec<&'static [u8]> {
let page_table = PageTable::from_token(token);
let mut start = ptr as usize;
let end = start + len;
let mut v = Vec::new();
while start < end {
let start_va = VirtAddr::from(start);
let mut vpn = start_va.floor();
let ppn = page_table
.translate(vpn)
.unwrap()
.ppn();
vpn.step();
let mut end_va: VirtAddr = vpn.into();
end_va = end_va.min(VirtAddr::from(end));
v.push(&ppn.get_bytes_array()[start_va.page_offset()..end_va.page_offset()]);
start = end_va.into();
}
v
}
参数中的 ``token`` 是某个应用地址空间的 token ``ptr````len`` 则分别表示该地址空间中的一段缓冲区的起始地址
和长度。 ``translated_byte_buffer`` 会以向量的形式返回一组可以在内核空间中直接访问的字节数组切片,具体实现在这里
不再赘述。
进而,我们完成对 ``sys_write`` 系统调用的改造:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
match fd {
FD_STDOUT => {
let buffers = translated_byte_buffer(current_user_token(), buf, len);
for buffer in buffers {
print!("{}", core::str::from_utf8(buffer).unwrap());
}
len as isize
},
_ => {
panic!("Unsupported fd in sys_write!");
}
}
}
我们尝试将每个字节数组切片转化为字符串 ``&str`` 然后输出即可。
小结
-------------------------------------
这一章内容很多,讲解了 **地址空间** 这一抽象概念是如何在一个具体的“头甲龙”操作系统中实现的。这里面的核心内容是如何建立基于页表机制的虚拟地址空间。为此,操作系统需要知道并管理整个系统中的物理内存;需要建立虚拟地址到物理地址映射关系的页表;并基于页表给操作系统自身和每个应用提供一个虚拟地址空间;并需要对管理应用的任务控制块进行扩展,确保能对应用的地址空间进行管理;由于应用和内核的地址空间是隔离的,需要有一个跳板来帮助完成应用与内核之间的切换执行;并导致了对异常、中断、系统调用的相应更改。这一系列的改进,最终的效果是编写应用更加简单了,且应用的执行或错误不会影响到内核和其他应用的正常工作。为了得到这些好处,我们需要比较费劲地进化我们的操作系统。如果同学结合阅读代码,编译并运行应用+内核,读懂了上面的文档,那完成本章的实验就有了一个坚实的基础。
如果同学能想明白如何插入/删除页表;如何在 ``trap_handler`` 下处理 ``LoadPageFault`` ;以及 ``sys_get_time`` 在使能页机制下如何实现,那就会发现下一节的实验练习也许 **就和lab1一样**

View File

@ -0,0 +1,115 @@
chapter4练习
============================================
- 本节难度: **看懂代码就和lab1一样**
编程作业
---------------------------------------------
申请内存
++++++++++++++++++++++++++++++++++++++++++++
你有没有想过,当你在 C 语言中写下的 ``new int[100];`` 执行时可能会发生哪些事情?你可能已经发现,目前我们给用户程序的内存都是固定的并没有增长的能力,这些程序是不能执行 ``new`` 这类导致内存使用增加的操作。libc 中通过 `sbrk <https://linux.die.net/man/2/sbrk>`_ 系统调用增加进程可使用的堆空间这也是本来的题目设计但是一位热心的往年助教J学长表示这一点也不酷他推荐了另一个申请内存的系统调用。
`mmap <https://man7.org/linux/man-pages/man2/mmap.2.html>`_ 本身主要使用来在内存中映射文件的,这里我们简化它的功能,仅仅用来提供申请内存的功能。
mmap 系统调用新定义:
- syscall ID222
- C接口 ``int mmap(void* start, unsigned long long len, int port)``
- Rust接口 ``fn mmap(start: usize, len: usize, port: usize) -> i32``
- 功能:申请长度为 len 字节的物理内存(不要求实际物理内存位置,可以随便找一块),并映射到 addr 开始的虚存,内存页属性为 port。
- 参数:
- start需要映射的虚存起始地址。
- len映射字节长度可以为 0 (如果是则直接返回),不可过大(上限 1GiB )。
- port第 0 位表示是否可读,第 1 位表示是否可写,第 2 位表示是否可执行。其他位无效(必须为 0 )。
- 说明:
- 正确时返回实际 map size为 4096 的倍数),错误返回 -1 。
- 为了简单addr 要求按页对齐(否则报错)len 可直接按页上取整。
- 为了简单,不考虑分配失败时的页回收(也就是内存泄漏)。
- 错误:
- [addr, addr + len) 存在已经被映射的页。
- 物理内存不足。
- port & !0x7 != 0 (port 其余位必须为0)。
- port & 0x7 = 0 (这样的内存无意义)。
munmap 系统调用新定义:
- syscall ID215
- C接口 ``int munmap(void* start, unsigned long long len)``
- Rust接口 ``fn munmap(start: usize, len: usize) -> i32``
- 功能:取消一块虚存的映射。
- 参数:同 mmap
- 说明:
- 为了简单,参数错误时不考虑内存的恢复和回收。
- 错误:
- [start, start + len) 中存在未被映射的虚存。
实验要求
++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch4。
- 完成实验指导书中的内容实现虚拟内存可以运行过去几个lab的程序。
- 更新 sys_write 的范围检查,改为基于页表的检查方法。
- 实现 mmap 和 munmap 两个自定义系统调用,并通过 `Rust测例 <https://github.com/DeathWish5/rCore_tutorial_tests>`_ 中 chapter4 对应的所有测例,测例详情见对应仓库,系统调用具体要求参考 `guide.md <https://github.com/DeathWish5/rCore_tutorial_tests/blob/master/guide.md>`_ 中chapter4对应的所有测例。
注意:记得删除 lab3 关于程序时间片上界的规定。
challenge: 支持多核。
实验检查
+++++++++++++++++++++++++++++++++++++++++++++
- 实验目录要求
目录要求不变(参考 lab1 目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。
加载的用户测例位置: ``../user/build/bin``
- 检查
可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。
问答作业
-------------------------------------------------
1. 请列举 SV39 页表页表项的组成,结合课堂内容,描述其中的标志位有何作用/潜在作用?
2. 缺页
这次的实验没有涉及到缺页有点遗憾,主要是缺页难以测试,而且更多的是一种优化,不符合这次实验的核心理念,所以这里补两道小题。
缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断,告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程。
- 请问哪些异常可能是缺页导致的?
- 发生缺页时描述相关的重要寄存器的值lab2中描述过的可以简单点
缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。
- 这样做有哪些好处?
此外 COW(Copy On Write) 也是常见的容易导致缺页的 Lazy 策略,这个之后再说。其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。
- 请问处理 10G 连续的内存页面,需要操作的页表实际大致占用多少内存(给出数量级即可)
- 请简单思考如何才能在现有框架基础上实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。
缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。
- 此时页面失效如何表现在页表项(PTE)上?
3. 双页表与单页表
为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 `KPTI <https://en.wikipedia.org/wiki/Kernel_page-table_isolation>`_ )
- 如何更换页表?
- 单页表情况下如何控制用户态无法访问内核页面tips:看看上一题最后一问)
- 单页表有何优势?(回答合理即可)
- 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?
报告要求
--------------------------------------------------------
* 简单总结本次实验与上个实验相比你增加的东西。控制在5行以内不要贴代码
* 完成问答问题。
* (optional) 你对本次实验设计及难度的看法。

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

14
source/chapter4/index.rst Normal file
View File

@ -0,0 +1,14 @@
第四章:地址空间
==============================================
.. toctree::
:maxdepth: 4
0intro
1rust-dynamic-allocation
2address-space
3sv39-implementation-1
4sv39-implementation-2
5kernel-app-spaces
6multitasking-based-on-as
7exercise

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
source/chapter4/pte-rwx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
source/chapter4/satp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Some files were not shown because too many files have changed in this diff Show More