add os[1-8]-ref for os refereces, add guide, add README

This commit is contained in:
Yu Chen
2022-06-27 22:22:44 +08:00
parent 7c1679774c
commit d752a67137
360 changed files with 32863 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.vscode
.idea
Cargo.lock
target
@@ -6,4 +7,4 @@ os/last-*
user
ci-user/
tools
workplace/
workplace/

View File

@@ -2,8 +2,19 @@ DOCKER_NAME ?= dinghao188/rcore-tutorial
DIR := workplace
.PHONY: docker build_docker
test: test3 test4 test5 test6 test7 test8
lab1: test3
lab2: test4
lab3: test5
lab4: test6 test7
lab5: test8
setup:
rm -rf ${DIR}
mkdir ${DIR}

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# rust-based os comp 2022
## Guide
- Guide deployed version can be found [here](https://LearningOS.github.io/rCore-Tutorial-Guide-2022S/).
- Source of Guide is in 'guide' DIR
## os reference framework
The 'os[1-8]-ref' are the 'os[1-8]' reference framework. You can read and copy some codes into os[1-8]
## kernel labs
There are five kernel labs.
According to the [Guide](https://LearningOS.github.io/rCore-Tutorial-Guide-2022S/), write os codes for:
- lab1 in 'os3' DIR
- lab2 in 'os4' DIR
- lab3 in 'os5' DIR
- lab4 in 'os6' DIR
- lab5 in 'os8' DIR
## Check your results
- lab1: `make test3`
- lab2 `make test4`
- lab3 `make test5`
- lab4 `make test6` and `make test7`
- lab5 `make test8`

5
guide/.gitignore vendored Normal file
View File

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

674
guide/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>.

23
guide/Makefile Normal file
View File

@@ -0,0 +1,23 @@
# 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

2
guide/all.sh Executable file
View File

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

35
guide/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

28
guide/requirements.txt Normal file
View File

@@ -0,0 +1,28 @@
alabaster==0.7.12
Babel==2.9.1
certifi==2021.5.30
charset-normalizer==2.0.4
docutils==0.16
idna==3.2
imagesize==1.2.0
jieba==0.42.1
Jinja2==3.0.1
MarkupSafe==2.0.1
packaging==21.0
Pygments==2.10.0
pyparsing==2.4.7
pytz==2021.1
requests==2.26.0
snowballstemmer==2.1.0
Sphinx==4.1.2
sphinx-comments==0.0.3
sphinx-rtd-theme==0.5.2
sphinx-tabs==3.2.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
urllib3==1.26.6
furo==2021.8.31

1
guide/show.sh Executable file
View File

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

View File

@@ -0,0 +1,267 @@
第零章:实验环境配置
================================
.. toctree::
:hidden:
:maxdepth: 4
本节我们将完成环境配置并成功运行 rCore-Tutorial 。整个流程分为下面几个部分:
- OS 环境配置
- Rust 开发环境配置
- Qemu 模拟器安装
- 其他工具安装
- 试运行 rCore-Tutorial
如果你在环境配置中遇到了无法解决的问题,请在本节讨论区留言,我们会尽力提供帮助。
OS 环境配置
-------------------------------
目前,实验主要支持 Ubuntu18.04/20.04 操作系统。使用 Windows10 和 macOS 的读者,可以安装一台 Ubuntu18.04 虚拟机或 Docker
进行实验。
Windows10 用户可以通过系统内置的 **WSL2** 虚拟机(请不要使用 WSL1来安装 Ubuntu 18.04 / 20.04 。读者请自行在互联网上搜索相关安装教程,或 `适用于 Linux 的 Windows 子系统安装指南 (Windows 10) <https://docs.microsoft.com/zh-cn/windows/wsl/install-win10#step-4---download-the-linux-kernel-update-package>`_
.. note::
**Docker 开发环境**
感谢 dinghao188 和张汉东老师帮忙配置好的 Docker 开发环境,进入 Docker 开发环境之后不需要任何软件工具链的安装和配置,可以直接将 tutorial 运行起来,目前应该仅支持将 tutorial 运行在 Qemu 模拟器上。
使用方法如下(以 Ubuntu18.04 为例):
1. 通过 ``su`` 切换到管理员账户 ``root``
2.``rCore-Tutorial`` 根目录下 ``make docker`` 进入到 Docker 环境;
3. 进入 Docker 之后,会发现当前处于根目录 ``/`` ,我们通过 ``cd mnt`` 将当前工作路径切换到 ``/mnt`` 目录;
4. 通过 ``ls`` 可以发现 ``/mnt`` 目录下的内容和 ``rCore-Tutorial-v3`` 目录下的内容完全相同,接下来就可以在这个环境下运行 tutorial 了。例如 ``cd os && make run``
使用 macOS 进行实验理论上也是可行的,但本章节仅介绍 Ubuntu 下的环境配置方案。
.. note::
经初步测试,使用 M1 芯片的 macOS 也可以运行本实验的框架,即我们的实验对平台的要求不是很高。但我们仍建议同学配置 Ubuntu 环境,以避免未知的环境问题。
Rust 开发环境配置
-------------------------------------------
首先安装 Rust 版本管理器 rustup 和 Rust 包管理器 cargo可以使用官方安装脚本
.. code-block:: bash
curl https://sh.rustup.rs -sSf | sh
如果因网络问题通过命令行下载脚本失败了,可以在浏览器地址栏中输入 `<https://sh.rustup.rs>`_ 将脚本下载到本地运行。或者使用字节跳动提供的镜像源。
建议将 rustup 的镜像地址修改为中科大的镜像服务器,以加速安装:
.. code-block:: bash
export RUSTUP_DIST_SERVER=https://mirrors.ustc.edu.cn/rust-static
export RUSTUP_UPDATE_ROOT=https://mirrors.ustc.edu.cn/rust-static/rustup
curl https://sh.rustup.rs -sSf | sh
或者使用 tuna 源来加速(建议清华同学在校园网中使用) `参见 rustup 帮助 <https://mirrors.tuna.tsinghua.edu.cn/help/rustup/>`_
.. code-block:: bash
export RUSTUP_DIST_SERVER=https://mirrors.tuna.edu.cn/rustup
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.edu.cn/rustup/rustup
curl https://sh.rustup.rs -sSf | sh
也可以设置科学上网代理:
.. code-block:: bash
# e.g. Shadowsocks 代理,请根据自身配置灵活调整下面的链接
export https_proxy=http://127.0.0.1:1080
export http_proxy=http://127.0.0.1:1080
export ftp_proxy=http://127.0.0.1:1080
安装中全程选择默认选项即可。
安装完成后,我们可以重新打开一个终端来让新设置的环境变量生效,也可以手动将环境变量设置应用到当前终端,
只需输入以下命令:
.. code-block:: bash
source $HOME/.cargo/env
确认一下我们正确安装了 Rust 工具链:
.. code-block:: bash
rustc --version
最好把 Rust 包管理器 cargo 镜像地址 crates.io 也替换成中国科学技术大学的镜像服务器,来加速三方库的下载。
打开或新建 ``~/.cargo/config`` 文件,并把内容修改为:
.. code-block:: toml
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"
同样也可以使用tuna源 `参见 crates.io 帮助 <https://mirrors.tuna.tsinghua.edu.cn/help/crates.io-index.git/>`_
.. code-block:: toml
[source.crates-io]
replace-with = 'tuna'
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
推荐 JetBrains Clion + Rust插件 或者 Visual Studio Code 搭配 rust-analyzer 和 RISC-V Support 插件 进行代码阅读和开发。
.. note::
* JetBrains Clion是付费商业软件但对于学生和教师只要在 JetBrains 网站注册账号,可以享受一定期限(半年左右)的免费使用的福利。
* Visual Studio Code 是开源软件。
* 当然,采用 VIMEmacs 等传统的编辑器也是没有问题的。
Qemu 模拟器安装
----------------------------------------
我们需要使用 Qemu 5.0.0 版本进行实验,为此,从源码手动编译安装 Qemu 模拟器:
.. attention::
也可以使用 Qemu6但要小心潜在的不兼容问题
.. 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>`_
找到。
GCC 11 可能无法正常编译 Qemu5 ,而 GCC 9.3.0 (Ubuntu 20.04 自带) 及 GCC 10.3.0 经测试可以编译,请自行选择合适的编译器版本。
之后我们可以在同目录下 ``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
试运行 rCore-Tutorial
------------------------------------------------------------
.. code-block:: bash
git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S
cd rCore-Tutorial-Code-2022S
我们先运行不需要处理用户代码的 ch1 分支:
.. code-block:: bash
git checkout ch1
cd os
LOG=DEBUG make run
如果你的环境配置正确,你应当会看到如下输出:
.. code-block:: bash
[rustsbi] RustSBI version 0.2.0-alpha.4
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation: RustSBI-QEMU Version 0.0.1
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: ssoft, stimer, sext (0x222)
[rustsbi] medeleg: ima, ia, bkpt, la, sa, uecall, ipage, lpage, spage (0xb1ab)
[rustsbi] pmp0: 0x80000000 ..= 0x800fffff (rwx)
[rustsbi] pmp1: 0x80000000 ..= 0x807fffff (rwx)
[rustsbi] pmp2: 0x0 ..= 0xffffffffffffff (---)
[rustsbi] enter supervisor 0x80200000
Hello, world!
[DEBUG] .rodata [0x80203000, 0x80205000)
[ INFO] .data [0x80205000, 0x80206000)
[ WARN] boot_stack [0x80206000, 0x80216000)
[ERROR] .bss [0x80216000, 0x80217000)
Panicked at src/main.rs:48 Shutdown machine!
通常 rCore 会自动关闭 Qemu 。如果在某些情况下需要强制结束,可以先按下 ``Ctrl+A`` ,再按下 ``X`` 来退出 Qemu。
.. attention::
请务必执行 ``make run``,这将为你安装一些上文没有提及的 Rust 包依赖。
如果卡在了
.. code-block::
Updating git repository `https://github.com/rcore-os/riscv`
请通过更换 hosts 等方式解决科学上网问题,或者将 riscv 项目下载到本地,并修改 os/Cargo.toml 中的 riscv 包依赖路径
.. code-block::
[dependencies]
riscv = { path = "YOUR riscv PATH", features = ["inline-asm"] }
恭喜你完成了实验环境的配置,可以开始阅读教程的正文部分了!
GDB 调试支持*
------------------------------
.. attention::
使用 GDB debug 并不是必须的,你可以暂时跳过本小节。
``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`` 等。

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/>`_

View File

@@ -0,0 +1,318 @@
附录 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
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
qemu
^^^^^^^^^^^^^^^^^^^^^^^
注意其中高亮部分给出了传给 qemu 的参数。
- ``-machine`` 告诉 qemu 使用预设的硬件配置。在整个项目中我们将一直沿用该配置。
- ``-bios`` 告诉 qemu 使用我们放在 ``bootloader`` 目录下的预编译版本作为 bootloader。
- ``-device`` 则告诉 qemu 将二进制镜像加载到内存指定的位置。
可以先输入 Ctrl+A ,再输入 X 来退出 qemu 终端。
.. warning::
**FIXME使用 GDB 跟踪 qemu 的运行状态**
其他工具和文件格式说明的参考
-------------------------------------------------------
- `链接脚本(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,32 @@
附录 DRISC-V相关信息
=================================================
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>`_
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,96 @@
引言
=====================
本章导读
--------------------------
大多数程序员的职业生涯都从 ``Hello, world!`` 开始。
.. code-block::
printf("Hello world!\n");
cout << "Hello world!\n";
print("Hello world!")
System.out.println("Hello world!");
echo "Hello world!"
println!("Hello world!");
然而,要用几行代码向世界问好,并不像表面上那么简单。
``Hello, world!`` 程序能够编译运行,靠的是以 **编译器** 为主的开发环境和以 **操作系统** 为主的执行环境。
在本章中,我们将抽丝剥茧,一步步让 ``Hello, world!`` 程序脱离其依赖的执行环境,
编写一个能打印 ``Hello, world!`` 的 OS。这趟旅途将让我们对应用程序及其执行环境有更深入的理解。
.. attention::
实验指导书存在的目的是帮助读者理解框架代码。
为便于测试,完成编程实验时,请以框架代码为基础,不必跟着文档从零开始编写内核。
为了做到这一步,首先需要让程序不依赖于标准库,
并通过编译。
接下来要让脱离了标准库的程序能输出(即支持 ``println!``),这对程序的开发和调试至关重要。
我们先在用户态下实现该功能,在 `此处 <https://github.com/LearningOS/rCore-Tutorial-Book-2021Autumn/tree/ch2-U-nostd>`_ 获取相关代码。
最后把程序移植到内核态,构建在裸机上支持输出的最小运行时环境。
实践体验
---------------------------
本章一步步实现了支持打印字符串的简单操作系统。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch1
运行本章代码,并设置日志级别为 ``TRACE``
.. code-block:: console
$ cd os
$ make run LOG=TRACE
预期输出:
.. figure:: color-demo.png
:align: center
除了 ``Hello, world!`` 之外还有一些额外的信息,最后关机。
本章代码树
------------------------------------------------
.. code-block::
├── bootloader (内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│   └── rustsbi-qemu.bin
├── os
│   ├── Cargo.toml (cargo 项目配置文件)
│   ├── Makefile
│   └── src
│   ├── console.rs (将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│   ├── entry.asm (设置内核执行环境的的一段汇编代码)
│   ├── lang_items.rs (需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│   ├── linker.ld (控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│   ├── logging.rs (为本项目实现了日志功能)
│   ├── main.rs (内核主函数)
│   └── sbi.rs (封装底层 SBI 实现提供的 SBI 接口)
└── rust-toolchain (整个项目的工具链版本)
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 5 25 6 155
make 1 11 4 34
Assembly 1 1 0 11
TOML 1 2 1 7
-------------------------------------------------------------------------------
SUM: 8 39 11 207
-------------------------------------------------------------------------------

View File

@@ -0,0 +1,120 @@
应用程序执行环境与平台支持
================================================
.. toctree::
:hidden:
:maxdepth: 5
执行应用程序
-------------------------------
我们先从最简单的 Rust ``Hello, world`` 程序开始,用 Cargo 工具创建 Rust 项目。
.. code-block:: console
$ cargo new os
此时,项目的文件结构如下:
.. code-block:: console
$ tree os
os
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
其中 ``Cargo.toml`` 中保存了项目的库依赖、作者信息等。
cargo 为我们准备好了 ``Hello world!`` 源代码:
.. code-block:: rust
:linenos:
:caption: 最简单的 Rust 应用
fn main() {
println!("Hello, world!");
}
输入 ``cargo run`` 构建并运行项目:
.. code-block:: console
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!`` ,但为了打印出 ``Hello, world!``,我们需要的不止几行源代码。
理解应用程序执行环境
-------------------------------
在现代通用操作系统(如 Linux上运行应用程序需要多层次的执行环境栈支持
.. figure:: app-software-stack.png
:align: center
应用程序执行环境栈:图中的白色块自上而下表示各级执行环境,黑色块则表示相邻两层执行环境之间的接口。
下层作为上层的执行环境,支持上层代码运行。
我们的应用程序通过调用标准库或第三方库提供的接口,仅需少量源代码就能完成复杂的功能;
``Hello, world!`` 程序调用的 ``println!`` 宏就是由 Rust 标准库 std 和 GNU Libc 等提供的。
这些库属于应用程序的 **执行环境** (Execution Environment),而它们的实现又依赖于操作系统提供的系统调用。
平台与目标三元组
---------------------------------------
编译器在编译、链接得到可执行文件时需要知道,程序要在哪个 **平台** (Platform) 上运行,
**目标三元组** (Target Triplet) 描述了目标平台的 CPU 指令集、操作系统类型和标准运行时库。
我们研究一下现在 ``Hello, world!`` 程序的目标三元组是什么:
.. code-block:: console
$ rustc --version --verbose
rustc 1.61.0-nightly (68369a041 2022-02-22)
binary: rustc
commit-hash: 68369a041cea809a87e5bd80701da90e0e0a4799
commit-date: 2022-02-22
host: x86_64-unknown-linux-gnu
release: 1.61.0-nightly
LLVM version: 14.0.0
其中 host 一项表明默认目标平台是 ``x86_64-unknown-linux-gnu``
CPU 架构是 x86_64CPU 厂商是 unknown操作系统是 linux运行时库是 gnu libc。
接下来,我们希望把 ``Hello, world!`` 移植到 RICV 目标平台 ``riscv64gc-unknown-none-elf`` 上运行。
.. note::
``riscv64gc-unknown-none-elf`` 的 CPU 架构是 riscv64gc厂商是 unknown操作系统是 none
elf 表示没有标准的运行时库。没有任何系统调用的封装支持,但可以生成 ELF 格式的执行程序。
我们不选择有 linux-gnu 支持的 ``riscv64gc-unknown-linux-gnu``,是因为我们的目标是开发操作系统内核,而非在 linux 系统上运行的应用程序。
修改目标平台
----------------------------------
将程序的目标平台换成 ``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
报错的原因是目标平台上确实没有 Rust 标准库 std也不存在任何受 OS 支持的系统调用。
这样的平台被我们称为 **裸机平台** (bare-metal)。
幸运的是,除了 std 之外Rust 还有一个不需要任何操作系统支持的核心库 core
它包含了 Rust 语言相当一部分核心机制,可以满足本门课程的需求。
有很多第三方库也不依赖标准库 std而仅仅依赖核心库 core。
为了以裸机平台为目标编译程序,我们要将对标准库 std 的引用换成核心库 core。

View File

@@ -0,0 +1,158 @@
.. _term-remove-std:
移除标准库依赖
==========================
.. toctree::
:hidden:
:maxdepth: 5
首先在 ``os`` 目录下新建 ``.cargo`` 目录,并在这个目录下创建 ``config`` 文件,输入如下内容:
.. code-block:: toml
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
这将使 cargo 工具在 os 目录下默认会使用 riscv64gc-unknown-none-elf 作为目标平台。
这种编译器运行的平台x86_64与可执行文件运行的目标平台不同的情况称为 **交叉编译** (Cross Compile)。
移除 println! 宏
----------------------------------
我们在 ``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 的系统调用。
无论如何,我们先将这行代码注释掉。
提供语义项 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
标准库 std 提供了 Rust 错误处理函数 ``#[panic_handler]``,其大致功能是打印出错位置和原因并杀死当前应用。
但核心库 core 并没有提供这项功能,得靠我们自己实现。
新建一个子模块 ``lang_items.rs``,在里面编写 panic 处理函数,通过标记 ``#[panic_handler]`` 告知编译器采用我们的实现:
.. code-block:: rust
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
目前我们遇到错误什么都不做,只在原地 ``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`` 的语义项。
``start`` 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
``main.rs`` 的开头加入设置 ``#![no_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 {}
}
分析被移除标准库的程序
-----------------------------
我们可以通过一些工具来分析目前的程序:
.. 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``
再通过 ``rust-objdump`` 工具把它反汇编,没有生成任何汇编代码。
可见,这个二进制程序虽然合法,但它是一个空程序,原因是缺少了编译器规定的入口函数 ``_start``
从下一节开始,我们将着手实现本节移除的、由用户态执行环境提供的功能。
.. note::
本节内容部分参考自 `BlogOS 的相关章节 <https://os.phil-opp.com/freestanding-rust-binary/>`_

View File

@@ -0,0 +1,282 @@
.. _term-print-userminienv:
构建用户态执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
.. note::
前三小节的用户态程序案例代码在 `此处 <https://github.com/LearningOS/rCore-Tutorial-Book-2021Autumn/tree/ch2-U-nostd>`_ 获取。
用户态最小化执行环境
----------------------------
执行环境初始化
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先我们要给 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
[反汇编导出汇编程序]
$ 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>
反汇编出的两条指令就是一个死循环,
这说明编译器生成的已经是一个合理的程序了。
``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
看起来是合法的执行程序。但如果我们执行它,会引发问题:
.. code-block:: console
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os
段错误 (核心已转储)
这个简单的程序导致 ``qemu-riscv64`` 崩溃了!为什么会这样?
.. note::
QEMU有两种运行模式
``User mode`` 模式,即用户态模拟,如 ``qemu-riscv64`` 程序,
能够模拟不同处理器的用户态指令的执行并可以直接解析ELF可执行文件
加载运行那些为不同处理器编译的用户级Linux应用程序。
``System mode`` 模式,即系统态模式,如 ``qemu-system-riscv64`` 程序,
能够模拟一个完整的基于不同CPU的硬件系统包括处理器、内存及其他外部设备支持运行完整的操作系统。
目前的执行环境还缺了一个退出机制,我们需要操作系统提供的 ``exit`` 系统调用来退出程序。这里先给出代码:
.. code-block:: rust
// os/src/main.rs
const SYSCALL_EXIT: usize = 93;
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret;
unsafe {
core::arch::asm!(
"ecall",
inlateout("x10") args[0] => ret,
in("x11") args[1],
in("x12") args[2],
in("x17") id,
);
}
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`` 函数,
向操作系统发出了退出的系统调用请求,退出码为 ``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
[打印程序的返回值]
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $?
9
可以看到,返回的结果确实是 ``9`` 。这样,我们勉强完成了一个简陋的用户态最小化执行环境。
有显示支持的用户态执行环境
----------------------------
没有 ``println`` 输出信息,终究觉得缺了点啥。
Rust 的 core 库内建了以一系列帮助实现显示字符的基本 Trait 和数据结构,函数等,我们可以对其中的关键部分进行扩展,就可以实现定制的 ``println!`` 功能。
实现输出字符串的相关函数
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. attention::
如果你觉得理解 Rust 宏有困难,把它当成黑盒就好!
首先封装一下对 ``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)+)?));
}
}
接下来,我们调整一下应用程序,让它发出显示字符串和退出的请求:
.. 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,328 @@
.. _term-print-kernelminienv:
构建裸机执行环境
=================================
.. toctree::
:hidden:
:maxdepth: 5
有了上一节实现的用户态的最小执行环境,稍加改造,就可以完成裸机上的最小执行环境了。
本节中,我们将把 ``Hello world!`` 应用程序从用户态搬到内核态。
裸机启动过程
----------------------------
用 QEMU 软件 ``qemu-system-riscv64`` 来模拟 RISC-V 64 计算机。加载内核程序的命令如下:
.. code-block:: bash
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
- ``-bios $(BOOTLOADER)`` 意味着硬件加载了一个 BootLoader 程序,即 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`` 的位置,这里有固化在硬件中的一小段引导代码,
它会很快跳转到 ``0x80000000`` 的 RustSBI 处。
RustSBI完成硬件初始化后会跳转到 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80200000`` 处,
执行操作系统的第一条指令。
.. figure:: chap1-intro.png
:align: center
.. note::
**RustSBI 是什么?**
SBI 是 RISC-V 的一种底层规范RustSBI 是它的一种实现。
操作系统内核与 RustSBI 的关系有点像应用与操作系统内核的关系后者向前者提供一定的服务。只是SBI提供的服务很少
比如关机,显示字符串等。
实现关机功能
----------------------------
对上一节实现的代码稍作调整,通过 ``ecall`` 调用 RustSBI 实现关机功能:
.. _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 {
core::arch::asm!(
"ecall",
...
const SBI_SHUTDOWN: usize = 8;
pub fn shutdown() -> ! {
sbi_call(SBI_SHUTDOWN, 0, 0, 0);
panic!("It should shutdown!");
}
// os/src/main.rs
#[no_mangle]
extern "C" fn _start() {
shutdown();
}
应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问
RustSBI提供的SBI调用的指令也是 ``ecall``
虽然指令一样,但它们所在的特权级是不一样的。
简单地说应用程序位于最弱的用户特权级User Mode
操作系统位于内核特权级Supervisor Mode
RustSBI位于机器特权级Machine Mode
下一章会进一步阐释具体细节。
编译执行,结果如下:
.. code-block:: bash
# 编译生成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
# 无法退出,风扇狂转,感觉碰到死循环
问题在哪?通过 rust-readobj 分析 ``os`` 可执行程序,发现其入口地址不是
RustSBI 约定的 ``0x80200000`` 。我们需要修改程序的内存布局并设置好栈空间。
设置正确的程序内存布局
----------------------------
可以通过 **链接脚本** (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`` RustSBI 期望的 OS 起始地址;
.. attention::
linker 脚本的语法不做要求,感兴趣的同学可以自行查阅相关资料。
``BASE_ADDRESS`` 开始,代码段 ``.text``, 只读数据段 ``.rodata``,数据段 ``.data``, bss 段 ``.bss`` 由低到高依次放置,
且每个段都有两个全局变量给出其起始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext````etext`` )。
正确配置栈空间布局
----------------------------
用另一段汇编代码初始化栈空间:
.. 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`` ,链接脚本里有它的位置。
``_start`` 作为操作系统的入口地址,将依据链接脚本被放在 ``BASE_ADDRESS`` 处。
``la sp, boot_stack_top`` 作为 OS 的第一条指令,
将 sp 设置为栈空间的栈顶。
简单起见,我们目前不考虑 sp 越过栈底 ``boot_stack`` ,也就是栈溢出的情形。
第二条指令则是函数调用 ``rust_main`` ,这里的 ``rust_main`` 是我们稍后自己编写的应用入口。
接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main``
.. code-block:: rust
:linenos:
:emphasize-lines: 7,9,10,11,12
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_items;
core::arch::global_asm!(include_str!("entry.asm"));
#[no_mangle]
pub fn rust_main() -> ! {
shutdown();
}
背景高亮指出了 ``main.rs`` 中新增的代码。
第 7 行,我们使用 ``global_asm`` 宏,将同目录下的汇编文件 ``entry.asm`` 嵌入到代码中。
从第 9 行开始,
我们声明了应用的入口点 ``rust_main`` ,需要注意的是,这里通过宏将 ``rust_main``
标记为 ``#[no_mangle]`` 以避免编译器对它的名字进行混淆,不然在链接时,
``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main``,导致链接失败。
再次使用上节中的编译生成和运行操作我们看到QEMU模拟的RISC-V 64计算机 **优雅** 地退出了!
.. code-block:: console
# 教程使用的 RustSBI 版本比代码框架稍旧,输出有所不同
$ 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 段** 的工作我们还没有完成。
.. 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) }
});
}
pub fn rust_main() -> ! {
clear_bss();
shutdown();
}
链接脚本 ``linker.ld`` 中给出的全局符号 ``sbss````ebss`` 让我们能轻松确定 ``.bss`` 段的位置。
添加裸机打印相关函数
----------------------------------
在上一节中我们为用户态程序实现的 ``println`` 宏,略作修改即可用于本节的内核态操作系统。
详见 ``os/src/console.rs``
利用 ``println`` 宏,我们重写异常处理函数 ``panic``,使其在 panic 时能打印错误发生的位置。
相关代码位于 ``os/src/lang_items.rs`` 中。
我们还使用第三方库 ``log`` 为你实现了日志模块,相关代码位于 ``os/src/logging.rs`` 中。
.. note::
在 cargo 项目中引入外部库 log需要修改 ``Cargo.toml`` 加入相应的依赖信息。
现在,让我们重复一遍本章开头的试验,``make run LOG=TRACE``
.. figure:: color-demo.png
:align: center
产生 panic 的地点与源码中的实际位置一致!至此,我们完成了第一章的实验内容,
.. note::
背景知识:`理解应用程序和执行环境 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter1/4understand-prog.html>`_

View File

@@ -0,0 +1,143 @@
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``。
实验检查
+++++++++++++++++++++++++++++++
.. - 实验目录要求(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`` 后一段内存,输出之后关机。
问答作业
-------------------------------
.. 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``: 单步执行一条汇编指令。
报告要求
-------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

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

View File

@@ -0,0 +1,149 @@
引言
================================
本章导读
---------------------------------
**批处理系统** (Batch System) 出现于计算资源匮乏的年代,其核心思想是:
将多个程序打包到一起输入计算机;当一个程序运行结束后,计算机会 *自动* 执行下一个程序。
应用程序难免会出错,如果一个程序的错误导致整个操作系统都无法运行,那就太糟糕了。
*保护* 操作系统不受出错程序破坏的机制被称为 **特权级** (Privilege) 机制,
它实现了用户态和内核态的隔离。
本章在上一章的基础上,让我们的 OS 内核能以批处理的形式一次运行多个应用程序,同时利用特权级机制,
令 OS 不因出错的用户态程序而崩溃。
本章首先为批处理操作系统设计用户程序,再阐述如何将这些应用程序链接到内核中,最后介绍如何利用特权级机制处理 Trap.
实践体验
---------------------------
本章我们引入了用户程序。为了将内核与应用解耦,我们将二者分成了两个仓库,分别是存放内核程序的 ``rCore-Tutorial-Code-20xxx`` (下称代码仓库,最后几位 x 表示学期)与存放用户程序的 ``rCore-Tutorial-Test-20xxx`` (下称测例仓库)。 你首先需要进入代码仓库文件夹并 clone 用户程序仓库(如果已经执行过该步骤则不需要再重复执行):
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch2
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
上面的指令会将测例仓库克隆到代码仓库下并命名为 ``user`` ,注意 ``/user`` 在代码仓库的 ``.gitignore`` 文件中,因此不会出现 ``.git`` 文件夹嵌套的问题,并且你在代码仓库进行 checkout 操作时也不会影响测例仓库的内容。
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run LOG=INFO
批处理系统自动加载并运行了所有的用户程序,尽管某些程序出错了:
.. code-block::
[rustsbi] RustSBI version 0.2.0-alpha.4
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation: RustSBI-QEMU Version 0.0.1
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: ssoft, stimer, sext (0x222)
[rustsbi] medeleg: ima, ia, bkpt, la, sa, uecall, ipage, lpage, spage (0xb1ab)
[rustsbi] pmp0: 0x80000000 ..= 0x800fffff (rwx)
[rustsbi] pmp1: 0x80000000 ..= 0x807fffff (rwx)
[rustsbi] pmp2: 0x0 ..= 0xffffffffffffff (---)
[rustsbi] enter supervisor 0x80200000
[kernel] Hello, world!
[ INFO] [kernel] num_app = 6
[ INFO] [kernel] app_0 [0x8020b040, 0x8020f868)
[ INFO] [kernel] app_1 [0x8020f868, 0x80214090)
[ INFO] [kernel] app_2 [0x80214090, 0x80218988)
[ INFO] [kernel] app_3 [0x80218988, 0x8021d160)
[ INFO] [kernel] app_4 [0x8021d160, 0x80221a68)
[ INFO] [kernel] app_5 [0x80221a68, 0x80226538)
[ INFO] [kernel] Loading app_0
[ERROR] [kernel] PageFault in application, core dumped.
[ INFO] [kernel] Loading app_1
[ERROR] [kernel] IllegalInstruction in application, core dumped.
[ INFO] [kernel] Loading app_2
[ERROR] [kernel] IllegalInstruction in application, core dumped.
[ INFO] [kernel] Loading app_3
[ INFO] [kernel] Application exited with code 1234
[ INFO] [kernel] Loading app_4
Hello, world from user mode program!
[ INFO] [kernel] Application exited with code 0
[ INFO] [kernel] Loading app_5
3^10000=5079(MOD 10007)
3^20000=8202(MOD 10007)
3^30000=8824(MOD 10007)
3^40000=5750(MOD 10007)
3^50000=3824(MOD 10007)
3^60000=8516(MOD 10007)
3^70000=2510(MOD 10007)
3^80000=9379(MOD 10007)
3^90000=2621(MOD 10007)
3^100000=2749(MOD 10007)
Test power OK!
[ INFO] [kernel] Application exited with code 0
Panicked at src/batch.rs:68 All applications completed!
本章代码树
-------------------------------------------------
.. code-block::
── os
│   ├── Cargo.toml
│   ├── Makefile (修改:构建内核之前先构建应用)
│   ├── build.rs (新增:生成 link_app.S 将应用作为一个数据段链接到内核)
│   └── src
│   ├── batch.rs(新增:实现了一个简单的批处理系统)
│   ├── console.rs
│   ├── entry.asm
│   ├── lang_items.rs
│   ├── link_app.S(构建产物,由 os/build.rs 输出)
│   ├── linker.ld
│   ├── logging.rs
│   ├── main.rs(修改:主函数中需要初始化 Trap 处理并加载和执行应用)
│   ├── sbi.rs
│   ├── sync(新增包装了RefCell暂时不用关心)
│   │   ├── mod.rs
│   │   └── up.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 上下文保存与恢复的汇编代码)
└── user(新增:应用测例保存在 user 目录下)
├── Cargo.toml
├── Makefile
└── src
├── bin(基于用户库 user_lib 开发的应用,每个应用放在一个源文件中)
│   ├── ...
├── console.rs
├── lang_items.rs
├── lib.rs(用户库 user_lib)
├── linker.ld(应用的链接脚本)
└── syscall.rs(包含 syscall 方法生成实际用于系统调用的汇编指令,
各个具体的 syscall 都是通过 syscall 来实现的)
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 14 62 21 435
Assembly 3 9 16 106
make 1 12 4 36
TOML 1 2 1 9
-------------------------------------------------------------------------------
SUM: 19 85 42 586
-------------------------------------------------------------------------------

View File

@@ -0,0 +1,214 @@
实现应用程序
===========================
.. toctree::
:hidden:
:maxdepth: 5
.. note::
拓展阅读:`RISC-V 特权级机制 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter2/1rv-privilege.html>`_
应用程序设计
-----------------------------
.. attention::
用户库看起来很复杂,它预留了直到 ch7 内核才能实现的系统调用接口console 模块还实现了输出缓存区。它们不是为本章准备的,你只需关注本节提到的部分即可。
应用程序、用户库包括入口函数、初始化函数、I/O函数和系统调用接口等多个rs文件组成放在项目根目录的 ``user`` 目录下:
- user/src/bin/*.rs各个应用程序
- user/src/*.rs用户库包括入口函数、初始化函数、I/O函数和系统调用接口等
- user/src/linker.ld应用程序的内存布局说明
项目结构
^^^^^^^^^^^^^^^^^^^^^^
``user/src/bin`` 里面有多个文件,其中三个是:
- ``hello_world``:在屏幕上打印一行 ``Hello, world!``
- ``bad_address``:访问一个非法的物理地址,测试批处理系统是否会被该错误影响
- ``power``:不断在计算操作和打印字符串操作之间切换
批处理系统会按照文件名顺序加载并运行它们。
每个应用程序的实现都在对应的单个文件中。打开 ``hello_world.rs``,能看到一个 ``main`` 函数,还有外部库引用:
.. code-block:: rust
#[macro_use]
extern crate user_lib;
这个外部库其实就是 ``user`` 目录下的 ``lib.rs`` 以及它引用的若干子模块。
``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());
}
第 2 行使用 ``link_section`` 宏将 ``_start`` 函数编译后的汇编代码放在名为 ``.text.entry`` 的代码段中,
方便用户库链接脚本将它作为用户程序的入口。
而从第 4 行开始,我们手动清零 ``.bss`` 段,然后调用 ``main`` 函数得到一个类型为 ``i32`` 的返回值,
最后,调用用户库提供的 ``exit`` 接口退出,并将返回值告知批处理系统。
我们在 ``lib.rs`` 中看到了另一个 ``main``
.. code-block:: rust
:linenos:
#![feature(linkage)] // 启用弱链接特性
#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
panic!("Cannot find main!");
}
我们使用 Rust 宏将其标志为弱链接。这样在最后链接的时候,
虽然 ``lib.rs````bin`` 目录下的某个应用程序中都有 ``main`` 符号,
但由于 ``lib.rs`` 中的 ``main`` 符号是弱链接,
链接器会使用 ``bin`` 目录下的函数作为 ``main``
如果在 ``bin`` 目录下找不到任何 ``main`` ,那么编译也能通过,但会在运行时报错。
内存布局
^^^^^^^^^^^^^^^^^^^^^^
我们使用链接脚本 ``user/src/linker.ld`` 规定用户程序的内存布局:
- 将程序的起始物理地址调整为 ``0x80400000`` ,三个应用程序都会被加载到这个物理地址上运行;
-``_start`` 所在的 ``.text.entry`` 放在整个程序的开头 ``0x80400000``
批处理系统在加载应用后,跳转到 ``0x80400000``,就进入了用户库的 ``_start`` 函数;
- 提供了最终生成可执行文件的 ``.bss`` 段的起始和终止地址,方便 ``clear_bss`` 函数使用。
其余的部分和第一章基本相同。
系统调用
^^^^^^^^^^^^^^^^^^^^^^
在子模块 ``syscall`` 中我们来通过 ``ecall`` 调用批处理系统提供的接口,
由于应用程序运行在用户态(即 U 模式), ``ecall`` 指令会触发名为 ``Environment call from U-mode`` 的异常,
并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务程序。
这个接口被称为 ABI 或者系统调用。
现在我们不关心 S 态的批处理系统如何提供应用程序所需的功能,只考虑如何使用它。
在本章中,应用程序和批处理系统约定如下两个系统调用:
.. 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) -> !;
实际调用时,我们要按照 RISC-V 调用规范,在合适的寄存器中放置参数,
然后执行 ``ecall`` 指令触发 Trap。当 Trap 结束,回到 U 模式后,
用户程序会从 ``ecall`` 的下一条指令继续执行,同时在合适的寄存器中读取返回值。
.. note::
RISC-V 寄存器编号从 ``0~31`` ,表示为 ``x0~x31`` 。 其中:
- ``x10~x17`` : 对应 ``a0~a7``
- ``x1`` :对应 ``ra``
约定寄存器 ``a0~a6`` 保存系统调用的参数, ``a0`` 保存系统调用的返回值,
寄存器 ``a7`` 用来传递 syscall ID。
这超出了 Rust 语言的表达能力,我们需要内嵌汇编来完成参数/返回值绑定和 ``ecall`` 指令的插入:
.. code-block:: rust
:linenos:
// user/src/syscall.rs
fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret: isize;
unsafe {
core::arch::asm!(
"ecall",
inlateout("x10") args[0] => ret,
in("x11") args[1],
in("x12") args[2],
in("x17") id
);
}
ret
}
第 3 行,我们将所有的系统调用都封装成 ``syscall`` 函数,可以看到它支持传入 syscall ID 和 3 个参数。
第 6 行开始,我们使用 Rust 提供的 ``asm!`` 宏在代码中内嵌汇编。
Rust 编译器无法判定汇编代码的安全性,所以我们需要将其包裹在 unsafe 块中。
简而言之,这条汇编代码的执行结果是以寄存器 ``a0~a2`` 来保存系统调用的参数,以及寄存器 ``a7`` 保存 syscall ID
返回值通过寄存器 ``a0`` 传递给局部变量 ``ret``
这段汇编代码与第一章中出现过的内嵌汇编很像,读者可以查看 ``os/src/sbi.rs``
.. note::
可以查看 `Inline assembly <https://doc.rust-lang.org/nightly/reference/inline-assembly.html>`_ 了解 ``asm`` 宏。
于是 ``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])
}
我们将上述两个系统调用在用户库 ``user_lib`` 中进一步封装,像标准库一样:
.. 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`` 子模块中,借助 ``write``,我们为应用程序实现了 ``println!`` 宏。
传入到 ``write````fd`` 参数设置为 1代表标准输出 STDOUT暂时不用考虑其他的 ``fd`` 选取情况。
编译生成应用程序二进制码
-------------------------------
简要介绍一下应用程序的构建,在 ``user`` 目录下 ``make build``
1. 对于 ``src/bin`` 下的每个应用程序,
``target/riscv64gc-unknown-none-elf/release`` 目录下生成一个同名的 ELF 可执行文件;
2. 使用 objcopy 二进制工具删除所有 ELF header 和符号,得到 ``.bin`` 后缀的纯二进制镜像文件。
它们将被链接进内核,并由内核在合适的时机加载到内存。

View File

@@ -0,0 +1,168 @@
.. _term-batchos:
实现批处理操作系统
==============================
.. toctree::
:hidden:
:maxdepth: 5
将应用程序链接到内核
--------------------------------------------
在本章中,我们要把应用程序的二进制镜像文件作为数据段链接到内核里,
内核需要知道应用程序的数量和它们的位置。
``os/src/main.rs`` 中能够找到这样一行:
.. code-block:: rust
core::arch::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/hello_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/bad_address.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/power.bin"
app_2_end:
第 13 行开始的三个数据段分别插入了三个应用程序的二进制镜像,
并且各自有一对全局符号 ``app_*_start, app_*_end`` 指示它们的开始和结束位置。
而第 3 行开始的另一个数据段相当于一个 64 位整数数组。
数组中的第一个元素表示应用程序的数量,后面则按照顺序放置每个应用程序的起始地址,
最后一个元素放置最后一个应用程序的结束位置。这样数组中相邻两个元素记录了每个应用程序的始末位置,
这个数组所在的位置由全局符号 ``_num_app`` 所指示。
这个文件是在 ``cargo build`` 时,由脚本 ``os/build.rs`` 控制生成的。
找到并加载应用程序二进制码
-----------------------------------------------
我们在 ``os````batch`` 子模块中实现一个应用管理器 ``AppManager`` ,结构体定义如下:
.. code-block:: rust
struct AppManager {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
初始化 ``AppManager`` 的全局实例:
.. code-block:: rust
lazy_static! {
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe {
UPSafeCell::new({
extern "C" {
fn _num_app();
}
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] =
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManager {
num_app,
current_app: 0,
app_start,
}
})
};
}
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。
用容器 ``UPSafeCell`` 包裹 ``AppManager`` 是为了防止全局对象 ``APP_MANAGER`` 被重复获取。
.. note::
``UPSafeCell`` 实现在 ``sync`` 模块中,调用 ``exclusive_access`` 方法能获取其内部对象的可变引用,
如果程序运行中同时存在多个这样的引用,会触发 ``already borrowed: BorrowMutError``
``UPSafeCell`` 既提供了内部可变性,又在单核情境下防止了内部对象被重复借用,我们将在后文中多次见到它。
这里使用了外部库 ``lazy_static`` 提供的 ``lazy_static!`` 宏。
``lazy_static!`` 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置初始值,
但是有些全局变量的初始化依赖于运行期间才能得到的数据。
如这里我们借助 ``lazy_static!`` 声明了一个 ``AppManager`` 结构的名为 ``APP_MANAGER`` 的全局实例,
只有在它第一次被使用到的时候才会进行实际的初始化工作。
``AppManager`` 的方法中, ``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!");
}
info!("[kernel] Loading app_{}", app_id);
// clear icache
core::arch::asm!("fence.i");
// clear app area
core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, APP_SIZE_LIMIT).fill(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`` 起始的位置,
这个位置是批处理操作系统和应用程序之间约定的常数地址。
我们将从这里开始的一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置。
清空内存前,我们插入了一条奇怪的汇编指令 ``fence.i`` ,它是用来清理 i-cache 的。
我们知道, 缓存又分成 **数据缓存** (d-cache) 和 **指令缓存** (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。
通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。
但在这里,我们会修改会被 CPU 取指的内存区域,使得 i-cache 中含有与内存不一致的内容,
必须使用 ``fence.i`` 指令手动清空 i-cache ,让里面所有的内容全部失效,
才能够保证程序执行正确性。
.. warning::
**模拟器与真机的不同之处**
在 Qemu 模拟器上,即使不加刷新 i-cache 的指令,大概率也能正常运行,但在物理计算机上不是这样。
``batch`` 子模块对外暴露出如下接口:
- ``init`` :调用 ``print_app_info`` 的时第一次用到了全局变量 ``APP_MANAGER`` ,它在这时完成初始化;
- ``run_next_app`` :批处理操作系统的核心操作,即加载并运行下一个应用程序。
批处理操作系统完成初始化,或者应用程序运行结束/出错后会调用该函数。下节再介绍其具体实现。

View File

@@ -0,0 +1,519 @@
.. _term-trap-handle:
实现特权级的切换
===========================
.. toctree::
:hidden:
:maxdepth: 5
RISC-V特权级切换
---------------------------------------
特权级切换的起因
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序前进行一些初始化工作,
并监控应用程序的执行,具体体现在:
- 启动应用程序时,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
- 应用程序发起系统调用后,需要切换到批处理操作系统中进行处理;
- 应用程序执行出错时,批处理操作系统要杀死该应用并加载运行下一个应用;
- 应用程序执行结束时,批处理操作系统要加载运行下一个应用。
这些处理都涉及到特权级切换,因此都需要硬件和操作系统协同提供的特权级切换机制。
特权级切换相关的控制状态寄存器
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
本章中我们仅考虑当 CPU 在 U 特权级运行用户程序的时候触发 Trap
并切换到 S 特权级的批处理操作系统进行处理。
.. 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 处理代码的入口地址
特权级切换的具体过程一部分由硬件直接完成,另一部分则需要由操作系统来实现。
.. _trap-hw-mechanism:
特权级切换的硬件控制机制
-------------------------------------
当 CPU 执行完一条指令并准备从用户特权级 陷入( ``Trap`` )到 S 特权级的时候,硬件会自动完成如下这些事情:
- ``sstatus````SPP`` 字段会被修改为 CPU 当前的特权级U/S
- ``sepc`` 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
- ``scause/stval`` 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 ``stvec`` 所设置的 Trap 处理入口地址,并将当前特权级设置为 S 然后从Trap 处理入口地址处开始执行。
.. 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 模式,
而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 ``sret`` 来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照 ``sstatus````SPP`` 字段设置为 U 或者 S
- CPU 会跳转到 ``sepc`` 寄存器指向的那条指令,然后继续执行。
用户栈与内核栈
--------------------------------
在 Trap 触发的一瞬间, CPU 会切换到 S 特权级并跳转到 ``stvec`` 所指示的位置。
但是在正式进入 S 特权级的 Trap 处理之前,我们必须保存原控制流的寄存器状态,这一般通过栈来完成。
但我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。
我们声明两个类型 ``KernelStack````UserStack`` 分别表示用户栈和内核栈,它们都只是字节数组的简单包装:
.. code-block:: rust
:linenos:
// os/src/batch.rs
#[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],
};
两个栈以全局变量的形式实例化在批处理操作系统的 ``.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 上下文,即在 Trap 发生时需要保存的物理资源内容,定义如下:
.. 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 上下文的保存与恢复
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先是具体实现 Trap 上下文保存和恢复的汇编代码。
.. _trap-context-save-restore:
在批处理操作系统初始化时,我们需要修改 ``stvec`` 寄存器来指向正确的 Trap 处理入口点。
.. code-block:: rust
:linenos:
// os/src/trap/mod.rs
core::arch::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`` 标记为函数,并通过 ``global_asm!`` 宏将 ``trap.S`` 这段汇编代码插入进来。
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 特权级规范的要求;
- 第 9 行的 ``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),因为它在第 9 行
后指向的是内核栈。用户栈的栈指针保存在 sscratch 中,必须通过 ``csrr`` 指令读到通用寄存器中后才能使用,因此我们先考虑保存其它通用寄存器,腾出空间。
我们要基于 sp 来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存 Trap 上下文的地址区间为 :math:`[\text{sp},\text{sp}+8\times34)`
按照 ``TrapContext`` 结构体的内存布局基于内核栈的位置sp所指地址来从低地址到高地址分别按顺序放置 x0~x31这些通用寄存器最后是 sstatus 和 sepc 。因此通用寄存器 xn
应该被保存在地址区间 :math:`[\text{sp}+8n,\text{sp}+8(n+1))`
为了简化代码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 并保存到内核栈上,注意: sscratch 的值是进入 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 特权级继续运行应用程序控制流。
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 。
- 第 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!``
宏打印出来。这里我们并没有检查传入参数的安全性,存在安全隐患。
- ``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:
// os/src/batch.rs
pub fn run_next_app() -> ! {
let mut app_manager = APP_MANAGER.exclusive_access();
let current_app = app_manager.get_current_app();
unsafe {
app_manager.load_app(current_app);
}
app_manager.move_to_next_app();
drop(app_manager);
// before this we have to drop local variables related to resources manually
// and release the resources
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!");
}
``__restore`` 所做的事情是在内核栈上压入一个 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,139 @@
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
.. 可以正确执行正确执行目标用户测例,并得到预期输出(详见测例注释)。
简答题
-------------------------------
.. 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`` 的方法?简单描述你的想法。
报告要求
-------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

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

View File

@@ -0,0 +1,209 @@
引言
========================================
本章导读
--------------------------
本章的目标是实现分时多任务系统,它能并发地执行多个用户程序,并调度这些程序。为此需要实现
- 一次性加载所有用户程序,减少任务切换开销;
- 支持任务切换机制,保存切换前后程序上下文;
- 支持程序主动放弃处理器,实现 yield 系统调用;
- 以时间片轮转算法调度用户程序,实现资源的时分复用。
实践体验
-------------------------------------
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch3
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
运行代码,看到用户程序交替输出信息:
.. code-block::
[rustsbi] RustSBI version 0.2.0-alpha.4
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation: RustSBI-QEMU Version 0.0.1
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: ssoft, stimer, sext (0x222)
[rustsbi] medeleg: ima, ia, bkpt, la, sa, uecall, ipage, lpage, spage (0xb1ab)
[rustsbi] pmp0: 0x80000000 ..= 0x800fffff (rwx)
[rustsbi] pmp1: 0x80000000 ..= 0x807fffff (rwx)
[rustsbi] pmp2: 0x0 ..= 0xffffffffffffff (---)
[rustsbi] enter supervisor 0x80200000
[kernel] Hello, world!
power_3 [10000/200000]
power_3 [20000/200000]
power_3 [30000/200000]
power_3 [40000/200000]
power_3 [50000/200000]
power_3 [60000/200000]
power_3 [70000/200000]
power_3 [80000/200000]
power_3 [90000/200000]
power_3 [100000/200000]
power_3 [110000/200000]
power_3 [120000/200000]
power_3 [130000/200000]
power_3 [140000/200000]
power_3 [150000/200000]
power_3 [160000/200000]
power_3 [170000/200000]
power_3 [180000/200000]
power_3 [190000/200000]
power_3 [200000/200000]
3^200000 = 871008973(MOD 998244353)
Test power_3 OK!
power_5 [10000/140000]
power_5 [20000/140000]
power_5 [30000/140000]
power_5 [40000/140000]
power_5 [50000/140000]
power_5 [60000/140000]
power_7 [10000/160000]
power_7 [20000/160000]
power_7 [30000/160000]
power_7 [40000/160000]
power_7 [50000/160000]
power_7 [60000/160000]
power_7 [70000/160000]
power_7 [80000/160000]
power_7 [90000/160000]
power_7 [100000/160000]
power_7 [110000/160000]
power_7 [120000/160000]
power_7 [130000/160000]
power_7 [140000/160000]
power_7 [150000/160000]
power_7 [160000/160000]
7^160000 = 667897727(MOD 998244353)
Test power_7 OK!
get_time OK! 42
current time_msec = 42
AAAAAAAAAA [1/5]
BBBBBBBBBB [1/5]
CCCCCCCCCC [1/5]
power_5 [70000/140000]
AAAAAAAAAA [2/5]
BBBBBBBBBB [2/5]
CCCCCCCCCC [2/5]
power_5 [80000/140000]
power_5 [90000/140000]
power_5 [100000/140000]
power_5 [110000/140000]
power_5 [120000/140000]
power_5 [130000/140000]
power_5 [140000/140000]
5^140000 = 386471875(MOD 998244353)
Test power_5 OK!
AAAAAAAAAA [3/5]
BBBBBBBBBB [3/5]
CCCCCCCCCC [3/5]
AAAAAAAAAA [4/5]
BBBBBBBBBB [4/5]
CCCCCCCCCC [4/5]
AAAAAAAAAA [5/5]
BBBBBBBBBB [5/5]
CCCCCCCCCC [5/5]
Test write A OK!
Test write B OK!
Test write C OK!
time_msec = 143 after sleeping 100 ticks, delta = 101ms!
Test sleep1 passed!
Test sleep OK!
Panicked at src/task/mod.rs:98 All applications completed!
本章代码树
---------------------------------------------
.. code-block::
── os
   ├── build.rs
   ├── Cargo.toml
   ├── Makefile
   └── src
   ├── batch.rs(移除:功能分别拆分到 loader 和 task 两个子模块)
  ├── config.rs(新增:保存内核的一些配置)
   ├── console.rs
├── logging.rs
├── sync
   ├── entry.asm
   ├── lang_items.rs
   ├── link_app.S
   ├── linker.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
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 21 87 20 627
Assembly 4 12 22 144
make 1 11 4 36
TOML 1 2 1 10
-------------------------------------------------------------------------------
SUM: 27 112 47 817
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章的重点是实现对应用之间的协作式和抢占式任务切换的操作系统支持。与上一章的操作系统实现相比,有如下一些不同的情况导致实现上也有差异:
.. - 多个应用同时放在内存中,所以他们的起始地址是不同的,且地址范围不能重叠
.. - 应用在整个执行过程中会暂停或被抢占,即会有主动或被动的任务切换
.. 这些实现上差异主要集中在对应用程序执行过程的管理、支持应用程序暂停的系统调用和主动切换应用程序所需的时钟中断机制的管理。
.. 对于第一个不同情况,需要对应用程序的地址空间布局进行调整,每个应用的地址空间都不相同,且不能重叠。这并不要修改应用程序本身,而是通过一个脚本 ``build.py`` 来针对每个应用程序修改链接脚本 ``linker.ld`` 中的 ``BASE_ADDRESS`` ,让编译器在编译不同应用时用到的 ``BASE_ADDRESS`` 都不同,且有足够大的地址间隔。这样就可以让每个应用所在的内存空间是不同的。
.. 对于第二个不同情况,需要实现任务切换,这就需要在上一章的 ``trap`` 上下文切换的基础上,再加上一个 ``task`` 上下文切换,才能完成完整的任务切换。这里面的关键数据结构是表示应用执行上下文的 ``TaskContext`` 数据结构和具体完成上下文切换的汇编语言编写的 ``__switch`` 函数。一个应用的执行需要被操作系统管理起来,这是通过 ``TaskControlBlock`` 数据结构来表示应用执行上下文的动态过程和动态状态(运行态、就绪态等)。而为了做好应用程序第一次执行的前期初始化准备, ``TaskManager`` 数据结构的全局变量实例 ``TASK_MANAGER`` 描述了应用程序初始化所需的数据, 而 ``TASK_MANAGER`` 的初始化赋值过程是实现这个准备的关键步骤。
.. 应用程序可以在用户态执行后,还需要有新的系统调用 ``sys_yield`` 的实现来支持应用自己的主动暂停;还要添加对时钟中断的处理,来支持抢占应用执行的抢占式切换。有了时钟中断,就可以在一定时间内打断应用的执行,并主动切换到另外一个应用,这部分主要是通过对 ``trap_handler`` 函数中进行扩展,来完成在时钟中断产生时可能进行的任务切换。 ``TaskManager`` 数据结构的成员函数 ``run_next_task`` 来实现基于任务控制块的切换,并会具体调用 ``__switch`` 函数完成硬件相关部分的任务上下文切换。
.. 如果理解了上面的数据结构和相关函数的关系和相互调用的情况,那么就比较容易理解本章改进后的操作系统了。
.. .. [#prionosuchus] 锯齿螈身长可达9米是迄今出现过的最大的两栖动物是二叠纪时期江河湖泊和沼泽中的顶级掠食者。
.. .. [#eoraptor] 始初龙(也称始盗龙)是后三叠纪时期的两足食肉动物,也是目前所知最早的恐龙,它们只有一米长,却代表着恐龙的黎明。
.. .. [#coelophysis] 腔骨龙(也称虚形龙)最早出现于三叠纪晚期,它体形纤细,善于奔跑,以小型动物为食。

View File

@@ -0,0 +1,71 @@
多道程序放置与加载
=====================================
多道程序放置
----------------------------
在第二章中,内核让所有应用都共享同一个固定的起始地址。
正因如此,内存中同时最多只能驻留一个应用,
要一次加载运行多个程序,就要求每个用户程序被内核加载到内存中的起始地址都不同。
为此,我们编写脚本 ``user/build.py`` 为每个应用定制各自的起始地址。
它的思路很简单,对于每一个应用程序,使用 ``cargo rustc`` 单独编译,
``-Clink-args=-Ttext=xxxx`` 选项指定链接时 .text 段的地址为 ``0x80400000 + app_id * 0x20000``
.. note::
qemu 预留的内存空间是有限的,如果加载的程序过多,程序地址超出内存空间,可能出现 ``core dumped``.
多道程序加载
----------------------------
在第二章中负责应用加载和执行的子模块 ``batch`` 被拆分为 ``loader````task``
前者负责启动时加载应用程序,后者负责切换和调度。
其中, ``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 {
core::arch::asm!("fence.i");
}
// 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`` 子模块中找到这两个常数, ``APP_BASE_ADDRESS`` 被设置为 ``0x80400000``
``APP_SIZE_LIMIT`` 和上一章一样被设置为 ``0x20000`` 。这种放置方式与 ``user/build.py`` 的实现一致。

View File

@@ -0,0 +1,104 @@
任务切换
================================
本节我们将见识操作系统的核心机制—— **任务切换**
即应用在运行中主动或被动地交出 CPU 的使用权,内核可以选择另一个程序继续执行。
内核需要保证用户程序两次运行期间,任务上下文(如寄存器、栈等)保持一致。
任务切换的设计与实现
---------------------------------
任务切换与上一章提及的 Trap 控制流切换相比,有如下异同:
- 与 Trap 切换不同,它不涉及特权级切换,部分由编译器完成;
- 与 Trap 切换相同,它对应用是透明的。
事实上,任务切换是来自两个不同应用在内核中的 Trap 控制流之间的切换。
当一个应用 Trap 到 S 态 OS 内核中进行进一步处理时,
其 Trap 控制流可以调用一个特殊的 ``__switch`` 函数。
``__switch`` 返回之后Trap 控制流将继续从调用该函数的位置继续向下执行。
而在调用 ``__switch`` 之后到返回前的这段时间里,
原 Trap 控制流 ``A`` 会先被暂停并被切换出去, CPU 转而运行另一个应用的 Trap 控制流 ``B``
``__switch`` 返回之后,原 Trap 控制流 ``A`` 才会从某一条 Trap 控制流 ``C`` 切换回来继续执行。
我们需要在 ``__switch`` 中保存 CPU 的某些寄存器,它们就是 **任务上下文** (Task Context)。
下面我们给出 ``__switch`` 的实现:
.. code-block:: riscv
:linenos:
# os/src/task/switch.S
.altmacro
.macro SAVE_SN n
sd s\n, (\n+2)*8(a0)
.endm
.macro LOAD_SN n
ld s\n, (\n+2)*8(a1)
.endm
.section .text
.globl __switch
__switch:
# __switch(
# current_task_cx_ptr: *mut TaskContext,
# next_task_cx_ptr: *const TaskContext
# )
# save kernel stack of current task
sd sp, 8(a0)
# save ra & s0~s11 of current execution
sd ra, 0(a0)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr
# restore ra & s0~s11 of next execution
ld ra, 0(a1)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# restore kernel stack of next task
ld sp, 8(a1)
ret
它的两个参数分别是当前和即将被切换到的 Trap 控制流的 ``task_cx_ptr`` ,从 RISC-V 调用规范可知,它们分别通过寄存器 ``a0/a1`` 传入。
内核先把 ``current_task_cx_ptr`` 中包含的寄存器值逐个保存,再把 ``next_task_cx_ptr`` 中包含的寄存器值逐个恢复。
``TaskContext`` 里包含的寄存器有:
.. code-block:: rust
:linenos:
// os/src/task/context.rs
#[repr(C)]
pub struct TaskContext {
ra: usize,
sp: usize,
s: [usize; 12],
}
``s0~s11`` 是被调用者保存寄存器, ``__switch`` 是用汇编编写的,编译器不会帮我们处理这些寄存器。
保存 ``ra`` 很重要,它记录了 ``__switch`` 函数返回之后应该跳转到哪里继续执行。
我们将这段汇编代码 ``__switch`` 解释为一个 Rust 函数:
.. code-block:: rust
:linenos:
// os/src/task/switch.rs
core::arch::global_asm!(include_str!("switch.S"));
extern "C" {
pub fn __switch(
current_task_cx_ptr: *mut TaskContext,
next_task_cx_ptr: *const TaskContext);
}
我们会调用该函数来完成切换功能,而不是直接跳转到符号 ``__switch`` 的地址。
因此在调用前后,编译器会帮我们保存和恢复调用者保存寄存器。

View File

@@ -0,0 +1,317 @@
管理多道程序
=========================================
而内核为了管理任务,需要维护任务信息,相关内容包括:
- 任务运行状态:未初始化、准备执行、正在执行、已退出
- 任务控制块:维护任务状态和任务上下文
- 任务相关系统调用:程序主动暂停 ``sys_yield`` 和主动退出 ``sys_exit``
yield 系统调用
-------------------------------------------------------------------------
.. image:: multiprogramming.png
上图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。
开始时,蓝色应用向外设提交了一个请求,外设随即开始工作,
但是它要一段时间后才能返回结果。蓝色应用于是调用 ``sys_yield`` 交出 CPU 使用权,
内核让绿色应用继续执行。一段时间后 CPU 切换回蓝色应用,发现外设仍未返回结果,
于是再次 ``sys_yield`` 。直到第二次切换回蓝色应用,外设才处理完请求,于是蓝色应用终于可以向下执行了。
我们还会遇到很多其他需要等待其完成才能继续向下执行的事件,调用 ``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
// yield 是 Rust 的关键字
pub fn yield_() -> isize { sys_yield() }
下文介绍内核应如何实现该系统调用。
任务控制块与任务运行状态
---------------------------------------------------------
任务运行状态暂包括如下几种:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
#[derive(Copy, Clone, PartialEq)]
pub enum TaskStatus {
UnInit, // 未初始化
Ready, // 准备运行
Running, // 正在运行
Exited, // 已退出
}
任务状态外和任务上下文一并保存在名为 **任务控制块** (Task Control Block) 的数据结构中:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
#[derive(Copy, Clone)]
pub struct TaskControlBlock {
pub task_status: TaskStatus,
pub task_cx: TaskContext,
}
任务控制块非常重要。在内核中,它就是应用的管理单位。后面的章节我们还会不断向里面添加更多内容。
任务管理器
--------------------------------------
内核需要一个全局的任务管理器来管理这些任务控制块:
.. code-block:: rust
// os/src/task/mod.rs
pub struct TaskManager {
num_app: usize,
inner: UPSafeCell<TaskManagerInner>,
}
struct TaskManagerInner {
tasks: [TaskControlBlock; MAX_APP_NUM],
current_task: usize,
}
这里用到了变量与常量分离的编程风格:字段 ``num_app`` 表示应用数目,它在 ``TaskManager`` 初始化后将保持不变;
而包裹在 ``TaskManagerInner`` 内的任务控制块数组 ``tasks``,以及正在执行的应用编号 ``current_task`` 会在执行过程中变化。
初始化 ``TaskManager`` 的全局实例 ``TASK_MANAGER``
.. 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: TaskContext::zero_init(),
task_status: TaskStatus::UnInit,
}; MAX_APP_NUM];
for (i, t) in tasks.iter_mut().enumerate().take(num_app) {
t.task_cx = TaskContext::goto_restore(init_app_cx(i));
t.task_status = TaskStatus::Ready;
}
TaskManager {
num_app,
inner: unsafe {
UPSafeCell::new(TaskManagerInner {
tasks,
current_task: 0,
})
},
}
};
}
- 第 5 行:调用 ``loader`` 子模块提供的 ``get_num_app`` 接口获取链接到内核的应用总数;
- 第 10~12 行:依次对每个任务控制块进行初始化,将其运行状态设置为 ``Ready`` ,并在它的内核栈栈顶压入一些初始化
上下文,然后更新它的 ``task_cx`` 。一些细节我们会稍后介绍。
- 从第 14 行开始:创建 ``TaskManager`` 实例并返回。
.. note::
关于 Rust 迭代器语法如 ``iter_mut/(a..b)`` ,及其方法如 ``enumerate/map/find/take``,请参考 Rust 官方文档。
实现 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() {
TASK_MANAGER.mark_current_suspended();
TASK_MANAGER.run_next_task();
}
pub fn exit_current_and_run_next() {
TASK_MANAGER.mark_current_exited();
TASK_MANAGER.run_next_task();
}
它们都是先修改当前应用的运行状态,然后尝试切换到下一个应用。修改运行状态比较简单,实现如下:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
impl TaskManager {
fn mark_current_suspended(&self) {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
inner.tasks[current].task_status = TaskStatus::Ready;
}
}
``mark_current_suspended`` 为例。首先获得里层 ``TaskManagerInner`` 的可变引用,然后修改任务控制块数组 ``tasks`` 中当前任务的状态。
再看 ``run_next_task`` 的实现:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
impl TaskManager {
fn run_next_task(&self) {
if let Some(next) = self.find_next_task() {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
inner.tasks[next].task_status = TaskStatus::Running;
inner.current_task = next;
let current_task_cx_ptr = &mut inner.tasks[current].task_cx as *mut TaskContext;
let next_task_cx_ptr = &inner.tasks[next].task_cx as *const TaskContext;
drop(inner);
// before this, we should drop local variables that must be dropped manually
unsafe {
__switch(current_task_cx_ptr, next_task_cx_ptr);
}
// go back to user mode
} else {
panic!("All applications completed!");
}
}
fn find_next_task(&self) -> Option<usize> {
let inner = self.inner.exclusive_access();
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)
}
}
``run_next_task`` 会调用 ``find_next_task`` 方法尝试寻找一个运行状态为 ``Ready`` 的应用并获得其 ID 。
如果找不到, 说明所有应用都执行完了, ``find_next_task`` 将返回 ``None`` ,内核 panic 退出。
如果能够找到下一个可运行应用,我们就调用 ``__switch`` 切换任务。
切换任务之前,我们要手动 drop 掉我们获取到的 ``TaskManagerInner`` 可变引用。
因为函数还没有返回, ``inner`` 不会自动销毁。我们只有令 ``TASK_MANAGER````inner`` 字段回到未被借用的状态,下次任务切换时才能再借用。
我们可以总结一下应用的运行状态变化图:
.. image:: fsm-coop.png
第一次进入用户态
------------------------------------------
我们在第二章中介绍过 CPU 第一次从内核态进入用户态的方法,只需在内核栈上压入构造好的 Trap 上下文,
然后 ``__restore`` 即可。本章要在此基础上做一些扩展。
在初始化任务控制块时,我们是这样做的:
.. code-block:: rust
// os/src/task/mod.rs
for (i, t) in tasks.iter_mut().enumerate().take(num_app) {
t.task_cx = TaskContext::goto_restore(init_app_cx(i));
t.task_status = TaskStatus::Ready;
}
``init_app_cx````loader`` 子模块中定义,它向内核栈压入了一个 Trap 上下文,并返回压入 Trap 上下文后 ``sp`` 的值。
这个 Trap 上下文的构造方式与第二章相同。
``goto_restore`` 保存传入的 ``sp``,并将 ``ra`` 设置为 ``__restore`` 的入口地址,构造任务上下文后返回。这样,任务管理器中各个应用的任务上下文就得到了初始化。
.. code-block:: rust
// os/src/task/context.rs
impl TaskContext {
pub fn goto_restore(kstack_ptr: usize) -> Self {
extern "C" { fn __restore(); }
Self {
ra: __restore as usize,
sp: kstack_ptr,
s: [0; 12],
}
}
}
``rust_main`` 中我们调用 ``task::run_first_task`` 来执行第一个应用:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
fn run_first_task(&self) -> ! {
let mut inner = self.inner.exclusive_access();
let task0 = &mut inner.tasks[0];
task0.task_status = TaskStatus::Running;
let next_task_cx_ptr = &task0.task_cx as *const TaskContext;
drop(inner);
let mut _unused = TaskContext::zero_init();
// before this, we should drop local variables that must be dropped manually
unsafe {
__switch(&mut _unused as *mut TaskContext, next_task_cx_ptr);
}
panic!("unreachable in run_first_task!");
}
我们显式声明了一个 ``_unused`` 变量,并将它的地址作为第一个参数传给 ``__switch``
声明此变量的意义仅仅是为了避免其他数据被覆盖。
``__switch`` 中恢复 ``sp`` 后, ``sp`` 将指向 ``init_app_cx`` 构造的 Trap 上下文,后面就回到第二章的情况了。
此外, ``__restore`` 的实现需要做出变化:它 **不再需要** 在开头 ``mv sp, a0`` 了。因为在 ``__switch`` 之后,``sp`` 就已经正确指向了我们需要的 Trap 上下文地址。

View File

@@ -0,0 +1,161 @@
分时多任务系统
===========================================================
现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。
一般将 **时间片** (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。
简单起见,我们使用 **时间片轮转算法** (RR, Round-Robin) 来对应用进行调度。
时钟中断与计时器
------------------------------------------------------------------
实现调度算法需要计时。RISC-V 要求处理器维护时钟计数器 ``mtime``,还有另外一个 CSR ``mtimecmp``
一旦计数器 ``mtime`` 的值超过了 ``mtimecmp``,就会触发一次时钟中断。
运行在 M 特权级的 SEE 已经预留了相应的接口,基于此编写的 ``get_time`` 函数可以取得当前 ``mtime`` 计数器的值;
.. code-block:: rust
// os/src/timer.rs
use riscv::register::time;
pub fn get_time() -> usize {
time::read()
}
在 10 ms 后设置时钟中断的代码如下:
.. 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`` 调用,用来设置 ``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 MICRO_PER_SEC: usize = 1_000_000;
pub fn get_time_us() -> usize {
time::read() / (CLOCK_FREQ / MICRO_PER_SEC)
}
``timer`` 子模块的 ``get_time_us`` 可以以微秒为单位返回当前计数器的值。
新增一个系统调用,使应用能获取当前的时间:
.. code-block:: rust
:caption: 第三章新增系统调用(二)
/// 功能:获取当前的时间,保存在 TimeVal 结构体 ts 中_tz 在我们的实现中忽略
/// 返回值:返回是否执行成功,成功则返回 0
/// syscall ID169
fn sys_get_time(ts: *mut TimeVal, _tz: usize) -> isize;
结构体 ``TimeVal`` 的定义如下,内核只需调用 ``get_time_us`` 即可实现该系统调用。
.. code-block:: rust
// os/src/syscall/process.rs
#[repr(C)]
pub struct TimeVal {
pub sec: usize,
pub usec: usize,
}
RISC-V 架构中的嵌套中断问题
-----------------------------------
默认情况下,当 Trap 进入某个特权级之后,在 Trap 处理的过程中同特权级的中断都会被屏蔽。
- 当 Trap 发生时,``sstatus.sie`` 会被保存在 ``sstatus.spie`` 字段中,同时 ``sstatus.sie`` 置零,
这也就在 Trap 处理的过程中屏蔽了所有 S 特权级的中断;
- 当 Trap 处理完毕 ``sret`` 的时候, ``sstatus.sie`` 会恢复到 ``sstatus.spie`` 内的值。
也就是说,如果不去手动设置 ``sstatus`` CSR ,在只考虑 S 特权级中断的情况下,是不会出现 **嵌套中断** (Nested Interrupt) 的。
.. note::
**嵌套中断与嵌套 Trap**
嵌套中断可以分为两部分:在处理一个中断的过程中又被同特权级/高特权级中断所打断。默认情况下硬件会避免前一部分,
也可以通过手动设置来允许前一部分的存在;而从上面介绍的规则可以知道,后一部分则是无论如何设置都不可避免的。
嵌套 Trap 则是指处理一个 Trap 过程中又再次发生 Trap ,嵌套中断算是嵌套 Trap 的一种。
抢占式调度
-----------------------------------
有了时钟中断和计时器,抢占式调度就很容易实现了:
.. 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 特权级时钟中断时,重新设置计时器,
调用 ``suspend_current_and_run_next`` 函数暂停当前应用并切换到下一个。
为了避免 S 特权级时钟中断被屏蔽,我们需要在执行第一个应用前调用 ``enable_timer_interrupt()`` 设置 ``sie.stie``
使得 S 特权级时钟中断不会被屏蔽;再设置第一个 10ms 的计时器。
.. code-block:: rust
:linenos:
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
// ...
trap::enable_timer_interrupt();
timer::set_next_trigger();
// ...
}
// os/src/trap/mod.rs
use riscv::register::sie;
pub fn enable_timer_interrupt() {
unsafe { sie::set_stimer(); }
}
就这样,我们实现了时间片轮转任务调度算法。 ``power`` 系列用户程序可以验证我们取得的成果:这些应用并没有主动 yield
内核仍能公平地把时间片分配给它们。

View File

@@ -0,0 +1,133 @@
chapter3练习
=======================================
编程作业
--------------------------------------
获取任务信息
++++++++++++++++++++++++++
ch3 中,我们的系统已经能够支持多个任务分时轮流运行,我们希望引入一个新的系统调用 ``sys_task_info`` 以获取当前任务的信息,定义如下:
.. code-block:: rust
fn sys_task_info(ti: *mut TaskInfo) -> isize
- syscall ID: 410
- 查询当前正在执行的任务信息任务信息包括任务控制块相关信息任务状态、任务使用的系统调用及调用次数、任务总运行时长单位ms
.. code-block:: rust
struct TaskInfo {
status: TaskStatus,
syscall_times: [u32; MAX_SYSCALL_NUM],
time: usize
}
- 参数:
- ti: 待查询任务信息
- 返回值执行成功返回0错误返回-1
- 说明:
- 相关结构已在框架中给出,只需添加逻辑实现功能需求即可。
- 在我们的实验中,系统调用号一定小于 500所以直接使用一个长为 ``MAX_SYSCALL_NUM=500`` 的数组做桶计数。
- 运行时间 time 返回系统调用时刻距离任务第一次被调度时刻的时长,也就是说这个时长可能包含该任务被其他任务抢占后的等待重新调度的时间。
- 由于查询的是当前任务的状态,因此 TaskStatus 一定是 Running。助教起初想设计根据任务 id 查询,但是既不好定义任务 id 也不好写测例,遂放弃 QAQ
- 调用 ``sys_task_info`` 也会对本次调用计数。
- 提示:
- 大胆修改已有框架!除了配置文件,你几乎可以随意修改已有框架的内容。
- 程序运行时间可以通过调用 ``get_time()`` 获取,注意任务运行总时长的单位是 ms。
- 系统调用次数可以考虑在进入内核态系统调用异常处理函数之后,进入具体系统调用函数之前维护。
- 阅读 TaskManager 的实现,思考如何维护内核控制块信息(可以在控制块可变部分加入需要的信息)。
实验要求
+++++++++++++++++++++++++++++++++++++++++
- 完成分支: ch3。
- 实验目录要求
.. code-block::
├── os(内核实现)
│   ├── Cargo.toml(配置文件)
│   └── src(所有内核的源代码放在 os/src 目录下)
│   ├── main.rs(内核主函数)
│   └── ...
├── reports (不是 report)
│   ├── lab1.md/pdf
│   └── ...
├── ...
- 通过所有测例:
CI 使用的测例与本地相同测试中user 文件夹及其它与构建相关的文件将被替换,请不要试图依靠硬编码通过测试。
默认情况下makefile 仅编译基础测例 (``BASE=1``),即无需修改框架即可正常运行的测例。
你需要在编译时指定 ``BASE=0`` 控制框架仅编译实验测例(在 os 目录执行 ``make run BASE=0``
或指定 ``BASE=2`` 控制框架同时编译基础测例和实验测例。
.. note::
你的实现只需且必须通过测例,建议读者感到困惑时先检查测例。
简答作业
--------------------------------------------
1. 正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。
请同学们可以自行测试这些内容 (运行 `Rust 三个 bad 测例 (ch2b_bad_*.rs) <https://github.com/LearningOS/rCore-Tutorial-Test-2022S/tree/master/src/bin>`_
注意在编译时至少需要指定 ``LOG=ERROR`` 才能观察到内核的报错信息)
描述程序出错行为,同时注意注明你使用的 sbi 及其版本。
2. 深入理解 `trap.S <https://github.com/LearningOS/rCore-Tutorial-Code-2022S/blob/ch3/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 态是哪一条指令发生的?
报告要求
-------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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

View File

@@ -0,0 +1,143 @@
引言
==============================
本章导读
-------------------------------
本章中内核将实现虚拟内存机制,这注定是一趟艰难的旅程。
实践体验
-----------------------
本章应用运行起来效果与上一章基本一致。
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch4
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
或许你之前已经克隆过了仓库,只希望从远程仓库更新,而非再克隆一次:
.. code-block:: console
$ cd rCore-Tutorial-Code-2022S
# 你可以将 upstream 改为你喜欢的名字
$ git remote add upstream https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
# 更新仓库信息
$ git fetch upstream
# 查看已添加的远程仓库;应该能看到已有一个 origin 和新添加的 upstream 仓库
$ git remote -v
# 根据需求选择以下一种操作即可
# 在本地新建一个与远程仓库对应的分支:
$ git checkout -b ch4 upstream/ch4
# 本地已有分支,从远程仓库更新:
$ git checkout ch4
$ git merge upstream/ch4
# 将更新推送到自己的远程仓库
$ git push origin ch4
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
本章代码树
-----------------------------------------------------
.. code-block::
:linenos:
├── os
│   ├── ...
│   └── src
│   ├── ...
│   ├── config.rs(修改:新增一些内存管理的相关配置)
│   ├── linker.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 以及其他内容)
│   ├── syscall
│   │   ├── fs.rs(修改:基于地址空间的 sys_write 实现)
│   │   ├── mod.rs
│   │   └── process.rs
│   ├── task
│   │   ├── context.rs(修改:构造一个跳转到不同位置的初始任务上下文)
│   │   ├── mod.rs(修改,详见文档)
│   │   ├── switch.rs
│   │   ├── switch.S
│   │   └── task.rs(修改,详见文档)
│   └── trap
│   ├── context.rs(修改:在 Trap 上下文中加入了更多内容)
│   ├── mod.rs(修改:基于地址空间修改了 Trap 机制,详见文档)
│   └── trap.S(修改:基于地址空间修改了 Trap 上下文保存与恢复汇编代码)
└── user
├── build.py(编译时不再使用)
├── ...
└── src
├── linker.ld(修改:将所有应用放在各自地址空间中固定的位置)
└── ...
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 26 138 56 1526
Assembly 3 3 26 86
make 1 11 4 36
TOML 1 2 1 13
-------------------------------------------------------------------------------
SUM: 31 154 87 1661
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章涉及的代码量相对多了起来,也许同学们不知如何从哪里看起或从哪里开始尝试实验。这里简要介绍一下“头甲龙”操作系统的大致开发过程。
.. 我们先从简单的地方入手,那当然就是先改进应用程序了。具体而言,主要就是把 ``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,216 @@
实现 SV39 多级页表机制(上)
========================================================
.. note::
背景知识: `地址空间 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter4/2address-space.html>`_
背景知识: `SV39 多级页表原理 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter4/3sv39-implementation-1.html#id6>`_
我们将在内核实现 RV64 架构 SV39 分页机制。由于内容过多,分成两个小节。
虚拟地址和物理地址
------------------------------------------------------
内存控制相关的CSR寄存器
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
默认情况下 MMU 未被使能,此时无论 CPU 处于哪个特权级,访存的地址都将直接被视作物理地址。
可以通过修改 S 特权级的 ``satp`` CSR 来启用分页模式,此后 S 和 U 特权级的访存地址会被视为虚拟地址,经过 MMU 的地址转换获得对应物理地址,再通过它来访问物理内存。
.. image:: satp.png
:name: satp-layout
上图是 RV64 架构下 ``satp`` 的字段分布。当 ``MODE`` 设置为 0 的时候,所有访存都被视为物理地址;而设置为 8
SV39 分页机制被启用,所有 S/U 特权级的访存被视为一个 39 位的虚拟地址MMU 会将其转换成 56 位的物理地址;如果转换失败,则会触发异常。
地址格式与组成
^^^^^^^^^^^^^^^^^^^^^^^^^^
.. image:: sv39-va-pa.png
我们采用分页管理,单个页面的大小设置为 :math:`4\text{KiB}` ,每个虚拟页面和物理页帧都按 4 KB 对齐。
:math:`4\text{KiB}` 需要用 12 位字节地址来表示,因此虚拟地址和物理地址都被分成两部分:
它们的低 12 位被称为 **页内偏移** (Page Offset) 。虚拟地址的高 27 位,即 :math:`[38:12]` 为它的虚拟页号 VPN
物理地址的高 44 位,即 :math:`[55:12]` 为它的物理页号 PPN。页号可以用来定位一个虚拟/物理地址属于哪一个虚拟页面/物理页帧。
地址转换是以页为单位进行的转换前后地址页内偏移部分不变。MMU 只是从虚拟地址中取出 27 位虚拟页号,
在页表中查到其对应的物理页号,如果找到,就将得到的 44 位的物理页号与 12 位页内偏移拼接到一起,形成 56 位物理地址。
.. note::
**RV64 架构中虚拟地址为何只有 39 位?**
虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的。
SV39 分页模式规定 64 位虚拟地址的 :math:`[63:39]` 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个
不合法的虚拟地址。。
也就是说,所有 :math:`2^{64}` 个虚拟地址中,只有最低的 :math:`256\text{GiB}` (当第 38 位为 0 时)
以及最高的 :math:`256\text{GiB}` (当第 38 位为 1 时)是可能通过 MMU 检查的。
地址相关的数据结构抽象与类型定义
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
实现页表之前,先将地址和页号的概念抽象为 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 类型声明,它们都是 usize 的一种简单包装。
将它们各自抽象出来而不是直接使用 usize是为了在 Rust 编译器的帮助下进行多种方便且安全的 **类型转换** (Type Convertion) 。
这些类型本身可以和 usize 之间互相转换,地址和页号之间也可以相互转换。以物理地址和物理页号之间的转换为例:
.. 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) }
}
页表项的数据结构抽象与类型定义
-----------------------------------------
.. 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`` 封装成一个标志位的集合类型,支持一些常见的集合
运算。
接下来我们实现页表项 ``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来让这个类型以值语义赋值/传参的时候
不会发生所有权转移,而是拷贝一份新的副本。
- 第 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()
}
}
这里相当于判断两个集合的交集是否为空。

View File

@@ -0,0 +1,447 @@
实现 SV39 多级页表机制(下)
========================================================
物理页帧管理
-----------------------------------
可用物理页的分配与回收
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
首先,我们需要知道物理内存的哪一部分是可用的。在 ``os/src/linker.ld`` 中,我们用符号 ``ekernel`` 指明了
内核数据的终止物理地址,在它之后的物理内存都是可用的。而在 ``config`` 子模块中:
.. code-block:: rust
// os/src/config.rs
pub const MEMORY_END: usize = 0x80800000;
我们硬编码整块物理内存的终止物理地址为 ``0x80800000`` 。 而物理内存的起始物理地址为 ``0x80000000``
意味着我们将可用内存大小设置为 :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``,并在正式分配物理页帧之前将 ``FRAME_ALLOCATOR`` 初始化,见 ``os/src/mm/frame_allocator.rs``
分配/回收物理页帧的接口
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
公开给其他子模块调用的分配/回收物理页帧的接口:
.. code-block:: rust
// os/src/mm/frame_allocator.rs
pub fn frame_alloc() -> Option<FrameTracker> {
FRAME_ALLOCATOR
.exclusive_access()
.alloc()
.map(FrameTracker::new)
}
fn frame_dealloc(ppn: PhysPageNum) {
FRAME_ALLOCATOR.exclusive_access().dealloc(ppn);
}
可以发现, ``frame_alloc`` 的返回值类型并不是 ``FrameAllocator`` 要求的物理页号 ``PhysPageNum`` ,而是将其
进一步包装为一个 ``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`` 我们就将它控制的物理页帧回收以供后续使用了。
最后做一个小结:从其他模块的视角看来,物理页帧分配的接口是调用 ``frame_alloc`` 函数得到一个 ``FrameTracker``
(如果物理内存还有剩余),它就代表了一个物理页帧,当它的生命周期结束之后它所控制的物理页帧将被自动回收。
多级页表实现
-----------------------------------
页表基本数据结构与访问接口
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们知道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:
在这些操作的过程中,我们自然需要访问或修改多级页表节点的内容。每个节点都被保存在一个物理页帧中,在多级页表的架构中,我们以
一个节点被存放在的物理页帧的物理页号作为指针指向该节点,这意味着,对于每个节点来说,一旦我们知道了指向它的物理页号,我们
就能够修改这个节点的内容。
.. _term-identical-mapping:
这就需要我们提前扩充多级页表维护的映射,使得对于每一个对应于某一特定物理页帧的物理页号 ``ppn`` ,均存在一个虚拟页号
``vpn`` 能够映射到它,而且要能够较为简单的针对一个 ``ppn`` 找到某一个能映射到它的 ``vpn`` 。这里我们采用一种最
简单的 **恒等映射** (Identical Mapping) ,也就是说对于物理内存上的每个物理页帧,我们都在多级页表中用一个与其
物理页号相等的虚拟页号映射到它。当我们想针对物理页号构造一个能映射到它的虚拟页号的时候,也只需使用一个和该物理页号
相等的虚拟页号即可。
内核中访问物理页帧的方法
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. _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`` 的解引用访问它指向的数据,而是可以像一个
正常的可变引用一样直接访问。
建立和拆除虚实地址映射关系
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
接下来介绍建立和拆除虚实地址映射关系的 ``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,586 @@
内核与应用的地址空间
================================================
本节我们就在内核中通过基于页表的各种数据结构实现地址空间的抽象。
实现地址空间抽象
------------------------------------------
逻辑段:一段连续地址的虚拟内存
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们以逻辑段 ``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,684 @@
基于地址空间的分时多任务
==============================================================
本节我们介绍如何基于地址空间抽象来实现第三章的分时多任务系统。
建立并开启基于分页模式的虚拟地址空间
--------------------------------------------
当 SBI 实现(本项目中基于 RustSBI初始化完成后 CPU 将跳转到内核入口点并在 S 特权级上执行,此时还并没有开启分页模式
,内核的每一次访存仍被视为一个物理地址直接访问物理内存。而在开启分页模式之后,内核的代码在访存的时候只能看到内核地址空间,
此时每次访存将被视为一个虚拟地址且需要通过 MMU 基于内核地址空间的多级页表的地址转换。这两种模式之间的过渡在内核初始化期间
完成。
创建内核地址空间
^^^^^^^^^^^^^^^^^^^^^^^^
我们创建内核地址空间的全局实例:
.. code-block:: rust
// os/src/mm/memory_set.rs
lazy_static! {
pub static ref KERNEL_SPACE: Arc<UPSafeCell<MemorySet>> = Arc::new(unsafe {
UPSafeCell::new(MemorySet::new_kernel()
)});
}
从之前对于 ``lazy_static!`` 宏的介绍可知, ``KERNEL_SPACE`` 在运行期间它第一次被用到时才会实际进行初始化,而它所
占据的空间则是编译期被放在全局数据段中。 ``Arc<UPSafeCell<_>>`` 同时带来 ``Arc<T>`` 提供的共享
引用,和 ``UPSafeCell<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.exclusive_access().activate();
}
可以看到,我们最先进行了全局动态内存分配器的初始化,因为接下来马上就要用到 Rust 的堆数据结构。接下来我们初始化物理页帧
管理器(内含堆数据结构 ``Vec<T>`` )使能可用物理页帧的分配和回收能力。最后我们创建内核地址空间并让 CPU 开启分页模式,
MMU 在地址转换的时候使用内核的多级页表,这一切均在一行之内做到:
- 首先,我们引用 ``KERNEL_SPACE`` ,这是它第一次被使用,就在此时它会被初始化,调用 ``MemorySet::new_kernel``
创建一个内核地址空间并使用 ``Arc<UPSafeCell<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);
core::arch::asm!("sfence.vma");
}
}
}
``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 就不会看到快表中已经
过期的键值对了。
.. _term-trampoline:
跳板的实现
------------------------------------
上一小节我们看到无论是内核还是应用的地址空间,最高的虚拟页面都是一个跳板。同时应用地址空间的次高虚拟页面还被设置为用来
存放应用的 Trap 上下文。那么跳板究竟起什么作用呢?为何不直接把 Trap 上下文仍放到应用的内核栈中呢?
回忆曾在第二章介绍过的,当一个应用 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
的失效问题。但是这种设计方式也有缺点:即内核的逻辑段需要在每个应用的地址空间内都映射一次,这会带来一些无法忽略的
内存占用开销,并显著限制了嵌入式平台的任务并发数。此外,这种做法无法应对处理器的 `熔断
(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.ld`` 的地址布局描述,设定电子指令的地址,并计算二者地址偏移量
并让跳转指令的实际效果为当前 pc 自增这个偏移量。但实际上我们知道由于我们设计的缘故,这条跳转指令在被执行的时候,
它的虚拟地址被操作系统内核设置在地址空间中的最高页面之内,加上这个偏移量并不能正确的得到 ``trap_handler`` 的入口地址。
**问题的本质可以概括为:跳转指令实际被执行时的虚拟地址和在编译器/汇编器/链接器进行后端代码生成和链接形成最终机器码时设置此指令的地址是不同的。**
加载和执行应用程序
------------------------------------
扩展任务控制块
^^^^^^^^^^^^^^^^^^^^^^^^^^^
为了让应用在运行时有一个安全隔离且符合编译器给应用设定的地址空间布局的虚拟地址空间,操作系统需要对任务进行更多的管理,所以任务控制块相比第三章也包含了更多内容:
.. code-block:: rust
:linenos:
:emphasize-lines: 6,7,8
// os/src/task/task.rs
pub struct TaskControlBlock {
pub task_status: TaskStatus,
pub task_cx: TaskContext,
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
.exclusive_access()
.insert_framed_area(
kernel_stack_bottom.into(),
kernel_stack_top.into(),
MapPermission::R | MapPermission::W,
);
let task_control_block = Self {
task_status,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
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.exclusive_access().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:
- 我们在应用的内核栈顶压入一个跳转到 ``trap_return`` 而不是 ``__restore`` 的任务上下文,
这主要是为了能够支持对该应用的启动并顺利切换到用户地址空间执行。在构造方式上,只是将 ra 寄存器的值设置为
``trap_return`` 的地址。 ``trap_return`` 是我们后面要介绍的新版的 Trap 处理的一部分。
- 初始化该应用的 Trap 上下文,由于它是在应用地址空间而不是在内核地址空间中,我们只能手动查页表找到
Trap 上下文实际被放在的物理页帧,再获得在用户空间的 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`` 泛型函数的特定版本实现。
.. 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 = {
info!("init TASK_MANAGER");
let num_app = get_num_app();
info!("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: unsafe {
UPSafeCell::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 位的值到一个通用寄存器。
为了方便后续的实现,全局任务管理器还需要提供关于当前应用与地址空间有关的一些信息。通过 ``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 {
core::arch::asm!(
"fence.i",
"jr {restore_va}",
restore_va = in(reg) restore_va,
in("a0") trap_cx_ptr,
in("a1") user_satp,
options(noreturn)
);
}
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`` 都是指编译器在链接时看到的内核内存布局中的地址。我们使用 ``jr`` 指令完成了跳转的任务。
- 在开始执行应用之前,我们需要使用 ``fence.i`` 指令清空指令缓存 i-cache 。这是因为,在内核中进行的一些操作
可能导致一些原先存放某个应用代码的物理页帧如今用来存放数据或者是其他应用的代码i-cache 中可能还保存着该物理页帧的
错误快照。因此我们直接将整个 i-cache 清空避免错误。
改进 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`` 然后输出即可。

View File

@@ -0,0 +1,113 @@
chapter4练习
============================================
编程作业
---------------------------------------------
重写 sys_get_time 和 sys_task_info
++++++++++++++++++++++++++++++++++++++++++++
引入虚存机制后,原来内核的 sys_get_time 和 sys_task_info 函数实现就无效了。请你重写这个函数,恢复其正常功能。
mmap 和 munmap 匿名映射
++++++++++++++++++++++++++++++++++++++++++++
`mmap <https://man7.org/linux/man-pages/man2/mmap.2.html>`_ 在 Linux 中主要用于在内存中映射文件,
本次实验简化它的功能,仅用于申请内存。
请实现 mmap 和 munmap 系统调用mmap 定义如下:
.. code-block:: rust
fn sys_mmap(start: usize, len: usize, port: usize) -> isize
- syscall ID222
- 申请长度为 len 字节的物理内存(不要求实际物理内存位置,可以随便找一块),将其映射到 start 开始的虚存,内存页属性为 port
- 参数:
- start 需要映射的虚存起始地址,要求按页对齐
- len 映射字节长度,可以为 0
- port第 0 位表示是否可读,第 1 位表示是否可写,第 2 位表示是否可执行。其他位无效且必须为 0
- 返回值:执行成功则返回 0错误返回 -1
- 说明:
- 为了简单目标虚存区间要求按页对齐len 可直接按页向上取整,不考虑分配失败时的页回收。
- 可能的错误:
- start 没有按页大小对齐
- port & !0x7 != 0 (port 其余位必须为0)
- port & 0x7 = 0 (这样的内存无意义)
- [start, start + len) 中存在已经被映射的页
- 物理内存不足
munmap 定义如下:
.. code-block:: rust
fn sys_munmap(start: usize, len: usize) -> isize
- syscall ID215
- 取消到 [start, start + len) 虚存的映射
- 参数和返回值请参考 mmap
- 说明:
- 为了简单,参数错误时不考虑内存的恢复和回收。
- 可能的错误:
- [start, start + len) 中存在未被映射的虚存。
tips:
- 一定要注意 mmap 是的页表项,注意 riscv 页表项的格式与 port 的区别。
- 你增加 PTE_U 了吗?
实验要求
++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch4。
- 实现 mmap 和 munmap 两个系统调用,通过所有测例。
- 实验目录请参考 ch3报告命名 lab2.md/pdf
TIPS注意 port 参数的语义,它与内核定义的 MapPermission 有明显不同!
问答作业
-------------------------------------------------
1. 请列举 SV39 页表页表项的组成,描述其中的标志位有何作用?
2. 缺页
缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断,
告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程。
- 请问哪些异常可能是缺页导致的?
- 发生缺页时,描述相关重要寄存器的值,上次实验描述过的可以简略。
缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。
比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,
而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。
- 这样做有哪些好处?
其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,
然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。
- 处理 10G 连续的内存页面,对应的 SV39 页表大致占用多少内存 (估算数量级即可)
- 请简单思考如何才能实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。
缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。
- 此时页面失效如何表现在页表项(PTE)上?
3. 双页表与单页表
为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,
用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。
(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 `KPTI <https://en.wikipedia.org/wiki/Kernel_page-table_isolation>`_ )
- 在单页表情况下,如何更换页表?
- 单页表情况下如何控制用户态无法访问内核页面tips:看看上一题最后一问)
- 单页表有何优势?(回答合理即可)
- 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?
报告要求
--------------------------------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,12 @@
第四章:地址空间
==============================================
.. toctree::
:maxdepth: 4
0intro
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,175 @@
引言
===========================================
本章导读
-------------------------------------------
我们将开发一个用户 **终端** (Terminal) 或 **命令行** (Command Line Application, 俗称 **Shell** )
形成用户与操作系统进行交互的命令行界面 (Command Line Interface)。
为此,我们要对任务建立新的抽象: **进程** ,并实现若干基于 **进程** 的强大系统调用。
.. note::
**任务和进程的关系与区别**
第三章提到的 **任务** 是这里提到的 **进程** 的初级阶段,与任务相比,进程能在运行中创建 **子进程**
用新的 **程序** 内容覆盖已有的 **程序** 内容、可管理更多物理或虚拟 **资源**
实践体验
-------------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch5
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
请仿照ch4的做法将代码在本地更新并push到自己的实验仓库中。
注意user仓库有对ch5的测例更新请重新clone或者使用git pull等获取。
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
待内核初始化完毕之后将在屏幕上打印可用的应用列表并进入shell程序
.. code-block::
[rustsbi] RustSBI version 0.2.0-alpha.4
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation: RustSBI-QEMU Version 0.0.1
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: ssoft, stimer, sext (0x222)
[rustsbi] medeleg: ima, ia, bkpt, la, sa, uecall, ipage, lpage, spage (0xb1ab)
[rustsbi] pmp0: 0x80000000 ..= 0x800fffff (rwx)
[rustsbi] pmp1: 0x80000000 ..= 0x807fffff (rwx)
[rustsbi] pmp2: 0x0 ..= 0xffffffffffffff (---)
[rustsbi] enter supervisor 0x80200000
[kernel] Hello, world!
/**** APPS ****
ch2b_bad_address
ch2b_bad_instructions
ch2b_bad_register
ch2b_hello_world
ch2b_power_3
ch2b_power_5
ch2b_power_7
ch3b_sleep
ch3b_sleep1
ch3b_yield0
ch3b_yield1
ch3b_yield2
ch5b_exit
ch5b_forktest
ch5b_forktest2
ch5b_forktest_simple
ch5b_forktree
ch5b_initproc
ch5b_user_shell
**************/
Rust user shell
>>
可以通过输入ch5b开头的应用来测试ch5实现的fork等功能:
.. code-block::
>> ch5b_forktest_simple
sys_wait without child process test passed!
parent start, pid = 2!
ready waiting on parent process!
hello child process!
child process pid = 3, exit code = 100
Shell: Process 2 exited with code 0
本章代码树
--------------------------------------
.. code-block::
:linenos:
├── os
   ├── build.rs(修改:基于应用名的应用构建器)
   ├── ...
   └── src
   ├── ...
   ├── loader.rs(修改:基于应用名的应用加载器)
   ├── main.rs(修改)
   ├── mm(修改:为了支持本章的系统调用对此模块做若干增强)
   │   ├── address.rs
   │   ├── frame_allocator.rs
   │   ├── heap_allocator.rs
   │   ├── memory_set.rs
   │   ├── mod.rs
   │   └── page_table.rs
   ├── syscall
   │   ├── fs.rs(修改:新增 sys_read)
   │   ├── mod.rs(修改:新的系统调用的分发处理)
   │   └── process.rs修改新增 sys_getpid/fork/exec/waitpid
   ├── task
   │   ├── context.rs
   │   ├── manager.rs(新增:任务管理器,为上一章任务管理器功能的一部分)
   │   ├── mod.rs(修改:调整原来的接口实现以支持进程)
   │   ├── pid.rs(新增:进程标识符和内核栈的 Rust 抽象)
   │   ├── processor.rs(新增:处理器管理结构 ``Processor`` ,为上一章任务管理器功能的一部分)
   │   ├── switch.rs
   │   ├── switch.S
   │   └── task.rs(修改:支持进程机制的任务控制块)
   └── trap
   ├── context.rs
   ├── mod.rs(修改:对于系统调用的实现进行修改以支持进程系统调用)
   └── trap.S
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 29 180 138 2049
Assembly 4 20 26 229
make 1 11 4 36
TOML 1 2 1 13
-------------------------------------------------------------------------------
SUM: 35 213 169 2327
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章的第一小节 :doc:`/chapter5/1process` 介绍了操作系统中经典的进程概念,并描述我们将要实现的参考自 Unix 系内核并经过简化的精简版进程模型。在该模型下,若想对进程进行管理,实现创建、退出等操作,核心就在于 ``fork/exec/waitpid`` 三个系统调用。
.. 首先我们修改运行在应用态的应用软件,它们均放置在 ``user`` 目录下。在新增系统调用的时候,需要在 ``user/src/lib.rs`` 中新增一个 ``sys_*`` 的函数,它的作用是将对应的系统调用按照与内核约定的 ABI 在 ``syscall`` 中转化为一条用于触发系统调用的 ``ecall`` 的指令;还需要在用户库 ``user_lib`` 将 ``sys_*`` 进一步封装成一个应用可以直接调用的与系统调用同名的函数。通过这种方式我们新增三个进程模型中核心的系统调用 ``fork/exec/waitpid`` ,一个查看进程 PID 的系统调用 ``getpid`` ,还有一个允许应用程序获取用户键盘输入的 ``read`` 系统调用。
.. 基于进程模型,我们在 ``user/src/bin`` 目录下重新实现了一组应用程序。其中有两个特殊的应用程序:用户初始程序 ``initproc.rs`` 和 shell 程序 ``user_shell.rs`` ,可以认为它们位于内核和其他应用程序之间的中间层提供一些基础功能,但是它们仍处于应用层。前者会被内核唯一自动加载、也是最早加载并执行,后者则负责从键盘接收用户输入的应用名并执行对应的应用。剩下的应用从不同层面测试了我们内核实现的正确性,读者可以自行参考。值得一提的是, ``usertests`` 可以按照顺序执行绝大部分应用,会在测试的时候为我们提供很多方便。
.. 接下来就需要在内核中实现简化版的进程机制并支持新增的系统调用。在本章第二小节 :doc:`/chapter5/2core-data-structures` 中我们对一些进程机制相关的数据结构进行了重构或者修改:
.. - 为了支持基于应用名而不是应用 ID 来查找应用 ELF 可执行文件,从而实现灵活的应用加载,在 ``os/build.rs`` 以及 ``os/src/loader.rs`` 中更新了 ``link_app.S`` 的格式使得它包含每个应用的名字,另外提供 ``get_app_data_by_name`` 接口获取应用的 ELF 数据。
.. - 在本章之前,任务管理器 ``TaskManager`` 不仅负责管理所有的任务状态,还维护着我们的 CPU 当前正在执行哪个任务。这种设计耦合度较高,我们将后一个功能分离到 ``os/src/task/processor.rs`` 中的处理器管理结构 ``Processor`` 中,它负责管理 CPU 上执行的任务和一些其他信息;而 ``os/src/task/manager.rs`` 中的任务管理器 ``TaskManager`` 仅负责管理所有任务。
.. - 针对新的进程模型,我们复用前面章节的任务控制块 ``TaskControlBlock`` 作为进程控制块来保存进程的一些信息,相比前面章节还要新增 PID、内核栈、应用数据大小、父子进程、退出码等信息。它声明在 ``os/src/task/task.rs`` 中。
.. - 从本章开始,内核栈在内核地址空间中的位置由所在进程的 PID 决定,我们需要在二者之间建立联系并提供一些相应的资源自动回收机制。可以参考 ``os/src/task/pid.rs`` 。
.. 有了这些数据结构的支撑,我们在本章第三小节 :doc:`/chapter5/3implement-process-mechanism` 实现进程机制。它可以分成如下几个方面:
.. - 初始进程的自动创建。在内核初始化的时候需要调用 ``os/src/task/mod.rs`` 中的 ``add_initproc`` 函数,它会调用 ``TaskControlBlock::new`` 读取并解析初始应用 ``initproc`` 的 ELF 文件数据并创建初始进程 ``INITPROC`` ,随后会将它加入到全局任务管理器 ``TASK_MANAGER`` 中参与调度。
.. - 进程切换机制。当一个进程退出或者是主动/被动交出 CPU 使用权之后需要由内核将 CPU 使用权交给其他进程。在本章中我们沿用 ``os/src/task/mod.rs`` 中的 ``suspend_current_and_run_next`` 和 ``exit_current_and_run_next`` 两个接口来实现进程切换功能,但是需要适当调整它们的实现。我们需要调用 ``os/src/task/task.rs`` 中的 ``schedule`` 函数进行进程切换,它会首先切换到处理器的 idle 控制流(即 ``os/src/task/processor`` 的 ``Processor::run`` 方法),然后在里面选取要切换到的进程并切换过去。
.. - 进程调度机制。在进程切换的时候我们需要选取一个进程切换过去。选取进程逻辑可以参考 ``os/src/task/manager.rs`` 中的 ``TaskManager::fetch_task`` 方法。
.. - 进程生成机制。这主要是指 ``fork/exec`` 两个系统调用。它们的实现分别可以在 ``os/src/syscall/process.rs`` 中找到,分别基于 ``os/src/process/task.rs`` 中的 ``TaskControlBlock::fork/exec`` 。
.. - 进程资源回收机制。当一个进程主动退出或出错退出的时候,在 ``exit_current_and_run_next`` 中会立即回收一部分资源并在进程控制块中保存退出码;而需要等到它的父进程通过 ``waitpid`` 系统调用(与 ``fork/exec`` 两个系统调用放在相同位置)捕获到它的退出码之后,它的进程控制块才会被回收,从而所有资源都被回收。
.. - 为了支持用户终端 ``user_shell`` 读取用户键盘输入的功能,还需要实现 ``read`` 系统调用,它可以在 ``os/src/syscall/fs.rs`` 中找到。

View File

@@ -0,0 +1,230 @@
与进程有关的重要系统调用
================================================
重要系统调用
------------------------------------------------------------
fork 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:由当前进程 fork 出一个子进程。
/// 返回值:对于子进程返回 0对于当前进程则返回子进程的 PID 。
/// syscall ID220
pub fn sys_fork() -> isize;
exec 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。
/// 参数:字符串 path 给出了要加载的可执行文件的名字;
/// 返回值:如果出错的话(如找不到名字相符的可执行文件)则返回 -1否则不应该返回。
/// 注意path 必须以 "\0" 结尾,否则内核将无法确定其长度
/// syscall ID221
pub fn sys_exec(path: &str) -> isize;
利用 ``fork````exec`` 的组合,我们能让创建一个子进程,并令其执行特定的可执行文件。
waitpid 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。
/// 参数pid 表示要等待的子进程的进程 ID如果为 -1 的话表示等待任意一个子进程;
/// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。
/// 返回值:如果要等待的子进程不存在则返回 -1否则如果要等待的子进程均未结束则返回 -2
/// 否则返回结束的子进程的进程 ID。
/// syscall ID260
pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;
``sys_waitpid`` 在用户库中被封装成两个不同的 API ``wait(exit_code: &mut i32)````waitpid(pid: usize, exit_code: &mut i32)``
前者用于等待任意一个子进程,后者用于等待特定子进程。它们实现的策略是如果子进程还未结束,就以 yield 让出时间片:
.. code-block:: rust
:linenos:
// user/src/lib.rs
pub fn wait(exit_code: &mut i32) -> isize {
loop {
match sys_waitpid(-1, exit_code as *mut _) {
-2 => { sys_yield(); }
n => { return n; }
}
}
}
应用程序示例
-----------------------------------------------
借助这三个重要系统调用,我们可以开发功能更强大的应用。下面是两个案例: **用户初始程序-init** 和 **shell程序-user_shell** 。
用户初始程序-initproc
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在内核初始化完毕后创建的第一个进程,是 **用户初始进程** (Initial Process) ,它将通过
``fork+exec`` 创建 ``user_shell`` 子进程,并将被用于回收僵尸进程。
.. code-block:: rust
:linenos:
// user/src/bin/ch5b_initproc.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{
fork,
wait,
exec,
yield_,
};
#[no_mangle]
fn main() -> i32 {
if fork() == 0 {
exec("ch5b_user_shell\0");
} else {
loop {
let mut exit_code: i32 = 0;
let pid = wait(&mut exit_code);
if pid == -1 {
yield_();
continue;
}
println!(
"[initproc] Released a zombie process, pid={}, exit_code={}",
pid,
exit_code,
);
}
}
0
}
- 第 19 行为 ``fork`` 出的子进程分支,通过 ``exec`` 启动shell程序 ``user_shell``
注意我们需要在字符串末尾手动加入 ``\0``
- 第 21 行开始则为父进程分支,表示用户初始程序-initproc自身。它不断循环调用 ``wait`` 来等待并回收系统中的僵尸进程占据的资源。
如果回收成功的话则会打印一条报告信息给出被回收子进程的 PID 和返回值;否则就 ``yield_`` 交出 CPU 资源并在下次轮到它执行的时候再回收看看。
shell程序-user_shell
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
user_shell 需要捕获用户输入并进行解析处理,为此添加一个能获取用户输入的系统调用:
.. code-block:: rust
/// 功能:从文件中读取一段内容到缓冲区。
/// 参数fd 是待读取文件的文件描述符,切片 buffer 则给出缓冲区。
/// 返回值:如果出现了错误则返回 -1否则返回实际读到的字节数。
/// syscall ID63
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize;
实际调用时,我们必须要同时向内核提供缓冲区的起始地址及长度:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize {
syscall(SYSCALL_READ, [fd, buffer.as_mut_ptr() as usize, buffer.len()])
}
我们在用户库中将其进一步封装成每次能够从 **标准输入** 中获取一个字符的 ``getchar`` 函数。
shell程序 ``user_shell`` 实现如下:
.. code-block:: rust
:linenos:
:emphasize-lines: 28,53,61
// user/src/bin/ch5b_user_shell.rs
#![no_std]
#![no_main]
extern crate alloc;
#[macro_use]
extern crate user_lib;
const LF: u8 = 0x0au8;
const CR: u8 = 0x0du8;
const DL: u8 = 0x7fu8;
const BS: u8 = 0x08u8;
use alloc::string::String;
use user_lib::{fork, exec, waitpid, yield_};
use user_lib::console::getchar;
#[no_mangle]
pub fn main() -> i32 {
println!("Rust user shell");
let mut line: String = String::new();
print!(">> ");
loop {
let c = getchar();
match c {
LF | CR => {
println!("");
if !line.is_empty() {
line.push('\0');
let pid = fork();
if pid == 0 {
// child process
if exec(line.as_str()) == -1 {
println!("Error when executing!");
return -4;
}
unreachable!();
} else {
let mut exit_code: i32 = 0;
let exit_pid = waitpid(pid as usize, &mut exit_code);
assert_eq!(pid, exit_pid);
println!(
"Shell: Process {} exited with code {}",
pid, exit_code
);
}
line.clear();
}
print!(">> ");
}
BS | DL => {
if !line.is_empty() {
print!("{}", BS as char);
print!(" ");
print!("{}", BS as char);
line.pop();
}
}
_ => {
print!("{}", c as char);
line.push(c as char);
}
}
}
}
可以看到,在以第 25 行开头的主循环中,每次都是调用 ``getchar`` 获取一个用户输入的字符,
并根据它相应进行一些动作。第 23 行声明的字符串 ``line`` 则维护着用户当前输入的命令内容,它也在不断发生变化。
- 如果用户输入回车键(第 28 行那么user_shell 会 fork 出一个子进程(第 34 行开始)并试图通过
``exec`` 系统调用执行一个应用,应用的名字在字符串 ``line`` 中给出。如果 exec 的返回值为 -1
说明在应用管理器中找不到对应名字的应用,此时子进程就直接打印错误信息并退出;否则子进程将开始执行目标应用。
fork 之后的 user_shell 进程自己的逻辑可以在第 41 行找到。它在等待 fork 出来的子进程结束并回收掉它的资源,还会顺带收集子进程的退出状态并打印出来。
- 如果用户输入退格键(第 53 行),首先我们需要将屏幕上当前行的最后一个字符用空格替换掉,
这可以通过输入一个特殊的退格字节 ``BS`` 来实现。其次user_shell 进程内维护的 ``line`` 也需要弹出最后一个字符。
- 如果用户输入了一个其他字符(第 61 行),就接将它打印在屏幕上,并加入到 ``line`` 中。
- 按键 ``Ctrl+A`` 再输入 ``X`` 来退出qemu模拟器。

View File

@@ -0,0 +1,540 @@
进程管理的核心数据结构
===================================
本节导读
-----------------------------------
为了更好实现进程管理,我们需要设计和调整内核中的一些数据结构,包括:
- 基于应用名的应用链接/加载器
- 进程标识符 ``PidHandle`` 以及内核栈 ``KernelStack``
- 任务控制块 ``TaskControlBlock``
- 任务管理器 ``TaskManager``
- 处理器管理结构 ``Processor``
基于应用名的应用链接/加载器
------------------------------------------------------------------------
在实现 ``exec`` 系统调用的时候,我们需要根据应用的名字而不仅仅是一个编号来获取应用的 ELF 格式数据。
因此,在链接器 ``os/build.rs`` 中,我们按顺序保存链接进来的每个应用的名字:
.. code-block::
:linenos:
:emphasize-lines: 8-13
// os/build.rs
for i in 0..apps.len() {
writeln!(f, r#" .quad app_{}_start"#, i)?;
}
writeln!(f, r#" .quad app_{}_end"#, apps.len() - 1)?;
writeln!(f, r#"
.global _app_names
_app_names:"#)?;
for app in apps.iter() {
writeln!(f, r#" .string "{}""#, app)?;
}
for (idx, app) in apps.iter().enumerate() {
...
}
第 8~13 行,各个应用的名字通过 ``.string`` 伪指令放到数据段中,注意链接器会自动在每个字符串的结尾加入分隔符
``\0`` ,它们的位置由全局符号 ``_app_names`` 指出。
而在加载器 ``loader.rs`` 中,我们用一个全局可见的 *只读* 向量 ``APP_NAMES`` 来按照顺序将所有应用的名字保存在内存中:
.. code-block:: Rust
// os/src/loader.rs
lazy_static! {
static ref APP_NAMES: Vec<&'static str> = {
let num_app = get_num_app();
extern "C" { fn _app_names(); }
let mut start = _app_names as usize as *const u8;
let mut v = Vec::new();
unsafe {
for _ in 0..num_app {
let mut end = start;
while end.read_volatile() != '\0' as u8 {
end = end.add(1);
}
let slice = core::slice::from_raw_parts(start, end as usize - start as usize);
let str = core::str::from_utf8(slice).unwrap();
v.push(str);
start = end.add(1);
}
}
v
};
}
使用 ``get_app_data_by_name`` 可以按照应用的名字来查找获得应用的 ELF 数据,而 ``list_apps``
在内核初始化时被调用,它可以打印出所有可用应用的名字。
.. code-block:: rust
// os/src/loader.rs
pub fn get_app_data_by_name(name: &str) -> Option<&'static [u8]> {
let num_app = get_num_app();
(0..num_app)
.find(|&i| APP_NAMES[i] == name)
.map(|i| get_app_data(i))
}
pub fn list_apps() {
println!("/**** APPS ****");
for app in APP_NAMES.iter() {
println!("{}", app);
}
println!("**************/")
}
进程标识符和内核栈
------------------------------------------------------------------------
进程标识符
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
同一时间存在的所有进程都有一个自己的进程标识符,它们是互不相同的整数。这里将其抽象为一个 ``PidHandle``
类型,当它的生命周期结束后,对应的整数会被编译器自动回收:
.. code-block:: rust
// os/src/task/pid.rs
pub struct PidHandle(pub usize);
类似之前的物理页帧分配器 ``FrameAllocator`` ,我们实现一个同样使用简单栈式分配策略的进程标识符分配器
``PidAllocator`` ,并将其全局实例化为 ``PID_ALLOCATOR``
.. code-block:: rust
// os/src/task/pid.rs
struct PidAllocator {
current: usize,
recycled: Vec<usize>,
}
impl PidAllocator {
pub fn new() -> Self {
PidAllocator {
current: 0,
recycled: Vec::new(),
}
}
pub fn alloc(&mut self) -> PidHandle {
if let Some(pid) = self.recycled.pop() {
PidHandle(pid)
} else {
self.current += 1;
PidHandle(self.current - 1)
}
}
pub fn dealloc(&mut self, pid: usize) {
assert!(pid < self.current);
assert!(
self.recycled.iter().find(|ppid| **ppid == pid).is_none(),
"pid {} has been deallocated!", pid
);
self.recycled.push(pid);
}
}
lazy_static! {
static ref PID_ALLOCATOR: UPSafeCell<PidAllocator> =
unsafe { UPSafeCell::new(PidAllocator::new()) };
}
``PidAllocator::alloc`` 将会分配出去一个将 ``usize`` 包装之后的 ``PidHandle``
我们将其包装为一个全局分配进程标识符的接口 ``pid_alloc``
.. code-block:: rust
// os/src/task/pid.rs
pub fn pid_alloc() -> PidHandle {
PID_ALLOCATOR.exclusive_access().alloc()
}
同时我们也需要为 ``PidHandle`` 实现 ``Drop`` Trait 来允许编译器进行自动的资源回收:
.. code-block:: rust
// os/src/task/pid.rs
impl Drop for PidHandle {
fn drop(&mut self) {
//println!("drop pid {}", self.0);
PID_ALLOCATOR.exclusive_access().dealloc(self.0);
}
}
内核栈
~~~~~~~~~~~~~~~~~~~~~~
从本章开始,我们将应用编号替换为进程标识符来决定每个进程内核栈在地址空间中的位置。
在内核栈 ``KernelStack`` 中保存着它所属进程的 PID
.. code-block:: rust
// os/src/task/pid.rs
pub struct KernelStack {
pid: usize,
}
它提供以下方法:
.. code-block:: rust
:linenos:
// os/src/task/pid.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)
}
impl KernelStack {
pub fn new(pid_handle: &PidHandle) -> Self {
let pid = pid_handle.0;
let (kernel_stack_bottom, kernel_stack_top) = kernel_stack_position(pid);
KERNEL_SPACE.exclusive_access().insert_framed_area(
kernel_stack_bottom.into(),
kernel_stack_top.into(),
MapPermission::R | MapPermission::W,
);
KernelStack {
pid: pid_handle.0,
}
}
pub fn push_on_top<T>(&self, value: T) -> *mut T where
T: Sized, {
let kernel_stack_top = self.get_top();
let ptr_mut = (kernel_stack_top - core::mem::size_of::<T>()) as *mut T;
unsafe { *ptr_mut = value; }
ptr_mut
}
pub fn get_top(&self) -> usize {
let (_, kernel_stack_top) = kernel_stack_position(self.pid);
kernel_stack_top
}
}
- 第 11 行, ``new`` 方法可以从一个 ``PidHandle`` ,也就是一个已分配的进程标识符中对应生成一个内核栈 ``KernelStack``
它调用了第 4 行声明的 ``kernel_stack_position`` 函数来根据进程标识符计算内核栈在内核地址空间中的位置,
随即在第 14 行将一个逻辑段插入内核地址空间 ``KERNEL_SPACE`` 中。
- 第 25 行的 ``push_on_top`` 方法可以将一个类型为 ``T`` 的变量压入内核栈顶并返回其裸指针,
这也是一个泛型函数。它在实现的时候用到了第 32 行的 ``get_top`` 方法来获取当前内核栈顶在内核地址空间中的地址。
内核栈 ``KernelStack`` 用到了 RAII 的思想,具体来说,实际保存它的物理页帧的生命周期被绑定到它下面,当
``KernelStack`` 生命周期结束后,这些物理页帧也将会被编译器自动回收:
.. code-block:: rust
// os/src/task/pid.rs
impl Drop for KernelStack {
fn drop(&mut self) {
let (kernel_stack_bottom, _) = kernel_stack_position(self.pid);
let kernel_stack_bottom_va: VirtAddr = kernel_stack_bottom.into();
KERNEL_SPACE
.exclusive_access()
.remove_area_with_start_vpn(kernel_stack_bottom_va.into());
}
}
``KernelStack`` 实现 ``Drop`` Trait一旦它的生命周期结束就将内核地址空间中对应的逻辑段删除为此在 ``MemorySet``
中新增了一个名为 ``remove_area_with_start_vpn`` 的方法,感兴趣的读者可以查阅。
进程控制块
------------------------------------------------------------------------
在内核中,每个进程的执行状态、资源控制等元数据均保存在一个被称为 **进程控制块** (PCB, Process Control Block)
的结构中,它是内核对进程进行管理的单位。在内核看来,它就等价于一个进程。
承接前面的章节,我们仅需对任务控制块 ``TaskControlBlock`` 进行若干改动,让它直接承担进程控制块的功能:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
pub struct TaskControlBlock {
// immutable
pub pid: PidHandle,
pub kernel_stack: KernelStack,
// mutable
inner: UPSafeCell<TaskControlBlockInner>,
}
pub struct TaskControlBlockInner {
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
pub task_cx: TaskContext,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub parent: Option<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
pub exit_code: i32,
}
任务控制块中包含两部分:
- 在初始化之后就不再变化的作为一个字段直接放在任务控制块中。这里将进程标识符 ``PidHandle`` 和内核栈 ``KernelStack`` 放在其中;
- 在运行过程中可能发生变化的则放在 ``TaskControlBlockInner`` 中,将它再包裹上一层 ``UPSafeCell<T>`` 放在任务控制块中。
在此使用 ``UPSafeCell<T>`` 可以提供互斥从而避免数据竞争。
``TaskControlBlockInner`` 中包含下面这些内容:
- ``trap_cx_ppn`` 指出了应用地址空间中的 Trap 上下文被放在的物理页帧的物理页号。
- ``base_size`` 的含义是:应用数据仅有可能出现在应用地址空间低于 ``base_size`` 字节的区域中。借助它我们可以清楚的知道应用有多少数据驻留在内存中。
- ``task_cx`` 保存任务上下文,用于任务切换。
- ``task_status`` 维护当前进程的执行状态。
- ``memory_set`` 表示应用地址空间。
- ``parent`` 指向当前进程的父进程(如果存在的话)。注意我们使用 ``Weak`` 而非 ``Arc``
来包裹另一个任务控制块,因此这个智能指针将不会影响父进程的引用计数。
- ``children`` 则将当前进程的所有子进程的任务控制块以 ``Arc`` 智能指针的形式保存在一个向量中,这样才能够更方便的找到它们。
- 当进程调用 exit 系统调用主动退出或者执行出错由内核终止的时候,它的退出码 ``exit_code``
会被内核保存在它的任务控制块中,并等待它的父进程通过 waitpid 回收它的资源的同时也收集它的 PID 以及退出码。
注意我们在维护父子进程关系的时候大量用到了智能指针 ``Arc/Weak`` ,当且仅当它的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收。
``TaskControlBlockInner`` 提供的方法主要是对于它内部字段的快捷访问:
.. code-block:: rust
// os/src/task/task.rs
impl TaskControlBlockInner {
pub fn get_trap_cx(&self) -> &'static mut TrapContext {
self.trap_cx_ppn.get_mut()
}
pub fn get_user_token(&self) -> usize {
self.memory_set.token()
}
fn get_status(&self) -> TaskStatus {
self.task_status
}
pub fn is_zombie(&self) -> bool {
self.get_status() == TaskStatus::Zombie
}
}
而任务控制块 ``TaskControlBlock`` 目前提供以下方法:
.. code-block:: rust
// os/src/task/task.rs
impl TaskControlBlock {
pub fn inner_exclusive_access(&self) -> RefMut<'_, TaskControlBlockInner> {
self.inner.exclusive_access()
}
pub fn getpid(&self) -> usize {
self.pid.0
}
pub fn new(elf_data: &[u8]) -> Self {...}
pub fn exec(&self, elf_data: &[u8]) {...}
pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {...}
}
- ``inner_exclusive_access`` 尝试获取互斥锁来得到 ``TaskControlBlockInner`` 的可变引用。
- ``getpid````usize`` 的形式返回当前进程的进程标识符。
- ``new`` 用来创建一个新的进程,目前仅用于内核中手动创建唯一一个初始进程 ``initproc``
- ``exec`` 用来实现 ``exec`` 系统调用,即当前进程加载并执行另一个 ELF 格式可执行文件。
- ``fork`` 用来实现 ``fork`` 系统调用,即当前进程 fork 出来一个与之几乎相同的子进程。
``new/exec/fork`` 的实现我们将在下一小节再介绍。
任务管理器
------------------------------------------------------------------------
在前面的章节中,任务管理器 ``TaskManager`` 不仅负责管理所有的任务,还维护着 CPU 当前在执行哪个任务。
由于这种设计不够灵活,我们需要将任务管理器对于 CPU 的监控职能拆分到处理器管理结构 ``Processor`` 中去,
任务管理器自身仅负责管理所有任务。在这里,任务指的就是进程。
.. code-block:: rust
:linenos:
// os/src/task/manager.rs
pub struct TaskManager {
ready_queue: VecDeque<Arc<TaskControlBlock>>,
}
/// A simple FIFO scheduler.
impl TaskManager {
pub fn new() -> Self {
Self {
ready_queue: VecDeque::new(),
}
}
pub fn add(&mut self, task: Arc<TaskControlBlock>) {
self.ready_queue.push_back(task);
}
pub fn fetch(&mut self) -> Option<Arc<TaskControlBlock>> {
self.ready_queue.pop_front()
}
}
lazy_static! {
pub static ref TASK_MANAGER: UPSafeCell<TaskManager> =
unsafe { UPSafeCell::new(TaskManager::new()) };
}
pub fn add_task(task: Arc<TaskControlBlock>) {
TASK_MANAGER.exclusive_access().add(task);
}
pub fn fetch_task() -> Option<Arc<TaskControlBlock>> {
TASK_MANAGER.exclusive_access().fetch()
}
``TaskManager`` 将所有的任务控制块用引用计数 ``Arc`` 智能指针包裹后放在一个双端队列 ``VecDeque`` 中。
使用智能指针的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销,
而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。
``TaskManager`` 提供 ``add/fetch`` 两个操作,前者表示将一个任务加入队尾,后者则表示从队头中取出一个任务来执行。
从调度算法来看,这里用到的就是最简单的 RR 算法。全局实例 ``TASK_MANAGER`` 则提供给内核的其他子模块 ``add_task/fetch_task`` 两个函数。
处理器管理结构
------------------------------------------------------------------------
处理器管理结构 ``Processor`` 负责维护从任务管理器 ``TaskManager`` 分离出去的那部分 CPU 状态:
.. code-block:: rust
// os/src/task/processor.rs
pub struct Processor {
current: Option<Arc<TaskControlBlock>>,
idle_task_cx: TaskContext,
}
包括:
- ``current`` 表示在当前处理器上正在执行的任务;
- ``idle_task_cx_ptr`` 表示当前处理器上的 idle 控制流的任务上下文的地址。
在单核环境下,我们仅创建单个 ``Processor`` 的全局实例 ``PROCESSOR``
.. code-block:: rust
// os/src/task/processor.rs
lazy_static! {
pub static ref PROCESSOR: UPSafeCell<Processor> = unsafe { UPSafeCell::new(Processor::new()) };
}
正在执行的任务
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
:linenos:
// os/src/task/processor.rs
impl Processor {
pub fn take_current(&mut self) -> Option<Arc<TaskControlBlock>> {
self.current.take()
}
pub fn current(&self) -> Option<Arc<TaskControlBlock>> {
self.current.as_ref().map(|task| Arc::clone(task))
}
}
pub fn take_current_task() -> Option<Arc<TaskControlBlock>> {
PROCESSOR.take_current()
}
pub fn current_task() -> Option<Arc<TaskControlBlock>> {
PROCESSOR.current()
}
pub fn current_user_token() -> usize {
let task = current_task().unwrap();
let token = task.inner_exclusive_access().get_user_token();
token
}
pub fn current_trap_cx() -> &'static mut TrapContext {
current_task()
.unwrap()
.inner_exclusive_access()
.get_trap_cx()
}
- 第 4 行的 ``Processor::take_current`` 可以取出当前正在执行的任务。 ``Option::take`` 意味着 ``current`` 字段也变为 ``None``
- 第 7 行的 ``Processor::current`` 返回当前执行的任务的一份拷贝。。
- ``current_user_token````current_trap_cx`` 基于 ``current_task`` 实现,提供当前正在执行的任务的更多信息。
任务调度的 idle 控制流
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
每个 ``Processor`` 都有一个 idle 控制流,它们运行在每个核各自的启动栈上,功能是尝试从任务管理器中选出一个任务来在当前核上执行。
在内核初始化完毕之后,核通过调用 ``run_tasks`` 函数来进入 idle 控制流:
.. code-block:: rust
:linenos:
// os/src/task/processor.rs
impl Processor {
fn get_idle_task_cx_ptr(&mut self) -> *mut TaskContext {
&mut self.idle_task_cx as *mut _
}
}
pub fn run_tasks() {
loop {
let mut processor = PROCESSOR.exclusive_access();
if let Some(task) = fetch_task() {
let idle_task_cx_ptr = processor.get_idle_task_cx_ptr();
// access coming task TCB exclusively
let mut task_inner = task.inner_exclusive_access();
let next_task_cx_ptr = &task_inner.task_cx as *const TaskContext;
task_inner.task_status = TaskStatus::Running;
drop(task_inner);
// release coming task TCB manually
processor.current = Some(task);
// release processor manually
drop(processor);
unsafe {
__switch(idle_task_cx_ptr, next_task_cx_ptr);
}
}
}
}
调度功能的主体在 ``run_tasks`` 中实现。它循环调用 ``fetch_task`` 直到顺利从任务管理器中取出一个任务,然后获得
``__switch`` 两个参数进行任务切换。注意在整个过程中要严格控制临界区。
当一个应用交出 CPU 使用权时,进入内核后它会调用 ``schedule`` 函数来切换到 idle 控制流并开启新一轮的任务调度。
.. code-block:: rust
// os/src/task/processor.rs
pub fn schedule(switched_task_cx_ptr: *mut TaskContext) {
let mut processor = PROCESSOR.exclusive_access();
let idle_task_cx_ptr = processor.get_idle_task_cx_ptr();
drop(processor);
unsafe {
__switch(switched_task_cx_ptr, idle_task_cx_ptr);
}
}
切换回去之后,我们将跳转到 ``Processor::run````__switch`` 返回之后的位置,也即开启了下一轮循环。

View File

@@ -0,0 +1,665 @@
进程管理机制的设计实现
============================================
本节导读
--------------------------------------------
本节将介绍如何基于上一节设计的内核数据结构来实现进程管理:
- 初始进程 ``initproc`` 的创建;
- 进程调度机制:当进程主动调用 ``sys_yield`` 交出 CPU 使用权,或者内核本轮分配的时间片用尽之后如何切换到下一个进程;
- 进程生成机制:介绍进程相关的两个重要系统调用 ``sys_fork/sys_exec`` 的实现;
- 字符输入机制:介绍 ``sys_read`` 系统调用的实现;
- 进程资源回收机制:当进程调用 ``sys_exit`` 正常退出或者出错被内核终止后,如何保存其退出码,其父进程又是如何通过
``sys_waitpid`` 收集该进程的信息并回收其资源。
初始进程的创建
--------------------------------------------
内核初始化完毕之后,即会调用 ``task`` 子模块提供的 ``add_initproc`` 函数来将初始进程 ``initproc``
加入任务管理器,但在这之前,我们需要初始进程的进程控制块 ``INITPROC`` ,这基于 ``lazy_static`` 在运行时完成。
.. code-block:: rust
// os/src/task/mod.rs
lazy_static! {
pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new(TaskControlBlock::new(
get_app_data_by_name("initproc").unwrap()
));
}
pub fn add_initproc() {
add_task(INITPROC.clone());
}
我们调用 ``TaskControlBlock::new`` 来创建一个进程控制块,它需要传入 ELF 可执行文件的数据切片作为参数,
这可以通过加载器 ``loader`` 子模块提供的 ``get_app_data_by_name`` 接口查找 ``initproc`` 的 ELF 数据来获得。在初始化
``INITPROC`` 之后,则在 ``add_initproc`` 中可以调用 ``task`` 的任务管理器 ``manager`` 子模块提供的 ``add_task`` 接口将其加入到任务管理器。
接下来介绍 ``TaskControlBlock::new`` 是如何实现的:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
use super::{PidHandle, pid_alloc, KernelStack};
use super::TaskContext;
use crate::config::TRAP_CONTEXT;
use crate::trap::TrapContext;
// impl TaskControlBlock
pub fn new(elf_data: &[u8]) -> 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();
// alloc a pid and a kernel stack in kernel space
let pid_handle = pid_alloc();
let kernel_stack = KernelStack::new(&pid_handle);
let kernel_stack_top = kernel_stack.get_top();
// push a task context which goes to trap_return to the top of kernel stack
let task_cx_ptr = kernel_stack.push_on_top(TaskContext::goto_trap_return());
let task_control_block = Self {
pid: pid_handle,
kernel_stack,
inner: unsafe { UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: user_sp,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
})
},
};
// prepare TrapContext in user space
let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
kernel_stack_top,
trap_handler as usize,
);
task_control_block
}
- 第 10 行,解析 ELF 得到应用地址空间 ``memory_set`` ,用户栈在应用地址空间中的位置 ``user_sp`` 以及应用的入口点 ``entry_point``
- 第 11 行,手动查页表找到应用地址空间中的 Trap 上下文实际所在的物理页帧。
- 第 16~18 行,为新进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置 ``kernel_stack_top``
- 第 20 行,在该进程的内核栈上压入初始化的任务上下文,使得第一次任务切换到它的时候可以跳转到 ``trap_return`` 并进入用户态开始执行。
- 第 21 行,整合之前的部分信息创建进程控制块 ``task_control_block``
- 第 39 行,初始化位于该进程应用地址空间中的 Trap 上下文,使得第一次进入用户态时,能正确跳转到应用入口点并设置好用户栈,
同时也保证在 Trap 的时候用户态能正确进入内核态。
进程调度机制
--------------------------------------------
调用 ``task`` 子模块提供的 ``suspend_current_and_run_next`` 函数可以暂停当前任务,并切换到下一个任务,下面给出了两种典型的使用场景:
.. code-block:: rust
:emphasize-lines: 4,18
// os/src/syscall/process.rs
pub fn sys_yield() -> isize {
suspend_current_and_run_next();
0
}
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Interrupt(Interrupt::SupervisorTimer) => {
set_next_trigger();
suspend_current_and_run_next();
}
...
}
trap_return();
}
随着进程概念的引入, ``suspend_current_and_run_next`` 的实现也需要发生变化:
.. code-block:: rust
:linenos:
// os/src/task/mod.rs
use processor::{task_current_task, schedule};
use manager::add_task;
pub fn suspend_current_and_run_next() {
// There must be an application running.
let task = take_current_task().unwrap();
// ---- access current TCB exclusively
let mut task_inner = task.inner_exclusive_access();
let task_cx_ptr = &mut task_inner.task_cx as *mut TaskContext;
// Change status to Ready
task_inner.task_status = TaskStatus::Ready;
drop(task_inner);
// ---- release current PCB
// push back to ready queue.
add_task(task);
// jump to scheduling cycle
schedule(task_cx_ptr);
}
首先通过 ``take_current_task`` 来取出当前正在执行的任务,修改其进程控制块内的状态,随后将这个任务放入任务管理器的队尾。接着调用
``schedule`` 函数来触发调度并切换任务。当仅有一个任务的时候, ``suspend_current_and_run_next`` 的效果是会继续执行这个任务。
进程的生成机制
--------------------------------------------
fork 系统调用的实现
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
实现 fork 时,最为关键且困难一点的是为子进程创建一个和父进程几乎完全相同的地址空间。我们的实现如下:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MapArea {
pub fn from_another(another: &MapArea) -> Self {
Self {
vpn_range: VPNRange::new(
another.vpn_range.get_start(),
another.vpn_range.get_end()
),
data_frames: BTreeMap::new(),
map_type: another.map_type,
map_perm: another.map_perm,
}
}
}
impl MemorySet {
pub fn from_existed_user(user_space: &MemorySet) -> MemorySet {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// copy data sections/trap_context/user_stack
for area in user_space.areas.iter() {
let new_area = MapArea::from_another(area);
memory_set.push(new_area, None);
// copy data from another space
for vpn in area.vpn_range {
let src_ppn = user_space.translate(vpn).unwrap().ppn();
let dst_ppn = memory_set.translate(vpn).unwrap().ppn();
dst_ppn.get_bytes_array().copy_from_slice(src_ppn.get_bytes_array());
}
}
memory_set
}
}
这需要对内存管理子模块 ``mm`` 做一些拓展:
- 第 4 行的 ``MapArea::from_another`` 可以从一个逻辑段复制得到一个虚拟地址区间、映射方式和权限控制均相同的逻辑段,
不同的是由于它还没有真正被映射到物理页帧上,所以 ``data_frames`` 字段为空。
- 第 18 行的 ``MemorySet::from_existed_user`` 可以复制一个完全相同的地址空间。首先在第 19 行,我们通过 ``new_bare``
新创建一个空的地址空间,并在第 21 行通过 ``map_trampoline`` 为这个地址空间映射上跳板页面,这是因为我们解析 ELF
创建地址空间的时候,并没有将跳板页作为一个单独的逻辑段插入到地址空间的逻辑段向量 ``areas`` 中,所以这里需要单独映射上。
剩下的逻辑段都包含在 ``areas`` 中。我们遍历原地址空间中的所有逻辑段,将复制之后的逻辑段插入新的地址空间,
在插入的时候就已经实际分配了物理页帧了。接着我们遍历逻辑段中的每个虚拟页面,对应完成数据复制,
这只需要找出两个地址空间中的虚拟页面各被映射到哪个物理页帧,就可转化为将数据从物理内存中的一个位置复制到另一个位置,使用
``copy_from_slice`` 即可轻松实现。
接着,我们实现 ``TaskControlBlock::fork`` 来从父进程的进程控制块创建一份子进程的控制块:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
impl TaskControlBlock {
pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {
// ---- access parent PCB exclusively
let mut parent_inner = self.inner_exclusive_access();
// copy user space(include trap context)
let memory_set = MemorySet::from_existed_user(&parent_inner.memory_set);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// alloc a pid and a kernel stack in kernel space
let pid_handle = pid_alloc();
let kernel_stack = KernelStack::new(&pid_handle);
let kernel_stack_top = kernel_stack.get_top();
let task_control_block = Arc::new(TaskControlBlock {
pid: pid_handle,
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: parent_inner.base_size,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: Some(Arc::downgrade(self)),
children: Vec::new(),
exit_code: 0,
})
},
});
// add child
parent_inner.children.push(task_control_block.clone());
// modify kernel_sp in trap_cx
// **** access children PCB exclusively
let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
trap_cx.kernel_sp = kernel_stack_top;
// return
task_control_block
// ---- release parent PCB automatically
// **** release children PCB automatically
}
}
它基本上和新建进程控制块的 ``TaskControlBlock::new`` 是相同的,但要注意以下几点:
- 子进程的地址空间不是通过解析 ELF而是通过在第 8 行调用 ``MemorySet::from_existed_user`` 复制父进程地址空间得到的;
- 在 fork 的时候需要注意父子进程关系的维护。既要将父进程的弱引用计数放到子进程的进程控制块中,又要将子进程插入到父进程的孩子向量 ``children`` 中。
实现 ``sys_fork`` 时,我们需要特别注意如何体现父子进程的差异:
.. code-block:: rust
:linenos:
// os/src/syscall/process.rs
pub fn sys_fork() -> isize {
let current_task = current_task().unwrap();
let new_task = current_task.fork();
let new_pid = new_task.pid.0;
// modify trap context of new_task, because it returns immediately after switching
let trap_cx = new_task.inner_exclusive_access().get_trap_cx();
// we do not have to move to next instruction since we have done it before
// for child process, fork returns 0
trap_cx.x[10] = 0;
// add new task to scheduler
add_task(new_task);
new_pid as isize
}
在调用 ``sys_fork`` 之前,我们已经将当前进程 Trap 上下文中的 sepc 向后移动了 4 字节,使得它回到用户态之后会从 ecall
的下一条指令开始执行。之后,当我们复制地址空间时,子进程地址空间 Trap 上下文的 sepc 也是移动之后的值,我们无需再进行修改。
父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态,但二者返回值不同。第 8~11 行我们将子进程的 Trap
上下文中用来存放系统调用返回值的 a0 寄存器修改为 0 ,而父进程系统调用的返回值会在 ``syscall`` 返回之后再设置为 ``sys_fork``
的返回值。这就做到了父进程 ``fork`` 的返回值为子进程的 PID ,而子进程的返回值为 0。
exec 系统调用的实现
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``exec`` 系统调用使得一个进程能够加载一个新的 ELF 可执行文件替换原有的应用地址空间并开始执行。我们先从进程控制块的层面进行修改:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
impl TaskControlBlock {
pub fn exec(&self, elf_data: &[u8]) {
// 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();
// **** access inner exclusively
let mut inner = self.inner_exclusive_access();
// substitute memory_set
inner.memory_set = memory_set;
// update trap_cx ppn
inner.trap_cx_ppn = trap_cx_ppn;
// initialize trap_cx
let trap_cx = inner.get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
// **** release inner automatically
}
}
它在解析传入的 ELF 格式数据之后只做了两件事情:
- 首先从 ELF 生成一个全新的地址空间并直接替换进来(第 15 行),这将导致原有地址空间生命周期结束,里面包含的全部物理页帧都会被回收;
- 然后修改新的地址空间中的 Trap 上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现 Trap 机制。
``sys_exec`` 的实现如下,它调用 ``translated_str`` 找到要执行的应用名,并试图从应用加载器提供的 ``get_app_data_by_name``
接口中获取对应的 ELF 数据,如果找到的话就调用 ``TaskControlBlock::exec`` 替换地址空间。
.. code-block:: rust
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
if let Some(data) = get_app_data_by_name(path.as_str()) {
let task = current_task().unwrap();
task.exec(data);
0
} else {
-1
}
}
应用在 ``sys_exec`` 系统调用中传递给内核的只有一个应用名字符串在用户地址空间中的首地址,内核必限手动查页表来获得字符串的值。
``translated_str`` 用来从用户地址空间中查找字符串,其原理就是逐字节查页表直到发现一个 ``\0`` 为止。为什么要逐字节查页表?
因为内核不知道字符串的长度,且字符串可能是跨物理页的。
.. code-block:: rust
// os/src/mm/page_table.rs
pub fn translated_str(token: usize, ptr: *const u8) -> String {
let page_table = PageTable::from_token(token);
let mut string = String::new();
let mut va = ptr as usize;
loop {
let ch: u8 = *(page_table.translate_va(VirtAddr::from(va)).unwrap().get_mut());
if ch == 0 {
break;
} else {
string.push(ch as char);
va += 1;
}
}
string
}
系统调用后重新获取 Trap 上下文
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
原来在 ``trap_handler`` 中我们是这样处理系统调用的:
.. code-block:: rust
// os/src/trap/mod.rs
#[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::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_return();
}
这里的 ``cx`` 是当前应用的 Trap 上下文的可变引用,我们需要通过查页表找到它具体被放在哪个物理页帧上,
并构造相同的虚拟地址来在内核中访问它。对于系统调用 ``sys_exec`` 来说,调用它之后, ``trap_handler``
原来上下文中的 ``cx`` 失效了,因为它是就原来的地址空间而言的。为了能够处理类似的这种情况,我们在 ``syscall``
返回之后需要重新获取 ``cx`` ,目前的实现如下:
.. code-block:: rust
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Exception(Exception::UserEnvCall) => {
// jump to next instruction anyway
let mut cx = current_trap_cx();
cx.sepc += 4;
// get system call return value
let result = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]);
// cx is changed during sys_exec, so we have to call it again
cx = current_trap_cx();
cx.x[10] = result as usize;
}
...
}
trap_return();
}
sys_read 获取输入
--------------------------------------------
我们需要实现 ``sys_read`` 系统调用,使应用能够取得用户的键盘输入。
.. code-block:: rust
// os/src/syscall/fs.rs
use crate::sbi::console_getchar;
const FD_STDIN: usize = 0;
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
match fd {
FD_STDIN => {
assert_eq!(len, 1, "Only support len = 1 in sys_read!");
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
let mut buffers = translated_byte_buffer(current_user_token(), buf, len);
unsafe { buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
_ => {
panic!("Unsupported fd in sys_read!");
}
}
}
目前我们仅支持从标准输入 ``FD_STDIN`` 即文件描述符 0 读入,且每次只能读入一个字符,这是利用 ``sbi``
提供的接口 ``console_getchar`` 实现的。如果还没有输入,我们就切换到其他进程,等下次切换回来时再看看是否有输入了。
获取到输入后就退出循环,并手动查页表将输入字符正确写入到应用地址空间。
进程资源回收机制
--------------------------------------------
进程的退出
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
当应用调用 ``sys_exit`` 系统调用主动退出,或者出错由内核终止之后,会在内核中调用 ``exit_current_and_run_next`` 函数:
.. code-block:: rust
:linenos:
:emphasize-lines: 4,29,34
// os/src/syscall/process.rs
pub fn sys_exit(exit_code: i32) -> ! {
exit_current_and_run_next(exit_code);
panic!("Unreachable in sys_exit!");
}
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Exception(Exception::StoreFault) |
Trap::Exception(Exception::StorePageFault) |
Trap::Exception(Exception::InstructionFault) |
Trap::Exception(Exception::InstructionPageFault) |
Trap::Exception(Exception::LoadFault) |
Trap::Exception(Exception::LoadPageFault) => {
println!(
"[kernel] {:?} in application, bad addr = {:#x}, bad instruction = {:#x}, core dumped.",
scause.cause(),
stval,
current_trap_cx().sepc,
);
// page fault exit code
exit_current_and_run_next(-2);
}
Trap::Exception(Exception::IllegalInstruction) => {
println!("[kernel] IllegalInstruction in application, core dumped.");
// illegal instruction exit code
exit_current_and_run_next(-3);
}
...
}
trap_return();
}
相比前面的章节, ``exit_current_and_run_next`` 带有一个退出码作为参数,这个退出码会在
``exit_current_and_run_next`` 写入当前进程的进程控制块:
.. code-block:: rust
:linenos:
// os/src/mm/memory_set.rs
impl MemorySet {
pub fn recycle_data_pages(&mut self) {
self.areas.clear();
}
}
// os/src/task/mod.rs
pub fn exit_current_and_run_next(exit_code: i32) {
// take from Processor
let task = take_current_task().unwrap();
// **** access current TCB exclusively
let mut inner = task.inner_exclusive_access();
// Change status to Zombie
inner.task_status = TaskStatus::Zombie;
// Record exit code
inner.exit_code = exit_code;
// do not move to its parent but under initproc
// ++++++ access initproc TCB exclusively
{
let mut initproc_inner = INITPROC.inner_exclusive_access();
for child in inner.children.iter() {
child.inner_exclusive_access().parent = Some(Arc::downgrade(&INITPROC));
initproc_inner.children.push(child.clone());
}
}
// ++++++ release parent PCB
inner.children.clear();
// deallocate user space
inner.memory_set.recycle_data_pages();
drop(inner);
// **** release current PCB
// drop task manually to maintain rc correctly
drop(task);
// we do not have to save task context
let mut _unused = TaskContext::zero_init();
schedule(&mut _unused as *mut _);
}
- 第 13 行,调用 ``take_current_task`` 来将当前进程控制块从处理器监控 ``PROCESSOR``
中取出,而不只是得到一份拷贝,这是为了正确维护进程控制块的引用计数;
- 第 17 行将进程控制块中的状态修改为 ``TaskStatus::Zombie`` 即僵尸进程;
- 第 19 行将传入的退出码 ``exit_code`` 写入进程控制块中,后续父进程在 ``waitpid`` 的时候可以收集;
- 第 24~26 行所做的事情是,将当前进程的所有子进程挂在初始进程 ``initproc`` 下面。第 32 行将当前进程的孩子向量清空。
- 第 34 行,对于当前进程占用的资源进行早期回收。 ``MemorySet::recycle_data_pages`` 只是将地址空间中的逻辑段列表
``areas`` 清空,这将导致应用地址空间的所有数据被存放在的物理页帧被回收,而用来存放页表的那些物理页帧此时则不会被回收。
- 最后在第 41 行我们调用 ``schedule`` 触发调度及任务切换,我们再也不会回到该进程的执行过程,因此无需关心任务上下文的保存。
父进程回收子进程资源
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
:linenos:
// os/src/syscall/process.rs
/// If there is not a child process whose pid is same as given, return -1.
/// Else if there is a child process but it is still running, return -2.
pub fn sys_waitpid(pid: isize, exit_code_ptr: *mut i32) -> isize {
let task = current_task().unwrap();
// find a child process
// ---- access current TCB exclusively
let mut inner = task.inner_exclusive_access();
if !inner
.children
.iter()
.any(|p| pid == -1 || pid as usize == p.getpid())
{
return -1;
// ---- release current PCB
}
let pair = inner.children.iter().enumerate().find(|(_, p)| {
// ++++ temporarily access child PCB lock exclusively
p.inner_exclusive_access().is_zombie() && (pid == -1 || pid as usize == p.getpid())
// ++++ release child PCB
});
if let Some((idx, _)) = pair {
let child = inner.children.remove(idx);
// confirm that child will be deallocated after removing from children list
assert_eq!(Arc::strong_count(&child), 1);
let found_pid = child.getpid();
// ++++ temporarily access child TCB exclusively
let exit_code = child.inner_exclusive_access().exit_code;
// ++++ release child PCB
*translated_refmut(inner.memory_set.token(), exit_code_ptr) = exit_code;
found_pid as isize
} else {
-2
}
// ---- release current PCB lock automatically
}
``sys_waitpid`` 是一个立即返回的系统调用,它的返回值语义是:如果当前的进程不存在一个符合要求的子进程,则返回
-1如果至少存在一个但是其中没有僵尸进程也即仍未退出则返回 -2如果都不是的话则可以正常回收并返回回收子进程的
pid 。但在编写应用的开发者看来, ``wait/waitpid`` 两个辅助函数都必定能够返回一个有意义的结果,要么是 -1要么是一个正数
PID ,是不存在 -2 这种通过等待即可消除的中间结果的。等待的过程由用户库 ``user_lib`` 完成。
首先判断 ``sys_waitpid`` 是否会返回 -1 ,这取决于当前进程是否有一个符合要求的子进程。当传入的 ``pid`` 为 -1
的时候,任何一个子进程都算是符合要求;但 ``pid`` 不为 -1 的时候,则只有 PID 恰好与 ``pid``
相同的子进程才算符合条件。我们简单通过迭代器即可完成判断。
再判断符合要求的子进程中是否有僵尸进程。如果找不到的话直接返回 ``-2`` ,否则进行下一步处理:
我们将子进程从向量中移除并置于当前上下文中,当它所在的代码块结束,这次引用变量的生命周期结束,子进程进程控制块的引用计数将变为
0 ,内核将彻底回收掉它占用的所有资源,包括内核栈、它的 PID 、存放页表的那些物理页帧等等。
获得子进程退出码后,考虑到应用传入的指针指向应用地址空间,我们还需要手动查页表找到对应物理内存中的位置。
``translated_refmut`` 的实现可以在 ``os/src/mm/page_table.rs`` 中找到。

View File

@@ -0,0 +1,137 @@
chapter5练习
==============================================
编程作业
---------------------------------------------
进程创建
+++++++++++++++++++++++++++++++++++++++++++++
大家一定好奇过为啥进程创建要用 fork + exec 这么一个奇怪的系统调用,就不能直接搞一个新进程吗?
思而不学则殆,我们就来试一试!这章的编程练习请大家实现一个完全 DIY 的系统调用 spawn用以创建一个新进程。
spawn 系统调用定义( `标准spawn看这里 <https://man7.org/linux/man-pages/man3/posix_spawn.3.html>`_ )
.. code-block:: rust
fn sys_spawn(path: *const u8) -> isize
- syscall ID: 400
- 功能:新建子进程,使其执行目标程序。
- 说明成功返回子进程id否则返回 -1。
- 可能的错误:
- 无效的文件名。
- 进程池满/内存不足等资源错误。
TIPS虽然测例很简单但提醒读者 spawn **不必** 像 fork 一样复制父进程的地址空间。
stride 调度算法
+++++++++++++++++++++++++++++++++++++++++
ch3 中我们实现的调度算法十分简单。现在我们要为我们的 os 实现一种带优先级的调度算法stride 调度算法。
算法描述如下:
(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。
为了实现该调度算法,内核还要增加 set_prio 系统调用
.. code-block:: rust
// syscall ID140
// 设置当前进程优先级为 prio
// 参数prio 进程优先级,要求 prio >= 2
// 返回值:如果输入合法则返回 prio否则返回 -1
fn sys_set_priority(prio: isize) -> isize;
实现 tips:
- 你可以在TCB加入新的字段来支持优先级等。
- 为了减少整数除的误差BIG_STRIDE 一般需要很大,但为了不至于发生反转现象(详见问答作业),或许选择一个适中的数即可,当然能进行溢出处理就更好了。
- stride 算法要找到 stride 最小的进程,使用优先级队列是效率不错的办法,但是我们的实验测例很简单,所以效率完全不是问题。事实上,很推荐使用暴力扫一遍的办法找最小值。
- 注意设置进程的初始优先级。
.. attention::
为了让大家能在本编程作业中使用 ``Vec`` 等数据结构,我们利用第三方库 ``buddy_system_allocator``
为大家实现了堆内存分配器,相关代码位于 ``mm/heap_allocator`` 模块。
背景知识: `Rust 中的动态内存分配 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter4/1rust-dynamic-allocation.html>`_
实验要求
+++++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch5。
- 实验目录请参考 ch3。注意在reports中放入lab1-3的所有报告。
- 通过所有测例。
在 os 目录下 ``make run BASE=2`` 加载所有测例, ``ch5_usertest`` 打包了所有你需要通过的测例,
你也可以通过修改这个文件调整本地测试的内容, 或者单独运行某测例来纠正特定的错误。 ``ch5_stride``
检查 stride 调度算法是否满足公平性要求,六个子程序运行的次数应该大致与其优先级呈正比,测试通过标准是
:math:`\max{\frac{runtimes}{prio}}/ \min{\frac{runtimes}{prio}} < 1.5`.
CI 的原理是用 ``ch5_usertest`` 替代 ``ch5b_initproc`` ,使内核在所有测例执行完后直接退出。
从本章开始,你的内核必须前向兼容,能通过前一章的所有测例。
.. note::
利用 ``git cherry-pick`` 系列指令,能方便地将前一章分支 commit 移植到本章分支。
问答作业
--------------------------------------------
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<Stride> 的 pop
方法能返回真正最小的 Stride。补全下列代码中的 ``partial_cmp`` 函数,假设两个 Stride 永远不会相等。
.. 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 Stride {
fn eq(&self, other: &Self) -> bool {
false
}
}
TIPS: 使用 8 bits 存储 stride, BigStride = 255, 则: ``(125 < 255) == false``, ``(129 < 255) == true``.
报告要求
------------------------------------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

@@ -0,0 +1,12 @@
第五章:进程及进程管理
==============================================
.. toctree::
:maxdepth: 4
0intro
1process
2core-data-structures
3implement-process-mechanism
4exercise

View File

@@ -0,0 +1,159 @@
引言
=========================================
本章导读
-----------------------------------------
本章我们将实现一个简单的文件系统 -- easyfs能够对 **持久存储设备** (Persistent Storage) I/O 资源进行管理;将设计两种文件:常规文件和目录文件,它们均以文件系统所维护的 **磁盘文件** 形式被组织并保存在持久存储设备上。
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch6
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
内核初始化完成之后就会进入shell程序在这里我们运行一下本章的测例 ``ch6b_filetest_simple``
.. code-block::
>> ch6b_filetest_simple
file_test passed!
Shell: Process 2 exited with code 0
>>
它会将 ``Hello, world!`` 输出到另一个文件 ``filea`` ,并读取里面的内容确认输出正确。我们也可以通过命令行工具 ``ch6b_cat`` 来查看 ``filea`` 中的内容:
.. code-block::
>> ch6b_cat
Hello, world!
Shell: Process 2 exited with code 0
>>
本章代码树
-----------------------------------------
.. code-block::
:linenos:
├── easy-fs(新增:从内核中独立出来的一个简单的文件系统 EasyFileSystem 的实现)
│   ├── Cargo.toml
│   └── src
│   ├── bitmap.rs(位图抽象)
│   ├── block_cache.rs(块缓存层,将块设备中的部分块缓存在内存中)
│   ├── block_dev.rs(声明块设备抽象接口 BlockDevice需要库的使用者提供其实现)
│   ├── efs.rs(实现整个 EasyFileSystem 的磁盘布局)
│   ├── layout.rs(一些保存在磁盘上的数据结构的内存布局)
│   ├── lib.rs
│   └── vfs.rs(提供虚拟文件系统的核心抽象,即索引节点 Inode)
├── easy-fs-fuse(新增:将当前 OS 上的应用可执行文件按照 easy-fs 的格式进行打包)
│   ├── Cargo.toml
│   └── src
│   └── main.rs
├── os
   ├── build.rs(修改:不再需要将用户态程序链接到内核中)
   ├── Cargo.toml(修改:新增 Qemu 的块设备驱动依赖 crate)
   ├── Makefile(修改:新增文件系统的构建流程)
   └── src
   ├── config.rs(修改:新增访问块设备所需的一些 MMIO 配置)
   ├── ...
   ├── drivers(新增Qemu 平台的块设备驱动)
   │   ├── block
   │   │   ├── mod.rs(将不同平台上的块设备全局实例化为 BLOCK_DEVICE 提供给其他模块使用)
   │   │   └── virtio_blk.rs(Qemu 平台的 virtio-blk 块设备)
   │   └── mod.rs
   ├── fs(新增:对文件系统及文件抽象)
   │   ├── inode.rs(新增:将 easy-fs 提供的 Inode 抽象封装为内核看到的 OSInode
   │   │ 并实现 fs 子模块的 File Trait)
   │   ├── mod.rs
   │   └── stdio.rs(新增:将标准输入输出也抽象为文件)
   ├── loader.rs(移除:应用加载器 loader 子模块,本章开始从文件系统中加载应用)
   ├── mm
   │   ├── address.rs
   │   ├── frame_allocator.rs
   │   ├── heap_allocator.rs
   │   ├── memory_set.rs(修改:在创建地址空间的时候插入 MMIO 虚拟页面)
   │   ├── mod.rs
   │   └── page_table.rs(新增:应用地址空间的缓冲区抽象 UserBuffer 及其迭代器实现)
   ├── syscall
   │   ├── fs.rs(修改:新增 sys_open修改sys_read、sys_write)
   │   ├── mod.rs
   │   └── process.rs(修改sys_exec 改为从文件系统中加载 ELF)
   ├── task
      ├── context.rs
      ├── manager.rs
      ├── mod.rs(修改:初始进程 INITPROC 的初始化)
      ├── pid.rs
      ├── processor.rs
      ├── switch.rs
      ├── switch.S
      └── task.rs(修改:在任务控制块中加入文件描述符表的相关机制)
cloc easy-fs os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 41 306 418 3349
Assembly 4 53 26 526
make 1 13 4 48
TOML 2 4 2 23
-------------------------------------------------------------------------------
SUM: 48 376 450 3946
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章涉及的代码量相对较多且与进程执行相关的管理还有直接的关系。其实我们是参考经典的UNIX基于索引的文件系统设计了一个简化的有一级目录并支持创建/打开/读写/关闭文件一系列操作的文件系统。这里简要介绍一下在内核中添加文件系统的大致开发过程。
.. 第一步是能够写出与文件访问相关的应用。这里是参考了Linux的创建/打开/读写/关闭文件的系统调用接口,力图实现一个 :ref:`简化版的文件系统模型 <fs-simplification>` 。在用户态我们只需要遵从相关系统调用的接口约定,在用户库里完成对应的封装即可。这一过程我们在前面的章节中已经重复过多次,读者应当对其比较熟悉。其中最为关键的是系统调用可以参考 :ref:`sys_open 语义介绍 <sys-open>` ,此外我们还给出了 :ref:`测例代码解读 <filetest-simple>` 。
.. 第二步就是要实现 easyfs 文件系统了。由于 Rust 语言的特点,我们可以在用户态实现 easyfs 文件系统,并在用户态完成文件系统功能的基本测试并基本验证其实现正确性之后,就可以放心的将该模块嵌入到操作系统内核中。当然,有了文件系统的具体实现,还需要对上一章的操作系统内核进行扩展,实现与 easyfs 文件系统对接的接口,这样才可以让操作系统拥有一个简单可用的文件系统。从而,内核可以支持允许文件读写功能的更复杂的应用,在命令行参数机制的加持下,可以进一步提升整个系统的灵活性,让应用的开发和调试变得更为轻松。
.. easyfs 文件系统的整体架构自下而上可分为五层。它的最底层就是对块设备的访问操作接口。在 ``easy-fs/src/block_dev.rs`` 中,可以看到 ``BlockDevice`` trait 代表了一个抽象块设备,该 trait 仅需求两个函数 ``read_block`` 和 ``write_block`` 分别代表将数据从块设备读到内存中的缓冲区中或者将数据从内存中的缓冲区写回到块设备中数据需要以块为单位进行读写。easy-fs 库的使用者需要负责为它们看到的实际的块设备具体实现 ``BlockDevice`` trait 并提供给 easy-fs 库的上层,这样的话 easy-fs 库的最底层就与一个具体的执行环境对接起来了。至于为什么块设备层位于 easy-fs 的最底层,是因为文件系统仅仅是在块设备上存储的结构稍微复杂一点的数据,但无论它的操作变换如何复杂,从块设备的角度终究可以被分解成若干次块读写。
.. 尽管在最底层我们就已经有了块读写的能力,但从编程方便性和性能的角度,仅有块读写这么基础的底层接口是不足以实现如此复杂的文件系统的,虽然它已经被我们大幅简化过了。比如,将一个块的内容读到内存的缓冲区,对缓冲区进行修改,并尚未写回的时候,如果由于编程上的不小心再次将该块的内容读到另一个缓冲区,而不是使用已有的缓冲区,这将会造成不一致问题。此外还有可能增加很多不必要的块读写次数,大幅降低文件系统的性能。因此,通过程序自动而非程序员手动对块的缓冲区进行统一管理也就势在必行了,该机制被我们抽象为 easy-fs 自底向上的第二层,即块缓存层。在 ``easy-fs/src/block_cache.rs`` 中, ``BlockCache`` 代表一个被我们管理起来的块的缓冲区,它带有缓冲区本体以及块的编号等信息。当它被创建的时候,将触发一次 ``read_block`` 将数据从块设备读到它的缓冲区中。接下来只要它驻留在内存中,便可保证对于同一个块的所有操作都会直接在它的缓冲区中进行而无需额外的 ``read_block`` 。块缓存管理器 ``BlockManager`` 在内存中管理有限个 ``BlockCache`` 并实现了类似 FIFO 的缓存替换算法,当一个块缓存被换出的时候视情况可能调用 ``write_block`` 将缓冲区数据写回块设备。总之,块缓存层对上提供 ``get_block_cache`` 接口来屏蔽掉相关细节,从而可以透明的读写一个块。
.. 有了块缓存我们就可以在内存中方便地处理easyfs文件系统在磁盘上的各种数据了这就是第三层文件系统的磁盘数据结构。easyfs文件系统中的所有需要持久保存的数据都会放到磁盘上这包括了管理这个文件系统的 **超级块 (Super Block)**,管理空闲磁盘块的 **索引节点位图区** 和 **数据块位图区** ,以及管理文件的 **索引节点区** 和 放置文件数据的 **数据块区** 组成。
.. easyfs文件系统中管理这些磁盘数据的控制逻辑主要集中在 **磁盘块管理器** 中,这是文件系统的第四层。对于文件系统管理而言,其核心是 ``EasyFileSystem`` 数据结构及其关键成员函数:
.. - EasyFileSystem.create创建文件系统
.. - EasyFileSystem.open打开文件系统
.. - EasyFileSystem.alloc_inode分配inode dealloc_inode未实现所以还不能删除文件
.. - EasyFileSystem.alloc_data分配数据块
.. - EasyFileSystem.dealloc_data回收数据块
.. 对于单个文件的管理和读写的控制逻辑主要是 **索引节点** 来完成,这是文件系统的第五层,其核心是 ``Inode`` 数据结构及其关键成员函数:
.. - Inode.new在磁盘上的文件系统中创建一个inode
.. - Inode.find根据文件名查找对应的磁盘上的inode
.. - Inode.create在根目录下创建一个文件
.. - Inode.read_at根据inode找到文件数据所在的磁盘数据块并读到内存中
.. - Inode.write_at根据inode找到文件数据所在的磁盘数据块把内存中数据写入到磁盘数据块中
.. 上述五层就构成了easyfs文件系统的整个内容。我们可以把easyfs文件系统看成是一个库被应用程序调用。而 ``easy-fs-fuse`` 这个应用就通过调用easyfs文件系统库中各种函数并用Linux上的文件模拟了一个块设备就可以在这个模拟的块设备上创建了一个easyfs文件系统。
.. 第三步我们需要把easyfs文件系统加入到我们的操作系统内核中。这还需要做两件事情第一件是在Qemu模拟的 ``virtio`` 块设备上实现块设备驱动程序 ``os/src/drivers/block/virtio_blk.rs`` 。由于我们可以直接使用 ``virtio-drivers`` crate中的块设备驱动所以只要提供这个块设备驱动所需要的内存申请与释放以及虚实地址转换的4个函数就可以了。而我们之前操作系统中的虚存管理实现中以及有这些函数导致块设备驱动程序很简单具体实现细节都被 ``virtio-drivers`` crate封装好了。
.. 第二件事情是把文件访问相关的系统调用与easyfs文件系统连接起来。在easfs文件系统中是没有进程的概念的。而进程是程序运行过程中访问资源的管理实体这就要对 ``easy-fs`` crate 提供的 ``Inode`` 结构进一步封装,形成 ``OSInode`` 结构,以表示进程中一个打开的常规文件。对于应用程序而言,它理解的磁盘数据是常规的文件和目录,不是 ``OSInode`` 这样相对复杂的结构。其实常规文件对应的 OSInode 是文件在操作系统内核中的内部表示,因此需要为它实现 File Trait 从而能够可以将它放入到进程文件描述符表中,并通过 sys_read/write 系统调用进行读写。这样就建立了文件与 ``OSInode`` 的对应关系,并通过上面描述的三个步骤完成了包含文件系统的操作系统内核,并能给应用提供基于文件的系统调用服务。
.. 完成包含文件系统的操作系统内核后我们在shell程序和内核中支持命令行参数的解析和传递这样可以让应用根据灵活地通过命令行参数来动态地表示要操作的文件。这需要扩展对应的系统调用 ``sys_exec`` ,主要的改动就是在创建新进程时,把命令行参数压入用户栈中,这样应用程序在执行时就可以从用户栈中获取到命令行的参数值了。
.. 在上一章,我们提到了把标准输出设备在文件描述符表中的文件描述符的值规定为 1 ,用 Stdin 表示;把标准输入设备在文件描述符表中的文件描述符的值规定为 0用 stdout 表示 。另外,还有一条文件描述符相关的重要规则:即进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中编号 最小的 空闲位置。利用这些约定,只实现新的系统调用 ``sys_dup`` 完成对文件描述符的复制,就可以巧妙地实现标准 I/O 重定向功能了。
.. 具体思路是,在某应用进程执行之前,父进程(比如 user_shell进程要对子应用进程的文件描述符表进行某种替换。以输出为例父进程在创建子进程前提前打开一个常规文件 A然后 ``fork`` 子进程,在子进程的最初执行中,通过 ``sys_close`` 关闭 Stdout 文件描述符,用 ``sys_dup`` 复制常规文件 A 的文件描述符,这样 Stdout 文件描述符实际上指向的就是常规文件A了这时再通过 ``sys_close`` 关闭常规文件 A 的文件描述符。至此,常规文件 A 替换掉了应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的 **重定向** ,即完成了执行新应用前的准备工作。
.. 接下来是子进程调用 ``sys_exec`` 系统调用,创建并开始执行新子应用进程。在重定向之后,新的子应用进程认为自己输出到 fd=1 的标准输出文件,但实际上是输出到父进程(比如 user_shell进程指定的文件A中。文件这一抽象概念透明化了文件、I/O设备之间的差异因为在进程看来无论是标准输出还是常规文件都是一种文件可以通过同样的接口来读写。这就是文件的强大之处。

View File

@@ -0,0 +1,243 @@
文件与文件描述符
===========================================
文件简介
-------------------------------------------
文件可代表很多种不同类型的I/O 资源,但是在进程看来,所有文件的访问都可以通过一个简洁统一的抽象接口 ``File`` 进行:
.. code-block:: rust
// os/src/fs/mod.rs
pub trait File : Send + Sync {
fn readable(&self) -> bool;
fn writable(&self) -> bool;
fn read(&self, buf: UserBuffer) -> usize;
fn write(&self, buf: UserBuffer) -> usize;
}
这个接口在内存和I/O资源之间建立了数据交换的通道。其中 ``UserBuffer`` 是我们在 ``mm`` 子模块中定义的应用地址空间中的一段缓冲区,我们可以将它看成一个 ``&[u8]`` 切片。
``read`` 指的是从文件即I/O资源中读取数据放到缓冲区中最多将缓冲区填满即读取缓冲区的长度那么多字节并返回实际读取的字节数``write`` 指的是将缓冲区中的数据写入文件,最多将缓冲区中的数据全部写入,并返回直接写入的字节数。
回过头来再看一下用户缓冲区的抽象 ``UserBuffer`` ,它的声明如下:
.. code-block:: rust
// os/src/mm/page_table.rs
pub fn translated_byte_buffer(
token: usize,
ptr: *const u8,
len: usize
) -> Vec<&'static mut [u8]>;
pub struct UserBuffer {
pub buffers: Vec<&'static mut [u8]>,
}
impl UserBuffer {
pub fn new(buffers: Vec<&'static mut [u8]>) -> Self {
Self { buffers }
}
pub fn len(&self) -> usize {
let mut total: usize = 0;
for b in self.buffers.iter() {
total += b.len();
}
total
}
}
它只是将我们调用 ``translated_byte_buffer`` 获得的包含多个切片的 ``Vec`` 进一步包装起来,通过 ``len`` 方法可以得到缓冲区的长度。此外,我们还让它作为一个迭代器可以逐字节进行读写。有兴趣的读者可以参考类型 ``UserBufferIterator`` 还有 ``IntoIterator````Iterator`` 两个 Trait 的使用方法。
标准输入和标准输出
--------------------------------------------
其实我们在第二章就对应用程序引入了基于 **文件** 的标准输出接口 ``sys_write`` ,在第五章引入标准输入接口 ``sys_read`` 。我们提前把标准输出设备在文件描述符表中的文件描述符的值规定为 ``1`` ,用 ``Stdout`` 表示;把标准输入设备文件描述符规定为 ``0``,用 ``Stdin`` 表示 。现在,我们重写这些系统调用,先为标准输入和标准输出实现 ``File`` Trait
.. code-block:: rust
:linenos:
// os/src/fs/stdio.rs
pub struct Stdin;
pub struct Stdout;
impl File for Stdin {
fn readable(&self) -> bool { true }
fn writable(&self) -> bool { false }
fn read(&self, mut user_buf: UserBuffer) -> usize {
assert_eq!(user_buf.len(), 1);
// busy loop
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
unsafe { user_buf.buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
fn write(&self, _user_buf: UserBuffer) -> usize {
panic!("Cannot write to stdin!");
}
}
impl File for Stdout {
fn readable(&self) -> bool { false }
fn writable(&self) -> bool { true }
fn read(&self, _user_buf: UserBuffer) -> usize{
panic!("Cannot read from stdout!");
}
fn write(&self, user_buf: UserBuffer) -> usize {
for buffer in user_buf.buffers.iter() {
print!("{}", core::str::from_utf8(*buffer).unwrap());
}
user_buf.len()
}
}
可以看到,标准输入文件 ``Stdin`` 是只读文件,只允许进程通过 ``read`` 从里面读入,目前每次仅支持读入一个字符,其实现与之前的 ``sys_read`` 基本相同,只是需要通过 ``UserBuffer`` 来获取具体将字节写入的位置。相反,标准输出文件 ``Stdout`` 是只写文件,只允许进程通过 ``write`` 写入到里面,实现方法是遍历每个切片,将其转化为字符串通过 ``print!`` 宏来输出。
文件描述符与文件描述符表
--------------------------------------------
为简化操作系统设计实现,可以让每个进程都带有一个线性的 **文件描述符表** ,记录所有它请求内核打开并可以读写的那些文件集合。而 **文件描述符** (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 **文件描述符** 所处的位置(可理解为数组下标)。进程通过文件描述符,可以在自身的文件描述符表中找到对应的文件记录信息,从而也就找到了对应的文件,并对文件进行读写。当打开( ``open`` )或创建( ``create`` 一个文件的时候,如果顺利,内核会返回给应用刚刚打开或创建的文件对应的文件描述符;而当应用想关闭( ``close`` )一个文件的时候,也需要向内核提供对应的文件描述符。
文件I/O操作
-------------------------------------------
在进程控制块中加入文件描述符表的相应字段:
.. code-block:: rust
:linenos:
:emphasize-lines: 12
// os/src/task/task.rs
pub struct TaskControlBlockInner {
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
pub task_cx: TaskContext,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub parent: Option<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
pub exit_code: i32,
pub fd_table: Vec<Option<Arc<dyn File + Send + Sync>>>,
}
可以看到 ``fd_table`` 的类型包含多层嵌套,我们从外到里分别说明:
- ``Vec`` 的动态长度特性使得我们无需设置一个固定的文件描述符数量上限;
- ``Option`` 使得我们可以区分一个文件描述符当前是否空闲,当它是 ``None`` 的时候是空闲的,而 ``Some`` 则代表它已被占用;
- ``Arc`` 首先提供了共享引用能力。后面我们会提到,可能会有多个进程共享同一个文件对它进行读写。此外被它包裹的内容会被放到内核堆而不是栈上,于是它便不需要在编译期有着确定的大小;
- ``dyn`` 关键字表明 ``Arc`` 里面的类型实现了 ``File/Send/Sync`` 三个 Trait ,但是编译期无法知道它具体是哪个类型(可能是任何实现了 ``File`` Trait 的类型如 ``Stdin/Stdout`` ,故而它所占的空间大小自然也无法确定),需要等到运行时才能知道它的具体类型。
.. note::
**Rust 语法卡片Rust 中的多态**
在编程语言中, **多态** (Polymorphism) 指的是在同一段代码中可以隐含多种不同类型的特征。在 Rust 中主要通过泛型和 Trait 来实现多态。
泛型是一种 **编译期多态** (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;而 Trait 对象(也即上面提到的 ``dyn`` 语法)是一种 **运行时多态** (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 **虚表** (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更为灵活。
当新建一个进程的时候,我们需要按照先前的说明为进程打开标准输入文件和标准输出文件:
.. code-block:: rust
:linenos:
:emphasize-lines: 19-26
// os/src/task/task.rs
impl TaskControlBlock {
pub fn new(elf_data: &[u8]) -> Self {
...
let task_control_block = Self {
pid: pid_handle,
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: user_sp,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
fd_table: vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
})
},
};
...
}
}
此外,在 fork 时,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件。这样,即使我们仅手动为初始进程 ``initproc`` 打开了标准输入输出,所有进程也都可以访问它们。
文件读写系统调用
---------------------------------------------------
基于文件抽象接口和文件描述符表,我们终于可以让文件读写系统调用 ``sys_read/write`` 变得更加具有普适性,不仅仅局限于之前特定的标准输入输出:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.write(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
} else {
-1
}
}
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.read(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
} else {
-1
}
}
我们都是在当前进程的文件描述符表中通过文件描述符找到某个文件,无需关心文件具体的类型,只要知道它一定实现了 ``File`` Trait 的 ``read/write`` 方法即可。Trait 对象提供的运行时多态能力会在运行的时候帮助我们定位到 ``read/write`` 的符合实际类型的实现。

View File

@@ -0,0 +1,112 @@
文件系统接口
=================================================
简易文件与目录抽象
-------------------------------------------------
与课堂所学相比,我们实现的文件系统进行了很大的简化:
- 扁平化:仅存在根目录 ``/`` 一个目录,所有的文件都放在根目录内。直接以文件名索引文件。
- 不设置用户和用户组概念,不记录文件访问/修改的任何时间戳,不支持软硬链接。
- 只实现了最基本的文件系统相关系统调用。
打开与读写文件的系统调用
--------------------------------------------------
打开文件
++++++++++++++++++++++++++++++++++++++++++++++++++
.. code-block:: rust
/// 功能:打开一个常规文件,并返回可以访问它的文件描述符。
/// 参数path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下),
/// flags 描述打开文件的标志,具体含义下面给出。
/// dirfd 和 mode 仅用于保证兼容性,忽略
/// 返回值:如果出现了错误则返回 -1否则返回打开常规文件的文件描述符。可能的错误原因是文件不存在。
/// syscall ID56
fn sys_openat(dirfd: usize, path: &str, flags: u32, mode: u32) -> isize
目前我们的内核支持以下几种标志(多种不同标志可能共存):
- 如果 ``flags`` 为 0则表示以只读模式 *RDONLY* 打开;
- 如果 ``flags`` 第 0 位被设置0x001表示以只写模式 *WRONLY* 打开;
- 如果 ``flags`` 第 1 位被设置0x002表示既可读又可写 *RDWR*
- 如果 ``flags`` 第 9 位被设置0x200表示允许创建文件 *CREATE* ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零;
- 如果 ``flags`` 第 10 位被设置0x400则在打开文件的时候应该清空文件的内容并将该文件的大小归零也即 *TRUNC*
在用户库 ``user_lib`` 中,我们将该系统调用封装为 ``open`` 接口:
.. code-block:: rust
// user/src/lib.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
pub fn open(path: &str, flags: OpenFlags) -> isize {
sys_openat(AT_FDCWD as usize, path, flags.bits, OpenFlags::RDWR.bits)
}
借助 ``bitflags!`` 宏我们将一个 ``u32`` 的 flags 包装为一个 ``OpenFlags`` 结构体,可以从它的 ``bits`` 字段获得 ``u32`` 表示。
顺序读写文件
++++++++++++++++++++++++++++++++++++++++++++++++++
在打开一个文件之后,我们就可以用之前的 ``sys_read/sys_write`` 两个系统调用来对它进行读写了。本教程只实现文件的顺序读写,而不考虑随机读写。
以本章的测试用例 ``ch6b_filetest_simple`` 来介绍文件系统接口的使用方法:
.. code-block:: rust
:linenos:
// user/src/bin/ch6b_filetest_simple.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{
open,
close,
read,
write,
OpenFlags,
};
#[no_mangle]
pub fn main() -> i32 {
let test_str = "Hello, world!";
let filea = "filea\0";
let fd = open(filea, OpenFlags::CREATE | OpenFlags::WRONLY);
assert!(fd > 0);
let fd = fd as usize;
write(fd, test_str.as_bytes());
close(fd);
let fd = open(filea, OpenFlags::RDONLY);
assert!(fd > 0);
let fd = fd as usize;
let mut buffer = [0u8; 100];
let read_len = read(fd, &mut buffer) as usize;
close(fd);
assert_eq!(
test_str,
core::str::from_utf8(&buffer[..read_len]).unwrap(),
);
println!("file_test passed!");
0
}
- 第 20~25 行,我们以 *只写 + 创建* 的模式打开文件 ``filea`` ,向其中写入字符串 ``Hello, world!`` 而后关闭文件。
- 第 27~32 行,我们以只读 的方式将文件 ``filea`` 的内容读取到缓冲区 ``buffer`` 中。 ``filea`` 的总大小不超过缓冲区的大小,因此通过单次 ``read`` 即可将内容全部读出来而更常见的情况是需要进行多次 ``read`` ,直到返回值为 0 才能确认文件已被读取完毕。

View File

@@ -0,0 +1,674 @@
简易文件系统 easy-fs (上)
=======================================
松耦合模块化设计思路
---------------------------------------
内核的功能越来越多,代码量也越来越大。出于解耦合考虑,文件系统 easy-fs 被从内核中分离出来,分成两个不同的 crate
- ``easy-fs`` 是简易文件系统的本体;
- ``easy-fs-fuse`` 是能在开发环境(如 Ubuntu中运行的应用程序用于将应用打包为 easy-fs 格式的文件系统镜像,也可以用来对 ``easy-fs`` 进行测试。
easy-fs与底层设备驱动之间通过抽象接口 ``BlockDevice`` 来连接,采用轮询方式访问 ``virtio_blk`` 虚拟磁盘设备避免调用外设中断的相关内核函数。easy-fs 避免了直接访问进程相关的数据和函数,从而能独立于内核开发。
``easy-fs`` crate 以层次化思路设计,自下而上可以分成五个层次:
1. 磁盘块设备接口层:以块为单位对磁盘块设备进行读写的 trait 接口
2. 块缓存层:在内存中缓存磁盘块的数据,避免频繁读写磁盘
3. 磁盘数据结构层:磁盘上的超级块、位图、索引节点、数据块、目录项等核心数据结构和相关处理
4. 磁盘块管理器层:合并了上述核心数据结构和磁盘布局所形成的磁盘文件系统数据结构
5. 索引节点层:管理索引节点,实现了文件创建/文件打开/文件读写等成员函数
本节将介绍前三层,下一节将介绍后两层。
.. image:: easy-fs-demo.png
:align: center
块设备接口层
---------------------------------------
``easy-fs`` 库的最底层声明了块设备的抽象接口 ``BlockDevice``
.. code-block:: rust
// easy-fs/src/block_dev.rs
pub trait BlockDevice : Send + Sync + Any {
fn read_block(&self, block_id: usize, buf: &mut [u8]);
fn write_block(&self, block_id: usize, buf: &[u8]);
}
它定义了两个抽象方法:
- ``read_block`` 可以将编号为 ``block_id`` 的块从磁盘读入内存中的缓冲区 ``buf``
- ``write_block`` 可以将内存中的缓冲区 ``buf`` 中的数据写入磁盘编号为 ``block_id`` 的块。
``easy-fs`` 的使用者将负责提供抽象方法的实现。
块缓存层
---------------------------------------
为了加速 IO内存可以作为磁盘的缓存。实现磁盘块缓存功能的代码在 ``block_cache.rs``
块缓存
+++++++++++++++++++++++++++++++++++++++++
块缓存 ``BlockCache`` 的声明如下:
.. code-block:: rust
// easy-fs/src/lib.rs
pub const BLOCK_SZ: usize = 512;
// easy-fs/src/block_cache.rs
pub struct BlockCache {
cache: [u8; BLOCK_SZ],
block_id: usize,
block_device: Arc<dyn BlockDevice>,
modified: bool,
}
其中:
- ``cache`` 是一个 512 字节的数组,表示位于内存中的缓冲区;
- ``block_id`` 记录了这个块的编号;
- ``block_device`` 记录块所属的底层设备;
- ``modified`` 记录自从这个块缓存从磁盘载入内存之后,它有没有被修改过。
创建 ``BlockCache`` 时,将一个块从磁盘读到缓冲区 ``cache``
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl BlockCache {
/// Load a new BlockCache from disk.
pub fn new(
block_id: usize,
block_device: Arc<dyn BlockDevice>
) -> Self {
let mut cache = [0u8; BLOCK_SZ];
block_device.read_block(block_id, &mut cache);
Self {
cache,
block_id,
block_device,
modified: false,
}
}
}
``BlockCache`` 向上提供以下方法:
.. code-block:: rust
:linenos:
// easy-fs/src/block_cache.rs
impl BlockCache {
fn addr_of_offset(&self, offset: usize) -> usize {
&self.cache[offset] as *const _ as usize
}
pub fn get_ref<T>(&self, offset: usize) -> &T where T: Sized {
let type_size = core::mem::size_of::<T>();
assert!(offset + type_size <= BLOCK_SZ);
let addr = self.addr_of_offset(offset);
unsafe { &*(addr as *const T) }
}
pub fn get_mut<T>(&mut self, offset: usize) -> &mut T where T: Sized {
let type_size = core::mem::size_of::<T>();
assert!(offset + type_size <= BLOCK_SZ);
self.modified = true;
let addr = self.addr_of_offset(offset);
unsafe { &mut *(addr as *mut T) }
}
}
- ``addr_of_offset`` 可以得到一个 ``BlockCache`` 内部的缓冲区中指定偏移量 ``offset`` 的字节地址;
- ``get_ref`` 是一个泛型方法,它可以获取缓冲区中的位于偏移量 ``offset`` 的一个类型为 ``T`` 的磁盘上数据结构的不可变引用。该泛型方法的 Trait Bound 限制类型 ``T`` 必须是一个编译时已知大小的类型,我们通过 ``core::mem::size_of::<T>()`` 在编译时获取类型 ``T`` 的大小并确认该数据结构被整个包含在磁盘块及其缓冲区之内。这里编译器会自动进行生命周期标注,约束返回的引用的生命周期不超过 ``BlockCache`` 自身,在使用的时候我们会保证这一点。
- ``get_mut````get_ref`` 的不同之处在于它会获取磁盘上数据结构的可变引用,由此可以对数据结构进行修改。由于这些数据结构目前位于内存中的缓冲区中,我们需要将 ``BlockCache````modified`` 标记为 true 表示该缓冲区已经被修改,之后需要将数据写回磁盘块才能真正将修改同步到磁盘。
我们可以将 ``get_ref/get_mut`` 进一步封装为更为易用的形式:
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl BlockCache {
pub fn read<T, V>(&self, offset: usize, f: impl FnOnce(&T) -> V) -> V {
f(self.get_ref(offset))
}
pub fn modify<T, V>(&mut self, offset:usize, f: impl FnOnce(&mut T) -> V) -> V {
f(self.get_mut(offset))
}
}
它们的含义是:在 ``BlockCache`` 缓冲区偏移量为 ``offset`` 的位置,获取一个类型为 ``T`` 不可变/可变引用,将闭包 ``f`` 作用于这个引用,返回 ``f`` 的返回值。 中所定义的操作。
这里我们传入闭包的类型为 ``FnOnce`` ,这是因为闭包里面的变量被捕获的方式涵盖了不可变引用/可变引用/和 move 三种可能性,故而我们需要选取范围最广的 ``FnOnce`` 。参数中的 ``impl`` 关键字体现了一种类似泛型的静态分发功能。
.. warning::
**Rust 语法卡片:闭包**
闭包是持有外部环境变量的函数。所谓外部环境, 就是指创建闭包时所在的词法作用域。Rust中定义的闭包按照对外部环境变量的使用方式借用、复制、转移所有权分为三个类型: Fn、FnMut、FnOnce。Fn类型的闭包会在闭包内部以共享借用的方式使用环境变量FnMut类型的闭包会在闭包内部以独占借用的方式使用环境变量而FnOnce类型的闭包会在闭包内部以所有者的身份使用环境变量。由此可见根据闭包内使用环境变量的方式即可判断创建出来的闭包的类型。
``BlockCache`` 的生命周期结束后,缓冲区也会被回收, ``modified`` 标记将会决定数据是否需要写回磁盘:
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl Drop for BlockCache {
fn drop(&mut self) {
if self.modified {
self.modified = false;
self.block_device.write_block(self.block_id, &self.cache);
}
}
}
块缓存全局管理器
+++++++++++++++++++++++++++++++++++++++++
内存只能同时缓存有限个磁盘块。当我们要对一个磁盘块进行读写时,块缓存全局管理器检查它是否已经被载入内存中,如果是则直接返回,否则就读取磁盘块到内存。如果内存中驻留的磁盘块缓冲区的数量已满,则需要进行缓存替换。这里使用一种类 FIFO 的缓存替换算法,在管理器中只需维护一个队列:
.. code-block:: rust
// easy-fs/src/block_cache.rs
use alloc::collections::VecDeque;
pub struct BlockCacheManager {
queue: VecDeque<(usize, Arc<Mutex<BlockCache>>)>,
}
队列 ``queue`` 维护块编号和块缓存的二元组。块缓存的类型是一个 ``Arc<Mutex<BlockCache>>`` ,这是 Rust 中的经典组合,它可以同时提供共享引用和互斥访问。这里的共享引用意义在于块缓存既需要在管理器 ``BlockCacheManager`` 保留一个引用,还需要将引用返回给块缓存的请求者。而互斥访问在单核上的意义在于提供内部可变性通过编译,在多核环境下则可以帮助我们避免可能的并发冲突。
``get_block_cache`` 方法尝试从块缓存管理器中获取一个编号为 ``block_id`` 的块缓存,如果找不到的话会读取磁盘,还有可能会发生缓存替换:
.. code-block:: rust
:linenos:
// easy-fs/src/block_cache.rs
impl BlockCacheManager {
pub fn get_block_cache(
&mut self,
block_id: usize,
block_device: Arc<dyn BlockDevice>,
) -> Arc<Mutex<BlockCache>> {
if let Some(pair) = self.queue
.iter()
.find(|pair| pair.0 == block_id) {
Arc::clone(&pair.1)
} else {
// substitute
if self.queue.len() == BLOCK_CACHE_SIZE {
// from front to tail
if let Some((idx, _)) = self.queue
.iter()
.enumerate()
.find(|(_, pair)| Arc::strong_count(&pair.1) == 1) {
self.queue.drain(idx..=idx);
} else {
panic!("Run out of BlockCache!");
}
}
// load block into mem and push back
let block_cache = Arc::new(Mutex::new(
BlockCache::new(block_id, Arc::clone(&block_device))
));
self.queue.push_back((block_id, Arc::clone(&block_cache)));
block_cache
}
}
}
- 第 9 行,遍历整个队列试图找到一个编号相同的块缓存,如果找到,将块缓存管理器中保存的块缓存的引用复制一份并返回;
- 第 13 行对应找不到的情况,此时必须将块从磁盘读入内存中的缓冲区。读取前需要判断已保存的块数量是否达到了上限。是,则执行缓存替换算法,替换的标准是其强引用计数 :math:`\eq 1` ,即除了块缓存管理器保留的一份副本之外,在外面没有副本正在使用。
- 第 27 行开始,创建一个新的块缓存(会触发 ``read_block`` 进行块读取)并加入到队尾,最后返回给请求者。
磁盘布局及磁盘上数据结构
---------------------------------------
磁盘数据结构层的代码在 ``layout.rs````bitmap.rs`` 中。
easy-fs 磁盘布局概述
+++++++++++++++++++++++++++++++++++++++
easy-fs 磁盘按照块编号从小到大顺序分成 5 个连续区域:
- 第一个区域只包括一个块,它是 **超级块** (Super Block),用于定位其他连续区域的位置,检查文件系统合法性。
- 第二个区域是一个索引节点位图,长度为若干个块。它记录了索引节点区域中有哪些索引节点已经被分配出去使用了。
- 第三个区域是索引节点区域,长度为若干个块。其中的每个块都存储了若干个索引节点。
- 第四个区域是一个数据块位图,长度为若干个块。它记录了后面的数据块区域中有哪些已经被分配出去使用了。
- 最后的区域则是数据块区域,其中的每个被分配出去的块保存了文件或目录的具体内容。
easy-fs 超级块
+++++++++++++++++++++++++++++++++++++++
超级块 ``SuperBlock`` 的内容如下:
.. code-block:: rust
// easy-fs/src/layout.rs
#[repr(C)]
pub struct SuperBlock {
magic: u32,
pub total_blocks: u32,
pub inode_bitmap_blocks: u32,
pub inode_area_blocks: u32,
pub data_bitmap_blocks: u32,
pub data_area_blocks: u32,
}
其中, ``magic`` 是一个用于文件系统合法性验证的魔数, ``total_block`` 给出文件系统的总块数。后面的四个字段则分别给出 easy-fs 布局中后四个连续区域的长度各为多少个块。
下面是它实现的方法:
.. code-block:: rust
// easy-fs/src/layout.rs
impl SuperBlock {
pub fn initialize(
&mut self,
total_blocks: u32,
inode_bitmap_blocks: u32,
inode_area_blocks: u32,
data_bitmap_blocks: u32,
data_area_blocks: u32,
);
pub fn is_valid(&self) -> bool {
self.magic == EFS_MAGIC
}
}
- ``initialize`` 用于在创建一个 easy-fs 的时候初始化超级块,各个区域的块数由更上层的磁盘块管理器传入。
- ``is_valid`` 则可以通过魔数判断超级块所在的文件系统是否合法。
位图
+++++++++++++++++++++++++++++++++++++++
在 easy-fs 布局中存在两类不同的位图,分别对索引节点和数据块进行管理。每个位图都由若干个块组成,每个块大小 4096 bits。每个 bit 都代表一个索引节点/数据块的分配状态。
.. code-block:: rust
// easy-fs/src/bitmap.rs
pub struct Bitmap {
start_block_id: usize,
blocks: usize,
}
type BitmapBlock = [u64; 64];
``Bitmap`` 是位图区域的管理器,它保存了位图区域的起始块编号和块数。而 ``BitmapBlock`` 将位图区域中的一个磁盘块解释为长度为 64 的一个 ``u64`` 数组。
首先来看 ``Bitmap`` 如何分配一个bit
.. code-block:: rust
:linenos:
// easy-fs/src/bitmap.rs
const BLOCK_BITS: usize = BLOCK_SZ * 8;
impl Bitmap {
pub fn alloc(&self, block_device: &Arc<dyn BlockDevice>) -> Option<usize> {
for block_id in 0..self.blocks {
let pos = get_block_cache(
block_id + self.start_block_id as usize,
Arc::clone(block_device),
)
.lock()
.modify(0, |bitmap_block: &mut BitmapBlock| {
if let Some((bits64_pos, inner_pos)) = bitmap_block
.iter()
.enumerate()
.find(|(_, bits64)| **bits64 != u64::MAX)
.map(|(bits64_pos, bits64)| {
(bits64_pos, bits64.trailing_ones() as usize)
}) {
// modify cache
bitmap_block[bits64_pos] |= 1u64 << inner_pos;
Some(block_id * BLOCK_BITS + bits64_pos * 64 + inner_pos as usize)
} else {
None
}
});
if pos.is_some() {
return pos;
}
}
None
}
}
其主要思路是遍历区域中的每个块再在每个块中以bit组每组 64 bits为单位进行遍历找到一个尚未被全部分配出去的组最后在里面分配一个bit。它将会返回分配的bit所在的位置等同于索引节点/数据块的编号。如果所有bit均已经被分配出去了则返回 ``None``
第 7 行枚举区域中的每个块(编号为 ``block_id`` 在循环内部我们需要读写这个块在块内尝试找到一个空闲的bit并置 1 。一旦涉及到块的读写,就需要用到块缓存层提供的接口:
- 第 8 行我们调用 ``get_block_cache`` 获取块缓存,注意我们传入的块编号是区域起始块编号 ``start_block_id`` 加上区域内的块编号 ``block_id`` 得到的块设备上的块编号。
- 第 12 行我们通过 ``.lock()`` 获取块缓存的互斥锁从而可以对块缓存进行访问。
- 第 13 行我们使用到了 ``BlockCache::modify`` 接口。它传入的偏移量 ``offset`` 为 0这是因为整个块上只有一个 ``BitmapBlock`` ,它的大小恰好为 512 字节。因此我们需要从块的开头开始才能访问到完整的 ``BitmapBlock`` 。同时,传给它的闭包需要显式声明参数类型为 ``&mut BitmapBlock`` ,不然的话, ``BlockCache`` 的泛型方法 ``modify/get_mut`` 无法得知应该用哪个类型来解析块上的数据。在声明之后,编译器才能在这里将两个方法中的泛型 ``T`` 实例化为具体类型 ``BitmapBlock``
总结一下,这里 ``modify`` 的含义就是:从缓冲区偏移量为 0 的位置开始将一段连续的数据(数据的长度随具体类型而定)解析为一个 ``BitmapBlock`` 并要对该数据结构进行修改。在闭包内部,我们可以使用这个 ``BitmapBlock`` 的可变引用 ``bitmap_block`` 对它进行访问。 ``read/get_ref`` 的用法完全相同,后面将不再赘述。
- 闭包的主体位于第 14~26 行。它尝试在 ``bitmap_block`` 中找到一个空闲的bit并返回其位置如果不存在的话则返回 ``None`` 。它的思路是,遍历每 64 bits构成的组一个 ``u64`` ),如果它并没有达到 ``u64::MAX`` (即 :math:`2^{64}-1` ),则通过 ``u64::trailing_ones`` 找到最低的一个 0 并置为 1 。如果能够找到的话bit组的编号将保存在变量 ``bits64_pos``而分配的bit在组内的位置将保存在变量 ``inner_pos`` 中。在返回分配的bit编号的时候它的计算方式是 ``block_id*BLOCK_BITS+bits64_pos*64+inner_pos`` 。注意闭包中的 ``block_id`` 并不在闭包的参数列表中,因此它是从外部环境(即自增 ``block_id`` 的循环)中捕获到的。
我们一旦在某个块中找到一个空闲的bit并成功分配就不再考虑后续的块。第 28 行体现了提前返回的思路。
回收 bit 的方法类似,感兴趣的读者可自行阅读源代码。
磁盘上索引节点
+++++++++++++++++++++++++++++++++++++++
在磁盘上的索引节点区域,每个块上都保存着若干个索引节点 ``DiskInode``
.. code-block:: rust
// easy-fs/src/layout.rs
const INODE_DIRECT_COUNT: usize = 28;
#[repr(C)]
pub struct DiskInode {
pub size: u32,
pub direct: [u32; INODE_DIRECT_COUNT],
pub indirect1: u32,
pub indirect2: u32,
type_: DiskInodeType,
}
#[derive(PartialEq)]
pub enum DiskInodeType {
File,
Directory,
}
每个文件/目录在磁盘上均以一个 ``DiskInode`` 的形式存储。其中包含文件/目录的元数据: ``size`` 表示文件/目录内容的字节数, ``type_`` 表示索引节点的类型 ``DiskInodeType`` ,目前仅支持文件 ``File`` 和目录 ``Directory`` 两种类型。其余的 ``direct/indirect1/indirect2`` 都是存储文件内容/目录内容的数据块的索引,这也是索引节点名字的由来。
为了尽可能节约空间,在进行索引的时候,块的编号用一个 ``u32`` 存储。索引方式分成直接索引和间接索引两种:
- 当文件很小的时候,只需用到直接索引, ``direct`` 数组中最多可以指向 ``INODE_DIRECT_COUNT`` 个数据块,当取值为 28 的时候,通过直接索引可以找到 14KiB 的内容。
- 当文件比较大的时候,不仅直接索引的 ``direct`` 数组装满,还需要用到一级间接索引 ``indirect1`` 。它指向一个一级索引块,这个块也位于磁盘布局的数据块区域中。这个一级索引块中的每个 ``u32`` 都用来指向数据块区域中一个保存该文件内容的数据块,因此,最多能够索引 :math:`\frac{512}{4}=128` 个数据块,对应 64KiB 的内容。
- 当文件大小超过直接索引和一级索引支持的容量上限 78KiB 的时候,就需要用到二级间接索引 ``indirect2`` 。它指向一个位于数据块区域中的二级索引块。二级索引块中的每个 ``u32`` 指向一个不同的一级索引块,这些一级索引块也位于数据块区域中。因此,通过二级间接索引最多能够索引 :math:`128\times 64\text{KiB}=8\text{MiB}` 的内容。
为了充分利用空间,我们将 ``DiskInode`` 的大小设置为 128 字节,每个块正好能够容纳 4 个 ``DiskInode`` 。在后续需要支持更多类型的元数据的时候,可以适当缩减直接索引 ``direct`` 的块数,并将节约出来的空间用来存放其他元数据,仍可保证 ``DiskInode`` 的总大小为 128 字节。
通过 ``initialize`` 方法可以初始化一个 ``DiskInode`` 为一个文件或目录:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// indirect1 and indirect2 block are allocated only when they are needed.
pub fn initialize(&mut self, type_: DiskInodeType) {
self.size = 0;
self.direct.iter_mut().for_each(|v| *v = 0);
self.indirect1 = 0;
self.indirect2 = 0;
self.type_ = type_;
}
}
需要注意的是, ``indirect1/2`` 均被初始化为 0 。因为最开始文件内容的大小为 0 字节,并不会用到一级/二级索引。为了节约空间,我们会完全按需分配一级/二级索引块。此外,直接索引 ``direct`` 也被清零。
``is_file````is_dir`` 两个方法可以用来确认 ``DiskInode`` 的类型为文件还是目录:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
pub fn is_dir(&self) -> bool {
self.type_ == DiskInodeType::Directory
}
pub fn is_file(&self) -> bool {
self.type_ == DiskInodeType::File
}
}
``get_block_id`` 方法体现了 ``DiskInode`` 最重要的数据块索引功能,它可以从索引中查到它自身用于保存文件内容的第 ``block_id`` 个数据块的块编号,这样后续才能对这个数据块进行访问:
.. code-block:: rust
:linenos:
:emphasize-lines: 10,12,18
// easy-fs/src/layout.rs
const INODE_INDIRECT1_COUNT: usize = BLOCK_SZ / 4;
const INDIRECT1_BOUND: usize = DIRECT_BOUND + INODE_INDIRECT1_COUNT;
type IndirectBlock = [u32; BLOCK_SZ / 4];
impl DiskInode {
pub fn get_block_id(&self, inner_id: u32, block_device: &Arc<dyn BlockDevice>) -> u32 {
let inner_id = inner_id as usize;
if inner_id < INODE_DIRECT_COUNT {
self.direct[inner_id]
} else if inner_id < INDIRECT1_BOUND {
get_block_cache(self.indirect1 as usize, Arc::clone(block_device))
.lock()
.read(0, |indirect_block: &IndirectBlock| {
indirect_block[inner_id - INODE_DIRECT_COUNT]
})
} else {
let last = inner_id - INDIRECT1_BOUND;
let indirect1 = get_block_cache(
self.indirect2 as usize,
Arc::clone(block_device)
)
.lock()
.read(0, |indirect2: &IndirectBlock| {
indirect2[last / INODE_INDIRECT1_COUNT]
});
get_block_cache(
indirect1 as usize,
Arc::clone(block_device)
)
.lock()
.read(0, |indirect1: &IndirectBlock| {
indirect1[last % INODE_INDIRECT1_COUNT]
})
}
}
}
这里需要说明的是:
- 第 10/12/18 行分别利用直接索引/一级索引和二级索引,具体选用哪种索引方式取决于 ``block_id`` 所在的区间。
- 在对一个索引块进行操作的时候,我们将其解析为磁盘数据结构 ``IndirectBlock`` ,实质上就是一个 ``u32`` 数组,每个都指向一个下一级索引块或者数据块。
- 对于二级索引的情况,需要先查二级索引块找到挂在它下面的一级索引块,再通过一级索引块找到数据块。
在初始化之后文件/目录的 ``size`` 均为 0 ,此时并不会索引到任何数据块。它需要通过 ``increase_size`` 方法逐步扩充容量。在扩充的时候,自然需要一些新的数据块来作为索引块或是保存内容的数据块。我们需要先编写一些辅助方法来确定在容量扩充的时候额外需要多少块:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// Return block number correspond to size.
pub fn data_blocks(&self) -> u32 {
Self::_data_blocks(self.size)
}
fn _data_blocks(size: u32) -> u32 {
(size + BLOCK_SZ as u32 - 1) / BLOCK_SZ as u32
}
/// Return number of blocks needed include indirect1/2.
pub fn total_blocks(size: u32) -> u32 {
let data_blocks = Self::_data_blocks(size) as usize;
let mut total = data_blocks as usize;
// indirect1
if data_blocks > INODE_DIRECT_COUNT {
total += 1;
}
// indirect2
if data_blocks > INDIRECT1_BOUND {
total += 1;
// sub indirect1
total += (data_blocks - INDIRECT1_BOUND + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT;
}
total as u32
}
pub fn blocks_num_needed(&self, new_size: u32) -> u32 {
assert!(new_size >= self.size);
Self::total_blocks(new_size) - Self::total_blocks(self.size)
}
}
``data_blocks`` 方法可以计算为了容纳自身 ``size`` 字节的内容需要多少个数据块。计算的过程只需用 ``size`` 除以每个块的大小 ``BLOCK_SZ`` 并向上取整。而 ``total_blocks`` 不仅包含数据块,还需要统计索引块。计算的方法也很简单,先调用 ``data_blocks`` 得到需要多少数据块,再根据数据块数目所处的区间统计索引块即可。 ``blocks_num_needed`` 可以计算将一个 ``DiskInode````size`` 扩容到 ``new_size`` 需要额外多少个数据和索引块。这只需要调用两次 ``total_blocks`` 作差即可。
下面给出 ``increase_size`` 方法的接口:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
pub fn increase_size(
&mut self,
new_size: u32,
new_blocks: Vec<u32>,
block_device: &Arc<dyn BlockDevice>,
);
}
其中 ``new_size`` 表示容量扩充之后的文件大小; ``new_blocks`` 是一个保存了本次容量扩充所需块编号的向量,这些块都是由上层的磁盘块管理器负责分配的。 ``increase_size`` 的实现有些复杂,在这里不详细介绍。大致的思路是按照直接索引、一级索引再到二级索引的顺序进行扩充。
有些时候我们还需要清空文件的内容并回收所有数据和索引块。这是通过 ``clear_size`` 方法来实现的:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// Clear size to zero and return blocks that should be deallocated.
///
/// We will clear the block contents to zero later.
pub fn clear_size(&mut self, block_device: &Arc<dyn BlockDevice>) -> Vec<u32>;
}
它会将回收的所有块的编号保存在一个向量中返回给磁盘块管理器。它的实现原理和 ``increase_size`` 一样也分为多个阶段,在这里不展开。
接下来需要考虑通过 ``DiskInode`` 来读写它索引的那些数据块中的数据。这些数据可以被视为一个字节序列,而每次我们都是选取其中的一段连续区间进行操作,以 ``read_at`` 为例:
.. code-block:: rust
:linenos:
// easy-fs/src/layout.rs
type DataBlock = [u8; BLOCK_SZ];
impl DiskInode {
pub fn read_at(
&self,
offset: usize,
buf: &mut [u8],
block_device: &Arc<dyn BlockDevice>,
) -> usize {
let mut start = offset;
let end = (offset + buf.len()).min(self.size as usize);
if start >= end {
return 0;
}
let mut start_block = start / BLOCK_SZ;
let mut read_size = 0usize;
loop {
// calculate end of current block
let mut end_current_block = (start / BLOCK_SZ + 1) * BLOCK_SZ;
end_current_block = end_current_block.min(end);
// read and update read size
let block_read_size = end_current_block - start;
let dst = &mut buf[read_size..read_size + block_read_size];
get_block_cache(
self.get_block_id(start_block as u32, block_device) as usize,
Arc::clone(block_device),
)
.lock()
.read(0, |data_block: &DataBlock| {
let src = &data_block[start % BLOCK_SZ..start % BLOCK_SZ + block_read_size];
dst.copy_from_slice(src);
});
read_size += block_read_size;
// move to next block
if end_current_block == end { break; }
start_block += 1;
start = end_current_block;
}
read_size
}
}
它的含义是:将文件内容从 ``offset`` 字节开始的部分读到内存中的缓冲区 ``buf`` 中,并返回实际读到的字节数。如果文件剩下的内容还足够多,那么缓冲区会被填满;不然的话文件剩下的全部内容都会被读到缓冲区中。具体实现上有很多细节,但大致的思路是遍历位于字节区间 ``start,end`` 中间的那些块,将它们视为一个 ``DataBlock`` (也就是一个字节数组),并将其中的部分内容复制到缓冲区 ``buf`` 中适当的区域。 ``start_block`` 维护着目前是文件内部第多少个数据块,需要首先调用 ``get_block_id`` 从索引中查到这个数据块在块设备中的块编号,随后才能传入 ``get_block_cache`` 中将正确的数据块缓存到内存中进行访问。
在第 14 行进行了简单的边界条件判断,如果要读取的内容超出了文件的范围那么直接返回 0 表示读取不到任何内容。
``write_at`` 的实现思路基本上和 ``read_at`` 完全相同。但不同的是 ``write_at`` 不会出现失败的情况,传入的整个缓冲区的数据都必定会被写入到文件中。当从 ``offset`` 开始的区间超出了文件范围的时候,就需要调用者在调用 ``write_at`` 之前提前调用 ``increase_size`` 将文件大小扩充到区间的右端保证写入的完整性。
目录项
+++++++++++++++++++++++++++++++++++++++
对于文件而言,它的内容在文件系统或内核看来没有任何既定的格式,只是一个字节序列。目录的内容却需要遵从一种特殊的格式,它可以看成一个目录项的序列,每个目录项都是一个二元组,包括目录下文件的文件名和索引节点编号。目录项 ``DirEntry`` 的定义如下:
.. code-block:: rust
// easy-fs/src/layout.rs
const NAME_LENGTH_LIMIT: usize = 27;
#[repr(C)]
pub struct DirEntry {
name: [u8; NAME_LENGTH_LIMIT + 1],
inode_number: u32,
}
pub const DIRENT_SZ: usize = 32;
目录项 ``Dirent`` 保存的文件名长度不能超过 27。目录项自身长 32 字节,每个数据块可以存储 16 个目录项。可以通过 ``empty````new`` 方法生成目录项,通过 ``name````inode_number`` 方法取出目录项中的内容:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DirEntry {
pub fn empty() -> Self;
pub fn new(name: &str, inode_number: u32) -> Self;
pub fn name(&self) -> &str;
pub fn inode_number(&self) -> u32
}
在从目录中读取目录项,或将目录项写入目录时,需要将目录项转化为缓冲区(即字节切片)的形式来符合 ``read_at OR write_at`` 接口的要求:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DirEntry {
pub fn as_bytes(&self) -> &[u8] {
unsafe {
core::slice::from_raw_parts(
self as *const _ as usize as *const u8,
DIRENT_SZ,
)
}
}
pub fn as_bytes_mut(&mut self) -> &mut [u8] {
unsafe {
core::slice::from_raw_parts_mut(
self as *mut _ as usize as *mut u8,
DIRENT_SZ,
)
}
}
}

View File

@@ -0,0 +1,591 @@
简易文件系统 easy-fs (下)
=======================================
磁盘块管理器
---------------------------------------
本层的代码在 ``efs.rs`` 中。
.. code-block:: rust
// easy-fs/src/efs.rs
pub struct EasyFileSystem {
pub block_device: Arc<dyn BlockDevice>,
pub inode_bitmap: Bitmap,
pub data_bitmap: Bitmap,
inode_area_start_block: u32,
data_area_start_block: u32,
}
``EasyFileSystem`` 包含索引节点和数据块的两个位图 ``inode_bitmap````data_bitmap`` ,还记录下索引节点区域和数据块区域起始块编号方便确定每个索引节点和数据块在磁盘上的具体位置。我们还要在其中保留块设备的一个指针 ``block_device`` ,在进行后续操作的时候,该指针会被拷贝并传递给下层的数据结构,让它们也能够直接访问块设备。
通过 ``create`` 方法可以在块设备上创建并初始化一个 easy-fs 文件系统:
.. code-block:: rust
:linenos:
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn create(
block_device: Arc<dyn BlockDevice>,
total_blocks: u32,
inode_bitmap_blocks: u32,
) -> Arc<Mutex<Self>> {
// calculate block size of areas & create bitmaps
let inode_bitmap = Bitmap::new(1, inode_bitmap_blocks as usize);
let inode_num = inode_bitmap.maximum();
let inode_area_blocks =
((inode_num * core::mem::size_of::<DiskInode>() + BLOCK_SZ - 1) / BLOCK_SZ) as u32;
let inode_total_blocks = inode_bitmap_blocks + inode_area_blocks;
let data_total_blocks = total_blocks - 1 - inode_total_blocks;
let data_bitmap_blocks = (data_total_blocks + 4096) / 4097;
let data_area_blocks = data_total_blocks - data_bitmap_blocks;
let data_bitmap = Bitmap::new(
(1 + inode_bitmap_blocks + inode_area_blocks) as usize,
data_bitmap_blocks as usize,
);
let mut efs = Self {
block_device: Arc::clone(&block_device),
inode_bitmap,
data_bitmap,
inode_area_start_block: 1 + inode_bitmap_blocks,
data_area_start_block: 1 + inode_total_blocks + data_bitmap_blocks,
};
// clear all blocks
for i in 0..total_blocks {
get_block_cache(
i as usize,
Arc::clone(&block_device)
)
.lock()
.modify(0, |data_block: &mut DataBlock| {
for byte in data_block.iter_mut() { *byte = 0; }
});
}
// initialize SuperBlock
get_block_cache(0, Arc::clone(&block_device))
.lock()
.modify(0, |super_block: &mut SuperBlock| {
super_block.initialize(
total_blocks,
inode_bitmap_blocks,
inode_area_blocks,
data_bitmap_blocks,
data_area_blocks,
);
});
// write back immediately
// create a inode for root node "/"
assert_eq!(efs.alloc_inode(), 0);
let (root_inode_block_id, root_inode_offset) = efs.get_disk_inode_pos(0);
get_block_cache(
root_inode_block_id as usize,
Arc::clone(&block_device)
)
.lock()
.modify(root_inode_offset, |disk_inode: &mut DiskInode| {
disk_inode.initialize(DiskInodeType::Directory);
});
Arc::new(Mutex::new(efs))
}
}
- 第 10~21 行根据传入的参数计算每个区域各应该包含多少块。根据 inode 位图的大小计算 inode 区域至少需要多少个块才能够使得 inode 位图中的每个bit都能够有一个实际的 inode 可以对应,这样就确定了 inode 位图区域和 inode 区域的大小。剩下的块都分配给数据块位图区域和数据块区域。我们希望数据块位图中的每个bit仍然能够对应到一个数据块但是数据块位图又不能过小不然会造成某些数据块永远不会被使用。因此数据块位图区域最合理的大小是剩余的块数除以 4097 再上取整,因为位图中的每个块能够对应 4096 个数据块。其余的块就都作为数据块使用。
- 第 22 行创建我们的 ``EasyFileSystem`` 实例 ``efs``
- 第 30 行首先将块设备的前 ``total_blocks`` 个块清零,因为我们的 easy-fs 要用到它们,这也是为初始化做准备。
- 第 41 行将位于块设备编号为 0 块上的超级块进行初始化,只需传入之前计算得到的每个区域的块数就行了。
- 第 54~63 行我们要做的事情是创建根目录 ``/`` 。首先需要调用 ``alloc_inode`` 在 inode 位图中分配一个 inode ,由于这是第一次分配,它的编号固定是 0 。接下来需要将分配到的 inode 初始化为 easy-fs 中的唯一一个目录,我们需要调用 ``get_disk_inode_pos`` 来根据 inode 编号获取该 inode 所在的块的编号以及块内偏移,之后就可以将它们传给 ``get_block_cache````modify`` 了。
通过 ``open`` 方法可以从一个已写入了 easy-fs 镜像的块设备上打开我们的 easy-fs
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn open(block_device: Arc<dyn BlockDevice>) -> Arc<Mutex<Self>> {
// read SuperBlock
get_block_cache(0, Arc::clone(&block_device))
.lock()
.read(0, |super_block: &SuperBlock| {
assert!(super_block.is_valid(), "Error loading EFS!");
let inode_total_blocks =
super_block.inode_bitmap_blocks + super_block.inode_area_blocks;
let efs = Self {
block_device,
inode_bitmap: Bitmap::new(
1,
super_block.inode_bitmap_blocks as usize
),
data_bitmap: Bitmap::new(
(1 + inode_total_blocks) as usize,
super_block.data_bitmap_blocks as usize,
),
inode_area_start_block: 1 + super_block.inode_bitmap_blocks,
data_area_start_block: 1 + inode_total_blocks + super_block.data_bitmap_blocks,
};
Arc::new(Mutex::new(efs))
})
}
}
它只需将块设备编号为 0 的块作为超级块读取进来,就可以从中知道 easy-fs 的磁盘布局,由此可以构造 ``efs`` 实例。
``EasyFileSystem`` 知道整个磁盘布局,即可以从 inode位图 或数据块位图上分配的 bit 编号来算出各个存储inode和数据块的磁盘块在磁盘上的实际位置。
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn get_disk_inode_pos(&self, inode_id: u32) -> (u32, usize) {
let inode_size = core::mem::size_of::<DiskInode>();
let inodes_per_block = (BLOCK_SZ / inode_size) as u32;
let block_id = self.inode_area_start_block + inode_id / inodes_per_block;
(block_id, (inode_id % inodes_per_block) as usize * inode_size)
}
pub fn get_data_block_id(&self, data_block_id: u32) -> u32 {
self.data_area_start_block + data_block_id
}
}
inode 和数据块的分配/回收也由它负责:
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn alloc_inode(&mut self) -> u32 {
self.inode_bitmap.alloc(&self.block_device).unwrap() as u32
}
/// Return a block ID not ID in the data area.
pub fn alloc_data(&mut self) -> u32 {
self.data_bitmap.alloc(&self.block_device).unwrap() as u32 + self.data_area_start_block
}
pub fn dealloc_data(&mut self, block_id: u32) {
get_block_cache(
block_id as usize,
Arc::clone(&self.block_device)
)
.lock()
.modify(0, |data_block: &mut DataBlock| {
data_block.iter_mut().for_each(|p| { *p = 0; })
});
self.data_bitmap.dealloc(
&self.block_device,
(block_id - self.data_area_start_block) as usize
)
}
}
注意:
- ``alloc_data````dealloc_data`` 分配/回收数据块传入/返回的参数都表示数据块在块设备上的编号而不是在数据块位图中分配的bit编号
- ``dealloc_inode`` 未实现,不支持文件删除。
索引节点
---------------------------------------
服务于文件相关系统调用的索引节点层的代码在 ``vfs.rs`` 中。
``EasyFileSystem`` 实现了我们设计的磁盘布局并能够将所有块有效的管理起来。但是对于文件系统的使用者而言,他们往往不关心磁盘布局是如何实现的,而是更希望能够直接看到目录树结构中逻辑上的文件和目录。为此我们设计索引节点 ``Inode`` 暴露给文件系统的使用者,让他们能够直接对文件和目录进行操作。 ``Inode````DiskInode`` 的区别从它们的名字中就可以看出: ``DiskInode`` 放在磁盘块中比较固定的位置,而 ``Inode`` 是放在内存中的记录文件索引节点信息的数据结构。
.. code-block:: rust
// easy-fs/src/vfs.rs
pub struct Inode {
block_id: usize,
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
}
``block_id````block_offset`` 记录该 ``Inode`` 对应的 ``DiskInode`` 保存在磁盘上的具体位置方便我们后续对它进行访问。 ``fs`` 是指向 ``EasyFileSystem`` 的一个指针,因为对 ``Inode`` 的种种操作实际上都是要通过底层的文件系统来完成。
仿照 ``BlockCache::read/modify`` ,我们可以设计两个方法来简化对于 ``Inode`` 对应的磁盘上的 ``DiskInode`` 的访问流程,而不是每次都需要 ``get_block_cache.lock.read/modify``
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
fn read_disk_inode<V>(&self, f: impl FnOnce(&DiskInode) -> V) -> V {
get_block_cache(
self.block_id,
Arc::clone(&self.block_device)
).lock().read(self.block_offset, f)
}
fn modify_disk_inode<V>(&self, f: impl FnOnce(&mut DiskInode) -> V) -> V {
get_block_cache(
self.block_id,
Arc::clone(&self.block_device)
).lock().modify(self.block_offset, f)
}
}
下面我们分别介绍文件系统的使用者对于文件系统的一些常用操作:
获取根目录的 inode
+++++++++++++++++++++++++++++++++++++++
文件系统的使用者在通过 ``EasyFileSystem::open`` 从装载了 easy-fs 镜像的块设备上打开 easy-fs 之后,要做的第一件事情就是获取根目录的 ``Inode`` 。因为我们目前仅支持绝对路径,对于任何文件/目录的索引都必须从根目录开始向下逐级进行。等到索引完成之后,我们才能对文件/目录进行操作。事实上 ``EasyFileSystem`` 提供了另一个名为 ``root_inode`` 的方法来获取根目录的 ``Inode`` :
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn root_inode(efs: &Arc<Mutex<Self>>) -> Inode {
let block_device = Arc::clone(&efs.lock().block_device);
// acquire efs lock temporarily
let (block_id, block_offset) = efs.lock().get_disk_inode_pos(0);
// release efs lock
Inode::new(
block_id,
block_offset,
Arc::clone(efs),
block_device,
)
}
}
// easy-fs/src/vfs.rs
impl Inode {
/// We should not acquire efs lock here.
pub fn new(
block_id: u32,
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
) -> Self {
Self {
block_id: block_id as usize,
block_offset,
fs,
block_device,
}
}
}
``root_inode`` 中,主要是在 ``Inode::new`` 的时候将传入的 ``inode_id`` 设置为 0 ,因为根目录对应于文件系统中第一个分配的 inode ,因此它的 ``inode_id`` 总会是 0 。同时在设计上,我们不会在 ``Inode::new`` 中尝试获取整个 ``EasyFileSystem`` 的锁来查询 inode 在块设备中的位置,而是在调用它之前预先查询并作为参数传过去。
文件索引
+++++++++++++++++++++++++++++++++++++++
为了尽可能简化我们的实现,所有的文件都在根目录下面。于是,我们不必实现目录索引。文件索引的查找比较简单,仅需在根目录的目录项中根据文件名找到文件的 inode 编号即可。由于没有子目录的存在,这个过程只会进行一次。
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn find(&self, name: &str) -> Option<Arc<Inode>> {
let fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
self.find_inode_id(name, disk_inode)
.map(|inode_id| {
let (block_id, block_offset) = fs.get_disk_inode_pos(inode_id);
Arc::new(Self::new(
block_id,
block_offset,
self.fs.clone(),
self.block_device.clone(),
))
})
})
}
fn find_inode_id(
&self,
name: &str,
disk_inode: &DiskInode,
) -> Option<u32> {
// assert it is a directory
assert!(disk_inode.is_dir());
let file_count = (disk_inode.size as usize) / DIRENT_SZ;
let mut dirent = DirEntry::empty();
for i in 0..file_count {
assert_eq!(
disk_inode.read_at(
DIRENT_SZ * i,
dirent.as_bytes_mut(),
&self.block_device,
),
DIRENT_SZ,
);
if dirent.name() == name {
return Some(dirent.inode_number() as u32);
}
}
None
}
}
``find`` 方法只会被根目录 ``Inode`` 调用,文件系统中其他文件的 ``Inode`` 不会调用这个方法。它首先调用 ``find_inode_id`` 方法尝试从根目录的 ``DiskInode`` 上找到要索引的文件名对应的 inode 编号。这就需要将根目录内容中的所有目录项都读到内存进行逐个比对。如果能够找到的话, ``find`` 方法会根据查到 inode 编号对应生成一个 ``Inode`` 用于后续对文件的访问。
这里需要注意的是,包括 ``find`` 在内所有暴露给文件系统的使用者的文件系统操作(还包括接下来将要介绍的几种),全程均需持有 ``EasyFileSystem`` 的互斥锁(相对的,文件系统内部的操作如之前的 ``Inode::new`` 或是上面的 ``find_inode_id`` 都是假定在已持有 efs 锁的情况下才被调用的,因此它们不应尝试获取锁)。这能够保证在多核情况下,同时最多只能有一个核在进行文件系统相关操作。这样也许会带来一些不必要的性能损失,但我们目前暂时先这样做。如果我们在这里加锁的话,其实就能够保证块缓存的互斥访问了。
文件列举
+++++++++++++++++++++++++++++++++++++++
``ls`` 方法可以收集根目录下的所有文件的文件名并以向量的形式返回,这个方法只有根目录的 ``Inode`` 才会调用:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn ls(&self) -> Vec<String> {
let _fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
let file_count = (disk_inode.size as usize) / DIRENT_SZ;
let mut v: Vec<String> = Vec::new();
for i in 0..file_count {
let mut dirent = DirEntry::empty();
assert_eq!(
disk_inode.read_at(
i * DIRENT_SZ,
dirent.as_bytes_mut(),
&self.block_device,
),
DIRENT_SZ,
);
v.push(String::from(dirent.name()));
}
v
})
}
}
文件创建
+++++++++++++++++++++++++++++++++++++++
``create`` 方法可以在根目录下创建一个文件,该方法只有根目录的 ``Inode`` 会调用:
.. code-block:: rust
:linenos:
// easy-fs/src/vfs.rs
impl Inode {
pub fn create(&self, name: &str) -> Option<Arc<Inode>> {
let mut fs = self.fs.lock();
if self.modify_disk_inode(|root_inode| {
// assert it is a directory
assert!(root_inode.is_dir());
// has the file been created?
self.find_inode_id(name, root_inode)
}).is_some() {
return None;
}
// create a new file
// alloc a inode with an indirect block
let new_inode_id = fs.alloc_inode();
// initialize inode
let (new_inode_block_id, new_inode_block_offset)
= fs.get_disk_inode_pos(new_inode_id);
get_block_cache(
new_inode_block_id as usize,
Arc::clone(&self.block_device)
).lock().modify(new_inode_block_offset, |new_inode: &mut DiskInode| {
new_inode.initialize(DiskInodeType::File);
});
self.modify_disk_inode(|root_inode| {
// append file in the dirent
let file_count = (root_inode.size as usize) / DIRENT_SZ;
let new_size = (file_count + 1) * DIRENT_SZ;
// increase size
self.increase_size(new_size as u32, root_inode, &mut fs);
// write dirent
let dirent = DirEntry::new(name, new_inode_id);
root_inode.write_at(
file_count * DIRENT_SZ,
dirent.as_bytes(),
&self.block_device,
);
});
let (block_id, block_offset) = fs.get_disk_inode_pos(new_inode_id);
// return inode
Some(Arc::new(Self::new(
block_id,
block_offset,
self.fs.clone(),
self.block_device.clone(),
)))
// release efs lock automatically by compiler
}
}
- 第 6~13 行,检查文件是否已经在根目录下,如果找到的话返回 ``None``
- 第 14~25 行,为待创建文件分配一个新的 inode 并进行初始化;
- 第 26~39 行,将待创建文件的目录项插入到根目录的内容中使得之后可以索引过来。
文件清空
+++++++++++++++++++++++++++++++++++++++
在以某些标志位打开文件(例如带有 *CREATE* 标志打开一个已经存在的文件)的时候,需要首先将文件清空。在索引到文件的 ``Inode`` 之后可以调用 ``clear`` 方法:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn clear(&self) {
let mut fs = self.fs.lock();
self.modify_disk_inode(|disk_inode| {
let size = disk_inode.size;
let data_blocks_dealloc = disk_inode.clear_size(&self.block_device);
assert!(data_blocks_dealloc.len() == DiskInode::total_blocks(size) as usize);
for data_block in data_blocks_dealloc.into_iter() {
fs.dealloc_data(data_block);
}
});
}
}
这会将之前该文件占据的索引块和数据块在 ``EasyFileSystem`` 中回收。
文件读写
+++++++++++++++++++++++++++++++++++++++
从根目录索引到一个文件之后可以对它进行读写,注意,和 ``DiskInode`` 一样,这里的读写作用在字节序列的一段区间上:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> usize {
let _fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
disk_inode.read_at(offset, buf, &self.block_device)
})
}
pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize {
let mut fs = self.fs.lock();
self.modify_disk_inode(|disk_inode| {
self.increase_size((offset + buf.len()) as u32, disk_inode, &mut fs);
disk_inode.write_at(offset, buf, &self.block_device)
})
}
}
具体实现比较简单,需要注意在 ``DiskInode::write_at`` 之前先调用 ``increase_size`` 对自身进行扩容:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
fn increase_size(
&self,
new_size: u32,
disk_inode: &mut DiskInode,
fs: &mut MutexGuard<EasyFileSystem>,
) {
if new_size < disk_inode.size {
return;
}
let blocks_needed = disk_inode.blocks_num_needed(new_size);
let mut v: Vec<u32> = Vec::new();
for _ in 0..blocks_needed {
v.push(fs.alloc_data());
}
disk_inode.increase_size(new_size, v, &self.block_device);
}
}
这里会从 ``EasyFileSystem`` 中分配一些用于扩容的数据块并传给 ``DiskInode::increase_size``
将应用打包为 easy-fs 镜像
---------------------------------------
在第六章中我们需要将所有的应用都链接到内核中,随后在应用管理器中通过应用名进行索引来找到应用的 ELF 数据。这样做有一个缺点,就是会造成内核体积过度膨胀。同时这也会浪费内存资源,因为未被执行的应用也占据了内存空间。在实现了我们自己的文件系统之后,终于可以将这些应用打包到 easy-fs 镜像中放到磁盘中当我们要执行应用的时候只需从文件系统中取出ELF 执行文件格式的应用 并加载到内存中执行即可,这样就避免了上面的那些问题。
``easy-fs-fuse`` 的主体 ``easy-fs-pack`` 函数就实现了这个功能:
.. code-block:: rust
:linenos:
// easy-fs-fuse/src/main.rs
use clap::{Arg, App};
fn easy_fs_pack() -> std::io::Result<()> {
let matches = App::new("EasyFileSystem packer")
.arg(Arg::with_name("source")
.short("s")
.long("source")
.takes_value(true)
.help("Executable source dir(with backslash)")
)
.arg(Arg::with_name("target")
.short("t")
.long("target")
.takes_value(true)
.help("Executable target dir(with backslash)")
)
.get_matches();
let src_path = matches.value_of("source").unwrap();
let target_path = matches.value_of("target").unwrap();
println!("src_path = {}\ntarget_path = {}", src_path, target_path);
let block_file = Arc::new(BlockFile(Mutex::new({
let f = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(format!("{}{}", target_path, "fs.img"))?;
f.set_len(8192 * 512).unwrap();
f
})));
// 4MiB, at most 4095 files
let efs = EasyFileSystem::create(
block_file.clone(),
8192,
1,
);
let root_inode = Arc::new(EasyFileSystem::root_inode(&efs));
let apps: Vec<_> = read_dir(src_path)
.unwrap()
.into_iter()
.map(|dir_entry| {
let mut name_with_ext = dir_entry.unwrap().file_name().into_string().unwrap();
name_with_ext.drain(name_with_ext.find('.').unwrap()..name_with_ext.len());
name_with_ext
})
.collect();
for app in apps {
// load app data from host file system
let mut host_file = File::open(format!("{}{}", target_path, app)).unwrap();
let mut all_data: Vec<u8> = Vec::new();
host_file.read_to_end(&mut all_data).unwrap();
// create a file in easy-fs
let inode = root_inode.create(app.as_str()).unwrap();
// write data to easy-fs
inode.write_at(0, all_data.as_slice());
}
// list apps
for app in root_inode.ls() {
println!("{}", app);
}
Ok(())
}
- 为了实现 ``easy-fs-fuse````os/user`` 的解耦,第 6~21 行使用 ``clap`` crate 进行命令行参数解析,需要通过 ``-s````-t`` 分别指定应用的源代码目录和保存应用 ELF 的目录而不是在 ``easy-fs-fuse`` 中硬编码。如果解析成功的话它们会分别被保存在变量 ``src_path````target_path`` 中。
- 第 23~38 行依次完成:创建 4MiB 的 easy-fs 镜像文件、进行 easy-fs 初始化、获取根目录 inode 。
- 第 39 行获取源码目录中的每个应用的源代码文件并去掉后缀名,收集到向量 ``apps`` 中。
- 第 48 行开始,枚举 ``apps`` 中的每个应用,从放置应用执行程序的目录中找到对应应用的 ELF 文件(这是一个 HostOS 上的文件)并将数据读入内存。接着需要在我们的 easy-fs 中创建一个同名文件并将 ELF 数据写入到这个文件中。这个过程相当于将 HostOS 上的文件系统中的一个文件复制到我们的 easy-fs 中。
尽管没有进行任何同步写回磁盘的操作,我们也不用担心块缓存中的修改没有写回磁盘。因为在 ``easy-fs-fuse`` 这个应用正常退出的过程中,块缓存因生命周期结束会被回收,届时如果 ``modified`` 标志为 true 就会将修改写回磁盘。

View File

@@ -0,0 +1,313 @@
在内核中使用 easy-fs
===============================================
块设备驱动层
-----------------------------------------------
``drivers`` 子模块中的 ``block/mod.rs`` 中,我们可以找到内核访问的块设备实例 ``BLOCK_DEVICE``
.. code-block:: rust
// os/src/drivers/block/mod.rs
type BlockDeviceImpl = virtio_blk::VirtIOBlock;
lazy_static! {
pub static ref BLOCK_DEVICE: Arc<dyn BlockDevice> = Arc::new(BlockDeviceImpl::new());
}
在 qemu 上,我们使用 ``VirtIOBlock`` 访问 VirtIO 块设备,并将它全局实例化为 ``BLOCK_DEVICE`` ,使内核的其他模块可以访问。
在启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备:
.. code-block:: makefile
:linenos:
:emphasize-lines: 11-12
# os/Makefile
FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img
run: build
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
-drive file=$(FS_IMG),if=none,format=raw,id=x0 \
-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
- 第 11 行,我们为虚拟机添加一块虚拟硬盘,内容为我们之前通过 ``easy-fs-fuse`` 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 ``x0``
- 第 12 行,我们将硬盘 ``x0`` 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。 ``virtio-mmio-bus.0`` 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0 。
**内存映射 I/O** (MMIO, Memory-Mapped I/O) 指通过特定的物理内存地址来访问外设的设备寄存器。查阅资料,可知 VirtIO 总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB 。
``config`` 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)。在创建内核地址空间的时候需要建立页表映射:
.. code-block:: rust
// os/src/config.rs
pub const MMIO: &[(usize, usize)] = &[
(0x10001000, 0x1000),
];
// os/src/mm/memory_set.rs
use crate::config::MMIO;
impl MemorySet {
/// Without kernel stacks.
pub fn new_kernel() -> Self {
...
println!("mapping memory-mapped registers");
for pair in MMIO {
memory_set.push(MapArea::new(
(*pair).0.into(),
((*pair).0 + (*pair).1).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
}
memory_set
}
}
这里我们进行的是透明的恒等映射,让内核可以兼容于直接访问物理地址的设备驱动库。
由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 `virtio-drivers <https://github.com/rcore-os/virtio-drivers>`_ crate感兴趣的同学可以自行了解。
内核索引节点层
-----------------------------------------------
内核将 ``easy-fs`` 提供的 ``Inode`` 进一步封装为 OS 中的索引节点 ``OSInode``
.. code-block:: rust
// os/src/fs/inode.rs
pub struct OSInode {
readable: bool,
writable: bool,
inner: UPSafeCell<OSInodeInner>,
}
pub struct OSInodeInner {
offset: usize,
inode: Arc<Inode>,
}
``OSInode`` 就表示进程中一个被打开的常规文件或目录。 ``readable/writable`` 分别表明该文件是否允许通过 ``sys_read/write`` 进行读写,读写过程中的偏移量 ``offset````Inode`` 则加上互斥锁丢到 ``OSInodeInner`` 中。
文件描述符层
-----------------------------------------------
``OSInode`` 也是要一种要放到进程文件描述符表中,通过 ``sys_read/write`` 进行读写的文件,我们需要为它实现 ``File`` Trait
.. code-block:: rust
// os/src/fs/inode.rs
impl File for OSInode {
fn readable(&self) -> bool { self.readable }
fn writable(&self) -> bool { self.writable }
fn read(&self, mut buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_read_size = 0usize;
for slice in buf.buffers.iter_mut() {
let read_size = inner.inode.read_at(inner.offset, *slice);
if read_size == 0 {
break;
}
inner.offset += read_size;
total_read_size += read_size;
}
total_read_size
}
fn write(&self, buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_write_size = 0usize;
for slice in buf.buffers.iter() {
let write_size = inner.inode.write_at(inner.offset, *slice);
assert_eq!(write_size, slice.len());
inner.offset += write_size;
total_write_size += write_size;
}
total_write_size
}
}
``read/write`` 的实现也比较简单,只需遍历 ``UserBuffer`` 中的每个缓冲区片段,调用 ``Inode`` 写好的 ``read/write_at`` 接口就好了。注意 ``read/write_at`` 的起始位置是在 ``OSInode`` 中维护的 ``offset`` ,这个 ``offset`` 也随着遍历的进行被持续更新。在 ``read/write`` 的全程需要获取 ``OSInode`` 的互斥锁,保证两个进程无法同时访问同个文件。
本章我们为 ``File`` Trait 新增了 ``readable/writable`` 两个抽象接口,从而在 ``sys_read/sys_write`` 的时候进行简单的访问权限检查。
文件系统相关内核机制实现
-----------------------------------------------
文件系统初始化
+++++++++++++++++++++++++++++++++++++++++++++++
为了使用 ``easy-fs`` 提供的抽象,内核需要进行一些初始化操作。我们需要从块设备 ``BLOCK_DEVICE`` 上打开文件系统,并从文件系统中获取根目录的 inode 。
.. code-block:: rust
// os/src/fs/inode.rs
lazy_static! {
pub static ref ROOT_INODE: Arc<Inode> = {
let efs = EasyFileSystem::open(BLOCK_DEVICE.clone());
Arc::new(EasyFileSystem::root_inode(&efs))
};
}
这之后就可以使用根目录的 inode ``ROOT_INODE`` ,在内核中调用 ``easy-fs`` 的相关接口了。例如,在文件系统初始化完毕之后,调用 ``list_apps`` 函数来打印所有可用应用的文件名:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn list_apps() {
println!("/**** APPS ****");
for app in ROOT_INODE.ls() {
println!("{}", app);
}
println!("**************/")
}
通过 sys_open 打开文件
+++++++++++++++++++++++++++++++++++++++++++++++
在内核中也定义一份打开文件的标志 ``OpenFlags``
.. code-block:: rust
// os/src/fs/inode.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
impl OpenFlags {
/// Do not check validity for simplicity
/// Return (readable, writable)
pub fn read_write(&self) -> (bool, bool) {
if self.is_empty() {
(true, false)
} else if self.contains(Self::WRONLY) {
(false, true)
} else {
(true, true)
}
}
}
它的 ``read_write`` 方法可以根据标志的情况返回要打开的文件是否允许读写。简单起见,这里假设标志自身一定合法。
接着,我们实现 ``open_file`` 内核函数,可根据文件名打开一个根目录下的文件:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn open_file(name: &str, flags: OpenFlags) -> Option<Arc<OSInode>> {
let (readable, writable) = flags.read_write();
if flags.contains(OpenFlags::CREATE) {
if let Some(inode) = ROOT_INODE.find(name) {
// clear size
inode.clear();
Some(Arc::new(OSInode::new(
readable,
writable,
inode,
)))
} else {
// create file
ROOT_INODE.create(name)
.map(|inode| {
Arc::new(OSInode::new(
readable,
writable,
inode,
))
})
}
} else {
ROOT_INODE.find(name)
.map(|inode| {
if flags.contains(OpenFlags::TRUNC) {
inode.clear();
}
Arc::new(OSInode::new(
readable,
writable,
inode
))
})
}
}
这里主要是实现了 ``OpenFlags`` 各标志位的语义。例如只有 ``flags`` 参数包含 `CREATE` 标志位才允许创建文件;而如果文件已经存在,则清空文件的内容。
在其基础上, ``sys_open`` 也就很容易实现了。
通过 sys_exec 加载并执行应用
+++++++++++++++++++++++++++++++++++++++++++++++
有了文件系统支持后, ``sys_exec`` 所需的表示应用 ELF 格式数据改为从文件系统中获取:
.. code-block:: rust
:linenos:
:emphasize-lines: 17-25
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe {
args = args.add(1);
}
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
argc as isize
} else {
-1
}
注意上面代码片段中的高亮部分。当执行获取应用的 ELF 数据的操作时,首先调用 ``open_file`` 函数,以只读的方式在内核中打开应用文件并获取它对应的 ``OSInode`` 。接下来可以通过 ``OSInode::read_all`` 将该文件的数据全部读到一个向量 ``all_data`` 中:
之后,就可以从向量 ``all_data`` 中拿到应用中的 ELF 数据,当解析完毕并创建完应用地址空间后该向量将会被回收。
同样的,我们在内核中创建初始进程 ``initproc`` 也需要替换为基于文件系统的实现:
.. code-block:: rust
// os/src/task/mod.rs
lazy_static! {
pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new({
let inode = open_file("ch6b_initproc", OpenFlags::RDONLY).unwrap();
let v = inode.read_all();
TaskControlBlock::new(v.as_slice())
});
}

View File

@@ -0,0 +1,114 @@
chapter6练习
================================================
编程作业
-------------------------------------------------
硬链接
++++++++++++++++++++++++++++++++++++++++++++++++++
硬链接要求两个不同的目录项指向同一个文件,在我们的文件系统中也就是两个不同名称目录项指向同一个磁盘块。
本节要求实现三个系统调用 ``sys_linkat、sys_unlinkat、sys_stat``
**linkat**
* syscall ID: 37
* 功能:创建一个文件的一个硬链接, `linkat标准接口 <https://linux.die.net/man/2/linkat>`_
* C接口: ``int linkat(int olddirfd, char* oldpath, int newdirfd, char* newpath, unsigned int flags)``
* Rust 接口: ``fn linkat(olddirfd: i32, oldpath: *const u8, newdirfd: i32, newpath: *const u8, flags: u32) -> i32``
* 参数:
* olddirfdnewdirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。
* flags: 仅为了兼容性考虑,本次实验中始终为 0可以忽略。
* oldpath原有文件路径
* newpath: 新的链接文件路径。
* 说明:
* 为了方便,不考虑新文件路径已经存在的情况(属于未定义行为),除非链接同名文件。
* 返回值:如果出现了错误则返回 -1否则返回 0。
* 可能的错误
* 链接同名文件。
**unlinkat**:
* syscall ID: 35
* 功能:取消一个文件路径到文件的链接, `unlinkat标准接口 <https://linux.die.net/man/2/unlinkat>`_
* C接口: ``int unlinkat(int dirfd, char* path, unsigned int flags)``
* Rust 接口: ``fn unlinkat(dirfd: i32, path: *const u8, flags: u32) -> i32``
* 参数:
* dirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。
* flags: 仅为了兼容性考虑,本次实验中始终为 0可以忽略。
* path文件路径。
* 说明:
* 注意考虑使用 unlink 彻底删除文件的情况此时需要回收inode以及它对应的数据块。
* 返回值:如果出现了错误则返回 -1否则返回 0。
* 可能的错误
* 文件不存在。
**fstat**:
* syscall ID: 80
* 功能:获取文件状态。
* C接口: ``int fstat(int fd, struct Stat* st)``
* Rust 接口: ``fn fstat(fd: i32, st: *mut Stat) -> i32``
* 参数:
* fd: 文件描述符
* st: 文件状态结构体
.. code-block:: rust
#[repr(C)]
#[derive(Debug)]
pub struct Stat {
/// 文件所在磁盘驱动器号,该实验中写死为 0 即可
pub dev: u64,
/// inode 文件所在 inode 编号
pub ino: u64,
/// 文件类型
pub mode: StatMode,
/// 硬链接数量初始为1
pub nlink: u32,
/// 无需考虑,为了兼容性设计
pad: [u64; 7],
}
/// StatMode 定义:
bitflags! {
pub struct StatMode: u32 {
const NULL = 0;
/// directory
const DIR = 0o040000;
/// ordinary regular file
const FILE = 0o100000;
}
}
实验要求
+++++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch6。
- 实验目录要求不变。
- 通过所有测例。
在 os 目录下 ``make run BASE=2`` 加载所有测例, ``ch6_usertest`` 打包了所有你需要通过的测例,你也可以通过修改这个文件调整本地测试的内容。
你的内核必须前向兼容,能通过前一章的所有测例。
.. note::
**如何调试 easy-fs**
如果你在第一章练习题中已经借助 ``log`` crate 实现了日志功能,那么你可以直接在 ``easy-fs`` 中引入 ``log`` crate通过 ``log::info!/debug!`` 等宏即可进行调试并在内核中看到日志输出。具体来说,在 ``easy-fs`` 中的修改是:在 ``easy-fs/Cargo.toml`` 的依赖中加入一行 ``log = "0.4.0"``,然后在 ``easy-fs/src/lib.rs`` 中加入一行 ``extern crate log``
你也可以完全在用户态进行调试。仿照 ``easy-fs-fuse`` 建立一个在当前操作系统中运行的应用程序,将测试逻辑写在 ``main`` 函数中。这个时候就可以将它引用的 ``easy-fs````no_std`` 去掉并使用 ``println!`` 进行调试。
问答作业
----------------------------------------------------------
1. 在我们的easy-fs中root inode起着什么作用如果root inode中的内容损坏了会发生什么
报告要求
-----------------------------------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,13 @@
第六章文件系统与I/O重定向
==============================================
.. toctree::
:maxdepth: 4
0intro
1file-descriptor.rst
1fs-interface
2fs-implementation-1
2fs-implementation-2
3using-easy-fs-in-kernel
4exercise

View File

@@ -0,0 +1,118 @@
引言
=========================================
本章导读
-----------------------------------------
本章将基于文件描述符实现父子进程之间的通信机制——管道。
我们还将扩展 ``exec`` 系统调用,使之能传递运行参数,并进一步改进 shell 程序,使其支持重定向符号 ``>````<``
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch7
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
进入shell程序后可以运行管道机制的简单测例 ``ch7b_pipetest`` ``ch7b_pipetest`` 需要保证父进程通过管道传输给子进程的字符串不会发生变化。
测例输出大致如下:
.. code-block::
>> ch7b_pipetest
Read OK, child process exited!
pipetest passed!
Shell: Process 2 exited with code 0
>>
同样的,也可以运行较为复杂的测例 ``ch7b_pipe_large_test``,体验通过两个管道实现双向通信。
此外在本章我们为shell程序支持了输入/输出重定向功能,可以将一个应用的输出保存到一个指定的文件。例如,下面的命令可以将 ``ch7b_yield`` 应用的输出保存在文件 ``fileb`` 当中,并在应用执行完毕之后确认它的输出:
.. code-block::
>> ch7b_yield > fileb
Shell: Process 2 exited with code 0
>> ch7b_cat fileb
Hello, I am process 2.
Back in process 2, iteration 0.
Back in process 2, iteration 1.
Back in process 2, iteration 2.
Back in process 2, iteration 3.
Back in process 2, iteration 4.
yield pass.
Shell: Process 2 exited with code 0
>>
本章代码树
-----------------------------------------
.. code-block::
── os
   └── src
   ├── ...
   ├── fs
   │   ├── inode.rs
   │   ├── mod.rs
   │   ├── pipe.rs(新增:实现了 File Trait 的第三个实现——可用来进程间通信的管道)
   │   └── stdio.rs
   ├── mm
   │   ├── address.rs
   │   ├── frame_allocator.rs
   │   ├── heap_allocator.rs
   │   ├── memory_set.rs
   │   ├── mod.rs
   │   └── page_table.rs
   ├── syscall
   │   ├── fs.rs(修改添加了sys_pipe和sys_dup)
   │   ├── mod.rs
   │   └── process.rs(修改sys_exec添加了对参数的支持)
   ├── task
      ├── context.rs
      ├── manager.rs
      ├── mod.rs
      ├── pid.rs
      ├── processor.rs
      ├── switch.rs
      ├── switch.S
      └── task.rs(修改在exec中将参数压入用户栈中)
cloc easy-fs os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 42 317 434 3574
Assembly 4 53 26 526
make 1 13 4 48
TOML 2 4 2 23
-------------------------------------------------------------------------------
SUM: 49 387 466 4171
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 在本章第一节 :doc:`/chapter6/1file-descriptor` 中,我们引入了文件的概念,用它来代表进程可以读写的多种被内核管理的硬件/软件资源。进程必须通过系统调用打开一个文件,将文件加入到自身的文件描述符表中,才能通过文件描述符(也就是某个特定文件在自身文件描述符表中的下标)来读写该文件。
.. 文件的抽象 Trait ``File`` 声明在 ``os/src/fs/mod.rs`` 中,它提供了 ``read/write`` 两个接口,可以将数据写入应用缓冲区抽象 ``UserBuffer`` ,或者从应用缓冲区读取数据。应用缓冲区抽象类型 ``UserBuffer`` 来自 ``os/src/mm/page_table.rs`` 中,它将 ``translated_byte_buffer`` 得到的 ``Vec<&'static mut [u8]>`` 进一步包装,不仅保留了原有的分段读写能力,还可以将其转化为一个迭代器逐字节进行读写,这在读写一些流式设备的时候特别有用。
.. 在进程控制块 ``TaskControlBlock`` 中需要加入文件描述符表字段 ``fd_table`` ,可以看到它是一个向量,里面保存了若干实现了 ``File`` Trait 的文件,由于采用动态分发,文件的类型可能各不相同。 ``os/src/syscall/fs.rs`` 的 ``sys_read/write`` 两个读写文件的系统调用需要访问当前进程的文件描述符表,用应用传入内核的文件描述符来索引对应的已打开文件,并调用 ``File`` Trait 的 ``read/write`` 接口; ``sys_close`` 这可以关闭一个文件。调用 ``TaskControlBlock`` 的 ``alloc_fd`` 方法可以在文件描述符表中分配一个文件描述符。进程控制块的其他操作也需要考虑到新增的文件描述符表字段的影响,如 ``TaskControlBlock::new`` 的时候需要对 ``fd_table`` 进行初始化, ``TaskControlBlock::fork`` 中则需要将父进程的 ``fd_table`` 复制一份给子进程。
.. 到本章为止我们支持两种文件:标准输入输出和管道。不同于前面章节,我们将标准输入输出分别抽象成 ``Stdin`` 和 ``Stdout`` 两个类型,并为他们实现 ``File`` Trait 。在 ``TaskControlBlock::new`` 创建初始进程的时候,就默认打开了标准输入输出,并分别绑定到文件描述符 0 和 1 上面。
.. 管道 ``Pipe`` 是另一种文件,它可以用于父子进程间的单向进程间通信。我们也需要为它实现 ``File`` Trait 。 ``os/src/syscall/fs.rs`` 中的系统调用 ``sys_pipe`` 可以用来打开一个管道并返回读端/写端两个文件的文件描述符。管道的具体实现在 ``os/src/fs/pipe.rs`` 中,本章第二节 :doc:`/chapter6/2pipe` 中给出了详细的讲解。管道机制的测试用例可以参考 ``user/src/bin`` 目录下的 ``pipetest.rs`` 和 ``pipe_large_test.rs`` 两个文件。

View File

@@ -0,0 +1,364 @@
管道
============================================
管道的系统调用原型及使用方法
--------------------------------------------
新增为当前进程打开一个管道(包含一个只读文件,一个只写文件)的系统调用:
.. code-block:: rust
/// 功能:为当前进程打开一个管道。
/// 参数pipe 表示应用地址空间中的一个长度为 2 的 usize 数组的起始地址,内核需要按顺序将管道读端
/// 和写端的文件描述符写入到数组中。
/// 返回值:如果出现了错误则返回 -1否则返回 0 。可能的错误原因是:传入的地址不合法。
/// syscall ID59
pub fn sys_pipe(pipe: *mut usize) -> isize;
用户库会将其包装为 ``pipe`` 函数:
.. code-block:: rust
// user/src/lib.rs
pub fn pipe(pipe_fd: &mut [usize]) -> isize { sys_pipe(pipe_fd) }
只有当一个管道的所有读端文件/写端文件都被关闭之后,管道占用的资源才会被回收。
.. code-block:: rust
/// 功能:当前进程关闭一个文件。
/// 参数fd 表示要关闭的文件的文件描述符。
/// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。
/// syscall ID57
pub fn sys_close(fd: usize) -> isize;
它会在用户库中被包装为 ``close`` 函数。
我们从测例 ``ch7b_pipetest`` 中理解管道的使用方法:
.. code-block:: rust
:linenos:
// user/src/bin/ch7b_pipetest.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{fork, close, pipe, read, write, wait};
static STR: &str = "Hello, world!";
#[no_mangle]
pub fn main() -> i32 {
// create pipe
let mut pipe_fd = [0usize; 2];
pipe(&mut pipe_fd);
// read end
assert_eq!(pipe_fd[0], 3);
// write end
assert_eq!(pipe_fd[1], 4);
if fork() == 0 {
// child process, read from parent
// close write_end
close(pipe_fd[1]);
let mut buffer = [0u8; 32];
let len_read = read(pipe_fd[0], &mut buffer) as usize;
// close read_end
close(pipe_fd[0]);
assert_eq!(core::str::from_utf8(&buffer[..len_read]).unwrap(), STR);
println!("Read OK, child process exited!");
0
} else {
// parent process, write to child
// close read end
close(pipe_fd[0]);
assert_eq!(write(pipe_fd[1], STR.as_bytes()), STR.len() as isize);
// close write end
close(pipe_fd[1]);
let mut child_exit_code: i32 = 0;
wait(&mut child_exit_code);
assert_eq!(child_exit_code, 0);
println!("pipetest passed!");
0
}
}
在父进程中,我们通过 ``pipe`` 打开一个管道文件数组,其中 ``pipe_fd[0]`` 保存了管道读端的文件描述符,而 ``pipe_fd[1]`` 保存了管道写端的文件描述符。在 ``fork`` 之后,子进程会完全继承父进程的文件描述符表,于是子进程也可以通过同样的文件描述符来访问同一个管道的读端和写端。之前提到过管道是单向的,在这个测例中我们希望管道中的数据从父进程流向子进程,也即父进程仅通过管道的写端写入数据,而子进程仅通过管道的读端读取数据。
因此,在第 25 和第 34 行,分别第一时间在子进程中关闭管道的写端和在父进程中关闭管道的读端。父进程在第 35 行将字符串 ``STR`` 写入管道的写端,随后在第 37 行关闭管道的写端;子进程在第 27 行从管道的读端读取字符串,并在第 29 行关闭。
如果想在父子进程之间实现双向通信,我们就必须创建两个管道。有兴趣的读者可以参考测例 ``ch7b_pipe_large_test``
通过 sys_close 关闭文件
--------------------------------------------
关闭文件的系统调用 ``sys_close`` 实现非常简单,我们只需将进程控制块中的文件描述符表对应的一项改为 ``None`` 代表它已经空闲即可,同时这也会导致内层的引用计数类型 ``Arc`` 被销毁,会减少一个文件的引用计数,当引用计数减少到 0 之后,文件所占用的资源就会被自动回收。
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_close(fd: usize) -> isize {
let task = current_task().unwrap();
let mut inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if inner.fd_table[fd].is_none() {
return -1;
}
inner.fd_table[fd].take();
0
}
基于文件的管道
--------------------------------------------
我们将管道的一端(读端或写端)抽象为 ``Pipe`` 类型:
.. code-block:: rust
// os/src/fs/pipe.rs
pub struct Pipe {
readable: bool,
writable: bool,
buffer: Arc<Mutex<PipeRingBuffer>>,
}
``readable````writable`` 分别指出该管道端可否支持读取/写入,通过 ``buffer`` 字段还可以找到该管道端所在的管道自身。后续我们将为它实现 ``File`` Trait ,之后它便可以通过文件描述符来访问。
而管道自身,也就是那个带有一定大小缓冲区的字节队列,我们抽象为 ``PipeRingBuffer`` 类型:
.. code-block:: rust
// os/src/fs/pipe.rs
const RING_BUFFER_SIZE: usize = 32;
#[derive(Copy, Clone, PartialEq)]
enum RingBufferStatus {
FULL,
EMPTY,
NORMAL,
}
pub struct PipeRingBuffer {
arr: [u8; RING_BUFFER_SIZE],
head: usize,
tail: usize,
status: RingBufferStatus,
write_end: Option<Weak<Pipe>>,
}
- ``RingBufferStatus`` 记录了缓冲区目前的状态:``FULL`` 表示缓冲区已满不能再继续写入; ``EMPTY`` 表示缓冲区为空无法从里面读取;而 ``NORMAL`` 则表示除了 ``FULL````EMPTY`` 之外的其他状态。
- ``PipeRingBuffer````arr/head/tail`` 三个字段用来维护一个循环队列,其中 ``arr`` 为存放数据的数组, ``head`` 为循环队列队头的下标, ``tail`` 为循环队列队尾的下标。
- ``PipeRingBuffer````write_end`` 字段还保存了它的写端的一个弱引用计数,这是由于在某些情况下需要确认该管道所有的写端是否都已经被关闭了,通过这个字段很容易确认这一点。
从内存管理的角度,每个读端或写端中都保存着所属管道自身的强引用计数,且我们确保这些引用计数只会出现在管道端口 ``Pipe`` 结构体中。于是,一旦一个管道所有的读端和写端均被关闭,便会导致它们所属管道的引用计数变为 0 ,循环队列缓冲区所占用的资源被自动回收。虽然 ``PipeRingBuffer`` 中保存了一个指向写端的引用计数,但是它是一个弱引用,也就不会出现循环引用的情况导致内存泄露。
.. chyyuu 介绍弱引用???
管道创建
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
通过 ``PipeRingBuffer::new`` 可以创建一个新的管道:
.. code-block:: rust
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn new() -> Self {
Self {
arr: [0; RING_BUFFER_SIZE],
head: 0,
tail: 0,
status: RingBufferStatus::EMPTY,
write_end: None,
}
}
}
``Pipe````read/write_end_with_buffer`` 方法可以分别从一个已有的管道创建它的读端和写端:
.. code-block:: rust
// os/src/fs/pipe.rs
impl Pipe {
pub fn read_end_with_buffer(buffer: Arc<Mutex<PipeRingBuffer>>) -> Self {
Self {
readable: true,
writable: false,
buffer,
}
}
pub fn write_end_with_buffer(buffer: Arc<Mutex<PipeRingBuffer>>) -> Self {
Self {
readable: false,
writable: true,
buffer,
}
}
}
可以看到,读端和写端的访问权限进行了相应设置:不允许向读端写入,也不允许从写端读取。
通过 ``make_pipe`` 方法可以创建一个管道并返回它的读端和写端:
.. code-block:: rust
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn set_write_end(&mut self, write_end: &Arc<Pipe>) {
self.write_end = Some(Arc::downgrade(write_end));
}
}
/// Return (read_end, write_end)
pub fn make_pipe() -> (Arc<Pipe>, Arc<Pipe>) {
let buffer = Arc::new(Mutex::new(PipeRingBuffer::new()));
let read_end = Arc::new(
Pipe::read_end_with_buffer(buffer.clone())
);
let write_end = Arc::new(
Pipe::write_end_with_buffer(buffer.clone())
);
buffer.lock().set_write_end(&write_end);
(read_end, write_end)
}
注意,我们调用 ``PipeRingBuffer::set_write_end`` 在管道中保留它的写端的弱引用计数。
现在来实现创建管道的系统调用 ``sys_pipe``
.. code-block:: rust
:linenos:
// os/src/task/task.rs
impl TaskControlBlockInner {
pub fn alloc_fd(&mut self) -> usize {
if let Some(fd) = (0..self.fd_table.len())
.find(|fd| self.fd_table[*fd].is_none()) {
fd
} else {
self.fd_table.push(None);
self.fd_table.len() - 1
}
}
}
// os/src/syscall/fs.rs
pub fn sys_pipe(pipe: *mut usize) -> isize {
let task = current_task().unwrap();
let token = current_user_token();
let mut inner = task.acquire_inner_lock();
let (pipe_read, pipe_write) = make_pipe();
let read_fd = inner.alloc_fd();
inner.fd_table[read_fd] = Some(pipe_read);
let write_fd = inner.alloc_fd();
inner.fd_table[write_fd] = Some(pipe_write);
*translated_refmut(token, pipe) = read_fd;
*translated_refmut(token, unsafe { pipe.add(1) }) = write_fd;
0
}
``TaskControlBlockInner::alloc_fd`` 可以在进程控制块中分配一个最小的空闲文件描述符来访问一个新打开的文件。它先从小到大遍历所有曾经被分配过的文件描述符尝试找到一个空闲的,如果没有的话就需要拓展文件描述符表的长度并新分配一个。
``sys_pipe`` 中,第 21 行我们调用 ``make_pipe`` 创建一个管道并获取其读端和写端,第 22~25 行我们分别为读端和写端分配文件描述符并将它们放置在文件描述符表中的相应位置中。第 26~27 行我们则是将读端和写端的文件描述符写回到应用地址空间。
管道读写
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
首先来看如何为 ``Pipe`` 实现 ``File`` Trait 的 ``read`` 方法,即从管道的读端读取数据。在此之前,我们需要对于管道循环队列进行封装来让它更易于使用:
.. code-block:: rust
:linenos:
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn read_byte(&mut self) -> u8 {
self.status = RingBufferStatus::NORMAL;
let c = self.arr[self.head];
self.head = (self.head + 1) % RING_BUFFER_SIZE;
if self.head == self.tail {
self.status = RingBufferStatus::EMPTY;
}
c
}
pub fn available_read(&self) -> usize {
if self.status == RingBufferStatus::EMPTY {
0
} else {
if self.tail > self.head {
self.tail - self.head
} else {
self.tail + RING_BUFFER_SIZE - self.head
}
}
}
pub fn all_write_ends_closed(&self) -> bool {
self.write_end.as_ref().unwrap().upgrade().is_none()
}
}
``PipeRingBuffer::read_byte`` 方法可以从管道中读取一个字节,注意在调用它之前需要确保管道缓冲区中不是空的。它会更新循环队列队头的位置,并比较队头和队尾是否相同,如果相同的话则说明管道的状态变为空 ``EMPTY`` 。仅仅通过比较队头和队尾是否相同不能确定循环队列是否为空,因为它既有可能表示队列为空,也有可能表示队列已满。因此我们需要在 ``read_byte`` 的同时进行状态更新。
``PipeRingBuffer::available_read`` 可以计算管道中还有多少个字符可以读取。我们首先需要需要判断队列是否为空,因为队头和队尾相等可能表示队列为空或队列已满,两种情况 ``available_read`` 的返回值截然不同。如果队列为空的话直接返回 0否则根据队头和队尾的相对位置进行计算。
``PipeRingBuffer::all_write_ends_closed`` 可以判断管道的所有写端是否都被关闭了,这是通过尝试将管道中保存的写端的弱引用计数升级为强引用计数来实现的。如果升级失败的话,说明管道写端的强引用计数为 0 ,也就意味着管道所有写端都被关闭了,从而管道中的数据不会再得到补充,待管道中仅剩的数据被读取完毕之后,管道就可以被销毁了。
下面是 ``Pipe````read`` 方法的实现:
.. code-block:: rust
:linenos:
// os/src/fs/pipe.rs
impl File for Pipe {
fn read(&self, buf: UserBuffer) -> usize {
assert_eq!(self.readable, true);
let mut buf_iter = buf.into_iter();
let mut read_size = 0usize;
loop {
let mut ring_buffer = self.buffer.lock();
let loop_read = ring_buffer.available_read();
if loop_read == 0 {
if ring_buffer.all_write_ends_closed() {
return read_size;
}
drop(ring_buffer);
suspend_current_and_run_next();
continue;
}
// read at most loop_read bytes
for _ in 0..loop_read {
if let Some(byte_ref) = buf_iter.next() {
unsafe { *byte_ref = ring_buffer.read_byte(); }
read_size += 1;
} else {
return read_size;
}
}
}
}
}
- 第 6 行的 ``buf_iter`` 将传入的应用缓冲区 ``buf`` 转化为一个能够逐字节对于缓冲区进行访问的迭代器,每次调用 ``buf_iter.next()`` 即可按顺序取出用于访问缓冲区中一个字节的裸指针。
- 第 7 行的 ``read_size`` 用来维护实际有多少字节从管道读入应用的缓冲区。
- ``File::read`` 的语义是要从文件中最多读取应用缓冲区大小那么多字符。这可能超出了循环队列的大小,或者由于尚未有进程从管道的写端写入足够的字符,因此我们需要将整个读取的过程放在一个循环中,当循环队列中不存在足够字符的时候暂时进行任务切换,等待循环队列中的字符得到补充之后再继续读取。
这个循环从第 8 行开始,第 10 行我们用 ``loop_read`` 来保存循环这一轮次中可以从管道循环队列中读取多少字符。如果管道为空则会检查管道的所有写端是否都已经被关闭,如果是的话,说明我们已经没有任何字符可以读取了,这时可以直接返回;否则我们需要等管道的字符得到填充之后再继续读取,因此我们调用 ``suspend_current_and_run_next`` 切换到其他任务,等到切换回来之后回到循环开头再看一下管道中是否有字符了。在调用之前我们需要手动释放管道自身的锁,因为切换任务时候的 ``__switch`` 并不是一个正常的函数调用。
如果 ``loop_read`` 不为 0 ,在这一轮次中管道中就有 ``loop_read`` 个字节可以读取。我们可以迭代应用缓冲区中的每个字节指针并调用 ``PipeRingBuffer::read_byte`` 方法来从管道中进行读取。如果这 ``loop_read`` 个字节均被读取之后还没有填满应用缓冲区就需要进入循环的下一个轮次,否则就可以直接返回了。
``Pipe````write`` 方法——即通过管道的写端向管道中写入数据的实现和 ``read`` 的原理类似,篇幅所限在这里不再赘述,感兴趣的读者可自行查阅。

View File

@@ -0,0 +1,337 @@
命令行参数与标准 I/O 重定向
=================================================
命令行参数
-------------------------------------------------
使用 C 语言开发 Linux 应用时,可以使用标准库提供的 ``argc/argv`` 来获取命令行参数我们希望在我们自己的内核和shell程序上支持这个功能。为了支持命令行参数 ``sys_exec`` 的系统调用接口需要发生变化:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize;
可以看到,它的参数多出了一个 ``args`` 数组,数组中的每个元素都是命令行参数字符串的起始地址。实际传递给内核的实际上是这个数组的起始地址:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize {
syscall(SYSCALL_EXEC, [path.as_ptr() as usize, args.as_ptr() as usize, 0])
}
// user/src/lib.rs
pub fn exec(path: &str, args: &[*const u8]) -> isize { sys_exec(path, args) }
shell程序的命令行参数分割
+++++++++++++++++++++++++++++++++++++++++++++++++
回忆一下在shell程序 ``user_shell`` 中,一旦接收到一个回车,我们就会将当前行的内容 ``line`` 作为一个名字并试图去执行同名的应用。但是现在 ``line`` 还可能包含一些命令行参数,只有最开头的一个才是要执行的应用名。因此我们要做的第一件事情就是将 ``line`` 用空格分割:
.. code-block:: rust
// user/src/bin/ch6b_user_shell.rs
let args: Vec<_> = line.as_str().split(' ').collect();
let mut args_copy: Vec<String> = args
.iter()
.map(|&arg| {
let mut string = String::new();
string.push_str(arg);
string
})
.collect();
args_copy
.iter_mut()
.for_each(|string| {
string.push('\0');
});
经过分割, ``args`` 中的 ``&str`` 都是 ``line`` 中的一段子区间,它们的结尾并没有包含 ``\0`` ,因为 ``line`` 是我们输入得到的,中间本来就没有 ``\0`` 。由于在向内核传入字符串的时候,我们只能传入字符串的起始地址,因此我们必须保证其结尾为 ``\0`` 。从而我们用 ``args_copy````args`` 中的字符串拷贝一份到堆上并在末尾手动加入 ``\0`` 。这样就可以安心的将 ``args_copy`` 中的字符串传入内核了。我们用 ``args_addr`` 来收集这些字符串的起始地址:
.. code-block:: rust
// user/src/bin/ch6b_user_shell.rs
let mut args_addr: Vec<*const u8> = args_copy
.iter()
.map(|arg| arg.as_ptr())
.collect();
args_addr.push(0 as *const u8);
向量 ``args_addr`` 中的每个元素都代表一个命令行参数字符串的起始地址。为了让内核能够获取到命令行参数的个数,我们在 ``args_addr`` 的末尾放入一个 0 ,这样内核看到它时就能知道命令行参数已经获取完毕了。
``fork`` 出来的子进程中,我们调用 ``exec`` 传入命令行参数。
sys_exec 将命令行参数压入用户栈
+++++++++++++++++++++++++++++++++++++++++++++++++
``sys_exec`` 中,首先需要将应用传进来的命令行参数取出来:
.. code-block:: rust
:linenos:
:emphasize-lines: 6-14,19
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe { args = args.add(1); }
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
// return argc because cx.x[10] will be covered with it later
argc as isize
} else {
-1
}
}
每次我们都可以从一个起始地址通过 ``translated_str`` 拿到一个字符串,直到 ``args`` 为 0 就说明没有更多命令行参数了。在第 19 行调用 ``TaskControlBlock::exec`` 的时候,我们需要将获取到的 ``args_vec`` 传入进去并将里面的字符串压入到用户栈上。
.. code-block:: rust
:linenos:
:emphasize-lines: 11-34,45,50,51
// os/src/task/task.rs
impl TaskControlBlock {
pub fn exec(&self, elf_data: &[u8], args: Vec<String>) {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, mut user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// push arguments on user stack
user_sp -= (args.len() + 1) * core::mem::size_of::<usize>();
let argv_base = user_sp;
let mut argv: Vec<_> = (0..=args.len())
.map(|arg| {
translated_refmut(
memory_set.token(),
(argv_base + arg * core::mem::size_of::<usize>()) as *mut usize
)
})
.collect();
*argv[args.len()] = 0;
for i in 0..args.len() {
user_sp -= args[i].len() + 1;
*argv[i] = user_sp;
let mut p = user_sp;
for c in args[i].as_bytes() {
*translated_refmut(memory_set.token(), p as *mut u8) = *c;
p += 1;
}
*translated_refmut(memory_set.token(), p as *mut u8) = 0;
}
// make the user_sp aligned to 8B
user_sp -= user_sp % core::mem::size_of::<usize>();
// **** access current TCB exclusively
let mut inner = self.inner_exclusive_access();
// substitute memory_set
inner.memory_set = memory_set;
// update trap_cx ppn
inner.trap_cx_ppn = trap_cx_ppn;
// initialize trap_cx
let mut trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
trap_cx.x[10] = args.len();
trap_cx.x[11] = argv_base;
*inner.get_trap_cx() = trap_cx;
// **** release current PCB
}
}
第 11-34 行所做的主要工作是将命令行参数以某种格式压入用户栈。具体的格式可以参考下图(比如应用传入了两个命令行参数 ``aa````bb``
.. image:: user-stack-cmdargs.png
:align: center
- 首先需要在用户栈上分配一个字符串指针数组,也就是蓝色区域。数组中的每个元素都指向一个用户栈更低处的命令行参数字符串的起始地址。在第 12~24 行可以看到,最开始我们只是分配空间,具体的值要等到字符串被放到用户栈上之后才能确定更新。
- 第 23~32 行,我们逐个将传入的 ``args`` 中的字符串压入到用户栈中,对应于图中的橙色区域。为了实现方便,我们在用户栈上预留空间之后逐字节进行复制。注意 ``args`` 中的字符串是通过 ``translated_str`` 从应用地址空间取出的,它的末尾不包含 ``\0`` 。为了应用能知道每个字符串的长度,我们需要手动在末尾加入 ``\0``
- 第 34 行将 ``user_sp`` 以 8 字节对齐,在 Qemu 平台上其实可以忽略这一步。
我们还需要对应修改 Trap 上下文。首先是第 45 行,我们的 ``user_sp`` 相比之前已经发生了变化,它上面已经压入了命令行参数。同时,我们还需要修改 Trap 上下文中的 ``a0/a1`` 寄存器,让 ``a0`` 表示命令行参数的个数,而 ``a1`` 则表示图中 ``argv_base`` 即蓝色区域的起始地址。这两个参数在第一次进入对应应用的用户态的时候会被接收并用于还原命令行参数。
用户库从用户栈上还原命令行参数
+++++++++++++++++++++++++++++++++++++++++++++++++
在应用第一次进入用户态的时候,我们放在 Trap 上下文 a0/a1 两个寄存器中的内容可以被用户库中的入口函数以参数的形式接收:
.. code-block:: rust
:linenos:
:emphasize-lines: 10-24
// user/src/lib.rs
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
unsafe { // 初始化堆分配器
HEAP.lock()
.init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
}
let mut v: Vec<&'static str> = Vec::new();
for i in 0..argc {
let str_start = unsafe {
((argv + i * core::mem::size_of::<usize>()) as *const usize).read_volatile()
};
let len = (0usize..).find(|i| unsafe {
((str_start + *i) as *const u8).read_volatile() == 0
}).unwrap();
v.push(
core::str::from_utf8(unsafe {
core::slice::from_raw_parts(str_start as *const u8, len)
}).unwrap()
);
}
exit(main(argc, v.as_slice()));
}
可以看到,在入口 ``_start`` 中我们就接收到了命令行参数个数 ``argc`` 和字符串数组的起始地址 ``argv`` 。但是这个起始地址不太好用,我们希望能够将其转化为编写应用的时候看到的 ``&[&str]`` 的形式。转化的主体在第 10~23 行,就是分别取出 ``argc`` 个字符串的起始地址(基于字符串数组的 base 地址 ``argv`` ),从它向后找到第一个 ``\0`` 就可以得到一个完整的 ``&str`` 格式的命令行参数字符串并加入到向量 ``v`` 中。最后通过 ``v.as_slice`` 就得到了我们在 ``main`` 主函数中看到的 ``&[&str]``
有了命令行参数支持,我们就可以编写命令行工具 ``ch6b_cat`` 来输出指定文件的内容了。读者可以自行参阅其实现。
标准输入输出重定向
-------------------------------------------------
为了增强 shell 程序使用文件系统时的灵活性,我们需要新增标准输入输出重定向功能。
重定向功能对于应用来说是透明的。在应用中除非明确指出了数据要从指定的文件输入或者输出到指定的文件,否则数据默认都是输入自进程文件描述表位置 0 处的标准输入,并输出到进程文件描述符表位置 1 处的标准输出。
为了对应用进程的文件描述符表进行某种替换,引入一个新的系统调用 ``sys_dup``
.. code-block:: rust
// user/src/syscall.rs
/// 功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
/// 参数fd 表示进程中一个已经打开的文件的文件描述符。
/// 返回值:如果出现了错误则返回 -1否则能够访问已打开文件的新文件描述符。
/// 可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
/// syscall ID24
pub fn sys_dup(fd: usize) -> isize;
这个系统调用的实现非常简单:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_dup(fd: usize) -> isize {
let task = current_task().unwrap();
let mut inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if inner.fd_table[fd].is_none() {
return -1;
}
let new_fd = inner.alloc_fd();
inner.fd_table[new_fd] = Some(Arc::clone(inner.fd_table[fd].as_ref().unwrap()));
new_fd as isize
}
``sys_dup`` 函数中,首先检查传入 ``fd`` 的合法性。然后在文件描述符表中分配一个新的文件描述符,并保存 ``fd`` 指向的已打开文件的一份拷贝即可。
在shell程序 ``user_shell`` 分割命令行参数的时候,我们要检查是否存在通过 ``<````>`` 进行输入输出重定向的情况,如果存在的话则需要将它们从命令行参数中移除,并记录匹配到的输入文件名或输出文件名到字符串 ``input````output`` 中。注意为了实现方便我们这里假设输入shell程序的命令一定合法``<````>`` 最多只会出现一次,且后面总是会有一个参数作为重定向到的文件。
.. code-block:: rust
// user/src/bin/ch6b_user_shell.rs
// redirect input
let mut input = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == "<\0") {
input = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
// redirect output
let mut output = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == ">\0") {
output = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
打开文件和替换的过程则发生在 ``fork`` 之后的子进程分支中:
.. code-block:: rust
:linenos:
// user/src/bin/user_shell.rs
let pid = fork();
if pid == 0 {
// input redirection
if !input.is_empty() {
let input_fd = open(input.as_str(), OpenFlags::RDONLY);
if input_fd == -1 {
println!("Error when opening file {}", input);
return -4;
}
let input_fd = input_fd as usize;
close(0);
assert_eq!(dup(input_fd), 0);
close(input_fd);
}
// output redirection
if !output.is_empty() {
let output_fd = open(
output.as_str(),
OpenFlags::CREATE | OpenFlags::WRONLY
);
if output_fd == -1 {
println!("Error when opening file {}", output);
return -4;
}
let output_fd = output_fd as usize;
close(1);
assert_eq!(dup(output_fd), 1);
close(output_fd);
}
// child process
if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
println!("Error when executing!");
return -4;
}
unreachable!();
} else {
let mut exit_code: i32 = 0;
let exit_pid = waitpid(pid as usize, &mut exit_code);
assert_eq!(pid, exit_pid);
println!("Shell: Process {} exited with code {}", pid, exit_code);
}
- 输入重定向发生在第 6~16 行。我们尝试打开输入文件 ``input````input_fd`` 中。之后,首先通过 ``close`` 关闭标准输入所在的文件描述符 0 。之后通过 ``dup`` 来分配一个新的文件描述符来访问 ``input_fd`` 对应的输入文件。这里用到了文件描述符分配的重要性质:即必定分配可用描述符中编号最小的一个。由于我们刚刚关闭了描述符 0 ,那么在 ``dup`` 的时候一定会将它分配出去,于是现在应用进程的文件描述符 0 就对应到输入文件了。最后,因为应用进程的后续执行不会用到输入文件原来的描述符 ``input_fd`` ,所以就将其关掉。
- 输出重定向则发生在 18~31 行。它的原理和输入重定向几乎完全一致,只是通过 ``open`` 打开文件的标志不太相同

View File

@@ -0,0 +1,20 @@
chapter7练习
===========================================
编程作业
-------------------------------------------
本章无编程作业
问答作业
-------------------------------------------
(1) 举出使用 pipe 的一个实际应用的例子。
(2) 如果需要在多个进程间互相通信,则需要为每一对进程建立一个管道,非常繁琐,请设计一个更易用的多进程通信机制。
报告要求
---------------------------------------
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

@@ -0,0 +1,10 @@
第七章:进程间通信
==============================================
.. toctree::
:maxdepth: 4
0intro
1pipe
2cmdargs-and-redirection
3exercise

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

View File

@@ -0,0 +1,239 @@
引言
=========================================
本章导读
-----------------------------------------
到本章开始之前,我们好像已经完成了组成应用程序执行环境的操作系统的三个重要抽象:进程、地址空间和文件,
让应用程序开发、运行和存储数据越来越方便和灵活。有了进程以后,可以让操作系统从宏观层面实现多个应用的并发执行,
而并发是通过操作系统基于处理器的时间片不断地切换进程来达到的。到目前为止的并发,仅仅是进程间的并发,
对于一个进程内部还没有并发性的体现。而这就是线程Thread出现的起因提高一个进程内的并发性。
.. chyyuu
https://en.wikipedia.org/wiki/Per_Brinch_Hansen 关于操作系统并发 Binch Hansen 和 Hoare ??
https://en.wikipedia.org/wiki/Thread_(computing) 关于线程
http://www.serpentine.com/blog/threads-faq/the-history-of-threads/ The history of threads
https://en.wikipedia.org/wiki/Core_War 我喜欢的一种早期游戏
[Dijkstra, 65] Dijkstra, E. W., Cooperating sequential processes, in Programming Languages, Genuys, F. (ed.), Academic Press, 1965.
[Saltzer, 66] Saltzer, J. H., Traffic control in a multiplexed computer system, MAC-TR-30 (Sc.D. Thesis), July, 1966.
https://en.wikipedia.org/wiki/THE_multiprogramming_system
http://www.cs.utexas.edu/users/EWD/ewd01xx/EWD196.PDF
https://en.wikipedia.org/wiki/Edsger_W._Dijkstra
https://en.wikipedia.org/wiki/Per_Brinch_Hansen
https://en.wikipedia.org/wiki/Tony_Hoare
https://en.wikipedia.org/wiki/Mutual_exclusion
https://en.wikipedia.org/wiki/Semaphore_(programming)
https://en.wikipedia.org/wiki/Monitor_(synchronization)
Dijkstra, Edsger W. The structure of the 'THE'-multiprogramming system (EWD-196) (PDF). E.W. Dijkstra Archive. Center for American History, University of Texas at Austin. (transcription) (Jun 14, 1965)
有了进程以后,为什么还会出现线程呢?考虑如下情况,对于很多应用(以单一进程的形式运行)而言,
逻辑上存在多个可并行执行的任务,如果其中一个任务被阻塞,将会引起不依赖该任务的其他任务也被阻塞。
举个具体的例子,我们平常用编辑器来编辑文本内容的时候,都会有一个定时自动保存的功能,
这个功能的作用是在系统或应用本身出现故障的情况前,已有的文档内容会被提前保存。
假设编辑器自动保存时由于磁盘性能导致写入较慢,导致整个进程被操作系统挂起,这就会影响到用户编辑文档的人机交互体验:
即软件的及时响应能力不足,用户只有等到磁盘写入完成后,操作系统重新调度该进程运行后,用户才可编辑。
如果我们把一个进程内的多个可并行执行任务通过一种更细粒度的方式让操作系统进行调度,
那么就可以通过处理器时间片切换实现这种细粒度的并发执行。这种细粒度的调度对象就是线程。
.. _term-thread-define:
线程定义
~~~~~~~~~~~~~~~~~~~~
简单地说线程是进程的组成部分进程可包含1 -- n个线程属于同一个进程的线程共享进程的资源
比如地址空间、打开的文件等。基本的线程由线程ID、执行状态、当前指令指针 (PC)、寄存器集合和栈组成。
线程是可以被操作系统或用户态调度器独立调度Scheduling和分派Dispatch的基本单位。
在本章之前,进程是程序的基本执行实体,是程序关于某数据集合上的一次运行活动,是系统进行资源(处理器、
地址空间和文件等)分配和调度的基本单位。在有了线程后,对进程的定义也要调整了,进程是线程的资源容器,
线程成为了程序的基本执行实体。
同步互斥
~~~~~~~~~~~~~~~~~~~~~~
在上面提到了同步互斥和数据一致性,它们的含义是什么呢?当多个线程共享同一进程的地址空间时,
每个线程都可以访问属于这个进程的数据(全局变量)。如果每个线程使用到的变量都是其他线程不会读取或者修改的话,
那么就不存在一致性问题。如果变量是只读的,多个线程读取该变量也不会有一致性问题。但是,当一个线程修改变量时,
其他线程在读取这个变量时,可能会看到一个不一致的值,这就是数据不一致性的问题。
.. note::
**并发相关术语**
- 共享资源shared resource不同的线程/进程都能访问的变量或数据结构。
- 临界区critical section访问共享资源的一段代码。
- 竞态条件race condition多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。
- 不确定性indeterminate 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,
即执行结果不确定,而开发者期望得到的是确定的结果。
- 互斥mutual exclusion一种操作原语能保证只有一个线程进入临界区从而避免出现竞态并产生确定的执行结果。
- 原子性atomic一系列操作要么全部完成要么一个都没执行不会看到中间状态。在数据库领域
具有原子性的一系列操作称为事务transaction
- 同步synchronization多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
- 死锁dead lock一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程
(包括他自身)才能引发的事件,这种情况就是死锁。
- 饥饿hungry指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。
在后续的章节中,会大量使用上述术语,如果现在还不够理解,没关系,随着后续的一步一步的分析和实验,
相信大家能够掌握上述术语的实际含义。
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch8
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
记得更新测例仓库的代码。
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
内核初始化完成之后就会进入 shell 程序,我们可以体会一下线程的创建和执行过程。在这里我们运行一下本章的测例 ``ch8b_threads``
.. code-block::
>> ch8b_threads
aaa....bbb...ccc...
thread#1 exited with code 1
thread#2 exited with code 2
thread#3 exited with code 3
main thread exited.
Shell: Process 2 exited with code 0
>>
它会有4个线程在执行等前3个线程执行完毕后主线程退出导致整个进程退出。
此外,在本章的操作系统支持通过互斥来执行“哲学家就餐问题”这个应用程序:
.. code-block::
>> ch8b_phil_din_mutex
Here comes 5 philosophers!
time cost = 720
'-' -> THINKING; 'x' -> EATING; ' ' -> WAITING
#0: ------- xxxxxxxx---------- xxxx----- xxxxxx--xxx
#1: ---xxxxxx-- xxxxxxx---------- x---xxxxxx
#2: ----- xx---------xx----xxxxxx------------ xxxx
#3: -----xxxxxxxxxx------xxxxx-------- xxxxxx-- xxxxxxxxx
#4: ------ x------ xxxxxx-- xxxxx------ xx
#0: ------- xxxxxxxx---------- xxxx----- xxxxxx--xxx
Shell: Process 2 exited with code 0
>>
我们可以看到5个代表“哲学家”的线程通过操作系统的 **信号量** 互斥机制在进行 “THINKING”、“EATING”、“WAITING” 的日常生活。
没有哲学家由于拿不到筷子而饥饿,也没有两个哲学家同时拿到一个筷子。
.. note::
**哲学家就餐问题**
计算机科学家 Dijkstra 提出并解决的哲学家就餐问题是经典的进程同步互斥问题。哲学家就餐问题描述如下:
有5个哲学家共用一张圆桌分别坐在周围的5张椅子上在圆桌上有5个碗和5只筷子他们的生活方式是交替地进行思考和进餐。
平时,每个哲学家进行思考,饥饿时便试图拿起其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
本章代码树
-----------------------------------------
.. code-block::
:linenos:
.
├── bootloader
│ └── rustsbi-qemu.bin
├── Dockerfile
├── easy-fs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ ├── bitmap.rs
│ ├── block_cache.rs
│ ├── block_dev.rs
│ ├── efs.rs
│ ├── layout.rs
│ ├── lib.rs
│ └── vfs.rs
├── easy-fs-fuse
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── LICENSE
├── Makefile
├── os
│ ├── build.rs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── Makefile
│ └── src
│ ├── config.rs (修改:扩大了内核堆空间)
│ ├── console.rs
│ ├── drivers
│ │ ├── block
│ │ │ ├── mod.rs
│ │ │ └── virtio_blk.rs
│ │ └── mod.rs
│ ├── entry.asm
│ ├── fs
│ │ ├── inode.rs
│ │ ├── mod.rs
│ │ ├── pipe.rs
│ │ └── stdio.rs
│ ├── lang_items.rs
│ ├── linker.ld
│ ├── logging.rs
│ ├── main.rs
│ ├── mm
│ │ ├── address.rs
│ │ ├── frame_allocator.rs
│ │ ├── heap_allocator.rs
│ │ ├── memory_set.rs (修改:去除了构建进程地址空间时分配用户栈和映射陷入上下文的逻辑)
│ │ ├── mod.rs
│ │ └── page_table.rs
│ ├── sbi.rs
│ ├── sync (新增:互斥锁、信号量和条件变量三种同步互斥机制的实现)
│ │ ├── condvar.rs
│ │ ├── mod.rs
│ │ ├── mutex.rs
│ │ ├── semaphore.rs
│ │ └── up.rs
│ ├── syscall
│ │ ├── fs.rs (修改:将原先对 task 的调用改为对 process 的调用)
│ │ ├── mod.rs
│ │ ├── process.rs (修改:将原先对 task 的调用改为对 process 的调用)
│ │ ├── sync.rs (新增:三种同步互斥机制相关的系统调用,以及基于定时器条件变量的 sleep 调用)
│ │ └── thread.rs (新增:线程相关系统调用)
│ ├── task
│ │ ├── context.rs (修改:将任务上下文的成员变量改为 pub 类型)
│ │ ├── id.rs (新增:由 pid.rs 修改而来,提供 pid/tid 、 kstack/ustack 的分配和回收机制)
│ │ ├── kthread.rs (新增:完全在内核态运行的线程,仅供参考,在实验中未使用)
│ │ ├── manager.rs
│ │ ├── mod.rs (修改:增加阻塞线程的功能,将 exit 扩展到多线程,并在主线程退出时一并退出进程)
│ │ ├── processor.rs (修改:增加获取当前线程的中断上下文虚拟地址及获取当前进程的功能)
│ │ ├── process.rs (新增:将原先 Task 中的地址空间、文件等机制拆分为进程)
│ │ ├── stackless_coroutine.rs (新增:完全在内核态运行的无栈协程,仅供参考,在实验中未使用)
│ │ ├── switch.rs
│ │ ├── switch.S
│ │ └── task.rs (修改:将进程相关的功能移至 process.rs 中)
│ ├── timer.rs (修改:增加定时器条件变量的实现)
│ └── trap
│ ├── context.rs
│ ├── mod.rs (修改:使用线程对应的中断上下文地址而非固定的 TRAP_CONTEXT)
│ └── trap.S
├── README.md
└── rust-toolchain

View File

@@ -0,0 +1,485 @@
内核态的线程管理
=========================================
线程概念
---------------------------------------------
这里会结合与进程的比较来说明线程的概念。到本章之前,我们看到了进程这一抽象,操作系统让进程拥有相互隔离的虚拟的地址空间,
让进程感到在独占一个虚拟的处理器。其实这只是操作系统通过时分复用和空分复用技术来让每个进程复用有限的物理内存和物理CPU。
而线程是在进程内中的一个新的抽象。在没有线程之前,一个进程在一个时刻只有一个执行点(即程序计数器 (PC)
寄存器保存的要执行指令的指针)。但线程的引入把进程内的这个单一执行点给扩展为多个执行点,即在进程中存在多个线程,
每个线程都有一个执行点。而且这些线程共享进程的地址空间,所以可以不必采用相对比较复杂的 IPC 机制(一般需要内核的介入),
而可以很方便地直接访问进程内的数据。
在线程的具体运行过程中,需要有程序计数器寄存器来记录当前的执行位置,需要有一组通用寄存器记录当前的指令的操作数据,
需要有一个栈来保存线程执行过程的函数调用栈和局部变量等,这就形成了线程上下文的主体部分。
这样如果两个线程运行在一个处理器上,就需要采用类似两个进程运行在一个处理器上的调度/切换管理机制,
即需要在一定时刻进行线程切换,并进行线程上下文的保存与恢复。这样在一个进程中的多线程可以独立运行,
取代了进程,成为操作系统调度的基本单位。
由于把进程的结构进行了细化,通过线程来表示对处理器的虚拟化,使得进程成为了管理线程的容器。
在进程中的线程没有父子关系,大家都是兄弟,但还是有个老大。这个代表老大的线程其实就是创建进程(比如通过
``fork`` 系统调用创建进程建立的第一个线程它的线程标识符TID``0``
线程模型与重要系统调用
----------------------------------------------
目前,我们只介绍本章实现的内核中采用的一种非常简单的线程模型。这个线程模型有三个运行状态:
就绪态、运行态和等待态共享所属进程的地址空间和其他共享资源如文件等可被操作系统调度来分时占用CPU执行
可以动态创建和退出;可通过系统调用获得操作系统的服务。我们实现的线程模型建立在进程的地址空间抽象之上:
每个线程都共享进程的代码段和和可共享的地址空间(如全局数据段、堆等),但有自己的独占的栈。
线程模型需要操作系统支持一些重要的系统调用:创建线程、等待子线程结束等,来支持灵活的多线程应用。
接下来会介绍这些系统调用的基本功能和设计思路。
线程创建系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在一个进程的运行过程中进程可以创建多个属于这个进程的线程每个线程有自己的线程标识符TIDThread Identifier
系统调用 ``thread_create`` 的原型如下:
.. code-block:: rust
:linenos:
/// 功能:当前进程创建一个新的线程
/// 参数entry 表示线程的入口函数地址
/// 参数arg表示线程的一个参数
pub fn sys_thread_create(entry: usize, arg: usize) -> isize
当进程调用 ``thread_create`` 系统调用后,内核会在这个进程内部创建一个新的线程,这个线程能够访问到进程所拥有的代码段,
堆和其他数据段。但内核会给这个新线程分配一个它专有的用户态栈,这样每个线程才能相对独立地被调度和执行。
另外,由于用户态进程与内核之间有各自独立的页表,所以二者需要有一个跳板页 ``TRAMPOLINE``
来处理用户态切换到内核态的地址空间平滑转换的事务。所以当出现线程后,在进程中的每个线程也需要有一个独立的跳板页
``TRAMPOLINE`` 来完成同样的事务。
相比于创建进程的 ``fork`` 系统调用,创建线程不需要要建立新的地址空间,这是二者之间最大的不同。
另外属于同一进程中的线程之间没有父子关系,这一点也与进程不一样。
等待子线程系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
当一个线程执行完代表它的功能后,会通过 ``exit`` 系统调用退出。内核在收到线程发出的 ``exit`` 系统调用后,
会回收线程占用的部分资源,即用户态用到的资源,比如用户态的栈,用于系统调用和异常处理的跳板页等。
而该线程的内核态用到的资源,比如内核栈等,需要通过进程/主线程调用 ``waittid`` 来回收了,
这样整个线程才能被彻底销毁。系统调用 ``waittid`` 的原型如下:
.. code-block:: rust
:linenos:
/// 参数tid表示线程id
/// 返回值:如果线程不存在,返回-1如果线程还没退出返回-2其他情况下返回结束线程的退出码
pub fn sys_waittid(tid: usize) -> i32
一般情况下进程/主线程要负责通过 ``waittid`` 来等待它创建出来的线程(不是主线程)结束并回收它们在内核中的资源
(如线程的内核栈、线程控制块等)。如果进程/主线程先调用了 ``exit`` 系统调用来退出,那么整个进程
(包括所属的所有线程)都会退出,而对应父进程会通过 ``waitpid`` 回收子进程剩余还没被回收的资源。
进程相关的系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在引入了线程机制后,进程相关的重要系统调用: ``fork````exec````waitpid`` 虽然在接口上没有变化,
但在它要完成的功能上需要有一定的扩展。首先,需要注意到把以前进程中与处理器执行相关的部分拆分到线程中。这样,在通过
``fork`` 创建进程其实也意味着要单独建立一个主线程来使用处理器,并为以后创建新的线程建立相应的线程控制块向量。
相对而言, ``exec````waitpid`` 这两个系统调用要做的改动比较小,还是按照与之前进程的处理方式来进行。总体上看,
进程相关的这三个系统调用还是保持了已有的进程操作的语义,并没有由于引入了线程,而带来大的变化。
应用程序示例
----------------------------------------------
我们刚刚介绍了 thread_create/waittid 两个重要系统调用,我们可以借助它们和之前实现的系统调用,
开发出功能更为灵活的应用程序。下面我们通过描述一个多线程应用程序 ``threads`` 的开发过程来展示这些系统调用的使用方法。
系统调用封装
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
同学可以在 user/src/syscall.rs 中看到以 sys_* 开头的系统调用的函数原型,它们后续还会在 user/src/lib.rs 中被封装成方便应用程序使用的形式。如 ``sys_thread_create`` 被封装成 ``thread_create`` ,而 ``sys_waittid`` 被封装成 ``waittid``
.. code-block:: rust
:linenos:
pub fn thread_create(entry: usize, arg: usize) -> isize { sys_thread_create(entry, arg) }
pub fn waittid(tid: usize) -> isize {
loop {
match sys_waittid(tid) {
-2 => { yield_(); }
exit_code => return exit_code,
}
}
}
waittid 等待一个线程标识符的值为tid 的线程结束。在具体实现方面,我们看到当 sys_waittid 返回值为 -2 ,即要等待的线程存在但它却尚未退出的时候,主线程调用 yield_ 主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 sys_waittid 查看要等待的线程是否退出。这样做是为了减小 CPU 资源的浪费。这种方法是为了尽可能简化内核的实现。
多线程应用程序 -- threads
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
多线程应用程序 -- threads 开始执行后,先调用 ``thread_create`` 创建了三个线程加上进程自带的主线程其实一共有四个线程。每个线程在打印了1000个字符后会执行 ``exit`` 退出。进程通过 ``waittid`` 等待这三个线程结束后,最终结束进程的执行。下面是多线程应用程序 -- threads 的源代码:
.. code-block:: rust
:linenos:
//usr/src/bin/ch8b_threads.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use user_lib::{thread_create, waittid, exit};
use alloc::vec::Vec;
pub fn thread_a() -> ! {
for _ in 0..1000 { print!("a"); }
exit(1)
}
pub fn thread_b() -> ! {
for _ in 0..1000 { print!("b"); }
exit(2)
}
pub fn thread_c() -> ! {
for _ in 0..1000 { print!("c"); }
exit(3)
}
#[no_mangle]
pub fn main() -> i32 {
let mut v = Vec::new();
v.push(thread_create(thread_a as usize, 0));
v.push(thread_create(thread_b as usize, 0));
v.push(thread_create(thread_c as usize, 0));
for tid in v.iter() {
let exit_code = waittid(*tid as usize);
println!("thread#{} exited with code {}", tid, exit_code);
}
println!("main thread exited.");
0
}
线程管理的核心数据结构
-----------------------------------------------
为了在现有进程管理的基础上实现线程管理,我们需要改进一些数据结构包含的内容及接口。
基本思路就是把进程中与处理器相关的部分分拆出来,形成线程相关的部分。
本节将按照如下顺序来进行介绍:
- 任务控制块 TaskControlBlock :表示线程的核心数据结构。
- 任务管理器 TaskManager :管理线程集合的核心数据结构。
- 处理器管理结构 Processor :用于线程调度,维护线程的处理器状态。
线程控制块
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在内核中,每个线程的执行状态和线程上下文等均保存在一个被称为线程控制块 (TCB, Task Control Block)
的结构中,它是内核对线程进行管理的核心数据结构。在内核看来,它就等价于一个线程。
.. code-block:: rust
:linenos:
pub struct TaskControlBlock {
// immutable
pub process: Weak<ProcessControlBlock>,
pub kernel_stack: KernelStack,
// mutable
inner: UPSafeCell<TaskControlBlockInner>,
}
pub struct TaskControlBlockInner {
pub trap_cx_ppn: PhysPageNum,
pub task_cx: TaskContext,
pub task_status: TaskStatus,
pub exit_code: Option<i32>,
pub res: Option<TaskUserRes>,
}
线程控制块就是任务控制块TaskControlBlock主要包括在线程初始化之后就不再变化的元数据
线程所属的进程和线程的内核栈,以及在运行过程中可能发生变化的元数据: UPSafeCell<TaskControlBlockInner> 。
大部分的细节放在 ``TaskControlBlockInner`` 中:
之前进程中的定义不存在的:
- ``res: Option<TaskUserRes>`` 指出了用户态的线程代码执行需要的信息,这些在线程初始化之后就不再变化:
.. code-block:: rust
:linenos:
pub struct TaskUserRes {
pub tid: usize,
pub ustack_base: usize,
pub process: Weak<ProcessControlBlock>,
}
- tid线程标识符
- ustack_base线程的栈顶地址
- process线程所属的进程
与之前进程中的定义相同/类似的部分:
- ``trap_cx_ppn`` 指出了应用地址空间中线程的 Trap 上下文被放在的物理页帧的物理页号。
- ``task_cx`` 保存暂停线程的线程上下文,用于线程切换。
- ``task_status`` 维护当前线程的执行状态。
- ``exit_code`` 线程退出码。
包含线程的进程控制块
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
把线程相关数据单独组织成数据结构后,进程的结构也需要进行一定的调整:
.. code-block:: rust
:linenos:
pub struct ProcessControlBlock {
// immutable
pub pid: PidHandle,
// mutable
inner: UPSafeCell<ProcessControlBlockInner>,
}
pub struct ProcessControlBlockInner {
...
pub tasks: Vec<Option<Arc<TaskControlBlock>>>,
pub task_res_allocator: RecycleAllocator,
}
从中可以看出,进程把与处理器执行相关的部分都移到了 ``TaskControlBlock`` 中,并组织为一个线程控制块向量中,
这就自然对应到多个线程的管理上了。而 ``RecycleAllocator`` 是对之前的 ``PidAllocator`` 的一个升级版,
即一个相对通用的资源分配器可用于分配进程标识符PID和线程的内核栈KernelStack
.. chyyuu 加一个PidAllocator的链接???
线程与处理器管理结构
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
线程管理的结构是线程管理器,即任务管理器,位于 ``os/src/task/manager.rs`` 中,
其数据结构和方法与之前章节中进程的任务管理器完全一样,仅负责管理所有线程。而处理器管理结构 ``Processor``
负责维护 CPU 状态、调度和特权级切换等事务。其数据结构与之前章节中进程的处理器管理结构完全一样。
但在相关方法上面,由于多个线程有各自的用户栈和跳板页,所以有些不同,下面会进一步分析。
.. chyyuu 加一个taskmanager,processor的链接???
线程管理机制的设计与实现
-----------------------------------------------
在上述线程模型和内核数据结构的基础上,我们还需完成线程管理的基本实现,从而构造出一个完整的“达科塔盗龙”操作系统。
本节将分析如何实现线程管理:
- 线程创建、线程退出与等待线程结束
- 线程执行中的特权级切换
.. - 进程管理中与线程相关的处理
线程创建、线程退出与等待线程结束
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
线程创建
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当一个进程执行中发出了创建线程的系统调用 ``sys_thread_create`` 后,操作系统就需要在当前进程的基础上创建一个线程了,
这里重点是需要了解创建线程控制块,在线程控制块中初始化各个成员变量,建立好进程和线程的关系等。
只有建立好这些成员变量,才能给线程建立一个灵活方便的执行环境。这里列出支持线程正确运行所需的重要的执行环境要素:
- 线程的用户态栈:确保在用户态的线程能正常执行函数调用;
- 线程的内核态栈:确保线程陷入内核后能正常执行函数调用;
- 线程的跳板页:确保线程能正确的进行用户态<-->内核态切换;
- 线程上下文:即线程用到的寄存器信息,用于线程切换。
线程创建的具体实现如下:
.. code-block:: rust
:linenos:
// os/src/syscall/thread.rs
pub fn sys_thread_create(entry: usize, arg: usize) -> isize {
let task = current_task().unwrap();
let process = task.process.upgrade().unwrap();
// create a new thread
let new_task = Arc::new(TaskControlBlock::new(
Arc::clone(&process),
task.inner_exclusive_access().res.as_ref().unwrap().ustack_base,
true,
));
// add new task to scheduler
add_task(Arc::clone(&new_task));
let new_task_inner = new_task.inner_exclusive_access();
let new_task_res = new_task_inner.res.as_ref().unwrap();
let new_task_tid = new_task_res.tid;
let mut process_inner = process.inner_exclusive_access();
// add new thread to current process
let tasks = &mut process_inner.tasks;
while tasks.len() < new_task_tid + 1 {
tasks.push(None);
}
tasks[new_task_tid] = Some(Arc::clone(&new_task));
let new_task_trap_cx = new_task_inner.get_trap_cx();
*new_task_trap_cx = TrapContext::app_init_context(
entry,
new_task_res.ustack_top(),
kernel_token(),
new_task.kernel_stack.get_top(),
trap_handler as usize,
);
(*new_task_trap_cx).x[10] = arg;
new_task_tid as isize
}
上述代码主要完成了如下事务:
- 第4-5行找到当前正在执行的线程 ``task`` 和此线程所属的进程 ``process``
- 第7-11行调用 ``TaskControlBlock::new`` 方法,创建一个新的线程 ``new_task`` ,在创建过程中,建立与进程
``process`` 的所属关系,分配了线程用户态栈、内核态栈、用于异常/中断的跳板页。
- 第13行把线程挂到调度队列中。
- 第19-22行把线程接入到所需进程的线程列表 ``tasks`` 中。
- 第25~32行初始化位于该线程在用户态地址空间中的 Trap 上下文:设置线程的函数入口点和用户栈,
使得第一次进入用户态时能从线程起始位置开始正确执行;设置好内核栈和陷入函数指针 ``trap_handler``
保证在 Trap 的时候用户态的线程能正确进入内核态。
线程退出
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当一个非主线程的其他线程发出 ``sys_exit`` 系统调用时,内核会调用 ``exit_current_and_run_next``
函数退出当前线程并切换到下一个线程,但不会导致其所属进程的退出。当 **主线程** 即进程发出这个系统调用,
内核会回收整个进程(这包括了其管理的所有线程)资源,并退出。具体实现如下:
.. code-block:: rust
:linenos:
// os/src/syscall/process.rs
pub fn sys_exit(exit_code: i32) -> ! {
exit_current_and_run_next(exit_code);
panic!("Unreachable in sys_exit!");
}
// os/src/task/mod.rs
pub fn exit_current_and_run_next(exit_code: i32) {
let task = take_current_task().unwrap();
let mut task_inner = task.inner_exclusive_access();
let process = task.process.upgrade().unwrap();
let tid = task_inner.res.as_ref().unwrap().tid;
// record exit code
task_inner.exit_code = Some(exit_code);
task_inner.res = None;
// here we do not remove the thread since we are still using the kstack
// it will be deallocated when sys_waittid is called
drop(task_inner);
drop(task);
// however, if this is the main thread of current process
// the process should terminate at once
if tid == 0 {
let mut process_inner = process.inner_exclusive_access();
// mark this process as a zombie process
process_inner.is_zombie = true;
// record exit code of main process
process_inner.exit_code = exit_code;
{
// move all child processes under init process
let mut initproc_inner = INITPROC.inner_exclusive_access();
for child in process_inner.children.iter() {
child.inner_exclusive_access().parent = Some(Arc::downgrade(&INITPROC));
initproc_inner.children.push(child.clone());
}
}
let mut recycle_res = Vec::<TaskUserRes>::new();
// deallocate user res (including tid/trap_cx/ustack) of all threads
// it has to be done before we dealloc the whole memory_set
// otherwise they will be deallocated twice
for task in process_inner.tasks.iter().filter(|t| t.is_some()) {
let task = task.as_ref().unwrap();
let mut task_inner = task.inner_exclusive_access();
if let Some(res) = task_inner.res.take() {
recycle_res.push(res);
}
}
drop(process_inner);
recycle_res.clear();
let mut process_inner = process.inner_exclusive_access();
process_inner.children.clear();
// deallocate other data in user space i.e. program code/data section
process_inner.memory_set.recycle_data_pages();
}
drop(process);
// we do not have to save task context
let mut _unused = TaskContext::zero_init();
schedule(&mut _unused as *mut _);
}
上述代码主要完成了如下事务:
- 第11-21行回收线程的各种资源。
- 第24-56行如果是主线程发出的退去请求则回收整个进程的部分资源并退出进程。第 33~37
行所做的事情是将当前进程的所有子进程挂在初始进程 INITPROC 下面,其做法是遍历每个子进程,
修改其父进程为初始进程,并加入初始进程的孩子向量中。第 49 行将当前进程的孩子向量清空。
- 第58-59行进行线程调度切换。
上述实现中很大一部分与第五章讲解的 进程的退出 的功能实现大致相同。
.. chyyuu 加上链接???
等待线程结束
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
主线程通过系统调用 ``sys_waittid`` 来等待其他线程的结束。具体实现如下:
.. code-block:: rust
:linenos:
// os/src/syscall/ch8b_thread.rs
pub fn sys_waittid(tid: usize) -> i32 {
let task = current_task().unwrap();
let process = task.process.upgrade().unwrap();
let task_inner = task.inner_exclusive_access();
let mut process_inner = process.inner_exclusive_access();
// a thread cannot wait for itself
if task_inner.res.as_ref().unwrap().tid == tid {
return -1;
}
let mut exit_code: Option<i32> = None;
let waited_task = process_inner.tasks[tid].as_ref();
if let Some(waited_task) = waited_task {
if let Some(waited_exit_code) = waited_task.inner_exclusive_access().exit_code {
exit_code = Some(waited_exit_code);
}
} else {
// waited thread does not exist
return -1;
}
if let Some(exit_code) = exit_code {
// dealloc the exited thread
process_inner.tasks[tid] = None;
exit_code
} else {
// waited thread has not exited
-2
}
}
上述代码主要完成了如下事务:
- 第9-10行如果是线程等自己返回错误.
- 第12-21行如果找到 ``tid`` 对应的退出线程,则收集该退出线程的退出码 ``exit_tid`` ,否则返回错误(退出线程不存在)。
- 第22-29行如果退出码存在则清空进程中对应此退出线程的线程控制块至此线程所占资源算是全部清空了否则返回错误线程还没退出
线程执行中的特权级切换和调度切换
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
线程执行中的特权级切换与第三章中 **任务切换的设计与实现** 小节中讲解的过程是一致的。而线程执行中的调度切换过程与第五章的 **进程调度机制** 小节中讲解的过程是一致的。
这里就不用再赘述一遍了。
.. [#dak] 达科塔盗龙是一种生存于距今6700万-6500万年前白垩纪晚期的兽脚类驰龙科恐龙它主打的并不是霸王龙的力量路线而是利用自己修长的后肢来提高敏捷度和奔跑速度。它全身几乎都长满了羽毛可能会滑翔或者其他接近飞行行为的行动模式。

View File

@@ -0,0 +1,379 @@
锁机制
=========================================
本节导读
-----------------------------------------
.. chyyuu https://en.wikipedia.org/wiki/Lock_(computer_science)
到目前为止,我们已经实现了进程和线程,也能够理解在一个时间段内,会有多个线程在执行,这就是并发。
而且,由于线程的引入,多个线程可以共享进程中的全局数据。如果多个线程都想读和更新全局数据,
那么谁先更新取决于操作系统内核的抢占式调度和分派策略。在一般情况下,每个线程都有可能先执行,
且可能由于中断等因素,随时被操作系统打断其执行,而切换到另外一个线程运行,
形成在一段时间内,多个线程交替执行的现象。如果没有一些保障机制(比如互斥、同步等),
那么这些对共享数据进行读写的交替执行的线程,其期望的共享数据的正确结果可能无法达到。
所以,我们需要研究一种保障机制 --- 锁 ,确保无论操作系统如何抢占线程,调度和切换线程的执行,
都可以保证对拥有锁的线程,可以独占地对共享数据进行读写,从而能够得到正确的共享数据结果。
这种机制的能力来自于处理器的指令、操作系统系统调用的基本支持,从而能够保证线程间互斥地读写共享数据。
下面各个小节将从为什么需要锁、锁的基本思路、锁的不同实现方式等逐步展开讲解。
为什么需要锁
-----------------------------------------
上一小节已经提到,没有保障机制的多个线程,在对共享数据进行读写的过程中,可能得不到预期的结果。
我们来看看这个简单的例子:
.. code-block:: c
:linenos:
:emphasize-lines: 4
// 线程的入口函数
int a=0;
void f() {
a = a + 1;
}
对于上述函数中的第 4 行代码,一般人理解处理器会一次就执行完这条简单的语句,但实际情况并不是这样。
我们可以用 GCC 编译出上述函数的汇编码:
.. code-block:: shell
:linenos:
$ riscv64-unknown-elf-gcc -o f.s -S f.c
可以看到生成的汇编代码如下:
.. code-block:: asm
:linenos:
:emphasize-lines: 18-23
//f.s
.text
.globl a
.section .sbss,"aw",@nobits
.align 2
.type a, @object
.size a, 4
a:
.zero 4
.text
.align 1
.globl f
.type f, @function
f:
addi sp,sp,-16
sd s0,8(sp)
addi s0,sp,16
lui a5,%hi(a)
lw a5,%lo(a)(a5)
addiw a5,a5,1
sext.w a4,a5
lui a5,%hi(a)
sw a4,%lo(a)(a5)
nop
ld s0,8(sp)
addi sp,sp,16
jr ra
.. chyyuu 可以给上面的汇编码添加注释???
从中可以看出对于高级语言的一条简单语句C 代码的第 4 行,对全局变量进行读写),很可能是由多条汇编代码
(汇编代码的第 18~23 行)组成。如果这个函数是多个线程要执行的函数,那么在上述汇编代码第
18 行到第 23 行中的各行之间,可能会发生中断,从而导致操作系统执行抢占式的线程调度和切换,
就会得到不一样的结果。由于执行这段汇编代码(第 18~23 行))的多个线程在访问全局变量过程中可能导致竞争状态,
因此我们将此段代码称为临界区critical section。临界区是访问共享变量或共享资源的代码片段
不能由多个线程同时执行,即需要保证互斥。
下面是有两个线程T0、T1在一个时间段内的一种可能的执行情况
===== ===== ======= ======= =========== =========
时间 T0 T1 OS 共享变量a 寄存器a5
===== ===== ======= ======= =========== =========
1 L18 -- -- 0 a的高位地址
2 -- -- 切换 0 0
3 -- L18 -- 0 a的高位地址
4 L20 -- -- 0 1
5 -- -- 切换 0 a的高位地址
6 -- L20 -- 0 1
7 -- -- 切换 0 1
8 L23 -- -- 1 1
9 -- -- 切换 1 1
10 -- L23 -- 1 1
===== ===== ======= ======= =========== =========
一般情况下,线程 T0 执行完毕后,再执行线程 T1那么共享全局变量 ``a`` 的值为 2 。但在上面的执行过程中,
可以看到在线程执行指令的过程中会发生线程切换,这样在时刻 10 的时候,共享全局变量 ``a`` 的值为 1
这不是我们预期的结果。出现这种情况的原因是两个线程在操作系统的调度下(在哪个时刻调度具有不确定性),
交错执行 ``a = a + 1`` 的不同汇编指令序列,导致虽然增加全局变量 ``a`` 的代码被执行了两次,
但结果还是只增加了 1 。这种多线程的最终执行结果不确定indeterminate取决于由于调度导致的、
不确定指令执行序列的情况就是竞态条件race condition
如果每个线程在执行 ``a = a + 1`` 这个 C 语句所对应多条汇编语句过程中,不会被操作系统切换,
那么就不会出现多个线程交叉读写全局变量的情况,也就不会出现结果不确定的问题了。
所以,访问(特指写操作)共享变量代码片段,不能由多个线程同时执行(即并行)或者在一个时间段内都去执行
(即并发)。要做到这一点,需要互斥机制的保障。从某种角度上看,这种互斥性也是一种原子性,
即线程在临界区的执行过程中,不会出现只执行了一部分,就被打断并切换到其他线程执行的情况。即,
要么线程执行的这一系列操作/指令都完成,要么这一系列操作/指令都不做,不会出现指令序列执行中被打断的情况。
锁的基本思路
-----------------------------------------
要保证多线程并发执行中的临界区的代码具有互斥性或原子性,我们可以建立一种锁,
只有拿到锁的线程才能在临界区中执行。这里的锁与现实生活中的锁的含义很类似。比如,我们可以写出如下的伪代码:
.. code-block:: Rust
:linenos:
lock(mutex); // 尝试取锁
a = a + 1; // 临界区,访问临界资源 a
unlock(mutex); // 是否锁
... // 剩余区
对于一个应用程序而言,它的执行是受到其执行环境的管理和限制的,而执行环境的主要组成就是用户态的系统库、
操作系统和更底层的处理器,这说明我们需要有硬件和操作系统来对互斥进行支持。一个自然的想法是,这个
``lock/unlock`` 互斥操作就是CPU提供的机器指令那上面这一段程序就很容易在计算机上执行了。
但需要注意,这里互斥的对象是线程的临界区代码,而临界区代码可以访问各种共享变量(简称临界资源)。
只靠两条机器指令,难以识别各种共享变量,不太可能约束可能在临界区的各种指令执行共享变量操作的互斥性。
所以,我们还是需要有一些相对更灵活和复杂一点的方法,能够设置一种所有线程能看到的标记,
在一个能进入临界区的线程设置好这个标记后,其他线程都不能再进入临界区了。总体上看,
对临界区的访问过程分为四个部分:
1. 尝试取锁: 查看锁是否可用,即临界区是否可访问(看占用临界区标志是否被设置),如果可以访问,
则设置占用临界区标志(锁不可用)并转到步骤 2 ,否则线程忙等或被阻塞;
2. 临界区: 访问临界资源的系列操作
3. 释放锁: 清除占用临界区标志(锁可用),如果有线程被阻塞,会唤醒阻塞线程;
4. 剩余区: 与临界区不相关部分的代码
根据上面的步骤可以看到锁机制有两种让线程忙等的忙等锁spin lock以及让线程阻塞的睡眠锁
sleep lock。锁的实现大体上基于三类机制用户态软件、机器指令硬件、内核态操作系统。
下面我们介绍来 rCore 中基于内核态操作系统级方法实现的支持互斥的锁。
我们还需要知道如何评价各种锁实现的效果。一般我们需要关注锁的三种属性:
1. 互斥性mutual exclusion即锁是否能够有效阻止多个线程进入临界区这是最基本的属性。
2. 公平性fairness当锁可用时每个竞争线程是否有公平的机会抢到锁。
3. 性能performance即使用锁的时间开销。
内核态操作系统级方法实现锁 --- mutex 系统调用
-----------------------------------------
使用 mutex 系统调用
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
如何能够实现轻量的可睡眠锁?一个自然的想法就是,让等待锁的线程睡眠,让释放锁的线程显式地唤醒等待锁的线程。
如果有多个等待锁的线程,可以全部释放,让大家再次竞争锁;也可以只释放最早等待的那个线程。
这就需要更多的操作系统支持,特别是需要一个等待队列来保存等待锁的线程。
我们先看看多线程应用程序如何使用mutex系统调用的
.. code-block:: Rust
:linenos:
:emphasize-lines: 8,13,21,32,35,38
// user/src/bin/race_adder_mutex_blocking.rs
static mut A: usize = 0;
...
unsafe fn f() -> ! {
let mut t = 2usize;
for _ in 0..PER_THREAD {
mutex_lock(0);
let a = &mut A as *mut usize;
let cur = a.read_volatile();
for _ in 0..500 { t = t * t % 10007; }
a.write_volatile(cur + 1);
mutex_unlock(0);
}
exit(t as i32)
}
#[no_mangle]
pub fn main() -> i32 {
let start = get_time();
assert_eq!(mutex_blocking_create(), 0);
let mut v = Vec::new();
for _ in 0..THREAD_COUNT {
v.push(thread_create(f as usize, 0) as usize);
}
...
}
// usr/src/syscall.rs
pub fn sys_mutex_create(blocking: bool) -> isize {
syscall(SYSCALL_MUTEX_CREATE, [blocking as usize, 0, 0])
}
pub fn sys_mutex_lock(id: usize) -> isize {
syscall(SYSCALL_MUTEX_LOCK, [id, 0, 0])
}
pub fn sys_mutex_unlock(id: usize) -> isize {
syscall(SYSCALL_MUTEX_UNLOCK, [id, 0, 0])
}
- 第21行创建了一个ID为 ``0`` 的互斥锁对应的是第32行 ``SYSCALL_MUTEX_CREATE`` 系统调用;
- 第8行尝试获取锁对应的是第35行 ``SYSCALL_MUTEX_LOCK`` 系统调用),如果取得锁,
将继续向下执行临界区代码;如果没有取得锁,将阻塞;
- 第13行释放锁对应的是第38行 ``SYSCALL_MUTEX_UNLOCK`` 系统调用),如果有等待在该锁上的线程,
则唤醒这些等待线程。
mutex 系统调用的实现
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
操作系统如何实现这些系统调用呢?首先考虑一下与此相关的核心数据结构,
然后考虑与数据结构相关的相关函数/方法的实现。
在线程的眼里, **互斥** 是一种每个线程能看到的资源,且在一个进程中,可以存在多个不同互斥资源,
所以我们可以把所有的互斥资源放在一起让进程来管理,如下面代码第 9 行所示。这里需要注意的是:
``mutex_list: Vec<Option<Arc<dyn Mutex>>>`` 表示的是实现了 ``Mutex`` trait 的一个“互斥资源”的向量。而
``MutexBlocking`` 是会实现 ``Mutex`` trait 的内核数据结构,它就是我们提到的 **互斥资源**
**互斥锁** 。操作系统需要显式地施加某种控制,来确定当一个线程释放锁时,等待的线程谁将能抢到锁。
为了做到这一点,操作系统需要有一个等待队列来保存等待锁的线程,如下面代码的第 20 行所示。
.. code-block:: Rust
:linenos:
:emphasize-lines: 9,20
pub struct ProcessControlBlock {
// immutable
pub pid: PidHandle,
// mutable
inner: UPSafeCell<ProcessControlBlockInner>,
}
pub struct ProcessControlBlockInner {
...
pub mutex_list: Vec<Option<Arc<dyn Mutex>>>,
}
pub trait Mutex: Sync + Send {
fn lock(&self);
fn unlock(&self);
}
pub struct MutexBlocking {
inner: UPSafeCell<MutexBlockingInner>,
}
pub struct MutexBlockingInner {
locked: bool,
wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
这样,在操作系统中,需要设计实现三个核心成员变量。互斥锁的成员变量有两个:表示是否锁上的 ``locked``
和管理等待线程的等待队列 ``wait_queue``;进程的成员变量:锁向量 ``mutex_list``
首先需要创建一个互斥锁,下面是应对 ``SYSCALL_MUTEX_CREATE`` 系统调用的创建互斥锁的函数:
.. code-block:: Rust
:linenos:
:emphasize-lines: 14,18
// os/src/syscall/sync.rs
pub fn sys_mutex_create(blocking: bool) -> isize {
let process = current_process();
let mut process_inner = process.inner_exclusive_access();
if let Some(id) = process_inner
.mutex_list
.iter()
.enumerate()
.find(|(_, item)| item.is_none())
.map(|(id, _)| id) {
process_inner.mutex_list[id] = if !blocking {
Some(Arc::new(MutexSpin::new()))
} else {
Some(Arc::new(MutexBlocking::new()))
};
id as isize
} else {
process_inner.mutex_list.push(Some(Arc::new(MutexSpin::new())));
process_inner.mutex_list.len() as isize - 1
}
}
- 第 14 行,如果向量中有空的元素,就在这个空元素的位置创建一个可睡眠的互斥锁;
- 第 18 行,如果向量满了,就在向量中添加新的可睡眠的互斥锁;
有了互斥锁,接下来就是实现 ``Mutex`` trait的内核函数对应 ``SYSCALL_MUTEX_LOCK`` 系统调用的
``sys_mutex_lock`` 。操作系统主要工作是,在锁已被其他线程获取的情况下,把当前线程放到等待队列中,
并调度一个新线程执行。主要代码如下:
.. code-block:: Rust
:linenos:
:emphasize-lines: 8,16,17,19,21
// os/src/syscall/sync.rs
pub fn sys_mutex_lock(mutex_id: usize) -> isize {
let process = current_process();
let process_inner = process.inner_exclusive_access();
let mutex = Arc::clone(process_inner.mutex_list[mutex_id].as_ref().unwrap());
drop(process_inner);
drop(process);
mutex.lock();
0
}
// os/src/sync/mutex.rs
impl Mutex for MutexBlocking {
fn lock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
if mutex_inner.locked {
mutex_inner.wait_queue.push_back(current_task().unwrap());
drop(mutex_inner);
block_current_and_run_next();
} else {
mutex_inner.locked = true;
}
}
}
.. chyyuu drop的作用
- 第 8 行,调用 ID 为 ``mutex_id`` 的互斥锁 ``mutex````lock`` 方法,具体工作由该方法来完成。
- 第 16 行,如果互斥锁 ``mutex`` 已经被其他线程获取了,那么在第 17 行,将把当前线程放入等待队列中;
在第 19 行,让当前线程处于等待状态,并调度其他线程执行。
- 第 21 行,如果互斥锁 ``mutex`` 还没被获取,那么当前线程会获取给互斥锁,并返回系统调用。
最后是实现 ``Mutex`` trait 的内核函数:对应 ``SYSCALL_MUTEX_UNLOCK`` 系统调用的 ``sys_mutex_unlock``
操作系统的主要工作是,如果有等待在这个互斥锁上的线程,需要唤醒最早等待的线程。主要代码如下:
.. code-block:: Rust
:linenos:
:emphasize-lines: 8,17-18,20
// os/src/syscall/sync.rs
pub fn sys_mutex_unlock(mutex_id: usize) -> isize {
let process = current_process();
let process_inner = process.inner_exclusive_access();
let mutex = Arc::clone(process_inner.mutex_list[mutex_id].as_ref().unwrap());
drop(process_inner);
drop(process);
mutex.unlock();
0
}
// os/src/sync/mutex.rs
impl Mutex for MutexBlocking {
fn unlock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
assert!(mutex_inner.locked);
if let Some(waking_task) = mutex_inner.wait_queue.pop_front() {
add_task(waking_task);
} else {
mutex_inner.locked = false;
}
}
}
- 第 8 行,调用 ID 为 ``mutex_id`` 的互斥锁 ``mutex````unlock`` 方法,具体工作由该方法来完成的。
- 第 17-18 行,如果有等待的线程,唤醒等待最久的那个线程,相当于将锁的所有权移交给该线程。
- 第 20 行,若没有线程等待,则释放锁。

View File

@@ -0,0 +1,275 @@
信号量机制
=========================================
本节导读
-----------------------------------------
.. chyyuu https://en.wikipedia.org/wiki/Semaphore_(programming)
在上一节中我们介绍了互斥锁mutex 或 lock的起因、使用和实现过程。通过互斥锁
可以让线程在临界区执行时,独占临界资源。当我们需要更灵活的互斥访问或同步操作方式,如提供了最多只允许
N 个线程访问临界资源的情况,让某个线程等待另外一个线程执行完毕后再继续执行的同步过程等,
互斥锁这种方式就有点力不从心了。
在本节中,将介绍功能更加强大和灵活的同步互斥机制 -- 信号量Semaphore它的设计思路、
使用和在操作系统中的具体实现。可以看到,信号量的实现需要互斥锁和处理器原子指令的支持,
它是一种更高级的同步互斥机制。
信号量的起源和基本思路
-----------------------------------------
1963 年前后当时的数学家其实是计算机科学家Edsger Dijkstra 和他的团队在为 Electrologica X8
计算机开发一个操作系统(称为 THE multiprogramming systemTHE 多道程序系统)的过程中,提出了信号量
Semphore是一种变量或抽象数据类型用于控制多个线程对共同资源的访问。
信号量是对互斥锁的一种巧妙的扩展。上一节中的互斥锁的初始值一般设置为 1 的整型变量,
表示临界区还没有被某个线程占用。互斥锁用 0 表示临界区已经被占用了,用 1 表示临界区为空,再通过
``lock/unlock`` 操作来协调多个线程轮流独占临界区执行。而信号量的初始值可设置为 N 的整数变量, 如果 N
大于 0 表示最多可以有 N 个线程进入临界区执行,如果 N 小于等于 0 ,表示不能有线程进入临界区了,
必须在后续操作中让信号量的值加 1 ,才能唤醒某个等待的线程。
Dijkstra 对信号量设计了两种操作PProberen荷兰语尝试操作和 VVerhogen荷兰语增加操作。
P 操作是检查信号量的值是否大于 0若该值大于 0则将其值减 1 并继续(表示可以进入临界区了);若该值为
0则线程将睡眠。注意此时 P 操作还未结束。而且由于信号量本身是一种临界资源(可回想一下上一节的锁,
其实也是一种临界资源),所以在 P 操作中,检查/修改信号量值以及可能发生的睡眠这一系列操作,
是一个不可分割的原子操作过程。通过原子操作才能保证,一旦 P 操作开始,则在该操作完成或阻塞睡眠之前,
其他线程均不允许访问该信号量。
V 操作会对信号量的值加 1 ,然后检查是否有一个或多个线程在该信号量上睡眠等待。如有,
则选择其中的一个线程唤醒并允许该线程继续完成它的 P 操作;如没有,则直接返回。注意,信号量的值加 1
并可能唤醒一个线程的一系列操作同样也是不可分割的原子操作过程。不会有某个进程因执行 V 操作而阻塞。
如果信号量是一个任意的整数通常被称为计数信号量Counting Semaphore或一般信号量General
Semaphore如果信号量只有0或1的取值则称为二值信号量Binary Semaphore。可以看出
互斥锁是信号量的一种特例 --- 二值信号量,信号量很好地解决了最多允许 N 个线程访问临界资源的情况。
信号量的一种实现伪代码如下所示:
.. code-block:: rust
:linenos:
fn P(S) {
if S >= 1
S = S - 1;
else
<block and enqueue the thread>;
}
fn V(S) {
if <some threads are blocked on the queue>
<unblock a thread>;
else
S = S + 1;
}
在上述实现中S 的取值范围为大于等于 0 的整数。S 的初值一般设置为一个大于 0 的正整数,
表示可以进入临界区的线程数。当 S 取值为 1表示是二值信号量也就是互斥锁了。
使用信号量实现线程互斥访问临界区的伪代码如下:
.. code-block:: rust
:linenos:
let static mut S: semaphore = 1;
// Thread i
fn foo() {
...
P(S);
execute Cricital Section;
V(S);
...
}
下面是另外一种信号量实现的伪代码:
.. code-block:: rust
:linenos:
fn P(S) {
S = S - 1;
if S < 0 then
<block and enqueue the thread>;
}
fn V(S) {
S = S + 1;
if <some threads are blocked on the queue>
<unblock a thread>;
}
在这种实现中S 的初值一般设置为一个大于 0 的正整数,表示可以进入临界区的线程数。但 S
的取值范围可以是小于 0 的整数,表示等待进入临界区的睡眠线程数。
信号量的另一种用途是用于实现同步synchronization。比如把信号量的初始值设置为 0
当一个线程 A 对此信号量执行一个 P 操作,那么该线程立即会被阻塞睡眠。之后有另外一个线程 B
对此信号量执行一个 V 操作,就会将线程 A 唤醒。这样线程 B 中执行 V 操作之前的代码序列 B-stmts
和线程 A 中执行 P 操作之后的代码 A-stmts 序列之间就形成了一种确定的同步执行关系,即线程 B 的
B-stmts 会先执行,然后才是线程 A 的 A-stmts 开始执行。相关伪代码如下所示:
.. code-block:: rust
:linenos:
let static mut S: semaphore = 0;
//Thread A
...
P(S);
Label_2:
A-stmts after Thread B::Label_1;
...
//Thread B
...
B-stmts before Thread A::Label_2;
Label_1:
V(S);
...
实现信号量
------------------------------------------
使用 semaphore 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
我们通过例子来看看如何实际使用信号量。下面是面向应用程序对信号量系统调用的简单使用,
可以看到对它的使用与上一节介绍的互斥锁系统调用类似。
在这个例子中,主线程先创建了信号量初值为 0 的信号量 ``SEM_SYNC`` ,然后再创建两个线程 First
和 Second 。线程 First 会先睡眠 10ms而当线程 Second 执行时,会由于执行信号量的 P
操作而等待睡眠;当线程 First 醒来后,会执行 V 操作,从而能够唤醒线程 Second。这样线程 First
和线程 Second 就形成了一种稳定的同步关系。
.. code-block:: rust
:linenos:
:emphasize-lines: 5,10,16,22,25,28
const SEM_SYNC: usize = 0; //信号量ID
unsafe fn first() -> ! {
sleep(10);
println!("First work and wakeup Second");
semaphore_up(SEM_SYNC); //信号量V操作
exit(0)
}
unsafe fn second() -> ! {
println!("Second want to continue,but need to wait first");
semaphore_down(SEM_SYNC); //信号量P操作
println!("Second can work now");
exit(0)
}
pub fn main() -> i32 {
// create semaphores
assert_eq!(semaphore_create(0) as usize, SEM_SYNC); // 信号量初值为0
// create first, second threads
...
}
pub fn sys_semaphore_create(res_count: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_CREATE, [res_count, 0, 0])
}
pub fn sys_semaphore_up(sem_id: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_UP, [sem_id, 0, 0])
}
pub fn sys_semaphore_down(sem_id: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_DOWN, [sem_id, 0, 0])
}
- 第 16 行,创建了一个初值为 0 ID 为 ``SEM_SYNC`` 的信号量,对应的是第 22 行
``SYSCALL_SEMAPHORE_CREATE`` 系统调用;
- 第 10 行,线程 Second 执行信号量 P 操作(对应第 28行 ``SYSCALL_SEMAPHORE_DOWN``
系统调用),由于信号量初值为 0 ,该线程将阻塞;
- 第 5 行,线程 First 执行信号量 V 操作(对应第 25 行 ``SYSCALL_SEMAPHORE_UP`` 系统调用),
会唤醒等待该信号量的线程 Second。
实现 semaphore 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
操作系统如何实现信号量系统调用呢?我们还是采用通常的分析做法:数据结构+方法,
即首先考虑一下与此相关的核心数据结构,然后考虑与数据结构相关的相关函数/方法的实现。
在线程的眼里,信号量是一种每个线程能看到的共享资源,且在一个进程中,可以存在多个不同信号量资源,
所以我们可以把所有的信号量资源放在一起让进程来管理,如下面代码第 9 行所示。这里需要注意的是:
``semaphore_list: Vec<Option<Arc<Semaphore>>>`` 表示的是信号量资源的列表。而 ``Semaphore``
是信号量的内核数据结构,由信号量值和等待队列组成。操作系统需要显式地施加某种控制,来确定当一个线程执行
P 操作和 V 操作时如何让线程睡眠或唤醒线程。在这里P 操作是由 ``Semaphore````down``
方法实现,而 V 操作是由 ``Semaphore````up`` 方法实现。
.. code-block:: rust
:linenos:
:emphasize-lines: 9,16,17,34-36,44-47
pub struct ProcessControlBlock {
// immutable
pub pid: PidHandle,
// mutable
inner: UPSafeCell<ProcessControlBlockInner>,
}
pub struct ProcessControlBlockInner {
...
pub semaphore_list: Vec<Option<Arc<Semaphore>>>,
}
pub struct Semaphore {
pub inner: UPSafeCell<SemaphoreInner>,
}
pub struct SemaphoreInner {
pub count: isize,
pub wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
impl Semaphore {
pub fn new(res_count: usize) -> Self {
Self {
inner: unsafe { UPSafeCell::new(
SemaphoreInner {
count: res_count as isize,
wait_queue: VecDeque::new(),
}
)},
}
}
pub fn up(&self) {
let mut inner = self.inner.exclusive_access();
inner.count += 1;
if inner.count <= 0 {
if let Some(task) = inner.wait_queue.pop_front() {
add_task(task);
}
}
}
pub fn down(&self) {
let mut inner = self.inner.exclusive_access();
inner.count -= 1;
if inner.count < 0 {
inner.wait_queue.push_back(current_task().unwrap());
drop(inner);
block_current_and_run_next();
}
}
}
首先是核心数据结构:
- 第 9 行,进程控制块中管理的信号量列表。
- 第 16-17 行,信号量的核心数据成员:信号量值和等待队列。
然后是重要的三个成员函数:
- 第 20 行,创建信号量,信号量初值为参数 ``res_count``
- 第 31 行,实现 V 操作的 ``up`` 函数,第 34 行,当信号量值小于等于 0 时,
将从信号量的等待队列中弹出一个线程放入线程就绪队列。
- 第 41 行,实现 P 操作的 ``down`` 函数,第 44 行,当信号量值小于 0 时,
将把当前线程放入信号量的等待队列,设置当前线程为挂起状态并选择新线程执行。
Dijkstra, Edsger W. Cooperating sequential processes (EWD-123) (PDF). E.W. Dijkstra Archive.
Center for American History, University of Texas at Austin. (transcription) (September 1965)
https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html
Downey, Allen B. (2016) [2005]. "The Little Book of Semaphores" (2nd ed.). Green Tea Press.
Leppäjärvi, Jouni (May 11, 2008). "A pragmatic, historically oriented survey on the universality
of synchronization primitives" (pdf). University of Oulu, Finland.

View File

@@ -0,0 +1,300 @@
条件变量机制
=========================================
本节导读
-----------------------------------------
到目前为止,我们已经了解了操作系统提供的互斥锁和信号量。但应用程序在使用这两者时需要非常小心,
如果使用不当,就会产生效率低下、竞态条件、死锁或者其他一些不可预测的情况。为了简化编程、避免错误,
计算机科学家针对某些情况设计了一种更高层的同步互斥原语。具体而言,在有些情况下,
线程需要检查某一条件condition满足之后才会继续执行。
我们来看一个例子,有两个线程 first 和 second 在运行,线程 first 会把全局变量 A 设置为
1而线程 second 在 ``A != 0`` 的条件满足后,才能继续执行,如下面的伪代码所示:
.. code-block:: rust
:linenos:
static mut A: usize = 0;
unsafe fn first() -> ! {
A=1;
...
}
unsafe fn second() -> ! {
while A==0 {
// 忙等或睡眠等待 A==1
};
//继续执行相关事务
}
在上面的例子中,如果线程 second 先执行,会忙等在 while 循环中,在操作系统的调度下,线程
first 会执行并把 A 赋值为 1 后,然后线程 second 再次执行时,就会跳出 while 循环,进行接下来的工作。
配合互斥锁,可以正确完成上述带条件的同步流程,如下面的伪代码所示:
.. code-block:: rust
:linenos:
static mut A: usize = 0;
unsafe fn first() -> ! {
mutex.lock();
A=1;
mutex.unlock();
...
}
unsafe fn second() -> ! {
mutex.lock();
while A==0 {
mutex.unlock();
// give other thread a chance to lock
mutex.lock();
};
mutex.unlock();
//继续执行相关事务
}
这种实现能执行,但效率低下,因为线程 second 会忙等检查,浪费处理器时间。我们希望有某种方式让线程
second 休眠,直到等待的条件满足,再继续执行。于是,我们可以写出如下的代码:
.. code-block:: rust
:linenos:
static mut A: usize = 0;
unsafe fn first() -> ! {
mutex.lock();
A=1;
wakup(second);
mutex.unlock();
...
}
unsafe fn second() -> ! {
mutex.lock();
while A==0 {
wait();
};
mutex.unlock();
//继续执行相关事务
}
粗略地看,这样就可以实现睡眠等待了。但请同学仔细想想,当线程 second 在睡眠的时候, ``mutex``
是否已经上锁了? 确实,线程 second 是带着上锁的 ``mutex`` 进入等待睡眠状态的。
如果这两个线程的调度顺序是先执行线程 second再执行线程first那么线程 second 会先睡眠且拥有
``mutex`` 的锁;当线程 first 执行时,会由于没有 ``mutex`` 的锁而进入等待锁的睡眠状态。
结果就是两个线程都睡了,都执行不下去,这就出现了 **死锁**
这里需要解决的两个关键问题: **如何等待一个条件?****在条件为真时如何向等待线程发出信号**
我们的计算机科学家给出了 **管程Monitor****条件变量Condition Variables**
这种巧妙的方法。接下来,我们就会深入讲解条件变量的设计与实现。
条件变量的基本思路
-------------------------------------------
管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中的过程,
这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。
管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用.
因为是由编译器而非程序员来生成互斥相关的代码,所以出错的可能性要小。
管程虽然借助编译器提供了一种实现互斥的简便途径,但这还不够,还需要一种线程间的沟通机制。
首先是等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。
其次是唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制,
及时唤醒等待条件为真的阻塞线程。为了避免管程中同时有两个活跃线程,
我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案:
- Hoare 语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行。注:此时唤醒线程的执行位置还在管程中。
- Hansen 语义:是执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句。
注:此时唤醒线程的执行位置离开了管程。
- Mesa 语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行。
注:此时唤醒线程的执行位置还在管程中。
一般开发者会采纳 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。这种沟通机制的具体实现就是
**条件变量** 和对应的操作wait 和 signal。线程使用条件变量来等待一个条件变成真。
条件变量其实是一个线程等待队列,当条件不满足时,线程通过执行条件变量的 wait
操作就可以把自己加入到等待队列中睡眠等待waiting该条件。另外某个线程当它改变条件为真后
就可以通过条件变量的 signal 操作来唤醒一个或者多个等待的线程(通过在该条件上发信号),让它们继续执行。
早期提出的管程是基于 Concurrent Pascal 来设计的,其他语言如 C 和 Rust 等,并没有在语言上支持这种机制。
我们还是可以用手动加入互斥锁的方式来代替编译器,就可以在 C 和 Rust 的基础上实现原始的管程机制了。
在目前的 C 语言应用开发中,实际上也是这么做的。这样,我们就可以用互斥锁和条件变量,
来重现上述的同步互斥例子:
.. code-block:: rust
:linenos:
static mut A: usize = 0;
unsafe fn first() -> ! {
mutex.lock();
A=1;
condvar.wakup();
mutex.unlock();
...
}
unsafe fn second() -> ! {
mutex.lock();
while A==0 {
condvar.wait(mutex); //在睡眠等待之前需要释放mutex
};
mutex.unlock();
//继续执行相关事务
}
有了上面的介绍,我们就可以实现条件变量的基本逻辑了。下面是条件变量的 wait 和 signal 操作的伪代码:
.. code-block:: rust
:linenos:
fn wait(mutex) {
mutex.unlock();
<block and enqueue the thread>;
mutex.lock();
}
fn signal() {
<unblock a thread>;
}
条件变量的wait操作包含三步1. 释放锁2. 把自己挂起3. 被唤醒后,再获取锁。条件变量的 signal
操作只包含一步:找到挂在条件变量上睡眠的线程,把它唤醒。
注意,条件变量不像信号量那样有一个整型计数值的成员变量,所以条件变量也不能像信号量那样有读写计数值的能力。
如果一个线程向一个条件变量发送唤醒操作,但是在该条件变量上并没有等待的线程,则唤醒操作实际上什么也没做。
实现条件变量
-------------------------------------------
使用 condvar 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
我们通过例子来看看如何实际使用条件变量。下面是面向应用程序对条件变量系统调用的简单使用,
可以看到对它的使用与上一节介绍的信号量系统调用类似。 在这个例子中,主线程先创建了初值为 1
的互斥锁和一个条件变量,然后再创建两个线程 First 和 Second。线程 First 会先睡眠 10ms而当线程
Second 执行时,会由于条件不满足执行条件变量的 wait 操作而等待睡眠;当线程 First 醒来后,通过设置
A 为 1让线程 second 等待的条件满足,然后会执行条件变量的 signal 操作,从而能够唤醒线程 Second。
这样线程 First 和线程 Second 就形成了一种稳定的同步与互斥关系。
.. code-block:: rust
:linenos:
:emphasize-lines: 11,19,26,33,36,39
static mut A: usize = 0; //全局变量
const CONDVAR_ID: usize = 0;
const MUTEX_ID: usize = 0;
unsafe fn first() -> ! {
sleep(10);
println!("First work, Change A --> 1 and wakeup Second");
mutex_lock(MUTEX_ID);
A=1;
condvar_signal(CONDVAR_ID);
mutex_unlock(MUTEX_ID);
...
}
unsafe fn second() -> ! {
println!("Second want to continue,but need to wait A=1");
mutex_lock(MUTEX_ID);
while A==0 {
condvar_wait(CONDVAR_ID, MUTEX_ID);
}
mutex_unlock(MUTEX_ID);
...
}
pub fn main() -> i32 {
// create condvar & mutex
assert_eq!(condvar_create() as usize, CONDVAR_ID);
assert_eq!(mutex_blocking_create() as usize, MUTEX_ID);
// create first, second threads
...
}
pub fn condvar_create() -> isize {
sys_condvar_create(0)
}
pub fn condvar_signal(condvar_id: usize) {
sys_condvar_signal(condvar_id);
}
pub fn condvar_wait(condvar_id: usize, mutex_id: usize) {
sys_condvar_wait(condvar_id, mutex_id);
}
- 第 26 行,创建了一个 ID 为 ``CONDVAR_ID`` 的条件量,对应第 33 行 ``SYSCALL_CONDVAR_CREATE`` 系统调用;
- 第 19 行,线程 Second 执行条件变量 ``wait`` 操作(对应第 39 行 ``SYSCALL_CONDVAR_WAIT`` 系统调用),
该线程将释放 ``mutex`` 锁并阻塞;
- 第 5 行,线程 First 执行条件变量 ``signal`` 操作(对应第 36 行 ``SYSCALL_CONDVAR_SIGNAL`` 系统调用),
会唤醒等待该条件变量的线程 Second。
实现 condvar 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
操作系统如何实现条件变量系统调用呢?在线程的眼里,条件变量是一种每个线程能看到的共享资源,
且在一个进程中,可以存在多个不同条件变量资源,所以我们可以把所有的条件变量资源放在一起让进程来管理,
如下面代码第9行所示。这里需要注意的是 ``condvar_list: Vec<Option<Arc<Condvar>>>``
表示的是条件变量资源的列表。而 ``Condvar`` 是条件变量的内核数据结构,由等待队列组成。
操作系统需要显式地施加某种控制,来确定当一个线程执行 ``wait`` 操作和 ``signal`` 操作时,
如何让线程睡眠或唤醒线程。在这里, ``wait`` 操作是由 ``Condvar````wait`` 方法实现,而 ``signal``
操作是由 ``Condvar````signal`` 方法实现。
.. code-block:: rust
:linenos:
:emphasize-lines: 9,15,18,27,33
pub struct ProcessControlBlock {
// immutable
pub pid: PidHandle,
// mutable
inner: UPSafeCell<ProcessControlBlockInner>,
}
pub struct ProcessControlBlockInner {
...
pub condvar_list: Vec<Option<Arc<Condvar>>>,
}
pub struct Condvar {
pub inner: UPSafeCell<CondvarInner>,
}
pub struct CondvarInner {
pub wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
impl Condvar {
pub fn new() -> Self {
Self {
inner: unsafe { UPSafeCell::new(
CondvarInner {
wait_queue: VecDeque::new(),
}
)},
}
}
pub fn signal(&self) {
let mut inner = self.inner.exclusive_access();
if let Some(task) = inner.wait_queue.pop_front() {
add_task(task);
}
}
pub fn wait(&self, mutex:Arc<dyn Mutex>) {
mutex.unlock();
let mut inner = self.inner.exclusive_access();
inner.wait_queue.push_back(current_task().unwrap());
drop(inner);
block_current_and_run_next();
mutex.lock();
}
}
首先是核心数据结构:
- 第 9 行,进程控制块中管理的条件变量列表。
- 第 15 行,条件变量的核心数据成员:等待队列。
然后是重要的三个成员函数:
- 第 18 行,创建条件变量,即创建了一个空的等待队列。
- 第 27 行,实现 ``signal`` 操作,将从条件变量的等待队列中弹出一个线程放入线程就绪队列。
- 第 33 行,实现 ``wait`` 操作,释放 ``mutex`` 互斥锁,将把当前线程放入条件变量的等待队列,
设置当前线程为挂起状态并选择新线程执行。在恢复执行后,再加上 ``mutex`` 互斥锁。
Hansen, Per Brinch (1993). "Monitors and concurrent Pascal: a personal history". HOPL-II:
The second ACM SIGPLAN conference on History of programming languages. History of Programming
Languages. New York, NY, USA: ACM. pp. 135. doi:10.1145/155360.155361. ISBN 0-89791-570-4.

View File

@@ -0,0 +1,132 @@
chapter8 练习
=======================================
编程作业
--------------------------------------
.. warning::
本次实验框架变动较大,且改动较为复杂,为降低同学们的工作量,本次实验不要求合并之前的实验内容,
只需通过 ch8 的全部测例和其他章节的基础测例即可。你可以直接在实验框架的 ch8 分支上完成以下作业。
.. note::
本次实验的工作量约为 100 行代码。
死锁检测
+++++++++++++++++++++++++++++++
目前的 mutex 和 semaphore 相关的系统调用不会分析资源的依赖情况,用户程序可能出现死锁。
我们希望在系统中加入死锁检测机制,当发现可能发生死锁时拒绝对应的资源获取请求。
一种检测死锁的算法如下:
定义如下三个数据结构:
- 可利用资源向量 Available :含有 m 个元素的一维数组,每个元素代表可利用的某一类资源的数目,
其初值是该类资源的全部可用数目,其值随该类资源的分配和回收而动态地改变。
Available[j] = k表示第 j 类资源的可用数量为 k。
- 分配矩阵 Allocationn * m 矩阵,表示每类资源已分配给每个线程的资源数。
Allocation[i,j] = g则表示线程 i 当前己分得第 j 类资源的数量为 g。
- 需求矩阵 Needn * m 的矩阵,表示每个线程还需要的各类资源数量。
Need[i,j] = d则表示线程 i 还需要第 j 类资源的数量为 d 。
算法运行过程如下:
1. 设置两个向量: 工作向量 Work表示操作系统可提供给线程继续运行所需的各类资源数目它含有
m 个元素。初始时Work = Available ;结束向量 Finish表示系统是否有足够的资源分配给线程
使之运行完成。初始时 Finish[0..n-1] = false表示所有线程都没结束当有足够资源分配给线程时
设置 Finish[i] = true。
2. 从线程集合中找到一个能满足下述条件的线程
.. code-block:: Rust
:linenos:
Finish[i] == false;
Need[i,j] ≤ Work[j];
若找到,执行步骤 3否则执行步骤 4。
3. 当线程 thr[i] 获得资源后,可顺利执行,直至完成,并释放出分配给它的资源,故应执行:
.. code-block:: Rust
:linenos:
Work[j] = Work[j] + Allocation[i, j];
Finish[i] = true;
跳转回步骤2
4. 如果 Finish[0..n-1] 都为 true则表示系统处于安全状态否则表示系统处于不安全状态即出现死锁。
出于兼容性和灵活性考虑,我们允许进程按需开启或关闭死锁检测功能。为此我们将实现一个新的系统调用:
``sys_enable_deadlock_detect``
**enable_deadlock_detect**
* syscall ID: 469
* 功能:为当前进程启用或禁用死锁检测功能。
* C 接口: ``int enable_deadlock_detect(int is_enable)``
* Rust 接口: ``fn enable_deadlock_detect(is_enable: i32) -> i32``
* 参数:
* is_enable: 为 1 表示启用死锁检测, 0 表示禁用死锁检测。
* 说明:
* 开启死锁检测功能后, ``mutex_lock````semaphore_down`` 如果检测到死锁,
应拒绝相应操作并返回 -0xDEAD (十六进制值)。
* 简便起见可对 mutex 和 semaphore 分别进行检测,无需考虑二者 (以及 ``waittid`` 等)
混合使用导致的死锁。
* 返回值:如果出现了错误则返回 -1否则返回 0。
* 可能的错误
* 参数不合法
* 死锁检测开启失败
实验要求
+++++++++++++++++++++++++++++++++++++++++
- 完成分支: ch8。
- 实验目录要求不变。
- 通过所有测例。
问答作业
--------------------------------------------
1. 在我们的多线程实现中,当主线程 (即 0 号线程) 退出时,视为整个进程退出,
此时需要结束该进程管理的所有线程并回收其资源。
- 需要回收的资源有哪些?
- 其他线程的 TaskControlBlock 可能在哪些位置被引用,分别是否需要回收,为什么?
2. 对比以下两种 ``Mutex.unlock`` 的实现,二者有什么区别?这些区别可能会导致什么问题?
.. code-block:: Rust
:linenos:
impl Mutex for Mutex1 {
fn unlock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
assert!(mutex_inner.locked);
mutex_inner.locked = false;
if let Some(waking_task) = mutex_inner.wait_queue.pop_front() {
add_task(waking_task);
}
}
}
impl Mutex for Mutex2 {
fn unlock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
assert!(mutex_inner.locked);
if let Some(waking_task) = mutex_inner.wait_queue.pop_front() {
add_task(waking_task);
} else {
mutex_inner.locked = false;
}
}
}
报告要求
-------------------------------
- 简单总结你实现的功能200字以内不要贴代码及你完成本次实验所用的时间。
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

@@ -0,0 +1,15 @@
第八章:并发
==============================================
.. toctree::
:maxdepth: 4
0intro
1thread-kernel
2lock
3semaphore
4condition-variable
5exercise
.. chyyuu
扩展章节,添加其他类型同步互斥的介绍

120
guide/source/conf.py Normal file
View File

@@ -0,0 +1,120 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'rCore-Tutorial-Guide-2022S'
copyright = 'OS2022Spring'
author = 'Yifan Wu'
language = 'zh_CN'
html_search_language = 'zh'
# The full version, including alpha/beta/rc tags
# release = '0.1'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx_comments",
"sphinx_tabs.tabs"
]
comments_config = {
"utterances": {
"repo": "LearningOS/rCore-Tutorial-Guide-2022S",
"issue-term": "pathname",
"label": "comments",
"theme": "github-light",
"crossorigin": "anonymous",
}
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'furo'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
html_css_files = [
'my_style.css',
#'dracula.css',
]
from pygments.lexer import RegexLexer
from pygments import token
from sphinx.highlighting import lexers
class RVLexer(RegexLexer):
name = 'riscv'
tokens = {
'root': [
# Comment
(r'#.*\n', token.Comment),
# General Registers
(r'\b(?:x[1-2]?[0-9]|x30|x31|zero|ra|sp|gp|tp|fp|t[0-6]|s[0-9]|s1[0-1]|a[0-7]|pc)\b', token.Name.Attribute),
# CSRs
(r'\bs(?:status|tvec|ip|ie|counteren|scratch|epc|cause|tval|atp|)\b', token.Name.Constant),
(r'\bm(?:isa|vendorid|archid|hardid|status|tvec|ideleg|ip|ie|counteren|scratch|epc|cause|tval)\b', token.Name.Constant),
# Instructions
(r'\b(?:(addi?w?)|(slti?u?)|(?:and|or|xor)i?|(?:sll|srl|sra)i?w?|lui|auipc|subw?|jal|jalr|beq|bne|bltu?|bgeu?|s[bhwd]|(l[bhw]u?)|ld)\b', token.Name.Decorator),
(r'\b(?:csrr?[rws]i?)\b', token.Name.Decorator),
(r'\b(?:ecall|ebreak|[msu]ret|wfi|sfence.vma)\b', token.Name.Decorator),
(r'\b(?:nop|li|la|mv|not|neg|negw|sext.w|seqz|snez|sltz|sgtz|f(?:mv|abs|neg).(?:s|d)|b(?:eq|ne|le|ge|lt)z|bgt|ble|bgtu|bleu|j|jr|ret|call)\b', token.Name.Decorator),
(r'(?:%hi|%lo|%pcrel_hi|%pcrel_lo|%tprel_(?:hi|lo|add))', token.Name.Decorator),
# Directives
(r'(?:.2byte|.4byte|.8byte|.quad|.half|.word|.dword|.byte|.dtpreldword|.dtprelword|.sleb128|.uleb128|.asciz|.string|.incbin|.zero)', token.Name.Function),
(r'(?:.align|.balign|.p2align)', token.Name.Function),
(r'(?:.globl|.local|.equ)', token.Name.Function),
(r'(?:.text|.data|.rodata|.bss|.comm|.common|.section)', token.Name.Function),
(r'(?:.option|.macro|.endm|.file|.ident|.size|.type)', token.Name.Function),
(r'(?:.set|.rept|.endr|.macro|.endm|.altmacro)', token.Name.Function),
# Number
(r'\b(?:(?:0x|)[\da-f]+|(?:0o|)[0-7]+|\d+)\b', token.Number),
# Labels
(r'\S+:', token.Name.Builtin),
# Whitespace
(r'\s', token.Whitespace),
# Other operators
(r'[,\+\*\-\(\)\\%]', token.Text),
# Hacks
(r'(?:SAVE_GP|trap_handler|__switch|LOAD_GP|SAVE_SN|LOAD_SN|__alltraps|__restore)', token.Name.Builtin),
(r'(?:.trampoline)', token.Name.Function),
(r'(?:n)', token.Name.Entity),
(r'(?:x)', token.Text),
],
}
lexers['riscv'] = RVLexer()

85
guide/source/index.rst Normal file
View File

@@ -0,0 +1,85 @@
.. rCore-Tutorial-Guide-2022S documentation master file, created by
sphinx-quickstart on Thu Oct 29 22:25:54 2020.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
rCore-Tutorial-Guide 2022 春季学期
==================================================
.. toctree::
:maxdepth: 2
:caption: 正文
:hidden:
0setup-devel-env
chapter1/index
chapter2/index
chapter3/index
chapter4/index
chapter5/index
chapter6/index
chapter7/index
chapter8/index
.. toctree::
:maxdepth: 2
:caption: 附录
:hidden:
appendix-a/index
appendix-b/index
appendix-c/index
appendix-d/index
.. toctree::
:maxdepth: 2
:caption: 开发注记
:hidden:
setup-sphinx
rest-example
项目简介
---------------------
本教程展示了如何 **从零开始****Rust** 语言写一个基于 **RISC-V** 架构的 **类 Unix 内核**
用于 2022 年春季学期操作系统课堂教学。
导读
---------------------
请先阅读 :doc:`0setup-devel-env` 完成环境配置。
以下是读者为了完成实验需掌握的技术,你可以在实操中熟悉它们。
- 阅读简单的 Makefile 文件;
- 阅读简单的 RISC-V 汇编代码;
- git 的基本功能,解决 git merge 冲突的办法;
- Rust 基本语法和一些进阶语法,包括 **Cargo 项目结构、Trait、函数式编程、Unsafe Rust、错误处理等**
鸣谢
----------------------
本项目基于 `2021 年秋季学期操作系统实验指导书 <https://github.com/LearningOS/rCore-Tutorial-Guide-2021A>`_ ,重构的目标是在保留结构的基础上屏蔽不必要的细节,缩短篇幅,优化语言,降低阅读成本。
如果你觉得本教程某些章节不够细致或不够连贯,可以参考上学期实验指导书的对应章节。
.. note::
这是一个注解,以这种方式出现的卡片提供了非必要的背景知识,你可以选择忽略。
.. attention::
虽然实验本身在总评中占比有限,但根据往届经验,考试中可能大量出现与编程作业、思考题、代码实现思路直接相关的题目。
项目协作
----------------------
- :doc:`/setup-sphinx` 介绍了如何基于 Sphinx 框架配置文档开发环境,之后可以本地构建并渲染 html 或其他格式的文档;
- :doc:`/rest-example` 给出了目前编写文档才用的 ReStructuredText 标记语言的一些基础语法及用例;
- 时间仓促,本项目还有很多不完善之处,欢迎大家积极在每一个章节的评论区留言,或者提交 Issues 或 Pull Requests让我们
一起努力让这本书变得更好!

View File

@@ -0,0 +1,32 @@
Pygments 默认配色:
Keyword.Constant 深绿加粗
Keyword.Declaration 深绿加粗
Keyword.Namespace 深绿加粗
Keyword.Pseudo 浅绿
Keyword.Reserved 深绿加粗
Keyword.Type 樱桃红
Name.Attribute 棕黄
Name.Builtin 浅绿
Name.Builtin.Pseudo 浅绿
Name.Class 深蓝加粗
Name.Constant 棕红
Name.Decorator 浅紫
Name.Entity 灰色
Name.Exception 深红
Name.Function 深蓝
Name.Function.Magic 深蓝
Name.Label 棕黄
Name.Namespace 深蓝加粗
Name.Other 默认黑色
Name.Tag 深绿加粗
Name.Variable 蓝黑
通用寄存器 -> 棕黄 Name.Attribute
CSR -> 棕红 Name.Constant
指令 -> 浅紫 Name.Decorator
伪指令 -> 樱桃红 Keyword.Type
Directives -> 深蓝 Name.Function
标签/剩余字面量 -> 浅绿 Name.Builtin
数字 -> Number

View File

@@ -0,0 +1,76 @@
reStructuredText 基本语法
=====================================================
.. toctree::
:hidden:
:maxdepth: 4
.. note::
下面是一个注记。
`这里 <https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#hyperlinks>`_ 给出了在 Sphinx 中
外部链接的引入方法。注意,链接的名字和用一对尖括号包裹起来的链接地址之间必须有一个空格。链接最后的下划线和片段的后续内容之间也需要
有一个空格。
接下来是一个文档内部引用的例子。比如,戳 :doc:`chapter0/5setup-devel-env` 可以进入快速上手环节。
.. warning::
下面是一个警告。
.. code-block:: rust
:linenos:
:caption: 一段示例 Rust 代码
// 我们甚至可以插入一段 Rust 代码!
fn add(a: i32, b: i32) -> i32 { a + b }
下面继续我们的警告。
.. attention:: Here is an attention.
.. caution:: please be cautious!
.. error::
下面是一个错误。
.. danger:: it is dangerous!
.. tip:: here is a tip
.. important:: this is important!
.. hint:: this is a hint.
这里是一行数学公式 :math:`\sin(\alpha+\beta)=\sin\alpha\cos\beta+\cos\alpha\sin\beta`
基本的文本样式:这是 *斜体* ,这是 **加粗** ,接下来的则是行间公式 ``a0`` 。它们的前后都需要有一个空格隔开其他内容,这个让人挺不爽的...
`这是 <https://docs.readthedocs.io/en/stable/guides/cross-referencing-with-sphinx.html#the-doc-role>`_ 一个全面展示
章节分布的例子,来自于 ReadTheDocs 的官方文档。事实上,现在我们也采用 ReadTheDocs 主题了,它非常美观大方。
下面是一个测试 gif。
.. image:: resources/test.gif
接下来是一个表格的例子。
.. 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}`

View File

@@ -0,0 +1,16 @@
修改和构建本项目
====================================
.. toctree::
:hidden:
:maxdepth: 4
TL;DR: ``python -m venv .venv`` 创建一个虚拟环境(你也可以使用 conda 等工具activate 后 ``pip install -r requirements.txt``
1. 参考 `这里 <https://www.sphinx-doc.org/en/master/usage/installation.html>`_ 安装 Sphinx。
2. ``pip install sphinx_rtd_theme`` 安装 Read The Docs 主题。
3. ``pip install jieba`` 安装中文分词。
4. ``pip install sphinx-comments`` 安装 Sphinx 讨论区插件。
5. :doc:`/rest-example` 是 ReST 的一些基本语法,也可以参考已完成的文档。
6. 修改之后,在项目根目录下 ``make clean && make html`` 即可在 ``build/html/index.html`` 查看本地构建的主页。请注意在修改章节目录结构之后需要 ``make clean`` 一下,不然可能无法正常更新。
7. 确认修改无误之后,将更改提交到自己的仓库,然后向项目仓库提交 Pull Request。如有问题可直接提交 Issue 或课程微信群内联系助教。

7
os1-ref/.cargo/config Normal file
View File

@@ -0,0 +1,7 @@
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]

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